15public sealed class Camera
17 private readonly
Scene _scene;
18 private PointF _positionPx =
new(0, 0);
19 private Func<PointF>? _followWorldPx;
20 private bool _hardFollow;
22 private int _followAxis;
23 private PointF _followAnchor =
new(0.5f, 0.5f);
24 private SceneLayer? TopologyLayer => _followLayer ?? _scene.SceneLayers.FirstOrDefault(layer => layer.Visible);
28 private PointF? _panTargetUpperLeftPx;
29 private float _panLerpPerSecond;
41 if (_positionPx != value)
45 if (_scene is not
null)
46 _scene.FullRefreshNeeded =
true;
65 public Rectangle
DeadZonePx {
get;
set; } = Rectangle.Empty;
74 internal Camera(
Scene scene) => _scene = scene ??
throw new ArgumentNullException(nameof(scene));
80 internal Func<SizeF> GetVisibleWorldSizePx {
get;
set; } = () =>
new SizeF(1280, 720);
82 #region Camera movement methods
92 public void SnapTo(PointF worldUpperLeftPx)
94 PositionPx = ClampToWorldBounds(worldUpperLeftPx);
107 var vis = GetVisibleWorldSizePx();
108 SnapTo(
new PointF(worldCenterPx.X - vis.Width * 0.5f,
109 worldCenterPx.Y - vis.Height * 0.5f));
124 float tileCenterX = anchor.X + layer.TileWidth * 0.5f;
125 float tileCenterY = anchor.Y + layer.TileHeight * 0.5f;
127 var vis = GetVisibleWorldSizePx();
130 var camTopLeft =
new PointF(
131 tileCenterX - vis.Width * 0.5f,
132 tileCenterY - vis.Height * 0.5f);
154 Follow(() => worldCenterPx, hardFollow:
false);
169 float tileCenterX = anchor.X + layer.TileWidth * 0.5f;
170 float tileCenterY = anchor.Y + layer.TileHeight * 0.5f;
172 PanCenterTo(
new PointF(tileCenterX, tileCenterY), speed);
187 public void PanTo(PointF worldTopLeftPx,
float speed)
192 _followWorldPx =
null;
197 _panTargetUpperLeftPx =
null;
202 _panLerpPerSecond = speed;
203 _panTargetUpperLeftPx = ClampToWorldBounds(worldTopLeftPx);
224 var vis = GetVisibleWorldSizePx();
226 var desiredUpperLeft =
new PointF(
227 worldCenterPx.X - vis.Width * 0.5f,
228 worldCenterPx.Y - vis.Height * 0.5f);
231 PanTo(desiredUpperLeft, speed);
249 if (durationSeconds <= 0f)
252 _panTargetUpperLeftPx =
null;
264 float k = -(float)Math.Log(0.01f) / durationSeconds;
269 _followWorldPx =
null;
272 _panLerpPerSecond = k;
273 _panTargetUpperLeftPx = ClampToWorldBounds(worldTopLeftPx);
293 var vis = GetVisibleWorldSizePx();
295 var desiredUpperLeft =
new PointF(
296 worldCenterPx.X - vis.Width * 0.5f,
297 worldCenterPx.Y - vis.Height * 0.5f);
319 if (layer is
null)
throw new ArgumentNullException(nameof(layer));
324 float tileCenterX = anchor.X + layer.TileWidth * 0.5f;
325 float tileCenterY = anchor.Y + layer.TileHeight * 0.5f;
347 internal void CancelPan()
349 _panTargetUpperLeftPx =
null;
350 _panLerpPerSecond = 0f;
368 public void Follow(Func<PointF> getWorldPixel,
bool hardFollow =
false)
370 FollowAt(getWorldPixel,
new PointF(0.5f, 0.5f), hardFollow);
396 public void FollowAt(Func<PointF> getWorldPixel, PointF viewportAnchor,
bool hardFollow =
false)
398 ArgumentNullException.ThrowIfNull(getWorldPixel);
399 ValidateViewportAnchor(viewportAnchor, nameof(viewportAnchor));
403 _followWorldPx = getWorldPixel;
404 _followAnchor = viewportAnchor;
405 _hardFollow = hardFollow;
416 public void FollowTo(PointF worldPointOfInterestPx)
419 Follow(() => worldPointOfInterestPx);
432 FollowAt(target,
new PointF(0.5f, 0.5f), speed, hard);
452 ArgumentNullException.ThrowIfNull(target);
453 ValidateViewportAnchor(viewportAnchor, nameof(viewportAnchor));
464 ? GetCenteredTile(layer, pos)
465 :
new PointF(pos.X, pos.Y);
501 ArgumentNullException.ThrowIfNull(target);
502 ValidateNormalizedAnchor(horizontalAnchor, nameof(horizontalAnchor));
515 var anchor = layer.GridToWorldPx(
new PointF(pos.X, pos.Y));
516 worldCenterX = anchor.X + layer.TileWidth * 0.5f;
520 worldCenterX = pos.X;
523 var vis = GetVisibleWorldSizePx();
528 new PointF(horizontalAnchor, 0.5f),
563 ArgumentNullException.ThrowIfNull(target);
564 ValidateNormalizedAnchor(verticalAnchor, nameof(verticalAnchor));
577 var anchor = layer.GridToWorldPx(
new PointF(pos.X, pos.Y));
578 worldCenterY = anchor.Y + layer.TileHeight * 0.5f;
582 worldCenterY = pos.Y;
585 var vis = GetVisibleWorldSizePx();
590 new PointF(0.5f, verticalAnchor),
604 _followWorldPx =
null;
605 _followAnchor =
new PointF(0.5f, 0.5f);
606 _panTargetUpperLeftPx =
null;
610 #endregion Camera movement methods
612 internal void Update(
float dtSeconds)
615 if (_panTargetUpperLeftPx is { } panTarget)
617 var clamped = ClampToWorldBounds(panTarget);
619 if (_hardFollow || _panLerpPerSecond <= 0f)
622 _panTargetUpperLeftPx =
null;
626 float t = 1f - (float)Math.Exp(-_panLerpPerSecond * Math.Max(0f, dtSeconds));
627 var newPos =
new PointF(
634 if (Math.Abs(newPos.X - clamped.X) < 0.5f &&
635 Math.Abs(newPos.Y - clamped.Y) < 0.5f)
638 _panTargetUpperLeftPx =
null;
643 if (_panTargetUpperLeftPx is not
null)
648 if (_followWorldPx is
null)
651 var target = _followWorldPx();
652 if (TopologyLayer is { } layer && (layer.WrapHorizontally || layer.WrapVertically))
654 var visible = GetVisibleWorldSizePx();
655 var followReference =
new PointF(
656 PositionPx.X + visible.Width * _followAnchor.X,
657 PositionPx.Y + visible.Height * _followAnchor.Y);
658 var period = layer.GetPeriod();
659 if (_followAxis == 1)
661 target = NearestWrappedByAxis(period, target, followReference, followX:
true);
662 target.Y = followReference.Y;
664 else if (_followAxis == 2)
666 target = NearestWrappedByAxis(period, target, followReference, followX:
false);
667 target.X = followReference.X;
671 target = period.Nearest(target, followReference);
674 var desiredUL = DesiredUpperLeftToContainTarget(target);
682 var clamped = ClampToWorldBounds(desiredUL);
689 #region private methods
691 private PointF DesiredUpperLeftToContainTarget(PointF targetWorldPx)
693 var vis = GetVisibleWorldSizePx();
696 targetWorldPx.X - vis.Width * _followAnchor.X,
697 targetWorldPx.Y - vis.Height * _followAnchor.Y);
700 var dzWorld =
new RectangleF(viewWorld.X +
DeadZonePx.X,
704 if (dzWorld.Contains(targetWorldPx))
710 if (targetWorldPx.X < dzWorld.Left)
711 newX -= dzWorld.Left - targetWorldPx.X;
713 if (targetWorldPx.X > dzWorld.Right)
714 newX += targetWorldPx.X - dzWorld.Right;
716 if (targetWorldPx.Y < dzWorld.Top)
717 newY -= dzWorld.Top - targetWorldPx.Y;
719 if (targetWorldPx.Y > dzWorld.Bottom)
720 newY += targetWorldPx.Y - dzWorld.Bottom;
722 return new PointF(newX, newY);
725 private static PointF NearestWrappedByAxis(LayerPeriod period, PointF point, PointF reference,
bool followX)
727 var delta =
new PointF(reference.X - point.X, reference.Y - point.Y);
728 var coefficients = period.Coefficients(delta);
729 int baseColumn = (int)Math.Round(coefficients.X);
730 int baseRow = (int)Math.Round(coefficients.Y);
732 int minColumn = period.WrapColumns ? baseColumn - 4 : 0;
733 int maxColumn = period.WrapColumns ? baseColumn + 4 : 0;
734 int minRow = period.WrapRows ? baseRow - 4 : 0;
735 int maxRow = period.WrapRows ? baseRow + 4 : 0;
737 var best = period.Nearest(point, reference);
738 float bestAxisDistance = followX ? Math.Abs(best.X - reference.X) : Math.Abs(best.Y - reference.Y);
739 float bestDistanceSquared = (best.X - reference.X) * (best.X - reference.X) + (best.Y - reference.Y) * (best.Y - reference.Y);
741 for (
int c = minColumn; c <= maxColumn; c++)
742 for (
int r = minRow; r <= maxRow; r++)
744 var offset = period.Offset(c, r);
745 var candidate =
new PointF(point.X + offset.X, point.Y + offset.Y);
746 float axisDistance = followX ? Math.Abs(candidate.X - reference.X) : Math.Abs(candidate.Y - reference.Y);
747 float distanceSquared = (candidate.X - reference.X) * (candidate.X - reference.X) +
748 (candidate.Y - reference.Y) * (candidate.Y - reference.Y);
749 if (axisDistance < bestAxisDistance ||
750 (axisDistance == bestAxisDistance && distanceSquared < bestDistanceSquared))
753 bestAxisDistance = axisDistance;
754 bestDistanceSquared = distanceSquared;
761 private PointF ClampToWorldBounds(PointF ul)
766 var vis = GetVisibleWorldSizePx();
767 if (TopologyLayer is { } layer && (layer.WrapHorizontally || layer.WrapVertically))
769 var period = layer.GetPeriod();
770 var position = period.Coefficients(ul);
771 PointF[] Corners(RectangleF rect) =>
new[] {
772 period.Coefficients(
new(rect.Left, rect.Top)), period.Coefficients(
new(rect.Right, rect.Top)),
773 period.Coefficients(
new(rect.Left, rect.Bottom)), period.Coefficients(
new(rect.Right, rect.Bottom)) };
775 var viewport = Corners(
new(0, 0, vis.Width, vis.Height));
776 float lowX = bounds.Min(p => p.X) - viewport.Min(p => p.X);
777 float lowY = bounds.Min(p => p.Y) - viewport.Min(p => p.Y);
778 float highX = Math.Max(lowX, bounds.Max(p => p.X) - viewport.Max(p => p.X));
779 float highY = Math.Max(lowY, bounds.Max(p => p.Y) - viewport.Max(p => p.Y));
780 return period.Offset(layer.WrapHorizontally ? position.X : Math.Clamp(position.X, lowX, highX),
781 layer.WrapVertically ? position.Y : Math.Clamp(position.Y, lowY, highY));
785 float maxX = WorldBoundsPx.Right - vis.Width;
786 float maxY = WorldBoundsPx.Bottom - vis.Height;
795 Math.Clamp(ul.X, minX, maxX),
796 Math.Clamp(ul.Y, minY, maxY));
799 private static void ValidateViewportAnchor(PointF viewportAnchor,
string paramName)
801 ValidateNormalizedAnchor(viewportAnchor.X, paramName);
802 ValidateNormalizedAnchor(viewportAnchor.Y, paramName);
805 private static void ValidateNormalizedAnchor(
float value,
string paramName)
807 if (!
float.IsFinite(value) || value < 0f || value > 1f)
808 throw new ArgumentOutOfRangeException(paramName, value,
"Camera follow anchors must be finite values from 0 through 1.");
818 private static PointF GetCenteredTile(SceneLayer layer, Vector2 gridPos)
820 var anchor = layer.
GridToWorldPx(
new PointF(gridPos.X, gridPos.Y));
826 #endregion private methods