using Embers.Actions;
using Embers.GameMechanics;
using K3;
using System;
using UnityEngine;
// stolen from Kromlech, and not very good.
namespace Embers.Character {
public enum MoveRegimes {
Free,
AnimatorControlled,
AxesToFacing,
AxesToFacingImmediate,
}
public interface IHasCharacterMotionState {
/// <summary> Velocity relative to facing</summary>
Vector2 RelativeVelocity { get; }
Vector2 RelativeDesiredVelocity { get; }
Vector3 ObservedVelocity { get; }
bool ContactWithGround { get; }
bool InCombatStance { get; }
Vector3 DesiredWorldspaceMotionAxis { get; }
event Action<float> JustHitGround;
MoveRegimes MoveRegime { get; }
// bool MovementHijackedByAnimator { get; }
bool IsRagdolling { get; }
bool IsSliding { get; }
bool IsSlipping { get; }
bool Autopilot { get; }
void SetRegime(MoveRegimes regime);
void SetRagdollActive(bool active);
void OverrideDesiredVelocity(Vector3 newDesiredV, float accel);
void ForceRotateTowards(Vector3 worldHeading);
Immersion ImmersionLevel { get; }
event ImmersionChangedDelegate ImmersionChanged;
public CollisionFlags CurrentCollisionFlags { get; }
(Vector3 bottom, Vector3 top) GetCharacterControllerCapsulePoints(Vector3? atPosition = null);
}
public enum Immersion {
NoImmersion,
WalkingOnPuddle,
WadingThroughFluid,
}
public delegate void ImmersionChangedDelegate(Immersion old, Immersion @new);
public class GameCharacter : MonoBehaviour, IHasCharacterMotionState, ICanGetTripped {
// CharacterController unityCharController;
// CombatStanceController combatStanceController;
// public CharacterState State { get; } = new CharacterState();
public Camera WorldCamera { get; set;}
public GameCharacterData Data { get; private set; }
#pragma warning disable 649
[SerializeField] private float walkSpeed;
[SerializeField] private float runSpeed;
[SerializeField] private float acceleration;
[SerializeField] float rotateAdjustSpeed;
[SerializeField] float combatModeSpeed;
[SerializeField] float combatModeAcceleration;
[SerializeField] SkeletalCharacter skeleton;
[SerializeField] AnimationCurve accelerationVsVelocity;
[Header("Slide")]
[SerializeField] float slideDecelerationBonus;
[SerializeField] float slideStartThreshold;
[SerializeField] float slideEndThreshold;
[SerializeField] float slideDotProductMinimum;
[Header("Water interactions")]
[SerializeField] float waterTesterShallow;
[SerializeField] float waterTesterWadingDepth;
[SerializeField] [Range(0.3f, 1f)]float wadeWalkSpeed;
[SerializeField] float wadingAccel;
[SerializeField] float wadingDecel;
[Header("Footing (slide when near edge)")]
[SerializeField] float footingRadius; // we raycast directly downward a small narrow capsule, fitting the character controller, but with this, smaller radius...
[SerializeField] float footingForwardBias; // ... and offset this much in the direction of the possible slide (can be 0)...
[SerializeField] float noFootingSlideForce; // ... and if nothing is below us, we apply this much "slipping" external force
#pragma warning restore 649
public bool ContactWithGround => Data.UnityCharacterController.isGrounded;
public bool IsRagdolling => movementAdaptsToRagdoll || skeleton.CurrentRagdollStrength > float.Epsilon;
public bool InCombatStance => Data.StanceController.InCombatStance;
public bool IsSliding { get; private set; }
/// <summary> The character is slipping due to no footing.</summary>
public bool IsSlipping { get; private set; }
Vector3 lockOnWorldDirection;
Vector3 desiredVelocity;
Vector3 currentVelocity;
bool previouslyInContact;
// [Header("Autopilot")]
private Vector3 autopilotTargetV;
private float autopilotAccel;
float currentReelFactor; // "Reeling" is temporary minor loss of velocity control
private CollisionFlags currentCollisionFlags;
//public bool MovementHijackedByAnimator { get; private set; }
public MoveRegimes MoveRegime { get; private set; }
public Immersion ImmersionLevel { get; private set; }
public Vector3 ObservedVelocity => averageVelocity.GetAverage();
public Vector2 RelativeVelocity { get; private set; }
public Vector2 RelativeDesiredVelocity { get; private set; }
private K3.Utility.RollingAverageVector3 averageVelocity = new K3.Utility.RollingAverageVector3(3);
public event Action<float> JustHitGround;
public event ImmersionChangedDelegate ImmersionChanged;
bool movementAdaptsToRagdoll;
Vector3 externalVelocityDelta;
ICharacterInputCollector inputCollector;
Collider[] physicsOverlapResults = new Collider[1];
/// <summary>Happens when the character is executing a targeted approach, instead of responding to input axes</summary>
public bool Autopilot { get; private set; }
public CollisionFlags CurrentCollisionFlags { get; private set; }
public Vector3 DesiredWorldspaceMotionAxis { get; private set; }
private void Awake() {
inputCollector = new CharacterInputCollector();
Data = new GameCharacterData(this, gameObject);
foreach (var c in GetComponentsInChildren<IHasCharacterReference>())
c.Character = Data;
if (WorldCamera == null) WorldCamera = EMB.SceneObjects.MainGameCamera;
}
internal void ApplyExternalForce(Vector3 f) {
externalVelocityDelta += f;
}
private void Update() {
if (Data.UnityCharacterController == null) return;
Data.Inputs = inputCollector.CollectInputs();
if (Input.GetKeyDown(KeyCode.R) && Input.GetKey(KeyCode.LeftControl)) {
SetRagdollActive(!movementAdaptsToRagdoll);
var rb = skeleton.RagdollPelvis.GetComponent<Rigidbody>();
rb.velocity = rb.velocity / 2 + new Vector3(4f, -5f, 0f);
}
Data.State.Process();
Data.Actor.DoInteractionEvaluation();
bool eatInputs = EMB.GetModule<Modules.InventoryInteractionsModule>().HandleInputs(Data);
if (eatInputs) inputCollector.InvalidateInputs();
if (!eatInputs) {
EMB.GetModule<Modules.CombatModule>().ProcessAttacks(Data);
EMB.GetModule<Modules.InteractionsModule>().ProcessInteractions(Data);
EMB.GetModule<Modules.GeneralCharacterActionsModule>().TryConvertInputsIntoActions(Data);
}
if (movementAdaptsToRagdoll) {
UpdateRagdollMovement();
} else {
UpdateControlledMovement();
}
}
public void SetRagdollActive(bool active) {
movementAdaptsToRagdoll = active;
skeleton.FullRagdollMode = movementAdaptsToRagdoll;
if (movementAdaptsToRagdoll) {
DropAllHeldItems();
skeleton.StartRagdoll(ObservedVelocity, 0.3f);
Data.UnityCharacterController.enabled = false;
} else {
Data.UnityCharacterController.enabled = true;
}
}
private void UpdateRagdollMovement() {
if (skeleton.IsRagdollDetached) {
transform.position = skeleton.RagdollCenter;
var desiredForward = -skeleton.RagdollPelvis.right;
desiredForward = Vector3.ProjectOnPlane(desiredForward, Vector3.up);
transform.rotation = Quaternion.LookRotation(desiredForward, Vector3.up);
}
}
private void FixedUpdate() {
ProcessImmersion();
UpdateVelocity();
ProcessFooting();
// you are expected to set the flag manually every tick if you want the behaviour to be continuous
Autopilot = false;
ProcessContactWithGround();
previouslyInContact = ContactWithGround;
}
private void ProcessContactWithGround() {
var velocityY = ObservedVelocity.y;
if (ContactWithGround != previouslyInContact) {
if (ContactWithGround) {
if (velocityY < -0.0001f) {
JustHitGround?.Invoke(velocityY);
ProcessJustHitGround(velocityY);
}
}
}
}
void ProcessFooting() {
IsSlipping = false;
// return;
bool canSlip = ContactWithGround && (MoveRegime != MoveRegimes.AnimatorControlled);
if (canSlip) {
//var slipDirection = ObservedVelocity;
//slipDirection = Vector3.ProjectOnPlane(slipDirection, Vector3.up);
//slipDirection.Normalize();
if (ShouldSlipOverEdge()) {
var slipDirection = GetSlipDirection();
if (slipDirection.HasValue) {
ApplyExternalForce(slipDirection.Value * noFootingSlideForce * Time.fixedDeltaTime);
}
}
}
}
private Vector3? GetSlipDirection() {
const int ANGLE_SAMPLES = 8;
var r = new Ray(transform.position, Vector3.down);
var resultant = Vector3.zero;
var resultantFound = false;
for (var i = 0; i < ANGLE_SAMPLES; i++) {
var angle = 360 * i / ANGLE_SAMPLES;
r.origin = transform.TransformPoint( Mathf.Cos(Mathf.Deg2Rad * angle), 0, Mathf.Sin(Mathf.Deg2Rad * angle)) + Vector3.up * 0.2f;
if (Physics.Raycast(r, 0.3f, 1 << 6)) {
resultant += transform.position - r.origin;
resultantFound = true;
}
}
if (resultantFound) return resultant;
return default;
}
private bool ShouldSlipOverEdge() {
var ray = new Ray(transform.position + transform.forward * footingForwardBias + Vector3.up * footingRadius, -Vector3.up);
if (Physics.SphereCast(ray, footingRadius, out var hitinfo, 0.1f, 1 << 6)) return false;
return true;
}
private void ProcessImmersion() {
var old = ImmersionLevel;
var level = Immersion.NoImmersion;
var waterTestRadius = Data.UnityCharacterController.radius;
if (Physics.OverlapSphereNonAlloc(transform.position + transform.up * waterTesterWadingDepth, waterTestRadius, physicsOverlapResults, 1 << 4, QueryTriggerInteraction.Collide) > 0) {
level = Immersion.WadingThroughFluid;
} else if (Physics.OverlapSphereNonAlloc(transform.position + transform.up * waterTesterShallow, waterTestRadius, physicsOverlapResults, 1 << 4, QueryTriggerInteraction.Collide) > 0) {
level = Immersion.WalkingOnPuddle;
}
ImmersionLevel = level;
if (ImmersionLevel != old) ImmersionChanged?.Invoke(old, ImmersionLevel);
}
/// <summary>There is no ragdoll</summary>
private void UpdateControlledMovement() {
if (Autopilot) {
AutopilotControls();
} else {
HackAndSlashControls(Data.Inputs);
}
}
private void ProcessJustHitGround(float vY) {
var ferocity = vY * vY;
if (ferocity > 0.1f) {
currentVelocity.x /= (1f + ferocity * 0.3f);
currentVelocity.z /= (1f + ferocity * 0.3f);
currentReelFactor = ferocity.Map(0.1f, 10f, 0.1f, 1.2f, true);
// Debug.Log($"hit ground, ferocity = {ferocity:F}, reel = {currentReelFactor:F2}s");
}
}
private void AutopilotControls() {
var maxMoveSpeedInThisRegime = walkSpeed;
if (ImmersionLevel == Immersion.WadingThroughFluid) maxMoveSpeedInThisRegime *= wadeWalkSpeed;
var planarV = autopilotTargetV;
planarV.y = 0;
if (autopilotTargetV.magnitude > maxMoveSpeedInThisRegime)
autopilotTargetV = autopilotTargetV.normalized * maxMoveSpeedInThisRegime;
desiredVelocity = autopilotTargetV;
RotateToFaceHeading(desiredVelocity);
currentCollisionFlags = Data.UnityCharacterController.Move(currentVelocity * Time.deltaTime);
}
private void HackAndSlashControls(CharacterInputState inputs) {
if (!Data.UnityCharacterController.enabled) return;
//if (Data.Inputs.defend)
// Data.StanceController.InCombatStance = !InCombatStance;
var maxMoveSpeedInThisRegime = walkSpeed;
if (inputs.atlethics.Holding()) maxMoveSpeedInThisRegime = runSpeed;
if (InCombatStance) maxMoveSpeedInThisRegime = combatModeSpeed;
if (ImmersionLevel == Immersion.WadingThroughFluid) maxMoveSpeedInThisRegime = wadeWalkSpeed;
if (currentReelFactor > 0f) { maxMoveSpeedInThisRegime = 0f; currentReelFactor -= Time.deltaTime; }
// if (MovementSpeedCap.HasValue && maxMoveSpeedInThisRegime > MovementSpeedCap.Value) maxMoveSpeedInThisRegime = MovementSpeedCap.Value;
// the good news is that the principle for movement is the same regardless of
// combat mode or not.
var worldCamHorRot = GetWorldCameraHorizontalRotation();
var inputAxesRelative = new Vector3(inputs.moveAxis.vector.x, 0, inputs.moveAxis.vector.y);
var m = inputAxesRelative.magnitude;
if (m > 1f) inputAxesRelative.Normalize();
const float DoNotMoveSlowerThanThisIfMovingAtAll = 0.4f;
if (m < DoNotMoveSlowerThanThisIfMovingAtAll) inputAxesRelative = inputAxesRelative.normalized * DoNotMoveSlowerThanThisIfMovingAtAll;
DesiredWorldspaceMotionAxis = worldCamHorRot * inputAxesRelative;
desiredVelocity = DesiredWorldspaceMotionAxis * maxMoveSpeedInThisRegime;
var planarWorldSpeed = Data.UnityCharacterController.velocity;
planarWorldSpeed.y = 0f;
HandleRotationBasedOnMoveRegime(planarWorldSpeed);
CurrentCollisionFlags = Data.UnityCharacterController.Move(currentVelocity * Time.deltaTime);
}
private void HandleRotationBasedOnMoveRegime(Vector3 planarWorldSpeed) {
switch (MoveRegime) {
case MoveRegimes.Free:
if (InCombatStance) {
lockOnWorldDirection = Data.StanceController.AbsoluteDirectionToTarget;
RotateToFaceHeading(lockOnWorldDirection);
} else {
if (planarWorldSpeed.sqrMagnitude >= 0.02f && !IsSliding)
RotateToFaceHeading(planarWorldSpeed.normalized);
}
break;
case MoveRegimes.AnimatorControlled:
break;
case MoveRegimes.AxesToFacing:
case MoveRegimes.AxesToFacingImmediate:
if (DesiredWorldspaceMotionAxis.sqrMagnitude > 0.1f) {
RotateToFaceHeading(DesiredWorldspaceMotionAxis.normalized, MoveRegime == MoveRegimes.AxesToFacingImmediate);
}
break;
default:
throw new NotImplementedException("Unhandled!");
}
}
public void ForceRotateTowards(Vector3 worldHeading) {
RotateToFaceHeading(worldHeading, false);
}
private void RotateToFaceHeading(Vector3 worldheading, bool immediate = false) {
if (worldheading.sqrMagnitude < 0.01f) return;
worldheading = Vector3.ProjectOnPlane(worldheading, Vector3.up);
worldheading.Normalize();
Quaternion targetRot = Quaternion.LookRotation(worldheading, Vector3.up);
if (immediate) {
transform.rotation = targetRot;
} else {
Quaternion currentRot = transform.rotation;
var angle = Quaternion.Angle(currentRot, targetRot);
if (angle > float.Epsilon) {
transform.rotation = Quaternion.Slerp(currentRot, targetRot, rotateAdjustSpeed * Time.deltaTime / angle);
}
}
}
public void OverrideDesiredVelocity(Vector3 target, float accel) {
Autopilot = true;
autopilotTargetV = target;
autopilotAccel = accel;
//UpdateVelocityTowardsDesiredVelocity(target, accel);
//if (!ContactWithGround) currentVelocity += Physics.gravity * Time.fixedDeltaTime;
}
private void UpdateVelocity() {
if (Data.UnityCharacterController.enabled) {
var effectiveAccelMagnitude = CalculateCurrentAcceleration();
bool moveHijacked = MoveRegime != MoveRegimes.Free;
if (moveHijacked) {
if (ContactWithGround) {
effectiveAccelMagnitude = acceleration * 2f;
UpdateVelocityTowardsDesiredVelocity(Vector3.zero, effectiveAccelMagnitude);
}
} else {
currentVelocity = Data.UnityCharacterController.velocity;
if (ContactWithGround) {
if (IsSliding) {
UpdateVelocityTowardsDesiredVelocity(Vector3.zero, effectiveAccelMagnitude, effectiveAccelMagnitude + slideDecelerationBonus); // ideally, dependent on the ground
} else {
float decelMagnitude = effectiveAccelMagnitude;
if (ImmersionLevel > Immersion.WalkingOnPuddle) decelMagnitude = wadingDecel;
UpdateVelocityTowardsDesiredVelocity(desiredVelocity, effectiveAccelMagnitude, decelMagnitude);
}
} else {
currentVelocity += Physics.gravity * Time.fixedDeltaTime; // there used to be a multiplier here
}
}
} else {
if (movementAdaptsToRagdoll) {
// this.ObservedAbsoluteVelocity = this.currentPhysicsBasedVelocity
this.currentVelocity = skeleton.RagdollVelocity;
this.desiredVelocity = Vector3.zero;
} else {
this.currentVelocity = Vector3.zero;
this.desiredVelocity = Vector3.zero;
}
}
currentVelocity += externalVelocityDelta;
externalVelocityDelta = default;
// previouslyInContact = Data.UnityCharacterController.isGrounded;
averageVelocity.Add(currentVelocity);
var relativeV = transform.InverseTransformDirection(ObservedVelocity);
RelativeVelocity = new Vector2(relativeV.x, relativeV.z);
relativeV = transform.InverseTransformDirection(desiredVelocity);
RelativeDesiredVelocity = new Vector2(relativeV.x, relativeV.z);
UpdateSlide();
if (ContactWithGround) currentVelocity.y = -1f; // stick to ground force
}
private float CalculateCurrentAcceleration() {
var velocityNormalized = RelativeVelocity.magnitude / runSpeed;
var accelMultiplierBasedOnVelocity = this.accelerationVsVelocity.Evaluate(velocityNormalized);
var effectiveAccel = acceleration * accelMultiplierBasedOnVelocity;
if (this.InCombatStance) effectiveAccel = combatModeAcceleration;
if (this.ImmersionLevel == Immersion.WadingThroughFluid) effectiveAccel = wadingAccel;
return effectiveAccel;
}
private void UpdateSlide() {
IsSliding = false;
if (!ContactWithGround) return;
var v = ObservedVelocity.magnitude;
var threshold = IsSliding ? slideEndThreshold : slideStartThreshold;
if (RelativeDesiredVelocity.sqrMagnitude > 0.1f && v > threshold) {
var dot = Vector3.Dot(RelativeDesiredVelocity.normalized, new Vector2(0, 1));
// make sure there is no slide when rapidly changing direction at low speeds:
var effectiveSlideDotProductMinimum = slideDotProductMinimum * v.Map(walkSpeed, runSpeed, 0.1f, 1f, true);
IsSliding = dot < effectiveSlideDotProductMinimum; // very naive formula but okay
}
}
private void UpdateVelocityTowardsDesiredVelocity(Vector3 targetV, float accel, float? decel = null) {
if (!ContactWithGround) throw new System.Exception("Do not call this unless we are grounded!");
if (!decel.HasValue) decel = accel;
var planarV = Vector3.ProjectOnPlane(currentVelocity, Vector3.up);
var decellerating = (targetV.magnitude < planarV.magnitude * 0.8f);
planarV = Vector3.MoveTowards(planarV, targetV, Time.fixedDeltaTime * (decellerating ? decel.Value : accel)); // this can also result in antigravity lol
currentVelocity = planarV;
}
private Quaternion GetWorldCameraHorizontalRotation() {
var forward = WorldCamera.transform.forward;
forward.y = 0;
forward.Normalize();
var rotation = Quaternion.LookRotation(forward, Vector3.up);
return rotation;
}
void OnControllerColliderHit(ControllerColliderHit hit) {
Rigidbody body = hit.collider.attachedRigidbody;
if (hit.collider.gameObject.layer == 6)
TryRagdollOnWallSlam(hit.normal, hit.point);
//dont move the rigidbody if the character is on top of it
if (currentCollisionFlags == CollisionFlags.Below) return;
if (body == null || body.isKinematic) return;
body.AddForceAtPosition(Data.UnityCharacterController.velocity * 0.1f, hit.point, ForceMode.Impulse);
}
private void TryRagdollOnWallSlam(Vector3 normal, Vector3 hitPoint) {
if (!IsRagdolling) {
var dot = Vector3.Dot(normal, ObservedVelocity.normalized);
var vertical = normal.y > 0.8f;
var ferocity = -dot * ObservedVelocity.sqrMagnitude;
if (!vertical) ferocity = -dot * RelativeVelocity.sqrMagnitude;
var relativeH = transform.InverseTransformPoint(hitPoint).y;
// arbitrary criteria
if (ferocity > runSpeed * 0.75f && relativeH > 0.45f && !vertical) {
SetRagdollActive(true);
} else if (ferocity > 8f && vertical) {
SetRagdollActive(true);
}
}
}
public void SetRegime(MoveRegimes regime) {
this.MoveRegime = regime;
}
public void TryTrip() {
bool shouldTrip = this.ContactWithGround
&& !this.movementAdaptsToRagdoll
&& !this.InCombatStance
&& this.ObservedVelocity.magnitude > runSpeed * 0.9f;
if (shouldTrip) {
SetRagdollActive(true);
}
}
private void DropAllHeldItems() {
var wielder = GetComponentInChildren<CombatSystem.IWielder>();
foreach (var slot in new[] { Items.ItemWieldSlots.Primary, Items.ItemWieldSlots.Offhand }) {
var item = wielder.GetExistingItemInSlot(slot);
if (item != null) {
item.Data.DropInPlace();
wielder.HandleWieldingStopped(item.InSlot, item.Subindex);
}
}
}
(Vector3 bottom, Vector3 top) IHasCharacterMotionState.GetCharacterControllerCapsulePoints(Vector3? atPosition) {
var ucc = Data.UnityCharacterController;
if (!atPosition.HasValue) atPosition = transform.position;
var p0 = atPosition.Value + Vector3.up * (ucc.skinWidth + ucc.radius);
return (p0, p0 + Vector3.up * (ucc.height + ucc.radius));
}
private void OnTriggerEnter(Collider other) {
var trigger = other.GetComponent<IInteractiveTrigger>();
if (trigger != null) trigger.StartInteraction(this);
}
private void OnTriggerStay(Collider other) {
var trigger = other.GetComponent<IInteractiveTrigger>();
if (trigger != null) trigger.ContinueInteraction(this);
}
private void OnTriggerExit(Collider other) {
var trigger = other.GetComponent<IInteractiveTrigger>();
if (trigger != null) trigger.EndInteraction(this);
}
}
}