using FMOD.Studio; using System.IO; using System.Linq; using UnityEngine; public abstract class NPCController : MonoBehaviour { protected Transform playerTransform; public string[] voiceLineKeys; [Header("Mouth Transform")] public Transform mouth; // assign your billboard mouth object [Header("Mouth Animation Settings")] public float minScaleY = 0.3f; public float maxScaleY = 1.0f; public float gain = 30f; // multiply RMS to make mouth open larger public float attack = 0.6f; // faster opening public float release = 0.2f; // slower closing [Header("Timeline Folder (StreamingAssets recommended)")] public string timelineFolder = "CharacterVoicelines"; private float[] rmsCurve; private EventInstance currentVoicelineEvent; private bool isSpeaking; private float smoothed; private int sampleRate = 50; // RMS samples per second (20ms windows) // If you change RMS window in Python, update this // Start is called before the first frame update void Awake() { } void Start() { } // Update is called once per frame void Update() { if (playerTransform != null) { // As if player is on same Y coordinate as this object, to not rotate around Y axis. Vector3 lookTargetPos = new Vector3(playerTransform.position.x, transform.position.y, playerTransform.position.z); transform.LookAt(lookTargetPos); } if (isSpeaking && rmsCurve != null && currentVoicelineEvent.isValid()) { AnimateMouth(); } } private void AnimateMouth() { // get FMOD timeline position currentVoicelineEvent.getTimelinePosition(out int ms); float time = ms / 1000f; // find sample index int index = Mathf.FloorToInt(time * sampleRate); index = Mathf.Clamp(index, 0, rmsCurve.Length - 1); float amp = rmsCurve[index] * gain; // attack/release smoothing float targetY = Mathf.Clamp(minScaleY + amp, minScaleY, maxScaleY); if (targetY > smoothed) smoothed = Mathf.Lerp(smoothed, targetY, attack); else smoothed = Mathf.Lerp(smoothed, targetY, release); // apply mouth scale Vector3 s = mouth.localScale; s.y = smoothed; Debug.Log("mouth scale: " + smoothed); mouth.localScale = s; } private void OnTriggerEnter(Collider other) { if (other.gameObject.tag == "Player Head") { playerTransform = other.transform; OnPlayerApproach(); } } protected virtual void OnPlayerApproach() {} private void OnTriggerExit(Collider other) { if (other.gameObject.tag == "Player Head") { playerTransform = null; OnPlayerLeave(); } } protected virtual void OnPlayerLeave() {} public void SpeakVoiceLine(int voiceLineId) { if (voiceLineId < 0 || voiceLineId >= voiceLineKeys.Length) { Debug.LogError("Invalid voiceLineId!"); return; } string key = voiceLineKeys[voiceLineId]; LoadCurve(key); // load RMS data Debug.Log("loaded timeline curve"); currentVoicelineEvent = AudioManager.Instance.PlayDialogue(key, gameObject); if (!currentVoicelineEvent.isValid()) { Debug.LogError("Failed to start dialogue event."); return; } //isSpeaking = true; // Stop mouth on end currentVoicelineEvent.setCallback((type, inst, param) => { if (type == EVENT_CALLBACK_TYPE.STOPPED) { isSpeaking = false; smoothed = minScaleY; if (mouth != null) { Vector3 s = mouth.localScale; s.y = minScaleY; mouth.localScale = s; } } return FMOD.RESULT.OK; }); } // --------------------------- // Load RMS Timeline (.txt) // --------------------------- private void LoadCurve(string key) { string folderPath = Path.Combine(Application.streamingAssetsPath, timelineFolder); string filePath = Path.Combine(folderPath, key + ".txt"); if (!File.Exists(filePath)) { Debug.LogError("Missing RMS timeline file: " + filePath); rmsCurve = null; return; } var lines = File.ReadAllLines(filePath); Debug.Log("read lines: " + lines.Length); rmsCurve = lines.Select(l => float.Parse(l, System.Globalization.CultureInfo.InvariantCulture)).ToArray(); } }