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}