From f2294c51d08b697294ef921810a599754ce6f0d2 Mon Sep 17 00:00:00 2001 From: Jucan Andrei Daniel Date: Sat, 13 Jun 2026 13:03:22 +0300 Subject: [PATCH 1/5] refactor: remove the standalone RoutineService and RoutineHandler Routine scheduling moves into the NPC services; drops their Reflex registration. --- .../Scripts/Adapters/Npc/RoutineHandler.cs | 102 ------------------ .../Adapters/Npc/RoutineHandler.cs.meta | 11 -- .../Scripts/ReflexProjectInstaller.cs | 2 - .../Scripts/Services/Npc/RoutineService.cs | 85 --------------- .../Services/Npc/RoutineService.cs.meta | 11 -- 5 files changed, 211 deletions(-) delete mode 100644 Assets/Gothic-Core/Scripts/Adapters/Npc/RoutineHandler.cs delete mode 100644 Assets/Gothic-Core/Scripts/Adapters/Npc/RoutineHandler.cs.meta delete mode 100644 Assets/Gothic-Core/Scripts/Services/Npc/RoutineService.cs delete mode 100644 Assets/Gothic-Core/Scripts/Services/Npc/RoutineService.cs.meta diff --git a/Assets/Gothic-Core/Scripts/Adapters/Npc/RoutineHandler.cs b/Assets/Gothic-Core/Scripts/Adapters/Npc/RoutineHandler.cs deleted file mode 100644 index 50d723847..000000000 --- a/Assets/Gothic-Core/Scripts/Adapters/Npc/RoutineHandler.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Gothic.Core.Logging; -using Gothic.Core.Manager; -using Gothic.Core.Models.Npc; -using Gothic.Core.Services.World; -using Reflex.Attributes; -using UnityEngine; -using Logger = Gothic.Core.Logging.Logger; - -namespace Gothic.Core.Adapters.Npc -{ - public class RoutineHandler : MonoBehaviour - { - [Inject] private readonly RoutineService _routineService; - [Inject] private readonly GameTimeService _gameTimeService; - - public readonly List Routines = new(); - - public RoutineData CurrentRoutine; - - private void Start() - { - _routineService.Subscribe(this, Routines); - } - - private void OnDisable() - { - _routineService.Unsubscribe(this, Routines); - } - - public void ChangeRoutine(DateTime now) - { - if (!CalculateCurrentRoutine()) - { - Logger.LogWarning("ChangeRoutine got called but the resulting routine was the same: " + - $"NPC: >{gameObject.name}< WP: >{CurrentRoutine.Waypoint}<", LogCat.Ai); - return; - } - - // FIXME - We need to set! routine, not Start() it immediately. Please check with G1 if we should - // changeRoutine and execute ZS_*_END normally or change immediately. - // GetComponent().StartRoutine(CurrentRoutine.Action, CurrentRoutine.Waypoint); - } - - /// - /// Calculate new routine based on given timestamp. - /// Hints about normalization: - /// 1. Daedalus handles routines with a 00:00 as midnight (24:00) - /// -> For the midnight topic, we normalize via %24 - /// 2. Routines can span multiple days (e.g. 22:00 - 09:00) - /// -> For the overnight topic, we leverage the second if when start > end - /// - /// Whether the routine changed or not. - public bool CalculateCurrentRoutine() - { - var currentTime = _gameTimeService.GetCurrentDateTime(); - - var normalizedNow = currentTime.Hour % 24 * 60 + currentTime.Minute; - - RoutineData newRoutine = null; - - // There are routines where stop is lower than start. (e.g. now:8:00, routine:22:00-9:00), therefore the second check. - foreach (var routine in Routines) - { - if (routine.NormalizedStart <= normalizedNow && normalizedNow < routine.NormalizedEnd) - { - newRoutine = routine; - break; - } - // Handling the case where the time range spans across midnight - - if (routine.NormalizedStart > routine.NormalizedEnd) - { - if (routine.NormalizedStart <= normalizedNow || normalizedNow < routine.NormalizedEnd) - { - newRoutine = routine; - break; - } - } - } - - // e.g. Mud has a bug as there is no routine covering 8am. We therefore pick the last one as seen in original G1. (sit) - if (newRoutine == null) - { - newRoutine = Routines.Last(); - } - - var changed = CurrentRoutine != newRoutine; - CurrentRoutine = newRoutine; - - return changed; - } - - public RoutineData GetPreviousRoutine() - { - var currentRoutineIndex = Routines.IndexOf(CurrentRoutine); - return currentRoutineIndex == 0 ? Routines.Last() : Routines[currentRoutineIndex - 1]; - } - } -} diff --git a/Assets/Gothic-Core/Scripts/Adapters/Npc/RoutineHandler.cs.meta b/Assets/Gothic-Core/Scripts/Adapters/Npc/RoutineHandler.cs.meta deleted file mode 100644 index ef0aaf2fb..000000000 --- a/Assets/Gothic-Core/Scripts/Adapters/Npc/RoutineHandler.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: fee73244aacb72e4898c65e81d38f9ef -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Assets/Gothic-Core/Scripts/ReflexProjectInstaller.cs b/Assets/Gothic-Core/Scripts/ReflexProjectInstaller.cs index 198481fb7..f92727855 100644 --- a/Assets/Gothic-Core/Scripts/ReflexProjectInstaller.cs +++ b/Assets/Gothic-Core/Scripts/ReflexProjectInstaller.cs @@ -15,7 +15,6 @@ using Gothic.Core.Services.Vobs; using Gothic.Core.Services.World; using Gothic.Services.UI; -using Gothic.Core.Models.Container; using Reflex.Core; using UnityEngine; using UnityEngine.SceneManagement; @@ -84,7 +83,6 @@ public void InstallBindings(ContainerBuilder containerBuilder) containerBuilder.AddSingleton(typeof(NpcService)); containerBuilder.AddSingleton(typeof(NpcAiService)); containerBuilder.AddSingleton(typeof(NpcHelperService)); - containerBuilder.AddSingleton(typeof(RoutineService)); containerBuilder.AddSingleton(typeof(NpcRoutineService)); containerBuilder.AddSingleton(typeof(NpcInventoryService)); containerBuilder.AddSingleton(typeof(FightService)); diff --git a/Assets/Gothic-Core/Scripts/Services/Npc/RoutineService.cs b/Assets/Gothic-Core/Scripts/Services/Npc/RoutineService.cs deleted file mode 100644 index 1f6dad338..000000000 --- a/Assets/Gothic-Core/Scripts/Services/Npc/RoutineService.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Collections.Generic; -using Gothic.Core.Adapters.Npc; -using Gothic.Core.Models.Npc; -using Gothic.Core.Services.Config; -using Gothic.Core.Models.Config; -using Gothic.Core.Services.Npc; -using Reflex.Attributes; - -namespace Gothic.Core.Manager -{ - /// - /// Manages the Routines in a central spot. Routines Subscribe here. Calls the Routines when they are due. - /// - public class RoutineService - { - [Inject] private readonly ConfigService _configService; - - private Dictionary> _npcStartTimeDict = new(); - - - public void Init() - { - //Init starting position - GlobalEventDispatcher.WorldSceneLoaded.AddListener(WorldLoadedEvent); - GlobalEventDispatcher.GameTimeMinuteChangeCallback.AddListener(Invoke); - } - - private void WorldLoadedEvent() - { - var time = new DateTime(1, 1, 1, _configService.Dev.StartTimeHour, _configService.Dev.StartTimeMinute, 0); - - Invoke(time); - } - - public void Subscribe(RoutineHandler npcID, List routines) - { - // We need to fill in routines backwards as e.g. Mud and Scorpio have duplicate routines. Last one needs to win. - routines.Reverse(); - foreach (var routine in routines) - { - _npcStartTimeDict.TryAdd(routine.NormalizedStart, new List()); - _npcStartTimeDict[routine.NormalizedStart].Add(npcID); - } - } - - public void Unsubscribe(RoutineHandler routineHandlerInstance, List routines) - { - foreach (var routine in routines) - { - if (!_npcStartTimeDict.TryGetValue(routine.NormalizedStart, out var routinesForStartPoint)) - { - return; - } - - routinesForStartPoint.Remove(routineHandlerInstance); - - // Remove element if empty - if (_npcStartTimeDict[routine.NormalizedStart].Count == 0) - { - _npcStartTimeDict.Remove(routine.NormalizedStart); - } - } - } - - /// - /// Calls the routineInstances that are due. - /// Triggers Routine Change - /// - private void Invoke(DateTime now) - { - var normalizedNow = now.Hour % 24 * 60 + now.Minute; - - if (!_npcStartTimeDict.TryGetValue(normalizedNow, out var routineItems)) - { - return; - } - - foreach (var routineItem in routineItems) - { - routineItem.ChangeRoutine(now); - } - } - } -} diff --git a/Assets/Gothic-Core/Scripts/Services/Npc/RoutineService.cs.meta b/Assets/Gothic-Core/Scripts/Services/Npc/RoutineService.cs.meta deleted file mode 100644 index ad76244c4..000000000 --- a/Assets/Gothic-Core/Scripts/Services/Npc/RoutineService.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: ff27067532e5fb14d8e963d887bb60cf -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: From 73486296a09ed8189f04d3f0c48c2e53fbef3571 Mon Sep 17 00:00:00 2001 From: Jucan Andrei Daniel Date: Sat, 13 Jun 2026 13:03:22 +0300 Subject: [PATCH 2/5] feat: fold routine scheduling into NpcRoutineService and NpcAiService Routine lookup/progression and AI ticking now live in the NPC services, wired through BootstrapService. --- .../Scripts/Services/BootstrapService.cs | 11 +- .../Scripts/Services/Npc/NpcAiService.cs | 121 +++++++++--------- .../Scripts/Services/Npc/NpcHelperService.cs | 91 +++++++++++-- .../Scripts/Services/Npc/NpcRoutineService.cs | 35 ++++- .../Scripts/Services/Npc/NpcService.cs | 9 +- 5 files changed, 187 insertions(+), 80 deletions(-) diff --git a/Assets/Gothic-Core/Scripts/Services/BootstrapService.cs b/Assets/Gothic-Core/Scripts/Services/BootstrapService.cs index e67990173..6d1f672f6 100644 --- a/Assets/Gothic-Core/Scripts/Services/BootstrapService.cs +++ b/Assets/Gothic-Core/Scripts/Services/BootstrapService.cs @@ -51,7 +51,7 @@ public class BootstrapService [Inject] private readonly VobService _vobService; [Inject] private readonly ConfigService _configService; [Inject] private readonly NpcService _npcService; - [Inject] private readonly RoutineService _routineService; + [Inject] private readonly NpcRoutineService _npcRoutineService; [Inject] private readonly FightService _fightService; [Inject] private readonly ParticleService _particleService; @@ -101,7 +101,7 @@ public void InitPhase1() _npcMeshCullingService.Init(); _vobSoundCullingService.Init(); _gameTimeService.Init(); - _routineService.Init(); + _npcRoutineService.Init(); _fightService.Init(); _particleService.Init(); } @@ -181,7 +181,12 @@ public void LoadWorld(string worldName, SaveGameService.SlotId saveGameId, strin } else { - // If we have saveGameId -1 that means to just change the world and keep the same data. + // World change triggers (level transitions) bypass SaveGameService, so LoadGameStart + // is never fired and culling domains don't get PreWorldCreate. Reset them manually. + _vobMeshCullingService.PreWorldCreate(); + _npcMeshCullingService.PreWorldCreate(); + _vobSoundCullingService.PreWorldCreate(); + _npcService.ClearQueues(); } _saveGameService.ChangeWorld(worldName); diff --git a/Assets/Gothic-Core/Scripts/Services/Npc/NpcAiService.cs b/Assets/Gothic-Core/Scripts/Services/Npc/NpcAiService.cs index 0adbe470f..ceebc755f 100644 --- a/Assets/Gothic-Core/Scripts/Services/Npc/NpcAiService.cs +++ b/Assets/Gothic-Core/Scripts/Services/Npc/NpcAiService.cs @@ -1,6 +1,5 @@ using System.Linq; using Gothic.Core.Adapters.Properties; -using Gothic.Core.Const; using Gothic.Core.Domain.Npc.Actions; using Gothic.Core.Domain.Npc.Actions.AnimationActions; using Gothic.Core.Models.Container; @@ -21,8 +20,6 @@ public class NpcAiService [Inject] private readonly MultiTypeCacheService _multiTypeCacheService; - private static int _raycastLayersToUse = (1 << Constants.DefaultLayer); - public void ExtNpcPerceptionEnable(NpcInstance npc, VmGothicEnums.PerceptionType perception, int function) { npc.GetUserData().Props.Perceptions[perception] = function; @@ -50,12 +47,12 @@ public void ExecutePerception(VmGothicEnums.PerceptionType type, NpcProperties p } var oldSelf = _gameStateService.GothicVm.GlobalSelf; - var oldOther = _gameStateService.GothicVm.GlobalOther; var oldVictim = _gameStateService.GothicVm.GlobalVictim; + var oldOther = _gameStateService.GothicVm.GlobalOther; _gameStateService.GothicVm.GlobalSelf = self; - if(other != null) + if(other != null) { _gameStateService.GothicVm.GlobalOther = other; } @@ -65,11 +62,18 @@ public void ExecutePerception(VmGothicEnums.PerceptionType type, NpcProperties p _gameStateService.GothicVm.GlobalVictim = victim; } - _gameStateService.GothicVm.Call(perceptionFunction); - - _gameStateService.GothicVm.GlobalSelf = oldSelf; - _gameStateService.GothicVm.GlobalOther = oldOther; - _gameStateService.GothicVm.GlobalVictim = oldVictim; + // The finally block ensures a throwing perception function doesn't leave the globals + // polluted for every subsequent script call of all NPCs. + try + { + _gameStateService.GothicVm.Call(perceptionFunction); + } + finally + { + _gameStateService.GothicVm.GlobalSelf = oldSelf; + _gameStateService.GothicVm.GlobalVictim = oldVictim; + _gameStateService.GothicVm.GlobalOther = oldOther; + } } public void ExtNpcSetPerceptionTime(NpcInstance npc, float time) @@ -108,46 +112,7 @@ public void ExtAiGoToFp(NpcInstance npc, string freePointName) /// public bool ExtNpcCanSeeNpc(NpcInstance self, NpcInstance other, bool freeLOS, float fov = 50f) { - var selfContainer = self.GetUserData(); - var otherContainer = other.GetUserData(); - - if (selfContainer == null || otherContainer == null) - { - return false; - } - - // Hint: For forward direction check, we can't use HeadMesh as e.g., Molerat's head is rotated to have red axis (right) as forward. - // OpenGothic is therefore using Body rotation, too. - var selfRoot = selfContainer.Go.transform; - var otherRoot = otherContainer.Go.transform; - var selfHead = selfContainer.PrefabProps.Head ?? selfRoot; - var otherHead = otherContainer.PrefabProps.Head ?? otherRoot; - - var selfGroundPosition = selfRoot.position; - var otherGroundPosition = otherRoot.position; - // Unity places positions of objects at the bottom. We need to lift them up towards the head - var selfRealHeadPosition = new Vector3(selfRoot.position.x, selfHead.position.y, selfRoot.position.z); - var otherRealHeadPosition = new Vector3(otherRoot.position.x, otherHead.position.y, otherRoot.position.z); - - var distanceToNpc = Vector3.Distance(selfRealHeadPosition, otherRealHeadPosition); - var inSightRange = distanceToNpc <= self.SensesRange; - - var hasLineOfSightCollisions = Physics.Linecast(selfRealHeadPosition, otherRealHeadPosition, _raycastLayersToUse); - - // DEBUG - collision detection and fetching the object which is blocking LoS. - // var hasLineOfSightCollisionsDebug = Physics.Linecast(selfRealHeadPosition, otherRealHeadPosition, out var hit, _raycastLayersToUse); - - // Calculate horizontal direction only (ignore Y axis for FOV check), basically a Gobbo is only using x+z for FOV and hero standing in front of it will work correctly. - var directionToTarget = new Vector3( - otherGroundPosition.x - selfGroundPosition.x, - 0f, - otherGroundPosition.z - selfGroundPosition.z - ).normalized; - var selfForwardHorizontal = new Vector3(selfRoot.forward.x, 0f, selfRoot.forward.z).normalized; - var angleToTarget = Vector3.Angle(selfForwardHorizontal, directionToTarget); - var inFov = angleToTarget <= fov; - - return inSightRange && !hasLineOfSightCollisions && (freeLOS || inFov); + return _npcHelperService.CanSeeNpc(self, other, freeLOS, fov); } public void ExtNpcClearAiQueue(NpcInstance npc) @@ -206,7 +171,7 @@ public void PlayAttackAni(NpcInstance npc, string name, FightAiMove move, NpcIns public void ExtAiStartState(NpcInstance npc, int action, bool stopCurrentState, string wayPointName) { var other = (NpcInstance)_gameStateService.GothicVm.GlobalOther; - var victim = (NpcInstance)_gameStateService.GothicVm.GlobalOther; + var victim = (NpcInstance)_gameStateService.GothicVm.GlobalVictim; npc.GetUserData().Props.AnimationQueue.Enqueue(new StartState( new AnimationAction(int0: action, bool0: stopCurrentState, string0: wayPointName, instance0: other, instance1: victim), @@ -256,9 +221,8 @@ public void ExtAiUseMob(NpcInstance npc, string target, int state) public void ExtAiStandUp(NpcInstance npc) { - // FIXME - Implement remaining tasks from G1 documentation: - // * Ist der Nsc in einem Animatinsstate, wird die passende Rücktransition abgespielt. - // * Benutzt der NSC gerade ein MOBSI, poppt er ins stehen. + // FIXME - Implement remaining task from G1 documentation: + // * Ist der Nsc in einem Animationsstate, wird die passende Rücktransition abgespielt (e.g. item states). npc.GetUserData().Props.AnimationQueue.Enqueue(new StandUp(new AnimationAction(), npc.GetUserData())); } @@ -386,6 +350,17 @@ public void ExtAiDrawWeapon(NpcInstance npc) npc.GetUserData().Props.AnimationQueue.Enqueue(new DrawWeapon(new AnimationAction(), npc.GetUserData())); } + public void ExtAiReadyRangedWeapon(NpcInstance npc) + { + // int0 == 1 --> DrawWeapon picks the equipped ranged weapon instead of the melee one. + npc.GetUserData().Props.AnimationQueue.Enqueue(new DrawWeapon(new AnimationAction(int0: 1), npc.GetUserData())); + } + + public void ExtAiUndrawWeapon(NpcInstance npc) + { + npc.GetUserData().Props.AnimationQueue.Enqueue(new UndrawWeapon(new AnimationAction(), npc.GetUserData())); + } + public bool ExtNpcIsDead(NpcInstance npcInstance) { // FIXME - BodyState is runtime-only and lost on NPC reload (e.g. world reload respawns the NPC alive). @@ -463,7 +438,16 @@ public void ExtSetTempAttitude(NpcInstance npc, VmGothicEnums.Attitude value) public bool ExtGetTarget(NpcInstance npc) { - return npc.GetUserData().Props.TargetNpc != null; + var target = npc.GetUserData().Props.TargetNpc; + + if (target == null) + { + return false; + } + + // Npc_GetTarget() also fills >other< with the target - scripts use it immediately afterwards. + _gameStateService.GothicVm.GlobalOther = target; + return true; } public void ExtSetTarget(NpcInstance npc, NpcInstance target) @@ -499,9 +483,11 @@ public void UpdateEnemyNpc(NpcInstance self) NpcContainer closestEnemy = null; var closestSqrDist = float.MaxValue; + var sensesRangeMeters = self.SensesRange / 100f; + var sensesRangeSqr = sensesRangeMeters * sensesRangeMeters; + // FIXME - Performance - Can we clean this up to support only spawned and visible NPCs/Monsters? - // Otherwise we might need to loop through it for each visible monster and 1000x per visible NPC/Monster - // -> about 10k checks per frame! + // A spatial lookup (e.g. the culling system's distance buckets) would avoid the full scan. foreach (var candidate in _multiTypeCacheService.NpcCache) { // Fast-fail checks in order of cheapest first @@ -515,7 +501,16 @@ public void UpdateEnemyNpc(NpcInstance self) continue; } - if (!_npcHelperService.CanSenseNpc(self, candidate.Instance, true)) + // Corpses aren't enemies. + if (candidate.Props.BodyState == VmGothicEnums.BodyState.BsDead) + { + continue; + } + + // Range and closest-so-far gates before the expensive attitude and senses checks + // (senses may include a line-of-sight raycast for see-only monsters). + var sqrDist = (candidate.Go.transform.position - selfPosition).sqrMagnitude; + if (sqrDist > sensesRangeSqr || sqrDist >= closestSqrDist) { continue; } @@ -525,13 +520,13 @@ public void UpdateEnemyNpc(NpcInstance self) continue; } - // Compare squared distances to avoid sqrt calculation - var sqrDist = (candidate.Go.transform.position - selfPosition).sqrMagnitude; - if (sqrDist < closestSqrDist) + if (!_npcHelperService.CanSenseNpc(self, candidate.Instance, true)) { - closestSqrDist = sqrDist; - closestEnemy = candidate; + continue; } + + closestSqrDist = sqrDist; + closestEnemy = candidate; } selfNpc.Props.EnemyNpc = closestEnemy?.Instance; diff --git a/Assets/Gothic-Core/Scripts/Services/Npc/NpcHelperService.cs b/Assets/Gothic-Core/Scripts/Services/Npc/NpcHelperService.cs index b3a41e31f..356310d4c 100644 --- a/Assets/Gothic-Core/Scripts/Services/Npc/NpcHelperService.cs +++ b/Assets/Gothic-Core/Scripts/Services/Npc/NpcHelperService.cs @@ -31,8 +31,9 @@ public class NpcHelperService [Inject] private readonly MultiTypeCacheService _multiTypeCacheService; [Inject] private readonly WayNetService _wayNetService; [Inject] private readonly VobService _vobService; - + private const float _fpLookupDistance = 7f; // meter + private static readonly int _raycastLayersToUse = 1 << Constants.DefaultLayer; public void Init() { @@ -138,11 +139,11 @@ public bool ExtIsNpcOnFp(NpcInstance npc, string vobNamePart) public bool ExtWldDetectNpcEx(NpcInstance npcInstance, int specificNpcIndex, int aiState, int guild, bool detectPlayer) { - var npc = GetProperties(npcInstance); var npcPos = npcInstance.GetUserData().Go.transform.position; - var npcVob = npcInstance.GetUserData().Vob; - // FIXME - add range check based on perceiveAll's range (npc.sense_range) + var sensesRangeMeters = npcInstance.SensesRange / 100f; + var sensesRangeSqr = sensesRangeMeters * sensesRangeMeters; + var foundNpc = _multiTypeCacheService.NpcCache .Where(i => i.Props != null) // ignore empty (safe check) .Where(i => i.Go != null) // ignore empty (safe check) @@ -154,7 +155,8 @@ public bool ExtWldDetectNpcEx(NpcInstance npcInstance, int specificNpcIndex, int specificNpcIndex == i.Instance.Index) // Specific NPC is found right now? .Where(i => aiState < 0 || aiState == i.Vob.CurrentStateIndex) .Where(i => guild < 0 || i.Instance.Guild == guild) // check guild - .OrderBy(i => Vector3.Distance(i.Go.transform.position, npcPos)) // get nearest + .Where(i => (i.Go.transform.position - npcPos).sqrMagnitude <= sensesRangeSqr) // detect only within senses range + .OrderBy(i => (i.Go.transform.position - npcPos).sqrMagnitude) // get nearest .FirstOrDefault(); // without this Dialog box stops and breaks the entire NPC logic @@ -232,20 +234,89 @@ private NpcProperties GetProperties([CanBeNull] NpcInstance npc) return npc?.GetUserData().Props; } - // FIXME - CanSense is not separating between smell, hear, and see as of now. Please add functionality. - public bool CanSenseNpc(NpcInstance self, NpcInstance other, bool freeLOS) + /// + /// Senses check based on C_NPC.senses: hearing and smelling only need the range check, + /// seeing additionally needs a free line of sight (and FOV unless freeLOS is set). + /// + public bool CanSenseNpc(NpcInstance self, NpcInstance other, bool freeLOS, float maxRangeMeters = float.MaxValue) { - var senseRange = (self.SensesRange / 100); // daedalus values are in cm, we need them in m + var senseRangeMeters = Mathf.Min(self.SensesRange / 100f, maxRangeMeters); // daedalus values are in cm, we need them in m var range = Vector3.Distance(other.GetUserData().Go.transform.position, self.GetUserData().Go.transform.position); - if (range > senseRange) + + if (range > senseRangeMeters) { return false; } - else + + var senses = (VmGothicEnums.NpcSenses)self.Senses; + + // Defensive: an NPC without configured senses keeps the previous distance-only behavior. + if (senses == 0) { return true; } + + if ((senses & (VmGothicEnums.NpcSenses.Hear | VmGothicEnums.NpcSenses.Smell)) != 0) + { + return true; + } + + return CanSeeNpc(self, other, freeLOS); + } + + /// + /// freeLOS - Free Line Of Sight == ignoreFOV + /// fov = 50 - OpenGothic assumes 100 fov for NPCs + /// fov = 30 - We reuse this for Focus angle during AI_Attack() + /// + public bool CanSeeNpc(NpcInstance self, NpcInstance other, bool freeLOS, float fov = 50f) + { + var selfContainer = self.GetUserData(); + var otherContainer = other.GetUserData(); + + if (selfContainer == null || otherContainer == null) + { + return false; + } + + // Hint: For forward direction check, we can't use HeadMesh as e.g., Molerat's head is rotated to have red axis (right) as forward. + // OpenGothic is therefore using Body rotation, too. + var selfRoot = selfContainer.Go.transform; + var otherRoot = otherContainer.Go.transform; + var selfHead = selfContainer.PrefabProps.Head ?? selfRoot; + var otherHead = otherContainer.PrefabProps.Head ?? otherRoot; + + var selfGroundPosition = selfRoot.position; + var otherGroundPosition = otherRoot.position; + // Unity places positions of objects at the bottom. We need to lift them up towards the head + var selfRealHeadPosition = new Vector3(selfRoot.position.x, selfHead.position.y, selfRoot.position.z); + var otherRealHeadPosition = new Vector3(otherRoot.position.x, otherHead.position.y, otherRoot.position.z); + + var distanceToNpc = Vector3.Distance(selfRealHeadPosition, otherRealHeadPosition); + var inSightRange = distanceToNpc <= self.SensesRange / 100f; // SensesRange is in cm. + + var hasLineOfSightCollisions = Physics.Linecast(selfRealHeadPosition, otherRealHeadPosition, _raycastLayersToUse); + + // Calculate horizontal direction only (ignore Y axis for FOV check), basically a Gobbo is only using x+z for FOV and hero standing in front of it will work correctly. + var directionToTarget = new Vector3( + otherGroundPosition.x - selfGroundPosition.x, + 0f, + otherGroundPosition.z - selfGroundPosition.z + ).normalized; + var selfForwardHorizontal = new Vector3(selfRoot.forward.x, 0f, selfRoot.forward.z).normalized; + var angleToTarget = Vector3.Angle(selfForwardHorizontal, directionToTarget); + var inFov = angleToTarget <= fov; + + return inSightRange && !hasLineOfSightCollisions && (freeLOS || inFov); + } + + /// + /// Range set via Perc_SetRange() in meters. Unset perceptions are unlimited (senses range still applies). + /// + public float GetPerceptionRange(VmGothicEnums.PerceptionType type) + { + return PerceptionRanges.TryGetValue(type, out var range) ? range : float.MaxValue; } } } diff --git a/Assets/Gothic-Core/Scripts/Services/Npc/NpcRoutineService.cs b/Assets/Gothic-Core/Scripts/Services/Npc/NpcRoutineService.cs index 882f5d6c5..07243bfc8 100644 --- a/Assets/Gothic-Core/Scripts/Services/Npc/NpcRoutineService.cs +++ b/Assets/Gothic-Core/Scripts/Services/Npc/NpcRoutineService.cs @@ -1,7 +1,10 @@ +using System; using System.Linq; using Gothic.Core.Extensions; using Gothic.Core.Logging; using Gothic.Core.Models.Npc; +using Gothic.Core.Services.Caches; +using Gothic.Core.Services.Culling; using Gothic.Core.Services.World; using MyBox; using Reflex.Attributes; @@ -15,9 +18,37 @@ public class NpcRoutineService { [Inject] private readonly GameStateService _gameStateService; [Inject] private readonly GameTimeService _gameTimeService; - + [Inject] private readonly MultiTypeCacheService _multiTypeCacheService; + [Inject] private readonly NpcMeshCullingService _npcMeshCullingService; + private DaedalusVm _vm => _gameStateService.GothicVm; + public void Init() + { + GlobalEventDispatcher.GameTimeMinuteChangeCallback.AddListener(OnGameTimeMinuteChanged); + } + + /// + /// Advance the time-based daily routine of all NPCs - including culled ones, so that the world progresses + /// while we're not looking. Visible NPCs pick the new routine up with their next StartNextRoutine(). + /// For culled NPCs, the culling sphere follows the schedule to the new waypoint. + /// FIXME - Original G1 interrupts the current ZS_* state of visible NPCs when their routine changes. + /// + private void OnGameTimeMinuteChanged(DateTime now) + { + foreach (var npc in _multiTypeCacheService.NpcCache) + { + // e.g. Hero and monsters have no daily routines. + if (npc.Props == null || npc.Props.Routines.Count == 0) + continue; + + if (!CalculateCurrentRoutine(npc.Instance)) + continue; + + _npcMeshCullingService.NotifyNpcRoutineChanged(npc); + } + } + public void ExtNpcExchangeRoutine(NpcInstance npcInstance, string routineName) { var formattedRoutineName = $"Rtn_{routineName}_{npcInstance.Id}"; @@ -66,7 +97,7 @@ public void ExchangeRoutine(NpcInstance npc, int routineIndex) /// /// Based on time of the day, we need to calculate routine. /// - private bool CalculateCurrentRoutine(NpcInstance npc) + public bool CalculateCurrentRoutine(NpcInstance npc) { var npcProps = npc.GetUserData().Props; var currentTime = _gameTimeService.GetCurrentDateTime(); diff --git a/Assets/Gothic-Core/Scripts/Services/Npc/NpcService.cs b/Assets/Gothic-Core/Scripts/Services/Npc/NpcService.cs index ec605f8f7..c4debeaa1 100644 --- a/Assets/Gothic-Core/Scripts/Services/Npc/NpcService.cs +++ b/Assets/Gothic-Core/Scripts/Services/Npc/NpcService.cs @@ -70,14 +70,19 @@ public void Init() GlobalEventDispatcher.LoadGameStart.AddListener(() => { - _objectsToInitQueue.Clear(); - _objectToReEnableQueue.Clear(); + ClearQueues(); }); GlobalEventDispatcher.NpcMeshCullingChanged.AddListener(EventNpcMeshCullingChanged); GlobalEventDispatcher.CreateNpc.AddListener(CreateVobNpc); } + public void ClearQueues() + { + _objectsToInitQueue.Clear(); + _objectToReEnableQueue.Clear(); + } + private IEnumerator InitNpcCoroutine() { while (true) From b5ef016d544d54c3d456b9bc3f133222b7e7132f Mon Sep 17 00:00:00 2001 From: Jucan Andrei Daniel Date: Sat, 13 Jun 2026 13:03:22 +0300 Subject: [PATCH 3/5] feat: draw/undraw weapon actions with weapon-type animation prefixes Adds AiDrawWeapon/UndrawWeapon actions using 1h/2h/Mag prefixes and the backing VM externals/enums. --- .../Actions/AnimationActions/AiDrawWeapon.cs | 97 ++++++++++++++++--- .../Actions/AnimationActions/UndrawWeapon.cs | 57 +++++++++++ .../AnimationActions/UndrawWeapon.cs.meta | 2 + .../Scripts/Domain/Vm/VmExternalDomain.cs | 18 ++++ .../Scripts/Models/Vm/VmGothicEnums.cs | 11 +++ 5 files changed, 169 insertions(+), 16 deletions(-) create mode 100644 Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/UndrawWeapon.cs create mode 100644 Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/UndrawWeapon.cs.meta diff --git a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/AiDrawWeapon.cs b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/AiDrawWeapon.cs index afdafddf6..1c92d638e 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/AiDrawWeapon.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/AiDrawWeapon.cs @@ -1,39 +1,104 @@ +using System.Linq; +using Gothic.Core.Const; using Gothic.Core.Models.Container; +using Gothic.Core.Models.Vm; using Gothic.Core.Extensions; -using EventType = ZenKit.EventType; +using Gothic.Core.Services.Npc; +using JetBrains.Annotations; +using ZenKit.Daedalus; namespace Gothic.Core.Domain.Npc.Actions.AnimationActions { public class DrawWeapon : AbstractAnimationAction { + private bool _isRangedRequested => Action.Int0 == 1; + public DrawWeapon(AnimationAction action, NpcContainer npcContainer) : base(action, npcContainer) { } public override void Start() { - // FIXME - We need to handle both mds and mdh options! (base vs overlay) - // "t_1hRun_2_1h" --> undraw animation! - // "t_Move_2_1hMove" --> drawing - // "t_1h_2_1hRun" - PrefabProps.AnimationSystem.PlayAnimation("t_Move_2_1hMove"); - } + var weapon = GetEquippedWeapon(); + var weaponState = GetWeaponState(weapon); - // FIXME - Hardcoded. We need to set it dynamically and not copying the ZS, but an object below. Otherwise it's hard to find previous parent when undrawing. - private void SyncZSlots() - { - var rightHand = NpcGo.FindChildRecursively("ZS_RIGHTHAND"); - var weapon1HSlot = NpcGo.FindChildRecursively("ZS_SWORD"); + // The fight mode must be set before the animation plays: all follow-up animations + // (s_1hRunL, t_2hRunTurnL, s_FistAttack, ...) compose their names from it. + Vob.FightMode = (int)weaponState; + + MoveWeaponToHand(weapon, weaponState); - // No weapon equipped in slot. - if (weapon1HSlot.transform.childCount == 0) + var prefix = AnimationService.GetWeaponAnimationPrefix(weaponState); + var animationName = $"t_Move_2_{prefix}Move"; + + if (!PrefabProps.AnimationSystem.PlayAnimation(animationName)) { + IsFinishedFlag = true; return; } - var weaponGo = weapon1HSlot.transform.GetChild(0).gameObject; + ActionEndEventTime = PrefabProps.AnimationSystem.GetAnimationDuration(animationName); + } + + [CanBeNull] + private ItemInstance GetEquippedWeapon() + { + var mainFlag = _isRangedRequested ? VmGothicEnums.ItemFlags.ItemKatFf : VmGothicEnums.ItemFlags.ItemKatNf; - weaponGo.SetParent(rightHand, true, true); + return Props.EquippedItems.FirstOrDefault(i => i.MainFlag == (int)mainFlag); + } + + private VmGothicEnums.WeaponState GetWeaponState([CanBeNull] ItemInstance weapon) + { + // No weapon equipped: fight with fists (the default for monsters and brawling humans). + if (weapon == null) + return VmGothicEnums.WeaponState.Fist; + + var flags = (VmGothicEnums.ItemFlags)weapon.Flags; + + if (_isRangedRequested) + return flags.HasFlag(VmGothicEnums.ItemFlags.ItemCrossbow) + ? VmGothicEnums.WeaponState.CBow + : VmGothicEnums.WeaponState.Bow; + + return flags.HasFlag(VmGothicEnums.ItemFlags.Item2HdAxe) || flags.HasFlag(VmGothicEnums.ItemFlags.Item2HdSwd) + ? VmGothicEnums.WeaponState.W2H + : VmGothicEnums.WeaponState.W1H; + } + + /// + /// Reparent the weapon mesh from its stow slot (back/hip) into the hand. + /// FIXME - Ideally this happens on the animation's DEF_DRAWSOUND event instead of immediately. + /// + private void MoveWeaponToHand([CanBeNull] ItemInstance weapon, VmGothicEnums.WeaponState weaponState) + { + if (weapon == null) + return; + + var slotGo = NpcGo.FindChildRecursively(GetStowSlotName(weaponState)); + var handGo = NpcGo.FindChildRecursively(GetHandSlotName(weaponState)); + + if (slotGo == null || handGo == null || slotGo.transform.childCount == 0) + return; + + slotGo.transform.GetChild(0).gameObject.SetParent(handGo, true, true); + } + + public static string GetStowSlotName(VmGothicEnums.WeaponState weaponState) + { + return weaponState switch + { + VmGothicEnums.WeaponState.W2H => Constants.SlotLongsword, + VmGothicEnums.WeaponState.Bow => Constants.SlotBow, + VmGothicEnums.WeaponState.CBow => Constants.SlotCrossbow, + _ => Constants.SlotSword + }; + } + + public static string GetHandSlotName(VmGothicEnums.WeaponState weaponState) + { + // Bows are held in the left hand (the right hand draws the arrow), everything else in the right. + return weaponState == VmGothicEnums.WeaponState.Bow ? Constants.SlotLeftHand : Constants.SlotRightHand; } } } diff --git a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/UndrawWeapon.cs b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/UndrawWeapon.cs new file mode 100644 index 000000000..bba481cab --- /dev/null +++ b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/UndrawWeapon.cs @@ -0,0 +1,57 @@ +using Gothic.Core.Models.Container; +using Gothic.Core.Models.Vm; +using Gothic.Core.Extensions; +using Gothic.Core.Services.Npc; + +namespace Gothic.Core.Domain.Npc.Actions.AnimationActions +{ + public class UndrawWeapon : AbstractAnimationAction + { + public UndrawWeapon(AnimationAction action, NpcContainer npcContainer) : base(action, npcContainer) + { + } + + public override void Start() + { + var weaponState = (VmGothicEnums.WeaponState)Vob.FightMode; + + if (weaponState == VmGothicEnums.WeaponState.NoWeapon) + { + IsFinishedFlag = true; + return; + } + + MoveWeaponBackToStowSlot(weaponState); + + var prefix = AnimationService.GetWeaponAnimationPrefix(weaponState); + + // From here on, follow-up animations are weaponless again (s_RunL, ...). + Vob.FightMode = (int)VmGothicEnums.WeaponState.NoWeapon; + + var animationName = $"t_{prefix}Move_2_Move"; + + if (!PrefabProps.AnimationSystem.PlayAnimation(animationName)) + { + IsFinishedFlag = true; + return; + } + + ActionEndEventTime = PrefabProps.AnimationSystem.GetAnimationDuration(animationName); + } + + private void MoveWeaponBackToStowSlot(VmGothicEnums.WeaponState weaponState) + { + // Fists carry no mesh. + if (weaponState == VmGothicEnums.WeaponState.Fist) + return; + + var handGo = NpcGo.FindChildRecursively(DrawWeapon.GetHandSlotName(weaponState)); + var slotGo = NpcGo.FindChildRecursively(DrawWeapon.GetStowSlotName(weaponState)); + + if (handGo == null || slotGo == null || handGo.transform.childCount == 0) + return; + + handGo.transform.GetChild(0).gameObject.SetParent(slotGo, true, true); + } + } +} diff --git a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/UndrawWeapon.cs.meta b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/UndrawWeapon.cs.meta new file mode 100644 index 000000000..8e5d3935b --- /dev/null +++ b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/UndrawWeapon.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 96ff26f560c0c462f989537bb8e74186 \ No newline at end of file diff --git a/Assets/Gothic-Core/Scripts/Domain/Vm/VmExternalDomain.cs b/Assets/Gothic-Core/Scripts/Domain/Vm/VmExternalDomain.cs index 220552f56..39ec0838f 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Vm/VmExternalDomain.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Vm/VmExternalDomain.cs @@ -92,6 +92,9 @@ public void RegisterExternals() vm.RegisterExternal("AI_UseMob", AI_UseMob); vm.RegisterExternal("AI_GoToNextFP", AI_GoToNextFP); vm.RegisterExternal("AI_DrawWeapon", AI_DrawWeapon); + vm.RegisterExternal("AI_ReadyMeleeWeapon", AI_ReadyMeleeWeapon); + vm.RegisterExternal("AI_ReadyRangedWeapon", AI_ReadyRangedWeapon); + vm.RegisterExternal("AI_RemoveWeapon", AI_RemoveWeapon); vm.RegisterExternal("AI_Output", AI_Output); vm.RegisterExternal("AI_ProcessInfos", AI_ProcessInfos); vm.RegisterExternal("AI_StopProcessInfos", AI_StopProcessInfos); @@ -414,6 +417,21 @@ public void AI_DrawWeapon(NpcInstance npc) _npcAiService.ExtAiDrawWeapon(npc); } + public void AI_ReadyMeleeWeapon(NpcInstance npc) + { + _npcAiService.ExtAiDrawWeapon(npc); + } + + public void AI_ReadyRangedWeapon(NpcInstance npc) + { + _npcAiService.ExtAiReadyRangedWeapon(npc); + } + + public void AI_RemoveWeapon(NpcInstance npc) + { + _npcAiService.ExtAiUndrawWeapon(npc); + } + public void AI_Output(NpcInstance self, NpcInstance target, string outputName) { _dialogService.ExtAiOutput(self, target, outputName); diff --git a/Assets/Gothic-Core/Scripts/Models/Vm/VmGothicEnums.cs b/Assets/Gothic-Core/Scripts/Models/Vm/VmGothicEnums.cs index 2f0278498..c6d19450d 100644 --- a/Assets/Gothic-Core/Scripts/Models/Vm/VmGothicEnums.cs +++ b/Assets/Gothic-Core/Scripts/Models/Vm/VmGothicEnums.cs @@ -296,6 +296,17 @@ public enum WeaponState Mage } + /// + /// Daedalus SENSE_* constants (C_NPC.senses bitmask). + /// + [Flags] + public enum NpcSenses + { + See = 1, + Hear = 2, + Smell = 4 + } + public enum MoverState { Open, From 64adcfab97a586a7aaa2e39bf29eabe224bcbd54 Mon Sep 17 00:00:00 2001 From: Jucan Andrei Daniel Date: Sat, 13 Jun 2026 13:03:22 +0300 Subject: [PATCH 4/5] feat: melee attack hit detection and death-animation handling Implements DEF_OPT_FRAME-gated attacks, the G1 damage formula, and clears the queued actions on death so corpses do not finish pending animations. --- .../Scripts/Adapters/Npc/AiHandler.cs | 47 +++++++++--- .../Npc/Actions/AnimationActions/Attack.cs | 74 +++++++++++++------ .../Scripts/Services/Npc/FightService.cs | 37 ++++++++-- 3 files changed, 119 insertions(+), 39 deletions(-) diff --git a/Assets/Gothic-Core/Scripts/Adapters/Npc/AiHandler.cs b/Assets/Gothic-Core/Scripts/Adapters/Npc/AiHandler.cs index 9a683c22e..70492a142 100644 --- a/Assets/Gothic-Core/Scripts/Adapters/Npc/AiHandler.cs +++ b/Assets/Gothic-Core/Scripts/Adapters/Npc/AiHandler.cs @@ -52,12 +52,23 @@ private void Start() /// private void Update() { - // If NPC/Monster is dead, stop any further process logic. + // If NPC/Monster is dead, only play out the already queued animations (e.g. the dying animation + // enqueued by FightService), then stop any further process logic. if (Properties.BodyState == VmGothicEnums.BodyState.BsDead) { - enabled = false; + Properties.CurrentAction.Tick(); + + if (Properties.CurrentAction.IsFinished()) + { + if (Properties.AnimationQueue.Count > 0) + PlayNextAnimation(Properties.AnimationQueue.Dequeue()); + else + enabled = false; + } + + return; } - + ExecuteActivePerceptions(); ExecuteStates(); @@ -87,7 +98,6 @@ private void Update() Vm.GlobalOther = Vm.GlobalHero; } - DaedalusSymbol loopSymbol; switch (Properties.CurrentLoopState) { // None means, the NPC is newly created and didn't execute any Routine as of now OR a State was changed via Daedalus scripts. @@ -135,7 +145,9 @@ private void Update() // Go on else { - Logger.Log($"Start playing >{Properties.AnimationQueue.Peek().GetType()}< on >{Go.transform.parent.name}<", LogCat.Ai); + // Editor-only: this fires for every dequeued action of every NPC - the string interpolation + // plus file sink would be measurable noise on device. + Logger.LogEditor($"Start playing >{Properties.AnimationQueue.Peek().GetType()}< on >{Go.transform.parent.name}<", LogCat.Ai); PlayNextAnimation(Properties.AnimationQueue.Dequeue()); } } @@ -180,18 +192,25 @@ private void ExecuteActivePerceptions() return; } - _npcAiService.UpdateEnemyNpc(NpcInstance); + var hero = (NpcInstance)_gameStateService.GothicVm.GlobalHero; + var assessPlayerRange = _npcHelperService.GetPerceptionRange(VmGothicEnums.PerceptionType.AssessPlayer); - // FIXME - CanSense is not separating between smell, hear, and see as of now. Please add functionality. - if(_npcHelperService.CanSenseNpc(NpcInstance, (NpcInstance)_gameStateService.GothicVm.GlobalHero, false)) + if(_npcHelperService.CanSenseNpc(NpcInstance, hero, false, assessPlayerRange)) { - _npcAiService.ExecutePerception(VmGothicEnums.PerceptionType.AssessPlayer, Properties, NpcInstance,null, (NpcInstance)_gameStateService.GothicVm.GlobalHero); + _npcAiService.ExecutePerception(VmGothicEnums.PerceptionType.AssessPlayer, Properties, NpcInstance, null, hero); } - // FIXME - Throws a lot of errors and warnings when NPCs are nearby monsters (e.g. Bridge guard next to OC) - if(Properties.EnemyNpc != null) + // Scanning all NPCs for the closest enemy is expensive - only do it for NPCs that react to enemies at all. + if (Properties.Perceptions.TryGetValue(VmGothicEnums.PerceptionType.AssessEnemy, out var enemyPerception) && + enemyPerception >= 0) { - _npcAiService.ExecutePerception(VmGothicEnums.PerceptionType.AssessEnemy, Properties, NpcInstance,null, Properties.EnemyNpc); + _npcAiService.UpdateEnemyNpc(NpcInstance); + + // FIXME - Throws a lot of errors and warnings when NPCs are nearby monsters (e.g. Bridge guard next to OC) + if(Properties.EnemyNpc != null) + { + _npcAiService.ExecutePerception(VmGothicEnums.PerceptionType.AssessEnemy, Properties, NpcInstance,null, Properties.EnemyNpc); + } } @@ -267,6 +286,10 @@ public void StartRoutine(int action) var routineSymbol = Vm.GetSymbolByIndex(action)!; Vob.CurrentStateName = routineSymbol.Name; + // Reset the previous routine's symbols: a new ZS without own _Loop/_End must not call the old ones. + Properties.StateLoop = 0; + Properties.StateEnd = 0; + var symbolLoop = Vm.GetSymbolByName($"{routineSymbol.Name}_Loop"); if (symbolLoop != null) { diff --git a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Attack.cs b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Attack.cs index 32da7ff9f..85795ac10 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Attack.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Attack.cs @@ -1,5 +1,4 @@ using System; -using System.Numerics; using Gothic.Core.Const; using Gothic.Core.Extensions; using Gothic.Core.Logging; @@ -17,7 +16,7 @@ public class Attack : AbstractAnimationAction { [Inject] private readonly NpcAiService _npcAiService; - private NpcInstance _enemy => (NpcInstance)GameStateService.GothicVm.GlobalVictim; + private NpcInstance _enemy => Props.EnemyNpc; private FightAiMove _move; @@ -51,27 +50,26 @@ private string FindAiFunctionTemplate() var attackRange = GetAttackRange(); var isInWRange = distance <= attackRange; // W-Range == Weapon range var isInGRange = !isInWRange && distance <= attackRange * 3; // G-Range == Goto range - var isInFKRange = !isInWRange && !isInGRange; // FK-Range == Fernkampf range; Yes, in G1 it's assumed that nothing is farther away than 30 m, but I don't expect any AI_Attack() call to be at this range. - var isRunning = false; // FIXME - We need to handle this for >MyGRunTo< + // FIXME - We need to handle an "isRunning" state for >MyGRunTo< switch ((VmGothicEnums.WeaponState)Vob.FightMode) { - case VmGothicEnums.WeaponState.Fist: - case VmGothicEnums.WeaponState.W1H: - case VmGothicEnums.WeaponState.W2H: - if (isInWRange) - return isInFocus ? FightConst.AttackActions.MyWFocus : FightConst.AttackActions.MyWNoFocus; - if (isInGRange) - return isInFocus ? FightConst.AttackActions.MyGFocus : FightConst.AttackActions.MyGFkNoFocus; - else - return isInFocus ? FightConst.AttackActions.MyFkFocus : FightConst.AttackActions.MyGFkNoFocus; - case VmGothicEnums.WeaponState.NoWeapon: case VmGothicEnums.WeaponState.Bow: case VmGothicEnums.WeaponState.CBow: case VmGothicEnums.WeaponState.Mage: - default: - throw new ArgumentOutOfRangeException(); + // Ranged/magic fight AI isn't implemented yet. Behave like a melee fighter so fights continue. + Logger.LogWarning($"Ai_Attack() with {(VmGothicEnums.WeaponState)Vob.FightMode} not yet implemented. Using melee behavior.", LogCat.Ai); + break; } + + // NoWeapon behaves like Fist: an NPC attacked before its AI_DrawWeapon finished still needs a fight move. + if (isInWRange) + return isInFocus ? FightConst.AttackActions.MyWFocus : FightConst.AttackActions.MyWNoFocus; + if (isInGRange) + return isInFocus ? FightConst.AttackActions.MyGFocus : FightConst.AttackActions.MyGFkNoFocus; + + // FK-Range == Fernkampf range. In G1 nothing is assumed to be farther away than 30m. + return isInFocus ? FightConst.AttackActions.MyFkFocus : FightConst.AttackActions.MyGFkNoFocus; } // FIXME - In the future, we need to handle more information than just playing the attack animations. But fine for the first iteration. @@ -96,24 +94,43 @@ private void StartAttackAction() _npcAiService.PlayAttackAni(NpcInstance, GetAnimName(VmGothicEnums.AnimationType.Move), _move, _enemy); break; case FightAiMove.Turn: + case FightAiMove.TurnToHit: _npcAiService.ExtAiTurnToNpc(NpcInstance, _enemy); break; // Some attacks have no action. Therefore TryGetFightAiData() returns Nop as fallback. case FightAiMove.Nop: break; - case FightAiMove.RunBack: - case FightAiMove.JumpBack: case FightAiMove.AttackSide: + if (Random.Range(0, 2) == 0) + _npcAiService.PlayAttackAni(NpcInstance, GetAnimName(VmGothicEnums.AnimationType.AttackL), _move, _enemy); + else + _npcAiService.PlayAttackAni(NpcInstance, GetAnimName(VmGothicEnums.AnimationType.AttackR), _move, _enemy); + break; + // The combo attacks (triple/whirl/master) are chained hit windows of the base swing in the + // original engine. Until attack combos are implemented, the base swing is the closest match. case FightAiMove.AttackFront: case FightAiMove.AttackTriple: case FightAiMove.AttackWhirl: case FightAiMove.AttackMaster: - case FightAiMove.TurnToHit: + _npcAiService.PlayAttackAni(NpcInstance, GetAnimName(VmGothicEnums.AnimationType.Attack), _move, _enemy); + break; case FightAiMove.Parry: + _npcAiService.PlayAttackAni(NpcInstance, GetAnimName(VmGothicEnums.AnimationType.AttackBlock), _move, _enemy); + break; + // No run-backwards loop exists in the assets; the parade jump-back is the closest match for both. + case FightAiMove.RunBack: + case FightAiMove.JumpBack: + _npcAiService.PlayAttackAni(NpcInstance, GetJumpBackAnimName(), _move, _enemy); + break; case FightAiMove.StandUp: + _npcAiService.ExtAiStandUp(NpcInstance); + break; + // Wait durations relative to FightAiMove.Wait (0.2s); the original engine scales them similarly. case FightAiMove.WaitLonger: + _npcAiService.ExtAiWait(NpcInstance, 0.4f); + break; case FightAiMove.WaitExt: - Logger.LogError($"Ai_Attack() type >{_move}< not yet handled. Skipping...", LogCat.Ai); + _npcAiService.ExtAiWait(NpcInstance, 0.8f); break; default: Logger.LogError("No action for Ai_Attack() selected. Missing path in logic!", LogCat.Ai); @@ -134,7 +151,7 @@ private float GetAttackRange() var baseRange = GameStateService.GuildValues.GetFightRangeBase(Vob.GuildTrue); // By default, use Fist range. - float weaponRange = GameStateService.GuildValues.GetFightRangeFist(Vob.GuildTrue);; + float weaponRange = GameStateService.GuildValues.GetFightRangeFist(Vob.GuildTrue); // If NPC has a weapon equipped, then use it's length in G1 (as FIGHT_RANGE_1HA and FIGHT_RANGE_1HS aren't set. Same for 2H). // FIXME - Check how G2 is handling ranges. Also via weapon range or guild values? @@ -164,7 +181,7 @@ private float GetAttackRange() } } - return (baseRange + weaponRange) / 100f; // m -> cm + return (baseRange + weaponRange) / 100f; // cm -> m } /// @@ -174,5 +191,18 @@ private string GetAnimName(VmGothicEnums.AnimationType type) { return AnimationService.GetAnimationName(type, NpcContainer); } + + private string GetJumpBackAnimName() + { + var fightMode = (VmGothicEnums.WeaponState)Vob.FightMode; + + // There is no weaponless jump-back animation - the fist one is used. + if (fightMode == VmGothicEnums.WeaponState.NoWeapon) + fightMode = VmGothicEnums.WeaponState.Fist; + + var prefix = AnimationService.GetWeaponAnimationPrefix(fightMode); + + return $"t_{prefix}ParadeJumpB"; + } } } diff --git a/Assets/Gothic-Core/Scripts/Services/Npc/FightService.cs b/Assets/Gothic-Core/Scripts/Services/Npc/FightService.cs index 163910d20..87e0ea06e 100644 --- a/Assets/Gothic-Core/Scripts/Services/Npc/FightService.cs +++ b/Assets/Gothic-Core/Scripts/Services/Npc/FightService.cs @@ -54,15 +54,23 @@ private void OnHit(NpcContainer attacker, NpcContainer target, Vector3 __) /// private bool OnHitUpdateHealth(NpcContainer attacker, NpcContainer target) { - // FIXME - We need to handle this via power and skill level of attacker, not weapon alone. + // FIXME - Talent/skill level (e.g. 1H skill) is not factored in yet. var hitPoints = target.Vob.GetAttribute((int)NpcAttribute.HitPoints); var maxHP = target.Vob.GetAttribute((int)NpcAttribute.HitPointsMax); var equippedWeapon = _npcHelperService.ExtNpcGetEquippedMeleeWeapon(attacker.Instance); - Logger.Log($"[FightService.OnHitUpdateHealth] Attacker: {attacker.Instance.GetName(NpcNameSlot.Slot0)}, WeaponName: {(equippedWeapon != null ? equippedWeapon.Name : "None")}, Damage: {(equippedWeapon != null ? equippedWeapon.DamageTotal.ToString() : "N/A")}", LogCat.Npc); - // FIXME - Instead of 0, use fist value - // FIXME - Instead of DamageTotal, use calculated NPC/Hero value - var damage = equippedWeapon?.DamageTotal ?? 0; + + // G1 melee damage: weapon damage + strength, reduced by the protection matching the damage type. + // Unarmed attackers (fists, monster claws/bites) deal blunt damage with their strength alone. + var strength = attacker.Vob.GetAttribute((int)NpcAttribute.Strength); + var weaponDamage = equippedWeapon?.DamageTotal ?? 0; + var protectionIndex = equippedWeapon == null + ? (int)DamageType.Blunt + : GetProtectionIndex(equippedWeapon.DamageType); + var protection = target.Vob.GetProtection(protectionIndex); + + // Like G1: protection -1 means immune to this damage type; otherwise no damage when fully absorbed. + var damage = protection < 0 ? 0 : Mathf.Max(0, weaponDamage + strength - protection); if (damage <= 0) damage = 10; // debug: force minimum 10 until proper damage calculation is implemented @@ -88,6 +96,21 @@ private bool OnHitUpdateHealth(NpcContainer attacker, NpcContainer target) return hitPoints <= 0; } + /// + /// C_ITEM.damageType is a DAM_* bitmask whose bit positions match the PROT_* indices. + /// Weapons carry one damage type; the first set bit wins. + /// + private static int GetProtectionIndex(int damageTypeMask) + { + for (var i = 0; i < 8; i++) + { + if ((damageTypeMask & (1 << i)) != 0) + return i; + } + + return (int)DamageType.Blunt; + } + private void OnDyingChangeAnimation(NpcContainer target) { // Clear pending AI queue and stop all running animations (e.g. s_walk still looping). @@ -96,6 +119,10 @@ private void OnDyingChangeAnimation(NpcContainer target) target.PrefabProps.AnimationSystem.StopAllAnimations(); _physicsService.DisablePhysicsForNpc(target.PrefabProps); + // Drop any queued non-death actions (e.g. a GoToWp/UseMob enqueued the same frame the killing hit + // landed). Otherwise AiHandler's dead-NPC branch would play them out on the corpse before dying. + target.Props.AnimationQueue.Clear(); + var animName = _animationService.GetAnimationName(VmGothicEnums.AnimationType.DeadB, target); target.PrefabProps.AnimationSystem.PlayAnimation(animName); } From 8b0958be603ab74ab49ed0610d5eec0927392b05 Mon Sep 17 00:00:00 2001 From: Jucan Andrei Daniel Date: Sat, 13 Jun 2026 13:03:22 +0300 Subject: [PATCH 5/5] fix: walk/turn/use-mob action sequencing and stepwise stop Fixes GoToFp orphan-walk slide, UseMob slot tags with stepwise stop, and turn/walk action transitions. --- .../AbstractRotateAnimationAction.cs | 10 +-- .../AbstractWalkAnimationAction.cs | 4 +- .../AbstractWalkAnimationAction2.cs | 26 +++++-- .../Npc/Actions/AnimationActions/GoToFp.cs | 9 ++- .../Npc/Actions/AnimationActions/GoToWp.cs | 12 +-- .../Npc/Actions/AnimationActions/Output.cs | 12 ++- .../Npc/Actions/AnimationActions/PlayAni.cs | 16 ++++ .../Npc/Actions/AnimationActions/StandUp.cs | 13 ++++ .../Npc/Actions/AnimationActions/UseMob.cs | 75 +++++++++---------- 9 files changed, 108 insertions(+), 69 deletions(-) diff --git a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/AbstractRotateAnimationAction.cs b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/AbstractRotateAnimationAction.cs index 3573266c0..36505aa3b 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/AbstractRotateAnimationAction.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/AbstractRotateAnimationAction.cs @@ -2,7 +2,6 @@ using Gothic.Core.Models.Vm; using Gothic.Core.Services; using Gothic.Core.Services.Npc; -using Reflex.Attributes; using UnityEngine; namespace Gothic.Core.Domain.Npc.Actions.AnimationActions @@ -53,9 +52,9 @@ public override void Start() return; } - // https://discussions.unity.com/t/determining-whether-to-rotate-left-or-right/44021 - var cross = Vector3.Cross(NpcGo.transform.forward, _finalRotation.eulerAngles); - _isRotateLeft = cross.y >= 0; + // Negative signed angle around the up axis means the target direction is to our left. + var targetForward = _finalRotation * Vector3.forward; + _isRotateLeft = Vector3.SignedAngle(NpcGo.transform.forward, targetForward, Vector3.up) < 0; if (PlayAnimation) { @@ -92,7 +91,8 @@ private void HandleRotation(Transform npcTransform) // Check if rotation is done. if (Quaternion.Angle(npcTransform.rotation, _finalRotation) < 1f) { - PrefabProps.AnimationSystem.StopAnimation(_rotationAnimationName); + if (_rotationAnimationName != null) + PrefabProps.AnimationSystem.StopAnimation(_rotationAnimationName); IsFinishedFlag = true; } diff --git a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/AbstractWalkAnimationAction.cs b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/AbstractWalkAnimationAction.cs index 0686d51a1..c3f38add2 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/AbstractWalkAnimationAction.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/AbstractWalkAnimationAction.cs @@ -81,9 +81,7 @@ public override void Tick() private string GetWalkModeAnimationString() { var fightMode = (VmGothicEnums.WeaponState)Vob.FightMode; - var weaponState = fightMode == VmGothicEnums.WeaponState.NoWeapon - ? "" - : fightMode.ToString(); + var weaponState = Services.Npc.AnimationService.GetWeaponAnimationPrefix(fightMode); var walkMode = (VmGothicEnums.WalkMode)Vob.AiHuman.WalkMode; switch (walkMode) { diff --git a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/AbstractWalkAnimationAction2.cs b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/AbstractWalkAnimationAction2.cs index 5b5e90322..9241a203c 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/AbstractWalkAnimationAction2.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/AbstractWalkAnimationAction2.cs @@ -2,7 +2,6 @@ using Gothic.Core.Models.Container; using Gothic.Core.Models.Vm; using Gothic.Core.Services.Npc; -using Reflex.Attributes; using UnityEngine; namespace Gothic.Core.Domain.Npc.Actions.AnimationActions @@ -12,6 +11,11 @@ public abstract class AbstractWalkAnimationAction2 : AbstractAnimationAction protected Transform NpcTransform => NpcGo.transform; protected bool IsDestReached; + // Name of the animation StartWalk() actually played. StopWalk() must stop exactly this one: + // recalculating the name would stop the wrong animation when walk/fight mode changed mid-walk + // (e.g. via an immediately executed AI_SetWalkmode), leaving the walk loop sliding the NPC forever. + private string _startedWalkAnimationName; + protected AbstractWalkAnimationAction2(AnimationAction action, NpcContainer npcContainer) : base(action, npcContainer) { } @@ -34,6 +38,16 @@ public override void Start() if (IsDestinationReached()) { OnDestinationReached(); + + // Already at the final destination (e.g. a FP_ROAM FreePoint right next to the NPC): + // never start the walk loop - nobody would stop it again and its root motion + // would slide the NPC around (visible e.g. on roaming Molerats). + // IsDestReached covers subclasses which continue at the spot without finishing + // (e.g. UseMob playing its transition animation) - the walk loop would blend + // that animation out again. Only a multi-stop route (GoToWp) resets the flag + // and walks on. + if (IsFinishedFlag || IsDestReached) + return; } StartWalk(); @@ -59,16 +73,18 @@ protected virtual void StartWalk() { PhysicsService.EnablePhysicsForNpc(PrefabProps); - var animName = AnimationService.GetAnimationName(VmGothicEnums.AnimationType.Move, NpcContainer); - PrefabProps.AnimationSystem.PlayAnimation(animName); + _startedWalkAnimationName = AnimationService.GetAnimationName(VmGothicEnums.AnimationType.Move, NpcContainer); + PrefabProps.AnimationSystem.PlayAnimation(_startedWalkAnimationName); } protected virtual void StopWalk() { PhysicsService.EnablePhysicsForNpc(PrefabProps); - var animName = AnimationService.GetAnimationName(VmGothicEnums.AnimationType.Move, NpcContainer); - PrefabProps.AnimationSystem.StopAnimation(animName); + if (_startedWalkAnimationName != null) + { + PrefabProps.AnimationSystem.StopAnimation(_startedWalkAnimationName); + } } private bool IsDestinationReached() diff --git a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/GoToFp.cs b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/GoToFp.cs index b352a2c7e..06b694288 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/GoToFp.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/GoToFp.cs @@ -10,8 +10,6 @@ public class GoToFp : AbstractWalkAnimationAction2 private string _destination => Action.String0; - private FreePoint _freePoint; - public GoToFp(AnimationAction action, NpcContainer npcContainer) : base(action, npcContainer) { } @@ -27,9 +25,14 @@ public override void Start() return; } + // Free the FP we still hold from a previous GoToFp. Otherwise every FP an NPC ever + // visited stays locked until the NPC is culled, and roaming runs out of free FPs. + if (Props.CurrentFreePoint != null && Props.CurrentFreePoint != _fp) + Props.CurrentFreePoint.IsLocked = false; + _fp.IsLocked = true; Props.CurrentFreePoint = _fp; - + base.Start(); } diff --git a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/GoToWp.cs b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/GoToWp.cs index 2cf9ab28e..8795ae16d 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/GoToWp.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/GoToWp.cs @@ -45,17 +45,6 @@ public override void Start() base.Start(); } - /// - /// Skip animation setting if we're on the final destination right from the start. - /// - protected override void StartWalk() - { - if (!IsFinishedFlag) - { - base.StartWalk(); - } - } - protected override Vector3 GetWalkDestination() { return _route.Peek().Position; @@ -79,6 +68,7 @@ protected override void OnDestinationReached() return; } + StopWalk(); AnimationEnd(); IsFinishedFlag = true; diff --git a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Output.cs b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Output.cs index 8b1f54eae..767a8d8e5 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Output.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/Output.cs @@ -6,11 +6,11 @@ using Gothic.Core.Services.Caches; using Gothic.Core.Services.Config; using Gothic.Core.Services.Npc; -using Gothic.Core.Adapters.Npc; using Gothic.Core.Extensions; -using Gothic.Core.Const; +using Gothic.Core.Logging; using Reflex.Attributes; using UnityEngine; +using Logger = Gothic.Core.Logging.Logger; using Random = UnityEngine.Random; namespace Gothic.Core.Domain.Npc.Actions.AnimationActions @@ -50,6 +50,14 @@ public override void Start() } var audioClip = _audioService.CreateAudioClip(OutputName); + + if (audioClip == null) + { + Logger.LogWarning($"AudioClip >{OutputName}< not found. Skipping speech output.", LogCat.Dialog); + IsFinishedFlag = true; + return; + } + _audioPlaySeconds = audioClip.length; // Hero diff --git a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/PlayAni.cs b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/PlayAni.cs index bb5e42c2b..d01f7da1d 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/PlayAni.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/PlayAni.cs @@ -21,5 +21,21 @@ public override void Start() } ActionEndEventTime = PrefabProps.AnimationSystem.GetAnimationDuration(_animName); } + + public override void Tick() + { + base.Tick(); + + if (IsFinishedFlag) + return; + + // An external stop (e.g. AI_StopAni) triggered the track's blend-out before + // the natural duration elapsed — finish the action now rather than waiting + // for the timer. + if (PrefabProps.AnimationSystem.IsAnimationBlendingOut(_animName)) + { + AnimationEnd(); + } + } } } diff --git a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/StandUp.cs b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/StandUp.cs index 3daa0f128..77f397292 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/StandUp.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/StandUp.cs @@ -1,4 +1,5 @@ using Gothic.Core.Models.Container; +using Gothic.Core.Models.Vm; namespace Gothic.Core.Domain.Npc.Actions.AnimationActions { @@ -10,6 +11,18 @@ public StandUp(AnimationAction action, NpcContainer npcContainer) : base(action, public override void Start() { + // G1 documentation: while using a Mobsi, AI_StandUp pops the NPC to standing without back-transitions. + if (PrefabProps.CurrentInteractable != null) + { + PrefabProps.CurrentInteractable = null; + PrefabProps.CurrentInteractableSlot = null; + Props.CurrentInteractableStateId = -1; + Props.BodyState = VmGothicEnums.BodyState.BsStand; + + PhysicsService.EnablePhysicsForNpc(PrefabProps); + } + + // Playing the idle blends out a possibly running Mobsi loop animation on the same layer. PrefabProps.AnimationSystem.PlayIdleAnimation(); } diff --git a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/UseMob.cs b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/UseMob.cs index 57831ccc5..79f11717d 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/UseMob.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Npc/Actions/AnimationActions/UseMob.cs @@ -13,7 +13,7 @@ namespace Gothic.Core.Domain.Npc.Actions.AnimationActions public class UseMob : AbstractWalkAnimationAction2 { private const string _mobTransitionAnimationString = "T_{0}{1}{2}_2_{3}"; - private const string _mobLoopAnimationString = "S_{0}_S{1}"; + private const string _mobLoopAnimationString = "S_{0}{1}S{2}"; private VobContainer _mobContainer; private GameObject _slotGo; private Vector3 _destination; @@ -23,6 +23,9 @@ public class UseMob : AbstractWalkAnimationAction2 private int _desiredState => Action.Int0; private bool IsStopUsingMob => _desiredState <= -1; + // -1 is the not-in-use state; scripts may send any negative value to stop. + private int TargetState => IsStopUsingMob ? -1 : _desiredState; + private bool _isMobFoundButNotYetInitialized; private string _currentMobAnimation; @@ -40,6 +43,11 @@ public override void Start() _slotGo = PrefabProps.CurrentInteractableSlot; _mobsiScheme = _mobContainer.Props.GetVisualScheme(); + // We already stand at the slot. Without this, Tick() would treat the unset + // _destination (0,0,0) as walk target and never reach TickMobUsage(). + _destination = _slotGo.transform.position; + IsDestReached = true; + StartMobUseAnimation(); return; } @@ -49,7 +57,8 @@ public override void Start() _mobContainer = container; _mobsiScheme = _mobContainer?.Props.GetVisualScheme(); - if (container!.Go == null) + // No free Mobsi of this scheme within reach (e.g. all occupied by other NPCs). + if (container == null || !container.Go) { IsFinishedFlag = true; return; @@ -63,9 +72,6 @@ public override void Start() private void StartNow() { - // We call Start only if the Mobsi is already available. - base.Start(); - _isMobFoundButNotYetInitialized = false; var slot = GetNearestMobSlot(); @@ -83,6 +89,10 @@ private void StartNow() PrefabProps.CurrentInteractableSlot = _slotGo; SetBodyState(); + + // base.Start() checks the walk destination and may start the walk loop - it must only + // run once _destination is set, and not at all when no slot was found. + base.Start(); } private void SetBodyState() @@ -136,11 +146,13 @@ private void TickMobUsage() { if (PrefabProps.AnimationSystem.IsPlaying(_currentMobAnimation)) return; - - UpdateState(); + + // A finished transition moves the state one step toward the target (e.g. S1 -> S0 -> Stand when stopping). + if (Props.CurrentInteractableStateId != TargetState) + UpdateState(); // If we arrived at the Mobsi, we will further execute the transitions step-by-step until demanded state is reached. - if (Props.CurrentInteractableStateId != _desiredState) + if (Props.CurrentInteractableStateId != TargetState) { PlayTransitionAnimation(); return; @@ -151,6 +163,7 @@ private void TickMobUsage() { PrefabProps.CurrentInteractable = null; PrefabProps.CurrentInteractableSlot = null; + Props.CurrentItem = -1; Props.BodyState = VmGothicEnums.BodyState.BsStand; PhysicsService.EnablePhysicsForNpc(PrefabProps); @@ -158,7 +171,8 @@ private void TickMobUsage() // Loop Mobsi animation until the same UseMob with -1 is called. else { - var animName = string.Format(_mobLoopAnimationString, _mobsiScheme, _desiredState); + // Loop animations carry the slot position as well (e.g. s_Bed_Front_S1 vs. s_Cauldron_S1). + var animName = string.Format(_mobLoopAnimationString, _mobsiScheme, GetSlotPositionTag(_slotGo.name), TargetState); PrefabProps.AnimationSystem.PlayAnimation(animName); } @@ -205,7 +219,10 @@ private void StartMobUseAnimation() NpcGo.transform.SetPositionAndRotation(_slotGo.transform.position, _slotGo.transform.rotation); - PlayTransitionAnimation(); + // Already in the demanded state (e.g. a repeated AI_UseMob with the same state): + // TickMobUsage() will replay the loop animation and finish without a transition. + if (Props.CurrentInteractableStateId != TargetState) + PlayTransitionAnimation(); } private string GetSlotPositionTag(string name) @@ -230,41 +247,19 @@ protected override Vector3 GetWalkDestination() private void UpdateState() { - // FIXME - We need to check. For Cauldron/Cook we have only t_s0_2_Stand, but not t_s1_2_s0 - But is it for all of them? - if (IsStopUsingMob) - { - Props.CurrentInteractableStateId = -1; - Props.CurrentItem = -1; - } - else - { - var newStateAddition = Props.CurrentInteractableStateId > _desiredState ? -1 : +1; - Props.CurrentInteractableStateId += newStateAddition; - } + var step = Props.CurrentInteractableStateId > TargetState ? -1 : +1; + Props.CurrentInteractableStateId += step; } private void PlayTransitionAnimation() { - string from; - string to; + var current = Props.CurrentInteractableStateId; + var next = current + (TargetState > current ? +1 : -1); - // FIXME - We need to check. For Cauldron/Cook we have only t_s0_2_Stand, but not t_s1_2_s0 - But is it for all of them? - if (IsStopUsingMob) - { - from = "S0"; - to = "Stand"; - } - else - { - from = Props.CurrentInteractableStateId.ToString(); - to = $"S{Props.CurrentInteractableStateId + 1}"; - - from = from switch - { - "-1" => "Stand", - _ => $"S{from}" - }; - } + // -1 is the not-in-use state and is named Stand inside the animations. + // Both directions exist as own animations (e.g. t_Cauldron_Stand_2_S0, t_Cauldron_S0_2_S1, t_Cauldron_S1_2_S0). + var from = current == -1 ? "Stand" : $"S{current}"; + var to = next == -1 ? "Stand" : $"S{next}"; var slotPositionName = GetSlotPositionTag(_slotGo.name); var animName = string.Format(_mobTransitionAnimationString, _mobsiScheme, slotPositionName, from, to);