소스 검색

Add bubble collision detection and device tilt features

- Implement physics-based bubble collision system using Flame collision detection
- Add device tilt detection with sensors_plus for responsive bubble movement
- Create TiltDetector service with accelerometer filtering and lifecycle management
- Update Bubble component with CollisionCallbacks, velocity physics, and tilt response
- Add CircleHitbox to bubbles for accurate collision detection
- Implement collision response with realistic bouncing and damping
- Add applyTiltForce method for intuitive tilt-based movement
- Update ZenTapGame to integrate tilt detection and collision system
- Add proper pause/resume handling for tilt detection
- Update dependencies: sensors_plus ^6.1.1
- Add comprehensive documentation for new features
Fszontagh 9 달 전
부모
커밋
41cf0021c2
6개의 변경된 파일301개의 추가작업 그리고 21개의 파일을 삭제
  1. 66 0
      COLLISION_AND_TILT_FEATURES.md
  2. 126 20
      lib/game/components/bubble.dart
  3. 26 0
      lib/game/zentap_game.dart
  4. 79 0
      lib/utils/tilt_detector.dart
  5. 1 1
      pubspec.lock
  6. 3 0
      pubspec.yaml

+ 66 - 0
COLLISION_AND_TILT_FEATURES.md

@@ -0,0 +1,66 @@
+# ZenTap - New Features
+
+## Recently Added Features
+
+### 🎯 Bubble Collision Detection
+- **Physics-based Movement**: Bubbles now move with realistic physics including velocity, friction, and collision response
+- **Collision Detection**: When bubbles collide with each other, they bounce off and change direction naturally
+- **Visual Feedback**: Smooth collision animations that make bubble interactions feel more dynamic and satisfying
+
+### 📱 Device Tilt Detection
+- **Accelerometer Integration**: Uses the device's built-in sensors to detect when the phone is tilted
+- **Responsive Movement**: When you tilt your device left or right, bubbles will gently drift in the opposite direction
+- **Smooth Experience**: Tilt detection is filtered to prevent jittery movement and provide a calm, relaxing experience
+
+## How It Works
+
+### Collision System
+1. Each bubble has a circular collision hitbox that matches its visual size
+2. When two bubbles overlap, the collision system calculates the collision direction
+3. Bubbles push away from each other with realistic physics
+4. Collision damping prevents bubbles from bouncing indefinitely
+
+### Tilt Detection
+1. The accelerometer monitors device orientation changes
+2. Tilt angles are filtered and normalized to provide smooth input
+3. Only significant tilts (> 5 degrees) trigger bubble movement
+4. Bubbles receive gentle forces opposite to the tilt direction for intuitive feel
+
+## Technical Implementation
+
+### Dependencies Added
+- `sensors_plus: ^6.1.1` - For accelerometer/gyroscope access
+
+### Key Components
+- `TiltDetector` - Service class that manages accelerometer data and provides clean tilt callbacks
+- `Bubble` class enhanced with:
+  - `CollisionCallbacks` mixin for collision detection
+  - Physics properties (velocity, friction, collision damping)
+  - `applyTiltForce()` method for responding to device tilt
+
+### Performance Optimizations
+- Low-pass filtering on accelerometer data to reduce noise
+- Efficient collision detection using Flame's built-in collision system
+- Velocity clamping to prevent excessive speeds
+- Periodic cleanup of physics calculations
+
+## User Experience
+
+### Play Mode & Zen Mode
+Both game modes now feature:
+- More dynamic bubble movement due to physics system
+- Responsive bubble interactions when they collide
+- Subtle tilt-based movement for enhanced relaxation
+
+### Accessibility
+- Tilt sensitivity is tuned for comfortable use
+- Works in both portrait and landscape orientations
+- Tilt detection automatically pauses when game is paused
+
+## Future Enhancements
+
+Potential additions could include:
+- Adjustable tilt sensitivity in settings
+- Different collision sound effects
+- Particle effects on bubble collisions
+- Multi-touch gesture support for enhanced bubble spawning

+ 126 - 20
lib/game/components/bubble.dart

@@ -1,11 +1,13 @@
 import 'dart:math';
+import 'dart:async';
 import 'package:flame/components.dart';
 import 'package:flame/effects.dart';
 import 'package:flame/events.dart';
