Add player position/rotation/wall-climb persistence and camera orbit (yaw/pitch/radius) persistence, auto-saved on quit/play-stop and auto-loaded on start. Also includes the wall-climb fixes (directional top-out, base detach on push-away). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
159 lines
5.9 KiB
C#
159 lines
5.9 KiB
C#
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();
|
|
|
|
/// <summary>Saves once during teardown (OnApplicationQuit and OnDestroy both call this; only the first writes).</summary>
|
|
private void SavePlayerOnTeardown()
|
|
{
|
|
if (_playerSavedOnTeardown) return;
|
|
_playerSavedOnTeardown = true;
|
|
SavePlayerState();
|
|
}
|
|
|
|
/// <summary>Writes the current transform and wall-climb state to disk.</summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Restores the saved transform and wall-climb state, or does nothing if there is no save.</summary>
|
|
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<PlayerState>(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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Deletes the saved player state (next start uses the authored scene transform). Editor convenience.</summary>
|
|
[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);
|
|
}
|
|
}
|
|
}
|