mirror of
https://github.com/flutter/samples.git
synced 2025-11-10 14:58:34 +00:00
Add game_template (#1180)
Adds a template / sample for games built in Flutter, with all the bells and whistles, like ads, in-app purchases, audio, main menu, settings, and so on. Co-authored-by: Parker Lougheed Co-authored-by: Shams Zakhour
This commit is contained in:
62
game_template/lib/src/ads/ads_controller.dart
Normal file
62
game_template/lib/src/ads/ads_controller.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
|
||||
import 'preloaded_banner_ad.dart';
|
||||
|
||||
/// Allows showing ads. A facade for `package:google_mobile_ads`.
|
||||
class AdsController {
|
||||
final MobileAds _instance;
|
||||
|
||||
PreloadedBannerAd? _preloadedAd;
|
||||
|
||||
/// Creates an [AdsController] that wraps around a [MobileAds] [instance].
|
||||
///
|
||||
/// Example usage:
|
||||
///
|
||||
/// var controller = AdsController(MobileAds.instance);
|
||||
AdsController(MobileAds instance) : _instance = instance;
|
||||
|
||||
void dispose() {
|
||||
_preloadedAd?.dispose();
|
||||
}
|
||||
|
||||
/// Initializes the injected [MobileAds.instance].
|
||||
Future<void> initialize() async {
|
||||
await _instance.initialize();
|
||||
}
|
||||
|
||||
/// Starts preloading an ad to be used later.
|
||||
///
|
||||
/// The work doesn't start immediately so that calling this doesn't have
|
||||
/// adverse effects (jank) during start of a new screen.
|
||||
void preloadAd() {
|
||||
// TODO: When ready, change this to the Ad Unit IDs provided by AdMob.
|
||||
// The current values are AdMob's sample IDs.
|
||||
final adUnitId = defaultTargetPlatform == TargetPlatform.android
|
||||
? 'ca-app-pub-3940256099942544/6300978111'
|
||||
// iOS
|
||||
: 'ca-app-pub-3940256099942544/2934735716';
|
||||
_preloadedAd =
|
||||
PreloadedBannerAd(size: AdSize.mediumRectangle, adUnitId: adUnitId);
|
||||
|
||||
// Wait a bit so that calling at start of a new screen doesn't have
|
||||
// adverse effects on performance.
|
||||
Future<void>.delayed(const Duration(seconds: 1)).then((_) {
|
||||
return _preloadedAd!.load();
|
||||
});
|
||||
}
|
||||
|
||||
/// Allows caller to take ownership of a [PreloadedBannerAd].
|
||||
///
|
||||
/// If this method returns a non-null value, then the caller is responsible
|
||||
/// for disposing of the loaded ad.
|
||||
PreloadedBannerAd? takePreloadedAd() {
|
||||
final ad = _preloadedAd;
|
||||
_preloadedAd = null;
|
||||
return ad;
|
||||
}
|
||||
}
|
||||
205
game_template/lib/src/ads/banner_ad_widget.dart
Normal file
205
game_template/lib/src/ads/banner_ad_widget.dart
Normal file
@@ -0,0 +1,205 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'ads_controller.dart';
|
||||
import 'preloaded_banner_ad.dart';
|
||||
|
||||
/// Displays a banner ad that conforms to the widget's size in the layout,
|
||||
/// and reloads the ad when the user changes orientation.
|
||||
///
|
||||
/// Do not use this widget on platforms that AdMob currently doesn't support.
|
||||
/// For example:
|
||||
///
|
||||
/// ```dart
|
||||
/// if (kIsWeb) {
|
||||
/// return Text('No ads here! (Yet.)');
|
||||
/// } else {
|
||||
/// return MyBannerAd();
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// This widget is adapted from pkg:google_mobile_ads's example code,
|
||||
/// namely the `anchored_adaptive_example.dart` file:
|
||||
/// https://github.com/googleads/googleads-mobile-flutter/blob/main/packages/google_mobile_ads/example/lib/anchored_adaptive_example.dart
|
||||
class BannerAdWidget extends StatefulWidget {
|
||||
const BannerAdWidget({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
_BannerAdWidgetState createState() => _BannerAdWidgetState();
|
||||
}
|
||||
|
||||
class _BannerAdWidgetState extends State<BannerAdWidget> {
|
||||
static final _log = Logger('BannerAdWidget');
|
||||
|
||||
static const useAnchoredAdaptiveSize = false;
|
||||
BannerAd? _bannerAd;
|
||||
_LoadingState _adLoadingState = _LoadingState.initial;
|
||||
|
||||
late Orientation _currentOrientation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OrientationBuilder(
|
||||
builder: (context, orientation) {
|
||||
if (_currentOrientation == orientation &&
|
||||
_bannerAd != null &&
|
||||
_adLoadingState == _LoadingState.loaded) {
|
||||
_log.info(() => 'We have everything we need. Showing the ad '
|
||||
'${_bannerAd.hashCode} now.');
|
||||
return SizedBox(
|
||||
width: _bannerAd!.size.width.toDouble(),
|
||||
height: _bannerAd!.size.height.toDouble(),
|
||||
child: AdWidget(ad: _bannerAd!),
|
||||
);
|
||||
}
|
||||
// Reload the ad if the orientation changes.
|
||||
if (_currentOrientation != orientation) {
|
||||
_log.info('Orientation changed');
|
||||
_currentOrientation = orientation;
|
||||
_loadAd();
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_currentOrientation = MediaQuery.of(context).orientation;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_log.info('disposing ad');
|
||||
_bannerAd?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final adsController = context.read<AdsController>();
|
||||
final ad = adsController.takePreloadedAd();
|
||||
if (ad != null) {
|
||||
_log.info("A preloaded banner was supplied. Using it.");
|
||||
_showPreloadedAd(ad);
|
||||
} else {
|
||||
_loadAd();
|
||||
}
|
||||
}
|
||||
|
||||
/// Load (another) ad, disposing of the current ad if there is one.
|
||||
Future<void> _loadAd() async {
|
||||
if (!mounted) return;
|
||||
_log.info('_loadAd() called.');
|
||||
if (_adLoadingState == _LoadingState.loading ||
|
||||
_adLoadingState == _LoadingState.disposing) {
|
||||
_log.info('An ad is already being loaded or disposed. Aborting.');
|
||||
return;
|
||||
}
|
||||
_adLoadingState = _LoadingState.disposing;
|
||||
await _bannerAd?.dispose();
|
||||
_log.fine('_bannerAd disposed');
|
||||
setState(() {
|
||||
_bannerAd = null;
|
||||
_adLoadingState = _LoadingState.loading;
|
||||
});
|
||||
|
||||
AdSize size;
|
||||
|
||||
if (useAnchoredAdaptiveSize) {
|
||||
final AnchoredAdaptiveBannerAdSize? adaptiveSize =
|
||||
await AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(
|
||||
MediaQuery.of(context).size.width.truncate());
|
||||
|
||||
if (adaptiveSize == null) {
|
||||
_log.warning('Unable to get height of anchored banner.');
|
||||
size = AdSize.banner;
|
||||
} else {
|
||||
size = adaptiveSize;
|
||||
}
|
||||
} else {
|
||||
size = AdSize.mediumRectangle;
|
||||
}
|
||||
|
||||
assert(Platform.isAndroid || Platform.isIOS,
|
||||
'AdMob currently does not support ${Platform.operatingSystem}');
|
||||
_bannerAd = BannerAd(
|
||||
// This is a test ad unit ID from
|
||||
// https://developers.google.com/admob/android/test-ads. When ready,
|
||||
// you replace this with your own, production ad unit ID,
|
||||
// created in https://apps.admob.com/.
|
||||
adUnitId: Theme.of(context).platform == TargetPlatform.android
|
||||
? 'ca-app-pub-3940256099942544/6300978111'
|
||||
: 'ca-app-pub-3940256099942544/2934735716',
|
||||
size: size,
|
||||
request: const AdRequest(),
|
||||
listener: BannerAdListener(
|
||||
onAdLoaded: (ad) {
|
||||
_log.info(() => 'Ad loaded: ${ad.responseInfo}');
|
||||
setState(() {
|
||||
// When the ad is loaded, get the ad size and use it to set
|
||||
// the height of the ad container.
|
||||
_bannerAd = ad as BannerAd;
|
||||
_adLoadingState = _LoadingState.loaded;
|
||||
});
|
||||
},
|
||||
onAdFailedToLoad: (ad, error) {
|
||||
_log.warning('Banner failedToLoad: $error');
|
||||
ad.dispose();
|
||||
},
|
||||
onAdImpression: (ad) {
|
||||
_log.info('Ad impression registered');
|
||||
},
|
||||
onAdClicked: (ad) {
|
||||
_log.info('Ad click registered');
|
||||
},
|
||||
),
|
||||
);
|
||||
return _bannerAd!.load();
|
||||
}
|
||||
|
||||
Future<void> _showPreloadedAd(PreloadedBannerAd ad) async {
|
||||
// It's possible that the banner is still loading (even though it started
|
||||
// preloading at the start of the previous screen).
|
||||
_adLoadingState = _LoadingState.loading;
|
||||
try {
|
||||
_bannerAd = await ad.ready;
|
||||
} on LoadAdError catch (error) {
|
||||
_log.severe('Error when loading preloaded banner: $error');
|
||||
unawaited(_loadAd());
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_adLoadingState = _LoadingState.loaded;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
enum _LoadingState {
|
||||
/// The state before we even start loading anything.
|
||||
initial,
|
||||
|
||||
/// The ad is being loaded at this point.
|
||||
loading,
|
||||
|
||||
/// The previous ad is being disposed of. After that is done, the next
|
||||
/// ad will be loaded.
|
||||
disposing,
|
||||
|
||||
/// An ad has been loaded already.
|
||||
loaded,
|
||||
}
|
||||
71
game_template/lib/src/ads/preloaded_banner_ad.dart
Normal file
71
game_template/lib/src/ads/preloaded_banner_ad.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class PreloadedBannerAd {
|
||||
static final _log = Logger('PreloadedBannerAd');
|
||||
|
||||
/// Something like [AdSize.mediumRectangle].
|
||||
final AdSize size;
|
||||
|
||||
final AdRequest _adRequest;
|
||||
|
||||
BannerAd? _bannerAd;
|
||||
|
||||
final String adUnitId;
|
||||
|
||||
final _adCompleter = Completer<BannerAd>();
|
||||
|
||||
PreloadedBannerAd({
|
||||
required this.size,
|
||||
required this.adUnitId,
|
||||
AdRequest? adRequest,
|
||||
}) : _adRequest = adRequest ?? const AdRequest();
|
||||
|
||||
Future<BannerAd> get ready => _adCompleter.future;
|
||||
|
||||
Future<void> load() {
|
||||
assert(Platform.isAndroid || Platform.isIOS,
|
||||
'AdMob currently does not support ${Platform.operatingSystem}');
|
||||
|
||||
_bannerAd = BannerAd(
|
||||
// This is a test ad unit ID from
|
||||
// https://developers.google.com/admob/android/test-ads. When ready,
|
||||
// you replace this with your own, production ad unit ID,
|
||||
// created in https://apps.admob.com/.
|
||||
adUnitId: adUnitId,
|
||||
size: size,
|
||||
request: _adRequest,
|
||||
listener: BannerAdListener(
|
||||
onAdLoaded: (ad) {
|
||||
_log.info(() => 'Ad loaded: ${_bannerAd.hashCode}');
|
||||
_adCompleter.complete(_bannerAd);
|
||||
},
|
||||
onAdFailedToLoad: (ad, error) {
|
||||
_log.warning('Banner failedToLoad: $error');
|
||||
_adCompleter.completeError(error);
|
||||
ad.dispose();
|
||||
},
|
||||
onAdImpression: (ad) {
|
||||
_log.info('Ad impression registered');
|
||||
},
|
||||
onAdClicked: (ad) {
|
||||
_log.info('Ad click registered');
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return _bannerAd!.load();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_log.info('preloaded banner ad being disposed');
|
||||
_bannerAd?.dispose();
|
||||
}
|
||||
}
|
||||
63
game_template/lib/src/app_lifecycle/app_lifecycle.dart
Normal file
63
game_template/lib/src/app_lifecycle/app_lifecycle.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class AppLifecycleObserver extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const AppLifecycleObserver({required this.child, Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
_AppLifecycleObserverState createState() => _AppLifecycleObserverState();
|
||||
}
|
||||
|
||||
class _AppLifecycleObserverState extends State<AppLifecycleObserver>
|
||||
with WidgetsBindingObserver {
|
||||
static final _log = Logger('AppLifecycleObserver');
|
||||
|
||||
final ValueNotifier<AppLifecycleState> lifecycleListenable =
|
||||
ValueNotifier(AppLifecycleState.inactive);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Using InheritedProvider because we don't want to use Consumer
|
||||
// or context.watch or anything like that to listen to this. We want
|
||||
// to manually add listeners. We're interested in the _events_ of lifecycle
|
||||
// state changes, and not so much in the state itself. (For example,
|
||||
// we want to stop sound when the app goes into the background, and
|
||||
// restart sound again when the app goes back into focus. We're not
|
||||
// rebuilding any widgets.)
|
||||
//
|
||||
// Provider, by default, throws when one
|
||||
// is trying to provide a Listenable (such as ValueNotifier) without using
|
||||
// something like ValueListenableProvider. InheritedProvider is more
|
||||
// low-level and doesn't have this problem.
|
||||
return InheritedProvider<ValueNotifier<AppLifecycleState>>.value(
|
||||
value: lifecycleListenable,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
_log.info(() => 'didChangeAppLifecycleState: $state');
|
||||
lifecycleListenable.value = state;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance!.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance!.addObserver(this);
|
||||
_log.info('Subscribed to app lifecycle updates');
|
||||
}
|
||||
}
|
||||
271
game_template/lib/src/audio/audio_controller.dart
Normal file
271
game_template/lib/src/audio/audio_controller.dart
Normal file
@@ -0,0 +1,271 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'dart:collection';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart' hide Logger;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../settings/settings.dart';
|
||||
import 'songs.dart';
|
||||
import 'sounds.dart';
|
||||
|
||||
/// Allows playing music and sound. A facade to `package:audioplayers`.
|
||||
class AudioController {
|
||||
static final _log = Logger('AudioController');
|
||||
|
||||
late AudioCache _musicCache;
|
||||
|
||||
late AudioCache _sfxCache;
|
||||
|
||||
final AudioPlayer _musicPlayer;
|
||||
|
||||
/// This is a list of [AudioPlayer] instances which are rotated to play
|
||||
/// sound effects.
|
||||
///
|
||||
/// Normally, we would just call [AudioCache.play] and let it procure its
|
||||
/// own [AudioPlayer] every time. But this seems to lead to errors and
|
||||
/// bad performance on iOS devices.
|
||||
final List<AudioPlayer> _sfxPlayers;
|
||||
|
||||
int _currentSfxPlayer = 0;
|
||||
|
||||
final Queue<Song> _playlist;
|
||||
|
||||
final Random _random = Random();
|
||||
|
||||
SettingsController? _settings;
|
||||
|
||||
ValueNotifier<AppLifecycleState>? _lifecycleNotifier;
|
||||
|
||||
/// Creates an instance that plays music and sound.
|
||||
///
|
||||
/// Use [polyphony] to configure the number of sound effects (SFX) that can
|
||||
/// play at the same time. A [polyphony] of `1` will always only play one
|
||||
/// sound (a new sound will stop the previous one). See discussion
|
||||
/// of [_sfxPlayers] to learn why this is the case.
|
||||
///
|
||||
/// Background music does not count into the [polyphony] limit. Music will
|
||||
/// never be overridden by sound effects.
|
||||
AudioController({int polyphony = 2})
|
||||
: assert(polyphony >= 1),
|
||||
_musicPlayer = AudioPlayer(playerId: 'musicPlayer'),
|
||||
_sfxPlayers = Iterable.generate(
|
||||
polyphony,
|
||||
(i) => AudioPlayer(
|
||||
playerId: 'sfxPlayer#$i',
|
||||
mode: PlayerMode.LOW_LATENCY)).toList(growable: false),
|
||||
_playlist = Queue.of(List<Song>.of(songs)..shuffle()) {
|
||||
_musicCache = AudioCache(
|
||||
fixedPlayer: _musicPlayer,
|
||||
prefix: 'assets/music/',
|
||||
);
|
||||
_sfxCache = AudioCache(
|
||||
fixedPlayer: _sfxPlayers.first,
|
||||
prefix: 'assets/sfx/',
|
||||
);
|
||||
|
||||
_musicPlayer.onPlayerCompletion.listen(_changeSong);
|
||||
}
|
||||
|
||||
/// Enables the [AudioController] to listen to [AppLifecycleState] events,
|
||||
/// and therefore do things like stopping playback when the game
|
||||
/// goes into the background.
|
||||
void attachLifecycleNotifier(
|
||||
ValueNotifier<AppLifecycleState> lifecycleNotifier) {
|
||||
_lifecycleNotifier?.removeListener(_handleAppLifecycle);
|
||||
|
||||
lifecycleNotifier.addListener(_handleAppLifecycle);
|
||||
_lifecycleNotifier = lifecycleNotifier;
|
||||
}
|
||||
|
||||
/// Enables the [AudioController] to track changes to settings.
|
||||
/// Namely, when any of [SettingsController.muted],
|
||||
/// [SettingsController.musicOn] or [SettingsController.soundsOn] changes,
|
||||
/// the audio controller will act accordingly.
|
||||
void attachSettings(SettingsController settingsController) {
|
||||
if (_settings == settingsController) {
|
||||
// Already attached to this instance. Nothing to do.
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove handlers from the old settings controller if present
|
||||
final oldSettings = _settings;
|
||||
if (oldSettings != null) {
|
||||
oldSettings.muted.removeListener(_mutedHandler);
|
||||
oldSettings.musicOn.removeListener(_musicOnHandler);
|
||||
oldSettings.soundsOn.removeListener(_soundsOnHandler);
|
||||
}
|
||||
|
||||
_settings = settingsController;
|
||||
|
||||
// Add handlers to the new settings controller
|
||||
settingsController.muted.addListener(_mutedHandler);
|
||||
settingsController.musicOn.addListener(_musicOnHandler);
|
||||
settingsController.soundsOn.addListener(_soundsOnHandler);
|
||||
|
||||
if (!settingsController.muted.value && settingsController.musicOn.value) {
|
||||
_startMusic();
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_lifecycleNotifier?.removeListener(_handleAppLifecycle);
|
||||
_stopAllSound();
|
||||
_musicPlayer.dispose();
|
||||
for (final player in _sfxPlayers) {
|
||||
player.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Preloads all sound effects.
|
||||
Future<void> initialize() async {
|
||||
_log.info('Preloading sound effects');
|
||||
// This assumes there is only a limited number of sound effects in the game.
|
||||
// If there are hundreds of long sound effect files, it's better
|
||||
// to be more selective when preloading.
|
||||
await _sfxCache
|
||||
.loadAll(SfxType.values.expand(soundTypeToFilename).toList());
|
||||
}
|
||||
|
||||
/// Plays a single sound effect, defined by [type].
|
||||
///
|
||||
/// The controller will ignore this call when the attached settings'
|
||||
/// [SettingsController.muted] is `true` or if its
|
||||
/// [SettingsController.soundsOn] is `false`.
|
||||
void playSfx(SfxType type) {
|
||||
final muted = _settings?.muted.value ?? true;
|
||||
if (muted) {
|
||||
_log.info(() => 'Ignoring playing sound ($type) because audio is muted.');
|
||||
return;
|
||||
}
|
||||
final soundsOn = _settings?.soundsOn.value ?? false;
|
||||
if (!soundsOn) {
|
||||
_log.info(() =>
|
||||
'Ignoring playing sound ($type) because sounds are turned off.');
|
||||
return;
|
||||
}
|
||||
|
||||
_log.info(() => 'Playing sound: $type');
|
||||
final options = soundTypeToFilename(type);
|
||||
final filename = options[_random.nextInt(options.length)];
|
||||
_log.info(() => '- Chosen filename: $filename');
|
||||
_sfxCache.play(filename, volume: soundTypeToVolume(type));
|
||||
_currentSfxPlayer = (_currentSfxPlayer + 1) % _sfxPlayers.length;
|
||||
_sfxCache.fixedPlayer = _sfxPlayers[_currentSfxPlayer];
|
||||
}
|
||||
|
||||
void _changeSong(void _) {
|
||||
_log.info('Last song finished playing.');
|
||||
// Put the song that just finished playing to the end of the playlist.
|
||||
_playlist.addLast(_playlist.removeFirst());
|
||||
// Play the next song.
|
||||
_log.info(() => 'Playing ${_playlist.first} now.');
|
||||
_musicCache.play(_playlist.first.filename);
|
||||
}
|
||||
|
||||
void _handleAppLifecycle() {
|
||||
switch (_lifecycleNotifier!.value) {
|
||||
case AppLifecycleState.paused:
|
||||
case AppLifecycleState.detached:
|
||||
_stopAllSound();
|
||||
break;
|
||||
case AppLifecycleState.resumed:
|
||||
if (!_settings!.muted.value && _settings!.musicOn.value) {
|
||||
_resumeMusic();
|
||||
}
|
||||
break;
|
||||
case AppLifecycleState.inactive:
|
||||
// No need to react to this state change.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _musicOnHandler() {
|
||||
if (_settings!.musicOn.value) {
|
||||
// Music got turned on.
|
||||
if (!_settings!.muted.value) {
|
||||
_resumeMusic();
|
||||
}
|
||||
} else {
|
||||
// Music got turned off.
|
||||
_stopMusic();
|
||||
}
|
||||
}
|
||||
|
||||
void _mutedHandler() {
|
||||
if (_settings!.muted.value) {
|
||||
// All sound just got muted.
|
||||
_stopAllSound();
|
||||
} else {
|
||||
// All sound just got un-muted.
|
||||
if (_settings!.musicOn.value) {
|
||||
_resumeMusic();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resumeMusic() async {
|
||||
_log.info('Resuming music');
|
||||
switch (_musicPlayer.state) {
|
||||
case PlayerState.PAUSED:
|
||||
_log.info('Calling _musicPlayer.resume()');
|
||||
try {
|
||||
await _musicPlayer.resume();
|
||||
} catch (e) {
|
||||
// Sometimes, resuming fails with an "Unexpected" error.
|
||||
_log.severe(e);
|
||||
await _musicCache.play(_playlist.first.filename);
|
||||
}
|
||||
break;
|
||||
case PlayerState.STOPPED:
|
||||
_log.info("resumeMusic() called when music is stopped. "
|
||||
"This probably means we haven't yet started the music. "
|
||||
"For example, the game was started with sound off.");
|
||||
await _musicCache.play(_playlist.first.filename);
|
||||
break;
|
||||
case PlayerState.PLAYING:
|
||||
_log.warning('resumeMusic() called when music is playing. '
|
||||
'Nothing to do.');
|
||||
break;
|
||||
case PlayerState.COMPLETED:
|
||||
_log.warning('resumeMusic() called when music is completed. '
|
||||
"Music should never be 'completed' as it's either not playing "
|
||||
"or looping forever.");
|
||||
await _musicCache.play(_playlist.first.filename);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _soundsOnHandler() {
|
||||
for (final player in _sfxPlayers) {
|
||||
if (player.state == PlayerState.PLAYING) {
|
||||
player.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startMusic() {
|
||||
_log.info('starting music');
|
||||
_musicCache.play(_playlist.first.filename);
|
||||
}
|
||||
|
||||
void _stopAllSound() {
|
||||
if (_musicPlayer.state == PlayerState.PLAYING) {
|
||||
_musicPlayer.pause();
|
||||
}
|
||||
for (final player in _sfxPlayers) {
|
||||
player.stop();
|
||||
}
|
||||
}
|
||||
|
||||
void _stopMusic() {
|
||||
_log.info('Stopping music');
|
||||
if (_musicPlayer.state == PlayerState.PLAYING) {
|
||||
_musicPlayer.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
24
game_template/lib/src/audio/songs.dart
Normal file
24
game_template/lib/src/audio/songs.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
const Set<Song> songs = {
|
||||
// Filenames with whitespace break package:audioplayers on iOS
|
||||
// (as of February 2022), so we use no whitespace.
|
||||
Song('Mr_Smith-Azul.mp3', 'Azul', artist: 'Mr Smith'),
|
||||
Song('Mr_Smith-Sonorus.mp3', 'Sonorus', artist: 'Mr Smith'),
|
||||
Song('Mr_Smith-Sunday_Solitude.mp3', 'SundaySolitude', artist: 'Mr Smith'),
|
||||
};
|
||||
|
||||
class Song {
|
||||
final String filename;
|
||||
|
||||
final String name;
|
||||
|
||||
final String? artist;
|
||||
|
||||
const Song(this.filename, this.name, {this.artist});
|
||||
|
||||
@override
|
||||
String toString() => 'Song<$filename>';
|
||||
}
|
||||
71
game_template/lib/src/audio/sounds.dart
Normal file
71
game_template/lib/src/audio/sounds.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
List<String> soundTypeToFilename(SfxType type) {
|
||||
switch (type) {
|
||||
case SfxType.huhsh:
|
||||
return const [
|
||||
'hash1.mp3',
|
||||
'hash2.mp3',
|
||||
'hash3.mp3',
|
||||
];
|
||||
case SfxType.wssh:
|
||||
return const [
|
||||
'wssh1.mp3',
|
||||
'wssh2.mp3',
|
||||
'dsht1.mp3',
|
||||
'ws1.mp3',
|
||||
'spsh1.mp3',
|
||||
'hh1.mp3',
|
||||
'hh2.mp3',
|
||||
'kss1.mp3',
|
||||
];
|
||||
case SfxType.buttonTap:
|
||||
return const [
|
||||
'k1.mp3',
|
||||
'k2.mp3',
|
||||
'p1.mp3',
|
||||
'p2.mp3',
|
||||
];
|
||||
case SfxType.congrats:
|
||||
return const [
|
||||
'yay1.mp3',
|
||||
'wehee1.mp3',
|
||||
'oo1.mp3',
|
||||
];
|
||||
case SfxType.erase:
|
||||
return const [
|
||||
'fwfwfwfwfw1.mp3',
|
||||
'fwfwfwfw1.mp3',
|
||||
];
|
||||
case SfxType.swishSwish:
|
||||
return const [
|
||||
'swishswish1.mp3',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// Allows control over loudness of different SFX types.
|
||||
double soundTypeToVolume(SfxType type) {
|
||||
switch (type) {
|
||||
case SfxType.huhsh:
|
||||
return 0.4;
|
||||
case SfxType.wssh:
|
||||
return 0.2;
|
||||
case SfxType.buttonTap:
|
||||
case SfxType.congrats:
|
||||
case SfxType.erase:
|
||||
case SfxType.swishSwish:
|
||||
return 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
enum SfxType {
|
||||
huhsh,
|
||||
wssh,
|
||||
buttonTap,
|
||||
congrats,
|
||||
erase,
|
||||
swishSwish,
|
||||
}
|
||||
103
game_template/lib/src/crashlytics/crashlytics.dart
Normal file
103
game_template/lib/src/crashlytics/crashlytics.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// Runs [mainFunction] in a guarded [Zone].
|
||||
///
|
||||
/// If a non-null [FirebaseCrashlytics] instance is provided through
|
||||
/// [crashlytics], then all errors will be reported through it.
|
||||
///
|
||||
/// These errors will also include latest logs from anywhere in the app
|
||||
/// that use `package:logging`.
|
||||
Future<void> guardWithCrashlytics(
|
||||
void Function() mainFunction, {
|
||||
required FirebaseCrashlytics? crashlytics,
|
||||
}) async {
|
||||
// Running the initialization code and [mainFunction] inside a guarded
|
||||
// zone, so that all errors (even those occurring in callbacks) are
|
||||
// caught and can be sent to Crashlytics.
|
||||
await runZonedGuarded<Future<void>>(() async {
|
||||
if (kDebugMode) {
|
||||
// Log more when in debug mode.
|
||||
Logger.root.level = Level.FINE;
|
||||
}
|
||||
// Subscribe to log messages.
|
||||
Logger.root.onRecord.listen((record) {
|
||||
final message = '${record.level.name}: ${record.time}: '
|
||||
'${record.loggerName}: '
|
||||
'${record.message}';
|
||||
|
||||
debugPrint(message);
|
||||
// Add the message to the rotating Crashlytics log.
|
||||
crashlytics?.log(message);
|
||||
|
||||
if (record.level >= Level.SEVERE) {
|
||||
crashlytics?.recordError(message, filterStackTrace(StackTrace.current));
|
||||
}
|
||||
});
|
||||
|
||||
// Pass all uncaught errors from the framework to Crashlytics.
|
||||
if (crashlytics != null) {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
FlutterError.onError = crashlytics.recordFlutterError;
|
||||
}
|
||||
|
||||
if (!kIsWeb) {
|
||||
// To catch errors outside of the Flutter context, we attach an error
|
||||
// listener to the current isolate.
|
||||
Isolate.current.addErrorListener(RawReceivePort((dynamic pair) async {
|
||||
final errorAndStacktrace = pair as List<dynamic>;
|
||||
await crashlytics?.recordError(
|
||||
errorAndStacktrace.first,
|
||||
errorAndStacktrace.last as StackTrace?,
|
||||
);
|
||||
}).sendPort);
|
||||
}
|
||||
|
||||
// Run the actual code.
|
||||
mainFunction();
|
||||
}, (error, stack) {
|
||||
// This sees all errors that occur in the runZonedGuarded zone.
|
||||
debugPrint('ERROR: $error\n\n'
|
||||
'STACK:$stack');
|
||||
crashlytics?.recordError(error, stack);
|
||||
});
|
||||
}
|
||||
|
||||
/// Takes a [stackTrace] and creates a new one, but without the lines that
|
||||
/// have to do with this file and logging. This way, Crashlytics won't group
|
||||
/// all messages that come from this file into one big heap just because
|
||||
/// the head of the StackTrace is identical.
|
||||
///
|
||||
/// See this:
|
||||
/// https://stackoverflow.com/questions/47654410/how-to-effectively-group-non-fatal-exceptions-in-crashlytics-fabrics.
|
||||
@visibleForTesting
|
||||
StackTrace filterStackTrace(StackTrace stackTrace) {
|
||||
try {
|
||||
final lines = stackTrace.toString().split('\n');
|
||||
final buf = StringBuffer();
|
||||
for (final line in lines) {
|
||||
if (line.contains('crashlytics.dart') ||
|
||||
line.contains('_BroadcastStreamController.java') ||
|
||||
line.contains('logger.dart')) {
|
||||
continue;
|
||||
}
|
||||
buf.writeln(line);
|
||||
}
|
||||
return StackTrace.fromString(buf.toString());
|
||||
} catch (e) {
|
||||
debugPrint('Problem while filtering stack trace: $e');
|
||||
}
|
||||
|
||||
// If there was an error while filtering,
|
||||
// return the original, unfiltered stack track.
|
||||
return stackTrace;
|
||||
}
|
||||
32
game_template/lib/src/game_internals/level_state.dart
Normal file
32
game_template/lib/src/game_internals/level_state.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// An extremely silly example of a game state.
|
||||
///
|
||||
/// Tracks only a single variable, [progress], and calls [onWin] when
|
||||
/// the value of [progress] reaches [goal].
|
||||
class LevelState extends ChangeNotifier {
|
||||
final VoidCallback onWin;
|
||||
|
||||
final int goal;
|
||||
|
||||
LevelState({required this.onWin, this.goal = 100});
|
||||
|
||||
int _progress = 0;
|
||||
|
||||
int get progress => _progress;
|
||||
|
||||
void setProgress(int value) {
|
||||
_progress = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void evaluate() {
|
||||
if (_progress >= goal) {
|
||||
onWin();
|
||||
}
|
||||
}
|
||||
}
|
||||
119
game_template/lib/src/games_services/games_services.dart
Normal file
119
game_template/lib/src/games_services/games_services.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:games_services/games_services.dart' as gs;
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'score.dart';
|
||||
|
||||
/// Allows awarding achievements and leaderboard scores,
|
||||
/// and also showing the platforms' UI overlays for achievements
|
||||
/// and leaderboards.
|
||||
///
|
||||
/// A facade of `package:games_services`.
|
||||
class GamesServicesController {
|
||||
static final Logger _log = Logger('GamesServicesController');
|
||||
|
||||
final Completer<bool> _signedInCompleter = Completer();
|
||||
|
||||
Future<bool> get signedIn => _signedInCompleter.future;
|
||||
|
||||
/// Unlocks an achievement on Game Center / Play Games.
|
||||
///
|
||||
/// You must provide the achievement ids via the [iOS] and [android]
|
||||
/// parameters.
|
||||
///
|
||||
/// Does nothing when the game isn't signed into the underlying
|
||||
/// games service.
|
||||
Future<void> awardAchievement(
|
||||
{required String iOS, required String android}) async {
|
||||
if (!await signedIn) {
|
||||
_log.warning('Trying to award achievement when not logged in.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await gs.GamesServices.unlock(
|
||||
achievement: gs.Achievement(
|
||||
androidID: android,
|
||||
iOSID: iOS,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
_log.severe('Cannot award achievement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Signs into the underlying games service.
|
||||
Future<void> initialize() async {
|
||||
try {
|
||||
await gs.GamesServices.signIn();
|
||||
// The API is unclear so we're checking to be sure. The above call
|
||||
// returns a String, not a boolean, and there's no documentation
|
||||
// as to whether every non-error result means we're safely signed in.
|
||||
final signedIn = await gs.GamesServices.isSignedIn;
|
||||
_signedInCompleter.complete(signedIn);
|
||||
} catch (e) {
|
||||
_log.severe('Cannot log into GamesServices: $e');
|
||||
_signedInCompleter.complete(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Launches the platform's UI overlay with achievements.
|
||||
Future<void> showAchievements() async {
|
||||
if (!await signedIn) {
|
||||
_log.severe('Trying to show achievements when not logged in.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await gs.GamesServices.showAchievements();
|
||||
} catch (e) {
|
||||
_log.severe('Cannot show achievements: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Launches the platform's UI overlay with leaderboard(s).
|
||||
Future<void> showLeaderboard() async {
|
||||
if (!await signedIn) {
|
||||
_log.severe('Trying to show leaderboard when not logged in.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await gs.GamesServices.showLeaderboards(
|
||||
// TODO: When ready, change both these leaderboard IDs.
|
||||
iOSLeaderboardID: "some_id_from_app_store",
|
||||
androidLeaderboardID: "sOmE_iD_fRoM_gPlAy",
|
||||
);
|
||||
} catch (e) {
|
||||
_log.severe('Cannot show leaderboard: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Submits [score] to the leaderboard.
|
||||
Future<void> submitLeaderboardScore(Score score) async {
|
||||
if (!await signedIn) {
|
||||
_log.warning('Trying to submit leaderboard when not logged in.');
|
||||
return;
|
||||
}
|
||||
|
||||
_log.info('Submitting $score to leaderboard.');
|
||||
|
||||
try {
|
||||
await gs.GamesServices.submitScore(
|
||||
score: gs.Score(
|
||||
// TODO: When ready, change these leaderboard IDs.
|
||||
iOSLeaderboardID: 'some_id_from_app_store',
|
||||
androidLeaderboardID: 'sOmE_iD_fRoM_gPlAy',
|
||||
value: score.score,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
_log.severe('Cannot submit leaderboard score: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
48
game_template/lib/src/games_services/score.dart
Normal file
48
game_template/lib/src/games_services/score.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Encapsulates a score and the arithmetic to compute it.
|
||||
@immutable
|
||||
class Score {
|
||||
final int score;
|
||||
|
||||
final Duration duration;
|
||||
|
||||
final int level;
|
||||
|
||||
factory Score(int level, int difficulty, Duration duration) {
|
||||
// The higher the difficulty, the higher the score.
|
||||
var score = difficulty;
|
||||
// The lower the time to beat the level, the higher the score.
|
||||
score *= 10000 ~/ (duration.inSeconds.abs() + 1);
|
||||
return Score._(score, duration, level);
|
||||
}
|
||||
|
||||
const Score._(this.score, this.duration, this.level);
|
||||
|
||||
String get formattedTime {
|
||||
final buf = StringBuffer();
|
||||
if (duration.inHours > 0) {
|
||||
buf.write('${duration.inHours}');
|
||||
buf.write(':');
|
||||
}
|
||||
final minutes = duration.inMinutes % Duration.minutesPerHour;
|
||||
if (minutes > 9) {
|
||||
buf.write('$minutes');
|
||||
} else {
|
||||
buf.write('0');
|
||||
buf.write('$minutes');
|
||||
}
|
||||
buf.write(':');
|
||||
buf.write((duration.inSeconds % Duration.secondsPerMinute)
|
||||
.toString()
|
||||
.padLeft(2, '0'));
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'Score<$score,$formattedTime,$level>';
|
||||
}
|
||||
41
game_template/lib/src/in_app_purchase/ad_removal.dart
Normal file
41
game_template/lib/src/in_app_purchase/ad_removal.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
/// Represents the state of an in-app purchase of ad removal such as
|
||||
/// [AdRemovalPurchase.notStarted()] or [AdRemovalPurchase.active()].
|
||||
class AdRemovalPurchase {
|
||||
/// The representation of this product on the stores.
|
||||
static const productId = 'remove_ads';
|
||||
|
||||
/// This is `true` if the `remove_ad` product has been purchased and verified.
|
||||
/// Do not show ads if so.
|
||||
final bool active;
|
||||
|
||||
/// This is `true` when the purchase is pending.
|
||||
final bool pending;
|
||||
|
||||
/// If there was an error with the purchase, this field will contain
|
||||
/// that error.
|
||||
final Object? error;
|
||||
|
||||
const AdRemovalPurchase.active() : this._(true, false, null);
|
||||
|
||||
const AdRemovalPurchase.error(Object error) : this._(false, false, error);
|
||||
|
||||
const AdRemovalPurchase.notStarted() : this._(false, false, null);
|
||||
|
||||
const AdRemovalPurchase.pending() : this._(false, true, null);
|
||||
|
||||
const AdRemovalPurchase._(this.active, this.pending, this.error);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(active, pending, error);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is AdRemovalPurchase &&
|
||||
other.active == active &&
|
||||
other.pending == pending &&
|
||||
other.error == error;
|
||||
}
|
||||
193
game_template/lib/src/in_app_purchase/in_app_purchase.dart
Normal file
193
game_template/lib/src/in_app_purchase/in_app_purchase.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../style/snack_bar.dart';
|
||||
import 'ad_removal.dart';
|
||||
|
||||
/// Allows buying in-app. Facade of `package:in_app_purchase`.
|
||||
class InAppPurchaseController extends ChangeNotifier {
|
||||
static final Logger _log = Logger('InAppPurchases');
|
||||
|
||||
StreamSubscription<List<PurchaseDetails>>? _subscription;
|
||||
|
||||
InAppPurchase inAppPurchaseInstance;
|
||||
|
||||
AdRemovalPurchase _adRemoval = const AdRemovalPurchase.notStarted();
|
||||
|
||||
/// Creates a new [InAppPurchaseController] with an injected
|
||||
/// [InAppPurchase] instance.
|
||||
///
|
||||
/// Example usage:
|
||||
///
|
||||
/// var controller = InAppPurchaseController(InAppPurchase.instance);
|
||||
InAppPurchaseController(this.inAppPurchaseInstance);
|
||||
|
||||
/// The current state of the ad removal purchase.
|
||||
AdRemovalPurchase get adRemoval => _adRemoval;
|
||||
|
||||
/// Launches the platform UI for buying an in-app purchase.
|
||||
///
|
||||
/// Currently, the only supported in-app purchase is ad removal.
|
||||
/// To support more, ad additional classes similar to [AdRemovalPurchase]
|
||||
/// and modify this method.
|
||||
Future<void> buy() async {
|
||||
if (!await inAppPurchaseInstance.isAvailable()) {
|
||||
_reportError('InAppPurchase.instance not available');
|
||||
return;
|
||||
}
|
||||
|
||||
_adRemoval = const AdRemovalPurchase.pending();
|
||||
notifyListeners();
|
||||
|
||||
_log.info('Querying the store with queryProductDetails()');
|
||||
final response = await inAppPurchaseInstance
|
||||
.queryProductDetails({AdRemovalPurchase.productId});
|
||||
|
||||
if (response.error != null) {
|
||||
_reportError('There was an error when making the purchase: '
|
||||
'${response.error}');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.productDetails.length != 1) {
|
||||
_log.info(
|
||||
'Products in response: '
|
||||
'${response.productDetails.map((e) => '${e.id}: ${e.title}, ').join()}',
|
||||
);
|
||||
_reportError('There was an error when making the purchase: '
|
||||
'product ${AdRemovalPurchase.productId} does not exist?');
|
||||
return;
|
||||
}
|
||||
final productDetails = response.productDetails.single;
|
||||
|
||||
_log.info('Making the purchase');
|
||||
final purchaseParam = PurchaseParam(productDetails: productDetails);
|
||||
try {
|
||||
final success = await inAppPurchaseInstance.buyNonConsumable(
|
||||
purchaseParam: purchaseParam);
|
||||
_log.info('buyNonConsumable() request was sent with success: $success');
|
||||
// The result of the purchase will be reported in the purchaseStream,
|
||||
// which is handled in [_listenToPurchaseUpdated].
|
||||
} catch (e) {
|
||||
_log.severe(
|
||||
'Problem with calling inAppPurchaseInstance.buyNonConsumable(): '
|
||||
'$e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Asks the underlying platform to list purchases that have been already
|
||||
/// made (for example, in a previous session of the game).
|
||||
Future<void> restorePurchases() async {
|
||||
if (!await inAppPurchaseInstance.isAvailable()) {
|
||||
_reportError('InAppPurchase.instance not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await inAppPurchaseInstance.restorePurchases();
|
||||
} catch (e) {
|
||||
_log.severe('Could not restore in-app purchases: $e');
|
||||
}
|
||||
_log.info('In-app purchases restored');
|
||||
}
|
||||
|
||||
/// Subscribes to the [inAppPurchaseInstance.purchaseStream].
|
||||
void subscribe() {
|
||||
_subscription?.cancel();
|
||||
_subscription =
|
||||
inAppPurchaseInstance.purchaseStream.listen((purchaseDetailsList) {
|
||||
_listenToPurchaseUpdated(purchaseDetailsList);
|
||||
}, onDone: () {
|
||||
_subscription?.cancel();
|
||||
}, onError: (dynamic error) {
|
||||
_log.severe('Error occurred on the purchaseStream: $error');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _listenToPurchaseUpdated(
|
||||
List<PurchaseDetails> purchaseDetailsList) async {
|
||||
for (final purchaseDetails in purchaseDetailsList) {
|
||||
_log.info(() => 'New PurchaseDetails instance received: '
|
||||
'productID=${purchaseDetails.productID}, '
|
||||
'status=${purchaseDetails.status}, '
|
||||
'purchaseID=${purchaseDetails.purchaseID}, '
|
||||
'error=${purchaseDetails.error}, '
|
||||
'pendingCompletePurchase=${purchaseDetails.pendingCompletePurchase}');
|
||||
|
||||
if (purchaseDetails.productID != AdRemovalPurchase.productId) {
|
||||
_log.severe("The handling of the product with id "
|
||||
"'${purchaseDetails.productID}' is not implemented.");
|
||||
_adRemoval = const AdRemovalPurchase.notStarted();
|
||||
notifyListeners();
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (purchaseDetails.status) {
|
||||
case PurchaseStatus.pending:
|
||||
_adRemoval = const AdRemovalPurchase.pending();
|
||||
notifyListeners();
|
||||
break;
|
||||
case PurchaseStatus.purchased:
|
||||
case PurchaseStatus.restored:
|
||||
bool valid = await _verifyPurchase(purchaseDetails);
|
||||
if (valid) {
|
||||
_adRemoval = const AdRemovalPurchase.active();
|
||||
if (purchaseDetails.status == PurchaseStatus.purchased) {
|
||||
showSnackBar('Thank you for your support!');
|
||||
}
|
||||
notifyListeners();
|
||||
} else {
|
||||
_log.severe('Purchase verification failed: $purchaseDetails');
|
||||
_adRemoval = AdRemovalPurchase.error(
|
||||
StateError('Purchase could not be verified'));
|
||||
notifyListeners();
|
||||
}
|
||||
break;
|
||||
case PurchaseStatus.error:
|
||||
_log.severe('Error with purchase: ${purchaseDetails.error}');
|
||||
_adRemoval = AdRemovalPurchase.error(purchaseDetails.error!);
|
||||
notifyListeners();
|
||||
break;
|
||||
case PurchaseStatus.canceled:
|
||||
_adRemoval = const AdRemovalPurchase.notStarted();
|
||||
notifyListeners();
|
||||
break;
|
||||
}
|
||||
|
||||
if (purchaseDetails.pendingCompletePurchase) {
|
||||
// Confirm purchase back to the store.
|
||||
await inAppPurchaseInstance.completePurchase(purchaseDetails);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _reportError(String message) {
|
||||
_log.severe(message);
|
||||
showSnackBar(message);
|
||||
_adRemoval = AdRemovalPurchase.error(message);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) async {
|
||||
_log.info('Verifying purchase: ${purchaseDetails.verificationData}');
|
||||
// TODO: verify the purchase.
|
||||
// See the info in [purchaseDetails.verificationData] to learn more.
|
||||
// There's also a codelab that explains purchase verification
|
||||
// on the backend:
|
||||
// https://codelabs.developers.google.com/codelabs/flutter-in-app-purchases#9
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../audio/audio_controller.dart';
|
||||
import '../audio/sounds.dart';
|
||||
import '../player_progress/player_progress.dart';
|
||||
import '../style/palette.dart';
|
||||
import '../style/responsive_screen.dart';
|
||||
import 'levels.dart';
|
||||
|
||||
class LevelSelectionScreen extends StatelessWidget {
|
||||
const LevelSelectionScreen({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.watch<Palette>();
|
||||
final playerProgress = context.watch<PlayerProgress>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: palette.backgroundLevelSelection,
|
||||
body: ResponsiveScreen(
|
||||
squarishMainArea: Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Select level',
|
||||
style:
|
||||
TextStyle(fontFamily: 'Permanent Marker', fontSize: 30),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 50),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
for (final level in gameLevels)
|
||||
ListTile(
|
||||
enabled: playerProgress.highestLevelReached >=
|
||||
level.number - 1,
|
||||
onTap: () {
|
||||
final audioController = context.read<AudioController>();
|
||||
audioController.playSfx(SfxType.buttonTap);
|
||||
|
||||
GoRouter.of(context)
|
||||
.go('/play/session/${level.number}');
|
||||
},
|
||||
leading: Text(level.number.toString()),
|
||||
title: Text('Level #${level.number}'),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
rectangularMenuArea: ElevatedButton(
|
||||
onPressed: () {
|
||||
GoRouter.of(context).pop();
|
||||
},
|
||||
child: const Text('Back'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
49
game_template/lib/src/level_selection/levels.dart
Normal file
49
game_template/lib/src/level_selection/levels.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
const gameLevels = [
|
||||
GameLevel(
|
||||
number: 1,
|
||||
difficulty: 5,
|
||||
// TODO: When ready, change these achievement IDs.
|
||||
// You configure this in App Store Connect.
|
||||
achievementIdIOS: 'first_win',
|
||||
// You get this string when you configure an achievement in Play Console.
|
||||
achievementIdAndroid: 'NhkIwB69ejkMAOOLDb',
|
||||
),
|
||||
GameLevel(
|
||||
number: 2,
|
||||
difficulty: 42,
|
||||
),
|
||||
GameLevel(
|
||||
number: 3,
|
||||
difficulty: 100,
|
||||
achievementIdIOS: 'finished',
|
||||
achievementIdAndroid: 'CdfIhE96aspNWLGSQg',
|
||||
),
|
||||
];
|
||||
|
||||
class GameLevel {
|
||||
final int number;
|
||||
|
||||
final int difficulty;
|
||||
|
||||
/// The achievement to unlock when the level is finished, if any.
|
||||
final String? achievementIdIOS;
|
||||
|
||||
final String? achievementIdAndroid;
|
||||
|
||||
bool get awardsAchievement => achievementIdAndroid != null;
|
||||
|
||||
const GameLevel({
|
||||
required this.number,
|
||||
required this.difficulty,
|
||||
this.achievementIdIOS,
|
||||
this.achievementIdAndroid,
|
||||
}) : assert(
|
||||
(achievementIdAndroid != null && achievementIdIOS != null) ||
|
||||
(achievementIdAndroid == null && achievementIdIOS == null),
|
||||
'Either both iOS and Android achievement ID must be provided, '
|
||||
'or none');
|
||||
}
|
||||
123
game_template/lib/src/main_menu/main_menu_screen.dart
Normal file
123
game_template/lib/src/main_menu/main_menu_screen.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../audio/audio_controller.dart';
|
||||
import '../audio/sounds.dart';
|
||||
import '../games_services/games_services.dart';
|
||||
import '../settings/settings.dart';
|
||||
import '../style/palette.dart';
|
||||
import '../style/responsive_screen.dart';
|
||||
|
||||
class MainMenuScreen extends StatelessWidget {
|
||||
const MainMenuScreen({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.watch<Palette>();
|
||||
final gamesServicesController = context.watch<GamesServicesController?>();
|
||||
final settingsController = context.watch<SettingsController>();
|
||||
final audioController = context.watch<AudioController>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: palette.backgroundMain,
|
||||
body: ResponsiveScreen(
|
||||
mainAreaProminence: 0.45,
|
||||
squarishMainArea: Center(
|
||||
child: Transform.rotate(
|
||||
angle: -0.1,
|
||||
child: const Text(
|
||||
'Flutter Game Template!',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Permanent Marker',
|
||||
fontSize: 55,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
rectangularMenuArea: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
audioController.playSfx(SfxType.buttonTap);
|
||||
GoRouter.of(context).go('/play');
|
||||
},
|
||||
child: const Text('Play'),
|
||||
),
|
||||
_gap,
|
||||
if (gamesServicesController != null) ...[
|
||||
_hideUntilReady(
|
||||
ready: gamesServicesController.signedIn,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => gamesServicesController.showAchievements(),
|
||||
child: const Text('Achievements'),
|
||||
),
|
||||
),
|
||||
_gap,
|
||||
_hideUntilReady(
|
||||
ready: gamesServicesController.signedIn,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => gamesServicesController.showLeaderboard(),
|
||||
child: const Text('Leaderboard'),
|
||||
),
|
||||
),
|
||||
_gap,
|
||||
],
|
||||
ElevatedButton(
|
||||
onPressed: () => GoRouter.of(context).go('/settings'),
|
||||
child: const Text('Settings'),
|
||||
),
|
||||
_gap,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 32),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: settingsController.muted,
|
||||
builder: (context, muted, child) {
|
||||
return IconButton(
|
||||
onPressed: () => settingsController.toggleMuted(),
|
||||
icon: Icon(muted ? Icons.volume_off : Icons.volume_up),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
_gap,
|
||||
const Text('Music by Mr Smith'),
|
||||
_gap,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Prevents the game from showing game-services-related menu items
|
||||
/// until we're sure the player is signed in.
|
||||
///
|
||||
/// This normally happens immediately after game start, so players will not
|
||||
/// see any flash. The exception is folks who decline to use Game Center
|
||||
/// or Google Play Game Services, or who haven't yet set it up.
|
||||
Widget _hideUntilReady({required Widget child, required Future<bool> ready}) {
|
||||
return FutureBuilder<bool>(
|
||||
future: ready,
|
||||
builder: (context, snapshot) {
|
||||
// Use Visibility here so that we have the space for the buttons
|
||||
// ready.
|
||||
return Visibility(
|
||||
visible: snapshot.data ?? false,
|
||||
maintainState: true,
|
||||
maintainSize: true,
|
||||
maintainAnimation: true,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static const _gap = SizedBox(height: 10);
|
||||
}
|
||||
180
game_template/lib/src/play_session/play_session_screen.dart
Normal file
180
game_template/lib/src/play_session/play_session_screen.dart
Normal file
@@ -0,0 +1,180 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:logging/logging.dart' hide Level;
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../ads/ads_controller.dart';
|
||||
import '../audio/audio_controller.dart';
|
||||
import '../audio/sounds.dart';
|
||||
import '../game_internals/level_state.dart';
|
||||
import '../games_services/games_services.dart';
|
||||
import '../games_services/score.dart';
|
||||
import '../in_app_purchase/in_app_purchase.dart';
|
||||
import '../level_selection/levels.dart';
|
||||
import '../player_progress/player_progress.dart';
|
||||
import '../style/confetti.dart';
|
||||
import '../style/palette.dart';
|
||||
|
||||
class PlaySessionScreen extends StatefulWidget {
|
||||
final GameLevel level;
|
||||
|
||||
const PlaySessionScreen(this.level, {Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<PlaySessionScreen> createState() => _PlaySessionScreenState();
|
||||
}
|
||||
|
||||
class _PlaySessionScreenState extends State<PlaySessionScreen> {
|
||||
static final _log = Logger('PlaySessionScreen');
|
||||
|
||||
static const _celebrationDuration = Duration(milliseconds: 2000);
|
||||
|
||||
static const _preCelebrationDuration = Duration(milliseconds: 500);
|
||||
|
||||
bool _duringCelebration = false;
|
||||
|
||||
late DateTime _startOfPlay;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.watch<Palette>();
|
||||
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => LevelState(
|
||||
goal: widget.level.difficulty,
|
||||
onWin: _playerWon,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: IgnorePointer(
|
||||
ignoring: _duringCelebration,
|
||||
child: Scaffold(
|
||||
backgroundColor: palette.backgroundPlaySession,
|
||||
body: Stack(
|
||||
children: [
|
||||
Center(
|
||||
// This is the entirety of the "game".
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: InkResponse(
|
||||
onTap: () => GoRouter.of(context).push('/settings'),
|
||||
child: Image.asset(
|
||||
'assets/images/settings.png',
|
||||
semanticLabel: 'Settings',
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text('Drag the slider to ${widget.level.difficulty}%'
|
||||
' or above!'),
|
||||
Consumer<LevelState>(
|
||||
builder: (context, levelState, child) => Slider(
|
||||
label: 'Level Progress',
|
||||
autofocus: true,
|
||||
value: levelState.progress / 100,
|
||||
onChanged: (value) =>
|
||||
levelState.setProgress((value * 100).round()),
|
||||
onChangeEnd: (value) => levelState.evaluate(),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => GoRouter.of(context).pop(),
|
||||
child: const Text('Back'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox.expand(
|
||||
child: Visibility(
|
||||
visible: _duringCelebration,
|
||||
child: IgnorePointer(
|
||||
child: Confetti(
|
||||
isStopped: !_duringCelebration,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_startOfPlay = DateTime.now();
|
||||
|
||||
// Preload ad for the win screen.
|
||||
final adsRemoved =
|
||||
context.read<InAppPurchaseController?>()?.adRemoval.active ?? false;
|
||||
if (!adsRemoved) {
|
||||
final adsController = context.read<AdsController?>();
|
||||
adsController?.preloadAd();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playerWon() async {
|
||||
_log.info('Level ${widget.level.number} won');
|
||||
|
||||
final score = Score(
|
||||
widget.level.number,
|
||||
widget.level.difficulty,
|
||||
DateTime.now().difference(_startOfPlay),
|
||||
);
|
||||
|
||||
final playerProgress = context.read<PlayerProgress>();
|
||||
playerProgress.setLevelReached(widget.level.number);
|
||||
|
||||
// Let the player see the game just after winning for a bit.
|
||||
await Future<void>.delayed(_preCelebrationDuration);
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_duringCelebration = true;
|
||||
});
|
||||
|
||||
final audioController = context.read<AudioController>();
|
||||
audioController.playSfx(SfxType.congrats);
|
||||
|
||||
final gamesServicesController = context.read<GamesServicesController?>();
|
||||
if (gamesServicesController != null) {
|
||||
// Award achievement.
|
||||
if (widget.level.awardsAchievement) {
|
||||
await gamesServicesController.awardAchievement(
|
||||
android: widget.level.achievementIdAndroid!,
|
||||
iOS: widget.level.achievementIdIOS!,
|
||||
);
|
||||
}
|
||||
|
||||
// Send score to leaderboard.
|
||||
await gamesServicesController.submitLeaderboardScore(score);
|
||||
}
|
||||
|
||||
/// Give the player some time to see the celebration animation.
|
||||
await Future<void>.delayed(_celebrationDuration);
|
||||
if (!mounted) return;
|
||||
|
||||
GoRouter.of(context).go('/play/won', extra: {'score': score});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'player_progress_persistence.dart';
|
||||
|
||||
/// An implementation of [PlayerProgressPersistence] that uses
|
||||
/// `package:shared_preferences`.
|
||||
class LocalStoragePlayerProgressPersistence extends PlayerProgressPersistence {
|
||||
final Future<SharedPreferences> instanceFuture =
|
||||
SharedPreferences.getInstance();
|
||||
|
||||
@override
|
||||
Future<int> getHighestLevelReached() async {
|
||||
final prefs = await instanceFuture;
|
||||
return prefs.getInt('highestLevelReached') ?? 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveHighestLevelReached(int level) async {
|
||||
final prefs = await instanceFuture;
|
||||
await prefs.setInt('highestLevelReached', level);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'player_progress_persistence.dart';
|
||||
|
||||
/// An in-memory implementation of [PlayerProgressPersistence].
|
||||
/// Useful for testing.
|
||||
class MemoryOnlyPlayerProgressPersistence implements PlayerProgressPersistence {
|
||||
int level = 0;
|
||||
|
||||
@override
|
||||
Future<int> getHighestLevelReached() async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
return level;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveHighestLevelReached(int level) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
this.level = level;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
/// An interface of persistence stores for the player's progress.
|
||||
///
|
||||
/// Implementations can range from simple in-memory storage through
|
||||
/// local preferences to cloud saves.
|
||||
abstract class PlayerProgressPersistence {
|
||||
Future<int> getHighestLevelReached();
|
||||
|
||||
Future<void> saveHighestLevelReached(int level);
|
||||
}
|
||||
57
game_template/lib/src/player_progress/player_progress.dart
Normal file
57
game_template/lib/src/player_progress/player_progress.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persistence/player_progress_persistence.dart';
|
||||
|
||||
/// Encapsulates the player's progress.
|
||||
class PlayerProgress extends ChangeNotifier {
|
||||
static const maxHighestScoresPerPlayer = 10;
|
||||
|
||||
final PlayerProgressPersistence _store;
|
||||
|
||||
int _highestLevelReached = 0;
|
||||
|
||||
/// Creates an instance of [PlayerProgress] backed by an injected
|
||||
/// persistence [store].
|
||||
PlayerProgress(PlayerProgressPersistence store) : _store = store;
|
||||
|
||||
/// The highest level that the player has reached so far.
|
||||
int get highestLevelReached => _highestLevelReached;
|
||||
|
||||
/// Fetches the latest data from the backing persistence store.
|
||||
Future<void> getLatestFromStore() async {
|
||||
final level = await _store.getHighestLevelReached();
|
||||
if (level > _highestLevelReached) {
|
||||
_highestLevelReached = level;
|
||||
notifyListeners();
|
||||
} else if (level < _highestLevelReached) {
|
||||
await _store.saveHighestLevelReached(_highestLevelReached);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets the player's progress so it's like if they just started
|
||||
/// playing the game for the first time.
|
||||
void reset() {
|
||||
_highestLevelReached = 0;
|
||||
notifyListeners();
|
||||
_store.saveHighestLevelReached(_highestLevelReached);
|
||||
}
|
||||
|
||||
/// Registers [level] as reached.
|
||||
///
|
||||
/// If this is higher than [highestLevelReached], it will update that
|
||||
/// value and save it to the injected persistence store.
|
||||
void setLevelReached(int level) {
|
||||
if (level > _highestLevelReached) {
|
||||
_highestLevelReached = level;
|
||||
notifyListeners();
|
||||
|
||||
unawaited(_store.saveHighestLevelReached(level));
|
||||
}
|
||||
}
|
||||
}
|
||||
75
game_template/lib/src/settings/custom_name_dialog.dart
Normal file
75
game_template/lib/src/settings/custom_name_dialog.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:game_template/src/settings/settings.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
void showCustomNameDialog(BuildContext context) {
|
||||
showGeneralDialog(
|
||||
context: context,
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
CustomNameDialog(animation: animation));
|
||||
}
|
||||
|
||||
class CustomNameDialog extends StatefulWidget {
|
||||
final Animation<double> animation;
|
||||
|
||||
const CustomNameDialog({required this.animation, Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CustomNameDialog> createState() => _CustomNameDialogState();
|
||||
}
|
||||
|
||||
class _CustomNameDialogState extends State<CustomNameDialog> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScaleTransition(
|
||||
scale: CurvedAnimation(
|
||||
parent: widget.animation,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
child: SimpleDialog(
|
||||
title: const Text('Change name'),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
maxLength: 12,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||
textAlign: TextAlign.center,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
textInputAction: TextInputAction.done,
|
||||
onChanged: (value) {
|
||||
context.read<SettingsController>().setPlayerName(value);
|
||||
},
|
||||
onSubmitted: (value) {
|
||||
// Player tapped 'Submit'/'Done' on their keyboard.
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
_controller.text = context.read<SettingsController>().playerName.value;
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'settings_persistence.dart';
|
||||
|
||||
/// An implementation of [SettingsPersistence] that uses
|
||||
/// `package:shared_preferences`.
|
||||
class LocalStorageSettingsPersistence extends SettingsPersistence {
|
||||
final Future<SharedPreferences> instanceFuture =
|
||||
SharedPreferences.getInstance();
|
||||
|
||||
@override
|
||||
Future<bool> getMusicOn() async {
|
||||
final prefs = await instanceFuture;
|
||||
return prefs.getBool('musicOn') ?? true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> getMuted({required bool defaultValue}) async {
|
||||
final prefs = await instanceFuture;
|
||||
return prefs.getBool('mute') ?? defaultValue;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getPlayerName() async {
|
||||
final prefs = await instanceFuture;
|
||||
return prefs.getString('playerName') ?? 'Player';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> getSoundsOn() async {
|
||||
final prefs = await instanceFuture;
|
||||
return prefs.getBool('soundsOn') ?? true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveMusicOn(bool value) async {
|
||||
final prefs = await instanceFuture;
|
||||
await prefs.setBool('musicOn', value);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveMuted(bool value) async {
|
||||
final prefs = await instanceFuture;
|
||||
await prefs.setBool('mute', value);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> savePlayerName(String value) async {
|
||||
final prefs = await instanceFuture;
|
||||
await prefs.setString('playerName', value);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveSoundsOn(bool value) async {
|
||||
final prefs = await instanceFuture;
|
||||
await prefs.setBool('soundsOn', value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'package:game_template/src/settings/persistence/settings_persistence.dart';
|
||||
|
||||
/// An in-memory implementation of [SettingsPersistence].
|
||||
/// Useful for testing.
|
||||
class MemoryOnlySettingsPersistence implements SettingsPersistence {
|
||||
bool musicOn = true;
|
||||
|
||||
bool soundsOn = true;
|
||||
|
||||
bool muted = false;
|
||||
|
||||
String playerName = 'Player';
|
||||
|
||||
@override
|
||||
Future<bool> getMusicOn() async => musicOn;
|
||||
|
||||
@override
|
||||
Future<bool> getMuted({required bool defaultValue}) async => muted;
|
||||
|
||||
@override
|
||||
Future<String> getPlayerName() async => playerName;
|
||||
|
||||
@override
|
||||
Future<bool> getSoundsOn() async => soundsOn;
|
||||
|
||||
@override
|
||||
Future<void> saveMusicOn(bool value) async => musicOn = value;
|
||||
|
||||
@override
|
||||
Future<void> saveMuted(bool value) async => muted = value;
|
||||
|
||||
@override
|
||||
Future<void> savePlayerName(String value) async => playerName = value;
|
||||
|
||||
@override
|
||||
Future<void> saveSoundsOn(bool value) async => soundsOn = value;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
/// An interface of persistence stores for settings.
|
||||
///
|
||||
/// Implementations can range from simple in-memory storage through
|
||||
/// local preferences to cloud-based solutions.
|
||||
abstract class SettingsPersistence {
|
||||
Future<bool> getMusicOn();
|
||||
|
||||
Future<bool> getMuted({required bool defaultValue});
|
||||
|
||||
Future<String> getPlayerName();
|
||||
|
||||
Future<bool> getSoundsOn();
|
||||
|
||||
Future<void> saveMusicOn(bool value);
|
||||
|
||||
Future<void> saveMuted(bool value);
|
||||
|
||||
Future<void> savePlayerName(String value);
|
||||
|
||||
Future<void> saveSoundsOn(bool value);
|
||||
}
|
||||
62
game_template/lib/src/settings/settings.dart
Normal file
62
game_template/lib/src/settings/settings.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'persistence/settings_persistence.dart';
|
||||
|
||||
/// An class that holds settings like [playerName] or [musicOn],
|
||||
/// and saves them to an injected persistence store.
|
||||
class SettingsController {
|
||||
final SettingsPersistence _persistence;
|
||||
|
||||
/// Whether or not the sound is on at all. This overrides both music
|
||||
/// and sound.
|
||||
ValueNotifier<bool> muted = ValueNotifier(false);
|
||||
|
||||
ValueNotifier<String> playerName = ValueNotifier('Player');
|
||||
|
||||
ValueNotifier<bool> soundsOn = ValueNotifier(false);
|
||||
|
||||
ValueNotifier<bool> musicOn = ValueNotifier(false);
|
||||
|
||||
/// Creates a new instance of [SettingsController] backed by [persistence].
|
||||
SettingsController({required SettingsPersistence persistence})
|
||||
: _persistence = persistence;
|
||||
|
||||
/// Asynchronously loads values from the injected persistence store.
|
||||
Future<void> loadStateFromPersistence() async {
|
||||
await Future.wait([
|
||||
_persistence
|
||||
// On the web, sound can only start after user interaction, so
|
||||
// we start muted there.
|
||||
// On any other platform, we start unmuted.
|
||||
.getMuted(defaultValue: kIsWeb)
|
||||
.then((value) => muted.value = value),
|
||||
_persistence.getSoundsOn().then((value) => soundsOn.value = value),
|
||||
_persistence.getMusicOn().then((value) => musicOn.value = value),
|
||||
_persistence.getPlayerName().then((value) => playerName.value = value),
|
||||
]);
|
||||
}
|
||||
|
||||
void setPlayerName(String name) {
|
||||
playerName.value = name;
|
||||
_persistence.savePlayerName(playerName.value);
|
||||
}
|
||||
|
||||
void toggleMusicOn() {
|
||||
musicOn.value = !musicOn.value;
|
||||
_persistence.saveMusicOn(musicOn.value);
|
||||
}
|
||||
|
||||
void toggleMuted() {
|
||||
muted.value = !muted.value;
|
||||
_persistence.saveMuted(muted.value);
|
||||
}
|
||||
|
||||
void toggleSoundsOn() {
|
||||
soundsOn.value = !soundsOn.value;
|
||||
_persistence.saveSoundsOn(soundsOn.value);
|
||||
}
|
||||
}
|
||||
187
game_template/lib/src/settings/settings_screen.dart
Normal file
187
game_template/lib/src/settings/settings_screen.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../in_app_purchase/in_app_purchase.dart';
|
||||
import '../player_progress/player_progress.dart';
|
||||
import '../style/palette.dart';
|
||||
import '../style/responsive_screen.dart';
|
||||
import 'custom_name_dialog.dart';
|
||||
import 'settings.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({Key? key}) : super(key: key);
|
||||
|
||||
static const _gap = SizedBox(height: 60);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsController>();
|
||||
final palette = context.watch<Palette>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: palette.backgroundSettings,
|
||||
body: ResponsiveScreen(
|
||||
squarishMainArea: ListView(
|
||||
children: [
|
||||
_gap,
|
||||
const Text(
|
||||
'Settings',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Permanent Marker',
|
||||
fontSize: 55,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
_gap,
|
||||
const _NameChangeLine(
|
||||
'Name',
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: settings.soundsOn,
|
||||
builder: (context, soundsOn, child) => _SettingsLine(
|
||||
'Sound FX',
|
||||
Icon(soundsOn ? Icons.graphic_eq : Icons.volume_off),
|
||||
onSelected: () => settings.toggleSoundsOn(),
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: settings.musicOn,
|
||||
builder: (context, musicOn, child) => _SettingsLine(
|
||||
'Music',
|
||||
Icon(musicOn ? Icons.music_note : Icons.music_off),
|
||||
onSelected: () => settings.toggleMusicOn(),
|
||||
),
|
||||
),
|
||||
Consumer<InAppPurchaseController?>(
|
||||
builder: (context, inAppPurchase, child) {
|
||||
if (inAppPurchase == null) {
|
||||
// In-app purchases are not supported yet.
|
||||
// Go to lib/main.dart and uncomment the lines that create
|
||||
// the InAppPurchaseController.
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget icon;
|
||||
VoidCallback? callback;
|
||||
if (inAppPurchase.adRemoval.active) {
|
||||
icon = const Icon(Icons.check);
|
||||
} else if (inAppPurchase.adRemoval.pending) {
|
||||
icon = const CircularProgressIndicator();
|
||||
} else {
|
||||
icon = const Icon(Icons.ad_units);
|
||||
callback = () {
|
||||
inAppPurchase.buy();
|
||||
};
|
||||
}
|
||||
return _SettingsLine(
|
||||
'Remove ads',
|
||||
icon,
|
||||
onSelected: callback,
|
||||
);
|
||||
}),
|
||||
_SettingsLine(
|
||||
'Reset progress',
|
||||
const Icon(Icons.delete),
|
||||
onSelected: () {
|
||||
context.read<PlayerProgress>().reset();
|
||||
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Player progress has been reset.')),
|
||||
);
|
||||
},
|
||||
),
|
||||
_gap,
|
||||
],
|
||||
),
|
||||
rectangularMenuArea: ElevatedButton(
|
||||
onPressed: () {
|
||||
GoRouter.of(context).pop();
|
||||
},
|
||||
child: const Text('Back'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NameChangeLine extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
const _NameChangeLine(this.title, {Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<SettingsController>();
|
||||
|
||||
return InkResponse(
|
||||
highlightShape: BoxShape.rectangle,
|
||||
onTap: () => showCustomNameDialog(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Permanent Marker',
|
||||
fontSize: 30,
|
||||
)),
|
||||
const Spacer(),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: settings.playerName,
|
||||
builder: (context, name, child) => Text(
|
||||
'‘$name’',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Permanent Marker',
|
||||
fontSize: 30,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsLine extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
final Widget icon;
|
||||
|
||||
final VoidCallback? onSelected;
|
||||
|
||||
const _SettingsLine(this.title, this.icon, {this.onSelected, Key? key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkResponse(
|
||||
highlightShape: BoxShape.rectangle,
|
||||
onTap: onSelected,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Permanent Marker',
|
||||
fontSize: 30,
|
||||
)),
|
||||
const Spacer(),
|
||||
icon,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
234
game_template/lib/src/style/confetti.dart
Normal file
234
game_template/lib/src/style/confetti.dart
Normal file
@@ -0,0 +1,234 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'dart:collection';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Shows a confetti (celebratory) animation: paper snippings falling down.
|
||||
///
|
||||
/// The widget fills the available space (like [SizedBox.expand] would).
|
||||
///
|
||||
/// When [isStopped] is `true`, the animation will not run. This is useful
|
||||
/// when the widget is not visible yet, for example. Provide [colors]
|
||||
/// to make the animation look good in context.
|
||||
///
|
||||
/// This is a partial port of this CodePen by Hemn Chawroka:
|
||||
/// https://codepen.io/iprodev/pen/azpWBr
|
||||
class Confetti extends StatefulWidget {
|
||||
static const _defaultColors = [
|
||||
Color(0xffd10841),
|
||||
Color(0xff1d75fb),
|
||||
Color(0xff0050bc),
|
||||
Color(0xffa2dcc7),
|
||||
];
|
||||
|
||||
final bool isStopped;
|
||||
|
||||
final List<Color> colors;
|
||||
|
||||
const Confetti({
|
||||
this.colors = _defaultColors,
|
||||
this.isStopped = false,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<Confetti> createState() => _ConfettiState();
|
||||
}
|
||||
|
||||
class ConfettiPainter extends CustomPainter {
|
||||
final defaultPaint = Paint();
|
||||
|
||||
final int snippingsCount = 200;
|
||||
|
||||
late final List<_PaperSnipping> _snippings;
|
||||
|
||||
Size? _size;
|
||||
|
||||
DateTime _lastTime = DateTime.now();
|
||||
|
||||
final UnmodifiableListView<Color> colors;
|
||||
|
||||
ConfettiPainter(
|
||||
{required Listenable animation, required Iterable<Color> colors})
|
||||
: colors = UnmodifiableListView(colors),
|
||||
super(repaint: animation);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (_size == null) {
|
||||
// First time we have a size.
|
||||
_snippings = List.generate(
|
||||
snippingsCount,
|
||||
(i) => _PaperSnipping(
|
||||
frontColor: colors[i % colors.length],
|
||||
bounds: size,
|
||||
));
|
||||
}
|
||||
|
||||
final didResize = _size != null && _size != size;
|
||||
final now = DateTime.now();
|
||||
final dt = now.difference(_lastTime);
|
||||
for (final snipping in _snippings) {
|
||||
if (didResize) {
|
||||
snipping.updateBounds(size);
|
||||
}
|
||||
snipping.update(dt.inMilliseconds / 1000);
|
||||
snipping.draw(canvas);
|
||||
}
|
||||
|
||||
_size = size;
|
||||
_lastTime = now;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class _ConfettiState extends State<Confetti>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomPaint(
|
||||
painter: ConfettiPainter(
|
||||
colors: widget.colors,
|
||||
animation: _controller,
|
||||
),
|
||||
willChange: true,
|
||||
child: const SizedBox.expand(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant Confetti oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.isStopped && !widget.isStopped) {
|
||||
_controller.repeat();
|
||||
} else if (!oldWidget.isStopped && widget.isStopped) {
|
||||
_controller.stop(canceled: false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
// We don't really care about the duration, since we're going to
|
||||
// use the controller on loop anyway.
|
||||
duration: const Duration(seconds: 1),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
if (!widget.isStopped) {
|
||||
_controller.repeat();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PaperSnipping {
|
||||
static final Random _random = Random();
|
||||
|
||||
static const degToRad = pi / 180;
|
||||
|
||||
static const backSideBlend = Color(0x70EEEEEE);
|
||||
|
||||
Size _bounds;
|
||||
|
||||
late final _Vector position = _Vector(
|
||||
_random.nextDouble() * _bounds.width,
|
||||
_random.nextDouble() * _bounds.height,
|
||||
);
|
||||
|
||||
final double rotationSpeed = 800 + _random.nextDouble() * 600;
|
||||
|
||||
final double angle = _random.nextDouble() * 360 * degToRad;
|
||||
|
||||
double rotation = _random.nextDouble() * 360 * degToRad;
|
||||
|
||||
double cosA = 1.0;
|
||||
|
||||
final double size = 7.0;
|
||||
|
||||
final double oscillationSpeed = 0.5 + _random.nextDouble() * 1.5;
|
||||
|
||||
final double xSpeed = 40;
|
||||
|
||||
final double ySpeed = 50 + _random.nextDouble() * 60;
|
||||
|
||||
late List<_Vector> corners = List.generate(4, (i) {
|
||||
final angle = this.angle + degToRad * (45 + i * 90);
|
||||
return _Vector(cos(angle), sin(angle));
|
||||
});
|
||||
|
||||
double time = _random.nextDouble();
|
||||
|
||||
final Color frontColor;
|
||||
|
||||
late final Color backColor = Color.alphaBlend(backSideBlend, frontColor);
|
||||
|
||||
final paint = Paint()..style = PaintingStyle.fill;
|
||||
|
||||
_PaperSnipping({
|
||||
required this.frontColor,
|
||||
required Size bounds,
|
||||
}) : _bounds = bounds;
|
||||
|
||||
void draw(Canvas canvas) {
|
||||
if (cosA > 0) {
|
||||
paint.color = frontColor;
|
||||
} else {
|
||||
paint.color = backColor;
|
||||
}
|
||||
|
||||
final path = Path()
|
||||
..addPolygon(
|
||||
List.generate(
|
||||
4,
|
||||
(index) => Offset(
|
||||
position.x + corners[index].x * size,
|
||||
position.y + corners[index].y * size * cosA,
|
||||
)),
|
||||
true,
|
||||
);
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
void update(double dt) {
|
||||
time += dt;
|
||||
rotation += rotationSpeed * dt;
|
||||
cosA = cos(degToRad * rotation);
|
||||
position.x += cos(time * oscillationSpeed) * xSpeed * dt;
|
||||
position.y += ySpeed * dt;
|
||||
if (position.y > _bounds.height) {
|
||||
// Move the snipping back to the top.
|
||||
position.x = _random.nextDouble() * _bounds.width;
|
||||
position.y = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void updateBounds(Size newBounds) {
|
||||
if (!newBounds.contains(Offset(position.x, position.y))) {
|
||||
position.x = _random.nextDouble() * newBounds.width;
|
||||
position.y = _random.nextDouble() * newBounds.height;
|
||||
}
|
||||
_bounds = newBounds;
|
||||
}
|
||||
}
|
||||
|
||||
class _Vector {
|
||||
double x, y;
|
||||
_Vector(this.x, this.y);
|
||||
}
|
||||
124
game_template/lib/src/style/my_transition.dart
Normal file
124
game_template/lib/src/style/my_transition.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
CustomTransitionPage<T> buildMyTransition<T>({
|
||||
required Widget child,
|
||||
required Color color,
|
||||
String? name,
|
||||
Object? arguments,
|
||||
String? restorationId,
|
||||
LocalKey? key,
|
||||
}) {
|
||||
return CustomTransitionPage<T>(
|
||||
child: child,
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return _MyReveal(
|
||||
animation: animation,
|
||||
color: color,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
key: key,
|
||||
name: name,
|
||||
arguments: arguments,
|
||||
restorationId: restorationId,
|
||||
transitionDuration: const Duration(milliseconds: 700),
|
||||
);
|
||||
}
|
||||
|
||||
class _MyReveal extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
final Animation<double> animation;
|
||||
|
||||
final Color color;
|
||||
|
||||
const _MyReveal({
|
||||
required this.child,
|
||||
required this.animation,
|
||||
required this.color,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_MyReveal> createState() => _MyRevealState();
|
||||
}
|
||||
|
||||
class _MyRevealState extends State<_MyReveal> {
|
||||
static final _log = Logger('_InkRevealState');
|
||||
|
||||
bool _finished = false;
|
||||
|
||||
final _tween = Tween(begin: const Offset(0, -1), end: Offset.zero);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
widget.animation.addStatusListener(_statusListener);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _MyReveal oldWidget) {
|
||||
if (oldWidget.animation != widget.animation) {
|
||||
oldWidget.animation.removeStatusListener(_statusListener);
|
||||
widget.animation.addStatusListener(_statusListener);
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.animation.removeStatusListener(_statusListener);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
SlideTransition(
|
||||
position: _tween.animate(
|
||||
CurvedAnimation(
|
||||
parent: widget.animation,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeOutCubic,
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
color: widget.color,
|
||||
),
|
||||
),
|
||||
AnimatedOpacity(
|
||||
opacity: _finished ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: widget.child,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _statusListener(AnimationStatus status) {
|
||||
_log.fine(() => 'status: $status');
|
||||
switch (status) {
|
||||
case AnimationStatus.completed:
|
||||
setState(() {
|
||||
_finished = true;
|
||||
});
|
||||
break;
|
||||
case AnimationStatus.forward:
|
||||
case AnimationStatus.dismissed:
|
||||
case AnimationStatus.reverse:
|
||||
setState(() {
|
||||
_finished = false;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
37
game_template/lib/src/style/palette.dart
Normal file
37
game_template/lib/src/style/palette.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A palette of colors to be used in the game.
|
||||
///
|
||||
/// The reason we're not going with something like Material Design's
|
||||
/// `Theme` is simply that this is simpler to work with and yet gives
|
||||
/// us everything we need for a game.
|
||||
///
|
||||
/// Games generally have more radical color palettes than apps. For example,
|
||||
/// every level of a game can have radically different colors.
|
||||
/// At the same time, games rarely support dark mode.
|
||||
///
|
||||
/// Colors taken from this fun palette:
|
||||
/// https://lospec.com/palette-list/crayola84
|
||||
///
|
||||
/// Colors here are implemented as getters so that hot reloading works.
|
||||
/// In practice, we could just as easily implement the colors
|
||||
/// as `static const`. But this way the palette is more malleable:
|
||||
/// we could allow players to customize colors, for example,
|
||||
/// or even get the colors from the network.
|
||||
class Palette {
|
||||
Color get pen => const Color(0xff1d75fb);
|
||||
Color get darkPen => const Color(0xFF0050bc);
|
||||
Color get redPen => const Color(0xFFd10841);
|
||||
Color get inkFullOpacity => const Color(0xff352b42);
|
||||
Color get ink => const Color(0xee352b42);
|
||||
Color get backgroundMain => const Color(0xffffffd1);
|
||||
Color get backgroundLevelSelection => const Color(0xffa2dcc7);
|
||||
Color get backgroundPlaySession => const Color(0xffffebb5);
|
||||
Color get background4 => const Color(0xffffd7ff);
|
||||
Color get backgroundSettings => const Color(0xffbfc8e3);
|
||||
Color get trueWhite => const Color(0xffffffff);
|
||||
}
|
||||
125
game_template/lib/src/style/responsive_screen.dart
Normal file
125
game_template/lib/src/style/responsive_screen.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A widget that makes it easy to create a screen with a square-ish
|
||||
/// main area, a smaller menu area, and a small area for a message on top.
|
||||
/// It works in both orientations on mobile- and tablet-sized screens.
|
||||
class ResponsiveScreen extends StatelessWidget {
|
||||
/// This is the "hero" of the screen. It's more or less square, and will
|
||||
/// be placed in the visual "center" of the screen.
|
||||
final Widget squarishMainArea;
|
||||
|
||||
/// The second-largest area after [squarishMainArea]. It can be narrow
|
||||
/// or wide.
|
||||
final Widget rectangularMenuArea;
|
||||
|
||||
/// An area reserved for some static text close to the top of the screen.
|
||||
final Widget topMessageArea;
|
||||
|
||||
/// How much bigger should the [squarishMainArea] be compared to the other
|
||||
/// elements.
|
||||
final double mainAreaProminence;
|
||||
|
||||
const ResponsiveScreen({
|
||||
required this.squarishMainArea,
|
||||
required this.rectangularMenuArea,
|
||||
this.topMessageArea = const SizedBox.shrink(),
|
||||
this.mainAreaProminence = 0.8,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// This widget wants to fill the whole screen.
|
||||
final size = constraints.biggest;
|
||||
final padding = EdgeInsets.all(size.shortestSide / 30);
|
||||
|
||||
if (size.height >= size.width) {
|
||||
// "Portrait" / "mobile" mode.
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: topMessageArea,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: (mainAreaProminence * 100).round(),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
minimum: padding,
|
||||
child: squarishMainArea,
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
maintainBottomViewPadding: true,
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: rectangularMenuArea,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// "Landscape" / "tablet" mode.
|
||||
final isLarge = size.width > 900;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: isLarge ? 7 : 5,
|
||||
child: SafeArea(
|
||||
right: false,
|
||||
maintainBottomViewPadding: true,
|
||||
minimum: padding,
|
||||
child: squarishMainArea,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Column(
|
||||
children: [
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
left: false,
|
||||
maintainBottomViewPadding: true,
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: topMessageArea,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
left: false,
|
||||
maintainBottomViewPadding: true,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: rectangularMenuArea,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
18
game_template/lib/src/style/snack_bar.dart
Normal file
18
game_template/lib/src/style/snack_bar.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shows [message] in a snack bar as long as a [ScaffoldMessengerState]
|
||||
/// with global key [scaffoldMessengerKey] is anywhere in the widget tree.
|
||||
void showSnackBar(String message) {
|
||||
final messenger = scaffoldMessengerKey.currentState;
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Use this when creating [MaterialApp] if you want [showSnackBar] to work.
|
||||
final GlobalKey<ScaffoldMessengerState> scaffoldMessengerKey =
|
||||
GlobalKey(debugLabel: 'scaffoldMessengerKey');
|
||||
73
game_template/lib/src/win_game/win_game_screen.dart
Normal file
73
game_template/lib/src/win_game/win_game_screen.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright 2022, the Flutter project authors. Please see the AUTHORS file
|
||||
// for details. All rights reserved. Use of this source code is governed by a
|
||||
// BSD-style license that can be found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../ads/ads_controller.dart';
|
||||
import '../ads/banner_ad_widget.dart';
|
||||
import '../games_services/score.dart';
|
||||
import '../in_app_purchase/in_app_purchase.dart';
|
||||
import '../style/palette.dart';
|
||||
import '../style/responsive_screen.dart';
|
||||
|
||||
class WinGameScreen extends StatelessWidget {
|
||||
final Score score;
|
||||
|
||||
const WinGameScreen({
|
||||
Key? key,
|
||||
required this.score,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final adsControllerAvailable = context.watch<AdsController?>() != null;
|
||||
final adsRemoved =
|
||||
context.watch<InAppPurchaseController?>()?.adRemoval.active ?? false;
|
||||
final palette = context.watch<Palette>();
|
||||
|
||||
const gap = SizedBox(height: 10);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: palette.backgroundPlaySession,
|
||||
body: ResponsiveScreen(
|
||||
squarishMainArea: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
if (adsControllerAvailable && !adsRemoved) ...[
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: BannerAdWidget(),
|
||||
),
|
||||
),
|
||||
],
|
||||
gap,
|
||||
const Center(
|
||||
child: Text(
|
||||
'You won!',
|
||||
style: TextStyle(fontFamily: 'Permanent Marker', fontSize: 50),
|
||||
),
|
||||
),
|
||||
gap,
|
||||
Center(
|
||||
child: Text(
|
||||
'Score: ${score.score}\n'
|
||||
'Time: ${score.formattedTime}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Permanent Marker', fontSize: 20),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
rectangularMenuArea: ElevatedButton(
|
||||
onPressed: () {
|
||||
GoRouter.of(context).pop();
|
||||
},
|
||||
child: const Text('Continue'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user