diff --git a/doc/bridge_packages/flame_console/flame_console.md b/doc/bridge_packages/flame_console/flame_console.md new file mode 100644 index 00000000000..425acbfb862 --- /dev/null +++ b/doc/bridge_packages/flame_console/flame_console.md @@ -0,0 +1,109 @@ +# flame_console + +Flame Console is a terminal overlay for Flame games which allows developers to debug and interact +with their games. + +It offers an overlay that can be plugged in to your `GameWidget` which when activated will show a +terminal-like interface written with Flutter widgets where commands can be executed to see +information about the running game and components, or perform actions. + +It comes with a set of built-in commands, but it is also possible to add custom commands. + + +## Usage + +Flame Console is an overlay, so to use it, you will need to register it in your game widget. + +Then, showing the overlay is up to you, below we see an example of a floating action button that will +show the console when pressed. + +```dart +@override +Widget build(BuildContext context) { + return Scaffold( + body: GameWidget( + game: _game, + overlayBuilderMap: { + 'console': (BuildContext context, MyGame game) => ConsoleView( + game: game, + onClose: () { + _game.overlays.remove('console'); + }, + ), + }, + ), + floatingActionButton: FloatingActionButton( + heroTag: 'console_button', + onPressed: () { + _game.overlays.add('console'); + }, + child: const Icon(Icons.developer_mode), + ), + ); +} +``` + + +## Built-in commands + +- `help` - List available commands and their usage. +- `ls` - List components. +- `rm` - Remove components. +- `debug` - Toggle debug mode on components. +- `pause` - Pauses the game loop. +- `resume` -Resumes the game loop. + + +## Custom commands + + Custom commands can be created by extending the `ConsoleCommand` class and adding them to the + the `customCommands` list in the `ConsoleView` widget. + + ```dart +class MyCustomCommand extends ConsoleCommand { + MyCustomCommand(); + + @override + String get name => 'my_command'; + + @override + String get description => 'Description of my command'; + + // The execute method is supposed to return a tuple where the first + // element is an error message in case of failure, and the second + // element is the output of the command. + @override + (String?, String) execute(MyGame game, List args) { + // do something on the game + return (null, 'Hello World'); + } +} +``` + +Then when creating the `ConsoleView` widget, add the custom command to the `customCommands` list. + +```dart +ConsoleView( + game: game, + onClose: () { + _game.overlays.remove('console'); + }, + customCommands: [MyCustomCommand()], +), +``` + + +## Customizing the console UI + +The console look and feel can also be customized. When creating the `ConsoleView` widget, there are +a couple of properties that can be used to customize it: + +- `containerBuilder`: It is used to created the decorated container where the history and the +command input is displayed. +- `cursorBuilder`: It is used to create the cursor widget. +- `historyBuilder`: It is used to create the scrolling element of the history, by default a simple +`SingleChildScrollView` is used. +- `cursorColor`: The color of the cursor. Can be used when just wanting to change the color +of the cursor. +- `textStyle`: The text style of the console. + diff --git a/packages/flame_console/.gitignore b/packages/flame_console/.gitignore new file mode 100644 index 00000000000..ac5aa9893e4 --- /dev/null +++ b/packages/flame_console/.gitignore @@ -0,0 +1,29 @@ +# 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/ +build/ diff --git a/packages/flame_console/.metadata b/packages/flame_console/.metadata new file mode 100644 index 00000000000..de8b3cef0d3 --- /dev/null +++ b/packages/flame_console/.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: "5874a72aa4c779a02553007c47dacbefba2374dc" + channel: "stable" + +project_type: package diff --git a/packages/flame_console/CHANGELOG.md b/packages/flame_console/CHANGELOG.md new file mode 100644 index 00000000000..4ef63ecd7cf --- /dev/null +++ b/packages/flame_console/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +* First release diff --git a/packages/flame_console/LICENSE b/packages/flame_console/LICENSE new file mode 100644 index 00000000000..3897c4d092d --- /dev/null +++ b/packages/flame_console/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2021 Blue Fire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/packages/flame_console/README.md b/packages/flame_console/README.md new file mode 100644 index 00000000000..328a2fc178c --- /dev/null +++ b/packages/flame_console/README.md @@ -0,0 +1,7 @@ +# Flame Console 💻 + +Terminal overlay for Flame games which allows developers to debug and interact with their running games. + +Check out the documentation +[here](https://docs.flame-engine.org/latest/bridge_packages/flame_console/flame_console.html) for +more information. diff --git a/packages/flame_console/analysis_options.yaml b/packages/flame_console/analysis_options.yaml new file mode 100644 index 00000000000..92aae2f2499 --- /dev/null +++ b/packages/flame_console/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options_with_dcm.yaml diff --git a/packages/flame_console/example/.gitignore b/packages/flame_console/example/.gitignore new file mode 100644 index 00000000000..18c4f270eb7 --- /dev/null +++ b/packages/flame_console/example/.gitignore @@ -0,0 +1,45 @@ +# 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 +.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 + +devtools_options.yaml diff --git a/packages/flame_console/example/.metadata b/packages/flame_console/example/.metadata new file mode 100644 index 00000000000..bd14d1f4512 --- /dev/null +++ b/packages/flame_console/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: "5874a72aa4c779a02553007c47dacbefba2374dc" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 5874a72aa4c779a02553007c47dacbefba2374dc + base_revision: 5874a72aa4c779a02553007c47dacbefba2374dc + - platform: web + create_revision: 5874a72aa4c779a02553007c47dacbefba2374dc + base_revision: 5874a72aa4c779a02553007c47dacbefba2374dc + + # 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/flame_console/example/README.md b/packages/flame_console/example/README.md new file mode 100644 index 00000000000..36f0f6ba4bc --- /dev/null +++ b/packages/flame_console/example/README.md @@ -0,0 +1,3 @@ +# flame_console example + +Example of using the flame console package diff --git a/packages/flame_console/example/analysis_options.yaml b/packages/flame_console/example/analysis_options.yaml new file mode 100644 index 00000000000..92aae2f2499 --- /dev/null +++ b/packages/flame_console/example/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options_with_dcm.yaml diff --git a/packages/flame_console/example/lib/game.dart b/packages/flame_console/example/lib/game.dart new file mode 100644 index 00000000000..0e0e638256a --- /dev/null +++ b/packages/flame_console/example/lib/game.dart @@ -0,0 +1,90 @@ +import 'dart:async'; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flame/palette.dart'; + +class MyGame extends FlameGame with HasKeyboardHandlerComponents { + @override + FutureOr onLoad() async { + await super.onLoad(); + + world.addAll([ + RectangleComponent( + position: Vector2(100, 0), + size: Vector2(100, 100), + paint: BasicPalette.white.paint(), + children: [ + RectangleHitbox.relative( + Vector2.all(0.8), + parentSize: Vector2(100, 100), + ), + SequenceEffect( + [ + MoveEffect.by( + Vector2(-200, 0), + LinearEffectController(1), + ), + MoveEffect.by( + Vector2(200, 0), + LinearEffectController(1), + ), + ], + infinite: true, + ), + ], + ), + RectangleComponent( + position: Vector2(200, 100), + size: Vector2(100, 100), + paint: BasicPalette.white.paint(), + children: [ + RectangleHitbox.relative( + Vector2.all(0.4), + parentSize: Vector2(100, 100), + ), + SequenceEffect( + [ + MoveEffect.by( + Vector2(-200, 0), + LinearEffectController(1), + ), + MoveEffect.by( + Vector2(200, 0), + LinearEffectController(1), + ), + ], + infinite: true, + ), + ], + ), + RectangleComponent( + position: Vector2(300, 200), + size: Vector2(100, 100), + paint: BasicPalette.white.paint(), + children: [ + RectangleHitbox.relative( + Vector2.all(0.2), + parentSize: Vector2(100, 100), + ), + SequenceEffect( + [ + MoveEffect.by( + Vector2(-200, 0), + LinearEffectController(1), + ), + MoveEffect.by( + Vector2(200, 0), + LinearEffectController(1), + ), + ], + infinite: true, + ), + ], + ), + ]); + } +} diff --git a/packages/flame_console/example/lib/main.dart b/packages/flame_console/example/lib/main.dart new file mode 100644 index 00000000000..bfcf5bbe2b0 --- /dev/null +++ b/packages/flame_console/example/lib/main.dart @@ -0,0 +1,50 @@ +import 'package:flame/game.dart'; +import 'package:flame_console/flame_console.dart'; +import 'package:flame_console_example/game.dart'; +import 'package:flutter/material.dart'; + +void main() { + runApp(const MaterialApp(home: MyGameApp())); +} + +class MyGameApp extends StatefulWidget { + const MyGameApp({super.key}); + + @override + State createState() => _MyGameAppState(); +} + +class _MyGameAppState extends State { + late final MyGame _game; + + @override + void initState() { + super.initState(); + + _game = MyGame(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: GameWidget( + game: _game, + overlayBuilderMap: { + 'console': (BuildContext context, MyGame game) => ConsoleView( + game: game, + onClose: () { + _game.overlays.remove('console'); + }, + ), + }, + ), + floatingActionButton: FloatingActionButton( + heroTag: 'console_button', + onPressed: () { + _game.overlays.add('console'); + }, + child: const Icon(Icons.developer_mode), + ), + ); + } +} diff --git a/packages/flame_console/example/pubspec.yaml b/packages/flame_console/example/pubspec.yaml new file mode 100644 index 00000000000..a554117d3e0 --- /dev/null +++ b/packages/flame_console/example/pubspec.yaml @@ -0,0 +1,23 @@ +name: flame_console_example +description: "Example of using the flame console package" + +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: ">=3.4.0 <4.0.0" + +dependencies: + flame: ^1.19.0 + flame_console: ^0.1.0 + flutter: + sdk: flutter + +dev_dependencies: + flame_lint: ^1.2.1 + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true diff --git a/packages/flame_console/lib/flame_console.dart b/packages/flame_console/lib/flame_console.dart new file mode 100644 index 00000000000..22ab71f1901 --- /dev/null +++ b/packages/flame_console/lib/flame_console.dart @@ -0,0 +1 @@ +export 'src/flame_console.dart'; diff --git a/packages/flame_console/lib/src/commands/commands.dart b/packages/flame_console/lib/src/commands/commands.dart new file mode 100644 index 00000000000..82fbaa0ff3b --- /dev/null +++ b/packages/flame_console/lib/src/commands/commands.dart @@ -0,0 +1,111 @@ +import 'package:args/args.dart'; +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_console/src/commands/commands.dart'; +import 'package:flame_console/src/commands/pause_command.dart'; +import 'package:flame_console/src/commands/resume_command.dart'; + +export 'debug_command.dart'; +export 'ls_command.dart'; +export 'remove_command.dart'; + +abstract class ConsoleCommand { + ArgParser get parser; + String get description; + String get name; + + List listAllChildren(Component component) { + return [ + for (final child in component.children) ...[ + child, + ...listAllChildren(child), + ], + ]; + } + + void onChildMatch( + void Function(Component) onChild, { + required Component rootComponent, + List ids = const [], + List types = const [], + int? limit, + }) { + final components = listAllChildren(rootComponent); + + var count = 0; + + for (final element in components) { + if (limit != null && count >= limit) { + break; + } + + final isIdMatch = + ids.isEmpty || ids.contains(element.hashCode.toString()); + final isTypeMatch = + types.isEmpty || types.contains(element.runtimeType.toString()); + + if (isIdMatch && isTypeMatch) { + count++; + onChild(element); + } + } + } + + (String?, String) run(G game, List args) { + final results = parser.parse(args); + return execute(game, results); + } + + (String?, String) execute(G game, ArgResults results); + + int? optionalIntResult(String key, ArgResults results) { + if (results[key] != null) { + return int.tryParse(results[key] as String); + } + return null; + } +} + +abstract class QueryCommand extends ConsoleCommand { + (String?, String) processChildren(List children); + + @override + (String?, String) execute(G game, ArgResults results) { + final children = []; + + onChildMatch( + children.add, + rootComponent: game, + ids: results['id'] as List? ?? [], + types: results['type'] as List? ?? [], + limit: optionalIntResult('limit', results), + ); + + return processChildren(children); + } + + @override + ArgParser get parser => ArgParser() + ..addMultiOption( + 'id', + abbr: 'i', + ) + ..addMultiOption( + 'type', + abbr: 't', + ) + ..addOption( + 'limit', + abbr: 'l', + ); +} + +class ConsoleCommands { + static List commands = [ + LsConsoleCommand(), + RemoveConsoleCommand(), + DebugConsoleCommand(), + PauseConsoleCommand(), + ResumeConsoleCommand(), + ]; +} diff --git a/packages/flame_console/lib/src/commands/debug_command.dart b/packages/flame_console/lib/src/commands/debug_command.dart new file mode 100644 index 00000000000..6bb1df6de27 --- /dev/null +++ b/packages/flame_console/lib/src/commands/debug_command.dart @@ -0,0 +1,19 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_console/src/commands/commands.dart'; + +class DebugConsoleCommand extends QueryCommand { + @override + (String?, String) processChildren(List children) { + for (final child in children) { + child.debugMode = !child.debugMode; + } + return (null, ''); + } + + @override + String get name => 'debug'; + + @override + String get description => 'Toggle debug mode on the matched components.'; +} diff --git a/packages/flame_console/lib/src/commands/ls_command.dart b/packages/flame_console/lib/src/commands/ls_command.dart new file mode 100644 index 00000000000..43a47317918 --- /dev/null +++ b/packages/flame_console/lib/src/commands/ls_command.dart @@ -0,0 +1,22 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_console/flame_console.dart'; + +class LsConsoleCommand extends QueryCommand { + @override + (String?, String) processChildren(List children) { + final out = StringBuffer(); + for (final component in children) { + final componentType = component.runtimeType.toString(); + out.writeln('${component.hashCode}@$componentType'); + } + + return (null, out.toString()); + } + + @override + String get name => 'ls'; + + @override + String get description => 'List components that match the query arguments.'; +} diff --git a/packages/flame_console/lib/src/commands/pause_command.dart b/packages/flame_console/lib/src/commands/pause_command.dart new file mode 100644 index 00000000000..0bea75b8e56 --- /dev/null +++ b/packages/flame_console/lib/src/commands/pause_command.dart @@ -0,0 +1,27 @@ +import 'package:args/args.dart'; +import 'package:flame/game.dart'; +import 'package:flame_console/flame_console.dart'; + +class PauseConsoleCommand extends ConsoleCommand { + @override + (String?, String) execute(G game, ArgResults results) { + if (game.paused) { + return ( + 'Game is already paused, use the resume command start it again', + '', + ); + } else { + game.pauseEngine(); + return (null, ''); + } + } + + @override + ArgParser get parser => ArgParser(); + + @override + String get name => 'pause'; + + @override + String get description => 'Pauses the game loop.'; +} diff --git a/packages/flame_console/lib/src/commands/remove_command.dart b/packages/flame_console/lib/src/commands/remove_command.dart new file mode 100644 index 00000000000..d320b63822b --- /dev/null +++ b/packages/flame_console/lib/src/commands/remove_command.dart @@ -0,0 +1,20 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_console/flame_console.dart'; + +class RemoveConsoleCommand extends QueryCommand { + @override + (String?, String) processChildren(List children) { + for (final component in children) { + component.removeFromParent(); + } + return (null, ''); + } + + @override + String get name => 'rm'; + + @override + String get description => + 'Removes components that match the query arguments.'; +} diff --git a/packages/flame_console/lib/src/commands/resume_command.dart b/packages/flame_console/lib/src/commands/resume_command.dart new file mode 100644 index 00000000000..25db4595b39 --- /dev/null +++ b/packages/flame_console/lib/src/commands/resume_command.dart @@ -0,0 +1,24 @@ +import 'package:args/args.dart'; +import 'package:flame/game.dart'; +import 'package:flame_console/flame_console.dart'; + +class ResumeConsoleCommand extends ConsoleCommand { + @override + (String?, String) execute(G game, ArgResults results) { + if (!game.paused) { + return ('Game is not paused, use the pause command to pause it', ''); + } else { + game.resumeEngine(); + return (null, ''); + } + } + + @override + ArgParser get parser => ArgParser(); + + @override + String get name => 'resume'; + + @override + String get description => 'Resumes the game loop.'; +} diff --git a/packages/flame_console/lib/src/controller.dart b/packages/flame_console/lib/src/controller.dart new file mode 100644 index 00000000000..a29d51ef799 --- /dev/null +++ b/packages/flame_console/lib/src/controller.dart @@ -0,0 +1,172 @@ +import 'package:flame/game.dart'; +import 'package:flame_console/flame_console.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class ConsoleState { + const ConsoleState({ + this.showHistory = false, + this.commandHistoryIndex = 0, + this.commandHistory = const [], + this.history = const [], + this.cmd = '', + }); + + final bool showHistory; + final int commandHistoryIndex; + final List commandHistory; + final List history; + final String cmd; + + ConsoleState copyWith({ + bool? showHistory, + int? commandHistoryIndex, + List? commandHistory, + List? history, + String? cmd, + }) { + return ConsoleState( + showHistory: showHistory ?? this.showHistory, + commandHistoryIndex: commandHistoryIndex ?? this.commandHistoryIndex, + commandHistory: commandHistory ?? this.commandHistory, + history: history ?? this.history, + cmd: cmd ?? this.cmd, + ); + } +} + +class ConsoleController { + ConsoleController({ + required this.repository, + required this.game, + required this.scrollController, + required this.onClose, + required this.commands, + ConsoleState state = const ConsoleState(), + }) : state = ValueNotifier(state); + + final ValueNotifier state; + final ConsoleRepository repository; + final G game; + final VoidCallback onClose; + final ScrollController scrollController; + final Map> commands; + + Future init() async { + final history = await repository.listCommandHistory(); + state.value = state.value.copyWith(history: history); + } + + void handleKeyEvent(KeyEvent event) { + if (event is KeyUpEvent) { + return; + } + final char = event.character; + + if (event.logicalKey == LogicalKeyboardKey.escape && + !state.value.showHistory) { + onClose(); + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp && + !state.value.showHistory) { + final newState = state.value.copyWith( + showHistory: true, + commandHistoryIndex: state.value.commandHistory.length - 1, + ); + state.value = newState; + } else if (event.logicalKey == LogicalKeyboardKey.enter && + state.value.showHistory) { + final newState = state.value.copyWith( + cmd: state.value.commandHistory[state.value.commandHistoryIndex], + showHistory: false, + ); + state.value = newState; + } else if ((event.logicalKey == LogicalKeyboardKey.arrowUp || + event.logicalKey == LogicalKeyboardKey.arrowDown) && + state.value.showHistory) { + final newState = state.value.copyWith( + commandHistoryIndex: event.logicalKey == LogicalKeyboardKey.arrowUp + ? (state.value.commandHistoryIndex - 1) + .clamp(0, state.value.commandHistory.length - 1) + : (state.value.commandHistoryIndex + 1) + .clamp(0, state.value.commandHistory.length - 1), + ); + state.value = newState; + } else if (event.logicalKey == LogicalKeyboardKey.escape && + state.value.showHistory) { + state.value = state.value.copyWith( + showHistory: false, + ); + } else if (event.logicalKey == LogicalKeyboardKey.enter && + !state.value.showHistory) { + final split = state.value.cmd.split(' '); + + if (split.isEmpty) { + return; + } + + if (split.first == 'clear') { + state.value = state.value.copyWith( + history: [], + cmd: '', + ); + return; + } + + if (split.first == 'help') { + final output = commands.entries.fold('', (previous, entry) { + final help = '${entry.key} - ${entry.value.description}\n\n' + '${entry.value.parser.usage}\n\n'; + + return '$previous\n$help'; + }); + + state.value = state.value.copyWith( + history: [...state.value.history, output], + ); + return; + } + + final originalCommand = state.value.cmd; + state.value = state.value.copyWith( + history: [...state.value.history, state.value.cmd], + cmd: '', + ); + + final command = commands[split.first]; + + if (command == null) { + state.value = state.value.copyWith( + history: [...state.value.history, 'Command not found'], + ); + } else { + repository.addToCommandHistory(originalCommand); + state.value = state.value.copyWith( + commandHistory: [...state.value.commandHistory, originalCommand], + ); + final result = command.run(game, split.skip(1).toList()); + + if (result.$1 != null) { + state.value = state.value.copyWith( + history: [...state.value.history, ...result.$1!.split('\n')], + ); + } else if (result.$2.isNotEmpty) { + state.value = state.value.copyWith( + history: [...state.value.history, ...result.$2.split('\n')], + ); + } + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + scrollController.jumpTo(scrollController.position.maxScrollExtent); + }); + } else if (event.logicalKey == LogicalKeyboardKey.backspace) { + state.value = state.value.copyWith( + cmd: state.value.cmd.substring(0, state.value.cmd.length - 1), + ); + } else if (char != null) { + state.value = state.value.copyWith( + cmd: state.value.cmd + char, + ); + } + } +} diff --git a/packages/flame_console/lib/src/flame_console.dart b/packages/flame_console/lib/src/flame_console.dart new file mode 100644 index 00000000000..b07822b44a2 --- /dev/null +++ b/packages/flame_console/lib/src/flame_console.dart @@ -0,0 +1,3 @@ +export 'commands/commands.dart'; +export 'repository/repository.dart'; +export 'view/view.dart'; diff --git a/packages/flame_console/lib/src/repository/console_repository.dart b/packages/flame_console/lib/src/repository/console_repository.dart new file mode 100644 index 00000000000..a8f87d896ea --- /dev/null +++ b/packages/flame_console/lib/src/repository/console_repository.dart @@ -0,0 +1,7 @@ +/// A repository to persist and read history of commands. +abstract class ConsoleRepository { + const ConsoleRepository(); + + Future addToCommandHistory(String command); + Future> listCommandHistory(); +} diff --git a/packages/flame_console/lib/src/repository/memory_console_repository.dart b/packages/flame_console/lib/src/repository/memory_console_repository.dart new file mode 100644 index 00000000000..6915b66c3f3 --- /dev/null +++ b/packages/flame_console/lib/src/repository/memory_console_repository.dart @@ -0,0 +1,21 @@ +import 'package:flame_console/flame_console.dart'; + +/// An implementation of a [ConsoleRepository] that stores the command history +/// in memory. +class MemoryConsoleRepository extends ConsoleRepository { + const MemoryConsoleRepository({ + List commands = const [], + }) : _commands = commands; + + final List _commands; + + @override + Future addToCommandHistory(String command) async { + _commands.add(command); + } + + @override + Future> listCommandHistory() async { + return _commands; + } +} diff --git a/packages/flame_console/lib/src/repository/repository.dart b/packages/flame_console/lib/src/repository/repository.dart new file mode 100644 index 00000000000..1d5fc5033cb --- /dev/null +++ b/packages/flame_console/lib/src/repository/repository.dart @@ -0,0 +1,2 @@ +export 'console_repository.dart'; +export 'memory_console_repository.dart'; diff --git a/packages/flame_console/lib/src/view/console_view.dart b/packages/flame_console/lib/src/view/console_view.dart new file mode 100644 index 00000000000..5de91bd52a6 --- /dev/null +++ b/packages/flame_console/lib/src/view/console_view.dart @@ -0,0 +1,235 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_console/flame_console.dart'; +import 'package:flame_console/src/controller.dart'; +import 'package:flame_console/src/view/container_builder.dart'; +import 'package:flame_console/src/view/cursor_builder.dart'; +import 'package:flame_console/src/view/history_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +typedef HistoryBuilder = Widget Function( + BuildContext context, + ScrollController scrollController, + Widget child, +); + +typedef ContainerBuilder = Widget Function( + BuildContext context, + Widget child, +); + +/// A Console like view that can be used to interact with a game. +/// +/// It should be registered as an overlay in the game widget +/// of the game you want to interact with. +/// +/// Example: +/// +/// ```dart +/// GameWidget( +/// game: _game, +/// overlayBuilderMap: { +/// 'console': (BuildContext context, MyGame game) => ConsoleView( +/// game: game, +/// onClose: () { +/// _game.overlays.remove('console'); +/// }, +/// ), +/// }, +/// ) +class ConsoleView extends StatefulWidget { + const ConsoleView({ + required this.game, + required this.onClose, + this.customCommands, + ConsoleRepository? repository, + this.containerBuilder, + this.cursorBuilder, + this.cursorColor, + this.historyBuilder, + this.textStyle, + @visibleForTesting this.controller, + super.key, + }) : repository = repository ?? const MemoryConsoleRepository(); + + final G game; + final List>? customCommands; + final VoidCallback onClose; + final ConsoleRepository repository; + final ConsoleController? controller; + + final ContainerBuilder? containerBuilder; + final WidgetBuilder? cursorBuilder; + final HistoryBuilder? historyBuilder; + + final Color? cursorColor; + final TextStyle? textStyle; + + @override + State createState() => _ConsoleViewState(); +} + +class _ConsoleKeyboardHandler extends Component with KeyboardHandler { + _ConsoleKeyboardHandler(this._onKeyEvent); + + final void Function(KeyEvent) _onKeyEvent; + + @override + bool onKeyEvent(KeyEvent event, Set keysPressed) { + _onKeyEvent(event); + return false; + } +} + +class _ConsoleViewState extends State { + late final List _commandList = [ + ...ConsoleCommands.commands, + if (widget.customCommands != null) ...widget.customCommands!, + ]; + + late final Map _commandsMap = { + for (final command in _commandList) command.name: command, + }; + + late final _controller = widget.controller ?? + ConsoleController( + repository: widget.repository, + game: widget.game, + scrollController: _scrollController, + onClose: widget.onClose, + commands: _commandsMap, + ); + + late final _scrollController = ScrollController(); + late final KeyboardHandler _keyboardHandler; + + @override + void initState() { + super.initState(); + + widget.game.add( + _keyboardHandler = _ConsoleKeyboardHandler( + _controller.handleKeyEvent, + ), + ); + + _controller.init(); + } + + @override + void dispose() { + _keyboardHandler.removeFromParent(); + _scrollController.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cursorColor = widget.cursorColor ?? Colors.white; + + final textStyle = widget.textStyle ?? + Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white, + ); + + final historyBuilder = widget.historyBuilder ?? defaultHistoryBuilder; + final containerBuilder = widget.containerBuilder ?? defaultContainerBuilder; + final cursorBuilder = widget.cursorBuilder ?? defaultCursorBuilder; + + return ValueListenableBuilder( + valueListenable: _controller.state, + builder: (context, state, _) { + return SizedBox( + height: 400, + width: double.infinity, + child: Stack( + children: [ + Positioned( + top: 0, + left: 0, + right: 0, + bottom: 48, + child: containerBuilder( + context, + historyBuilder( + context, + _scrollController, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final line in state.history) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text(line, style: textStyle), + ), + ], + ), + ), + ), + ), + if (state.showHistory) + Positioned( + bottom: 48, + left: 0, + right: 0, + child: containerBuilder( + context, + SizedBox( + height: 168, + child: Column( + verticalDirection: VerticalDirection.up, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (state.commandHistory.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text('No history', style: textStyle), + ), + for (var i = state.commandHistoryIndex; + i >= 0 && i >= state.commandHistoryIndex - 5; + i--) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: ColoredBox( + color: i == state.commandHistoryIndex + ? cursorColor.withOpacity(.5) + : Colors.transparent, + child: Text( + state.commandHistory[i], + style: textStyle?.copyWith( + color: i == state.commandHistoryIndex + ? cursorColor + : textStyle.color, + ), + ), + ), + ), + ], + ), + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: containerBuilder( + context, + Row( + children: [ + Text(state.cmd, style: textStyle), + SizedBox(width: (textStyle?.fontSize ?? 12) / 4), + cursorBuilder(context), + ], + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/packages/flame_console/lib/src/view/container_builder.dart b/packages/flame_console/lib/src/view/container_builder.dart new file mode 100644 index 00000000000..e7078aae903 --- /dev/null +++ b/packages/flame_console/lib/src/view/container_builder.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +Widget defaultContainerBuilder(BuildContext context, Widget child) { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.8), + border: Border.all(color: Colors.white), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: child, + ), + ); +} diff --git a/packages/flame_console/lib/src/view/cursor_builder.dart b/packages/flame_console/lib/src/view/cursor_builder.dart new file mode 100644 index 00000000000..b7bb8f49680 --- /dev/null +++ b/packages/flame_console/lib/src/view/cursor_builder.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +Widget defaultCursorBuilder(BuildContext context) { + return const ColoredBox( + color: Colors.white, + child: SizedBox( + width: 8, + height: 20, + ), + ); +} diff --git a/packages/flame_console/lib/src/view/history_builder.dart b/packages/flame_console/lib/src/view/history_builder.dart new file mode 100644 index 00000000000..d6f31e69c04 --- /dev/null +++ b/packages/flame_console/lib/src/view/history_builder.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +Widget defaultHistoryBuilder( + BuildContext context, + ScrollController scrollController, + Widget child, +) { + return SingleChildScrollView( + controller: scrollController, + child: child, + ); +} diff --git a/packages/flame_console/lib/src/view/view.dart b/packages/flame_console/lib/src/view/view.dart new file mode 100644 index 00000000000..a6a52a9da84 --- /dev/null +++ b/packages/flame_console/lib/src/view/view.dart @@ -0,0 +1 @@ +export 'console_view.dart'; diff --git a/packages/flame_console/pubspec.yaml b/packages/flame_console/pubspec.yaml new file mode 100644 index 00000000000..91c8649dc37 --- /dev/null +++ b/packages/flame_console/pubspec.yaml @@ -0,0 +1,20 @@ +name: flame_console +description: "An extensible and customizable console to help debug Flame games." +version: 0.1.0 +homepage: + +environment: + sdk: ">=3.4.0 <4.0.0" + flutter: ">=3.22.0" + +dependencies: + args: ^2.5.0 + flame: ^1.19.0 + flutter: + sdk: flutter + +dev_dependencies: + flame_lint: ^1.2.1 + flame_test: ^1.17.1 + flutter_test: + sdk: flutter diff --git a/packages/flame_console/test/src/commands_test.dart b/packages/flame_console/test/src/commands_test.dart new file mode 100644 index 00000000000..73b92aa08b2 --- /dev/null +++ b/packages/flame_console/test/src/commands_test.dart @@ -0,0 +1,139 @@ +import 'package:args/src/arg_parser.dart'; +import 'package:args/src/arg_results.dart'; +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_console/flame_console.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _NoopCommand extends ConsoleCommand { + @override + String get description => ''; + + @override + String get name => ''; + + @override + (String?, String) execute(FlameGame game, ArgResults results) { + return (null, ''); + } + + @override + ArgParser get parser => ArgParser(); +} + +void main() { + group('Commands', () { + testWithGame( + 'listAllChildren crawls on all children', + FlameGame.new, + (game) async { + await game.world.add( + RectangleComponent( + children: [ + PositionComponent(), + ], + ), + ); + + await game.ready(); + + final command = _NoopCommand(); + final components = command.listAllChildren(game.world); + + expect(components, hasLength(2)); + expect(components[0], isA()); + expect(components[1], isA()); + }, + ); + + group('onChildMatch', () { + testWithGame( + 'match children with the given types', + FlameGame.new, + (game) async { + await game.world.addAll([ + RectangleComponent( + children: [ + PositionComponent(), + ], + ), + PositionComponent(), + ]); + + await game.ready(); + + final command = _NoopCommand(); + final components = []; + command.onChildMatch( + components.add, + rootComponent: game.world, + types: ['PositionComponent'], + ); + + expect(components, hasLength(2)); + expect(components[0], isA()); + expect(components[1], isA()); + }, + ); + + testWithGame( + 'match children with the given types and limit', + FlameGame.new, + (game) async { + await game.world.addAll([ + RectangleComponent( + children: [ + PositionComponent(), + ], + ), + PositionComponent(), + ]); + + await game.ready(); + + final command = _NoopCommand(); + final components = []; + command.onChildMatch( + components.add, + rootComponent: game.world, + types: ['PositionComponent'], + limit: 1, + ); + + expect(components, hasLength(1)); + expect(components[0], isA()); + }, + ); + + testWithGame( + 'match children with the given id', + FlameGame.new, + (game) async { + late Component target; + await game.world.addAll([ + target = RectangleComponent( + children: [ + PositionComponent(), + ], + ), + PositionComponent(), + ]); + + await game.ready(); + + final command = _NoopCommand(); + final components = []; + command.onChildMatch( + components.add, + rootComponent: game.world, + ids: [target.hashCode.toString()], + ); + + expect(components, hasLength(1)); + expect(components[0], isA()); + }, + ); + }); + }); +} diff --git a/packages/flame_console/test/src/debug_command_test.dart b/packages/flame_console/test/src/debug_command_test.dart new file mode 100644 index 00000000000..5cdd3376c80 --- /dev/null +++ b/packages/flame_console/test/src/debug_command_test.dart @@ -0,0 +1,35 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_console/flame_console.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Debug Command', () { + final components = [ + RectangleComponent(), + PositionComponent(), + ]; + testWithGame( + 'toggle debug mode on components', + FlameGame.new, + (game) async { + await game.world.addAll(components); + + await game.ready(); + + final command = DebugConsoleCommand(); + command.execute(game, command.parser.parse([])); + + for (final component in components) { + expect(component.debugMode, isTrue); + } + + command.execute(game, command.parser.parse([])); + for (final component in components) { + expect(component.debugMode, isFalse); + } + }, + ); + }); +} diff --git a/packages/flame_console/test/src/pause_command_test.dart b/packages/flame_console/test/src/pause_command_test.dart new file mode 100644 index 00000000000..800f22de0fa --- /dev/null +++ b/packages/flame_console/test/src/pause_command_test.dart @@ -0,0 +1,38 @@ +import 'package:flame/game.dart'; +import 'package:flame_console/src/commands/pause_command.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Pause Command', () { + testWithGame( + 'pauses the game', + FlameGame.new, + (game) async { + expect(game.paused, isFalse); + final command = PauseConsoleCommand(); + command.execute(game, command.parser.parse([])); + expect(game.paused, isTrue); + }, + ); + + group('when the game is already paused', () { + testWithGame( + 'returns error', + FlameGame.new, + (game) async { + game.pauseEngine(); + expect(game.paused, isTrue); + final command = PauseConsoleCommand(); + final result = command.execute(game, command.parser.parse([])); + expect(game.paused, isTrue); + + expect( + result.$1, + 'Game is already paused, use the resume command start it again', + ); + }, + ); + }); + }); +} diff --git a/packages/flame_console/test/src/resume_command_test.dart b/packages/flame_console/test/src/resume_command_test.dart new file mode 100644 index 00000000000..a1c41167ee2 --- /dev/null +++ b/packages/flame_console/test/src/resume_command_test.dart @@ -0,0 +1,38 @@ +import 'package:flame/game.dart'; +import 'package:flame_console/src/commands/resume_command.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Resume Command', () { + testWithGame( + 'resumes the game', + FlameGame.new, + (game) async { + game.pauseEngine(); + expect(game.paused, isTrue); + final command = ResumeConsoleCommand(); + command.execute(game, command.parser.parse([])); + expect(game.paused, isFalse); + }, + ); + + group('when the game is not paused', () { + testWithGame( + 'returns error', + FlameGame.new, + (game) async { + expect(game.paused, isFalse); + final command = ResumeConsoleCommand(); + final result = command.execute(game, command.parser.parse([])); + expect(game.paused, isFalse); + + expect( + result.$1, + 'Game is not paused, use the pause command to pause it', + ); + }, + ); + }); + }); +}