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/screens/game_over.dart | 63 +++++++++ lib/screens/level.dart | 286 +++++++++++++++++++++++++++++++++++++++ lib/screens/level_selection.dart | 157 +++++++++++++++++++++ 3 files changed, 506 insertions(+) create mode 100644 lib/screens/game_over.dart create mode 100644 lib/screens/level.dart create mode 100644 lib/screens/level_selection.dart (limited to 'lib/screens') 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)), + ); + } +} -- cgit v1.2.3