diff --git a/lib/game/components/camera_target.dart b/lib/game/components/camera_target.dart index a252538..2a22e7d 100644 --- a/lib/game/components/camera_target.dart +++ b/lib/game/components/camera_target.dart @@ -1,4 +1,3 @@ -import 'package:crystal_ball/game/constants.dart'; import 'package:crystal_ball/game/crystal_ball.dart'; import 'package:flame/components.dart'; import 'package:flame/effects.dart'; @@ -7,34 +6,42 @@ import 'package:flutter/animation.dart'; class CameraTarget extends PositionComponent with HasGameRef { CameraTarget() : super( - position: Vector2(0.0, -kCameraSize.height / 4), - size: Vector2.all(1), + position: Vector2(0, 0), + size: Vector2.all(0), anchor: Anchor.center, priority: 0x7fffffff, ); - final effectController = CurvedEffectController( + final effectController = GoodCurvedEffectController( 0.1, Curves.easeInOut, )..setToEnd(); late final moveEffect = MoveCameraTarget(position, effectController); - @override - Color get debugColor => const Color(0xFFFFFF00); - - @override - bool get debugMode => true; - @override Future onLoad() async { await add(moveEffect); } - void go({required Vector2 to, bool calm = false}) { - effectController.duration = calm ? 10 : 0.5; + void go({ + required Vector2 to, + Curve curve = Curves.easeInOut, + double duration = 0.25, + double scale = 1, + }) { + effectController + ..duration = duration * 4 + ..curve = curve; moveEffect.go(to: to); + // add(ScaleEffect.to(Vector2.all(scale), effectController)); + } + + @override + void update(double dt) { + super.update(dt); + game.camera.viewfinder.zoom = scale.x; } } @@ -66,3 +73,13 @@ class MoveCameraTarget extends Effect with EffectTarget { _from = target.position; } } + +class GoodCurvedEffectController extends DurationEffectController { + GoodCurvedEffectController(super.duration, this.curve) + : assert(duration > 0, 'Duration must be positive: $duration'); + + Curve curve; + + @override + double get progress => curve.transform(timer / duration); +} diff --git a/lib/game/components/components.dart b/lib/game/components/components.dart index e0c4c13..e512119 100644 --- a/lib/game/components/components.dart +++ b/lib/game/components/components.dart @@ -1 +1,4 @@ +export 'camera_target.dart'; +export 'game_state_controller.dart'; +export 'keyboard_handler.dart'; export 'platform_spawner.dart'; diff --git a/lib/game/components/game_state_controller.dart b/lib/game/components/game_state_controller.dart new file mode 100644 index 0000000..5dad35b --- /dev/null +++ b/lib/game/components/game_state_controller.dart @@ -0,0 +1,56 @@ +import 'package:crystal_ball/game/game.dart'; +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flutter/animation.dart'; + +class GameStateController extends Component + with + HasGameRef, + FlameBlocListenable { + Timer? timer; + + @override + void onNewState(GameState state) { + super.onNewState(state); + timer?.stop(); + timer = null; + switch (state) { + case GameState.initial: + break; + case GameState.starting: + game.world.cameraTarget.go( + to: Vector2(0, -kCameraSize.height / 4), + curve: Curves.easeInOutCubic, + duration: kOpeningDuration, + ); + timer = Timer( + kOpeningDuration, + onTick: bloc.gameStarted, + ); + case GameState.playing: + break; + case GameState.gameOver: + game.world.cameraTarget.go( + to: Vector2(0, 0), + curve: Curves.easeInOutCubic, + duration: 0.3, + ); + game.world.theBall.position = Vector2.zero(); + for (final platform in game.world.flameMultiBlocProvider + .descendants() + .whereType()) { + platform.removeFromParent(); + } + timer = Timer( + 1, + onTick: bloc.setInitial, + ); + } + } + + @override + void update(double dt) { + super.update(dt); + timer?.update(dt); + } +} diff --git a/lib/game/components/keyboard_handler.dart b/lib/game/components/keyboard_handler.dart new file mode 100644 index 0000000..7ce4594 --- /dev/null +++ b/lib/game/components/keyboard_handler.dart @@ -0,0 +1,81 @@ +import 'package:crystal_ball/game/game.dart'; +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flutter/services.dart'; + +class KeyboardHandlerSync extends Component + with FlameBlocReader { + KeyboardHandlerSync(); + + @override + Future onLoad() async { + await add(KeyboardListenerComponent( + keyDown: { + LogicalKeyboardKey.space: onSpace, + }, + )); + + return super.onLoad(); + } + + bool onSpace(Set logicalKeys) { + if (bloc.state == GameState.initial) { + bloc.startGame(); + return false; + } + return true; + } +} + +class DirectionalController extends Component + with FlameBlocReader { + DirectionalController(); + + double _directionalCoefficient = 0; + + double get directionalCoefficient => _directionalCoefficient; + + @override + Future onLoad() async { + await add(KeyboardListenerComponent( + keyDown: { + LogicalKeyboardKey.arrowLeft: onLeftStart, + LogicalKeyboardKey.arrowRight: onRightStart, + }, + keyUp: { + LogicalKeyboardKey.arrowLeft: onLeftEnd, + LogicalKeyboardKey.arrowRight: onRightEnd, + }, + )); + + return super.onLoad(); + } + + bool onLeftStart(Set logicalKeys) { + if (!bloc.isPlaying) return true; + _directionalCoefficient = -1; + return false; + } + + bool onRightStart(Set logicalKeys) { + if (!bloc.isPlaying) return true; + _directionalCoefficient = 1; + return false; + } + + bool onLeftEnd(Set logicalKeys) { + if (!bloc.isPlaying) return true; + if (_directionalCoefficient < 0) { + _directionalCoefficient = 0; + } + return false; + } + + bool onRightEnd(Set logicalKeys) { + if (!bloc.isPlaying) return true; + if (_directionalCoefficient > 0) { + _directionalCoefficient = 0; + } + return false; + } +} diff --git a/lib/game/components/platform_spawner.dart b/lib/game/components/platform_spawner.dart index 76f6f10..7653591 100644 --- a/lib/game/components/platform_spawner.dart +++ b/lib/game/components/platform_spawner.dart @@ -2,24 +2,101 @@ import 'dart:math'; import 'package:crystal_ball/game/game.dart'; import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; -class PlatformSpawner extends Component with HasGameRef { +class PlatformSpawner extends Component + with + HasGameRef, + FlameBlocListenable { PlatformSpawner({ required this.random, }); final Random random; - static const interval = 2.0; + double currentMinY = kStartPlatformHeight; + + bool needsPreloadCheck = false; @override - void onLoad() { - game.world.add( + void onLoad() {} + + Future spawnPlatform() async { + final y = currentMinY; + final padedHalfWidth = (kCameraSize.width - 100) / 2; + final x = random.nextDoubleInBetween(-padedHalfWidth, padedHalfWidth); + + final color = PlatformColor.random(random); + + final width = kPlatformMinWidth + + random.nextDoubleAntiSmooth() * kPlatformWidthVariation; + + final size = Vector2(width, kPlatformHeight); + + await game.world.flameMultiBlocProvider.add( Platform( - position: Vector2(0, -200), - size: Vector2(200, 35), - color: PlatformColor.green, + position: Vector2(x, -y), + size: size, + color: color, ), ); + + final interval = kMeanPlatformInterval + + random.nextVariation() * kPlatformIntervalVariation; + currentMinY += interval; + } + + void preloadPlatforms() async { + needsPreloadCheck = false; + int count = 0; + while (distanceToCameraTop < kPlatformPreloadArea && count < 10) { + await spawnPlatform(); + count++; + } + needsPreloadCheck = true; + } + + void spawnIntitialPlatforms() async { + print("spwn init"); + await Future.delayed(Duration(milliseconds: 1200)); + int count = 0; + while (distanceToCameraTop < kPlatformPreloadArea && count < 10) { + final delayed = Future.delayed( + Duration( + milliseconds: (kPlatformSpawnDuration * 1000).floor(), + ), + ); + await Future.wait([spawnPlatform(), delayed]); + count++; + } + needsPreloadCheck = true; + } + + @override + void onNewState(GameState state) { + super.onNewState(state); + switch (state) { + case GameState.initial: + needsPreloadCheck = false; + currentMinY = kStartPlatformHeight; + case GameState.starting: + spawnIntitialPlatforms(); + case GameState.playing: + case GameState.gameOver: + } + } + + double get cameraTop => game.world.cameraTarget.y - kCameraSize.height / 2; + + double get distanceToCameraTop => currentMinY - (-cameraTop); + + @override + void update(double dt) { + super.update(dt); + + if (needsPreloadCheck && distanceToCameraTop < kPlatformPreloadArea) { + needsPreloadCheck = false; + preloadPlatforms(); + } } } diff --git a/lib/game/constants.dart b/lib/game/constants.dart index a47bb70..2817b96 100644 --- a/lib/game/constants.dart +++ b/lib/game/constants.dart @@ -1,9 +1,28 @@ +import 'dart:math'; + import 'package:flame/components.dart'; const (double, double) kCameraSize = (900, 1600); -const double kPlayerRadius = 50; +const double kPlayerRadius = 20; const (double, double) kPlayerSize = (kPlayerRadius * 2, kPlayerRadius * 2); +const double kOpeningDuration = 4; + +const double kPlatformSpawnDuration = 0.4; +const double kPlatformVerticalInterval = 1; +const double kStartPlatformHeight = 400; +const double kMeanPlatformInterval = 370; +const double kPlatformIntervalVariation = 100; +const double kPlatformMinWidth = 120; +const double kPlatformWidthVariation = 100; +const double kPlatformHeight = 40; +const double kPlatformPreloadArea = 1600; + +const double kGravity = 100; +const double kJumpVelocity = 3000; + +const double kReaperTolerance = 800; + extension TransformRec on (double, double) { Vector2 get asVector2 => Vector2($1, $2); @@ -11,3 +30,24 @@ extension TransformRec on (double, double) { double get height => $2; } + +extension RandomX on Random { + double nextDoubleAntiSmooth() { + final normal = nextDouble(); + return _invSmoothstep(normal); + } + + double nextVariation() { + return nextDoubleAntiSmooth() * 2 - 1; + } + + double nextDoubleInBetween(double min, double max) { + return nextDoubleAntiSmooth() * (max - min) + min; + } +} + +double _invSmoothstep(double normal) { + if (normal <= 0) return 0; + if (normal >= 1) return 1; + return 0.5 - sin(asin(1 - 2 * normal) / 3); +} diff --git a/lib/game/crystal_ball.dart b/lib/game/crystal_ball.dart index d7bfb6e..9991912 100644 --- a/lib/game/crystal_ball.dart +++ b/lib/game/crystal_ball.dart @@ -1,9 +1,9 @@ import 'dart:math'; -import 'package:crystal_ball/game/components/camera_target.dart'; import 'package:crystal_ball/game/game.dart'; import 'package:flame/components.dart'; import 'package:flame/game.dart'; +import 'package:flame/input.dart'; import 'package:flame_bloc/flame_bloc.dart'; import 'package:flutter/painting.dart'; @@ -11,13 +11,19 @@ class CrystalWorld extends World { CrystalWorld({ // ignore: strict_raw_type required List providers, + required this.random, super.priority = -0x7fffffff, }) { flameMultiBlocProvider = FlameMultiBlocProvider( providers: providers, children: [ - TheBall(position: Vector2.zero()), + PlatformSpawner(random: random), + GameStateController(), + KeyboardHandlerSync(), + directionalController = DirectionalController(), + theBall = TheBall(position: Vector2.zero()), Ground(), + reaper = Reaper(), ], ); add(flameMultiBlocProvider); @@ -27,9 +33,18 @@ class CrystalWorld extends World { late final FlameMultiBlocProvider flameMultiBlocProvider; late final cameraTarget = CameraTarget(); + + late final DirectionalController directionalController; + + late final Reaper reaper; + + late final TheBall theBall; + + final Random random; } -class CrystalBallGame extends FlameGame { +class CrystalBallGame extends FlameGame + with HasKeyboardHandlerComponents, HasCollisionDetection { CrystalBallGame({ required this.textStyle, required this.random, @@ -45,23 +60,18 @@ class CrystalBallGame extends FlameGame { ), ), world: CrystalWorld( + random: random, providers: [ FlameBlocProvider.value( value: gameCubit, ), ], ), - children: [ - PlatformSpawner(random: random), - ], ) { camera.follow(world.cameraTarget); images.prefix = ''; } - @override - bool get debugMode => true; - final TextStyle textStyle; final Random random; diff --git a/lib/game/cubit/game_cubit.dart b/lib/game/cubit/game_cubit.dart index d6078cd..9c49018 100644 --- a/lib/game/cubit/game_cubit.dart +++ b/lib/game/cubit/game_cubit.dart @@ -14,6 +14,10 @@ class GameCubit extends Cubit { } void startGame() { + setState(GameState.starting); + } + + void gameStarted() { setState(GameState.playing); } @@ -24,7 +28,7 @@ class GameCubit extends Cubit { enum GameState { initial, + starting, playing, - // paused, gameOver, } diff --git a/lib/game/entities/entities.dart b/lib/game/entities/entities.dart index 82d6730..89dd55f 100644 --- a/lib/game/entities/entities.dart +++ b/lib/game/entities/entities.dart @@ -1,3 +1,4 @@ -export 'ground/ground.dart'; +export 'ground.dart'; export 'platform.dart'; -export 'the_ball/the_ball.dart'; +export 'reaper.dart'; +export 'the_ball.dart'; diff --git a/lib/game/entities/ground.dart b/lib/game/entities/ground.dart new file mode 100644 index 0000000..921df7f --- /dev/null +++ b/lib/game/entities/ground.dart @@ -0,0 +1,54 @@ +import 'dart:ui'; + +import 'package:crystal_ball/game/constants.dart'; +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; + +class Ground extends Component { + Ground() + : super( + children: [ + _Rectangle(), + ], + ); + + @override + // TODO: implement debugMode + bool get debugMode => true; +} + +class _Rectangle extends RectangleComponent + with CollisionCallbacks, ParentIsA { + _Rectangle() + : super( + anchor: Anchor.topCenter, + paint: Paint()..color = const Color(0xFF1F1616), + position: Vector2(0, kPlayerSize.height / 2), + size: Vector2( + kCameraSize.width, + kCameraSize.height / 2, + ), + children: [ + RectangleHitbox( + size: Vector2( + kCameraSize.width, + kCameraSize.height / 2, + ), + ), + RectangleHitbox( + position: Vector2(0, kPlayerRadius), + size: Vector2( + kCameraSize.width, + kCameraSize.height / 2, + ), + ), + RectangleHitbox( + position: Vector2(0, kPlayerRadius * 2), + size: Vector2( + kCameraSize.width, + kCameraSize.height / 2, + ), + ), + ], + ); +} diff --git a/lib/game/entities/ground/ground.dart b/lib/game/entities/ground/ground.dart deleted file mode 100644 index 40e9d74..0000000 --- a/lib/game/entities/ground/ground.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'dart:ui'; - -import 'package:crystal_ball/game/constants.dart'; -import 'package:flame/components.dart'; - -class Ground extends Component { - Ground() - : super( - children: [ - _Rectangle(), - ], - ); -} - -class _Rectangle extends RectangleComponent { - _Rectangle() - : super( - anchor: Anchor.topCenter, - paint: Paint()..color = const Color(0xFF1F1616), - position: Vector2(0, kPlayerSize.height / 2), - size: Vector2( - kCameraSize.width, - kCameraSize.height / 2, - ), - ); -} diff --git a/lib/game/entities/platform.dart b/lib/game/entities/platform.dart index 446f83d..ad603e4 100644 --- a/lib/game/entities/platform.dart +++ b/lib/game/entities/platform.dart @@ -1,35 +1,84 @@ +import 'dart:math'; import 'dart:ui'; +import 'package:crystal_ball/game/game.dart'; +import 'package:flame/collisions.dart'; import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame_bloc/flame_bloc.dart'; enum PlatformColor { - orange._(Color(0xFFFFA500)), - blue._(Color(0xFF2A48DF)), - green._(Color(0xFF00FF00)); + red._(Color(0xFFFF0000), 1), + orange._(Color(0xFFFFA500), 2), + blue._(Color(0xFF2A48DF), 30), + green._(Color(0xFF00FF00), 100); - const PlatformColor._(this.color); + const PlatformColor._(this.color, this.rarity); final Color color; + final int rarity; Paint get paint => Paint()..color = color; + + static PlatformColor random(Random random) => + values[random.nextInt(values.length)]; + + static PlatformColor rarityRandom(Random random) { + final totalRarity = values.fold( + 0, + (previousValue, element) => previousValue + element.rarity, + ); + final randomValue = random.nextInt(totalRarity); + int currentRarity = 0; + for (final color in values) { + currentRarity += color.rarity; + if (randomValue < currentRarity) { + return color; + } + } + return values.last; + } } -class Platform extends PositionComponent { +class Platform extends PositionComponent + with + HasPaint, + HasGameRef, + FlameBlocListenable { Platform({ required Vector2 super.position, required Vector2 super.size, required this.color, }) : super( anchor: Anchor.center, - children: [ - _InerPlatform( - size: size, - paint: color.paint, - ), - ], - ); + priority: 1000, + ) { + add( + _InerPlatform( + size: size, + paint: paint, + ), + ); + add(RectangleHitbox(size: size)); + } + + @override + late final Paint paint = color.paint; + + late final effectController = EffectController( + duration: kPlatformSpawnDuration.toDouble(), + ); final PlatformColor color; + + @override + void update(double dt) { + super.update(dt); + if (!bloc.isPlaying) return; + if (y > game.world.reaper.y) { + removeFromParent(); + } + } } class _InerPlatform extends Component with ParentIsA { diff --git a/lib/game/entities/reaper.dart b/lib/game/entities/reaper.dart new file mode 100644 index 0000000..8926fb3 --- /dev/null +++ b/lib/game/entities/reaper.dart @@ -0,0 +1,22 @@ +import 'package:crystal_ball/game/game.dart'; +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; + +class Reaper extends PositionComponent with HasGameRef { + Reaper() + : super( + position: Vector2(0, 0), + size: Vector2(kCameraSize.width * 2, 100), + anchor: Anchor.topCenter, + children: [ + RectangleHitbox(), + ], + ); + + @override + void update(double dt) { + super.update(dt); + position.y = game.world.cameraTarget.position.y + + (kCameraSize.height + kReaperTolerance) / 2; + } +} diff --git a/lib/game/entities/the_ball.dart b/lib/game/entities/the_ball.dart new file mode 100644 index 0000000..d77c4dd --- /dev/null +++ b/lib/game/entities/the_ball.dart @@ -0,0 +1,143 @@ +import 'dart:ui'; + +import 'package:crystal_ball/game/game.dart'; +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame_bloc/flame_bloc.dart'; +import 'package:flutter/animation.dart'; + +class TheBall extends PositionComponent + with + FlameBlocListenable, + CollisionCallbacks, + HasGameRef { + TheBall({ + required Vector2 super.position, + }) : super( + anchor: Anchor.center, + priority: 100000, + children: [ + _Circle(radius: kPlayerRadius), + CircleHitbox( + radius: kPlayerRadius, + anchor: Anchor.center, + ), + // _CameraSpot(), + ], + ); + + Vector2 _velocity = Vector2.zero(); + + final double _gravity = kGravity; + + void jump() { + _velocity.y = -kJumpVelocity; + } + + @override + void onNewState(GameState state) { + super.onNewState(state); + switch (state) { + case GameState.initial: + case GameState.starting: + position = Vector2.zero(); + jump(); + case GameState.playing: + case GameState.gameOver: + // todo: figure out + } + } + + @override + void update(double dt) { + super.update(dt); + if (!bloc.isPlaying) return; + + _velocity.y += _gravity; + final horzV = _velocity.y.abs() * 0.5; + _velocity.x = + game.world.directionalController.directionalCoefficient * horzV; + + final maxH = kCameraSize.width / 2 - kPlayerRadius; + + position += _velocity * dt; + position.x = clampDouble(x, -maxH, maxH); + } + + @override + void onCollisionStart( + Set intersectionPoints, PositionComponent other) { + super.onCollisionStart(intersectionPoints, other); + if (!bloc.isPlaying) return; + if (other is Ground || other is ParentIsA) { + _velocity.y = 0; + position.y = 0; + jump(); + } + if (other is Platform && _velocity.y > 0) { + _velocity.y = 0; + position.y = other.topLeftPosition.y - kPlayerRadius; + jump(); + game.world.cameraTarget.go( + to: Vector2(0, other.topLeftPosition.y - kCameraSize.height / 2 + 100), + duration: 2, + curve: Curves.ease, + ); + } + + if (other is Reaper && _velocity.y > 0) { + bloc.gameOver(); + _velocity.y = 0; + } + } +} + +class _Circle extends CircleComponent { + _Circle({ + required double super.radius, + }) : super( + anchor: Anchor.center, + paint: Paint()..color = const Color(0xFFFFFFFF), + ); +} + +// class _CameraSpot extends PositionComponent +// with HasGameRef, ParentIsA { +// _CameraSpot() +// : super( +// anchor: Anchor.center, +// ); +// +// final timerInitial = 0.1; +// +// GameCubit get gameCubit => parent.bloc; +// +// late double timer = timerInitial; +// +// @override +// void update(double dt) { +// if (!gameCubit.isPlaying) { +// return; +// } +// +// if (timer <= 0) { +// final distance = (absolutePosition.y - game.world.cameraTarget.position.y); +// +// if(distance > 0) return; +// +// print('distance $distance'); +// +// final factor = smoothStep(0, 1, distance.abs() / 400); +// +// final duration = 3 - factor * 2.8; +// +// game.world.cameraTarget.go( +// to: Vector2(0, absolutePosition.y), +// duration: duration, +// curve: Curves.easeInCirc, +// ); +// timer = timerInitial; +// } +// timer -= dt; +// } +// } diff --git a/lib/game/entities/the_ball/the_ball.dart b/lib/game/entities/the_ball/the_ball.dart deleted file mode 100644 index 0e4d9a1..0000000 --- a/lib/game/entities/the_ball/the_ball.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'dart:ui'; - -import 'package:crystal_ball/game/game.dart'; -import 'package:flame/components.dart'; - -class TheBall extends PositionComponent { - TheBall({ - required Vector2 super.position, - }) : super( - anchor: Anchor.center, - children: [ - _Circle(radius: kPlayerRadius), - ], - ); -} - -class _Circle extends CircleComponent { - _Circle({ - required double super.radius, - }) : super( - anchor: Anchor.center, - paint: Paint()..color = const Color(0xFFFFFFFF), - ); -}