feat(demo): 改造 Lucky Dice 交互演示

This commit is contained in:
JSD\13999
2026-06-23 16:10:44 +08:00
parent c8b0687b0a
commit 97fe14fd31
5 changed files with 330 additions and 92 deletions

View File

@@ -2,7 +2,8 @@
"name": "FishDice.Demo", "name": "FishDice.Demo",
"rootNamespace": "FishDice.Demo", "rootNamespace": "FishDice.Demo",
"references": [ "references": [
"FishDice.Runtime" "FishDice.Runtime",
"UnityEngine.UI"
], ],
"includePlatforms": [], "includePlatforms": [],
"excludePlatforms": [], "excludePlatforms": [],

View File

@@ -1,142 +1,307 @@
using System.Collections.Generic; using System;
using System.Collections;
using FishDice.LuckyDice;
using UnityEngine.EventSystems;
using UnityEngine; using UnityEngine;
using UnityEngine.UI;
using Random = UnityEngine.Random;
namespace FishDice.Demo namespace FishDice.Demo
{ {
public sealed class LuckyDiceDemoController : MonoBehaviour public sealed class LuckyDiceDemoController : MonoBehaviour
{ {
private static readonly LuckyDiceDemoScenario[] Scenarios = private static readonly string[] NormalPreviewFaces = { "2", "3", "4", "5", "6", "Clover" };
{ private static readonly string[] LuckyPreviewFaces = { "Rocket", "Thief" };
new LuckyDiceDemoScenario("Normal Reward", "Numbers pay the base reward.", 0, 1), public const string BuiltinFontResourcePath = "LegacyRuntime.ttf";
new LuckyDiceDemoScenario("Clover Bonus", "A number plus Clover grants reward and bonus.", 0, 5),
new LuckyDiceDemoScenario("Lucky Dice: Rocket", "Two Clovers enter Lucky Dice and resolve Slap Down.", 5, 5, 0),
new LuckyDiceDemoScenario("Lucky Dice: Thief", "Two Clovers enter Lucky Dice and resolve Treasure Heist.", 5, 5, 1)
};
private readonly List<LuckyDiceDemoSnapshot> _history = new List<LuckyDiceDemoSnapshot>(); private readonly SystemRandomSource _flowRandom = new SystemRandomSource(new System.Random());
private Vector2 _scroll; private Text _titleText;
private LuckyDiceDemoSnapshot _current; private Text _statusText;
private GUIStyle _titleStyle; private Text _detailText;
private GUIStyle _labelStyle; private Text _rewardText;
private GUIStyle _summaryStyle; private Text[] _normalDiceTexts;
private Text[] _luckyDiceTexts;
private Button _primaryButton;
private Text _primaryButtonText;
private Coroutine _flowRoutine;
private LuckyDiceDemoSnapshot _lastSnapshot;
private Font _uiFont;
private void Awake() private void Awake()
{ {
RunScenario(Scenarios[0]); BuildUi();
ShowReady();
} }
private void OnGUI() private void StartRoll()
{ {
EnsureStyles(); if (_flowRoutine != null)
var margin = 24f;
var width = Mathf.Min(780f, Screen.width - margin * 2f);
var height = Mathf.Min(620f, Screen.height - margin * 2f);
GUILayout.BeginArea(new Rect(margin, margin, width, height), GUI.skin.box);
GUILayout.Label("FishDice Lucky Dice Demo", _titleStyle);
GUILayout.Label("Runtime path: normal roll -> combo facts -> rule actions -> optional Lucky Dice target mode.", _labelStyle);
GUILayout.Space(10f);
GUILayout.BeginHorizontal();
if (GUILayout.Button("Random Roll", GUILayout.Height(34f)))
{ {
RunRandom(); StopCoroutine(_flowRoutine);
} }
foreach (var scenario in Scenarios) _flowRoutine = StartCoroutine(PlayRollFlow());
{
if (GUILayout.Button(scenario.Title, GUILayout.Height(34f)))
{
RunScenario(scenario);
}
}
GUILayout.EndHorizontal();
GUILayout.Space(12f);
DrawSnapshot(_current);
GUILayout.Space(12f);
DrawHistory();
GUILayout.EndArea();
} }
private void RunScenario(LuckyDiceDemoScenario scenario) private IEnumerator PlayRollFlow()
{ {
SetCurrent(LuckyDiceDemoRunner.RunScenario(scenario)); _primaryButton.interactable = false;
_primaryButtonText.text = "投掷中";
_rewardText.text = string.Empty;
_detailText.text = string.Empty;
SetLuckyDiceVisible(false);
SetStatus("摇动普通骰子");
_lastSnapshot = LuckyDiceDemoRunner.RunRandom(_flowRandom);
yield return AnimateDice(_normalDiceTexts, NormalPreviewFaces, 0.9f);
ApplyDiceTexts(_normalDiceTexts, _lastSnapshot.NormalFaces);
SetStatus("组合判定");
_detailText.text = _lastSnapshot.ComboLine;
yield return new WaitForSeconds(0.55f);
if (_lastSnapshot.OutcomeKind == "lucky_dice")
{
SetStatus("触发 Lucky Dice");
_rewardText.text = "双 Clover 命中";
yield return new WaitForSeconds(0.45f);
SetLuckyDiceVisible(true);
SetStatus("揭示特殊骰子");
yield return AnimateDice(_luckyDiceTexts, LuckyPreviewFaces, 1.0f);
ApplyDiceTexts(_luckyDiceTexts, _lastSnapshot.LuckyDiceSlotKeys);
} }
private void RunRandom() ShowResult(_lastSnapshot);
{ _flowRoutine = null;
SetCurrent(LuckyDiceDemoRunner.RunRandom());
} }
private void SetCurrent(LuckyDiceDemoSnapshot snapshot) private IEnumerator AnimateDice(Text[] diceTexts, string[] previewFaces, float duration)
{ {
_current = snapshot; var elapsed = 0f;
_history.Insert(0, snapshot); while (elapsed < duration)
if (_history.Count > 8)
{ {
_history.RemoveAt(_history.Count - 1); for (var i = 0; i < diceTexts.Length; i++)
{
diceTexts[i].text = previewFaces[Random.Range(0, previewFaces.Length)];
}
yield return new WaitForSeconds(0.08f);
elapsed += 0.08f;
} }
} }
private void DrawSnapshot(LuckyDiceDemoSnapshot snapshot) private void ShowReady()
{ {
if (snapshot == null) _titleText.text = "Lucky Dice";
SetStatus("准备投骰");
_detailText.text = "普通骰子会随机落点,命中双 Clover 后进入 Lucky Dice。";
_rewardText.text = string.Empty;
ApplyDiceTexts(_normalDiceTexts, new[] { "?", "?" });
ApplyDiceTexts(_luckyDiceTexts, new[] { "?", "?", "?" });
SetLuckyDiceVisible(false);
_primaryButton.interactable = true;
_primaryButtonText.text = "投骰";
}
private void ShowResult(LuckyDiceDemoSnapshot snapshot)
{
SetStatus(ResolveResultStatus(snapshot));
_detailText.text = $"{snapshot.FaceLine}\n{snapshot.RuleLine}\n{snapshot.EffectLine}";
_rewardText.text = snapshot.TargetModeLine == "Target Mode: -"
? snapshot.Summary
: $"{snapshot.Summary}\n{snapshot.TargetModeLine}";
_primaryButton.interactable = true;
_primaryButtonText.text = "再投一次";
}
private static string ResolveResultStatus(LuckyDiceDemoSnapshot snapshot)
{
switch (snapshot.OutcomeKind)
{
case "lucky_dice":
return "进入目标玩法";
case "clover_bonus":
return "获得 Clover Bonus";
case "normal_reward":
return "获得普通奖励";
default:
return "本轮结束";
}
}
private void BuildUi()
{
EnsureEventSystem();
var canvasObject = new GameObject("LuckyDiceCanvas");
canvasObject.transform.SetParent(transform, false);
var canvas = canvasObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.pixelPerfect = true;
var scaler = canvasObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ConstantPixelSize;
scaler.scaleFactor = 1f;
scaler.referencePixelsPerUnit = 100f;
canvasObject.AddComponent<GraphicRaycaster>();
var root = CreatePanel("Root", canvasObject.transform, new Color(0.06f, 0.09f, 0.13f, 1f));
Stretch(root.rectTransform);
var board = CreatePanel("Board", root.transform, new Color(0.12f, 0.16f, 0.22f, 0.96f));
Center(board.rectTransform, new Vector2(760f, 600f));
_titleText = CreateText("Title", board.transform, "Lucky Dice", 34, FontStyle.Bold, Color.white);
SetRect(_titleText.rectTransform, new Vector2(0f, 194f), new Vector2(680f, 48f));
_statusText = CreateText("Status", board.transform, string.Empty, 24, FontStyle.Bold, new Color(1f, 0.84f, 0.38f));
SetRect(_statusText.rectTransform, new Vector2(0f, 142f), new Vector2(680f, 40f));
_normalDiceTexts = new[]
{
CreateDiceText("NormalDiceA", board.transform, new Vector2(-96f, 64f), new Color(0.96f, 0.97f, 1f)),
CreateDiceText("NormalDiceB", board.transform, new Vector2(96f, 64f), new Color(0.96f, 0.97f, 1f))
};
_luckyDiceTexts = new[]
{
CreateDiceText("LuckySlotA", board.transform, new Vector2(-150f, -48f), new Color(1f, 0.86f, 0.46f)),
CreateDiceText("LuckySlotB", board.transform, new Vector2(0f, -48f), new Color(1f, 0.86f, 0.46f)),
CreateDiceText("LuckySlotC", board.transform, new Vector2(150f, -48f), new Color(1f, 0.86f, 0.46f))
};
_detailText = CreateText("Detail", board.transform, string.Empty, 16, FontStyle.Normal, new Color(0.82f, 0.88f, 0.94f));
SetRect(_detailText.rectTransform, new Vector2(0f, -142f), new Vector2(650f, 86f));
_rewardText = CreateText("Reward", board.transform, string.Empty, 19, FontStyle.Bold, new Color(0.55f, 0.92f, 0.75f));
SetRect(_rewardText.rectTransform, new Vector2(0f, -210f), new Vector2(650f, 56f));
_primaryButton = CreateButton("PrimaryButton", board.transform, "投骰", new Vector2(0f, -254f), new Vector2(190f, 48f));
_primaryButton.onClick.AddListener(StartRoll);
}
private static void EnsureEventSystem()
{
if (FindObjectOfType<EventSystem>() != null)
{ {
return; return;
} }
GUILayout.Label(snapshot.Title, _summaryStyle); var eventSystemObject = new GameObject("EventSystem");
GUILayout.Label(snapshot.Description, _labelStyle); eventSystemObject.AddComponent<EventSystem>();
GUILayout.Space(6f); eventSystemObject.AddComponent<StandaloneInputModule>();
GUILayout.Label(snapshot.FaceLine, _labelStyle);
GUILayout.Label(snapshot.ComboLine, _labelStyle);
GUILayout.Label(snapshot.RuleLine, _labelStyle);
GUILayout.Label(snapshot.EffectLine, _labelStyle);
GUILayout.Label(snapshot.LuckyDiceLine, _labelStyle);
GUILayout.Label(snapshot.TargetModeLine, _labelStyle);
GUILayout.Space(6f);
GUILayout.Label(snapshot.Summary, _summaryStyle);
} }
private void DrawHistory() private Text CreateDiceText(string name, Transform parent, Vector2 anchoredPosition, Color faceColor)
{ {
GUILayout.Label("Recent Rolls", _summaryStyle); var background = CreatePanel(name, parent, new Color(0.2f, 0.25f, 0.33f, 1f));
_scroll = GUILayout.BeginScrollView(_scroll, GUILayout.Height(180f)); SetRect(background.rectTransform, anchoredPosition, new Vector2(116f, 84f));
foreach (var snapshot in _history)
{ var text = CreateText(name + "Text", background.transform, "?", 24, FontStyle.Bold, faceColor);
GUILayout.Label($"{snapshot.Title} | {snapshot.FaceLine} | {snapshot.Summary}", _labelStyle); Stretch(text.rectTransform);
} return text;
GUILayout.EndScrollView();
} }
private void EnsureStyles() private Button CreateButton(string name, Transform parent, string label, Vector2 anchoredPosition, Vector2 size)
{ {
if (_titleStyle != null) var image = CreatePanel(name, parent, new Color(0.92f, 0.56f, 0.16f, 1f));
{ SetRect(image.rectTransform, anchoredPosition, size);
return; var button = image.gameObject.AddComponent<Button>();
_primaryButtonText = CreateText(name + "Text", image.transform, label, 20, FontStyle.Bold, Color.white);
Stretch(_primaryButtonText.rectTransform);
return button;
} }
_titleStyle = new GUIStyle(GUI.skin.label) private Image CreatePanel(string name, Transform parent, Color color)
{ {
fontSize = 26, var gameObject = new GameObject(name, typeof(RectTransform), typeof(Image));
fontStyle = FontStyle.Bold, gameObject.transform.SetParent(parent, false);
normal = { textColor = Color.white }, var image = gameObject.GetComponent<Image>();
wordWrap = true image.color = color;
}; return image;
_labelStyle = new GUIStyle(GUI.skin.label) }
private Text CreateText(string name, Transform parent, string text, int fontSize, FontStyle fontStyle, Color color)
{ {
fontSize = 15, var gameObject = new GameObject(name, typeof(RectTransform), typeof(CanvasRenderer), typeof(Text));
normal = { textColor = new Color(0.86f, 0.9f, 0.95f) }, gameObject.transform.SetParent(parent, false);
wordWrap = true var label = gameObject.GetComponent<Text>();
}; label.font = GetUiFont();
_summaryStyle = new GUIStyle(GUI.skin.label) label.text = text;
label.fontSize = fontSize;
label.fontStyle = fontStyle;
label.color = color;
label.alignment = TextAnchor.MiddleCenter;
label.horizontalOverflow = HorizontalWrapMode.Wrap;
label.verticalOverflow = VerticalWrapMode.Overflow;
label.alignByGeometry = true;
label.raycastTarget = false;
return label;
}
private Font GetUiFont()
{ {
fontSize = 18, if (_uiFont == null)
fontStyle = FontStyle.Bold, {
normal = { textColor = new Color(1f, 0.86f, 0.35f) }, _uiFont = Resources.GetBuiltinResource<Font>(BuiltinFontResourcePath);
wordWrap = true }
};
return _uiFont;
}
private void SetStatus(string text)
{
_statusText.text = text;
}
private void ApplyDiceTexts(Text[] diceTexts, string[] values)
{
for (var i = 0; i < diceTexts.Length; i++)
{
diceTexts[i].text = i < values.Length ? FormatFace(values[i]) : "-";
}
}
private static string FormatFace(string value)
{
if (string.IsNullOrEmpty(value))
{
return "-";
}
return char.ToUpperInvariant(value[0]) + value.Substring(1);
}
private void SetLuckyDiceVisible(bool visible)
{
foreach (var text in _luckyDiceTexts)
{
text.transform.parent.gameObject.SetActive(visible);
}
}
private static void Center(RectTransform rectTransform, Vector2 size)
{
rectTransform.anchorMin = new Vector2(0.5f, 0.5f);
rectTransform.anchorMax = new Vector2(0.5f, 0.5f);
rectTransform.pivot = new Vector2(0.5f, 0.5f);
rectTransform.sizeDelta = size;
rectTransform.anchoredPosition = Vector2.zero;
}
private static void SetRect(RectTransform rectTransform, Vector2 position, Vector2 size)
{
rectTransform.anchorMin = new Vector2(0.5f, 0.5f);
rectTransform.anchorMax = new Vector2(0.5f, 0.5f);
rectTransform.pivot = new Vector2(0.5f, 0.5f);
rectTransform.sizeDelta = size;
rectTransform.anchoredPosition = position;
}
private static void Stretch(RectTransform rectTransform)
{
rectTransform.anchorMin = Vector2.zero;
rectTransform.anchorMax = Vector2.one;
rectTransform.offsetMin = Vector2.zero;
rectTransform.offsetMax = Vector2.zero;
} }
} }
} }

