diff options
Diffstat (limited to 'lib')
-rw-r--r-- | lib/arrows.dart | 57 | ||||
-rw-r--r-- | lib/level.dart | 257 | ||||
-rw-r--r-- | lib/simfile.dart | 131 |
3 files changed, 359 insertions, 86 deletions
diff --git a/lib/arrows.dart b/lib/arrows.dart new file mode 100644 index 0000000..ff53e02 --- /dev/null +++ b/lib/arrows.dart @@ -0,0 +1,57 @@ +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/level.dart b/lib/level.dart index 1391f8a..aa042f4 100644 --- a/lib/level.dart +++ b/lib/level.dart @@ -3,6 +3,9 @@ import 'dart:io'; 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/simfile.dart'; class Level extends StatefulWidget { const Level({super.key, required this.stepmaniaFolderPath}); @@ -12,8 +15,16 @@ class Level extends StatefulWidget { State<Level> createState() => _LevelState(); } +class InputDirection { + bool up = false; + bool down = false; + bool left = false; + bool right = false; +} + class _LevelState extends State<Level> { final player = AudioPlayer(); + Simfile? simfile; bool _isPlaying = true; Duration? _duration; Duration? _position; @@ -21,6 +32,11 @@ class _LevelState extends State<Level> { StreamSubscription? _durationSubscription; StreamSubscription? _positionSubscription; + final FocusNode _focusNode = FocusNode(); + InputDirection inputDirection = InputDirection(); + + List<Note> notes = []; + @override void setState(VoidCallback fn) { // Subscriptions only can be closed asynchronously, @@ -51,10 +67,7 @@ class _LevelState extends State<Level> { _positionSubscription = player.onPositionChanged.listen( (p) => setState(() => _position = p), ); - } - @override - Widget build(BuildContext context) { player.onDurationChanged.listen((Duration d) { // print('Max duration: $d'); setState(() => _duration = d); @@ -63,85 +76,172 @@ class _LevelState extends State<Level> { 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) { + 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; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Great!'), + duration: Duration(milliseconds: 500), + ), + ); + } + } else if (note.position < -0.5 * 1.0 / 60.0) { + print("Missed"); + note.wasHit = false; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Missed!'), + duration: Duration(milliseconds: 500), + ), + ); + } + } }); + String simfilePath = Directory(widget.stepmaniaFolderPath) + .listSync() + .firstWhere((entity) => entity.path.endsWith('.sm'), + orElse: () => File('')) + .path; + String audioPath = Directory(widget.stepmaniaFolderPath) .listSync() .firstWhere((entity) => entity.path.endsWith('.ogg'), orElse: () => File('')) .path; + + simfile = Simfile(simfilePath); + simfile!.load(); + + simfile!.chartSimplest!.beats.forEach((time, noteData) { + int arrowIndex = noteData.indexOf('1'); + if (arrowIndex < 0 || arrowIndex > 3) { + return; + } + notes.add(Note(time: time, direction: ArrowDirection.values[arrowIndex])); + }); + + print(audioPath); + player.play(DeviceFileSource(audioPath)); - return 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.stepmaniaFolderPath.split('/').last), - 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: [ - Arrow( - position: -100.0, - ), - Arrow( - position: 00.0, - ), - Arrow( - position: 100.0, - ), - Arrow( - position: 200.0, - ), - Positioned( - top: 50, - width: MediaQuery.of(context).size.width, - left: 0, - child: Text( - "Great!", - textScaler: TextScaler.linear(4), - textAlign: TextAlign.center, + } + + @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.stepmaniaFolderPath.split('/').last), + 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, + )), ), - Positioned( - left: MediaQuery.of(context).size.width / 2 - 50, - bottom: 50, - child: Container( - width: 100, - height: 100, - decoration: BoxDecoration( - shape: BoxShape.circle, - // color: Colors.blue, - border: Border.all(color: Colors.black, width: 10)), + 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: Text( + "Great!", + 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 @@ -152,18 +252,3 @@ class _LevelState extends State<Level> { super.dispose(); } } - -class Arrow extends StatelessWidget { - final double position; - - const Arrow({super.key, required this.position}); - - @override - Widget build(BuildContext context) { - return Positioned( - left: MediaQuery.of(context).size.width / 2 - 25, // Center the arrow - top: position, - child: Icon(size: 100, Icons.arrow_forward), - ); - } -} diff --git a/lib/simfile.dart b/lib/simfile.dart new file mode 100644 index 0000000..102c989 --- /dev/null +++ b/lib/simfile.dart @@ -0,0 +1,131 @@ +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 path; + String? lines; + + // tags of simfile + Map<String, String> tags = {}; + + Chart? chartSimplest; + + Map<double, double> bpms = {}; + double offset = 0; + + Simfile(this.path); + + 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() { + lines = File(path).readAsStringSync(); + + RegExp commentsRegExp = RegExp(r'//.*$'); + lines = lines?.replaceAll(commentsRegExp, ''); + RegExp fieldDataRegExp = RegExp(r'#([^;]+):([^;]*);'); + + for (final fieldData in fieldDataRegExp.allMatches(lines!)) { + _parseTag(fieldData); + } + } +} |