Add color effects, rain animation, double jump, and difficulty tuning#4
Add color effects, rain animation, double jump, and difficulty tuning#4BlackAlpha-debug wants to merge 4 commits into
Conversation
…tuning - Add score-based background color transition (day → sunset → night) - Add dino tint/glow effect on jump and red flash on collision - Implement animated rain overlay using CustomPainter, tied to score-based color transitions (intensifies as sky darkens) - Add double jump ability to the dino - Increase game speed multiplier to 1.75x - Reduce obstacle spawn frequency until score reaches 800 for a smoother early-game difficulty curve No changes to collision detection logic; rain layer is purely decorative and ignores pointer/hit-testing.
Updated image sources in the README to use direct links.
There was a problem hiding this comment.
Code Review
This pull request introduces several enhancements to the Flutter Dinosaur Game, including double jump mechanics, dynamic day-to-night sky transitions, a game-over red flash overlay, progressive difficulty, and an animated rain particle system. The review feedback highlights critical issues: modifying state inside a helper called during the build phase, potential ConcurrentModificationError when modifying the cacti list during iteration, potential TickerCanceled exceptions when chaining animations on a widget that might be disposed, and redundant controllers with frame-rate-dependent physics in the rain effect.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| List<Color> _getSkyColors() { | ||
| int phase = (runDistance ~/ skyPhaseInterval) | ||
| .clamp(0, skyGradients.length - 1); | ||
| if (phase != _currentSkyPhase) { | ||
| _currentSkyPhase = phase; | ||
| } | ||
| return skyGradients[_currentSkyPhase]; | ||
| } |
There was a problem hiding this comment.
Modifying state (_currentSkyPhase) inside a helper method called during the build phase is an anti-pattern in Flutter. Furthermore, because setState is not called when the sky phase changes, the AnimatedContainer background gradient will not update immediately when the score threshold is crossed; it will only update when some other event triggers a setState (like a cloud or cactus resetting).
To fix this, make _getSkyColors() a pure getter, and update _currentSkyPhase inside _update() using setState when the phase changes.
List<Color> _getSkyColors() {
return skyGradients[_currentSkyPhase];
}| double extraGap = runDistance < 800 | ||
| ? 150 + (800 - runDistance) * 0.3 | ||
| : 0; | ||
| cacti.add(Cactus( |
There was a problem hiding this comment.
Modifying the cacti list (via remove and add) while iterating over it in the for (Cactus cactus in cacti) loop will throw a ConcurrentModificationError in Dart. Although the error is currently swallowed by the empty try-catch block in _update(), it prematurely aborts the update loop, which can cause gameplay glitches or frozen obstacles.
To fix this, you should iterate over a copy of the list, for example:
for (Cactus cactus in List.from(cacti)) {Note that the same issue also affects the ground and clouds loops (lines 191 and 207), which should also be refactored to iterate over copies (e.g., List.from(ground) and List.from(clouds)).
| void initState() { | ||
| super.initState(); | ||
| _pulseController = AnimationController( | ||
| vsync: this, | ||
| duration: const Duration(milliseconds: 400), | ||
| ); | ||
| _pulseAnimation = Tween<double>(begin: 0.0, end: 0.6).animate( | ||
| CurvedAnimation(parent: _pulseController, curve: Curves.easeOut), | ||
| ); | ||
| } | ||
|
|
||
| @override | ||
| void didUpdateWidget(_DinoWidget oldWidget) { | ||
| super.didUpdateWidget(oldWidget); | ||
| if (widget.state == DinoState.jumping && _lastState != DinoState.jumping) { | ||
| _pulseController.forward(from: 0.0).then((_) => _pulseController.reverse()); | ||
| } | ||
| _lastState = widget.state; | ||
| } | ||
|
|
There was a problem hiding this comment.
Calling _pulseController.forward(from: 0.0).then((_) => _pulseController.reverse()) can lead to unhandled TickerCanceled exceptions or calling reverse() on a disposed controller if the widget is disposed mid-jump.
To prevent this, register a status listener in initState to automatically reverse the animation when it completes, and simply call forward(from: 0.0) in didUpdateWidget.
@override
void initState() {
super.initState();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
)..addStatusListener((status) {
if (status == AnimationStatus.completed) {
_pulseController.reverse();
}
});
_pulseAnimation = Tween<double>(begin: 0.0, end: 0.6).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeOut),
);
}
@override
void didUpdateWidget(_DinoWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.state == DinoState.jumping && _lastState != DinoState.jumping) {
_pulseController.forward(from: 0.0);
}
_lastState = widget.state;
}| class _RainEffectState extends State<RainEffect> | ||
| with SingleTickerProviderStateMixin { | ||
| late AnimationController _rainController; | ||
| final List<RainDrop> _drops = []; | ||
| final Random _random = Random(); | ||
| Size _lastSize = Size.zero; | ||
| static const int _maxDrops = 150; | ||
|
|
||
| @override | ||
| void initState() { | ||
| super.initState(); | ||
| _rainController = AnimationController( | ||
| vsync: this, | ||
| duration: const Duration(days: 99), | ||
| ); | ||
| _rainController.addListener(_tick); | ||
|
|
||
| if (widget.gameController.isAnimating) { | ||
| _rainController.forward(); | ||
| } | ||
| widget.gameController.addStatusListener(_onGameStatus); | ||
| } | ||
|
|
||
| void _onGameStatus(AnimationStatus status) { | ||
| if (status == AnimationStatus.forward) { | ||
| if (!_rainController.isAnimating) _rainController.forward(); | ||
| } else if (status == AnimationStatus.dismissed || | ||
| status == AnimationStatus.completed) { | ||
| _rainController.stop(); | ||
| } | ||
| } | ||
|
|
||
| void _initDrops(Size size) { | ||
| _drops.clear(); | ||
| int count = _dropCount(); | ||
| for (int i = 0; i < count; i++) { | ||
| _drops.add(_createDrop(size, randomY: true)); | ||
| } | ||
| } | ||
|
|
||
| int _dropCount() { | ||
| if (widget.skyPhase < 2) return 0; | ||
| return ((_maxDrops * (widget.skyPhase - 1)) ~/ (4)).clamp(0, _maxDrops); | ||
| } | ||
|
|
||
| RainDrop _createDrop(Size size, {bool randomY = false}) { | ||
| return RainDrop( | ||
| x: _random.nextDouble() * size.width, | ||
| y: randomY ? _random.nextDouble() * size.height : -_random.nextDouble() * 40, | ||
| speed: 600 + _random.nextDouble() * 400, | ||
| length: 12 + _random.nextDouble() * 18, | ||
| ); | ||
| } | ||
|
|
||
| void _tick() { | ||
| if (_lastSize == Size.zero) return; | ||
| final int targetCount = _dropCount(); | ||
|
|
||
| while (_drops.length < targetCount) { | ||
| _drops.add(_createDrop(_lastSize, randomY: true)); | ||
| } | ||
| while (_drops.length > targetCount) { | ||
| _drops.removeLast(); | ||
| } | ||
|
|
||
| const double dt = 1 / 60; | ||
| for (final drop in _drops) { | ||
| drop.y += drop.speed * dt; | ||
| if (drop.y > _lastSize.height) { | ||
| drop.y = -drop.length; | ||
| drop.x = _random.nextDouble() * _lastSize.width; | ||
| } | ||
| } | ||
| setState(() {}); | ||
| } | ||
|
|
||
| double _rainOpacity() { | ||
| if (widget.skyPhase < 2) return 0.0; | ||
| return ((widget.skyPhase - 1) / 4).clamp(0.0, 1.0); | ||
| } | ||
|
|
||
| @override | ||
| void didUpdateWidget(RainEffect oldWidget) { | ||
| super.didUpdateWidget(oldWidget); | ||
| if (widget.gameController.isAnimating && !_rainController.isAnimating) { | ||
| _rainController.forward(); | ||
| } | ||
| } | ||
|
|
||
| @override | ||
| void dispose() { | ||
| widget.gameController.removeStatusListener(_onGameStatus); | ||
| _rainController.dispose(); | ||
| super.dispose(); | ||
| } |
There was a problem hiding this comment.
Creating a redundant _rainController and manually syncing it with widget.gameController adds unnecessary complexity and overhead. Instead, RainEffect can directly listen to widget.gameController ticks. This simplifies the state, removes the need for SingleTickerProviderStateMixin, and automatically syncs the rain animation with the game's play/pause state.
Additionally, using a hardcoded 1 / 60 for dt makes the rain speed inconsistent on high-refresh-rate screens (e.g., 90Hz or 120Hz). Using the actual elapsed time between frames ensures frame-rate independent physics.
class _RainEffectState extends State<RainEffect> {
final List<RainDrop> _drops = [];
final Random _random = Random();
Size _lastSize = Size.zero;
static const int _maxDrops = 150;
DateTime? _lastTickTime;
@override
void initState() {
super.initState();
widget.gameController.addListener(_tick);
}
@override
void didUpdateWidget(RainEffect oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.gameController != widget.gameController) {
oldWidget.gameController.removeListener(_tick);
widget.gameController.addListener(_tick);
}
}
@override
void dispose() {
widget.gameController.removeListener(_tick);
super.dispose();
}
void _initDrops(Size size) {
_drops.clear();
int count = _dropCount();
for (int i = 0; i < count; i++) {
_drops.add(_createDrop(size, randomY: true));
}
}
int _dropCount() {
if (widget.skyPhase < 2) return 0;
return ((_maxDrops * (widget.skyPhase - 1)) ~/ (4)).clamp(0, _maxDrops);
}
RainDrop _createDrop(Size size, {bool randomY = false}) {
return RainDrop(
x: _random.nextDouble() * size.width,
y: randomY ? _random.nextDouble() * size.height : -_random.nextDouble() * 40,
speed: 600 + _random.nextDouble() * 400,
length: 12 + _random.nextDouble() * 18,
);
}
void _tick() {
if (_lastSize == Size.zero) return;
final int targetCount = _dropCount();
while (_drops.length < targetCount) {
_drops.add(_createDrop(_lastSize, randomY: true));
}
while (_drops.length > targetCount) {
_drops.removeLast();
}
final now = DateTime.now();
if (_lastTickTime == null) {
_lastTickTime = now;
return;
}
final double dt = now.difference(_lastTickTime!).inMilliseconds / 1000.0;
_lastTickTime = now;
// If the game was paused or there was a lag spike, skip physics update for this frame
if (dt > 0.1) return;
for (final drop in _drops) {
drop.y += drop.speed * dt;
if (drop.y > _lastSize.height) {
drop.y = -drop.length;
drop.x = _random.nextDouble() * _lastSize.width;
}
}
setState(() {});
}
double _rainOpacity() {
if (widget.skyPhase < 2) return 0.0;
return ((widget.skyPhase - 1) / 4).clamp(0.0, 1.0);
}There was a problem hiding this comment.
Pull request overview
This PR enhances the Flutter dinosaur runner with new gameplay mechanics and visual effects (double jump, dynamic sky gradients, rain overlay, and a collision flash), alongside documentation and some platform/build metadata updates.
Changes:
- Added score-driven sky gradient transitions and a decorative rain overlay layer.
- Implemented double jump behavior and tuned early-game obstacle spacing / base speed.
- Refreshed README presentation and updated Windows runner/build metadata + dependency lockfile.
Reviewed changes
Copilot reviewed 7 out of 9 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| windows/runner/Runner.rc | Updates Windows version resource macros for version stamping. |
| windows/flutter/CMakeLists.txt | Adds a fallback FLUTTER_TARGET_PLATFORM and uses it in the Flutter tool backend invocation. |
| screenshots/.gitkeep | Keeps the screenshots directory tracked in git. |
| README.md | Major README revamp with badges, screenshots, feature list, and setup/build guidance. |
| pubspec.lock | Updates locked dependency versions and SDK constraints. |
| lib/rain.dart | Introduces a CustomPainter-based rain particle overlay driven by game state/sky phase. |
| lib/main.dart | Integrates sky gradients, rain overlay, obstacle spacing tuning, and a death flash effect. |
| lib/dino.dart | Adds double-jump logic and a jump glow/pulse effect wrapper widget. |
| lib/constants.dart | Adds sky gradient palettes/intervals and increases base run speed. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| void dispose() { | ||
| gravityController.dispose(); | ||
| accelerationController.dispose(); | ||
| jumpVelocityController.dispose(); | ||
| runVelocityController.dispose(); | ||
| dayNightOffestController.dispose(); | ||
| _deathFlashController.dispose(); | ||
| super.dispose(); | ||
| } |
| // Game-over red flash overlay | ||
| AnimatedBuilder( | ||
| animation: _deathFlashAnimation, | ||
| builder: (context, _) { | ||
| return IgnorePointer( | ||
| child: Container( | ||
| color: Colors.red.withValues(alpha: _deathFlashAnimation.value), | ||
| ), |
| if (obstacleRect.right < 0) { | ||
| setState(() { | ||
| cacti.remove(cactus); | ||
| double extraGap = runDistance < 800 | ||
| ? 150 + (800 - runDistance) * 0.3 |
| ); | ||
| } | ||
|
|
||
| void _tick() { | ||
| if (_lastSize == Size.zero) return; | ||
| final int targetCount = _dropCount(); | ||
|
|
||
| while (_drops.length < targetCount) { | ||
| _drops.add(_createDrop(_lastSize, randomY: true)); | ||
| } | ||
| while (_drops.length > targetCount) { | ||
| _drops.removeLast(); | ||
| } | ||
|
|
||
| const double dt = 1 / 60; | ||
| for (final drop in _drops) { | ||
| drop.y += drop.speed * dt; | ||
| if (drop.y > _lastSize.height) { | ||
| drop.y = -drop.length; | ||
| drop.x = _random.nextDouble() * _lastSize.width; | ||
| } | ||
| } | ||
| setState(() {}); | ||
| } |
| @override | ||
| void didUpdateWidget(RainEffect oldWidget) { | ||
| super.didUpdateWidget(oldWidget); | ||
| if (widget.gameController.isAnimating && !_rainController.isAnimating) { | ||
| _rainController.forward(); | ||
| } | ||
| } |
| <h1 align="center">Flutter Dino Runner</h1> | ||
| <p align="center"> | ||
| <strong>Chrome Dinosaur Game — Reimagined in Flutter</strong> | ||
| </p> | ||
| <p align="center"> | ||
| A cross-platform dinosaur runner game inspired by Chrome's offline dino, built with Flutter. Features double jump, dynamic day-to-night sky transitions, animated rain particles, jump glow effects, and a game-over flash — all running at 60fps with custom painters and animation controllers. | ||
| </p> | ||
| </p> |
| void didUpdateWidget(_DinoWidget oldWidget) { | ||
| super.didUpdateWidget(oldWidget); | ||
| if (widget.state == DinoState.jumping && _lastState != DinoState.jumping) { | ||
| _pulseController.forward(from: 0.0).then((_) => _pulseController.reverse()); | ||
| } | ||
| _lastState = widget.state; |
| # Set fallback configurations for older versions of the flutter tool. | ||
| if (NOT DEFINED FLUTTER_TARGET_PLATFORM) | ||
| set(FLUTTER_TARGET_PLATFORM "windows-x64") | ||
| endif() |
| dino.state = DinoState.running; | ||
| dino.dispY = 0; | ||
| worldController.reset(); |
Summary
This PR adds new visual effects and gameplay improvements to the dinosaur game, along with some repository cleanup.
Changes
Gameplay
Visual Effects
Repository Cleanup
windows/platform folder (C++/CMake desktop runner, not targeted)web/platform folder for browser-based testing/demoandroid/orios/platform foldersTesting
Notes