Files

226 lines
7.0 KiB
C#

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.
/// Per-skill budget: 3600 / targetFiringsPerHour seconds between fires for that voice,
/// on top of the global minSecondsBetween floor.
/// </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);
readonly Dictionary<string, float> _lastFiredBySkill =
new Dictionary<string, float>(StringComparer.OrdinalIgnoreCase);
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 now = Time.time;
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;
}
if (!_lastFiredBySkill.TryGetValue(skill.Id, out var lastFired))
{
lastFired = float.NegativeInfinity;
}
if (!IsOffCooldown(lastFired, now, skill.TargetFiringsPerHour))
{
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 = now;
_lastFiredBySkill[picked.Id] = now;
_firedContexts.Add(contextId);
_recent.Add(picked.Id);
while (_recent.Count > Math.Max(1, recencyWindow))
{
_recent.RemoveAt(0);
}
}
/// <summary>
/// True when enough time has elapsed since lastFired given targetFiringsPerHour.
/// targetFiringsPerHour &lt;= 0 means the skill never fires.
/// </summary>
public static bool IsOffCooldown(float lastFired, float now, float targetPerHour)
{
if (targetPerHour <= 0f)
{
return false;
}
var minInterval = 3600f / targetPerHour;
return now - lastFired >= minInterval;
}
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;
}
}
}