diff --git a/Assets/Scenes/SampleScene.unity b/Assets/Scenes/SampleScene.unity index 1d92169..a22937e 100644 --- a/Assets/Scenes/SampleScene.unity +++ b/Assets/Scenes/SampleScene.unity @@ -528,6 +528,8 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: df129be8fadab79498bd429a59bb98e4, type: 3} m_Name: m_EditorClassIdentifier: Assembly-CSharp::ThirdPersonOrbitInput + persistCamera: 1 + cameraSaveId: lookMode: 0 horizontalSensitivity: 0.1 verticalSensitivity: 0.075 @@ -721,7 +723,7 @@ Transform: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 172071430} serializedVersion: 2 - m_LocalRotation: {x: 0.1305262, y: -3.7968678e-12, z: 4.3538282e-13, w: 0.9914449} + m_LocalRotation: {x: 0.1305262, y: 5.7946933e-12, z: -2.6404215e-12, w: 0.9914449} m_LocalPosition: {x: 0, y: 1.9146858, z: -4.3466663} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 @@ -1256,7 +1258,7 @@ Transform: m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 330585543} serializedVersion: 2 - m_LocalRotation: {x: 0.1305262, y: -3.8055076e-12, z: 5.010046e-13, w: 0.9914449} + m_LocalRotation: {x: 0.1305262, y: 5.543152e-12, z: -7.2976987e-13, w: 0.9914449} m_LocalPosition: {x: 0, y: 1.9146858, z: -4.3466663} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 @@ -1364,7 +1366,7 @@ Transform: m_GameObject: {fileID: 347639556} serializedVersion: 2 m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} - m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalPosition: {x: -25, y: -4.5, z: -30} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: @@ -3233,6 +3235,8 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 463b6be548abf464287096e1e92fcd01, type: 3} m_Name: m_EditorClassIdentifier: Assembly-CSharp::PlayerCharacterController + persistPlayer: 1 + playerSaveId: moveSpeed: 4 sprintSpeedMultiplier: 1.8 acceleration: 30 @@ -3377,6 +3381,8 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 463b6be548abf464287096e1e92fcd01, type: 3} m_Name: m_EditorClassIdentifier: Assembly-CSharp::PlayerCharacterController + persistPlayer: 1 + playerSaveId: moveSpeed: 4 sprintSpeedMultiplier: 1.8 acceleration: 30 diff --git a/Assets/Scripts/PlayerCharacterController.Persistence.cs b/Assets/Scripts/PlayerCharacterController.Persistence.cs new file mode 100644 index 0000000..2b5c2a0 --- /dev/null +++ b/Assets/Scripts/PlayerCharacterController.Persistence.cs @@ -0,0 +1,158 @@ +using System; +using System.IO; +using UnityEngine; +using UnityEngine.SceneManagement; + +public partial class PlayerCharacterController +{ + // --- Persistence: player position, rotation and wall-climb state --- + // + // Saved on quit / play-stop and restored on the next start, so the player resumes exactly + // where (and how) they left off - including hanging on a wall. This is small structured + // state, so it goes to a human-readable JSON file (like PaintHud.Persistence), not the raw + // byte dump the painter uses for its bulk pixel maps. + + [Header("Persistence")] + [Tooltip("Save the player's position, rotation and wall-climb state on quit / play-stop and " + + "restore them on the next start. Off = the player starts at its authored scene transform.")] + public bool persistPlayer = true; + + [Tooltip("Save file identifier. Empty = derived from the active scene + object name. Set " + + "explicitly to keep a save stable across object renames.")] + public string playerSaveId = ""; + + private const int PlayerFileVersion = 1; + private bool _playerSavedOnTeardown; // guards a double save across OnApplicationQuit + OnDestroy + + private static string PlayerSaveDir => Path.Combine(Application.persistentDataPath, "MeshPaint"); + + private string PlayerSavePath() + { + string key = string.IsNullOrEmpty(playerSaveId) + ? SanitizePlayerKey(SceneManager.GetActiveScene().name + "_" + gameObject.name) + : SanitizePlayerKey(playerSaveId); + return Path.Combine(PlayerSaveDir, key + ".player"); + } + + private static string SanitizePlayerKey(string s) + { + foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_'); + return s.Replace(' ', '_'); + } + + [Serializable] + private class PlayerState + { + public int version = PlayerFileVersion; + public Vector3 position; + public Quaternion rotation; + + // Wall-climb attachment. The normal/offset/yaw/left-ground flags fully describe the grip; + // ProbeWall re-validates contact on the next frame, so a grip restored away from any wall + // drops cleanly after the grace period rather than freezing the player in mid-air. + public bool isWallClimbing; + public Vector3 wallNormal; + public float wallOffset; + public float wallYaw; + public bool climbLeftGround; + } + + void Start() => RestorePlayerState(); + + private void OnApplicationQuit() => SavePlayerOnTeardown(); + private void OnDestroy() => SavePlayerOnTeardown(); + + /// Saves once during teardown (OnApplicationQuit and OnDestroy both call this; only the first writes). + private void SavePlayerOnTeardown() + { + if (_playerSavedOnTeardown) return; + _playerSavedOnTeardown = true; + SavePlayerState(); + } + + /// Writes the current transform and wall-climb state to disk. + public void SavePlayerState() + { + if (!persistPlayer) return; + + try + { + var state = new PlayerState + { + position = transform.position, + rotation = transform.rotation, + isWallClimbing = _isWallClimbing, + wallNormal = _wallNormal, + wallOffset = _wallOffset, + wallYaw = _wallYaw, + climbLeftGround = _climbLeftGround, + }; + + Directory.CreateDirectory(PlayerSaveDir); + File.WriteAllText(PlayerSavePath(), JsonUtility.ToJson(state, true)); + } + catch (Exception e) + { + Debug.LogWarning($"[PlayerCharacterController] Saving player state failed: {e.Message}", this); + } + } + + /// Restores the saved transform and wall-climb state, or does nothing if there is no save. + private void RestorePlayerState() + { + if (!persistPlayer) return; + + var state = LoadPlayerState(); + if (state == null) return; + + // Teleport through the CharacterController: toggling enabled clears its cached internal + // position so it neither snaps back nor fights the restored transform. + bool wasEnabled = _controller && _controller.enabled; + if (_controller) _controller.enabled = false; + transform.SetPositionAndRotation(state.position, state.rotation); + if (_controller) _controller.enabled = wasEnabled; + + _isWallClimbing = state.isWallClimbing; + if (state.wallNormal.sqrMagnitude > 1e-6f) + _wallNormal = state.wallNormal.normalized; + _wallOffset = state.wallOffset; + _wallYaw = state.wallYaw; + _climbLeftGround = state.climbLeftGround; + if (_isWallClimbing) + _verticalVelocity = 0f; // a restored grip starts at rest, not mid-fall + } + + private PlayerState LoadPlayerState() + { + string path = PlayerSavePath(); + if (!File.Exists(path)) return null; + + try + { + var state = JsonUtility.FromJson(File.ReadAllText(path)); + if (state == null || state.version != PlayerFileVersion) return null; + return state; + } + catch (Exception e) + { + Debug.LogWarning($"[PlayerCharacterController] Loading player state failed: {e.Message}", this); + return null; + } + } + + /// Deletes the saved player state (next start uses the authored scene transform). Editor convenience. + [ContextMenu("Clear Saved Player State")] + public void ClearSavedPlayerState() + { + try + { + string path = PlayerSavePath(); + if (File.Exists(path)) File.Delete(path); + Debug.Log($"[PlayerCharacterController] Cleared saved player state: {path}", this); + } + catch (Exception e) + { + Debug.LogWarning($"[PlayerCharacterController] Clearing saved player state failed: {e.Message}", this); + } + } +} diff --git a/Assets/Scripts/PlayerCharacterController.Persistence.cs.meta b/Assets/Scripts/PlayerCharacterController.Persistence.cs.meta new file mode 100644 index 0000000..0adce57 --- /dev/null +++ b/Assets/Scripts/PlayerCharacterController.Persistence.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 527b09f5e99500640b40acffaa58f011 \ No newline at end of file diff --git a/Assets/Scripts/PlayerCharacterController.cs b/Assets/Scripts/PlayerCharacterController.cs index dba7635..9642f48 100644 --- a/Assets/Scripts/PlayerCharacterController.cs +++ b/Assets/Scripts/PlayerCharacterController.cs @@ -27,7 +27,7 @@ using UnityEngine.InputSystem; /// [RequireComponent(typeof(CharacterController))] [DisallowMultipleComponent] -public class PlayerCharacterController : MonoBehaviour +public partial class PlayerCharacterController : MonoBehaviour { [Header("Movement")] [Tooltip("Horizontal movement speed (units/second).")] @@ -428,7 +428,10 @@ public class PlayerCharacterController : MonoBehaviour if (found && !onCeiling && _climbLeftGround && Vector3.Angle(_wallNormal, Vector3.up) < wallMinAngle) { - TopOutDetach(); // climbed face flattened into walkable: hop over the lip instead of dropping onto the steep section below + // Hop over the lip only when actually moving up onto the flattened face (a real + // top-out); if we reached it going down, just step off cleanly with no hop. + if (v > 0f) TopOutDetach(); + else ExitWallClimb(); return; } @@ -530,17 +533,27 @@ public class PlayerCharacterController : MonoBehaviour _controller.Move(velocity * dt); - // Reaching walkable ground detaches and drops back to normal grounded movement. Gated - // on having left the ground first, so grabbing a wall at its base (still grounded) - // doesn't instantly detach. We test for walkable ground under the feet rather than - // trusting isGrounded: the standoff correction can lift the capsule just off the - // surface, so at a slope's top isGrounded stays false and the old check never fired. + // Reaching walkable ground detaches back to normal grounded movement. The top-out hop + // (carry over the lip) fires ONLY when topping out upward over an edge (v > 0). Arriving + // at the floor by descending, OR pressing down/away (S, v < 0) at the wall's base while + // grounded, just lets go cleanly with no hop -- the latter is the only detach path that + // doesn't require having left the ground first, so a wall grabbed at its base can be + // released by pressing away. We test walkable ground under the feet rather than trusting + // isGrounded: the standoff correction can lift the capsule just off the surface, so at a + // slope's top isGrounded stays false and the old check never fired. bool onWalkableGround = _controller.isGrounded || WalkableGroundBelow(); if (!onWalkableGround) - _climbLeftGround = true; - else if (_climbLeftGround) { - TopOutDetach(); // reached walkable ground at the top: hop up onto it instead of sliding back down the face + _climbLeftGround = true; + } + else if (_climbLeftGround && v > 0f) + { + TopOutDetach(); // topped out upward onto walkable ground: hop over the lip (holding W carries on) + return; + } + else if (_climbLeftGround || v < 0f) + { + ExitWallClimb(); // descended to the floor, or pulling down/away at the base: let go, no hop return; } diff --git a/Assets/Scripts/ThirdPersonOrbitInput.Persistence.cs b/Assets/Scripts/ThirdPersonOrbitInput.Persistence.cs new file mode 100644 index 0000000..c1c6db4 --- /dev/null +++ b/Assets/Scripts/ThirdPersonOrbitInput.Persistence.cs @@ -0,0 +1,143 @@ +using System; +using System.IO; +using UnityEngine; +using UnityEngine.SceneManagement; + +public partial class ThirdPersonOrbitInput +{ + // --- Persistence: third-person camera orbit (rotation + distance) --- + // + // Saved on quit / play-stop and restored on the next start, so the camera resumes at the same + // view. The camera is a CinemachineOrbitalFollow whose world position/rotation are derived each + // frame from the orbit axes (yaw/pitch) and radius relative to the follow target - so saving the + // literal transform would be pointless (Cinemachine overwrites it). The canonical state is the + // orbit yaw/pitch (= rotation) and radius (= distance); combined with the persisted player + // position this reproduces the exact same view. Small structured state -> human-readable JSON + // (like PlayerCharacterController.Persistence), not the painter's raw byte dump. + + [Header("Persistence")] + [Tooltip("Save the camera orbit (yaw/pitch rotation and zoom distance) on quit / play-stop and " + + "restore it on the next start. Off = the camera starts at its authored orbit state.")] + public bool persistCamera = true; + + [Tooltip("Save file identifier. Empty = derived from the active scene + object name. Set " + + "explicitly to keep a save stable across object renames.")] + public string cameraSaveId = ""; + + private const int CameraFileVersion = 1; + private bool _cameraSavedOnTeardown; // guards a double save across OnApplicationQuit + OnDestroy + + private static string CameraSaveDir => Path.Combine(Application.persistentDataPath, "MeshPaint"); + + private string CameraSavePath() + { + string key = string.IsNullOrEmpty(cameraSaveId) + ? SanitizeCameraKey(SceneManager.GetActiveScene().name + "_" + gameObject.name) + : SanitizeCameraKey(cameraSaveId); + return Path.Combine(CameraSaveDir, key + ".camera"); + } + + private static string SanitizeCameraKey(string s) + { + foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_'); + return s.Replace(' ', '_'); + } + + [Serializable] + private class CameraState + { + public int version = CameraFileVersion; + public float yaw; // CinemachineOrbitalFollow.HorizontalAxis.Value + public float pitch; // CinemachineOrbitalFollow.VerticalAxis.Value + public float radius; // CinemachineOrbitalFollow.Radius (zoom distance) + } + + void Start() => RestoreCameraState(); + + private void OnApplicationQuit() => SaveCameraOnTeardown(); + private void OnDestroy() => SaveCameraOnTeardown(); + + /// Saves once during teardown (OnApplicationQuit and OnDestroy both call this; only the first writes). + private void SaveCameraOnTeardown() + { + if (_cameraSavedOnTeardown) return; + _cameraSavedOnTeardown = true; + SaveCameraState(); + } + + /// Writes the current orbit yaw/pitch and radius to disk. + public void SaveCameraState() + { + if (!persistCamera || !_orbit) return; + + try + { + var state = new CameraState + { + yaw = _orbit.HorizontalAxis.Value, + pitch = _orbit.VerticalAxis.Value, + radius = _orbit.Radius, + }; + + Directory.CreateDirectory(CameraSaveDir); + File.WriteAllText(CameraSavePath(), JsonUtility.ToJson(state, true)); + } + catch (Exception e) + { + Debug.LogWarning($"[ThirdPersonOrbitInput] Saving camera state failed: {e.Message}", this); + } + } + + /// Restores the saved orbit yaw/pitch and radius, or does nothing if there is no save. + private void RestoreCameraState() + { + if (!persistCamera || !_orbit) return; + + var state = LoadCameraState(); + if (state == null) return; + + _orbit.HorizontalAxis.Value = state.yaw; + _orbit.VerticalAxis.Value = state.pitch; + + // Clamp into the current zoom range (min/max may have changed in the inspector since saving) + // and seed the eased zoom target so the radius doesn't drift back toward the authored value. + float radius = Mathf.Clamp(state.radius, minRadius, maxRadius); + _orbit.Radius = radius; + _targetRadius = radius; + _zoomInit = true; + } + + private CameraState LoadCameraState() + { + string path = CameraSavePath(); + if (!File.Exists(path)) return null; + + try + { + var state = JsonUtility.FromJson(File.ReadAllText(path)); + if (state == null || state.version != CameraFileVersion) return null; + return state; + } + catch (Exception e) + { + Debug.LogWarning($"[ThirdPersonOrbitInput] Loading camera state failed: {e.Message}", this); + return null; + } + } + + /// Deletes the saved camera state (next start uses the authored orbit). Editor convenience. + [ContextMenu("Clear Saved Camera State")] + public void ClearSavedCameraState() + { + try + { + string path = CameraSavePath(); + if (File.Exists(path)) File.Delete(path); + Debug.Log($"[ThirdPersonOrbitInput] Cleared saved camera state: {path}", this); + } + catch (Exception e) + { + Debug.LogWarning($"[ThirdPersonOrbitInput] Clearing saved camera state failed: {e.Message}", this); + } + } +} diff --git a/Assets/Scripts/ThirdPersonOrbitInput.Persistence.cs.meta b/Assets/Scripts/ThirdPersonOrbitInput.Persistence.cs.meta new file mode 100644 index 0000000..90d852b --- /dev/null +++ b/Assets/Scripts/ThirdPersonOrbitInput.Persistence.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9680e7022f5587f49864d07bd74e49ee \ No newline at end of file diff --git a/Assets/Scripts/ThirdPersonOrbitInput.cs b/Assets/Scripts/ThirdPersonOrbitInput.cs index eb08c5a..7e5918c 100644 --- a/Assets/Scripts/ThirdPersonOrbitInput.cs +++ b/Assets/Scripts/ThirdPersonOrbitInput.cs @@ -12,7 +12,7 @@ using UnityEngine.InputSystem; /// [RequireComponent(typeof(CinemachineOrbitalFollow))] [DisallowMultipleComponent] -public class ThirdPersonOrbitInput : MonoBehaviour +public partial class ThirdPersonOrbitInput : MonoBehaviour { public enum OrbitLookMode { diff --git a/Assets/Supercyan Free Forest Sample/Materials/High Quality/SignAndBridge/forestpack_roadSignAndBridge.mat b/Assets/Supercyan Free Forest Sample/Materials/High Quality/SignAndBridge/forestpack_roadSignAndBridge.mat index bad598e..d4b53e3 100644 --- a/Assets/Supercyan Free Forest Sample/Materials/High Quality/SignAndBridge/forestpack_roadSignAndBridge.mat +++ b/Assets/Supercyan Free Forest Sample/Materials/High Quality/SignAndBridge/forestpack_roadSignAndBridge.mat @@ -54,7 +54,7 @@ Material: m_Scale: {x: 1, y: 1} m_Offset: {x: 0, y: 0} - _MainTex: - m_Texture: {fileID: 0} + m_Texture: {fileID: 2800000, guid: d1de526d00e4052458506ce20689b585, type: 3} m_Scale: {x: 1, y: 1} m_Offset: {x: 0, y: 0} - _MetallicGlossMap: @@ -137,7 +137,7 @@ Material: - __dirty: 1 m_Colors: - _BaseColor: {r: 0.98529404, g: 0.98529404, b: 0.98529404, a: 1} - - _Color: {r: 1, g: 1, b: 1, a: 1} + - _Color: {r: 0.98529404, g: 0.98529404, b: 0.98529404, a: 1} - _ColorMultiply: {r: 1, g: 1, b: 1, a: 1} - _ColorOverride: {r: 1, g: 1, b: 1, a: 0} - _EmissionColor: {r: 0, g: 0, b: 0, a: 1}