diff --git a/README.md b/README.md index d711d489..5b521f36 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,15 @@ redistributable. This is because gamepads uses GameInput API v0 which is statically linked. +## Bridge packages + +- [flame_gamepads](https://github.com/flame-engine/flame/tree/main/packages/flame_gamepads) - + Provides a GamepadCallbacks component mixin for your Flame games +- [flutter_gamepads](./packages/flutter_gamepads/) - Provides a widget that maps gamepad input + to UI interaction with your Flutter widgets. In regular Flutter apps as well as for Flame + overlays. + + ## Support The simplest way to show us your support is by giving the project a star! :star: diff --git a/packages/flutter_gamepads/.gitignore b/packages/flutter_gamepads/.gitignore new file mode 100644 index 00000000..dd5eb989 --- /dev/null +++ b/packages/flutter_gamepads/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/packages/flutter_gamepads/.metadata b/packages/flutter_gamepads/.metadata new file mode 100644 index 00000000..c196d6b2 --- /dev/null +++ b/packages/flutter_gamepads/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa" + channel: "[user-branch]" + +project_type: package diff --git a/packages/flutter_gamepads/CHANGELOG.md b/packages/flutter_gamepads/CHANGELOG.md new file mode 100644 index 00000000..dd3f8712 --- /dev/null +++ b/packages/flutter_gamepads/CHANGELOG.md @@ -0,0 +1,5 @@ +# Change Log + +## 0.1.0 + + - Initial release diff --git a/packages/flutter_gamepads/LICENSE b/packages/flutter_gamepads/LICENSE new file mode 100644 index 00000000..fd50777c --- /dev/null +++ b/packages/flutter_gamepads/LICENSE @@ -0,0 +1 @@ +../../LICENSE diff --git a/packages/flutter_gamepads/README.md b/packages/flutter_gamepads/README.md new file mode 100644 index 00000000..2efc3f47 --- /dev/null +++ b/packages/flutter_gamepads/README.md @@ -0,0 +1,255 @@ +# flutter_gamepads + +A Flutter package that maps gamepad input to UI interaction. + +This package is built on Flutter’s focus and intent system which powers keyboard navigation in +Flutter. This means that for a large part, the same effort you spend on supporting keyboard and +screen reader users also benefit gamepad users and vice versa. + +The philosophy is that you just add Gamepad support to your app to extend its multi-modality +of user input. + + +## Features + +- Move focus +- Activate focused button +- Dismiss +- Scroll +- (actually anything that you can do with Intents in Flutter, plus more with callbacks) +- Gamepad buttons and axes can be used as input +- Input repetition (on long press/activation) +- Uses [*gamepads*](https://pub.dev/packages/gamepads) as the underlying Gamepad platforms + support library +- A GamepadControl widget to wrap your app which in some cases is all you need. +- Callbacks that allow intercepting an Intent before it actually is emitted. +- Extensive example project showing how the package can be used in pure Flutter apps + as well as for Flame game overlays. +- Can be used in both pure Flutter apps and in Flame games for overlays and menus. For Flame + usage, see the Flame-specific guidance later in the README. + + +### Limitations + +This package does not magically "just work" in all cases. Your app has to work reasonably well +with the Flutter focus system and there can be some widgets that need some extra work to get +working. + +Text input is currently not supported via Gamepad input. + + +## Quick-start + + +### Preparation + +Use only TAB key and Space/Enter to navigate your app. If this works, it will likely work well +with gamepad input. + +If there are widgets you cannot reach, fix that first. If there are widgets you cannot interact +with while they are focused, that can be handled specifically for gamepads. + +If you cannot clearly see which widget has focus, update the theme of your app and make for +example the border of focused widgets stand out in a different color. + + +### Gamepad support + +1. Start by wrapping your MaterialApp with `GamepadControl`, which in some cases is all you need. +2. Test your app +3. If you find out that some widgets can't be controlled with a gamepad, add an `onBeforeIntent` + callback to `GamepadControl` or wrap each widget with a `GamepadInterceptor`. See below for + detailed explanation of callbacks. + + +## Usage + +`GamepadControl` is the main widget of this package. You usually have exactly one of this widget +that wraps your MaterialApp or similar. This widget will listen on *gamepads* input stream +and emit Flutter intents on the primary focused context based on user input. + + +### Callbacks + +You can provide a `onBeforeIntent` method to `GamepadControl` to intercept just before an intent +would be invoked on the primary focus of your Flutter app. + +If you return false from it, the intent is blocked from being emitted. + +```dart +bool onBeforeIntent(GamepadActivator activator, Intent intent) { + +} +``` + +However with local states this becomes impractical and *flutter_gamepads* provides an +other widget `GamepadInterceptor` that you wrap a subtree of widgets. It's only purpose +is to provide an onBeforeIntent callback that is locally scoped. + +```dart +GamepadInterceptor( + onBeforeIntent: (activator, intent) { + + }, + child: YourWidget(), +) +``` + + +#### onBeforeIntent examples + +An example of how to build a gamepad-extended widget can be found in +[SliderWithGamepadExport](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_gamepads/example/lib/flutter_example/pages/slider_with_gamepad_support.dart). + +Another example, using the activator to support 4-way directional D-pad +within a Tick-tac-toe game is in [TicTacToe widget](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_gamepads/example/lib/flutter_example/pages/game_page.dart). + + +### Blocking Gamepad input + +There are four ways to block gamepad input from invoking intents: + +1. Omitting the `GamepadControl` widget from your widget tree + - Fully unregisters `gamepad` event handles, axis activation memory, repeat timers etc. +2. `GamepadControl.ignoreEvents == true` + - Early check on each *gamepads* event, axis activation memory is reset and repeat timers are + reset. +3. `GamepadInterceptor.onBeforeIntent() => false` + - Blocks each intent before it is passed on to GamepadControl.onBeforeIntent() +4. `GamepadControl.onBeforeIntent() => false` + - Blocks each intent before it is passed on to Flutter. + +Method 1 and 2 are good for when you fully want to block gamepad control of Flutter UI. + +Method 3 or 4 is good if you want to block specific intents. + + +### Multiple GamepadControl widgets + +Note that if you have multiple `GamepadControl` widgets concurrently in your widget tree, they +will all emit intents on the `primaryFocus` focus node. Except if you set `ignoreEvents` or +use `onBeforeIntent` to block intents from all but one of the `GamepadControl` widgets. + +The `GamepadControl` widget does not check if primaryFocus is a descendant of itself. + + +### How it works + +`GamepadControl` listens on `NormalizedGamepadEvent` from *gamepads* package and maps those +to a `GamepadActivator` and its related `Intent`. + +Input repetition is conceptually started on activation of a GamepadActivator and stopped +once the activator has been canceled (eg. button up or axis below minimum threshold). + +`GamepadControl` will lookup the closest ancestor `GamepadInterceptor` from `primaryFocus` +and call its `onBeforeIntent` first (if there is one), and then proceed to `onBeforeIntent` on +`GamepadControl`. Calling is lazy so if the local `onBeforeIntent` returns fall, the one on +`GamepadControl` is not called. + +If no onBeforeIntent has rejected, the Intent will be invoked on the primary focus context. + +[Diagram of the callbacks and intent emit chain](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_gamepads/example/lib/flutter_example/docs/input_diagram.svg) + + +### Defaults + +By default `GamepadControl` comes with these bindings: + +- D-pad up: Previous focus +- D-pad left: Previous focus +- D-pad right: Next focus +- D-pad down: Next focus +- A: Activate +- B: Dismiss +- Right stick up: Scroll up +- Right stick left: Scroll left +- Right stick right: Scroll right +- Right stick down: Scroll down + +Except for Activate and Dismiss, all intents have input repeat enabled by default. + +The bindings can customized via the `shortcuts` parameter. It is not limited to the intents +above. Any class that inherits from the `Intent` base class can be used as the emitted intent +for a gamepad activator (button or axis). + + +### Flame specific guidance + +`flutter_gamepads` can be helpful in scenarios when you have overlays in your +Flame game that you want users to be able to navigate with their gamepad. + +1. Wrap your `GameWidget` with a `GamepadControl` widget +2. For overlays that represent a modal dialog, you will need to trap the focus + in the dialog. See + [OverlayDialogBackdrop](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_gamepads/example/lib/flame_example/overlays/overlay_dialog_backdrop.dart) + in Flame example app for how you can do that. In that example the dialog itself + will receive the focus so that when a mouse user opens the dialog, it won't show + a focus indicator on a button in the dialog. +3. To close overlay dialogs on DismissIntent, you will need to catch it with + `onBeforeIntent` and close the overlay. In + [Flame Example](https://github.com/flame-engine/gamepads/tree/main/packages/flutter_gamepads/example/lib/flame_example/main.dart) + this is done generically at the root, but could instead wrap each dialog in a + `GamepadInterceptor` to do it locally if you need to guard closing the dialog + by some condition. +4. If you need to disable `GamepadControl` while in-game you can do so by setting + `ignoreEvents = true` on it. + + +## Code example + +In the example folder there is both a full Flutter app and a full Flame game example showing +how the package can be used in those two scenarios. + +Here follows a brief code example: + +```dart +GamepadControl( + child: MaterialApp( + home: Scaffold( + body: Column( + children: [ + SwitchListTile( + title: const Text('Works with gamepads'), + value: switchValue, + onChanged: (value) => setState(() => switchValue = value), + ), + ElevatedButton(onPressed: () {}, Text('Can be clicked with gamepad')), + // This can be focused, but gamepad users cannot change the value + // The solution is given below. + Slider( + value: sliderValue, + label: 'Does not work with gamepads', + onChange: (value) => setState(() => sliderValue = value), + ), + // This slider can be operated with Gamepad due to the + // compatibility layer provided via GamepadInterceptor. + GamepadInterceptor( + onBeforeIntent: (activator, intent) { + if (intent is ScrollIntent) { + if (intent.direction == AxisDirection.right) { + setState(() _value = min(1.0, _value + 0.1)); + } else if (intent.direction == + AxisDirection.left) { + setState(() _value = max(0.0, _value - 0.1)); + } + // Block actual emit of ScrollIntent + return false; + } + // Allow other intents such as focus change to occur + return true; + }, + child: Slider( + value: _value, + label: 'Works with gamepads', + max: 1.0, + // This setState never occur by Gamepad input, but is + // good to allow keyboard/mouse input as well. + onChange: (value) => setState(() => _value = value), + ), + ), + ], + ), + ), + ), +) +``` diff --git a/packages/flutter_gamepads/analysis_options.yaml b/packages/flutter_gamepads/analysis_options.yaml new file mode 100644 index 00000000..ba5631f3 --- /dev/null +++ b/packages/flutter_gamepads/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml \ No newline at end of file diff --git a/packages/flutter_gamepads/docs/input_diagram.mmd b/packages/flutter_gamepads/docs/input_diagram.mmd new file mode 100644 index 00000000..b7c6e18e --- /dev/null +++ b/packages/flutter_gamepads/docs/input_diagram.mmd @@ -0,0 +1,29 @@ +flowchart TD + input["Normalized Gamepad Input (gamepads package)"] + --> ignoreCheck{"ignoreEvents"} + + ignoreCheck -->|true| stopIgnore[STOP] + ignoreCheck -->|false| findActivator[Find matching GamepadActivator] + + findActivator --> keyEventType{"Activator event"} + + keyEventType -->|Activated| startRepeat[Start repeat timer for intent] + keyEventType -->|Canceled| stopRepeat[Stop repeat timer of intent] + stopRepeat --> stopAfterStopRepeat[STOP] + keyEventType -->|Neither| stopInvalid[STOP] + + timerEvent[Repeat timer event] + startRepeat --> findInterceptor + timerEvent --> findInterceptor + + findInterceptor[Find nearest GamepadInterceptor ancestor from primaryFocus] + + findInterceptor --> interceptorDecision{"GamepadInterceptor.onBeforeIntent()?"} + + interceptorDecision -->|Return false| stopInterceptor[STOP] + interceptorDecision -->|Return true| controlDecision{"GamepadControl.onBeforeIntent()?"} + + controlDecision -->|Return false| stopControl[STOP] + controlDecision -->|Return true| emitIntent[Emit Intent to primaryFocus] + + emitIntent --> actionsSystem[Flutter Actions system handles it] \ No newline at end of file diff --git a/packages/flutter_gamepads/docs/input_diagram.svg b/packages/flutter_gamepads/docs/input_diagram.svg new file mode 100644 index 00000000..0c92c99c --- /dev/null +++ b/packages/flutter_gamepads/docs/input_diagram.svg @@ -0,0 +1 @@ +truefalseActivatedCanceledNeitherReturn falseReturn trueReturn falseReturn trueNormalized Gamepad Input (gamepads package)ignoreEventsSTOPFind matching GamepadActivatorActivator eventStart repeat timer for intentStop repeat timer of intentSTOPSTOPRepeat timer eventFind nearest GamepadInterceptor ancestor from primaryFocusGamepadInterceptor.onBeforeIntent()?STOPGamepadControl.onBeforeIntent()?STOPEmit Intent to primaryFocusFlutter Actions system handles it \ No newline at end of file diff --git a/packages/flutter_gamepads/example/.gitignore b/packages/flutter_gamepads/example/.gitignore new file mode 100644 index 00000000..09237690 --- /dev/null +++ b/packages/flutter_gamepads/example/.gitignore @@ -0,0 +1,53 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + + +/test +/macos +/android +/ios +/web +/windows +/linux \ No newline at end of file diff --git a/packages/flutter_gamepads/example/.metadata b/packages/flutter_gamepads/example/.metadata new file mode 100644 index 00000000..f0bb85c0 --- /dev/null +++ b/packages/flutter_gamepads/example/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa" + channel: "[user-branch]" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + - platform: windows + create_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + base_revision: 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/flutter_gamepads/example/README.md b/packages/flutter_gamepads/example/README.md new file mode 100644 index 00000000..ad2d05af --- /dev/null +++ b/packages/flutter_gamepads/example/README.md @@ -0,0 +1,8 @@ +# flutter_gamepads example + +A simple example project showcasing the flutter_gamepads plugin. + +The example project consists of two distinct different apps: + +- [A Flutter example](lib/flutter_example/) +- [A Flame game example](lib/flame_example/) diff --git a/packages/flutter_gamepads/example/analysis_options.yaml b/packages/flutter_gamepads/example/analysis_options.yaml new file mode 100644 index 00000000..ba5631f3 --- /dev/null +++ b/packages/flutter_gamepads/example/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml \ No newline at end of file diff --git a/packages/flutter_gamepads/example/assets/images/power_up.png b/packages/flutter_gamepads/example/assets/images/power_up.png new file mode 100644 index 00000000..a5c0069a Binary files /dev/null and b/packages/flutter_gamepads/example/assets/images/power_up.png differ diff --git a/packages/flutter_gamepads/example/assets/images/power_up.svg b/packages/flutter_gamepads/example/assets/images/power_up.svg new file mode 100644 index 00000000..25c2dc9e --- /dev/null +++ b/packages/flutter_gamepads/example/assets/images/power_up.svg @@ -0,0 +1,51 @@ + + + + + + + + + + diff --git a/packages/flutter_gamepads/example/assets/images/spaceship.png b/packages/flutter_gamepads/example/assets/images/spaceship.png new file mode 100644 index 00000000..69507b60 Binary files /dev/null and b/packages/flutter_gamepads/example/assets/images/spaceship.png differ diff --git a/packages/flutter_gamepads/example/assets/images/spaceship.svg b/packages/flutter_gamepads/example/assets/images/spaceship.svg new file mode 100644 index 00000000..538a887f --- /dev/null +++ b/packages/flutter_gamepads/example/assets/images/spaceship.svg @@ -0,0 +1,50 @@ + + + + + + + + + + diff --git a/packages/flutter_gamepads/example/lib/flame_example/README.md b/packages/flutter_gamepads/example/lib/flame_example/README.md new file mode 100644 index 00000000..f61a17e7 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flame_example/README.md @@ -0,0 +1,4 @@ +# Flame example + +`flutter_gamepads` package is used to allow gamepad control over the overlays in the game. +While the spaceship is controlled with `gamepads` events directly. diff --git a/packages/flutter_gamepads/example/lib/flame_example/components/power_up.dart b/packages/flutter_gamepads/example/lib/flame_example/components/power_up.dart new file mode 100644 index 00000000..f46bde8f --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flame_example/components/power_up.dart @@ -0,0 +1,12 @@ +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; + +class PowerUp extends SpriteComponent { + @override + Future onLoad() async { + sprite = await Sprite.load('power_up.png'); + anchor = Anchor.center; + add(RectangleHitbox(collisionType: CollisionType.passive)); + return super.onLoad(); + } +} diff --git a/packages/flutter_gamepads/example/lib/flame_example/components/spaceship.dart b/packages/flutter_gamepads/example/lib/flame_example/components/spaceship.dart new file mode 100644 index 00000000..17876890 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flame_example/components/spaceship.dart @@ -0,0 +1,140 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gamepads_example/flame_example/components/power_up.dart'; +import 'package:flutter_gamepads_example/flame_example/game.dart'; +import 'package:flutter_gamepads_example/flame_example/state/game_state.dart'; +import 'package:gamepads/gamepads.dart'; + +class SpaceShip extends SpriteComponent + with KeyboardHandler, CollisionCallbacks, HasGameReference { + /// inputX refer to request to change ship rotation + double inputX = 0; + + /// inputY refer to request to accelerate/bake the ship + double inputY = 0; + + /// Velocity in world coordinates + Vector2 velocity = Vector2.zero(); + StreamSubscription? _subscription; + + static const rotationVelocity = 2.0; + static const accel = 50.0; + static const decel = 100.0; + static const autoBrakeDecel = 25.0; + static const gamepadDeadZone = 0.15; + + @override + Future onLoad() async { + sprite = await Sprite.load('spaceship.png'); + anchor = Anchor.center; + add(RectangleHitbox()); + _subscription = Gamepads.normalizedEvents.listen(onGamepadEvent); + return super.onLoad(); + } + + @override + void onRemove() { + _subscription?.cancel(); + super.onRemove(); + } + + @override + void update(double dt) { + super.update(dt); + + // Get installed upgrades + final hasTurnRail = game.gameState.installedUpgrades.value.contains( + SpaceshipUpgrades.turnRail, + ); + final hasAutoBrake = game.gameState.installedUpgrades.value.contains( + SpaceshipUpgrades.autoBrake, + ); + + // Update angle based on user input (inputX) + angle += inputX * dt * rotationVelocity; + + // Get fraction of travel in angle direction in x and y world coordinates. + // (velocity independent base-fraction) + final angleX = cos(angle - pi / 2); + final angleY = sin(angle - pi / 2); + + // Compute change in velocity based on user input (inputY) + final ddy = inputY > 0 ? accel : decel; + if (hasTurnRail) { + var dy = velocity.length; + dy = max(0, dy + inputY * dt * ddy); + velocity.x = angleX * dy; + velocity.y = angleY * dy; + } else { + velocity.x += angleX * dt * max(inputY, 0) * ddy; + velocity.y += angleY * dt * max(inputY, 0) * ddy; + } + + // Auto brake? + if (hasAutoBrake && inputY < 0.01 && velocity.length >= 0.001) { + velocity.clampLength(0, max(0, velocity.length - dt * autoBrakeDecel)); + } + + // Update spaceship position + if (velocity.length > 0.001) { + position.x += velocity.x * dt; + position.y += velocity.y * dt; + } + } + + // Keyboard input support + @override + bool onKeyEvent(KeyEvent event, Set keysPressed) { + if (keysPressed.contains(LogicalKeyboardKey.arrowLeft) || + keysPressed.contains(LogicalKeyboardKey.keyA)) { + inputX = -1; + } else if (keysPressed.contains(LogicalKeyboardKey.arrowRight) || + keysPressed.contains(LogicalKeyboardKey.keyD)) { + inputX = 1; + } else { + inputX = 0; + } + if (keysPressed.contains(LogicalKeyboardKey.arrowUp) || + keysPressed.contains(LogicalKeyboardKey.keyW)) { + inputY = 1; + } else if (keysPressed.contains(LogicalKeyboardKey.arrowDown) || + keysPressed.contains(LogicalKeyboardKey.keyS)) { + inputY = -1; + } else { + inputY = 0; + } + + return super.onKeyEvent(event, keysPressed); + } + + /// Listener for Gamepad events. This process events directly from + /// underlying gamepads plugin, and not via GamepadControl. + /// + /// Whenever a dialog is opened, the flame game engine is paused causing + /// no update() to occur for the spaceship. + void onGamepadEvent(NormalizedGamepadEvent event) { + if (event.axis == GamepadAxis.leftStickX) { + inputX = event.value.abs() > gamepadDeadZone ? event.value : 0; + } + if (event.axis == GamepadAxis.rightTrigger) { + inputY = event.value.abs() > gamepadDeadZone ? event.value : 0; + } + if (event.axis == GamepadAxis.leftTrigger) { + inputY = event.value.abs() > gamepadDeadZone ? -event.value : 0; + } + } + + @override + void onCollision(Set intersectionPoints, PositionComponent other) { + if (other is PowerUp) { + game.world.remove(other); + game.gameState.powerUps.value += 1; + } + + super.onCollision(intersectionPoints, other); + } +} diff --git a/packages/flutter_gamepads/example/lib/flame_example/game.dart b/packages/flutter_gamepads/example/lib/flame_example/game.dart new file mode 100644 index 00000000..52efaffc --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flame_example/game.dart @@ -0,0 +1,59 @@ +import 'package:flame/game.dart'; +import 'package:flame/input.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/overlays.dart'; +import 'package:flutter_gamepads_example/flame_example/state/game_state.dart'; +import 'package:flutter_gamepads_example/flame_example/world.dart'; + +/// This is our flame game. +class MyGame extends FlameGame with HasKeyboardHandlerComponents { + final GameState gameState = GameState(); + final void Function()? exitApp; + + MyGame(this.exitApp) : super(world: MyWorld()); + + /// Is any dialog style overlay active? + bool get anyDialogOpen => + overlays.activeOverlays.contains(MyOverlays.help.name) || + overlays.activeOverlays.contains(MyOverlays.upgrade.name); + + /// Update flame engine pause based on if the game should currently + /// be paused or not. + void updateEnginePause() { + final shouldBePaused = gameState.userPaused.value || anyDialogOpen; + if (shouldBePaused != paused) { + if (shouldBePaused) { + pauseEngine(); + } else { + resumeEngine(); + } + } + } + + /// Show an overlay + /// + /// Automatically pauses the game engine if the overlay is a dialog + /// according to [anyDialogOpen]. + void showOverlay(MyOverlays overlay) { + overlays.add(overlay.name); + updateEnginePause(); + } + + /// Hide an overlay + /// + /// Automatically unpauses the game if there are no longer any open dialogs + /// according to [anyDialogOpen] and the user has not manually paused the + /// game. + void hideOverlay(MyOverlays overlay) { + overlays.remove(overlay.name); + updateEnginePause(); + } + + /// Close all dialog style overlays + void hideAllDialogs() { + overlays.removeAll([ + MyOverlays.help.name, + MyOverlays.upgrade.name, + ]); + updateEnginePause(); + } +} diff --git a/packages/flutter_gamepads/example/lib/flame_example/main.dart b/packages/flutter_gamepads/example/lib/flame_example/main.dart new file mode 100644 index 00000000..c5f5092a --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flame_example/main.dart @@ -0,0 +1,57 @@ +import 'package:flame/game.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads/flutter_gamepads.dart'; +import 'package:flutter_gamepads_example/flame_example/game.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/help_overlay.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/overlays.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/statusbar.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/upgrade_overlay.dart'; +import 'package:flutter_gamepads_example/flame_example/theme.dart'; + +void main() { + runApp(const MyFlameApp()); +} + +class MyFlameApp extends StatelessWidget { + /// Callback provided by the Example chooser that allow the + /// example to signal that user wants to exit the example. + final void Function()? exitApp; + const MyFlameApp({this.exitApp, super.key}); + + @override + Widget build(BuildContext context) { + final game = MyGame(exitApp); + return MaterialApp( + theme: buildTheme(), + // The role here of GamepadControl is to allow users to interact + // with the Flutter widgets in overlays of the Flame game. + // + // For Spaceship control, the game uses direct gamepads event + // processing. See components/spaceship.dart. + home: GamepadControl( + // An alternative to closing dialogs globally here is to wrap + // each dialog with a GamepadInterceptor to be able to locally + // catch the DismissIntent there. That option can be useful if + // you need to prevent closing in some situations. + onBeforeIntent: (activator, intent) { + if (intent is DismissIntent && game.anyDialogOpen) { + game.hideAllDialogs(); + return false; + } + return true; + }, + child: GameWidget( + game: game, + initialActiveOverlays: [MyOverlays.statusbar.name], + overlayBuilderMap: { + MyOverlays.help.name: (context, MyGame game) => HelpOverlay(game), + MyOverlays.statusbar.name: (context, MyGame game) => + StatusBarOverlay(game), + MyOverlays.upgrade.name: (context, MyGame game) => + UpgradeOverlay(game), + }, + ), + ), + ); + } +} diff --git a/packages/flutter_gamepads/example/lib/flame_example/overlays/help_overlay.dart b/packages/flutter_gamepads/example/lib/flame_example/overlays/help_overlay.dart new file mode 100644 index 00000000..1ba0ee26 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flame_example/overlays/help_overlay.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads_example/flame_example/game.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/overlay_dialog_backdrop.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/overlays.dart'; + +class HelpOverlay extends StatelessWidget { + final MyGame game; + const HelpOverlay(this.game, {super.key}); + + @override + Widget build(BuildContext context) { + return OverlayDialogBackdrop( + child: AlertDialog( + title: const Text('Controls'), + content: const Text(_bodyText), + actions: [ + FilledButton( + onPressed: () { + game.hideOverlay(MyOverlays.help); + }, + child: const Text('Close'), + ), + ], + ), + ); + } +} + +const _bodyText = '''** Spaceship ** +Turn left: A, LeftArrow, Gamepad left stick +Turn right: D, RightArrow, Gamepad left stick +Accelerate: W, UpArrow, Gamepad right trigger +Brake: D, DownArrow, Gamepad left trigger + +Note: to brake, you need the turnRail upgrade. + +** Gamepad UI controls ** +Move focus: D-pad +Activate button: A +Close dialog: B + '''; diff --git a/packages/flutter_gamepads/example/lib/flame_example/overlays/overlay_dialog_backdrop.dart b/packages/flutter_gamepads/example/lib/flame_example/overlays/overlay_dialog_backdrop.dart new file mode 100644 index 00000000..a29798ef --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flame_example/overlays/overlay_dialog_backdrop.dart @@ -0,0 +1,40 @@ +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; + +/// A dialog backdrop that also moves Focus to within the dialog and blocks +/// pointer input outside of the dialog. +class OverlayDialogBackdrop extends StatefulWidget { + final Widget child; + const OverlayDialogBackdrop({required this.child, super.key}); + + @override + State createState() => _OverlayDialogBackdropState(); +} + +class _OverlayDialogBackdropState extends State { + final FocusScopeNode _focusScope = FocusScopeNode(); + + @override + void initState() { + super.initState(); + // Set the focus within the dialog just after it opens + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusScope.requestFocus(); + }); + } + + @override + Widget build(BuildContext context) { + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + // A FocusScope widget traps the focus to stay within the dialog. + // (but it doesn't itself move the focus there, hence the code in + // initState) + child: FocusScope(node: _focusScope, child: widget.child), + ), + ); + } +} diff --git a/packages/flutter_gamepads/example/lib/flame_example/overlays/overlays.dart b/packages/flutter_gamepads/example/lib/flame_example/overlays/overlays.dart new file mode 100644 index 00000000..b5a9098f --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flame_example/overlays/overlays.dart @@ -0,0 +1,5 @@ +enum MyOverlays { + help, + statusbar, + upgrade, +} diff --git a/packages/flutter_gamepads/example/lib/flame_example/overlays/statusbar.dart b/packages/flutter_gamepads/example/lib/flame_example/overlays/statusbar.dart new file mode 100644 index 00000000..b1a62494 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flame_example/overlays/statusbar.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads_example/flame_example/game.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/overlays.dart'; + +class StatusBarOverlay extends StatefulWidget { + final MyGame game; + + const StatusBarOverlay(this.game, {super.key}); + + @override + State createState() => _StatusBarOverlayState(); +} + +class _StatusBarOverlayState extends State { + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(4), + color: Theme.of(context).colorScheme.surface, + child: Row( + children: [ + if (widget.game.exitApp != null) ...[ + FilledButton( + onPressed: widget.game.exitApp, + child: const Icon( + Icons.chevron_left, + semanticLabel: 'Exit Flame Example', + ), + ), + const SizedBox(width: 5), + ], + FilledButton( + onPressed: onTogglePause, + child: ValueListenableBuilder( + valueListenable: widget.game.gameState.userPaused, + builder: (context, userPaused, child) { + return Icon( + userPaused ? Icons.play_arrow : Icons.pause, + semanticLabel: userPaused ? 'Unpause' : 'Pause', + ); + }, + ), + ), + const SizedBox(width: 5), + FilledButton( + onPressed: onShowUpgradeOverlay, + child: ValueListenableBuilder( + valueListenable: widget.game.gameState.powerUps, + builder: (context, value, child) { + return Row( + children: [ + Image.asset( + 'assets/images/power_up.png', + semanticLabel: 'Power ups', + ), + Text('$value'), + ], + ); + }, + ), + ), + const SizedBox(width: 5), + FilledButton( + onPressed: onShowHelpOverlay, + child: const Text('Controls'), + ), + ], + ), + ); + } + + void onTogglePause() { + widget.game.gameState.userPaused.value = + !widget.game.gameState.userPaused.value; + widget.game.updateEnginePause(); + } + + void onShowUpgradeOverlay() { + widget.game.showOverlay(MyOverlays.upgrade); + } + + void onShowHelpOverlay() { + widget.game.showOverlay(MyOverlays.help); + } +} diff --git a/packages/flutter_gamepads/example/lib/flame_example/overlays/upgrade_overlay.dart b/packages/flutter_gamepads/example/lib/flame_example/overlays/upgrade_overlay.dart new file mode 100644 index 00000000..aa6a0c95 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flame_example/overlays/upgrade_overlay.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads_example/flame_example/game.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/overlay_dialog_backdrop.dart'; +import 'package:flutter_gamepads_example/flame_example/overlays/overlays.dart'; +import 'package:flutter_gamepads_example/flame_example/state/game_state.dart'; + +class UpgradeOverlay extends StatelessWidget { + final MyGame game; + const UpgradeOverlay(this.game, {super.key}); + + @override + Widget build(BuildContext context) { + return OverlayDialogBackdrop( + child: AlertDialog( + title: const Text('Upgrades'), + content: SizedBox( + width: 500, + height: 400, + child: ValueListenableBuilder( + valueListenable: game.gameState.powerUps, + builder: (context, powerUps, child) { + if (powerUps > 0) { + return UpgradesList(game); + } + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'No PowerUp credits available, collect some power ups' + ' with your spaceship.', + ), + ], + ); + }, + ), + ), + actions: [ + FilledButton( + onPressed: () { + game.hideOverlay(MyOverlays.upgrade); + }, + child: const Text('Close'), + ), + ], + ), + ); + } +} + +class UpgradesList extends StatelessWidget { + final MyGame game; + const UpgradesList(this.game, {super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: game.gameState.installedUpgrades, + builder: (context, installedUpgrades, child) { + return ListView( + children: [ + ...SpaceshipUpgrades.values.map( + (upgrade) => Padding( + padding: const EdgeInsets.only(top: 8, bottom: 8), + child: FilledButton( + onPressed: installedUpgrades.contains(upgrade) + ? null + : () { + game.gameState.installedUpgrades.value = { + ...installedUpgrades, + upgrade, + }; + game.gameState.powerUps.value -= 1; + game.hideOverlay(MyOverlays.upgrade); + }, + child: Text(upgrade.name), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/packages/flutter_gamepads/example/lib/flame_example/state/game_state.dart b/packages/flutter_gamepads/example/lib/flame_example/state/game_state.dart new file mode 100644 index 00000000..63fff623 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flame_example/state/game_state.dart @@ -0,0 +1,13 @@ +import 'package:flame/components.dart'; +import 'package:flutter/foundation.dart'; + +enum SpaceshipUpgrades { + autoBrake, + turnRail, +} + +class GameState extends Component { + final powerUps = ValueNotifier(0); + final installedUpgrades = ValueNotifier>({}); + final userPaused = ValueNotifier(false); +} diff --git a/packages/flutter_gamepads/example/lib/flame_example/theme.dart b/packages/flutter_gamepads/example/lib/flame_example/theme.dart new file mode 100644 index 00000000..ab4692f1 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flame_example/theme.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +ThemeData buildTheme() { + return ThemeData.from( + colorScheme: ColorScheme.dark( + primary: Colors.orange[700]!, + surface: Color.lerp(Colors.orange[900], Colors.grey[800], 0.7)!, + ), + ).copyWith( + dialogTheme: DialogThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadiusGeometry.circular(5), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: ButtonStyle( + /// Provide an expressive border color for focused buttons. + shape: WidgetStateProperty.resolveWith((state) { + return RoundedRectangleBorder( + side: BorderSide( + color: state.contains(WidgetState.focused) + ? Colors.lightGreenAccent + : Colors.transparent, + width: 4, + ), + borderRadius: BorderRadiusGeometry.circular( + state.contains(WidgetState.focused) ? 2 : 5, + ), + ); + }), + ), + ), + ); +} diff --git a/packages/flutter_gamepads/example/lib/flame_example/world.dart b/packages/flutter_gamepads/example/lib/flame_example/world.dart new file mode 100644 index 00000000..cc6cb488 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flame_example/world.dart @@ -0,0 +1,34 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flame/components.dart'; +import 'package:flame/extensions.dart'; +import 'package:flutter_gamepads_example/flame_example/components/power_up.dart'; +import 'package:flutter_gamepads_example/flame_example/components/spaceship.dart'; +import 'package:flutter_gamepads_example/flame_example/game.dart'; + +class MyWorld extends World + with HasCollisionDetection, HasGameReference { + static const worldSizeX = 1000.0; + static const worldSizeY = 1000.0; + @override + FutureOr onLoad() { + final spaceShip = SpaceShip(); + + addAll([ + spaceShip, + ...List.generate( + 10, + (i) => PowerUp() + ..position = Vector2( + Random().nextDoubleBetween(-worldSizeX / 2, worldSizeX / 2), + Random().nextDoubleBetween(-worldSizeX / 2, worldSizeY / 2), + ), + ), + ]); + + game.camera.follow(spaceShip); + + return super.onLoad(); + } +} diff --git a/packages/flutter_gamepads/example/lib/flutter_example/README.md b/packages/flutter_gamepads/example/lib/flutter_example/README.md new file mode 100644 index 00000000..8ea324f7 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flutter_example/README.md @@ -0,0 +1,5 @@ +# Flame example + +All gamepad input in this app is provided via `flutter_gamepads` package. It uses `GamepadInterceptor` +in some places to to provide gamepad support for certain widgets or scenarios that does not +work out of the box with just wrapping the app with `GamepadControl`. diff --git a/packages/flutter_gamepads/example/lib/flutter_example/main.dart b/packages/flutter_gamepads/example/lib/flutter_example/main.dart new file mode 100644 index 00000000..607ee019 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flutter_example/main.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads/flutter_gamepads.dart'; +import 'package:flutter_gamepads_example/flutter_example/pages/game_page.dart'; +import 'package:flutter_gamepads_example/flutter_example/pages/home_page.dart'; +import 'package:flutter_gamepads_example/flutter_example/pages/settings_page.dart'; +import 'package:flutter_gamepads_example/flutter_example/theme.dart'; + +void main() { + runApp(const MyFlutterApp()); +} + +class MyFlutterApp extends StatelessWidget { + /// Callback provided by the Example chooser that allow the + /// example to signal that user wants to exit the example. + final void Function()? exitApp; + + const MyFlutterApp({this.exitApp, super.key}); + + @override + Widget build(BuildContext context) { + // The GamepadControl widget here in the root provides user ability + // to control the app with their gamepad. At some places through + // out flutter_example, there is GamepadInterceptor widgets that + // intercepts Gamepad input before GamepadControl invokes the mapped + // intent for the Gamepad input. + return GamepadControl( + child: MaterialApp( + theme: appTheme(), + initialRoute: '/', + routes: { + '/': (context) => HomePage(exitApp: exitApp), + '/settings': (context) => const SettingsPage(), + '/game': (context) => const GamePage(), + }, + ), + ); + } +} diff --git a/packages/flutter_gamepads/example/lib/flutter_example/pages/game_page.dart b/packages/flutter_gamepads/example/lib/flutter_example/pages/game_page.dart new file mode 100644 index 00000000..dec2c845 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flutter_example/pages/game_page.dart @@ -0,0 +1,231 @@ +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads/flutter_gamepads.dart'; +import 'package:gamepads/gamepads.dart'; + +class GamePage extends StatelessWidget { + const GamePage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Game')), + backgroundColor: Colors.green[900], + body: const _TickTackToe(), + ); + } +} + +class _TickTackToe extends StatefulWidget { + const _TickTackToe(); + + @override + State<_TickTackToe> createState() => _TickTackToeState(); +} + +enum _CellValue { + empty, + o, + x, +} + +class _TickTackToeState extends State<_TickTackToe> { + late final List<_CellValue> _board; + late final List _focusNodes; + var _player = _CellValue.x; + + static const buttonDirMap = { + GamepadButton.dpadUp: AxisDirection.up, + GamepadButton.dpadDown: AxisDirection.down, + GamepadButton.dpadLeft: AxisDirection.left, + GamepadButton.dpadRight: AxisDirection.right, + }; + + @override + void initState() { + _board = List<_CellValue>.generate(9, (_) => _CellValue.empty); + _focusNodes = List.generate(9, (_) => FocusNode()); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return GamepadInterceptor( + onBeforeIntent: (activator, intent) { + if (intent is ScrollIntent) { + moveFocus(intent.direction); + return false; + } + if (activator is GamepadActivatorButton && + buttonDirMap.keys.contains(activator.button)) { + return !moveFocus(buttonDirMap[activator.button]!); + } + return true; + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: SizedBox( + width: _cellSize * 3, + height: _cellSize * 3, + child: GridView.count( + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + children: _board + .mapIndexed( + (i, v) => _Cell( + onActivate: () => cellActivate(i), + value: v, + index: i, + focusNode: _focusNodes[i], + ), + ) + .toList(), + ), + ), + ), + const SizedBox(height: 10), + Text( + 'Current player: ${switch (_player) { + _CellValue.x => 'x', + _CellValue.o => 'o', + _CellValue.empty => '', + }}', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelLarge!.copyWith( + color: Colors.white70, + ), + ), + const SizedBox(height: 20), + Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: min(MediaQuery.sizeOf(context).width, 500), + ), + child: const Text( + 'GAMEPAD INFO\n' + 'You can use the D-pad or right stick to move focus directionally up/down/left/right' + ' which is supported via GamepadInterceptor.' + '\n\n' + 'Right stick only works while focus is within the 3x3 grid.', + style: TextStyle( + fontStyle: FontStyle.italic, + color: Colors.white70, + ), + softWrap: true, + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ); + } + + bool moveFocus(AxisDirection direction) { + final focusedIndex = _focusNodes.indexWhere( + (focusNode) => focusNode.hasFocus, + ); + var newFocusedIndex = focusedIndex; + if (newFocusedIndex == -1) { + newFocusedIndex = 4; + } else { + switch (direction) { + case AxisDirection.down: + if (newFocusedIndex < 6) { + newFocusedIndex += 3; + } + case AxisDirection.up: + if (newFocusedIndex > 2) { + newFocusedIndex -= 3; + } + case AxisDirection.left: + if (newFocusedIndex % 3 > 0) { + newFocusedIndex -= 1; + } + case AxisDirection.right: + if (newFocusedIndex % 3 < 2) { + newFocusedIndex += 1; + } + } + } + if (newFocusedIndex != focusedIndex) { + _focusNodes[newFocusedIndex].requestFocus(); + return true; + } + return false; + } + + void cellActivate(int index) { + setState(() { + _board[index] = _player; + _player = switch (_player) { + _CellValue.o => _CellValue.x, + _CellValue.x => _CellValue.o, + _CellValue.empty => throw Exception(), + }; + }); + } +} + +class _Cell extends StatefulWidget { + final void Function() onActivate; + final _CellValue value; + final int index; + final FocusNode focusNode; + const _Cell({ + required this.value, + required this.index, + required this.focusNode, + required this.onActivate, + }); + + @override + State<_Cell> createState() => _CellState(); +} + +class _CellState extends State<_Cell> { + @override + Widget build(BuildContext context) { + return Theme( + data: Theme.of(context).copyWith( + filledButtonTheme: FilledButtonThemeData( + style: ButtonStyle( + fixedSize: const WidgetStatePropertyAll( + Size(_cellSize, _cellSize), + ), + shape: const WidgetStatePropertyAll( + RoundedRectangleBorder(), + ), + backgroundColor: WidgetStatePropertyAll(Colors.brown[300]), + side: WidgetStateProperty.resolveWith((state) { + return BorderSide( + color: state.contains(WidgetState.focused) + ? Colors.orange + : Colors.brown, + width: 3, + ); + }), + ), + ), + ), + child: FilledButton( + focusNode: widget.focusNode, + onPressed: () { + widget.onActivate(); + }, + child: switch (widget.value) { + _CellValue.empty => Container(), + _CellValue.x => const Icon(Icons.close), + _CellValue.o => const Icon(Icons.circle_outlined), + }, + ), + ); + } +} + +const _cellSize = 70.0; diff --git a/packages/flutter_gamepads/example/lib/flutter_example/pages/home_page.dart b/packages/flutter_gamepads/example/lib/flutter_example/pages/home_page.dart new file mode 100644 index 00000000..e3abded0 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flutter_example/pages/home_page.dart @@ -0,0 +1,185 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads/flutter_gamepads.dart'; +import 'package:gamepads/gamepads.dart'; + +class HomePage extends StatelessWidget { + final void Function()? exitApp; + const HomePage({this.exitApp, super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + drawer: Drawer( + backgroundColor: Colors.indigo[200], + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: AlignmentGeometry.centerRight, + child: FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: const Icon(Icons.chevron_left, semanticLabel: 'Close'), + ), + ), + if (exitApp != null) ...[ + const SizedBox(height: 50), + FilledButton( + onPressed: exitApp, + child: const Text('Exit Flutter Example'), + ), + ], + ], + ), + ), + ), + appBar: AppBar( + title: const Text('Flutter Example'), + ), + body: ListView( + padding: const EdgeInsets.all(20), + children: [ + const Card( + child: Padding( + padding: EdgeInsets.all(20), + child: Text('A short sample app for flutter gamepads testing'), + ), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: () => onShowGame(context), + child: const Text('Play game'), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: () => onGotoSettings(context), + child: const Text('Settings'), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: () => onShowDialog(context), + child: const Text('Show dialog'), + ), + const SizedBox(height: 20), + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Controls', + style: Theme.of(context).textTheme.titleLarge, + ), + Table( + columnWidths: const { + 0: IntrinsicColumnWidth(), + }, + children: [ + ...gamepadInfo(), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } + + List gamepadInfo() { + final shortcuts = GamepadControl(child: Container()).shortcuts; + return shortcuts.keys.map((key) { + var activator = ''; + if (key is GamepadActivatorButton) { + activator += 'Button ${key.button.name}'; + } else if (key is GamepadActivatorAxis) { + activator += 'Axis ${key.axis.name}'; + } + + final intent = shortcuts[key]; + final intentText = intent.toString().split('Intent')[0]; + + return TableRow( + children: [ + Text(activator), + Padding( + padding: const EdgeInsets.only(left: 30), + child: Text('→ $intentText'), + ), + ], + ); + }).toList(); + } + + void onShowGame(BuildContext context) { + Navigator.of(context).pushNamed('/game'); + } + + void onShowDialog(BuildContext context) { + final controller = ScrollController(); + showDialog( + context: context, + builder: (context) => GamepadInterceptor( + onBeforeIntent: (activator, intent) { + // The ListView just contains text and never therefore receives focus. + // Using GamepadInterceptor we can still support scrolling this + // ListView. + if (intent is ScrollIntent) { + if (intent.direction == AxisDirection.up) { + controller.jumpTo(max(0, controller.offset - 75)); + } else if (intent.direction == AxisDirection.down) { + controller.jumpTo( + min( + controller.position.maxScrollExtent, + controller.offset + 75.0, + ), + ); + } + return false; + } + return true; + }, + child: AlertDialog( + title: const Text('List of gamepad buttons'), + content: SizedBox( + height: 200, + width: 200, + child: ListView( + controller: controller, + children: [ + const Text( + 'You can use right stick on gamepad to scroll this view.' + ' It is supported via GamepadInterceptor.', + style: TextStyle(fontStyle: FontStyle.italic), + ), + const SizedBox(height: 20), + ...GamepadButton.values.map((b) => Text(b.name)), + ], + ), + ), + actions: [ + FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ), + ); + } + + void onShowSnackbar(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Primary action triggered')), + ); + } + + void onGotoSettings(BuildContext context) { + Navigator.of(context).pushNamed('/settings'); + } +} diff --git a/packages/flutter_gamepads/example/lib/flutter_example/pages/settings_page.dart b/packages/flutter_gamepads/example/lib/flutter_example/pages/settings_page.dart new file mode 100644 index 00000000..458294f6 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flutter_example/pages/settings_page.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads_example/flutter_example/pages/slider_with_gamepad_support.dart'; + +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key}); + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + final TextEditingController nameController = TextEditingController( + text: 'Player One', + ); + double volume = 50; + String selectedGenre = 'Adventure'; + bool vibrationEnabled = true; + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: ListView( + padding: const EdgeInsets.all(20), + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: 'Player name', + prefixIcon: Icon(Icons.person_outline), + ), + ), + const Text( + 'It is not possible to enter text with Gamepad', + style: TextStyle(fontStyle: FontStyle.italic), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + // This one Wraps a Slider with GamepadInterceptor to add gamepad + // support to the default Slider widget + SliderWithGamepadSupport( + value: volume, + max: 100, + divisions: 5, + label: 'Volume: ${volume.round()}', + onChanged: (value) { + setState(() { + volume = value; + }); + }, + ), + const Text( + 'The slider can be changed (while it is focused) using right stick ' + 'on your gamepad. Supported via GamepadInterceptor.', + style: TextStyle(fontStyle: FontStyle.italic), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + ListTile( + title: const Text('Genre'), + trailing: SizedBox( + width: 150, + child: DropdownButtonFormField( + initialValue: selectedGenre, + items: + [ + 'Adventure', + 'Simulation', + 'RPG', + ] + .map( + (s) => DropdownMenuItem( + value: s, + child: Text(s), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + setState(() => selectedGenre = value); + } + }, + ), + ), + ), + const SizedBox(height: 20), + SwitchListTile( + title: const Text('Vibration'), + value: vibrationEnabled, + onChanged: (value) => setState(() => vibrationEnabled = value), + ), + ], + ), + ); + } +} diff --git a/packages/flutter_gamepads/example/lib/flutter_example/pages/slider_with_gamepad_support.dart b/packages/flutter_gamepads/example/lib/flutter_example/pages/slider_with_gamepad_support.dart new file mode 100644 index 00000000..81fcc7cc --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flutter_example/pages/slider_with_gamepad_support.dart @@ -0,0 +1,54 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads/flutter_gamepads.dart'; + +class SliderWithGamepadSupport extends StatelessWidget { + final int divisions; + final double value; + final String? label; + final double min; + final double max; + final void Function(double) onChanged; + + const SliderWithGamepadSupport({ + required this.onChanged, + required this.value, + required this.divisions, + this.min = 0, + this.max = 1.0, + this.label, + super.key, + }); + + double get step => (max - min) / math.max(divisions, 1); + + @override + Widget build(BuildContext context) { + return GamepadInterceptor( + onBeforeIntent: (activator, intent) { + // The Slider widget does not itself support any public Intent to + // control it. + // + // So instead we intercept that GamepadControl is about to emit a + // ScrollIntent and implement changing the Slider value ourself. + if (intent is ScrollIntent) { + if (intent.direction == AxisDirection.right) { + onChanged(math.min(max, value + step)); + } else if (intent.direction == AxisDirection.left) { + onChanged(math.max(min, value - step)); + } + return false; + } + return true; + }, + child: Slider.adaptive( + max: max, + divisions: divisions, + label: label, + value: value, + onChanged: onChanged, + ), + ); + } +} diff --git a/packages/flutter_gamepads/example/lib/flutter_example/theme.dart b/packages/flutter_gamepads/example/lib/flutter_example/theme.dart new file mode 100644 index 00000000..8a98d345 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/flutter_example/theme.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +ThemeData appTheme() { + final theme = ThemeData.from( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), + ); + const focusColor = Colors.orange; + // A base border with highlight color for focused state. + final focusBorder = WidgetStateBorderSide.resolveWith((state) { + if (state.contains(WidgetState.focused)) { + return const BorderSide(color: focusColor, width: 3); + } + return const BorderSide(color: Colors.transparent, width: 3); + }); + // Apply the border style to different types of buttons used in the example + // app + return theme.copyWith( + focusColor: focusColor, + elevatedButtonTheme: ElevatedButtonThemeData( + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll(Colors.indigo[500]), + foregroundColor: const WidgetStatePropertyAll(Colors.white), + side: focusBorder, + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll(Colors.indigo[500]), + foregroundColor: const WidgetStatePropertyAll(Colors.white), + side: focusBorder, + ), + ), + iconButtonTheme: IconButtonThemeData( + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll(Colors.indigo[100]), + foregroundColor: const WidgetStatePropertyAll(Colors.black), + side: focusBorder, + ), + ), + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder(), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: focusColor, + width: 3, + ), + ), + ), + cardTheme: CardThemeData( + color: Colors.grey[200], + ), + ); +} diff --git a/packages/flutter_gamepads/example/lib/main.dart b/packages/flutter_gamepads/example/lib/main.dart new file mode 100644 index 00000000..9fc01916 --- /dev/null +++ b/packages/flutter_gamepads/example/lib/main.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads/flutter_gamepads.dart'; +import 'package:flutter_gamepads_example/flame_example/main.dart'; +import 'package:flutter_gamepads_example/flutter_example/main.dart'; + +void main() { + runApp(const ChooserApp()); +} + +/// Description of example apps +enum Example { + flutter('Flutter Example', 'A pure Flutter example'), + flame('Flame Example', 'A Flame game with overlays'); + + const Example(this.label, this.description); + + final String label; + final String description; + + Widget imageBuilder(BuildContext context) { + return switch (this) { + Example.flame => Image.asset('assets/images/spaceship.png'), + Example.flutter => const FlutterLogo(size: 32), + }; + } + + Widget exampleAppBuilder(BuildContext context, void Function() exitApp) { + return switch (this) { + Example.flame => MyFlameApp(exitApp: exitApp), + Example.flutter => MyFlutterApp(exitApp: exitApp), + }; + } +} + +/// A chooser app that lets user select between two example apps: +/// * Flutter example +/// * Flame example +class ChooserApp extends StatefulWidget { + const ChooserApp({super.key}); + + @override + State createState() => _ChooserAppState(); +} + +class _ChooserAppState extends State { + Example? selectedExample; + + @override + Widget build(BuildContext context) { + return switch (selectedExample) { + (final Example example) => example.exampleAppBuilder(context, exitApp), + null => buildExampleSelectionUi(context), + }; + } + + Widget buildExampleSelectionUi(BuildContext context) { + // The GamepadControl widget provides user ability to control the UI + // with their gamepad. + // + // It has to sit here in buildEXampleSelectionUI so that it doesn't + // build when one of the example apps builds as they provide their + // own setup with a GamepadControl. + return GamepadControl( + child: MaterialApp( + theme: _theme, + home: Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Choose an Example app', + style: Theme.of( + context, + ).textTheme.titleLarge!.copyWith(color: Colors.white), + ), + ...Example.values.map( + (example) => buildExampleButton(context, example), + ), + ], + ), + ), + ), + ), + ); + } + + Widget buildExampleButton(BuildContext context, Example example) { + return Padding( + padding: const EdgeInsets.only(top: 20), + child: FilledButton( + onPressed: () { + setState(() => selectedExample = example); + }, + child: Column( + children: [ + example.imageBuilder(context), + Text( + example.label, + style: Theme.of(context).textTheme.titleMedium, + ), + Text(example.description), + ], + ), + ), + ); + } + + /// Exit selected example app + void exitApp() { + setState(() { + selectedExample = null; + }); + } +} + +ThemeData get _theme => + ThemeData.from( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.orange, + brightness: Brightness.dark, + ), + ).copyWith( + filledButtonTheme: FilledButtonThemeData( + style: ButtonStyle( + padding: const WidgetStatePropertyAll(EdgeInsets.all(30)), + // An expressive border for focused buttons makes it easier to + // use the chooser app with gamepad and keyboard input. + side: WidgetStateProperty.resolveWith((state) { + return state.contains(WidgetState.focused) + ? BorderSide( + color: Colors.deepOrange[800]!, + width: 5, + strokeAlign: 3, + ) + : BorderSide.none; + }), + ), + ), + ); diff --git a/packages/flutter_gamepads/example/pubspec.yaml b/packages/flutter_gamepads/example/pubspec.yaml new file mode 100644 index 00000000..87d9de3c --- /dev/null +++ b/packages/flutter_gamepads/example/pubspec.yaml @@ -0,0 +1,29 @@ +name: flutter_gamepads_example +description: A simple example project showcasing the flutter_gamepads plugin. +resolution: workspace +publish_to: 'none' + +version: 0.1.0 + +environment: + sdk: ">=3.9.0 <4.0.0" + +dependencies: + collection: ^1.19.1 + flame: ^1.32.0 + flutter: + sdk: flutter + flutter_gamepads: ^0.1.0 + gamepads: ^0.1.10 + +dev_dependencies: + flame_lint: ^1.4.1 + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true + + assets: + - assets/images/spaceship.png + - assets/images/power_up.png \ No newline at end of file diff --git a/packages/flutter_gamepads/lib/flutter_gamepads.dart b/packages/flutter_gamepads/lib/flutter_gamepads.dart new file mode 100644 index 00000000..e92644fe --- /dev/null +++ b/packages/flutter_gamepads/lib/flutter_gamepads.dart @@ -0,0 +1,3 @@ +export 'src/api/gamepad_activator.dart'; +export 'src/gamepad_control.dart'; +export 'src/gamepad_interceptor.dart'; diff --git a/packages/flutter_gamepads/lib/src/api/gamepad_activator.dart b/packages/flutter_gamepads/lib/src/api/gamepad_activator.dart new file mode 100644 index 00000000..e4ca3589 --- /dev/null +++ b/packages/flutter_gamepads/lib/src/api/gamepad_activator.dart @@ -0,0 +1,105 @@ +import 'package:flutter/foundation.dart'; +import 'package:gamepads/gamepads.dart'; + +@immutable +class GamepadActivator { + const GamepadActivator(); +} + +class GamepadActivatorButton extends GamepadActivator { + final GamepadButton button; + const GamepadActivatorButton({required this.button}); + + /// GamepadActivator for [GamepadButton.a] + const GamepadActivatorButton.a() : button = GamepadButton.a; + + /// GamepadActivator for [GamepadButton.b] + const GamepadActivatorButton.b() : button = GamepadButton.b; + + /// GamepadActivator for [GamepadButton.x] + const GamepadActivatorButton.x() : button = GamepadButton.x; + + /// GamepadActivator for [GamepadButton.y] + const GamepadActivatorButton.y() : button = GamepadButton.y; + + /// GamepadActivator for [GamepadButton.leftBumper] + const GamepadActivatorButton.leftBumper() : button = GamepadButton.leftBumper; + + /// GamepadActivator for [GamepadButton.rightBumper] + const GamepadActivatorButton.rightBumper() + : button = GamepadButton.rightBumper; + + /// GamepadActivator for [GamepadButton.leftTrigger] + const GamepadActivatorButton.leftTrigger() + : button = GamepadButton.leftTrigger; + + /// GamepadActivator for [GamepadButton.rightTrigger] + const GamepadActivatorButton.rightTrigger() + : button = GamepadButton.rightTrigger; + + /// GamepadActivator for [GamepadButton.back] + const GamepadActivatorButton.back() : button = GamepadButton.back; + + /// GamepadActivator for [GamepadButton.start] + const GamepadActivatorButton.start() : button = GamepadButton.start; + + /// GamepadActivator for [GamepadButton.home] + const GamepadActivatorButton.home() : button = GamepadButton.home; + + /// GamepadActivator for [GamepadButton.leftStick] + const GamepadActivatorButton.leftStick() : button = GamepadButton.leftStick; + + /// GamepadActivator for [GamepadButton.rightStick] + const GamepadActivatorButton.rightStick() : button = GamepadButton.rightStick; + + /// GamepadActivator for [GamepadButton.dpadUp] + const GamepadActivatorButton.dpadUp() : button = GamepadButton.dpadUp; + + /// GamepadActivator for [GamepadButton.dpadDown] + const GamepadActivatorButton.dpadDown() : button = GamepadButton.dpadDown; + + /// GamepadActivator for [GamepadButton.dpadLeft] + const GamepadActivatorButton.dpadLeft() : button = GamepadButton.dpadLeft; + + /// GamepadActivator for [GamepadButton.dpadRight] + const GamepadActivatorButton.dpadRight() : button = GamepadButton.dpadRight; +} + +class GamepadActivatorAxis extends GamepadActivator { + final GamepadAxis axis; + final double minThreshold; + const GamepadActivatorAxis({required this.axis, required this.minThreshold}); + + const GamepadActivatorAxis.leftStickUp() + : axis = GamepadAxis.leftStickY, + minThreshold = _threshold; + const GamepadActivatorAxis.leftStickDown() + : axis = GamepadAxis.leftStickY, + minThreshold = -_threshold; + const GamepadActivatorAxis.leftStickLeft() + : axis = GamepadAxis.leftStickX, + minThreshold = -_threshold; + const GamepadActivatorAxis.leftStickRight() + : axis = GamepadAxis.leftStickX, + minThreshold = _threshold; + const GamepadActivatorAxis.rightStickUp() + : axis = GamepadAxis.rightStickY, + minThreshold = _threshold; + const GamepadActivatorAxis.rightStickDown() + : axis = GamepadAxis.rightStickY, + minThreshold = -_threshold; + const GamepadActivatorAxis.rightStickLeft() + : axis = GamepadAxis.rightStickX, + minThreshold = -_threshold; + const GamepadActivatorAxis.rightStickRight() + : axis = GamepadAxis.rightStickX, + minThreshold = _threshold; + const GamepadActivatorAxis.leftTrigger() + : axis = GamepadAxis.leftTrigger, + minThreshold = _threshold; + const GamepadActivatorAxis.rightTrigger() + : axis = GamepadAxis.rightTrigger, + minThreshold = _threshold; +} + +const _threshold = 0.3; diff --git a/packages/flutter_gamepads/lib/src/gamepad_control.dart b/packages/flutter_gamepads/lib/src/gamepad_control.dart new file mode 100644 index 00000000..03d621a8 --- /dev/null +++ b/packages/flutter_gamepads/lib/src/gamepad_control.dart @@ -0,0 +1,272 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads/flutter_gamepads.dart'; +import 'package:gamepads/gamepads.dart'; + +/// Wrap your widget tree with this widget to allow users +/// to navigate it using their gamepad. +/// +/// Note: If you have more than one concurrent GamepadControl +/// in your widget tree, you need to set ignoreEvents=true on +/// at least n-1 of your GamepadControl widgets to avoid duplicate +/// intents being fired. +/// +/// Make sure your theme is setup so that widgets get a clearly +/// visible visual indicator when they are focused. +class GamepadControl extends StatefulWidget { + final Widget child; + final bool ignoreEvents; + final bool Function(GamepadActivator, Intent)? onBeforeIntent; + + final Map shortcuts; + + final Set repeatIntents; + final Duration initialRepeatDelay; + final Duration repeatedRepeatDelay; + + const GamepadControl({ + required this.child, + + /// Called just before an Intent is invoked. Return false to block + /// emitting the Intent. + /// + /// This includes both initial activation of a button or axis, but + /// also repetition due to holding long enough for input repeat. + /// + /// Additionally, you can wrap something deep in your tree with + /// [GamepadInterceptor] widget to receive onBeforeIntent locally + /// in that build context. + this.onBeforeIntent, + + /// If set to true, the gamepad control is temporarily disabled. All + /// input repeats gets canceled and to activate axis inputs users + /// has to go from non-active to active axis value again (after + /// ignoreEvents has become true again). + this.ignoreEvents = false, + + /// Configures the bindings between Gamepad activator (button or axis) + /// and intents to invoke. + /// + /// References of available intents can be found by looking up + /// [WidgetsApp.defaultShortcuts], which contains the default keyboard + /// shortcuts used in apps. + this.shortcuts = const { + GamepadActivatorButton.a(): ActivateIntent(), + GamepadActivatorButton.b(): DismissIntent(), + GamepadActivatorButton.dpadUp(): PreviousFocusIntent(), + GamepadActivatorButton.dpadLeft(): PreviousFocusIntent(), + GamepadActivatorButton.dpadDown(): NextFocusIntent(), + GamepadActivatorButton.dpadRight(): NextFocusIntent(), + GamepadActivatorAxis.rightStickUp(): ScrollIntent( + direction: AxisDirection.up, + ), + GamepadActivatorAxis.rightStickLeft(): ScrollIntent( + direction: AxisDirection.left, + ), + GamepadActivatorAxis.rightStickDown(): ScrollIntent( + direction: AxisDirection.down, + ), + GamepadActivatorAxis.rightStickRight(): ScrollIntent( + direction: AxisDirection.right, + ), + }, + + /// Set of intents which will be repeated if a GamepadActivator + /// linked to this Intent is being activated for + /// [initialRepeatDelay]. After that [repeatedRepeatDelay] sets + /// the delay between each repeat. + /// + /// Intents not listed here will not have input repetition on + /// hold. + this.repeatIntents = const { + PreviousFocusIntent(), + NextFocusIntent(), + ScrollIntent(direction: AxisDirection.up), + ScrollIntent(direction: AxisDirection.down), + ScrollIntent(direction: AxisDirection.left), + ScrollIntent(direction: AxisDirection.right), + }, + + /// Delay after first input until first input repeat occurs. + this.initialRepeatDelay = const Duration(milliseconds: 700), + + /// Delay after the first input repetition to the next reptilton and beyond. + this.repeatedRepeatDelay = const Duration(milliseconds: 200), + + super.key, + }); + + @override + State createState() => _GamepadControlState(); +} + +class _GamepadControlState extends State { + StreamSubscription? _subscription; + + final Map _previousAxisValue = {}; + final Map _repeat = {}; + + @override + void initState() { + super.initState(); + _subscription = Gamepads.normalizedEvents.listen(onGamepadEvent); + } + + @override + void dispose() { + _subscription?.cancel(); + _cancelAllRepeatTimers(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant GamepadControl oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.ignoreEvents) { + _previousAxisValue.clear(); + _cancelAllRepeatTimers(); + } + } + + @override + Widget build(BuildContext context) { + return widget.child; + } + + void onGamepadEvent(NormalizedGamepadEvent event) { + if (widget.ignoreEvents == true) { + return; + } + final intents = _find(event); + for (final (activator, intent, activated, canceled) in intents) { + if (canceled) { + _repeat[intent]?.cancel(); + _repeat.remove(intent); + } + if (activated) { + _maybeInvokeIntent( + activator, + intent, + const Duration(milliseconds: 700), + ); + } + } + _updatePreviousAxisValues(event); + } + + /// Find intents that match the given gamepad event. + /// + /// Return list of (Intent, activated, canceled) + List<(GamepadActivator, Intent, bool, bool)> _find( + NormalizedGamepadEvent event, + ) { + final result = <(GamepadActivator, Intent, bool, bool)>[]; + for (final entry in widget.shortcuts.entries) { + final activator = entry.key; + switch (activator) { + case final GamepadActivatorButton buttonActivator: + if (buttonActivator.button == event.button) { + result.add(( + activator, + entry.value, + event.value != 0, + event.value == 0, + )); + } + case final GamepadActivatorAxis axisActivator: + if (axisActivator.axis == event.axis) { + final activatorSign = axisActivator.minThreshold > 0; + final inputSign = event.value > 0; + // Axis is activated when moving to from below Threshold to + // above it. + final axisActivated = + activatorSign == inputSign && + event.value.abs() > axisActivator.minThreshold.abs() && + (!_previousAxisValue.containsKey(axisActivator.axis) || + _previousAxisValue[axisActivator.axis]!.abs() <= + axisActivator.minThreshold.abs()); + // Cancel is easier as duplicate cancels is not an issue. + final axisCanceled = axisActivator.minThreshold > 0 + ? event.value <= axisActivator.minThreshold + : event.value >= axisActivator.minThreshold; + result.add((activator, entry.value, axisActivated, axisCanceled)); + } + } + } + return result; + } + + /// Invoke [intent] on target context if it is not being blocked by + /// onBeforeInvoke or CallbackInterceptor.onBeforeInvoke. Also + /// schedule a repeat after [repeatDuration]. + void _maybeInvokeIntent( + GamepadActivator activator, + Intent intent, + Duration repeatDuration, + ) { + final activateContext = _resolveInvokeContext(intent); + if (activateContext != null) { + // Activate the timer before calling _allowInvoke so that interceptors + // receive repeated input. + if (widget.repeatIntents.contains(intent)) { + _repeat[intent] = Timer( + repeatDuration, + () => _onRepeat(activator, intent), + ); + } + final allowInvoke = _allowInvoke(activateContext, activator, intent); + if (allowInvoke) { + Actions.maybeInvoke(activateContext, intent); + } + } + } + + /// Resolve target context for given [intent] + BuildContext? _resolveInvokeContext(Intent intent) { + // Same lookup as [ShortcutManager.handleKeypress] + final focusedContext = primaryFocus?.context; + // Allow previous/next to use parent context when focusContext is null + // to allow user to focus something even when there is no autofocus. + return focusedContext ?? + ((intent is PreviousFocusIntent || intent is NextFocusIntent) + ? context + : null); + } + + /// Check if invoking [intent] is permitted by + /// CallbackInterceptor.onBeforeInvoke and onBeforeInvoke. + bool _allowInvoke( + BuildContext activateContext, + GamepadActivator activator, + Intent intent, + ) { + final interceptor = activateContext + .findAncestorWidgetOfExactType(); + var allow = true; + if (interceptor != null) { + allow = interceptor.onBeforeIntent(activator, intent); + } + if (allow && widget.onBeforeIntent != null) { + allow = widget.onBeforeIntent!(activator, intent); + } + return allow; + } + + void _onRepeat(GamepadActivator activator, Intent intent) { + _maybeInvokeIntent(activator, intent, const Duration(milliseconds: 200)); + } + + void _updatePreviousAxisValues(NormalizedGamepadEvent event) { + if (event.axis != null) { + _previousAxisValue[event.axis!] = event.value; + } + } + + void _cancelAllRepeatTimers() { + _repeat.values.forEach((timer) { + timer.cancel(); + }); + _repeat.clear(); + } +} diff --git a/packages/flutter_gamepads/lib/src/gamepad_interceptor.dart b/packages/flutter_gamepads/lib/src/gamepad_interceptor.dart new file mode 100644 index 00000000..c8b8886c --- /dev/null +++ b/packages/flutter_gamepads/lib/src/gamepad_interceptor.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gamepads/src/api/gamepad_activator.dart'; + +/// Wrap part of your widget tree with this widget to be able to +/// receive onBeforeIntent locally near the widget you want to control. +/// +/// This has to wrap the widget that holds the FocusNode. Eg. to wrap +/// a Slider() or other control you want to setup gamepad support for. +class GamepadInterceptor extends StatelessWidget { + final Widget child; + final bool Function(GamepadActivator activator, Intent intent) onBeforeIntent; + + const GamepadInterceptor({ + /// Called just before an Intent is invoked. Return false to block + /// emitting the Intent. + required this.onBeforeIntent, + + required this.child, + super.key, + }); + + @override + Widget build(BuildContext context) { + return child; + } +} diff --git a/packages/flutter_gamepads/pubspec.yaml b/packages/flutter_gamepads/pubspec.yaml new file mode 100644 index 00000000..e5be033b --- /dev/null +++ b/packages/flutter_gamepads/pubspec.yaml @@ -0,0 +1,23 @@ +name: flutter_gamepads +resolution: workspace +description: A Flutter package that maps gamepad input to UI interaction. +version: 0.1.10 +homepage: https://github.com/flame-engine/gamepads +repository: https://github.com/flame-engine/gamepads/tree/main/packages/flutter_gamepads + +flutter: + +environment: + sdk: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" + +dependencies: + flutter: + sdk: flutter + gamepads: ^0.1.10 + +dev_dependencies: + flame_lint: ^1.4.1 + flutter_test: + sdk: flutter + gamepads_platform_interface: ^0.1.3 diff --git a/packages/flutter_gamepads/test/flutter_gamepads_test.dart b/packages/flutter_gamepads/test/flutter_gamepads_test.dart new file mode 100644 index 00000000..ae5b2c49 --- /dev/null +++ b/packages/flutter_gamepads/test/flutter_gamepads_test.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gamepads/src/gamepad_control.dart'; +import 'package:flutter_gamepads/src/gamepad_interceptor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:gamepads/gamepads.dart'; + +import 'package:gamepads_platform_interface/gamepads_platform_interface.dart'; +import 'package:gamepads_platform_interface/method_channel_gamepads_platform_interface.dart'; + +final platformInterface = + GamepadsPlatformInterface.instance + as MethodChannelGamepadsPlatformInterface; + +enum _UiButton { noButton, first, second } + +void main() { + Gamepads.normalizer = GamepadNormalizer.forPlatform( + GamepadPlatform.windows, + ); + + testWidgets('GamepadControl', (WidgetTester tester) async { + var lastButtonPressed = _UiButton.noButton; + var beforeInvokeActivate = false; + var beforeInvokeDismiss = false; + final aFocusNode = FocusNode(); + final bFocusNode = FocusNode(); + final ignoreEvents = ValueNotifier(false); + var emitIntents = true; + + final widget = MaterialApp( + home: ValueListenableBuilder( + valueListenable: ignoreEvents, + builder: (context, value, child) { + return GamepadControl( + onBeforeIntent: (activator, intent) { + if (intent is ActivateIntent) { + beforeInvokeActivate = true; + } else if (intent is DismissIntent) { + beforeInvokeDismiss = true; + } + return emitIntents; + }, + ignoreEvents: ignoreEvents.value, + child: Column( + children: [ + ElevatedButton( + focusNode: aFocusNode, + onPressed: () => lastButtonPressed = _UiButton.first, + child: const Text('First'), + ), + ElevatedButton( + focusNode: bFocusNode, + onPressed: () => lastButtonPressed = _UiButton.second, + child: const Text('Second'), + ), + ], + ), + ); + }, + ), + ); + + await tester.pumpWidget(widget); + await _keyPress('dpadDown'); + await tester.pumpAndSettle(); + + // First UI button has focus + expect(aFocusNode.hasFocus, isTrue); + expect(lastButtonPressed, equals(_UiButton.noButton)); + expect(beforeInvokeActivate, isFalse); + + // Emit 'a' gamepad button => should press the first UI button + await _keyPress('a'); + await tester.pumpAndSettle(); + expect(aFocusNode.hasFocus, isTrue); + expect(lastButtonPressed, equals(_UiButton.first)); + expect(beforeInvokeActivate, isTrue); + expect(beforeInvokeDismiss, isFalse); + + // Emit 'dpadRight' gamepad button => should focus second UI button + beforeInvokeActivate = false; + lastButtonPressed = _UiButton.noButton; + await _keyPress('dpadRight'); + await tester.pumpAndSettle(); + expect(bFocusNode.hasFocus, isTrue); + expect(lastButtonPressed, equals(_UiButton.noButton)); + expect(beforeInvokeActivate, isFalse); + expect(beforeInvokeDismiss, isFalse); + + // Emit 'a' gamepad button => should press the second UI button + await _keyPress('a'); + await tester.pumpAndSettle(); + expect(bFocusNode.hasFocus, isTrue); + expect(lastButtonPressed, equals(_UiButton.second)); + expect(beforeInvokeActivate, isTrue); + expect(beforeInvokeDismiss, isFalse); + + // Emit 'b' gamepad button => should call onBeforeInvoke with dismiss intent + beforeInvokeActivate = false; + lastButtonPressed = _UiButton.noButton; + await _keyPress('b'); + await tester.pumpAndSettle(); + expect(lastButtonPressed, equals(_UiButton.noButton)); + expect(beforeInvokeActivate, isFalse); + expect(beforeInvokeDismiss, isTrue); + + // allow events, but returning false from onBeforeIntent should block intent + emitIntents = false; + beforeInvokeActivate = false; + beforeInvokeDismiss = false; + lastButtonPressed = _UiButton.noButton; + await _keyPress('a'); + await tester.pumpAndSettle(); + expect(lastButtonPressed, equals(_UiButton.noButton)); + expect(beforeInvokeActivate, isTrue); + expect(beforeInvokeDismiss, isFalse); + + // ignore events => should not even call onBeforeIntent or invoke any thing + ignoreEvents.value = true; + emitIntents = true; + await tester.pumpAndSettle(); + beforeInvokeActivate = false; + beforeInvokeDismiss = false; + lastButtonPressed = _UiButton.noButton; + await _keyPress('a'); + await tester.pumpAndSettle(); + expect(lastButtonPressed, equals(_UiButton.noButton)); + expect(beforeInvokeActivate, isFalse); + expect(beforeInvokeDismiss, isFalse); + }); + + testWidgets('GamepadInterceptor', (WidgetTester tester) async { + var rootBeforeIntentCalled = false; + var rootEmit = true; + var interceptorBeforeIntentCalled = false; + var interceptorEmit = true; + final secondFocusNode = FocusNode(); + var secondPressed = false; + final widget = MaterialApp( + home: GamepadControl( + onBeforeIntent: (activator, intent) { + rootBeforeIntentCalled = true; + return rootEmit; + }, + child: Column( + children: [ + ElevatedButton( + onPressed: () => {}, + child: const Text('Button'), + ), + GamepadInterceptor( + onBeforeIntent: (activator, intent) { + interceptorBeforeIntentCalled = true; + return interceptorEmit; + }, + child: ElevatedButton( + focusNode: secondFocusNode, + onPressed: () { + secondPressed = true; + }, + child: const Text('Second'), + ), + ), + ], + ), + ), + ); + + await tester.pumpWidget(widget); + await _keyPress('dpadDown'); + await _keyPress('dpadDown'); + await tester.pumpAndSettle(); + + expect(secondFocusNode.hasFocus, isTrue); + expect(secondPressed, isFalse); + + // Emit gamepad event => should call both onBeforeIntent + rootBeforeIntentCalled = false; + interceptorBeforeIntentCalled = false; + await _keyPress('a'); + await tester.pumpAndSettle(); + expect(rootBeforeIntentCalled, isTrue); + expect(interceptorBeforeIntentCalled, isTrue); + expect(secondPressed, isTrue); + + // Return false only from the root onBeforeIntent + rootEmit = false; + rootBeforeIntentCalled = false; + interceptorBeforeIntentCalled = false; + secondPressed = false; + await _keyPress('a'); + await tester.pumpAndSettle(); + expect(rootBeforeIntentCalled, isTrue); + expect(interceptorBeforeIntentCalled, isTrue); + expect(secondPressed, isFalse); + + // Return false only from the interceptor onBeforeIntent + rootEmit = true; + interceptorEmit = false; + rootBeforeIntentCalled = false; + interceptorBeforeIntentCalled = false; + await _keyPress('a'); + await tester.pumpAndSettle(); + expect(rootBeforeIntentCalled, isFalse); + expect(interceptorBeforeIntentCalled, isTrue); + expect(secondPressed, isFalse); + }); +} + +Future _keyPress(String key) async { + await _event(key, 1.0); + await _event(key, 0.0); +} + +Future _event(String key, double value) async { + final millis = DateTime.now().millisecondsSinceEpoch; + await platformInterface.platformCallHandler( + MethodCall('onGamepadEvent', { + 'gamepadId': '1', + 'time': millis, + 'type': 'button', + 'key': key, + 'value': value, + }), + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index 28a57827..8dee05e9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,8 @@ name: gamepads_workspace repository: https://github.com/flame-engine/gamepads workspace: + - packages/flutter_gamepads + - packages/flutter_gamepads/example - packages/gamepads - packages/gamepads/example - packages/gamepads_android
true
false
Activated
Canceled
Neither
Return false
Return true
Normalized Gamepad Input (gamepads package)
ignoreEvents
STOP
Find matching GamepadActivator
Activator event
Start repeat timer for intent
Stop repeat timer of intent
Repeat timer event
Find nearest GamepadInterceptor ancestor from primaryFocus
GamepadInterceptor.onBeforeIntent()?
GamepadControl.onBeforeIntent()?
Emit Intent to primaryFocus
Flutter Actions system handles it