Add a system for setting up "real" camera framing to work between devices (#26)

- In-level authoring utility to designate level bounds
- Camera Adapter component to be placed on a level's camera to perform the adjustments
- EdgeAnchor tool, which allows anchoring of game objects to the screen bounds

Co-authored-by: Michal Pikulski <michal@foolhardyhorizons.com>
Reviewed-on: #26
This commit is contained in:
2025-10-14 04:56:00 +00:00
parent aefff3d050
commit 2573e7f80e
12 changed files with 1261 additions and 134 deletions

View File

@@ -342,17 +342,17 @@ LineRenderer:
m_SortingLayer: 0
m_SortingOrder: 0
m_Positions:
- {x: -0.15602553, y: 3.8573277, z: 0}
- {x: -0.1566351, y: 3.753693, z: 0}
- {x: -0.1572447, y: 3.6518767, z: 0}
- {x: -0.15785426, y: 3.5518785, z: 0}
- {x: -0.15846384, y: 3.453699, z: 0}
- {x: -0.15907341, y: 3.3573375, z: 0}
- {x: -0.15968299, y: 3.2627945, z: 0}
- {x: -0.16029257, y: 3.1700697, z: 0}
- {x: -0.16090216, y: 3.0791638, z: 0}
- {x: -0.16151173, y: 2.9900756, z: 0}
- {x: -0.16212131, y: 2.9028063, z: 0}
- {x: -0.15602553, y: 4.0749445, z: 0}
- {x: -0.1566351, y: 3.9736378, z: 0}
- {x: -0.1572447, y: 3.8729858, z: 0}
- {x: -0.15785426, y: 3.7729874, z: 0}
- {x: -0.15846384, y: 3.6736438, z: 0}
- {x: -0.15907341, y: 3.5749543, z: 0}
- {x: -0.15968299, y: 3.4769194, z: 0}
- {x: -0.16029257, y: 3.3795385, z: 0}
- {x: -0.16090216, y: 3.2828126, z: 0}
- {x: -0.16151173, y: 3.1867406, z: 0}
- {x: -0.16212131, y: 3.0913231, z: 0}
m_Parameters:
serializedVersion: 3
widthMultiplier: 1
@@ -454,6 +454,7 @@ GameObject:
- component: {fileID: 224729333}
- component: {fileID: 224729332}
- component: {fileID: 224729334}
- component: {fileID: 224729335}
m_Layer: 0
m_Name: CinemachineCamera
m_TagString: Untagged
@@ -541,6 +542,21 @@ Animator:
m_AllowConstantClipSamplingOptimization: 1
m_KeepAnimatorStateOnDisable: 0
m_WriteDefaultValuesOnDisable: 0
--- !u!114 &224729335
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 224729330}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 8a71a21143bd4f4992d08829084d1e3b, type: 3}
m_Name:
m_EditorClassIdentifier: AppleHillsScripts::AppleHillsCamera.CameraScreenAdapter
referenceMarker: {fileID: 1651034645}
adjustOnStart: 1
adjustOnScreenResize: 1
--- !u!1 &323864663
GameObject:
m_ObjectHideFlags: 0
@@ -882,6 +898,7 @@ GameObject:
- component: {fileID: 747976404}
- component: {fileID: 747976405}
- component: {fileID: 747976406}
- component: {fileID: 747976407}
m_Layer: 0
m_Name: BottleMarine
m_TagString: Player
@@ -898,7 +915,7 @@ Transform:
m_GameObject: {fileID: 747976396}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 2.9799, z: 0}
m_LocalPosition: {x: 0, y: 3.1975174, z: 0}
m_LocalScale: {x: 0.57574, y: 0.57574, z: 0.57574}
m_ConstrainProportionsScale: 0
m_Children:
@@ -918,6 +935,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: d39dbaae819c4a128a11ca60fbbc98c9, type: 3}
m_Name:
m_EditorClassIdentifier:
edgeAnchor: {fileID: 747976407}
--- !u!114 &747976399
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -1089,6 +1107,31 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: AppleHillsScripts::Minigames.DivingForPictures.Utilities.BottlePauser
wobbleReference: {fileID: 747976399}
--- !u!114 &747976407
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 747976396}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: ed380d10e1e04ae7990e5c726c929063, type: 3}
m_Name:
m_EditorClassIdentifier: AppleHillsScripts::AppleHillsCamera.EdgeAnchor
referenceMarker: {fileID: 1651034645}
cameraAdapter: {fileID: 224729335}
anchorEdge: 0
useReferenceMargin: 0
customMargin: 2
adjustOnStart: 1
adjustOnScreenResize: 0
preserveOtherAxes: 1
accountForObjectSize: 1
showVisualization: 1
visualizationColor: {r: 1, g: 0, b: 1, a: 1}
showObjectBounds: 1
debugMode: 0
--- !u!1 &824396214
GameObject:
m_ObjectHideFlags: 0
@@ -1324,17 +1367,17 @@ LineRenderer:
m_SortingLayer: 0
m_SortingOrder: 0
m_Positions:
- {x: -0.15602553, y: 3.8573277, z: 0}
- {x: -0.11662118, y: 3.66006, z: 0}
- {x: -0.07721684, y: 3.4853156, z: 0}
- {x: -0.03781248, y: 3.3330944, z: 0}
- {x: 0.0015918687, y: 3.2033968, z: 0}
- {x: 0.040996216, y: 3.0962222, z: 0}
- {x: 0.08040057, y: 3.011571, z: 0}
- {x: 0.11980491, y: 2.9494433, z: 0}
- {x: 0.15920927, y: 2.9098392, z: 0}
- {x: 0.1986136, y: 2.892758, z: 0}
- {x: 0.23801796, y: 2.8982003, z: 0}
- {x: -0.15602553, y: 4.074945, z: 0}
- {x: -0.11662118, y: 3.8796225, z: 0}
- {x: -0.07721684, y: 3.7057445, z: 0}
- {x: -0.03781248, y: 3.5533106, z: 0}
- {x: 0.0015918687, y: 3.4223216, z: 0}
- {x: 0.040996216, y: 3.3127766, z: 0}
- {x: 0.08040057, y: 3.2246757, z: 0}
- {x: 0.11980491, y: 3.1580195, z: 0}
- {x: 0.15920927, y: 3.1128078, z: 0}
- {x: 0.1986136, y: 3.0890403, z: 0}
- {x: 0.23801796, y: 3.0867171, z: 0}
m_Parameters:
serializedVersion: 3
widthMultiplier: 1
@@ -1867,17 +1910,17 @@ LineRenderer:
m_SortingLayer: 0
m_SortingOrder: 0
m_Positions:
- {x: -0.15602553, y: 3.8573277, z: 0}
- {x: -0.18956745, y: 3.6568341, z: 0}
- {x: -0.22310936, y: 3.479415, z: 0}
- {x: -0.25665125, y: 3.3250687, z: 0}
- {x: -0.29019317, y: 3.193797, z: 0}
- {x: -0.32373506, y: 3.0855987, z: 0}
- {x: -0.35727698, y: 3.0004745, z: 0}
- {x: -0.39081886, y: 2.938424, z: 0}
- {x: -0.4243608, y: 2.8994474, z: 0}
- {x: -0.45790267, y: 2.8835444, z: 0}
- {x: -0.4914446, y: 2.8907156, z: 0}
- {x: -0.15602553, y: 4.074945, z: 0}
- {x: -0.18956745, y: 3.8764977, z: 0}
- {x: -0.22310936, y: 3.7000232, z: 0}
- {x: -0.25665125, y: 3.5455205, z: 0}
- {x: -0.29019317, y: 3.4129908, z: 0}
- {x: -0.32373506, y: 3.3024333, z: 0}
- {x: -0.35727698, y: 3.213848, z: 0}
- {x: -0.39081886, y: 3.1472356, z: 0}
- {x: -0.4243608, y: 3.1025953, z: 0}
- {x: -0.45790267, y: 3.0799277, z: 0}
- {x: -0.4914446, y: 3.0792325, z: 0}
m_Parameters:
serializedVersion: 3
widthMultiplier: 1
@@ -1968,6 +2011,59 @@ MonoBehaviour:
ropeDamping: 0.3
initialSeparationDistance: 0.1
initialFallImpulse: 2
--- !u!1 &1651034644
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1651034646}
- component: {fileID: 1651034645}
m_Layer: 0
m_Name: ScreenReferenceMarker
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1651034645
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1651034644}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 3058fe4801134fea916ad685f924668f, type: 3}
m_Name:
m_EditorClassIdentifier: AppleHillsScripts::AppleHillsCamera.ScreenReferenceMarker
targetWidth: 6.2
topMargin: 0.2
bottomMargin: 0.2
leftMargin: 0.2
rightMargin: 0.2
gizmoColor: {r: 0, g: 1, b: 0, a: 0.5}
screenEdgeColor: {r: 1, g: 1, b: 0, a: 0.3}
showVerticalMargins: 1
showHorizontalMargins: 1
--- !u!4 &1651034646
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1651034644}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1679185997
GameObject:
m_ObjectHideFlags: 0
@@ -2031,6 +2127,7 @@ MonoBehaviour:
- {fileID: 2956826569642009690, guid: b3501bc08c6afbf4f868bc7b6c2a2d92, type: 3}
- {fileID: 2956826569642009690, guid: 4f9c8bd3fb844484492a54a2998b9b3b, type: 3}
- {fileID: 2956826569642009690, guid: 16ad8a46d2c7c534982f4ee943e06f74, type: 3}
initialTilePrefab: {fileID: 0}
onTileSpawned:
m_PersistentCalls:
m_Calls: []
@@ -2040,6 +2137,22 @@ MonoBehaviour:
onLastTileLeft:
m_PersistentCalls:
m_Calls: []
depthDifficultyRanges:
- minDepth: 0
maxDepth: 10
difficulty: 1
- minDepth: 11
maxDepth: 20
difficulty: 2
- minDepth: 21
maxDepth: 30
difficulty: 3
- minDepth: 31
maxDepth: 40
difficulty: 4
- minDepth: 41
maxDepth: 2147483647
difficulty: 5
--- !u!1 &1787733334
GameObject:
m_ObjectHideFlags: 0
@@ -2337,7 +2450,7 @@ Transform:
m_GameObject: {fileID: 2106431001}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: -0.165, y: 2.509, z: 0}
m_LocalPosition: {x: -0.165, y: 2.697517, z: 0}
m_LocalScale: {x: 0.57574, y: 0.57574, z: 0.57574}
m_ConstrainProportionsScale: 0
m_Children:
@@ -2366,7 +2479,7 @@ MonoBehaviour:
bottleWobble: {fileID: 747976399}
followStiffness: 4
useWobbleOffset: 1
baseY: 2.5
verticalDistance: 0.5
--- !u!114 &2106431004
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -2410,3 +2523,4 @@ SceneRoots:
- {fileID: 1679185998}
- {fileID: 323864665}
- {fileID: 424805726}
- {fileID: 1651034646}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 95f834c9cbc34cff9490c5582d66e463
timeCreated: 1760359480

