summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--android/app/src/main/AndroidManifest.xml13
-rw-r--r--lib/arrows.dart2
-rw-r--r--lib/esense_connect_dialog.dart52
-rw-r--r--lib/game_over_stats.dart63
-rw-r--r--lib/level.dart57
-rw-r--r--lib/level_selection.dart137
-rw-r--r--lib/simfile.dart27
-rw-r--r--pubspec.lock57
-rw-r--r--pubspec.yaml6
9 files changed, 360 insertions, 54 deletions
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 56d0153..1499c1a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,4 +1,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+ <!-- The following permission is related to the eSense library -->
+ <uses-permission
+ android:name="android.permission.BLUETOOTH"
+ android:maxSdkVersion="30" />
+ <uses-permission
+ android:name="android.permission.BLUETOOTH_ADMIN"
+ android:maxSdkVersion="30" />
+ <uses-permission
+ android:name="android.permission.BLUETOOTH_SCAN"
+ android:usesPermissionFlags="neverForLocation" />
+ <uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
+ <uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
+
<application
android:label="sense_the_rhythm"
android:name="${applicationName}"
diff --git a/lib/arrows.dart b/lib/arrows.dart
index b4779f7..ff53e02 100644
--- a/lib/arrows.dart
+++ b/lib/arrows.dart
@@ -15,7 +15,7 @@ class Note {
final double time;
final ArrowDirection direction;
double position = 0;
- bool wasHit = false;
+ bool? wasHit;
Note({required this.time, required this.direction});
}
diff --git a/lib/esense_connect_dialog.dart b/lib/esense_connect_dialog.dart
new file mode 100644
index 0000000..b598174
--- /dev/null
+++ b/lib/esense_connect_dialog.dart
@@ -0,0 +1,52 @@
+import 'package:flutter/material.dart';
+
+class ESenseConnectDialog extends StatefulWidget {
+ final void Function(String) connect;
+ final ValueNotifier<String> deviceStatus;
+ const ESenseConnectDialog(
+ {super.key, required this.deviceStatus, required this.connect});
+
+ @override
+ State<ESenseConnectDialog> createState() => _ESenseConnectDialogState();
+}
+
+class _ESenseConnectDialogState extends State<ESenseConnectDialog> {
+ String eSenseDeviceName = '';
+
+ @override
+ Widget build(BuildContext context) {
+ return AlertDialog(
+ title: const Text('Connect to ESense'),
+ content: Column(mainAxisSize: MainAxisSize.min, children: [
+ TextField(
+ onChanged: (input) {
+ setState(() {
+ eSenseDeviceName = input;
+ });
+ },
+ decoration: InputDecoration(
+ border: OutlineInputBorder(),
+ hintText: 'eSense-xxxx',
+ labelText: 'Device name',
+ ),
+ ),
+ // Text(eSenseDeviceName),
+ ValueListenableBuilder(
+ valueListenable: widget.deviceStatus,
+ builder: (BuildContext context, String value, Widget? child) {
+ return Text(value);
+ }),
+ ]),
+ actions: <Widget>[
+ TextButton(
+ onPressed: () => Navigator.pop(context, 'Cancel'),
+ child: const Text('Discard'),
+ ),
+ TextButton(
+ onPressed: () => widget.connect(eSenseDeviceName),
+ child: const Text('Connect'),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/game_over_stats.dart b/lib/game_over_stats.dart
new file mode 100644
index 0000000..0e73024
--- /dev/null
+++ b/lib/game_over_stats.dart
@@ -0,0 +1,63 @@
+import 'package:flutter/material.dart';
+import 'package:sense_the_rhythm/arrows.dart';
+import 'package:sense_the_rhythm/level.dart';
+import 'package:sense_the_rhythm/simfile.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/level.dart b/lib/level.dart
index a6d4967..e896615 100644
--- a/lib/level.dart
+++ b/lib/level.dart
@@ -5,11 +5,12 @@ import 'package:flutter/material.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/services.dart';
import 'package:sense_the_rhythm/arrows.dart';
+import 'package:sense_the_rhythm/game_over_stats.dart';
import 'package:sense_the_rhythm/simfile.dart';
class Level extends StatefulWidget {
- const Level({super.key, required this.stepmaniaFolderPath});
- final String stepmaniaFolderPath;
+ const Level(this.simfile, {super.key});
+ final Simfile simfile;
@override
State<Level> createState() => _LevelState();
@@ -24,7 +25,6 @@ class InputDirection {
class _LevelState extends State<Level> {
final player = AudioPlayer();
- Simfile? simfile;
bool _isPlaying = true;
Duration? _duration;
Duration? _position;
@@ -73,13 +73,24 @@ class _LevelState extends State<Level> {
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 && note.position.abs() < 0.5 * 1.0 / 60.0) {
+ if (note.wasHit != null) {
+ continue;
+ }
+ if (note.position.abs() < 0.5 * 1.0 / 60.0) {
bool keypressCorrect = false;
switch (note.direction) {
case ArrowDirection.up:
@@ -101,31 +112,25 @@ class _LevelState extends State<Level> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
- content: Text('This is a toast message'),
- duration: Duration(seconds: 2),
+ content: Text('Great!'),
+ duration: Duration(milliseconds: 500),
),
);
}
+ } else if (note.position < -0.5 * 1.0 / 60.0) {
+ print("Missed");
+ note.wasHit = false;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text('Missed!'),
+ duration: Duration(milliseconds: 500),
+ ),
+ );
}
}
});
- String simfilePath = Directory(widget.stepmaniaFolderPath)
- .listSync()
- .firstWhere((entity) => entity.path.endsWith('.sm'),
- orElse: () => File(''))
- .path;
-
- String audioPath = Directory(widget.stepmaniaFolderPath)
- .listSync()
- .firstWhere((entity) => entity.path.endsWith('.ogg'),
- orElse: () => File(''))
- .path;
-
- simfile = Simfile(simfilePath);
- simfile!.load();
-
- simfile!.chartSimplest!.beats.forEach((time, noteData) {
+ widget.simfile.chartSimplest!.beats.forEach((time, noteData) {
int arrowIndex = noteData.indexOf('1');
if (arrowIndex < 0 || arrowIndex > 3) {
return;
@@ -133,9 +138,7 @@ class _LevelState extends State<Level> {
notes.add(Note(time: time, direction: ArrowDirection.values[arrowIndex]));
});
- print(audioPath);
-
- player.play(DeviceFileSource(audioPath));
+ player.play(DeviceFileSource(widget.simfile.audioPath!));
}
@override
@@ -185,7 +188,7 @@ class _LevelState extends State<Level> {
}
},
),
- title: Text(widget.stepmaniaFolderPath.split('/').last),
+ title: Text(widget.simfile.tags['TITLE']!),
actions: [
IconButton(
icon: Icon(Icons.close),
diff --git a/lib/level_selection.dart b/lib/level_selection.dart
index e2cdcbe..0c1a0fe 100644
--- a/lib/level_selection.dart
+++ b/lib/level_selection.dart
@@ -1,7 +1,11 @@
import 'dart:io';
+import 'package:esense_flutter/esense.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
+import 'package:permission_handler/permission_handler.dart';
+import 'package:sense_the_rhythm/esense_connect_dialog.dart';
+import 'package:sense_the_rhythm/simfile.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'level.dart';
@@ -15,26 +19,103 @@ class LevelSelection extends StatefulWidget {
class _LevelSelectionState extends State<LevelSelection> {
String? stepmaniaCoursesPath;
- List<FileSystemEntity> stepmaniaCoursesFolders = [];
+ List<Simfile> stepmaniaCoursesFolders = [];
+
+ String eSenseDeviceName = '';
+ ESenseManager? eSenseManager;
+ ValueNotifier<String> _deviceStatus = ValueNotifier('');
+ // String _deviceStatus = '';
+ bool connected = false;
+ bool sampling = false;
@override
void initState() {
super.initState();
+ _listenToESense();
loadFolderPath();
}
+ Future<void> _askForPermissions() async {
+ if (!(await Permission.bluetoothScan.request().isGranted &&
+ await Permission.bluetoothConnect.request().isGranted)) {
+ print(
+ 'WARNING - no permission to use Bluetooth granted. Cannot access eSense device.');
+ }
+ // for some strange reason, Android requires permission to location for Bluetooth to work.....?
+ if (Platform.isAndroid) {
+ if (!(await Permission.locationWhenInUse.request().isGranted)) {
+ print(
+ 'WARNING - no permission to access location granted. Cannot access eSense device.');
+ }
+ }
+ }
+
+ Future<void> _listenToESense() async {
+ await _askForPermissions();
+
+ // if you want to get the connection events when connecting,
+ // set up the listener BEFORE connecting...
+ eSenseManager!.connectionEvents.listen((event) {
+ print('CONNECTION event: $event');
+
+ // when we're connected to the eSense device, we can start listening to events from it
+ // if (event.type == ConnectionType.connected) _listenToESenseEvents();
+
+ setState(() {
+ connected = false;
+ switch (event.type) {
+ case ConnectionType.connected:
+ _deviceStatus.value = 'connected';
+ connected = true;
+ break;
+ case ConnectionType.unknown:
+ _deviceStatus.value = 'unknown';
+ break;
+ case ConnectionType.disconnected:
+ _deviceStatus.value = 'disconnected';
+ sampling = false;
+ break;
+ case ConnectionType.device_found:
+ _deviceStatus.value = 'device_found';
+ break;
+ case ConnectionType.device_not_found:
+ _deviceStatus.value = 'device_not_found';
+ break;
+ }
+ });
+ });
+ }
+
+ Future<void> _connectToESense(String deviceName) async {
+ if (!connected) {
+ await _askForPermissions();
+ print('Trying to connect to eSense device...');
+ setState(() {
+ eSenseDeviceName = deviceName;
+ });
+ print(eSenseDeviceName);
+ eSenseManager = ESenseManager(eSenseDeviceName);
+ connected = await eSenseManager!.connect();
+ print('success!');
+
+ setState(() {
+ _deviceStatus.value = connected ? 'connecting...' : 'connection failed';
+ });
+ }
+ }
+
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;
- });
- setState(() async {
- stepmaniaCoursesFolders =
- await listFilesAndFolders(stepmaniaCoursesPathSetting);
+ stepmaniaCoursesFolders = stepmaniaCoursesFoldersFuture;
});
}
@@ -50,15 +131,18 @@ class _LevelSelectionState extends State<LevelSelection> {
}
}
- Future<List<FileSystemEntity>> listFilesAndFolders(
- String directoryPath) async {
+ Future<List<Simfile>> listFilesAndFolders(String directoryPath) async {
final directory = Directory(directoryPath);
try {
// List all files and folders in the directory
return directory
.listSync()
.where((entity) => FileSystemEntity.isDirectorySync(entity.path))
- .toList();
+ .map((entity) {
+ Simfile simfile = Simfile(entity.path);
+ simfile.load();
+ return simfile;
+ }).toList();
} catch (e) {
print("Error reading directory: $e");
return [];
@@ -68,7 +152,23 @@ class _LevelSelectionState extends State<LevelSelection> {
@override
Widget build(BuildContext context) {
return Scaffold(
- appBar: AppBar(title: const Text('Sense the Rhythm')),
+ appBar: AppBar(
+ title: const Text('Sense the Rhythm'),
+ actions: [
+ IconButton(
+ onPressed: () => showDialog(
+ context: context,
+ builder: (BuildContext context) {
+ return ESenseConnectDialog(
+ deviceStatus: _deviceStatus,
+ connect: (String name) {
+ _connectToESense(name);
+ });
+ },
+ ),
+ icon: const Icon(Icons.bluetooth))
+ ],
+ ),
body: Builder(builder: (context) {
if (stepmaniaCoursesPath == null) {
return Text('Add a Directory with Stepmania Songs on \'+\'');
@@ -81,26 +181,17 @@ class _LevelSelectionState extends State<LevelSelection> {
separatorBuilder: (BuildContext context, int index) =>
const Divider(),
itemBuilder: (context, index) {
- String thumbnailPath = Directory(
- stepmaniaCoursesFolders[index].path)
- .listSync()
- .firstWhere(
- (file) => file.path.toLowerCase().endsWith('banner.png'),
- orElse: () => File(''))
- .path;
return ListTile(
- leading: Image.file(File(thumbnailPath)),
+ leading: Image.file(
+ File(stepmaniaCoursesFolders[index].bannerPath!)),
trailing: Icon(Icons.play_arrow),
- title:
- Text(stepmaniaCoursesFolders[index].path.split('/').last),
+ title: Text(stepmaniaCoursesFolders[index].tags["TITLE"]!),
subtitle: Text('3:45'),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
- builder: (BuildContext context) => Level(
- stepmaniaFolderPath:
- stepmaniaCoursesFolders[index].path,
- ))),
+ builder: (BuildContext context) =>
+ Level(stepmaniaCoursesFolders[index]))),
);
},
);
diff --git a/lib/simfile.dart b/lib/simfile.dart
index 102c989..7bdf5c0 100644
--- a/lib/simfile.dart
+++ b/lib/simfile.dart
@@ -37,7 +37,10 @@ class Chart {
}
class Simfile {
- String path;
+ String directoryPath;
+ String? simfilePath;
+ String? audioPath;
+ String? bannerPath;
String? lines;
// tags of simfile
@@ -48,7 +51,7 @@ class Simfile {
Map<double, double> bpms = {};
double offset = 0;
- Simfile(this.path);
+ Simfile(this.directoryPath);
void _parseChart({required List<String> keys, required String value}) {
Chart chart = Chart();
@@ -118,7 +121,25 @@ class Simfile {
}
void load() {
- lines = File(path).readAsStringSync();
+ simfilePath = Directory(directoryPath)
+ .listSync()
+ .firstWhere((entity) => entity.path.endsWith('.sm'),
+ orElse: () => File(''))
+ .path;
+
+ audioPath = Directory(directoryPath)
+ .listSync()
+ .firstWhere((entity) => entity.path.endsWith('.ogg'),
+ orElse: () => File(''))
+ .path;
+
+ bannerPath = Directory(directoryPath)
+ .listSync()
+ .firstWhere((file) => file.path.toLowerCase().endsWith('banner.png'),
+ orElse: () => File(''))
+ .path;
+
+ lines = File(simfilePath!).readAsStringSync();
RegExp commentsRegExp = RegExp(r'//.*$');
lines = lines?.replaceAll(commentsRegExp, '');
diff --git a/pubspec.lock b/pubspec.lock
index 553fa2e..62488a6 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -121,6 +121,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
+ esense_flutter:
+ dependency: "direct main"
+ description:
+ path: "packages/esense_flutter"
+ ref: master
+ resolved-ref: "3784963fcdeaebc4e81679dc331e3f195ecd1c42"
+ url: "https://github.com/cph-cachet/flutter-plugins.git"
+ source: git
+ version: "1.0.0"
fake_async:
dependency: transitive
description:
@@ -320,6 +329,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
+ permission_handler:
+ dependency: "direct main"
+ description:
+ name: permission_handler
+ sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb"
+ url: "https://pub.dev"
+ source: hosted
+ version: "11.3.1"
+ permission_handler_android:
+ dependency: transitive
+ description:
+ name: permission_handler_android
+ sha256: "71bbecfee799e65aff7c744761a57e817e73b738fedf62ab7afd5593da21f9f1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "12.0.13"
+ permission_handler_apple:
+ dependency: transitive
+ description:
+ name: permission_handler_apple
+ sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0
+ url: "https://pub.dev"
+ source: hosted
+ version: "9.4.5"
+ permission_handler_html:
+ dependency: transitive
+ description:
+ name: permission_handler_html
+ sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.1.3+5"
+ permission_handler_platform_interface:
+ dependency: transitive
+ description:
+ name: permission_handler_platform_interface
+ sha256: e9c8eadee926c4532d0305dff94b85bf961f16759c3af791486613152af4b4f9
+ url: "https://pub.dev"
+ source: hosted
+ version: "4.2.3"
+ permission_handler_windows:
+ dependency: transitive
+ description:
+ name: permission_handler_windows
+ sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.2.1"
platform:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index c42d0a3..77e9319 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -14,6 +14,12 @@ dependencies:
shared_preferences: ^2.3.4
file_picker: ^8.1.6
audioplayers: ^6.1.0
+ esense_flutter:
+ git:
+ url: https://github.com/cph-cachet/flutter-plugins.git
+ ref: master
+ path: packages/esense_flutter/
+ permission_handler: ^11.3.1
dev_dependencies:
flutter_test: