226 lines
7.0 KiB
C#
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 <= 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;
|
|
}
|
|
}
|
|
}
|