View File

@@ -26,6 +26,15 @@ namespace FishDice.Demo
return Run(scenario, new SystemRandomSource()); return Run(scenario, new SystemRandomSource());
} }
public static LuckyDiceDemoSnapshot RunRandom(IRandomSource random)
{
var scenario = new LuckyDiceDemoScenario(
"Random Roll",
"Uses the default FishDice runtime config and the supplied random source.");
return Run(scenario, random);
}
private static LuckyDiceDemoSnapshot Run(LuckyDiceDemoScenario scenario, IRandomSource random) private static LuckyDiceDemoSnapshot Run(LuckyDiceDemoScenario scenario, IRandomSource random)
{ {
var flow = LuckyDiceRuntimeFactory.CreateDefault(random).CreateDefaultFlow(); var flow = LuckyDiceRuntimeFactory.CreateDefault(random).CreateDefaultFlow();
@@ -43,6 +52,11 @@ namespace FishDice.Demo
var luckyDiceLine = FormatLuckyDice(result.LuckyDiceResult); var luckyDiceLine = FormatLuckyDice(result.LuckyDiceResult);
var targetModeLine = "Target Mode: " + (result.TargetMode == null ? "-" : result.TargetMode.ModeKey); var targetModeLine = "Target Mode: " + (result.TargetMode == null ? "-" : result.TargetMode.ModeKey);
var summary = FormatSummary(result); var summary = FormatSummary(result);
var normalFaces = result.RollResult.Faces.Select(face => face.Key).ToArray();
var luckyDiceSlots = result.LuckyDiceResult == null || result.LuckyDiceResult.SlotResultKeys == null
? new string[0]
: result.LuckyDiceResult.SlotResultKeys.ToArray();
var outcomeKind = ResolveOutcomeKind(result);
return new LuckyDiceDemoSnapshot( return new LuckyDiceDemoSnapshot(
scenario.Title, scenario.Title,
@@ -53,7 +67,10 @@ namespace FishDice.Demo
effectLine, effectLine,
luckyDiceLine, luckyDiceLine,
targetModeLine, targetModeLine,
summary); summary,
normalFaces,
luckyDiceSlots,
outcomeKind);
} }
private static string FormatLuckyDice(LuckyDiceSelectionResult result) private static string FormatLuckyDice(LuckyDiceSelectionResult result)
@@ -86,6 +103,26 @@ namespace FishDice.Demo
return "No configured outcome."; return "No configured outcome.";
} }
private static string ResolveOutcomeKind(RollFlowResult result)
{
if (result.TargetMode != null)
{
return "lucky_dice";
}
if (result.Effects.OfType<MultiplierEffect>().Any())
{
return "clover_bonus";
}
if (result.Effects.OfType<RewardEffect>().Any())
{
return "normal_reward";
}
return "none";
}
private static string JoinOrDash(IEnumerable<string> values) private static string JoinOrDash(IEnumerable<string> values)
{ {
var materialized = values.Where(value => !string.IsNullOrEmpty(value)).ToArray(); var materialized = values.Where(value => !string.IsNullOrEmpty(value)).ToArray();

View File

@@ -11,7 +11,10 @@ namespace FishDice.Demo
string effectLine, string effectLine,
string luckyDiceLine, string luckyDiceLine,
string targetModeLine, string targetModeLine,
string summary) string summary,
string[] normalFaces,
string[] luckyDiceSlotKeys,
string outcomeKind)
{ {
Title = title; Title = title;
Description = description; Description = description;
@@ -22,6 +25,9 @@ namespace FishDice.Demo
LuckyDiceLine = luckyDiceLine; LuckyDiceLine = luckyDiceLine;
TargetModeLine = targetModeLine; TargetModeLine = targetModeLine;
Summary = summary; Summary = summary;
NormalFaces = normalFaces ?? new string[0];
LuckyDiceSlotKeys = luckyDiceSlotKeys ?? new string[0];
OutcomeKind = string.IsNullOrEmpty(outcomeKind) ? "none" : outcomeKind;
} }
public string Title { get; } public string Title { get; }
@@ -41,5 +47,11 @@ namespace FishDice.Demo
public string TargetModeLine { get; } public string TargetModeLine { get; }
public string Summary { get; } public string Summary { get; }
public string[] NormalFaces { get; }
public string[] LuckyDiceSlotKeys { get; }
public string OutcomeKind { get; }
} }
} }

