Initial commit: Tiny Tackle Heroes Unity project
This commit is contained in:
@@ -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:
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4ad3c73befbdc67418a27dafec62cf8b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user