Merge branch 'main' into release

release
Tom Arra 2 years ago
commit 135bd31e29

@ -42,7 +42,8 @@
"unawaited",
"unfocus",
"unlayered",
"vsync"
"vsync",
"microtask"
],
"ignorePaths": [
".github/workflows/**"

@ -51,7 +51,7 @@ class CameraFocusingBehavior extends Component
position: _foci[GameStatus.waiting]?.position ?? Vector2(0, -112),
),
GameStatus.playing: _FocusData(
zoom: size.y / 165,
zoom: size.y / 160,
position: _foci[GameStatus.playing]?.position ?? Vector2(0, -7.8),
),
GameStatus.gameOver: _FocusData(

@ -2,6 +2,8 @@ import 'package:flame/components.dart';
import 'package:flame_bloc/flame_bloc.dart';
import 'package:pinball/select_character/select_character.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
import 'package:platform_helper/platform_helper.dart';
/// Updates the [ArcadeBackground] and launch [Ball] to reflect character
/// selections.
@ -11,12 +13,14 @@ class CharacterSelectionBehavior extends Component
HasGameRef {
@override
void onNewState(CharacterThemeState state) {
gameRef
.descendants()
.whereType<ArcadeBackground>()
.single
.bloc
.onCharacterSelected(state.characterTheme);
if (!readProvider<PlatformHelper>().isMobile) {
gameRef
.descendants()
.whereType<ArcadeBackground>()
.single
.bloc
.onCharacterSelected(state.characterTheme);
}
gameRef
.descendants()
.whereType<Ball>()

@ -42,7 +42,7 @@ class _BarrierBehindDino extends BodyComponent {
Body createBody() {
final shape = EdgeShape()
..set(
Vector2(25.3, -14.2),
Vector2(24.2, -14.8),
Vector2(25.3, -7.7),
);

@ -42,7 +42,7 @@ class FlutterForest extends Component with ZIndex {
)..initialPosition = Vector2(21.8, -46.75),
DashAnimatronic(
children: [
AnimatronicLoopingBehavior(animationCoolDown: 4),
AnimatronicLoopingBehavior(animationCoolDown: 11),
],
)..position = Vector2(20, -66),
FlutterForestBonusBehavior(),

@ -5,7 +5,6 @@ import 'package:pinball/select_character/select_character.dart';
import 'package:pinball_audio/pinball_audio.dart';
import 'package:pinball_components/pinball_components.dart';
import 'package:pinball_flame/pinball_flame.dart';
import 'package:platform_helper/platform_helper.dart';
/// Listens to the [GameBloc] and updates the game accordingly.
class GameBlocStatusListener extends Component
@ -68,31 +67,25 @@ class GameBlocStatusListener extends Component
gameRef.descendants().whereType<Signpost>().single.bloc.onReset();
}
void _addPlungerBehaviors(Plunger plunger) {
final platformHelper = readProvider<PlatformHelper>();
const pullingStrength = 7.0;
final provider =
plunger.firstChild<FlameBlocProvider<PlungerCubit, PlungerState>>()!;
if (platformHelper.isMobile) {
provider.add(
PlungerAutoPullingBehavior(strength: pullingStrength),
);
} else {
provider.addAll(
void _addPlungerBehaviors(Plunger plunger) => plunger
.firstChild<FlameBlocProvider<PlungerCubit, PlungerState>>()!
.addAll(
[
PlungerKeyControllingBehavior(),
PlungerPullingBehavior(strength: pullingStrength),
PlungerPullingBehavior(strength: 7),
PlungerAutoPullingBehavior(),
PlungerKeyControllingBehavior()
],
);
}
}
void _removePlungerBehaviors(Plunger plunger) {
plunger
.descendants()
.whereType<PlungerPullingBehavior>()
.forEach(plunger.remove);
plunger
.descendants()
.whereType<PlungerAutoPullingBehavior>()
.forEach(plunger.remove);
plunger
.descendants()
.whereType<PlungerKeyControllingBehavior>()

@ -35,7 +35,7 @@ class SparkyScorch extends Component {
)..initialPosition = Vector2(-3.3, -52.55),
SparkyAnimatronic(
children: [
AnimatronicLoopingBehavior(animationCoolDown: 3),
AnimatronicLoopingBehavior(animationCoolDown: 8),
],
)..position = Vector2(-14, -58.2),
SparkyComputer(

@ -155,19 +155,18 @@ class PinballGame extends PinballForge2DGame
@override
void onTapDown(int pointerId, TapDownInfo info) {
if (info.raw.kind == PointerDeviceKind.touch) {
if (info.raw.kind == PointerDeviceKind.touch &&
_gameBloc.state.status.isPlaying) {
final rocket = descendants().whereType<RocketSpriteComponent>().first;
final bounds = rocket.topLeftPosition & rocket.size;
// NOTE: As long as Flame does not have https://github.com/flame-engine/flame/issues/1586
// we need to check it at the highest level manually.
final tappedRocket = bounds.contains(info.eventPosition.game.toOffset());
if (tappedRocket) {
descendants()
.whereType<FlameBlocProvider<PlungerCubit, PlungerState>>()
.first
.bloc
.pulled();
.autoPulled();
} else {
final tappedLeftSide = info.eventPosition.widget.x < canvasSize.x / 2;
focusedBoardSide[pointerId] =

@ -2,10 +2,10 @@ import 'dart:math';
import 'package:audioplayers/audioplayers.dart';
import 'package:clock/clock.dart';
import 'package:flame_audio/audio_pool.dart';
import 'package:flame_audio/flame_audio.dart';
import 'package:flutter/material.dart';
import 'package:pinball_audio/gen/assets.gen.dart';
import 'package:pinball_audio/src/pinball_audio_pool.dart';
/// Sounds available to play.
enum PinballAudio {
@ -52,17 +52,8 @@ enum PinballAudio {
flipper,
}
/// Defines the contract of the creation of an [AudioPool].
typedef CreateAudioPool = Future<AudioPool> Function(
String sound, {
bool? repeating,
int? maxPlayers,
int? minPlayers,
String? prefix,
});
/// Defines the contract for playing a single audio.
typedef PlaySingleAudio = Future<void> Function(String, {double volume});
typedef PlaySingleAudio = Future<AudioPlayer> Function(String, {double volume});
/// Defines the contract for looping a single audio.
typedef LoopSingleAudio = Future<void> Function(String, {double volume});
@ -94,13 +85,20 @@ class _SimplePlayAudio extends _Audio {
final PlaySingleAudio playSingleAudio;
final String path;
final double? volume;
AudioPlayer? _player;
@override
Future<void> load() => preCacheSingleAudio(prefixFile(path));
@override
void play() {
playSingleAudio(prefixFile(path), volume: volume ?? 1);
Future<void> play() async {
final url = prefixFile(path);
final volume = this.volume ?? 1;
if (_player == null) {
_player = await playSingleAudio(url, volume: volume);
} else {
await _player!.play(url, volume: volume);
}
}
}
@ -153,81 +151,94 @@ class _SingleLoopAudio extends _LoopAudio {
class _SingleAudioPool extends _Audio {
_SingleAudioPool({
required this.path,
required this.createAudioPool,
required this.duration,
required this.maxPlayers,
required this.preCacheSingleAudio,
required this.playSingleAudio,
});
final String path;
final CreateAudioPool createAudioPool;
final int maxPlayers;
late AudioPool pool;
final Duration duration;
final PreCacheSingleAudio preCacheSingleAudio;
final PlaySingleAudio playSingleAudio;
late PinballAudioPool pool;
@override
Future<void> load() async {
pool = await createAudioPool(
prefixFile(path),
maxPlayers: maxPlayers,
prefix: '',
pool = PinballAudioPool(
path: prefixFile(path),
poolSize: maxPlayers,
preCacheSingleAudio: preCacheSingleAudio,
playSingleAudio: playSingleAudio,
duration: duration,
);
await pool.load();
}
@override
void play() => pool.start();
void play() => pool.play();
}
class _RandomABAudio extends _Audio {
_RandomABAudio({
required this.createAudioPool,
required this.preCacheSingleAudio,
required this.playSingleAudio,
required this.seed,
required this.audioAssetA,
required this.audioAssetB,
required this.duration,
this.volume,
});
final CreateAudioPool createAudioPool;
final PreCacheSingleAudio preCacheSingleAudio;
final PlaySingleAudio playSingleAudio;
final Random seed;
final String audioAssetA;
final String audioAssetB;
final Duration duration;
final double? volume;
late AudioPool audioA;
late AudioPool audioB;
late PinballAudioPool audioA;
late PinballAudioPool audioB;
@override
Future<void> load() async {
await Future.wait(
[
createAudioPool(
prefixFile(audioAssetA),
maxPlayers: 4,
prefix: '',
).then((pool) => audioA = pool),
createAudioPool(
prefixFile(audioAssetB),
maxPlayers: 4,
prefix: '',
).then((pool) => audioB = pool),
],
audioA = PinballAudioPool(
path: prefixFile(audioAssetA),
poolSize: 4,
preCacheSingleAudio: preCacheSingleAudio,
playSingleAudio: playSingleAudio,
duration: duration,
);
audioB = PinballAudioPool(
path: prefixFile(audioAssetB),
poolSize: 4,
preCacheSingleAudio: preCacheSingleAudio,
playSingleAudio: playSingleAudio,
duration: duration,
);
await Future.wait([audioA.load(), audioB.load()]);
}
@override
void play() {
(seed.nextBool() ? audioA : audioB).start(volume: volume ?? 1);
(seed.nextBool() ? audioA : audioB).play(volume: volume ?? 1);
}
}
class _ThrottledAudio extends _Audio {
class _ThrottledAudio extends _SimplePlayAudio {
_ThrottledAudio({
required this.preCacheSingleAudio,
required this.playSingleAudio,
required this.path,
required PreCacheSingleAudio preCacheSingleAudio,
required PlaySingleAudio playSingleAudio,
required String path,
required this.duration,
});
}) : super(
preCacheSingleAudio: preCacheSingleAudio,
playSingleAudio: playSingleAudio,
path: path,
);
final PreCacheSingleAudio preCacheSingleAudio;
final PlaySingleAudio playSingleAudio;
final String path;
final Duration duration;
DateTime? _lastPlayed;
@ -236,12 +247,12 @@ class _ThrottledAudio extends _Audio {
Future<void> load() => preCacheSingleAudio(prefixFile(path));
@override
void play() {
Future<void> play() async {
final now = clock.now();
if (_lastPlayed == null ||
(_lastPlayed != null && now.difference(_lastPlayed!) > duration)) {
_lastPlayed = now;
playSingleAudio(prefixFile(path));
await super.play();
}
}
}
@ -252,14 +263,12 @@ class _ThrottledAudio extends _Audio {
class PinballAudioPlayer {
/// {@macro pinball_audio_player}
PinballAudioPlayer({
CreateAudioPool? createAudioPool,
PlaySingleAudio? playSingleAudio,
LoopSingleAudio? loopSingleAudio,
PreCacheSingleAudio? preCacheSingleAudio,
ConfigureAudioCache? configureAudioCache,
Random? seed,
}) : _createAudioPool = createAudioPool ?? AudioPool.create,
_playSingleAudio = playSingleAudio ?? FlameAudio.audioCache.play,
}) : _playSingleAudio = playSingleAudio ?? FlameAudio.audioCache.play,
_loopSingleAudio = loopSingleAudio ?? FlameAudio.audioCache.loop,
_preCacheSingleAudio =
preCacheSingleAudio ?? FlameAudio.audioCache.load,
@ -279,10 +288,11 @@ class PinballAudioPlayer {
playSingleAudio: _playSingleAudio,
path: Assets.sfx.sparky,
),
PinballAudio.dino: _SimplePlayAudio(
PinballAudio.dino: _ThrottledAudio(
preCacheSingleAudio: _preCacheSingleAudio,
playSingleAudio: _playSingleAudio,
path: Assets.sfx.dino,
duration: const Duration(seconds: 6),
),
PinballAudio.dash: _SimplePlayAudio(
preCacheSingleAudio: _preCacheSingleAudio,
@ -307,8 +317,10 @@ class PinballAudioPlayer {
),
PinballAudio.flipper: _SingleAudioPool(
path: Assets.sfx.flipper,
createAudioPool: _createAudioPool,
maxPlayers: 2,
maxPlayers: 4,
preCacheSingleAudio: _preCacheSingleAudio,
playSingleAudio: _playSingleAudio,
duration: const Duration(milliseconds: 200),
),
PinballAudio.ioPinballVoiceOver: _SimplePlayAudio(
preCacheSingleAudio: _preCacheSingleAudio,
@ -321,17 +333,21 @@ class PinballAudioPlayer {
path: Assets.sfx.gameOverVoiceOver,
),
PinballAudio.bumper: _RandomABAudio(
createAudioPool: _createAudioPool,
preCacheSingleAudio: _preCacheSingleAudio,
playSingleAudio: _playSingleAudio,
seed: _seed,
audioAssetA: Assets.sfx.bumperA,
audioAssetB: Assets.sfx.bumperB,
duration: const Duration(seconds: 1),
volume: 0.6,
),
PinballAudio.kicker: _RandomABAudio(
createAudioPool: _createAudioPool,
preCacheSingleAudio: _preCacheSingleAudio,
playSingleAudio: _playSingleAudio,
seed: _seed,
audioAssetA: Assets.sfx.kickerA,
audioAssetB: Assets.sfx.kickerB,
duration: const Duration(seconds: 1),
volume: 0.6,
),
PinballAudio.cowMoo: _ThrottledAudio(
@ -349,8 +365,6 @@ class PinballAudioPlayer {
};
}
final CreateAudioPool _createAudioPool;
final PlaySingleAudio _playSingleAudio;
final LoopSingleAudio _loopSingleAudio;

@ -0,0 +1,87 @@
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:pinball_audio/pinball_audio.dart';
class _PlayerEntry {
_PlayerEntry({
required this.available,
required this.player,
});
bool available;
final AudioPlayer player;
}
/// {@template pinball_audio_pool}
/// Creates an audio player pool used to trigger many sounds at the same time.
/// {@endtemplate}
class PinballAudioPool {
/// {@macro pinball_audio_pool}
PinballAudioPool({
required this.path,
required this.poolSize,
required this.preCacheSingleAudio,
required this.playSingleAudio,
required this.duration,
});
/// Sounds path.
final String path;
/// Max size of this pool.
final int poolSize;
/// Function to cache audios.
final PreCacheSingleAudio preCacheSingleAudio;
/// Function to play audios.
final PlaySingleAudio playSingleAudio;
/// How long the sound lasts.
final Duration duration;
final List<_PlayerEntry> _players = [];
/// Loads the pool.
Future<void> load() async {
await preCacheSingleAudio(path);
}
/// Plays the pool.
Future<void> play({double volume = 1}) async {
AudioPlayer? player;
if (_players.length < poolSize) {
_players.add(
_PlayerEntry(
available: false,
player: player = await playSingleAudio(path, volume: volume),
),
);
} else {
final entries = _players.where((entry) => entry.available);
if (entries.isNotEmpty) {
final entry = entries.first..available = false;
player = entry.player;
unawaited(entry.player.play(path, volume: volume));
}
}
if (player != null) {
unawaited(
Future<void>.delayed(duration).then(
(_) {
_returnEntryAvailability(player!);
},
),
);
} else {}
}
void _returnEntryAvailability(
AudioPlayer player,
) {
_players.where((entry) => entry.player == player).single.available = true;
}
}

@ -0,0 +1,74 @@
// ignore_for_file: one_member_abstracts
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball_audio/src/pinball_audio_pool.dart';
class _MockAudioPlayer extends Mock implements AudioPlayer {}
class _MockPlaySingleAudio extends Mock {
Future<AudioPlayer> onCall(String path, {double volume});
}
abstract class _PreCacheSingleAudio {
Future<void> onCall(String path);
}
class _MockPreCacheSingleAudio extends Mock implements _PreCacheSingleAudio {}
void main() {
group('PinballAudioPool', () {
late _PreCacheSingleAudio preCacheSingleAudio;
late _MockPlaySingleAudio playSingleAudio;
late PinballAudioPool pool;
late AudioPlayer audioPlayer;
setUp(() {
preCacheSingleAudio = _MockPreCacheSingleAudio();
when(() => preCacheSingleAudio.onCall(any())).thenAnswer((_) async {});
audioPlayer = _MockAudioPlayer();
when(() => audioPlayer.play(any(), volume: any(named: 'volume')))
.thenAnswer((_) async => 1);
playSingleAudio = _MockPlaySingleAudio();
when(() => playSingleAudio.onCall(any(), volume: any(named: 'volume')))
.thenAnswer((_) async => audioPlayer);
pool = PinballAudioPool(
path: 'path',
poolSize: 1,
preCacheSingleAudio: preCacheSingleAudio.onCall,
playSingleAudio: playSingleAudio.onCall,
duration: const Duration(milliseconds: 10),
);
});
test('pre cache the sound', () async {
await pool.load();
verify(() => preCacheSingleAudio.onCall('path')).called(1);
});
test('plays a fresh sound', () async {
await pool.load();
await pool.play();
verify(
() => playSingleAudio.onCall(
'path',
volume: any(named: 'volume'),
),
).called(1);
});
test('plays from the pool after it returned', () async {
await pool.load();
await pool.play();
await Future<void>.delayed(const Duration(milliseconds: 12));
await pool.play();
verify(() => audioPlayer.play('path')).called(1);
});
});
}

@ -3,33 +3,22 @@ import 'dart:math';
import 'package:audioplayers/audioplayers.dart';
import 'package:clock/clock.dart';
import 'package:flame_audio/audio_pool.dart';
import 'package:flame_audio/flame_audio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball_audio/gen/assets.gen.dart';
import 'package:pinball_audio/pinball_audio.dart';
class _MockAudioPool extends Mock implements AudioPool {}
class _MockAudioCache extends Mock implements AudioCache {}
class _MockCreateAudioPool extends Mock {
Future<AudioPool> onCall(
String sound, {
bool? repeating,
int? maxPlayers,
int? minPlayers,
String? prefix,
});
}
class _MockAudioPlayer extends Mock implements AudioPlayer {}
class _MockConfigureAudioCache extends Mock {
void onCall(AudioCache cache);
}
class _MockPlaySingleAudio extends Mock {
Future<void> onCall(String path, {double volume});
Future<AudioPlayer> onCall(String path, {double volume});
}
class _MockLoopSingleAudio extends Mock {
@ -48,7 +37,6 @@ class _MockClock extends Mock implements Clock {}
void main() {
group('PinballAudio', () {
late _MockCreateAudioPool createAudioPool;
late _MockConfigureAudioCache configureAudioCache;
late _MockPlaySingleAudio playSingleAudio;
late _MockLoopSingleAudio loopSingleAudio;
@ -61,21 +49,12 @@ void main() {
});
setUp(() {
createAudioPool = _MockCreateAudioPool();
when(
() => createAudioPool.onCall(
any(),
maxPlayers: any(named: 'maxPlayers'),
prefix: any(named: 'prefix'),
),
).thenAnswer((_) async => _MockAudioPool());
configureAudioCache = _MockConfigureAudioCache();
when(() => configureAudioCache.onCall(any())).thenAnswer((_) {});
playSingleAudio = _MockPlaySingleAudio();
when(() => playSingleAudio.onCall(any(), volume: any(named: 'volume')))
.thenAnswer((_) async {});
.thenAnswer((_) async => _MockAudioPlayer());
loopSingleAudio = _MockLoopSingleAudio();
when(() => loopSingleAudio.onCall(any(), volume: any(named: 'volume')))
@ -88,7 +67,6 @@ void main() {
audioPlayer = PinballAudioPlayer(
configureAudioCache: configureAudioCache.onCall,
createAudioPool: createAudioPool.onCall,
playSingleAudio: playSingleAudio.onCall,
loopSingleAudio: loopSingleAudio.onCall,
preCacheSingleAudio: preCacheSingleAudio.onCall,
@ -101,64 +79,6 @@ void main() {
});
group('load', () {
test('creates the bumpers pools', () async {
await Future.wait(
audioPlayer.load().map((loadableBuilder) => loadableBuilder()),
);
verify(
() => createAudioPool.onCall(
'packages/pinball_audio/${Assets.sfx.bumperA}',
maxPlayers: 4,
prefix: '',
),
).called(1);
verify(
() => createAudioPool.onCall(
'packages/pinball_audio/${Assets.sfx.bumperB}',
maxPlayers: 4,
prefix: '',
),
).called(1);
});
test('creates the kicker pools', () async {
await Future.wait(
audioPlayer.load().map((loadableBuilder) => loadableBuilder()),
);
verify(
() => createAudioPool.onCall(
'packages/pinball_audio/${Assets.sfx.kickerA}',
maxPlayers: 4,
prefix: '',
),
).called(1);
verify(
() => createAudioPool.onCall(
'packages/pinball_audio/${Assets.sfx.kickerB}',
maxPlayers: 4,
prefix: '',
),
).called(1);
});
test('creates the flipper pool', () async {
await Future.wait(
audioPlayer.load().map((loadableBuilder) => loadableBuilder()),
);
verify(
() => createAudioPool.onCall(
'packages/pinball_audio/${Assets.sfx.flipper}',
maxPlayers: 2,
prefix: '',
),
).called(1);
});
test('configures the audio cache instance', () async {
await Future.wait(
audioPlayer.load().map((loadableBuilder) => loadableBuilder()),
@ -170,7 +90,6 @@ void main() {
test('sets the correct prefix', () async {
audioPlayer = PinballAudioPlayer(
createAudioPool: createAudioPool.onCall,
playSingleAudio: playSingleAudio.onCall,
preCacheSingleAudio: preCacheSingleAudio.onCall,
);
@ -186,6 +105,26 @@ void main() {
audioPlayer.load().map((loadableBuilder) => loadableBuilder()),
);
verify(
() => preCacheSingleAudio
.onCall('packages/pinball_audio/assets/sfx/bumper_a.mp3'),
).called(1);
verify(
() => preCacheSingleAudio
.onCall('packages/pinball_audio/assets/sfx/bumper_b.mp3'),
).called(1);
verify(
() => preCacheSingleAudio
.onCall('packages/pinball_audio/assets/sfx/kicker_a.mp3'),
).called(1);
verify(
() => preCacheSingleAudio
.onCall('packages/pinball_audio/assets/sfx/kicker_b.mp3'),
).called(1);
verify(
() => preCacheSingleAudio
.onCall('packages/pinball_audio/assets/sfx/flipper.mp3'),
).called(1);
verify(
() => preCacheSingleAudio
.onCall('packages/pinball_audio/assets/sfx/google.mp3'),
@ -236,33 +175,6 @@ void main() {
});
group('bumper', () {
late AudioPool bumperAPool;
late AudioPool bumperBPool;
setUp(() {
bumperAPool = _MockAudioPool();
when(() => bumperAPool.start(volume: any(named: 'volume')))
.thenAnswer((_) async => () {});
when(
() => createAudioPool.onCall(
'packages/pinball_audio/${Assets.sfx.bumperA}',
maxPlayers: any(named: 'maxPlayers'),
prefix: any(named: 'prefix'),
),
).thenAnswer((_) async => bumperAPool);
bumperBPool = _MockAudioPool();
when(() => bumperBPool.start(volume: any(named: 'volume')))
.thenAnswer((_) async => () {});
when(
() => createAudioPool.onCall(
'packages/pinball_audio/${Assets.sfx.bumperB}',
maxPlayers: any(named: 'maxPlayers'),
prefix: any(named: 'prefix'),
),
).thenAnswer((_) async => bumperBPool);
});
group('when seed is true', () {
test('plays the bumper A sound pool', () async {
when(seed.nextBool).thenReturn(true);
@ -271,7 +183,12 @@ void main() {
);
audioPlayer.play(PinballAudio.bumper);
verify(() => bumperAPool.start(volume: 0.6)).called(1);
verify(
() => playSingleAudio.onCall(
'packages/pinball_audio/${Assets.sfx.bumperA}',
volume: 0.6,
),
).called(1);
});
});
@ -283,39 +200,17 @@ void main() {
);
audioPlayer.play(PinballAudio.bumper);
verify(() => bumperBPool.start(volume: 0.6)).called(1);
verify(
() => playSingleAudio.onCall(
'packages/pinball_audio/${Assets.sfx.bumperB}',
volume: 0.6,
),
).called(1);
});
});
});
group('kicker', () {
late AudioPool kickerAPool;
late AudioPool kickerBPool;
setUp(() {
kickerAPool = _MockAudioPool();
when(() => kickerAPool.start(volume: any(named: 'volume')))
.thenAnswer((_) async => () {});
when(
() => createAudioPool.onCall(
'packages/pinball_audio/${Assets.sfx.kickerA}',
maxPlayers: any(named: 'maxPlayers'),
prefix: any(named: 'prefix'),
),
).thenAnswer((_) async => kickerAPool);
kickerBPool = _MockAudioPool();
when(() => kickerBPool.start(volume: any(named: 'volume')))
.thenAnswer((_) async => () {});
when(
() => createAudioPool.onCall(
'packages/pinball_audio/${Assets.sfx.kickerB}',
maxPlayers: any(named: 'maxPlayers'),
prefix: any(named: 'prefix'),
),
).thenAnswer((_) async => kickerBPool);
});
group('when seed is true', () {
test('plays the kicker A sound pool', () async {
when(seed.nextBool).thenReturn(true);
@ -324,7 +219,12 @@ void main() {
);
audioPlayer.play(PinballAudio.kicker);
verify(() => kickerAPool.start(volume: 0.6)).called(1);
verify(
() => playSingleAudio.onCall(
'packages/pinball_audio/${Assets.sfx.kickerA}',
volume: 0.6,
),
).called(1);
});
});
@ -336,27 +236,17 @@ void main() {
);
audioPlayer.play(PinballAudio.kicker);
verify(() => kickerBPool.start(volume: 0.6)).called(1);
verify(
() => playSingleAudio.onCall(
'packages/pinball_audio/${Assets.sfx.kickerB}',
volume: 0.6,
),
).called(1);
});
});
});
group('flipper', () {
late AudioPool pool;
setUp(() {
pool = _MockAudioPool();
when(() => pool.start(volume: any(named: 'volume')))
.thenAnswer((_) async => () {});
when(
() => createAudioPool.onCall(
'packages/pinball_audio/${Assets.sfx.flipper}',
maxPlayers: any(named: 'maxPlayers'),
prefix: any(named: 'prefix'),
),
).thenAnswer((_) async => pool);
});
test('plays the flipper sound pool', () async {
when(seed.nextBool).thenReturn(true);
await Future.wait(
@ -364,7 +254,12 @@ void main() {
);
audioPlayer.play(PinballAudio.flipper);
verify(() => pool.start()).called(1);
verify(
() => playSingleAudio.onCall(
'packages/pinball_audio/${Assets.sfx.flipper}',
volume: any(named: 'volume'),
),
).called(1);
});
});
@ -376,14 +271,21 @@ void main() {
audioPlayer.play(PinballAudio.cowMoo);
verify(
() => playSingleAudio
.onCall('packages/pinball_audio/${Assets.sfx.cowMoo}'),
() => playSingleAudio.onCall(
'packages/pinball_audio/${Assets.sfx.cowMoo}',
volume: any(named: 'volume'),
),
).called(1);
});
test('only plays the sound again after 2 seconds', () async {
final clock = _MockClock();
await withClock(clock, () async {
final audioPlayerInstance = _MockAudioPlayer();
when(
() => playSingleAudio.onCall(any(), volume: any(named: 'volume')),
).thenAnswer((_) async => audioPlayerInstance);
when(clock.now).thenReturn(DateTime(2022));
await Future.wait(
audioPlayer.load().map((loadableBuilder) => loadableBuilder()),
@ -393,16 +295,20 @@ void main() {
..play(PinballAudio.cowMoo);
verify(
() => playSingleAudio
.onCall('packages/pinball_audio/${Assets.sfx.cowMoo}'),
() => playSingleAudio.onCall(
'packages/pinball_audio/${Assets.sfx.cowMoo}',
volume: any(named: 'volume'),
),
).called(1);
when(clock.now).thenReturn(DateTime(2022, 1, 1, 1, 2));
audioPlayer.play(PinballAudio.cowMoo);
verify(
() => playSingleAudio
.onCall('packages/pinball_audio/${Assets.sfx.cowMoo}'),
() => playSingleAudio.onCall(
'packages/pinball_audio/${Assets.sfx.cowMoo}',
volume: any(named: 'volume'),
),
).called(1);
});
});
@ -422,6 +328,44 @@ void main() {
),
).called(1);
});
test('uses the cached player on the second time', () async {
final audioPlayerCache = _MockAudioPlayer();
when(() => audioPlayerCache.play(any(), volume: any(named: 'volume')))
.thenAnswer((_) async => 0);
when(() => playSingleAudio.onCall(any(), volume: any(named: 'volume')))
.thenAnswer((_) async => audioPlayerCache);
audioPlayer = PinballAudioPlayer(
configureAudioCache: configureAudioCache.onCall,
playSingleAudio: playSingleAudio.onCall,
loopSingleAudio: loopSingleAudio.onCall,
preCacheSingleAudio: preCacheSingleAudio.onCall,
seed: seed,
);
await Future.wait(
audioPlayer.load().map((loadableBuilder) => loadableBuilder()),
);
audioPlayer.play(PinballAudio.google);
verify(
() => playSingleAudio.onCall(
'packages/pinball_audio/${Assets.sfx.google}',
volume: any(named: 'volume'),
),
).called(1);
await Future.microtask(() {});
audioPlayer.play(PinballAudio.google);
verify(
() => audioPlayerCache.play(
'packages/pinball_audio/${Assets.sfx.google}',
volume: any(named: 'volume'),
),
).called(1);
});
});
group('sparky', () {
@ -454,6 +398,36 @@ void main() {
),
).called(1);
});
test('only plays the sound again after 6 seconds', () async {
final clock = _MockClock();
await withClock(clock, () async {
when(clock.now).thenReturn(DateTime(2022));
await Future.wait(
audioPlayer.load().map((loadableBuilder) => loadableBuilder()),
);
audioPlayer
..play(PinballAudio.dino)
..play(PinballAudio.dino);
verify(
() => playSingleAudio.onCall(
'packages/pinball_audio/${Assets.sfx.dino}',
volume: any(named: 'volume'),
),
).called(1);
when(clock.now).thenReturn(DateTime(2022, 1, 1, 1, 6));
audioPlayer.play(PinballAudio.dino);
verify(
() => playSingleAudio.onCall(
'packages/pinball_audio/${Assets.sfx.dino}',
volume: any(named: 'volume'),
),
).called(1);
});
});
});
group('android', () {

@ -28,14 +28,19 @@ class PlungerPullingBehavior extends Component
}
}
class PlungerAutoPullingBehavior extends PlungerPullingBehavior {
PlungerAutoPullingBehavior({
required double strength,
}) : super(strength: strength);
class PlungerAutoPullingBehavior extends Component
with FlameBlocReader<PlungerCubit, PlungerState> {
late final Plunger _plunger;
@override
Future<void> onLoad() async {
await super.onLoad();
_plunger = parent!.parent! as Plunger;
}
@override
void update(double dt) {
super.update(dt);
if (!bloc.state.isAutoPulling) return;
final joint = _plunger.body.joints.whereType<PrismaticJoint>().single;
final reachedBottom = joint.getJointTranslation() <= joint.getLowerLimit();

@ -8,4 +8,6 @@ class PlungerCubit extends Cubit<PlungerState> {
void pulled() => emit(PlungerState.pulling);
void released() => emit(PlungerState.releasing);
void autoPulled() => emit(PlungerState.autoPulling);
}

@ -4,9 +4,13 @@ enum PlungerState {
pulling,
releasing,
autoPulling,
}
extension PlungerStateX on PlungerState {
bool get isPulling => this == PlungerState.pulling;
bool get isPulling =>
this == PlungerState.pulling || this == PlungerState.autoPulling;
bool get isReleasing => this == PlungerState.releasing;
bool get isAutoPulling => this == PlungerState.autoPulling;
}

@ -132,6 +132,7 @@ class _PlungerSpriteAnimationGroupComponent
animations = {
PlungerState.releasing: pullAnimation.reversed(),
PlungerState.pulling: pullAnimation,
PlungerState.autoPulling: pullAnimation,
};
current = readBloc<PlungerCubit, PlungerState>().state;

@ -3,6 +3,7 @@
import 'dart:async';
import 'package:bloc_test/bloc_test.dart';
import 'package:flame/components.dart';
import 'package:flame_bloc/flame_bloc.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_test/flame_test.dart';
@ -12,7 +13,7 @@ import 'package:pinball_components/pinball_components.dart';
class _TestGame extends Forge2DGame {
Future<void> pump(
PlungerPullingBehavior behavior, {
Component behavior, {
PlungerCubit? plungerBloc,
}) async {
final plunger = Plunger.test();
@ -85,62 +86,28 @@ void main() {
group('PlungerAutoPullingBehavior', () {
test('can be instantiated', () {
expect(
PlungerAutoPullingBehavior(strength: 0),
PlungerAutoPullingBehavior(),
isA<PlungerAutoPullingBehavior>(),
);
});
flameTester.test('can be loaded', (game) async {
final behavior = PlungerAutoPullingBehavior(strength: 0);
final behavior = PlungerAutoPullingBehavior();
await game.pump(behavior);
expect(game.descendants(), contains(behavior));
});
flameTester.test(
"pulls while joint hasn't reached limit",
(game) async {
final plungerBloc = _MockPlungerCubit();
whenListen<PlungerState>(
plungerBloc,
Stream.value(PlungerState.pulling),
initialState: PlungerState.pulling,
);
const strength = 2.0;
final behavior = PlungerAutoPullingBehavior(
strength: strength,
);
await game.pump(
behavior,
plungerBloc: plungerBloc,
);
final plunger = behavior.ancestors().whereType<Plunger>().single;
final joint = _MockPrismaticJoint();
when(joint.getJointTranslation).thenReturn(2);
when(joint.getLowerLimit).thenReturn(0);
plunger.body.joints.add(joint);
game.update(0);
expect(plunger.body.linearVelocity.x, equals(0));
expect(plunger.body.linearVelocity.y, equals(strength));
},
);
flameTester.test(
'releases when joint reaches limit',
(game) async {
final plungerBloc = _MockPlungerCubit();
whenListen<PlungerState>(
plungerBloc,
Stream.value(PlungerState.pulling),
initialState: PlungerState.pulling,
Stream.value(PlungerState.autoPulling),
initialState: PlungerState.autoPulling,
);
const strength = 2.0;
final behavior = PlungerAutoPullingBehavior(
strength: strength,
);
final behavior = PlungerAutoPullingBehavior();
await game.pump(
behavior,
plungerBloc: plungerBloc,

@ -0,0 +1,25 @@
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball_components/pinball_components.dart';
void main() {
group('PlungerCubit', () {
test('can be instantiated', () {
expect(PlungerCubit(), isA<PlungerCubit>());
});
blocTest<PlungerCubit, PlungerState>(
'overrides previous pulling state',
build: PlungerCubit.new,
act: (cubit) => cubit
..pulled()
..autoPulled()
..pulled(),
expect: () => [
PlungerState.pulling,
PlungerState.autoPulling,
PlungerState.pulling,
],
);
});
}

@ -77,8 +77,45 @@ void main() {
);
flameTester.test(
'onNewState calls onCharacterSelected on the arcade background bloc',
'onNewState does not call onCharacterSelected on the arcade background '
'bloc when platform is mobile',
(game) async {
final platformHelper = _MockPlatformHelper();
when(() => platformHelper.isMobile).thenAnswer((_) => true);
final arcadeBackgroundBloc = _MockArcadeBackgroundCubit();
whenListen(
arcadeBackgroundBloc,
const Stream<ArcadeBackgroundState>.empty(),
initialState: const ArcadeBackgroundState.initial(),
);
final behavior = CharacterSelectionBehavior();
await game.pump(
[
behavior,
ZCanvasComponent(),
Plunger.test(),
Ball.test(),
],
platformHelper: platformHelper,
);
const dinoThemeState = CharacterThemeState(theme.DinoTheme());
behavior.onNewState(dinoThemeState);
await game.ready();
verifyNever(
() => arcadeBackgroundBloc
.onCharacterSelected(dinoThemeState.characterTheme),
);
},
);
flameTester.test(
'onNewState calls onCharacterSelected on the arcade background '
'bloc when platform is not mobile',
(game) async {
final platformHelper = _MockPlatformHelper();
when(() => platformHelper.isMobile).thenAnswer((_) => false);
final arcadeBackgroundBloc = _MockArcadeBackgroundCubit();
whenListen(
arcadeBackgroundBloc,
@ -96,6 +133,7 @@ void main() {
Plunger.test(),
Ball.test(),
],
platformHelper: platformHelper,
);
const dinoThemeState = CharacterThemeState(theme.DinoTheme());

@ -36,7 +36,6 @@ class _TestGame extends Forge2DGame with HasTappables {
Future<void> pump(
Iterable<Component> children, {
PinballAudioPlayer? pinballAudioPlayer,
PlatformHelper? platformHelper,
GoogleWordCubit? googleWordBloc,
}) async {
return ensureAdd(
@ -62,7 +61,7 @@ class _TestGame extends Forge2DGame with HasTappables {
_MockAppLocalizations(),
),
FlameProvider<PlatformHelper>.value(
platformHelper ?? PlatformHelper(),
PlatformHelper(),
),
],
children: children,
@ -80,8 +79,6 @@ class _MockLeaderboardRepository extends Mock implements LeaderboardRepository {
class _MockShareRepository extends Mock implements ShareRepository {}
class _MockPlatformHelper extends Mock implements PlatformHelper {}
class _MockPlungerCubit extends Mock implements PlungerCubit {}
class _MockGoogleWordCubit extends Mock implements GoogleWordCubit {}
@ -278,7 +275,7 @@ void main() {
create: PlungerCubit.new,
children: [
PlungerPullingBehavior(strength: 0),
PlungerAutoPullingBehavior(strength: 0)
PlungerAutoPullingBehavior()
],
),
);
@ -460,10 +457,8 @@ void main() {
);
flameTester.test(
'adds PlungerKeyControllingBehavior to Plunger when on desktop',
'adds PlungerKeyControllingBehavior to Plunger',
(game) async {
final platformHelper = _MockPlatformHelper();
when(() => platformHelper.isMobile).thenReturn(false);
final component = GameBlocStatusListener();
final leaderboardRepository = _MockLeaderboardRepository();
final shareRepository = _MockShareRepository();
@ -482,7 +477,6 @@ void main() {
bloc: _MockSignpostCubit(),
),
],
platformHelper: platformHelper,
);
await plunger.ensureAdd(
FlameBlocProvider<PlungerCubit, PlungerState>(
@ -506,10 +500,8 @@ void main() {
);
flameTester.test(
'adds PlungerPullingBehavior to Plunger when on desktop',
'adds PlungerPullingBehavior to Plunger',
(game) async {
final platformHelper = _MockPlatformHelper();
when(() => platformHelper.isMobile).thenReturn(false);
final component = GameBlocStatusListener();
final leaderboardRepository = _MockLeaderboardRepository();
final shareRepository = _MockShareRepository();
@ -528,7 +520,6 @@ void main() {
bloc: _MockSignpostCubit(),
),
],
platformHelper: platformHelper,
);
await plunger.ensureAdd(
FlameBlocProvider<PlungerCubit, PlungerState>(
@ -549,10 +540,8 @@ void main() {
);
flameTester.test(
'adds PlungerAutoPullingBehavior to Plunger when on mobile',
'adds PlungerAutoPullingBehavior to Plunger',
(game) async {
final platformHelper = _MockPlatformHelper();
when(() => platformHelper.isMobile).thenReturn(true);
final component = GameBlocStatusListener();
final leaderboardRepository = _MockLeaderboardRepository();
final shareRepository = _MockShareRepository();
@ -571,7 +560,6 @@ void main() {
bloc: _MockSignpostCubit(),
),
],
platformHelper: platformHelper,
);
await plunger.ensureAdd(
FlameBlocProvider<PlungerCubit, PlungerState>(

@ -246,9 +246,61 @@ void main() {
});
group('flipper control', () {
flameTester.test('tap control only works if game is playing',
(game) async {
await game.ready();
final gameBloc = game
.descendants()
.whereType<FlameBlocProvider<GameBloc, GameState>>()
.first
.bloc;
final eventPosition = _MockEventPosition();
when(() => eventPosition.game).thenReturn(Vector2.zero());
when(() => eventPosition.widget).thenReturn(Vector2.zero());
final raw = _MockTapDownDetails();
when(() => raw.kind).thenReturn(PointerDeviceKind.touch);
final tapDownEvent = _MockTapDownInfo();
when(() => tapDownEvent.eventPosition).thenReturn(eventPosition);
when(() => tapDownEvent.raw).thenReturn(raw);
final flipperBloc = game
.descendants()
.whereType<Flipper>()
.where((flipper) => flipper.side == BoardSide.left)
.single
.descendants()
.whereType<FlameBlocProvider<FlipperCubit, FlipperState>>()
.first
.bloc;
gameBloc.emit(gameBloc.state.copyWith(status: GameStatus.gameOver));
game.onTapDown(0, tapDownEvent);
await Future<void>.delayed(Duration.zero);
expect(flipperBloc.state, FlipperState.movingDown);
gameBloc.emit(gameBloc.state.copyWith(status: GameStatus.playing));
game.onTapDown(0, tapDownEvent);
await Future<void>.delayed(Duration.zero);
expect(flipperBloc.state, FlipperState.movingUp);
});
flameTester.test('tap down moves left flipper up', (game) async {
await game.ready();
final gameBloc = game
.descendants()
.whereType<FlameBlocProvider<GameBloc, GameState>>()
.first
.bloc;
gameBloc.emit(gameBloc.state.copyWith(status: GameStatus.playing));
final eventPosition = _MockEventPosition();
when(() => eventPosition.game).thenReturn(Vector2.zero());
when(() => eventPosition.widget).thenReturn(Vector2.zero());
@ -278,6 +330,14 @@ void main() {
flameTester.test('tap down moves right flipper up', (game) async {
await game.ready();
final gameBloc = game
.descendants()
.whereType<FlameBlocProvider<GameBloc, GameState>>()
.first
.bloc;
gameBloc.emit(gameBloc.state.copyWith(status: GameStatus.playing));
final eventPosition = _MockEventPosition();
when(() => eventPosition.game).thenReturn(Vector2.zero());
when(() => eventPosition.widget).thenReturn(game.canvasSize);
@ -307,6 +367,14 @@ void main() {
flameTester.test('tap up moves flipper down', (game) async {
await game.ready();
final gameBloc = game
.descendants()
.whereType<FlameBlocProvider<GameBloc, GameState>>()
.first
.bloc;
gameBloc.emit(gameBloc.state.copyWith(status: GameStatus.playing));
final eventPosition = _MockEventPosition();
when(() => eventPosition.game).thenReturn(Vector2.zero());
when(() => eventPosition.widget).thenReturn(Vector2.zero());
@ -332,6 +400,14 @@ void main() {
flameTester.test('tap cancel moves flipper down', (game) async {
await game.ready();
final gameBloc = game
.descendants()
.whereType<FlameBlocProvider<GameBloc, GameState>>()
.first
.bloc;
gameBloc.emit(gameBloc.state.copyWith(status: GameStatus.playing));
final eventPosition = _MockEventPosition();
when(() => eventPosition.game).thenReturn(Vector2.zero());
when(() => eventPosition.widget).thenReturn(Vector2.zero());
@ -363,6 +439,14 @@ void main() {
(game) async {
await game.ready();
final gameBloc = game
.descendants()
.whereType<FlameBlocProvider<GameBloc, GameState>>()
.first
.bloc;
gameBloc.emit(gameBloc.state.copyWith(status: GameStatus.playing));
final raw = _MockTapDownDetails();
when(() => raw.kind).thenReturn(PointerDeviceKind.touch);
@ -416,6 +500,14 @@ void main() {
flameTester.test('plunger control tap down emits plunging', (game) async {
await game.ready();
final gameBloc = game
.descendants()
.whereType<FlameBlocProvider<GameBloc, GameState>>()
.first
.bloc;
gameBloc.emit(gameBloc.state.copyWith(status: GameStatus.playing));
final eventPosition = _MockEventPosition();
when(() => eventPosition.game).thenReturn(Vector2(40, 60));
@ -434,7 +526,7 @@ void main() {
.single
.bloc;
expect(plungerBloc.state, PlungerState.pulling);
expect(plungerBloc.state, PlungerState.autoPulling);
});
});
});

Loading…
Cancel
Save