implemented skill system according to designdocuments

This commit is contained in:
2026-08-15 08:09:29 +02:00
parent f8def361fa
commit 063e94f6eb
95 changed files with 2924 additions and 4 deletions
@@ -14,9 +14,29 @@ Bouncer: Evening. Name on the list?
You: Depends who's asking.
-> I'm on the list.
<<set $claimed_to_be_on_list = true>>
Bouncer: ...you're not on the list.
Bouncer: But it's slow tonight. Go on.
<<set $went_inside = true>>
<<check persuasion standard>>
<<if $check_result>>
Bouncer: ...you're not on the list.
Bouncer: But it's slow tonight. Go on.
<<set $went_inside = true>>
<<else>>
Bouncer: Nobody's on the list tonight. Especially not you.
<<set $went_inside = false>>
<<endif>>
-> Let me through. <<if skill_rank("intimidation") >= 2>>
<<check intimidation hard -1>>
<<if $check_degree == "critical_success">>
Bouncer: ...yeah. Yeah, okay.
<<set $reputation += 2>>
<<set $went_inside = true>>
<<elseif $check_result>>
Bouncer: Fine. Don't make it a thing.
<<set $went_inside = true>>
<<else>>
Bouncer: Try that again and you're talking to the pavement.
<<set $reputation -= 1>>
<<set $went_inside = false>>
<<endif>>
-> Actually, I'll head home.
Bouncer: Smart choice.
<<set $went_inside = false>>
@@ -0,0 +1,18 @@
// Passive skill commentary. Fired by CommentarySystem, never <<jump>>ed to.
// One node per (context, skill) pair; if a skill has nothing to say about a
// context, simply omit the node and it won't be considered.
title: Commentary_Entrance_Queue_paranoia
---
Paranoia: The man three back hasn't looked at his phone once. Nobody waits like that.
===
title: Commentary_Entrance_Queue_read_room
---
Read the Room: Everyone here is pretending they're not cold.
===
title: Commentary_Entrance_Queue_streetwise
---
Streetwise: Door's been "slow tonight" for forty minutes. That's a price, not a queue.
===
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 26e9b5dea1744e39a5fe59bb1bc20fa7
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 11500000, guid: 94073015eacc34c1d8fc6786e43d60ca, type: 3}
@@ -18,4 +18,13 @@ title: Declarations
// Smart variable: derived, not stored. Can never drift out of sync with
// $reputation, so there's nothing to remember to update.
<<declare $is_vip = $reputation > 10>>
// Written by <<check>> (YarnSkillCommands). Never <<set>> these by hand.
<<declare $check_result = false as bool>>
<<declare $check_roll = 0 as number>>
<<declare $check_total = 0 as number>>
<<declare $check_dc = 0 as number>>
<<declare $check_modifier = 0 as number>>
<<declare $check_degree = "" as string>>
<<declare $check_skill = "" as string>>
===
@@ -0,0 +1,22 @@
// Scratch node for verifying the skill-check plumbing. Not part of the story.
// Point DialogueRunner.startNode at "Debug_SkillCheck" to run it, then set it
// back to "Start" when you're done.
title: Debug_SkillCheck
---
Narrator: Skill check harness.
<<check persuasion standard>>
<<if $check_result>>
Narrator: Passed. Rolled {$check_roll}, total {$check_total} against DC {$check_dc}.
<<else>>
Narrator: Failed. Rolled {$check_roll}, total {$check_total} against DC {$check_dc}.
<<endif>>
// Explicit numeric DC plus a situational modifier.
<<check deduction 16 2>>
Narrator: Deduction came out {$check_degree}.
// Deliberate typo: should log an error and continue, not deadlock.
<<check nosuchskill standard>>
Narrator: Survived the bad skill id.
===
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: e497c84fe7bd48ea8c828ac1702f79bf
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 11500000, guid: 94073015eacc34c1d8fc6786e43d60ca, type: 3}
@@ -0,0 +1,273 @@
using System.Collections.Generic;
using System.IO;
using NightclubArcadia.Skills;
using TMPro;
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.UI;
using Yarn.Unity;
/// <summary>
/// Creates SkillDefinition assets, SkillDatabase, and wires SkillRuntime + roll
/// banner + commentary into Assets/Scenes/DialogueTest.unity.
/// </summary>
public static class SkillSystemSetup
{
const string DefinitionsFolder = "Assets/Skills/Definitions";
const string SkillsFolder = "Assets/Skills";
const string DatabasePath = "Assets/Skills/SkillDatabase.asset";
const string ScenePath = "Assets/Scenes/DialogueTest.unity";
const string AutoSetupSessionKey = "NightclubArcadia.SkillSystem.AutoSetupDone";
struct SkillSpec
{
public string Id;
public string DisplayName;
public SkillDomain Domain;
public Color Accent;
public int StartingRank;
}
static readonly SkillSpec[] Roster =
{
new SkillSpec { Id = "deduction", DisplayName = "Deduction", Domain = SkillDomain.Reason, Accent = new Color(0.55f, 0.75f, 1f), StartingRank = 1 },
new SkillSpec { Id = "streetwise", DisplayName = "Streetwise", Domain = SkillDomain.Reason, Accent = new Color(0.7f, 0.85f, 0.55f), StartingRank = 1 },
new SkillSpec { Id = "recall", DisplayName = "Recall", Domain = SkillDomain.Reason, Accent = new Color(0.65f, 0.7f, 1f), StartingRank = 1 },
new SkillSpec { Id = "read_room", DisplayName = "Read the Room", Domain = SkillDomain.Instinct, Accent = new Color(1f, 0.75f, 0.45f), StartingRank = 1 },
new SkillSpec { Id = "paranoia", DisplayName = "Paranoia", Domain = SkillDomain.Instinct, Accent = new Color(1f, 0.45f, 0.55f), StartingRank = 1 },
new SkillSpec { Id = "reflexes", DisplayName = "Reflexes", Domain = SkillDomain.Instinct, Accent = new Color(1f, 0.9f, 0.4f), StartingRank = 1 },
new SkillSpec { Id = "persuasion", DisplayName = "Persuasion", Domain = SkillDomain.Presence, Accent = new Color(0.85f, 0.65f, 1f), StartingRank = 1 },
// Rank 2 so the Bouncer "Let me through" option gated by skill_rank >= 2 is visible in the demo.
new SkillSpec { Id = "intimidation", DisplayName = "Intimidation", Domain = SkillDomain.Presence, Accent = new Color(1f, 0.4f, 0.4f), StartingRank = 2 },
new SkillSpec { Id = "rapport", DisplayName = "Rapport", Domain = SkillDomain.Presence, Accent = new Color(0.55f, 0.95f, 0.85f), StartingRank = 1 },
new SkillSpec { Id = "endurance", DisplayName = "Endurance", Domain = SkillDomain.Body, Accent = new Color(0.85f, 0.7f, 0.5f), StartingRank = 1 },
new SkillSpec { Id = "sleight_of_hand", DisplayName = "Sleight of Hand", Domain = SkillDomain.Body, Accent = new Color(0.7f, 0.9f, 0.95f), StartingRank = 1 },
new SkillSpec { Id = "nerve", DisplayName = "Nerve", Domain = SkillDomain.Body, Accent = new Color(0.95f, 0.6f, 0.35f), StartingRank = 1 },
};
[DidReloadScripts]
static void AutoSetupAfterReload()
{
if (EditorApplication.isPlayingOrWillChangePlaymode)
{
return;
}
if (SessionState.GetBool(AutoSetupSessionKey, false))
{
return;
}
// Defer one frame so AssetDatabase is fully ready after domain reload.
EditorApplication.delayCall += () =>
{
if (SessionState.GetBool(AutoSetupSessionKey, false))
{
return;
}
SessionState.SetBool(AutoSetupSessionKey, true);
EnsureSkillAssetsOnly();
};
}
/// <summary>
/// Creates/updates ScriptableObject assets without opening a scene.
/// Safe to run on domain reload. Scene wiring stays on the menu item /
/// the checked-in DialogueTest.unity Skill System objects.
/// </summary>
static void EnsureSkillAssetsOnly()
{
EnsureFolder("Assets", "Skills");
EnsureFolder("Assets/Skills", "Definitions");
var definitions = new List<SkillDefinition>();
foreach (var spec in Roster)
{
definitions.Add(CreateOrUpdateDefinition(spec));
}
var database = AssetDatabase.LoadAssetAtPath<SkillDatabase>(DatabasePath);
if (database == null)
{
database = ScriptableObject.CreateInstance<SkillDatabase>();
AssetDatabase.CreateAsset(database, DatabasePath);
}
database.EditorSetSkills(definitions);
EditorUtility.SetDirty(database);
AssetDatabase.SaveAssets();
Debug.Log($"[SkillSystemSetup] Skill assets ready at {DatabasePath}. Run Tools/Nightclub Arcadia/Set Up Skill System if scene refs need rewiring.");
}
[MenuItem("Tools/Nightclub Arcadia/Set Up Skill System")]
public static void SetUpSkillSystem()
{
EnsureFolder("Assets", "Skills");
EnsureFolder("Assets/Skills", "Definitions");
var definitions = new List<SkillDefinition>();
foreach (var spec in Roster)
{
definitions.Add(CreateOrUpdateDefinition(spec));
}
var database = AssetDatabase.LoadAssetAtPath<SkillDatabase>(DatabasePath);
if (database == null)
{
database = ScriptableObject.CreateInstance<SkillDatabase>();
AssetDatabase.CreateAsset(database, DatabasePath);
}
database.EditorSetSkills(definitions);
EditorUtility.SetDirty(database);
AssetDatabase.SaveAssets();
WireDialogueTestScene(database);
AssetDatabase.SaveAssets();
Debug.Log($"Skill system setup complete. Database at {DatabasePath}; scene wired at {ScenePath}.");
}
static SkillDefinition CreateOrUpdateDefinition(SkillSpec spec)
{
var path = $"{DefinitionsFolder}/{spec.Id}.asset";
var asset = AssetDatabase.LoadAssetAtPath<SkillDefinition>(path);
if (asset == null)
{
asset = ScriptableObject.CreateInstance<SkillDefinition>();
AssetDatabase.CreateAsset(asset, path);
}
asset.EditorSet(
spec.Id,
spec.DisplayName,
spec.Domain,
$"Placeholder skill — replace when Skill_System_Draft.md is available.",
spec.StartingRank,
spec.Accent);
EditorUtility.SetDirty(asset);
return asset;
}
static void WireDialogueTestScene(SkillDatabase database)
{
if (!File.Exists(ScenePath))
{
Debug.LogError($"Scene not found at {ScenePath}. Run Tools/Nightclub Arcadia/Set Up Yarn Dialogue Test first.");
return;
}
var scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single);
var runner = Object.FindAnyObjectByType<DialogueRunner>();
if (runner == null)
{
Debug.LogError("No DialogueRunner in DialogueTest.unity.");
return;
}
var skillGo = GameObject.Find("Skill System");
if (skillGo == null)
{
skillGo = new GameObject("Skill System");
}
var runtime = skillGo.GetComponent<SkillRuntime>() ?? skillGo.AddComponent<SkillRuntime>();
var commentary = skillGo.GetComponent<CommentarySystem>() ?? skillGo.AddComponent<CommentarySystem>();
var idle = skillGo.GetComponent<CommentaryIdleTimer>() ?? skillGo.AddComponent<CommentaryIdleTimer>();
var banner = EnsureBanner(runner.transform);
var view = banner.GetComponent<SkillCheckResultView>() ?? banner.AddComponent<SkillCheckResultView>();
var runtimeSo = new SerializedObject(runtime);
runtimeSo.FindProperty("database").objectReferenceValue = database;
runtimeSo.FindProperty("dialogueRunner").objectReferenceValue = runner;
runtimeSo.FindProperty("resultView").objectReferenceValue = view;
runtimeSo.FindProperty("commentary").objectReferenceValue = commentary;
runtimeSo.FindProperty("debugContextId").stringValue = "Entrance_Queue";
runtimeSo.ApplyModifiedPropertiesWithoutUndo();
var commentarySo = new SerializedObject(commentary);
commentarySo.FindProperty("dialogueRunner").objectReferenceValue = runner;
commentarySo.FindProperty("database").objectReferenceValue = database;
commentarySo.ApplyModifiedPropertiesWithoutUndo();
var idleSo = new SerializedObject(idle);
idleSo.FindProperty("dialogueRunner").objectReferenceValue = runner;
idleSo.FindProperty("contextId").stringValue = "Entrance_Queue";
idleSo.ApplyModifiedPropertiesWithoutUndo();
var group = banner.GetComponent<CanvasGroup>();
var label = banner.GetComponentInChildren<TMP_Text>(true);
var viewSo = new SerializedObject(view);
viewSo.FindProperty("group").objectReferenceValue = group;
viewSo.FindProperty("label").objectReferenceValue = label;
viewSo.ApplyModifiedPropertiesWithoutUndo();
EditorSceneManager.MarkSceneDirty(scene);
EditorSceneManager.SaveScene(scene);
}
static GameObject EnsureBanner(Transform dialogueRoot)
{
var canvas = dialogueRoot.GetComponentInChildren<Canvas>(true);
if (canvas == null)
{
var canvasGo = new GameObject("Skill Check Canvas", typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster));
canvas = canvasGo.GetComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvasGo.transform.SetParent(dialogueRoot, false);
}
var existing = canvas.transform.Find("Skill Check Banner");
if (existing != null)
{
return existing.gameObject;
}
var banner = new GameObject("Skill Check Banner", typeof(RectTransform), typeof(CanvasGroup), typeof(Image));
banner.transform.SetParent(canvas.transform, false);
var rt = banner.GetComponent<RectTransform>();
rt.anchorMin = new Vector2(0.1f, 0.82f);
rt.anchorMax = new Vector2(0.9f, 0.95f);
rt.offsetMin = Vector2.zero;
rt.offsetMax = Vector2.zero;
var image = banner.GetComponent<Image>();
image.color = new Color(0f, 0f, 0f, 0.75f);
var group = banner.GetComponent<CanvasGroup>();
group.alpha = 0f;
group.blocksRaycasts = false;
group.interactable = false;
var labelGo = new GameObject("Label", typeof(RectTransform));
labelGo.transform.SetParent(banner.transform, false);
var labelRt = labelGo.GetComponent<RectTransform>();
labelRt.anchorMin = Vector2.zero;
labelRt.anchorMax = Vector2.one;
labelRt.offsetMin = new Vector2(16f, 8f);
labelRt.offsetMax = new Vector2(-16f, -8f);
var tmp = labelGo.AddComponent<TextMeshProUGUI>();
tmp.text = string.Empty;
tmp.alignment = TextAlignmentOptions.Center;
tmp.fontSize = 28f;
tmp.textWrappingMode = TextWrappingModes.Normal;
tmp.color = Color.white;
return banner;
}
static void EnsureFolder(string parent, string child)
{
var path = $"{parent}/{child}";
if (!AssetDatabase.IsValidFolder(path))
{
AssetDatabase.CreateFolder(parent, child);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 231654cf350a49ce9920bc4f24a1f002
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,48 @@
using UnityEditor;
using UnityEditor.TestTools.TestRunner.Api;
using UnityEngine;
/// <summary>
/// One-click EditMode test run for the skill system.
/// </summary>
public static class SkillSystemTestRunner
{
[MenuItem("Tools/Nightclub Arcadia/Run Skill EditMode Tests")]
public static void RunEditModeTests()
{
var api = ScriptableObject.CreateInstance<TestRunnerApi>();
var filter = new Filter
{
testMode = TestMode.EditMode,
assemblyNames = new[] { "NightclubArcadia.Skills.Tests" },
};
api.RegisterCallbacks(new Callbacks());
api.Execute(new ExecutionSettings(filter));
Debug.Log("[SkillSystemTestRunner] EditMode tests started — watch the Console / Test Runner.");
}
sealed class Callbacks : ICallbacks
{
public void RunStarted(ITestAdaptor testsToRun) { }
public void RunFinished(ITestResultAdaptor result)
{
Debug.Log($"[SkillSystemTestRunner] Finished. Pass:{result.PassCount} Fail:{result.FailCount} Skip:{result.SkipCount}");
if (result.FailCount > 0)
{
Debug.LogError("[SkillSystemTestRunner] One or more skill EditMode tests failed.");
}
}
public void TestStarted(ITestAdaptor test) { }
public void TestFinished(ITestResultAdaptor result)
{
if (result.TestStatus == TestStatus.Failed)
{
Debug.LogError($"[SkillSystemTestRunner] FAIL {result.FullName}\n{result.Message}\n{result.StackTrace}");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7c8e1a2b3d4f4e6a9b0c1d2e3f4a5b6c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -56,7 +56,7 @@ public static class YarnDemoSetup
EditorUtility.SetDirty(runner);
#if UNITY_2023_1_OR_NEWER
var eventSystems = Object.FindObjectsByType<EventSystem>(FindObjectsInactive.Include, FindObjectsSortMode.None);
var eventSystems = Object.FindObjectsByType<EventSystem>(FindObjectsInactive.Include);
#else
var eventSystems = Object.FindObjectsOfType<EventSystem>(true);
#endif
@@ -562,6 +562,446 @@ MonoBehaviour:
nextLineKeyCode: 27
hurryUpOptionsKeyCode: 32
cancelDialogueKeyCode: 0
--- !u!114 &910000019 stripped
MonoBehaviour:
m_CorrespondingSourceObject: {fileID: 8309902628685535410, guid: 52571f68872914e24837210513edea1d, type: 3}
m_PrefabInstance: {fileID: 1720996731}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4bec29c0a230741bdac901dba8da47ee, type: 3}
m_Name:
m_EditorClassIdentifier: YarnSpinner.Unity::Yarn.Unity.DialogueRunner
--- !u!1 &910000001
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 910000002}
- component: {fileID: 910000003}
- component: {fileID: 910000004}
- component: {fileID: 910000005}
m_Layer: 0
m_Name: Skill System
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &910000002
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 910000001}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 910000007}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &910000003
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 910000001}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: cb04a8b1bce645fba398b22bbb4db58a, type: 3}
m_Name:
m_EditorClassIdentifier: NightclubArcadia.Skills::NightclubArcadia.Skills.SkillRuntime
database: {fileID: 11400000, guid: d15ae35514b14ded9b398d630e8ef373, type: 2}
dialogueRunner: {fileID: 910000019}
resultView: {fileID: 910000015}
commentary: {fileID: 910000004}
rollSeed: 0
debugContextId: Entrance_Queue
--- !u!114 &910000004
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 910000001}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 6626d81593b24235b36ae6c168aef87f, type: 3}
m_Name:
m_EditorClassIdentifier: NightclubArcadia.Skills::NightclubArcadia.Skills.CommentarySystem
dialogueRunner: {fileID: 910000019}
database: {fileID: 11400000, guid: d15ae35514b14ded9b398d630e8ef373, type: 2}
minSecondsBetween: 20
minRankToSpeak: 1
recencyWindow: 3
--- !u!114 &910000005
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 910000001}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 0d65653213924ceaa756c6b6e65b522c, type: 3}
m_Name:
m_EditorClassIdentifier: NightclubArcadia.Skills::NightclubArcadia.Skills.CommentaryIdleTimer
dialogueRunner: {fileID: 910000019}
contextId: Entrance_Queue
idleSeconds: 45
--- !u!1 &910000006
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 910000007}
- component: {fileID: 910000008}
- component: {fileID: 910000009}
- component: {fileID: 910000010}
m_Layer: 5
m_Name: Skill Check Canvas
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &910000007
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 910000006}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 0, y: 0, z: 0}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 910000012}
m_Father: {fileID: 910000002}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0, y: 0}
--- !u!223 &910000008
Canvas:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 910000006}
m_Enabled: 1
serializedVersion: 3
m_RenderMode: 0
m_Camera: {fileID: 0}
m_PlaneDistance: 100
m_PixelPerfect: 0
m_ReceivesEvents: 1
m_OverrideSorting: 0
m_OverridePixelPerfect: 0
m_SortingBucketNormalizedSize: 0
m_VertexColorAlwaysGammaSpace: 0
m_AdditionalShaderChannelsFlag: 25
m_UpdateRectTransformForStandalone: 0
m_SortingLayerID: 0
m_SortingOrder: 100
m_TargetDisplay: 0
--- !u!114 &910000009
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 910000006}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 0cd44c1031e13a943bb63640046fad76, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.CanvasScaler
m_UiScaleMode: 1
m_ReferencePixelsPerUnit: 100
m_ScaleFactor: 1
m_ReferenceResolution: {x: 1920, y: 1080}
m_ScreenMatchMode: 0
m_MatchWidthOrHeight: 0.5
m_PhysicalUnit: 3
m_FallbackScreenDPI: 96
m_DefaultSpriteDPI: 96
m_DynamicPixelsPerUnit: 1
m_PresetInfoIsWorld: 0
--- !u!114 &910000010
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 910000006}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: dc42784cf147c0c48a680349fa168899, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.GraphicRaycaster
m_IgnoreReversedGraphics: 1
m_BlockingObjects: 0
m_BlockingMask:
serializedVersion: 2
m_Bits: 4294967295
--- !u!1 &910000011
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 910000012}
- component: {fileID: 910000020}
- component: {fileID: 910000013}
- component: {fileID: 910000014}
- component: {fileID: 910000015}
m_Layer: 5
m_Name: Skill Check Banner
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &910000012
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 910000011}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 910000017}
m_Father: {fileID: 910000007}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.1, y: 0.82}
m_AnchorMax: {x: 0.9, y: 0.95}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &910000020
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 910000011}
m_CullTransparentMesh: 1
--- !u!225 &910000013
CanvasGroup:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 910000011}
m_Enabled: 1
m_Alpha: 0
m_Interactable: 0
m_BlocksRaycasts: 0
m_IgnoreParentGroups: 0
--- !u!114 &910000014
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 910000011}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 0, g: 0, b: 0, a: 0.75}
m_RaycastTarget: 0
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 0}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!114 &910000015
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 910000011}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: af3c81416f5f4a869baa347fd9dee9e3, type: 3}
m_Name:
m_EditorClassIdentifier: NightclubArcadia.Skills::NightclubArcadia.Skills.SkillCheckResultView
group: {fileID: 910000013}
label: {fileID: 910000018}
holdSeconds: 1.6
fadeSeconds: 0.15
--- !u!1 &910000016
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 910000017}
- component: {fileID: 910000021}
- component: {fileID: 910000018}
m_Layer: 5
m_Name: Label
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &910000017
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 910000016}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 910000012}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: -32, y: -16}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &910000021
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 910000016}
m_CullTransparentMesh: 1
--- !u!114 &910000018
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 910000016}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 0
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text:
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
m_sharedMaterial: {fileID: 0}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4294967295
m_fontColor: {r: 1, g: 1, b: 1, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 28
m_fontSizeBase: 28
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 0
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!1660057539 &9223372036854775807
SceneRoots:
m_ObjectHideFlags: 0
@@ -569,3 +1009,4 @@ SceneRoots:
- {fileID: 214568766}
- {fileID: 1516720415}
- {fileID: 1720996731}
- {fileID: 910000002}
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 13f14dfeaec742b38567bb2e4c1ba440
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 26f1b62937714176b821c2ee871f6cd3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d9e3484419734ba89ff5bcd5a991f8e2
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,44 @@
using UnityEngine;
using Yarn.Unity;
namespace NightclubArcadia.Skills
{
public sealed class CommentaryIdleTimer : MonoBehaviour
{
[SerializeField] DialogueRunner dialogueRunner;
[SerializeField] string contextId = "Entrance_Queue";
[SerializeField] float idleSeconds = 45f;
float _idleAccum;
void Update()
{
if (string.IsNullOrEmpty(contextId))
{
return;
}
if (dialogueRunner != null && dialogueRunner.IsDialogueRunning)
{
_idleAccum = 0f;
return;
}
_idleAccum += Time.deltaTime;
if (_idleAccum < idleSeconds)
{
return;
}
_idleAccum = 0f;
var runtime = SkillRuntime.Instance;
if (runtime == null || runtime.Commentary == null)
{
return;
}
runtime.Commentary.RequestCommentary(contextId, repeatable: true);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0d65653213924ceaa756c6b6e65b522c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,194 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using Yarn.Unity;
namespace NightclubArcadia.Skills
{
/// <summary>
/// Passive skill commentary outside the Yarn node tree.
/// Fires at most one speaker per RequestCommentary call.
/// </summary>
public sealed class CommentarySystem : MonoBehaviour
{
[SerializeField] DialogueRunner dialogueRunner;
[SerializeField] SkillDatabase database;
[SerializeField] float minSecondsBetween = 20f;
[SerializeField] int minRankToSpeak = 1;
[SerializeField] int recencyWindow = 3;
readonly List<string> _recent = new List<string>();
readonly HashSet<string> _firedContexts = new HashSet<string>(StringComparer.Ordinal);
float _lastFiredAt = float.NegativeInfinity;
public void RequestCommentary(string contextId)
{
RequestCommentary(contextId, repeatable: false);
}
public void RequestCommentary(string contextId, bool repeatable)
{
if (string.IsNullOrEmpty(contextId))
{
return;
}
if (dialogueRunner == null || database == null)
{
Debug.LogWarning($"[{nameof(CommentarySystem)}] Missing DialogueRunner or SkillDatabase.", this);
return;
}
if (dialogueRunner.IsDialogueRunning)
{
return;
}
if (Time.time - _lastFiredAt < minSecondsBetween)
{
return;
}
if (!repeatable && _firedContexts.Contains(contextId))
{
return;
}
var runtime = SkillRuntime.Instance;
if (runtime == null || runtime.Skills == null || runtime.Rolls == null)
{
Debug.LogWarning($"[{nameof(CommentarySystem)}] SkillRuntime is not ready.", this);
return;
}
var candidates = new List<SkillDefinition>();
foreach (var skill in database.All)
{
if (skill == null || string.IsNullOrEmpty(skill.Id))
{
continue;
}
if (runtime.Skills.GetRank(skill.Id) < minRankToSpeak)
{
continue;
}
var nodeName = $"Commentary_{contextId}_{skill.Id}";
if (!NodeExists(nodeName))
{
continue;
}
candidates.Add(skill);
}
var picked = Pick(candidates, runtime.Skills, _recent, recencyWindow, runtime.Rolls);
if (picked == null)
{
return;
}
var startNode = $"Commentary_{contextId}_{picked.Id}";
_ = dialogueRunner.StartDialogue(startNode);
_lastFiredAt = Time.time;
_firedContexts.Add(contextId);
_recent.Add(picked.Id);
while (_recent.Count > Math.Max(1, recencyWindow))
{
_recent.RemoveAt(0);
}
}
bool NodeExists(string nodeName)
{
if (dialogueRunner.Dialogue != null && dialogueRunner.Dialogue.NodeExists(nodeName))
{
return true;
}
var project = dialogueRunner.YarnProject;
if (project != null)
{
foreach (var name in project.NodeNames)
{
if (string.Equals(name, nodeName, StringComparison.Ordinal))
{
return true;
}
}
}
return false;
}
/// <summary>
/// Weighted pick: weight = max(1, rank); recent speakers (within recencyWindow) have weight halved.
/// </summary>
public static SkillDefinition Pick(
IReadOnlyList<SkillDefinition> candidates,
PlayerSkills skills,
IReadOnlyList<string> recent,
int recencyWindowSize,
IRollSource rolls)
{
if (candidates == null || candidates.Count == 0 || skills == null || rolls == null)
{
return null;
}
var weights = new int[candidates.Count];
var total = 0;
for (var i = 0; i < candidates.Count; i++)
{
var skill = candidates[i];
var weight = Math.Max(1, skills.GetRank(skill.Id));
if (WasRecent(skill.Id, recent, recencyWindowSize))
{
weight = Math.Max(1, weight / 2);
}
weights[i] = weight;
total += weight;
}
if (total <= 0)
{
return null;
}
// IRollSource returns 1..sides; map onto [0, total).
var pick = rolls.Roll(total) - 1;
var cumulative = 0;
for (var i = 0; i < candidates.Count; i++)
{
cumulative += weights[i];
if (pick < cumulative)
{
return candidates[i];
}
}
return candidates[candidates.Count - 1];
}
static bool WasRecent(string skillId, IReadOnlyList<string> recent, int window)
{
if (recent == null || recent.Count == 0 || window <= 0)
{
return false;
}
var start = Math.Max(0, recent.Count - window);
for (var i = start; i < recent.Count; i++)
{
if (string.Equals(recent[i], skillId, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6626d81593b24235b36ae6c168aef87f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,33 @@
using UnityEngine;
namespace NightclubArcadia.Skills
{
[RequireComponent(typeof(Collider))]
public sealed class CommentaryTrigger : MonoBehaviour
{
[SerializeField] string contextId;
[SerializeField] bool repeatable;
[SerializeField] string playerTag = "Player";
void OnTriggerEnter(Collider other)
{
if (string.IsNullOrEmpty(contextId))
{
return;
}
if (!string.IsNullOrEmpty(playerTag) && !other.CompareTag(playerTag))
{
return;
}
var runtime = SkillRuntime.Instance;
if (runtime == null || runtime.Commentary == null)
{
return;
}
runtime.Commentary.RequestCommentary(contextId, repeatable);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 04f12949da1741e5a591519698c9ae95
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 249854c65e7742beaac1d2c969863d03
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,43 @@
using System;
using System.Globalization;
namespace NightclubArcadia.Skills
{
public enum DcBand
{
Trivial = 5,
Easy = 8,
Standard = 11,
Hard = 14,
Formidable = 17,
Legendary = 20,
}
public static class DcBands
{
public static bool TryParse(string input, out int dc)
{
dc = 0;
if (string.IsNullOrWhiteSpace(input))
{
return false;
}
input = input.Trim();
if (int.TryParse(input, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric))
{
dc = numeric;
return true;
}
if (Enum.TryParse<DcBand>(input, ignoreCase: true, out var band))
{
dc = (int)band;
return true;
}
return false;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 04013b352063419eb6f0c8932a3cb116
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,10 @@
namespace NightclubArcadia.Skills
{
public enum SkillCheckDegree
{
CriticalFailure,
Failure,
Success,
CriticalSuccess,
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a099dc0cf50346e98ea9795411a21f99
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,40 @@
namespace NightclubArcadia.Skills
{
public readonly struct SkillCheckResult
{
public SkillCheckResult(
string skillId,
string skillDisplayName,
int roll,
int rankMod,
int situationalMod,
int stateMod,
int total,
int dc,
bool success,
SkillCheckDegree degree)
{
SkillId = skillId;
SkillDisplayName = skillDisplayName;
Roll = roll;
RankMod = rankMod;
SituationalMod = situationalMod;
StateMod = stateMod;
Total = total;
DC = dc;
Success = success;
Degree = degree;
}
public string SkillId { get; }
public string SkillDisplayName { get; }
public int Roll { get; }
public int RankMod { get; }
public int SituationalMod { get; }
public int StateMod { get; }
public int Total { get; }
public int DC { get; }
public bool Success { get; }
public SkillCheckDegree Degree { get; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2ee1160f9fa3439280ca256e5f23193f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace NightclubArcadia.Skills
{
[CreateAssetMenu(menuName = "Nightclub Arcadia/Skill Database", fileName = "SkillDatabase")]
public sealed class SkillDatabase : ScriptableObject
{
[SerializeField] List<SkillDefinition> skills = new List<SkillDefinition>();
Dictionary<string, SkillDefinition> _lookup;
public IReadOnlyList<SkillDefinition> All => skills;
public bool TryGet(string skillId, out SkillDefinition def)
{
EnsureLookup();
if (string.IsNullOrEmpty(skillId))
{
def = null;
return false;
}
return _lookup.TryGetValue(skillId, out def);
}
void EnsureLookup()
{
if (_lookup != null)
{
return;
}
_lookup = new Dictionary<string, SkillDefinition>(StringComparer.OrdinalIgnoreCase);
if (skills == null)
{
return;
}
foreach (var skill in skills)
{
if (skill == null || string.IsNullOrEmpty(skill.Id))
{
continue;
}
_lookup[skill.Id] = skill;
}
}
void OnValidate()
{
_lookup = null;
if (skills == null)
{
return;
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var skill in skills)
{
if (skill == null)
{
Debug.LogError($"[{nameof(SkillDatabase)}] Null skill entry in {name}.", this);
continue;
}
if (string.IsNullOrEmpty(skill.Id))
{
Debug.LogError($"[{nameof(SkillDatabase)}] Skill '{skill.name}' has an empty Id.", this);
continue;
}
if (!seen.Add(skill.Id))
{
Debug.LogError($"[{nameof(SkillDatabase)}] Duplicate skill Id '{skill.Id}'.", this);
}
}
}
#if UNITY_EDITOR
public void EditorSetSkills(List<SkillDefinition> newSkills)
{
skills = newSkills ?? new List<SkillDefinition>();
_lookup = null;
}
#endif
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 73f6e7831a60457b918d303c16fc6cec
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,40 @@
using UnityEngine;
namespace NightclubArcadia.Skills
{
[CreateAssetMenu(menuName = "Nightclub Arcadia/Skill Definition", fileName = "NewSkill")]
public sealed class SkillDefinition : ScriptableObject
{
[SerializeField] string id;
[SerializeField] string displayName;
[SerializeField] SkillDomain domain;
[TextArea] [SerializeField] string description;
[SerializeField] int startingRank;
[SerializeField] Color accentColor = Color.white;
public string Id => id;
public string DisplayName => displayName;
public SkillDomain Domain => domain;
public string Description => description;
public int StartingRank => startingRank;
public Color AccentColor => accentColor;
#if UNITY_EDITOR
public void EditorSet(
string newId,
string newDisplayName,
SkillDomain newDomain,
string newDescription,
int newStartingRank,
Color newAccentColor)
{
id = newId;
displayName = newDisplayName;
domain = newDomain;
description = newDescription;
startingRank = newStartingRank;
accentColor = newAccentColor;
}
#endif
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9a26230b6f20436891aa772e8f858f3f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,10 @@
namespace NightclubArcadia.Skills
{
public enum SkillDomain
{
Reason,
Instinct,
Presence,
Body,
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0bdf9a02be4b42a4a48a099fe924980e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,17 @@
{
"name": "NightclubArcadia.Skills",
"rootNamespace": "NightclubArcadia.Skills",
"references": [
"YarnSpinner.Unity",
"Unity.TextMeshPro"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: ac01048d38154701b13372655db532b2
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e76dc9c927344424a7f8a4acf54d7253
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
namespace NightclubArcadia.Skills
{
public interface IRollSource
{
/// <summary>Returns a value in [1, sides] inclusive.</summary>
int Roll(int sides);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 43de4384f09d4bd7b142d105f2c04007
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
namespace NightclubArcadia.Skills
{
/// <summary>
/// Pure rank state. No rolling, DC comparison, modifier math, UI, or Yarn.
/// </summary>
public sealed class PlayerSkills
{
readonly Dictionary<string, int> _ranks =
new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
public PlayerSkills(SkillDatabase db)
{
if (db == null)
{
throw new ArgumentNullException(nameof(db));
}
foreach (var skill in db.All)
{
if (skill == null || string.IsNullOrEmpty(skill.Id))
{
continue;
}
_ranks[skill.Id] = skill.StartingRank;
}
}
public IReadOnlyDictionary<string, int> AllRanks => _ranks;
public int GetRank(string skillId)
{
if (string.IsNullOrEmpty(skillId))
{
return 0;
}
return _ranks.TryGetValue(skillId, out var rank) ? rank : 0;
}
public void SetRank(string skillId, int rank)
{
if (string.IsNullOrEmpty(skillId))
{
throw new ArgumentException("Skill id is required.", nameof(skillId));
}
_ranks[skillId] = rank;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ace1f9c8cfc64ff9aa040d60495b6115
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,24 @@
using System;
namespace NightclubArcadia.Skills
{
public sealed class SeededRollSource : IRollSource
{
readonly Random _random;
public SeededRollSource(int seed)
{
_random = new Random(seed);
}
public int Roll(int sides)
{
if (sides < 1)
{
throw new ArgumentOutOfRangeException(nameof(sides));
}
return _random.Next(1, sides + 1);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5e89e99b02ed453db3efbd72ec8d038a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,84 @@
using System;
namespace NightclubArcadia.Skills
{
/// <summary>
/// Pure check math. Exactly three modifier sources: rank, situational, state.
/// No UnityEngine types. No side effects on PlayerSkills / VariableStorage / UI.
/// </summary>
public sealed class SkillCheckSystem
{
readonly SkillDatabase _database;
readonly PlayerSkills _skills;
readonly SkillStateModifiers _state;
readonly IRollSource _rolls;
public SkillCheckSystem(
SkillDatabase database,
PlayerSkills skills,
SkillStateModifiers state,
IRollSource rolls)
{
_database = database ?? throw new ArgumentNullException(nameof(database));
_skills = skills ?? throw new ArgumentNullException(nameof(skills));
_state = state ?? throw new ArgumentNullException(nameof(state));
_rolls = rolls ?? throw new ArgumentNullException(nameof(rolls));
}
public SkillCheckResult Resolve(string skillId, int dc, int situationalMod)
{
skillId ??= string.Empty;
string displayName;
int rankMod;
if (_database.TryGet(skillId, out var def) && def != null)
{
displayName = string.IsNullOrEmpty(def.DisplayName)
? def.Id.ToUpperInvariant()
: def.DisplayName;
rankMod = _skills.GetRank(skillId);
}
else
{
displayName = skillId.ToUpperInvariant();
rankMod = 0;
}
var roll = _rolls.Roll(20);
var stateMod = _state.TotalFor(skillId);
var total = roll + rankMod + situationalMod + stateMod;
bool success;
SkillCheckDegree degree;
if (roll == 1)
{
success = false;
degree = SkillCheckDegree.CriticalFailure;
}
else if (roll == 20)
{
success = true;
degree = SkillCheckDegree.CriticalSuccess;
}
else
{
success = total >= dc;
degree = success ? SkillCheckDegree.Success : SkillCheckDegree.Failure;
}
return new SkillCheckResult(
skillId,
displayName,
roll,
rankMod,
situationalMod,
stateMod,
total,
dc,
success,
degree);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 83a2adbf493a4d6eb4f5911582cc15ac
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,75 @@
using UnityEngine;
using Yarn.Unity;
namespace NightclubArcadia.Skills
{
/// <summary>
/// Composition root and singleton that static Yarn commands route through.
/// </summary>
public sealed class SkillRuntime : MonoBehaviour
{
public static SkillRuntime Instance { get; private set; }
[SerializeField] SkillDatabase database;
[SerializeField] DialogueRunner dialogueRunner;
[SerializeField] SkillCheckResultView resultView;
[SerializeField] CommentarySystem commentary;
[SerializeField] int rollSeed;
[SerializeField] string debugContextId = "Entrance_Queue";
public SkillDatabase Database => database;
public DialogueRunner DialogueRunner => dialogueRunner;
public SkillCheckResultView ResultView => resultView;
public CommentarySystem Commentary => commentary;
public PlayerSkills Skills { get; private set; }
public SkillStateModifiers States { get; private set; }
public SkillCheckSystem Checks { get; private set; }
public IRollSource Rolls { get; private set; }
void Awake()
{
if (Instance != null && Instance != this)
{
Debug.LogError($"[{nameof(SkillRuntime)}] Duplicate instance on '{name}'. Destroying this component.", this);
Destroy(this);
return;
}
Instance = this;
if (database == null)
{
Debug.LogError($"[{nameof(SkillRuntime)}] SkillDatabase is not assigned.", this);
}
Skills = database != null ? new PlayerSkills(database) : null;
States = new SkillStateModifiers();
Rolls = rollSeed != 0 ? (IRollSource)new SeededRollSource(rollSeed) : new UnityRollSource();
if (Skills != null)
{
Checks = new SkillCheckSystem(database, Skills, States, Rolls);
}
}
void OnDestroy()
{
if (Instance == this)
{
Instance = null;
}
}
[ContextMenu("Fire Commentary (debug)")]
void FireCommentaryDebug()
{
if (commentary == null)
{
Debug.LogWarning($"[{nameof(SkillRuntime)}] No CommentarySystem assigned.", this);
return;
}
commentary.RequestCommentary(debugContextId);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cb04a8b1bce645fba398b22bbb4db58a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
namespace NightclubArcadia.Skills
{
/// <summary>
/// Transient state modifiers (third of three modifier sources). Pure state — no check math.
/// Use skillId "*" to apply to every skill.
/// </summary>
public sealed class SkillStateModifiers
{
readonly Dictionary<string, Dictionary<string, int>> _bySource =
new Dictionary<string, Dictionary<string, int>>(StringComparer.Ordinal);
public void Set(string sourceKey, string skillId, int delta)
{
if (string.IsNullOrEmpty(sourceKey))
{
throw new ArgumentException("Source key is required.", nameof(sourceKey));
}
if (string.IsNullOrEmpty(skillId))
{
throw new ArgumentException("Skill id is required.", nameof(skillId));
}
if (!_bySource.TryGetValue(sourceKey, out var map))
{
map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
_bySource[sourceKey] = map;
}
map[skillId] = delta;
}
public void Clear(string sourceKey)
{
if (string.IsNullOrEmpty(sourceKey))
{
return;
}
_bySource.Remove(sourceKey);
}
public int TotalFor(string skillId)
{
if (string.IsNullOrEmpty(skillId))
{
return 0;
}
var total = 0;
foreach (var map in _bySource.Values)
{
if (map.TryGetValue(skillId, out var specific))
{
total += specific;
}
if (map.TryGetValue("*", out var wildcard))
{
total += wildcard;
}
}
return total;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 02f6b5bf30ad4f8db0711ed25b083247
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,17 @@
using UnityEngine;
namespace NightclubArcadia.Skills
{
public sealed class UnityRollSource : IRollSource
{
public int Roll(int sides)
{
if (sides < 1)
{
throw new System.ArgumentOutOfRangeException(nameof(sides));
}
return Random.Range(1, sides + 1);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 54552e499883406b9118fd2ba28e08ec
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bdc1a9f9c02f400fa01d8341f81dc1c3
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,98 @@
using System.Collections;
using TMPro;
using UnityEngine;
namespace NightclubArcadia.Skills
{
/// <summary>
/// Transparent roll banner. Must show roll, each of the three modifiers, total, and DC.
/// </summary>
public sealed class SkillCheckResultView : MonoBehaviour
{
[SerializeField] CanvasGroup group;
[SerializeField] TMP_Text label;
[SerializeField] float holdSeconds = 1.6f;
[SerializeField] float fadeSeconds = 0.15f;
void Awake()
{
if (group != null)
{
group.alpha = 0f;
group.blocksRaycasts = false;
group.interactable = false;
}
}
public IEnumerator Show(SkillCheckResult result)
{
if (label != null)
{
label.text = Format(result);
label.color = ResolveAccent(result.SkillId);
}
if (group == null)
{
yield return new WaitForSeconds(holdSeconds);
yield break;
}
yield return Fade(0f, 1f);
yield return new WaitForSeconds(holdSeconds);
yield return Fade(1f, 0f);
}
static Color ResolveAccent(string skillId)
{
var runtime = SkillRuntime.Instance;
if (runtime != null &&
runtime.Database != null &&
runtime.Database.TryGet(skillId, out var def) &&
def != null)
{
return def.AccentColor;
}
return Color.white;
}
static string Format(SkillCheckResult r)
{
var outcome = r.Degree switch
{
SkillCheckDegree.CriticalSuccess => "CRITICAL SUCCESS",
SkillCheckDegree.CriticalFailure => "CRITICAL FAILURE",
SkillCheckDegree.Success => "SUCCESS",
_ => "FAILURE",
};
var name = string.IsNullOrEmpty(r.SkillDisplayName)
? (r.SkillId ?? string.Empty).ToUpperInvariant()
: r.SkillDisplayName.ToUpperInvariant();
return $"{name} — d20 {r.Roll} {Signed(r.RankMod)} rank {Signed(r.SituationalMod)} situation {Signed(r.StateMod)} state = {r.Total} vs DC {r.DC} · {outcome}";
}
static string Signed(int value) => value >= 0 ? $"+{value}" : value.ToString();
IEnumerator Fade(float from, float to)
{
if (fadeSeconds <= 0f)
{
group.alpha = to;
yield break;
}
var elapsed = 0f;
while (elapsed < fadeSeconds)
{
elapsed += Time.unscaledDeltaTime;
group.alpha = Mathf.Lerp(from, to, Mathf.Clamp01(elapsed / fadeSeconds));
yield return null;
}
group.alpha = to;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: af3c81416f5f4a869baa347fd9dee9e3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a5b25da74b134d59828005e34c846be0
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,115 @@
using System.Collections;
using UnityEngine;
using Yarn.Unity;
namespace NightclubArcadia.Skills
{
/// <summary>
/// Static Yarn adapter. No MonoBehaviour, no check math — translation only.
/// Call sites: &lt;&lt;check persuasion standard&gt;&gt; / &lt;&lt;check intimidation hard -1&gt;&gt;
/// </summary>
public static class YarnSkillCommands
{
[YarnCommand("check")]
public static IEnumerator Check(string skillId, string difficulty, int modifier = 0)
{
var runtime = SkillRuntime.Instance;
if (runtime == null || runtime.Checks == null || runtime.DialogueRunner == null)
{
Debug.LogError("[YarnSkillCommands] <<check>> called but SkillRuntime is not ready.");
var fallbackRunner = runtime != null ? runtime.DialogueRunner : null;
if (fallbackRunner == null)
{
fallbackRunner = Object.FindFirstObjectByType<DialogueRunner>();
}
WriteFailedResult(fallbackRunner, skillId, difficulty);
yield break;
}
if (!DcBands.TryParse(difficulty, out var dc))
{
Debug.LogError($"[YarnSkillCommands] <<check {skillId} {difficulty}>>: unknown difficulty; treating as Standard (11).");
dc = (int)DcBand.Standard;
}
if (runtime.Database == null || !runtime.Database.TryGet(skillId, out _))
{
Debug.LogError($"[YarnSkillCommands] <<check>>: no skill '{skillId}'.");
}
var result = runtime.Checks.Resolve(skillId, dc, modifier);
WriteResult(runtime.DialogueRunner.VariableStorage, result);
if (runtime.ResultView == null)
{
Debug.LogWarning("[YarnSkillCommands] SkillCheckResultView is not assigned; skipping roll banner.");
yield break;
}
yield return runtime.StartCoroutine(runtime.ResultView.Show(result));
}
[YarnFunction("skill_rank")]
public static float SkillRank(string skillId)
{
var runtime = SkillRuntime.Instance;
if (runtime == null || runtime.Skills == null)
{
return 0f;
}
return runtime.Skills.GetRank(skillId);
}
static void WriteResult(VariableStorageBehaviour storage, SkillCheckResult result)
{
if (storage == null)
{
return;
}
storage.SetValue("$check_result", result.Success);
storage.SetValue("$check_roll", (float)result.Roll);
storage.SetValue("$check_total", (float)result.Total);
storage.SetValue("$check_dc", (float)result.DC);
storage.SetValue("$check_modifier", (float)(result.RankMod + result.SituationalMod + result.StateMod));
storage.SetValue("$check_degree", DegreeToString(result.Degree));
storage.SetValue("$check_skill", result.SkillId ?? string.Empty);
}
static void WriteFailedResult(DialogueRunner runner, string skillId, string difficulty)
{
if (runner == null || runner.VariableStorage == null)
{
return;
}
DcBands.TryParse(difficulty, out var dc);
if (dc == 0)
{
dc = (int)DcBand.Standard;
}
var storage = runner.VariableStorage;
storage.SetValue("$check_result", false);
storage.SetValue("$check_roll", 0f);
storage.SetValue("$check_total", 0f);
storage.SetValue("$check_dc", (float)dc);
storage.SetValue("$check_modifier", 0f);
storage.SetValue("$check_degree", "failure");
storage.SetValue("$check_skill", skillId ?? string.Empty);
}
static string DegreeToString(SkillCheckDegree degree)
{
return degree switch
{
SkillCheckDegree.CriticalFailure => "critical_failure",
SkillCheckDegree.Failure => "failure",
SkillCheckDegree.Success => "success",
SkillCheckDegree.CriticalSuccess => "critical_success",
_ => "failure",
};
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8c00579d400d44ac85b16d9b27be7a87
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 89b18dcb780d45cc94f8de93f4ba62d4
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: aa1191ce507748a7aea886e539eb9d27
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
%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: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: deduction
m_EditorClassIdentifier:
id: deduction
displayName: Deduction
domain: 0
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 0.55, g: 0.75, b: 1, a: 1}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 21592f6cf7a6457a8198553ab124d41b
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
%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: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: endurance
m_EditorClassIdentifier:
id: endurance
displayName: Endurance
domain: 3
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 0.85, g: 0.7, b: 0.5, a: 1}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 84586fcc502c42889a44dd32ed2a2f1d
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
%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: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: intimidation
m_EditorClassIdentifier:
id: intimidation
displayName: Intimidation
domain: 2
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 2
accentColor: {r: 1, g: 0.4, b: 0.4, a: 1}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 254c6612b22d44e7b3552a8aac9311be
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
%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: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: nerve
m_EditorClassIdentifier:
id: nerve
displayName: Nerve
domain: 3
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 0.95, g: 0.6, b: 0.35, a: 1}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ee266337568a41db9aca1f2af43ec466
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
%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: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: paranoia
m_EditorClassIdentifier:
id: paranoia
displayName: Paranoia
domain: 1
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 1, g: 0.45, b: 0.55, a: 1}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b9de7affe9fa49f8a1f3bb64a87d817f
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
%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: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: persuasion
m_EditorClassIdentifier:
id: persuasion
displayName: Persuasion
domain: 2
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 0.85, g: 0.65, b: 1, a: 1}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: f510b7fc944e4c1190c35a5f82a52650
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
%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: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: rapport
m_EditorClassIdentifier:
id: rapport
displayName: Rapport
domain: 2
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 0.55, g: 0.95, b: 0.85, a: 1}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: eb856a5941c0497b8eb7796793c617ba
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
%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: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: read_room
m_EditorClassIdentifier:
id: read_room
displayName: Read the Room
domain: 1
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 1, g: 0.75, b: 0.45, a: 1}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: abfd9204c20644c090c30bd6404c45a6
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
%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: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: recall
m_EditorClassIdentifier:
id: recall
displayName: Recall
domain: 0
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 0.65, g: 0.7, b: 1, a: 1}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c675bc7c6c864e6e9e4f71394ba1f07f
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
%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: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: reflexes
m_EditorClassIdentifier:
id: reflexes
displayName: Reflexes
domain: 1
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 1, g: 0.9, b: 0.4, a: 1}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 78d1f1a41b2b48358f889935c3e3094c
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
%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: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: sleight_of_hand
m_EditorClassIdentifier:
id: sleight_of_hand
displayName: Sleight of Hand
domain: 3
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 0.7, g: 0.9, b: 0.95, a: 1}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 60239bdfcace42f89d3ff82e383e98b8
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
%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: 9a26230b6f20436891aa772e8f858f3f, type: 3}
m_Name: streetwise
m_EditorClassIdentifier:
id: streetwise
displayName: Streetwise
domain: 0
description: "Placeholder skill \u2014 replace when Skill_System_Draft.md is available."
startingRank: 1
accentColor: {r: 0.7, g: 0.85, b: 0.55, a: 1}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 910cd1397efb4e23bca22bbe6c3c6971
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,27 @@
%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: 73f6e7831a60457b918d303c16fc6cec, type: 3}
m_Name: SkillDatabase
m_EditorClassIdentifier:
skills:
- {fileID: 11400000, guid: 21592f6cf7a6457a8198553ab124d41b, type: 2}
- {fileID: 11400000, guid: 910cd1397efb4e23bca22bbe6c3c6971, type: 2}
- {fileID: 11400000, guid: c675bc7c6c864e6e9e4f71394ba1f07f, type: 2}
- {fileID: 11400000, guid: abfd9204c20644c090c30bd6404c45a6, type: 2}
- {fileID: 11400000, guid: b9de7affe9fa49f8a1f3bb64a87d817f, type: 2}
- {fileID: 11400000, guid: 78d1f1a41b2b48358f889935c3e3094c, type: 2}
- {fileID: 11400000, guid: f510b7fc944e4c1190c35a5f82a52650, type: 2}
- {fileID: 11400000, guid: 254c6612b22d44e7b3552a8aac9311be, type: 2}
- {fileID: 11400000, guid: eb856a5941c0497b8eb7796793c617ba, type: 2}
- {fileID: 11400000, guid: 84586fcc502c42889a44dd32ed2a2f1d, type: 2}
- {fileID: 11400000, guid: 60239bdfcace42f89d3ff82e383e98b8, type: 2}
- {fileID: 11400000, guid: ee266337568a41db9aca1f2af43ec466, type: 2}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d15ae35514b14ded9b398d630e8ef373
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 40490aad9dd94cdca1d9170d5f729cba
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c70934ddbc404dfcb0952241bec17b6c
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,90 @@
using System.Collections.Generic;
using NUnit.Framework;
using NightclubArcadia.Skills;
using UnityEngine;
namespace NightclubArcadia.Skills.Tests
{
public class CommentaryPickerTests
{
sealed class FixedRollSource : IRollSource
{
readonly int _value;
public FixedRollSource(int value) => _value = value;
public int Roll(int sides) => _value;
}
SkillDefinition Make(string id, int startingRank)
{
var def = ScriptableObject.CreateInstance<SkillDefinition>();
def.EditorSet(id, id, SkillDomain.Instinct, string.Empty, startingRank, Color.white);
return def;
}
PlayerSkills SkillsFor(params (string id, int rank)[] ranks)
{
var db = ScriptableObject.CreateInstance<SkillDatabase>();
var list = new List<SkillDefinition>();
foreach (var (id, rank) in ranks)
{
list.Add(Make(id, rank));
}
db.EditorSetSkills(list);
return new PlayerSkills(db);
}
[Test]
public void Pick_EmptyCandidates_ReturnsNull()
{
var skills = SkillsFor(("paranoia", 2));
var result = CommentarySystem.Pick(
new List<SkillDefinition>(),
skills,
new List<string>(),
3,
new FixedRollSource(1));
Assert.IsNull(result);
}
[Test]
public void Pick_HigherRankPreferred_GivenLowRoll()
{
// ranks 1 and 3 → weights 1 and 3 → total 4
// roll 1 maps to pick index 0 of cumulative: only weight-1 skill
// roll 2..4 maps to higher-ranked skill
var low = Make("read_room", 1);
var high = Make("paranoia", 3);
var skills = SkillsFor(("read_room", 1), ("paranoia", 3));
var highPick = CommentarySystem.Pick(
new List<SkillDefinition> { low, high },
skills,
new List<string>(),
3,
new FixedRollSource(2));
Assert.AreEqual("paranoia", highPick.Id);
}
[Test]
public void Pick_RecentSpeaker_HasHalvedWeight()
{
// Without recency: weights 2 and 2, total 4
// With "a" recent: weights 1 and 2, total 3 → roll 1 picks a, roll 2+ picks b
var a = Make("a", 2);
var b = Make("b", 2);
var skills = SkillsFor(("a", 2), ("b", 2));
var pick = CommentarySystem.Pick(
new List<SkillDefinition> { a, b },
skills,
new List<string> { "a" },
3,
new FixedRollSource(2));
Assert.AreEqual("b", pick.Id);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6ba3f2a4a2a44285acd610ab51884753
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,26 @@
using NUnit.Framework;
using NightclubArcadia.Skills;
namespace NightclubArcadia.Skills.Tests
{
public class DcBandTests
{
[TestCase("standard", 11)]
[TestCase("STANDARD", 11)]
[TestCase("13", 13)]
[TestCase("-3", -3)]
public void TryParse_AcceptsBandNamesAndIntegers(string input, int expected)
{
Assert.IsTrue(DcBands.TryParse(input, out var dc));
Assert.AreEqual(expected, dc);
}
[TestCase("banana")]
[TestCase("")]
[TestCase(null)]
public void TryParse_RejectsGarbage(string input)
{
Assert.IsFalse(DcBands.TryParse(input, out _));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9057738b1d8f402d907c1d9dab8d82a2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,24 @@
{
"name": "NightclubArcadia.Skills.Tests",
"rootNamespace": "NightclubArcadia.Skills.Tests",
"references": [
"NightclubArcadia.Skills",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"nunit.framework.dll"
],
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 09d3d01ec5554542b5dd23239ace7a70
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,137 @@
using System.Collections.Generic;
using NUnit.Framework;
using NightclubArcadia.Skills;
using UnityEngine;
namespace NightclubArcadia.Skills.Tests
{
public class SkillCheckSystemTests
{
sealed class FixedRollSource : IRollSource
{
readonly int _value;
public FixedRollSource(int value) => _value = value;
public int Roll(int sides) => _value;
}
SkillDatabase CreateDatabase(params (string id, string display, int rank)[] skills)
{
var db = ScriptableObject.CreateInstance<SkillDatabase>();
var list = new List<SkillDefinition>();
foreach (var (id, display, rank) in skills)
{
var def = ScriptableObject.CreateInstance<SkillDefinition>();
def.EditorSet(id, display, SkillDomain.Presence, string.Empty, rank, Color.white);
list.Add(def);
}
db.EditorSetSkills(list);
return db;
}
[Test]
public void Resolve_SumsModifiers_AndSucceedsWhenTotalMeetsDc()
{
var db = CreateDatabase(("persuasion", "Persuasion", 2));
var skills = new PlayerSkills(db);
var state = new SkillStateModifiers();
var system = new SkillCheckSystem(db, skills, state, new FixedRollSource(10));
var result = system.Resolve("persuasion", 12, 1);
Assert.AreEqual(13, result.Total);
Assert.IsTrue(result.Success);
Assert.AreEqual(SkillCheckDegree.Success, result.Degree);
Assert.AreEqual(2, result.RankMod);
Assert.AreEqual(1, result.SituationalMod);
Assert.AreEqual(0, result.StateMod);
}
[Test]
public void Resolve_NaturalOne_IsCriticalFailureEvenIfModifiersWouldPass()
{
var db = CreateDatabase(("persuasion", "Persuasion", 10));
var skills = new PlayerSkills(db);
var state = new SkillStateModifiers();
var system = new SkillCheckSystem(db, skills, state, new FixedRollSource(1));
var result = system.Resolve("persuasion", 5, 5);
Assert.IsFalse(result.Success);
Assert.AreEqual(SkillCheckDegree.CriticalFailure, result.Degree);
}
[Test]
public void Resolve_NaturalTwenty_IsCriticalSuccessEvenIfBelowDc()
{
var db = CreateDatabase(("persuasion", "Persuasion", 0));
var skills = new PlayerSkills(db);
var state = new SkillStateModifiers();
var system = new SkillCheckSystem(db, skills, state, new FixedRollSource(20));
var result = system.Resolve("persuasion", 25, 0);
Assert.IsTrue(result.Success);
Assert.AreEqual(SkillCheckDegree.CriticalSuccess, result.Degree);
}
[Test]
public void Resolve_ExactDc_IsSuccess()
{
var db = CreateDatabase(("persuasion", "Persuasion", 1));
var skills = new PlayerSkills(db);
var state = new SkillStateModifiers();
var system = new SkillCheckSystem(db, skills, state, new FixedRollSource(10));
var result = system.Resolve("persuasion", 11, 0);
Assert.AreEqual(11, result.Total);
Assert.IsTrue(result.Success);
}
[Test]
public void Resolve_AppliesNegativeAndWildcardStateModifiers()
{
var db = CreateDatabase(("reflexes", "Reflexes", 1));
var skills = new PlayerSkills(db);
var state = new SkillStateModifiers();
state.Set("drunk", "reflexes", -2);
state.Set("crowd", "*", -1);
var system = new SkillCheckSystem(db, skills, state, new FixedRollSource(10));
var result = system.Resolve("reflexes", 5, 0);
Assert.AreEqual(-3, result.StateMod);
Assert.AreEqual(8, result.Total);
}
[Test]
public void Resolve_UnknownSkill_DoesNotThrow_AndUsesZeroRank()
{
var db = CreateDatabase(("persuasion", "Persuasion", 3));
var skills = new PlayerSkills(db);
var state = new SkillStateModifiers();
var system = new SkillCheckSystem(db, skills, state, new FixedRollSource(10));
SkillCheckResult result = default;
Assert.DoesNotThrow(() => result = system.Resolve("nosuchskill", 10, 0));
Assert.AreEqual(0, result.RankMod);
Assert.AreEqual("NOSUCHSKILL", result.SkillDisplayName);
}
[Test]
public void Resolve_DoesNotMutatePlayerSkills()
{
var db = CreateDatabase(("persuasion", "Persuasion", 2));
var skills = new PlayerSkills(db);
var before = skills.GetRank("persuasion");
var state = new SkillStateModifiers();
var system = new SkillCheckSystem(db, skills, state, new FixedRollSource(10));
system.Resolve("persuasion", 10, 0);
Assert.AreEqual(before, skills.GetRank("persuasion"));
Assert.AreEqual(1, skills.AllRanks.Count);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 78734080b9b5473db4365b8203fc79b0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: