diff --git a/doc/flame/inputs/other_inputs.md b/doc/flame/inputs/other_inputs.md index 982e1104f6c..8f2427189cb 100644 --- a/doc/flame/inputs/other_inputs.md +++ b/doc/flame/inputs/other_inputs.md @@ -142,3 +142,33 @@ else which isn't a pure sprite. Flame has a separate plugin to support external game controllers (gamepads), check [here](https://github.com/flame-engine/flame_gamepad) for more information. + + +## AdvancedButtonComponent + +The `AdvancedButtonComponent` have separate states for each of the different pointer phases. +The skin can be customized for each state and each skin is represented by a `PositionComponent`. + +These are the fields that can be used to customize the looks of the `AdvancedButtonComponent`: + +- `defaultSkin`: Component that will be displayed by default on the button. +- `downSkin`: Component displayed when the button is clicked or tapped. +- `hoverSkin`: Component displayed when the button is hovered. (desktop and web). +- `defaultLabel`: Component shown on top of skins. Automatically aligned to center. +- `disabledSkin`: Component displayed when button is disabled. +- `disabledLabel`: Component shown on top of skins when button is disabled. + + +## ToggleButtonComponent + +The [ToggleButtonComponent] is an [AdvancedButtonComponent] that can switch between selected +and not selected. + +In addition to the already existing skins, the [ToggleButtonComponent] contains the following skins: + +- `defaultSelectedSkin`: The component to display when the button is selected. +- `downAndSelectedSkin`: The component that is displayed when the selectable button is selected and + pressed. +- `hoverAndSelectedSkin`: Hover on selectable and selected button (desktop and web). +- `disabledAndSelectedSkin`: For when the button is selected and in the disabled state. +- `defaultSelectedLabel`: Component shown on top of the skins when button is selected. diff --git a/examples/lib/stories/input/advanced_button_example.dart b/examples/lib/stories/input/advanced_button_example.dart new file mode 100644 index 00000000000..e74b0fbc700 --- /dev/null +++ b/examples/lib/stories/input/advanced_button_example.dart @@ -0,0 +1,125 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame/palette.dart'; +import 'package:flutter/painting.dart'; + +class AdvancedButtonExample extends FlameGame { + static const String description = + '''This example shows how you can use a button with different states'''; + + @override + Future onLoad() async { + final defaultButton = DefaultButton(); + defaultButton.position = Vector2(50, 50); + defaultButton.size = Vector2(250, 50); + add(defaultButton); + + final disableButton = DisableButton(); + disableButton.isDisabled = true; + disableButton.position = Vector2(400, 50); + disableButton.size = defaultButton.size; + add(disableButton); + + final toggleButton = ToggleButton(); + toggleButton.position = Vector2(50, 150); + toggleButton.size = defaultButton.size; + add(toggleButton); + } +} + +class ToggleButton extends ToggleButtonComponent { + @override + Future onLoad() async { + super.onLoad(); + + defaultLabel = TextComponent( + text: 'Toggle button', + textRenderer: TextPaint( + style: TextStyle( + fontSize: 24, + color: BasicPalette.white.color, + ), + ), + ); + + defaultSelectedLabel = TextComponent( + text: 'Toggle button', + textRenderer: TextPaint( + style: TextStyle( + fontSize: 24, + color: BasicPalette.red.color, + ), + ), + ); + + defaultSkin = RoundedRectComponent() + ..setColor(const Color.fromRGBO(0, 200, 0, 1)); + + hoverSkin = RoundedRectComponent() + ..setColor(const Color.fromRGBO(0, 180, 0, 1)); + + downSkin = RoundedRectComponent() + ..setColor(const Color.fromRGBO(0, 100, 0, 1)); + + defaultSelectedSkin = RoundedRectComponent() + ..setColor(const Color.fromRGBO(0, 0, 200, 1)); + + hoverAndSelectedSkin = RoundedRectComponent() + ..setColor(const Color.fromRGBO(0, 0, 180, 1)); + + downAndSelectedSkin = RoundedRectComponent() + ..setColor(const Color.fromRGBO(0, 0, 100, 1)); + } +} + +class DefaultButton extends AdvancedButtonComponent { + @override + Future onLoad() async { + super.onLoad(); + + defaultLabel = TextComponent(text: 'Default button'); + + defaultSkin = RoundedRectComponent() + ..setColor(const Color.fromRGBO(0, 200, 0, 1)); + + hoverSkin = RoundedRectComponent() + ..setColor(const Color.fromRGBO(0, 180, 0, 1)); + + downSkin = RoundedRectComponent() + ..setColor(const Color.fromRGBO(0, 100, 0, 1)); + } +} + +class DisableButton extends AdvancedButtonComponent { + @override + Future onLoad() async { + super.onLoad(); + + disabledLabel = TextComponent(text: 'Disabled button'); + + defaultSkin = RoundedRectComponent() + ..setColor(const Color.fromRGBO(0, 255, 0, 1)); + + disabledSkin = RoundedRectComponent() + ..setColor(const Color.fromRGBO(100, 100, 100, 1)); + } +} + +class RoundedRectComponent extends PositionComponent with HasPaint { + @override + void render(Canvas canvas) { + canvas.drawRRect( + RRect.fromLTRBAndCorners( + 0, + 0, + width, + height, + topLeft: Radius.circular(height), + topRight: Radius.circular(height), + bottomRight: Radius.circular(height), + bottomLeft: Radius.circular(height), + ), + paint, + ); + } +} diff --git a/examples/lib/stories/input/input.dart b/examples/lib/stories/input/input.dart index a16d95af7fc..d977a260f48 100644 --- a/examples/lib/stories/input/input.dart +++ b/examples/lib/stories/input/input.dart @@ -1,5 +1,6 @@ import 'package:dashbook/dashbook.dart'; import 'package:examples/commons/commons.dart'; +import 'package:examples/stories/input/advanced_button_example.dart'; import 'package:examples/stories/input/double_tap_callbacks_example.dart'; import 'package:examples/stories/input/draggables_example.dart'; import 'package:examples/stories/input/gesture_hitboxes_example.dart'; @@ -129,5 +130,11 @@ void addInputStories(Dashbook dashbook) { (_) => GameWidget(game: JoystickAdvancedExample()), codeLink: baseLink('input/joystick_advanced_example.dart'), info: JoystickAdvancedExample.description, + ) + ..add( + 'Advanced Button', + (_) => GameWidget(game: AdvancedButtonExample()), + codeLink: baseLink('input/advanced_button_example.dart'), + info: AdvancedButtonExample.description, ); } diff --git a/packages/flame/lib/components.dart b/packages/flame/lib/components.dart index dfd4834b991..a0352d8f7ae 100644 --- a/packages/flame/lib/components.dart +++ b/packages/flame/lib/components.dart @@ -13,8 +13,10 @@ export 'src/components/core/position_type.dart'; export 'src/components/custom_painter_component.dart'; export 'src/components/fps_component.dart'; export 'src/components/fps_text_component.dart'; +export 'src/components/input/advanced_button_component.dart'; export 'src/components/input/joystick_component.dart'; export 'src/components/input/keyboard_listener_component.dart'; +export 'src/components/input/toggle_button_component.dart'; export 'src/components/isometric_tile_map_component.dart'; export 'src/components/mixins/component_viewport_margin.dart'; export 'src/components/mixins/coordinate_transform.dart'; diff --git a/packages/flame/lib/src/components/input/advanced_button_component.dart b/packages/flame/lib/src/components/input/advanced_button_component.dart new file mode 100644 index 00000000000..62816180444 --- /dev/null +++ b/packages/flame/lib/src/components/input/advanced_button_component.dart @@ -0,0 +1,292 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/layout.dart'; +import 'package:flutter/foundation.dart'; + +/// The [AdvancedButtonComponent] has different skins for +/// different button states. +/// The [defaultSkin] must be added to the constructor or +/// if you are inheriting - defined in the onLod method. +/// +/// The label is a [PositionComponent] and is added +/// to the foreground of the button. The label is automatically aligned to +/// the center of the button. +/// +/// Note: You have to set the skins that you want to use ([defaultSkin], +/// [downSkin], [hoverSkin], [disabledSkin], [defaultLabel]) in [onLoad] +/// if you are not passing them in through the constructor. +class AdvancedButtonComponent extends PositionComponent + with HoverCallbacks, TapCallbacks { + AdvancedButtonComponent({ + this.onPressed, + this.onChangeState, + PositionComponent? defaultSkin, + PositionComponent? downSkin, + PositionComponent? hoverSkin, + PositionComponent? disabledSkin, + PositionComponent? defaultLabel, + PositionComponent? disabledLabel, + super.size, + super.position, + super.scale, + super.angle, + super.anchor, + super.children, + super.priority, + }) { + this.defaultSkin = defaultSkin; + this.downSkin = downSkin; + this.hoverSkin = hoverSkin; + this.disabledSkin = disabledSkin; + this.defaultLabel = defaultLabel; + this.disabledLabel = disabledLabel; + size.addListener(_updateSizes); + } + + /// Callback for what should happen when the button is pressed. + void Function()? onPressed; + + /// Callback when button state changes + void Function(ButtonState state)? onChangeState; + + @mustCallSuper + @override + Future onLoad() async { + super.onLoad(); + add(skinContainer); + add(labelAlignContainer); + } + + @protected + final skinContainer = Component(); + + @protected + AlignComponent labelAlignContainer = AlignComponent(alignment: Anchor.center); + + @override + @mustCallSuper + void onMount() { + super.onMount(); + assert( + defaultSkin != null, + 'The defaultSkin has to either be passed ' + 'in as an argument or set in onLoad', + ); + if (_state.isDefault && !contains(defaultSkin!)) { + defaultSkin!.parent = skinContainer; + } + } + + @protected + bool isPressed = false; + + @override + @mustCallSuper + void onTapDown(TapDownEvent event) { + if (_isDisabled) { + return; + } + onPressed?.call(); + isPressed = true; + updateState(); + } + + @override + void onTapUp(TapUpEvent event) { + isPressed = false; + updateState(); + } + + @override + void onHoverEnter() { + updateState(); + } + + @override + void onHoverExit() { + isPressed = false; + updateState(); + } + + Map skinsMap = {}; + + PositionComponent? get defaultSkin => skinsMap[ButtonState.up]; + + set defaultSkin(PositionComponent? value) { + skinsMap[ButtonState.up] = value; + if (size.isZero()) { + size = skinsMap[ButtonState.up]?.size ?? Vector2.zero(); + } + invalidateSkins(); + } + + set downSkin(PositionComponent? value) { + skinsMap[ButtonState.down] = value; + invalidateSkins(); + } + + set hoverSkin(PositionComponent? value) { + skinsMap[ButtonState.hover] = value; + invalidateSkins(); + } + + set disabledSkin(PositionComponent? value) { + skinsMap[ButtonState.disabled] = value; + invalidateSkins(); + } + + Map labelsMap = {}; + + PositionComponent? get defaultLabel => labelsMap[ButtonState.up]; + + set defaultLabel(PositionComponent? value) { + labelsMap[ButtonState.up] = value; + updateLabel(); + } + + set disabledLabel(PositionComponent? value) { + labelsMap[ButtonState.disabled] = value; + updateLabel(); + } + + @protected + void invalidateSkins() { + _updateSizes(); + _updateSkin(); + } + + bool _isDisabled = false; + + bool get isDisabled => _isDisabled; + + set isDisabled(bool value) { + if (_isDisabled == value) { + return; + } + _isDisabled = value; + updateState(); + } + + void _updateSizes() { + for (final skin in skinsMap.values) { + skin?.size = size; + } + } + + @protected + void updateState() { + if (isDisabled) { + setState(ButtonState.disabled); + return; + } + if (isPressed) { + setState(ButtonState.down); + return; + } + if (isHovered) { + setState(ButtonState.hover); + return; + } + setState(ButtonState.up); + } + + ButtonState _state = ButtonState.up; + + @protected + void setState(ButtonState value) { + if (_state == value) { + return; + } + _state = value; + _updateSkin(); + updateLabel(); + onChangeState?.call(_state); + } + + void _updateSkin() { + _removeSkins(); + setSkin(_state); + } + + @protected + void setSkin(ButtonState state) { + (skinsMap[state] ?? defaultSkin)?.parent = skinContainer; + } + + void _removeSkins() { + for (final skins in skinsMap.values) { + skins?.parent = null; + } + } + + @protected + void updateLabel() { + _removeLabels(); + addLabel(_state); + } + + @protected + void addLabel(ButtonState state) { + labelAlignContainer.child = labelsMap[state] ?? defaultLabel; + } + + void _removeLabels() { + for (final label in labelsMap.values) { + label?.parent = null; + } + } + + @protected + bool hasSkinForState(ButtonState state) { + return skinsMap[state] != null; + } +} + +enum ButtonState { + up, + upAndSelected, + down, + downAndSelected, + hover, + hoverAndSelected, + disabled, + disabledAndSelected; + + const ButtonState(); + + bool get isDefault { + return this == ButtonState.up; + } + + bool get isDefaultSelected { + return this == ButtonState.upAndSelected; + } + + bool get isNotDefault { + return !isDefault; + } + + bool get isDown { + return this == ButtonState.down; + } + + bool get isDownAndSelected { + return this == ButtonState.downAndSelected; + } + + bool get isHover { + return this == ButtonState.hover; + } + + bool get isHoverAndSelected { + return this == ButtonState.hoverAndSelected; + } + + bool get isDisabled { + return this == ButtonState.disabled; + } + + bool get isDisabledAndSelected { + return this == ButtonState.disabledAndSelected; + } +} diff --git a/packages/flame/lib/src/components/input/toggle_button_component.dart b/packages/flame/lib/src/components/input/toggle_button_component.dart new file mode 100644 index 00000000000..3eceaaeeb5c --- /dev/null +++ b/packages/flame/lib/src/components/input/toggle_button_component.dart @@ -0,0 +1,167 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flutter/foundation.dart'; + +/// The [ToggleButtonComponent] is an [AdvancedButtonComponent] that can switch +/// between the selected and not selected state, imagine for example a switch +/// widget or a tab that can be selected. +/// +/// Note: You have to set the [defaultSkin], [defaultSelectedSkin] +/// and other skins that you want to use in [onLoad] if you are not passed in +/// through the constructor. +class ToggleButtonComponent extends AdvancedButtonComponent { + ToggleButtonComponent({ + super.onPressed, + this.onSelectedChanged, + super.onChangeState, + super.defaultSkin, + super.downSkin, + super.hoverSkin, + super.disabledSkin, + PositionComponent? defaultSelectedSkin, + PositionComponent? downAndSelectedSkin, + PositionComponent? hoverAndSelectedSkin, + PositionComponent? disabledAndSelectedSkin, + super.defaultLabel, + super.disabledLabel, + PositionComponent? defaultSelectedLabel, + PositionComponent? disabledAndSelectedLabel, + super.size, + super.position, + super.scale, + super.angle, + super.anchor, + super.children, + super.priority, + }) { + this.defaultSelectedSkin = defaultSelectedSkin; + this.downAndSelectedSkin = downAndSelectedSkin; + this.hoverAndSelectedSkin = hoverAndSelectedSkin; + this.disabledAndSelectedSkin = disabledAndSelectedSkin; + this.defaultSelectedLabel = defaultSelectedLabel; + this.disabledAndSelectedLabel = disabledAndSelectedLabel; + } + + /// Callback when button selected changed + ValueChanged? onSelectedChanged; + + @override + @mustCallSuper + void onMount() { + assert( + defaultSelectedSkin != null, + 'The defaultSelectedSkin has to either be passed ' + 'in as an argument or set in onLoad', + ); + super.onMount(); + } + + PositionComponent? get defaultSelectedSkin => + skinsMap[ButtonState.upAndSelected]; + + set defaultSelectedSkin(PositionComponent? value) { + skinsMap[ButtonState.upAndSelected] = value; + invalidateSkins(); + } + + set downAndSelectedSkin(PositionComponent? value) { + skinsMap[ButtonState.downAndSelected] = value; + invalidateSkins(); + } + + set hoverAndSelectedSkin(PositionComponent? value) { + skinsMap[ButtonState.hoverAndSelected] = value; + invalidateSkins(); + } + + set disabledAndSelectedSkin(PositionComponent? value) { + skinsMap[ButtonState.disabledAndSelected] = value; + invalidateSkins(); + } + + PositionComponent? get defaultSelectedLabel => + labelsMap[ButtonState.upAndSelected]; + + set defaultSelectedLabel(PositionComponent? value) { + labelsMap[ButtonState.upAndSelected] = value; + updateLabel(); + } + + set disabledAndSelectedLabel(PositionComponent? value) { + labelsMap[ButtonState.disabledAndSelected] = value; + updateLabel(); + } + + @override + void onTapUp(TapUpEvent event) { + isSelected = !_isSelected; + super.onTapUp(event); + } + + bool _isSelected = false; + + bool get isSelected => _isSelected; + + set isSelected(bool value) { + if (_isSelected == value) { + return; + } + _isSelected = value; + updateState(); + onSelectedChanged?.call(_isSelected); + } + + @override + @protected + void setSkin(ButtonState state) { + var skin = skinsMap[state]; + if (state.isDisabledAndSelected && !hasSkinForState(state)) { + skin = skinsMap[ButtonState.disabled]; + } + if (state.isDownAndSelected && !hasSkinForState(state)) { + skin = skinsMap[ButtonState.down]; + } + if (state.isHoverAndSelected && !hasSkinForState(state)) { + skin = skinsMap[ButtonState.hover]; + } + if (state.isDownAndSelected && !hasSkinForState(state)) { + skin = skinsMap[ButtonState.down]; + } + skin = skin ?? (isSelected ? defaultSelectedSkin : defaultSkin); + skin?.parent = skinContainer; + } + + @override + @protected + void addLabel(ButtonState state) { + labelAlignContainer.child = + labelsMap[state] ?? (isSelected ? defaultSelectedLabel : defaultLabel); + } + + @mustCallSuper + @protected + @override + void updateState() { + if (isDisabled) { + setState( + _isSelected ? ButtonState.disabledAndSelected : ButtonState.disabled, + ); + return; + } + if (isPressed) { + setState( + _isSelected ? ButtonState.downAndSelected : ButtonState.down, + ); + return; + } + if (isHovered) { + setState( + _isSelected ? ButtonState.hoverAndSelected : ButtonState.hover, + ); + return; + } + setState( + _isSelected ? ButtonState.upAndSelected : ButtonState.up, + ); + } +} diff --git a/packages/flame/test/_goldens/advanced_button_component.png b/packages/flame/test/_goldens/advanced_button_component.png new file mode 100644 index 00000000000..0f70cadec09 Binary files /dev/null and b/packages/flame/test/_goldens/advanced_button_component.png differ diff --git a/packages/flame/test/components/advanced_button_component_test.dart b/packages/flame/test/components/advanced_button_component_test.dart new file mode 100644 index 00000000000..d271ad313ee --- /dev/null +++ b/packages/flame/test/components/advanced_button_component_test.dart @@ -0,0 +1,221 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame/src/events/flame_game_mixins/multi_tap_dispatcher.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('AdvancedButtonComponent', () { + testGolden( + 'label renders correctly', + (game) async { + await game.add( + AdvancedButtonComponent( + defaultSkin: RectangleComponent(size: Vector2(40, 20)), + defaultLabel: RectangleComponent( + size: Vector2(10, 5), + paint: Paint()..color = const Color(0xFFFF0000), + ), + ), + ); + }, + size: Vector2(50, 30), + goldenFile: '../_goldens/advanced_button_component.png', + ); + + testWithFlameGame('correctly registers taps', (game) async { + var pressedTimes = 0; + final initialGameSize = Vector2.all(200); + final componentSize = Vector2.all(10); + final buttonPosition = Vector2.all(100); + late final AdvancedButtonComponent button; + game.onGameResize(initialGameSize); + await game.ensureAdd( + button = AdvancedButtonComponent( + defaultSkin: RectangleComponent(size: componentSize), + onPressed: () => pressedTimes++, + position: buttonPosition, + size: componentSize, + ), + ); + + expect(pressedTimes, 0); + final tapDispatcher = game.firstChild()!; + + tapDispatcher.handleTapDown(1, TapDownDetails()); + expect(pressedTimes, 0); + + tapDispatcher.handleTapUp( + 1, + createTapUpDetails( + globalPosition: button.positionOfAnchor(Anchor.center).toOffset(), + ), + ); + expect(pressedTimes, 0); + + tapDispatcher.handleTapDown( + 1, + TapDownDetails(globalPosition: buttonPosition.toOffset()), + ); + expect(pressedTimes, 1); + + tapDispatcher.handleTapUp( + 1, + createTapUpDetails(globalPosition: buttonPosition.toOffset()), + ); + expect(pressedTimes, 1); + + tapDispatcher.handleTapDown( + 1, + TapDownDetails(globalPosition: buttonPosition.toOffset()), + ); + tapDispatcher.handleTapCancel(1); + expect(pressedTimes, 2); + }); + + testWithFlameGame('correctly registers taps onGameResize', (game) async { + var pressedTimes = 0; + final initialGameSize = Vector2.all(100); + final componentSize = Vector2.all(10); + final buttonPosition = Vector2.all(100); + late final AdvancedButtonComponent button; + game.onGameResize(initialGameSize); + await game.ensureAdd( + button = AdvancedButtonComponent( + defaultSkin: RectangleComponent(size: componentSize), + onPressed: () => pressedTimes++, + position: buttonPosition, + size: componentSize, + ), + ); + final previousPosition = + button.positionOfAnchor(Anchor.center).toOffset(); + game.onGameResize(initialGameSize * 2); + final tapDispatcher = game.firstChild()!; + + tapDispatcher.handleTapDown( + 1, + TapDownDetails(globalPosition: previousPosition), + ); + expect(pressedTimes, 1); + + tapDispatcher.handleTapUp( + 1, + createTapUpDetails(globalPosition: previousPosition), + ); + expect(pressedTimes, 1); + + tapDispatcher.handleTapDown( + 1, + TapDownDetails(globalPosition: previousPosition), + ); + tapDispatcher.handleTapCancel(1); + expect(pressedTimes, 2); + }); + + testWithFlameGame('correctly work isDisabled', (game) async { + var pressedTimes = 0; + final initialGameSize = Vector2.all(100); + final componentSize = Vector2.all(10); + final buttonPosition = Vector2.all(100); + late final AdvancedButtonComponent button; + game.onGameResize(initialGameSize); + await game.ensureAdd( + button = AdvancedButtonComponent( + defaultSkin: RectangleComponent(size: componentSize), + onPressed: () => pressedTimes++, + position: buttonPosition, + size: componentSize, + ), + ); + button.isDisabled = true; + final previousPosition = + button.positionOfAnchor(Anchor.center).toOffset(); + game.onGameResize(initialGameSize * 2); + final tapDispatcher = game.firstChild()!; + + tapDispatcher.handleTapDown( + 1, + TapDownDetails(globalPosition: previousPosition), + ); + expect(pressedTimes, 0); + + tapDispatcher.handleTapUp( + 1, + createTapUpDetails(globalPosition: previousPosition), + ); + expect(pressedTimes, 0); + + tapDispatcher.handleTapDown( + 1, + TapDownDetails(globalPosition: previousPosition), + ); + tapDispatcher.handleTapCancel(1); + expect(pressedTimes, 0); + }); + + testWidgets( + '[#1723] can be pressed while the engine is paused', + (tester) async { + final game = FlameGame(); + game.add( + AdvancedButtonComponent( + defaultSkin: CircleComponent(radius: 40), + downSkin: CircleComponent(radius: 40), + position: Vector2(400, 300), + anchor: Anchor.center, + onPressed: () { + game.pauseEngine(); + game.overlays.add('pause-menu'); + }, + ), + ); + await tester.pumpWidget( + GameWidget( + game: game, + overlayBuilderMap: { + 'pause-menu': (context, _) { + return SimpleStatelessWidget( + build: (context) { + return Center( + child: OutlinedButton( + onPressed: () { + game.overlays.remove('pause-menu'); + game.resumeEngine(); + }, + child: const Text('Resume'), + ), + ); + }, + ); + }, + }, + ), + ); + await tester.pump(); + await tester.pump(); + + await tester.tapAt(const Offset(400, 300)); + await tester.pump(const Duration(seconds: 1)); + expect(game.paused, true); + + await tester.tapAt(const Offset(400, 300)); + await tester.pump(const Duration(seconds: 1)); + expect(game.paused, false); + }, + ); + }); +} + +class SimpleStatelessWidget extends StatelessWidget { + const SimpleStatelessWidget({ + required Widget Function(BuildContext) build, + super.key, + }) : _build = build; + + final Widget Function(BuildContext) _build; + + @override + Widget build(BuildContext context) => _build(context); +} diff --git a/packages/flame/test/components/toogle_button_component_test.dart b/packages/flame/test/components/toogle_button_component_test.dart new file mode 100644 index 00000000000..14c9d51b008 --- /dev/null +++ b/packages/flame/test/components/toogle_button_component_test.dart @@ -0,0 +1,254 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame/src/events/flame_game_mixins/multi_tap_dispatcher.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ToggleButtonComponent', () { + testWithFlameGame('correctly registers taps', (game) async { + var pressedTimes = 0; + final initialGameSize = Vector2.all(200); + final componentSize = Vector2.all(10); + final buttonPosition = Vector2.all(100); + late final ToggleButtonComponent button; + game.onGameResize(initialGameSize); + await game.ensureAdd( + button = ToggleButtonComponent( + defaultSkin: RectangleComponent(size: componentSize), + defaultSelectedSkin: RectangleComponent(size: componentSize), + onPressed: () => pressedTimes++, + position: buttonPosition, + size: componentSize, + ), + ); + + expect(pressedTimes, 0); + final tapDispatcher = game.firstChild()!; + + tapDispatcher.handleTapDown(1, TapDownDetails()); + expect(pressedTimes, 0); + + tapDispatcher.handleTapUp( + 1, + createTapUpDetails( + globalPosition: button.positionOfAnchor(Anchor.center).toOffset(), + ), + ); + expect(pressedTimes, 0); + + tapDispatcher.handleTapDown( + 1, + TapDownDetails(globalPosition: buttonPosition.toOffset()), + ); + expect(pressedTimes, 1); + + tapDispatcher.handleTapUp( + 1, + createTapUpDetails(globalPosition: buttonPosition.toOffset()), + ); + expect(pressedTimes, 1); + + tapDispatcher.handleTapDown( + 1, + TapDownDetails(globalPosition: buttonPosition.toOffset()), + ); + tapDispatcher.handleTapCancel(1); + expect(pressedTimes, 2); + }); + + testWithFlameGame('correctly registers taps onGameResize', (game) async { + var pressedTimes = 0; + final initialGameSize = Vector2.all(100); + final componentSize = Vector2.all(10); + final buttonPosition = Vector2.all(100); + late final ToggleButtonComponent button; + game.onGameResize(initialGameSize); + await game.ensureAdd( + button = ToggleButtonComponent( + defaultSkin: RectangleComponent(size: componentSize), + defaultSelectedSkin: RectangleComponent(size: componentSize), + onPressed: () => pressedTimes++, + position: buttonPosition, + size: componentSize, + ), + ); + final previousPosition = + button.positionOfAnchor(Anchor.center).toOffset(); + game.onGameResize(initialGameSize * 2); + final tapDispatcher = game.firstChild()!; + + tapDispatcher.handleTapDown( + 1, + TapDownDetails(globalPosition: previousPosition), + ); + expect(pressedTimes, 1); + + tapDispatcher.handleTapUp( + 1, + createTapUpDetails(globalPosition: previousPosition), + ); + expect(pressedTimes, 1); + + tapDispatcher.handleTapDown( + 1, + TapDownDetails(globalPosition: previousPosition), + ); + tapDispatcher.handleTapCancel(1); + expect(pressedTimes, 2); + }); + + testWithFlameGame('correctly work isDisabled', (game) async { + var pressedTimes = 0; + final initialGameSize = Vector2.all(100); + final componentSize = Vector2.all(10); + final buttonPosition = Vector2.all(100); + late final ToggleButtonComponent button; + game.onGameResize(initialGameSize); + await game.ensureAdd( + button = ToggleButtonComponent( + defaultSkin: RectangleComponent(size: componentSize), + defaultSelectedSkin: RectangleComponent(size: componentSize), + onPressed: () => pressedTimes++, + position: buttonPosition, + size: componentSize, + ), + ); + button.isDisabled = true; + final previousPosition = + button.positionOfAnchor(Anchor.center).toOffset(); + game.onGameResize(initialGameSize * 2); + final tapDispatcher = game.firstChild()!; + + tapDispatcher.handleTapDown( + 1, + TapDownDetails(globalPosition: previousPosition), + ); + expect(pressedTimes, 0); + + tapDispatcher.handleTapUp( + 1, + createTapUpDetails(globalPosition: previousPosition), + ); + expect(pressedTimes, 0); + + tapDispatcher.handleTapDown( + 1, + TapDownDetails(globalPosition: previousPosition), + ); + tapDispatcher.handleTapCancel(1); + expect(pressedTimes, 0); + }); + + testWithFlameGame('toggle works correctly', (game) async { + var pressedTimes = 0; + final initialGameSize = Vector2.all(100); + final componentSize = Vector2.all(10); + final buttonPosition = Vector2.all(100); + late final ToggleButtonComponent button; + game.onGameResize(initialGameSize); + await game.ensureAdd( + button = ToggleButtonComponent( + defaultSkin: RectangleComponent(size: componentSize), + defaultSelectedSkin: RectangleComponent(size: componentSize), + onPressed: () => pressedTimes++, + position: buttonPosition, + size: componentSize, + ), + ); + final previousPosition = + button.positionOfAnchor(Anchor.center).toOffset(); + game.onGameResize(initialGameSize * 2); + final tapDispatcher = game.firstChild()!; + + tapDispatcher.handleTapDown( + 1, + TapDownDetails(globalPosition: previousPosition), + ); + expect(button.isSelected, false); + + tapDispatcher.handleTapUp( + 1, + createTapUpDetails(globalPosition: previousPosition), + ); + expect(button.isSelected, true); + + tapDispatcher.handleTapDown( + 1, + TapDownDetails(globalPosition: previousPosition), + ); + expect(button.isSelected, true); + + tapDispatcher.handleTapUp( + 1, + createTapUpDetails(globalPosition: previousPosition), + ); + expect(button.isSelected, false); + }); + + testWidgets( + '[#1723] can be pressed while the engine is paused', + (tester) async { + final game = FlameGame(); + game.add( + ToggleButtonComponent( + defaultSkin: CircleComponent(radius: 40), + downSkin: CircleComponent(radius: 40), + defaultSelectedSkin: CircleComponent(radius: 40), + position: Vector2(400, 300), + anchor: Anchor.center, + onPressed: () { + game.pauseEngine(); + game.overlays.add('pause-menu'); + }, + ), + ); + await tester.pumpWidget( + GameWidget( + game: game, + overlayBuilderMap: { + 'pause-menu': (context, _) { + return SimpleStatelessWidget( + build: (context) { + return Center( + child: OutlinedButton( + onPressed: () { + game.overlays.remove('pause-menu'); + game.resumeEngine(); + }, + child: const Text('Resume'), + ), + ); + }, + ); + }, + }, + ), + ); + await tester.pump(); + await tester.pump(); + + await tester.tapAt(const Offset(400, 300)); + await tester.pump(const Duration(seconds: 1)); + expect(game.paused, true); + + await tester.tapAt(const Offset(400, 300)); + await tester.pump(const Duration(seconds: 1)); + expect(game.paused, false); + }, + ); + }); +} + +class SimpleStatelessWidget extends StatelessWidget { + const SimpleStatelessWidget({ + required Widget Function(BuildContext) build, + super.key, + }) : _build = build; + + final Widget Function(BuildContext) _build; + + @override + Widget build(BuildContext context) => _build(context); +}