Working on music cross fade and settings

This commit is contained in:
Vladislav Khorev 2026-06-24 20:58:31 +03:00
parent 84f9fa37a8
commit b8d79c7b7b
13 changed files with 779 additions and 332 deletions

BIN
audio/bishkek night.ogg (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -1,36 +0,0 @@
cmake_minimum_required(VERSION 3.10)
project(AudioPlayer)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Use pkg-config to find Vorbis
#find_package(PkgConfig REQUIRED)
#pkg_check_modules(VORBIS REQUIRED vorbis vorbisfile)
#pkg_check_modules(OGG REQUIRED ogg)
find_package(OpenAL REQUIRED)
add_library(audioplayer
src/AudioPlayer.cpp
include/AudioPlayer.hpp
)
target_include_directories(audioplayer
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
${OPENAL_INCLUDE_DIR}
${VORBIS_INCLUDE_DIRS}
${OGG_INCLUDE_DIRS}
)
target_link_libraries(audioplayer
PUBLIC
${OPENAL_LIBRARY}
${VORBIS_LIBRARIES}
${OGG_LIBRARIES}
)
# Test executable
add_executable(test_audio examples/test_audio.cpp)
target_link_libraries(test_audio PRIVATE audioplayer stdc++fs)
#git add ../../sounds

View File

@ -1,32 +0,0 @@
#include "AudioPlayer.hpp"
#include <iostream>
#include <thread>
#include <chrono>
#include <string>
int main() {
try {
AudioPlayer player;
const std::string filename = "Symphony No.6 (1st movement).ogg";
std::cout << "🔍 Looking for file: " << filename << " in sounds directory...\n";
if (!player.playFromSoundsDir(filename)) {
std::cout << "❌ Failed to play audio file\n";
return 1;
}
std::cout << "✅ Playing symphony...\n";
// Check status for 30 seconds
for (int i = 0; i < 30; ++i) {
std::cout << "📊 Status: " << (player.isPlaying() ? "Playing ▶️" : "Stopped ⏹️") << "\n";
std::this_thread::sleep_for(std::chrono::seconds(1));
}
return 0;
} catch (const std::exception& e) {
std::cerr << "❌ Error: " << e.what() << "\n";
return 1;
}
}

View File

@ -1,37 +0,0 @@
#pragma once
#include <string>
#include <AL/al.h>
#include <AL/alc.h>
#include <vorbis/vorbisfile.h>
#include <vector>
#include <cstdint>
class AudioPlayer {
public:
AudioPlayer();
~AudioPlayer();
// Для музыки с зацикливанием (если filename пустой - продолжает играть текущую)
bool playMusic(const std::string& filename = "");
// Для одноразовых звуковых эффектов
bool playSound(const std::string& filename);
void stop();
bool isPlaying() const;
private:
ALCdevice* device;
ALCcontext* context;
ALuint musicSource; // Источник для музыки
ALuint soundSource; // Источник для звуков
ALuint musicBuffer; // Буфер для музыки
ALuint soundBuffer; // Буфер для звуков
bool playing;
std::string currentMusic; // Хранит имя текущего музыкального файла
std::vector<char> loadOgg(const std::string& filename, ALuint buffer);
std::string findFileInSounds(const std::string& filename);
bool isOggFile(const std::string& filename) const;
};

View File

@ -1,194 +0,0 @@
#include "AudioPlayer.hpp"
#include <filesystem>
#include <fstream>
#include <iostream>
#include <stdexcept>
#include <cstdint>
#include <algorithm>
AudioPlayer::AudioPlayer() : device(nullptr), context(nullptr),
musicSource(0), soundSource(0), musicBuffer(0), soundBuffer(0), playing(false) {
device = alcOpenDevice(nullptr);
if (!device) {
throw std::runtime_error("Failed to open audio device");
}
context = alcCreateContext(device, nullptr);
if (!context) {
alcCloseDevice(device);
throw std::runtime_error("Failed to create audio context");
}
alcMakeContextCurrent(context);
alGenSources(1, &musicSource);
alGenSources(1, &soundSource);
alGenBuffers(1, &musicBuffer);
alGenBuffers(1, &soundBuffer);
}
AudioPlayer::~AudioPlayer() {
if (musicSource)
alDeleteSources(1, &musicSource);
if (soundSource)
alDeleteSources(1, &soundSource);
if (musicBuffer)
alDeleteBuffers(1, &musicBuffer);
if (soundBuffer)
alDeleteBuffers(1, &soundBuffer);
if (context) {
alcMakeContextCurrent(nullptr);
alcDestroyContext(context);
}
if (device)
alcCloseDevice(device);
}
bool AudioPlayer::isOggFile(const std::string& filename) const {
std::string ext = std::filesystem::path(filename).extension().string();
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
return ext == ".ogg";
}
std::string AudioPlayer::findFileInSounds(const std::string& filename) {
// Primary search path - "sounds" directory next to executable
std::filesystem::path soundsDir = std::filesystem::current_path() / "sounds";
// Alternative search paths
std::vector<std::filesystem::path> altPaths = {
std::filesystem::current_path() / ".." / "sounds", // One level up
std::filesystem::current_path() / ".." / ".." / "sounds", // Two levels up
"/home/albert/gay-jam/ZeptoLabTest1/sounds" // Absolute path
};
std::cout << "🔍 Searching for \"" << filename << "\" in:\n";
std::cout << " " << soundsDir << "\n";
if (std::filesystem::exists(soundsDir / filename)) {
return (soundsDir / filename).string();
}
// Try alternative paths
for (const auto& path : altPaths) {
std::cout << " " << path << "\n";
if (std::filesystem::exists(path / filename)) {
return (path / filename).string();
}
}
throw std::runtime_error("❌ File not found: " + filename);
}
std::vector<char> AudioPlayer::loadOgg(const std::string& filename, ALuint buffer) {
FILE* file = fopen(filename.c_str(), "rb");
if (!file) {
throw std::runtime_error("Cannot open file: " + filename);
}
OggVorbis_File vf;
if (ov_open_callbacks(file, &vf, nullptr, 0, OV_CALLBACKS_DEFAULT) < 0) {
fclose(file);
throw std::runtime_error("Input not an Ogg file: " + filename);
}
vorbis_info* vi = ov_info(&vf, -1);
std::vector<char> audioData;
char data[4096];
int bitstream;
long bytes;
do {
bytes = ov_read(&vf, data, sizeof(data), 0, 2, 1, &bitstream);
if (bytes > 0) {
audioData.insert(audioData.end(), data, data + bytes);
}
} while (bytes > 0);
// Setup the buffer with the audio data
alBufferData(buffer,
(vi->channels == 1) ? AL_FORMAT_MONO16 : AL_FORMAT_STEREO16,
audioData.data(),
audioData.size(),
vi->rate);
ov_clear(&vf);
return audioData;
}
bool AudioPlayer::playMusic(const std::string& filename) {
try {
// Если filename пустой, просто проверяем играет ли музыка
if (filename.empty()) {
if (!isPlaying()) {
alSourcei(musicSource, AL_LOOPING, AL_TRUE); // Включаем зацикливание
alSourcePlay(musicSource);
}
return true;
}
// Если filename не пустой, загружаем новую музыку
if (!isOggFile(filename)) {
std::cerr << "❌ Error: Music file must be an .ogg file\n";
return false;
}
std::string fullPath = findFileInSounds(filename);
std::cout << "✅ Found music file: " << fullPath << "\n";
// Останавливаем текущую музыку
alSourceStop(musicSource);
// Загружаем и настраиваем новую музыку
loadOgg(fullPath, musicBuffer);
alSourcei(musicSource, AL_BUFFER, musicBuffer);
alSourcei(musicSource, AL_LOOPING, AL_TRUE); // Включаем зацикливание
std::cout << "▶️ Starting music playback... " << musicSource << std::endl;
std::cout << "▶️ Music buffer... " << musicBuffer << std::endl;
alSourcePlay(musicSource);
currentMusic = filename;
playing = true;
return true;
} catch (const std::exception& e) {
std::cerr << "❌ Error playing music: " << e.what() << std::endl;
return false;
}
}
bool AudioPlayer::playSound(const std::string& filename) {
try {
if (!isOggFile(filename)) {
std::cerr << "❌ Error: Sound file must be an .ogg file\n";
return false;
}
std::string fullPath = findFileInSounds(filename);
std::cout << "✅ Found sound file: " << fullPath << "\n";
// Загружаем и настраиваем звук
loadOgg(fullPath, soundBuffer);
alSourcei(soundSource, AL_BUFFER, soundBuffer);
alSourcei(soundSource, AL_LOOPING, AL_FALSE); // Выключаем зацикливание
std::cout << "▶️ Playing sound effect...\n";
alSourcePlay(soundSource);
return true;
} catch (const std::exception& e) {
std::cerr << "❌ Error playing sound: " << e.what() << std::endl;
return false;
}
}
void AudioPlayer::stop() {
alSourceStop(musicSource);
alSourceStop(soundSource);
playing = false;
}
bool AudioPlayer::isPlaying() const {
ALint state;
alGetSourcei(musicSource, AL_SOURCE_STATE, &state);
return state == AL_PLAYING;
}

View File

@ -0,0 +1,407 @@
{
"dialogues": [
{
"id": "door_dialog001",
"start": "line_1",
"nodes": [
{
"id": "line_1",
"type": "Line",
"speaker": "Бекзат",
"portrait": "resources/dialogue/portrait_hero_neutral.png",
"text": "Дверь закрыта на ключ.",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "dialog_contaier001",
"start": "line_1",
"nodes": [
{
"id": "line_1",
"type": "Line",
"speaker": "Бекзат",
"portrait": "resources/dialogue/portrait_hero_neutral.png",
"text": "Это куча строительного мусора, я не буду в ней копаться!",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "dialog_contaier003",
"start": "line_1",
"nodes": [
{
"id": "line_1",
"type": "Line",
"speaker": "Бекзат",
"portrait": "resources/dialogue/portrait_hero_neutral.png",
"text": "Я уже забрал курсовую работу Бегимай, больше мне незачем сюда лезть.",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "dialog_contaier002",
"start": "line_1",
"nodes": [
{
"id": "line_1",
"type": "Line",
"speaker": "Бекзат",
"portrait": "resources/dialogue/portrait_hero_neutral.png",
"text": "Если Алик не соврал, курсовая работа Бегимай лежит где-то тут.",
"next": "line_2"
},
{
"id": "line_2",
"type": "Line",
"speaker": "Бекзат",
"portrait": "resources/dialogue/portrait_hero_neutral.png",
"text": "[Копается в куче мусора]",
"next": "line_3"
},
{
"id": "line_3",
"type": "Line",
"speaker": "Бекзат",
"portrait": "resources/dialogue/portrait_hero_neutral.png",
"text": "Ага, нашел! Вот и она!",
"luaCallback": "dialog_contaier002_get_coursework",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "dialog_contaier003",
"start": "line_1",
"nodes": [
{
"id": "line_1",
"type": "Line",
"speaker": "Бекзат",
"portrait": "resources/dialogue/portrait_hero_neutral.png",
"text": "Я уже нашел в этой куче мусора то, что мне нужно.",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "dialog_taxi001",
"start": "line_1",
"nodes": [
{
"id": "line_1",
"type": "Line",
"speaker": "Бекзат",
"portrait": "resources/dialogue/portrait_hero_neutral.png",
"text": "Прежде чем выходить за ворота, я должен заказать такси до общаги.",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "dialog_taxi002",
"start": "line_1",
"nodes": [
{
"id": "line_1",
"type": "Line",
"speaker": "Бекзат",
"portrait": "resources/dialogue/portrait_hero_neutral.png",
"text": "Я заказал такси до общаги, машина уже ждет!",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "dialog_taxi004",
"start": "line_1",
"nodes": [
{
"id": "line_1",
"type": "Line",
"speaker": "Бекзат",
"portrait": "resources/dialogue/portrait_hero_neutral.png",
"text": "Я уже заказал такси, машина уже ждет!",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "dialog_video001",
"start": "line_1",
"nodes": [
{
"id": "line_1",
"type": "Line",
"speaker": "Бекзат",
"portrait": "resources/dialogue/portrait_hero_neutral.png",
"text": "Ого, пока я залипал в приложении, уже наступила ночь!",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "dialog_video002",
"start": "line_1",
"nodes": [
{
"id": "line_1",
"type": "Line",
"speaker": "Бекзат",
"portrait": "resources/dialogue/portrait_hero_neutral.png",
"text": "Я не буду сейчас смотреть видеоролики, давай лучше вернемся в общагу и пойдем спать.",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "dialog_video003",
"start": "line_1",
"nodes": [
{
"id": "line_1",
"type": "Line",
"speaker": "Бекзат",
"portrait": "resources/dialogue/portrait_hero_neutral.png",
"text": "Мне некогда деградировать сегодня, у меня много дел!",
"next": "end_1"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "dialog_chat_parents001",
"start": "line_1",
"nodes": [
{
"id": "line_1",
"type": "Line",
"speaker": "Отец",
"portrait": "resources/dialogue/portrait_phone.png",
"text": "Бекзат, сынок, мы c мамой тебе отправили немного денег, постарайся прожить на эти деньги до конца недели!",
"next": "line_2",
"chatBubble": "in"
},
{
"id": "line_2",
"type": "Line",
"speaker": "Бекзат",
"portrait": "resources/dialogue/portrait_phone.png",
"text": "Спасибо!",
"next": "end_1",
"chatBubble": "out"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "dialog_chat_news001",
"start": "line_1",
"nodes": [
{
"id": "line_1",
"type": "Line",
"speaker": "Отец",
"portrait": "resources/dialogue/portrait_phone.png",
"text": "Жители Бишкека все чаще жалуются на депрессию и апатию. Смотрите свежее видео об этом на нашем канале!",
"next": "end_1",
"chatBubble": "in"
},
{
"id": "end_1",
"type": "End"
}
]
},
{
"id": "dialog_chat_aiperi001",
"start": "line_1",
"nodes": [
{
"id": "line_1",
"type": "Line",
"speaker": "Айпери",
"portrait": "resources/dialogue/portrait_phone.png",
"text": "Бекзат, помнишь мы скидывались на торт для Аиды Джаныбековой? Я тогда еще приносила скатерть, тарелки и нож для торта. И я до сих пор не получила назад ничего.",
"next": "line_2",
"chatBubble": "in"
},
{
"id": "line_2",
"type": "Line",
"speaker": "Бекзат",
"portrait": "resources/dialogue/portrait_phone.png",
"text": "Скатерть и тарелки вроде бы лежат в студзоне.",
"next": "line_3",
"chatBubble": "out"
},
{
"id": "line_3",
"type": "Line",
"speaker": "Айпери",
"portrait": "resources/dialogue/portrait_phone.png",
"text": "А нож?",
"next": "line_4",
"chatBubble": "in"
},
{
"id": "line_4",
"type": "Line",
"speaker": "Бекзат",
"portrait": "resources/dialogue/portrait_phone.png",
"text": "Нож, наверное, так и остался в учительской.",
"next": "line_5",
"chatBubble": "out"
},
{
"id": "line_5",
"type": "Line",
"speaker": "Айпери",
"portrait": "resources/dialogue/portrait_phone.png",
"text": "А давай не \"наверное\"?",
"next": "line_6",
"chatBubble": "in"
},
{
"id": "line_6",
"type": "Line",
"speaker": "Айпери",
"portrait": "resources/dialogue/portrait_phone.png",
"text": "А давай ты приедешь в универ, зайдешь в учительскую, заберешь нож и отдашь мне?",
"next": "line_7",
"chatBubble": "in"
},
{
"id": "line_7",
"type": "Line",
"speaker": "Айпери",
"portrait": "resources/dialogue/portrait_phone.png",
"text": "У вас сегодня как раз Аида ведет лекцию. После лекции попросишь у нее ключи от учительской и заберешь нож.",
"next": "line_8",
"chatBubble": "in"
},
{
"id": "line_8",
"type": "Line",
"speaker": "Бекзат",
"portrait": "resources/dialogue/portrait_phone.png",
"text": "Почему ты сама не можешь забрать?",
"next": "line_9",
"chatBubble": "out"
},
{
"id": "line_9",
"type": "Line",
"speaker": "Айпери",
"portrait": "resources/dialogue/portrait_phone.png",
"text": "Ты же знаешь, если я встречу Аиду, она 100% даст мне какое-нибудь сложное задание.",
"next": "line_10",
"chatBubble": "in"
},
{
"id": "line_10",
"type": "Line",
"speaker": "Айпери",
"portrait": "resources/dialogue/portrait_phone.png",
"text": "И потом, это ты у меня брал нож, с чего я должна ходить искать его по всему универу?",
"next": "line_11",
"chatBubble": "in"
},
{
"id": "line_11",
"type": "Line",
"speaker": "Айпери",
"portrait": "resources/dialogue/portrait_phone.png",
"text": "Так что жду тебя в универе! Не вздумай прогулять!",
"next": "setflag_1",
"chatBubble": "in",
"questUnlock": "aiperi_knife"
},
{
"id": "setflag_1",
"type": "SetFlag",
"effects": [
{
"flag": "aiperi_knife_aware",
"value": 1
}
],
"next": "end_1"
},
{
"id": "end_1",
"luaCallback": "on_aiperi_dialog_over",
"type": "End"
}
]
},
{
"id": "dialog_chat_aiperi002",
"start": "line_1",
"nodes": [
{
"id": "line_1",
"type": "Line",
"speaker": "Айпери",
"portrait": "resources/dialogue/portrait_phone.png",
"text": "Бекзат, ты где? Я жду тебя возле лестницы.",
"next": "end_1",
"chatBubble": "in"
},
{
"id": "end_1",
"type": "End"
}
]
}
]
}

View File

@ -11,6 +11,98 @@
"height": "match_parent", "height": "match_parent",
"texture": "resources/w/ui/img/main/bkg.png" "texture": "resources/w/ui/img/main/bkg.png"
}, },
{
"type": "LinearLayout",
"orientation": "vertical",
"horizontal_gravity": "center",
"vertical_gravity": "center",
"horizontal_align": "left",
"width": 500,
"height": 600,
"spacing": 20,
"children": [
{
"type": "TextView",
"name": "settingsTitleText",
"width": 500,
"height": 60,
"text": "Settings",
"fontSize": 40,
"textCentered": true,
"color": [1.0, 1.0, 1.0, 1.0]
},
{
"type": "TextView",
"name": "musicVolumeText",
"width": 500,
"height": 40,
"text": "Music volume: 100",
"fontSize": 32,
"textCentered": false,
"topAligned": true,
"paddingX": 10,
"color": [1.0, 1.0, 1.0, 1.0]
},
{
"type": "Slider",
"name": "musicVolumeSlider",
"width": 500,
"height": 40,
"orientation": "horizontal",
"value": 0.78125,
"interactive": true,
"textures": {
"track": "resources/w/red.png",
"knob": "resources/w/blue.png"
}
},
{
"type": "TextView",
"name": "soundVolumeText",
"width": 500,
"height": 40,
"text": "Sound volume: 80",
"fontSize": 32,
"textCentered": false,
"topAligned": true,
"paddingX": 10,
"color": [1.0, 1.0, 1.0, 1.0]
},
{
"type": "Slider",
"name": "soundVolumeSlider",
"width": 500,
"height": 40,
"orientation": "horizontal",
"value": 0.625,
"interactive": true,
"textures": {
"track": "resources/w/red.png",
"knob": "resources/w/blue.png"
}
},
{
"type": "TextButton",
"name": "musicToggleButton",
"width": 500,
"height": 60,
"text": "Music: ON",
"fontSize": 32,
"textCentered": true,
"color": [0.2, 0.9, 0.2, 1.0]
},
{
"type": "TextButton",
"name": "soundToggleButton",
"width": 500,
"height": 60,
"text": "Sound: ON",
"fontSize": 32,
"textCentered": true,
"color": [0.2, 0.9, 0.2, 1.0]
}
]
},
{ {
"type": "TextButton", "type": "TextButton",
"name": "settingsBackButton", "name": "settingsBackButton",

View File

@ -43,6 +43,12 @@ bool AudioPlayerAsync::init() {
void AudioPlayerAsync::shutdown() { void AudioPlayerAsync::shutdown() {
if (!initialized) return; if (!initialized) return;
if (currentMusic_) {
Mix_HaltMusic();
Mix_FreeMusic(currentMusic_);
currentMusic_ = nullptr;
}
{ {
std::lock_guard<std::mutex> lock(soundCacheMutex); std::lock_guard<std::mutex> lock(soundCacheMutex);
for (auto& pair : soundCache) { for (auto& pair : soundCache) {
@ -62,6 +68,7 @@ void AudioPlayerAsync::playSoundAsync(const std::string& filePath, int loops, in
std::cerr << "AudioPlayerAsync not initialized" << std::endl; std::cerr << "AudioPlayerAsync not initialized" << std::endl;
return; return;
} }
if (!soundEnabled_) return;
auto task = [this, filePath, loops, channel]() { auto task = [this, filePath, loops, channel]() {
Mix_Chunk* sound = nullptr; Mix_Chunk* sound = nullptr;
@ -99,20 +106,20 @@ void AudioPlayerAsync::playSoundAsync(const std::string& filePath, int loops, in
} }
void AudioPlayerAsync::playMusicAsync(const std::string& filePath, int loops) { void AudioPlayerAsync::playMusicAsync(const std::string& filePath, int loops) {
std::cout << "AudioPlayerAsync::playMusicAsync called step 1" << std::endl;
if (!initialized) return; if (!initialized) return;
std::cout << "AudioPlayerAsync::playMusicAsync called step 1" << std::endl;
if (filePath == currentTrack) if (filePath == currentTrack)
{
return; return;
}
currentTrack = filePath; currentTrack = filePath;
if (!musicEnabled_) return;
auto task = [filePath, loops]() { auto task = [this, filePath, loops]() {
std::cout << "AudioPlayerAsync::playMusicAsync called step 3" << std::endl; Mix_HaltMusic();
if (currentMusic_) {
Mix_FreeMusic(currentMusic_);
currentMusic_ = nullptr;
}
Mix_Music* music = Mix_LoadMUS(filePath.c_str()); Mix_Music* music = Mix_LoadMUS(filePath.c_str());
if (!music) { if (!music) {
std::cerr << "Failed to load music " << filePath << ": " << Mix_GetError() << std::endl; std::cerr << "Failed to load music " << filePath << ": " << Mix_GetError() << std::endl;
@ -121,7 +128,9 @@ void AudioPlayerAsync::playMusicAsync(const std::string& filePath, int loops) {
if (Mix_PlayMusic(music, loops) == -1) { if (Mix_PlayMusic(music, loops) == -1) {
std::cerr << "Mix_PlayMusic failed: " << Mix_GetError() << std::endl; std::cerr << "Mix_PlayMusic failed: " << Mix_GetError() << std::endl;
Mix_FreeMusic(music); Mix_FreeMusic(music);
return;
} }
currentMusic_ = music;
}; };
#ifdef __EMSCRIPTEN__ #ifdef __EMSCRIPTEN__
@ -178,6 +187,7 @@ void AudioPlayerAsync::resumeMusicAsync() {
void AudioPlayerAsync::setMusicVolume(int volume) { void AudioPlayerAsync::setMusicVolume(int volume) {
if (!initialized) return; if (!initialized) return;
volume = std::max(0, std::min(128, volume)); volume = std::max(0, std::min(128, volume));
musicVolume_ = volume;
#ifdef __EMSCRIPTEN__ #ifdef __EMSCRIPTEN__
Mix_VolumeMusic(volume); Mix_VolumeMusic(volume);
#else #else
@ -192,6 +202,7 @@ void AudioPlayerAsync::setMusicVolume(int volume) {
void AudioPlayerAsync::setSoundVolume(int volume) { void AudioPlayerAsync::setSoundVolume(int volume) {
if (!initialized) return; if (!initialized) return;
volume = std::max(0, std::min(128, volume)); volume = std::max(0, std::min(128, volume));
soundVolume_ = volume;
#ifdef __EMSCRIPTEN__ #ifdef __EMSCRIPTEN__
Mix_Volume(-1, volume); Mix_Volume(-1, volume);
#else #else
@ -203,6 +214,117 @@ void AudioPlayerAsync::setSoundVolume(int volume) {
#endif #endif
} }
void AudioPlayerAsync::crossFadeMusicAsync(const std::string& filePath, int fadeDurationMs, int loops) {
if (!initialized) return;
if (filePath == currentTrack) return;
currentTrack = filePath;
if (!musicEnabled_) return;
auto task = [this, filePath, fadeDurationMs, loops]() {
#ifdef __EMSCRIPTEN__
Mix_HaltMusic();
if (currentMusic_) { Mix_FreeMusic(currentMusic_); currentMusic_ = nullptr; }
#else
if (Mix_PlayingMusic()) {
Mix_FadeOutMusic(fadeDurationMs / 2);
SDL_Delay(fadeDurationMs / 2 + 20);
}
Mix_HaltMusic();
if (currentMusic_) { Mix_FreeMusic(currentMusic_); currentMusic_ = nullptr; }
#endif
Mix_Music* music = Mix_LoadMUS(filePath.c_str());
if (!music) {
std::cerr << "Failed to load music " << filePath << ": " << Mix_GetError() << std::endl;
return;
}
if (Mix_FadeInMusic(music, loops, fadeDurationMs / 2) == -1) {
std::cerr << "Mix_FadeInMusic failed: " << Mix_GetError() << std::endl;
Mix_FreeMusic(music);
return;
}
currentMusic_ = music;
};
#ifdef __EMSCRIPTEN__
task();
#else
std::unique_lock<std::mutex> lock(mtx);
taskQueue.push(std::move(task));
cv.notify_one();
#endif
}
void AudioPlayerAsync::crossFadeMusicFromPositionAsync(const std::string& filePath, int fadeDurationMs, int loops) {
if (!initialized) return;
if (filePath == currentTrack) return;
currentTrack = filePath;
if (!musicEnabled_) return;
auto task = [this, filePath, fadeDurationMs, loops]() {
double position = 0.0;
if (currentMusic_ && Mix_PlayingMusic()) {
double pos = Mix_GetMusicPosition(currentMusic_);
if (pos >= 0.0) position = pos;
}
#ifndef __EMSCRIPTEN__
if (Mix_PlayingMusic()) {
Mix_FadeOutMusic(fadeDurationMs / 2);
SDL_Delay(fadeDurationMs / 2 + 20);
}
#endif
Mix_HaltMusic();
if (currentMusic_) { Mix_FreeMusic(currentMusic_); currentMusic_ = nullptr; }
Mix_Music* music = Mix_LoadMUS(filePath.c_str());
if (!music) {
std::cerr << "Failed to load music " << filePath << ": " << Mix_GetError() << std::endl;
return;
}
if (Mix_FadeInMusicPos(music, loops, fadeDurationMs / 2, position) == -1) {
std::cerr << "Mix_FadeInMusicPos failed: " << Mix_GetError() << std::endl;
Mix_FreeMusic(music);
return;
}
currentMusic_ = music;
};
#ifdef __EMSCRIPTEN__
task();
#else
std::unique_lock<std::mutex> lock(mtx);
taskQueue.push(std::move(task));
cv.notify_one();
#endif
}
void AudioPlayerAsync::setMusicEnabled(bool enabled) {
if (musicEnabled_ == enabled) return;
musicEnabled_ = enabled;
if (!initialized) return;
if (!enabled) {
// Halt playback but keep currentTrack so we can restart it on re-enable.
#ifdef __EMSCRIPTEN__
Mix_HaltMusic();
#else
std::unique_lock<std::mutex> lock(mtx);
taskQueue.push([]() { Mix_HaltMusic(); });
cv.notify_one();
#endif
} else {
// Restart the last requested track, if any.
if (!currentTrack.empty()) {
const std::string track = currentTrack;
currentTrack = ""; // clear so crossFadeMusicAsync doesn't bail early
crossFadeMusicAsync(track);
}
}
}
void AudioPlayerAsync::setSoundEnabled(bool enabled) {
soundEnabled_ = enabled;
}
#ifndef __EMSCRIPTEN__ #ifndef __EMSCRIPTEN__
void AudioPlayerAsync::workerThread() { void AudioPlayerAsync::workerThread() {
while (true) { while (true) {

View File

@ -24,11 +24,20 @@ public:
void playSoundAsync(const std::string& filePath, int loops = 0, int channel = -1); void playSoundAsync(const std::string& filePath, int loops = 0, int channel = -1);
void playMusicAsync(const std::string& filePath, int loops = -1); void playMusicAsync(const std::string& filePath, int loops = -1);
void crossFadeMusicAsync(const std::string& filePath, int fadeDurationMs = 1000, int loops = -1);
void crossFadeMusicFromPositionAsync(const std::string& filePath, int fadeDurationMs = 1000, int loops = -1);
void stopMusicAsync(); void stopMusicAsync();
void pauseMusicAsync(); void pauseMusicAsync();
void resumeMusicAsync(); void resumeMusicAsync();
void setMusicVolume(int volume); // 0..128 void setMusicVolume(int volume); // 0..128
void setSoundVolume(int volume); // 0..128 void setSoundVolume(int volume); // 0..128
int getMusicVolume() const { return musicVolume_; }
int getSoundVolume() const { return soundVolume_; }
void setMusicEnabled(bool enabled);
void setSoundEnabled(bool enabled);
bool isMusicEnabled() const { return musicEnabled_; }
bool isSoundEnabled() const { return soundEnabled_; }
void exit() { stop = true; } void exit() { stop = true; }
@ -47,6 +56,12 @@ private:
std::mutex soundCacheMutex; std::mutex soundCacheMutex;
std::string currentTrack; std::string currentTrack;
Mix_Music* currentMusic_ = nullptr;
int musicVolume_ = 128;
int soundVolume_ = 128;
bool musicEnabled_ = true;
bool soundEnabled_ = true;
bool initialized = false; bool initialized = false;
}; };

View File

@ -327,7 +327,7 @@ namespace ZL
menuManager.startGameFunc = [this]() { menuManager.startGameFunc = [this]() {
gameState.currentLocationName = "location_dorm"; gameState.currentLocationName = "location_dorm";
currentLocation()->scriptEngine.callLocationEnterCallback(); currentLocation()->scriptEngine.callLocationEnterCallback();
this->audioPlayer->playMusicAsync("audio/obshaga.ogg"); this->audioPlayer->crossFadeMusicAsync("audio/obshaga.ogg");
}; };
menuManager.onSaveGame = [this](int slot) { saveGame(slot); }; menuManager.onSaveGame = [this](int slot) { saveGame(slot); };
@ -343,7 +343,9 @@ namespace ZL
std::cout << "Audio initialization failed" << std::endl; std::cout << "Audio initialization failed" << std::endl;
} }
audioPlayer->playMusicAsync("audio/main menu final.ogg"); menuManager.loadSettings();
audioPlayer->crossFadeMusicAsync("audio/main menu final.ogg");
loadingCompleted = true; loadingCompleted = true;
@ -453,10 +455,10 @@ namespace ZL
} }
gameState.locations["uni_interior"]->requestDarklandsPlayBattleMusic = [this]() { gameState.locations["uni_interior"]->requestDarklandsPlayBattleMusic = [this]() {
audioPlayer->playMusicAsync("audio/bishkek fight.ogg"); audioPlayer->crossFadeMusicFromPositionAsync("audio/bishkek fight.ogg");
}; };
gameState.locations["uni_interior"]->requestDarklandsPlayNormalMusic = [this]() { gameState.locations["uni_interior"]->requestDarklandsPlayNormalMusic = [this]() {
audioPlayer->playMusicAsync("audio/bishkek fight calm.ogg"); audioPlayer->crossFadeMusicFromPositionAsync("audio/bishkek fight calm.ogg");
}; };
LocationSetup uniExteriorParams = uniInteriorParams; LocationSetup uniExteriorParams = uniInteriorParams;
uniExteriorParams.gameObjectsJsonPath = "resources/config2/gameobjects_uni_exterior_x.json"; uniExteriorParams.gameObjectsJsonPath = "resources/config2/gameobjects_uni_exterior_x.json";

View File

@ -1,7 +1,9 @@
#include "MenuManager.h" #include "MenuManager.h"
#include "Game.h" #include "Game.h"
#include "render/TextRenderer.h" #include "render/TextRenderer.h"
#include "utils/Utils.h"
#include <iostream> #include <iostream>
#include <fstream>
#include <algorithm> #include <algorithm>
#include <string> #include <string>
@ -252,7 +254,7 @@ namespace ZL {
void MenuManager::showMainMenu() { void MenuManager::showMainMenu() {
if (onResetGame) onResetGame(); if (onResetGame) onResetGame();
audioPlayer_.playMusicAsync("audio/main menu final.ogg"); audioPlayer_.crossFadeMusicAsync("audio/main menu final.ogg");
uiState_ = GameUiState::MainMenu; uiState_ = GameUiState::MainMenu;
uiManager.clearMenuStack(); uiManager.clearMenuStack();
@ -294,9 +296,72 @@ namespace ZL {
void MenuManager::showSettingsScreen() { void MenuManager::showSettingsScreen() {
uiManager.pushMenuFromSavedRoot(settingsScreenRoot); uiManager.pushMenuFromSavedRoot(settingsScreenRoot);
uiManager.setTextButtonCallback("settingsBackButton", [this](const std::string&) { uiManager.setTextButtonCallback("settingsBackButton", [this](const std::string&) {
saveSettings();
uiManager.popMenu(); uiManager.popMenu();
uiManager.updateAllLayouts(); uiManager.updateAllLayouts();
});
// Set initial slider positions before registering callbacks so
// setSliderValue does not fire an unregistered callback.
const float musicFrac = audioPlayer_.getMusicVolume() / 128.0f;
const float soundFrac = audioPlayer_.getSoundVolume() / 128.0f;
uiManager.setSliderValue("musicVolumeSlider", musicFrac);
uiManager.setSliderValue("soundVolumeSlider", soundFrac);
uiManager.setText("musicVolumeText",
"Music volume: " + std::to_string(audioPlayer_.getMusicVolume()));
uiManager.setText("soundVolumeText",
"Sound volume: " + std::to_string(audioPlayer_.getSoundVolume()));
uiManager.setSliderCallback("musicVolumeSlider",
[this](const std::string&, float value) {
const int vol = static_cast<int>(value * 128.0f + 0.5f);
audioPlayer_.setMusicVolume(vol);
uiManager.setText("musicVolumeText",
"Music volume: " + std::to_string(vol));
}); });
uiManager.setSliderCallback("soundVolumeSlider",
[this](const std::string&, float value) {
const int vol = static_cast<int>(value * 128.0f + 0.5f);
audioPlayer_.setSoundVolume(vol);
uiManager.setText("soundVolumeText",
"Sound volume: " + std::to_string(vol));
});
// Music on/off toggle — helper to sync button text and color from current state
auto refreshMusicToggle = [this]() {
const bool on = audioPlayer_.isMusicEnabled();
uiManager.setTextButtonText("musicToggleButton", on ? "Music: ON" : "Music: OFF");
uiManager.setTextButtonColor("musicToggleButton", on
? std::array<float, 4>{ 0.2f, 0.9f, 0.2f, 1.0f }
: std::array<float, 4>{ 0.9f, 0.2f, 0.2f, 1.0f });
};
auto refreshSoundToggle = [this]() {
const bool on = audioPlayer_.isSoundEnabled();
uiManager.setTextButtonText("soundToggleButton", on ? "Sound: ON" : "Sound: OFF");
uiManager.setTextButtonColor("soundToggleButton", on
? std::array<float, 4>{ 0.2f, 0.9f, 0.2f, 1.0f }
: std::array<float, 4>{ 0.9f, 0.2f, 0.2f, 1.0f });
};
refreshMusicToggle();
refreshSoundToggle();
uiManager.setTextButtonCallback("musicToggleButton", [this](const std::string&) {
audioPlayer_.setMusicEnabled(!audioPlayer_.isMusicEnabled());
const bool on = audioPlayer_.isMusicEnabled();
uiManager.setTextButtonText("musicToggleButton", on ? "Music: ON" : "Music: OFF");
uiManager.setTextButtonColor("musicToggleButton", on
? std::array<float, 4>{ 0.2f, 0.9f, 0.2f, 1.0f }
: std::array<float, 4>{ 0.9f, 0.2f, 0.2f, 1.0f });
});
uiManager.setTextButtonCallback("soundToggleButton", [this](const std::string&) {
audioPlayer_.setSoundEnabled(!audioPlayer_.isSoundEnabled());
const bool on = audioPlayer_.isSoundEnabled();
uiManager.setTextButtonText("soundToggleButton", on ? "Sound: ON" : "Sound: OFF");
uiManager.setTextButtonColor("soundToggleButton", on
? std::array<float, 4>{ 0.2f, 0.9f, 0.2f, 1.0f }
: std::array<float, 4>{ 0.9f, 0.2f, 0.2f, 1.0f });
});
} }
void MenuManager::showLoadGameScreen() void MenuManager::showLoadGameScreen()
@ -906,29 +971,29 @@ namespace ZL {
setupGameplayHudCallbacks(); setupGameplayHudCallbacks();
if (gameState_.isDarklands) if (gameState_.isDarklands)
{ {
audioPlayer_.playMusicAsync("audio/bishkek fight calm.ogg"); audioPlayer_.crossFadeMusicAsync("audio/bishkek fight calm.ogg");
} }
else if (gameState_.isNight) else if (gameState_.isNight)
{ {
audioPlayer_.playMusicAsync("audio/bishkek fight calm.ogg"); audioPlayer_.crossFadeMusicAsync("audio/bishkek night.ogg");
} }
else else
{ {
audioPlayer_.playMusicAsync("audio/bishkek univer day.ogg"); audioPlayer_.crossFadeMusicAsync("audio/bishkek univer day.ogg");
} }
} else if (locationName == "uni_interior") { } else if (locationName == "uni_interior") {
applyUniIntHud(); applyUniIntHud();
if (gameState_.isDarklands) if (gameState_.isDarklands)
{ {
audioPlayer_.playMusicAsync("audio/bishkek fight calm.ogg"); audioPlayer_.crossFadeMusicAsync("audio/bishkek fight calm.ogg");
} }
else if (gameState_.isNight) else if (gameState_.isNight)
{ {
audioPlayer_.playMusicAsync("audio/bishkek fight calm.ogg"); audioPlayer_.crossFadeMusicAsync("audio/bishkek night.ogg");
} }
else else
{ {
audioPlayer_.playMusicAsync("audio/bishkek univer day.ogg"); audioPlayer_.crossFadeMusicAsync("audio/bishkek univer day.ogg");
} }
} else { } else {
// Returning to dorm: reuse step5ab, suppress already-completed hints // Returning to dorm: reuse step5ab, suppress already-completed hints
@ -938,11 +1003,11 @@ namespace ZL {
if (gameState_.isNight) if (gameState_.isNight)
{ {
audioPlayer_.playMusicAsync("audio/bishkek fight calm.ogg"); audioPlayer_.crossFadeMusicAsync("audio/bishkek night.ogg");
} }
else else
{ {
audioPlayer_.playMusicAsync("audio/obshaga.ogg"); audioPlayer_.crossFadeMusicAsync("audio/obshaga.ogg");
} }
} }
} }
@ -1452,4 +1517,37 @@ namespace ZL {
updateToasts(deltaMs); updateToasts(deltaMs);
} }
void MenuManager::saveSettings() {
nlohmann::json root;
root["musicVolume"] = audioPlayer_.getMusicVolume();
root["soundVolume"] = audioPlayer_.getSoundVolume();
root["musicEnabled"] = audioPlayer_.isMusicEnabled();
root["soundEnabled"] = audioPlayer_.isSoundEnabled();
std::ofstream file("settings.json");
if (file.is_open()) {
file << root.dump(2);
} else {
std::cerr << "[settings] Could not open settings.json for writing" << std::endl;
}
}
void MenuManager::loadSettings() {
const std::string content = ZL::readTextFile("settings.json");
if (content.empty()) return;
try {
const nlohmann::json root = nlohmann::json::parse(content);
if (root.contains("musicVolume"))
audioPlayer_.setMusicVolume(root["musicVolume"].get<int>());
if (root.contains("soundVolume"))
audioPlayer_.setSoundVolume(root["soundVolume"].get<int>());
if (root.contains("musicEnabled"))
audioPlayer_.setMusicEnabled(root["musicEnabled"].get<bool>());
if (root.contains("soundEnabled"))
audioPlayer_.setSoundEnabled(root["soundEnabled"].get<bool>());
} catch (const std::exception& e) {
std::cerr << "[settings] Failed to parse settings.json: " << e.what() << std::endl;
}
}
} // namespace ZL } // namespace ZL

View File

@ -93,6 +93,9 @@ namespace ZL {
void onCutsceneFinished(); void onCutsceneFinished();
bool cutsceneHudActive_ = false; bool cutsceneHudActive_ = false;
void saveSettings();
void loadSettings();
// Toast notification system // Toast notification system
void showToast(const std::string& iconPath, const std::string& text); void showToast(const std::string& iconPath, const std::string& text);
void update(float deltaMs); void update(float deltaMs);

View File

@ -327,16 +327,16 @@ namespace ZL {
knobMesh.data.PositionData.clear(); knobMesh.data.PositionData.clear();
knobMesh.data.TexCoordData.clear(); knobMesh.data.TexCoordData.clear();
float kw = vertical ? rect.w * 4.0f : rect.w * 0.5f; // Knob is built centered at the middle of the track rect.
float kh = vertical ? rect.w * 4.0f : rect.h * 0.5f; // draw() applies a per-frame translation so the knob tracks the current value
// without needing to rebuild the VBO on every value change.
float cx = rect.x + rect.w * 0.5f; const float kSize = vertical ? rect.w * 2.0f : rect.h;
float cy = rect.y + (vertical ? (value * rect.h) : (rect.h * 0.5f)); const float cx = rect.x + rect.w * 0.5f;
const float cy = rect.y + rect.h * 0.5f;
float x0 = cx - kw * 0.5f; const float x0 = cx - kSize * 0.5f;
float y0 = cy - kh * 0.5f; const float y0 = cy - kSize * 0.5f;
float x1 = cx + kw * 0.5f; const float x1 = cx + kSize * 0.5f;
float y1 = cy + kh * 0.5f; const float y1 = cy + kSize * 0.5f;
knobMesh.data.PositionData.push_back({ x0, y0, 0 }); knobMesh.data.PositionData.push_back({ x0, y0, 0 });
knobMesh.data.TexCoordData.push_back({ 0, 0 }); knobMesh.data.TexCoordData.push_back({ 0, 0 });
@ -366,8 +366,15 @@ namespace ZL {
renderer.DrawVertexRenderStruct(trackMesh); renderer.DrawVertexRenderStruct(trackMesh);
} }
if (texKnob) { if (texKnob) {
// The knob mesh is built centered at the middle of the track rect.
// Translate it so its center lands on the value position along the track.
const float offsetX = vertical ? 0.0f : (value - 0.5f) * rect.w;
const float offsetY = vertical ? (value - 0.5f) * rect.h : 0.0f;
renderer.PushMatrix();
renderer.TranslateMatrix({ offsetX, offsetY, 0.0f });
glBindTexture(GL_TEXTURE_2D, texKnob->getTexID()); glBindTexture(GL_TEXTURE_2D, texKnob->getTexID());
renderer.DrawVertexRenderStruct(knobMesh); renderer.DrawVertexRenderStruct(knobMesh);
renderer.PopMatrix();
} }
} }
@ -1301,7 +1308,6 @@ namespace ZL {
if (fabs(s->value - value) < 1e-6f) return true; if (fabs(s->value - value) < 1e-6f) return true;
s->value = value; s->value = value;
s->buildTrackMesh(); s->buildTrackMesh();
s->buildKnobMesh();
if (s->onValueChanged) s->onValueChanged(s->name, s->value); if (s->onValueChanged) s->onValueChanged(s->name, s->value);
return true; return true;
} }
@ -1796,7 +1802,6 @@ namespace ZL {
if (t > 1.0f) t = 1.0f; if (t > 1.0f) t = 1.0f;
s->value = t; s->value = t;
s->buildTrackMesh(); s->buildTrackMesh();
s->buildKnobMesh();
if (s->onValueChanged) s->onValueChanged(s->name, s->value); if (s->onValueChanged) s->onValueChanged(s->name, s->value);
} }
} }
@ -1843,7 +1848,6 @@ namespace ZL {
if (t > 1.0f) t = 1.0f; if (t > 1.0f) t = 1.0f;
s->value = t; s->value = t;
s->buildTrackMesh(); s->buildTrackMesh();
s->buildKnobMesh();
if (s->onValueChanged) s->onValueChanged(s->name, s->value); if (s->onValueChanged) s->onValueChanged(s->name, s->value);
break; break;
} }