From cd061eceeb421d6bec5cd38bf9f315036ce88887 Mon Sep 17 00:00:00 2001 From: Orangerot Date: Sat, 11 Jan 2025 18:22:32 +0100 Subject: style: refactored into folder structure --- lib/arrows.dart | 57 ------- lib/esense_connect_dialog.dart | 52 ------ lib/esense_input.dart | 166 ------------------- lib/game_over_stats.dart | 63 ------- lib/level.dart | 295 --------------------------------- lib/level_selection.dart | 158 ------------------ lib/main.dart | 2 +- lib/models/arrow_direction.dart | 12 ++ lib/models/input_direction.dart | 13 ++ lib/models/note.dart | 10 ++ lib/screens/game_over.dart | 63 +++++++ lib/screens/level.dart | 286 ++++++++++++++++++++++++++++++++ lib/screens/level_selection.dart | 157 ++++++++++++++++++ lib/simfile.dart | 152 ----------------- lib/utils/esense_input.dart | 166 +++++++++++++++++++ lib/utils/simfile.dart | 151 +++++++++++++++++ lib/widgets/arrow.dart | 18 ++ lib/widgets/arrows.dart | 24 +++ lib/widgets/esense_connect_dialog.dart | 52 ++++++ 19 files changed, 953 insertions(+), 944 deletions(-) delete mode 100644 lib/arrows.dart delete mode 100644 lib/esense_connect_dialog.dart delete mode 100644 lib/esense_input.dart delete mode 100644 lib/game_over_stats.dart delete mode 100644 lib/level.dart delete mode 100644 lib/level_selection.dart create mode 100644 lib/models/arrow_direction.dart create mode 100644 lib/models/input_direction.dart create mode 100644 lib/models/note.dart create mode 100644 lib/screens/game_over.dart create mode 100644 lib/screens/level.dart create mode 100644 lib/screens/level_selection.dart delete mode 100644 lib/simfile.dart create mode 100644 lib/utils/esense_input.dart create mode 100644 lib/utils/simfile.dart create mode 100644 lib/widgets/arrow.dart create mode 100644 lib/widgets/arrows.dart create mode 100644 lib/widgets/esense_connect_dialog.dart diff --git a/lib/arrows.dart b/lib/arrows.dart deleted file mode 100644 index ff53e02..0000000 --- a/lib/arrows.dart +++ /dev/null @@ -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 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_connect_dialog.dart b/lib/esense_connect_dialog.dart deleted file mode 100644 index b598174..0000000 --- a/lib/esense_connect_dialog.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; - -class ESenseConnectDialog extends StatefulWidget { - final void Function(String) connect; - final ValueNotifier deviceStatus; - const ESenseConnectDialog( - {super.key, required this.deviceStatus, required this.connect}); - - @override - State createState() => _ESenseConnectDialogState(); -} - -class _ESenseConnectDialogState extends State { - 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: [ - TextButton( - onPressed: () => Navigator.pop(context, 'Cancel'), - child: const Text('Discard'), - ), - TextButton( - onPressed: () => widget.connect(eSenseDeviceName), - child: const Text('Connect'), - ), - ], - ); - } -} diff --git a/lib/esense_input.dart b/lib/esense_input.dart deleted file mode 100644 index c8fc22a..0000000 --- a/lib/esense_input.dart +++ /dev/null @@ -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 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 _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 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 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 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 deleted file mode 100644 index 0e73024..0000000 --- a/lib/game_over_stats.dart +++ /dev/null @@ -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 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 deleted file mode 100644 index c57f34c..0000000 --- a/lib/level.dart +++ /dev/null @@ -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 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 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 notes = []; - - late AnimationController _animationController; - late Animation _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(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 deleted file mode 100644 index 3d3a95a..0000000 --- a/lib/level_selection.dart +++ /dev/null @@ -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 createState() => _LevelSelectionState(); -} - -class _LevelSelectionState extends State { - String? stepmaniaCoursesPath; - List stepmaniaCoursesFolders = []; - List stepmaniaCoursesFoldersFiltered = []; - String searchString = ''; - - @override - void initState() { - super.initState(); - loadFolderPath(); - } - - Future loadFolderPath() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - final String? stepmaniaCoursesPathSetting = - prefs.getString('stepmania_courses'); - - if (stepmaniaCoursesPathSetting == null) return; - List stepmaniaCoursesFoldersFuture = - await listFilesAndFolders(stepmaniaCoursesPathSetting); - - setState(() { - stepmaniaCoursesPath = stepmaniaCoursesPathSetting; - stepmaniaCoursesFolders = stepmaniaCoursesFoldersFuture; - stepmaniaCoursesFoldersFiltered = stepmaniaCoursesFoldersFuture; - }); - } - - Future 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> listFilesAndFolders(String directoryPath) async { - final directory = Directory(directoryPath); - try { - // List all files and folders in the directory - List 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 index eb01d44..85443bb 100644 --- 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 new file mode 100644 index 0000000..5f0298b --- /dev/null +++ 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 new file mode 100644 index 0000000..08096c7 --- /dev/null +++ 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 new file mode 100644 index 0000000..bcc6ac6 --- /dev/null +++ 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 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 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 createState() => _LevelState(); +} + +class _LevelState extends State 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 notes = []; + + late AnimationController _animationController; + late Animation _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(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 createState() => _LevelSelectionState(); +} + +class _LevelSelectionState extends State { + String? stepmaniaCoursesPath; + List stepmaniaCoursesFolders = []; + List stepmaniaCoursesFoldersFiltered = []; + String searchString = ''; + + @override + void initState() { + super.initState(); + loadFolderPath(); + } + + Future loadFolderPath() async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + final String? stepmaniaCoursesPathSetting = + prefs.getString('stepmania_courses'); + + if (stepmaniaCoursesPathSetting == null) return; + List stepmaniaCoursesFoldersFuture = + await listFilesAndFolders(stepmaniaCoursesPathSetting); + + setState(() { + stepmaniaCoursesPath = stepmaniaCoursesPathSetting; + stepmaniaCoursesFolders = stepmaniaCoursesFoldersFuture; + stepmaniaCoursesFoldersFiltered = stepmaniaCoursesFoldersFuture; + }); + } + + Future 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> listFilesAndFolders(String directoryPath) async { + final directory = Directory(directoryPath); + try { + // List all files and folders in the directory + List 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 deleted file mode 100644 index 7bdf5c0..0000000 --- a/lib/simfile.dart +++ /dev/null @@ -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>? measures; - - Map beats = {}; -} - -class Simfile { - String directoryPath; - String? simfilePath; - String? audioPath; - String? bannerPath; - String? lines; - - // tags of simfile - Map tags = {}; - - Chart? chartSimplest; - - Map bpms = {}; - double offset = 0; - - Simfile(this.directoryPath); - - void _parseChart({required List 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> measures = []; - for (final measureRaw in value.split(',')) { - List 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 keys = - fieldData[1]!.split(':').map((key) => key.trim()).toList(); - String value = fieldData[2]!; - if (keys[0] == "BPMS") { - for (final pairRaw in value.split(',')) { - List 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 new file mode 100644 index 0000000..d909c0d --- /dev/null +++ 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 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 _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 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 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 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 new file mode 100644 index 0000000..0af734f --- /dev/null +++ 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>? measures; + + Map beats = {}; +} + +class Simfile { + String directoryPath; + String? simfilePath; + String? audioPath; + String? bannerPath; + String? lines; + + // tags of simfile + Map tags = {}; + + Chart? chartSimplest; + + Map bpms = {}; + double offset = 0; + + Simfile(this.directoryPath); + + void _parseChart({required List 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> measures = []; + for (final measureRaw in value.split(',')) { + List 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 keys = + fieldData[1]!.split(':').map((key) => key.trim()).toList(); + String value = fieldData[2]!; + if (keys[0] == "BPMS") { + for (final pairRaw in value.split(',')) { + List 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 new file mode 100644 index 0000000..1ad0ec4 --- /dev/null +++ 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 new file mode 100644 index 0000000..cf04e59 --- /dev/null +++ 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 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/widgets/esense_connect_dialog.dart b/lib/widgets/esense_connect_dialog.dart new file mode 100644 index 0000000..b598174 --- /dev/null +++ b/lib/widgets/esense_connect_dialog.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +class ESenseConnectDialog extends StatefulWidget { + final void Function(String) connect; + final ValueNotifier deviceStatus; + const ESenseConnectDialog( + {super.key, required this.deviceStatus, required this.connect}); + + @override + State createState() => _ESenseConnectDialogState(); +} + +class _ESenseConnectDialogState extends State { + 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: [ + TextButton( + onPressed: () => Navigator.pop(context, 'Cancel'), + child: const Text('Discard'), + ), + TextButton( + onPressed: () => widget.connect(eSenseDeviceName), + child: const Text('Connect'), + ), + ], + ); + } +} -- cgit v1.2.3