Initial commit: Tiny Tackle Heroes Unity project

This commit is contained in:
2026-09-15 17:37:42 +09:00
commit 591fa9a826
1623 changed files with 182420 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: aea34c11b1b446d3a9c2f4eafbbd3de9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 461719d1ac662d841923bc00a88249e8
@@ -0,0 +1,883 @@
using System.Collections.Generic;
using BumpCombat.Audio;
using BumpCombat.Core;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem;
using UnityEngine.UI;
namespace BumpCombat.UI
{
/// <summary>
/// Runtime title, pause, and settings panels. The RunManager owns state;
/// this component only presents and requests those transitions.
/// </summary>
[DisallowMultipleComponent]
public sealed class RunMenuController : MonoBehaviour
{
private enum Screen
{
None,
Title,
TitleModes,
Pause,
Settings,
}
private Screen currentScreen;
private Screen settingsCaller;
private RunManager runManager;
private BumpCombatAudioService audioService;
private GameObject panel;
private GameObject modalDim;
private Text titleText;
private Text subtitleText;
private Text inputHelpText;
private readonly List<Button> buttons = new();
private readonly List<GameObject> buttonFocusMarkers = new();
private readonly GameObject[] settingsFocusRows = new GameObject[4];
private Slider masterSlider;
private Slider musicSlider;
private Slider sfxSlider;
private Text masterValueText;
private Text musicValueText;
private Text sfxValueText;
private int selectedButton;
private int selectedSettingsControl;
public bool IsVisible => panel != null && panel.activeSelf;
private void Start()
{
runManager = RunManager.Instance;
audioService = BumpCombatAudioService.Instance;
PresentationUiStyle.EnsureEventSystem(
clearMenuNavigationActions: true);
CreatePanel();
if (runManager != null)
{
runManager.OnTitleChanged += HandleTitleChanged;
runManager.OnPauseChanged += HandlePauseChanged;
runManager.OnGameOver += HandleGameOver;
}
if (runManager != null && runManager.IsTitleScreen)
{
SetScreen(Screen.Title);
}
else
{
SetScreen(Screen.None);
}
}
private void OnDestroy()
{
if (runManager != null)
{
runManager.OnTitleChanged -= HandleTitleChanged;
runManager.OnPauseChanged -= HandlePauseChanged;
runManager.OnGameOver -= HandleGameOver;
}
}
private void Update()
{
if (runManager == null || Keyboard.current == null)
{
return;
}
Keyboard keyboard = Keyboard.current;
if (keyboard.escapeKey.wasPressedThisFrame)
{
if (currentScreen == Screen.Settings)
{
SetScreen(settingsCaller);
}
else if (currentScreen == Screen.TitleModes)
{
SetScreen(Screen.Title);
}
else if (currentScreen == Screen.Pause)
{
runManager.ClosePause();
}
else if (currentScreen == Screen.None
&& !runManager.IsSelectionOpen
&& !runManager.IsGameOver
&& !runManager.IsTitleScreen)
{
if (runManager.TryOpenPause())
{
SetScreen(Screen.Pause);
}
}
return;
}
if (!IsVisible)
{
return;
}
if (currentScreen == Screen.Settings)
{
HandleSettingsKeyboard(keyboard);
return;
}
bool moved = false;
if (keyboard.upArrowKey.wasPressedThisFrame)
{
selectedButton = FindNextActiveButton(-1);
moved = true;
}
else if (keyboard.downArrowKey.wasPressedThisFrame)
{
selectedButton = FindNextActiveButton(1);
moved = true;
}
if (moved)
{
SelectButton(selectedButton, true);
}
if (keyboard.enterKey.wasPressedThisFrame
|| keyboard.numpadEnterKey.wasPressedThisFrame
|| keyboard.spaceKey.wasPressedThisFrame)
{
if (selectedButton >= 0 && selectedButton < buttons.Count)
{
Button selected = buttons[selectedButton];
if (selected != null && selected.interactable)
{
audioService?.PlayUi("ui_confirm");
selected.onClick.Invoke();
}
}
}
}
private void LateUpdate()
{
if (!IsVisible || modalDim == null || panel == null)
{
return;
}
Transform panelParent = panel.transform.parent;
if (panelParent == null || modalDim.transform.parent != panelParent)
{
return;
}
int lastIndex = panelParent.childCount - 1;
int modalIndex = modalDim.transform.GetSiblingIndex();
int panelIndex = panel.transform.GetSiblingIndex();
if (panelIndex == lastIndex && modalIndex == lastIndex - 1)
{
return;
}
modalDim.transform.SetAsLastSibling();
panel.transform.SetAsLastSibling();
}
private void HandleTitleChanged(bool isOpen)
{
if (isOpen)
{
SetScreen(Screen.Title);
}
else if (currentScreen == Screen.Title)
{
SetScreen(Screen.None);
}
}
private void HandlePauseChanged(bool isOpen)
{
if (isOpen)
{
SetScreen(Screen.Pause);
}
else if (currentScreen == Screen.Pause)
{
SetScreen(Screen.None);
}
}
private void HandleGameOver()
{
SetScreen(Screen.None);
}
private void SetScreen(Screen screen)
{
currentScreen = screen;
if (panel == null)
{
return;
}
panel.SetActive(screen != Screen.None);
if (modalDim != null)
{
modalDim.SetActive(screen != Screen.None);
}
if (screen == Screen.None)
{
return;
}
panel.transform.SetAsLastSibling();
HideAllControls();
switch (screen)
{
case Screen.Title:
titleText.text = "Tiny Tackle Heroes";
subtitleText.text = "DIRECTIONAL ACTION SURVIVAL";
inputHelpText.text = "항목 이동: 위/아래 선택: Enter/Space";
ShowButton(0, "게임시작", OpenTitleModes);
ShowButton(1, "설정", OpenTitleSettings);
ShowButton(2, "업적", OpenTitleAchievements);
ShowButton(3, "나가기", QuitGame);
break;
case Screen.TitleModes:
titleText.text = "게임 시작";
subtitleText.text = "모드를 선택하세요";
inputHelpText.text = "항목 이동: 위/아래 선택: Enter/Space 뒤로: Esc";
ShowTitleModeButtons();
break;
case Screen.Pause:
titleText.text = "PAUSED";
subtitleText.text = "THE RUN IS WAITING";
inputHelpText.text = "항목 이동: 위/아래 선택: Enter/Space 계속: Esc";
ShowButton(0, "계속", ContinueRun);
ShowButton(1, "설정", OpenPauseSettings);
ShowButton(2, "다시 시작", RestartRun);
ShowButton(3, "타이틀", ReturnToTitle);
break;
case Screen.Settings:
titleText.text = "설정";
subtitleText.text = "음량";
inputHelpText.text = "항목 이동: 위/아래 음량 조절: 왼쪽/오른쪽\n뒤로 선택: Enter/Space 즉시 뒤로: Esc";
ShowSlider(masterSlider, masterValueText, "전체", audioService?.MasterVolume ?? 0.8f);
ShowSlider(musicSlider, musicValueText, "음악", audioService?.MusicVolume ?? 0.55f);
ShowSlider(sfxSlider, sfxValueText, "효과음", audioService?.SfxVolume ?? 0.8f);
ShowButton(3, "뒤로", BackFromSettings);
break;
}
if (screen == Screen.Settings)
{
selectedSettingsControl = 0;
SelectSettingsControl(false);
}
else
{
selectedButton = FindFirstActiveButton();
SelectButton(selectedButton, false);
}
}
private void HideAllControls()
{
for (int i = 0; i < buttons.Count; i++)
{
buttons[i].gameObject.SetActive(false);
}
SetControlVisible(masterSlider, false);
SetControlVisible(musicSlider, false);
SetControlVisible(sfxSlider, false);
masterValueText.gameObject.SetActive(false);
musicValueText.gameObject.SetActive(false);
sfxValueText.gameObject.SetActive(false);
for (int i = 0; i < settingsFocusRows.Length; i++)
{
settingsFocusRows[i]?.SetActive(false);
}
}
private void ShowButton(int index, string label, UnityEngine.Events.UnityAction action)
{
Button button = GetButton(index);
button.gameObject.SetActive(true);
button.interactable = true;
PositionButton(button, 95f - index * 88f);
Text labelText = button.GetComponentInChildren<Text>();
labelText.text = label;
labelText.color = Color.white;
button.onClick.RemoveAllListeners();
button.onClick.AddListener(action);
}
private Button GetButton(int index)
{
Transform child = panel.transform.Find($"Button {index + 1}");
Button button = child.GetComponent<Button>();
buttons[index] = button;
return button;
}
private void SelectButton(int index, bool playSound)
{
if (buttons.Count == 0 || index < 0 || index >= buttons.Count)
{
return;
}
Button button = buttons[index];
if (!IsButtonSelectable(button))
{
return;
}
for (int i = 0; i < buttonFocusMarkers.Count; i++)
{
buttonFocusMarkers[i].SetActive(currentScreen != Screen.Settings && i == index);
}
EventSystem.current?.SetSelectedGameObject(button.gameObject);
if (playSound)
{
audioService?.PlayUi("ui_move");
}
}
private int FindFirstActiveButton()
{
for (int i = 0; i < buttons.Count; i++)
{
if (IsButtonSelectable(buttons[i]))
{
return i;
}
}
return -1;
}
private int FindNextActiveButton(int direction)
{
if (buttons.Count == 0)
{
return -1;
}
int candidate = selectedButton;
for (int i = 0; i < buttons.Count; i++)
{
candidate = (candidate + direction + buttons.Count) % buttons.Count;
if (IsButtonSelectable(buttons[candidate]))
{
return candidate;
}
}
return selectedButton;
}
private static bool IsButtonSelectable(Button button)
{
return button != null
&& button.gameObject.activeSelf
&& button.interactable;
}
private void HandleSettingsKeyboard(Keyboard keyboard)
{
if (keyboard.upArrowKey.wasPressedThisFrame)
{
selectedSettingsControl = (selectedSettingsControl + 3) % 4;
SelectSettingsControl(true);
}
else if (keyboard.downArrowKey.wasPressedThisFrame)
{
selectedSettingsControl = (selectedSettingsControl + 1) % 4;
SelectSettingsControl(true);
}
if (selectedSettingsControl < 3
&& (keyboard.leftArrowKey.wasPressedThisFrame
|| keyboard.rightArrowKey.wasPressedThisFrame))
{
Slider slider = GetSettingsSlider(selectedSettingsControl);
float delta = keyboard.rightArrowKey.wasPressedThisFrame ? 0.05f : -0.05f;
slider.value = Mathf.Clamp01(slider.value + delta);
}
else if (selectedSettingsControl == 3
&& (keyboard.enterKey.wasPressedThisFrame
|| keyboard.numpadEnterKey.wasPressedThisFrame
|| keyboard.spaceKey.wasPressedThisFrame))
{
audioService?.PlayUi("ui_confirm");
buttons[3].onClick.Invoke();
}
}
private Slider GetSettingsSlider(int index)
{
return index switch
{
0 => masterSlider,
1 => musicSlider,
_ => sfxSlider,
};
}
private void SelectSettingsControl(bool playSound)
{
for (int i = 0; i < settingsFocusRows.Length; i++)
{
if (settingsFocusRows[i] != null)
{
settingsFocusRows[i].SetActive(i == selectedSettingsControl);
}
}
if (selectedSettingsControl == 3)
{
SelectButton(3, playSound);
return;
}
Slider slider = GetSettingsSlider(selectedSettingsControl);
EventSystem.current?.SetSelectedGameObject(slider.gameObject);
if (playSound)
{
audioService?.PlayUi("ui_move");
}
}
private void BeginDevelopmentRun()
{
BeginRun(RunMode.Development);
}
private void BeginProductionRun()
{
BeginRun(RunMode.Production);
}
private void OpenTitleModes()
{
SetScreen(Screen.TitleModes);
}
private void ShowTitleModeButtons()
{
int index = 0;
if (IsDevelopmentStartVisible(Application.isEditor, Debug.isDebugBuild))
{
ShowButton(index++, "개발용 시작", BeginDevelopmentRun);
}
ShowButton(index++, "기본 모드", BeginProductionRun);
ShowDisabledButton(index++, "챌린지 모드(준비 중)");
ShowDisabledButton(index++, "하드코어 모드(준비 중)");
ShowButton(index, "뒤로", ReturnToTitleMenu);
int visibleButtonCount = index + 1;
float top = (visibleButtonCount - 1) * 36f;
for (int buttonIndex = 0; buttonIndex < visibleButtonCount; buttonIndex++)
{
RectTransform rect = buttons[buttonIndex].GetComponent<RectTransform>();
rect.anchoredPosition = new Vector2(
rect.anchoredPosition.x,
top - buttonIndex * 72f);
}
}
private void ShowDisabledButton(int index, string label)
{
Button button = GetButton(index);
button.gameObject.SetActive(true);
button.interactable = false;
PositionButton(button, 95f - index * 88f);
Text labelText = button.GetComponentInChildren<Text>();
labelText.text = label;
labelText.color = new Color32(166, 171, 181, 255);
button.onClick.RemoveAllListeners();
}
private static bool IsDevelopmentStartVisible(
bool isEditor,
bool isDevelopmentBuild)
{
return isEditor || isDevelopmentBuild;
}
private static void PositionButton(Button button, float y)
{
RectTransform rect = button.GetComponent<RectTransform>();
rect.anchoredPosition = new Vector2(rect.anchoredPosition.x, y);
}
private void OpenTitleAchievements()
{
MissionAchievementPanel achievements = GetComponent<MissionAchievementPanel>();
SetScreen(Screen.None);
if (achievements == null
|| !achievements.OpenTitleAchievements(ReturnToTitleMenu))
{
SetScreen(Screen.Title);
return;
}
audioService?.PlayUi("ui_confirm");
}
private void ReturnToTitleMenu()
{
if (runManager != null && runManager.IsTitleScreen)
{
SetScreen(Screen.Title);
}
}
private void BeginRun(RunMode mode)
{
if (runManager == null || !runManager.BeginRun(mode))
{
return;
}
audioService?.PlayUi("ui_confirm");
SetScreen(Screen.None);
}
private void ContinueRun()
{
audioService?.PlayUi("ui_confirm");
runManager?.ClosePause();
}
private void OpenTitleSettings()
{
settingsCaller = Screen.Title;
SetScreen(Screen.Settings);
}
private void OpenPauseSettings()
{
settingsCaller = Screen.Pause;
SetScreen(Screen.Settings);
}
private void BackFromSettings()
{
SetScreen(settingsCaller);
}
private void RestartRun()
{
audioService?.PlayUi("ui_confirm");
runManager?.RestartRun();
}
private void ReturnToTitle()
{
audioService?.PlayUi("ui_confirm");
runManager?.RestartToTitle();
}
private static void QuitGame()
{
Application.Quit();
}
private void ShowSlider(
Slider slider,
Text valueText,
string label,
float value)
{
slider.gameObject.SetActive(true);
valueText.gameObject.SetActive(true);
slider.value = Mathf.Clamp01(value);
valueText.text = $"{label} {Mathf.RoundToInt(slider.value * 100f)}%";
}
private void HandleMasterSlider(float value)
{
audioService?.SetMasterVolume(value);
masterValueText.text = $"MASTER {Mathf.RoundToInt(value * 100f)}%";
}
private void HandleMusicSlider(float value)
{
audioService?.SetMusicVolume(value);
musicValueText.text = $"MUSIC {Mathf.RoundToInt(value * 100f)}%";
}
private void HandleSfxSlider(float value)
{
audioService?.SetSfxVolume(value);
sfxValueText.text = $"SFX {Mathf.RoundToInt(value * 100f)}%";
}
private void CreatePanel()
{
Font font = PresentationUiStyle.GetGalmuriFont();
panel = PresentationUiStyle.CreatePanel(
transform,
"Run Menu",
new Vector2(0.5f, 0.5f),
Vector2.zero,
new Vector2(1180f, 720f),
Color.white);
PresentationUiStyle.ApplyPanel(panel.GetComponent<Image>());
modalDim = new GameObject(
"Run Menu Modal Dim",
typeof(RectTransform),
typeof(CanvasRenderer),
typeof(Image));
modalDim.transform.SetParent(transform, false);
RectTransform modalRect = modalDim.GetComponent<RectTransform>();
modalRect.anchorMin = Vector2.zero;
modalRect.anchorMax = Vector2.one;
modalRect.offsetMin = Vector2.zero;
modalRect.offsetMax = Vector2.zero;
Image modalImage = modalDim.GetComponent<Image>();
modalImage.color = new Color(0f, 0f, 0f, 0.72f);
modalImage.raycastTarget = true;
modalDim.transform.SetSiblingIndex(panel.transform.GetSiblingIndex());
titleText = CreateText(panel.transform, "Title", font, 46, new Vector2(0f, 260f), new Vector2(1000f, 70f));
subtitleText = CreateText(panel.transform, "Subtitle", font, 18, new Vector2(0f, 205f), new Vector2(1000f, 40f));
inputHelpText = CreateText(panel.transform, "Menu Input Help", font, 18, Vector2.zero, new Vector2(1100f, 40f));
RectTransform inputHelpRect = inputHelpText.rectTransform;
inputHelpRect.anchorMin = Vector2.zero;
inputHelpRect.anchorMax = Vector2.zero;
inputHelpRect.pivot = Vector2.zero;
inputHelpRect.anchoredPosition = new Vector2(32f, 24f);
inputHelpRect.sizeDelta = new Vector2(1100f, 60f);
inputHelpText.alignment = TextAnchor.MiddleLeft;
inputHelpText.color = new Color32(200, 208, 218, 255);
CreateSettingsFocusRows(panel.transform);
for (int i = 0; i < 5; i++)
{
Button button = CreateButton(panel.transform, $"Button {i + 1}", font, new Vector2(0f, 95f - i * 88f));
button.gameObject.SetActive(false);
buttons.Add(button);
buttonFocusMarkers.Add(CreateButtonFocusMarker(button.transform));
AddButtonPointerSelection(button, i);
}
masterSlider = CreateSlider(panel.transform, "Master Slider", new Vector2(0f, 90f));
musicSlider = CreateSlider(panel.transform, "Music Slider", new Vector2(0f, 0f));
sfxSlider = CreateSlider(panel.transform, "Sfx Slider", new Vector2(0f, -90f));
masterValueText = CreateText(panel.transform, "Master Value", font, 22, new Vector2(-230f, 90f), new Vector2(280f, 50f));
musicValueText = CreateText(panel.transform, "Music Value", font, 22, new Vector2(-230f, 0f), new Vector2(280f, 50f));
sfxValueText = CreateText(panel.transform, "Sfx Value", font, 22, new Vector2(-230f, -90f), new Vector2(280f, 50f));
masterSlider.onValueChanged.AddListener(HandleMasterSlider);
musicSlider.onValueChanged.AddListener(HandleMusicSlider);
sfxSlider.onValueChanged.AddListener(HandleSfxSlider);
AddSliderPointerSelection(masterSlider, 0);
AddSliderPointerSelection(musicSlider, 1);
AddSliderPointerSelection(sfxSlider, 2);
panel.SetActive(false);
}
private void CreateSettingsFocusRows(Transform parent)
{
for (int i = 0; i < settingsFocusRows.Length; i++)
{
bool isBackButton = i == 3;
float y = isBackButton ? -169f : 90f - i * 90f;
Vector2 size = isBackButton
? new Vector2(520f, 76f)
: new Vector2(820f, 52f);
GameObject row = new($"Settings Focus {i}", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
row.transform.SetParent(parent, false);
RectTransform rowRect = row.GetComponent<RectTransform>();
rowRect.anchorMin = new Vector2(0.5f, 0.5f);
rowRect.anchorMax = new Vector2(0.5f, 0.5f);
rowRect.pivot = new Vector2(0.5f, 0.5f);
rowRect.anchoredPosition = new Vector2(isBackButton ? 0f : 5f, y);
rowRect.sizeDelta = size;
Image rowImage = row.GetComponent<Image>();
rowImage.color = new Color32(75, 128, 148, 72);
rowImage.raycastTarget = false;
GameObject marker = new($"Marker {i}", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
marker.transform.SetParent(row.transform, false);
RectTransform markerRect = marker.GetComponent<RectTransform>();
markerRect.anchorMin = new Vector2(0.5f, 0.5f);
markerRect.anchorMax = new Vector2(0.5f, 0.5f);
markerRect.pivot = new Vector2(0.5f, 0.5f);
markerRect.anchoredPosition = new Vector2(-size.x * 0.5f + 12f, 0f);
markerRect.sizeDelta = new Vector2(6f, 34f);
Image markerImage = marker.GetComponent<Image>();
markerImage.color = new Color32(240, 196, 92, 255);
markerImage.raycastTarget = false;
row.SetActive(false);
settingsFocusRows[i] = row;
}
}
private static GameObject CreateButtonFocusMarker(Transform parent)
{
GameObject marker = new("Focus Marker", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
marker.transform.SetParent(parent, false);
RectTransform rect = marker.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 = new Vector2(-210f, 0f);
rect.sizeDelta = new Vector2(6f, 38f);
Image image = marker.GetComponent<Image>();
image.color = new Color32(240, 196, 92, 255);
image.raycastTarget = false;
marker.SetActive(false);
return marker;
}
private static Text CreateText(
Transform parent,
string name,
Font font,
int fontSize,
Vector2 position,
Vector2 size)
{
return PresentationUiStyle.CreateText(
parent,
name,
font,
fontSize,
TextAnchor.MiddleCenter,
position,
size);
}
private static Button CreateButton(
Transform parent,
string name,
Font font,
Vector2 position)
{
GameObject buttonObject = new(name, typeof(RectTransform), typeof(CanvasRenderer), typeof(Image), typeof(Button));
buttonObject.transform.SetParent(parent, false);
RectTransform rect = buttonObject.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 = position;
rect.sizeDelta = new Vector2(460f, 68f);
Button button = buttonObject.GetComponent<Button>();
Image image = buttonObject.GetComponent<Image>();
button.targetGraphic = image;
Navigation navigation = button.navigation;
navigation.mode = Navigation.Mode.None;
button.navigation = navigation;
PresentationUiStyle.ApplyButton(button);
Text text = CreateText(buttonObject.transform, "Label", font, 24, Vector2.zero, new Vector2(430f, 58f));
text.raycastTarget = false;
return button;
}
private static Slider CreateSlider(Transform parent, string name, Vector2 position)
{
GameObject sliderObject = new(name, typeof(RectTransform), typeof(Slider));
sliderObject.transform.SetParent(parent, false);
RectTransform rect = sliderObject.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 = position + Vector2.right * 130f;
rect.sizeDelta = new Vector2(500f, 42f);
Slider slider = sliderObject.GetComponent<Slider>();
slider.minValue = 0f;
slider.maxValue = 1f;
slider.wholeNumbers = false;
GameObject backgroundObject = new("Background", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
backgroundObject.transform.SetParent(sliderObject.transform, false);
RectTransform backgroundRect = backgroundObject.GetComponent<RectTransform>();
backgroundRect.anchorMin = new Vector2(0f, 0.5f);
backgroundRect.anchorMax = new Vector2(1f, 0.5f);
backgroundRect.sizeDelta = new Vector2(0f, 18f);
Image background = backgroundObject.GetComponent<Image>();
background.color = new Color(0.16f, 0.19f, 0.24f, 1f);
GameObject fillAreaObject = new("Fill Area", typeof(RectTransform));
fillAreaObject.transform.SetParent(sliderObject.transform, false);
RectTransform fillAreaRect = fillAreaObject.GetComponent<RectTransform>();
fillAreaRect.anchorMin = new Vector2(0f, 0.5f);
fillAreaRect.anchorMax = new Vector2(1f, 0.5f);
fillAreaRect.sizeDelta = new Vector2(0f, 18f);
GameObject fillObject = new("Fill", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
fillObject.transform.SetParent(fillAreaObject.transform, false);
RectTransform fillRect = fillObject.GetComponent<RectTransform>();
fillRect.anchorMin = Vector2.zero;
fillRect.anchorMax = Vector2.one;
fillRect.offsetMin = Vector2.zero;
fillRect.offsetMax = Vector2.zero;
Image fillImage = fillObject.GetComponent<Image>();
fillImage.color = new Color32(222, 183, 112, 255);
GameObject handleAreaObject = new("Handle Area", typeof(RectTransform));
handleAreaObject.transform.SetParent(sliderObject.transform, false);
RectTransform handleAreaRect = handleAreaObject.GetComponent<RectTransform>();
handleAreaRect.anchorMin = new Vector2(0f, 0.5f);
handleAreaRect.anchorMax = new Vector2(1f, 0.5f);
handleAreaRect.sizeDelta = Vector2.zero;
GameObject handleObject = new("Handle", typeof(RectTransform), typeof(CanvasRenderer), typeof(Image));
handleObject.transform.SetParent(handleAreaObject.transform, false);
RectTransform handleRect = handleObject.GetComponent<RectTransform>();
handleRect.anchorMin = new Vector2(0f, 0.5f);
handleRect.anchorMax = new Vector2(0f, 0.5f);
handleRect.sizeDelta = new Vector2(24f, 36f);
Image handleImage = handleObject.GetComponent<Image>();
handleImage.color = new Color32(240, 216, 153, 255);
slider.fillRect = fillRect;
slider.handleRect = handleRect;
slider.targetGraphic = handleImage;
Navigation navigation = slider.navigation;
navigation.mode = Navigation.Mode.None;
slider.navigation = navigation;
slider.direction = Slider.Direction.LeftToRight;
return slider;
}
private void AddButtonPointerSelection(Button button, int index)
{
EventTrigger trigger = button.gameObject.AddComponent<EventTrigger>();
EventTrigger.Entry entry = new() { eventID = EventTriggerType.PointerEnter };
entry.callback.AddListener(_ =>
{
if (!button.interactable)
{
return;
}
selectedButton = index;
if (currentScreen == Screen.Settings && index == 3)
{
selectedSettingsControl = 3;
SelectSettingsControl(false);
}
else
{
SelectButton(index, false);
}
});
trigger.triggers.Add(entry);
}
private void AddSliderPointerSelection(Slider slider, int index)
{
EventTrigger trigger = slider.gameObject.AddComponent<EventTrigger>();
EventTrigger.Entry entry = new() { eventID = EventTriggerType.PointerEnter };
entry.callback.AddListener(_ =>
{
selectedSettingsControl = index;
SelectSettingsControl(false);
});
trigger.triggers.Add(entry);
}
private static void SetControlVisible(Component control, bool visible)
{
if (control != null)
{
control.gameObject.SetActive(visible);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3be9c01d27a64d09a68f4e2b9c5d7a13
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,447 @@
using System.Collections.Generic;
using System.Globalization;
using BumpCombat.Audio;
using BumpCombat.Core;
using BumpCombat.Player;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;
namespace BumpCombat.UI
{
[DisallowMultipleComponent]
public sealed class RunTutorialController : MonoBehaviour
{
public enum TutorialKind
{
Artifact,
Guard,
ArtifactEnhancement,
}
private readonly List<TutorialKind> pendingTutorials = new();
private readonly HashSet<TutorialKind> queuedOrShown = new();
private RunManager runManager;
private GameObject panel;
private GameObject modalDim;
private TutorialMotionPreview motionPreview;
private Text titleText;
private Text bodyText;
private Text helpText;
private Button continueButton;
private TutorialKind? currentTutorial;
private bool artifactAcquired;
private int inputReadyFrame;
private bool started;
private void Awake()
{
runManager = RunManager.Instance;
}
private void OnEnable()
{
if (runManager == null)
{
runManager = RunManager.Instance;
}
if (runManager == null)
{
return;
}
runManager.OnGuardAvailabilityChanged += HandleGuardAvailabilityChanged;
runManager.OnArtifactChargeAvailabilityChanged +=
HandleArtifactChargeAvailabilityChanged;
runManager.OnSelectionChanged += HandleSelectionChanged;
runManager.OnPauseChanged += HandlePauseChanged;
runManager.OnGameOver += HandleGameOver;
if (started)
{
TryShowNextTutorial();
}
}
private void Start()
{
PresentationUiStyle.EnsureEventSystem(
clearMenuNavigationActions: false);
CreatePanel();
if (runManager != null && runManager.IsDevelopmentRun)
{
if (runManager.IsGuardUnlocked)
{
QueueTutorial(TutorialKind.Guard);
}
if (runManager.IsArtifactChargeUnlocked)
{
QueueTutorial(TutorialKind.ArtifactEnhancement);
}
}
started = true;
TryShowNextTutorial();
}
private void OnDisable()
{
motionPreview?.Hide();
if (runManager == null)
{
return;
}
runManager.OnGuardAvailabilityChanged -= HandleGuardAvailabilityChanged;
runManager.OnArtifactChargeAvailabilityChanged -=
HandleArtifactChargeAvailabilityChanged;
runManager.OnSelectionChanged -= HandleSelectionChanged;
runManager.OnPauseChanged -= HandlePauseChanged;
runManager.OnGameOver -= HandleGameOver;
if (currentTutorial.HasValue)
{
pendingTutorials.Insert(0, currentTutorial.Value);
currentTutorial = null;
panel?.SetActive(false);
modalDim?.SetActive(false);
if (runManager.IsSelectionOpen)
{
runManager.CloseSelection(this);
}
}
}
private void Update()
{
if (!currentTutorial.HasValue
|| Time.frameCount <= inputReadyFrame
|| Keyboard.current == null)
{
return;
}
Keyboard keyboard = Keyboard.current;
if (keyboard.spaceKey.wasPressedThisFrame
|| keyboard.enterKey.wasPressedThisFrame
|| keyboard.numpadEnterKey.wasPressedThisFrame)
{
ContinueCurrentTutorial();
}
}
private void LateUpdate()
{
if (!currentTutorial.HasValue || panel == null || modalDim == null)
{
return;
}
int lastIndex = transform.childCount - 1;
int dimIndex = modalDim.transform.GetSiblingIndex();
int panelIndex = panel.transform.GetSiblingIndex();
if (panelIndex == lastIndex && dimIndex == lastIndex - 1)
{
return;
}
modalDim.transform.SetAsLastSibling();
panel.transform.SetAsLastSibling();
}
internal void NotifyArtifactAcquired()
{
artifactAcquired = true;
QueueTutorial(TutorialKind.Artifact);
}
private void HandleGuardAvailabilityChanged(bool available)
{
if (available
&& (runManager.IsProductionRun || runManager.IsDevelopmentRun))
{
QueueTutorial(TutorialKind.Guard);
}
}
private void HandleArtifactChargeAvailabilityChanged(bool available)
{
if (available
&& (runManager.IsProductionRun || runManager.IsDevelopmentRun))
{
QueueTutorial(TutorialKind.ArtifactEnhancement);
}
}
private void HandleSelectionChanged(bool isOpen)
{
if (!isOpen)
{
TryShowNextTutorial();
}
}
private void HandlePauseChanged(bool isOpen)
{
if (!isOpen)
{
TryShowNextTutorial();
}
}
private void HandleGameOver()
{
pendingTutorials.Clear();
queuedOrShown.Clear();
motionPreview?.Hide();
if (currentTutorial.HasValue)
{
currentTutorial = null;
panel?.SetActive(false);
modalDim?.SetActive(false);
runManager.CloseSelection(this);
}
}
private void QueueTutorial(TutorialKind kind)
{
if (queuedOrShown.Contains(kind))
{
return;
}
queuedOrShown.Add(kind);
pendingTutorials.Add(kind);
pendingTutorials.Sort();
TryShowNextTutorial();
}
private void TryShowNextTutorial()
{
if (panel == null
|| !isActiveAndEnabled
|| currentTutorial.HasValue
|| pendingTutorials.Count == 0
|| (runManager != null
&& runManager.IsDevelopmentRun
&& !artifactAcquired))
{
return;
}
if (runManager == null || !runManager.TryOpenSelection(this))
{
return;
}
currentTutorial = pendingTutorials[0];
pendingTutorials.RemoveAt(0);
RenderCurrentTutorial();
modalDim.SetActive(true);
panel.SetActive(true);
motionPreview?.Show(currentTutorial.Value);
inputReadyFrame = Time.frameCount;
BumpCombatAudioService.Instance?.PlayUi("ui_confirm");
}
private void ContinueCurrentTutorial()
{
if (!currentTutorial.HasValue || Time.frameCount <= inputReadyFrame)
{
return;
}
FinishCurrentTutorial();
}
private void FinishCurrentTutorial()
{
if (!currentTutorial.HasValue)
{
return;
}
currentTutorial = null;
motionPreview?.Hide();
panel.SetActive(false);
modalDim.SetActive(false);
BumpCombatAudioService.Instance?.PlayUi("ui_confirm");
runManager.CloseSelection(this);
TryShowNextTutorial();
}
private void RenderCurrentTutorial()
{
switch (currentTutorial)
{
case TutorialKind.Artifact:
titleText.text = "아티팩트 획득";
bodyText.text =
"1. 아티팩트를 더 얻은 뒤 S로 교체합니다.\n"
+ "2. A를 눌렀다 놓으면 일반기를 사용합니다.\n"
+ "3. 이동하거나 몸통박치기에 성공하면 공용 게이지가 찹니다.";
break;
case TutorialKind.Guard:
PlayerHealth playerHealth =
Object.FindAnyObjectByType<PlayerHealth>();
float guardDuration = playerHealth != null
? playerHealth.GuardDuration
: PlayerHealth.DefaultGuardDuration;
float guardCooldown = playerHealth != null
? playerHealth.GuardCooldownDuration
: PlayerHealth.DefaultGuardCooldown;
float guardSuccessRechargePercent = playerHealth != null
? playerHealth.GuardSuccessCooldownRechargePercent
: PlayerHealth.DefaultGuardSuccessCooldownRechargePercent;
titleText.text = "가드 해금";
bodyText.text =
$"D를 누르면 {guardDuration.ToString("0.##", CultureInfo.InvariantCulture)}초 동안 피해를 막습니다.\n"
+ $"기본 재사용 대기시간은 {guardCooldown.ToString("0.##", CultureInfo.InvariantCulture)}초입니다.\n"
+ $"가드 성공 시 가드 게이지를 {guardSuccessRechargePercent.ToString("0.##", CultureInfo.InvariantCulture)}% 충전합니다.\n"
+ "발동당 1회 적용되며, 남은 대기시간이 줄어듭니다.";
break;
case TutorialKind.ArtifactEnhancement:
titleText.text = "아티팩트 강화 해금";
bodyText.text =
"A를 길게 눌러 완충한 뒤 놓으면 강화기를 발동합니다.\n"
+ "강화기는 일반기와 같은 양의 게이지를 소모합니다.";
break;
}
}
private void CreatePanel()
{
Font font = PresentationUiStyle.GetGalmuriFont();
modalDim = new GameObject(
"Run Tutorial Modal Dim",
typeof(RectTransform),
typeof(CanvasRenderer),
typeof(Image));
modalDim.transform.SetParent(transform, false);
RectTransform dimRect = modalDim.GetComponent<RectTransform>();
dimRect.anchorMin = Vector2.zero;
dimRect.anchorMax = Vector2.one;
dimRect.offsetMin = Vector2.zero;
dimRect.offsetMax = Vector2.zero;
Image dimImage = modalDim.GetComponent<Image>();
dimImage.color = new Color(0f, 0f, 0f, 0.78f);
dimImage.raycastTarget = true;
panel = PresentationUiStyle.CreatePanel(
transform,
"Run Tutorial",
new Vector2(0.5f, 0.5f),
Vector2.zero,
new Vector2(1500f, 760f),
new Color(0.03f, 0.04f, 0.08f, 0.98f));
PresentationUiStyle.ApplyPanel(panel.GetComponent<Image>());
GameObject previewObject = new(
"Tutorial Motion Preview",
typeof(RectTransform),
typeof(CanvasRenderer),
typeof(Image));
previewObject.transform.SetParent(panel.transform, false);
RectTransform previewRect = previewObject.GetComponent<RectTransform>();
previewRect.anchorMin = new Vector2(0.5f, 0.5f);
previewRect.anchorMax = new Vector2(0.5f, 0.5f);
previewRect.pivot = new Vector2(0.5f, 0.5f);
previewRect.anchoredPosition = new Vector2(-360f, 20f);
previewRect.sizeDelta = new Vector2(560f, 350f);
Image previewFrame = previewObject.GetComponent<Image>();
previewFrame.color = new Color(0.02f, 0.03f, 0.05f, 0.98f);
previewFrame.raycastTarget = false;
motionPreview = previewObject.AddComponent<TutorialMotionPreview>();
titleText = PresentationUiStyle.CreateText(
panel.transform,
"Title",
font,
42,
TextAnchor.MiddleCenter,
new Vector2(0f, 310f),
new Vector2(1380f, 76f));
bodyText = PresentationUiStyle.CreateText(
panel.transform,
"Instructions",
font,
30,
TextAnchor.MiddleLeft,
new Vector2(350f, 20f),
new Vector2(620f, 340f));
bodyText.horizontalOverflow = HorizontalWrapMode.Wrap;
bodyText.verticalOverflow = VerticalWrapMode.Truncate;
bodyText.resizeTextForBestFit = true;
bodyText.resizeTextMinSize = 24;
bodyText.resizeTextMaxSize = 30;
helpText = PresentationUiStyle.CreateText(
panel.transform,
"Tutorial Help",
font,
18,
TextAnchor.MiddleCenter,
new Vector2(0f, -250f),
new Vector2(1320f, 40f));
helpText.text = "왼쪽은 자동 시연입니다 · Enter / Space 계속";
helpText.color = new Color32(200, 208, 218, 255);
GameObject buttonObject = new(
"Continue Button",
typeof(RectTransform),
typeof(CanvasRenderer),
typeof(Image),
typeof(Button));
buttonObject.transform.SetParent(panel.transform, false);
RectTransform buttonRect = buttonObject.GetComponent<RectTransform>();
buttonRect.anchorMin = new Vector2(0.5f, 0.5f);
buttonRect.anchorMax = new Vector2(0.5f, 0.5f);
buttonRect.pivot = new Vector2(0.5f, 0.5f);
buttonRect.anchoredPosition = new Vector2(0f, -320f);
buttonRect.sizeDelta = new Vector2(380f, 72f);
continueButton = buttonObject.GetComponent<Button>();
continueButton.targetGraphic = buttonObject.GetComponent<Image>();
Navigation navigation = continueButton.navigation;
navigation.mode = Navigation.Mode.None;
continueButton.navigation = navigation;
PresentationUiStyle.ApplyButton(continueButton);
Text buttonLabel = PresentationUiStyle.CreateText(
buttonObject.transform,
"Label",
font,
26,
TextAnchor.MiddleCenter,
Vector2.zero,
new Vector2(350f, 60f));
buttonLabel.text = "계속";
buttonLabel.raycastTarget = false;
continueButton.onClick.AddListener(ContinueCurrentTutorial);
panel.SetActive(false);
modalDim.SetActive(false);
}
#if UNITY_EDITOR
public bool DebugIsVisible => panel != null && panel.activeSelf;
public TutorialKind? DebugCurrentTutorial => currentTutorial;
public int DebugPendingCount => pendingTutorials.Count;
public bool DebugHasRaycastBlocker =>
modalDim != null && modalDim.GetComponent<Image>().raycastTarget;
public TutorialMotionPreview DebugMotionPreview => motionPreview;
public string DebugBodyText => bodyText != null ? bodyText.text : null;
public string DebugHelpText => helpText != null ? helpText.text : null;
public bool DebugContinue()
{
if (!currentTutorial.HasValue)
{
return false;
}
FinishCurrentTutorial();
return true;
}
#endif
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 885dbb7ec45f4c4fae597bd6b30bfe12
@@ -0,0 +1,369 @@
using System;
using System.Collections.Generic;
using BumpCombat.Combat;
using BumpCombat.Player;
using UnityEngine;
using UnityEngine.UI;
namespace BumpCombat.UI
{
// Presentation-only replay: uses the authored frames and an unscaled clock.
// It never sends input or grants resources to the live player.
public sealed class TutorialMotionPreview : MonoBehaviour
{
private readonly Dictionary<string, Sprite[]> frames = new();
private readonly List<Sprite> generatedFrames = new();
private readonly Image[] slots = new Image[3];
private readonly Color[] colors = { new Color32(100, 229, 145, 255),
new Color32(255, 114, 108, 255), new Color32(105, 177, 255, 255) };
private static readonly float[] RayFrameDurations = { 0.02f, 0.03f, 0.03f, 0.03f, 0.02f, 0.02f };
private static readonly float[] BumpFrameDurations = CreateBumpFrameDurations();
private Image actor, target, effect, guard, gaugeFill, skillBack, skillFront;
private Text actionLabel, keyLabel, gaugeLabel, pageLabel;
private PlayerHealth playerHealth;
private ActiveArtifactDefinition cycloneDefinition, rayDefinition;
private RunTutorialController.TutorialKind kind;
private float clock;
private bool playing;
public void Show(RunTutorialController.TutorialKind tutorial)
{
playerHealth = FindAnyObjectByType<PlayerHealth>();
ActiveArtifactController artifacts = FindAnyObjectByType<ActiveArtifactController>();
if (artifacts != null)
for (int i = 0; i < artifacts.CatalogCount; i++)
{
ActiveArtifactDefinition definition = artifacts.GetCatalogArtifactAt(i);
if (definition == null) continue;
if (definition.Effect == ActiveArtifactEffect.Cyclone) cycloneDefinition = definition;
if (definition.Effect == ActiveArtifactEffect.Phoenix) rayDefinition = definition;
}
if (actor == null) Build();
kind = tutorial;
clock = 0f;
playing = true;
gameObject.SetActive(true);
Render();
}
public void Hide()
{
playing = false;
gameObject.SetActive(false);
}
private void Update()
{
if (!playing) return;
clock += Time.unscaledDeltaTime;
Render();
}
private void OnDestroy()
{
foreach (Sprite sprite in generatedFrames) Destroy(sprite);
}
private void Build()
{
Image background = gameObject.GetComponent<Image>();
if (background == null) background = gameObject.AddComponent<Image>();
background.color = new Color32(18, 24, 32, 255);
background.raycastTarget = false;
gameObject.AddComponent<RectMask2D>();
pageLabel = Label("Demo Page", "", 20, new Vector2(235, 150), new Vector2(70, 28));
actionLabel = Label("Demo Action", "", 24, new Vector2(0, 113), new Vector2(530, 40));
keyLabel = Label("Demo Key", "", 22, new Vector2(0, 73), new Vector2(510, 32));
keyLabel.color = new Color32(255, 220, 133, 255);
// Keep each complete authored cell; transparent pixels preserve registration.
skillBack = Picture("Demo Skill Back", Vector2.zero, new Vector2(300, 300));
actor = Picture("Demo Character", new Vector2(-60, -15), new Vector2(400, 400));
target = Picture("Demo Enemy", new Vector2(155, -15), new Vector2(300, 300));
skillFront = Picture("Demo Skill Front", Vector2.zero, new Vector2(300, 300));
effect = Picture("Demo Effect", new Vector2(60, -15), new Vector2(180, 180));
guard = Picture("Demo Guard", new Vector2(-60, -15), new Vector2(400, 400));
for (int i = 0; i < slots.Length; i++)
{
slots[i] = Picture("Demo Slot " + i, new Vector2(-48 + i * 48, -93), new Vector2(38, 12));
slots[i].sprite = null;
}
Image track = Picture("Demo Gauge Track", new Vector2(0, -124), new Vector2(400, 14));
track.color = new Color32(45, 53, 65, 255);
gaugeFill = Picture("Demo Gauge Fill", new Vector2(-200, -124), new Vector2(400, 14));
gaugeFill.rectTransform.pivot = new Vector2(0, 0.5f);
gaugeLabel = Label("Demo Gauge Value", "", 18, new Vector2(0, -150), new Vector2(520, 30));
}
private void Render()
{
actor.rectTransform.anchoredPosition = new Vector2(-60, -15);
actor.rectTransform.localScale = Vector3.one;
target.rectTransform.anchoredPosition = new Vector2(155, -15);
target.color = Color.white;
target.enabled = false;
effect.enabled = false;
effect.rectTransform.sizeDelta = new Vector2(180, 180);
skillBack.enabled = false;
skillFront.enabled = false;
guard.enabled = false;
SetFrame(actor, "Tutorial/Swordsman_Idle", clock, 10f);
for (int i = 0; i < slots.Length; i++) slots[i].enabled = kind != RunTutorialController.TutorialKind.Guard;
switch (kind)
{
case RunTutorialController.TutorialKind.Artifact: RenderArtifact(); break;
case RunTutorialController.TutorialKind.Guard: RenderGuard(); break;
default: RenderEnhancement(); break;
}
}
private void RenderArtifact()
{
float t = clock % 12f;
pageLabel.text = $"{(t < 2f ? 1 : t < 4f ? 2 : t < 6f ? 3 : t < 9f ? 4 : 5)}/5";
int selected = t < 2f ? 2 : 0;
SelectSlot(selected);
float gauge = 60f;
float afterUse = 60f - (cycloneDefinition != null ? cycloneDefinition.NormalGaugeCost : 30f);
if (t < 2f)
{
Step("장비를 확인하세요", "대기");
}
else if (t < 4f)
{
Step("바람깃 브로치로 교체합니다", "[ S ] 교체");
if (t < 2.6f) ShowEffect("Artifacts/ThreeColor-v1/Charge/" + EffectName(selected) + "-Release-v1", t - 2f, 10f, new Vector2(-60, -15));
}
else if (t < 6f)
{
Step("사용하면 게이지가 줄어듭니다", "[ A ] 눌렀다 놓기");
gauge = afterUse;
UseCyclone(t - 4f);
}
else if (t < 9f)
{
Step("이동하면 게이지가 찹니다", "[ ← → ] 이동");
float move = (t - 6f) / 3f;
actor.rectTransform.anchoredPosition = new Vector2(-110 + Mathf.PingPong(move * 240f, 120f), -15);
actor.rectTransform.localScale = new Vector3(move > 0.5f ? -1 : 1, 1, 1);
SetFrame(actor, "Tutorial/Swordsman_Walk", t, 10f);
gauge = afterUse + (t - 6f) * 2f;
}
else
{
Step("몸통박치기 적중으로도 충전됩니다", "[ → ] 적에게 이동");
float bump = t - 9f;
float travel = Mathf.Clamp01(bump / 0.65f);
actor.rectTransform.anchoredPosition = new Vector2(-60 + travel * 143f, -15);
target.enabled = true;
SetFrame(target, "Tutorial/Slime_Idle", clock, 10f);
if (bump < 0.65f) SetFrame(actor, "Tutorial/Swordsman_Walk", bump, 10f);
else if (bump < 1.2f) SetFrame(actor, "Tutorial/Swordsman_BumpAttack-v1", bump - 0.65f, 10f, false);
if (bump >= 0.65f)
{
target.rectTransform.anchoredPosition += Vector2.right * Mathf.Min(24f, (bump - 0.65f) * 90f);
target.color = bump < 0.85f ? colors[selected] : Color.white;
ShowBumpFlash(bump - 0.65f, new Vector2(135f, -22f));
}
gauge = afterUse + (bump < 0.65f ? 6f : 10f);
}
Gauge(gauge, colors[selected], "아티팩트 게이지 " + Mathf.FloorToInt(gauge) + " / 100");
}
private void RenderGuard()
{
float duration = playerHealth != null ? playerHealth.GuardDuration : PlayerHealth.DefaultGuardDuration;
float cooldown = playerHealth != null ? playerHealth.GuardCooldownDuration : PlayerHealth.DefaultGuardCooldown;
float waitDuration = Mathf.Min(2f, Mathf.Max(0f, cooldown - duration));
float t = clock % (duration + waitDuration + 3f);
float sinceUse = t - 2f;
// Preserve the active guard duration; compress only the remaining wait.
float cooldownElapsed = sinceUse <= duration ? sinceUse
: waitDuration > 0f ? Mathf.Lerp(duration, cooldown, Mathf.Clamp01((sinceUse - duration) / waitDuration))
: cooldown;
pageLabel.text = $"{(sinceUse < 0f ? 1 : sinceUse < duration ? 2 : sinceUse < duration + waitDuration ? 3 : 4)}/4";
if (sinceUse < 0f)
Step("가드를 사용할 준비가 됐습니다", "[ D ] 가드");
else if (sinceUse < duration)
{
Step($"{duration:0.##}초 동안 피해를 막습니다", "[ D ] 무적");
guard.enabled = true;
SetFrame(guard, "Combat/Guard-v1/Swordsman_Idle-outline", clock, 10f);
}
else if (sinceUse < duration + waitDuration)
Step($"발동부터 {cooldown:0.##}초 뒤 다시 사용합니다", "재사용 대기 · 빠르게 시연");
else Step("가드가 다시 준비됐습니다", "[ D ] 사용 가능");
float ready = sinceUse < 0f || cooldown <= 0f ? 1f : Mathf.Clamp01(cooldownElapsed / cooldown);
Gauge(ready * 100f, new Color32(255, 222, 110, 255), ready >= 1f ? "가드 준비 완료" : "가드 재사용 대기");
}
private void RenderEnhancement()
{
float chargeDuration = rayDefinition != null ? rayDefinition.ChargeDuration : 0.8f;
float releaseAt = 2f + chargeDuration + 0.2f;
float t = clock % (releaseAt + 4f);
pageLabel.text = $"{(t < 2f ? 1 : t < 2f + chargeDuration ? 2 : t < releaseAt ? 3 : 4)}/4";
SelectSlot(1);
float gauge = t < releaseAt ? 60f : 60f - (rayDefinition != null ? rayDefinition.ChargedGaugeCost : 25f);
if (t < 2f) Step("아티팩트 강화가 해금됐습니다", "대기");
else if (t < releaseAt)
{
float progress = chargeDuration <= 0f ? 1f : Mathf.Clamp01((t - 2f) / chargeDuration);
Step(progress < 1f ? "키를 누른 채 충전합니다" : "완충! 이제 키를 놓으세요", "[ A ] 누르기 유지 · " + Mathf.RoundToInt(progress * 100f) + "%");
ShowEffect("Artifacts/ThreeColor-v1/Charge/Phoenix-" + (progress < 1f ? "Aura" : "ReadyAura") + "-v1", t - 2f, 12f, new Vector2(-60, -15), true);
}
else
{
Step("강화기 발동 · 같은 게이지 비용", "[ A ] 놓기");
UseScorchingRay(t - releaseAt);
}
Gauge(gauge, colors[1], "아티팩트 게이지 " + gauge.ToString("0") + " / 100");
}
private void UseCyclone(float elapsed)
{
float duration = cycloneDefinition != null ? cycloneDefinition.NormalDuration : 0.36f;
float distance = cycloneDefinition != null ? cycloneDefinition.NormalMoveDistance : 0.65f;
float progress = Mathf.Clamp01(elapsed / Mathf.Max(0.01f, duration));
Vector2 position = new Vector2(-60f + distance * 64f * progress, -15f);
actor.rectTransform.anchoredPosition = position;
target.enabled = true;
SetFrame(target, "Tutorial/Slime_Idle", clock, 10f);
target.rectTransform.anchoredPosition = new Vector2(65f + progress * 20f, -15f);
if (elapsed >= duration) return;
SetFrame(actor, "Artifacts/ThreeColor-v1/Player/Swordsman_ArtifactUse-v1-Green-v1", elapsed, 10f, false);
skillBack.rectTransform.anchoredPosition = position + Vector2.up * 32f;
skillFront.rectTransform.anchoredPosition = skillBack.rectTransform.anchoredPosition;
skillBack.rectTransform.sizeDelta = skillFront.rectTransform.sizeDelta = new Vector2(330, 330);
SetFrame(skillBack, "Artifacts/ThreeColor-v1/CycloneOrbit-v3/Cyclone-Normal-Back-v3", elapsed, 20f);
SetFrame(skillFront, "Artifacts/ThreeColor-v1/CycloneOrbit-v3/Cyclone-Normal-Front-v3", elapsed, 20f);
float hitInterval = cycloneDefinition != null ? cycloneDefinition.HitInterval : 0.14f;
float hitAge = elapsed % Mathf.Max(0.01f, hitInterval);
ShowEffect("Artifacts/ThreeColor-v1/Combat/ArtifactImpact-Cyclone-Normal-v1", hitAge, 28f, new Vector2(65f, -20f));
target.color = hitAge < 0.07f ? colors[0] : Color.white;
}
private void UseScorchingRay(float elapsed)
{
target.enabled = true;
SetFrame(target, "Tutorial/Slime_Idle", clock, 10f);
target.rectTransform.anchoredPosition = new Vector2(155f + Mathf.Min(20f, elapsed * 100f), -15f);
if (elapsed < 0.5f)
SetFrame(actor, "Artifacts/ThreeColor-v1/Player/Swordsman_ArtifactUse-v1-Red-v1", elapsed, 10f, false);
// Same six authored frames and 0.15s burst as ScorchingRayRoutine.
if (elapsed >= 0.15f) return;
Sprite[] sequence = LoadFrames("Artifacts/ThreeColor-v1/ScorchingRay/ScorchingRay-Ghostfire-Charged-v1");
skillFront.rectTransform.sizeDelta = new Vector2(360, 180);
skillFront.rectTransform.anchoredPosition = new Vector2(95f, -16f);
SetTimedFrame(skillFront, sequence, RayFrameDurations, elapsed);
target.color = colors[1];
ShowEffect("Artifacts/ThreeColor-v1/Combat/ArtifactImpact-SearingRay-Charged-v1", elapsed, 28f, new Vector2(155f, -20f));
}
private void ShowBumpFlash(float age, Vector2 position)
{
float total = 0f;
foreach (float duration in BumpFrameDurations) total += duration;
if (age < 0f || age >= total) return;
effect.rectTransform.anchoredPosition = position;
effect.rectTransform.sizeDelta = new Vector2(260, 260);
SetTimedFrame(effect, LoadFrames("Artifacts/ThreeColor-v1/Combat/BumpImpact-Weak-Green-v1"), BumpFrameDurations, age);
}
private static float[] CreateBumpFrameDurations()
{
float[] result = new float[CombatFeedback.BumpImpactFrameCount];
for (int i = 0; i < result.Length; i++) result[i] = CombatFeedback.GetBumpImpactFrameDuration(i);
return result;
}
private static void SetTimedFrame(Image image, Sprite[] sequence, float[] durations, float age)
{
if (sequence.Length == 0) { image.enabled = false; return; }
int frame = 0;
while (frame < durations.Length - 1 && age >= durations[frame]) age -= durations[frame++];
image.sprite = sequence[Mathf.Min(frame, sequence.Length - 1)];
image.enabled = true;
}
private static string EffectName(int selected) => selected == 0 ? "Cyclone" : selected == 1 ? "Phoenix" : "ThunderCrash";
private void Step(string description, string key) { actionLabel.text = description; keyLabel.text = key; }
private void SelectSlot(int selected)
{
for (int i = 0; i < slots.Length; i++)
{
slots[i].color = i == selected ? colors[i] : colors[i] * new Color(0.35f, 0.35f, 0.35f, 1f);
slots[i].rectTransform.sizeDelta = new Vector2(38, i == selected ? 16 : 8);
}
}
private void Gauge(float value, Color color, string caption)
{
gaugeFill.color = color;
gaugeFill.rectTransform.sizeDelta = new Vector2(400f * Mathf.Clamp01(value / 100f), 14f);
gaugeLabel.text = caption;
}
private void ShowEffect(string path, float time, float fps, Vector2 position, bool loop = false)
{
effect.rectTransform.anchoredPosition = position;
effect.enabled = true;
SetFrame(effect, path, time, fps, loop);
}
private void SetFrame(Image image, string path, float time, float fps, bool loop = true)
{
Sprite[] sequence = LoadFrames(path);
if (sequence.Length == 0) { image.enabled = false; return; }
int index = Mathf.FloorToInt(time * fps);
image.sprite = sequence[loop ? index % sequence.Length : Mathf.Min(index, sequence.Length - 1)];
image.enabled = true;
}
private Sprite[] LoadFrames(string path)
{
if (!frames.TryGetValue(path, out Sprite[] sequence))
{
sequence = Resources.LoadAll<Sprite>(path);
if (sequence.Length == 0)
{
Texture2D texture = Resources.Load<Texture2D>(path);
int width = path.Contains("CycloneOrbit-v3") ? 100 : path.Contains("ScorchingRay-Ghostfire-Charged") ? 256 : 64;
int height = path.Contains("CycloneOrbit-v3") ? 100 : path.Contains("ScorchingRay-Ghostfire-Charged") ? 128 : 64;
if (texture != null && texture.height == height && texture.width % width == 0)
{
sequence = new Sprite[texture.width / width];
for (int i = 0; i < sequence.Length; i++)
{
sequence[i] = Sprite.Create(texture, new Rect(i * width, 0, width, height), new Vector2(0.5f, 0.5f), 32, 0, SpriteMeshType.FullRect);
sequence[i].name = path + " Frame " + i;
generatedFrames.Add(sequence[i]);
}
}
}
Array.Sort(sequence, (a, b) => a.rect.x.CompareTo(b.rect.x));
frames.Add(path, sequence);
}
return sequence;
}
private Image Picture(string name, Vector2 position, Vector2 size)
{
Image image = PresentationUiStyle.CreateImage(transform, name, position, size);
image.raycastTarget = false;
image.preserveAspect = true;
return image;
}
private Text Label(string name, string content, int size, Vector2 position, Vector2 bounds)
{
Text text = PresentationUiStyle.CreateText(transform, name, PresentationUiStyle.GetGalmuriFont(), size, TextAnchor.MiddleCenter, position, bounds);
text.text = content;
text.raycastTarget = false;
return text;
}
#if UNITY_EDITOR
public void DebugSample(float seconds) { clock = seconds; Render(); }
public Sprite DebugActorSprite => actor.sprite;
public string DebugAction => actionLabel.text;
public float DebugGaugeWidth => gaugeFill.rectTransform.sizeDelta.x;
public bool DebugGuardVisible => guard.enabled;
public Sprite DebugSkillSprite => skillFront.enabled ? skillFront.sprite : null;
public Sprite DebugImpactSprite => effect.enabled ? effect.sprite : null;
#endif
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8abd446f65a34cebb3d86938862e471b