Settings, config and saves: the source
Open the live demo · Read the guide · View on GitHub
Regions the guide quotes: config (line 27), save (line 44), use (line 111).
1/// What a player has changed about how the game behaves for them, and where
2/// a run in progress is kept between launches.
3///
4/// **`flutter3d_game`'s real `GameConfig`, `SaveFile` and `SettingsPanel` are
5/// not a dependency of this app.** `GameConfig` is two maps of numbers with
6/// no file and no platform behind it; this page reimplements exactly that
7/// shape. `SaveFile` is thinner still — underneath its own few lines it is
8/// entirely the real `Storage` and `Snapshot` this app already depends on
9/// through `flutter3d_app` and `flutter3d_sim`, so this page's version calls
10/// those directly rather than reimplementing anything of substance.
11/// `SettingsPanel` itself is a Flutter widget over a `Bindings` and a
12/// gamepad's dead zone, both of which live in packages this app does not
13/// have; it is described in the guide rather than built here.
14///
15/// Quoted by `game_settings.md` and shown whole in the Source tab.
16library;
18import 'dart:convert';
20import 'package:flutter/widgets.dart';
21import 'package:flutter3d/flutter3d.dart';
22import 'package:flutter3d_app/flutter3d_app.dart';
23import 'package:flutter3d_showcase/src/demo/demo.dart';
24import 'package:flutter3d_sim/flutter3d_sim.dart';
25import 'package:vector_math/vector_math.dart';
27/// Everything a player has changed, as two maps of numbers: no file, no
28/// path, no platform. Where the bytes go is the application's business.
29final class _GameConfig {
30 _GameConfig({Map<String, double>? volumes, Map<String, double>? settings})
31 : volumes = <String, double>{...?volumes},
32 settings = <String, double>{...?settings};
34 final Map<String, double> volumes;
35 final Map<String, double> settings;
37 double volumeOf(String bus) => volumes[bus] ?? 1.0;
38 void setVolume(String bus, double volume) =>
39 volumes[bus] = volume.clamp(0.0, 1.0);
40 double settingOf(String name, double fallback) => settings[name] ?? fallback;
41 void setSetting(String name, double value) => settings[name] = value;
42}
44/// Where a run in progress is kept. Everything below the field names is the
45/// real `Storage` and `Snapshot` this app already depends on.
46final class _SaveFile {
47 _SaveFile({required String appName}) : _storage = defaultStorage(appName);
49 final Storage _storage;
50 static const String _name = 'save.json';
52 ({String level, Snapshot run})? read() {
53 final text = _storage.read(_name);
54 if (text == null) return null;
55 final json = jsonDecode(text) as Map<String, Object?>;
56 return (
57 level: json['level']! as String,
58 run: Snapshot.fromJson(json['run']! as Map<String, Object?>),
59 );
60 }
62 bool write(String level, Snapshot run) => _storage.write(
63 _name,
64 jsonEncode(<String, Object?>{'level': level, 'run': run.toJson()}),
65 );
67 void clear() => _storage.remove(_name);
68}
70final class GameSettingsDemo extends ShowcaseDemo {
71 late final String _report;
72 late final double _musicVolume;
73 late final double _sfxVolume;
74 late final String? _resumedLevel;
75 late final double? _resumedX;
76 late final bool _clearedSaveIsGone;
79 Scene build(DemoContext context) {
80 final (
81 String report,
82 double musicVolume,
83 double sfxVolume,
84 String? resumedLevel,
85 double? resumedX,
86 bool clearedSaveIsGone,
87 ) = _run();
88 _report = report;
89 _musicVolume = musicVolume;
90 _sfxVolume = sfxVolume;
91 _resumedLevel = resumedLevel;
92 _resumedX = resumedX;
93 _clearedSaveIsGone = clearedSaveIsGone;
94 final material = Material(
95 name: 'panel',
96 baseColor: Vector4(0.5, 0.7, 0.5, 1.0),
97 );
98 final node = MeshNode(
99 DeviceMesh.upload(context.device, SphereShape(segments: 16).build()),
100 material,
101 );
102 return Scene()
103 ..add(node)
104 ..add(
105 LightNode(name: 'sun', intensity: 3.0)
106 ..setLocalForward(Vector3(-0.4, -1.0, -0.3)),
107 );
108 }
110 static (String, double, double, String?, double?, bool) _run() {
111 final config = _GameConfig()
112 ..setVolume('music', 0.6)
113 ..setSetting('a11y.cameraMotion', 0.0);
114 final musicVolume = config.volumeOf('music');
115 final sfxVolume = config.volumeOf('sfx');
116 final cameraMotion = config.settingOf('a11y.cameraMotion', 1.0);
118 final save = _SaveFile(appName: 'flutter3d-showcase-demo');
119 final snapshot = Snapshot(<String, Object?>{'x': 4.5});
120 save.write('levels/one.json', snapshot);
121 final resumed = save.read();
122 save.clear();
123 final afterClear = save.read();
125 final resumedX = (resumed?.run.data['x'] as num?)?.toDouble();
126 final report =
127 'music volume: $musicVolume, sfx (never set): $sfxVolume\n'
128 'camera motion setting: $cameraMotion\n'
129 'resumed at level ${resumed?.level}, x=$resumedX\n'
130 'after clearing the save: $afterClear';
131 return (
132 report,
133 musicVolume,
134 sfxVolume,
135 resumed?.level,
136 resumedX,
137 afterClear == null,
138 );
139 }
142 Widget? customBody(BuildContext buildContext, DemoContext context) =>
143 Container(
144 color: const Color(0xFF14161A),
145 padding: const EdgeInsets.all(24),
146 alignment: Alignment.topLeft,
147 child: DefaultTextStyle(
148 style: const TextStyle(color: Color(0xFFE8E8EC), fontSize: 16),
149 child: Text(_report),
150 ),
151 );
154 void verify(Scene scene, FrameResult frame) {
155 if (frame.drawCalls < 1) {
156 throw StateError('the panel marker was not drawn');
157 }
158 // Compared as numbers, not read back out of `_report`: `sfxVolume`'s
159 // default is a whole-number double, and a web backend prints one of
160 // those without its trailing `.0` — a compiled `1` failing a substring
161 // match against `'sfx (never set): 1.0'` would be this check catching
162 // its own string, not the config.
163 if (_musicVolume != 0.6 || _sfxVolume != 1.0) {
164 throw StateError('a bus nobody set should default to full volume');
165 }
166 if (_resumedLevel != 'levels/one.json' || _resumedX != 4.5) {
167 throw StateError(
168 'a written save should read back with its own level '
169 'and its own state',
170 );
171 }
172 if (!_clearedSaveIsGone) {
173 throw StateError('a cleared save should read back as nothing');
174 }
175 }
176}