2183 lines
79 KiB
C#
2183 lines
79 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|