import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:gba_core/gba_core.dart'; import 'emulator_session.dart'; import 'game_pad.dart'; /// Plays a game: the picture, the on-screen controls, and keyboard or /// game controller input. class EmulatorScreen extends StatefulWidget { const EmulatorScreen({ super.key, required this.romPath, required this.savePath, this.biosPath, }); final String romPath; final String savePath; final String? biosPath; @override State createState() => _EmulatorScreenState(); } class _EmulatorScreenState extends State with WidgetsBindingObserver { EmulatorSession? _session; String? _startError; bool _userPaused = false; bool _leaving = false; int _touchKeys = 0; int _keyboardKeys = 0; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); _start(); } Future _start() async { try { final session = await EmulatorSession.start( romPath: widget.romPath, savePath: widget.savePath, biosPath: widget.biosPath, ); if (!mounted) { await session.stop(); return; } session.error.addListener(_onSessionError); setState(() => _session = session); } on StateError catch (error) { if (mounted) setState(() => _startError = error.message); } } void _onSessionError() { final error = _session?.error.value; if (error != null && mounted) setState(() => _startError = error); } @override void didChangeAppLifecycleState(AppLifecycleState state) { final session = _session; if (session == null) return; if (state == AppLifecycleState.resumed) { if (!_userPaused) session.resume(); } else { // Also stores the save, in case the app is closed from here. session.pause(); } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); final session = _session; if (session != null && !_leaving) session.stop(); super.dispose(); } Future _leave() async { if (_leaving) return; _leaving = true; final navigator = Navigator.of(context); await _session?.stop(); if (mounted) navigator.pop(); } void _togglePause() { final session = _session; if (session == null) return; setState(() => _userPaused = !_userPaused); _userPaused ? session.pause() : session.resume(); } void _sendKeys() => _session?.setKeys(_touchKeys | _keyboardKeys); KeyEventResult _onKey(FocusNode node, KeyEvent event) { final key = _keyMap[event.logicalKey]; if (key == null) return KeyEventResult.ignored; if (event is KeyDownEvent || event is KeyRepeatEvent) { _keyboardKeys |= key; } else if (event is KeyUpEvent) { _keyboardKeys &= ~key; } _sendKeys(); return KeyEventResult.handled; } static final Map _keyMap = { LogicalKeyboardKey.arrowUp: GbaKey.up, LogicalKeyboardKey.arrowDown: GbaKey.down, LogicalKeyboardKey.arrowLeft: GbaKey.left, LogicalKeyboardKey.arrowRight: GbaKey.right, LogicalKeyboardKey.keyX: GbaKey.a, LogicalKeyboardKey.keyZ: GbaKey.b, LogicalKeyboardKey.keyA: GbaKey.l, LogicalKeyboardKey.keyS: GbaKey.r, LogicalKeyboardKey.enter: GbaKey.start, LogicalKeyboardKey.backspace: GbaKey.select, LogicalKeyboardKey.gameButtonA: GbaKey.a, LogicalKeyboardKey.gameButtonB: GbaKey.b, LogicalKeyboardKey.gameButtonLeft1: GbaKey.l, LogicalKeyboardKey.gameButtonRight1: GbaKey.r, LogicalKeyboardKey.gameButtonStart: GbaKey.start, LogicalKeyboardKey.gameButtonSelect: GbaKey.select, }; @override Widget build(BuildContext context) { return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (!didPop) _leave(); }, child: Scaffold( backgroundColor: Colors.black, body: Focus( autofocus: true, onKeyEvent: _onKey, child: _startError != null ? _buildError(context) : _buildGame(context), ), ), ); } Widget _buildError(BuildContext context) { return SafeArea( child: Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.error_outline, color: Colors.white70, size: 48), const SizedBox(height: 16), Text( _startError!, style: const TextStyle(color: Colors.white), textAlign: TextAlign.center, maxLines: 12, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 24), FilledButton(onPressed: _leave, child: const Text('Back')), ], ), ), ), ); } Widget _buildGame(BuildContext context) { final padding = MediaQuery.paddingOf(context); return LayoutBuilder( builder: (context, constraints) { final size = constraints.biggest; final landscape = size.width > size.height; final screen = _screenRect(size, padding); // In landscape the close and pause buttons sit in the top corners // of the side panels; the shoulder buttons go below them. final layout = GamePadLayout.compute( size, screen, topReserved: padding.top + _TopBar.height, ); final session = _session; return Stack( fit: StackFit.expand, children: [ if (session != null) CustomPaint(painter: _FramePainter(session.frame, screen)) else Positioned.fromRect( rect: screen, child: const Center(child: CircularProgressIndicator()), ), GamePad( layout: layout, onKeysChanged: (keys) { _touchKeys = keys; _sendKeys(); }, ), Positioned( top: padding.top, left: 0, right: 0, child: _TopBar( title: landscape ? null : session?.title ?? '', fps: session?.fps, paused: _userPaused, onClose: _leave, onPause: _togglePause, ), ), if (_userPaused) Positioned.fromRect( rect: screen, child: const ColoredBox( color: Color(0x99000000), child: Center( child: Text( 'Paused', style: TextStyle(color: Colors.white, fontSize: 24), ), ), ), ), ], ); }, ); } /// The game picture's rectangle: full width under the top bar in /// portrait, centred between the control panels in landscape, always at /// the GBA's 3:2 aspect ratio. static Rect _screenRect(Size size, EdgeInsets padding) { if (size.width > size.height) { final height = math.min( size.height - padding.vertical - 16, size.width * 0.56 / 1.5, ); final width = height * 1.5; return Rect.fromCenter( center: Offset(size.width / 2, size.height / 2), width: width, height: height, ); } final top = padding.top + _TopBar.height; final width = size.width; return Rect.fromLTWH(0, top, width, width / 1.5); } } /// Close on the left; frame rate and pause on the right. Without a /// [title] (landscape) the middle is empty and lets touches through. class _TopBar extends StatelessWidget { const _TopBar({ required this.title, required this.fps, required this.paused, required this.onClose, required this.onPause, }); static const double height = 48; final String? title; final ValueNotifier? fps; final bool paused; final VoidCallback onClose; final VoidCallback onPause; @override Widget build(BuildContext context) { const style = TextStyle(color: Colors.white70, fontSize: 13); final fps = this.fps; final title = this.title; return SizedBox( height: height, child: Row( children: [ IconButton( tooltip: 'Close game', icon: const Icon(Icons.close, color: Colors.white70), onPressed: onClose, ), if (title != null) Expanded( child: Text(title, style: style, overflow: TextOverflow.ellipsis), ) else const Spacer(), if (fps != null && !paused) ValueListenableBuilder( valueListenable: fps, builder: (context, value, _) => Text('${value.toStringAsFixed(0)} fps', style: style), ), IconButton( tooltip: paused ? 'Resume' : 'Pause', icon: Icon( paused ? Icons.play_arrow : Icons.pause, color: Colors.white70, ), onPressed: onPause, ), ], ), ); } } /// Draws the latest frame, scaled with hard pixel edges. class _FramePainter extends CustomPainter { _FramePainter(this.frame, this.screen) : super(repaint: frame); final ValueNotifier frame; final Rect screen; @override void paint(Canvas canvas, Size size) { final image = frame.value; if (image == null) return; canvas.drawImageRect( image, Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), screen, Paint()..filterQuality = FilterQuality.none, ); } @override bool shouldRepaint(_FramePainter old) => old.frame != frame || old.screen != screen; }