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
@@ -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: