Line data Source code
1 : import 'dart:math' as math; 2 : import 'dart:ui' show Rect, Canvas; 3 : 4 : import '../../components.dart'; 5 : import '../../extensions.dart'; 6 : import '../../game.dart'; 7 : import 'projector.dart'; 8 : 9 : /// A camera translates your game coordinate system; this is useful when your 10 : /// world is not 1:1 with your screen size. 11 : /// 12 : /// A camera always has a current [position], however you cannot set it 13 : /// directly. You must use some methods to ensure that the camera moves smoothly 14 : /// as the game runs. Smoothly here means that sudden snaps should be avoided, 15 : /// as they feel jarring to the player. 16 : /// 17 : /// There are three major factors that determine the camera position: 18 : /// 19 : /// * Follow 20 : /// If you want, you can call [followComponent] at the beginning of your 21 : /// stage/world/level, and provided a [PositionComponent]. 22 : /// The camera will follow this component making sure its position is fixed 23 : /// on the screen. 24 : /// You can set the relative position of the screen you want the follow 25 : /// object to stay in (normally the center), and you can even change that 26 : /// and get a smooth transition. 27 : /// 28 : /// * Move 29 : /// You can alternatively move the camera to a specific world coordinate. 30 : /// This will set the top left of the camera and will ignore any existing follow 31 : /// rules and move the camera smoothly until it reaches the desired destination. 32 : /// 33 : /// * Shake 34 : /// Regardless of the the previous rules, you can additionally add a shake 35 : /// effect for a brief period of time on top of the current coordinate. 36 : /// The shake adds a random immediate delta to each tick to simulate the shake 37 : /// effect. 38 : /// 39 : /// Note: in the context of the BaseGame, the camera effectively translates 40 : /// the position where components are rendered with relation to the Viewport. 41 : /// Components marked as `isHud = true` are always rendered in screen 42 : /// coordinates, bypassing the camera altogether. 43 : class Camera extends Projector { 44 : static const defaultSpeed = 50.0; // in pixels/s 45 : 46 : /// This must be set by the Game as soon as the Camera is created. 47 : /// 48 : /// Do not change this reference. 49 : late BaseGame gameRef; 50 : 51 : /// If set, this bypasses follow and moves the camera to a specific point 52 : /// in the world. 53 : /// 54 : /// You can use this if you are not using follow but have a few different 55 : /// camera positions or if you are using follow but you want to highlight a 56 : /// spot in the world during an animation. 57 : Vector2? _currentCameraDelta; 58 : Vector2? _targetCameraDelta; 59 : 60 : /// Remaining time in seconds for the camera shake. 61 : double _shakeTimer = 0.0; 62 : 63 : /// The intensity of the current shake action. 64 : double _shakeIntensity = 0.0; 65 : 66 : /// The matrix used for scaling and translating the canvas 67 : final Matrix4 _transform = Matrix4.identity(); 68 : 69 : // Configurable parameters 70 : 71 : double speed = defaultSpeed; 72 : double defaultShakeIntensity = 75.0; // in pixels 73 : double defaultShakeDuration = 0.3; // in seconds 74 : 75 : /// This is the current position of the camera, ie the world coordinate that 76 : /// is rendered on the top left of the screen (origin of the screen space). 77 : /// 78 : /// Zero means no translation is applied. 79 : /// You can't change this directly; the camera will handle all ongoing 80 : /// movements so they smoothly transition. 81 : /// If you want to immediately snap the camera to a new place, you can do: 82 : /// ``` 83 : /// camera.snapTo(newPosition); 84 : /// ``` 85 9 : Vector2 get position => _internalPosition.clone(); 86 : 87 : /// Do not change this directly since it bypasses [onPositionUpdate] 88 : final Vector2 _internalPosition = Vector2.zero(); 89 : 90 12 : Vector2 get _position => _internalPosition; 91 22 : set _position(Vector2 position) { 92 44 : _internalPosition.setFrom(position); 93 44 : onPositionUpdate(_internalPosition); 94 : } 95 : 96 : /// If set, the camera will "follow" this vector, making sure that this 97 : /// vector is always rendered in a fixed position in the screen, by 98 : /// immediately moving the camera to "focus" on the where the vector is. 99 : /// 100 : /// You might want to set it to the player component by using the 101 : /// [followComponent] method. 102 : /// Note that this is not smooth because the movement of the followed vector 103 : /// is assumed to be smooth. 104 : Vector2? follow; 105 : 106 : /// Where in the screen the follow object should be. 107 : /// 108 : /// This is a fractional value relating to the screen size. 109 : /// Changing this will smoothly move the camera to the new position 110 : /// (unless you use the followObject method that immediately sets 111 : /// up the camera for the new parameters). 112 0 : Vector2 get relativeOffset => _currentRelativeOffset; 113 : 114 : final Vector2 _currentRelativeOffset = Vector2.zero(); 115 : final Vector2 _targetRelativeOffset = Vector2.zero(); 116 : 117 : /// If set, this determines boundaries for the camera movement. 118 : /// 119 : /// The camera will never move such that a region outside the world boundaries 120 : /// is shown, meaning it will stop following when the object gets close to the 121 : /// edges. 122 : /// 123 : /// Changing this value can immediately snap the camera if it is a wrong 124 : /// position, but other than that it's just prevent movement so should not 125 : /// add any non-smooth movement. 126 : Rect? worldBounds; 127 : 128 : /// If set, the camera will zoom by this ratio. This can be greater than 1 129 : /// (zoom in) or smaller (zoom out), but should always be greater than zero. 130 : /// 131 : /// Note: do not confuse this with the zoom applied by the viewport. The 132 : /// viewport applies a (normally) fixed zoom to adapt multiple screens into 133 : /// one aspect ratio. The zoom might be different per dimension depending 134 : /// on the Viewport implementation. Also, if used with the default 135 : /// BaseGame implementation, it will apply to all components. 136 : /// The zoom from the camera is only for components that respect camera, 137 : /// and is applied after the viewport is set. It exists to be used if there 138 : /// is any kind of user configurable camera on your game. 139 : double zoom = 1.0; 140 : 141 24 : Camera(); 142 : 143 : /// Use this method to transform the canvas using the current rules provided 144 : /// by this camera object. 145 : /// 146 : /// If you are using BaseGame, this will be done for you for all non-HUD 147 : /// components. 148 : /// When using this method you are responsible for saving/restoring canvas 149 : /// state to avoid leakage. 150 3 : void apply(Canvas canvas) { 151 15 : canvas.transform(_transformMatrix(position, zoom).storage); 152 : } 153 : 154 3 : Matrix4 _transformMatrix(Vector2 position, double zoom) { 155 12 : final translateX = -_position.x * zoom; 156 12 : final translateY = -_position.y * zoom; 157 9 : if (_transform.m11 == zoom && 158 9 : _transform.m22 == zoom && 159 9 : _transform.m33 == zoom && 160 9 : _transform.m41 == translateX && 161 9 : _transform.m42 == translateY) { 162 3 : return _transform; 163 : } 164 2 : _transform.setIdentity(); 165 2 : _transform.translate(translateX, translateY); 166 2 : _transform.scale(zoom); 167 1 : return _transform; 168 : } 169 : 170 : /// This smoothly updates the camera for an amount of time [dt]. 171 : /// 172 : /// This should be called by the Game class during the update cycle. 173 22 : void update(double dt) { 174 44 : final ds = speed * dt; 175 22 : final shake = _shakeDelta(); 176 : 177 66 : _currentRelativeOffset.moveToTarget(_targetRelativeOffset, ds); 178 26 : if (_targetCameraDelta != null && _currentCameraDelta != null) { 179 12 : _currentCameraDelta?.moveToTarget(_targetCameraDelta!, ds); 180 : } 181 66 : _position = _target()..add(shake); 182 : 183 22 : if (shaking) { 184 2 : _shakeTimer -= dt; 185 2 : if (_shakeTimer < 0.0) { 186 1 : _shakeTimer = 0.0; 187 : } 188 : } 189 : } 190 : 191 : /// Use this to immediately "snap" the camera to where it should be right 192 : /// now. This bypasses any currently smooth transitions and might be janky, 193 : /// but can be used to setup after a new world transition for example. 194 4 : void snap() { 195 8 : if (_targetCameraDelta != null && _currentCameraDelta != null) { 196 12 : _currentCameraDelta!.setFrom(_targetCameraDelta!); 197 : } 198 12 : _currentRelativeOffset.setFrom(_targetRelativeOffset); 199 4 : update(0); 200 : } 201 : 202 : // Coordinates 203 : 204 5 : @override 205 : Vector2 unprojectVector(Vector2 screenCoordinates) { 206 20 : return _position + (screenCoordinates / zoom); 207 : } 208 : 209 1 : @override 210 : Vector2 projectVector(Vector2 worldCoordinates) { 211 4 : return (worldCoordinates - _position) * zoom; 212 : } 213 : 214 1 : @override 215 : Vector2 unscaleVector(Vector2 screenCoordinates) { 216 2 : return screenCoordinates / zoom; 217 : } 218 : 219 1 : @override 220 : Vector2 scaleVector(Vector2 worldCoordinates) { 221 2 : return worldCoordinates * zoom; 222 : } 223 : 224 : /// Takes coordinates in the screen space and returns their counter-part in 225 : /// the world space. 226 0 : Vector2 screenToWorld(Vector2 screenCoordinates) { 227 0 : return unprojectVector(screenCoordinates); 228 : } 229 : 230 : /// Takes coordinates in the world space and returns their counter-part in 231 : /// the screen space. 232 0 : Vector2 worldToScreen(Vector2 worldCoordinates) { 233 0 : return projectVector(worldCoordinates); 234 : } 235 : 236 : /// This is the (current) absolute target of the camera, i.e., the 237 : /// coordinate that should with `relativeOffset` taken into consideration but 238 : /// regardless of world boundaries or shake. 239 22 : Vector2 absoluteTarget() { 240 66 : return _currentCameraDelta ?? follow ?? Vector2.zero(); 241 : } 242 : 243 : // Follow 244 : 245 : /// Immediately snaps the camera to start following the [component]. 246 : /// 247 : /// This means that the camera will move so that the position vector of the 248 : /// component is in a fixed position on the screen. 249 : /// That position is determined by a fraction of screen size defined by 250 : /// [relativeOffset] (default to the center). 251 : /// [worldBounds] can be optionally set to add boundaries to how far the 252 : /// camera is allowed to move. 253 : /// The component is "grabbed" by its anchor (default top left). 254 : /// So for example if you want the center of the object to be at the fixed 255 : /// position, set the components anchor to center. 256 1 : void followComponent( 257 : PositionComponent component, { 258 : Anchor relativeOffset = Anchor.center, 259 : Rect? worldBounds, 260 : }) { 261 1 : followVector2( 262 1 : component.position, 263 : relativeOffset: relativeOffset, 264 : worldBounds: worldBounds, 265 : ); 266 : } 267 : 268 : /// Immediately snaps the camera to start following [vector2]. 269 : /// 270 : /// This means that the camera will move so that the position vector is in a 271 : /// fixed position on the screen. 272 : /// That position is determined by a fraction of screen size defined by 273 : /// [relativeOffset] (default to the center). 274 : /// [worldBounds] can be optionally set to add boundaries to how far the 275 : /// camera is allowed to move. 276 1 : void followVector2( 277 : Vector2 vector2, { 278 : Anchor relativeOffset = Anchor.center, 279 : Rect? worldBounds, 280 : }) { 281 1 : follow = vector2; 282 1 : this.worldBounds = worldBounds; 283 3 : _targetRelativeOffset.setFrom(relativeOffset.toVector2()); 284 3 : _currentRelativeOffset.setFrom(_targetRelativeOffset); 285 : } 286 : 287 : /// This will trigger a smooth transition to a new relative offset. 288 : /// 289 : /// You can use this for example to change camera modes in your game, maybe 290 : /// you have two different options for the player to choose or your have a 291 : /// "dialog" camera that puts the player in a better place to show the 292 : /// dialog UI. 293 2 : void setRelativeOffset(Anchor newRelativeOffset) { 294 6 : _targetRelativeOffset.setFrom(newRelativeOffset.toVector2()); 295 : } 296 : 297 22 : Vector2 _screenDelta() { 298 110 : return gameRef.size.clone()..multiply(_currentRelativeOffset); 299 : } 300 : 301 22 : Vector2 _target() { 302 22 : final target = absoluteTarget(); 303 44 : final attemptedTarget = target - _screenDelta(); 304 : 305 22 : final bounds = worldBounds; 306 : if (bounds != null) { 307 7 : if (bounds.width > gameRef.size.x * zoom) { 308 1 : final cameraLeftEdge = attemptedTarget.x; 309 5 : final cameraRightEdge = attemptedTarget.x + gameRef.size.x; 310 2 : if (cameraLeftEdge < bounds.left) { 311 2 : attemptedTarget.x = bounds.left; 312 2 : } else if (cameraRightEdge > bounds.right) { 313 6 : attemptedTarget.x = bounds.right - gameRef.size.x; 314 : } 315 : } else { 316 7 : attemptedTarget.x = (gameRef.size.x - bounds.width) / 2; 317 : } 318 : 319 7 : if (bounds.height > gameRef.size.y * zoom) { 320 1 : final cameraTopEdge = attemptedTarget.y; 321 5 : final cameraBottomEdge = attemptedTarget.y + gameRef.size.y; 322 2 : if (cameraTopEdge < bounds.top) { 323 2 : attemptedTarget.y = bounds.top; 324 2 : } else if (cameraBottomEdge > bounds.bottom) { 325 6 : attemptedTarget.y = bounds.bottom - gameRef.size.y; 326 : } 327 : } else { 328 7 : attemptedTarget.y = (gameRef.size.y - bounds.height) / 2; 329 : } 330 : } 331 : 332 : return attemptedTarget; 333 : } 334 : 335 : // Movement 336 : 337 : /// Moves the camera by a given [displacement] (delta). This is the same as 338 : /// [moveTo] but instead of providing an absolute end position, you can 339 : /// provide a desired translation vector. 340 0 : void translateBy(Vector2 displacement) { 341 0 : moveTo(absoluteTarget() + displacement); 342 : } 343 : 344 : /// Applies an ad-hoc movement to the camera towards the target, bypassing 345 : /// follow. Once it arrives the camera will not move until [resetMovement] 346 : /// is called. 347 : /// 348 : /// The camera will be smoothly transitioned to this position. 349 : /// This will replace any previous targets. 350 4 : void moveTo(Vector2 position) { 351 16 : _currentCameraDelta = _position + _screenDelta(); 352 8 : _targetCameraDelta = position.clone(); 353 : } 354 : 355 : /// Instantly moves the camera to the target, bypassing follow. 356 : /// This will replace any previous targets. 357 3 : void snapTo(Vector2 position) { 358 3 : moveTo(position); 359 3 : snap(); 360 : } 361 : 362 : /// Smoothly resets any moveTo targets. 363 0 : void resetMovement() { 364 0 : _currentCameraDelta = null; 365 0 : _targetCameraDelta = null; 366 : } 367 : 368 : // Shake 369 : 370 : /// Applies a shaking effect to the camera for [duration] seconds and with 371 : /// [intensity] expressed in pixels. 372 1 : void shake({double? duration, double? intensity}) { 373 2 : _shakeTimer += duration ?? defaultShakeDuration; 374 2 : _shakeIntensity = intensity ?? defaultShakeIntensity; 375 : } 376 : 377 : /// Whether the camera is currently shaking or not. 378 66 : bool get shaking => _shakeTimer > 0.0; 379 : 380 : /// Buffer to re-use for the shake delta. 381 : final _shakeBuffer = Vector2.zero(); 382 : 383 : /// The random number generator to use for shaking 384 : final _shakeRng = math.Random(); 385 : 386 : /// Generates one value between [-1, 1] * [_shakeIntensity] used once for each 387 : /// of the axis in the shake delta. 388 7 : double _shakeValue() => (_shakeRng.nextDouble() - 0.5) * 2 * _shakeIntensity; 389 : 390 : /// Generates a random [Vector2] of displacement applied to the camera. 391 : /// This will be a random [Vector2] every tick causing a shakiness effect. 392 22 : Vector2 _shakeDelta() { 393 22 : if (shaking) { 394 4 : _shakeBuffer.setValues(_shakeValue(), _shakeValue()); 395 44 : } else if (!_shakeBuffer.isZero()) { 396 2 : _shakeBuffer.setZero(); 397 : } 398 22 : return _shakeBuffer; 399 : } 400 : 401 : /// If you need updated on when the position of the camera is updated you 402 : /// can override this. 403 22 : void onPositionUpdate(Vector2 position) {} 404 : }