import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:gba_core/gba_core.dart'; /// A round or rectangular on-screen button. final class PadButton { const PadButton(this.key, this.label, this.rect, {this.round = true}); /// The [GbaKey] bit it presses. final int key; final String label; final Rect rect; final bool round; bool hit(Offset point) { if (!round) return rect.inflate(8).contains(point); // A generous touch target: 25% beyond the drawn circle. final radius = rect.shortestSide / 2 * 1.25; return (point - rect.center).distanceSquared <= radius * radius; } } /// Where the on-screen controls go around the game picture, and which /// buttons a touch at a given point presses. final class GamePadLayout { GamePadLayout._(this.dpad, this.buttons); /// Lays the controls out in [size], around the game picture at [screen]: /// below it in portrait, on either side of it in landscape, where they /// start below [topReserved] (the row holding the close and pause /// buttons). factory GamePadLayout.compute( Size size, Rect screen, { double topReserved = 0, }) { final landscape = size.width > size.height; return landscape ? _landscape(size, screen, topReserved) : _portrait(size, screen); } /// The D-pad's square. final Rect dpad; /// A, B, L, R, Start and Select. final List buttons; static GamePadLayout _portrait(Size size, Rect screen) { final top = screen.bottom + 8; final area = Rect.fromLTRB(0, top, size.width, size.height); final w = area.width; final h = area.height; final shoulderHeight = math.min(48.0, h * 0.13); final shoulderWidth = w * 0.3; final dpadSize = math.min(w * 0.42, h * 0.5); final buttonRadius = math.min(w * 0.09, h * 0.1); final smallWidth = w * 0.18; final smallHeight = math.min(34.0, h * 0.08); final smallY = area.bottom - h * 0.12; return GamePadLayout._( Rect.fromCenter( center: Offset(w * 0.25, top + h * 0.5), width: dpadSize, height: dpadSize, ), [ PadButton( GbaKey.l, 'L', Rect.fromLTWH(12, top + 8, shoulderWidth, shoulderHeight), round: false, ), PadButton( GbaKey.r, 'R', Rect.fromLTWH( w - 12 - shoulderWidth, top + 8, shoulderWidth, shoulderHeight, ), round: false, ), PadButton( GbaKey.a, 'A', Rect.fromCircle( center: Offset(w * 0.85, top + h * 0.44), radius: buttonRadius, ), ), PadButton( GbaKey.b, 'B', Rect.fromCircle( center: Offset(w * 0.66, top + h * 0.56), radius: buttonRadius, ), ), PadButton( GbaKey.select, 'SELECT', Rect.fromCenter( center: Offset(w * 0.39, smallY), width: smallWidth, height: smallHeight, ), round: false, ), PadButton( GbaKey.start, 'START', Rect.fromCenter( center: Offset(w * 0.61, smallY), width: smallWidth, height: smallHeight, ), round: false, ), ], ); } static GamePadLayout _landscape(Size size, Rect screen, double topReserved) { final left = Rect.fromLTRB(0, 0, screen.left, size.height); final right = Rect.fromLTRB(screen.right, 0, size.width, size.height); final h = size.height; final panel = left.width; final shoulderTop = topReserved + 8; final shoulderHeight = math.min(44.0, h * 0.12); final dpadSize = math.min(panel * 0.85, h * 0.46); final buttonRadius = math.min(panel * 0.17, h * 0.1); final smallHeight = math.min(32.0, h * 0.09); return GamePadLayout._( Rect.fromCenter( center: Offset( left.center.dx, math.max(h * 0.52, shoulderTop + shoulderHeight + dpadSize / 2 + 12), ), width: dpadSize, height: dpadSize, ), [ PadButton( GbaKey.l, 'L', Rect.fromLTWH( left.left + 12, shoulderTop, left.width - 24, shoulderHeight, ), round: false, ), PadButton( GbaKey.r, 'R', Rect.fromLTWH( right.left + 12, shoulderTop, right.width - 24, shoulderHeight, ), round: false, ), PadButton( GbaKey.a, 'A', Rect.fromCircle( center: Offset(right.left + right.width * 0.7, h * 0.45), radius: buttonRadius, ), ), PadButton( GbaKey.b, 'B', Rect.fromCircle( center: Offset(right.left + right.width * 0.32, h * 0.6), radius: buttonRadius, ), ), PadButton( GbaKey.select, 'SELECT', Rect.fromCenter( center: Offset(left.center.dx, h - 12 - smallHeight / 2), width: left.width * 0.6, height: smallHeight, ), round: false, ), PadButton( GbaKey.start, 'START', Rect.fromCenter( center: Offset(right.center.dx, h - 12 - smallHeight / 2), width: right.width * 0.6, height: smallHeight, ), round: false, ), ], ); } /// The buttons a touch at [point] presses. int keysAt(Offset point) { var keys = _dpadKeys(point); for (final button in buttons) { if (button.hit(point)) keys |= button.key; } return keys; } /// Eight directions, by angle from the D-pad's centre; the middle is a /// small dead zone. The touch area reaches a little beyond the drawing. int _dpadKeys(Offset point) { final offset = point - dpad.center; final radius = dpad.width / 2; final distance = offset.distance; if (distance < radius * 0.18 || distance > radius * 1.35) return 0; // 0 = right, counting clockwise (screen y points down) in eighths. final sector = ((math.atan2(offset.dy, offset.dx) / (math.pi / 4)).round()) % 8; return const [ GbaKey.right, GbaKey.right | GbaKey.down, GbaKey.down, GbaKey.down | GbaKey.left, GbaKey.left, GbaKey.left | GbaKey.up, GbaKey.up, GbaKey.up | GbaKey.right, ][sector]; } } /// The on-screen controls. Each finger is tracked separately, so buttons /// can be held together and a thumb can slide from one to another. class GamePad extends StatefulWidget { const GamePad({super.key, required this.layout, required this.onKeysChanged}); final GamePadLayout layout; /// Called with the mask of held [GbaKey]s whenever it changes. final ValueChanged onKeysChanged; @override State createState() => _GamePadState(); } class _GamePadState extends State { final Map _pointers = {}; int _keys = 0; void _update() { var keys = 0; for (final point in _pointers.values) { keys |= widget.layout.keysAt(point); } if (keys == _keys) return; if ((keys & ~_keys) != 0) HapticFeedback.selectionClick(); setState(() => _keys = keys); widget.onKeysChanged(keys); } @override Widget build(BuildContext context) { return Listener( behavior: HitTestBehavior.opaque, onPointerDown: (event) { _pointers[event.pointer] = event.localPosition; _update(); }, onPointerMove: (event) { _pointers[event.pointer] = event.localPosition; _update(); }, onPointerUp: (event) { _pointers.remove(event.pointer); _update(); }, onPointerCancel: (event) { _pointers.remove(event.pointer); _update(); }, child: CustomPaint( painter: _GamePadPainter(widget.layout, _keys), size: Size.infinite, ), ); } } class _GamePadPainter extends CustomPainter { _GamePadPainter(this.layout, this.keys); final GamePadLayout layout; final int keys; static const Color _idle = Color(0x40FFFFFF); static const Color _pressed = Color(0x90FFFFFF); static const Color _text = Color(0xCCFFFFFF); @override void paint(Canvas canvas, Size size) { _paintDpad(canvas); for (final button in layout.buttons) { final paint = Paint() ..color = (keys & button.key) != 0 ? _pressed : _idle; if (button.round) { canvas.drawCircle( button.rect.center, button.rect.shortestSide / 2, paint, ); } else { canvas.drawRRect( RRect.fromRectAndRadius( button.rect, Radius.circular(button.rect.height / 2), ), paint, ); } _label( canvas, button.label, button.rect.center, button.round ? button.rect.height * 0.4 : button.rect.height * 0.38, ); } } void _paintDpad(Canvas canvas) { final rect = layout.dpad; final arm = rect.width / 3; final center = rect.center; void part(int key, Rect r) { canvas.drawRRect( RRect.fromRectAndRadius(r, const Radius.circular(6)), Paint()..color = (keys & key) != 0 ? _pressed : _idle, ); } part(GbaKey.up, Rect.fromLTWH(center.dx - arm / 2, rect.top, arm, arm)); part( GbaKey.down, Rect.fromLTWH(center.dx - arm / 2, rect.bottom - arm, arm, arm), ); part(GbaKey.left, Rect.fromLTWH(rect.left, center.dy - arm / 2, arm, arm)); part( GbaKey.right, Rect.fromLTWH(rect.right - arm, center.dy - arm / 2, arm, arm), ); canvas.drawRect( Rect.fromCenter(center: center, width: arm, height: arm), Paint()..color = _idle, ); } void _label(Canvas canvas, String text, Offset center, double fontSize) { final painter = TextPainter( text: TextSpan( text: text, style: TextStyle( color: _text, fontSize: fontSize, fontWeight: FontWeight.w600, ), ), textDirection: TextDirection.ltr, )..layout(); painter.paint( canvas, center - Offset(painter.width / 2, painter.height / 2), ); } @override bool shouldRepaint(_GamePadPainter old) => old.keys != keys || old.layout != layout; }