diff --git a/.gitea/workflows/publish-nuget.yml b/.gitea/workflows/publish-nuget.yml
new file mode 100644
index 0000000..06dc7b7
--- /dev/null
+++ b/.gitea/workflows/publish-nuget.yml
@@ -0,0 +1,52 @@
+name: Publish NuGet packages
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ env:
+ NUGET_AUTH_TOKEN: ${{ secrets.SHRINKSDK_PACKAGE_TOKEN }}
+ steps:
+ - name: Checkout tagged source
+ uses: actions/checkout@v4
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Validate tag and pack projects
+ env:
+ GITEA_REF: ${{ gitea.ref }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ tag="${GITEA_REF#refs/tags/}"
+ version="$(node -p "require('./package.json').version")"
+ test "$tag" = "v$version"
+ mkdir -p packages
+ mapfile -d '' projects < <(find DotNet~ Godot~ -type f -name '*.csproj' -print0 2>/dev/null || true)
+ test "${#projects[@]}" -gt 0
+ for project in "${projects[@]}"; do
+ dotnet restore "$project" --configfile NuGet.Config
+ dotnet pack "$project" --configuration Release --no-restore --output "$PWD/packages" --include-symbols --include-source
+ done
+ find packages -maxdepth 1 -name '*.nupkg' -type f | grep -q .
+
+ - name: Publish packages
+ shell: bash
+ run: |
+ set -euo pipefail
+ : "${NUGET_AUTH_TOKEN:?SHRINKSDK_PACKAGE_TOKEN is required}"
+ dotnet nuget push 'packages/*.nupkg' --api-key "$NUGET_AUTH_TOKEN" --source https://git.crash.work/api/packages/ShrinkSDK/nuget/index.json --skip-duplicate
+ if compgen -G 'packages/*.snupkg' > /dev/null; then
+ dotnet nuget push 'packages/*.snupkg' --api-key "$NUGET_AUTH_TOKEN" --source https://git.crash.work/api/packages/ShrinkSDK/nuget/index.json --skip-duplicate
+ fi
diff --git a/.gitignore b/.gitignore
index 246935d..c3b8764 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,11 @@
/Tools~/**/[Oo]bj/
*.user
*.DotSettings.user
+/DotNet~/**/[Bb]in/
+/DotNet~/**/[Oo]bj/
+/Godot~/**/[Bb]in/
+/Godot~/**/[Oo]bj/
+/artifacts/
+/packages/
+!DotNet~/**/*.csproj
+!Godot~/**/*.csproj
diff --git a/.npmignore b/.npmignore
index 700f03d..6a59c77 100644
--- a/.npmignore
+++ b/.npmignore
@@ -6,3 +6,7 @@ Tools~/
*.sln
*.user
*.DotSettings.user
+DotNet~/
+Godot~/
+NuGet.Config
+Directory.Build.props
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..43b188b
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,13 @@
+
+
+ latest
+ enable
+ true
+ true
+ ShrinkSDK
+ ShrinkSDK
+ https://git.crash.work/ShrinkSDK
+ true
+ snupkg
+
+
diff --git a/DotNet~/Runtime/ShrinkTutorialCore.cs b/DotNet~/Runtime/ShrinkTutorialCore.cs
new file mode 100644
index 0000000..886c88f
--- /dev/null
+++ b/DotNet~/Runtime/ShrinkTutorialCore.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Collections.Generic;
+
+namespace ShrinkTutorial;
+
+[Serializable]
+public sealed class ShrinkTutorialStep
+{
+ public string StepId = string.Empty;
+ public string Title = string.Empty;
+ public string Body = string.Empty;
+ public string Target = string.Empty;
+ public ShrinkTutorialTargetMode TargetMode;
+ public ShrinkTutorialCompleteCondition CompleteCondition = ShrinkTutorialCompleteCondition.ClickTarget;
+ public ShrinkTutorialDialogAnchor DialogAnchor = ShrinkTutorialDialogAnchor.Auto;
+ public ShrinkTutorialMaskShape MaskShape = ShrinkTutorialMaskShape.Rect;
+ public string CustomEventName = string.Empty;
+ public bool BlockInputOutsideTarget = true;
+}
+
+[Serializable]
+public sealed class ShrinkTutorialData
+{
+ public string TutorialId = string.Empty;
+ public List Steps = new();
+}
+
+public sealed class ShrinkTutorialRunner
+{
+ private readonly IShrinkTutorialStorage _storage;
+ private ShrinkTutorialProgress _progress;
+ public ShrinkTutorialRunner(IShrinkTutorialStorage storage)
+ {
+ _storage = storage ?? throw new ArgumentNullException(nameof(storage));
+ _progress = storage.Load() ?? new ShrinkTutorialProgress();
+ }
+ public ShrinkTutorialData Current { get; private set; }
+ public int StepIndex { get; private set; } = -1;
+ public ShrinkTutorialStep CurrentStep => Current != null && StepIndex >= 0 && StepIndex < Current.Steps.Count ? Current.Steps[StepIndex] : null;
+ public event Action StepChanged;
+ public event Action Completed;
+
+ public bool Start(ShrinkTutorialData tutorial)
+ {
+ if (tutorial == null || tutorial.Steps.Count == 0 || _progress.completedTutorials.Contains(tutorial.TutorialId)) return false;
+ Current = tutorial;
+ StepIndex = 0;
+ StepChanged?.Invoke(CurrentStep);
+ return true;
+ }
+
+ public bool CompleteStep(string customEventName = null)
+ {
+ var step = CurrentStep;
+ if (step == null) return false;
+ if (step.CompleteCondition == ShrinkTutorialCompleteCondition.CustomEvent &&
+ !string.Equals(step.CustomEventName, customEventName, StringComparison.Ordinal)) return false;
+ StepIndex++;
+ if (StepIndex < Current.Steps.Count) { StepChanged?.Invoke(CurrentStep); return true; }
+ _progress.completedTutorials.Add(Current.TutorialId);
+ _storage.Save(_progress);
+ var id = Current.TutorialId;
+ Current = null;
+ StepIndex = -1;
+ Completed?.Invoke(id);
+ return true;
+ }
+
+ public void Reset()
+ {
+ Current = null;
+ StepIndex = -1;
+ _storage.Reset();
+ _progress = new ShrinkTutorialProgress();
+ }
+}
diff --git a/DotNet~/ShrinkSDK.Tutorial.csproj b/DotNet~/ShrinkSDK.Tutorial.csproj
new file mode 100644
index 0000000..30af74f
--- /dev/null
+++ b/DotNet~/ShrinkSDK.Tutorial.csproj
@@ -0,0 +1,19 @@
+
+
+ netstandard2.1
+ false
+ ShrinkTutorial.Runtime
+ ShrinkTutorial
+ ShrinkSDK.Tutorial
+ 0.2.0
+ Engine-neutral ShrinkSDK tutorial state and step runtime.
+ disable
+
+
+
+
+
+
+
+
+
diff --git a/Godot~/ShrinkGodotTutorialOverlay.cs b/Godot~/ShrinkGodotTutorialOverlay.cs
new file mode 100644
index 0000000..be5aa04
--- /dev/null
+++ b/Godot~/ShrinkGodotTutorialOverlay.cs
@@ -0,0 +1,89 @@
+#nullable enable
+
+using Godot;
+
+namespace ShrinkTutorial.Godot;
+
+public partial class ShrinkGodotTutorialOverlay : Control
+{
+ private readonly Label _title = new();
+ private readonly Label _body = new();
+ private ShrinkTutorialRunner? _runner;
+ private Control? _target;
+
+ public override void _Ready()
+ {
+ SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
+ MouseFilter = MouseFilterEnum.Stop;
+ var panel = new PanelContainer { CustomMinimumSize = new Vector2(320, 100) };
+ panel.Position = new Vector2(24, 24);
+ var text = new VBoxContainer();
+ text.AddChild(_title);
+ text.AddChild(_body);
+ panel.AddChild(text);
+ AddChild(panel);
+ }
+
+ public void Bind(ShrinkTutorialRunner runner)
+ {
+ if (_runner != null) _runner.StepChanged -= ShowStep;
+ _runner = runner;
+ _runner.StepChanged += ShowStep;
+ if (_runner.CurrentStep != null) ShowStep(_runner.CurrentStep);
+ }
+
+ public override void _ExitTree()
+ {
+ if (_runner != null) _runner.StepChanged -= ShowStep;
+ _runner = null;
+ _target = null;
+ }
+
+ public override void _Process(double delta)
+ {
+ if (_runner?.CurrentStep is not { } step) return;
+ _target = ResolveTarget(step);
+ QueueRedraw();
+ }
+
+ public override void _Draw()
+ {
+ var viewport = GetViewportRect();
+ if (_target == null)
+ {
+ DrawRect(viewport, new Color(0, 0, 0, 0.72f));
+ return;
+ }
+ var hole = _target.GetGlobalRect().Grow(8);
+ var shade = new Color(0, 0, 0, 0.72f);
+ DrawRect(new Rect2(viewport.Position, new Vector2(viewport.Size.X, hole.Position.Y)), shade);
+ DrawRect(new Rect2(new Vector2(0, hole.End.Y), new Vector2(viewport.Size.X, viewport.Size.Y - hole.End.Y)), shade);
+ DrawRect(new Rect2(new Vector2(0, hole.Position.Y), new Vector2(hole.Position.X, hole.Size.Y)), shade);
+ DrawRect(new Rect2(new Vector2(hole.End.X, hole.Position.Y), new Vector2(viewport.Size.X - hole.End.X, hole.Size.Y)), shade);
+ }
+
+ public override void _GuiInput(InputEvent @event)
+ {
+ if (@event is not InputEventMouseButton { Pressed: true }) return;
+ var step = _runner?.CurrentStep;
+ if (step == null) return;
+ if (step.CompleteCondition == ShrinkTutorialCompleteCondition.AnyClick ||
+ step.CompleteCondition == ShrinkTutorialCompleteCondition.ClickTarget && _target?.GetGlobalRect().HasPoint(GetGlobalMousePosition()) == true)
+ _runner!.CompleteStep();
+ AcceptEvent();
+ }
+
+ private void ShowStep(ShrinkTutorialStep step)
+ {
+ _title.Text = step.Title;
+ _body.Text = step.Body;
+ Visible = true;
+ }
+
+ private Control? ResolveTarget(ShrinkTutorialStep step)
+ {
+ if (step.TargetMode == ShrinkTutorialTargetMode.Path) return GetTree().Root.GetNodeOrNull(step.Target);
+ if (step.TargetMode == ShrinkTutorialTargetMode.AnchorId) return GetTree().GetFirstNodeInGroup("shrink_tutorial_" + step.Target) as Control;
+ return null;
+ }
+}
diff --git a/Godot~/ShrinkSDK.Tutorial.Godot.csproj b/Godot~/ShrinkSDK.Tutorial.Godot.csproj
new file mode 100644
index 0000000..746e33c
--- /dev/null
+++ b/Godot~/ShrinkSDK.Tutorial.Godot.csproj
@@ -0,0 +1,15 @@
+
+
+ net8.0
+ ShrinkTutorial.Godot
+ ShrinkTutorial.Godot
+ ShrinkSDK.Tutorial.Godot
+ 0.1.0
+ Godot Control overlay and Resources for ShrinkSDK Tutorial.
+ false
+
+
+
+
+
+
diff --git a/NuGet.Config b/NuGet.Config
new file mode 100644
index 0000000..43e134b
--- /dev/null
+++ b/NuGet.Config
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index 2c02f3d..2ffbbcb 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,8 @@
一个面向 Unity 2022.3+ 的互动式引导系统,重点解决这几件事:
+共享教程状态与步骤运行时位于 `DotNet~`;Godot 4.6 的 `Resource`、`Control` 遮罩和输入适配位于 `Godot~`。Godot 项目安装 `ShrinkSDK.Tutorial.Godot`。
+
- 通过真实点击完成步骤,而不是纯文案翻页。
- 支持静态路径目标和动态锚点目标。
- 遮罩只屏蔽无关区域,目标区域可继续点击。