View File

@@ -32,5 +32,28 @@ namespace FishDice.Tests.LuckyDice
Assert.That(snapshot.LuckyDiceLine, Is.EqualTo("Lucky Dice: rocket x3")); Assert.That(snapshot.LuckyDiceLine, Is.EqualTo("Lucky Dice: rocket x3"));
Assert.That(snapshot.TargetModeLine, Is.EqualTo("Target Mode: slap_down_normal")); Assert.That(snapshot.TargetModeLine, Is.EqualTo("Target Mode: slap_down_normal"));
} }
[Test]
public void DemoRunner_exposes_structured_faces_for_player_ui_flow()
{
var normal = LuckyDiceDemoRunner.RunScenario(
new LuckyDiceDemoScenario("Normal Reward", "Numbers pay the base reward.", 0, 1));
var lucky = LuckyDiceDemoRunner.RunScenario(
new LuckyDiceDemoScenario("Lucky Dice", "Clover pair enters the special dice flow.", 5, 5, 0));
Assert.That(normal.NormalFaces, Is.EqualTo(new[] { "2", "3" }));
Assert.That(normal.LuckyDiceSlotKeys, Is.Empty);
Assert.That(normal.OutcomeKind, Is.EqualTo("normal_reward"));
Assert.That(lucky.NormalFaces, Is.EqualTo(new[] { "clover", "clover" }));
Assert.That(lucky.LuckyDiceSlotKeys, Is.EqualTo(new[] { "rocket", "rocket", "rocket" }));
Assert.That(lucky.OutcomeKind, Is.EqualTo("lucky_dice"));
}
[Test]
public void DemoController_uses_current_unity_builtin_font_for_player_ui_text()
{
Assert.That(LuckyDiceDemoController.BuiltinFontResourcePath, Is.EqualTo("LegacyRuntime.ttf"));
}
} }
} }