diff options
Diffstat (limited to 'lib/screens')
| -rw-r--r-- | lib/screens/game_over.dart | 63 | ||||
| -rw-r--r-- | lib/screens/level.dart | 286 | ||||
| -rw-r--r-- | lib/screens/level_selection.dart | 157 | 
3 files changed, 506 insertions, 0 deletions
| 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<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 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<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 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<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)), +    ); +  } +} | 
