commit 8ab0857ee0bb7766bebd0f7736431233b4bea9fa Author: MeatSlicer Developer Date: Wed Jul 29 18:30:21 2026 +1000 Add adaptive progression and item expansion diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f7b71b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +tools/.env +tools/.venv/ +assets/raw/ +.playwright-mcp/ +.serena/ +served-state.txt +__pycache__/ +*.pyc diff --git a/assets/ammo.png b/assets/ammo.png new file mode 100644 index 0000000..c7f1835 Binary files /dev/null and b/assets/ammo.png differ diff --git a/assets/boss_augerprime.png b/assets/boss_augerprime.png new file mode 100644 index 0000000..3560fe0 Binary files /dev/null and b/assets/boss_augerprime.png differ diff --git a/assets/boss_augerprime_sheet.png b/assets/boss_augerprime_sheet.png new file mode 100644 index 0000000..51389a4 Binary files /dev/null and b/assets/boss_augerprime_sheet.png differ diff --git a/assets/boss_bonesaw.png b/assets/boss_bonesaw.png new file mode 100644 index 0000000..f76a354 Binary files /dev/null and b/assets/boss_bonesaw.png differ diff --git a/assets/boss_bonesaw_sheet.png b/assets/boss_bonesaw_sheet.png new file mode 100644 index 0000000..358a4a8 Binary files /dev/null and b/assets/boss_bonesaw_sheet.png differ diff --git a/assets/boss_flenser.png b/assets/boss_flenser.png new file mode 100644 index 0000000..c2581b0 Binary files /dev/null and b/assets/boss_flenser.png differ diff --git a/assets/boss_flenser_sheet.png b/assets/boss_flenser_sheet.png new file mode 100644 index 0000000..c4c1777 Binary files /dev/null and b/assets/boss_flenser_sheet.png differ diff --git a/assets/boss_gorecrown.png b/assets/boss_gorecrown.png new file mode 100644 index 0000000..0ced077 Binary files /dev/null and b/assets/boss_gorecrown.png differ diff --git a/assets/boss_gorecrown_sheet.png b/assets/boss_gorecrown_sheet.png new file mode 100644 index 0000000..a2e1277 Binary files /dev/null and b/assets/boss_gorecrown_sheet.png differ diff --git a/assets/boss_hookchoir.png b/assets/boss_hookchoir.png new file mode 100644 index 0000000..880ed77 Binary files /dev/null and b/assets/boss_hookchoir.png differ diff --git a/assets/boss_hookchoir_sheet.png b/assets/boss_hookchoir_sheet.png new file mode 100644 index 0000000..e385e3c Binary files /dev/null and b/assets/boss_hookchoir_sheet.png differ diff --git a/assets/boss_knifecrawl.png b/assets/boss_knifecrawl.png new file mode 100644 index 0000000..98e2de8 Binary files /dev/null and b/assets/boss_knifecrawl.png differ diff --git a/assets/boss_knifecrawl_sheet.png b/assets/boss_knifecrawl_sheet.png new file mode 100644 index 0000000..ef54973 Binary files /dev/null and b/assets/boss_knifecrawl_sheet.png differ diff --git a/assets/boss_platefather.png b/assets/boss_platefather.png new file mode 100644 index 0000000..7568348 Binary files /dev/null and b/assets/boss_platefather.png differ diff --git a/assets/boss_platefather_sheet.png b/assets/boss_platefather_sheet.png new file mode 100644 index 0000000..6f9072b Binary files /dev/null and b/assets/boss_platefather_sheet.png differ diff --git a/assets/boss_scald.png b/assets/boss_scald.png new file mode 100644 index 0000000..2a6dacb Binary files /dev/null and b/assets/boss_scald.png differ diff --git a/assets/boss_scald_sheet.png b/assets/boss_scald_sheet.png new file mode 100644 index 0000000..db04837 Binary files /dev/null and b/assets/boss_scald_sheet.png differ diff --git a/assets/boss_vealmother.png b/assets/boss_vealmother.png new file mode 100644 index 0000000..b8af4b8 Binary files /dev/null and b/assets/boss_vealmother.png differ diff --git a/assets/boss_vealmother_sheet.png b/assets/boss_vealmother_sheet.png new file mode 100644 index 0000000..fb4d644 Binary files /dev/null and b/assets/boss_vealmother_sheet.png differ diff --git a/assets/bullet_bone.png b/assets/bullet_bone.png new file mode 100644 index 0000000..48d357e Binary files /dev/null and b/assets/bullet_bone.png differ diff --git a/assets/bullet_cleaver.png b/assets/bullet_cleaver.png new file mode 100644 index 0000000..f2bb974 Binary files /dev/null and b/assets/bullet_cleaver.png differ diff --git a/assets/bullet_eye.png b/assets/bullet_eye.png new file mode 100644 index 0000000..2938162 Binary files /dev/null and b/assets/bullet_eye.png differ diff --git a/assets/bullet_gore.png b/assets/bullet_gore.png new file mode 100644 index 0000000..5f1f0e5 Binary files /dev/null and b/assets/bullet_gore.png differ diff --git a/assets/bullet_harpoon.png b/assets/bullet_harpoon.png new file mode 100644 index 0000000..3a1ab23 Binary files /dev/null and b/assets/bullet_harpoon.png differ diff --git a/assets/bullet_saw.png b/assets/bullet_saw.png new file mode 100644 index 0000000..745585d Binary files /dev/null and b/assets/bullet_saw.png differ diff --git a/assets/bullet_steam.png b/assets/bullet_steam.png new file mode 100644 index 0000000..7e01fc3 Binary files /dev/null and b/assets/bullet_steam.png differ diff --git a/assets/bullet_syringe.png b/assets/bullet_syringe.png new file mode 100644 index 0000000..4f04998 Binary files /dev/null and b/assets/bullet_syringe.png differ diff --git a/assets/decal_blood1.png b/assets/decal_blood1.png new file mode 100644 index 0000000..17d0053 Binary files /dev/null and b/assets/decal_blood1.png differ diff --git a/assets/decal_blood2.png b/assets/decal_blood2.png new file mode 100644 index 0000000..4c9afdd Binary files /dev/null and b/assets/decal_blood2.png differ diff --git a/assets/decal_blood3.png b/assets/decal_blood3.png new file mode 100644 index 0000000..c5a5419 Binary files /dev/null and b/assets/decal_blood3.png differ diff --git a/assets/decal_blood4.png b/assets/decal_blood4.png new file mode 100644 index 0000000..3f46bb4 Binary files /dev/null and b/assets/decal_blood4.png differ diff --git a/assets/door_locked.png b/assets/door_locked.png new file mode 100644 index 0000000..7258aca Binary files /dev/null and b/assets/door_locked.png differ diff --git a/assets/door_open.png b/assets/door_open.png new file mode 100644 index 0000000..afb615d Binary files /dev/null and b/assets/door_open.png differ diff --git a/assets/enemy_exploder.png b/assets/enemy_exploder.png new file mode 100644 index 0000000..b89c0b5 Binary files /dev/null and b/assets/enemy_exploder.png differ diff --git a/assets/enemy_exploder_sheet.png b/assets/enemy_exploder_sheet.png new file mode 100644 index 0000000..e49179d Binary files /dev/null and b/assets/enemy_exploder_sheet.png differ diff --git a/assets/enemy_mini.png b/assets/enemy_mini.png new file mode 100644 index 0000000..a8b9cbc Binary files /dev/null and b/assets/enemy_mini.png differ diff --git a/assets/enemy_mini_sheet.png b/assets/enemy_mini_sheet.png new file mode 100644 index 0000000..e8eab28 Binary files /dev/null and b/assets/enemy_mini_sheet.png differ diff --git a/assets/enemy_runner.png b/assets/enemy_runner.png new file mode 100644 index 0000000..1a84bb9 Binary files /dev/null and b/assets/enemy_runner.png differ diff --git a/assets/enemy_runner_sheet.png b/assets/enemy_runner_sheet.png new file mode 100644 index 0000000..7019fb1 Binary files /dev/null and b/assets/enemy_runner_sheet.png differ diff --git a/assets/enemy_shambler.png b/assets/enemy_shambler.png new file mode 100644 index 0000000..7e5298f Binary files /dev/null and b/assets/enemy_shambler.png differ diff --git a/assets/enemy_shambler_sheet.png b/assets/enemy_shambler_sheet.png new file mode 100644 index 0000000..9261637 Binary files /dev/null and b/assets/enemy_shambler_sheet.png differ diff --git a/assets/enemy_spitter.png b/assets/enemy_spitter.png new file mode 100644 index 0000000..1258df8 Binary files /dev/null and b/assets/enemy_spitter.png differ diff --git a/assets/enemy_spitter_sheet.png b/assets/enemy_spitter_sheet.png new file mode 100644 index 0000000..2c3fee3 Binary files /dev/null and b/assets/enemy_spitter_sheet.png differ diff --git a/assets/enemy_splitter.png b/assets/enemy_splitter.png new file mode 100644 index 0000000..99fa01d Binary files /dev/null and b/assets/enemy_splitter.png differ diff --git a/assets/enemy_splitter_sheet.png b/assets/enemy_splitter_sheet.png new file mode 100644 index 0000000..5e59a39 Binary files /dev/null and b/assets/enemy_splitter_sheet.png differ diff --git a/assets/gem_big.png b/assets/gem_big.png new file mode 100644 index 0000000..40a36df Binary files /dev/null and b/assets/gem_big.png differ diff --git a/assets/gem_small.png b/assets/gem_small.png new file mode 100644 index 0000000..8bf70e9 Binary files /dev/null and b/assets/gem_small.png differ diff --git a/assets/heart.png b/assets/heart.png new file mode 100644 index 0000000..be3f9a8 Binary files /dev/null and b/assets/heart.png differ diff --git a/assets/i_backstabber.png b/assets/i_backstabber.png new file mode 100644 index 0000000..1600432 Binary files /dev/null and b/assets/i_backstabber.png differ diff --git a/assets/i_bloodlust.png b/assets/i_bloodlust.png new file mode 100644 index 0000000..10a43fd Binary files /dev/null and b/assets/i_bloodlust.png differ diff --git a/assets/i_dentures.png b/assets/i_dentures.png new file mode 100644 index 0000000..6c9f5c3 Binary files /dev/null and b/assets/i_dentures.png differ diff --git a/assets/i_ghoulheart.png b/assets/i_ghoulheart.png new file mode 100644 index 0000000..df55d5d Binary files /dev/null and b/assets/i_ghoulheart.png differ diff --git a/assets/i_hollowpoints.png b/assets/i_hollowpoints.png new file mode 100644 index 0000000..36e8be0 Binary files /dev/null and b/assets/i_hollowpoints.png differ diff --git a/assets/i_homingtumor.png b/assets/i_homingtumor.png new file mode 100644 index 0000000..c232f0d Binary files /dev/null and b/assets/i_homingtumor.png differ diff --git a/assets/i_hydramaw.png b/assets/i_hydramaw.png new file mode 100644 index 0000000..4345c5c Binary files /dev/null and b/assets/i_hydramaw.png differ diff --git a/assets/i_ironstomach.png b/assets/i_ironstomach.png new file mode 100644 index 0000000..e919fd0 Binary files /dev/null and b/assets/i_ironstomach.png differ diff --git a/assets/i_leadmarrow.png b/assets/i_leadmarrow.png new file mode 100644 index 0000000..1091b9d Binary files /dev/null and b/assets/i_leadmarrow.png differ diff --git a/assets/i_luckycoin.png b/assets/i_luckycoin.png new file mode 100644 index 0000000..26a5b36 Binary files /dev/null and b/assets/i_luckycoin.png differ diff --git a/assets/i_magnetmaw.png b/assets/i_magnetmaw.png new file mode 100644 index 0000000..49a567a Binary files /dev/null and b/assets/i_magnetmaw.png differ diff --git a/assets/i_orbitalknives.png b/assets/i_orbitalknives.png new file mode 100644 index 0000000..89ebf63 Binary files /dev/null and b/assets/i_orbitalknives.png differ diff --git a/assets/i_piercegaze.png b/assets/i_piercegaze.png new file mode 100644 index 0000000..8c372a4 Binary files /dev/null and b/assets/i_piercegaze.png differ diff --git a/assets/i_ricochet.png b/assets/i_ricochet.png new file mode 100644 index 0000000..9152052 Binary files /dev/null and b/assets/i_ricochet.png differ diff --git a/assets/i_scalpel.png b/assets/i_scalpel.png new file mode 100644 index 0000000..a1c6f2e Binary files /dev/null and b/assets/i_scalpel.png differ diff --git a/assets/i_splinterbone.png b/assets/i_splinterbone.png new file mode 100644 index 0000000..08d6ba3 Binary files /dev/null and b/assets/i_splinterbone.png differ diff --git a/assets/i_splittongue.png b/assets/i_splittongue.png new file mode 100644 index 0000000..13184e8 Binary files /dev/null and b/assets/i_splittongue.png differ diff --git a/assets/i_twitch.png b/assets/i_twitch.png new file mode 100644 index 0000000..352653b Binary files /dev/null and b/assets/i_twitch.png differ diff --git a/assets/i_volatilebile.png b/assets/i_volatilebile.png new file mode 100644 index 0000000..643e8a0 Binary files /dev/null and b/assets/i_volatilebile.png differ diff --git a/assets/pedestal.png b/assets/pedestal.png new file mode 100644 index 0000000..ec5c00f Binary files /dev/null and b/assets/pedestal.png differ diff --git a/assets/perk_adrenal.png b/assets/perk_adrenal.png new file mode 100644 index 0000000..8ab89f9 Binary files /dev/null and b/assets/perk_adrenal.png differ diff --git a/assets/perk_bigheart.png b/assets/perk_bigheart.png new file mode 100644 index 0000000..b8eda3a Binary files /dev/null and b/assets/perk_bigheart.png differ diff --git a/assets/perk_bloodrush.png b/assets/perk_bloodrush.png new file mode 100644 index 0000000..021b1e5 Binary files /dev/null and b/assets/perk_bloodrush.png differ diff --git a/assets/perk_deadeye.png b/assets/perk_deadeye.png new file mode 100644 index 0000000..7fe38e7 Binary files /dev/null and b/assets/perk_deadeye.png differ diff --git a/assets/perk_longbone.png b/assets/perk_longbone.png new file mode 100644 index 0000000..19fc6b6 Binary files /dev/null and b/assets/perk_longbone.png differ diff --git a/assets/perk_magnetb.png b/assets/perk_magnetb.png new file mode 100644 index 0000000..2060b88 Binary files /dev/null and b/assets/perk_magnetb.png differ diff --git a/assets/perk_quick.png b/assets/perk_quick.png new file mode 100644 index 0000000..3f22635 Binary files /dev/null and b/assets/perk_quick.png differ diff --git a/assets/perk_scavenge.png b/assets/perk_scavenge.png new file mode 100644 index 0000000..1922a30 Binary files /dev/null and b/assets/perk_scavenge.png differ diff --git a/assets/perk_sharpen.png b/assets/perk_sharpen.png new file mode 100644 index 0000000..c9c380a Binary files /dev/null and b/assets/perk_sharpen.png differ diff --git a/assets/perk_shieldheart.png b/assets/perk_shieldheart.png new file mode 100644 index 0000000..6459759 Binary files /dev/null and b/assets/perk_shieldheart.png differ diff --git a/assets/player.png b/assets/player.png new file mode 100644 index 0000000..f1b6199 Binary files /dev/null and b/assets/player.png differ diff --git a/assets/player_leg.png b/assets/player_leg.png new file mode 100644 index 0000000..d263ee3 Binary files /dev/null and b/assets/player_leg.png differ diff --git a/assets/player_legs.png b/assets/player_legs.png new file mode 100644 index 0000000..b7a7b5c Binary files /dev/null and b/assets/player_legs.png differ diff --git a/assets/player_legs_sheet.png b/assets/player_legs_sheet.png new file mode 100644 index 0000000..eb2b992 Binary files /dev/null and b/assets/player_legs_sheet.png differ diff --git a/assets/player_sheet.png b/assets/player_sheet.png new file mode 100644 index 0000000..d5c6eeb Binary files /dev/null and b/assets/player_sheet.png differ diff --git a/assets/player_waist.png b/assets/player_waist.png new file mode 100644 index 0000000..873de6f Binary files /dev/null and b/assets/player_waist.png differ diff --git a/assets/pt_bile.png b/assets/pt_bile.png new file mode 100644 index 0000000..47faf71 Binary files /dev/null and b/assets/pt_bile.png differ diff --git a/assets/pt_bonepopper.png b/assets/pt_bonepopper.png new file mode 100644 index 0000000..aa5f99c Binary files /dev/null and b/assets/pt_bonepopper.png differ diff --git a/assets/pt_cauterizer.png b/assets/pt_cauterizer.png new file mode 100644 index 0000000..753b3f0 Binary files /dev/null and b/assets/pt_cauterizer.png differ diff --git a/assets/pt_cleaver.png b/assets/pt_cleaver.png new file mode 100644 index 0000000..73fd9ad Binary files /dev/null and b/assets/pt_cleaver.png differ diff --git a/assets/pt_eye.png b/assets/pt_eye.png new file mode 100644 index 0000000..55cf615 Binary files /dev/null and b/assets/pt_eye.png differ diff --git a/assets/pt_fleshmasher.png b/assets/pt_fleshmasher.png new file mode 100644 index 0000000..72148b5 Binary files /dev/null and b/assets/pt_fleshmasher.png differ diff --git a/assets/pt_guthook.png b/assets/pt_guthook.png new file mode 100644 index 0000000..4f1aa74 Binary files /dev/null and b/assets/pt_guthook.png differ diff --git a/assets/pt_hemophage.png b/assets/pt_hemophage.png new file mode 100644 index 0000000..c4e9c60 Binary files /dev/null and b/assets/pt_hemophage.png differ diff --git a/assets/pt_marrow.png b/assets/pt_marrow.png new file mode 100644 index 0000000..81ce544 Binary files /dev/null and b/assets/pt_marrow.png differ diff --git a/assets/pt_redhand.png b/assets/pt_redhand.png new file mode 100644 index 0000000..9cc63ea Binary files /dev/null and b/assets/pt_redhand.png differ diff --git a/assets/pt_repeater.png b/assets/pt_repeater.png new file mode 100644 index 0000000..522246b Binary files /dev/null and b/assets/pt_repeater.png differ diff --git a/assets/pt_saw.png b/assets/pt_saw.png new file mode 100644 index 0000000..24ad7c3 Binary files /dev/null and b/assets/pt_saw.png differ diff --git a/assets/pt_spinaltap.png b/assets/pt_spinaltap.png new file mode 100644 index 0000000..d0e3620 Binary files /dev/null and b/assets/pt_spinaltap.png differ diff --git a/assets/pt_swarmjar.png b/assets/pt_swarmjar.png new file mode 100644 index 0000000..52aa1fb Binary files /dev/null and b/assets/pt_swarmjar.png differ diff --git a/assets/pt_tenderizer.png b/assets/pt_tenderizer.png new file mode 100644 index 0000000..29474ed Binary files /dev/null and b/assets/pt_tenderizer.png differ diff --git a/assets/pt_trapqueen.png b/assets/pt_trapqueen.png new file mode 100644 index 0000000..7bc2679 Binary files /dev/null and b/assets/pt_trapqueen.png differ diff --git a/assets/sfx/boss_augerprime_roar.mp3 b/assets/sfx/boss_augerprime_roar.mp3 new file mode 100644 index 0000000..0b67313 Binary files /dev/null and b/assets/sfx/boss_augerprime_roar.mp3 differ diff --git a/assets/sfx/boss_augerprime_spool.mp3 b/assets/sfx/boss_augerprime_spool.mp3 new file mode 100644 index 0000000..09f029d Binary files /dev/null and b/assets/sfx/boss_augerprime_spool.mp3 differ diff --git a/assets/sfx/boss_augerprime_sweep.mp3 b/assets/sfx/boss_augerprime_sweep.mp3 new file mode 100644 index 0000000..8abca98 Binary files /dev/null and b/assets/sfx/boss_augerprime_sweep.mp3 differ diff --git a/assets/sfx/boss_bonesaw_charge.mp3 b/assets/sfx/boss_bonesaw_charge.mp3 new file mode 100644 index 0000000..02564d4 Binary files /dev/null and b/assets/sfx/boss_bonesaw_charge.mp3 differ diff --git a/assets/sfx/boss_bonesaw_roar.mp3 b/assets/sfx/boss_bonesaw_roar.mp3 new file mode 100644 index 0000000..4db9ddc Binary files /dev/null and b/assets/sfx/boss_bonesaw_roar.mp3 differ diff --git a/assets/sfx/boss_bonesaw_saws.mp3 b/assets/sfx/boss_bonesaw_saws.mp3 new file mode 100644 index 0000000..7a1e4d6 Binary files /dev/null and b/assets/sfx/boss_bonesaw_saws.mp3 differ diff --git a/assets/sfx/boss_death.mp3 b/assets/sfx/boss_death.mp3 new file mode 100644 index 0000000..b22b2ad Binary files /dev/null and b/assets/sfx/boss_death.mp3 differ diff --git a/assets/sfx/boss_enrage.mp3 b/assets/sfx/boss_enrage.mp3 new file mode 100644 index 0000000..a811f7d Binary files /dev/null and b/assets/sfx/boss_enrage.mp3 differ diff --git a/assets/sfx/boss_flenser_blink.mp3 b/assets/sfx/boss_flenser_blink.mp3 new file mode 100644 index 0000000..2ec959f Binary files /dev/null and b/assets/sfx/boss_flenser_blink.mp3 differ diff --git a/assets/sfx/boss_flenser_flay.mp3 b/assets/sfx/boss_flenser_flay.mp3 new file mode 100644 index 0000000..96e456c Binary files /dev/null and b/assets/sfx/boss_flenser_flay.mp3 differ diff --git a/assets/sfx/boss_flenser_roar.mp3 b/assets/sfx/boss_flenser_roar.mp3 new file mode 100644 index 0000000..2674046 Binary files /dev/null and b/assets/sfx/boss_flenser_roar.mp3 differ diff --git a/assets/sfx/boss_gorecrown_roar.mp3 b/assets/sfx/boss_gorecrown_roar.mp3 new file mode 100644 index 0000000..6087128 Binary files /dev/null and b/assets/sfx/boss_gorecrown_roar.mp3 differ diff --git a/assets/sfx/boss_gorecrown_summon.mp3 b/assets/sfx/boss_gorecrown_summon.mp3 new file mode 100644 index 0000000..183f5d5 Binary files /dev/null and b/assets/sfx/boss_gorecrown_summon.mp3 differ diff --git a/assets/sfx/boss_gorecrown_volley.mp3 b/assets/sfx/boss_gorecrown_volley.mp3 new file mode 100644 index 0000000..bbedf71 Binary files /dev/null and b/assets/sfx/boss_gorecrown_volley.mp3 differ diff --git a/assets/sfx/boss_hookchoir_chains.mp3 b/assets/sfx/boss_hookchoir_chains.mp3 new file mode 100644 index 0000000..3e5c94e Binary files /dev/null and b/assets/sfx/boss_hookchoir_chains.mp3 differ diff --git a/assets/sfx/boss_hookchoir_release.mp3 b/assets/sfx/boss_hookchoir_release.mp3 new file mode 100644 index 0000000..1652318 Binary files /dev/null and b/assets/sfx/boss_hookchoir_release.mp3 differ diff --git a/assets/sfx/boss_hookchoir_roar.mp3 b/assets/sfx/boss_hookchoir_roar.mp3 new file mode 100644 index 0000000..55d656b Binary files /dev/null and b/assets/sfx/boss_hookchoir_roar.mp3 differ diff --git a/assets/sfx/boss_knifecrawl_dash.mp3 b/assets/sfx/boss_knifecrawl_dash.mp3 new file mode 100644 index 0000000..ddd0162 Binary files /dev/null and b/assets/sfx/boss_knifecrawl_dash.mp3 differ diff --git a/assets/sfx/boss_knifecrawl_knives.mp3 b/assets/sfx/boss_knifecrawl_knives.mp3 new file mode 100644 index 0000000..6373093 Binary files /dev/null and b/assets/sfx/boss_knifecrawl_knives.mp3 differ diff --git a/assets/sfx/boss_knifecrawl_roar.mp3 b/assets/sfx/boss_knifecrawl_roar.mp3 new file mode 100644 index 0000000..bbb81ad Binary files /dev/null and b/assets/sfx/boss_knifecrawl_roar.mp3 differ diff --git a/assets/sfx/boss_platefather_plate.mp3 b/assets/sfx/boss_platefather_plate.mp3 new file mode 100644 index 0000000..6230d10 Binary files /dev/null and b/assets/sfx/boss_platefather_plate.mp3 differ diff --git a/assets/sfx/boss_platefather_roar.mp3 b/assets/sfx/boss_platefather_roar.mp3 new file mode 100644 index 0000000..c38ed30 Binary files /dev/null and b/assets/sfx/boss_platefather_roar.mp3 differ diff --git a/assets/sfx/boss_platefather_stomp.mp3 b/assets/sfx/boss_platefather_stomp.mp3 new file mode 100644 index 0000000..55dfeed Binary files /dev/null and b/assets/sfx/boss_platefather_stomp.mp3 differ diff --git a/assets/sfx/boss_platefather_wall.mp3 b/assets/sfx/boss_platefather_wall.mp3 new file mode 100644 index 0000000..3fbb453 Binary files /dev/null and b/assets/sfx/boss_platefather_wall.mp3 differ diff --git a/assets/sfx/boss_scald_burst.mp3 b/assets/sfx/boss_scald_burst.mp3 new file mode 100644 index 0000000..9fdf742 Binary files /dev/null and b/assets/sfx/boss_scald_burst.mp3 differ diff --git a/assets/sfx/boss_scald_roar.mp3 b/assets/sfx/boss_scald_roar.mp3 new file mode 100644 index 0000000..18141ea Binary files /dev/null and b/assets/sfx/boss_scald_roar.mp3 differ diff --git a/assets/sfx/boss_scald_vent.mp3 b/assets/sfx/boss_scald_vent.mp3 new file mode 100644 index 0000000..63f35b8 Binary files /dev/null and b/assets/sfx/boss_scald_vent.mp3 differ diff --git a/assets/sfx/boss_vealmother_bile.mp3 b/assets/sfx/boss_vealmother_bile.mp3 new file mode 100644 index 0000000..1d57834 Binary files /dev/null and b/assets/sfx/boss_vealmother_bile.mp3 differ diff --git a/assets/sfx/boss_vealmother_birth.mp3 b/assets/sfx/boss_vealmother_birth.mp3 new file mode 100644 index 0000000..8e7bb47 Binary files /dev/null and b/assets/sfx/boss_vealmother_birth.mp3 differ diff --git a/assets/sfx/boss_vealmother_roar.mp3 b/assets/sfx/boss_vealmother_roar.mp3 new file mode 100644 index 0000000..11f9cce Binary files /dev/null and b/assets/sfx/boss_vealmother_roar.mp3 differ diff --git a/assets/sfx/gore_burst1.mp3 b/assets/sfx/gore_burst1.mp3 new file mode 100644 index 0000000..fb40091 Binary files /dev/null and b/assets/sfx/gore_burst1.mp3 differ diff --git a/assets/sfx/gore_burst2.mp3 b/assets/sfx/gore_burst2.mp3 new file mode 100644 index 0000000..3aaa3de Binary files /dev/null and b/assets/sfx/gore_burst2.mp3 differ diff --git a/assets/sfx/gore_burst3.mp3 b/assets/sfx/gore_burst3.mp3 new file mode 100644 index 0000000..943ac89 Binary files /dev/null and b/assets/sfx/gore_burst3.mp3 differ diff --git a/assets/sfx/imp_crit.mp3 b/assets/sfx/imp_crit.mp3 new file mode 100644 index 0000000..d2bed57 Binary files /dev/null and b/assets/sfx/imp_crit.mp3 differ diff --git a/assets/sfx/imp_explosion.mp3 b/assets/sfx/imp_explosion.mp3 new file mode 100644 index 0000000..54692ed Binary files /dev/null and b/assets/sfx/imp_explosion.mp3 differ diff --git a/assets/sfx/imp_flesh1.mp3 b/assets/sfx/imp_flesh1.mp3 new file mode 100644 index 0000000..5c19393 Binary files /dev/null and b/assets/sfx/imp_flesh1.mp3 differ diff --git a/assets/sfx/imp_flesh2.mp3 b/assets/sfx/imp_flesh2.mp3 new file mode 100644 index 0000000..e43c889 Binary files /dev/null and b/assets/sfx/imp_flesh2.mp3 differ diff --git a/assets/sfx/imp_flesh3.mp3 b/assets/sfx/imp_flesh3.mp3 new file mode 100644 index 0000000..3af478b Binary files /dev/null and b/assets/sfx/imp_flesh3.mp3 differ diff --git a/assets/sfx/imp_flesh4.mp3 b/assets/sfx/imp_flesh4.mp3 new file mode 100644 index 0000000..1c6001e Binary files /dev/null and b/assets/sfx/imp_flesh4.mp3 differ diff --git a/assets/sfx/imp_ricochet.mp3 b/assets/sfx/imp_ricochet.mp3 new file mode 100644 index 0000000..6d6a2f9 Binary files /dev/null and b/assets/sfx/imp_ricochet.mp3 differ diff --git a/assets/sfx/imp_trap_snap.mp3 b/assets/sfx/imp_trap_snap.mp3 new file mode 100644 index 0000000..b48ad7a Binary files /dev/null and b/assets/sfx/imp_trap_snap.mp3 differ diff --git a/assets/sfx/imp_wall1.mp3 b/assets/sfx/imp_wall1.mp3 new file mode 100644 index 0000000..e5c3639 Binary files /dev/null and b/assets/sfx/imp_wall1.mp3 differ diff --git a/assets/sfx/imp_wall2.mp3 b/assets/sfx/imp_wall2.mp3 new file mode 100644 index 0000000..2932834 Binary files /dev/null and b/assets/sfx/imp_wall2.mp3 differ diff --git a/assets/sfx/imp_wall3.mp3 b/assets/sfx/imp_wall3.mp3 new file mode 100644 index 0000000..79735fa Binary files /dev/null and b/assets/sfx/imp_wall3.mp3 differ diff --git a/assets/sfx/mon_exploder_die.mp3 b/assets/sfx/mon_exploder_die.mp3 new file mode 100644 index 0000000..523f0d5 Binary files /dev/null and b/assets/sfx/mon_exploder_die.mp3 differ diff --git a/assets/sfx/mon_exploder_fuse.mp3 b/assets/sfx/mon_exploder_fuse.mp3 new file mode 100644 index 0000000..9a01391 Binary files /dev/null and b/assets/sfx/mon_exploder_fuse.mp3 differ diff --git a/assets/sfx/mon_mini_die.mp3 b/assets/sfx/mon_mini_die.mp3 new file mode 100644 index 0000000..d92dc3b Binary files /dev/null and b/assets/sfx/mon_mini_die.mp3 differ diff --git a/assets/sfx/mon_runner_die.mp3 b/assets/sfx/mon_runner_die.mp3 new file mode 100644 index 0000000..4205f89 Binary files /dev/null and b/assets/sfx/mon_runner_die.mp3 differ diff --git a/assets/sfx/mon_shambler_die.mp3 b/assets/sfx/mon_shambler_die.mp3 new file mode 100644 index 0000000..bd5637e Binary files /dev/null and b/assets/sfx/mon_shambler_die.mp3 differ diff --git a/assets/sfx/mon_spawn.mp3 b/assets/sfx/mon_spawn.mp3 new file mode 100644 index 0000000..8dbc5f5 Binary files /dev/null and b/assets/sfx/mon_spawn.mp3 differ diff --git a/assets/sfx/mon_spitter_attack.mp3 b/assets/sfx/mon_spitter_attack.mp3 new file mode 100644 index 0000000..1d86743 Binary files /dev/null and b/assets/sfx/mon_spitter_attack.mp3 differ diff --git a/assets/sfx/mon_spitter_die.mp3 b/assets/sfx/mon_spitter_die.mp3 new file mode 100644 index 0000000..db33e2c Binary files /dev/null and b/assets/sfx/mon_spitter_die.mp3 differ diff --git a/assets/sfx/mon_splitter_die.mp3 b/assets/sfx/mon_splitter_die.mp3 new file mode 100644 index 0000000..061c251 Binary files /dev/null and b/assets/sfx/mon_splitter_die.mp3 differ diff --git a/assets/sfx/mon_splitter_split.mp3 b/assets/sfx/mon_splitter_split.mp3 new file mode 100644 index 0000000..88f96f3 Binary files /dev/null and b/assets/sfx/mon_splitter_split.mp3 differ diff --git a/assets/sfx/plr_death.mp3 b/assets/sfx/plr_death.mp3 new file mode 100644 index 0000000..3302f89 Binary files /dev/null and b/assets/sfx/plr_death.mp3 differ diff --git a/assets/sfx/plr_hurt.mp3 b/assets/sfx/plr_hurt.mp3 new file mode 100644 index 0000000..8cf2f29 Binary files /dev/null and b/assets/sfx/plr_hurt.mp3 differ diff --git a/assets/sfx/plr_lifesteal.mp3 b/assets/sfx/plr_lifesteal.mp3 new file mode 100644 index 0000000..907b696 Binary files /dev/null and b/assets/sfx/plr_lifesteal.mp3 differ diff --git a/assets/sfx/plr_shield_break.mp3 b/assets/sfx/plr_shield_break.mp3 new file mode 100644 index 0000000..0c16a80 Binary files /dev/null and b/assets/sfx/plr_shield_break.mp3 differ diff --git a/assets/sfx/plr_shield_up.mp3 b/assets/sfx/plr_shield_up.mp3 new file mode 100644 index 0000000..1a61e5b Binary files /dev/null and b/assets/sfx/plr_shield_up.mp3 differ diff --git a/assets/sfx/plr_step1.mp3 b/assets/sfx/plr_step1.mp3 new file mode 100644 index 0000000..07b52b0 Binary files /dev/null and b/assets/sfx/plr_step1.mp3 differ diff --git a/assets/sfx/plr_step2.mp3 b/assets/sfx/plr_step2.mp3 new file mode 100644 index 0000000..ce3bc0f Binary files /dev/null and b/assets/sfx/plr_step2.mp3 differ diff --git a/assets/sfx/plr_step3.mp3 b/assets/sfx/plr_step3.mp3 new file mode 100644 index 0000000..3b9e761 Binary files /dev/null and b/assets/sfx/plr_step3.mp3 differ diff --git a/assets/sfx/plr_step4.mp3 b/assets/sfx/plr_step4.mp3 new file mode 100644 index 0000000..79aeb62 Binary files /dev/null and b/assets/sfx/plr_step4.mp3 differ diff --git a/assets/sfx/plr_step_wet1.mp3 b/assets/sfx/plr_step_wet1.mp3 new file mode 100644 index 0000000..de200f7 Binary files /dev/null and b/assets/sfx/plr_step_wet1.mp3 differ diff --git a/assets/sfx/plr_step_wet2.mp3 b/assets/sfx/plr_step_wet2.mp3 new file mode 100644 index 0000000..3ba9a76 Binary files /dev/null and b/assets/sfx/plr_step_wet2.mp3 differ diff --git a/assets/sfx/plr_step_wet3.mp3 b/assets/sfx/plr_step_wet3.mp3 new file mode 100644 index 0000000..0dcd47f Binary files /dev/null and b/assets/sfx/plr_step_wet3.mp3 differ diff --git a/assets/sfx/plr_step_wet4.mp3 b/assets/sfx/plr_step_wet4.mp3 new file mode 100644 index 0000000..4fabd9b Binary files /dev/null and b/assets/sfx/plr_step_wet4.mp3 differ diff --git a/assets/sfx/ui_ammo.mp3 b/assets/sfx/ui_ammo.mp3 new file mode 100644 index 0000000..9413ff9 Binary files /dev/null and b/assets/sfx/ui_ammo.mp3 differ diff --git a/assets/sfx/ui_gem.mp3 b/assets/sfx/ui_gem.mp3 new file mode 100644 index 0000000..9c7ee84 Binary files /dev/null and b/assets/sfx/ui_gem.mp3 differ diff --git a/assets/sfx/ui_heart.mp3 b/assets/sfx/ui_heart.mp3 new file mode 100644 index 0000000..732fd70 Binary files /dev/null and b/assets/sfx/ui_heart.mp3 differ diff --git a/assets/sfx/ui_item.mp3 b/assets/sfx/ui_item.mp3 new file mode 100644 index 0000000..c840496 Binary files /dev/null and b/assets/sfx/ui_item.mp3 differ diff --git a/assets/sfx/ui_levelup.mp3 b/assets/sfx/ui_levelup.mp3 new file mode 100644 index 0000000..4172850 Binary files /dev/null and b/assets/sfx/ui_levelup.mp3 differ diff --git a/assets/sfx/ui_menu.mp3 b/assets/sfx/ui_menu.mp3 new file mode 100644 index 0000000..7253c87 Binary files /dev/null and b/assets/sfx/ui_menu.mp3 differ diff --git a/assets/sfx/ui_perk.mp3 b/assets/sfx/ui_perk.mp3 new file mode 100644 index 0000000..e3771fc Binary files /dev/null and b/assets/sfx/ui_perk.mp3 differ diff --git a/assets/sfx/ui_stairs.mp3 b/assets/sfx/ui_stairs.mp3 new file mode 100644 index 0000000..5dc66c5 Binary files /dev/null and b/assets/sfx/ui_stairs.mp3 differ diff --git a/assets/sfx/ui_weapon.mp3 b/assets/sfx/ui_weapon.mp3 new file mode 100644 index 0000000..2b63859 Binary files /dev/null and b/assets/sfx/ui_weapon.mp3 differ diff --git a/assets/sfx/wpn_bonepopper.mp3 b/assets/sfx/wpn_bonepopper.mp3 new file mode 100644 index 0000000..c7e50e5 Binary files /dev/null and b/assets/sfx/wpn_bonepopper.mp3 differ diff --git a/assets/sfx/wpn_cleaver.mp3 b/assets/sfx/wpn_cleaver.mp3 new file mode 100644 index 0000000..0af4eac Binary files /dev/null and b/assets/sfx/wpn_cleaver.mp3 differ diff --git a/assets/sfx/wpn_eye.mp3 b/assets/sfx/wpn_eye.mp3 new file mode 100644 index 0000000..467491c Binary files /dev/null and b/assets/sfx/wpn_eye.mp3 differ diff --git a/assets/sfx/wpn_fleshmasher.mp3 b/assets/sfx/wpn_fleshmasher.mp3 new file mode 100644 index 0000000..9bc2af6 Binary files /dev/null and b/assets/sfx/wpn_fleshmasher.mp3 differ diff --git a/assets/sfx/wpn_guthook.mp3 b/assets/sfx/wpn_guthook.mp3 new file mode 100644 index 0000000..1ce9c36 Binary files /dev/null and b/assets/sfx/wpn_guthook.mp3 differ diff --git a/assets/sfx/wpn_hemophage.mp3 b/assets/sfx/wpn_hemophage.mp3 new file mode 100644 index 0000000..5f6ca01 Binary files /dev/null and b/assets/sfx/wpn_hemophage.mp3 differ diff --git a/assets/sfx/wpn_marrow.mp3 b/assets/sfx/wpn_marrow.mp3 new file mode 100644 index 0000000..6d5a0f5 Binary files /dev/null and b/assets/sfx/wpn_marrow.mp3 differ diff --git a/assets/sfx/wpn_repeater.mp3 b/assets/sfx/wpn_repeater.mp3 new file mode 100644 index 0000000..f2b6095 Binary files /dev/null and b/assets/sfx/wpn_repeater.mp3 differ diff --git a/assets/sfx/wpn_saw.mp3 b/assets/sfx/wpn_saw.mp3 new file mode 100644 index 0000000..937aae5 Binary files /dev/null and b/assets/sfx/wpn_saw.mp3 differ diff --git a/assets/sfx/wpn_spinaltap_fire.mp3 b/assets/sfx/wpn_spinaltap_fire.mp3 new file mode 100644 index 0000000..b82d82a Binary files /dev/null and b/assets/sfx/wpn_spinaltap_fire.mp3 differ diff --git a/assets/sfx/wpn_swarmjar.mp3 b/assets/sfx/wpn_swarmjar.mp3 new file mode 100644 index 0000000..bdc784a Binary files /dev/null and b/assets/sfx/wpn_swarmjar.mp3 differ diff --git a/assets/sfx/wpn_tenderizer.mp3 b/assets/sfx/wpn_tenderizer.mp3 new file mode 100644 index 0000000..1192b64 Binary files /dev/null and b/assets/sfx/wpn_tenderizer.mp3 differ diff --git a/assets/sfx/wpn_trapqueen.mp3 b/assets/sfx/wpn_trapqueen.mp3 new file mode 100644 index 0000000..fd24ec5 Binary files /dev/null and b/assets/sfx/wpn_trapqueen.mp3 differ diff --git a/assets/sfx/wpnloop_bile.mp3 b/assets/sfx/wpnloop_bile.mp3 new file mode 100644 index 0000000..78ece91 Binary files /dev/null and b/assets/sfx/wpnloop_bile.mp3 differ diff --git a/assets/sfx/wpnloop_cauterizer.mp3 b/assets/sfx/wpnloop_cauterizer.mp3 new file mode 100644 index 0000000..90e5775 Binary files /dev/null and b/assets/sfx/wpnloop_cauterizer.mp3 differ diff --git a/assets/sfx/wpnloop_redhand.mp3 b/assets/sfx/wpnloop_redhand.mp3 new file mode 100644 index 0000000..66e88e6 Binary files /dev/null and b/assets/sfx/wpnloop_redhand.mp3 differ diff --git a/assets/sfx/wpnloop_spinaltap_charge.mp3 b/assets/sfx/wpnloop_spinaltap_charge.mp3 new file mode 100644 index 0000000..acd1260 Binary files /dev/null and b/assets/sfx/wpnloop_spinaltap_charge.mp3 differ diff --git a/assets/stairs.png b/assets/stairs.png new file mode 100644 index 0000000..d811ff8 Binary files /dev/null and b/assets/stairs.png differ diff --git a/assets/tile_floor1.png b/assets/tile_floor1.png new file mode 100644 index 0000000..c22fb5c Binary files /dev/null and b/assets/tile_floor1.png differ diff --git a/assets/tile_floor2.png b/assets/tile_floor2.png new file mode 100644 index 0000000..fff82f4 Binary files /dev/null and b/assets/tile_floor2.png differ diff --git a/assets/tile_floor3.png b/assets/tile_floor3.png new file mode 100644 index 0000000..d7ac43f Binary files /dev/null and b/assets/tile_floor3.png differ diff --git a/assets/tile_floor4.png b/assets/tile_floor4.png new file mode 100644 index 0000000..92037eb Binary files /dev/null and b/assets/tile_floor4.png differ diff --git a/assets/tile_floor5.png b/assets/tile_floor5.png new file mode 100644 index 0000000..6a6e8f3 Binary files /dev/null and b/assets/tile_floor5.png differ diff --git a/assets/tile_floor6.png b/assets/tile_floor6.png new file mode 100644 index 0000000..41a678c Binary files /dev/null and b/assets/tile_floor6.png differ diff --git a/assets/tile_floor7.png b/assets/tile_floor7.png new file mode 100644 index 0000000..31ba11b Binary files /dev/null and b/assets/tile_floor7.png differ diff --git a/assets/tile_floor8.png b/assets/tile_floor8.png new file mode 100644 index 0000000..fe23d06 Binary files /dev/null and b/assets/tile_floor8.png differ diff --git a/assets/tile_wall.png b/assets/tile_wall.png new file mode 100644 index 0000000..64e75ba Binary files /dev/null and b/assets/tile_wall.png differ diff --git a/assets/tile_wall2.png b/assets/tile_wall2.png new file mode 100644 index 0000000..34dae6c Binary files /dev/null and b/assets/tile_wall2.png differ diff --git a/assets/tile_wall3.png b/assets/tile_wall3.png new file mode 100644 index 0000000..6d9048c Binary files /dev/null and b/assets/tile_wall3.png differ diff --git a/assets/tile_wall4.png b/assets/tile_wall4.png new file mode 100644 index 0000000..0d9b24d Binary files /dev/null and b/assets/tile_wall4.png differ diff --git a/assets/w_bile.png b/assets/w_bile.png new file mode 100644 index 0000000..5f672da Binary files /dev/null and b/assets/w_bile.png differ diff --git a/assets/w_bonepopper.png b/assets/w_bonepopper.png new file mode 100644 index 0000000..7542dd7 Binary files /dev/null and b/assets/w_bonepopper.png differ diff --git a/assets/w_cauterizer.png b/assets/w_cauterizer.png new file mode 100644 index 0000000..0daa31a Binary files /dev/null and b/assets/w_cauterizer.png differ diff --git a/assets/w_cleaver.png b/assets/w_cleaver.png new file mode 100644 index 0000000..9d8fc93 Binary files /dev/null and b/assets/w_cleaver.png differ diff --git a/assets/w_eye.png b/assets/w_eye.png new file mode 100644 index 0000000..2da291e Binary files /dev/null and b/assets/w_eye.png differ diff --git a/assets/w_fleshmasher.png b/assets/w_fleshmasher.png new file mode 100644 index 0000000..5523a46 Binary files /dev/null and b/assets/w_fleshmasher.png differ diff --git a/assets/w_guthook.png b/assets/w_guthook.png new file mode 100644 index 0000000..1f4eb69 Binary files /dev/null and b/assets/w_guthook.png differ diff --git a/assets/w_hemophage.png b/assets/w_hemophage.png new file mode 100644 index 0000000..307e863 Binary files /dev/null and b/assets/w_hemophage.png differ diff --git a/assets/w_marrow.png b/assets/w_marrow.png new file mode 100644 index 0000000..11aa779 Binary files /dev/null and b/assets/w_marrow.png differ diff --git a/assets/w_redhand.png b/assets/w_redhand.png new file mode 100644 index 0000000..44d8366 Binary files /dev/null and b/assets/w_redhand.png differ diff --git a/assets/w_repeater.png b/assets/w_repeater.png new file mode 100644 index 0000000..88c4ba3 Binary files /dev/null and b/assets/w_repeater.png differ diff --git a/assets/w_saw.png b/assets/w_saw.png new file mode 100644 index 0000000..e1491a0 Binary files /dev/null and b/assets/w_saw.png differ diff --git a/assets/w_spinaltap.png b/assets/w_spinaltap.png new file mode 100644 index 0000000..a516ff5 Binary files /dev/null and b/assets/w_spinaltap.png differ diff --git a/assets/w_swarmjar.png b/assets/w_swarmjar.png new file mode 100644 index 0000000..890403a Binary files /dev/null and b/assets/w_swarmjar.png differ diff --git a/assets/w_tenderizer.png b/assets/w_tenderizer.png new file mode 100644 index 0000000..0a0096e Binary files /dev/null and b/assets/w_tenderizer.png differ diff --git a/assets/w_trapqueen.png b/assets/w_trapqueen.png new file mode 100644 index 0000000..f360683 Binary files /dev/null and b/assets/w_trapqueen.png differ diff --git a/assets/wt_bile.png b/assets/wt_bile.png new file mode 100644 index 0000000..05b7012 Binary files /dev/null and b/assets/wt_bile.png differ diff --git a/assets/wt_bonepopper.png b/assets/wt_bonepopper.png new file mode 100644 index 0000000..73995dc Binary files /dev/null and b/assets/wt_bonepopper.png differ diff --git a/assets/wt_cauterizer.png b/assets/wt_cauterizer.png new file mode 100644 index 0000000..253ae63 Binary files /dev/null and b/assets/wt_cauterizer.png differ diff --git a/assets/wt_cleaver.png b/assets/wt_cleaver.png new file mode 100644 index 0000000..75303a4 Binary files /dev/null and b/assets/wt_cleaver.png differ diff --git a/assets/wt_eye.png b/assets/wt_eye.png new file mode 100644 index 0000000..5440020 Binary files /dev/null and b/assets/wt_eye.png differ diff --git a/assets/wt_fleshmasher.png b/assets/wt_fleshmasher.png new file mode 100644 index 0000000..6209e1e Binary files /dev/null and b/assets/wt_fleshmasher.png differ diff --git a/assets/wt_guthook.png b/assets/wt_guthook.png new file mode 100644 index 0000000..b9bc131 Binary files /dev/null and b/assets/wt_guthook.png differ diff --git a/assets/wt_hemophage.png b/assets/wt_hemophage.png new file mode 100644 index 0000000..e2cb848 Binary files /dev/null and b/assets/wt_hemophage.png differ diff --git a/assets/wt_marrow.png b/assets/wt_marrow.png new file mode 100644 index 0000000..e43d8a6 Binary files /dev/null and b/assets/wt_marrow.png differ diff --git a/assets/wt_redhand.png b/assets/wt_redhand.png new file mode 100644 index 0000000..146241a Binary files /dev/null and b/assets/wt_redhand.png differ diff --git a/assets/wt_repeater.png b/assets/wt_repeater.png new file mode 100644 index 0000000..bd38371 Binary files /dev/null and b/assets/wt_repeater.png differ diff --git a/assets/wt_saw.png b/assets/wt_saw.png new file mode 100644 index 0000000..f2a5a6e Binary files /dev/null and b/assets/wt_saw.png differ diff --git a/assets/wt_spinaltap.png b/assets/wt_spinaltap.png new file mode 100644 index 0000000..af98246 Binary files /dev/null and b/assets/wt_spinaltap.png differ diff --git a/assets/wt_swarmjar.png b/assets/wt_swarmjar.png new file mode 100644 index 0000000..cac6626 Binary files /dev/null and b/assets/wt_swarmjar.png differ diff --git a/assets/wt_tenderizer.png b/assets/wt_tenderizer.png new file mode 100644 index 0000000..10a2d20 Binary files /dev/null and b/assets/wt_tenderizer.png differ diff --git a/assets/wt_trapqueen.png b/assets/wt_trapqueen.png new file mode 100644 index 0000000..e04484b Binary files /dev/null and b/assets/wt_trapqueen.png differ diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..04eb447 --- /dev/null +++ b/css/style.css @@ -0,0 +1,69 @@ +html, body { + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; + font-family: "Courier New", ui-monospace, monospace; + background-color: #040305; + background-image: + radial-gradient(circle at 50% 42%, rgba(74, 14, 25, 0.52) 0, rgba(15, 5, 9, 0.72) 39%, #030204 100%), + linear-gradient(rgba(118, 37, 51, 0.055) 1px, transparent 1px), + linear-gradient(90deg, rgba(118, 37, 51, 0.055) 1px, transparent 1px); + background-size: auto, 32px 32px, 32px 32px; +} +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + opacity: 0.24; + background-image: + repeating-linear-gradient(0deg, transparent 0 3px, rgba(255,255,255,0.018) 3px 4px), + linear-gradient(90deg, rgba(50,153,166,0.035), transparent 18%, transparent 82%, rgba(181,30,53,0.05)), + radial-gradient(circle at 20% 30%, rgba(130,18,30,0.15), transparent 28%), + radial-gradient(circle at 80% 70%, rgba(47,118,128,0.08), transparent 30%); +} +body::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + opacity: 0.18; + background: + radial-gradient(ellipse at 3% 8%, rgba(126,8,23,0.8) 0 7px, transparent 9px), + radial-gradient(ellipse at 97% 18%, rgba(96,5,16,0.72) 0 11px, transparent 14px), + radial-gradient(ellipse at 6% 91%, rgba(116,7,19,0.65) 0 13px, transparent 16px), + linear-gradient(180deg, rgba(105,6,18,0.3), transparent 9%, transparent 90%, rgba(74,3,12,0.3)); + mix-blend-mode: screen; +} +#wrap { + position: relative; + isolation: isolate; + width: 100vw; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} +#game { + image-rendering: pixelated; + image-rendering: crisp-edges; + background: #090608; + border: 2px solid #692131; + outline: 1px solid rgba(71, 150, 161, 0.24); + outline-offset: 5px; + clip-path: polygon(0 8px, 8px 0, calc(100% - 8px) 0, 100% 8px, 100% calc(100% - 8px), calc(100% - 8px) 100%, 8px 100%, 0 calc(100% - 8px)); + box-shadow: + 0 0 0 9px rgba(7, 3, 5, 0.84), + 0 20px 80px rgba(0, 0, 0, 0.82), + 0 0 70px rgba(110, 11, 28, 0.38), + 0 0 130px rgba(35, 94, 103, 0.12); + cursor: crosshair; +} + +@media (max-width: 700px) { + #game { + outline-offset: 2px; + box-shadow: 0 8px 38px rgba(0,0,0,0.84), 0 0 38px rgba(96,8,18,0.38); + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..fed8fc8 --- /dev/null +++ b/index.html @@ -0,0 +1,35 @@ + + + + + + + +MEATSLICER + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + diff --git a/js/audio.js b/js/audio.js new file mode 100644 index 0000000..ae1b0f2 --- /dev/null +++ b/js/audio.js @@ -0,0 +1,166 @@ +// ---- music manager: crossfading loops mapped to game moods ---- +const Music = { + current: null, // { audio, name } + fading: null, + bossCount: 0, + + TRACKS: { + menu: 'Blood Arcade.mp3', + floors: [ + 'Rust Hollow Loop.mp3', 'Blood Dice Loop.mp3', 'Crimsonland Requiem.mp3', + 'Crimsonland Tool.mp3', 'Blood Arcade_Alt.mp3', 'Rust Hollow Loop_Alt.mp3', + 'Blood Dice Loop_alt.mp3', 'Crimsonland Requiem_alt.mp3', 'Crimsonland Tool_alt.mp3', + 'Blood Arcade.mp3', + ], + bosses: { + bonesaw: ['Bone Saw Loop_Boss.mp3', 'Bone Saw Loop_Alt_Boss.mp3'], + gorecrown: ['Gore Crown Loop_Boss.mp3', 'Gore Crown Loop_Alt_Boss.mp3'], + knifecrawl: ['Knife Floor Crawl_Boss.mp3', 'Knife Floor Crawl_Alt_Boss.mp3'], + vealmother: ['Veal Mother Loop_Boss.mp3', 'Veal Mother Loop_Alt_Boss.mp3'], + flenser: ['Flenser Loop_Boss.mp3', 'Flenser Loop_Alt_Boss.mp3'], + hookchoir: ['Hook Choir Loop_Boss.mp3', 'Hook Choir Loop_Alt_Boss.mp3'], + platefather: ['Plate Father Loop_Boss.mp3', 'Plate Father Loop_Alt_Boss.mp3'], + augerprime: ['Auger Prime Loop_Boss.mp3', 'Auger Prime Loop_Alt_Boss.mp3'], + scald: ['Scald Loop_Boss.mp3', 'Scald Loop_Alt_Boss.mp3'], + }, + }, + + // jukebox: every non-boss track, selectable by the player + PLAYLIST: [ + 'Blood Arcade.mp3', 'Blood Arcade_Alt.mp3', + 'Rust Hollow Loop.mp3', 'Rust Hollow Loop_Alt.mp3', + 'Blood Dice Loop.mp3', 'Blood Dice Loop_alt.mp3', + 'Crimsonland Requiem.mp3', 'Crimsonland Requiem_alt.mp3', + 'Crimsonland Tool.mp3', 'Crimsonland Tool_alt.mp3', + 'Bone Saw Loop_Boss.mp3', 'Bone Saw Loop_Alt_Boss.mp3', + 'Gore Crown Loop_Boss.mp3', 'Gore Crown Loop_Alt_Boss.mp3', + 'Knife Floor Crawl_Boss.mp3', 'Knife Floor Crawl_Alt_Boss.mp3', + 'Veal Mother Loop_Boss.mp3', 'Veal Mother Loop_Alt_Boss.mp3', + 'Flenser Loop_Boss.mp3', 'Flenser Loop_Alt_Boss.mp3', + 'Hook Choir Loop_Boss.mp3', 'Hook Choir Loop_Alt_Boss.mp3', + 'Plate Father Loop_Boss.mp3', 'Plate Father Loop_Alt_Boss.mp3', + 'Auger Prime Loop_Boss.mp3', 'Auger Prime Loop_Alt_Boss.mp3', + 'Scald Loop_Boss.mp3', 'Scald Loop_Alt_Boss.mp3', + ], + override: null, // filename when the player picked a track manually + + pretty(file) { + return file.replace(/\.mp3$/i, '').replace(/_Boss$/i, '').replace(/_Alt$/i, ' (Alt)').replace(/_alt$/i, ' (Alt)'); + }, + + // the track the game *should* be playing right now (respects manual override) + requestFloorMusic() { + if (this.override) this.play(this.override); + else this.playFloor(G.floor); + }, + + makeAudio(name) { + const a = new Audio('mp3-music/' + encodeURIComponent(name)); + a.loop = true; + a.volume = 0; + a.preload = 'auto'; + return a; + }, + + floorTrack(floor) { return this.TRACKS.floors[(floor - 1) % this.TRACKS.floors.length]; }, + bossTrack(kind) { + const pair = this.TRACKS.bosses[kind] || this.TRACKS.bosses.bonesaw; + return pair[this.bossCount % 2]; + }, + + play(name) { + if (this.current && this.current.name === name) return; + if (this.fading) { + this.fading.audio.pause(); + if (typeof Sfx !== 'undefined') Sfx.detachMusic(this.fading.audio); + this.fading = null; + } + if (this.current) this.fading = this.current; + const a = this.makeAudio(name); + a.muted = G.muted; + if (typeof Sfx !== 'undefined') Sfx.attachMusic(a); + a.play().catch(() => {}); // browser may block until user gesture; retried on next call + this.current = { audio: a, name, retryT: 0 }; + }, + + playMenu() { this.play(this.TRACKS.menu); }, + playFloor(floor) { this.play(this.floorTrack(floor)); }, + playBoss(kind) { this.play(this.bossTrack(kind)); this.bossCount++; }, + + stop() { + if (this.current) { + this.current.audio.pause(); + if (typeof Sfx !== 'undefined') Sfx.detachMusic(this.current.audio); + this.current = null; + } + if (this.fading) { + this.fading.audio.pause(); + if (typeof Sfx !== 'undefined') Sfx.detachMusic(this.fading.audio); + this.fading = null; + } + }, + + setMuted(m) { + if (this.current) this.current.audio.muted = m; + if (this.fading) this.fading.audio.muted = m; + if (typeof Sfx !== 'undefined') Sfx.setMusicMuted(m); + }, + + setVolume(v) { + G.musicVol = clamp(v, 0, 1); + try { localStorage.setItem('meatslicer_music_volume', String(G.musicVol)); } catch (e) {} + if (this.current) this.current.audio.volume = Math.min(this.current.audio.volume, G.musicVol); + if (this.fading) this.fading.audio.volume = Math.min(this.fading.audio.volume, G.musicVol); + }, + + // cycle the jukebox selection: Auto -> track 0 -> ... -> track N -> Auto + cycle(dir) { + const n = this.PLAYLIST.length; + if (G.musicSel === undefined || G.musicSel === null) G.musicSel = -1; + G.musicSel += dir; + if (G.musicSel >= n) G.musicSel = -1; + if (G.musicSel < -1) G.musicSel = n - 1; + + if (G.musicSel === -1) { + this.override = null; + addToast('MUSIC: AUTO', 'floor rotation'); + } else { + this.override = this.PLAYLIST[G.musicSel]; + addToast('MUSIC: ' + this.pretty(this.override)); + } + // never interrupt a live boss track — it hands back when the boss dies + if (!G.boss) this.requestFloorMusic(); + Sfx.pickup(); + }, + + update(dt) { + const target = G.musicVol === undefined ? 0.55 : G.musicVol; + // Tracks created before the AudioContext resumed keep playing directly; + // attach once the context is running so large SFX can duck the music bus. + if (typeof Sfx !== 'undefined') { + if (this.current) Sfx.attachMusic(this.current.audio); + if (this.fading) Sfx.attachMusic(this.fading.audio); + } + if (this.current && this.current.audio.volume < target) { + this.current.audio.volume = clamp(this.current.audio.volume + dt * 0.8, 0, target); + // If autoplay was blocked, retry at a gentle cadence rather than once + // per frame (which can flood promises and the browser media pipeline). + this.current.retryT -= dt; + if (this.current.audio.paused && this.current.retryT <= 0) { + this.current.retryT = 1; + this.current.audio.play().catch(() => {}); + } + } + if (this.fading) { + const v = this.fading.audio.volume - dt * 0.8; + if (v <= 0.01) { + this.fading.audio.volume = 0; + this.fading.audio.pause(); + if (typeof Sfx !== 'undefined') Sfx.detachMusic(this.fading.audio); + this.fading = null; + } else { + this.fading.audio.volume = v; + } + } + }, +}; diff --git a/js/bosses.js b/js/bosses.js new file mode 100644 index 0000000..46dd8a2 --- /dev/null +++ b/js/bosses.js @@ -0,0 +1,602 @@ +// ---- nine rotating industrial-horror bosses ---- + +const BOSS_DEFS = [ + { kind: 'bonesaw', name: 'BONE SAW', sprite: 'boss_bonesaw', hp: 520, spd: 60, r: 45, drawSize: 128, debutCycle: 0 }, + { kind: 'gorecrown', name: 'GORE CROWN', sprite: 'boss_gorecrown', hp: 600, spd: 42, r: 48, drawSize: 128, debutCycle: 0 }, + { kind: 'knifecrawl', name: 'KNIFE FLOOR CRAWL', sprite: 'boss_knifecrawl', hp: 560, spd: 75, r: 45, drawSize: 128, debutCycle: 0 }, + { kind: 'vealmother', name: 'VEAL MOTHER', sprite: 'boss_vealmother', hp: 640, spd: 34, r: 52, drawSize: 128, debutCycle: 1 }, + { kind: 'flenser', name: 'THE FLENSER', sprite: 'boss_flenser', hp: 460, spd: 96, r: 40, drawSize: 128, debutCycle: 1 }, + { kind: 'hookchoir', name: 'HOOK CHOIR', sprite: 'boss_hookchoir', hp: 580, spd: 46, r: 50, drawSize: 128, debutCycle: 1 }, + { kind: 'platefather',name: 'PLATE FATHER', sprite: 'boss_platefather',hp: 700, spd: 38, r: 54, drawSize: 128, debutCycle: 2 }, + { kind: 'augerprime', name: 'AUGER PRIME', sprite: 'boss_augerprime', hp: 600, spd: 52, r: 48, drawSize: 128, debutCycle: 2 }, + { kind: 'scald', name: 'THE SCALD', sprite: 'boss_scald', hp: 620, spd: 40, r: 50, drawSize: 128, debutCycle: 2 }, +]; + +function bossIndexForFloor(floor) { return (floor - 1) % BOSS_DEFS.length; } + +function spawnBoss(floor) { + const def = BOSS_DEFS[bossIndexForFloor(floor)]; + const cycle = Math.floor((floor - 1) / 3); + const tier = Math.max(0, cycle - def.debutCycle); + const up = powerScore(); + const pressureHp = G.pressure; + const hpMul = (1 + (floor - 1) * 0.30) * (1 + up * 0.05) * pressureHp; + const e = { + type: 'boss_' + def.kind, bossKind: def.kind, boss: true, name: def.name, + x: W / 2, y: H / 2 - 100, vx: 0, vy: 0, + r: def.r, hp: def.hp * hpMul, maxHp: def.hp * hpMul, + drawSize: def.drawSize, + spd: def.spd * (1 + cycle * 0.1 + up * 0.01) * G.pressure, dmg: 2, + sprite: def.sprite, elite: false, + hitT: 0, flash: 0, burnT: 0, burnDps: 0, slowT: 0, rootT: 0, + wob: 0, animSeed: rand(0, TAU), animT: 0, attackT: 0, actionT: 0, + spawnT: 0, dead: false, + state: 'idle', stateT: 1.2, atkT: 2.2, sumT: 4, + chargeAng: 0, cycle, tier, atkCount: 0, dashes: 0, spiralOff: 0, enragedPlayed: false, + atkT2: 2.6, alpha: 1, phased: false, dmgTakenMul: def.kind === 'platefather' ? 0.25 : 1, + plates: def.kind === 'platefather' ? 4 : 0, plateDmg: 0, + escorts: [], broodTriggered: false, blinkCount: 0, hookRespawnT: 0, + }; + G.enemies.push(e); + G.boss = e; + Sfx.boss(e, 'roar'); + addToast(def.name, 'fresh meat wants YOU'); + return e; +} + +function updateBoss(e, dt) { + e.wob += dt; + const p = G.player; + const a = angleTo(e.x, e.y, p.x, p.y); + const d = dist(e.x, e.y, p.x, p.y); + const enraged = e.hp < e.maxHp * 0.35; + const spdMul = enraged ? 1.35 : 1; + if (enraged && !e.enragedPlayed) { e.enragedPlayed = true; Sfx.boss(e, 'enrage'); } + + switch (e.bossKind) { + case 'bonesaw': updateBoneSaw(e, dt, a, d, spdMul, enraged); break; + case 'gorecrown': updateGoreCrown(e, dt, a, d, spdMul, enraged); break; + case 'knifecrawl': updateKnifeCrawl(e, dt, a, d, spdMul, enraged); break; + case 'vealmother': updateVealMother(e, dt, a, d, spdMul, enraged); break; + case 'flenser': updateFlenser(e, dt, a, d, spdMul, enraged); break; + case 'hookchoir': updateHookChoir(e, dt, a, d, spdMul, enraged); break; + case 'platefather': updatePlateFather(e, dt, a, d, spdMul, enraged); break; + case 'augerprime': updateAugerPrime(e, dt, a, d, spdMul, enraged); break; + case 'scald': updateScald(e, dt, a, d, spdMul, enraged); break; + } +} + +// --- Bone Saw: telegraphed charges + flings bouncing saws --- +function updateBoneSaw(e, dt, a, d, spdMul, enraged) { + const p = G.player; + e.stateT -= dt; e.atkT -= dt; + + if (e.state === 'idle') { + e.vx += Math.cos(a) * e.spd * spdMul * 3 * dt; + e.vy += Math.sin(a) * e.spd * spdMul * 3 * dt; + if (e.atkT <= 0) { + e.atkT = enraged ? 2.2 : 3.2; + if (chance(0.55)) { // telegraph a charge + e.state = 'aim'; e.stateT = 0.6; e.vx = 0; e.vy = 0; + } else { // fling saws + const n = enraged ? 10 : 6; + for (let i = 0; i < n; i++) { + const fa = a + (i - (n - 1) / 2) * 0.28; + G.ebullets.push({ + x: e.x, y: e.y, vx: Math.cos(fa) * 240, vy: Math.sin(fa) * 240, + r: 8, dmg: 1, life: 3.2, t: 0, sprite: 'bullet_saw', + }); + } + Sfx.boss(e, 'saws'); + } + } + } else if (e.state === 'aim') { + e.chargeAng = a; + if (e.stateT <= 0) { e.state = 'charge'; e.stateT = 0.85; Sfx.boss(e, 'charge'); } + } else if (e.state === 'charge') { + e.vx = Math.cos(e.chargeAng) * 430 * spdMul; + e.vy = Math.sin(e.chargeAng) * 430 * spdMul; + if (chance(0.5)) spawnBlood(e.x, e.y, e.chargeAng + Math.PI, 2); + // wall slam + const nx = e.x + e.vx * dt, ny = e.y + e.vy * dt; + if (nx < WALL + e.r + 2 || nx > W - WALL - e.r - 2 || ny < WALL + e.r + 2 || ny > H - WALL - e.r - 2) { + e.state = 'idle'; e.stateT = 1; e.atkT = 1.2; + addShake(10); Sfx.explode({ x: e.x, y: e.y }); + } + if (e.stateT <= 0) { e.state = 'idle'; e.stateT = 0.8; } + } +} + +// --- Gore Crown: rings + summons; cycles add volleys, double rings, spirals, nastier summons --- +function updateGoreCrown(e, dt, a, d, spdMul, enraged) { + e.atkT -= dt; e.sumT -= dt; + // slow relentless chase + e.vx += Math.cos(a) * e.spd * spdMul * 3 * dt; + e.vy += Math.sin(a) * e.spd * spdMul * 3 * dt; + + if (e.atkT <= 0) { + e.atkT = Math.max((enraged ? 1.6 : 2.4) - e.cycle * 0.15, 1.2); + e.atkCount++; + const ringN = Math.min((enraged ? 18 : 12) + e.cycle * 4, 40); + // cycle 2+: alternate between rings and aimed gore volleys + if (e.tier >= 1 && e.atkCount % 2 === 0) { + const n = 3 + Math.min(e.cycle, 4); + for (let i = 0; i < n; i++) { + const fa = a + (i - (n - 1) / 2) * 0.22; + G.ebullets.push({ + x: e.x, y: e.y, vx: Math.cos(fa) * 260, vy: Math.sin(fa) * 260, + r: 6, dmg: 1, life: 3, t: 0, sprite: 'bullet_gore', + }); + } + } else { + // rings: cycle 3+ every 3rd is doubled, cycle 4+ rings slowly rotate + if (e.tier >= 3) e.spiralOff += 0.35; + const off = e.spiralOff + rand(0, TAU); + const rings = (e.tier >= 2 && e.atkCount % 3 === 0) ? 2 : 1; + for (let ri = 0; ri < rings; ri++) { + for (let i = 0; i < ringN; i++) { + const fa = off + ri * (Math.PI / ringN) + (i / ringN) * TAU; + G.ebullets.push({ + x: e.x, y: e.y, vx: Math.cos(fa) * 170, vy: Math.sin(fa) * 170, + r: 6, dmg: 1, life: 4, t: 0, sprite: 'bullet_gore', + }); + } + } + } + Sfx.boss(e, 'volley'); + } + if (e.sumT <= 0) { + e.sumT = Math.max((enraged ? 5 : 7) - e.cycle * 0.5, 3.5); + const n = Math.min(2 + e.cycle, 5, Math.max(0, MAX_ENEMIES - G.enemies.length)); + for (let i = 0; i < n; i++) { + const pos = spawnPosAwayFromPlayer(); + let type = chance(0.6) ? 'runner' : 'mini'; + if (e.tier >= 1 && chance(0.3)) type = 'splitter'; // tier 2+: splitters join + if (e.tier >= 2 && chance(0.25)) type = 'exploder'; // tier 3+: exploders join + G.enemies.push(makeEnemy(type, pos.x, pos.y, G.floor, false)); + spawnBlood(pos.x, pos.y, 0, 6); + } + Sfx.boss(e, 'summon'); + } +} + +// --- Knife Floor Crawl: telegraphs + dashes; cycles add hunting telegraphs, dash trails, volleys, chain dashes --- +function updateKnifeCrawl(e, dt, a, d, spdMul, enraged) { + const p = G.player; + e.atkT -= dt; e.stateT -= dt; + if (e.state === 'idle') { + e.vx += Math.cos(a) * e.spd * spdMul * 3 * dt; + e.vy += Math.sin(a) * e.spd * spdMul * 3 * dt; + if (e.atkT <= 0) { + e.atkT = Math.max((enraged ? 1.8 : 2.6) - e.cycle * 0.15, 1.3); + const n = Math.min(3 + e.cycle, 6); + for (let i = 0; i < n; i++) { + const tx = i === 0 ? p.x : p.x + rand(-130, 130); + const ty = i === 0 ? p.y : p.y + rand(-130, 130); + G.telegraphs.push({ + kind: 'knives', + x: clamp(tx, WALL + 20, W - WALL - 20), y: clamp(ty, WALL + 20, H - WALL - 20), + r: 34, t: 0, dur: Math.max(0.7 - e.cycle * 0.05, 0.45), tick: 0, + track: e.tier >= 1 && i === 0 ? 0.4 : 0, // tier 2+: one telegraph hunts you + }); + } + // cycle 3+: cleaver volley + if (e.tier >= 2) { + const vn = 3 + Math.min(e.cycle - 1, 3); + for (let i = 0; i < vn; i++) { + const fa = a + (i - (vn - 1) / 2) * 0.2; + G.ebullets.push({ + x: e.x, y: e.y, vx: Math.cos(fa) * 300, vy: Math.sin(fa) * 300, + r: 6, dmg: 1, life: 2.5, t: 0, sprite: 'bullet_cleaver', + }); + } + Sfx.boss(e, 'knives'); + } + if (chance(Math.min(enraged ? 0.5 : 0.3 + e.cycle * 0.08, 0.6))) { + e.state = 'dash'; e.stateT = 0.5; e.chargeAng = a; + e.dashes = e.tier >= 3 ? 2 : 1; // tier 4+: chain dash + Sfx.boss(e, 'dash'); + } + } + } else if (e.state === 'dash') { + e.vx = Math.cos(e.chargeAng) * 320 * spdMul; + e.vy = Math.sin(e.chargeAng) * 320 * spdMul; + if (e.stateT <= 0) { + // cycle 2+: knives erupt where the dash ends + if (e.tier >= 1) { + G.telegraphs.push({ + kind: 'knives', x: clamp(e.x, WALL + 20, W - WALL - 20), y: clamp(e.y, WALL + 20, H - WALL - 20), + r: 34, t: 0, dur: 0.55, tick: 0, track: 0, + }); + } + if (e.dashes > 1) { // chain dash + e.dashes--; + e.chargeAng = angleTo(e.x, e.y, p.x, p.y); + e.stateT = 0.45; + } else { + e.state = 'idle'; + } + } + } +} + +function fireBossBullet(e, ang, speed, sprite, opts) { + const o = opts || {}; + G.ebullets.push({ + x: e.x, y: e.y, vx: Math.cos(ang) * speed, vy: Math.sin(ang) * speed, + r: o.r || 6, dmg: o.dmg || 1, life: o.life || 3.5, t: 0, + sprite: sprite || 'bullet_gore', homing: o.homing || 0, + accel: o.accel || 0, bounces: o.bounces || 0, + }); +} + +function fireBossRing(e, count, speed, sprite, off, opts, gapStart, gapSize) { + for (let i = 0; i < count; i++) { + if (gapStart !== undefined) { + const delta = (i - gapStart + count) % count; + if (delta < gapSize) continue; + } + fireBossBullet(e, (off || 0) + (i / count) * TAU, speed, sprite, opts); + } +} + +// --- Veal Mother: brood summons, homing bile, escort-gated shield phase --- +function updateVealMother(e, dt, a, d, spdMul, enraged) { + e.atkT -= dt; e.sumT -= dt; e.stateT -= dt; + const escortAlive = e.escorts.some(m => m && m.hp > 0); + if (e.broodTriggered && !escortAlive && e.dmgTakenMul < 1) { + e.dmgTakenMul = 1; e.state = 'idle'; + addToast('BROOD SHIELD BROKEN', 'the mother is exposed'); + } + const moveMul = escortAlive ? 0.5 : 1; + e.vx += Math.cos(a) * e.spd * spdMul * moveMul * 3 * dt; + e.vy += Math.sin(a) * e.spd * spdMul * moveMul * 3 * dt; + + if (!e.broodTriggered && e.hp < e.maxHp * 0.5) { + e.broodTriggered = true; e.state = 'brood'; e.stateT = 0.7; + const slots = Math.max(0, MAX_ENEMIES - G.enemies.length); + for (let i = 0; i < Math.min(2, slots); i++) { + const ang = a + Math.PI + (i ? 0.7 : -0.7); + const m = makeEnemy('splitter', clamp(e.x + Math.cos(ang) * 90, WALL + 30, W - WALL - 30), + clamp(e.y + Math.sin(ang) * 90, WALL + 30, H - WALL - 30), G.floor, true); + e.escorts.push(m); G.enemies.push(m); Sfx.spawn(m); + } + if (e.escorts.length) e.dmgTakenMul = 0.35; + addToast('BROOD SHIELD', 'kill the splitters'); + Sfx.boss(e, 'birth'); + } + + if (e.atkT <= 0) { + e.atkT = escortAlive ? 1.6 : (enraged ? 2.4 : 3.4); + if (escortAlive) { + fireBossRing(e, 10 + Math.min(e.tier, 3) * 2, 175, 'bullet_syringe', rand(0, TAU), { life: 4 }); + } else { + const n = e.tier >= 3 ? 9 : 5; + for (let i = 0; i < n; i++) { + const fa = a + (i - (n - 1) / 2) * 0.18; + fireBossBullet(e, fa, 190, 'bullet_syringe', { life: 3.2, homing: 1.2 }); + } + } + e.attackT = 0.35; e.actionT = 0; + Sfx.boss(e, 'bile'); + } + if (e.sumT <= 0) { + e.sumT = enraged ? 3.8 : 5.5; + const n = Math.min(3 + e.tier, 6, Math.max(0, MAX_ENEMIES - G.enemies.length)); + for (let i = 0; i < n; i++) { + const pos = spawnPosAwayFromPlayer(); + let type = 'mini'; + if (e.tier >= 1 && chance(0.3)) type = 'splitter'; + if (e.tier >= 2 && chance(0.2)) type = 'exploder'; + const m = makeEnemy(type, pos.x, pos.y, G.floor, false); + G.enemies.push(m); spawnBlood(pos.x, pos.y, 0, 5); Sfx.spawn(m); + } + e.state = 'birth'; e.stateT = 0.5; Sfx.boss(e, 'birth'); + } + if (e.state === 'birth' && e.stateT <= 0) e.state = escortAlive ? 'brood' : 'idle'; +} + +// --- The Flenser: telegraphed vanish, reposition, cleaver ambush --- +function updateFlenser(e, dt, a, d, spdMul, enraged) { + const p = G.player; + e.atkT -= dt; e.stateT -= dt; + if (e.state === 'idle') { + e.alpha = 1; e.phased = false; + e.vx += Math.cos(a) * e.spd * spdMul * 3.5 * dt; + e.vy += Math.sin(a) * e.spd * spdMul * 3.5 * dt; + if (e.atkT <= 0) { + e.atkT = enraged ? 1.5 : 2.4; e.state = 'fade'; e.stateT = 0.35; + e.blinkCount = e.tier >= 3 ? 2 : 1; e.oldX = e.x; e.oldY = e.y; + e.vx = 0; e.vy = 0; Sfx.boss(e, 'blink'); + } + } else if (e.state === 'fade') { + e.vx = 0; e.vy = 0; e.alpha = clamp(e.stateT / 0.35, 0, 1); + if (e.stateT <= 0) { + e.state = 'gone'; e.stateT = enraged ? 0.3 : 0.5; e.phased = true; e.alpha = 0; + const behind = p.aim + Math.PI, range = rand(90, 140); + e.x = clamp(p.x + Math.cos(behind) * range, WALL + e.r, W - WALL - e.r); + e.y = clamp(p.y + Math.sin(behind) * range, WALL + e.r, H - WALL - e.r); + G.telegraphs.push({ kind: 'knives', x: e.x, y: e.y, r: 38, t: 0, dur: e.stateT + 0.22, tick: 0, track: 0 }); + if (e.tier >= 1) G.telegraphs.push({ kind: 'knives', x: e.oldX, y: e.oldY, r: 30, t: 0, dur: 0.6, tick: 0, track: 0 }); + } + } else if (e.state === 'gone') { + e.vx = 0; e.vy = 0; + if (e.stateT <= 0) { + e.state = 'strike'; e.stateT = 0.4; e.phased = false; e.alpha = 1; + const strikeA = angleTo(e.x, e.y, p.x, p.y); + const volleys = e.tier >= 2 ? 2 : 1; + for (let v = 0; v < volleys; v++) for (let i = 0; i < 7; i++) { + fireBossBullet(e, strikeA + (i - 3) * 0.16 + v * 0.08, 330 - v * 35, 'bullet_cleaver', { life: 2.5 }); + } + e.chargeAng = strikeA + Math.PI; e.attackT = 0.4; e.actionT = 0; + Sfx.boss(e, 'flay'); + } + } else if (e.state === 'strike') { + e.vx = Math.cos(e.chargeAng) * 380 * spdMul; + e.vy = Math.sin(e.chargeAng) * 380 * spdMul; + if (e.stateT <= 0) { + e.blinkCount--; + if (e.blinkCount > 0) { + e.state = 'fade'; e.stateT = 0.35; e.oldX = e.x; e.oldY = e.y; Sfx.boss(e, 'blink'); + } else e.state = 'idle'; + } + } +} + +function spawnHookRing(e, count, radius, spin) { + for (let i = 0; i < count; i++) { + const b = { + x: e.x, y: e.y, vx: 0, vy: 0, r: 7, dmg: 1, life: 20, t: 0, + sprite: 'bullet_harpoon', behavior: 'orbit', anchor: e, + orbA: (i / count) * TAU, orbR: radius, orbTargetR: radius, + orbSpd: spin, releaseSpd: 300, + }; + G.ebullets.push(b); + } +} + +// --- Hook Choir: chained hook rings wind outward then launch tangentially --- +function updateHookChoir(e, dt, a, d, spdMul, enraged) { + e.atkT -= dt; e.atkT2 -= dt; e.stateT -= dt; e.hookRespawnT -= dt; + e.vx += Math.cos(a) * e.spd * spdMul * 2.5 * dt; + e.vy += Math.sin(a) * e.spd * spdMul * 2.5 * dt; + const liveHooks = G.ebullets.filter(b => b.behavior === 'orbit' && b.anchor === e); + if (!liveHooks.length && e.hookRespawnT <= 0) { + spawnHookRing(e, 4 + Math.min(e.tier, 3), 70, (enraged ? 2.65 : 1.9)); + if (e.tier >= 1) spawnHookRing(e, 4 + Math.min(e.tier, 2), 110, -(enraged ? 2.25 : 1.6)); + } + if (e.state === 'idle' && e.atkT <= 0) { + e.state = 'wind'; e.stateT = 0.7; e.atkT = enraged ? 2.8 : 4; + for (const b of liveHooks) { b.orbTargetR = 170; b.orbSpd *= 1.75; } + Sfx.boss(e, 'chains'); + } else if (e.state === 'wind' && e.stateT <= 0) { + e.state = 'idle'; e.hookRespawnT = 1.2; + for (const b of liveHooks) { + const releaseAng = b.orbA + Math.sign(b.orbSpd) * Math.PI / 2; + b.behavior = null; b.anchor = null; b.homing = e.tier >= 3 ? 0.8 : 0; + b.vx = Math.cos(releaseAng) * 300; b.vy = Math.sin(releaseAng) * 300; + } + Sfx.boss(e, 'release'); + } + if (e.atkT2 <= 0) { + e.atkT2 = 2.8; + for (let i = -1; i <= 1; i++) fireBossBullet(e, a + i * 0.18, 260, 'bullet_harpoon', { life: 3 }); + } +} + +function spawnPlateStomp(e) { + const rings = e.tier >= 3 ? [120, 200] : [120]; + for (const radius of rings) for (let i = 0; i < 6; i++) { + const ang = (i / 6) * TAU + (radius > 120 ? Math.PI / 6 : 0); + G.hazards.push({ kind: 'fire', hostile: true, owner: e, + x: e.x + Math.cos(ang) * radius, y: e.y + Math.sin(ang) * radius, + r: 32, life: 3.5, t: 0, tick: 0, tickRate: 0.45, dmg: 1, dps: 0 }); + } + addShake(9); Sfx.boss(e, 'stomp'); +} + +// --- Plate Father: breakable armor, projectile walls, tallow stomps --- +function updatePlateFather(e, dt, a, d, spdMul, enraged) { + e.atkT -= dt; e.stateT -= dt; + e.vx += Math.cos(a) * e.spd * spdMul * 2.4 * dt; + e.vy += Math.sin(a) * e.spd * spdMul * 2.4 * dt; + if (e.state === 'stomp') { + e.vx = 0; e.vy = 0; + if (e.stateT <= 0) { spawnPlateStomp(e); e.state = 'idle'; } + return; + } + if (e.atkT <= 0) { + e.atkT = enraged ? 2 : 3; e.atkCount++; + if (e.atkCount % 2 === 0) { + e.state = 'stomp'; e.stateT = 0.7; e.attackT = 0.7; e.actionT = 0; + } else { + const count = 20, gap = e.tier >= 1 ? 2 : 3; + const gapStart = irand(0, count - 1), off = rand(0, TAU); + fireBossRing(e, count, 150, 'bullet_gore', off, { life: 4.5 }, gapStart, gap); + if (e.tier >= 2) fireBossRing(e, count, 125, 'bullet_gore', off + Math.PI / count, { life: 5 }, (gapStart + 7) % count, gap); + e.attackT = 0.45; e.actionT = 0; Sfx.boss(e, 'wall'); + } + } +} + +// --- Auger Prime: rotating beam arms and accelerating saw spit --- +function updateAugerPrime(e, dt, a, d, spdMul, enraged) { + e.atkT -= dt; e.stateT -= dt; + if (e.state === 'idle') { + e.vx += Math.cos(a) * e.spd * spdMul * 2.7 * dt; + e.vy += Math.sin(a) * e.spd * spdMul * 2.7 * dt; + if (e.atkT <= 0) { + e.atkT = enraged ? 2.4 : 3.6; e.state = 'spool'; e.stateT = enraged ? 0.5 : 0.8; + e.chargeAng = a; e.vx = 0; e.vy = 0; Sfx.boss(e, 'spool'); + } + } else if (e.state === 'spool') { + e.vx = 0; e.vy = 0; + if (e.stateT <= 0) { + e.state = 'sweep'; e.stateT = 2.2; + const arms = e.tier >= 3 ? 3 : (e.tier >= 1 ? 2 : 1); + G.telegraphs.push({ kind: 'sweep', owner: e, ang: e.chargeAng, + spin: (chance(0.5) ? -1 : 1) * (e.tier >= 2 ? 1.65 : 1.15), + len: 420, w: 26, arms, t: 0, dur: 2.2, tick: 0 }); + for (let i = -1; i <= 1; i++) fireBossBullet(e, e.chargeAng + i * 0.22, 150, 'bullet_saw', { accel: 260, life: 2.6, bounces: 1 }); + Sfx.boss(e, 'sweep'); + } + } else if (e.state === 'sweep') { + e.vx = 0; e.vy = 0; + if (e.stateT <= 0) e.state = 'idle'; + } +} + +// --- The Scald: steam vents, hostile tallow pools, stalling burst rings --- +function updateScald(e, dt, a, d, spdMul, enraged) { + const p = G.player; + e.atkT -= dt; + e.vx += Math.cos(a) * e.spd * spdMul * 2.4 * dt; + e.vy += Math.sin(a) * e.spd * spdMul * 2.4 * dt; + if (e.atkT <= 0) { + e.atkT = enraged ? 1.9 : 2.8; e.atkCount++; + const n = e.tier >= 2 ? 7 : 3 + Math.min(e.tier, 2); + const radius = e.tier >= 1 ? 58 : 46; + const activePools = G.hazards.filter(h => h.hostile).length + G.telegraphs.filter(t => t.kind === 'pool').length; + const availablePools = Math.max(0, 14 - activePools); + for (let i = 0; i < Math.min(n, availablePools); i++) { + const tx = i === 0 ? p.x : p.x + rand(-180, 180); + const ty = i === 0 ? p.y : p.y + rand(-150, 150); + G.telegraphs.push({ kind: 'pool', owner: e, + x: clamp(tx, WALL + radius, W - WALL - radius), y: clamp(ty, WALL + radius, H - WALL - radius), + r: radius, t: 0, dur: 0.6, life: enraged ? 7 : 5 }); + } + e.attackT = 0.45; e.actionT = 0; Sfx.boss(e, 'vent'); + if (e.atkCount % 3 === 0) { + fireBossRing(e, 16, 220, 'bullet_steam', rand(0, TAU), { accel: -60, life: 3.4, r: 8 }); + if (e.tier >= 3) fireBossRing(e, 16, 155, 'bullet_steam', rand(0, TAU), { accel: -35, life: 4.2, r: 8 }); + addShake(7); Sfx.boss(e, 'burst'); + } + } +} + +// telegraphs resolve here (knives erupting, lingering blades) +function updateTelegraphs(dt) { + const p = G.player; + for (let i = G.telegraphs.length - 1; i >= 0; i--) { + const t = G.telegraphs[i]; + t.t += dt; + if (t.kind === 'sweep') { + if (!t.owner || t.owner.hp <= 0 || t.t >= t.dur) { G.telegraphs.splice(i, 1); continue; } + t.ang += t.spin * dt; t.tick -= dt; + let hit = false; + for (let arm = 0; arm < t.arms; arm++) { + const ang = t.ang + (arm / t.arms) * TAU; + const ex = t.owner.x + Math.cos(ang) * t.len; + const ey = t.owner.y + Math.sin(ang) * t.len; + if (distToSegment(p.x, p.y, t.owner.x, t.owner.y, ex, ey) < t.w / 2 + p.r) hit = true; + } + if (hit && t.tick <= 0) { t.tick = 0.5; hurtPlayer(1, angleTo(t.owner.x, t.owner.y, p.x, p.y)); } + continue; + } + if (t.kind === 'pool') { + if (t.t >= t.dur) { + G.hazards.push({ kind: 'acid', hostile: true, owner: t.owner, + x: t.x, y: t.y, r: t.r, life: t.life, t: 0, tick: 0, tickRate: 0.4, dmg: 1, dps: 0 }); + for (let k = 0; k < 10; k++) addParticle({ type: 'smoke', x: t.x + rand(-t.r, t.r), y: t.y + rand(-t.r, t.r), vx: rand(-15, 15), vy: -rand(40, 100), life: 0.5, t: 0, r: rand(3, 7) }); + G.telegraphs.splice(i, 1); + } + continue; + } + // hunting telegraphs track the player briefly before locking + if (t.kind === 'knives' && t.track > 0) { + t.track -= dt; + const ta = angleTo(t.x, t.y, p.x, p.y); + const ts = 140 * dt; + t.x = clamp(t.x + Math.cos(ta) * ts, WALL + 20, W - WALL - 20); + t.y = clamp(t.y + Math.sin(ta) * ts, WALL + 20, H - WALL - 20); + } + if (t.kind === 'knives' && t.t >= t.dur) { + // erupt + if (dist(t.x, t.y, p.x, p.y) < t.r + p.r) hurtPlayer(1, angleTo(t.x, t.y, p.x, p.y)); + for (let k = 0; k < 8; k++) { + addParticle({ type: 'spark', x: t.x + rand(-t.r, t.r), y: t.y + rand(-t.r, t.r), vx: 0, vy: -rand(60, 160), life: 0.3, t: 0, r: 2 }); + } + Sfx.boss(G.boss || { bossKind: 'knifecrawl', x: t.x, y: t.y }, 'knives'); + t.kind = 'linger'; t.t = 0; t.dur = 1.1; t.tick = 0; + } else if (t.kind === 'linger') { + t.tick -= dt; + if (t.tick <= 0) { + t.tick = 0.4; + if (dist(t.x, t.y, p.x, p.y) < t.r) hurtPlayer(1, angleTo(t.x, t.y, p.x, p.y)); + } + if (t.t >= t.dur) G.telegraphs.splice(i, 1); + } + } +} + +function drawTelegraphs(ctx) { + for (const t of G.telegraphs) { + if (t.kind === 'sweep') { + if (!t.owner) continue; + const pulse = 0.65 + Math.sin(t.t * 24) * 0.2; + for (let arm = 0; arm < t.arms; arm++) { + const ang = t.ang + (arm / t.arms) * TAU; + const ex = t.owner.x + Math.cos(ang) * t.len; + const ey = t.owner.y + Math.sin(ang) * t.len; + ctx.globalAlpha = 0.24 * pulse; ctx.strokeStyle = '#ff442e'; ctx.lineWidth = t.w; + ctx.beginPath(); ctx.moveTo(t.owner.x, t.owner.y); ctx.lineTo(ex, ey); ctx.stroke(); + ctx.globalAlpha = 0.85; ctx.strokeStyle = '#ffd36a'; ctx.lineWidth = 3; + ctx.beginPath(); ctx.moveTo(t.owner.x, t.owner.y); ctx.lineTo(ex, ey); ctx.stroke(); + } + ctx.globalAlpha = 1; + } else if (t.kind === 'pool') { + const k = clamp(t.t / t.dur, 0, 1); + ctx.globalAlpha = 0.2 + k * 0.35; ctx.fillStyle = '#b3ca32'; + ctx.beginPath(); ctx.arc(t.x, t.y, t.r * (0.7 + k * 0.3), 0, TAU); ctx.fill(); + ctx.globalAlpha = 0.8; ctx.strokeStyle = k > 0.75 ? '#fff1a0' : '#d5e64c'; ctx.lineWidth = 2 + k * 3; + ctx.beginPath(); ctx.arc(t.x, t.y, t.r * (1 - k * 0.15), 0, TAU); ctx.stroke(); + ctx.globalAlpha = 1; + } else if (t.kind === 'knives') { + const k = t.t / t.dur; + const pulse = 0.7 + Math.sin(t.t * 26) * 0.3; + ctx.globalAlpha = 0.25 + k * 0.45; + ctx.strokeStyle = k > 0.72 ? '#ffdd9a' : '#ff3550'; ctx.lineWidth = 2 + k * 3; + ctx.beginPath(); ctx.arc(t.x, t.y, t.r * (1 - k * 0.4), 0, TAU); ctx.stroke(); + ctx.globalAlpha = (0.2 + k * 0.35) * pulse; + ctx.beginPath(); ctx.arc(t.x, t.y, t.r + 5, 0, TAU); ctx.stroke(); + ctx.globalAlpha = 0.15 + k * 0.3; + ctx.fillStyle = '#e51f3b'; + ctx.beginPath(); ctx.arc(t.x, t.y, t.r * (1 - k * 0.4), 0, TAU); ctx.fill(); + ctx.globalAlpha = 1; + } else if (t.kind === 'linger') { + ctx.globalAlpha = 0.8 * (1 - t.t / t.dur); + for (let k = 0; k < 4; k++) { + const a = (k / 4) * TAU + 0.4; + Sprites.draw(ctx, 'bullet_cleaver', t.x + Math.cos(a) * t.r * 0.5, t.y + Math.sin(a) * t.r * 0.5, a + Math.PI / 2, 24); + } + ctx.globalAlpha = 1; + } + } +} + +function onBossDeath(e) { + G.boss = null; + Sfx.boss(e, 'death'); + G.telegraphs = []; + G.ebullets = []; + G.hazards = G.hazards.filter(h => !h.hostile); + // Isaac-style: boss death ends the fight. Minions are swept without score or drops. + for (let i = G.enemies.length - 1; i >= 0; i--) { + const m = G.enemies[i]; + if (m === e || m.boss) continue; + spawnBlood(m.x, m.y, rand(0, TAU), 8); + spawnGibs(m.x, m.y, 3); + spawnCorpse(m); + m.dead = true; + m.hp = 0; + } + addScore(500 + G.floor * 100); + addShake(12); + // boss reward: item pedestal + a real weapon drop + stairs down + spawnItemPedestal(W / 2 - 90, H / 2); + const bw = rollWeaponDrop(G.floor + 2); // bosses drop the good stuff + spawnPickup('weapon', W / 2 + 90, H / 2, { wid: bw.id }); + spawnPickup('stairs', W / 2, H / 2 + 80); + addToast(e.name + ' DESTROYED', 'take the meat, take the stairs'); + Music.requestFloorMusic(); +} diff --git a/js/bullets.js b/js/bullets.js new file mode 100644 index 0000000..20ec078 --- /dev/null +++ b/js/bullets.js @@ -0,0 +1,297 @@ +// ---- projectile simulation (player bullets + enemy bullets) ---- + +const PROJECTILE_DRAW_SIZE = { + bullet_bone: 16, bullet_saw: 24, bullet_cleaver: 24, + bullet_harpoon: 32, bullet_eye: 24, bullet_syringe: 24, bullet_gore: 24, + bullet_steam: 24, +}; + +function nearestEnemy(x, y, maxD) { + let best = null, bd = maxD * maxD; + for (const e of G.enemies) { + if (e.phased) continue; + const d = dist2(x, y, e.x, e.y); + if (d < bd) { bd = d; best = e; } + } + return best; +} + +function updateBullets(dt) { + const p = G.player; + if (G.bullets.length > 520) G.bullets.splice(0, G.bullets.length - 520); + if (G.ebullets.length > 640) G.ebullets.splice(0, G.ebullets.length - 640); + for (let i = G.bullets.length - 1; i >= 0; i--) { + const b = G.bullets[i]; + // Sweep the first frame from the player's pivot to the per-weapon muzzle, + // then sweep between simulation positions so long/fast weapons cannot skip + // enemies standing inside the visible barrel or between frames. + const prevX = b.spawnX === undefined ? b.x : b.spawnX; + const prevY = b.spawnY === undefined ? b.y : b.spawnY; + delete b.spawnX; delete b.spawnY; + b.t += dt; + let dead = false; + + // homing steer + if (b.homing) { + const e = nearestEnemy(b.x, b.y, 320); + if (e) { + const want = angleTo(b.x, b.y, e.x, e.y); + const cur = Math.atan2(b.vy, b.vx); + const na = angleLerp(cur, want, clamp(b.homing * dt, 0, 1)); + const sp = Math.hypot(b.vx, b.vy); + b.vx = Math.cos(na) * sp; b.vy = Math.sin(na) * sp; + } + } + + // boomerang: decelerate then fly back to player + if (b.behavior === 'boomerang') { + b.phase += dt; + if (b.phase > 0.45) { + const a = angleTo(b.x, b.y, p.x, p.y); + const sp = Math.min(Math.hypot(b.vx, b.vy) + 900 * dt, 520); + b.vx = Math.cos(a) * sp; b.vy = Math.sin(a) * sp; + if (dist2(b.x, b.y, p.x, p.y) < 400) dead = true; // caught + } else { + b.vx *= 0.985; b.vy *= 0.985; + } + b.ang += 14 * dt; + } + + b.x += b.vx * dt; b.y += b.vy * dt; + if (b.behavior === 'bounce' || b.behavior === 'pierce_drag') b.ang += 12 * dt; + + // walls + const outL = b.x < WALL + 4, outR = b.x > W - WALL - 4, outU = b.y < WALL + 4, outD = b.y > H - WALL - 4; + if (outL || outR || outU || outD) { + const bouncesLeft = (b.bounces || 0) + (b.bounce || 0); + if (bouncesLeft > 0) { + if (outL || outR) b.vx = -b.vx; + if (outU || outD) b.vy = -b.vy; + b.x = clamp(b.x, WALL + 4, W - WALL - 4); + b.y = clamp(b.y, WALL + 4, H - WALL - 4); + if (b.bounces > 0) b.bounces--; else if (b.bounce > 0) b.bounce--; + b.ang = Math.atan2(b.vy, b.vx); + spawnSpark(b.x, b.y, b.ang); + Sfx.ricochet({ x: b.x, y: b.y }); + } else if (b.lobbed) { + dead = true; // fizzles on wall + } else { + spawnSpark(b.x, b.y, Math.atan2(b.vy, b.vx)); + Sfx.wallHit({ x: b.x, y: b.y }); + dead = true; + } + } + + // expired + if (!dead && b.t >= b.life) { + dead = true; + // bile congeals into acid pools + if (b.behavior === 'cone' && chance(0.35)) { + G.hazards.push({ kind: 'acid', x: b.x, y: b.y, r: 24 * (b.sizeMul || 1), life: 3.5, t: 0, dps: b.dmg * 1.5 }); + } + } + + // lobbed projectiles trigger on expiry (reached target) + if (dead && b.lobbed && !b._triggered) { + b._triggered = true; + lobTrigger(b); + } + + // enemy collision + if (!dead) { + for (const e of G.enemies) { + if (e.hp <= 0 || e.phased || (b.hit && b.hit.has(e))) continue; + if (e === b.dragTarget) continue; + if (distToSegment(e.x, e.y, prevX, prevY, b.x, b.y) < b.r + e.r) { + const a = Math.atan2(b.vy, b.vx); + const killed = damageEnemy(e, b.dmg, a, true, { source: 'player', bullet: b }); + if (!b.hit) b.hit = new Set(); + b.hit.add(e); + spawnBlood(b.x, b.y, a, 3); + if (b.behavior === 'flame') { e.burnT = Math.max(e.burnT, 2.2); e.burnDps = Math.max(e.burnDps || 0, b.dmg * 2); } + if (b.behavior === 'cone') { + e.slowT = Math.max(e.slowT, 1.6); + if (G.hazards.length < 80 && chance(0.45)) G.hazards.push({ kind: 'acid', x: b.x, y: b.y, r: 24 * (b.sizeMul || 1), life: 3.5, t: 0, dps: b.dmg * 1.5 }); + } + // Hemophage remains an on-hit drain, naturally limited by its fire interval. + if (b.lifesteal && p.hp < p.stats.maxHp && chance(b.lifesteal * 0.25)) lifestealPlayer(1); + if (b.behavior === 'pierce_drag' && !b.dragTarget && !e.boss) { + b.dragTarget = e; e.dragged = b; + } + if (b.pierce > 0) { b.pierce--; } + else if (b.behavior === 'boomerang' || b.behavior === 'pierce_drag') { /* pass through */ } + else { + dead = true; break; + } + } + } + } + + // drag victim along with the harpoon + if (b.dragTarget) { + if (b.dragTarget.hp <= 0 || dead) b.dragTarget.dragged = null, b.dragTarget = null; + else { + b.dragTarget.x = b.x; b.dragTarget.y = b.y; + if (b.t >= b.life) { damageEnemy(b.dragTarget, b.dmg * 0.5, b.ang, true); b.dragTarget.dragged = null; } + } + } + + if (dead) G.bullets.splice(i, 1); + } + + // ---- enemy bullets ---- + for (let i = G.ebullets.length - 1; i >= 0; i--) { + const b = G.ebullets[i]; + b.t += dt; + if (b.behavior === 'orbit') { + if (!b.anchor || b.anchor.hp <= 0) { + b.behavior = null; + const releaseAng = (b.orbA || 0) + Math.sign(b.orbSpd || 1) * Math.PI / 2; + const releaseSpd = b.releaseSpd || 280; + b.vx = Math.cos(releaseAng) * releaseSpd; + b.vy = Math.sin(releaseAng) * releaseSpd; + b.anchor = null; + } else { + b.orbA += b.orbSpd * dt; + if (b.orbTargetR !== undefined) b.orbR = lerp(b.orbR, b.orbTargetR, clamp(dt * 5, 0, 1)); + b.x = b.anchor.x + Math.cos(b.orbA) * b.orbR; + b.y = b.anchor.y + Math.sin(b.orbA) * b.orbR; + } + } + if (b.behavior !== 'orbit') { + if (b.homing) { + const want = angleTo(b.x, b.y, p.x, p.y); + const cur = Math.atan2(b.vy, b.vx); + const ang = angleLerp(cur, want, clamp(b.homing * dt, 0, 1)); + const sp = Math.hypot(b.vx, b.vy); + b.vx = Math.cos(ang) * sp; b.vy = Math.sin(ang) * sp; + } + if (b.accel) { + const sp = Math.max(0, Math.hypot(b.vx, b.vy) + b.accel * dt); + const ang = Math.atan2(b.vy, b.vx); + b.vx = Math.cos(ang) * sp; b.vy = Math.sin(ang) * sp; + } + b.x += b.vx * dt; b.y += b.vy * dt; + } + let dead = b.t >= b.life; + if (b.behavior !== 'orbit') { + const outL = b.x < WALL + 4, outR = b.x > W - WALL - 4; + const outU = b.y < WALL + 4, outD = b.y > H - WALL - 4; + if (outL || outR || outU || outD) { + if (b.bounces > 0) { + if (outL || outR) b.vx = -b.vx; + if (outU || outD) b.vy = -b.vy; + b.x = clamp(b.x, WALL + 4, W - WALL - 4); + b.y = clamp(b.y, WALL + 4, H - WALL - 4); + b.bounces--; + } else dead = true; + } + } + if (!dead && p.hp > 0 && dist2(b.x, b.y, p.x, p.y) < (b.r + p.r) * (b.r + p.r)) { + hurtPlayer(b.dmg, Math.atan2(b.vy, b.vx)); + dead = true; + } + if (dead) G.ebullets.splice(i, 1); + } +} + +function lobTrigger(b) { + if (b.behavior === 'lob') { // Flesh Masher: meat bomb + const radius = 85 * (b.rangeMul || 1) * Math.sqrt(b.sizeMul || 1); + areaDamage(b.x, b.y, radius, b.dmg, true, { source: 'player' }); + spawnExplosionFx(b.x, b.y, radius); + spawnBlood(b.x, b.y, 0, 16, true); + Sfx.explode({ x: b.x, y: b.y }); + } else if (b.behavior === 'lob_trap') { // Trap Queen + G.hazards.push({ kind: 'trap', x: b.x, y: b.y, r: 16 * (b.sizeMul || 1), life: 12 * (b.rangeMul || 1), t: 0, dmg: b.dmg }); + Sfx.trapSet({ x: b.x, y: b.y }); + } else if (b.behavior === 'lob_swarm') { // Swarm Jar: homing maggots + const st = G.player.stats; + for (let i = 0; i < 6 + st.split + st.fan; i++) { + const a = rand(0, TAU); + G.bullets.push({ + x: b.x, y: b.y, ang: a, + vx: Math.cos(a) * 260, vy: Math.sin(a) * 260, + r: 4 * st.sizeMul, dmg: b.dmg, pierce: st.pierce, bounce: st.bounce, life: 2.2 * st.rangeMul, t: 0, + behavior: 'maggot', sprite: 'bullet_gore', homing: 8 + st.homing * 0.7, sizeMul: st.sizeMul, + }); + } + spawnBlood(b.x, b.y, 0, 8); + Sfx.spit({ x: b.x, y: b.y }); + } +} + +function drawBullets(ctx) { + for (const b of G.bullets) { + let rot = b.ang; + const sp = Math.hypot(b.vx || 0, b.vy || 0); + if (sp > 20 && b.behavior !== 'boomerang') { + const nx = b.vx / sp, ny = b.vy / sp; + ctx.globalAlpha = 0.18; + ctx.strokeStyle = b.behavior === 'cone' ? '#8ee84a' : + (b.behavior === 'flame' ? '#ff8a2a' : + (b.behavior === 'pierce_drag' ? '#b35b42' : + (b.behavior === 'homing' ? '#df6b73' : '#f4dfbc'))); + ctx.lineWidth = Math.max(2, b.r * 0.7); + ctx.beginPath(); ctx.moveTo(b.x, b.y); ctx.lineTo(b.x - nx * Math.min(22, sp * 0.04), b.y - ny * Math.min(22, sp * 0.04)); ctx.stroke(); + ctx.globalAlpha = 1; + } + if (b.behavior === 'flame') { + const life = 1 - b.t / b.life; + ctx.globalAlpha = 0.24 * life; + ctx.fillStyle = '#b01818'; ctx.beginPath(); ctx.arc(b.x, b.y, b.r + 7, 0, TAU); ctx.fill(); + ctx.globalAlpha = 0.8 * life; + ctx.fillStyle = '#ff8020'; ctx.beginPath(); ctx.arc(b.x, b.y, b.r + 3, 0, TAU); ctx.fill(); + ctx.fillStyle = '#fff0a0'; ctx.beginPath(); ctx.arc(b.x, b.y, Math.max(2, b.r * 0.45), 0, TAU); ctx.fill(); + ctx.globalAlpha = 1; + continue; + } + if (b.behavior === 'cone') { + const life = 1 - b.t / b.life; + ctx.globalAlpha = 0.2 * life; + ctx.fillStyle = '#b9ec55'; ctx.beginPath(); ctx.arc(b.x, b.y, b.r + 6, 0, TAU); ctx.fill(); + ctx.globalAlpha = 0.75 * life; + ctx.fillStyle = '#70c030'; ctx.beginPath(); ctx.arc(b.x, b.y, b.r + 2, 0, TAU); ctx.fill(); + ctx.fillStyle = '#d6ff78'; ctx.beginPath(); ctx.arc(b.x - 2, b.y - 2, 1.6, 0, TAU); ctx.fill(); + ctx.globalAlpha = 1; + continue; + } + if (b.behavior === 'maggot') { + ctx.fillStyle = '#d0d8b0'; + ctx.save(); ctx.translate(b.x, b.y); ctx.rotate(Math.atan2(b.vy, b.vx)); + ctx.beginPath(); ctx.ellipse(0, 0, 4.5, 2.4, 0, 0, TAU); ctx.fill(); + ctx.fillStyle = '#6b4934'; ctx.fillRect(2.5, -1, 2, 2); ctx.restore(); + continue; + } + if (b.sprite === 'bullet_saw') rot = b.t * 18; + Sprites.draw(ctx, b.sprite, b.x, b.y, rot, (PROJECTILE_DRAW_SIZE[b.sprite] || 24) * Math.sqrt(b.sizeMul || 1)); + if (b.sprite === 'bullet_saw') { + ctx.globalAlpha = 0.35; + ctx.strokeStyle = '#ffd67d'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.arc(b.x, b.y, 11 + Math.sin(b.t * 28) * 2, 0, TAU); ctx.stroke(); + ctx.globalAlpha = 1; + } else if (b.sprite === 'bullet_harpoon' && sp > 20) { + const nx = b.vx / sp, ny = b.vy / sp; + ctx.globalAlpha = 0.5; ctx.strokeStyle = '#6f4b3d'; ctx.lineWidth = 2; + ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(b.x - nx * 7, b.y - ny * 7); ctx.lineTo(b.x - nx * 30, b.y - ny * 30); ctx.stroke(); + ctx.setLineDash([]); ctx.globalAlpha = 1; + } else if (b.sprite === 'bullet_cleaver') { + ctx.globalAlpha = 0.28; ctx.strokeStyle = '#f0c8b2'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(b.x, b.y, 13, rot - 1.1, rot + 0.4); ctx.stroke(); ctx.globalAlpha = 1; + } + } + for (const b of G.ebullets) { + if (b.behavior === 'orbit' && b.anchor && b.anchor.hp > 0) { + ctx.globalAlpha = 0.38; + ctx.strokeStyle = '#7b5141'; ctx.lineWidth = 2; + ctx.setLineDash([4, 3]); + ctx.beginPath(); ctx.moveTo(b.anchor.x, b.anchor.y); ctx.lineTo(b.x, b.y); ctx.stroke(); + ctx.setLineDash([]); ctx.globalAlpha = 1; + } + ctx.globalAlpha = 0.16; + ctx.fillStyle = '#ff3650'; ctx.beginPath(); ctx.arc(b.x, b.y, b.r * 1.9, 0, TAU); ctx.fill(); + ctx.globalAlpha = 1; + const sprite = b.sprite || 'bullet_gore'; + Sprites.draw(ctx, sprite, b.x, b.y, b.t * 6, PROJECTILE_DRAW_SIZE[sprite] || 24); + } +} diff --git a/js/enemies.js b/js/enemies.js new file mode 100644 index 0000000..29bd0c7 --- /dev/null +++ b/js/enemies.js @@ -0,0 +1,434 @@ +// ---- enemy types, AI, damage & death ---- + +const ENEMY_TYPES = { + shambler: { hp: 26, spd: 55, dmg: 1, r: 18, drawSize: 64, xp: 1, sprite: 'enemy_shambler' }, + runner: { hp: 12, spd: 236, dmg: 1, r: 15, drawSize: 64, xp: 1, sprite: 'enemy_runner' }, + spitter: { hp: 20, spd: 60, dmg: 1, r: 18, drawSize: 64, xp: 2, sprite: 'enemy_spitter', ranged: true }, + splitter: { hp: 30, spd: 70, dmg: 1, r: 21, drawSize: 64, xp: 1, sprite: 'enemy_splitter', splits: true }, + mini: { hp: 6, spd: 120, dmg: 1, r: 10, drawSize: 32, xp: 1, sprite: 'enemy_mini' }, + exploder: { hp: 14, spd: 110, dmg: 2, r: 16, drawSize: 64, xp: 2, sprite: 'enemy_exploder', explodes: true }, +}; +const MAX_ENEMIES = 72; + +function makeEnemy(type, x, y, floor, elite) { + const t = ENEMY_TYPES[type]; + const up = powerScore(); + // floor scaling × player-power scaling (+5% hp / +1% speed per upgrade) + const fscale = (1 + (floor - 1) * 0.22) * (1 + up * 0.05); + const sscale = 1 + (floor - 1) * 0.04 + up * 0.01; + const e = { + type, x, y, vx: 0, vy: 0, + r: t.r * (elite ? 1.45 : 1), + drawSize: t.drawSize * (elite ? 1.5 : 1), + hp: t.hp * fscale * (elite ? 2.6 : 1) * G.pressure, + maxHp: t.hp * fscale * (elite ? 2.6 : 1) * G.pressure, + spd: t.spd * sscale * (elite ? 0.9 : 1) * rand(0.9, 1.1) * G.pressure, + dmg: t.dmg + (elite ? 1 : 0), + xp: t.xp * (elite ? 3 : 1), + sprite: t.sprite, + elite: !!elite, + hitT: 0, flash: 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, + spawnT: 0, dead: false, + boss: false, + }; + return e; +} + +// pick an enemy type for a floor-scaled wave +function pickEnemyType(floor) { + const table = [ + ['shambler', 40], + ['runner', floor >= 1 ? 25 : 0], + ['spitter', floor >= 2 ? 18 : 0], + ['splitter', floor >= 2 ? 16 : 0], + ['exploder', floor >= 3 ? 14 : 0], + ]; + const total = table.reduce((s, t) => s + t[1], 0); + let r = Math.random() * total; + for (const [type, w] of table) { r -= w; if (r <= 0) return type; } + return 'shambler'; +} + +// true if (x,y) is within keep-out range of any of the current room's doorways +function nearAnyDoor(x, y) { + const r = G.cur; + if (!r) return false; + const M = 130; // spawn keep-out radius around doors + if (r.doors.n && Math.abs(x - W / 2) < DOOR_HALF + 30 && y < WALL + M) return true; + if (r.doors.s && Math.abs(x - W / 2) < DOOR_HALF + 30 && y > H - WALL - M) return true; + if (r.doors.e && Math.abs(y - H / 2) < DOOR_HALF + 30 && x > W - WALL - M) return true; + if (r.doors.w && Math.abs(y - H / 2) < DOOR_HALF + 30 && x < WALL + M) return true; + return false; +} + +function spawnPosAwayFromPlayer() { + const p = G.player; + for (let tries = 0; tries < 30; tries++) { + const x = rand(WALL + 40, W - WALL - 40); + const y = rand(WALL + 40, H - WALL - 40); + if (dist(x, y, p.x, p.y) <= 200) continue; + if (nearAnyDoor(x, y)) continue; + return { x, y }; + } + // fallback: center of the room, away from every door + return { x: rand(W / 2 - 150, W / 2 + 150), y: rand(H / 2 - 120, H / 2 + 120) }; +} + +function spawnWave(count, floor) { + for (let i = 0; i < count; i++) { + if (G.enemies.length >= MAX_ENEMIES) break; + const pos = spawnPosAwayFromPlayer(); + const elite = chance(Math.min(0.03 + floor * 0.02, 0.2)); + const e = makeEnemy(pickEnemyType(floor), pos.x, pos.y, floor, elite); + G.enemies.push(e); + spawnBlood(pos.x, pos.y, rand(0, TAU), 4); + Sfx.spawn(e); + } +} + +// returns true if the enemy died +function damageEnemy(e, dmg, ang, knockback, opts) { + if (e.hp <= 0) return true; + if (e.phased) return false; + if (!(dmg > 0)) return false; // NaN / zero / negative damage does nothing + // crit roll + const p = G.player; + const o = opts || {}; + const crit = !!(p && !o.noCrit && chance(p.stats.crit || 0)); + if (crit) dmg *= p.stats.critMul || 2; + const mitigated = e.dmgTakenMul !== undefined && e.dmgTakenMul < 1; + dmg *= e.dmgTakenMul === undefined ? 1 : e.dmgTakenMul; + e.hp -= dmg; + e.flash = 0.12; + spawnText(e.x, e.y, Math.max(1, Math.round(dmg)), mitigated ? '#d0b887' : '#ffb0a0'); + if (e.plates > 0) { + e.plateDmg = (e.plateDmg || 0) + dmg; + const threshold = e.maxHp * 0.06; + if (e.plateDmg >= threshold) { + e.plateDmg -= threshold; + e.plates--; + e.rootT = Math.max(e.rootT || 0, 0.45); + e.flash = 0.2; + addShake(6); + spawnGibs(e.x, e.y, 4, true); + spawnText(e.x, e.y - e.r, 'PLATE BROKEN', '#ffd080'); + Sfx.boss(e, 'plate'); + if (e.plates <= 0) { + e.dmgTakenMul = 1; + addToast('ARMOR BREACHED', 'Plate Father is exposed'); + Sfx.boss(e, 'enrage'); + } + } + } + if (knockback && !e.boss && !e.dragged) { + const kb = p ? p.stats.knockbackMul || 1 : 1; + e.vx += Math.cos(ang) * 90 * kb; e.vy += Math.sin(ang) * 90 * kb; + } + if (p && !o.noProc) procOnHit(e, dmg, ang, o); + Sfx.hit(e, crit); + if (e.hp <= 0) { killEnemy(e, ang); return true; } + return false; +} + +// Shared player-hit payload. Every weapon primitive routes through damageEnemy, +// so items remain useful for bullets, beams, slams, saws, traps and orbitals. +function procOnHit(e, dmg, ang, opts) { + const p = G.player, s = p.stats; + const chanceScale = opts.procScale === undefined ? 1 : opts.procScale; + const magnitudeScale = opts.procMagnitudeScale === undefined ? 1 : opts.procMagnitudeScale; + const intervalScale = opts.procIntervalScale === undefined ? 1 : opts.procIntervalScale; + const procDamage = dmg / Math.max(0.05, intervalScale) * magnitudeScale; + if (s.splinter > 0 && !(opts.bullet && opts.bullet.shard) && chance(clamp(chanceScale, 0, 1))) { + const count = Math.min(18, Math.floor(s.splinter)); + for (let i = 0; i < count; i++) { + const sa = rand(0, TAU); + G.bullets.push({ + x: e.x, y: e.y, ang: sa, + vx: Math.cos(sa) * 300 * s.shotSpeedMul, vy: Math.sin(sa) * 300 * s.shotSpeedMul, + r: 3 * s.sizeMul, dmg: procDamage * 0.4, pierce: 0, bounce: s.bounce, + life: 0.35 * s.rangeMul, t: 0, behavior: 'bullet', sprite: 'bullet_bone', + homing: s.homing ? 1.6 + s.homing * 0.7 : 0, shard: true, sizeMul: s.sizeMul, + }); + } + spawnSpark(e.x, e.y, ang); + } + if (s.bleed > 0) { + e.bleedT = Math.max(e.bleedT || 0, 2.2); + e.bleedDps = Math.max(e.bleedDps || 0, procDamage * s.bleed * 0.45); + } + if (s.igniteChance > 0 && chance(clamp(s.igniteChance * chanceScale, 0, 0.9))) { + e.burnT = Math.max(e.burnT || 0, 1.8); + e.burnDps = Math.max(e.burnDps || 0, 5 * s.dmgMul); + } + if (s.slowOnHit > 0 && chance(clamp(s.slowOnHit * chanceScale, 0, 0.9))) e.slowT = Math.max(e.slowT || 0, 1.5); + if (!e.boss && s.stunOnHit > 0 && chance(clamp(s.stunOnHit * chanceScale, 0, 0.75))) e.stunT = Math.max(e.stunT || 0, 0.35); + if (s.pullOnHit > 0 && !e.boss && chance(clamp(s.pullOnHit * chanceScale, 0, 0.8))) { + const toward = angleTo(e.x, e.y, p.x, p.y); + e.vx += Math.cos(toward) * 170; e.vy += Math.sin(toward) * 170; + } + if (s.acidOnHit > 0 && G.hazards.length < 80 && chance(clamp(s.acidOnHit * chanceScale, 0, 0.65))) { + G.hazards.push({ kind: 'acid', x: e.x, y: e.y, r: 18 * s.sizeMul, life: 2.5, t: 0, dps: 5 * s.dmgMul }); + } + if (s.chain > 0 && G.enemies.length > 1 && chance(clamp(chanceScale, 0, 1))) { + const hit = new Set([e]); + let from = e; + for (let i = 0; i < Math.min(6, Math.floor(s.chain)); i++) { + let next = null, best = 180 * 180; + for (const target of G.enemies) { + if (target.hp <= 0 || target.phased || hit.has(target)) continue; + const d2 = dist2(from.x, from.y, target.x, target.y); + if (d2 < best) { best = d2; next = target; } + } + if (!next) break; + hit.add(next); + damageEnemy(next, procDamage * 0.35, angleTo(from.x, from.y, next.x, next.y), false, { noProc: true, noCrit: true }); + spawnSpark(next.x, next.y, angleTo(from.x, from.y, next.x, next.y)); + from = next; + } + } + if (s.mortar > 0) { + p.mortarHits = (p.mortarHits || 0) + chanceScale; + const every = Math.max(2, 7 - Math.min(5, Math.floor(s.mortar))); + if (p.mortarHits >= every) { + p.mortarHits -= every; + const radius = 42 + s.mortar * 5; + for (const target of G.enemies) { + if (target.hp > 0 && target !== e && dist2(e.x, e.y, target.x, target.y) < radius * radius) { + damageEnemy(target, procDamage * 0.55, angleTo(e.x, e.y, target.x, target.y), true, { noProc: true, noCrit: true }); + } + } + spawnExplosionFx(e.x, e.y, radius); + } + } +} + +function tickEnemyDamage(e, amount) { + if (!(amount > 0) || e.hp <= 0) return; + e.hp -= amount * (e.dmgTakenMul === undefined ? 1 : e.dmgTakenMul); + if (e.hp <= 0) killEnemy(e, rand(0, TAU)); +} + +function killEnemy(e, ang) { + if (e.dead) return; + spawnCorpse(e); + e.dead = true; + e.hp = 0; + const p = G.player; + if (p.stats.frenzy > 0) p.frenzyT = Math.max(p.frenzyT || 0, 3); + G.kills++; + addScore(e.boss ? 500 : (e.elite ? 40 : 10)); + spawnBlood(e.x, e.y, ang || rand(0, TAU), e.boss ? 40 : (e.elite ? 20 : 10), e.boss || e.elite); + spawnGibs(e.x, e.y, e.boss ? 16 : (e.elite ? 8 : 4), e.boss || e.elite); + if (e.boss || e.elite) spawnShockwave(e.x, e.y, e.boss ? e.r * 2.4 : e.r * 1.7, '#b82a38', e.boss ? 0.9 : 0.55); + if (!e.boss) Sfx.enemyDie(e); + + // Vampire Dentures heal on kills, naturally normalizing slow and rapid weapons. + const stealChance = clamp((p.stats.lifestealChance || 0) * 3, 0, 0.9); + if (p.hp < p.stats.maxHp && stealChance && chance(stealChance)) { + lifestealPlayer(e.boss ? 4 : (e.elite ? 2 : 1)); + } + + if (e.boss) { onBossDeath(e); return; } + + // Volatile Bile: kills explode — radius and damage scale with tier + if (p.stats.explodeOnKill > 0) { + const rad = 60 + p.stats.explodeOnKill * 15; + areaDamage(e.x, e.y, rad, (10 + p.stats.explodeOnKill * 8) * p.stats.dmgMul, true); + spawnExplosionFx(e.x, e.y, rad); + Sfx.explode({ x: e.x, y: e.y }); + } + + // drops + const luck = p.stats.luck; + let gemVal = e.xp + (p.stats.bloodlust && chance(p.stats.bloodlust) ? e.xp : 0); + // Bloodrush keeps its XP multiplier and makes the bonus visible as extra crystals. + const xpBonusChance = clamp((p.stats.xpMul || 1) - 1, 0, 0.9); + if (xpBonusChance && chance(xpBonusChance)) gemVal += Math.max(1, Math.round(e.xp * 0.5)); + if (e.elite) { + // special monsters: always bonus XP, mostly ammo, sometimes an item + gemVal += irand(3, 5); + spawnGems(e.x, e.y, gemVal); + if (chance(0.18 + luck * 0.1)) { + spawnPickup('item', e.x, e.y, { iid: randomItemId() }); + } else if (chance(0.6 + luck * 0.2)) { + spawnPickup('ammo', e.x, e.y); + } + } else { + spawnGems(e.x, e.y, gemVal); + if (chance(0.07 + luck * 0.04)) { + spawnPickup('ammo', e.x, e.y); + } else if (chance(0.022 + luck * 0.03)) { + spawnPickup('heart', e.x, e.y); + } + } + + // splitter splits + if (ENEMY_TYPES[e.type].splits && !e.elite) { + Sfx.split(e); + for (let i = 0; i < 2 && G.enemies.length < MAX_ENEMIES; i++) { + const m = makeEnemy('mini', e.x + rand(-12, 12), e.y + rand(-12, 12), G.floor, false); + G.enemies.push(m); + } + } + // exploder goes boom + if (ENEMY_TYPES[e.type].explodes) explodeAt(e.x, e.y, 60, 2, false); +} + +function explodeAt(x, y, r, dmg, fromPlayer) { + spawnExplosionFx(x, y, r); + Sfx.explode({ x, y }); + if (fromPlayer) { + areaDamage(x, y, r, dmg, true); + } else { + const p = G.player; + if (dist(x, y, p.x, p.y) < r + p.r) hurtPlayer(dmg, angleTo(x, y, p.x, p.y)); + } +} + +function updateEnemies(dt) { + const p = G.player; + for (let i = G.enemies.length - 1; i >= 0; i--) { + const e = G.enemies[i]; + 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.attackT > 0) { e.attackT -= dt; e.actionT += dt; } + + // status effects + if (e.flash > 0) e.flash -= dt; + if (e.orbT > 0) e.orbT -= dt; + if (e.burnT > 0) { + e.burnT -= dt; + 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; + tickEnemyDamage(e, e.bleedDps * dt); + if (e.hp <= 0) { G.enemies.splice(i, 1); continue; } + } + if (e.slowT > 0) e.slowT -= dt; + if (e.stunT > 0) { e.stunT -= dt; e.vx = 0; e.vy = 0; continue; } + if (e.rootT > 0) { e.rootT -= dt; e.vx = 0; e.vy = 0; continue; } + if (e.dragged) { e.vx = 0; e.vy = 0; continue; } // harpooned + + if (e.boss) { updateBoss(e, dt); } + else updateEnemyAI(e, dt); + + // integrate + separation + e.x += e.vx * dt; e.y += e.vy * dt; + const drag = Math.pow(0.82, dt * 60); + e.vx *= drag; e.vy *= drag; + // Resolve each pair once instead of twice. This halves the hottest + // enemy-crowd loop while preserving the same mutual separation. + for (let j = i - 1; j >= 0; j--) { + const o = G.enemies[j]; + if (o.hp <= 0) continue; + const d2 = dist2(e.x, e.y, o.x, o.y); + const min = (e.r + o.r) * 0.9; + if (d2 < min * min && d2 > 0.01) { + const d = Math.sqrt(d2), push = (min - d) / d * 30 * dt; + const px = (e.x - o.x) * push, py = (e.y - o.y) * push; + e.x += px; e.y += py; o.x -= px; o.y -= py; + } + } + e.x = clamp(e.x, WALL + e.r, W - WALL - e.r); + e.y = clamp(e.y, WALL + e.r, H - WALL - e.r); + + // contact damage + if (e.hitT > 0) e.hitT -= dt; + if (!e.phased && e.hitT <= 0 && p.hp > 0 && dist2(e.x, e.y, p.x, p.y) < (e.r + p.r) * (e.r + p.r)) { + e.attackT = 0.3; e.actionT = 0; + hurtPlayer(e.dmg, angleTo(e.x, e.y, p.x, p.y), e); + e.hitT = 0.7; + } + } +} + +function updateEnemyAI(e, dt) { + const p = G.player; + const a = angleTo(e.x, e.y, p.x, p.y); + const d = dist(e.x, e.y, p.x, p.y); + const spd = e.slowT > 0 ? e.spd * 0.45 : e.spd; + const t = ENEMY_TYPES[e.type]; + + if (t.ranged) { + // spitter: hold ~230px range and fire + if (d > 250) { e.vx += Math.cos(a) * spd * 3 * dt; e.vy += Math.sin(a) * spd * 3 * dt; } + else if (d < 190) { e.vx -= Math.cos(a) * spd * 3 * dt; e.vy -= Math.sin(a) * spd * 3 * dt; } + e.fireT -= dt; + if (e.fireT <= 0 && d < 420) { + e.fireT = e.elite ? 1.1 : 1.9; + e.attackT = 0.34; e.actionT = 0; + const n = e.elite ? 3 : 1; + for (let k = 0; k < n; k++) { + const fa = a + (n > 1 ? (k - 1) * 0.25 : 0); + G.ebullets.push({ + x: e.x, y: e.y, vx: Math.cos(fa) * 220, vy: Math.sin(fa) * 220, + r: 5, dmg: 1, life: 3, t: 0, sprite: 'bullet_gore', + }); + } + Sfx.spit(e); + } + } else if (t.explodes) { + e.vx += Math.cos(a) * spd * 4 * dt; e.vy += Math.sin(a) * spd * 4 * dt; + if (e.fuse < 0 && d < 56) { e.fuse = 0.5; e.attackT = 0.5; e.actionT = 0; Sfx.fuse(e); } + if (e.fuse >= 0) { + e.fuse -= dt; + e.flash = 0.05; + if (e.fuse <= 0) { e.hp = 0; explodeAt(e.x, e.y, 70, 2, false); } + } + } else { + // chaser with a little wobble + e.wob += dt * 3; + const wa = a + Math.sin(e.wob) * 0.3; + e.vx += Math.cos(wa) * spd * 4 * dt; e.vy += Math.sin(wa) * spd * 4 * dt; + } +} + +function drawEnemies(ctx) { + for (const e of G.enemies) { + if (e.hp <= 0) continue; + const p = G.player; + const face = angleTo(e.x, e.y, p.x, p.y); + const speed = Math.min(Math.hypot(e.vx, e.vy) / Math.max(e.spd, 1), 1); + let action = speed > 0.12 ? 'move' : 'idle'; + let animTime = e.animT || 0; + if (e.attackT > 0 || (e.boss && e.state !== 'idle')) { + action = 'attack'; + animTime = e.attackT > 0 ? e.actionT : (e.animT || 0); + } + if (e.flash > 0) { action = 'hit'; animTime = 0.12 - e.flash; } + let pulse = 1; + if (e.type === 'exploder' && e.fuse >= 0) pulse = 1 + (0.5 - e.fuse) * 0.13 + Math.sin(G.time * 28) * 0.04; + const appear = clamp((e.spawnT || 0) / 0.24, 0.12, 1); + if (e.alpha === undefined || e.alpha > 0.05) Sprites.shadow(ctx, e.x, e.y + e.r * 0.55, e.r * 0.9, e.r * 0.34, (e.boss ? 0.5 : 0.34) * (e.alpha === undefined ? 1 : e.alpha)); + Sprites.actor(ctx, e.sprite, e.x, e.y, face, action, animTime, + e.drawSize || (e.boss ? 128 : 64), e.alpha === undefined ? 1 : e.alpha, appear * pulse, appear * pulse); + if (e.plates > 0) { + ctx.save(); ctx.translate(e.x, e.y); ctx.lineWidth = 4; + for (let k = 0; k < e.plates; k++) { + ctx.strokeStyle = k % 2 ? '#8d755d' : '#b79b71'; + ctx.beginPath(); ctx.arc(0, 0, e.r + 7, k * Math.PI / 2 + 0.08, k * Math.PI / 2 + 1.25); ctx.stroke(); + } + ctx.restore(); + } + if (e.elite) { + ctx.strokeStyle = '#ffd060'; ctx.lineWidth = 2; ctx.globalAlpha = 0.7; + 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); + } + } +} diff --git a/js/hud.js b/js/hud.js new file mode 100644 index 0000000..3cc3c87 --- /dev/null +++ b/js/hud.js @@ -0,0 +1,208 @@ +// ---- HUD: hearts, XP, weapon, items, boss bar, minimap, toasts ---- + +function drawHUD(ctx) { + const p = G.player; + ctx.save(); + const panelH = p.holstered ? 140 : 112; + drawPixelPanel(ctx, 8, 8, 304, panelH, { + accent: '#cf2942', border: 'rgba(115,43,56,0.95)', blood: true, + seed: G.floor, bloodAlpha: 0.34, + }); + ctx.textAlign = 'left'; + ctx.fillStyle = '#d9c9bc'; ctx.font = 'bold 10px monospace'; + ctx.fillText('BUTCHER // VITALS', 18, 23); + ctx.fillStyle = '#d7a934'; ctx.textAlign = 'right'; + ctx.fillText('LV ' + String(p.level).padStart(2, '0'), 300, 23); + + // Hearts retain their illustrative icons on a cleaner fixed baseline. + const hearts = Math.ceil(p.stats.maxHp / 2); + const maxVitalIcons = 11; + const shownHearts = Math.min(hearts, maxVitalIcons); + for (let i = 0; i < shownHearts; i++) { + const x = 21 + i * 22, y = 39; + const fill = clamp(p.hp - i * 2, 0, 2) / 2; + Sprites.draw(ctx, 'heart', x, y, 0, 18, false, 0.25); + if (fill > 0) { + ctx.save(); + ctx.beginPath(); ctx.rect(x - 10, y - 10, 20 * fill, 20); ctx.clip(); + Sprites.draw(ctx, 'heart', x, y, 0, 18); + ctx.restore(); + } + } + const shownShields = Math.min(p.shieldHp, Math.max(0, maxVitalIcons - shownHearts)); + for (let i = 0; i < shownShields; i++) { + const x = 21 + (shownHearts + i) * 22, y = 39; + ctx.fillStyle = '#3bc9e0'; + ctx.beginPath(); ctx.arc(x - 3, y - 2, 4, 0, TAU); ctx.arc(x + 3, y - 2, 4, 0, TAU); ctx.fill(); + ctx.beginPath(); ctx.moveTo(x - 6.5, y); ctx.lineTo(x, y + 7); ctx.lineTo(x + 6.5, y); ctx.fill(); + } + const hiddenVitals = hearts + p.shieldHp - shownHearts - shownShields; + if (hiddenVitals > 0) { + drawPixelTag(ctx, '+' + hiddenVitals, 266, 29, { width: 34, height: 21, color: '#d7b24a', accent: '#8f6724' }); + } + + const need = xpForLevel(p.level); + drawPixelBar(ctx, 16, 53, 220, 10, p.xp / need, { + fill: '#3d9fc2', glint: '#a5edee', border: '#465d69', segments: 12, + }); + ctx.fillStyle = '#88b9c4'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'right'; + ctx.fillText(Math.floor(p.xp) + ' / ' + need + ' XP', 299, 62); + + const w = WEAPONS[p.weapon.id]; + ctx.fillStyle = 'rgba(3,2,3,0.6)'; ctx.fillRect(15, 70, 48, 34); + ctx.strokeStyle = 'rgba(116,56,65,0.65)'; ctx.strokeRect(15.5, 70.5, 47, 33); + Sprites.draw(ctx, 'w_' + w.id, 39, 87, 0, 54); + ctx.textAlign = 'left'; ctx.fillStyle = '#f0e5d7'; ctx.font = 'bold 12px monospace'; + ctx.fillText(w.name.toUpperCase(), 72, 84); + ctx.fillStyle = '#7d6262'; ctx.font = '9px monospace'; + ctx.fillText('ACTIVE WEAPON', 72, 99); + const ammoText = p.weapon.ammo === Infinity ? 'SIDEARM' : String(Math.ceil(p.weapon.ammo)).padStart(3, '0'); + drawPixelTag(ctx, ammoText, 238, 74, { + width: 58, height: 26, + color: p.weapon.ammo === Infinity ? '#9c8d86' : (p.weapon.ammo < w.ammo * 0.25 ? '#ff4057' : '#e0b94e'), + accent: p.weapon.ammo === Infinity ? '#5d4c50' : '#a47e25', font: 'bold 10px monospace', + }); + + if (p.holstered) { + const holsterEmpty = p.holstered.ammo <= 0; + ctx.fillStyle = 'rgba(86,40,48,0.35)'; ctx.fillRect(15, 112, 281, 20); + Sprites.draw(ctx, 'w_' + p.holstered.id, 29, 122, 0, 28, false, 0.68); + ctx.fillStyle = holsterEmpty ? '#d94652' : '#aa9290'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'left'; + ctx.fillText((holsterEmpty ? '[EMPTY] ' : '[R] ') + WEAPONS[p.holstered.id].name.toUpperCase(), 47, 125); + ctx.fillStyle = holsterEmpty ? '#ef4057' : '#d0aa4a'; ctx.textAlign = 'right'; + ctx.fillText(holsterEmpty ? 'NEEDS AMMO' : Math.ceil(p.holstered.ammo) + ' AMMO', 289, 125); + } + + const allItemEntries = Object.entries(p.items); + const maxVisibleItems = allItemEntries.length > 24 ? 23 : 24; + const itemEntries = allItemEntries.slice(0, maxVisibleItems); + if (itemEntries.length) { + const cols = Math.min(12, itemEntries.length), rows = Math.ceil(itemEntries.length / 12); + const trayY = panelH + 16, trayW = 28 + cols * 34, trayH = 22 + rows * 34; + drawPixelPanel(ctx, 8, trayY, trayW, trayH, { + accent: '#a77d2b', cut: 5, blood: true, seed: itemEntries.length, bloodAlpha: 0.2, + }); + ctx.fillStyle = '#9e857a'; ctx.font = 'bold 8px monospace'; ctx.textAlign = 'left'; + ctx.fillText('IMPLANTS', 17, trayY + 12); + for (let index = 0; index < itemEntries.length; index++) { + const [iid, lvl] = itemEntries[index]; + const ix = 28 + (index % 12) * 34; + const iy = trayY + 31 + Math.floor(index / 12) * 34; + Sprites.draw(ctx, 'i_' + iid, ix, iy, 0, 30, false, 0.95); + if (lvl > 1) { + ctx.fillStyle = '#ffd060'; ctx.font = 'bold 8px monospace'; + ctx.fillText(romanNum(lvl), ix - 7, iy + 11); + } + } + if (allItemEntries.length > itemEntries.length) drawPixelTag(ctx, '+' + (allItemEntries.length - itemEntries.length), trayW - 49, trayY + trayH - 23, { + width: 30, height: 20, color: '#e0b94e', accent: '#8d6724', font: 'bold 8px monospace', + }); + } + + drawMinimap(ctx); + + drawPixelPanel(ctx, W - 180, H - 104, 172, 96, { + accent: '#b5892f', cut: 6, blood: true, seed: G.kills + 4, bloodAlpha: 0.22, + }); + ctx.textAlign = 'left'; ctx.fillStyle = '#88746f'; ctx.font = 'bold 8px monospace'; + ctx.fillText('RUN DATA', W - 168, H - 87); + ctx.fillStyle = '#d6aa39'; ctx.font = 'bold 12px monospace'; + ctx.fillText('FLOOR ' + String(G.floor).padStart(2, '0'), W - 168, H - 65); + ctx.textAlign = 'right'; ctx.fillStyle = '#a18d87'; ctx.font = '9px monospace'; + ctx.fillText('KILLS ' + String(G.kills).padStart(3, '0'), W - 18, H - 67); + ctx.fillStyle = '#f0e5d7'; ctx.font = 'bold 11px monospace'; + ctx.fillText('SCORE ' + String(G.score).padStart(6, '0'), W - 18, H - 46); + ctx.textAlign = 'left'; ctx.fillStyle = G.pressure > 1 ? '#d6aa39' : '#69b8bd'; ctx.font = 'bold 8px monospace'; + ctx.fillText('PRESSURE ' + Math.round(G.pressure * 100) + '%', W - 168, H - 28); + ctx.textAlign = 'right'; ctx.fillText('STREAK ' + G.streak, W - 18, H - 28); + drawPixelBar(ctx, W - 168, H - 20, 150, 8, (G.pressure - PRESSURE_MIN) / (PRESSURE_MAX - PRESSURE_MIN), { + fill: G.pressure > 1 ? '#c59231' : '#3d8f99', glint: G.pressure > 1 ? '#f4d86d' : '#8ce0df', border: '#5d3c43', segments: 10, + }); + + if (G.boss && G.boss.hp > 0) { + const bw = 460, bx = W / 2 - bw / 2, by = H - 48; + drawPixelPanel(ctx, bx, by, bw, 40, { + accent: '#e12842', cut: 6, fill: 'rgba(12,3,6,0.92)', blood: true, + seed: G.floor, bloodAlpha: 0.25, + }); + const k = clamp(G.boss.hp / G.boss.maxHp, 0, 1); + ctx.fillStyle = '#efc6bd'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'left'; + ctx.fillText('TARGET // ' + G.boss.name, bx + 12, by + 15); + ctx.textAlign = 'right'; ctx.fillStyle = '#af7d7c'; + ctx.fillText(Math.ceil(G.boss.hp) + ' / ' + Math.ceil(G.boss.maxHp), bx + bw - 12, by + 15); + drawPixelBar(ctx, bx + 11, by + 22, bw - 22, 10, k, { + fill: '#b51e35', glint: '#ff7d82', border: '#6e2936', segments: 20, + }); + } + + ctx.textAlign = 'center'; + for (let i = 0; i < G.toasts.length; i++) { + const t = G.toasts[i]; + const a = clamp(2.5 - t.t, 0, 1); + const y = 22 + i * 48; + ctx.globalAlpha = a; + ctx.font = 'bold 13px monospace'; + const tw = Math.max(ctx.measureText(t.text).width + 48, 190); + drawPixelPanel(ctx, W / 2 - tw / 2, y, tw, t.sub ? 40 : 28, { + cut: 5, shadow: false, accent: '#c59231', blood: true, + seed: i + G.floor, bloodAlpha: 0.2, + }); + ctx.fillStyle = '#e3b746'; ctx.fillText(t.text.toUpperCase(), W / 2, y + 17); + if (t.sub) { + ctx.fillStyle = '#9f8981'; ctx.font = '9px monospace'; + ctx.fillText(t.sub.toUpperCase(), W / 2, y + 31); + } + ctx.globalAlpha = 1; + } + + if (G.muted) { + drawPixelTag(ctx, '[M] MUTED', 9, H - 31, { + width: 76, height: 22, color: '#a28f87', accent: '#5f2932', + }); + } + ctx.restore(); +} + +function drawMinimap(ctx) { + const rooms = Object.values(G.rooms); + if (!rooms.length) return; + const s = 12, pad = 3; + let minX = 0, maxX = 0, minY = 0, maxY = 0; + for (const r of rooms) { + minX = Math.min(minX, r.gx); maxX = Math.max(maxX, r.gx); + minY = Math.min(minY, r.gy); maxY = Math.max(maxY, r.gy); + } + const gridW = (maxX - minX + 1) * (s + pad) - pad; + const gridH = (maxY - minY + 1) * (s + pad) - pad; + const panelW = Math.max(148, gridW + 24), panelH = Math.max(78, gridH + 38); + const px = W - panelW - 8, py = 8; + drawPixelPanel(ctx, px, py, panelW, panelH, { + accent: '#4a9cad', cut: 6, blood: true, seed: G.floor + 9, bloodAlpha: 0.16, + }); + ctx.fillStyle = '#8eb7bd'; ctx.font = 'bold 8px monospace'; ctx.textAlign = 'left'; + ctx.fillText('SECTOR MAP', px + 11, py + 16); + ctx.textAlign = 'right'; ctx.fillStyle = '#d1a839'; + ctx.fillText('F-' + String(G.floor).padStart(2, '0'), px + panelW - 11, py + 16); + const ox = px + Math.floor((panelW - gridW) / 2); + const oy = py + 27 + Math.floor((panelH - 32 - gridH) / 2); + for (const r of rooms) { + if (!r.visited && r !== G.cur) continue; + const x = ox + (r.gx - minX) * (s + pad); + const y = oy + (r.gy - minY) * (s + pad); + ctx.fillStyle = '#130b0e'; ctx.fillRect(x - 1, y - 1, s + 2, s + 2); + if (r === G.cur) ctx.fillStyle = '#e9ded0'; + else if (r.type === 'boss') ctx.fillStyle = '#b72138'; + else if (r.type === 'item') ctx.fillStyle = '#c9a227'; + else if (r.cleared) ctx.fillStyle = '#43545a'; + else ctx.fillStyle = '#7a4f59'; + ctx.fillRect(x, y, s, s); + if (r === G.cur) { + ctx.strokeStyle = '#67c7d2'; ctx.strokeRect(x - 2.5, y - 2.5, s + 5, s + 5); + } + } +} + +function drawToastsUpdate(dt) { + for (const t of G.toasts) t.t += dt; + while (G.toasts.length && G.toasts[0].t > 2.5) G.toasts.shift(); +} diff --git a/js/input.js b/js/input.js new file mode 100644 index 0000000..ea739a4 --- /dev/null +++ b/js/input.js @@ -0,0 +1,47 @@ +// ---- keyboard + mouse input ---- +const Input = { + keys: {}, + pressed: {}, // edge-triggered, cleared each frame + mx: W / 2, my: H / 2, + mdown: false, + mpressed: false, // edge-triggered mouse + mreleased: false, + anyKey: false, +}; + +function initInput(canvas) { + window.addEventListener('keydown', (e) => { + const k = e.key.toLowerCase(); + if (!Input.keys[k]) Input.pressed[k] = true; + Input.keys[k] = true; + Input.anyKey = true; + if (['arrowup', 'arrowdown', 'arrowleft', 'arrowright', ' '].includes(k)) e.preventDefault(); + }); + window.addEventListener('keyup', (e) => { Input.keys[e.key.toLowerCase()] = false; }); + window.addEventListener('blur', () => { Input.keys = {}; Input.mdown = false; }); + + function setMouse(e) { + const r = canvas.getBoundingClientRect(); + Input.mx = (e.clientX - r.left) * (W / r.width); + Input.my = (e.clientY - r.top) * (H / r.height); + } + canvas.addEventListener('mousemove', setMouse); + canvas.addEventListener('mousedown', (e) => { + setMouse(e); + if (e.button === 0) { Input.mdown = true; Input.mpressed = true; Input.anyKey = true; } + }); + window.addEventListener('mouseup', (e) => { + if (e.button === 0) { Input.mdown = false; Input.mreleased = true; } + }); + canvas.addEventListener('contextmenu', (e) => e.preventDefault()); +} + +function clearInputEdges() { + Input.pressed = {}; + Input.mpressed = false; + Input.mreleased = false; + Input.anyKey = false; +} + +function keyDown(...ks) { return ks.some(k => Input.keys[k]); } +function keyPressed(...ks) { return ks.some(k => Input.pressed[k]); } diff --git a/js/items.js b/js/items.js new file mode 100644 index 0000000..845b49d --- /dev/null +++ b/js/items.js @@ -0,0 +1,93 @@ +// ---- Isaac-style stacking passive items ---- + +const ITEMS = { + hollowpoints: { name: 'Hollow Points', desc: '+25% damage', apply: s => { s.dmgMul *= 1.25; } }, + twitch: { name: 'Twitch Fibers', desc: '+20% fire rate', apply: s => { s.rateMul *= 1.20; } }, + scalpel: { name: 'Scalpel', desc: '+25% shot speed', apply: s => { s.shotSpeedMul *= 1.25; } }, + leadmarrow: { name: 'Lead Marrow', desc: '+25% range', apply: s => { s.rangeMul *= 1.25; } }, + piercegaze: { name: 'Piercing Gaze', desc: 'Shots pierce +1 enemy', apply: s => { s.pierce += 1; } }, + ricochet: { name: 'Ricochet Ribs', desc: 'Shots bounce +1 time', apply: s => { s.bounce += 1; } }, + splittongue: { name: 'Split Tongue', desc: 'Twin parallel shot', apply: s => { s.split += 1; } }, + hydramaw: { name: 'Hydra Maw', desc: 'Fires 2 extra angled shots', apply: s => { s.fan += 1; } }, + homingtumor: { name: 'Homing Tumor', desc: 'Shots seek flesh (stronger per tier)', apply: s => { s.homing = (s.homing || 0) + 1; } }, + orbitalknives: { name: 'Orbital Knives', desc: '+1 circling knife', apply: s => { s.orbitals += 1; } }, + dentures: { name: 'Vampire Dentures', desc: 'Kills may heal you', apply: s => { s.lifestealChance += 0.04; } }, + volatilebile: { name: 'Volatile Bile', desc: 'Kills explode (bigger per tier)', apply: s => { s.explodeOnKill = (s.explodeOnKill || 0) + 1; } }, + backstabber: { name: 'Backstabber', desc: '+1 rear shot', apply: s => { s.rear = (s.rear || 0) + 1; } }, + splinterbone: { name: 'Splinter Bone', desc: 'Shots shatter into +2 shards on hit', apply: s => { s.splinter = (s.splinter || 0) + 2; } }, + ironstomach: { name: 'Iron Stomach', desc: '+½ heart container, heal ½ heart', apply: (s, p) => { s.maxHp += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } }, + luckycoin: { name: 'Lucky Coin', desc: 'Better drops', apply: s => { s.luck += 0.2; } }, + magnetmaw: { name: 'Magnet Maw', desc: '+60% pickup radius', apply: s => { s.magnet *= 1.6; } }, + bloodlust: { name: 'Bloodlust', desc: 'Kills may drop bonus XP', apply: s => { s.bloodlust = (s.bloodlust || 0) + 0.12; } }, + ghoulheart: { name: 'Ghoul Heart', desc: '+2 max HP, heal 2', apply: (s, p) => { s.maxHp += 2; p.hp = Math.min(s.maxHp, p.hp + 2); } }, + chainsinew: { name: 'Chain Sinew', desc: 'Hits arc to +1 nearby enemy', apply: s => { s.chain += 1; } }, + mortarbone: { name: 'Mortar Bone', desc: 'Every sixth hit erupts', apply: s => { s.mortar += 1; } }, + bloatrounds: { name: 'Bloat Rounds', desc: '+25% attack size, +10% damage', apply: s => { s.sizeMul *= 1.25; s.dmgMul *= 1.10; } }, + marrowglut: { name: 'Marrow Glut', desc: '+20% damage, -10% fire rate', apply: s => { s.dmgMul *= 1.20; s.rateMul *= 0.90; } }, + hollowneedle: { name: 'Hollow Needle', desc: '+50% critical damage', apply: s => { s.critMul += 0.50; } }, + bloodshoteye: { name: 'Bloodshot Eye', desc: '+6% critical chance', apply: s => { s.crit += 0.06; } }, + flayerkiss: { name: 'Flayer Kiss', desc: 'Hits inflict heavy bleeding', apply: s => { s.bleed += 0.20; } }, + emberjar: { name: 'Ember Jar', desc: '20% chance to ignite', apply: s => { s.igniteChance += 0.20; } }, + acidgland: { name: 'Acid Gland', desc: 'Hits may leave acid pools', apply: s => { s.acidOnHit += 0.12; } }, + hookrounds: { name: 'Hook Rounds', desc: 'Hits pull enemies toward you', apply: s => { s.pullOnHit += 0.18; } }, + sledgerounds: { name: 'Sledge Rounds', desc: 'More knockback, hits may stun', apply: s => { s.knockbackMul *= 1.35; s.stunOnHit += 0.08; } }, + graftedtrigger:{ name: 'Grafted Trigger', desc: '+18% fire rate', apply: s => { s.rateMul *= 1.18; } }, + deadmanswitch: { name: "Dead Man's Switch", desc: 'Kills trigger a fire-rate frenzy', apply: s => { s.frenzy += 0.12; } }, + orbitcrown: { name: 'Orbit Crown', desc: '+25% orbital speed and damage', apply: s => { s.orbSpeedMul *= 1.25; s.orbDmgMul *= 1.25; } }, + tannedhide: { name: 'Tanned Hide', desc: '+8% chance to ignore damage', apply: s => { s.armor += 0.08 / 0.92; } }, + deadmansclock: { name: "Dead Man's Clock", desc: '+0.25s hurt immunity', apply: s => { s.invBonus += 0.25; } }, + hollowbones: { name: 'Hollow Bones', desc: '+12% move speed', apply: s => { s.speedMul *= 1.12; } }, + boneplate: { name: 'Bone Plate', desc: '+½ shield heart each floor', apply: (s, p) => { s.shieldPerk = (s.shieldPerk || 0) + 1; p.shieldHp += 1; } }, + wormgut: { name: 'Worm Gut', desc: 'Heal ½ heart after combat rooms', apply: s => { s.roomHeal += 1; } }, + spinecage: { name: 'Spine Cage', desc: 'Contact attackers take damage', apply: s => { s.thorns += 8; } }, + secondstomach: { name: 'Second Stomach', desc: '+½ heart and excess healing shields', apply: (s, p) => { s.maxHp += 1; s.overShield += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } }, + spitewell: { name: 'Spite Well', desc: 'Taking damage releases a blood nova', apply: s => { s.retaliate += 10; } }, + twinhearts: { name: 'Twin Hearts', desc: '+1 max heart, heal 1 heart', apply: (s, p) => { s.maxHp += 2; p.hp = Math.min(s.maxHp, p.hp + 2); } }, + brassmagazine: { name: 'Brass Magazine', desc: '15% less ammo use, +20% ammo found', apply: s => { s.ammoEff *= 1.15; s.ammoPickupMul *= 1.20; } }, + crowbait: { name: 'Crow Bait', desc: '+25% drop luck', apply: s => { s.luck += 0.25; } }, + gorgingleech: { name: 'Gorging Leech', desc: '+20% XP gained', apply: s => { s.xpMul *= 1.20; } }, + rerollrib: { name: 'Reroll Rib', desc: '+1 perk reroll each level', apply: s => { s.rerollPerLevel += 1; s.rerolls += 1; } }, +}; + +// 50% of rolls favor items the player already owns (below cap) so high tiers +// are reachable if you commit to them +function randomItemId() { + const p = G.player; + const owned = p ? Object.keys(p.items).filter(iid => (p.items[iid] || 0) < ITEM_LEVEL_CAP) : []; + if (owned.length && chance(0.5)) return choice(owned); + return choice(Object.keys(ITEMS)); +} + +const ITEM_LEVEL_CAP = 9; +function romanNum(n) { return ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'][n - 1] || ('Lv' + n); } + +// duplicate pickups upgrade the item's quality (effect re-applies per tier) +function giveItem(iid) { + const p = G.player; + const item = ITEMS[iid]; + const lvl = p.items[iid] || 0; + if (lvl >= ITEM_LEVEL_CAP) { + addScore(150); + spawnGems(p.x, p.y, 5); + addToast(item.name + ' MAX', 'already mastered — +150 score'); + Sfx.pickup(); + return; + } + item.apply(p.stats, p); + p.items[iid] = lvl + 1; + if (lvl > 0) { + addToast(item.name + ' ' + romanNum(lvl + 1), 'UPGRADED — ' + item.desc); + spawnText(p.x, p.y - 14, 'UPGRADED!', '#ffd060'); + } else { + addToast(item.name, item.desc); + spawnText(p.x, p.y - 14, item.name.toUpperCase(), '#c9a227'); + } + Sfx.item(); + refreshOrbitals(p); +} + +// spawn an item pedestal (item room / boss reward) +function spawnItemPedestal(x, y, iid) { + spawnPickup('itemspot', x, y); + spawnPickup('item', x, y, { iid: iid || randomItemId() }); +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..9df3355 --- /dev/null +++ b/js/main.js @@ -0,0 +1,366 @@ +// ---- bootstrap, game loop, mode dispatch ---- + +let canvas, ctx, lastT = 0; + +function init() { + canvas = document.getElementById('game'); + ctx = canvas.getContext('2d'); + ctx.imageSmoothingEnabled = false; + initInput(canvas); + Sprites.load(); + fitCanvas(); + buildAtmosphereLayer(); + window.addEventListener('resize', fitCanvas); + if (document.addEventListener) { + document.addEventListener('visibilitychange', () => { + lastT = performance.now(); + if (document.hidden) { Input.keys = {}; Input.mdown = false; } + }); + } + try { G.best = parseInt(localStorage.getItem('meatslicer_best') || '0', 10) || 0; } + catch (e) { G.best = 0; } + try { + const sv = parseFloat(localStorage.getItem('meatslicer_sfx_volume')); + const mv = parseFloat(localStorage.getItem('meatslicer_music_volume')); + if (Number.isFinite(sv)) G.sfxVol = clamp(sv, 0, 1); + if (Number.isFinite(mv)) G.musicVol = clamp(mv, 0, 1); + G.autoPerk = localStorage.getItem('meatslicer_autoperk') === '1'; + } catch (e) {} + requestAnimationFrame(loop); +} + +function setAutoPerk(enabled) { + G.autoPerk = !!enabled; + try { localStorage.setItem('meatslicer_autoperk', G.autoPerk ? '1' : '0'); } catch (e) {} + addToast('AUTO-DRAFT ' + (G.autoPerk ? 'ON' : 'OFF'), G.autoPerk ? 'level-ups now choose randomly' : 'manual mutation drafts restored'); + Sfx.menu(); +} + +function buildAtmosphereLayer() { + if (!document.createElement) return; + const layer = document.createElement('canvas'); + layer.width = W; layer.height = H; + const c = layer.getContext('2d'); + const vignette = c.createRadialGradient(W / 2, H / 2, 170, W / 2, H / 2, 590); + vignette.addColorStop(0, 'rgba(0,0,0,0)'); + vignette.addColorStop(0.72, 'rgba(12,2,5,0.12)'); + vignette.addColorStop(1, 'rgba(4,1,2,0.64)'); + c.fillStyle = vignette; c.fillRect(0, 0, W, H); + // Fixed grain gives the room texture without per-frame random allocations. + for (let i = 0; i < 380; i++) { + const x = hashTile(i * 17, 11) % W, y = hashTile(i * 29, 23) % H; + c.fillStyle = i % 3 ? 'rgba(255,220,205,0.018)' : 'rgba(0,0,0,0.035)'; + c.fillRect(x, y, 1, 1); + } + G.atmosphereLayer = layer; +} + +function fitCanvas() { + const k = Math.min(window.innerWidth / W, window.innerHeight / H) * 0.97; + canvas.style.width = Math.floor(W * k) + 'px'; + canvas.style.height = Math.floor(H * k) + 'px'; +} + +function startRun() { + Sfx.init(); Sfx.resume(); + Sfx.stopAllLoops(); Sfx.menu(); + G.floor = 1; G.score = 0; G.kills = 0; G.time = 0; + G.parts = []; G.corpses = []; G.toasts = []; G.pendingLevelups = 0; G.perkChoices = null; + G.pressure = 1; G.streak = 0; G.roomDamaged = false; G.roomEnterT = 0; G.recentHits = []; + G.shake = 0; G.flash = 0; + initPlayer(); + genFloor(1); + enterRoom(0, 0); + G.player.shieldHp = G.player.stats.shieldPerk || 0; + G.mode = 'play'; + Music.requestFloorMusic(); + addToast('FLOOR 1', 'clear the rooms. find the stairs. feed.'); +} + +function gameOver() { + G.mode = 'gameover'; + Sfx.playerDeath(); + if (G.score > (G.best || 0)) { + G.best = G.score; + try { localStorage.setItem('meatslicer_best', String(G.best)); } catch (e) {} + } + Music.playMenu(); +} + +function loop(now) { + const dt = Math.min((now - lastT) / 1000 || 0.016, 0.05); + lastT = now; + update(dt); + draw(); + clearInputEdges(); + requestAnimationFrame(loop); +} + +function update(dt) { + Music.update(dt); + + // global keys + if (keyPressed('m')) { + G.muted = !G.muted; + Music.setMuted(G.muted); + Sfx.setMuted(G.muted); + } + + switch (G.mode) { + case 'menu': + if (Input.mpressed || keyPressed('enter', ' ')) startRun(); + break; + case 'play': + G.time += dt; + if (keyPressed('p', 'escape')) { G.mode = 'pause'; Sfx.stopAllLoops(); Sfx.menu(); break; } + if (keyPressed('n')) Music.cycle(1); + if (keyPressed('r')) swapWeapon(); + if (keyPressed('t')) setAutoPerk(!G.autoPerk); + updatePlayer(dt); + updateRoom(dt); + updateEnemies(dt); + updateTelegraphs(dt); + updateBullets(dt); + updateHazards(dt); + updatePickups(dt); + updateParticles(dt); + drawToastsUpdate(dt); + break; + case 'levelup': + updateLevelup(); + updateParticles(dt); + break; + case 'pause': + if (keyPressed('p', 'escape')) { G.mode = 'play'; Sfx.menu(); break; } + if (keyPressed('r')) swapWeapon(true); + if (keyPressed('t')) setAutoPerk(!G.autoPerk); + if (keyPressed('-', '_')) { Sfx.setVolume(G.sfxVol - 0.1); Sfx.menu(); } + if (keyPressed('=', '+')) { Sfx.setVolume(G.sfxVol + 0.1); Sfx.menu(); } + if (keyPressed(',', '<')) { Music.setVolume(G.musicVol - 0.1); Sfx.menu(); } + if (keyPressed('.', '>')) { Music.setVolume(G.musicVol + 0.1); Sfx.menu(); } + if (keyPressed('arrowleft', '[')) Music.cycle(-1); + else if (keyPressed('arrowright', ']')) Music.cycle(1); + else if (Input.mpressed) { + // jukebox arrows + if (dist(Input.mx, Input.my, W / 2 - 200, H / 2 + 60) < 22) Music.cycle(-1); + else if (dist(Input.mx, Input.my, W / 2 + 200, H / 2 + 60) < 22) Music.cycle(1); + } + break; + case 'gameover': + updateParticles(dt); + if (keyPressed('r', 'enter', ' ') || Input.mpressed) startRun(); + break; + } + + if (G.shake > 0) G.shake = Math.max(0, G.shake - dt * 30); + if (G.flash > 0) G.flash = Math.max(0, G.flash - dt); + if (G.transition > 0) G.transition = Math.max(0, G.transition - dt); +} + +function draw() { + ctx.fillStyle = '#0a0506'; + ctx.fillRect(0, 0, W, H); + + if (G.mode === 'menu') { drawMenu(ctx); return; } + + // world (with screen shake) + ctx.save(); + if (G.shake > 0) ctx.translate(rand(-G.shake, G.shake) * 0.5, rand(-G.shake, G.shake) * 0.5); + drawRoom(ctx); + drawCorpses(ctx); + drawPickups(ctx); + drawEnemies(ctx); + drawPlayer(ctx); + drawBullets(ctx); + drawParticles(ctx); + drawTelegraphs(ctx); + ctx.restore(); + + if (G.atmosphereLayer) ctx.drawImage(G.atmosphereLayer, 0, 0); + + // Fine inner frame and corner cuts finish the playfield presentation. + ctx.strokeStyle = 'rgba(104,36,43,0.55)'; ctx.lineWidth = 2; + ctx.strokeRect(2, 2, W - 4, H - 4); + ctx.strokeStyle = 'rgba(224,177,102,0.22)'; ctx.lineWidth = 1; + ctx.beginPath(); + for (const [x, y, sx, sy] of [[8, 8, 1, 1], [W - 8, 8, -1, 1], [8, H - 8, 1, -1], [W - 8, H - 8, -1, -1]]) { + ctx.moveTo(x, y + sy * 20); ctx.lineTo(x, y); ctx.lineTo(x + sx * 20, y); + } + ctx.stroke(); + + drawHUD(ctx); + + // hurt vignette + if (G.flash > 0) { + ctx.fillStyle = 'rgba(180,10,25,' + (G.flash * 0.55) + ')'; + ctx.fillRect(0, 0, W, H); + } + // room transition fade + if (G.transition > 0) { + ctx.fillStyle = 'rgba(5,2,3,' + clamp(G.transition * 3, 0, 1) + ')'; + ctx.fillRect(0, 0, W, H); + } + + if (G.mode === 'levelup') drawLevelup(ctx); + else if (G.mode === 'pause') drawPause(ctx); + else if (G.mode === 'gameover') drawGameOver(ctx); +} + +function drawMenu(ctx) { + const t = performance.now() / 1000; + const bg = ctx.createRadialGradient && ctx.createRadialGradient(W / 2, H * 0.4, 30, W / 2, H * 0.45, 590); + if (bg && bg.addColorStop) { + bg.addColorStop(0, '#260b11'); bg.addColorStop(0.5, '#100609'); bg.addColorStop(1, '#050204'); + ctx.fillStyle = bg; + } else ctx.fillStyle = '#100609'; + ctx.fillRect(0, 0, W, H); + + // Low-contrast industrial grid replaces decorative circles with a cleaner, + // modern system-screen backdrop while preserving hard pixel geometry. + ctx.lineWidth = 1; + for (let x = 0; x <= W; x += 48) { + ctx.strokeStyle = x % 192 === 0 ? 'rgba(121,35,48,0.11)' : 'rgba(121,35,48,0.045)'; + ctx.beginPath(); ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, H); ctx.stroke(); + } + for (let y = 0; y <= H; y += 48) { + ctx.strokeStyle = y % 192 === 0 ? 'rgba(121,35,48,0.11)' : 'rgba(121,35,48,0.045)'; + ctx.beginPath(); ctx.moveTo(0, y + 0.5); ctx.lineTo(W, y + 0.5); ctx.stroke(); + } + ctx.globalAlpha = 0.13; + Sprites.draw(ctx, 'bullet_cleaver', 168, 274, -0.2, 150); + Sprites.draw(ctx, 'bullet_cleaver', W - 168, 274, Math.PI + 0.2, 150); + ctx.globalAlpha = 1; + + drawPixelTag(ctx, 'SLAUGHTER PROTOCOL // 01', 28, 24, { + width: 188, height: 24, color: '#a99089', accent: '#b6243a', + }); + drawPixelTag(ctx, 'LOCAL RUNNER // ONLINE', W - 196, 24, { + width: 168, height: 24, color: '#74aeb5', accent: '#398b99', + }); + + ctx.textAlign = 'center'; + ctx.font = '900 70px monospace'; + ctx.fillStyle = '#39070d'; ctx.fillText('MEATSLICER', W / 2 + 4, 248); + ctx.fillStyle = '#f02b48'; ctx.fillText('MEATSLICER', W / 2, 243); + ctx.fillStyle = '#ff9a8a'; ctx.globalAlpha = 0.35; ctx.fillText('MEATSLICER', W / 2, 240); ctx.globalAlpha = 1; + drawBloodTrim(ctx, W / 2 - 230, 255, 460, 11, 0.65); + ctx.fillStyle = '#d4ad3d'; ctx.font = 'bold 12px monospace'; + ctx.fillText('A BUTCHER\'S DESCENT // CLEAR · HARVEST · DESCEND', W / 2, 281); + + const pulse = 0.75 + Math.sin(t * 3) * 0.18; + drawPixelPanel(ctx, W / 2 - 180, 320, 360, 64, { + accent: '#e22b46', border: 'rgba(218,40,62,' + pulse + ')', lineWidth: 2, + fill: 'rgba(12,4,7,0.9)', blood: true, seed: 4, bloodAlpha: 0.35, + }); + ctx.fillStyle = '#7f6062'; ctx.font = 'bold 9px monospace'; + ctx.fillText('INITIATE DESCENT', W / 2, 341); + ctx.fillStyle = '#f5e9d6'; ctx.font = 'bold 14px monospace'; + ctx.fillText('[ ENTER ] OR CLICK TO START', W / 2, 365); + + const controls = ['WASD MOVE', 'MOUSE AIM', 'LMB FIRE', 'R SWAP', 'P PAUSE', 'T AUTO', 'M MUTE', 'N MUSIC']; + ctx.font = 'bold 9px monospace'; + const widths = controls.map(label => Math.ceil(ctx.measureText(label).width) + 16); + const total = widths.reduce((sum, width) => sum + width, 0) + (controls.length - 1) * 6; + let tx = (W - total) / 2; + for (let i = 0; i < controls.length; i++) { + drawPixelTag(ctx, controls[i], tx, 420, { width: widths[i], height: 24, color: '#a9958e', accent: i < 3 ? '#8f2637' : '#5f3943' }); + tx += widths[i] + 6; + } + + ctx.fillStyle = '#655155'; ctx.font = '9px monospace'; + ctx.fillText('ROOM-CLEAR COMBAT // PERK DRAFTS // SIXTEEN WEAPONS // CROWNED MEAT', W / 2, 474); + if (G.best) { + drawPixelTag(ctx, 'BEST CUT ' + String(G.best).padStart(6, '0'), W / 2 - 74, 503, { + width: 148, height: 26, color: '#e0b94e', accent: '#a47e25', + }); + } +} + +function drawPause(ctx) { + ctx.fillStyle = 'rgba(4,2,3,0.78)'; ctx.fillRect(0, 0, W, H); + drawPixelPanel(ctx, W / 2 - 270, 126, 540, 388, { + accent: '#c92a40', border: 'rgba(119,51,61,0.95)', fill: 'rgba(11,5,8,0.96)', + blood: true, seed: 8, bloodAlpha: 0.25, + }); + ctx.textAlign = 'center'; + ctx.fillStyle = '#7f6768'; ctx.font = 'bold 9px monospace'; + ctx.fillText('RUN STATE // SUSPENDED', W / 2, 158); + ctx.fillStyle = '#f0e5d7'; ctx.font = 'bold 36px monospace'; + ctx.fillText('PAUSED', W / 2, 202); + drawBloodTrim(ctx, W / 2 - 130, 213, 260, 8, 0.45); + + ctx.fillStyle = '#b89532'; ctx.font = 'bold 10px monospace'; + ctx.fillText('JUKEBOX // ACTIVE TRACK', W / 2, 270); + const sel = (G.musicSel === undefined || G.musicSel < 0) ? 'AUTO · FLOOR ROTATION' : Music.pretty(Music.PLAYLIST[G.musicSel]).toUpperCase(); + drawPixelPanel(ctx, W / 2 - 170, H / 2 + 20, 340, 82, { + cut: 5, shadow: false, accent: '#468e9d', fill: 'rgba(5,10,12,0.7)', border: '#315b65', + }); + ctx.fillStyle = '#8faeb2'; ctx.font = 'bold 9px monospace'; + ctx.fillText('NOW PLAYING', W / 2, H / 2 + 43); + ctx.fillStyle = '#e9ded0'; ctx.font = 'bold 14px monospace'; + ctx.fillText(sel, W / 2, H / 2 + 70); + + for (const sx of [-200, 200]) { + const x = W / 2 + sx, y = H / 2 + 60; + const hover = dist(Input.mx, Input.my, x, y) < 22; + drawPixelPanel(ctx, x - 20, y - 20, 40, 40, { + cut: 5, shadow: false, fill: hover ? '#631526' : '#251017', + border: hover ? '#ef3150' : '#65303b', accent: hover ? '#ef3150' : '#773142', + }); + ctx.fillStyle = '#eee0d3'; ctx.font = 'bold 18px monospace'; + ctx.fillText(sx < 0 ? '<' : '>', x, y + 6); + } + + ctx.textAlign = 'left'; ctx.font = 'bold 9px monospace'; ctx.fillStyle = '#a99a91'; + ctx.fillText('SFX [- / +]', W / 2 - 170, 435); + drawPixelBar(ctx, W / 2 - 55, 426, 225, 10, G.sfxVol, { + fill: '#c92a40', glint: '#ff7d82', border: '#63303a', segments: 10, + }); + ctx.fillText('MUSIC [, / .]', W / 2 - 170, 445); + drawPixelBar(ctx, W / 2 - 55, 436, 225, 10, G.musicVol, { + fill: '#468e9d', glint: '#a5edee', border: '#315b65', segments: 10, + }); + + drawPixelTag(ctx, '[P] RESUME', W / 2 - 250, 462, { width: 112, height: 28, color: '#f0e5d7', accent: '#b5243a' }); + drawPixelTag(ctx, '[R] SWAP', W / 2 - 125, 462, { width: 112, height: 28, color: '#bba9a1', accent: '#6e3843' }); + drawPixelTag(ctx, '[T] AUTO ' + (G.autoPerk ? 'ON' : 'OFF'), W / 2, 462, { width: 112, height: 28, color: G.autoPerk ? '#79d9ca' : '#bba9a1', accent: G.autoPerk ? '#378b80' : '#6e3843' }); + drawPixelTag(ctx, '[M] MUTE', W / 2 + 125, 462, { width: 112, height: 28, color: '#bba9a1', accent: '#6e3843' }); + ctx.fillStyle = '#69585a'; ctx.font = '9px monospace'; + ctx.textAlign = 'center'; + ctx.fillText('ARROW KEYS / CLICK TO CHANGE TRACK · LIVE BOSS FIGHTS OVERRIDE SELECTION', W / 2, 503); +} + +function drawGameOver(ctx) { + 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)', + blood: true, seed: G.kills + 3, bloodAlpha: 0.48, + }); + ctx.textAlign = 'center'; + ctx.fillStyle = '#8f6870'; ctx.font = 'bold 9px monospace'; + ctx.fillText('RUN TERMINATED // CARCASS RECOVERED', W / 2, 174); + ctx.fillStyle = '#ee2947'; ctx.font = 'bold 48px monospace'; + ctx.fillText('BUTCHERED', W / 2, 231); + drawBloodTrim(ctx, W / 2 - 170, 242, 340, G.kills + 3, 0.7); + + drawPixelPanel(ctx, W / 2 - 188, 278, 116, 72, { cut: 5, shadow: false, accent: '#9c7428', fill: '#130b0d' }); + drawPixelPanel(ctx, W / 2 - 58, 278, 116, 72, { cut: 5, shadow: false, accent: '#9c7428', fill: '#130b0d' }); + drawPixelPanel(ctx, W / 2 + 72, 278, 116, 72, { cut: 5, shadow: false, accent: '#9c7428', fill: '#130b0d' }); + const stats = [['FLOOR', G.floor], ['KILLS', G.kills], ['SCORE', G.score]]; + for (let i = 0; i < stats.length; i++) { + const x = W / 2 - 130 + i * 130; + ctx.fillStyle = '#7e6a66'; ctx.font = 'bold 9px monospace'; ctx.fillText(stats[i][0], x, 300); + ctx.fillStyle = i === 2 ? '#e2b742' : '#eee0d3'; ctx.font = 'bold 18px monospace'; + ctx.fillText(String(stats[i][1]).padStart(i === 2 ? 6 : 2, '0'), x, 329); + } + if (G.score >= (G.best || 0) && G.score > 0) { + 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', + }); + ctx.fillStyle = '#f1e4d5'; ctx.font = 'bold 12px monospace'; + ctx.fillText('[ R / ENTER ] CUT AGAIN', W / 2, 444); +} + +window.addEventListener('load', init); diff --git a/js/particles.js b/js/particles.js new file mode 100644 index 0000000..08e578c --- /dev/null +++ b/js/particles.js @@ -0,0 +1,252 @@ +// ---- particles: blood, gibs, flame, smoke, shockwaves, sparks, text, beams ---- + +const MAX_PARTICLES = 850; + +// Cosmetic bursts should never be able to starve the game loop. Important +// particles (damage text and beams) make room by retiring the oldest effect. +function addParticle(p, important) { + if (G.parts.length >= MAX_PARTICLES) { + if (!important) return false; + G.parts.splice(0, Math.min(24, G.parts.length - MAX_PARTICLES + 1)); + } + G.parts.push(p); + return true; +} + +function spawnBlood(x, y, ang, n, big) { + for (let i = 0; i < n; i++) { + const a = ang + rand(-0.9, 0.9); + const sp = rand(40, big ? 260 : 160); + addParticle({ + type: 'blood', x, y, + vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, + life: rand(0.3, 0.7), t: 0, + r: rand(1.5, big ? 4.5 : 3), + mist: i % 4 === 0, shade: i % 3, + }); + } + // persistent floor decal + if (G.cur && chance(0.55)) { + G.cur.decals.push({ + x: x + rand(-14, 14), y: y + rand(-14, 14), + img: 'decal_blood' + irand(1, 4), + s: rand(0.5, big ? 1.6 : 1.0), rot: rand(0, TAU), + }); + if (G.cur.decals.length > 220) G.cur.decals.shift(); + } +} + + +// Footstep-time query only: newest decals first, with their rendered scale. +function onGore(x, y) { + const room = G.cur; + if (!room || !room.decals) return false; + for (let i = room.decals.length - 1; i >= 0; i--) { + const decal = room.decals[i], radius = 24 * decal.s; + if (dist2(x, y, decal.x, decal.y) < radius * radius) return true; + } + return false; +} + +function spawnGibs(x, y, n, big) { + 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); + const r = rand(2, big ? 7 : 5); + addParticle({ + type: 'gib', x, y, + vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, + 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), + }); + } +} + +function spawnSpark(x, y, ang) { + for (let i = 0; i < 4; i++) { + const a = ang + Math.PI + rand(-0.7, 0.7); + const sp = rand(80, 200); + addParticle({ type: 'spark', x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: rand(0.1, 0.25), t: 0, r: 1.5 }); + } +} + +function spawnText(x, y, str, color) { + addParticle({ type: 'text', x: x + rand(-6, 6), y: y - 10, vx: 0, vy: -45, life: 0.7, t: 0, str, color: color || '#f7ead8' }, true); +} + +function spawnBeam(x1, y1, ang, len) { + addParticle({ type: 'beam', x: x1, y: y1, ang, len, life: 0.22, t: 0 }, true); + for (let i = 0; i < 14; i++) { + const d = rand(20, len); + addParticle({ + type: 'spark', x: x1 + Math.cos(ang) * d, y: y1 + Math.sin(ang) * d, + vx: rand(-120, 120), vy: rand(-120, 120), life: rand(0.15, 0.35), t: 0, r: 2, + }); + } +} + +function spawnSmoke(x, y, ang, n, color) { + for (let i = 0; i < n; i++) { + const a = ang + rand(-0.8, 0.8), sp = rand(14, 55); + addParticle({ + type: 'smoke', x: x + rand(-3, 3), y: y + rand(-3, 3), + vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - rand(5, 24), + life: rand(0.28, 0.65), t: 0, r: rand(3, 7), color: color || '#21161a', + }); + } +} + +function spawnShockwave(x, y, radius, color, alpha) { + 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, + }); +} + +function spawnMuzzleFx(x, y, ang, behavior) { + if (behavior === 'slam') return; + const back = ang + Math.PI; + if (behavior === 'flame') { + for (let i = 0; i < 3; i++) { + const a = ang + rand(-0.22, 0.22), sp = rand(70, 155); + addParticle({ type: 'flame', x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: rand(0.14, 0.3), t: 0, r: rand(3, 6) }); + } + spawnSmoke(x, y, back, 1); + } else if (behavior === 'cone') { + for (let i = 0; i < 3; i++) { + const a = ang + rand(-0.3, 0.3), sp = rand(55, 130); + addParticle({ type: 'mist', x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: rand(0.18, 0.34), t: 0, r: rand(2, 5), color: '#82c83d' }); + } + } else if (behavior === 'beam') { + spawnShockwave(x, y, 34, '#77e7ff', 0.75); + spawnSpark(x, y, ang); + } else { + const count = behavior === 'spread' ? 4 : 2; + for (let i = 0; i < count; i++) { + const a = back + rand(-0.55, 0.55), sp = rand(45, 120); + addParticle({ type: 'spark', x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: rand(0.08, 0.18), t: 0, r: rand(0.8, 1.6) }); + } + if (behavior === 'lob' || behavior === 'lob_trap' || behavior === 'lob_swarm') spawnSmoke(x, y, back, 2); + } +} + +function spawnExplosionFx(x, y, r) { + const flames = Math.min(28, 14 + Math.floor(r / 10)); + for (let i = 0; i < flames; i++) { + const a = rand(0, TAU), sp = rand(60, 300); + addParticle({ type: 'flame', x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: rand(0.22, 0.58), t: 0, r: rand(3, 8) }); + } + spawnShockwave(x, y, r, '#ff9a55', 0.9); + spawnShockwave(x, y, r * 0.66, '#ffd49a', 0.6); + spawnSmoke(x, y, -Math.PI / 2, Math.min(8, 3 + Math.floor(r / 25))); + spawnGibs(x, y, 5, r >= 80); + addShake(6); +} + +function spawnCorpse(e) { + G.corpses.push({ + sprite: e.sprite, x: e.x, y: e.y, + facing: G.player ? angleTo(e.x, e.y, G.player.x, G.player.y) : 0, + size: e.drawSize || (e.boss ? 128 : 64), t: 0, life: e.boss ? 1.15 : 0.75, + }); + if (G.corpses.length > 48) G.corpses.shift(); +} + +function updateParticles(dt) { + for (let i = G.parts.length - 1; i >= 0; i--) { + const p = G.parts[i]; + p.t += dt; + if (p.t >= p.life) { G.parts.splice(i, 1); continue; } + p.x += (p.vx || 0) * dt; p.y += (p.vy || 0) * dt; + if (p.type === 'blood' || p.type === 'gib') { + const drag = Math.pow(0.88, dt * 60); + p.vx *= drag; p.vy *= drag; + } else if (p.type === 'smoke' || p.type === 'mist') { + const drag = Math.pow(0.94, dt * 60); + p.vx *= drag; p.vy *= drag; + } + if (p.rot !== undefined) p.rot += p.vr * dt; + } + for (let i = G.corpses.length - 1; i >= 0; i--) { + const c = G.corpses[i]; + c.t += dt; + if (c.t >= c.life) G.corpses.splice(i, 1); + } + if (G.player && G.player.hp <= 0) G.player.deathT = (G.player.deathT || 0) + dt; +} + +function drawCorpses(ctx) { + for (const c of G.corpses) { + const fade = clamp((c.life - c.t) * 4, 0, 1); + Sprites.shadow(ctx, c.x, c.y + c.size * 0.2, c.size * 0.3, c.size * 0.1, 0.2 * fade); + Sprites.actor(ctx, c.sprite, c.x, c.y, c.facing, 'death', c.t, c.size, fade); + } +} + +function drawParticles(ctx) { + for (const p of G.parts) { + const k = 1 - p.t / p.life; + if (p.type === 'blood') { + const speed = Math.hypot(p.vx || 0, p.vy || 0); + ctx.globalAlpha = k * (p.mist ? 0.55 : 0.92); + ctx.fillStyle = p.shade === 0 ? '#d02032' : (p.shade === 1 ? '#8f0d1c' : '#5d0913'); + ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(Math.atan2(p.vy || 0, p.vx || 1)); + ctx.beginPath(); + ctx.ellipse(0, 0, p.r * k + Math.min(4, speed * 0.018), p.r * k * (p.mist ? 0.45 : 0.7) + 0.4, 0, 0, TAU); + ctx.fill(); ctx.restore(); + } else if (p.type === 'gib') { + ctx.globalAlpha = k; + ctx.strokeStyle = p.bone ? '#5b4938' : '#3c0710'; ctx.lineWidth = 1; + ctx.fillStyle = p.bone ? '#d2bd98' : (p.shade === 0 ? '#9f1524' : (p.shade === 1 ? '#68101a' : '#3e0a0e')); + ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot); + ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); ctx.strokeRect(-p.w / 2, -p.h / 2, p.w, p.h); + ctx.restore(); + } else if (p.type === 'spark') { + ctx.globalAlpha = k; + ctx.strokeStyle = '#ffe69a'; + ctx.lineWidth = Math.max(1, p.r * k); + ctx.beginPath(); ctx.moveTo(p.x, p.y); + ctx.lineTo(p.x - p.vx * 0.025, p.y - p.vy * 0.025); ctx.stroke(); + } else if (p.type === 'flame') { + const radius = p.r * k + 1; + ctx.globalAlpha = k * 0.34; + ctx.fillStyle = '#7b1016'; ctx.beginPath(); ctx.arc(p.x, p.y, radius * 1.75, 0, TAU); ctx.fill(); + ctx.globalAlpha = k * 0.9; + ctx.fillStyle = k > 0.55 ? '#ff8c22' : '#d3261f'; ctx.beginPath(); ctx.arc(p.x, p.y, radius, 0, TAU); ctx.fill(); + ctx.fillStyle = '#ffe7a2'; ctx.globalAlpha = k * 0.75; + ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(0.8, radius * 0.38), 0, TAU); ctx.fill(); + } else if (p.type === 'smoke') { + ctx.globalAlpha = k * 0.36; + ctx.fillStyle = p.color; + ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (1.45 - k * 0.45), 0, TAU); ctx.fill(); + } else if (p.type === 'mist') { + ctx.globalAlpha = k * 0.52; + ctx.fillStyle = p.color || '#82c83d'; + ctx.beginPath(); ctx.arc(p.x, p.y, p.r * (0.7 + (1 - k) * 0.8), 0, TAU); ctx.fill(); + } else if (p.type === 'shockwave') { + const progress = 1 - k; + const radius = lerp(p.r, p.maxR, progress); + ctx.globalAlpha = k * p.alpha; + ctx.strokeStyle = p.color; ctx.lineWidth = 1.5 + k * 5; + ctx.beginPath(); ctx.ellipse(p.x, p.y, radius, radius * 0.72, 0, 0, TAU); ctx.stroke(); + ctx.globalAlpha = k * 0.18; + ctx.fillStyle = p.color; ctx.beginPath(); ctx.ellipse(p.x, p.y, radius * 0.86, radius * 0.58, 0, 0, TAU); ctx.fill(); + } else if (p.type === 'beam') { + ctx.globalAlpha = k; + ctx.strokeStyle = '#d9f6ff'; ctx.lineWidth = 13 * k + 3; + ctx.beginPath(); ctx.moveTo(p.x, p.y); + ctx.lineTo(p.x + Math.cos(p.ang) * p.len, p.y + Math.sin(p.ang) * p.len); ctx.stroke(); + ctx.strokeStyle = '#4bd7ff'; ctx.lineWidth = 4; + ctx.stroke(); + ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1.5; + ctx.stroke(); + } else if (p.type === 'text') { + ctx.globalAlpha = Math.min(1, k * 2); + ctx.fillStyle = p.color; + ctx.font = 'bold 12px monospace'; ctx.textAlign = 'center'; + ctx.fillText(p.str, p.x, p.y); + } + } + ctx.globalAlpha = 1; +} diff --git a/js/perks.js b/js/perks.js new file mode 100644 index 0000000..28f2629 --- /dev/null +++ b/js/perks.js @@ -0,0 +1,181 @@ +// ---- Crimsonland-style level-up perk draft ---- + +const PERKS = [ + { id: 'adrenal', name: 'Adrenal Surge', desc: '+3.5% fire rate', apply: s => { s.rateMul *= 1.035; } }, + { id: 'sharpen', name: 'Sharpened', desc: '+4% damage', apply: s => { s.dmgMul *= 1.04; } }, + { id: 'quick', name: 'Quickening', desc: '+3% move speed', apply: s => { s.speedMul *= 1.03; } }, + { id: 'longbone', name: 'Long Bone', desc: '+5% range', apply: s => { s.rangeMul *= 1.05; } }, + { id: 'bigheart', name: 'Big Heart', desc: '+½ max heart, heal ½ heart', apply: (s, p) => { s.maxHp += 1; p.hp = Math.min(s.maxHp, p.hp + 1); } }, + { id: 'magnetb', name: 'Magnet Bile', desc: '+10% pickup radius', apply: s => { s.magnet *= 1.10; } }, + { id: 'shieldheart', name: 'Shield Heart', desc: '+½ shield heart at the start of each floor', apply: (s, p) => { s.shieldPerk = (s.shieldPerk || 0) + 1; p.shieldHp = (p.shieldHp || 0) + 1; } }, + { id: 'scavenge', name: 'Scavenger', desc: '+4% drop luck', apply: s => { s.luck += 0.04; } }, + { id: 'bloodrush',name: 'Bloodrush', desc: '+5% XP gain, bonus crystal chance', apply: s => { s.xpMul *= 1.05; } }, + { id: 'deadeye', name: 'Deadeye', desc: '+5% shot speed', apply: s => { s.shotSpeedMul *= 1.05; } }, + { id: 'critbone', name: 'Bone Splitter', desc: '+2% critical chance', apply: s => { s.crit += 0.02; } }, + { id: 'critmeat', name: 'Cold Precision', desc: '+15% critical damage', apply: s => { s.critMul += 0.15; } }, + { id: 'flensing', name: 'Flensing Edge', desc: 'Hits inflict light bleeding', apply: s => { s.bleed += 0.08; } }, + { id: 'ember', name: 'Ember Hands', desc: '8% chance to ignite', apply: s => { s.igniteChance += 0.08; } }, + { id: 'frostbile',name: 'Chill Bile', desc: '10% chance to slow', apply: s => { s.slowOnHit += 0.10; } }, + { id: 'heavyhand',name: 'Heavy Hand', desc: '+20% knockback', apply: s => { s.knockbackMul *= 1.20; } }, + { id: 'thickhide',name: 'Thick Hide', desc: '+4% chance to ignore damage', apply: s => { s.armor += 1 / 24; } }, + { id: 'secondwind',name: 'Second Wind', desc: '+0.15s hurt immunity', apply: s => { s.invBonus += 0.15; } }, + { id: 'scrapfeed',name: 'Scrap Feed', desc: '5% less ammo consumed', apply: s => { s.ammoEff *= 1.05; } }, + { id: 'boneknit', name: 'Bone Knit', desc: 'Heal ½ heart after each combat room', apply: s => { s.roomHeal += 1; } }, + { id: 'spiteflesh',name: 'Spite Flesh', desc: 'Contact attackers take damage', apply: s => { s.thorns += 4; } }, + { id: 'carrion', name: 'Carrion Sense', desc: '+8% pickup radius and pull speed', apply: s => { s.magnet *= 1.08; s.magnetPull *= 1.08; } }, + { id: 'sinew', name: 'Sinew Weave', desc: '+3% move speed and range', apply: s => { s.speedMul *= 1.03; s.rangeMul *= 1.03; } }, +]; + +const PERK_CARD_W = 170, PERK_CARD_H = 230, PERK_CARD_GAP = 18; +const PERK_CARD_Y = H / 2 - 100; +function perkCardX(i) { + const total = PERK_CARD_W * 3 + PERK_CARD_GAP * 2; + return (W - total) / 2 + i * (PERK_CARD_W + PERK_CARD_GAP); +} + +function xpForLevel(level) { return 8 + level * 4; } + +function gainXP(v) { + const p = G.player; + p.xp += v; + addScore(Math.round(v)); + while (p.xp >= xpForLevel(p.level)) { + p.xp -= xpForLevel(p.level); + p.level++; + p.stats.rerolls += p.stats.rerollPerLevel || 0; + G.pendingLevelups++; + } +} + +function openPerkDraft() { + if (G.autoPerk) { + Sfx.levelup(); + let guard = 0; + while (G.pendingLevelups > 0 && guard++ < 64) { + grantPerk(choice(PERKS)); + G.pendingLevelups--; + } + G.perkChoices = null; + G.mode = 'play'; + return; + } + const opts = shuffle([...PERKS]).slice(0, 3); + G.perkChoices = opts; + G.mode = 'levelup'; + Sfx.levelup(); +} + +function grantPerk(perk) { + perk.apply(G.player.stats, G.player); + addToast(perk.name, perk.desc); + Sfx.perk(); +} + +function choosePerk(i) { + if (!G.perkChoices || i >= G.perkChoices.length) return; + const perk = G.perkChoices[i]; + grantPerk(perk); + G.perkChoices = null; + G.pendingLevelups--; + if (G.pendingLevelups > 0) openPerkDraft(); + else G.mode = 'play'; +} + +function updateLevelup() { + if (keyPressed('1')) choosePerk(0); + else if (keyPressed('2')) choosePerk(1); + else if (keyPressed('3')) choosePerk(2); + else if (keyPressed('4', ' ')) choosePerk(irand(0, G.perkChoices.length - 1)); + else if (keyPressed('t')) { + setAutoPerk(true); + G.mode = 'play'; + openPerkDraft(); + } + else if (keyPressed('r') && G.player.stats.rerolls > 0) { + G.player.stats.rerolls--; + G.perkChoices = shuffle([...PERKS]).slice(0, 3); + Sfx.menu(); + } + else if (Input.mpressed && G.perkChoices) { + // click a card + for (let i = 0; i < 3; i++) { + const cx = perkCardX(i); + if (Input.mx > cx && Input.mx < cx + PERK_CARD_W && Input.my > PERK_CARD_Y && Input.my < PERK_CARD_Y + PERK_CARD_H) { + choosePerk(i); break; + } + } + if (Input.my > PERK_CARD_Y + PERK_CARD_H + 10 && Input.my < PERK_CARD_Y + PERK_CARD_H + 42 && Input.mx > W / 2 - 115 && Input.mx < W / 2 + 115) { + choosePerk(irand(0, G.perkChoices.length - 1)); + } + } +} + +function drawLevelup(ctx) { + ctx.fillStyle = 'rgba(7,2,4,0.91)'; + ctx.fillRect(0, 0, W, H); + ctx.save(); + ctx.globalAlpha = 0.12; + Sprites.draw(ctx, 'decal_blood2', 122, 96, -0.35, 180); + Sprites.draw(ctx, 'decal_blood3', W - 108, H - 76, 0.5, 190); + ctx.restore(); + drawPixelTag(ctx, 'MUTATION DRAFT // LEVEL ' + String(G.player.level).padStart(2, '0'), W / 2 - 108, H / 2 - 184, { + width: 216, height: 24, color: '#cfaca5', accent: '#bf263d', + }); + ctx.fillStyle = '#ef2946'; ctx.font = 'bold 30px monospace'; ctx.textAlign = 'center'; + ctx.fillText('CHOOSE A CUT', W / 2, H / 2 - 137); + drawBloodTrim(ctx, W / 2 - 145, H / 2 - 126, 290, G.player.level, 0.62); + ctx.fillStyle = '#8d7773'; ctx.font = 'bold 10px monospace'; + ctx.fillText('SELECT ONE PERMANENT RUN MODIFIER', W / 2, H / 2 - 101); + + for (let i = 0; i < 3; i++) { + const perk = G.perkChoices[i]; + const cx = perkCardX(i); + const hover = Input.mx > cx && Input.mx < cx + PERK_CARD_W && Input.my > PERK_CARD_Y && Input.my < PERK_CARD_Y + PERK_CARD_H; + if (hover) { + ctx.save(); ctx.globalAlpha = 0.16; ctx.fillStyle = '#ef3150'; + pixelPanelPath(ctx, cx - 4, PERK_CARD_Y - 4, PERK_CARD_W + 8, PERK_CARD_H + 8, 10); ctx.fill(); + ctx.restore(); + } + drawPixelPanel(ctx, cx, PERK_CARD_Y, PERK_CARD_W, PERK_CARD_H, { + cut: 8, lineWidth: hover ? 2 : 1, fill: hover ? '#321019' : '#16090d', + border: hover ? '#ef3150' : '#63303a', accent: hover ? '#ef3150' : '#992238', + blood: true, seed: i + G.player.level, bloodAlpha: hover ? 0.55 : 0.28, + }); + drawPixelTag(ctx, '0' + (i + 1), cx + 10, PERK_CARD_Y + 10, { + width: 34, height: 24, color: '#e8bf47', accent: '#9d7726', fill: '#130b0d', + }); + drawPixelPanel(ctx, cx + 47, PERK_CARD_Y + 16, 76, 76, { + cut: 5, shadow: false, fill: 'rgba(3,2,3,0.72)', + border: hover ? '#c93a4e' : '#49252d', accent: hover ? '#dc3049' : '#75303e', + }); + Sprites.draw(ctx, 'perk_' + perk.id, cx + PERK_CARD_W / 2, PERK_CARD_Y + 54, 0, 68); + ctx.textAlign = 'center'; + ctx.fillStyle = '#eee2d5'; ctx.font = 'bold 13px monospace'; + wrapText(ctx, perk.name.toUpperCase(), cx + PERK_CARD_W / 2, PERK_CARD_Y + 119, 150, 16); + ctx.fillStyle = '#a9928b'; ctx.font = '10px monospace'; + wrapText(ctx, perk.desc.toUpperCase(), cx + PERK_CARD_W / 2, PERK_CARD_Y + 169, 146, 14); + ctx.fillStyle = hover ? '#d7aa39' : '#5f4d4d'; ctx.font = 'bold 8px monospace'; + ctx.fillText(hover ? 'CLICK TO INSTALL' : 'PERMANENT UPGRADE', cx + PERK_CARD_W / 2, PERK_CARD_Y + 215); + } + drawPixelTag(ctx, '[4 / SPACE] RANDOM CUT', W / 2 - 115, PERK_CARD_Y + PERK_CARD_H + 10, { + width: 230, height: 30, color: '#e7c65c', accent: '#a47e25', + }); + drawPixelTag(ctx, '[T] AUTO-DRAFT: ' + (G.autoPerk ? 'ON' : 'OFF'), W - 202, 24, { + width: 178, height: 26, color: G.autoPerk ? '#79d9ca' : '#a9958e', accent: G.autoPerk ? '#378b80' : '#67303a', + }); + if (G.player.stats.rerolls > 0) drawPixelTag(ctx, '[R] REROLL ×' + G.player.stats.rerolls, 24, 24, { + width: 138, height: 26, color: '#d8b84e', accent: '#947124', + }); +} + +function wrapText(ctx, text, x, y, maxW, lh) { + const words = text.split(' '); + let line = '', yy = y; + for (const w of words) { + const test = line ? line + ' ' + w : w; + if (ctx.measureText(test).width > maxW && line) { + ctx.fillText(line, x, yy); yy += lh; line = w; + } else line = test; + } + ctx.fillText(line, x, yy); +} diff --git a/js/pickups.js b/js/pickups.js new file mode 100644 index 0000000..877b6d6 --- /dev/null +++ b/js/pickups.js @@ -0,0 +1,194 @@ +// ---- pickups: XP gems, hearts, ammo, weapon drops, pedestals, stairs ---- + +function spawnPickup(type, x, y, data) { + const p = Object.assign({ + type, x, y, t: rand(0, TAU), + vx: rand(-40, 40), vy: rand(-40, 40), + magnet: false, + }, data || {}); + G.pickups.push(p); + return p; +} + +function spawnGems(x, y, value) { + while (value >= 5) { spawnPickup('gem', x + rand(-10, 10), y + rand(-10, 10), { v: 5 }); value -= 5; } + for (let i = 0; i < value; i++) spawnPickup('gem', x + rand(-10, 10), y + rand(-10, 10), { v: 1 }); +} + + +// Ammo drops preserve useful trigger time: low-damage rapid weapons receive +// a larger share, while heavy multi-pellet shots receive fewer rounds. +function ammoRefillFraction(w) { + const effectiveDamage = Math.max(1, (w.dmg || 1) * (w.pellets || 1)); + const interval = Math.max(0.05, w.interval ?? 0.3); + const damageWeight = Math.log2(effectiveDamage / 8); + const rateWeight = Math.log2(interval / 0.3); + // Heavy, slow attacks push the fraction down; light, rapid attacks push it up. + const heaviness = clamp((damageWeight + rateWeight) / 4, -1, 1); + return clamp(0.62 - heaviness * 0.28, 0.30, 0.88) * (w.ammoWeight || 1); +} + +// room-clear reward burst (weapons come from bosses / item rooms, not room clears) +function spawnRoomReward(x, y, floor) { + spawnGems(x, y, irand(3, 5 + floor)); + const luck = G.player.stats.luck; + if (chance(0.24 + luck * 0.3)) spawnPickup('ammo', x + rand(-30, 30), y + rand(-30, 30)); + if (chance(0.10 + luck * 0.3)) spawnPickup('heart', x + rand(-30, 30), y + rand(-30, 30)); +} + +function updatePickups(dt) { + const p = G.player; + const magR = 46 * p.stats.magnet; + for (let i = G.pickups.length - 1; i >= 0; i--) { + const k = G.pickups[i]; + if (!k || k.dead) { G.pickups.splice(i, 1); continue; } + if (k.delay) k.delay -= dt; + k.t += dt; + k.x += k.vx * dt; k.y += k.vy * dt; + k.vx *= 0.9; k.vy *= 0.9; + const d = dist(k.x, k.y, p.x, p.y); + + // gems & small pickups fly to the player inside magnet radius + if ((k.type === 'gem' || k.type === 'heart' || k.type === 'ammo') && d < magR) { + const a = angleTo(k.x, k.y, p.x, p.y); + const sp = lerp(320, 60, d / magR); + k.x += Math.cos(a) * sp * dt; k.y += Math.sin(a) * sp * dt; + } + + if (d < p.r + 12 && !(k.delay > 0)) { + collectPickup(k); + G.pickups.splice(i, 1); + } + } +} + +function collectPickup(k) { + const p = G.player; + switch (k.type) { + case 'gem': + gainXP(k.v * p.stats.xpMul); + Sfx.gem(); + break; + case 'heart': + if (p.hp < p.stats.maxHp) { healPlayer(2); spawnText(p.x, p.y, '+2', '#d92038'); } + else addScore(25); + Sfx.heart(); + break; + case 'ammo': { + // Generic rounds refill every finite-ammo weapon the butcher is carrying, + // including a special weapon currently holstered behind the Bone Popper. + const carried = []; + if (p.weapon && p.weapon.id !== 'bonepopper') carried.push(p.weapon); + if (p.holstered && p.holstered.id !== 'bonepopper' && !carried.includes(p.holstered)) carried.push(p.holstered); + let total = 0, wasted = 0; + for (const inst of carried) { + const def = WEAPONS[inst.id]; + const cap = def.ammo * 1.5; + const wanted = Math.max(1, Math.ceil(def.ammo * ammoRefillFraction(def) * p.stats.ammoPickupMul)); + const add = Math.max(0, Math.min(wanted, cap - inst.ammo)); + inst.ammo += add; + total += add; + wasted += wanted - add; + } + if (wasted > 0) addScore(Math.round(wasted * 2)); + if (total > 0) spawnText(p.x, p.y, '+' + total + ' CARRIED AMMO', '#d0b060'); + else if (!carried.length) addScore(15); + Sfx.ammo(); + break; + } + case 'weapon': { + const w = WEAPONS[k.wid]; + // drop the old special with its remaining ammo so it's never wasted + const old = p.weapon.id !== 'bonepopper' ? p.weapon : p.holstered; + if (old) { + const a = rand(0, TAU); + spawnPickup('weapon', p.x + Math.cos(a) * 34, p.y + Math.sin(a) * 34, + { wid: old.id, ammo: old.ammo, delay: 0.6 }); + } + p.weapon = { id: w.id, ammo: k.ammo !== undefined ? k.ammo : w.ammo }; + p.holstered = null; + p.fireT = 0; p.charge = 0; + addToast(w.name, w.desc); + spawnText(p.x, p.y, w.name.toUpperCase(), '#ffd060'); + Sfx.weapon(); + break; + } + case 'item': { + giveItem(k.iid); + for (const q of G.pickups) if (q.type === 'itemspot') q.dead = true; // remove pedestal base + break; + } + case 'stairs': + nextFloor(); + break; + } +} + +// readable nameplate shown when the player walks up to a pickup +function drawPickupLabel(ctx, text, x, y, color) { + ctx.save(); + ctx.font = 'bold 11px monospace'; + ctx.textAlign = 'center'; + const w = Math.ceil(ctx.measureText(text).width) + 18; + drawPixelPanel(ctx, x - w / 2, y - 10, w, 20, { + cut: 3, shadow: false, fill: 'rgba(7,3,5,0.9)', border: 'rgba(117,59,68,0.9)', accent: color, + }); + ctx.fillStyle = color; + ctx.fillText(text, x, y + 4); + ctx.restore(); +} + +function drawPickups(ctx) { + const p = G.player; + for (const k of G.pickups) { + const bob = Math.sin(k.t * 4) * 2.5; + if (k.type !== 'itemspot' && k.type !== 'stairs') Sprites.shadow(ctx, k.x, k.y + 9, 11, 4, 0.3); + if (k.type === 'gem') { + const big = k.v >= 5; + ctx.save(); + ctx.globalAlpha = 0.12 + Math.sin(k.t * 6) * 0.04; + ctx.fillStyle = big ? '#55f5dc' : '#55aaff'; + ctx.beginPath(); ctx.arc(k.x, k.y + bob, big ? 20 : 14, 0, TAU); ctx.fill(); + ctx.restore(); + Sprites.draw(ctx, big ? 'gem_big' : 'gem_small', k.x, k.y + bob, k.t * 0.8, big ? 32 : 24, false, 1, 0.85 + Math.abs(Math.sin(k.t * 2)) * 0.15, 1); + } + else if (k.type === 'heart') Sprites.draw(ctx, 'heart', k.x, k.y + bob, Math.sin(k.t * 2) * 0.08, 32, false, 1, 1 + Math.sin(k.t * 5) * 0.05, 1 + Math.sin(k.t * 5) * 0.05); + else if (k.type === 'ammo') Sprites.draw(ctx, 'ammo', k.x, k.y + bob, Math.sin(k.t * 1.5) * 0.04, 32); + else if (k.type === 'weapon') { + ctx.save(); + ctx.globalAlpha = 0.24 + Math.sin(k.t * 5) * 0.08; + ctx.strokeStyle = '#ffd76a'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(k.x, k.y, 24 + Math.sin(k.t * 4) * 3, 0, TAU); ctx.stroke(); + ctx.restore(); + Sprites.draw(ctx, 'w_' + k.wid, k.x, k.y + bob, 0, 64); + } else if (k.type === 'itemspot') { + Sprites.draw(ctx, 'pedestal', k.x, k.y, 0, 64); + } else if (k.type === 'item') { + ctx.save(); + ctx.globalAlpha = 0.2 + Math.sin(k.t * 3) * 0.07; + ctx.fillStyle = '#ffe075'; + ctx.beginPath(); ctx.arc(k.x, k.y - 28, 30, 0, TAU); ctx.fill(); + ctx.globalAlpha = 0.35; + ctx.strokeStyle = '#ffe9a6'; ctx.lineWidth = 1; + ctx.beginPath(); ctx.arc(k.x, k.y - 28, 25 + Math.sin(k.t * 4) * 3, 0, TAU); ctx.stroke(); + ctx.restore(); + Sprites.draw(ctx, 'i_' + k.iid, k.x, k.y - 28 + bob, 0, 64); + } else if (k.type === 'stairs') { + Sprites.draw(ctx, 'stairs', k.x, k.y, 0, 64); + ctx.save(); + ctx.globalAlpha = 0.5 + Math.sin(k.t * 3) * 0.25; + ctx.strokeStyle = '#d8b84e'; ctx.lineWidth = 2; + const inset = 3 + Math.sin(k.t * 3) * 2; + ctx.strokeRect(k.x - 31 + inset, k.y - 31 + inset, 62 - inset * 2, 62 - inset * 2); + ctx.restore(); + } + + // hover nameplates when close + if (dist2(k.x, k.y, p.x, p.y) < 90 * 90) { + if (k.type === 'weapon') drawPickupLabel(ctx, WEAPONS[k.wid].name, k.x, k.y - 44, '#ffd060'); + else if (k.type === 'item') drawPickupLabel(ctx, ITEMS[k.iid].name, k.x, k.y - 66, '#c9a227'); + else if (k.type === 'stairs') drawPickupLabel(ctx, 'STAIRS DOWN', k.x, k.y - 52, '#e8e0d0'); + else if (k.type === 'ammo') drawPickupLabel(ctx, 'AMMO', k.x, k.y - 26, '#d0b060'); + } + } +} diff --git a/js/player.js b/js/player.js new file mode 100644 index 0000000..00b6649 --- /dev/null +++ b/js/player.js @@ -0,0 +1,304 @@ +// ---- the butcher (player) ---- + +function initPlayer() { + G.player = { + x: W / 2, y: H / 2, r: 20, + hp: 6, shieldHp: 0, + aim: 0, bodyFacing: 0, fireT: 0, charge: 0, invT: 0, frenzyT: 0, + step: 0, stepTick: 0, moveBlend: 0, recoil: 0, muzzleT: 0, + animT: 0, attackT: 0, actionT: 0, hitT: 0, deathT: 0, + weapon: { id: 'bonepopper', ammo: Infinity }, + holstered: null, // a special weapon set aside while the pistol is out + level: 1, xp: 0, + items: {}, // iid -> quality tier (1..3) + orbAng: 0, + stats: { + dmgMul: 1, rateMul: 1, speedMul: 1, shotSpeedMul: 1, rangeMul: 1, + pierce: 0, bounce: 0, homing: 0, split: 0, fan: 0, rear: 0, splinter: 0, + orbitals: 0, lifestealChance: 0, explodeOnKill: 0, + armor: 0, luck: 0, magnet: 1, magnetPull: 1, xpMul: 1, crit: 0.05, critMul: 2, bloodlust: 0, + bleed: 0, igniteChance: 0, slowOnHit: 0, stunOnHit: 0, acidOnHit: 0, pullOnHit: 0, + chain: 0, knockbackMul: 1, invBonus: 0, ammoEff: 1, ammoPickupMul: 1, + roomHeal: 0, thorns: 0, retaliate: 0, sizeMul: 1, mortar: 0, frenzy: 0, + orbSpeedMul: 1, orbDmgMul: 1, overShield: 0, + rerolls: 0, rerollPerLevel: 0, + maxHp: 6, + }, + }; +} + +function refreshOrbitals(p) { /* orbital count is read live from stats */ } + +// R: swap between the Bone Popper and your picked-up special weapon +function swapWeapon(silent) { + const p = G.player; + if (!p.holstered && p.weapon.id === 'bonepopper') return; // nothing to swap + if (p.weapon.id === 'bonepopper' && p.holstered && p.holstered.ammo <= 0) { + if (!silent) addToast(WEAPONS[p.holstered.id].name, 'empty — find ammo'); + Sfx.menu(); + return; + } + const tmp = p.weapon; + p.weapon = p.holstered || { id: 'bonepopper', ammo: Infinity }; + p.holstered = tmp.id === 'bonepopper' ? null : tmp; // the pistol itself is never holstered + p.charge = 0; + p.fireT = 0; + Sfx.pickup(); + if (!silent) addToast(WEAPONS[p.weapon.id].name, p.weapon.id === 'bonepopper' ? 'sidearm out' : 'back in hand'); +} + +function updatePlayer(dt) { + const p = G.player; + const st = p.stats; + if (p.invT > 0) p.invT -= dt; + if (p.hitT > 0) p.hitT -= dt; + if (p.frenzyT > 0) p.frenzyT -= dt; + p.animT += dt; + if (p.attackT > 0) { p.attackT -= dt; p.actionT += dt; } + + // movement + let mx = 0, my = 0; + if (keyDown('w', 'arrowup')) my -= 1; + if (keyDown('s', 'arrowdown')) my += 1; + if (keyDown('a', 'arrowleft')) mx -= 1; + if (keyDown('d', 'arrowright')) mx += 1; + if (mx || my) { + const len = Math.hypot(mx, my); + const spd = 178 * st.speedMul; + p.x += (mx / len) * spd * dt; + p.y += (my / len) * spd * dt; + p.step += dt * 9 * Math.min(st.speedMul, 1.8); + const stepTick = Math.floor(p.step / Math.PI); + if (stepTick !== p.stepTick && p.hp > 0) Sfx.footstep(p.moveBlend, onGore(p.x, p.y)); + p.stepTick = stepTick; + } + else { + // Finish on a planted contact frame instead of freezing halfway through a stride. + const planted = Math.round(p.step / Math.PI) * Math.PI; + p.step = lerp(p.step, planted, clamp(dt * 10, 0, 1)); + p.stepTick = Math.round(p.step / Math.PI); + } + p.moveBlend = lerp(p.moveBlend, mx || my ? 1 : 0, clamp(dt * 12, 0, 1)); + p.recoil = Math.max(0, p.recoil - dt * 7); + p.muzzleT = Math.max(0, p.muzzleT - dt); + p.x = clamp(p.x, WALL + p.r, W - WALL - p.r); + p.y = clamp(p.y, WALL + p.r, H - WALL - p.r); + + // aim + p.aim = angleTo(p.x, p.y, Input.mx, Input.my); + // The waist and legs turn smoothly with travel while the weapon torso aims freely. + // Once planted, the lower body turns back under the independently aimed torso. + if (mx || my) { + p.bodyFacing = angleLerp(p.bodyFacing, Math.atan2(my, mx), clamp(dt * 12, 0, 1)); + } else { + p.bodyFacing = angleLerp(p.bodyFacing, p.aim, clamp(dt * 8, 0, 1)); + } + + // firing + const w = WEAPONS[p.weapon.id]; + const sustained = ['cone', 'flame', 'saw', 'beam'].includes(w.behavior); + Sfx.syncWeaponLoop(w, sustained && Input.mdown && p.weapon.ammo > 0); + if (p.fireT > 0) p.fireT -= dt; + const frenzyMul = p.frenzyT > 0 ? 1 + st.frenzy : 1; + const effectiveRate = st.rateMul * frenzyMul; + const rate = w.interval / effectiveRate; + + if (w.behavior === 'saw') { + if (Input.mdown && p.weapon.ammo > 0) { + sawTick(p, w, p.weapon, dt); + p.muzzleT = 0.04; p.recoil = 0.18 + Math.sin(G.time * 45) * 0.05; + if (p.attackT <= 0) p.actionT = 0; + p.attackT = 0.2; + } + else p.fireT = 0; + } else if (w.behavior === 'beam') { + if (Input.mdown && p.weapon.ammo > 0) { + p.charge = Math.min(p.charge + dt * effectiveRate * Math.sqrt(st.shotSpeedMul), w.chargeTime); + } else if (Input.mreleased && p.charge > 0) { + if (p.charge >= w.chargeTime * 0.4) fireBeam(p, w, p.charge / w.chargeTime); + p.charge = 0; + } else { + p.charge = Math.max(0, p.charge - dt * 2); + } + } else { + if (Input.mdown && p.fireT <= 0 && p.weapon.ammo > 0) { + fireWeapon(p, w); + p.recoil = Math.min(1, p.recoil + (w.behavior === 'slam' ? 1 : 0.45)); + p.muzzleT = 0.07; + p.attackT = 0.4; p.actionT = 0; + p.fireT = rate; + if (p.weapon.ammo !== Infinity) p.weapon.ammo -= 1 / st.ammoEff; + Sfx.shoot(w); + if (w.behavior === 'slam') addShake(5); + } + } + + // out of ammo -> back to the Bone Popper + if (p.weapon.ammo <= 0 && p.weapon.id !== 'bonepopper') { + const spent = p.weapon; + p.weapon = { id: 'bonepopper', ammo: Infinity }; + p.holstered = spent; + p.charge = 0; p.fireT = 0; + Sfx.stopAllLoops(); + addToast('Bone Popper', WEAPONS[spent.id].name + ' is empty'); + } + + // orbital knives + if (st.orbitals > 0) { + p.orbAng += dt * 3.2 * st.orbSpeedMul; + const n = st.orbitals; + for (let i = 0; i < n; i++) { + const a = p.orbAng + (i / n) * TAU; + const ox = p.x + Math.cos(a) * 58, oy = p.y + Math.sin(a) * 58; + for (const e of G.enemies) { + if (e.orbT > 0) continue; + if (dist2(ox, oy, e.x, e.y) < (12 + e.r) * (12 + e.r)) { + e.orbT = 0.35; + damageEnemy(e, 12 * st.dmgMul * st.orbDmgMul, a + Math.PI / 2, true, { + source: 'player', procScale: 0.5, procMagnitudeScale: 0.5, + }); + spawnBlood(e.x, e.y, a, 3); + } + } + } + } + + // open queued level-ups + if (G.pendingLevelups > 0 && G.mode === 'play') openPerkDraft(); +} + +function armorBlockChance(rating) { + return Math.min(0.75, Math.max(0, rating) / (1 + Math.max(0, rating))); +} + +function pressureRelief(dmgTaken) { + const p = G.player; + const hpFrac = clamp(p.hp / Math.max(1, p.stats.maxHp), 0, 1); + const severity = clamp(dmgTaken / Math.max(2, p.stats.maxHp * 0.25), 0.4, 2); + const desperation = 1 + (1 - hpFrac) * 2; + const churn = 1 + Math.min(G.recentHits.length, 4) * 0.35; + return 0.03 * severity * desperation * churn; +} + +function hurtPlayer(dmg, ang, attacker) { + const p = G.player; + if (p.invT > 0 || p.hp <= 0 || G.mode !== 'play') return; + if (p.stats.armor > 0 && chance(armorBlockChance(p.stats.armor))) { + p.invT = 0.2; + p.hitT = 0.1; + spawnText(p.x, p.y - 20, 'BLOCKED', '#d8c991'); + addShake(2); + return; + } + let reduced = Math.max(1, Math.round(dmg)); + // shield hearts absorb first (from the Shield Heart perk) + if (p.shieldHp > 0) { + const absorbed = Math.min(p.shieldHp, reduced); + p.shieldHp -= absorbed; + reduced -= absorbed; + if (p.shieldHp <= 0) { spawnText(p.x, p.y - 20, 'SHIELD DOWN', '#3bc9e0'); Sfx.shieldBreak(); } + } + p.hp -= reduced; + p.hitT = 0.18; + p.invT = 0.9 + p.stats.invBonus; + G.flash = 0.35; + addShake(7); + Sfx.hurt(); + spawnBlood(p.x, p.y, ang, 8); + if (ang) { p.x += Math.cos(ang) * 14; p.y += Math.sin(ang) * 14; } + if (reduced > 0) { + G.roomDamaged = true; + G.recentHits = (G.recentHits || []).filter(t => G.time - t < 12); + G.recentHits.push(G.time); + G.pressure = clamp(G.pressure - pressureRelief(reduced), PRESSURE_MIN, PRESSURE_MAX); + G.streak = 0; + } + if (attacker && attacker.hp > 0 && p.stats.thorns > 0) { + damageEnemy(attacker, p.stats.thorns * p.stats.dmgMul, angleTo(p.x, p.y, attacker.x, attacker.y), true, { noProc: true }); + } + if (p.stats.retaliate > 0) { + areaDamage(p.x, p.y, 85 * Math.sqrt(p.stats.sizeMul), p.stats.retaliate * p.stats.dmgMul, true, { noProc: true }); + spawnExplosionFx(p.x, p.y, 70); + } + if (p.hp <= 0) { + p.hp = 0; + p.deathT = 0; + gameOver(); + } +} + +function healPlayer(n) { + const p = G.player; + const missing = Math.max(0, p.stats.maxHp - p.hp); + const healed = Math.min(missing, n); + p.hp += healed; + const overflow = n - healed; + if (overflow > 0 && p.stats.overShield > 0) { + p.shieldHp = Math.min((p.stats.shieldPerk || 0) + p.stats.overShield, p.shieldHp + overflow); + } +} + + +// Lifesteal has dedicated feedback so ordinary healing sources do not double-print. +function lifestealPlayer(amount) { + const p = G.player; + if (!p || p.hp >= p.stats.maxHp) return false; + const before = p.hp; + healPlayer(amount); + const healed = p.hp - before; + if (healed <= 0) return false; + const hearts = healed / 2; + const label = hearts === 0.5 ? '+½ HEART' : ('+' + hearts + (hearts === 1 ? ' HEART' : ' HEARTS')); + spawnText(p.x, p.y - 18, label, '#d92038'); + Sfx.lifesteal(); + return true; +} + +function drawPlayer(ctx) { + const p = G.player; + const blink = p.invT > 0 && Math.floor(G.time * 20) % 2 === 0; + if (blink && p.hp > 0) return; + + // orbital knives + const st = p.stats; + if (st.orbitals > 0) { + for (let i = 0; i < st.orbitals; i++) { + const a = p.orbAng + (i / st.orbitals) * TAU; + Sprites.draw(ctx, 'bullet_cleaver', p.x + Math.cos(a) * 58, p.y + Math.sin(a) * 58, a + Math.PI / 2, 24); + } + } + + const w = WEAPONS[p.weapon.id]; + 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); + } else { + Sprites.legs(ctx, p.x, p.y + 4, p.bodyFacing, p.step, 124); + const torsoOffset = (w.torsoFwd || 18) - kick; + const torsoX = p.x + Math.cos(p.aim) * torsoOffset; + const torsoY = p.y + Math.sin(p.aim) * torsoOffset; + Sprites.draw(ctx, 'pt_' + p.weapon.id, torsoX, torsoY, p.aim, w.torsoW || 116, p.hitT > 0); + } + if (p.hp > 0 && p.muzzleT > 0 && w.behavior !== 'slam') { + const muzzleDist = (w.muzzle || 40) - kick; + const mx = p.x + Math.cos(p.aim) * muzzleDist, my = p.y + Math.sin(p.aim) * muzzleDist; + ctx.save(); ctx.translate(mx, my); ctx.rotate(p.aim); + ctx.globalAlpha = p.muzzleT / 0.07; + ctx.fillStyle = '#ffe6a0'; + ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(13, -5); ctx.lineTo(9, 0); ctx.lineTo(13, 5); ctx.closePath(); ctx.fill(); + ctx.restore(); + } + + // Spinal Tap charge indicator + if (w.behavior === 'beam' && p.charge > 0.05) { + const k = p.charge / w.chargeTime; + ctx.strokeStyle = k >= 1 ? '#60c0ff' : '#2a5a8a'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(p.x, p.y, p.r + 8, -Math.PI / 2, -Math.PI / 2 + TAU * k); ctx.stroke(); + if (k >= 1 && chance(0.3)) { + addParticle({ type: 'spark', x: p.x + rand(-10, 10), y: p.y + rand(-10, 10), vx: 0, vy: -30, life: 0.2, t: 0, r: 1.5 }); + } + } +} diff --git a/js/rooms.js b/js/rooms.js new file mode 100644 index 0000000..5682389 --- /dev/null +++ b/js/rooms.js @@ -0,0 +1,369 @@ +// ---- floor generation, room state, doors, hazards ---- + +const DIRS = { n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0] }; +const OPP = { n: 's', s: 'n', e: 'w', w: 'e' }; + +function genFloor(floor) { + G.rooms = {}; + const target = Math.min(5 + floor, 9); + G.rooms['0,0'] = makeRoom(0, 0, 'start'); + let cx = 0, cy = 0, guard = 0; + while (Object.keys(G.rooms).length < target && guard++ < 500) { + const [dx, dy] = choice(Object.values(DIRS)); + const nx = cx + dx, ny = cy + dy; + if (!G.rooms[nx + ',' + ny]) { + G.rooms[nx + ',' + ny] = makeRoom(nx, ny, 'combat'); + if (chance(0.5)) { cx = nx; cy = ny; } + } else { cx = nx; cy = ny; } + } + // assign boss (farthest) and item (second farthest, dist >= 2) + const list = Object.values(G.rooms).sort((a, b) => + (Math.abs(b.gx) + Math.abs(b.gy)) - (Math.abs(a.gx) + Math.abs(a.gy))); + list[0].type = 'boss'; + for (const r of list) { + const d = Math.abs(r.gx) + Math.abs(r.gy); + if (r.type === 'combat' && d >= 2) { r.type = 'item'; break; } + } + // wire doors + for (const r of Object.values(G.rooms)) { + for (const [dir, [dx, dy]] of Object.entries(DIRS)) { + r.doors[dir] = !!G.rooms[(r.gx + dx) + ',' + (r.gy + dy)]; + } + } +} + +function makeRoom(gx, gy, type) { + return { gx, gy, type, doors: {}, cleared: type === 'start', visited: false, decals: [], wavesLeft: 0, spawnT: 0, bossSpawned: false, pickups: [] }; +} + +function roomKey(x, y) { return x + ',' + y; } + +function wavesFor(floor) { + const base = 3 + Math.ceil(floor * 1.4); + const waves = [base]; + if (floor >= 2) waves.push(Math.ceil(base * 0.7)); + if (floor >= 4) waves.push(Math.ceil(base * 0.5)); + return waves; +} + +function enterRoom(gx, gy) { + // stash the old room's pickups (stairs, pedestals, rewards, gems all persist) + if (G.cur) G.cur.pickups = G.pickups; + G.enemies = []; + G.ebullets = []; + G.bullets = []; + G.hazards = []; + G.telegraphs = []; + G.corpses = []; + G.boss = null; + G.roomLayer = null; + G.roomLayerKey = ''; + G.roomDamaged = false; + G.roomEnterT = G.time; + + const room = G.rooms[roomKey(gx, gy)]; + G.cur = room; + G.pickups = room.pickups; + + // spawn protection: brief invincibility walking into a fresh room + if (G.player) G.player.invT = Math.max(G.player.invT, 1.0); + + if (!room.visited) { + room.visited = true; + if (room.type === 'combat') { + const w = wavesFor(G.floor); + room.wavesLeft = w.length; + room.pendingWave = w; + spawnWave(w[0], G.floor); + room.wavesLeft--; + room.spawnT = 0; + } else if (room.type === 'boss') { + const boss = spawnBoss(G.floor); + room.bossSpawned = true; + Music.playBoss(boss.bossKind); + } else if (room.type === 'item') { + spawnItemPedestal(W / 2 - 45, H / 2); + // item rooms sometimes also stock a weapon + if (chance(0.35)) { + const w = rollWeaponDrop(G.floor); + spawnPickup('weapon', W / 2 + 55, H / 2, { wid: w.id }); + } + room.cleared = true; + } + } else if (room.type === 'combat' && !room.cleared && room.wavesLeft > 0) { + // re-entering an uncleared room: restart waves + spawnWave(room.pendingWave[room.pendingWave.length - room.wavesLeft], G.floor); + room.wavesLeft--; + room.spawnT = 0; + } +} + +function roomLocked() { + const r = G.cur; + if (!r) return false; + if (r.type !== 'combat' && r.type !== 'boss') return false; + return !r.cleared; +} + +function updateRoom(dt) { + const r = G.cur; + const p = G.player; + + // If the current pressure is clearly overwhelming the build, ease it down + // smoothly. Spawn-time application means living enemies never jump stats. + if (G.enemies.length > 0) { + const lowHealth = p.hp / Math.max(1, p.stats.maxHp) < 0.35; + const stalled = G.time - G.roomEnterT > 90; + if (lowHealth || stalled) { + const decay = lowHealth ? 0.02 : 0.008; + G.pressure = Math.max(PRESSURE_MIN, G.pressure - decay * dt); + } + } + G.recentHits = (G.recentHits || []).filter(t => G.time - t < 12); + + // wave progression / room clear + if (r.type === 'combat' && !r.cleared) { + if (G.enemies.length === 0) { + if (r.wavesLeft > 0) { + r.spawnT += dt; + if (r.spawnT > 0.9) { + r.spawnT = 0; + spawnWave(r.pendingWave[r.pendingWave.length - r.wavesLeft], G.floor); + r.wavesLeft--; + } + } else { + r.cleared = true; + recordRoomClear(r); + spawnRoomReward(W / 2, H / 2, G.floor); + Sfx.roomClear(); + addScore(50); + } + } + } else if (r.type === 'boss' && r.bossSpawned && !r.cleared) { + if (!G.boss && G.enemies.length === 0) { + r.cleared = true; + recordRoomClear(r); + Sfx.roomClear(); + } + } + + // door transitions + if (!roomLocked()) { + let dir = null; + if (p.y < WALL + p.r + 6 && Math.abs(p.x - W / 2) < DOOR_HALF && keyDown('w', 'arrowup')) dir = 'n'; + else if (p.y > H - WALL - p.r - 6 && Math.abs(p.x - W / 2) < DOOR_HALF && keyDown('s', 'arrowdown')) dir = 's'; + else if (p.x > W - WALL - p.r - 6 && Math.abs(p.y - H / 2) < DOOR_HALF && keyDown('d', 'arrowright')) dir = 'e'; + else if (p.x < WALL + p.r + 6 && Math.abs(p.y - H / 2) < DOOR_HALF && keyDown('a', 'arrowleft')) dir = 'w'; + if (dir && r.doors[dir]) { + const [dx, dy] = DIRS[dir]; + enterRoom(r.gx + dx, r.gy + dy); + // place player at opposite door + if (dir === 'n') { p.y = H - WALL - p.r - 8; } + if (dir === 's') { p.y = WALL + p.r + 8; } + if (dir === 'e') { p.x = WALL + p.r + 8; } + if (dir === 'w') { p.x = W - WALL - p.r - 8; } + G.transition = 0.3; + Sfx.door(); + } + } +} + +function recordRoomClear(room) { + if (!G.roomDamaged) { + G.streak++; + G.pressure = Math.min(PRESSURE_MAX, G.pressure + PRESSURE_GAIN); + } else { + G.streak = 0; + } + if (room.type === 'combat' && G.player.stats.roomHeal > 0 && G.player.hp < G.player.stats.maxHp) { + const before = G.player.hp; + healPlayer(G.player.stats.roomHeal); + if (G.player.hp > before) spawnText(G.player.x, G.player.y - 18, 'ROOM KNIT +' + (G.player.hp - before), '#d98991'); + } +} + +function nextFloor() { + G.floor++; + addScore(250); + Sfx.stairs(); + genFloor(G.floor); + enterRoom(0, 0); + // Shield Heart perk: fresh shield hearts each floor + G.player.shieldHp = G.player.stats.shieldPerk || 0; + if (G.player.shieldHp > 0) { spawnText(G.player.x, G.player.y - 20, 'SHIELD UP', '#3bc9e0'); Sfx.shieldUp(); } + G.player.x = W / 2; G.player.y = H / 2; + addToast('FLOOR ' + G.floor, 'the meat runs deeper here'); + Music.requestFloorMusic(); + G.transition = 0.5; +} + +// ---- hazards (acid pools, fire, bear traps) ---- +function updateHazards(dt) { + const p = G.player; + for (let i = G.hazards.length - 1; i >= 0; i--) { + const h = G.hazards[i]; + h.t += dt; + if (h.t >= h.life) { G.hazards.splice(i, 1); continue; } + if (h.hostile) { + h.tick = (h.tick || 0) - dt; + if (h.tick <= 0 && dist2(h.x, h.y, p.x, p.y) < (h.r + p.r) * (h.r + p.r)) { + h.tick = h.tickRate || 0.4; + hurtPlayer(h.dmg || 1, angleTo(h.x, h.y, p.x, p.y)); + } + } + if (h.kind === 'trap') { + for (const e of G.enemies) { + if (e.boss) continue; + if (dist2(h.x, h.y, e.x, e.y) < (h.r + e.r) * (h.r + e.r)) { + e.rootT = 2.2; + damageEnemy(e, h.dmg, rand(0, TAU), false); + spawnBlood(e.x, e.y, 0, 6); + Sfx.trapSnap({ x: h.x, y: h.y }); + G.hazards.splice(i, 1); + break; + } + } + } else if (!h.hostile || h.dps > 0) { // weapon pools hurt enemies; zero-DPS boss pools do not + for (const e of G.enemies) { + if (e.hp <= 0) continue; + if (e === h.owner) continue; + if (dist2(h.x, h.y, e.x, e.y) < (h.r + e.r * 0.5) * (h.r + e.r * 0.5)) { + if (h.kind === 'acid') { e.slowT = 0.4; tickEnemyDamage(e, h.dps * dt); } + else { e.burnT = Math.max(e.burnT, 0.8); e.burnDps = h.dps; } + } + } + } + } +} + +// ---- rendering ---- +function hashTile(x, y) { return ((x * 73856093) ^ (y * 19349663)) >>> 0; } + +const FLOOR_TILE_POOL = [ + 'tile_floor1', 'tile_floor1', 'tile_floor1', + 'tile_floor2', 'tile_floor2', 'tile_floor2', + 'tile_floor3', 'tile_floor3', + 'tile_floor4', 'tile_floor6', 'tile_floor6', 'tile_floor6', + 'tile_floor5', 'tile_floor7', 'tile_floor8', 'tile_floor1', +]; +const WALL_TILE_POOL = ['tile_wall', 'tile_wall', 'tile_wall2', 'tile_wall3', 'tile_wall4']; + +function drawFloorTiles(target, r) { + for (let ty = 0; ty < H / TILE; ty++) { + for (let tx = 0; tx < W / TILE; tx++) { + const h = hashTile(tx + r.gx * 31, ty + r.gy * 31); + const sprite = FLOOR_TILE_POOL[h % FLOOR_TILE_POOL.length]; + Sprites.draw(target, sprite, tx * TILE + TILE / 2, ty * TILE + TILE / 2, 0, TILE); + } + } +} + +function drawFloor(ctx, r) { + // A room's 600 floor tiles are static. Cache them into one bitmap in the + // browser; headless tests keep the simple direct path. + const canCache = typeof document !== 'undefined' && document.createElement && G.imagesLoaded; + if (!canCache) { drawFloorTiles(ctx, r); return; } + const key = r.gx + ',' + r.gy + ':' + G.floor; + if (!G.roomLayer || G.roomLayerKey !== key) { + const layer = document.createElement('canvas'); + layer.width = W; layer.height = H; + const lctx = layer.getContext('2d'); + lctx.imageSmoothingEnabled = false; + drawFloorTiles(lctx, r); + G.roomLayer = layer; G.roomLayerKey = key; + } + ctx.drawImage(G.roomLayer, 0, 0); +} + +function drawRoom(ctx) { + const r = G.cur; + drawFloor(ctx, r); + // blood decals + for (const d of r.decals) { + Sprites.draw(ctx, d.img, d.x, d.y, d.rot, 48 * d.s, false, 0.55); + } + // hazards + for (const h of G.hazards) { + const k = 1 - h.t / h.life; + if (h.kind === 'trap') { + Sprites.draw(ctx, 'bullet_saw', h.x, h.y, h.t * 0.5, 28, false, clamp(k * 3, 0, 1)); + } else { + ctx.globalAlpha = clamp(k * 2, 0, 0.55); + const pulse = h.r * (0.82 + Math.sin(h.t * 5) * 0.08); + ctx.fillStyle = h.hostile ? (h.kind === 'acid' ? '#a7c92b' : '#ff642b') : (h.kind === 'acid' ? '#4f9e25' : '#d65b18'); + ctx.beginPath(); ctx.arc(h.x, h.y, pulse, 0, TAU); ctx.fill(); + ctx.strokeStyle = h.hostile ? '#fff0a0' : (h.kind === 'acid' ? '#a5e34c' : '#ffb13b'); + ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(h.x, h.y, pulse * 0.72, 0, TAU); ctx.stroke(); + ctx.globalAlpha = 1; + } + } + // walls + drawWalls(ctx, r); +} + +function drawWalls(ctx, r) { + const locked = roomLocked(); + // Draw 64px industrial panels into exact 48px collision bands. Door gaps + // are clipping regions, not skipped whole tiles, so they remain precisely + // 88px wide even though the art grid is larger. + function clipBand(dir) { + ctx.beginPath(); + if (dir === 'n' || dir === 's') { + const y = dir === 'n' ? 0 : H - WALL; + if (r.doors[dir]) { + ctx.rect(0, y, W / 2 - DOOR_HALF, WALL); + ctx.rect(W / 2 + DOOR_HALF, y, W / 2 - DOOR_HALF, WALL); + } else ctx.rect(0, y, W, WALL); + } else { + const x = dir === 'w' ? 0 : W - WALL; + if (r.doors[dir]) { + ctx.rect(x, 0, WALL, H / 2 - DOOR_HALF); + ctx.rect(x, H / 2 + DOOR_HALF, WALL, H / 2 - DOOR_HALF); + } else ctx.rect(x, 0, WALL, H); + } + ctx.clip(); + } + + for (const dir of ['n', 's']) { + ctx.save(); clipBand(dir); + const y = dir === 'n' ? TILE / 2 : H - TILE / 2; + for (let x = 0; x < W; x += TILE) { + const h = hashTile(x / TILE + r.gx * 17, (dir === 'n' ? -1 : 11) + r.gy * 19); + Sprites.draw(ctx, WALL_TILE_POOL[h % WALL_TILE_POOL.length], x + TILE / 2, y, 0, TILE); + } + ctx.restore(); + } + for (const dir of ['w', 'e']) { + ctx.save(); clipBand(dir); + const x = dir === 'w' ? TILE / 2 : W - TILE / 2; + for (let y = 0; y < H; y += TILE) { + const h = hashTile((dir === 'w' ? -1 : 16) + r.gx * 17, y / TILE + r.gy * 19); + Sprites.draw(ctx, WALL_TILE_POOL[h % WALL_TILE_POOL.length], x, y + TILE / 2, Math.PI / 2, TILE); + } + ctx.restore(); + } + // dark inner edge + ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 3; + ctx.strokeRect(WALL, WALL, W - WALL * 2, H - WALL * 2); + + // doors + for (const [dir, has] of Object.entries(r.doors)) { + if (!has) continue; + const sprite = locked ? 'door_locked' : 'door_open'; + let x, y, rot; + if (dir === 'n') { x = W / 2; y = WALL / 2; rot = 0; } + else if (dir === 's') { x = W / 2; y = H - WALL / 2; rot = 0; } + else if (dir === 'e') { x = W - WALL / 2; y = H / 2; rot = Math.PI / 2; } + else { x = WALL / 2; y = H / 2; rot = Math.PI / 2; } + Sprites.draw(ctx, sprite, x, y, rot, 96); + if (locked) { + ctx.save(); + ctx.globalAlpha = 0.5 + Math.sin(G.time * 6) * 0.2; + ctx.fillStyle = '#8f1f2a'; + if (dir === 'n' || dir === 's') ctx.fillRect(x - DOOR_HALF, y - 6, DOOR_HALF * 2, 12); + else ctx.fillRect(x - 6, y - DOOR_HALF, 12, DOOR_HALF * 2); + ctx.restore(); + } + } +} diff --git a/js/sfx.js b/js/sfx.js new file mode 100644 index 0000000..3012182 --- /dev/null +++ b/js/sfx.js @@ -0,0 +1,387 @@ +// ---- hybrid WebAudio SFX: generated samples with procedural fallbacks ---- +const Sfx = { + ctx: null, master: null, comp: null, glue: null, limiter: null, muted: false, + mixBus: null, musicBus: null, musicMute: null, duckGain: null, saturators: {}, + buses: {}, active: [], procedural: [], lastPlayed: new Map(), currentWeaponLoop: null, + footstepN: 0, fleshN: 0, wallN: 0, goreN: 0, + lastPunch: -999, lastPunchLevel: 0, + maxVoices: 24, + cooldowns: { + hit: 0.035, crit: 0.07, ricochet: 0.05, wallHit: 0.045, sawHit: 0.09, gem: 0.025, + enemyDie: 0.04, goreBurst: 0.055, footstep: 0.10, lifesteal: 0.12, + loop_wpnloop_bile: 0.12, loop_wpnloop_cauterizer: 0.12, + loop_wpnloop_redhand: 0.11, loop_wpnloop_spinaltap_charge: 0.12, + }, + + init() { + if (this.ctx) return; + try { + this.ctx = new (window.AudioContext || window.webkitAudioContext)(); + this.master = this.ctx.createGain(); + this.master.gain.value = G.muted ? 0 : (G.sfxVol === undefined ? 0.45 : G.sfxVol); + + this.glue = this.ctx.createDynamicsCompressor(); + this.glue.threshold.value = -10; this.glue.knee.value = 6; this.glue.ratio.value = 3.5; + this.glue.attack.value = 0.012; this.glue.release.value = 0.14; + this.mixBus = this.ctx.createGain(); this.mixBus.gain.value = 1; + this.limiter = this.ctx.createDynamicsCompressor(); + this.limiter.threshold.value = -0.5; this.limiter.knee.value = 0; this.limiter.ratio.value = 20; + this.limiter.attack.value = 0.001; this.limiter.release.value = 0.06; + this.comp = this.glue; // compatibility alias for older diagnostics + this.master.connect(this.glue); this.glue.connect(this.mixBus); + this.mixBus.connect(this.limiter); this.limiter.connect(this.ctx.destination); + + const curve = new Float32Array(2048); + const drive = 2.4, norm = Math.tanh(drive); + for (let i = 0; i < curve.length; i++) { + const x = i * 2 / (curve.length - 1) - 1; + curve[i] = Math.tanh(x * drive) / norm; + } + this.buses = {}; this.saturators = {}; this.procedural = []; + for (const [name, level] of Object.entries({ weapons: 0.92, impacts: 0.88, voices: 0.9, ui: 0.72 })) { + const node = this.ctx.createGain(); node.gain.value = level; this.buses[name] = node; + if (name === 'weapons' || name === 'impacts') { + const shaper = this.ctx.createWaveShaper(); shaper.curve = curve; shaper.oversample = '4x'; + node.connect(shaper); shaper.connect(this.master); this.saturators[name] = shaper; + } else node.connect(this.master); + } + const sub = this.ctx.createGain(); sub.gain.value = 0.9; + const subFilter = this.ctx.createBiquadFilter(); subFilter.type = 'lowpass'; subFilter.frequency.value = 140; + sub.connect(subFilter); subFilter.connect(this.master); this.buses.sub = sub; + + this.musicBus = this.ctx.createGain(); this.musicBus.gain.value = 1; + this.musicMute = this.ctx.createGain(); this.musicMute.gain.value = G.muted ? 0 : 1; + this.duckGain = this.ctx.createGain(); this.duckGain.gain.value = 1; + this.musicBus.connect(this.musicMute); this.musicMute.connect(this.duckGain); this.duckGain.connect(this.mixBus); + + if (typeof SfxBank !== 'undefined') { SfxBank.init(this.ctx); SfxBank.preload(); } + } catch (e) { this.ctx = null; } + }, + + attachMusic(audio) { + if (!audio || audio._sfxSource || audio._sfxAttachFailed || !this.ctx || !this.musicBus || + this.ctx.state !== 'running' || !this.ctx.createMediaElementSource) return false; + try { + const source = this.ctx.createMediaElementSource(audio); + audio._sfxSource = source; // mark before connect: a media element can only be wrapped once + source.connect(this.musicBus); + return true; + } catch (e) { + audio._sfxAttachFailed = true; + if (typeof console !== 'undefined' && console.warn) console.warn('Music WebAudio attach failed', e); + return false; + } + }, + detachMusic(audio) { + if (!audio || !audio._sfxSource) return; + try { audio._sfxSource.disconnect(); } catch (e) {} + // The owning Music entry is discarded immediately; never re-wrap this element. + }, + duck(amount, duration) { + if (!this.ctx || !this.duckGain || G.muted) return; + const t = this.ctx.currentTime, gain = this.duckGain.gain; + if (gain.cancelAndHoldAtTime) gain.cancelAndHoldAtTime(t); + else { gain.cancelScheduledValues(t); gain.setValueAtTime(Math.max(0.001, gain.value), t); } + gain.linearRampToValueAtTime(Math.max(0.15, 1 - clamp(amount, 0, 0.85)), t + 0.015); + gain.exponentialRampToValueAtTime(1, t + Math.max(0.06, duration || 0.15)); + }, + punch(level, pos) { + if (!this.ctx || G.muted || this.muted || level <= 0) return; + const t = this.ctx.currentTime, gap = t - this.lastPunch; + if (gap < 0.055) { + if (level <= this.lastPunchLevel) return; + level *= 0.55; + } + this.lastPunch = t; this.lastPunchLevel = level; + const out = this.output('sub', pos, clamp(level, 0, 1.2)); + if (!out) return; + const osc = this.ctx.createOscillator(), env = this.ctx.createGain(); + osc.type = 'sine'; osc.frequency.setValueAtTime(78, t); + osc.frequency.exponentialRampToValueAtTime(34, t + 0.11); + env.gain.setValueAtTime(0.85, t); + env.gain.exponentialRampToValueAtTime(0.001, t + 0.11); + osc.connect(env); env.connect(out); this.trackProcedural(osc); osc.start(t); osc.stop(t + 0.12); + this.noise(0.012, 0.035 * level, 3500, 'impacts', pos, true); + if (level >= 0.7) this.duck(clamp(level * 0.4, 0.22, 0.48), 0.14 + level * 0.06); + }, + + resume() { if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume().catch(() => {}); }, + rampNode(node, target, duration) { + if (!node || !this.ctx) return; + const t = this.ctx.currentTime, gain = node.gain; + if (gain.cancelAndHoldAtTime) gain.cancelAndHoldAtTime(t); + else { gain.cancelScheduledValues(t); gain.setValueAtTime(Math.max(0, gain.value), t); } + gain.linearRampToValueAtTime(Math.max(0, target), t + (duration || 0.018)); + }, + setMuted(m) { + this.muted = !!m; + this.rampNode(this.master, m ? 0 : (G.sfxVol === undefined ? 0.45 : G.sfxVol)); + }, + setMusicMuted(m) { this.rampNode(this.musicMute, m ? 0 : 1); }, + setVolume(v) { + G.sfxVol = clamp(v, 0, 1); + if (!G.muted) this.rampNode(this.master, G.sfxVol); + try { localStorage.setItem('meatslicer_sfx_volume', String(G.sfxVol)); } catch (e) {} + }, + + canPlay(key) { + if (!this.ctx || G.muted || this.muted) return false; + const now = this.ctx.currentTime, last = this.lastPlayed.get(key) || -999; + if (now - last < (this.cooldowns[key] || 0)) return false; + this.lastPlayed.set(key, now); + return true; + }, + + output(bus, pos, vol) { + if (!this.ctx) return null; + const gain = this.ctx.createGain(); + const p = G.player; + let pan = 0, distanceGain = 1; + if (pos && p) { + pan = clamp((pos.x - p.x) / (W / 2), -1, 1) * 0.75; + const maxD = Math.hypot(W, H); + distanceGain = 1 - 0.35 * clamp(dist(pos.x, pos.y, p.x, p.y) / maxD, 0, 1); + } + gain.gain.value = (vol === undefined ? 1 : vol) * distanceGain; + if (this.ctx.createStereoPanner) { + const panner = this.ctx.createStereoPanner(); panner.pan.value = pan; + gain.connect(panner); panner.connect(this.buses[bus] || this.master); + } else gain.connect(this.buses[bus] || this.master); + return gain; + }, + + sample(name, bus, pos, gain, key) { + if (typeof SfxBank === 'undefined') return false; + if (!SfxBank.buffers.has(name)) { SfxBank.want(name); return false; } + if (!this.canPlay(key || name)) return true; + this.enforceVoiceLimit(); + const out = this.output(bus, pos, 1); + return SfxBank.play(name, out, { gain: (gain || 1) * rand(0.92, 1.08), rate: rand(0.97, 1.03) }); + }, + + enforceVoiceLimit() { + const sampled = typeof SfxBank === 'undefined' ? [] : SfxBank.active; + while (sampled.length + this.procedural.length >= this.maxVoices) { + const sampleVoice = sampled[0], proceduralVoice = this.procedural[0]; + if (sampleVoice && (!proceduralVoice || sampleVoice.started <= proceduralVoice.started)) { + sampled.shift(); + try { sampleVoice.src.stop(); } catch (e) {} + } else if (proceduralVoice) { + this.procedural.shift(); + try { proceduralVoice.src.stop(); } catch (e) {} + } else break; + } + }, + + trackProcedural(src) { + if (!src || !this.ctx) return; + this.enforceVoiceLimit(); + const voice = { src, started: this.ctx.currentTime }; + this.procedural.push(voice); + src.onended = () => { + const index = this.procedural.indexOf(voice); + if (index >= 0) this.procedural.splice(index, 1); + }; + }, + + osc(freq, dur, type, vol, slide, bus, pos, detune) { + if (!this.ctx || G.muted) return; + const t = this.ctx.currentTime, o = this.ctx.createOscillator(), g = this.ctx.createGain(); + o.type = type || 'square'; o.frequency.setValueAtTime(Math.max(20, freq), t); + if (detune) o.detune.value = detune; + if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(20, freq + slide), t + dur); + g.gain.setValueAtTime(Math.max(0.001, vol || 0.3), t); g.gain.exponentialRampToValueAtTime(0.001, t + dur); + o.connect(g); g.connect(this.output(bus || 'impacts', pos)); + this.trackProcedural(o); o.start(t); o.stop(t + dur); + }, + blip(freq, dur, type, vol, slide, bus, pos) { this.osc(freq, dur, type, vol, slide, bus, pos, 0); }, + tone(freq, dur, type, vol, slide, detune, bus, pos) { + this.osc(freq, dur, type, (vol || 0.25) / 2, slide, bus, pos, -(detune || 8)); + this.osc(freq, dur, type, (vol || 0.25) / 2, slide, bus, pos, detune || 8); + }, + thump(freq, dur, vol, slideTo, bus, pos) { this.osc(freq, dur, 'sine', vol || 0.4, (slideTo || 45) - freq, bus, pos, 0); }, + noise(dur, vol, low, bus, pos, highpass) { + if (!this.ctx || G.muted) return; + const t = this.ctx.currentTime, len = Math.max(1, Math.floor(this.ctx.sampleRate * dur)); + const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate), d = buf.getChannelData(0); + for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len); + const src = this.ctx.createBufferSource(), f = this.ctx.createBiquadFilter(), g = this.ctx.createGain(); + src.buffer = buf; f.type = highpass ? 'highpass' : 'lowpass'; f.frequency.value = low || 900; g.gain.value = vol || 0.25; + src.connect(f); f.connect(g); g.connect(this.output(bus || 'impacts', pos)); + this.trackProcedural(src); src.start(t); + }, + click(vol, freq, bus, pos) { this.noise(0.02, vol || 0.15, freq || 1500, bus, pos, true); }, + + shoot(weapon) { + const w = typeof weapon === 'string' ? null : weapon; + const id = w ? w.id : null, kind = w ? w.sfx : weapon; + const sampleName = id && !['bile', 'cauterizer', 'redhand'].includes(id) ? + (id === 'spinaltap' ? 'wpn_spinaltap_fire' : 'wpn_' + id) : null; + const sampled = sampleName && this.sample(sampleName, 'weapons', null, 0.88, 'shoot_' + id); + this.punch(w ? w.punch : 0.4); + if (sampled) return; + if (!this.canPlay('shoot_' + (id || kind))) return; + switch (kind) { + case 'spread': this.thump(110, .22, .5, 40, 'weapons'); this.noise(.16, .4, 900, 'weapons'); this.click(.2, 1200, 'weapons'); break; + case 'rapid': this.thump(150, .07, .3, 60, 'weapons'); this.blip(rand(700, 900), .05, 'square', .12, -300, 'weapons'); break; + case 'heavy': this.thump(85, .3, .6, 35, 'weapons'); this.noise(.22, .4, 500, 'weapons'); this.click(.25, 800, 'weapons'); break; + case 'flame': this.noise(.09, .12, 700, 'weapons'); this.thump(90, .09, .12, 50, 'weapons'); break; + case 'saw': this.tone(rand(140, 200), .08, 'sawtooth', .16, 0, 12, 'weapons'); this.thump(120, .08, .2, 60, 'weapons'); break; + case 'beam': this.thump(70, .45, .55, 30, 'weapons'); this.blip(200, .4, 'sawtooth', .3, 1800, 'weapons'); this.noise(.35, .3, 3000, 'weapons'); break; + case 'lob': this.thump(180, .16, .4, 70, 'weapons'); this.blip(300, .12, 'triangle', .2, -150, 'weapons'); break; + case 'boomerang': this.blip(450, .1, 'triangle', .18, 350, 'weapons'); this.noise(.12, .14, 2500, 'weapons'); break; + default: this.thump(170, .1, .4, 55, 'weapons'); this.blip(rand(500, 650), .06, 'square', .14, -200, 'weapons'); this.click(.12, 1800, 'weapons'); + } + }, + + syncWeaponLoop(w, active) { + const map = { bile: 'wpnloop_bile', cauterizer: 'wpnloop_cauterizer', redhand: 'wpnloop_redhand', spinaltap: 'wpnloop_spinaltap_charge' }; + const wanted = active ? map[w.id] : null; + if (this.currentWeaponLoop && this.currentWeaponLoop !== wanted) this.loopStop(this.currentWeaponLoop); + this.currentWeaponLoop = wanted; + if (!wanted || !this.ctx || G.muted) return; + if (typeof SfxBank !== 'undefined') { + if (SfxBank.loops.has(wanted)) return; + const gain = wanted.includes('redhand') ? .58 : .42; + if (SfxBank.loopStart(wanted, this.output('weapons', null), { gain })) return; + } + if (this.canPlay('loop_' + wanted)) { + if (wanted.includes('redhand')) this.tone(150, .11, 'sawtooth', .16, 20, 10, 'weapons'); + else this.noise(.12, .11, wanted.includes('spinaltap') ? 2800 : 800, 'weapons'); + } + }, + loopStop(name) { if (typeof SfxBank !== 'undefined') SfxBank.loopStop(name); if (this.currentWeaponLoop === name) this.currentWeaponLoop = null; }, + stopAllLoops() { if (typeof SfxBank !== 'undefined') SfxBank.stopAll(); this.currentWeaponLoop = null; }, + + hit(e, crit) { + const pos = e && { x: e.x, y: e.y }; + if (crit) { + const sampled = this.sample('imp_crit', 'impacts', pos, .82, 'crit'); + this.punch(.14, pos); + if (sampled) return; + if (!this.canPlay('crit')) return; + this.blip(1100, .06, 'square', .18, -500, 'impacts', pos); + return; + } + this.fleshN = (this.fleshN % 4) + 1; + if (this.sample('imp_flesh' + this.fleshN, 'impacts', pos, .68, 'hit')) return; + if (!this.canPlay('hit')) return; + this.noise(.075, .26, 720, 'impacts', pos); + this.thump(170, .065, .18, 70, 'impacts', pos); + }, + sawHit(pos) { if (!this.canPlay('sawHit')) return; this.noise(.045, .12, 1700, 'impacts', pos); this.click(.1, 2200, 'impacts', pos); }, + enemyDie(e) { + const type = e && e.boss ? null : e && e.type, pos = e && { x: e.x, y: e.y }; + const voiced = type && this.sample('mon_' + type + '_die', 'voices', pos, e.elite ? 1 : .78, 'enemyDie'); + if (!voiced && this.canPlay('enemyDie')) { + this.noise(.15, .3, 600, 'voices', pos); + this.thump(160, .14, .3, 50, 'voices', pos); + this.blip(200, .12, 'sawtooth', .15, -120, 'voices', pos); + } + this.goreN = (this.goreN % 3) + 1; + const gored = this.sample('gore_burst' + this.goreN, 'impacts', pos, e && e.elite ? .72 : .5, 'goreBurst'); + if (!gored && this.canPlay('goreBurst')) this.noise(.12, .2, 430, 'impacts', pos); + this.punch(e && e.elite ? .42 : .28, pos); + }, + explode(pos) { + const sampled = this.sample('imp_explosion', 'impacts', pos, .92, 'explode'); + this.punch(.82, pos); + if (sampled) return; + if (!this.canPlay('explode')) return; + this.thump(70, .35, .5, 30, 'impacts', pos); + this.noise(.4, .5, 350, 'impacts', pos); + }, + hurt() { if (this.sample('plr_hurt', 'voices', null, .8, 'hurt')) return; this.thump(120, .2, .4, 45, 'voices'); this.blip(180, .2, 'sawtooth', .3, -100, 'voices'); }, + + lifesteal() { + const sampled = this.sample('plr_lifesteal', 'voices', null, .72, 'lifesteal'); + this.punch(.12); + if (sampled) return; + if (!this.canPlay('lifesteal')) return; + this.noise(.1, .16, 650, 'voices'); + this.blip(260, .14, 'sine', .2, 180, 'voices'); + }, + playerDeath() { this.stopAllLoops(); if (this.sample('plr_death', 'voices', null, 1, 'playerDeath')) return; this.thump(80, .65, .7, 25, 'voices'); this.noise(.7, .45, 350, 'voices'); }, + footstep(strength, wet) { + if (!this.canPlay('footstep')) return; + this.footstepN = (this.footstepN % 4) + 1; + const dryName = 'plr_step' + this.footstepN; + const wanted = wet ? 'plr_step_wet' + this.footstepN : dryName; + const force = strength === undefined ? 1 : clamp(strength, .55, 1); + const side = this.footstepN % 2 ? .96 : 1.04; + const gain = .66 * force * side; + let sampled = false; + if (typeof SfxBank !== 'undefined' && SfxBank.buffers.has(wanted)) { + this.enforceVoiceLimit(); + sampled = SfxBank.play(wanted, this.output('impacts', null), { + gain: gain * rand(.96, 1.04), rate: rand(.965, 1.035), + }); + } else if (typeof SfxBank !== 'undefined') { + SfxBank.want(wanted); + // Keep the gait audible while a wet variant is still decoding. + if (wet && SfxBank.buffers.has(dryName)) { + this.enforceVoiceLimit(); + sampled = SfxBank.play(dryName, this.output('impacts', null), { gain: gain * .9, rate: rand(.97, 1.03) }); + } + } + this.punch(.18 * force * side); + if (!sampled) { + if (wet) { + this.noise(.08, .17 * force, 360, 'impacts'); + this.noise(.025, .05 * force, 1500, 'impacts', null, true); + } else this.noise(.055, .13 * force, 420, 'impacts'); + } + }, + ricochet(pos) { if (this.sample('imp_ricochet', 'impacts', pos, .5, 'ricochet')) return; this.blip(rand(1400, 1900), .045, 'triangle', .12, -500, 'impacts', pos); }, + + wallHit(pos) { + this.wallN = (this.wallN % 3) + 1; + if (this.sample('imp_wall' + this.wallN, 'impacts', pos, .58, 'wallHit')) return; + if (!this.canPlay('wallHit')) return; + this.noise(.045, .18, 2400, 'impacts', pos, true); + this.thump(150, .04, .1, 80, 'impacts', pos); + }, + trapSet(pos) { this.blip(240, .08, 'square', .13, -80, 'impacts', pos); this.click(.12, 900, 'impacts', pos); }, + trapSnap(pos) { if (this.sample('imp_trap_snap', 'impacts', pos, .75, 'trapSnap')) return; this.noise(.09, .28, 1200, 'impacts', pos); this.thump(170, .08, .2, 80, 'impacts', pos); }, + split(e) { const p = { x: e.x, y: e.y }; if (this.sample('mon_splitter_split', 'voices', p, .75, 'split')) return; this.noise(.12, .25, 700, 'voices', p); }, + fuse(e) { const p = { x: e.x, y: e.y }; if (this.sample('mon_exploder_fuse', 'voices', p, .65, 'fuse')) return; this.blip(820, .25, 'square', .16, 300, 'voices', p); }, + spawn(e) { const p = { x: e.x, y: e.y }; if (this.sample('mon_spawn', 'voices', p, e.elite ? .7 : .4, 'spawn')) return; this.noise(.12, .12, 500, 'voices', p); }, + spit(e) { const p = e && { x: e.x, y: e.y }; if (this.sample('mon_spitter_attack', 'voices', p, .68, 'spit')) return; this.blip(350, .1, 'sawtooth', .15, -150, 'voices', p); this.thump(140, .08, .18, 70, 'voices', p); }, + + boss(e, event) { + const pos = e && { x: e.x, y: e.y }; + const name = event === 'death' ? 'boss_death' : (event === 'enrage' ? 'boss_enrage' : 'boss_' + e.bossKind + '_' + event); + const gain = event === 'death' ? 1 : .88; + const sampled = this.sample(name, 'voices', pos, gain, 'boss_' + event); + const weight = event === 'death' ? 1 : (event === 'roar' ? .92 : (event === 'enrage' || event === 'stomp' ? .82 : .55)); + this.punch(weight, pos); + if (event === 'death' || event === 'roar' || event === 'enrage') this.duck(event === 'death' ? .48 : .35, event === 'death' ? .34 : .22); + if (sampled) return; + if (!this.canPlay('boss_' + event)) return; + if (event === 'death') { + this.thump(55, .8, .75, 25, 'voices', pos); + this.noise(.8, .5, 300, 'voices', pos); + } else { + this.thump(60, .6, .5, 30, 'voices', pos); + this.blip(90, .6, 'sawtooth', .35, 40, 'voices', pos); + this.noise(.5, .35, 300, 'voices', pos); + } + }, + bossRoar(e) { if (e) this.boss(e, 'roar'); else { this.thump(60, .6, .5, 30, 'voices'); this.noise(.5, .35, 300, 'voices'); } }, + + ui(name, fallback) { if (this.sample('ui_' + name, 'ui', null, .72, 'ui_' + name)) return true; if (fallback) fallback(); return false; }, + pickup() { this.ui('menu', () => this.blip(600, .07, 'square', .15, 300, 'ui')); }, + gem() { this.ui('gem', () => this.blip(rand(800, 1100), .05, 'sine', .12, 200, 'ui')); }, + heart() { this.ui('heart', () => { this.blip(400, .15, 'sine', .25, 200, 'ui'); this.thump(140, .12, .2, 70, 'ui'); }); }, + ammo() { this.ui('ammo', () => { this.click(.14, 1600, 'ui'); this.blip(520, .08, 'square', .12, -80, 'ui'); }); }, + weapon() { this.ui('weapon', () => { this.thump(120, .14, .4, 50, 'ui'); this.blip(300, .1, 'square', .25, 200, 'ui'); this.blip(450, .12, 'square', .2, 250, 'ui'); }); }, + item() { this.ui('item', () => { this.thump(100, .2, .4, 45, 'ui'); this.blip(350, .15, 'triangle', .3, 150, 'ui'); this.blip(525, .2, 'triangle', .25, 200, 'ui'); }); }, + levelup() { this.ui('levelup', () => { this.blip(400, .12, 'square', .25, 200, 'ui'); setTimeout(() => this.blip(600, .12, 'square', .25, 200, 'ui'), 100); setTimeout(() => this.blip(800, .2, 'square', .25, 200, 'ui'), 200); }); }, + perk() { this.ui('perk', () => this.blip(500, .1, 'square', .25, 300, 'ui')); }, + menu() { this.ui('menu', () => this.blip(620, .055, 'square', .11, 120, 'ui')); }, + door(pos) { this.noise(.25, .3, 400, 'impacts', pos); this.thump(90, .2, .35, 40, 'impacts', pos); }, + roomClear() { this.blip(260, .18, 'triangle', .2, 280, 'ui'); this.thump(90, .18, .25, 40, 'ui'); }, + stairs() { this.ui('stairs', () => { this.blip(250, .3, 'triangle', .3, 250, 'ui'); this.thump(80, .3, .35, 40, 'ui'); }); }, + shieldBreak() { if (this.sample('plr_shield_break', 'impacts', null, .85, 'shieldBreak')) return; this.noise(.15, .3, 2600, 'impacts'); this.blip(900, .18, 'triangle', .2, -650, 'impacts'); }, + shieldUp() { if (this.sample('plr_shield_up', 'ui', null, .7, 'shieldUp')) return; this.blip(500, .16, 'sine', .18, 380, 'ui'); }, +}; diff --git a/js/sfxbank.js b/js/sfxbank.js new file mode 100644 index 0000000..db8e7a3 --- /dev/null +++ b/js/sfxbank.js @@ -0,0 +1,199 @@ +// ---- decoded sample bank: lazy loading, MP3 silence trimming, pooled playback ---- +// Sfx remains the public API. This helper returns false until a requested clip +// has decoded, allowing Sfx to use its procedural fallback without blocking. +const SfxBank = { + ctx: null, + buffers: new Map(), + pending: new Map(), + failed: new Set(), + active: [], + loops: new Map(), + + FILES: { + wpn_bonepopper: 'wpn_bonepopper.mp3', + wpn_repeater: 'wpn_repeater.mp3', + wpn_marrow: 'wpn_marrow.mp3', + wpn_cleaver: 'wpn_cleaver.mp3', + wpn_saw: 'wpn_saw.mp3', + wpn_hemophage: 'wpn_hemophage.mp3', + wpn_eye: 'wpn_eye.mp3', + wpn_guthook: 'wpn_guthook.mp3', + wpn_fleshmasher: 'wpn_fleshmasher.mp3', + wpn_trapqueen: 'wpn_trapqueen.mp3', + wpn_tenderizer: 'wpn_tenderizer.mp3', + wpn_swarmjar: 'wpn_swarmjar.mp3', + wpn_spinaltap_fire: 'wpn_spinaltap_fire.mp3', + wpnloop_bile: 'wpnloop_bile.mp3', + wpnloop_cauterizer: 'wpnloop_cauterizer.mp3', + wpnloop_redhand: 'wpnloop_redhand.mp3', + wpnloop_spinaltap_charge: 'wpnloop_spinaltap_charge.mp3', + + boss_bonesaw_roar: 'boss_bonesaw_roar.mp3', + boss_bonesaw_charge: 'boss_bonesaw_charge.mp3', + boss_bonesaw_saws: 'boss_bonesaw_saws.mp3', + boss_gorecrown_roar: 'boss_gorecrown_roar.mp3', + boss_gorecrown_volley: 'boss_gorecrown_volley.mp3', + boss_gorecrown_summon: 'boss_gorecrown_summon.mp3', + boss_knifecrawl_roar: 'boss_knifecrawl_roar.mp3', + boss_knifecrawl_dash: 'boss_knifecrawl_dash.mp3', + boss_knifecrawl_knives: 'boss_knifecrawl_knives.mp3', + boss_vealmother_roar: 'boss_vealmother_roar.mp3', + boss_vealmother_birth: 'boss_vealmother_birth.mp3', + boss_vealmother_bile: 'boss_vealmother_bile.mp3', + boss_flenser_roar: 'boss_flenser_roar.mp3', + boss_flenser_blink: 'boss_flenser_blink.mp3', + boss_flenser_flay: 'boss_flenser_flay.mp3', + boss_hookchoir_roar: 'boss_hookchoir_roar.mp3', + boss_hookchoir_chains: 'boss_hookchoir_chains.mp3', + boss_hookchoir_release: 'boss_hookchoir_release.mp3', + boss_platefather_roar: 'boss_platefather_roar.mp3', + boss_platefather_wall: 'boss_platefather_wall.mp3', + boss_platefather_stomp: 'boss_platefather_stomp.mp3', + boss_platefather_plate: 'boss_platefather_plate.mp3', + boss_augerprime_roar: 'boss_augerprime_roar.mp3', + boss_augerprime_spool: 'boss_augerprime_spool.mp3', + boss_augerprime_sweep: 'boss_augerprime_sweep.mp3', + boss_scald_roar: 'boss_scald_roar.mp3', + boss_scald_vent: 'boss_scald_vent.mp3', + boss_scald_burst: 'boss_scald_burst.mp3', + boss_enrage: 'boss_enrage.mp3', + boss_death: 'boss_death.mp3', + + mon_shambler_die: 'mon_shambler_die.mp3', + mon_runner_die: 'mon_runner_die.mp3', + mon_spitter_attack: 'mon_spitter_attack.mp3', + mon_spitter_die: 'mon_spitter_die.mp3', + mon_splitter_die: 'mon_splitter_die.mp3', + mon_splitter_split: 'mon_splitter_split.mp3', + mon_mini_die: 'mon_mini_die.mp3', + mon_exploder_fuse: 'mon_exploder_fuse.mp3', + mon_exploder_die: 'mon_exploder_die.mp3', + mon_spawn: 'mon_spawn.mp3', + + ui_gem: 'ui_gem.mp3', + ui_heart: 'ui_heart.mp3', + ui_ammo: 'ui_ammo.mp3', + ui_weapon: 'ui_weapon.mp3', + ui_item: 'ui_item.mp3', + ui_levelup: 'ui_levelup.mp3', + ui_perk: 'ui_perk.mp3', + ui_stairs: 'ui_stairs.mp3', + ui_menu: 'ui_menu.mp3', + + imp_crit: 'imp_crit.mp3', + imp_ricochet: 'imp_ricochet.mp3', + imp_explosion: 'imp_explosion.mp3', + plr_hurt: 'plr_hurt.mp3', + plr_lifesteal: 'plr_lifesteal.mp3', + plr_death: 'plr_death.mp3', + plr_shield_break: 'plr_shield_break.mp3', + plr_shield_up: 'plr_shield_up.mp3', + plr_step1: 'plr_step1.mp3', + plr_step2: 'plr_step2.mp3', + plr_step3: 'plr_step3.mp3', + plr_step4: 'plr_step4.mp3', + plr_step_wet1: 'plr_step_wet1.mp3', + plr_step_wet2: 'plr_step_wet2.mp3', + plr_step_wet3: 'plr_step_wet3.mp3', + plr_step_wet4: 'plr_step_wet4.mp3', + imp_flesh1: 'imp_flesh1.mp3', + imp_flesh2: 'imp_flesh2.mp3', + imp_flesh3: 'imp_flesh3.mp3', + imp_flesh4: 'imp_flesh4.mp3', + imp_wall1: 'imp_wall1.mp3', + imp_wall2: 'imp_wall2.mp3', + imp_wall3: 'imp_wall3.mp3', + gore_burst1: 'gore_burst1.mp3', + gore_burst2: 'gore_burst2.mp3', + gore_burst3: 'gore_burst3.mp3', + imp_trap_snap: 'imp_trap_snap.mp3', + }, + + init(ctx) { this.ctx = ctx || null; }, + + want(name) { + if (this.buffers.has(name) || this.pending.has(name) || this.failed.has(name)) return; + const file = this.FILES[name]; + if (!file || !this.ctx || typeof fetch !== 'function') return; + const job = fetch('/assets/sfx/' + encodeURIComponent(file) + '?v=33') + .then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); }) + .then(bytes => this.ctx.decodeAudioData(bytes)) + .then(buffer => this.buffers.set(name, this.trim(buffer))) + .catch(() => this.failed.add(name)) + .finally(() => this.pending.delete(name)); + this.pending.set(name, job); + }, + + preload() { for (const name of Object.keys(this.FILES)) this.want(name); }, + + trim(buffer) { + const threshold = 0.002; + const channels = buffer.numberOfChannels; + const length = buffer.length; + let first = 0, last = length - 1; + outerStart: for (; first < length; first++) { + for (let c = 0; c < channels; c++) { + if (Math.abs(buffer.getChannelData(c)[first]) >= threshold) break outerStart; + } + } + outerEnd: for (; last > first; last--) { + for (let c = 0; c < channels; c++) { + if (Math.abs(buffer.getChannelData(c)[last]) >= threshold) break outerEnd; + } + } + // Keep 3 ms before the transient and 20 ms after the tail to avoid clicks. + const padIn = Math.floor(buffer.sampleRate * 0.003); + const padOut = Math.floor(buffer.sampleRate * 0.02); + first = Math.max(0, first - padIn); + last = Math.min(length - 1, last + padOut); + return { buffer, offset: first / buffer.sampleRate, duration: Math.max(0.03, (last - first + 1) / buffer.sampleRate) }; + }, + + play(name, output, opts) { + const clip = this.buffers.get(name); + if (!clip || !this.ctx || !output) { this.want(name); return false; } + opts = opts || {}; + const src = this.ctx.createBufferSource(); + const gain = this.ctx.createGain(); + src.buffer = clip.buffer; + src.playbackRate.value = opts.rate || 1; + gain.gain.value = opts.gain === undefined ? 1 : opts.gain; + src.connect(gain); gain.connect(output); + const voice = { src, gain, name, started: this.ctx.currentTime }; + this.active.push(voice); + src.onended = () => { const i = this.active.indexOf(voice); if (i >= 0) this.active.splice(i, 1); }; + src.start(0, clip.offset, clip.duration); + return true; + }, + + loopStart(name, output, opts) { + if (this.loops.has(name)) return true; + const clip = this.buffers.get(name); + if (!clip || !this.ctx || !output) { this.want(name); return false; } + opts = opts || {}; + const src = this.ctx.createBufferSource(); + const gain = this.ctx.createGain(); + src.buffer = clip.buffer; src.loop = true; + src.loopStart = clip.offset; src.loopEnd = clip.offset + clip.duration; + src.playbackRate.value = opts.rate || 1; + gain.gain.value = 0.001; + src.connect(gain); gain.connect(output); + src.start(0, clip.offset); + gain.gain.exponentialRampToValueAtTime(Math.max(0.001, opts.gain || 0.5), this.ctx.currentTime + 0.04); + this.loops.set(name, { src, gain }); + return true; + }, + + loopStop(name) { + const voice = this.loops.get(name); + if (!voice || !this.ctx) return; + const end = this.ctx.currentTime + 0.06; + voice.gain.gain.cancelScheduledValues(this.ctx.currentTime); + voice.gain.gain.setValueAtTime(Math.max(0.001, voice.gain.gain.value), this.ctx.currentTime); + voice.gain.gain.exponentialRampToValueAtTime(0.001, end); + voice.src.stop(end + 0.01); + this.loops.delete(name); + }, + + stopAll() { for (const name of [...this.loops.keys()]) this.loopStop(name); }, +}; diff --git a/js/sprites.js b/js/sprites.js new file mode 100644 index 0000000..e00eb2f --- /dev/null +++ b/js/sprites.js @@ -0,0 +1,294 @@ +// ---- sprite loading + procedural fallback rendering ---- +// Every sprite is optional: if assets/.png failed to load, we draw +// a vector fallback so the game is always playable. + +const SPRITE_MANIFEST = [ + 'player', 'player_legs', + 'enemy_shambler', 'enemy_runner', 'enemy_spitter', 'enemy_splitter', 'enemy_mini', 'enemy_exploder', + 'boss_bonesaw', 'boss_gorecrown', 'boss_knifecrawl', + 'boss_vealmother', 'boss_flenser', 'boss_hookchoir', + 'boss_platefather', 'boss_augerprime', 'boss_scald', + 'tile_floor1', 'tile_floor2', 'tile_floor3', 'tile_floor4', + 'tile_floor5', 'tile_floor6', 'tile_floor7', 'tile_floor8', + 'tile_wall', 'tile_wall2', 'tile_wall3', 'tile_wall4', + 'door_open', 'door_locked', + 'bullet_bone', 'bullet_saw', 'bullet_cleaver', 'bullet_harpoon', 'bullet_eye', 'bullet_syringe', 'bullet_gore', 'bullet_steam', + 'gem_small', 'gem_big', 'heart', 'ammo', 'pedestal', 'stairs', + 'decal_blood1', 'decal_blood2', 'decal_blood3', 'decal_blood4', + // level-up perk emblems + 'perk_adrenal', 'perk_sharpen', 'perk_quick', 'perk_longbone', 'perk_bigheart', + 'perk_magnetb', 'perk_shieldheart', 'perk_scavenge', 'perk_bloodrush', 'perk_deadeye', + 'perk_critbone', 'perk_critmeat', 'perk_flensing', 'perk_ember', 'perk_frostbile', + 'perk_heavyhand', 'perk_thickhide', 'perk_secondwind', 'perk_scrapfeed', 'perk_boneknit', + 'perk_spiteflesh', 'perk_carrion', 'perk_sinew', + // weapon icons + 'w_bonepopper', 'w_repeater', 'w_marrow', 'w_cleaver', 'w_saw', 'w_bile', 'w_hemophage', 'w_eye', + 'w_guthook', 'w_cauterizer', 'w_fleshmasher', 'w_trapqueen', 'w_tenderizer', 'w_redhand', 'w_spinaltap', 'w_swarmjar', + // player upper bodies with the weapon and gripping hands baked together + 'pt_bonepopper', 'pt_repeater', 'pt_marrow', 'pt_cleaver', 'pt_saw', 'pt_bile', 'pt_hemophage', 'pt_eye', + 'pt_guthook', 'pt_cauterizer', 'pt_fleshmasher', 'pt_trapqueen', 'pt_tenderizer', 'pt_redhand', 'pt_spinaltap', 'pt_swarmjar', + // item icons + 'i_hollowpoints', 'i_twitch', 'i_scalpel', 'i_leadmarrow', 'i_piercegaze', 'i_ricochet', + 'i_splittongue', 'i_hydramaw', 'i_homingtumor', 'i_orbitalknives', 'i_dentures', 'i_volatilebile', + 'i_backstabber', 'i_splinterbone', 'i_ironstomach', 'i_luckycoin', 'i_magnetmaw', 'i_bloodlust', 'i_ghoulheart', + 'i_chainsinew', 'i_mortarbone', 'i_bloatrounds', 'i_marrowglut', 'i_hollowneedle', 'i_bloodshoteye', + 'i_flayerkiss', 'i_emberjar', 'i_acidgland', 'i_hookrounds', 'i_sledgerounds', 'i_graftedtrigger', + 'i_deadmanswitch', 'i_orbitcrown', 'i_tannedhide', 'i_deadmansclock', 'i_hollowbones', 'i_boneplate', + 'i_wormgut', 'i_spinecage', 'i_secondstomach', 'i_spitewell', 'i_twinhearts', 'i_brassmagazine', + 'i_crowbait', 'i_gorgingleech', 'i_rerollrib', + // full 64px/128px eight-direction action atlases + 'player_sheet', 'player_legs_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', + 'boss_vealmother_sheet', 'boss_flenser_sheet', 'boss_hookchoir_sheet', + 'boss_platefather_sheet', 'boss_augerprime_sheet', 'boss_scald_sheet', +]; + +// These planned icons intentionally use the built-in gold '?' treatment until +// the second-pass image generation is approved. Keeping them in the manifest +// documents the asset contract without issuing noisy 404 requests at runtime. +const SPRITE_FALLBACK_ONLY = new Set([ + 'perk_critbone', 'perk_critmeat', 'perk_flensing', 'perk_ember', 'perk_frostbile', + 'perk_heavyhand', 'perk_thickhide', 'perk_secondwind', 'perk_scrapfeed', 'perk_boneknit', + 'perk_spiteflesh', 'perk_carrion', 'perk_sinew', + 'i_chainsinew', 'i_mortarbone', 'i_bloatrounds', 'i_marrowglut', 'i_hollowneedle', 'i_bloodshoteye', + 'i_flayerkiss', 'i_emberjar', 'i_acidgland', 'i_hookrounds', 'i_sledgerounds', 'i_graftedtrigger', + 'i_deadmanswitch', 'i_orbitcrown', 'i_tannedhide', 'i_deadmansclock', 'i_hollowbones', 'i_boneplate', + 'i_wormgut', 'i_spinecage', 'i_secondstomach', 'i_spitewell', 'i_twinhearts', 'i_brassmagazine', + 'i_crowbait', 'i_gorgingleech', 'i_rerollrib', +]); + +const ACTOR_ANIMS = { + idle: { frames: 4, fps: 5, offset: 0 }, + move: { frames: 8, fps: 12, offset: 32 }, + attack: { frames: 6, fps: 15, offset: 96 }, + hit: { frames: 3, fps: 18, offset: 144 }, + death: { frames: 8, fps: 12, offset: 168 }, +}; + +const Sprites = { + imgs: {}, + flashCanvas: null, + flashCtx: null, + + shadow(ctx, x, y, rx, ry, alpha) { + ctx.save(); + ctx.globalAlpha = alpha === undefined ? 0.32 : alpha; + ctx.fillStyle = '#050203'; + ctx.beginPath(); ctx.ellipse(x, y, rx, ry, 0, 0, TAU); ctx.fill(); + ctx.restore(); + }, + + load() { + let pending = 0; + for (const name of SPRITE_MANIFEST) { + if (SPRITE_FALLBACK_ONLY.has(name)) continue; + pending++; + const img = new Image(); + img.onload = () => { this.imgs[name] = img; if (--pending === 0) G.imagesLoaded = true; }; + img.onerror = () => { if (--pending === 0) G.imagesLoaded = true; }; + img.src = 'assets/' + name + '.png?v=34'; + } + }, + + get(name) { return this.imgs[name] || null; }, + + // Compact player-only strip: eight forward-facing stride frames. + // Runtime rotation is smooth; distance-driven phase keeps contacts from skating. + legs(ctx, x, y, facing, phase, targetW) { + const sheet = this.imgs.player_legs_sheet; + if (!sheet) { + this.draw(ctx, 'player_legs', x, y, facing, targetW); + return; + } + const frameSize = 96; + const frameCount = Math.max(1, Math.floor(sheet.width / frameSize)); + let frame = Math.floor(Math.max(0, phase) / (TAU / frameCount)) % frameCount; + if (frame < 0) frame += frameCount; + const tw = targetW || frameSize; + ctx.save(); + ctx.translate(x, y); + ctx.rotate(facing); + ctx.drawImage(sheet, frame * frameSize, 0, frameSize, frameSize, + -tw / 2, -tw / 2, tw, tw); + ctx.restore(); + }, + + 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; + if (!sheet) { + this.draw(ctx, name, x, y, facing, targetW, action === 'hit', alpha, scaleX, scaleY); + return; + } + const frameSize = name.startsWith('boss_') ? 128 : (name === 'player' ? 96 : 64); + let direction = Math.round(facing / (TAU / 8)) % 8; + if (direction < 0) direction += 8; + const rawFrame = Math.floor(Math.max(0, time) * def.fps); + const frame = action === 'death' ? Math.min(def.frames - 1, rawFrame) : rawFrame % def.frames; + const cell = def.offset + direction * def.frames + frame; + const sx = (cell % 8) * frameSize, sy = Math.floor(cell / 8) * frameSize; + const tw = targetW || frameSize; + ctx.save(); + ctx.translate(x, y); + if (scaleX !== undefined || scaleY !== undefined) ctx.scale(scaleX || 1, scaleY || 1); + if (alpha !== undefined) ctx.globalAlpha = alpha; + ctx.drawImage(sheet, sx, sy, frameSize, frameSize, -tw / 2, -tw / 2, tw, tw); + ctx.restore(); + }, + + // draw centered at x,y with rotation (radians), scaled to targetW pixels wide + // (fallbacks are authored in a canonical 32px box, so they scale the same way) + draw(ctx, name, x, y, rot, targetW, flash, alpha, scaleX, scaleY) { + const img = this.imgs[name]; + const tw = targetW || 32; + ctx.save(); + ctx.translate(x, y); + if (rot) ctx.rotate(rot); + if (scaleX !== undefined || scaleY !== undefined) ctx.scale(scaleX || 1, scaleY || 1); + if (alpha !== undefined) ctx.globalAlpha = alpha; + if (img) { + const s = tw / img.width; + if (flash) { + // Isolate masking from the already-opaque room canvas. + if (!this.flashCanvas && typeof document !== 'undefined' && document.createElement) { + this.flashCanvas = document.createElement('canvas'); + this.flashCtx = this.flashCanvas.getContext('2d'); + } + if (this.flashCanvas) { + this.flashCanvas.width = img.width; this.flashCanvas.height = img.height; + const fc = this.flashCtx; + fc.imageSmoothingEnabled = false; + fc.clearRect(0, 0, img.width, img.height); + fc.globalCompositeOperation = 'source-over'; + fc.drawImage(img, 0, 0); + fc.globalCompositeOperation = 'source-atop'; + fc.fillStyle = '#fff4e6'; fc.fillRect(0, 0, img.width, img.height); + ctx.drawImage(this.flashCanvas, -img.width * s / 2, -img.height * s / 2, img.width * s, img.height * s); + } else ctx.drawImage(img, -img.width * s / 2, -img.height * s / 2, img.width * s, img.height * s); + } else ctx.drawImage(img, -img.width * s / 2, -img.height * s / 2, img.width * s, img.height * s); + } else { + this.fallback(ctx, name, tw / 32, flash); + } + ctx.restore(); + }, + + // ---- vector fallbacks (only used when a PNG is missing) ---- + fallback(ctx, name, s, flash) { + ctx.save(); + ctx.scale(s, s); + if (flash) { ctx.fillStyle = '#fff'; ctx.strokeStyle = '#fff'; } + const C = (c) => flash ? '#fff' : c; + + if (name === 'player_legs') { + ctx.fillStyle = C('#3e302b'); + ctx.fillRect(-10, -9, 17, 7); ctx.fillRect(-8, 5, 17, 7); + ctx.fillStyle = C('#666f75'); + ctx.fillRect(4, -9, 7, 7); ctx.fillRect(6, 5, 7, 7); + ctx.fillStyle = C('#9a6c3b'); + ctx.fillRect(-7, -5, 10, 12); + } else if (name.startsWith('pt_')) { + ctx.fillStyle = C('#7e1926'); + ctx.beginPath(); ctx.ellipse(-4, 0, 13, 12, 0, 0, TAU); ctx.fill(); + ctx.fillStyle = C('#ddaF8f'); + ctx.beginPath(); ctx.arc(-1, -8, 5, 0, TAU); ctx.fill(); + ctx.fillRect(2, -7, 16, 5); ctx.fillRect(2, 3, 16, 5); + ctx.fillStyle = C('#ddd2bd'); + ctx.fillRect(-5, -14, 10, 4); + ctx.fillStyle = C('#737b80'); + ctx.fillRect(12, -3, 17, 6); + ctx.fillStyle = C('#ddaF8f'); + ctx.beginPath(); ctx.arc(13, -3, 3, 0, TAU); ctx.arc(13, 4, 3, 0, TAU); ctx.fill(); + } else if (name === 'player') { + ctx.fillStyle = C('#c9a227'); + ctx.beginPath(); ctx.arc(0, 0, 11, 0, TAU); ctx.fill(); // body + ctx.fillStyle = C('#7a1212'); + ctx.fillRect(2, -4, 14, 8); // cleaver arm + ctx.fillStyle = C('#e8e0d0'); + ctx.beginPath(); ctx.arc(-3, -3, 3.5, 0, TAU); ctx.arc(-3, 4, 3.5, 0, TAU); ctx.fill(); // eyes + } else if (name.startsWith('enemy_')) { + const cols = { enemy_shambler: '#6a8f3c', enemy_runner: '#a33e2e', enemy_spitter: '#7a4f9e', enemy_splitter: '#8f6b3c', enemy_mini: '#7a5a30', enemy_exploder: '#c93b3b' }; + const r = name === 'enemy_mini' ? 6 : (name === 'enemy_exploder' ? 10 : 11); + ctx.fillStyle = C(cols[name] || '#888'); + ctx.beginPath(); ctx.arc(0, 0, r, 0, TAU); ctx.fill(); + ctx.fillStyle = C('#1a0505'); + ctx.beginPath(); ctx.arc(3, -3, 2.2, 0, TAU); ctx.arc(3, 3, 2.2, 0, TAU); ctx.fill(); + if (name === 'enemy_exploder') { ctx.strokeStyle = C('#ff0'); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(0, 0, r + 3, 0, TAU); ctx.stroke(); } + } else if (name.startsWith('boss_')) { + const cols = { + boss_bonesaw: '#b8b0a0', boss_gorecrown: '#8f1f2e', boss_knifecrawl: '#5a6b7a', + boss_vealmother: '#b98d78', boss_flenser: '#d2544c', boss_hookchoir: '#74645a', + boss_platefather: '#8e7c64', boss_augerprime: '#4f857e', boss_scald: '#b69245', + }; + ctx.fillStyle = C(cols[name] || '#a55'); + ctx.beginPath(); ctx.arc(0, 0, 15, 0, TAU); ctx.fill(); + ctx.strokeStyle = C('#2a0a0a'); ctx.lineWidth = 2; ctx.stroke(); + ctx.fillStyle = C('#ff2020'); + ctx.beginPath(); ctx.arc(5, -4, 2.5, 0, TAU); ctx.arc(5, 4, 2.5, 0, TAU); ctx.fill(); + } else if (name.startsWith('tile_floor')) { + const cols = { tile_floor1: '#241417', tile_floor2: '#2a1518', tile_floor3: '#1f1215' }; + ctx.fillStyle = C(cols[name] || '#241417'); + ctx.fillRect(-16, -16, 32, 32); + ctx.fillStyle = C('#33191c'); + ctx.fillRect(-10, -6, 6, 4); ctx.fillRect(4, 8, 8, 3); + } else if (name.startsWith('tile_wall')) { + ctx.fillStyle = C('#3d1f22'); ctx.fillRect(-16, -16, 32, 32); + ctx.fillStyle = C('#54282c'); ctx.fillRect(-16, -16, 32, 5); + } else if (name === 'door_open' || name === 'door_locked') { + ctx.fillStyle = C('#180d0e'); ctx.fillRect(-15, -8, 30, 16); + if (name === 'door_locked') { + ctx.fillStyle = C('#8f2f2f'); + for (let i = -13; i < 15; i += 6) { ctx.beginPath(); ctx.moveTo(i, -8); ctx.lineTo(i + 3, 0); ctx.lineTo(i, 8); ctx.fill(); } + } + } else if (name.startsWith('bullet_')) { + const cols = { bullet_bone: '#e8e0d0', bullet_saw: '#c0c8d0', bullet_cleaver: '#d0d8e0', bullet_harpoon: '#b09070', bullet_eye: '#e0e0f0', bullet_syringe: '#70e090', bullet_gore: '#c03b6b', bullet_steam: '#e8e0b0' }; + ctx.fillStyle = C(cols[name] || '#fff'); + if (name === 'bullet_saw') { + for (let i = 0; i < 6; i++) { ctx.rotate(TAU / 6); ctx.fillRect(3, -1.5, 5, 3); } + ctx.beginPath(); ctx.arc(0, 0, 4, 0, TAU); ctx.fill(); + } else if (name === 'bullet_eye') { + ctx.beginPath(); ctx.arc(0, 0, 5, 0, TAU); ctx.fill(); + ctx.fillStyle = C('#c02020'); ctx.beginPath(); ctx.arc(1, 0, 2.2, 0, TAU); ctx.fill(); + } else if (name === 'bullet_harpoon' || name === 'bullet_cleaver') { + ctx.fillRect(-7, -2, 14, 4); ctx.beginPath(); ctx.moveTo(9, 0); ctx.lineTo(4, -4); ctx.lineTo(4, 4); ctx.fill(); + } else { + ctx.beginPath(); ctx.arc(0, 0, 3.5, 0, TAU); ctx.fill(); + } + } else if (name.startsWith('gem_')) { + const big = name === 'gem_big'; + ctx.fillStyle = C(big ? '#3be0c9' : '#3b8fe0'); + const r = big ? 7 : 4.5; + ctx.beginPath(); ctx.moveTo(0, -r); ctx.lineTo(r * 0.8, 0); ctx.lineTo(0, r); ctx.lineTo(-r * 0.8, 0); ctx.closePath(); ctx.fill(); + } else if (name === 'heart') { + ctx.fillStyle = C('#d92038'); + ctx.beginPath(); ctx.arc(-3, -2, 4, 0, TAU); ctx.arc(3, -2, 4, 0, TAU); ctx.fill(); + ctx.beginPath(); ctx.moveTo(-6.5, 0); ctx.lineTo(0, 7); ctx.lineTo(6.5, 0); ctx.fill(); + } else if (name === 'ammo') { + ctx.fillStyle = C('#8a6f3c'); ctx.fillRect(-7, -5, 14, 10); + ctx.fillStyle = C('#d0b060'); ctx.fillRect(-5, -3, 3, 6); ctx.fillRect(-1, -3, 3, 6); ctx.fillRect(3, -3, 3, 6); + } else if (name === 'pedestal') { + ctx.fillStyle = C('#5a4a52'); ctx.fillRect(-10, 2, 20, 8); + ctx.fillStyle = C('#7a6a72'); ctx.fillRect(-7, -8, 14, 10); + } else if (name === 'stairs') { + ctx.fillStyle = C('#120a0c'); ctx.fillRect(-14, -14, 28, 28); + ctx.strokeStyle = C('#6b4a3a'); ctx.lineWidth = 3; + for (let i = -10; i <= 8; i += 6) { ctx.beginPath(); ctx.moveTo(i, -12); ctx.lineTo(i, 12); ctx.stroke(); } + } else if (name.startsWith('decal_blood')) { + ctx.fillStyle = C('#5a0a12'); + ctx.beginPath(); ctx.arc(0, 0, 10, 0, TAU); ctx.arc(8, 4, 5, 0, TAU); ctx.arc(-7, 5, 4, 0, TAU); ctx.fill(); + } else if (name.startsWith('w_')) { + ctx.fillStyle = C('#b8a888'); ctx.fillRect(-8, -3, 16, 6); + ctx.fillStyle = C('#6b5333'); ctx.fillRect(-4, 3, 5, 6); + } else if (name.startsWith('i_') || name.startsWith('perk_')) { + ctx.fillStyle = C('#c9a227'); + ctx.beginPath(); ctx.arc(0, 0, 9, 0, TAU); ctx.fill(); + ctx.fillStyle = C('#2a1015'); + ctx.font = 'bold 10px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('?', 0, 0); + } else { + ctx.fillStyle = C('#f0f'); ctx.fillRect(-8, -8, 16, 16); + } + ctx.restore(); + }, +}; diff --git a/js/state.js b/js/state.js new file mode 100644 index 0000000..d0cc975 --- /dev/null +++ b/js/state.js @@ -0,0 +1,61 @@ +// ---- global game state ---- +const W = 960, H = 640, TILE = 64, WALL = 48, DOOR_HALF = 44; + +const G = { + mode: 'menu', // menu | play | levelup | pause | gameover + time: 0, + floor: 1, + score: 0, + kills: 0, + player: null, + rooms: {}, // "x,y" -> room + cur: null, // current room + enemies: [], + bullets: [], // player projectiles + ebullets: [], // enemy projectiles + pickups: [], + parts: [], // particles + corpses: [], // short-lived frame-animated deaths + hazards: [], // acid/fire pools, traps (hurt enemies) + telegraphs: [], // boss telegraphs -> effects (hurt player) + toasts: [], + shake: 0, + flash: 0, // red screen flash when hurt + boss: null, // live boss ref + pendingLevelups: 0, + perkChoices: null, + autoPerk: false, + pressure: 1, + streak: 0, + roomDamaged: false, + roomEnterT: 0, + recentHits: [], + transition: 0, // room transition fade + muted: false, + sfxVol: 0.45, + musicVol: 0.55, + imagesLoaded: false, + roomLayer: null, + roomLayerKey: '', + atmosphereLayer: null, +}; + +const PERK_POWER_WEIGHT = 0.5; +const PRESSURE_MIN = 0.75, PRESSURE_MAX = 1.60, PRESSURE_GAIN = 0.01; + +// total player power: perks drafted (level - 1) + item tiers owned. +// monsters scale against this so difficulty tracks the build. +function powerScore() { + const p = G.player; + if (!p) return 0; + let n = (p.level - 1) * PERK_POWER_WEIGHT; + for (const iid in p.items) n += p.items[iid]; + return Math.max(0, n); +} + +function addShake(n) { G.shake = Math.min(G.shake + n, 18); } +function addToast(text, sub) { + G.toasts.push({ text, sub: sub || '', t: 0 }); + if (G.toasts.length > 3) G.toasts.shift(); +} +function addScore(n) { G.score += n; } diff --git a/js/utils.js b/js/utils.js new file mode 100644 index 0000000..77862d3 --- /dev/null +++ b/js/utils.js @@ -0,0 +1,136 @@ +// ---- math / helper utilities ---- +const TAU = Math.PI * 2; + +function rand(a, b) { return a + Math.random() * (b - a); } +function irand(a, b) { return Math.floor(rand(a, b + 1)); } +function choice(arr) { return arr[Math.floor(Math.random() * arr.length)]; } +function chance(p) { return Math.random() < p; } +function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); } +function lerp(a, b, t) { return a + (b - a) * t; } +function dist(x1, y1, x2, y2) { return Math.hypot(x2 - x1, y2 - y1); } +function dist2(x1, y1, x2, y2) { const dx = x2 - x1, dy = y2 - y1; return dx * dx + dy * dy; } +function angleTo(x1, y1, x2, y2) { return Math.atan2(y2 - y1, x2 - x1); } +function angleLerp(a, b, t) { + let d = (b - a) % TAU; + if (d > Math.PI) d -= TAU; + if (d < -Math.PI) d += TAU; + return a + d * t; +} +// distance from point to segment (for beam weapons) +function distToSegment(px, py, x1, y1, x2, y2) { + const dx = x2 - x1, dy = y2 - y1; + const l2 = dx * dx + dy * dy; + if (l2 === 0) return dist(px, py, x1, y1); + let t = ((px - x1) * dx + (py - y1) * dy) / l2; + t = clamp(t, 0, 1); + return dist(px, py, x1 + t * dx, y1 + t * dy); +} +function shuffle(arr) { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +// Deterministic blood trim for UI panels. It is deliberately static so richer +// presentation never adds per-frame allocations or visual noise during combat. +function drawBloodTrim(ctx, x, y, w, seed, alpha) { + const oldAlpha = typeof ctx.globalAlpha === 'number' ? ctx.globalAlpha : 1; + const usable = Math.max(1, Math.floor(w - 18)); + ctx.save(); + ctx.globalAlpha = oldAlpha * (alpha === undefined ? 0.72 : alpha); + ctx.fillStyle = '#861020'; + ctx.fillRect(x + 2, y, w - 4, 3); + for (let i = 0; i < 6; i++) { + const px = x + 9 + ((i * 47 + (seed || 0) * 29) % usable); + const r = 2 + ((i + (seed || 0)) % 3); + ctx.beginPath(); ctx.arc(px, y + 2, r, 0, TAU); ctx.fill(); + if ((i + (seed || 0)) % 2 === 0) { + const drip = 4 + ((i * 7 + (seed || 0) * 3) % 13); + ctx.fillRect(px - 1, y + 2, 2, drip); + ctx.beginPath(); ctx.arc(px, y + 2 + drip, 1.6, 0, TAU); ctx.fill(); + } + } + ctx.fillStyle = 'rgba(235,54,70,0.45)'; + ctx.fillRect(x + 4, y, Math.max(10, w * 0.22), 1); + ctx.restore(); +} + +function pixelPanelPath(ctx, x, y, w, h, cut) { + const c = Math.max(0, Math.min(cut === undefined ? 7 : cut, w / 3, h / 3)); + ctx.beginPath(); + ctx.moveTo(x + c, y); ctx.lineTo(x + w - c, y); + ctx.lineTo(x + w, y + c); ctx.lineTo(x + w, y + h - c); + ctx.lineTo(x + w - c, y + h); ctx.lineTo(x + c, y + h); + ctx.lineTo(x, y + h - c); ctx.lineTo(x, y + c); ctx.closePath(); +} + +// Shared hard-edged panel language for HUD and modal screens. Chamfered corners, +// one-pixel highlights, and square corner pins keep it modern without losing the +// authored 2D pixel aesthetic. +function drawPixelPanel(ctx, x, y, w, h, opts) { + const o = opts || {}; + const cut = o.cut === undefined ? 7 : o.cut; + ctx.save(); + if (o.shadow !== false) { + ctx.fillStyle = o.shadowColor || 'rgba(0,0,0,0.5)'; + pixelPanelPath(ctx, x + 4, y + 5, w, h, cut); ctx.fill(); + } + ctx.fillStyle = o.fill || 'rgba(10,5,8,0.88)'; + pixelPanelPath(ctx, x, y, w, h, cut); ctx.fill(); + ctx.strokeStyle = o.border || 'rgba(111,45,57,0.9)'; + ctx.lineWidth = o.lineWidth || 1; + pixelPanelPath(ctx, x + 0.5, y + 0.5, w - 1, h - 1, Math.max(1, cut - 0.5)); ctx.stroke(); + ctx.strokeStyle = o.highlight || 'rgba(255,217,186,0.08)'; + ctx.beginPath(); ctx.moveTo(x + cut + 2, y + 2); ctx.lineTo(x + w - cut - 2, y + 2); ctx.stroke(); + const accent = o.accent || '#8f1f2f'; + ctx.fillStyle = accent; + ctx.fillRect(x + cut, y, Math.max(16, Math.min(w * 0.34, 92)), 2); + ctx.fillRect(x + 2, y + cut, 2, Math.max(12, Math.min(h * 0.32, 34))); + ctx.fillStyle = 'rgba(210,171,115,0.28)'; + ctx.fillRect(x + cut, y + 5, 3, 3); + ctx.fillRect(x + w - cut - 3, y + 5, 3, 3); + ctx.fillRect(x + cut, y + h - 8, 3, 3); + ctx.fillRect(x + w - cut - 3, y + h - 8, 3, 3); + if (o.blood) drawBloodTrim(ctx, x + 2, y + 2, w - 4, o.seed || 0, o.bloodAlpha === undefined ? 0.38 : o.bloodAlpha); + ctx.restore(); +} + +function drawPixelBar(ctx, x, y, w, h, value, opts) { + const o = opts || {}; + const k = clamp(value, 0, 1); + ctx.save(); + ctx.fillStyle = o.track || '#11080c'; ctx.fillRect(x, y, w, h); + ctx.fillStyle = o.fill || '#b51d35'; ctx.fillRect(x + 2, y + 2, Math.max(0, (w - 4) * k), Math.max(1, h - 4)); + if (k > 0) { + ctx.fillStyle = o.glint || 'rgba(255,210,180,0.3)'; + ctx.fillRect(x + 2, y + 2, Math.max(0, (w - 4) * k), 1); + } + const segments = o.segments || 0; + if (segments > 1) { + ctx.fillStyle = o.divider || 'rgba(5,2,4,0.6)'; + for (let i = 1; i < segments; i++) { + const sx = x + Math.round((w * i) / segments); + ctx.fillRect(sx, y + 1, 1, h - 2); + } + } + ctx.strokeStyle = o.border || '#55303a'; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); + ctx.restore(); +} + +function drawPixelTag(ctx, text, x, y, opts) { + const o = opts || {}; + ctx.save(); + ctx.font = o.font || 'bold 10px monospace'; + const w = o.width || Math.ceil(ctx.measureText(text).width) + 16; + const h = o.height || 22; + drawPixelPanel(ctx, x, y, w, h, { + cut: 4, shadow: false, fill: o.fill || 'rgba(15,7,10,0.86)', + border: o.border || 'rgba(101,48,58,0.8)', accent: o.accent || '#7d1a29', + }); + ctx.fillStyle = o.color || '#cdbdb2'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(text, x + w / 2, y + h / 2 + 1); + ctx.restore(); + return w; +} diff --git a/js/weapons.js b/js/weapons.js new file mode 100644 index 0000000..366e9f0 --- /dev/null +++ b/js/weapons.js @@ -0,0 +1,200 @@ +// ---- The Butcher's Arsenal: 16 horrorcore weapons ---- +// behavior: bullet | spread | boomerang | bounce | cone | flame | homing | +// pierce_drag | lob | lob_trap | lob_swarm | slam | saw | beam + +const WEAPONS = { + bonepopper: { id: 'bonepopper', name: 'Bone Popper', held: 38, torsoW: 106, torsoFwd: 18, muzzle: 62, tier: -1, ammo: Infinity, interval: 0.60, dmg: 8, spd: 540, range: 0.45, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'default', punch: 0.42, desc: 'Trusty bone-shard sidearm' }, + repeater: { id: 'repeater', name: 'Ribcage Repeater', held: 52, torsoW: 120, torsoFwd: 20, muzzle: 70, tier: 0, ammo: 140, interval: 0.09, dmg: 6, spd: 560, spread: 0.10, range: 0.42, behavior: 'bullet', sprite: 'bullet_bone', sfx: 'rapid', punch: 0.18, desc: 'Rattles off rib slivers' }, + marrow: { id: 'marrow', name: 'Marrow Scatter', held: 54, torsoW: 120, torsoFwd: 20, muzzle: 69, tier: 0, ammo: 42, interval: 0.58, dmg: 11, spd: 500, pellets: 6, spread: 0.55, range: 0.38, behavior: 'spread', sprite: 'bullet_bone', sfx: 'spread', punch: 0.92, desc: 'A devastating close blast of jagged bone' }, + cleaver: { id: 'cleaver', name: 'Cleaver Cadence', held: 60, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 0, ammo: 34, interval: 0.38, dmg: 15, spd: 400, range: 1.10, behavior: 'boomerang', sprite: 'bullet_cleaver', sfx: 'boomerang', punch: 0.42, desc: 'It always comes back' }, + saw: { id: 'saw', name: 'Sawblade Launcher',held: 52, torsoW: 118, torsoFwd: 20, muzzle: 70, tier: 1, ammo: 26, interval: 0.34, dmg: 13, spd: 360, bounces: 5, range: 0.60, behavior: 'bounce', sprite: 'bullet_saw', sfx: 'saw', punch: 0.45, desc: 'Bouncing, hungry steel' }, + bile: { id: 'bile', name: 'Bile Blunderbuss', held: 54, torsoW: 118, torsoFwd: 20, muzzle: 68, tier: 1, ammo: 70, interval: 0.12, dmg: 7, spd: 340, spread: 0.32, range: 0.38, behavior: 'cone', sprite: 'bullet_gore', sfx: 'flame', punch: 0.34, desc: 'A brutal corrosive torrent; pools linger' }, + hemophage: { id: 'hemophage', name: 'Hemophage', held: 50, torsoW: 116, torsoFwd: 19, muzzle: 67, tier: 1, ammo: 44, interval: 0.19, dmg: 8, spd: 480, lifesteal: 0.5, range: 0.45, behavior: 'bullet', sprite: 'bullet_syringe', sfx: 'default', punch: 0.35, desc: 'Drinks what it touches' }, + eye: { id: 'eye', name: 'Eye Ballista', held: 58, torsoW: 120, torsoFwd: 20, muzzle: 70, tier: 1, ammo: 32, interval: 0.30, dmg: 10, spd: 320, range: 0.62, behavior: 'homing', sprite: 'bullet_eye', sfx: 'default', punch: 0.40, desc: 'The eyes follow. They always follow' }, + guthook: { id: 'guthook', name: 'Gut Hook', held: 64, torsoW: 128, torsoFwd: 22, muzzle: 76, tier: 2, ammo: 14, interval: 0.85, dmg: 48, spd: 720, range: 0.60, behavior: 'pierce_drag', sprite: 'bullet_harpoon', sfx: 'heavy', punch: 0.95, desc: 'Impales and drags the catch' }, + cauterizer: { id: 'cauterizer', name: 'The Cauterizer', held: 58, torsoW: 128, torsoFwd: 22, muzzle: 76, tier: 2, ammo: 110, interval: 0.05, dmg: 4.5, spd: 310, spread: 0.34, range: 0.36, behavior: 'flame', sprite: 'bullet_gore', sfx: 'flame', punch: 0.32, desc: 'Incinerates anything brave enough to get close' }, + fleshmasher: { id: 'fleshmasher', name: 'Flesh Masher', held: 62, torsoW: 128, torsoFwd: 22, muzzle: 76, tier: 2, ammo: 14, interval: 0.75, dmg: 34, spd: 999, behavior: 'lob', sprite: 'bullet_gore', sfx: 'lob', punch: 0.90, desc: 'Lobs ground-meat bombs' }, + trapqueen: { id: 'trapqueen', name: 'Trap Queen', held: 58, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 2, ammo: 12, interval: 0.55, dmg: 10, spd: 999, behavior: 'lob_trap', sprite: 'bullet_saw', sfx: 'lob', punch: 0.65, ammoWeight: 0.75, desc: 'Bear traps. For bears. And worse' }, + tenderizer: { id: 'tenderizer', name: 'The Tenderizer', held: 66, torsoW: 132, torsoFwd: 23, muzzle: 78, tier: 2, ammo: 26, interval: 0.58, dmg: 60, spd: 0, behavior: 'slam', sprite: 'bullet_bone', sfx: 'heavy', punch: 1.00, desc: 'A room-shaking close-range meat paste' }, + redhand: { id: 'redhand', name: 'Red Right Hand', held: 68, torsoW: 134, torsoFwd: 24, muzzle: 80, tier: 2, ammo: 160, interval: 0.10, dmg: 7, spd: 0, behavior: 'saw', sprite: 'bullet_saw', sfx: 'saw', punch: 0.38, desc: 'A chainsaw that liquefies anything in reach' }, + spinaltap: { id: 'spinaltap', name: 'Spinal Tap', held: 68, torsoW: 136, torsoFwd: 24, muzzle: 81, tier: 3, ammo: 9, interval: 0.30, dmg: 95, spd: 0, chargeTime: 0.85, behavior: 'beam', sprite: 'bullet_bone', sfx: 'beam', punch: 1.00, desc: 'Charge it. Delete a line of meat' }, + swarmjar: { id: 'swarmjar', name: 'Swarm Jar', held: 62, torsoW: 124, torsoFwd: 21, muzzle: 73, tier: 3, ammo: 7, interval: 0.80, dmg: 9, spd: 999, behavior: 'lob_swarm', sprite: 'bullet_gore', sfx: 'lob', punch: 0.70, ammoWeight: 0.8, desc: 'A jar of friends. They are hungry' }, +}; + +// rarity weights per tier, by floor +function rollWeaponDrop(floor) { + const table = [ + { tier: 0, w: Math.max(20, 60 - floor * 5) }, + { tier: 1, w: 30 + floor * 2 }, + { tier: 2, w: 10 + floor * 3 }, + { tier: 3, w: Math.min(4 + floor * 2, 22) }, + ]; + const total = table.reduce((s, t) => s + t.w, 0); + let r = Math.random() * total; + let tier = 0; + for (const t of table) { r -= t.w; if (r <= 0) { tier = t.tier; break; } } + const pool = Object.values(WEAPONS).filter(w => w.tier === tier); + return choice(pool); +} + +// spawn player projectiles for one trigger pull (called when weapon fires) +function fireWeapon(p, w) { + const st = p.stats; + const ang = p.aim; + const muzzle = w.muzzle || 35; + const bx = p.x + Math.cos(ang) * muzzle, by = p.y + Math.sin(ang) * muzzle; + const dmgMul = st.dmgMul; + const spdMul = st.shotSpeedMul; + spawnMuzzleFx(bx, by, ang, w.behavior); + + // One shared volley shape makes split/fan/rear items useful for every + // projectile weapon instead of only the basic bullet behavior. + const volley = [ang]; + for (let i = 1; i <= st.split; i++) volley.push(ang + (i % 2 ? 1 : -1) * Math.ceil(i / 2) * 0.035); + for (let i = 1; i <= st.fan; i++) { volley.push(ang + 0.16 * i); volley.push(ang - 0.16 * i); } + for (let i = 1; i <= st.rear; i++) volley.push(ang + Math.PI + (i - (st.rear + 1) / 2) * 0.18); + + const mk = (a, overrides) => { + const b = Object.assign({ + x: bx, y: by, ang: a, + spawnX: p.x, spawnY: p.y, + vx: Math.cos(a) * w.spd * spdMul, vy: Math.sin(a) * w.spd * spdMul, + r: 5 * st.sizeMul, dmg: w.dmg * dmgMul, pierce: st.pierce, bounce: st.bounce, + life: (w.range || 0.45) * st.rangeMul, + t: 0, behavior: w.behavior, sprite: w.sprite, + homing: w.behavior === 'homing' ? 6 + st.homing * 0.7 : (st.homing ? 1.6 + st.homing * 0.7 : 0), + lifesteal: w.lifesteal || 0, + bounces: w.bounces || 0, hit: null, dragTarget: null, + rangeMul: st.rangeMul, sizeMul: st.sizeMul, + }, overrides || {}); + G.bullets.push(b); + return b; + }; + + switch (w.behavior) { + case 'spread': { + // Split adds both full volleys and a little density to a shotgun blast. + const n = w.pellets + st.split; + for (const center of volley) { + for (let i = 0; i < n; i++) mk(center + rand(-w.spread, w.spread) * (0.5 + i / n)); + } + break; + } + case 'cone': case 'flame': { + for (const center of volley) { + for (let i = 0; i < 2; i++) mk(center + rand(-w.spread, w.spread), { pierce: 2 + st.pierce, r: 6 * st.sizeMul }); + } + break; + } + case 'boomerang': { + for (const a of volley) mk(a, { r: 8 * st.sizeMul, pierce: st.pierce, phase: 0, life: (w.range || 1.1) * st.rangeMul }); + break; + } + case 'pierce_drag': { + for (const a of volley) mk(a, { r: 7 * st.sizeMul, pierce: st.pierce, life: (w.range || 0.6) * st.rangeMul }); + break; + } + case 'lob': case 'lob_trap': case 'lob_swarm': { + const primaryD = Math.max(dist(bx, by, Input.mx, Input.my), 1); + for (let i = 0; i < volley.length; i++) { + const a = volley[i]; + const tx = i === 0 ? Input.mx : p.x + Math.cos(a) * primaryD; + const ty = i === 0 ? Input.my : p.y + Math.sin(a) * primaryD; + const d = Math.max(dist(bx, by, tx, ty), 1); + const flight = clamp(d / (520 * spdMul), 0.12, 0.8); + mk(a, { + vx: (tx - bx) / flight, vy: (ty - by) / flight, + r: 7 * st.sizeMul, pierce: st.pierce, bounce: st.bounce, + life: flight, lobbed: true, + }); + } + break; + } + case 'slam': { + const inert = 1 + (st.pierce + st.bounce + st.homing) * 0.06; + for (const a of volley) { + const reach = 46 * st.rangeMul * Math.sqrt(st.shotSpeedMul); + const cx = p.x + Math.cos(a) * reach, cy = p.y + Math.sin(a) * reach; + const radius = 70 * st.rangeMul * Math.sqrt(st.sizeMul); + areaDamage(cx, cy, radius, w.dmg * dmgMul * inert, true, { source: 'player' }); + spawnExplosionFx(cx, cy, radius); + } + Sfx.explode({ x: p.x + Math.cos(ang) * 46, y: p.y + Math.sin(ang) * 46 }); + break; + } + default: { // bullet, bounce and homing projectiles + for (const a of volley) mk(a + rand(-(w.spread || 0), w.spread || 0)); + } + } +} + +// continuous chainsaw damage while held (def = weapon stats, inst = ammo holder) +function sawTick(p, def, inst, dt) { + const st = p.stats; + const angles = [p.aim]; + for (let i = 1; i <= st.split; i++) angles.push(p.aim + (i % 2 ? 1 : -1) * 0.18); + for (let i = 1; i <= st.fan; i++) { angles.push(p.aim + i * 0.28); angles.push(p.aim - i * 0.28); } + for (let i = 1; i <= st.rear; i++) angles.push(p.aim + Math.PI + (i - (st.rear + 1) / 2) * 0.18); + const inert = 1 + (st.pierce + st.bounce + st.homing) * 0.06; + let hitAny = false; + for (const a of angles) { + const reach = 30 * st.rangeMul * Math.sqrt(st.shotSpeedMul); + const cx = p.x + Math.cos(a) * reach, cy = p.y + Math.sin(a) * reach; + const radius = 34 * Math.sqrt(st.sizeMul); + for (const e of G.enemies) { + if (dist2(cx, cy, e.x, e.y) < (radius + e.r) * (radius + e.r)) { + const tickScale = dt / Math.max(0.01, def.interval || 0.1); + damageEnemy(e, def.dmg * st.dmgMul * st.rateMul * inert * tickScale, a, false, { + source: 'player', procScale: tickScale, procIntervalScale: tickScale, + }); + hitAny = true; + } + } + if (chance(Math.min(1, dt * 20))) spawnSpark(cx, cy, a); + if (hitAny) spawnBlood(cx, cy, a, 4); + } + if (hitAny) Sfx.sawHit({ x: p.x, y: p.y }); + inst.ammo -= dt * 18 / st.ammoEff; +} + +// fire the Spinal Tap beam +function fireBeam(p, def, charge) { + const st = p.stats; + const angles = [p.aim]; + for (let i = 1; i <= st.split; i++) angles.push(p.aim + (i % 2 ? 1 : -1) * 0.025); + for (let i = 1; i <= st.fan; i++) { angles.push(p.aim + 0.16 * i); angles.push(p.aim - 0.16 * i); } + for (let i = 1; i <= st.rear; i++) angles.push(p.aim + Math.PI + (i - (st.rear + 1) / 2) * 0.18); + const len = 900 * st.rangeMul; + const inert = 1 + (st.pierce + st.bounce + st.homing) * 0.06; + const dmg = def.dmg * st.dmgMul * (0.4 + 0.6 * charge) * inert; + const width = 14 * Math.sqrt(st.sizeMul); + for (const ang of angles) { + const x2 = p.x + Math.cos(ang) * len, y2 = p.y + Math.sin(ang) * len; + for (const e of G.enemies) { + if (distToSegment(e.x, e.y, p.x, p.y, x2, y2) < e.r + width) { + damageEnemy(e, dmg, ang, true, { source: 'player' }); + } + } + spawnBeam(p.x, p.y, ang, len); + } + const ang = p.aim; + const muzzle = def.muzzle || 35; + spawnMuzzleFx(p.x + Math.cos(ang) * muzzle, p.y + Math.sin(ang) * muzzle, ang, 'beam'); + p.recoil = 1; + p.muzzleT = 0.11; + p.attackT = 0.5; + p.actionT = 0; + addShake(8); + Sfx.shoot(def); + p.weapon.ammo -= 1 / st.ammoEff; +} + +// damage every enemy in a radius (slams, explosions) +function areaDamage(x, y, r, dmg, knockback, opts) { + for (const e of G.enemies) { + const d = dist(x, y, e.x, e.y); + if (d < r + e.r) { + damageEnemy(e, dmg, angleTo(x, y, e.x, e.y), knockback, opts); + } + } +} diff --git a/mp3-music/Auger Prime Loop_Alt_Boss.mp3 b/mp3-music/Auger Prime Loop_Alt_Boss.mp3 new file mode 100644 index 0000000..8a1aab7 Binary files /dev/null and b/mp3-music/Auger Prime Loop_Alt_Boss.mp3 differ diff --git a/mp3-music/Auger Prime Loop_Boss.mp3 b/mp3-music/Auger Prime Loop_Boss.mp3 new file mode 100644 index 0000000..df5fd20 Binary files /dev/null and b/mp3-music/Auger Prime Loop_Boss.mp3 differ diff --git a/mp3-music/Blood Arcade.mp3 b/mp3-music/Blood Arcade.mp3 new file mode 100644 index 0000000..a2819f7 Binary files /dev/null and b/mp3-music/Blood Arcade.mp3 differ diff --git a/mp3-music/Blood Arcade_Alt.mp3 b/mp3-music/Blood Arcade_Alt.mp3 new file mode 100644 index 0000000..205f2dd Binary files /dev/null and b/mp3-music/Blood Arcade_Alt.mp3 differ diff --git a/mp3-music/Blood Dice Loop.mp3 b/mp3-music/Blood Dice Loop.mp3 new file mode 100644 index 0000000..9faba6f Binary files /dev/null and b/mp3-music/Blood Dice Loop.mp3 differ diff --git a/mp3-music/Blood Dice Loop_alt.mp3 b/mp3-music/Blood Dice Loop_alt.mp3 new file mode 100644 index 0000000..7ac08a7 Binary files /dev/null and b/mp3-music/Blood Dice Loop_alt.mp3 differ diff --git a/mp3-music/Bone Saw Loop_Alt_Boss.mp3 b/mp3-music/Bone Saw Loop_Alt_Boss.mp3 new file mode 100644 index 0000000..821c155 Binary files /dev/null and b/mp3-music/Bone Saw Loop_Alt_Boss.mp3 differ diff --git a/mp3-music/Bone Saw Loop_Boss.mp3 b/mp3-music/Bone Saw Loop_Boss.mp3 new file mode 100644 index 0000000..6836eb9 Binary files /dev/null and b/mp3-music/Bone Saw Loop_Boss.mp3 differ diff --git a/mp3-music/Crimsonland Requiem.mp3 b/mp3-music/Crimsonland Requiem.mp3 new file mode 100644 index 0000000..02ba368 Binary files /dev/null and b/mp3-music/Crimsonland Requiem.mp3 differ diff --git a/mp3-music/Crimsonland Requiem_alt.mp3 b/mp3-music/Crimsonland Requiem_alt.mp3 new file mode 100644 index 0000000..9f774c1 Binary files /dev/null and b/mp3-music/Crimsonland Requiem_alt.mp3 differ diff --git a/mp3-music/Crimsonland Tool.mp3 b/mp3-music/Crimsonland Tool.mp3 new file mode 100644 index 0000000..c58af15 Binary files /dev/null and b/mp3-music/Crimsonland Tool.mp3 differ diff --git a/mp3-music/Crimsonland Tool_alt.mp3 b/mp3-music/Crimsonland Tool_alt.mp3 new file mode 100644 index 0000000..b16db00 Binary files /dev/null and b/mp3-music/Crimsonland Tool_alt.mp3 differ diff --git a/mp3-music/Flenser Loop_Alt_Boss.mp3 b/mp3-music/Flenser Loop_Alt_Boss.mp3 new file mode 100644 index 0000000..881dac6 Binary files /dev/null and b/mp3-music/Flenser Loop_Alt_Boss.mp3 differ diff --git a/mp3-music/Flenser Loop_Boss.mp3 b/mp3-music/Flenser Loop_Boss.mp3 new file mode 100644 index 0000000..6b027bc Binary files /dev/null and b/mp3-music/Flenser Loop_Boss.mp3 differ diff --git a/mp3-music/Gore Crown Loop_Alt_Boss.mp3 b/mp3-music/Gore Crown Loop_Alt_Boss.mp3 new file mode 100644 index 0000000..ff58a5f Binary files /dev/null and b/mp3-music/Gore Crown Loop_Alt_Boss.mp3 differ diff --git a/mp3-music/Gore Crown Loop_Boss.mp3 b/mp3-music/Gore Crown Loop_Boss.mp3 new file mode 100644 index 0000000..b09230e Binary files /dev/null and b/mp3-music/Gore Crown Loop_Boss.mp3 differ diff --git a/mp3-music/Hook Choir Loop_Alt_Boss.mp3 b/mp3-music/Hook Choir Loop_Alt_Boss.mp3 new file mode 100644 index 0000000..d4645cb Binary files /dev/null and b/mp3-music/Hook Choir Loop_Alt_Boss.mp3 differ diff --git a/mp3-music/Hook Choir Loop_Boss.mp3 b/mp3-music/Hook Choir Loop_Boss.mp3 new file mode 100644 index 0000000..0e32bac Binary files /dev/null and b/mp3-music/Hook Choir Loop_Boss.mp3 differ diff --git a/mp3-music/Knife Floor Crawl_Alt_Boss.mp3 b/mp3-music/Knife Floor Crawl_Alt_Boss.mp3 new file mode 100644 index 0000000..6648b40 Binary files /dev/null and b/mp3-music/Knife Floor Crawl_Alt_Boss.mp3 differ diff --git a/mp3-music/Knife Floor Crawl_Boss.mp3 b/mp3-music/Knife Floor Crawl_Boss.mp3 new file mode 100644 index 0000000..3883475 Binary files /dev/null and b/mp3-music/Knife Floor Crawl_Boss.mp3 differ diff --git a/mp3-music/Plate Father Loop_Alt_Boss.mp3 b/mp3-music/Plate Father Loop_Alt_Boss.mp3 new file mode 100644 index 0000000..eca0166 Binary files /dev/null and b/mp3-music/Plate Father Loop_Alt_Boss.mp3 differ diff --git a/mp3-music/Plate Father Loop_Boss.mp3 b/mp3-music/Plate Father Loop_Boss.mp3 new file mode 100644 index 0000000..a54bb7e Binary files /dev/null and b/mp3-music/Plate Father Loop_Boss.mp3 differ diff --git a/mp3-music/Rust Hollow Loop.mp3 b/mp3-music/Rust Hollow Loop.mp3 new file mode 100644 index 0000000..0fc52a9 Binary files /dev/null and b/mp3-music/Rust Hollow Loop.mp3 differ diff --git a/mp3-music/Rust Hollow Loop_Alt.mp3 b/mp3-music/Rust Hollow Loop_Alt.mp3 new file mode 100644 index 0000000..c72a914 Binary files /dev/null and b/mp3-music/Rust Hollow Loop_Alt.mp3 differ diff --git a/mp3-music/Scald Loop_Alt_Boss.mp3 b/mp3-music/Scald Loop_Alt_Boss.mp3 new file mode 100644 index 0000000..34bc5e4 Binary files /dev/null and b/mp3-music/Scald Loop_Alt_Boss.mp3 differ diff --git a/mp3-music/Scald Loop_Boss.mp3 b/mp3-music/Scald Loop_Boss.mp3 new file mode 100644 index 0000000..a84b2a7 Binary files /dev/null and b/mp3-music/Scald Loop_Boss.mp3 differ diff --git a/mp3-music/Veal Mother Loop_Alt_Boss.mp3 b/mp3-music/Veal Mother Loop_Alt_Boss.mp3 new file mode 100644 index 0000000..f49951e Binary files /dev/null and b/mp3-music/Veal Mother Loop_Alt_Boss.mp3 differ diff --git a/mp3-music/Veal Mother Loop_Boss.mp3 b/mp3-music/Veal Mother Loop_Boss.mp3 new file mode 100644 index 0000000..a672ec5 Binary files /dev/null and b/mp3-music/Veal Mother Loop_Boss.mp3 differ diff --git a/play.bat b/play.bat new file mode 100644 index 0000000..3807442 --- /dev/null +++ b/play.bat @@ -0,0 +1,8 @@ +@echo off +rem MeatSlicer launcher - serves only game files on the local machine. +cd /d %~dp0 +start "MeatSlicer server" /min python tools\serve_game.py --port 8123 +timeout /t 1 /nobreak >nul +start "" http://localhost:8123 +echo MeatSlicer running at http://localhost:8123 +echo Close the minimized "MeatSlicer server" window to stop it. diff --git a/test/audio.html b/test/audio.html new file mode 100644 index 0000000..622410b --- /dev/null +++ b/test/audio.html @@ -0,0 +1,87 @@ + + + + + +MeatSlicer SFX Lab + + + + +

MEATSLICER // SFX LAB

+
+ + + + click initialize, then audition +
+
+ + + + + + + + diff --git a/test/headless.html b/test/headless.html new file mode 100644 index 0000000..611c265 --- /dev/null +++ b/test/headless.html @@ -0,0 +1,85 @@ + + + + +headless harness + + + + +
+ + + + + + + + + + + + + + + + + + + + + diff --git a/test/repro_boss.js b/test/repro_boss.js new file mode 100644 index 0000000..d96b568 --- /dev/null +++ b/test/repro_boss.js @@ -0,0 +1,118 @@ +// Long-fight soak for a specific boss. Usage: node test/repro_boss.js +'use strict'; +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const FLOOR = parseInt(process.argv[2] || '1', 10); + +function fakeCtx() { + return new Proxy({}, { + get(t, prop) { + if (prop === 'measureText') return () => ({ width: 50 }); + if (typeof prop === 'string' && !(prop in t)) t[prop] = /^[a-z]/.test(prop) ? function () {} : undefined; + return t[prop]; + }, + set(t, prop, v) { t[prop] = v; return true; }, + }); +} +const listeners = {}; +const canvasStub = { + width: 960, height: 640, style: {}, + getContext: () => fakeCtx(), + addEventListener: (ev, fn) => { (listeners['canvas:' + ev] = listeners['canvas:' + ev] || []).push(fn); }, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 960, height: 640 }), +}; +const sandbox = { + console, + performance: { now: () => simTime }, + requestAnimationFrame: () => {}, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: () => canvasStub }, + Image: class { constructor() { this.width = 32; this.height = 32; } set src(v) { this.onerror && this.onerror(); } }, + Audio: class { constructor() { this.volume = 0; this.paused = true; } play() { return Promise.resolve(); } pause() {} }, + AudioContext: undefined, + setTimeout: (fn) => fn(), + Math, JSON, Object, Array, Number, String, Boolean, Promise, Uint8ClampedArray, Proxy, Reflect, Set, Map, Date, Error, RegExp, parseInt, parseFloat, isNaN, Infinity, NaN, undefined, +}; +sandbox.window = sandbox; +sandbox.globalThis = sandbox; +sandbox.window.addEventListener = (ev, fn) => { (listeners['win:' + ev] = listeners['win:' + ev] || []).push(fn); }; +vm.createContext(sandbox); +for (const f of ['utils', 'state', 'input', 'sfx', 'audio', 'sprites', 'particles', 'weapons', + 'bullets', 'pickups', 'items', 'perks', 'enemies', 'bosses', 'rooms', 'player', 'hud', 'main']) { + vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'js', f + '.js'), 'utf8'), sandbox, { filename: f + '.js' }); +} +vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, W, H, WALL })', sandbox); + +let simTime = 0; +const ctx = sandbox; +let SYS = 'init'; +function step(n) { + for (let i = 0; i < n; i++) { + simTime += 16; + if (ctx.G.mode === 'play') { + SYS = 'updatePlayer'; ctx.updatePlayer(0.016); + SYS = 'updateRoom'; ctx.updateRoom(0.016); + SYS = 'updateEnemies'; ctx.updateEnemies(0.016); + SYS = 'updateTelegraphs'; ctx.updateTelegraphs(0.016); + SYS = 'updateBullets'; ctx.updateBullets(0.016); + SYS = 'updateHazards'; ctx.updateHazards(0.016); + SYS = 'updatePickups'; ctx.updatePickups(0.016); + SYS = 'updateParticles'; ctx.updateParticles(0.016); + SYS = 'drawToastsUpdate'; ctx.drawToastsUpdate(0.016); + SYS = 'draw'; ctx.draw(); + } else { + SYS = 'update(' + ctx.G.mode + ')'; + ctx.update(0.016); + SYS = 'draw'; ctx.draw(); + } + ctx.clearInputEdges(); + } +} + +for (const fn of (listeners['win:load'] || [])) fn(); +step(2); +ctx.startRun(); +for (let i = 1; i < FLOOR; i++) ctx.nextFloor(); +const bossRoom = Object.values(ctx.G.rooms).find(r => r.type === 'boss'); +ctx.enterRoom(bossRoom.gx, bossRoom.gy); +ctx.G.player.hp = 99999; // keep sim player alive (maxHp small for HUD) +step(2); +console.log('floor', FLOOR, '| boss:', ctx.G.boss && ctx.G.boss.bossKind, '| state:', ctx.G.boss && ctx.G.boss.state); +if (!ctx.G.boss) { console.log('SETUP FAILED'); process.exit(1); } + +const weaponIds = Object.keys(ctx.WEAPONS); +const FRAMES = 12000; +let prevState = 'idle', charges = 0, flingShots = 0, maxEbul = 0; +try { + for (let f = 0; f < FRAMES; f++) { + if (f % 40 === 0) { + for (const k of ['w', 'a', 's', 'd']) ctx.Input.keys[k] = false; + ctx.Input.keys[['w', 'a', 's', 'd'][Math.floor(Math.random() * 4)]] = true; + } + if (ctx.G.boss) { ctx.Input.mx = ctx.G.boss.x; ctx.Input.my = ctx.G.boss.y; } + ctx.Input.mdown = true; + if (f % 900 === 0 && f > 0) { + ctx.G.player.weapon = { id: weaponIds[Math.floor(Math.random() * weaponIds.length)], ammo: 999 }; + } + if (ctx.G.boss) ctx.G.boss.hp = Math.min(ctx.G.boss.maxHp, ctx.G.boss.hp + 3); + ctx.G.player.hp = 99999; + if (ctx.G.mode === 'levelup') ctx.choosePerk(0); + const ebulBefore = ctx.G.ebullets.length; + step(1); + if (ctx.G.boss && ctx.G.boss.state !== prevState) { + if (ctx.G.boss.state === 'charge') charges++; + prevState = ctx.G.boss.state; + } + if (ctx.G.ebullets.length > ebulBefore) flingShots += ctx.G.ebullets.length - ebulBefore; + maxEbul = Math.max(maxEbul, ctx.G.ebullets.length); + if (f % 1000 === 999) { + console.log(`f${f + 1}: mode=${ctx.G.mode} state=${ctx.G.boss ? ctx.G.boss.state : 'dead'} enemies=${ctx.G.enemies.length} ebul=${ctx.G.ebullets.length} bossHp=${ctx.G.boss ? Math.round(ctx.G.boss.hp) : 'dead'}`); + } + } +} catch (e) { + console.log('EXCEPTION in ' + SYS + ':', e.stack); + process.exit(1); +} +console.log(`SOAK OK | charges=${charges} flingShots=${flingShots} maxEbul=${maxEbul}`); diff --git a/test/repro_boss2.js b/test/repro_boss2.js new file mode 100644 index 0000000..dbebd21 --- /dev/null +++ b/test/repro_boss2.js @@ -0,0 +1,150 @@ +// Repro hunt: simulate a LONG Gore Crown (floor 2 boss) fight with a +// realistically behaving player, watching for exceptions / runaway growth. +'use strict'; +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +function fakeCtx() { + return new Proxy({}, { + get(t, prop) { + if (prop === 'measureText') return () => ({ width: 50 }); + if (typeof prop === 'string' && !(prop in t)) t[prop] = /^[a-z]/.test(prop) ? function () {} : undefined; + return t[prop]; + }, + set(t, prop, v) { t[prop] = v; return true; }, + }); +} +const listeners = {}; +const canvasStub = { + width: 960, height: 640, style: {}, + getContext: () => fakeCtx(), + addEventListener: (ev, fn) => { (listeners['canvas:' + ev] = listeners['canvas:' + ev] || []).push(fn); }, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 960, height: 640 }), +}; +const sandbox = { + console, + performance: { now: () => simTime }, + requestAnimationFrame: () => {}, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: () => canvasStub }, + Image: class { constructor() { this.width = 32; this.height = 32; } set src(v) { this.onerror && this.onerror(); } }, + Audio: class { constructor() { this.volume = 0; this.paused = true; } play() { return Promise.resolve(); } pause() {} }, + AudioContext: undefined, + setTimeout: (fn) => fn(), + Math, JSON, Object, Array, Number, String, Boolean, Promise, Uint8ClampedArray, Proxy, Reflect, Set, Map, Date, Error, RegExp, parseInt, parseFloat, isNaN, Infinity, NaN, undefined, +}; +sandbox.window = sandbox; +sandbox.globalThis = sandbox; +sandbox.window.addEventListener = (ev, fn) => { (listeners['win:' + ev] = listeners['win:' + ev] || []).push(fn); }; +vm.createContext(sandbox); +for (const f of ['utils', 'state', 'input', 'sfx', 'audio', 'sprites', 'particles', 'weapons', + 'bullets', 'pickups', 'items', 'perks', 'enemies', 'bosses', 'rooms', 'player', 'hud', 'main']) { + vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'js', f + '.js'), 'utf8'), sandbox, { filename: f + '.js' }); +} +vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, W, H, WALL })', sandbox); + +let simTime = 0; +const ctx = sandbox; +let SYS = 'init'; +function step(n) { + for (let i = 0; i < n; i++) { + simTime += 16; + // instrumented play-mode dispatch so the last label before a hang = culprit + if (ctx.G.mode === 'play') { + SYS = 'updatePlayer'; ctx.updatePlayer(0.016); + SYS = 'updateRoom'; ctx.updateRoom(0.016); + SYS = 'updateEnemies'; ctx.updateEnemies(0.016); + SYS = 'updateTelegraphs'; ctx.updateTelegraphs(0.016); + SYS = 'updateBullets'; ctx.updateBullets(0.016); + SYS = 'updateHazards'; ctx.updateHazards(0.016); + SYS = 'updatePickups'; ctx.updatePickups(0.016); + SYS = 'updateParticles'; ctx.updateParticles(0.016); + SYS = 'drawToastsUpdate'; ctx.drawToastsUpdate(0.016); + SYS = 'draw'; ctx.draw(); + } else { + SYS = 'update(' + ctx.G.mode + ')'; + ctx.update(0.016); + SYS = 'draw'; ctx.draw(); + } + ctx.clearInputEdges(); + } +} +process.on('SIGINT', () => { console.log('INT at', SYS); process.exit(1); }); +for (const fn of (listeners['win:load'] || [])) fn(); +step(2); + +// start a run and go to floor 2's boss room +ctx.startRun(); +ctx.nextFloor(); +const bossRoom = Object.values(ctx.G.rooms).find(r => r.type === 'boss'); +ctx.enterRoom(bossRoom.gx, bossRoom.gy); +// invincible sim player so the fight actually lasts (we're hunting freezes, not deaths) +// NOTE: keep maxHp small — drawHUD loops once per heart; huge maxHp = slow test +ctx.G.player.hp = 99999; +step(2); +console.log('boss:', ctx.G.boss && ctx.G.boss.bossKind); +if (!ctx.G.boss || ctx.G.boss.bossKind !== 'gorecrown') { console.log('SETUP FAILED'); process.exit(1); } + +// realistic fight: move erratically, hold fire aimed at boss, cycle weapons, +// draft perks when offered, DON'T kill the boss (heal it) so the fight drags on +const weaponIds = Object.keys(ctx.WEAPONS); +let maxEnemies = 0, maxParts = 0, maxEbullets = 0, maxPickups = 0; +const FRAMES = 12000; // ~3.2 minutes of game time +try { + for (let f = 0; f < FRAMES; f++) { + // wiggle movement + if (f % 40 === 0) { + for (const k of ['w', 'a', 's', 'd']) ctx.Input.keys[k] = false; + ctx.Input.keys[['w', 'a', 's', 'd'][Math.floor(Math.random() * 4)]] = true; + } + // aim at boss and hold fire + if (ctx.G.boss) { ctx.Input.mx = ctx.G.boss.x; ctx.Input.my = ctx.G.boss.y; } + ctx.Input.mdown = true; + // swap weapons occasionally + if (f % 900 === 0 && f > 0) { + const wid = weaponIds[Math.floor(Math.random() * weaponIds.length)]; + ctx.G.player.weapon = { id: wid, ammo: 999 }; + } + // keep the boss alive: top up its hp each frame + if (ctx.G.boss) ctx.G.boss.hp = Math.min(ctx.G.boss.maxHp, ctx.G.boss.hp + 3); + // keep the sim player alive + ctx.G.player.hp = 99999; + // auto-draft perks + if (ctx.G.mode === 'levelup') ctx.choosePerk(0); + + step(1); + maxEnemies = Math.max(maxEnemies, ctx.G.enemies.length); + maxParts = Math.max(maxParts, ctx.G.parts.length); + maxEbullets = Math.max(maxEbullets, ctx.G.ebullets.length); + maxPickups = Math.max(maxPickups, ctx.G.pickups.length); + if (f % 100 === 99) { + console.log(`f${f + 1}: mode=${ctx.G.mode} enemies=${ctx.G.enemies.length} parts=${ctx.G.parts.length} ebul=${ctx.G.ebullets.length} pick=${ctx.G.pickups.length} bossHp=${ctx.G.boss ? Math.round(ctx.G.boss.hp) : 'dead'} weapon=${ctx.G.player.weapon.id}`); + } + } +} catch (e) { + console.log('EXCEPTION DURING FIGHT:', e.stack); + process.exit(1); +} +console.log(`survived ${FRAMES} frames. maxEnemies=${maxEnemies} maxParts=${maxParts} maxEbullets=${maxEbullets} maxPickups=${maxPickups}`); + +// now kill the boss while minions are alive, then mop up +try { + ctx.Input.mdown = false; + if (ctx.G.boss) ctx.damageEnemy(ctx.G.boss, 999999, 0, false); + step(5); + console.log('boss killed with', ctx.G.enemies.length, 'minions alive; room cleared =', ctx.G.cur.cleared); + for (const e of [...ctx.G.enemies]) ctx.damageEnemy(e, 999999, 0, false); + step(30); + console.log('after mop-up: cleared =', ctx.G.cur.cleared, '| stairs =', ctx.G.pickups.some(p => p.type === 'stairs'), '| floor =', ctx.G.floor); + const stairs = ctx.G.pickups.find(p => p.type === 'stairs'); + if (stairs) { + ctx.G.player.x = stairs.x; ctx.G.player.y = stairs.y; + step(5); + console.log('floor after stairs =', ctx.G.floor, '| mode =', ctx.G.mode); + } + console.log('POST-FIGHT OK'); +} catch (e) { + console.log('EXCEPTION AFTER FIGHT:', e.stack); + process.exit(1); +} diff --git a/test/smoke.js b/test/smoke.js new file mode 100644 index 0000000..706953a --- /dev/null +++ b/test/smoke.js @@ -0,0 +1,1125 @@ +// Headless smoke test: stubs the DOM/canvas, loads the game scripts, +// and drives the game through its major code paths. +// Run: node test/smoke.js +'use strict'; +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +// ---- fake canvas 2d context ---- +function fakeCtx() { + return new Proxy({}, { + get(t, prop) { + if (prop === 'measureText') return () => ({ width: 50 }); + if (prop === 'getImageData') return () => ({ data: new Uint8ClampedArray(4) }); + if (prop === 'createLinearGradient' || prop === 'createRadialGradient') + return () => ({ addColorStop() {} }); + if (typeof prop === 'string') { + if (!(prop in t)) t[prop] = (typeof prop === 'string' && /^[a-z]/.test(prop)) ? function () {} : undefined; + } + return t[prop]; + }, + set(t, prop, v) { t[prop] = v; return true; }, + }); +} + +const listeners = {}; +const canvasStub = { + width: 960, height: 640, style: {}, + getContext: () => fakeCtx(), + addEventListener: (ev, fn) => { (listeners['canvas:' + ev] = listeners['canvas:' + ev] || []).push(fn); }, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 960, height: 640 }), +}; + +const sandbox = { + console, + performance: { now: () => simTime }, + requestAnimationFrame: () => {}, // we drive update() manually + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: () => canvasStub }, + Image: class { constructor() { this.width = 32; this.height = 32; } set src(v) { this.onerror && this.onerror(); } }, + Audio: class { + constructor() { this.volume = 0; this.paused = true; } + play() { this.paused = false; return Promise.resolve(); } + pause() { this.paused = true; } + }, + AudioContext: undefined, // exercise the no-WebAudio path + setTimeout: (fn, ms) => fn(), // run sfx sequences inline + Math, JSON, Object, Array, Number, String, Boolean, Promise, Uint8ClampedArray, Proxy, Reflect, Set, Map, Date, Error, RegExp, parseInt, parseFloat, isNaN, Infinity, NaN, undefined, +}; +sandbox.window = sandbox; +sandbox.globalThis = sandbox; +sandbox.window.addEventListener = (ev, fn) => { (listeners['win:' + ev] = listeners['win:' + ev] || []).push(fn); }; +vm.createContext(sandbox); + +// load game scripts in index.html order +const files = ['utils', 'state', 'input', 'sfx', 'sfxbank', 'audio', 'sprites', 'particles', 'weapons', + 'bullets', 'pickups', 'items', 'perks', 'enemies', 'bosses', 'rooms', 'player', 'hud', 'main']; +for (const f of files) { + const code = fs.readFileSync(path.join(__dirname, '..', 'js', f + '.js'), 'utf8'); + vm.runInContext(code, sandbox, { filename: f + '.js' }); +} +// const/let top-level bindings live in the context's lexical scope, not on the +// global object — expose the ones the harness pokes at directly. +vm.runInContext('Object.assign(this, { G, Input, WEAPONS, Music, Sfx, SfxBank, W, H, WALL, ITEMS, PERKS, ENEMY_TYPES, BOSS_DEFS, SPRITE_MANIFEST, Sprites, ACTOR_ANIMS })', sandbox); + +let simTime = 0; +const ctx = sandbox; +let autoDraft = false; // when true, perk offers are auto-picked so tests stay in play mode +function step(n, dtMs) { + for (let i = 0; i < n; i++) { + if (autoDraft && ctx.G.mode === 'levelup') ctx.choosePerk(0); + simTime += dtMs; + ctx.update(dtMs / 1000); + ctx.draw(); + ctx.clearInputEdges(); + } +} +function fire(ev, key) { + for (const fn of (listeners['win:' + ev] || [])) fn({ key, preventDefault() {} }); +} +function press(key) { fire('keydown', key); } +function release(key) { fire('keyup', key); } +function tap(key) { press(key); step(1, 16); release(key); } + +let failures = 0; +function check(name, cond) { + if (cond) console.log(' PASS ' + name); + else { console.log(' FAIL ' + name); failures++; } +} + +console.log('== boot =='); +for (const fn of (listeners['win:load'] || [])) fn(); +step(5, 16); +check('menu mode', ctx.G.mode === 'menu'); +check('no images loaded (fallbacks ok)', ctx.G.imagesLoaded === true); +check('five complete actor actions registered', Object.keys(ctx.ACTOR_ANIMS).join(',') === 'idle,move,attack,hit,death'); +let sheetDraws = 0; +let outOfBoundsDraws = 0; +const actorCtx = fakeCtx(); +actorCtx.drawImage = (img, sx, sy, sw, sh) => { + sheetDraws++; + if (sx < 0 || sy < 0 || sx + sw > img.width || sy + sh > img.height) outOfBoundsDraws++; +}; +const sheetCases = [ + ['player', 768, 2784, 96], + ['enemy_shambler', 512, 1856, 64], + ['boss_bonesaw', 1024, 3712, 128], + ['boss_gorecrown', 1024, 3712, 128], + ['boss_knifecrawl', 1024, 3712, 128], + ['boss_vealmother', 1024, 3712, 128], + ['boss_flenser', 1024, 3712, 128], + ['boss_hookchoir', 1024, 3712, 128], + ['boss_platefather', 1024, 3712, 128], + ['boss_augerprime', 1024, 3712, 128], + ['boss_scald', 1024, 3712, 128], +]; +for (const [name, width, height, targetW] of sheetCases) { + ctx.Sprites.imgs[name + '_sheet'] = { width, height }; + for (let dir = 0; dir < 8; dir++) { + for (const action of Object.keys(ctx.ACTOR_ANIMS)) { + ctx.Sprites.actor(actorCtx, name, 0, 0, dir * Math.PI / 4, action, 0.2, targetW); + } + } + delete ctx.Sprites.imgs[name + '_sheet']; +} +check('all frame sizes address 8 directions and 5 actions', sheetDraws === 440); +check('actor atlas reads stay in bounds', outOfBoundsDraws === 0); +const layeredPlayerSprites = ['player_legs', 'player_legs_sheet', ...Object.keys(ctx.WEAPONS).map(id => 'pt_' + id)]; +check('layered player sprites are all preloaded', layeredPlayerSprites.every(name => ctx.SPRITE_MANIFEST.includes(name))); +let legDraws = 0, legOutOfBounds = 0; +const legRotations = []; +const legCtx = fakeCtx(); +legCtx.rotate = angle => legRotations.push(angle); +legCtx.drawImage = (img, sx, sy, sw, sh) => { + legDraws++; + if (sx < 0 || sy < 0 || sx + sw > img.width || sy + sh > img.height) legOutOfBounds++; +}; +ctx.Sprites.imgs.player_legs_sheet = { width: 768, height: 96 }; +for (let frame = 0; frame < 8; frame++) { + ctx.Sprites.legs(legCtx, 0, 0, Math.PI / 3, frame * Math.PI / 4 + 0.01, 96); +} +delete ctx.Sprites.imgs.player_legs_sheet; +check('legs strip addresses all 8 stride frames', legDraws === 8); +check('legs strip reads stay in bounds', legOutOfBounds === 0); +check('legs strip rotates smoothly at runtime', legRotations.length === 8 && legRotations.every(angle => Math.abs(angle - Math.PI / 3) < 0.001)); +check('every weapon has layered render and muzzle geometry', Object.values(ctx.WEAPONS).every(w => + Number.isFinite(w.torsoW) && Number.isFinite(w.torsoFwd) && Number.isFinite(w.muzzle) && w.muzzle > 0)); +check('every weapon has finite impact punch metadata', Object.values(ctx.WEAPONS).every(w => Number.isFinite(w.punch) && w.punch > 0)); +check('arsenal still contains exactly 16 weapons', Object.keys(ctx.WEAPONS).length === 16); +check('visceral DSP and fallback events are wired', + ['punch', 'duck', 'wallHit', 'lifesteal'].every(name => typeof ctx.Sfx[name] === 'function')); +const missingSfxFiles = Object.values(ctx.SfxBank.FILES).filter(file => { + const soundPath = path.join(__dirname, '..', 'assets', 'sfx', file); + return !fs.existsSync(soundPath) || fs.statSync(soundPath).size === 0; +}); +check('every registered SFX file exists and is non-empty', missingSfxFiles.length === 0); +if (missingSfxFiles.length) console.log(' MISSING-SFX: ' + missingSfxFiles.join(', ')); + +console.log('== start run =='); +tap('Enter'); +check('play mode', ctx.G.mode === 'play'); +check('player exists', !!ctx.G.player); +check('Bone Popper baseline is 8 damage at 0.60s', ctx.WEAPONS.bonepopper.dmg === 8 && ctx.WEAPONS.bonepopper.interval === 0.60); +check('floor 1 rooms generated', Object.keys(ctx.G.rooms).length >= 5); +check('start room current', ctx.G.cur && ctx.G.cur.type === 'start'); +step(30, 16); + +console.log('== movement + firing =='); +const px = ctx.G.player.x; +press('d'); step(20, 16); release('d'); +check('player moved right', ctx.G.player.x > px); +ctx.Input.mx = ctx.G.player.x; ctx.Input.my = ctx.G.player.y - 200; +press('d'); step(12, 16); release('d'); +const bodyAimDelta = Math.abs(Math.atan2(Math.sin(ctx.G.player.aim - ctx.G.player.bodyFacing), Math.cos(ctx.G.player.aim - ctx.G.player.bodyFacing))); +check('body follows movement while weapon aim stays independent', bodyAimDelta > 0.7 && Math.abs(ctx.G.player.bodyFacing) < 0.45); +const idleBodyFacing = ctx.G.player.bodyFacing; +step(12, 16); +const aimDeltaBeforeIdle = Math.abs(Math.atan2(Math.sin(ctx.G.player.aim - idleBodyFacing), Math.cos(ctx.G.player.aim - idleBodyFacing))); +const aimDeltaAfterIdle = Math.abs(Math.atan2(Math.sin(ctx.G.player.aim - ctx.G.player.bodyFacing), Math.cos(ctx.G.player.aim - ctx.G.player.bodyFacing))); +check('legs rotate back toward aim after movement stops', aimDeltaAfterIdle < aimDeltaBeforeIdle); +let footstepCalls = 0, wetFootsteps = 0; +const realFootstep = ctx.Sfx.footstep; +const decalStart = ctx.G.cur.decals.length; +for (const offset of [45, 90, 135, 180]) { + ctx.G.cur.decals.push({ x: ctx.G.player.x + offset, y: ctx.G.player.y, s: 1.6, rot: 0, img: 'decal_blood1' }); +} +check('gore proximity query detects blood underfoot', ctx.onGore(ctx.G.player.x + 45, ctx.G.player.y)); +ctx.Sfx.footstep = (_strength, wet) => { footstepCalls++; if (wet) wetFootsteps++; }; +press('d'); step(60, 16); release('d'); +const movingFootsteps = footstepCalls; +step(30, 16); +ctx.Sfx.footstep = realFootstep; +ctx.G.cur.decals.splice(decalStart); +check('stride-9 cycle emits synchronized footsteps', movingFootsteps >= 2 && footstepCalls === movingFootsteps); +check('blood decals select wet footstep events', wetFootsteps >= 1); +ctx.Input.mdown = true; ctx.Input.mx = 700; ctx.Input.my = 300; +step(30, 16); +check('bullets fired', ctx.G.bullets.length > 0 || ctx.G.kills >= 0); // may have hit nothing +step(60, 16); +ctx.Input.mdown = false; + +let wallHitCalls = 0; +const realWallHit = ctx.Sfx.wallHit; +ctx.Sfx.wallHit = () => { wallHitCalls++; }; +ctx.G.bullets.length = 0; +ctx.G.bullets.push({ + x: ctx.W - ctx.WALL - 5, y: ctx.H / 2, vx: 700, vy: 0, r: 5, dmg: 1, + pierce: 0, bounce: 0, bounces: 0, life: 1, t: 0, behavior: 'bullet', + sprite: 'bullet_bone', homing: 0, hit: null, dragTarget: null, +}); +ctx.updateBullets(0.02); +ctx.Sfx.wallHit = realWallHit; +check('absorbed rounds trigger wall impact audio', wallHitCalls === 1); + +console.log('== combat room: lock, clear, unlock =='); +// find a combat room adjacent to start and force-move there +const start = ctx.G.rooms['0,0']; +const dir = Object.keys(start.doors).find(d => start.doors[d]); +check('start has a door', !!dir); +// teleport into neighbor and enter it +const DIRS = { n: [0, -1], s: [0, 1], e: [1, 0], w: [-1, 0] }; +const [dx, dy] = DIRS[dir]; +ctx.enterRoom(start.gx + dx, start.gy + dy); +step(10, 16); +const room = ctx.G.cur; +if (room.type === 'combat') { + check('enemies spawned', ctx.G.enemies.length > 0); + check('room locked', ctx.roomLocked() === true); + // kill everything, let waves cycle + for (let guard = 0; guard < 200 && !room.cleared; guard++) { + for (const e of [...ctx.G.enemies]) ctx.damageEnemy(e, 99999, 0, false); + step(30, 16); + } + check('room cleared', room.cleared === true); + check('room unlocked', ctx.roomLocked() === false); + check('reward spawned', true); // reward pickups may already be collected +} else { + check('neighbor room handled (' + room.type + ')', true); +} + +console.log('== spawn safety =='); +{ + ctx.enterRoom(0, 0); + check('room entry grants spawn protection', ctx.G.player.invT > 0.5); + ctx.G.player.x = ctx.W / 2; ctx.G.player.y = ctx.H / 2; + let nearDoor = 0, tooClose = 0; + for (let i = 0; i < 80; i++) { + const pos = ctx.spawnPosAwayFromPlayer(); + if (ctx.nearAnyDoor(pos.x, pos.y)) nearDoor++; + if (Math.hypot(pos.x - ctx.G.player.x, pos.y - ctx.G.player.y) < 150) tooClose++; + } + check('no spawns near doors', nearDoor === 0); + check('no spawns near player', tooClose === 0); +} + +console.log('== XP / perks =='); +ctx.gainXP(100); +step(5, 16); +check('levelup triggered', ctx.G.mode === 'levelup' || ctx.G.pendingLevelups > 0); +let guard = 0; +while (ctx.G.mode === 'levelup' && guard++ < 30) { tap('1'); step(2, 16); } +check('perks drafted', ctx.G.mode === 'play'); +check('player leveled', ctx.G.player.level > 1); + +console.log('== rebalanced perks + automatic draft =='); +{ + check('13 new perks added', ctx.PERKS.length === 23); + const perkStats = { dmgMul: 1 }; + ctx.PERKS.find(k => k.id === 'sharpen').apply(perkStats, { hp: 1 }); + check('core level-up damage bonus was halved', Math.abs(perkStats.dmgMul - 1.04) < 0.001); + const p = ctx.G.player; + ctx.G.pendingLevelups = 1; ctx.G.autoPerk = false; ctx.openPerkDraft(); + p.stats.rerolls = 1; tap('r'); + check('Reroll Rib currency refreshes a manual draft', p.stats.rerolls === 0 && ctx.G.perkChoices.length === 3); + tap('4'); + check('one-off random draft choice resolves normally', ctx.G.mode === 'play' && ctx.G.pendingLevelups === 0); + const levelBefore = p.level; + ctx.G.autoPerk = true; + ctx.gainXP(180); + step(3, 16); + check('auto-draft drains queued level-ups without opening modal', p.level > levelBefore && ctx.G.pendingLevelups === 0 && ctx.G.mode === 'play'); + ctx.G.autoPerk = false; + ctx.startRun(); step(3, 16); +} +autoDraft = true; // later sections must not get stuck in the perk draft + +console.log('== shield heart perk =='); +{ + const p = ctx.G.player; + p.hp = 6; p.shieldHp = 0; p.stats.shieldPerk = 0; p.invT = 0; + check('old armor perk is gone', !ctx.PERKS.some(k => k.id === 'thick')); + const sh = ctx.PERKS.find(k => k.id === 'shieldheart'); + sh.apply(p.stats, p); sh.apply(p.stats, p); + check('two picks = two half-heart shields', p.stats.shieldPerk === 2 && p.shieldHp === 2); + const hpBefore = p.hp; + ctx.hurtPlayer(1, 0); + check('shield absorbs damage first', p.hp === hpBefore && p.shieldHp === 1); + p.invT = 0; + ctx.hurtPlayer(1, 0); + check('second hit breaks shield, hp untouched', p.hp === hpBefore && p.shieldHp === 0); + p.invT = 0; + ctx.hurtPlayer(1, 0); + check('unshielded hit hurts', p.hp === hpBefore - 1); + p.hp = hpBefore; + ctx.nextFloor(); step(3, 16); + check('shield refreshes each floor', p.shieldHp === 2); + ctx.startRun(); step(5, 16); // reset floor/state for later sections +} + +console.log('== items =='); +ctx.giveItem('hollowpoints'); +ctx.giveItem('orbitalknives'); +ctx.giveItem('splittongue'); +ctx.giveItem('ironstomach'); +check('items applied', Object.keys(ctx.G.player.items).length === 4); +check('orbital knives active', ctx.G.player.stats.orbitals >= 1); +step(30, 16); + +console.log('== expanded perk and item rosters =='); +{ + check('27 new items added', Object.keys(ctx.ITEMS).length === 46); + const newPerks = ['critbone', 'critmeat', 'flensing', 'ember', 'frostbile', 'heavyhand', 'thickhide', 'secondwind', 'scrapfeed', 'boneknit', 'spiteflesh', 'carrion', 'sinew']; + const newItems = ['chainsinew', 'mortarbone', 'bloatrounds', 'marrowglut', 'hollowneedle', 'bloodshoteye', 'flayerkiss', 'emberjar', 'acidgland', 'hookrounds', 'sledgerounds', 'graftedtrigger', 'deadmanswitch', 'orbitcrown', 'tannedhide', 'deadmansclock', 'hollowbones', 'boneplate', 'wormgut', 'spinecage', 'secondstomach', 'spitewell', 'twinhearts', 'brassmagazine', 'crowbait', 'gorgingleech', 'rerollrib']; + check('all new perks have manifest icons or fallbacks', newPerks.every(id => ctx.SPRITE_MANIFEST.includes('perk_' + id))); + check('all new items have manifest icons or fallbacks', newItems.every(id => ctx.SPRITE_MANIFEST.includes('i_' + id))); + check('new roster entries all have names, descriptions and effects', + newPerks.every(id => { const k = ctx.PERKS.find(p => p.id === id); return k && k.name && k.desc && typeof k.apply === 'function'; }) && + newItems.every(id => { const k = ctx.ITEMS[id]; return k && k.name && k.desc && typeof k.apply === 'function'; })); + const livePlayer = ctx.G.player; + ctx.initPlayer(); + const probe = ctx.G.player; + for (const id of newItems) for (let tier = 0; tier < 9; tier++) ctx.ITEMS[id].apply(probe.stats, probe); + for (const id of newPerks) for (let tier = 0; tier < 3; tier++) ctx.PERKS.find(k => k.id === id).apply(probe.stats, probe); + check('all new effects execute repeatedly without non-finite stats', + Object.values(probe.stats).every(v => typeof v !== 'number' || Number.isFinite(v)) && Number.isFinite(probe.hp) && Number.isFinite(probe.shieldHp)); + ctx.G.player = livePlayer; +} + +console.log('== diminishing armor block chance =='); +{ + const c1 = ctx.armorBlockChance(0.5), c2 = ctx.armorBlockChance(1), cHuge = ctx.armorBlockChance(999); + check('armor chance has diminishing absolute returns', c1 > 0 && c2 > c1 && (c2 - c1) < c1); + check('armor chance is capped at 75%', Math.abs(cHuge - 0.75) < 0.0001); + const p = ctx.G.player, hp = p.hp; + const realChance = ctx.chance; + ctx.__smokeRealChance = realChance; + vm.runInContext('chance = () => true', ctx); + p.stats.armor = 999; p.invT = 0; + ctx.hurtPlayer(1, 0); + check('successful armor roll ignores all damage', p.hp === hp); + vm.runInContext('chance = __smokeRealChance', ctx); + delete ctx.__smokeRealChance; + p.stats.armor = 0; p.invT = 0; +} + +console.log('== close-range weapon impact pass =='); +check('Marrow Scatter was substantially strengthened', ctx.WEAPONS.marrow.dmg >= 11 && ctx.WEAPONS.marrow.range >= 0.38); +check('Bile Blunderbuss was substantially strengthened', ctx.WEAPONS.bile.dmg >= 7 && ctx.WEAPONS.bile.range >= 0.38); +check('Cauterizer was substantially strengthened', ctx.WEAPONS.cauterizer.dmg >= 4.5 && ctx.WEAPONS.cauterizer.range >= 0.36); +check('melee weapons were substantially strengthened', ctx.WEAPONS.tenderizer.dmg >= 60 && ctx.WEAPONS.redhand.dmg >= 7); + +console.log('== continuous saw frame-rate independence =='); +{ + const p = ctx.G.player; + const savedStats = p.stats; + function sawDamage(steps) { + p.stats = Object.assign({}, savedStats, { + dmgMul: 1, rateMul: 1, rangeMul: 1, shotSpeedMul: 1, sizeMul: 1, crit: 0, + split: 0, fan: 0, rear: 0, pierce: 0, bounce: 0, homing: 0, + bleed: 0, igniteChance: 0, slowOnHit: 0, stunOnHit: 0, acidOnHit: 0, pullOnHit: 0, chain: 0, mortar: 0, + }); + p.aim = 0; + const e = ctx.makeEnemy('shambler', p.x + 30, p.y, 1, false); + e.hp = e.maxHp = 10000; e.spd = 0; + ctx.G.enemies = [e]; + const inst = { ammo: 999 }; + for (let i = 0; i < steps; i++) ctx.sawTick(p, ctx.WEAPONS.redhand, inst, 1 / steps); + return e.maxHp - e.hp; + } + const d30 = sawDamage(30), d120 = sawDamage(120); + check('chainsaw DPS is stable across simulation rates', Math.abs(d30 - d120) / Math.max(1, d30) < 0.02); + p.stats = Object.assign({}, savedStats, { + crit: 0, bleed: 0.5, splinter: 0, igniteChance: 0, slowOnHit: 0, stunOnHit: 0, + acidOnHit: 0, pullOnHit: 0, chain: 0, mortar: 0, + }); + const normal = ctx.makeEnemy('shambler', 400, 300, 1, false); + const orbital = ctx.makeEnemy('shambler', 500, 300, 1, false); + ctx.G.enemies = [normal, orbital]; + ctx.damageEnemy(normal, 12, 0, false, { noCrit: true }); + ctx.damageEnemy(orbital, 12, 0, false, { noCrit: true, procScale: 0.5, procMagnitudeScale: 0.5 }); + check('orbital proc dampening does not amplify effect magnitude', Math.abs(orbital.bleedDps - normal.bleedDps * 0.5) < 0.001); + p.stats = savedStats; ctx.G.enemies.length = 0; +} + +console.log('== item upgrades (9 quality tiers) =='); +{ + const p = ctx.G.player; + const dmgBefore = p.stats.dmgMul; + ctx.giveItem('hollowpoints'); + check('duplicate upgrades to tier II', p.items.hollowpoints === 2); + check('upgrade re-applies the effect', Math.abs(p.stats.dmgMul - dmgBefore * 1.25) < 0.001); + for (let i = 0; i < 7; i++) ctx.giveItem('hollowpoints'); + check('nine copies reach tier IX', p.items.hollowpoints === 9); + check('full linear scaling to IX', Math.abs(p.stats.dmgMul - dmgBefore * Math.pow(1.25, 8)) < 0.01); + const scoreBefore = ctx.G.score; + const dmgAtCap = p.stats.dmgMul; + ctx.giveItem('hollowpoints'); + check('tenth copy capped, converts to score', p.items.hollowpoints === 9 && ctx.G.score > scoreBefore); + check('capped copy gives no extra effect', p.stats.dmgMul === dmgAtCap); + check('still one icon in the item list', Object.keys(p.items).filter(k => k === 'hollowpoints').length === 1); +} + +console.log('== tier-scaled items + duplicate favoring =='); +{ + const p = ctx.G.player; + ctx.giveItem('backstabber'); ctx.giveItem('backstabber'); + check('backstabber stacks rear shots', p.stats.rear === 2); + ctx.G.enemies.length = 0; + ctx.G.bullets.length = 0; + p.weapon = { id: 'bonepopper', ammo: Infinity }; + p.fireT = 0; + ctx.Input.mx = p.x + 100; ctx.Input.my = p.y; + ctx.Input.mdown = true; step(3, 16); ctx.Input.mdown = false; + check('rear fan fires extra shots', ctx.G.bullets.length >= 3); // fwd + split + 2 rear + ctx.giveItem('homingtumor'); ctx.giveItem('homingtumor'); + check('homing tumor stacks', p.stats.homing === 2); + ctx.G.bullets.length = 0; + p.fireT = 0; // ensure a fresh volley actually fires + ctx.Input.mdown = true; step(3, 16); ctx.Input.mdown = false; + const hb = ctx.G.bullets[0]; + check('homing strength scales with tier', hb && Math.abs(hb.homing - 3.0) < 0.001); + ctx.giveItem('volatilebile'); ctx.giveItem('volatilebile'); + check('volatile bile tiers up', p.stats.explodeOnKill === 2); + // kill-explosion should damage a nearby enemy + ctx.G.enemies.length = 0; + const v1 = ctx.makeEnemy('shambler', 700, 320, 1, false); v1.spd = 0; v1.hp = 1; + const v2 = ctx.makeEnemy('shambler', 740, 320, 1, false); v2.spd = 0; + ctx.G.enemies.push(v1, v2); + const v2hpBefore = v2.hp; + ctx.damageEnemy(v1, 9999, 0, false); + check('kill-explosion hurts nearby enemies', v2.hp < v2hpBefore); + ctx.G.enemies.length = 0; + let ownedHits = 0; + for (let i = 0; i < 200; i++) { + if (p.items[ctx.randomItemId()]) ownedHits++; + } + check('item rolls favor owned items (' + ownedHits + '/200)', ownedHits >= 60); +} + +console.log('== all weapons fire without crashing =='); +for (const wid of Object.keys(ctx.WEAPONS)) { + ctx.G.player.weapon = { id: wid, ammo: 50 }; + ctx.G.player.charge = 0; + ctx.Input.mdown = true; + step(20, 16); + ctx.Input.mdown = false; + ctx.Input.mreleased = true; + step(5, 16); + ctx.Input.mreleased = false; + step(40, 16); +} +check('16 weapons exercised', true); +check('reverted to bone popper when dry', ctx.G.player.weapon.ammo > 0 || ctx.G.player.weapon.id === 'bonepopper'); + +console.log('== every weapon actually DAMAGES enemies (NaN regression) =='); +{ + const p = ctx.G.player; + const saveHp = p.hp, saveMax = p.stats.maxHp, saveOrb = p.stats.orbitals; + p.stats.orbitals = 0; // don't let orbitals mask a broken weapon + const noDmg = []; + for (const wid of Object.keys(ctx.WEAPONS)) { + ctx.G.enemies.length = 0; + const e = ctx.makeEnemy('shambler', p.x + 42, p.y, 1, false); + e.spd = 0; + ctx.G.enemies.push(e); + p.weapon = { id: wid, ammo: 60 }; + p.charge = 0; p.hp = 999; p.stats.maxHp = 999; + ctx.Input.mx = e.x; ctx.Input.my = e.y; + ctx.Input.mdown = true; + step(50, 16); + ctx.Input.mdown = false; + ctx.Input.mreleased = true; + step(8, 16); + ctx.Input.mreleased = false; + step(20, 16); + if (!(e.hp < e.maxHp)) noDmg.push(wid); + ctx.G.bullets.length = 0; + ctx.G.hazards.length = 0; + } + check('all 16 weapons deal damage', noDmg.length === 0); + if (noDmg.length) console.log(' NO-DAMAGE: ' + noDmg.join(', ')); + ctx.G.enemies.length = 0; + p.weapon = { id: 'bonepopper', ammo: Infinity }; + p.hp = saveHp; p.stats.maxHp = saveMax; p.stats.orbitals = saveOrb; +} + +console.log('== shared on-hit items work with every weapon =='); +{ + const p = ctx.G.player; + const saveBleed = p.stats.bleed, saveCrit = p.stats.crit, saveOrb = p.stats.orbitals; + p.stats.bleed = 1; p.stats.crit = 0; p.stats.orbitals = 0; + const realChance = ctx.chance; + ctx.__smokeRealChance = realChance; + vm.runInContext('chance = () => true', ctx); + const misses = []; + for (const wid of Object.keys(ctx.WEAPONS)) { + ctx.G.enemies.length = 0; ctx.G.bullets.length = 0; ctx.G.hazards.length = 0; + const e = ctx.makeEnemy('shambler', p.x + 42, p.y, 1, false); + e.hp = e.maxHp = 10000; e.spd = 0; + ctx.G.enemies.push(e); + p.weapon = { id: wid, ammo: 60 }; p.charge = 0; p.fireT = 0; + ctx.Input.mx = e.x; ctx.Input.my = e.y; ctx.Input.mdown = true; + step(50, 16); + ctx.Input.mdown = false; ctx.Input.mreleased = true; step(8, 16); + ctx.Input.mreleased = false; step(20, 16); + if (!(e.bleedT > 0)) misses.push(wid); + } + check('all 16 weapons deliver shared bleed/on-hit payloads', misses.length === 0); + if (misses.length) console.log(' NO-ON-HIT: ' + misses.join(', ')); + vm.runInContext('chance = __smokeRealChance', ctx); + delete ctx.__smokeRealChance; + p.stats.bleed = saveBleed; p.stats.crit = saveCrit; p.stats.orbitals = saveOrb; + ctx.G.enemies.length = 0; ctx.G.bullets.length = 0; ctx.G.hazards.length = 0; + p.weapon = { id: 'bonepopper', ammo: Infinity }; +} + +console.log('== per-weapon muzzle spawn distances =='); +{ + const p = ctx.G.player; + const oldAim = p.aim; + p.aim = 0; + const badMuzzles = []; + for (const w of Object.values(ctx.WEAPONS)) { + if (['slam', 'saw', 'beam'].includes(w.behavior)) continue; + ctx.G.bullets.length = 0; + ctx.fireWeapon(p, w); + const b = ctx.G.bullets[0]; + if (!b || Math.abs(b.x - (p.x + w.muzzle)) > 0.001 || Math.abs(b.y - p.y) > 0.001) badMuzzles.push(w.id); + } + p.aim = oldAim; + ctx.G.bullets.length = 0; + check('projectiles originate at each weapon muzzle', badMuzzles.length === 0); + if (badMuzzles.length) console.log(' BAD-MUZZLE: ' + badMuzzles.join(', ')); +} + +console.log('== generic ammo drops =='); +{ + const p = ctx.G.player; + p.stats.luck = 5; + ctx.G.pickups.length = 0; + p.x = 100; p.y = 100; + for (let i = 0; i < 40; i++) { + const e = ctx.makeEnemy('shambler', 700, 500, 1, false); + ctx.G.enemies.push(e); + ctx.damageEnemy(e, 9999, 0, false); + } + step(3, 16); + const ammoCount = ctx.G.pickups.filter(k => k.type === 'ammo').length; + check('ammo drops are common (' + ammoCount + ' from 40 kills)', ammoCount >= 2); + // generic ammo refills every finite-ammo weapon currently carried + p.weapon = { id: 'repeater', ammo: 10 }; + p.holstered = { id: 'spinaltap', ammo: 2 }; + ctx.spawnPickup('ammo', p.x, p.y); + step(2, 16); + check('ammo pickup refills held weapon', p.weapon.ammo > 10); + check('ammo pickup also refills holstered weapon', p.holstered.ammo > 2); + const holsteredAmmo = p.holstered.ammo; + p.weapon = { id: 'bonepopper', ammo: Infinity }; + ctx.spawnPickup('ammo', p.x, p.y); + step(2, 16); + check('ammo reaches holstered weapon while sidearm is selected', p.holstered.ammo > holsteredAmmo); + + const repeaterRefill = Math.ceil(ctx.WEAPONS.repeater.ammo * ctx.ammoRefillFraction(ctx.WEAPONS.repeater)); + const spinalRefill = Math.ceil(ctx.WEAPONS.spinaltap.ammo * ctx.ammoRefillFraction(ctx.WEAPONS.spinaltap)); + check('rapid low-damage weapons receive more rounds than heavy weapons', repeaterRefill > spinalRefill * 10); + const sameDamageFast = ctx.ammoRefillFraction({ dmg: 8, interval: 0.1, ammo: 50 }); + const sameDamageSlow = ctx.ammoRefillFraction({ dmg: 8, interval: 1.0, ammo: 50 }); + check('fire rate explicitly increases ammo refill share', sameDamageFast > sameDamageSlow); + check('persistent utility weapon overrides dampen refill size', + ctx.WEAPONS.trapqueen.ammoWeight < 1 && ctx.WEAPONS.swarmjar.ammoWeight < 1); + + const repeaterCap = ctx.WEAPONS.repeater.ammo * 1.5; + p.weapon = { id: 'repeater', ammo: repeaterCap - 1 }; + p.holstered = null; + const overflowScore = ctx.G.score; + ctx.collectPickup({ type: 'ammo' }); + check('ammo pickup clamps at one and a half magazines', p.weapon.ammo === repeaterCap); + check('ammo overflow converts to score', ctx.G.score > overflowScore); + + p.stats.luck = 0; + p.weapon = { id: 'bonepopper', ammo: Infinity }; + p.holstered = null; + ctx.G.pickups.length = 0; +} + +console.log('== drop economy: weapons are rare, elites feed you =='); +{ + const p = ctx.G.player; + p.stats.luck = 0; + ctx.G.pickups.length = 0; + // regular monsters: no weapon drops at all + for (let i = 0; i < 60; i++) { + const e = ctx.makeEnemy('shambler', 700, 500, 1, false); + ctx.G.enemies.push(e); + ctx.damageEnemy(e, 9999, 0, false); + } + step(2, 16); + check('regular kills never drop weapons', !ctx.G.pickups.some(k => k.type === 'weapon')); + // elites: always bonus gems, mostly ammo, sometimes an item + p.x = 100; p.y = 100; + let eliteGemValue = 0, eliteAmmo = 0, eliteItems = 0, eliteWeapons = 0; + for (let i = 0; i < 30; i++) { + const e = ctx.makeEnemy('shambler', 700, 500, 1, true); + ctx.G.enemies.push(e); + ctx.G.pickups.length = 0; // isolate this kill's drops + ctx.damageEnemy(e, 9999, 0, false); + eliteGemValue += ctx.G.pickups.filter(k => k.type === 'gem').reduce((s, k) => s + k.v, 0); + eliteAmmo += ctx.G.pickups.filter(k => k.type === 'ammo').length; + eliteItems += ctx.G.pickups.filter(k => k.type === 'item').length; + eliteWeapons += ctx.G.pickups.filter(k => k.type === 'weapon').length; + } + step(3, 16); + check('elites always drop bonus gems (avg value ' + (eliteGemValue / 30).toFixed(1) + ')', eliteGemValue / 30 >= 5.5); + check('elites mostly drop ammo (' + eliteAmmo + '/30)', eliteAmmo >= 8); + check('elites sometimes drop items (' + eliteItems + '/30)', eliteItems >= 1); + check('elites never drop weapons', eliteWeapons === 0); + ctx.G.pickups.length = 0; +} + +console.log('== visible XP bonus + kill-based lifesteal =='); +{ + const p = ctx.G.player; + const saved = { + hp: p.hp, maxHp: p.stats.maxHp, xpMul: p.stats.xpMul, + bloodlust: p.stats.bloodlust, lifestealChance: p.stats.lifestealChance, + explodeOnKill: p.stats.explodeOnKill, + }; + const realChance = ctx.chance; + ctx.__smokeRealChance = realChance; + vm.runInContext('chance = () => true', ctx); + p.stats.bloodlust = 0; + p.stats.explodeOnKill = 0; + + function gemValueFor(multiplier) { + p.stats.xpMul = multiplier; + ctx.G.pickups.length = 0; + const e = ctx.makeEnemy('shambler', 700, 500, 1, false); + ctx.killEnemy(e, 0); + return ctx.G.pickups.filter(k => k.type === 'gem').reduce((sum, k) => sum + k.v, 0); + } + const baseGemValue = gemValueFor(1); + const boostedGemValue = gemValueFor(2); + check('XP multiplier adds a visible bonus crystal roll', boostedGemValue > baseGemValue); + check('Bloodrush card advertises its crystal chance', + ctx.PERKS.find(perk => perk.id === 'bloodrush').desc.includes('bonus crystal')); + + p.stats.xpMul = 1; + p.stats.lifestealChance = 1; + p.hp = Math.max(0, p.stats.maxHp - 4); + const hpBeforeHit = p.hp; + const survivor = ctx.makeEnemy('shambler', 700, 500, 1, false); + survivor.hp = survivor.maxHp = 1000; + ctx.damageEnemy(survivor, 1, 0, false); + check('Dentures no longer heal on nonlethal hits', p.hp === hpBeforeHit); + + let lifestealSounds = 0; + const realLifestealSfx = ctx.Sfx.lifesteal; + ctx.Sfx.lifesteal = () => { lifestealSounds++; }; + const victim = ctx.makeEnemy('shambler', 700, 500, 1, false); + ctx.damageEnemy(victim, 9999, 0, false); + check('Dentures heal half a heart on a normal kill', p.hp === hpBeforeHit + 1); + check('lifesteal proc has dedicated feedback audio', lifestealSounds === 1); + + // Hemophage keeps its weapon-specific on-hit drain with the same feedback. + p.hp = hpBeforeHit; + p.stats.lifestealChance = 0; + lifestealSounds = 0; + ctx.G.enemies.length = 0; + ctx.G.bullets.length = 0; + const drainTarget = ctx.makeEnemy('shambler', p.x + 20, p.y, 1, false); + drainTarget.hp = drainTarget.maxHp = 1000; + drainTarget.spd = 0; + ctx.G.enemies.push(drainTarget); + ctx.G.bullets.push({ + x: drainTarget.x, y: drainTarget.y, vx: 0, vy: 0, r: 5, dmg: 1, + pierce: 0, bounce: 0, life: 1, t: 0, behavior: 'bullet', + sprite: 'bullet_syringe', homing: 0, lifesteal: ctx.WEAPONS.hemophage.lifesteal, + bounces: 0, hit: null, dragTarget: null, + }); + ctx.updateBullets(0.016); + check('Hemophage still heals on a nonlethal hit', p.hp === hpBeforeHit + 1); + check('Hemophage drain uses lifesteal feedback', lifestealSounds === 1); + + ctx.Sfx.lifesteal = realLifestealSfx; + vm.runInContext('chance = __smokeRealChance', ctx); + delete ctx.__smokeRealChance; + p.hp = saved.hp; + p.stats.maxHp = saved.maxHp; + p.stats.xpMul = saved.xpMul; + p.stats.bloodlust = saved.bloodlust; + p.stats.lifestealChance = saved.lifestealChance; + p.stats.explodeOnKill = saved.explodeOnKill; + ctx.G.pickups.length = 0; + ctx.G.enemies.length = 0; +} + +console.log('== iron stomach is a health upgrade =='); +{ + const p = ctx.G.player; + const maxBefore = p.stats.maxHp, hpBefore = p.hp; + ctx.giveItem('ironstomach'); + check('iron stomach adds half a heart container', p.stats.maxHp === maxBefore + 1); + check('iron stomach heals half a heart', p.hp === Math.min(p.stats.maxHp, hpBefore + 1)); + ctx.giveItem('ironstomach'); + check('it stacks per tier', p.stats.maxHp === maxBefore + 2); +} + +console.log('== monsters scale with player upgrades =='); +{ + const p = ctx.G.player; + ctx.G.pressure = 1; + p.level = 1; p.items = {}; + const base = ctx.makeEnemy('shambler', 500, 300, 1, false); + check('no upgrades = base hp', Math.abs(base.hp - 26) < 0.01); + // Weighted power: 4 perks × 0.5 + 2 item tiers = 4 power. + p.level = 5; p.items = { hollowpoints: 2 }; + const e6 = ctx.makeEnemy('shambler', 500, 300, 1, false); + check('weaker perks count half as much as item tiers', Math.abs(ctx.powerScore() - 4) < 0.001); + check('weighted power gives +20% hp', Math.abs(e6.hp - 26 * 1.2) < 0.01); + check('weighted power gives ~+4% speed', e6.spd >= 55 * 1.04 * 0.9 && e6.spd <= 55 * 1.04 * 1.1); + const e64 = ctx.makeEnemy('shambler', 500, 300, 4, false); + check('floor + weighted scaling stack', Math.abs(e64.hp - 26 * 1.66 * 1.2) < 0.02); + const b = ctx.spawnBoss(1); + check('boss hp scales with weighted upgrades', Math.abs(b.hp - 520 * 1.2) < 0.01); + ctx.G.enemies.length = 0; ctx.G.boss = null; ctx.G.cur.cleared = true; + ctx.startRun(); step(5, 16); +} + +console.log('== adaptive clean-room pressure =='); +{ + const p = ctx.G.player; + ctx.G.pressure = 1; ctx.G.streak = 0; ctx.G.roomDamaged = false; + ctx.recordRoomClear({ type: 'combat' }); + ctx.G.roomDamaged = false; ctx.recordRoomClear({ type: 'combat' }); + ctx.G.roomDamaged = false; ctx.recordRoomClear({ type: 'combat' }); + check('three clean rooms add exactly 3% pressure', Math.abs(ctx.G.pressure - 1.03) < 0.0001 && ctx.G.streak === 3); + const pressured = ctx.makeEnemy('shambler', 500, 300, 1, false); + check('pressure strengthens and speeds newly spawned enemies', pressured.maxHp > 26 && pressured.spd >= 55 * 1.03 * 0.9); + + p.stats.armor = 0; p.stats.maxHp = 10; p.shieldHp = 0; + ctx.G.pressure = 1.2; ctx.G.recentHits = []; p.hp = 10; p.invT = 0; ctx.G.mode = 'play'; + ctx.hurtPlayer(1, 0); const fullHealthRelief = 1.2 - ctx.G.pressure; + ctx.G.pressure = 1.2; ctx.G.recentHits = []; p.hp = 3; p.invT = 0; ctx.G.mode = 'play'; + ctx.hurtPlayer(1, 0); const lowHealthRelief = 1.2 - ctx.G.pressure; + check('low health dynamically grants more pressure relief', lowHealthRelief > fullHealthRelief); + + ctx.G.pressure = 1.2; ctx.G.streak = 4; ctx.G.roomDamaged = false; + p.hp = 10; p.shieldHp = 1; p.invT = 0; ctx.G.mode = 'play'; + ctx.hurtPlayer(1, 0); + check('fully shielded hits preserve a clean-room streak', ctx.G.pressure === 1.2 && ctx.G.streak === 4 && !ctx.G.roomDamaged); + p.shieldHp = 0; + + ctx.G.pressure = 0.751; ctx.G.recentHits = []; p.hp = 3; p.invT = 0; ctx.G.mode = 'play'; + ctx.hurtPlayer(1, 0); + check('pressure relief is floored at 75%', ctx.G.pressure === 0.75); + + ctx.G.pressure = 9; ctx.G.roomDamaged = false; ctx.recordRoomClear({ type: 'combat' }); + check('pressure is capped at 160%', ctx.G.pressure === 1.6); + ctx.startRun(); step(3, 16); + check('new runs reset pressure and streak', ctx.G.pressure === 1 && ctx.G.streak === 0); +} + +console.log('== weapon swap (R) =='); +{ + const p = ctx.G.player; + p.weapon = { id: 'bonepopper', ammo: Infinity }; + p.holstered = null; + tap('r'); + check('R with no special does nothing', p.weapon.id === 'bonepopper' && !p.holstered); + ctx.spawnPickup('weapon', p.x, p.y, { wid: 'repeater' }); + step(2, 16); + check('picked up special', p.weapon.id === 'repeater'); + p.weapon.ammo = 77; + const playSwapToast = ctx.G.toasts[ctx.G.toasts.length - 1]; + tap('r'); + check('R swaps to pistol', p.weapon.id === 'bonepopper' && p.holstered && p.holstered.id === 'repeater'); + check('play weapon swap emits a toast', ctx.G.toasts[ctx.G.toasts.length - 1] !== playSwapToast); + check('holster keeps remaining ammo', p.holstered.ammo === 77); + tap('r'); + check('R swaps back', p.weapon.id === 'repeater' && p.weapon.ammo === 77 && !p.holstered); + // picking up another special drops the old one with its ammo + ctx.spawnPickup('weapon', p.x, p.y, { wid: 'marrow' }); + step(2, 16); + check('new special becomes active', p.weapon.id === 'marrow'); + check('old special dropped with its ammo', ctx.G.pickups.some(k => k.type === 'weapon' && k.wid === 'repeater' && k.ammo === 77)); + // dropped weapon has a collect delay so it isn't instantly re-grabbed + check('dropped weapon not instantly re-collected', p.weapon.id === 'marrow'); + // dry weapon reverts to pistol but remains in the holster for later refill + p.weapon = { id: 'marrow', ammo: 0 }; + step(2, 16); + check('dry special reverts to pistol and stays holstered', p.weapon.id === 'bonepopper' && p.holstered && p.holstered.id === 'marrow' && p.holstered.ammo === 0); + tap('r'); + check('empty holstered special is unselectable', p.weapon.id === 'bonepopper' && p.holstered.id === 'marrow'); + ctx.spawnPickup('ammo', p.x, p.y); + step(2, 16); + check('ammo refills empty holstered special', p.holstered && p.holstered.ammo > 0); + tap('r'); + check('refilled special becomes selectable', p.weapon.id === 'marrow'); + ctx.G.pickups.length = 0; +} + +console.log('== splinter item =='); +{ + const p = ctx.G.player; + const saveOrb = p.stats.orbitals; + p.stats.orbitals = 0; + ctx.giveItem('splinterbone'); + check('splinter stat applied', p.stats.splinter === 2); + ctx.G.enemies.length = 0; + const e = ctx.makeEnemy('shambler', p.x + 50, p.y, 1, false); + e.spd = 0; + ctx.G.enemies.push(e); + p.weapon = { id: 'bonepopper', ammo: Infinity }; + ctx.Input.mx = e.x; ctx.Input.my = e.y; + ctx.Input.mdown = true; + let sawShard = false; + for (let i = 0; i < 40 && !sawShard; i++) { + step(1, 16); + if (ctx.G.bullets.some(b => b.shard)) sawShard = true; + } + ctx.Input.mdown = false; + step(40, 16); + check('shots shatter into shards on hit', sawShard); + check('splinter stacks', (ctx.ITEMS.splinterbone.apply(p.stats, p), p.stats.splinter === 4)); + ctx.G.enemies.length = 0; + ctx.G.bullets.length = 0; + p.stats.orbitals = saveOrb; +} + +console.log('== boss fight =='); +ctx.nextFloor(); +step(10, 16); +check('floor 2', ctx.G.floor === 2); +// jump to boss room +const bossRoom = Object.values(ctx.G.rooms).find(r => r.type === 'boss'); +check('boss room exists', !!bossRoom); +ctx.enterRoom(bossRoom.gx, bossRoom.gy); +step(10, 16); +check('boss spawned', !!ctx.G.boss); +check('boss hp is finite', ctx.G.boss && isFinite(ctx.G.boss.hp)); +check('boss music playing', ctx.Music.current && ctx.Music.current.name.includes('Boss')); +// whittle the boss down (also exercises telegraphs/patterns) +for (let g2 = 0; g2 < 400 && ctx.G.boss; g2++) { + for (const e of [...ctx.G.enemies]) ctx.damageEnemy(e, 50, 0, false); + step(20, 16); +} +check('boss dead', !ctx.G.boss); +check('boss death clears remaining minions', ctx.G.enemies.length === 0); +check('stairs spawned', ctx.G.pickups.some(p => p.type === 'stairs') || ctx.G.mode !== 'play'); +check('boss drops a weapon', ctx.G.pickups.some(p => p.type === 'weapon')); +check('boss reward pedestal', ctx.G.pickups.some(p => p.type === 'item')); + +console.log('== stairs persist when leaving and re-entering the boss room =='); +{ + const bRoom = ctx.G.cur; + check('currently in boss room', bRoom.type === 'boss'); + const bdir = Object.keys(bRoom.doors).find(d => bRoom.doors[d]); + const [bdx, bdy] = DIRS[bdir]; + ctx.enterRoom(bRoom.gx + bdx, bRoom.gy + bdy); + step(5, 16); + check('left the boss room', ctx.G.cur !== bRoom); + ctx.enterRoom(bRoom.gx, bRoom.gy); + step(5, 16); + check('back in boss room', ctx.G.cur === bRoom); + check('stairs still there', ctx.G.pickups.some(p => p.type === 'stairs')); + check('pedestal item still there', ctx.G.pickups.some(p => p.type === 'item')); +} + +console.log('== next floor via stairs =='); +const stairs = ctx.G.pickups.find(p => p.type === 'stairs'); +if (stairs) { + ctx.G.player.x = stairs.x; ctx.G.player.y = stairs.y; + step(5, 16); + check('floor advanced', ctx.G.floor === 3); +} + +console.log('== hazards / telegraphs / enemy bullets =='); +ctx.G.hazards.push({ kind: 'acid', x: 200, y: 200, r: 30, life: 2, t: 0, dps: 10 }); +ctx.G.hazards.push({ kind: 'trap', x: 300, y: 300, r: 16, life: 2, t: 0, dmg: 5 }); +ctx.G.telegraphs.push({ kind: 'knives', x: 400, y: 400, r: 34, t: 0, dur: 0.05, tick: 0 }); +step(60, 16); +check('hazards/telegraphs resolved', ctx.G.hazards.length >= 0 && ctx.G.telegraphs.length >= 0); + +console.log('== nine-boss roster and cycle mechanics =='); +{ + ctx.G.player.invT = 0; + const kinds = []; + for (let floor = 1; floor <= 9; floor++) { + ctx.G.floor = floor; + ctx.G.enemies.length = 0; ctx.G.ebullets.length = 0; ctx.G.telegraphs.length = 0; ctx.G.hazards.length = 0; ctx.G.boss = null; + const boss = ctx.spawnBoss(floor); + kinds.push(boss.bossKind); + for (let i = 0; i < 180; i++) { + ctx.updateBoss(boss, 1 / 60); + ctx.updateTelegraphs(1 / 60); + ctx.updateBullets(1 / 60); + ctx.updateHazards(1 / 60); + } + check(boss.name + ' survives a pattern soak', Number.isFinite(boss.x) && Number.isFinite(boss.y) && Number.isFinite(boss.hp)); + } + check('all nine boss kinds rotate without duplicates', new Set(kinds).size === 9 && kinds.length === 9); + check('every boss has a two-track music pair', ctx.BOSS_DEFS.every(d => ctx.Music.TRACKS.bosses[d.kind] && ctx.Music.TRACKS.bosses[d.kind].length === 2)); + check('all 18 boss themes are selectable in the jukebox', Object.values(ctx.Music.TRACKS.bosses).flat().every(f => ctx.Music.PLAYLIST.includes(f))); + check('all new boss stills and sheets are in the sprite manifest', ctx.BOSS_DEFS.every(d => ctx.SPRITE_MANIFEST.includes(d.sprite) && ctx.SPRITE_MANIFEST.includes(d.sprite + '_sheet'))); + + // Gore Crown repeats on floor 11 with all original cycle gates still active. + ctx.G.floor = 11; ctx.G.enemies.length = 0; ctx.G.ebullets.length = 0; ctx.G.boss = null; + const g = ctx.spawnBoss(11); + check('gorecrown repeats with debut-relative tier', g.bossKind === 'gorecrown' && g.cycle === 3 && g.tier === 3); + let sawRing = false, sawVolley = false; + for (let i = 0; i < 6; i++) { + g.atkT = 0; + const before = ctx.G.ebullets.length; + ctx.updateBoss(g, 0.001); + const batch = ctx.G.ebullets.length - before; + if (batch >= 12) sawRing = true; + if (batch > 0 && batch <= 7) sawVolley = true; + } + check('cycle gorecrown still fires rings', sawRing); + check('cycle gorecrown adds aimed volleys', sawVolley); + ctx.G.floor = 200; ctx.G.enemies.length = 0; ctx.G.ebullets.length = 0; ctx.G.boss = null; + const deepGore = ctx.spawnBoss(200); + let deepestBatch = 0; + for (let i = 0; i < 6; i++) { + deepGore.atkT = 0; const before = ctx.G.ebullets.length; ctx.updateBoss(deepGore, 0.001); + deepestBatch = Math.max(deepestBatch, ctx.G.ebullets.length - before); + } + check('deep-floor gore rings stay bounded', deepestBatch <= 80); + + ctx.G.floor = 12; ctx.G.enemies.length = 0; ctx.G.boss = null; ctx.G.telegraphs.length = 0; + const k1 = ctx.spawnBoss(12); k1.atkT = 0; ctx.updateBoss(k1, 0.001); + check('cycle knife telegraph hunts the player', k1.bossKind === 'knifecrawl' && ctx.G.telegraphs.some(t => t.track > 0)); + ctx.G.floor = 21; ctx.G.enemies.length = 0; ctx.G.boss = null; ctx.G.telegraphs.length = 0; ctx.G.ebullets.length = 0; + const k2 = ctx.spawnBoss(21); k2.atkT = 0; ctx.updateBoss(k2, 0.001); + check('cycle knifecrawl fires cleaver volley', ctx.G.ebullets.some(b => b.sprite === 'bullet_cleaver')); + ctx.G.floor = 30; ctx.G.enemies.length = 0; ctx.G.boss = null; + const k3 = ctx.spawnBoss(30); k3.state = 'dash'; k3.stateT = 0.001; k3.dashes = 2; k3.chargeAng = 0; + ctx.updateBoss(k3, 0.01); + check('cycle knifecrawl chain-dashes', k3.state === 'dash' && k3.dashes === 1); + + ctx.G.floor = 3; + ctx.G.enemies.length = 0; ctx.G.boss = null; ctx.G.telegraphs.length = 0; ctx.G.ebullets.length = 0; ctx.G.hazards.length = 0; +} + +console.log('== new boss combat primitives =='); +{ + const p = ctx.G.player; + p.hp = p.stats.maxHp = 999; p.invT = 0; p.stats.crit = 0; + ctx.G.floor = 7; ctx.G.enemies.length = 0; ctx.G.boss = null; + const plate = ctx.spawnBoss(7); + const hpBefore = plate.hp, rawToBreak = plate.maxHp * 0.06 / plate.dmgTakenMul + 1; + ctx.damageEnemy(plate, rawToBreak, 0, false); + check('Plate Father armor mitigates damage', plate.hp > hpBefore - rawToBreak && plate.dmgTakenMul === 0.25); + check('breaking a plate staggers the boss', plate.plates === 3 && plate.rootT > 0); + const atkBefore = plate.atkT; + ctx.updateEnemies(0.1); + check('plate stagger pauses the boss state machine', plate.atkT === atkBefore); + const savedStun = p.stats.stunOnHit; + const realChance = ctx.chance; + ctx.__smokeRealChance = realChance; + vm.runInContext('chance = () => true', ctx); + p.stats.stunOnHit = 1; plate.stunT = 0; + ctx.damageEnemy(plate, 1, 0, false, { noCrit: true }); + check('rapid stun items cannot permanently lock bosses', plate.stunT === 0); + p.stats.stunOnHit = savedStun; + vm.runInContext('chance = __smokeRealChance', ctx); + delete ctx.__smokeRealChance; + + ctx.G.floor = 5; ctx.G.enemies.length = 0; ctx.G.boss = null; + const flenser = ctx.spawnBoss(5); flenser.phased = true; + const flenserHp = flenser.hp; + ctx.damageEnemy(flenser, 100, 0, false); + check('phased Flenser is untargetable', flenser.hp === flenserHp && ctx.nearestEnemy(flenser.x, flenser.y, 500) === null); + + const deadAnchor = { x: 300, y: 300, hp: 0 }; + ctx.G.enemies.length = 0; + ctx.G.ebullets = [{ x: 320, y: 300, vx: 0, vy: 0, r: 6, dmg: 1, life: 2, t: 0, + sprite: 'bullet_harpoon', behavior: 'orbit', anchor: deadAnchor, orbA: 0, orbR: 20, orbSpd: 1 }]; + ctx.updateBullets(0.016); + check('orphaned orbit hooks release safely', ctx.G.ebullets[0] && ctx.G.ebullets[0].behavior !== 'orbit' && ctx.G.ebullets[0].anchor === null); + + p.x = 400; p.y = 300; p.invT = 0; + const hazardHp = p.hp; + ctx.G.hazards = [{ kind: 'acid', hostile: true, x: p.x, y: p.y, r: 30, life: 2, t: 0, tick: 0, dmg: 1, dps: 0 }]; + ctx.updateHazards(0.016); + check('hostile boss hazards damage the player', p.hp === hazardHp - 1); + p.invT = 0; + const safeHp = p.hp; + ctx.G.hazards = [{ kind: 'acid', x: p.x, y: p.y, r: 30, life: 2, t: 0, dps: 0 }]; + ctx.updateHazards(0.016); + check('ordinary weapon hazards remain player-safe', p.hp === safeHp); + + ctx.G.floor = 9; ctx.G.enemies.length = 0; ctx.G.boss = null; ctx.G.telegraphs.length = 0; ctx.G.hazards.length = 0; + const scald = ctx.spawnBoss(9); scald.hp = scald.maxHp * 0.2; + for (let i = 0; i < 10; i++) { scald.atkT = 0; ctx.updateBoss(scald, 0.001); } + check('Scald hostile pool count is capped', ctx.G.telegraphs.filter(t => t.kind === 'pool').length <= 14); + + ctx.G.floor = 8; ctx.G.enemies.length = 0; ctx.G.boss = null; + const cleanupBoss = ctx.spawnBoss(8); + ctx.G.telegraphs.push({ kind: 'sweep', owner: cleanupBoss, t: 0, dur: 2, ang: 0, spin: 1, len: 100, w: 20, arms: 1, tick: 0 }); + ctx.G.ebullets.push({ x: 100, y: 100 }); + ctx.G.hazards.push({ kind: 'acid', hostile: true, x: 100, y: 100, r: 20, life: 2, t: 0 }); + ctx.onBossDeath(cleanupBoss); + check('boss death clears persistent hostile attacks', ctx.G.telegraphs.length === 0 && ctx.G.ebullets.length === 0 && !ctx.G.hazards.some(h => h.hostile)); + ctx.G.enemies.length = 0; ctx.G.boss = null; ctx.G.hazards.length = 0; ctx.G.pickups.length = 0; + p.hp = Math.min(p.hp, p.stats.maxHp); +} + +console.log('== item rooms sometimes stock weapons =='); +{ + let withWeapon = 0; + for (let trial = 0; trial < 20; trial++) { + ctx.genFloor(1); + const ir = Object.values(ctx.G.rooms).find(r => r.type === 'item'); + if (!ir) continue; + ctx.enterRoom(ir.gx, ir.gy); + if (ctx.G.pickups.some(k => k.type === 'weapon')) withWeapon++; + } + check('item rooms sometimes have weapons (' + withWeapon + '/20)', withWeapon >= 1 && withWeapon <= 19); + // restore a sane run state for later sections + ctx.startRun(); + step(5, 16); +} + +console.log('== performance and duplicate-hit regressions =='); +{ + ctx.startRun(); + ctx.G.player.stats.crit = 0; + ctx.G.enemies.length = 0; ctx.G.bullets.length = 0; ctx.G.hazards.length = 0; + const victim = ctx.makeEnemy('shambler', 400, 300, 1, false); + victim.hp = 1; + ctx.G.enemies.push(victim); + ctx.G.hazards.push({ kind: 'acid', x: 400, y: 300, r: 30, life: 2, t: 0, dps: 8 }); + const killsBefore = ctx.G.kills; + ctx.damageEnemy(victim, 10, 0, false); + ctx.updateHazards(0.016); + check('same-frame hazard cannot double-kill', ctx.G.kills === killsBefore + 1); + + const target = ctx.makeEnemy('shambler', 500, 300, 1, false); + target.hp = target.maxHp = 100; + ctx.G.enemies = [target]; + ctx.G.bullets = [{ x: 500, y: 300, vx: 0, vy: 0, ang: 0, r: 5, dmg: 7, + pierce: 5, bounce: 0, bounces: 0, life: 1, t: 0, behavior: 'bullet', sprite: 'bullet_bone', hit: null }]; + ctx.updateBullets(0.016); + const hpAfterFirst = target.hp; + ctx.updateBullets(0.016); + check('piercing shot hits one target only once', target.hp === hpAfterFirst); + + ctx.G.parts.length = 0; + ctx.spawnExplosionFx(300, 300, 90); + check('explosions create layered shockwaves', ctx.G.parts.filter(p => p.type === 'shockwave').length === 2); + check('explosions create flame and smoke', ctx.G.parts.some(p => p.type === 'flame') && ctx.G.parts.some(p => p.type === 'smoke')); + ctx.G.parts.length = 0; + ctx.spawnMuzzleFx(300, 300, 0, 'flame'); + check('flame weapons emit layered muzzle particles', ctx.G.parts.some(p => p.type === 'flame') && ctx.G.parts.some(p => p.type === 'smoke')); + ctx.G.parts.length = 0; + ctx.spawnBeam(100, 100, 0, 200); + ctx.updateParticles(0.016); + const beamParticle = ctx.G.parts.find(p => p.type === 'beam'); + check('static beam particles remain finite', beamParticle && Number.isFinite(beamParticle.x) && Number.isFinite(beamParticle.y)); + ctx.G.parts.length = 0; + for (let i = 0; i < 1000; i++) ctx.addParticle({ type: 'spark', x: 0, y: 0, vx: 0, vy: 0, life: 1, t: 0, r: 1 }); + check('cosmetic particles are capped', ctx.G.parts.length <= 850); +} + +console.log('== death and restart =='); +ctx.G.player.invT = 0; +ctx.hurtPlayer(9999, 0); +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'); +step(5, 16); +check('restart works', ctx.G.mode === 'play' && ctx.G.floor === 1); + +console.log('== pause / mute =='); +tap('p'); +check('paused', ctx.G.mode === 'pause'); +ctx.G.player.weapon = { id: 'bonepopper', ammo: Infinity }; +ctx.G.player.holstered = { id: 'repeater', ammo: 20 }; +const pausedSwapToast = ctx.G.toasts[ctx.G.toasts.length - 1]; +tap('r'); +check('weapon swap works while paused', ctx.G.player.weapon.id === 'repeater'); +check('paused weapon swap stays silent', ctx.G.toasts[ctx.G.toasts.length - 1] === pausedSwapToast); +tap('r'); +tap('p'); +check('unpaused', ctx.G.mode === 'play'); +tap('m'); +check('muted', ctx.G.muted === true); + +console.log('== jukebox =='); +ctx.Music.cycle(1); +check('manual track selected', ctx.G.musicSel === 0 && ctx.Music.override === ctx.Music.PLAYLIST[0]); +check('manual track playing', ctx.Music.current.name === ctx.Music.PLAYLIST[0]); +tap('n'); // N hotkey during play +check('N cycles forward', ctx.G.musicSel === 1 && ctx.Music.current.name === ctx.Music.PLAYLIST[1]); +ctx.nextFloor(); step(5, 16); +check('override survives floor change', ctx.Music.current.name === ctx.Music.PLAYLIST[1]); +const jbr = Object.values(ctx.G.rooms).find(r => r.type === 'boss'); +ctx.enterRoom(jbr.gx, jbr.gy); step(5, 16); +check('boss track interrupts override', ctx.Music.current.name.includes('Boss')); +ctx.Music.cycle(1); // cycling during a boss must not cut the boss track +check('boss track not interrupted', ctx.Music.current.name.includes('Boss')); +for (const e of [...ctx.G.enemies]) ctx.damageEnemy(e, 99999, 0, false); +step(10, 16); +check('returns to override after boss', ctx.Music.current.name === ctx.Music.PLAYLIST[2]); +while (ctx.G.musicSel !== -1) ctx.Music.cycle(1); +check('wrapped back to auto', ctx.Music.override === null); +step(5, 16); +check('auto plays a playlist track', ctx.Music.PLAYLIST.includes(ctx.Music.current.name)); + +step(120, 16); // idle soak + +console.log(failures === 0 ? '\nALL CHECKS PASSED' : '\n' + failures + ' CHECKS FAILED'); +process.exit(failures === 0 ? 0 : 1); diff --git a/tools/draw_sprites.py b/tools/draw_sprites.py new file mode 100644 index 0000000..a8dab18 --- /dev/null +++ b/tools/draw_sprites.py @@ -0,0 +1,1473 @@ +#!/usr/bin/env python3 +""" +MeatSlicer hand-crafted pixel-art sprite renderer. + +Draws every game asset with Pillow at native pixel resolution (no +anti-aliasing, chunky 1px detail) in a consistent grimy meat-and-bone +palette. Overwrites assets/.png. + +Usage: python tools/draw_sprites.py [substr ...] # optional name filters +""" +import math +import random +import sys +from pathlib import Path + +from PIL import Image, ImageDraw, ImageEnhance, ImageFilter + +ROOT = Path(__file__).resolve().parent.parent +OUT = ROOT / "assets" + +# ---- palette ---- +OUTL = (16, 6, 9, 255) # near-black maroon outline +MEAT_D = (111, 20, 34, 255) +MEAT = (157, 31, 48, 255) +MEAT_L = (201, 66, 67, 255) +BLOOD_D = (84, 7, 18, 255) +BLOOD = (174, 15, 38, 255) +GORE = (239, 35, 67, 255) +BONE = (244, 232, 211, 255) +BONE_D = (190, 174, 145, 255) +GOLD = (218, 176, 54, 255) +STEEL = (201, 210, 218, 255) +STEEL_D = (99, 111, 123, 255) +SKIN = (221, 181, 143, 255) +GREEN = (123, 211, 55, 255) +GREEN_D = (67, 143, 31, 255) +PURPLE = (143, 82, 176, 255) +PURPLE_D = (82, 44, 111, 255) +TEAL = (66, 237, 208, 255) +BLUE = (66, 155, 239, 255) +YELLOW = (255, 228, 91, 255) +WHITE = (255, 247, 233, 255) +BLACK = (4, 1, 3, 255) +SHAMB = (116, 157, 60, 255) +SHAMB_D = (67, 98, 37, 255) +BROWN = (153, 108, 59, 255) +BROWN_D = (94, 62, 35, 255) + + +def canvas(size): + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + return img, ImageDraw.Draw(img) + + +def E(d, box, c): d.ellipse(box, fill=c) +def R(d, box, c): d.rectangle(box, fill=c) +def P(d, pts, c): d.polygon(pts, fill=c) +def L(d, pts, c, w=1): d.line(pts, fill=c, width=w) + + +def oc(d, cx, cy, r, fill, ol=OUTL): + """outlined circle""" + E(d, [cx - r - 1, cy - r - 1, cx + r + 1, cy + r + 1], ol) + E(d, [cx - r, cy - r, cx + r, cy + r], fill) + + +def speckle(d, size, n, cols, seed, box=None): + rng = random.Random(seed) + x0, y0, x1, y1 = box or (0, 0, size - 1, size - 1) + for _ in range(n): + x = rng.randint(x0, x1) + y = rng.randint(y0, y1) + d.point((x, y), fill=rng.choice(cols)) + + +def finish_sprite(img, name): + """Apply one shared finishing pass so the entire set reads as one atlas.""" + alpha = img.getchannel("A") + rgb = ImageEnhance.Color(img.convert("RGB")).enhance(1.10) + rgb = ImageEnhance.Contrast(rgb).enhance(1.06) + graded = rgb.convert("RGBA") + graded.putalpha(alpha) + + # Transparent gameplay sprites get a consistent one-pixel wine-black rim. + # Tiles and decals intentionally remain seamless/soft at their edges. + if not name.startswith(("tile_", "decal_")): + expanded = alpha.filter(ImageFilter.MaxFilter(3)) + rim = Image.new("RGBA", img.size, OUTL) + rim.putalpha(expanded) + rim.alpha_composite(graded) + graded = rim + + # Production art is authored on a small logical grid, then finished on a + # 2x canvas. The second-resolution texture pixels and highlights retain a + # crisp 32-bit-era silhouette while avoiding the old 16/32px placeholder + # look at gameplay scale. + graded = graded.resize((graded.width * 2, graded.height * 2), Image.Resampling.NEAREST) + + # Passive items are presented as filthy relic medallions rather than + # ungrounded clip-art silhouettes. The object remains unique; the shared + # iron/bone mount makes the complete inventory read as one designed set. + if name.startswith("i_"): + plate = Image.new("RGBA", graded.size, (0, 0, 0, 0)) + pd = ImageDraw.Draw(plate) + w, h = graded.size + rim = [(w * 0.50, 1), (w * 0.80, h * 0.10), (w - 2, h * 0.38), + (w * 0.91, h * 0.76), (w * 0.62, h - 2), (w * 0.25, h * 0.91), + (2, h * 0.63), (w * 0.08, h * 0.24)] + rim = [(round(x), round(y)) for x, y in rim] + pd.polygon(rim, fill=(14, 5, 9, 248), outline=(104, 55, 45, 255), width=3) + pd.ellipse([7, 7, w - 8, h - 8], fill=(31, 11, 19, 235), outline=(150, 92, 57, 255), width=2) + pd.arc([11, 11, w - 12, h - 12], 205, 330, fill=(225, 186, 113, 210), width=2) + for rx, ry in ((9, 16), (w - 11, 17), (13, h - 13), (w - 14, h - 12)): + pd.ellipse([rx - 2, ry - 2, rx + 2, ry + 2], fill=(83, 91, 91, 255), outline=OUTL, width=1) + plate.alpha_composite(graded) + graded = plate + + px = graded.load() + rng = random.Random(sum(ord(c) for c in name) * 97) + area = graded.width * graded.height + is_icon = name.startswith(("w_", "wt_", "i_")) + is_tile = name.startswith(("tile_", "door_", "pedestal", "stairs")) + grit = area // (82 if is_tile else (62 if is_icon else 150)) + for _ in range(max(10, grit)): + x = rng.randrange(1, graded.width - 1) + y = rng.randrange(1, graded.height - 1) + r, g, b, a = px[x, y] + if a > 220 and (r + g + b) > 90: + lift = rng.choice((-28, -20, -14, -9, 10, 17)) + px[x, y] = (max(0, min(255, r + lift)), max(0, min(255, g + lift)), + max(0, min(255, b + lift)), a) + if rng.random() < 0.22 and x + 1 < graded.width: + px[x + 1, y] = px[x, y] + + # Masked scratches, dried blood, oxidisation and greasy edge wear. These + # are painted at the final 64/128px resolution so they are true detail, + # not enlarged low-resolution noise. + scratch_count = 9 if is_tile else (10 if is_icon else 5) + for _ in range(scratch_count): + x = rng.randrange(3, graded.width - 3) + y = rng.randrange(3, graded.height - 3) + length = rng.randrange(3, max(5, min(graded.width // 6, 14))) + dx, dy = rng.choice(((1, 0), (1, 1), (1, -1), (0, 1))) + stain = rng.choice(((48, 18, 20), (72, 25, 22), (92, 12, 22), (112, 76, 48))) + for n in range(length): + sx, sy = x + dx * n, y + dy * n + if not (0 <= sx < graded.width and 0 <= sy < graded.height): + break + r, g, b, a = px[sx, sy] + if a > 180: + mix = 0.34 if is_tile else 0.26 + px[sx, sy] = (round(r * (1 - mix) + stain[0] * mix), + round(g * (1 - mix) + stain[1] * mix), + round(b * (1 - mix) + stain[2] * mix), a) + + # Selective chipped highlights make steel, bone, and wet tissue catch the + # light without smoothing the deliberately hard pixel edges. + for _ in range(max(5, area // 550)): + x = rng.randrange(1, graded.width - 1) + y = rng.randrange(1, graded.height - 1) + r, g, b, a = px[x, y] + if a > 220 and r + g + b > 300 and rng.random() < 0.7: + px[x, y] = (min(255, r + 34), min(255, g + 30), min(255, b + 24), a) + + # Weapon families get larger chips, rivets and congealed blood marks. Side + # profiles stay readable in inventory; top-down versions stay distinct in + # the butcher's hands. + if name.startswith(("w_", "wt_")): + d = ImageDraw.Draw(graded) + rng = random.Random(sum(ord(c) for c in name) * 193) + for _ in range(5 if name.startswith("w_") else 9): + x, y = rng.randrange(4, graded.width - 4), rng.randrange(4, graded.height - 4) + if graded.getpixel((x, y))[3] > 180: + col = rng.choice((BLOOD, BLOOD_D, (81, 88, 91, 255), (219, 176, 79, 255))) + d.rectangle([x, y, x + rng.randrange(1, 4), y + rng.randrange(1, 3)], fill=col) + return graded + + +def save(img, name): + finish_sprite(img, name).save(OUT / f"{name}.png", optimize=True) + + +# ===================================================================== +# characters / enemies / bosses (all face RIGHT, sprites rotate in game) +# ===================================================================== + +def a_player(): + img, d = canvas(64) + # Ridiculously broad butcher silhouette: tiny head, barrel chest, huge arms. + oc(d, 28, 34, 19, MEAT_D) + E(d, [10, 20, 46, 48], (126, 25, 38, 255)) # chest mass + oc(d, 17, 25, 10, SKIN) # rear shoulder + oc(d, 39, 27, 11, SKIN) # weapon shoulder + E(d, [10, 24, 23, 40], (225, 180, 140, 255)) # rear bicep + E(d, [34, 22, 50, 40], (230, 184, 142, 255)) # front bicep + # muscle shadows, veins, and absurd definition + L(d, [(13, 30), (18, 26), (21, 31)], (157, 79, 67, 255), 2) + L(d, [(37, 32), (42, 26), (47, 31)], (157, 79, 67, 255), 2) + L(d, [(42, 22), (45, 29), (49, 34)], BLOOD, 1) + d.point([(16, 23), (20, 37), (39, 24), (46, 35)], fill=WHITE) + # blood-stained apron stretched across the torso + P(d, [(18, 24), (38, 24), (42, 53), (15, 53)], OUTL) + P(d, [(19, 25), (37, 25), (40, 51), (17, 51)], (184, 133, 76, 255)) + P(d, [(20, 27), (35, 27), (37, 49), (18, 49)], (207, 154, 91, 255)) + L(d, [(17, 25), (39, 25)], (104, 70, 44, 255), 3) + d.point([(24, 33), (31, 39), (21, 44), (34, 47)], fill=BLOOD) + # deliberately undersized scowling head and chef crown + oc(d, 39, 15, 7, SKIN) + R(d, [33, 6, 47, 13], OUTL); R(d, [34, 7, 46, 12], WHITE) + E(d, [34, 3, 46, 10], WHITE) + d.point([(42, 14), (43, 14)], fill=BLACK) + L(d, [(39, 19), (44, 18)], MEAT_D, 2) + # thick weapon forearm and clenched fist + R(d, [43, 30, 58, 38], OUTL); R(d, [44, 31, 56, 37], SKIN) + oc(d, 58, 34, 4, SKIN) + L(d, [(48, 31), (51, 37)], (157, 79, 67, 255), 1) + return img + + +def _player_legs_frame(index=0, frame_size=96): + """Eight-frame, distance-driven butcher stride authored facing right. + + Uses the generated single-leg production sprite when present, so the walk + cycle has the same detail level as the AI-authored torso/weapon art. The + procedural blocky fallback below only exists for source-only rebuilds. + Runtime rendering rotates this forward-facing frame smoothly. + """ + leg_path = OUT / "player_leg.png" + if leg_path.exists(): + source = Image.open(leg_path).convert("RGBA") + box = source.getbbox() + if box: + source = source.crop(box) + # Legs are deliberately tucked under the waist: only boots/lower shins + # should peek out from beneath the torso during the stride. + scale = min(44 / source.width, 25 / source.height) + source = source.resize((max(1, round(source.width * scale)), max(1, round(source.height * scale))), Image.Resampling.NEAREST) + + waist = None + waist_path = OUT / "player_waist.png" + if waist_path.exists(): + waist = Image.open(waist_path).convert("RGBA") + waist_box = waist.getbbox() + if waist_box: + waist = waist.crop(waist_box) + # Keep the attachment socket hidden under the freely aimed torso; + # a larger waist reads as a second body when aim and travel diverge. + waist_scale = min(38 / waist.width, 38 / waist.height) + waist = waist.resize((max(1, round(waist.width * waist_scale)), max(1, round(waist.height * waist_scale))), Image.Resampling.NEAREST) + + phase = index / 8 * math.tau + contact = math.cos(phase) # frames 0/4 are full-contact extremes + pass_k = math.sin(phase) + frame = Image.new("RGBA", (frame_size, frame_size), (0, 0, 0, 0)) + cx, cy = frame_size // 2, frame_size // 2 + 3 + + def paste_leg(extension, side, lift, mirrored=False): + # Anchor both thighs beneath the waist and vary only their extension. + # Translating the whole source would expose its cropped thigh behind + # the hips; scaling keeps both boots unmistakably pointed forward. + img = source.transpose(Image.Transpose.FLIP_TOP_BOTTOM) if mirrored else source + lift_scale = 1 + lift * 0.08 + img = img.resize((round(extension * lift_scale), round(img.height * lift_scale)), Image.Resampling.NEAREST) + x = round(cx - 8) + y = round(cy + side * 11 - img.height / 2) + frame.alpha_composite(img, (x, y)) + + # Feet trade long/short extensions through contact and pass poses. + # Mirroring one copy makes the generated art read as a left/right pair. + left_extension = 34 + contact * 9 + right_extension = 34 - contact * 9 + paste_leg(right_extension, 1, max(0, -pass_k), True) + paste_leg(left_extension, -1, max(0, pass_k)) + if waist is not None: + frame.alpha_composite(waist, (round(cx - waist.width / 2), round(cy - waist.height / 2))) + else: + # Source-only fallback: cover the thigh joints with a compact hip block. + d = ImageDraw.Draw(frame) + d.polygon([(cx - 15, cy - 13), (cx + 12, cy - 12), (cx + 16, cy + 11), (cx - 17, cy + 12)], fill=OUTL) + d.polygon([(cx - 13, cy - 11), (cx + 10, cy - 10), (cx + 13, cy + 9), (cx - 14, cy + 10)], fill=(116, 76, 45, 255)) + d.line([(cx - 11, cy - 9), (cx + 8, cy - 9)], fill=(201, 151, 88, 255), width=2) + return frame + + logical = 48 + img, d = canvas(logical) + phase = index / 8 * math.tau + # Contact frames 0/4 plant opposite boots at full extension; the audio + # clock also fires at those half-cycle boundaries. + stride = math.cos(phase) * 10.0 + lift_l = max(0.0, math.sin(phase)) + lift_r = max(0.0, -math.sin(phase)) + sway = math.cos(phase) * 0.8 + bob = -round(abs(math.sin(phase)) * 1.5) + + # Hip/apron hem hides the leg joints beneath the independently aimed torso. + P(d, [(15, 17 + bob), (32, 17 + bob), (34, 29 + bob), (14, 29 + bob)], OUTL) + P(d, [(17, 18 + bob), (30, 18 + bob), (32, 27 + bob), (16, 27 + bob)], (116, 76, 45, 255)) + L(d, [(18, 19 + bob), (29, 19 + bob)], (201, 151, 88, 255), 2) + + def leg(y, travel, lift, rear=False): + x = 24 + travel + yy = y + sway * (-1 if rear else 1) + P(d, [(18, yy - 4), (28, yy - 4), (31 + travel * .35, yy + 3), + (19 + travel * .35, yy + 4)], OUTL) + P(d, [(19, yy - 3), (27, yy - 3), (29 + travel * .35, yy + 2), + (20 + travel * .35, yy + 3)], (73, 46, 37, 255)) + # Lifted feet grow slightly in top-down perspective. + bw = 8 + round(lift * 1.5) + bh = 6 + round(lift) + bx = x + 4 + by = yy + E(d, [bx - bw, by - bh / 2, bx + bw, by + bh / 2], OUTL) + E(d, [bx - bw + 1, by - bh / 2 + 1, bx + bw - 1, by + bh / 2 - 1], (44, 39, 37, 255)) + R(d, [bx + bw - 4, by - bh / 2 + 1, bx + bw - 1, by + bh / 2 - 1], STEEL_D) + if lift > .25: + L(d, [(bx - 2, by - 2), (bx + 4, by - 2)], (151, 137, 112, 255), 1) + + # Rear foot first so crossing strides overlap naturally. + leg(31, -stride, lift_r, True) + leg(17, stride, lift_l, False) + speckle(d, logical, 8, (BLOOD_D, BROWN_D), 700 + index, (12, 11, 44, 38)) + + frame = finish_sprite(img, "player_legs") + if frame.size != (frame_size, frame_size): + frame = frame.resize((frame_size, frame_size), Image.Resampling.NEAREST) + return frame + + +def a_player_legs(): + """Static planted stance used when the compact animation sheet is missing.""" + frame = _player_legs_frame(0, 96) + return frame.resize((48, 48), Image.Resampling.NEAREST) + + +def a_player_torso(wid): + """Procedural fallback and composition guide for a weapon-specific torso.""" + img, _d = canvas(64) + body = a_player().resize((48, 48), Image.Resampling.NEAREST) + weapon = a_weapon_top(wid).resize((44, 44), Image.Resampling.NEAREST) + img.alpha_composite(body, (0, 8)) + img.alpha_composite(weapon, (24, 10)) + d = ImageDraw.Draw(img) + # Repaint two fists over the rear grip so the guide unmistakably reads held. + oc(d, 33, 28, 3, SKIN) + oc(d, 38, 35, 3, SKIN) + L(d, [(29, 27), (35, 29)], (157, 79, 67, 255), 2) + L(d, [(31, 35), (40, 34)], (157, 79, 67, 255), 2) + return img + + +def a_shambler(): + img, d = canvas(40) + # reaching arms + R(d, [24, 12, 33, 16], SHAMB_D) + R(d, [24, 25, 33, 29], SHAMB_D) + d.point([(32, 13), (33, 15), (32, 27)], fill=SHAMB) + oc(d, 18, 20, 10, SHAMB) + E(d, [10, 12, 22, 28], SHAMB_D) # belly shade + # face + d.point([(22, 16), (22, 22)], fill=BLACK) # eyes + R(d, [20, 25, 25, 27], BLOOD_D) # mouth + d.point([(21, 25), (23, 25)], fill=BONE) # teeth + speckle(d, 40, 10, [BLOOD, SHAMB_D], 7, box=(10, 12, 26, 28)) + return img + + +def a_runner(): + img, d = canvas(40) + # limbs sprinting right + P(d, [(22, 12), (36, 8), (34, 13), (24, 16)], MEAT_L) + P(d, [(22, 26), (36, 30), (34, 34), (24, 30)], MEAT_L) + P(d, [(8, 14), (2, 10), (4, 16)], MEAT_D) + P(d, [(8, 26), (2, 30), (4, 24)], MEAT_D) + oc(d, 17, 20, 8, (163, 62, 46, 255)) + E(d, [20, 15, 29, 25], MEAT_L) # snout + d.point([(25, 17), (26, 17)], fill=YELLOW) # eye + d.point([(27, 22), (28, 22), (26, 23)], fill=BONE) # teeth + return img + + +def a_spitter(): + img, d = canvas(40) + oc(d, 18, 20, 10, PURPLE) + E(d, [9, 11, 20, 29], PURPLE_D) + # big drooling maw + oc(d, 22, 20, 6, (30, 10, 20, 255)) + for i, (tx, ty) in enumerate([(19, 15), (22, 14), (25, 15), (19, 25), (22, 26), (25, 25)]): + d.point([(tx, ty)], fill=BONE) + d.point([(24, 28), (25, 30), (23, 31)], fill=GREEN) # drool + d.point([(14, 13), (15, 13)], fill=YELLOW) # eye + speckle(d, 40, 8, [PURPLE_D, GREEN_D], 11, box=(10, 10, 26, 30)) + return img + + +def a_splitter(): + img, d = canvas(44) + oc(d, 20, 22, 12, BROWN) + # surface growths + oc(d, 13, 15, 4, BROWN_D) + oc(d, 26, 13, 5, BROWN_D) + oc(d, 30, 25, 4, BROWN_D) + oc(d, 15, 29, 5, BROWN_D) + d.point([(26, 12), (15, 14), (30, 24)], fill=MEAT_L) + d.point([(24, 20), (24, 24)], fill=BLACK) # eyes + R(d, [22, 27, 27, 29], BLOOD_D) + return img + + +def a_mini(): + img, d = canvas(24) + E(d, [4, 8, 20, 17], OUTL) + E(d, [5, 9, 19, 16], (122, 90, 48, 255)) + E(d, [13, 9, 19, 16], BROWN) # head + d.point([(15, 15), (17, 15), (16, 15)], fill=BONE) # teeth + d.point([(17, 11)], fill=BLACK) # eye + R(d, [7, 16, 9, 18], SHAMB_D) # tail nub + return img + + +def a_exploder(): + img, d = canvas(40) + oc(d, 19, 21, 10, (201, 59, 59, 255)) + E(d, [11, 13, 22, 29], MEAT_L) + # glowing veins + L(d, [(14, 16), (19, 20), (16, 25)], YELLOW) + L(d, [(24, 14), (21, 20), (26, 26)], YELLOW) + d.point([(19, 20), (17, 18), (23, 23)], fill=WHITE) # hot spots + d.point([(23, 17), (23, 18)], fill=BLACK) # eye + return img + + +def _teeth_ring(d, cx, cy, r_out, r_in, n, c): + for i in range(n): + a0 = (i / n) * math.tau if hasattr(math, "tau") else (i / n) * 2 * math.pi + a1 = ((i + 0.5) / n) * 2 * math.pi + a2 = ((i + 1) / n) * 2 * math.pi + p1 = (cx + math.cos(a0) * r_in, cy + math.sin(a0) * r_in) + pm = (cx + math.cos(a1) * r_out, cy + math.sin(a1) * r_out) + p2 = (cx + math.cos(a2) * r_in, cy + math.sin(a2) * r_in) + P(d, [p1, pm, p2], c) + + +def a_boss_bonesaw(): + img, d = canvas(96) + cx = cy = 48 + _teeth_ring(d, cx, cy, 45, 36, 18, STEEL_D) + oc(d, cx, cy, 37, STEEL_D) + E(d, [cx - 36, cy - 36, cx + 36, cy + 36], STEEL) + E(d, [cx - 26, cy - 26, cx + 26, cy + 26], BONE_D) + # bone segments + for i in range(8): + a = i * math.pi / 4 + x0 = cx + math.cos(a) * 8 + y0 = cy + math.sin(a) * 8 + x1 = cx + math.cos(a) * 24 + y1 = cy + math.sin(a) * 24 + L(d, [(x0, y0), (x1, y1)], BONE, 4) + oc(d, cx, cy, 9, MEAT_D) # hub + E(d, [cx - 5, cy - 5, cx + 5, cy + 5], (40, 8, 12, 255)) + d.point([(cx + 2, cy - 2), (cx + 3, cy - 2)], fill=GORE) # eye glint + speckle(d, 96, 26, [BLOOD_D, BLOOD], 3, box=(cx - 30, cy - 30, cx + 30, cy + 30)) + return img + + +def a_boss_gorecrown(): + img, d = canvas(96) + cx, cy = 48, 52 + # crown of bone shards + for i in range(7): + a = math.pi * (1.15 + 0.7 * i / 6) # top arc + bx = cx + math.cos(a) * 30 + by = cy + math.sin(a) * 30 + tx = cx + math.cos(a) * 44 + ty = cy + math.sin(a) * 44 + P(d, [(bx - 4, by), (bx + 4, by), (tx, ty)], BONE) + # pulsing body (two lobes + taper) + oc(d, cx - 12, cy - 8, 18, MEAT) + oc(d, cx + 12, cy - 8, 18, MEAT) + P(d, [(cx - 28, cy), (cx + 28, cy), (cx, cy + 34)], MEAT) + P(d, [(cx - 24, cy + 4), (cx + 24, cy + 4), (cx, cy + 30)], MEAT_D) + # arteries + L(d, [(cx - 16, cy - 14), (cx - 4, cy + 6), (cx - 8, cy + 22)], BLOOD, 3) + L(d, [(cx + 14, cy - 16), (cx + 6, cy + 4), (cx + 10, cy + 24)], BLOOD, 3) + # gold eyes + oc(d, cx - 9, cy - 10, 4, GOLD) + oc(d, cx + 9, cy - 10, 4, GOLD) + d.point([(cx - 8, cy - 10), (cx + 10, cy - 10)], fill=BLACK) + R(d, [cx - 8, cy + 8, cx + 8, cy + 12], BLOOD_D) # maw + for tx in range(cx - 6, cx + 8, 4): + d.point([(tx, cy + 8)], fill=BONE) + return img + + +def a_boss_knifecrawl(): + img, d = canvas(96) + cx = cy = 48 + # 8 knife legs + for i in range(8): + a = i * math.pi / 4 + math.pi / 8 + x0 = cx + math.cos(a) * 14 + y0 = cy + math.sin(a) * 14 + x1 = cx + math.cos(a) * 42 + y1 = cy + math.sin(a) * 42 + L(d, [(x0, y0), (x1, y1)], STEEL_D, 7) + L(d, [(x0, y0), (x1, y1)], STEEL, 4) + tx = cx + math.cos(a) * 46 + ty = cy + math.sin(a) * 46 + d.point([(tx, ty)], fill=WHITE) # blade tip + # blood near base + d.point([(x0 + 1, y0), (x0, y0 + 1)], fill=BLOOD) + # central fused mass + oc(d, cx, cy, 17, (90, 107, 122, 255)) + E(d, [cx - 12, cy - 12, cx + 12, cy + 12], STEEL_D) + # eye cluster + for (ex, ey) in [(-6, -4), (5, -6), (0, 3), (-3, 7), (7, 5)]: + oc(d, cx + ex, cy + ey, 3, (200, 30, 40, 255)) + d.point([(cx + ex, cy + ey)], fill=BLACK) + return img + + +# ===================================================================== +# tiles / doors +# ===================================================================== + +def _floor(base, dark, vein, seed): + img, d = canvas(32) + R(d, [0, 0, 31, 31], base) + speckle(d, 32, 82, [dark, base, vein], seed) + rng = random.Random(seed + 1) + # layered tissue/rust veins with forks and pores + for _ in range(4): + x = rng.randint(2, 28) + y = rng.randint(2, 28) + ex, ey = x + rng.randint(-9, 9), y + rng.randint(-9, 9) + L(d, [(x, y), (ex, ey)], dark, 2) + L(d, [(x, y), (ex, ey)], vein) + if rng.random() < 0.65: + L(d, [((x + ex) // 2, (y + ey) // 2), (ex + rng.randint(-4, 4), ey + rng.randint(-4, 4))], vein) + for _ in range(7): + x, y = rng.randint(2, 29), rng.randint(2, 29) + E(d, [x - 1, y - 1, x + 1, y + 1], dark) + d.point([(x, y)], fill=(22, 8, 12, 255)) + # subtle slab edge and accumulated black grime + L(d, [(0, 0), (31, 0)], tuple(min(255, c + 16) for c in base[:3]) + (255,)) + L(d, [(0, 31), (31, 31)], dark) + L(d, [(31, 0), (31, 31)], dark) + return img + + +def a_floor1(): + img = _floor((34, 17, 21, 255), (47, 22, 28, 255), (89, 29, 40, 255), 10) + d = ImageDraw.Draw(img) + # sutured butcher slab + L(d, [(5, 18), (12, 15), (19, 18), (27, 14)], BLOOD_D, 2) + for x, y in [(8, 17), (14, 16), (21, 17), (25, 15)]: L(d, [(x - 1, y - 2), (x + 1, y + 2)], BONE_D) + return img + + +def a_floor2(): + img = _floor((40, 22, 23, 255), (52, 29, 27, 255), (88, 42, 28, 255), 20) + d = ImageDraw.Draw(img) + # corroded metal patch with sunken rivets + L(d, [(2, 8), (29, 8)], (26, 14, 16, 255), 2) + L(d, [(2, 24), (29, 24)], (72, 34, 29, 255)) + for x, y in [(4, 4), (27, 4), (4, 27), (27, 27)]: + oc(d, x, y, 1, STEEL_D, OUTL) + return img +def a_floor3(): + img = _floor((27, 15, 20, 255), (39, 22, 34, 255), (60, 27, 43, 255), 30) + d = ImageDraw.Draw(img) + # black organ membrane, bone chips, and wet pustules + E(d, [4, 5, 13, 13], (48, 22, 38, 255)); E(d, [20, 18, 29, 27], (54, 23, 41, 255)) + d.point([(7, 22), (24, 9), (19, 27), (8, 8), (25, 22)], fill=BONE_D) + d.point([(9, 7), (23, 20)], fill=MEAT_L) + return img + + +def a_wall(): + img, d = canvas(32) + R(d, [0, 0, 31, 31], (57, 27, 33, 255)) + R(d, [0, 0, 31, 5], (98, 43, 50, 255)) # top highlight + speckle(d, 32, 70, [(43, 19, 25, 255), (80, 35, 43, 255), BLOOD_D], 40) + L(d, [(0, 3), (31, 3)], (107, 74, 58, 255)) # bone ridge + d.point([(6, 2), (17, 2), (26, 2)], fill=BONE_D) + # mortared meat blocks, cracks, hooks and old streaks + L(d, [(0, 14), (31, 14)], (35, 15, 20, 255), 2) + L(d, [(15, 5), (15, 14)], (38, 16, 20, 255)) + L(d, [(7, 16), (9, 23), (5, 29)], (28, 11, 15, 255)) + L(d, [(25, 5), (23, 11), (27, 15)], BLOOD_D, 2) + P(d, [(27, 18), (30, 18), (29, 26), (27, 28)], STEEL_D) + return img + + +def a_door_open(): + img, d = canvas(48) + # black opening + R(d, [8, 10, 39, 37], BLACK) + R(d, [10, 12, 37, 35], (12, 6, 8, 255)) + # bone frame + R(d, [4, 6, 9, 41], OUTL); R(d, [5, 7, 8, 40], BONE) + R(d, [38, 6, 43, 41], OUTL); R(d, [39, 7, 42, 40], BONE) + R(d, [4, 6, 43, 10], OUTL); R(d, [5, 7, 42, 9], BONE_D) + d.point([(7, 14), (7, 26), (40, 20), (40, 32)], fill=BONE_D) + return img + + +def a_door_locked(): + img, d = canvas(48) + R(d, [4, 6, 43, 41], OUTL) + R(d, [5, 7, 42, 40], MEAT_D) # membrane + L(d, [(6, 12), (42, 34)], MEAT, 2) + L(d, [(6, 30), (42, 14)], MEAT, 2) + # interlocking teeth + for x in range(6, 42, 6): + P(d, [(x, 22), (x + 3, 30), (x + 6, 22)], BONE) + P(d, [(x, 24), (x + 3, 16), (x + 6, 24)], BONE_D) + speckle(d, 48, 12, [BLOOD, BLOOD_D], 55, box=(6, 8, 41, 39)) + return img + + +# ===================================================================== +# projectiles +# ===================================================================== + +def a_bullet_bone(): + img, d = canvas(16) + P(d, [(2, 9), (9, 5), (14, 7), (9, 11)], OUTL) + P(d, [(3, 9), (9, 6), (13, 7), (9, 10)], BONE) + d.point([(5, 8), (10, 8)], fill=WHITE) + return img + + +def a_bullet_saw(): + img, d = canvas(24) + _teeth_ring(d, 12, 12, 11, 8, 8, STEEL_D) + oc(d, 12, 12, 8, STEEL) + E(d, [8, 8, 16, 16], STEEL_D) + oc(d, 12, 12, 3, OUTL) + d.point([(10, 10)], fill=WHITE) + return img + + +def a_bullet_cleaver(): + img, d = canvas(20) + R(d, [2, 8, 8, 12], OUTL); R(d, [3, 9, 7, 11], (120, 84, 50, 255)) + P(d, [(8, 5), (18, 5), (18, 15), (8, 15)], OUTL) + P(d, [(9, 6), (17, 6), (17, 14), (9, 14)], STEEL) + L(d, [(9, 13), (17, 13)], WHITE) + d.point([(15, 8)], fill=BLOOD) + return img + + +def a_bullet_harpoon(): + img, d = canvas(28) + L(d, [(2, 14), (20, 14)], OUTL, 3) + L(d, [(2, 14), (20, 14)], (176, 144, 112, 255), 1) + P(d, [(18, 9), (27, 14), (18, 19)], OUTL) + P(d, [(19, 11), (26, 14), (19, 17)], STEEL) + P(d, [(20, 11), (16, 7), (22, 12)], STEEL_D) # barb + d.point([(4, 13), (8, 13)], fill=BLOOD) + return img + + +def a_bullet_eye(): + img, d = canvas(18) + oc(d, 9, 9, 7, WHITE) + oc(d, 10, 9, 4, (192, 32, 32, 255)) + E(d, [8, 7, 12, 11], BLACK) + d.point([(11, 7)], fill=WHITE) + d.point([(4, 5), (3, 12), (5, 14), (14, 4)], fill=(200, 60, 60, 255)) # vessels + return img + + +def a_bullet_syringe(): + img, d = canvas(20) + L(d, [(16, 10), (19, 10)], STEEL) # needle + R(d, [2, 6, 15, 14], OUTL) + R(d, [3, 7, 14, 13], (190, 200, 210, 255)) + R(d, [4, 8, 12, 12], GREEN) # fluid + R(d, [1, 8, 3, 12], STEEL_D) # plunger + d.point([(6, 8)], fill=WHITE) + return img + + +def a_bullet_gore(): + img, d = canvas(18) + oc(d, 9, 10, 6, BLOOD) + E(d, [4, 5, 8, 9], BLOOD) + E(d, [12, 4, 15, 7], BLOOD) + d.point([(7, 8), (11, 12)], fill=GORE) + d.point([(6, 14)], fill=BLOOD_D) + return img + + +# ===================================================================== +# pickups / props +# ===================================================================== + +def _gem(c_light, c_dark, size, r): + img, d = canvas(size) + cx, cy = size // 2, size // 2 + P(d, [(cx, cy - r), (cx + r - 2, cy), (cx, cy + r), (cx - r + 2, cy)], OUTL) + P(d, [(cx, cy - r + 1), (cx + r - 3, cy), (cx, cy + r - 1), (cx - r + 3, cy)], c_dark) + P(d, [(cx, cy - r + 1), (cx + r - 3, cy), (cx, cy)], c_light) + d.point([(cx - 1, cy - 2)], fill=WHITE) + return img + + +def a_gem_small(): return _gem(BLUE, (30, 90, 160, 255), 16, 6) +def a_gem_big(): return _gem(TEAL, (25, 140, 125, 255), 20, 8) + + +def a_heart(): + img, d = canvas(20) + E(d, [2, 3, 10, 12], OUTL); E(d, [9, 3, 17, 12], OUTL) + P(d, [(2, 8), (18, 8), (10, 18)], OUTL) + E(d, [3, 4, 9, 11], GORE); E(d, [10, 4, 16, 11], GORE) + P(d, [(3, 8), (17, 8), (10, 17)], GORE) + R(d, [8, 2, 12, 6], MEAT_D) # arteries + d.point([(6, 6), (7, 6)], fill=WHITE) + return img + + +def a_ammo(): + img, d = canvas(20) + R(d, [2, 8, 17, 17], OUTL) + R(d, [3, 9, 16, 16], (138, 111, 60, 255)) + R(d, [3, 7, 16, 10], (100, 72, 40, 255)) # lid + for i, x in enumerate([5, 9, 13]): + R(d, [x, 10, x + 2, 15], BONE) + d.point([(x, 10)], fill=GOLD) + return img + + +def a_pedestal(): + img, d = canvas(40) + E(d, [8, 4, 32, 14], OUTL); E(d, [9, 5, 31, 13], (122, 106, 114, 255)) + R(d, [12, 12, 28, 30], OUTL); R(d, [13, 12, 27, 30], (90, 74, 82, 255)) + R(d, [7, 28, 33, 35], OUTL); R(d, [8, 29, 32, 34], (122, 106, 114, 255)) + L(d, [(16, 6), (14, 12), (15, 18)], BLOOD) # drip + d.point([(11, 7)], fill=WHITE) + return img + + +def a_stairs(): + img, d = canvas(40) + R(d, [4, 4, 35, 35], OUTL) + R(d, [5, 5, 34, 34], (18, 10, 12, 255)) + for i in range(4): + y = 9 + i * 6 + R(d, [8, y, 32, y + 3], (12 + i * 4, 8 + i * 2, 10 + i * 2, 255)) + L(d, [(8, y), (32, y)], (107, 74, 58, 255)) + return img + + +# ===================================================================== +# blood decals +# ===================================================================== + +def _decal(seed, blobs): + img, d = canvas(48) + rng = random.Random(seed) + for (bx, by, br) in blobs: + E(d, [bx - br, by - br, bx + br, by + br], BLOOD_D) + E(d, [bx - br + 2, by - br + 2, bx + br - 2, by + br - 2], (107, 14, 24, 255)) + for _ in range(14): # droplets + x = rng.randint(6, 42) + y = rng.randint(6, 42) + r = rng.randint(1, 2) + E(d, [x - r, y - r, x + r, y + r], BLOOD_D) + return img + + +def a_decal1(): return _decal(1, [(24, 24, 13)]) +def a_decal2(): return _decal(2, [(16, 24, 8), (28, 22, 9), (36, 26, 6)]) +def a_decal3(): return _decal(3, [(24, 24, 17), (14, 14, 7), (34, 34, 7)]) +def a_decal4(): return _decal(4, [(12, 30, 5), (22, 22, 4), (30, 16, 5), (38, 10, 3)]) + + +# ===================================================================== +# weapon icons (32px) +# ===================================================================== + +def a_w_bonepopper(): + img, d = canvas(32) + R(d, [6, 8, 27, 14], OUTL); R(d, [7, 9, 26, 13], BONE) # barrel/slide + R(d, [27, 10, 30, 13], OUTL); R(d, [28, 11, 29, 12], BLACK) # muzzle + R(d, [6, 14, 12, 27], OUTL); R(d, [7, 15, 11, 26], BONE_D) # rear grip + P(d, [(13, 14), (18, 14), (16, 20)], OUTL) # trigger guard + d.point([(16, 16)], fill=GOLD) # trigger + d.point([(9, 10), (17, 10), (22, 11)], fill=WHITE) # glint + d.point([(8, 18), (8, 22)], fill=BONE) # grip joints + return img + + +def a_w_repeater(): + img, d = canvas(32) + for i in range(4): # ribs + x = 8 + i * 5 + d.arc([x - 4, 4, x + 6, 22], 270, 90, fill=BONE, width=2) + R(d, [4, 13, 28, 17], OUTL); R(d, [5, 14, 27, 16], STEEL_D) + R(d, [24, 11, 30, 14], STEEL) # muzzle + return img + + +def a_w_marrow(): + img, d = canvas(32) + R(d, [4, 9, 27, 13], OUTL); R(d, [5, 10, 26, 12], BONE) + R(d, [4, 14, 27, 18], OUTL); R(d, [5, 15, 26, 17], BONE_D) + R(d, [2, 10, 6, 18], (120, 84, 50, 255)) # stock + d.point([(26, 11), (26, 16)], fill=BLACK) + return img + + +def a_w_cleaver(): + img, d = canvas(32) + R(d, [5, 19, 13, 26], OUTL); R(d, [6, 20, 12, 25], (120, 84, 50, 255)) + P(d, [(10, 4), (27, 4), (27, 20), (10, 20)], OUTL) + P(d, [(11, 5), (26, 5), (26, 19), (11, 19)], STEEL) + L(d, [(11, 18), (26, 18)], WHITE) + d.point([(21, 8), (23, 12), (19, 14)], fill=BLOOD) + return img + + +def a_w_saw(): + img, d = canvas(32) + _teeth_ring(d, 16, 13, 11, 8, 10, STEEL_D) + oc(d, 16, 13, 8, STEEL) + oc(d, 16, 13, 3, OUTL) + R(d, [10, 22, 22, 27], OUTL); R(d, [11, 23, 21, 26], (120, 84, 50, 255)) + return img + + +def a_w_bile(): + img, d = canvas(32) + P(d, [(6, 13), (20, 9), (26, 10), (26, 20), (20, 21), (6, 17)], OUTL) + P(d, [(7, 14), (20, 10), (25, 11), (25, 19), (20, 20), (7, 16)], (176, 144, 90, 255)) + d.point([(23, 21), (24, 23), (22, 24)], fill=GREEN) # drip + d.point([(21, 12)], fill=GREEN_D) + R(d, [3, 13, 7, 18], (120, 84, 50, 255)) + return img + + +def a_w_hemophage(): + img, d = canvas(32) + R(d, [4, 10, 22, 18], OUTL); R(d, [5, 11, 21, 17], (190, 200, 210, 255)) + R(d, [6, 12, 16, 16], GORE) # blood vial + L(d, [(22, 14), (29, 14)], STEEL) + R(d, [3, 18, 8, 23], STEEL_D) + d.point([(9, 12)], fill=WHITE) + return img + + +def a_w_eye(): + img, d = canvas(32) + d.arc([3, 5, 29, 27], 200, 340, fill=(120, 84, 50, 255), width=3) + L(d, [(6, 23), (26, 8)], BONE_D, 1) # string + oc(d, 16, 15, 6, WHITE) + oc(d, 17, 15, 3, (192, 32, 32, 255)) + d.point([(17, 15)], fill=BLACK) + return img + + +def a_w_guthook(): + img, d = canvas(32) + L(d, [(3, 26), (24, 8)], OUTL, 4) + L(d, [(3, 26), (24, 8)], (176, 144, 112, 255), 2) + P(d, [(22, 4), (30, 7), (24, 12)], STEEL) # hook tip + d.arc([22, 8, 30, 16], 90, 300, fill=STEEL_D, width=2) + d.point([(8, 21), (12, 18)], fill=BLOOD) + return img + + +def a_w_cauterizer(): + img, d = canvas(32) + R(d, [4, 14, 14, 24], OUTL); R(d, [5, 15, 13, 23], STEEL_D) # tank + L(d, [(14, 17), (24, 12)], OUTL, 4) + L(d, [(14, 17), (24, 12)], STEEL, 2) + P(d, [(24, 8), (31, 11), (26, 14), (29, 17), (24, 15)], (255, 144, 48, 255)) # flame + d.point([(25, 12)], fill=YELLOW) + return img + + +def a_w_fleshmasher(): + img, d = canvas(32) + R(d, [4, 12, 26, 20], OUTL); R(d, [5, 13, 25, 19], STEEL_D) + E(d, [22, 10, 28, 22], OUTL); E(d, [23, 11, 27, 21], BLACK) # muzzle + oc(d, 12, 8, 5, MEAT_L) # meat ball + d.point([(11, 7), (14, 9)], fill=BLOOD_D) + return img + + +def a_w_trapqueen(): + img, d = canvas(32) + d.arc([5, 5, 27, 27], 180, 360, fill=STEEL_D, width=3) + d.arc([5, 5, 27, 27], 0, 180, fill=STEEL, width=3) + for i in range(6): # teeth + a = math.pi * (i / 5) + x = 16 + math.cos(a) * 11 + y = 16 - math.sin(a) * 11 + d.point([(int(x), int(y))], fill=WHITE) + E(d, [13, 13, 19, 19], OUTL); E(d, [14, 14, 18, 18], GOLD) + d.point([(10, 20), (22, 19)], fill=BLOOD) + return img + + +def a_w_tenderizer(): + img, d = canvas(32) + R(d, [13, 13, 17, 28], OUTL); R(d, [14, 14, 16, 27], (120, 84, 50, 255)) + R(d, [6, 3, 24, 13], OUTL); R(d, [7, 4, 23, 12], STEEL_D) + for x in range(8, 24, 4): + d.point([(x, 3), (x + 1, 3)], fill=STEEL) # spikes + d.point([(x, 12)], fill=STEEL) + d.point([(10, 6), (19, 9)], fill=BLOOD) + return img + + +def a_w_redhand(): + img, d = canvas(32) + R(d, [4, 12, 12, 21], OUTL); R(d, [5, 13, 11, 20], MEAT_D) # body + R(d, [12, 14, 28, 18], OUTL); R(d, [13, 15, 27, 17], STEEL_D) # bar + for x in range(13, 28, 3): + d.point([(x, 14)], fill=STEEL) # chain teeth + R(d, [6, 8, 10, 12], (120, 84, 50, 255)) # handle + d.point([(15, 16), (21, 16), (25, 17)], fill=BLOOD) + return img + + +def a_w_spinaltap(): + img, d = canvas(32) + L(d, [(3, 20), (28, 8)], OUTL, 4) + for i in range(6): # vertebrae + t = i / 5 + x = 5 + t * 22 + y = 19 - t * 11 + E(d, [x - 2, y - 2, x + 2, y + 2], BONE) + L(d, [(3, 20), (28, 8)], (96, 192, 255, 255), 1) # energy + d.point([(27, 7), (28, 9), (26, 6)], fill=(224, 240, 255, 255)) + return img + + +def a_w_swarmjar(): + img, d = canvas(32) + R(d, [8, 8, 24, 27], OUTL) + R(d, [9, 9, 23, 26], (200, 210, 215, 110)) + R(d, [7, 5, 25, 9], OUTL); R(d, [8, 6, 24, 8], STEEL_D) # lid + rng = random.Random(8) + for _ in range(9): # maggots + x = rng.randint(11, 21) + y = rng.randint(14, 24) + E(d, [x - 1, y - 1, x + 2, y + 1], (208, 216, 176, 255)) + d.point([(12, 11)], fill=WHITE) + return img + + +def a_weapon_top(wid): + """Oversized top-down in-hand silhouette, authored separately from HUD art.""" + img, d = canvas(64) + cy = 32 + + def body(x0, y0, x1, y1, fill=STEEL_D): + R(d, [x0 - 2, y0 - 2, x1 + 2, y1 + 2], OUTL) + R(d, [x0, y0, x1, y1], fill) + + # Every weapon has a deliberately exaggerated, readable top silhouette. + if wid == "bonepopper": + body(8, 24, 43, 40, BONE_D); body(38, 27, 59, 37, BONE) + P(d, [(13, 40), (27, 40), (22, 53), (14, 51)], OUTL) + P(d, [(15, 40), (25, 40), (21, 50), (16, 49)], BROWN_D) + L(d, [(42, 27), (57, 27)], WHITE, 2) + elif wid == "repeater": + body(6, 26, 58, 38, STEEL_D) + for x in range(12, 48, 7): + d.arc([x - 5, 18, x + 7, 46], 250, 110, fill=BONE, width=3) + R(d, [52, 28, 63, 36], STEEL); d.point([(61, 30), (61, 34)], fill=BLACK) + elif wid == "marrow": + body(5, 19, 57, 29, BONE); body(5, 35, 57, 45, BONE_D) + R(d, [53, 20, 63, 28], OUTL); R(d, [53, 36, 63, 44], OUTL) + L(d, [(8, 24), (46, 24)], WHITE, 2) + elif wid == "cleaver": + body(3, 28, 20, 36, BROWN_D) + P(d, [(18, 12), (61, 15), (61, 49), (18, 52)], OUTL) + P(d, [(21, 15), (58, 18), (58, 46), (21, 49)], STEEL) + L(d, [(23, 46), (56, 43)], WHITE, 2); d.point([(45, 23), (50, 36)], fill=BLOOD) + elif wid == "saw": + body(5, 26, 35, 38, BROWN_D) + _teeth_ring(d, 47, cy, 17, 13, 14, STEEL_D) + oc(d, 47, cy, 12, STEEL); oc(d, 47, cy, 4, OUTL) + elif wid == "bile": + body(4, 24, 39, 40, (161, 118, 68, 255)) + P(d, [(36, 19), (62, 25), (62, 39), (36, 45)], OUTL) + P(d, [(38, 22), (59, 27), (59, 37), (38, 42)], GREEN_D) + E(d, [45, 26, 56, 38], GREEN); d.point([(57, 39), (60, 43)], fill=GREEN) + elif wid == "hemophage": + body(4, 25, 45, 39, STEEL) + R(d, [12, 28, 39, 36], GORE); L(d, [(44, 32), (63, 32)], WHITE, 2) + R(d, [5, 22, 10, 42], STEEL_D); d.point([(17, 29), (20, 29)], fill=WHITE) + elif wid == "eye": + d.arc([3, 5, 58, 59], 205, 335, fill=BROWN_D, width=7) + L(d, [(9, 48), (56, 15)], BONE, 2) + oc(d, 36, cy, 15, WHITE); oc(d, 40, cy, 8, GORE); oc(d, 42, cy, 3, BLACK) + d.point([(37, 26), (48, 29)], fill=(210, 70, 70, 255)) + elif wid == "guthook": + L(d, [(3, cy), (52, cy)], OUTL, 8); L(d, [(4, cy), (52, cy)], BONE_D, 4) + P(d, [(46, 14), (63, 26), (52, 34), (60, 45), (45, 39), (40, 28)], OUTL) + P(d, [(47, 18), (59, 26), (49, 32), (56, 40), (46, 37), (43, 28)], STEEL) + d.point([(18, 30), (29, 34), (49, 35)], fill=BLOOD) + elif wid == "cauterizer": + body(4, 20, 26, 44, STEEL_D); body(24, 27, 50, 37, STEEL) + P(d, [(47, 20), (63, 27), (56, 32), (63, 38), (48, 44), (53, 32)], OUTL) + P(d, [(50, 23), (61, 28), (54, 32), (61, 37), (50, 41), (55, 32)], (255, 126, 35, 255)) + d.point([(58, 31), (59, 32)], fill=YELLOW) + elif wid == "fleshmasher": + body(3, 19, 48, 45, STEEL_D); oc(d, 53, cy, 12, OUTL); E(d, [48, 25, 62, 39], BLACK) + oc(d, 23, cy, 11, MEAT_L); d.point([(19, 29), (27, 36)], fill=BLOOD_D) + R(d, [8, 23, 12, 41], GOLD) + elif wid == "trapqueen": + body(4, 25, 42, 39, STEEL_D) + d.arc([35, 11, 65, 41], 185, 355, fill=STEEL, width=6) + d.arc([35, 23, 65, 53], 5, 175, fill=STEEL_D, width=6) + for y in (20, 44): + for x in range(42, 62, 6): P(d, [(x, y), (x + 3, cy), (x + 5, y)], BONE) + elif wid == "tenderizer": + body(3, 28, 38, 36, BROWN_D) + R(d, [34, 12, 62, 52], OUTL); R(d, [37, 15, 59, 49], STEEL_D) + for y in range(17, 50, 8): + d.point([(58, y), (59, y), (38, y + 2)], fill=STEEL) + d.point([(46, 21), (52, 39)], fill=BLOOD) + elif wid == "redhand": + body(2, 20, 25, 44, MEAT_D); body(22, 25, 61, 39, STEEL_D) + for x in range(25, 61, 5): + P(d, [(x, 24), (x + 2, 19), (x + 4, 24)], STEEL) + P(d, [(x, 40), (x + 2, 45), (x + 4, 40)], STEEL) + R(d, [7, 13, 18, 22], BROWN_D); d.point([(31, 30), (42, 35), (55, 29)], fill=BLOOD) + elif wid == "spinaltap": + L(d, [(3, cy), (61, cy)], OUTL, 10) + for x in range(7, 58, 8): + oc(d, x, cy, 6, BONE); R(d, [x - 2, 23, x + 2, 41], BONE_D) + L(d, [(5, cy), (62, cy)], (72, 205, 255, 255), 3) + d.point([(61, 29), (62, 32), (61, 35)], fill=WHITE) + elif wid == "swarmjar": + body(2, 26, 24, 38, BROWN_D) + R(d, [20, 13, 57, 51], OUTL); R(d, [23, 16, 54, 48], (175, 190, 180, 210)) + R(d, [53, 19, 62, 45], STEEL_D) + rng = random.Random(81) + for _ in range(15): + x, y = rng.randint(27, 50), rng.randint(20, 44) + E(d, [x - 2, y - 1, x + 3, y + 1], (216, 224, 177, 255)) + d.point([(27, 18), (30, 18)], fill=WHITE) + return img + + +# ===================================================================== +# item icons (32px) +# ===================================================================== + +def a_i_hollowpoints(): + img, d = canvas(32) + P(d, [(16, 4), (21, 12), (21, 26), (11, 26), (11, 12)], OUTL) + P(d, [(16, 5), (20, 12), (20, 25), (12, 25), (12, 12)], BONE) + E(d, [14, 6, 18, 11], OUTL) # hollow tip + d.point([(13, 18)], fill=GOLD) + return img + + +def a_i_twitch(): + img, d = canvas(32) + for i, pts in enumerate([[(6, 22), (14, 14), (22, 18), (26, 10)], + [(8, 26), (16, 18), (24, 22)], + [(10, 12), (18, 8), (24, 12)]]): + L(d, [tuple(p) for p in pts], MEAT_L if i == 0 else MEAT_D, 3) + d.point([(15, 15), (20, 19), (12, 20)], fill=GORE) + d.point([(25, 9)], fill=YELLOW) # spark + return img + + +def a_i_scalpel(): + img, d = canvas(32) + # dark handle + L(d, [(5, 27), (16, 16)], OUTL, 6) + L(d, [(5, 27), (16, 16)], (70, 74, 82, 255), 3) + d.point([(7, 25), (10, 22)], fill=STEEL) # grip ridges + # bright triangular blade + P(d, [(14, 17), (29, 3), (22, 17)], OUTL) + P(d, [(15, 16), (27, 5), (21, 16)], STEEL) + L(d, [(17, 14), (26, 6)], WHITE) + d.point([(12, 19), (14, 21)], fill=BLOOD) + return img + + +def a_i_leadmarrow(): + img, d = canvas(32) + oc(d, 16, 16, 12, BONE) + oc(d, 16, 16, 7, BONE_D) + oc(d, 16, 16, 4, (60, 56, 64, 255)) + d.point([(12, 12), (21, 19)], fill=WHITE) + return img + + +def a_i_piercegaze(): + img, d = canvas(32) + oc(d, 15, 17, 10, WHITE) + oc(d, 16, 17, 5, (60, 120, 200, 255)) + E(d, [14, 15, 18, 19], BLACK) + L(d, [(8, 4), (26, 28)], STEEL_D, 2) # needle + P(d, [(25, 27), (28, 30), (24, 29)], STEEL) + d.point([(6, 3)], fill=WHITE) + return img + + +def a_i_ricochet(): + img, d = canvas(32) + L(d, [(6, 8), (16, 16), (8, 26)], BONE, 4) # bent ribs + L(d, [(20, 6), (14, 16), (24, 24)], BONE_D, 3) + oc(d, 24, 12, 3, GOLD) # bullet + L(d, [(24, 9), (24, 4)], WHITE, 1) + return img + + +def a_i_splittongue(): + img, d = canvas(32) + P(d, [(8, 28), (16, 14), (12, 6), (18, 10), (24, 4), (22, 14), (18, 28)], OUTL) + P(d, [(10, 26), (16, 15), (14, 8), (18, 12), (22, 7), (20, 15), (17, 26)], GORE) + L(d, [(15, 22), (17, 15)], MEAT_D) + return img + + +def a_i_hydramaw(): + img, d = canvas(32) + oc(d, 16, 18, 12, (40, 10, 16, 255)) + for a in range(0, 360, 30): + r1 = math.radians(a) + x = 16 + math.cos(r1) * 11 + y = 18 + math.sin(r1) * 11 + d.point([(int(x), int(y))], fill=BONE) + for a in range(15, 360, 45): + r1 = math.radians(a) + x = 16 + math.cos(r1) * 6 + y = 18 + math.sin(r1) * 6 + d.point([(int(x), int(y))], fill=BONE_D) + E(d, [13, 15, 19, 21], BLOOD_D) + return img + + +def a_i_homingtumor(): + img, d = canvas(32) + oc(d, 15, 17, 11, PURPLE) + oc(d, 8, 10, 4, PURPLE_D) + oc(d, 24, 9, 3, PURPLE_D) + oc(d, 17, 17, 6, WHITE) # eye + oc(d, 18, 17, 3, (200, 40, 40, 255)) + d.point([(18, 17)], fill=BLACK) + return img + + +def a_i_orbitalknives(): + img, d = canvas(32) + d.arc([5, 5, 27, 27], 0, 360, fill=(90, 70, 80, 255), width=1) + for i in range(3): + a = i * 2 * math.pi / 3 + x = 16 + math.cos(a) * 11 + y = 16 + math.sin(a) * 11 + P(d, [(x - 2, y + 2), (x + 3, y - 4), (x + 2, y + 3)], STEEL) + d.point([(int(x), int(y))], fill=OUTL) + E(d, [14, 14, 18, 18], GOLD) + return img + + +def a_i_dentures(): + img, d = canvas(32) + d.arc([6, 6, 26, 22], 0, 180, fill=(220, 150, 160, 255), width=4) + for x in range(9, 24, 4): + R(d, [x, 15, x + 2, 20], BONE) + P(d, [(10, 15), (13, 15), (11, 25)], BONE) # fangs + P(d, [(19, 15), (22, 15), (21, 25)], BONE) + d.point([(11, 24), (21, 24)], fill=BLOOD) + return img + + +def a_i_volatilebile(): + img, d = canvas(32) + oc(d, 16, 18, 11, GREEN_D) + E(d, [8, 10, 20, 26], GREEN) + d.point([(12, 13), (19, 15)], fill=(220, 255, 150, 255)) + oc(d, 22, 8, 3, YELLOW) # bubble + oc(d, 9, 6, 2, YELLOW) + return img + + +def a_i_backstabber(): + img, d = canvas(32) + oc(d, 12, 18, 9, (40, 30, 44, 255)) # shadow back + P(d, [(20, 6), (26, 8), (17, 20), (14, 17)], OUTL) + P(d, [(21, 7), (24, 8), (16, 18), (15, 17)], STEEL) + R(d, [12, 17, 17, 22], (120, 84, 50, 255)) + d.point([(10, 14), (13, 11)], fill=BLOOD) + return img + + +def a_i_ironstomach(): + img, d = canvas(32) + oc(d, 16, 17, 11, STEEL_D) + E(d, [8, 9, 24, 25], STEEL) + d.arc([8, 9, 24, 25], 0, 360, fill=OUTL, width=1) + for a in range(0, 360, 60): + r1 = math.radians(a) + d.point([(int(16 + math.cos(r1) * 9), int(17 + math.sin(r1) * 9))], fill=OUTL) # rivets + R(d, [14, 3, 18, 8], OUTL); R(d, [15, 4, 17, 7], STEEL_D) # pipe + return img + + +def a_i_luckycoin(): + img, d = canvas(32) + oc(d, 16, 16, 11, GOLD) + E(d, [8, 8, 24, 24], (230, 195, 80, 255)) + # skull + E(d, [12, 10, 20, 18], OUTL) + d.point([(14, 13), (18, 13)], fill=(230, 195, 80, 255)) + R(d, [14, 18, 18, 21], OUTL) + d.point([(10, 9), (22, 21), (20, 8)], fill=BLOOD) + return img + + +def a_i_magnetmaw(): + img, d = canvas(32) + d.arc([6, 6, 26, 26], 180, 360, fill=GORE, width=7) + R(d, [5, 15, 12, 25], GORE); R(d, [20, 15, 27, 25], GORE) + R(d, [5, 22, 12, 26], BONE); R(d, [20, 22, 27, 26], BONE) # teeth tips + d.point([(10, 8), (14, 6), (20, 9)], fill=YELLOW) # sparks + return img + + +def a_i_bloodlust(): + img, d = canvas(32) + P(d, [(16, 4), (26, 18), (16, 29), (6, 18)], OUTL) + P(d, [(16, 5), (25, 18), (16, 28), (7, 18)], GORE) + d.point([(12, 15), (20, 15)], fill=BLACK) # screaming eyes + E(d, [13, 20, 19, 25], BLACK) # mouth + d.point([(11, 9)], fill=WHITE) + return img + + +def a_i_splinterbone(): + img, d = canvas(32) + # main bone shard with a crack + P(d, [(5, 20), (18, 8), (22, 12), (12, 26)], OUTL) + P(d, [(6, 20), (17, 10), (20, 12), (12, 24)], BONE) + L(d, [(10, 18), (15, 16), (14, 21)], BONE_D) + # splinters flying off + P(d, [(21, 6), (27, 3), (24, 9)], BONE) + P(d, [(24, 13), (30, 13), (26, 17)], BONE_D) + P(d, [(19, 22), (24, 25), (18, 26)], BONE) + d.point([(26, 5), (27, 14), (21, 24)], fill=WHITE) + return img + + +def a_i_ghoulheart(): + img, d = canvas(32) + E(d, [6, 7, 15, 16], OUTL); E(d, [16, 7, 25, 16], OUTL) + P(d, [(6, 12), (26, 12), (16, 28)], OUTL) + E(d, [7, 8, 14, 15], (140, 140, 130, 255)) + E(d, [17, 8, 24, 15], (140, 140, 130, 255)) + P(d, [(7, 12), (25, 12), (16, 27)], (140, 140, 130, 255)) + L(d, [(11, 10), (16, 14), (13, 18), (18, 22)], (90, 90, 85, 255), 1) # stitches + d.point([(16, 14), (13, 18)], fill=OUTL) + return img + + +# ===================================================================== + +ASSETS = { + "player": a_player, + "player_legs": a_player_legs, + "enemy_shambler": a_shambler, "enemy_runner": a_runner, "enemy_spitter": a_spitter, + "enemy_splitter": a_splitter, "enemy_mini": a_mini, "enemy_exploder": a_exploder, + "boss_bonesaw": a_boss_bonesaw, "boss_gorecrown": a_boss_gorecrown, "boss_knifecrawl": a_boss_knifecrawl, + "tile_floor1": a_floor1, "tile_floor2": a_floor2, "tile_floor3": a_floor3, "tile_wall": a_wall, + "door_open": a_door_open, "door_locked": a_door_locked, + "bullet_bone": a_bullet_bone, "bullet_saw": a_bullet_saw, "bullet_cleaver": a_bullet_cleaver, + "bullet_harpoon": a_bullet_harpoon, "bullet_eye": a_bullet_eye, + "bullet_syringe": a_bullet_syringe, "bullet_gore": a_bullet_gore, + "gem_small": a_gem_small, "gem_big": a_gem_big, "heart": a_heart, "ammo": a_ammo, + "pedestal": a_pedestal, "stairs": a_stairs, + "decal_blood1": a_decal1, "decal_blood2": a_decal2, "decal_blood3": a_decal3, "decal_blood4": a_decal4, + "w_bonepopper": a_w_bonepopper, "w_repeater": a_w_repeater, "w_marrow": a_w_marrow, + "w_cleaver": a_w_cleaver, "w_saw": a_w_saw, "w_bile": a_w_bile, "w_hemophage": a_w_hemophage, + "w_eye": a_w_eye, "w_guthook": a_w_guthook, "w_cauterizer": a_w_cauterizer, + "w_fleshmasher": a_w_fleshmasher, "w_trapqueen": a_w_trapqueen, "w_tenderizer": a_w_tenderizer, + "w_redhand": a_w_redhand, "w_spinaltap": a_w_spinaltap, "w_swarmjar": a_w_swarmjar, + "i_hollowpoints": a_i_hollowpoints, "i_twitch": a_i_twitch, "i_scalpel": a_i_scalpel, + "i_leadmarrow": a_i_leadmarrow, "i_piercegaze": a_i_piercegaze, "i_ricochet": a_i_ricochet, + "i_splittongue": a_i_splittongue, "i_hydramaw": a_i_hydramaw, "i_homingtumor": a_i_homingtumor, + "i_orbitalknives": a_i_orbitalknives, "i_dentures": a_i_dentures, "i_volatilebile": a_i_volatilebile, + "i_backstabber": a_i_backstabber, "i_splinterbone": a_i_splinterbone, + "i_ironstomach": a_i_ironstomach, "i_luckycoin": a_i_luckycoin, + "i_magnetmaw": a_i_magnetmaw, "i_bloodlust": a_i_bloodlust, "i_ghoulheart": a_i_ghoulheart, +} + +WEAPON_IDS = [ + "bonepopper", "repeater", "marrow", "cleaver", "saw", "bile", "hemophage", "eye", + "guthook", "cauterizer", "fleshmasher", "trapqueen", "tenderizer", "redhand", "spinaltap", "swarmjar", +] +for _weapon_id in WEAPON_IDS: + ASSETS["wt_" + _weapon_id] = lambda wid=_weapon_id: a_weapon_top(wid) + ASSETS["pt_" + _weapon_id] = lambda wid=_weapon_id: a_player_torso(wid) + + +# ===================================================================== +# 64px / 128px eight-direction actor animation atlases +# ===================================================================== + +ANIM_ACTIONS = { + "idle": (4, 5), + "move": (8, 12), + "attack": (6, 15), + "hit": (3, 18), + "death": (8, 12), +} + +ACTORS = [ + "player", + "enemy_shambler", "enemy_runner", "enemy_spitter", "enemy_splitter", "enemy_mini", "enemy_exploder", + "boss_bonesaw", "boss_gorecrown", "boss_knifecrawl", + "boss_vealmother", "boss_flenser", "boss_hookchoir", + "boss_platefather", "boss_augerprime", "boss_scald", +] + + +def _fit_actor(img, name, frame_size): + box = img.getbbox() + img = img.crop(box) if box else img + if name == "enemy_mini": + target = 28 + elif name.startswith("boss_"): + target = 120 + elif name == "player": + target = 88 + else: + target = 58 + target = min(target, frame_size - 8) + scale = min(target / img.width, target / img.height) + return img.resize((max(1, round(img.width * scale)), max(1, round(img.height * scale))), Image.Resampling.NEAREST) + + +def _tint(img, color, amount): + tint = Image.new("RGBA", img.size, color) + tint.putalpha(img.getchannel("A")) + return Image.blend(img, tint, amount) + + +def _actor_frame(base, name, action, index, count, direction, frame_size): + phase = index / count * math.tau + work = base.copy() + xscale = yscale = 1.0 + xoff = yoff = angle = 0 + + if action == "idle": + yscale = 1 + math.sin(phase) * 0.035 + xscale = 1 - math.sin(phase) * 0.018 + yoff = round(-abs(math.sin(phase)) * 1.5) + elif action == "move": + stride = math.sin(phase) + xscale = 1 + abs(stride) * 0.06 + yscale = 1 - abs(stride) * 0.045 + yoff = round(-abs(stride) * 3) + angle = stride * 4.5 + xoff = round(stride * 1.5) + elif action == "attack": + thrust = math.sin(min(index / max(count - 1, 1), 1) * math.pi) + xscale = 1 + thrust * 0.10 + yscale = 1 - thrust * 0.04 + xoff = round(thrust * 5) + angle = -thrust * 5 + elif action == "hit": + xoff = (-2, 3, -1)[index % 3] + angle = (-7, 5, -2)[index % 3] + work = _tint(work, (255, 236, 218, 255), 0.58 - index * 0.16) + elif action == "death": + fall = index / max(count - 1, 1) + angle = fall * 82 + xoff = round(fall * 8) + yoff = round(fall * 13) + yscale = max(0.28, 1 - fall * 0.68) + work.putalpha(work.getchannel("A").point(lambda a: round(a * (1 - fall * 0.62)))) + + w = max(1, round(work.width * xscale)) + h = max(1, round(work.height * yscale)) + work = work.resize((w, h), Image.Resampling.NEAREST) + if angle: + work = work.rotate(angle, Image.Resampling.NEAREST, expand=True) + + frame = Image.new("RGBA", (frame_size, frame_size), (0, 0, 0, 0)) + frame.alpha_composite(work, ((frame_size - work.width) // 2 + xoff, (frame_size - work.height) // 2 + yoff)) + + # Small frame-authored accents keep attacks and species readable in motion. + d = ImageDraw.Draw(frame) + cx = frame_size // 2 + cy = frame_size // 2 + if action == "attack" and index in (2, 3): + accent = GORE if "spitter" not in name else GREEN + d.line([(cx + frame_size // 5, cy - 3), (cx + frame_size // 2 - 3, cy)], fill=accent, width=max(1, frame_size // 32)) + if name == "enemy_exploder" and action != "death" and index % 2: + d.ellipse([cx - 3, cy - 3, cx + 3, cy + 3], fill=YELLOW) + if name == "boss_gorecrown" and index % 2: + d.point([(cx - 12, cy - 10), (cx + 12, cy - 10)], fill=WHITE) + + # The canonical actor faces right. Bake all eight compass directions into + # the atlas so runtime rendering never rotates/softens character pixels. + degrees = direction * 45 + if degrees: + frame = frame.rotate(-degrees, Image.Resampling.NEAREST, expand=False) + return frame + + +def render_actor_sheet(name, use_existing=False): + frame_size = 128 if name.startswith("boss_") else (96 if name == "player" else 64) + source_path = OUT / f"{name}.png" + if use_existing and source_path.exists(): + source = Image.open(source_path).convert("RGBA") + else: + source = finish_sprite(ASSETS[name](), name) + source = _fit_actor(source, name, frame_size) + columns = 8 + cells = sum(count * 8 for count, _fps in ANIM_ACTIONS.values()) + rows = math.ceil(cells / columns) + sheet = Image.new("RGBA", (columns * frame_size, rows * frame_size), (0, 0, 0, 0)) + cell = 0 + for action, (count, _fps) in ANIM_ACTIONS.items(): + for direction in range(8): + for index in range(count): + frame = _actor_frame(source, name, action, index, count, direction, frame_size) + sheet.alpha_composite(frame, ((cell % columns) * frame_size, (cell // columns) * frame_size)) + cell += 1 + sheet.save(OUT / f"{name}_sheet.png", optimize=True) + return sheet + + +def render_legs_sheet(): + """Render one forward-facing stride row; runtime rotates it smoothly.""" + frame_size = 96 + sheet = Image.new("RGBA", (frame_size * 8, frame_size), (0, 0, 0, 0)) + planted = None + for index in range(8): + frame = _player_legs_frame(index, frame_size) + if index == 0: + planted = frame.copy() + sheet.alpha_composite(frame, (index * frame_size, 0)) + sheet.save(OUT / "player_legs_sheet.png", optimize=True) + if planted is not None: + planted.save(OUT / "player_legs.png", optimize=True) + return sheet + + +def main(): + filters = [a for a in sys.argv[1:] if not a.startswith("--")] + OUT.mkdir(exist_ok=True) + names = [n for n in ASSETS if not filters or any(f in n for f in filters)] + for name in names: + img = ASSETS[name]() + save(img, name) + print(f"drew assets/{name}.png ({img.width}x{img.height})") + actor_names = [n for n in ACTORS if not filters or any(f in n for f in filters)] + 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): + sheet = render_legs_sheet() + print(f"drew assets/player_legs_sheet.png ({sheet.width}x{sheet.height})") + print(f"\n{len(names)} sprites and {len(actor_names)} animation sheets rendered.") + + +if __name__ == "__main__": + main() diff --git a/tools/gen_assets.py b/tools/gen_assets.py new file mode 100644 index 0000000..8e23f8d --- /dev/null +++ b/tools/gen_assets.py @@ -0,0 +1,575 @@ +#!/usr/bin/env python3 +"""Generate MeatSlicer assets through OpenRouter's GPT Image 2 endpoint. + +The attached ``example_image_style.jfif`` is sent with every request as a +rendering-style reference. GPT Image 2 currently emits opaque images, so +isolated sprites are requested on #FF00FF and border-flood keyed to alpha. + +Usage: + python tools/gen_assets.py player tile_floor1 w_redhand i_scalpel --force + python tools/gen_assets.py --force # full production pass + python tools/gen_assets.py tile # all matching names + python tools/gen_assets.py --list + +Authentication: ``OPENROUTER_API_KEY`` in the process environment or in the +user-only ``~/.config/MeatSlicer/.env`` file, which remains outside the served +game directory. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import subprocess +import sys +import time +import urllib.error +import urllib.request +from collections import deque +from dataclasses import dataclass +from io import BytesIO +from pathlib import Path + +from PIL import Image + + +ROOT = Path(__file__).resolve().parent.parent +TOOLS = ROOT / "tools" +ASSETS = ROOT / "assets" +RAW = ASSETS / "raw" +REFERENCE = RAW / "guides" / "style_reference.png" +MANIFEST = RAW / "openrouter_manifest.json" +USER_ENV = Path.home() / ".config" / "MeatSlicer" / ".env" + +ENDPOINT = "https://openrouter.ai/api/v1/images" +MODEL = "openai/gpt-image-2" +PROVIDER = "openai" + + +STYLE = ( + "Use the attached image strictly as the rendering-style guide. Create authentic " + "hand-crafted late-1990s 32-bit pixel art for a top-down industrial horror game: " + "crisp square pixel clusters, no vector shapes, no smooth digital painting, no 3D " + "render, no text. Use strong top-left directional lighting, dark crevices instead " + "of uniform black outlines, chunky bevels, selective specular highlights, and large " + "deliberate grime features rather than random noise. Palette: desaturated concrete, " + "gunmetal, oxidized teal copper, rust brown and dirty bone; reserve saturated blood " + "red, toxic green and spark gold for accents. Industrial abattoir mood. " +) +SPRITE_BG = ( + "Single isolated asset, centered, fully visible, no cropping, no cast shadow, no " + "ground plane, on one perfectly flat solid #FF00FF magenta background covering every " + "background pixel. No border, frame, label, letters, numbers, UI, or extra objects. " +) +TILE_RULES = ( + "One seamless square top-down game tile, full bleed edge-to-edge. No outer border, " + "no text, no surrounding sheet, no perspective plane. Use only a few large readable " + "features so it remains clear when repeated across a room. Opposite edges must tile. " +) + + +@dataclass(frozen=True) +class Spec: + prompt: str + size: int + kind: str = "sprite" # sprite | tile | decal + + +def _weapon_prompts() -> dict[str, str]: + return { + "bonepopper": "a crude oversized pistol assembled from carved femur, iron bands and bone teeth", + "repeater": "a ridiculous rapid-fire gun built from a ribcage, brass feed mechanism and many barrels", + "marrow": "a brutal double-barrel bone shotgun with exposed red marrow and a rusted stock", + "cleaver": "an enormous heavy butcher cleaver with chipped steel, wood handle and old blood", + "saw": "a grotesque sawblade launcher with a loaded serrated disc, oily motor and sparks", + "bile": "a brass blunderbuss with a swollen toxic-green bile chamber and corroded nozzle", + "hemophage": "a surgical syringe rifle with glass blood vials, needles and steel tubing", + "eye": "a biomechanical ballista built around a huge bloodshot tracking eyeball", + "guthook": "a massive rusted harpoon cannon ending in a barbed slaughterhouse meat hook", + "cauterizer": "an absurd industrial flamethrower with gas tank and scorched medical cautery nozzle", + "fleshmasher": "a heavy grenade cannon loaded with compacted meat, steel grinder drum and crank", + "trapqueen": "a bear-trap launcher with huge interlocking bloody teeth and spring mechanisms", + "tenderizer": "a comically enormous spiked meat-tenderizer war hammer with hydraulic fittings", + "redhand": "a giant filthy chainsaw with a red motor housing, long toothed bar and blood spray", + "spinaltap": "a massive energy railgun built around a glowing human spine and electrical coils", + "swarmjar": "a grotesque jar launcher with a huge glass maggot tank, steel cap and feeding tubes", + } + + +WEAPONS = _weapon_prompts() + +SPECS: dict[str, Spec] = { + # Actors: true top-down, facing right. Animation sheets are derived afterward. + "player": Spec( + "a highly stylised, ridiculously muscular hulking butcher hero seen from directly above, " + "huge shoulders and arms, tiny bald head under a white butcher cap, dark leather harness, " + "short blood-stained apron that does not hide the body silhouette, empty clenched hands ready " + "to hold a separate weapon, facing right; iconic readable protagonist silhouette", 96), + "player_leg": Spec( + "one single hulking butcher leg matching the attached player hero, seen in strict orthographic " + "plan view from directly above, extending horizontally from a cropped upper-thigh joint at the " + "left to a heavy black industrial leather boot with a steel toe at the right. Thick brown " + "blood-stained work trousers, grime, creases and worn straps, facing RIGHT. One leg only, no " + "second leg, no torso, no loose body parts; a straight readable silhouette designed to be " + "mirrored for the opposite leg and animated around a hip pivot", 64), + "player_waist": Spec( + "the attached hulking butcher hero's isolated lower-torso attachment piece in strict " + "orthographic plan view directly from above, unmistakably facing RIGHT: a broad compact waist " + "and hip mass wrapped in a thick dark leather butcher belt, stained short apron hem trailing " + "toward the RIGHT, buckles and straps. The left-right body axis must match the attached player " + "whose face and hands point right. In the exact center is a deep near-black circular recessed " + "socket where the missing upper torso attaches. Waist and hips only: no upper torso, chest, " + "head, arms, legs or boots. Compact and readable as the base the torso sits upon", 64), + "enemy_shambler": Spec("stitched cadaver worker made of pale grey flesh, butcher hooks in its back, reaching arms, facing right, directly top-down", 64), + "enemy_runner": Spec("lean feral abattoir ghoul sprinting on four long limbs, exposed spine and jaw, facing right, directly top-down", 64), + "enemy_spitter": Spec("bloated infected slaughterhouse mutant with huge toxic bile maw, swollen sacs and drool, facing right, directly top-down", 64), + "enemy_splitter": Spec("round tumour beast made of fused meat nodules and industrial staples, splitting seams, facing right, directly top-down", 64), + "enemy_mini": Spec("small vicious meat grub with tiny hooked legs and an oversized ring of teeth, facing right, directly top-down", 32), + "enemy_exploder": Spec("swollen explosive cyst mutant with taut pale skin, glowing red-yellow cracks and embedded blasting cap, facing right, directly top-down", 64), + "boss_bonesaw": Spec("colossal radial bone-saw machine monster, multiple concentric serrated rusted discs, bone hub, one hateful red lens, directly top-down", 128), + "boss_gorecrown": Spec("colossal pulsing heart monarch fused to an iron crown of bone spikes and chains, radial silhouette, directly top-down", 128), + "boss_knifecrawl": Spec("colossal spider horror made from fused butcher knives, scalpels and pale flesh, radial blade legs, directly top-down", 128), + "boss_vealmother": Spec("colossal bloated brood sac of translucent veal flesh, ring of pulsing birth orifices, tangled umbilical roots, radial silhouette, directly top-down", 128), + "boss_flenser": Spec("colossal flayed acrobat horror, radial crown of cleaver flails on flensed sinew, no skin, directly top-down", 128), + "boss_hookchoir": Spec("colossal ceiling-rail carousel hub of rusted meat hooks on taut chains, radial spokes, directly top-down", 128), + "boss_platefather": Spec("colossal armored slaughter engine plated in four riveted iron shields over raw muscle, radial silhouette, directly top-down", 128), + "boss_augerprime": Spec("colossal industrial auger drill monster, radial spiral boring arms, oxidized copper gearbox hub, directly top-down", 128), + "boss_scald": Spec("colossal rendering vat boiler creature, ring of brass scald vents venting steam, tallow crust, radial silhouette, directly top-down", 128), + + # Environment — large low-frequency features, not wallpaper noise. + "tile_floor1": Spec("heavy square cracked concrete abattoir slab, broad bevel, one dark fracture and faint old blood stain", 64, "tile"), + "tile_floor2": Spec("large riveted gunmetal floor plate, six chunky rivets, scratched steel and restrained rust", 64, "tile"), + "tile_floor3": Spec("large oxidized copper floor plate with broad teal patina streak and worn rust edge", 64, "tile"), + "tile_floor4": Spec("broken concrete floor slab with one large impact crater and radiating cracks", 64, "tile"), + "tile_floor5": Spec("industrial steel drainage grate set into dirty concrete, deep black slots and rust", 64, "tile"), + "tile_floor6": Spec("plain worn abattoir ceramic floor slab, cold grey-green glaze, chipped corners and sparse blood", 64, "tile"), + "tile_floor7": Spec("steel floor hatch with recessed panel, bolts and a faded yellow-black hazard stripe", 64, "tile"), + "tile_floor8": Spec("concrete slab invaded by one cluster of dark fleshy veins and bone growth", 64, "tile"), + "tile_wall": Spec("thick industrial abattoir wall panel, dark gunmetal, rusted beams, pipes and top bevel", 64, "tile"), + "tile_wall2": Spec("reinforced concrete wall panel with steel edge columns, cracks and old blood run", 64, "tile"), + "tile_wall3": Spec("oxidized copper wall panel with teal patina, rivets and a recessed conduit", 64, "tile"), + "tile_wall4": Spec("slaughterhouse wall panel with steel braces, hanging hook and dirty bone deposits", 64, "tile"), + "door_open": Spec("wide open industrial blast doorway viewed top-down, thick riveted steel frame, pitch black opening, blood-worn threshold", 96), + "door_locked": Spec("wide sealed industrial blast doorway viewed top-down, interlocking steel jaws, red warning lamp, chains and dried blood", 96), + + # Projectiles and pickups. + "bullet_bone": Spec("sharp dirty bone shard projectile pointing right", 16), + "bullet_saw": Spec("small spinning industrial circular saw projectile with bright steel teeth", 24), + "bullet_cleaver": Spec("small chipped bloodied meat cleaver projectile pointing right", 24), + "bullet_harpoon": Spec("long barbed rusted slaughterhouse harpoon pointing right", 32), + "bullet_eye": Spec("small bloodshot tracking eyeball with torn optic nerve", 24), + "bullet_syringe": Spec("small glass syringe projectile filled with toxic green fluid pointing right", 24), + "bullet_gore": Spec("small wet glob of dark crimson gore", 24), + "bullet_steam": Spec("small pale white curling steam cloud projectile, translucent hot vapor with a golden edge, no container, no metal", 24), + "gem_small": Spec("small bright cyan-blue data crystal shard with hard specular edge", 24), + "gem_big": Spec("large bright toxic-teal data crystal cluster with hard specular edges", 32), + "heart": Spec("glossy anatomical heart pickup with cut arteries and metal staples", 32), + "ammo": Spec("small open rusted ammunition box holding bone-white rounds", 32), + "pedestal": Spec("short industrial steel reward pedestal with hydraulic column, top-down, blood and warning stripe", 64), + "stairs": Spec("square industrial stairwell descending into blackness, concrete lip, steel steps, top-down", 64), + "decal_blood1": Spec("irregular dark blood pool and droplets viewed directly above", 64, "decal"), + "decal_blood2": Spec("long dragged blood smear with finger trails viewed directly above", 64, "decal"), + "decal_blood3": Spec("wide glossy blood splatter with radial spray viewed directly above", 64, "decal"), + "decal_blood4": Spec("sparse arc of blood droplets and one bone chip viewed directly above", 64, "decal"), + + # Level-up draft icons. + "perk_adrenal": Spec("swollen crimson adrenal gland fused to a steel injector, tense veins and one electric spark", 64), + "perk_sharpen": Spec("chipped butcher cleaver blade dragged across a blood-stained industrial whetstone with one gold spark", 64), + "perk_quick": Spec("severed muscular butcher foot and ankle with leather straps and stretched sprinting tendons", 64), + "perk_longbone": Spec("unnaturally elongated sharpened femur reinforced with two rusted steel extension collars", 64), + "perk_bigheart": Spec("absurdly oversized glossy anatomical heart reinforced with steel staples and thick arteries", 64), + "perk_magnetb": Spec("corroded horseshoe electromagnet pulling three toxic-green bile droplets toward its poles", 64), + "perk_shieldheart": Spec("cyan anatomical heart protected behind a compact riveted butcher-steel shield", 64), + "perk_scavenge": Spec("snarling pale rat skull with a scrap-metal pack clutching one cartridge and one blue crystal", 64), + "perk_bloodrush": Spec("pressurized crimson artery loop bursting through a cracked steel valve with a blue crystal in the flow", 64), + "perk_deadeye": Spec("huge bloodshot tracking eyeball pierced in perfect alignment by a fast bone projectile", 64), + "perk_critbone": Spec("a split skull target struck exactly through its glowing weak point by a bone round", 64), + "perk_critmeat": Spec("a frozen anatomical brain held in a precise surgical caliper", 64), + "perk_flensing": Spec("a curved flensing knife peeling one clean strip from bloody hide", 64), + "perk_ember": Spec("a charred butcher hand clutching a bright industrial ember", 64), + "perk_frostbile": Spec("a toxic bile sac crusted with cyan frost and steel staples", 64), + "perk_heavyhand": Spec("an enormous clenched butcher fist reinforced with iron knuckles", 64), + "perk_thickhide": Spec("a layered square of scarred hide reinforced with rivets", 64), + "perk_secondwind": Spec("a pair of punctured lungs inflating around a steel air valve", 64), + "perk_scrapfeed": Spec("a compact ammunition feeder chewing rusted scrap into cartridges", 64), + "perk_boneknit": Spec("two broken bones knitting together with red sinew and staples", 64), + "perk_spiteflesh": Spec("a bleeding slab of flesh bristling with outward-pointing bone spikes", 64), + "perk_carrion": Spec("a hooked vulture skull sniffing a trail of blue crystals", 64), + "perk_sinew": Spec("a woven braid of taut red tendon reinforced by tiny steel rings", 64), +} + +for wid, subject in WEAPONS.items(): + SPECS["w_" + wid] = Spec(subject + ", clean readable side-profile inventory view, pointing right", 32) + SPECS["wt_" + wid] = Spec( + subject + ", strict orthographic plan view from a camera at the zenith exactly 90 degrees " + "above the weapon, lying flat and pointing right; show the top surfaces and bilateral width, " + "not a side elevation, not a profile, no visible vertical side face; exaggerated broad readable " + "top-down silhouette for a twin-stick shooter", 64) + SPECS["pt_" + wid] = Spec( + "the same hulking butcher hero's upper body seen directly from above: enormous shoulders, " + "both muscular arms in an alert combat-ready firing stance, tiny head in a white butcher cap, " + "blood-stained apron straps, gripping " + subject + " with both hands, weapon pointing right. " + "Preserve the attached weapon reference's exact weapon silhouette, materials and colors. Turn " + "the butcher's face unmistakably to the RIGHT along the weapon: visible brow, nose, eye line " + "and chin all aim toward its barrel or blade. Brace or shoulder the weapon where appropriate, " + "with bent elbows, trigger hand on the rear grip and support hand under the foregrip. This is a " + "ready-to-fire pose, not a neutral display pose. Upper body only: no legs, no hips, no feet. " + "Center the shoulders while the weapon extends right", 128) + +ITEMS = { + "hollowpoints": "a carved bone hollow-point round with metal jacket", + "twitch": "a bundle of twitching exposed muscle fibres wired to electrodes", + "scalpel": "a chipped surgical scalpel with wrapped handle and dried blood", + "leadmarrow": "a sawn bone cross-section packed with dark metallic marrow", + "piercegaze": "a bloodshot eye pierced by a long surgical needle", + "ricochet": "a bent rib deflecting a bright bullet with one spark", + "splittongue": "a severed forked tongue with staples and blood", + "hydramaw": "a round fleshy maw with three concentric rows of teeth", + "homingtumor": "an irregular purple tumour with one tracking eye and wires", + "orbitalknives": "three small butcher knives orbiting a bloody steel hub", + "dentures": "rusted surgical dentures with long bloody fangs", + "volatilebile": "a swollen toxic-green bile sac in a steel restraint cage", + "backstabber": "a long knife embedded in a shadowed butcher back silhouette", + "splinterbone": "a cracked bone exploding into three sharp splinters", + "ironstomach": "an anatomical stomach replaced with riveted iron plating and pipes", + "luckycoin": "a tarnished brass coin stamped with a skull and blood smear", + "magnetmaw": "a corroded horseshoe magnet whose ends are toothed jaws", + "bloodlust": "a crimson blood drop containing a small screaming face", + "ghoulheart": "a grey undead heart repaired with stitches, staples and wire", + "chainsinew": "a loop of red sinew carrying a bright electric arc between steel hooks", + "mortarbone": "a hollow femur packed as a tiny explosive mortar shell", + "bloatrounds": "three grotesquely swollen bone bullets pressing against steel bands", + "marrowglut": "an overfilled bowl of dark metallic marrow with a cartridge submerged in it", + "hollowneedle": "an impossibly fine hollow surgical needle with a jeweled crimson tip", + "bloodshoteye": "a huge bloodshot eyeball with a brass crosshair bolted around its iris", + "flayerkiss": "two crossed skinning blades wrapped in a strip of fresh hide", + "emberjar": "a cracked specimen jar holding a living orange coal and black smoke", + "acidgland": "a luminous green acid gland restrained by a corroded steel cage", + "hookrounds": "a cluster of small barbed hook-shaped cartridges joined by wire", + "sledgerounds": "one massive blunt iron cartridge with a dented hammer face", + "graftedtrigger": "a gun trigger grafted into a twitching severed finger", + "deadmanswitch": "a red industrial dead-man switch gripped by a skeletal hand", + "orbitcrown": "a rusted crown whose points are tiny orbiting butcher knives", + "tannedhide": "a folded sheet of thick scarred leather hide with riveted corners", + "deadmansclock": "a cracked brass stopwatch containing a tiny skull", + "hollowbones": "a lightweight birdlike hollow bone cut open to reveal air chambers", + "boneplate": "a broad overlapping plate made from polished rib bones and iron rivets", + "wormgut": "a pale medicinal worm coiled inside a stitched length of intestine", + "spinecage": "a compact rib cage turned outward into defensive bloody spikes", + "secondstomach": "two anatomical stomachs joined by surgical tubes and clamps", + "spitewell": "a deep iron cup overflowing upward with angry crimson blood", + "twinhearts": "two small anatomical hearts stitched together side by side", + "brassmagazine": "an oversized brass ammunition magazine packed with bone rounds", + "crowbait": "a black feather bundle tied around a shining skull coin", + "gorgingleech": "a fat crimson leech engorged around a glowing blue crystal", + "rerollrib": "a curved rib carved with dice pips and a circular arrow", +} +for iid, subject in ITEMS.items(): + SPECS["i_" + iid] = Spec(subject + ", isolated inventory object, strong top-left light, no medallion, no icon background", 32) + + +# Reference-derived industrial ramps. All generated images are mapped onto this +# palette so independent API calls remain visually coherent. +PALETTE = [ + (5, 7, 8), (10, 13, 14), (16, 20, 20), (23, 28, 28), (31, 37, 36), + (42, 47, 44), (55, 59, 54), (72, 73, 65), (92, 88, 76), (119, 109, 91), + (151, 137, 112), (187, 172, 142), (222, 209, 176), + (18, 28, 28), (25, 43, 41), (36, 60, 55), (48, 82, 73), (65, 104, 89), + (82, 124, 105), (105, 145, 121), + (35, 20, 17), (55, 29, 22), (77, 39, 27), (105, 52, 32), (136, 70, 39), + (164, 91, 53), (190, 120, 72), + (53, 10, 15), (82, 11, 19), (117, 15, 24), (155, 20, 31), (199, 31, 42), + (231, 55, 58), (250, 99, 76), + (47, 31, 31), (72, 46, 43), (103, 66, 57), (138, 92, 75), (181, 130, 104), + (219, 176, 140), (239, 213, 180), + (44, 67, 28), (66, 99, 37), (91, 137, 47), (126, 181, 58), (168, 220, 76), + (178, 126, 34), (222, 173, 44), (251, 221, 101), + (29, 70, 86), (40, 107, 126), (56, 151, 166), (92, 202, 202), +] + + +def load_key() -> str: + key = os.environ.get("OPENROUTER_API_KEY") + if key: + return key + if USER_ENV.exists(): + for line in USER_ENV.read_text(encoding="utf-8").splitlines(): + if line.startswith("OPENROUTER_API_KEY="): + return line.split("=", 1)[1].strip() + raise SystemExit(f"No OPENROUTER_API_KEY found in the environment or {USER_ENV}") + + +def data_url(path: Path) -> str: + mime = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg", ".jfif"} else "image/png" + return f"data:{mime};base64," + base64.b64encode(path.read_bytes()).decode("ascii") + + +def shape_guide(name: str) -> Path | None: + if name in {"player_leg", "player_waist"}: + player = ASSETS / "player.png" + return player if player.exists() else None + if not name.startswith(("wt_", "pt_")): + return None + # Existing production torsos are the strongest possible edit reference: + # preserve their weapon while refining head direction and firing posture. + current = ASSETS / f"{name}.png" + if name.startswith("pt_") and current.exists(): + return current + guide_dir = RAW / "guides" + guide_dir.mkdir(parents=True, exist_ok=True) + path = guide_dir / f"{name}.png" + if not path.exists(): + from draw_sprites import a_player_torso, a_weapon_top + source = a_player_torso(name[3:]) if name.startswith("pt_") else a_weapon_top(name[3:]) + image = source.resize((512, 512), Image.Resampling.NEAREST) + image.save(path, optimize=True) + return path + + +def reference_payload(name: str) -> list[dict]: + if not REFERENCE.exists(): + raise SystemExit(f"Missing style reference: {REFERENCE}") + references = [{"type": "image_url", "image_url": {"url": data_url(REFERENCE)}}] + guide = shape_guide(name) + if guide: + references.append({"type": "image_url", "image_url": {"url": data_url(guide)}}) + return references + + +def request_image(name: str, prompt: str, key: str, quality: str, attempts: int = 5) -> tuple[bytes, dict]: + payload = { + "model": MODEL, + "prompt": prompt, + "quality": quality, + "aspect_ratio": "1:1", + "output_format": "png", + "background": "opaque", + "n": 1, + "input_references": reference_payload(name), + "provider": {"only": [PROVIDER], "allow_fallbacks": False}, + } + encoded = json.dumps(payload).encode("utf-8") + for attempt in range(attempts): + request = urllib.request.Request( + ENDPOINT, + data=encoded, + headers={ + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + "HTTP-Referer": "http://localhost:8123", + "X-Title": "MeatSlicer asset pipeline", + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=300) as response: + result = json.loads(response.read()) + image_data = result.get("data") or [] + if image_data and image_data[0].get("b64_json"): + return base64.b64decode(image_data[0]["b64_json"]), result.get("usage") or {} + if attempt == attempts - 1: + raise RuntimeError("OpenRouter returned no image data") + wait = min(45, 4 * (2**attempt)) + print(f" Empty image response; retrying in {wait}s") + time.sleep(wait) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace")[:500] + if exc.code not in {408, 429, 500, 502, 503, 504, 520} or attempt == attempts - 1: + raise RuntimeError(f"OpenRouter HTTP {exc.code}: {detail}") from exc + wait = min(45, 4 * (2**attempt)) + print(f" HTTP {exc.code}; retrying in {wait}s") + time.sleep(wait) + except (OSError, TimeoutError) as exc: + if attempt == attempts - 1: + raise + wait = min(45, 4 * (2**attempt)) + print(f" {type(exc).__name__}; retrying in {wait}s") + time.sleep(wait) + raise RuntimeError("generation exhausted retries") + + +def is_magenta(pixel: tuple[int, int, int, int]) -> bool: + r, g, b, _a = pixel + return r > 145 and b > 145 and g < 175 and r + b > g * 2.15 + + +def flood_key_magenta(image: Image.Image) -> Image.Image: + image = image.convert("RGBA") + width, height = image.size + pixels = image.load() + seen = bytearray(width * height) + queue: deque[tuple[int, int]] = deque() + for x in range(width): + queue.append((x, 0)); queue.append((x, height - 1)) + for y in range(height): + queue.append((0, y)); queue.append((width - 1, y)) + while queue: + x, y = queue.popleft() + index = y * width + x + if x < 0 or y < 0 or x >= width or y >= height or seen[index]: + continue + seen[index] = 1 + if not is_magenta(pixels[x, y]): + continue + pixels[x, y] = (0, 0, 0, 0) + if x: queue.append((x - 1, y)) + if x + 1 < width: queue.append((x + 1, y)) + if y: queue.append((x, y - 1)) + if y + 1 < height: queue.append((x, y + 1)) + return image + + +def palette_image() -> Image.Image: + palette = [] + for color in PALETTE: + palette.extend(color) + palette.extend([0] * (768 - len(palette))) + image = Image.new("P", (1, 1)) + image.putpalette(palette) + return image + + +def quantize_shared(image: Image.Image) -> Image.Image: + alpha = image.getchannel("A") if image.mode == "RGBA" else None + rgb = image.convert("RGB").quantize(palette=palette_image(), dither=Image.Dither.NONE).convert("RGB") + if alpha is None: + return rgb + output = rgb.convert("RGBA") + output.putalpha(alpha.point(lambda value: 255 if value >= 80 else 0)) + return output + + +def process_sprite(raw: bytes, size: int) -> Image.Image: + image = flood_key_magenta(Image.open(BytesIO(raw))) + box = image.getbbox() + if not box: + raise RuntimeError("magenta key removed the entire image") + image = image.crop(box) + margin = max(1, size // 16) + inner = size - margin * 2 + scale = min(inner / image.width, inner / image.height) + dimensions = (max(1, round(image.width * scale)), max(1, round(image.height * scale))) + image = image.resize(dimensions, Image.Resampling.LANCZOS) + canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + canvas.alpha_composite(image, ((size - dimensions[0]) // 2, (size - dimensions[1]) // 2)) + return quantize_shared(canvas) + + +def process_tile(raw: bytes, size: int) -> Image.Image: + image = Image.open(BytesIO(raw)).convert("RGB") + side = min(image.size) + left = (image.width - side) // 2 + top = (image.height - side) // 2 + image = image.crop((left, top, left + side, top + side)) + image = image.resize((size, size), Image.Resampling.LANCZOS) + return quantize_shared(image) + + +def build_prompt(name: str, spec: Spec) -> str: + if spec.kind == "tile": + return STYLE + TILE_RULES + spec.prompt + if spec.kind == "decal": + return STYLE + SPRITE_BG + spec.prompt + ". Thin irregular edges; no floor tile beneath it." + return STYLE + SPRITE_BG + spec.prompt + "." + + +def read_manifest() -> dict: + if MANIFEST.exists(): + try: + return json.loads(MANIFEST.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + pass + return {"model": MODEL, "reference": REFERENCE.name, "assets": {}} + + +def write_manifest(manifest: dict) -> None: + MANIFEST.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8") + + +def render_sheets(actor_names: list[str]) -> None: + from draw_sprites import render_actor_sheet, render_legs_sheet + for name in actor_names: + if name in SPECS and (ASSETS / f"{name}.png").exists(): + sheet = render_actor_sheet(name, use_existing=True) + print(f" sheet {name}: {sheet.width}x{sheet.height}") + if "player" in actor_names: + sheet = render_legs_sheet() + print(f" sheet player_legs: {sheet.width}x{sheet.height}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("filters", nargs="*", help="asset-name substrings") + parser.add_argument("--force", action="store_true", help="overwrite generated assets") + parser.add_argument("--quality", choices=("low", "medium", "high"), default="medium") + parser.add_argument("--list", action="store_true", help="list asset names and exit") + parser.add_argument("--no-sheets", action="store_true", help="do not rebuild actor sheets") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + names = [name for name in SPECS if not args.filters or any(value in name for value in args.filters)] + if args.list: + print("\n".join(names)) + return + if not names: + raise SystemExit("No asset names matched") + + key = load_key() + ASSETS.mkdir(exist_ok=True) + RAW.mkdir(exist_ok=True) + manifest = read_manifest() + total_cost = 0.0 + generated = [] + failed = [] + + for index, name in enumerate(names, 1): + spec = SPECS[name] + output = ASSETS / f"{name}.png" + if output.exists() and name in manifest.get("assets", {}) and not args.force: + print(f"[{index}/{len(names)}] {name}: already generated") + continue + prompt = build_prompt(name, spec) + print(f"[{index}/{len(names)}] {name} ({spec.size}px {spec.kind})") + try: + raw, usage = request_image(name, prompt, key, args.quality) + raw_path = RAW / f"{name}.png" + raw_path.write_bytes(raw) + image = process_tile(raw, spec.size) if spec.kind == "tile" else process_sprite(raw, spec.size) + image.save(output, optimize=True) + cost = float(usage.get("cost") or 0) + total_cost += cost + manifest["assets"][name] = { + "prompt": prompt, + "quality": args.quality, + "size": spec.size, + "kind": spec.kind, + "cost": cost, + "generated_at": int(time.time()), + } + write_manifest(manifest) + generated.append(name) + print(f" -> assets/{name}.png ({image.width}x{image.height}) cost=${cost:.4f}") + except Exception as exc: # keep a long batch moving and report all failures + failed.append(name) + print(f" FAILED: {exc}") + + if not args.no_sheets: + actor_names = [name for name in names if name == "player" or name.startswith(("enemy_", "boss_"))] + render_sheets(actor_names) + if ("player_leg" in names or "player_waist" in names) and "player" not in actor_names: + from draw_sprites import render_legs_sheet + sheet = render_legs_sheet() + print(f" sheet player_legs: {sheet.width}x{sheet.height}") + + print(f"\nGenerated {len(generated)}, failed {len(failed)}, reported cost ${total_cost:.4f}") + if failed: + print("Failed:", ", ".join(failed)) + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/tools/serve_game.py b/tools/serve_game.py new file mode 100644 index 0000000..d758a02 --- /dev/null +++ b/tools/serve_game.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Serve the browser game without exposing tools, secrets, or raw assets.""" + +from __future__ import annotations + +import argparse +import posixpath +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import unquote, urlsplit + + +ROOT = Path(__file__).resolve().parent.parent +PUBLIC_FILES = {"/", "/index.html"} +PUBLIC_PREFIXES = ("/assets/", "/css/", "/js/", "/mp3-music/", "/test/") +PRIVATE_PREFIXES = ("/assets/raw", "/tools") + + +class GameRequestHandler(SimpleHTTPRequestHandler): + def is_public(self) -> bool: + raw_path = unquote(urlsplit(self.path).path) + if "\\" in raw_path: + return False + path = posixpath.normpath(raw_path) + segments = [segment for segment in path.split("/") if segment] + if any(segment.startswith(".") for segment in segments): + return False + if path in PUBLIC_FILES: + return True + if path.startswith(PRIVATE_PREFIXES): + return False + return path.startswith(PUBLIC_PREFIXES) + + def list_directory(self, path: str): + self.send_error(404) + return None + + def do_GET(self) -> None: + if not self.is_public(): + self.send_error(404) + return + super().do_GET() + + def do_HEAD(self) -> None: + if not self.is_public(): + self.send_error(404) + return + super().do_HEAD() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--bind", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8123) + args = parser.parse_args() + handler = partial(GameRequestHandler, directory=str(ROOT)) + with ThreadingHTTPServer((args.bind, args.port), handler) as server: + print(f"MeatSlicer running at http://{args.bind}:{args.port}") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/wpn-hemophage-2.mp3 b/wpn-hemophage-2.mp3 new file mode 100644 index 0000000..ce5f3e2 Binary files /dev/null and b/wpn-hemophage-2.mp3 differ