implemented skill system according to designdocuments
This commit is contained in:
@@ -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: <<check persuasion standard>> / <<check intimidation hard -1>>
|
||||
/// </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:
|
||||
Reference in New Issue
Block a user