diff --git a/.github/CONTRIBUTORS.md b/.github/CONTRIBUTORS.md index 5c5170c8..7cd4b420 100644 --- a/.github/CONTRIBUTORS.md +++ b/.github/CONTRIBUTORS.md @@ -10,7 +10,7 @@ Project contributors listed chronologically. * Contributed LibGDX [collections](../collections) utilities. * [@sreich](https://github.com/sreich) * Contributed various utilities from the [Ore Infinium](https://github.com/sreich/ore-infinium) project. -* [@raincole](https://github.com/raincole) +* [@yhlai-code](https://github.com/yhlai-code) (_raincole_) * Contributed LibGDX [collections](../collections) utilities. * Provided insightful review of the [`Async`](../async) module. * [@Jkly](https://github.com/Jkly) @@ -46,6 +46,7 @@ Project contributors listed chronologically. * Contributed [actors](../actors) utilities. * Wrote a complete [KTX tutorial](https://github.com/Quillraven/SimpleKtxGame/wiki) based on the original LibGDX introduction. * Author of the [preferences](../preferences) module. + * Tested and reviewed the [assets async](../assets-async) module. * [@FocusPo1nt](https://github.com/FocusPo1nt) * Added utilities to [style module](../style). * [@maltaisn](https://github.com/maltaisn) diff --git a/CHANGELOG.md b/CHANGELOG.md index fed6140a..6435f050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,35 @@ - `PerformanceCounter.prettyPrint` allows to print basic performance data after profiling. - **[CHANGE]** (`ktx-app`) `LetterboxingViewport` moved from `ktx-app` to `ktx-graphics`. - **[FEATURE]** (`ktx-ashley`) Added `Entity.contains` (`in` operator) that checks if an `Entity` has a `Component`. +- **[FEATURE]** (`ktx-assets-async`) Added a new KTX module: coroutines-based asset loading. + - `AssetStorage` is a non-blocking coroutines-based alternative to LibGDX `AssetManager`. + - `get` operator obtains an asset from the storage or throws a `MissingAssetException`. + - `getOrNull` obtains an asset from the storage or return `null` if the asset is unavailable. + - `getAsync` obtains a reference to the asset from the storage as `Deferred`. + - `load` suspends a coroutine until an asset is loaded and returns its instance. + - `loadAsync` schedules asynchronous loading of an asset. + - `loadSync` blocks the thread until selected asset is loaded. + - `unload` schedules asynchronous unloading of an asset. + - `add` allows to manually add a loaded asset to `AssetManager`. + - `dispose` unloads all assets from the storage. + - `getLoader` and `setLoader` manage `AssetLoader` instances used to load assets. + - `isLoaded` checks if loading of an asset was finished. + - `contains` operator checks if the asset was scheduled for loading or added to the storage. + - `progress` allows to check asset loading progress. + - `getReferenceCount` returns how many times the asset was loaded or referenced by other assets as a dependency. + - `getDependencies` returns a list of dependencies of the selected asset. + - `getAssetDescriptor` creates an `AssetDescriptor` with loading data for the selected asset. + - `getIdentifier` creates an `Identifier` uniquely pointing to an asset of selected type and file path. + - `Identifier` data class added as an utility to uniquely identify assets by their type and path. + - `Identifier.toAssetDescriptor` allows to convert an `Identifier` to an `AssetDescriptor`. + - `AssetDescriptor.toIdentifier` allows to convert an `AssetDescriptor` to `Identifier` used to uniquely identify `AssetStorage` assets. + - `LoadingProgress` is an internal class used by the `AssetStorage` to track loading progress. - **[FEATURE]** (`ktx-async`) Added `RenderingScope` factory function for custom scopes using rendering thread dispatcher. +- **[FEATURE]** (`ktx-async`) `newAsyncContext` and `newSingleThreadAsyncContext` now support `threadName` parameter +that allows to set thread name pattern of `AsyncExecutor` threads. +- **[FIX]** (`ktx-async`) `isOnRenderingThread` now behaves consistently regardless of launching coroutine context. +- **[FEATURE]** (`ktx-freetype-async`) This KTX module is now restored and updated to the new `AssetStorage` API. +There are no public API changes since the last released version. - **[FEATURE]** (`ktx-graphics`) Added `LetterboxingViewport` from `ktx-app`. - **[FEATURE]** (`ktx-graphics`) Added `takeScreenshot` utility function that allows to save a screenshot of the application. - **[FEATURE]** (`ktx-graphics`) Added `BitmapFont.center` extension method that allows to center text on an object. diff --git a/README.md b/README.md index 13528959..5ad89a6a 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ Examples of Kotlin language features used to improve usability, performance and * *Extension methods* with sensible *default parameters*. * *Inline methods* with reduced runtime overhead for various listeners, builders and loggers. * *Nullable types* which improve typing information of selected interfaces and functions. -* *Default interface methods* simplifying the implementation. +* *Default interface methods* for common interfaces, simplifying their implementations. * *Type-safe builders* for GUI, styling and physics engine. -* *Coroutines context* providing concurrency utilities. +* *Coroutines context* providing concurrency utilities and non-blocking asset loading. * *Reified types* that simplify usage of methods normally consuming `Class` parameters. See the [_Choosing KTX_](https://github.com/libktx/ktx/wiki/Choosing-KTX) article for pros and cons of this framework. @@ -33,17 +33,19 @@ You can include selected **KTX** modules based on the needs of your application. Module | Dependency name | Description :---: | :--- | --- -[actors](actors) | `ktx-actors` | General [`Scene2D`](https://github.com/libgdx/libgdx/wiki/Scene2d) GUI utilities for stages, actors, actions and event listeners. -[app](app) | `ktx-app` | `ApplicationListener` implementations and other general application utilities. +[actors](actors) | `ktx-actors` | [`Scene2D`](https://github.com/libgdx/libgdx/wiki/Scene2d) GUI extensions for stages, actors, actions and event listeners. +[app](app) | `ktx-app` | `ApplicationListener` implementations and general application utilities. [ashley](ashley) | `ktx-ashley` | [`Ashley`](https://github.com/libgdx/ashley) entity-component-system utilities. [assets](assets) | `ktx-assets` | Resources management utilities. +[assets-async](assets-async) | `ktx-assets-async` | Non-blocking asset loading using coroutines. [async](async) | `ktx-async` | [Coroutines](https://kotlinlang.org/docs/reference/coroutines.html) context based on LibGDX threading model. [box2d](box2d) | `ktx-box2d` | [`Box2D`](https://github.com/libgdx/libgdx/wiki/Box2d) physics engine utilities. [collections](collections) | `ktx-collections` | Extensions for LibGDX custom collections. -[freetype](freetype) | `ktx-freetype` | `FreeType` font loading utilities. +[freetype](freetype) | `ktx-freetype` | `FreeType` fonts loading utilities. +[freetype-async](freetype-async) | `ktx-freetype-async` | Non-blocking `FreeType` fonts loading using coroutines. [graphics](graphics) | `ktx-graphics` | Utilities related to rendering tools and graphics. [i18n](i18n) | `ktx-i18n` | Internationalization API utilities. -[inject](inject) | `ktx-inject` | A simple dependency injection system with low overhead and no reflection usage. +[inject](inject) | `ktx-inject` | A dependency injection system with low overhead and no reflection usage. [json](json) | `ktx-json` | Utilities for LibGDX [JSON](https://github.com/libgdx/libgdx/wiki/Reading-and-writing-JSON) serialization API. [log](log) | `ktx-log` | Minimal runtime overhead cross-platform logging using inlined functions. [math](math) | `ktx-math` | Operator functions for LibGDX math API and general math utilities. @@ -51,8 +53,8 @@ Module | Dependency name | Description [scene2d](scene2d) | `ktx-scene2d` | Type-safe Kotlin builders for [`Scene2D`](https://github.com/libgdx/libgdx/wiki/Scene2d) GUI. [style](style) | `ktx-style` | Type-safe Kotlin builders for `Scene2D` widget styles extending `Skin` API. [tiled](tiled) | `ktx-tiled` | Utilities for [Tiled](https://www.mapeditor.org/) maps. -[vis](vis) | `ktx-vis` | Type-safe Kotlin builders for [`VisUI`](https://github.com/kotcrab/vis-ui/). An _alternative_ to the [scene2d](scene2d) module. -[vis-style](vis-style) | `ktx-vis-style` | Type-safe Kotlin builders for `VisUI` widget styles. An _extension_ of [style](style) module. +[vis](vis) | `ktx-vis` | Type-safe Kotlin builders for [`VisUI`](https://github.com/kotcrab/vis-ui/). An _alternative_ to [scene2d](scene2d). +[vis-style](vis-style) | `ktx-vis-style` | Type-safe Kotlin builders for `VisUI` widget styles. ### Installation diff --git a/assets-async/README.md b/assets-async/README.md new file mode 100644 index 00000000..004ca81c --- /dev/null +++ b/assets-async/README.md @@ -0,0 +1,945 @@ +[![Maven Central](https://img.shields.io/maven-central/v/io.github.libktx/ktx-assets-async.svg)](https://search.maven.org/artifact/io.github.libktx/ktx-assets-async) + +# KTX: asynchronous file loading + +Asset manager using coroutines to load assets asynchronously. + +### Why? + +LibGDX provides an `AssetManager` class for loading and managing assets. Even with [KTX extensions](../assets), +`AssetManager` is not fully compatible with Kotlin concurrency model based on coroutines due to thread blocking. +While it does support asynchronous asset loading, it uses only a single thread for asynchronous operations and +achieves its thread safety by synchronizing all of its methods. To achieve truly multi-threaded loading with +multiple threads for asynchronous loading, one must maintain multiple manager instances. Besides, its API relies +on polling - one must repeatedly update its state until the assets are loaded. + +This **KTX** module brings an `AssetManager` alternative - `AssetStorage`. It leverages Kotlin coroutines +for asynchronous operations. It ensures thread safety by using a single non-blocking `Mutex` for +a minimal set of operations mutating its state, while supporting truly multi-threaded asset loading +on any `CoroutineContext`. + +Feature | **KTX** `AssetStorage` | LibGDX `AssetManager` +--- | --- | --- +*Asynchronous loading* | **Supported.** Loading that can be done asynchronously is performed in the chosen coroutine context. Parts that require OpenGL context are performed on the main rendering thread. | **Supported.** Loading that can be performed asynchronously is done a dedicated thread, with necessary sections executed on the main rendering thread. +*Synchronous loading* | **Supported.** `loadSync` blocks the current thread until a selected asset is loaded. A blocking coroutine can also be launched to load selected assets eagerly, but it cannot block the rendering thread or loader threads to work correctly. | **Limited.** `finishLoading` method can be used to block the thread until the asset is loaded, but since it has no effect on loading order, it requires precise scheduling or it will block the thread until some or all unselected assets are loaded. +*Thread safety* | **Excellent.** Uses [`ktx-async`](../async) threading model based on coroutines. Executes blocking IO operations in a separate coroutine context and - when necessary - finishes loading on the main rendering thread. Same asset - or assets with same dependencies - can be safely scheduled for loading by multiple coroutines concurrently. Multi-threaded coroutine context can be used for asynchronous loading, possibly achieving loading performance boost. Concurrent `AssetStorage` usage is tested extensively by unit tests. | **Good.** Achieved through synchronizing most methods, which unfortunately blocks the threads that use them. Thread blocking might affect application performance, especially since even the basic `get` method is synchronized. Some operations, such as `update` or `finishLoading`, must be called from specific threads (i.e. rendering thread). +*Concurrency* | **Supported.** Multiple asset loading coroutines can be launched in parallel. Coroutine context used for asynchronous loading can have multiple threads that will be used concurrently. | **Limited.** `update()` loads assets one by one. `AsyncExecutor` with only a single thread is used internally by the `AssetManager`. To utilize multiple threads for loading, one must use multiple manager instances. +*Loading order* | **Controlled by the user.** With suspending `load`, synchronous `loadSync` and `Deferred`-returning `loadAsync`, the user can have full control over asset loading order and parallelization. Selected assets can be loaded one after another within a single coroutine or in parallel with multiple coroutines, depending on the need. | **Unpredictable.** If multiple assets are scheduled at once, it is difficult to reason about their loading order. `finishLoading` has no effect on loading order and instead blocks the thread until the selected asset is loaded. +*Exceptions* | **Customized.** All expected issues are given separate exception classes with common root type for easier handling. Each loading issue can be handled differently. | **Generic.** Throws either `GdxRuntimeException` or a built-in Java runtime exception. Specific issues are difficult to handle separately. +*Error handling* | **Build-in language syntax.** A regular try-catch block within coroutine body can be used to handle asynchronous loading errors. Provides a clean way to handle exceptions thrown by each asset separately. | **Via listener.** One can register a global error handling listener that will be notified if a loading exception is thrown. Flow of the application is undisturbed, which makes it difficult to handle exceptions of specific assets. +*File name collisions* | **Multiple assets of different types can be loaded from same path.** For example, you can load both a `Texture` and a `Pixmap` from the same PNG file. | **File paths act as unique identifiers.** `AssetManager` cannot store multiple assets with the same path, even if they have different types. +*Progress tracking* | **Supported with caveats.** `AssetStorage` does not force the users to schedule loading of all assets up front. To get the exact percent of loaded assets, all assets must be scheduled first (e.g. with `loadAsync`). | **Supported.** Since all loaded assets have to be scheduled up front, `AssetManager` can track total loading progress. +*Usage* | **Launch coroutine, load assets, use as soon as loaded.** Asynchronous complexity is hidden by the coroutines. | **Schedule loading, update in loop until loaded, extract from manager.** API based on polling _(are you done yet?),_ which might prove tedious during loading phase. Loading callbacks for individual assets are available, but have obscure API and still require constant updating of the manager. + +#### Usage comparison + +Here's an example of a simple application that loads three assets and switches to the next view, +passing the loaded assets. + +Implemented using LibGDX `AssetManager`: + +```kotlin +class Application: ApplicationAdapter() { + private lateinit var assetManager: AssetManager + + override fun create() { + assetManager = AssetManager().apply { + // Assets scheduled for loading up front. Notice no returns. + load("logo.png", Texture::class.java) + load("i18n.properties", I18NBundle::class.java) + load("ui.json", Skin::class.java) + } + } + + override fun render() { + // Manager requires constant updating: + if (assetManager.update()) { + // When update() returns true, all scheduled assets are loaded: + finishLoading() + } + // Render loading prompt here. + // Other than performance impact of calling synchronized update() each frame, + // AssetManager does not block the rendering thread. + } + + private fun finishLoading() { + assetManager.apply { + // Assets have to be retrieved from the manager manually: + val logo = get("logo.png") + val bundle = get("i18n.properties") + val skin = get("ui.json") + goToNextView(logo, bundle, skin) + } + } + + private fun goToNextView(logo: Texture, bundle: I18NBundle, skin: Skin) { TODO() } +} +``` + +The same use case rewritten with **KTX** `AssetStorage`: + +```kotlin +class Application: ApplicationAdapter() { + override fun create() { + // ktx-async module requires initiating Kotlin coroutines context: + KtxAsync.initiate() + val assetStorage = AssetStorage() + + // Launching asynchronous coroutine that will _not_ block the rendering thread: + KtxAsync.launch { + assetStorage.apply { + // Loading assets. Notice the immediate returns. + // The coroutine will suspend until each asset is loaded: + val logo = load("logo.png") + val bundle = load("i18n.properties") + val skin = load("ui.json") + // Assets are loaded and we already have references to all of them: + goToNextView(logo, bundle, skin) + } + } + } + + override fun render() { + // Render loading prompt. AssetStorage does not block the rendering thread. + } + + private fun goToNextView(logo: Texture, bundle: I18NBundle, skin: Skin) { TODO() } +} +``` + +Without the polling-based `AssetManager` API with its constant `update()` calls and +non-returning `load`, application using `AssetStorage` is shorter and easier to read. + +After the assets are loaded, `AssetStorage` and `AssetManager` behave more or less the same: +they store assets mapped by their file path that can be retrieved or disposed on demand. +In case of the `AssetManager`, assets are uniquely identified by their paths; `AssetStorage` +identifies assets by their paths and types, i.e. you can load multiple assets with different +classes from the same file. + +The key difference between **KTX** storage and LibGDX manager is the threading model: + `AssetManager` leverages only a single thread for asynchronous loading operations and ensures +thread safety by relying on the `synchronized` methods, +while `AssetStorage` can utilize any chosen number of threads specified by its coroutine +context and uses non-blocking coroutines to load the assets. + +### Guide + +#### Setup + +See [`ktx-async`](../async) setup section to enable coroutines in your project. + +`KtxAsync.initiate()` must be called on the main rendering thread before `AssetStorage` is used. + +#### API + +`AssetStorage` contains the following core methods: + +- `get: T` - returns a loaded asset or throws `MissingAssetException` if the asset is unavailable. +- `getOrNull: T?` - returns a loaded asset or `null` if the asset is unavailable. +- `getAsync: Deferred` - returns a `Deferred` reference to the asset. Suspending `await()` can be +called to obtain the asset instance. `isCompleted` can be used to check if the asset loading was finished. +- `load: T` _(suspending)_ - schedules asset for asynchronous loading. Suspends the coroutine until +the asset is fully loaded. Resumes the coroutine and returns the asset once it is loaded. +- `loadAsync: Deferred` - schedules asset for asynchronous loading. Returns a `Deferred` reference +to the asset which will be completed after the loading is finished. +- `loadSync: T` - blocks the current thread until the selected asset is loaded. Use _only outside of coroutines_ +for crucial assets that need to be loaded synchronously (e.g. loading screen assets). +- `unload: Boolean` _(suspending)_ - unloads the selected asset. If the asset is no longer referenced, +it will be removed from the storage and disposed of. Suspends the coroutine until the asset is unloaded. +Returns `true` is the selected asset was present in storage or `false` if the asset was absent. +- `add` _(suspending)_ - manually adds a fully loaded asset to storage. The storage will take care of +disposing of the asset. +- `dispose` (blocking and suspending variants available) - unloads all assets. Cancels all current +loadings. Depending on the variant, will block the current thread or suspend the coroutine until +all of the assets are unloaded. + +Additional asset management methods include: + +- `isLoaded: Boolean` - checks if the selected asset is fully loaded. +- `contains: Boolean` - checks if the selected asset is present in storage, loaded or not. +- `progress` - allows to access loading progress data. +- `getReferenceCount: Int` - allows to check how many times the asset was loaded, added or required +as dependency by other assets. Returns 0 if the asset is not present in the storage. +- `getDependencies: List` - returns list of dependencies of the selected asset. +If the asset is not present in the storage, an empty list will be returned. +- `getLoader: AssetLoader` - allows to obtain `AssetLoader` instance for the given file. +- `setLoader` - allows to associate a custom `AssetLoader` with the selected file and asset types. + +`AssetStorage` uniquely identifies assets by their path and `Class`. +Since these values can be passed in 3 basic ways, most methods are available in 3 variants: + +- Inlined, with reified type and `String` path parameter. +- With `Identifier` parameter, which stores `Class` and `String` path of the asset. +- With LibGDX `AssetDescriptor` storing `Class`, `String` file name and loading data of the asset. + +All three variants behave identically and are available for convenience. +If any asset data is missing from either `String` path or `Identifier`, additional parameters are available +to match the `AssetDescriptor` API. + +To ease the API usage, the following utilities are provided: + +- `AssetStorage.getAssetDescriptor` - creates an `AssetDescriptor` instance that has loading data of an asset. +- `AssetStorage.getIdentifier` - creates an `Identifier` instance that uniquely identifies a stored asset. +- `AssetDescriptor.toIdentifier` - converts an `AssetDescriptor` to an `Identifier`. +- `Identifier.toAssetDescriptor` - converts an `Identifier` to an `AssetDescriptor` with optional loading parameters. + +#### Error handling + +`AssetStorage` throws exceptions extending the `AssetStorageException` class. All of its subclasses are documented, +explaining when and why they are thrown. +Please refer to the [sources documentation](src/main/kotlin/ktx/assets/async/errors.kt) for further details. + +### Usage examples + +Note that usage example require basic understanding of the [`ktx-async`](../async) module and Kotlin +[coroutines](https://kotlinlang.org/docs/reference/coroutines.html). + +Creating an `AssetStorage` with default settings: + +```kotlin +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun create() { + // Necessary to initiate the coroutines context: + KtxAsync.initiate() + + val assetStorage = AssetStorage() +} +``` + +Customizing an `AssetStorage`. In this example a multi-threaded coroutine context +was assigned to storage, so the assets can be loaded in parallel on multiple threads: + +```kotlin +import com.badlogic.gdx.assets.loaders.resolvers.InternalFileHandleResolver +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync +import ktx.async.newAsyncContext + +fun create() { + KtxAsync.initiate() + + val assetStorage = AssetStorage( + // Used to perform asynchronous file loading: + asyncContext = newAsyncContext(threads = 4), + // Used for resolving file paths: + fileResolver = InternalFileHandleResolver(), + // Whether to add standard LibGDX loaders for common assets: + useDefaultLoaders = true + ) +} +``` + +Loading assets using `AssetStorage`: + +```kotlin +import com.badlogic.gdx.graphics.Texture +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun loadAsset(assetStorage: AssetStorage) { + // Launching a coroutine to load the assets asynchronously: + KtxAsync.launch { + // This will suspend the coroutine until the texture is loaded: + val texture = assetStorage.load("images/logo.png") + // Now the coroutine resumes and the texture can be used. + } +} +``` + +Loading assets with customized loading parameters: + +```kotlin +import com.badlogic.gdx.assets.loaders.TextureLoader.TextureParameter +import com.badlogic.gdx.graphics.Texture +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun loadAsset(assetStorage: AssetStorage) { + KtxAsync.launch { + // You can optionally specify loading parameters for each asset. + // AssetStorage reuses default LibGDX asset loaders and their + // parameters classes. + val texture = assetStorage.load( + path = "images/logo.png", + parameters = TextureParameter().apply { + genMipMaps = true + } + ) + // Now the texture is loaded and can be used. + } +} +``` + +Loading assets sequentially using `AssetStorage`: + +```kotlin +import com.badlogic.gdx.graphics.Texture +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun loadAssets(assetStorage: AssetStorage) { + // Unlike AssetManager, AssetStorage allows to easily control the order of loading. + KtxAsync.launch { + // This will suspend the coroutine until the texture is loaded: + val logo = assetStorage.load("images/logo.png") + // After the first image is loaded, + // the coroutine resumes and loads the second asset: + val background = assetStorage.load("images/background.png") + + // Now both images are loaded and can be used. + } +} +``` + +Loading assets asynchronously: + +```kotlin +import com.badlogic.gdx.graphics.Texture +import com.badlogic.gdx.graphics.g2d.BitmapFont +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun loadAssets(assetStorage: AssetStorage) { + // You can also schedule the assets for asynchronous loading + // without suspending the coroutine. + KtxAsync.launch { + // Launching asynchronous asset loading with Kotlin's built-in `async`: + val texture = async { assetStorage.load("images/logo.png") } + val font = async { assetStorage.load("fonts/font.fnt") } + + // Suspending the coroutine until both assets are loaded: + doSomethingWithTextureAndFont(texture.await(), font.await()) + } +} +``` + +Loading assets in parallel: + +```kotlin +import com.badlogic.gdx.graphics.Texture +import com.badlogic.gdx.graphics.g2d.BitmapFont +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync +import ktx.async.newAsyncContext + +fun loadAssets() { + // Using Kotlin's `async` will ensure that the coroutine is not + // immediately suspended and assets are scheduled asynchronously, + // but to take advantage of really parallel asset loading, we have + // to pass a context with multiple loading threads to AssetStorage: + val assetStorage = AssetStorage(asyncContext = newAsyncContext(threads = 2)) + + // Instead of using Kotlin's built-in `async`, you can also use + // the `loadAsync` method of AssetStorage with is a shortcut for + // `async(assetStorage.asyncContext) { assetStorage.load }`: + val texture = assetStorage.loadAsync("images/logo.png") + val font = assetStorage.loadAsync("fonts/font.fnt") + // Now both assets will be loaded asynchronously, in parallel. + + KtxAsync.launch { + // Suspending the coroutine until both assets are loaded: + doSomethingWithTextureAndFont(texture.await(), font.await()) + } +} +``` + +Unloading assets from `AssetStorage`: + +```kotlin +import com.badlogic.gdx.graphics.Texture +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun unloadAsset(assetStorage: AssetStorage) { + KtxAsync.launch { + // Suspends the coroutine until the asset is unloaded: + assetStorage.unload("images/logo.png") + + // When the coroutine resumes here, the asset is unloaded. + // If no other assets use it as dependency, it will + // be removed from the asset storage and disposed of. + + // Note that you can also do this asynchronously if you don't + // want to suspend the coroutine: + async { assetStorage.unload("images/logo.png") } + } +} +``` + +Accessing assets from `AssetStorage`: + +```kotlin +import com.badlogic.gdx.graphics.Texture +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun accessAsset(assetStorage: AssetStorage) { + // Typically you can simply use assets returned by `load`, + // but AssetStorage also allows you to access assets + // already loaded by other coroutines. + + // Immediately returns loaded asset or throws an exception if missing: + var texture = assetStorage.get("images/logo.png") + // If you specify the variable type, you can use the braces operator as well: + val thisIsA: Texture = assetStorage["images/logo.png"] + + // Immediately returns loaded asset or returns null if missing: + val textureOrNull = assetStorage.getOrNull("images/logo.png") + + KtxAsync.launch { + // There is also a special way to access your assets within coroutines + // when you need to wait until they are loaded asynchronously. + + // When calling `getAsync`, AssetStorage will not throw an exception + // or return null if the asset is still loading. Instead, it will + // return a Kotlin Deferred reference. This allows you suspend the + // coroutine until the asset is loaded: + val asset: Deferred = assetStorage.getAsync("images/logo.png") + // Checking if the asset loading has finished: + asset.isCompleted + // Suspending the coroutine to obtain asset instance: + texture = asset.await() + + // If you want to suspend the coroutine to wait for the asset, + // you can do this in a single line: + texture = assetStorage.getAsync("images/logo.png").await() + + // Now the coroutine is resumed and `texture` can be used. + } +} +``` + +Accessing additional data about an asset: + +```kotlin +import com.badlogic.gdx.graphics.Texture +import ktx.assets.async.AssetStorage + +fun inspectAsset(assetStorage: AssetStorage) { + // Returns true if asset is in the storage, loaded or not: + assetStorage.contains("images/logo.png") + // Returns true if the asset loading has finished: + assetStorage.isLoaded("images/logo.png") + // Checks how many times the asset was loaded or used as a dependency: + assetStorage.getReferenceCount("images/logo.png") + // Returns a list of dependencies loaded along with the asset: + assetStorage.getDependencies("images/logo.png") +} +``` + +Adding a fully loaded asset manually to the `AssetStorage`: + +```kotlin +import com.badlogic.gdx.graphics.g2d.Batch +import com.badlogic.gdx.graphics.g2d.SpriteBatch +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun addAsset(assetStorage: AssetStorage) { + KtxAsync.launch { + // You can store arbitrary objects in AssetStorage. + // They will be marked as loaded and accessible with `get`. + // If they are disposable, calling `unload` will also + // dispose of these assets. This might be useful for + // expensive objects such as the Batch. + val batch: Batch = SpriteBatch() + // Suspending the coroutine until the `batch` is added: + assetStorage.add("batch", batch) + // Now our `batch` will be available under "batch" path + // and `Batch` class. + } +} +``` + +Loading assets _synchronously_ with `AssetStorage`: + +```kotlin +import com.badlogic.gdx.graphics.g2d.BitmapFont + +// Sometimes you need to load assets immediately and coroutines just get in the way. +// A common example of this would be getting the assets for the loading screen. +// In this case you can use `loadSync`, which will block the current thread until +// the asset is loaded: +val font = assetStorage.loadSync("com/badlogic/gdx/utils/arial-15.fnt") + +// Note that you should not use `loadSync` from within coroutines. + +// Whenever possible, prefer `load` or `loadAsync`. Try not to mix synchronous and +// asynchronous loading, especially on the same assets or assets with same dependencies. +``` + +Disposing of all assets stored by `AssetStorage`: + +```kotlin +// Will block the current thread to unload all assets: +assetStorage.dispose() + +// This will also disrupt loading of all unloaded assets. +// Disposing errors are logged by default, and do not stop the process. +``` + +Disposing of all assets asynchronously: + +```kotlin +import com.badlogic.gdx.Gdx +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun unloadAllAssets(assetStorage: AssetStorage) { + KtxAsync.launch { + // Suspends the coroutine until all assets are disposed of: + assetStorage.dispose { identifier, exception -> + // This lambda will be invoked for each encountered disposing error: + Gdx.app.error("KTX", "Unable to dispose of asset: $identifier", exception) + } + } +} +``` + +Loading assets with custom error handling: + +```kotlin +import com.badlogic.gdx.graphics.Texture +import kotlinx.coroutines.launch +import ktx.assets.async.AssetLoadingException +import ktx.assets.async.AssetStorage +import ktx.assets.async.AssetStorageException +import ktx.async.KtxAsync + +fun loadAsset(assetStorage: AssetStorage) { + KtxAsync.launch { + // You can handle loading errors with a classic try-catch block: + try { + val texture = assetStorage.load("images/logo.png") + } catch (exception: AssetLoadingException) { + // Asset loader threw an exception - unable to load the asset. + } catch (exception: AssetStorageException) { + // Another error occurred. See AssetStorageException subclasses. + } + + // Note that if the asset loading ended with an exception, + // the same exception will be rethrown each time the asset + // is accessed with `get`, `getOrNull`, `getAsync.await` or `load`. + } +} +``` + +Tracking loading progress: + +```kotlin +import ktx.assets.async.AssetStorage + +fun trackProgress(assetStorage: AssetStorage) { + // Current loading progress can be tracked with the `progress` property: + val progress = assetStorage.progress + + // Total number of scheduled assets: + progress.total + // Total number of loaded assets: + progress.loaded + // Current progress percentage in [0, 1]: + progress.percent + // Checks if all scheduled assets are loaded: + progress.isFinished + + // Remember that due to the asynchronous nature of AssetStorage, + // the progress is only _eventually consistent_ with the storage. + // It will not know about the assets that are not fully scheduled + // for loading yet. Use progress for display only and base your + // application logic on coroutine callbacks instead. +} +``` + +Adding a custom `AssetLoader` to `AssetStorage`: + +```kotlin +import ktx.assets.async.AssetStorage + +fun createCustomAssetStorage(): AssetStorage { + val assetStorage = AssetStorage() + // Custom asset loaders should be added before loading any assets: + assetStorage.setLoader(suffix = ".file.extension") { + MyCustomAssetLoader(assetStorage.fileResolver) + } + // Remember to extend one of: + // com.badlogic.gdx.assets.loaders.SynchronousAssetLoader + // com.badlogic.gdx.assets.loaders.AsynchronousAssetLoader + + return assetStorage +} +``` + +#### Implementation notes + +##### Multiple calls of `load`, `loadAsync`, `loadSync` and `unload` + +It is completely safe to call `load` and `loadAsync` multiple times with the same asset data, even just to obtain +asset instances. In that sense, they can be used as an alternative to `getAsync` inside coroutines. + +Instead of loading the same asset multiple times, `AssetStorage` will just increase the reference count +to the asset and return the same instance on each request. This also works concurrently - the storage will +always load just _one_ asset instance, regardless of how many different threads and coroutines called `load` +in parallel. + +However, to eventually unload the asset, you have to call `unload` the same number of times as `load`/`loadAsync`, +or simply dispose of all assets with `dispose`, which clears all reference counts and unloads everything +from the storage. + +Unlike `load` and `loadAsync`, `add` should be called only once on a single asset, and when it is not stored +in the storage. You cannot add existing assets to the storage, even if they were previously loaded by it. +This is because if we are not sure that `AssetStorage` handled the loading (or creation) of an object, tracking +its dependencies and lifecycle is difficult and left to the user. Trying to `add` an asset with existing path +and type will not increase its reference count, and will throw an exception instead. + +Adding assets that were previously loaded by the `AssetStorage` under different paths is also a misuse of the API +which might result in unloading the asset or its dependencies prematurely. True aliases are currently unsupported. + +`loadSync` can also be used multiple times on the same asset in addition to `load` and `loadAsync`, but keep in mind +that mixing synchronous and asynchronous loading can cause loading exceptions. In particular, `loadSync` will not wait +for asset dependencies loaded by other asynchronous coroutines and will raise an exception instead. As a rule of thumb, +it is best not to mix synchronous and asynchronous loading of the same asset or assets with the same dependencies. + +##### Loading methods comparison: `load` vs `loadAsync` vs `loadSync` + +Loading method | Return type | Suspending | Loading type | When to use +:---: | :---: | :---: | --- | --- +`load` | `Asset` | Yes | Asynchronous. Schedules asset loading and awaits for the loaded asset, suspending the coroutine. | Within coroutines for controlled, sequential loading. +`loadAsync` | `Deferred` | No, but can be with `Deferred.await` | Asynchronous. Schedules asset loading and returns a `Deferred` reference to the asset. | Outside of coroutines: for scheduling of assets that can be loaded non-sequentially and in parallel. Within coroutines: for controlled order of loading with `Deferred.await`. +`loadSync` | `Asset` | No | Synchronous. Blocks the current thread until an asset is loaded. Loading is performed on the rendering thread. | **Only** outside of coroutines, for crucial assets that need to be loaded synchronously (e.g. loading screen assets). + +_`Asset` is the generic type of the loaded asset._ + +* Avoid mixing concurrent synchronous (`loadSync`) and asynchronous (`load`, `loadAsync`) loading of the same asset, +or assets with the same dependencies. +* Prefer asynchronous (`load`, `loadAsync`) asset loading methods whenever possible due to better performance and +compatibility with coroutines. +* Note that `loadSync` is _not necessary_ to load initial assets. You launch a coroutine with `KtxAsync.launch`, +load the assets sequentially with `load` or in parallel with `loadAsync`/`await`, and then switch to the first view +that uses the assets once they are loaded. Loading assets asynchronously might be faster, especially when done +in parallel - but ultimately it comes down to personal preference and code maintainability. + +##### Avoid `runBlocking` + +Kotlin's `runBlocking` function allows to launch a coroutine and block the current thread until the coroutine +is finished. In general, you should **avoid** `runBlocking` calls from the main rendering thread or the threads +assigned to `AssetStorage` for asynchronous loading. + +This simple example will cause a deadlock: + +```kotlin +import com.badlogic.gdx.ApplicationAdapter +import com.badlogic.gdx.backends.lwjgl.LwjglApplication +import com.badlogic.gdx.graphics.Texture +import kotlinx.coroutines.runBlocking +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync + +fun main() { + LwjglApplication(App()) +} + +class App : ApplicationAdapter() { + override fun create() { + KtxAsync.initiate() + val assetStorage = AssetStorage() + runBlocking { // <- !!! Do NOT do this. !!! + assetStorage.load("images/logo.png") + } + println("Will never be printed.") + } +} +``` + +This is because `AssetStorage` needs access to the main rendering thread to finish loading the `Texture` +with OpenGL context, but we have blocked the main rendering thread to wait for the asset. + +In a similar manner, this example blocks the only thread assigned to `AssetStorage` for asynchronous operations: + +```kotlin +import com.badlogic.gdx.ApplicationAdapter +import com.badlogic.gdx.backends.lwjgl.LwjglApplication +import com.badlogic.gdx.graphics.Texture +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync +import ktx.async.newSingleThreadAsyncContext + +fun main() { + LwjglApplication(App()) +} + +class App : ApplicationAdapter() { + override fun create() { + KtxAsync.initiate() + val asyncContext = newSingleThreadAsyncContext() + val assetStorage = AssetStorage(asyncContext = asyncContext) + // Launching coroutine on the storage thread: + KtxAsync.launch(asyncContext) { + runBlocking { // <- !!! Do NOT do this. !!! + assetStorage.load("images/logo.png") + } + println("Will never be printed and AssetStorage will be unusable.") + } + } +} +``` + +As a rule of thumb, you should use suspending `AssetStorage` methods only from non-blocking coroutines, e.g. those +launched with `KtxAsync.launch` or `GlobalScope.launch`. If you change `runBlocking` to a proper coroutine launch +in either of the examples, you will notice that the deadlocks no longer occur. + +It does not mean that `runBlocking` will always cause a deadlock, however. You can safely use `runBlocking`: + +- For `dispose`, both suspending and non-suspending variants. +- For all non-suspending methods such as `get`, `getOrNull`, `contains`, `isLoaded`, `setLoader`, `getLoader`. +- For `add`. While `add` does suspend the coroutine, it requires neither the rendering thread nor the loading threads. +- For `load` and `getAsync.await` calls requesting already loaded assets. **Use with caution.** +- From within other threads than the main rendering thread and the `AssetStorage` loading threads. These threads +will be blocked until the operation is finished, which is not ideal, but at least the loading will remain possible. + +##### `AssetStorage` as a drop-in replacement for `AssetManager` + +Consider this typical application using `AssetManager`: + +```kotlin +import com.badlogic.gdx.ApplicationAdapter +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.graphics.Texture +import com.badlogic.gdx.graphics.g2d.BitmapFont + +class WithAssetManager: ApplicationAdapter() { + private lateinit var assetManager: AssetManager + + override fun create() { + assetManager = AssetManager() + + // Scheduling assets for asynchronous loading: + assetManager.load("images/logo.png", Texture::class.java) + assetManager.load("com/badlogic/gdx/utils/arial-15.fnt", BitmapFont::class.java) + } + + override fun render() { + // Manager has to be constantly updated until the assets are loaded: + if (assetManager.update()) { + // Now the assets are loaded: + changeView() + } + // Render loading prompt. + } + + private fun changeView() { + val texture: Texture = assetManager["images/logo.png"] + TODO("Now the assets are loaded and can be accessed with $assetManager.get!") + } +} +``` + +Since usually applications have more than just 2 assets, many developers choose to treat `AssetManager` as +a container of assets: a map with file paths as keys and loaded assets are values. You typically load most +or all assets at the very beginning while showing the loading screen, and then just use `AssetManager.get(path)` +to obtain the assets after they are loaded. + +However, this approach has some inconveniences and problems: + +- The API is not very idiomatic to Kotlin, but in this particular case [ktx-assets](../assets) can help. +- `update` has to be called on rendering thread during loading. +- If you forget to stop updating the manager after the assets are loaded, the initiation code (such as `changeView` +in our example) can be ran multiple times. If you replace `TODO` with `println` in the example above, you will notice +that `changeView` is invoked on every `render` after the loading is finished. +- The majority of `AssetManager` methods are `synchronized`, which means they block the thread that they are +executed in and are usually more expensive to call than regular methods. This includes the `get` method, which does +not change the internal state of the manager at all. Even if the assets are fully loaded and you no longer modify +the `AssetManager` state, you still pay the cost of synchronization. This is especially relevant if you use multiple +threads, as they can slow each other down, both waiting for the lock. +- `AssetManager` stores assets mapped only by their paths. `manager.get(path)` and `manager.get(path)` +are both valid method calls that will throw a _runtime_ class cast exception. + +`AssetStorage` avoids most of these problems. + +Similarly to `AssetManager`, `AssetStorage` stores and allows you to `get` your loaded assets, so if you want to +migrate from `AssetManager` to `AssetStorage`, all you have to change initially is the loading code: + +```kotlin +import com.badlogic.gdx.ApplicationAdapter +import com.badlogic.gdx.graphics.Texture +import com.badlogic.gdx.graphics.g2d.BitmapFont +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync +import ktx.async.newAsyncContext + +class WithAssetStorageBasic: ApplicationAdapter() { + // Using multiple threads to load the assets in parallel: + private val assetStorage = AssetStorage(newAsyncContext(threads = 2)) + + override fun create() { + KtxAsync.initiate() + + // Scheduling assets for asynchronous loading: + assetStorage.loadAsync("images/logo.png") + assetStorage.loadAsync("com/badlogic/gdx/utils/arial-15.fnt") + } + + override fun render() { + // The closest alternative to `update` would be to check the progress on each render: + if (assetStorage.progress.isFinished) { + changeView() + } + } + + private fun changeView() { + val texture: Texture = assetStorage["images/logo.png"] + TODO("Now the assets are loaded and can be accessed with $assetStorage.get!") + } +} +``` + +As you can see, after the assets are loaded, the API of both `AssetManager` and `AssetStorage` is very similar. + +While this example might seem almost identical to the `AssetManager` code, you already get the benefit of parallel +loading and using coroutines under the hood. + +Now, this approach might just work in most cases, but there are still two things we should address: + +- You will notice that your IDE warns you about not using the results of `loadAsync` which return `Deferred` instances. +This means we're launching asynchronous coroutines and ignore their results. +- `AssetStorage.progress` should be used only for display and debugging. You generally should not base your application +logic on `progress`, as it is only _eventually consistent_ with the `AssetStorage` state. + +Let's rewrite it again - this time with proper coroutines: + +```kotlin +import com.badlogic.gdx.ApplicationAdapter +import com.badlogic.gdx.graphics.Texture +import com.badlogic.gdx.graphics.g2d.BitmapFont +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync +import ktx.async.newAsyncContext + +class WithAssetStorage: ApplicationAdapter() { + // Using multiple threads to load the assets in parallel: + private val assetStorage = AssetStorage(newAsyncContext(threads = 2)) + + override fun create() { + KtxAsync.initiate() + + // Scheduling assets for asynchronous loading: + val assets = listOf( + assetStorage.loadAsync("images/logo.png"), + assetStorage.loadAsync("com/badlogic/gdx/utils/arial-15.fnt") + ) + + // Instead of constantly updating or checking the progress, + // we're launching a coroutine that "waits" for the assets: + KtxAsync.launch { + // Suspending coroutine until all assets are loaded: + assets.joinAll() + // Resuming! Now the assets are loaded and we can obtain them with `get`: + changeView() + } + } + + private fun changeView() { + val texture: Texture = assetStorage["images/logo.png"] + TODO("Now the assets are loaded and can be accessed with $assetStorage.get!") + } +} +``` + +The IDE no longer warns us about ignoring `Deferred` results and we no longer have to check the progress all the time. +Instead, we suspend a coroutine to "wait" for the assets and run our initiation code once the assets are loaded. + +The code using `AssetStorage` is not necessarily shorter in this case, but: + +- You get the performance improvements of loading assets in parallel. +- `AssetStorage` does not have to be updated on render. +- Your code is reactive and `changeView` is guaranteed to be called only once as soon as the assets are loaded. +- `AssetStorage.get` is non-blocking and faster than `AssetManager.get`. `AssetStorage` does a better job of storing +your assets efficiently after the loading is finished. +- `AssetStorage` stores assets mapped by their path _and_ type. You will not have to deal with class cast exceptions. +- You can easily expand the launched coroutine or integrate more coroutines into your application later for other +asynchronous operations. + +Besides, you get the additional benefits of other `AssetStorage` features and methods described in this file. + +##### API equivalents + +Closest equivalents in `AssetManager` and `AssetStorage` APIs: + +`AssetManager` | `AssetStorage` | Note +:---: | :---: | --- +`get(String)` | `get(String)` | +`get(String, Class)` | `get(Identifier)` | +`get(AssetDescriptor)` | `get(AssetDescriptor)` | +`load(String, Class)` | `loadAsync(String)` | `load(String)` can also be used as an alternative within coroutines. +`load(String, Class, AssetLoaderParameters)` | `loadAsync(String, AssetLoaderParameters)` | `load(String, AssetLoaderParameters)` can also be used as an alternative within coroutines. +`load(AssetDescriptor)` | `loadAsync(AssetDescriptor)` | `load(AssetDescriptor)` can also be used as an alternative within coroutines. +`isLoaded(String)` | `isLoaded(String)` | `AssetStorage` requires asset type, so the method is generic. +`isLoaded(String, Class)` | `isLoaded(Identifier)` | +`isLoaded(AssetDescriptor)` | `isLoaded(AssetDescriptor)` | +`unload(String)` | `unload(String)`, `unload(Identifier)` | `AssetStorage` requires asset type, so the methods are generic. +`getProgress()` | `progress.percent` | +`isFinished()` | `progress.isFinished` | +`update()`, `update(Int)` | N/A | `AssetStorage` does not need to be updated. Rely on coroutines to execute code when the assets are loaded or use `progress.isFinished`. +`finishLoadingAsset(String)` | `loadSync(String)` | Assets that need to be loaded immediately (e.g. loading screen assets) can be loaded with `loadSync` instead of asynchronous `load` or `loadAsync` for convenience. +`finishLoadingAsset(AssetDescriptor)` | `loadSync(AssetDescriptor)` | +`finishLoading()` | N/A | `AssetStorage` does not provide methods that block the thread until all assets are loaded. Rely on `progress.isFinished` instead. +`addAsset(String, Class, T)` | `add(String, T)` | +`contains(String)` | `contains(String)`, `contains(Identifier)` | `AssetStorage` requires asset type, so the methods are generic. +`setErrorHandler` | N/A, `try-catch` | With `AssetStorage` you can handle loading errors immediately with regular built-in `try-catch` syntax. Error listener is not required. +`clear()` | `dispose()` | `AssetStorage.dispose` will not kill `AssetStorage` threads and can be safely used multiple times like `AssetManager.clear`. +`dispose()` | `dispose()` | `AssetStorage` also provides a suspending variant with custom error handling. + +##### Integration with LibGDX and known unsupported features + +`AssetStorage` does its best to integrate with LibGDX APIs - including the `AssetLoader` implementations, which were +designed for the `AssetManager`. [A dedicated wrapper](src/main/kotlin/ktx/assets/async/wrapper.kt) extends and +overrides `AssetManager`, delegating a subset of supported methods to `AssetStorage`. The official `AssetLoader` +implementations use supported methods such as `get`, but please note that some third-party loaders might not +work out of the box with `AssetStorage`. Exceptions related to broken loaders include `UnsupportedMethodException` +and `MissingDependencyException`. + +`AssetStorage`, even with its wrapper, cannot be used as drop-in replacement for `AssetManager` throughout the +official APIs. In particular, `Texture.setAssetManager` and `Cubemap.setAssetManager` are both unsupported. + +If you heavily rely on these unsupported APIs or custom asset loaders, you might need to use `AssetManager` instead. +See [`ktx-assets`](../assets) module for `AssetManager` utilities. + +#### Synergy + +While [`ktx-assets`](../assets) module does provide some extensions to the `AssetManager`, which is a direct +alternative to the `AssetStorage`, this module's other utilities for LibGDX assets and files APIs might still +prove useful. + +### Alternatives + +There seem to be no other coroutines-based asset loaders available. +However, LibGDX `AssetManager` is still viable when efficient parallel loading is not a requirement. +Alternatives to the `AssetStorage` include: + +- Using [`AssetManager`](https://github.com/libgdx/libgdx/wiki/Managing-your-assets) directly. +- Using [`ktx-assets`](../assets) extensions for `AssetManager`. +- [`AnnotationAssetManager`](https://bitbucket.org/dermetfan/libgdx-utils/wiki/net.dermetfan.gdx.assets.AnnotationAssetManager) +from [`libgdx-utils`](https://bitbucket.org/dermetfan/libgdx-utils) that extends `AssetManager` and allows +to specify assets for loading by marking fields with annotations. +- Loading assets without a manager. + +#### Additional documentation + +- [`ktx-async` module](../async), which is used extensively by this extension. +- [Official `AssetManager` article.](https://github.com/libgdx/libgdx/wiki/Managing-your-assets) diff --git a/assets-async/build.gradle b/assets-async/build.gradle new file mode 100644 index 00000000..0b5d90e5 --- /dev/null +++ b/assets-async/build.gradle @@ -0,0 +1,9 @@ +dependencies { + compile project(':assets') + compile project(':async') + testCompile project(':async').sourceSets.test.output + testCompile "org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:$kotlinCoroutinesVersion" + testCompile "com.badlogicgames.gdx:gdx-backend-headless:$gdxVersion" + testCompile "com.badlogicgames.gdx:gdx-backend-lwjgl:$gdxVersion" + testCompile "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop" +} diff --git a/assets-async/gradle.properties b/assets-async/gradle.properties new file mode 100644 index 00000000..084d33c9 --- /dev/null +++ b/assets-async/gradle.properties @@ -0,0 +1,2 @@ +projectName=ktx-assets-async +projectDesc=Asynchronous coroutines-based asset loader for LibGDX. diff --git a/assets-async/src/main/kotlin/ktx/assets/async/errors.kt b/assets-async/src/main/kotlin/ktx/assets/async/errors.kt new file mode 100644 index 00000000..4c7a36c0 --- /dev/null +++ b/assets-async/src/main/kotlin/ktx/assets/async/errors.kt @@ -0,0 +1,124 @@ +package ktx.assets.async + +import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetLoaderParameters +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.AssetLoader +import com.badlogic.gdx.assets.loaders.AsynchronousAssetLoader +import com.badlogic.gdx.assets.loaders.SynchronousAssetLoader +import com.badlogic.gdx.utils.GdxRuntimeException + +/** + * Thrown by [AssetStorage] and related services. + * [message] describes the problem, while [cause] is the optional cause of the exception. + * + * Note that [AssetStorage] usually throws subclasses of this exception, rather than + * instances of this exception directly. This class acts as the common superclass + * with which all [AssetStorage]-related exceptions can be caught and handled. + */ +open class AssetStorageException(message: String, cause: Throwable? = null) : GdxRuntimeException(message, cause) + +/** + * Thrown when the asset requested by an [AssetStorage.get] variant is not available + * in the [AssetStorage] at all or has not been loaded yet. + * + * This exception can also be thrown by [AssetStorage.loadSync] when mixing synchronous asset + * loading with asynchronous loading via [AssetStorage.load] or [AssetStorage.loadAsync]. + */ +class MissingAssetException(identifier: Identifier<*>) : + AssetStorageException(message = "Asset: $identifier is not loaded.") + +/** + * Thrown by [AssetStorage.load] or [AssetStorage.get] variant when the requested asset + * was unloaded asynchronously. + */ +class UnloadedAssetException(identifier: Identifier<*>) : + AssetStorageException(message = "Asset: $identifier was unloaded.") + +/** + * Thrown by [AssetStorage.add] when attempting to add an asset with [Identifier] + * that is already present in the [AssetStorage]. + */ +class AlreadyLoadedAssetException(identifier: Identifier<*>) : + AssetStorageException(message = "Asset: $identifier was already added to storage.") + +/** + * Thrown by [AssetStorage.load] when the [AssetLoader] for the requested asset type + * and path is unavailable. See [AssetStorage.setLoader]. + */ +class MissingLoaderException(descriptor: AssetDescriptor<*>) : + AssetStorageException( + message = "No loader available for assets of type: ${descriptor.type} " + + "with path: ${descriptor.fileName}." + ) + +/** + * Thrown by [AssetStorage.load] or [AssetStorage.get] when the asset failed to load + * due to invalid loader implementation. Since loaders are pre-validated during + * registration, normally this exception is extremely rare and caused by invalid + * [AssetStorage.setLoader] usage. + */ +class InvalidLoaderException(loader: Loader<*>) : + AssetStorageException( + message = "Invalid loader: $loader. It must extend either " + + "SynchronousAssetLoader or AsynchronousAssetLoader." + ) + +/** + * Thrown by [AssetStorage.load] or [AssetStorage.get] when the asset failed to load + * due to an unexpected loading exception, usually thrown by the associated [AssetLoader]. + */ +class AssetLoadingException(descriptor: AssetDescriptor<*>, cause: Throwable) + : AssetStorageException(message = "Unable to load asset: $descriptor", cause = cause) + +/** + * Thrown when unsupported methods are called on the [AssetManagerWrapper]. + * It is typically only caused by [AssetLoader] instances or a [AssetLoaderParameters.LoadedCallback]. + * + * [AssetStorage] reuses official [AssetLoader] implementations to load the assets. + * [SynchronousAssetLoader] and [AsynchronousAssetLoader] both expect an instance of [AssetManager] + * to perform some basic operations on assets. To support the loaders API, [AssetStorage] is wrapped + * with an [AssetManagerWrapper] which delegates supported methods to [AssetStorage] and throws + * this exception otherwise. + * + * Most official loaders only call [AssetManager.get] to obtain asset dependencies, but custom loaders + * can perform operations that are unsupported by [AssetStorage] due to its asynchronous nature + * and storing assets mapped by path and type rather than path alone. If this exception causes the loading + * to fail, [AssetLoader] associated with the asset has to be refactored. + */ +class UnsupportedMethodException(method: String) : + AssetStorageException( + message = "AssetLoader used unsupported operation of AssetManager wrapper: $method " + + "Please refactor AssetLoader not to call this method on AssetManager." + ) + +/** + * This exception can be thrown by [AssetStorage.loadSync] if dependencies of an asset were scheduled + * for asynchronous loading, but are not loaded yet. [AssetStorage.loadSync] will not wait for the + * assets and instead will throw this exception. + * + * If [AssetStorage.loadSync] was not used, this exception is only ever thrown when trying to access + * assets via [AssetManagerWrapper]. It is then typically only caused by [AssetLoader] instances or + * a [AssetLoaderParameters.LoadedCallback]. + * + * If you did not use [AssetStorage.loadSync], it usually means that [AssetLoader] attempts to access + * an asset that either: + * - Is already unloaded. + * - Failed to load with exception. + * - Was not listed by [AssetLoader.getDependencies]. + * - Has not loaded yet, which should never happen if the dependency was listed correctly. + * + * It can also be caused by an [AssetLoaderParameters.LoadedCallback] assigned to an asset when it tries + * to access unloaded assets with [AssetManagerWrapper.get]. + * + * Normally this exception is only expected in case of concurrent loading and unloading of the same asset, or when + * mixing synchronous [AssetStorage.loadSync] with asynchronous [AssetStorage.load] or [AssetStorage.loadAsync]. + * If it occurs otherwise, the [AssetLoader] associated with the asset might incorrectly list its dependencies. + */ +class MissingDependencyException(identifier: Identifier<*>, cause: Throwable? = null) : + AssetStorageException( + message = "A loader has requested an instance of ${identifier.type} at path ${identifier.path}. " + + "This asset was either not listed in dependencies, loaded with exception, is not loaded yet " + + "or was unloaded asynchronously.", + cause = cause + ) diff --git a/assets-async/src/main/kotlin/ktx/assets/async/loaders.kt b/assets-async/src/main/kotlin/ktx/assets/async/loaders.kt new file mode 100644 index 00000000..190169b2 --- /dev/null +++ b/assets-async/src/main/kotlin/ktx/assets/async/loaders.kt @@ -0,0 +1,165 @@ +package ktx.assets.async + +import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetLoaderParameters +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.AssetLoader +import com.badlogic.gdx.assets.loaders.AsynchronousAssetLoader +import com.badlogic.gdx.assets.loaders.SynchronousAssetLoader +import com.badlogic.gdx.assets.loaders.resolvers.AbsoluteFileHandleResolver +import com.badlogic.gdx.files.FileHandle +import com.badlogic.gdx.utils.ObjectMap +import com.badlogic.gdx.utils.Array as GdxArray + +/** + * Stores [AssetLoader] instances mapped by loaded asset type. Internal [AssetStorage] utility. + * + * Implementation note: LibGDX loaders are not thread-safe. Instead, they assume that only a single asset is loaded + * at a time and use internal, unsynchronized fields to store temporary variables like the dependencies. To avoid + * threading issues, we use a separate loader for each loaded asset instead of singleton instances - hence + * the functional loader providers. + */ +internal class AssetLoaderStorage { + private val loaders: ObjectMap, AssetLoaderContainer<*>> = ObjectMap() + + /** + * Provides a [Loader] for the given asset [type]. Optionally, file [path] can be given, + * as depending on the file suffix, a different loader might be used for the same asset type. + */ + fun getLoader(type: Class, path: String? = null): Loader? { + @Suppress("UNCHECKED_CAST") + val loadersForType = loaders[type] as AssetLoaderContainer? ?: return null + if (path == null || loadersForType.loadersBySuffix.size == 0) { + return loadersForType.mainLoader?.invoke() + } + var maxMatchingSuffixLength = 0 + var loaderProvider = loadersForType.mainLoader + loadersForType.loadersBySuffix.forEach { + val suffix = it.key + if (maxMatchingSuffixLength < suffix.length && path.endsWith(suffix)) { + maxMatchingSuffixLength = suffix.length + loaderProvider = it.value + } + } + return loaderProvider?.invoke() + } + + /** + * Adds or replaces [Loader] for the given class. [loaderProvider] is invoked + * each time an instance of the selected loader is requested. The loader will be + * associated with the given asset [type]. Optionally, a [suffix] can be given + * to a associate the loader with specific file paths. + */ + fun setLoaderProvider(type: Class, suffix: String? = null, loaderProvider: () -> Loader) { + validate(loaderProvider) + getOrCreateLoadersContainer(type).apply { + if (suffix.isNullOrEmpty()) { + mainLoader = loaderProvider + } else { + loadersBySuffix.put(suffix, loaderProvider) + } + } + } + + private fun validate(loaderProvider: () -> Loader) { + val loader = loaderProvider() + if (loader !is SynchronousAssetLoader<*, *> && loader !is AsynchronousAssetLoader<*, *>) { + throw InvalidLoaderException(loader) + } + } + + private fun getOrCreateLoadersContainer(type: Class): AssetLoaderContainer { + val loadersForType = loaders[type] + if (loadersForType == null) { + val container = AssetLoaderContainer() + loaders.put(type, container) + return container + } + @Suppress("UNCHECKED_CAST") + return loadersForType as AssetLoaderContainer + } + + override fun toString(): String = "AssetLoaderStorage[loaders=$loaders]" + + private class AssetLoaderContainer { + val loadersBySuffix: ObjectMap Loader> = ObjectMap() + var mainLoader: (() -> Loader)? + get() = loadersBySuffix[""] + set(value) { + loadersBySuffix.put("", value) + } + + override fun toString(): String = loadersBySuffix.toString() + } +} + +// Workarounds for LibGDX generics API. + +/** [AssetLoader] with improved generics. */ +typealias Loader = AssetLoader> + +/** [SynchronousAssetLoader] with improved generics. */ +typealias SynchronousLoader = SynchronousAssetLoader> + +/** [AsynchronousAssetLoader] with improved generics. */ +typealias AsynchronousLoader = AsynchronousAssetLoader> + +/** Casts [AssetDescriptor.params] stored with raw type. */ +private val AssetDescriptor.parameters: AssetLoaderParameters? + @Suppress("UNCHECKED_CAST") + get() = params as AssetLoaderParameters? + +/** + * Allows to use [AssetLoader.getDependencies] method with [AssetDescriptor]. + * [assetDescriptor] contains asset data. + * Returns a [com.badlogic.gdx.utils.Array] with asset dependencies described + * with [AssetDescriptor] instances. Null if here are no dependencies. + */ +fun Loader<*>.getDependencies(assetDescriptor: AssetDescriptor<*>): GdxArray> = + @Suppress("UNCHECKED_CAST") + (this as AssetLoader<*, AssetLoaderParameters<*>>) + .getDependencies(assetDescriptor.fileName, assetDescriptor.file, assetDescriptor.parameters) ?: GdxArray(0) + +/** + * Allows to use [SynchronousAssetLoader.load] method with [AssetDescriptor]. + * [assetManager] provides asset dependencies for the loader. + * [assetDescriptor] contains asset data. Returns fully loaded [Asset] instance. + */ +fun SynchronousLoader.load(assetManager: AssetManager, assetDescriptor: AssetDescriptor): Asset = + @Suppress("UNCHECKED_CAST") + (this as SynchronousAssetLoader>) + .load(assetManager, assetDescriptor.fileName, assetDescriptor.file, assetDescriptor.parameters) + +/** + * Allows to use [AsynchronousAssetLoader.loadAsync] method with [AssetDescriptor]. + * Performs the asynchronous asset loading part without yielding results. + * [assetManager] provides asset dependencies for the loader. + * [assetDescriptor] contains asset data. + */ +fun AsynchronousLoader.loadAsync(assetManager: AssetManager, assetDescriptor: AssetDescriptor) = + @Suppress("UNCHECKED_CAST") + (this as AsynchronousAssetLoader>) + .loadAsync(assetManager, assetDescriptor.fileName, assetDescriptor.file, assetDescriptor.parameters) + +/** + * Allows to use [AsynchronousAssetLoader.loadSync] method with [AssetDescriptor]. + * Note that [loadAsync] must have been called first with the same asset data. + * [assetManager] provides asset dependencies for the loader. + * [assetDescriptor] contains asset data. Returns fully loaded [Asset] instance. + */ +fun AsynchronousLoader.loadSync(assetManager: AssetManager, assetDescriptor: AssetDescriptor): Asset = + @Suppress("UNCHECKED_CAST") + (this as AsynchronousAssetLoader>) + .loadSync(assetManager, assetDescriptor.fileName, assetDescriptor.file, assetDescriptor.parameters) + +/** Required for [ManualLoader] by LibGDX API. */ +internal class ManualLoadingParameters : AssetLoaderParameters() + +/** Mocks [AssetLoader] API for assets manually added to the [AssetStorage]. See [AssetStorage.add]. */ +internal object ManualLoader : AssetLoader(AbsoluteFileHandleResolver()) { + private val emptyDependencies = GdxArray>(0) + override fun getDependencies( + fileName: String?, file: FileHandle?, + parameter: ManualLoadingParameters? + ): GdxArray> = emptyDependencies +} diff --git a/assets-async/src/main/kotlin/ktx/assets/async/progress.kt b/assets-async/src/main/kotlin/ktx/assets/async/progress.kt new file mode 100644 index 00000000..488d1e81 --- /dev/null +++ b/assets-async/src/main/kotlin/ktx/assets/async/progress.kt @@ -0,0 +1,108 @@ +package ktx.assets.async + +import kotlin.math.max +import kotlin.math.min +import java.util.concurrent.atomic.AtomicInteger + +/** + * Tracks the loading progress of the [AssetStorage]. + * + * Counts the [total], [loaded] and [failed] assets. + * + * [percent] allows to see current loading progress in range of [0, 1]. + * + * The values stored by the [LoadingProgress] are _eventually consistent._ + * The progress can go slightly out of sync of the actual amounts of loaded assets, + * as it is not protected by the [AssetStorage.lock]. + * + * Due to the asynchronous nature of [AssetStorage], some assets that will eventually + * be scheduled by coroutines might not be counted by [LoadingProgress] yet. + * Calling [AssetStorage.load] or [AssetStorage.loadAsync] is not guaranteed + * to immediately update the [total] number of assets. + * + * Use the [LoadingProgress] for display only and base your actual application + * logic on [AssetStorage] API instead. + */ +class LoadingProgress { + private val totalCounter = AtomicInteger() + private val loadedCounter = AtomicInteger() + private val failedCounter = AtomicInteger() + + /** Total number of scheduled assets. */ + val total: Int + get() = totalCounter.get() + /** Total number of successfully loaded assets. */ + val loaded: Int + get() = loadedCounter.get() + /** Total number of assets that failed to load. */ + val failed: Int + get() = failedCounter.get() + /** Current asset loading percent. Does not take [failed] assets into account. */ + val percent: Float + get() { + val total = max(totalCounter.get(), 0) + val loaded = max(min(loadedCounter.get(), total), 0) + return when { + total == 0 || loaded == 0 -> 0f + total == loaded -> 1f + else -> loaded.toFloat() / total.toFloat() + } + } + + /** + * True if all registered assets are loaded or failed to load. + * + * Remember that his value might not reflect the actual state of all assets that are being scheduled + * due to the asynchronous nature of [AssetStorage]. + */ + val isFinished: Boolean + get() = total > 0 && (loadedCounter.get() + failedCounter.get()) == totalCounter.get() + /** True if there are any [failed] assets. */ + val isFailed: Boolean + get() = failedCounter.get() > 0 + + /** Must be called after a new asset was scheduled for loading. */ + internal fun registerScheduledAsset() { + totalCounter.incrementAndGet() + } + + /** Must be called after a new loaded asset was added manually. */ + internal fun registerAddedAsset() { + totalCounter.incrementAndGet() + loadedCounter.incrementAndGet() + } + + /** Must be called after an asset has finished loading. */ + internal fun registerLoadedAsset() { + loadedCounter.incrementAndGet() + } + + /** Must be called after an asset failed to load successfully. */ + internal fun registerFailedAsset() { + failedCounter.incrementAndGet() + } + + /** Must be called after a fully loaded asset was unloaded. */ + internal fun removeLoadedAsset() { + totalCounter.decrementAndGet() + loadedCounter.decrementAndGet() + } + + /** Must be called after an asset currently in the process of loading was cancelled and unloaded. */ + internal fun removeScheduledAsset() { + totalCounter.decrementAndGet() + } + + /** Must be called after an unsuccessfully loaded asset was unloaded. */ + internal fun removeFailedAsset() { + totalCounter.decrementAndGet() + failedCounter.decrementAndGet() + } + + /** Must be called after disposal of all assets. */ + internal fun reset() { + totalCounter.set(0) + loadedCounter.set(0) + failedCounter.set(0) + } +} diff --git a/assets-async/src/main/kotlin/ktx/assets/async/storage.kt b/assets-async/src/main/kotlin/ktx/assets/async/storage.kt new file mode 100644 index 00000000..f17e2c85 --- /dev/null +++ b/assets-async/src/main/kotlin/ktx/assets/async/storage.kt @@ -0,0 +1,1298 @@ +package ktx.assets.async + +import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetLoaderParameters +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.* +import com.badlogic.gdx.assets.loaders.resolvers.InternalFileHandleResolver +import com.badlogic.gdx.files.FileHandle +import com.badlogic.gdx.graphics.g3d.loader.G3dModelLoader +import com.badlogic.gdx.graphics.g3d.loader.ObjLoader +import com.badlogic.gdx.utils.* +import com.badlogic.gdx.utils.async.AsyncExecutor +import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import ktx.assets.TextAssetLoader +import ktx.async.KtxAsync +import ktx.async.newSingleThreadAsyncContext +import ktx.async.onRenderingThread +import kotlin.coroutines.CoroutineContext +import com.badlogic.gdx.graphics.g3d.particles.ParticleEffectLoader as ParticleEffect3dLoader + +/** + * Asynchronous asset loader based on coroutines API. An [AssetManager] alternative. + * + * Note that [KtxAsync.initiate] must be called on the rendering thread before creating an [AssetStorage]. + * + * [asyncContext] is used to perform asynchronous file loading. Defaults to a single-threaded context using an + * [AsyncExecutor]. See [newSingleThreadAsyncContext] or [ktx.async.newAsyncContext] functions to create a custom + * loading context. Multi-threaded contexts are fully supported and might boost loading performance if the assets + * are loaded asynchronously in parallel. + * + * [fileResolver] determines how file paths are interpreted. Defaults to [InternalFileHandleResolver], which loads + * internal files. + * + * If `useDefaultLoaders` is true (which is the default), all default LibGDX [AssetLoader] implementations + * will be registered. + */ +class AssetStorage( + val asyncContext: CoroutineContext = newSingleThreadAsyncContext(threadName = "AssetStorage-Thread"), + val fileResolver: FileHandleResolver = InternalFileHandleResolver(), + useDefaultLoaders: Boolean = true +) : Disposable { + @Suppress("LeakingThis") + private val asAssetManager: AssetManager = AssetManagerWrapper(this) + private val loaderStorage = AssetLoaderStorage() + + private val lock = Mutex() + private val assets = mutableMapOf, Asset<*>>() + + /** + * Allows to track progress of the loaded assets. + * + * The values stored by the [LoadingProgress] are _eventually consistent._ + * The progress can go slightly out of sync of the actual amounts of loaded assets, + * as it is not protected by the [lock]. + * + * Due to the asynchronous nature of [AssetStorage], some assets that will eventually + * be scheduled by coroutines might not be counted by [LoadingProgress] yet. + * Calling [load] and [loadAsync] is not guaranteed to immediately update the + * [LoadingProgress.total] number of assets. + * + * Use the [progress] for display only and base your actual application logic on [AssetStorage] API. + */ + val progress = LoadingProgress() + + /** LibGDX [Logger] used internally, usually to report issues. */ + var logger: Logger + get() = asAssetManager.logger + set(value) { + asAssetManager.logger = value + } + + init { + if (useDefaultLoaders) { + setLoader { TextAssetLoader(fileResolver) } + setLoader { BitmapFontLoader(fileResolver) } + setLoader { MusicLoader(fileResolver) } + setLoader { PixmapLoader(fileResolver) } + setLoader { SoundLoader(fileResolver) } + setLoader { TextureAtlasLoader(fileResolver) } + setLoader { TextureLoader(fileResolver) } + setLoader { SkinLoader(fileResolver) } + setLoader { ParticleEffectLoader(fileResolver) } + setLoader { ParticleEffect3dLoader(fileResolver) } + setLoader { I18NBundleLoader(fileResolver) } + setLoader(suffix = ".g3dj") { G3dModelLoader(JsonReader(), fileResolver) } + setLoader(suffix = ".g3db") { G3dModelLoader(UBJsonReader(), fileResolver) } + setLoader(suffix = ".obj") { ObjLoader(fileResolver) } + setLoader { ShaderProgramLoader(fileResolver) } + setLoader { CubemapLoader(fileResolver) } + } + } + + /** + * Creates a new [Identifier] that allows to uniquely describe an asset by [path] and class. + * Uses reified [T] type to obtain the asset class. + * + * [T] is type of the loaded asset. + * [path] to the file must be consistent with [fileResolver] asset type. + */ + inline fun getIdentifier(path: String): Identifier = Identifier(path.normalizePath(), T::class.java) + + /** + * Creates a new [AssetDescriptor] for the selected asset. + * + * [T] is type of the loaded asset. + * [path] to the file should be consistent with [fileResolver] asset type. + * Loading [parameters] are optional and passed to the associated [AssetLoader]. + * Returns a new instance of [AssetDescriptor] with a resolved [FileHandle]. + * + * If the asset requires a [FileHandle] incompatible with the storage [fileResolver], + * use the [fileHandle] parameter to set it. + */ + inline fun getAssetDescriptor( + path: String, + parameters: AssetLoaderParameters? = null, + fileHandle: FileHandle? = null + ): AssetDescriptor { + val descriptor = AssetDescriptor(path.normalizePath(), T::class.java, parameters) + descriptor.file = fileHandle ?: fileResolver.resolve(path) + return descriptor + } + + /** + * Returns a loaded asset of type [T] loaded from selected [path] or throws [MissingAssetException] + * if the asset is not loaded yet or was never scheduled for loading. Rethrows any exceptions + * encountered during asset loading. + * + * [T] is the type of the asset. Must match the type requested during loading. + * [path] must match the asset path passed during loading. + * + * This method might throw the following exceptions: + * - [MissingAssetException] if the asset of [T] type with the given [path] was never added with [load] or [add]. + * - [UnloadedAssetException] if the asset was already unloaded asynchronously. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * See also [getOrNull] and [getAsync]. + */ + inline operator fun get(path: String): T = this[getIdentifier(path)] + + /** + * Returns a loaded asset of type [T] described by [descriptor] or throws [MissingAssetException] + * if the asset is not loaded yet or was never scheduled for loading. Rethrows any exceptions + * encountered during asset loading. + * + * [T] is the type of the asset. Must match the type requested during loading. + * [descriptor] contains the asset data. See [getAssetDescriptor]. + * + * This method might throw the following exceptions: + * - [MissingAssetException] if the asset of [T] type described by [descriptor] was never added with [load] or [add]. + * - [UnloadedAssetException] if the asset was already unloaded asynchronously. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * See also [getOrNull] and [getAsync]. + */ + operator fun get(descriptor: AssetDescriptor): T = this[descriptor.toIdentifier()] + + /** + * Returns a loaded asset of type [T] identified by [identifier] or throws [MissingAssetException] + * if the asset is not loaded yet or was never scheduled for loading. Rethrows any exceptions + * encountered during asset loading. + * + * [T] is the type of the asset. Must match the type requested during loading. + * [identifier] uniquely identifies a file by its path and type. See [Identifier]. + * + * This method might throw the following exceptions: + * - [MissingAssetException] if the asset of [T] type identified by [identifier] was never added with [load] or [add]. + * - [UnloadedAssetException] if the asset was already unloaded asynchronously. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * See also [getOrNull] and [getAsync]. + */ + operator fun get(identifier: Identifier): T { + val reference = getAsync(identifier) + return getOrThrow(identifier, reference) + } + + private fun getOrThrow(asset: Asset): T = getOrThrow(asset.identifier, asset.reference) + + private fun getOrThrow(identifier: Identifier, reference: Deferred): T = + @Suppress("EXPERIMENTAL_API_USAGE") // Avoids runBlocking call. + if (reference.isCompleted) reference.getCompleted() else throw MissingAssetException(identifier) + + /** + * Returns a loaded asset of type [T] loaded from selected [path] or `null` + * if the asset is not loaded yet or was never scheduled for loading. + * Rethrows any exceptions encountered during asset loading. + * + * [T] is the type of the asset. Must match the type requested during loading. + * [path] must match the asset path passed during loading. + * + * This method might throw the following exceptions: + * - [UnloadedAssetException] if the asset was already unloaded asynchronously. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * See also [get] and [getAsync]. + */ + inline fun getOrNull(path: String): T? = getOrNull(getIdentifier(path)) + + /** + * Returns a loaded asset of type [T] described by [descriptor] or `null` + * if the asset is not loaded yet or was never scheduled for loading. + * Rethrows any exceptions encountered during asset loading. + * + * [T] is the type of the asset. Must match the type requested during loading. + * [descriptor] contains the asset data. See [getAssetDescriptor]. + * + * This method might throw the following exceptions: + * - [UnloadedAssetException] if the asset was already unloaded asynchronously. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * See also [get] and [getAsync]. + */ + fun getOrNull(descriptor: AssetDescriptor): T? = getOrNull(descriptor.toIdentifier()) + + /** + * Returns a loaded asset of type [T] identified by [identifier] or `null` + * if the asset is not loaded yet or was never scheduled for loading. + * Rethrows any exceptions encountered during asset loading. + * + * [T] is the type of the asset. Must match the type requested during loading. + * [identifier] uniquely identifies a file by its path and type. See [Identifier]. + * + * This method might throw the following exceptions: + * - [UnloadedAssetException] if the asset was already unloaded asynchronously. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * See also [get] and [getAsync]. + */ + fun getOrNull(identifier: Identifier): T? { + val asset = assets[identifier] + @Suppress("UNCHECKED_CAST", "EXPERIMENTAL_API_USAGE") // Avoids runBlocking call. + return if (asset == null || !asset.reference.isCompleted) null else asset.reference.getCompleted() as T + } + + /** + * Returns the reference to the asset wrapped with [Deferred]. + * Use [Deferred.await] to obtain the instance. + * + * [T] is the type of the asset. Must match the type requested during loading. + * [path] must match the asset path passed during loading. + * + * Note that while the result is a [CompletableDeferred], it should never be completed manually. + * Instead, rely on the [AssetStorage] to load the asset. + * + * Using [Deferred.await] might throw the following exceptions: + * - [MissingAssetException] if the asset at [path] was never added with [load] or [add]. + * - [UnloadedAssetException] if the asset was already unloaded asynchronously. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * Otherwise, using [Deferred.await] will suspend the coroutine until the asset is loaded + * and return its instance. + * + * See also [get] and [getOrNull] for synchronous alternatives. + */ + inline fun getAsync(path: String): Deferred = getAsync(getIdentifier(path)) + + /** + * Returns the reference to the asset wrapped with [Deferred]. Use [Deferred.await] to obtain the instance. + * Throws [AssetStorageException] if the asset was unloaded or never scheduled to begin with. + * + * [T] is the type of the asset. Must match the type requested during loading. + * [descriptor] contains the asset data. See [getAssetDescriptor]. + * + * Note that while the result is a [CompletableDeferred], it should never be completed manually. + * Instead, rely on the [AssetStorage] to load the asset. + * + * Using [Deferred.await] might throw the following exceptions: + * - [MissingAssetException] if the asset was never added with [load] or [add]. + * - [UnloadedAssetException] if the asset was already unloaded asynchronously. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * Otherwise, using [Deferred.await] will suspend the coroutine until the asset is loaded + * and return its instance. + * + * See also [get] and [getOrNull] for synchronous alternatives. + */ + fun getAsync(descriptor: AssetDescriptor): Deferred = getAsync(descriptor.toIdentifier()) + + /** + * Returns the reference to the asset wrapped with [Deferred]. + * Use [Deferred.await] to obtain the instance. + * + * [T] is the type of the asset. Must match the type requested during loading. + * [identifier] uniquely identifies a file by its path and type. See [Identifier]. + * + * Note that while the result is a [CompletableDeferred], it should never be completed manually. + * Instead, rely on the [AssetStorage] to load the asset. + * + * Using [Deferred.await] might throw the following exceptions: + * - [MissingAssetException] if the asset with [identifier] was never added with [load] or [add]. + * - [UnloadedAssetException] if the asset was already unloaded asynchronously. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * Otherwise, using [Deferred.await] will suspend the coroutine until the asset is loaded + * and return its instance. + * + * See also [get] and [getOrNull] for synchronous alternatives. + */ + fun getAsync(identifier: Identifier): Deferred { + val asset = assets[identifier] + @Suppress("UNCHECKED_CAST") + return if (asset != null) asset.reference as Deferred else getMissingAssetAsync(identifier) + } + + private fun getMissingAssetAsync(identifier: Identifier): Deferred = CompletableDeferred().apply { + completeExceptionally(MissingAssetException(identifier)) + } + + /** + * Checks whether an asset in the selected [path] with [T] type is already loaded. + * Returns false if the asset is not loaded yet, is unloaded or was never loaded to begin with. + * + * Note that assets that loaded exceptionally (i.e. asset loader threw an exception) will + * also report as loaded, but trying to obtain their instance will cause rethrowing of that + * exception, forcing the user to handle it. + */ + inline fun isLoaded(path: String): Boolean = isLoaded(getIdentifier(path)) + + /** + * Checks whether an asset described with [descriptor] is already loaded. + * Returns false if the asset is not loaded yet, is unloaded or was never loaded to begin with. + * + * Note that assets that loaded exceptionally (i.e. asset loader threw an exception) will + * also report as loaded, but trying to obtain their instance will cause rethrowing of that + * exception, forcing the user to handle it. + */ + fun isLoaded(descriptor: AssetDescriptor<*>): Boolean = isLoaded(descriptor.toIdentifier()) + + /** + * Checks whether an asset identified with [identifier] is already loaded. + * Returns false if the asset is not loaded yet, is unloaded or was never loaded to begin with. + * + * Note that assets that loaded exceptionally (i.e. asset loader threw an exception) will + * also report as loaded, but trying to obtain their instance will cause rethrowing of that + * exception, forcing the user to handle it. + */ + fun isLoaded(identifier: Identifier<*>): Boolean = + assets[identifier]?.reference?.isCompleted ?: false + + /** + * Checks whether an asset in the selected [path] and [T] type is currently managed by the storage. + * This will return true for assets that are currently being loaded or + */ + inline operator fun contains(path: String): Boolean = contains(getIdentifier(path)) + + /** + * Checks whether an asset described by [descriptor] is currently managed by the storage. + * This will return true for assets that are currently being loaded or + */ + operator fun contains(descriptor: AssetDescriptor<*>): Boolean = contains(descriptor.toIdentifier()) + + /** + * Checks whether an asset identified by [identifier] is currently managed by the storage. + * This will return true for assets that are currently being loaded or + */ + operator fun contains(identifier: Identifier<*>): Boolean = identifier in assets + + /** + * Adds a fully loaded [asset] to the storage. Allows to avoid loading the asset with the [AssetStorage] + * and to manually add it to storage context. + * + * [T] is the type of the [asset]. Note that a superclass of the asset can be chosen to associate the file with. + * [path] must be a unique ID that will be used to retrieve the asset. Since the [asset] is loaded manually, + * it does not have to be an actual file path. + * + * Throws [AlreadyLoadedAssetException] if an asset with the same path is already loaded or scheduled for loading. + */ + suspend inline fun add(path: String, asset: T) = + add(getAssetDescriptor(path), asset) + + /** + * Adds a fully loaded [asset] to the storage. Allows to avoid loading the asset with the [AssetStorage] + * and to manually add it to storage context. + * + * [T] is the type of the [asset]. Note that a superclass of the asset can be chosen to associate the file with. + * [identifier] uniquely identifies the assets and defines its type. Since the [asset] is loaded manually, + * [Identifier.path] does not have to be an actual file path. See [getIdentifier]. + * + * Throws [AlreadyLoadedAssetException] if an asset with the same path is already loaded or scheduled for loading. + */ + suspend fun add(identifier: Identifier, asset: T) = + add(identifier.toAssetDescriptor(), asset) + + /** + * Adds a fully loaded [asset] to the storage. Allows to avoid loading the asset with the [AssetStorage] + * and to manually add it to storage context. + * + * [T] is the type of the [asset]. Note that a superclass of the asset can be chosen to associate the file with. + * [descriptor] contains the asset data. See [getAssetDescriptor]. + * + * Throws [AlreadyLoadedAssetException] if an asset with the same path is already loaded or scheduled for loading. + */ + suspend fun add(descriptor: AssetDescriptor, asset: T) { + val identifier = descriptor.toIdentifier() + lock.withLock { + @Suppress("UNCHECKED_CAST") + val existingAsset = assets[identifier] as? Asset + if (existingAsset != null) { + // Asset is already stored. Will fail to replace. + throw AlreadyLoadedAssetException(identifier) + } + // Asset is currently not stored. Creating. + @Suppress("UNCHECKED_CAST") + assets[identifier] = Asset( + descriptor = descriptor, + reference = CompletableDeferred(asset), + dependencies = emptyList(), + referenceCount = 1, + loader = ManualLoader as Loader + ) + progress.registerAddedAsset() + } + } + + /** + * Schedules asynchronous loading of an asset of [T] type located at [path]. + * Return a [Deferred] reference which will eventually point to a fully loaded instance of [T]. + * + * [path] must be compatible with the [fileResolver]. + * Loading [parameters] are optional and can be used to configure the loaded asset. + * + * [Deferred.await] might throw the following exceptions: + * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * If the asset was already loaded, added or scheduled for loading, this method will not fail or throw + * an exception (unless the original loading fails). Instead, the coroutine will be suspended until + * the original loading is finished and then return the same result. + * + * Note that to unload an asset, [unload] method should be called the same amount of times as [load] + * or [loadAsync]. Asset dependencies should not be unloaded directly; instead, unload the asset that + * required them and caused them to load in the first place. + * + * If the [parameters] define a [AssetLoaderParameters.loadedCallback], it will be invoked on the main + * rendering thread after the asset is loaded successfully with this [AssetStorage] wrapped as an + * [AssetManager] with [AssetManagerWrapper]. Note that the wrapper supports a limited number of methods. + * It is encouraged not to rely on [AssetLoaderParameters.LoadedCallback] and use coroutines instead. + * Exceptions thrown by callbacks will not be propagated, and will be logged with [logger] instead. + */ + inline fun loadAsync(path: String, parameters: AssetLoaderParameters? = null): Deferred = + loadAsync(getAssetDescriptor(path, parameters)) + + /** + * Schedules loading of an asset with path and type specified by [identifier]. + * Suspends the coroutine until an asset is loaded and returns a fully loaded instance of [T]. + * + * [Identifier.path] must be compatible with the [fileResolver]. + * Loading [parameters] are optional and can be used to configure the loaded asset. + * + * [Deferred.await] might throw the following exceptions: + * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * If the asset was already loaded, added or scheduled for loading, this method will not fail or throw + * an exception (unless the original loading fails). Instead, the coroutine will be suspended until + * the original loading is finished and then return the same result. + * + * Note that to unload an asset, [unload] method should be called the same amount of times as [load] + * or [loadAsync]. Asset dependencies should not be unloaded directly; instead, unload the asset that + * required them and caused them to load in the first place. + * + * If the [parameters] define a [AssetLoaderParameters.loadedCallback], it will be invoked on the main + * rendering thread after the asset is loaded successfully with this [AssetStorage] wrapped as an + * [AssetManager] with [AssetManagerWrapper]. Note that the wrapper supports a limited number of methods. + * It is encouraged not to rely on [AssetLoaderParameters.LoadedCallback] and use coroutines instead. + * Exceptions thrown by callbacks will not be propagated, and will be logged with [logger] instead. + */ + fun loadAsync(identifier: Identifier, parameters: AssetLoaderParameters? = null): Deferred = + loadAsync(identifier.toAssetDescriptor(parameters)) + + /** + * Schedules loading of an asset of [T] type described by the [descriptor]. + * Suspends the coroutine until an asset is loaded and returns a fully loaded instance of [T]. + * + * [Deferred.await] might throw the following exceptions: + * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * If the asset was already loaded, added or scheduled for loading, this method will not fail or throw + * an exception (unless the original loading fails). Instead, the coroutine will be suspended until + * the original loading is finished and then return the same result. + * + * Note that to unload an asset, [unload] method should be called the same amount of times as [load] + * or [loadAsync]. Asset dependencies should not be unloaded directly; instead, unload the asset that + * required them and caused them to load in the first place. + * + * If the [AssetDescriptor.params] define a [AssetLoaderParameters.loadedCallback], it will be invoked on + * the main rendering thread after the asset is loaded successfully with this [AssetStorage] wrapped as an + * [AssetManager] with [AssetManagerWrapper]. Note that the wrapper supports a limited number of methods. + * It is encouraged not to rely on [AssetLoaderParameters.LoadedCallback] and use coroutines instead. + * Exceptions thrown by callbacks will not be propagated, and will be logged with [logger] instead. + */ + fun loadAsync(descriptor: AssetDescriptor): Deferred = KtxAsync.async(asyncContext) { + load(descriptor) + } + + /** + * Schedules loading of an asset of [T] type located at [path]. + * Suspends the coroutine until an asset is loaded and returns a fully loaded instance of [T]. + * + * [path] must be compatible with the [fileResolver]. + * Loading [parameters] are optional and can be used to configure the loaded asset. + * + * Might throw the following exceptions: + * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * If the asset was already loaded, added or scheduled for loading, this method will not fail or throw + * an exception (unless the original loading fails). Instead, the coroutine will be suspended until + * the original loading is finished and then return the same result. + * + * Note that to unload an asset, [unload] method should be called the same amount of times as [load]. + * Asset dependencies should not be unloaded directly; instead, unload the asset that required them + * and caused them to load in the first place. + * + * If the [parameters] define a [AssetLoaderParameters.loadedCallback], it will be invoked on the main + * rendering thread after the asset is loaded successfully with this [AssetStorage] wrapped as an + * [AssetManager] with [AssetManagerWrapper]. Note that the wrapper supports a limited number of methods. + * It is encouraged not to rely on [AssetLoaderParameters.LoadedCallback] and use coroutines instead. + * Exceptions thrown by callbacks will not be propagated, and will be logged with [logger] instead. + */ + suspend inline fun load(path: String, parameters: AssetLoaderParameters? = null): T = + load(getAssetDescriptor(path, parameters)) + + /** + * Schedules loading of an asset with path and type specified by [identifier]. + * Suspends the coroutine until an asset is loaded and returns a fully loaded instance of [T]. + * + * [Identifier.path] must be compatible with the [fileResolver]. + * Loading [parameters] are optional and can be used to configure the loaded asset. + * + * Might throw the following exceptions: + * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * If the asset was already loaded, added or scheduled for loading, this method will not fail or throw + * an exception (unless the original loading fails). Instead, the coroutine will be suspended until + * the original loading is finished and then return the same result. + * + * Note that to unload an asset, [unload] method should be called the same amount of times as [load]. + * Asset dependencies should not be unloaded directly; instead, unload the asset that required them + * and caused them to load in the first place. + * + * If the [parameters] define a [AssetLoaderParameters.loadedCallback], it will be invoked on the main + * rendering thread after the asset is loaded successfully with this [AssetStorage] wrapped as an + * [AssetManager] with [AssetManagerWrapper]. Note that the wrapper supports a limited number of methods. + * It is encouraged not to rely on [AssetLoaderParameters.LoadedCallback] and use coroutines instead. + * Exceptions thrown by callbacks will not be propagated, and will be logged with [logger] instead. + */ + suspend fun load(identifier: Identifier, parameters: AssetLoaderParameters? = null): T = + load(identifier.toAssetDescriptor(parameters)) + + /** + * Schedules loading of an asset of [T] type described by the [descriptor]. + * Suspends the coroutine until an asset is loaded and returns a fully loaded instance of [T]. + * + * Might throw the following exceptions: + * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * If the asset was already loaded, added or scheduled for loading, this method will not fail or throw + * an exception (unless the original loading fails). Instead, the coroutine will be suspended until + * the original loading is finished and then return the same result. + * + * Note that to unload an asset, [unload] method should be called the same amount of times as [load]. + * Asset dependencies should not be unloaded directly; instead, unload the asset that required them + * and caused them to load in the first place. + * + * If the [AssetDescriptor.params] define a [AssetLoaderParameters.loadedCallback], it will be invoked on + * the main rendering thread after the asset is loaded successfully with this [AssetStorage] wrapped as an + * [AssetManager] with [AssetManagerWrapper]. Note that the wrapper supports a limited number of methods. + * It is encouraged not to rely on [AssetLoaderParameters.LoadedCallback] and use coroutines instead. + * Exceptions thrown by callbacks will not be propagated, and will be logged with [logger] instead. + */ + suspend fun load(descriptor: AssetDescriptor): T { + lateinit var newAssets: List> + lateinit var asset: Asset + lock.withLock { + asset = obtainAsset(descriptor) + newAssets = updateReferences(asset) + } + newAssets.forEach { assetToLoad -> + // Loading new assets asynchronously: + progress.registerScheduledAsset() + KtxAsync.launch(asyncContext) { + withAssetLoadingErrorHandling(assetToLoad) { + loadAsset(assetToLoad) + } + } + } + // Awaiting for our asset to load: + return asset.reference.await() + } + + /** Must be called with [lock]. */ + private suspend fun obtainAsset(descriptor: AssetDescriptor): Asset { + val identifier = descriptor.toIdentifier() + val asset = assets[identifier] + if (asset != null) { + // Asset already exists and identifier ensures same type - returning: + @Suppress("UNCHECKED_CAST") + return asset as Asset + } + return createNewAsset(descriptor).also { + assets[identifier] = it + } + } + + private suspend fun createNewAsset(descriptor: AssetDescriptor): Asset = + withContext(asyncContext) { + resolveFileHandle(descriptor) + val loader = getLoader(descriptor.type, descriptor.fileName) ?: throw MissingLoaderException(descriptor) + val dependencies = loader.getDependencies(descriptor) + Asset( + descriptor = descriptor, + dependencies = dependencies.map { obtainAsset(it) }, + loader = loader, + referenceCount = 0 + ) + } + + private fun resolveFileHandle(descriptor: AssetDescriptor<*>) { + if (descriptor.file == null) { + descriptor.file = fileResolver.resolve(descriptor.fileName) + } + } + + /** + * Must be executed with [lock]. + * Updates reference counts of entire dependency tree starting with [root]. + * Returns a list of new assets that have to be loaded. + */ + private fun updateReferences(root: Asset<*>): List> { + val queue = Queue>() + queue.addLast(root) + val newAssets = mutableListOf>() + while (!queue.isEmpty) { + val asset = queue.removeFirst() + asset.referenceCount++ + if (asset.referenceCount == 1) { + newAssets.add(asset) + } + asset.dependencies.forEach(queue::addLast) + } + return newAssets + } + + private suspend fun loadAsset( + asset: Asset + ): T { + asset.dependencies.forEach { dependency -> + withAssetLoadingErrorHandling(asset) { + dependency.reference.await() + } + } + if (asset.reference.isCompleted) { + // The asset failed to load due to its dependencies or asynchronous unloading: + return asset.reference.await() + } + withAssetLoadingErrorHandling(asset) { + when (val loader = asset.loader) { + is SynchronousLoader -> loadWithSynchronousLoader(loader, asset) + is AsynchronousLoader -> loadWithAsynchronousLoader(loader, asset) + else -> throw InvalidLoaderException(loader) + } + } + return asset.reference.await() + } + + private inline fun withAssetLoadingErrorHandling(asset: Asset<*>, operation: () -> Unit) { + try { + operation() + } catch (exception: AssetStorageException) { + setLoadedExceptionally(asset, exception) + } catch (exception: Throwable) { + setLoadedExceptionally(asset, AssetLoadingException(asset.descriptor, cause = exception)) + } + } + + private fun setLoadedExceptionally(asset: Asset<*>, exception: AssetStorageException) { + if (asset.reference.completeExceptionally(exception)) { + // This the passed exception managed to complete the loading, we record a failed asset loading: + progress.registerFailedAsset() + } + } + + private suspend fun loadWithSynchronousLoader( + synchronousLoader: SynchronousLoader, + asset: Asset + ) { + // If any of the isCompleted checks returns true, asset is likely to be unloaded asynchronously. + if (asset.reference.isCompleted) { + return + } + onRenderingThread { + if (!asset.reference.isCompleted) { + val value = synchronousLoader.load(asAssetManager, asset.descriptor) + setLoaded(asset, value) + } + } + } + + private suspend fun loadWithAsynchronousLoader( + asynchronousLoader: AsynchronousLoader, + asset: Asset + ) { + // If any of the isCompleted checks returns true, asset is likely to be unloaded asynchronously. + withContext(asyncContext) { + if (!asset.reference.isCompleted) { + asynchronousLoader.loadAsync(asAssetManager, asset.descriptor) + } + } + if (asset.reference.isCompleted) { + return + } + onRenderingThread { + if (!asset.reference.isCompleted) { + val value = asynchronousLoader.loadSync(asAssetManager, asset.descriptor) + setLoaded(asset, value) + } + } + } + + private fun setLoaded(asset: Asset, value: T) { + if (asset.reference.complete(value)) { + // The asset was correctly loaded and assigned. + progress.registerLoadedAsset() + try { + // Notifying the LibGDX loading callback to support AssetManager behavior: + asset.descriptor.params?.loadedCallback?.finishedLoading( + asAssetManager, asset.identifier.path, asset.identifier.type + ) + } catch (exception: Throwable) { + // We are unable to propagate the exception at this point, so we just log it: + logger.error( + "Exception occurred during execution of loaded callback of asset: ${asset.identifier}", + exception + ) + } + } else { + // The asset was unloaded asynchronously. The deferred was likely completed with an exception. + // Now we have to take care of the loaded value or it will remain loaded and unreferenced. + value.dispose(asset.identifier) + } + } + + /** + * Blocks the current thread until the asset with [T] type is loaded from the given [path]. + * + * This method is safe to call from the main rendering thread, as well as other application threads. + * However, avoid loading the same asset or assets with the same dependencies with both synchronous + * [loadSync] and asynchronous [load] or [loadAsync], and avoid running this method from within + * coroutines. + * + * This method should be used only to load crucial assets that are needed to initiate the application, + * e.g. assets required to display the loading screen. Whenever possible, prefer [load] and [loadAsync]. + * + * Might throw the following exceptions: + * - [MissingAssetException] when attempting to load an asset that was already scheduled for asynchronous loading. + * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * If the asset was already loaded, added or scheduled for loading, this method will either return the asset + * immediately if it is loaded, or throw [MissingAssetException] if it is unloaded. In either case, it will + * increase the reference count of the asset - see [getReferenceCount] and [unload] for details. + */ + inline fun loadSync(path: String): T = loadSync(getAssetDescriptor(path)) + + /** + * Blocks the current thread until the asset with [T] type is loaded with data specified by the [identifier] + * and optional loading [parameters]. + * + * This method is safe to call from the main rendering thread, as well as other application threads. + * However, avoid loading the same asset or assets with the same dependencies with both synchronous + * [loadSync] and asynchronous [load] or [loadAsync], and avoid running this method from within + * coroutines. + * + * This method should be used only to load crucial assets that are needed to initiate the application, + * e.g. assets required to display the loading screen. Whenever possible, prefer [load] and [loadAsync]. + * + * Might throw the following exceptions: + * - [MissingAssetException] when attempting to load an asset that was already scheduled for asynchronous loading. + * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * If the asset was already loaded, added or scheduled for loading, this method will either return the asset + * immediately if it is loaded, or throw [MissingAssetException] if it is unloaded. In either case, it will + * increase the reference count of the asset - see [getReferenceCount] and [unload] for details. + */ + fun loadSync(identifier: Identifier, parameters: AssetLoaderParameters? = null) = + loadSync(identifier.toAssetDescriptor(parameters)) + + /** + * Blocks the current thread until the asset with [T] type is loaded using the asset [descriptor]. + * + * This method is safe to call from the main rendering thread, as well as other application threads. + * However, avoid loading the same asset or assets with the same dependencies with both synchronous + * [loadSync] and asynchronous [load] or [loadAsync], and avoid running this method from within + * coroutines. + * + * This method should be used only to load crucial assets that are needed to initiate the application, + * e.g. assets required to display the loading screen. Whenever possible, prefer [load] and [loadAsync]. + * + * Might throw the following exceptions: + * - [MissingAssetException] when attempting to load an asset that was already scheduled for asynchronous loading. + * - [UnloadedAssetException] if the asset unloaded asynchronously by another coroutine. + * - [MissingLoaderException] if the [AssetLoader] for asset of requested type is not registered. + * - [InvalidLoaderException] if the [AssetLoader] implementation of requested type is invalid. + * - [AssetLoadingException] if the [AssetLoader] has thrown an exception during loading. + * - [MissingDependencyException] is the [AssetLoader] is unable to obtain an instance of asset's dependency. + * - [UnsupportedMethodException] is the [AssetLoader] uses unsupported operation on [AssetManagerWrapper]. + * + * If the asset was already loaded, added or scheduled for loading, this method will either return the asset + * immediately if it is loaded, or throw [MissingAssetException] if it is unloaded. In either case, it will + * increase the reference count of the asset - see [getReferenceCount] and [unload] for details. + */ + fun loadSync(descriptor: AssetDescriptor): T = runBlocking { + lateinit var asset: Asset + val newAssets = lock.withLock { + asset = obtainAsset(descriptor) + updateReferences(asset) + } + loadSync(newAssets) + getOrThrow(asset) + } + + private suspend fun loadSync(assets: List>) { + val queue = Queue>(assets.size) + assets.forEach { + progress.registerScheduledAsset() + // Adding assets in reversed order - dependencies should be first: + queue.addFirst(it) + } + verifyDependenciesForSynchronousLoading(assets) + while (!queue.isEmpty) { + val asset = queue.removeFirst() + // "Awaiting" for dependencies to be loaded without suspending: + if (asset.dependencies.any { !it.reference.isCompleted }) { + queue.addLast(asset) + continue + } + onRenderingThread { + withAssetLoadingErrorHandling(asset) { + loadAssetSync(asset) + } + } + } + } + + private fun verifyDependenciesForSynchronousLoading(assets: List>) { + val identifiers = assets.map { it.identifier }.toSet() + val exceptions = mutableListOf() + assets.forEach { asset -> + // Gathering all dependencies that are not loaded and were scheduled for asynchronous loading: + asset.dependencies.filter { !it.reference.isCompleted && it.identifier !in identifiers } + // Preparing an exception if such a dependency occurs: + .map { MissingDependencyException(it.identifier) } + // Setting parent asset as exceptionally loaded: + .forEach { exception -> setLoadedExceptionally(asset, exception); exceptions.add(exception) } + } + // Throwing one of the exceptions if any occurs: + exceptions.firstOrNull()?.let { throw it } + } + + private fun loadAssetSync(asset: Asset) = + when (val loader = asset.loader) { + is SynchronousLoader -> setLoaded(asset, loader.load(asAssetManager, asset.descriptor)) + is AsynchronousLoader -> { + loader.loadAsync(asAssetManager, asset.descriptor) + setLoaded(asset, loader.loadSync(asAssetManager, asset.descriptor)) + } + else -> throw InvalidLoaderException(loader) + } + + /** + * Removes asset loaded with the given [path] and [T] type and all of its dependencies. + * Does nothing if asset was not loaded in the first place. + * Will not dispose of the asset if it still is referenced by any other assets. + * Any removed assets that implement [Disposable] will be disposed. + * + * Note: only assets that were explicitly scheduled for loading with [load], [loadAsync] or + * [loadSync], or manually added to storage with [add] should be unloaded. + * Dependencies of assets will be removed automatically along with the original assets + * that caused them to load in the first place. + * + * Assets scheduled for loading multiple times must be explicitly unloaded multiple times - + * until the asset is unloaded as many times as it was referenced, it is assumed that it is + * still used. Manually unloading dependencies of other assets (that were not scheduled + * for loading explicitly) might lead to unexpected runtime exceptions. + * + * Will log all exceptions related to unloading of the assets. Silence the [logger] + * to avoid exception logging. + * + * Returns `true` if the asset was present in the [AssetStorage]. Note that if the asset + * is still referenced (i.e. [load] was called multiple times or the asset is a dependency + * of an asset that is still loaded), the asset will not be disposed of and will remain + * in the storage even if `true` is returned. + */ + suspend inline fun unload(path: String): Boolean = unload(getIdentifier(path)) + + /** + * Removes asset described by the [descriptor] and all of its dependencies. + * Does nothing if asset was not loaded in the first place. + * Will not dispose of the asset if it still is referenced by any other assets. + * Any removed assets that implement [Disposable] will be disposed. + * + * Note: only assets that were explicitly scheduled for loading with [load], [loadAsync] or + * [loadSync], or manually added to storage with [add] should be unloaded. + * Dependencies of assets will be removed automatically along with the original assets + * that caused them to load in the first place. + * + * Assets scheduled for loading multiple times must be explicitly unloaded multiple times - + * until the asset is unloaded as many times as it was referenced, it is assumed that it is + * still used. Manually unloading dependencies of other assets (that were not scheduled + * for loading explicitly) might lead to unexpected runtime exceptions. + * + * Will log all exceptions related to unloading of the assets. Silence the [logger] + * to avoid exception logging. + * + * Returns `true` if the asset was present in the [AssetStorage]. Note that if the asset + * is still referenced (i.e. [load] was called multiple times or the asset is a dependency + * of an asset that is still loaded), the asset will not be disposed of and will remain + * in the storage even if `true` is returned. + */ + suspend fun unload(descriptor: AssetDescriptor<*>): Boolean = unload(descriptor.toIdentifier()) + + /** + * Removes asset loaded with the given [identifier] and all of its dependencies. + * Does nothing if asset was not loaded in the first place. + * Will not dispose of the asset if it still is referenced by any other assets. + * Any removed assets that implement [Disposable] will be disposed. + * + * Note: only assets that were explicitly scheduled for loading with [load], [loadAsync] or + * [loadSync], or manually added to storage with [add] should be unloaded. + * Dependencies of assets will be removed automatically along with the original assets + * that caused them to load in the first place. + * + * Assets scheduled for loading multiple times must be explicitly unloaded multiple times - + * until the asset is unloaded as many times as it was referenced, it is assumed that it is + * still used. Manually unloading dependencies of other assets (that were not scheduled + * for loading explicitly) might lead to unexpected runtime exceptions. + * + * Will log all exceptions related to unloading of the assets. Silence the [logger] + * to avoid exception logging. + * + * Returns `true` if the asset was present in the [AssetStorage]. Note that if the asset + * is still referenced (i.e. [load] was called multiple times or the asset is a dependency + * of an asset that is still loaded), the asset will not be disposed of and will remain + * in the storage even if `true` is returned. + */ + suspend fun unload(identifier: Identifier<*>): Boolean = lock.withLock { + val root = assets[identifier] + if (root == null) { + // Asset is absent in the storage. Returning false - unsuccessful unload: + false + } else { + val queue = Queue>() + queue.addLast(root) + while (!queue.isEmpty) { + val asset = queue.removeFirst() + asset.referenceCount-- + if (asset.referenceCount == 0) { + // The asset is no longer referenced by the user or any dependencies. Removing and disposing. + assets.remove(asset.identifier) + disposeOf(asset) + } + asset.dependencies.forEach(queue::addLast) + } + // Asset was present in the storage. Returning true - successful unload: + true + } + } + + @Suppress("EXPERIMENTAL_API_USAGE") // Allows to dispose of assets without suspending calls. + private fun disposeOf(asset: Asset<*>) { + if (!asset.reference.isCompleted) { + val exception = UnloadedAssetException(asset.identifier) + // If the asset is not loaded yet, we complete the reference with exception: + val cancelled = asset.reference.completeExceptionally(exception) + if (cancelled) { + progress.removeScheduledAsset() + // We managed to complete the reference exceptionally. The loading coroutine will dispose of the asset. + return + } + } + val exception = asset.reference.getCompletionExceptionOrNull() + if (exception != null) { + // The asset was not loaded successfully. Nothing to dispose of. + progress.removeFailedAsset() + } else { + progress.removeLoadedAsset() + asset.reference.getCompleted().dispose(asset.identifier) + } + } + + /** + * Performs cast to [Disposable] if possible and disposes of the object with [Disposable.dispose]. + * Logs any disposing errors. + */ + private fun Any?.dispose(identifier: Identifier<*>) { + try { + (this as? Disposable)?.dispose() + } catch (exception: Throwable) { + logger.error("Failed to dispose of asset: $identifier", exception) + } + } + + /** + * Returns the [AssetLoader] associated with the file. [Asset] is used to determine the type + * of the loaded file. [path] might be necessary to choose the correct loader, as some loaders + * might be assigned to specific file suffixes or extensions. + */ + inline fun getLoader(path: String? = null): Loader? = + getLoader(Asset::class.java, path) + + /** + * Internal API exposed for inlined method. See inlined [getLoader] with generics. + * [type] is the class of the loaded asset, while path is used to determine if + * a loader specifically assigned to a file suffix or extension is necessary. + */ + fun getLoader(type: Class, path: String?): Loader? = + loaderStorage.getLoader(type, path?.normalizePath()) + + /** + * Associates the [AssetLoader] with specific asset type determined by [T]. + * [loaderProvider] should create a new instance of loader of the selected types. + * Optional [suffix] can be passed if the loader should handle only the files + * with a specific file name suffix or extension. + * + * Throws [InvalidLoaderException] if the [AssetLoader] does not extend + * [SynchronousAssetLoader] or [AsynchronousAssetLoader]. + */ + inline fun setLoader(suffix: String? = null, noinline loaderProvider: () -> Loader) = + setLoader(T::class.java, suffix, loaderProvider) + + /** + * Internal API exposed for inlined method. See inlined [setLoader] with reified generics. + * Associates the [AssetLoader] [loaderProvider] with [type] under the specified optional [suffix]. + * + * Throws [InvalidLoaderException] if the [AssetLoader] does not extend + * [SynchronousAssetLoader] or [AsynchronousAssetLoader]. + */ + fun setLoader(type: Class, suffix: String? = null, loaderProvider: () -> Loader) { + loaderStorage.setLoaderProvider(type, suffix, loaderProvider) + } + + /** + * Matches [AssetDescriptor] pre-processing. Return this [String] with normalized file separators. + */ + fun String.normalizePath() = replace('\\', '/') + + /** + * Returns the amount of references to the asset under the given [path] of [T] type. + * + * References include manual registration of the asset with [add], + * scheduling the asset for loading with [load], [loadAsync] or [loadSync], + * and the amount of times the asset was referenced as a dependency of other assets. + */ + inline fun getReferenceCount(path: String): Int = getReferenceCount(getIdentifier(path)) + + /** + * Returns the amount of references to the asset described by [descriptor]. + * + * References include manual registration of the asset with [add], + * scheduling the asset for loading with [load], [loadAsync] or [loadSync], + * and the amount of times the asset was referenced as a dependency of other assets. + */ + fun getReferenceCount(descriptor: AssetDescriptor<*>): Int = getReferenceCount(descriptor.toIdentifier()) + + /** + * Returns the amount of references to the asset identified by [identifier]. + * + * References include manual registration of the asset with [add], + * scheduling the asset for loading with [load], [loadAsync] or [loadSync], + * and the amount of times the asset was referenced as a dependency of other assets. + */ + fun getReferenceCount(identifier: Identifier<*>): Int = assets[identifier]?.referenceCount ?: 0 + + /** + * Returns a copy of the list of dependencies of the asset under [path] with [T] type. + * If the asset is not loaded or has no dependencies, an empty list is returned. + */ + inline fun getDependencies(path: String): List> = + getDependencies(getIdentifier(path)) + + /** + * Returns a copy of the list of dependencies of the asset described by [descriptor]. + * If the asset is not loaded or has no dependencies, an empty list is returned. + */ + fun getDependencies(descriptor: AssetDescriptor<*>): List> = + getDependencies(descriptor.toIdentifier()) + + /** + * Returns a copy of the list of dependencies of the asset identified by [identifier]. + * If the asset is not loaded or has no dependencies, an empty list is returned. + */ + fun getDependencies(identifier: Identifier<*>): List> { + val dependencies = assets[identifier]?.dependencies + return dependencies?.map { it.identifier } ?: emptyList() + } + + /** + * Unloads all assets. Blocks current thread until are assets are unloaded. + * Logs all disposing exceptions. + * + * Prefer suspending [dispose] method that takes an error handler as parameter. + * + * Calling [dispose] is not guaranteed to keep the eventual consistency of [progress] + * if [dispose] is called during asynchronous asset loading. + * If exact loading progress is crucial, prefer creating another instance of [AssetStorage] + * than reusing existing one that has been disposed. + */ + override fun dispose() { + runBlocking { + dispose { identifier, cause -> + logger.error("Unable to dispose of $identifier.", cause) + } + } + } + + /** + * Unloads all assets. Cancels loading of all scheduled assets. + * [onError] will be invoked on every caught disposing exception. + * + * Calling [dispose] is not guaranteed to keep the eventual consistency of [progress] + * if [dispose] is called during asynchronous asset loading. + * If exact loading progress is crucial, prefer creating another instance of [AssetStorage] + * than reusing existing one that has been disposed. + */ + suspend fun dispose(onError: (identifier: Identifier<*>, cause: Throwable) -> Unit) { + lock.withLock { + for (asset in assets.values) { + if (!asset.reference.isCompleted) { + val exception = UnloadedAssetException(asset.identifier) + if (asset.reference.completeExceptionally(exception)) { + // We managed to complete the deferred exceptionally, + // so the loading coroutine will take care of the rest. + continue + } + } + try { + (asset.reference.await() as? Disposable)?.dispose() + } catch (exception: Throwable) { + onError(asset.identifier, exception) + } + asset.referenceCount = 0 + } + assets.clear() + progress.reset() + } + } + + override fun toString(): String = "AssetStorage(assets=${ + assets.keys.sortedBy { it.path }.joinToString(separator = ", ", prefix = "[", postfix = "]") + })" +} + +/** + * Container for a single asset of type [T] managed by [AssetStorage]. + */ +internal data class Asset( + /** Stores asset loading data. */ + val descriptor: AssetDescriptor, + /** Unique identifier of the asset. */ + val identifier: Identifier = descriptor.toIdentifier(), + /** Stores reference to the actual asset once it is loaded. */ + val reference: CompletableDeferred = CompletableDeferred(), + /** Lists asset dependencies that require loading. */ + val dependencies: List>, + /** Used to load the asset. */ + val loader: Loader, + /** Control variable. Lists how many times the asset is referenced by other assets as dependency + * or by direct manual load requests. */ + @Volatile var referenceCount: Int = 0 +) + +/** + * Uniquely identifies a single asset stored in an [AssetStorage] by its [type] and [path]. + * + * Multiple assets with the same [path] can be stored in an [AssetStorage] as long as they + * have a different [type]. Similarly, [AssetStorage] can store multiple assets of the same + * [type], as long as each has a different [path]. + * + * Avoid using [Identifier] constructor directly. Instead, rely on [AssetStorage.getIdentifier] + * or [AssetDescriptor.toIdentifier]. + */ +data class Identifier( + /** File path to the asset compatible with the [AssetStorage.fileResolver]. Must be normalized. */ + val path: String, + /** [Class] of the asset specified during loading. */ + val type: Class +) { + /** + * Converts this [Identifier] to an [AssetDescriptor] that describes the asset and its loading data. + * + * If the returned [AssetDescriptor] is used to load an asset, and the asset requires specific loading + * instructions, make sure to pass the loading [parameters] to set [AssetDescriptor.parameters]. Similarly, + * if the asset requires a custom [FileHandle] incompatible with [AssetStorage.fileResolver], pass the + * [fileHandle] parameter to set it as [AssetDescriptor.file]. + * + * If the [AssetDescriptor] is used to simply identify an asset similarly to an [Identifier], + * [parameters] and [fileHandle] are not required. You can retrieve a loaded asset from the + * [AssetStorage] with either its [Identifier] or an [AssetDescriptor] without loading data - + * the parameters and file are only used when calling [AssetStorage.load]. + */ + fun toAssetDescriptor( + parameters: AssetLoaderParameters? = null, fileHandle: FileHandle? = null + ): AssetDescriptor = + AssetDescriptor(path, type, parameters).apply { + if (fileHandle != null) { + file = fileHandle + } + } +} + +/** + * Converts this [AssetDescriptor] to an [AssetStorage] [Identifier]. + * Copies [AssetDescriptor.type] to [Identifier.type] and [AssetDescriptor.fileName] to [Identifier.path]. + * + * Note that loading parameters from [AssetDescriptor.parameters] are ignored. If the returned [Identifier] + * is used to load an asset, and the asset requires specific loading instructions, make sure to pass the + * loading parameters to the [AssetStorage.load] method. + * + * Similarly, [AssetDescriptor.file] is not used by the [Identifier]. Instead, [AssetDescriptor.fileName] + * will be used to resolve the file using [AssetStorage.fileResolver]. If a [FileHandle] of a different type + * is required, use [AssetDescriptor] for loading instead. + */ +fun AssetDescriptor.toIdentifier(): Identifier = Identifier(fileName, type) diff --git a/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt b/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt new file mode 100644 index 00000000..f478fc4e --- /dev/null +++ b/assets-async/src/main/kotlin/ktx/assets/async/wrapper.kt @@ -0,0 +1,208 @@ +package ktx.assets.async + +import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetErrorListener +import com.badlogic.gdx.assets.AssetLoaderParameters +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.AssetLoader +import com.badlogic.gdx.assets.loaders.FileHandleResolver +import com.badlogic.gdx.utils.Logger +import kotlinx.coroutines.launch +import ktx.async.KtxAsync +import com.badlogic.gdx.utils.Array as GdxArray + +/** + * Extends [AssetManager], delegating all of its asset-related method calls to [assetStorage]. + * Allows to use classic [AssetLoader] implementations with [AssetStorage]. Internal API, + * DO NOT use directly. + */ +@Suppress("DEPRECATION") +internal class AssetManagerWrapper(val assetStorage: AssetStorage) + : AssetManager(assetStorage.fileResolver, false) { + private var initiated = false + + init { + // Shutting down super's executor: + super.dispose() + // Replacing logger: + val logger = Logger("AssetStorage") + logger.level = Logger.ERROR + super.setLogger(logger) + + initiated = true + } + + override fun clear() = dispose() + + @Deprecated("This operation is non-blocking. Assets might still be loaded after this call.", + replaceWith = ReplaceWith("AssetStorage.dispose")) + override fun dispose() { + if (initiated) { + logger.error("Not fully supported AssetManagerWrapper.dispose called by AssetLoader.") + KtxAsync.launch { + assetStorage.dispose { path, error -> + logger.error("Unable to dispose of the asset: $path", error) + } + } + } + } + + @Deprecated("Not supported by AssetStorage.", + replaceWith = ReplaceWith("contains(fileName, type)")) + override fun contains(fileName: String): Boolean = false + override fun contains(fileName: String, type: Class<*>?): Boolean = + assetStorage.contains(AssetDescriptor(fileName, type)) + + @Deprecated("This operation is non-blocking. Assets might not be available in storage after call.", + replaceWith = ReplaceWith("AssetStorage.add")) + override fun addAsset(fileName: String, type: Class, asset: T) { + logger.error("Not fully supported AssetManagerWrapper.addAsset called by AssetLoader.") + KtxAsync.launch { + assetStorage.add(AssetDescriptor(fileName, type), asset) + } + } + + @Deprecated("Not supported by AssetStorage.", replaceWith = ReplaceWith("Nothing")) + override fun taskFailed(assetDesc: AssetDescriptor<*>?, ex: RuntimeException?) = + throw UnsupportedMethodException("taskFailed") + + @Deprecated("Not supported by AssetStorage.", replaceWith = ReplaceWith("Nothing")) + override fun setErrorListener(listener: AssetErrorListener?) { + logger.error("Not fully supported AssetManagerWrapper.setErrorListener called by AssetLoader.") + } + + @Deprecated("Not supported by AssetStorage.", replaceWith = ReplaceWith("Nothing")) + override fun containsAsset(asset: T): Boolean = throw UnsupportedMethodException("containsAsset") + + override fun get(assetDescriptor: AssetDescriptor): Asset = + get(assetDescriptor.fileName, assetDescriptor.type) + + override fun get(fileName: String, type: Class): Asset { + val identifier = Identifier(fileName, type) + return try { + assetStorage[identifier] + } catch (exception: Throwable) { + throw MissingDependencyException(identifier, exception) + } + } + + @Deprecated("Not supported by AssetStorage.", replaceWith = ReplaceWith("get(fileName, type)")) + override fun get(fileName: String): Asset = throw UnsupportedMethodException("get(String)") + + @Deprecated("Not supported by AssetStorage.", replaceWith = ReplaceWith("Nothing")) + override fun getAssetNames(): GdxArray = throw UnsupportedMethodException("getAssetNames") + + @Deprecated("Multiple assets with different types can be listed under the same path.", + replaceWith = ReplaceWith("Nothing")) + override fun getAssetType(fileName: String): Class<*>? = throw UnsupportedMethodException("getAssetType") + + @Deprecated("Not supported by AssetStorage.", replaceWith = ReplaceWith("Nothing")) + override fun getAll(type: Class, out: GdxArray): GdxArray = + throw UnsupportedMethodException("getAll") + + @Deprecated("Not supported by AssetStorage.", replaceWith = ReplaceWith("Nothing")) + override fun getAssetFileName(asset: T): String? { + logger.error("Not supported AssetManagerWrapper.getAssetFileName called by AssetLoader.") + return null + } + + override fun getDiagnostics(): String = assetStorage.toString() + override fun getFileHandleResolver(): FileHandleResolver = assetStorage.fileResolver + + override fun getProgress(): Float = assetStorage.progress.percent + override fun getLoadedAssets(): Int = assetStorage.progress.loaded + override fun isFinished(): Boolean = assetStorage.progress.isFinished + + override fun getLoader(type: Class): AssetLoader<*, *>? = getLoader(type, "") + override fun getLoader(type: Class, fileName: String): AssetLoader<*, *>? = + assetStorage.getLoader(type, fileName) + + override fun isLoaded(assetDesc: AssetDescriptor<*>): Boolean = assetStorage.isLoaded(assetDesc) + override fun isLoaded(fileName: String, type: Class<*>): Boolean = isLoaded(AssetDescriptor(fileName, type)) + + @Deprecated("Not supported by AssetStorage.", + replaceWith = ReplaceWith("isLoaded(fileName, type)")) + override fun isLoaded(fileName: String): Boolean = false.also { + logger.error("Not supported AssetManagerWrapper.addAsset called by AssetLoader.") + } + + @Deprecated("AssetStorage requires type of asset to unload.", + replaceWith = ReplaceWith("AssetStorage.unload")) + override fun unload(fileName: String) { + logger.error("Not supported AssetManagerWrapper.unload called by AssetLoader.") + } + + override fun load(fileName: String, type: Class) = load(fileName, type, null) + override fun load(fileName: String, type: Class, parameters: AssetLoaderParameters?) = + load(AssetDescriptor(fileName, type, parameters)) + + override fun load(descriptor: AssetDescriptor<*>) { + KtxAsync.launch { + assetStorage.load(descriptor) + } + } + + @Deprecated("AssetLoader instances can be mutable." + + "AssetStorage requires functional providers of loaders rather than singular instances.", + replaceWith = ReplaceWith("AssetStorage.setLoader")) + override fun ?> setLoader(type: Class, loader: AssetLoader) = + setLoader(type, null, loader) + + @Deprecated("AssetLoader instances can be mutable." + + "AssetStorage requires functional providers of loaders rather than singular instances.", + replaceWith = ReplaceWith("AssetStorage.setLoader")) + override fun ?> setLoader( + type: Class, suffix: String?, loader: AssetLoader + ) { + logger.error("Not fully supported AssetManagerWrapper.setLoader called by AssetLoader.") + assetStorage.setLoader(type, suffix) { + @Suppress("UNCHECKED_CAST") + loader as Loader + } + } + + @Deprecated("AssetStorage requires type to find dependencies.", + replaceWith = ReplaceWith("AssetStorage.getDependencies")) + override fun getDependencies(fileName: String): GdxArray = GdxArray.with().also { + logger.error("Not supported AssetManagerWrapper.getDependencies called by AssetLoader.") + } + + @Deprecated("AssetStorage requires type to find reference count.", + replaceWith = ReplaceWith("AssetStorage.getReferenceCount")) + override fun getReferenceCount(fileName: String): Int = 0.also { + logger.error("Not supported AssetManagerWrapper.getReferenceCount called by AssetLoader.") + } + + @Deprecated("AssetStorage does not have to be updated.", ReplaceWith("Nothing")) + override fun update(millis: Int): Boolean = isFinished + + @Deprecated("AssetStorage does not have to be updated.", ReplaceWith("Nothing")) + override fun update(): Boolean = isFinished + + @Deprecated("Unsupported operation.", ReplaceWith("Nothing")) + override fun setReferenceCount(fileName: String, refCount: Int) = + throw UnsupportedMethodException("setReferenceCount") + + @Deprecated("AssetStorage does not maintain an assets queue.", ReplaceWith("Nothing")) + override fun getQueuedAssets(): Int = 0.also { + logger.error("Not supported AssetManagerWrapper.getQueuedAssets called by AssetLoader.") + } + + @Deprecated("Unsupported operation.", ReplaceWith("Nothing")) + override fun finishLoading() { + logger.error("Not supported AssetManagerWrapper.finishLoading called by AssetLoader.") + } + + @Suppress("UNCHECKED_CAST") + override fun finishLoadingAsset(assetDesc: AssetDescriptor<*>): T = + get(assetDesc as AssetDescriptor) + + @Deprecated("Unsupported without asset type.", ReplaceWith("finishLoadingAsset(assetDescriptor)")) + override fun finishLoadingAsset(fileName: String): T = + throw UnsupportedMethodException("finishLoadingAsset(String)") + + override fun toString(): String = "AssetManagerWrapper(storage=$assetStorage)" + override fun hashCode(): Int = assetStorage.hashCode() + override fun equals(other: Any?): Boolean = + other is AssetManagerWrapper && other.assetStorage === assetStorage +} diff --git a/assets-async/src/test/kotlin/ktx/assets/async/loadersTest.kt b/assets-async/src/test/kotlin/ktx/assets/async/loadersTest.kt new file mode 100644 index 00000000..8d2f4d5e --- /dev/null +++ b/assets-async/src/test/kotlin/ktx/assets/async/loadersTest.kt @@ -0,0 +1,266 @@ +package ktx.assets.async + +import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetLoaderParameters +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.AssetLoader +import com.badlogic.gdx.assets.loaders.SynchronousAssetLoader +import com.badlogic.gdx.files.FileHandle +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import io.kotlintest.matchers.shouldThrow +import org.junit.Assert.* +import org.junit.Test +import java.io.File +import com.badlogic.gdx.utils.Array as GdxArray + +/** + * Tests [AssetLoaderStorage] - manager of [AssetLoader] instances used by an [AssetStorage] to load assets. + */ +class AssetLoaderStorageTest { + @Test + fun `should return null when requested to return loader for unknown type`() { + // Given: + val storage = AssetLoaderStorage() + + // When: + val loader = storage.getLoader(String::class.java) + + // Then: + assertNull(loader) + } + + @Test + fun `should return null when requested to return loader with suffix for unknown type`() { + // Given: + val storage = AssetLoaderStorage() + + // When: + val loader = storage.getLoader(String::class.java, ".txt") + + // Then: + assertNull(loader) + } + + @Test + fun `should return loader for known type`() { + // Given: + val storage = AssetLoaderStorage() + val stringLoader = mockStringLoader() + storage.setLoaderProvider(String::class.java) { stringLoader } + + // When: + val loader = storage.getLoader(String::class.java) + + // Then: + assertSame(stringLoader, loader) + } + + @Test + fun `should return null when requested main loader for known type without appropriate suffix`() { + // Given: + val storage = AssetLoaderStorage() + storage.setLoaderProvider(String::class.java, suffix = ".txt") { mockStringLoader() } + + // When: + val loader = storage.getLoader(String::class.java) + + // Then: + assertNull(loader) + } + + @Test + fun `should return null when requested loader for known type with invalid suffix`() { + // Given: + val storage = AssetLoaderStorage() + storage.setLoaderProvider(String::class.java, suffix = ".txt") { mockStringLoader() } + + // When: + val loader = storage.getLoader(String::class.java, path = "invalid.md") + + // Then: + assertNull(loader) + } + + @Test + fun `should return main loader if suffix does not match specific loader`() { + // Given: + val storage = AssetLoaderStorage() + val mainLoader = mockStringLoader() + val suffixLoader = mockStringLoader() + storage.setLoaderProvider(String::class.java) { mainLoader } + storage.setLoaderProvider(String::class.java, suffix = ".md") { suffixLoader } + + // When: + val loader = storage.getLoader(String::class.java, path = "test.txt") + + // Then: + assertSame(mainLoader, loader) + } + + @Test + fun `should return loader for known type with valid suffix`() { + // Given: + val storage = AssetLoaderStorage() + val stringLoader = mockStringLoader() + storage.setLoaderProvider(String::class.java, suffix = ".txt") { stringLoader } + + // When: + val loader = storage.getLoader(String::class.java, path = "test.txt") + + // Then: + assertSame(stringLoader, loader) + } + + @Test + fun `should override main loader`() { + // Given: + val storage = AssetLoaderStorage() + val previousLoader = mockStringLoader() + val stringLoader = mockStringLoader() + storage.setLoaderProvider(String::class.java) { previousLoader } + storage.setLoaderProvider(String::class.java) { stringLoader } + + // When: + val loader = storage.getLoader(String::class.java) + + // Then: + assertNotSame(previousLoader, loader) + assertSame(stringLoader, loader) + } + + @Test + fun `should override loader with suffix`() { + // Given: + val storage = AssetLoaderStorage() + val previousLoader = mockStringLoader() + val stringLoader = mockStringLoader() + storage.setLoaderProvider(String::class.java) { previousLoader } + storage.setLoaderProvider(String::class.java) { stringLoader } + + // When: + val loader = storage.getLoader(String::class.java) + + // Then: + assertNotSame(previousLoader, loader) + assertSame(stringLoader, loader) + } + + @Test + fun `should not cache loaders and invoke loader provider for each request`() { + // Given: + val storage = AssetLoaderStorage() + storage.setLoaderProvider(String::class.java) { mockStringLoader() } + + // When: + val firstLoader = storage.getLoader(String::class.java) + val secondLoader = storage.getLoader(String::class.java) + + // Then: + assertNotSame(firstLoader, secondLoader) + } + + @Test + fun `should reject loader that does not extend SynchronousAssetLoader or AsynchronousAssetLoader`() { + // Given: + val storage = AssetLoaderStorage() + val invalidLoader = mock>>() + + // Expect: + shouldThrow { + storage.setLoaderProvider(String::class.java) { invalidLoader } + } + } + + private fun mockStringLoader() = mock>>() +} + +/** + * Tests [AssetLoader] extensions. + */ +class LoadersTest { + @Test + fun `should get dependencies`() { + // Given: + val dependencies = GdxArray>() + val file = FileHandle(File("test")) + val assetDescriptor = AssetDescriptor("test", String::class.java) + assetDescriptor.file = file + val loader = mock> { + on(it.getDependencies("test", file, null)) doReturn dependencies + } + + // When: + val result = loader.getDependencies(assetDescriptor) + + // Then: + assertSame(dependencies, result) + verify(loader).getDependencies("test", file, null) + } + + @Test + fun `should load synchronously`() { + // Given: + val assetManager = mock() + val file = FileHandle(File("test")) + val assetDescriptor = AssetDescriptor("test", String::class.java) + assetDescriptor.file = file + val loader = mock> { + on(it.load(assetManager, "test", file, null)) doReturn "Asset." + } + + // When: + val asset = loader.load(assetManager, assetDescriptor) + + // Then: + assertSame("Asset.", asset) + verify(loader).load(assetManager, "test", file, null) + } + + @Test + fun `should load asynchronously`() { + // Given: + val assetManager = mock() + val file = FileHandle(File("test")) + val assetDescriptor = AssetDescriptor("test", String::class.java) + assetDescriptor.file = file + val loader = mock> { + on(it.loadSync(assetManager, "test", file, null)) doReturn "Asset." + } + + // When: + loader.loadAsync(assetManager, assetDescriptor) + val asset = loader.loadSync(assetManager, assetDescriptor) + + // Then: + assertSame("Asset.", asset) + verify(loader).loadAsync(assetManager, "test", file, null) + verify(loader).loadSync(assetManager, "test", file, null) + } +} + +/** + * Tests [ManualLoader] implementation. + */ +class ManualLoaderTest { + @Test + fun `should return empty dependencies array`() { + // When: + val dependencies = ManualLoader.getDependencies("file.path", mock(), null) + + // Then: + assertEquals(GdxArray>(0), dependencies) + } + + @Test + fun `should return empty dependencies array with loading parameters`() { + // When: + val dependencies = ManualLoader.getDependencies( + "file.path", mock(), ManualLoadingParameters() + ) + + // Then: + assertEquals(GdxArray>(0), dependencies) + } +} diff --git a/assets-async/src/test/kotlin/ktx/assets/async/progressTest.kt b/assets-async/src/test/kotlin/ktx/assets/async/progressTest.kt new file mode 100644 index 00000000..ce922771 --- /dev/null +++ b/assets-async/src/test/kotlin/ktx/assets/async/progressTest.kt @@ -0,0 +1,282 @@ +package ktx.assets.async + +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test + +class LoadingProgressTest { + @Test + fun `should increase count of scheduled assets`() { + // Given: + val progress = LoadingProgress() + + // When: + progress.registerScheduledAsset() + + // Then: + assertEquals(1, progress.total) + assertEquals(0, progress.loaded) + assertEquals(0, progress.failed) + } + + @Test + fun `should increase count of loaded assets`() { + // Given: + val progress = LoadingProgress() + progress.registerScheduledAsset() + + // When: + progress.registerLoadedAsset() + + // Then: + assertEquals(1, progress.total) + assertEquals(1, progress.loaded) + assertEquals(0, progress.failed) + } + + @Test + fun `should increase count of added assets`() { + // Given: + val progress = LoadingProgress() + + // When: + progress.registerAddedAsset() + + // Then: + assertEquals(1, progress.total) + assertEquals(1, progress.loaded) + assertEquals(0, progress.failed) + } + + @Test + fun `should increase count of failed assets`() { + // Given: + val progress = LoadingProgress() + progress.registerScheduledAsset() + + // When: + progress.registerFailedAsset() + + // Then: + assertEquals(1, progress.total) + assertEquals(0, progress.loaded) + assertEquals(1, progress.failed) + } + + @Test + fun `should decrease count of scheduled assets`() { + // Given: + val progress = LoadingProgress() + progress.registerScheduledAsset() + + // When: + progress.removeScheduledAsset() + + // Then: + assertEquals(0, progress.total) + assertEquals(0, progress.loaded) + assertEquals(0, progress.failed) + } + + @Test + fun `should decrease count of loaded assets`() { + // Given: + val progress = LoadingProgress() + progress.registerScheduledAsset() + progress.registerLoadedAsset() + + // When: + progress.removeLoadedAsset() + + // Then: + assertEquals(0, progress.total) + assertEquals(0, progress.loaded) + assertEquals(0, progress.failed) + } + + @Test + fun `should decrease count of added assets`() { + // Given: + val progress = LoadingProgress() + progress.registerAddedAsset() + + // When: + progress.removeLoadedAsset() + + // Then: + assertEquals(0, progress.total) + assertEquals(0, progress.loaded) + assertEquals(0, progress.failed) + } + + @Test + fun `should decrease count of failed assets`() { + // Given: + val progress = LoadingProgress() + progress.registerScheduledAsset() + progress.registerFailedAsset() + + // When: + progress.removeFailedAsset() + + // Then: + assertEquals(0, progress.total) + assertEquals(0, progress.loaded) + assertEquals(0, progress.failed) + } + + @Test + fun `should report progress`() { + // Given: + val progress = LoadingProgress() + + // When: + repeat(5) { progress.registerScheduledAsset() } + // Then: + assertEquals(0f, progress.percent) + assertFalse(progress.isFinished) + assertFalse(progress.isFailed) + + // When: + progress.registerLoadedAsset() + // Then: + assertEquals(0.2f, progress.percent) + assertFalse(progress.isFinished) + assertFalse(progress.isFailed) + + // When: + progress.registerLoadedAsset() + // Then: + assertEquals(0.4f, progress.percent) + assertFalse(progress.isFinished) + assertFalse(progress.isFailed) + + // When: + progress.registerLoadedAsset() + // Then: + assertEquals(0.6f, progress.percent) + assertFalse(progress.isFinished) + assertFalse(progress.isFailed) + + // When: + progress.registerLoadedAsset() + // Then: + assertEquals(0.8f, progress.percent) + assertFalse(progress.isFinished) + assertFalse(progress.isFailed) + + // When: + progress.registerLoadedAsset() + // Then: + assertEquals(1f, progress.percent) + assertTrue(progress.isFinished) + assertFalse(progress.isFailed) + } + + @Test + fun `should report progress within 0, 1 range even when out of sync`() { + // Given: + val progress = LoadingProgress() + + // When: loaded assets count is higher than scheduled assets: + progress.registerScheduledAsset() + progress.registerLoadedAsset() + progress.registerLoadedAsset() + + // Then: + assertEquals(1f, progress.percent) + } + + @Test + fun `should not include failed assets in progress`() { + // Given: + val progress = LoadingProgress() + repeat(4) { progress.registerScheduledAsset() } + repeat(3) { progress.registerLoadedAsset() } + + // When: + progress.registerFailedAsset() + + // Then: + assertEquals(0.75f, progress.percent) + assertTrue(progress.isFailed) + assertTrue(progress.isFinished) + } + + @Test + fun `should reset the progress`() { + // Given: + val progress = LoadingProgress() + repeat(100) { progress.registerScheduledAsset() } + repeat(75) { progress.registerLoadedAsset() } + repeat(25) { progress.registerFailedAsset() } + repeat(50) { progress.registerAddedAsset() } + + // When: + progress.reset() + + // Then: + assertEquals(0, progress.total) + assertEquals(0, progress.loaded) + assertEquals(0, progress.failed) + assertFalse(progress.isFinished) + assertFalse(progress.isFailed) + } + + @Test + fun `should handle concurrent updates`() { + // Given: + val progress = LoadingProgress() + + // When: + val jobs = (1..1000).map { id -> + GlobalScope.launch { + when (id % 4) { + 0 -> progress.registerScheduledAsset() + 1 -> progress.registerLoadedAsset() + 2 -> progress.registerFailedAsset() + 3 -> progress.registerAddedAsset() + } + } + } + + // Then: + runBlocking { jobs.joinAll() } + assertEquals(500, progress.total) + assertEquals(500, progress.loaded) + assertEquals(250, progress.failed) + } + + @Test + fun `should handle concurrent registration and removal`() { + // Given: + val progress = LoadingProgress() + + // When: + val jobs = (1..1000).map { id -> + GlobalScope.launch { + when (id % 4) { + 0 -> progress.registerScheduledAsset() + 1 -> progress.registerLoadedAsset() + 2 -> progress.registerFailedAsset() + 3 -> progress.registerAddedAsset() + } + when (id % 5) { + 0 -> progress.removeScheduledAsset() + 1 -> progress.removeLoadedAsset() + 2 -> progress.removeFailedAsset() + else -> progress.registerScheduledAsset() + } + } + } + + // Then: + runBlocking { jobs.joinAll() } + assertEquals(900 - 600, progress.total) + assertEquals(500 - 200, progress.loaded) + assertEquals(250 - 200, progress.failed) + } +} diff --git a/assets-async/src/test/kotlin/ktx/assets/async/storageLoadingTest.kt b/assets-async/src/test/kotlin/ktx/assets/async/storageLoadingTest.kt new file mode 100644 index 00000000..d2e85d83 --- /dev/null +++ b/assets-async/src/test/kotlin/ktx/assets/async/storageLoadingTest.kt @@ -0,0 +1,1164 @@ +package ktx.assets.async + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetLoaderParameters +import com.badlogic.gdx.assets.loaders.resolvers.ClasspathFileHandleResolver +import com.badlogic.gdx.audio.Music +import com.badlogic.gdx.audio.Sound +import com.badlogic.gdx.backends.lwjgl.LwjglNativesLoader +import com.badlogic.gdx.backends.lwjgl.audio.OpenALAudio +import com.badlogic.gdx.graphics.Cubemap +import com.badlogic.gdx.graphics.Pixmap +import com.badlogic.gdx.graphics.Texture +import com.badlogic.gdx.graphics.g2d.BitmapFont +import com.badlogic.gdx.graphics.g2d.ParticleEffect +import com.badlogic.gdx.graphics.g2d.TextureAtlas +import com.badlogic.gdx.graphics.g3d.Model +import com.badlogic.gdx.graphics.glutils.ShaderProgram +import com.badlogic.gdx.math.Vector2 +import com.badlogic.gdx.scenes.scene2d.ui.Button +import com.badlogic.gdx.scenes.scene2d.ui.Skin +import com.badlogic.gdx.utils.I18NBundle +import com.badlogic.gdx.utils.Logger +import com.nhaarman.mockitokotlin2.mock +import io.kotlintest.matchers.shouldThrow +import kotlinx.coroutines.runBlocking +import ktx.assets.TextAssetLoader +import ktx.async.AsyncTest +import org.junit.* +import org.junit.Assert.* +import org.junit.rules.TestName +import java.util.* +import com.badlogic.gdx.graphics.g3d.particles.ParticleEffect as ParticleEffect3D + +/** + * [AssetStorage] has 3 main variants of asset loading: [AssetStorage.load], [AssetStorage.loadAsync] + * and [AssetStorage.loadSync]. To test each and every one, a common abstract test suite is provided. + * + * This test suite ensures that each method supports loading of every default asset type + * and performs basic asset loading logic tests. + * + * Note that variants consuming [String] path and reified asset types could not be easily tested, + * as they cannot be invoked in abstract methods. However, since all of them are just aliases and + * contain no logic other than [AssetDescriptor] or [Identifier] initiation, the associated loading + * methods are still tested. + * + * See also: [AssetStorageTest]. + */ +abstract class AbstractAssetStorageLoadingTest : AsyncTest() { + @get:Rule + var testName = TestName() + + /** + * Must be overridden with the tested loading method variant. + * Blocks the current thread until the selected asset is loaded. + */ + protected abstract fun AssetStorage.testLoad( + path: String, + type: Class, + parameters: AssetLoaderParameters? + ): T + + private inline fun AssetStorage.testLoad( + path: String, + parameters: AssetLoaderParameters? = null + ): T = testLoad(path, T::class.java, parameters) + + // --- Asset support tests: + + @Test + fun `should load text assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertEquals("Content.", asset) + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + } + + @Test + fun `should load text assets with parameters`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + + // When: + val asset = storage.testLoad(path, parameters = TextAssetLoader.TextAssetLoaderParameters("UTF-8")) + + // Then: + assertEquals("Content.", asset) + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + } + + @Test + fun `should unload text assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load BitmapFont assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "com/badlogic/gdx/utils/arial-15.fnt" + val dependency = "com/badlogic/gdx/utils/arial-15.png" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) + // Font dependencies: + assertTrue(storage.isLoaded(dependency)) + assertEquals(1, storage.getReferenceCount(dependency)) + assertSame(asset.region.texture, storage.get(dependency)) + + storage.dispose() + } + + @Test + fun `should unload BitmapFont with dependencies`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "com/badlogic/gdx/utils/arial-15.fnt" + val dependency = "com/badlogic/gdx/utils/arial-15.png" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + assertFalse(storage.isLoaded(dependency)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load Music assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/sound.ogg" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload Music assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/sound.ogg" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load Sound assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/sound.ogg" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload Sound assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/sound.ogg" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load TextureAtlas assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.atlas" + val dependency = "ktx/assets/async/texture.png" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) + // Atlas dependencies: + assertTrue(storage.isLoaded(dependency)) + assertSame(asset.textures.first(), storage.get(dependency)) + assertEquals(1, storage.getReferenceCount(dependency)) + + storage.dispose() + } + + @Test + fun `should unload TextureAtlas assets with dependencies`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.atlas" + val dependency = "ktx/assets/async/texture.png" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + assertFalse(storage.isLoaded(dependency)) + assertEquals(0, storage.getReferenceCount(dependency)) + } + + @Test + fun `should load Texture assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload Texture assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load Pixmap assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload Pixmap assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load Skin assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.json" + val atlas = "ktx/assets/async/skin.atlas" + val texture = "ktx/assets/async/texture.png" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertNotNull(asset.get("default", Button.ButtonStyle::class.java)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(atlas)), storage.getDependencies(path)) + // Skin dependencies: + assertTrue(storage.isLoaded(atlas)) + assertEquals(1, storage.getReferenceCount(atlas)) + assertSame(asset.atlas, storage.get(atlas)) + assertEquals(listOf(storage.getIdentifier(texture)), storage.getDependencies(atlas)) + // Atlas dependencies: + assertTrue(storage.isLoaded(texture)) + assertSame(asset.atlas.textures.first(), storage.get(texture)) + assertEquals(1, storage.getReferenceCount(texture)) + + storage.dispose() + } + + @Test + fun `should unload Skin assets with dependencies`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.json" + val atlas = "ktx/assets/async/skin.atlas" + val texture = "ktx/assets/async/texture.png" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + assertFalse(storage.isLoaded(atlas)) + assertEquals(0, storage.getReferenceCount(atlas)) + assertFalse(storage.isLoaded(texture)) + assertEquals(0, storage.getReferenceCount(texture)) + } + + @Test + fun `should load I18NBundle assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/i18n" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertEquals("Value.", asset["key"]) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload I18NBundle assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/i18n" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load ParticleEffect assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/particle.p2d" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload ParticleEffect assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/particle.p2d" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load ParticleEffect3D assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/particle.p3d" + val dependency = "ktx/assets/async/texture.png" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(listOf(storage.getIdentifier(dependency)), storage.getDependencies(path)) + // Particle dependencies: + assertTrue(storage.isLoaded(dependency)) + assertNotNull(storage.get(dependency)) + assertEquals(1, storage.getReferenceCount(dependency)) + + storage.dispose() + } + + @Test + fun `should unload ParticleEffect3D assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/particle.p3d" + val dependency = "ktx/assets/async/texture.png" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + assertFalse(storage.isLoaded(dependency)) + assertEquals(0, storage.getReferenceCount(dependency)) + } + + @Test + fun `should load OBJ Model assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.obj" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload OBJ Model assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.obj" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load G3DJ Model assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.g3dj" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload G3DJ Model assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.g3dj" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load G3DB Model assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.g3db" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload G3DB Model assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/model.g3db" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load ShaderProgram assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/shader.frag" + // Silencing logs - shader will fail to compile, as GL is mocked: + storage.logger.level = Logger.NONE + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload ShaderProgram assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/shader.frag" + // Silencing logs - shader will fail to compile, as GL is mocked: + storage.logger.level = Logger.NONE + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should load Cubemap assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/cubemap.zktx" + + // When: + val asset = storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(emptyList(), storage.getDependencies(path)) + + storage.dispose() + } + + @Test + fun `should unload Cubemap assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/cubemap.zktx" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should dispose of multiple assets of different types without errors`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + storage.logger.level = Logger.NONE + val assets = listOf( + storage.getIdentifier("ktx/assets/async/string.txt"), + storage.getIdentifier("com/badlogic/gdx/utils/arial-15.fnt"), + storage.getIdentifier("ktx/assets/async/sound.ogg"), + storage.getIdentifier("ktx/assets/async/skin.atlas"), + storage.getIdentifier("ktx/assets/async/texture.png"), + storage.getIdentifier("ktx/assets/async/skin.json"), + storage.getIdentifier("ktx/assets/async/i18n"), + storage.getIdentifier("ktx/assets/async/particle.p2d"), + storage.getIdentifier("ktx/assets/async/particle.p3d"), + storage.getIdentifier("ktx/assets/async/model.obj"), + storage.getIdentifier("ktx/assets/async/model.g3dj"), + storage.getIdentifier("ktx/assets/async/model.g3db"), + storage.getIdentifier("ktx/assets/async/shader.frag"), + storage.getIdentifier("ktx/assets/async/cubemap.zktx") + ) + assets.forEach { + storage.testLoad(it.path, it.type, parameters = null) + assertTrue(storage.isLoaded(it)) + } + + // When: + storage.dispose() + + // Then: + assets.forEach { + assertFalse(it in storage) + assertFalse(storage.isLoaded(it)) + assertEquals(0, storage.getReferenceCount(it)) + assertEquals(emptyList(), storage.getDependencies(it)) + shouldThrow { + storage[it] + } + } + } + + // --- Behavior tests: + + @Test + fun `should return same asset instance with subsequent load calls on loaded asset`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + val loaded = storage.testLoad(path) + + // When: + val assets = (1..10).map { storage.testLoad(path) } + + // Then: + assertEquals(11, storage.getReferenceCount(path)) + assets.forEach { asset -> + assertSame(loaded, asset) + } + checkProgress(storage, loaded = 1) + + storage.dispose() + } + + @Test + fun `should obtain loaded asset with path`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + + // When: + storage.testLoad(path) + + // Then: + assertTrue(storage.contains(path)) + assertTrue(storage.isLoaded(path)) + assertEquals("Content.", storage.get(path)) + assertEquals("Content.", storage.getOrNull(path)) + assertEquals("Content.", runBlocking { storage.getAsync(path).await() }) + assertEquals(emptyList(), storage.getDependencies(path)) + checkProgress(storage, loaded = 1, warn = true) + } + + @Test + fun `should obtain loaded asset with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val identifier = storage.getIdentifier("ktx/assets/async/string.txt") + + // When: + storage.testLoad(identifier.path) + + // Then: + assertTrue(identifier in storage) + assertTrue(storage.isLoaded(identifier)) + assertEquals("Content.", storage[identifier]) + assertEquals("Content.", storage.getOrNull(identifier)) + assertEquals("Content.", runBlocking { storage.getAsync(identifier).await() }) + assertEquals(emptyList(), storage.getDependencies(identifier)) + checkProgress(storage, loaded = 1, warn = true) + } + + @Test + fun `should obtain loaded asset with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val descriptor = storage.getAssetDescriptor("ktx/assets/async/string.txt") + + // When: + storage.testLoad(descriptor.fileName) + + // Then: + assertTrue(descriptor in storage) + assertTrue(storage.isLoaded(descriptor)) + assertEquals("Content.", storage[descriptor]) + assertEquals("Content.", storage.getOrNull(descriptor)) + assertEquals("Content.", runBlocking { storage.getAsync(descriptor).await() }) + assertEquals(emptyList(), storage.getDependencies(descriptor)) + checkProgress(storage, loaded = 1, warn = true) + } + + @Test + fun `should unload assets with path`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + storage.testLoad(path) + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + checkProgress(storage, total = 0) + } + + @Test + fun `should unload assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val descriptor = storage.getAssetDescriptor(path) + storage.testLoad(path) + + // When: + runBlocking { storage.unload(descriptor) } + + // Then: + assertFalse(storage.isLoaded(descriptor)) + assertEquals(0, storage.getReferenceCount(descriptor)) + checkProgress(storage, total = 0) + } + + @Test + fun `should unload assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val identifier = storage.getIdentifier(path) + storage.testLoad(path) + + // When: + runBlocking { storage.unload(identifier) } + + // Then: + assertFalse(storage.isLoaded(identifier)) + assertEquals(0, storage.getReferenceCount(identifier)) + checkProgress(storage, total = 0) + } + + @Test + fun `should allow to load multiple assets with different type and same path`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + + // When: + storage.testLoad(path) + storage.testLoad(path) + + // Then: + assertTrue(storage.isLoaded(path)) + assertTrue(storage.isLoaded(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertNotSame(storage.get(path), storage.get(path)) + checkProgress(storage, loaded = 2, warn = true) + + storage.dispose() + } + + @Test + fun `should increase references count and return the same asset when trying to load asset with same path`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val elements = IdentityHashMap() + + // When: + repeat(3) { + val asset = storage.testLoad(path) + elements[asset] = true + } + + // Then: + assertEquals(3, storage.getReferenceCount(path)) + assertEquals(1, elements.size) + } + + @Test + fun `should fail to load asset with missing loader`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + + // When: + shouldThrow { + storage.testLoad(path) + } + + // Then: + assertFalse(storage.contains(path)) + checkProgress(storage, total = 0) + } + + @Test + fun `should increase references counts of dependencies when loading same asset`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.json" + val dependencies = arrayOf( + storage.getIdentifier("ktx/assets/async/skin.atlas"), + storage.getIdentifier("ktx/assets/async/texture.png") + ) + val loadedAssets = IdentityHashMap() + + // When: + repeat(3) { + val asset = storage.testLoad(path) + loadedAssets[asset] = true + } + + // Then: + assertEquals(3, storage.getReferenceCount(path)) + dependencies.forEach { + assertEquals(3, storage.getReferenceCount(it)) + } + assertEquals(1, loadedAssets.size) + checkProgress(storage, loaded = 3, warn = true) + } + + @Test + fun `should handle loading exceptions`() { + // Given: + val loader = AssetStorageTest.FakeSyncLoader( + onLoad = { throw IllegalStateException("Expected.") } + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + val path = "fake path" + + // When: + shouldThrow { + storage.testLoad(path) + } + + // Then: asset should still be in storage, but rethrowing original exception: + assertTrue(storage.contains(path)) + assertEquals(1, storage.getReferenceCount(path)) + shouldThrow { + storage.get(path) + } + shouldThrow { + storage.getOrNull(path) + } + val reference = storage.getAsync(path) + shouldThrow { + runBlocking { reference.await() } + } + checkProgress(storage, failed = 1, warn = true) + } + + @Test + fun `should handle asynchronous loading exceptions`() { + // Given: + val loader = AssetStorageTest.FakeAsyncLoader( + onAsync = { throw IllegalStateException("Expected.") }, + onSync = {} + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + val path = "fake path" + + // When: + shouldThrow { + storage.testLoad(path) + } + + // Then: asset should still be in storage, but rethrowing original exception: + assertTrue(storage.contains(path)) + assertEquals(1, storage.getReferenceCount(path)) + shouldThrow { + storage.get(path) + } + shouldThrow { + storage.getOrNull(path) + } + val reference = storage.getAsync(path) + shouldThrow { + runBlocking { reference.await() } + } + checkProgress(storage, failed = 1, warn = true) + } + + @Test + fun `should handle synchronous loading exceptions`() { + // Given: + val loader = AssetStorageTest.FakeAsyncLoader( + onAsync = { }, + onSync = { throw IllegalStateException("Expected.") } + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + val path = "fake path" + + // When: + shouldThrow { + storage.testLoad(path) + } + + // Then: asset should still be in storage, but rethrowing original exception: + assertTrue(storage.contains(path)) + assertEquals(1, storage.getReferenceCount(path)) + shouldThrow { + storage.get(path) + } + shouldThrow { + storage.getOrNull(path) + } + val reference = storage.getAsync(path) + shouldThrow { + runBlocking { reference.await() } + } + checkProgress(storage, failed = 1, warn = true) + } + + @Test + fun `should not fail to unload asset that was loaded exceptionally`() { + // Given: + val loader = AssetStorageTest.FakeSyncLoader( + onLoad = { throw IllegalStateException("Expected.") } + ) + val storage = AssetStorage(useDefaultLoaders = false) + val path = "fake path" + storage.setLoader { loader } + storage.logger.level = Logger.NONE // Disposing exception will be logged. + try { + storage.testLoad(path) + } catch (exception: AssetLoadingException) { + // Expected. + } + + // When: + val unloaded = runBlocking { + storage.unload(path) + } + + // Then: + assertTrue(unloaded) + assertFalse(storage.contains(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + /** + * Allows to validate state of [LoadingProgress] without failing the test case. + * Pass [warn] not to fail the test on progress mismatch. + * + * Progress is eventually consistent. It does not have to be up to date with the [AssetStorage] state. + * Usually it will be and all tests would pass just fine, but there are these rare situations where + * the asserts are evaluated before the progress is updated. That's why if such case is possible, + * only a warning will be printed instead of failing the test. + * + * If the warnings are common, it might point to a bug within the progress updating. + */ + private fun checkProgress( + storage: AssetStorage, + loaded: Int = 0, failed: Int = 0, + total: Int = loaded + failed, + warn: Boolean = false + ) { + if (warn) { + val progress = storage.progress + if (total != progress.total || loaded != progress.loaded || failed != progress.failed) { + System.err.println(""" + Warning: mismatch in progress value in `${testName.methodName}`. + Value | Expected | Actual + total | ${"%8d".format(total)} | ${progress.total} + loaded | ${"%8d".format(loaded)} | ${progress.loaded} + failed | ${"%8d".format(failed)} | ${progress.failed} + If this warning is repeated consistently, there might be a related bug in progress reporting. + """.trimIndent()) + } + } else { + assertEquals(total, storage.progress.total) + assertEquals(loaded, storage.progress.loaded) + assertEquals(failed, storage.progress.failed) + } + } + + companion object { + @JvmStatic + @BeforeClass + fun `load LibGDX statics`() { + // Necessary for LibGDX asset loaders to work. + LwjglNativesLoader.load() + Gdx.graphics = mock() + Gdx.gl20 = mock() + Gdx.gl = Gdx.gl20 + } + + @JvmStatic + @AfterClass + fun `dispose of LibGDX statics`() { + Gdx.graphics = null + Gdx.audio = null + Gdx.gl20 = null + Gdx.gl = null + } + } + + @Before + override fun `setup LibGDX application`() { + super.`setup LibGDX application`() + Gdx.audio = OpenALAudio() + } + + @After + override fun `exit LibGDX application`() { + super.`exit LibGDX application`() + (Gdx.audio as OpenALAudio).dispose() + } +} + +/** + * Performs asset loading tests with [AssetStorage.loadAsync] consuming [AssetDescriptor]. + */ +class AssetStorageLoadingTestWithAssetDescriptorLoadAsync : AbstractAssetStorageLoadingTest() { + override fun AssetStorage.testLoad( + path: String, type: Class, parameters: AssetLoaderParameters? + ): T = runBlocking { + loadAsync(AssetDescriptor(path, type, parameters)).await() + } +} + +/** + * Performs asset loading tests with [AssetStorage.loadAsync] consuming [Identifier]. + */ +class AssetStorageLoadingTestWithIdentifierLoadAsync : AbstractAssetStorageLoadingTest() { + override fun AssetStorage.testLoad( + path: String, type: Class, parameters: AssetLoaderParameters? + ): T = runBlocking { + loadAsync(Identifier(path, type), parameters).await() + } +} + +/** + * Performs asset loading tests with [AssetStorage.load] consuming [AssetDescriptor]. + */ +class AssetStorageLoadingTestWithAssetDescriptorLoad : AbstractAssetStorageLoadingTest() { + override fun AssetStorage.testLoad( + path: String, type: Class, parameters: AssetLoaderParameters? + ): T = runBlocking { + load(AssetDescriptor(path, type, parameters)) + } +} + +/** + * Performs asset loading tests with [AssetStorage.load] consuming [Identifier]. + */ +class AssetStorageLoadingTestWithIdentifierLoad : AbstractAssetStorageLoadingTest() { + override fun AssetStorage.testLoad( + path: String, type: Class, parameters: AssetLoaderParameters? + ): T = runBlocking { + load(Identifier(path, type), parameters) + } +} + +/** + * Performs asset loading tests with [AssetStorage.loadSync] consuming [AssetDescriptor]. + */ +class AssetStorageLoadingTestWithAssetDescriptorLoadSync : AbstractAssetStorageLoadingTest() { + override fun AssetStorage.testLoad( + path: String, type: Class, parameters: AssetLoaderParameters? + ): T = loadSync(AssetDescriptor(path, type, parameters)) +} + +/** + * Performs asset loading tests with [AssetStorage.loadSync] consuming [Identifier]. + */ +class AssetStorageLoadingTestWithIdentifierLoadSync : AbstractAssetStorageLoadingTest() { + override fun AssetStorage.testLoad( + path: String, type: Class, parameters: AssetLoaderParameters? + ): T = loadSync(Identifier(path, type), parameters) +} diff --git a/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt new file mode 100644 index 00000000..4ba90546 --- /dev/null +++ b/assets-async/src/test/kotlin/ktx/assets/async/storageTest.kt @@ -0,0 +1,2132 @@ +package ktx.assets.async + +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.assets.AssetDescriptor +import com.badlogic.gdx.assets.AssetLoaderParameters +import com.badlogic.gdx.assets.AssetManager +import com.badlogic.gdx.assets.loaders.AsynchronousAssetLoader +import com.badlogic.gdx.assets.loaders.SynchronousAssetLoader +import com.badlogic.gdx.assets.loaders.resolvers.ClasspathFileHandleResolver +import com.badlogic.gdx.audio.Music +import com.badlogic.gdx.audio.Sound +import com.badlogic.gdx.backends.lwjgl.LwjglNativesLoader +import com.badlogic.gdx.files.FileHandle +import com.badlogic.gdx.graphics.Cubemap +import com.badlogic.gdx.graphics.Pixmap +import com.badlogic.gdx.graphics.Texture +import com.badlogic.gdx.graphics.g2d.BitmapFont +import com.badlogic.gdx.graphics.g2d.ParticleEffect +import com.badlogic.gdx.graphics.g2d.TextureAtlas +import com.badlogic.gdx.graphics.g3d.Model +import com.badlogic.gdx.graphics.glutils.ShaderProgram +import com.badlogic.gdx.math.Vector2 +import com.badlogic.gdx.scenes.scene2d.ui.Skin +import com.badlogic.gdx.utils.Disposable +import com.badlogic.gdx.utils.GdxRuntimeException +import com.badlogic.gdx.utils.I18NBundle +import com.badlogic.gdx.utils.Logger +import com.google.common.collect.Sets +import com.nhaarman.mockitokotlin2.* +import io.kotlintest.matchers.shouldThrow +import kotlinx.coroutines.* +import kotlinx.coroutines.future.asCompletableFuture +import ktx.assets.TextAssetLoader.TextAssetLoaderParameters +import ktx.async.* +import org.junit.AfterClass +import org.junit.Assert.* +import org.junit.BeforeClass +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestName +import java.lang.Integer.min +import java.util.* +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ThreadLocalRandom +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.max +import com.badlogic.gdx.graphics.g3d.particles.ParticleEffect as ParticleEffect3D +import com.badlogic.gdx.utils.Array as GdxArray + +/** + * Tests [AssetStorage]: coroutines-based asset manager. + * + * This suite tests the behavior and logic of the [AssetStorage]. [AbstractAssetStorageLoadingTest] + * and its extensions test whether [AssetStorage] can correctly load all default asset types. + * + * Implementation note: the tests use [runBlocking] to launch the coroutines for simplicity + * of asserts. Normally [KtxAsync].launch is highly encouraged to build truly asynchronous applications. + * Using [runBlocking] on the main rendering thread might lead to deadlocks, as the rendering thread + * is necessary to load some assets (e.g. textures). + * + * In a similar manner, some [AssetDescriptor] instances are created manually. In an actual application, + * using [AssetStorage.getAssetDescriptor] is a much easier way of obtaining [AssetDescriptor] instances. + */ +class AssetStorageTest : AsyncTest() { + @get:Rule + var testName = TestName() + + companion object { + @JvmStatic + @BeforeClass + fun `load LibGDX statics`() { + // Necessary for LibGDX asset loaders to work. + LwjglNativesLoader.load() + Gdx.graphics = mock() + Gdx.gl20 = mock() + Gdx.gl = Gdx.gl20 + } + + @JvmStatic + @AfterClass + fun `dispose of LibGDX statics`() { + Gdx.graphics = null + Gdx.audio = null + Gdx.gl20 = null + Gdx.gl = null + } + } + + /** + * Allows to validate state of [LoadingProgress] without failing the test case. + * Pass [warn] not to fail the test on progress mismatch. + * + * Progress is eventually consistent. It does not have to be up to date with the [AssetStorage] state. + * Usually it will be and all tests would pass just fine, but there are these rare situations where + * the asserts are evaluated before the progress is updated. That's why if such case is possible, + * only a warning will be printed instead of failing the test. + * + * If the warnings are common, it might point to a bug within the progress updating. + */ + private fun checkProgress( + storage: AssetStorage, + loaded: Int = 0, failed: Int = 0, + total: Int = loaded + failed, + warn: Boolean = false + ) { + if (warn) { + val progress = storage.progress + if (total != progress.total || loaded != progress.loaded || failed != progress.failed) { + System.err.println(""" + Warning: mismatch in progress value in `${testName.methodName}`. + Value | Expected | Actual + total | ${"%8d".format(total)} | ${progress.total} + loaded | ${"%8d".format(loaded)} | ${progress.loaded} + failed | ${"%8d".format(failed)} | ${progress.failed} + If this warning is repeated consistently, there might be a related bug in progress reporting. + """.trimIndent()) + } + } else { + assertEquals(total, storage.progress.total) + assertEquals(loaded, storage.progress.loaded) + assertEquals(failed, storage.progress.failed) + } + } + + @Test + fun `should throw exception when attempting to get unloaded asset`() { + // Given: + val storage = AssetStorage() + + // When: + shouldThrow { + storage.get("ktx/assets/async/string.txt") + } + + // Then: + checkProgress(storage, total = 0) + } + + @Test + fun `should return null when attempting to get unloaded asset or null`() { + // Given: + val storage = AssetStorage() + + // When: + val asset = storage.getOrNull("ktx/assets/async/string.txt") + + // Then: + assertNull(asset) + checkProgress(storage, total = 0) + } + + @Test + fun `should return deferred that throws exception when attempting to get unloaded asset asynchronously`() { + // Given: + val storage = AssetStorage() + + // When: + val result = storage.getAsync("ktx/assets/async/string.txt") + + // Then: + shouldThrow { + runBlocking { result.await() } + } + checkProgress(storage, total = 0) + } + + @Test + fun `should throw exception when attempting to get unloaded asset with identifier`() { + // Given: + val storage = AssetStorage() + val identifier = storage.getIdentifier("ktx/assets/async/string.txt") + + // When: + shouldThrow { + storage[identifier] + } + + // Then: + checkProgress(storage, total = 0) + } + + @Test + fun `should return null when attempting to get unloaded asset or null with identifier`() { + // Given: + val storage = AssetStorage() + val identifier = storage.getIdentifier("ktx/assets/async/string.txt") + + // When: + val asset = storage.getOrNull(identifier) + + // Then: + assertNull(asset) + checkProgress(storage, total = 0) + } + + @Test + fun `should return deferred that throws exception when attempting to get unloaded asset asynchronously with identifier`() { + // Given: + val storage = AssetStorage() + val identifier = storage.getIdentifier("ktx/assets/async/string.txt") + + // When: + val result = storage.getAsync(identifier) + + // Then: + shouldThrow { + runBlocking { result.await() } + } + checkProgress(storage, total = 0) + } + + @Test + fun `should throw exception when attempting to get unloaded asset with descriptor`() { + // Given: + val storage = AssetStorage() + val descriptor = storage.getAssetDescriptor("ktx/assets/async/string.txt") + + // When: + shouldThrow { + storage[descriptor] + } + + // Then: + checkProgress(storage, total = 0) + } + + @Test + fun `should return null when attempting to get unloaded asset or null with descriptor`() { + // Given: + val storage = AssetStorage() + val descriptor = storage.getAssetDescriptor("ktx/assets/async/string.txt") + + // When: + val asset = storage.getOrNull(descriptor) + + // Then: + assertNull(asset) + checkProgress(storage, total = 0) + } + + @Test + fun `should return deferred that throws exception when attempting to get unloaded asset asynchronously with descriptor`() { + // Given: + val storage = AssetStorage() + val descriptor = storage.getAssetDescriptor("ktx/assets/async/string.txt") + + // When: + val result = storage.getAsync(descriptor) + + // Then: + shouldThrow { + runBlocking { result.await() } + } + checkProgress(storage, total = 0) + } + + @Test + fun `should obtain loaded asset with path`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + + // When: + runBlocking { storage.load(path) } + + // Then: + assertTrue(storage.contains(path)) + assertTrue(storage.isLoaded(path)) + assertEquals("Content.", storage.get(path)) + assertEquals("Content.", storage.getOrNull(path)) + assertEquals("Content.", runBlocking { storage.getAsync(path).await() }) + assertEquals(emptyList(), storage.getDependencies(path)) + checkProgress(storage, loaded = 1, warn = true) + } + + @Test + fun `should obtain loaded asset with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val identifier = storage.getIdentifier("ktx/assets/async/string.txt") + + // When: + runBlocking { storage.loadAsync(identifier.path).await() } + + // Then: + assertTrue(identifier in storage) + assertTrue(storage.isLoaded(identifier)) + assertEquals("Content.", storage[identifier]) + assertEquals("Content.", storage.getOrNull(identifier)) + assertEquals("Content.", runBlocking { storage.getAsync(identifier).await() }) + assertEquals(emptyList(), storage.getDependencies(identifier)) + checkProgress(storage, loaded = 1, warn = true) + } + + @Test + fun `should obtain loaded asset with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val descriptor = storage.getAssetDescriptor("ktx/assets/async/string.txt") + + // When: + storage.loadSync(descriptor.fileName) + + // Then: + assertTrue(descriptor in storage) + assertTrue(storage.isLoaded(descriptor)) + assertEquals("Content.", storage[descriptor]) + assertEquals("Content.", storage.getOrNull(descriptor)) + assertEquals("Content.", runBlocking { storage.getAsync(descriptor).await() }) + assertEquals(emptyList(), storage.getDependencies(descriptor)) + checkProgress(storage, loaded = 1, warn = true) + } + + @Test + fun `should unload assets with path`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertFalse(storage.isLoaded(path)) + assertEquals(0, storage.getReferenceCount(path)) + checkProgress(storage, total = 0, warn = true) + } + + @Test + fun `should unload assets with descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val descriptor = storage.getAssetDescriptor(path) + runBlocking { storage.loadAsync(path).await() } + + // When: + runBlocking { storage.unload(descriptor) } + + // Then: + assertFalse(storage.isLoaded(descriptor)) + assertEquals(0, storage.getReferenceCount(descriptor)) + checkProgress(storage, total = 0, warn = true) + } + + @Test + fun `should unload assets with identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val identifier = storage.getIdentifier(path) + storage.loadSync(path) + + // When: + runBlocking { storage.unload(identifier) } + + // Then: + assertFalse(storage.isLoaded(identifier)) + assertEquals(0, storage.getReferenceCount(identifier)) + checkProgress(storage, total = 0, warn = true) + } + + @Test + fun `should differentiate assets by path and type`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + + // When: + runBlocking { storage.add("path", "ASSET") } + + // Then: + assertTrue(storage.contains("path")) + assertFalse(storage.contains("different path")) + assertFalse(storage.contains("path")) // Different type. + } + + @Test + fun `should point to the same asset when loading with path, descriptor and identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val descriptor = storage.getAssetDescriptor(path) + val identifier = storage.getIdentifier(path) + + // When: + val viaPath = runBlocking { storage.load(path) } + val viaDescriptor = runBlocking { storage.load(descriptor) } + val viaIdentifier = runBlocking { storage.load(identifier) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(viaPath, viaDescriptor) + assertSame(viaDescriptor, viaIdentifier) + assertEquals(3, storage.getReferenceCount(path)) + checkProgress(storage, loaded = 1, warn = true) + } + + @Test + fun `should point to the same asset when loading with path, descriptor and identifier asynchronously`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val descriptor = storage.getAssetDescriptor(path) + val identifier = storage.getIdentifier(path) + val pathReference = storage.loadAsync(path) + val descriptorReference = storage.loadAsync(descriptor) + val identifierReference = storage.loadAsync(identifier) + + // When: + val viaPath = runBlocking { pathReference.await() } + val viaDescriptor = runBlocking { descriptorReference.await() } + val viaIdentifier = runBlocking { identifierReference.await() } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(viaPath, viaDescriptor) + assertSame(viaDescriptor, viaIdentifier) + assertEquals(3, storage.getReferenceCount(path)) + checkProgress(storage, loaded = 1, warn = true) + } + + @Test + fun `should point to the same asset when loading with path, descriptor and identifier synchronously`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val descriptor = storage.getAssetDescriptor(path) + val identifier = storage.getIdentifier(path) + + // When: + val viaPath = storage.loadSync(path) + val viaDescriptor = storage.loadSync(descriptor) + val viaIdentifier = storage.loadSync(identifier) + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(viaPath, viaDescriptor) + assertSame(viaDescriptor, viaIdentifier) + assertEquals(3, storage.getReferenceCount(path)) + checkProgress(storage, loaded = 1, warn = true) + } + + @Test + fun `should manually add asset to storage`() { + // Given: + val storage = AssetStorage() + val asset = Vector2(20f, 10f) + val fakePath = "myVector" + + // When: + runBlocking { storage.add(fakePath, asset) } + + // Then: + assertTrue(storage.isLoaded(fakePath)) + assertSame(asset, storage.get(fakePath)) + assertEquals(1, storage.getReferenceCount(fakePath)) + checkProgress(storage, loaded = 1) + } + + @Test + fun `should manually add asset to storage with descriptor`() { + // Given: + val storage = AssetStorage() + val asset = Vector2(20f, 10f) + val fakePath = "myVector" + val descriptor = storage.getAssetDescriptor(fakePath) + + // When: + runBlocking { storage.add(descriptor, asset) } + + // Then: + assertTrue(storage.isLoaded(descriptor)) + assertSame(asset, storage[descriptor]) + assertEquals(1, storage.getReferenceCount(descriptor)) + checkProgress(storage, loaded = 1) + } + + @Test + fun `should manually add asset to storage with identifier`() { + // Given: + val storage = AssetStorage() + val asset = Vector2(20f, 10f) + val fakePath = "myVector" + val identifier = storage.getIdentifier(fakePath) + + // When: + runBlocking { storage.add(identifier, asset) } + + // Then: + assertTrue(storage.isLoaded(identifier)) + assertSame(asset, storage[identifier]) + assertEquals(1, storage.getReferenceCount(identifier)) + checkProgress(storage, loaded = 1) + } + + @Test + fun `should unload and dispose assets manually added to storage`() { + // Given: + val storage = AssetStorage() + val asset = FakeAsset() + val fakePath = "disposable" + runBlocking { storage.add(fakePath, asset) } + + // When: + val unloaded = runBlocking { storage.unload(fakePath) } + + // Then: + assertTrue(unloaded) + assertFalse(storage.isLoaded(fakePath)) + assertEquals(0, storage.getReferenceCount(fakePath)) + assertTrue(asset.isDisposed) + checkProgress(storage, total = 0) + } + + @Test + fun `should add asset after it was unloaded`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + val asset = mock() + runBlocking { storage.load(path) } + runBlocking { storage.unload(path) } + + // When: + runBlocking { storage.add(path, asset) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + } + + @Test + fun `should allow to add asset with different type than loaded`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + val asset = mock() + runBlocking { storage.load(path) } + + // When: + runBlocking { storage.add(path, asset) } + + // Then: + assertTrue(storage.isLoaded(path)) + assertTrue(storage.isLoaded(path)) + assertSame(asset, storage.get(path)) + + storage.dispose() + } + + @Test + fun `should allow to load multiple assets with different type and same path`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/texture.png" + + // When: + runBlocking { + storage.load(path) + storage.load(path) + } + + // Then: + assertTrue(storage.isLoaded(path)) + assertTrue(storage.isLoaded(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertNotSame(storage.get(path), storage.get(path)) + checkProgress(storage, loaded = 2, warn = true) + + storage.dispose() + } + + @Test + fun `should support loading assets in parallel`() { + // Given: + val storage = AssetStorage( + fileResolver = ClasspathFileHandleResolver(), + asyncContext = newAsyncContext(2) + ) + val firstPath = "ktx/assets/async/texture.png" + val secondPath = "ktx/assets/async/model.obj" + val scheduler = newAsyncContext(2) + val tasksReference = CompletableFuture>>() + + // When: + KtxAsync.launch { + val first = async(scheduler) { storage.load(firstPath) } + val second = async(scheduler) { storage.load(secondPath) } + tasksReference.complete(listOf(first, second)) + } + + // Then: + val tasks = tasksReference.join() + runBlocking { tasks.joinAll() } + assertTrue(storage.isLoaded(firstPath)) + assertTrue(storage.isLoaded(secondPath)) + assertSame(tasks[0].asCompletableFuture().join(), storage.get(firstPath)) + assertSame(tasks[1].asCompletableFuture().join(), storage.get(secondPath)) + checkProgress(storage, loaded = 2, warn = true) + + storage.dispose() + } + + @Test + fun `should fail to add asset that was already loaded`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + runBlocking { storage.load(path) } + + // When: + shouldThrow { + runBlocking { + storage.add(path, "ASSET") + } + } + + // Then: + assertEquals(1, storage.getReferenceCount(path)) + } + + @Test + fun `should fail to add asset that was already added`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + runBlocking { storage.add(path, "ASSET") } + + // When: + shouldThrow { + runBlocking { + // Even though the asset is the same, `add` should work only once. + // This is because finding all dependencies of an asset is tricky + // if it is both loaded and added. To keep the dependencies reference + // counts in check, we're only allowing to add an asset once. + storage.add(path, "ASSET") + } + } + + // Then: + assertEquals(1, storage.getReferenceCount(path)) + } + + @Test + fun `should increase references count and return the same asset when trying to load asset with same path`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val elements = IdentityHashMap() + + // When: + runBlocking { + repeat(3) { + val asset = storage.load(path) + elements[asset] = true + } + } + + // Then: + assertEquals(3, storage.getReferenceCount(path)) + assertEquals(1, elements.size) + } + + @Test + fun `should fail to load asset with missing loader`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + + // When: + shouldThrow { + runBlocking { storage.load(path) } + } + + // Then: + assertFalse(storage.contains(path)) + checkProgress(storage, total = 0) + } + + @Test + fun `should register default loaders`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = true) + + // Expect: + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader(path = ".obj")) + assertNotNull(storage.getLoader(path = ".g3dj")) + assertNotNull(storage.getLoader(path = ".g3db")) + assertNotNull(storage.getLoader()) + assertNotNull(storage.getLoader()) + } + + @Test + fun `should not register default loaders`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + + // Expect: + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + assertNull(storage.getLoader(path = ".obj")) + assertNull(storage.getLoader(path = ".g3dj")) + assertNull(storage.getLoader(path = ".g3db")) + assertNull(storage.getLoader()) + assertNull(storage.getLoader()) + } + + @Test + fun `should increase references counts of dependencies when loading asset with same path`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.json" + val dependencies = arrayOf( + storage.getIdentifier("ktx/assets/async/skin.atlas"), + storage.getIdentifier("ktx/assets/async/texture.png") + ) + val loadedAssets = IdentityHashMap() + + // When: + runBlocking { + repeat(3) { + val asset = storage.load(path) + loadedAssets[asset] = true + } + } + + // Then: + assertEquals(3, storage.getReferenceCount(path)) + dependencies.forEach { + assertEquals(3, storage.getReferenceCount(it)) + } + assertEquals(1, loadedAssets.size) + checkProgress(storage, loaded = 3, warn = true) + } + + @Test + fun `should increase references counts of dependencies when loading asset with same descriptor`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val descriptor = storage.getAssetDescriptor("ktx/assets/async/skin.json") + val dependencies = arrayOf( + storage.getIdentifier("ktx/assets/async/skin.atlas"), + storage.getIdentifier("ktx/assets/async/texture.png") + ) + val loadedAssets = IdentityHashMap() + + // When: + runBlocking { + repeat(3) { + val asset = storage.loadAsync(descriptor).await() + loadedAssets[asset] = true + } + } + + // Then: + assertEquals(3, storage.getReferenceCount(descriptor)) + dependencies.forEach { + assertEquals(3, storage.getReferenceCount(it)) + } + assertEquals(1, loadedAssets.size) + checkProgress(storage, loaded = 3, warn = true) + + storage.dispose() + } + + @Test + fun `should increase references counts of dependencies when loading asset with same identifier`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val identifier = storage.getIdentifier("ktx/assets/async/skin.json") + val dependencies = arrayOf( + storage.getIdentifier("ktx/assets/async/skin.atlas"), + storage.getIdentifier("ktx/assets/async/texture.png") + ) + val loadedAssets = IdentityHashMap() + + // When: + repeat(3) { + val asset = storage.loadSync(identifier) + loadedAssets[asset] = true + } + + // Then: + assertEquals(3, storage.getReferenceCount(identifier)) + dependencies.forEach { + assertEquals(3, storage.getReferenceCount(it)) + } + assertEquals(1, loadedAssets.size) + checkProgress(storage, loaded = 3, warn = true) + + storage.dispose() + } + + @Test + fun `should not unload dependencies that still referenced by other assets`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.json" + val dependency = "ktx/assets/async/skin.atlas" + val nestedDependency = "ktx/assets/async/texture.png" + runBlocking { + storage.load(path) + storage.load(dependency) + } + + // When: + runBlocking { storage.unload(path) } + + // Then: + assertEquals(0, storage.getReferenceCount(path)) + assertFalse(storage.isLoaded(path)) + assertEquals(1, storage.getReferenceCount(dependency)) + assertTrue(storage.isLoaded(dependency)) + assertEquals(1, storage.getReferenceCount(nestedDependency)) + assertTrue(storage.isLoaded(nestedDependency)) + checkProgress(storage, loaded = 2, warn = true) + + storage.dispose() + } + + @Test + fun `should support concurrent loading`() { + // Given: + val schedulers = newAsyncContext(threads = 16) + val loaders = newAsyncContext(threads = 4) + val storage = AssetStorage( + fileResolver = ClasspathFileHandleResolver(), + asyncContext = loaders + ) + val path = "com/badlogic/gdx/utils/arial-15.fnt" + val dependency = "com/badlogic/gdx/utils/arial-15.png" + + // When: + val assets = (1..100).map { + val result = CompletableDeferred() + KtxAsync.launch(schedulers) { + val asset = storage.load(path) + result.complete(asset) + } + result + } + + // Then: + runBlocking { assets.joinAll() } + assertTrue(storage.isLoaded(path)) + assertEquals(100, storage.getReferenceCount(path)) + assertTrue(storage.isLoaded(dependency)) + assertEquals(100, storage.getReferenceCount(dependency)) + assertEquals(1, assets.map { it.asCompletableFuture().join() }.toSet().size) + checkProgress(storage, loaded = 2) + + storage.dispose() + } + + /** + * Allows to test concurrent loading and unloading. + * + * During concurrent unloading, some assets are expected to be requested for unloading + * before they are fully loaded, resulting in [UnloadedAssetException] caught by the + * coroutines waiting for or loading the asset. This method allows to ignore these exceptions. + */ + private suspend inline fun loadIgnoringUnloadException(storage: AssetStorage, path: String) { + try { + storage.load(path) + } catch (exception: UnloadedAssetException) { + // Expected. + } + } + + @Test + fun `should support concurrent loading and unloading`() { + // Given: + val schedulers = newAsyncContext(threads = 16) + val loaders = newAsyncContext(threads = 4) + val storage = AssetStorage( + fileResolver = ClasspathFileHandleResolver(), + asyncContext = loaders + ) + val path = "ktx/assets/async/string.txt" + + // When: spawning 100 coroutines that load and unload the asset, 1 of which loads it 2 times: + val assets = (1..100).map { id -> + val result = CompletableDeferred() + KtxAsync.launch(schedulers) { + loadIgnoringUnloadException(storage, path) + storage.unload(path) + if (id == 99) { + // Loading 1 additional asset: + loadIgnoringUnloadException(storage, path) + } + result.complete(true) + } + result + } + + // Then: + runBlocking { assets.joinAll() } + assertTrue(storage.isLoaded(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertEquals("Content.", storage.get(path)) + checkProgress(storage, loaded = 1, warn = true) + + storage.dispose() + } + + @Test + fun `should support concurrent loading and unloading with dependencies`() { + // Given: + val schedulers = newAsyncContext(threads = 16) + val loaders = newAsyncContext(threads = 4) + val storage = AssetStorage( + fileResolver = ClasspathFileHandleResolver(), + asyncContext = loaders + ) + val path = "com/badlogic/gdx/utils/arial-15.fnt" + val dependency = "com/badlogic/gdx/utils/arial-15.png" + + // When: spawning 100 coroutines that load and unload the asset, 1 of which loads it 2 times: + val assets = (1..100).map { id -> + val result = CompletableDeferred() + KtxAsync.launch(schedulers) { + loadIgnoringUnloadException(storage, path) + storage.unload(path) + if (id == 1) { + // Loading 1 additional asset: + loadIgnoringUnloadException(storage, path) + } + result.complete(true) + } + result + } + + // Then: + runBlocking { assets.joinAll() } + assertTrue(storage.isLoaded(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertTrue(storage.isLoaded(dependency)) + assertEquals(1, storage.getReferenceCount(dependency)) + assertSame( + storage.get(path).region.texture, + storage.get(dependency) + ) + checkProgress(storage, loaded = 2, warn = true) + + storage.dispose() + } + + @Test + fun `should support concurrent loading and unloading with deeply nested dependencies`() { + // Given: + val schedulers = newAsyncContext(threads = 16) + val loaders = newAsyncContext(threads = 4) + val storage = AssetStorage( + fileResolver = ClasspathFileHandleResolver(), + asyncContext = loaders + ) + val path = "ktx/assets/async/skin.json" + val dependency = "ktx/assets/async/skin.atlas" + val nestedDependency = "ktx/assets/async/texture.png" + + // When: spawning 100 coroutines that load and unload the asset, 1 of which loads it 2 times: + val assets = (1..100).map { id -> + val result = CompletableDeferred() + KtxAsync.launch(schedulers) { + loadIgnoringUnloadException(storage, path) + storage.unload(path) + if (id == 1) { + loadIgnoringUnloadException(storage, path) + } + result.complete(true) + } + result + } + + // Then: + runBlocking { assets.joinAll() } + assertTrue(storage.isLoaded(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertTrue(storage.isLoaded(dependency)) + assertEquals(1, storage.getReferenceCount(dependency)) + assertTrue(storage.isLoaded(nestedDependency)) + assertEquals(1, storage.getReferenceCount(nestedDependency)) + val skin = storage.get(path) + val atlas = storage.get(dependency) + val texture = storage.get(nestedDependency) + assertSame(skin.atlas, atlas) + assertSame(atlas.textures.first(), texture) + checkProgress(storage, loaded = 3, warn = true) + + storage.dispose() + } + + @Test + fun `should handle stress test`() { + // Given: + val schedulers = newAsyncContext(threads = 16) + val loaders = newAsyncContext(threads = 4) + val storage = AssetStorage( + fileResolver = ClasspathFileHandleResolver(), + asyncContext = loaders + ) + val path = "ktx/assets/async/skin.json" + val dependency = "ktx/assets/async/skin.atlas" + val nestedDependency = "ktx/assets/async/texture.png" + val loads = AtomicInteger() + val unloads = AtomicInteger() + + // When: spawning 1000 coroutines that randomly load or unload the asset and try to access it: + val assets = (1..1000).map { + val result = CompletableDeferred() + KtxAsync.launch(schedulers) { + repeat(ThreadLocalRandom.current().nextInt(50)) { skipFrame() } + if (ThreadLocalRandom.current().nextFloat() < 0.4f) { + // Some unloads are expected to miss and loads are longer, + // so there's a lower probability for load. + loads.incrementAndGet() + loadIgnoringUnloadException(storage, path) + } else { + val unloaded = storage.unload(path) + if (unloaded) unloads.incrementAndGet() + } + try { + // Concurrent access: + storage.getOrNull(path) + } catch (expected: UnloadedAssetException) { + // Assets can be unloaded asynchronously. This is OK. + } + result.complete(true) + } + result + } + + // Then: + runBlocking { assets.joinAll() } + val expectedReferences = max(0, loads.get() - unloads.get()) + assertEquals(expectedReferences, storage.getReferenceCount(path)) + assertEquals(expectedReferences, storage.getReferenceCount(dependency)) + assertEquals(expectedReferences, storage.getReferenceCount(nestedDependency)) + // Either the skin is unloaded or there are 3 assets - skin, atlas, texture: + val assetsCount = min(1, expectedReferences) * 3 + checkProgress(storage, loaded = assetsCount, warn = true) + + storage.dispose() + } + + @Test + fun `should register asset loader`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val loader = mock>() + + // When: + storage.setLoader { loader } + + // Then: + assertSame(loader, storage.getLoader()) + + // Implementation note: normally you create a new instance in the setLoader lambda, + // this is just for verification. + } + + @Test + fun `should register asset loader with suffix`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val loader = mock>() + + // When: + storage.setLoader(suffix = ".txt") { loader } + + // Then: + assertSame(loader, storage.getLoader("file.txt")) + assertNull(storage.getLoader("file.md")) + } + + @Test + fun `should return null if asset loader is not available`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + + // Expect: + assertNull(storage.getLoader()) + } + + @Test + fun `should reject invalid asset loader implementations`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + + // When: loader does not extend Synchronous/AsynchronousAssetLoader: + val invalidLoader = mock>() + + // Then: + shouldThrow { + storage.setLoader { invalidLoader } + } + } + + @Test + fun `should dispose of all assets`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val paths = (1..5).map { storage.getIdentifier(it.toString()) } + val assets = paths.map { it to FakeAsset() } + .onEach { (identifier, asset) -> runBlocking { storage.add(identifier, asset) } } + .map { (_, asset) -> asset } + + // When: + storage.dispose() + + // Then: + assertTrue(assets.all { it.isDisposed }) + assertTrue(paths.all { it !in storage }) + checkProgress(storage, total = 0) + } + + @Test + fun `should dispose of all assets with optional error handling`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val paths = (1..5).map { storage.getIdentifier(it.toString()) } + val assets = paths.map { it to FakeAsset() } + .onEach { (identifier, asset) -> runBlocking { storage.add(identifier, asset) } } + .map { (_, asset) -> asset } + + // When: + runBlocking { + storage.dispose { identifier, cause -> + fail("Unexpected exception when unloading $identifier: $cause") + throw cause + } + } + + // Then: + assertTrue(assets.all { it.isDisposed }) + assertTrue(paths.all { it !in storage }) + } + + @Test + fun `should catch errors during disposing with handler`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val validAsset = mock() + val brokenAsset = mock { + on(it.dispose()) doThrow GdxRuntimeException("Expected.") + } + runBlocking { + storage.add("broken", brokenAsset) + storage.add("valid", validAsset) + } + + // When: + runBlocking { + storage.dispose { identifier, error -> + assertEquals(storage.getIdentifier("broken"), identifier) + assertEquals("Expected.", error.message) + assertTrue(error is GdxRuntimeException) + } + } + + // Then: + verify(brokenAsset).dispose() + verify(validAsset).dispose() + } + + @Test + fun `should log errors during disposing`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val logger = mock() + storage.logger = logger + val validAsset = mock() + val exception = GdxRuntimeException("Expected.") + val brokenAsset = mock { + on(it.dispose()) doThrow exception + } + runBlocking { + storage.add("broken", brokenAsset) + storage.add("valid", validAsset) + } + + // When: + storage.dispose() + + // Then: + verify(brokenAsset).dispose() + verify(validAsset).dispose() + verify(logger).error(any(), eq(exception)) + } + + @Test + fun `should create AssetDescriptor`() { + // When: + val storage = AssetStorage(useDefaultLoaders = false) + val descriptor = storage.getAssetDescriptor("ktx/assets/async/string.txt") + + // Then: + assertEquals("ktx/assets/async/string.txt", descriptor.fileName) + assertEquals("ktx/assets/async/string.txt", descriptor.file.path()) + assertEquals(String::class.java, descriptor.type) + assertNull(descriptor.params) + } + + @Test + fun `should create AssetDescriptor with loading parameters`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val parameters = mock>() + + // When: + val descriptor = storage.getAssetDescriptor("ktx/assets/async/string.txt", parameters) + + // Then: + assertEquals("ktx/assets/async/string.txt", descriptor.fileName) + assertEquals("ktx/assets/async/string.txt", descriptor.file.path()) + assertEquals(String::class.java, descriptor.type) + assertSame(parameters, descriptor.params) + } + + @Test + fun `should create AssetDescriptor with a custom file`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val file = mock() + + // When: + val descriptor = storage.getAssetDescriptor("ktx/assets/async/string.txt", fileHandle = file) + + // Then: + assertEquals("ktx/assets/async/string.txt", descriptor.fileName) + assertEquals(String::class.java, descriptor.type) + assertSame(file, descriptor.file) + } + + @Test + fun `should create Identifier`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val path = "my\\file.png" + + // When: + val identifier = storage.getIdentifier(path) + + // Then: should normalize path and extract reified type: + assertEquals("my/file.png", identifier.path) + assertSame(Vector2::class.java, identifier.type) + } + + @Test + fun `should normalize file paths`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val paths = mapOf( + "path.txt" to "path.txt", + "dir/path.txt" to "dir/path.txt", + "\\path.txt" to "/path.txt", + "dir\\path.txt" to "dir/path.txt", + "home\\dir\\path.txt" to "home/dir/path.txt", + "\\home\\dir\\dir\\" to "/home/dir/dir/" + ) + + paths.forEach { (original, expected) -> + // When: + val normalized = with(storage) { original.normalizePath() } + + // Then: Should match AssetDescriptor logic. + assertEquals(expected, normalized) + assertEquals(AssetDescriptor(expected, String::class.java).fileName, normalized) + } + } + + @Test + fun `should convert AssetDescriptor to Identifier`() { + // Given: + val assetDescriptor = AssetDescriptor("file.path", String::class.java, TextAssetLoaderParameters("UTF-8")) + + // When: + val identifier = assetDescriptor.toIdentifier() + + // Then: should copy path and class without loading parameters: + assertEquals(assetDescriptor.fileName, identifier.path) + assertEquals(assetDescriptor.type, identifier.type) + } + + @Test + fun `should convert Identifier to AssetDescriptor`() { + // Given: + val identifier = Identifier("file.path", String::class.java) + + // When: + val assetDescriptor = identifier.toAssetDescriptor() + + // Then: + assertEquals("file.path", assetDescriptor.fileName) + assertSame(String::class.java, assetDescriptor.type) + } + + @Test + fun `should convert Identifier to AssetDescriptor with loading parameters`() { + // Given: + val identifier = Identifier("file.path", String::class.java) + val parameters = mock>() + + // When: + val assetDescriptor = identifier.toAssetDescriptor(parameters) + + // Then: + assertEquals("file.path", assetDescriptor.fileName) + assertSame(String::class.java, assetDescriptor.type) + assertSame(parameters, assetDescriptor.params) + } + + @Test + fun `should convert Identifier to AssetDescriptor with a custom file`() { + // Given: + val identifier = Identifier("file.path", String::class.java) + val file = mock() + + // When: + val assetDescriptor = identifier.toAssetDescriptor(fileHandle = file) + + // Then: + assertEquals("file.path", assetDescriptor.fileName) + assertSame(String::class.java, assetDescriptor.type) + assertSame(file, assetDescriptor.file) + } + + @Test + fun `should invoke loaded callback on rendering thread to match AssetManager behavior`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val callbackFinished = CompletableFuture() + val callbackExecutions = AtomicInteger() + var callbackExecutedOnRenderingThread = false + lateinit var callbackManager: AssetManager + var callbackPath = "" + var callbackType = Any::class.java + val parameters = TextAssetLoaderParameters().apply { + loadedCallback = AssetLoaderParameters.LoadedCallback { assetManager, fileName, type -> + callbackExecutions.incrementAndGet() + callbackExecutedOnRenderingThread = KtxAsync.isOnRenderingThread() + callbackManager = assetManager + callbackPath = fileName + callbackType = type + callbackFinished.complete(true) + } + } + + // When: + runBlocking { storage.load(path, parameters) } + + // Then: + callbackFinished.join() + assertEquals(1, callbackExecutions.get()) + assertTrue(callbackExecutedOnRenderingThread) + assertTrue(callbackManager is AssetManagerWrapper) + assertSame(storage, (callbackManager as AssetManagerWrapper).assetStorage) + assertEquals(path, callbackPath) + assertSame(String::class.java, callbackType) + } + + @Test + fun `should log exceptions thrown by loading callbacks`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + val loggingFinished = CompletableFuture() + val exception = IllegalStateException("Expected.") + val logger = mock { + on(it.error(any(), any())) doAnswer { loggingFinished.complete(true); Unit } + } + storage.logger = logger + val parameters = TextAssetLoaderParameters().apply { + loadedCallback = AssetLoaderParameters.LoadedCallback { _, _, _ -> + throw exception + } + } + + // When: + runBlocking { storage.load(path, parameters) } + + // Then: asset should still be loaded, but the callback exception must be logged: + loggingFinished.join() + assertTrue(storage.isLoaded(path)) + assertEquals("Content.", storage.get(path)) + verify(logger).error(any(), eq(exception)) + } + + @Test + fun `should not block the main rendering thread when loading assets synchronously`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/string.txt" + + // When: + val asset = runBlocking(KtxAsync.coroutineContext) { + storage.loadSync(path) + } + + // Then: + assertEquals("Content.", asset) + assertTrue(storage.isLoaded(path)) + } + + @Test + fun `should not block the main rendering thread when loading assets with dependencies synchronously`() { + // Given: + val storage = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + val path = "ktx/assets/async/skin.json" + val dependencies = arrayOf( + storage.getIdentifier("ktx/assets/async/skin.atlas"), + storage.getIdentifier("ktx/assets/async/texture.png") + ) + + // When: + val asset = runBlocking(KtxAsync.coroutineContext) { + storage.loadSync(path) + } + + // Then: + assertTrue(storage.isLoaded(path)) + dependencies.forEach { + assertTrue(storage.isLoaded(it)) + } + assertSame(asset.atlas, storage[dependencies[0]]) + } + + /** For [Disposable.dispose] interface testing and loaders testing. */ + class FakeAsset : Disposable { + val disposingFinished = CompletableFuture() + var isDisposed: Boolean = false + override fun dispose() { + isDisposed = true + disposingFinished.complete(true) + } + } + + /** For loaders testing. */ + class FakeAsyncLoader( + private val onAsync: (assetManager: AssetManager) -> Unit, + private val onSync: (asset: FakeAsset) -> Unit, + private val dependencies: GdxArray> = GdxArray.with() + ) : AsynchronousAssetLoader(ClasspathFileHandleResolver()) { + override fun loadAsync( + manager: AssetManager, fileName: String?, file: FileHandle?, parameter: FakeParameters? + ) { + onAsync(manager) + } + + override fun loadSync( + manager: AssetManager, fileName: String?, file: FileHandle?, parameter: FakeParameters? + ): FakeAsset = FakeAsset().also(onSync) + + @Suppress("UNCHECKED_CAST") + override fun getDependencies( + fileName: String?, file: FileHandle?, parameter: FakeParameters? + ): GdxArray> = dependencies as GdxArray> + } + + /** For loaders testing. */ + open class FakeSyncLoader( + private val onLoad: (asset: FakeAsset) -> Unit, + private val dependencies: GdxArray> = GdxArray.with() + ) : SynchronousAssetLoader(ClasspathFileHandleResolver()) { + @Suppress("UNCHECKED_CAST") + override fun getDependencies( + fileName: String?, file: FileHandle?, parameter: FakeParameters? + ): GdxArray> = dependencies as GdxArray> + + override fun load( + assetManager: AssetManager, fileName: String?, file: FileHandle?, parameter: FakeParameters? + ): FakeAsset = FakeAsset().also(onLoad) + } + + /** For loaders testing. */ + class FakeParameters : AssetLoaderParameters() + + @Test + fun `should execute asynchronous loading on specified context and synchronous loading on rendering thread`() { + // Given: + val asyncContext = newSingleThreadAsyncContext(threadName = "CustomThreadName") + val asyncThread = getExecutionThread(asyncContext.executor) + val isAsyncThread = CompletableFuture() + val isRenderingThread = CompletableFuture() + val isRenderingThreadDuringAsync = CompletableFuture() + val loader = FakeAsyncLoader( + onSync = { isRenderingThread.complete(KtxAsync.isOnRenderingThread()) }, + onAsync = { + isAsyncThread.complete(asyncThread === Thread.currentThread()) + isRenderingThreadDuringAsync.complete(KtxAsync.isOnRenderingThread()) + } + ) + val storage = AssetStorage(asyncContext = asyncContext, useDefaultLoaders = false) + storage.setLoader { loader } + + // When: + runBlocking { storage.load("fake.path") } + + // Then: + assertTrue(isRenderingThread.getNow(false)) + assertTrue(isAsyncThread.getNow(false)) + assertFalse(isRenderingThreadDuringAsync.getNow(true)) + } + + @Test + fun `should load assets on rendering thread with synchronous loading `() { + // Given: + val isRenderingThread = CompletableFuture() + val loader = FakeSyncLoader( + onLoad = { isRenderingThread.complete(KtxAsync.isOnRenderingThread()) } + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + + // When: + storage.loadSync("fake.path") + + // Then: + assertTrue(isRenderingThread.getNow(false)) + } + + @Test + fun `should load assets synchronously on rendering thread with synchronous loader`() { + // Given: + val isRenderingThread = CompletableFuture() + val loader = FakeSyncLoader( + onLoad = { isRenderingThread.complete(KtxAsync.isOnRenderingThread()) } + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + + // When: + storage.loadSync("fake.path") + + // Then: + assertTrue(isRenderingThread.getNow(false)) + } + + @Test + fun `should load assets synchronously on rendering thread with asynchronous loader`() { + // Given: + val isRenderingThreadDuringAsync = CompletableFuture() + val isRenderingThreadDuringSync = CompletableFuture() + val loader = FakeAsyncLoader( + onAsync = { isRenderingThreadDuringAsync.complete(KtxAsync.isOnRenderingThread()) }, + onSync = { isRenderingThreadDuringSync.complete(KtxAsync.isOnRenderingThread()) } + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + + // When: + storage.loadSync("fake.path") + + // Then: entire synchronous loading should be executed on the rendering thread: + assertTrue(isRenderingThreadDuringAsync.getNow(false)) + assertTrue(isRenderingThreadDuringSync.getNow(false)) + } + + @Test + fun `should handle loading exceptions`() { + // Given: + val loader = FakeSyncLoader( + onLoad = { throw IllegalStateException("Expected.") } + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + val path = "fake path" + + // When: + shouldThrow { + runBlocking { storage.load(path) } + } + + // Then: asset should still be in storage, but rethrowing original exception: + assertTrue(storage.contains(path)) + assertEquals(1, storage.getReferenceCount(path)) + shouldThrow { + storage.get(path) + } + shouldThrow { + storage.getOrNull(path) + } + val reference = storage.getAsync(path) + shouldThrow { + runBlocking { reference.await() } + } + checkProgress(storage, failed = 1, warn = true) + } + + @Test + fun `should handle asynchronous loading exceptions`() { + // Given: + val loader = FakeAsyncLoader( + onAsync = { throw IllegalStateException("Expected.") }, + onSync = {} + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + val path = "fake path" + + // When: + shouldThrow { + runBlocking { storage.load(path) } + } + + // Then: asset should still be in storage, but rethrowing original exception: + assertTrue(storage.contains(path)) + assertEquals(1, storage.getReferenceCount(path)) + shouldThrow { + storage.get(path) + } + shouldThrow { + storage.getOrNull(path) + } + val reference = storage.getAsync(path) + shouldThrow { + runBlocking { reference.await() } + } + checkProgress(storage, failed = 1, warn = true) + } + + @Test + fun `should handle synchronous loading exceptions`() { + // Given: + val loader = FakeAsyncLoader( + onAsync = { }, + onSync = { throw IllegalStateException("Expected.") } + ) + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { loader } + val path = "fake path" + + // When: + shouldThrow { + runBlocking { storage.load(path) } + } + + // Then: asset should still be in storage, but rethrowing original exception: + assertTrue(storage.contains(path)) + assertEquals(1, storage.getReferenceCount(path)) + shouldThrow { + storage.get(path) + } + shouldThrow { + storage.getOrNull(path) + } + val reference = storage.getAsync(path) + shouldThrow { + runBlocking { reference.await() } + } + checkProgress(storage, failed = 1, warn = true) + } + + @Test + fun `should throw exception when asset is unloaded asynchronously`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val loadingStarted = CompletableFuture() + val unloadingFinished = CompletableFuture() + val exceptionCaught = CompletableFuture() + val path = "fake path" + val unloader = newSingleThreadAsyncContext() + lateinit var exception: Throwable + storage.setLoader { + FakeSyncLoader( + onLoad = { + loadingStarted.complete(true) + unloadingFinished.join() + } + ) + } + KtxAsync.launch { + try { + storage.load(path) + } catch (expected: Throwable) { + exception = expected + } + exceptionCaught.complete(true) + } + + // When: + KtxAsync.launch(unloader) { + loadingStarted.join() + storage.unload(path) + unloadingFinished.complete(true) + } + + // Then: + exceptionCaught.join() + assertTrue(exception is UnloadedAssetException) + assertFalse(storage.contains(path)) + shouldThrow { + storage.get(path) + } + checkProgress(storage, total = 0, warn = true) + } + + @Test + fun `should fail to load assets with loaders that use unsupported AssetManagerWrapper methods`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val path = "fake path" + storage.setLoader { + FakeAsyncLoader( + onAsync = { assetManager -> assetManager.get("Trying to access asset without its type.") }, + onSync = {} + ) + } + + // When: + shouldThrow { + runBlocking { + storage.load(path) + } + } + + // Then: + assertTrue(storage.contains(path)) + assertEquals(1, storage.getReferenceCount(path)) + shouldThrow { + storage.get(path) + } + } + + @Test + fun `should fail to load assets when loaders try to access unregistered unloaded dependency`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val path = "fake path" + storage.setLoader { + FakeAsyncLoader( + onAsync = { assetManager -> assetManager.get("Missing", FakeAsset::class.java) }, + onSync = {} + ) + } + + // When: + shouldThrow { + runBlocking { + storage.load(path) + } + } + + // Then: + assertTrue(storage.contains(path)) + assertEquals(1, storage.getReferenceCount(path)) + shouldThrow { + storage.get(path) + } + } + + @Test + fun `should not fail to unload asset that was loaded exceptionally`() { + // Given: + val loader = FakeSyncLoader( + onLoad = { throw IllegalStateException("Expected.") } + ) + val storage = AssetStorage(useDefaultLoaders = false) + val path = "fake path" + storage.setLoader { loader } + storage.logger.level = Logger.NONE // Disposing exception will be logged. + runBlocking { + try { + storage.load(path) + } catch (exception: AssetLoadingException) { + // Expected. + } + } + + // When: + val unloaded = runBlocking { + storage.unload(path) + } + + // Then: + assertTrue(unloaded) + assertFalse(storage.contains(path)) + assertEquals(0, storage.getReferenceCount(path)) + } + + @Test + fun `should fail to load an asset or any of its dependencies with a dependency loader is missing`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val path = "fake path" + val dependency = "dependency with missing loader" + storage.setLoader { + FakeSyncLoader( + onLoad = {}, + dependencies = GdxArray.with(storage.getAssetDescriptor(dependency)) + ) + } + + // When: + shouldThrow { + runBlocking { storage.load("path") } + } + + // Then: + assertFalse(storage.contains(path)) + assertFalse(storage.contains(dependency)) + checkProgress(storage, total = 0) + } + + @Test + fun `should not fail to unload asset that was loaded exceptionally with dependencies`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val path = "path.sync" + val dependency = "path.async" + val loader = FakeSyncLoader( + onLoad = {}, + dependencies = GdxArray.with(storage.getAssetDescriptor(dependency)) + ) + val dependencyLoader = FakeAsyncLoader( + onAsync = {}, + onSync = { throw IllegalStateException("Expected.") } + ) + storage.setLoader(suffix = ".sync") { loader } + storage.setLoader(suffix = ".async") { dependencyLoader } + storage.logger.level = Logger.NONE // Disposing exception will be logged. + runBlocking { + try { + storage.load(path) + } catch (exception: AssetLoadingException) { + // Expected. Asset fails to load due to dependency. + } + } + assertTrue(storage.contains(path)) + assertTrue(storage.contains(dependency)) + + // When: + val unloaded = runBlocking { + storage.unload(path) + } + + // Then: + assertTrue(unloaded) + assertFalse(storage.contains(path)) + assertEquals(0, storage.getReferenceCount(path)) + assertFalse(storage.contains(dependency)) + assertEquals(0, storage.getReferenceCount(dependency)) + } + + @Test + fun `should report as unloaded until the asset has finished loading`() { + // Given: + val loadingStarted = CompletableFuture() + val loading = CompletableFuture() + val loadingFinished = CompletableFuture() + val loader = FakeSyncLoader( + onLoad = { + loadingStarted.join() + loading.complete(true) + loadingFinished.join() + } + ) + val storage = AssetStorage(useDefaultLoaders = false) + val path = "fake.path" + val identifier = storage.getIdentifier(path) + storage.setLoader { loader } + + // When: + KtxAsync.launch { storage.load(identifier) } + + // Then: + assertFalse(storage.isLoaded(identifier)) + + loadingStarted.complete(true) + loading.join() + assertTrue(identifier in storage) + assertFalse(storage.isLoaded(identifier)) + checkProgress(storage, loaded = 0, total = 1) + + loadingFinished.complete(true) + runBlocking { storage.getAsync(path).await() } + assertTrue(identifier in storage) + assertTrue(storage.isLoaded(identifier)) + checkProgress(storage, loaded = 1, total = 1, warn = true) + } + + @Test + fun `should utilize asynchronous threads for asset loading`() { + // Given: + val asyncThreadNameSuffix = "AssetStorageAsyncThread" + val renderingThread = getMainRenderingThread() + val loaders = newAsyncContext(4, threadName = asyncThreadNameSuffix) + val schedulers = newAsyncContext(4) + val asyncLoadingThreads: MutableSet = Sets.newConcurrentHashSet() + val syncLoadingThreads: MutableSet = Sets.newConcurrentHashSet() + val storage = AssetStorage(asyncContext = loaders, useDefaultLoaders = false) + storage.setLoader { + FakeAsyncLoader( + onAsync = { asyncLoadingThreads.add(Thread.currentThread()) }, + onSync = { syncLoadingThreads.add(Thread.currentThread()) } + ) + } + + // When: asynchronously loading multiple assets: + val tasks = (1..100).map { index -> + val finished = CompletableDeferred() + KtxAsync.launch(schedulers) { + storage.load(path = index.toString()) + finished.complete(true) + } + finished + } + + // Then: + runBlocking { tasks.joinAll() } + assertEquals(1, syncLoadingThreads.size) + assertTrue(renderingThread in syncLoadingThreads) + assertEquals(4, asyncLoadingThreads.size) + assertTrue(asyncLoadingThreads.all { asyncThreadNameSuffix in it.name }) + } + + @Test + fun `should dispose of assets that were unloaded before assignment`() { + // Given: + val path = "fake path" + val loadingStarted = CompletableFuture() + val unloadingFinished = CompletableFuture() + val loadingFinished = CompletableFuture() + lateinit var asset: FakeAsset + lateinit var exception: Throwable + val storage = AssetStorage(useDefaultLoaders = false) + storage.setLoader { + FakeSyncLoader( + onLoad = { + asset = it + loadingStarted.complete(true) + unloadingFinished.join() + } + ) + } + + // When: + KtxAsync.launch { + try { + // Scheduling asset for loading - will be unloaded before it is finished: + storage.load(path) + } catch (expected: AssetStorageException) { + // UnloadedAssetException should be caught: + exception = expected + loadingFinished.complete(true) + } + } + // Waiting for the loading to start: + loadingStarted.join() + runBlocking { + // Unloading the asset: + storage.unload(path) + // Loader is waiting for the unloading to finish - unlocking thread: + unloadingFinished.complete(true) + } + + // Then: + asset.disposingFinished.join() + assertTrue(asset.isDisposed) + loadingFinished.join() + shouldThrow { + throw exception + } + } + + @Test + fun `should safely dispose of assets that failed to load`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + storage.logger.level = Logger.NONE + val path = "fake path" + storage.setLoader { + FakeSyncLoader( + onLoad = { throw IllegalStateException("Expected.") } + ) + } + runBlocking { + try { + storage.load(path) + } catch (exception: AssetLoadingException) { + // Expected. + } + } + + // When: + storage.dispose() + + // Then: + assertFalse(storage.contains(path)) + } + + @Test + fun `should safely dispose of assets that failed to load with error handling`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + storage.logger.level = Logger.NONE + val path = "fake path" + lateinit var exception: Throwable + lateinit var identifier: Identifier<*> + storage.setLoader { + FakeSyncLoader( + onLoad = { throw IllegalStateException("Expected.") } + ) + } + runBlocking { + try { + storage.load(path) + } catch (exception: AssetLoadingException) { + // Expected. + } + } + + // When: + runBlocking { + storage.dispose { id, cause -> + exception = cause + identifier = id + } + } + + // Then: + assertFalse(storage.contains(path)) + assertTrue(exception is AssetLoadingException) + assertEquals(storage.getIdentifier(path), identifier) + } + + @Test + fun `should immediately throw exception when attempting to synchronously load already scheduled asset`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val path = "fake path" + val loadingStarted = CompletableFuture() + val loadingFinished = CompletableFuture() + storage.setLoader { + FakeSyncLoader( + onLoad = { + loadingStarted.complete(true) + loadingFinished.join() + } + ) + } + val reference = storage.loadAsync(path) + loadingStarted.join() + + // When: asset is not loaded yet: + shouldThrow { + storage.loadSync(path) + } + + // Then: + assertFalse(storage.isLoaded(path)) + loadingFinished.complete(true) + runBlocking { reference.await() } + assertTrue(storage.isLoaded(path)) + // Should still count the reference, since load was called: + assertEquals(2, storage.getReferenceCount(path)) + } + + @Test + fun `should throw exception when a dependency of a synchronously loaded asset is being loaded asynchronously`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + val path = "fake.path" + val dependency = "path.dep" + val loadingStarted = CompletableFuture() + val loadingFinished = CompletableFuture() + storage.setLoader { + object: FakeSyncLoader( + dependencies = GdxArray.with(storage.getAssetDescriptor(dependency)), + onLoad = {} + ) { + override fun load( + assetManager: AssetManager, + fileName: String?, + file: FileHandle?, + parameter: FakeParameters? + ): FakeAsset { + assetManager.get(dependency, FakeAsset::class.java) + return super.load(assetManager, fileName, file, parameter) + } + } + } + storage.setLoader(suffix = ".dep") { + FakeSyncLoader( + onLoad = { + loadingStarted.complete(true) + loadingFinished.join() + } + ) + } + val reference = storage.loadAsync(dependency) + loadingStarted.join() + + // When: dependency is not loaded yet: + shouldThrow { + storage.loadSync(path) + } + + // Then: + assertTrue(storage.isLoaded(path)) + assertEquals(1, storage.getReferenceCount(path)) + assertFalse(storage.isLoaded(dependency)) + loadingFinished.complete(true) + runBlocking { reference.await() } + assertTrue(storage.isLoaded(dependency)) + // Should still count the reference, since load was called: + assertEquals(2, storage.getReferenceCount(dependency)) + } +} diff --git a/assets-async/src/test/resources/ktx/assets/async/cubemap.zktx b/assets-async/src/test/resources/ktx/assets/async/cubemap.zktx new file mode 100644 index 00000000..534146fb Binary files /dev/null and b/assets-async/src/test/resources/ktx/assets/async/cubemap.zktx differ diff --git a/assets-async/src/test/resources/ktx/assets/async/i18n.properties b/assets-async/src/test/resources/ktx/assets/async/i18n.properties new file mode 100644 index 00000000..9819b570 --- /dev/null +++ b/assets-async/src/test/resources/ktx/assets/async/i18n.properties @@ -0,0 +1 @@ +key=Value. diff --git a/assets-async/src/test/resources/ktx/assets/async/model.g3db b/assets-async/src/test/resources/ktx/assets/async/model.g3db new file mode 100644 index 00000000..47f1e9ab Binary files /dev/null and b/assets-async/src/test/resources/ktx/assets/async/model.g3db differ diff --git a/assets-async/src/test/resources/ktx/assets/async/model.g3dj b/assets-async/src/test/resources/ktx/assets/async/model.g3dj new file mode 100644 index 00000000..2339d9c0 --- /dev/null +++ b/assets-async/src/test/resources/ktx/assets/async/model.g3dj @@ -0,0 +1,83 @@ +{ + "version": [ 0, 1], + "id": "", + "meshes": [ + { + "attributes": ["POSITION", "NORMAL"], + "vertices": [ + -1.000000, -1.000000, -1.000000, 0.000000, 0.000000, -1.000000, + 1.000000, 1.000000, -1.000000, 0.000000, 0.000000, -1.000000, + 1.000000, -1.000000, -1.000000, 0.000000, 0.000000, -1.000000, + -1.000000, 1.000000, -1.000000, 0.000000, 0.000000, -1.000000, + -1.000000, 1.000000, 1.000000, 0.000000, 0.000000, 1.000000, + 0.999999, -1.000001, 1.000000, 0.000000, 0.000000, 1.000000, + 1.000000, 0.999999, 1.000000, 0.000000, 0.000000, 1.000000, + -1.000000, -1.000000, 1.000000, 0.000000, 0.000000, 1.000000, + 1.000000, 0.999999, 1.000000, 1.000000, -0.000000, 0.000000, + 1.000000, -1.000000, -1.000000, 1.000000, -0.000000, 0.000000, + 1.000000, 1.000000, -1.000000, 1.000000, -0.000000, 0.000000, + 0.999999, -1.000001, 1.000000, 1.000000, -0.000000, 0.000000, + 0.999999, -1.000001, 1.000000, -0.000000, -1.000000, -0.000000, + -1.000000, -1.000000, -1.000000, -0.000000, -1.000000, -0.000000, + 1.000000, -1.000000, -1.000000, -0.000000, -1.000000, -0.000000, + -1.000000, -1.000000, 1.000000, -0.000000, -1.000000, -0.000000, + -1.000000, 1.000000, 1.000000, -1.000000, 0.000000, -0.000000, + -1.000000, -1.000000, -1.000000, -1.000000, 0.000000, -0.000000, + -1.000000, -1.000000, 1.000000, -1.000000, 0.000000, -0.000000, + -1.000000, 1.000000, -1.000000, -1.000000, 0.000000, -0.000000, + -1.000000, 1.000000, 1.000000, 0.000000, 1.000000, 0.000000, + 1.000000, 1.000000, -1.000000, 0.000000, 1.000000, 0.000000, + -1.000000, 1.000000, -1.000000, 0.000000, 1.000000, 0.000000, + 1.000000, 0.999999, 1.000000, 0.000000, 1.000000, 0.000000 + ], + "parts": [ + { + "id": "Cube_part1", + "type": "TRIANGLES", + "indices": [ + 0, 1, 2, 1, 0, 3, 4, 5, 6, 5, 4, 7, + 8, 9, 10, 9, 8, 11, 12, 13, 14, 13, 12, 15, + 16, 17, 18, 17, 16, 19, 20, 21, 22, 21, 20, 23 + ] + } + ] + } + ], + "materials": [ + { + "id": "Material", + "ambient": [ 0.000000, 0.000000, 0.000000], + "diffuse": [ 0.800000, 0.800000, 0.800000], + "emissive": [ 0.800000, 0.800000, 0.800000], + "opacity": 1.000000, + "specular": [ 1.000000, 1.000000, 1.000000], + "shininess": 9.607843 + } + ], + "nodes": [ + { + "id": "Cube", + "rotation": [-0.707107, 0.000000, 0.000000, 0.707107], + "scale": [ 100.000000, 100.000000, 100.000000], + "parts": [ + { + "meshpartid": "Cube_part1", + "materialid": "Material" + } + ] + }, + { + "id": "Lamp", + "rotation": [ 0.169076, 0.755880, -0.272171, 0.570948], + "scale": [ 100.000008, 100.000000, 100.000000], + "translation": [ 407.624542, 590.386230, -100.545395] + }, + { + "id": "Camera", + "rotation": [-0.212557, 0.904797, -0.084389, 0.359222], + "scale": [ 100.000000, 100.000008, 99.999992], + "translation": [ 748.113159, 534.366516, 650.763977] + } + ], + "animations": [] +} diff --git a/assets-async/src/test/resources/ktx/assets/async/model.obj b/assets-async/src/test/resources/ktx/assets/async/model.obj new file mode 100644 index 00000000..1f93f969 --- /dev/null +++ b/assets-async/src/test/resources/ktx/assets/async/model.obj @@ -0,0 +1,22 @@ +o Cube +v 1.000000 -1.000000 -1.000000 +v 1.000000 -1.000000 1.000000 +v -1.000000 -1.000000 1.000000 +v -1.000000 -1.000000 -1.000000 +v 1.000000 1.000000 -0.999999 +v 0.999999 1.000000 1.000001 +v -1.000000 1.000000 1.000000 +v -1.000000 1.000000 -1.000000 +vn 0.0000 -1.0000 0.0000 +vn 0.0000 1.0000 0.0000 +vn 1.0000 0.0000 0.0000 +vn -0.0000 -0.0000 1.0000 +vn -1.0000 -0.0000 -0.0000 +vn 0.0000 0.0000 -1.0000 +s off +f 1//1 2//1 3//1 4//1 +f 5//2 8//2 7//2 6//2 +f 1//3 5//3 6//3 2//3 +f 2//4 6//4 7//4 3//4 +f 3//5 7//5 8//5 4//5 +f 5//6 1//6 4//6 8//6 diff --git a/assets-async/src/test/resources/ktx/assets/async/particle.p2d b/assets-async/src/test/resources/ktx/assets/async/particle.p2d new file mode 100644 index 00000000..aec76f28 --- /dev/null +++ b/assets-async/src/test/resources/ktx/assets/async/particle.p2d @@ -0,0 +1,135 @@ +Untitled +- Delay - +active: false +- Duration - +lowMin: 3000.0 +lowMax: 3000.0 +- Count - +min: 0 +max: 200 +- Emission - +lowMin: 0.0 +lowMax: 0.0 +highMin: 250.0 +highMax: 250.0 +relative: false +scalingCount: 1 +scaling0: 1.0 +timelineCount: 1 +timeline0: 0.0 +- Life - +lowMin: 0.0 +lowMax: 0.0 +highMin: 500.0 +highMax: 1000.0 +relative: false +scalingCount: 3 +scaling0: 1.0 +scaling1: 1.0 +scaling2: 0.3 +timelineCount: 3 +timeline0: 0.0 +timeline1: 0.66 +timeline2: 1.0 +- Life Offset - +active: false +- X Offset - +active: false +- Y Offset - +active: false +- Spawn Shape - +shape: point +- Spawn Width - +lowMin: 0.0 +lowMax: 0.0 +highMin: 0.0 +highMax: 0.0 +relative: false +scalingCount: 1 +scaling0: 1.0 +timelineCount: 1 +timeline0: 0.0 +- Spawn Height - +lowMin: 0.0 +lowMax: 0.0 +highMin: 0.0 +highMax: 0.0 +relative: false +scalingCount: 1 +scaling0: 1.0 +timelineCount: 1 +timeline0: 0.0 +- Scale - +lowMin: 0.0 +lowMax: 0.0 +highMin: 32.0 +highMax: 32.0 +relative: false +scalingCount: 1 +scaling0: 1.0 +timelineCount: 1 +timeline0: 0.0 +- Velocity - +active: true +lowMin: 0.0 +lowMax: 0.0 +highMin: 30.0 +highMax: 300.0 +relative: false +scalingCount: 1 +scaling0: 1.0 +timelineCount: 1 +timeline0: 0.0 +- Angle - +active: true +lowMin: 90.0 +lowMax: 90.0 +highMin: 45.0 +highMax: 135.0 +relative: false +scalingCount: 3 +scaling0: 1.0 +scaling1: 0.0 +scaling2: 0.0 +timelineCount: 3 +timeline0: 0.0 +timeline1: 0.5 +timeline2: 1.0 +- Rotation - +active: false +- Wind - +active: false +- Gravity - +active: false +- Tint - +colorsCount: 3 +colors0: 1.0 +colors1: 0.12156863 +colors2: 0.047058824 +timelineCount: 1 +timeline0: 0.0 +- Transparency - +lowMin: 0.0 +lowMax: 0.0 +highMin: 1.0 +highMax: 1.0 +relative: false +scalingCount: 4 +scaling0: 0.0 +scaling1: 1.0 +scaling2: 0.75 +scaling3: 0.0 +timelineCount: 4 +timeline0: 0.0 +timeline1: 0.2 +timeline2: 0.8 +timeline3: 1.0 +- Options - +attached: false +continuous: false +aligned: false +additive: true +behind: false +premultipliedAlpha: false +- Image Path - +texture.png diff --git a/assets-async/src/test/resources/ktx/assets/async/particle.p3d b/assets-async/src/test/resources/ktx/assets/async/particle.p3d new file mode 100644 index 00000000..0914bfca --- /dev/null +++ b/assets-async/src/test/resources/ktx/assets/async/particle.p3d @@ -0,0 +1 @@ +{unique:{billboardBatch:{class:com.badlogic.gdx.graphics.g3d.particles.ResourceData$SaveData,data:{cfg:{class:com.badlogic.gdx.graphics.g3d.particles.batches.BillboardParticleBatch$Config,mode:Screen}},indices:[0]}},data:[],assets:[{filename:texture.png,type:com.badlogic.gdx.graphics.Texture}],resource:{class:com.badlogic.gdx.graphics.g3d.particles.ParticleEffect,controllers:[{name:Billboard Controller,emitter:{class:com.badlogic.gdx.graphics.g3d.particles.emitters.RegularEmitter,minParticleCount:0,maxParticleCount:200,continous:true,emission:{active:true,lowMin:0,lowMax:0,highMin:250,highMax:250,relative:false,scaling:[1],timeline:[0]},delay:{active:false,lowMin:0,lowMax:0},duration:{active:true,lowMin:3000,lowMax:3000},life:{active:true,lowMin:0,lowMax:0,highMin:500,highMax:1000,relative:false,scaling:[1,1,0.3],timeline:[0,0.66,1]},lifeOffset:{active:false,lowMin:0,lowMax:0,highMin:0,highMax:0,relative:false,scaling:[1],timeline:[0]}},influencers:[{class:com.badlogic.gdx.graphics.g3d.particles.influencers.RegionInfluencer$Single,regions:[{u2:1,v2:1,halfInvAspectRatio:0.5}]},{class:com.badlogic.gdx.graphics.g3d.particles.influencers.SpawnInfluencer,spawnShape:{class:com.badlogic.gdx.graphics.g3d.particles.values.PointSpawnShapeValue,active:false,xOffsetValue:{active:false,lowMin:0,lowMax:0},yOffsetValue:{active:false,lowMin:0,lowMax:0},zOffsetValue:{active:false,lowMin:0,lowMax:0},spawnWidthValue:{active:false,lowMin:0,lowMax:0,highMin:0,highMax:0,relative:false,scaling:[1],timeline:[0]},spawnHeightValue:{active:false,lowMin:0,lowMax:0,highMin:0,highMax:0,relative:false,scaling:[1],timeline:[0]},spawnDepthValue:{active:false,lowMin:0,lowMax:0,highMin:0,highMax:0,relative:false,scaling:[1],timeline:[0]},edges:false}},{class:com.badlogic.gdx.graphics.g3d.particles.influencers.ColorInfluencer$Single,alpha:{active:false,lowMin:0,lowMax:0,highMin:1,highMax:1,relative:false,scaling:[0,0.15,0.5,0],timeline:[0,0.5,0.8,1]},color:{active:false,colors:[1,0.12156863,0.047058824,0,0,0],timeline:[0,1]}},{class:com.badlogic.gdx.graphics.g3d.particles.influencers.DynamicsInfluencer,velocities:[{class:com.badlogic.gdx.graphics.g3d.particles.influencers.DynamicsModifier$PolarAcceleration,isGlobal:false,strengthValue:{active:false,lowMin:0,lowMax:0,highMin:5,highMax:10,relative:false,scaling:[1],timeline:[0]},thetaValue:{active:false,lowMin:0,lowMax:0,highMin:0,highMax:360,relative:false,scaling:[1],timeline:[0]},phiValue:{active:true,lowMin:0,lowMax:0,highMin:-35,highMax:35,relative:false,scaling:[1,0,0],timeline:[0,0.5,1]}}]}],renderer:{class:com.badlogic.gdx.graphics.g3d.particles.renderers.BillboardRenderer}}]}} diff --git a/assets-async/src/test/resources/ktx/assets/async/shader.frag b/assets-async/src/test/resources/ktx/assets/async/shader.frag new file mode 100644 index 00000000..765cd2b5 --- /dev/null +++ b/assets-async/src/test/resources/ktx/assets/async/shader.frag @@ -0,0 +1,4 @@ +void main() +{ + gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); +} diff --git a/assets-async/src/test/resources/ktx/assets/async/shader.vert b/assets-async/src/test/resources/ktx/assets/async/shader.vert new file mode 100644 index 00000000..513d5ad3 --- /dev/null +++ b/assets-async/src/test/resources/ktx/assets/async/shader.vert @@ -0,0 +1,2 @@ +main { +} diff --git a/assets-async/src/test/resources/ktx/assets/async/skin.atlas b/assets-async/src/test/resources/ktx/assets/async/skin.atlas new file mode 100644 index 00000000..0e059fd0 --- /dev/null +++ b/assets-async/src/test/resources/ktx/assets/async/skin.atlas @@ -0,0 +1,15 @@ + +texture.png +size: 32,32 +format: RGBA8888 +filter: Linear,Linear +repeat: none +button + rotate: false + xy: 1, 1 + size: 27, 27 + split: 8, 8, 8, 8 + pad: 10, 10, 10, 10 + orig: 27, 27 + offset: 0, 0 + index: -1 diff --git a/assets-async/src/test/resources/ktx/assets/async/skin.json b/assets-async/src/test/resources/ktx/assets/async/skin.json new file mode 100644 index 00000000..d03cdc4a --- /dev/null +++ b/assets-async/src/test/resources/ktx/assets/async/skin.json @@ -0,0 +1,7 @@ +{ + "com.badlogic.gdx.scenes.scene2d.ui.Button$ButtonStyle": { + "default": { + "up": "button" + } + } +} diff --git a/assets-async/src/test/resources/ktx/assets/async/sound.ogg b/assets-async/src/test/resources/ktx/assets/async/sound.ogg new file mode 100644 index 00000000..85c17133 Binary files /dev/null and b/assets-async/src/test/resources/ktx/assets/async/sound.ogg differ diff --git a/assets-async/src/test/resources/ktx/assets/async/string.txt b/assets-async/src/test/resources/ktx/assets/async/string.txt new file mode 100644 index 00000000..fffc22b4 --- /dev/null +++ b/assets-async/src/test/resources/ktx/assets/async/string.txt @@ -0,0 +1 @@ +Content. \ No newline at end of file diff --git a/assets-async/src/test/resources/ktx/assets/async/texture.png b/assets-async/src/test/resources/ktx/assets/async/texture.png new file mode 100644 index 00000000..76dcb079 Binary files /dev/null and b/assets-async/src/test/resources/ktx/assets/async/texture.png differ diff --git a/assets-async/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/assets-async/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000..1f0955d4 --- /dev/null +++ b/assets-async/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline diff --git a/assets/README.md b/assets/README.md index 60e9fbfe..423a8a56 100644 --- a/assets/README.md +++ b/assets/README.md @@ -6,9 +6,9 @@ Utilities for management of assets and heavy resources. ### Why? -LibGDX does a good job of helping you with assets (through `AssetManager`, for example), but - as usual in case of Java -APIs - it does not use the full potential of Kotlin features. This library aims to provide Kotlin extensions and wrappers -for the existing API to make assets usage more natural in Kotlin applications. +LibGDX does a good job of helping you with assets through `AssetManager` and related APIs, but - as usual in case of +Java libraries - it does not allow to use the full potential of Kotlin features. This library aims to provide Kotlin +extensions and wrappers for the existing asset APIs to make assets usage more idiomatic in Kotlin applications. ### Guide @@ -364,6 +364,9 @@ val textures = Images.values().map { it() } ### Alternatives +- [`ktx-assets-async`](../assets-async) provides an alternative asset manager with non-blocking API based on coroutines. +In contrary to LibGDX `AssetManager`, **KTX** `AssetStorage` supports concurrent loading of assets on multiple threads +performing asynchronous operations. - [libgdx-utils](https://bitbucket.org/dermetfan/libgdx-utils/) feature an annotation-based asset manager implementation which easies loading of assets (through internal reflection usage). - [Autumn MVC](https://github.com/czyzby/gdx-lml/tree/master/mvc) is a [Spring](https://spring.io/)-inspired diff --git a/async/README.md b/async/README.md index 51e14c5a..4b262b95 100644 --- a/async/README.md +++ b/async/README.md @@ -245,10 +245,16 @@ import ktx.async.AsyncExecutorDispatcher import ktx.async.newAsyncContext import ktx.async.newSingleThreadAsyncContext +// Context with a single thread: val singleThreaded = newSingleThreadAsyncContext() +// Context with multiple threads: val multiThreaded = newAsyncContext(threads = 4) +// Context with a custom thread name pattern: +val multiThreadedWithNamedThreads = newAsyncContext(threads = 4, threadName = "MyThread") + +// Context with an existing executor: val executor = AsyncExecutor(2) val fromExistingExecutor = AsyncExecutorDispatcher(executor, threads = 2) ``` diff --git a/async/src/main/kotlin/ktx/async/async.kt b/async/src/main/kotlin/ktx/async/async.kt index ee388b9e..7a8003e0 100644 --- a/async/src/main/kotlin/ktx/async/async.kt +++ b/async/src/main/kotlin/ktx/async/async.kt @@ -18,11 +18,11 @@ object KtxAsync : CoroutineScope { override val coroutineContext = MainDispatcher /** - * Should be invoked on the main rendering thread before using KTX coroutines. Might slightly affect performance - * otherwise. + * Should be invoked on the main rendering thread before using KTX coroutines. + * Failing to do so will cause some parts of the API to throw exceptions. */ fun initiate() { - ImmediateDispatcher.initiate() + MainDispatcher.initiate() } } @@ -48,14 +48,19 @@ fun RenderingScope() = CoroutineScope(SupervisorJob() + MainDispatcher) /** * Creates a new [AsyncExecutorDispatcher] wrapping around an [AsyncExecutor] with a single thread to execute tasks * asynchronously outside of the main rendering thread. + * + * [AsyncExecutor] thread will be named according to the [threadName] pattern. */ -fun newSingleThreadAsyncContext() = newAsyncContext(1) +fun newSingleThreadAsyncContext(threadName: String = "AsyncExecutor-Thread") = newAsyncContext(1, threadName) /** * Creates a new [AsyncExecutorDispatcher] wrapping around an [AsyncExecutor] with the chosen amount of [threads] * to execute tasks asynchronously outside of the main rendering thread. + * + * [AsyncExecutor] threads will be named according to the [threadName] pattern. */ -fun newAsyncContext(threads: Int) = AsyncExecutorDispatcher(AsyncExecutor(threads), threads) +fun newAsyncContext(threads: Int, threadName: String = "AsyncExecutor-Thread") = + AsyncExecutorDispatcher(AsyncExecutor(threads, threadName), threads) /** * Suspends the coroutine to execute the defined [block] on the main rendering thread and return its result. @@ -65,18 +70,23 @@ suspend fun onRenderingThread(block: suspend CoroutineScope.() -> T) = withC /** * Returns true if the coroutine was launched from a rendering thread dispatcher. */ -fun CoroutineScope.isOnRenderingThread() = coroutineContext[ContinuationInterceptor.Key] is RenderingThreadDispatcher +fun CoroutineScope.isOnRenderingThread() = + coroutineContext[ContinuationInterceptor.Key] is RenderingThreadDispatcher + && Thread.currentThread() === MainDispatcher.mainThread + /** * Attempts to skip the current frame. Resumes the execution using a task scheduled with [Application.postRunnable]. + * * Due to asynchronous nature of the execution, there is no guarantee that this method will always skip only a *single* - * frame before further method calls are executed, but it will always skip *at least one* frame. + * frame before resuming, but it will always suspend the current coroutine until the [Runnable] instances scheduled + * with [Application.postRunnable] are executed by the [Application]. */ suspend fun skipFrame() { suspendCancellableCoroutine { continuation -> Gdx.app.postRunnable { - val context = continuation.context[ContinuationInterceptor.Key] if (continuation.isActive) { + val context = continuation.context[ContinuationInterceptor.Key] if (context is RenderingThreadDispatcher) { // Executed via main thread dispatcher and already on the main thread - resuming immediately: with(continuation) { context.resumeUndispatched(Unit) } diff --git a/async/src/main/kotlin/ktx/async/dispatchers.kt b/async/src/main/kotlin/ktx/async/dispatchers.kt index dfcc91d7..7e7f302b 100644 --- a/async/src/main/kotlin/ktx/async/dispatchers.kt +++ b/async/src/main/kotlin/ktx/async/dispatchers.kt @@ -114,15 +114,7 @@ class RenderingThreadDispatcherFactory : MainDispatcherFactory { */ object MainDispatcher : RenderingThreadDispatcher() { @ExperimentalCoroutinesApi - override val immediate: MainCoroutineDispatcher = ImmediateDispatcher -} - -/** - * Supports immediate tasks execution in the main rendering thread context. - */ -object ImmediateDispatcher : RenderingThreadDispatcher() { - @ExperimentalCoroutinesApi - override val immediate: MainCoroutineDispatcher = this + override val immediate = this lateinit var mainThread: Thread /** Must be called **on the rendering thread** before using KTX coroutines. */ @@ -130,7 +122,5 @@ object ImmediateDispatcher : RenderingThreadDispatcher() { mainThread = Thread.currentThread() } - override fun isDispatchNeeded(context: CoroutineContext): Boolean = Thread.currentThread() != mainThread - - override fun toString(): String = super.toString() + "(immediate)" + override fun isDispatchNeeded(context: CoroutineContext): Boolean = !KtxAsync.isOnRenderingThread() } diff --git a/async/src/test/kotlin/ktx/async/asyncTest.kt b/async/src/test/kotlin/ktx/async/asyncTest.kt index f6011a8f..642e84a8 100644 --- a/async/src/test/kotlin/ktx/async/asyncTest.kt +++ b/async/src/test/kotlin/ktx/async/asyncTest.kt @@ -44,6 +44,16 @@ class KtxAsyncTest : AsyncTest() { assertEquals(1, dispatcher.threads) } + @Test + fun `should create a single-threaded AsyncExecutorDispatcher with selected name`() { + // When: + val dispatcher: AsyncExecutorDispatcher = newSingleThreadAsyncContext(threadName = "MyThreadName") + + // Then: + assertEquals(1, dispatcher.threads) + assertTrue("MyThreadName" in getExecutionThread(dispatcher.executor).name) + } + @Test fun `should create a multi-threaded AsyncExecutorDispatcher`() { // When: @@ -53,6 +63,16 @@ class KtxAsyncTest : AsyncTest() { assertEquals(4, dispatcher.threads) } + @Test + fun `should create a multi-threaded AsyncExecutorDispatcher with selected name`() { + // When: + val dispatcher: AsyncExecutorDispatcher = newAsyncContext(threads = 4, threadName = "MyThreadName") + + // Then: + assertEquals(4, dispatcher.threads) + assertTrue("MyThreadName" in getExecutionThread(dispatcher.executor).name) + } + @Test fun `should execute on the main rendering thread`() { // Given: @@ -134,6 +154,45 @@ class KtxAsyncTest : AsyncTest() { assertTrue(isOnRenderingThread.get()) } + @Test + fun `should detect rendering thread from coroutine with different context`() { + // Given: + val executionThread = CompletableFuture() + val isOnRenderingThread = AtomicBoolean(true) + val dispatcher: AsyncExecutorDispatcher = newSingleThreadAsyncContext() + + // When: + KtxAsync.launch(dispatcher) { + onRenderingThread { + isOnRenderingThread.set(isOnRenderingThread()) + executionThread.complete(Thread.currentThread()) + } + } + + // Then: + assertSame(getMainRenderingThread(), executionThread.join()) + assertTrue(isOnRenderingThread.get()) + } + + @Test + fun `should detect rendering thread from coroutine launched from global context`() { + // Given: + val executionThread = CompletableFuture() + val isOnRenderingThread = AtomicBoolean(false) + + // When: + GlobalScope.launch { + onRenderingThread { + isOnRenderingThread.set(isOnRenderingThread()) + executionThread.complete(Thread.currentThread()) + } + } + + // Then: + assertSame(getMainRenderingThread(), executionThread.join()) + assertTrue(isOnRenderingThread.get()) + } + @Test fun `should detect non-rendering threads`() { // Given: @@ -152,6 +211,23 @@ class KtxAsyncTest : AsyncTest() { assertFalse(isOnRenderingThread.get()) } + @Test + fun `should detect non-rendering thread launched from global scope`() { + // Given: + val executionThread = CompletableFuture() + val isOnRenderingThread = AtomicBoolean(true) + + // When: + GlobalScope.launch { + isOnRenderingThread.set(isOnRenderingThread()) + executionThread.complete(Thread.currentThread()) + } + + // Then: + assertNotSame(getMainRenderingThread(), executionThread.join()) + assertFalse(isOnRenderingThread.get()) + } + @Test fun `should detect nested non-rendering threads`() { // Given: @@ -171,6 +247,25 @@ class KtxAsyncTest : AsyncTest() { assertNotSame(getMainRenderingThread(), executionThread.join()) assertFalse(isOnRenderingThread.get()) } + @Test + fun `should detect nested non-rendering threads with separate context`() { + // Given: + val executionThread = CompletableFuture() + val isOnRenderingThread = AtomicBoolean(true) + val dispatcher: AsyncExecutorDispatcher = newSingleThreadAsyncContext() + + // When: + KtxAsync.launch(Dispatchers.KTX) { + withContext(dispatcher) { + isOnRenderingThread.set(isOnRenderingThread()) + executionThread.complete(Thread.currentThread()) + } + } + + // Then: + assertNotSame(getMainRenderingThread(), executionThread.join()) + assertFalse(isOnRenderingThread.get()) + } @Test fun `should detect non-rendering threads with context switch`() { diff --git a/async/src/test/kotlin/ktx/async/utils.kt b/async/src/test/kotlin/ktx/async/utils.kt index a81ecb5d..0ac397ed 100644 --- a/async/src/test/kotlin/ktx/async/utils.kt +++ b/async/src/test/kotlin/ktx/async/utils.kt @@ -1,11 +1,11 @@ package ktx.async -import com.badlogic.gdx.Application import com.badlogic.gdx.ApplicationAdapter import com.badlogic.gdx.Gdx import com.badlogic.gdx.backends.headless.HeadlessApplication import com.badlogic.gdx.utils.async.AsyncExecutor import org.junit.After +import org.junit.Assert.assertSame import org.junit.Before import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit.SECONDS @@ -25,12 +25,13 @@ abstract class AsyncTest { initTask.complete(Unit) } initTask.join() + assertSame(getExecutionThread(Gdx.app::postRunnable), getMainRenderingThread()) } /** - * Finds the main rendering [Thread] used to execute runnables scheduled with [Application.postRunnable]. + * Finds the main rendering [Thread] registered in [MainDispatcher]. */ - protected fun getMainRenderingThread(): Thread = getExecutionThread(Gdx.app::postRunnable) + protected fun getMainRenderingThread(): Thread = MainDispatcher.mainThread /** * Finds the [Thread] that [AsyncExecutor] executes tasks with. Note that if the executor uses more than a single diff --git a/freetype-async/README.md b/freetype-async/README.md index 71050dbb..a2bd2003 100644 --- a/freetype-async/README.md +++ b/freetype-async/README.md @@ -1,34 +1,29 @@ [![Maven Central](https://img.shields.io/maven-central/v/io.github.libktx/ktx-freetype-async.svg)](https://search.maven.org/artifact/io.github.libktx/ktx-freetype-async) -# Warning - -As of the `1.9.9-b1` version, the `ktx-freetype-async` module is **disabled**, since `AssetStorage` was removed -from `ktx-async`. `AssetStorage` will eventually be refactored to use the new coroutines API and `ktx-freetype-async` -will be enabled. However, until then please use [`ktx-freetype`](../freetype) or a previous **KTX** version instead. - # KTX: FreeType font asynchronous loading utilities -A tiny modules that makes it easier to use [`gdx-freetype`](https://github.com/libgdx/libgdx/wiki/Gdx-freetype) library -along with the coroutines-based `AssetStorage` from [`ktx-async`](../async). +A tiny modules that makes it easier to use [`gdx-freetype`](https://github.com/libgdx/libgdx/wiki/Gdx-freetype) +library along with the coroutines-based `AssetStorage` from [`ktx-assets-async`](../assets-async). ### Why? -`gdx-freetype` requires quite a bit of setup before it can be fully integrated with LibGDX `AssetStorage` due to how -LibGDX `AssetManager` loaders are implemented. This module aims to limit the boilerplate necessary to load FreeType -fonts in LibGDX applications. +`gdx-freetype` requires quite a bit of setup before it can be fully integrated with `AssetManager` or `AssetStorage` +due to how LibGDX `AssetManager` loaders are implemented. This module aims to limit the boilerplate necessary to load +FreeType fonts in LibGDX applications with the asynchronous **KTX** `AssetStorage`. -See also [`ktx-freetype`](../freetype). +See also: [`ktx-freetype`](../freetype). ### Guide -This module consists of the following functions: +This module consists of the following utilities: * Extension method `AssetStorage.registerFreeTypeFontLoaders` allows to register all loaders required to load FreeType font assets. It should be called right after constructing a `AssetStorage` instance and before loading any assets. * Extension method `AssetStorage.loadFreeTypeFont` allows to easily configure loaded `BitmapFont` instances with Kotlin DSL. -Since it depends on the [`ktx-freetype`](../freetype) module, it also comes with the following functions: +Since it depends on the [`ktx-freetype`](../freetype) module, it also comes with the following utilities that might +prove useful even when using `AssetStorage`: * `ktx.freetype.freeTypeFontParameters` function is a Kotlin DSL for customizing font loading parameters. * `FreeTypeFontGenerator.generateFont` extension function allows to generate `BitmapFont` instances using a @@ -39,23 +34,25 @@ Since it depends on the [`ktx-freetype`](../freetype) module, it also comes with Creating `AssetStorage` with registered FreeType font loaders: ```kotlin -import ktx.async.assets.AssetStorage -import ktx.async.enableKtxCoroutines -import ktx.freetype.async.* +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync +import ktx.freetype.async.registerFreeTypeFontLoaders fun initiateAssetStorage(): AssetStorage { - // Coroutines have to be enabled. AssetStorage uses an asynchronous executor, - // so concurrency level has to be above 0. See ktx-async documentation. - enableKtxCoroutines(asynchronousExecutorConcurrencyLevel = 1) - + // Coroutines have to be enabled. + // This has to be called on the main rendering thread: + KtxAsync.initiate() + val assetStorage = AssetStorage() - // Calling registerFreeTypeFontLoaders is necessary in order to load TTF/OTF files. + // Registering TTF/OTF file loaders: assetStorage.registerFreeTypeFontLoaders() + + // AssetStorage is now ready to load FreeType fonts. return assetStorage } ``` -Registering `BitmapFont` loaders for custom file extensions: +Registering `BitmapFont` loaders only for custom file extensions: ```kotlin import ktx.freetype.async.* @@ -74,18 +71,26 @@ assetStorage.registerFreeTypeFontLoaders(replaceDefaultBitmapFontLoader = true) Loading a FreeType font using `AssetStorage` in a coroutine: ```kotlin -import ktx.async.ktxAsync -import ktx.freetype.async.* - -ktxAsync { - val font = assetStorage.loadFreeTypeFont("font.ttf") - // font is BitmapFont +import com.badlogic.gdx.graphics.g2d.BitmapFont +import kotlinx.coroutines.launch +import ktx.assets.async.AssetStorage +import ktx.async.KtxAsync +import ktx.freetype.async.loadFreeTypeFont + +fun loadFont(assetStorage: AssetStorage) { + // Launching a coroutine: + KtxAsync.launch { + // Loading a font: + val font: BitmapFont = assetStorage.loadFreeTypeFont("font.ttf") + // Font is now ready to use. + } } ``` Loading a FreeType font with custom parameters using `AssetStorage`: ```kotlin +import com.badlogic.gdx.graphics.Color import ktx.freetype.async.* val font = assetStorage.loadFreeTypeFont("font.ttf") { @@ -101,7 +106,7 @@ Accessing a fully loaded font: ```kotlin import com.badlogic.gdx.graphics.g2d.BitmapFont -val font = assetStorage.get("font.ttf") +val font = assetStorage.get("font.ttf").await() ``` Loading a `FreeTypeFontGenerator`: @@ -132,7 +137,7 @@ Generating a new `BitmapFont` using LibGDX `FreeTypeFontGenerator`: import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator import ktx.freetype.* -val generator: FreeTypeFontGenerator = getGenerator() +val generator: FreeTypeFontGenerator // Default parameters: val fontA = generator.generateFont() // Customized: @@ -149,5 +154,6 @@ FreeType font loaders can be registered manually. See #### Additional documentation -- [`gdx-freetype` article.](https://github.com/libgdx/libgdx/wiki/Gdx-freetype) +- [Official `gdx-freetype` article.](https://github.com/libgdx/libgdx/wiki/Gdx-freetype) - [`ktx-async` module.](../async) +- [`ktx-assets-async` module.](../assets-async) diff --git a/freetype-async/build.gradle b/freetype-async/build.gradle index 1092aa09..65112a77 100644 --- a/freetype-async/build.gradle +++ b/freetype-async/build.gradle @@ -1,11 +1,13 @@ dependencies { - compile project(':async') + compile project(':assets-async') compile project(':freetype') provided "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" provided "com.badlogicgames.gdx:gdx-freetype:$gdxVersion" testCompile project(':async').sourceSets.test.output + testCompile "org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:$kotlinCoroutinesVersion" testCompile "com.badlogicgames.gdx:gdx-freetype-platform:$gdxVersion:natives-desktop" + testCompile "com.badlogicgames.gdx:gdx-backend-headless:$gdxVersion" testCompile "com.badlogicgames.gdx:gdx-backend-lwjgl:$gdxVersion" testCompile "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop" } diff --git a/freetype-async/src/main/kotlin/ktx/freetype/async/freetypeAsync.kt b/freetype-async/src/main/kotlin/ktx/freetype/async/freetypeAsync.kt index 317709e8..2a537c41 100644 --- a/freetype-async/src/main/kotlin/ktx/freetype/async/freetypeAsync.kt +++ b/freetype-async/src/main/kotlin/ktx/freetype/async/freetypeAsync.kt @@ -5,41 +5,52 @@ import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator.FreeTypeFontParameter import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGeneratorLoader import com.badlogic.gdx.graphics.g2d.freetype.FreetypeFontLoader -import ktx.async.assets.AssetStorage +import ktx.assets.async.AssetStorage import ktx.freetype.freeTypeFontParameters /** - * Registers all loaders necessary to load [BitmapFont] and [FreeTypeFontGenerator] instances from TTF and OTF files. - * @param fileExtensions a collection of supported file extensions. If an empty array is passed, [BitmapFont] loaders - * will not be registered. Defaults to ".ttf" and ".otf". - * @param replaceDefaultBitmapFontLoader if true, default [BitmapFont] loader will be replaced and any attempts to load - * [BitmapFont] will result in use of [FreetypeFontLoader] instead. [fileExtensions] will be ignored and FreeType loader - * will be used by default unless overridden. + * Registers all loaders necessary to load [BitmapFont] and [FreeTypeFontGenerator] + * instances from TTF and OTF files. + * + * [fileExtensions] is a collection of supported file extensions. If an empty array is passed, + * [BitmapFont] loaders will not be registered. Defaults to ".ttf" and ".otf". + * + * If [replaceDefaultBitmapFontLoader] is true, default [BitmapFont] loader will be replaced + * and any attempts to load [BitmapFont] will result in the use of [FreetypeFontLoader] instead. + * [fileExtensions] will be ignored and FreeType loader will be used by default for all font + * assets unless overridden. */ fun AssetStorage.registerFreeTypeFontLoaders( - fileExtensions: Array = arrayOf(".ttf", ".otf"), - replaceDefaultBitmapFontLoader: Boolean = false) { - val fontGeneratorLoader = FreeTypeFontGeneratorLoader(fileResolver) - setLoader(fontGeneratorLoader) + fileExtensions: Array = arrayOf(".ttf", ".otf"), + replaceDefaultBitmapFontLoader: Boolean = false +) { + setLoader { FreeTypeFontGeneratorLoader(fileResolver) } - val fontLoader = FreetypeFontLoader(fileResolver) if (replaceDefaultBitmapFontLoader) { - setLoader(fontLoader) + setLoader { FreetypeFontLoader(fileResolver) } } else { fileExtensions.forEach { extension -> - setLoader(fontLoader, suffix = extension) + setLoader(suffix = extension) { FreetypeFontLoader(fileResolver) } } } } /** * Allows to customize parameters of a loaded FreeType font. - * @param file path to the FreeType font file. - * @param setup should specify font parameters. Will be invoked on a new instance of [FreeTypeFontParameter]. Inlined. - * @return fully loaded BitmapFont. Note that this method will suspend the current coroutine to perform asynchronous - * font loading. + * + * [path] is the file path to the FreeType font file. + * Must be compatible with the [AssetStorage.fileResolver]. + * + * [setup] can be used to specify and customize the parameters of the loaded font. + * It will be inlined and invoked on a [FreeTypeFontParameter]. + * + * Returns the result of font loading. See [AssetStorage.load] for lists of possible outcomes. + * + * Note that you can also call [AssetStorage.loadSync] or [AssetStorage.loadAsync] directly if needed, + * but you must pass [FreeTypeFontParameter]. See [freeTypeFontParameters] utility. */ suspend inline fun AssetStorage.loadFreeTypeFont( - file: String, - setup: FreeTypeFontParameter.() -> Unit = {}): BitmapFont = - load(file, parameters = freeTypeFontParameters(file, setup)) + path: String, + setup: FreeTypeFontParameter.() -> Unit = {} +): BitmapFont = + load(path, parameters = freeTypeFontParameters(path, setup)) diff --git a/freetype-async/src/test/kotlin/ktx/freetype/async/freetypeAsyncTest.kt b/freetype-async/src/test/kotlin/ktx/freetype/async/freetypeAsyncTest.kt index c817e0de..6a7a5819 100644 --- a/freetype-async/src/test/kotlin/ktx/freetype/async/freetypeAsyncTest.kt +++ b/freetype-async/src/test/kotlin/ktx/freetype/async/freetypeAsyncTest.kt @@ -2,199 +2,378 @@ package ktx.freetype.async import com.badlogic.gdx.Gdx import com.badlogic.gdx.assets.loaders.resolvers.ClasspathFileHandleResolver -import com.badlogic.gdx.backends.lwjgl.LwjglFiles import com.badlogic.gdx.backends.lwjgl.LwjglNativesLoader import com.badlogic.gdx.graphics.g2d.BitmapFont import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGeneratorLoader import com.badlogic.gdx.graphics.g2d.freetype.FreetypeFontLoader import com.nhaarman.mockitokotlin2.mock -import ktx.async.`coroutine test` -import ktx.async.`destroy coroutines context` -import ktx.async.assets.AssetStorage +import io.kotlintest.matchers.shouldThrow +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import ktx.assets.async.AssetStorage +import ktx.assets.async.MissingAssetException +import ktx.async.AsyncTest +import ktx.async.newAsyncContext import ktx.freetype.freeTypeFontParameters -import org.junit.After import org.junit.Assert.* -import org.junit.Before +import org.junit.BeforeClass import org.junit.Test /** - * Tests FreeType font loading utilities. Uses Hack font for testing. See https://github.com/source-foundry/Hack for - * font details. + * Tests FreeType font loading utilities. + * + * Uses Hack font for testing. See https://github.com/source-foundry/Hack for font details. + * + * Implementation note: tests use [runBlocking] to simplify the implementation. This is + * a blocking operation and might permanently block your rendering thread. In an actual + * application, prefer [ktx.async.KtxAsync].launch to run your coroutines. */ -class FreeTypeAsyncTest { +class FreeTypeAsyncTest : AsyncTest() { private val ttfFile = "ktx/freetype/async/hack.ttf" private val otfFile = "ktx/freetype/async/hack.otf" - @Test - fun `should register FreeType font loaders`() = `coroutine test`(concurrencyLevel = 1) { - val assetStorage = assetStorage() - - assetStorage.registerFreeTypeFontLoaders() + companion object { + @JvmStatic + @BeforeClass + fun `initiate LibGDX`() { + LwjglNativesLoader.load() + Gdx.graphics = mock() + Gdx.gl20 = mock() + Gdx.gl = Gdx.gl20 + } + } - assertTrue(assetStorage.getLoader() is FreeTypeFontGeneratorLoader) - assertTrue(assetStorage.getLoader(".ttf") is FreetypeFontLoader) - assertTrue(assetStorage.getLoader(".otf") is FreetypeFontLoader) + @Test + fun `should register FreeType font loaders`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) + + // When: + storage.registerFreeTypeFontLoaders() + + // Then: + assertTrue(storage.getLoader() is FreeTypeFontGeneratorLoader) + assertTrue(storage.getLoader(".ttf") is FreetypeFontLoader) + assertTrue(storage.getLoader(".otf") is FreetypeFontLoader) + // Default font loader should not be registered: + assertNull(storage.getLoader()) } @Test - fun `should register FreeType font loaders with custom extensions`() = `coroutine test`(concurrencyLevel = 1) { - val assetStorage = assetStorage() + fun `should register FreeType font loaders with custom extensions`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false) - assetStorage.registerFreeTypeFontLoaders(fileExtensions = arrayOf(".custom")) + // When: + storage.registerFreeTypeFontLoaders(fileExtensions = arrayOf(".custom")) - assertTrue(assetStorage.getLoader() is FreeTypeFontGeneratorLoader) - assertTrue(assetStorage.getLoader(".custom") is FreetypeFontLoader) + // Then: + assertTrue(storage.getLoader() is FreeTypeFontGeneratorLoader) + assertTrue(storage.getLoader(".custom") is FreetypeFontLoader) // Should not register loader for unlisted extensions: - assertFalse(assetStorage.getLoader(".ttf") is FreetypeFontLoader) - assertFalse(assetStorage.getLoader(".otf") is FreetypeFontLoader) + assertNull(storage.getLoader(".ttf")) + assertNull(storage.getLoader(".otf")) + assertNull(storage.getLoader()) } @Test - fun `should register FreeType font loaders with default loader override`() = `coroutine test`(concurrencyLevel = 1) { - val assetStorage = assetStorage() + fun `should register FreeType font loaders with default font loader override`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = true) - assetStorage.registerFreeTypeFontLoaders(replaceDefaultBitmapFontLoader = true) + // When: + storage.registerFreeTypeFontLoaders(replaceDefaultBitmapFontLoader = true) - assertTrue(assetStorage.getLoader() is FreeTypeFontGeneratorLoader) - assertTrue(assetStorage.getLoader() is FreetypeFontLoader) + // Then: + assertTrue(storage.getLoader() is FreeTypeFontGeneratorLoader) + assertTrue(storage.getLoader() is FreetypeFontLoader) } @Test - fun `should load OTF file into BitmapFont`() = `coroutine test`(concurrencyLevel = 1) { async -> - val assetStorage = assetStorage() - assetStorage.registerFreeTypeFontLoaders() - - async { - val asset = assetStorage.loadFreeTypeFont(otfFile) - - val font = assetStorage.get(otfFile) - assertTrue(font is BitmapFont) - assertSame(asset, font) - } + fun `should load OTF file into BitmapFont`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() + + // When: + val asset = runBlocking { storage.loadFreeTypeFont(otfFile) } + + // Then: + assertTrue(storage.isLoaded(otfFile)) + assertSame(asset, storage.get(otfFile)) + assertEquals(1, storage.getReferenceCount(otfFile)) + // Automatically loads a generator for the font: + assertEquals(listOf(storage.getIdentifier("$otfFile.gen")), + storage.getDependencies(otfFile)) + assertTrue(storage.isLoaded("$otfFile.gen")) + + storage.dispose() } @Test - fun `should load OTF file into BitmapFont with custom params`() = `coroutine test`(concurrencyLevel = 1) { async -> - val assetStorage = assetStorage() - assetStorage.registerFreeTypeFontLoaders() - - async { - val asset = assetStorage.loadFreeTypeFont(otfFile) { + fun `should load OTF file into BitmapFont with custom params`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() + + // When: + val asset = runBlocking { + storage.loadFreeTypeFont(otfFile) { size = 12 borderWidth = 1f } - - val font = assetStorage.get(otfFile) - assertTrue(font is BitmapFont) - assertSame(asset, font) } - } - @Test - fun `should load TTF file into BitmapFont`() = `coroutine test`(concurrencyLevel = 1) { async -> - val assetStorage = assetStorage() - assetStorage.registerFreeTypeFontLoaders() + // Then: + assertTrue(storage.isLoaded(otfFile)) + assertSame(asset, storage.get(otfFile)) + assertEquals(1, storage.getReferenceCount(otfFile)) + // Automatically loads a generator for the font: + assertEquals(listOf(storage.getIdentifier("$otfFile.gen")), + storage.getDependencies(otfFile)) + assertTrue(storage.isLoaded("$otfFile.gen")) - async { - val asset = assetStorage.loadFreeTypeFont(ttfFile) + storage.dispose() + } - val font = assetStorage.get(ttfFile) - assertTrue(font is BitmapFont) - assertSame(asset, font) - } + @Test + fun `should unload BitmapFont assets loaded from OTF file`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() + runBlocking { storage.loadFreeTypeFont(otfFile) } + + // When: + runBlocking { storage.unload(otfFile) } + + // Then: + assertFalse(storage.isLoaded(otfFile)) + assertEquals(0, storage.getReferenceCount(otfFile)) + assertFalse(storage.isLoaded("$otfFile.gen")) + assertEquals(0, storage.getReferenceCount("$otfFile.gen")) } @Test - fun `should load TTF file into BitmapFont with custom params`() = `coroutine test`(concurrencyLevel = 1) { async -> - val assetStorage = assetStorage() - assetStorage.registerFreeTypeFontLoaders() + fun `should load TTF file into BitmapFont`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() + + // When: + val asset = runBlocking { storage.loadFreeTypeFont(ttfFile) } + + // Then: + assertTrue(storage.isLoaded(ttfFile)) + assertSame(asset, storage.get(ttfFile)) + assertEquals(1, storage.getReferenceCount(ttfFile)) + // Automatically loads a generator for the font: + assertEquals(listOf(storage.getIdentifier("$ttfFile.gen")), + storage.getDependencies(ttfFile)) + assertTrue(storage.isLoaded("$ttfFile.gen")) + + storage.dispose() + } - async { - val asset = assetStorage.loadFreeTypeFont(ttfFile) { + @Test + fun `should load TTF file into BitmapFont with custom params`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() + + // When: + val asset = runBlocking { + storage.loadFreeTypeFont(ttfFile) { size = 12 borderWidth = 1f } - - val font = assetStorage.get(ttfFile) - assertTrue(font is BitmapFont) - assertSame(asset, font) } + + // Then: + assertTrue(storage.isLoaded(ttfFile)) + assertSame(asset, storage.get(ttfFile)) + assertEquals(1, storage.getReferenceCount(ttfFile)) + // Automatically loads a generator for the font: + assertEquals(listOf(storage.getIdentifier("$ttfFile.gen")), + storage.getDependencies(ttfFile)) + assertTrue(storage.isLoaded("$ttfFile.gen")) + + storage.dispose() + } + + @Test + fun `should unload BitmapFont assets loaded from TTF file`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() + runBlocking { storage.loadFreeTypeFont(ttfFile) } + + // When: + runBlocking { storage.unload(ttfFile) } + + // Then: + assertFalse(storage.isLoaded(ttfFile)) + assertEquals(0, storage.getReferenceCount(ttfFile)) + assertFalse(storage.isLoaded("$ttfFile.gen")) + assertEquals(0, storage.getReferenceCount("$ttfFile.gen")) } @Test - fun `should use FreeType loader to load OTF file into BitmapFont`() = `coroutine test`(concurrencyLevel = 1) { async -> - // Note that this method uses "raw" AssetStorage API without font loading utilities. - val assetStorage = assetStorage() - assetStorage.registerFreeTypeFontLoaders() + fun `should use FreeType loader to load OTF file into BitmapFont`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() - async { - val asset = assetStorage.load(otfFile, parameters = freeTypeFontParameters(otfFile)) - val font = assetStorage.get(otfFile) - assertTrue(font is BitmapFont) - assertSame(font, asset) + // When: + val asset = runBlocking { + // Note that this method uses "raw" AssetStorage API without font loading utilities. + // Without the freeTypeFontParameters, this will fail to load. + storage.load(otfFile, parameters = freeTypeFontParameters(otfFile)) } + + // Then: + assertTrue(storage.isLoaded(otfFile)) + assertSame(asset, storage.get(otfFile)) + assertEquals(1, storage.getReferenceCount(otfFile)) + // Automatically loads a generator for the font: + assertEquals(listOf(storage.getIdentifier("$otfFile.gen")), + storage.getDependencies(otfFile)) + assertTrue(storage.isLoaded("$otfFile.gen")) + + storage.dispose() } @Test - fun `should use FreeType loader to load TTF file into BitmapFont`() = `coroutine test`(concurrencyLevel = 1) { async -> - // Note that this method uses "raw" AssetStorage API without font loading utilities. - val assetStorage = assetStorage() - assetStorage.registerFreeTypeFontLoaders() + fun `should use FreeType loader to load TTF file into BitmapFont`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() + + // When: + val asset = runBlocking { + // Note that this method uses "raw" AssetStorage API without font loading utilities. + // Without the freeTypeFontParameters, this will fail to load. + storage.load(ttfFile, parameters = freeTypeFontParameters(ttfFile)) + } - async { - val asset = assetStorage.load(ttfFile, parameters = freeTypeFontParameters(ttfFile)) + // Then: + assertTrue(storage.isLoaded(ttfFile)) + assertSame(asset, storage.get(ttfFile)) + assertEquals(1, storage.getReferenceCount(ttfFile)) + // Automatically loads a generator for the font: + assertEquals(listOf(storage.getIdentifier("$ttfFile.gen")), + storage.getDependencies(ttfFile)) + assertTrue(storage.isLoaded("$ttfFile.gen")) - val font = assetStorage.get(ttfFile) - assertTrue(font is BitmapFont) - assertSame(font, asset) - } + storage.dispose() } + @Test - fun `should load OTF file into FreeTypeFontGenerator`() = `coroutine test`(concurrencyLevel = 1) { async -> - val assetStorage = assetStorage() - assetStorage.registerFreeTypeFontLoaders() + fun `should load OTF file into FreeTypeFontGenerator`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() - async { - val fontGenerator = assetStorage.load(otfFile) + // When: + val asset = runBlocking { storage.load(otfFile) } - assertNotNull(fontGenerator) - assertSame(fontGenerator, assetStorage.get(otfFile)) - } + // Then: + assertTrue(storage.isLoaded(otfFile)) + assertSame(asset, storage.get(otfFile)) + assertEquals(1, storage.getReferenceCount(otfFile)) + assertEquals(emptyList(), storage.getDependencies(otfFile)) + + storage.dispose() } @Test - fun `should load TTF file into FreeTypeFontGenerator`() = `coroutine test`(concurrencyLevel = 1) { async -> - val assetStorage = assetStorage() - assetStorage.registerFreeTypeFontLoaders() + fun `should load TTF file into FreeTypeFontGenerator`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() - async { - val fontGenerator = assetStorage.load(ttfFile) + // When: + val asset = runBlocking { storage.load(ttfFile) } - assertNotNull(fontGenerator) - assertSame(fontGenerator, assetStorage.get(ttfFile)) - } - } + // Then: + assertTrue(storage.isLoaded(ttfFile)) + assertSame(asset, storage.get(ttfFile)) + assertEquals(1, storage.getReferenceCount(ttfFile)) + assertEquals(emptyList(), storage.getDependencies(ttfFile)) - @Before - fun `setup LibGDX`() { - LwjglNativesLoader.load() - Gdx.gl = mock() - Gdx.gl20 = Gdx.gl - Gdx.graphics = mock() - Gdx.files = LwjglFiles() + storage.dispose() } - @After - fun `cleanup LibGDX`() { - Gdx.gl = null - Gdx.gl20 = null - Gdx.files = null - Gdx.graphics = null - `destroy coroutines context`() + @Test + fun `should allow to load BitmapFont and FreeTypeFontGenerator assets in parallel`() { + // Given: + val storage = AssetStorage( + useDefaultLoaders = false, + fileResolver = ClasspathFileHandleResolver(), + asyncContext = newAsyncContext(threads = 4) + ) + storage.registerFreeTypeFontLoaders() + + // When: + runBlocking { + val otf = async { storage.loadFreeTypeFont(otfFile) } + val ttf = async { storage.loadFreeTypeFont(ttfFile) } + val otfGenerator = async { storage.load(otfFile) } + val ttfGenerator = async { storage.load(ttfFile) } + + otf.await(); ttf.await(); otfGenerator.await(); ttfGenerator.await() + } + + // Then: + assertTrue(storage.isLoaded(otfFile)) + assertTrue(storage.isLoaded(ttfFile)) + assertTrue(storage.isLoaded(otfFile)) + assertTrue(storage.isLoaded(ttfFile)) + assertTrue(storage.isLoaded("$otfFile.gen")) + assertTrue(storage.isLoaded("$ttfFile.gen")) + + assertEquals(1, storage.getReferenceCount(otfFile)) + assertEquals(1, storage.getReferenceCount(ttfFile)) + assertEquals(1, storage.getReferenceCount(otfFile)) + assertEquals(1, storage.getReferenceCount(ttfFile)) + assertEquals(1, storage.getReferenceCount("$otfFile.gen")) + assertEquals(1, storage.getReferenceCount("$ttfFile.gen")) + + storage.dispose() } - private fun assetStorage() = AssetStorage(fileResolver = ClasspathFileHandleResolver()) + @Test + fun `should dispose of multiple font assets without errors`() { + // Given: + val storage = AssetStorage(useDefaultLoaders = false, fileResolver = ClasspathFileHandleResolver()) + storage.registerFreeTypeFontLoaders() + val assets = listOf( + storage.getAssetDescriptor(otfFile, parameters = freeTypeFontParameters(otfFile)), + storage.getAssetDescriptor(ttfFile, parameters = freeTypeFontParameters(otfFile)), + storage.getAssetDescriptor(otfFile), + storage.getAssetDescriptor(ttfFile) + ) + runBlocking { + assets.forEach { + storage.load(it) + assertTrue(storage.isLoaded(it)) + } + } + + // When: + storage.dispose() + + // Then: + assets.forEach { + assertFalse(it in storage) + assertFalse(storage.isLoaded(it)) + assertEquals(0, storage.getReferenceCount(it)) + assertEquals(emptyList(), storage.getDependencies(it)) + shouldThrow { + storage[it] + } + } + } } diff --git a/scene2d/README.md b/scene2d/README.md index 754f5385..076f2079 100644 --- a/scene2d/README.md +++ b/scene2d/README.md @@ -361,8 +361,9 @@ table { #### Synergy -Pair this library with [`ktx-style`](../style) for type-safe styles building and [`ktx-actors`](../actors) for useful -extension methods for `Scene2D` API. [`ktx-assets`](../assets) might help with `Skin` management. +Pair this library with [`ktx-style`](../style) for type-safe actor styles building and [`ktx-actors`](../actors) +for useful extension methods for `Scene2D` API. [`ktx-assets`](../assets) or [`ktx-assets-async`](../assets-async) +might help with `Skin` loading and management. ### Alternatives diff --git a/settings.gradle b/settings.gradle index 9c5f8409..99782aed 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,12 +3,14 @@ include( 'app', 'ashley', 'assets', + 'assets-async', 'async', 'box2d', 'collections', 'json', 'graphics', 'freetype', + 'freetype-async', 'i18n', 'inject', 'log', diff --git a/style/README.md b/style/README.md index 77d5bcce..fbb2e08a 100644 --- a/style/README.md +++ b/style/README.md @@ -338,6 +338,11 @@ The advantage of using an `enum` over a "standard" singleton (`object`) with `St constants is that you can easily extract a list of all values from an `enum`, while getting all fields from an object or constants from a package is not trivial. +#### Synergy + +[`ktx-assets`](../assets) or [`ktx-assets-async`](../assets-async) might prove useful for loading and management +of `Skin` assets including `Textures` and `TextureAtlases`. + ### Alternatives - Default LibGDX JSON skin loading mechanism allows to customize `Skin` instances thanks to reflection. Apart from the