diff --git a/.gitignore b/.gitignore index 295badd..127138a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ Thumbs.db # Editor *.tmp + +# Cloudflare wrangler cache (tools/site deploys) +.wrangler/ diff --git a/assets/audio/CREDITS.md b/assets/audio/CREDITS.md new file mode 100644 index 0000000..b78df86 --- /dev/null +++ b/assets/audio/CREDITS.md @@ -0,0 +1,27 @@ +# Audio credits + +Everything here is CC0 (public domain): free to use, change and ship, with +no attribution required. Credit is given anyway. + +## Kenney (CC0) +`sfx/kenney/` holds sounds from three of Kenney's packs, renamed: +- **RPG Audio**: creaks, doors, cloth, leather, knife, metal clicks +- **Impact Sounds**: wood, mining, plank, punch and soft impacts; footsteps + on grass, snow, wood and concrete +- **Interface Sounds**: open, close, tick and confirmation chimes + +https://kenney.nl/assets/rpg-audio, +https://kenney.nl/assets/impact-sounds, +https://kenney.nl/assets/interface-sounds: +"License: (Creative Commons Zero, CC0) +http://creativecommons.org/publicdomain/zero/1.0/". Thank you, Kenney. + +Which file is which is listed in `KENNEY` in `tools/audio/make_audio.py`, +which also re-copies them (`--kenney `). + +## Emberwild's own (CC0) +`music/`, `ambience/` and `sfx/synth/` are synthesised from scratch by +`tools/audio/make_audio.py` (no samples): the five music tracks (calm, +night, storm, danger, boss), the birds, crickets and fire crackle, and the +swings, eating, splashes, the fishing reel and bite, the bow, the hurt thump +and death. Released under CC0 like the rest of this folder. diff --git a/assets/audio/ambience/birds.ogg b/assets/audio/ambience/birds.ogg new file mode 100644 index 0000000..28847c1 Binary files /dev/null and b/assets/audio/ambience/birds.ogg differ diff --git a/assets/audio/ambience/crickets.ogg b/assets/audio/ambience/crickets.ogg new file mode 100644 index 0000000..7a61601 Binary files /dev/null and b/assets/audio/ambience/crickets.ogg differ diff --git a/assets/audio/ambience/fire.ogg b/assets/audio/ambience/fire.ogg new file mode 100644 index 0000000..78e8ece Binary files /dev/null and b/assets/audio/ambience/fire.ogg differ diff --git a/assets/audio/music/boss.ogg b/assets/audio/music/boss.ogg new file mode 100644 index 0000000..fbe2cb7 Binary files /dev/null and b/assets/audio/music/boss.ogg differ diff --git a/assets/audio/music/calm.ogg b/assets/audio/music/calm.ogg new file mode 100644 index 0000000..d3f076e Binary files /dev/null and b/assets/audio/music/calm.ogg differ diff --git a/assets/audio/music/danger.ogg b/assets/audio/music/danger.ogg new file mode 100644 index 0000000..27481cc Binary files /dev/null and b/assets/audio/music/danger.ogg differ diff --git a/assets/audio/music/night.ogg b/assets/audio/music/night.ogg new file mode 100644 index 0000000..23dfe4f Binary files /dev/null and b/assets/audio/music/night.ogg differ diff --git a/assets/audio/music/storm.ogg b/assets/audio/music/storm.ogg new file mode 100644 index 0000000..17b3777 Binary files /dev/null and b/assets/audio/music/storm.ogg differ diff --git a/assets/audio/sfx/creatures/bear_grunt_0.ogg b/assets/audio/sfx/creatures/bear_grunt_0.ogg new file mode 100644 index 0000000..9a8dc1e Binary files /dev/null and b/assets/audio/sfx/creatures/bear_grunt_0.ogg differ diff --git a/assets/audio/sfx/creatures/bear_grunt_1.ogg b/assets/audio/sfx/creatures/bear_grunt_1.ogg new file mode 100644 index 0000000..d05a29d Binary files /dev/null and b/assets/audio/sfx/creatures/bear_grunt_1.ogg differ diff --git a/assets/audio/sfx/creatures/bear_roar_0.ogg b/assets/audio/sfx/creatures/bear_roar_0.ogg new file mode 100644 index 0000000..2b2be27 Binary files /dev/null and b/assets/audio/sfx/creatures/bear_roar_0.ogg differ diff --git a/assets/audio/sfx/creatures/bear_roar_1.ogg b/assets/audio/sfx/creatures/bear_roar_1.ogg new file mode 100644 index 0000000..a3c6773 Binary files /dev/null and b/assets/audio/sfx/creatures/bear_roar_1.ogg differ diff --git a/assets/audio/sfx/creatures/boar_grunt_0.ogg b/assets/audio/sfx/creatures/boar_grunt_0.ogg new file mode 100644 index 0000000..34f03fb Binary files /dev/null and b/assets/audio/sfx/creatures/boar_grunt_0.ogg differ diff --git a/assets/audio/sfx/creatures/boar_grunt_1.ogg b/assets/audio/sfx/creatures/boar_grunt_1.ogg new file mode 100644 index 0000000..c245e20 Binary files /dev/null and b/assets/audio/sfx/creatures/boar_grunt_1.ogg differ diff --git a/assets/audio/sfx/creatures/boar_squeal_0.ogg b/assets/audio/sfx/creatures/boar_squeal_0.ogg new file mode 100644 index 0000000..eb2925b Binary files /dev/null and b/assets/audio/sfx/creatures/boar_squeal_0.ogg differ diff --git a/assets/audio/sfx/creatures/crow_caw_0.ogg b/assets/audio/sfx/creatures/crow_caw_0.ogg new file mode 100644 index 0000000..6d1d726 Binary files /dev/null and b/assets/audio/sfx/creatures/crow_caw_0.ogg differ diff --git a/assets/audio/sfx/creatures/crow_caw_1.ogg b/assets/audio/sfx/creatures/crow_caw_1.ogg new file mode 100644 index 0000000..43fd2ce Binary files /dev/null and b/assets/audio/sfx/creatures/crow_caw_1.ogg differ diff --git a/assets/audio/sfx/creatures/fox_yip_0.ogg b/assets/audio/sfx/creatures/fox_yip_0.ogg new file mode 100644 index 0000000..2c3fe94 Binary files /dev/null and b/assets/audio/sfx/creatures/fox_yip_0.ogg differ diff --git a/assets/audio/sfx/creatures/fox_yip_1.ogg b/assets/audio/sfx/creatures/fox_yip_1.ogg new file mode 100644 index 0000000..6ceaef4 Binary files /dev/null and b/assets/audio/sfx/creatures/fox_yip_1.ogg differ diff --git a/assets/audio/sfx/creatures/guard_rattle_0.ogg b/assets/audio/sfx/creatures/guard_rattle_0.ogg new file mode 100644 index 0000000..8919253 Binary files /dev/null and b/assets/audio/sfx/creatures/guard_rattle_0.ogg differ diff --git a/assets/audio/sfx/creatures/guard_rattle_1.ogg b/assets/audio/sfx/creatures/guard_rattle_1.ogg new file mode 100644 index 0000000..28b7abc Binary files /dev/null and b/assets/audio/sfx/creatures/guard_rattle_1.ogg differ diff --git a/assets/audio/sfx/creatures/stag_bellow_0.ogg b/assets/audio/sfx/creatures/stag_bellow_0.ogg new file mode 100644 index 0000000..65d20bd Binary files /dev/null and b/assets/audio/sfx/creatures/stag_bellow_0.ogg differ diff --git a/assets/audio/sfx/creatures/stag_bellow_1.ogg b/assets/audio/sfx/creatures/stag_bellow_1.ogg new file mode 100644 index 0000000..3cee22c Binary files /dev/null and b/assets/audio/sfx/creatures/stag_bellow_1.ogg differ diff --git a/assets/audio/sfx/creatures/stag_snort_0.ogg b/assets/audio/sfx/creatures/stag_snort_0.ogg new file mode 100644 index 0000000..d7bbf6a Binary files /dev/null and b/assets/audio/sfx/creatures/stag_snort_0.ogg differ diff --git a/assets/audio/sfx/creatures/wolf_growl_0.ogg b/assets/audio/sfx/creatures/wolf_growl_0.ogg new file mode 100644 index 0000000..3c02987 Binary files /dev/null and b/assets/audio/sfx/creatures/wolf_growl_0.ogg differ diff --git a/assets/audio/sfx/creatures/wolf_growl_1.ogg b/assets/audio/sfx/creatures/wolf_growl_1.ogg new file mode 100644 index 0000000..de7d473 Binary files /dev/null and b/assets/audio/sfx/creatures/wolf_growl_1.ogg differ diff --git a/assets/audio/sfx/creatures/wolf_growl_2.ogg b/assets/audio/sfx/creatures/wolf_growl_2.ogg new file mode 100644 index 0000000..4758f12 Binary files /dev/null and b/assets/audio/sfx/creatures/wolf_growl_2.ogg differ diff --git a/assets/audio/sfx/creatures/wolf_snarl_0.ogg b/assets/audio/sfx/creatures/wolf_snarl_0.ogg new file mode 100644 index 0000000..92e64ce Binary files /dev/null and b/assets/audio/sfx/creatures/wolf_snarl_0.ogg differ diff --git a/assets/audio/sfx/creatures/wolf_snarl_1.ogg b/assets/audio/sfx/creatures/wolf_snarl_1.ogg new file mode 100644 index 0000000..59af239 Binary files /dev/null and b/assets/audio/sfx/creatures/wolf_snarl_1.ogg differ diff --git a/assets/audio/sfx/creatures/wolf_yelp_0.ogg b/assets/audio/sfx/creatures/wolf_yelp_0.ogg new file mode 100644 index 0000000..139478f Binary files /dev/null and b/assets/audio/sfx/creatures/wolf_yelp_0.ogg differ diff --git a/assets/audio/sfx/creatures/wolf_yelp_1.ogg b/assets/audio/sfx/creatures/wolf_yelp_1.ogg new file mode 100644 index 0000000..5d91e52 Binary files /dev/null and b/assets/audio/sfx/creatures/wolf_yelp_1.ogg differ diff --git a/assets/audio/sfx/kenney/chop_0.ogg b/assets/audio/sfx/kenney/chop_0.ogg new file mode 100644 index 0000000..97735ff Binary files /dev/null and b/assets/audio/sfx/kenney/chop_0.ogg differ diff --git a/assets/audio/sfx/kenney/chop_1.ogg b/assets/audio/sfx/kenney/chop_1.ogg new file mode 100644 index 0000000..a314921 Binary files /dev/null and b/assets/audio/sfx/kenney/chop_1.ogg differ diff --git a/assets/audio/sfx/kenney/chop_2.ogg b/assets/audio/sfx/kenney/chop_2.ogg new file mode 100644 index 0000000..d5cf1d0 Binary files /dev/null and b/assets/audio/sfx/kenney/chop_2.ogg differ diff --git a/assets/audio/sfx/kenney/chop_3.ogg b/assets/audio/sfx/kenney/chop_3.ogg new file mode 100644 index 0000000..e559cb3 Binary files /dev/null and b/assets/audio/sfx/kenney/chop_3.ogg differ diff --git a/assets/audio/sfx/kenney/chop_4.ogg b/assets/audio/sfx/kenney/chop_4.ogg new file mode 100644 index 0000000..219828a Binary files /dev/null and b/assets/audio/sfx/kenney/chop_4.ogg differ diff --git a/assets/audio/sfx/kenney/cloth_0.ogg b/assets/audio/sfx/kenney/cloth_0.ogg new file mode 100644 index 0000000..b76853a Binary files /dev/null and b/assets/audio/sfx/kenney/cloth_0.ogg differ diff --git a/assets/audio/sfx/kenney/cloth_1.ogg b/assets/audio/sfx/kenney/cloth_1.ogg new file mode 100644 index 0000000..45e7839 Binary files /dev/null and b/assets/audio/sfx/kenney/cloth_1.ogg differ diff --git a/assets/audio/sfx/kenney/cloth_2.ogg b/assets/audio/sfx/kenney/cloth_2.ogg new file mode 100644 index 0000000..f995ff2 Binary files /dev/null and b/assets/audio/sfx/kenney/cloth_2.ogg differ diff --git a/assets/audio/sfx/kenney/cloth_3.ogg b/assets/audio/sfx/kenney/cloth_3.ogg new file mode 100644 index 0000000..a7ad4aa Binary files /dev/null and b/assets/audio/sfx/kenney/cloth_3.ogg differ diff --git a/assets/audio/sfx/kenney/creak_0.ogg b/assets/audio/sfx/kenney/creak_0.ogg new file mode 100644 index 0000000..4f6de6f Binary files /dev/null and b/assets/audio/sfx/kenney/creak_0.ogg differ diff --git a/assets/audio/sfx/kenney/creak_1.ogg b/assets/audio/sfx/kenney/creak_1.ogg new file mode 100644 index 0000000..bfa3045 Binary files /dev/null and b/assets/audio/sfx/kenney/creak_1.ogg differ diff --git a/assets/audio/sfx/kenney/creak_2.ogg b/assets/audio/sfx/kenney/creak_2.ogg new file mode 100644 index 0000000..0673845 Binary files /dev/null and b/assets/audio/sfx/kenney/creak_2.ogg differ diff --git a/assets/audio/sfx/kenney/door_close_0.ogg b/assets/audio/sfx/kenney/door_close_0.ogg new file mode 100644 index 0000000..21021c5 Binary files /dev/null and b/assets/audio/sfx/kenney/door_close_0.ogg differ diff --git a/assets/audio/sfx/kenney/door_close_1.ogg b/assets/audio/sfx/kenney/door_close_1.ogg new file mode 100644 index 0000000..f64212b Binary files /dev/null and b/assets/audio/sfx/kenney/door_close_1.ogg differ diff --git a/assets/audio/sfx/kenney/door_close_2.ogg b/assets/audio/sfx/kenney/door_close_2.ogg new file mode 100644 index 0000000..c08399c Binary files /dev/null and b/assets/audio/sfx/kenney/door_close_2.ogg differ diff --git a/assets/audio/sfx/kenney/door_close_3.ogg b/assets/audio/sfx/kenney/door_close_3.ogg new file mode 100644 index 0000000..ef44236 Binary files /dev/null and b/assets/audio/sfx/kenney/door_close_3.ogg differ diff --git a/assets/audio/sfx/kenney/door_open_0.ogg b/assets/audio/sfx/kenney/door_open_0.ogg new file mode 100644 index 0000000..7df2826 Binary files /dev/null and b/assets/audio/sfx/kenney/door_open_0.ogg differ diff --git a/assets/audio/sfx/kenney/door_open_1.ogg b/assets/audio/sfx/kenney/door_open_1.ogg new file mode 100644 index 0000000..d57087c Binary files /dev/null and b/assets/audio/sfx/kenney/door_open_1.ogg differ diff --git a/assets/audio/sfx/kenney/knife_0.ogg b/assets/audio/sfx/kenney/knife_0.ogg new file mode 100644 index 0000000..8633843 Binary files /dev/null and b/assets/audio/sfx/kenney/knife_0.ogg differ diff --git a/assets/audio/sfx/kenney/knife_1.ogg b/assets/audio/sfx/kenney/knife_1.ogg new file mode 100644 index 0000000..cf6098e Binary files /dev/null and b/assets/audio/sfx/kenney/knife_1.ogg differ diff --git a/assets/audio/sfx/kenney/leather_0.ogg b/assets/audio/sfx/kenney/leather_0.ogg new file mode 100644 index 0000000..d98f77b Binary files /dev/null and b/assets/audio/sfx/kenney/leather_0.ogg differ diff --git a/assets/audio/sfx/kenney/leather_1.ogg b/assets/audio/sfx/kenney/leather_1.ogg new file mode 100644 index 0000000..80aa470 Binary files /dev/null and b/assets/audio/sfx/kenney/leather_1.ogg differ diff --git a/assets/audio/sfx/kenney/leather_2.ogg b/assets/audio/sfx/kenney/leather_2.ogg new file mode 100644 index 0000000..83e3cf6 Binary files /dev/null and b/assets/audio/sfx/kenney/leather_2.ogg differ diff --git a/assets/audio/sfx/kenney/metal_click_0.ogg b/assets/audio/sfx/kenney/metal_click_0.ogg new file mode 100644 index 0000000..1a9ee4b Binary files /dev/null and b/assets/audio/sfx/kenney/metal_click_0.ogg differ diff --git a/assets/audio/sfx/kenney/metal_click_1.ogg b/assets/audio/sfx/kenney/metal_click_1.ogg new file mode 100644 index 0000000..509c68f Binary files /dev/null and b/assets/audio/sfx/kenney/metal_click_1.ogg differ diff --git a/assets/audio/sfx/kenney/mine_0.ogg b/assets/audio/sfx/kenney/mine_0.ogg new file mode 100644 index 0000000..3346d1a Binary files /dev/null and b/assets/audio/sfx/kenney/mine_0.ogg differ diff --git a/assets/audio/sfx/kenney/mine_1.ogg b/assets/audio/sfx/kenney/mine_1.ogg new file mode 100644 index 0000000..b5475fa Binary files /dev/null and b/assets/audio/sfx/kenney/mine_1.ogg differ diff --git a/assets/audio/sfx/kenney/mine_2.ogg b/assets/audio/sfx/kenney/mine_2.ogg new file mode 100644 index 0000000..5bd4f35 Binary files /dev/null and b/assets/audio/sfx/kenney/mine_2.ogg differ diff --git a/assets/audio/sfx/kenney/mine_3.ogg b/assets/audio/sfx/kenney/mine_3.ogg new file mode 100644 index 0000000..2fc4bb8 Binary files /dev/null and b/assets/audio/sfx/kenney/mine_3.ogg differ diff --git a/assets/audio/sfx/kenney/mine_4.ogg b/assets/audio/sfx/kenney/mine_4.ogg new file mode 100644 index 0000000..ff732a8 Binary files /dev/null and b/assets/audio/sfx/kenney/mine_4.ogg differ diff --git a/assets/audio/sfx/kenney/plank_0.ogg b/assets/audio/sfx/kenney/plank_0.ogg new file mode 100644 index 0000000..70d13ee Binary files /dev/null and b/assets/audio/sfx/kenney/plank_0.ogg differ diff --git a/assets/audio/sfx/kenney/plank_1.ogg b/assets/audio/sfx/kenney/plank_1.ogg new file mode 100644 index 0000000..2c53b78 Binary files /dev/null and b/assets/audio/sfx/kenney/plank_1.ogg differ diff --git a/assets/audio/sfx/kenney/plank_2.ogg b/assets/audio/sfx/kenney/plank_2.ogg new file mode 100644 index 0000000..922a6db Binary files /dev/null and b/assets/audio/sfx/kenney/plank_2.ogg differ diff --git a/assets/audio/sfx/kenney/plank_3.ogg b/assets/audio/sfx/kenney/plank_3.ogg new file mode 100644 index 0000000..fad0463 Binary files /dev/null and b/assets/audio/sfx/kenney/plank_3.ogg differ diff --git a/assets/audio/sfx/kenney/plank_4.ogg b/assets/audio/sfx/kenney/plank_4.ogg new file mode 100644 index 0000000..ffe3719 Binary files /dev/null and b/assets/audio/sfx/kenney/plank_4.ogg differ diff --git a/assets/audio/sfx/kenney/punch_0.ogg b/assets/audio/sfx/kenney/punch_0.ogg new file mode 100644 index 0000000..d8e0b89 Binary files /dev/null and b/assets/audio/sfx/kenney/punch_0.ogg differ diff --git a/assets/audio/sfx/kenney/punch_1.ogg b/assets/audio/sfx/kenney/punch_1.ogg new file mode 100644 index 0000000..3308046 Binary files /dev/null and b/assets/audio/sfx/kenney/punch_1.ogg differ diff --git a/assets/audio/sfx/kenney/punch_2.ogg b/assets/audio/sfx/kenney/punch_2.ogg new file mode 100644 index 0000000..9704fd2 Binary files /dev/null and b/assets/audio/sfx/kenney/punch_2.ogg differ diff --git a/assets/audio/sfx/kenney/punch_3.ogg b/assets/audio/sfx/kenney/punch_3.ogg new file mode 100644 index 0000000..32ac6c2 Binary files /dev/null and b/assets/audio/sfx/kenney/punch_3.ogg differ diff --git a/assets/audio/sfx/kenney/punch_4.ogg b/assets/audio/sfx/kenney/punch_4.ogg new file mode 100644 index 0000000..1620aa9 Binary files /dev/null and b/assets/audio/sfx/kenney/punch_4.ogg differ diff --git a/assets/audio/sfx/kenney/punch_heavy_0.ogg b/assets/audio/sfx/kenney/punch_heavy_0.ogg new file mode 100644 index 0000000..55e70b5 Binary files /dev/null and b/assets/audio/sfx/kenney/punch_heavy_0.ogg differ diff --git a/assets/audio/sfx/kenney/punch_heavy_1.ogg b/assets/audio/sfx/kenney/punch_heavy_1.ogg new file mode 100644 index 0000000..6655951 Binary files /dev/null and b/assets/audio/sfx/kenney/punch_heavy_1.ogg differ diff --git a/assets/audio/sfx/kenney/punch_heavy_2.ogg b/assets/audio/sfx/kenney/punch_heavy_2.ogg new file mode 100644 index 0000000..8573e89 Binary files /dev/null and b/assets/audio/sfx/kenney/punch_heavy_2.ogg differ diff --git a/assets/audio/sfx/kenney/punch_heavy_3.ogg b/assets/audio/sfx/kenney/punch_heavy_3.ogg new file mode 100644 index 0000000..78cc8ab Binary files /dev/null and b/assets/audio/sfx/kenney/punch_heavy_3.ogg differ diff --git a/assets/audio/sfx/kenney/punch_heavy_4.ogg b/assets/audio/sfx/kenney/punch_heavy_4.ogg new file mode 100644 index 0000000..e7ceea0 Binary files /dev/null and b/assets/audio/sfx/kenney/punch_heavy_4.ogg differ diff --git a/assets/audio/sfx/kenney/soft_heavy_0.ogg b/assets/audio/sfx/kenney/soft_heavy_0.ogg new file mode 100644 index 0000000..1c321ef Binary files /dev/null and b/assets/audio/sfx/kenney/soft_heavy_0.ogg differ diff --git a/assets/audio/sfx/kenney/soft_heavy_1.ogg b/assets/audio/sfx/kenney/soft_heavy_1.ogg new file mode 100644 index 0000000..d7fbb0f Binary files /dev/null and b/assets/audio/sfx/kenney/soft_heavy_1.ogg differ diff --git a/assets/audio/sfx/kenney/soft_heavy_2.ogg b/assets/audio/sfx/kenney/soft_heavy_2.ogg new file mode 100644 index 0000000..4748c69 Binary files /dev/null and b/assets/audio/sfx/kenney/soft_heavy_2.ogg differ diff --git a/assets/audio/sfx/kenney/soft_heavy_3.ogg b/assets/audio/sfx/kenney/soft_heavy_3.ogg new file mode 100644 index 0000000..417cacc Binary files /dev/null and b/assets/audio/sfx/kenney/soft_heavy_3.ogg differ diff --git a/assets/audio/sfx/kenney/soft_heavy_4.ogg b/assets/audio/sfx/kenney/soft_heavy_4.ogg new file mode 100644 index 0000000..c42b1d5 Binary files /dev/null and b/assets/audio/sfx/kenney/soft_heavy_4.ogg differ diff --git a/assets/audio/sfx/kenney/step_grass_0.ogg b/assets/audio/sfx/kenney/step_grass_0.ogg new file mode 100644 index 0000000..5b5ec87 Binary files /dev/null and b/assets/audio/sfx/kenney/step_grass_0.ogg differ diff --git a/assets/audio/sfx/kenney/step_grass_1.ogg b/assets/audio/sfx/kenney/step_grass_1.ogg new file mode 100644 index 0000000..56c3a84 Binary files /dev/null and b/assets/audio/sfx/kenney/step_grass_1.ogg differ diff --git a/assets/audio/sfx/kenney/step_grass_2.ogg b/assets/audio/sfx/kenney/step_grass_2.ogg new file mode 100644 index 0000000..aa0b414 Binary files /dev/null and b/assets/audio/sfx/kenney/step_grass_2.ogg differ diff --git a/assets/audio/sfx/kenney/step_grass_3.ogg b/assets/audio/sfx/kenney/step_grass_3.ogg new file mode 100644 index 0000000..fe9e119 Binary files /dev/null and b/assets/audio/sfx/kenney/step_grass_3.ogg differ diff --git a/assets/audio/sfx/kenney/step_grass_4.ogg b/assets/audio/sfx/kenney/step_grass_4.ogg new file mode 100644 index 0000000..0dcff84 Binary files /dev/null and b/assets/audio/sfx/kenney/step_grass_4.ogg differ diff --git a/assets/audio/sfx/kenney/step_snow_0.ogg b/assets/audio/sfx/kenney/step_snow_0.ogg new file mode 100644 index 0000000..be47875 Binary files /dev/null and b/assets/audio/sfx/kenney/step_snow_0.ogg differ diff --git a/assets/audio/sfx/kenney/step_snow_1.ogg b/assets/audio/sfx/kenney/step_snow_1.ogg new file mode 100644 index 0000000..7c16210 Binary files /dev/null and b/assets/audio/sfx/kenney/step_snow_1.ogg differ diff --git a/assets/audio/sfx/kenney/step_snow_2.ogg b/assets/audio/sfx/kenney/step_snow_2.ogg new file mode 100644 index 0000000..e42fb28 Binary files /dev/null and b/assets/audio/sfx/kenney/step_snow_2.ogg differ diff --git a/assets/audio/sfx/kenney/step_snow_3.ogg b/assets/audio/sfx/kenney/step_snow_3.ogg new file mode 100644 index 0000000..70447bf Binary files /dev/null and b/assets/audio/sfx/kenney/step_snow_3.ogg differ diff --git a/assets/audio/sfx/kenney/step_snow_4.ogg b/assets/audio/sfx/kenney/step_snow_4.ogg new file mode 100644 index 0000000..1360e5a Binary files /dev/null and b/assets/audio/sfx/kenney/step_snow_4.ogg differ diff --git a/assets/audio/sfx/kenney/step_stone_0.ogg b/assets/audio/sfx/kenney/step_stone_0.ogg new file mode 100644 index 0000000..163cc56 Binary files /dev/null and b/assets/audio/sfx/kenney/step_stone_0.ogg differ diff --git a/assets/audio/sfx/kenney/step_stone_1.ogg b/assets/audio/sfx/kenney/step_stone_1.ogg new file mode 100644 index 0000000..4c62bac Binary files /dev/null and b/assets/audio/sfx/kenney/step_stone_1.ogg differ diff --git a/assets/audio/sfx/kenney/step_stone_2.ogg b/assets/audio/sfx/kenney/step_stone_2.ogg new file mode 100644 index 0000000..99aec98 Binary files /dev/null and b/assets/audio/sfx/kenney/step_stone_2.ogg differ diff --git a/assets/audio/sfx/kenney/step_stone_3.ogg b/assets/audio/sfx/kenney/step_stone_3.ogg new file mode 100644 index 0000000..bfb613a Binary files /dev/null and b/assets/audio/sfx/kenney/step_stone_3.ogg differ diff --git a/assets/audio/sfx/kenney/step_stone_4.ogg b/assets/audio/sfx/kenney/step_stone_4.ogg new file mode 100644 index 0000000..fe8d18d Binary files /dev/null and b/assets/audio/sfx/kenney/step_stone_4.ogg differ diff --git a/assets/audio/sfx/kenney/step_wood_0.ogg b/assets/audio/sfx/kenney/step_wood_0.ogg new file mode 100644 index 0000000..1a519c7 Binary files /dev/null and b/assets/audio/sfx/kenney/step_wood_0.ogg differ diff --git a/assets/audio/sfx/kenney/step_wood_1.ogg b/assets/audio/sfx/kenney/step_wood_1.ogg new file mode 100644 index 0000000..069a2b1 Binary files /dev/null and b/assets/audio/sfx/kenney/step_wood_1.ogg differ diff --git a/assets/audio/sfx/kenney/step_wood_2.ogg b/assets/audio/sfx/kenney/step_wood_2.ogg new file mode 100644 index 0000000..bd8d388 Binary files /dev/null and b/assets/audio/sfx/kenney/step_wood_2.ogg differ diff --git a/assets/audio/sfx/kenney/step_wood_3.ogg b/assets/audio/sfx/kenney/step_wood_3.ogg new file mode 100644 index 0000000..7883eaa Binary files /dev/null and b/assets/audio/sfx/kenney/step_wood_3.ogg differ diff --git a/assets/audio/sfx/kenney/step_wood_4.ogg b/assets/audio/sfx/kenney/step_wood_4.ogg new file mode 100644 index 0000000..79c32db Binary files /dev/null and b/assets/audio/sfx/kenney/step_wood_4.ogg differ diff --git a/assets/audio/sfx/kenney/ui_catch_0.ogg b/assets/audio/sfx/kenney/ui_catch_0.ogg new file mode 100644 index 0000000..4e01396 Binary files /dev/null and b/assets/audio/sfx/kenney/ui_catch_0.ogg differ diff --git a/assets/audio/sfx/kenney/ui_close_0.ogg b/assets/audio/sfx/kenney/ui_close_0.ogg new file mode 100644 index 0000000..b2545e7 Binary files /dev/null and b/assets/audio/sfx/kenney/ui_close_0.ogg differ diff --git a/assets/audio/sfx/kenney/ui_good_0.ogg b/assets/audio/sfx/kenney/ui_good_0.ogg new file mode 100644 index 0000000..78f119c Binary files /dev/null and b/assets/audio/sfx/kenney/ui_good_0.ogg differ diff --git a/assets/audio/sfx/kenney/ui_open_0.ogg b/assets/audio/sfx/kenney/ui_open_0.ogg new file mode 100644 index 0000000..699b498 Binary files /dev/null and b/assets/audio/sfx/kenney/ui_open_0.ogg differ diff --git a/assets/audio/sfx/kenney/ui_step_0.ogg b/assets/audio/sfx/kenney/ui_step_0.ogg new file mode 100644 index 0000000..673b0a0 Binary files /dev/null and b/assets/audio/sfx/kenney/ui_step_0.ogg differ diff --git a/assets/audio/sfx/kenney/ui_tick_0.ogg b/assets/audio/sfx/kenney/ui_tick_0.ogg new file mode 100644 index 0000000..e5827a0 Binary files /dev/null and b/assets/audio/sfx/kenney/ui_tick_0.ogg differ diff --git a/assets/audio/sfx/kenney/ui_tip_0.ogg b/assets/audio/sfx/kenney/ui_tip_0.ogg new file mode 100644 index 0000000..10697e4 Binary files /dev/null and b/assets/audio/sfx/kenney/ui_tip_0.ogg differ diff --git a/assets/audio/sfx/kenney/wood_heavy_0.ogg b/assets/audio/sfx/kenney/wood_heavy_0.ogg new file mode 100644 index 0000000..30fb995 Binary files /dev/null and b/assets/audio/sfx/kenney/wood_heavy_0.ogg differ diff --git a/assets/audio/sfx/kenney/wood_heavy_1.ogg b/assets/audio/sfx/kenney/wood_heavy_1.ogg new file mode 100644 index 0000000..1ec513e Binary files /dev/null and b/assets/audio/sfx/kenney/wood_heavy_1.ogg differ diff --git a/assets/audio/sfx/kenney/wood_heavy_2.ogg b/assets/audio/sfx/kenney/wood_heavy_2.ogg new file mode 100644 index 0000000..17f4e31 Binary files /dev/null and b/assets/audio/sfx/kenney/wood_heavy_2.ogg differ diff --git a/assets/audio/sfx/kenney/wood_heavy_3.ogg b/assets/audio/sfx/kenney/wood_heavy_3.ogg new file mode 100644 index 0000000..fb6e75f Binary files /dev/null and b/assets/audio/sfx/kenney/wood_heavy_3.ogg differ diff --git a/assets/audio/sfx/kenney/wood_heavy_4.ogg b/assets/audio/sfx/kenney/wood_heavy_4.ogg new file mode 100644 index 0000000..ebca8ef Binary files /dev/null and b/assets/audio/sfx/kenney/wood_heavy_4.ogg differ diff --git a/assets/audio/sfx/kenney/wood_light_0.ogg b/assets/audio/sfx/kenney/wood_light_0.ogg new file mode 100644 index 0000000..4bf6be6 Binary files /dev/null and b/assets/audio/sfx/kenney/wood_light_0.ogg differ diff --git a/assets/audio/sfx/kenney/wood_light_1.ogg b/assets/audio/sfx/kenney/wood_light_1.ogg new file mode 100644 index 0000000..2184d05 Binary files /dev/null and b/assets/audio/sfx/kenney/wood_light_1.ogg differ diff --git a/assets/audio/sfx/kenney/wood_light_2.ogg b/assets/audio/sfx/kenney/wood_light_2.ogg new file mode 100644 index 0000000..c27596a Binary files /dev/null and b/assets/audio/sfx/kenney/wood_light_2.ogg differ diff --git a/assets/audio/sfx/kenney/wood_light_3.ogg b/assets/audio/sfx/kenney/wood_light_3.ogg new file mode 100644 index 0000000..88359d1 Binary files /dev/null and b/assets/audio/sfx/kenney/wood_light_3.ogg differ diff --git a/assets/audio/sfx/kenney/wood_light_4.ogg b/assets/audio/sfx/kenney/wood_light_4.ogg new file mode 100644 index 0000000..b7a35eb Binary files /dev/null and b/assets/audio/sfx/kenney/wood_light_4.ogg differ diff --git a/assets/audio/sfx/synth/bow.ogg b/assets/audio/sfx/synth/bow.ogg new file mode 100644 index 0000000..3cdbe60 Binary files /dev/null and b/assets/audio/sfx/synth/bow.ogg differ diff --git a/assets/audio/sfx/synth/cast.ogg b/assets/audio/sfx/synth/cast.ogg new file mode 100644 index 0000000..c6b5655 Binary files /dev/null and b/assets/audio/sfx/synth/cast.ogg differ diff --git a/assets/audio/sfx/synth/death.ogg b/assets/audio/sfx/synth/death.ogg new file mode 100644 index 0000000..1b57094 Binary files /dev/null and b/assets/audio/sfx/synth/death.ogg differ diff --git a/assets/audio/sfx/synth/eat_0.ogg b/assets/audio/sfx/synth/eat_0.ogg new file mode 100644 index 0000000..8f63046 Binary files /dev/null and b/assets/audio/sfx/synth/eat_0.ogg differ diff --git a/assets/audio/sfx/synth/eat_1.ogg b/assets/audio/sfx/synth/eat_1.ogg new file mode 100644 index 0000000..fdf97f6 Binary files /dev/null and b/assets/audio/sfx/synth/eat_1.ogg differ diff --git a/assets/audio/sfx/synth/eat_2.ogg b/assets/audio/sfx/synth/eat_2.ogg new file mode 100644 index 0000000..9972a1f Binary files /dev/null and b/assets/audio/sfx/synth/eat_2.ogg differ diff --git a/assets/audio/sfx/synth/plop.ogg b/assets/audio/sfx/synth/plop.ogg new file mode 100644 index 0000000..07ce828 Binary files /dev/null and b/assets/audio/sfx/synth/plop.ogg differ diff --git a/assets/audio/sfx/synth/reel.ogg b/assets/audio/sfx/synth/reel.ogg new file mode 100644 index 0000000..86f815c Binary files /dev/null and b/assets/audio/sfx/synth/reel.ogg differ diff --git a/assets/audio/sfx/synth/splash_0.ogg b/assets/audio/sfx/synth/splash_0.ogg new file mode 100644 index 0000000..ee464d7 Binary files /dev/null and b/assets/audio/sfx/synth/splash_0.ogg differ diff --git a/assets/audio/sfx/synth/splash_1.ogg b/assets/audio/sfx/synth/splash_1.ogg new file mode 100644 index 0000000..4b692ba Binary files /dev/null and b/assets/audio/sfx/synth/splash_1.ogg differ diff --git a/assets/audio/sfx/synth/splash_big.ogg b/assets/audio/sfx/synth/splash_big.ogg new file mode 100644 index 0000000..ea5c8b8 Binary files /dev/null and b/assets/audio/sfx/synth/splash_big.ogg differ diff --git a/assets/audio/sfx/synth/swing_0.ogg b/assets/audio/sfx/synth/swing_0.ogg new file mode 100644 index 0000000..4984f3e Binary files /dev/null and b/assets/audio/sfx/synth/swing_0.ogg differ diff --git a/assets/audio/sfx/synth/swing_1.ogg b/assets/audio/sfx/synth/swing_1.ogg new file mode 100644 index 0000000..81e6dc4 Binary files /dev/null and b/assets/audio/sfx/synth/swing_1.ogg differ diff --git a/assets/audio/sfx/synth/swing_2.ogg b/assets/audio/sfx/synth/swing_2.ogg new file mode 100644 index 0000000..f89df3a Binary files /dev/null and b/assets/audio/sfx/synth/swing_2.ogg differ diff --git a/assets/audio/sfx/synth/thump_0.ogg b/assets/audio/sfx/synth/thump_0.ogg new file mode 100644 index 0000000..8da176d Binary files /dev/null and b/assets/audio/sfx/synth/thump_0.ogg differ diff --git a/assets/audio/sfx/synth/thump_1.ogg b/assets/audio/sfx/synth/thump_1.ogg new file mode 100644 index 0000000..4b61eed Binary files /dev/null and b/assets/audio/sfx/synth/thump_1.ogg differ diff --git a/project.godot b/project.godot index e540d4c..362981f 100644 --- a/project.godot +++ b/project.godot @@ -35,6 +35,8 @@ WorldNet="*res://scripts/net/world_net.gd" PlayerNet="*res://scripts/net/player_net.gd" CreatureNet="*res://scripts/net/creature_net.gd" Chat="*res://scripts/net/chat.gd" +Sfx="*res://scripts/core/sfx.gd" +Music="*res://scripts/core/music.gd" [display] diff --git a/scripts/building/campfire.gd b/scripts/building/campfire.gd index c768348..3911ab0 100644 --- a/scripts/building/campfire.gd +++ b/scripts/building/campfire.gd @@ -17,6 +17,7 @@ var _noise := FastNoiseLite.new() var _lit := true var _roofed_cache := -1.0 ## -1 unknown, 0 open sky, 1 under a roof var _roof_timer := 0.0 +var _crackle: AudioStreamPlayer3D func _build() -> void: add_to_group("station:campfire") @@ -38,6 +39,19 @@ func _build() -> void: [0.0, Color(1.0, 0.75, 0.35, 1.0)], [1.0, Color(1.0, 0.3, 0.05, 0.0)]]), true, 0.04, 0.15)) for p in _fx: add_child(p) + # The crackle, close to it (sfx.gd's buses; not on a headless server). + if DisplayServer.get_name() != "headless": + _crackle = AudioStreamPlayer3D.new() + var st: Variant = load("res://assets/audio/ambience/fire.ogg") + if st is AudioStreamOggVorbis: + (st as AudioStreamOggVorbis).loop = true + _crackle.stream = st + _crackle.bus = "Ambience" + _crackle.unit_size = 2.0 + _crackle.max_distance = 16.0 + _crackle.volume_db = -4.0 + _crackle.position = Vector3(0, 0.3, 0) + add_child(_crackle) # TODO: smoke. Alpha-blended unshaded particles rendered as an opaque dark # ball whatever their ramp colour (additive flames are fine). Needs a small # particle shader. @@ -118,6 +132,12 @@ func _set_lit(on: bool) -> void: _light.visible = on for p in _fx: p.emitting = on + if _crackle != null: + _crackle.autoplay = on # (lit before it's in the tree: it starts when it's added) + if on and not _crackle.playing and _crackle.is_inside_tree(): + _crackle.play(randf() * 5.0) + elif not on: + _crackle.stop() func save_extra() -> Dictionary: return {"fuel": fuel} diff --git a/scripts/core/actions.gd b/scripts/core/actions.gd index 44f12dc..d26631e 100644 --- a/scripts/core/actions.gd +++ b/scripts/core/actions.gd @@ -219,6 +219,7 @@ func craft(player: Node, recipe_id: String) -> bool: inv.remove(id, int(r.inputs[id])) _give(player, r.output, r.count) crafted.emit(player, recipe_id, String(r.output), int(r.count)) + Sfx.at("craft", player.global_position) return true func pickup(player: Node3D, pickup_node: Node3D) -> bool: @@ -239,6 +240,7 @@ func pickup(player: Node3D, pickup_node: Node3D) -> bool: notify.emit("+%d %s" % [pickup_node.amount, ItemDB.item_name(id)]) elif _give(player, id, pickup_node.amount) <= 0: return false + Sfx.at("pickup", pickup_node.global_position) pickup_node.collected() return true @@ -262,6 +264,7 @@ func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool: for id in drops: _give(player, id, int(drops[id])) use_held(player) + Sfx.at(harvest_sound(String(node.tool_kind), bool(node.depleted)), hit_point) harvested.emit(player, node, bool(node.depleted)) return true @@ -285,6 +288,7 @@ func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool: return false player.inventory.take_from(slot, 1) var piece: Node3D = world.spawn_piece(def, xf) + Sfx.at(place_sound(id), xf.origin) placed.emit(player, id, piece) return true @@ -308,6 +312,7 @@ func dismantle(player: Node3D, piece: Node3D) -> bool: notify.emit("Inventory full") return false _give(player, piece.item_id, 1) + Sfx.at("dismantle", piece.global_position) piece.get_parent().get_parent().remove_piece(piece, player) return true @@ -317,6 +322,7 @@ func toggle_door(player: Node3D, door: Node3D) -> bool: if _online(): return _ask("door", [WorldNet.nid(door)]) door.set_open(not door.open) + Sfx.at("door_open" if door.open else "door_close", door.global_position) return true ## E on the barrow's coffin (barrow.gd): take what's in it. Opening it wakes @@ -377,6 +383,7 @@ func use_bed(player: Node3D, bed: Node3D) -> bool: else: world.set_bed_spawn(bed) notify.emit("Spawn point set") + Sfx.for_player(player, "ui_good") return true var why: String = world.sleep_block_reason(bed) if not why.is_empty(): @@ -467,6 +474,7 @@ func hit_player(player: Node3D, peer: int) -> bool: return false player.melee_ready_at = Time.get_ticks_msec() + int(m.cooldown * 1000.0 * 0.8) target.survival.damage(m.damage, player.global_position, String(player.display)) + Sfx.at("hit_flesh", target.global_position) use_held(player) notify.emit("Hit %s" % String(target.display)) return true @@ -499,6 +507,7 @@ func throw_spear(player: Node3D, slot: int, origin: Vector3, dir: Vector3, world var id: String = s.id var left := ItemDB.durability(s) - 1 player.inventory.take_from(slot, 1) + Sfx.at("throw", origin) var pr: Node3D = world.launch(id, origin, dir.normalized() * float(w.throw.speed) + player.velocity * 0.5, float(w.throw.damage), player) if ItemDB.max_durability(id) > 0: @@ -545,6 +554,7 @@ func shoot(player: Node3D, origin: Vector3, dir: Vector3, power: float, world: N var dmg: float = lerpf(float(bow.damage[0]), float(bow.damage[1]), power) dmg *= float(ItemDB.get_item(ammo).get("ammo", {}).get("damage_mult", 1.0)) world.launch(ammo, origin, dir.normalized() * spd, dmg, player) + Sfx.at("bow", origin) use_held(player) return true @@ -558,6 +568,7 @@ func butcher(player: Node3D, carcass: Node3D) -> bool: var drops: Dictionary = carcass.butcher_drops() carcass.remove_from_group("carcass") butchered.emit(player, String(carcass.kind)) + Sfx.at("butcher", carcass.global_position) for id in drops: var got := _give(player, id, int(drops[id])) if got < int(drops[id]): @@ -583,6 +594,7 @@ func cast_line(player: Node3D, origin: Vector3, dir: Vector3, world: Node3D) -> var d := dir.normalized() d.y = maxf(d.y, -0.2) + 0.35 # a lobbed cast, even when looking level world.start_fishing(player, origin, d.normalized() * float(rod.get("cast_speed", 12.0))) + Sfx.at("cast", origin) use_held(player) return true @@ -596,12 +608,16 @@ func reel(player: Node3D, world: Node3D) -> bool: var caught := "" if b.state == "biting": caught = b.fish + Sfx.at("reel", player.global_position) + if not caught.is_empty(): + Sfx.at("catch", b.global_position) world.end_fishing(player) if caught.is_empty(): return false if _give(player, caught, 1) == 0: world.drop_item(caught, 1, Transform3D(Basis(), player.global_position + Vector3(0, 0.1, 0))) notify.emit("Caught a %s" % ItemDB.item_name(caught).to_lower()) + Sfx.for_player(player, "ui_catch") emit_signal("caught", player, caught) # (the local `caught` hides the signal's name) return true @@ -618,6 +634,7 @@ func wear(player: Node3D, slot: int) -> bool: _give(player, player.worn, 1) player.worn = id player.worn_changed.emit() + Sfx.at("wear", player.global_position) notify.emit("Wearing %s" % ItemDB.item_name(id).to_lower()) return true @@ -663,6 +680,7 @@ func eat(player: Node3D, slot: int) -> bool: player.inventory.take_from(slot, 1) var eaten := String(s.id) player.survival.eat(food) + Sfx.at("eat", player.global_position) notify.emit("Ate %s" % ItemDB.item_name(eaten).to_lower()) ate.emit(player, eaten) # Raw meat and fish can make you ill (P4). Cooked, smoked or salted can't. @@ -688,6 +706,7 @@ func add_fuel(player: Node3D, fire: Node3D) -> bool: if player.inventory.count(id) > 0: player.inventory.remove(id, 1) fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10))) + Sfx.at("fuel", fire.global_position) notify.emit("Added %s to the fire" % ItemDB.item_name(id).to_lower()) return true notify.emit("Nothing to burn (wood or sticks)") @@ -703,3 +722,22 @@ func transfer(player: Node3D, from: Inventory, slot: int, to: Inventory, contain _ask("transfer", [WorldNet.nid(container), slot, from == player.inventory]) return from.quick_move(slot, to) + +# --- sounds (sfx.gd) -------------------------------------------------------------------- + +## The sound of a tool on a tree, a boulder or a bush; heavier when it falls. +static func harvest_sound(tool_kind: String, felled: bool) -> String: + match tool_kind: + "axe": + return "tree_fall" if felled else "chop" + "pickaxe": + return "rock_break" if felled else "mine" + return "rustle" + +## A piece going down: wood, stone, or something soft. +static func place_sound(id: String) -> String: + if id.contains("stone") or id in ["campfire", "forge", "hearth", "stonecutter"]: + return "place_stone" + if id.contains("rug") or id.contains("bed") or id == "smoking_rack": + return "place_soft" + return "place_wood" diff --git a/scripts/core/music.gd b/scripts/core/music.gd new file mode 100644 index 0000000..c2b7a0a --- /dev/null +++ b/scripts/core/music.gd @@ -0,0 +1,227 @@ +extends Node +## The music and the ambience, chosen from what's going on around you. The +## tracks are synthesised by tools/audio/make_audio.py (assets/audio/music): +## +## boss the Old Stag is up, within 70 m +## danger a wolf, bear, boar or barrow guard is coming for someone nearby +## (and for 10 s after it stops) +## storm a storm or blizzard, or the red wolf moon +## night after dark +## calm by day (and on the title screen) +## +## Calm, night and storm play once and then rest for a while, with just the +## ambience (birds by day, crickets at night), so the music doesn't wear +## thin; danger and boss loop until it's over. Changes crossfade. Indoors the +## music and ambience are quieter. It only reads things every game already +## has (the clock, the weather, the creatures it can see), so it works the +## same online. Headless, it still works out the mood (`mood`) but plays +## nothing. + +const DIR := "res://assets/audio/music/" +const AMB := "res://assets/audio/ambience/" +const MOODS := ["boss", "danger", "storm", "night", "calm"] +const LOOPS := ["danger", "boss"] +## States a creature is in when it's after someone. +const AGGRO := ["charge", "stalk", "lunge", "bash", "stomp", "telegraph", "warn", "raid", "summon", "alert", "defend"] +const DANGER_RANGE := 26.0 +const BOSS_RANGE := 70.0 +const DANGER_HOLD := 10.0 +const FADE := 3.0 +const FADE_FAST := 1.2 +const REST := [35.0, 80.0] ## seconds of quiet between calm/night/storm plays + +var world: Node3D = null +var mood := "" ## what the music is for now +var playing := "" ## the track that's sounding ("" between plays) +var _danger_until := -1.0 +var _clock := 0.0 +var _tick := 0.0 +var _rest_left := 0.0 +var _a: AudioStreamPlayer +var _b: AudioStreamPlayer +var _cur: AudioStreamPlayer +var _birds: AudioStreamPlayer +var _crickets: AudioStreamPlayer +var _tracks: Dictionary = {} +var _rng := RandomNumberGenerator.new() +var _quiet := false +var _fading := 0 ## crossfades in progress + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS # (the pause menu stops the game, not the music) + _rng.randomize() + _quiet = DisplayServer.get_name() == "headless" + if _quiet: + return + _a = _player("Music") + _b = _player("Music") + _cur = _a + _birds = _player("Ambience", _load(AMB + "birds.ogg", true)) + _crickets = _player("Ambience", _load(AMB + "crickets.ogg", true)) + for p in [_a, _b]: + p.finished.connect(_on_finished.bind(p)) + +func _player(bus: String, stream: AudioStream = null) -> AudioStreamPlayer: + var p := AudioStreamPlayer.new() + p.bus = bus + p.volume_db = -80.0 + p.stream = stream + add_child(p) + return p + +static func _load(path: String, loop: bool) -> AudioStream: + var s: Variant = load(path) + if s is AudioStreamOggVorbis: + (s as AudioStreamOggVorbis).loop = loop + return s + +func start(w: Node3D) -> void: + world = w + mood = "" + _danger_until = -1.0 + _tick = 0.0 + if not _quiet: + for p in [_birds, _crickets]: + if not p.playing: + p.play() + +func stop(w: Node3D = null) -> void: + if w != null and w != world: + return + world = null + +func _process(delta: float) -> void: + step(delta) + +func step(dt: float) -> void: + _clock += dt + _tick -= dt + if _tick <= 0.0: + _tick = 0.5 + var m := decide() + if m != mood: + _change(m) + if not _quiet and playing.is_empty() and not mood.is_empty(): + _rest_left -= dt + if _rest_left <= 0.0: + _start_track(mood, FADE) + _ambience(dt) + +## What the music should be for, right now. +func decide() -> String: + if world == null or not is_instance_valid(world): + return "calm" + if world.get("menu_mode"): + return "calm" + var me: Node3D = world.get("player") + if me == null or not is_instance_valid(me): + return "calm" + var here := me.global_position + var boss := false + var danger := false + for c in get_tree().get_nodes_in_group("creature"): + if not is_instance_valid(c) or not (c is Node3D) or (c.has_method("is_dead") and c.is_dead()): + continue + var d: float = (c as Node3D).global_position.distance_to(here) + var kind := String(c.get("kind")) if c.get("kind") != null else "" + if kind == "old_stag" and d < BOSS_RANGE: + boss = true + elif d < DANGER_RANGE and String(c.get("state")) in AGGRO and _hostile(c): + danger = true + if boss: + return "boss" + if danger: + _danger_until = _clock + DANGER_HOLD + if _clock < _danger_until: + return "danger" + var dn: Node = world.get("day_night") + var wx: Node = world.get("weather") + var night: bool = dn != null and dn.is_night() + if wx != null and String(wx.get("state")) in ["storm", "blizzard"]: + return "storm" + if night and dn.wolf_moon_now(): + return "storm" + return "night" if night else "calm" + +static func _hostile(c: Node) -> bool: + var def: Variant = c.get("def") + return def is Dictionary and (def as Dictionary).has("damage") + +func _change(m: String) -> void: + var was := mood + mood = m + if _quiet: + return + # Into a fight: straight in. Out of one: let the fight music fade, then a + # short rest before the calmer music comes back. + if m in LOOPS: + _start_track(m, FADE_FAST) + elif was in LOOPS or was.is_empty(): + _fade_out(FADE) + _rest_left = 4.0 if not was.is_empty() else 1.5 + elif not playing.is_empty(): + _start_track(m, FADE) # (a calm piece mid-play crossfades to the night one) + else: + _rest_left = minf(_rest_left, 6.0) + +func _start_track(m: String, fade: float) -> void: + if not _tracks.has(m): + _tracks[m] = _load(DIR + m + ".ogg", m in LOOPS) + var next: AudioStreamPlayer = _b if _cur == _a else _a + var old := _cur + next.stream = _tracks[m] + next.volume_db = -40.0 + next.play() + _fade(next, _target_db(), fade) + if old.playing: + _fade(old, -60.0, fade, true) + _cur = next + playing = m + +func _fade_out(fade: float) -> void: + if _cur != null and _cur.playing: + _fade(_cur, -60.0, fade, true) + playing = "" + +func _fade(p: AudioStreamPlayer, db: float, secs: float, then_stop: bool = false) -> void: + _fading += 1 + var tw := create_tween() + tw.tween_property(p, "volume_db", db, secs) + tw.tween_callback(func(): _fading -= 1) + if then_stop: + tw.tween_callback(p.stop) + +func _on_finished(p: AudioStreamPlayer) -> void: + if p == _cur: + playing = "" + _rest_left = _rng.randf_range(REST[0], REST[1]) + +## Indoors everything's a little further away. +func _indoors() -> bool: + if world == null or not is_instance_valid(world) or world.get("player") == null: + return false + var sv: Node = world.player.get("survival") + return sv != null and bool(sv.get("sheltered")) + +func _target_db() -> float: + return -6.0 if _indoors() else -2.0 + +func _ambience(dt: float) -> void: + if _quiet or _birds == null: + return + var day := 1.0 + var wet := 0.0 + var snow := false + if world != null and is_instance_valid(world) and world.get("day_night") != null: + day = float(world.day_night.daylight) + var s := String(world.weather.get("state")) if world.get("weather") != null else "clear" + wet = 1.0 if s in ["rain", "storm", "blizzard"] else 0.0 + snow = s in ["snow", "blizzard"] + var inside := 0.35 if _indoors() else 1.0 + var birds := clampf((day - 0.35) / 0.4, 0.0, 1.0) * (1.0 - wet) * inside + var crickets := clampf((0.45 - day) / 0.3, 0.0, 1.0) * (0.0 if snow else 1.0) * (1.0 - wet * 0.8) * inside + var k := clampf(dt * 0.8, 0.0, 1.0) + _birds.volume_db = lerpf(_birds.volume_db, linear_to_db(maxf(birds * 0.8, 0.0001)), k) + _crickets.volume_db = lerpf(_crickets.volume_db, linear_to_db(maxf(crickets * 0.7, 0.0001)), k) + if _cur != null and _cur.playing and _fading == 0: + _cur.volume_db = lerpf(_cur.volume_db, _target_db(), k) diff --git a/scripts/core/sfx.gd b/scripts/core/sfx.gd new file mode 100644 index 0000000..40047f8 --- /dev/null +++ b/scripts/core/sfx.gd @@ -0,0 +1,282 @@ +extends Node +## Sound effects. Recorded ones are Kenney's CC0 packs (assets/audio/sfx/kenney, +## see assets/audio/CREDITS.md); the rest are synthesised by +## tools/audio/make_audio.py (assets/audio/sfx/synth). Each sound has a few +## variants and a little pitch spread, so repeats don't sound mechanical. +## +## - `at(id, pos)`: something happened in the world. In single-player it plays +## there. On a server (which has no speakers) it's sent to every player +## within earshot, who hear it from that spot: you hear a friend chopping. +## - `for_player(player, id)`: a sound only that player hears, close up +## (eating, getting hurt). On a server, sent to that player's game. +## - `ui(id)`: menus and the HUD, only ever on this machine. +## Headless (tests, a dedicated server) nothing is played, but every sound +## asked for is logged in `heard`. +## +## Volumes: buses Music, SFX and Ambience under Master, set in the pause menu +## and kept in user://audio.json. + +const K := "res://assets/audio/sfx/kenney/" +const S := "res://assets/audio/sfx/synth/" +const C := "res://assets/audio/sfx/creatures/" +const EARSHOT := 45.0 ## a server sends a sound to players this close + +## id -> files (or a [prefix, count] pair), volume (dB), pitch range, reach (m), +## and an optional second sound layered on top. +const SOUNDS := { + "chop": {"set": [K + "chop_%d.ogg", 5], "db": -2.0, "pitch": [0.9, 1.1]}, + "tree_fall": {"set": [K + "wood_heavy_%d.ogg", 5], "db": 0.0, "pitch": [0.6, 0.7], "reach": 60.0, "with": "creak"}, + "creak": {"set": [K + "creak_%d.ogg", 3], "db": -2.0, "pitch": [0.8, 0.95], "reach": 50.0}, + "mine": {"set": [K + "mine_%d.ogg", 5], "db": -1.0, "pitch": [0.9, 1.1]}, + "rock_break": {"set": [K + "mine_%d.ogg", 5], "db": 1.0, "pitch": [0.6, 0.7], "reach": 50.0}, + "rustle": {"set": [K + "cloth_%d.ogg", 4], "db": -4.0, "pitch": [0.7, 0.9]}, + "pickup": {"set": [K + "leather_%d.ogg", 3], "db": -6.0, "pitch": [0.95, 1.15], "reach": 15.0}, + "craft": {"set": [K + "metal_click_%d.ogg", 2], "db": -6.0, "pitch": [0.9, 1.1], "reach": 15.0, "with": "pickup"}, + "place_wood": {"set": [K + "plank_%d.ogg", 5], "db": -1.0, "pitch": [0.85, 1.05]}, + "place_stone": {"set": [K + "mine_%d.ogg", 5], "db": -3.0, "pitch": [1.1, 1.3]}, + "place_soft": {"set": [K + "cloth_%d.ogg", 4], "db": -3.0, "pitch": [0.8, 1.0], "reach": 15.0}, + "dismantle": {"set": [K + "wood_heavy_%d.ogg", 5], "db": -2.0, "pitch": [1.0, 1.15]}, + "door_open": {"set": [K + "door_open_%d.ogg", 2], "db": -3.0, "pitch": [0.95, 1.05]}, + "door_close": {"set": [K + "door_close_%d.ogg", 4], "db": -3.0, "pitch": [0.95, 1.05]}, + "swing": {"set": [S + "swing_%d.ogg", 3], "db": -9.0, "pitch": [0.9, 1.15], "reach": 12.0}, + "hit_flesh": {"set": [K + "punch_%d.ogg", 5], "db": -2.0, "pitch": [0.85, 1.05]}, + "hit_bone": {"set": [K + "wood_light_%d.ogg", 5], "db": -1.0, "pitch": [1.2, 1.4]}, + "creature_die": {"set": [K + "soft_heavy_%d.ogg", 5], "db": 0.0, "pitch": [0.7, 0.85]}, + "butcher": {"set": [K + "knife_%d.ogg", 2], "db": -3.0, "pitch": [0.9, 1.1], "reach": 15.0}, + "eat": {"set": [S + "eat_%d.ogg", 3], "db": -5.0, "pitch": [0.9, 1.15], "reach": 8.0}, + "hurt": {"set": [K + "punch_heavy_%d.ogg", 5], "db": -2.0, "pitch": [0.8, 0.95], "with": "thump"}, + "thump": {"set": [S + "thump_%d.ogg", 2], "db": -5.0, "pitch": [0.9, 1.1]}, + "death": {"files": [S + "death.ogg"], "db": -2.0, "pitch": [1.0, 1.0]}, + "cast": {"files": [S + "cast.ogg"], "db": -6.0, "pitch": [0.95, 1.1], "reach": 20.0}, + "splash": {"set": [S + "splash_%d.ogg", 2], "db": -4.0, "pitch": [0.9, 1.15], "reach": 30.0}, + "swim": {"set": [S + "splash_%d.ogg", 2], "db": -14.0, "pitch": [1.2, 1.5], "reach": 15.0}, + "bite": {"files": [S + "plop.ogg"], "db": -3.0, "pitch": [0.9, 1.1], "reach": 25.0}, + "reel": {"files": [S + "reel.ogg"], "db": -7.0, "pitch": [0.95, 1.05], "reach": 15.0}, + "catch": {"files": [S + "splash_big.ogg"], "db": -3.0, "pitch": [0.95, 1.05], "reach": 30.0}, + "bow": {"files": [S + "bow.ogg"], "db": -4.0, "pitch": [0.92, 1.08], "reach": 25.0}, + "throw": {"set": [S + "swing_%d.ogg", 3], "db": -5.0, "pitch": [0.7, 0.8], "reach": 20.0}, + "fuel": {"set": [K + "wood_light_%d.ogg", 5], "db": -4.0, "pitch": [0.8, 1.0], "reach": 15.0}, + "wear": {"set": [K + "cloth_%d.ogg", 4], "db": -4.0, "pitch": [0.9, 1.1], "reach": 10.0}, + "step_grass": {"set": [K + "step_grass_%d.ogg", 5], "db": -15.0, "pitch": [0.9, 1.1], "reach": 12.0}, + "step_snow": {"set": [K + "step_snow_%d.ogg", 5], "db": -13.0, "pitch": [0.9, 1.1], "reach": 12.0}, + "step_wood": {"set": [K + "step_wood_%d.ogg", 5], "db": -12.0, "pitch": [0.9, 1.1], "reach": 12.0}, + "step_stone": {"set": [K + "step_stone_%d.ogg", 5], "db": -13.0, "pitch": [0.9, 1.1], "reach": 12.0}, + "ui_open": {"files": [K + "ui_open_0.ogg"], "db": -10.0, "pitch": [1.0, 1.0]}, + "ui_close": {"files": [K + "ui_close_0.ogg"], "db": -10.0, "pitch": [1.0, 1.0]}, + "ui_tick": {"files": [K + "ui_tick_0.ogg"], "db": -14.0, "pitch": [0.95, 1.05]}, + "ui_good": {"files": [K + "ui_good_0.ogg"], "db": -8.0, "pitch": [1.0, 1.0]}, + "ui_step": {"files": [K + "ui_step_0.ogg"], "db": -8.0, "pitch": [1.0, 1.0]}, + "ui_catch": {"files": [K + "ui_catch_0.ogg"], "db": -8.0, "pitch": [1.0, 1.0]}, + "ui_tip": {"files": [K + "ui_tip_0.ogg"], "db": -12.0, "pitch": [1.0, 1.0]}, + # Creature voices (creature.gd's VOICES picks them). + "wolf_growl": {"set": [C + "wolf_growl_%d.ogg", 3], "db": -3.0, "pitch": [0.92, 1.08], "reach": 30.0}, + "wolf_snarl": {"set": [C + "wolf_snarl_%d.ogg", 2], "db": -1.0, "pitch": [0.92, 1.1], "reach": 25.0}, + "wolf_yelp": {"set": [C + "wolf_yelp_%d.ogg", 2], "db": -3.0, "pitch": [0.92, 1.1], "reach": 30.0}, + "alpha_growl": {"set": [C + "wolf_growl_%d.ogg", 3], "db": 0.0, "pitch": [0.68, 0.76], "reach": 40.0}, + "alpha_snarl": {"set": [C + "wolf_snarl_%d.ogg", 2], "db": 1.0, "pitch": [0.7, 0.78], "reach": 30.0}, + "alpha_yelp": {"set": [C + "wolf_yelp_%d.ogg", 2], "db": -1.0, "pitch": [0.72, 0.8], "reach": 35.0}, + "spirit_growl": {"set": [C + "wolf_growl_%d.ogg", 3], "db": -7.0, "pitch": [1.15, 1.3], "reach": 25.0}, + "spirit_snarl": {"set": [C + "wolf_snarl_%d.ogg", 2], "db": -5.0, "pitch": [1.15, 1.3], "reach": 20.0}, + "bear_roar": {"set": [C + "bear_roar_%d.ogg", 2], "db": 2.0, "pitch": [0.92, 1.05], "reach": 70.0}, + "bear_grunt": {"set": [C + "bear_grunt_%d.ogg", 2], "db": 0.0, "pitch": [0.85, 1.05], "reach": 30.0}, + "bear_hurt": {"set": [C + "bear_grunt_%d.ogg", 2], "db": 1.0, "pitch": [1.2, 1.35], "reach": 35.0}, + "boar_grunt": {"set": [C + "boar_grunt_%d.ogg", 2], "db": -3.0, "pitch": [0.9, 1.1], "reach": 22.0}, + "boar_squeal": {"files": [C + "boar_squeal_0.ogg"], "db": -2.0, "pitch": [0.9, 1.1], "reach": 35.0}, + "stag_bellow": {"set": [C + "stag_bellow_%d.ogg", 2], "db": 3.0, "pitch": [0.92, 1.05], "reach": 90.0}, + "stag_snort": {"files": [C + "stag_snort_0.ogg"], "db": -1.0, "pitch": [0.85, 1.0], "reach": 30.0}, + "guard_rattle": {"set": [C + "guard_rattle_%d.ogg", 2], "db": -2.0, "pitch": [0.85, 1.1], "reach": 25.0}, + "crow_caw": {"set": [C + "crow_caw_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.1], "reach": 40.0}, + "fox_yip": {"set": [C + "fox_yip_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.12], "reach": 30.0}, +} +const BUSES := ["Music", "SFX", "Ambience"] +const DEFAULT_VOLUMES := {"Master": 0.85, "Music": 0.6, "SFX": 0.9, "Ambience": 0.7} +const POOL_3D := 20 +const POOL_2D := 6 + +var prefs_path := "user://audio.json" +var volumes: Dictionary = DEFAULT_VOLUMES.duplicate() +var heard: Array = [] ## [id, position or null] of each sound asked for (newest last) +var ids: Array = [] ## SOUNDS' keys, sorted: a sound's number on the network +var _streams: Dictionary = {} ## id -> [AudioStream] +var _p3: Array = [] +var _p2: Array = [] +var _i3 := 0 +var _i2 := 0 +var _rng := RandomNumberGenerator.new() +var _quiet := false ## headless: log, don't play + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + ids = SOUNDS.keys() + ids.sort() + _rng.randomize() + _quiet = DisplayServer.get_name() == "headless" + for b in BUSES: + if AudioServer.get_bus_index(b) == -1: + AudioServer.add_bus() + var i := AudioServer.bus_count - 1 + AudioServer.set_bus_name(i, b) + AudioServer.set_bus_send(i, "Master") + load_prefs() + if _quiet: + return + for i in range(POOL_3D): + var p := AudioStreamPlayer3D.new() + p.bus = "SFX" + p.attenuation_model = AudioStreamPlayer3D.ATTENUATION_INVERSE_DISTANCE + p.unit_size = 4.0 + add_child(p) + _p3.append(p) + for i in range(POOL_2D): + var p := AudioStreamPlayer.new() + p.bus = "SFX" + add_child(p) + _p2.append(p) + +# --- volumes --------------------------------------------------------------------- + +func load_prefs() -> void: + volumes = DEFAULT_VOLUMES.duplicate() + if FileAccess.file_exists(prefs_path): + var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(prefs_path)) + if d is Dictionary: + for k in volumes: + if d.has(k): + volumes[k] = clampf(float(d[k]), 0.0, 1.0) + for k in volumes: + _apply_volume(k) + +func set_volume(bus: String, v: float) -> void: + volumes[bus] = clampf(v, 0.0, 1.0) + _apply_volume(bus) + var f := FileAccess.open(prefs_path, FileAccess.WRITE) + if f != null: + f.store_string(JSON.stringify(volumes, "\t")) + +func _apply_volume(bus: String) -> void: + var i := AudioServer.get_bus_index(bus) + if i == -1: + return + var v: float = volumes[bus] + AudioServer.set_bus_mute(i, v <= 0.001) + AudioServer.set_bus_volume_db(i, linear_to_db(maxf(v, 0.001))) + +# --- playing ----------------------------------------------------------------------- + +## Something happened at `pos` in the world. +func at(id: String, pos: Vector3) -> void: + if not SOUNDS.has(id): + push_warning("[sfx] no sound '%s'" % id) + return + _log(id, pos) + if Net.is_server(): + var n := ids.find(id) + for peer in Net.players: + var pp: Vector3 = Net.player_pos(int(peer)) + if pp != Vector3.INF and pp.distance_to(pos) <= float(SOUNDS[id].get("reach", 30.0)) + 5.0: + _s2c_at.rpc_id(int(peer), n, pos) + return + _play3(id, pos) + +## Only `player` hears it, close up (their own eating, getting hurt). +func for_player(player: Node, id: String) -> void: + if not SOUNDS.has(id): + return + if Net.is_server(): + _log(id, null) + if player != null and player.get("peer") != null: + _s2c_me.rpc_id(int(player.peer), ids.find(id)) + return + me(id) + +## A sound for this machine's own player (not sent anywhere). +func me(id: String) -> void: + _log(id, null) + _play2(id) + +## Menus and the HUD. +func ui(id: String) -> void: + _log(id, null) + _play2(id) + +@rpc("authority", "call_remote", "unreliable_ordered") +func _s2c_at(n: int, pos: Vector3) -> void: + if n >= 0 and n < ids.size(): + _log(ids[n], pos) + _play3(ids[n], pos) + +@rpc("authority", "call_remote", "unreliable_ordered") +func _s2c_me(n: int) -> void: + if n >= 0 and n < ids.size(): + _log(ids[n], null) + _play2(ids[n]) + +func _log(id: String, pos: Variant) -> void: + heard.append([id, pos]) + if heard.size() > 400: + heard = heard.slice(200) + +## How often `id` was asked for since the log was last cleared (tests). +func count(id: String) -> int: + var n := 0 + for h in heard: + if h[0] == id: + n += 1 + return n + +func _play3(id: String, pos: Vector3) -> void: + if _quiet or _p3.is_empty(): + return + var d: Dictionary = SOUNDS[id] + var st := _pick(id) + if st == null: + return + var p: AudioStreamPlayer3D = _p3[_i3] + _i3 = (_i3 + 1) % _p3.size() + p.stop() + p.stream = st + p.volume_db = float(d.get("db", 0.0)) + p.max_distance = float(d.get("reach", 30.0)) + p.pitch_scale = _rng.randf_range(d.pitch[0], d.pitch[1]) + p.global_position = pos + p.play() + if d.has("with"): + _play3(String(d["with"]), pos) + +func _play2(id: String) -> void: + if _quiet or _p2.is_empty(): + return + var d: Dictionary = SOUNDS[id] + var st := _pick(id) + if st == null: + return + var p: AudioStreamPlayer = _p2[_i2] + _i2 = (_i2 + 1) % _p2.size() + p.stop() + p.stream = st + p.volume_db = float(d.get("db", 0.0)) + p.pitch_scale = _rng.randf_range(d.pitch[0], d.pitch[1]) + p.play() + if d.has("with"): + _play2(String(d["with"])) + +func _pick(id: String) -> AudioStream: + if not _streams.has(id): + var got: Array = [] + for path in files(id): + var s: Variant = load(path) + if s is AudioStream: + got.append(s) + _streams[id] = got + var l: Array = _streams[id] + return l[_rng.randi() % l.size()] if not l.is_empty() else null + +## A sound's files. +static func files(id: String) -> Array: + var d: Dictionary = SOUNDS[id] + if d.has("files"): + return d.files + var out: Array = [] + for i in range(int(d.set[1])): + out.append(String(d.set[0]) % i) + return out diff --git a/scripts/creatures/creature.gd b/scripts/creatures/creature.gd index 6e80dc0..12f18dd 100644 --- a/scripts/creatures/creature.gd +++ b/scripts/creatures/creature.gd @@ -217,11 +217,14 @@ func step(dt: float) -> void: _aggro = maxf(_aggro - dt, 0.0) # Who it's reacting to: the player (or on a server, the nearest one). var t := _pick_target() + var was := state + var cd0 := _attack_cd if t != null: player = t _think(dt) elif state in ["flee", "charge"]: state = "idle" # nobody about + _voice(dt, was, cd0) _move(dt) _animate(dt) @@ -410,6 +413,13 @@ func take_hit(damage: float, from: Vector3, embed_id: String = "") -> void: hp -= damage if not embed_id.is_empty(): embedded[embed_id] = embedded.get(embed_id, 0) + 1 + Sfx.at("hit_bone" if bool(def.get("bone", false)) else "hit_flesh", global_position + Vector3(0, 0.6, 0)) + var cry := String(VOICES.get(kind, {}).get("hurt", "")) + if not cry.is_empty() and _hurt_voice_cd <= 0.0: + Sfx.at(cry, global_position + Vector3(0, 0.8, 0)) + _hurt_voice_cd = 0.6 # (a flurry of arrows: one cry, not five) + if hp <= 0.0: + Sfx.at("creature_die", global_position) _flash = 0.15 _set_material(_hit_mat) _burst() @@ -491,3 +501,60 @@ func _burst() -> void: p.global_position = global_position + Vector3(0, Meshes.build(kind).height * 0.6, 0) p.emitting = true p.finished.connect(p.queue_free) + +# --- voices (sfx.gd) --------------------------------------------------------------------- + +## What each creature sounds like: "alert" when it turns on someone, "angry" +## now and then while it's after them, "attack" as it strikes, "hurt" when hit, +## and "idle" now and then while it's minding its own business. Played by +## whoever runs the creature (single-player, or the server, which sends them on). +const VOICES := { + "wolf": {"alert": "wolf_growl", "angry": "wolf_growl", "attack": "wolf_snarl", "hurt": "wolf_yelp"}, + "alpha": {"alert": "alpha_growl", "angry": "alpha_growl", "attack": "alpha_snarl", "hurt": "alpha_yelp"}, + "spirit_wolf": {"alert": "spirit_growl", "angry": "spirit_growl", "attack": "spirit_snarl", "hurt": "spirit_growl"}, + "bear": {"alert": "bear_roar", "angry": "bear_grunt", "attack": "bear_grunt", "hurt": "bear_hurt", "idle": "bear_grunt"}, + "boar": {"alert": "boar_grunt", "angry": "boar_grunt", "attack": "boar_grunt", "hurt": "boar_squeal", "idle": "boar_grunt"}, + "old_stag": {"alert": "stag_bellow", "angry": "stag_bellow", "attack": "stag_snort", "hurt": "stag_snort"}, + "guard": {"alert": "guard_rattle", "angry": "guard_rattle", "attack": "guard_rattle"}, + "crow": {"alert": "crow_caw", "hurt": "crow_caw", "idle": "crow_caw"}, + "fox": {"alert": "fox_yip", "hurt": "fox_yip"}, +} +## States a creature is in when it's after someone (music.gd's list too). +const ANGRY := ["charge", "stalk", "lunge", "bash", "stomp", "telegraph", "warn", "raid", "summon"] +const ANGRY_EVERY := [4.0, 8.0] +const IDLE_EVERY := [18.0, 45.0] +var _voice_left := -1.0 +var _hurt_voice_cd := 0.0 +## Its own dice, so voices never change what the creature does (the +## behaviour tests replay the same choices from the same seed). +var _voice_rng := RandomNumberGenerator.new() + +func _voice(dt: float, was: String, cd0: float) -> void: + _hurt_voice_cd = maxf(_hurt_voice_cd - dt, 0.0) + var v: Dictionary = VOICES.get(kind, {}) + if v.is_empty(): + return + var mouth := global_position + Vector3(0, 0.8, 0) + if _attack_cd > cd0 + 0.05 and v.has("attack"): + Sfx.at(String(v.attack), mouth) + _voice_left = maxf(_voice_left, 1.5) + return + var angry := state in ANGRY + if angry and not (was in ANGRY) and v.has("alert"): + Sfx.at(String(v.alert), mouth) + _voice_left = _voice_rng.randf_range(ANGRY_EVERY[0], ANGRY_EVERY[1]) + return + if _voice_left < 0.0: + _voice_rng.seed = get_instance_id() + _voice_left = _voice_rng.randf_range(2.0, IDLE_EVERY[1]) # (a new creature doesn't call out at once) + _voice_left -= dt + if _voice_left > 0.0: + return + if angry and v.has("angry"): + Sfx.at(String(v.angry), mouth) + _voice_left = _voice_rng.randf_range(ANGRY_EVERY[0], ANGRY_EVERY[1]) + elif not angry and v.has("idle") and state != "rest" and state != "flee": + Sfx.at(String(v.idle), mouth) + _voice_left = _voice_rng.randf_range(IDLE_EVERY[0], IDLE_EVERY[1]) + else: + _voice_left = _voice_rng.randf_range(ANGRY_EVERY[0], ANGRY_EVERY[1]) diff --git a/scripts/creatures/old_stag.gd b/scripts/creatures/old_stag.gd index f91d26e..84ac307 100644 --- a/scripts/creatures/old_stag.gd +++ b/scripts/creatures/old_stag.gd @@ -126,6 +126,7 @@ func _think(dt: float) -> void: p.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "the Old Stag") _hit_this_charge = true charge_hits += 1 + Sfx.at("stag_snort", global_position + Vector3(0, 1.2, 0)) var left := Vector2(_target.x - global_position.x, _target.z - global_position.z) if left.dot(Vector2(_dir.x, _dir.z)) <= 0.0 or _state_t <= 0.0: state = "stunned" @@ -140,6 +141,7 @@ func _think(dt: float) -> void: for p in world.players(): if p.global_position.distance_to(global_position) < STOMP_R and not p.survival.dead: p.survival.damage(STOMP_DAMAGE * world.difficulty_mult("damage"), global_position, "the Old Stag") + Sfx.at("rock_break", global_position) stones.stomped(self) _start_circle() _: diff --git a/scripts/fishing/bobber.gd b/scripts/fishing/bobber.gd index 4a989e9..cc7b6fd 100644 --- a/scripts/fishing/bobber.gd +++ b/scripts/fishing/bobber.gd @@ -128,6 +128,7 @@ func step(dt: float) -> void: p.y = ground + 0.03 state = "landed" Actions.tell(player, "Cast into the water") + Sfx.at("splash", p) global_position = p "floating": wait_left -= dt @@ -137,6 +138,7 @@ func step(dt: float) -> void: fish = _pick_fish() bite_left = BITE_WINDOW Actions.tell(player, "Bite! Click to reel in") + Sfx.at("bite", global_position) "biting": bite_left -= dt # The float bobs hard and goes under. diff --git a/scripts/net/net.gd b/scripts/net/net.gd index ff6b4bf..0a87961 100644 --- a/scripts/net/net.gd +++ b/scripts/net/net.gd @@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day const Identity = preload("res://scripts/net/identity.gd") const ServerConfig = preload("res://scripts/net/server_config.gd") -const PROTOCOL := 12 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5) +const PROTOCOL := 13 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5); 13: sounds sent to players nearby (sfx.gd) const DEFAULT_PORT := 24242 const HANDSHAKE_TIMEOUT := 10.0 const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join diff --git a/scripts/player/hands.gd b/scripts/player/hands.gd index 6c098ca..e45dd43 100644 --- a/scripts/player/hands.gd +++ b/scripts/player/hands.gd @@ -339,6 +339,7 @@ func swing() -> void: if _swing_cd > 0.0: return _swing_cd = float(Actions.melee_stats(player).cooldown) + get_tree().create_timer(0.12).timeout.connect(func(): Sfx.me("swing")) # (as it comes down) var tw := create_tween() var wind := Transform3D(_view_rest.basis.rotated(Vector3.RIGHT, deg_to_rad(35)), _view_rest.origin + Vector3(0, 0.08, 0.05)) var strike := Transform3D(_view_rest.basis.rotated(Vector3.RIGHT, deg_to_rad(-70)), _view_rest.origin + Vector3(-0.05, -0.05, -0.15)) diff --git a/scripts/player/player.gd b/scripts/player/player.gd index 766a9eb..4213370 100644 --- a/scripts/player/player.gd +++ b/scripts/player/player.gd @@ -103,3 +103,42 @@ func _physics_process(delta: float) -> void: velocity.z = move_toward(velocity.z, 0, current_speed) move_and_slide() + _footsteps(delta, swimming) + +# --- footsteps (sfx.gd) ------------------------------------------------------------ + +const STRIDE := 1.9 ## metres between footsteps (walking; a little longer sprinting) +var _stride := 0.0 + +## A footstep every stride on the ground, by what's underfoot (a floor +## piece's wood or stone, snow up high or while it's snowing, else grass); +## a stroke now and then while swimming. Only you hear your own. +func _footsteps(dt: float, swimming: bool) -> void: + var v := Vector2(velocity.x, velocity.z).length() + if v < 1.0 or not (is_on_floor() or swimming): + _stride = minf(_stride, STRIDE * 0.6) # (the first step after stopping comes soon) + return + _stride += v * dt + var gap := STRIDE * (1.2 if v > speed + 0.5 else 1.0) * (1.6 if swimming else 1.0) + if _stride < gap: + return + _stride = 0.0 + Sfx.me("swim" if swimming else step_sound()) + +func step_sound() -> String: + var q := PhysicsRayQueryParameters3D.create(global_position + Vector3(0, 0.3, 0), global_position - Vector3(0, 0.5, 0)) + q.exclude = [get_rid()] + var hit := get_world_3d().direct_space_state.intersect_ray(q) + var c: Object = hit.get("collider") + # (a piece's collider is its body or one of its children) + for _i in range(3): + if not (c is Node): + break + if (c as Node).is_in_group("piece"): + return "step_stone" if String((c as Node).get("item_id")).contains("stone") else "step_wood" + c = (c as Node).get_parent() + var w: Node = get_parent() + var wx: Node = w.get("weather") if w != null else null + if wx != null and (String(wx.get("state")) in ["snow", "blizzard"] or global_position.y > float(wx.get("snowline")) - 1.0): + return "step_snow" + return "step_grass" diff --git a/scripts/ui/guide.gd b/scripts/ui/guide.gd index 82cdbd7..a1a2cbc 100644 --- a/scripts/ui/guide.gd +++ b/scripts/ui/guide.gd @@ -83,6 +83,7 @@ func _redraw() -> void: _progress.text = Guide.progress_text() func _on_done(_i: int, s: Dictionary) -> void: + Sfx.ui("ui_step") _tick.text = "✓ " + String(s.title) var tw := create_tween() _tick.modulate.a = 1.0 diff --git a/scripts/ui/hint_card.gd b/scripts/ui/hint_card.gd index f08014d..06f06ac 100644 --- a/scripts/ui/hint_card.gd +++ b/scripts/ui/hint_card.gd @@ -37,6 +37,7 @@ func setup() -> void: Hints.shown.connect(show_hint) func show_hint(h: Dictionary) -> void: + Sfx.ui("ui_tip") _title.text = String(h.title) _text.text = ("\"%s\"" % String(h.text)) if not Hints.guide_name.is_empty() else String(h.text) _box.visible = true diff --git a/scripts/ui/hud.gd b/scripts/ui/hud.gd index 09f7eb0..24608ac 100644 --- a/scripts/ui/hud.gd +++ b/scripts/ui/hud.gd @@ -99,6 +99,7 @@ func bind(p: CharacterBody3D) -> void: player.inventory.changed.connect(_refresh) player.selected_changed.connect(_refresh) + player.selected_changed.connect(func(): Sfx.ui("ui_tick")) player.inventory_requested.connect(_on_inventory_requested) player.hands.prompt_changed.connect(func(t): _prompt.text = t) _hurt = ColorRect.new() diff --git a/scripts/ui/inventory_screen.gd b/scripts/ui/inventory_screen.gd index 5524968..6c05725 100644 --- a/scripts/ui/inventory_screen.gd +++ b/scripts/ui/inventory_screen.gd @@ -144,12 +144,15 @@ func open(chest: Node) -> void: container.inventory.changed.connect(_refresh) visible = true player.ui_open = true + Sfx.ui("ui_open") Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _pick_tab() _rebuild_chest() _refresh() func close() -> void: + if visible: + Sfx.ui("ui_close") if WorldNet.is_client(): PlayerNet.ask("close") # the server puts the cursor back in our bag _cursor = null diff --git a/scripts/ui/pause_menu.gd b/scripts/ui/pause_menu.gd index eaf9c21..b69d9ef 100644 --- a/scripts/ui/pause_menu.gd +++ b/scripts/ui/pause_menu.gd @@ -80,6 +80,25 @@ func bind(p: CharacterBody3D) -> void: sens_row.add_child(sens) _main.add_child(sens_row) + # Volumes (sfx.gd keeps them in user://audio.json). + for row in [["Music", "Music"], ["Sounds", "SFX"], ["Ambience", "Ambience"]]: + var vr := HBoxContainer.new() + var lab := UITheme.label(row[0], 16) + lab.custom_minimum_size = Vector2(150, 0) + vr.add_child(lab) + var vs := HSlider.new() + vs.min_value = 0.0 + vs.max_value = 1.0 + vs.step = 0.05 + vs.value = float(Sfx.volumes.get(row[1], 0.8)) + vs.size_flags_horizontal = Control.SIZE_EXPAND_FILL + vs.focus_mode = Control.FOCUS_NONE + vs.accessibility_name = "%s volume" % row[0] + var bus: String = row[1] + vs.value_changed.connect(func(v: float): Sfx.set_volume(bus, v)) + vr.add_child(vs) + _main.add_child(vr) + # Old Tam's tips in normal worlds (T4): on or off, or all of them again. if not Guide.active: var tips_row := HBoxContainer.new() diff --git a/scripts/world/world.gd b/scripts/world/world.gd index e935d49..b8ca434 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -147,6 +147,9 @@ func _ready() -> void: hud.bind(player) if not menu_mode and not server_mode: player.survival.died.connect(_on_player_died) + player.survival.hurt.connect(func(_amount, _from): Sfx.me("hurt")) + if not server_mode: + Music.start(self) # (the title screen's backdrop world too: calm music) if Saves.has_world() and not menu_mode and not server_mode: # Save on window close instead of quitting straight away. get_tree().auto_accept_quit = false @@ -188,6 +191,7 @@ func heat_at(pos: Vector3, holder: Node = null) -> float: ## wake up at the island start. The world carries on. func _on_player_died() -> void: Hints.event("died") + Sfx.me("death") var pos := player.global_position pos.y = terrain.sample_height(pos.x, pos.z) if pos.y < terrain.water_level: @@ -247,6 +251,7 @@ func respawn_after_death() -> void: ## Online: the server says we died. Same screen; Respawn asks the server. func died_online(text: String, has_items: bool, to_bed: bool, penalty: String = "grave") -> void: Hints.event("died") + Sfx.me("death") dead_waiting = true player.velocity = Vector3.ZERO player.set_physics_process(false) @@ -561,6 +566,7 @@ func _exit_tree() -> void: Guide.stop() if Hints.world == self: Hints.stop() + Music.stop(self) func _notification(what: int) -> void: if what != NOTIFICATION_WM_CLOSE_REQUEST or menu_mode: diff --git a/tests/net_world_bot.gd b/tests/net_world_bot.gd index b26fe99..c16dfd0 100644 --- a/tests/net_world_bot.gd +++ b/tests/net_world_bot.gd @@ -179,7 +179,8 @@ static func _pickup(w: Node3D, WN: Node, key: String) -> Node3D: static func report(w: Node3D, WN: Node, cmd: Dictionary, corr: int) -> Dictionary: var out := {"nodes": {}, "pieces": {}, "taken": {}, "inv": w.player.inventory.to_data(), - "received": WN.received, "corrections": corr, "dropped": []} + "received": WN.received, "corrections": corr, "dropped": [], + "sounds": w.get_tree().root.get_node("Sfx").heard.map(func(h): return h[0])} for uid in cmd.get("uids", []): var n: Node3D = w.props.resource_nodes.get(uid) out.nodes[uid] = {"hp": n.hp, "depleted": n.depleted} if n != null else null diff --git a/tests/run_all.sh b/tests/run_all.sh index 2c50979..36f4403 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then echo "Importing the project first (fresh copy)..." godot --headless --path . --import >/dev/null 2>&1 fi -for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_host test_tutorial test_tutorial_full test_hints test_updates test_terrain_edges test_boundary; do +for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_terrain_edges test_boundary; do extra="" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30" # A suite that waits on a world that failed to load would hang forever, diff --git a/tests/test_audio.gd b/tests/test_audio.gd new file mode 100644 index 0000000..b166401 --- /dev/null +++ b/tests/test_audio.gd @@ -0,0 +1,280 @@ +extends SceneTree +## Sound and music (scripts/core/sfx.gd, scripts/core/music.gd): every sound +## and track loads; doing things makes the right sounds (chop, a tree +## falling, mining, crafting, placing, eating, getting hurt, fishing, +## footsteps on grass and on a wooden floor); the music follows the mood +## (day, night, storm, the red moon, wolves after you, the Old Stag), holds on +## a moment after danger; and the volumes are kept. Headless, so nothing is +## heard: Sfx logs what was asked for. +## Run: godot --headless --path . --script res://tests/test_audio.gd + +var _fails := 0 +var _finished := false +var A: Node +var S: Node +var SX: Node +var M: Node +var world: Node3D +var player: CharacterBody3D + +func check(cond: bool, what: String) -> void: + print((" ok " if cond else " FAIL ") + what) + if not cond: + _fails += 1 + +func _initialize() -> void: + _run.call_deferred() + +func _run() -> void: + A = root.get_node("Actions") + S = root.get_node("Saves") + SX = root.get_node("Sfx") + M = root.get_node("Music") + SX.prefs_path = "user://test_audio.json" + DirAccess.remove_absolute(SX.prefs_path) + S.root_dir = "user://test_worlds" + for w in S.list_worlds(): + S.delete_world(w.id) + await _body() + check(_finished, "test ran to the end (no script errors)") + for w in S.list_worlds(): + S.delete_world(w.id) + DirAccess.remove_absolute(S.root_dir) + DirAccess.remove_absolute(SX.prefs_path) + SX.load_prefs() + print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) + quit(1 if _fails > 0 else 0) + +func _heard(id: String, since: int) -> int: + var n := 0 + for i in range(since, SX.heard.size()): + if SX.heard[i][0] == id: + n += 1 + return n + +func _node(kind: String) -> Node3D: + var best: Node3D = null + var bd := INF + for n in world.props.resource_nodes.values(): + if is_instance_valid(n) and n.kind == kind and not n.depleted: + var d: float = n.global_position.distance_to(player.global_position) + if d < bd: + bd = d + best = n + return best + +func _hold(id: String) -> int: + for i in range(player.inventory.size()): + var s = player.inventory.get_slot(i) + if s != null and s.id == id: + var tmp = player.inventory.get_slot(0) + player.inventory.set_slot(0, s) + player.inventory.set_slot(i, tmp) + player.select(0) + return 0 + return -1 + +func _body() -> void: + print("the files") + var bad: Array = [] + for id in SX.SOUNDS: + for f in SX.files(id): + var st: Variant = load(f) + if not (st is AudioStream) or (st as AudioStream).get_length() < 0.02: + bad.append(f) + check(bad.is_empty(), "every sound's files load (%d sounds) %s" % [SX.SOUNDS.size(), bad]) + var lens: Array = [] + for m in ["calm", "night", "storm", "danger", "boss"]: + var st: Variant = load(M.DIR + m + ".ogg") + lens.append(roundi(st.get_length()) if st is AudioStream else 0) + check(lens.all(func(l): return l >= 40), "five music tracks, 40 s or more each %s" % [lens]) + var amb := ["birds", "crickets", "fire"].filter(func(a): return load(M.AMB + a + ".ogg") is AudioStream) + check(amb.size() == 3, "birds, crickets and the fire's crackle") + check(AudioServer.get_bus_index("Music") > 0 and AudioServer.get_bus_index("SFX") > 0 and AudioServer.get_bus_index("Ambience") > 0, + "Music, SFX and Ambience buses") + + print("in the world") + S.current_id = S.create_world("Sounds", 1337, {}) + world = load("res://scenes/world.tscn").instantiate() + root.add_child(world) + for _i in range(5): + await physics_frame + player = world.player + player.set_physics_process(false) + player.survival.set_process(false) + world.day_night.frozen = true + world.weather.set_process(false) + world.weather.force("clear") + world.creatures.enabled = false + if world.wolves != null: + world.wolves.enabled = false + var inv: Inventory = player.inventory + inv.add("stone_axe", 1) + inv.add("stone_pickaxe", 1) + + var h: int = SX.heard.size() + var tree := _node("tree") + _hold("stone_axe") + player.global_position = tree.global_position + Vector3(1.2, 0, 0) + var hits := 0 + while not tree.depleted and hits < 40: + player.survival.stamina = 100.0 + if A.harvest_hit(player, tree, tree.global_position + Vector3(0, 1.0, 0)): + hits += 1 + check(_heard("chop", h) == hits - 1 and _heard("tree_fall", h) == 1, "an axe on a tree: chop x%d, then it falls" % (hits - 1)) + h = SX.heard.size() + var rock := _node("rock") + _hold("stone_pickaxe") + player.global_position = rock.global_position + Vector3(1.6, 0, 0) + player.survival.stamina = 100.0 + A.harvest_hit(player, rock, rock.global_position + Vector3(0, 0.5, 0)) + check(_heard("mine", h) == 1, "a pickaxe on a boulder: mine") + var at: Vector3 = SX.heard.back()[1] + check(at is Vector3 and at.distance_to(rock.global_position) < 2.0, "heard where it happened") + + h = SX.heard.size() + var rec: Dictionary = root.get_node("ItemDB").recipes["stone_axe"] + for id in rec.inputs: + inv.add(id, int(rec.inputs[id])) + check(A.craft(player, "stone_axe") and _heard("craft", h) == 1, "crafting") + inv.add("wood_floor", 1) + var slot := _hold("wood_floor") + var p0 := player.global_position + Vector3(4, 0, 0) + p0.y = world.terrain.sample_height(p0.x, p0.z) + var r: Dictionary = world.build.placement(ItemDB_get("wood_floor"), {"position": p0, "normal": Vector3.UP, + "collider": world.terrain.get_node("TerrainBody")}, 0.0) + check(r.ok and A.place(player, slot, r.xf, world) and _heard("place_wood", h) == 1, "placing a floor: a plank's knock") + + inv.add("berries", 3) + player.survival.hunger = 40.0 + A.eat(player, _hold("berries")) + check(_heard("eat", h) == 1, "eating") + player.survival.damage(5.0, player.global_position + Vector3(1, 0, 0), "a test") + check(_heard("hurt", h) == 1, "getting hurt") + SX.heard.clear() + + print("footsteps") + check(player.step_sound() == "step_grass", "on the meadow: grass") + var floor_piece: Node3D = null + for pc in world.all_pieces(): + if pc.item_id == "wood_floor": + floor_piece = pc + player.set_physics_process(true) + player.global_position = floor_piece.global_position + Vector3(0, 0.6, 0) + player.velocity = Vector3.ZERO + for _i in range(20): + await physics_frame + check(player.step_sound() == "step_wood", "on the floor you built: wood") + player.global_position = p0 + Vector3(8, 0.3, 0) + for _i in range(10): + await physics_frame + player.set_physics_process(false) + var steps0: int = SX.count("step_grass") + player.velocity = Vector3(5, 0, 0) + for _i in range(60): + player._footsteps(1.0 / 60.0, false) if player.is_on_floor() else null + player.velocity = Vector3.ZERO + var steps: int = SX.count("step_grass") - steps0 + check(steps >= 2 and steps <= 3, "walking 5 m: a step every stride (%d)" % steps) + + print("fishing") + SX.heard.clear() + inv.add("fishing_rod", 1) + _hold("fishing_rod") + player.survival.stamina = 100.0 + A.cast_line(player, player.global_position + Vector3(0, 1.4, 0), Vector3(1, 0.2, 0), world) + var bob: Node3D = world.bobber_of(player) + check(SX.count("cast") == 1 and bob != null, "a cast") + if bob != null: + bob.state = "biting" + bob.fish = "perch" + A.reel(player, world) + check(SX.count("reel") == 1 and SX.count("catch") == 1 and SX.count("ui_catch") == 1, "reeling a fish in: the reel, a splash, and a chime") + + print("creature voices") + SX.heard.clear() + var w2: Node3D = world.creatures.spawn("wolf", player.global_position + Vector3(9, 0, 0)) + for _i in range(2): + await physics_frame + w2.set_physics_process(false) + w2.state = "wander" + w2._voice(0.1, "wander", 0.0) + w2.state = "stalk" + w2._voice(0.1, "wander", 0.0) + check(SX.count("wolf_growl") == 1, "a wolf turns on you: it growls") + for _i in range(20): + w2._voice(0.5, "stalk", 0.0) + check(SX.count("wolf_growl") >= 2 and SX.count("wolf_growl") <= 4, "and keeps growling now and then while it stalks (%d)" % SX.count("wolf_growl")) + w2._attack_cd = 1.2 + w2._voice(0.1, "stalk", 0.0) + check(SX.count("wolf_snarl") == 1, "it lunges: a snarl") + w2.take_hit(5.0, player.global_position) + check(SX.count("wolf_yelp") == 1, "hit it: it yelps") + w2.take_hit(5.0, player.global_position) + check(SX.count("wolf_yelp") == 1, "(hit twice at once: one yelp)") + w2.queue_free() + var missing: Array = [] + for k in w2.VOICES: + for what in w2.VOICES[k]: + if not SX.SOUNDS.has(String(w2.VOICES[k][what])): + missing.append("%s.%s" % [k, what]) + check(missing.is_empty(), "every creature's voice is a sound that exists %s" % [missing]) + + print("the music") + M.start(world) + check(M.decide() == "calm", "a clear morning: calm") + world.day_night.time = 23.0 / 24.0 + world.day_night.apply(true) + check(M.decide() == "night", "after dark: night") + world.day_night.day = 7 + check(M.decide() == "storm", "the red wolf moon: the storm music") + world.day_night.day = 5 + world.weather.force("storm") + world.day_night.time = 13.0 / 24.0 + world.day_night.apply(true) + check(M.decide() == "storm", "a storm by day: storm") + world.weather.force("clear") + var wolf: Node3D = world.creatures.spawn("wolf", player.global_position + Vector3(12, 0, 0)) + for _i in range(3): + await physics_frame + wolf.set_physics_process(false) + wolf.state = "wander" + check(M.decide() == "calm", "a wolf wandering nearby: still calm") + wolf.state = "stalk" + M.step(0.6) + check(M.mood == "danger", "the wolf comes for you: danger") + wolf.queue_free() + await process_frame + for _i in range(10): + M.step(0.5) + check(M.mood == "danger", "it's gone, but the music holds on a moment") + for _i in range(20): + M.step(0.5) + check(M.mood == "calm", "then calm again") + var stag: Node3D = world.creatures.spawn("rabbit", player.global_position + Vector3(30, 0, 0)) + for _i in range(2): + await physics_frame + stag.set_physics_process(false) + stag.kind = "old_stag" + M.step(0.6) + check(M.mood == "boss", "the Old Stag is up: the boss music") + stag.queue_free() + M.stop(world) + M.step(0.6) + check(M.mood == "calm", "back at the title: calm") + + print("volumes") + SX.set_volume("Music", 0.25) + SX.set_volume("SFX", 0.0) + var mb := AudioServer.get_bus_index("Music") + check(absf(AudioServer.get_bus_volume_db(mb) - linear_to_db(0.25)) < 0.01, "Music at a quarter") + check(AudioServer.is_bus_mute(AudioServer.get_bus_index("SFX")), "Sounds at zero: muted") + SX.load_prefs() + check(absf(float(SX.volumes.Music) - 0.25) < 0.001 and float(SX.volumes.SFX) == 0.0, "kept for next time") + + world.queue_free() + await process_frame + _finished = true + +func ItemDB_get(id: String) -> Dictionary: + return root.get_node("ItemDB").get_item(id) diff --git a/tests/test_net_shared.gd b/tests/test_net_shared.gd index 866436b..9261f68 100644 --- a/tests/test_net_shared.gd +++ b/tests/test_net_shared.gd @@ -372,9 +372,15 @@ func _items_on_the_ground() -> void: var rep := await _bob({"do": "report"}) check(rep.dropped.any(func(d): return d.item == "flint" and int(d.count) == 2), "Bob sees it") await _bob({"do": "walk", "to": _v(at + Vector3(1.0, 0, 0))}) + var SX: Node = root.get_node("Sfx") + var heard0: int = SX.count("pickup") var took := await _bob({"do": "take", "key": key}) check(_count(took.get("inv", []), "flint") == 2, "Bob picks it up") await _sleep(0.3) + check(SX.count("pickup") > heard0, "Alice hears him pick it up (the server sends the sound to players nearby)") + var brep := await _bob({"do": "report"}) + check(brep.get("sounds", []).has("pickup"), "and so does Bob") + await _sleep(0.3) check(WN.dropped.is_empty() or not WN.dropped.has(int(key.substr(1))), "and it's gone for Alice") print("first come, first served") diff --git a/tools/audio/__pycache__/make_audio.cpython-314.pyc b/tools/audio/__pycache__/make_audio.cpython-314.pyc new file mode 100644 index 0000000..96be057 Binary files /dev/null and b/tools/audio/__pycache__/make_audio.cpython-314.pyc differ diff --git a/tools/audio/make_audio.py b/tools/audio/make_audio.py new file mode 100644 index 0000000..e541ea2 --- /dev/null +++ b/tools/audio/make_audio.py @@ -0,0 +1,783 @@ +#!/usr/bin/env python3 +"""Emberwild's own sounds: the music, the ambience beds and the sound effects +that the Kenney CC0 packs don't cover, all synthesised here (no samples, no +third-party audio), so they're ours to release under CC0 too. + + python3 tools/audio/make_audio.py # everything + python3 tools/audio/make_audio.py music # or: music | ambience | sfx | voices + python3 tools/audio/make_audio.py --kenney ~/Downloads/kenney # also re-copy the Kenney files + +Writes .ogg files under assets/audio/ (via ffmpeg). Deterministic: the same +seeds give the same files. Pure Python (no numpy), so it takes a minute or two. + +Music (assets/audio/music/): calm (day), night, storm, danger, boss. Each is +a seamless loop: the tail of the last bar is folded back onto the start. +""" +import math, os, random, shutil, struct, subprocess, sys, tempfile, wave + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +OUT = os.path.join(ROOT, "assets", "audio") +TAU = 2.0 * math.pi +sin, exp = math.sin, math.exp + + +def midi(m): + return 440.0 * 2.0 ** ((m - 69) / 12.0) + + +# --- writing ----------------------------------------------------------------------- + +def _encode(path, chans, sr, q=4): + """chans: [left, right] or [mono] float lists. Peak-normalised, soft-clipped.""" + peak = max(max(abs(x) for x in c) for c in chans) or 1.0 + g = 0.89 / peak + tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name + with wave.open(tmp, "wb") as w: + w.setnchannels(len(chans)) + w.setsampwidth(2) + w.setframerate(sr) + frames = bytearray() + n = len(chans[0]) + for i in range(n): + for c in chans: + v = math.tanh(c[i] * g * 1.05) / math.tanh(1.05) + frames += struct.pack("= 1.0: + ph -= 1.0 + y1 += lp * (s - y1) + y2 += lp * (y1 - y2) + buf[(i0 + j) % L] += y2 * amp * e + + +def add_drum(buf, sr, t0, f0, amp, drop=1.6, fall=28.0, decay=8.0, noise=0.15, dur=0.7, rng=None): + """Toms, kicks and timpani: a pitch-dropping sine with a noisy start.""" + i0 = int(t0 * sr) + n = int(dur * sr) + L = len(buf) + ph = 0.0 + y = 0.0 + rng = rng or random.Random(int(t0 * 1000)) + for j in range(n): + t = j / sr + f = f0 * (1.0 + drop * exp(-t * fall)) + ph += TAU * f / sr + s = sin(ph) * exp(-t * decay) + y += 0.3 * ((rng.random() * 2 - 1) - y) + s += noise * y * exp(-t * 30.0) + buf[(i0 + j) % L] += s * amp + + +def add_noise(buf, sr, t0, dur, amp, lp=0.2, hp=0.0, env=None, rng=None): + """Filtered noise with an envelope function env(k) for k in 0..1.""" + i0 = int(t0 * sr) + n = int(dur * sr) + L = len(buf) + rng = rng or random.Random(int(t0 * 977) + 3) + y = z = 0.0 + for j in range(n): + x = rng.random() * 2 - 1 + y += lp * (x - y) + s = y + if hp: + z += hp * (s - z) + s = s - z + e = env(j / n) if env else 1.0 + buf[(i0 + j) % L] += s * amp * e + + +def echo(src, sr, delay, fb, mix, loops=2): + """A circular (loop-safe) feedback delay; returns the wet signal.""" + L = len(src) + d = int(delay * sr) + wet = [0.0] * L + for _ in range(loops): + for i in range(L): + wet[i] = src[(i - d) % L] * mix + wet[(i - d) % L] * fb + return wet + + +def lowpass(src, a): + y = 0.0 + out = [0.0] * len(src) + for _ in range(2): # (twice round, so the loop point has no click) + for i in range(len(src)): + y += a * (src[i] - y) + out[i] = y + return out + + +# --- the music --------------------------------------------------------------------- + +def _mix(sr, L, dry_l, dry_r, send, d_l=0.37, d_r=0.29, fb=0.38, mix=0.5): + wl = echo(send, sr, d_l, fb, mix) + wr = echo(send, sr, d_r, fb, mix) + return ([dry_l[i] + send[i] + wl[i] for i in range(L)], + [dry_r[i] + send[i] + wr[i] for i in range(L)]) + + +def track_calm(): + """Day: D major, 72 bpm, a warm pad, soft bass, and a sparse harp melody.""" + sr, bpm, bars = 22050, 72, 16 + beat = 60.0 / bpm + L = int(bars * 4 * beat * sr) + rng = random.Random(11) + pad, bass, mel = [0.0] * L, [0.0] * L, [0.0] * L + # D, Bm, G, A, D, F#m, G, A: two bars each. + chords = [(62, 66, 69), (59, 62, 66), (55, 59, 62), (57, 61, 64), + (62, 66, 69), (54, 57, 61), (55, 59, 62), (57, 61, 64)] + roots = [38, 35, 43, 45, 38, 42, 43, 45] + scale = [74, 76, 78, 81, 83, 86, 88] # D pentatonic, upper + for c, ch in enumerate(chords): + t = c * 8 * beat + add_pad(pad, sr, t, 8 * beat, [midi(m) for m in ch], 0.55, attack=1.8, release=2.2, lp=0.18) + add_pluck(bass, sr, t, midi(roots[c]), 0.30, decay=0.9, harm=(1.0, 0.25)) + add_pluck(bass, sr, t + 4 * beat, midi(roots[c]), 0.22, decay=0.9, harm=(1.0, 0.25)) + prev = 3 + for b in range(8): + if (c % 2 == 1 and b >= 6) or rng.random() > 0.55: + continue + prev = max(0, min(len(scale) - 1, prev + rng.choice([-2, -1, -1, 1, 1, 2]))) + m = scale[prev] + off = rng.choice([0.0, 0.0, 0.5]) * beat + add_pluck(mel, sr, t + b * beat + off, midi(m), 0.26 + rng.random() * 0.08, decay=2.0) + if rng.random() < 0.18: + add_pluck(mel, sr, t + b * beat + off + 0.5 * beat, midi(m + 12), 0.1, decay=3.0, + harm=(1.0, 0.1, 0.05)) + dry = [pad[i] + bass[i] for i in range(L)] + return sr, _mix(sr, L, dry, dry, mel) + + +def track_night(): + """Night: A minor, 56 bpm, dark pad, a low drone and a few distant notes.""" + sr, bpm, bars = 22050, 56, 12 + beat = 60.0 / bpm + L = int(bars * 4 * beat * sr) + rng = random.Random(23) + pad, low, mel = [0.0] * L, [0.0] * L, [0.0] * L + chords = [(57, 60, 64), (53, 57, 60), (48, 52, 55), (55, 59, 62), (57, 60, 64), (52, 55, 59)] + for c, ch in enumerate(chords): + t = c * 8 * beat + add_pad(pad, sr, t, 8 * beat, [midi(m) for m in ch], 0.5, attack=2.5, release=3.0, bright=0.1, lp=0.08) + add_pad(low, sr, 0.0, L / sr - 3.0, [midi(33), midi(45)], 0.35, attack=3.0, release=3.0, bright=0.05, detune=0.001) + scale = [69, 72, 74, 76, 79, 81] # A minor pentatonic + t = 1.5 * beat + while t < L / sr - 2.0: + m = rng.choice(scale) + add_pluck(mel, sr, t, midi(m), 0.18, decay=1.4, harm=(1.0, 0.2, 0.06)) + if rng.random() < 0.3: + add_pluck(mel, sr, t + beat, midi(rng.choice(scale) - 12), 0.14, decay=1.2) + t += beat * rng.choice([3, 4, 4, 5, 6]) + dry = [pad[i] + low[i] for i in range(L)] + return sr, _mix(sr, L, dry, dry, mel, d_l=0.53, d_r=0.41, fb=0.45, mix=0.55) + + +def track_storm(): + """Storm, blizzard, the red moon: D minor, slow, tremolo low strings, rolls.""" + sr, bpm, bars = 22050, 60, 12 + beat = 60.0 / bpm + L = int(bars * 4 * beat * sr) + rng = random.Random(37) + pad, strings, drums, wind = [0.0] * L, [0.0] * L, [0.0] * L, [0.0] * L + chords = [(50, 53, 57), (46, 50, 53), (43, 46, 50), (45, 49, 52), (50, 53, 57), (46, 50, 55)] + roots = [26, 22, 31, 33, 26, 22] + for c, ch in enumerate(chords): + t = c * 8 * beat + add_pad(pad, sr, t, 8 * beat, [midi(m) for m in ch], 0.45, attack=2.0, release=2.0, bright=0.2, lp=0.12) + add_saw(strings, sr, t, 8 * beat - 0.2, midi(roots[c] + 12), 0.5, lp=0.05, attack=1.0, release=1.0, + trem=0.6, trem_hz=7.0) + add_saw(strings, sr, t, 8 * beat - 0.2, midi(roots[c]), 0.35, lp=0.03, attack=1.5, release=1.0) + # a timpani roll swelling into each second chord + if c % 2 == 1: + n = 22 + for k in range(n): + tt = t + 6 * beat + k * (2 * beat / n) + add_drum(drums, sr, tt, midi(roots[c] + 12) * 0.98, 0.1 + 0.5 * (k / n) ** 2, drop=0.2, + fall=20, decay=6, noise=0.2, dur=0.5, rng=rng) + add_drum(drums, sr, t, midi(roots[c] + 12), 0.8, drop=0.3, fall=18, decay=3.0, dur=1.6, rng=rng) + add_noise(wind, sr, 0.0, L / sr, 0.25, lp=0.02, + env=lambda k: 0.6 + 0.4 * sin(TAU * 3 * k) * sin(TAU * 5 * k + 1.0)) + dry = [pad[i] + strings[i] + drums[i] + wind[i] for i in range(L)] + send = [0.25 * strings[i] for i in range(L)] + return sr, _mix(sr, L, dry, dry, send, fb=0.3, mix=0.4) + + +def _action(bpm, bars, seed, boss): + sr = 22050 + beat = 60.0 / bpm + L = int(bars * 4 * beat * sr) + rng = random.Random(seed) + pad, ost, drums, brass, fx = [0.0] * L, [0.0] * L, [0.0] * L, [0.0] * L, [0.0] * L + # E minor: Em, C, D, B (with D#), then Em, Am, C, B. + prog = [(40, (52, 55, 59)), (36, (48, 52, 55)), (38, (50, 54, 57)), (35, (47, 51, 54)), + (40, (52, 55, 59)), (33, (45, 48, 52)), (36, (48, 52, 55)), (35, (47, 51, 54))] + figure = [0, 0, 12, 0, 7, 0, 10, 7] if not boss else [0, 12, 0, 7, 0, 12, 10, 7] + for bar in range(bars): + root, ch = prog[bar % len(prog)] + t = bar * 4 * beat + build = bar >= bars - 4 + add_pad(pad, sr, t, 4 * beat, [midi(m) for m in ch], 0.35, attack=0.3, release=0.6, bright=0.3, lp=0.15) + step = beat / 2 + for k in range(8): + m = root + 12 + figure[k] + add_saw(ost, sr, t + k * step, step * 0.7, midi(m), 0.42 if k % 2 == 0 else 0.3, + lp=0.09 if boss else 0.07, attack=0.004, release=0.06) + # drums: low tom on 1 and 3, a higher one on the "and" of 4, shaker 8ths + add_drum(drums, sr, t, 70, 0.9, drop=1.2, decay=7, rng=rng) + add_drum(drums, sr, t + 2 * beat, 70, 0.75, drop=1.2, decay=7, rng=rng) + add_drum(drums, sr, t + 3.5 * beat, 105, 0.55, drop=1.0, decay=9, rng=rng) + if boss: + for b in range(4): + add_drum(drums, sr, t + b * beat, 48, 0.8, drop=2.5, fall=35, decay=10, noise=0.05, rng=rng) + if build or (boss and bar % 4 == 3): + for k in range(8): + add_drum(drums, sr, t + 2 * beat + k * beat / 4, 120 + k * 8, 0.25 + k * 0.05, decay=10, rng=rng) + for k in range(8): + add_noise(drums, sr, t + k * step, 0.06, 0.12 if k % 2 else 0.06, lp=0.9, hp=0.4, + env=lambda q: exp(-q * 6), rng=rng) + if boss: + for m in ch: + add_saw(brass, sr, t, 4 * beat - 0.1, midi(m), 0.22, lp=0.06, attack=0.25, release=0.3) + if bar % 4 == 3: + add_noise(fx, sr, t, 4 * beat, 0.35, lp=0.6, hp=0.2, env=lambda q: q ** 3, rng=rng) + dry = [pad[i] + ost[i] + drums[i] + brass[i] + fx[i] for i in range(L)] + send = [0.2 * ost[i] + 0.3 * brass[i] for i in range(L)] + return sr, _mix(sr, L, dry, dry, send, d_l=beat * 0.75, d_r=beat * 0.5, fb=0.25, mix=0.35) + + +def track_danger(): + """Wolves at the fire, a bear, a fight: E minor, 104 bpm, ostinato and toms.""" + return _action(104, 20, 41, False) + + +def track_boss(): + """The Old Stag: the danger theme, faster, with brass, kicks and swells.""" + return _action(116, 24, 53, True) + + +# --- ambience --------------------------------------------------------------------- + +def amb_birds(): + sr, secs = 22050, 32 + L = sr * secs + rng = random.Random(61) + l, r = [0.0] * L, [0.0] * L + add_noise(l, sr, 0, secs, 0.06, lp=0.03) + add_noise(r, sr, 0, secs, 0.06, lp=0.03, rng=random.Random(5)) + t = 0.4 + while t < secs - 1.0: + pan = rng.random() + base = rng.uniform(2400, 4200) + kind = rng.randrange(3) + n = rng.randint(2, 6) + tt = t + for k in range(n): + d = rng.uniform(0.05, 0.11) + f0 = base * rng.uniform(0.9, 1.15) + f1 = f0 * (rng.uniform(0.7, 0.85) if kind == 0 else rng.uniform(1.15, 1.35) if kind == 1 else 1.0) + nn = int(d * sr) + ph = 0.0 + a = rng.uniform(0.15, 0.3) + for j in range(nn): + q = j / nn + ph += TAU * (f0 + (f1 - f0) * q) / sr + e = sin(math.pi * q) ** 2 + s = sin(ph) * e * a + i = (int(tt * sr) + j) % L + l[i] += s * (1 - pan) + r[i] += s * pan + tt += d + rng.uniform(0.02, 0.09) + t += rng.uniform(0.6, 2.6) + return sr, (l, r) + + +def amb_crickets(): + sr, secs = 22050, 30 + L = sr * secs + rng = random.Random(71) + l, r = [0.0] * L, [0.0] * L + add_noise(l, sr, 0, secs, 0.04, lp=0.02) + add_noise(r, sr, 0, secs, 0.04, lp=0.02, rng=random.Random(9)) + for c in range(4): + pan = rng.random() + f = rng.uniform(3800, 4700) + period = rng.uniform(0.5, 0.9) + pulses = rng.randint(3, 5) + amp = rng.uniform(0.06, 0.14) + t = rng.random() * period + while t < secs: + for p in range(pulses): + nn = int(0.018 * sr) + i0 = int((t + p * 0.032) * sr) + for j in range(nn): + e = sin(math.pi * j / nn) + s = sin(TAU * f * j / sr) * e * amp + l[(i0 + j) % L] += s * (1 - pan) + r[(i0 + j) % L] += s * pan + t += period * rng.uniform(0.95, 1.05) + if rng.random() < 0.08: + t += rng.uniform(1.0, 3.0) # (they pause now and then) + return sr, (l, r) + + +def amb_fire(): + sr, secs = 22050, 6 + L = sr * secs + rng = random.Random(83) + b = [0.0] * L + add_noise(b, sr, 0, secs, 0.35, lp=0.015, env=lambda k: 0.8 + 0.2 * sin(TAU * 4 * k)) + add_noise(b, sr, 0, secs, 0.06, lp=0.3, hp=0.1, env=lambda k: 0.6 + 0.4 * sin(TAU * 7 * k + 2)) + t = 0.0 + while t < secs: + a = rng.uniform(0.2, 0.9) if rng.random() < 0.85 else rng.uniform(1.0, 1.6) + add_noise(b, sr, t, rng.uniform(0.004, 0.02), a, lp=0.8, hp=0.3, env=lambda q: exp(-q * 5), rng=rng) + t += rng.expovariate(9.0) + return sr, (b,) + + +# --- sound effects ------------------------------------------------------------------ + +SR_FX = 44100 + + +def fx_buf(secs): + return [0.0] * int(secs * SR_FX) + + +def fx_whoosh(seed, secs=0.32, f0=0.04, f1=0.25, amp=1.0): + rng = random.Random(seed) + b = fx_buf(secs + 0.05) + y = z = 0.0 + n = int(secs * SR_FX) + for j in range(n): + q = j / n + a = f0 + (f1 - f0) * sin(math.pi * q) + x = rng.random() * 2 - 1 + y += a * (x - y) + z += 0.03 * (y - z) + b[j] = (y - z) * sin(math.pi * q) ** 1.5 * amp + return b + + +def fx_crunch(seed): + rng = random.Random(seed) + b = fx_buf(0.5) + t = 0.0 + for k in range(rng.randint(3, 4)): + d = rng.uniform(0.05, 0.09) + add_noise(b, SR_FX, t, d, rng.uniform(0.7, 1.0), lp=rng.uniform(0.35, 0.7), hp=0.05, + env=lambda q: exp(-q * 7) * min(1.0, q * 40), rng=rng) + for _ in range(6): + add_noise(b, SR_FX, t + rng.uniform(0, d), 0.004, rng.uniform(0.4, 0.9), lp=0.9, hp=0.3, + env=lambda q: 1 - q, rng=rng) + t += d + rng.uniform(0.04, 0.07) + return b + + +def fx_splash(seed, big=1.0): + rng = random.Random(seed) + b = fx_buf(0.9) + add_noise(b, SR_FX, 0.0, 0.7, 0.9 * big, lp=0.25, hp=0.02, env=lambda q: exp(-q * 5) * min(1, q * 60), rng=rng) + add_noise(b, SR_FX, 0.0, 0.25, 0.5 * big, lp=0.7, hp=0.2, env=lambda q: exp(-q * 9), rng=rng) + for _ in range(int(10 * big)): + t = rng.uniform(0.05, 0.6) + f = rng.uniform(380, 1100) + nn = int(0.05 * SR_FX) + i0 = int(t * SR_FX) + ph = 0.0 + for j in range(nn): + q = j / nn + ph += TAU * f * (1 + 0.8 * q) / SR_FX + if i0 + j < len(b): + b[i0 + j] += sin(ph) * sin(math.pi * q) * 0.12 + return b + + +def fx_plop(): + b = fx_buf(0.3) + ph = 0.0 + n = int(0.16 * SR_FX) + for j in range(n): + q = j / n + ph += TAU * (650 - 430 * q) / SR_FX + b[j] = sin(ph) * exp(-q * 4) * min(1, q * 80) * 0.9 + add_noise(b, SR_FX, 0.0, 0.12, 0.25, lp=0.4, hp=0.1, env=lambda q: exp(-q * 8)) + return b + + +def fx_reel(): + rng = random.Random(97) + b = fx_buf(0.9) + for k in range(16): + t = k * 0.048 + rng.uniform(0, 0.004) + add_noise(b, SR_FX, t, 0.006, 0.8, lp=0.9, hp=0.4, env=lambda q: 1 - q, rng=rng) + i0 = int(t * SR_FX) + for j in range(int(0.012 * SR_FX)): + b[i0 + j] += sin(TAU * 2900 * j / SR_FX) * exp(-j / 90) * 0.35 + return b + + +def fx_cast(): + b = fx_whoosh(101, secs=0.45, f0=0.05, f1=0.3) + ph = 0.0 + n = int(0.6 * SR_FX) + out = b + [0.0] * (n - len(b) + 10) + for j in range(n): + q = j / n + ph += TAU * (2400 - 1200 * q) / SR_FX + out[j] += sin(ph) * 0.05 * sin(math.pi * q) + return out + + +def fx_bow(): + b = fx_buf(0.6) + add_noise(b, SR_FX, 0.0, 0.03, 0.6, lp=0.6, hp=0.2, env=lambda q: 1 - q) + ph = [0.0, 0.0, 0.0] + n = int(0.5 * SR_FX) + for j in range(n): + t = j / SR_FX + f = 118 * (1 + 0.04 * sin(TAU * 18 * t) * exp(-t * 8)) + s = 0.0 + for k, h in enumerate((1.0, 0.5, 0.3)): + ph[k] += TAU * f * (k + 1) / SR_FX + s += h * sin(ph[k]) + b[j] += s * exp(-t * 9) * 0.6 + return b + + +def fx_thump(seed): + rng = random.Random(seed) + b = fx_buf(0.45) + ph = 0.0 + n = len(b) + for j in range(n): + t = j / SR_FX + ph += TAU * (85 + 70 * exp(-t * 30)) / SR_FX + b[j] = sin(ph) * exp(-t * 11) * 0.9 + add_noise(b, SR_FX, 0.0, 0.12, 0.4, lp=0.08, env=lambda q: exp(-q * 6), rng=rng) + return b + + +def fx_death(): + b = fx_buf(1.6) + ph = [0.0, 0.0] + n = len(b) + for j in range(n): + t = j / SR_FX + ph[0] += TAU * (60 + 40 * exp(-t * 4)) / SR_FX + ph[1] += TAU * (220 * 2 ** (-t * 1.2)) / SR_FX + b[j] = sin(ph[0]) * exp(-t * 2.5) * 0.8 + sin(ph[1]) * exp(-t * 2.0) * 0.3 * min(1, t * 20) + return b + + +# --- creature voices ----------------------------------------------------------------- +# A buzzy source (a saw with a wandering pitch, roughened by fast amplitude +# flutter and breath noise) through a few resonant "throat" filters. + +def bandpass(src, sr, f, q, gain=1.0): + w = TAU * f / sr + alpha = sin(w) / (2 * q) + cw = math.cos(w) + a0 = 1 + alpha + b0, b2 = alpha / a0, -alpha / a0 + a1, a2 = -2 * cw / a0, (1 - alpha) / a0 + out = [0.0] * len(src) + x1 = x2 = y1 = y2 = 0.0 + for i, x in enumerate(src): + y = b0 * x + b2 * x2 - a1 * y1 - a2 * y2 + x2, x1 = x1, x + y2, y1 = y1, y + out[i] = y * gain + return out + + +def voice(seed, dur, pitch, env, formants, breath=0.25, rasp=0.5, rasp_hz=28.0, jitter=0.04): + """pitch(q) and env(q) for q in 0..1; formants: [(Hz, Q, gain)].""" + rng = random.Random(seed) + sr = SR_FX + n = int(dur * sr) + src = [0.0] * n + ph = 0.0 + wob = 0.0 + nz = 0.0 + for j in range(n): + q = j / n + wob += (rng.random() * 2 - 1) * 0.02 - wob * 0.002 + f = pitch(q) * (1 + jitter * wob) + ph += f / sr + ph -= int(ph) + saw = 2 * ph - 1 + nz += 0.5 * ((rng.random() * 2 - 1) - nz) + flutter = 1 - rasp * (0.5 + 0.5 * sin(TAU * rasp_hz * j / sr + 3 * nz)) + src[j] = (saw * flutter + breath * nz) * env(q) + out = [0.0] * n + for f, qq, g in formants: + bp = bandpass(src, sr, f, qq, g) + for j in range(n): + out[j] += bp[j] + return out + + +def _swell(a, d, power=1.0): + """Rise over the first a of the sound, fall over the last d.""" + return lambda q: (min(1.0, q / a) * min(1.0, (1 - q) / d)) ** power + + +def make_voices(): + d = os.path.join(OUT, "sfx", "creatures") + print("creature voices ->", os.path.relpath(d, ROOT)) + W = [(420, 4, 1.0), (1150, 5, 0.6), (2500, 6, 0.25)] # a wolf's throat + B = [(260, 3, 1.0), (720, 4, 0.7), (1700, 5, 0.25)] # a bear's, lower and wider + for i in range(3): + base = 72 + 9 * i + _encode(os.path.join(d, "wolf_growl_%d.ogg" % i), + [voice(100 + i, 1.1 + 0.2 * i, lambda q, b=base: b * (1 + 0.15 * sin(TAU * 1.3 * q)), + _swell(0.25, 0.3), W, breath=0.45, rasp=0.75, rasp_hz=24 + 3 * i)], SR_FX, 5) + for i in range(2): + _encode(os.path.join(d, "wolf_snarl_%d.ogg" % i), + [voice(110 + i, 0.45, lambda q, k=i: 150 + 90 * k + 160 * sin(math.pi * q), + _swell(0.08, 0.4), W, breath=0.6, rasp=0.6, rasp_hz=38)], SR_FX, 5) + _encode(os.path.join(d, "wolf_yelp_%d.ogg" % i), + [voice(120 + i, 0.32, lambda q, k=i: 750 + 120 * k + 650 * sin(math.pi * min(1, q * 1.4)), + _swell(0.05, 0.6), [(900, 3, 1.0), (2200, 4, 0.5)], breath=0.15, rasp=0.15, jitter=0.02)], SR_FX, 5) + _encode(os.path.join(d, "bear_roar_0.ogg"), + [voice(130, 1.7, lambda q: 58 + 30 * sin(math.pi * q ** 0.7), _swell(0.2, 0.35), B, + breath=0.7, rasp=0.8, rasp_hz=21)], SR_FX, 5) + _encode(os.path.join(d, "bear_roar_1.ogg"), + [voice(131, 1.3, lambda q: 64 + 24 * sin(math.pi * q), _swell(0.12, 0.4), B, + breath=0.75, rasp=0.85, rasp_hz=19)], SR_FX, 5) + for i in range(2): + _encode(os.path.join(d, "bear_grunt_%d.ogg" % i), + [voice(140 + i, 0.45, lambda q, k=i: 70 + 12 * k - 15 * q, _swell(0.1, 0.5), B, + breath=0.5, rasp=0.7, rasp_hz=17)], SR_FX, 5) + P = [(380, 3, 1.0), (950, 4, 0.6), (2100, 5, 0.3)] # a boar's snout + for i in range(2): + pulses = [0.0, 0.16, 0.32][: 2 + i] + def env(q, ps=pulses): + t = q * 0.55 + return max(max(0.0, 1 - abs(t - p - 0.05) / 0.06) for p in ps) + _encode(os.path.join(d, "boar_grunt_%d.ogg" % i), + [voice(150 + i, 0.55, lambda q: 95 + 10 * sin(TAU * 3 * q), env, P, breath=0.6, rasp=0.6, rasp_hz=30)], SR_FX, 5) + _encode(os.path.join(d, "boar_squeal_0.ogg"), + [voice(160, 0.6, lambda q: 650 + 300 * sin(math.pi * q) + 40 * sin(TAU * 9 * q), _swell(0.08, 0.35), + [(1200, 3, 1.0), (2600, 4, 0.5)], breath=0.3, rasp=0.35, rasp_hz=45)], SR_FX, 5) + S = [(520, 4, 1.0), (1450, 5, 0.7), (2900, 6, 0.3)] # a stag's long neck + for i in range(2): + _encode(os.path.join(d, "stag_bellow_%d.ogg" % i), + [voice(170 + i, 1.9 - 0.3 * i, lambda q, k=i: 105 + 8 * k + 55 * sin(math.pi * q ** 0.6), _swell(0.18, 0.3), S, + breath=0.55, rasp=0.65, rasp_hz=26)], SR_FX, 5) + sn = fx_buf(0.4) + add_noise(sn, SR_FX, 0.0, 0.32, 1.0, lp=0.18, hp=0.02, env=lambda q: min(1, q * 25) * exp(-q * 5)) + _encode(os.path.join(d, "stag_snort_0.ogg"), [sn], SR_FX, 5) + for i in range(2): + rng = random.Random(180 + i) + rb = fx_buf(0.6) + t = 0.0 + while t < 0.5: + i0 = int(t * SR_FX) + f = rng.uniform(1300, 2300) + for j in range(int(0.02 * SR_FX)): + if i0 + j < len(rb): + rb[i0 + j] += sin(TAU * f * j / SR_FX) * exp(-j / 130) * rng.uniform(0.5, 1.0) + add_noise(rb, SR_FX, t, 0.006, 0.6, lp=0.9, hp=0.3, env=lambda q: 1 - q, rng=rng) + t += rng.uniform(0.025, 0.06) + _encode(os.path.join(d, "guard_rattle_%d.ogg" % i), [rb], SR_FX, 5) + for i in range(2): + caws = [0.0, 0.33][: 1 + i] + def cenv(q, cs=caws, dur=0.32 + 0.33 * i): + t = q * dur + return max(max(0.0, min(1, (t - c) / 0.02)) * max(0.0, 1 - (t - c) / 0.24) if t >= c else 0.0 for c in cs) + _encode(os.path.join(d, "crow_caw_%d.ogg" % i), + [voice(190 + i, 0.32 + 0.33 * i, lambda q: 520 - 80 * q, cenv, [(1300, 3, 1.0), (2400, 4, 0.6)], + breath=0.5, rasp=0.5, rasp_hz=60)], SR_FX, 5) + for i in range(2): + _encode(os.path.join(d, "fox_yip_%d.ogg" % i), + [voice(200 + i, 0.18, lambda q, k=i: 800 + 80 * k + 500 * sin(math.pi * q), _swell(0.05, 0.5), + [(1100, 3, 1.0), (2600, 4, 0.4)], breath=0.2, rasp=0.2, jitter=0.02)], SR_FX, 5) + + +def make_sfx(): + d = os.path.join(OUT, "sfx", "synth") + print("sound effects ->", os.path.relpath(d, ROOT)) + for i in range(3): + _encode(os.path.join(d, "swing_%d.ogg" % i), [fx_whoosh(10 + i, secs=0.26 + 0.04 * i)], SR_FX, 5) + _encode(os.path.join(d, "eat_%d.ogg" % i), [fx_crunch(20 + i)], SR_FX, 5) + for i in range(2): + _encode(os.path.join(d, "splash_%d.ogg" % i), [fx_splash(30 + i)], SR_FX, 5) + _encode(os.path.join(d, "thump_%d.ogg" % i), [fx_thump(40 + i)], SR_FX, 5) + _encode(os.path.join(d, "splash_big.ogg"), [fx_splash(35, 1.6)], SR_FX, 5) + _encode(os.path.join(d, "plop.ogg"), [fx_plop()], SR_FX, 5) + _encode(os.path.join(d, "reel.ogg"), [fx_reel()], SR_FX, 5) + _encode(os.path.join(d, "cast.ogg"), [fx_cast()], SR_FX, 5) + _encode(os.path.join(d, "bow.ogg"), [fx_bow()], SR_FX, 5) + _encode(os.path.join(d, "death.ogg"), [fx_death()], SR_FX, 5) + + +def make_ambience(): + d = os.path.join(OUT, "ambience") + print("ambience ->", os.path.relpath(d, ROOT)) + for name, fn in (("birds", amb_birds), ("crickets", amb_crickets), ("fire", amb_fire)): + sr, ch = fn() + _encode(os.path.join(d, name + ".ogg"), list(ch), sr, 3) + + +def make_music(): + d = os.path.join(OUT, "music") + print("music ->", os.path.relpath(d, ROOT)) + for name, fn in (("calm", track_calm), ("night", track_night), ("storm", track_storm), + ("danger", track_danger), ("boss", track_boss)): + sr, ch = fn() + _encode(os.path.join(d, name + ".ogg"), list(ch), sr, 4) + + +# --- the Kenney CC0 packs (copied once, renamed; see assets/audio/CREDITS.md) ---------- + +KENNEY = { + # ours: (pack, file in its Audio/ folder) + "chop": [("impact-sounds", "impactWood_medium_%03d.ogg" % i) for i in range(5)], + "wood_heavy": [("impact-sounds", "impactWood_heavy_%03d.ogg" % i) for i in range(5)], + "wood_light": [("impact-sounds", "impactWood_light_%03d.ogg" % i) for i in range(5)], + "mine": [("impact-sounds", "impactMining_%03d.ogg" % i) for i in range(5)], + "plank": [("impact-sounds", "impactPlank_medium_%03d.ogg" % i) for i in range(5)], + "punch": [("impact-sounds", "impactPunch_medium_%03d.ogg" % i) for i in range(5)], + "punch_heavy": [("impact-sounds", "impactPunch_heavy_%03d.ogg" % i) for i in range(5)], + "soft_heavy": [("impact-sounds", "impactSoft_heavy_%03d.ogg" % i) for i in range(5)], + "step_grass": [("impact-sounds", "footstep_grass_%03d.ogg" % i) for i in range(5)], + "step_snow": [("impact-sounds", "footstep_snow_%03d.ogg" % i) for i in range(5)], + "step_wood": [("impact-sounds", "footstep_wood_%03d.ogg" % i) for i in range(5)], + "step_stone": [("impact-sounds", "footstep_concrete_%03d.ogg" % i) for i in range(5)], + "creak": [("rpg-audio", "creak%d.ogg" % i) for i in (1, 2, 3)], + "door_open": [("rpg-audio", "doorOpen_%d.ogg" % i) for i in (1, 2)], + "door_close": [("rpg-audio", "doorClose_%d.ogg" % i) for i in (1, 2, 3, 4)], + "cloth": [("rpg-audio", "cloth%d.ogg" % i) for i in (1, 2, 3, 4)], + "leather": [("rpg-audio", n) for n in ("handleSmallLeather.ogg", "handleSmallLeather2.ogg", "dropLeather.ogg")], + "knife": [("rpg-audio", n) for n in ("knifeSlice.ogg", "knifeSlice2.ogg")], + "metal_click": [("rpg-audio", n) for n in ("metalClick.ogg", "metalLatch.ogg")], + "ui_open": [("interface-sounds", "open_001.ogg")], + "ui_close": [("interface-sounds", "close_001.ogg")], + "ui_tick": [("interface-sounds", "tick_001.ogg")], + "ui_good": [("interface-sounds", "confirmation_001.ogg")], + "ui_step": [("interface-sounds", "confirmation_003.ogg")], + "ui_catch": [("interface-sounds", "confirmation_002.ogg")], + "ui_tip": [("interface-sounds", "question_001.ogg")], +} + + +def copy_kenney(src): + d = os.path.join(OUT, "sfx", "kenney") + os.makedirs(d, exist_ok=True) + by_name = {} + for root, _, files in os.walk(src): + pack = next((p for p in ("rpg-audio", "impact-sounds", "interface-sounds") if p in root), None) + for f in files: + if pack and f.endswith(".ogg"): + by_name[(pack, f)] = os.path.join(root, f) + n = 0 + for ours, lst in KENNEY.items(): + for i, key in enumerate(lst): + if key not in by_name: + sys.exit("missing from the Kenney packs: %s/%s" % key) + shutil.copyfile(by_name[key], os.path.join(d, "%s_%d.ogg" % (ours, i))) + n += 1 + print("copied %d Kenney sounds -> %s" % (n, os.path.relpath(d, ROOT))) + + +if __name__ == "__main__": + args = sys.argv[1:] + if "--kenney" in args: + i = args.index("--kenney") + copy_kenney(os.path.expanduser(args[i + 1])) + del args[i:i + 2] + what = args[0] if args else "all" + if what in ("all", "sfx"): + make_sfx() + if what in ("all", "sfx", "voices"): + make_voices() + if what in ("all", "ambience"): + make_ambience() + if what in ("all", "music"): + make_music()