com.mavrodiev.playground 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/CHANGELOG.md.meta +7 -0
  3. package/Editor/Tutorials/ColourRangeCheck.cs +47 -0
  4. package/Editor/Tutorials/ColourRangeCheck.cs.meta +2 -0
  5. package/Editor/Tutorials/Mavrodiev.Tutorials.Editor.asmdef +16 -0
  6. package/Editor/Tutorials/Mavrodiev.Tutorials.Editor.asmdef.meta +7 -0
  7. package/Editor/Tutorials/PredictionParagraph.cs +108 -0
  8. package/Editor/Tutorials/PredictionParagraph.cs.meta +2 -0
  9. package/Editor/Tutorials/TryParagraph.cs +79 -0
  10. package/Editor/Tutorials/TryParagraph.cs.meta +2 -0
  11. package/Editor/Tutorials/TutorialActions.cs +36 -0
  12. package/Editor/Tutorials/TutorialActions.cs.meta +2 -0
  13. package/Editor/Tutorials/TutorialCheck.cs +14 -0
  14. package/Editor/Tutorials/TutorialCheck.cs.meta +2 -0
  15. package/Editor/Tutorials/TutorialDefinition.cs +136 -0
  16. package/Editor/Tutorials/TutorialDefinition.cs.meta +2 -0
  17. package/Editor/Tutorials/TutorialGenerator.cs +176 -0
  18. package/Editor/Tutorials/TutorialGenerator.cs.meta +2 -0
  19. package/Editor/Tutorials.meta +8 -0
  20. package/Editor.meta +8 -0
  21. package/LICENSE.md +9 -0
  22. package/LICENSE.md.meta +7 -0
  23. package/README.md +5 -2
  24. package/README.md.meta +7 -0
  25. package/Samples~/Tutorial-Test/Editor/TutorialTestDefinition.cs +43 -0
  26. package/Samples~/Tutorial-Test/Editor/TutorialTestDefinition.cs.meta +2 -0
  27. package/Samples~/Tutorial-Test/Editor.meta +8 -0
  28. package/Samples~/Tutorial-Test/Materials/M_Test_Floor.mat +139 -0
  29. package/Samples~/Tutorial-Test/Materials/M_Test_Floor.mat.meta +8 -0
  30. package/Samples~/Tutorial-Test/Materials/M_Test_Plinth.mat +139 -0
  31. package/Samples~/Tutorial-Test/Materials/M_Test_Plinth.mat.meta +8 -0
  32. package/Samples~/Tutorial-Test/Materials/M_Test_Stand.mat +139 -0
  33. package/Samples~/Tutorial-Test/Materials/M_Test_Stand.mat.meta +8 -0
  34. package/Samples~/Tutorial-Test/Materials/M_Train_Base-Colour-1.mat +139 -0
  35. package/Samples~/Tutorial-Test/Materials/M_Train_Base-Colour-1.mat.meta +8 -0
  36. package/Samples~/Tutorial-Test/Materials/M_Train_Base-Colour-2.mat +139 -0
  37. package/Samples~/Tutorial-Test/Materials/M_Train_Base-Colour-2.mat.meta +8 -0
  38. package/Samples~/Tutorial-Test/Materials/M_Train_Base-Colour-3.mat +139 -0
  39. package/Samples~/Tutorial-Test/Materials/M_Train_Base-Colour-3.mat.meta +8 -0
  40. package/Samples~/Tutorial-Test/Materials/M_Train_Base-Colour-4.mat +139 -0
  41. package/Samples~/Tutorial-Test/Materials/M_Train_Base-Colour-4.mat.meta +8 -0
  42. package/Samples~/Tutorial-Test/Materials.meta +8 -0
  43. package/Samples~/Tutorial-Test/Meshes/InfosStand.fbx +0 -0
  44. package/Samples~/Tutorial-Test/Meshes/InfosStand.fbx.meta +106 -0
  45. package/Samples~/Tutorial-Test/Meshes/Sphere-Smooth.asset +181 -0
  46. package/Samples~/Tutorial-Test/Meshes/Sphere-Smooth.asset.meta +8 -0
  47. package/Samples~/Tutorial-Test/Meshes.meta +8 -0
  48. package/Samples~/Tutorial-Test/Station-C.unity +3022 -0
  49. package/Samples~/Tutorial-Test/Station-C.unity.meta +7 -0
  50. package/Samples~/Tutorial-Test/Tutorial/Contents.asset +36 -0
  51. package/Samples~/Tutorial-Test/Tutorial/Contents.asset.meta +8 -0
  52. package/Samples~/Tutorial-Test/Tutorial/Tutorial-Actions.asset +14 -0
  53. package/Samples~/Tutorial-Test/Tutorial/Tutorial-Actions.asset.meta +8 -0
  54. package/Samples~/Tutorial-Test/Tutorial/Tutorial-Test.asset +504 -0
  55. package/Samples~/Tutorial-Test/Tutorial/Tutorial-Test.asset.meta +8 -0
  56. package/Samples~/Tutorial-Test/Tutorial.meta +8 -0
  57. package/package.json +20 -4
  58. package/package.json.meta +7 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,4 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 — 2026-11-03