View File

@@ -0,0 +1,127 @@
using UnityEngine;
using Unity.Cinemachine;
using System;
namespace AppleHillsCamera
{
/// <summary>
/// Adjusts the camera's orthographic size to match the target width from a ScreenReferenceMarker.
/// Works with both regular cameras and Cinemachine virtual cameras.
/// </summary>
public class CameraScreenAdapter : MonoBehaviour
{
[Tooltip("Reference that defines the target width to match")]
public ScreenReferenceMarker referenceMarker;
[Tooltip("Whether to adjust the camera automatically on Start")]
public bool adjustOnStart = true;
[Tooltip("Whether to adjust the camera automatically when the screen size changes")]
public bool adjustOnScreenResize = true;
// Event that other components can subscribe to when camera is adjusted
public event Action OnCameraAdjusted;
private Camera _regularCamera;
private CinemachineCamera _virtualCamera;
private int _lastScreenWidth;
private int _lastScreenHeight;
private bool _usingCinemachine;
private void Awake()
{
// Try to get regular camera first
_regularCamera = GetComponent<Camera>();
// Try to get Cinemachine camera if no regular camera or if both exist
_virtualCamera = GetComponent<CinemachineCamera>();
// Determine which camera type we're using
_usingCinemachine = _virtualCamera != null;
_lastScreenWidth = Screen.width;
_lastScreenHeight = Screen.height;
if (!_usingCinemachine && _regularCamera == null)
{
Debug.LogError("CameraScreenAdapter: No camera component found. Add this script to a GameObject with either Camera or CinemachineCamera component.");
enabled = false;
return;
}
}
private void Start()
{
if (adjustOnStart)
{
AdjustCamera();
}
}
private void Update()
{
if (adjustOnScreenResize &&
(Screen.width != _lastScreenWidth || Screen.height != _lastScreenHeight))
{
AdjustCamera();
_lastScreenWidth = Screen.width;
_lastScreenHeight = Screen.height;
}
}
/// <summary>
/// Manually trigger camera adjustment to match the reference width.
/// </summary>
public void AdjustCamera()
{
if (referenceMarker == null)
{
Debug.LogWarning("CameraScreenAdapter: Missing reference marker.");
return;
}
// Calculate the orthographic size based on the target width and screen aspect ratio
float targetWidth = referenceMarker.targetWidth;
float screenAspect = (float)Screen.height / Screen.width;
// Orthographic size is half the height, so we calculate:
// orthoSize = (targetWidth / 2) * (screenHeight / screenWidth)
float orthoSize = (targetWidth / 2f) * screenAspect;
// Apply the calculated size to the camera
if (_usingCinemachine)
{
// Apply to Cinemachine virtual camera
var lens = _virtualCamera.Lens;
lens.OrthographicSize = orthoSize;
_virtualCamera.Lens = lens;
Debug.Log($"Cinemachine Camera adapted: Width={targetWidth}, Aspect={screenAspect:F2}, OrthoSize={orthoSize:F2}");
}
else
{
// Apply to regular camera
if (_regularCamera.orthographic)
{
_regularCamera.orthographicSize = orthoSize;
Debug.Log($"Camera adapted: Width={targetWidth}, Aspect={screenAspect:F2}, OrthoSize={orthoSize:F2}");
}
else
{
Debug.LogWarning("CameraScreenAdapter: Regular camera is not in orthographic mode.");
return;
}
}
// Notify subscribers that the camera has been adjusted
OnCameraAdjusted?.Invoke();
}
/// <summary>
/// Gets the camera component being controlled by this adapter
/// </summary>
public Camera GetControlledCamera()
{
return _usingCinemachine ? _virtualCamera.GetComponent<Camera>() : _regularCamera;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 8a71a21143bd4f4992d08829084d1e3b
timeCreated: 1760359498

View File

@@ -0,0 +1,549 @@
using AppleHills.Core;
using UnityEngine;
using System;
namespace AppleHillsCamera
{
/// <summary>
/// Anchors a game object at a fixed distance from a screen edge.
/// </summary>
[ExecuteInEditMode] // Make it run in the editor
public class EdgeAnchor : MonoBehaviour
{
// Event that fires when the anchor's position is updated
public event Action OnPositionUpdated;
public enum AnchorEdge
{
Top,
Bottom,
Left,
Right
}
[Tooltip("Reference marker that defines the screen edges and margins")]
public ScreenReferenceMarker referenceMarker;
[Tooltip("Camera adapter to subscribe to for runtime updates")]
public CameraScreenAdapter cameraAdapter;
[Tooltip("Which screen edge to anchor to")]
public AnchorEdge anchorEdge = AnchorEdge.Top;
[Tooltip("Whether to use the predefined margin from the reference marker")]
public bool useReferenceMargin = true;
[Tooltip("Custom margin to use if not using the reference margin")]
public float customMargin = 1f;
[Tooltip("Whether to adjust the position automatically on Start")]
public bool adjustOnStart = true;
[Tooltip("Whether to adjust the position when the screen size changes")]
public bool adjustOnScreenResize = true;
[Tooltip("Whether to preserve the object's position on other axes")]
public bool preserveOtherAxes = true;
[Tooltip("Whether to account for this object's size in positioning")]
public bool accountForObjectSize = true;
[Header("Visualization")]
[Tooltip("Whether to show the anchor visualization in the editor")]
public bool showVisualization = true;
[Tooltip("Color for the anchor visualization line")]
public Color visualizationColor = new Color(1f, 0f, 0f, 0.8f);
[Tooltip("Show object bounds in visualization")]
public bool showObjectBounds = true;
[Tooltip("Debug mode - print position changes to console")]
public bool debugMode = false;
private Camera _camera;
private int _lastScreenWidth;
private int _lastScreenHeight;
private float _lastOrthoSize = 0f;
private Vector3 _originalPosition;
private bool _initialized = false;
private Bounds _objectBounds;
#if UNITY_EDITOR
private void OnDrawGizmos()
{
if (!showVisualization || referenceMarker == null)
return;
// Find camera if needed
if (_camera == null)
FindCamera();
if (_camera == null)
return;
// Calculate the anchor point (the exact point on the screen edge)
Vector3 anchorPoint = CalculateScreenEdgePoint();
// Save original color
Color originalColor = Gizmos.color;
// Draw line to anchor point
Gizmos.color = visualizationColor;
Gizmos.DrawLine(transform.position, anchorPoint);
// Draw a small sphere at the anchor point
Gizmos.DrawSphere(anchorPoint, 0.1f);
// Draw object bounds if enabled
if (showObjectBounds && accountForObjectSize)
{
Bounds bounds = GetObjectBounds();
if (bounds.size != Vector3.zero)
{
Color boundsColor = visualizationColor;
boundsColor.a *= 0.3f;
Gizmos.color = boundsColor;
Gizmos.DrawWireCube(bounds.center, bounds.size);
}
}
// Restore original color
Gizmos.color = originalColor;
}
#endif
private void Awake()
{
_originalPosition = transform.position;
FindCamera();
if (_camera != null)
{
_lastOrthoSize = _camera.orthographicSize;
}
_lastScreenWidth = Screen.width;
_lastScreenHeight = Screen.height;
_initialized = true;
}
private void OnEnable()
{
if (!_initialized)
{
_originalPosition = transform.position;
FindCamera();
_lastScreenWidth = Screen.width;
_lastScreenHeight = Screen.height;
_initialized = true;
}
// Subscribe to camera adapter events
if (Application.isPlaying && cameraAdapter != null)
{
cameraAdapter.OnCameraAdjusted += HandleCameraAdjusted;
}
// Adjust position immediately when enabled in editor
#if UNITY_EDITOR
if (!Application.isPlaying)
{
UpdatePosition();
}
#endif
}
private void OnDisable()
{
// Unsubscribe from camera adapter events
if (cameraAdapter != null)
{
cameraAdapter.OnCameraAdjusted -= HandleCameraAdjusted;
}
}
private void HandleCameraAdjusted()
{
// Update position when camera is adjusted
if (Application.isPlaying)
{
if (debugMode)
{
Debug.Log($"Camera adjusted event received by {gameObject.name}, updating position");
}
// Ensure we have the latest camera reference
FindCamera();
UpdatePosition();
}
}
private void Start()
{
// If no camera adapter was manually set, try to find one in the scene
if (cameraAdapter == null && Application.isPlaying)
{
FindCameraAdapter();
}
// Ensure we're subscribed to camera adapter events
if (cameraAdapter != null && Application.isPlaying)
{
cameraAdapter.OnCameraAdjusted -= HandleCameraAdjusted; // Remove any duplicate subscriptions
cameraAdapter.OnCameraAdjusted += HandleCameraAdjusted; // Subscribe
}
if (adjustOnStart && Application.isPlaying)
{
UpdatePosition();
}
}
private void FindCameraAdapter()
{
// Try to find the camera adapter in the scene
var adapters = FindObjectsOfType<CameraScreenAdapter>();
if (adapters != null && adapters.Length > 0)
{
// Prioritize any adapter that's on the same camera we're using
foreach (var adapter in adapters)
{
if (_camera != null && adapter.GetControlledCamera() == _camera)
{
cameraAdapter = adapter;
Debug.Log($"EdgeAnchor on {gameObject.name} auto-connected to CameraScreenAdapter on {cameraAdapter.gameObject.name}");
return;
}
}
// If no matching camera found, use the first one
cameraAdapter = adapters[0];
Debug.Log($"EdgeAnchor on {gameObject.name} auto-connected to CameraScreenAdapter on {cameraAdapter.gameObject.name}");
}
}
private void Update()
{
bool shouldUpdate = false;
// Check if we have a valid camera
if (_camera == null)
{
FindCamera();
if (_camera != null) shouldUpdate = true;
}
// Check if camera's ortho size has changed
if (_camera != null && _camera.orthographicSize != _lastOrthoSize)
{
if (debugMode)
{
Debug.Log($"{gameObject.name}: Camera ortho size changed from {_lastOrthoSize} to {_camera.orthographicSize}");
}
_lastOrthoSize = _camera.orthographicSize;
shouldUpdate = true;
}
// Update if screen size has changed
if (adjustOnScreenResize &&
(Screen.width != _lastScreenWidth || Screen.height != _lastScreenHeight))
{
shouldUpdate = true;
_lastScreenWidth = Screen.width;
_lastScreenHeight = Screen.height;
}
// In editor, check for reference marker changes or inspector changes
#if UNITY_EDITOR
if (!Application.isPlaying)
{
shouldUpdate = true;
}
#endif
// Update position if needed
if (shouldUpdate)
{
UpdatePosition();
}
}
private void FindCamera()
{
Camera prevCamera = _camera;
// First check if we have a camera adapter reference
if (cameraAdapter != null)
{
_camera = cameraAdapter.GetControlledCamera();
if (_camera != null)
{
return;
}
}
// Look for the main camera
_camera = Camera.main;
// If no main camera found, try to find any camera
if (_camera == null)
{
_camera = FindAnyObjectByType<Camera>();
}
if (_camera == null)
{
Debug.LogError("EdgeAnchor: No camera found in the scene.");
}
else if (_camera != prevCamera && debugMode)
{
Debug.Log($"{gameObject.name}: Camera reference updated to {_camera.name}");
}
}
/// <summary>
/// Get the combined bounds of all renderers on this object and its children
/// </summary>
private Bounds GetObjectBounds()
{
Bounds bounds = new Bounds(transform.position, Vector3.zero);
// Get all renderers in this object and its children
Renderer[] renderers = GetComponentsInChildren<Renderer>();
if (renderers.Length > 0)
{
// Start with the first renderer's bounds
bounds = renderers[0].bounds;
// Expand to include all other renderers
for (int i = 1; i < renderers.Length; i++)
{
bounds.Encapsulate(renderers[i].bounds);
}
}
else
{
// No renderers found, create a small placeholder bounds
bounds = new Bounds(transform.position, new Vector3(0.1f, 0.1f, 0.1f));
}
// Cache the bounds
_objectBounds = bounds;
return bounds;
}
/// <summary>
/// Manually trigger position adjustment based on the anchor settings.
/// </summary>
public void UpdatePosition()
{
if (referenceMarker == null)
{
Debug.LogWarning("EdgeAnchor: Missing reference marker.");
return;
}
if (_camera == null)
{
FindCamera();
if (_camera == null) return;
}
// Get the margin value to use
float margin = GetMarginValue();
// Calculate the new position based on anchor edge and object size
Vector3 newPosition = CalculateAnchoredPosition(margin);
// If preserving other axes, keep their original values
if (preserveOtherAxes)
{
switch (anchorEdge)
{
case AnchorEdge.Top:
case AnchorEdge.Bottom:
newPosition.x = transform.position.x;
newPosition.z = transform.position.z;
break;
case AnchorEdge.Left:
case AnchorEdge.Right:
newPosition.y = transform.position.y;
newPosition.z = transform.position.z;
break;
}
}
// Apply the new position
if (debugMode && Vector3.Distance(transform.position, newPosition) > 0.01f)
{
Debug.Log($"{gameObject.name} position updated: {transform.position} -> {newPosition}, Camera OrthoSize: {_camera.orthographicSize}");
}
transform.position = newPosition;
// Notify listeners that the position has been updated
OnPositionUpdated?.Invoke();
// Store the current ortho size for change detection
if (_camera != null)
{
_lastOrthoSize = _camera.orthographicSize;
}
}
private float GetMarginValue()
{
if (!useReferenceMargin)
{
return customMargin;
}
switch (anchorEdge)
{
case AnchorEdge.Top:
return referenceMarker.topMargin;
case AnchorEdge.Bottom:
return referenceMarker.bottomMargin;
case AnchorEdge.Left:
return referenceMarker.leftMargin;
case AnchorEdge.Right:
return referenceMarker.rightMargin;
default:
return customMargin;
}
}
private Vector3 CalculateAnchoredPosition(float margin)
{
if (_camera == null)
return transform.position;
// Always get the CURRENT camera properties to ensure we have latest values
float cameraOrthoSize = _camera.orthographicSize;
float screenAspect = (float)Screen.width / Screen.height;
float screenHeight = cameraOrthoSize * 2f;
float screenWidth = screenHeight * screenAspect;
Vector3 cameraPosition = _camera.transform.position;
Vector3 newPosition = transform.position;
// Calculate object size offset if needed
float offsetX = 0f;
float offsetY = 0f;
if (accountForObjectSize)
{
Bounds bounds = GetObjectBounds();
Vector3 extents = bounds.extents; // Half the size
Vector3 centerOffset = bounds.center - transform.position; // Offset from pivot to center
switch (anchorEdge)
{
case AnchorEdge.Top:
// For top edge, offset is negative (moving down) by the top extent
offsetY = -extents.y - centerOffset.y;
break;
case AnchorEdge.Bottom:
// For bottom edge, offset is positive (moving up) by the bottom extent
offsetY = extents.y - centerOffset.y;
break;
case AnchorEdge.Left:
// For left edge, offset is positive (moving right) by the left extent
offsetX = extents.x - centerOffset.x;
break;
case AnchorEdge.Right:
// For right edge, offset is negative (moving left) by the right extent
offsetX = -extents.x - centerOffset.x;
break;
}
}
switch (anchorEdge)
{
case AnchorEdge.Top:
// Position from the top of the screen
// When margin is 0, object's top edge is exactly at the top screen edge
newPosition.y = cameraPosition.y + cameraOrthoSize - margin + offsetY;
break;
case AnchorEdge.Bottom:
// Position from the bottom of the screen
// When margin is 0, object's bottom edge is exactly at the bottom screen edge
newPosition.y = cameraPosition.y - cameraOrthoSize + margin + offsetY;
break;
case AnchorEdge.Left:
// Position from the left of the screen
// When margin is 0, object's left edge is exactly at the left screen edge
newPosition.x = cameraPosition.x - (screenWidth / 2f) + margin + offsetX;
break;
case AnchorEdge.Right:
// Position from the right of the screen
// When margin is 0, object's right edge is exactly at the right screen edge
newPosition.x = cameraPosition.x + (screenWidth / 2f) - margin + offsetX;
break;
}
return newPosition;
}
/// <summary>
/// Calculates the exact point on the screen edge for visualization purposes
/// </summary>
private Vector3 CalculateScreenEdgePoint()
{
if (_camera == null)
return transform.position;
// Get the screen edges in world coordinates
float cameraOrthoSize = _camera.orthographicSize;
float screenAspect = (float)Screen.width / Screen.height;
float screenHeight = cameraOrthoSize * 2f;
float screenWidth = screenHeight * screenAspect;
Vector3 cameraPosition = _camera.transform.position;
Vector3 objectPosition = transform.position;
// Calculate the point exactly on the screen edge that corresponds to the object's anchor
switch (anchorEdge)
{
case AnchorEdge.Top:
// Point on top edge with same X coordinate as the object
return new Vector3(
objectPosition.x,
cameraPosition.y + cameraOrthoSize,
objectPosition.z
);
case AnchorEdge.Bottom:
// Point on bottom edge with same X coordinate as the object
return new Vector3(
objectPosition.x,
cameraPosition.y - cameraOrthoSize,
objectPosition.z
);
case AnchorEdge.Left:
// Point on left edge with same Y coordinate as the object
return new Vector3(
cameraPosition.x - (screenWidth / 2f),
objectPosition.y,
objectPosition.z
);
case AnchorEdge.Right:
// Point on right edge with same Y coordinate as the object
return new Vector3(
cameraPosition.x + (screenWidth / 2f),
objectPosition.y,
objectPosition.z
);
default:
return objectPosition;
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: ed380d10e1e04ae7990e5c726c929063
timeCreated: 1760359522

View File

@@ -0,0 +1,142 @@
using UnityEngine;
namespace AppleHillsCamera
{
/// <summary>
/// Defines reference sizes and distances for screen adaptation.
/// Used by CameraScreenAdapter and EdgeAnchor components.
/// </summary>
public class ScreenReferenceMarker : MonoBehaviour
{
[Header("Horizontal Reference")]
[Tooltip("The target width that should match the screen width")]
public float targetWidth = 10f;
[Header("Vertical References")]
[Tooltip("Distance from top of screen to use for anchoring")]
public float topMargin = 1f;
[Tooltip("Distance from bottom of screen to use for anchoring")]
public float bottomMargin = 1f;
[Tooltip("Distance from left of screen to use for anchoring")]
public float leftMargin = 1f;
[Tooltip("Distance from right of screen to use for anchoring")]
public float rightMargin = 1f;
[Header("Visualization")]
[Tooltip("Color to use for gizmo visualization")]
public Color gizmoColor = new Color(0f, 1f, 0f, 0.5f);
[Tooltip("Color to use for screen edge visualization")]
public Color screenEdgeColor = new Color(1f, 1f, 0f, 0.3f);
[Tooltip("Show the vertical margins in scene view")]
public bool showVerticalMargins = true;
[Tooltip("Show the horizontal margins in scene view")]
public bool showHorizontalMargins = true;
#if UNITY_EDITOR
private void OnDrawGizmos()
{
// Save original color
Color originalColor = Gizmos.color;
// Set the color for our gizmos
Gizmos.color = gizmoColor;
Vector3 position = transform.position;
// Draw the width reference
Vector3 left = position + Vector3.left * (targetWidth / 2f);
Vector3 right = position + Vector3.right * (targetWidth / 2f);
Gizmos.DrawLine(left, right);
// Draw vertical endpoints
float endCapSize = 0.5f;
Gizmos.DrawLine(left, left + Vector3.up * endCapSize);
Gizmos.DrawLine(left, left + Vector3.down * endCapSize);
Gizmos.DrawLine(right, right + Vector3.up * endCapSize);
Gizmos.DrawLine(right, right + Vector3.down * endCapSize);
// Calculate visual screen edges based on actual camera viewport
float halfWidth = targetWidth / 2f;
float halfHeight;
// Try to get camera references in the preferred order
// 1. Try to find the main camera in the scene (highest priority)
Camera mainCamera = Camera.main;
if (mainCamera != null && mainCamera.orthographic)
{
// Use the main camera's actual orthographic size for the height
halfHeight = mainCamera.orthographicSize;
}
else
{
// 2. Use Game/Simulator window resolution
float gameViewAspect = (float)Screen.height / Screen.width;
halfHeight = halfWidth * gameViewAspect;
// 3. Fallback to the scene view camera if needed
UnityEditor.SceneView sceneView = UnityEditor.SceneView.lastActiveSceneView;
if (sceneView != null && sceneView.camera != null && sceneView.camera.orthographic)
{
// Use the scene view camera's aspect ratio instead
float sceneAspect = sceneView.camera.pixelHeight / (float)sceneView.camera.pixelWidth;
halfHeight = halfWidth * sceneAspect;
}
}
// Screen edge positions
Vector3 topEdge = position + Vector3.up * halfHeight;
Vector3 bottomEdge = position + Vector3.down * halfHeight;
Vector3 leftEdge = position + Vector3.left * halfWidth;
Vector3 rightEdge = position + Vector3.right * halfWidth;
// Draw screen edges with yellow color
Gizmos.color = screenEdgeColor;
// Draw full screen rectangle
Gizmos.DrawLine(leftEdge + Vector3.up * halfHeight, rightEdge + Vector3.up * halfHeight); // Top edge
Gizmos.DrawLine(leftEdge + Vector3.down * halfHeight, rightEdge + Vector3.down * halfHeight); // Bottom edge
Gizmos.DrawLine(leftEdge + Vector3.up * halfHeight, leftEdge + Vector3.down * halfHeight); // Left edge
Gizmos.DrawLine(rightEdge + Vector3.up * halfHeight, rightEdge + Vector3.down * halfHeight); // Right edge
// Draw margin references if enabled
Gizmos.color = gizmoColor;
if (showVerticalMargins)
{
// Top margin (distance from top edge)
Gizmos.DrawLine(
topEdge + Vector3.down * topMargin + Vector3.left * (targetWidth / 4f),
topEdge + Vector3.down * topMargin + Vector3.right * (targetWidth / 4f));
// Bottom margin (distance from bottom edge)
Gizmos.DrawLine(
bottomEdge + Vector3.up * bottomMargin + Vector3.left * (targetWidth / 4f),
bottomEdge + Vector3.up * bottomMargin + Vector3.right * (targetWidth / 4f));
}
if (showHorizontalMargins)
{
// Left margin (distance from left edge)
Gizmos.DrawLine(
leftEdge + Vector3.right * leftMargin + Vector3.up * (halfHeight / 2f),
leftEdge + Vector3.right * leftMargin + Vector3.down * (halfHeight / 2f));
// Right margin (distance from right edge)
Gizmos.DrawLine(
rightEdge + Vector3.left * rightMargin + Vector3.up * (halfHeight / 2f),
rightEdge + Vector3.left * rightMargin + Vector3.down * (halfHeight / 2f));
}
// Restore original color
Gizmos.color = originalColor;
}
#endif
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 3058fe4801134fea916ad685f924668f
timeCreated: 1760359480

View File

@@ -1,123 +1,128 @@
using System;
using AppleHills.Core.Settings;
using Core;
using Input;
using Interactions;
using UnityEngine;
using AppleHills.Core.Settings;
using Core; // Added for IInteractionSettings
/// <summary>
/// Handles level switching when interacted with. Applies switch data and triggers scene transitions.
/// </summary>
public class LevelSwitch : MonoBehaviour
// Added for IInteractionSettings
namespace LevelS
{
/// <summary>
/// Data for this level switch (target scene, icon, etc).
/// Handles level switching when interacted with. Applies switch data and triggers scene transitions.
/// </summary>
public LevelSwitchData switchData;
private SpriteRenderer _iconRenderer;
private Interactable _interactable;
public class LevelSwitch : MonoBehaviour
{
/// <summary>
/// Data for this level switch (target scene, icon, etc).
/// </summary>
public LevelSwitchData switchData;
private SpriteRenderer _iconRenderer;
private Interactable _interactable;
// Settings reference
private IInteractionSettings _interactionSettings;
// Settings reference
private IInteractionSettings _interactionSettings;
private bool _isActive = true;
private bool _isActive = true;
/// <summary>
/// Unity Awake callback. Sets up icon, interactable, and event handlers.
/// </summary>
void Awake()
{
_isActive = true;
if (_iconRenderer == null)
_iconRenderer = GetComponent<SpriteRenderer>();
_interactable = GetComponent<Interactable>();
if (_interactable != null)
/// <summary>
/// Unity Awake callback. Sets up icon, interactable, and event handlers.
/// </summary>
void Awake()
{
_interactable.characterArrived.AddListener(OnCharacterArrived);
_isActive = true;
if (_iconRenderer == null)
_iconRenderer = GetComponent<SpriteRenderer>();
_interactable = GetComponent<Interactable>();
if (_interactable != null)
{
_interactable.characterArrived.AddListener(OnCharacterArrived);
}
// Initialize settings reference
_interactionSettings = GameManager.GetSettingsObject<IInteractionSettings>();
ApplySwitchData();
}
// Initialize settings reference
_interactionSettings = GameManager.GetSettingsObject<IInteractionSettings>();
ApplySwitchData();
}
/// <summary>
/// Unity OnDestroy callback. Cleans up event handlers.
/// </summary>
void OnDestroy()
{
if (_interactable != null)
/// <summary>
/// Unity OnDestroy callback. Cleans up event handlers.
/// </summary>
void OnDestroy()
{
_interactable.characterArrived.RemoveListener(OnCharacterArrived);
if (_interactable != null)
{
_interactable.characterArrived.RemoveListener(OnCharacterArrived);
}
}
}
#if UNITY_EDITOR
/// <summary>
/// Unity OnValidate callback. Ensures icon and data are up to date in editor.
/// </summary>
void OnValidate()
{
if (_iconRenderer == null)
_iconRenderer = GetComponent<SpriteRenderer>();
ApplySwitchData();
}
/// <summary>
/// Unity OnValidate callback. Ensures icon and data are up to date in editor.
/// </summary>
void OnValidate()
{
if (_iconRenderer == null)
_iconRenderer = GetComponent<SpriteRenderer>();
ApplySwitchData();
}
#endif
/// <summary>
/// Applies the switch data to the level switch (icon, name, etc).
/// </summary>
public void ApplySwitchData()
{
if (switchData != null)
/// <summary>
/// Applies the switch data to the level switch (icon, name, etc).
/// </summary>
public void ApplySwitchData()
{
if (_iconRenderer != null)
_iconRenderer.sprite = switchData.mapSprite;
gameObject.name = switchData.targetLevelSceneName;
// Optionally update other fields, e.g. description
if (switchData != null)
{
if (_iconRenderer != null)
_iconRenderer.sprite = switchData.mapSprite;
gameObject.name = switchData.targetLevelSceneName;
// Optionally update other fields, e.g. description
}
}
}
/// <summary>
/// Handles the start of an interaction (shows confirmation menu and switches the level if confirmed).
/// </summary>
private void OnCharacterArrived()
{
if (switchData == null || string.IsNullOrEmpty(switchData.targetLevelSceneName) || !_isActive)
return;
/// <summary>
/// Handles the start of an interaction (shows confirmation menu and switches the level if confirmed).
/// </summary>
private void OnCharacterArrived()
{
if (switchData == null || string.IsNullOrEmpty(switchData.targetLevelSceneName) || !_isActive)
return;
var menuPrefab = _interactionSettings?.LevelSwitchMenuPrefab;
if (menuPrefab == null)
{
Debug.LogError("LevelSwitchMenu prefab not assigned in InteractionSettings!");
return;
}
// Spawn the menu overlay (assume Canvas parent is handled in prefab setup)
var menuGo = Instantiate(menuPrefab);
var menu = menuGo.GetComponent<LevelSwitchMenu>();
if (menu == null)
{
Debug.LogError("LevelSwitchMenu component missing on prefab!");
Destroy(menuGo);
return;
}
// Setup menu with data and callbacks
menu.Setup(switchData, OnMenuConfirm, OnMenuCancel);
_isActive = false; // Prevent re-triggering until menu is closed
var menuPrefab = _interactionSettings?.LevelSwitchMenuPrefab;
if (menuPrefab == null)
{
Debug.LogError("LevelSwitchMenu prefab not assigned in InteractionSettings!");
return;
}
// Spawn the menu overlay (assume Canvas parent is handled in prefab setup)
var menuGo = Instantiate(menuPrefab);
var menu = menuGo.GetComponent<LevelSwitchMenu>();
if (menu == null)
{
Debug.LogError("LevelSwitchMenu component missing on prefab!");
Destroy(menuGo);
return;
}
// Setup menu with data and callbacks
menu.Setup(switchData, OnMenuConfirm, OnMenuCancel);
_isActive = false; // Prevent re-triggering until menu is closed
// Switch input mode to UI only
InputManager.Instance.SetInputMode(InputMode.UI);
}
// Switch input mode to UI only
InputManager.Instance.SetInputMode(InputMode.UI);
}
private async void OnMenuConfirm()
{
var progress = new Progress<float>(p => Debug.Log($"Loading progress: {p * 100:F0}%"));
await SceneManagerService.Instance.SwitchSceneAsync(switchData.targetLevelSceneName, progress);
}
private async void OnMenuConfirm()
{
var progress = new Progress<float>(p => Debug.Log($"Loading progress: {p * 100:F0}%"));
await SceneManagerService.Instance.SwitchSceneAsync(switchData.targetLevelSceneName, progress);
}
private void OnMenuCancel()
{
_isActive = true; // Allow interaction again if cancelled
private void OnMenuCancel()
{
_isActive = true; // Allow interaction again if cancelled
}
}
}

View File

@@ -1,6 +1,7 @@
using UnityEngine;
using AppleHills.Core.Settings;
using Input;
using AppleHillsCamera;
namespace Minigames.DivingForPictures
{
@@ -10,6 +11,10 @@ namespace Minigames.DivingForPictures
/// </summary>
public class PlayerController : MonoBehaviour, ITouchInputConsumer
{
[Tooltip("Reference to the edge anchor that this player should follow for Y position")]
[SerializeField] private EdgeAnchor edgeAnchor;
// Settings reference
private IDivingMinigameSettings _settings;
@@ -42,6 +47,38 @@ namespace Minigames.DivingForPictures
_targetFingerX = transform.position.x;
_isTouchActive = false;
// Try to find edge anchor if not assigned
if (edgeAnchor == null)
{
// First try to find edge anchor on the same object or parent
edgeAnchor = GetComponentInParent<EdgeAnchor>();
// If not found, find any edge anchor in the scene
if (edgeAnchor == null)
{
edgeAnchor = FindObjectOfType<EdgeAnchor>();
if (edgeAnchor == null)
{
Debug.LogWarning("[PlayerController] No EdgeAnchor found in scene. Origin Y position won't update with camera changes.");
}
else
{
Debug.Log($"[PlayerController] Auto-connected to EdgeAnchor on {edgeAnchor.gameObject.name}");
}
}
}
// Subscribe to edge anchor events if it exists
if (edgeAnchor != null)
{
// Unsubscribe first to prevent duplicate subscriptions
edgeAnchor.OnPositionUpdated -= UpdateOriginYFromAnchor;
edgeAnchor.OnPositionUpdated += UpdateOriginYFromAnchor;
// Update origin Y based on current anchor position
UpdateOriginYFromAnchor();
}
DivingGameManager.Instance.OnGameInitialized += Initialize;
// If game is already initialized, initialize immediately
@@ -70,6 +107,12 @@ namespace Minigames.DivingForPictures
private void OnDestroy()
{
DivingGameManager.Instance.OnGameInitialized -= Initialize;
// Unsubscribe from edge anchor events
if (edgeAnchor != null)
{
edgeAnchor.OnPositionUpdated -= UpdateOriginYFromAnchor;
}
}
/// <summary>
@@ -185,5 +228,31 @@ namespace Minigames.DivingForPictures
}
transform.position = new Vector3(newX, newY, transform.position.z);
}
/// <summary>
/// Updates the origin Y position based on camera adjustments
/// </summary>
public void UpdateOriginY(float newOriginY)
{
_originY = newOriginY;
}
/// <summary>
/// Updates the origin Y position based on the current position of the player
/// This method is intended to be called by the camera adapter when the camera is adjusted.
/// </summary>
private void UpdateOriginYFromCurrentPosition()
{
_originY = transform.position.y;
}
/// <summary>
/// Updates the origin Y position based on the current position of the edge anchor
/// This method is intended to be called by the edge anchor when its position is updated.
/// </summary>
private void UpdateOriginYFromAnchor()
{
_originY = edgeAnchor.transform.position.y;
}
}
}

View File

@@ -16,12 +16,43 @@ public class RockFollower : MonoBehaviour
/// </summary>
public bool useWobbleOffset = true;
/// <summary>
/// The base Y position for the rock.
/// The vertical distance between the rock and the bottle.
/// </summary>
public float baseY = -6f;
[SerializeField] private float verticalDistance = 6f;
private float velocityX; // For SmoothDamp
#if UNITY_EDITOR
/// <summary>
/// Called in editor when properties are changed.
/// Updates the object's position when verticalDistance is modified.
/// </summary>
private void OnValidate()
{
// Only update position if playing or in prefab mode
if (Application.isPlaying || UnityEditor.PrefabUtility.IsPartOfPrefabAsset(this))
return;
if (bottleTransform != null)
{
// Calculate the new Y position based on bottle's position and the updated verticalDistance
float newY = bottleTransform.position.y - verticalDistance;
// Apply the wobble offset if enabled
if (useWobbleOffset && bottleWobble != null)
{
newY += bottleWobble.VerticalOffset;
}
// Update only the Y position, keeping X and Z unchanged
transform.position = new Vector3(transform.position.x, newY, transform.position.z);
// Mark the scene as dirty to ensure changes are saved
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(UnityEngine.SceneManagement.SceneManager.GetActiveScene());
}
}
#endif
void Update()
{
if (bottleTransform == null) return;
@@ -33,8 +64,8 @@ public class RockFollower : MonoBehaviour
// Smoothly follow bottle's X with stiffer motion
float newX = Mathf.SmoothDamp(currentX, targetX, ref velocityX, 1f / followStiffness);
// Calculate Y position
float newY = baseY;
// Calculate Y position based on bottle's position and vertical distance
float newY = bottleTransform.position.y - verticalDistance;
if (useWobbleOffset && bottleWobble != null)
{
newY += bottleWobble.VerticalOffset;