Initial commit: Tiny Tackle Heroes Unity project

This commit is contained in:
2026-09-15 17:37:42 +09:00
commit 591fa9a826
1623 changed files with 182420 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 673c2c567619408db1bf9b1e3e5d07f8
@@ -0,0 +1,278 @@
using BumpCombat.Combat;
using UnityEngine;
namespace BumpCombat.Player
{
public enum ActiveArtifactEffect
{
Dash,
Pulse,
Phoenix,
Cyclone,
ThunderCrash,
ChainLightning,
}
[CreateAssetMenu(
fileName = "ActiveArtifact",
menuName = "BumpCombat/Active Artifact")]
public sealed class ActiveArtifactDefinition : ScriptableObject
{
[SerializeField] private string artifactId;
[SerializeField] private string displayName;
[SerializeField] private string placeholderSymbol;
[SerializeField] private ActiveArtifactEffect effect;
[SerializeField] private DamageTag damageTag = DamageTag.Collision;
[SerializeField] private Color iconColor = Color.white;
[SerializeField] private Color effectColor = Color.white;
[SerializeField, Min(0f)] private float normalGaugeCost = 25f;
[SerializeField, Min(0f)] private float chargedGaugeCost = 60f;
[SerializeField, Min(0.01f)] private float chargeDuration = 0.8f;
[SerializeField, Min(0f)] private float normalDamage = 15f;
[SerializeField, Min(0f)] private float chargedDamage = 30f;
[SerializeField, Min(0f)] private float normalRange = 1.25f;
[SerializeField, Min(0f)] private float chargedRange = 2f;
[SerializeField, Min(0f)] private float normalKnockback = 1.5f;
[SerializeField, Min(0f)] private float chargedKnockback = 3f;
[SerializeField, Min(0f)] private float normalWidth = 0.5f;
[SerializeField, Min(0f)] private float chargedWidth = 1f;
[SerializeField, Min(0f)] private float normalMoveDistance;
[SerializeField, Min(0f)] private float chargedMoveDistance;
[SerializeField, Min(0f)] private float normalDuration = 0.15f;
[SerializeField, Min(0f)] private float chargedDuration = 0.5f;
[SerializeField, Min(0.01f)] private float hitInterval = 0.15f;
[SerializeField, Min(1)] private int normalMaxTargets = 1;
[SerializeField, Min(1)] private int chargedMaxTargets = 12;
[SerializeField, Min(1)] private int chargedMaxHitsPerTarget = 1;
[SerializeField, Min(0f)] private float chainRange = 2.5f;
[SerializeField, Min(1)] private int normalChainDepth = 4;
[SerializeField, Min(1)] private int chargedChainDepth = 4;
[SerializeField, Min(1)] private int normalBranchCount = 1;
[SerializeField, Min(1)] private int chargedBranchCount = 2;
[SerializeField, Range(0f, 1f)] private float damageMultiplierPerChain = 0.9f;
[SerializeField, Min(0f)] private float normalInvulnerabilityDuration;
[SerializeField, Min(0f)] private float chargedInvulnerabilityDuration;
[SerializeField, Range(0f, 1f)] private float normalDamageReduction;
[SerializeField, Range(0f, 1f)] private float chargedDamageReduction;
[SerializeField, Min(0f)] private float normalBuffDuration;
[SerializeField, Min(0f)] private float chargedBuffDuration;
[SerializeField, Range(0f, 1f)] private float normalMoveSpeedIncrease;
[SerializeField, Range(0f, 1f)] private float chargedMoveSpeedIncrease;
[SerializeField, Min(0f)] private float normalIgniteDuration;
[SerializeField, Min(0f)] private float chargedIgniteDuration;
[SerializeField, Min(0f)] private float normalIgniteInterval = 0.5f;
[SerializeField, Min(0f)] private float chargedIgniteInterval = 0.5f;
[SerializeField, Min(0f)] private float normalIgniteTickDamage;
[SerializeField, Min(0f)] private float chargedIgniteTickDamage;
[SerializeField, Range(0f, 1f)] private float normalVulnerabilityIncrease;
[SerializeField, Range(0f, 1f)] private float chargedVulnerabilityIncrease;
[SerializeField, Min(0f)] private float normalVulnerabilityDuration;
[SerializeField, Min(0f)] private float chargedVulnerabilityDuration;
[SerializeField, Range(0f, 1f)] private float normalShockChance;
[SerializeField, Range(0f, 1f)] private float chargedShockChance;
[SerializeField, Range(0f, 1f)] private float normalShockIncrease;
[SerializeField, Range(0f, 1f)] private float chargedShockIncrease;
[SerializeField, Min(0f)] private float normalShockDuration;
[SerializeField, Min(0f)] private float chargedShockDuration;
public string ArtifactId => artifactId;
public string DisplayName => displayName;
public string PlaceholderSymbol => placeholderSymbol;
public ActiveArtifactEffect Effect => effect;
public ArtifactColor ArtifactColor => GetArtifactColor(effect);
public DamageTag DamageTag => damageTag;
public Color IconColor => iconColor;
public Color EffectColor => effectColor;
public float NormalGaugeCost => normalGaugeCost;
public float ChargedGaugeCost => chargedGaugeCost;
public float ChargeDuration => chargeDuration;
public float NormalDamage => normalDamage;
public float ChargedDamage => chargedDamage;
public float NormalRange => normalRange;
public float ChargedRange => chargedRange;
public float NormalKnockback => normalKnockback;
public float ChargedKnockback => chargedKnockback;
public float NormalWidth => normalWidth;
public float ChargedWidth => chargedWidth;
public float NormalMoveDistance => normalMoveDistance;
public float ChargedMoveDistance => chargedMoveDistance;
public float NormalDuration => normalDuration;
public float ChargedDuration => chargedDuration;
public float HitInterval => hitInterval;
public int NormalMaxTargets => normalMaxTargets;
public int ChargedMaxTargets => chargedMaxTargets;
public int ChargedMaxHitsPerTarget => chargedMaxHitsPerTarget;
public float ChainRange => chainRange;
public int NormalChainDepth => normalChainDepth;
public int ChargedChainDepth => chargedChainDepth;
public int NormalBranchCount => normalBranchCount;
public int ChargedBranchCount => chargedBranchCount;
public float DamageMultiplierPerChain => damageMultiplierPerChain;
public float NormalInvulnerabilityDuration => normalInvulnerabilityDuration;
public float ChargedInvulnerabilityDuration => chargedInvulnerabilityDuration;
public float NormalDamageReduction => normalDamageReduction;
public float ChargedDamageReduction => chargedDamageReduction;
public float NormalBuffDuration => normalBuffDuration;
public float ChargedBuffDuration => chargedBuffDuration;
public float NormalMoveSpeedIncrease => normalMoveSpeedIncrease;
public float ChargedMoveSpeedIncrease => chargedMoveSpeedIncrease;
public float NormalIgniteDuration => normalIgniteDuration;
public float ChargedIgniteDuration => chargedIgniteDuration;
public float NormalIgniteInterval => normalIgniteInterval;
public float ChargedIgniteInterval => chargedIgniteInterval;
public float NormalIgniteTickDamage => normalIgniteTickDamage;
public float ChargedIgniteTickDamage => chargedIgniteTickDamage;
public float NormalVulnerabilityIncrease => normalVulnerabilityIncrease;
public float ChargedVulnerabilityIncrease => chargedVulnerabilityIncrease;
public float NormalVulnerabilityDuration => normalVulnerabilityDuration;
public float ChargedVulnerabilityDuration => chargedVulnerabilityDuration;
public float NormalShockChance => normalShockChance;
public float ChargedShockChance => chargedShockChance;
public float NormalShockIncrease => normalShockIncrease;
public float ChargedShockIncrease => chargedShockIncrease;
public float NormalShockDuration => normalShockDuration;
public float ChargedShockDuration => chargedShockDuration;
public static ArtifactColor GetArtifactColor(ActiveArtifactEffect effectKind)
{
return effectKind switch
{
ActiveArtifactEffect.Dash
or ActiveArtifactEffect.Cyclone => ArtifactColor.Green,
ActiveArtifactEffect.Pulse
or ActiveArtifactEffect.Phoenix => ArtifactColor.Red,
ActiveArtifactEffect.ThunderCrash
or ActiveArtifactEffect.ChainLightning => ArtifactColor.Blue,
_ => ArtifactColor.Green,
};
}
public static Color32 GetArtifactPaletteColor(ArtifactColor color)
{
return color switch
{
ArtifactColor.Green => new Color32(103, 231, 178, 255),
ArtifactColor.Red => new Color32(255, 100, 100, 255),
_ => new Color32(104, 179, 255, 255),
};
}
#if UNITY_EDITOR
public void Configure(
string id,
string name,
string symbol,
ActiveArtifactEffect effectKind,
DamageTag tag,
Color icon,
Color effectTint,
float normalCost,
float chargedCost,
float chargeTime,
float normalAttackDamage,
float chargedAttackDamage,
float normalEffectRange,
float chargedEffectRange,
float normalKnockbackForce,
float chargedKnockbackForce,
float normalEffectWidth = 0.5f,
float chargedEffectWidth = 1f,
float normalMovementDistance = 0f,
float chargedMovementDistance = 0f,
float normalEffectDuration = 0.15f,
float chargedEffectDuration = 0.5f,
float multiHitInterval = 0.15f,
int normalTargetLimit = 1,
int chargedTargetLimit = 12,
float chainHopRange = 2.5f,
int normalMaximumChainDepth = 4,
int chargedMaximumChainDepth = 4,
int normalBranches = 1,
int chargedBranches = 2,
float chainDamageMultiplier = 0.9f,
float normalInvulnerability = 0f,
float chargedInvulnerability = 0f,
int chargedHitLimitPerTarget = 1,
float normalIncomingDamageReduction = 0f,
float chargedIncomingDamageReduction = 0f,
float normalPlayerBuffDuration = 0f,
float chargedPlayerBuffDuration = 0f,
float normalMovementSpeedIncrease = 0f,
float chargedMovementSpeedIncrease = 0f,
float normalBurnDuration = 0f,
float chargedBurnDuration = 0f,
float normalBurnInterval = 0.5f,
float chargedBurnInterval = 0.5f,
float normalBurnTickDamage = 0f,
float chargedBurnTickDamage = 0f,
float normalBumpVulnerabilityIncrease = 0f,
float chargedBumpVulnerabilityIncrease = 0f,
float normalBumpVulnerabilityDuration = 0f,
float chargedBumpVulnerabilityDuration = 0f,
float normalElectrocuteChance = 0f,
float chargedElectrocuteChance = 0f,
float normalElectrocuteIncrease = 0f,
float chargedElectrocuteIncrease = 0f,
float normalElectrocuteDuration = 0f,
float chargedElectrocuteDuration = 0f)
{
artifactId = id;
displayName = name;
placeholderSymbol = symbol;
effect = effectKind;
damageTag = tag;
iconColor = icon;
effectColor = effectTint;
normalGaugeCost = normalCost;
chargedGaugeCost = chargedCost;
chargeDuration = chargeTime;
normalDamage = normalAttackDamage;
chargedDamage = chargedAttackDamage;
normalRange = normalEffectRange;
chargedRange = chargedEffectRange;
normalKnockback = normalKnockbackForce;
chargedKnockback = chargedKnockbackForce;
normalWidth = normalEffectWidth;
chargedWidth = chargedEffectWidth;
normalMoveDistance = normalMovementDistance;
chargedMoveDistance = chargedMovementDistance;
normalDuration = normalEffectDuration;
chargedDuration = chargedEffectDuration;
hitInterval = multiHitInterval;
normalMaxTargets = normalTargetLimit;
chargedMaxTargets = chargedTargetLimit;
chainRange = chainHopRange;
normalChainDepth = normalMaximumChainDepth;
chargedChainDepth = chargedMaximumChainDepth;
normalBranchCount = normalBranches;
chargedBranchCount = chargedBranches;
damageMultiplierPerChain = chainDamageMultiplier;
normalInvulnerabilityDuration = normalInvulnerability;
chargedInvulnerabilityDuration = chargedInvulnerability;
chargedMaxHitsPerTarget = chargedHitLimitPerTarget;
normalDamageReduction = normalIncomingDamageReduction;
chargedDamageReduction = chargedIncomingDamageReduction;
normalBuffDuration = normalPlayerBuffDuration;
chargedBuffDuration = chargedPlayerBuffDuration;
normalMoveSpeedIncrease = normalMovementSpeedIncrease;
chargedMoveSpeedIncrease = chargedMovementSpeedIncrease;
normalIgniteDuration = normalBurnDuration;
chargedIgniteDuration = chargedBurnDuration;
normalIgniteInterval = normalBurnInterval;
chargedIgniteInterval = chargedBurnInterval;
normalIgniteTickDamage = normalBurnTickDamage;
chargedIgniteTickDamage = chargedBurnTickDamage;
normalVulnerabilityIncrease = normalBumpVulnerabilityIncrease;
chargedVulnerabilityIncrease = chargedBumpVulnerabilityIncrease;
normalVulnerabilityDuration = normalBumpVulnerabilityDuration;
chargedVulnerabilityDuration = chargedBumpVulnerabilityDuration;
normalShockChance = normalElectrocuteChance;
chargedShockChance = chargedElectrocuteChance;
normalShockIncrease = normalElectrocuteIncrease;
chargedShockIncrease = chargedElectrocuteIncrease;
normalShockDuration = normalElectrocuteDuration;
chargedShockDuration = chargedElectrocuteDuration;
}
#endif
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 01e09802b0334be29db2e63037c28782
@@ -0,0 +1,128 @@
using System.Collections;
using BumpCombat.Combat;
using BumpCombat.Constants;
using UnityEngine;
namespace BumpCombat.Player
{
[RequireComponent(typeof(Rigidbody2D), typeof(PlayerController))]
[RequireComponent(typeof(PlayerStats))]
public sealed class DashController : MonoBehaviour
{
private float dashDuration => GameplayConstants.Current.Artifacts.DashDuration;
private float strongDashDuration => GameplayConstants.Current.Artifacts.ChargedDashDuration;
private Rigidbody2D body;
private PlayerController playerController;
private BumpCombatResolver combatResolver;
private Animator animator;
private CombatFeedback combatFeedback;
private static readonly int IsDashingParameter = Animator.StringToHash("IsDashing");
public bool IsDashing { get; private set; }
public bool IsStrongDashing { get; private set; }
private void Awake()
{
body = GetComponent<Rigidbody2D>();
playerController = GetComponent<PlayerController>();
combatResolver = GetComponent<BumpCombatResolver>();
animator = GetComponent<Animator>();
combatFeedback = GetComponent<CombatFeedback>();
if (combatFeedback == null)
{
combatFeedback = gameObject.AddComponent<CombatFeedback>();
}
}
public bool TryArtifactDash(
bool isCharged,
ActiveArtifactDefinition definition,
int castIdentity = 0)
{
if (definition == null
|| IsDashing
|| playerController == null
|| playerController.IsDamageKnockbackActive
|| playerController.IsHurtMovementLocked)
{
return false;
}
StartCoroutine(DashRoutine(
GetDashDirection(),
isCharged,
definition,
castIdentity));
return true;
}
private Vector2 GetDashDirection()
{
return playerController.MoveDirection.sqrMagnitude > 0f
? playerController.MoveDirection.normalized
: playerController.FacingDirection.normalized;
}
private IEnumerator DashRoutine(
Vector2 direction,
bool isStrongDash,
ActiveArtifactDefinition artifactDefinition,
int castIdentity)
{
IsDashing = true;
IsStrongDashing = isStrongDash;
animator.SetBool(IsDashingParameter, true);
float distance = isStrongDash
? artifactDefinition.ChargedRange
: artifactDefinition.NormalRange;
float duration = isStrongDash
? strongDashDuration
: dashDuration;
Vector2 start = body.position;
Vector2 end = playerController.ClampToArena(start + direction * distance);
combatResolver.BeginDashPathContactSession();
float elapsed = 0f;
while (elapsed < duration)
{
yield return new WaitForFixedUpdate();
combatFeedback.EmitDashAfterimage(body.position, isStrongDash);
Vector2 segmentStart = body.position;
elapsed += Time.fixedDeltaTime;
Vector2 segmentEnd = Vector2.Lerp(
start,
end,
Mathf.Clamp01(elapsed / duration));
Vector2 segment = segmentEnd - segmentStart;
if (segment.sqrMagnitude > 0.000001f)
{
combatResolver.ResolveDashPathSegment(
segmentStart,
segment.normalized,
segment.magnitude,
isStrongDash,
isStrongDash
? artifactDefinition.ChargedDamage
: artifactDefinition.NormalDamage,
isStrongDash
? artifactDefinition.ChargedKnockback
: artifactDefinition.NormalKnockback,
artifactDefinition.ArtifactId,
artifactDefinition.DamageTag,
artifactDefinition.ArtifactColor,
castIdentity,
artifactDefinition.Effect);
}
body.MovePosition(segmentEnd);
}
body.position = end;
IsDashing = false;
IsStrongDashing = false;
animator.SetBool(IsDashingParameter, false);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 77ef8e2be8dd8104ba9fe7c7d8113073
@@ -0,0 +1,342 @@
using UnityEngine;
using UnityEngine.InputSystem;
using BumpCombat.Core;
using BumpCombat.Constants;
namespace BumpCombat.Player
{
[RequireComponent(typeof(Rigidbody2D), typeof(SpriteRenderer), typeof(Animator))]
[RequireComponent(typeof(PlayerStats))]
public sealed class PlayerController : MonoBehaviour
{
private static readonly int IsMovingParameter = Animator.StringToHash("IsMoving");
private static readonly int BumpAttackState =
Animator.StringToHash("Base Layer.BumpAttack");
private static readonly int BackBumpAttackState =
Animator.StringToHash("Base Layer.BackBumpAttack");
private static readonly int ArtifactUseState =
Animator.StringToHash("Base Layer.ArtifactUse");
public const string BumpAttackStateName = "BumpAttack";
public const string BackBumpAttackStateName = "BackBumpAttack";
public const string ArtifactUseStateName = "ArtifactUse";
public const string HurtStateName = "Hurt";
public const string DashStateName = "Dash";
public const string DeathStateName = "Death";
private Rigidbody2D body;
private SpriteRenderer spriteRenderer;
private Animator animator;
private PlayerStats playerStats;
private DashController dashController;
private ActiveArtifactController activeArtifactController;
private PlayerHealth playerHealth;
private Vector2 moveInput;
private Vector2 pendingImpactRecoil;
private Vector2 damageKnockbackDirection;
private float damageKnockbackDistance;
private float damageKnockbackDuration;
private float damageKnockbackElapsed;
public Vector2 MoveDirection { get; private set; }
public Vector2 FacingDirection { get; private set; } = Vector2.right;
public Vector2 CurrentVelocity { get; private set; }
public Vector2 AttackIntentVelocity { get; private set; }
public bool IsDamageKnockbackActive { get; private set; }
public bool IsHurtMovementLocked =>
playerHealth != null && playerHealth.IsHurtMovementLocked;
public Vector2 PlayerClampHalfExtents
{
get
{
ArenaBounds bounds = ArenaBounds.Resolve();
return bounds != null
? bounds.PlayerClampHalfExtents
: new Vector2(
Mathf.Max(0f, GameplayConstants.Current.Player.ArenaHalfExtents.x
- GameplayConstants.Current.Player.ArenaPadding.x),
Mathf.Max(0f, GameplayConstants.Current.Player.ArenaHalfExtents.y
- GameplayConstants.Current.Player.ArenaPadding.y));
}
}
private void Awake()
{
body = GetComponent<Rigidbody2D>();
spriteRenderer = GetComponent<SpriteRenderer>();
animator = GetComponent<Animator>();
playerStats = GetComponent<PlayerStats>();
dashController = GetComponent<DashController>();
activeArtifactController = GetComponent<ActiveArtifactController>();
playerHealth = GetComponent<PlayerHealth>();
}
private void OnDisable()
{
if (animator == null
|| animator.runtimeAnimatorController == null
|| !animator.isActiveAndEnabled)
{
return;
}
animator.SetBool(IsMovingParameter, false);
animator.Rebind();
animator.Update(0f);
}
public bool TryPlayBumpAttackAnimation(bool isBackHit = false)
{
return TryPlayCombatAnimation(isBackHit ? BackBumpAttackState : BumpAttackState, true);
}
public bool TryPlayArtifactUseAnimation()
{
return TryPlayCombatAnimation(ArtifactUseState, false);
}
private bool TryPlayCombatAnimation(
int stateHash,
bool blockActiveArtifactUse)
{
if (animator == null
|| animator.runtimeAnimatorController == null
|| !animator.isActiveAndEnabled
|| !animator.HasState(0, stateHash))
{
return false;
}
AnimatorStateInfo current = animator.GetCurrentAnimatorStateInfo(0);
AnimatorStateInfo next = animator.IsInTransition(0)
? animator.GetNextAnimatorStateInfo(0)
: default;
bool priorityStateActive = IsPriorityAnimationState(current)
|| IsPriorityAnimationState(next);
bool artifactUseStateActive = blockActiveArtifactUse
&& (IsAnimationState(current, ArtifactUseStateName)
|| IsAnimationState(next, ArtifactUseStateName));
bool isDead = playerHealth != null
&& playerHealth.CurrentHealth <= 0f;
bool isDashing = dashController != null && dashController.IsDashing;
if (isDead
|| IsHurtMovementLocked
|| isDashing
|| priorityStateActive
|| artifactUseStateActive)
{
return false;
}
animator.Play(stateHash, 0, 0f);
return true;
}
private static bool IsPriorityAnimationState(AnimatorStateInfo state)
{
return IsAnimationState(state, HurtStateName)
|| IsAnimationState(state, DashStateName)
|| IsAnimationState(state, DeathStateName);
}
private static bool IsAnimationState(
AnimatorStateInfo state,
string stateName)
{
return state.IsName(stateName);
}
private void Update()
{
if (!RunManager.GameplayInputEnabled || IsHurtMovementLocked)
{
SetMoveInput(Vector2.zero);
return;
}
Keyboard keyboard = Keyboard.current;
if (keyboard == null)
{
SetMoveInput(Vector2.zero);
return;
}
float horizontal = (keyboard.rightArrowKey.isPressed ? 1f : 0f)
- (keyboard.leftArrowKey.isPressed ? 1f : 0f);
float vertical = (keyboard.upArrowKey.isPressed ? 1f : 0f)
- (keyboard.downArrowKey.isPressed ? 1f : 0f);
SetMoveInput(Vector2.ClampMagnitude(new Vector2(horizontal, vertical), 1f));
}
private void FixedUpdate()
{
AttackIntentVelocity = Vector2.zero;
if (dashController != null && dashController.IsDashing)
{
pendingImpactRecoil = Vector2.zero;
IsDamageKnockbackActive = false;
CurrentVelocity = Vector2.zero;
return;
}
Vector2 startPosition = body.position;
if (IsDamageKnockbackActive)
{
UpdateDamageKnockback();
return;
}
if (activeArtifactController != null
&& activeArtifactController.LocksMovement)
{
body.linearVelocity = Vector2.zero;
CurrentVelocity = Vector2.zero;
return;
}
if (IsHurtMovementLocked)
{
body.linearVelocity = Vector2.zero;
CurrentVelocity = Vector2.zero;
return;
}
if (pendingImpactRecoil.sqrMagnitude > 0f)
{
Vector2 recoilTarget = ClampToArena(startPosition + pendingImpactRecoil);
pendingImpactRecoil = Vector2.zero;
body.linearVelocity = Vector2.zero;
body.MovePosition(recoilTarget);
CurrentVelocity = Vector2.zero;
return;
}
AttackIntentVelocity = moveInput * playerStats.MoveSpeed;
Vector2 targetPosition = ClampToArena(
startPosition + AttackIntentVelocity * Time.fixedDeltaTime);
body.MovePosition(targetPosition);
CurrentVelocity = (targetPosition - startPosition) / Time.fixedDeltaTime;
if (CurrentVelocity.sqrMagnitude > 0.0001f)
{
activeArtifactController?.AddMovementCharge(Time.fixedDeltaTime);
}
}
public Vector2 ClampToArena(Vector2 position)
{
ArenaBounds bounds = ArenaBounds.Resolve();
return bounds != null
? bounds.ClampPlayerPosition(position)
: new Vector2(
Mathf.Clamp(
position.x,
-GameplayConstants.Current.Player.ArenaHalfExtents.x
+ GameplayConstants.Current.Player.ArenaPadding.x,
GameplayConstants.Current.Player.ArenaHalfExtents.x
- GameplayConstants.Current.Player.ArenaPadding.x),
Mathf.Clamp(
position.y,
-GameplayConstants.Current.Player.ArenaHalfExtents.y
+ GameplayConstants.Current.Player.ArenaPadding.y,
GameplayConstants.Current.Player.ArenaHalfExtents.y
- GameplayConstants.Current.Player.ArenaPadding.y));
}
public bool TryReposition(Vector2 position)
{
if (body == null)
{
return false;
}
pendingImpactRecoil = Vector2.zero;
IsDamageKnockbackActive = false;
body.linearVelocity = Vector2.zero;
body.position = ClampToArena(position);
transform.position = body.position;
return true;
}
public void ApplyImpactRecoil(Vector2 direction, float distance)
{
if (direction.sqrMagnitude <= 0f || distance <= 0f)
{
return;
}
Vector2 recoil = direction.normalized * distance;
pendingImpactRecoil = Vector2.ClampMagnitude(
pendingImpactRecoil + recoil,
distance);
}
public void ApplyDamageKnockback(Vector2 direction, float distance, float duration)
{
if (direction.sqrMagnitude <= 0f || distance <= 0f)
{
return;
}
pendingImpactRecoil = Vector2.zero;
body.linearVelocity = Vector2.zero;
damageKnockbackDirection = direction.normalized;
damageKnockbackDistance = distance;
damageKnockbackDuration = Mathf.Max(duration, Time.fixedDeltaTime);
damageKnockbackElapsed = 0f;
IsDamageKnockbackActive = true;
CurrentVelocity = Vector2.zero;
}
private void UpdateDamageKnockback()
{
float previousProgress = Mathf.Clamp01(
damageKnockbackElapsed / damageKnockbackDuration);
damageKnockbackElapsed = Mathf.Min(
damageKnockbackElapsed + Time.fixedDeltaTime,
damageKnockbackDuration);
float currentProgress = Mathf.Clamp01(
damageKnockbackElapsed / damageKnockbackDuration);
float previousEase = 1f - (1f - previousProgress) * (1f - previousProgress);
float currentEase = 1f - (1f - currentProgress) * (1f - currentProgress);
float stepDistance = damageKnockbackDistance * (currentEase - previousEase);
body.linearVelocity = Vector2.zero;
body.MovePosition(ClampToArena(
body.position + damageKnockbackDirection * stepDistance));
CurrentVelocity = Vector2.zero;
if (damageKnockbackElapsed >= damageKnockbackDuration)
{
IsDamageKnockbackActive = false;
}
}
private void SetMoveInput(Vector2 input)
{
moveInput = input;
MoveDirection = input;
if (IsDamageKnockbackActive
|| IsHurtMovementLocked
|| (activeArtifactController != null
&& activeArtifactController.LocksMovement))
{
animator.SetBool(IsMovingParameter, false);
return;
}
animator.SetBool(IsMovingParameter, input.sqrMagnitude > 0f);
if (input.sqrMagnitude <= 0f)
{
return;
}
FacingDirection = input.normalized;
if (!Mathf.Approximately(input.x, 0f))
{
spriteRenderer.flipX = input.x < 0f;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cfdd4de5148b1a64b867f0b64a67b5d8
@@ -0,0 +1,334 @@
using System;
using BumpCombat.Combat;
using BumpCombat.Core;
using BumpCombat.Constants;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.LowLevel;
namespace BumpCombat.Player
{
[RequireComponent(typeof(PlayerController), typeof(Animator))]
[RequireComponent(typeof(PlayerStats))]
public sealed class PlayerHealth : MonoBehaviour
{
private static readonly int HurtParameter = Animator.StringToHash("Hurt");
// Nonserialized overrides retain isolated test control; normal gameplay
// reads its values from the centralized PlayerConstants asset.
private float invulnerabilityDuration = -1f;
private float knockbackDuration = -1f;
private float hurtMovementLockDuration = -1f;
private float guardCooldownDuration = -1f;
private float guardDuration = -1f;
private float guardSuccessCooldownRechargePercent = -1f;
private PlayerStats playerStats;
private PlayerController playerController;
private Animator animator;
private DashController dashController;
private float invulnerableUntil;
private float hurtMovementLockedUntil;
private float guardUntil;
private float guardCooldownUntil;
private int nextGuardActivationIdentity;
private int activeGuardActivationIdentity;
private bool activeGuardHasBlockedAttack;
private RunManager subscribedRunManager;
public event Action<float, float> OnHealthChanged;
public event Action<Vector2, float> OnDamaged;
/// <summary>Raised only for positive health actually lost after mitigation and lethal clamping.</summary>
public event Action<float> OnDamageTaken;
public event Action<int> OnGuardAttackBlocked;
public event Action<Vector2> OnGuardImpact;
public event Action OnDied;
public float MaxHealth => playerStats.MaxHealth;
public float CurrentHealth { get; private set; }
public static float DefaultGuardCooldown =>
GameplayConstants.Current.Player.GuardCooldownDuration;
public static float DefaultGuardDuration =>
GameplayConstants.Current.Player.GuardDuration;
public static float DefaultGuardSuccessCooldownRechargePercent =>
GameplayConstants.Current.Player.GuardSuccessCooldownRechargePercent;
public float InvulnerabilityDuration => Mathf.Max(
ResolveOverride(invulnerabilityDuration, GameplayConstants.Current.Player.InvulnerabilityDuration),
ResolveOverride(hurtMovementLockDuration, GameplayConstants.Current.Player.HurtMovementLockDuration)
+ GameplayConstants.Current.Player.HurtRecoveryPadding);
public bool IsHurtMovementLocked => Time.time < hurtMovementLockedUntil;
public float GuardCooldownDuration => ResolveOverride(
guardCooldownDuration,
GameplayConstants.Current.Player.GuardCooldownDuration);
public float GuardDuration => ResolveOverride(
guardDuration,
GameplayConstants.Current.Player.GuardDuration);
public float GuardSuccessCooldownRechargePercent =>
ResolveOverride(
guardSuccessCooldownRechargePercent,
GameplayConstants.Current.Player.GuardSuccessCooldownRechargePercent);
public bool IsGuardAvailable => RunManager.Instance?.IsGuardUnlocked ?? true;
public bool IsGuarding =>
IsGuardAvailable
&& isActiveAndEnabled
&& CurrentHealth > 0f
&& Time.time < guardUntil;
public int ActiveGuardActivationIdentity =>
IsGuarding ? activeGuardActivationIdentity : 0;
public float GuardCooldownRemaining =>
Mathf.Max(0f, guardCooldownUntil - Time.time);
/// <summary>Remaining cooldown as a fraction: 1 immediately after use, 0 when ready.</summary>
public float GuardCooldownNormalized => GuardCooldownDuration > 0f
? Mathf.Clamp01(GuardCooldownRemaining / GuardCooldownDuration)
: 0f;
/// <summary>Guard readiness as a fraction: 0 during cooldown, 1 when ready.</summary>
public float GuardReadinessNormalized => IsGuardAvailable
? 1f - GuardCooldownNormalized
: 0f;
public bool IsInvulnerable =>
IsGuarding
|| Time.time < invulnerableUntil
|| (dashController != null && dashController.IsDashing);
private void Awake()
{
playerStats = GetComponent<PlayerStats>();
playerController = GetComponent<PlayerController>();
animator = GetComponent<Animator>();
dashController = GetComponent<DashController>();
CurrentHealth = MaxHealth;
playerStats.OnStatChanged += HandleStatChanged;
}
private void Start()
{
OnHealthChanged?.Invoke(CurrentHealth, MaxHealth);
RefreshRunManagerSubscription();
}
private void OnEnable()
{
InputSystem.onAfterUpdate += HandleInputSystemUpdate;
}
private void OnDestroy()
{
if (playerStats != null)
{
playerStats.OnStatChanged -= HandleStatChanged;
}
UnsubscribeRunManager();
}
private void OnDisable()
{
InputSystem.onAfterUpdate -= HandleInputSystemUpdate;
// A disabled player cannot remain visibly guarded when re-enabled.
// Keep the cooldown so disabling cannot refresh a spent guard.
guardUntil = 0f;
UnsubscribeRunManager();
}
private void Update()
{
RefreshRunManagerSubscription();
}
private void HandleInputSystemUpdate()
{
InputUpdateType currentUpdateType = InputState.currentUpdateType;
if ((currentUpdateType == InputUpdateType.Dynamic
|| currentUpdateType == InputUpdateType.Fixed
|| currentUpdateType == InputUpdateType.Manual)
&& Keyboard.current?.dKey.wasPressedThisFrame == true)
{
TryActivateGuard();
}
}
public bool TryActivateGuard()
{
if (!isActiveAndEnabled
|| Time.timeScale <= 0f
|| CurrentHealth <= 0f
|| !IsGuardAvailable
|| !RunManager.GameplayInputEnabled
|| IsHurtMovementLocked
|| IsGuarding
|| GuardCooldownRemaining > 0f)
{
return false;
}
float now = Time.time;
nextGuardActivationIdentity = nextGuardActivationIdentity == int.MaxValue
? 1
: nextGuardActivationIdentity + 1;
activeGuardActivationIdentity = nextGuardActivationIdentity;
activeGuardHasBlockedAttack = false;
guardUntil = now + GuardDuration;
guardCooldownUntil = now + GuardCooldownDuration;
return true;
}
public bool TryTakeDamage(float damage, Vector2 knockbackDirection, float knockbackDistance)
{
if (CurrentHealth <= 0f)
{
return false;
}
float damageTaken = Mathf.Max(
0f,
DamageCalculator.FinalizeDamage(
playerStats.CalculateIncomingDamage(damage)));
if (damageTaken <= 0f)
{
return false;
}
if (IsGuarding)
{
if (!activeGuardHasBlockedAttack)
{
activeGuardHasBlockedAttack = true;
float cooldownRecharge = GuardCooldownDuration
* Mathf.Clamp01(
GuardSuccessCooldownRechargePercent / 100f);
guardCooldownUntil = Mathf.Max(
Time.time,
guardCooldownUntil - cooldownRecharge);
}
OnGuardAttackBlocked?.Invoke(activeGuardActivationIdentity);
OnGuardImpact?.Invoke(knockbackDirection.normalized);
return false;
}
if (IsInvulnerable)
{
return false;
}
float healthBefore = CurrentHealth;
CurrentHealth = Mathf.Max(0f, CurrentHealth - damageTaken);
float actualDamageTaken = healthBefore - CurrentHealth;
invulnerableUntil = Time.time + InvulnerabilityDuration;
hurtMovementLockedUntil = Time.time + ResolveOverride(
hurtMovementLockDuration,
GameplayConstants.Current.Player.HurtMovementLockDuration);
playerController.ApplyDamageKnockback(
knockbackDirection,
knockbackDistance,
ResolveOverride(
knockbackDuration,
GameplayConstants.Current.Player.KnockbackDuration));
animator.SetTrigger(HurtParameter);
OnDamaged?.Invoke(knockbackDirection.normalized, knockbackDistance);
OnHealthChanged?.Invoke(CurrentHealth, MaxHealth);
if (actualDamageTaken > 0f)
{
OnDamageTaken?.Invoke(actualDamageTaken);
}
if (CurrentHealth <= 0f)
{
OnDied?.Invoke();
RunManager.Instance?.EndRun();
}
return true;
}
private static float ResolveOverride(float value, float configuredValue)
{
return value >= 0f ? value : configuredValue;
}
public void Heal(float amount)
{
if (amount <= 0f || CurrentHealth <= 0f)
{
return;
}
CurrentHealth = Mathf.Min(MaxHealth, CurrentHealth + amount);
OnHealthChanged?.Invoke(CurrentHealth, MaxHealth);
}
public void GrantInvulnerability(float duration)
{
if (duration <= 0f || CurrentHealth <= 0f)
{
return;
}
invulnerableUntil = Mathf.Max(
invulnerableUntil,
Time.time + duration);
}
private void HandleStatChanged(CharacterStat stat)
{
if (stat != CharacterStat.MaxHealth)
{
return;
}
CurrentHealth = Mathf.Min(CurrentHealth, MaxHealth);
OnHealthChanged?.Invoke(CurrentHealth, MaxHealth);
}
private void HandleRunStarted()
{
guardUntil = 0f;
guardCooldownUntil = 0f;
nextGuardActivationIdentity = 0;
activeGuardActivationIdentity = 0;
activeGuardHasBlockedAttack = false;
}
private void RefreshRunManagerSubscription()
{
RunManager current = RunManager.Instance;
if (subscribedRunManager == current)
{
return;
}
UnsubscribeRunManager();
subscribedRunManager = current;
if (subscribedRunManager != null)
{
subscribedRunManager.OnRunStarted += HandleRunStarted;
subscribedRunManager.OnGuardAvailabilityChanged +=
HandleGuardAvailabilityChanged;
HandleGuardAvailabilityChanged(
subscribedRunManager.IsGuardUnlocked);
}
}
private void HandleGuardAvailabilityChanged(bool isAvailable)
{
if (!isAvailable)
{
guardUntil = 0f;
}
}
private void UnsubscribeRunManager()
{
if (subscribedRunManager == null)
{
return;
}
subscribedRunManager.OnRunStarted -= HandleRunStarted;
subscribedRunManager.OnGuardAvailabilityChanged -=
HandleGuardAvailabilityChanged;
subscribedRunManager = null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e4085b12c1120684fa6ce9e4550d2ef8
@@ -0,0 +1,263 @@
using System.Collections.Generic;
using BumpCombat.Combat;
using BumpCombat.Core;
using BumpCombat.Constants;
using BumpCombat.Enemies;
using UnityEngine;
namespace BumpCombat.Player
{
[DisallowMultipleComponent]
public sealed class PlayerStats : CharacterModel
{
private const string PressureStreakSourceId = "player.pressure-streak";
private readonly HashSet<int> pressureStreakProcessedEnemyLifetimes = new();
private RunManager subscribedRunManager;
private PlayerHealth playerHealth;
private float pressureStreakExpiresAt = -1f;
private float pressureStreakLastKillAt = -1f;
public override float MoveSpeed => Evaluate(
CharacterStat.MoveSpeed,
GameplayConstants.Current.Player.BaseMoveSpeed);
public override float MaxHealth => Evaluate(
CharacterStat.MaxHealth,
GameplayConstants.Current.Player.BaseMaxHealth);
public int PressureStreakKillCount { get; private set; }
public int PressureStreakKillTarget => Mathf.Max(
1,
GameplayConstants.Current.Player.PressureStreakKillTarget);
public float PressureStreakRemaining => Mathf.Max(
0f,
pressureStreakExpiresAt - GetCombatTime());
public bool IsPressureStreakWindowActive => PressureStreakRemaining > 0f;
public bool IsPressureStreakActive => IsPressureStreakWindowActive
&& GetStackCount(
PressureStreakSourceId,
CharacterStat.MoveSpeed) > 0;
private void OnEnable()
{
playerHealth = GetComponent<PlayerHealth>();
if (playerHealth != null)
{
playerHealth.OnDied += HandlePlayerDied;
}
CombatEvents.OnValidHit += HandleValidHit;
RefreshRunManagerSubscription();
}
private void Start()
{
RefreshRunManagerSubscription();
if (subscribedRunManager != null
&& !subscribedRunManager.IsTitleScreen
&& !subscribedRunManager.IsGameOver)
{
HandleRunStarted();
}
}
private void Update()
{
RefreshRunManagerSubscription();
RefreshPressureStreak();
}
private void OnDisable()
{
CombatEvents.OnValidHit -= HandleValidHit;
if (playerHealth != null)
{
playerHealth.OnDied -= HandlePlayerDied;
}
UnsubscribeRunManager();
ResetPressureStreak();
}
private void OnDestroy()
{
CombatEvents.OnValidHit -= HandleValidHit;
if (playerHealth != null)
{
playerHealth.OnDied -= HandlePlayerDied;
}
UnsubscribeRunManager();
}
public void RefreshPressureStreak()
{
float now = GetCombatTime();
PrunePressureStreak(now);
if (pressureStreakExpiresAt > 0f && now >= pressureStreakExpiresAt)
{
ClearPressureStreakBuff();
}
}
private void HandleValidHit(CombatHitResult result)
{
if (result.Attacker != gameObject
|| !result.IsOrdinaryBump
|| result.IsArtifactHit
|| result.Damage <= 0f
|| result.Target == null)
{
return;
}
if ((playerHealth != null && playerHealth.CurrentHealth <= 0f)
|| RunManager.Instance?.IsGameOver == true)
{
return;
}
EnemyController enemy = result.Target.GetComponent<EnemyController>()
?? result.Target.GetComponentInParent<EnemyController>();
if (enemy == null || !enemy.IsDead)
{
return;
}
int lifetimeIdentity = enemy.SpawnLifetimeIdentity;
if (lifetimeIdentity <= 0)
{
lifetimeIdentity = enemy.GetInstanceID();
}
if (!pressureStreakProcessedEnemyLifetimes.Add(lifetimeIdentity))
{
return;
}
float now = GetCombatTime();
RefreshPressureStreak();
if (pressureStreakLastKillAt < 0f
|| now - pressureStreakLastKillAt
> Mathf.Max(
0f,
GameplayConstants.Current.Player.PressureStreakWindowSeconds))
{
PressureStreakKillCount = 0;
}
PressureStreakKillCount++;
pressureStreakLastKillAt = now;
pressureStreakExpiresAt = now
+ Mathf.Max(
0f,
GameplayConstants.Current.Player.PressureStreakDuration);
if (PressureStreakKillCount >= PressureStreakKillTarget)
{
EnsurePressureStreakBuff();
}
else
{
RemoveModifiersFromSource(PressureStreakSourceId);
}
}
private void PrunePressureStreak(float now)
{
if (pressureStreakLastKillAt >= 0f
&& now - pressureStreakLastKillAt
> Mathf.Max(
0f,
GameplayConstants.Current.Player.PressureStreakWindowSeconds))
{
PressureStreakKillCount = 0;
pressureStreakLastKillAt = -1f;
ClearPressureStreakBuff();
}
}
private void EnsurePressureStreakBuff()
{
if (GetStackCount(
PressureStreakSourceId,
CharacterStat.MoveSpeed) > 0)
{
return;
}
AddModifier(new StatModifier(
PressureStreakSourceId,
CharacterStat.MoveSpeed,
ModifierOperation.Increased,
Mathf.Max(
0f,
GameplayConstants.Current.Player.PressureStreakMoveSpeedIncrease)));
}
private void ClearPressureStreakBuff()
{
RemoveModifiersFromSource(PressureStreakSourceId);
pressureStreakExpiresAt = -1f;
}
private void ResetPressureStreak()
{
pressureStreakProcessedEnemyLifetimes.Clear();
PressureStreakKillCount = 0;
pressureStreakLastKillAt = -1f;
ClearPressureStreakBuff();
}
private void HandlePlayerDied()
{
ResetPressureStreak();
}
private void HandleRunStarted()
{
ResetPressureStreak();
}
private void HandleGameOver()
{
ResetPressureStreak();
}
private float GetCombatTime()
{
RunManager current = RunManager.Instance;
return current != null ? current.ElapsedTime : Time.time;
}
private void RefreshRunManagerSubscription()
{
RunManager current = RunManager.Instance;
if (subscribedRunManager == current)
{
return;
}
UnsubscribeRunManager();
subscribedRunManager = current;
if (subscribedRunManager == null)
{
return;
}
subscribedRunManager.OnRunStarted += HandleRunStarted;
subscribedRunManager.OnGameOver += HandleGameOver;
}
private void UnsubscribeRunManager()
{
if (subscribedRunManager == null)
{
return;
}
subscribedRunManager.OnRunStarted -= HandleRunStarted;
subscribedRunManager.OnGameOver -= HandleGameOver;
subscribedRunManager = null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cd485475b35048168a803a2b70638abc