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
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8fca7e2b3d6a4b9a9f7e3c2d1a0b5e6f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,661 @@
using System;
using System.Collections;
using System.Collections.Generic;
using BumpCombat.Combat;
using BumpCombat.Core;
using BumpCombat.Enemies;
using BumpCombat.Player;
using BumpCombat.Progression;
using BumpCombat.Spawning;
using UnityEngine;
namespace BumpCombat.Audio
{
/// <summary>
/// Small scene-local audio router for the presentation pass. It keeps
/// combat sounds bounded and leaves gameplay systems unaware of clips.
/// </summary>
[DisallowMultipleComponent]
public sealed class BumpCombatAudioService : MonoBehaviour
{
private const string ResourceRoot = "Presentation/Audio-v1/";
private const string MasterKey = "BumpCombat.MasterVolume";
private const string MusicKey = "BumpCombat.MusicVolume";
private const string SfxKey = "BumpCombat.SfxVolume";
private const float DefaultMaster = 0.8f;
private const float DefaultMusic = 0.55f;
private const float DefaultSfx = 0.8f;
private const float SfxBusGain = 0.65f;
private const int DefaultVoiceCount = 8;
private const string MusicSourcePrefix = "Music";
private sealed class Voice
{
public AudioSource Source;
public string ClipId;
public float StartedAt;
public float Gain = 1f;
}
private readonly Dictionary<string, AudioClip> clips = new();
private readonly Dictionary<string, float> lastPlayedAt = new();
private readonly List<Voice> voices = new();
private readonly HashSet<RunTimedEvent> activeBossEvents = new();
private AudioSource musicSource;
private AudioSource activeMusic;
private AudioClip activeMusicClip;
private float musicWeight;
private Coroutine musicFadeRoutine;
private RunManager runManager;
private PlayerHealth playerHealth;
private ExperienceSystem experienceSystem;
private ActiveArtifactController artifacts;
private SpawnDirector spawnDirector;
private bool nextPitchUp;
private bool gaugeWasReady;
private bool runEndSoundPlayed;
private int lastExperience;
private int lastLevel;
private float musicDuck = 1f;
private float masterVolume;
private float musicVolume;
private float sfxVolume;
public static BumpCombatAudioService Instance { get; private set; }
public float MasterVolume => masterVolume;
public float MusicVolume => musicVolume;
public float SfxVolume => sfxVolume;
public int VoiceCount => voices.Count;
public string ActiveMusicClipId { get; private set; }
public string LastPlayedClipId { get; private set; }
public float CurrentMusicOutputGain =>
musicSource != null ? musicSource.volume : 0f;
public bool AreMusicSourcesStopped =>
musicSource == null || !musicSource.isPlaying;
private void Awake()
{
if (Instance != null && Instance != this)
{
StopAndRemoveGeneratedMusicSources();
Destroy(this);
return;
}
Instance = this;
StopAndRemoveGeneratedMusicSources();
masterVolume = LoadVolume(MasterKey, DefaultMaster);
musicVolume = LoadVolume(MusicKey, DefaultMusic);
sfxVolume = LoadVolume(SfxKey, DefaultSfx);
LoadClips();
CreateSources();
}
private void Start()
{
BindRuntimeObjects();
PlayMusic("bgm_courtyard", true);
}
private void OnDestroy()
{
bool ownsInstance = Instance == this;
UnbindRuntimeObjects();
if (musicFadeRoutine != null)
{
StopCoroutine(musicFadeRoutine);
musicFadeRoutine = null;
}
if (ownsInstance)
{
StopAndRemoveGeneratedMusicSources();
Instance = null;
}
}
private void StopAndRemoveGeneratedMusicSources()
{
for (int i = transform.childCount - 1; i >= 0; i--)
{
Transform child = transform.GetChild(i);
if (!IsGeneratedMusicSourceName(child.name))
{
continue;
}
AudioSource[] sources = child.GetComponentsInChildren<AudioSource>(true);
for (int j = 0; j < sources.Length; j++)
{
sources[j].Stop();
sources[j].volume = 0f;
}
Destroy(child.gameObject);
}
}
private static bool IsGeneratedMusicSourceName(string objectName)
{
return string.Equals(objectName, MusicSourcePrefix, StringComparison.Ordinal)
|| string.Equals(objectName, "Music A", StringComparison.Ordinal)
|| string.Equals(objectName, "Music B", StringComparison.Ordinal);
}
private void LoadClips()
{
string[] ids =
{
"ui_move", "ui_confirm", "hit_light", "hit_heavy",
"player_hurt", "enemy_defeat", "xp_pickup", "level_up",
"gauge_ready", "run_end", "charge_start", "artifact_dash",
"artifact_pulse", "artifact_ray", "artifact_cyclone",
"artifact_thunder", "artifact_arc", "bgm_courtyard", "bgm_boss",
};
for (int i = 0; i < ids.Length; i++)
{
AudioClip clip = Resources.Load<AudioClip>(ResourceRoot + ids[i]);
if (clip != null)
{
clips[ids[i]] = clip;
}
}
}
private void CreateSources()
{
for (int i = 0; i < DefaultVoiceCount; i++)
{
GameObject sourceObject = new($"SFX Voice {i + 1}");
sourceObject.transform.SetParent(transform, false);
AudioSource source = sourceObject.AddComponent<AudioSource>();
source.playOnAwake = false;
source.ignoreListenerPause = true;
source.spatialBlend = 0f;
voices.Add(new Voice { Source = source });
}
musicSource = CreateMusicSource("Music");
}
private AudioSource CreateMusicSource(string name)
{
GameObject sourceObject = new(name);
sourceObject.transform.SetParent(transform, false);
AudioSource source = sourceObject.AddComponent<AudioSource>();
source.playOnAwake = false;
source.loop = true;
source.ignoreListenerPause = true;
source.spatialBlend = 0f;
return source;
}
private void BindRuntimeObjects()
{
runManager = RunManager.Instance;
playerHealth = FindAnyObjectByType<PlayerHealth>();
experienceSystem = FindAnyObjectByType<ExperienceSystem>();
artifacts = FindAnyObjectByType<ActiveArtifactController>();
spawnDirector = FindAnyObjectByType<SpawnDirector>();
CombatEvents.OnValidHit += HandleValidHit;
CombatEvents.OnEnemyDied += HandleEnemyDied;
if (runManager != null)
{
runManager.OnSelectionChanged += HandleSelectionChanged;
runManager.OnPauseChanged += HandlePauseChanged;
runManager.OnTitleChanged += HandleTitleChanged;
runManager.OnGameOver += HandleGameOver;
}
if (playerHealth != null)
{
playerHealth.OnDamaged += HandlePlayerDamaged;
playerHealth.OnDied += HandlePlayerDied;
}
if (experienceSystem != null)
{
lastExperience = experienceSystem.CurrentExperience;
lastLevel = experienceSystem.Level;
experienceSystem.OnExperienceChanged += HandleExperienceChanged;
experienceSystem.OnLevelGained += HandleLevelGained;
}
if (artifacts != null)
{
gaugeWasReady = artifacts.CurrentGauge >= artifacts.MaxGauge;
artifacts.OnStateChanged += HandleArtifactStateChanged;
artifacts.OnArtifactChargeStarted += HandleArtifactChargeStarted;
artifacts.OnArtifactUseSucceeded += HandleArtifactUseSucceeded;
}
if (spawnDirector != null)
{
spawnDirector.OnEventEnemySpawned += HandleEventEnemySpawned;
spawnDirector.OnEventEnemyDefeated += HandleEventEnemyDefeated;
}
UpdateMusicDuck(runManager?.IsTitleScreen == true
|| runManager?.IsSelectionOpen == true
|| runManager?.IsPaused == true);
}
private void UnbindRuntimeObjects()
{
CombatEvents.OnValidHit -= HandleValidHit;
CombatEvents.OnEnemyDied -= HandleEnemyDied;
if (runManager != null)
{
runManager.OnSelectionChanged -= HandleSelectionChanged;
runManager.OnPauseChanged -= HandlePauseChanged;
runManager.OnTitleChanged -= HandleTitleChanged;
runManager.OnGameOver -= HandleGameOver;
}
if (playerHealth != null)
{
playerHealth.OnDamaged -= HandlePlayerDamaged;
playerHealth.OnDied -= HandlePlayerDied;
}
if (experienceSystem != null)
{
experienceSystem.OnExperienceChanged -= HandleExperienceChanged;
experienceSystem.OnLevelGained -= HandleLevelGained;
}
if (artifacts != null)
{
artifacts.OnStateChanged -= HandleArtifactStateChanged;
artifacts.OnArtifactChargeStarted -= HandleArtifactChargeStarted;
artifacts.OnArtifactUseSucceeded -= HandleArtifactUseSucceeded;
}
if (spawnDirector != null)
{
spawnDirector.OnEventEnemySpawned -= HandleEventEnemySpawned;
spawnDirector.OnEventEnemyDefeated -= HandleEventEnemyDefeated;
}
}
private void HandleValidHit(CombatHitResult result)
{
// Artifact hits already have one sound at cast start. Keep this
// event for successful ordinary bumps only, avoiding AoE chatter.
if (!result.IsOrdinaryBump || !result.ShowsHitFeedback)
{
return;
}
PlaySfx(
result.IsStrongDash || result.HasBackBonus
? "hit_heavy"
: "hit_light",
1f,
false,
0.05f);
}
private void HandleEnemyDied(GameObject target)
{
PlaySfx("enemy_defeat", 0.85f, false, 0.08f);
}
private void HandlePlayerDamaged(Vector2 direction, float force)
{
PlaySfx("player_hurt", 1f, false, 0.08f);
}
private void HandlePlayerDied()
{
if (runEndSoundPlayed)
{
return;
}
runEndSoundPlayed = true;
PlaySfx("run_end", 1f, false, 0f);
FadeMusicOut(0.5f);
}
private void HandleGameOver()
{
if (runEndSoundPlayed)
{
return;
}
runEndSoundPlayed = true;
PlaySfx("run_end", 1f, false, 0f);
FadeMusicOut(0.5f);
}
private void HandleExperienceChanged(int current, int required, int level)
{
if (current > lastExperience || level > lastLevel)
{
PlaySfx("xp_pickup", 0.6f, false, 0.07f);
}
lastExperience = current;
lastLevel = level;
}
private void HandleLevelGained(int level)
{
PlaySfx("level_up", 1f, false, 0.1f);
}
private void HandleArtifactStateChanged()
{
if (artifacts == null)
{
return;
}
bool ready = artifacts.CurrentGauge >= artifacts.MaxGauge - 0.0001f;
if (ready && !gaugeWasReady)
{
PlaySfx("gauge_ready", 0.9f, false, 0.1f);
}
gaugeWasReady = ready;
}
private void HandleArtifactChargeStarted(ActiveArtifactDefinition definition)
{
PlaySfx("charge_start", 0.8f, false, 0.1f);
}
private void HandleArtifactUseSucceeded(
ActiveArtifactDefinition definition,
bool charged)
{
if (definition == null)
{
return;
}
string clipId = definition.Effect switch
{
ActiveArtifactEffect.Dash => "artifact_dash",
ActiveArtifactEffect.Pulse => "artifact_pulse",
ActiveArtifactEffect.Phoenix => "artifact_ray",
ActiveArtifactEffect.Cyclone => "artifact_cyclone",
ActiveArtifactEffect.ThunderCrash => "artifact_thunder",
ActiveArtifactEffect.ChainLightning => "artifact_arc",
_ => null,
};
if (clipId != null)
{
PlaySfx(clipId, charged ? 1f : 0.85f, false, 0.04f);
}
}
private void HandleEventEnemySpawned(
RunTimedEvent timedEvent,
EnemyController enemy)
{
if (timedEvent == RunTimedEvent.MidBoss
|| timedEvent == RunTimedEvent.FinalBoss)
{
activeBossEvents.Add(timedEvent);
PlayMusic("bgm_boss", false);
}
}
private void HandleEventEnemyDefeated(RunTimedEvent timedEvent)
{
if (timedEvent == RunTimedEvent.MidBoss
|| timedEvent == RunTimedEvent.FinalBoss)
{
activeBossEvents.Remove(timedEvent);
if (activeBossEvents.Count == 0)
{
PlayMusic("bgm_courtyard", false);
}
}
}
private void HandleSelectionChanged(bool isOpen)
{
UpdateMusicDuck(isOpen || runManager?.IsPaused == true || runManager?.IsTitleScreen == true);
}
private void HandlePauseChanged(bool isOpen)
{
UpdateMusicDuck(isOpen || runManager?.IsSelectionOpen == true || runManager?.IsTitleScreen == true);
}
private void HandleTitleChanged(bool isOpen)
{
UpdateMusicDuck(isOpen || runManager?.IsSelectionOpen == true || runManager?.IsPaused == true);
}
private void UpdateMusicDuck(bool duck)
{
musicDuck = duck ? 0.35f : 1f;
ApplyMusicVolume();
}
public bool PlayUi(string clipId, float volume = 1f)
{
return PlaySfx(clipId, volume, true, 0.025f);
}
public bool PlaySfx(
string clipId,
float volume = 1f,
bool isUi = false,
float minimumInterval = 0f)
{
if (!clips.TryGetValue(clipId, out AudioClip clip)
|| clip == null
|| voices.Count == 0)
{
return false;
}
float now = Time.unscaledTime;
if (minimumInterval > 0f
&& lastPlayedAt.TryGetValue(clipId, out float previous)
&& now - previous < minimumInterval)
{
return false;
}
Voice voice = FindVoice(isUi);
if (voice == null)
{
return false;
}
lastPlayedAt[clipId] = now;
LastPlayedClipId = clipId;
voice.ClipId = clipId;
voice.StartedAt = now;
voice.Gain = Mathf.Clamp01(volume);
AudioSource source = voice.Source;
source.Stop();
source.clip = clip;
source.loop = false;
source.volume = Mathf.Clamp01(masterVolume * sfxVolume * SfxBusGain * voice.Gain);
source.pitch = isUi ? 1f : nextPitchUp ? 1.02f : 0.98f;
nextPitchUp = !nextPitchUp;
source.Play();
return true;
}
private Voice FindVoice(bool isUi)
{
int first = isUi ? 0 : 1;
int last = isUi ? 1 : voices.Count;
for (int i = first; i < last; i++)
{
if (!voices[i].Source.isPlaying)
{
return voices[i];
}
}
Voice oldest = null;
for (int i = first; i < last; i++)
{
if (oldest == null || voices[i].StartedAt < oldest.StartedAt)
{
oldest = voices[i];
}
}
return oldest;
}
public void SetMasterVolume(float value)
{
masterVolume = SaveVolume(MasterKey, value);
ApplySfxVolume();
ApplyMusicVolume();
}
public void SetMusicVolume(float value)
{
musicVolume = SaveVolume(MusicKey, value);
ApplyMusicVolume();
}
public void SetSfxVolume(float value)
{
sfxVolume = SaveVolume(SfxKey, value);
ApplySfxVolume();
}
private void ApplySfxVolume()
{
for (int i = 0; i < voices.Count; i++)
{
if (voices[i].Source.isPlaying)
{
voices[i].Source.volume = masterVolume
* sfxVolume
* SfxBusGain
* voices[i].Gain;
}
}
}
private void PlayMusic(string clipId, bool immediate)
{
if (!clips.TryGetValue(clipId, out AudioClip clip) || clip == null)
{
return;
}
if (activeMusicClip == clip && activeMusic != null && activeMusic.isPlaying)
{
return;
}
if (musicFadeRoutine != null)
{
StopCoroutine(musicFadeRoutine);
musicFadeRoutine = null;
}
// Music owns one loop source. Stop the previous clip before
// assigning the new one so a boss transition can never overlap
// ambient music, even during the transition fade.
SetMusicWeight(immediate ? 1f : 0f);
musicSource.Stop();
musicSource.clip = clip;
musicSource.loop = true;
musicSource.volume = musicWeight * GetMusicGain();
musicSource.Play();
activeMusic = musicSource;
activeMusicClip = clip;
ActiveMusicClipId = clipId;
ApplyMusicVolume();
if (immediate)
{
return;
}
musicFadeRoutine = StartCoroutine(FadeMusicIn(0.5f));
}
private IEnumerator FadeMusicIn(float duration)
{
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.unscaledDeltaTime;
float t = Mathf.Clamp01(elapsed / duration);
SetMusicWeight(Mathf.Lerp(0f, 1f, t));
ApplyMusicVolume();
yield return null;
}
SetMusicWeight(1f);
ApplyMusicVolume();
musicFadeRoutine = null;
}
private void FadeMusicOut(float duration)
{
if (musicSource == null || !musicSource.isPlaying)
{
return;
}
if (musicFadeRoutine != null)
{
StopCoroutine(musicFadeRoutine);
}
musicFadeRoutine = StartCoroutine(FadeMusicOutRoutine(duration));
}
private IEnumerator FadeMusicOutRoutine(float duration)
{
float musicStart = musicWeight;
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.unscaledDeltaTime;
float t = Mathf.Clamp01(elapsed / duration);
SetMusicWeight(Mathf.Lerp(musicStart, 0f, t));
ApplyMusicVolume();
yield return null;
}
if (musicSource != null)
{
musicSource.Stop();
}
SetMusicWeight(0f);
ApplyMusicVolume();
activeMusic = null;
activeMusicClip = null;
musicFadeRoutine = null;
}
private float GetMusicGain()
{
return Mathf.Clamp01(masterVolume * musicVolume * musicDuck);
}
private void ApplyMusicVolume()
{
float gain = GetMusicGain();
if (musicSource != null)
{
musicSource.volume = musicSource.isPlaying ? musicWeight * gain : 0f;
}
}
private void SetMusicWeight(float weight)
{
musicWeight = Mathf.Clamp01(weight);
}
private static float LoadVolume(string key, float fallback)
{
return Mathf.Clamp01(PlayerPrefs.GetFloat(key, fallback));
}
private static float SaveVolume(string key, float value)
{
float clamped = Mathf.Clamp01(value);
PlayerPrefs.SetFloat(key, clamped);
PlayerPrefs.Save();
return clamped;
}
#if UNITY_EDITOR
public bool DebugHasClip(string clipId)
{
return clips.ContainsKey(clipId);
}
#endif
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 47b32c4ff8d74a0c9a7d1e2b3c4f5a6d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,9 @@
{
"name": "BumpCombat.Runtime",
"rootNamespace": "BumpCombat",
"references": [
"Unity.InputSystem",
"UnityEngine.UI"
],
"autoReferenced": true
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 54f77fe7d40d4284d968a8d5b03b73e1
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: cdddbd299fbc20e46916ce230def09b9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,874 @@
using System;
using System.Collections.Generic;
using BumpCombat.Core;
using BumpCombat.Player;
using UnityEngine;
namespace BumpCombat.Combat
{
[DisallowMultipleComponent]
[DefaultExecutionOrder(1000)]
public sealed class ArtifactChargeVisual : MonoBehaviour
{
public const int FrameCount = 6;
public const int FlashFrameCount = 4;
public const int CellSize = 64;
public const float PixelsPerUnit = 32f;
public const float ChargeVisualY = 0f;
public const float ReadyFlashDuration = 0.14f;
public const float ReleaseVisualDuration = 0.24f;
public const float SwitchVisualDuration = 0.36f;
private const int AuraSortingOffset = -1;
private const int ReadyAuraSortingOffset = -1;
private const int FlashSortingOffset = 1;
private const int ReleaseSortingOffset = 1;
private const string ResourceRoot = "Artifacts/ThreeColor-v1/Charge/";
private const string AuraSuffix = "-Aura-v1";
private const string ReadyAuraSuffix = "-ReadyAura-v1";
private const string FlashSuffix = "-Flash-v1";
private const string ReleaseSuffix = "-Release-v1";
private static readonly float[] AuraFrameDurations =
{
0.06f, 0.06f, 0.06f, 0.06f, 0.06f, 0.06f,
};
private static readonly float[] ReadyAuraFrameDurations =
{
0.06f, 0.06f, 0.06f, 0.06f, 0.06f, 0.06f,
};
private static readonly float[] FlashFrameDurations =
{
0.03f, 0.04f, 0.04f, 0.03f,
};
private static readonly float[] ReleaseFrameDurations =
{
0.03f, 0.04f, 0.04f, 0.04f, 0.04f, 0.05f,
};
private sealed class CachedFrames
{
public CachedFrames(Sprite[] frames, bool ownsSprites)
{
Frames = frames;
OwnsSprites = ownsSprites;
}
public Sprite[] Frames { get; }
public bool OwnsSprites { get; }
}
private sealed class VisualSlot
{
public VisualSlot(
string objectName,
int sortingOffset,
float[] frameDurations)
{
ObjectName = objectName;
SortingOffset = sortingOffset;
FrameDurations = frameDurations;
}
public string ObjectName { get; }
public int SortingOffset { get; }
public float[] FrameDurations { get; }
public Sprite[] Frames;
public GameObject Root;
public SpriteRenderer Renderer;
public int FrameIndex;
public float FrameElapsed;
public bool Loop;
}
private static readonly Dictionary<string, CachedFrames> FrameCache = new();
private readonly VisualSlot aura = new(
"Artifact Charge Aura",
AuraSortingOffset,
AuraFrameDurations);
private readonly VisualSlot readyAura = new(
"Artifact Charge Ready Aura",
ReadyAuraSortingOffset,
ReadyAuraFrameDurations);
private readonly VisualSlot flash = new(
"Artifact Charge Flash",
FlashSortingOffset,
FlashFrameDurations);
private readonly VisualSlot release = new(
"Artifact Release",
ReleaseSortingOffset,
ReleaseFrameDurations);
private readonly VisualSlot switchPulse = new(
"Artifact Switch",
FlashSortingOffset,
AuraFrameDurations);
private ActiveArtifactController controller;
private readonly Dictionary<string, Sprite[]> equipmentFrames = new();
private readonly Dictionary<Sprite, Sprite> equipmentOriginals = new();
private PlayerHealth playerHealth;
private SpriteRenderer playerRenderer;
private SpriteRenderer guardOutline;
private readonly Dictionary<Sprite, Sprite> guardFrames = new();
private RunManager subscribedRunManager;
private ActiveArtifactDefinition chargingArtifact;
private bool readyFlashPlayed;
private int flashPlayCount;
private int releasePlayCount;
public bool IsAuraVisible => IsVisible(aura);
public bool IsSwitchVisible => IsVisible(switchPulse);
public GameObject SwitchObject => switchPulse.Root;
public bool IsReadyAuraVisible => IsVisible(readyAura);
public bool IsFlashVisible => IsVisible(flash);
public bool IsReleaseVisible => IsVisible(release);
public GameObject AuraObject => aura.Root;
public GameObject ReadyAuraObject => readyAura.Root;
public GameObject FlashObject => flash.Root;
public GameObject ReleaseObject => release.Root;
public int FlashPlayCount => flashPlayCount;
public int ReleasePlayCount => releasePlayCount;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetFrameCache()
{
foreach (CachedFrames cached in FrameCache.Values)
{
if (!cached.OwnsSprites)
{
continue;
}
foreach (Sprite sprite in cached.Frames)
{
if (sprite == null)
{
continue;
}
if (Application.isPlaying)
{
Destroy(sprite);
}
else
{
DestroyImmediate(sprite);
}
}
}
FrameCache.Clear();
}
private void Awake()
{
controller = GetComponent<ActiveArtifactController>();
playerHealth = GetComponent<PlayerHealth>();
playerRenderer = FindPlayerRenderer();
}
private void OnEnable()
{
if (controller == null)
{
controller = GetComponent<ActiveArtifactController>();
}
if (playerHealth == null)
{
playerHealth = GetComponent<PlayerHealth>();
}
if (playerRenderer == null)
{
playerRenderer = FindPlayerRenderer();
}
SubscribeController();
SubscribePlayerHealth();
RefreshRunManagerSubscription();
RestoreCurrentChargeVisuals();
}
private void OnDisable()
{
if (guardOutline != null) guardOutline.enabled = false;
RestoreEquipmentSprite();
UnsubscribeController();
UnsubscribePlayerHealth();
UnsubscribeRunManager();
ClearAllVisuals();
}
private void OnDestroy()
{
if (guardOutline != null) Destroy(guardOutline.gameObject);
UnsubscribeController();
UnsubscribePlayerHealth();
UnsubscribeRunManager();
ClearAllVisuals();
}
private void LateUpdate()
{
RefreshEquipmentSprite();
RefreshGuardOutline();
RefreshRunManagerSubscription();
if (subscribedRunManager != null
&& (subscribedRunManager.IsSelectionOpen
|| subscribedRunManager.IsGameOver
|| subscribedRunManager.IsTitleScreen))
{
ClearAllVisuals();
return;
}
if (controller == null)
{
ClearAllVisuals();
return;
}
if (!controller.isActiveAndEnabled)
{
ClearAllVisuals();
return;
}
if (controller.IsCharging)
{
UpdateChargeVisuals();
}
else
{
ClearChargeHoldVisuals();
}
SyncSlot(aura, true);
SyncSlot(readyAura, true);
SyncSlot(flash, true);
SyncSlot(release, false);
SyncSlot(switchPulse, true);
AdvanceSlot(aura, Time.deltaTime);
AdvanceSlot(readyAura, Time.deltaTime);
AdvanceSlot(flash, Time.deltaTime);
AdvanceSlot(release, Time.deltaTime);
AdvanceSlot(switchPulse, Time.deltaTime);
}
public void RefreshEquipmentSprite()
{
if (playerRenderer == null || playerRenderer.sprite == null) return;
Sprite current = playerRenderer.sprite;
Sprite original = equipmentOriginals.TryGetValue(current, out Sprite source) ? source : current;
if (controller == null || !controller.isActiveAndEnabled || controller.CurrentArtifact == null)
{
playerRenderer.sprite = original;
return;
}
string path = "Artifacts/ThreeColor-v1/Player/" + original.texture.name
+ "-" + controller.CurrentArtifact.ArtifactColor + "-v1";
if (!equipmentFrames.TryGetValue(path, out Sprite[] frames))
{
frames = Resources.LoadAll<Sprite>(path);
equipmentFrames[path] = frames;
}
foreach (Sprite frame in frames)
{
if (frame.rect != original.rect) continue;
equipmentOriginals[frame] = original;
playerRenderer.sprite = frame;
return;
}
playerRenderer.sprite = original;
}
public void RefreshGuardOutline()
{
bool visible = isActiveAndEnabled && playerHealth != null
&& playerHealth.isActiveAndEnabled && playerHealth.CurrentHealth > 0f
&& playerHealth.IsGuarding && playerRenderer != null
&& playerRenderer.enabled && playerRenderer.sprite != null
&& (RunManager.Instance == null || !RunManager.Instance.IsGameOver);
if (!visible)
{
if (guardOutline != null) guardOutline.enabled = false;
return;
}
if (guardOutline == null)
{
var root = new GameObject("Player Guard Outline");
root.transform.SetParent(playerRenderer.transform, false);
guardOutline = root.AddComponent<SpriteRenderer>();
}
Sprite current = playerRenderer.sprite;
Sprite original = equipmentOriginals.TryGetValue(current, out Sprite source) ? source : current;
if (!guardFrames.TryGetValue(original, out Sprite mask))
{
foreach (Sprite frame in Resources.LoadAll<Sprite>("Combat/Guard-v1/" + original.texture.name + "-outline"))
{
if (frame.rect != original.rect) continue;
mask = frame;
break;
}
guardFrames[original] = mask;
}
guardOutline.sprite = mask;
guardOutline.enabled = mask != null;
guardOutline.flipX = playerRenderer.flipX;
guardOutline.flipY = playerRenderer.flipY;
guardOutline.color = new Color(1f, 1f, 1f, playerRenderer.color.a);
guardOutline.sharedMaterial = playerRenderer.sharedMaterial;
guardOutline.sortingLayerID = playerRenderer.sortingLayerID;
guardOutline.sortingOrder = playerRenderer.sortingOrder + 1;
}
private void RestoreEquipmentSprite()
{
if (playerRenderer != null && playerRenderer.sprite != null
&& equipmentOriginals.TryGetValue(playerRenderer.sprite, out Sprite original))
playerRenderer.sprite = original;
}
public void Connect(ActiveArtifactController artifactController)
{
if (controller == artifactController && controller != null)
{
SubscribeController();
return;
}
UnsubscribeController();
controller = artifactController;
SubscribeController();
}
public void NotifyChargeEnded(bool preserveReadyFlash)
{
ClearChargeHoldVisuals();
if (!preserveReadyFlash)
{
ClearSlot(flash);
}
}
public void NotifyArtifactUseFailed()
{
ClearAllVisuals();
}
public void ClearAllVisuals()
{
ClearSlot(aura);
ClearSlot(readyAura);
ClearSlot(flash);
ClearSlot(release);
ClearSlot(switchPulse);
}
private void SubscribeController()
{
if (controller == null)
{
return;
}
controller.OnStateChanged -= HandleControllerStateChanged;
controller.OnArtifactChargeStarted -= HandleArtifactChargeStarted;
controller.OnArtifactUseSucceeded -= HandleArtifactUseSucceeded;
controller.OnArtifactSwitched -= HandleArtifactSwitched;
controller.OnStateChanged += HandleControllerStateChanged;
controller.OnArtifactChargeStarted += HandleArtifactChargeStarted;
controller.OnArtifactUseSucceeded += HandleArtifactUseSucceeded;
controller.OnArtifactSwitched += HandleArtifactSwitched;
}
private void UnsubscribeController()
{
if (controller == null)
{
return;
}
controller.OnStateChanged -= HandleControllerStateChanged;
controller.OnArtifactChargeStarted -= HandleArtifactChargeStarted;
controller.OnArtifactUseSucceeded -= HandleArtifactUseSucceeded;
controller.OnArtifactSwitched -= HandleArtifactSwitched;
}
private void SubscribePlayerHealth()
{
if (playerHealth == null)
{
return;
}
playerHealth.OnDamaged -= HandlePlayerDamaged;
playerHealth.OnDied -= HandlePlayerDied;
playerHealth.OnDamaged += HandlePlayerDamaged;
playerHealth.OnDied += HandlePlayerDied;
}
private void UnsubscribePlayerHealth()
{
if (playerHealth == null)
{
return;
}
playerHealth.OnDamaged -= HandlePlayerDamaged;
playerHealth.OnDied -= HandlePlayerDied;
}
private void RefreshRunManagerSubscription()
{
RunManager current = RunManager.Instance;
if (current == subscribedRunManager)
{
return;
}
UnsubscribeRunManager();
subscribedRunManager = current;
if (subscribedRunManager != null)
{
subscribedRunManager.OnSelectionChanged += HandleSelectionChanged;
}
}
private void UnsubscribeRunManager()
{
if (subscribedRunManager != null)
{
subscribedRunManager.OnSelectionChanged -= HandleSelectionChanged;
subscribedRunManager = null;
}
}
private void HandleArtifactChargeStarted(ActiveArtifactDefinition definition)
{
ClearSlot(switchPulse);
chargingArtifact = definition;
readyFlashPlayed = false;
ClearSlot(readyAura);
ClearSlot(flash);
ClearSlot(aura);
if (!Application.isPlaying || definition == null)
{
return;
}
aura.Frames = LoadFrames(
GetResourcePath(definition, AuraSuffix),
FrameCount);
readyAura.Frames = LoadFrames(
GetResourcePath(definition, ReadyAuraSuffix),
FrameCount);
flash.Frames = LoadFrames(
GetResourcePath(definition, FlashSuffix),
FlashFrameCount);
if (aura.Frames.Length == 0)
{
return;
}
CreateSlot(aura, true, Vector2.zero, 1f);
}
private void RestoreCurrentChargeVisuals()
{
if (!Application.isPlaying
|| controller == null
|| !controller.IsCharging)
{
return;
}
chargingArtifact = controller.CurrentArtifact;
if (chargingArtifact == null)
{
return;
}
aura.Frames = LoadFrames(
GetResourcePath(chargingArtifact, AuraSuffix),
FrameCount);
readyAura.Frames = LoadFrames(
GetResourcePath(chargingArtifact, ReadyAuraSuffix),
FrameCount);
flash.Frames = LoadFrames(
GetResourcePath(chargingArtifact, FlashSuffix),
FlashFrameCount);
UpdateChargeVisuals();
}
private void HandleControllerStateChanged()
{
if (controller == null || !controller.IsCharging)
{
ClearChargeHoldVisuals();
return;
}
UpdateChargeVisuals();
}
private void HandleArtifactUseSucceeded(
ActiveArtifactDefinition definition,
bool charged)
{
ClearSlot(switchPulse);
ClearChargeHoldVisuals();
if (!Application.isPlaying || definition == null)
{
return;
}
release.Frames = LoadFrames(
GetResourcePath(definition, ReleaseSuffix),
FrameCount);
if (release.Frames.Length == 0)
{
return;
}
Vector2 origin = controller.LastArtifactUsePosition;
CreateSlot(release, false, origin, charged ? 1.2f : 1f);
releasePlayCount++;
}
private void HandleArtifactSwitched(ActiveArtifactDefinition definition)
{
ClearSlot(switchPulse);
if (!Application.isPlaying || definition == null)
{
return;
}
switchPulse.Frames = LoadFrames(
GetResourcePath(definition, ReleaseSuffix), FrameCount);
if (switchPulse.Frames.Length == 0)
{
return;
}
// Reuse the existing colored outward burst, following the player
// for a single short cycle so movement does not leave it behind.
CreateSlot(switchPulse, true, Vector2.zero, 1f);
}
private void HandlePlayerDamaged(Vector2 _, float __)
{
ClearAllVisuals();
}
private void HandlePlayerDied()
{
ClearAllVisuals();
}
private void HandleSelectionChanged(bool isOpen)
{
if (isOpen)
{
ClearAllVisuals();
}
}
private void UpdateChargeVisuals()
{
if (controller == null
|| !controller.IsCharging
|| chargingArtifact == null)
{
return;
}
if (!controller.IsFullyCharged)
{
EnsureLoopSlot(aura, AuraSuffix, 1f);
ClearSlot(readyAura);
SetAlpha(
aura,
Mathf.Lerp(0.55f, 0.85f, controller.ChargeProgress));
return;
}
EnsureLoopSlot(readyAura, ReadyAuraSuffix, 1f);
ClearSlot(aura);
if (!readyFlashPlayed)
{
readyFlashPlayed = true;
flashPlayCount++;
if (flash.Frames.Length > 0 && Application.isPlaying)
{
CreateSlot(flash, true, Vector2.zero, 1f);
}
}
}
private void EnsureLoopSlot(
VisualSlot slot,
string suffix,
float scale)
{
if (slot.Root != null || chargingArtifact == null)
{
return;
}
slot.Frames = LoadFrames(
GetResourcePath(chargingArtifact, suffix),
FrameCount);
if (slot.Frames.Length == 0 || !Application.isPlaying)
{
return;
}
CreateSlot(slot, true, Vector2.zero, scale);
}
private void CreateSlot(
VisualSlot slot,
bool followPlayer,
Vector2 worldOrigin,
float scale)
{
ClearSlot(slot);
slot.Root = new GameObject(slot.ObjectName);
if (followPlayer)
{
slot.Root.transform.SetParent(transform, false);
slot.Root.transform.localPosition = new Vector3(
0f,
ChargeVisualY,
0f);
}
else
{
slot.Root.transform.position = new Vector3(
worldOrigin.x,
worldOrigin.y + ChargeVisualY,
transform.position.z);
}
slot.Root.transform.localScale = Vector3.one * scale;
slot.Renderer = slot.Root.AddComponent<SpriteRenderer>();
slot.Renderer.color = Color.white;
slot.Renderer.sprite = slot.Frames[0];
slot.Renderer.enabled = true;
slot.FrameIndex = 0;
slot.FrameElapsed = 0f;
slot.Loop = slot == aura || slot == readyAura;
SyncSlot(slot, followPlayer);
}
private void SyncSlot(VisualSlot slot, bool followPlayer)
{
if (slot.Root == null || slot.Renderer == null)
{
return;
}
if (followPlayer)
{
slot.Root.transform.SetParent(transform, false);
slot.Root.transform.localPosition = new Vector3(
0f,
ChargeVisualY,
0f);
}
if (playerRenderer == null)
{
playerRenderer = FindPlayerRenderer();
}
if (playerRenderer != null)
{
slot.Renderer.sortingLayerID = playerRenderer.sortingLayerID;
slot.Renderer.sortingOrder =
playerRenderer.sortingOrder + slot.SortingOffset;
slot.Renderer.sharedMaterial = playerRenderer.sharedMaterial;
}
Color color = Color.white;
if (slot == aura
&& controller != null
&& controller.IsCharging
&& !controller.IsFullyCharged)
{
color.a = Mathf.Lerp(0.55f, 0.85f, controller.ChargeProgress);
}
slot.Renderer.color = color;
slot.Renderer.enabled = true;
}
private static void SetAlpha(VisualSlot slot, float alpha)
{
if (slot.Renderer == null)
{
return;
}
Color color = Color.white;
color.a = Mathf.Clamp01(alpha);
slot.Renderer.color = color;
}
private static void AdvanceSlot(VisualSlot slot, float deltaTime)
{
if (slot.Root == null
|| slot.Renderer == null
|| slot.Frames == null
|| slot.Frames.Length == 0
|| deltaTime <= 0f)
{
return;
}
slot.FrameElapsed += deltaTime;
while (slot.FrameElapsed >= slot.FrameDurations[slot.FrameIndex])
{
slot.FrameElapsed -= slot.FrameDurations[slot.FrameIndex];
int nextFrame = slot.FrameIndex + 1;
if (nextFrame >= slot.Frames.Length)
{
if (!slot.Loop)
{
ClearSlot(slot);
return;
}
nextFrame = 0;
}
slot.FrameIndex = nextFrame;
slot.Renderer.sprite = slot.Frames[slot.FrameIndex];
}
}
private void ClearChargeHoldVisuals()
{
ClearSlot(aura);
ClearSlot(readyAura);
}
private static bool IsVisible(VisualSlot slot)
{
return slot.Root != null
&& slot.Renderer != null
&& slot.Renderer.enabled;
}
private SpriteRenderer FindPlayerRenderer()
{
SpriteRenderer rootRenderer = GetComponent<SpriteRenderer>();
if (rootRenderer != null)
{
return rootRenderer;
}
SpriteRenderer[] renderers = GetComponentsInChildren<SpriteRenderer>(true);
foreach (SpriteRenderer renderer in renderers)
{
if (renderer != null && renderer.transform != transform)
{
return renderer;
}
}
return GetComponent<SpriteRenderer>();
}
private static string GetResourcePath(
ActiveArtifactDefinition definition,
string suffix)
{
return ResourceRoot + definition.Effect + suffix;
}
private static void ClearSlot(VisualSlot slot)
{
if (slot.Root != null)
{
slot.Root.SetActive(false);
if (Application.isPlaying)
{
Destroy(slot.Root);
}
else
{
DestroyImmediate(slot.Root);
}
}
slot.Root = null;
slot.Renderer = null;
slot.FrameIndex = 0;
slot.FrameElapsed = 0f;
}
private static Sprite[] LoadFrames(
string resourcePath,
int expectedFrameCount)
{
if (FrameCache.TryGetValue(resourcePath, out CachedFrames cached))
{
return cached.Frames;
}
Sprite[] importedFrames = Resources.LoadAll<Sprite>(resourcePath);
if (importedFrames != null
&& importedFrames.Length >= expectedFrameCount)
{
Array.Sort(
importedFrames,
(left, right) => String.CompareOrdinal(left.name, right.name));
Sprite[] frames = new Sprite[expectedFrameCount];
Array.Copy(importedFrames, frames, expectedFrameCount);
FrameCache[resourcePath] = new CachedFrames(frames, false);
return frames;
}
Texture2D texture = importedFrames != null
&& importedFrames.Length > 0
? importedFrames[0].texture
: Resources.Load<Texture2D>(resourcePath);
if (texture == null
|| texture.width < CellSize * expectedFrameCount
|| texture.height < CellSize
|| texture.width % expectedFrameCount != 0)
{
Sprite[] missing = Array.Empty<Sprite>();
FrameCache[resourcePath] = new CachedFrames(missing, false);
return missing;
}
int frameWidth = texture.width / expectedFrameCount;
Sprite[] generatedFrames = new Sprite[expectedFrameCount];
for (int i = 0; i < expectedFrameCount; i++)
{
generatedFrames[i] = Sprite.Create(
texture,
new Rect(i * frameWidth, 0f, frameWidth, CellSize),
new Vector2(0.5f, 0.5f),
PixelsPerUnit,
0,
SpriteMeshType.FullRect);
generatedFrames[i].name =
$"{resourcePath} Frame {i + 1}";
generatedFrames[i].hideFlags = HideFlags.HideAndDontSave;
}
FrameCache[resourcePath] = new CachedFrames(generatedFrames, true);
return generatedFrames;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5de4a9b0cc4d4d5ab0e9a06ee7256bb9
@@ -0,0 +1,312 @@
using System;
using System.Collections.Generic;
using BumpCombat.Enemies;
using UnityEngine;
namespace BumpCombat.Combat
{
[DefaultExecutionOrder(1000)]
public sealed class ArtifactStatusVisual : MonoBehaviour
{
public const int StatusSortingOffset = 1;
private const int FrameCount = 6;
private const float PixelsPerUnit = 32f;
private const string IgniteResourcePath = "Artifacts/ThreeColor-v1/StatusEffects/Ignite-Warlock-v1";
private const string ShockResourcePath = "Artifacts/ThreeColor-v1/StatusEffects/Shock-Warlock-v1";
private static readonly float[] IgniteFrameDurations =
{
0.1f,
0.1f,
0.1f,
0.1f,
0.1f,
0.1f,
};
private static readonly float[] ShockFrameDurations =
{
0.06f,
0.04f,
0.07f,
0.05f,
0.07f,
0.11f,
};
private sealed class CachedFrames
{
public CachedFrames(Sprite[] frames, bool ownsSprites)
{
Frames = frames;
OwnsSprites = ownsSprites;
}
public Sprite[] Frames { get; }
public bool OwnsSprites { get; }
}
private static readonly Dictionary<string, CachedFrames> FrameCache = new();
private sealed class StatusSlot
{
public StatusSlot(
string resourcePath,
string objectName,
float[] frameDurations)
{
ResourcePath = resourcePath;
ObjectName = objectName;
FrameDurations = frameDurations;
}
public string ResourcePath { get; }
public string ObjectName { get; }
public float[] FrameDurations { get; }
public Sprite[] Frames;
public GameObject Root;
public SpriteRenderer Renderer;
public int FrameIndex;
public float FrameElapsed;
}
private readonly StatusSlot ignite = new(
IgniteResourcePath,
"Ignite Status Visual",
IgniteFrameDurations);
private readonly StatusSlot shock = new(
ShockResourcePath,
"Shock Status Visual",
ShockFrameDurations);
private EnemyController enemy;
private SpriteRenderer sourceRenderer;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetFrameCache()
{
foreach (CachedFrames cached in FrameCache.Values)
{
if (!cached.OwnsSprites)
{
continue;
}
foreach (Sprite sprite in cached.Frames)
{
if (sprite == null)
{
continue;
}
if (Application.isPlaying)
{
Destroy(sprite);
}
else
{
DestroyImmediate(sprite);
}
}
}
FrameCache.Clear();
}
private void Awake()
{
enemy = GetComponent<EnemyController>();
sourceRenderer = GetComponent<SpriteRenderer>();
}
private void OnEnable()
{
if (enemy == null)
{
enemy = GetComponent<EnemyController>();
}
if (sourceRenderer == null)
{
sourceRenderer = GetComponent<SpriteRenderer>();
}
if (enemy != null)
{
enemy.OnDied += HandleEnemyDied;
}
}
private void OnDisable()
{
if (enemy != null)
{
enemy.OnDied -= HandleEnemyDied;
}
ClearSlot(ignite);
ClearSlot(shock);
}
private void LateUpdate()
{
if (enemy == null
|| sourceRenderer == null
|| enemy.IsDead
|| !gameObject.activeInHierarchy)
{
ClearSlot(ignite);
ClearSlot(shock);
return;
}
UpdateSlot(ignite, enemy.IsIgnited);
UpdateSlot(shock, enemy.IsShocked);
}
private void HandleEnemyDied(EnemyController deadEnemy)
{
ClearSlot(ignite);
ClearSlot(shock);
}
private void UpdateSlot(StatusSlot slot, bool active)
{
if (!active)
{
ClearSlot(slot);
return;
}
if (slot.Root == null)
{
slot.Frames ??= LoadFrames(slot.ResourcePath);
if (slot.Frames.Length == 0)
{
return;
}
CreateSlot(slot);
}
SyncSlot(slot);
AdvanceSlot(slot, Time.deltaTime);
}
private void CreateSlot(StatusSlot slot)
{
slot.Root = new GameObject(slot.ObjectName);
slot.Root.transform.SetParent(transform, false);
slot.Root.transform.localPosition = Vector3.zero;
slot.Renderer = slot.Root.AddComponent<SpriteRenderer>();
slot.Renderer.color = Color.white;
slot.FrameIndex = 0;
slot.FrameElapsed = 0f;
slot.Renderer.sprite = slot.Frames[slot.FrameIndex];
}
private void SyncSlot(StatusSlot slot)
{
if (slot.Root == null || slot.Renderer == null)
{
return;
}
Vector2 groundAnchor = enemy.GroundAnchorPosition;
slot.Root.transform.SetPositionAndRotation(
new Vector3(groundAnchor.x, groundAnchor.y, transform.position.z),
Quaternion.identity);
slot.Root.transform.localScale = Vector3.one;
slot.Renderer.sortingLayerID = sourceRenderer.sortingLayerID;
slot.Renderer.sortingOrder =
sourceRenderer.sortingOrder + StatusSortingOffset;
slot.Renderer.flipX = sourceRenderer.flipX;
slot.Renderer.flipY = sourceRenderer.flipY;
slot.Renderer.sharedMaterial = sourceRenderer.sharedMaterial;
slot.Renderer.enabled = true;
}
private static void AdvanceSlot(StatusSlot slot, float deltaTime)
{
if (slot.Renderer == null
|| slot.Frames == null
|| slot.Frames.Length == 0
|| deltaTime <= 0f)
{
return;
}
slot.FrameElapsed += deltaTime;
while (slot.FrameElapsed >= slot.FrameDurations[slot.FrameIndex])
{
slot.FrameElapsed -= slot.FrameDurations[slot.FrameIndex];
slot.FrameIndex = (slot.FrameIndex + 1) % slot.Frames.Length;
slot.Renderer.sprite = slot.Frames[slot.FrameIndex];
}
}
private static void ClearSlot(StatusSlot slot)
{
if (slot.Root != null)
{
slot.Root.SetActive(false);
Destroy(slot.Root);
}
slot.Root = null;
slot.Renderer = null;
slot.FrameIndex = 0;
slot.FrameElapsed = 0f;
}
private static Sprite[] LoadFrames(string resourcePath)
{
if (FrameCache.TryGetValue(resourcePath, out CachedFrames cached))
{
return cached.Frames;
}
Sprite[] importedFrames = Resources.LoadAll<Sprite>(resourcePath);
if (importedFrames != null && importedFrames.Length >= FrameCount)
{
Sprite[] frames = new Sprite[FrameCount];
Array.Copy(importedFrames, frames, FrameCount);
FrameCache[resourcePath] = new CachedFrames(frames, false);
return frames;
}
Texture2D texture = importedFrames != null
&& importedFrames.Length > 0
? importedFrames[0].texture
: Resources.Load<Texture2D>(resourcePath);
if (texture == null
|| texture.width < FrameCount
|| texture.width % FrameCount != 0
|| texture.height <= 0)
{
Sprite[] missing = Array.Empty<Sprite>();
FrameCache[resourcePath] = new CachedFrames(missing, false);
return missing;
}
int frameWidth = texture.width / FrameCount;
Sprite[] generatedFrames = new Sprite[FrameCount];
for (int i = 0; i < FrameCount; i++)
{
generatedFrames[i] = Sprite.Create(
texture,
new Rect(i * frameWidth, 0f, frameWidth, texture.height),
new Vector2(0.5f, 0.125f),
PixelsPerUnit,
0,
SpriteMeshType.FullRect);
generatedFrames[i].name = $"{resourcePath} Frame {i + 1}";
generatedFrames[i].hideFlags = HideFlags.HideAndDontSave;
}
FrameCache[resourcePath] = new CachedFrames(generatedFrames, true);
return generatedFrames;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4a11e9bb7e724c4b8e3c1a7d0f8b6d21
@@ -0,0 +1,83 @@
using UnityEngine;
using BumpCombat.Constants;
namespace BumpCombat.Combat
{
public static class BumpCombatMath
{
public static float FrontThreshold => GameplayConstants.Current.Combat.FrontThreshold;
public static float BackThreshold => GameplayConstants.Current.Combat.BackThreshold;
public static float PositionDamageMultiplier =>
GameplayConstants.Current.Combat.PositionDamageMultiplier;
public static float BackDamageMultiplier =>
GameplayConstants.Current.Combat.BackDamageMultiplier;
public static HitSide ClassifySide(Vector2 enemyFacing, Vector2 enemyToPlayer)
{
float dot = Vector2.Dot(enemyFacing.normalized, enemyToPlayer.normalized);
if (dot >= FrontThreshold)
{
return HitSide.Front;
}
if (dot <= BackThreshold)
{
return HitSide.Back;
}
return HitSide.Side;
}
public static float CalculateLateralOffset(
Vector2 attackDirection,
Vector2 attackerPosition,
Vector2 targetPosition)
{
if (attackDirection.sqrMagnitude <= 0f)
{
return 0f;
}
Vector2 direction = attackDirection.normalized;
Vector2 perpendicular = new(-direction.y, direction.x);
return Mathf.Abs(Vector2.Dot(
targetPosition - attackerPosition,
perpendicular));
}
public static bool IsOffsetHit(
HitSide side,
Vector2 attackDirection,
Vector2 attackerPosition,
Vector2 targetPosition,
float minimumLateralOffset)
{
return side == HitSide.Front
&& CalculateLateralOffset(
attackDirection,
attackerPosition,
targetPosition) >= minimumLateralOffset;
}
public static float ApplySideMultiplier(
float baseDamage,
HitSide side,
bool isOffsetHit = false)
{
float multiplier = side == HitSide.Back
? BackDamageMultiplier
: side == HitSide.Side || isOffsetHit
? PositionDamageMultiplier
: 1f;
return DamageCalculator.ApplyMultiplier(baseDamage, multiplier);
}
public static bool AppliesPlayerImpactRecoil(
HitSide side,
bool isEventEnemy = false)
{
return side != HitSide.Back || isEventEnemy;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 82c7f2a4e56e603458720f402519a7c2
@@ -0,0 +1,526 @@
using System.Collections.Generic;
using BumpCombat.Core;
using BumpCombat.Constants;
using BumpCombat.Enemies;
using BumpCombat.Player;
using UnityEngine;
namespace BumpCombat.Combat
{
[RequireComponent(typeof(PlayerController))]
[RequireComponent(typeof(PlayerStats))]
public sealed class BumpCombatResolver : MonoBehaviour
{
private float baseDamage => GameplayConstants.Current.Combat.BaseDamage;
private float dashDamage => GameplayConstants.Current.Combat.DashDamage;
private float strongDashDamage => GameplayConstants.Current.Combat.StrongDashDamage;
private float minimumAttackSpeed => GameplayConstants.Current.Combat.MinimumAttackSpeed;
private float minimumApproachDot => GameplayConstants.Current.Combat.MinimumApproachDot;
private float knockbackForce => GameplayConstants.Current.Combat.KnockbackForce;
private float dashKnockbackForce => GameplayConstants.Current.Combat.DashKnockbackForce;
private float strongDashKnockbackForce => GameplayConstants.Current.Combat.StrongDashKnockbackForce;
private float strongDashLaunchHeight => GameplayConstants.Current.Combat.StrongDashLaunchHeight;
private float strongDashLaunchDuration => GameplayConstants.Current.Combat.StrongDashLaunchDuration;
private float playerImpactRecoilDistance => GameplayConstants.Current.Combat.PlayerImpactRecoilDistance;
private float frontKnockbackMultiplier => GameplayConstants.Current.Combat.FrontKnockbackMultiplier;
private float frontPlayerImpactRecoilDistance => GameplayConstants.Current.Combat.FrontPlayerImpactRecoilDistance;
private float eventBackPlayerImpactRecoilDistance => GameplayConstants.Current.Combat.EventBackPlayerImpactRecoilDistance;
private float shieldedPlayerImpactRecoilDistance => GameplayConstants.Current.Combat.ShieldedPlayerImpactRecoilDistance;
private float minimumOffsetDistance => GameplayConstants.Current.Combat.MinimumOffsetDistance;
private float normalHitInterval => GameplayConstants.Current.Combat.NormalHitInterval;
private float dashContactSkin => GameplayConstants.Current.Combat.DashContactSkin;
private readonly HashSet<int> dashTargets = new();
private readonly RaycastHit2D[] dashHits = new RaycastHit2D[64];
private readonly Dictionary<int, float> nextNormalHitTimes = new();
private PlayerStats playerStats;
private PlayerController playerController;
private DashController dashController;
private ActiveArtifactController activeArtifactController;
private CircleCollider2D bodyCollider;
private void Awake()
{
playerStats = GetComponent<PlayerStats>();
playerController = GetComponent<PlayerController>();
dashController = GetComponent<DashController>();
activeArtifactController = GetComponent<ActiveArtifactController>();
bodyCollider = GetComponent<CircleCollider2D>();
}
public void BeginDashPathContactSession()
{
dashTargets.Clear();
}
private void OnCollisionEnter2D(Collision2D collision)
{
TryResolveNormalCollision(collision);
}
private void OnCollisionStay2D(Collision2D collision)
{
TryResolveNormalCollision(collision);
}
private void OnTriggerEnter2D(Collider2D other)
{
TryResolveCrowdTrigger(other);
}
private void OnTriggerStay2D(Collider2D other)
{
TryResolveCrowdTrigger(other);
}
private void TryResolveNormalCollision(
Collision2D collision)
{
EnemyController enemy = collision.collider.GetComponentInParent<EnemyController>();
if (enemy == null || enemy.IsCrowd)
{
return;
}
Vector2 hitPosition = collision.contactCount > 0
? collision.GetContact(0).point
: Vector2.Lerp(transform.position, enemy.transform.position, 0.5f);
TryResolveNormalContact(enemy, hitPosition);
}
private void TryResolveCrowdTrigger(
Collider2D other)
{
EnemyController enemy = other.GetComponentInParent<EnemyController>();
if (enemy == null || !enemy.IsCrowd)
{
return;
}
Vector2 hitPosition = Vector2.Lerp(
transform.position,
enemy.transform.position,
0.5f);
TryResolveNormalContact(enemy, hitPosition);
}
private void TryResolveNormalContact(
EnemyController enemy,
Vector2 hitPosition)
{
if (!RunManager.GameplayInputEnabled
|| playerController.IsHurtMovementLocked
|| playerController.IsDamageKnockbackActive
|| dashController.IsDashing
|| (activeArtifactController != null
&& activeArtifactController.LocksMovement))
{
return;
}
if (enemy.IsDead)
{
return;
}
if (nextNormalHitTimes.TryGetValue(
enemy.GetInstanceID(),
out float nextNormalHitTime)
&& Time.time < nextNormalHitTime)
{
return;
}
Vector2 toEnemy = ((Vector2)enemy.transform.position - (Vector2)transform.position).normalized;
if (playerController.AttackIntentVelocity.magnitude < minimumAttackSpeed
|| playerController.MoveDirection.sqrMagnitude <= 0f
|| Vector2.Dot(playerController.MoveDirection, toEnemy) < minimumApproachDot)
{
return;
}
Vector2 enemyToPlayer =
((Vector2)transform.position - (Vector2)enemy.transform.position).normalized;
HitSide side = BumpCombatMath.ClassifySide(
enemy.FacingDirection,
enemyToPlayer);
if (enemy.IsGroggyTierGated && enemy.IsDamageInvulnerable)
{
playerController.ApplyImpactRecoil(
enemyToPlayer,
shieldedPlayerImpactRecoilDistance);
nextNormalHitTimes[enemy.GetInstanceID()] =
Time.time + normalHitInterval;
CombatFeedback.ShowBlockedBump(
gameObject,
hitPosition,
playerController.MoveDirection);
return;
}
ResolveEnemy(
enemy,
false,
hitPosition,
transform.position,
playerController.MoveDirection,
side);
}
public void ResolveDashPath(
Vector2 start,
Vector2 direction,
float distance,
bool isStrongDash = false,
float? damageOverride = null,
float? knockbackOverride = null,
string sourceId = null,
DamageTag damageTag = DamageTag.Collision,
ArtifactColor? artifactColor = null,
int castIdentity = 0,
ActiveArtifactEffect? artifactEffect = null)
{
ResolveDashPathInternal(
start,
direction,
distance,
isStrongDash,
damageOverride,
knockbackOverride,
sourceId,
damageTag,
artifactColor,
castIdentity,
artifactEffect,
true);
}
public void ResolveDashPathSegment(
Vector2 start,
Vector2 direction,
float distance,
bool isStrongDash = false,
float? damageOverride = null,
float? knockbackOverride = null,
string sourceId = null,
DamageTag damageTag = DamageTag.Collision,
ArtifactColor? artifactColor = null,
int castIdentity = 0,
ActiveArtifactEffect? artifactEffect = null)
{
ResolveDashPathInternal(
start,
direction,
distance,
isStrongDash,
damageOverride,
knockbackOverride,
sourceId,
damageTag,
artifactColor,
castIdentity,
artifactEffect,
false);
}
private void ResolveDashPathInternal(
Vector2 start,
Vector2 direction,
float distance,
bool isStrongDash,
float? damageOverride,
float? knockbackOverride,
string sourceId,
DamageTag damageTag,
ArtifactColor? artifactColor,
int castIdentity,
ActiveArtifactEffect? artifactEffect,
bool resetDashTargets)
{
if (resetDashTargets)
{
dashTargets.Clear();
}
Vector2 castStart = start;
float radius = GameplayConstants.Current.Combat.DashFallbackCastRadius;
if (bodyCollider != null)
{
Vector2 colliderOffset =
(Vector2)bodyCollider.bounds.center - (Vector2)transform.position;
castStart += colliderOffset;
radius = Mathf.Max(
bodyCollider.bounds.extents.x,
bodyCollider.bounds.extents.y)
+ dashContactSkin;
}
int hitCount = Physics2D.CircleCast(
castStart,
radius,
direction,
ContactFilter2D.noFilter,
dashHits,
distance);
for (int i = 0; i < hitCount; i++)
{
EnemyController enemy = dashHits[i].collider.GetComponentInParent<EnemyController>();
if (enemy == null || enemy.IsDead || !dashTargets.Add(enemy.GetInstanceID()))
{
continue;
}
Vector2 hitPosition = dashHits[i].point;
if (hitPosition == Vector2.zero)
{
hitPosition = dashHits[i].collider.ClosestPoint(
castStart + direction * dashHits[i].distance);
}
Vector2 attackerPositionAtHit =
castStart + direction * dashHits[i].distance;
ResolveEnemy(
enemy,
true,
hitPosition,
attackerPositionAtHit,
direction,
null,
isStrongDash,
damageOverride,
knockbackOverride,
sourceId,
damageTag,
artifactColor,
castIdentity,
artifactEffect);
}
}
private bool ResolveEnemy(
EnemyController enemy,
bool isDash,
Vector2 hitPosition,
Vector2 attackerPosition,
Vector2 attackDirection,
HitSide? knownSide = null,
bool isStrongDash = false,
float? damageOverride = null,
float? knockbackOverride = null,
string sourceId = null,
DamageTag damageTag = DamageTag.Collision,
ArtifactColor? artifactColor = null,
int castIdentity = 0,
ActiveArtifactEffect? artifactEffect = null)
{
Vector2 enemyToPlayer =
(attackerPosition - (Vector2)enemy.transform.position).normalized;
HitSide side = knownSide
?? BumpCombatMath.ClassifySide(enemy.FacingDirection, enemyToPlayer);
bool isEventEnemy = enemy.IsEventEnemy;
bool isOffsetHit = !isDash && BumpCombatMath.IsOffsetHit(
side,
attackDirection,
attackerPosition,
enemy.transform.position,
minimumOffsetDistance);
float rawDamage = damageOverride
?? (isStrongDash
? strongDashDamage
: isDash
? dashDamage
: baseDamage);
float finalDamage = BumpCombatMath.ApplySideMultiplier(
playerStats.CalculateDamage(rawDamage, damageTag),
side,
isOffsetHit);
Vector2 knockbackDirection = -enemyToPlayer;
bool suppressOrdinaryEventReaction = !isDash && isEventEnemy;
float appliedKnockback = knockbackOverride
?? (isStrongDash
? strongDashKnockbackForce
: isDash
? dashKnockbackForce
: side == HitSide.Front
? knockbackForce * frontKnockbackMultiplier
: knockbackForce);
if (suppressOrdinaryEventReaction)
{
appliedKnockback = 0f;
}
EnemyArtifactHitResult artifactHitResult =
EnemyArtifactHitResult.Rejected;
bool acceptedHit;
if (artifactColor.HasValue)
{
artifactHitResult = enemy.TryTakeArtifactHit(
finalDamage,
knockbackDirection,
appliedKnockback,
artifactColor.Value,
castIdentity,
!suppressOrdinaryEventReaction);
acceptedHit = artifactHitResult
!= EnemyArtifactHitResult.Rejected;
}
else
{
acceptedHit = enemy.TryTakeDamage(
finalDamage,
knockbackDirection,
appliedKnockback,
!suppressOrdinaryEventReaction,
!isDash);
}
if (!acceptedHit)
{
return false;
}
if (artifactHitResult == EnemyArtifactHitResult.Shielded)
{
if (enemy.LastArtifactContactCounted
&& artifactEffect.HasValue)
{
CombatEvents.RaiseArtifactEffectiveHit(
enemy.gameObject,
castIdentity,
artifactEffect.Value,
isStrongDash,
true);
CombatFeedback.ShowArtifactImpact(
CombatHitResult.ForArtifactContact(
gameObject,
enemy.gameObject,
artifactEffect.Value,
isStrongDash,
side,
knockbackDirection,
hitPosition,
damageTag,
sourceId,
isOffsetHit));
}
// The contact has already advanced the groggy stagger pip.
// A shielded dash must not become a bump, interrupt, launch,
// recoil, gauge charge, or damage-hit feedback.
return false;
}
float damageTaken = enemy.LastAppliedDamage;
if (damageTaken <= 0f)
{
return false;
}
if (artifactColor.HasValue && artifactEffect.HasValue)
{
CombatEvents.RaiseArtifactEffectiveHit(
enemy.gameObject,
castIdentity,
artifactEffect.Value,
isStrongDash,
false);
}
float reportedDamage = enemy.LastReportedDamage;
bool protectionBegan = enemy.IsDamageInvulnerable;
if (protectionBegan)
{
appliedKnockback = 0f;
}
if (!isDash)
{
playerController.TryPlayBumpAttackAnimation(
side == HitSide.Back);
}
if (!isDash)
{
nextNormalHitTimes[enemy.GetInstanceID()] =
Time.time + normalHitInterval;
}
bool launchSucceeded = false;
if (!protectionBegan && isStrongDash)
{
enemy.TryInterruptAttackFromLaunch();
if (CanLaunchAfterHit(enemy.CanBeLaunched, enemy.IsDead))
{
launchSucceeded = true;
enemy.PlayLaunchVisual(
knockbackDirection,
strongDashLaunchHeight,
strongDashLaunchDuration);
}
else if (ShouldShowLaunchResist(enemy.CanBeLaunched))
{
CombatEvents.RaiseLaunchResisted(enemy.gameObject);
}
}
else if (!protectionBegan && side == HitSide.Back)
{
bool broke = enemy.TryInterruptAttackFromBackHit();
if (broke && !isDash)
{
CombatEvents.RaiseRearAttackCancelled(enemy.gameObject);
}
if (suppressOrdinaryEventReaction && broke)
{
enemy.PlayHurtAnimation();
}
}
if (!isDash && !protectionBegan)
{
if (!enemy.IsCrowd
&& BumpCombatMath.AppliesPlayerImpactRecoil(side, isEventEnemy))
{
playerController.ApplyImpactRecoil(
enemyToPlayer,
side == HitSide.Front
? frontPlayerImpactRecoilDistance
: side == HitSide.Back && isEventEnemy
? eventBackPlayerImpactRecoilDistance
: playerImpactRecoilDistance);
}
activeArtifactController?.AddNormalHitCharge();
}
CombatEvents.RaiseValidHit(new CombatHitResult(
gameObject,
enemy.gameObject,
isDash,
side,
reportedDamage,
knockbackDirection,
appliedKnockback,
hitPosition,
isOffsetHit,
isStrongDash,
damageTag,
sourceId,
true,
true,
launchSucceeded,
artifactEffect.HasValue,
artifactEffect.GetValueOrDefault(),
artifactEffect.HasValue && isStrongDash));
return true;
}
public static bool CanLaunchAfterHit(bool canBeLaunched, bool isDead)
{
return canBeLaunched && !isDead;
}
public static bool ShouldShowLaunchResist(bool canBeLaunched)
{
return !canBeLaunched;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ff1740f8f4f9b1946b04764e9179117a
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8a7dac88bf4ade942a657edf639850b8
@@ -0,0 +1,224 @@
using System;
using BumpCombat.Player;
using UnityEngine;
namespace BumpCombat.Combat
{
public readonly struct CombatHitResult
{
public CombatHitResult(
GameObject attacker,
GameObject target,
bool isDash,
HitSide side,
float damage,
Vector2 knockbackDirection,
float knockbackForce,
Vector2 hitPosition,
bool isOffsetHit = false,
bool isStrongDash = false,
DamageTag damageTag = DamageTag.Collision,
string sourceId = null,
bool usesPositionBonus = true,
bool showsHitFeedback = true,
bool launchSucceeded = false,
bool isArtifactHit = false,
ActiveArtifactEffect artifactEffect = ActiveArtifactEffect.Dash,
bool isChargedArtifact = false)
{
Attacker = attacker;
Target = target;
IsDash = isDash;
Side = side;
Damage = damage;
KnockbackDirection = knockbackDirection;
KnockbackForce = knockbackForce;
HitPosition = hitPosition;
IsOffsetHit = isOffsetHit;
IsStrongDash = isStrongDash;
DamageTag = damageTag;
SourceId = sourceId;
UsesPositionBonus = usesPositionBonus;
ShowsHitFeedback = showsHitFeedback;
LaunchSucceeded = launchSucceeded;
IsArtifactHit = isArtifactHit;
ArtifactEffect = artifactEffect;
IsChargedArtifact = isChargedArtifact;
}
public static CombatHitResult ForArtifactContact(
GameObject attacker,
GameObject target,
ActiveArtifactEffect artifactEffect,
bool isChargedArtifact,
HitSide side,
Vector2 knockbackDirection,
Vector2 hitPosition,
DamageTag damageTag,
string sourceId,
bool isOffsetHit = false)
{
bool isDash = artifactEffect == ActiveArtifactEffect.Dash;
return new CombatHitResult(
attacker,
target,
isDash,
side,
0f,
knockbackDirection,
0f,
hitPosition,
isOffsetHit: isOffsetHit,
isStrongDash: isDash && isChargedArtifact,
damageTag: damageTag,
sourceId: sourceId,
usesPositionBonus: false,
showsHitFeedback: false,
launchSucceeded: false,
isArtifactHit: true,
artifactEffect: artifactEffect,
isChargedArtifact: isChargedArtifact);
}
public GameObject Attacker { get; }
public GameObject Target { get; }
public bool IsDash { get; }
public bool IsStrongDash { get; }
public DamageTag DamageTag { get; }
public string SourceId { get; }
public bool UsesPositionBonus { get; }
public bool ShowsHitFeedback { get; }
/// <summary>True only when a strong dash actually created the launch state.</summary>
public bool LaunchSucceeded { get; }
public bool IsArtifactHit { get; }
public ActiveArtifactEffect ArtifactEffect { get; }
public bool IsChargedArtifact { get; }
public bool IsOrdinaryBump => !IsDash && string.IsNullOrEmpty(SourceId);
public HitSide Side { get; }
public float Damage { get; }
public bool HasBackBonus => UsesPositionBonus && Side == HitSide.Back;
public bool IsOffsetHit { get; }
public bool HasPositionBonus =>
UsesPositionBonus && (Side == HitSide.Side || IsOffsetHit);
public Vector2 KnockbackDirection { get; }
public float KnockbackForce { get; }
public Vector2 HitPosition { get; }
}
/// <summary>A confirmed artifact effect on one enemy during one activation.</summary>
public readonly struct ArtifactEffectiveHitResult
{
public ArtifactEffectiveHitResult(
GameObject target,
int castIdentity,
ActiveArtifactEffect artifactEffect,
bool isCharged,
bool matchedShieldRequirement)
{
Target = target;
CastIdentity = castIdentity;
ArtifactEffect = artifactEffect;
IsCharged = isCharged;
MatchedShieldRequirement = matchedShieldRequirement;
}
public GameObject Target { get; }
public int CastIdentity { get; }
public ActiveArtifactEffect ArtifactEffect { get; }
public bool IsCharged { get; }
public bool MatchedShieldRequirement { get; }
}
public static class CombatEvents
{
public static event Action<CombatHitResult> OnValidHit;
public static event Action<ArtifactEffectiveHitResult> OnArtifactEffectiveHit;
public static event Action<GameObject> OnRearAttackCancelled;
public static event Action<GameObject, float> OnStunApplied;
public static event Action<GameObject, bool> OnAttackCancelled;
public static event Action<GameObject, int, int, bool> OnEnemyStagger;
public static event Action<GameObject> OnEnemyStaggerCleared;
public static event Action<GameObject> OnLaunchResisted;
public static event Action<GameObject, Vector2, Vector2> OnKnockbackCompleted;
public static event Action<GameObject> OnEnemyRetired;
public static event Action<GameObject> OnEnemyDied;
public static event Action<Vector2, float> OnThunderShield;
public static void RaiseValidHit(CombatHitResult result)
{
OnValidHit?.Invoke(result);
}
public static void RaiseArtifactEffectiveHit(
GameObject target,
int castIdentity,
ActiveArtifactEffect artifactEffect,
bool isCharged,
bool matchedShieldRequirement = false)
{
OnArtifactEffectiveHit?.Invoke(new ArtifactEffectiveHitResult(
target,
castIdentity,
artifactEffect,
isCharged,
matchedShieldRequirement));
}
public static void RaiseRearAttackCancelled(GameObject target)
{
OnRearAttackCancelled?.Invoke(target);
}
public static void RaiseStunApplied(GameObject target, float duration)
{
OnStunApplied?.Invoke(target, duration);
}
public static void RaiseAttackCancelled(GameObject target, bool fromStun)
{
OnAttackCancelled?.Invoke(target, fromStun);
}
public static void RaiseEnemyStagger(
GameObject target,
int current,
int maximum,
bool broke)
{
OnEnemyStagger?.Invoke(target, current, maximum, broke);
}
public static void RaiseEnemyStaggerCleared(GameObject target)
{
OnEnemyStaggerCleared?.Invoke(target);
}
public static void RaiseLaunchResisted(GameObject target)
{
OnLaunchResisted?.Invoke(target);
}
public static void RaiseKnockbackCompleted(
GameObject target,
Vector2 start,
Vector2 end)
{
OnKnockbackCompleted?.Invoke(target, start, end);
}
public static void RaiseEnemyRetired(GameObject target)
{
OnEnemyRetired?.Invoke(target);
}
public static void RaiseEnemyDied(GameObject target)
{
OnEnemyDied?.Invoke(target);
}
public static void RaiseThunderShield(Vector2 center, float duration)
{
OnThunderShield?.Invoke(center, duration);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: dfeb839829347ce488873cb652ceefa4
@@ -0,0 +1,71 @@
using UnityEngine;
namespace BumpCombat.Combat
{
public static class DamageCalculator
{
public static float FinalizeDamage(float damage)
{
return Mathf.Floor(damage);
}
public static string FormatDamage(float damage)
{
return FinalizeDamage(damage).ToString("0");
}
public static float ApplyMultiplier(float damage, float multiplier)
{
return damage * multiplier;
}
public static float CalculateWithAdditiveModifiers(
float baseDamage,
float flatBonus,
float increasedBonus)
{
float damage = baseDamage + flatBonus;
return ApplyMultiplier(damage, 1f + increasedBonus);
}
public static float ApplyMoreModifier(float damage, float moreBonus)
{
return ApplyMultiplier(damage, 1f + moreBonus);
}
public static float ApplyRepeatedMultiplier(
float damage,
float multiplier,
int applicationCount)
{
float result = damage;
for (int i = 0; i < applicationCount; i++)
{
result = ApplyMultiplier(result, multiplier);
}
return result;
}
public static float ApplyDamageTakenEffects(
float damage,
float shockIncrease,
bool isShocked,
float vulnerabilityIncrease,
bool isBumpVulnerable)
{
float result = damage;
if (isShocked)
{
result = ApplyMultiplier(result, 1f + shockIncrease);
}
if (isBumpVulnerable)
{
result = ApplyMultiplier(result, 1f + vulnerabilityIncrease);
}
return FinalizeDamage(result);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bb5039010a3d4f829e4bc4465959f4b5
@@ -0,0 +1,15 @@
namespace BumpCombat.Combat
{
public enum DamageTag
{
Collision,
Spell,
}
public enum ArtifactColor
{
Green,
Red,
Blue,
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 35a71b8b2a234e29b3ba91470cc7e61d
@@ -0,0 +1,9 @@
namespace BumpCombat.Combat
{
public enum HitSide
{
Front,
Side,
Back,
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0036ace962504d043a25e8c9b2b90045
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 676375fa59864e3689c85c797621e136
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,32 @@
using BumpCombat.Player;
using UnityEngine;
namespace BumpCombat.Constants
{
[CreateAssetMenu(menuName = "BumpCombat/Constants/Artifacts")]
public sealed class ArtifactConstants : ScriptableObject
{
[Header("공유 게이지")]
[Min(1f)] public float MaximumGauge = 100f;
[Min(0f)] public float StartingGauge;
[Min(0f)] public float NormalBumpGaugeGain = 4f;
[Min(0f)] public float MovementGaugeGainPerSecond = 2f;
[Header("보유 제한")]
[Tooltip("플레이어가 보유할 수 있는 아티팩트 총량. 초기 선택 수도 남은 슬롯에 맞춰 자동으로 줄어듭니다."), Min(1)]
public int MaximumOwnedArtifacts = 3;
[Min(1)] public int MaximumArtifactsPerColor = 1;
[Header("공통 효과")]
[Tooltip("Pulse Ring 기본기의 기절시간 (초)"), Min(0f)] public float PulseNormalStunDuration = 0.3f;
[Tooltip("Pulse Ring 충전기의 기절시간 (초)"), Min(0f)] public float PulseChargedStunDuration = 0.6f;
[Tooltip("Pulse Ring 경계에서 안전하게 멈추는 거리 (유닛)"), Min(0f)] public float PulseBoundaryClearance = 0.1f;
[Tooltip("Chain Lightning 투사체 가속 배율"), Min(0f)] public float ProjectileAccelerationStrength = 2f;
[Tooltip("대시 아티팩트 기본기 이동 지속시간 (초)"), Min(0.01f)] public float DashDuration = 0.1f;
[Tooltip("대시 아티팩트 충전기 이동 지속시간 (초)"), Min(0.01f)] public float ChargedDashDuration = 0.14f;
[Header("아티팩트 정의")]
[Tooltip("실행 가능 아티팩트의 정의 에셋")]
public ActiveArtifactDefinition[] Definitions = System.Array.Empty<ActiveArtifactDefinition>();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d182399864ec4f88a905bcdf5dd248c8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,41 @@
using UnityEngine;
namespace BumpCombat.Constants
{
[CreateAssetMenu(menuName = "BumpCombat/Constants/Combat")]
public sealed class CombatConstants : ScriptableObject
{
[Header("몸통박치기 방향 판정")]
[Tooltip("적 정면으로 분류하는 방향 내적 기준"), Range(-1f, 1f)] public float FrontThreshold = 0.707f;
[Tooltip("적 후방으로 분류하는 방향 내적 기준"), Range(-1f, 1f)] public float BackThreshold = -0.5f;
[Tooltip("측면 또는 정면 비껴치기 피해 배율"), Min(0f)] public float PositionDamageMultiplier = 1.2f;
[Tooltip("후방 몸통박치기 피해 배율"), Min(0f)] public float BackDamageMultiplier = 1.5f;
[Header("몸통박치기 피해")]
[Min(0f)] public float BaseDamage = 10f;
[Tooltip("대시 아티팩트 정의가 피해량을 지정하지 않을 때 사용하는 대체 피해량"), Min(0f)]
public float DashDamage = 20f;
[Tooltip("충전 대시 아티팩트 정의가 피해량을 지정하지 않을 때 사용하는 대체 피해량"), Min(0f)]
public float StrongDashDamage = 30f;
[Min(0f)] public float MinimumAttackSpeed = 0.5f;
[Range(-1f, 1f)] public float MinimumApproachDot = 0.25f;
[Header("넉백과 반동")]
[Min(0f)] public float KnockbackForce = 1f;
[Min(0f)] public float DashKnockbackForce = 3f;
[Min(0f)] public float StrongDashKnockbackForce = 4f;
[Min(0f)] public float StrongDashLaunchHeight = 0.65f;
[Min(0.01f)] public float StrongDashLaunchDuration = 0.32f;
[Min(0f)] public float PlayerImpactRecoilDistance = 0.08f;
[Min(1f)] public float FrontKnockbackMultiplier = 1.5f;
[Min(0f)] public float FrontPlayerImpactRecoilDistance = 0.24f;
[Min(0f)] public float EventBackPlayerImpactRecoilDistance = 0.12f;
[Min(0f)] public float ShieldedPlayerImpactRecoilDistance = 0.75f;
[Header("판정 간격과 범위")]
[Min(0f)] public float MinimumOffsetDistance = 0.16f;
[Min(0f)] public float NormalHitInterval = 0.25f;
[Min(0f)] public float DashContactSkin = 0.02f;
[Min(0f)] public float DashFallbackCastRadius = 0.22f;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: db153e77cef645c49a74705c9d542ea5
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,49 @@
using UnityEngine;
namespace BumpCombat.Constants
{
[CreateAssetMenu(menuName = "BumpCombat/Constants/Combat Feedback")]
public sealed class CombatFeedbackSettings : ScriptableObject
{
[Header("히트스톱 (초)")]
[Min(0f)] public float NormalHitStopDuration = 0.045f;
[Min(0f)] public float BackHitStopDuration = 0.06f;
[Min(0f)] public float DashHitStopDuration = 0.055f;
[Min(0f)] public float StrongDashHitStopDuration = 0.075f;
[Min(0f)] public float DamageHitStopDuration = 0.04f;
[Header("타격·피격 표시 (초)")]
[Min(0f)] public float FlashDuration = 0.07f;
[Min(0f)] public float DamageFlashDuration = 0.12f;
[Min(0f)] public float ImpactDuration = 0.14f;
[Min(0f)] public float SlashDuration = 0.11f;
[Min(0f)] public float AfterimageDuration = 0.16f;
[Min(0f)] public float InvulnerabilityBlinkInterval = 0.06f;
[Range(0f, 1f)] public float InvulnerabilityBlinkAlpha = 0.35f;
[Header("카메라 흔들림")]
[Tooltip("흔들림 지속시간 (초)"), Min(0f)] public float CameraShakeDuration = 0.12f;
[Tooltip("일반 흔들림 크기 (픽셀)"), Min(0f)] public float CameraShakePixels = 3f;
[Tooltip("강한 흔들림 크기 (픽셀)"), Min(0f)] public float HeavyCameraShakePixels = 5f;
[Tooltip("강한 피격으로 표시하는 넉백 거리 (유닛)"), Min(0f)] public float HeavyKnockbackThreshold = 1f;
[Header("무적 적 몸통박치기 표시")]
[Tooltip("유지시간 (초)"), Min(0f)] public float BlockedBumpHoldDuration = 0.08f;
[Tooltip("사라지는 시간 (초)"), Min(0f)] public float BlockedBumpFadeDuration = 0.04f;
[Tooltip("수직 오프셋 (유닛)")] public float BlockedBumpVerticalOffset = 0.35f;
[Header("가드 차단 불꽃")]
[Tooltip("유지시간 (초)"), Min(0f)] public float GuardBlockImpactHoldDuration = 0.08f;
[Tooltip("사라지는 시간 (초)"), Min(0f)] public float GuardBlockImpactFadeDuration = 0.1f;
[Tooltip("최소 생성 간격 (초)"), Min(0f)] public float GuardBlockImpactInterval = 0.08f;
[Tooltip("공격자 방향 오프셋 (유닛)"), Min(0f)] public float GuardBlockImpactOffset = 0.35f;
[Header("동시 표시 제한")]
[Tooltip("무거운 일회성 효과의 최대 동시 개수"), Min(1)] public int MaxConcurrentHeavyEffects = 12;
[Header("회오리 이동 버프 잔상")]
[Tooltip("최소 생성 간격 (초)"), Min(0.001f)] public float CycloneAfterimageInterval = 0.06f;
[Tooltip("잔상 유지시간 (초)"), Min(0f)] public float CycloneAfterimageDuration = 0.22f;
[Tooltip("최대 동시 잔상 수"), Min(1)] public int MaxCycloneAfterimages = 4;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 65792944731040cbae0b4c6ff5fef348
@@ -0,0 +1,60 @@
using BumpCombat.Enemies;
using UnityEngine;
namespace BumpCombat.Constants
{
[CreateAssetMenu(menuName = "BumpCombat/Constants/Enemies")]
public sealed class EnemyConstants : ScriptableObject
{
[Header("행동 판정")]
[Tooltip("원뿔 공격 판정의 방향 내적 기준"), Range(0f, 1f)] public float ConeDirectionThreshold = 0.5f;
[Tooltip("원형 범위 공격의 최소 경고시간 (초)"), Min(0f)] public float MinimumTargetCircleWarningDuration = 1.8f;
[Tooltip("적 분리 방향을 다시 계산하는 간격 (초)"), Min(0f)] public float SeparationRefreshInterval = 0.1f;
[Min(0f)] public float DefaultSeparationRadius = 0.55f;
[Min(0f)] public float DefaultSeparationStrength = 0.6f;
[Tooltip("Animator에 사망 클립이 없을 때의 대체 사망시간 (초)"), Min(0f)] public float DefaultDeathDuration = 0.5f;
[Tooltip("사망 클립 종료 뒤 오브젝트를 정리하기 전 여유시간 (초)"), Min(0f)] public float DeathRemovalPadding = 0.05f;
[Tooltip("몸 공격 상태에서 적이 전진하는 속도 (유닛/초)"), Min(0f)] public float BodyContactSpeed = 3f;
[Tooltip("적 넉백 속도가 줄어드는 비율 (유닛/초)"), Min(0f)] public float KnockbackDeceleration = 8f;
[Tooltip("군중형이 플레이어를 둘러싸며 접근하는 기준점 개수"), Min(1)] public int CrowdApproachSlotCount = 24;
[Tooltip("군중형이 접근 기준점에 배치되는 동심원 개수"), Min(1)] public int CrowdApproachRingCount = 3;
[Tooltip("군중형 접근 기준점의 가장 안쪽 반경 (유닛)"), Min(0f)] public float CrowdApproachBaseRadius = 0.75f;
[Tooltip("군중형 접근 기준점 동심원 간 간격 (유닛)"), Min(0f)] public float CrowdApproachRingSpacing = 0.15f;
[Min(0f)] public float SummonedEnemyNoAnimationLockDuration = 0.15f;
[Header("적과 플레이어 사이 거리")]
[Tooltip("일반 적이 주는 플레이어 넉백 거리 (유닛)"), Min(0f)] public float NormalPlayerKnockbackDistance = 0.75f;
[Tooltip("대형 무기 적이 주는 플레이어 넉백 거리 (유닛)"), Min(0f)] public float HeavyPlayerKnockbackDistance = 1.1f;
[Tooltip("원거리 적 공격 허용 범위 안쪽 여백 (유닛)"), Min(0f)] public float RangedAttackArenaInset = 0.75f;
[Tooltip("화면 안쪽에서 원거리 공격을 시작하기 위한 여백 (유닛)"), Min(0f)] public float RangedAttackCameraInset = 0.5f;
[Tooltip("원거리 적 접근 목표의 전장 가장자리 여백 (유닛)"), Min(0f)] public float RangedApproachArenaInset = 0.8f;
[Tooltip("원거리 적 접근 목표의 화면 여백 (유닛)"), Min(0f)] public float RangedApproachCameraInset = 0.55f;
[Header("엘리트 이상 그로기")]
[Min(0f)] public float EliteGroggyDuration = 5f;
[Min(0f)] public float MidBossGroggyDuration = 7f;
[Min(0f)] public float FinalBossGroggyDuration = 10f;
[Tooltip("엘리트 실드를 깰 때 필요한 일치색 적중 횟수"), Min(1)] public int EliteGroggyRequiredMatchingHits = 1;
[Tooltip("중간보스 실드를 깰 때 필요한 일치색 적중 횟수"), Min(1)] public int MidBossGroggyRequiredMatchingHits = 2;
[Tooltip("최종보스 실드를 깰 때 필요한 일치색 적중 횟수"), Min(1)] public int FinalBossGroggyRequiredMatchingHits = 4;
[Header("네크로맨서")]
[Min(0.5f)] public float GeneralSummonCooldown = 8f;
[Min(0.5f)] public float CrowdSummonCooldown = 12f;
[Min(0.5f)] public float MultiAoeCooldown = 4f;
[Tooltip("소환 예고와 소환 중 행동 잠금시간 (초)"), Min(0.5f)] public float SummonVisualDuration = 0.875f;
[Range(0.01f, 0.2f)] public float DesperationHealthFraction = 0.05f;
[Header("공격 토큰")]
[Min(1)] public int MaxMeleeAttackers = 4;
[Min(1)] public int MaxRangedAttackers = 3;
[Min(1)] public int MaxEarlyCrowdAttackers = 1;
[Min(1)] public int MaxLateCrowdAttackers = 2;
[Min(0f)] public float CrowdAttackExpansionTime = 180f;
[Min(0f)] public float CrowdAttackGrantInterval = 0.9f;
[Header("적 정의")]
[Tooltip("각 적의 능력치·공격 패턴 정의. 적 프리팹은 여기에 있는 에셋을 참조합니다.")]
public EnemyDefinition[] Definitions = System.Array.Empty<EnemyDefinition>();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 049a9353a64f4c50a4c7b9649bee023a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,99 @@
using UnityEngine;
namespace BumpCombat.Constants
{
[CreateAssetMenu(menuName = "BumpCombat/Constants/Gameplay Catalog")]
public sealed class GameplayConstants : ScriptableObject
{
private const string ResourcePath = "GameplayConstants";
private static GameplayConstants current;
private static PlayerConstants fallbackPlayer;
private static CombatConstants fallbackCombat;
private static EnemyConstants fallbackEnemies;
private static ItemConstants fallbackItems;
private static LevelUpConstants fallbackLevelUp;
private static ArtifactConstants fallbackArtifacts;
private static RunConstants fallbackRun;
private static CombatFeedbackSettings fallbackCombatFeedback;
[Header("설정 분류")]
[SerializeField, Tooltip("플레이어 기본 능력치, 피격, 가드 설정")]
private PlayerConstants player;
[SerializeField, Tooltip("몸통박치기 판정과 피해 설정")]
private CombatConstants combat;
[SerializeField, Tooltip("적 행동과 적 공통 설정")]
private EnemyConstants enemies;
[SerializeField, Tooltip("아이템과 경험치 구슬 설정")]
private ItemConstants items;
[SerializeField, Tooltip("경험치, 레벨업, 미션 설정")]
private LevelUpConstants levelUp;
[SerializeField, Tooltip("아티팩트 게이지와 공통 실행 설정")]
private ArtifactConstants artifacts;
[SerializeField, Tooltip("런 시간표, 스폰, 전장 설정")]
private RunConstants run;
[SerializeField, Tooltip("타격, 피격, 가드 효과의 표시 설정")]
private CombatFeedbackSettings combatFeedback;
public static GameplayConstants Current
{
get
{
if (current == null)
{
current = Resources.Load<GameplayConstants>(ResourcePath);
if (current == null)
{
current = CreateInstance<GameplayConstants>();
}
}
return current;
}
}
public PlayerConstants Player => Resolve(player, ref fallbackPlayer);
public CombatConstants Combat => Resolve(combat, ref fallbackCombat);
public EnemyConstants Enemies => Resolve(enemies, ref fallbackEnemies);
public ItemConstants Items => Resolve(items, ref fallbackItems);
public LevelUpConstants LevelUp => Resolve(levelUp, ref fallbackLevelUp);
public ArtifactConstants Artifacts => Resolve(artifacts, ref fallbackArtifacts);
public RunConstants Run => Resolve(run, ref fallbackRun);
public CombatFeedbackSettings CombatFeedback =>
Resolve(combatFeedback, ref fallbackCombatFeedback);
#if UNITY_EDITOR
public static void SetCurrentForTests(GameplayConstants value)
{
current = value;
}
public static void ResetCurrentForTests()
{
current = null;
fallbackPlayer = null;
fallbackCombat = null;
fallbackEnemies = null;
fallbackItems = null;
fallbackLevelUp = null;
fallbackArtifacts = null;
fallbackRun = null;
fallbackCombatFeedback = null;
}
#endif
private static T Resolve<T>(T value, ref T fallback) where T : ScriptableObject
{
if (value != null)
{
return value;
}
if (fallback == null)
{
fallback = CreateInstance<T>();
}
return fallback;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7a161f5df6854cfb8b5aad874b423db9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,13 @@
using UnityEngine;
namespace BumpCombat.Constants
{
[CreateAssetMenu(menuName = "BumpCombat/Constants/Items")]
public sealed class ItemConstants : ScriptableObject
{
[Header("경험치 구슬")]
[Tooltip("플레이어가 구슬을 습득하는 반경 (유닛)"), Min(0f)] public float ExperienceOrbPickupRadius = 0.35f;
[Tooltip("구슬이 플레이어를 따라오기 시작하는 반경 (유닛)"), Min(0f)] public float ExperienceOrbMagnetRadius = 2f;
[Tooltip("끌려오는 구슬의 이동속도 (유닛/초)"), Min(0f)] public float ExperienceOrbMagnetSpeed = 5f;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: badc477d7b434262b0f032cfc3da0ee6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,73 @@
using System;
using BumpCombat.Progression;
using UnityEngine;
namespace BumpCombat.Constants
{
[Serializable]
public struct MissionTuning
{
[Tooltip("미션 목록에 표시할 이름")]
public string DisplayName;
[Tooltip("목표가 포함된 조건 설명. {0}은 목표 수, {1}은 측정 시간창입니다.")]
public string ConditionFormat;
[Min(1)] public int Target;
[Min(0)] public int ExperienceReward;
public bool RequiresGuardUnlock;
public bool RequiresChargeUnlock;
public MissionTuning(
string displayName,
string conditionFormat,
int target,
int experienceReward,
bool requiresGuardUnlock = false,
bool requiresChargeUnlock = false)
{
DisplayName = displayName;
ConditionFormat = conditionFormat;
Target = target;
ExperienceReward = experienceReward;
RequiresGuardUnlock = requiresGuardUnlock;
RequiresChargeUnlock = requiresChargeUnlock;
}
}
[CreateAssetMenu(menuName = "BumpCombat/Constants/Level Up")]
public sealed class LevelUpConstants : ScriptableObject
{
[Header("경험치")]
[Tooltip("레벨 1에서 다음 레벨까지 필요한 경험치"), Min(1)] public int BaseExperienceToNextLevel = 10;
[Tooltip("레벨이 하나 오를 때마다 늘어나는 필요 경험치"), Min(0)] public int AdditionalExperiencePerLevel = 5;
[Header("레벨업 선택")]
[Tooltip("레벨업 때 표시할 모디파이어 후보 수 (UI 지원 범위: 1~3)"), Range(1, 3)] public int OptionCount = 3;
[Tooltip("레벨업 후보 모디파이어 정의 에셋")]
public ModifierDefinition[] ModifierDefinitions = Array.Empty<ModifierDefinition>();
[Header("미션")]
[Tooltip("파죽지세의 측정 시간창 (전투초)"), Min(0.1f)] public float RampageWindowSeconds = 3f;
[Tooltip("순서: 일반 적 처치, XP 구슬, 범핑, 아티팩트, 후방 범핑, 다중 적중, 공격 취소, 가드, 실드, 강화기, 파죽지세")]
public MissionTuning[] Missions =
{
new("첫 소탕", "소환수가 아닌 일반 몬스터 {0}마리 처치", 100, 10),
new("성장의 발판", "경험치 구슬 {0}개 실제 습득", 50, 10),
new("몸으로 돌파", "실제 피해가 발생한 몸통박치기 {0}회", 60, 10),
new("아티팩트 활용", "서로 다른 아티팩트 발동 {0}회에서 각각 유효 적중", 10, 10),
new("등을 노려라", "서로 다른 적 {0}마리에게 후방 몸통박치기 성공", 15, 15),
new("한 번에 몰아서", "아티팩트 한 번의 발동으로 서로 다른 적 {0}마리 유효 적중", 5, 15),
new("빈틈 차단", "일반 적의 준비/활성 공격을 후방 몸통박치기로 실제 취소 {0}회", 8, 15),
new("정확한 방어", "서로 다른 가드 발동 {0}회에서 실제 공격 차단", 3, 15, true),
new("색을 맞춰라", "맞는 색 아티팩트로 실드의 남은 요구 횟수 실제 차감 {0}회", 6, 15),
new("강화의 순간", "서로 다른 충전 강화기 발동 {0}회에서 각각 유효 적중", 3, 15, false, true),
new("파죽지세", "연속된 {1}전투초 안에 소환수가 아닌 적 {0}마리 처치", 10, 20),
};
public MissionTuning GetMission(int index, MissionTuning fallback)
{
return Missions != null && index >= 0 && index < Missions.Length
? Missions[index]
: fallback;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 031fdbfbffe244b5aae9675d923637dd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,40 @@
using UnityEngine;
namespace BumpCombat.Constants
{
[CreateAssetMenu(menuName = "BumpCombat/Constants/Player")]
public sealed class PlayerConstants : ScriptableObject
{
[Header("기본 능력치")]
[Tooltip("플레이어 기본 이동 속도 (유닛/초)"), Min(0f)] public float BaseMoveSpeed = 3f;
[Tooltip("플레이어 기본 최대 체력"), Min(1f)] public float BaseMaxHealth = 100f;
[Header("피격")]
[Tooltip("피격 뒤 무적시간 (초)"), Min(0f)] public float InvulnerabilityDuration = 1f;
[Tooltip("피격 넉백 지속시간 (초)"), Min(0f)] public float KnockbackDuration = 0.14f;
[Tooltip("Hurt 이동 잠금시간 (초)"), Min(0f)] public float HurtMovementLockDuration = 0.5f;
[Tooltip("Hurt 종료 뒤 무적에 더하는 여유시간 (초)"), Min(0f)] public float HurtRecoveryPadding = 0.5f;
[Header("연속 처치 이속 보너스")]
[Tooltip("연속 처치 인정 시간창 (게임 초)"), Min(0f)]
public float PressureStreakWindowSeconds = 3f;
[Tooltip("보너스 발동에 필요한 몸통박치기 처치 수"), Min(1)]
public int PressureStreakKillTarget = 3;
[Tooltip("연속 처치 보너스 이동속도 증가율"), Range(0f, 1f)]
public float PressureStreakMoveSpeedIncrease = 0.2f;
[Tooltip("연속 처치 보너스 지속시간 (게임 초)"), Min(0f)]
public float PressureStreakDuration = 3f;
[Header("가드")]
[Tooltip("가드 재사용 대기시간 (초)"), Min(0f)] public float GuardCooldownDuration = 10f;
[Tooltip("가드 무적 지속시간 (초)"), Min(0f)] public float GuardDuration = 1f;
[Tooltip("가드가 실제 공격을 막았을 때 회복하는 쿨타임 비율 (%)"), Range(0f, 100f)]
public float GuardSuccessCooldownRechargePercent = 70f;
[Header("전장 가장자리 fallback")]
[Tooltip("ArenaBounds가 없는 테스트 장면에서 사용하는 전장 반너비/반높이")]
public Vector2 ArenaHalfExtents = new(15f, 8.4375f);
[Tooltip("ArenaBounds가 없는 테스트 장면의 플레이어 가장자리 여백")]
public Vector2 ArenaPadding = new(0.5f, 0.625f);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2643d1587a2745d0b584eadd6b34978b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,155 @@
using System;
using BumpCombat.Core;
using BumpCombat.Enemies;
using UnityEngine;
namespace BumpCombat.Constants
{
[Serializable]
public struct SpawnDensityPhase
{
[Tooltip("다음 단계로 넘어가는 진행시간 (초). 마지막 단계는 무시됩니다.")]
public float EndTimeSeconds;
[Min(0)] public int CrowdTarget;
[Min(0)] public int NormalTarget;
public SpawnDensityPhase(float endTimeSeconds, int crowdTarget, int normalTarget)
{
EndTimeSeconds = endTimeSeconds;
CrowdTarget = crowdTarget;
NormalTarget = normalTarget;
}
}
[CreateAssetMenu(menuName = "BumpCombat/Constants/Run and Spawning")]
public sealed class RunConstants : ScriptableObject
{
[Header("런 이벤트 시간표 (초)")]
public float[] DevelopmentEliteEventTimes = { 36f, 72f, 108f, 144f, 180f, 216f };
public float[] FormalEliteEventTimes = { 180f, 360f, 540f, 720f, 900f, 1080f };
public float[] ProductionEliteEventTimes = { 180f, 360f, 540f, 720f };
[Min(0f)] public float DevelopmentProgressionMultiplier = 5f;
[Min(0f)] public float DevelopmentMidBossEventTime = 120f;
[Min(0f)] public float DevelopmentFinalBossEventTime = 240f;
[Min(0f)] public float FormalMidBossEventTime = 600f;
[Min(0f)] public float FormalFinalBossEventTime = 1200f;
[Min(0f)] public float ProductionMidBossEventTime = 600f;
[Min(0f)] public float ProductionFinalBossEventTime = 900f;
[Min(0)] public int DevelopmentStartupArtifactSelectionCount = 3;
[Min(0)] public int ProductionStartupArtifactSelectionCount = 1;
[Min(0)] public int LegacyStartupArtifactSelectionCount = 3;
[Header("스폰 밀도 단계")]
[Tooltip("순서대로 시작시점, 1분, 3분, 5분, 10분 이후의 군중형/일반형 목표 수")]
public SpawnDensityPhase[] SpawnDensity =
{
new(60f, 3, 3), new(180f, 6, 4), new(300f, 12, 4),
new(600f, 19, 5), new(float.PositiveInfinity, 27, 5),
};
[Min(0)] public int TimedEventCrowdTarget = 5;
[Min(0)] public int TimedEventNormalTarget = 3;
[Min(0)] public int FinalBossMaximumCrowdTarget = 2;
[Header("웨이브")]
[Min(1)] public int MaximumAliveEnemies = 100;
[Min(1)] public int MaximumBossEncounterEnemies = 8;
[Min(0.1f)] public float WaveInterval = 5f;
[Min(0.1f)] public float TimedEventWaveInterval = 12f;
[Min(1)] public int MaximumWaveSize = 3;
[Min(1)] public int MaximumTimedEventWaveSize = 1;
[Min(0f)] public float InitialWaveDelay = 0.5f;
[Header("실제 플레이 유입량·습격")]
[Min(0)] public int ProductionWaveCrowdSize = 2;
[Min(0)] public int ProductionWaveNormalSize = 1;
[Min(0)] public int ProductionTimedEventCrowdSize = 1;
[Min(0f)] public float ProductionSwarmInterval = 90f;
[Min(0f)] public float ProductionSwarmBossExclusionWindow = 30f;
[Min(0f)] public float ProductionSwarmEarlyEndTime = 300f;
[Min(0f)] public float ProductionSwarmMidEndTime = 600f;
[Min(0)] public int ProductionSwarmEarlyCount = 16;
[Min(0)] public int ProductionSwarmMidCount = 20;
[Min(0)] public int ProductionSwarmLateCount = 24;
[Min(1)] public int ProductionSwarmBatchSize = 4;
[Min(0.1f)] public float ProductionSwarmBatchInterval = 2f;
[Header("스폰 위치")]
[Min(0f)] public float SpawnEdgeInset = 0.25f;
[Min(0f)] public float PlayerSafeSpawnDistance = 4f;
[Min(0f)] public float SpawnCameraMargin = 0.5f;
[Min(0f)] public float SummonSpawnRadius = 2.5f;
[Min(0f)] public float SummonSpawnMinimumSeparation = 1.5f;
[Min(0f)] public float SummonSpawnPlayerClearance = 1.25f;
public Vector2 FallbackSpawnHalfExtents = new(14.5f, 7.8125f);
[Tooltip("시간 중간보스의 후보 프리팹 종류")]
public EnemyKind[] MidBossKinds = { EnemyKind.GreatswordSkeleton, EnemyKind.NecroGolem };
[Header("이벤트 적 기본 튜닝")]
public RunEventEnemyTuning EliteTuning = RunEventEnemyTuning.Create(
EnemyKind.Lancer, 3f, 30, 1.25f, Color.white, 1.05f, 1.1f, 0.85f, 1f,
0.7f, 1f, 1.1f, 1.15f, 1f, 2, 0.7f, 0.35f, 2, 0.65f, true);
public RunEventEnemyTuning MidBossTuning = RunEventEnemyTuning.Create(
EnemyKind.GreatswordSkeleton, 8f, 100, 1.6f, new Color(1f, 0.35f, 0.3f),
0.9f, 1.25f, 0.95f, 1f, 0.8f, 1.15f, 1f, 1f, 1.35f, 2, 0.7f,
0.4f, 3, 0.3f, false);
public RunEventEnemyTuning FinalBossTuning = RunEventEnemyTuning.Create(
EnemyKind.Warlock, 15f, 0, 2f, new Color(0.75f, 0.35f, 1f), 0.85f,
1.25f, 0.9f, 1f, 0.75f, 1f, 1f, 1f, 1.25f, 3, 0.75f, 0.3f,
4, 0.15f, false);
[Min(1f)] public float ProductionMidBossHealthMultiplier = 7f;
[Min(1f)] public float ProductionFinalBossHealthMultiplier = 12f;
[Tooltip("순서대로 첫 번째, 두 번째, 세 번째, 이후 시간 엘리트 체력 배율")]
public float[] ProductionEliteHealthMultipliers = { 2.5f, 3.5f, 3.5f, 5f };
[Tooltip("순서대로 첫 번째, 두 번째, 세 번째, 이후 시간 엘리트 공격 배율")]
public float[] ProductionEliteAttackDamageMultipliers = { 1f, 1f, 1.1f, 1.2f };
[Tooltip("순서대로 첫 번째, 두 번째, 세 번째, 이후 시간 엘리트 연속 공격 수")]
public int[] ProductionEliteAttacksPerSequence = { 1, 2, 3, 2 };
[Tooltip("엘리트 순환에 사용할 적 종류")]
public EnemyKind[] EliteKinds =
{
EnemyKind.ArmoredSkeleton,
EnemyKind.Werewolf,
EnemyKind.Werebear,
};
[Tooltip("개발 런에서 순서대로 적용할 엘리트 연속 공격 수")]
public int[] DevelopmentEliteAttacksPerSequence = { 2, 2, 3 };
[Tooltip("생산 런에서 순서대로 적용할 엘리트 경고시간 배율")]
public float[] ProductionEliteWarningDurationMultipliers = { 1f, 0.85f, 0.85f, 0.85f };
[Tooltip("생산 런에서 순서대로 적용할 엘리트 회복시간 배율")]
public float[] ProductionEliteRecoveryDurationMultipliers = { 1f, 0.7f, 0.7f, 0.7f };
[Header("전장")]
public Vector2 ArenaBackgroundHalfExtents = new(15f, 8.4375f);
public Vector2 ArenaPlayableHalfExtents = new(15f, 8.4375f);
public Vector2 ArenaPlayerPadding = new(0.5f, 0.625f);
public Vector2 ArenaCameraViewHalfExtents = new(10f, 5.625f);
public Vector2 ArenaCameraDeadZone = new(0.5f, 0.3f);
[Min(0f)] public float ArenaCameraSmoothTime = 0.15f;
public float[] EliteTimes(bool development, bool production)
{
float[] times = production
? ProductionEliteEventTimes
: development ? DevelopmentEliteEventTimes : FormalEliteEventTimes;
return times ?? Array.Empty<float>();
}
public SpawnDensityPhase GetSpawnDensity(float elapsedTime)
{
if (SpawnDensity == null || SpawnDensity.Length == 0)
{
return new SpawnDensityPhase(float.PositiveInfinity, 27, 5);
}
for (int i = 0; i < SpawnDensity.Length - 1; i++)
{
if (elapsedTime < SpawnDensity[i].EndTimeSeconds)
{
return SpawnDensity[i];
}
}
return SpawnDensity[SpawnDensity.Length - 1];
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 42e84bae00be47f387921d3e618ed6a8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4ad3c73befbdc67418a27dafec62cf8b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+153
View File
@@ -0,0 +1,153 @@
using BumpCombat.Constants;
using UnityEngine;
namespace BumpCombat.Core
{
/// <summary>
/// Shared world-space geometry for the finite first map. Player movement,
/// enemy movement, spawning, and camera framing all resolve this component
/// instead of keeping independent copies of the arena extents.
/// </summary>
public sealed class ArenaBounds : MonoBehaviour
{
public static ArenaBounds Instance { get; private set; }
public Vector2 BackgroundHalfExtents => GameplayConstants.Current.Run.ArenaBackgroundHalfExtents;
public Vector2 PlayableHalfExtents => GameplayConstants.Current.Run.ArenaPlayableHalfExtents;
public Vector2 PlayerPadding => GameplayConstants.Current.Run.ArenaPlayerPadding;
public Vector2 CameraViewHalfExtents => GameplayConstants.Current.Run.ArenaCameraViewHalfExtents;
public Vector2 PlayerClampHalfExtents => new(
Mathf.Max(0f, PlayableHalfExtents.x - PlayerPadding.x),
Mathf.Max(0f, PlayableHalfExtents.y - PlayerPadding.y));
// The player and enemies share the full floor with one small common
// ground-anchor clearance so the player body stays inside the view.
// Collider size is intentionally not subtracted here: doing so made
// large enemies stop short of the exact edge the player could reach.
public Vector2 SharedActorClampHalfExtents => PlayerClampHalfExtents;
public Vector2 CameraCenterHalfExtents => new(
Mathf.Max(0f, BackgroundHalfExtents.x - CameraViewHalfExtents.x),
Mathf.Max(0f, BackgroundHalfExtents.y - CameraViewHalfExtents.y));
private void Awake()
{
Instance = this;
}
private void OnDestroy()
{
if (Instance == this)
{
Instance = null;
}
}
public static ArenaBounds Resolve()
{
return Instance != null
? Instance
: FindAnyObjectByType<ArenaBounds>();
}
public Vector2 ClampPlayerPosition(Vector2 position)
{
return ClampToHalfExtents(position, PlayerClampHalfExtents);
}
public Vector2 ClampEnemyPosition(Vector2 position, float edgeInset = 0f)
{
Vector2 extents = GetReachableHalfExtents(edgeInset);
return ClampToHalfExtents(position, extents);
}
public Vector2 ClampEnemyPosition(
Vector2 position,
Collider2D bodyCollider,
float edgeInset = 0f)
{
if (bodyCollider == null || !bodyCollider.enabled)
{
return ClampEnemyPosition(position, edgeInset);
}
// Enemy movement follows the same ground-anchor boundary as the
// player. The previous collider-aware clamp introduced a second,
// scale-dependent boundary that was especially visible on elites
// and bosses at the edge of the map.
return ClampToHalfExtents(position, GetReachableHalfExtents(edgeInset));
}
public Vector2 GetReachableHalfExtents(float edgeInset = 0f)
{
return new Vector2(
Mathf.Max(0f, PlayerClampHalfExtents.x - Mathf.Max(0f, edgeInset)),
Mathf.Max(0f, PlayerClampHalfExtents.y - Mathf.Max(0f, edgeInset)));
}
public bool IsInsideReachableArena(Vector2 position, float inset = 0f)
{
Vector2 extents = GetReachableHalfExtents(inset);
return Mathf.Abs(position.x) <= extents.x + 0.0001f
&& Mathf.Abs(position.y) <= extents.y + 0.0001f;
}
public Vector2 ClampCameraCenter(Vector2 position)
{
return ClampToHalfExtents(position, CameraCenterHalfExtents);
}
public Vector2 ClampCameraCenter(
Vector2 position,
Vector2 viewHalfExtents)
{
Vector2 centerHalfExtents = new(
Mathf.Max(0f, BackgroundHalfExtents.x - Mathf.Abs(viewHalfExtents.x)),
Mathf.Max(0f, BackgroundHalfExtents.y - Mathf.Abs(viewHalfExtents.y)));
return ClampToHalfExtents(position, centerHalfExtents);
}
public Vector2 GetCameraVisibleHalfExtents(Camera camera)
{
if (camera == null || !camera.orthographic)
{
return CameraViewHalfExtents;
}
return new Vector2(
Mathf.Abs(camera.orthographicSize * camera.aspect),
Mathf.Abs(camera.orthographicSize));
}
public Bounds GetCameraVisibleBounds(Camera camera, float inset = 0f)
{
Vector2 halfExtents = GetCameraVisibleHalfExtents(camera);
float safeInset = Mathf.Max(0f, inset);
halfExtents.x = Mathf.Max(0f, halfExtents.x - safeInset);
halfExtents.y = Mathf.Max(0f, halfExtents.y - safeInset);
Vector3 center = camera != null ? camera.transform.position : Vector3.zero;
return new Bounds(
new Vector3(center.x, center.y, 0f),
new Vector3(halfExtents.x * 2f, halfExtents.y * 2f, 0f));
}
public bool IsInsideCameraVisible(
Camera camera,
Vector2 position,
float inset = 0f)
{
Bounds visible = GetCameraVisibleBounds(camera, inset);
return position.x >= visible.min.x
&& position.x <= visible.max.x
&& position.y >= visible.min.y
&& position.y <= visible.max.y;
}
private static Vector2 ClampToHalfExtents(
Vector2 position,
Vector2 halfExtents)
{
position.x = Mathf.Clamp(position.x, -halfExtents.x, halfExtents.x);
position.y = Mathf.Clamp(position.y, -halfExtents.y, halfExtents.y);
return position;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0e94a2b4f2e44ac0a7c4e0b2fd1c7e91
@@ -0,0 +1,263 @@
using BumpCombat.Constants;
using BumpCombat.Player;
using UnityEngine;
using UnityEngine.Rendering;
namespace BumpCombat.Core
{
[RequireComponent(typeof(Camera))]
public sealed class ArenaCameraFollow : MonoBehaviour
{
[SerializeField] private Transform target;
[SerializeField] private ArenaBounds bounds;
private Camera worldCamera;
private Vector2 currentCenter;
private Vector2 smoothVelocity;
private Vector2 externalShakeOffset;
private bool initialized;
private RunManager runManager;
public Vector2 CurrentCenter => currentCenter;
public Vector2 DeadZone => GameplayConstants.Current.Run.ArenaCameraDeadZone;
public float SmoothTime => GameplayConstants.Current.Run.ArenaCameraSmoothTime;
public Vector2 ExternalShakeOffset => externalShakeOffset;
private void Awake()
{
worldCamera = GetComponent<Camera>();
ResolveBounds();
currentCenter = bounds != null
? bounds.ClampCameraCenter(
new Vector2(transform.position.x, transform.position.y),
bounds.GetCameraVisibleHalfExtents(worldCamera))
: new Vector2(transform.position.x, transform.position.y);
initialized = true;
}
private void OnEnable()
{
RegisterNativeViewportCorrection();
}
private void Start()
{
RegisterNativeViewportCorrection();
ResolveTarget();
runManager = RunManager.Instance;
if (runManager != null)
{
runManager.OnRunStarted += HandleRunStarted;
}
ApplyCameraPosition();
}
private void OnDisable()
{
UnregisterNativeViewportCorrection();
}
private void OnDestroy()
{
UnregisterNativeViewportCorrection();
if (runManager != null)
{
runManager.OnRunStarted -= HandleRunStarted;
}
}
private void RegisterNativeViewportCorrection()
{
RenderPipelineManager.beginCameraRendering -= HandleBeginCameraRendering;
RenderPipelineManager.beginCameraRendering += HandleBeginCameraRendering;
}
private void UnregisterNativeViewportCorrection()
{
RenderPipelineManager.beginCameraRendering -= HandleBeginCameraRendering;
}
private void HandleBeginCameraRendering(
ScriptableRenderContext context,
Camera camera)
{
if (camera != worldCamera)
{
return;
}
int targetWidth = camera.targetTexture != null
? camera.targetTexture.width
: Screen.width;
int targetHeight = camera.targetTexture != null
? camera.targetTexture.height
: Screen.height;
int evenWidth = targetWidth & ~1;
int evenHeight = targetHeight & ~1;
if (targetWidth < 2
|| targetHeight < 2
|| (evenWidth == targetWidth && evenHeight == targetHeight))
{
return;
}
Rect currentPixelRect = camera.pixelRect;
if (Mathf.Abs(currentPixelRect.x) < 0.01f
&& Mathf.Abs(currentPixelRect.y) < 0.01f
&& Mathf.Abs(currentPixelRect.width - evenWidth) < 0.01f
&& Mathf.Abs(currentPixelRect.height - evenHeight) < 0.01f)
{
return;
}
camera.pixelRect = new Rect(0f, 0f, evenWidth, evenHeight);
camera.ResetAspect();
camera.orthographicSize *= evenHeight / (float)targetHeight;
}
private void LateUpdate()
{
if (!initialized)
{
return;
}
ResolveBounds();
ResolveTarget();
if (RunManager.GameplayInputEnabled && target != null)
{
Vector2 desiredCenter = GetDeadZoneTargetCenter(
currentCenter,
target.position,
DeadZone);
if (bounds != null)
{
desiredCenter = bounds.ClampCameraCenter(
desiredCenter,
bounds.GetCameraVisibleHalfExtents(worldCamera));
}
float deltaTime = Time.deltaTime;
// During hit stop the player and camera must hold their
// current state. SmoothDamp receives only positive scaled
// delta time, avoiding an invalid velocity update at zero.
if (deltaTime > 0f)
{
currentCenter = SmoothTime <= 0.0001f
? desiredCenter
: Vector2.SmoothDamp(
currentCenter,
desiredCenter,
ref smoothVelocity,
SmoothTime,
Mathf.Infinity,
deltaTime);
if (bounds != null)
{
currentCenter = bounds.ClampCameraCenter(
currentCenter,
bounds.GetCameraVisibleHalfExtents(worldCamera));
}
}
}
ApplyCameraPosition();
}
public void SetTarget(Transform newTarget)
{
target = newTarget;
}
public void SetExternalShakeOffset(Vector2 offset)
{
externalShakeOffset = offset;
ApplyCameraPosition();
}
public void ClearExternalShakeOffset()
{
externalShakeOffset = Vector2.zero;
ApplyCameraPosition();
}
public void ResetCenter(Vector2 center = default)
{
ResolveBounds();
currentCenter = bounds != null
? bounds.ClampCameraCenter(
center,
bounds.GetCameraVisibleHalfExtents(worldCamera))
: center;
smoothVelocity = Vector2.zero;
externalShakeOffset = Vector2.zero;
ApplyCameraPosition();
}
private void HandleRunStarted()
{
ResetCenter(Vector2.zero);
}
public static Vector2 GetDeadZoneTargetCenter(
Vector2 center,
Vector2 targetPosition,
Vector2 deadZone = default)
{
Vector2 result = center;
if (deadZone == default)
{
deadZone = GameplayConstants.Current.Run.ArenaCameraDeadZone;
}
float deltaX = targetPosition.x - center.x;
if (Mathf.Abs(deltaX) > deadZone.x)
{
result.x += deltaX - Mathf.Sign(deltaX) * deadZone.x;
}
float deltaY = targetPosition.y - center.y;
if (Mathf.Abs(deltaY) > deadZone.y)
{
result.y += deltaY - Mathf.Sign(deltaY) * deadZone.y;
}
return result;
}
private void ResolveBounds()
{
if (bounds == null)
{
bounds = ArenaBounds.Resolve();
}
}
private void ResolveTarget()
{
if (target == null)
{
PlayerController player = FindAnyObjectByType<PlayerController>();
if (player != null)
{
target = player.transform;
}
}
}
private void ApplyCameraPosition()
{
Vector2 finalCenter = currentCenter + externalShakeOffset;
if (bounds != null)
{
finalCenter = bounds.ClampCameraCenter(
finalCenter,
bounds.GetCameraVisibleHalfExtents(worldCamera));
}
Vector3 position = transform.position;
position.x = finalCenter.x;
position.y = finalCenter.y;
transform.position = position;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5f8d8aa54b4c4b449a7b4b6a7c4d31ef
@@ -0,0 +1,299 @@
using System;
using System.Collections.Generic;
using BumpCombat.Combat;
using UnityEngine;
namespace BumpCombat.Core
{
// The former PlayerStat values remain stable for serialized ModifierDefinition assets.
public enum CharacterStat
{
CollisionDamage = 0,
MoveSpeed = 1,
MaxHealth = 2,
Armor = 3,
LifeSteal = 4,
ExperienceGain = 5,
ExperiencePickupRange = 6,
ArtifactGaugeGain = 7,
IncomingDamage = 8,
AttackDamage = 9,
}
public enum ModifierOperation
{
Flat = 0,
Increased = 1,
More = 2,
}
public readonly struct StatModifier
{
public StatModifier(
string sourceId,
CharacterStat stat,
ModifierOperation operation,
float value,
int maxStacks = 1)
{
if (string.IsNullOrWhiteSpace(sourceId))
{
throw new ArgumentException(
"A modifier source ID is required.",
nameof(sourceId));
}
if (maxStacks < 1)
{
throw new ArgumentOutOfRangeException(
nameof(maxStacks),
"Maximum stacks must be at least one.");
}
SourceId = sourceId;
Stat = stat;
Operation = operation;
Value = value;
MaxStacks = maxStacks;
}
public string SourceId { get; }
public CharacterStat Stat { get; }
public ModifierOperation Operation { get; }
public float Value { get; }
public int MaxStacks { get; }
}
public readonly struct AppliedStatModifier
{
internal AppliedStatModifier(StatModifier modifier, int currentStacks)
{
SourceId = modifier.SourceId;
Stat = modifier.Stat;
Operation = modifier.Operation;
Value = modifier.Value;
CurrentStacks = currentStacks;
MaxStacks = modifier.MaxStacks;
}
public string SourceId { get; }
public CharacterStat Stat { get; }
public ModifierOperation Operation { get; }
public float Value { get; }
public int CurrentStacks { get; }
public int MaxStacks { get; }
}
[DisallowMultipleComponent]
public abstract class CharacterModel : MonoBehaviour
{
private sealed class ModifierEntry
{
public ModifierEntry(StatModifier modifier)
{
Modifier = modifier;
CurrentStacks = 1;
}
public StatModifier Modifier { get; }
public int CurrentStacks { get; set; }
}
private readonly List<ModifierEntry> modifiers = new();
public event Action<CharacterStat> OnStatChanged;
public abstract float MoveSpeed { get; }
public abstract float MaxHealth { get; }
public bool AddModifier(StatModifier modifier)
{
ModifierEntry existing = FindMatchingEntry(modifier);
if (existing != null)
{
if (existing.CurrentStacks >= existing.Modifier.MaxStacks)
{
return false;
}
existing.CurrentStacks++;
}
else
{
modifiers.Add(new ModifierEntry(modifier));
}
OnStatChanged?.Invoke(modifier.Stat);
return true;
}
public int RemoveModifiersFromSource(string sourceId)
{
if (string.IsNullOrWhiteSpace(sourceId))
{
return 0;
}
int removedStacks = 0;
HashSet<CharacterStat> changedStats = new();
for (int i = modifiers.Count - 1; i >= 0; i--)
{
ModifierEntry entry = modifiers[i];
if (!string.Equals(
entry.Modifier.SourceId,
sourceId,
StringComparison.Ordinal))
{
continue;
}
removedStacks += entry.CurrentStacks;
changedStats.Add(entry.Modifier.Stat);
modifiers.RemoveAt(i);
}
foreach (CharacterStat stat in changedStats)
{
OnStatChanged?.Invoke(stat);
}
return removedStacks;
}
public int GetStackCount(string sourceId, CharacterStat stat)
{
int stackCount = 0;
foreach (ModifierEntry entry in modifiers)
{
if (entry.Modifier.Stat == stat
&& string.Equals(
entry.Modifier.SourceId,
sourceId,
StringComparison.Ordinal))
{
stackCount += entry.CurrentStacks;
}
}
return stackCount;
}
public IReadOnlyList<AppliedStatModifier> GetAppliedModifiers()
{
List<AppliedStatModifier> snapshot = new(modifiers.Count);
foreach (ModifierEntry entry in modifiers)
{
snapshot.Add(new AppliedStatModifier(
entry.Modifier,
entry.CurrentStacks));
}
return snapshot;
}
public float Evaluate(CharacterStat stat, float baseValue)
{
bool isDamageStat = stat == CharacterStat.CollisionDamage
|| stat == CharacterStat.AttackDamage
|| stat == CharacterStat.IncomingDamage;
float flat = 0f;
float increased = 0f;
float moreMultiplier = 1f;
foreach (ModifierEntry entry in modifiers)
{
if (entry.Modifier.Stat != stat)
{
continue;
}
float stackedValue =
entry.Modifier.Value * entry.CurrentStacks;
switch (entry.Modifier.Operation)
{
case ModifierOperation.Flat:
flat += stackedValue;
break;
case ModifierOperation.Increased:
increased += stackedValue;
break;
case ModifierOperation.More:
if (!isDamageStat)
{
for (int i = 0; i < entry.CurrentStacks; i++)
{
moreMultiplier *= 1f + entry.Modifier.Value;
}
}
break;
}
}
if (isDamageStat)
{
float damage = DamageCalculator.CalculateWithAdditiveModifiers(
baseValue,
flat,
increased);
foreach (ModifierEntry entry in modifiers)
{
if (entry.Modifier.Stat != stat
|| entry.Modifier.Operation != ModifierOperation.More)
{
continue;
}
for (int i = 0; i < entry.CurrentStacks; i++)
{
damage = DamageCalculator.ApplyMoreModifier(
damage,
entry.Modifier.Value);
}
}
return damage;
}
return (baseValue + flat) * (1f + increased) * moreMultiplier;
}
public float CalculateAttackDamage(float baseDamage)
{
return Evaluate(CharacterStat.AttackDamage, baseDamage);
}
public float CalculateDamage(float baseDamage, DamageTag damageTag)
{
float damage = CalculateAttackDamage(baseDamage);
return damageTag == DamageTag.Collision
? Evaluate(CharacterStat.CollisionDamage, damage)
: damage;
}
public float CalculateIncomingDamage(float baseDamage)
{
return Evaluate(CharacterStat.IncomingDamage, baseDamage);
}
private ModifierEntry FindMatchingEntry(StatModifier modifier)
{
foreach (ModifierEntry entry in modifiers)
{
StatModifier existing = entry.Modifier;
if (existing.Stat == modifier.Stat
&& existing.Operation == modifier.Operation
&& existing.MaxStacks == modifier.MaxStacks
&& Mathf.Approximately(existing.Value, modifier.Value)
&& string.Equals(
existing.SourceId,
modifier.SourceId,
StringComparison.Ordinal))
{
return entry;
}
}
return null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d967ab9fbc194171a194a20d7e794ca1
+514
View File
@@ -0,0 +1,514 @@
using System;
using BumpCombat.Constants;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.SceneManagement;
namespace BumpCombat.Core
{
public enum RunTimedEvent
{
Elite,
MidBoss,
FinalBoss,
}
public enum RunMode
{
Development,
Production,
}
public sealed class RunManager : MonoBehaviour
{
[SerializeField] private bool useDebugEventTimes = true;
private RunMode selectedRunMode;
private bool hasSelectedRunMode;
private bool guardUnlocked = true;
private bool artifactChargeUnlocked = true;
private bool gameOver;
private bool titleOpen;
private bool pauseOpen;
private bool selectionOpen;
private MonoBehaviour selectionOwner;
private int pauseOpenSuppressedFrame = -1;
private int nextEliteEventIndex;
private bool midBossEventRaised;
private bool finalBossEventRaised;
private static bool skipTitleOnNextLoad;
private static RunMode? runModeOnNextLoad;
public static RunManager Instance { get; private set; }
public static bool GameplayInputEnabled =>
Instance == null || (!Instance.gameOver
&& !Instance.titleOpen
&& !Instance.pauseOpen
&& !Instance.selectionOpen);
public event Action<float> OnTimeChanged;
public event Action<RunTimedEvent> OnTimedEvent;
public event Action<bool> OnSelectionChanged;
public event Action<bool> OnTitleChanged;
public event Action OnRunStarted;
public event Action<bool> OnGuardAvailabilityChanged;
public event Action<bool> OnArtifactChargeAvailabilityChanged;
public event Action<bool> OnPauseChanged;
public event Action OnGameOver;
public float ElapsedTime { get; private set; }
public float ProgressionTime =>
GetProgressionTime(ElapsedTime, UseDebugEventTimes);
public bool IsGameOver => gameOver;
public bool IsTitleScreen => titleOpen;
public bool IsPaused => pauseOpen;
public bool IsSelectionOpen => selectionOpen;
public bool UseDebugEventTimes => hasSelectedRunMode
? selectedRunMode == RunMode.Development
: useDebugEventTimes;
public bool IsProductionRun => hasSelectedRunMode
&& selectedRunMode == RunMode.Production;
public bool IsDevelopmentRun => hasSelectedRunMode
&& selectedRunMode == RunMode.Development;
public bool IsGuardUnlocked => !IsProductionRun || guardUnlocked;
public bool IsArtifactChargeUnlocked => !IsProductionRun
|| artifactChargeUnlocked;
public int StartupArtifactSelectionCount => hasSelectedRunMode
? selectedRunMode == RunMode.Development
? GameplayConstants.Current.Run.DevelopmentStartupArtifactSelectionCount
: GameplayConstants.Current.Run.ProductionStartupArtifactSelectionCount
: GameplayConstants.Current.Run.LegacyStartupArtifactSelectionCount;
public int RaisedEliteEventCount => nextEliteEventIndex;
public bool UseDebugArtifactSelection
{
get
{
#if UNITY_EDITOR
if (ForceProductionModeForTests)
{
return false;
}
#endif
if (hasSelectedRunMode)
{
return selectedRunMode == RunMode.Development;
}
return useDebugEventTimes || Debug.isDebugBuild;
}
}
// Legacy runs keep the prototype's three-color startup. Explicit menu
// modes choose their own selection count through StartupArtifactSelectionCount.
public bool UseArtifactStartupSelection => true;
#if UNITY_EDITOR
public static bool ForceProductionModeForTests { get; set; }
#endif
private void Awake()
{
Instance = this;
bool skipTitle = skipTitleOnNextLoad;
skipTitleOnNextLoad = false;
hasSelectedRunMode = runModeOnNextLoad.HasValue;
if (hasSelectedRunMode)
{
selectedRunMode = runModeOnNextLoad.Value;
}
runModeOnNextLoad = null;
titleOpen = !skipTitle;
guardUnlocked = !IsProductionRun;
artifactChargeUnlocked = !IsProductionRun;
#if UNITY_EDITOR
// Existing scene tests grant the catalog directly and expect the
// simulation to run without a front-end interaction.
if (BumpCombat.Progression.ArtifactRewardController.GrantCatalogForTests)
{
titleOpen = false;
}
#endif
RefreshTimeScale();
if (FindAnyObjectByType<BumpCombat.Progression.ArtifactRewardController>() != null)
{
EnsurePresentationServices();
return;
}
Canvas canvas = FindAnyObjectByType<Canvas>();
if (canvas != null)
{
canvas.gameObject.AddComponent<BumpCombat.Progression.ArtifactRewardController>();
}
EnsurePresentationServices();
}
private void EnsurePresentationServices()
{
Canvas canvas = FindAnyObjectByType<Canvas>();
if (canvas == null)
{
return;
}
if (canvas.GetComponent<BumpCombat.Audio.BumpCombatAudioService>() == null)
{
canvas.gameObject.AddComponent<BumpCombat.Audio.BumpCombatAudioService>();
}
if (canvas.GetComponent<BumpCombat.UI.RunMenuController>() == null)
{
canvas.gameObject.AddComponent<BumpCombat.UI.RunMenuController>();
}
if (canvas.GetComponent<BumpCombat.UI.RunTutorialController>() == null)
{
canvas.gameObject.AddComponent<BumpCombat.UI.RunTutorialController>();
}
if (canvas.GetComponent<BumpCombat.Progression.RunMissionTracker>() == null)
{
canvas.gameObject.AddComponent<BumpCombat.Progression.RunMissionTracker>();
}
if (canvas.GetComponent<BumpCombat.UI.MissionAchievementPanel>() == null)
{
canvas.gameObject.AddComponent<BumpCombat.UI.MissionAchievementPanel>();
}
}
private void OnDestroy()
{
if (Instance == this)
{
Instance = null;
}
}
private void Update()
{
if (gameOver)
{
if (Keyboard.current?.rKey.wasPressedThisFrame == true)
{
RestartRun();
}
return;
}
if (selectionOpen)
{
return;
}
if (titleOpen || pauseOpen)
{
return;
}
ElapsedTime += Time.deltaTime;
OnTimeChanged?.Invoke(ElapsedTime);
RaiseTimedEvents();
}
public void SetSelectionOpen(bool isOpen)
{
if (selectionOpen == isOpen)
{
return;
}
selectionOpen = isOpen;
selectionOwner = null;
RefreshTimeScale();
OnSelectionChanged?.Invoke(isOpen);
}
public bool TryOpenSelection(MonoBehaviour owner)
{
if (owner == null || titleOpen || selectionOpen || gameOver || pauseOpen)
{
return false;
}
selectionOwner = owner;
selectionOpen = true;
RefreshTimeScale();
OnSelectionChanged?.Invoke(true);
return true;
}
public bool CloseSelection(MonoBehaviour owner)
{
if (!selectionOpen || selectionOwner != owner)
{
return false;
}
selectionOwner = null;
selectionOpen = false;
RefreshTimeScale();
OnSelectionChanged?.Invoke(false);
return true;
}
public bool BeginRun()
{
if (gameOver || !titleOpen)
{
return false;
}
return BeginRunInternal();
}
public bool BeginRun(RunMode mode)
{
if (gameOver || !titleOpen)
{
return false;
}
selectedRunMode = mode;
hasSelectedRunMode = true;
guardUnlocked = mode != RunMode.Production;
artifactChargeUnlocked = mode != RunMode.Production;
OnGuardAvailabilityChanged?.Invoke(IsGuardUnlocked);
OnArtifactChargeAvailabilityChanged?.Invoke(IsArtifactChargeUnlocked);
return BeginRunInternal();
}
private bool BeginRunInternal()
{
titleOpen = false;
RefreshTimeScale();
OnTitleChanged?.Invoke(false);
OnRunStarted?.Invoke();
return true;
}
public void UnlockGuard()
{
if (!IsProductionRun || guardUnlocked)
{
return;
}
guardUnlocked = true;
OnGuardAvailabilityChanged?.Invoke(true);
}
public void UnlockArtifactCharge()
{
if (!IsProductionRun || artifactChargeUnlocked)
{
return;
}
artifactChargeUnlocked = true;
OnArtifactChargeAvailabilityChanged?.Invoke(true);
}
public bool TryOpenPause()
{
if (gameOver
|| titleOpen
|| selectionOpen
|| pauseOpen
|| Time.frameCount == pauseOpenSuppressedFrame)
{
return false;
}
pauseOpen = true;
RefreshTimeScale();
OnPauseChanged?.Invoke(true);
return true;
}
public void SuppressPauseOpeningForCurrentFrame()
{
pauseOpenSuppressedFrame = Time.frameCount;
}
public bool ClosePause()
{
if (!pauseOpen)
{
return false;
}
pauseOpen = false;
RefreshTimeScale();
OnPauseChanged?.Invoke(false);
return true;
}
public void RestartRun()
{
runModeOnNextLoad = hasSelectedRunMode
? selectedRunMode
: null;
skipTitleOnNextLoad = true;
Time.timeScale = 1f;
SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
}
public void RestartToTitle()
{
runModeOnNextLoad = null;
skipTitleOnNextLoad = false;
Time.timeScale = 1f;
SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
}
public void EndRun()
{
if (gameOver)
{
return;
}
gameOver = true;
pauseOpen = false;
RefreshTimeScale();
OnGameOver?.Invoke();
}
private void RefreshTimeScale()
{
Time.timeScale = gameOver || titleOpen || pauseOpen || selectionOpen
? 0f
: 1f;
}
private void RaiseTimedEvents()
{
float[] eliteTimes = CurrentEliteEventTimes;
float midBossTime = GetEventTimeForCurrentRun(RunTimedEvent.MidBoss);
float finalBossTime = GetEventTimeForCurrentRun(RunTimedEvent.FinalBoss);
while (nextEliteEventIndex < eliteTimes.Length
&& ElapsedTime >= eliteTimes[nextEliteEventIndex])
{
nextEliteEventIndex++;
OnTimedEvent?.Invoke(RunTimedEvent.Elite);
}
if (!midBossEventRaised && ElapsedTime >= midBossTime)
{
midBossEventRaised = true;
OnTimedEvent?.Invoke(RunTimedEvent.MidBoss);
}
if (!finalBossEventRaised && ElapsedTime >= finalBossTime)
{
finalBossEventRaised = true;
OnTimedEvent?.Invoke(RunTimedEvent.FinalBoss);
}
}
public static float GetEventTime(
RunTimedEvent timedEvent,
bool useDebugTimes)
{
return timedEvent switch
{
RunTimedEvent.Elite => FirstEliteTime(useDebugTimes, false),
RunTimedEvent.MidBoss => useDebugTimes
? GameplayConstants.Current.Run.DevelopmentMidBossEventTime
: GameplayConstants.Current.Run.FormalMidBossEventTime,
RunTimedEvent.FinalBoss => useDebugTimes
? GameplayConstants.Current.Run.DevelopmentFinalBossEventTime
: GameplayConstants.Current.Run.FormalFinalBossEventTime,
_ => float.PositiveInfinity,
};
}
private static float FirstEliteTime(bool development, bool production)
{
float[] times = GameplayConstants.Current.Run.EliteTimes(
development,
production);
return times.Length > 0 ? times[0] : float.PositiveInfinity;
}
public static float[] GetEliteEventTimes(bool useDebugTimes)
{
return (float[])GameplayConstants.Current.Run.EliteTimes(
useDebugTimes,
false).Clone();
}
public static float[] GetEliteEventTimes(RunMode mode)
{
return (float[])GetEliteEventTimesForMode(mode).Clone();
}
public float[] GetEliteEventTimesForCurrentRun()
{
return (float[])CurrentEliteEventTimes.Clone();
}
public int CurrentEliteEventCount => CurrentEliteEventTimes.Length;
public float GetEliteEventTimeForCurrentRun(int index)
{
float[] eliteTimes = CurrentEliteEventTimes;
return index >= 0 && index < eliteTimes.Length
? eliteTimes[index]
: float.PositiveInfinity;
}
private float[] CurrentEliteEventTimes => hasSelectedRunMode
? GetEliteEventTimesForMode(selectedRunMode)
: GameplayConstants.Current.Run.EliteTimes(useDebugEventTimes, false);
private static float[] GetEliteEventTimesForMode(RunMode mode)
{
return GameplayConstants.Current.Run.EliteTimes(
mode == RunMode.Development,
mode == RunMode.Production);
}
public float GetEventTimeForCurrentRun(RunTimedEvent timedEvent)
{
return hasSelectedRunMode
? GetEventTime(timedEvent, selectedRunMode)
: GetEventTime(timedEvent, useDebugEventTimes);
}
public static float GetEventTime(
RunTimedEvent timedEvent,
RunMode mode)
{
if (mode == RunMode.Development)
{
return GetEventTime(timedEvent, true);
}
return timedEvent switch
{
RunTimedEvent.Elite => FirstEliteTime(false, true),
RunTimedEvent.MidBoss => GameplayConstants.Current.Run.ProductionMidBossEventTime,
RunTimedEvent.FinalBoss => GameplayConstants.Current.Run.ProductionFinalBossEventTime,
_ => float.PositiveInfinity,
};
}
public static float GetProgressionTime(
float elapsedTime,
bool useDebugTimes)
{
return Mathf.Max(0f, elapsedTime)
* (useDebugTimes
? GameplayConstants.Current.Run.DevelopmentProgressionMultiplier
: 1f);
}
#if UNITY_EDITOR
public void DebugAdvanceTime(float elapsedTime)
{
ElapsedTime = Mathf.Max(ElapsedTime, elapsedTime);
OnTimeChanged?.Invoke(ElapsedTime);
RaiseTimedEvents();
}
#endif
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 13ba9bc56077f3245aaad4ca423a51a5
@@ -0,0 +1,28 @@
using UnityEngine;
namespace BumpCombat.Core
{
[RequireComponent(typeof(SpriteRenderer))]
public sealed class YSortRenderer : MonoBehaviour
{
[SerializeField] private int baseOrder = 1000;
[SerializeField, Min(1)] private int precision = 100;
private SpriteRenderer spriteRenderer;
private void Awake()
{
spriteRenderer = GetComponent<SpriteRenderer>();
}
private void LateUpdate()
{
spriteRenderer.sortingOrder = CalculateSortingOrder();
}
public int CalculateSortingOrder()
{
return baseOrder - Mathf.RoundToInt(transform.position.y * precision);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a569e36e83373bb4b9429e8aeeae0684
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5ef6f1fe14aba1644874d875a147c5d5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,15 @@
{
"name": "BumpCombat.Editor",
"rootNamespace": "BumpCombat.Editor",
"references": [
"BumpCombat.Runtime",
"Unity.2D.Sprite.Editor",
"Unity.RenderPipelines.Universal.2D.Runtime",
"Unity.InputSystem",
"UnityEngine.UI"
],
"includePlatforms": [
"Editor"
],
"autoReferenced": true
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: f581d40ac7fda0749beec6f18cad5509
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4c6f6f5e25da95148bff42f9956faf92
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f3079c864fc365d408e412c1a7b6d79f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,102 @@
using System.Collections.Generic;
using BumpCombat.Constants;
using BumpCombat.Core;
using UnityEngine;
namespace BumpCombat.Enemies
{
public sealed class AttackTokenManager : MonoBehaviour
{
private int maxMeleeAttackers => GameplayConstants.Current.Enemies.MaxMeleeAttackers;
private int maxRangedAttackers => GameplayConstants.Current.Enemies.MaxRangedAttackers;
private int maxEarlyCrowdAttackers => GameplayConstants.Current.Enemies.MaxEarlyCrowdAttackers;
private int maxLateCrowdAttackers => GameplayConstants.Current.Enemies.MaxLateCrowdAttackers;
private float crowdAttackExpansionTime => GameplayConstants.Current.Enemies.CrowdAttackExpansionTime;
private float crowdAttackGrantInterval => GameplayConstants.Current.Enemies.CrowdAttackGrantInterval;
private readonly HashSet<int> meleeOwners = new();
private readonly HashSet<int> rangedOwners = new();
private readonly HashSet<int> crowdOwners = new();
private float nextCrowdGrantTime;
public static AttackTokenManager Instance { get; private set; }
public int ActiveCrowdAttackers => crowdOwners.Count;
private void Awake()
{
Instance = this;
}
private void OnDestroy()
{
if (Instance == this)
{
Instance = null;
}
}
public bool TryAcquire(EnemyController enemy)
{
if (enemy.IsEventEnemy)
{
return true;
}
if (enemy.IsCrowd)
{
return TryAcquireCrowd(enemy);
}
HashSet<int> owners = enemy.Definition.IsRanged ? rangedOwners : meleeOwners;
int limit = enemy.Definition.IsRanged ? maxRangedAttackers : maxMeleeAttackers;
int id = enemy.GetInstanceID();
if (owners.Contains(id))
{
return true;
}
return owners.Count < limit && owners.Add(id);
}
public void Release(EnemyController enemy)
{
int id = enemy.GetInstanceID();
meleeOwners.Remove(id);
rangedOwners.Remove(id);
crowdOwners.Remove(id);
}
public static int GetCrowdAttackLimit(
float elapsedTime,
int earlyLimit,
int lateLimit,
float expansionTime)
{
return elapsedTime < expansionTime ? earlyLimit : lateLimit;
}
private bool TryAcquireCrowd(EnemyController enemy)
{
int id = enemy.GetInstanceID();
if (crowdOwners.Contains(id))
{
return true;
}
float elapsedTime = RunManager.Instance?.ProgressionTime ?? 0f;
int limit = GetCrowdAttackLimit(
elapsedTime,
maxEarlyCrowdAttackers,
maxLateCrowdAttackers,
crowdAttackExpansionTime);
if (crowdOwners.Count >= limit || Time.time < nextCrowdGrantTime)
{
return false;
}
crowdOwners.Add(id);
nextCrowdGrantTime = Time.time + crowdAttackGrantInterval;
return true;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8760dc11e88608340866c05710e5995b
@@ -0,0 +1,67 @@
using UnityEngine;
namespace BumpCombat.Enemies
{
public sealed class AttackWarningVisual : MonoBehaviour
{
private static Material sharedMaterial;
private static Sprite circleSprite;
private SpriteRenderer circle;
private void Awake()
{
circle = gameObject.AddComponent<SpriteRenderer>();
circle.sharedMaterial = GetSharedMaterial();
circle.sortingLayerName = "EnemyWarning";
circle.color = new Color(1f, 1f, 1f, .15f);
if (circleSprite == null)
circleSprite = Resources.Load<Sprite>("Enemies/Warning-v3/Necromancer-Warning-Circle");
circle.sprite = circleSprite;
circle.enabled = false;
gameObject.SetActive(false);
}
public void Show(EnemyController controller, Vector2 origin,
Vector2 direction, Vector2 targetPosition)
{
if (controller.AttackShape == EnemyAttackShape.TargetCircle)
ShowTargetCircle(targetPosition, controller.AttackRadius);
else
Hide();
}
public void SetActivePhase(bool active)
{
if (active) Hide();
}
public void ShowTargetCircle(Vector2 center, float radius)
{
gameObject.SetActive(true);
// Gameplay range is already scaled; only the captured landing point is shown.
transform.SetParent(null, true);
transform.position = center;
transform.rotation = Quaternion.identity;
transform.localScale = new Vector3(radius, radius, 1f);
circle.enabled = true;
}
public void Hide()
{
transform.localScale = Vector3.one;
gameObject.SetActive(false);
}
public void CollapseAndHide() => Hide();
private static Material GetSharedMaterial()
{
if (sharedMaterial == null)
sharedMaterial = new Material(Shader.Find("Sprites/Default"))
{
name = "Attack Warning Material",
};
return sharedMaterial;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c973fa4116e48b245b848be02df52fb8
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5338d0101c8feca43bbf5a1f1a5fddc4
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 595424085784314478c7a9e0670d0ab3
@@ -0,0 +1,399 @@
using System;
using UnityEngine;
namespace BumpCombat.Enemies
{
public enum EnemyKind
{
Slime = 0,
Skeleton = 1,
Lancer = 2,
GreatswordSkeleton = 3,
Warlock = 4,
Bat = 5,
Necrofire = 6,
SkeletonArcher = 7,
ArmoredSkeleton = 8,
Werewolf = 9,
Werebear = 10,
NecroGolem = 11,
Necromancer = 12,
}
public enum EnemyRole
{
Normal,
Crowd,
}
public enum EnemyAttackShape
{
Body,
Box,
Cone,
TargetCircle,
}
public enum EnemyAttackDirectionMode
{
LockedDirection,
HorizontalRoot,
}
public enum EnemyAttackGeometryScaleMode
{
WorldUnits,
RootScale,
}
[Serializable]
public struct EnemyAttackContactPhase
{
[SerializeField, Range(0f, 1f)] private float startNormalized;
[SerializeField, Range(0f, 1f)] private float endNormalized;
public float StartNormalized => Mathf.Clamp01(startNormalized);
public float EndNormalized => Mathf.Clamp01(Mathf.Max(
startNormalized,
endNormalized));
public static EnemyAttackContactPhase Create(float start, float end)
{
return new EnemyAttackContactPhase
{
startNormalized = Mathf.Clamp01(start),
endNormalized = Mathf.Clamp01(end),
};
}
}
[Serializable]
public struct EnemyAttackPattern
{
[SerializeField] private string animationState;
[SerializeField] private EnemyAttackShape attackShape;
[SerializeField] private EnemyAttackDirectionMode directionMode;
[SerializeField] private EnemyAttackGeometryScaleMode geometryScaleMode;
[SerializeField, Min(0f)] private float warningDuration;
[SerializeField, Min(0f)] private float activeDuration;
[SerializeField, Min(0f)] private float recoveryDuration;
[SerializeField, Min(0f)] private float attackRange;
[SerializeField, Min(0f)] private float attackLength;
[SerializeField, Min(0f)] private float attackWidth;
[SerializeField, Min(0f)] private float attackRadius;
[SerializeField] private Vector2 attackOriginOffset;
[SerializeField, Min(0f)] private float animationLeadTime;
[SerializeField, Min(0f)] private float damageMultiplier;
[SerializeField] private EnemyAttackContactPhase[] contactPhases;
public string AnimationState => animationState;
public EnemyAttackShape AttackShape => attackShape;
public EnemyAttackDirectionMode DirectionMode => directionMode;
public EnemyAttackGeometryScaleMode GeometryScaleMode => geometryScaleMode;
public float WarningDuration => Mathf.Max(0f, warningDuration);
public float ActiveDuration => Mathf.Max(0f, activeDuration);
public float RecoveryDuration => Mathf.Max(0f, recoveryDuration);
public float AttackRange => Mathf.Max(0f, attackRange);
public float AttackLength => Mathf.Max(0f, attackLength);
public float AttackWidth => Mathf.Max(0f, attackWidth);
public float AttackRadius => Mathf.Max(0f, attackRadius);
public Vector2 AttackOriginOffset => attackOriginOffset;
public float AnimationLeadTime => Mathf.Max(0f, animationLeadTime);
public float DamageMultiplier => damageMultiplier <= 0f ? 1f : damageMultiplier;
public int ContactPhaseCount => contactPhases?.Length ?? 0;
public EnemyAttackContactPhase GetContactPhase(int index)
{
if (contactPhases == null || contactPhases.Length == 0)
{
return EnemyAttackContactPhase.Create(0f, 1f);
}
return contactPhases[Mathf.Clamp(index, 0, contactPhases.Length - 1)];
}
public bool IsContactWindowActive(
float activeElapsed,
float effectiveActiveDuration)
{
if (effectiveActiveDuration <= 0.0001f
|| activeElapsed < 0f
|| activeElapsed >= effectiveActiveDuration)
{
return false;
}
if (contactPhases == null || contactPhases.Length == 0)
{
return true;
}
float normalized = effectiveActiveDuration <= 0.0001f
? 1f
: Mathf.Clamp01(activeElapsed / effectiveActiveDuration);
for (int i = 0; i < contactPhases.Length; i++)
{
EnemyAttackContactPhase phase = contactPhases[i];
if (normalized >= phase.StartNormalized
&& normalized < phase.EndNormalized)
{
return true;
}
}
return false;
}
public static EnemyAttackPattern Create(
string state,
EnemyAttackShape shape,
float warning,
float active,
float recovery,
float range,
float length,
float width,
float radius,
float damage = 1f,
float animationLead = 0f)
{
return new EnemyAttackPattern
{
animationState = state,
attackShape = shape,
directionMode = EnemyAttackDirectionMode.LockedDirection,
geometryScaleMode = EnemyAttackGeometryScaleMode.WorldUnits,
warningDuration = warning,
activeDuration = active,
recoveryDuration = recovery,
attackRange = range,
attackLength = length,
attackWidth = width,
attackRadius = radius,
attackOriginOffset = Vector2.zero,
animationLeadTime = animationLead,
damageMultiplier = damage,
contactPhases = null,
};
}
}
[Serializable]
public struct RunEventEnemyTuning
{
[SerializeField] private EnemyKind prefabKind;
[SerializeField, Min(1f)] private float healthMultiplier;
[SerializeField, Min(0)] private int experienceValue;
[SerializeField, Min(1f)] private float scaleMultiplier;
[SerializeField] private Color tint;
[SerializeField, Min(0f)] private float moveSpeedMultiplier;
[SerializeField, Min(0f)] private float attackDamageMultiplier;
[SerializeField, Min(0f)] private float warningDurationMultiplier;
[SerializeField, Min(0f)] private float activeDurationMultiplier;
[SerializeField, Min(0f)] private float recoveryDurationMultiplier;
[SerializeField, Min(0f)] private float attackRangeMultiplier;
[SerializeField, Min(0f)] private float attackLengthMultiplier;
[SerializeField, Min(0f)] private float attackWidthMultiplier;
[SerializeField, Min(0f)] private float attackRadiusMultiplier;
[SerializeField, Min(1)] private int attacksPerSequence;
[SerializeField, Min(0f)] private float repeatWarningDurationMultiplier;
[SerializeField, Min(0f)] private float betweenAttacksRecoveryMultiplier;
[SerializeField, Min(1)] private int backHitsToInterrupt;
[SerializeField, Range(0f, 1f)] private float knockbackMultiplier;
[SerializeField] private bool canBeLaunched;
public EnemyKind PrefabKind => prefabKind;
public float HealthMultiplier => Mathf.Max(1f, healthMultiplier);
public int ExperienceValue => Mathf.Max(0, experienceValue);
public float ScaleMultiplier => Mathf.Max(1f, scaleMultiplier);
public Color Tint => tint;
public float MoveSpeedMultiplier => Mathf.Max(0f, moveSpeedMultiplier);
public float AttackDamageMultiplier => Mathf.Max(0f, attackDamageMultiplier);
public float WarningDurationMultiplier => Mathf.Max(0f, warningDurationMultiplier);
public float ActiveDurationMultiplier => Mathf.Max(0f, activeDurationMultiplier);
public float RecoveryDurationMultiplier => Mathf.Max(0f, recoveryDurationMultiplier);
public float AttackRangeMultiplier => Mathf.Max(0f, attackRangeMultiplier);
public float AttackLengthMultiplier => Mathf.Max(0f, attackLengthMultiplier);
public float AttackWidthMultiplier => Mathf.Max(0f, attackWidthMultiplier);
public float AttackRadiusMultiplier => Mathf.Max(0f, attackRadiusMultiplier);
public int AttacksPerSequence => Mathf.Max(1, attacksPerSequence);
public float RepeatWarningDurationMultiplier =>
Mathf.Max(0f, repeatWarningDurationMultiplier);
public float BetweenAttacksRecoveryMultiplier =>
Mathf.Max(0f, betweenAttacksRecoveryMultiplier);
public int BackHitsToInterrupt => Mathf.Max(1, backHitsToInterrupt);
public float KnockbackMultiplier => Mathf.Clamp01(knockbackMultiplier);
public bool CanBeLaunched => canBeLaunched;
public static RunEventEnemyTuning Create(
EnemyKind kind,
float health,
int experience,
float scale,
Color color,
float moveSpeed,
float attackDamage,
float warningDuration,
float activeDuration,
float recoveryDuration,
float attackRange,
float attackLength,
float attackWidth,
float attackRadius,
int attackCount,
float repeatWarning,
float betweenAttacksRecovery,
int staggerHits,
float knockback,
bool launchable)
{
return new RunEventEnemyTuning
{
prefabKind = kind,
healthMultiplier = health,
experienceValue = experience,
scaleMultiplier = scale,
tint = color,
moveSpeedMultiplier = moveSpeed,
attackDamageMultiplier = attackDamage,
warningDurationMultiplier = warningDuration,
activeDurationMultiplier = activeDuration,
recoveryDurationMultiplier = recoveryDuration,
attackRangeMultiplier = attackRange,
attackLengthMultiplier = attackLength,
attackWidthMultiplier = attackWidth,
attackRadiusMultiplier = attackRadius,
attacksPerSequence = attackCount,
repeatWarningDurationMultiplier = repeatWarning,
betweenAttacksRecoveryMultiplier = betweenAttacksRecovery,
backHitsToInterrupt = staggerHits,
knockbackMultiplier = knockback,
canBeLaunched = launchable,
};
}
}
[CreateAssetMenu(menuName = "BumpCombat/Enemy Definition")]
public sealed class EnemyDefinition : ScriptableObject
{
[SerializeField] private EnemyKind kind;
[SerializeField] private EnemyRole role;
[SerializeField] private EnemyAttackShape attackShape;
[SerializeField, Min(1f)] private float maxHealth;
[SerializeField, Min(0f)] private float moveSpeed;
[SerializeField, Min(0f)] private float attackDamage;
[SerializeField, Min(0f)] private float warningDuration;
[SerializeField, Min(0f)] private float activeDuration;
[SerializeField, Min(0f)] private float recoveryDuration;
[SerializeField, Min(0f)] private float attackRange;
[SerializeField, Min(0f)] private float preferredRange;
[SerializeField, Min(0f)] private float attackLength;
[SerializeField, Min(0f)] private float attackWidth;
[SerializeField, Min(0f)] private float attackRadius;
[SerializeField, Min(0f)] private float attackAnimationLeadTime;
[SerializeField, Min(0f)] private float projectileSpeed;
[SerializeField, Min(0f)] private float projectileRadius;
[SerializeField, Min(0)] private int experienceValue;
[SerializeField] private EnemyAttackPattern[] attackPatterns;
[SerializeField, Min(0f)] private float separationRadius = 0.55f;
[SerializeField, Min(0f)] private float separationStrength = 0.6f;
public EnemyKind Kind => kind;
public EnemyRole Role => role;
public EnemyAttackShape AttackShape => attackShape;
public float MaxHealth => maxHealth;
public float MoveSpeed => moveSpeed;
public float AttackDamage => attackDamage;
public float WarningDuration => warningDuration;
public float ActiveDuration => activeDuration;
public float RecoveryDuration => recoveryDuration;
public float AttackRange => attackRange;
public float PreferredRange => preferredRange;
public float AttackLength => attackLength;
public float AttackWidth => attackWidth;
public float AttackRadius => attackRadius;
public float AttackAnimationLeadTime => Mathf.Max(0f, attackAnimationLeadTime);
public float ProjectileSpeed => Mathf.Max(0f, projectileSpeed);
public float ProjectileRadius => Mathf.Max(0f, projectileRadius);
public int ExperienceValue => experienceValue;
public float SeparationRadius => Mathf.Max(0f, separationRadius);
public float SeparationStrength => Mathf.Max(0f, separationStrength);
public int AttackPatternCount => attackPatterns?.Length ?? 0;
public bool IsRanged => kind == EnemyKind.Warlock
|| kind == EnemyKind.Necrofire
|| kind == EnemyKind.SkeletonArcher
|| kind == EnemyKind.Necromancer;
public bool IsCrowd => role == EnemyRole.Crowd;
public bool HasContactDamage => kind == EnemyKind.Slime
|| kind == EnemyKind.Bat;
public EnemyAttackPattern GetAttackPattern(int index)
{
if (attackPatterns == null || attackPatterns.Length == 0)
{
return EnemyAttackPattern.Create(
"Attack",
attackShape,
warningDuration,
activeDuration,
recoveryDuration,
attackRange,
attackLength,
attackWidth,
attackRadius,
1f,
attackAnimationLeadTime);
}
return attackPatterns[Mathf.Clamp(index, 0, attackPatterns.Length - 1)];
}
public EnemyAttackPattern[] AttackPatterns => attackPatterns;
#if UNITY_EDITOR
public void Configure(
EnemyKind enemyKind,
EnemyAttackShape shape,
float health,
float speed,
float damage,
float warning,
float active,
float recovery,
float range,
float preferred,
float length,
float width,
float radius,
int experience,
float animationLeadTime = 0f)
{
kind = enemyKind;
attackShape = shape;
maxHealth = health;
moveSpeed = speed;
attackDamage = damage;
warningDuration = warning;
activeDuration = active;
recoveryDuration = recovery;
attackRange = range;
preferredRange = preferred;
attackLength = length;
attackWidth = width;
attackRadius = radius;
attackAnimationLeadTime = animationLeadTime;
experienceValue = experience;
}
public void ConfigureRole(EnemyRole enemyRole)
{
role = enemyRole;
}
public void ConfigureAttackPatterns(EnemyAttackPattern[] patterns)
{
attackPatterns = patterns;
}
#endif
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f124e8255c489cc4eb7345424f04a5da
@@ -0,0 +1,63 @@
using BumpCombat.Core;
using UnityEngine;
namespace BumpCombat.Enemies
{
[DisallowMultipleComponent]
public sealed class EnemyModel : CharacterModel
{
private EnemyDefinition definition;
private float maxHealthMultiplier = 1f;
private float moveSpeedMultiplier = 1f;
private float attackDamageMultiplier = 1f;
public EnemyDefinition Definition => definition;
public override float MaxHealth => definition == null
? 0f
: Evaluate(
CharacterStat.MaxHealth,
definition.MaxHealth * maxHealthMultiplier);
public override float MoveSpeed => definition == null
? 0f
: Evaluate(
CharacterStat.MoveSpeed,
definition.MoveSpeed * moveSpeedMultiplier);
public float AttackDamage => definition == null
? 0f
: CalculateAttackDamage(
definition.AttackDamage * attackDamageMultiplier);
public void ConfigureDefinition(EnemyDefinition enemyDefinition)
{
definition = enemyDefinition;
}
public void ConfigureEventMultipliers(
float healthMultiplier,
float speedMultiplier,
float damageMultiplier)
{
maxHealthMultiplier = Mathf.Max(1f, healthMultiplier);
moveSpeedMultiplier = Mathf.Max(0f, speedMultiplier);
attackDamageMultiplier = Mathf.Max(0f, damageMultiplier);
}
public float CalculatePatternDamage(
float patternMultiplier,
bool applyEventTuning)
{
if (definition == null)
{
return 0f;
}
float damage = definition.AttackDamage * patternMultiplier;
if (applyEventTuning)
{
damage *= attackDamageMultiplier;
}
return CalculateAttackDamage(damage);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 70ba651b3c0e4317b4e4eb978e69d7ba
@@ -0,0 +1,142 @@
using BumpCombat.Player;
using BumpCombat.Core;
using UnityEngine;
namespace BumpCombat.Enemies
{
/// <summary>
/// A small gameplay projectile used by the stage ranged enemies. The
/// projectile owns its travel and lifetime; the enemy owns its warning
/// and attack timing.
/// </summary>
public sealed class EnemyProjectile : MonoBehaviour
{
private PlayerHealth target;
private Vector2 direction;
private float damage;
private float speed;
private float remainingDistance;
private float hitRadius;
private bool resolved;
public EnemyController Owner { get; private set; }
public static EnemyProjectile Launch(
EnemyController owner,
Vector2 origin,
Vector2 direction,
float damage,
float speed,
float maxDistance,
float hitRadius,
bool isBeam)
{
GameObject root = new GameObject(
isBeam ? "Necrofire Beam Projectile" : "Skeleton Archer Arrow");
root.transform.position = origin;
root.transform.right = direction.sqrMagnitude > 0.0001f
? direction.normalized
: Vector2.right;
EnemyProjectile projectile = root.AddComponent<EnemyProjectile>();
projectile.Owner = owner;
projectile.direction = root.transform.right;
projectile.damage = Mathf.Max(0f, damage);
projectile.speed = Mathf.Max(0f, speed);
projectile.remainingDistance = Mathf.Max(0.1f, maxDistance);
projectile.hitRadius = Mathf.Max(0.03f, hitRadius);
projectile.target = FindAnyObjectByType<PlayerHealth>();
StageEnemyEffectVisual.AttachProjectile(
root,
isBeam,
isBeam ? 1.2f : 0.6f,
Mathf.Max(0.04f, projectile.hitRadius * 2f));
return projectile;
}
private void Update()
{
if (resolved
|| target == null
|| Owner == null
|| Owner.IsDead
|| !Owner.isActiveAndEnabled)
{
Destroy(gameObject);
return;
}
if (!RunManager.GameplayInputEnabled)
{
return;
}
if (Time.deltaTime <= 0f)
{
return;
}
float distance = speed * Time.deltaTime;
Vector2 previousPosition = transform.position;
if (distance > 0f)
{
transform.position += (Vector3)(direction * distance);
remainingDistance -= distance;
}
bool hitTarget = false;
if (distance > 0f)
{
RaycastHit2D[] hits = Physics2D.CircleCastAll(
previousPosition,
hitRadius,
direction,
distance,
Physics2D.AllLayers);
for (int i = 0; i < hits.Length; i++)
{
if (hits[i].collider != null
&& hits[i].collider.GetComponentInParent<PlayerHealth>() == target)
{
hitTarget = true;
break;
}
}
}
else
{
Collider2D[] overlaps = Physics2D.OverlapCircleAll(
transform.position,
hitRadius,
Physics2D.AllLayers);
for (int i = 0; i < overlaps.Length; i++)
{
if (overlaps[i] != null
&& overlaps[i].GetComponentInParent<PlayerHealth>() == target)
{
hitTarget = true;
break;
}
}
}
if (hitTarget)
{
resolved = true;
target.TryTakeDamage(
damage,
((Vector2)target.transform.position - (Vector2)transform.position).normalized,
Owner.PlayerKnockbackDistance);
Destroy(gameObject);
return;
}
if (remainingDistance <= 0f)
{
Destroy(gameObject);
}
}
private void OnDisable()
{
resolved = true;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6e5b3bbcd7a34fb5bcb821c3da8d7c09
@@ -0,0 +1,82 @@
using UnityEngine;
namespace BumpCombat.Enemies
{
/// <summary>
/// The authored timing contract for the first Lancer thrust pilot.
/// The tip is the only moving collision sample; the shaft is visual only.
/// </summary>
public static class LancerAttackMotion
{
public const float WarningStartReachFraction = 0.65f;
public const float WarningEndReachFraction = 0.35f;
public const float ActiveExtensionEndFraction = 0.65f;
public const float HandAnchorPixelsX = 10f;
public const float HandAnchorPixelsY = 6f;
public const float PixelsPerUnit = 32f;
public static float EvaluateWarningReachFraction(float normalizedWarning)
{
return Mathf.Lerp(
WarningStartReachFraction,
WarningEndReachFraction,
Mathf.Clamp01(normalizedWarning));
}
public static float EvaluateActiveReachFraction(float normalizedActive)
{
float progress = Mathf.Clamp01(normalizedActive);
if (progress <= ActiveExtensionEndFraction)
{
return Mathf.Lerp(
WarningEndReachFraction,
1f,
progress / ActiveExtensionEndFraction);
}
return Mathf.Lerp(
1f,
WarningEndReachFraction,
(progress - ActiveExtensionEndFraction)
/ (1f - ActiveExtensionEndFraction));
}
public static Vector2 GetHandAnchorOffset(
Vector2 lockedDirection,
float worldScale = 1f)
{
float side = lockedDirection.x < 0f ? -1f : 1f;
return new Vector2(
side * HandAnchorPixelsX / PixelsPerUnit,
HandAnchorPixelsY / PixelsPerUnit) * worldScale;
}
public static Vector2 GetTipPosition(
Vector2 rootPosition,
Vector2 lockedDirection,
float attackLength,
float normalizedReach,
float worldScale = 1f)
{
Vector2 direction = lockedDirection.sqrMagnitude > 0.0001f
? lockedDirection.normalized
: Vector2.right;
Vector2 hand = rootPosition + GetHandAnchorOffset(
direction,
worldScale);
float rootReach = Mathf.Max(0f, attackLength)
* Mathf.Clamp01(normalizedReach);
float handLength = Mathf.Max(
0f,
rootReach - Vector2.Dot(hand - rootPosition, direction));
return hand + direction * handLength;
}
public static bool IsDamageWindow(float normalizedActive)
{
return Mathf.Clamp01(normalizedActive)
<= ActiveExtensionEndFraction + 0.0001f;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e5fd9a9ed2e949a9a56cde6711a6321c
@@ -0,0 +1,197 @@
using UnityEngine;
namespace BumpCombat.Enemies
{
/// <summary>Original Lancer pixels, posed from the same clock and tip as combat.</summary>
[DisallowMultipleComponent]
[DefaultExecutionOrder(1000)]
public sealed class LancerThrustVisual : MonoBehaviour
{
private const string Lease = "LancerThrust";
private const float RecoveryPoseDuration = 0.08f;
private static readonly float[] TipPixels = { 22f, 20f, 19f, 30f, 28f, 26f };
private static Sprite[] bodyFrames;
private static Sprite[] spearFrames;
private EnemyController controller;
private EnemyAttack attack;
private SpriteRenderer source;
private SpriteRenderer bodyVisual;
private SpriteRenderer spearVisual;
private bool showing;
private bool ownsLease;
private float recoveryRemaining;
public SpriteRenderer BodyRenderer => bodyVisual;
public SpriteRenderer SpearRenderer => spearVisual;
public bool IsVisible => showing && bodyVisual != null && bodyVisual.enabled;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetFrames()
{
ReleaseFrames(bodyFrames);
ReleaseFrames(spearFrames);
bodyFrames = null;
spearFrames = null;
}
private static void ReleaseFrames(Sprite[] frames)
{
if (frames == null) return;
foreach (Sprite frame in frames)
{
if (frame != null) Destroy(frame);
}
}
public void Begin(EnemyAttack owner, EnemyController enemy)
{
attack = owner;
controller = enemy;
source = GetComponent<SpriteRenderer>();
bodyFrames ??= LoadFrames("Body", new Vector2(0.5f, 0.5f));
spearFrames ??= LoadFrames("Spear", new Vector2(0.6f, 0.56f));
if (source == null || bodyFrames == null || spearFrames == null) return;
if (bodyVisual == null) bodyVisual = CreateRenderer("Lancer Thrust Body");
if (spearVisual == null) spearVisual = CreateRenderer("Lancer Thrust Spear");
recoveryRemaining = 0f;
showing = true;
if (!ownsLease)
{
controller.AcquireSpriteVisualLease(Lease);
ownsLease = true;
}
ApplyPose(0, LancerAttackMotion.WarningStartReachFraction);
}
public void Stop(bool cancelled)
{
if (!showing) return;
if (cancelled || !isActiveAndEnabled || controller == null
|| controller.IsDead || controller.IsStunned)
{
Hide();
return;
}
recoveryRemaining = RecoveryPoseDuration;
ApplyPose(5, LancerAttackMotion.WarningEndReachFraction);
}
private void LateUpdate()
{
if (!showing) return;
if (controller == null || controller.IsDead || controller.IsStunned
|| !attack.isActiveAndEnabled)
{
Hide();
return;
}
if (controller.IsKnockbackActive || controller.IsLaunchVisualActive)
{
// Knockback can pause an uncancelled attack. Let Hurt/Launch show,
// then resume this same state clock when the controller resumes.
SuspendRenderers();
return;
}
if (recoveryRemaining > 0f)
{
recoveryRemaining -= Time.deltaTime;
if (recoveryRemaining <= 0f) Hide();
else ApplyPose(5, LancerAttackMotion.WarningEndReachFraction);
return;
}
float progress = controller.StateNormalizedTime;
if (controller.State == EnemyState.Warning)
{
ApplyPose(Mathf.Min(2, Mathf.FloorToInt(progress * 3f)),
LancerAttackMotion.EvaluateWarningReachFraction(progress));
}
else if (controller.State == EnemyState.Active)
{
ApplyPose(progress <= LancerAttackMotion.ActiveExtensionEndFraction ? 3 : 4,
LancerAttackMotion.EvaluateActiveReachFraction(progress));
}
else Hide();
}
private void ApplyPose(int frame, float reach)
{
if (!ownsLease)
{
controller.AcquireSpriteVisualLease(Lease);
ownsLease = true;
}
Vector2 direction = attack.LockedDirection.sqrMagnitude > 0.0001f
? attack.LockedDirection.normalized : Vector2.right;
float scale = Mathf.Max(0.0001f, Mathf.Abs(transform.lossyScale.x));
Vector2 hand = (Vector2)transform.position
+ LancerAttackMotion.GetHandAnchorOffset(direction, scale);
Vector2 tip = LancerAttackMotion.GetTipPosition(
transform.position, direction, controller.AttackLength, reach, scale);
bodyVisual.sprite = bodyFrames[frame];
bodyVisual.flipX = direction.x < 0f;
bodyVisual.color = source.color;
bodyVisual.sortingLayerID = source.sortingLayerID;
bodyVisual.sortingOrder = source.sortingOrder;
bodyVisual.enabled = true;
spearVisual.sprite = spearFrames[frame];
spearVisual.color = source.color;
spearVisual.sortingLayerID = source.sortingLayerID;
spearVisual.sortingOrder = source.sortingOrder + 1;
spearVisual.enabled = true;
spearVisual.transform.SetPositionAndRotation(hand,
Quaternion.Euler(0f, 0f, Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg));
// Spear has no inherited scale: elite reach/width are already combat values.
spearVisual.transform.localScale = new Vector3(
Vector2.Distance(hand, tip) * 32f / TipPixels[frame],
controller.AttackWidth * 32f / 8f, 1f);
}
private SpriteRenderer CreateRenderer(string objectName)
{
GameObject child = new(objectName);
SpriteRenderer renderer = child.AddComponent<SpriteRenderer>();
if (objectName.EndsWith("Body")) child.transform.SetParent(transform, false);
// An independent transform preserves world rotation/width under scaled elites.
renderer.sharedMaterial = source.sharedMaterial;
return renderer;
}
private static Sprite[] LoadFrames(string part, Vector2 pivot)
{
Texture2D texture = Resources.Load<Texture2D>("Enemies/Lancer/Lancer-Thrust-" + part + "-v1");
if (texture == null || texture.width != 600 || texture.height != 100) return null;
Sprite[] frames = new Sprite[6];
for (int i = 0; i < frames.Length; i++)
{
frames[i] = Sprite.Create(texture, new Rect(i * 100, 0, 100, 100), pivot, 32f,
0, SpriteMeshType.FullRect);
frames[i].name = "Lancer-Thrust-" + part + "-" + i;
}
return frames;
}
private void Hide()
{
showing = false;
recoveryRemaining = 0f;
SuspendRenderers();
}
private void SuspendRenderers()
{
if (bodyVisual != null) bodyVisual.enabled = false;
if (spearVisual != null) spearVisual.enabled = false;
if (ownsLease && controller != null) controller.ReleaseSpriteVisualLease(Lease);
ownsLease = false;
}
private void OnDisable() => Hide();
private void OnDestroy()
{
Hide();
if (bodyVisual != null) Destroy(bodyVisual.gameObject);
if (spearVisual != null) Destroy(spearVisual.gameObject);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2eb45ff7e03b43e9a8a8978339c50002
@@ -0,0 +1,392 @@
using System.Collections.Generic;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Spawning;
using UnityEngine;
namespace BumpCombat.Enemies
{
/// <summary>
/// Owns the Necromancer's one-time desperation summon phase. Timed events
/// and ordinary attack patterns remain owned by RunManager and
/// EnemyController.
/// </summary>
[RequireComponent(typeof(EnemyController))]
public sealed class NecromancerBossController : MonoBehaviour,
IEnemyDamageGate,
IEnemyAttackPatternGate,
IEnemyActionGate
{
private float generalSummonCooldown => GameplayConstants.Current.Enemies.GeneralSummonCooldown;
private float crowdSummonCooldown => GameplayConstants.Current.Enemies.CrowdSummonCooldown;
private float multiAoeCooldown => GameplayConstants.Current.Enemies.MultiAoeCooldown;
private float summonVisualDuration => GameplayConstants.Current.Enemies.SummonVisualDuration;
private float desperationHealth => GameplayConstants.Current.Enemies.DesperationHealthFraction;
private readonly List<EnemyController> phaseSummons = new();
private EnemyController controller;
private SpawnDirector spawnDirector;
private bool desperationStarted;
private bool desperationPending;
private bool desperationPhaseStarted;
private bool desperationCompleted;
private float nextGeneralSummonTime;
private float nextCrowdSummonTime;
private float nextMultiAoeTime;
private float summonLockUntil;
public bool IsDamageBlocked => desperationPending
|| phaseSummons.Count > 0;
public bool IsPhaseActive => phaseSummons.Count > 0;
public int PhaseSummonCount => phaseSummons.Count;
public float MultiAoeCooldown => Mathf.Max(0.5f, multiAoeCooldown);
public bool IsAttackLocked => desperationPending
|| Time.time < summonLockUntil;
public bool DesperationStarted => desperationStarted;
public bool CanUseAttackPattern(int patternIndex)
{
return patternIndex != 2 || Time.time >= nextMultiAoeTime;
}
public void NotifyAttackPatternUsed(int patternIndex)
{
if (patternIndex == 2)
{
nextMultiAoeTime = Time.time + MultiAoeCooldown;
}
}
private void Awake()
{
controller = GetComponent<EnemyController>();
}
private void OnEnable()
{
if (controller != null)
{
controller.OnDamageApplied += HandleDamageApplied;
controller.OnDied += HandleOwnerDied;
}
}
private void Start()
{
spawnDirector = FindAnyObjectByType<SpawnDirector>();
nextGeneralSummonTime = Time.time + generalSummonCooldown;
nextCrowdSummonTime = Time.time + crowdSummonCooldown;
}
private void OnDestroy()
{
if (controller != null)
{
controller.OnDamageApplied -= HandleDamageApplied;
controller.OnDied -= HandleOwnerDied;
}
ClearPhaseSummons();
StageEnemyEffectVisual.SetProtection(gameObject, false);
}
private void OnDisable()
{
if (controller != null)
{
controller.OnDamageApplied -= HandleDamageApplied;
controller.OnDied -= HandleOwnerDied;
}
spawnDirector?.DespawnSummonsOwnedBy(controller, true);
DespawnOwnedPhaseSummons();
StageEnemyEffectVisual.ClearOwnedEffects(gameObject);
StageEnemyEffectVisual.SetProtection(gameObject, false);
desperationStarted = false;
desperationPending = false;
desperationPhaseStarted = false;
desperationCompleted = false;
summonLockUntil = 0f;
}
private void Update()
{
if (controller == null || controller.IsDead)
{
return;
}
if (!RunManager.GameplayInputEnabled)
{
return;
}
if (controller.IsGroggy)
{
return;
}
RemoveDeadPhaseSummons();
if (controller.IsGroggy)
{
return;
}
if (desperationPending)
{
TryBeginDesperationPhase();
return;
}
if (IsAttackLocked)
{
return;
}
if (controller.IsStunned
|| controller.IsKnockbackActive)
{
return;
}
if (controller.IsAttackSequenceInProgress)
{
return;
}
if (Time.time >= nextGeneralSummonTime)
{
bool spawned = spawnDirector != null
&& spawnDirector.TrySpawnAmbientSummons(
controller,
1,
false,
out List<EnemyController> generalSummons)
&& generalSummons.Count > 0;
nextGeneralSummonTime = Time.time + generalSummonCooldown;
if (spawned)
{
PlaySummonTelegraph(false);
return;
}
}
if (Time.time >= nextCrowdSummonTime)
{
bool spawned = spawnDirector != null
&& spawnDirector.TrySpawnAmbientSummons(
controller,
3,
true,
out List<EnemyController> crowdSummons)
&& crowdSummons.Count > 0;
nextCrowdSummonTime = Time.time + crowdSummonCooldown;
if (spawned)
{
PlaySummonTelegraph(false);
}
}
}
public float FilterDamage(float currentHealth, float requestedDamage)
{
if (requestedDamage <= 0f || controller == null)
{
return 0f;
}
if (desperationPending || phaseSummons.Count > 0)
{
return 0f;
}
if (controller.IsGroggy && !desperationStarted)
{
float desperationTarget = Mathf.Max(
0.0001f,
controller.MaximumHealth * desperationHealth);
if (currentHealth > desperationTarget)
{
return Mathf.Min(
requestedDamage,
currentHealth - desperationTarget);
}
return 0f;
}
return requestedDamage;
}
private void HandleDamageApplied(float healthBefore, float damageApplied)
{
if (controller == null || controller.IsDead)
{
return;
}
float healthFraction = controller.CurrentHealth
/ Mathf.Max(0.0001f, controller.MaximumHealth);
if (controller.IsGroggy
&& !desperationStarted
&& healthFraction <= desperationHealth + 0.0001f)
{
desperationStarted = true;
desperationPending = true;
controller.EndGroggyForProtection();
TryBeginDesperationPhase();
return;
}
if (desperationPending)
{
return;
}
}
private void TryBeginDesperationPhase()
{
if (!RunManager.GameplayInputEnabled)
{
return;
}
if (spawnDirector == null)
{
spawnDirector = FindAnyObjectByType<SpawnDirector>();
}
if (spawnDirector == null)
{
return;
}
if (!spawnDirector.TrySpawnPhaseSummons(
controller,
2,
true,
out List<EnemyController> spawned))
{
return;
}
phaseSummons.AddRange(spawned);
for (int i = 0; i < spawned.Count; i++)
{
spawned[i].OnDied += HandlePhaseSummonDied;
}
desperationPhaseStarted = true;
desperationPending = false;
StageEnemyEffectVisual.SetProtection(gameObject, true);
PlaySummonTelegraph(true);
}
private void PlaySummonTelegraph(bool interruptAttack)
{
if (interruptAttack)
{
controller.CancelCurrentAttack();
}
float visualDuration = controller.GetSummonAnimationDuration();
if (visualDuration <= 0f)
{
visualDuration = summonVisualDuration;
}
summonLockUntil = Time.time + visualDuration;
StageEnemyEffectVisual.PlaySummon(
gameObject,
transform.position,
visualDuration);
controller.PlaySummonAnimation();
}
private void HandlePhaseSummonDied(EnemyController summon)
{
if (summon == null)
{
return;
}
summon.OnDied -= HandlePhaseSummonDied;
phaseSummons.Remove(summon);
if (phaseSummons.Count == 0
&& desperationPhaseStarted
&& !desperationPending
&& !desperationCompleted)
{
CompleteDesperationPhase();
}
}
private void HandleOwnerDied(EnemyController owner)
{
spawnDirector ??= FindAnyObjectByType<SpawnDirector>();
spawnDirector?.DespawnSummonsOwnedBy(owner, true);
DespawnOwnedPhaseSummons();
StageEnemyEffectVisual.ClearOwnedEffects(gameObject);
StageEnemyEffectVisual.SetProtection(gameObject, false);
}
private void RemoveDeadPhaseSummons()
{
for (int i = phaseSummons.Count - 1; i >= 0; i--)
{
if (phaseSummons[i] == null || phaseSummons[i].IsDead)
{
if (phaseSummons[i] != null)
{
phaseSummons[i].OnDied -= HandlePhaseSummonDied;
}
phaseSummons.RemoveAt(i);
}
}
if (phaseSummons.Count == 0
&& desperationPhaseStarted
&& !desperationPending
&& !desperationCompleted)
{
CompleteDesperationPhase();
}
}
private void CompleteDesperationPhase()
{
if (controller == null
|| controller.IsDead
|| desperationCompleted)
{
return;
}
if (controller.BeginGroggyAfterProtection())
{
desperationCompleted = true;
StageEnemyEffectVisual.SetProtection(gameObject, false);
}
}
private void ClearPhaseSummons()
{
for (int i = 0; i < phaseSummons.Count; i++)
{
if (phaseSummons[i] != null)
{
phaseSummons[i].OnDied -= HandlePhaseSummonDied;
}
}
phaseSummons.Clear();
}
private void DespawnOwnedPhaseSummons()
{
for (int i = 0; i < phaseSummons.Count; i++)
{
EnemyController summon = phaseSummons[i];
if (summon != null && !summon.IsDead)
{
// OnDisable also runs during scene unload. Suppress the
// retired-event feedback there so unload cannot create
// transient visual objects after the scene is closing.
summon.RetireFromRun(true, true);
}
}
ClearPhaseSummons();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6e5b3bbcd7a34fb5bcb821c3da8d7c0a
@@ -0,0 +1,205 @@
using System.Collections.Generic;
using BumpCombat.Combat;
using BumpCombat.Player;
using UnityEngine;
namespace BumpCombat.Enemies
{
// Authored exterior pixels only; this component never changes the damage gate.
[DefaultExecutionOrder(10000)]
public sealed class NecromancerProtectionVisual : MonoBehaviour
{
private static readonly Dictionary<Sprite, Sprite> Masks = new();
private EnemyController controller;
private SpriteRenderer body;
private SpriteRenderer outline;
private SpriteRenderer groggySpiral;
private float orbitTime;
private TextMesh shieldLabel;
private MeshRenderer shieldLabelRenderer;
private static readonly Color GroggyColor = new Color32(255, 238, 182, 255);
private void Awake()
{
controller = GetComponent<EnemyController>();
body = GetComponent<SpriteRenderer>();
var root = new GameObject("Necromancer Protection");
root.transform.SetParent(transform, false);
outline = root.AddComponent<SpriteRenderer>();
outline.enabled = false;
var labelRoot = new GameObject("Artifact Shield Label");
labelRoot.transform.SetParent(transform, false);
shieldLabel = labelRoot.AddComponent<TextMesh>();
shieldLabel.fontSize = 32;
shieldLabel.characterSize = 0.06f;
shieldLabel.anchor = TextAnchor.MiddleCenter;
shieldLabel.alignment = TextAlignment.Center;
Font font = Resources.Load<Font>("Presentation/Fonts/Galmuri9");
shieldLabelRenderer = labelRoot.GetComponent<MeshRenderer>();
if (font != null)
{
shieldLabel.font = font;
shieldLabelRenderer.sharedMaterial = font.material;
}
shieldLabelRenderer.enabled = false;
var marker = new GameObject("Groggy Spiral");
marker.transform.SetParent(transform, false);
groggySpiral = marker.AddComponent<SpriteRenderer>();
groggySpiral.enabled = false;
}
private void LateUpdate()
{
if (controller != null && controller.IsGroggy) orbitTime += Time.deltaTime;
else orbitTime = 0f;
Refresh();
}
public void Refresh()
{
if (outline == null) return;
bool visible = controller != null && controller.isActiveAndEnabled && !controller.IsDead
&& (controller.IsDamageInvulnerable || controller.IsGroggy)
&& body != null && body.enabled && body.sprite != null;
outline.enabled = visible;
RefreshGroggyMarker(visible && controller.IsGroggy);
RefreshShieldLabel(visible && controller.IsDamageInvulnerable && !controller.IsGroggy);
if (!visible) return;
if (!Masks.TryGetValue(body.sprite, out var mask))
{
var candidates = Resources.LoadAll<Sprite>("Enemies/Protection-v2/" + body.sprite.texture.name + "-outline");
foreach (var candidate in candidates)
if (candidate.rect == body.sprite.rect) { mask = candidate; break; }
Masks[body.sprite] = mask;
}
outline.sprite = mask;
outline.enabled = mask != null;
outline.flipX = body.flipX;
outline.flipY = body.flipY;
Color tint = controller.IsGroggy ? GroggyColor : GetShieldColor();
tint.a = body.color.a;
outline.color = tint;
outline.sharedMaterial = body.sharedMaterial;
outline.sortingLayerID = body.sortingLayerID;
outline.sortingOrder = body.sortingOrder + 1;
}
private void OnDisable()
{
if (outline != null) outline.enabled = false;
if (shieldLabelRenderer != null) shieldLabelRenderer.enabled = false;
RefreshGroggyMarker(false);
orbitTime = 0f;
}
private bool HasProtectionSummons => GetComponent<NecromancerBossController>()?.IsDamageBlocked == true;
private Color GetShieldColor()
{
if (HasProtectionSummons) return new Color32(205, 211, 224, 255);
return ActiveArtifactDefinition.GetArtifactPaletteColor(controller.ShieldColor);
}
private void RefreshShieldLabel(bool visible)
{
if (shieldLabelRenderer == null) return;
shieldLabelRenderer.enabled = visible;
if (!visible) return;
string group = controller.ShieldColor == ArtifactColor.Green ? "초록"
: controller.ShieldColor == ArtifactColor.Red ? "빨강" : "파랑";
shieldLabel.text = HasProtectionSummons ? "보호 소환" : group + " 실드 " + controller.ShieldHitsRemaining;
shieldLabel.color = GetShieldColor();
shieldLabel.transform.position = transform.TransformPoint(new Vector3(0, GetHeadHeight(), 0)) + Vector3.up * 0.38f;
Vector3 scale = transform.lossyScale;
shieldLabel.transform.localScale = new Vector3(1f / Mathf.Max(0.001f, Mathf.Abs(scale.x)),
1f / Mathf.Max(0.001f, Mathf.Abs(scale.y)), 1f);
shieldLabelRenderer.sortingLayerID = body.sortingLayerID;
shieldLabelRenderer.sortingOrder = body.sortingOrder + 101;
}
private float GetHeadHeight()
{
return CrowdControlMarkerArt.HeadLocalHeight(body.sprite);
}
private void RefreshGroggyMarker(bool visible)
{
if (groggySpiral == null) return;
groggySpiral.enabled = visible;
if (!visible) return;
groggySpiral.sprite = CrowdControlMarkerArt.Frame(orbitTime, true);
groggySpiral.transform.position = CrowdControlMarkerArt.MarkerWorldPosition(body);
groggySpiral.transform.localScale = CombatFeedback.CalculateStunMarkerBaseLocalScale(transform.lossyScale);
groggySpiral.sortingLayerID = body.sortingLayerID;
groggySpiral.sortingOrder = body.sortingOrder + 100;
groggySpiral.color = new Color(1f, 1f, 1f, body.color.a);
groggySpiral.enabled = groggySpiral.sprite != null;
}
}
// Shared authored CC art and head landmarks. No combat state is modified here.
public static class CrowdControlMarkerArt
{
public const float MarkerScale = 0.5f;
public const float MarkerHalfHeight = 10f / 32f * MarkerScale;
private static Sprite[] stunFrames;
private static Sprite[] groggyFrames;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetFrames()
{
foreach (Sprite[] frames in new[] { stunFrames, groggyFrames })
if (frames != null)
foreach (Sprite frame in frames)
if (frame != null) Object.Destroy(frame);
stunFrames = null;
groggyFrames = null;
}
public static Sprite Frame(float elapsed, bool groggy)
{
Sprite[] frames = groggy ? groggyFrames : stunFrames;
if (frames == null)
{
Texture2D texture = Resources.Load<Texture2D>(
"Artifacts/CrowdControl-v1/CC-Spiral-" + (groggy ? "Groggy" : "Stun") + "-v1");
if (texture == null) return null;
frames = new Sprite[8];
for (int i = 0; i < frames.Length; i++)
frames[i] = Sprite.Create(texture, new Rect(i * 32, 0, 32, 24), new Vector2(.5f, .5f), 32f);
if (groggy) groggyFrames = frames;
else stunFrames = frames;
}
return frames[Mathf.FloorToInt(Mathf.Max(0f, elapsed) / .06f) % frames.Length];
}
public static float HeadLocalHeight(Sprite sprite)
{
if (sprite == null) return 0f;
// First idle frame's central body strip. Excludes weapons, shadows and transparent padding.
string name = sprite.texture.name;
int top = name.StartsWith("ArmoredSkeleton") ? 39
: name.StartsWith("SkeletonArcher") ? 38
: name.StartsWith("GreatswordSkeleton") ? 38
: name.StartsWith("Skeleton") ? 42
: name.StartsWith("Slime") ? 46
: name.StartsWith("Bat") ? 40
: name.StartsWith("NecroGolem") ? 29
: name.StartsWith("Necromancer") ? 30
: name.StartsWith("Necrofire") ? 30
: name.StartsWith("Werebear") ? 40
: name.StartsWith("Werewolf") ? 41
: name.StartsWith("Warlock") ? 34
: name.StartsWith("Lancer") ? 28 : -1;
return top < 0 ? sprite.bounds.max.y
: (sprite.rect.height - top - sprite.pivot.y) / sprite.pixelsPerUnit;
}
public static Vector3 MarkerWorldPosition(SpriteRenderer body, float gap = 4f / 32f)
{
Vector3 head = body.transform.TransformPoint(new Vector3(0f, HeadLocalHeight(body.sprite), 0f));
return head + Vector3.up * (Mathf.Max(0f, gap)
+ MarkerHalfHeight * Mathf.Abs(body.transform.lossyScale.y));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4c3829a856e40ae8198eba14e4432e5f
@@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace BumpCombat.Enemies
{
// The code-owned phase clock samples existing sprite clips; it never deals damage.
public sealed class StageAttackAnimationVisual : MonoBehaviour
{
private readonly Dictionary<string, AnimationClip> clips = new();
private EnemyController controller;
private Animator animator;
private void Awake()
{
controller = GetComponent<EnemyController>();
animator = GetComponent<Animator>();
}
private void LateUpdate()
{
if (controller == null || !controller.isActiveAndEnabled
|| controller.Definition == null || controller.Definition.AttackPatternCount == 0
|| controller.IsDead || controller.IsStunned || controller.IsKnockbackActive
|| controller.IsLaunchVisualActive || animator == null || !animator.enabled
|| animator.runtimeAnimatorController == null)
return;
var state = animator.GetCurrentAnimatorStateInfo(0);
if (state.IsName("Hurt") || state.IsName("Summon") || (animator.IsInTransition(0)
&& animator.GetNextAnimatorStateInfo(0).IsName("Hurt")))
return;
var pattern = controller.CurrentAttackPattern;
var clip = GetClip(pattern.AnimationState);
if (clip == null) return;
float impactTime = Mathf.Min(pattern.AnimationLeadTime, clip.length);
float activeTime = Mathf.Min(pattern.ActiveDuration, Mathf.Max(0f, clip.length - impactTime));
float sampleTime;
if (controller.State == EnemyState.Warning)
{
float remaining = controller.StateDuration * (1f - controller.StateNormalizedTime);
// Necrofire visibly charges throughout its long warning, reaching the
// authored beam frame only when the gameplay clock enters Active.
float lead = controller.Definition.Kind == EnemyKind.Necrofire
? controller.StateDuration : Mathf.Min(impactTime, controller.StateDuration);
if (remaining > lead || lead <= .0001f) return;
sampleTime = impactTime * (1f - remaining / lead);
}
else if (controller.State == EnemyState.Active)
{
sampleTime = impactTime + activeTime * controller.StateNormalizedTime;
}
else if (controller.State == EnemyState.Recovery)
{
float start = impactTime + activeTime;
sampleTime = Mathf.Lerp(start, clip.length, controller.StateNormalizedTime);
}
else return;
clip.SampleAnimation(gameObject, Mathf.Clamp(sampleTime, 0f, Mathf.Max(0f, clip.length - .0001f)));
}
private AnimationClip GetClip(string state)
{
if (string.IsNullOrEmpty(state)) return null;
if (clips.TryGetValue(state, out var found)) return found;
foreach (var clip in animator.runtimeAnimatorController.animationClips)
{
if (clip.name.EndsWith("_" + state, StringComparison.Ordinal)
|| clip.name.Contains("_" + state + "-stage-v1"))
{
clips[state] = clip;
return clip;
}
}
clips[state] = null;
return null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 150ef0547df44492ba209ca7d7c06341
@@ -0,0 +1,222 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace BumpCombat.Enemies
{
// Presentation only: existing sprite frames follow gameplay-owned lifetimes/positions.
public sealed class StageEnemyEffectVisual : MonoBehaviour
{
private static readonly Dictionary<string, Sprite[]> Frames = new();
private SpriteRenderer spriteRenderer;
private Sprite[] frames;
private GameObject owner;
private float elapsed;
private float duration;
private bool repeat;
private bool followsOwner;
private EnemyController aoeOwner;
private SpriteRenderer summonBody;
private const float AoeFallDuration = .35f;
public static StageEnemyEffectVisual PlayAoe(
GameObject owner, Vector2 groundCenter, float radius, float duration)
{
var effect = Create(owner, "Necromancer AOE", "Necromancer_Aoe",
groundCenter, duration, false, false);
// The existing warning remains the authoritative range indicator.
effect.transform.localScale = Vector3.one * (radius * 64f / 46f);
effect.aoeOwner = owner.GetComponent<EnemyController>();
effect.spriteRenderer.enabled = false;
return effect;
}
public static StageEnemyEffectVisual PlaySummon(
GameObject owner, Vector2 groundCenter, float duration)
{
var effect = Create(owner, "Necromancer Summon", "Necromancer_Summon",
groundCenter, duration, false, false);
effect.summonBody = owner.GetComponent<SpriteRenderer>();
effect.LateUpdate();
return effect;
}
private void LateUpdate()
{
if (summonBody == null || summonBody.sprite == null) return;
var sprite = summonBody.sprite;
// shadow-v2: all ten summon frames share shadow bounds x43..58,
// y55..60 (top-down) in the 100px cell. Align to its center.
Vector2 local = (new Vector2(50.5f, 42.5f) - sprite.pivot)
/ sprite.pixelsPerUnit;
if (summonBody.flipX) local.x = -local.x;
if (summonBody.flipY) local.y = -local.y;
transform.position = summonBody.transform.TransformPoint(local);
spriteRenderer.sortingOrder = 1100 - Mathf.RoundToInt(transform.position.y * 100f);
}
public static StageEnemyEffectVisual PlayRay(GameObject owner, Vector2 origin,
Vector2 direction, float length, float width, float duration)
{
var effect = Create(owner, "Necrofire Ray", "Necrofire_Beam",
origin, duration, false, false);
effect.transform.rotation = Quaternion.Euler(0f, 0f,
Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg);
effect.transform.localScale = new Vector3(length * 32f / 100f,
width * 32f / 22f, 1f);
return effect;
}
public static void SetProtection(GameObject owner, bool active)
{
var visual = owner.GetComponent<NecromancerProtectionVisual>();
if (visual == null && active) visual = owner.AddComponent<NecromancerProtectionVisual>();
if (visual != null) visual.Refresh();
}
public static void ClearOwnedEffects(GameObject owner)
{
foreach (var effect in FindObjectsByType<StageEnemyEffectVisual>(
FindObjectsInactive.Include, FindObjectsSortMode.None))
if (effect.owner == owner) Destroy(effect.gameObject);
}
public static StageEnemyEffectVisual AttachProjectile(
GameObject projectile, bool isBeam, float length, float width)
{
var effect = Create(projectile, "Enemy Projectile Sprite",
isBeam ? "Necrofire_Beam" : "SkeletonArcher_Arrow",
projectile.transform.position, .3f, isBeam, true);
effect.transform.SetParent(projectile.transform, false);
if (isBeam)
{
effect.transform.localScale = new Vector3(length * 32f / 100f,
width * 32f / 22f, 1f);
// Gameplay sweeps the leading tip; the existing beam pixels trail it.
effect.transform.localPosition = new Vector3(-length, 0f, 0f);
}
else
{
effect.transform.localScale = new Vector3(length * 32f / 20f,
width * 32f / 7f, 1f);
effect.transform.localPosition = new Vector3(-length * .5f, 0f, 0f);
}
effect.repeat = true;
return effect;
}
private static StageEnemyEffectVisual Create(GameObject owner, string name,
string resource, Vector2 position, float duration, bool repeat, bool follow)
{
var root = new GameObject(name);
root.transform.position = position;
var effect = root.AddComponent<StageEnemyEffectVisual>();
effect.owner = owner;
effect.duration = Mathf.Max(.01f, duration);
effect.repeat = repeat;
effect.followsOwner = follow;
effect.frames = LoadFrames(resource);
effect.spriteRenderer = root.AddComponent<SpriteRenderer>();
effect.spriteRenderer.sortingOrder = 1100 - Mathf.RoundToInt(position.y * 100f);
if (effect.frames.Length > 0) effect.spriteRenderer.sprite = effect.frames[0];
return effect;
}
private static Sprite[] LoadFrames(string resource)
{
if (Frames.TryGetValue(resource, out var cached)) return cached;
var texture = Resources.Load<Texture2D>("Enemies/Stage-v1/" + resource);
if (texture == null) return Array.Empty<Sprite>();
var result = new Sprite[texture.width / 100];
for (int i = 0; i < result.Length; i++)
{
Rect rect;
Vector2 pivot;
if (resource == "SkeletonArcher_Arrow")
{
rect = new Rect(i * 100 + 41, 46, 20, 7);
pivot = new Vector2(.5f, .5f);
}
else if (resource == "Necrofire_Beam")
{
rect = new Rect(i * 100, 46, 100, 22);
pivot = new Vector2(0f, .5f);
}
else
{
rect = new Rect(i * 100, 0, 100, 100);
pivot = resource == "Necromancer_Summon"
? new Vector2(.52f, .435f) : new Vector2(.53f, .44f);
}
result[i] = Sprite.Create(texture, rect, pivot, 32f);
result[i].name = resource + "_visual_" + i;
}
Frames[resource] = result;
return result;
}
private void Update()
{
if (owner == null || !owner.activeInHierarchy)
{
Destroy(gameObject);
return;
}
if (aoeOwner != null)
{
UpdateAoe();
return;
}
elapsed += Time.deltaTime;
if (!repeat && elapsed >= duration)
{
Destroy(gameObject);
return;
}
if (frames.Length > 0)
{
float progress = repeat ? elapsed % duration / duration : elapsed / duration;
int frame = Mathf.FloorToInt(progress * frames.Length);
spriteRenderer.sprite = frames[Mathf.Min(frames.Length - 1, frame)];
}
if (followsOwner)
spriteRenderer.sortingOrder = 1100 - Mathf.RoundToInt(transform.position.y * 100f);
}
private void UpdateAoe()
{
// Use the damage state clock, including knockback and pause, rather than
// allowing an independent effect timer to explode before the attack.
int frame;
if (aoeOwner.State == EnemyState.Warning)
{
float remaining = aoeOwner.StateTimeRemaining;
spriteRenderer.enabled = remaining <= AoeFallDuration;
frame = Mathf.Clamp(Mathf.FloorToInt(
(1f - remaining / AoeFallDuration) * 4f), 0, 3);
}
else if (aoeOwner.State == EnemyState.Active)
{
spriteRenderer.enabled = true;
frame = 4 + Mathf.Min(1, Mathf.FloorToInt(aoeOwner.StateNormalizedTime * 2f));
}
else
{
spriteRenderer.enabled = false;
return;
}
if (frames.Length > 0)
spriteRenderer.sprite = frames[Mathf.Min(frames.Length - 1, frame)];
}
private void OnDisable()
{
if (spriteRenderer != null) spriteRenderer.enabled = false;
}
private void OnEnable()
{
if (spriteRenderer != null) spriteRenderer.enabled = true;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7e6f84c020e454809f0158b2b3c8021e
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 78c4a47b410237343a45e5ba7519ca03
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
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
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5d74c995447bfb240a7a596e1e912117
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Some files were not shown because too many files have changed in this diff Show More