mirror of
https://github.com/flutter/samples.git
synced 2025-11-14 03:19:06 +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:
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,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user