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(); final InputDirection _inputDirection = InputDirection(); String _hitOrMissMessage = 'Play!'; final 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; }), ); // listen for new values from player _durationSubscription = _player.onDurationChanged.listen((Duration duration) { setState(() => _duration = duration); }); _positionSubscription = _player.onPositionChanged.listen((Duration position) { setState(() => _position = position); for (final note in _notes) { _noteHitCheck(note, position); } }); // go to GameOverStats when level finishes _player.onPlayerComplete.listen((void _) { Route route = MaterialPageRoute( builder: (context) => GameOverStats( simfile: widget.simfile, notes: _notes, )); Navigator.pushReplacement(context, route); }); // listen for esense button and pause/resume if (ESenseInput.instance.connected) { _buttonSubscription = ESenseInput.instance.buttonEvents().listen((event) { if (!event.pressed) { _pauseResume(); } }); } // convert beats to notes 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 void dispose() { _animationController.dispose(); _durationSubscription?.cancel(); _positionSubscription?.cancel(); _buttonSubscription?.cancel(); _player.dispose(); super.dispose(); } /// toggle between pause and resume void _pauseResume() { if (_isPlaying) { _player.pause(); setState(() { _isPlaying = false; }); } else { _player.resume(); setState(() { _isPlaying = true; }); } } /// checks if the [note] is hit on [time] with the correct InputDirection void _noteHitCheck(Note note, Duration time) { note.position = note.time - time.inMilliseconds / 60000.0; if (note.wasHit != null) { return; } // you have +- half a second to hit if (note.position.abs() < 0.5 * 1.0 / 60.0) { // combine keyboard and esense input InputDirection esenseDirection = ESenseInput.instance.getInputDirection(note.direction); _inputDirection.up |= esenseDirection.up; _inputDirection.down |= esenseDirection.down; _inputDirection.left |= esenseDirection.left; _inputDirection.right |= esenseDirection.right; // check if input matches arrow direction 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'; }); } } /// sets the InputDirection based on the arrow keys void _keyboardHandler(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; } } @override Widget build(BuildContext context) { return KeyboardListener( focusNode: _focusNode, autofocus: true, onKeyEvent: _keyboardHandler, child: Scaffold( appBar: AppBar( leading: IconButton( icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow), onPressed: _pauseResume, ), 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), 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)), ), ), ])), ); } }