feat: Adding FlameConsole (#3329)

Adding Flame Console package.

---------

Co-authored-by: Lukas Klingsbo <me@lukas.fyi>
This commit is contained in:
Erick
2024-10-24 11:10:22 -03:00
committed by GitHub
parent fec4499636
commit cf5358cd90
36 changed files with 1395 additions and 0 deletions

View File

@ -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<MyGame> {
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<String> 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.

29
packages/flame_console/.gitignore vendored Normal file
View File

@ -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/

View File

@ -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

View File

@ -0,0 +1,3 @@
## 0.1.0
* First release

View File

@ -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.

View File

@ -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.

View File

@ -0,0 +1 @@
include: package:flame_lint/analysis_options_with_dcm.yaml

View File

@ -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

View File

@ -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'

View File

@ -0,0 +1,3 @@
# flame_console example
Example of using the flame console package

View File

@ -0,0 +1 @@
include: package:flame_lint/analysis_options_with_dcm.yaml

View File

@ -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<void> 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,
),
],
),
]);
}
}

View File

@ -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<MyGameApp> createState() => _MyGameAppState();
}
class _MyGameAppState extends State<MyGameApp> {
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),
),
);
}
}

View File

@ -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

View File

@ -0,0 +1 @@
export 'src/flame_console.dart';

View File

@ -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<G extends FlameGame> {
ArgParser get parser;
String get description;
String get name;
List<Component> listAllChildren(Component component) {
return [
for (final child in component.children) ...[
child,
...listAllChildren(child),
],
];
}
void onChildMatch(
void Function(Component) onChild, {
required Component rootComponent,
List<String> ids = const [],
List<String> 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<String> 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<G extends FlameGame> extends ConsoleCommand<G> {
(String?, String) processChildren(List<Component> children);
@override
(String?, String) execute(G game, ArgResults results) {
final children = <Component>[];
onChildMatch(
children.add,
rootComponent: game,
ids: results['id'] as List<String>? ?? [],
types: results['type'] as List<String>? ?? [],
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<ConsoleCommand> commands = [
LsConsoleCommand(),
RemoveConsoleCommand(),
DebugConsoleCommand(),
PauseConsoleCommand(),
ResumeConsoleCommand(),
];
}

View File

@ -0,0 +1,19 @@
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flame_console/src/commands/commands.dart';
class DebugConsoleCommand<G extends FlameGame> extends QueryCommand<G> {
@override
(String?, String) processChildren(List<Component> 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.';
}

View File

@ -0,0 +1,22 @@
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flame_console/flame_console.dart';
class LsConsoleCommand<G extends FlameGame> extends QueryCommand<G> {
@override
(String?, String) processChildren(List<Component> 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.';
}

View File

@ -0,0 +1,27 @@
import 'package:args/args.dart';
import 'package:flame/game.dart';
import 'package:flame_console/flame_console.dart';
class PauseConsoleCommand<G extends FlameGame> extends ConsoleCommand<G> {
@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.';
}

View File

@ -0,0 +1,20 @@
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flame_console/flame_console.dart';
class RemoveConsoleCommand<G extends FlameGame> extends QueryCommand<G> {
@override
(String?, String) processChildren(List<Component> 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.';
}

View File

@ -0,0 +1,24 @@
import 'package:args/args.dart';
import 'package:flame/game.dart';
import 'package:flame_console/flame_console.dart';
class ResumeConsoleCommand<G extends FlameGame> extends ConsoleCommand<G> {
@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.';
}

View File

@ -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<String> commandHistory;
final List<String> history;
final String cmd;
ConsoleState copyWith({
bool? showHistory,
int? commandHistoryIndex,
List<String>? commandHistory,
List<String>? 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<G extends FlameGame> {
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<ConsoleState> state;
final ConsoleRepository repository;
final G game;
final VoidCallback onClose;
final ScrollController scrollController;
final Map<String, ConsoleCommand<G>> commands;
Future<void> 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,
);
}
}
}

View File

@ -0,0 +1,3 @@
export 'commands/commands.dart';
export 'repository/repository.dart';
export 'view/view.dart';

View File

@ -0,0 +1,7 @@
/// A repository to persist and read history of commands.
abstract class ConsoleRepository {
const ConsoleRepository();
Future<void> addToCommandHistory(String command);
Future<List<String>> listCommandHistory();
}

View File

@ -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<String> commands = const [],
}) : _commands = commands;
final List<String> _commands;
@override
Future<void> addToCommandHistory(String command) async {
_commands.add(command);
}
@override
Future<List<String>> listCommandHistory() async {
return _commands;
}
}

View File

@ -0,0 +1,2 @@
export 'console_repository.dart';
export 'memory_console_repository.dart';

View File

@ -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<G extends FlameGame> 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<ConsoleCommand<G>>? 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<ConsoleView> createState() => _ConsoleViewState();
}
class _ConsoleKeyboardHandler extends Component with KeyboardHandler {
_ConsoleKeyboardHandler(this._onKeyEvent);
final void Function(KeyEvent) _onKeyEvent;
@override
bool onKeyEvent(KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
_onKeyEvent(event);
return false;
}
}
class _ConsoleViewState extends State<ConsoleView> {
late final List<ConsoleCommand> _commandList = [
...ConsoleCommands.commands,
if (widget.customCommands != null) ...widget.customCommands!,
];
late final Map<String, ConsoleCommand> _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),
],
),
),
),
],
),
);
},
);
}
}

View File

@ -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,
),
);
}

View File

@ -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,
),
);
}

View File

@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
Widget defaultHistoryBuilder(
BuildContext context,
ScrollController scrollController,
Widget child,
) {
return SingleChildScrollView(
controller: scrollController,
child: child,
);
}

View File

@ -0,0 +1 @@
export 'console_view.dart';

View File

@ -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

View File

@ -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<World> 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<RectangleComponent>());
expect(components[1], isA<PositionComponent>());
},
);
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 = <Component>[];
command.onChildMatch(
components.add,
rootComponent: game.world,
types: ['PositionComponent'],
);
expect(components, hasLength(2));
expect(components[0], isA<PositionComponent>());
expect(components[1], isA<PositionComponent>());
},
);
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 = <Component>[];
command.onChildMatch(
components.add,
rootComponent: game.world,
types: ['PositionComponent'],
limit: 1,
);
expect(components, hasLength(1));
expect(components[0], isA<PositionComponent>());
},
);
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 = <Component>[];
command.onChildMatch(
components.add,
rootComponent: game.world,
ids: [target.hashCode.toString()],
);
expect(components, hasLength(1));
expect(components[0], isA<PositionComponent>());
},
);
});
});
}

View File

@ -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);
}
},
);
});
}

View File

@ -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',
);
},
);
});
});
}

View File

@ -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',
);
},
);
});
});
}