diff --git a/Assets/Scenes/SampleScene.unity b/Assets/Scenes/SampleScene.unity
index c52489c..c5c1312 100644
--- a/Assets/Scenes/SampleScene.unity
+++ b/Assets/Scenes/SampleScene.unity
@@ -152,16 +152,15 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: df129be8fadab79498bd429a59bb98e4, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::ThirdPersonOrbitInput
+ lookMode: 0
horizontalSensitivity: 0.2
verticalSensitivity: 0.15
invertY: 0
- toggleKey: 3
- startInLookMode: 1
enableZoom: 1
zoomSpeed: 0.5
minRadius: 1.5
maxRadius: 8
- lockCursorInLookMode: 1
+ manageCursor: 1
--- !u!114 &172071432
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -779,7 +778,7 @@ MonoBehaviour:
m_EditorClassIdentifier: Assembly-CSharp::MeshPainter
useGpu: 1
brushColor: {r: 0, g: 0, b: 0, a: 1}
- brushSize: 2
+ brushSize: 10
textureSize: 1024
baseColor: {r: 0.8, g: 0.8, b: 0.8, a: 1}
paddingTexels: 2
@@ -1205,6 +1204,55 @@ MonoBehaviour:
slideAcceleration: 20
maxSlideSpeed: 8
slideControl: 0.3
+--- !u!1 &1516578026
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 1516578028}
+ - component: {fileID: 1516578027}
+ m_Layer: 0
+ m_Name: GameManager
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!114 &1516578027
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1516578026}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 730466b26610d95498d22ab748d855a7, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Assembly-CSharp::GameModeManager
+ player: {fileID: 1141039642}
+ cameraInput: {fileID: 172071431}
+ painter: {fileID: 682237521}
+ switchKey: 3
+ startModeIndex: 0
+--- !u!4 &1516578028
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1516578026}
+ 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 &1774267801
GameObject:
m_ObjectHideFlags: 0
@@ -1302,3 +1350,4 @@ SceneRoots:
- {fileID: 963088882}
- {fileID: 200502122}
- {fileID: 172071436}
+ - {fileID: 1516578028}
diff --git a/Assets/Scripts/GameMode.cs b/Assets/Scripts/GameMode.cs
new file mode 100644
index 0000000..ae8bf04
--- /dev/null
+++ b/Assets/Scripts/GameMode.cs
@@ -0,0 +1,29 @@
+///
+/// Base class for a high-level game mode (state) handled by
+/// . A mode configures the relevant scene systems
+/// when it becomes active (), can clean up when it is left
+/// () and may run per-frame logic in .
+///
+/// Modes are plain C# classes (not MonoBehaviours). To add a new mode, derive
+/// from this class and register an instance in GameModeManager.BuildModes().
+///
+public abstract class GameMode
+{
+ /// The owning manager, providing access to the controlled systems.
+ protected GameModeManager Manager { get; private set; }
+
+ /// Human-readable name (shown in logs / debug UI).
+ public abstract string DisplayName { get; }
+
+ /// Wires the mode to its manager. Called once during registration.
+ internal void Bind(GameModeManager manager) => Manager = manager;
+
+ /// Called once when this mode becomes the active mode.
+ public virtual void Enter() { }
+
+ /// Called once when this mode stops being the active mode.
+ public virtual void Exit() { }
+
+ /// Called every frame while this mode is the active mode.
+ public virtual void Tick() { }
+}
diff --git a/Assets/Scripts/GameMode.cs.meta b/Assets/Scripts/GameMode.cs.meta
new file mode 100644
index 0000000..81ded8a
--- /dev/null
+++ b/Assets/Scripts/GameMode.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 1dbd23638c056cc4db7ce7c0ffffe53c
\ No newline at end of file
diff --git a/Assets/Scripts/GameModeManager.cs b/Assets/Scripts/GameModeManager.cs
new file mode 100644
index 0000000..35cb003
--- /dev/null
+++ b/Assets/Scripts/GameModeManager.cs
@@ -0,0 +1,117 @@
+using System.Collections.Generic;
+using UnityEngine;
+#if ENABLE_INPUT_SYSTEM
+using UnityEngine.InputSystem;
+#endif
+
+///
+/// Central state manager that switches the game between high-level modes.
+/// Currently two modes exist:
+/// 0. Player Control - WASD movement, camera orbits freely with the mouse.
+/// 1. Paint - player stands still, left mouse paints the mesh, the
+/// camera orbits only while the right mouse button is held.
+///
+/// Pressing the switch key (Tab) cycles to the next registered mode, so adding
+/// more modes later automatically extends the cycle.
+///
+/// The manager owns references to the systems a mode may toggle (player
+/// controller, orbit camera input, mesh painter) and exposes them to the modes.
+/// Modes are plain C# classes derived from ; register
+/// additional ones in .
+///
+/// Attach this to a single, persistent GameObject in the scene.
+///
+[DisallowMultipleComponent]
+public class GameModeManager : MonoBehaviour
+{
+ [Header("Controlled Systems")]
+ [Tooltip("Player movement controller. Empty = found automatically in the scene.")]
+ public PlayerCharacterController player;
+
+ [Tooltip("Third-person orbit camera input. Empty = found automatically in the scene.")]
+ public ThirdPersonOrbitInput cameraInput;
+
+ [Tooltip("Mesh painter. Empty = found automatically in the scene.")]
+ public MeshPainter painter;
+
+ [Header("Switching")]
+ [Tooltip("Key that cycles to the next mode.")]
+#if ENABLE_INPUT_SYSTEM
+ public Key switchKey = Key.Tab;
+#else
+ public KeyCode switchKey = KeyCode.Tab;
+#endif
+
+ [Tooltip("Index of the mode that is active at startup (0 = Player Control).")]
+ public int startModeIndex = 0;
+
+ // --- accessors used by the modes ---
+ public PlayerCharacterController Player => player;
+ public ThirdPersonOrbitInput CameraInput => cameraInput;
+ public MeshPainter Painter => painter;
+
+ private readonly List _modes = new List();
+ private int _activeIndex = -1;
+
+ /// The currently active mode (null before startup).
+ public GameMode ActiveMode =>
+ (_activeIndex >= 0 && _activeIndex < _modes.Count) ? _modes[_activeIndex] : null;
+
+ private void Awake()
+ {
+ // Auto-resolve references so the manager works with minimal wiring.
+ if (player == null) player = FindFirstObjectByType();
+ if (cameraInput == null) cameraInput = FindFirstObjectByType();
+ if (painter == null) painter = FindFirstObjectByType();
+
+ BuildModes();
+ }
+
+ private void Start()
+ {
+ if (_modes.Count > 0)
+ SwitchTo(Mathf.Clamp(startModeIndex, 0, _modes.Count - 1));
+ }
+
+ /// Registers all available modes. Add new modes here.
+ private void BuildModes()
+ {
+ _modes.Clear();
+ _modes.Add(new PlayerControlMode());
+ _modes.Add(new PaintMode());
+
+ foreach (var m in _modes)
+ m.Bind(this);
+ }
+
+ private void Update()
+ {
+ if (_modes.Count == 0) return;
+
+ if (WasSwitchPressed())
+ SwitchTo((_activeIndex + 1) % _modes.Count);
+
+ ActiveMode?.Tick();
+ }
+
+ /// Activates the mode at (Exit old, Enter new).
+ public void SwitchTo(int index)
+ {
+ if (index < 0 || index >= _modes.Count || index == _activeIndex) return;
+
+ ActiveMode?.Exit();
+ _activeIndex = index;
+ ActiveMode.Enter();
+
+ Debug.Log($"[GameModeManager] Mode -> {ActiveMode.DisplayName}", this);
+ }
+
+ private bool WasSwitchPressed()
+ {
+#if ENABLE_INPUT_SYSTEM
+ return Keyboard.current != null && Keyboard.current[switchKey].wasPressedThisFrame;
+#else
+ return Input.GetKeyDown(switchKey);
+#endif
+ }
+}
diff --git a/Assets/Scripts/GameModeManager.cs.meta b/Assets/Scripts/GameModeManager.cs.meta
new file mode 100644
index 0000000..0e59969
--- /dev/null
+++ b/Assets/Scripts/GameModeManager.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 730466b26610d95498d22ab748d855a7
\ No newline at end of file
diff --git a/Assets/Scripts/MeshPainter.cs b/Assets/Scripts/MeshPainter.cs
index 2c24ae2..71eb500 100644
--- a/Assets/Scripts/MeshPainter.cs
+++ b/Assets/Scripts/MeshPainter.cs
@@ -29,6 +29,12 @@ using UnityEngine.InputSystem;
[DisallowMultipleComponent]
public class MeshPainter : MonoBehaviour
{
+ ///
+ /// When false, mouse painting is ignored (the canvas/material stays as-is).
+ /// Toggled by the GameModeManager (painting is only enabled in paint mode).
+ ///
+ public bool PaintingEnabled { get; set; } = true;
+
[Header("Backend")]
[Tooltip("ON = GPU painting (shader/RenderTexture, fast). OFF = CPU painting. " +
"Can be toggled live in Play mode - the painted content is carried over.")]
@@ -429,6 +435,13 @@ public class MeshPainter : MonoBehaviour
// Live backend switching.
if (useGpu != _gpuActive) SwitchBackend(useGpu);
+ // Painting disabled by the game mode (e.g. player-control mode): do nothing.
+ if (!PaintingEnabled)
+ {
+ _hasLastScreen = false;
+ return;
+ }
+
if (updateColliderEveryFrame && _skinned != null)
{
BakeColliderMesh();
diff --git a/Assets/Scripts/PaintMode.cs b/Assets/Scripts/PaintMode.cs
new file mode 100644
index 0000000..2b1dfdf
--- /dev/null
+++ b/Assets/Scripts/PaintMode.cs
@@ -0,0 +1,21 @@
+///
+/// Mode 2: the player stands still and the mesh is painted with the left mouse
+/// button. The camera orbits only while the right mouse button is held, so
+/// dragging with the left button paints instead of rotating the view.
+///
+public class PaintMode : GameMode
+{
+ public override string DisplayName => "Paint";
+
+ public override void Enter()
+ {
+ if (Manager.Player != null)
+ Manager.Player.ControlEnabled = false;
+
+ if (Manager.CameraInput != null)
+ Manager.CameraInput.LookMode = ThirdPersonOrbitInput.OrbitLookMode.HoldRightMouseButton;
+
+ if (Manager.Painter != null)
+ Manager.Painter.PaintingEnabled = true;
+ }
+}
diff --git a/Assets/Scripts/PaintMode.cs.meta b/Assets/Scripts/PaintMode.cs.meta
new file mode 100644
index 0000000..dba9deb
--- /dev/null
+++ b/Assets/Scripts/PaintMode.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a4815d31a23d2b14fb3fd22d9e13630d
\ No newline at end of file
diff --git a/Assets/Scripts/PlayerCharacterController.cs b/Assets/Scripts/PlayerCharacterController.cs
index 7e1c07b..8c3522b 100644
--- a/Assets/Scripts/PlayerCharacterController.cs
+++ b/Assets/Scripts/PlayerCharacterController.cs
@@ -71,6 +71,13 @@ public class PlayerCharacterController : MonoBehaviour
[Range(0f, 1f)]
public float slideControl = 0.3f;
+ ///
+ /// When false, movement/jump input is ignored (the player stands still), but
+ /// gravity and ground contact keep running so the character stays grounded.
+ /// Toggled by the GameModeManager (e.g. disabled in paint mode).
+ ///
+ public bool ControlEnabled { get; set; } = true;
+
private CharacterController _controller;
private float _verticalVelocity;
@@ -92,21 +99,26 @@ public class PlayerCharacterController : MonoBehaviour
float h = 0f, v = 0f;
bool jumpPressed = false;
-#if ENABLE_INPUT_SYSTEM
- var keyboard = Keyboard.current;
- if (keyboard != null)
+ // Skip input entirely when control is disabled (e.g. paint mode); gravity
+ // and grounding below still run so the player stays planted on the ground.
+ if (ControlEnabled)
{
- if (keyboard.aKey.isPressed) h -= 1f;
- if (keyboard.dKey.isPressed) h += 1f;
- if (keyboard.sKey.isPressed) v -= 1f;
- if (keyboard.wKey.isPressed) v += 1f;
- jumpPressed = keyboard.spaceKey.wasPressedThisFrame;
- }
+#if ENABLE_INPUT_SYSTEM
+ var keyboard = Keyboard.current;
+ if (keyboard != null)
+ {
+ if (keyboard.aKey.isPressed) h -= 1f;
+ if (keyboard.dKey.isPressed) h += 1f;
+ if (keyboard.sKey.isPressed) v -= 1f;
+ if (keyboard.wKey.isPressed) v += 1f;
+ jumpPressed = keyboard.spaceKey.wasPressedThisFrame;
+ }
#else
- h = Input.GetAxisRaw("Horizontal");
- v = Input.GetAxisRaw("Vertical");
- jumpPressed = Input.GetButtonDown("Jump");
+ h = Input.GetAxisRaw("Horizontal");
+ v = Input.GetAxisRaw("Vertical");
+ jumpPressed = Input.GetButtonDown("Jump");
#endif
+ }
// --- Build the horizontal move direction ----------------------------
Vector3 inputDir = new Vector3(h, 0f, v);
diff --git a/Assets/Scripts/PlayerControlMode.cs b/Assets/Scripts/PlayerControlMode.cs
new file mode 100644
index 0000000..2998da4
--- /dev/null
+++ b/Assets/Scripts/PlayerControlMode.cs
@@ -0,0 +1,20 @@
+///
+/// Mode 1: the player walks with WASD/Space and the camera orbits freely with
+/// the mouse (cursor locked/hidden). Mesh painting is disabled.
+///
+public class PlayerControlMode : GameMode
+{
+ public override string DisplayName => "Player Control";
+
+ public override void Enter()
+ {
+ if (Manager.Player != null)
+ Manager.Player.ControlEnabled = true;
+
+ if (Manager.CameraInput != null)
+ Manager.CameraInput.LookMode = ThirdPersonOrbitInput.OrbitLookMode.AlwaysActive;
+
+ if (Manager.Painter != null)
+ Manager.Painter.PaintingEnabled = false;
+ }
+}
diff --git a/Assets/Scripts/PlayerControlMode.cs.meta b/Assets/Scripts/PlayerControlMode.cs.meta
new file mode 100644
index 0000000..4dc72bc
--- /dev/null
+++ b/Assets/Scripts/PlayerControlMode.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c2f4d0884ba29d849afcaf6121027023
\ No newline at end of file
diff --git a/Assets/Scripts/ThirdPersonOrbitInput.cs b/Assets/Scripts/ThirdPersonOrbitInput.cs
index 158983d..188773f 100644
--- a/Assets/Scripts/ThirdPersonOrbitInput.cs
+++ b/Assets/Scripts/ThirdPersonOrbitInput.cs
@@ -8,10 +8,14 @@ using UnityEngine.InputSystem;
/// Feeds mouse input into a so the camera
/// orbits around the follow target like a classic third-person camera.
///
-/// Rotation is always on by default and the cursor is locked/hidden while looking.
-/// Pressing the toggle key (Tab) unlocks the cursor and disables camera rotation,
-/// so you can freely use the mouse (e.g. paint the mesh with the left button or
-/// click UI). Pressing it again re-locks the cursor and re-enables rotation.
+/// How the orbit is driven is controlled by :
+/// * AlwaysActive - rotates with every mouse move, cursor locked/hidden.
+/// * HoldRightMouseButton - rotates only while the right mouse button is held;
+/// the cursor is free otherwise (so the left button can paint).
+/// * Disabled - no rotation, cursor free.
+///
+/// The look mode is normally set by the GameModeManager when switching modes,
+/// but can also be configured directly in the Inspector for standalone use.
///
/// Horizontal/vertical mouse movement drives the orbital axes (yaw/pitch); the
/// mouse wheel zooms by changing the orbit radius. The script reads the new Input
@@ -25,6 +29,24 @@ using UnityEngine.InputSystem;
[DisallowMultipleComponent]
public class ThirdPersonOrbitInput : MonoBehaviour
{
+ /// How the camera orbit is driven.
+ public enum OrbitLookMode
+ {
+ /// Rotate with every mouse move; cursor locked/hidden.
+ AlwaysActive,
+ /// Rotate only while the right mouse button is held; cursor free otherwise.
+ HoldRightMouseButton,
+ /// No rotation; cursor free.
+ Disabled
+ }
+
+ [Header("Look Mode")]
+ [Tooltip("How the camera orbit is driven. Usually set by GameModeManager:\n" +
+ "AlwaysActive = rotates with every mouse move (cursor locked).\n" +
+ "HoldRightMouseButton = rotates only while RMB is held (cursor free otherwise).\n" +
+ "Disabled = no rotation.")]
+ [SerializeField] private OrbitLookMode lookMode = OrbitLookMode.AlwaysActive;
+
[Header("Rotation")]
[Tooltip("Horizontal look sensitivity in degrees of yaw per pixel of mouse movement.")]
public float horizontalSensitivity = 0.2f;
@@ -35,23 +57,11 @@ public class ThirdPersonOrbitInput : MonoBehaviour
[Tooltip("If ON, moving the mouse up tilts the camera down (inverted pitch).")]
public bool invertY = false;
- [Header("Toggle")]
- [Tooltip("Key that toggles between look mode (cursor locked, camera rotates) and " +
- "free mode (cursor unlocked, camera rotation disabled).")]
-#if ENABLE_INPUT_SYSTEM
- public Key toggleKey = Key.Tab;
-#else
- public KeyCode toggleKey = KeyCode.Tab;
-#endif
-
- [Tooltip("If ON, the camera starts in look mode (rotation enabled, cursor locked).")]
- public bool startInLookMode = true;
-
[Header("Zoom")]
[Tooltip("If ON, the mouse wheel zooms the camera in and out (changes orbit radius).")]
public bool enableZoom = true;
- [Tooltip("How much the orbit radius changes per mouse wheel notch.")]
+ [Tooltip("How many world units the orbit radius changes per mouse wheel notch.")]
public float zoomSpeed = 0.5f;
[Tooltip("Closest the camera can zoom in (orbit radius in units).")]
@@ -61,14 +71,21 @@ public class ThirdPersonOrbitInput : MonoBehaviour
public float maxRadius = 8f;
[Header("Cursor")]
- [Tooltip("If ON, the cursor is locked and hidden while in look mode (rotation " +
- "enabled), and freed in free mode so you can use the mouse normally.")]
- public bool lockCursorInLookMode = true;
+ [Tooltip("If ON, the cursor is locked and hidden while the camera is actively " +
+ "rotating, and freed otherwise so you can use the mouse normally.")]
+ public bool manageCursor = true;
CinemachineOrbitalFollow _orbit;
- // True = look mode (cursor locked, camera rotates). False = free mode.
- bool _rotationEnabled = true;
+ ///
+ /// How the camera orbit is currently driven. Setting this also updates the
+ /// cursor state immediately (e.g. when GameModeManager switches modes).
+ ///
+ public OrbitLookMode LookMode
+ {
+ get => lookMode;
+ set { lookMode = value; ApplyCursorState(); }
+ }
void Awake()
{
@@ -79,7 +96,6 @@ public class ThirdPersonOrbitInput : MonoBehaviour
void OnEnable()
{
- _rotationEnabled = startInLookMode;
ApplyCursorState();
}
@@ -94,14 +110,14 @@ public class ThirdPersonOrbitInput : MonoBehaviour
if (_orbit == null)
return;
- // Tab toggles between look mode and free mode.
- if (WasTogglePressed())
- {
- _rotationEnabled = !_rotationEnabled;
- ApplyCursorState();
- }
+ bool rotating = ShouldRotate();
- if (_rotationEnabled)
+ // Keep the cursor in sync every frame: in HoldRightMouseButton mode the
+ // lock state follows the right mouse button.
+ if (manageCursor)
+ SetCursorLocked(rotating);
+
+ if (rotating)
{
Vector2 delta = ReadMouseDelta();
if (delta.sqrMagnitude > 0f)
@@ -116,14 +132,31 @@ public class ThirdPersonOrbitInput : MonoBehaviour
{
float scroll = ReadScroll();
if (!Mathf.Approximately(scroll, 0f))
- _orbit.Radius = Mathf.Clamp(_orbit.Radius - scroll * zoomSpeed, minRadius, maxRadius);
+ {
+ // One wheel notch = one zoom step. Use only the sign so the step
+ // size is exactly zoomSpeed regardless of the platform's raw wheel
+ // magnitude (the new Input System reports ~1 per notch, the legacy
+ // Windows path used ~120). Scrolling up (positive) zooms in.
+ _orbit.Radius = Mathf.Clamp(_orbit.Radius - Mathf.Sign(scroll) * zoomSpeed, minRadius, maxRadius);
+ }
+ }
+ }
+
+ /// Whether the camera should orbit this frame, based on the look mode.
+ bool ShouldRotate()
+ {
+ switch (lookMode)
+ {
+ case OrbitLookMode.AlwaysActive: return true;
+ case OrbitLookMode.HoldRightMouseButton: return IsRightMouseHeld();
+ default: return false;
}
}
void ApplyCursorState()
{
- if (lockCursorInLookMode)
- SetCursorLocked(_rotationEnabled);
+ if (manageCursor)
+ SetCursorLocked(ShouldRotate());
}
///
@@ -155,12 +188,12 @@ public class ThirdPersonOrbitInput : MonoBehaviour
Cursor.visible = !locked;
}
- bool WasTogglePressed()
+ bool IsRightMouseHeld()
{
#if ENABLE_INPUT_SYSTEM
- return Keyboard.current != null && Keyboard.current[toggleKey].wasPressedThisFrame;
+ return Mouse.current != null && Mouse.current.rightButton.isPressed;
#else
- return Input.GetKeyDown(toggleKey);
+ return Input.GetMouseButton(1);
#endif
}
@@ -177,10 +210,11 @@ public class ThirdPersonOrbitInput : MonoBehaviour
float ReadScroll()
{
#if ENABLE_INPUT_SYSTEM
- // Scroll y is reported in steps of ~120 per notch; normalize to ~1 per notch.
- return Mouse.current != null ? Mouse.current.scroll.ReadValue().y / 120f : 0f;
+ // Raw wheel delta. Only its sign is used in Update (one notch = one zoom
+ // step), so the platform-dependent magnitude does not matter here.
+ return Mouse.current != null ? Mouse.current.scroll.ReadValue().y : 0f;
#else
- return Input.GetAxis("Mouse ScrollWheel") * 10f;
+ return Input.GetAxis("Mouse ScrollWheel");
#endif
}
}