+import 'package:flame/collisions.dart';
 import 'package:flutter/material.dart';
 import '../../utils/colors.dart';
 
-class Bubble extends SpriteComponent with HasGameReference, TapCallbacks {
+class Bubble extends SpriteComponent with HasGameReference, TapCallbacks, CollisionCallbacks {
   static const double startSize = 60.0; // Increased from 40.0
   static const double maxSize = 120.0; // Increased from 100.0
   static const double lifecycleDuration = 12.0; // seconds
@@ -20,6 +22,13 @@ class Bubble extends SpriteComponent with HasGameReference, TapCallbacks {
   double _lifecycleProgress = 0.0;
   late double _targetSize;
   
+  // Physics properties for collision and movement
+  Vector2 velocity = Vector2.zero();
+  static const double maxSpeed = 50.0;
+  static const double friction = 0.98;
+  static const double collisionDamping = 0.7;
+  double _floatTimer = 0.0;
+  
   Bubble({
     required Vector2 position,
     this.onPop,
@@ -44,6 +53,12 @@ class Bubble extends SpriteComponent with HasGameReference, TapCallbacks {
     
     await super.onLoad();
     
+    // Add circular collision hitbox
+    add(CircleHitbox(
+      radius: _targetSize / 2,
+      anchor: Anchor.center,
+    ));
+    
     // Start with zero scale for smooth entrance animation
     scale = Vector2.zero();
     
@@ -53,6 +68,13 @@ class Bubble extends SpriteComponent with HasGameReference, TapCallbacks {
     // Add varied, organic floating animations
     _addRandomFloatingAnimation();
     _addRandomRotationAnimation();
+    
+    // Initialize random velocity for natural movement
+    final random = Random();
+    velocity = Vector2(
+      (random.nextDouble() - 0.5) * 20, // -10 to +10
+      (random.nextDouble() - 0.5) * 20, // -10 to +10
+    );
   }
 
   @override
@@ -73,11 +95,65 @@ class Bubble extends SpriteComponent with HasGameReference, TapCallbacks {
     final opacityFactor = 1.0 - (_lifecycleProgress * 0.6); // Fade to 40% opacity
     opacity = opacityFactor.clamp(0.4, 1.0);
     
+    // Physics-based movement
+    _updatePhysics(dt);
+    
+    // Keep bubble within screen bounds
+    _keepInBounds();
+    
     // Auto-pop when lifecycle is complete
     if (_lifecycleProgress >= 1.0) {
       pop(userTriggered: false);
     }
   }
+  
+  void _updatePhysics(double dt) {
+    // Apply velocity to position
+    position.add(velocity * dt);
+    
+    // Apply friction
+    velocity.scale(friction);
+    
+    // Add periodic floating effects
+    _floatTimer += dt;
+    if (_floatTimer > 2.0) {
+      _floatTimer = 0.0;
+      final random = Random();
+      final floatForce = Vector2(
+        (random.nextDouble() - 0.5) * 8, // -4 to +4 horizontal
+        (random.nextDouble() - 0.5) * 8, // -4 to +4 vertical
+      );
+      velocity.add(floatForce);
+    }
+    
+    // Clamp velocity to max speed
+    if (velocity.length > maxSpeed) {
+      velocity.normalize();
+      velocity.scale(maxSpeed);
+    }
+  }
+  
+  void _keepInBounds() {
+    const margin = 60.0;
+    final gameSize = game.size;
+    
+    // Bounce off screen edges
+    if (position.x < margin) {
+      position.x = margin;
+      velocity.x = velocity.x.abs(); // Bounce right
+    } else if (position.x > gameSize.x - margin) {
+      position.x = gameSize.x - margin;
+      velocity.x = -velocity.x.abs(); // Bounce left
+    }
+    
+    if (position.y < margin + 100) { // Account for UI at top
+      position.y = margin + 100;
+      velocity.y = velocity.y.abs(); // Bounce down
+    } else if (position.y > gameSize.y - margin) {
+      position.y = gameSize.y - margin;
+      velocity.y = -velocity.y.abs(); // Bounce up
+    }
+  }
 
   Map<String, dynamic> _getRandomBubbleData() {
     final random = Random();
@@ -105,26 +181,11 @@ class Bubble extends SpriteComponent with HasGameReference, TapCallbacks {
   void _addRandomFloatingAnimation() {
     final random = Random();
     
-    // Random floating direction and amplitude
-    final floatDirection = Vector2(
-      (random.nextDouble() - 0.5) * 30, // -15 to +15 horizontal
-      -5 - random.nextDouble() * 15,    // -5 to -20 vertical (upward bias)
-    );
-    
-    // Random duration between 2.5 and 6 seconds
-    final duration = 2.5 + random.nextDouble() * 3.5;
+    // Add a small upward bias to the initial velocity
+    velocity.y += -5 - random.nextDouble() * 10; // -5 to -15 upward bias
     
-    add(
-      MoveEffect.by(
-        floatDirection,
-        EffectController(
-          duration: duration,
-          alternate: true,
-          infinite: true,
-          curve: Curves.easeInOut,
-        ),
-      ),
-    );
+    // The floating effect will now be handled by the physics system
+    // and periodic velocity changes in the update method
   }
 
   void _addEntranceAnimation() {
@@ -381,4 +442,49 @@ class Bubble extends SpriteComponent with HasGameReference, TapCallbacks {
       ),
     );
   }
+
+  // Collision detection methods
+  @override
+  bool onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
+    super.onCollision(intersectionPoints, other);
+    
+    if (other is Bubble && !isPopping && !other.isPopping) {
+      _handleBubbleCollision(other, intersectionPoints);
+    }
+    return true;
+  }
+  
+  void _handleBubbleCollision(Bubble other, Set<Vector2> intersectionPoints) {
+    if (intersectionPoints.isEmpty) return;
+    
+    // Calculate collision direction
+    final direction = (position - other.position).normalized();
+    
+    // Apply collision response - bubbles bounce off each other
+    final collisionForce = 30.0;
+    velocity.add(direction * collisionForce);
+    other.velocity.add(direction * -collisionForce);
+    
+    // Apply damping to prevent infinite acceleration
+    velocity.scale(collisionDamping);
+    other.velocity.scale(collisionDamping);
+    
+    // Add slight separation to prevent sticking
+    final separation = direction * 2.0;
+    position.add(separation);
+    other.position.sub(separation);
+  }
+  
+  /// Apply tilt force to bubble based on device orientation
+  void applyTiltForce(double tiltStrength) {
+    if (isPopping) return;
+    
+    // Apply force opposite to tilt direction
+    // If device tilts right, bubbles move left and vice versa
+    final tiltForce = -tiltStrength * 15.0; // Adjust force strength
+    velocity.x += tiltForce;
+    
+    // Add slight vertical component for more natural movement
+    velocity.y += tiltForce * 0.3;
+  }
 }

+ 26 - 0
lib/game/zentap_game.dart

@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
 import 'dart:math';
 import '../utils/colors.dart';
 import '../utils/score_manager.dart';
+import '../utils/tilt_detector.dart';
 import 'components/bubble.dart';
 import 'components/bubble_spawner.dart';
 import 'audio/audio_manager.dart';
@@ -21,6 +22,8 @@ class ZenTapGame extends FlameGame with HasCollisionDetection {
   
   BubbleSpawner? bubbleSpawner;
   final AudioManager audioManager = AudioManager();
+  final TiltDetector _tiltDetector = TiltDetector();
+  final TiltDetector tiltDetector = TiltDetector();
   
   // Performance optimization: cache last displayed second to avoid unnecessary text updates
   int _lastDisplayedSecond = -1;
@@ -70,6 +73,26 @@ class ZenTapGame extends FlameGame with HasCollisionDetection {
     
     // Start background music after all initialization is complete
     await audioManager.playBackgroundMusic();
+    
+    // Start tilt detection
+    _initializeTiltDetection();
+  }
+  
+  void _initializeTiltDetection() {
+    _tiltDetector.startListening(
+      onTiltChanged: (tiltAngle) {
+        _applyTiltToAllBubbles(_tiltDetector.normalizedTilt);
+      },
+    );
+  }
+  
+  void _applyTiltToAllBubbles(double tiltStrength) {
+    if (bubbleSpawner == null) return;
+    
+    final activeBubbles = bubbleSpawner!.getActiveBubbles();
+    for (final bubble in activeBubbles) {
+      bubble.applyTiltForce(tiltStrength);
+    }
   }
 
   Future<void> _initializeGame() async {
@@ -286,12 +309,14 @@ class ZenTapGame extends FlameGame with HasCollisionDetection {
     paused = true;
     gameActive = false;
     audioManager.pauseBackgroundMusic();
+    _tiltDetector.stopListening();
   }
 
   void resumeGame() {
     paused = false;
     gameActive = true;
     audioManager.resumeBackgroundMusic();
+    _initializeTiltDetection();
   }
 
   void handleShake() {
@@ -328,6 +353,7 @@ class ZenTapGame extends FlameGame with HasCollisionDetection {
   @override
   void onRemove() {
     audioManager.stopBackgroundMusic();
+    _tiltDetector.stopListening();
     super.onRemove();
   }
 }

+ 79 - 0
lib/utils/tilt_detector.dart

@@ -0,0 +1,79 @@
+import 'dart:async';
+import 'dart:math' as math;
+import 'package:sensors_plus/sensors_plus.dart';
+
+class TiltDetector {
+  static final TiltDetector _instance = TiltDetector._internal();
+  factory TiltDetector() => _instance;
+  TiltDetector._internal();
+
+  StreamSubscription<AccelerometerEvent>? _accelerometerSubscription;
+  Function(double)? onTiltChanged;
+  
+  bool _isListening = false;
+  double _currentTilt = 0.0;
+  
+  // Filtering parameters to smooth out noise
+  static const double _alpha = 0.8; // Low-pass filter constant
+  double _filteredX = 0.0;
+  
+  bool get isListening => _isListening;
+  double get currentTilt => _currentTilt;
+
+  /// Start listening to accelerometer data
+  /// [onTiltChanged] callback receives tilt angle in degrees
+  /// Positive values = device tilted right, negative = tilted left
+  void startListening({Function(double)? onTiltChanged}) {
+    if (_isListening) return;
+    
+    this.onTiltChanged = onTiltChanged;
+    _isListening = true;
+    
+    _accelerometerSubscription = accelerometerEventStream(
+      samplingPeriod: const Duration(milliseconds: 100), // 10 Hz sampling rate
+    ).listen(_onAccelerometerEvent);
+  }
+  
+  /// Stop listening to accelerometer data
+  void stopListening() {
+    if (!_isListening) return;
+    
+    _accelerometerSubscription?.cancel();
+    _accelerometerSubscription = null;
+    _isListening = false;
+    onTiltChanged = null;
+    _currentTilt = 0.0;
+    _filteredX = 0.0;
+  }
+  
+  void _onAccelerometerEvent(AccelerometerEvent event) {
+    // Apply low-pass filter to smooth out noise
+    _filteredX = _alpha * _filteredX + (1 - _alpha) * event.x;
+    
+    // Calculate tilt angle in degrees
+    // The accelerometer X axis represents left-right tilt
+    // Clamp to reasonable tilt range (-45 to +45 degrees)
+    final tiltRadians = math.atan2(_filteredX, 9.8);
+    _currentTilt = (tiltRadians * 180 / math.pi).clamp(-45.0, 45.0);
+    
+    // Only trigger callback if tilt is significant (> 5 degrees)
+    if (_currentTilt.abs() > 5.0) {
+      onTiltChanged?.call(_currentTilt);
+    }
+  }
+  
+  /// Get normalized tilt strength from -1.0 to 1.0
+  /// -1.0 = maximum left tilt, +1.0 = maximum right tilt
+  double get normalizedTilt {
+    return (_currentTilt / 45.0).clamp(-1.0, 1.0);
+  }
+  
+  /// Check if device is significantly tilted
+  bool get isTilted => _currentTilt.abs() > 5.0;
+  
+  /// Check if device is tilted left
+  bool get isTiltedLeft => _currentTilt < -5.0;
+  
+  /// Check if device is tilted right
+  bool get isTiltedRight => _currentTilt > 5.0;
+}

+ 1 - 1
pubspec.lock

@@ -409,7 +409,7 @@ packages:
     source: hosted
     version: "6.1.5"
   sensors_plus:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: sensors_plus
       sha256: "905282c917c6bb731c242f928665c2ea15445aa491249dea9d98d7c79dc8fd39"

+ 3 - 0
pubspec.yaml

@@ -53,6 +53,9 @@ dependencies:
   # Shake detection
   shake: ^3.0.0
   
+  # Device sensors for tilt detection
+  sensors_plus: ^6.1.1
+  
   # Charts for statistics
   fl_chart: ^1.0.0