diff --git a/examples/assets/images/trex.png b/examples/assets/images/trex.png new file mode 100644 index 000000000..f321c354a Binary files /dev/null and b/examples/assets/images/trex.png differ diff --git a/examples/lib/main.dart b/examples/lib/main.dart index ca09c6cf4..236089af6 100644 --- a/examples/lib/main.dart +++ b/examples/lib/main.dart @@ -8,6 +8,7 @@ import 'stories/collision_detection/collision_detection.dart'; import 'stories/components/components.dart'; import 'stories/effects/effects.dart'; import 'stories/experimental/experimental.dart'; +import 'stories/games/games.dart'; import 'stories/input/input.dart'; import 'stories/parallax/parallax.dart'; import 'stories/rendering/rendering.dart'; @@ -23,6 +24,10 @@ void main() async { theme: ThemeData.dark(), ); + // Some small sample games + addGameStories(dashbook); + + // Feature examples addAnimationStories(dashbook); addCameraAndViewportStories(dashbook); addCollisionDetectionStories(dashbook); diff --git a/examples/lib/stories/games/games.dart b/examples/lib/stories/games/games.dart new file mode 100644 index 000000000..18ba8280e --- /dev/null +++ b/examples/lib/stories/games/games.dart @@ -0,0 +1,26 @@ +import 'package:dashbook/dashbook.dart'; +import 'package:flame/game.dart'; +import 'package:flutter/material.dart'; + +import '../../commons/commons.dart'; +import 'trex/trex_game.dart'; + +void addGameStories(Dashbook dashbook) { + dashbook.storiesOf('Sample Games').add( + 'T-Rex', + (_) => Container( + color: Colors.black, + margin: const EdgeInsets.all(45), + child: ClipRect( + child: GameWidget( + game: TRexGame(), + loadingBuilder: (_) => const Center( + child: Text('Loading'), + ), + ), + ), + ), + codeLink: baseLink('games/trex'), + info: TRexGame.description, + ); +} diff --git a/examples/lib/stories/games/trex/background/cloud.dart b/examples/lib/stories/games/trex/background/cloud.dart new file mode 100644 index 000000000..586f7fa6b --- /dev/null +++ b/examples/lib/stories/games/trex/background/cloud.dart @@ -0,0 +1,62 @@ +import 'package:flame/components.dart'; + +import '../random_extension.dart'; +import '../trex_game.dart'; +import 'cloud_manager.dart'; + +class Cloud extends SpriteComponent + with ParentIsA, HasGameRef { + Cloud({required Vector2 position}) + : cloudGap = random.fromRange( + minCloudGap, + maxCloudGap, + ), + super( + position: position, + size: initialSize, + ); + + static Vector2 initialSize = Vector2(92.0, 28.0); + + static const double maxCloudGap = 400.0; + static const double minCloudGap = 100.0; + + static const double maxSkyLevel = 71.0; + static const double minSkyLevel = 30.0; + + final double cloudGap; + + @override + Future onLoad() async { + sprite = Sprite( + gameRef.spriteImage, + srcPosition: Vector2(166.0, 2.0), + srcSize: initialSize, + ); + } + + @override + void update(double dt) { + super.update(dt); + if (shouldRemove) { + return; + } + x -= parent.cloudSpeed.ceil() * 50 * dt; + + if (!isVisible) { + removeFromParent(); + } + } + + bool get isVisible { + return x + width > 0; + } + + @override + void onGameResize(Vector2 gameSize) { + super.onGameResize(gameSize); + y = ((absolutePosition.y / 2 - (maxSkyLevel - minSkyLevel)) + + random.fromRange(minSkyLevel, maxSkyLevel)) - + absolutePositionOf(absoluteTopLeftPosition).y; + } +} diff --git a/examples/lib/stories/games/trex/background/cloud_manager.dart b/examples/lib/stories/games/trex/background/cloud_manager.dart new file mode 100644 index 000000000..a031356f3 --- /dev/null +++ b/examples/lib/stories/games/trex/background/cloud_manager.dart @@ -0,0 +1,42 @@ +import 'package:flame/components.dart'; + +import '../random_extension.dart'; +import '../trex_game.dart'; +import 'cloud.dart'; + +class CloudManager extends PositionComponent with HasGameRef { + final double cloudFrequency = 0.5; + final int maxClouds = 20; + final double bgCloudSpeed = 0.2; + + void addCloud() { + final cloudPosition = Vector2( + gameRef.size.x + Cloud.initialSize.x + 10, + ((absolutePosition.y / 2 - (Cloud.maxSkyLevel - Cloud.minSkyLevel)) + + random.fromRange(Cloud.minSkyLevel, Cloud.maxSkyLevel)) - + absolutePosition.y, + ); + add(Cloud(position: cloudPosition)); + } + + double get cloudSpeed => bgCloudSpeed / 1000 * gameRef.currentSpeed; + + @override + void update(double dt) { + super.update(dt); + final numClouds = children.length; + if (numClouds > 0) { + final lastCloud = children.last as Cloud; + if (numClouds < maxClouds && + (gameRef.size.x / 2 - lastCloud.x) > lastCloud.cloudGap) { + addCloud(); + } + } else { + addCloud(); + } + } + + void reset() { + removeAll(children); + } +} diff --git a/examples/lib/stories/games/trex/background/horizon.dart b/examples/lib/stories/games/trex/background/horizon.dart new file mode 100644 index 000000000..bcb30ffae --- /dev/null +++ b/examples/lib/stories/games/trex/background/horizon.dart @@ -0,0 +1,82 @@ +import 'dart:collection'; +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:flame/components.dart'; + +import '../obstacle/obstacle_manager.dart'; +import '../trex_game.dart'; +import 'cloud_manager.dart'; + +class Horizon extends PositionComponent with HasGameRef { + Horizon() : super(); + + static final Vector2 lineSize = Vector2(1200, 24); + final Queue groundLayers = Queue(); + late final CloudManager cloudManager = CloudManager(); + late final ObstacleManager obstacleManager = ObstacleManager(); + + late final _softSprite = Sprite( + gameRef.spriteImage, + srcPosition: Vector2(2.0, 104.0), + srcSize: lineSize, + ); + + late final _bumpySprite = Sprite( + gameRef.spriteImage, + srcPosition: Vector2(gameRef.spriteImage.width / 2, 104.0), + srcSize: lineSize, + ); + + @override + Future onLoad() async { + add(cloudManager); + add(obstacleManager); + } + + @override + void update(double dt) { + super.update(dt); + final increment = gameRef.currentSpeed * dt; + for (final line in groundLayers) { + line.x -= increment; + } + + final firstLine = groundLayers.first; + if (firstLine.x <= -firstLine.width) { + firstLine.x = groundLayers.last.x + groundLayers.last.width; + groundLayers.remove(firstLine); + groundLayers.add(firstLine); + } + } + + @override + void onGameResize(Vector2 gameSize) { + super.onGameResize(gameSize); + final newLines = _generateLines(); + groundLayers.addAll(newLines); + addAll(newLines); + y = (gameSize.y / 2) + 21.0; + } + + void reset() { + cloudManager.reset(); + obstacleManager.reset(); + groundLayers.forEachIndexed((i, line) => line.x = i * lineSize.x); + } + + List _generateLines() { + final number = + 1 + (gameRef.size.x / lineSize.x).ceil() - groundLayers.length; + final lastX = (groundLayers.lastOrNull?.x ?? 0) + + (groundLayers.lastOrNull?.width ?? 0); + return List.generate( + max(number, 0), + (i) => SpriteComponent( + sprite: (i + groundLayers.length).isEven ? _softSprite : _bumpySprite, + size: lineSize, + )..x = lastX + lineSize.x * i, + growable: false, + ); + } +} diff --git a/examples/lib/stories/games/trex/game_over.dart b/examples/lib/stories/games/trex/game_over.dart new file mode 100644 index 000000000..afea11d99 --- /dev/null +++ b/examples/lib/stories/games/trex/game_over.dart @@ -0,0 +1,62 @@ +import 'dart:ui'; + +import 'package:flame/components.dart'; + +import 'trex_game.dart'; + +class GameOverPanel extends Component { + bool visible = false; + + @override + Future onLoad() async { + add(GameOverText()); + add(GameOverRestart()); + } + + @override + void renderTree(Canvas canvas) { + if (visible) { + super.renderTree(canvas); + } + } +} + +class GameOverText extends SpriteComponent with HasGameRef { + GameOverText() : super(size: Vector2(382, 25), anchor: Anchor.center); + + @override + Future onLoad() async { + sprite = Sprite( + gameRef.spriteImage, + srcPosition: Vector2(955.0, 26.0), + srcSize: size, + ); + } + + @override + void onGameResize(Vector2 gameSize) { + super.onGameResize(gameSize); + x = gameSize.x / 2; + y = gameSize.y * .25; + } +} + +class GameOverRestart extends SpriteComponent with HasGameRef { + GameOverRestart() : super(size: Vector2(72, 64), anchor: Anchor.center); + + @override + Future onLoad() async { + sprite = Sprite( + gameRef.spriteImage, + srcPosition: Vector2.all(2.0), + srcSize: size, + ); + } + + @override + void onGameResize(Vector2 gameSize) { + super.onGameResize(gameSize); + x = gameSize.x / 2; + y = gameSize.y * .75; + } +} diff --git a/examples/lib/stories/games/trex/obstacle/obstacle.dart b/examples/lib/stories/games/trex/obstacle/obstacle.dart new file mode 100644 index 000000000..9664d221c --- /dev/null +++ b/examples/lib/stories/games/trex/obstacle/obstacle.dart @@ -0,0 +1,48 @@ +import 'package:flame/components.dart'; + +import '../random_extension.dart'; +import '../trex_game.dart'; +import 'obstacle_type.dart'; + +class Obstacle extends SpriteComponent with HasGameRef { + Obstacle({ + required this.settings, + required this.groupIndex, + }) : super(size: settings.size); + + final double _gapCoefficient = 0.6; + final double _maxGapCoefficient = 1.5; + + bool followingObstacleCreated = false; + late double gap; + final ObstacleTypeSettings settings; + final int groupIndex; + + bool get isVisible => (x + width) > 0; + + @override + Future onLoad() async { + sprite = settings.sprite(gameRef.spriteImage); + x = gameRef.size.x + width * groupIndex; + y = settings.y; + gap = computeGap(_gapCoefficient, gameRef.currentSpeed); + addAll(settings.generateHitboxes()); + } + + double computeGap(double gapCoefficient, double speed) { + final minGap = + (width * speed * settings.minGap * gapCoefficient).roundToDouble(); + final maxGap = (minGap * _maxGapCoefficient).roundToDouble(); + return random.fromRange(minGap, maxGap); + } + + @override + void update(double dt) { + super.update(dt); + x -= gameRef.currentSpeed * dt; + + if (!isVisible) { + removeFromParent(); + } + } +} diff --git a/examples/lib/stories/games/trex/obstacle/obstacle_manager.dart b/examples/lib/stories/games/trex/obstacle/obstacle_manager.dart new file mode 100644 index 000000000..02227182b --- /dev/null +++ b/examples/lib/stories/games/trex/obstacle/obstacle_manager.dart @@ -0,0 +1,81 @@ +import 'dart:collection'; + +import 'package:flame/components.dart'; + +import '../random_extension.dart'; +import '../trex_game.dart'; +import 'obstacle.dart'; +import 'obstacle_type.dart'; + +class ObstacleManager extends Component with HasGameRef { + ObstacleManager(); + + ListQueue history = ListQueue(); + static const int maxObstacleDuplication = 2; + + @override + void update(double dt) { + final obstacles = children.query(); + + if (obstacles.isNotEmpty) { + final lastObstacle = children.last as Obstacle?; + + if (lastObstacle != null && + !lastObstacle.followingObstacleCreated && + lastObstacle.isVisible && + (lastObstacle.x + lastObstacle.width + lastObstacle.gap) < + gameRef.size.x) { + addNewObstacle(); + lastObstacle.followingObstacleCreated = true; + } + } else { + addNewObstacle(); + } + } + + void addNewObstacle() { + final speed = gameRef.currentSpeed; + if (speed == 0) { + return; + } + var settings = random.nextBool() + ? ObstacleTypeSettings.cactusSmall + : ObstacleTypeSettings.cactusLarge; + if (duplicateObstacleCheck(settings.type) || speed < settings.allowedAt) { + settings = ObstacleTypeSettings.cactusSmall; + } + + final groupSize = _groupSize(settings); + for (var i = 0; i < groupSize; i++) { + add(Obstacle(settings: settings, groupIndex: i)); + gameRef.score++; + } + + history.addFirst(settings.type); + while (history.length > maxObstacleDuplication) { + history.removeLast(); + } + } + + bool duplicateObstacleCheck(ObstacleType nextType) { + var duplicateCount = 0; + + for (final type in history) { + duplicateCount += type == nextType ? 1 : 0; + } + return duplicateCount >= maxObstacleDuplication; + } + + void reset() { + removeAll(children); + history.clear(); + } + + int _groupSize(ObstacleTypeSettings settings) { + if (gameRef.currentSpeed > settings.multipleAt) { + return random.fromRange(1.0, ObstacleTypeSettings.maxGroupSize).floor(); + } else { + return 1; + } + } +} diff --git a/examples/lib/stories/games/trex/obstacle/obstacle_type.dart b/examples/lib/stories/games/trex/obstacle/obstacle_type.dart new file mode 100644 index 000000000..84b0f7a6e --- /dev/null +++ b/examples/lib/stories/games/trex/obstacle/obstacle_type.dart @@ -0,0 +1,105 @@ +import 'dart:ui'; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; + +enum ObstacleType { + cactusSmall, + cactusLarge, +} + +class ObstacleTypeSettings { + const ObstacleTypeSettings._internal( + this.type, { + required this.size, + required this.y, + required this.allowedAt, + required this.multipleAt, + required this.minGap, + required this.minSpeed, + this.numFrames, + this.frameRate, + this.speedOffset, + required this.generateHitboxes, + }); + + final ObstacleType type; + final Vector2 size; + final double y; + final int allowedAt; + final int multipleAt; + final double minGap; + final double minSpeed; + final int? numFrames; + final double? frameRate; + final double? speedOffset; + + static const maxGroupSize = 3.0; + + final List Function() generateHitboxes; + + static final cactusSmall = ObstacleTypeSettings._internal( + ObstacleType.cactusSmall, + size: Vector2(34.0, 70.0), + y: -55.0, + allowedAt: 0, + multipleAt: 1000, + minGap: 120.0, + minSpeed: 0.0, + generateHitboxes: () => [ + RectangleHitbox( + position: Vector2(5.0, 7.0), + size: Vector2(10.0, 54.0), + ), + RectangleHitbox( + position: Vector2(5.0, 7.0), + size: Vector2(12.0, 68.0), + ), + RectangleHitbox( + position: Vector2(15.0, 4.0), + size: Vector2(14.0, 28.0), + ), + ], + ); + + static final cactusLarge = ObstacleTypeSettings._internal( + ObstacleType.cactusLarge, + size: Vector2(50.0, 100.0), + y: -74.0, + allowedAt: 800, + multipleAt: 1500, + minGap: 120.0, + minSpeed: 0.0, + generateHitboxes: () => [ + RectangleHitbox( + position: Vector2(0.0, 26.0), + size: Vector2(14.0, 40.0), + ), + RectangleHitbox( + position: Vector2(16.0, 0.0), + size: Vector2(14.0, 98.0), + ), + RectangleHitbox( + position: Vector2(28.0, 22.0), + size: Vector2(20.0, 40.0), + ), + ], + ); + + Sprite sprite(Image spriteImage) { + switch (type) { + case ObstacleType.cactusSmall: + return Sprite( + spriteImage, + srcPosition: Vector2(446.0, 2.0), + srcSize: size, + ); + case ObstacleType.cactusLarge: + return Sprite( + spriteImage, + srcPosition: Vector2(652.0, 2.0), + srcSize: size, + ); + } + } +} diff --git a/examples/lib/stories/games/trex/player.dart b/examples/lib/stories/games/trex/player.dart new file mode 100644 index 000000000..f115ef8e3 --- /dev/null +++ b/examples/lib/stories/games/trex/player.dart @@ -0,0 +1,130 @@ +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; + +import 'trex_game.dart'; + +enum PlayerState { crashed, jumping, running, waiting } + +class Player extends SpriteAnimationGroupComponent + with HasGameRef, CollisionCallbacks { + Player() : super(size: Vector2(90, 88)); + + final double gravity = 1; + + final double initialJumpVelocity = -15.0; + final double introDuration = 1500.0; + final double startXPosition = 50; + + double _jumpVelocity = 0.0; + + double get groundYPos { + return (gameRef.size.y / 2) - height / 2; + } + + @override + Future onLoad() async { + // Body hitbox + add( + RectangleHitbox.relative( + Vector2(0.7, 0.6), + position: Vector2(0, height / 3), + parentSize: size, + ), + ); + // Head hitbox + add( + RectangleHitbox.relative( + Vector2(0.45, 0.35), + position: Vector2(width / 2, 0), + parentSize: size, + ), + ); + animations = { + PlayerState.running: _getAnimation( + size: Vector2(88.0, 90.0), + frames: [Vector2(1514.0, 4.0), Vector2(1602.0, 4.0)], + stepTime: 0.2, + ), + PlayerState.waiting: _getAnimation( + size: Vector2(88.0, 90.0), + frames: [Vector2(76.0, 6.0)], + ), + PlayerState.jumping: _getAnimation( + size: Vector2(88.0, 90.0), + frames: [Vector2(1339.0, 6.0)], + ), + PlayerState.crashed: _getAnimation( + size: Vector2(88.0, 90.0), + frames: [Vector2(1782.0, 6.0)], + ), + }; + current = PlayerState.waiting; + } + + void jump(double speed) { + if (current == PlayerState.jumping) { + return; + } + + current = PlayerState.jumping; + _jumpVelocity = initialJumpVelocity - (speed / 500); + } + + void reset() { + y = groundYPos; + _jumpVelocity = 0.0; + current = PlayerState.running; + } + + @override + void update(double dt) { + super.update(dt); + if (current == PlayerState.jumping) { + y += _jumpVelocity; + _jumpVelocity += gravity; + if (y > groundYPos) { + reset(); + } + } else { + y = groundYPos; + } + + if (gameRef.isIntro && x < startXPosition) { + x += (startXPosition / introDuration) * dt * 5000; + } + } + + @override + void onGameResize(Vector2 size) { + super.onGameResize(size); + y = groundYPos; + } + + @override + void onCollisionStart( + Set intersectionPoints, + PositionComponent other, + ) { + super.onCollisionStart(intersectionPoints, other); + gameRef.gameOver(); + } + + SpriteAnimation _getAnimation({ + required Vector2 size, + required List frames, + double stepTime = double.infinity, + }) { + return SpriteAnimation.spriteList( + frames + .map( + (vector) => Sprite( + gameRef.spriteImage, + srcSize: size, + srcPosition: vector, + ), + ) + .toList(), + stepTime: stepTime, + ); + } +} diff --git a/examples/lib/stories/games/trex/random_extension.dart b/examples/lib/stories/games/trex/random_extension.dart new file mode 100644 index 000000000..0ef7c6e01 --- /dev/null +++ b/examples/lib/stories/games/trex/random_extension.dart @@ -0,0 +1,8 @@ +import 'dart:math'; + +Random random = Random(); + +extension RandomExtension on Random { + double fromRange(double min, double max) => + (nextDouble() * (max - min + 1)).floor() + min; +} diff --git a/examples/lib/stories/games/trex/trex_game.dart b/examples/lib/stories/games/trex/trex_game.dart new file mode 100644 index 000000000..8942f3776 --- /dev/null +++ b/examples/lib/stories/games/trex/trex_game.dart @@ -0,0 +1,133 @@ +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:flame/flame.dart'; +import 'package:flame/game.dart'; +import 'package:flame/input.dart'; +import 'package:flutter/material.dart' hide Image; +import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'background/horizon.dart'; +import 'game_over.dart'; +import 'player.dart'; + +enum GameState { playing, intro, gameOver } + +class TRexGame extends FlameGame + with KeyboardEvents, TapDetector, HasCollisionDetection { + static const String description = ''' + A game similar to the game in chrome that you get to play while offline. + Press space or tap/click the screen to jump, the more obstacles you manage + to survive, the more points you get. + '''; + + late final Image spriteImage; + + @override + Color backgroundColor() => const Color(0xFFFFFFFF); + + late final player = Player(); + late final horizon = Horizon(); + late final gameOverPanel = GameOverPanel(); + late final TextComponent scoreText; + + late int _score; + int get score => _score; + set score(int newScore) { + _score = newScore; + scoreText.text = 'Score:$score'; + } + + @override + Future onLoad() async { + spriteImage = await Flame.images.load('trex.png'); + add(horizon); + add(player); + add(gameOverPanel); + + final textStyle = GoogleFonts.pressStart2p( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.grey.shade700, + ); + final textPaint = TextPaint(style: textStyle); + add( + scoreText = TextComponent( + position: Vector2(20, 20), + textRenderer: textPaint, + )..positionType = PositionType.viewport, + ); + score = 0; + } + + GameState state = GameState.intro; + double currentSpeed = 0.0; + double timePlaying = 0.0; + + final double acceleration = 10; + final double maxSpeed = 2500.0; + final double startSpeed = 600; + + bool get isPlaying => state == GameState.playing; + bool get isGameOver => state == GameState.gameOver; + bool get isIntro => state == GameState.intro; + + @override + KeyEventResult onKeyEvent( + RawKeyEvent event, + Set keysPressed, + ) { + if (keysPressed.contains(LogicalKeyboardKey.enter) || + keysPressed.contains(LogicalKeyboardKey.space)) { + onAction(); + } + return KeyEventResult.handled; + } + + @override + void onTapDown(TapDownInfo info) { + onAction(); + } + + void onAction() { + if (isGameOver || isIntro) { + restart(); + return; + } + player.jump(currentSpeed); + } + + void gameOver() { + gameOverPanel.visible = true; + state = GameState.gameOver; + player.current = PlayerState.crashed; + currentSpeed = 0.0; + } + + void restart() { + state = GameState.playing; + player.reset(); + horizon.reset(); + currentSpeed = startSpeed; + gameOverPanel.visible = false; + timePlaying = 0.0; + score = 0; + } + + @override + void update(double dt) { + super.update(dt); + if (isGameOver) { + return; + } + + if (isPlaying) { + timePlaying += dt; + + if (currentSpeed < maxSpeed) { + currentSpeed += acceleration * dt; + } + } + } +} diff --git a/examples/pubspec.yaml b/examples/pubspec.yaml index e94dbc68d..319ce4440 100644 --- a/examples/pubspec.yaml +++ b/examples/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: flame_svg: ^1.2.0 flame_forge2d: ^0.11.0 dashbook: 0.1.6 + google_fonts: 2.2.0 flutter: sdk: flutter