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