Skip to content

Add color effects, rain animation, double jump, and difficulty tuning#4

Open
BlackAlpha-debug wants to merge 4 commits into
HeveshL:mainfrom
BlackAlpha-debug:main
Open

Add color effects, rain animation, double jump, and difficulty tuning#4
BlackAlpha-debug wants to merge 4 commits into
HeveshL:mainfrom
BlackAlpha-debug:main

Conversation

@BlackAlpha-debug

Copy link
Copy Markdown

Summary

This PR adds new visual effects and gameplay improvements to the dinosaur game, along with some repository cleanup.

Changes

Gameplay

  • Added double jump ability to the dino
  • Increased game speed multiplier to 1.75x
  • Reduced obstacle spawn frequency until score reaches 800, for a smoother early-game difficulty curve

Visual Effects

  • Added score-based background color transitions (day → sunset → night)
  • Added dino tint/glow effect on jump and a red flash effect on collision
  • Added an animated rain overlay (CustomPainter-based), with intensity tied to the score-based color transitions — rain becomes more visible as the sky darkens

Repository Cleanup

  • Removed unused windows/ platform folder (C++/CMake desktop runner, not targeted)
  • Removed and later re-added web/ platform folder for browser-based testing/demo
  • No changes to android/ or ios/ platform folders

Testing

  • Verified the app builds and runs correctly in Chrome (web) after platform folder changes
  • Manually tested double jump, color transitions, rain effect, and obstacle spacing across multiple play sessions

Notes

  • No changes made to core collision detection logic
  • Rain layer is purely decorative (ignores pointer/hit-testing, correct z-order)

…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.
Copilot AI review requested due to automatic review settings July 12, 2026 09:02

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/main.dart
Comment on lines +94 to +101
List<Color> _getSkyColors() {
int phase = (runDistance ~/ skyPhaseInterval)
.clamp(0, skyGradients.length - 1);
if (phase != _currentSkyPhase) {
_currentSkyPhase = phase;
}
return skyGradients[_currentSkyPhase];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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];
  }

Comment thread lib/main.dart
Comment on lines +177 to 180
double extraGap = runDistance < 800
? 150 + (800 - runDistance) * 0.3
: 0;
cacti.add(Cactus(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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

Comment thread lib/dino.dart
Comment on lines +120 to +139
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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;
  }

Comment thread lib/rain.dart
Comment on lines +59 to +153
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();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/main.dart
Comment on lines 232 to 240
void dispose() {
gravityController.dispose();
accelerationController.dispose();
jumpVelocityController.dispose();
runVelocityController.dispose();
dayNightOffestController.dispose();
_deathFlashController.dispose();
super.dispose();
}
Comment thread lib/main.dart
Comment on lines +323 to 330
// Game-over red flash overlay
AnimatedBuilder(
animation: _deathFlashAnimation,
builder: (context, _) {
return IgnorePointer(
child: Container(
color: Colors.red.withValues(alpha: _deathFlashAnimation.value),
),
Comment thread lib/main.dart
Comment on lines 174 to +178
if (obstacleRect.right < 0) {
setState(() {
cacti.remove(cactus);
double extraGap = runDistance < 800
? 150 + (800 - runDistance) * 0.3
Comment thread lib/rain.dart
Comment on lines +110 to +133
);
}

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(() {});
}
Comment thread lib/rain.dart
Comment on lines +140 to +146
@override
void didUpdateWidget(RainEffect oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.gameController.isAnimating && !_rainController.isAnimating) {
_rainController.forward();
}
}
Comment thread README.md
Comment on lines +2 to +9
<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>
Comment thread lib/dino.dart
Comment on lines +132 to +137
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;
Comment on lines +12 to +15
# Set fallback configurations for older versions of the flutter tool.
if (NOT DEFINED FLUTTER_TARGET_PLATFORM)
set(FLUTTER_TARGET_PLATFORM "windows-x64")
endif()
Comment thread lib/main.dart
Comment on lines 123 to 125
dino.state = DinoState.running;
dino.dispY = 0;
worldController.reset();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants