├── Images ├── Actor.png ├── Cover.png ├── MessageExchange.png ├── AnimatorController.png ├── MessageInterchange.png └── MessageExchange-Animator.png ├── Player ├── PlayerSMBDie.cs ├── PlayerSMBDrop.cs ├── PlayerSMBTake.cs ├── PlayerSMBIdle.cs ├── PlayerSMBFreeFall.cs ├── PlayerSMBGetHit.cs ├── PlayerKnob.cs ├── PlayerSMBRoll.cs ├── PlayerSMBAttack.cs ├── PlayerBroadcaster.cs ├── PlayerReactorFootstep.cs ├── PlayerHUDDriver.cs ├── PlayerControl.cs ├── PlayerActorDie.cs ├── PlayerHeadlamp.cs ├── PlayerActorDrop.cs ├── PlayerAnimationEventProxy.cs ├── PlayerHealth.cs ├── PlayerActorIdle.cs ├── PlayerSensorItem.cs ├── PlayerActorGetHit.cs ├── PlayerParam.cs ├── PlayerActorRoll.cs ├── PlayerParamData.cs ├── PlayerActorFreeFall.cs ├── PlayerAnimatorDriver.cs ├── PlayerSensorProximity.cs ├── PlayerSignpost.cs ├── PlayerActorTake.cs ├── Player.cs └── PlayerActorAttack.cs ├── Core ├── MessageExchangeSMB.cs ├── Cooldown.cs ├── FiniteStateMachine.cs ├── MessageExchange.cs ├── MessageInterchange.cs ├── BaseSMB.cs ├── BasicTypeExtensions.cs └── Locomotor.cs ├── LICENSE ├── Input └── PlayerInputDriver.cs └── README.md /Images/Actor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeonghoey/znk-codesample/HEAD/Images/Actor.png -------------------------------------------------------------------------------- /Images/Cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeonghoey/znk-codesample/HEAD/Images/Cover.png -------------------------------------------------------------------------------- /Images/MessageExchange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeonghoey/znk-codesample/HEAD/Images/MessageExchange.png -------------------------------------------------------------------------------- /Images/AnimatorController.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeonghoey/znk-codesample/HEAD/Images/AnimatorController.png -------------------------------------------------------------------------------- /Images/MessageInterchange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeonghoey/znk-codesample/HEAD/Images/MessageInterchange.png -------------------------------------------------------------------------------- /Images/MessageExchange-Animator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeonghoey/znk-codesample/HEAD/Images/MessageExchange-Animator.png -------------------------------------------------------------------------------- /Player/PlayerSMBDie.cs: -------------------------------------------------------------------------------- 1 | public interface IMETPlayerSMBDie : IMessageExchangeTarget 2 | { 3 | void OnEnterSolo(); 4 | } 5 | 6 | public class PlayerSMBDie : MessageExchangeSMB 7 | { 8 | private static readonly System.Action callOnEnterSolo = t => t.OnEnterSolo(); 9 | 10 | public override void OnEnterSolo() 11 | { 12 | Invoke(callOnEnterSolo); 13 | } 14 | } -------------------------------------------------------------------------------- /Player/PlayerSMBDrop.cs: -------------------------------------------------------------------------------- 1 | public interface IMETPlayerSMBDrop : IMessageExchangeTarget 2 | { 3 | void OnEnter(); 4 | void OnExit(); 5 | } 6 | 7 | public class PlayerSMBDrop : MessageExchangeSMB 8 | { 9 | private static readonly System.Action callOnEnter = t => t.OnEnter(); 10 | private static readonly System.Action callOnExit = t => t.OnExit(); 11 | 12 | public override void OnEnter() 13 | { 14 | Invoke(callOnEnter); 15 | } 16 | 17 | public override void OnExit() 18 | { 19 | Invoke(callOnExit); 20 | } 21 | } -------------------------------------------------------------------------------- /Player/PlayerSMBTake.cs: -------------------------------------------------------------------------------- 1 | public interface IMETPlayerSMBTake : IMessageExchangeTarget 2 | { 3 | void OnEnter(); 4 | void OnExit(); 5 | } 6 | 7 | public class PlayerSMBTake : MessageExchangeSMB 8 | { 9 | private static readonly System.Action callOnEnter = t => t.OnEnter(); 10 | private static readonly System.Action callOnExit = t => t.OnExit(); 11 | 12 | public override void OnEnter() 13 | { 14 | Invoke(callOnEnter); 15 | } 16 | 17 | public override void OnExit() 18 | { 19 | Invoke(callOnExit); 20 | } 21 | } -------------------------------------------------------------------------------- /Player/PlayerSMBIdle.cs: -------------------------------------------------------------------------------- 1 | public interface IMETPlayerSMBIdle : IMessageExchangeTarget 2 | { 3 | void OnEnterSolo(); 4 | void OnExitSolo(); 5 | } 6 | 7 | public class PlayerSMBIdle : MessageExchangeSMB 8 | { 9 | private static readonly System.Action callOnEnterSolo = t => t.OnEnterSolo(); 10 | private static readonly System.Action callOnExitSolo = t => t.OnExitSolo(); 11 | 12 | public override void OnEnterSolo() 13 | { 14 | Invoke(callOnEnterSolo); 15 | } 16 | 17 | public override void OnExitSolo() 18 | { 19 | Invoke(callOnExitSolo); 20 | } 21 | } -------------------------------------------------------------------------------- /Player/PlayerSMBFreeFall.cs: -------------------------------------------------------------------------------- 1 | public interface IMETPlayerSMBFreeFall : IMessageExchangeTarget 2 | { 3 | void OnEnterSolo(); 4 | void OnExitSolo(); 5 | } 6 | 7 | public class PlayerSMBFreeFall : MessageExchangeSMB 8 | { 9 | private static readonly System.Action callOnEnterSolo = t => t.OnEnterSolo(); 10 | private static readonly System.Action callOnExitSolo = t => t.OnExitSolo(); 11 | 12 | public override void OnEnterSolo() 13 | { 14 | Invoke(callOnEnterSolo); 15 | } 16 | 17 | public override void OnExitSolo() 18 | { 19 | Invoke(callOnExitSolo); 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /Core/MessageExchangeSMB.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using UnityEngine; 3 | 4 | public class MessageExchangeSMB : BaseSMB 5 | { 6 | private MessageExchange CachedMessageExchange 7 | { 8 | get 9 | { 10 | if (cachedMessageExchange == null) 11 | { 12 | cachedMessageExchange = LastAnimator.GetComponentInParent(); 13 | } 14 | return cachedMessageExchange; 15 | } 16 | } 17 | 18 | private MessageExchange cachedMessageExchange; 19 | 20 | protected void Invoke(Action f) where T : IMessageExchangeTarget 21 | { 22 | CachedMessageExchange.Invoke(f); 23 | } 24 | } -------------------------------------------------------------------------------- /Player/PlayerSMBGetHit.cs: -------------------------------------------------------------------------------- 1 | public interface IMETPlayerSMBGetHit : IMessageExchangeTarget 2 | { 3 | void OnEnter(); 4 | void OnEnterSolo(); 5 | void OnExitSolo(); 6 | } 7 | 8 | public class PlayerSMBGetHit : MessageExchangeSMB 9 | { 10 | private static readonly System.Action callOnEnter = t => t.OnEnter(); 11 | private static readonly System.Action callOnEnterSolo = t => t.OnEnterSolo(); 12 | private static readonly System.Action callOnExitSolo = t => t.OnExitSolo(); 13 | 14 | public override void OnEnter() 15 | { 16 | Invoke(callOnEnter); 17 | } 18 | 19 | public override void OnEnterSolo() 20 | { 21 | Invoke(callOnEnterSolo); 22 | } 23 | 24 | public override void OnExitSolo() 25 | { 26 | Invoke(callOnExitSolo); 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /Player/PlayerKnob.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | 3 | public class PlayerKnob : MonoBehaviour, 4 | IMETPlayerSensorItemKnob 5 | { 6 | [SerializeField] private MessageExchange messageExchange; 7 | 8 | void OnEnable() 9 | { 10 | messageExchange.Register(this); 11 | } 12 | 13 | void OnDisable() 14 | { 15 | messageExchange.Deregister(this); 16 | } 17 | 18 | void IMETPlayerSensorItemKnob.OnEnter(Collider other) 19 | { 20 | var knob = other.GetComponent(); 21 | if (knob == null) 22 | { 23 | return; 24 | } 25 | knob.OnPlayerEnter(); 26 | } 27 | 28 | void IMETPlayerSensorItemKnob.OnExit(Collider other) 29 | { 30 | var knob = other.GetComponent(); 31 | if (knob == null) 32 | { 33 | return; 34 | } 35 | knob.OnPlayerExit(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Player/PlayerSMBRoll.cs: -------------------------------------------------------------------------------- 1 | public interface IMETPlayerSMBRoll : IMessageExchangeTarget 2 | { 3 | void OnEnter(); 4 | void OnEnterSolo(); 5 | void OnExitSolo(); 6 | void OnExit(); 7 | } 8 | 9 | public class PlayerSMBRoll : MessageExchangeSMB 10 | { 11 | private static readonly System.Action callOnEnter = t => t.OnEnter(); 12 | private static readonly System.Action callOnEnterSolo = t => t.OnEnterSolo(); 13 | private static readonly System.Action callOnExitSolo = t => t.OnExitSolo(); 14 | private static readonly System.Action callOnExit = t => t.OnExit(); 15 | 16 | public override void OnEnter() 17 | { 18 | Invoke(callOnEnter); 19 | } 20 | 21 | public override void OnEnterSolo() 22 | { 23 | Invoke(callOnEnterSolo); 24 | } 25 | 26 | public override void OnExitSolo() 27 | { 28 | Invoke(callOnExitSolo); 29 | } 30 | 31 | public override void OnExit() 32 | { 33 | Invoke(callOnExit); 34 | } 35 | } -------------------------------------------------------------------------------- /Player/PlayerSMBAttack.cs: -------------------------------------------------------------------------------- 1 | public interface IMETPlayerSMBAttack : IMessageExchangeTarget 2 | { 3 | void OnEnter(); 4 | void OnEnterSolo(); 5 | void OnExitSolo(); 6 | void OnExit(); 7 | } 8 | 9 | public class PlayerSMBAttack : MessageExchangeSMB 10 | { 11 | private static readonly System.Action callOnEnter = t => t.OnEnter(); 12 | private static readonly System.Action callOnEnterSolo = t => t.OnEnterSolo(); 13 | private static readonly System.Action callOnExitSolo = t => t.OnExitSolo(); 14 | private static readonly System.Action callOnExit = t => t.OnExit(); 15 | 16 | public override void OnEnter() 17 | { 18 | Invoke(callOnEnter); 19 | } 20 | 21 | public override void OnEnterSolo() 22 | { 23 | Invoke(callOnEnterSolo); 24 | } 25 | 26 | public override void OnExitSolo() 27 | { 28 | Invoke(callOnExitSolo); 29 | } 30 | 31 | public override void OnExit() 32 | { 33 | Invoke(callOnExit); 34 | } 35 | } -------------------------------------------------------------------------------- /Core/Cooldown.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | 3 | public class Cooldown 4 | { 5 | private float duration = 0f; 6 | private float timeRemaining = 0f; 7 | 8 | public void Set(float duration, bool isReadyInitially) 9 | { 10 | this.duration = duration; 11 | this.timeRemaining = isReadyInitially ? 0f : duration; 12 | } 13 | 14 | public void SetSpread(float duration) 15 | { 16 | this.duration = duration; 17 | this.timeRemaining = Random.Range(0f, duration); 18 | } 19 | 20 | public void Reset(bool isReady) 21 | { 22 | this.timeRemaining = isReady ? 0f : this.duration; 23 | } 24 | 25 | public void Tick(float deltaTime) 26 | { 27 | if (timeRemaining > 0f) 28 | { 29 | timeRemaining -= deltaTime; 30 | } 31 | } 32 | 33 | public bool IsReady 34 | { 35 | get => timeRemaining <= 0f; 36 | } 37 | 38 | public bool Claim() 39 | { 40 | if (timeRemaining > 0f) 41 | { 42 | return false; 43 | } 44 | timeRemaining = duration; 45 | return true; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Yeongho Kim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Player/PlayerBroadcaster.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using UnityEngine; 3 | 4 | public interface IMITPlayerBroadcasterOnGetHit : IMessageInterchangeTarget 5 | { 6 | void OnGetHit(); 7 | } 8 | 9 | public interface IMITPlayerBroadcasterOnDie : IMessageInterchangeTarget 10 | { 11 | void OnDead(GameObject playerGO); 12 | } 13 | 14 | public class PlayerBroadcaster : MonoBehaviour, 15 | IMETPlayerSMBGetHit, 16 | IMETPlayerActorDie 17 | { 18 | [SerializeField] private MessageInterchange messageInterchange; 19 | [SerializeField] private MessageExchange messageExchange; 20 | [SerializeField] private Player player; 21 | 22 | private static readonly Action callOnGetHit = t => t.OnGetHit(); 23 | private static readonly Action callOnDie = (t, go) => t.OnDead(go); 24 | 25 | void OnEnable() 26 | { 27 | messageExchange.Register(this); 28 | } 29 | 30 | void OnDisable() 31 | { 32 | messageExchange.Deregister(this); 33 | } 34 | 35 | void IMETPlayerSMBGetHit.OnEnter() { } 36 | 37 | void IMETPlayerSMBGetHit.OnEnterSolo() 38 | { 39 | messageInterchange.Invoke(callOnGetHit); 40 | } 41 | 42 | void IMETPlayerSMBGetHit.OnExitSolo() { } 43 | 44 | void IMETPlayerActorDie.OnDead() 45 | { 46 | messageInterchange.Invoke(callOnDie, player.gameObject); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Player/PlayerReactorFootstep.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | using FMODUnity; 3 | 4 | public class PlayerReactorFootstep : MonoBehaviour, 5 | IMETPlayerAnimationEventProxyOnFootstep 6 | { 7 | [SerializeField] private MessageExchange messageExchange; 8 | [SerializeField] private Locomotor locomotor; 9 | [SerializeField] private float maxSpeedForLowestPitch; 10 | 11 | [Header("L")] 12 | [SerializeField] private Transform footL; 13 | [SerializeField] private StudioEventEmitter footLAudio; 14 | 15 | [Header("R")] 16 | [SerializeField] private Transform footR; 17 | [SerializeField] private StudioEventEmitter footRAudio; 18 | 19 | void OnEnable() 20 | { 21 | messageExchange.Register(this); 22 | } 23 | 24 | void OnDisable() 25 | { 26 | messageExchange.Deregister(this); 27 | } 28 | 29 | void IMETPlayerAnimationEventProxyOnFootstep.OnStepLeft() 30 | { 31 | footLAudio.Play(); 32 | footLAudio.SetParameter("playerSpeedNormalized", CalculateSpeedNormalized()); 33 | } 34 | 35 | void IMETPlayerAnimationEventProxyOnFootstep.OnStepRight() 36 | { 37 | footRAudio.Play(); 38 | footRAudio.SetParameter("playerSpeedNormalized", CalculateSpeedNormalized()); 39 | } 40 | 41 | float CalculateSpeedNormalized() 42 | { 43 | return Mathf.Min(1.0f, locomotor.Speed / maxSpeedForLowestPitch); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Player/PlayerHUDDriver.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | using FMODUnity; 3 | 4 | public class PlayerHUDDriver : MonoBehaviour, 5 | IMETPlayerHealthOnChanage, 6 | IMITLevelEnd 7 | { 8 | [SerializeField] private MessageInterchange messageInterchange; 9 | [SerializeField] private MessageExchange messageExchange; 10 | [SerializeField] private HUDRing hudRing; 11 | [SerializeField] private HUDDotContainer hudHealth; 12 | [SerializeField] private StudioEventEmitter audioHeartbeat; 13 | 14 | private bool isOnEndPhase; 15 | 16 | void OnEnable() 17 | { 18 | messageInterchange.Register(this); 19 | messageExchange.Register(this); 20 | hudHealth.OnLoopBlink += PlayHeartbeat; 21 | } 22 | 23 | void OnDisable() 24 | { 25 | messageInterchange.Deregister(this); 26 | messageExchange.Deregister(this); 27 | hudHealth.OnLoopBlink -= PlayHeartbeat; 28 | } 29 | 30 | void Start() 31 | { 32 | isOnEndPhase = false; 33 | RefreshVisibility(); 34 | } 35 | 36 | void IMETPlayerHealthOnChanage.OnChanage(int healthRemaining) 37 | { 38 | hudHealth.IsBlinking = healthRemaining == 1; 39 | } 40 | 41 | void IMITLevelEnd.OnEndPhase(bool isGoalArrived) 42 | { 43 | isOnEndPhase = true; 44 | hudHealth.IsBlinking = false; 45 | RefreshVisibility(); 46 | } 47 | 48 | private void RefreshVisibility() 49 | { 50 | hudRing.IsVisible = !isOnEndPhase; 51 | hudHealth.IsVisible = !isOnEndPhase; 52 | } 53 | 54 | private void PlayHeartbeat() 55 | { 56 | audioHeartbeat.Play(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Player/PlayerControl.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | 3 | public class PlayerControl : MonoBehaviour, IMITPlayerInputDriver 4 | { 5 | [SerializeField] private MessageInterchange messageInterchange; 6 | 7 | private const float sqrMagAttackInputThreshold = 0.75f * 0.75f; 8 | 9 | public Vector2 InputDirMove { get; private set; } 10 | public Vector3 WorldDirMove { get; private set; } 11 | public Vector2 InputDirAttack { get; private set; } 12 | public float SqrMagInputDirAttack { get => InputDirAttack.sqrMagnitude; } 13 | public Vector3 WorldDirAttack { get; private set; } 14 | public bool ButtonRoll { get; private set; } 15 | public bool ButtonTake { get; private set; } 16 | public bool ButtonDrop { get; private set; } 17 | 18 | void OnEnable() 19 | { 20 | messageInterchange.Register(this); 21 | } 22 | 23 | void OnDisable() 24 | { 25 | messageInterchange.Deregister(this); 26 | } 27 | 28 | void IMITPlayerInputDriver.OnMove(Vector2 inputDir) 29 | { 30 | InputDirMove = inputDir; 31 | WorldDirMove = InputDirMove.ToWorldDir(); 32 | } 33 | 34 | void IMITPlayerInputDriver.OnAttack(Vector2 inputDir) 35 | { 36 | if (inputDir.sqrMagnitude < sqrMagAttackInputThreshold) 37 | { 38 | inputDir = Vector2.zero; 39 | } 40 | InputDirAttack = inputDir; 41 | WorldDirAttack = InputDirAttack.ToWorldDir(); 42 | } 43 | 44 | void IMITPlayerInputDriver.OnRoll(bool isPressing) 45 | { 46 | ButtonRoll = isPressing; 47 | } 48 | 49 | void IMITPlayerInputDriver.OnTake(bool isPressing) 50 | { 51 | ButtonTake = isPressing; 52 | } 53 | 54 | void IMITPlayerInputDriver.OnDrop(bool isPressing) 55 | { 56 | ButtonDrop = isPressing; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Core/FiniteStateMachine.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | public class FiniteStateMachineState 5 | { 6 | public TContext C { get; private set; } 7 | 8 | public void Init(TContext context) 9 | { 10 | this.C = context; 11 | } 12 | 13 | public virtual void OnCreate() { } 14 | public virtual void OnEnter() { } 15 | public virtual void OnFixedUpdate() { } 16 | public virtual void OnUpdate() { } 17 | public virtual void OnLateUpdate() { } 18 | public virtual void OnExit() { } 19 | } 20 | 21 | public class FiniteStateMachine 22 | where TState : FiniteStateMachineState 23 | { 24 | public TContext Context { get; private set; } 25 | public TState Current { get; private set; } 26 | 27 | private Dictionary instanceMap = new Dictionary(); 28 | 29 | public void Init(TContext context) 30 | where S : TState, new() 31 | { 32 | Context = context; 33 | Current = InstanceOf(); 34 | Current.OnEnter(); 35 | } 36 | 37 | public void TransitionTo() 38 | where S : TState, new() 39 | { 40 | var nextState = InstanceOf(); 41 | if (Current == nextState) 42 | { 43 | return; 44 | } 45 | Current.OnExit(); 46 | Current = InstanceOf(); 47 | Current.OnEnter(); 48 | } 49 | 50 | public S InstanceOf() 51 | where S : TState, new() 52 | { 53 | Type t = typeof(S); 54 | TState s; 55 | if (!instanceMap.TryGetValue(t, out s)) 56 | { 57 | s = new S(); 58 | s.Init(Context); 59 | s.OnCreate(); 60 | instanceMap.Add(t, s); 61 | } 62 | return (S)s; 63 | } 64 | 65 | public void FixedUpdate() 66 | { 67 | Current.OnFixedUpdate(); 68 | } 69 | 70 | public void Update() 71 | { 72 | Current.OnUpdate(); 73 | } 74 | 75 | public void LateUpdate() 76 | { 77 | Current.OnLateUpdate(); 78 | } 79 | } -------------------------------------------------------------------------------- /Core/MessageExchange.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using UnityEngine; 4 | 5 | public interface IMessageExchangeTarget { } 6 | 7 | public class MessageExchange : MonoBehaviour 8 | { 9 | private DefaultDictionary> targets = 10 | new DefaultDictionary>(); 11 | 12 | public void Invoke(Action f) 13 | where TTarget : IMessageExchangeTarget 14 | { 15 | foreach (var t in targets[typeof(TTarget)]) 16 | { 17 | f((TTarget)t); 18 | } 19 | } 20 | 21 | public void Invoke(Action f, TArg arg) 22 | where TTarget : IMessageExchangeTarget 23 | { 24 | foreach (var t in targets[typeof(TTarget)]) 25 | { 26 | f((TTarget)t, arg); 27 | } 28 | } 29 | 30 | public void Invoke(Action f, TArg0 arg0, TArg1 arg1) 31 | where TTarget : IMessageExchangeTarget 32 | { 33 | foreach (var t in targets[typeof(TTarget)]) 34 | { 35 | f((TTarget)t, arg0, arg1); 36 | } 37 | } 38 | 39 | public void Invoke(Action f, TArg0 arg0, TArg1 arg1, TArg2 arg2) 40 | where TTarget : IMessageExchangeTarget 41 | { 42 | foreach (var t in targets[typeof(TTarget)]) 43 | { 44 | f((TTarget)t, arg0, arg1, arg2); 45 | } 46 | } 47 | 48 | public void Register(IMessageExchangeTarget target) 49 | { 50 | var baseType = typeof(IMessageExchangeTarget); 51 | 52 | foreach (var t in target.GetType().GetInterfaces()) 53 | { 54 | if (t != baseType && baseType.IsAssignableFrom(t)) 55 | { 56 | targets[t].Add(target); 57 | } 58 | } 59 | } 60 | 61 | public void Deregister(IMessageExchangeTarget target) 62 | { 63 | var baseType = typeof(IMessageExchangeTarget); 64 | 65 | foreach (var t in target.GetType().GetInterfaces()) 66 | { 67 | if (t != baseType && baseType.IsAssignableFrom(t)) 68 | { 69 | targets[t].Remove(target); 70 | } 71 | } 72 | } 73 | } 74 | 75 | -------------------------------------------------------------------------------- /Player/PlayerActorDie.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | using FMODUnity; 3 | 4 | public interface IMETPlayerActorDie : IMessageExchangeTarget 5 | { 6 | void OnDead(); 7 | } 8 | 9 | public class PlayerActorDie : MonoBehaviour, 10 | IMETPlayerHealthOnChanage, 11 | IMETPlayerOnBitten, 12 | IMETPlayerSMBDie 13 | { 14 | [SerializeField] private MessageExchange messageExchange; 15 | [SerializeField] private Locomotor locomotor; 16 | [SerializeField] private PlayerAnimatorDriver playerAnimatorDriver; 17 | [SerializeField] private PlayerParam playerParam; 18 | [SerializeField] private GameObject playerBloodSplatPrefab; 19 | [SerializeField] private Platformer platformer; 20 | [SerializeField] private Collider deadCollider; 21 | [SerializeField] private StudioEventEmitter audioDie; 22 | 23 | private bool isAlreadyDead; 24 | 25 | void Awake() 26 | { 27 | isAlreadyDead = false; 28 | } 29 | 30 | void OnEnable() 31 | { 32 | messageExchange.Register(this); 33 | } 34 | 35 | void OnDisable() 36 | { 37 | messageExchange.Deregister(this); 38 | } 39 | 40 | void IMETPlayerHealthOnChanage.OnChanage(int healthRemaining) 41 | { 42 | if (healthRemaining == 0) 43 | { 44 | playerAnimatorDriver.TriggerDie(); 45 | } 46 | } 47 | 48 | void IMETPlayerOnBitten.OnBitten(Vector3 bittenFrom) 49 | { 50 | var position = deadCollider.ClosestPoint(bittenFrom); 51 | var worldDir = (bittenFrom - position).ToWorldDir(); 52 | var rotation = worldDir.WorldDirToRotation(Quaternion.identity); 53 | InstantiateBloodSplat(position, rotation); 54 | } 55 | 56 | void IMETPlayerSMBDie.OnEnterSolo() 57 | { 58 | if (isAlreadyDead) 59 | { 60 | InstantiateBloodSplat(locomotor.Position, Quaternion.identity); 61 | return; 62 | } 63 | isAlreadyDead = true; 64 | locomotor.Brake(playerParam.DieBrake); 65 | locomotor.DetectCollisions = false; 66 | audioDie.Play(); 67 | messageExchange.Invoke(t => t.OnDead()); 68 | } 69 | 70 | private void InstantiateBloodSplat(Vector3 position, Quaternion rotation) 71 | { 72 | Instantiate(playerBloodSplatPrefab, position, rotation, platformer.Current); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Player/PlayerHeadlamp.cs: -------------------------------------------------------------------------------- 1 | using System.Collections; 2 | using UnityEngine; 3 | using FMODUnity; 4 | 5 | public class PlayerHeadlamp : MonoBehaviour, 6 | IMETPlayerSensorItemHeadlamp, 7 | IMITLevelLightDriver 8 | { 9 | [SerializeField] private MessageInterchange messageInterchange; 10 | [SerializeField] private MessageExchange messageExchange; 11 | [SerializeField] private LightDriver playerHeadlampLight; 12 | [SerializeField] private SkinnedMeshRenderer playerHeadlamp; 13 | [SerializeField] private StudioEventEmitter audioHeadlampTake; 14 | 15 | private bool isDark => currentBrightness == 0f; 16 | private bool isEquipped => playerHeadlamp.enabled; 17 | 18 | private float currentBrightness; 19 | 20 | void Awake() 21 | { 22 | currentBrightness = 0f; 23 | } 24 | 25 | void OnEnable() 26 | { 27 | messageInterchange.Register(this); 28 | messageExchange.Register(this); 29 | } 30 | 31 | void OnDisable() 32 | { 33 | messageInterchange.Deregister(this); 34 | messageExchange.Deregister(this); 35 | } 36 | 37 | void IMETPlayerSensorItemHeadlamp.OnEnter(Collider other) 38 | { 39 | if (playerHeadlampLight.IsOn) 40 | { 41 | return; 42 | } 43 | 44 | var headlamp = other.GetComponent(); 45 | if (headlamp == null) 46 | { 47 | return; 48 | } 49 | 50 | if (headlamp.Claim()) 51 | { 52 | StartCoroutine(QueueEquip(delay: headlamp.EffectDuration + 0.2f)); 53 | } 54 | } 55 | 56 | void IMETPlayerSensorItemHeadlamp.OnExit(Collider other) { } 57 | 58 | void IMITLevelLightDriver.OnDesiredBrightnessUpdated(float desiredBrightness) 59 | { 60 | currentBrightness = desiredBrightness; 61 | RefreshLampState(); 62 | } 63 | 64 | private IEnumerator QueueEquip(float delay) 65 | { 66 | audioHeadlampTake.Play(); 67 | yield return new WaitForSeconds(delay); 68 | playerHeadlamp.enabled = true; 69 | RefreshLampState(); 70 | 71 | } 72 | 73 | private void RefreshLampState() 74 | { 75 | if (isEquipped && isDark) 76 | { 77 | playerHeadlampLight.Switch(true); 78 | } 79 | else 80 | { 81 | playerHeadlampLight.Switch(false); 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Core/MessageInterchange.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using UnityEngine; 4 | 5 | public interface IMessageInterchangeTarget { } 6 | 7 | [CreateAssetMenu(fileName = "MessageInterchange", menuName = "Zignpost/MessageInterchange")] 8 | public class MessageInterchange : ScriptableObject 9 | { 10 | private DefaultDictionary> targets = 11 | new DefaultDictionary>(); 12 | 13 | public void Invoke(Action f) 14 | where TTarget : IMessageInterchangeTarget 15 | { 16 | foreach (var t in targets[typeof(TTarget)]) 17 | { 18 | f((TTarget)t); 19 | } 20 | } 21 | 22 | public void Invoke(Action f, TArg arg) 23 | where TTarget : IMessageInterchangeTarget 24 | { 25 | foreach (var t in targets[typeof(TTarget)]) 26 | { 27 | f((TTarget)t, arg); 28 | } 29 | } 30 | 31 | public void Invoke(Action f, TArg0 arg0, TArg1 arg1) 32 | where TTarget : IMessageInterchangeTarget 33 | { 34 | foreach (var t in targets[typeof(TTarget)]) 35 | { 36 | f((TTarget)t, arg0, arg1); 37 | } 38 | } 39 | 40 | public void Invoke(Action f, TArg0 arg0, TArg1 arg1, TArg2 arg2) 41 | where TTarget : IMessageInterchangeTarget 42 | { 43 | foreach (var t in targets[typeof(TTarget)]) 44 | { 45 | f((TTarget)t, arg0, arg1, arg2); 46 | } 47 | } 48 | 49 | public void Register(IMessageInterchangeTarget target) 50 | { 51 | var baseType = typeof(IMessageInterchangeTarget); 52 | 53 | foreach (var t in target.GetType().GetInterfaces()) 54 | { 55 | if (t != baseType && baseType.IsAssignableFrom(t)) 56 | { 57 | targets[t].Add(target); 58 | } 59 | } 60 | } 61 | 62 | public void Deregister(IMessageInterchangeTarget target) 63 | { 64 | var baseType = typeof(IMessageInterchangeTarget); 65 | 66 | foreach (var t in target.GetType().GetInterfaces()) 67 | { 68 | if (t != baseType && baseType.IsAssignableFrom(t)) 69 | { 70 | targets[t].Remove(target); 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Player/PlayerActorDrop.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | using FMODUnity; 3 | 4 | public interface IMETPlayerActorDrop : IMessageExchangeTarget 5 | { 6 | void OnDrop(); 7 | } 8 | 9 | public class PlayerActorDrop : MonoBehaviour, 10 | IMETPlayerSMBIdle, 11 | IMETPlayerSMBDrop, 12 | IMETPlayerAnimationEventProxyOnDrop 13 | { 14 | [SerializeField] private MessageExchange messageExchange; 15 | [SerializeField] private Locomotor locomotor; 16 | [SerializeField] private PlayerParam playerParam; 17 | [SerializeField] private PlayerControl playerControl; 18 | [SerializeField] private PlayerAnimatorDriver playerAnimatorDriver; 19 | [SerializeField] private StudioEventEmitter audioDrop; 20 | 21 | private FSM fsm = new FSM(); 22 | 23 | void OnEnable() 24 | { 25 | messageExchange.Register(this); 26 | } 27 | 28 | void OnDisable() 29 | { 30 | messageExchange.Deregister(this); 31 | } 32 | 33 | void Start() 34 | { 35 | fsm.Init(this); 36 | } 37 | 38 | void Update() 39 | { 40 | fsm.Update(); 41 | } 42 | 43 | void IMETPlayerSMBIdle.OnEnterSolo() 44 | { 45 | fsm.TransitionTo(); 46 | } 47 | 48 | void IMETPlayerSMBIdle.OnExitSolo() 49 | { 50 | fsm.TransitionTo(); 51 | } 52 | 53 | void IMETPlayerSMBDrop.OnEnter() 54 | { 55 | fsm.TransitionTo(); 56 | } 57 | 58 | void IMETPlayerSMBDrop.OnExit() 59 | { 60 | fsm.TransitionTo(); 61 | } 62 | 63 | void IMETPlayerAnimationEventProxyOnDrop.OnDrop() 64 | { 65 | messageExchange.Invoke(t => t.OnDrop()); 66 | playerAnimatorDriver.SetIsHoldingSignpost(false); 67 | audioDrop.Play(); 68 | } 69 | 70 | class FSM : FiniteStateMachine { } 71 | 72 | class State : FiniteStateMachineState { } 73 | 74 | class StateWait : State { } 75 | 76 | class StateReady : State 77 | { 78 | public override void OnUpdate() 79 | { 80 | if (C.playerParam.IsHoldingSignpost && C.playerControl.ButtonDrop) 81 | { 82 | C.playerAnimatorDriver.TriggerDrop(); 83 | C.fsm.TransitionTo(); 84 | } 85 | } 86 | } 87 | 88 | class StateDropping : State 89 | { 90 | public override void OnEnter() 91 | { 92 | float brake = C.playerParam.DropBrake; 93 | C.locomotor.Brake(brake); 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Player/PlayerAnimationEventProxy.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using UnityEngine; 3 | 4 | public interface IMETPlayerAnimationEventProxyOnFootstep : IMessageExchangeTarget 5 | { 6 | void OnStepLeft(); 7 | void OnStepRight(); 8 | } 9 | 10 | public interface IMETPlayerAnimationEventProxyOnHit : IMessageExchangeTarget 11 | { 12 | void OnHit(); 13 | void OnImpact(); 14 | } 15 | 16 | public interface IMETPlayerAnimationEventProxyOnTake : IMessageExchangeTarget 17 | { 18 | void OnTake(); 19 | } 20 | 21 | public interface IMETPlayerAnimationEventProxyOnDrop : IMessageExchangeTarget 22 | { 23 | void OnDrop(); 24 | } 25 | 26 | public class PlayerAnimationEventProxy : MonoBehaviour 27 | { 28 | [SerializeField] private MessageExchange messageExchange; 29 | 30 | private static readonly Action callOnStepLeft = t => t.OnStepLeft(); 31 | private static readonly Action callOnStepRight = t => t.OnStepRight(); 32 | private static readonly Action callOnHit = t => t.OnHit(); 33 | private static readonly Action callOnImpact = t => t.OnImpact(); 34 | private static readonly Action callOnTake = t => t.OnTake(); 35 | private static readonly Action callOnDrop = t => t.OnDrop(); 36 | 37 | // NOTE: Because of blending walk and run animations and 38 | // how animation events work, the two same step event can be triggered almost simultaneously; 39 | // To prevent this, aggregate consecutive step events. 40 | private Cooldown stepCooldown = new Cooldown(); 41 | 42 | void Start() 43 | { 44 | stepCooldown.Set(duration: 0.1f, isReadyInitially: true); 45 | } 46 | 47 | void Update() 48 | { 49 | stepCooldown.Tick(Time.deltaTime); 50 | } 51 | 52 | void FootL() 53 | { 54 | if (stepCooldown.Claim()) 55 | { 56 | messageExchange.Invoke(callOnStepLeft); 57 | } 58 | } 59 | 60 | void FootR() 61 | { 62 | if (stepCooldown.Claim()) 63 | { 64 | messageExchange.Invoke(callOnStepRight); 65 | } 66 | } 67 | 68 | void Hit() 69 | { 70 | messageExchange.Invoke(callOnHit); 71 | } 72 | 73 | void Impact() 74 | { 75 | messageExchange.Invoke(callOnImpact); 76 | } 77 | 78 | void TakeSignpost() 79 | { 80 | messageExchange.Invoke(callOnTake); 81 | } 82 | 83 | void DropSignpost() 84 | { 85 | messageExchange.Invoke(callOnDrop); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Player/PlayerHealth.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using UnityEngine; 3 | using FMODUnity; 4 | 5 | public interface IMETPlayerHealthOnMedkit : IMessageExchangeTarget 6 | { 7 | void OnUsed(ItemConsumable itemMedkit); 8 | } 9 | 10 | public interface IMETPlayerHealthOnChanage : IMessageExchangeTarget 11 | { 12 | void OnChanage(int healthRemaining); 13 | } 14 | 15 | public class PlayerHealth : MonoBehaviour, 16 | IMETPlayerSensorItemMedkit 17 | { 18 | [SerializeField] private MessageExchange messageExchange; 19 | [SerializeField] private PlayerParam playerParam; 20 | [SerializeField] private HUDDotContainer hudHealth; 21 | [SerializeField] private StudioEventEmitter audioMedkit; 22 | 23 | // NOTE: This is for ending; 24 | [SerializeField] private bool isInvincible = false; 25 | 26 | private static readonly Action callOnChange = 27 | (t, healthRemaining) => t.OnChanage(healthRemaining); 28 | 29 | private const int medkitAmount = 1; 30 | 31 | private int healthMax; 32 | private int health; 33 | private ItemConsumable itemMedkitInteracting; 34 | 35 | public int Max => healthMax; 36 | public int Value => health; 37 | 38 | void Awake() 39 | { 40 | healthMax = playerParam.HealthMax; 41 | health = healthMax; 42 | itemMedkitInteracting = null; 43 | } 44 | 45 | void OnEnable() 46 | { 47 | messageExchange.Register(this); 48 | } 49 | 50 | void OnDisable() 51 | { 52 | messageExchange.Deregister(this); 53 | } 54 | 55 | void Start() 56 | { 57 | hudHealth.SetMax(healthMax); 58 | } 59 | 60 | void Update() 61 | { 62 | UpdateItemMedikit(); 63 | } 64 | 65 | void IMETPlayerSensorItemMedkit.OnEnter(Collider other) 66 | { 67 | itemMedkitInteracting = other.GetComponent(); 68 | } 69 | 70 | void IMETPlayerSensorItemMedkit.OnExit(Collider other) 71 | { 72 | itemMedkitInteracting = null; 73 | } 74 | 75 | public void Claim(int amount) 76 | { 77 | if (isInvincible || health == 0) 78 | { 79 | return; 80 | } 81 | AdjustHealth(-amount); 82 | } 83 | 84 | private void UpdateItemMedikit() 85 | { 86 | if (health == healthMax) 87 | { 88 | return; 89 | } 90 | if (itemMedkitInteracting == null) 91 | { 92 | return; 93 | } 94 | if (itemMedkitInteracting.Claim()) 95 | { 96 | AdjustHealth(+medkitAmount); 97 | audioMedkit.Play(); 98 | messageExchange.Invoke(t => t.OnUsed(itemMedkitInteracting)); 99 | } 100 | } 101 | 102 | private void AdjustHealth(int amount) 103 | { 104 | int value = Mathf.Clamp(health + amount, 0, healthMax); 105 | health = value; 106 | hudHealth.SetValue(value); 107 | messageExchange.Invoke(callOnChange, health); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Player/PlayerActorIdle.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | 3 | public class PlayerActorIdle : MonoBehaviour, 4 | IMETPlayerSMBIdle, 5 | IMETPlayerActorFreeFall 6 | { 7 | [SerializeField] private MessageExchange messageExchange; 8 | [SerializeField] private Locomotor locomotor; 9 | [SerializeField] private PlayerControl playerControl; 10 | [SerializeField] private PlayerParam playerParam; 11 | [SerializeField] private PlayerAnimatorDriver playerAnimatorDriver; 12 | [SerializeField] private CapsuleCollider envCollider; 13 | [SerializeField] private LayerMask wallLayerMask; 14 | [SerializeField] private float moveCorrectionDistance; 15 | 16 | private FSM fsm = new FSM(); 17 | private bool isOnLedge; 18 | private Vector3 ledgeWorldDir; 19 | 20 | void OnEnable() 21 | { 22 | messageExchange.Register(this); 23 | } 24 | 25 | void OnDisable() 26 | { 27 | messageExchange.Deregister(this); 28 | } 29 | 30 | void Start() 31 | { 32 | fsm.Init(this); 33 | isOnLedge = false; 34 | ledgeWorldDir = Vector3.zero; 35 | } 36 | 37 | void FixedUpdate() 38 | { 39 | fsm.FixedUpdate(); 40 | } 41 | 42 | void Update() 43 | { 44 | fsm.Update(); 45 | } 46 | 47 | void IMETPlayerSMBIdle.OnEnterSolo() 48 | { 49 | fsm.TransitionTo(); 50 | } 51 | 52 | void IMETPlayerSMBIdle.OnExitSolo() 53 | { 54 | fsm.TransitionTo(); 55 | } 56 | 57 | void IMETPlayerActorFreeFall.OnChangeLedgeState(bool isOnLedge, Vector3 normal) 58 | { 59 | this.isOnLedge = isOnLedge; 60 | this.ledgeWorldDir = normal.WithY(0f).normalized; 61 | } 62 | 63 | class FSM : FiniteStateMachine { } 64 | 65 | class State : FiniteStateMachineState { } 66 | 67 | class StateWait : State { } 68 | 69 | class StateMoving : State 70 | { 71 | private Vector3 worldDirMove; 72 | 73 | public override void OnEnter() 74 | { 75 | C.playerAnimatorDriver.ResetActionTriggers(); 76 | C.locomotor.Mass = C.playerParam.NormalMass; 77 | } 78 | 79 | public override void OnFixedUpdate() 80 | { 81 | worldDirMove = C.playerControl.WorldDirMove; 82 | C.locomotor.CorrectWorldDirMove(ref worldDirMove, 83 | C.envCollider, 84 | C.moveCorrectionDistance, 85 | C.wallLayerMask); 86 | Rotate(); 87 | Accelerate(); 88 | } 89 | 90 | private void Rotate() 91 | { 92 | if (worldDirMove == Vector3.zero) 93 | { 94 | return; 95 | } 96 | 97 | float rotationSpeed = C.playerParam.MoveRotationSpeed; 98 | C.locomotor.RotateTowardMovingDirection(rotationSpeed); 99 | } 100 | 101 | private void Accelerate() 102 | { 103 | float accel = C.playerParam.MoveAcceleration; 104 | C.locomotor.Accelerate(worldDirMove, accel); 105 | 106 | // Ledge 107 | if (C.isOnLedge) 108 | { 109 | bool isTowardLedge = Vector3.Dot(C.ledgeWorldDir, worldDirMove) < 0f; 110 | if (isTowardLedge) 111 | { 112 | C.locomotor.Accelerate(Vector3.up, C.playerParam.LedgeSoarAccel); 113 | } 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Player/PlayerSensorItem.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | 3 | public interface IMETPlayerSensorItemHeadlamp : IMessageExchangeTarget 4 | { 5 | void OnEnter(Collider other); 6 | void OnExit(Collider other); 7 | } 8 | 9 | public interface IMETPlayerSensorItemKnob : IMessageExchangeTarget 10 | { 11 | void OnEnter(Collider other); 12 | void OnExit(Collider other); 13 | } 14 | 15 | public interface IMETPlayerSensorItemMedkit : IMessageExchangeTarget 16 | { 17 | void OnEnter(Collider other); 18 | void OnExit(Collider other); 19 | } 20 | 21 | public interface IMETPlayerSensorItemSignpost : IMessageExchangeTarget 22 | { 23 | void OnEnter(Collider other); 24 | void OnExit(Collider other); 25 | } 26 | 27 | public class PlayerSensorItem : MonoBehaviour, 28 | IMETPlayerParam, 29 | IMETPlayerHealthOnChanage 30 | { 31 | [SerializeField] private MessageExchange messageExchange; 32 | [SerializeField] private CapsuleCollider mainCollider; 33 | [SerializeField] private PlayerParam playerParam; 34 | 35 | private CapsuleCollider trigger; 36 | private bool isDead; 37 | 38 | void Awake() 39 | { 40 | trigger = gameObject.AddComponent(); 41 | trigger.isTrigger = true; 42 | trigger.center = mainCollider.center; 43 | trigger.direction = mainCollider.direction; 44 | trigger.height = mainCollider.height; 45 | trigger.radius = 0f; 46 | isDead = false; 47 | } 48 | 49 | void OnEnable() 50 | { 51 | messageExchange.Register(this); 52 | } 53 | 54 | void OnDisable() 55 | { 56 | messageExchange.Deregister(this); 57 | } 58 | 59 | void OnTriggerEnter(Collider other) 60 | { 61 | if (isDead) 62 | { 63 | return; 64 | } 65 | switch (other.gameObject.tag) 66 | { 67 | case "ItemHeadlamp": 68 | messageExchange.Invoke(t => t.OnEnter(other)); 69 | break; 70 | case "ItemKnob": 71 | messageExchange.Invoke(t => t.OnEnter(other)); 72 | break; 73 | case "ItemMedkit": 74 | messageExchange.Invoke(t => t.OnEnter(other)); 75 | break; 76 | case "ItemSignpost": 77 | messageExchange.Invoke(t => t.OnEnter(other)); 78 | break; 79 | } 80 | } 81 | 82 | void OnTriggerExit(Collider other) 83 | { 84 | switch (other.gameObject.tag) 85 | { 86 | case "ItemHeadlamp": 87 | messageExchange.Invoke(t => t.OnExit(other)); 88 | break; 89 | case "ItemKnob": 90 | messageExchange.Invoke(t => t.OnExit(other)); 91 | break; 92 | case "ItemMedkit": 93 | messageExchange.Invoke(t => t.OnExit(other)); 94 | break; 95 | case "ItemSignpost": 96 | messageExchange.Invoke(t => t.OnExit(other)); 97 | break; 98 | } 99 | } 100 | 101 | void IMETPlayerParam.OnChanged(PlayerParamData data) 102 | { 103 | trigger.radius = data.ItemRange; 104 | } 105 | 106 | void IMETPlayerHealthOnChanage.OnChanage(int healthRemaining) 107 | { 108 | if (healthRemaining == 0) 109 | { 110 | isDead = true; 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Player/PlayerActorGetHit.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | using Cinemachine; 3 | using FMODUnity; 4 | 5 | public class PlayerActorGetHit : MonoBehaviour, 6 | IMETPlayerOnAttacked, 7 | IMETPlayerSMBGetHit, 8 | IMETPlayerHealthOnChanage 9 | { 10 | [SerializeField] private MessageExchange messageExchange; 11 | [SerializeField] private Locomotor locomotor; 12 | [SerializeField] private PlayerParam playerParam; 13 | [SerializeField] private PlayerHealth playerHealth; 14 | [SerializeField] private PlayerAnimatorDriver playerAnimatorDriver; 15 | [SerializeField] private GameObject playerBloodSplatPrefab; 16 | [SerializeField] private Platformer platformer; 17 | [SerializeField] private Transform bloodSplatSpawnPoint; 18 | [SerializeField] private CinemachineImpulseSource cinemachineImpulseSource; 19 | [SerializeField] private float impulseForce; 20 | [SerializeField] private StudioEventEmitter audioGetHit; 21 | 22 | private Vector3 attackedFrom; 23 | private float knockbackSpeed; 24 | private int damage; 25 | private bool isAboutToDie; 26 | 27 | void OnEnable() 28 | { 29 | messageExchange.Register(this); 30 | } 31 | 32 | void OnDisable() 33 | { 34 | messageExchange.Deregister(this); 35 | } 36 | 37 | void IMETPlayerOnAttacked.OnAttacked(IPlayerOnAttackedInfo attackedInfo) 38 | { 39 | this.attackedFrom = attackedInfo.AttackedFrom; 40 | this.knockbackSpeed = attackedInfo.KnockbackSpeed; 41 | this.damage = attackedInfo.Damage; 42 | this.isAboutToDie = false; 43 | locomotor.Brake(attackedInfo.BrakeMultiplier); 44 | TriggerGetHit(); 45 | } 46 | 47 | private void TriggerGetHit() 48 | { 49 | Vector3 worldDir = (attackedFrom - transform.position).ToWorldDir(); 50 | Vector3 localDir = transform.InverseTransformDirection(worldDir); 51 | float getHitFromX = localDir.x; 52 | float getHitFromY = localDir.z; 53 | playerAnimatorDriver.TriggerGetHit(getHitFromX, getHitFromY); 54 | } 55 | 56 | void IMETPlayerSMBGetHit.OnEnter() { } 57 | 58 | void IMETPlayerSMBGetHit.OnEnterSolo() 59 | { 60 | playerHealth.Claim(damage); 61 | InstantiateBloodSplat(); 62 | ApplyKnockback(); 63 | ShakeCamera(); 64 | PlayAudio(); 65 | } 66 | 67 | void IMETPlayerHealthOnChanage.OnChanage(int healthRemaining) 68 | { 69 | if (healthRemaining == 0) 70 | { 71 | this.isAboutToDie = true; 72 | } 73 | } 74 | 75 | void IMETPlayerSMBGetHit.OnExitSolo() 76 | { 77 | // NOTE: Skip braking because Brake will be applied by PlayerActorDie; 78 | if (!isAboutToDie) 79 | { 80 | locomotor.Brake(playerParam.GetHitBrake); 81 | } 82 | locomotor.Mass = playerParam.NormalMass; 83 | } 84 | 85 | private void InstantiateBloodSplat() 86 | { 87 | var position = bloodSplatSpawnPoint.position; 88 | var worldDir = (attackedFrom - position).ToWorldDir(); 89 | var rotation = Quaternion.LookRotation(worldDir); 90 | Instantiate(playerBloodSplatPrefab, position, rotation, platformer.Current); 91 | } 92 | 93 | private void ApplyKnockback() 94 | { 95 | locomotor.Mass = playerParam.GetHitKnockbackMass; 96 | Vector3 worldDir = (locomotor.Position - attackedFrom).ToWorldDir(); 97 | locomotor.Push(worldDir, speedChange: knockbackSpeed); 98 | } 99 | 100 | private void ShakeCamera() 101 | { 102 | cinemachineImpulseSource.GenerateImpulseWithForce(impulseForce); 103 | } 104 | 105 | private void PlayAudio() 106 | { 107 | audioGetHit.Play(); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Player/PlayerParam.cs: -------------------------------------------------------------------------------- 1 | using System.Collections; 2 | using UnityEngine; 3 | 4 | public class PlayerParam : MonoBehaviour, 5 | ISerializationCallbackReceiver, 6 | IMETPlayerActorTake, 7 | IMETPlayerActorDrop, 8 | IMETPlayerSignpost 9 | { 10 | [SerializeField] private MessageExchange messageExchange; 11 | [SerializeField] private PlayerParamData unarmed; 12 | 13 | public bool IsHoldingSignpost => current.IsHoldingSignpost; 14 | public BoxChecker AttackBoxChecker => current.AttackBoxChecker; 15 | public float AttackBrake => current.AttackBrake; 16 | public float AttackHitstopDuration => current.AttackHitstopDuration; 17 | public float AttackingRotationSpeed => current.AttackingRotationSpeed; 18 | public float AttakKnockbackSpeed => current.AttackKnockbackSpeed; 19 | public float AttackPushSpeed => current.AttackPushSpeed; 20 | public float AttackSpeedMultiplier => current.AttackSpeedMultiplier; 21 | public float DieBrake => current.DieBrake; 22 | public float DropBrake => current.DropBrake; 23 | public float GetHitBrake => current.GetHitBrake; 24 | public float GetHitKnockbackMass => current.NormalMass * current.GetHitKnockbackMassMultiplier; 25 | public float ItemRange => current.ItemRange; 26 | public float LedgeSoarAccel => current.LedgeSoarAccel; 27 | public float MoveAcceleration => current.MoveAcceleration; 28 | public float MoveRotationSpeed => current.MoveRotationSpeed; 29 | public float NormalMass => current.NormalMass; 30 | public float ProximityRange => current.ProximityRange; 31 | public float RollBrake => current.RollBrake; 32 | public float RollingRadiusMultiplier => current.RollingRadiusMultiplier; 33 | public float RollingRotationSpeed => current.RollingRotationSpeed; 34 | public float RollingSpeed => current.RollingSpeed; 35 | public float RollingSoarSpeed => current.RollingSoarSpeed; 36 | public float TakeBrake => current.TakeBrake; 37 | public GameObject ItemSignpostOnFloorPrefab => current.ItemSignpostOnFloorPrefab; 38 | public GameObject SignpostBrokenPrefab => current.SignpostBrokenPrefab; 39 | public GameObject SignpostInHandPrefab => current.SignpostInHandPrefab; 40 | public int AttackDamage => current.AttackDamage; 41 | public int HealthMax => current.HealthMax; 42 | 43 | private PlayerParamData current; 44 | 45 | void ISerializationCallbackReceiver.OnBeforeSerialize() { } 46 | 47 | void ISerializationCallbackReceiver.OnAfterDeserialize() 48 | { 49 | current = unarmed; 50 | } 51 | 52 | void OnEnable() 53 | { 54 | messageExchange.Register(this); 55 | } 56 | 57 | void OnDisable() 58 | { 59 | messageExchange.Deregister(this); 60 | } 61 | 62 | void Start() 63 | { 64 | Set(unarmed); 65 | } 66 | 67 | void IMETPlayerActorTake.OnTake(PlayerParamData signpostParamData, int maxDurability, int durability) 68 | { 69 | SetDelayed(signpostParamData); 70 | } 71 | 72 | void IMETPlayerActorDrop.OnDrop() 73 | { 74 | SetDelayed(unarmed); 75 | } 76 | 77 | void IMETPlayerSignpost.OnBreak() 78 | { 79 | SetDelayed(unarmed); 80 | } 81 | 82 | private void Set(PlayerParamData data) 83 | { 84 | current = data; 85 | messageExchange.Invoke(t => t.OnChanged(data)); 86 | } 87 | 88 | private void SetDelayed(PlayerParamData data) 89 | { 90 | // NOTE: This is for breaking the message chain; 91 | StartCoroutine(SetDelayedInternal(data)); 92 | } 93 | 94 | private IEnumerator SetDelayedInternal(PlayerParamData data) 95 | { 96 | yield return null; 97 | Set(data); 98 | } 99 | } 100 | 101 | public interface IMETPlayerParam : IMessageExchangeTarget 102 | { 103 | void OnChanged(PlayerParamData data); 104 | } -------------------------------------------------------------------------------- /Player/PlayerActorRoll.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | using FMODUnity; 3 | 4 | public class PlayerActorRoll : MonoBehaviour, 5 | IMETPlayerSMBIdle, 6 | IMETPlayerSMBRoll 7 | { 8 | [SerializeField] private CapsuleCollider mainCollider; 9 | [SerializeField] private Locomotor locomotor; 10 | [SerializeField] private MessageExchange messageExchange; 11 | [SerializeField] private PlayerParam playerParam; 12 | [SerializeField] private PlayerControl playerControl; 13 | [SerializeField] private PlayerAnimatorDriver playerAnimatorDriver; 14 | [SerializeField] private StudioEventEmitter studioEventEmitter; 15 | 16 | // NOTE: This should include the rolling time ( which is about 0.4s) 17 | private const float cooldownDuration = 0.8f; 18 | 19 | public bool IsRolling => fsm.Current == fsm.InstanceOf(); 20 | 21 | private FSM fsm = new FSM(); 22 | private Cooldown cooldown = new Cooldown(); 23 | private float radiusOriginal; 24 | 25 | void OnEnable() 26 | { 27 | messageExchange.Register(this); 28 | } 29 | 30 | void OnDisable() 31 | { 32 | messageExchange.Deregister(this); 33 | } 34 | 35 | void Start() 36 | { 37 | radiusOriginal = mainCollider.radius; 38 | cooldown.Set(cooldownDuration, isReadyInitially: true); 39 | fsm.Init(this); 40 | } 41 | 42 | void FixedUpdate() 43 | { 44 | fsm.FixedUpdate(); 45 | } 46 | 47 | void Update() 48 | { 49 | float dt = Time.deltaTime; 50 | cooldown.Tick(dt); 51 | fsm.Update(); 52 | } 53 | 54 | /// IMETPlayerSMBIdle 55 | void IMETPlayerSMBIdle.OnEnterSolo() 56 | { 57 | fsm.TransitionTo(); 58 | } 59 | 60 | void IMETPlayerSMBIdle.OnExitSolo() 61 | { 62 | fsm.TransitionTo(); 63 | } 64 | 65 | // IMETPlayerSMBRoll 66 | void IMETPlayerSMBRoll.OnEnter() 67 | { 68 | mainCollider.radius = radiusOriginal * playerParam.RollingRadiusMultiplier; 69 | fsm.TransitionTo(); 70 | } 71 | 72 | void IMETPlayerSMBRoll.OnEnterSolo() { } 73 | 74 | void IMETPlayerSMBRoll.OnExitSolo() 75 | { 76 | mainCollider.radius = radiusOriginal; 77 | fsm.TransitionTo(); 78 | } 79 | 80 | void IMETPlayerSMBRoll.OnExit() { } 81 | 82 | class FSM : FiniteStateMachine { } 83 | 84 | class State : FiniteStateMachineState { } 85 | 86 | class StateWait : State { } 87 | 88 | class StateReady : State 89 | { 90 | public override void OnUpdate() 91 | { 92 | if (C.playerControl.ButtonRoll && C.cooldown.Claim()) 93 | { 94 | C.playerAnimatorDriver.TriggerRoll(); 95 | C.fsm.TransitionTo(); 96 | } 97 | } 98 | } 99 | 100 | class StateRolling : State 101 | { 102 | Vector3 worldDir; 103 | Quaternion targetRotation; 104 | float rotationSpeed; 105 | 106 | public override void OnEnter() 107 | { 108 | worldDir = C.playerControl.WorldDirMove.ToWorldDir(); 109 | if (worldDir == Vector3.zero) 110 | { 111 | worldDir = C.locomotor.WorldDir; 112 | } 113 | targetRotation = C.locomotor.RotationToward(worldDir); 114 | rotationSpeed = C.playerParam.RollingRotationSpeed; 115 | C.locomotor.Brake(C.playerParam.RollBrake); 116 | // NOTE: Soar to make the player move between ledges smoothly. 117 | C.locomotor.Push(Vector3.up, C.playerParam.RollingSoarSpeed); 118 | C.locomotor.Push(worldDir, C.playerParam.RollingSpeed); 119 | C.studioEventEmitter.Play(); 120 | } 121 | 122 | public override void OnFixedUpdate() 123 | { 124 | C.locomotor.RotateTowardTarget(targetRotation, rotationSpeed); 125 | } 126 | } 127 | } -------------------------------------------------------------------------------- /Player/PlayerParamData.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | 3 | [CreateAssetMenu(fileName = "PlayerParamData", menuName = "Zignpost/PlayerParamData")] 4 | public class PlayerParamData : InheritableSO 5 | { 6 | [Header("Common")] 7 | [SerializeField] private Inheritable healthMax; 8 | [SerializeField] private Inheritable proximityRange; 9 | [SerializeField] private Inheritable normalMass; 10 | 11 | [Header("Signpost")] 12 | [SerializeField] private Inheritable isHoldingSignpost; 13 | [SerializeField] private Inheritable signpostInHandPrefab; 14 | [SerializeField] private Inheritable signpostBrokenPrefab; 15 | [SerializeField] private Inheritable itemSignpostOnFloorPrefab; 16 | 17 | [Header("Move")] 18 | [SerializeField] private Inheritable moveAcceleration; 19 | [SerializeField] private Inheritable moveRotationSpeed; 20 | [SerializeField] private Inheritable ledgeSoarAccel; 21 | 22 | [Header("Roll")] 23 | [SerializeField] private Inheritable rollBrake; 24 | [SerializeField] private Inheritable rollingSpeed; 25 | [SerializeField] private Inheritable rollingSoarSpeed; 26 | [SerializeField] private Inheritable rollingRotationSpeed; 27 | [SerializeField] private Inheritable rollingRadiusMultiplier; 28 | 29 | [Header("Attack")] 30 | [SerializeField] private Inheritable attackBrake; 31 | [SerializeField] private Inheritable attackPushSpeed; 32 | [SerializeField] private Inheritable attackSpeedMultiplier; 33 | [SerializeField] private Inheritable attackingRotationSpeed; 34 | [SerializeField] private Inheritable attackBoxChecker; 35 | [SerializeField] private Inheritable attackDamage; 36 | [SerializeField] private Inheritable attackHitstopDuration; 37 | [SerializeField] private Inheritable attackKnockbackSpeed; 38 | 39 | [Header("Take")] 40 | [SerializeField] private Inheritable itemRange; 41 | [SerializeField] private Inheritable takeBrake; 42 | 43 | [Header("Drop")] 44 | [SerializeField] private Inheritable dropBrake; 45 | 46 | [Header("Die")] 47 | [SerializeField] private Inheritable dieBrake; 48 | 49 | [Header("GetHit")] 50 | [SerializeField] private Inheritable getHitKnockbackMassMultiplier; 51 | [SerializeField] private Inheritable getHitBrake; 52 | 53 | public bool IsHoldingSignpost => isHoldingSignpost.Value; 54 | public BoxChecker AttackBoxChecker => attackBoxChecker.Value; 55 | public float AttackBrake => attackBrake.Value; 56 | public float AttackHitstopDuration => attackHitstopDuration.Value; 57 | public float AttackingRotationSpeed => attackingRotationSpeed.Value; 58 | public float AttackKnockbackSpeed => attackKnockbackSpeed.Value; 59 | public float AttackPushSpeed => attackPushSpeed.Value; 60 | public float AttackSpeedMultiplier => attackSpeedMultiplier.Value; 61 | public float DieBrake => dieBrake.Value; 62 | public float DropBrake => dropBrake.Value; 63 | public float GetHitBrake => getHitBrake.Value; 64 | public float GetHitKnockbackMassMultiplier => getHitKnockbackMassMultiplier.Value; 65 | public float ItemRange => itemRange.Value; 66 | public float LedgeSoarAccel => ledgeSoarAccel.Value; 67 | public float MoveAcceleration => moveAcceleration.Value; 68 | public float MoveRotationSpeed => moveRotationSpeed.Value; 69 | public float NormalMass => normalMass.Value; 70 | public float ProximityRange => proximityRange.Value; 71 | public float RollBrake => rollBrake.Value; 72 | public float RollingRadiusMultiplier => rollingRadiusMultiplier.Value; 73 | public float RollingRotationSpeed => rollingRotationSpeed.Value; 74 | public float RollingSoarSpeed => rollingSoarSpeed.Value; 75 | public float RollingSpeed => rollingSpeed.Value; 76 | public float TakeBrake => takeBrake.Value; 77 | public GameObject ItemSignpostOnFloorPrefab => itemSignpostOnFloorPrefab.Value; 78 | public GameObject SignpostBrokenPrefab => signpostBrokenPrefab.Value; 79 | public GameObject SignpostInHandPrefab => signpostInHandPrefab.Value; 80 | public int AttackDamage => attackDamage.Value; 81 | public int HealthMax => healthMax.Value; 82 | } 83 | -------------------------------------------------------------------------------- /Player/PlayerActorFreeFall.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using UnityEngine; 3 | using FMODUnity; 4 | 5 | public interface IMETPlayerActorFreeFall : IMessageExchangeTarget 6 | { 7 | void OnChangeLedgeState(bool isOnLedge, Vector3 normal); 8 | } 9 | 10 | public class PlayerActorFreeFall : MonoBehaviour, 11 | IMETPlayerSMBFreeFall 12 | { 13 | [SerializeField] private MessageExchange messageExchange; 14 | [SerializeField] private Locomotor locomotor; 15 | [SerializeField] private GroundChecker groundChecker; 16 | [SerializeField] private PlayerParam playerParam; 17 | [SerializeField] private PlayerControl playerControl; 18 | [SerializeField] private PlayerAnimatorDriver playerAnimatorDriver; 19 | [SerializeField] private StudioEventEmitter audioLand; 20 | 21 | private static readonly Action callOnChangeLedgeState = 22 | (t, isOnLedgeThis, ledgeNormal) => t.OnChangeLedgeState(isOnLedgeThis, ledgeNormal); 23 | 24 | private const float groundToAirPeriod = 0.35f; 25 | private const float airToGroundPeriod = 0.2f; 26 | private float transitionPeriod => isOnGround ? groundToAirPeriod : airToGroundPeriod; 27 | 28 | // If the position is not changed at least stuckEpsilon for period, 29 | // it's considered stuck and forced to be as grounded. 30 | private const float stuckPeriod = 0.2f; 31 | private const float sqrStuckEpsilon = 0.01f * 0.01f; 32 | 33 | private bool isOnGround; 34 | private bool isFreeFalling; 35 | private Vector3 prevPosition; 36 | private Cooldown transitionCooldown; 37 | private Cooldown stuckCooldown; 38 | 39 | void OnEnable() 40 | { 41 | messageExchange.Register(this); 42 | } 43 | 44 | void OnDisable() 45 | { 46 | messageExchange.Deregister(this); 47 | } 48 | 49 | void Start() 50 | { 51 | isOnGround = true; 52 | isFreeFalling = false; 53 | prevPosition = locomotor.Position; 54 | transitionCooldown = new Cooldown(); 55 | transitionCooldown.Set(transitionPeriod, isReadyInitially: true); 56 | stuckCooldown = new Cooldown(); 57 | stuckCooldown.Set(stuckPeriod, isReadyInitially: false); 58 | } 59 | 60 | void FixedUpdate() 61 | { 62 | if (isFreeFalling && playerControl.WorldDirMove != Vector3.zero) 63 | { 64 | var worldDir = playerControl.WorldDirMove; 65 | var targetRotation = locomotor.RotationToward(worldDir); 66 | var rotationSpeed = playerParam.MoveRotationSpeed; 67 | locomotor.RotateTowardTarget(targetRotation, rotationSpeed); 68 | } 69 | } 70 | 71 | void Update() 72 | { 73 | float dt = Time.deltaTime; 74 | 75 | Vector3 ledgeNormal; 76 | var (isOnGroundThis, isOnLedgeThis) = groundChecker.Check(out ledgeNormal); 77 | 78 | // Stuck prevention 79 | stuckCooldown.Tick(dt); 80 | var currentPosition = locomotor.Position; 81 | float sqrDiff = (currentPosition - prevPosition).sqrMagnitude; 82 | prevPosition = currentPosition; 83 | if (sqrDiff > sqrStuckEpsilon) 84 | { 85 | stuckCooldown.Reset(isReady: false); 86 | } 87 | if (stuckCooldown.IsReady) 88 | { 89 | isOnGroundThis = true; 90 | } 91 | 92 | // NOTE: Give some transition padding on isOnGround state; 93 | transitionCooldown.Tick(dt); 94 | if (isOnGroundThis == isOnGround) 95 | { 96 | transitionCooldown.Set(transitionPeriod, isReadyInitially: false); 97 | } 98 | if (transitionCooldown.Claim()) 99 | { 100 | this.isOnGround = isOnGroundThis; 101 | playerAnimatorDriver.IsOnGround = isOnGround; 102 | locomotor.IsOnGround = isOnGround; 103 | } 104 | 105 | // NOTE: On the other hand, ledge state should always be updated. 106 | messageExchange.Invoke(callOnChangeLedgeState, isOnLedgeThis, ledgeNormal); 107 | } 108 | 109 | void IMETPlayerSMBFreeFall.OnEnterSolo() 110 | { 111 | isFreeFalling = true; 112 | } 113 | 114 | void IMETPlayerSMBFreeFall.OnExitSolo() 115 | { 116 | isFreeFalling = false; 117 | audioLand.Play(); 118 | } 119 | } -------------------------------------------------------------------------------- /Player/PlayerAnimatorDriver.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | 3 | public class PlayerAnimatorDriver : MonoBehaviour, 4 | IMETPlayerParam 5 | { 6 | [SerializeField] private Animator animator; 7 | [SerializeField] private MessageExchange messageExchange; 8 | [SerializeField] private Locomotor locomotor; 9 | [SerializeField] private PlayerParam playerParam; 10 | 11 | private readonly int unarmedIdle = Animator.StringToHash("Main.Unarmed.Idle"); 12 | private readonly int signpostIdle = Animator.StringToHash("Main.Signpost.Idle"); 13 | private readonly int moveSpeed = Animator.StringToHash("moveSpeed"); 14 | private readonly int roll = Animator.StringToHash("roll"); 15 | private readonly int attack = Animator.StringToHash("attack"); 16 | private readonly int attackRotatingClockwise = Animator.StringToHash("attackRotatingClockwise"); 17 | private readonly int take = Animator.StringToHash("take"); 18 | private readonly int drop = Animator.StringToHash("drop"); 19 | private readonly int isHoldingSignpost = Animator.StringToHash("isHoldingSignpost"); 20 | private readonly int getHit = Animator.StringToHash("getHit"); 21 | private readonly int getHitFromX = Animator.StringToHash("getHitFromX"); 22 | private readonly int getHitFromY = Animator.StringToHash("getHitFromY"); 23 | private readonly int die = Animator.StringToHash("die"); 24 | private readonly int isOnGround = Animator.StringToHash("isOnGround"); 25 | private readonly int attackSpeedMultiplier = Animator.StringToHash("attackSpeedMultiplier"); 26 | 27 | private Cooldown pauseResetCooldown; 28 | 29 | public bool IsOnGround 30 | { 31 | set => animator.SetBool(isOnGround, value); 32 | } 33 | 34 | void Awake() 35 | { 36 | pauseResetCooldown = new Cooldown(); 37 | pauseResetCooldown.Set(0f, isReadyInitially: true); 38 | } 39 | 40 | void OnEnable() 41 | { 42 | messageExchange.Register(this); 43 | } 44 | 45 | void OnDisable() 46 | { 47 | messageExchange.Deregister(this); 48 | } 49 | 50 | void Start() 51 | { 52 | animator.Play(playerParam.IsHoldingSignpost ? signpostIdle : unarmedIdle); 53 | } 54 | 55 | void Update() 56 | { 57 | animator.SetFloat(moveSpeed, locomotor.Speed); 58 | if (!pauseResetCooldown.IsReady) 59 | { 60 | pauseResetCooldown.Tick(Time.deltaTime); 61 | if (pauseResetCooldown.IsReady) 62 | { 63 | animator.speed = 1f; 64 | } 65 | } 66 | } 67 | 68 | void IMETPlayerParam.OnChanged(PlayerParamData data) 69 | { 70 | animator.SetFloat(attackSpeedMultiplier, data.AttackSpeedMultiplier); 71 | } 72 | 73 | public void SetPause(float duration) 74 | { 75 | if (duration <= 0f) 76 | { 77 | return; 78 | } 79 | animator.speed = 0f; 80 | pauseResetCooldown.Set(duration, isReadyInitially: false); 81 | } 82 | 83 | public void TriggerRoll() 84 | { 85 | animator.SetTrigger(roll); 86 | } 87 | 88 | public void TriggerAttack() 89 | { 90 | animator.SetTrigger(attack); 91 | } 92 | 93 | public void SetAttackRotationDir(bool isClockwise) 94 | { 95 | animator.SetFloat(attackRotatingClockwise, isClockwise ? 1f : 0f); 96 | } 97 | 98 | public void TriggerDrop() 99 | { 100 | animator.SetTrigger(drop); 101 | } 102 | 103 | public void TriggerTake() 104 | { 105 | animator.SetTrigger(take); 106 | } 107 | 108 | public void SetIsHoldingSignpost(bool value) 109 | { 110 | animator.SetBool(isHoldingSignpost, value); 111 | } 112 | 113 | public void ResetActionTriggers() 114 | { 115 | animator.ResetTrigger(roll); 116 | animator.ResetTrigger(attack); 117 | animator.ResetTrigger(drop); 118 | animator.ResetTrigger(take); 119 | } 120 | 121 | public void TriggerGetHit(float x, float y) 122 | { 123 | animator.SetFloat(getHitFromX, x); 124 | animator.SetFloat(getHitFromY, y); 125 | animator.SetTrigger(getHit); 126 | } 127 | 128 | public void TriggerDie() 129 | { 130 | animator.SetTrigger(die); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Core/BaseSMB.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | 3 | // A StateMachineBehaviour with subdivided callback methods 4 | public class BaseSMB : StateMachineBehaviour 5 | { 6 | public virtual void OnEnter() { } 7 | public virtual void OnUpdateEntering() { } 8 | public virtual void OnEnterSolo() { } 9 | public virtual void OnUpdate() { } 10 | public virtual void OnUpdateSolo() { } 11 | public virtual void OnExitSolo() { } 12 | public virtual void OnUpdateExiting() { } 13 | public virtual void OnExit() { } 14 | 15 | protected Animator LastAnimator { get; private set; } 16 | protected AnimatorStateInfo LastStateInfo { get; private set; } 17 | protected int LastLayerIndex { get; private set; } 18 | 19 | class TransitionState 20 | { 21 | public float lastNormalizedTime = 0f; 22 | public bool soloEntered = false; 23 | public bool soloExited = false; 24 | } 25 | 26 | // NOTE: There can be up to two transition state 27 | // because two transition of the same SMB can exist when transitioning to self; 28 | private TransitionState[] tsBuffer = new TransitionState[2] { new TransitionState(), new TransitionState() }; 29 | private int tsCount = 0; 30 | 31 | public sealed override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) 32 | { 33 | if (tsCount > 1) 34 | { 35 | // NOTE: Unexpected; Ignore it. 36 | return; 37 | } 38 | 39 | LastAnimator = animator; 40 | LastStateInfo = stateInfo; 41 | LastLayerIndex = layerIndex; 42 | 43 | var ts = tsBuffer[tsCount]; 44 | ts.lastNormalizedTime = stateInfo.normalizedTime; 45 | ts.soloEntered = false; 46 | ts.soloExited = false; 47 | tsCount += 1; 48 | OnEnter(); 49 | } 50 | 51 | public sealed override void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) 52 | { 53 | TransitionState current = null; 54 | float normalizedTime = stateInfo.normalizedTime; 55 | for (int i = 0; i < tsCount; i++) 56 | { 57 | var ts = tsBuffer[i]; 58 | if (normalizedTime >= ts.lastNormalizedTime) 59 | { 60 | current = ts; 61 | break; 62 | } 63 | } 64 | 65 | if (current == null) 66 | { 67 | // NOTE: Unexpected; Ignore it. 68 | return; 69 | } 70 | 71 | LastAnimator = animator; 72 | LastStateInfo = stateInfo; 73 | LastLayerIndex = layerIndex; 74 | 75 | OnUpdate(); 76 | 77 | current.lastNormalizedTime = normalizedTime; 78 | 79 | if (!animator.IsInTransition(layerIndex)) 80 | { 81 | if (!current.soloEntered) 82 | { 83 | OnEnterSolo(); 84 | current.soloEntered = true; 85 | } 86 | OnUpdateSolo(); 87 | } 88 | 89 | if (animator.IsInTransition(layerIndex)) 90 | { 91 | if (!current.soloEntered) 92 | { 93 | OnUpdateEntering(); 94 | } 95 | 96 | if (current.soloEntered && !current.soloExited) 97 | { 98 | OnExitSolo(); 99 | current.soloExited = true; 100 | } 101 | 102 | if (current.soloEntered && current.soloExited) 103 | { 104 | OnUpdateExiting(); 105 | } 106 | } 107 | } 108 | 109 | public sealed override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) 110 | { 111 | if (tsCount < 1) 112 | { 113 | // NOTE: This can happen when using animator.Play() on Start(). 114 | // Ignore it. 115 | return; 116 | } 117 | 118 | LastAnimator = animator; 119 | LastStateInfo = stateInfo; 120 | LastLayerIndex = layerIndex; 121 | 122 | var current = tsBuffer[0]; 123 | if (!current.soloEntered) 124 | { 125 | OnEnterSolo(); 126 | } 127 | if (!current.soloExited) 128 | { 129 | OnExitSolo(); 130 | } 131 | 132 | var temp = tsBuffer[0]; 133 | tsBuffer[0] = tsBuffer[1]; 134 | tsBuffer[1] = temp; 135 | 136 | tsCount -= 1; 137 | 138 | OnExit(); 139 | } 140 | } -------------------------------------------------------------------------------- /Input/PlayerInputDriver.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using UnityEngine; 3 | using UnityEngine.InputSystem; 4 | 5 | public interface IMITPlayerInputDriver : IMessageInterchangeTarget 6 | { 7 | void OnMove(Vector2 inputDir); 8 | void OnAttack(Vector2 inputDir); 9 | void OnRoll(bool isPressing); 10 | void OnTake(bool isPressing); 11 | void OnDrop(bool isPressing); 12 | } 13 | 14 | public interface IMITPlayerInputDriverForPause : IMessageInterchangeTarget 15 | { 16 | void OnPause(); 17 | } 18 | 19 | public class PlayerInputDriver : MonoBehaviour, 20 | IMITModalPause, 21 | IMITLevelEnd, 22 | IMITEnding 23 | { 24 | [SerializeField] private MessageInterchange messageInterchange; 25 | [SerializeField] private PlayerInput playerInput; 26 | 27 | private static readonly Action callOnMove = (t, v) => t.OnMove(v); 28 | private static readonly Action callOnAttack = (t, v) => t.OnAttack(v); 29 | private static readonly Action callOnRoll = (t, b) => t.OnRoll(b); 30 | private static readonly Action callOnTake = (t, b) => t.OnTake(b); 31 | private static readonly Action callOnDrop = (t, b) => t.OnDrop(b); 32 | private static readonly Action callOnPause = (t) => t.OnPause(); 33 | 34 | private bool isOnResult; 35 | 36 | void OnEnable() 37 | { 38 | messageInterchange.Register(this); 39 | } 40 | 41 | void OnDisable() 42 | { 43 | messageInterchange.Deregister(this); 44 | } 45 | 46 | void Start() 47 | { 48 | isOnResult = false; 49 | playerInput.SwitchCurrentActionMap("UI"); 50 | } 51 | 52 | public void OnMove(InputAction.CallbackContext context) 53 | { 54 | var inputDir = context.ReadValue(); 55 | messageInterchange.Invoke(callOnMove, inputDir); 56 | } 57 | 58 | public void OnAttack(InputAction.CallbackContext context) 59 | { 60 | var inputDir = context.ReadValue(); 61 | messageInterchange.Invoke(callOnAttack, inputDir); 62 | } 63 | 64 | public void OnRoll(InputAction.CallbackContext context) 65 | { 66 | if (context.performed) 67 | { 68 | messageInterchange.Invoke(callOnRoll, true); 69 | } 70 | if (context.canceled) 71 | { 72 | messageInterchange.Invoke(callOnRoll, false); 73 | } 74 | } 75 | 76 | public void OnTake(InputAction.CallbackContext context) 77 | { 78 | if (context.performed) 79 | { 80 | messageInterchange.Invoke(callOnTake, true); 81 | } 82 | if (context.canceled) 83 | { 84 | messageInterchange.Invoke(callOnTake, false); 85 | } 86 | } 87 | 88 | public void OnDrop(InputAction.CallbackContext context) 89 | { 90 | if (context.performed) 91 | { 92 | messageInterchange.Invoke(callOnDrop, true); 93 | } 94 | if (context.canceled) 95 | { 96 | messageInterchange.Invoke(callOnDrop, false); 97 | } 98 | } 99 | 100 | public void OnPause(InputAction.CallbackContext context) 101 | { 102 | if (context.performed) 103 | { 104 | messageInterchange.Invoke(callOnPause); 105 | } 106 | } 107 | 108 | public void OnDeviceLost(PlayerInput playerInput) 109 | { 110 | messageInterchange.Invoke(callOnPause); 111 | } 112 | 113 | // NOTE: To make sure to not send Player Character Input when loading next scene, 114 | // ActionMap should be switched to UI before chaning scenes. 115 | 116 | void IMITLevelEnd.OnEndPhase(bool isGoalArrived) 117 | { 118 | playerInput.SwitchCurrentActionMap("UI"); 119 | isOnResult = true; 120 | } 121 | 122 | void IMITModalPause.OnPause() 123 | { 124 | playerInput.SwitchCurrentActionMap("UI"); 125 | } 126 | 127 | void IMITModalPause.OnResume() 128 | { 129 | string actionMap = isOnResult ? "UI" : "Player"; 130 | playerInput.SwitchCurrentActionMap(actionMap); 131 | } 132 | 133 | void IMITEnding.OnAfterFadeIn() 134 | { 135 | playerInput.SwitchCurrentActionMap("Player"); 136 | } 137 | 138 | void IMITEnding.OnBeforeCredits() 139 | { 140 | playerInput.SwitchCurrentActionMap("UI"); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Player/PlayerSensorProximity.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using UnityEngine; 4 | 5 | public interface IPlayerSensorProximityTarget 6 | { 7 | void OnEnter(); 8 | void OnExit(); 9 | } 10 | 11 | public interface IMETPlayerSensorProximity : IMessageExchangeTarget 12 | { 13 | void OnEnter(Collider other); 14 | void OnExit(Collider other); 15 | } 16 | 17 | public class PlayerSensorProximity : MonoBehaviour, 18 | IMETPlayerParam 19 | { 20 | [SerializeField] private MessageExchange messageExchange; 21 | [SerializeField] private PlayerParam playerParam; 22 | [SerializeField] private WallChecker wallChecker; 23 | 24 | private static readonly Action callOnEnter = (t, other) => t.OnEnter(other); 25 | private static readonly Action callOnExit = (t, other) => t.OnExit(other); 26 | 27 | private class Trackee 28 | { 29 | public IPlayerSensorProximityTarget target; 30 | public bool isEntered; 31 | } 32 | 33 | private const float refreshPeriod = 0.1f; 34 | 35 | private Dictionary candidates; 36 | private ObjectPool trackeePool; 37 | private List removeStageBuffer; 38 | private Cooldown refreshCooldown; 39 | private SphereCollider trigger; 40 | 41 | void Awake() 42 | { 43 | candidates = new Dictionary(); 44 | trackeePool = new ObjectPool(); 45 | removeStageBuffer = new List(); 46 | refreshCooldown = new Cooldown(); 47 | refreshCooldown.Set(refreshPeriod, isReadyInitially: true); 48 | trigger = gameObject.AddComponent(); 49 | trigger.isTrigger = true; 50 | trigger.radius = 0f; 51 | } 52 | 53 | void OnEnable() 54 | { 55 | messageExchange.Register(this); 56 | } 57 | 58 | void OnDisable() 59 | { 60 | messageExchange.Deregister(this); 61 | } 62 | 63 | void Update() 64 | { 65 | refreshCooldown.Tick(Time.deltaTime); 66 | if (refreshCooldown.Claim()) 67 | { 68 | Refresh(); 69 | } 70 | } 71 | 72 | void OnTriggerEnter(Collider other) 73 | { 74 | var target = other.GetComponent(); 75 | if (target == null) 76 | { 77 | return; 78 | } 79 | var trackee = trackeePool.Take(); 80 | trackee.target = target; 81 | trackee.isEntered = false; 82 | candidates[other] = trackee; 83 | } 84 | 85 | void OnTriggerExit(Collider other) 86 | { 87 | var target = other.GetComponent(); 88 | if (target == null) 89 | { 90 | return; 91 | } 92 | Trackee trackee; 93 | bool ok = candidates.TryGetValue(other, out trackee); 94 | if (!ok) 95 | { 96 | return; 97 | } 98 | if (trackee.isEntered) 99 | { 100 | OnExit(other, trackee); 101 | } 102 | candidates.Remove(other); 103 | trackeePool.Return(trackee); 104 | } 105 | 106 | void IMETPlayerParam.OnChanged(PlayerParamData data) 107 | { 108 | trigger.radius = data.ProximityRange; 109 | } 110 | 111 | private void Refresh() 112 | { 113 | removeStageBuffer.Clear(); 114 | foreach (var item in candidates) 115 | { 116 | var collider = item.Key; 117 | var trackee = item.Value; 118 | if (collider == null) 119 | { 120 | removeStageBuffer.Add(collider); 121 | trackeePool.Return(trackee); 122 | continue; 123 | } 124 | bool isHit = wallChecker.Check(collider); 125 | if (!trackee.isEntered && !isHit) 126 | { 127 | OnEnter(collider, trackee); 128 | } 129 | if (trackee.isEntered && isHit) 130 | { 131 | OnExit(collider, trackee); 132 | } 133 | } 134 | foreach (var collider in removeStageBuffer) 135 | { 136 | candidates.Remove(collider); 137 | } 138 | } 139 | 140 | private void OnEnter(Collider collider, Trackee trackee) 141 | { 142 | messageExchange.Invoke(callOnEnter, collider); 143 | trackee.target.OnEnter(); 144 | trackee.isEntered = true; 145 | } 146 | 147 | private void OnExit(Collider collider, Trackee trackee) 148 | { 149 | messageExchange.Invoke(callOnExit, collider); 150 | trackee.target.OnExit(); 151 | trackee.isEntered = false; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /Player/PlayerSignpost.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | using Cinemachine; 3 | using FMODUnity; 4 | 5 | public interface IMETPlayerSignpost : IMessageExchangeTarget 6 | { 7 | void OnBreak(); 8 | } 9 | 10 | public class PlayerSignpost : MonoBehaviour, 11 | IMETPlayerActorTake, 12 | IMETPlayerActorDrop 13 | { 14 | [SerializeField] private MessageExchange messageExchange; 15 | [SerializeField] private PlayerAnimatorDriver playerAnimatorDriver; 16 | [SerializeField] private Platformer platformer; 17 | [SerializeField] private Transform handSlot; 18 | [SerializeField] private Transform breakPoint; 19 | [SerializeField] private Transform dropPoint; 20 | [SerializeField] private SilhouetteUpdater signpostInHandSilhouetteUpdater; 21 | [SerializeField] private int signpostSilhouetteMaterialIndex = 1; 22 | [SerializeField] private CinemachineImpulseSource CamShakeBreak; 23 | [SerializeField] private StudioEventEmitter audioWear; 24 | [SerializeField] private StudioEventEmitter audioBreak; 25 | 26 | private PlayerParamData signpostParamData; 27 | private int maxDurability; 28 | private int durability; 29 | 30 | public int MaxDurability { get => maxDurability; } 31 | public int Durability { get => durability; } 32 | 33 | void OnEnable() 34 | { 35 | messageExchange.Register(this); 36 | } 37 | 38 | void OnDisable() 39 | { 40 | messageExchange.Deregister(this); 41 | } 42 | 43 | void Start() 44 | { 45 | ClearState(); 46 | } 47 | 48 | void IMETPlayerActorTake.OnTake(PlayerParamData signpostParamData, int maxDurability, int durability) 49 | { 50 | this.signpostParamData = signpostParamData; 51 | this.maxDurability = maxDurability; 52 | this.durability = durability; 53 | ResetHand(); 54 | RefreshSignpostEffector(); 55 | } 56 | 57 | void IMETPlayerActorDrop.OnDrop() 58 | { 59 | ClearSlot(handSlot); 60 | PlaceItemSigpost(); 61 | ClearState(); 62 | } 63 | 64 | public void Claim(int hitCount) 65 | { 66 | if (signpostParamData == null) 67 | { 68 | return; 69 | } 70 | 71 | if (durability == 0) 72 | { 73 | ClearSlot(handSlot); 74 | PlaceSignpostBroken(); 75 | ClearState(); 76 | messageExchange.Invoke(t => t.OnBreak()); 77 | playerAnimatorDriver.SetIsHoldingSignpost(false); 78 | CamShakeBreak.GenerateImpulse(); 79 | audioBreak.Play(); 80 | } 81 | else 82 | { 83 | durability = Mathf.Clamp(durability - hitCount, 0, maxDurability); 84 | RefreshSignpostEffector(); 85 | if (durability == 0) 86 | { 87 | audioWear.Play(); 88 | } 89 | } 90 | } 91 | 92 | private void ResetHand() 93 | { 94 | ClearSlot(handSlot); 95 | var prefab = signpostParamData.SignpostInHandPrefab; 96 | var go = Instantiate(prefab, handSlot); 97 | var signpostRenderer = go.GetComponent(); 98 | signpostInHandSilhouetteUpdater.Activate(signpostRenderer, signpostSilhouetteMaterialIndex); 99 | } 100 | 101 | private void ClearSlot(Transform slot) 102 | { 103 | signpostInHandSilhouetteUpdater.Deactivate(); 104 | foreach (Transform s in slot) 105 | { 106 | Destroy(s.gameObject); 107 | } 108 | } 109 | 110 | private void PlaceItemSigpost() 111 | { 112 | var prefab = signpostParamData.ItemSignpostOnFloorPrefab; 113 | var go = Instantiate(prefab, dropPoint.position, dropPoint.rotation); 114 | var itemSignpost = go.GetComponent(); 115 | itemSignpost.InitUsed(maxDurability, durability); 116 | if (platformer.Current != null) 117 | { 118 | var p = go.GetComponent(); 119 | p.Inherit(platformer); 120 | } 121 | } 122 | 123 | private void PlaceSignpostBroken() 124 | { 125 | var prefab = signpostParamData.SignpostBrokenPrefab; 126 | var go = Instantiate(prefab, breakPoint.position, breakPoint.rotation); 127 | if (platformer.Current != null) 128 | { 129 | var platformers = go.GetComponentsInChildren(); 130 | foreach (var p in platformers) 131 | { 132 | p.Inherit(platformer); 133 | } 134 | } 135 | 136 | } 137 | 138 | private void ClearState() 139 | { 140 | signpostParamData = null; 141 | maxDurability = 0; 142 | durability = 0; 143 | } 144 | 145 | private void RefreshSignpostEffector() 146 | { 147 | if (durability > 0) 148 | { 149 | return; 150 | } 151 | var effector = handSlot.GetComponentInChildren(); 152 | if (effector == null) 153 | { 154 | return; 155 | } 156 | effector.enabled = true; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /Core/BasicTypeExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using UnityEngine; 3 | 4 | public static class BasicTypeExtensions 5 | { 6 | public static readonly Quaternion Ccw90 = Quaternion.AngleAxis(-90f, Vector3.up); 7 | 8 | public static Vector3 WithY(this Vector3 v, float y) 9 | { 10 | return new Vector3(v.x, y, v.z); 11 | } 12 | 13 | public static Vector3 ToWorldDir(this Vector2 moveInput) 14 | { 15 | Vector3 right = Camera.main.transform.right; 16 | right.y = 0f; 17 | right.Normalize(); 18 | Vector3 forward = Ccw90 * right; 19 | return (forward * moveInput.y + right * moveInput.x).normalized; 20 | } 21 | 22 | public static Vector3 ToWorldDir(this Vector3 v) 23 | { 24 | Vector3 x = v; 25 | x.y = 0f; 26 | x.Normalize(); 27 | return x; 28 | } 29 | 30 | public static Quaternion WorldDirToRotation(this Vector3 worldDir, Quaternion defaultRotation) 31 | { 32 | return worldDir != Vector3.zero ? 33 | Quaternion.LookRotation(worldDir, Vector3.up) : 34 | defaultRotation; 35 | } 36 | 37 | public static bool IsClockwise(this Quaternion a, Quaternion target) 38 | { 39 | Quaternion b = Quaternion.RotateTowards(a, target, 1f); 40 | Vector3 va = a * Vector3.forward; 41 | Vector3 vb = b * Vector3.forward; 42 | return va.x * vb.z - va.z * vb.x < 0f; 43 | } 44 | 45 | public static bool IsSameOrientation(this Quaternion a, Quaternion b) 46 | { 47 | return Mathf.Approximately(Mathf.Abs(Quaternion.Dot(a, b)), 1.0f); 48 | } 49 | 50 | public static Color WithAlpha(this Color c, float alpha) 51 | { 52 | return new Color(c.r, c.g, c.b, alpha); 53 | } 54 | 55 | public static Color WithValue(this Color c, float value) 56 | { 57 | float h, s; 58 | Color.RGBToHSV(c, out h, out s, out _); 59 | return Color.HSVToRGB(h, s, value); 60 | } 61 | 62 | public static Color WithBrightness(this Color c, float brightness) 63 | { 64 | float currentBrightness = c.maxColorComponent; 65 | if (currentBrightness == 0f) 66 | { 67 | return new Color(brightness, brightness, brightness, 1f); 68 | } 69 | float f = brightness / currentBrightness; 70 | return new Color(f * c.r, f * c.g, f * c.b, c.a); 71 | } 72 | 73 | public static void AssertLayer(this GameObject gameObject, string expected) 74 | { 75 | if (gameObject.layer != LayerMask.NameToLayer(expected)) 76 | { 77 | Debug.LogWarning($"Expected: {expected}, current: {LayerMask.LayerToName(gameObject.layer)}", gameObject); 78 | } 79 | } 80 | 81 | public static int AddMaterial(this Renderer renderer, Material material) 82 | { 83 | var ms = renderer.sharedMaterials; 84 | int n = ms.Length; 85 | for (int i = 0; i < n; i++) 86 | { 87 | var m = ms[i]; 88 | if (m == material) 89 | { 90 | return i; 91 | } 92 | } 93 | var newMs = new Material[n + 1]; 94 | ms.CopyTo(newMs, 0); 95 | newMs[n] = material; 96 | renderer.sharedMaterials = newMs; 97 | return n; 98 | } 99 | 100 | public static bool RemoveMaterial(this Renderer renderer, Material material) 101 | { 102 | var ms = renderer.sharedMaterials; 103 | int n = ms.Length; 104 | var newMs = new List(capacity: n); 105 | for (int i = 0; i < n; i++) 106 | { 107 | var m = ms[i]; 108 | if (m != material) 109 | { 110 | newMs.Add(m); 111 | } 112 | } 113 | if (newMs.Count == n) 114 | { 115 | return false; 116 | } 117 | renderer.sharedMaterials = newMs.ToArray(); 118 | return true; 119 | } 120 | 121 | public static bool RemoveMaterial(this Renderer renderer, int materialIndex) 122 | { 123 | var ms = renderer.sharedMaterials; 124 | int n = ms.Length; 125 | var newMs = new List(capacity: n); 126 | for (int i = 0; i < n; i++) 127 | { 128 | var m = ms[i]; 129 | if (i != materialIndex) 130 | { 131 | newMs.Add(m); 132 | } 133 | } 134 | if (newMs.Count == n) 135 | { 136 | return false; 137 | } 138 | renderer.sharedMaterials = newMs.ToArray(); 139 | return true; 140 | } 141 | 142 | public static bool SetMaterial(this Renderer renderer, Material material, int materialIndex) 143 | { 144 | var sharedMaterials = renderer.sharedMaterials; 145 | if (sharedMaterials.Length > materialIndex) 146 | { 147 | sharedMaterials[materialIndex] = material; 148 | renderer.sharedMaterials = sharedMaterials; 149 | return true; 150 | } 151 | else 152 | { 153 | return false; 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # <좀비와 열쇠> 코드 샘플 2 | ![Cover.png](/Images/Cover.png) 3 | 4 | 이 코드 샘플은 <[좀비와 열쇠(Zombies and Keys)](https://store.steampowered.com/app/1167150/Zombies_and_Keys/)>의 구현 방식을 설명하기 위한 플레이어 캐릭터 관련 코드를 담고 있습니다. 전부 개발자가 직접 작성한 코드이며, 실제 게임에 사용된 코드이지만 저장소에 포함된 코드만으로는 실행되지 않습니다. 아래에 코드의 이해를 돕기 위한 설명이 이어집니다. 5 | 6 | ## MessageExchange 7 | 몇 번의 습작을 개발하는 과정에서 컴포넌트 간의 책임을 잘 분리하지 않아 몇몇 MonoBehaviour가 과도한 책임을 맡게 되어 비대해지는 상황을 많이 겪었습니다. 비대해진 MonoBehaviour는 버그가 발생하기 쉬우며 기능 추가 및 수정이 어려웠습니다. 8 | 9 | ![MessageExchange.png](/Images/MessageExchange.png) 10 | 11 | 이 문제를 해결하기 위해 <좀비와 열쇠>에서는 각 기능을 독립적으로 구현할 수 있도록 [MessageExchange.cs]라는 간단한 메시징(이벤트) 시스템을 구현했습니다. 게임 속 개체(플레이어, 좀비 등)를 이루는 컴포넌트들은 이 시스템을 기반으로 상호작용합니다. 12 | 13 | MessageExchange는 Meditator 디자인 패턴이자 PubSub 패턴으로 볼 수 있고, 그 구현 방식은 특정 interface를 구현한 MonoBehaviour가 해당 메시지를 처리하도록 하는, Unity UI의 [Messaging System]을 단일 GameObject가 아닌, 단일 개체의 전체, 즉 GameObject Hierarchy로 확장한 버전으로 생각할 수 있습니다. 14 | 15 | MessageExchange를 위한 interface는 모두 `IMessageExchangeTarget`을 상속하며, 그 이름은 `IMET`로 시작합니다. 예를 들어 플레이어의 체력이 변동된 경우, 플레이어의 체력을 담당하는 [PlayerHealth.cs]는 다음과 같이 MessageExchange 메시지를 발생시킵니다. 16 | 17 | ```csharp 18 | public interface IMETPlayerHealthOnChanage : IMessageExchangeTarget 19 | { 20 | void OnChanage(int healthRemaining); 21 | } 22 | 23 | // NOTE: Garbage가 생성되는 것을 막기 위해 readonly로 만들어 둡니다. 24 | private static readonly Action callOnChange = 25 | (t, healthRemaining) => t.OnChanage(healthRemaining); 26 | 27 | messageExchange.Invoke(callOnChange, health); 28 | ``` 29 | 30 | 플레이어의 체력이 1 남은 경우, 체력표시 HUD를 빨갛게 깜빡이는데요, HUD 기능을 제어하는 [PlayerHUDDriver.cs]는 `IMETPlayerHealthOnChanage`를 다음과 같이 처리합니다. 31 | 32 | ```csharp 33 | // 관련 내용을 제외하고 모두 생략했습니다. 34 | public class PlayerHUDDriver : MonoBehaviour, 35 | IMETPlayerHealthOnChanage 36 | { 37 | [SerializeField] private MessageExchange messageExchange; 38 | [SerializeField] private HUDDotContainer hudHealth; 39 | 40 | void OnEnable() 41 | { 42 | messageExchange.Register(this); 43 | } 44 | 45 | void OnDisable() 46 | { 47 | messageExchange.Deregister(this); 48 | } 49 | 50 | void IMETPlayerHealthOnChanage.OnChanage(int healthRemaining) 51 | { 52 | hudHealth.IsBlinking = healthRemaining == 1; 53 | } 54 | } 55 | ``` 56 | 57 | 58 | ## Animator와 MessageExchange 연동 59 | <좀비와 열쇠>는 게이머에게 보이는 것과 게임 로직이 매끄럽게 연결되는 반응성 좋은 액션 게임이 되길 원했습니다. 이 목표를 달성하기 위해 Animator의 State와 State 간 트랜지션에 따라 게임 로직이 반응하도록 게임을 설계했습니다. 60 | 61 | ![AnimatorController.png](/Images/AnimatorController.png) 62 | 63 | Animator 내부에서 일어나는 모든 일을 MessageExchange를 거치도록 하기 위해 Unity Animator의 콜백 이벤트들을 연결해주는 어댑터 컴포넌트들을 작성했습니다. 64 | 65 | ![MessageExchange-Animator.png](/Images/MessageExchange-Animator.png) 66 | 67 | [PlayerSMBIdle.cs]과 같이 `PlayerSMB`로 시작하는 파일들은 Animator의 각 State 대응되는 `StateMachineBehaviour`를 코드이며 해당 상태의 Enter, Exit 등의 이벤트를 MessageExchange 메시지로 변환시키는 역할을 합니다. 68 | 69 | Animator가 설치된 GameObject의 Component의 함수 이름으로 호출하는 레거시 방식의 [Animation Event]의 경우에는 [PlayerAnimationEventProxy.cs]에서 [Animation Event] 호출을 MessageExchange 메시지로 변환합니다. 70 | 71 | 72 | ## MessageInterchange 73 | MessageExchange의 전역 버전으로 [MessageInterchange.cs]도 있습니다. 정확히 똑같이 동작하지만 ScriptableObject 형태로 구현되어 개체 간의 통신을 담당합니다. 그 한 가지 예는 "입력처리"입니다. 74 | 75 | ![MessageInterchange.png](/Images/MessageInterchange.png) 76 | 77 | 게이머의 조작 자체는 게임상에서 유일하고, 플레이어 캐릭터와는 직접적인 상관이 없기 때문에, 별도의 개체에 구현했습니다. [PlayerInputDriver.cs]는 Unity의 New Input System을 콜백을 다음과 같은 MessageInterchange 메시지로 변환하여 발생시킵니다. MessageExchange와 마찬가지로 MessageInterchange를 거치는 메시지 interface는 `IMIT`로 시작합니다. 78 | 79 | ```csharp 80 | public interface IMITPlayerInputDriver : IMessageInterchangeTarget 81 | { 82 | void OnMove(Vector2 inputDir); 83 | void OnAttack(Vector2 inputDir); 84 | void OnRoll(bool isPressing); 85 | void OnTake(bool isPressing); 86 | void OnDrop(bool isPressing); 87 | } 88 | ``` 89 | 90 | Player 개체에 속한 [PlayerControl.cs]은 `IMITPlayerInputDriver` 메시지를 받아 플레이어 캐릭터의 도메인 입력으로 변환하고, 그 최신 값을 다른 컴포넌트가 참조할 수 있도록 캐싱하고 있다가 제공합니다. 91 | 92 | 93 | ## Actor와 FSM 94 | 95 | ![Actor.png](/Images/Actor.png) 96 | 97 | [PlayerActorIdle.cs]와 같이 `PlayerActor`로 시작하는 파일들은 [PlayerControl.cs]에서 입력을 읽고 MessageExchange에서 적절한 메시지를 읽어 간단한 유한 상태 기계([FiniteStateMachine.cs]) 로직에 따라 상태를 바꿔가며 동작하는, 도메인 로직이 구현된 파일입니다. 98 | 99 | # 기타 100 | - [Player.cs] - 피격 처리 등, 현재 플레이어 개체가 외부 특정 개체와 직접 상호작용 역할을 할 때 필요한 기능이 구현되어 있습니다. 101 | - [PlayerParamData.cs] - 플레이어가 맨손 일때와 표지판을 들었을 때의 파라매터 데이터를 담고 있는 ScriptableObject입니다. 102 | - [Locomotor.cs] - 플레이어 캐릭터와 좀비 공통으로 사용되는 이동 입력 보정같은 물리 상호작용에 사용되는 로직 103 | - [BasicTypeExtensions.cs] - [Extension Method]로 구현된 자주 사용되는 유틸리티 기능들 104 | 105 | 106 | [MessageExchange.cs]: /Core/MessageExchange.cs 107 | [MessageInterchange.cs]: /Core/MessageInterchange.cs 108 | [FiniteStateMachine.cs]: /Core/FiniteStateMachine.cs 109 | [BasicTypeExtensions.cs]: /Core/BasicTypeExtensions.cs 110 | [Locomotor.cs]: /Core/Locomotor.cs 111 | 112 | [Player.cs]: /Player/Player.cs 113 | [PlayerParamData.cs]: /Player/PlayerParamData.cs 114 | [PlayerInputDriver.cs]: /Input/PlayerInputDriver.cs 115 | [PlayerControl.cs]: /Player/PlayerControl.cs 116 | [PlayerHealth.cs]: /Player/PlayerHealth.cs 117 | [PlayerHUDDriver.cs]: /Player/PlayerHUDDriver.cs 118 | [PlayerInputDriver.cs]: /Player/PlayerInputDriver.cs 119 | [PlayerSMBIdle.cs]: /Player/PlayerSMBIdle.cs 120 | [PlayerAnimationEventProxy.cs]: /Player/PlayerAnimationEventProxy.cs 121 | [PlayerActorIdle.cs]: /Player/PlayerActorIdle.cs 122 | 123 | [Messaging System]: https://docs.unity3d.com/Packages/com.unity.ugui@1.0/manual/MessagingSystem.html 124 | [Animation Event]: https://docs.unity3d.com/Manual/script-AnimationWindowEvent.html 125 | [Extension Method]: https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/extension-methods 126 | -------------------------------------------------------------------------------- /Player/PlayerActorTake.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using UnityEngine; 3 | using FMODUnity; 4 | 5 | public interface IMETPlayerActorTake : IMessageExchangeTarget 6 | { 7 | void OnTake(PlayerParamData signpostParamData, int maxDurability, int durability); 8 | } 9 | 10 | public class PlayerActorTake : MonoBehaviour, 11 | IMETPlayerSensorItemSignpost, 12 | IMETPlayerSMBIdle, 13 | IMETPlayerSMBTake, 14 | IMETPlayerAnimationEventProxyOnTake 15 | { 16 | [SerializeField] private MessageExchange messageExchange; 17 | [SerializeField] private Locomotor locomotor; 18 | [SerializeField] private PlayerParam playerParam; 19 | [SerializeField] private PlayerControl playerControl; 20 | [SerializeField] private PlayerAnimatorDriver playerAnimatorDriver; 21 | [SerializeField] private StudioEventEmitter audioTake; 22 | 23 | private FSM fsm = new FSM(); 24 | private List itemSignpostsNearby = new List(); 25 | private ItemSignpost itemSignpostInteracting = null; 26 | 27 | void OnEnable() 28 | { 29 | messageExchange.Register(this); 30 | } 31 | 32 | void OnDisable() 33 | { 34 | messageExchange.Deregister(this); 35 | } 36 | 37 | void Start() 38 | { 39 | fsm.Init(this); 40 | } 41 | 42 | void FixedUpdate() 43 | { 44 | fsm.FixedUpdate(); 45 | } 46 | 47 | void Update() 48 | { 49 | fsm.Update(); 50 | } 51 | 52 | void IMETPlayerSensorItemSignpost.OnEnter(Collider other) 53 | { 54 | var itemSignpost = other.gameObject.GetComponent(); 55 | if (itemSignpost != null && 56 | // NOTE: When the signpostInteracting is stuttering, 57 | // the interacting signpost can be found as new one. 58 | itemSignpost != itemSignpostInteracting) 59 | { 60 | itemSignpostsNearby.Add(itemSignpost); 61 | } 62 | } 63 | 64 | void IMETPlayerSensorItemSignpost.OnExit(Collider other) 65 | { 66 | var itemSignpost = other.gameObject.GetComponent(); 67 | itemSignpostsNearby.Remove(itemSignpost); 68 | } 69 | 70 | void IMETPlayerSMBIdle.OnEnterSolo() 71 | { 72 | fsm.TransitionTo(); 73 | } 74 | 75 | void IMETPlayerSMBIdle.OnExitSolo() 76 | { 77 | fsm.TransitionTo(); 78 | } 79 | 80 | void IMETPlayerSMBTake.OnEnter() 81 | { 82 | fsm.TransitionTo(); 83 | } 84 | 85 | void IMETPlayerSMBTake.OnExit() 86 | { 87 | // NOTE: Make sure there is no itemSignpostInteracting; 88 | // This is important because when getting hit while taking before OnTake is called, 89 | // itemSignpostInteracting will remain and prevent the last one from being taken. 90 | itemSignpostInteracting = null; 91 | fsm.TransitionTo(); 92 | } 93 | 94 | void IMETPlayerAnimationEventProxyOnTake.OnTake() 95 | { 96 | if (itemSignpostInteracting != null) 97 | { 98 | messageExchange.Invoke(t => 99 | t.OnTake( 100 | itemSignpostInteracting.SignpostParamData, 101 | itemSignpostInteracting.MaxDurability, 102 | itemSignpostInteracting.Durability)); 103 | itemSignpostInteracting.OnClaim(); 104 | itemSignpostsNearby.Remove(itemSignpostInteracting); 105 | Destroy(itemSignpostInteracting.gameObject); 106 | itemSignpostInteracting = null; 107 | } 108 | audioTake.Play(); 109 | playerAnimatorDriver.SetIsHoldingSignpost(true); 110 | } 111 | 112 | class FSM : FiniteStateMachine { } 113 | class State : FiniteStateMachineState { } 114 | 115 | class StateWait : State { } 116 | 117 | class StateReady : State 118 | { 119 | public override void OnEnter() 120 | { 121 | // NOTE: Make sure there is no itemSignpostInteracting; 122 | C.itemSignpostInteracting = null; 123 | } 124 | 125 | public override void OnUpdate() 126 | { 127 | if (C.itemSignpostsNearby.Count > 0 && C.playerControl.ButtonTake) 128 | { 129 | C.itemSignpostInteracting = PickClosest(); 130 | if (C.itemSignpostInteracting == null) 131 | { 132 | // NOTE: Unexpected. Skip if it happens. 133 | return; 134 | } 135 | C.playerAnimatorDriver.TriggerTake(); 136 | C.fsm.TransitionTo(); 137 | } 138 | } 139 | 140 | private ItemSignpost PickClosest() 141 | { 142 | Vector3 position = C.locomotor.transform.position; 143 | ItemSignpost target = null; 144 | float sqrMagMin = Mathf.Infinity; 145 | int n = C.itemSignpostsNearby.Count; 146 | for (int i = n - 1; i >= 0; i--) 147 | { 148 | var itemSignpost = C.itemSignpostsNearby[i]; 149 | if (itemSignpost == null) 150 | { 151 | // NOTE: Unexpected. Skip if it happens. 152 | continue; 153 | } 154 | float sqrMag = (itemSignpost.HUDRingPosition - position).sqrMagnitude; 155 | if (sqrMag < sqrMagMin) 156 | { 157 | target = itemSignpost; 158 | sqrMagMin = sqrMag; 159 | } 160 | } 161 | return target; 162 | } 163 | } 164 | 165 | class StateTaking : State 166 | { 167 | Quaternion targetRotation; 168 | 169 | public override void OnEnter() 170 | { 171 | var target = C.itemSignpostInteracting.HUDRingPosition; 172 | targetRotation = C.locomotor.RotationTowardTarget(target); 173 | float brake = C.playerParam.TakeBrake; 174 | C.locomotor.Brake(brake); 175 | } 176 | 177 | public override void OnFixedUpdate() 178 | { 179 | var rotationSpeed = C.playerParam.MoveRotationSpeed; 180 | C.locomotor.RotateTowardTarget(targetRotation, rotationSpeed); 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /Player/Player.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using UnityEngine; 3 | 4 | public interface IMETPlayerOnAggroed : IMessageExchangeTarget 5 | { 6 | void OnAggroChanged(int aggroedCount); 7 | } 8 | 9 | public interface IMETPlayerOnAttacked : IMessageExchangeTarget 10 | { 11 | void OnAttacked(IPlayerOnAttackedInfo attackedInfo); 12 | } 13 | 14 | public interface IMETPlayerOnBitten : IMessageExchangeTarget 15 | { 16 | void OnBitten(Vector3 bittenFrom); 17 | } 18 | 19 | public interface IPlayerOnAttackedInfo 20 | { 21 | Vector3 AttackedFrom { get; } 22 | float BrakeMultiplier { get; } 23 | float KnockbackSpeed { get; } 24 | int Damage { get; } 25 | } 26 | 27 | public class PlayerOnAttackedInfo : IPlayerOnAttackedInfo 28 | { 29 | public Vector3 attackedFrom; 30 | public float brakeMultiplier; 31 | public float knockbackSpeed; 32 | public int damage; 33 | 34 | Vector3 IPlayerOnAttackedInfo.AttackedFrom => attackedFrom; 35 | float IPlayerOnAttackedInfo.BrakeMultiplier => brakeMultiplier; 36 | float IPlayerOnAttackedInfo.KnockbackSpeed => knockbackSpeed; 37 | int IPlayerOnAttackedInfo.Damage => damage; 38 | } 39 | 40 | public class Player : MonoBehaviour, 41 | IZombieTarget, 42 | IZombieActorAttackVictim, 43 | IZombieActorBiteVictim, 44 | IFreeFallDeathCheckerTarget, 45 | ITrapTarget, 46 | IMITLevelEnd, 47 | IMETPlayerSMBGetHit, 48 | IMETPlayerSMBRoll 49 | { 50 | [SerializeField] private ZombieAggroTracker zombieAggroTracker; 51 | [SerializeField] private MessageInterchange messageInterchange; 52 | [SerializeField] private MessageExchange messageExchange; 53 | [SerializeField] private PlayerParam playerParam; 54 | 55 | public static Vector3 InitialPosition; 56 | public static Quaternion InitialRotation; 57 | 58 | private static readonly Action callOnAggroChanged = (t, n) => t.OnAggroChanged(n); 59 | 60 | private PlayerOnAttackedInfo attackedInfo = new PlayerOnAttackedInfo(); 61 | 62 | private const float damageableDuration = 0.25f; 63 | private const float skipSequentialHitDuration = 1.5f; 64 | 65 | private Cooldown damageableCooldown; 66 | private Cooldown skipSequentialHitCooldown; 67 | private bool isGetHitting; 68 | private bool isRolling; 69 | private bool isDead; 70 | private bool isSafe; 71 | 72 | void Awake() 73 | { 74 | InitialPosition = transform.position; 75 | InitialRotation = transform.rotation; 76 | 77 | isGetHitting = false; 78 | isRolling = false; 79 | isDead = false; 80 | isSafe = false; 81 | damageableCooldown = new Cooldown(); 82 | damageableCooldown.Set(damageableDuration, isReadyInitially: true); 83 | skipSequentialHitCooldown = new Cooldown(); 84 | skipSequentialHitCooldown.Set(skipSequentialHitDuration, isReadyInitially: true); 85 | } 86 | 87 | void OnEnable() 88 | { 89 | zombieAggroTracker.OnChanged += OnAggroChanged; 90 | messageInterchange.Register(this); 91 | messageExchange.Register(this); 92 | } 93 | 94 | void OnDisable() 95 | { 96 | zombieAggroTracker.OnChanged -= OnAggroChanged; 97 | messageInterchange.Deregister(this); 98 | messageExchange.Deregister(this); 99 | } 100 | 101 | void Update() 102 | { 103 | float dt = Time.deltaTime; 104 | damageableCooldown.Tick(dt); 105 | skipSequentialHitCooldown.Tick(dt); 106 | } 107 | 108 | private void OnAggroChanged(int count) 109 | { 110 | messageExchange.Invoke(callOnAggroChanged, count); 111 | } 112 | 113 | Vector3 IZombieTarget.Position { get => transform.position; } 114 | 115 | bool IZombieTarget.IsDead { get => isDead; } 116 | 117 | GameObject IZombieTarget.RootGO { get => this.gameObject; } 118 | 119 | void IZombieActorAttackVictim.OnAttacked(IZombieActorAttackInfo attackInfo) 120 | { 121 | if (isGetHitting || isDead || isSafe) 122 | { 123 | return; 124 | } 125 | 126 | if (damageableCooldown.IsReady && skipSequentialHitCooldown.Claim()) 127 | { 128 | attackedInfo.attackedFrom = attackInfo.AttackedFrom; 129 | attackedInfo.brakeMultiplier = 0f; 130 | attackedInfo.knockbackSpeed = attackInfo.KnockbackSpeed; 131 | attackedInfo.damage = attackInfo.Damage; 132 | messageExchange.Invoke(t => t.OnAttacked(attackedInfo)); 133 | } 134 | } 135 | 136 | void IZombieActorBiteVictim.OnBitten(ZombieActorBiteInfo biteInfo) 137 | { 138 | messageExchange.Invoke(t => t.OnBitten(biteInfo.BittenFrom)); 139 | } 140 | 141 | void IFreeFallDeathCheckerTarget.OnDeathContact() 142 | { 143 | // NOTE: Don't skip when isDead is true; actors will react to this event. 144 | if (isSafe) 145 | { 146 | return; 147 | } 148 | attackedInfo.attackedFrom = transform.position - transform.forward; 149 | attackedInfo.brakeMultiplier = 1f; 150 | attackedInfo.knockbackSpeed = 0f; 151 | attackedInfo.damage = playerParam.HealthMax; 152 | messageExchange.Invoke(t => t.OnAttacked(attackedInfo)); 153 | } 154 | 155 | bool ITrapTarget.IsRolling => isRolling; 156 | 157 | void ITrapTarget.OnTrapped(TrapInfo trapInfo) 158 | { 159 | if (isGetHitting || isDead || isSafe) 160 | { 161 | return; 162 | } 163 | attackedInfo.attackedFrom = trapInfo.Position; 164 | attackedInfo.brakeMultiplier = trapInfo.BrakeMultiplier; 165 | attackedInfo.knockbackSpeed = trapInfo.KnockbackSpeed; 166 | attackedInfo.damage = trapInfo.Damage; 167 | messageExchange.Invoke(t => t.OnAttacked(attackedInfo)); 168 | } 169 | 170 | void IMITLevelEnd.OnEndPhase(bool isGoalArrived) 171 | { 172 | if (isGoalArrived) 173 | { 174 | isSafe = true; 175 | } 176 | else 177 | { 178 | isDead = true; 179 | } 180 | } 181 | 182 | void IMETPlayerSMBGetHit.OnEnter() 183 | { 184 | isGetHitting = true; 185 | } 186 | 187 | void IMETPlayerSMBGetHit.OnEnterSolo() { } 188 | 189 | void IMETPlayerSMBGetHit.OnExitSolo() 190 | { 191 | isGetHitting = false; 192 | } 193 | 194 | void IMETPlayerSMBRoll.OnEnter() 195 | { 196 | damageableCooldown.Reset(isReady: false); 197 | isRolling = true; 198 | } 199 | void IMETPlayerSMBRoll.OnEnterSolo() { } 200 | void IMETPlayerSMBRoll.OnExitSolo() { } 201 | 202 | void IMETPlayerSMBRoll.OnExit() 203 | { 204 | isRolling = false; 205 | } 206 | } -------------------------------------------------------------------------------- /Core/Locomotor.cs: -------------------------------------------------------------------------------- 1 | using UnityEngine; 2 | 3 | public class Locomotor : MonoBehaviour, IMETRampChecker 4 | { 5 | [SerializeField] private MessageExchange messageExchange; 6 | [SerializeField] private new Rigidbody rigidbody; 7 | [SerializeField] private new Collider collider; 8 | [SerializeField] private PhysicMaterial freeFallMaterial; 9 | [SerializeField] private Platformer platformer; 10 | [SerializeField] private string layerInvalidatedName; 11 | 12 | public Vector3 Position => rigidbody.position; 13 | public Quaternion Rotation => rigidbody.rotation; 14 | public Vector3 RelVelocity => rigidbody.velocity - platformer.Velocity; 15 | public float Speed => RelVelocity.WithY(0f).magnitude; 16 | 17 | // NOTE: This is for increasing the free falling speed; 18 | private const float freeFallDrag = 0.5f; 19 | 20 | private float defaultDrag; 21 | private PhysicMaterial defaultMaterial; 22 | private bool isOnGround; 23 | 24 | private bool isOnRamp; 25 | private Vector3 gravityPortion; 26 | 27 | private int layerDefault; 28 | private int layerInvalidated; 29 | 30 | void Awake() 31 | { 32 | messageExchange.Register(this); 33 | defaultDrag = rigidbody.drag; 34 | defaultMaterial = collider.sharedMaterial; 35 | isOnGround = true; 36 | isOnRamp = false; 37 | gravityPortion = Vector3.zero; 38 | layerDefault = rigidbody.gameObject.layer; 39 | layerInvalidated = LayerMask.NameToLayer(layerInvalidatedName); 40 | } 41 | 42 | void OnDestroy() 43 | { 44 | messageExchange.Deregister(this); 45 | } 46 | 47 | void FixedUpdate() 48 | { 49 | if (isOnRamp) 50 | { 51 | rigidbody.AddForce(-gravityPortion, ForceMode.Acceleration); 52 | } 53 | } 54 | 55 | void IMETRampChecker.OnChange(bool isOnRamp, Vector3 normal, Vector3 tangent) 56 | { 57 | this.isOnRamp = isOnRamp; 58 | this.gravityPortion = Vector3.Project(Physics.gravity, tangent); 59 | } 60 | 61 | public float Mass 62 | { 63 | get => rigidbody.mass; 64 | set => rigidbody.mass = value; 65 | } 66 | 67 | public bool DetectCollisions 68 | { 69 | get => rigidbody.gameObject.layer == layerDefault; 70 | set 71 | { 72 | rigidbody.gameObject.layer = value ? layerDefault : layerInvalidated; 73 | } 74 | } 75 | 76 | public bool IsOnGround 77 | { 78 | get => isOnGround; 79 | set 80 | { 81 | if (isOnGround != value) 82 | { 83 | isOnGround = value; 84 | rigidbody.drag = isOnGround ? defaultDrag : freeFallDrag; 85 | collider.sharedMaterial = isOnGround ? defaultMaterial : freeFallMaterial; 86 | } 87 | } 88 | 89 | } 90 | 91 | public Vector3 WorldDir => (rigidbody.rotation * Vector3.forward).WithY(0f).normalized; 92 | 93 | public void CorrectWorldDirMove(ref Vector3 worldDirMove, CapsuleCollider capsuleCollider, float distance, int layerMask) 94 | { 95 | if (worldDirMove == Vector3.zero) 96 | { 97 | return; 98 | } 99 | 100 | float radius = capsuleCollider.radius; 101 | float height = capsuleCollider.height; 102 | 103 | Vector3 position = rigidbody.position; 104 | float y = position.y; 105 | 106 | // NOTE: Offsets for detecting close objects; 107 | // i.e. When moving up the ramp, the ramp should be detected before the floor at the end 108 | // until the character reaches the end. Otherwise this will consider the floor as wall. 109 | Vector3 xzOffset = worldDirMove * -radius; 110 | const float yOffset = 0.05f; 111 | Vector3 point1 = xzOffset + position.WithY(y + radius - yOffset); 112 | Vector3 point2 = xzOffset + position.WithY(y + height - radius); 113 | 114 | RaycastHit hitInfo; 115 | bool isHit = Physics.CapsuleCast(point1, point2, radius, worldDirMove, out hitInfo, distance + radius, layerMask); 116 | if (isHit) 117 | { 118 | // NOTE: Skip if it's ramp-ish; 119 | if (Vector3.Angle(Vector3.up, hitInfo.normal) < 70f) 120 | { 121 | return; 122 | } 123 | 124 | Vector3 projected = Vector3.ProjectOnPlane(worldDirMove, hitInfo.normal).WithY(0f).normalized; 125 | if (projected != Vector3.zero) 126 | { 127 | worldDirMove = projected; 128 | } 129 | } 130 | } 131 | 132 | public Quaternion RotationToward(Vector3 worldDir) 133 | { 134 | if (worldDir == Vector3.zero) 135 | { 136 | return rigidbody.rotation; 137 | } 138 | return Quaternion.LookRotation(worldDir, Vector3.up); 139 | } 140 | 141 | public Quaternion RotationTowardTarget(Vector3 targetPosition) 142 | { 143 | var relativePos = targetPosition - rigidbody.position; 144 | var worldDir = relativePos.ToWorldDir(); 145 | return RotationToward(worldDir); 146 | } 147 | 148 | public void RotateTowardTarget(Quaternion targetRotation, float rotationSpeed) 149 | { 150 | var maxDegreesDelta = rotationSpeed * Time.deltaTime; 151 | rigidbody.rotation = Quaternion.RotateTowards(rigidbody.rotation, targetRotation, maxDegreesDelta); 152 | } 153 | 154 | public void RotateTowardMovingDirection(float rotationSpeed) 155 | { 156 | Vector3 movingDir = RelVelocity.ToWorldDir(); 157 | if (movingDir == Vector3.zero) 158 | { 159 | return; 160 | } 161 | 162 | Quaternion targetRotation = Quaternion.LookRotation(movingDir, Vector3.up); 163 | var maxDegreesDelta = rotationSpeed * Time.deltaTime; 164 | rigidbody.rotation = Quaternion.RotateTowards(rigidbody.rotation, targetRotation, maxDegreesDelta); 165 | } 166 | 167 | public bool IsClockwise(Quaternion targetRotation) 168 | { 169 | return rigidbody.rotation.IsClockwise(targetRotation); 170 | } 171 | 172 | public bool IsLookingTarget(Quaternion targetRotation) 173 | { 174 | return rigidbody.rotation.IsSameOrientation(targetRotation); 175 | } 176 | 177 | public bool IsLookingTarget(Quaternion targetRotation, float angle) 178 | { 179 | return Quaternion.Angle(rigidbody.rotation, targetRotation) < angle; 180 | } 181 | 182 | public void Accelerate(Vector3 worldDir, float acceleration) 183 | { 184 | Vector3 accel = worldDir * acceleration; 185 | rigidbody.AddForce(accel, ForceMode.Acceleration); 186 | } 187 | 188 | public void Push(Vector3 worldDir, float speedChange) 189 | { 190 | rigidbody.AddForce(worldDir * speedChange, ForceMode.VelocityChange); 191 | } 192 | 193 | public void Brake(float multiplier) 194 | { 195 | var negVelocity = -RelVelocity * multiplier; 196 | negVelocity.y = 0f; 197 | rigidbody.AddForce(negVelocity, ForceMode.VelocityChange); 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /Player/PlayerActorAttack.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using UnityEngine; 3 | using Cinemachine; 4 | using FMODUnity; 5 | 6 | public interface IPlayerActorAttackVictim 7 | { 8 | void OnAttacked(IPlayerActorAttackInfo attackInfo); 9 | void OnHitstop(float duration); 10 | } 11 | 12 | public interface IPlayerActorAttackInfo 13 | { 14 | GameObject AttackerGO { get; } 15 | Vector3 AttackedFrom { get; } 16 | Vector3 WorldDirAttack { get; } 17 | float KnockbackSpeed { get; } 18 | int Damage { get; } 19 | } 20 | 21 | public class PlayerActorAttackInfo : IPlayerActorAttackInfo 22 | { 23 | public GameObject attackerGO; 24 | public Vector3 attackedFrom; 25 | public Vector3 worldDirAttack; 26 | public float knockbackSpeed; 27 | public int damage; 28 | 29 | GameObject IPlayerActorAttackInfo.AttackerGO => attackerGO; 30 | Vector3 IPlayerActorAttackInfo.AttackedFrom => attackedFrom; 31 | Vector3 IPlayerActorAttackInfo.WorldDirAttack => worldDirAttack; 32 | float IPlayerActorAttackInfo.KnockbackSpeed => knockbackSpeed; 33 | int IPlayerActorAttackInfo.Damage => damage; 34 | } 35 | 36 | public class PlayerActorAttack : MonoBehaviour, 37 | IMETPlayerSMBIdle, 38 | IMETPlayerSMBRoll, 39 | IMETPlayerSMBAttack, 40 | IMETPlayerAnimationEventProxyOnHit 41 | { 42 | [SerializeField] private MessageExchange messageExchange; 43 | [SerializeField] private Locomotor locomotor; 44 | [SerializeField] private WallChecker wallChecker; 45 | [SerializeField] private PlayerParam playerParam; 46 | [SerializeField] private PlayerControl playerControl; 47 | [SerializeField] private PlayerSignpost playerSignpost; 48 | [SerializeField] private PlayerAnimatorDriver playerAnimatorDriver; 49 | [SerializeField] private CinemachineImpulseSource impulseHit; 50 | [SerializeField] private StudioEventEmitter audioUnarmed; 51 | [SerializeField] private StudioEventEmitter audioSignpost; 52 | 53 | private const float angleDiffForAttackRotatingDirection = 90f; 54 | private const float impulsePerHit = 0.1f; 55 | private const float impulseMin = 0.2f; 56 | private const float impulseMax = 1.2f; 57 | 58 | private FSM fsm = new FSM(); 59 | private PlayerActorAttackInfo attackInfo = new PlayerActorAttackInfo(); 60 | private GameObject playerGO; 61 | private bool lastIsClockwise = false; 62 | 63 | // NOTE: See RefreshCurrentWorldDirAttack() for how these variables work 64 | private float sqrMagInputDirAttack; 65 | private Vector3 currentWorldDirAttack; 66 | 67 | private List currentVictims; 68 | 69 | void Awake() 70 | { 71 | playerGO = GetComponentInParent().gameObject; 72 | currentVictims = new List(); 73 | } 74 | 75 | void OnEnable() 76 | { 77 | messageExchange.Register(this); 78 | } 79 | 80 | void OnDisable() 81 | { 82 | messageExchange.Deregister(this); 83 | } 84 | 85 | void Start() 86 | { 87 | fsm.Init(this); 88 | } 89 | 90 | void FixedUpdate() 91 | { 92 | fsm.FixedUpdate(); 93 | } 94 | 95 | void Update() 96 | { 97 | fsm.Update(); 98 | } 99 | 100 | void OnDrawGizmosSelected() 101 | { 102 | playerParam.AttackBoxChecker.DrawGizmo(transform.localToWorldMatrix); 103 | } 104 | 105 | // IMETPlayerSMBIdle 106 | void IMETPlayerSMBIdle.OnEnterSolo() 107 | { 108 | fsm.TransitionTo(); 109 | } 110 | 111 | void IMETPlayerSMBIdle.OnExitSolo() 112 | { 113 | fsm.TransitionTo(); 114 | } 115 | 116 | // IMETPlayerSMBRoll 117 | void IMETPlayerSMBRoll.OnEnter() { } 118 | 119 | void IMETPlayerSMBRoll.OnEnterSolo() 120 | { 121 | fsm.TransitionTo(); 122 | } 123 | 124 | void IMETPlayerSMBRoll.OnExitSolo() 125 | { 126 | fsm.TransitionTo(); 127 | } 128 | 129 | void IMETPlayerSMBRoll.OnExit() { } 130 | 131 | // IMETPlayerSMBAttack 132 | void IMETPlayerSMBAttack.OnEnter() 133 | { 134 | fsm.TransitionTo(); 135 | } 136 | 137 | void IMETPlayerSMBAttack.OnEnterSolo() 138 | { 139 | fsm.TransitionTo(); 140 | } 141 | 142 | void IMETPlayerSMBAttack.OnExitSolo() 143 | { 144 | // NOTE: It's expected to already be in StateWait. 145 | // For consecutive punch attack, make it ready at this point. 146 | fsm.TransitionTo(); 147 | } 148 | 149 | void IMETPlayerSMBAttack.OnExit() { } 150 | 151 | // IMETPlayerAnimationEventProxyOnHit 152 | void IMETPlayerAnimationEventProxyOnHit.OnHit() 153 | { 154 | // NOTE: Put it to StateWait, in other words, stop rotating. 155 | fsm.TransitionTo(); 156 | 157 | var colliders = playerParam.AttackBoxChecker.Check(transform.position, transform.rotation); 158 | attackInfo.attackerGO = playerGO; 159 | attackInfo.damage = playerParam.AttackDamage; 160 | attackInfo.attackedFrom = transform.position; 161 | attackInfo.worldDirAttack = locomotor.WorldDir; 162 | attackInfo.knockbackSpeed = playerParam.AttakKnockbackSpeed; 163 | currentVictims.Clear(); 164 | foreach (Collider other in colliders) 165 | { 166 | var victim = other.GetComponent(); 167 | if (victim != null && !wallChecker.Check(other)) 168 | { 169 | victim.OnAttacked(attackInfo); 170 | currentVictims.Add(victim); 171 | } 172 | } 173 | 174 | if (currentVictims.Count > 0) 175 | { 176 | float force = Mathf.Clamp( 177 | impulsePerHit * currentVictims.Count, 178 | impulseMin, 179 | impulseMax); 180 | impulseHit.GenerateImpulseWithForce(force); 181 | playerSignpost.Claim(currentVictims.Count); 182 | } 183 | } 184 | 185 | void IMETPlayerAnimationEventProxyOnHit.OnImpact() 186 | { 187 | float duration = playerParam.AttackHitstopDuration; 188 | if (currentVictims.Count > 0) 189 | { 190 | playerAnimatorDriver.SetPause(duration); 191 | } 192 | foreach (var victim in currentVictims) 193 | { 194 | victim.OnHitstop(duration); 195 | } 196 | currentVictims.Clear(); 197 | } 198 | 199 | private bool RefreshCurrentWorldDirAttack() 200 | { 201 | // NOTE: Update the attacking direction when only the input magnitude is increasing; 202 | // There are two cases for this: 203 | // 1) R-stick on gamepad: 204 | // The input is continuous. 205 | // Player intention of attack direction would be only valid when 206 | // tilting the stick, in other words, when the magnitude is increasing 207 | // 2) Keyboard 208 | // For the keyboard input, it's intentionally not normalized, so that 209 | // the magnitude of digonal is greater than that of one direction, 210 | // so that the dignal input take priority. 211 | float currentSqrMagInputDirAttack = playerControl.SqrMagInputDirAttack; 212 | if (currentSqrMagInputDirAttack >= sqrMagInputDirAttack) 213 | { 214 | sqrMagInputDirAttack = currentSqrMagInputDirAttack; 215 | currentWorldDirAttack = playerControl.WorldDirAttack; 216 | return true; 217 | } 218 | return false; 219 | } 220 | 221 | class FSM : FiniteStateMachine { } 222 | class State : FiniteStateMachineState { } 223 | 224 | class StateWait : State { } 225 | 226 | class StateReady : State 227 | { 228 | public override void OnUpdate() 229 | { 230 | if (C.playerControl.InputDirAttack != Vector2.zero) 231 | { 232 | C.sqrMagInputDirAttack = 0f; 233 | C.currentWorldDirAttack = Vector3.zero; 234 | C.RefreshCurrentWorldDirAttack(); 235 | C.playerAnimatorDriver.TriggerAttack(); 236 | C.fsm.TransitionTo(); 237 | } 238 | } 239 | } 240 | 241 | // NOTE: This is the base class for PreAttacking and Attacking 242 | abstract class StateAttackingBase : State 243 | { 244 | protected Quaternion targetRotation; 245 | protected float rotationSpeed; 246 | 247 | public override void OnEnter() 248 | { 249 | C.RefreshCurrentWorldDirAttack(); 250 | Vector3 worldDir = C.currentWorldDirAttack; 251 | targetRotation = C.locomotor.RotationToward(worldDir); 252 | rotationSpeed = C.playerParam.AttackingRotationSpeed; 253 | OnEnterAfterInitRotation(); 254 | } 255 | 256 | protected abstract void OnEnterAfterInitRotation(); 257 | 258 | public sealed override void OnFixedUpdate() 259 | { 260 | C.locomotor.RotateTowardTarget(targetRotation, rotationSpeed); 261 | } 262 | } 263 | 264 | // NOTE: PreAttacking is expected to be running between "Enter ~ EnterSolo 265 | // This state is for a room to commit the actual attack direction while 266 | // giving some feedback of attacking by playing sound and rotationing to 267 | // the candidate direction; 268 | class StatePreAttacking : StateAttackingBase 269 | { 270 | public override void OnUpdate() 271 | { 272 | // NOTE: In this PreAttacking stage, 273 | // the target rotation should be kept updated. 274 | if (C.RefreshCurrentWorldDirAttack()) 275 | { 276 | targetRotation = C.locomotor.RotationToward(C.currentWorldDirAttack); 277 | } 278 | } 279 | 280 | protected override void OnEnterAfterInitRotation() 281 | { 282 | SetAttackRotation(); 283 | PlayAudio(); 284 | } 285 | 286 | private void SetAttackRotation() 287 | { 288 | bool isClockwise; 289 | if (C.locomotor.IsLookingTarget(targetRotation, angleDiffForAttackRotatingDirection)) 290 | { 291 | isClockwise = !C.lastIsClockwise; 292 | } 293 | else 294 | { 295 | isClockwise = C.locomotor.IsClockwise(targetRotation); 296 | } 297 | C.lastIsClockwise = isClockwise; 298 | C.playerAnimatorDriver.SetAttackRotationDir(isClockwise); 299 | } 300 | 301 | private void PlayAudio() 302 | { 303 | var audio = C.playerParam.IsHoldingSignpost ? C.audioSignpost : C.audioUnarmed; 304 | audio.Play(); 305 | } 306 | } 307 | 308 | // NOTE: From this point, attack direction is committed, 309 | // and push the character to the attack direction; 310 | class StateAttacking : StateAttackingBase 311 | { 312 | protected override void OnEnterAfterInitRotation() 313 | { 314 | ApplyMovement(C.currentWorldDirAttack); 315 | } 316 | 317 | private void ApplyMovement(Vector3 worldDir) 318 | { 319 | var lastSpeed = C.locomotor.Speed; 320 | C.locomotor.Brake(C.playerParam.AttackBrake); 321 | // NOTE: Keep momentum; 322 | var speed = C.playerParam.AttackPushSpeed + (lastSpeed * (1 - C.playerParam.AttackBrake)); 323 | C.locomotor.Push(worldDir, speed); 324 | } 325 | } 326 | } --------------------------------------------------------------------------------