4
+ First version: the Tutorial Test sample (one station with its in-editor tutorial) and the tutorial editor tools. Needs Unity 6.5 or newer (the Tutorial Framework 6.0).
@@ -0,0 +1,7 @@
1
+ fileFormatVersion: 2
2
+ guid: cfb9ee7d6ab91ef44876cdcdc5ad3250
3
+ TextScriptImporter:
4
+ externalObjects: {}
5
+ userData:
6
+ assetBundleName:
7
+ assetBundleVariant:
@@ -0,0 +1,47 @@
1
+ using System.Collections.Generic;
2
+ using UnityEngine;
3
+
4
+ namespace Mavrodiev.Tutorials.Editor
5
+ {
6
+ /// <summary>
7
+ /// Done when a colour property of every listed material has all three channels between Min and Max,
8
+ /// in sRGB values from 0 to 255 (as the colour picker shows them).
9
+ /// </summary>
10
+ public class ColourRangeCheck : TutorialCheck
11
+ {
12
+ public Material[] Materials = new Material[0];
13
+ [Tooltip("What to call each material in the status line (\"sphere 2\"); the material's name when empty.")]
14
+ public string[] Labels = new string[0];
15
+ public string Property = "_BaseColor";
16
+ public int Min = 32;
17
+ public int Max = 243;
18
+
19
+ bool InRange(float channel)
20
+ {
21
+ int value = Mathf.RoundToInt(channel * 255f);
22
+ return value >= Min && value <= Max;
23
+ }
24
+
25
+ public override bool Evaluate(out string status)
26
+ {
27
+ var outside = new List<string>();
28
+ for (int i = 0; i < Materials.Length; i++)
29
+ {
30
+ Material material = Materials[i];
31
+ string label = i < Labels.Length && !string.IsNullOrEmpty(Labels[i]) ? Labels[i] : material != null ? material.name : "(missing material)";
32
+ if (material == null || !material.HasProperty(Property))
33
+ {
34
+ outside.Add(label);
35
+ continue;
36
+ }
37
+ Color c = material.GetColor(Property);
38
+ if (!InRange(c.r) || !InRange(c.g) || !InRange(c.b))
39
+ outside.Add(label);
40
+ }
41
+ status = outside.Count == 0
42
+ ? $"All {Materials.Length} are between {Min} and {Max}."
43
+ : $"Still outside {Min} to {Max}: {string.Join(", ", outside)}.";
44
+ return outside.Count == 0;
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,2 @@
1
+ fileFormatVersion: 2
2
+ guid: 9db79303d16a16446960954df3b4ff9c
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "Mavrodiev.Tutorials.Editor",
3
+ "rootNamespace": "Mavrodiev.Tutorials.Editor",
4
+ "references": [ "Unity.Tutorials.Editor" ],
5
+ "includePlatforms": [ "Editor" ],
6
+ "excludePlatforms": [],
7
+ "allowUnsafeCode": false,
8
+ "overrideReferences": false,
9
+ "precompiledReferences": [],
10
+ "autoReferenced": true,
11
+ "defineConstraints": [],
12
+ "versionDefines": [
13
+ { "name": "com.unity.learn.iet-framework.authoring", "expression": "1.0.0", "define": "MAVRODIEV_TUTORIAL_AUTHORING" }
14
+ ],
15
+ "noEngineReferences": false
16
+ }
@@ -0,0 +1,7 @@
1
+ fileFormatVersion: 2
2
+ guid: a317af76b3780ca4b9dc79f196d248aa
3
+ AssemblyDefinitionImporter:
4
+ externalObjects: {}
5
+ userData:
6
+ assetBundleName:
7
+ assetBundleVariant:
@@ -0,0 +1,108 @@
1
+ using System;
2
+ using System.IO;
3
+ using Unity.Tutorials.Editor;
4
+ using Unity.Tutorials.Editor.Paragraphs;
5
+ using UnityEditor;
6
+ using UnityEngine;
7
+ using UnityEngine.UIElements;
8
+
9
+ namespace Mavrodiev.Tutorials.Editor
10
+ {
11
+ /// <summary>
12
+ /// A tutorial paragraph with a text box: the student writes a prediction, and Save writes it to
13
+ /// Assets/Predictions/<File>.md, one "## <Key>" section per prediction. With ShowOnly it shows
14
+ /// the saved answer instead (for the page that gives the answer).
15
+ /// </summary>
16
+ public class PredictionParagraph : ParagraphBase
17
+ {
18
+ public const string Folder = "Assets/Predictions";
19
+
20
+ [Tooltip("The file under Assets/Predictions/, without .md (one file per tutorial).")]
21
+ public string File;
22
+ [Tooltip("The section in that file (one per prediction).")]
23
+ public string Key;
24
+ [Tooltip("The question, shown above the text box and written above the answer in the file.")]
25
+ public string Question;
26
+ [Tooltip("Next stays disabled until an answer is saved.")]
27
+ public bool RequireAnswer;
28
+ [Tooltip("Show the saved answer, read-only, instead of the text box.")]
29
+ public bool ShowOnly;
30
+ [Tooltip("The page this paragraph sits on (set by the generator), redrawn after Save when RequireAnswer is on.")]
31
+ public TutorialPage Page;
32
+
33
+ string Path => $"{Folder}/{File}.md";
34
+
35
+ public override bool IsCompleted() => ShowOnly || !RequireAnswer || !string.IsNullOrWhiteSpace(Load());
36
+
37
+ public override VisualElement GetDisplayRoot()
38
+ {
39
+ var root = new VisualElement();
40
+ root.style.marginTop = 6;
41
+ root.style.marginBottom = 6;
42
+
43
+ if (ShowOnly)
44
+ {
45
+ string saved = Load();
46
+ root.Add(Wrapped("<b>Your prediction:</b> " + (string.IsNullOrWhiteSpace(saved) ? "<i>(none saved)</i>" : saved)));
47
+ return root;
48
+ }
49
+
50
+ if (!string.IsNullOrEmpty(Question))
51
+ root.Add(Wrapped($"<b>Predict:</b> {Question}"));
52
+ var field = new TextField { multiline = true, value = Load() };
53
+ field.style.minHeight = 60;
54
+ field.style.whiteSpace = WhiteSpace.Normal;
55
+ var status = Wrapped(string.IsNullOrWhiteSpace(field.value) ? "" : $"Saved in {Path}");
56
+ var save = new Button(() =>
57
+ {
58
+ Save(field.value);
59
+ status.text = $"Saved in {Path}";
60
+ if (RequireAnswer && Page != null)
61
+ Page.RaiseNonMaskingSettingsChanged(); // makes the window redraw, so Next unlocks
62
+ }) { text = "Save prediction" };
63
+
64
+ root.Add(field);
65
+ root.Add(save);
66
+ root.Add(status);
67
+ return root;
68
+ }
69
+
70
+ static Label Wrapped(string text)
71
+ {
72
+ var label = new Label(text);
73
+ label.style.whiteSpace = WhiteSpace.Normal;
74
+ return label;
75
+ }
76
+
77
+ string Load()
78
+ {
79
+ if (!System.IO.File.Exists(Path))
80
+ return "";
81
+ string[] lines = System.IO.File.ReadAllLines(Path);
82
+ int start = Array.IndexOf(lines, "## " + Key);
83
+ if (start < 0)
84
+ return "";
85
+ var answer = new System.Text.StringBuilder();
86
+ for (int i = start + 2; i < lines.Length && !lines[i].StartsWith("## "); i++)
87
+ answer.AppendLine(lines[i]);
88
+ return answer.ToString().Trim();
89
+ }
90
+
91
+ void Save(string answer)
92
+ {
93
+ Directory.CreateDirectory(Folder);
94
+ string section = $"## {Key}\n*{Question}*\n\n{answer.Trim()}\n\n";
95
+ string text = System.IO.File.Exists(Path) ? System.IO.File.ReadAllText(Path).Replace("\r\n", "\n") : $"# Predictions: {File}\n\n";
96
+ int start = text.IndexOf($"## {Key}\n", StringComparison.Ordinal);
97
+ if (start < 0)
98
+ text += section;
99
+ else
100
+ {
101
+ int end = text.IndexOf("\n## ", start + 1, StringComparison.Ordinal);
102
+ text = text.Substring(0, start) + section + (end < 0 ? "" : text.Substring(end + 1));
103
+ }
104
+ System.IO.File.WriteAllText(Path, text);
105
+ AssetDatabase.ImportAsset(Path);
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,2 @@
1
+ fileFormatVersion: 2
2
+ guid: a06b22c6ff07fca49832cf1ff940470b
@@ -0,0 +1,79 @@
1
+ using Unity.Tutorials.Editor;
2
+ using Unity.Tutorials.Editor.Paragraphs;
3
+ using UnityEngine;
4
+ using UnityEngine.UIElements;
5
+
6
+ namespace Mavrodiev.Tutorials.Editor
7
+ {
8
+ /// <summary>
9
+ /// A Try step: a title, the steps, and a status line that a TutorialCheck updates twice a second.
10
+ /// By default it never blocks Next (the student may move on before the step is done);
11
+ /// with Blocking on, Next stays disabled until the check passes.
12
+ /// </summary>
13
+ public class TryParagraph : ParagraphBase
14
+ {
15
+ public string Title = "Try";
16
+ [TextArea(3, 15)] public string Text;
17
+ [Tooltip("Optional. Without a check, the step shows no status line.")]
18
+ public TutorialCheck Check;
19
+ [Tooltip("Next stays disabled until the check passes.")]
20
+ public bool Blocking;
21
+ [Tooltip("The page this paragraph sits on (set by the generator), redrawn when a blocking check changes.")]
22
+ public TutorialPage Page;
23
+
24
+ static readonly Color Done = new Color(0.35f, 0.8f, 0.4f);
25
+ static readonly Color NotYet = new Color(0.65f, 0.65f, 0.65f);
26
+
27
+ bool m_LastDone;
28
+
29
+ public override bool IsCompleted()
30
+ {
31
+ if (!Blocking || Check == null)
32
+ return true;
33
+ return Check.Evaluate(out _);
34
+ }
35
+
36
+ public override VisualElement GetDisplayRoot()
37
+ {
38
+ var root = new VisualElement();
39
+ root.style.marginTop = 6;
40
+ root.style.marginBottom = 6;
41
+ root.style.paddingLeft = 8;
42
+ root.style.borderLeftWidth = 3;
43
+ root.style.borderLeftColor = NotYet;
44
+
45
+ root.Add(Wrapped($"<b>{Title}</b>"));
46
+ root.Add(Wrapped(Text));
47
+ if (Check == null)
48
+ return root;
49
+
50
+ var status = Wrapped("");
51
+ status.style.marginTop = 4;
52
+ root.Add(status);
53
+
54
+ void Update()
55
+ {
56
+ bool done = Check.Evaluate(out string text);
57
+ status.text = (done ? "✔ " : "○ ") + text;
58
+ status.style.color = done ? Done : NotYet;
59
+ root.style.borderLeftColor = done ? Done : NotYet;
60
+ bool changed = done != m_LastDone;
61
+ m_LastDone = done;
62
+ if (Blocking && changed && Page != null)
63
+ Page.RaiseNonMaskingSettingsChanged(); // makes the window redraw, so Next follows the check
64
+ }
65
+
66
+ m_LastDone = Check.Evaluate(out _);
67
+ Update();
68
+ root.schedule.Execute(Update).Every(500);
69
+ return root;
70
+ }
71
+
72
+ static Label Wrapped(string text)
73
+ {
74
+ var label = new Label(text);
75
+ label.style.whiteSpace = WhiteSpace.Normal;
76
+ return label;
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,2 @@
1
+ fileFormatVersion: 2
2
+ guid: a8abd1cbb83a66041a53effcda8e4c96
@@ -0,0 +1,36 @@
1
+ using UnityEditor;
2
+ using UnityEngine;
3
+
4
+ namespace Mavrodiev.Tutorials.Editor
5
+ {
6
+ /// <summary>
7
+ /// Methods a tutorial page calls when it opens (its Showing event). The generator writes one
8
+ /// asset of this type next to each sample's tutorials, because an event needs an object to call.
9
+ /// </summary>
10
+ public class TutorialActions : ScriptableObject
11
+ {
12
+ /// <summary>
13
+ /// Turns on the camera with this name and turns off every other camera, so the Game view shows
14
+ /// the station; selects it and aligns the Scene view to it.
15
+ /// </summary>
16
+ public void ShowCamera(string cameraName)
17
+ {
18
+ Camera target = null;
19
+ foreach (Camera camera in Object.FindObjectsByType<Camera>(FindObjectsInactive.Include))
20
+ {
21
+ bool on = camera.name == cameraName;
22
+ camera.enabled = on;
23
+ if (on)
24
+ target = camera;
25
+ }
26
+ if (target == null)
27
+ {
28
+ Debug.LogWarning($"Tutorial: no camera named '{cameraName}' in the open scene.");
29
+ return;
30
+ }
31
+ Selection.activeGameObject = target.gameObject;
32
+ if (SceneView.lastActiveSceneView != null)
33
+ SceneView.lastActiveSceneView.AlignViewToObject(target.transform);
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,2 @@
1
+ fileFormatVersion: 2
2
+ guid: 987706d7848530d4cb673c0d2e2a08bd
@@ -0,0 +1,14 @@
1
+ using UnityEngine;
2
+
3
+ namespace Mavrodiev.Tutorials.Editor
4
+ {
5
+ /// <summary>
6
+ /// A check of the scene or its assets, shown live under a Try step (see TryParagraph).
7
+ /// Subclass it once per kind of check; the generator stores each one inside the tutorial asset.
8
+ /// </summary>
9
+ public abstract class TutorialCheck : ScriptableObject
10
+ {
11
+ /// <summary>True when the step is done. Status is one short line for the student, done or not.</summary>
12
+ public abstract bool Evaluate(out string status);
13
+ }
14
+ }
@@ -0,0 +1,2 @@
1
+ fileFormatVersion: 2
2
+ guid: e6dab61ab1252cc4c93c0d81aa029969
@@ -0,0 +1,136 @@
1
+ using System.Collections.Generic;
2
+ using Unity.Tutorials.Editor;
3
+ using Unity.Tutorials.Editor.Paragraphs;
4
+ using UnityEditor;
5
+ using UnityEngine;
6
+
7
+ namespace Mavrodiev.Tutorials.Editor
8
+ {
9
+ /// <summary>
10
+ /// One tutorial, written as C# data. Subclass it in a sample's Editor/ folder; TutorialGenerator
11
+ /// finds every subclass and writes its assets into the sample's Tutorial/ folder.
12
+ /// </summary>
13
+ public abstract class TutorialDefinition
14
+ {
15
+ /// <summary>Divider between sections of one page.</summary>
16
+ public const string LINE = "────────────────────────────────────";
17
+
18
+ /// <summary>File name: Tutorial/&lt;Id&gt;.asset, and the predictions file Assets/Predictions/&lt;Id&gt;.md.</summary>
19
+ public abstract string Id { get; }
20
+ /// <summary>The title in the tutorial window and on the contents card.</summary>
21
+ public abstract string Title { get; }
22
+ /// <summary>The text on the contents card.</summary>
23
+ public virtual string Description => "";
24
+ /// <summary>The scene the tutorial opens, as a path inside the sample folder ("Materials-Training.unity"); null: it opens none and stays in the active scene.</summary>
25
+ public virtual string Scene => null;
26
+ /// <summary>The card's place on the sample's contents page.</summary>
27
+ public virtual int Order => 0;
28
+ /// <summary>The sample's contents page; when a sample has several tutorials, the first by Order sets it.</summary>
29
+ public virtual string ContentsTitle => Title;
30
+ public virtual string ContentsSubtitle => "";
31
+
32
+ /// <summary>The pages, in order. Build them with the helpers below.</summary>
33
+ public abstract IEnumerable<Page> Pages();
34
+
35
+ /// <summary>The sample folder ("Assets/_Samples/playground/Materials"), set by the generator before Pages().</summary>
36
+ public string Folder { get; internal set; }
37
+
38
+ /// <summary>Loads an asset by its path inside the sample folder.</summary>
39
+ protected T Load<T>(string pathInSample) where T : Object => AssetDatabase.LoadAssetAtPath<T>($"{Folder}/{pathInSample}");
40
+
41
+ // ── building blocks ──────────────────────────────────────────────────
42
+
43
+ public class Page
44
+ {
45
+ public string Title;
46
+ public string Camera; // camera switched to when the page opens, or null
47
+ public Block[] Blocks;
48
+
49
+ /// <summary>Switches to this camera (Game view, selection and Scene view) when the page opens.</summary>
50
+ public Page ShowCamera(string cameraName)
51
+ {
52
+ Camera = cameraName;
53
+ return this;
54
+ }
55
+ }
56
+
57
+ /// <summary>Makes one paragraph of a page. The generator calls it with the page being built.</summary>
58
+ public delegate ParagraphBase Block(Build build);
59
+
60
+ /// <summary>What a Block gets: the tutorial and page being written.</summary>
61
+ public class Build
62
+ {
63
+ public TutorialDefinition Definition;
64
+ public Tutorial Tutorial;
65
+ public TutorialPage Page;
66
+
67
+ /// <summary>Stores an object (a check, a criterion) inside the tutorial asset.</summary>
68
+ public T Store<T>(T obj) where T : Object
69
+ {
70
+ if (obj != null && !AssetDatabase.Contains(obj))
71
+ {
72
+ obj.name = obj.GetType().Name;
73
+ obj.hideFlags |= HideFlags.HideInHierarchy;
74
+ AssetDatabase.AddObjectToAsset(obj, Tutorial);
75
+ }
76
+ return obj;
77
+ }
78
+ }
79
+
80
+ protected static Page NewPage(string title, params Block[] blocks) => new Page { Title = title, Blocks = blocks };
81
+
82
+ /// <summary>A block of text; rich text (&lt;b&gt;, &lt;i&gt;), \n and • work.</summary>
83
+ protected static Block Text(string text) => b =>
84
+ {
85
+ var paragraph = ScriptableObject.CreateInstance<NarrativeParagraph>();
86
+ paragraph.Text = text;
87
+ return paragraph;
88
+ };
89
+
90
+ /// <summary>The question and a text box; Save writes the answer to Assets/Predictions/&lt;Id&gt;.md under "## key".</summary>
91
+ protected static Block Predict(string key, string question, bool requireAnswer = false) => b =>
92
+ {
93
+ var paragraph = ScriptableObject.CreateInstance<PredictionParagraph>();
94
+ paragraph.File = b.Definition.Id;
95
+ paragraph.Key = key;
96
+ paragraph.Question = question;
97
+ paragraph.RequireAnswer = requireAnswer;
98
+ paragraph.Page = b.Page;
99
+ return paragraph;
100
+ };
101
+
102
+ /// <summary>Shows what the student saved for this key ("Your prediction: …").</summary>
103
+ protected static Block YourPrediction(string key) => b =>
104
+ {
105
+ var paragraph = ScriptableObject.CreateInstance<PredictionParagraph>();
106
+ paragraph.File = b.Definition.Id;
107
+ paragraph.Key = key;
108
+ paragraph.ShowOnly = true;
109
+ return paragraph;
110
+ };
111
+
112
+ /// <summary>A Try step: title, steps, and an optional live check. Never blocks Next unless blocking is true.</summary>
113
+ protected static Block Try(string title, string steps, TutorialCheck check = null, bool blocking = false) => b =>
114
+ {
115
+ var paragraph = ScriptableObject.CreateInstance<TryParagraph>();
116
+ paragraph.Title = title;
117
+ paragraph.Text = steps;
118
+ paragraph.Check = b.Store(check);
119
+ paragraph.Blocking = blocking;
120
+ paragraph.Page = b.Page;
121
+ return paragraph;
122
+ };
123
+
124
+ /// <summary>
125
+ /// The four pages of one station (Playground-Design § Presentation 2): the model with the camera switch,
126
+ /// Predict, Try, and the answer next to the student's own prediction.
127
+ /// </summary>
128
+ protected static IEnumerable<Page> Station(string key, string title, string camera, string model, string question, Block tryStep, string answer)
129
+ {
130
+ yield return NewPage($"{key} · {title}", Text($"<b>The model:</b> {model}")).ShowCamera(camera);
131
+ yield return NewPage($"{key} · Predict", Predict(key, question));
132
+ yield return NewPage($"{key} · Try", tryStep);
133
+ yield return NewPage($"{key} · Answer", YourPrediction(key), Text($"<b>Answer:</b> {answer}"));
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,2 @@
1
+ fileFormatVersion: 2
2
+ guid: 8c276b766bf2f7e48aab866f2a2e5488
@@ -0,0 +1,176 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using System.Linq;
4
+ using Unity.Tutorials.Editor;
5
+ using Unity.Tutorials.Editor.Paragraphs;
6
+ using UnityEditor;
7
+ using UnityEditor.Events;
8
+ using UnityEngine;
9
+ using UnityEngine.Events;
10
+ using Object = UnityEngine.Object;
11
+
12
+ namespace Mavrodiev.Tutorials.Editor
13
+ {
14
+ /// <summary>
15
+ /// Writes the tutorial assets of every TutorialDefinition in the project, into its sample's Tutorial/ folder:
16
+ /// &lt;Id&gt;.asset (the tutorial, with its pages inside), Contents.asset (the sample's contents page) and
17
+ /// Tutorial-Actions.asset (what the pages call). Never edit these by hand: change the definition and regenerate.
18
+ /// The menu shows only where the authoring package is installed (the Workshop), not in students' projects.
19
+ /// </summary>
20
+ public static class TutorialGenerator
21
+ {
22
+ public const string OutputFolder = "Tutorial";
23
+
24
+ #if MAVRODIEV_TUTORIAL_AUTHORING
25
+ [MenuItem("Tutorials/Authoring/Generate Mavrodiev Tutorials")]
26
+ #endif
27
+ static void GenerateFromMenu() => Debug.Log(GenerateAll());
28
+
29
+ /// <summary>Regenerates every tutorial; returns one line per tutorial written.</summary>
30
+ public static string GenerateAll()
31
+ {
32
+ var report = new System.Text.StringBuilder();
33
+ var byFolder = new Dictionary<string, List<TutorialDefinition>>();
34
+ foreach (Type type in TypeCache.GetTypesDerivedFrom<TutorialDefinition>().Where(t => !t.IsAbstract))
35
+ {
36
+ string folder = SampleFolderOf(type);
37
+ if (folder == null)
38
+ {
39
+ report.AppendLine($"Skipped {type.Name}: its script must be in a sample's Editor/ folder, in a file named {type.Name}.cs.");
40
+ continue;
41
+ }
42
+ var definition = (TutorialDefinition)Activator.CreateInstance(type);
43
+ definition.Folder = folder;
44
+ if (!byFolder.TryGetValue(folder, out var list))
45
+ byFolder[folder] = list = new List<TutorialDefinition>();
46
+ list.Add(definition);
47
+ }
48
+
49
+ foreach (var pair in byFolder)
50
+ {
51
+ string output = $"{pair.Key}/{OutputFolder}";
52
+ if (!AssetDatabase.IsValidFolder(output))
53
+ AssetDatabase.CreateFolder(pair.Key, OutputFolder);
54
+ var actions = LoadOrCreate<TutorialActions>($"{output}/Tutorial-Actions.asset");
55
+ var ordered = pair.Value.OrderBy(d => d.Order).ToList();
56
+ var tutorials = ordered.Select(d => Write(d, output, actions, report)).ToList();
57
+ WriteContents(ordered, tutorials, output);
58
+ }
59
+ AssetDatabase.SaveAssets();
60
+ return report.Length > 0 ? report.ToString() : "No tutorial definitions found.";
61
+ }
62
+
63
+ static Tutorial Write(TutorialDefinition definition, string output, TutorialActions actions, System.Text.StringBuilder report)
64
+ {
65
+ var tutorial = LoadOrCreate<Tutorial>($"{output}/{definition.Id}.asset");
66
+ ClearSubAssets(tutorial);
67
+
68
+ tutorial.TutorialTitle = definition.Title;
69
+ if (string.IsNullOrEmpty(definition.Scene))
70
+ {
71
+ tutorial.SceneManagementBehavior = Tutorial.SceneManagementBehaviorType.UseActiveScene;
72
+ tutorial.Scenes = new SceneAsset[0];
73
+ }
74
+ else
75
+ {
76
+ var scene = AssetDatabase.LoadAssetAtPath<SceneAsset>($"{definition.Folder}/{definition.Scene}");
77
+ if (scene == null)
78
+ report.AppendLine($" {definition.Id}: scene '{definition.Scene}' not found in {definition.Folder}.");
79
+ tutorial.SceneManagementBehavior = Tutorial.SceneManagementBehaviorType.LoadScenes;
80
+ tutorial.Scenes = new[] { scene };
81
+ }
82
+ // Keep true: with false, Tutorial Framework 6.0.7 throws an ArgumentNullException when the tutorial ends.
83
+ tutorial.ReturnToPreviousScenes = true;
84
+ tutorial.ProgressTrackingEnabled = true;
85
+ if (string.IsNullOrEmpty(tutorial.LessonId))
86
+ tutorial.LessonId = Guid.NewGuid().ToString("N");
87
+
88
+ var pages = new List<TutorialPage>();
89
+ foreach (TutorialDefinition.Page def in definition.Pages())
90
+ {
91
+ int index = pages.Count;
92
+ var page = ScriptableObject.CreateInstance<TutorialPage>();
93
+ page.Title = def.Title;
94
+ page.name = $"{index}_{new string(def.Title.Where(c => char.IsLetterOrDigit(c) || c == ' ' || c == '-').ToArray())}";
95
+ AssetDatabase.AddObjectToAsset(page, tutorial);
96
+ var so = new SerializedObject(page);
97
+ so.FindProperty("<IndexInTutorial>k__BackingField").intValue = index;
98
+ so.ApplyModifiedPropertiesWithoutUndo();
99
+
100
+ var build = new TutorialDefinition.Build { Definition = definition, Tutorial = tutorial, Page = page };
101
+ foreach (TutorialDefinition.Block block in def.Blocks)
102
+ {
103
+ ParagraphBase paragraph = block(build);
104
+ paragraph.name = paragraph.GetType().Name;
105
+ paragraph.hideFlags |= HideFlags.HideInHierarchy;
106
+ AssetDatabase.AddObjectToAsset(paragraph, tutorial);
107
+ page.Paragraphs.Add(paragraph);
108
+ }
109
+ if (!string.IsNullOrEmpty(def.Camera))
110
+ {
111
+ UnityEventTools.AddStringPersistentListener(page.Showing, actions.ShowCamera, def.Camera);
112
+ // Added listeners default to Runtime Only, which never fires in the Editor.
113
+ page.Showing.SetPersistentListenerState(page.Showing.GetPersistentEventCount() - 1, UnityEventCallState.EditorAndRuntime);
114
+ }
115
+ EditorUtility.SetDirty(page);
116
+ pages.Add(page);
117
+ }
118
+ tutorial.PagesCollection.SetItems(pages);
119
+ EditorUtility.SetDirty(tutorial);
120
+ report.AppendLine($"{definition.Id}: {pages.Count} pages → {output}/{definition.Id}.asset");
121
+ return tutorial;
122
+ }
123
+
124
+ static void WriteContents(List<TutorialDefinition> definitions, List<Tutorial> tutorials, string output)
125
+ {
126
+ var contents = LoadOrCreate<TutorialContainer>($"{output}/Contents.asset");
127
+ contents.Title = definitions[0].ContentsTitle;
128
+ contents.Subtitle = definitions[0].ContentsSubtitle;
129
+ contents.Sections = definitions.Select((d, i) => new TutorialContainer.Section
130
+ {
131
+ Heading = d.Title,
132
+ Text = d.Description,
133
+ Type = TutorialContainer.SectionType.Tutorial,
134
+ Tutorial = tutorials[i],
135
+ }).ToArray();
136
+ EditorUtility.SetDirty(contents);
137
+ }
138
+
139
+ // The sample folder is the parent of the Editor/ folder that holds the definition's script.
140
+ static string SampleFolderOf(Type type)
141
+ {
142
+ foreach (string guid in AssetDatabase.FindAssets($"{type.Name} t:MonoScript", new[] { "Assets" }))
143
+ {
144
+ string path = AssetDatabase.GUIDToAssetPath(guid);
145
+ var script = AssetDatabase.LoadAssetAtPath<MonoScript>(path);
146
+ if (script == null || script.GetClass() != type)
147
+ continue;
148
+ string folder = path.Substring(0, path.LastIndexOf('/'));
149
+ return folder.EndsWith("/Editor") ? folder.Substring(0, folder.Length - "/Editor".Length) : null;
150
+ }
151
+ return null;
152
+ }
153
+
154
+ // Reuses the asset if it exists, so its ID (and every reference to it) survives regeneration.
155
+ static T LoadOrCreate<T>(string path) where T : ScriptableObject
156
+ {
157
+ var asset = AssetDatabase.LoadAssetAtPath<T>(path);
158
+ if (asset != null)
159
+ return asset;
160
+ asset = ScriptableObject.CreateInstance<T>();
161
+ AssetDatabase.CreateAsset(asset, path);
162
+ return asset;
163
+ }
164
+
165
+ static void ClearSubAssets(Object main)
166
+ {
167
+ foreach (Object sub in AssetDatabase.LoadAllAssetsAtPath(AssetDatabase.GetAssetPath(main)))
168
+ {
169
+ if (sub == null || sub == main)
170
+ continue;
171
+ AssetDatabase.RemoveObjectFromAsset(sub);
172
+ Object.DestroyImmediate(sub, true);
173
+ }
174
+ }
175
+ }
176
+ }
@@ -0,0 +1,2 @@
1
+ fileFormatVersion: 2
2
+ guid: f913802eb03fe214bbdc2ee4cb14760b