summaryrefslogtreecommitdiff
path: root/lib/screens
diff options
context:
space:
mode:
Diffstat (limited to 'lib/screens')
-rw-r--r--lib/screens/game_over.dart63
-rw-r--r--lib/screens/level.dart286
-rw-r--r--lib/screens/level_selection.dart157
3 files changed, 506 insertions, 0 deletions
diff --git a/lib/screens/game_over.dart b/lib/screens/game_over.dart
new file mode 100644
index 0000000..76ea2fb
--- /dev/null
+++ b/lib/screens/game_over.dart
@@ -0,0 +1,63 @@
+import 'package:flutter/material.dart';
+import 'package:sense_the_rhythm/models/note.dart';
+import 'package:sense_the_rhythm/utils/simfile.dart';
+import 'package:sense_the_rhythm/screens/level.dart';
+
+class GameOverStats extends StatelessWidget {
+ const GameOverStats({super.key, required this.simfile, required this.notes});
+
+ final Simfile simfile;
+ final List<Note> notes;
+
+ @override
+ Widget build(BuildContext context) {
+ int hits = notes.where((note) => note.wasHit == true).length;
+ int misses = notes.where((note) => note.wasHit == false).length;
+ int total = notes.length;
+ int percent = (hits.toDouble() / total.toDouble() * 100).toInt();
+
+ return Scaffold(
+ appBar: AppBar(
+ leading: IconButton(
+ onPressed: () => Navigator.pop(context),
+ icon: Icon(Icons.arrow_back)),
+ title: Text('Game Stats'),
+ ),
+ body: Center(
+ child: Column(
+ children: [
+ Text(' $percent%',
+ style: TextStyle(
+ fontSize: 60,
+ fontWeight: FontWeight.bold,
+ color: Colors.orange)),
+ DataTable(columns: [
+ DataColumn(label: Container()),
+ DataColumn(label: Container()),
+ ], rows: [
+ DataRow(cells: [
+ DataCell(Text('Hits')),
+ DataCell(Text(hits.toString())),
+ ]),
+ DataRow(cells: [
+ DataCell(Text('Misses')),
+ DataCell(Text(misses.toString())),
+ ]),
+ DataRow(cells: [
+ DataCell(Text('Total')),
+ DataCell(Text(total.toString())),
+ ]),
+ ]),
+ TextButton(
+ onPressed: () {
+ Route route =
+ MaterialPageRoute(builder: (context) => Level(simfile));
+ Navigator.pushReplacement(context, route);
+ },
+ child: Text('Retry'))
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/screens/level.dart b/lib/screens/level.dart
new file mode 100644
index 0000000..16c7d11
--- /dev/null
+++ b/lib/screens/level.dart
@@ -0,0 +1,286 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:audioplayers/audioplayers.dart';
+import 'package:flutter/services.dart';
+import 'package:sense_the_rhythm/models/arrow_direction.dart';
+import 'package:sense_the_rhythm/models/input_direction.dart';
+import 'package:sense_the_rhythm/models/note.dart';
+import 'package:sense_the_rhythm/utils/esense_input.dart';
+import 'package:sense_the_rhythm/utils/simfile.dart';
+import 'package:sense_the_rhythm/widgets/arrows.dart';
+import 'package:sense_the_rhythm/screens/game_over.dart';
+
+class Level extends StatefulWidget {
+ const Level(this.simfile, {super.key});
+ final Simfile simfile;
+
+ @override
+ State<Level> createState() => _LevelState();
+}
+
+class _LevelState extends State<Level> with SingleTickerProviderStateMixin {
+ final player = AudioPlayer();
+ bool _isPlaying = true;
+ Duration? _duration;
+ Duration? _position;
+
+ StreamSubscription? _durationSubscription;
+ StreamSubscription? _positionSubscription;
+ StreamSubscription? _buttonSubscription;
+
+ final FocusNode _focusNode = FocusNode();
+ InputDirection inputDirection = InputDirection();
+
+ String hitOrMissMessage = 'Play!';
+
+ List<Note> notes = [];
+
+ late AnimationController _animationController;
+ late Animation<double> _animation;
+
+ @override
+ void setState(VoidCallback fn) {
+ // Subscriptions only can be closed asynchronously,
+ // therefore events can occur after widget has been disposed.
+ if (mounted) {
+ super.setState(fn);
+ }
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ ESenseInput.instance.resetAngles();
+
+ _animationController = AnimationController(
+ vsync: this,
+ duration: Duration(seconds: 2),
+ );
+ _animation =
+ Tween<double>(begin: 1.0, end: 0.0).animate(_animationController);
+ _animationController.forward();
+
+ // Use initial values from player
+ player.getDuration().then(
+ (value) => setState(() {
+ _duration = value;
+ }),
+ );
+ player.getCurrentPosition().then(
+ (value) => setState(() {
+ _position = value;
+ }),
+ );
+ _durationSubscription = player.onDurationChanged.listen((duration) {
+ setState(() => _duration = duration);
+ });
+
+ if (ESenseInput.instance.connected) {
+ _buttonSubscription = ESenseInput.instance.buttonEvents().listen((event) {
+ if (!event.pressed) {
+ if (_isPlaying) {
+ player.pause();
+ setState(() {
+ _isPlaying = false;
+ });
+ } else {
+ player.resume();
+ setState(() {
+ _isPlaying = true;
+ });
+ }
+ }
+ });
+ }
+
+ _positionSubscription = player.onPositionChanged.listen(
+ (p) => setState(() => _position = p),
+ );
+
+ player.onDurationChanged.listen((Duration d) {
+ // print('Max duration: $d');
+ setState(() => _duration = d);
+ });
+
+ player.onPlayerComplete.listen((void _) {
+ Route route = MaterialPageRoute(
+ builder: (context) => GameOverStats(
+ simfile: widget.simfile,
+ notes: notes,
+ ));
+ Navigator.pushReplacement(context, route);
+ });
+
+ player.onPositionChanged.listen((Duration p) {
+ // print('Current position: $p');
+ setState(() => _position = p);
+ for (final note in notes) {
+ note.position = note.time - p.inMilliseconds / 60000.0;
+ if (note.wasHit != null) {
+ continue;
+ }
+ if (note.position.abs() < 0.5 * 1.0 / 60.0) {
+ InputDirection esenseDirection =
+ ESenseInput.instance.getInputDirection(note.direction);
+ inputDirection.up |= esenseDirection.up;
+ inputDirection.down |= esenseDirection.down;
+ inputDirection.left |= esenseDirection.left;
+ inputDirection.right |= esenseDirection.right;
+ bool keypressCorrect = false;
+ switch (note.direction) {
+ case ArrowDirection.up:
+ keypressCorrect = inputDirection.up;
+ break;
+ case ArrowDirection.down:
+ keypressCorrect = inputDirection.down;
+ break;
+ case ArrowDirection.right:
+ keypressCorrect = inputDirection.right;
+ break;
+ case ArrowDirection.left:
+ keypressCorrect = inputDirection.left;
+ break;
+ }
+ if (keypressCorrect) {
+ print("you hit!");
+ note.wasHit = true;
+ _animationController.reset();
+ _animationController.forward();
+ inputDirection.reset();
+ setState(() {
+ hitOrMissMessage = 'Great!';
+ });
+ }
+ } else if (note.position < -0.5 * 1.0 / 60.0) {
+ print("Missed");
+ note.wasHit = false;
+ _animationController.reset();
+ _animationController.forward();
+ inputDirection.reset();
+ setState(() {
+ hitOrMissMessage = 'Missed';
+ });
+ }
+ }
+ });
+
+ widget.simfile.chartSimplest!.beats.forEach((time, noteData) {
+ int arrowIndex = noteData.indexOf('1');
+ if (arrowIndex < 0 || arrowIndex > 3) {
+ return;
+ }
+ notes.add(Note(time: time, direction: ArrowDirection.values[arrowIndex]));
+ });
+
+ player.play(DeviceFileSource(widget.simfile.audioPath!));
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return KeyboardListener(
+ focusNode: _focusNode,
+ autofocus: true,
+ onKeyEvent: (event) {
+ bool isDown = false;
+ if (event is KeyDownEvent) {
+ isDown = true;
+ } else if (event is KeyUpEvent) {
+ isDown = false;
+ } else {
+ return;
+ }
+ switch (event.logicalKey) {
+ case LogicalKeyboardKey.arrowUp:
+ inputDirection.up = isDown;
+ break;
+ case LogicalKeyboardKey.arrowDown:
+ inputDirection.down = isDown;
+ break;
+ case LogicalKeyboardKey.arrowLeft:
+ inputDirection.left = isDown;
+ break;
+ case LogicalKeyboardKey.arrowRight:
+ inputDirection.right = isDown;
+ break;
+ }
+ },
+ child: Scaffold(
+ appBar: AppBar(
+ leading: IconButton(
+ icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow),
+ onPressed: () {
+ if (_isPlaying) {
+ player.pause();
+ setState(() {
+ _isPlaying = false;
+ });
+ } else {
+ player.resume();
+ setState(() {
+ _isPlaying = true;
+ });
+ }
+ },
+ ),
+ title: Text(widget.simfile.tags['TITLE']!),
+ actions: [
+ IconButton(
+ icon: Icon(Icons.close),
+ onPressed: () => Navigator.pop(context))
+ ],
+ bottom: PreferredSize(
+ preferredSize: Size(double.infinity, 1.0),
+ child: LinearProgressIndicator(
+ value: (_duration != null &&
+ _position != null &&
+ _position!.inMilliseconds > 0 &&
+ _position!.inMilliseconds < _duration!.inMilliseconds)
+ ? _position!.inMilliseconds / _duration!.inMilliseconds
+ : 0.0,
+ )),
+ ),
+ body: Stack(children: [
+ Arrows(
+ notes: notes,
+ position: _position != null
+ ? _position!.inMilliseconds.toDouble()
+ : 0.0),
+ Positioned(
+ top: 50,
+ width: MediaQuery.of(context).size.width,
+ left: 0,
+ child: FadeTransition(
+ opacity: _animation,
+ child: Text(
+ hitOrMissMessage,
+ textScaler: TextScaler.linear(4),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ ),
+ Positioned(
+ left: MediaQuery.of(context).size.width / 2 - 50,
+ bottom: 50,
+ child: Container(
+ width: 100,
+ height: 100,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ border: Border.all(color: Colors.black, width: 10)),
+ ),
+ ),
+ ])),
+ );
+ }
+
+ @override
+ void dispose() {
+ _animationController.dispose();
+ _durationSubscription?.cancel();
+ _positionSubscription?.cancel();
+ _buttonSubscription?.cancel();
+ player.dispose();
+ super.dispose();
+ }
+}
diff --git a/lib/screens/level_selection.dart b/lib/screens/level_selection.dart
new file mode 100644
index 0000000..1b5d0d1
--- /dev/null
+++ b/lib/screens/level_selection.dart
@@ -0,0 +1,157 @@
+import 'dart:io';
+
+import 'package:file_picker/file_picker.dart';
+import 'package:flutter/material.dart';
+import 'package:permission_handler/permission_handler.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:sense_the_rhythm/utils/esense_input.dart';
+import 'package:sense_the_rhythm/utils/simfile.dart';
+import 'package:sense_the_rhythm/widgets/esense_connect_dialog.dart';
+import 'package:sense_the_rhythm/screens/level.dart';
+
+class LevelSelection extends StatefulWidget {
+ const LevelSelection({super.key});
+
+ @override
+ State<LevelSelection> createState() => _LevelSelectionState();
+}
+
+class _LevelSelectionState extends State<LevelSelection> {
+ String? stepmaniaCoursesPath;
+ List<Simfile> stepmaniaCoursesFolders = [];
+ List<Simfile> stepmaniaCoursesFoldersFiltered = [];
+ String searchString = '';
+
+ @override
+ void initState() {
+ super.initState();
+ loadFolderPath();
+ }
+
+ Future<void> loadFolderPath() async {
+ SharedPreferences prefs = await SharedPreferences.getInstance();
+ final String? stepmaniaCoursesPathSetting =
+ prefs.getString('stepmania_courses');
+
+ if (stepmaniaCoursesPathSetting == null) return;
+ List<Simfile> stepmaniaCoursesFoldersFuture =
+ await listFilesAndFolders(stepmaniaCoursesPathSetting);
+
+ setState(() {
+ stepmaniaCoursesPath = stepmaniaCoursesPathSetting;
+ stepmaniaCoursesFolders = stepmaniaCoursesFoldersFuture;
+ stepmaniaCoursesFoldersFiltered = stepmaniaCoursesFoldersFuture;
+ });
+ }
+
+ Future<void> selectFolder() async {
+ await Permission.manageExternalStorage.request();
+ String? selectedFolder = await FilePicker.platform.getDirectoryPath();
+
+ if (selectedFolder != null) {
+ // Save the selected folder path
+ SharedPreferences prefs = await SharedPreferences.getInstance();
+ await prefs.setString('stepmania_courses', selectedFolder);
+
+ loadFolderPath();
+ }
+ }
+
+ Future<List<Simfile>> listFilesAndFolders(String directoryPath) async {
+ final directory = Directory(directoryPath);
+ try {
+ // List all files and folders in the directory
+ List<Simfile> simfiles = directory
+ .listSync()
+ .where((entity) => FileSystemEntity.isDirectorySync(entity.path))
+ .map((entity) {
+ Simfile simfile = Simfile(entity.path);
+ simfile.load();
+ return simfile;
+ }).toList();
+ simfiles.sort((a, b) => a.tags['TITLE']!.compareTo(b.tags['TITLE']!));
+
+ return simfiles;
+ } catch (e) {
+ print("Error reading directory: $e");
+ return [];
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Sense the Rhythm'),
+ actions: [
+ IconButton(
+ onPressed: () => showDialog(
+ context: context,
+ builder: (BuildContext context) {
+ return ESenseConnectDialog(
+ deviceStatus: ESenseInput.instance.deviceStatus,
+ connect: (String name) {
+ ESenseInput.instance.connectToESense(name);
+ });
+ },
+ ),
+ icon: const Icon(Icons.bluetooth))
+ ],
+ ),
+ body: Builder(builder: (context) {
+ if (stepmaniaCoursesPath == null) {
+ return Text('Add a Directory with Stepmania Songs on \'+\'');
+ } else if (stepmaniaCoursesFolders.isEmpty) {
+ return Text(
+ 'Folder empty. Add Stepmania Songs to Folder or select a different folder on \'+\'');
+ } else {
+ return Column(
+ children: [
+ Padding(
+ padding:
+ const EdgeInsets.symmetric(horizontal: 16.0, vertical: 0.0),
+ child: TextField(
+ onChanged: (input) {
+ setState(() {
+ stepmaniaCoursesFoldersFiltered = stepmaniaCoursesFolders
+ .where((simfile) => simfile.tags["TITLE"]!
+ .toLowerCase()
+ .contains(input.toLowerCase()))
+ .toList();
+ });
+ },
+ decoration: InputDecoration(
+ // icon: Icon(Icons.search),
+ hintText: 'Search'),
+ ),
+ ),
+ Expanded(
+ child: ListView.separated(
+ itemCount: stepmaniaCoursesFoldersFiltered.length,
+ separatorBuilder: (BuildContext context, int index) =>
+ const Divider(),
+ itemBuilder: (context, index) {
+ Simfile simfile = stepmaniaCoursesFoldersFiltered[index];
+ return ListTile(
+ leading: Image.file(File(simfile.bannerPath!)),
+ trailing: Icon(Icons.play_arrow),
+ title: Text(simfile.tags["TITLE"]!),
+ subtitle: Text('3:45'),
+ onTap: () => Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (BuildContext context) =>
+ Level(simfile))),
+ );
+ },
+ ),
+ ),
+ ],
+ );
+ }
+ }),
+ floatingActionButton: FloatingActionButton(
+ onPressed: () => {selectFolder()}, child: Icon(Icons.add)),
+ );
+ }
+}