diff --git a/ChapterMaster.yyp b/ChapterMaster.yyp index d7170a5d1e..20d9accc62 100644 --- a/ChapterMaster.yyp +++ b/ChapterMaster.yyp @@ -594,7 +594,7 @@ "isEcma":false, "LibraryEmitters":[], "MetaData":{ - "IDEVersion":"2026.100.0.1083", + "IDEVersion":"2026.0.0.16", }, "name":"ChapterMaster", "resources":[ @@ -713,6 +713,7 @@ {"id":{"name":"__gml_pragma_global","path":"scripts/__gml_pragma_global/__gml_pragma_global.yy",},}, {"id":{"name":"__init","path":"scripts/__init/__init.yy",},}, {"id":{"name":"Armamentarium","path":"scripts/Armamentarium/Armamentarium.yy",},}, + {"id":{"name":"AudioManager","path":"scripts/AudioManager/AudioManager.yy",},}, {"id":{"name":"ColourItem","path":"scripts/ColourItem/ColourItem.yy",},}, {"id":{"name":"ColourPicker","path":"scripts/ColourPicker/ColourPicker.yy",},}, {"id":{"name":"CombatDebugger","path":"scripts/CombatDebugger/CombatDebugger.yy",},}, @@ -861,7 +862,6 @@ {"id":{"name":"scr_mission_functions","path":"scripts/scr_mission_functions/scr_mission_functions.yy",},}, {"id":{"name":"scr_mission_reward","path":"scripts/scr_mission_reward/scr_mission_reward.yy",},}, {"id":{"name":"scr_move_unit_info","path":"scripts/scr_move_unit_info/scr_move_unit_info.yy",},}, - {"id":{"name":"scr_music","path":"scripts/scr_music/scr_music.yy",},}, {"id":{"name":"scr_necron_tombs","path":"scripts/scr_necron_tombs/scr_necron_tombs.yy",},}, {"id":{"name":"scr_ork_fleet_functions","path":"scripts/scr_ork_fleet_functions/scr_ork_fleet_functions.yy",},}, {"id":{"name":"scr_ork_planet_functions","path":"scripts/scr_ork_planet_functions/scr_ork_planet_functions.yy",},}, @@ -879,6 +879,7 @@ {"id":{"name":"scr_popup","path":"scripts/scr_popup/scr_popup.yy",},}, {"id":{"name":"scr_post_battle_events","path":"scripts/scr_post_battle_events/scr_post_battle_events.yy",},}, {"id":{"name":"scr_powers","path":"scripts/scr_powers/scr_powers.yy",},}, + {"id":{"name":"scr_predicate","path":"scripts/scr_predicate/scr_predicate.yy",},}, {"id":{"name":"scr_promote","path":"scripts/scr_promote/scr_promote.yy",},}, {"id":{"name":"scr_punit_combat_heplers","path":"scripts/scr_punit_combat_heplers/scr_punit_combat_heplers.yy",},}, {"id":{"name":"scr_purge_world","path":"scripts/scr_purge_world/scr_purge_world.yy",},}, diff --git a/datafiles/audio_modding.md b/datafiles/audio_modding.md new file mode 100644 index 0000000000..def013d0d0 --- /dev/null +++ b/datafiles/audio_modding.md @@ -0,0 +1,110 @@ +# Audio Modding Guide + +## Overview + +- You can add your own music and sound effects (SFX) to Chapter Master. +- The game supports **.ogg** files only. +- Newly added files are **lazy-loaded** on the next play, you don't need to relaunch the game. + +## Where To Put Files + +There are two places where audio files live: + +1. **Shipped** (inside the game's install folder) +2. **User** (your personal override folder) + +**User files always override shipped files** if they share the same name. This means you never need to modify the game's installation. + +``` +AppData/Local/ChapterMaster/Custom Files/Audio/ + Music/ + menu/ + sector/ + battle/ + creation/ + defeat/ + diplomacy/ + postbattle/ + SFX/ +``` + +> If a folder doesn't exist, just create it. The game will read it automatically. + +## Music + +- Music is organized by **context** (what is happening in the game). +- Each context is a folder. +- Any `.ogg` file you put in that folder becomes part of that context's playlist. +- The game picks a random track from the folder and avoids playing the same track twice in a row. + +### Context Folders + +| Context | Folder Name | When It Plays | +|---|---|---| +| Menu | `menu/` | Main Menu | +| Sector | `sector/` | Star Map | +| Battle | `battle/` | Combat | +| Creation | `creation/` | Chapter Creation | +| Defeat | `defeat/` | Defeat | +| Diplomacy | `diplomacy/` | Diplomacy Events | +| Post-Battle | `postbattle/` | After Combat Results | + +### Example + +To add your own battle music: +1. Go to your **User** folder: `/Local/ChapterMaster/Custom Files/Audio/Music/battle/` +2. Drop `.ogg` files in there (e.g. `my_battle_track.ogg`, `epic_fight.ogg`) +3. Next time you enter combat, the game will randomly pick from your tracks + +### Built-in Music + +- The game comes with built-in tracks. +- They play only when no external `.ogg` files exist in a context folder. +- You don't need to worry about them, just adding your own files automatically replaces them. + +## Sound Effects (SFX) + +SFX files go into the `SFX/` folder (not inside a context subfolder). + +### Overridable SFX Names + +Place a file named exactly like one of these in `SFX/` to replace that sound: + +| Name | Plays When... | +|---|---| +| `click` | Button click | +| `click_small` | Small button click (???) | +| `error` | Error (duh) | +| `buzz` | Buzzing on the creation screen | +| `end_turn` | Ending your turn | +| `identify` | Identifying an artifact | +| `stc` | Identifying an STC fragment | + +### Example + +To replace the click sound: + +1. Create an `.ogg` file named `click.ogg` +2. Put it in `/Local/ChapterMaster/Custom Files/Audio/SFX/` +3. The game will now use your file instead of the built-in one + +## File Priority (How The Game Decides Which File To Play) + +1. User folder (already cached from a previous play) +2. User folder (loads fresh from disk) +3. Shipped folder +4. Built-in asset (compiled into the game) + +The game checks these in order and plays the first one it finds. This means: +- Put your custom files in the **User folder** to override everything +- Add new names (not just override existing ones) to expand playlists + +## Quick Start Checklist + +1. Find your user audio folder: `/Local/ChapterMaster/Custom Files/Audio/` +2. Pick SFX or Music, and the context: `Music/battle/` (or whatever) +3. Convert your audio to **.ogg** format +4. Drop the `.ogg` files in the correct folder +5. Launch the game, your audio will play automatically + +That's it. No config files, no mod loader, no editing game files. diff --git a/objects/obj_controller/Alarm_7.gml b/objects/obj_controller/Alarm_7.gml index 9e4c2b94a1..99beda201b 100644 --- a/objects/obj_controller/Alarm_7.gml +++ b/objects/obj_controller/Alarm_7.gml @@ -1,9 +1,7 @@ // Player defeat screen LOGGER.info("Player Defeated; Exited to Defeat Screen"); -audio_stop_sound(snd_royal); -audio_play_sound(snd_defeat, 0, true, 0.1); -audio_sound_gain(snd_defeat, 1, 5000); +global.audio_manager.play_playlist(CONTEXT_DEFEAT, 5000); if ((marines + command <= 50) && (global.defeat != 2)) { global.defeat = 0; diff --git a/objects/obj_controller/Create_0.gml b/objects/obj_controller/Create_0.gml index 50f66d3a5d..449ad8969d 100644 --- a/objects/obj_controller/Create_0.gml +++ b/objects/obj_controller/Create_0.gml @@ -77,11 +77,8 @@ LOGGER.info("Creating Controller"); scr_colors_initialize(); is_test_map = false; target_navy_number = 5; -global.sound_playing = undefined; global.defeat = 0; tutorial = 0; -sound_in = 0; -sound_to = ""; fix_right = 0; text_bar = 0; bar_fix = false; diff --git a/objects/obj_controller/Step_0.gml b/objects/obj_controller/Step_0.gml index 9959e8edb8..b71e564339 100644 --- a/objects/obj_controller/Step_0.gml +++ b/objects/obj_controller/Step_0.gml @@ -55,22 +55,6 @@ try { } formating = 0; } - // Sounds - if (sound_in >= 0) { - sound_in -= 1; - } - if ((sound_in == 0) && (sound_to != "")) { - audio_stop_all(); - var nope = false; - if (sound_to == "blood") { - global.sound_playing = audio_play_sound(snd_blood, 0, true); - audio_sound_gain(global.sound_playing, 1, 5000); - } - if (sound_to == "royal") { - global.sound_playing = audio_play_sound(snd_royal, 0, true); - audio_sound_gain(global.sound_playing, 1, 5000); - } - } // Cheat codes if (cheatcode != "") { cheatyface = 1; @@ -380,11 +364,11 @@ try { // Sound controls if (click > 0) { click = -1; - audio_play_sound(snd_click, -80, false); + global.audio_manager.play_sfx(SFX_CLICK); } if (click2 > 0) { click2 = -1; - audio_play_sound(snd_click_small, -80, 0); + global.audio_manager.play_sfx(SFX_CLICK_SMALL); } // Return artifact if (qsfx == 1) { diff --git a/objects/obj_creation/Create_0.gml b/objects/obj_creation/Create_0.gml index a84de12577..6c7a588fbe 100644 --- a/objects/obj_creation/Create_0.gml +++ b/objects/obj_creation/Create_0.gml @@ -41,9 +41,7 @@ custom_icon = 0; /// Stores the chapter icon in one spot so we dont have to keep checking whether we're using a custom image or not every time we wanna display it somewhere global.chapter_id = 0; -audio_stop_all(); -audio_play_sound(snd_diboz, 0, true, 0.1); -audio_sound_gain(snd_diboz, 1, 5000); +global.audio_manager.play_playlist(CONTEXT_CREATION, 5000); global.load = -1; planet_types = global.planet_types; diff --git a/objects/obj_creation/Step_0.gml b/objects/obj_creation/Step_0.gml index 53284db60f..6a35d22e37 100644 --- a/objects/obj_creation/Step_0.gml +++ b/objects/obj_creation/Step_0.gml @@ -21,7 +21,7 @@ if (slide == 1) { // Play audio if ((slate5 == 1) || (slate6 == 1)) { if ((global.settings.master_volume > 0) && (global.settings.sfx_volume > 0)) { - audio_play_sound(snd_buzz, 0, false); + global.audio_manager.play_sfx(SFX_BUZZ); } } diff --git a/objects/obj_defeat/Step_0.gml b/objects/obj_defeat/Step_0.gml index b906056636..a5b0e51021 100644 --- a/objects/obj_defeat/Step_0.gml +++ b/objects/obj_defeat/Step_0.gml @@ -6,7 +6,7 @@ if (goodbye > 0) { } if (fadeout == 1) { - audio_sound_gain(snd_defeat, 0, 2000); + global.audio_manager.stop_music(2000) } if (fadeout == 60) { diff --git a/objects/obj_formation_bar/Create_0.gml b/objects/obj_formation_bar/Create_0.gml index e4e22d1683..3683c0b240 100644 --- a/objects/obj_formation_bar/Create_0.gml +++ b/objects/obj_formation_bar/Create_0.gml @@ -154,7 +154,7 @@ mouse_release = function() { obj_cursor.dragging = 0; obj_cursor.image_index = 0; if ((global.settings.master_volume > 0) && (global.settings.sfx_volume > 0)) { - audio_play_sound(snd_error, -80, false); + global.audio_manager.play_sfx(SFX_ERROR); } } diff --git a/objects/obj_main_menu/Create_0.gml b/objects/obj_main_menu/Create_0.gml index 3ccd9e88a8..6d1a3f450f 100644 --- a/objects/obj_main_menu/Create_0.gml +++ b/objects/obj_main_menu/Create_0.gml @@ -3,9 +3,7 @@ title_alpha = 0; cooldown = 0; update_blink_visible = false; -audio_stop_all(); -global.current_music = audio_play_sound(snd_prologue, 10, true, 0.1); -audio_sound_gain(global.current_music, 1, (global.returned > 0) ? 0 : 5000); +global.audio_manager.play_playlist(CONTEXT_MENU, (global.returned > 0) ? 0 : 5000); if (instance_exists(obj_cursor)) { obj_cursor.image_alpha = (global.returned > 0) ? 1 : 0; diff --git a/objects/obj_main_menu/Draw_0.gml b/objects/obj_main_menu/Draw_0.gml index 27dfef8558..cdfd84f6a5 100644 --- a/objects/obj_main_menu/Draw_0.gml +++ b/objects/obj_main_menu/Draw_0.gml @@ -24,7 +24,7 @@ if (global.game_version != "unknown version") { if (point_and_click([1400, 830, 1600, 900])) { clipboard_set_text($"{_build_date_line}\n{_version_line}"); - audio_play_sound(snd_click_small, 0, false); + global.audio_manager.play_sfx(SFX_CLICK_SMALL); } // Update notification @@ -35,7 +35,7 @@ if (UPDATE_CHECKER.update_available) { if (point_and_click([1400, 780, 1600, 830])) { url_open(UPDATE_CHECKER.latest_release_url); - audio_play_sound(snd_click_small, 0, false); + global.audio_manager.play_sfx(SFX_CLICK_SMALL); } } diff --git a/objects/obj_ncombat/Alarm_7.gml b/objects/obj_ncombat/Alarm_7.gml index 5aeeb945c6..d212ea07e4 100644 --- a/objects/obj_ncombat/Alarm_7.gml +++ b/objects/obj_ncombat/Alarm_7.gml @@ -1,7 +1,5 @@ try { - audio_stop_sound(snd_battle); - audio_play_sound(snd_royal, 0, true); - audio_sound_gain(snd_royal, 1, 5000); + global.audio_manager.play_playlist(CONTEXT_POSTBATTLE, 5000); // Execute the cleaning scripts // Check for any more battles diff --git a/objects/obj_ncombat/Create_0.gml b/objects/obj_ncombat/Create_0.gml index bf29d2e423..a5facdb1f3 100644 --- a/objects/obj_ncombat/Create_0.gml +++ b/objects/obj_ncombat/Create_0.gml @@ -5,9 +5,7 @@ if (instance_number(obj_ncombat) > 1) { set_zoom_to_default(); LOGGER.info("Ground Combat Started"); -audio_stop_sound(snd_royal); -audio_play_sound(snd_battle, 0, true); -audio_sound_gain(snd_battle, 1, 5000); +global.audio_manager.play_playlist(CONTEXT_BATTLE, 5000); hue = 0; turn_count = 0; diff --git a/objects/obj_persistent/Create_0.gml b/objects/obj_persistent/Create_0.gml index 5dd9fa314a..53ee57d962 100644 --- a/objects/obj_persistent/Create_0.gml +++ b/objects/obj_persistent/Create_0.gml @@ -1,5 +1,11 @@ randomize(); +audio_group_load(audiogroup_sfx); +audio_group_load(audiogroup_music); + +global.audio_manager = new AudioManager(); +global.audio_manager.discover(); + global.settings = new SettingsManager(); global.settings.load(); global.settings.apply_video(); @@ -11,7 +17,6 @@ USERNAME_PROMPT.prompt(); global.save_version = 0; global.returned = 0; global.debug = false; -global.current_music = -1; global.load = 0; global.cheat_req = false; global.cheat_gene = false; @@ -22,5 +27,4 @@ global.language = "en"; instance_create_depth(0, 0, 0, obj_garbage_collector); instance_create_depth(0, 0, 0, obj_img); -audio_group_load(audiogroup_sfx); -audio_group_load(audiogroup_music); + diff --git a/objects/obj_saveload/Destroy_0.gml b/objects/obj_saveload/Destroy_0.gml index 4aaa69cdfa..b929b323cc 100644 --- a/objects/obj_saveload/Destroy_0.gml +++ b/objects/obj_saveload/Destroy_0.gml @@ -1,6 +1,5 @@ scr_image("loading", -666, 0, 0, 0, 0); -if ((!audio_is_playing(snd_royal)) && instance_exists(obj_controller)) { - audio_play_sound(snd_royal, 0, true); - audio_sound_gain(snd_royal, 1, 5000); +if (instance_exists(obj_controller)) { + global.audio_manager.play_playlist(CONTEXT_SECTOR, 5000); } diff --git a/objects/obj_turn_end/Alarm_1.gml b/objects/obj_turn_end/Alarm_1.gml index c95b53d6db..5c55b3fd37 100644 --- a/objects/obj_turn_end/Alarm_1.gml +++ b/objects/obj_turn_end/Alarm_1.gml @@ -17,7 +17,7 @@ if (array_length(audience_stack) > 0) { obj_controller.audience_data = current_audience.audience_data; if ((obj_controller.diplomacy == 10) && (obj_controller.faction_gender[10] == 2)) { - scr_music("blood", 60); + global.audio_manager.play_playlist(CONTEXT_DIPLOMACY, 60); } if (string_count("intro", current_audience.topic) > 0) { diff --git a/scripts/Armamentarium/Armamentarium.gml b/scripts/Armamentarium/Armamentarium.gml index 39309635b6..fefa1964ef 100644 --- a/scripts/Armamentarium/Armamentarium.gml +++ b/scripts/Armamentarium/Armamentarium.gml @@ -305,7 +305,7 @@ function STCResearchPanel(_controller_ref, _on_change_callback) constructor { if (mouse_button_clicked()) { controller.stc_research.research_focus = _cat; - audio_play_sound(snd_click, 10, false); + global.audio_manager.play_sfx(SFX_CLICK); refresh_eta(); } } @@ -430,7 +430,7 @@ function STCResearchPanel(_controller_ref, _on_change_callback) constructor { advance_stc_research(_target); - audio_play_sound(snd_stc, -500, false); + global.audio_manager.play_sfx(SFX_STC); if (is_method(on_change)) { on_change(); @@ -867,7 +867,7 @@ function Armamentarium(_controller) constructor { _item.stocked -= _sold_count; controller.requisition += _sell_price; - audio_play_sound(snd_click, 10, false); + global.audio_manager.play_sfx(SFX_CLICK); return true; }; @@ -882,7 +882,7 @@ function Armamentarium(_controller) constructor { // 1. Warships if (shop_type == "ships") { add_event({e_id: "ship_construction", ship_class: _item.name, duration: _item.request_duration}); - audio_play_sound(snd_click, 10, false); + global.audio_manager.play_sfx(SFX_CLICK); return; } @@ -900,7 +900,7 @@ function Armamentarium(_controller) constructor { } _item.stocked += _count; - audio_play_sound(snd_click, 10, false); + global.audio_manager.play_sfx(SFX_CLICK); }; /// @desc Switches the current shop category. @@ -1051,7 +1051,7 @@ function Armamentarium(_controller) constructor { if (array_length(_queue) < FORGE_QUEUE_MAX) { array_push(_queue, {item: _item, count: _count, forge_points: _cost, ordered: controller.turn}); } else { - audio_play_sound(snd_error, 10, false); + global.audio_manager.play_sfx(SFX_ERROR); } } diff --git a/scripts/AudioManager/AudioManager.gml b/scripts/AudioManager/AudioManager.gml new file mode 100644 index 0000000000..3d55b7f73c --- /dev/null +++ b/scripts/AudioManager/AudioManager.gml @@ -0,0 +1,458 @@ +/// @desc Manages runtime-loaded audio (music + SFX) with built-in asset fallback. +/// Intended for use as `global.audio_manager` to handle all audio playback, +/// crossfading, and volume control. +/// +/// Audio loading tiers (user overrides shipped): +/// 1. Shipped: `working_directory/Audio/` +/// 2. User: `Audio/` (relative -> AppData/Local/ChapterMaster/Audio/) +/// +/// Music uses a context/playlist system. Context folders under `Audio/Music//` +/// are scanned on discovery. Each context picks a random track from its folder. +/// Falls back to built-in `snd_*` assets if no external files are found. + +// Context names for playlist-based playback. +#macro CONTEXT_MENU "menu" +#macro CONTEXT_SECTOR "sector" +#macro CONTEXT_BATTLE "battle" +#macro CONTEXT_CREATION "creation" +#macro CONTEXT_DEFEAT "defeat" +#macro CONTEXT_DIPLOMACY "diplomacy" +#macro CONTEXT_POSTBATTLE "postbattle" + +// Music track names for built-in fallback and playlist resolution. +#macro MUSIC_PROLOGUE "prologue" +#macro MUSIC_ROYAL "royal" +#macro MUSIC_BLOOD "blood" +#macro MUSIC_BATTLE "battle" +#macro MUSIC_DIBOZ "diboz" +#macro MUSIC_DEFEAT "defeat" +#macro MUSIC_POSTBATTLE "postbattle" + +// SFX name macros for use with play_sfx(). +#macro SFX_CLICK "click" +#macro SFX_CLICK_SMALL "click_small" +#macro SFX_ERROR "error" +#macro SFX_BUZZ "buzz" +#macro SFX_END_TURN "end_turn" +#macro SFX_IDENTIFY "identify" +#macro SFX_STC "stc" + +function AudioManager() constructor { + // ###### Constants ###### + + AUDIO_DIR = working_directory + "/Custom Files/Audio/"; + MUSIC_DIR = AUDIO_DIR + "Music/"; + SFX_DIR = AUDIO_DIR + "SFX/"; + + USER_AUDIO_DIR = program_directory + "Audio/"; + USER_MUSIC_DIR = USER_AUDIO_DIR + "Music/"; + USER_SFX_DIR = USER_AUDIO_DIR + "SFX//"; + + DEFAULT_CROSSFADE_MS = 2000; + DEFAULT_STOP_FADE_MS = 500; + + FILE_EXT = ".ogg"; + + audio_group_set_gain(audiogroup_music, 1.0); + audio_group_set_gain(audiogroup_sfx, 1.0); + + // ###### Public ###### + + /// @type {String} + /// Name of the currently playing music track (empty = none). + current_audio_name = ""; + + /// @type {Real} + /// Audio instance index of the currently playing music, or -1. + current_audio_id = -1; + + /// @type {String} + /// Context of the currently playing music ("battle", "menu", ...). + /// Empty if the last call was to `play_track()`. + current_context = ""; + + // ###### Private ###### + + /// @type {Real} + /// Volume applied to music (0-1). + __music_volume = 1.0; + + /// @type {Real} + /// Volume applied to SFX (0-1). + __sfx_volume = 1.0; + + // Map: filename (no extension) -> externally-loaded audio index. + __music_cache = {}; + __sfx_cache = {}; + + // Map: context name -> array of track names in that context's playlist. + __context_playlists = {}; + + // Map: context name -> last played track name (avoid consecutive repeat). + __last_context_track = {}; + + // Maps a logical name to the compile-time `snd_*` asset. + // Used when no external file exists for that name. + __builtin_music = { + MUSIC_PROLOGUE: snd_prologue, + MUSIC_ROYAL: snd_royal, + MUSIC_BLOOD: snd_blood, + MUSIC_BATTLE: snd_battle, + MUSIC_DIBOZ: snd_diboz, + MUSIC_DEFEAT: snd_defeat, + MUSIC_POSTBATTLE: snd_postbattle, + }; + + __builtin_sfx = { + SFX_CLICK: snd_click, + SFX_CLICK_SMALL: snd_click_small, + SFX_ERROR: snd_error, + SFX_BUZZ: snd_buzz, + SFX_END_TURN: snd_end_turn, + SFX_IDENTIFY: snd_identify, + SFX_STC: snd_stc, + }; + + // Maps context name to built-in track names for fallback. + // When no external files are found, a random track from this list is used. + __context_fallback = { + CONTEXT_MENU: [MUSIC_PROLOGUE], + CONTEXT_SECTOR: [MUSIC_ROYAL], + CONTEXT_BATTLE: [MUSIC_BATTLE], + CONTEXT_CREATION: [MUSIC_DIBOZ], + CONTEXT_DEFEAT: [MUSIC_DEFEAT], + CONTEXT_DIPLOMACY: [MUSIC_BLOOD], + CONTEXT_POSTBATTLE: [MUSIC_POSTBATTLE], + }; + + // ###### Private Methods ###### + + /// @desc Ensures audio directories exist for user file drops. + static __ensure_dir = function(_path) { + if (!directory_exists(_path)) { + directory_create(_path); + } + }; + + /// @desc Scans a directory for .ogg files and adds their names (without extension) to a map. + /// @param {String} _dir The directory to scan (must end with a slash) + /// @param {Struct} _map The struct to populate with track names + /// @returns {Real} The number of files found in this directory + static __scan_audio_dir = function(_dir, _map) { + var _count = 0; + var _file = file_find_first(_dir + "*" + FILE_EXT, fa_none); + + while (_file != "") { + // Strip the 4-character ".ogg" + var _name = string_copy(_file, 1, string_length(_file) - 4); + _map[$ _name] = true; + _count++; + _file = file_find_next(); + } + + file_find_close(); + return _count; + }; + + /// @desc Attempts to load and cache a streamed sound from two candidate paths. + /// @param {Struct} _cache The cache struct to use + /// @param {String} _key The cache key/track name + /// @param {String} _user_path The user file path + /// @param {String} _shipped_path The shipped file path + /// @returns {Real} audio index, or -1 if neither path produces a valid stream. + static __load_stream = function(_cache, _key, _user_path, _shipped_path) { + if (struct_exists(_cache, _key)) { + return _cache[$ _key]; + } + + var _paths = [ + _user_path, + _shipped_path, + ]; + + for (var i = 0; i < 2; i++) { + if (!file_exists(_paths[i])) { + continue; + } + + var _id = audio_create_stream(_paths[i]); + if (_id >= 0) { + _cache[$ _key] = _id; + return _id; + } + + LOGGER.warning($"AudioManager: failed to stream '{_key}' from ${_paths[i]}"); + } + + return -1; + }; + + /// @desc Resolves a music track name to an audio index. + /// Priority: cached external > user external > shipped external > built-in. + /// Does not search context sub-folders; use `__resolve_context_track()` for playlist tracks. + /// @param {String} _name track name (without extension) + /// @returns {Real} audio index, or -1 if nothing found + static __get_music = function(_name) { + var _user_path = USER_MUSIC_DIR + _name + FILE_EXT; + var _shipped_path = MUSIC_DIR + _name + FILE_EXT; + var _id = __load_stream(__music_cache, _name, _user_path, _shipped_path); + + if (_id >= 0) { + return _id; + } + + if (struct_exists(__builtin_music, _name)) { + return __builtin_music[$ _name]; + } + + return -1; + }; + + /// @desc Resolves an SFX name to an audio index. + /// Priority: cached external > user external > shipped external > built-in. + /// @param {String} _name sound name (without extension) + /// @returns {Real} audio index, or -1 if nothing found + static __get_sfx = function(_name) { + var _user_path = USER_SFX_DIR + _name + FILE_EXT; + var _shipped_path = SFX_DIR + _name + FILE_EXT; + var _id = __load_stream(__sfx_cache, _name, _user_path, _shipped_path); + + if (_id >= 0) { + return _id; + } + + if (struct_exists(__builtin_sfx, _name)) { + return __builtin_sfx[$ _name]; + } + + return -1; + }; + + /// @desc Resolves a context track name to an audio index. + /// Checks user context dir first, then shipped context dir. + /// Results are cached with a compound key to avoid collisions. + /// @param {String} _context context name + /// @param {String} _name track name + /// @returns {Real} audio index, or -1 if not found + static __resolve_context_track = function(_context, _name) { + var _user_path = USER_MUSIC_DIR + _context + "/" + _name + FILE_EXT; + var _shipped_path = MUSIC_DIR + _context + "/" + _name + FILE_EXT; + var _id = __load_stream(__music_cache, _context + "/" + _name, _user_path, _shipped_path); + + return _id; + }; + + /// @desc Destroys all streamed audio in the given cache. + static __flush_cache = function(_cache) { + var _keys = struct_get_names(_cache); + for (var i = 0; i < array_length(_keys); i++) { + var _id = _cache[$ _keys[i]]; + if (_id >= 0) audio_destroy_stream(_id); + } + }; + + // ###### Public Methods ###### + + /// @desc Scans shipped and user audio directories, builds track lists for + /// root music/SFX and per-context playlists. Safe to call multiple times. + /// @returns {Real} total number of audio files found + static discover = function() { + __context_playlists = {}; + var _total = 0; + var _map; + + // Ensure all base directories exist + __ensure_dir(AUDIO_DIR); + __ensure_dir(MUSIC_DIR); + __ensure_dir(SFX_DIR); + __ensure_dir(USER_AUDIO_DIR); + __ensure_dir(USER_MUSIC_DIR); + __ensure_dir(USER_SFX_DIR); + + var _contexts = struct_get_names(__context_fallback); + for (var i = 0; i < array_length(_contexts); i++) { + __ensure_dir(MUSIC_DIR + _contexts[i] + "/"); + __ensure_dir(USER_MUSIC_DIR + _contexts[i] + "/"); + } + + // Root music files (shipped + user) + _map = {}; + _total += __scan_audio_dir(MUSIC_DIR, _map); + _total += __scan_audio_dir(USER_MUSIC_DIR, _map); + + // Root SFX files (shipped + user) + _map = {}; + _total += __scan_audio_dir(SFX_DIR, _map); + _total += __scan_audio_dir(USER_SFX_DIR, _map); + + // Context playlists (shipped + user, user same-name overrides) + for (var i = 0; i < array_length(_contexts); i++) { + var _ctx = _contexts[i]; + _map = {}; + + _total += __scan_audio_dir(MUSIC_DIR + _ctx + "/", _map); + _total += __scan_audio_dir(USER_MUSIC_DIR + _ctx + "/", _map); + + __context_playlists[$ _ctx] = struct_get_names(_map); + } + + return _total; + }; + + /// @desc Plays a specific named track with crossfade. + /// Resolves the name from root music dirs + built-in fallback. + /// When `_audio_id` is provided (by `play_playlist` for context tracks), + /// the lookup is bypassed and the given audio index is used directly. + /// @param {String} _name track name (without extension) + /// @param {Real} _fade_ms crossfade duration in ms (default DEFAULT_CROSSFADE_MS) + /// @param {Real} _audio_id optional pre-resolved audio index (internal) + /// @param {String} _context optional context name (internal) + /// @returns {Real} audio instance index, or -1 on failure + static play_track = function(_name, _fade_ms = 2000, _audio_id = -1, _context = "") { + if (_name == current_audio_name && current_audio_id >= 0 && audio_is_playing(current_audio_id)) { + return current_audio_id; + } + + var _id = _audio_id; + if (_id < 0) { + _id = __get_music(_name); + if (_id < 0) { + return -1; + } + } + + if (current_audio_id >= 0 && audio_is_playing(current_audio_id)) { + audio_sound_gain(current_audio_id, 0, _fade_ms); + + var _old_id = current_audio_id; + var _callback = predicate0(_old_id, function(_id_to_stop) { + if (audio_exists(_id_to_stop)) { + audio_stop_sound(_id_to_stop); + } + }); + + call_later(_fade_ms / 1000, time_source_units_seconds, _callback); + } + + current_context = _context; + current_audio_name = _name; + current_audio_id = audio_play_sound(_id, 0, true, 0); + audio_sound_gain(current_audio_id, __music_volume, _fade_ms); + return current_audio_id; + }; + + /// @desc Plays a random track from a music context (playlist) with crossfade. + /// External files in `audio/music//` are picked from first; + /// if empty, a random built-in fallback is chosen instead. + /// @param {String} _context context name (CONTEXT_* constant, e.g. CONTEXT_MENU) + /// @param {Real} _fade_ms crossfade duration in ms (default DEFAULT_CROSSFADE_MS) + /// @returns {Real} audio instance index, or -1 on failure + static play_playlist = function(_context, _fade_ms = DEFAULT_CROSSFADE_MS) { + var _track_names = struct_exists(__context_playlists, _context) ? __context_playlists[$ _context] : []; + + var _chosen = ""; + var _audio_id = -1; + + if (array_length(_track_names) > 0) { + var _last = struct_exists(__last_context_track, _context) ? __last_context_track[$ _context] : ""; + + if (_last != "" && array_length(_track_names) > 1) { + var _candidates = []; + for (var i = 0; i < array_length(_track_names); i++) { + if (_track_names[i] != _last) { + array_push(_candidates, _track_names[i]); + } + } + + _chosen = _candidates[irandom(array_length(_candidates) - 1)]; + } else { + _chosen = _track_names[irandom(array_length(_track_names) - 1)]; + } + + __last_context_track[$ _context] = _chosen; + + _audio_id = __resolve_context_track(_context, _chosen); + } + + if (_audio_id < 0 && struct_exists(__context_fallback, _context)) { + var _fallbacks = __context_fallback[$ _context]; + _chosen = _fallbacks[irandom(array_length(_fallbacks) - 1)]; + } + + if (_chosen == "") { + _chosen = _context; + } + + current_context = _context; + return play_track(_chosen, _fade_ms, _audio_id, _context); + }; + + /// @desc Plays a sound effect once. + /// @param {String} _name sound name (without extension) + /// @param {Real} _priority priority (default 0); lower = easier to cut + /// @param {Bool} _loop loop the sound? (default false) + /// @param {Real} _gain per-instance gain multiplier (default 1.0) + /// @returns {Real} audio instance index, or -1 on failure + static play_sfx = function(_name, _priority = 0, _loop = false, _gain = 1.0) { + var _id = __get_sfx(_name); + if (_id < 0) { + return -1; + } + + var _total_gain = _gain * __sfx_volume; + return audio_play_sound(_id, _priority, _loop, _total_gain); + }; + + /// @desc Stops the current music with an optional fade-out. + /// @param {Real} _fade_ms fade-out duration in ms (default DEFAULT_STOP_FADE_MS) + static stop_music = function(_fade_ms = DEFAULT_STOP_FADE_MS) { + if (current_audio_id >= 0 && audio_is_playing(current_audio_id)) { + audio_sound_gain(current_audio_id, 0, _fade_ms); + + var _old_id = current_audio_id; + var _callback = predicate0(_old_id, function(_id_to_stop) { + if (audio_exists(_id_to_stop)) { + audio_stop_sound(_id_to_stop); + } + }); + + call_later(_fade_ms / 1000, time_source_units_seconds, _callback); + } + + current_audio_name = ""; + current_context = ""; + current_audio_id = -1; + }; + + /// @desc Updates the volume for music. + /// Applies to both built-in and external tracks via per-sound gain. + /// @param {Real} _vol volume (0-1) + static set_music_volume = function(_vol) { + __music_volume = clamp(_vol, 0, 1); + if (current_audio_id >= 0 && audio_is_playing(current_audio_id)) { + audio_sound_gain(current_audio_id, __music_volume, DEFAULT_STOP_FADE_MS); + } + }; + + /// @desc Updates the volume for runtime-loaded SFX. + /// Note: Does not retroactively adjust SFX that are already playing. + /// @param {Real} _vol volume (0-1) + static set_sfx_volume = function(_vol) { + __sfx_volume = clamp(_vol, 0, 1); + }; + + /// @desc Frees all runtime-loaded audio resources. + /// Call during game exit or when reloading audio assets. + static cleanup = function() { + __flush_cache(__music_cache); + __flush_cache(__sfx_cache); + + __music_cache = {}; + __sfx_cache = {}; + __context_playlists = {}; + __last_context_track = {}; + current_audio_name = ""; + current_context = ""; + current_audio_id = -1; + }; +} diff --git a/scripts/AudioManager/AudioManager.yy b/scripts/AudioManager/AudioManager.yy new file mode 100644 index 0000000000..75cce68204 --- /dev/null +++ b/scripts/AudioManager/AudioManager.yy @@ -0,0 +1,13 @@ +{ + "$GMScript":"v1", + "%Name":"AudioManager", + "isCompatibility":false, + "isDnD":false, + "name":"AudioManager", + "parent":{ + "name":"Constructors", + "path":"folders/Scripts/Constructors.yy", + }, + "resourceType":"GMScript", + "resourceVersion":"2.0", +} \ No newline at end of file diff --git a/scripts/ColourItem/ColourItem.gml b/scripts/ColourItem/ColourItem.gml index 7b4f6b4e38..43897716e6 100644 --- a/scripts/ColourItem/ColourItem.gml +++ b/scripts/ColourItem/ColourItem.gml @@ -15,7 +15,7 @@ function fetch_marine_components_to_memory() { } } catch (_exception) { // Sprite prefetch failure logged but non-fatal - show_debug_message($"Sprite prefetch failed for element at index {_index}: {_exception}"); + LOGGER.debug($"Sprite prefetch failed for element at index {_index}: {_exception}"); } }); } diff --git a/scripts/SettingsManager/SettingsManager.gml b/scripts/SettingsManager/SettingsManager.gml index f034b2b5cc..2d0b5485b6 100644 --- a/scripts/SettingsManager/SettingsManager.gml +++ b/scripts/SettingsManager/SettingsManager.gml @@ -58,8 +58,9 @@ function SettingsManager() constructor { static apply_audio = function() { audio_master_gain(master_volume); - audio_group_set_gain(audiogroup_music, music_volume); - audio_group_set_gain(audiogroup_sfx, sfx_volume); + + global.audio_manager.set_music_volume(music_volume); + global.audio_manager.set_sfx_volume(sfx_volume); }; static sync_ui = function() { diff --git a/scripts/scr_buttons/scr_buttons.gml b/scripts/scr_buttons/scr_buttons.gml index 5831db6a18..52c85400ec 100644 --- a/scripts/scr_buttons/scr_buttons.gml +++ b/scripts/scr_buttons/scr_buttons.gml @@ -433,7 +433,7 @@ function SpriteButton(data) constructor { alpha_idle = 0.8; alpha_disabled = 0.5; - sound_click = snd_click; + sound_click = SFX_CLICK; tooltip_text = ""; tooltip_w = 300; @@ -461,8 +461,8 @@ function SpriteButton(data) constructor { tooltip_draw(tooltip_text, tooltip_w); } - if (is_clicked && sound_click != undefined) { - audio_play_sound(sound_click, 10, false); + if (is_clicked && sound_click != "") { + global.audio_manager.play_sfx(sound_click); } } @@ -924,7 +924,7 @@ function UIDropdown(_options, _width = 180, _on_change = undefined) constructor if (_is_hovering_main && mouse_button_clicked()) { is_open = !is_open; - audio_play_sound(snd_click, 10, false); + global.audio_manager.play_sfx(SFX_CLICK); } if (!is_open) { @@ -980,7 +980,7 @@ function UIDropdown(_options, _width = 180, _on_change = undefined) constructor selected_index = i; is_open = false; _selection = options[i].value; - audio_play_sound(snd_click, 10, false); + global.audio_manager.play_sfx(SFX_CLICK); if (is_callable(on_change)) { on_change(_selection); @@ -1390,7 +1390,7 @@ function ToggleButton(data = {}) constructor { static clicked = function() { if (hover() && mouse_button_clicked()) { active = !active; - audio_play_sound(snd_click_small, 10, false); + global.audio_manager.play_sfx(SFX_CLICK_SMALL); return true; } else { return false; @@ -1521,10 +1521,10 @@ function InteractiveButton(data = {}) constructor { static clicked = function() { if (hover() && mouse_button_clicked()) { if (!active) { - audio_play_sound(snd_error, 10, false); + global.audio_manager.play_sfx(SFX_ERROR); return false; } else { - audio_play_sound(snd_click_small, 10, false); + global.audio_manager.play_sfx(SFX_CLICK_SMALL); return true; } } else { diff --git a/scripts/scr_controller_helpers/scr_controller_helpers.gml b/scripts/scr_controller_helpers/scr_controller_helpers.gml index 17bdfaf731..c78546e4b9 100644 --- a/scripts/scr_controller_helpers/scr_controller_helpers.gml +++ b/scripts/scr_controller_helpers/scr_controller_helpers.gml @@ -448,7 +448,7 @@ function scr_end_turn() { with (obj_star_event) { instance_destroy(); } - audio_play_sound(snd_end_turn, -50, false); + global.audio_manager.play_sfx(SFX_END_TURN); turn += 1; with (obj_star) { diff --git a/scripts/scr_creation/scr_creation.gml b/scripts/scr_creation/scr_creation.gml index bdc4f9c481..a5002329ec 100644 --- a/scripts/scr_creation/scr_creation.gml +++ b/scripts/scr_creation/scr_creation.gml @@ -458,9 +458,7 @@ function scr_creation(slide_num) { } instance_create(0, 0, obj_ini); - audio_stop_all(); - audio_play_sound(snd_royal, 0, true); - audio_sound_gain(snd_royal, 1, 5000); + global.audio_manager.play_playlist(CONTEXT_SECTOR, 5000); if (founding == eCHAPTERS.SALAMANDERS || global.chapter_id == eCHAPTERS.SALAMANDERS) { obj_ini.skin_color = 1; diff --git a/scripts/scr_librarium/scr_librarium.gml b/scripts/scr_librarium/scr_librarium.gml index 6e8de3b812..e445796a91 100644 --- a/scripts/scr_librarium/scr_librarium.gml +++ b/scripts/scr_librarium/scr_librarium.gml @@ -51,7 +51,7 @@ function scr_librarium_gui() { requisition -= 150; cooldown = 8000; identifiable = 0; - audio_play_sound(snd_identify, -500, false); + global.audio_manager.play_sfx(SFX_IDENTIFY); } } } else if (cur_arti.identified() < 1) { diff --git a/scripts/scr_music/scr_music.gml b/scripts/scr_music/scr_music.gml deleted file mode 100644 index ac4a7e0bbf..0000000000 --- a/scripts/scr_music/scr_music.gml +++ /dev/null @@ -1,19 +0,0 @@ -function scr_music(argument0, argument1) { - // argument0: target sound - // argument1: wait timer - - if (real(global.sound_playing) != 0) { - audio_sound_gain(global.sound_playing, 0, 2000); - } - if (real(global.sound_playing) == 0) { - if (audio_is_playing(snd_blood)) { - audio_sound_gain(snd_blood, 0, 2000); - } - if (audio_is_playing(snd_royal)) { - audio_sound_gain(snd_royal, 0, 2000); - } - } - - obj_controller.sound_to = string(argument0); - obj_controller.sound_in = round(argument1 / 33); -} diff --git a/scripts/scr_predicate/scr_predicate.gml b/scripts/scr_predicate/scr_predicate.gml new file mode 100644 index 0000000000..b2df81f083 --- /dev/null +++ b/scripts/scr_predicate/scr_predicate.gml @@ -0,0 +1,38 @@ +// https://forum.gamemaker.io/index.php?threads/gml-basics-passing-arguments-to-predicates.123957/ + +// Usage: +// var _variable = 5; +// var _callback = predicate0(_variable, function(_argument) { +// `_argument` variable with value `5` exists in this scope now; +// }); +// Then pass `_callback` to any function that accepts callback. + +/// @desc Return a predicate that runs func with only the provided trailing argument. +/// @param {Any} arg The trailing argument. +/// @param {function} func The function to run (should ideally take 1 argument). +/// @returns {Function} +function predicate0(arg, func) { + var _self = self; + return method({ + vals: 0, + arg: arg, + func: is_undefined(method_get_self(func)) ? method(_self, func) : func, + }, function() { + return func(arg); + }); +} + +/// @desc Return a predicate that accepts 1 argument and runs func with it and the provided trailing argument. +/// @param {Any} arg The trailing argument. +/// @param {function} func The function to run (should ideally take 2 arguments). +/// @returns {function} +function predicate1(arg, func) { + var _self = self; + return method({ + vals: 1, + arg: arg, + func: is_undefined(method_get_self(func)) ? method(_self, func) : func, + }, function(v) { + return func(v, arg); + }); +} diff --git a/scripts/scr_music/scr_music.yy b/scripts/scr_predicate/scr_predicate.yy similarity index 56% rename from scripts/scr_music/scr_music.yy rename to scripts/scr_predicate/scr_predicate.yy index 1171705fb1..11eee9fcf1 100644 --- a/scripts/scr_music/scr_music.yy +++ b/scripts/scr_predicate/scr_predicate.yy @@ -1,12 +1,12 @@ { "$GMScript":"v1", - "%Name":"scr_music", + "%Name":"scr_predicate", "isCompatibility":false, "isDnD":false, - "name":"scr_music", + "name":"scr_predicate", "parent":{ - "name":"Sys", - "path":"folders/Scripts/Sys.yy", + "name":"Scripts", + "path":"folders/Scripts.yy", }, "resourceType":"GMScript", "resourceVersion":"2.0", diff --git a/scripts/scr_ui_diplomacy/scr_ui_diplomacy.gml b/scripts/scr_ui_diplomacy/scr_ui_diplomacy.gml index ae55a50632..64dea21a69 100644 --- a/scripts/scr_ui_diplomacy/scr_ui_diplomacy.gml +++ b/scripts/scr_ui_diplomacy/scr_ui_diplomacy.gml @@ -50,8 +50,8 @@ function intro_to_diplomacy(faction_enum) { function exit_diplomacy_dialogue() { obj_controller.menu_lock = false; - if (audio_is_playing(snd_blood) == true) { - scr_music("royal", 2000); + if (global.audio_manager.current_context == CONTEXT_DIPLOMACY) { + global.audio_manager.play_playlist(CONTEXT_SECTOR, 2000); } var _close_diplomacy = true; diff --git a/scripts/scr_ui_manage/scr_ui_manage.gml b/scripts/scr_ui_manage/scr_ui_manage.gml index 095d999c25..799bb9110d 100644 --- a/scripts/scr_ui_manage/scr_ui_manage.gml +++ b/scripts/scr_ui_manage/scr_ui_manage.gml @@ -580,11 +580,11 @@ function alternative_manage_views(x1, y1) { surface_reset_target(); // save to local game folder - var main_dir = working_directory + "/main"; + var main_dir = working_directory + "/Screenshots"; if (!directory_exists(main_dir)) { directory_create(main_dir); } - var base_name = main_dir + $"/marine_capture_{_unit.name()}_{_unit.marine_number}{_unit.company}"; + var base_name = main_dir + $"/marine_capture_{_unit.name()}_id{_unit.marine_number}_co{_unit.company}"; var extension = ".png"; var index = 0; var path;