This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
name: Publish UPM package
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.SHRINKSDK_PACKAGE_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- name: Fetch tagged revision
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
ref="${{ gitea.sha }}"
|
||||||
|
test -n "$ref"
|
||||||
|
git init .
|
||||||
|
git remote add origin "https://git.crash.work/ShrinkSDK/ShrinkTutorial.git"
|
||||||
|
git fetch --depth=1 origin "$ref"
|
||||||
|
git checkout --detach FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Validate immutable release version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
tag="$(git describe --exact-match --tags HEAD)"
|
||||||
|
version="$(node -p "require('./package.json').version")"
|
||||||
|
test "$tag" = "v$version"
|
||||||
|
npm pack --dry-run
|
||||||
|
|
||||||
|
- name: Publish to ShrinkSDK registry
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
: "${NODE_AUTH_TOKEN:?SHRINKSDK_PACKAGE_TOKEN is required}"
|
||||||
|
npmrc="$HOME/.npmrc"
|
||||||
|
cleanup() { rm -f "$npmrc"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
printf '%s\n' \
|
||||||
|
'registry=https://git.crash.work/api/packages/ShrinkSDK/npm/' \
|
||||||
|
'//git.crash.work/api/packages/ShrinkSDK/npm/:_authToken=${NODE_AUTH_TOKEN}' > "$npmrc"
|
||||||
|
npm publish --registry=https://git.crash.work/api/packages/ShrinkSDK/npm/
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
name: Verify standalone Unity package
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
editmode:
|
||||||
|
runs-on: unity-2022.3.62f3
|
||||||
|
container:
|
||||||
|
image: docker.1panel.live/unityci/editor:ubuntu-2022.3.62f3-windows-mono-3
|
||||||
|
volumes:
|
||||||
|
- /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-license:/root/.local/share/unity3d/Unity
|
||||||
|
- /www/dk_project/bt-recovery/gitea/runner/seedskey-unity-entitlements:/root/.config/unity3d/Unity/licenses
|
||||||
|
steps:
|
||||||
|
- name: Fetch selected revision
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
ref="${{ gitea.sha }}"
|
||||||
|
git init .
|
||||||
|
git remote add origin "https://git.crash.work/ShrinkSDK/ShrinkTutorial.git"
|
||||||
|
git fetch --depth=1 origin "$ref"
|
||||||
|
git checkout --detach FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Run package EditMode tests
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
unity_bin="$(command -v unity-editor || command -v unity || command -v Unity || true)"
|
||||||
|
test -n "$unity_bin"
|
||||||
|
"$unity_bin" \
|
||||||
|
-batchmode \
|
||||||
|
-nographics \
|
||||||
|
-quit \
|
||||||
|
-projectPath "$PWD/Development~/UnityProject" \
|
||||||
|
-runTests \
|
||||||
|
-testPlatform EditMode \
|
||||||
|
-testResults "$PWD/TestResults/editmode.xml" \
|
||||||
|
-logFile "$PWD/TestResults/unity.log"
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
/Development~/UnityProject/[Ll]ibrary/
|
||||||
|
/Development~/UnityProject/[Tt]emp/
|
||||||
|
/Development~/UnityProject/[Oo]bj/
|
||||||
|
/Development~/UnityProject/[Ll]ogs/
|
||||||
|
/Development~/UnityProject/[Uu]ser[Ss]ettings/
|
||||||
|
/Development~/UnityProject/TestResults/
|
||||||
|
/Tools~/**/[Bb]in/
|
||||||
|
/Tools~/**/[Oo]bj/
|
||||||
|
*.user
|
||||||
|
*.DotSettings.user
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.git/
|
||||||
|
.gitea/
|
||||||
|
Development~/
|
||||||
|
Tools~/
|
||||||
|
*.csproj
|
||||||
|
*.sln
|
||||||
|
*.user
|
||||||
|
*.DotSettings.user
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- 修复 `ClickTarget` 对 UI 目标过早在按下阶段完成的问题,改为等待指针释放并确认 EventSystem 射线真实命中目标后再过步。
|
||||||
|
- 修复隐藏中的 `Path` 目标仍被教程当作可用目标的问题;现在 inactive 目标会继续等待,不会把提示框提前挂到尚未显示的控件上。
|
||||||
|
- 修复 `ShrinkTutorialSample` 第 2 步运行时奖励按钮在样例场景中的显示与布局问题,并在奖励生成后禁用重复点击的 `SpawnRewardButton`。
|
||||||
|
- 修复教程结束或 `maskShape=None` 步骤下遮罩仍继续绘制/拦截的问题;现在无高亮时蒙版会完全隐藏。
|
||||||
|
- 修复 `ShrinkTutorialSample` 在前一次演示完成后下次 Play 不再显示教程的问题;样例现在会在启动时清掉自身教程进度再重新播放。
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
- 初始版本。
|
||||||
|
- 提供 `ShrinkTutorialManager`、教程数据资产、锚点注册、触发器、默认 PlayerPrefs 持久化。
|
||||||
|
- 提供独立高层级 Canvas、遮罩挖洞、提示对话框与跳过按钮。
|
||||||
|
- 提供基础编辑器窗口与 `ShrinkSDK/引导/*` 菜单入口。
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f1716ffaa3c64e64fb86ac1eccaaad5b
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[Ll]ibrary/
|
||||||
|
[Tt]emp/
|
||||||
|
[Oo]bj/
|
||||||
|
[Ll]ogs/
|
||||||
|
[Uu]ser[Ss]ettings/
|
||||||
|
TestResults/
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"scopedRegistries": [
|
||||||
|
{
|
||||||
|
"name": "ShrinkSDK",
|
||||||
|
"url": "https://git.crash.work/api/packages/ShrinkSDK/npm/",
|
||||||
|
"scopes": [
|
||||||
|
"com.cneicy"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"com.unity.test-framework": "1.1.33",
|
||||||
|
"com.cneicy.shrink-tutorial": "file:../../.."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
m_EditorVersion: 2022.3.62f3
|
||||||
|
m_EditorVersionWithRevision: 2022.3.62f3 (96770f904ca7)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a15dc5a1b31422141bf850b2b942ff76
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: dea1943190171294d8592757a6d3a12a
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
#if UNITY_EDITOR
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using TMPro;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEditor.SceneManagement;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.SceneManagement;
|
||||||
|
using UnityEngine.UI;
|
||||||
|
|
||||||
|
namespace ShrinkTutorial.Editor
|
||||||
|
{
|
||||||
|
public static class ShrinkTutorialSampleSceneBuilder
|
||||||
|
{
|
||||||
|
private const string ScenePath = "Assets/Scenes/ShrinkTutorialSample.unity";
|
||||||
|
private const string SettingsFolder = "Assets/Modules/ShrinkTutorial/Samples/Resources";
|
||||||
|
private const string SettingsPath = SettingsFolder + "/ShrinkTutorialSettings.asset";
|
||||||
|
private const string DatabasePath = "Assets/Modules/ShrinkTutorial/Samples/ShrinkTutorialSampleDatabase.asset";
|
||||||
|
private const string TutorialPath = "Assets/Modules/ShrinkTutorial/Samples/ShrinkTutorialSampleTutorial.asset";
|
||||||
|
|
||||||
|
[MenuItem("ShrinkSDK/引导/创建示例场景")]
|
||||||
|
public static void BuildSampleScene()
|
||||||
|
{
|
||||||
|
EnsureFolder("Assets/Modules/ShrinkTutorial");
|
||||||
|
EnsureFolder("Assets/Modules/ShrinkTutorial/Samples");
|
||||||
|
EnsureFolder(SettingsFolder);
|
||||||
|
EnsureFolder("Assets/Scenes");
|
||||||
|
|
||||||
|
var settings = EnsureSettings();
|
||||||
|
var database = EnsureDatabase();
|
||||||
|
var tutorial = EnsureTutorial();
|
||||||
|
|
||||||
|
database.hideFlags = HideFlags.None;
|
||||||
|
settings.database = database;
|
||||||
|
EditorUtility.SetDirty(settings);
|
||||||
|
|
||||||
|
UpdateTutorialData(tutorial);
|
||||||
|
SetDatabaseContent(database, tutorial);
|
||||||
|
|
||||||
|
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
|
||||||
|
scene.name = "ShrinkTutorialSample";
|
||||||
|
|
||||||
|
CreateCamera();
|
||||||
|
var canvas = CreateCanvas();
|
||||||
|
var root = new GameObject("Tutorial Sample Root");
|
||||||
|
var controller = root.AddComponent<ShrinkTutorialSampleSceneController>();
|
||||||
|
|
||||||
|
var openButton = CreateButton(canvas.transform, "OpenInventoryButton", "Open Inventory", new Vector2(0f, 110f), new Vector2(240f, 64f));
|
||||||
|
var statusText = CreateText(canvas.transform, "StatusText", "Waiting for tutorial start.", 26f,
|
||||||
|
new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0f, 42f), new Vector2(900f, 48f));
|
||||||
|
statusText.alignment = TextAlignmentOptions.Center;
|
||||||
|
statusText.color = new Color(0.92f, 0.92f, 0.92f, 1f);
|
||||||
|
|
||||||
|
CreateText(canvas.transform, "TitleText", "ShrinkTutorial Sample Scene", 42f,
|
||||||
|
new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -70f), new Vector2(900f, 60f))
|
||||||
|
.alignment = TextAlignmentOptions.Center;
|
||||||
|
|
||||||
|
CreateText(canvas.transform, "DescText", "This scene demonstrates static targets, dynamic anchors, and custom-event completion.", 24f,
|
||||||
|
new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -120f), new Vector2(1100f, 50f))
|
||||||
|
.alignment = TextAlignmentOptions.Center;
|
||||||
|
|
||||||
|
var inventoryPanel = CreatePanel(canvas.transform, "InventoryPanel", new Vector2(0f, -60f), new Vector2(720f, 360f),
|
||||||
|
new Color(0.12f, 0.16f, 0.22f, 0.96f));
|
||||||
|
inventoryPanel.gameObject.SetActive(false);
|
||||||
|
|
||||||
|
CreateText(inventoryPanel, "InventoryTitle", "Inventory Panel", 34f,
|
||||||
|
new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -34f), new Vector2(300f, 48f))
|
||||||
|
.alignment = TextAlignmentOptions.Center;
|
||||||
|
|
||||||
|
CreateText(inventoryPanel, "InventoryHint", "Create the runtime reward first, then click the spawned item.", 22f,
|
||||||
|
new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0f, -74f), new Vector2(520f, 40f))
|
||||||
|
.alignment = TextAlignmentOptions.Center;
|
||||||
|
|
||||||
|
var spawnButton = CreateButton(inventoryPanel, "SpawnRewardButton", "Spawn Reward Item", new Vector2(0f, 88f), new Vector2(250f, 58f));
|
||||||
|
|
||||||
|
var rewardContainer = new GameObject("RewardContainer", typeof(RectTransform), typeof(VerticalLayoutGroup), typeof(ContentSizeFitter));
|
||||||
|
var rewardContainerRect = rewardContainer.GetComponent<RectTransform>();
|
||||||
|
rewardContainerRect.SetParent(inventoryPanel, false);
|
||||||
|
rewardContainerRect.anchorMin = new Vector2(0.5f, 0f);
|
||||||
|
rewardContainerRect.anchorMax = new Vector2(0.5f, 0f);
|
||||||
|
rewardContainerRect.pivot = new Vector2(0.5f, 0f);
|
||||||
|
rewardContainerRect.anchoredPosition = new Vector2(0f, 24f);
|
||||||
|
rewardContainerRect.sizeDelta = new Vector2(480f, 120f);
|
||||||
|
|
||||||
|
var layoutGroup = rewardContainer.GetComponent<VerticalLayoutGroup>();
|
||||||
|
layoutGroup.childAlignment = TextAnchor.UpperCenter;
|
||||||
|
layoutGroup.childControlWidth = true;
|
||||||
|
layoutGroup.childControlHeight = true;
|
||||||
|
layoutGroup.childForceExpandHeight = false;
|
||||||
|
layoutGroup.childForceExpandWidth = true;
|
||||||
|
layoutGroup.spacing = 12f;
|
||||||
|
|
||||||
|
var fitter = rewardContainer.GetComponent<ContentSizeFitter>();
|
||||||
|
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
|
||||||
|
|
||||||
|
AssignControllerReferences(controller, openButton, spawnButton, inventoryPanel.gameObject, rewardContainerRect, statusText);
|
||||||
|
|
||||||
|
EditorSceneManager.SaveScene(scene, ScenePath);
|
||||||
|
AssetDatabase.SaveAssets();
|
||||||
|
AssetDatabase.Refresh();
|
||||||
|
EditorSceneManager.OpenScene(ScenePath);
|
||||||
|
|
||||||
|
Selection.activeObject = AssetDatabase.LoadAssetAtPath<SceneAsset>(ScenePath);
|
||||||
|
Debug.Log("[ShrinkTutorial] Sample scene created: Assets/Scenes/ShrinkTutorialSample.unity");
|
||||||
|
}
|
||||||
|
|
||||||
|
[MenuItem("ShrinkSDK/Tutorial/Create Sample Scene")]
|
||||||
|
public static void BuildSampleSceneAlias()
|
||||||
|
{
|
||||||
|
BuildSampleScene();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ShrinkTutorialSettings EnsureSettings()
|
||||||
|
{
|
||||||
|
var settings = AssetDatabase.LoadAssetAtPath<ShrinkTutorialSettings>(SettingsPath);
|
||||||
|
if (settings)
|
||||||
|
return settings;
|
||||||
|
|
||||||
|
settings = ScriptableObject.CreateInstance<ShrinkTutorialSettings>();
|
||||||
|
settings.autoCreateManager = true;
|
||||||
|
settings.dontDestroyOnLoad = true;
|
||||||
|
settings.showSkipButton = true;
|
||||||
|
AssetDatabase.CreateAsset(settings, SettingsPath);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ShrinkTutorialDatabase EnsureDatabase()
|
||||||
|
{
|
||||||
|
var database = AssetDatabase.LoadAssetAtPath<ShrinkTutorialDatabase>(DatabasePath);
|
||||||
|
if (database)
|
||||||
|
return database;
|
||||||
|
|
||||||
|
database = ScriptableObject.CreateInstance<ShrinkTutorialDatabase>();
|
||||||
|
AssetDatabase.CreateAsset(database, DatabasePath);
|
||||||
|
return database;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ShrinkTutorialData EnsureTutorial()
|
||||||
|
{
|
||||||
|
var tutorial = AssetDatabase.LoadAssetAtPath<ShrinkTutorialData>(TutorialPath);
|
||||||
|
if (tutorial)
|
||||||
|
return tutorial;
|
||||||
|
|
||||||
|
tutorial = ScriptableObject.CreateInstance<ShrinkTutorialData>();
|
||||||
|
AssetDatabase.CreateAsset(tutorial, TutorialPath);
|
||||||
|
return tutorial;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void UpdateTutorialData(ShrinkTutorialData tutorial)
|
||||||
|
{
|
||||||
|
tutorial.tutorialId = ShrinkTutorialSampleSceneController.TutorialId;
|
||||||
|
tutorial.priority = 100;
|
||||||
|
tutorial.prerequisiteIds = new string[0];
|
||||||
|
tutorial.steps = new List<ShrinkTutorialStep>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
targetMode = ShrinkTutorialTargetMode.Path,
|
||||||
|
targetPath = "Tutorial Sample Canvas/OpenInventoryButton",
|
||||||
|
completeCondition = ShrinkTutorialCompleteCondition.ClickTarget,
|
||||||
|
fallbackText = "Click here to open the inventory panel.",
|
||||||
|
dialogAnchor = ShrinkTutorialDialogAnchor.Bottom
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
targetMode = ShrinkTutorialTargetMode.Path,
|
||||||
|
targetPath = "Tutorial Sample Canvas/InventoryPanel/SpawnRewardButton",
|
||||||
|
completeCondition = ShrinkTutorialCompleteCondition.ClickTarget,
|
||||||
|
fallbackText = "The inventory is open. Click 'Spawn Reward Item' to demonstrate dynamic-anchor waiting.",
|
||||||
|
dialogAnchor = ShrinkTutorialDialogAnchor.Top
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
targetMode = ShrinkTutorialTargetMode.AnchorId,
|
||||||
|
anchorId = ShrinkTutorialSampleSceneController.RewardAnchorId,
|
||||||
|
waitForTarget = true,
|
||||||
|
waitTimeout = 10f,
|
||||||
|
completeCondition = ShrinkTutorialCompleteCondition.CustomEvent,
|
||||||
|
customEventName = ShrinkTutorialSampleSceneController.RewardClaimEventName,
|
||||||
|
fallbackText = "This button was created at runtime. Click it and the step will complete via a custom event.",
|
||||||
|
dialogAnchor = ShrinkTutorialDialogAnchor.Top
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
targetMode = ShrinkTutorialTargetMode.None,
|
||||||
|
completeCondition = ShrinkTutorialCompleteCondition.AnyClick,
|
||||||
|
fallbackText = "Demo finished. You completed the static-target, dynamic-anchor, and custom-event flow. Click anywhere to close.",
|
||||||
|
dialogAnchor = ShrinkTutorialDialogAnchor.Auto,
|
||||||
|
showArrow = false,
|
||||||
|
maskShape = ShrinkTutorialMaskShape.None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorUtility.SetDirty(tutorial);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetDatabaseContent(ShrinkTutorialDatabase database, ShrinkTutorialData tutorial)
|
||||||
|
{
|
||||||
|
var serializedObject = new SerializedObject(database);
|
||||||
|
var tutorialsProperty = serializedObject.FindProperty("tutorials");
|
||||||
|
tutorialsProperty.arraySize = 1;
|
||||||
|
tutorialsProperty.GetArrayElementAtIndex(0).objectReferenceValue = tutorial;
|
||||||
|
serializedObject.ApplyModifiedPropertiesWithoutUndo();
|
||||||
|
EditorUtility.SetDirty(database);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssignControllerReferences(
|
||||||
|
ShrinkTutorialSampleSceneController controller,
|
||||||
|
Button openButton,
|
||||||
|
Button spawnButton,
|
||||||
|
GameObject inventoryPanel,
|
||||||
|
RectTransform rewardContainer,
|
||||||
|
TextMeshProUGUI statusText)
|
||||||
|
{
|
||||||
|
var serializedObject = new SerializedObject(controller);
|
||||||
|
serializedObject.FindProperty("openInventoryButton").objectReferenceValue = openButton;
|
||||||
|
serializedObject.FindProperty("spawnRewardButton").objectReferenceValue = spawnButton;
|
||||||
|
serializedObject.FindProperty("inventoryPanel").objectReferenceValue = inventoryPanel;
|
||||||
|
serializedObject.FindProperty("rewardContainer").objectReferenceValue = rewardContainer;
|
||||||
|
serializedObject.FindProperty("statusText").objectReferenceValue = statusText;
|
||||||
|
serializedObject.ApplyModifiedPropertiesWithoutUndo();
|
||||||
|
EditorUtility.SetDirty(controller);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CreateCamera()
|
||||||
|
{
|
||||||
|
var cameraObject = new GameObject("Main Camera", typeof(Camera));
|
||||||
|
cameraObject.tag = "MainCamera";
|
||||||
|
var camera = cameraObject.GetComponent<Camera>();
|
||||||
|
camera.clearFlags = CameraClearFlags.SolidColor;
|
||||||
|
camera.backgroundColor = new Color(0.06f, 0.08f, 0.12f, 1f);
|
||||||
|
camera.orthographic = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Canvas CreateCanvas()
|
||||||
|
{
|
||||||
|
var canvasObject = new GameObject("Tutorial Sample Canvas", typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster));
|
||||||
|
var canvas = canvasObject.GetComponent<Canvas>();
|
||||||
|
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||||
|
|
||||||
|
var scaler = canvasObject.GetComponent<CanvasScaler>();
|
||||||
|
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||||
|
scaler.referenceResolution = new Vector2(1920f, 1080f);
|
||||||
|
scaler.matchWidthOrHeight = 0.5f;
|
||||||
|
|
||||||
|
var background = CreatePanel(canvasObject.transform, "Background", Vector2.zero, new Vector2(0f, 0f), new Color(0.06f, 0.08f, 0.12f, 1f));
|
||||||
|
Stretch(background);
|
||||||
|
background.SetAsFirstSibling();
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RectTransform CreatePanel(Transform parent, string name, Vector2 anchoredPosition, Vector2 size, Color color)
|
||||||
|
{
|
||||||
|
var panelObject = new GameObject(name, typeof(RectTransform), typeof(Image));
|
||||||
|
var rectTransform = panelObject.GetComponent<RectTransform>();
|
||||||
|
rectTransform.SetParent(parent, false);
|
||||||
|
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.anchoredPosition = anchoredPosition;
|
||||||
|
rectTransform.sizeDelta = size;
|
||||||
|
|
||||||
|
var image = panelObject.GetComponent<Image>();
|
||||||
|
image.color = color;
|
||||||
|
return rectTransform;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Button CreateButton(Transform parent, string name, string label, Vector2 anchoredPosition, Vector2 size)
|
||||||
|
{
|
||||||
|
var buttonObject = new GameObject(name, typeof(RectTransform), typeof(Image), typeof(Button));
|
||||||
|
var rectTransform = buttonObject.GetComponent<RectTransform>();
|
||||||
|
rectTransform.SetParent(parent, false);
|
||||||
|
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.anchoredPosition = anchoredPosition;
|
||||||
|
rectTransform.sizeDelta = size;
|
||||||
|
|
||||||
|
var image = buttonObject.GetComponent<Image>();
|
||||||
|
image.color = new Color(0.2f, 0.42f, 0.72f, 1f);
|
||||||
|
|
||||||
|
var button = buttonObject.GetComponent<Button>();
|
||||||
|
button.targetGraphic = image;
|
||||||
|
|
||||||
|
var text = CreateText(buttonObject.transform, "Label", label, 28f,
|
||||||
|
Vector2.zero, Vector2.one, new Vector2(0.5f, 0.5f), Vector2.zero, Vector2.zero);
|
||||||
|
text.alignment = TextAlignmentOptions.Center;
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TextMeshProUGUI CreateText(
|
||||||
|
Transform parent,
|
||||||
|
string name,
|
||||||
|
string text,
|
||||||
|
float fontSize,
|
||||||
|
Vector2 anchorMin,
|
||||||
|
Vector2 anchorMax,
|
||||||
|
Vector2 pivot,
|
||||||
|
Vector2 anchoredPosition,
|
||||||
|
Vector2 sizeDelta)
|
||||||
|
{
|
||||||
|
var textObject = new GameObject(name, typeof(RectTransform), typeof(TextMeshProUGUI));
|
||||||
|
var rectTransform = textObject.GetComponent<RectTransform>();
|
||||||
|
rectTransform.SetParent(parent, false);
|
||||||
|
rectTransform.anchorMin = anchorMin;
|
||||||
|
rectTransform.anchorMax = anchorMax;
|
||||||
|
rectTransform.pivot = pivot;
|
||||||
|
rectTransform.anchoredPosition = anchoredPosition;
|
||||||
|
rectTransform.sizeDelta = sizeDelta;
|
||||||
|
|
||||||
|
var tmp = textObject.GetComponent<TextMeshProUGUI>();
|
||||||
|
tmp.text = text;
|
||||||
|
tmp.fontSize = fontSize;
|
||||||
|
tmp.color = Color.white;
|
||||||
|
tmp.enableWordWrapping = true;
|
||||||
|
tmp.font = TMP_Settings.defaultFontAsset;
|
||||||
|
return tmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Stretch(RectTransform rectTransform)
|
||||||
|
{
|
||||||
|
rectTransform.anchorMin = Vector2.zero;
|
||||||
|
rectTransform.anchorMax = Vector2.one;
|
||||||
|
rectTransform.offsetMin = Vector2.zero;
|
||||||
|
rectTransform.offsetMax = Vector2.zero;
|
||||||
|
rectTransform.pivot = new Vector2(0.5f, 0.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureFolder(string path)
|
||||||
|
{
|
||||||
|
if (AssetDatabase.IsValidFolder(path))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var parent = Path.GetDirectoryName(path)?.Replace("\\", "/");
|
||||||
|
var name = Path.GetFileName(path);
|
||||||
|
if (!string.IsNullOrEmpty(parent) && !AssetDatabase.IsValidFolder(parent))
|
||||||
|
EnsureFolder(parent);
|
||||||
|
AssetDatabase.CreateFolder(parent, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 761e37eb274476b4aaf7ea7ad1baf078
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "ShrinkTutorial.Editor",
|
||||||
|
"rootNamespace": "ShrinkTutorial.Editor",
|
||||||
|
"references": [
|
||||||
|
"ShrinkTutorial.Runtime",
|
||||||
|
"Unity.TextMeshPro"
|
||||||
|
],
|
||||||
|
"includePlatforms": [
|
||||||
|
"Editor"
|
||||||
|
],
|
||||||
|
"excludePlatforms": [],
|
||||||
|
"allowUnsafeCode": false,
|
||||||
|
"overrideReferences": false,
|
||||||
|
"autoReferenced": true,
|
||||||
|
"defineConstraints": [],
|
||||||
|
"versionDefines": [],
|
||||||
|
"noEngineReferences": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: cc9e4ac50c4134a49baf8d86e43979c9
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
#if UNITY_EDITOR
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkTutorial.Editor
|
||||||
|
{
|
||||||
|
public class ShrinkTutorialEditorWindow : EditorWindow
|
||||||
|
{
|
||||||
|
private ShrinkTutorialDatabase _database;
|
||||||
|
private Vector2 _tutorialListScroll;
|
||||||
|
private Vector2 _detailScroll;
|
||||||
|
private ShrinkTutorialData _selectedTutorial;
|
||||||
|
private UnityEditor.Editor _selectedTutorialEditor;
|
||||||
|
private List<string> _issues = new();
|
||||||
|
|
||||||
|
[MenuItem("ShrinkSDK/引导/教程编辑器")]
|
||||||
|
public static void ShowWindow()
|
||||||
|
{
|
||||||
|
var window = GetWindow<ShrinkTutorialEditorWindow>("引导系统");
|
||||||
|
window.minSize = new Vector2(960f, 580f);
|
||||||
|
window.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
[MenuItem("ShrinkSDK/Tutorial/Editor")]
|
||||||
|
public static void ShowWindowAlias()
|
||||||
|
{
|
||||||
|
ShowWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
[MenuItem("ShrinkSDK/引导/重置教程进度")]
|
||||||
|
public static void ResetProgress()
|
||||||
|
{
|
||||||
|
var settings = ShrinkTutorialSettings.Instance;
|
||||||
|
var key = settings ? settings.playerPrefsStorageKey : "ShrinkTutorial.Progress";
|
||||||
|
new PlayerPrefsShrinkTutorialStorage(key).Reset();
|
||||||
|
Debug.Log("[ShrinkTutorial] 已重置教程进度。");
|
||||||
|
}
|
||||||
|
|
||||||
|
[MenuItem("ShrinkSDK/Tutorial/Reset Progress")]
|
||||||
|
public static void ResetProgressAlias()
|
||||||
|
{
|
||||||
|
ResetProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEnable()
|
||||||
|
{
|
||||||
|
TryAssignDefaultDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGUI()
|
||||||
|
{
|
||||||
|
DrawToolbar();
|
||||||
|
|
||||||
|
EditorGUILayout.BeginHorizontal();
|
||||||
|
DrawTutorialList();
|
||||||
|
DrawDetailPanel();
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
|
||||||
|
DrawIssues();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawToolbar()
|
||||||
|
{
|
||||||
|
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
|
||||||
|
_database = (ShrinkTutorialDatabase)EditorGUILayout.ObjectField(_database, typeof(ShrinkTutorialDatabase), false, GUILayout.Width(320f));
|
||||||
|
|
||||||
|
if (GUILayout.Button("从设置读取", EditorStyles.toolbarButton, GUILayout.Width(90f)))
|
||||||
|
TryAssignDefaultDatabase();
|
||||||
|
|
||||||
|
if (GUILayout.Button("验证数据库", EditorStyles.toolbarButton, GUILayout.Width(90f)))
|
||||||
|
ValidateDatabase();
|
||||||
|
|
||||||
|
GUILayout.FlexibleSpace();
|
||||||
|
EditorGUILayout.EndHorizontal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawTutorialList()
|
||||||
|
{
|
||||||
|
EditorGUILayout.BeginVertical(GUILayout.Width(280f));
|
||||||
|
EditorGUILayout.LabelField("教程列表", EditorStyles.boldLabel);
|
||||||
|
|
||||||
|
if (!_database)
|
||||||
|
{
|
||||||
|
EditorGUILayout.HelpBox("请先指定 ShrinkTutorialDatabase。", MessageType.Info);
|
||||||
|
EditorGUILayout.EndVertical();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_tutorialListScroll = EditorGUILayout.BeginScrollView(_tutorialListScroll);
|
||||||
|
foreach (var tutorial in _database.Tutorials)
|
||||||
|
{
|
||||||
|
using (new EditorGUI.DisabledScope(!tutorial))
|
||||||
|
{
|
||||||
|
var label = tutorial ? $"{tutorial.tutorialId} ({tutorial.steps.Count} 步)" : "<空引用>";
|
||||||
|
if (GUILayout.Toggle(_selectedTutorial == tutorial, label, "Button"))
|
||||||
|
SelectTutorial(tutorial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditorGUILayout.EndScrollView();
|
||||||
|
EditorGUILayout.EndVertical();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawDetailPanel()
|
||||||
|
{
|
||||||
|
EditorGUILayout.BeginVertical();
|
||||||
|
|
||||||
|
if (!_selectedTutorial)
|
||||||
|
{
|
||||||
|
EditorGUILayout.HelpBox("左侧选择一个教程后,这里会显示完整配置。", MessageType.Info);
|
||||||
|
EditorGUILayout.EndVertical();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorGUILayout.LabelField(_selectedTutorial.tutorialId, EditorStyles.boldLabel);
|
||||||
|
_detailScroll = EditorGUILayout.BeginScrollView(_detailScroll);
|
||||||
|
|
||||||
|
if (_selectedTutorialEditor == null)
|
||||||
|
_selectedTutorialEditor = UnityEditor.Editor.CreateEditor(_selectedTutorial);
|
||||||
|
|
||||||
|
_selectedTutorialEditor.OnInspectorGUI();
|
||||||
|
|
||||||
|
EditorGUILayout.EndScrollView();
|
||||||
|
EditorGUILayout.EndVertical();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawIssues()
|
||||||
|
{
|
||||||
|
if (_issues.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
EditorGUILayout.Space();
|
||||||
|
EditorGUILayout.LabelField("校验结果", EditorStyles.boldLabel);
|
||||||
|
foreach (var issue in _issues)
|
||||||
|
EditorGUILayout.HelpBox(issue, MessageType.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryAssignDefaultDatabase()
|
||||||
|
{
|
||||||
|
var settings = ShrinkTutorialSettings.Instance;
|
||||||
|
if (settings)
|
||||||
|
_database = settings.database;
|
||||||
|
|
||||||
|
if (!_database)
|
||||||
|
{
|
||||||
|
var guids = AssetDatabase.FindAssets("t:ShrinkTutorialDatabase");
|
||||||
|
if (guids.Length > 0)
|
||||||
|
{
|
||||||
|
var path = AssetDatabase.GUIDToAssetPath(guids[0]);
|
||||||
|
_database = AssetDatabase.LoadAssetAtPath<ShrinkTutorialDatabase>(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateDatabase()
|
||||||
|
{
|
||||||
|
_issues.Clear();
|
||||||
|
|
||||||
|
if (!_database)
|
||||||
|
{
|
||||||
|
_issues.Add("未指定 ShrinkTutorialDatabase。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_issues.AddRange(_database.Validate());
|
||||||
|
if (_issues.Count == 0)
|
||||||
|
_issues.Add("未发现配置问题。");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectTutorial(ShrinkTutorialData tutorial)
|
||||||
|
{
|
||||||
|
if (_selectedTutorial == tutorial)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_selectedTutorial = tutorial;
|
||||||
|
DestroyImmediate(_selectedTutorialEditor);
|
||||||
|
_selectedTutorialEditor = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1aace4135844b604f8b73b1dba817e27
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# ShrinkTutorial
|
||||||
|
|
||||||
|
一个面向 Unity 2022.3+ 的互动式引导系统,重点解决这几件事:
|
||||||
|
|
||||||
|
- 通过真实点击完成步骤,而不是纯文案翻页。
|
||||||
|
- 支持静态路径目标和动态锚点目标。
|
||||||
|
- 遮罩只屏蔽无关区域,目标区域可继续点击。
|
||||||
|
- 教程内容由 `ScriptableObject` 驱动,运行时由 `ShrinkTutorialManager` 调度。
|
||||||
|
|
||||||
|
## 当前能力
|
||||||
|
|
||||||
|
| 能力 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 数据驱动教程 | `ShrinkTutorialData + ShrinkTutorialStep + ShrinkTutorialDatabase` |
|
||||||
|
| 运行时调度 | `ShrinkTutorialManager` 负责排队、前置检查、逐步执行、完成/跳过持久化 |
|
||||||
|
| 目标定位 | 支持 `Hierarchy Path` 与 `AnchorId` |
|
||||||
|
| 完成条件 | `ClickTarget / AnyClick / CustomEvent / Auto` |
|
||||||
|
| 动态目标等待 | `waitForTarget + waitTimeout` |
|
||||||
|
| 默认 UI | 独立 Overlay Canvas、遮罩挖洞、提示框、箭头、跳过按钮 |
|
||||||
|
| 编辑器入口 | `ShrinkSDK/引导/教程编辑器`、`ShrinkSDK/引导/重置教程进度` |
|
||||||
|
|
||||||
|
## 暂未覆盖
|
||||||
|
|
||||||
|
- `DragToTarget` 目前按自定义事件完成处理,尚未内建拖拽命中判定。
|
||||||
|
- 圆形高亮当前以圆形点击判定为主,视觉挖洞仍按外接矩形处理。
|
||||||
|
- 本地化只提供接口 `IShrinkTutorialLocalizationProvider`,不内置具体表系统。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
Assets/Modules/ShrinkTutorial/
|
||||||
|
├── Runtime/
|
||||||
|
│ ├── Core/
|
||||||
|
│ ├── Storage/
|
||||||
|
│ ├── Trigger/
|
||||||
|
│ └── UI/
|
||||||
|
└── Editor/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速接入
|
||||||
|
|
||||||
|
### 1. 创建配置资产
|
||||||
|
|
||||||
|
通过菜单创建:
|
||||||
|
|
||||||
|
- `Assets -> Create -> ShrinkTutorial -> Settings`
|
||||||
|
- `Assets -> Create -> ShrinkTutorial -> Tutorial Database`
|
||||||
|
- `Assets -> Create -> ShrinkTutorial -> Tutorial Data`
|
||||||
|
|
||||||
|
然后把教程资产挂到 `ShrinkTutorialDatabase`,再把数据库挂到 `ShrinkTutorialSettings.database`。
|
||||||
|
|
||||||
|
### 2. 配置步骤
|
||||||
|
|
||||||
|
常见配置建议:
|
||||||
|
|
||||||
|
- `targetMode = Path`:用于静态 UI / 场景对象。
|
||||||
|
- `targetMode = AnchorId`:用于运行时实例化对象。
|
||||||
|
- `completeCondition = ClickTarget`:最常用的真实点击引导。
|
||||||
|
对 UI 目标会在指针释放且 EventSystem 射线真实命中目标后再过步,避免遮罩或浮层导致“业务没执行但教程已前进”。
|
||||||
|
- `completeCondition = CustomEvent`:业务代码在成功后调用 `CompleteStep(...)`。
|
||||||
|
|
||||||
|
### 3. 触发教程
|
||||||
|
|
||||||
|
可以直接调用:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
ShrinkTutorialManager.Instance.StartTutorial("tut.inventory.open");
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以给入口物体挂 `ShrinkTutorialTrigger`,在 `OnEnable` 首次可见时自动触发。
|
||||||
|
|
||||||
|
### 4. 动态对象锚点
|
||||||
|
|
||||||
|
给动态实例化对象挂 `ShrinkTutorialAnchor`,填写 `anchorId`,步骤里选择 `AnchorId` 模式即可。
|
||||||
|
|
||||||
|
## 外部接口
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
ShrinkTutorialManager.Instance.StartTutorial("tutorial.id");
|
||||||
|
ShrinkTutorialManager.Instance.CompleteStep("event_name");
|
||||||
|
ShrinkTutorialManager.Instance.SkipCurrent();
|
||||||
|
ShrinkTutorialManager.Instance.PauseCurrent();
|
||||||
|
ShrinkTutorialManager.Instance.ResumeCurrent();
|
||||||
|
```
|
||||||
|
|
||||||
|
状态查询:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var done = ShrinkTutorialManager.Instance.HasCompleted("tutorial.id");
|
||||||
|
var skipped = ShrinkTutorialManager.Instance.HasSkipped("tutorial.id");
|
||||||
|
var running = ShrinkTutorialManager.Instance.IsRunning;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地化接入
|
||||||
|
|
||||||
|
如果项目已有本地化系统,实现下面接口后注入即可:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public sealed class MyTutorialLocalizer : IShrinkTutorialLocalizationProvider
|
||||||
|
{
|
||||||
|
public bool TryResolve(string key, out string text)
|
||||||
|
{
|
||||||
|
text = MyLocalization.Get(key);
|
||||||
|
return !string.IsNullOrWhiteSpace(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ShrinkTutorialLocalization.Provider = new MyTutorialLocalizer();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 调试建议
|
||||||
|
|
||||||
|
- 先确认 `ShrinkTutorialSettings.database` 已正确赋值。
|
||||||
|
- 如果目标是动态对象,优先检查 `ShrinkTutorialAnchorRegistry` 是否已注册该 `anchorId`。
|
||||||
|
- 如果步骤目标当前还没显示出来,不要把它视为异常;`Path` 目标在 `activeInHierarchy == false` 时现在会继续等待,而不是提前显示到隐藏控件上。
|
||||||
|
- 如果步骤走 `CustomEvent`,确认业务代码在真正完成后再调用 `CompleteStep(...)`。
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 815b39283eb44ac42b67e2adf554e139
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 62f536381eee1d2448688fe2cf79dd3c
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: af36311d5483742418cfb512be14c6db
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkTutorial
|
||||||
|
{
|
||||||
|
[CreateAssetMenu(fileName = "ShrinkTutorialData", menuName = "ShrinkTutorial/Tutorial Data")]
|
||||||
|
public class ShrinkTutorialData : ScriptableObject
|
||||||
|
{
|
||||||
|
public string tutorialId = string.Empty;
|
||||||
|
public int priority;
|
||||||
|
public string[] prerequisiteIds = new string[0];
|
||||||
|
public List<ShrinkTutorialStep> steps = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 356e79bb4dd3ef04099347bbe1dbca2b
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkTutorial
|
||||||
|
{
|
||||||
|
[CreateAssetMenu(fileName = "ShrinkTutorialDatabase", menuName = "ShrinkTutorial/Tutorial Database")]
|
||||||
|
public class ShrinkTutorialDatabase : ScriptableObject
|
||||||
|
{
|
||||||
|
[SerializeField] private List<ShrinkTutorialData> tutorials = new();
|
||||||
|
|
||||||
|
private Dictionary<string, ShrinkTutorialData> _cache;
|
||||||
|
|
||||||
|
public IReadOnlyList<ShrinkTutorialData> Tutorials => tutorials;
|
||||||
|
|
||||||
|
public bool TryGetTutorial(string tutorialId, out ShrinkTutorialData tutorial)
|
||||||
|
{
|
||||||
|
EnsureCache();
|
||||||
|
return _cache.TryGetValue(tutorialId, out tutorial);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string> Validate()
|
||||||
|
{
|
||||||
|
var issues = new List<string>();
|
||||||
|
var ids = new HashSet<string>();
|
||||||
|
|
||||||
|
foreach (var tutorial in tutorials)
|
||||||
|
{
|
||||||
|
if (!tutorial)
|
||||||
|
{
|
||||||
|
issues.Add("存在空的教程资源引用。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(tutorial.tutorialId))
|
||||||
|
{
|
||||||
|
issues.Add($"教程资源 {tutorial.name} 缺少 tutorialId。");
|
||||||
|
}
|
||||||
|
else if (!ids.Add(tutorial.tutorialId))
|
||||||
|
{
|
||||||
|
issues.Add($"tutorialId 重复: {tutorial.tutorialId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tutorial.steps == null || tutorial.steps.Count == 0)
|
||||||
|
{
|
||||||
|
issues.Add($"教程 {tutorial.tutorialId} 没有任何步骤。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < tutorial.steps.Count; i++)
|
||||||
|
{
|
||||||
|
var step = tutorial.steps[i];
|
||||||
|
if (step == null)
|
||||||
|
{
|
||||||
|
issues.Add($"教程 {tutorial.tutorialId} 第 {i + 1} 步为空。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.targetMode == ShrinkTutorialTargetMode.Path && string.IsNullOrWhiteSpace(step.targetPath))
|
||||||
|
issues.Add($"教程 {tutorial.tutorialId} 第 {i + 1} 步缺少 targetPath。");
|
||||||
|
|
||||||
|
if (step.targetMode == ShrinkTutorialTargetMode.AnchorId && string.IsNullOrWhiteSpace(step.anchorId))
|
||||||
|
issues.Add($"教程 {tutorial.tutorialId} 第 {i + 1} 步缺少 anchorId。");
|
||||||
|
|
||||||
|
if (step.completeCondition == ShrinkTutorialCompleteCondition.CustomEvent &&
|
||||||
|
string.IsNullOrWhiteSpace(step.customEventName))
|
||||||
|
issues.Add($"教程 {tutorial.tutorialId} 第 {i + 1} 步使用 CustomEvent 但未填写 customEventName。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnValidate()
|
||||||
|
{
|
||||||
|
_cache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureCache()
|
||||||
|
{
|
||||||
|
if (_cache != null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_cache = new Dictionary<string, ShrinkTutorialData>();
|
||||||
|
foreach (var tutorial in tutorials)
|
||||||
|
{
|
||||||
|
if (!tutorial || string.IsNullOrWhiteSpace(tutorial.tutorialId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
_cache[tutorial.tutorialId] = tutorial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b0483b6d3c8490947a769ec2f8afdc34
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace ShrinkTutorial
|
||||||
|
{
|
||||||
|
public enum ShrinkTutorialTargetMode
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Path = 1,
|
||||||
|
AnchorId = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ShrinkTutorialCompleteCondition
|
||||||
|
{
|
||||||
|
ClickTarget = 0,
|
||||||
|
AnyClick = 1,
|
||||||
|
CustomEvent = 2,
|
||||||
|
Auto = 3,
|
||||||
|
DragToTarget = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ShrinkTutorialDialogAnchor
|
||||||
|
{
|
||||||
|
Auto = 0,
|
||||||
|
Top = 1,
|
||||||
|
Bottom = 2,
|
||||||
|
Left = 3,
|
||||||
|
Right = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ShrinkTutorialMaskShape
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Rect = 1,
|
||||||
|
Circle = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a542a494fb74e964d80f595ce7b5ad19
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace ShrinkTutorial
|
||||||
|
{
|
||||||
|
public interface IShrinkTutorialLocalizationProvider
|
||||||
|
{
|
||||||
|
bool TryResolve(string key, out string text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ShrinkTutorialLocalization
|
||||||
|
{
|
||||||
|
public static IShrinkTutorialLocalizationProvider Provider { get; set; }
|
||||||
|
|
||||||
|
public static string Resolve(string key, string fallbackText)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(key) &&
|
||||||
|
Provider != null &&
|
||||||
|
Provider.TryResolve(key, out var localizedText) &&
|
||||||
|
!string.IsNullOrWhiteSpace(localizedText))
|
||||||
|
{
|
||||||
|
return localizedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(fallbackText))
|
||||||
|
return fallbackText;
|
||||||
|
|
||||||
|
return key ?? string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 1b59e81c0301fa140b198d020c18e829
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,717 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.EventSystems;
|
||||||
|
using UnityEngine.UI;
|
||||||
|
|
||||||
|
namespace ShrinkTutorial
|
||||||
|
{
|
||||||
|
[DefaultExecutionOrder(-1500)]
|
||||||
|
public class ShrinkTutorialManager : MonoBehaviour
|
||||||
|
{
|
||||||
|
private static ShrinkTutorialManager _instance;
|
||||||
|
|
||||||
|
[SerializeField] private ShrinkTutorialSettings settingsOverride;
|
||||||
|
|
||||||
|
private readonly List<string> _pendingTutorialIds = new();
|
||||||
|
private readonly HashSet<string> _completedTutorials = new();
|
||||||
|
private readonly HashSet<string> _skippedTutorials = new();
|
||||||
|
private readonly List<RaycastResult> _pointerRaycastResults = new();
|
||||||
|
|
||||||
|
private IShrinkTutorialStorage _storage;
|
||||||
|
private Canvas _canvas;
|
||||||
|
private ShrinkTutorialMask _mask;
|
||||||
|
private ShrinkTutorialDialog _dialog;
|
||||||
|
|
||||||
|
private Coroutine _runner;
|
||||||
|
private ShrinkTutorialData _currentTutorial;
|
||||||
|
private int _currentStepIndex = -1;
|
||||||
|
private bool _isPaused;
|
||||||
|
private bool _skipRequested;
|
||||||
|
private bool _stepCompleted;
|
||||||
|
private bool _stepCompletionQueued;
|
||||||
|
private float _stepAutoTimer;
|
||||||
|
private string _receivedCustomEvent = string.Empty;
|
||||||
|
|
||||||
|
public static ShrinkTutorialManager Instance => _instance;
|
||||||
|
|
||||||
|
public bool IsRunning => _currentTutorial != null;
|
||||||
|
public string CurrentTutorialId => _currentTutorial ? _currentTutorial.tutorialId : string.Empty;
|
||||||
|
|
||||||
|
public event Action<string> OnTutorialStarted;
|
||||||
|
public event Action<string> OnTutorialCompleted;
|
||||||
|
public event Action<string> OnTutorialSkipped;
|
||||||
|
public event Action<string, int> OnStepChanged;
|
||||||
|
public event Action<ShrinkTutorialSignalArgs> OnSignalTriggered;
|
||||||
|
|
||||||
|
public static ShrinkTutorialManager EnsureInstance(ShrinkTutorialSettings settings = null)
|
||||||
|
{
|
||||||
|
if (_instance)
|
||||||
|
return _instance;
|
||||||
|
|
||||||
|
var existing = FindObjectOfType<ShrinkTutorialManager>();
|
||||||
|
if (existing)
|
||||||
|
return existing;
|
||||||
|
|
||||||
|
var host = new GameObject("ShrinkTutorialManager");
|
||||||
|
var manager = host.AddComponent<ShrinkTutorialManager>();
|
||||||
|
manager.settingsOverride = settings;
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ResetStaticState()
|
||||||
|
{
|
||||||
|
_instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasCompleted(string tutorialId)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(tutorialId) && _completedTutorials.Contains(tutorialId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasSkipped(string tutorialId)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(tutorialId) && _skippedTutorials.Contains(tutorialId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearProgress(string tutorialId)
|
||||||
|
{
|
||||||
|
InitializeIfNeeded();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(tutorialId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var changed = _completedTutorials.Remove(tutorialId);
|
||||||
|
changed = _skippedTutorials.Remove(tutorialId) || changed;
|
||||||
|
if (changed)
|
||||||
|
SaveProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StartTutorial(string tutorialId)
|
||||||
|
{
|
||||||
|
InitializeIfNeeded();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(tutorialId) || HasCompleted(tutorialId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (!TryGetDatabaseTutorial(tutorialId, out var tutorial))
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"[ShrinkTutorial] 未找到教程: {tutorialId}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ArePrerequisitesSatisfied(tutorial) || _currentTutorial)
|
||||||
|
{
|
||||||
|
EnqueueTutorial(tutorialId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogDebug($"StartTutorial -> {tutorialId}");
|
||||||
|
_runner = StartCoroutine(RunTutorialRoutine(tutorial));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CompleteStep(string customEventName = "")
|
||||||
|
{
|
||||||
|
if (_currentTutorial)
|
||||||
|
{
|
||||||
|
LogDebug($"CompleteStep signal received, tutorial={_currentTutorial.tutorialId}, step={_currentStepIndex}, event={customEventName}");
|
||||||
|
_receivedCustomEvent = customEventName ?? string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SkipCurrent()
|
||||||
|
{
|
||||||
|
if (!_currentTutorial)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_skipRequested = true;
|
||||||
|
_stepCompleted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PauseCurrent()
|
||||||
|
{
|
||||||
|
if (!_currentTutorial)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_isPaused = true;
|
||||||
|
SetUiVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ResumeCurrent()
|
||||||
|
{
|
||||||
|
if (!_currentTutorial)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_isPaused = false;
|
||||||
|
SetUiVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Awake()
|
||||||
|
{
|
||||||
|
if (_instance && _instance != this)
|
||||||
|
{
|
||||||
|
Destroy(gameObject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_instance = this;
|
||||||
|
InitializeIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeIfNeeded()
|
||||||
|
{
|
||||||
|
if (_storage != null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var settings = ResolveSettings();
|
||||||
|
_storage = new PlayerPrefsShrinkTutorialStorage(settings.playerPrefsStorageKey);
|
||||||
|
|
||||||
|
var progress = _storage.Load();
|
||||||
|
_completedTutorials.Clear();
|
||||||
|
_skippedTutorials.Clear();
|
||||||
|
|
||||||
|
foreach (var tutorialId in progress.completedTutorials)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(tutorialId))
|
||||||
|
_completedTutorials.Add(tutorialId);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var tutorialId in progress.skippedTutorials)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(tutorialId))
|
||||||
|
_skippedTutorials.Add(tutorialId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.dontDestroyOnLoad)
|
||||||
|
DontDestroyOnLoad(gameObject);
|
||||||
|
|
||||||
|
EnsureEventSystem();
|
||||||
|
EnsureUi();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerator RunTutorialRoutine(ShrinkTutorialData tutorial)
|
||||||
|
{
|
||||||
|
_currentTutorial = tutorial;
|
||||||
|
OnTutorialStarted?.Invoke(tutorial.tutorialId);
|
||||||
|
|
||||||
|
for (var stepIndex = 0; stepIndex < tutorial.steps.Count; stepIndex++)
|
||||||
|
{
|
||||||
|
_currentStepIndex = stepIndex;
|
||||||
|
_skipRequested = false;
|
||||||
|
_stepCompleted = false;
|
||||||
|
_stepCompletionQueued = false;
|
||||||
|
_stepAutoTimer = tutorial.steps[stepIndex].autoDelay;
|
||||||
|
_receivedCustomEvent = string.Empty;
|
||||||
|
|
||||||
|
var step = tutorial.steps[stepIndex];
|
||||||
|
LogDebug(
|
||||||
|
$"Enter step {stepIndex}, condition={step.completeCondition}, targetMode={step.targetMode}, " +
|
||||||
|
$"targetPath={step.targetPath}, anchorId={step.anchorId}, customEvent={step.customEventName}");
|
||||||
|
OnStepChanged?.Invoke(tutorial.tutorialId, stepIndex);
|
||||||
|
|
||||||
|
yield return WaitWhilePaused();
|
||||||
|
|
||||||
|
Transform targetTransform = null;
|
||||||
|
yield return ResolveTargetRoutine(step, value => targetTransform = value);
|
||||||
|
|
||||||
|
if (step.RequiresTarget() && !targetTransform)
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"[ShrinkTutorial] 教程 {tutorial.tutorialId} 第 {stepIndex + 1} 步缺少目标,已跳过。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetTransform)
|
||||||
|
LogDebug($"Resolved target for step {stepIndex}: {GetTransformPath(targetTransform)}");
|
||||||
|
|
||||||
|
EmitSignal(step.enterSignal);
|
||||||
|
_dialog.SetSkipVisible(ResolveSettings().showSkipButton);
|
||||||
|
|
||||||
|
while (!_stepCompleted && !_skipRequested)
|
||||||
|
{
|
||||||
|
yield return WaitWhilePaused();
|
||||||
|
|
||||||
|
UpdateStepVisuals(step, targetTransform);
|
||||||
|
TickStepCompletion(step, targetTransform);
|
||||||
|
yield return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
EmitSignal(step.exitSignal);
|
||||||
|
_dialog.Hide();
|
||||||
|
|
||||||
|
if (_skipRequested)
|
||||||
|
{
|
||||||
|
MarkSkipped(tutorial.tutorialId);
|
||||||
|
CleanupAfterTutorial();
|
||||||
|
OnTutorialSkipped?.Invoke(tutorial.tutorialId);
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MarkCompleted(tutorial.tutorialId);
|
||||||
|
OnTutorialCompleted?.Invoke(tutorial.tutorialId);
|
||||||
|
CleanupAfterTutorial();
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerator ResolveTargetRoutine(ShrinkTutorialStep step, Action<Transform> assignResult)
|
||||||
|
{
|
||||||
|
var elapsed = 0f;
|
||||||
|
var target = ShrinkTutorialTargetUtility.ResolveTarget(step);
|
||||||
|
|
||||||
|
while (!target && step.waitForTarget)
|
||||||
|
{
|
||||||
|
if (step.waitTimeout > 0f && elapsed >= step.waitTimeout)
|
||||||
|
break;
|
||||||
|
|
||||||
|
elapsed += Time.unscaledDeltaTime;
|
||||||
|
yield return null;
|
||||||
|
target = ShrinkTutorialTargetUtility.ResolveTarget(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
assignResult?.Invoke(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerator WaitWhilePaused()
|
||||||
|
{
|
||||||
|
while (_isPaused)
|
||||||
|
yield return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TickStepCompletion(ShrinkTutorialStep step, Transform targetTransform)
|
||||||
|
{
|
||||||
|
switch (step.completeCondition)
|
||||||
|
{
|
||||||
|
case ShrinkTutorialCompleteCondition.Auto:
|
||||||
|
_stepAutoTimer -= Time.unscaledDeltaTime;
|
||||||
|
if (_stepAutoTimer <= 0f)
|
||||||
|
{
|
||||||
|
LogDebug($"Auto step completed, tutorial={_currentTutorial?.tutorialId}, step={_currentStepIndex}");
|
||||||
|
_stepCompleted = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ShrinkTutorialCompleteCondition.AnyClick:
|
||||||
|
if (WasPrimaryPointerPressedOutsideDialog())
|
||||||
|
{
|
||||||
|
LogDebug($"AnyClick step completed, tutorial={_currentTutorial?.tutorialId}, step={_currentStepIndex}");
|
||||||
|
_stepCompleted = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ShrinkTutorialCompleteCondition.ClickTarget:
|
||||||
|
if (targetTransform &&
|
||||||
|
ShrinkTutorialTargetUtility.TryBuildScreenRect(targetTransform, out var targetRect) &&
|
||||||
|
WasPrimaryPointerReleased(targetRect, step.maskShape, out var screenPosition) &&
|
||||||
|
WasPointerReleaseRoutedToTarget(targetTransform, screenPosition))
|
||||||
|
{
|
||||||
|
LogDebug(
|
||||||
|
$"ClickTarget candidate accepted, tutorial={_currentTutorial?.tutorialId}, step={_currentStepIndex}, " +
|
||||||
|
$"pointer={screenPosition}, target={GetTransformPath(targetTransform)}");
|
||||||
|
QueueStepCompletion();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ShrinkTutorialCompleteCondition.CustomEvent:
|
||||||
|
case ShrinkTutorialCompleteCondition.DragToTarget:
|
||||||
|
if (MatchesCustomEvent(step.customEventName))
|
||||||
|
{
|
||||||
|
LogDebug(
|
||||||
|
$"CustomEvent step completed, tutorial={_currentTutorial?.tutorialId}, step={_currentStepIndex}, event={step.customEventName}");
|
||||||
|
_stepCompleted = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateStepVisuals(ShrinkTutorialStep step, Transform targetTransform)
|
||||||
|
{
|
||||||
|
Rect targetRect = default;
|
||||||
|
var hasTargetRect = targetTransform && ShrinkTutorialTargetUtility.TryBuildScreenRect(targetTransform, out targetRect);
|
||||||
|
|
||||||
|
if (hasTargetRect && step.maskShape != ShrinkTutorialMaskShape.None)
|
||||||
|
_mask.SetHighlight(targetRect, step.maskShape, step.maskPadding);
|
||||||
|
else
|
||||||
|
_mask.ClearHighlight();
|
||||||
|
|
||||||
|
var tutorialText = ShrinkTutorialLocalization.Resolve(step.localizedTextKey, step.fallbackText);
|
||||||
|
_dialog.Show(tutorialText, hasTargetRect ? targetRect : (Rect?)null, step.dialogAnchor, step.dialogOffset, step.showArrow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool MatchesCustomEvent(string expectedEventName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_receivedCustomEvent))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(expectedEventName))
|
||||||
|
{
|
||||||
|
_receivedCustomEvent = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(expectedEventName, _receivedCustomEvent, StringComparison.Ordinal))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
_receivedCustomEvent = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool WasPrimaryPointerPressedOutsideDialog()
|
||||||
|
{
|
||||||
|
var pressed = TryGetPointerDownPosition(out var screenPosition);
|
||||||
|
return pressed && !_dialog.ContainsScreenPoint(screenPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool WasPrimaryPointerReleased(Rect targetRect, ShrinkTutorialMaskShape maskShape, out Vector2 screenPosition)
|
||||||
|
{
|
||||||
|
if (!TryGetPointerUpPosition(out screenPosition))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (_dialog.ContainsScreenPoint(screenPosition))
|
||||||
|
{
|
||||||
|
LogDebug($"Pointer release ignored because it hit dialog, pointer={screenPosition}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var insideTarget = maskShape == ShrinkTutorialMaskShape.Circle
|
||||||
|
? IsPointInsideCircle(targetRect, screenPosition)
|
||||||
|
: targetRect.Contains(screenPosition);
|
||||||
|
if (!insideTarget)
|
||||||
|
LogDebug($"Pointer release outside target, pointer={screenPosition}, rect={targetRect}");
|
||||||
|
|
||||||
|
return insideTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsPointInsideCircle(Rect targetRect, Vector2 screenPosition)
|
||||||
|
{
|
||||||
|
var center = targetRect.center;
|
||||||
|
var radius = Mathf.Min(targetRect.width, targetRect.height) * 0.5f;
|
||||||
|
return Vector2.SqrMagnitude(screenPosition - center) <= radius * radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetPointerDownPosition(out Vector2 screenPosition)
|
||||||
|
{
|
||||||
|
if (Input.touchCount > 0)
|
||||||
|
{
|
||||||
|
var touch = Input.GetTouch(0);
|
||||||
|
if (touch.phase == TouchPhase.Began)
|
||||||
|
{
|
||||||
|
screenPosition = touch.position;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Input.GetMouseButtonDown(0))
|
||||||
|
{
|
||||||
|
screenPosition = Input.mousePosition;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
screenPosition = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetPointerUpPosition(out Vector2 screenPosition)
|
||||||
|
{
|
||||||
|
if (Input.touchCount > 0)
|
||||||
|
{
|
||||||
|
var touch = Input.GetTouch(0);
|
||||||
|
if (touch.phase == TouchPhase.Ended)
|
||||||
|
{
|
||||||
|
screenPosition = touch.position;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Input.GetMouseButtonUp(0))
|
||||||
|
{
|
||||||
|
screenPosition = Input.mousePosition;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
screenPosition = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool WasPointerReleaseRoutedToTarget(Transform targetTransform, Vector2 screenPosition)
|
||||||
|
{
|
||||||
|
var eventSystem = EventSystem.current;
|
||||||
|
if (!eventSystem)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
_pointerRaycastResults.Clear();
|
||||||
|
var pointerEventData = new PointerEventData(eventSystem)
|
||||||
|
{
|
||||||
|
position = screenPosition
|
||||||
|
};
|
||||||
|
eventSystem.RaycastAll(pointerEventData, _pointerRaycastResults);
|
||||||
|
|
||||||
|
foreach (var result in _pointerRaycastResults)
|
||||||
|
{
|
||||||
|
var hitTransform = result.gameObject ? result.gameObject.transform : null;
|
||||||
|
if (IsSameOrNestedTarget(hitTransform, targetTransform))
|
||||||
|
{
|
||||||
|
LogDebug(
|
||||||
|
$"Raycast matched target, pointer={screenPosition}, target={GetTransformPath(targetTransform)}, " +
|
||||||
|
$"hit={GetTransformPath(hitTransform)}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var hitSummary = _pointerRaycastResults.Count == 0
|
||||||
|
? "<none>"
|
||||||
|
: string.Join(" | ", _pointerRaycastResults.Take(5).Select(result =>
|
||||||
|
{
|
||||||
|
var hitTransform = result.gameObject ? result.gameObject.transform : null;
|
||||||
|
return GetTransformPath(hitTransform);
|
||||||
|
}));
|
||||||
|
LogDebug(
|
||||||
|
$"Raycast missed target, pointer={screenPosition}, target={GetTransformPath(targetTransform)}, hits={hitSummary}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSameOrNestedTarget(Transform hitTransform, Transform targetTransform)
|
||||||
|
{
|
||||||
|
return hitTransform &&
|
||||||
|
targetTransform &&
|
||||||
|
(hitTransform == targetTransform ||
|
||||||
|
hitTransform.IsChildOf(targetTransform) ||
|
||||||
|
targetTransform.IsChildOf(hitTransform));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void QueueStepCompletion()
|
||||||
|
{
|
||||||
|
if (_stepCompletionQueued || !_currentTutorial)
|
||||||
|
return;
|
||||||
|
|
||||||
|
LogDebug($"QueueStepCompletion, tutorial={_currentTutorial.tutorialId}, step={_currentStepIndex}");
|
||||||
|
_stepCompletionQueued = true;
|
||||||
|
StartCoroutine(CompleteStepAtEndOfFrame(_currentTutorial.tutorialId, _currentStepIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerator CompleteStepAtEndOfFrame(string tutorialId, int stepIndex)
|
||||||
|
{
|
||||||
|
yield return new WaitForEndOfFrame();
|
||||||
|
_stepCompletionQueued = false;
|
||||||
|
|
||||||
|
if (_currentTutorial &&
|
||||||
|
!_skipRequested &&
|
||||||
|
!_stepCompleted &&
|
||||||
|
string.Equals(_currentTutorial.tutorialId, tutorialId, StringComparison.Ordinal) &&
|
||||||
|
_currentStepIndex == stepIndex)
|
||||||
|
{
|
||||||
|
LogDebug($"Step completed at end of frame, tutorial={tutorialId}, step={stepIndex}");
|
||||||
|
_stepCompleted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EmitSignal(string signalName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(signalName) || !_currentTutorial)
|
||||||
|
return;
|
||||||
|
|
||||||
|
OnSignalTriggered?.Invoke(new ShrinkTutorialSignalArgs(
|
||||||
|
_currentTutorial.tutorialId,
|
||||||
|
_currentStepIndex,
|
||||||
|
signalName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CleanupAfterTutorial()
|
||||||
|
{
|
||||||
|
_mask.ClearHighlight();
|
||||||
|
_dialog.Hide();
|
||||||
|
_currentTutorial = null;
|
||||||
|
_currentStepIndex = -1;
|
||||||
|
_runner = null;
|
||||||
|
_stepCompletionQueued = false;
|
||||||
|
|
||||||
|
TryStartQueuedTutorial();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureUi()
|
||||||
|
{
|
||||||
|
if (_canvas && _mask && _dialog)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var settings = ResolveSettings();
|
||||||
|
var canvasObject = new GameObject("ShrinkTutorialCanvas", typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler),
|
||||||
|
typeof(GraphicRaycaster));
|
||||||
|
canvasObject.transform.SetParent(transform, false);
|
||||||
|
|
||||||
|
_canvas = canvasObject.GetComponent<Canvas>();
|
||||||
|
_canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||||
|
_canvas.sortingOrder = settings.canvasSortingOrder;
|
||||||
|
|
||||||
|
var scaler = canvasObject.GetComponent<CanvasScaler>();
|
||||||
|
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||||
|
scaler.referenceResolution = new Vector2(1920f, 1080f);
|
||||||
|
scaler.matchWidthOrHeight = 0.5f;
|
||||||
|
|
||||||
|
var maskObject = new GameObject("Mask", typeof(RectTransform), typeof(CanvasRenderer), typeof(ShrinkTutorialMask));
|
||||||
|
maskObject.transform.SetParent(canvasObject.transform, false);
|
||||||
|
StretchFull(maskObject.GetComponent<RectTransform>());
|
||||||
|
_mask = maskObject.GetComponent<ShrinkTutorialMask>();
|
||||||
|
_mask.color = settings.maskColor;
|
||||||
|
|
||||||
|
var dialogObject = new GameObject("Dialog", typeof(RectTransform), typeof(ShrinkTutorialDialog));
|
||||||
|
dialogObject.transform.SetParent(canvasObject.transform, false);
|
||||||
|
_dialog = dialogObject.GetComponent<ShrinkTutorialDialog>();
|
||||||
|
_dialog.Initialize(canvasObject.transform, SkipCurrent);
|
||||||
|
_dialog.Hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureEventSystem()
|
||||||
|
{
|
||||||
|
if (FindObjectOfType<EventSystem>())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var eventSystemObject = new GameObject("EventSystem", typeof(EventSystem), typeof(StandaloneInputModule));
|
||||||
|
DontDestroyOnLoad(eventSystemObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryGetDatabaseTutorial(string tutorialId, out ShrinkTutorialData tutorial)
|
||||||
|
{
|
||||||
|
tutorial = null;
|
||||||
|
var settings = ResolveSettings();
|
||||||
|
return settings.database && settings.database.TryGetTutorial(tutorialId, out tutorial);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShrinkTutorialSettings ResolveSettings()
|
||||||
|
{
|
||||||
|
return settingsOverride ? settingsOverride : ShrinkTutorialSettings.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ArePrerequisitesSatisfied(ShrinkTutorialData tutorial)
|
||||||
|
{
|
||||||
|
if (!tutorial || tutorial.prerequisiteIds == null)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
foreach (var prerequisiteId in tutorial.prerequisiteIds)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(prerequisiteId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!_completedTutorials.Contains(prerequisiteId))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnqueueTutorial(string tutorialId)
|
||||||
|
{
|
||||||
|
if (_pendingTutorialIds.Contains(tutorialId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_pendingTutorialIds.Add(tutorialId);
|
||||||
|
_pendingTutorialIds.Sort(ComparePendingTutorial);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int ComparePendingTutorial(string leftId, string rightId)
|
||||||
|
{
|
||||||
|
var leftPriority = TryGetDatabaseTutorial(leftId, out var leftTutorial) ? leftTutorial.priority : 0;
|
||||||
|
var rightPriority = TryGetDatabaseTutorial(rightId, out var rightTutorial) ? rightTutorial.priority : 0;
|
||||||
|
return rightPriority.CompareTo(leftPriority);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryStartQueuedTutorial()
|
||||||
|
{
|
||||||
|
for (var i = 0; i < _pendingTutorialIds.Count; i++)
|
||||||
|
{
|
||||||
|
var tutorialId = _pendingTutorialIds[i];
|
||||||
|
if (!TryGetDatabaseTutorial(tutorialId, out var tutorial) || !ArePrerequisitesSatisfied(tutorial))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
_pendingTutorialIds.RemoveAt(i);
|
||||||
|
StartTutorial(tutorialId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MarkCompleted(string tutorialId)
|
||||||
|
{
|
||||||
|
_completedTutorials.Add(tutorialId);
|
||||||
|
_skippedTutorials.Remove(tutorialId);
|
||||||
|
SaveProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MarkSkipped(string tutorialId)
|
||||||
|
{
|
||||||
|
_skippedTutorials.Add(tutorialId);
|
||||||
|
SaveProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveProgress()
|
||||||
|
{
|
||||||
|
_storage.Save(new ShrinkTutorialProgress
|
||||||
|
{
|
||||||
|
completedTutorials = new List<string>(_completedTutorials),
|
||||||
|
skippedTutorials = new List<string>(_skippedTutorials)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetUiVisible(bool visible)
|
||||||
|
{
|
||||||
|
if (_canvas)
|
||||||
|
_canvas.enabled = visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void StretchFull(RectTransform rectTransform)
|
||||||
|
{
|
||||||
|
rectTransform.anchorMin = Vector2.zero;
|
||||||
|
rectTransform.anchorMax = Vector2.one;
|
||||||
|
rectTransform.offsetMin = Vector2.zero;
|
||||||
|
rectTransform.offsetMax = Vector2.zero;
|
||||||
|
rectTransform.pivot = new Vector2(0.5f, 0.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.Diagnostics.Conditional("UNITY_EDITOR")]
|
||||||
|
private static void LogDebug(string message)
|
||||||
|
{
|
||||||
|
if (!IsDebugLoggingEnabled())
|
||||||
|
return;
|
||||||
|
|
||||||
|
Debug.Log($"[ShrinkTutorial][Debug] {message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.Diagnostics.Conditional("UNITY_EDITOR")]
|
||||||
|
private static void LogDebug(string format, params object[] args)
|
||||||
|
{
|
||||||
|
if (!IsDebugLoggingEnabled())
|
||||||
|
return;
|
||||||
|
|
||||||
|
Debug.LogFormat("[ShrinkTutorial][Debug] " + format, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDebugLoggingEnabled()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetTransformPath(Transform transform)
|
||||||
|
{
|
||||||
|
if (!transform)
|
||||||
|
return "<null>";
|
||||||
|
|
||||||
|
var path = transform.name;
|
||||||
|
while (transform.parent)
|
||||||
|
{
|
||||||
|
transform = transform.parent;
|
||||||
|
path = $"{transform.name}/{path}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly struct ShrinkTutorialSignalArgs
|
||||||
|
{
|
||||||
|
public ShrinkTutorialSignalArgs(string tutorialId, int stepIndex, string signalName)
|
||||||
|
{
|
||||||
|
TutorialId = tutorialId;
|
||||||
|
StepIndex = stepIndex;
|
||||||
|
SignalName = signalName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TutorialId { get; }
|
||||||
|
public int StepIndex { get; }
|
||||||
|
public string SignalName { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 11fb462c0e7f4f04ab5312d000322af4
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkTutorial
|
||||||
|
{
|
||||||
|
public static class ShrinkTutorialRuntimeBootstrap
|
||||||
|
{
|
||||||
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||||
|
private static void ResetStaticState()
|
||||||
|
{
|
||||||
|
ShrinkTutorialAnchorRegistry.Reset();
|
||||||
|
ShrinkTutorialManager.ResetStaticState();
|
||||||
|
ShrinkTutorialSettings.Instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||||
|
private static void AutoCreateManager()
|
||||||
|
{
|
||||||
|
var settings = ShrinkTutorialSettings.Instance;
|
||||||
|
if (settings && !settings.autoCreateManager)
|
||||||
|
return;
|
||||||
|
|
||||||
|
ShrinkTutorialManager.EnsureInstance(settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f4103feef1d168648aac09027fa8da2b
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkTutorial
|
||||||
|
{
|
||||||
|
[CreateAssetMenu(fileName = "ShrinkTutorialSettings", menuName = "ShrinkTutorial/Settings")]
|
||||||
|
public class ShrinkTutorialSettings : ScriptableObject
|
||||||
|
{
|
||||||
|
private static ShrinkTutorialSettings _instance;
|
||||||
|
|
||||||
|
public static ShrinkTutorialSettings Instance
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_instance)
|
||||||
|
return _instance;
|
||||||
|
|
||||||
|
_instance = Resources.Load<ShrinkTutorialSettings>("ShrinkTutorialSettings");
|
||||||
|
|
||||||
|
#if UNITY_EDITOR
|
||||||
|
if (!_instance)
|
||||||
|
{
|
||||||
|
var guids = UnityEditor.AssetDatabase.FindAssets("t:ShrinkTutorialSettings");
|
||||||
|
if (guids.Length > 0)
|
||||||
|
{
|
||||||
|
var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[0]);
|
||||||
|
_instance = UnityEditor.AssetDatabase.LoadAssetAtPath<ShrinkTutorialSettings>(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if (!_instance)
|
||||||
|
{
|
||||||
|
_instance = CreateInstance<ShrinkTutorialSettings>();
|
||||||
|
Debug.LogWarning(
|
||||||
|
"[ShrinkTutorial] 未找到 ShrinkTutorialSettings,当前使用默认配置。\n" +
|
||||||
|
"请通过 Assets -> Create -> ShrinkTutorial -> Settings 创建配置资源。");
|
||||||
|
}
|
||||||
|
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
internal set => _instance = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Header("数据")]
|
||||||
|
public ShrinkTutorialDatabase database;
|
||||||
|
|
||||||
|
[Header("运行时")]
|
||||||
|
public bool autoCreateManager = true;
|
||||||
|
public bool dontDestroyOnLoad = true;
|
||||||
|
public int canvasSortingOrder = 9999;
|
||||||
|
|
||||||
|
[Header("遮罩")]
|
||||||
|
public Color maskColor = new(0f, 0f, 0f, 0.72f);
|
||||||
|
|
||||||
|
[Header("存储")]
|
||||||
|
public string playerPrefsStorageKey = "ShrinkTutorial.Progress";
|
||||||
|
|
||||||
|
[Header("交互")]
|
||||||
|
public bool showSkipButton = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 975bc7797d2338c42b237370d7ea7fcc
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkTutorial
|
||||||
|
{
|
||||||
|
[Serializable]
|
||||||
|
public class ShrinkTutorialStep
|
||||||
|
{
|
||||||
|
[Header("目标定位")]
|
||||||
|
public ShrinkTutorialTargetMode targetMode = ShrinkTutorialTargetMode.None;
|
||||||
|
public string targetPath = string.Empty;
|
||||||
|
public string anchorId = string.Empty;
|
||||||
|
public bool waitForTarget = true;
|
||||||
|
[Min(0f)] public float waitTimeout = 5f;
|
||||||
|
|
||||||
|
[Header("完成条件")]
|
||||||
|
public ShrinkTutorialCompleteCondition completeCondition = ShrinkTutorialCompleteCondition.ClickTarget;
|
||||||
|
public string customEventName = string.Empty;
|
||||||
|
[Min(0f)] public float autoDelay = 0.5f;
|
||||||
|
public string dragTargetAnchorId = string.Empty;
|
||||||
|
|
||||||
|
[Header("文案")]
|
||||||
|
public string localizedTextKey = string.Empty;
|
||||||
|
[TextArea(2, 6)] public string fallbackText = string.Empty;
|
||||||
|
|
||||||
|
[Header("表现")]
|
||||||
|
public ShrinkTutorialDialogAnchor dialogAnchor = ShrinkTutorialDialogAnchor.Auto;
|
||||||
|
public Vector2 dialogOffset = Vector2.zero;
|
||||||
|
public ShrinkTutorialMaskShape maskShape = ShrinkTutorialMaskShape.Rect;
|
||||||
|
[Min(0f)] public float maskPadding = 12f;
|
||||||
|
public bool showArrow = true;
|
||||||
|
|
||||||
|
[Header("信号")]
|
||||||
|
public string enterSignal = string.Empty;
|
||||||
|
public string exitSignal = string.Empty;
|
||||||
|
|
||||||
|
public bool RequiresTarget()
|
||||||
|
{
|
||||||
|
return completeCondition == ShrinkTutorialCompleteCondition.ClickTarget ||
|
||||||
|
completeCondition == ShrinkTutorialCompleteCondition.DragToTarget ||
|
||||||
|
targetMode != ShrinkTutorialTargetMode.None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4fb75d0b9c8a11c4d8609ba4a30c68ee
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.SceneManagement;
|
||||||
|
|
||||||
|
namespace ShrinkTutorial
|
||||||
|
{
|
||||||
|
internal static class ShrinkTutorialTargetUtility
|
||||||
|
{
|
||||||
|
public static Transform ResolveTarget(ShrinkTutorialStep step)
|
||||||
|
{
|
||||||
|
var target = step.targetMode switch
|
||||||
|
{
|
||||||
|
ShrinkTutorialTargetMode.Path => FindByHierarchyPath(step.targetPath),
|
||||||
|
ShrinkTutorialTargetMode.AnchorId => ShrinkTutorialAnchorRegistry.Get(step.anchorId),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
return IsTargetAvailable(target) ? target : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryBuildScreenRect(Transform targetTransform, out Rect rect)
|
||||||
|
{
|
||||||
|
rect = default;
|
||||||
|
if (!IsTargetAvailable(targetTransform))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (targetTransform is RectTransform rectTransform)
|
||||||
|
return TryBuildRectTransformScreenRect(rectTransform, out rect);
|
||||||
|
|
||||||
|
if (targetTransform.TryGetComponent<Renderer>(out var renderer))
|
||||||
|
return TryBuildBoundsScreenRect(renderer.bounds, out rect);
|
||||||
|
|
||||||
|
if (targetTransform.TryGetComponent<Collider>(out var collider))
|
||||||
|
return TryBuildBoundsScreenRect(collider.bounds, out rect);
|
||||||
|
|
||||||
|
var screenPoint = RectTransformUtility.WorldToScreenPoint(GetWorldCamera(), targetTransform.position);
|
||||||
|
rect = new Rect(screenPoint.x - 24f, screenPoint.y - 24f, 48f, 48f);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryBuildRectTransformScreenRect(RectTransform rectTransform, out Rect rect)
|
||||||
|
{
|
||||||
|
var corners = new Vector3[4];
|
||||||
|
rectTransform.GetWorldCorners(corners);
|
||||||
|
var camera = GetCanvasCamera(rectTransform);
|
||||||
|
var min = RectTransformUtility.WorldToScreenPoint(camera, corners[0]);
|
||||||
|
var max = min;
|
||||||
|
for (var i = 1; i < corners.Length; i++)
|
||||||
|
{
|
||||||
|
var point = RectTransformUtility.WorldToScreenPoint(camera, corners[i]);
|
||||||
|
min = Vector2.Min(min, point);
|
||||||
|
max = Vector2.Max(max, point);
|
||||||
|
}
|
||||||
|
|
||||||
|
rect = Rect.MinMaxRect(min.x, min.y, max.x, max.y);
|
||||||
|
return rect.width > 0f && rect.height > 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryBuildBoundsScreenRect(Bounds bounds, out Rect rect)
|
||||||
|
{
|
||||||
|
rect = default;
|
||||||
|
var camera = GetWorldCamera();
|
||||||
|
if (!camera)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var corners = new List<Vector3>(8)
|
||||||
|
{
|
||||||
|
new(bounds.min.x, bounds.min.y, bounds.min.z),
|
||||||
|
new(bounds.min.x, bounds.min.y, bounds.max.z),
|
||||||
|
new(bounds.min.x, bounds.max.y, bounds.min.z),
|
||||||
|
new(bounds.min.x, bounds.max.y, bounds.max.z),
|
||||||
|
new(bounds.max.x, bounds.min.y, bounds.min.z),
|
||||||
|
new(bounds.max.x, bounds.min.y, bounds.max.z),
|
||||||
|
new(bounds.max.x, bounds.max.y, bounds.min.z),
|
||||||
|
new(bounds.max.x, bounds.max.y, bounds.max.z)
|
||||||
|
};
|
||||||
|
|
||||||
|
var min = new Vector2(float.MaxValue, float.MaxValue);
|
||||||
|
var max = new Vector2(float.MinValue, float.MinValue);
|
||||||
|
var hasVisiblePoint = false;
|
||||||
|
|
||||||
|
foreach (var corner in corners)
|
||||||
|
{
|
||||||
|
var screenPoint = camera.WorldToScreenPoint(corner);
|
||||||
|
if (screenPoint.z < 0f)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
hasVisiblePoint = true;
|
||||||
|
var point = new Vector2(screenPoint.x, screenPoint.y);
|
||||||
|
min = Vector2.Min(min, point);
|
||||||
|
max = Vector2.Max(max, point);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasVisiblePoint)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
rect = Rect.MinMaxRect(min.x, min.y, max.x, max.y);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Camera GetCanvasCamera(RectTransform rectTransform)
|
||||||
|
{
|
||||||
|
var canvas = rectTransform.GetComponentInParent<Canvas>();
|
||||||
|
if (!canvas || canvas.renderMode == RenderMode.ScreenSpaceOverlay)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return canvas.worldCamera ? canvas.worldCamera : Camera.main;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Camera GetWorldCamera()
|
||||||
|
{
|
||||||
|
if (Camera.main)
|
||||||
|
return Camera.main;
|
||||||
|
|
||||||
|
foreach (var camera in Camera.allCameras)
|
||||||
|
{
|
||||||
|
if (camera && camera.enabled)
|
||||||
|
return camera;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTargetAvailable(Transform targetTransform)
|
||||||
|
{
|
||||||
|
return targetTransform && targetTransform.gameObject.activeInHierarchy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Transform FindByHierarchyPath(string hierarchyPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(hierarchyPath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var segments = hierarchyPath.Split('/');
|
||||||
|
for (var sceneIndex = 0; sceneIndex < SceneManager.sceneCount; sceneIndex++)
|
||||||
|
{
|
||||||
|
var scene = SceneManager.GetSceneAt(sceneIndex);
|
||||||
|
if (!scene.isLoaded)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
foreach (var root in scene.GetRootGameObjects())
|
||||||
|
{
|
||||||
|
if (TryMatchPath(root.transform, segments, 0, out var result))
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryMatchPath(Transform current, string[] segments, int segmentIndex, out Transform result)
|
||||||
|
{
|
||||||
|
result = null;
|
||||||
|
if (!current || segmentIndex >= segments.Length || current.name != segments[segmentIndex])
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (segmentIndex == segments.Length - 1)
|
||||||
|
{
|
||||||
|
result = current;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < current.childCount; i++)
|
||||||
|
{
|
||||||
|
if (TryMatchPath(current.GetChild(i), segments, segmentIndex + 1, out result))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 73608135e8345684c9f9194f1a23363e
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 323d621af84dc3a47b8cc1a23f49d1ec
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
using System.Collections;
|
||||||
|
using TMPro;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UI;
|
||||||
|
|
||||||
|
namespace ShrinkTutorial
|
||||||
|
{
|
||||||
|
public class ShrinkTutorialSampleSceneController : MonoBehaviour
|
||||||
|
{
|
||||||
|
public const string TutorialId = "sample.shrink_tutorial.basic";
|
||||||
|
public const string RewardAnchorId = "sample.reward.item";
|
||||||
|
public const string RewardClaimEventName = "sample.reward.claimed";
|
||||||
|
|
||||||
|
[SerializeField] private Button openInventoryButton;
|
||||||
|
[SerializeField] private Button spawnRewardButton;
|
||||||
|
[SerializeField] private GameObject inventoryPanel;
|
||||||
|
[SerializeField] private RectTransform rewardContainer;
|
||||||
|
[SerializeField] private TextMeshProUGUI statusText;
|
||||||
|
|
||||||
|
private Button _spawnedRewardButton;
|
||||||
|
|
||||||
|
private void Awake()
|
||||||
|
{
|
||||||
|
AutoWire();
|
||||||
|
BindButtons();
|
||||||
|
LogDebug($"Awake, openButton={DescribeObject(openInventoryButton)}, spawnButton={DescribeObject(spawnRewardButton)}, inventoryPanel={DescribeObject(inventoryPanel)}");
|
||||||
|
|
||||||
|
if (inventoryPanel)
|
||||||
|
inventoryPanel.SetActive(false);
|
||||||
|
|
||||||
|
ClearRewardItems();
|
||||||
|
SetStatus("Waiting for tutorial start.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerator Start()
|
||||||
|
{
|
||||||
|
yield return null;
|
||||||
|
var tutorialManager = ShrinkTutorialManager.EnsureInstance();
|
||||||
|
tutorialManager.ClearProgress(TutorialId);
|
||||||
|
tutorialManager.StartTutorial(TutorialId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDestroy()
|
||||||
|
{
|
||||||
|
if (openInventoryButton)
|
||||||
|
openInventoryButton.onClick.RemoveListener(HandleOpenInventory);
|
||||||
|
|
||||||
|
if (spawnRewardButton)
|
||||||
|
spawnRewardButton.onClick.RemoveListener(HandleSpawnReward);
|
||||||
|
|
||||||
|
if (_spawnedRewardButton)
|
||||||
|
_spawnedRewardButton.onClick.RemoveListener(HandleClaimReward);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Reset()
|
||||||
|
{
|
||||||
|
AutoWire();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnValidate()
|
||||||
|
{
|
||||||
|
AutoWire();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void HandleOpenInventory()
|
||||||
|
{
|
||||||
|
LogDebug($"HandleOpenInventory before, inventoryActive={inventoryPanel && inventoryPanel.activeSelf}");
|
||||||
|
if (inventoryPanel)
|
||||||
|
inventoryPanel.SetActive(true);
|
||||||
|
|
||||||
|
SetStatus("Inventory panel opened.");
|
||||||
|
LogDebug($"HandleOpenInventory after, inventoryActive={inventoryPanel && inventoryPanel.activeSelf}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void HandleSpawnReward()
|
||||||
|
{
|
||||||
|
LogDebug($"HandleSpawnReward start, hasReward={_spawnedRewardButton}");
|
||||||
|
if (!rewardContainer)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_spawnedRewardButton)
|
||||||
|
{
|
||||||
|
SetStatus("Reward item has already been created.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var buttonObject = new GameObject("RuntimeRewardButton",
|
||||||
|
typeof(RectTransform),
|
||||||
|
typeof(Image),
|
||||||
|
typeof(Button),
|
||||||
|
typeof(LayoutElement),
|
||||||
|
typeof(ShrinkTutorialAnchor));
|
||||||
|
var rectTransform = buttonObject.GetComponent<RectTransform>();
|
||||||
|
rectTransform.SetParent(rewardContainer, false);
|
||||||
|
rectTransform.anchorMin = new Vector2(0f, 0.5f);
|
||||||
|
rectTransform.anchorMax = new Vector2(1f, 0.5f);
|
||||||
|
rectTransform.pivot = new Vector2(0.5f, 0.5f);
|
||||||
|
rectTransform.offsetMin = new Vector2(0f, 0f);
|
||||||
|
rectTransform.offsetMax = new Vector2(0f, 0f);
|
||||||
|
rectTransform.sizeDelta = new Vector2(0f, 58f);
|
||||||
|
|
||||||
|
var image = buttonObject.GetComponent<Image>();
|
||||||
|
image.color = new Color(0.18f, 0.48f, 0.28f, 1f);
|
||||||
|
|
||||||
|
var button = buttonObject.GetComponent<Button>();
|
||||||
|
button.targetGraphic = image;
|
||||||
|
button.onClick.AddListener(HandleClaimReward);
|
||||||
|
_spawnedRewardButton = button;
|
||||||
|
|
||||||
|
var layoutElement = buttonObject.GetComponent<LayoutElement>();
|
||||||
|
layoutElement.minHeight = 58f;
|
||||||
|
layoutElement.preferredHeight = 58f;
|
||||||
|
layoutElement.preferredWidth = 420f;
|
||||||
|
layoutElement.flexibleWidth = 1f;
|
||||||
|
|
||||||
|
var anchor = buttonObject.GetComponent<ShrinkTutorialAnchor>();
|
||||||
|
anchor.SetAnchorId(RewardAnchorId);
|
||||||
|
|
||||||
|
var labelObject = new GameObject("Label", typeof(RectTransform), typeof(TextMeshProUGUI));
|
||||||
|
var labelRect = labelObject.GetComponent<RectTransform>();
|
||||||
|
labelRect.SetParent(rectTransform, false);
|
||||||
|
labelRect.anchorMin = Vector2.zero;
|
||||||
|
labelRect.anchorMax = Vector2.one;
|
||||||
|
labelRect.offsetMin = Vector2.zero;
|
||||||
|
labelRect.offsetMax = Vector2.zero;
|
||||||
|
|
||||||
|
var label = labelObject.GetComponent<TextMeshProUGUI>();
|
||||||
|
label.text = "Claim Runtime Reward";
|
||||||
|
label.fontSize = 24f;
|
||||||
|
label.alignment = TextAlignmentOptions.Center;
|
||||||
|
label.color = Color.white;
|
||||||
|
label.enableWordWrapping = false;
|
||||||
|
label.overflowMode = TextOverflowModes.Ellipsis;
|
||||||
|
|
||||||
|
if (spawnRewardButton)
|
||||||
|
spawnRewardButton.interactable = false;
|
||||||
|
|
||||||
|
LayoutRebuilder.ForceRebuildLayoutImmediate(rewardContainer);
|
||||||
|
Canvas.ForceUpdateCanvases();
|
||||||
|
|
||||||
|
SetStatus("Reward button created. Waiting for dynamic anchor step.");
|
||||||
|
LogDebug($"HandleSpawnReward created reward button, path={GetTransformPath(buttonObject.transform)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void HandleClaimReward()
|
||||||
|
{
|
||||||
|
LogDebug($"HandleClaimReward start, hasReward={_spawnedRewardButton}");
|
||||||
|
if (_spawnedRewardButton)
|
||||||
|
{
|
||||||
|
_spawnedRewardButton.interactable = false;
|
||||||
|
var image = _spawnedRewardButton.GetComponent<Image>();
|
||||||
|
if (image)
|
||||||
|
image.color = new Color(0.25f, 0.25f, 0.25f, 1f);
|
||||||
|
|
||||||
|
var label = _spawnedRewardButton.GetComponentInChildren<TextMeshProUGUI>();
|
||||||
|
if (label)
|
||||||
|
label.text = "Reward Claimed";
|
||||||
|
}
|
||||||
|
|
||||||
|
SetStatus("Custom completion event sent.");
|
||||||
|
ShrinkTutorialManager.Instance?.CompleteStep(RewardClaimEventName);
|
||||||
|
LogDebug($"HandleClaimReward complete, event={RewardClaimEventName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BindButtons()
|
||||||
|
{
|
||||||
|
if (openInventoryButton)
|
||||||
|
{
|
||||||
|
openInventoryButton.onClick.RemoveListener(HandleOpenInventory);
|
||||||
|
openInventoryButton.onClick.AddListener(HandleOpenInventory);
|
||||||
|
LogDebug($"Bound openInventoryButton -> {GetTransformPath(openInventoryButton.transform)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spawnRewardButton)
|
||||||
|
{
|
||||||
|
spawnRewardButton.onClick.RemoveListener(HandleSpawnReward);
|
||||||
|
spawnRewardButton.onClick.AddListener(HandleSpawnReward);
|
||||||
|
LogDebug($"Bound spawnRewardButton -> {GetTransformPath(spawnRewardButton.transform)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AutoWire()
|
||||||
|
{
|
||||||
|
if (!openInventoryButton)
|
||||||
|
openInventoryButton = FindButton("Tutorial Sample Canvas/OpenInventoryButton");
|
||||||
|
|
||||||
|
if (!spawnRewardButton)
|
||||||
|
spawnRewardButton = FindButton("Tutorial Sample Canvas/InventoryPanel/SpawnRewardButton");
|
||||||
|
|
||||||
|
if (!inventoryPanel)
|
||||||
|
{
|
||||||
|
var inventoryTransform = transform.Find("Tutorial Sample Canvas/InventoryPanel");
|
||||||
|
inventoryPanel = inventoryTransform ? inventoryTransform.gameObject : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rewardContainer)
|
||||||
|
rewardContainer = FindRectTransform("Tutorial Sample Canvas/InventoryPanel/RewardContainer");
|
||||||
|
|
||||||
|
if (!statusText)
|
||||||
|
statusText = FindText("Tutorial Sample Canvas/StatusText");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearRewardItems()
|
||||||
|
{
|
||||||
|
if (!rewardContainer)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (var i = rewardContainer.childCount - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
Destroy(rewardContainer.GetChild(i).gameObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
_spawnedRewardButton = null;
|
||||||
|
|
||||||
|
if (spawnRewardButton)
|
||||||
|
spawnRewardButton.interactable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetStatus(string text)
|
||||||
|
{
|
||||||
|
if (statusText)
|
||||||
|
statusText.text = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Button FindButton(string path)
|
||||||
|
{
|
||||||
|
var target = transform.Find(path);
|
||||||
|
return target ? target.GetComponent<Button>() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RectTransform FindRectTransform(string path)
|
||||||
|
{
|
||||||
|
var target = transform.Find(path);
|
||||||
|
return target ? target as RectTransform : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TextMeshProUGUI FindText(string path)
|
||||||
|
{
|
||||||
|
var target = transform.Find(path);
|
||||||
|
return target ? target.GetComponent<TextMeshProUGUI>() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.Diagnostics.Conditional("UNITY_EDITOR")]
|
||||||
|
private static void LogDebug(string message)
|
||||||
|
{
|
||||||
|
if (!IsDebugLoggingEnabled())
|
||||||
|
return;
|
||||||
|
|
||||||
|
Debug.Log($"[ShrinkTutorialSample][Debug] {message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDebugLoggingEnabled()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescribeObject(Object value)
|
||||||
|
{
|
||||||
|
return value ? value.name : "<null>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetTransformPath(Transform transform)
|
||||||
|
{
|
||||||
|
if (!transform)
|
||||||
|
return "<null>";
|
||||||
|
|
||||||
|
var path = transform.name;
|
||||||
|
while (transform.parent)
|
||||||
|
{
|
||||||
|
transform = transform.parent;
|
||||||
|
path = $"{transform.name}/{path}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 185ca11b5511dba4992782a251c84bda
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "ShrinkTutorial.Runtime",
|
||||||
|
"rootNamespace": "ShrinkTutorial",
|
||||||
|
"references": [
|
||||||
|
"Unity.TextMeshPro"
|
||||||
|
],
|
||||||
|
"includePlatforms": [],
|
||||||
|
"excludePlatforms": [],
|
||||||
|
"allowUnsafeCode": false,
|
||||||
|
"overrideReferences": false,
|
||||||
|
"autoReferenced": true,
|
||||||
|
"defineConstraints": [],
|
||||||
|
"versionDefines": [],
|
||||||
|
"noEngineReferences": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 325708c5cd4f3fc43b16367919f1eb97
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7ca1678d9b87f034890b1d202dc2ecf8
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace ShrinkTutorial
|
||||||
|
{
|
||||||
|
[Serializable]
|
||||||
|
public sealed class ShrinkTutorialProgress
|
||||||
|
{
|
||||||
|
public List<string> completedTutorials = new();
|
||||||
|
public List<string> skippedTutorials = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IShrinkTutorialStorage
|
||||||
|
{
|
||||||
|
ShrinkTutorialProgress Load();
|
||||||
|
void Save(ShrinkTutorialProgress progress);
|
||||||
|
void Reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 67a3fb9a0dd678c4eb81a707d0d95697
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkTutorial
|
||||||
|
{
|
||||||
|
public sealed class PlayerPrefsShrinkTutorialStorage : IShrinkTutorialStorage
|
||||||
|
{
|
||||||
|
private readonly string _storageKey;
|
||||||
|
|
||||||
|
public PlayerPrefsShrinkTutorialStorage(string storageKey)
|
||||||
|
{
|
||||||
|
_storageKey = string.IsNullOrWhiteSpace(storageKey)
|
||||||
|
? "ShrinkTutorial.Progress"
|
||||||
|
: storageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ShrinkTutorialProgress Load()
|
||||||
|
{
|
||||||
|
if (!PlayerPrefs.HasKey(_storageKey))
|
||||||
|
return new ShrinkTutorialProgress();
|
||||||
|
|
||||||
|
var json = PlayerPrefs.GetString(_storageKey, string.Empty);
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
return new ShrinkTutorialProgress();
|
||||||
|
|
||||||
|
return JsonUtility.FromJson<ShrinkTutorialProgress>(json) ?? new ShrinkTutorialProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save(ShrinkTutorialProgress progress)
|
||||||
|
{
|
||||||
|
var json = JsonUtility.ToJson(progress ?? new ShrinkTutorialProgress());
|
||||||
|
PlayerPrefs.SetString(_storageKey, json);
|
||||||
|
PlayerPrefs.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
PlayerPrefs.DeleteKey(_storageKey);
|
||||||
|
PlayerPrefs.Save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4cfcca11fd52a15428f5db55524c7380
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 083e926abf070a94d9d51a48b9da5f4d
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkTutorial
|
||||||
|
{
|
||||||
|
public class ShrinkTutorialAnchor : MonoBehaviour
|
||||||
|
{
|
||||||
|
[SerializeField] private string anchorId = string.Empty;
|
||||||
|
|
||||||
|
public string AnchorId => anchorId;
|
||||||
|
|
||||||
|
public void SetAnchorId(string value)
|
||||||
|
{
|
||||||
|
anchorId = value ?? string.Empty;
|
||||||
|
|
||||||
|
if (isActiveAndEnabled)
|
||||||
|
ShrinkTutorialAnchorRegistry.Register(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEnable()
|
||||||
|
{
|
||||||
|
ShrinkTutorialAnchorRegistry.Register(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDisable()
|
||||||
|
{
|
||||||
|
ShrinkTutorialAnchorRegistry.Unregister(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnValidate()
|
||||||
|
{
|
||||||
|
if (!Application.isPlaying)
|
||||||
|
return;
|
||||||
|
|
||||||
|
ShrinkTutorialAnchorRegistry.Register(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d553777c8c60c3342a2b6c6d47f9a36d
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkTutorial
|
||||||
|
{
|
||||||
|
public static class ShrinkTutorialAnchorRegistry
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, ShrinkTutorialAnchor> Anchors = new();
|
||||||
|
|
||||||
|
public static void Register(ShrinkTutorialAnchor anchor)
|
||||||
|
{
|
||||||
|
if (!anchor || string.IsNullOrWhiteSpace(anchor.AnchorId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
Anchors[anchor.AnchorId] = anchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Unregister(ShrinkTutorialAnchor anchor)
|
||||||
|
{
|
||||||
|
if (!anchor || string.IsNullOrWhiteSpace(anchor.AnchorId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (Anchors.TryGetValue(anchor.AnchorId, out var current) && current == anchor)
|
||||||
|
Anchors.Remove(anchor.AnchorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Transform Get(string anchorId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(anchorId))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return Anchors.TryGetValue(anchorId, out var anchor) && anchor
|
||||||
|
? anchor.transform
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Reset()
|
||||||
|
{
|
||||||
|
Anchors.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6d49253f099902345bd20e381ed50127
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ShrinkTutorial
|
||||||
|
{
|
||||||
|
public class ShrinkTutorialTrigger : MonoBehaviour
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> FiredSessionKeys = new();
|
||||||
|
|
||||||
|
[SerializeField] private string tutorialId = string.Empty;
|
||||||
|
[SerializeField] private bool fireOnEnable = true;
|
||||||
|
[SerializeField] private bool onlyOncePerSession = true;
|
||||||
|
[SerializeField] private bool onlyIfNotCompleted = true;
|
||||||
|
|
||||||
|
private void OnEnable()
|
||||||
|
{
|
||||||
|
if (fireOnEnable)
|
||||||
|
Fire();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Fire()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(tutorialId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var sessionKey = $"{gameObject.scene.path}:{gameObject.GetInstanceID()}:{tutorialId}";
|
||||||
|
if (onlyOncePerSession && !FiredSessionKeys.Add(sessionKey))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var manager = ShrinkTutorialManager.Instance;
|
||||||
|
if (!manager)
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"[ShrinkTutorial] 触发器 {name} 找不到 TutorialManager。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onlyIfNotCompleted && manager.HasCompleted(tutorialId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
manager.StartTutorial(tutorialId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||||
|
private static void ResetSessionState()
|
||||||
|
{
|
||||||
|
FiredSessionKeys.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 13a35349f92056c4682d3355ca61f107
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c46242a48501b1b4e955ce14ef821418
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
using System;
|
||||||
|
using TMPro;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UI;
|
||||||
|
|
||||||
|
namespace ShrinkTutorial
|
||||||
|
{
|
||||||
|
public class ShrinkTutorialDialog : MonoBehaviour
|
||||||
|
{
|
||||||
|
private RectTransform _rectTransform;
|
||||||
|
private RectTransform _arrowRectTransform;
|
||||||
|
private TextMeshProUGUI _contentText;
|
||||||
|
private GameObject _skipButtonObject;
|
||||||
|
private Action _skipAction;
|
||||||
|
|
||||||
|
public void Initialize(Transform parent, Action skipAction)
|
||||||
|
{
|
||||||
|
_skipAction = skipAction;
|
||||||
|
_rectTransform = GetComponent<RectTransform>();
|
||||||
|
if (!_rectTransform)
|
||||||
|
_rectTransform = gameObject.AddComponent<RectTransform>();
|
||||||
|
_rectTransform.SetParent(parent, false);
|
||||||
|
_rectTransform.sizeDelta = new Vector2(360f, 180f);
|
||||||
|
_rectTransform.anchorMin = new Vector2(0.5f, 0.5f);
|
||||||
|
_rectTransform.anchorMax = new Vector2(0.5f, 0.5f);
|
||||||
|
_rectTransform.pivot = new Vector2(0.5f, 0.5f);
|
||||||
|
|
||||||
|
var background = gameObject.AddComponent<Image>();
|
||||||
|
background.color = new Color(0.08f, 0.1f, 0.14f, 0.96f);
|
||||||
|
background.raycastTarget = true;
|
||||||
|
|
||||||
|
var content = CreateText("Content", "Tutorial", 24, TextAlignmentOptions.TopLeft);
|
||||||
|
var contentRect = content.rectTransform;
|
||||||
|
contentRect.SetParent(_rectTransform, false);
|
||||||
|
contentRect.anchorMin = new Vector2(0f, 0f);
|
||||||
|
contentRect.anchorMax = new Vector2(1f, 1f);
|
||||||
|
contentRect.offsetMin = new Vector2(24f, 64f);
|
||||||
|
contentRect.offsetMax = new Vector2(-24f, -24f);
|
||||||
|
_contentText = content;
|
||||||
|
|
||||||
|
var skipButton = new GameObject("SkipButton", typeof(RectTransform), typeof(Image), typeof(Button));
|
||||||
|
var skipRect = skipButton.GetComponent<RectTransform>();
|
||||||
|
skipRect.SetParent(_rectTransform, false);
|
||||||
|
skipRect.anchorMin = new Vector2(1f, 0f);
|
||||||
|
skipRect.anchorMax = new Vector2(1f, 0f);
|
||||||
|
skipRect.pivot = new Vector2(1f, 0f);
|
||||||
|
skipRect.anchoredPosition = new Vector2(-16f, 16f);
|
||||||
|
skipRect.sizeDelta = new Vector2(96f, 36f);
|
||||||
|
|
||||||
|
var skipImage = skipButton.GetComponent<Image>();
|
||||||
|
skipImage.color = new Color(0.22f, 0.34f, 0.5f, 1f);
|
||||||
|
|
||||||
|
var skipButtonComponent = skipButton.GetComponent<Button>();
|
||||||
|
skipButtonComponent.onClick.AddListener(() => _skipAction?.Invoke());
|
||||||
|
_skipButtonObject = skipButton;
|
||||||
|
|
||||||
|
var skipLabel = CreateText("Label", "Skip", 20, TextAlignmentOptions.Center);
|
||||||
|
var skipLabelRect = skipLabel.rectTransform;
|
||||||
|
skipLabelRect.SetParent(skipRect, false);
|
||||||
|
skipLabelRect.anchorMin = Vector2.zero;
|
||||||
|
skipLabelRect.anchorMax = Vector2.one;
|
||||||
|
skipLabelRect.offsetMin = Vector2.zero;
|
||||||
|
skipLabelRect.offsetMax = Vector2.zero;
|
||||||
|
|
||||||
|
var arrow = new GameObject("Arrow", typeof(RectTransform), typeof(Image));
|
||||||
|
_arrowRectTransform = arrow.GetComponent<RectTransform>();
|
||||||
|
_arrowRectTransform.SetParent(_rectTransform, false);
|
||||||
|
_arrowRectTransform.sizeDelta = new Vector2(20f, 20f);
|
||||||
|
_arrowRectTransform.localRotation = Quaternion.Euler(0f, 0f, 45f);
|
||||||
|
var arrowImage = arrow.GetComponent<Image>();
|
||||||
|
arrowImage.color = background.color;
|
||||||
|
arrowImage.raycastTarget = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Show(string text, Rect? targetRect, ShrinkTutorialDialogAnchor anchor, Vector2 offset, bool showArrow)
|
||||||
|
{
|
||||||
|
if (!_rectTransform)
|
||||||
|
return;
|
||||||
|
|
||||||
|
gameObject.SetActive(true);
|
||||||
|
_contentText.text = string.IsNullOrWhiteSpace(text) ? " " : text;
|
||||||
|
LayoutRebuilder.ForceRebuildLayoutImmediate(_rectTransform);
|
||||||
|
|
||||||
|
if (!targetRect.HasValue)
|
||||||
|
{
|
||||||
|
_rectTransform.anchoredPosition = offset;
|
||||||
|
_arrowRectTransform.gameObject.SetActive(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var canvasRect = _rectTransform.parent as RectTransform;
|
||||||
|
var targetCenterScreen = targetRect.Value.center;
|
||||||
|
RectTransformUtility.ScreenPointToLocalPointInRectangle(canvasRect, targetCenterScreen, null, out var targetCenterLocal);
|
||||||
|
var localTargetRect = ConvertScreenRectToLocalRect(canvasRect, targetRect.Value);
|
||||||
|
|
||||||
|
var resolvedAnchor = ResolveAnchor(localTargetRect, anchor, canvasRect.rect);
|
||||||
|
var panelSize = _rectTransform.rect.size;
|
||||||
|
var margin = 24f;
|
||||||
|
var position = targetCenterLocal + offset;
|
||||||
|
|
||||||
|
switch (resolvedAnchor)
|
||||||
|
{
|
||||||
|
case ShrinkTutorialDialogAnchor.Top:
|
||||||
|
position += new Vector2(0f, localTargetRect.height * 0.5f + panelSize.y * 0.5f + margin);
|
||||||
|
break;
|
||||||
|
case ShrinkTutorialDialogAnchor.Bottom:
|
||||||
|
position += new Vector2(0f, -(localTargetRect.height * 0.5f + panelSize.y * 0.5f + margin));
|
||||||
|
break;
|
||||||
|
case ShrinkTutorialDialogAnchor.Left:
|
||||||
|
position += new Vector2(-(localTargetRect.width * 0.5f + panelSize.x * 0.5f + margin), 0f);
|
||||||
|
break;
|
||||||
|
case ShrinkTutorialDialogAnchor.Right:
|
||||||
|
position += new Vector2(localTargetRect.width * 0.5f + panelSize.x * 0.5f + margin, 0f);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var halfCanvas = canvasRect.rect.size * 0.5f;
|
||||||
|
position.x = Mathf.Clamp(position.x, -halfCanvas.x + panelSize.x * 0.5f + 12f, halfCanvas.x - panelSize.x * 0.5f - 12f);
|
||||||
|
position.y = Mathf.Clamp(position.y, -halfCanvas.y + panelSize.y * 0.5f + 12f, halfCanvas.y - panelSize.y * 0.5f - 12f);
|
||||||
|
_rectTransform.anchoredPosition = position;
|
||||||
|
|
||||||
|
ConfigureArrow(resolvedAnchor, showArrow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Hide()
|
||||||
|
{
|
||||||
|
if (gameObject.activeSelf)
|
||||||
|
gameObject.SetActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ContainsScreenPoint(Vector2 screenPoint)
|
||||||
|
{
|
||||||
|
return _rectTransform && gameObject.activeInHierarchy &&
|
||||||
|
RectTransformUtility.RectangleContainsScreenPoint(_rectTransform, screenPoint, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetSkipVisible(bool visible)
|
||||||
|
{
|
||||||
|
if (_skipButtonObject)
|
||||||
|
_skipButtonObject.SetActive(visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ConfigureArrow(ShrinkTutorialDialogAnchor anchor, bool showArrow)
|
||||||
|
{
|
||||||
|
_arrowRectTransform.gameObject.SetActive(showArrow);
|
||||||
|
if (!showArrow)
|
||||||
|
return;
|
||||||
|
|
||||||
|
switch (anchor)
|
||||||
|
{
|
||||||
|
case ShrinkTutorialDialogAnchor.Top:
|
||||||
|
_arrowRectTransform.anchorMin = new Vector2(0.5f, 0f);
|
||||||
|
_arrowRectTransform.anchorMax = new Vector2(0.5f, 0f);
|
||||||
|
_arrowRectTransform.anchoredPosition = new Vector2(0f, -10f);
|
||||||
|
break;
|
||||||
|
case ShrinkTutorialDialogAnchor.Bottom:
|
||||||
|
_arrowRectTransform.anchorMin = new Vector2(0.5f, 1f);
|
||||||
|
_arrowRectTransform.anchorMax = new Vector2(0.5f, 1f);
|
||||||
|
_arrowRectTransform.anchoredPosition = new Vector2(0f, 10f);
|
||||||
|
break;
|
||||||
|
case ShrinkTutorialDialogAnchor.Left:
|
||||||
|
_arrowRectTransform.anchorMin = new Vector2(1f, 0.5f);
|
||||||
|
_arrowRectTransform.anchorMax = new Vector2(1f, 0.5f);
|
||||||
|
_arrowRectTransform.anchoredPosition = new Vector2(10f, 0f);
|
||||||
|
break;
|
||||||
|
case ShrinkTutorialDialogAnchor.Right:
|
||||||
|
_arrowRectTransform.anchorMin = new Vector2(0f, 0.5f);
|
||||||
|
_arrowRectTransform.anchorMax = new Vector2(0f, 0.5f);
|
||||||
|
_arrowRectTransform.anchoredPosition = new Vector2(-10f, 0f);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
_arrowRectTransform.gameObject.SetActive(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ShrinkTutorialDialogAnchor ResolveAnchor(Rect targetRect, ShrinkTutorialDialogAnchor preferredAnchor, Rect canvasRect)
|
||||||
|
{
|
||||||
|
if (preferredAnchor != ShrinkTutorialDialogAnchor.Auto)
|
||||||
|
return preferredAnchor;
|
||||||
|
|
||||||
|
var topSpace = canvasRect.yMax - targetRect.yMax;
|
||||||
|
var bottomSpace = targetRect.yMin - canvasRect.yMin;
|
||||||
|
if (topSpace >= 240f)
|
||||||
|
return ShrinkTutorialDialogAnchor.Top;
|
||||||
|
if (bottomSpace >= 240f)
|
||||||
|
return ShrinkTutorialDialogAnchor.Bottom;
|
||||||
|
|
||||||
|
var rightSpace = canvasRect.xMax - targetRect.xMax;
|
||||||
|
var leftSpace = targetRect.xMin - canvasRect.xMin;
|
||||||
|
return rightSpace >= leftSpace
|
||||||
|
? ShrinkTutorialDialogAnchor.Right
|
||||||
|
: ShrinkTutorialDialogAnchor.Left;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Rect ConvertScreenRectToLocalRect(RectTransform canvasRect, Rect screenRect)
|
||||||
|
{
|
||||||
|
RectTransformUtility.ScreenPointToLocalPointInRectangle(canvasRect,
|
||||||
|
new Vector2(screenRect.xMin, screenRect.yMin), null, out var minLocal);
|
||||||
|
RectTransformUtility.ScreenPointToLocalPointInRectangle(canvasRect,
|
||||||
|
new Vector2(screenRect.xMax, screenRect.yMax), null, out var maxLocal);
|
||||||
|
|
||||||
|
return Rect.MinMaxRect(
|
||||||
|
Mathf.Min(minLocal.x, maxLocal.x),
|
||||||
|
Mathf.Min(minLocal.y, maxLocal.y),
|
||||||
|
Mathf.Max(minLocal.x, maxLocal.x),
|
||||||
|
Mathf.Max(minLocal.y, maxLocal.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TextMeshProUGUI CreateText(string objectName, string text, float fontSize, TextAlignmentOptions alignment)
|
||||||
|
{
|
||||||
|
var textObject = new GameObject(objectName, typeof(RectTransform), typeof(TextMeshProUGUI));
|
||||||
|
var tmp = textObject.GetComponent<TextMeshProUGUI>();
|
||||||
|
tmp.text = text;
|
||||||
|
tmp.fontSize = fontSize;
|
||||||
|
tmp.alignment = alignment;
|
||||||
|
tmp.color = Color.white;
|
||||||
|
tmp.enableWordWrapping = true;
|
||||||
|
return tmp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9380f8798cf316c4d9a7d9d743037688
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UI;
|
||||||
|
|
||||||
|
namespace ShrinkTutorial
|
||||||
|
{
|
||||||
|
public class ShrinkTutorialMask : MaskableGraphic, ICanvasRaycastFilter
|
||||||
|
{
|
||||||
|
private Rect _holeRect;
|
||||||
|
private ShrinkTutorialMaskShape _shape;
|
||||||
|
private bool _hasHole;
|
||||||
|
|
||||||
|
public void SetHighlight(Rect screenRect, ShrinkTutorialMaskShape shape, float padding)
|
||||||
|
{
|
||||||
|
enabled = true;
|
||||||
|
raycastTarget = true;
|
||||||
|
|
||||||
|
var camera = GetEventCamera();
|
||||||
|
var minScreen = new Vector2(screenRect.xMin - padding, screenRect.yMin - padding);
|
||||||
|
var maxScreen = new Vector2(screenRect.xMax + padding, screenRect.yMax + padding);
|
||||||
|
|
||||||
|
RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, minScreen, camera, out var minLocal);
|
||||||
|
RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, maxScreen, camera, out var maxLocal);
|
||||||
|
|
||||||
|
_holeRect = Rect.MinMaxRect(
|
||||||
|
Mathf.Min(minLocal.x, maxLocal.x),
|
||||||
|
Mathf.Min(minLocal.y, maxLocal.y),
|
||||||
|
Mathf.Max(minLocal.x, maxLocal.x),
|
||||||
|
Mathf.Max(minLocal.y, maxLocal.y));
|
||||||
|
_shape = shape;
|
||||||
|
_hasHole = shape != ShrinkTutorialMaskShape.None;
|
||||||
|
SetVerticesDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearHighlight()
|
||||||
|
{
|
||||||
|
_hasHole = false;
|
||||||
|
_shape = ShrinkTutorialMaskShape.None;
|
||||||
|
_holeRect = default;
|
||||||
|
raycastTarget = false;
|
||||||
|
SetVerticesDirty();
|
||||||
|
enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
|
||||||
|
{
|
||||||
|
if (!_hasHole)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, screenPoint, eventCamera, out var localPoint);
|
||||||
|
if (_shape == ShrinkTutorialMaskShape.Circle)
|
||||||
|
{
|
||||||
|
var radius = Mathf.Min(_holeRect.width, _holeRect.height) * 0.5f;
|
||||||
|
return Vector2.SqrMagnitude(localPoint - _holeRect.center) > radius * radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !_holeRect.Contains(localPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPopulateMesh(VertexHelper vh)
|
||||||
|
{
|
||||||
|
vh.Clear();
|
||||||
|
|
||||||
|
var outer = rectTransform.rect;
|
||||||
|
if (!_hasHole)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var hole = ClampRect(_holeRect, outer);
|
||||||
|
|
||||||
|
if (hole.yMax < outer.yMax)
|
||||||
|
{
|
||||||
|
AddQuad(vh,
|
||||||
|
new Vector2(outer.xMin, hole.yMax),
|
||||||
|
new Vector2(outer.xMin, outer.yMax),
|
||||||
|
new Vector2(outer.xMax, outer.yMax),
|
||||||
|
new Vector2(outer.xMax, hole.yMax));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hole.yMin > outer.yMin)
|
||||||
|
{
|
||||||
|
AddQuad(vh,
|
||||||
|
new Vector2(outer.xMin, outer.yMin),
|
||||||
|
new Vector2(outer.xMin, hole.yMin),
|
||||||
|
new Vector2(outer.xMax, hole.yMin),
|
||||||
|
new Vector2(outer.xMax, outer.yMin));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hole.xMin > outer.xMin)
|
||||||
|
{
|
||||||
|
AddQuad(vh,
|
||||||
|
new Vector2(outer.xMin, hole.yMin),
|
||||||
|
new Vector2(outer.xMin, hole.yMax),
|
||||||
|
new Vector2(hole.xMin, hole.yMax),
|
||||||
|
new Vector2(hole.xMin, hole.yMin));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hole.xMax < outer.xMax)
|
||||||
|
{
|
||||||
|
AddQuad(vh,
|
||||||
|
new Vector2(hole.xMax, hole.yMin),
|
||||||
|
new Vector2(hole.xMax, hole.yMax),
|
||||||
|
new Vector2(outer.xMax, hole.yMax),
|
||||||
|
new Vector2(outer.xMax, hole.yMin));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Rect ClampRect(Rect source, Rect bounds)
|
||||||
|
{
|
||||||
|
return Rect.MinMaxRect(
|
||||||
|
Mathf.Clamp(source.xMin, bounds.xMin, bounds.xMax),
|
||||||
|
Mathf.Clamp(source.yMin, bounds.yMin, bounds.yMax),
|
||||||
|
Mathf.Clamp(source.xMax, bounds.xMin, bounds.xMax),
|
||||||
|
Mathf.Clamp(source.yMax, bounds.yMin, bounds.yMax));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddQuad(VertexHelper vh, Vector2 bottomLeft, Vector2 topLeft, Vector2 topRight, Vector2 bottomRight)
|
||||||
|
{
|
||||||
|
var startIndex = vh.currentVertCount;
|
||||||
|
vh.AddVert(bottomLeft, color, Vector2.zero);
|
||||||
|
vh.AddVert(topLeft, color, Vector2.up);
|
||||||
|
vh.AddVert(topRight, color, Vector2.one);
|
||||||
|
vh.AddVert(bottomRight, color, Vector2.right);
|
||||||
|
vh.AddTriangle(startIndex, startIndex + 1, startIndex + 2);
|
||||||
|
vh.AddTriangle(startIndex, startIndex + 2, startIndex + 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Camera GetEventCamera()
|
||||||
|
{
|
||||||
|
var currentCanvas = canvas ? canvas : GetComponentInParent<Canvas>();
|
||||||
|
if (!currentCanvas || currentCanvas.renderMode == RenderMode.ScreenSpaceOverlay)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return currentCanvas.worldCamera;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d5cadf3e241805441be468ff8eec081a
|
||||||
|
MonoImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
serializedVersion: 2
|
||||||
|
defaultReferences: []
|
||||||
|
executionOrder: 0
|
||||||
|
icon: {instanceID: 0}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: cb71f9aea6023824fa71640bc941fab6
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f112dafa4f195064aa296f97ed9f4810
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
%YAML 1.1
|
||||||
|
%TAG !u! tag:unity3d.com,2011:
|
||||||
|
--- !u!114 &11400000
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 0}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: 975bc7797d2338c42b237370d7ea7fcc, type: 3}
|
||||||
|
m_Name: ShrinkTutorialSettings
|
||||||
|
m_EditorClassIdentifier:
|
||||||
|
database: {fileID: 11400000, guid: 68d65a9e822a48940ae3bac1485b6663, type: 2}
|
||||||
|
autoCreateManager: 1
|
||||||
|
dontDestroyOnLoad: 1
|
||||||
|
canvasSortingOrder: 9999
|
||||||
|
maskColor: {r: 0, g: 0, b: 0, a: 0.72}
|
||||||
|
playerPrefsStorageKey: ShrinkTutorial.Progress
|
||||||
|
showSkipButton: 1
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: de01167f7d6accc47a6fe5f039e31e93
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
%YAML 1.1
|
||||||
|
%TAG !u! tag:unity3d.com,2011:
|
||||||
|
--- !u!114 &11400000
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 0}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: b0483b6d3c8490947a769ec2f8afdc34, type: 3}
|
||||||
|
m_Name: ShrinkTutorialSampleDatabase
|
||||||
|
m_EditorClassIdentifier:
|
||||||
|
tutorials:
|
||||||
|
- {fileID: 11400000, guid: 155d1ef556140b149bfe20bd35c266a4, type: 2}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 68d65a9e822a48940ae3bac1485b6663
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
%YAML 1.1
|
||||||
|
%TAG !u! tag:unity3d.com,2011:
|
||||||
|
--- !u!114 &11400000
|
||||||
|
MonoBehaviour:
|
||||||
|
m_ObjectHideFlags: 0
|
||||||
|
m_CorrespondingSourceObject: {fileID: 0}
|
||||||
|
m_PrefabInstance: {fileID: 0}
|
||||||
|
m_PrefabAsset: {fileID: 0}
|
||||||
|
m_GameObject: {fileID: 0}
|
||||||
|
m_Enabled: 1
|
||||||
|
m_EditorHideFlags: 0
|
||||||
|
m_Script: {fileID: 11500000, guid: 356e79bb4dd3ef04099347bbe1dbca2b, type: 3}
|
||||||
|
m_Name: ShrinkTutorialSampleTutorial
|
||||||
|
m_EditorClassIdentifier:
|
||||||
|
tutorialId: sample.shrink_tutorial.basic
|
||||||
|
priority: 100
|
||||||
|
prerequisiteIds: []
|
||||||
|
steps:
|
||||||
|
- targetMode: 1
|
||||||
|
targetPath: Tutorial Sample Canvas/OpenInventoryButton
|
||||||
|
anchorId:
|
||||||
|
waitForTarget: 1
|
||||||
|
waitTimeout: 5
|
||||||
|
completeCondition: 0
|
||||||
|
customEventName:
|
||||||
|
autoDelay: 0.5
|
||||||
|
dragTargetAnchorId:
|
||||||
|
localizedTextKey:
|
||||||
|
fallbackText: Click here to open the inventory panel.
|
||||||
|
dialogAnchor: 2
|
||||||
|
dialogOffset: {x: 0, y: 0}
|
||||||
|
maskShape: 1
|
||||||
|
maskPadding: 12
|
||||||
|
showArrow: 1
|
||||||
|
enterSignal:
|
||||||
|
exitSignal:
|
||||||
|
- targetMode: 1
|
||||||
|
targetPath: Tutorial Sample Canvas/InventoryPanel/SpawnRewardButton
|
||||||
|
anchorId:
|
||||||
|
waitForTarget: 1
|
||||||
|
waitTimeout: 5
|
||||||
|
completeCondition: 0
|
||||||
|
customEventName:
|
||||||
|
autoDelay: 0.5
|
||||||
|
dragTargetAnchorId:
|
||||||
|
localizedTextKey:
|
||||||
|
fallbackText: The inventory is open. Click 'Spawn Reward Item' to demonstrate
|
||||||
|
dynamic-anchor waiting.
|
||||||
|
dialogAnchor: 1
|
||||||
|
dialogOffset: {x: 0, y: 0}
|
||||||
|
maskShape: 1
|
||||||
|
maskPadding: 12
|
||||||
|
showArrow: 1
|
||||||
|
enterSignal:
|
||||||
|
exitSignal:
|
||||||
|
- targetMode: 2
|
||||||
|
targetPath:
|
||||||
|
anchorId: sample.reward.item
|
||||||
|
waitForTarget: 1
|
||||||
|
waitTimeout: 10
|
||||||
|
completeCondition: 2
|
||||||
|
customEventName: sample.reward.claimed
|
||||||
|
autoDelay: 0.5
|
||||||
|
dragTargetAnchorId:
|
||||||
|
localizedTextKey:
|
||||||
|
fallbackText: This button was created at runtime. Click it and the step will
|
||||||
|
complete via a custom event.
|
||||||
|
dialogAnchor: 1
|
||||||
|
dialogOffset: {x: 0, y: 0}
|
||||||
|
maskShape: 1
|
||||||
|
maskPadding: 12
|
||||||
|
showArrow: 1
|
||||||
|
enterSignal:
|
||||||
|
exitSignal:
|
||||||
|
- targetMode: 0
|
||||||
|
targetPath:
|
||||||
|
anchorId:
|
||||||
|
waitForTarget: 1
|
||||||
|
waitTimeout: 5
|
||||||
|
completeCondition: 1
|
||||||
|
customEventName:
|
||||||
|
autoDelay: 0.5
|
||||||
|
dragTargetAnchorId:
|
||||||
|
localizedTextKey:
|
||||||
|
fallbackText: Demo finished. You completed the static-target, dynamic-anchor,
|
||||||
|
and custom-event flow. Click anywhere to close.
|
||||||
|
dialogAnchor: 0
|
||||||
|
dialogOffset: {x: 0, y: 0}
|
||||||
|
maskShape: 0
|
||||||
|
maskPadding: 12
|
||||||
|
showArrow: 0
|
||||||
|
enterSignal:
|
||||||
|
exitSignal:
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 155d1ef556140b149bfe20bd35c266a4
|
||||||
|
NativeFormatImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
mainObjectFileID: 11400000
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "com.cneicy.shrink-tutorial",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"displayName": "ShrinkTutorial",
|
||||||
|
"description": "面向 Unity 的互动式新手引导系统,提供数据驱动教程、遮罩挖洞、动态锚点、步骤条件与运行时调度。",
|
||||||
|
"unity": "2022.3",
|
||||||
|
"dependencies": {
|
||||||
|
"com.unity.textmeshpro": "3.0.7",
|
||||||
|
"com.unity.ugui": "1.0.0",
|
||||||
|
"com.unity.modules.physics": "1.0.0"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"tutorial",
|
||||||
|
"guide",
|
||||||
|
"onboarding",
|
||||||
|
"ui",
|
||||||
|
"workflow"
|
||||||
|
],
|
||||||
|
"author": {
|
||||||
|
"name": "cneicy",
|
||||||
|
"url": "https://git.crash.work/ShrinkSDK"
|
||||||
|
},
|
||||||
|
"documentationUrl": "https://git.crash.work/ShrinkSDK/ShrinkTutorial",
|
||||||
|
"changelogUrl": "https://git.crash.work/ShrinkSDK/ShrinkTutorial/src/branch/main/CHANGELOG.md"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 51dc201111e264640b18d73fbfd830b7
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Reference in New Issue
Block a user