Skip to content

Commit

Permalink
feat: AssetsBundle can be customized in Images and AssetsCache. (#2807)
Browse files Browse the repository at this point in the history
Previously, `Images` and `AssetsCache` would always try to fetch assets
from the `rootBundle`.

With this PR, we can have instances of those classes that uses a custom
`AssetsBundle`. When this gets merged, we could consider refactoring
flame_network_assets to use this new feature and allow Flame users to
use the same API
to load both local and network assets.
  • Loading branch information
erickzanardo committed Oct 9, 2023
1 parent 2df90c9 commit a23f80e
Show file tree
Hide file tree
Showing 8 changed files with 149 additions and 46 deletions.
7 changes: 6 additions & 1 deletion doc/flame/structure.md
Expand Up @@ -39,5 +39,10 @@ flutter:
```

If you want to change this structure, this is possible by using the `prefix` parameter and creating
your instances of `AssetsCache`, `ImagesCache`, `AudioCache`, and `SoundPool`s, instead of using the
your instances of `AssetsCache`, `Images`, and `AudioCache`, instead of using the
global ones provided by Flame.

Additionally, `AssetsCache` and `Images` can receive a custom
[`AssetBundle`](https://api.flutter.dev/flutter/services/AssetBundle-class.html).
This can be used to make Flame look for assets in a different location other the `rootBundle`,
like the file system for example.
23 changes: 17 additions & 6 deletions packages/flame/lib/src/cache/assets_cache.dart
@@ -1,16 +1,24 @@
import 'dart:convert';
import 'dart:typed_data';

import 'package:flutter/services.dart' show rootBundle;
import 'package:flame/flame.dart';
import 'package:flutter/services.dart' show AssetBundle;

/// A class that loads, and caches files.
///
/// It automatically looks for files in the `assets` directory.
class AssetsCache {
final String prefix;
final Map<String, _Asset<dynamic>> _files = {};
AssetsCache({
this.prefix = 'assets/',
AssetBundle? bundle,
}) : bundle = bundle ?? Flame.bundle;

/// The [AssetBundle] from which assets are loaded.
/// defaults to [Flame.bundle].
AssetBundle bundle;

AssetsCache({this.prefix = 'assets/'});
String prefix;
final Map<String, _Asset<dynamic>> _files = {};

/// Removes the file from the cache.
void clear(String file) {
Expand All @@ -22,6 +30,9 @@ class AssetsCache {
_files.clear();
}

/// Returns the number of files in the cache.
int get cacheCount => _files.length;

/// Reads a file from assets folder.
Future<String> readFile(String fileName) async {
if (!_files.containsKey(fileName)) {
Expand Down Expand Up @@ -53,12 +64,12 @@ class AssetsCache {
}

Future<_StringAsset> _readFile(String fileName) async {
final string = await rootBundle.loadString('$prefix$fileName');
final string = await bundle.loadString('$prefix$fileName');
return _StringAsset(string);
}

Future<_BinaryAsset> _readBinary(String fileName) async {
final data = await rootBundle.load('$prefix$fileName');
final data = await bundle.load('$prefix$fileName');
final bytes = Uint8List.view(data.buffer);
return _BinaryAsset(bytes);
}
Expand Down
16 changes: 11 additions & 5 deletions packages/flame/lib/src/cache/images.dart
Expand Up @@ -7,12 +7,18 @@ import 'package:flutter/painting.dart';
import 'package:flutter/services.dart';

class Images {
Images({String prefix = 'assets/images/'}) {
this.prefix = prefix;
}
Images({
String prefix = 'assets/images/',
AssetBundle? bundle,
}) : _prefix = prefix,
bundle = bundle ?? Flame.bundle;

final Map<String, _ImageAsset> _assets = {};

/// The [AssetBundle] from which images are loaded.
/// defaults to [Flame.bundle].
AssetBundle bundle;

/// Path prefix to the project's directory with images.
///
/// This path is relative to the project's root, and the default prefix is
Expand Down Expand Up @@ -126,7 +132,7 @@ class Images {
/// Loads all images in the [prefix]ed path that are matching the specified
/// pattern.
Future<List<Image>> loadAllFromPattern(Pattern pattern) async {
final manifestContent = await rootBundle.loadString('AssetManifest.json');
final manifestContent = await bundle.loadString('AssetManifest.json');
final manifestMap = json.decode(manifestContent) as Map<String, dynamic>;
final imagePaths = manifestMap.keys.where((path) {
return path.startsWith(_prefix) && path.toLowerCase().contains(pattern);
Expand Down Expand Up @@ -160,7 +166,7 @@ class Images {
}

Future<Image> _fetchToMemory(String name) async {
final data = await Flame.bundle.load('$_prefix$name');
final data = await bundle.load('$_prefix$name');
final bytes = Uint8List.view(data.buffer);
return decodeImageFromList(bytes);
}
Expand Down
26 changes: 15 additions & 11 deletions packages/flame/test/cache/assets_cache_test.dart
Expand Up @@ -7,7 +7,7 @@ import 'package:mocktail/mocktail.dart';

import '../fixtures/fixture_reader.dart';

class _AssetsCacheMock extends Mock implements AssetsCache {}
class _MockAssetBundle extends Mock implements AssetBundle {}

void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Expand Down Expand Up @@ -50,9 +50,8 @@ void main() {
final file = await assetsCache.readFile(fileName);
expect(file, isA<String>());

final assetsCacheMock = _AssetsCacheMock();
assetsCacheMock.clear(fileName);
verify(() => assetsCacheMock.clear(fileName)).called(1);
assetsCache.clear(fileName);
expect(assetsCache.cacheCount, equals(0));
});

test('clearCache', () async {
Expand All @@ -62,14 +61,8 @@ void main() {
final file = await assetsCache.readFile(fileName);
expect(file, isA<String>());

final assetsCacheMock = _AssetsCacheMock();
assetsCacheMock.clearCache();
verify(assetsCacheMock.clearCache).called(1);

// If all file was not clear from cache then it will not readBinaryFile
assetsCache.clearCache();
final fileTxtAsBinary = await assetsCache.readBinaryFile(fileName);
expect(fileTxtAsBinary, isA<Uint8List>());
expect(assetsCache.cacheCount, equals(0));
});

testWithFlameGame(
Expand All @@ -86,5 +79,16 @@ void main() {
expect(game.assets, equals(Flame.assets));
},
);

test('bundle can be overridden', () async {
final bundle = _MockAssetBundle();
when(() => bundle.loadString(any())).thenAnswer((_) async => 'Two ducks');

final cache = AssetsCache(bundle: bundle);

final result = await cache.readFile('duck_count');
expect(result, equals('Two ducks'));
verify(() => bundle.loadString('assets/duck_count')).called(1);
});
});
}
22 changes: 22 additions & 0 deletions packages/flame/test/cache/images_test.dart
@@ -1,12 +1,16 @@
import 'dart:convert';
import 'dart:ui';

import 'package:collection/collection.dart';
import 'package:flame/cache.dart';
import 'package:flame/flame.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

class _MockAssetBundle extends Mock implements AssetBundle {}

void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// A simple 1x1 pixel encoded as base64 - just so that we have something to
Expand Down Expand Up @@ -125,6 +129,24 @@ void main() {
expect(images.fromCache('image1'), isNotNull);
expect(images.fromCache('image2'), isNotNull);
});

test('can have its bundle overridden', () async {
final bundle = _MockAssetBundle();
when(() => bundle.load(any())).thenAnswer(
(_) async {
final list = base64Decode(pixel.split(',').last);
return ByteData.view(list.buffer);
},
);

final images = Images(bundle: bundle);
final image = await images.load('pixel.png');

expect(image.width, equals(1));
expect(image.height, equals(1));

verify(() => bundle.load('assets/images/pixel.png')).called(1);
});
});
}

Expand Down
6 changes: 6 additions & 0 deletions packages/flame_tiled/lib/src/tiled_component.dart
@@ -1,10 +1,12 @@
import 'dart:ui';

import 'package:collection/collection.dart';
import 'package:flame/cache.dart';
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flame_tiled/src/renderable_tile_map.dart';
import 'package:flame_tiled/src/tile_atlas.dart';
import 'package:flutter/services.dart';
import 'package:meta/meta.dart';
import 'package:tiled/tiled.dart';

Expand Down Expand Up @@ -104,6 +106,8 @@ class TiledComponent<T extends FlameGame> extends PositionComponent
String prefix = 'assets/tiles/',
int? priority,
bool? ignoreFlip,
AssetBundle? bundle,
Images? images,
}) async {
return TiledComponent(
await RenderableTiledMap.fromFile(
Expand All @@ -113,6 +117,8 @@ class TiledComponent<T extends FlameGame> extends PositionComponent
atlasMaxY: atlasMaxY,
ignoreFlip: ignoreFlip,
prefix: prefix,
bundle: bundle,
images: images,
),
priority: priority,
);
Expand Down
31 changes: 25 additions & 6 deletions packages/flame_tiled/test/tile_atlas_test.dart
@@ -1,7 +1,8 @@
import 'package:flame/cache.dart';
import 'package:flame/components.dart';
import 'package:flame/flame.dart';
import 'package:flame_tiled/flame_tiled.dart';
import 'package:flame_tiled/src/tile_atlas.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

import 'test_asset_bundle.dart';
Expand All @@ -23,9 +24,11 @@ void main() {
});

group('loadImages', () {
late AssetBundle bundle;

setUp(() {
TiledAtlas.atlasMap.clear();
Flame.bundle = TestAssetBundle(
bundle = TestAssetBundle(
imageNames: [
'images/blue.png',
'images/purple_rock.png',
Expand All @@ -47,6 +50,7 @@ void main() {
test('handles empty map', () async {
final atlas = await TiledAtlas.fromTiledMap(
TiledMap(height: 1, tileHeight: 1, tileWidth: 1, width: 1),
images: Images(bundle: bundle),
);

expect(atlas.atlas, isNull);
Expand Down Expand Up @@ -76,8 +80,10 @@ void main() {
);

test('returns single image atlas for simple map', () async {
final images = Images(bundle: bundle);
final atlas = await TiledAtlas.fromTiledMap(
simpleMap,
images: images,
);

expect(atlas.offsets, hasLength(1));
Expand All @@ -86,7 +92,7 @@ void main() {
expect(atlas.atlas!.height, 74);
expect(atlas.key, 'images/green.png');

expect(Flame.images.containsKey('images/green.png'), isTrue);
expect(images.containsKey('images/green.png'), isTrue);

expect(
await imageToPng(atlas.atlas!),
Expand All @@ -97,9 +103,11 @@ void main() {
test('returns cached atlas', () async {
final atlas1 = await TiledAtlas.fromTiledMap(
simpleMap,
images: Images(bundle: bundle),
);
final atlas2 = await TiledAtlas.fromTiledMap(
simpleMap,
images: Images(bundle: bundle),
);

expect(atlas1, isNot(same(atlas2)));
Expand All @@ -108,8 +116,12 @@ void main() {
});

test('packs complex maps with multiple images', () async {
final component =
await TiledComponent.load('isometric_plain.tmx', Vector2(128, 74));
final component = await TiledComponent.load(
'isometric_plain.tmx',
Vector2(128, 74),
bundle: bundle,
images: Images(bundle: bundle),
);

final atlas = TiledAtlas.atlasMap.values.first;
expect(
Expand All @@ -125,6 +137,7 @@ void main() {
test('clearing cache', () async {
await TiledAtlas.fromTiledMap(
simpleMap,
images: Images(bundle: bundle),
);

expect(TiledAtlas.atlasMap.isNotEmpty, true);
Expand All @@ -136,9 +149,11 @@ void main() {
});

group('Single tileset map', () {
late AssetBundle bundle;

setUp(() {
TiledAtlas.atlasMap.clear();
Flame.bundle = TestAssetBundle(
bundle = TestAssetBundle(
imageNames: [
'4_color_sprite.png',
],
Expand All @@ -156,10 +171,14 @@ void main() {
TiledComponent.load(
'single_tile_map_1.tmx',
Vector2(16, 16),
bundle: bundle,
images: Images(bundle: bundle),
),
TiledComponent.load(
'single_tile_map_2.tmx',
Vector2(16, 16),
bundle: bundle,
images: Images(bundle: bundle),
),
]);

Expand Down

0 comments on commit a23f80e

Please sign in to comment.