Cocos2d-JS: load external audio assets and javascript codesCocos2d-JS: загрузить внешние аудио файлы и javascript код
Today I would like to tell about a quite complicated problem, which I have recently managed to resolve. The major sense of it can be found in the title of the post. One may as a question: what is the way this feature can be useful? It seems like no one was wondering this problem before, because Google was extremely poor for the answers. It is very logical and obvious: this is so much easier to debug, isn’t it? In order to try out new sounds or changes in the level logic, you do not need to recompile the entire project each time, you don’t even need to restart the application instead. Let me tell you how I managed to do it.
Сегодня я хотел бы поведать о достаточно сложной задаче, которую мне недавно удалось решить. Краткую суть ее можно увидеть в заголовке поста. Может возникнуть вопрос: зачем такое вообще могло понадобиться? Такое ощущение, что никто до меня этим вопросом не задавался, ибо гугл был крайне скуден на ответы. Ведь это же очень логично и очевидно — так гораздо легче отлаживать. Для того, чтобы опробовать новый звук или изменения в уровневой логике, не нужно перекомпилировать каждый раз проект, можно даже не перезапускать приложение. Сейчас расскажу, как мне удалось этого добиться.
It is important to make a little remark. Though the project is cross-platform (target platforms — iOS and Android), the given solution is going to be basically for iOS, because the considering functionality is basically required for the period of debugging, and I prefer to debug mobile projects on iOS devices. However, this solution is applicable for Android as well, you just need to add a bit of native code. Here is the way it works briefly: a collection of game sounds is stored on an external server (where they can be manipulated: add, delete, change), also there is a sort of service screen in the game, where the assets are being loaded.
Files are stored locally in the standard Documents folder. I also provided a kind of versioning based on checksums calculation — it allows to avoid not to download all files every time, only those that was really changed. I designed a web-based console for more convenience.
I’m not going to describe all updating mechanics here — it is not the essence of this article. Let’s see how these files get into the JS environment. Sounds playback is performed by AudioEngine module which is the part of Cocos2d framework. You can playback audio file with following:
cc.audioEngine.playEffect(soundPath, repeat);
here soundPath is the path to audio asset. The main mysterious was that all manuals and forums claimed this parameter was a relative path, and Cocos2d is looking for specified file in the directories listed in searchPath collection. You can add the item to this collection the following way:
jsb.fileUtils.addSearchPath(resPath);
but, as I figured out, resPath is also considered by Cocos2d as a relative, so it looks for this file in the Application Bundle folder. The salvation was in the fact that as a parameter soundPath playEffect function, you can specify an absolute path! All we have to do is to supply playEffect function with the path to an audio file that can be located anywhere in the file system. How to tell a javascript code, where to get the audio file? In order to do this, the creators of cocos2d provided a binding mechanism for javascript (JSB), with the help of which native Objective-C methods can be called from javascript code. One option of this interaction is to create Objective-C class with a set of static methods, which will be called from a javascript-code. Here is the example:
#import
#define LOAD_REMOTE_ASSETS YES
@interface NativeOcClass : NSObject
+(NSString *)callNativeLoaderPathForAudioAsset:(NSString *)soundId;
+(NSString *)callNativeLoaderLevelSource:(NSString *)campaign :(NSString *)levelName;
@end
I declared two methods here (the second one will be described a bit later). For now we going to speak about first method callNativeLoaderPathForAudioAsset: — this method returns the absolute path to an audio file with soundId identifier specified. Here is the implementation:
+(NSString *)callNativeLoaderPathForAudioAsset:(NSString *)soundId {
if (!LOAD_REMOTE_ASSETS)
return @"";
NSUserDefaults * ud = [NSUserDefaults standardUserDefaults];
NSDictionary * files = [ud objectForKey: @"remoteAssets"];
NSString * revisionKey = [NSString stringWithFormat:@"revision_%@", soundId];
NSString * assetsDirPath = [NativeOcClass callNativeDocumentsResPath];
NSString * audioPath = [assetsDirPath stringByAppendingPathComponent:@"res/audio"];
NSString * soundIdRevision = [NSString stringWithFormat:@"%@_%@", soundId, [files objectForKey:revisionKey]];
NSString * filePath = [audioPath stringByAppendingPathComponent:soundIdRevision];
if ([[NSFileManager defaultManager] fileExistsAtPath:filePath])
return filePath;
return @"";
}
Don’t look at the logic related to download mechanics. The major idea is that this method can return an absolute path to the file or empty string if it doesn’t find a requested file in the Documents folder. In this case javascript code will use asset from Application bundle folder. This method is called from javascript:
soundPathForCode: function(soundId) {
var filePath = (jsb.reflection.callStaticMethod("NativeOcClass", "callNativeLoaderPathForAudioAsset:", soundId));
if (filePath!="")
return filePath;
return "res/audio/" + Sounds[soundId];
},
here NativeOcClass is the class where callNativeLoaderPathForAudioAsset: method is declared. As you can see it’s pretty straight forward. As I previously said we specify the path we received from callNativeLoaderPathForAudioAsset methods as an argument of playEffect function.
var soundPath = this.soundPathForCode("player_jump");
cc.audioEngine.playEffect(soundPath, repeat);
I used the same approach for implementing remote downloads of level’s javascript code. callNativeLoaderLevelSource is designed for this purpose. It returns javascript code string of level which is synchronized from the server exactly as the sound assets. The only difference with audio files is that this code is being interpreted by eval mechanism of Javascript engine.
recreateLevelIfNeeded: function(campaign, levelNumber) {
var jsFileName = "level"+levelNumber;
var levelJsCode = jsb.reflection.callStaticMethod("NativeOcClass", "callNativeLoaderLevelSource::", campaign.toLowerCase(), jsFileName);
if (levelJsCode != "") {
eval(levelJsCode);
}
},
Следует сразу оговориться. Хоть проект и кроссплатформенный (целевые платформы — iOS и Android), приведенное решение будет преимущественно для iOS, поскольку рассматриваемая функциональность мне нужна только на период отладки, а отлаживать я предпочитаю на iOS устройствах. Тем не менее, для андроида это решение также применимо, нужно только немного дописать нативный код. И так, как это работает в двух словах: коллекция игровых звуков хранится на внешнем сервере (ими там можно манипулировать: добавлять, удалять, менять), затем в самой игре есть некий служебный экран, где собственно происходит загрузка ассетов.
Файлы сохраняются локально внутри стандартной папки Documents. Также я предусмотрел некоторую версионность, основанную на контрольных суммах — чтобы не загружать каждый раз все файлы, а только изменившиеся. Для удобства я сконструировал веб-консоль.
Всю механику обновления я здесь описывать не стану — это не есть суть статьи. Перейдем к тому, как эти ассеты попадают в JS окружение. Проигрыванием звуков занимается модуль AudioEngine, являющийся частью фреймворка Cocos2d. Проигрывание аудио-файла можно осуществить следующим образом
cc.audioEngine.playEffect(soundPath, repeat);
где soundPath — путь к аудио-ассету. Загвоздка была в том, что во всех мануалах и на форумах говорится, что это относительный путь, и что кокос ищет указанный файл в директориях, перечисленных в коллекции searchPath. Добавить элемент в эту коллекцию можно так:
jsb.fileUtils.addSearchPath(resPath);
но, как оказалось, resPath так же может быть только относительным, то есть данный путь будет внутри папки, где лежат все запчасти билда (Applicaition Bundle). Спасением стало то, что в качестве параметра soundPath функции playEffect можно указать абсолютный путь! Таким образом, все, что нам осталось сделать, это подсунуть функции playEffect путь к аудиофайлу, который может находится в любом месте файловой системы. Как сообщить javascript коду, где брать аудио файл? Для этого создатели cocos2d очень разумно предусмотрели механизм javascript binding (JSB), за счет которого можно вызывать из javascript кода нативные методы Objective-C Один из вариантов данного взаимодействия заключается в создании Objective-C класса с набором статических методов, которые и будут вызываться из javascript-кода. Вот пример:
#import
#define LOAD_REMOTE_ASSETS YES
@interface NativeOcClass : NSObject
+(NSString *)callNativeLoaderPathForAudioAsset:(NSString *)soundId;
+(NSString *)callNativeLoaderLevelSource:(NSString *)campaign :(NSString *)levelName;
@end
Я здесь объявил два метода, о втором я расскажу чуть ниже. Сейчас речь пойдет callNativeLoaderPathForAudioAsset: — данный метод возвращает абсолютный путь к аудио файлу с идентификатором soundId. Вот его реализация:
+(NSString *)callNativeLoaderPathForAudioAsset:(NSString *)soundId {
if (!LOAD_REMOTE_ASSETS)
return @"";
NSUserDefaults * ud = [NSUserDefaults standardUserDefaults];
NSDictionary * files = [ud objectForKey: @"remoteAssets"];
NSString * revisionKey = [NSString stringWithFormat:@"revision_%@", soundId];
NSString * assetsDirPath = [NativeOcClass callNativeDocumentsResPath];
NSString * audioPath = [assetsDirPath stringByAppendingPathComponent:@"res/audio"];
NSString * soundIdRevision = [NSString stringWithFormat:@"%@_%@", soundId, [files objectForKey:revisionKey]];
NSString * filePath = [audioPath stringByAppendingPathComponent:soundIdRevision];
if ([[NSFileManager defaultManager] fileExistsAtPath:filePath])
return filePath;
return @"";
}
Не обращайте внимание на дополнительную логику связанную с механикой подгрузки файлов с сервера. Главная идея в том, что данный метод может вернуть абсолютный путь к аудиофайлу или пустую строку, если запрашиваемый файл отсутствует в папке Documents. В данном случае javasсript код будет использовать ассет, находящийся в бандле приложения (Application Bundle). Из javascript этот метод вызывается так:
soundPathForCode: function(soundId) {
var filePath = (jsb.reflection.callStaticMethod("NativeOcClass", "callNativeLoaderPathForAudioAsset:", soundId));
if (filePath!="")
return filePath;
return "res/audio/" + Sounds[soundId];
},
здесь NativeOcClass — класс, внутри которого объявлен метод callNativeLoaderPathForAudioAsset: Как видите, все довольно просто. Далее, как я уже и говорил, мы указываем полученный путь в качестве аргумента вызова функции playEffect
var soundPath = this.soundPathForCode("player_jump");
cc.audioEngine.playEffect(soundPath, repeat);
Таким же образом я сделал удаленную загрузку javascript кода уровней. Для этого в классе NativeOcClass объявлен метод callNativeLoaderLevelSource — он возвращает строку с javascript кодом уровня, который так же как и аудио ассеты синхронизируется с сервера. Только в отличие от аудиофайлов данный код интерпретируется механизмом eval движка Javascript.
recreateLevelIfNeeded: function(campaign, levelNumber) {
var jsFileName = "level"+levelNumber;
var levelJsCode = jsb.reflection.callStaticMethod("NativeOcClass", "callNativeLoaderLevelSource::", campaign.toLowerCase(), jsFileName);
if (levelJsCode != "") {
eval(levelJsCode);
}
},