Files
tiny_tackle_heroes/Assets/_Project/Scripts/UI/RunHUD.cs
T

2183 lines
79 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections;
using System.Collections.Generic;
using BumpCombat.Combat;
using BumpCombat.Constants;
using BumpCombat.Core;
using BumpCombat.Enemies;
using BumpCombat.Player;
using BumpCombat.Progression;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem.UI;
using UnityEngine.UI;
namespace BumpCombat.UI
{
public sealed class RunHUD : MonoBehaviour
{
[SerializeField] private Image healthFill;
[SerializeField] private Text healthText;
[SerializeField] private Image experienceFill;
[SerializeField] private Text levelText;
[SerializeField] private Text timerText;
[SerializeField] private Text enemyCountText;
[SerializeField] private Text hitDebugText;
[SerializeField] private Text eventText;
[SerializeField] private GameObject gameOverPanel;
[SerializeField] private Text damagePopupTemplate;
[SerializeField, Min(1)] private int maxConcurrentDamagePopups = 12;
private PlayerHealth playerHealth;
private PlayerStats playerStats;
private ActiveArtifactController activeArtifactController;
private ExperienceSystem experienceSystem;
private RunManager runManager;
private BumpCombat.Spawning.SpawnDirector spawnDirector;
private Camera worldCamera;
private Image artifactGaugeFill;
private Text artifactGaugeText;
private Text artifactNameText;
private Text artifactStateText;
private Image guardGaugeFill;
private Image guardGaugeTrack;
private Text guardLabelText;
private Text pressureStreakText;
private bool showingTimedEventMessage;
private Image healthGaugeTrack;
private Image experienceGaugeTrack;
private GameObject statusPanel;
private GameObject artifactPanel;
private CanvasGroup compactHudGroup;
private GameObject timerPanel;
private GameObject debugSpawnPanel;
private Button[] debugSpawnButtons = System.Array.Empty<Button>();
private GameObject minimapPanel;
private RectTransform minimapMapRect;
private RectTransform minimapMarkerRoot;
private RectTransform minimapViewportRoot;
private Image minimapPlayerMarker;
private Image[] minimapViewportEdges = System.Array.Empty<Image>();
private readonly List<Image> minimapEnemyMarkers = new();
private EnemyController[] minimapEnemies = System.Array.Empty<EnemyController>();
private float minimapEnemyRefreshElapsed;
private ArenaBounds arenaBounds;
// The panel sprite has transparent source padding. At multiplier 1,
// 6px of canvas space keeps the map inside the visible frame.
private static readonly Vector2 MinimapSize = new(288f, 150f);
private Image[] artifactSlotImages = System.Array.Empty<Image>();
private Image[] artifactSlotIcons = System.Array.Empty<Image>();
private readonly Stack<Text> damagePopupPool = new();
private readonly HashSet<Text> activeDamagePopups = new();
private static readonly RunTimedEvent[] NextBossEvents =
{
RunTimedEvent.MidBoss,
RunTimedEvent.FinalBoss,
};
private static readonly string[] NextBossLabels =
{
"다음 중간보스",
"다음 보스",
};
private int activeDamagePopupCount;
private int damagePopupLifecycleVersion;
private float compactHudFadeElapsed;
private int lastNextEventSecond = -1;
private bool lastNextEventUsesDebugTimes;
private bool lastNextEventIsProductionRun;
private static readonly Color PlayerDamagePopupColor =
new(244f / 255f, 117f / 255f, 112f / 255f, 1f);
private static readonly Color GuardReadyColor = new Color32(255, 205, 92, 255);
private static readonly Color GuardActiveColor = new Color32(255, 232, 128, 255);
private static readonly Color GuardCooldownColor = new Color32(137, 129, 123, 255);
private void Start()
{
PresentationUiStyle.EnsureEventSystem(
clearMenuNavigationActions: false);
playerHealth = FindAnyObjectByType<PlayerHealth>();
playerStats = FindAnyObjectByType<PlayerStats>();
activeArtifactController = FindAnyObjectByType<ActiveArtifactController>();
experienceSystem = FindAnyObjectByType<ExperienceSystem>();
runManager = RunManager.Instance;
spawnDirector = FindAnyObjectByType<BumpCombat.Spawning.SpawnDirector>();
worldCamera = Camera.main;
playerHealth.OnHealthChanged += UpdateHealth;
playerHealth.OnDamageTaken += HandlePlayerDamageTaken;
experienceSystem.OnExperienceChanged += UpdateExperience;
CombatEvents.OnValidHit += HandleValidHit;
runManager.OnTimedEvent += HandleTimedEvent;
runManager.OnGameOver += ShowGameOver;
UpdateHealth(playerHealth.CurrentHealth, playerHealth.MaxHealth);
UpdateExperience(
experienceSystem.CurrentExperience,
experienceSystem.RequiredExperience,
experienceSystem.Level);
ApplyStaticHudStyle();
if (activeArtifactController != null)
{
CreateArtifactHud();
UpdateArtifactHud();
}
UpdateGuardHud();
CreateDebugSpawnHud();
UpdateDebugSpawnHud();
CreateMinimapHud();
UpdateMinimapHud();
gameOverPanel.SetActive(false);
damagePopupTemplate.gameObject.SetActive(false);
compactHudFadeElapsed = 0f;
SetCompactHudAlpha(1f);
}
private void OnDestroy()
{
if (playerHealth != null)
{
playerHealth.OnHealthChanged -= UpdateHealth;
playerHealth.OnDamageTaken -= HandlePlayerDamageTaken;
}
if (experienceSystem != null)
{
experienceSystem.OnExperienceChanged -= UpdateExperience;
}
if (runManager != null)
{
runManager.OnTimedEvent -= HandleTimedEvent;
runManager.OnGameOver -= ShowGameOver;
}
CombatEvents.OnValidHit -= HandleValidHit;
}
private void OnDisable()
{
// A disabled HUD must not leave visible popups or a stale cap when
// the scene is re-enabled. Coroutines from the previous lifecycle
// are fenced off by the version check in ShowDamagePopup.
damagePopupLifecycleVersion++;
foreach (Text popup in activeDamagePopups)
{
if (popup != null)
{
// Active entries belong to the old lifecycle. Destroying
// them prevents repeated HUD toggles from orphaning
// transient Text objects; the version fence keeps their
// stopped/finishing coroutines from returning them later.
Destroy(popup.gameObject);
}
}
activeDamagePopups.Clear();
activeDamagePopupCount = 0;
compactHudFadeElapsed = 0f;
SetCompactHudAlpha(1f);
}
private void OnEnable()
{
compactHudFadeElapsed = 0f;
SetCompactHudAlpha(1f);
}
private void Update()
{
UpdateArtifactHud();
UpdateGuardHud();
UpdatePressureStreakHud();
UpdateDebugSpawnHud();
UpdateMinimapHud();
compactHudFadeElapsed += Time.unscaledDeltaTime;
if (compactHudFadeElapsed >= 0.1f)
{
compactHudFadeElapsed = 0f;
UpdateCompactHudVisibility();
}
if (runManager == null)
{
return;
}
int seconds = Mathf.FloorToInt(runManager.ElapsedTime);
timerText.text = $"{seconds / 60:00}:{seconds % 60:00}";
UpdateNextEventHud();
enemyCountText.text =
$"ENEMIES {EnemyController.AliveCount}/{spawnDirector?.MaximumAliveEnemies ?? 100}";
}
private void UpdateCompactHudVisibility()
{
if (compactHudGroup == null
|| statusPanel == null
|| worldCamera == null)
{
return;
}
Rect clusterRect = GetScreenRect(
statusPanel.GetComponent<RectTransform>());
bool overlaps = false;
if (playerHealth != null)
{
overlaps = TryGetActorScreenRect(
playerHealth.gameObject,
out Rect playerRect)
&& clusterRect.Overlaps(playerRect, true);
}
if (!overlaps && EnemyController.AliveCount > 0)
{
EnemyController[] enemies = Object.FindObjectsByType<EnemyController>(
FindObjectsInactive.Exclude,
FindObjectsSortMode.None);
for (int i = 0; i < enemies.Length; i++)
{
EnemyController enemy = enemies[i];
if (enemy == null || !enemy.isActiveAndEnabled || enemy.IsDead)
{
continue;
}
if (TryGetActorScreenRect(enemy.gameObject, out Rect enemyRect)
&& clusterRect.Overlaps(enemyRect, true))
{
overlaps = true;
break;
}
}
}
SetCompactHudAlpha(overlaps ? 0.4f : 1f);
}
private bool TryGetActorScreenRect(GameObject actor, out Rect screenRect)
{
screenRect = default;
if (actor == null || !actor.activeInHierarchy)
{
return false;
}
bool hasBounds = false;
Bounds worldBounds = default;
SpriteRenderer sprite = actor.GetComponent<SpriteRenderer>();
if (sprite != null && sprite.enabled && sprite.gameObject.activeInHierarchy)
{
worldBounds = sprite.bounds;
hasBounds = true;
}
Collider2D collider = actor.GetComponent<Collider2D>();
if (collider != null && collider.enabled && collider.gameObject.activeInHierarchy)
{
if (hasBounds)
{
worldBounds.Encapsulate(collider.bounds);
}
else
{
worldBounds = collider.bounds;
hasBounds = true;
}
}
if (!hasBounds)
{
return false;
}
screenRect = WorldBoundsToScreenRect(worldBounds);
return screenRect.width > 0f && screenRect.height > 0f;
}
private Rect WorldBoundsToScreenRect(Bounds bounds)
{
Vector3 center = bounds.center;
Vector3[] corners =
{
new(bounds.min.x, bounds.min.y, center.z),
new(bounds.min.x, bounds.max.y, center.z),
new(bounds.max.x, bounds.min.y, center.z),
new(bounds.max.x, bounds.max.y, center.z),
};
Vector3 first = worldCamera.WorldToScreenPoint(corners[0]);
float minX = first.x;
float maxX = first.x;
float minY = first.y;
float maxY = first.y;
for (int i = 1; i < corners.Length; i++)
{
Vector3 point = worldCamera.WorldToScreenPoint(corners[i]);
minX = Mathf.Min(minX, point.x);
maxX = Mathf.Max(maxX, point.x);
minY = Mathf.Min(minY, point.y);
maxY = Mathf.Max(maxY, point.y);
}
return Rect.MinMaxRect(minX, minY, maxX, maxY);
}
private static Rect GetScreenRect(RectTransform rect)
{
Vector3[] corners = new Vector3[4];
rect.GetWorldCorners(corners);
return Rect.MinMaxRect(
corners[0].x,
corners[0].y,
corners[2].x,
corners[2].y);
}
private void SetCompactHudAlpha(float alpha)
{
if (compactHudGroup != null)
{
compactHudGroup.alpha = alpha;
}
}
private void UpdateHealth(float current, float maximum)
{
healthFill.fillAmount = maximum > 0f ? current / maximum : 0f;
healthText.text = $"HP {Mathf.CeilToInt(current)}/{Mathf.CeilToInt(maximum)}";
}
private void UpdateExperience(int current, int required, int level)
{
experienceFill.fillAmount = required > 0 ? current / (float)required : 0f;
levelText.text = $"LV {level}";
}
private void ApplyStaticHudStyle()
{
PresentationUiStyle.ApplyText(healthText, 24);
PresentationUiStyle.ApplyText(levelText, 18);
PresentationUiStyle.ApplyText(timerText, 30);
PresentationUiStyle.ApplyText(enemyCountText, 18);
PresentationUiStyle.ApplyText(hitDebugText, 18);
PresentationUiStyle.ApplyText(eventText, 21);
ConfigureCompactHudText(healthText, 24, 24);
ConfigureCompactHudText(levelText, 18, 18);
timerText.alignment = TextAnchor.MiddleCenter;
LayoutStatusHud();
EnsureTimerPanel();
EnsureNextEventText();
LayoutDebugText();
Image healthPanel = healthFill != null
? healthFill.transform.parent.GetComponent<Image>()
: null;
Image experiencePanel = experienceFill != null
? experienceFill.transform.parent.GetComponent<Image>()
: null;
PresentationUiStyle.ApplyPanel(healthPanel);
PresentationUiStyle.ApplyPanel(experiencePanel);
LayoutExperienceLevelText(
experienceFill != null ? experienceFill.transform.parent : null);
PresentationUiStyle.ApplyGaugeFill(
healthFill,
new Color32(207, 99, 102, 255));
PresentationUiStyle.ApplyGaugeFill(
experienceFill,
new Color32(114, 179, 161, 255));
EnsureHealthGaugeTrack();
EnsureExperienceGaugeTrack();
CreateGuardHud();
if (gameOverPanel != null)
{
PresentationUiStyle.ApplyPanel(
gameOverPanel.GetComponent<Image>(),
true);
PresentationUiStyle.ApplyText(
gameOverPanel.GetComponentInChildren<Text>(true),
34);
}
PresentationUiStyle.ApplyDamagePopup(damagePopupTemplate, 30);
}
private void LayoutStatusHud()
{
if (statusPanel != null || healthFill == null || experienceFill == null)
{
return;
}
statusPanel = CreateRuntimePanel(
transform,
"Run Status HUD",
Vector2.zero,
new Vector2(24f, 24f),
new Vector2(392f, 136f),
Color.clear);
SetRectTransform(
statusPanel.GetComponent<RectTransform>(),
Vector2.zero,
Vector2.zero,
new Vector2(24f, 24f),
new Vector2(392f, 136f));
Image statusImage = statusPanel.GetComponent<Image>();
PresentationUiStyle.ApplyPanel(statusImage);
statusImage.enabled = false;
compactHudGroup = statusPanel.AddComponent<CanvasGroup>();
compactHudGroup.interactable = false;
compactHudGroup.blocksRaycasts = false;
Transform healthPanel = healthFill.transform.parent;
Transform experiencePanel = experienceFill.transform.parent;
healthPanel.SetParent(statusPanel.transform, false);
experiencePanel.SetParent(statusPanel.transform, false);
SetRectTransform(
healthPanel.GetComponent<RectTransform>(),
new Vector2(0.5f, 0.5f),
new Vector2(0.5f, 0.5f),
new Vector2(-16f, 61f - 136f * 0.5f),
new Vector2(352f, 12f));
SetRectTransform(
experiencePanel.GetComponent<RectTransform>(),
new Vector2(0.5f, 0.5f),
new Vector2(0.5f, 0.5f),
new Vector2(-16f, -39f),
new Vector2(352f, 8f));
Image healthPanelImage = healthPanel.GetComponent<Image>();
Image experiencePanelImage = experiencePanel.GetComponent<Image>();
healthPanelImage.enabled = false;
experiencePanelImage.enabled = false;
healthPanelImage.raycastTarget = false;
experiencePanelImage.raycastTarget = false;
healthText.transform.SetParent(statusPanel.transform, false);
SetRectTransform(
healthText.rectTransform,
new Vector2(0.5f, 0.5f),
new Vector2(0.5f, 0.5f),
new Vector2(78f, 48f),
new Vector2(172f, 32f));
healthText.alignment = TextAnchor.MiddleCenter;
healthText.transform.SetAsLastSibling();
SetRectTransform(
healthFill.rectTransform,
new Vector2(0.5f, 0.5f),
new Vector2(0.5f, 0.5f),
new Vector2(0f, 0f),
new Vector2(352f, 12f));
SetRectTransform(
experienceFill.rectTransform,
new Vector2(0.5f, 0.5f),
new Vector2(0.5f, 0.5f),
new Vector2(0f, 0f),
new Vector2(352f, 8f));
pressureStreakText = CreateRuntimeText(
statusPanel.transform,
"Pressure Streak",
PresentationUiStyle.GetGalmuriFont(),
14,
TextAnchor.MiddleCenter,
new Vector2(0f, -64f),
new Vector2(352f, 18f));
ConfigureCompactHudText(pressureStreakText, 14, 14);
pressureStreakText.color = new Color32(255, 205, 92, 255);
pressureStreakText.gameObject.SetActive(false);
}
private void LayoutExperienceLevelText(Transform experiencePanel)
{
if (levelText == null || statusPanel == null || experiencePanel == null)
{
return;
}
levelText.transform.SetParent(experiencePanel, false);
RectTransform rect = levelText.rectTransform;
rect.anchorMin = new Vector2(0.5f, 0.5f);
rect.anchorMax = new Vector2(0.5f, 0.5f);
rect.pivot = new Vector2(0.5f, 0.5f);
rect.anchoredPosition = Vector2.zero;
rect.sizeDelta = new Vector2(352f, 24f);
levelText.alignment = TextAnchor.MiddleCenter;
levelText.transform.SetAsLastSibling();
}
private void CreateGuardHud()
{
if (statusPanel == null || guardGaugeFill != null)
{
return;
}
Font font = PresentationUiStyle.GetGalmuriFont();
GameObject track = CreateRuntimePanel(
statusPanel.transform,
"Guard Gauge Track",
new Vector2(0.5f, 0.5f),
new Vector2(182f, -3f),
new Vector2(18f, 94f),
new Color32(58, 55, 57, 255));
guardGaugeTrack = track.GetComponent<Image>();
PresentationUiStyle.ApplyPanel(guardGaugeTrack, true);
guardGaugeTrack.pixelsPerUnitMultiplier = 5f;
guardGaugeTrack.color = new Color32(103, 96, 94, 255);
GameObject fill = new(
"Guard Gauge Fill",
typeof(RectTransform),
typeof(CanvasRenderer),
typeof(Image));
fill.transform.SetParent(track.transform, false);
RectTransform fillRect = fill.GetComponent<RectTransform>();
fillRect.anchorMin = Vector2.zero;
fillRect.anchorMax = Vector2.one;
fillRect.offsetMin = new Vector2(2f, 2f);
fillRect.offsetMax = new Vector2(-2f, -2f);
guardGaugeFill = fill.GetComponent<Image>();
PresentationUiStyle.ApplyGaugeFill(
guardGaugeFill,
new Color32(145, 133, 125, 255));
guardGaugeFill.fillMethod = Image.FillMethod.Vertical;
guardGaugeFill.fillOrigin = 0;
guardGaugeFill.fillAmount = 0f;
guardLabelText = CreateRuntimeText(
statusPanel.transform,
"Guard Label",
font,
14,
TextAnchor.MiddleCenter,
new Vector2(182f, 56f),
new Vector2(32f, 20f));
ConfigureCompactHudText(guardLabelText, 14, 14);
guardLabelText.text = "D";
guardLabelText.color = GuardCooldownColor;
}
private void UpdateGuardHud()
{
if (playerHealth == null || guardGaugeFill == null)
{
return;
}
bool unlocked = RunManager.Instance?.IsGuardUnlocked ?? true;
bool active = unlocked && playerHealth.IsGuarding;
bool ready = unlocked
&& playerHealth.GuardCooldownRemaining <= 0f;
guardGaugeFill.fillAmount = unlocked
? Mathf.Clamp01(playerHealth.GuardReadinessNormalized)
: 0f;
Color stateColor = active
? GuardActiveColor
: ready
? GuardReadyColor
: GuardCooldownColor;
guardGaugeFill.color = stateColor;
guardLabelText.text = unlocked ? "D" : "D×";
guardLabelText.color = stateColor;
}
private void UpdatePressureStreakHud()
{
if (pressureStreakText == null)
{
return;
}
if (playerStats == null
|| runManager == null
|| runManager.IsTitleScreen
|| runManager.IsPaused
|| runManager.IsSelectionOpen
|| runManager.IsGameOver)
{
pressureStreakText.gameObject.SetActive(false);
return;
}
playerStats.RefreshPressureStreak();
float remaining = playerStats.PressureStreakRemaining;
int killCount = playerStats.PressureStreakKillCount;
if (killCount <= 0)
{
pressureStreakText.gameObject.SetActive(false);
return;
}
pressureStreakText.gameObject.SetActive(true);
if (remaining > 0f)
{
pressureStreakText.text =
$"연속 처치 {killCount}/{playerStats.PressureStreakKillTarget}"
+ $" · {remaining:0.0}초";
if (playerStats.IsPressureStreakActive)
{
float increase = GameplayConstants.Current.Player
.PressureStreakMoveSpeedIncrease * 100f;
pressureStreakText.text =
$"연속 처치 {killCount}/{playerStats.PressureStreakKillTarget}"
+ $" · 이속 +{increase:0}% {remaining:0.0}초";
}
}
else
{
pressureStreakText.text =
$"연속 처치 {killCount}/{playerStats.PressureStreakKillTarget}";
}
}
private void EnsureHealthGaugeTrack()
{
if (healthGaugeTrack != null || healthFill == null)
{
return;
}
GameObject trackObject = new(
"Health Gauge Track",
typeof(RectTransform),
typeof(CanvasRenderer),
typeof(Image));
trackObject.transform.SetParent(healthFill.transform.parent, false);
RectTransform trackRect = trackObject.GetComponent<RectTransform>();
RectTransform fillRect = healthFill.rectTransform;
trackRect.anchorMin = fillRect.anchorMin;
trackRect.anchorMax = fillRect.anchorMax;
trackRect.pivot = fillRect.pivot;
trackRect.anchoredPosition = fillRect.anchoredPosition;
trackRect.sizeDelta = fillRect.sizeDelta;
healthGaugeTrack = trackObject.GetComponent<Image>();
healthGaugeTrack.color = new Color32(17, 24, 32, 255);
healthGaugeTrack.raycastTarget = false;
trackObject.transform.SetSiblingIndex(healthFill.transform.GetSiblingIndex());
}
private void EnsureExperienceGaugeTrack()
{
if (experienceGaugeTrack != null || experienceFill == null)
{
return;
}
GameObject trackObject = new(
"Experience Gauge Track",
typeof(RectTransform),
typeof(CanvasRenderer),
typeof(Image));
trackObject.transform.SetParent(
experienceFill.transform.parent,
false);
RectTransform trackRect = trackObject.GetComponent<RectTransform>();
RectTransform fillRect = experienceFill.rectTransform;
trackRect.anchorMin = fillRect.anchorMin;
trackRect.anchorMax = fillRect.anchorMax;
trackRect.pivot = fillRect.pivot;
trackRect.anchoredPosition = fillRect.anchoredPosition;
trackRect.sizeDelta = fillRect.sizeDelta;
experienceGaugeTrack = trackObject.GetComponent<Image>();
experienceGaugeTrack.color = new Color32(17, 24, 32, 255);
experienceGaugeTrack.raycastTarget = false;
trackObject.transform.SetSiblingIndex(
experienceFill.transform.GetSiblingIndex());
}
private static void ConfigureCompactHudText(
Text text,
int minSize = 12,
int maxSize = 24)
{
if (text == null)
{
return;
}
text.horizontalOverflow = HorizontalWrapMode.Overflow;
text.verticalOverflow = VerticalWrapMode.Overflow;
text.resizeTextForBestFit = true;
text.resizeTextMinSize = minSize;
text.resizeTextMaxSize = maxSize;
}
private void EnsureTimerPanel()
{
if (timerText == null || timerPanel != null)
{
return;
}
SetRectTransform(
timerText.rectTransform,
new Vector2(0.5f, 1f),
new Vector2(0.5f, 1f),
new Vector2(0f, -24f),
new Vector2(180f, 66f));
timerPanel = CreateRuntimePanel(
transform,
"Timer Panel",
new Vector2(0.5f, 1f),
new Vector2(0f, -24f),
new Vector2(180f, 66f),
new Color(0.02f, 0.03f, 0.06f, 0.88f));
SetRectTransform(
timerPanel.GetComponent<RectTransform>(),
new Vector2(0.5f, 1f),
new Vector2(0.5f, 1f),
new Vector2(0f, -24f),
new Vector2(180f, 66f));
PresentationUiStyle.ApplyPanel(timerPanel.GetComponent<Image>());
timerPanel.transform.SetSiblingIndex(
timerText.transform.GetSiblingIndex());
}
private void EnsureNextEventText()
{
if (eventText == null)
{
return;
}
SetRectTransform(
eventText.rectTransform,
new Vector2(0.5f, 1f),
new Vector2(0.5f, 1f),
new Vector2(0f, -100f),
new Vector2(420f, 28f));
ConfigureCompactHudText(eventText, 14, 18);
eventText.gameObject.SetActive(true);
UpdateNextEventHud();
}
private void UpdateNextEventHud()
{
if (eventText == null || runManager == null || showingTimedEventMessage)
{
return;
}
int elapsedSecond = Mathf.FloorToInt(runManager.ElapsedTime);
if (elapsedSecond == lastNextEventSecond
&& runManager.UseDebugEventTimes == lastNextEventUsesDebugTimes
&& runManager.IsProductionRun == lastNextEventIsProductionRun)
{
return;
}
lastNextEventSecond = elapsedSecond;
lastNextEventUsesDebugTimes = runManager.UseDebugEventTimes;
lastNextEventIsProductionRun = runManager.IsProductionRun;
float nextTime = float.PositiveInfinity;
string nextLabel = "다음 이벤트 없음";
for (int i = 0; i < runManager.CurrentEliteEventCount; i++)
{
float eliteTime = runManager.GetEliteEventTimeForCurrentRun(i);
if (eliteTime > runManager.ElapsedTime)
{
nextTime = eliteTime;
nextLabel = "다음 엘리트";
break;
}
}
for (int i = 0; i < NextBossEvents.Length; i++)
{
float eventTime = runManager.GetEventTimeForCurrentRun(
NextBossEvents[i]);
if (eventTime > runManager.ElapsedTime && eventTime < nextTime)
{
nextTime = eventTime;
nextLabel = NextBossLabels[i];
}
}
if (float.IsPositiveInfinity(nextTime))
{
eventText.text = nextLabel;
return;
}
int seconds = Mathf.FloorToInt(nextTime);
eventText.text =
$"{nextLabel} {seconds / 60:00}:{seconds % 60:00}";
}
private void LayoutDebugText()
{
SetRectTransform(
enemyCountText.rectTransform,
new Vector2(0f, 1f),
new Vector2(0f, 1f),
new Vector2(24f, -180f),
new Vector2(360f, 28f));
SetRectTransform(
hitDebugText.rectTransform,
new Vector2(0f, 1f),
new Vector2(0f, 1f),
new Vector2(24f, -214f),
new Vector2(480f, 28f));
}
private void HandleValidHit(CombatHitResult result)
{
string hitLabel = !string.IsNullOrEmpty(result.SourceId)
? $"ARTIFACT {GetArtifactHitDisplayName(result.SourceId)}"
: result.HasBackBonus
? "BACK ×1.5"
: result.IsOffsetHit
? "OFFSET ×1.2"
: result.HasPositionBonus
? "SIDE ×1.2"
: "FRONT";
if (result.LaunchSucceeded)
{
hitLabel = $"LAUNCH {hitLabel}";
}
string damageText = DamageCalculator.FormatDamage(result.Damage);
hitDebugText.text = $"{hitLabel} {damageText}";
string popupText = !string.IsNullOrEmpty(result.SourceId)
? $"{GetArtifactHitDisplayName(result.SourceId)} {damageText}"
: result.HasBackBonus
? $"BACK ×1.5 {damageText}"
: result.IsOffsetHit
? $"OFFSET ×1.2 {damageText}"
: result.HasPositionBonus
? $"SIDE ×1.2 {damageText}"
: damageText;
if (result.LaunchSucceeded)
{
popupText = $"LAUNCH {popupText}";
}
Color popupColor;
if (!TryGetArtifactPopupColor(result, out popupColor))
{
popupColor = result.LaunchSucceeded
? new Color(0.95f, 0.55f, 1f)
: result.HasBackBonus
? new Color(1f, 0.75f, 0.2f)
: result.HasPositionBonus
? new Color(0.65f, 1f, 0.3f)
: Color.white;
}
TryShowDamagePopup(
popupText,
popupColor,
result.Target.transform.position);
}
private static bool TryGetArtifactPopupColor(
CombatHitResult result,
out Color color)
{
if (result.IsArtifactHit)
{
color = GetArtifactPopupColor(result.ArtifactEffect);
return true;
}
if (TryGetLegacyArtifactEffect(result.SourceId, out ActiveArtifactEffect effect))
{
color = GetArtifactPopupColor(effect);
return true;
}
color = Color.white;
return false;
}
private static Color GetArtifactPopupColor(ActiveArtifactEffect effect)
{
if (effect < ActiveArtifactEffect.Dash
|| effect > ActiveArtifactEffect.ChainLightning)
{
return Color.white;
}
return ActiveArtifactDefinition.GetArtifactPaletteColor(
ActiveArtifactDefinition.GetArtifactColor(effect));
}
private static bool TryGetLegacyArtifactEffect(
string sourceId,
out ActiveArtifactEffect effect)
{
if (string.Equals(sourceId, "dash", System.StringComparison.OrdinalIgnoreCase))
{
effect = ActiveArtifactEffect.Dash;
return true;
}
if (string.Equals(sourceId, "pulse", System.StringComparison.OrdinalIgnoreCase))
{
effect = ActiveArtifactEffect.Pulse;
return true;
}
if (string.Equals(sourceId, "phoenix", System.StringComparison.OrdinalIgnoreCase)
|| string.Equals(sourceId, "scorching_ray", System.StringComparison.OrdinalIgnoreCase))
{
effect = ActiveArtifactEffect.Phoenix;
return true;
}
if (string.Equals(sourceId, "cyclone", System.StringComparison.OrdinalIgnoreCase))
{
effect = ActiveArtifactEffect.Cyclone;
return true;
}
if (string.Equals(sourceId, "thunder_crash", System.StringComparison.OrdinalIgnoreCase))
{
effect = ActiveArtifactEffect.ThunderCrash;
return true;
}
if (string.Equals(sourceId, "chain_lightning", System.StringComparison.OrdinalIgnoreCase)
|| string.Equals(sourceId, "arc", System.StringComparison.OrdinalIgnoreCase))
{
effect = ActiveArtifactEffect.ChainLightning;
return true;
}
effect = default;
return false;
}
private void HandlePlayerDamageTaken(float damage)
{
if (damage <= 0f || playerHealth == null)
{
return;
}
TryShowDamagePopup(
$"-{DamageCalculator.FormatDamage(damage)}",
PlayerDamagePopupColor,
playerHealth.transform.position + Vector3.up * 0.75f);
}
private bool TryShowDamagePopup(
string text,
Color color,
Vector3 worldPosition)
{
if (damagePopupTemplate == null
|| worldCamera == null
|| !isActiveAndEnabled
|| activeDamagePopupCount >= Mathf.Max(1, maxConcurrentDamagePopups))
{
return false;
}
// Increment before starting the coroutine so attack and player
// damage raised in the same frame share one deterministic cap.
activeDamagePopupCount++;
StartCoroutine(
ShowDamagePopup(
text,
color,
worldPosition,
damagePopupLifecycleVersion));
return true;
}
private IEnumerator ShowDamagePopup(
string text,
Color color,
Vector3 worldPosition,
int lifecycleVersion)
{
Text popup = null;
try
{
popup = damagePopupPool.Count > 0
? damagePopupPool.Pop()
: Instantiate(
damagePopupTemplate,
damagePopupTemplate.transform.parent);
activeDamagePopups.Add(popup);
PresentationUiStyle.ApplyDamagePopup(popup, 30);
popup.gameObject.SetActive(true);
popup.text = text;
popup.color = color;
RectTransform rect = popup.rectTransform;
float minimumWidth = Mathf.Max(
300f,
damagePopupTemplate.rectTransform.rect.width);
float width = Mathf.Max(
minimumWidth,
popup.preferredWidth + 16f);
rect.SetSizeWithCurrentAnchors(
RectTransform.Axis.Horizontal,
width);
rect.position = worldCamera.WorldToScreenPoint(worldPosition);
float elapsed = 0f;
while (elapsed < 0.6f
&& lifecycleVersion == damagePopupLifecycleVersion
&& isActiveAndEnabled)
{
elapsed += Time.unscaledDeltaTime;
rect.anchoredPosition +=
Vector2.up * (40f * Time.unscaledDeltaTime);
Color fadedColor = popup.color;
fadedColor.a = 1f - elapsed / 0.6f;
popup.color = fadedColor;
yield return null;
}
}
finally
{
bool ownsPopup = popup != null
&& activeDamagePopups.Remove(popup)
&& lifecycleVersion == damagePopupLifecycleVersion;
if (popup != null)
{
popup.gameObject.SetActive(false);
if (ownsPopup)
{
damagePopupPool.Push(popup);
}
}
if (lifecycleVersion == damagePopupLifecycleVersion)
{
activeDamagePopupCount = Mathf.Max(
0,
activeDamagePopupCount - 1);
}
}
}
private string GetArtifactHitDisplayName(string sourceId)
{
if (activeArtifactController != null)
{
for (int i = 0; i < activeArtifactController.CatalogCount; i++)
{
ActiveArtifactDefinition definition =
activeArtifactController.GetCatalogArtifactAt(i);
if (definition != null
&& string.Equals(
definition.ArtifactId,
sourceId,
System.StringComparison.OrdinalIgnoreCase))
{
return definition.DisplayName;
}
}
}
return sourceId.ToUpperInvariant();
}
private void CreateArtifactHud()
{
Font font = PresentationUiStyle.GetGalmuriFont();
int slotCount = Mathf.Clamp(
activeArtifactController.MaxOwnedArtifacts,
1,
3);
artifactSlotImages = new Image[slotCount];
artifactSlotIcons = new Image[slotCount];
const float panelWidth = 360f;
const float panelHeight = 136f;
const float slotSize = 60f;
const float slotSpacing = 62f;
const float gaugeWidth = 352f;
const float gaugeCenterX = 180f;
artifactPanel = CreateRuntimePanel(
statusPanel.transform,
"Active Artifact HUD",
new Vector2(0.5f, 0.5f),
new Vector2(panelWidth * 0.5f, panelHeight * 0.5f),
new Vector2(panelWidth, panelHeight),
Color.clear);
SetRectTransform(
artifactPanel.GetComponent<RectTransform>(),
new Vector2(0.5f, 0.5f),
new Vector2(0.5f, 0.5f),
new Vector2(-16f, 0f),
new Vector2(panelWidth, panelHeight));
Image artifactPanelImage = artifactPanel.GetComponent<Image>();
PresentationUiStyle.ApplyPanel(artifactPanelImage);
artifactPanelImage.enabled = false;
for (int i = 0; i < artifactSlotImages.Length; i++)
{
GameObject slot = CreateRuntimePanel(
artifactPanel.transform,
$"Artifact Slot {i + 1}",
new Vector2(0.5f, 0.5f),
new Vector2(
30f + i * slotSpacing - panelWidth * 0.5f,
34f),
new Vector2(slotSize, slotSize),
new Color(0.12f, 0.14f, 0.2f, 0.9f));
artifactSlotImages[i] = slot.GetComponent<Image>();
PresentationUiStyle.ApplySlot(
artifactSlotImages[i],
false,
new Color(0.12f, 0.14f, 0.2f, 0.9f));
artifactSlotIcons[i] = CreateRuntimeImage(
slot.transform,
"Icon",
Vector2.zero,
new Vector2(48f, 48f));
}
GameObject gauge = CreateRuntimePanel(
artifactPanel.transform,
"Shared Gauge",
new Vector2(0.5f, 0.5f),
new Vector2(gaugeCenterX - panelWidth * 0.5f, -24f),
new Vector2(gaugeWidth, 18f),
new Color(0f, 0f, 0f, 0.75f));
GameObject fill = new(
"Fill",
typeof(RectTransform),
typeof(CanvasRenderer),
typeof(Image));
fill.transform.SetParent(gauge.transform, false);
RectTransform fillRect = fill.GetComponent<RectTransform>();
fillRect.anchorMin = Vector2.zero;
fillRect.anchorMax = Vector2.one;
fillRect.offsetMin = new Vector2(2f, 2f);
fillRect.offsetMax = new Vector2(-2f, -2f);
artifactGaugeFill = fill.GetComponent<Image>();
PresentationUiStyle.ApplyGaugeFill(
artifactGaugeFill,
new Color32(182, 148, 214, 255));
artifactGaugeText = CreateRuntimeText(
gauge.transform,
"Label",
font,
18,
TextAnchor.MiddleCenter,
Vector2.zero,
new Vector2(gaugeWidth, 26f));
PresentationUiStyle.ApplyText(artifactGaugeText, 18);
artifactNameText = CreateRuntimeText(
gauge.transform,
"Artifact Name",
font,
22,
TextAnchor.MiddleCenter,
new Vector2(0f, 32f),
new Vector2(gaugeWidth, 34f));
PresentationUiStyle.ApplyText(artifactNameText, 30);
ConfigureCompactHudText(artifactNameText, 14, 32);
artifactNameText.gameObject.SetActive(false);
artifactStateText = CreateRuntimeText(
artifactPanel.transform,
"State",
font,
18,
TextAnchor.MiddleCenter,
new Vector2(296f - panelWidth * 0.5f, 21f),
new Vector2(120f, 24f));
ConfigureCompactHudText(artifactStateText, 11, 18);
}
private void UpdateArtifactHud()
{
if (activeArtifactController == null || artifactGaugeFill == null)
{
return;
}
float maximum = activeArtifactController.MaxGauge;
float current = activeArtifactController.CurrentGauge;
artifactGaugeFill.fillAmount = maximum > 0f ? current / maximum : 0f;
artifactGaugeText.text =
$"ART {Mathf.FloorToInt(current)}/{Mathf.CeilToInt(maximum)}";
for (int i = 0; i < artifactSlotImages.Length; i++)
{
ActiveArtifactDefinition definition =
activeArtifactController.GetArtifactAt(i);
bool selected = i == activeArtifactController.SelectedIndex
&& definition != null;
Color color = definition != null
? definition.IconColor
: new Color(0.12f, 0.14f, 0.2f, 0.9f);
PresentationUiStyle.ApplySlot(
artifactSlotImages[i],
selected,
selected ? color : Color.Lerp(color, Color.black, 0.6f));
Sprite icon = PresentationUiStyle.GetArtifactIcon(definition);
artifactSlotIcons[i].sprite = icon;
artifactSlotIcons[i].enabled = icon != null && definition != null;
artifactSlotIcons[i].color = Color.white;
}
ActiveArtifactDefinition currentArtifact =
activeArtifactController.CurrentArtifact;
artifactGaugeFill.color = currentArtifact != null
? currentArtifact.EffectColor
: new Color32(132, 132, 132, 255);
artifactNameText.text = currentArtifact == null
? "EMPTY"
: currentArtifact.DisplayName;
artifactNameText.color = currentArtifact != null
? Color.white
: new Color(0.75f, 0.8f, 0.9f);
if (currentArtifact == null)
{
artifactStateText.text = "획득 대기";
artifactStateText.color = new Color(0.75f, 0.8f, 0.9f);
}
else if (activeArtifactController.IsCharging)
{
artifactStateText.text = activeArtifactController.IsFullyCharged
? "완충"
: $"충전 {activeArtifactController.ChargeProgress * 100f:0}%";
artifactStateText.color = currentArtifact != null
? currentArtifact.EffectColor
: Color.white;
}
else if (currentArtifact != null
&& current < currentArtifact.NormalGaugeCost)
{
artifactStateText.text =
$"부족 {Mathf.CeilToInt(currentArtifact.NormalGaugeCost)}";
artifactStateText.color = new Color(1f, 0.45f, 0.4f);
}
else if (RunManager.Instance != null
&& !RunManager.Instance.IsArtifactChargeUnlocked)
{
artifactStateText.text = "충전 잠금";
artifactStateText.color = new Color(0.75f, 0.8f, 0.9f);
}
else
{
artifactStateText.text = string.Empty;
artifactStateText.color = Color.white;
}
}
private void CreateDebugSpawnHud()
{
Font font = PresentationUiStyle.GetGalmuriFont();
debugSpawnPanel = CreateRuntimePanel(
transform,
"Debug Event Spawn Buttons",
new Vector2(0f, 1f),
new Vector2(24f, -24f),
new Vector2(240f, 144f),
new Color(0.02f, 0.03f, 0.06f, 0.88f));
PresentationUiStyle.ApplyPanel(debugSpawnPanel.GetComponent<Image>());
SetRectTransform(
debugSpawnPanel.GetComponent<RectTransform>(),
new Vector2(0f, 1f),
new Vector2(0f, 1f),
new Vector2(24f, -24f),
new Vector2(240f, 144f));
RunTimedEvent[] events =
{
RunTimedEvent.Elite,
RunTimedEvent.MidBoss,
RunTimedEvent.FinalBoss,
};
string[] labels = { "엘리트 출현", "중간보스 출현", "보스 출현" };
debugSpawnButtons = new Button[events.Length];
for (int i = 0; i < events.Length; i++)
{
GameObject buttonObject = new(
$"Debug Spawn {events[i]}",
typeof(RectTransform),
typeof(CanvasRenderer),
typeof(Image),
typeof(Button));
buttonObject.transform.SetParent(debugSpawnPanel.transform, false);
RectTransform rect = buttonObject.GetComponent<RectTransform>();
rect.anchorMin = new Vector2(0.5f, 0f);
rect.anchorMax = new Vector2(0.5f, 0f);
rect.pivot = new Vector2(0.5f, 0f);
rect.anchoredPosition = new Vector2(
0f,
8f + (events.Length - 1 - i) * 42f);
rect.sizeDelta = new Vector2(220f, 38f);
Image image = buttonObject.GetComponent<Image>();
image.color = new Color(0.12f, 0.16f, 0.25f, 1f);
image.raycastTarget = true;
Button button = buttonObject.GetComponent<Button>();
button.targetGraphic = image;
PresentationUiStyle.ApplyButton(button);
RunTimedEvent requestedEvent = events[i];
button.onClick.AddListener(() =>
{
if (spawnDirector != null
&& spawnDirector.TryDebugSpawnEventEnemy(requestedEvent))
{
HandleTimedEvent(requestedEvent);
}
UpdateDebugSpawnHud();
});
CreateRuntimeText(
buttonObject.transform,
"Label",
font,
18,
TextAnchor.MiddleCenter,
Vector2.zero,
new Vector2(220f, 38f)).text = labels[i];
debugSpawnButtons[i] = button;
}
}
private void UpdateDebugSpawnHud()
{
if (debugSpawnPanel == null)
{
return;
}
bool visible = runManager != null && runManager.UseDebugArtifactSelection;
debugSpawnPanel.SetActive(visible);
if (!visible || spawnDirector == null)
{
return;
}
RunTimedEvent[] events =
{
RunTimedEvent.Elite,
RunTimedEvent.MidBoss,
RunTimedEvent.FinalBoss,
};
for (int i = 0; i < debugSpawnButtons.Length; i++)
{
debugSpawnButtons[i].interactable =
spawnDirector.CanDebugSpawnEventEnemy(events[i]);
}
}
private void CreateMinimapHud()
{
if (minimapPanel != null)
{
return;
}
arenaBounds = ArenaBounds.Resolve();
const float panelWidth = 300f;
const float panelHeight = 162f;
minimapPanel = CreateRuntimePanel(
transform,
"Minimap",
new Vector2(1f, 1f),
new Vector2(-24f, -24f),
new Vector2(panelWidth, panelHeight),
new Color(0.02f, 0.03f, 0.06f, 0.88f));
SetRectTransform(
minimapPanel.GetComponent<RectTransform>(),
new Vector2(1f, 1f),
new Vector2(1f, 1f),
new Vector2(-24f, -24f),
new Vector2(panelWidth, panelHeight));
Image minimapFrame = minimapPanel.GetComponent<Image>();
PresentationUiStyle.ApplyPanel(minimapFrame);
minimapFrame.pixelsPerUnitMultiplier = 1f;
minimapFrame.fillCenter = false;
GameObject map = CreateRuntimePanel(
minimapPanel.transform,
"Map",
new Vector2(0.5f, 0.5f),
Vector2.zero,
MinimapSize,
new Color(0.035f, 0.06f, 0.08f, 0.28f));
minimapMapRect = map.GetComponent<RectTransform>();
map.GetComponent<Image>().color = new Color(0.035f, 0.06f, 0.08f, 0.28f);
map.AddComponent<RectMask2D>();
minimapMarkerRoot = CreateMinimapLayer(map.transform, "Markers");
minimapViewportRoot = CreateMinimapLayer(map.transform, "Minimap Camera Viewport");
minimapMarkerRoot.SetAsLastSibling();
minimapViewportEdges = new Image[4];
for (int i = 0; i < minimapViewportEdges.Length; i++)
{
minimapViewportEdges[i] = CreateRuntimeImage(
minimapViewportRoot,
$"Edge {i}",
Vector2.zero,
Vector2.zero);
minimapViewportEdges[i].color = new Color32(157, 219, 232, 220);
}
minimapPlayerMarker = CreateRuntimeImage(
minimapMarkerRoot,
"Minimap Player",
Vector2.zero,
new Vector2(9f, 9f));
minimapPlayerMarker.color = new Color32(106, 236, 255, 255);
}
private static RectTransform CreateMinimapLayer(
Transform parent,
string name)
{
GameObject layer = new(name, typeof(RectTransform));
layer.transform.SetParent(parent, false);
RectTransform rect = layer.GetComponent<RectTransform>();
rect.anchorMin = new Vector2(0.5f, 0.5f);
rect.anchorMax = new Vector2(0.5f, 0.5f);
rect.pivot = new Vector2(0.5f, 0.5f);
rect.anchoredPosition = Vector2.zero;
rect.sizeDelta = MinimapSize;
return rect;
}
private void UpdateMinimapHud()
{
if (minimapPanel == null)
{
return;
}
bool activeRun = runManager != null
&& !runManager.IsTitleScreen
&& !runManager.IsPaused
&& !runManager.IsSelectionOpen
&& !runManager.IsGameOver;
minimapPanel.SetActive(activeRun);
if (!activeRun)
{
return;
}
arenaBounds ??= ArenaBounds.Resolve();
if (arenaBounds == null || minimapMapRect == null)
{
minimapPanel.SetActive(false);
return;
}
minimapPanel.SetActive(true);
Vector2 extents = arenaBounds.GetReachableHalfExtents();
if (extents.x <= 0.001f || extents.y <= 0.001f)
{
return;
}
SetMinimapMarkerPosition(
minimapPlayerMarker,
playerHealth != null ? playerHealth.transform.position : Vector3.zero,
extents,
minimapMapRect.rect.size);
minimapEnemyRefreshElapsed -= Time.unscaledDeltaTime;
if (minimapEnemyRefreshElapsed <= 0f)
{
minimapEnemyRefreshElapsed = 0.1f;
minimapEnemies = Object.FindObjectsByType<EnemyController>(
FindObjectsInactive.Exclude,
FindObjectsSortMode.None);
}
int markerIndex = 0;
for (int i = 0; i < minimapEnemies.Length; i++)
{
EnemyController enemy = minimapEnemies[i];
if (enemy == null || !enemy.isActiveAndEnabled || enemy.IsDead)
{
continue;
}
Image marker = GetMinimapEnemyMarker(markerIndex++);
marker.gameObject.SetActive(true);
marker.color = GetMinimapEnemyColor(enemy);
float markerSize = enemy.IsEventEnemy ? 10f : 6f;
marker.rectTransform.sizeDelta = new Vector2(markerSize, markerSize);
SetMinimapMarkerPosition(
marker,
enemy.GroundAnchorPosition,
extents,
minimapMapRect.rect.size);
}
minimapPlayerMarker.transform.SetAsLastSibling();
for (int i = markerIndex; i < minimapEnemyMarkers.Count; i++)
{
minimapEnemyMarkers[i].gameObject.SetActive(false);
}
UpdateMinimapViewport(extents);
}
private Image GetMinimapEnemyMarker(int index)
{
while (minimapEnemyMarkers.Count <= index)
{
Image marker = CreateRuntimeImage(
minimapMarkerRoot,
$"Minimap Enemy {minimapEnemyMarkers.Count + 1}",
Vector2.zero,
new Vector2(6f, 6f));
marker.color = new Color32(230, 96, 95, 255);
minimapEnemyMarkers.Add(marker);
}
return minimapEnemyMarkers[index];
}
private static Color GetMinimapEnemyColor(EnemyController enemy)
{
if (!enemy.IsEventEnemy)
{
return enemy.IsCrowd
? new Color32(194, 151, 103, 255)
: new Color32(230, 96, 95, 255);
}
return enemy.RunEventRole switch
{
RunTimedEvent.Elite => new Color32(255, 220, 107, 255),
RunTimedEvent.MidBoss => new Color32(255, 157, 83, 255),
RunTimedEvent.FinalBoss => new Color32(235, 126, 255, 255),
_ => new Color32(118, 238, 190, 255),
};
}
private void SetMinimapMarkerPosition(
Image marker,
Vector2 worldPosition,
Vector2 extents,
Vector2 mapSize)
{
if (marker == null)
{
return;
}
Vector2 normalized = new(
Mathf.Clamp01((worldPosition.x + extents.x) / (extents.x * 2f)),
Mathf.Clamp01((worldPosition.y + extents.y) / (extents.y * 2f)));
Vector2 halfMarker = marker.rectTransform.rect.size * 0.5f;
float halfMapWidth = mapSize.x * 0.5f;
float halfMapHeight = mapSize.y * 0.5f;
SetRectTransform(
marker.rectTransform,
new Vector2(0.5f, 0.5f),
new Vector2(0.5f, 0.5f),
new Vector2(
Mathf.Lerp(
-halfMapWidth + halfMarker.x,
halfMapWidth - halfMarker.x,
normalized.x),
Mathf.Lerp(
-halfMapHeight + halfMarker.y,
halfMapHeight - halfMarker.y,
normalized.y)),
marker.rectTransform.sizeDelta);
}
private void UpdateMinimapViewport(Vector2 extents)
{
if (worldCamera == null || minimapViewportEdges.Length != 4)
{
return;
}
Vector2 mapSize = minimapMapRect.rect.size;
Bounds visible = arenaBounds.GetCameraVisibleBounds(worldCamera);
Vector2 min = WorldToMinimapPosition(visible.min, extents, mapSize);
Vector2 max = WorldToMinimapPosition(visible.max, extents, mapSize);
float width = Mathf.Max(2f, max.x - min.x);
float height = Mathf.Max(2f, max.y - min.y);
Vector2 center = (min + max) * 0.5f;
SetRectTransform(
minimapViewportRoot,
new Vector2(0.5f, 0.5f),
new Vector2(0.5f, 0.5f),
center,
new Vector2(width, height));
const float edgeThickness = 2f;
SetRectTransform(
minimapViewportEdges[0].rectTransform,
new Vector2(0.5f, 0.5f),
new Vector2(0.5f, 0.5f),
new Vector2(0f, (height - edgeThickness) * 0.5f),
new Vector2(width, edgeThickness));
SetRectTransform(
minimapViewportEdges[1].rectTransform,
new Vector2(0.5f, 0.5f),
new Vector2(0.5f, 0.5f),
new Vector2(0f, -(height - edgeThickness) * 0.5f),
new Vector2(width, edgeThickness));
SetRectTransform(
minimapViewportEdges[2].rectTransform,
new Vector2(0.5f, 0.5f),
new Vector2(0.5f, 0.5f),
new Vector2(-(width - edgeThickness) * 0.5f, 0f),
new Vector2(edgeThickness, height));
SetRectTransform(
minimapViewportEdges[3].rectTransform,
new Vector2(0.5f, 0.5f),
new Vector2(0.5f, 0.5f),
new Vector2((width - edgeThickness) * 0.5f, 0f),
new Vector2(edgeThickness, height));
}
private static Vector2 WorldToMinimapPosition(
Vector2 worldPosition,
Vector2 extents,
Vector2 mapSize)
{
Vector2 normalized = new(
Mathf.Clamp01((worldPosition.x + extents.x) / (extents.x * 2f)),
Mathf.Clamp01((worldPosition.y + extents.y) / (extents.y * 2f)));
return new Vector2(
(normalized.x - 0.5f) * mapSize.x,
(normalized.y - 0.5f) * mapSize.y);
}
private static void SetRectTransform(
RectTransform rect,
Vector2 anchor,
Vector2 pivot,
Vector2 anchoredPosition,
Vector2 size)
{
rect.anchorMin = anchor;
rect.anchorMax = anchor;
rect.pivot = pivot;
rect.anchoredPosition = anchoredPosition;
rect.sizeDelta = size;
}
private static GameObject CreateRuntimePanel(
Transform parent,
string name,
Vector2 anchor,
Vector2 anchoredPosition,
Vector2 size,
Color color)
{
return PresentationUiStyle.CreatePanel(
parent,
name,
anchor,
anchoredPosition,
size,
color);
}
private static Image CreateRuntimeImage(
Transform parent,
string name,
Vector2 anchoredPosition,
Vector2 size)
{
return PresentationUiStyle.CreateImage(
parent,
name,
anchoredPosition,
size);
}
private static Text CreateRuntimeText(
Transform parent,
string name,
Font font,
int fontSize,
TextAnchor alignment,
Vector2 anchoredPosition,
Vector2 size)
{
return PresentationUiStyle.CreateText(
parent,
name,
font,
fontSize,
alignment,
anchoredPosition,
size);
}
private void HandleTimedEvent(RunTimedEvent timedEvent)
{
string message = timedEvent switch
{
RunTimedEvent.Elite => "ELITE APPEARED",
RunTimedEvent.MidBoss => "MID BOSS APPEARED",
RunTimedEvent.FinalBoss => "FINAL BOSS APPEARED",
_ => string.Empty,
};
StartCoroutine(ShowEventMessage(message));
}
private IEnumerator ShowEventMessage(string message)
{
showingTimedEventMessage = true;
lastNextEventSecond = -1;
eventText.text = message;
eventText.gameObject.SetActive(true);
yield return new WaitForSecondsRealtime(3f);
showingTimedEventMessage = false;
lastNextEventSecond = -1;
UpdateNextEventHud();
}
private void ShowGameOver()
{
gameOverPanel.SetActive(true);
}
#if UNITY_EDITOR
public void Configure(
Image hpFill,
Text hpText,
Image xpFill,
Text level,
Text timer,
Text enemyCount,
Text hitDebug,
Text timedEvent,
GameObject gameOver,
Text damageTemplate)
{
healthFill = hpFill;
healthText = hpText;
experienceFill = xpFill;
levelText = level;
timerText = timer;
enemyCountText = enemyCount;
hitDebugText = hitDebug;
eventText = timedEvent;
gameOverPanel = gameOver;
damagePopupTemplate = damageTemplate;
}
#endif
}
/// <summary>
/// Shared presentation-only styling for the run HUD and selection screens.
/// Gameplay code remains responsible for state; this class only applies
/// optional Resources sprites, colors, and the Korean-capable font.
/// </summary>
internal static class PresentationUiStyle
{
private const string UiResourceRoot = "Presentation/UI-v1/";
private const string ArtifactIconResourceRoot =
"Artifacts/ThreeColor-v1/Icons/";
private const string FontResource =
"Presentation/Fonts/NanumGothic-Regular";
private const string NeoFontResource = "Presentation/Fonts/neodgm";
private const string GalmuriFontResource = "Presentation/Fonts/Galmuri9";
private const float ThreePixelBorderMultiplier = 1f / 3f;
private static readonly Color PanelFallback =
new(24f / 255f, 27f / 255f, 39f / 255f, 0.94f);
private static readonly Color PanelWarmFallback =
new(40f / 255f, 31f / 255f, 40f / 255f, 0.96f);
private static readonly Color FrameFallback =
new(99f / 255f, 108f / 255f, 129f / 255f, 1f);
private static readonly Color WarmFallback =
new(208f / 255f, 170f / 255f, 107f / 255f, 1f);
private static readonly Dictionary<string, Sprite> SpriteCache = new();
private static Font cachedFont;
private static Font cachedNeoFont;
private static Font cachedGalmuriFont;
public static void EnsureEventSystem(bool clearMenuNavigationActions)
{
EventSystem eventSystem = EventSystem.current
?? UnityEngine.Object.FindAnyObjectByType<EventSystem>();
if (eventSystem == null)
{
GameObject eventSystemObject = new("EventSystem");
eventSystem = eventSystemObject.AddComponent<EventSystem>();
}
if (eventSystem.GetComponent<BaseInputModule>() == null)
{
eventSystem.gameObject
.AddComponent<InputSystemUIInputModule>();
}
if (clearMenuNavigationActions)
{
InputSystemUIInputModule inputSystemModule =
eventSystem.GetComponent<InputSystemUIInputModule>();
if (inputSystemModule == null)
{
return;
}
// The menu reads keyboard navigation itself; keep pointer actions
// intact so Unity UI continues to dispatch pointer input.
inputSystemModule.move = null;
inputSystemModule.submit = null;
inputSystemModule.cancel = null;
}
}
public static GameObject CreatePanel(
Transform parent,
string name,
Vector2 anchor,
Vector2 anchoredPosition,
Vector2 size,
Color color)
{
Vector2 pivot = anchor.y <= 0f
? new Vector2(0.5f, 0f)
: new Vector2(0.5f, 0.5f);
Image image = CreateImage(
parent,
name,
anchor,
anchor,
pivot,
anchoredPosition,
size,
false);
image.color = color;
return image.gameObject;
}
public static Image CreateImage(
Transform parent,
string name,
Vector2 anchoredPosition,
Vector2 size)
{
Vector2 center = new(0.5f, 0.5f);
return CreateImage(
parent,
name,
center,
center,
center,
anchoredPosition,
size,
true);
}
public static Text CreateText(
Transform parent,
string name,
Font font,
int fontSize,
TextAnchor alignment,
Vector2 anchoredPosition,
Vector2 size)
{
GameObject textObject = new(
name,
typeof(RectTransform),
typeof(CanvasRenderer),
typeof(Text));
textObject.transform.SetParent(parent, false);
RectTransform rect = textObject.GetComponent<RectTransform>();
rect.anchorMin = new Vector2(0.5f, 0.5f);
rect.anchorMax = new Vector2(0.5f, 0.5f);
rect.pivot = new Vector2(0.5f, 0.5f);
rect.anchoredPosition = anchoredPosition;
rect.sizeDelta = size;
Text text = textObject.GetComponent<Text>();
text.font = font;
text.fontSize = fontSize;
text.alignment = alignment;
text.color = Color.white;
text.raycastTarget = false;
ApplyText(text, fontSize);
return text;
}
public static Image CreateCardBackdrop(
Text referenceText,
string name,
Vector2 size)
{
RectTransform textRect = referenceText.rectTransform;
Image image = CreateImage(
referenceText.transform.parent,
name,
textRect.anchorMin,
textRect.anchorMax,
textRect.pivot,
textRect.anchoredPosition,
size,
false);
image.transform.SetSiblingIndex(
referenceText.transform.GetSiblingIndex());
ApplyCard(image, false);
return image;
}
private static Image CreateImage(
Transform parent,
string name,
Vector2 anchorMin,
Vector2 anchorMax,
Vector2 pivot,
Vector2 anchoredPosition,
Vector2 size,
bool preserveAspect)
{
GameObject imageObject = new(
name,
typeof(RectTransform),
typeof(CanvasRenderer),
typeof(Image));
imageObject.transform.SetParent(parent, false);
RectTransform rect = imageObject.GetComponent<RectTransform>();
rect.anchorMin = anchorMin;
rect.anchorMax = anchorMax;
rect.pivot = pivot;
rect.anchoredPosition = anchoredPosition;
rect.sizeDelta = size;
Image image = imageObject.GetComponent<Image>();
image.preserveAspect = preserveAspect;
image.raycastTarget = false;
return image;
}
public static Font GetFont()
{
if (cachedFont != null)
{
return cachedFont;
}
cachedFont = Resources.Load<Font>(FontResource);
if (cachedFont != null)
{
return cachedFont;
}
cachedFont = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
if (cachedFont == null)
{
cachedFont = Font.CreateDynamicFontFromOSFont(
new[] { "Malgun Gothic", "맑은 고딕", "Arial" },
16);
}
return cachedFont;
}
public static Font GetGalmuriFont()
{
if (cachedGalmuriFont != null)
{
return cachedGalmuriFont;
}
cachedGalmuriFont = Resources.Load<Font>(GalmuriFontResource);
if (cachedGalmuriFont != null)
{
return cachedGalmuriFont;
}
cachedGalmuriFont = GetNeoFont();
return cachedGalmuriFont != null ? cachedGalmuriFont : GetFont();
}
public static Font GetNeoFont()
{
if (cachedNeoFont != null)
{
return cachedNeoFont;
}
cachedNeoFont = Resources.Load<Font>(NeoFontResource);
return cachedNeoFont != null ? cachedNeoFont : GetFont();
}
public static void ApplyText(Text text, int fontSize = 16)
{
if (text == null)
{
return;
}
// Galmuri9 is the common pixel UI font. Keep Neo and Nanum as
// runtime fallbacks when the resource is unavailable.
Font font = GetGalmuriFont();
if (font != null)
{
text.font = font;
}
text.fontSize = fontSize;
text.fontStyle = FontStyle.Normal;
text.raycastTarget = false;
}
public static void ApplyNeoText(Text text, int fontSize = 32)
{
ApplyText(text, fontSize);
}
public static void ApplyDamagePopup(Text text, int fontSize = 30)
{
ApplyText(text, fontSize);
if (text == null)
{
return;
}
text.resizeTextForBestFit = false;
text.horizontalOverflow = HorizontalWrapMode.Overflow;
text.verticalOverflow = VerticalWrapMode.Overflow;
Outline outline = text.GetComponent<Outline>()
?? text.gameObject.AddComponent<Outline>();
outline.effectColor = new Color32(18, 23, 34, 230);
outline.effectDistance = Vector2.one;
outline.useGraphicAlpha = true;
}
public static void ApplyPanel(Image image, bool warm = false)
{
if (image == null)
{
return;
}
Sprite sprite = LoadSprite(warm ? "PanelWarm" : "Panel");
if (sprite != null)
{
image.sprite = sprite;
image.type = Image.Type.Sliced;
image.fillCenter = true;
image.pixelsPerUnitMultiplier = ThreePixelBorderMultiplier;
image.color = Color.white;
}
else
{
image.color = warm ? PanelWarmFallback : PanelFallback;
}
image.raycastTarget = false;
}
public static void ApplyCard(Image image, bool selected)
{
ApplyPanel(image, selected);
if (image != null && image.sprite == null)
{
image.color = selected ? WarmFallback : FrameFallback * 0.45f;
}
}
public static void ApplySlot(Image image, bool selected, Color fallback)
{
if (image == null)
{
return;
}
Sprite sprite = LoadSprite(selected ? "SlotSelected" : "Slot");
if (sprite != null)
{
image.sprite = sprite;
image.type = Image.Type.Sliced;
image.fillCenter = true;
image.pixelsPerUnitMultiplier = ThreePixelBorderMultiplier;
image.color = Color.white;
}
else
{
image.color = fallback;
}
image.raycastTarget = false;
}
public static void ApplyGaugeFill(Image image, Color fallback)
{
if (image == null)
{
return;
}
Sprite sprite = LoadSprite("GaugeFill");
if (sprite != null)
{
image.sprite = sprite;
image.type = Image.Type.Filled;
image.fillMethod = Image.FillMethod.Horizontal;
image.fillOrigin = 0;
image.color = fallback;
}
else
{
image.color = fallback;
}
image.raycastTarget = false;
}
public static void ApplyButton(Button button)
{
if (button == null)
{
return;
}
Image image = button.targetGraphic as Image
?? button.GetComponent<Image>();
ApplyPanel(image);
if (image != null && image.sprite == null)
{
image.color = PanelFallback;
}
if (image != null)
{
image.raycastTarget = true;
}
ColorBlock colors = button.colors;
colors.normalColor = Color.white;
colors.highlightedColor = new Color(1f, 0.94f, 0.78f, 1f);
colors.pressedColor = new Color(0.86f, 0.72f, 0.5f, 1f);
colors.selectedColor = colors.highlightedColor;
colors.disabledColor = new Color(0.42f, 0.46f, 0.5f, 0.58f);
colors.colorMultiplier = 1f;
button.colors = colors;
}
public static Sprite GetArtifactIcon(ActiveArtifactDefinition definition)
{
if (definition == null)
{
return null;
}
string resourceName = definition.ArtifactId?.ToLowerInvariant() switch
{
"dash" => "IconDash",
"pulse" => "IconPulse",
"phoenix" => "IconRay",
"cyclone" => "IconCyclone",
"thunder_crash" => "IconThunder",
"chain_lightning" => "IconArc",
_ => null,
};
return resourceName == null
? null
: LoadArtifactIcon(resourceName);
}
private static Sprite LoadArtifactIcon(string resourceName)
{
string cacheKey = ArtifactIconResourceRoot + resourceName;
if (SpriteCache.TryGetValue(cacheKey, out Sprite cached))
{
return cached;
}
Sprite sprite = Resources.Load<Sprite>(cacheKey);
SpriteCache[cacheKey] = sprite;
return sprite;
}
private static Sprite LoadSprite(string resourceName)
{
if (SpriteCache.TryGetValue(resourceName, out Sprite cached))
{
return cached;
}
Sprite sprite = Resources.Load<Sprite>(UiResourceRoot + resourceName);
SpriteCache[resourceName] = sprite;
return sprite;
}
}
}