commit cd061eceeb421d6bec5cd38bf9f315036ce88887
parent f98162b6d8ed70e571440c539ef053dce09f704d
Author: Orangerot <purple@orangerot.dev>
Date: Sat, 11 Jan 2025 18:22:32 +0100
style: refactored into folder structure
Diffstat:
18 files changed, 901 insertions(+), 892 deletions(-)
diff --git a/lib/arrows.dart b/lib/arrows.dart
@@ -1,57 +0,0 @@
-import 'package:flutter/material.dart';
-
-enum ArrowDirection {
- left(Icons.arrow_back),
- down(Icons.arrow_downward),
- up(Icons.arrow_upward),
- right(Icons.arrow_forward);
-
- const ArrowDirection(this.icon);
-
- final IconData icon;
-}
-
-class Note {
- final double time;
- final ArrowDirection direction;
- double position = 0;
- bool? wasHit;
-
- Note({required this.time, required this.direction});
-}
-
-class Arrows extends StatelessWidget {
- final List<Note> notes;
- final double position;
-
- const Arrows({super.key, required this.notes, required this.position});
-
- @override
- Widget build(BuildContext context) {
- return Stack(
- children: notes.map((note) {
- double position = note.position * 10000; // * 20 * MediaQuery.of(context).size.height;
-
- return Arrow(
- position: position,
- direction: note.direction,
- );
- }).toList());
- }
-}
-
-class Arrow extends StatelessWidget {
- final double position;
- final ArrowDirection direction;
-
- const Arrow({super.key, required this.position, required this.direction});
-
- @override
- Widget build(BuildContext context) {
- return Positioned(
- left: MediaQuery.of(context).size.width / 2 - 50, // Center the arrow
- bottom: position + 50,
- child: Icon(size: 100, color: Colors.redAccent.shade400, direction.icon),
- );
- }
-}
diff --git a/lib/esense_input.dart b/lib/esense_input.dart
@@ -1,166 +0,0 @@
-import 'dart:async';
-import 'dart:io';
-
-import 'package:esense_flutter/esense.dart';
-import 'package:flutter/material.dart';
-import 'package:permission_handler/permission_handler.dart';
-import 'package:sense_the_rhythm/arrows.dart';
-import 'package:sense_the_rhythm/level.dart';
-
-class ESenseInput {
- static final instance = ESenseInput._();
-
- ESenseManager eSenseManager = ESenseManager('unknown');
- ValueNotifier<String> deviceStatus = ValueNotifier('');
- StreamSubscription? subscription;
-
- String eSenseDeviceName = '';
- bool connected = false;
- bool sampling = false;
-
- int sampleRate = 20;
-
- InputDirection inputDirection = InputDirection();
- int x = 0;
- int y = 0;
- int z = 0;
-
- ESenseInput._() {
- _listenToESense();
- }
-
- Future<void> _askForPermissions() async {
- if (!Platform.isAndroid && !Platform.isIOS) return;
- if (!(await Permission.bluetoothScan.request().isGranted &&
- await Permission.bluetoothConnect.request().isGranted &&
- await Permission.bluetooth.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.');
- }
- }
- }
-
- void _listenToESense() {
- // 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();
-
- connected = false;
- switch (event.type) {
- case ConnectionType.connected:
- deviceStatus.value = 'connected';
- connected = true;
- _startListenToSensorEvents();
- break;
- case ConnectionType.unknown:
- deviceStatus.value = 'unknown';
- break;
- case ConnectionType.disconnected:
- deviceStatus.value = 'disconnected';
- sampling = false;
- _pauseListenToSensorEvents();
- break;
- case ConnectionType.device_found:
- deviceStatus.value = 'device_found';
- break;
- case ConnectionType.device_not_found:
- deviceStatus.value = 'device_not_found';
- break;
- }
- });
- }
-
- Stream<ButtonEventChanged> buttonEvents() {
- return eSenseManager.eSenseEvents
- .where((event) => event.runtimeType == ButtonEventChanged)
- .cast();
- }
-
- void _startListenToSensorEvents() async {
- // // any changes to the sampling frequency must be done BEFORE listening to sensor events
- print('setting sampling frequency...');
- bool successs = await eSenseManager.setSamplingRate(sampleRate);
- if (successs) {
- print('setSamplingRate success');
- } else {
- print('setSamplingRate fail');
- }
-
- // subscribe to sensor event from the eSense device
- subscription = eSenseManager.sensorEvents.listen((event) {
- // print('SENSOR event: $event');
- if (event.gyro != null) {
- _parseGyroData(event.gyro!);
- }
- });
- sampling = true;
- }
-
- void _pauseListenToSensorEvents() async {
- subscription?.cancel();
- sampling = false;
- }
-
- void _parseGyroData(List<int> data) {
- // Float value in deg/s = Gyro value / Gyro scale factor
- // The default configuration is +- 500deg/s for the gyroscope.
- x = (x + (15 * data[0] ~/ (500 * sampleRate))) % 360;
- y = (y + (15 * data[1] ~/ (500 * sampleRate))) % 360;
- z = (z + (15 * data[2] ~/ (500 * sampleRate))) % 360;
- print('$x, $y, $z');
- // print('${(z.toDouble() / 500.0 * (1.0 / sampleRate.toDouble())) * 7.5}');
- // print('${z.toDouble() / 500.0 * (1.0 / 10.0)}');
- }
-
- void resetAngles() {
- inputDirection.reset();
- x = 0;
- y = 0;
- z = 0;
- }
-
- InputDirection getInputDirection(ArrowDirection expect) {
- inputDirection.up = z > 270 && z < 340;
- inputDirection.down = z > 40 && z < 180;
- inputDirection.left = y > 40 && y < 180;
- inputDirection.right = y > 270 && y < 340;
-
- if (expect == ArrowDirection.up && inputDirection.up ||
- expect == ArrowDirection.down && inputDirection.down) {
- y = 0;
- print("ehit");
- }
- if (expect == ArrowDirection.left && inputDirection.left ||
- expect == ArrowDirection.right && inputDirection.right) {
- z = 0;
- print("ehit");
- }
-
- return inputDirection;
- }
-
- Future<void> connectToESense(String deviceName) async {
- if (!connected) {
- await _askForPermissions();
- print('Trying to connect to eSense device namend \'$deviceName\'');
- eSenseDeviceName = deviceName;
- eSenseManager.deviceName = deviceName;
- connected = await eSenseManager.connect();
- print(
- 'Trying to connect to eSense device namend \'${eSenseManager.deviceName}\'');
-
- deviceStatus.value = connected ? 'connecting...' : 'connection failed';
- print(deviceStatus.value);
- }
- }
-}
diff --git a/lib/game_over_stats.dart b/lib/game_over_stats.dart
@@ -1,63 +0,0 @@
-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
@@ -1,295 +0,0 @@
-import 'dart:async';
-
-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/esense_input.dart';
-import 'package:sense_the_rhythm/game_over_stats.dart';
-import 'package:sense_the_rhythm/simfile.dart';
-
-class Level extends StatefulWidget {
- const Level(this.simfile, {super.key});
- final Simfile simfile;
-
- @override
- State<Level> createState() => _LevelState();
-}
-
-class InputDirection {
- bool up = false;
- bool down = false;
- bool left = false;
- bool right = false;
-
- void reset() {
- up = false;
- down = false;
- left = false;
- right = false;
- }
-}
-
-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);
- });
-
- _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/level_selection.dart b/lib/level_selection.dart
@@ -1,158 +0,0 @@
-import 'dart:io';
-
-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/esense_input.dart';
-import 'package:sense_the_rhythm/simfile.dart';
-import 'package:shared_preferences/shared_preferences.dart';
-
-import '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)),
- );
- }
-}
diff --git a/lib/main.dart b/lib/main.dart
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
-import 'level_selection.dart';
+import 'package:sense_the_rhythm/screens/level_selection.dart';
void main() {
runApp(const MyApp());
diff --git a/lib/models/arrow_direction.dart b/lib/models/arrow_direction.dart
@@ -0,0 +1,12 @@
+import 'package:flutter/material.dart';
+
+enum ArrowDirection {
+ left(Icons.arrow_back),
+ down(Icons.arrow_downward),
+ up(Icons.arrow_upward),
+ right(Icons.arrow_forward);
+
+ const ArrowDirection(this.icon);
+
+ final IconData icon;
+}
diff --git a/lib/models/input_direction.dart b/lib/models/input_direction.dart
@@ -0,0 +1,13 @@
+class InputDirection {
+ bool up = false;
+ bool down = false;
+ bool left = false;
+ bool right = false;
+
+ void reset() {
+ up = false;
+ down = false;
+ left = false;
+ right = false;
+ }
+}
diff --git a/lib/models/note.dart b/lib/models/note.dart
@@ -0,0 +1,10 @@
+import 'package:sense_the_rhythm/models/arrow_direction.dart';
+
+class Note {
+ final double time;
+ final ArrowDirection direction;
+ double position = 0;
+ bool? wasHit;
+
+ Note({required this.time, required this.direction});
+}
diff --git a/lib/screens/game_over.dart 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
@@ -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
@@ -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)),
+ );
+ }
+}
diff --git a/lib/simfile.dart b/lib/simfile.dart
@@ -1,152 +0,0 @@
-import 'dart:ffi';
-import 'dart:io';
-
-enum Difficulty { Beginner, Easy, Medium, Hard, Challenge, Edit }
-
-// These are the standard note values:
-//
-// 0 – No note
-// 1 – Normal note
-// 2 – Hold head
-// 3 – Hold/Roll tail
-// 4 – Roll head
-// M – Mine (or other negative note)
-//
-// Later versions of StepMania accept other note values which may not work in older versions:
-//
-// K – Automatic keysound
-// L – Lift note
-// F – Fake note
-
-RegExp noteTypes = RegExp(r'^([012345MKLF]+)\s*([,;])?');
-
-class Chart {
- String? chartType;
- // Description/author
- String? author;
- // Difficulty (one of Beginner, Easy, Medium, Hard, Challenge, Edit)
- Difficulty? difficulty;
- // Numerical meter
- int? numericalMeter;
- // Groove radar values, generated by the program
- String? radarValues;
-
- List<List<String>>? measures;
-
- Map<double, String> beats = {};
-}
-
-class Simfile {
- String directoryPath;
- String? simfilePath;
- String? audioPath;
- String? bannerPath;
- String? lines;
-
- // tags of simfile
- Map<String, String> tags = {};
-
- Chart? chartSimplest;
-
- Map<double, double> bpms = {};
- double offset = 0;
-
- Simfile(this.directoryPath);
-
- void _parseChart({required List<String> keys, required String value}) {
- Chart chart = Chart();
- chart.chartType = keys[1];
- chart.author = keys[2];
- chart.difficulty = Difficulty.values.byName(keys[3]);
- chart.numericalMeter = int.parse(keys[4]);
- chart.radarValues = keys[5];
-
- if (chartSimplest == null ||
- (chart.difficulty!.index <= chartSimplest!.difficulty!.index &&
- chart.numericalMeter! <= chartSimplest!.numericalMeter!)) {
- List<List<String>> measures = [];
- for (final measureRaw in value.split(',')) {
- List<String> measure = [];
- for (final noteRaw in measureRaw.split('\n')) {
- String note = noteRaw.trim();
- if (noteTypes.hasMatch(note)) {
- measure.add(note);
- }
- }
- measures.add(measure);
- }
-
- double bpm = bpms.entries.first.value;
-
- for (final (measureIndex, measure) in measures.indexed) {
- for (final (noteIndex, noteData) in measure.indexed) {
- double beat = measureIndex * 4.0 +
- (noteIndex.toDouble() / measure.length) * 4.0;
- double minutesPerBeat = 1.0 / bpm;
- double offsetMinutes = offset / 60.0;
- chart.beats[beat * minutesPerBeat + offsetMinutes] = noteData;
- }
- }
-
- chart.measures = measures;
- chartSimplest = chart;
- }
- }
-
- void _parseTag(RegExpMatch fieldData) {
- List<String> keys =
- fieldData[1]!.split(':').map((key) => key.trim()).toList();
- String value = fieldData[2]!;
- if (keys[0] == "BPMS") {
- for (final pairRaw in value.split(',')) {
- List<String> pair = pairRaw.split('=');
- if (pair.length != 2) {
- continue;
- }
- double time = double.parse(pair[0]);
- double bpm = double.parse(pair[1]);
- bpms[time] = bpm;
- }
- }
-
- if (keys[0] == "OFFSET") {
- offset = double.parse(value);
- }
-
- if (keys[0] != "NOTES") {
- tags[keys[0]] = value;
- return;
- }
- _parseChart(keys: keys, value: value);
- }
-
- void load() {
- 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, '');
- RegExp fieldDataRegExp = RegExp(r'#([^;]+):([^;]*);');
-
- for (final fieldData in fieldDataRegExp.allMatches(lines!)) {
- _parseTag(fieldData);
- }
- }
-}
diff --git a/lib/utils/esense_input.dart b/lib/utils/esense_input.dart
@@ -0,0 +1,166 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:esense_flutter/esense.dart';
+import 'package:flutter/material.dart';
+import 'package:permission_handler/permission_handler.dart';
+import 'package:sense_the_rhythm/models/arrow_direction.dart';
+import 'package:sense_the_rhythm/models/input_direction.dart';
+
+class ESenseInput {
+ static final instance = ESenseInput._();
+
+ ESenseManager eSenseManager = ESenseManager('unknown');
+ ValueNotifier<String> deviceStatus = ValueNotifier('');
+ StreamSubscription? subscription;
+
+ String eSenseDeviceName = '';
+ bool connected = false;
+ bool sampling = false;
+
+ int sampleRate = 20;
+
+ InputDirection inputDirection = InputDirection();
+ int x = 0;
+ int y = 0;
+ int z = 0;
+
+ ESenseInput._() {
+ _listenToESense();
+ }
+
+ Future<void> _askForPermissions() async {
+ if (!Platform.isAndroid && !Platform.isIOS) return;
+ if (!(await Permission.bluetoothScan.request().isGranted &&
+ await Permission.bluetoothConnect.request().isGranted &&
+ await Permission.bluetooth.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.');
+ }
+ }
+ }
+
+ void _listenToESense() {
+ // 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();
+
+ connected = false;
+ switch (event.type) {
+ case ConnectionType.connected:
+ deviceStatus.value = 'connected';
+ connected = true;
+ _startListenToSensorEvents();
+ break;
+ case ConnectionType.unknown:
+ deviceStatus.value = 'unknown';
+ break;
+ case ConnectionType.disconnected:
+ deviceStatus.value = 'disconnected';
+ sampling = false;
+ _pauseListenToSensorEvents();
+ break;
+ case ConnectionType.device_found:
+ deviceStatus.value = 'device_found';
+ break;
+ case ConnectionType.device_not_found:
+ deviceStatus.value = 'device_not_found';
+ break;
+ }
+ });
+ }
+
+ Stream<ButtonEventChanged> buttonEvents() {
+ return eSenseManager.eSenseEvents
+ .where((event) => event.runtimeType == ButtonEventChanged)
+ .cast();
+ }
+
+ void _startListenToSensorEvents() async {
+ // // any changes to the sampling frequency must be done BEFORE listening to sensor events
+ print('setting sampling frequency...');
+ bool successs = await eSenseManager.setSamplingRate(sampleRate);
+ if (successs) {
+ print('setSamplingRate success');
+ } else {
+ print('setSamplingRate fail');
+ }
+
+ // subscribe to sensor event from the eSense device
+ subscription = eSenseManager.sensorEvents.listen((event) {
+ // print('SENSOR event: $event');
+ if (event.gyro != null) {
+ _parseGyroData(event.gyro!);
+ }
+ });
+ sampling = true;
+ }
+
+ void _pauseListenToSensorEvents() async {
+ subscription?.cancel();
+ sampling = false;
+ }
+
+ void _parseGyroData(List<int> data) {
+ // Float value in deg/s = Gyro value / Gyro scale factor
+ // The default configuration is +- 500deg/s for the gyroscope.
+ x = (x + (15 * data[0] ~/ (500 * sampleRate))) % 360;
+ y = (y + (15 * data[1] ~/ (500 * sampleRate))) % 360;
+ z = (z + (15 * data[2] ~/ (500 * sampleRate))) % 360;
+ print('$x, $y, $z');
+ // print('${(z.toDouble() / 500.0 * (1.0 / sampleRate.toDouble())) * 7.5}');
+ // print('${z.toDouble() / 500.0 * (1.0 / 10.0)}');
+ }
+
+ void resetAngles() {
+ inputDirection.reset();
+ x = 0;
+ y = 0;
+ z = 0;
+ }
+
+ InputDirection getInputDirection(ArrowDirection expect) {
+ inputDirection.up = z > 270 && z < 340;
+ inputDirection.down = z > 40 && z < 180;
+ inputDirection.left = y > 40 && y < 180;
+ inputDirection.right = y > 270 && y < 340;
+
+ if (expect == ArrowDirection.up && inputDirection.up ||
+ expect == ArrowDirection.down && inputDirection.down) {
+ y = 0;
+ print("ehit");
+ }
+ if (expect == ArrowDirection.left && inputDirection.left ||
+ expect == ArrowDirection.right && inputDirection.right) {
+ z = 0;
+ print("ehit");
+ }
+
+ return inputDirection;
+ }
+
+ Future<void> connectToESense(String deviceName) async {
+ if (!connected) {
+ await _askForPermissions();
+ print('Trying to connect to eSense device namend \'$deviceName\'');
+ eSenseDeviceName = deviceName;
+ eSenseManager.deviceName = deviceName;
+ connected = await eSenseManager.connect();
+ print(
+ 'Trying to connect to eSense device namend \'${eSenseManager.deviceName}\'');
+
+ deviceStatus.value = connected ? 'connecting...' : 'connection failed';
+ print(deviceStatus.value);
+ }
+ }
+}
diff --git a/lib/utils/simfile.dart b/lib/utils/simfile.dart
@@ -0,0 +1,151 @@
+import 'dart:io';
+
+enum Difficulty { Beginner, Easy, Medium, Hard, Challenge, Edit }
+
+// These are the standard note values:
+//
+// 0 – No note
+// 1 – Normal note
+// 2 – Hold head
+// 3 – Hold/Roll tail
+// 4 – Roll head
+// M – Mine (or other negative note)
+//
+// Later versions of StepMania accept other note values which may not work in older versions:
+//
+// K – Automatic keysound
+// L – Lift note
+// F – Fake note
+
+RegExp noteTypes = RegExp(r'^([012345MKLF]+)\s*([,;])?');
+
+class Chart {
+ String? chartType;
+ // Description/author
+ String? author;
+ // Difficulty (one of Beginner, Easy, Medium, Hard, Challenge, Edit)
+ Difficulty? difficulty;
+ // Numerical meter
+ int? numericalMeter;
+ // Groove radar values, generated by the program
+ String? radarValues;
+
+ List<List<String>>? measures;
+
+ Map<double, String> beats = {};
+}
+
+class Simfile {
+ String directoryPath;
+ String? simfilePath;
+ String? audioPath;
+ String? bannerPath;
+ String? lines;
+
+ // tags of simfile
+ Map<String, String> tags = {};
+
+ Chart? chartSimplest;
+
+ Map<double, double> bpms = {};
+ double offset = 0;
+
+ Simfile(this.directoryPath);
+
+ void _parseChart({required List<String> keys, required String value}) {
+ Chart chart = Chart();
+ chart.chartType = keys[1];
+ chart.author = keys[2];
+ chart.difficulty = Difficulty.values.byName(keys[3]);
+ chart.numericalMeter = int.parse(keys[4]);
+ chart.radarValues = keys[5];
+
+ if (chartSimplest == null ||
+ (chart.difficulty!.index <= chartSimplest!.difficulty!.index &&
+ chart.numericalMeter! <= chartSimplest!.numericalMeter!)) {
+ List<List<String>> measures = [];
+ for (final measureRaw in value.split(',')) {
+ List<String> measure = [];
+ for (final noteRaw in measureRaw.split('\n')) {
+ String note = noteRaw.trim();
+ if (noteTypes.hasMatch(note)) {
+ measure.add(note);
+ }
+ }
+ measures.add(measure);
+ }
+
+ double bpm = bpms.entries.first.value;
+
+ for (final (measureIndex, measure) in measures.indexed) {
+ for (final (noteIndex, noteData) in measure.indexed) {
+ double beat = measureIndex * 4.0 +
+ (noteIndex.toDouble() / measure.length) * 4.0;
+ double minutesPerBeat = 1.0 / bpm;
+ double offsetMinutes = offset / 60.0;
+ chart.beats[beat * minutesPerBeat + offsetMinutes] = noteData;
+ }
+ }
+
+ chart.measures = measures;
+ chartSimplest = chart;
+ }
+ }
+
+ void _parseTag(RegExpMatch fieldData) {
+ List<String> keys =
+ fieldData[1]!.split(':').map((key) => key.trim()).toList();
+ String value = fieldData[2]!;
+ if (keys[0] == "BPMS") {
+ for (final pairRaw in value.split(',')) {
+ List<String> pair = pairRaw.split('=');
+ if (pair.length != 2) {
+ continue;
+ }
+ double time = double.parse(pair[0]);
+ double bpm = double.parse(pair[1]);
+ bpms[time] = bpm;
+ }
+ }
+
+ if (keys[0] == "OFFSET") {
+ offset = double.parse(value);
+ }
+
+ if (keys[0] != "NOTES") {
+ tags[keys[0]] = value;
+ return;
+ }
+ _parseChart(keys: keys, value: value);
+ }
+
+ void load() {
+ 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, '');
+ RegExp fieldDataRegExp = RegExp(r'#([^;]+):([^;]*);');
+
+ for (final fieldData in fieldDataRegExp.allMatches(lines!)) {
+ _parseTag(fieldData);
+ }
+ }
+}
diff --git a/lib/widgets/arrow.dart b/lib/widgets/arrow.dart
@@ -0,0 +1,18 @@
+import 'package:flutter/material.dart';
+import 'package:sense_the_rhythm/models/arrow_direction.dart';
+
+class Arrow extends StatelessWidget {
+ final double position;
+ final ArrowDirection direction;
+
+ const Arrow({super.key, required this.position, required this.direction});
+
+ @override
+ Widget build(BuildContext context) {
+ return Positioned(
+ left: MediaQuery.of(context).size.width / 2 - 50, // Center the arrow
+ bottom: position + 50,
+ child: Icon(size: 100, color: Colors.redAccent.shade400, direction.icon),
+ );
+ }
+}
diff --git a/lib/widgets/arrows.dart b/lib/widgets/arrows.dart
@@ -0,0 +1,24 @@
+import 'package:flutter/material.dart';
+import 'package:sense_the_rhythm/models/note.dart';
+import 'package:sense_the_rhythm/widgets/arrow.dart';
+
+class Arrows extends StatelessWidget {
+ final List<Note> notes;
+ final double position;
+
+ const Arrows({super.key, required this.notes, required this.position});
+
+ @override
+ Widget build(BuildContext context) {
+ return Stack(
+ children: notes.map((note) {
+ double position =
+ note.position * 10000; // * 20 * MediaQuery.of(context).size.height;
+
+ return Arrow(
+ position: position,
+ direction: note.direction,
+ );
+ }).toList());
+ }
+}
diff --git a/lib/esense_connect_dialog.dart b/lib/widgets/esense_connect_dialog.dart