Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,15 @@ MonoBehaviour:
ShowVOBMeshCullingGizmos: 0
ShowCapsuleOverlapGizmos: 0
EnableNpcs: 1
EnableCombatSystem: 1
EnableNpcMeshCulling: 1
NpcCullingDistance: 50
SpawnNpcInstances:
Value:
SpawnMonsterInstances:
Value: 00000000
EnableNpcEyeBlinking: 0
EnableNpcLooting: 1
ShowNpcColliders: 0
ShowFreePoints: 0
ShowWayPoints: 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ protected void StartAnimation(string morphMeshName, [CanBeNull] string animation
var newMorph = new MorphAnimationData();

newMorph.MeshMetadata = _resourceCacheService.TryGetMorphMesh(morphMeshName);
if (newMorph.MeshMetadata == null)
{
Logger.LogWarning($"MorphMesh not found: {morphMeshName}", LogCat.Mesh);
return;
}

newMorph.AnimationMetadata = animationName == null
? newMorph.MeshMetadata.Animations.First()
: newMorph.MeshMetadata.Animations.First(anim => anim.Name.EqualsIgnoreCase(animationName));
Expand Down
1 change: 1 addition & 0 deletions Assets/Gothic-Core/Scripts/Adapters/Npc/NpcLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ public class NpcLoader : MonoBehaviour
public bool IsLoaded;
}
}

This file was deleted.

22 changes: 19 additions & 3 deletions Assets/Gothic-Core/Scripts/Domain/Culling/VobMeshCullingDomain.cs
Original file line number Diff line number Diff line change
Expand Up @@ -489,8 +489,13 @@ public void StartTrackVobPositionUpdates(GameObject go)
}

if (index == -1)
Logger.LogError($"Couldn't find object in Culling list {rootGo.name}. Culling updates will break.",
{
// Dynamically spawned items (loot panel, backpack fill) are not registered in the culling lists.
// This is expected — just skip tracking for them.
Logger.LogWarning($"VOB {rootGo.name} not in culling list — skipping position tracking (dynamically spawned).",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah, good one. We should add it to the list later. But the comment remarks it already as TODO.

LogCat.Vob);
return;
}

_pausedVobs.Add(rootGo, new Tuple<VobList, int>(vobType, index));
}
Expand Down Expand Up @@ -542,6 +547,11 @@ private IEnumerator StopTrackVobPositionUpdatesDelayed(GameObject rootGo)
{
yield return new WaitForSeconds(1f);
_pausedVobsToReenableCoroutine.Remove(rootGo);

// GO may have been destroyed (e.g., loot panel closed) during the 1-second delay.
if (rootGo == null)
yield break;

if (!_pausedVobsToReenable.ContainsKey(rootGo))
{
_pausedVobsToReenable.Add(rootGo, rootGo.GetComponentInChildren<Rigidbody>());
Expand All @@ -567,9 +577,11 @@ private IEnumerator StopVobTrackingBasedOnVelocity()
continue;
}

UpdateSpherePosition(key);
rigidBody.isKinematic = true;
// Item may not be in _pausedVobs if it was dynamically spawned and never registered in culling lists.
if (_pausedVobs.ContainsKey(key))
UpdateSpherePosition(key);

rigidBody.isKinematic = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh very good one. Otherwise they always keep their physiscs. Also after disabling->enabling. You saved us a lot of headache for future performance bottlenecks if we have too many physical objects...

_pausedVobs.Remove(key);
_pausedVobsToReenable.Remove(key);
}
Expand All @@ -593,6 +605,10 @@ private void UpdateSpherePosition(GameObject go)
_ => throw new ArgumentOutOfRangeException()
};

// Index may be stale if other VOBs were removed from the list after this item was grabbed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this will happen as we currently don't add dynamic (new) objects into the list. But it's ok as a safety check and won't harm.

if (index >= sphereList.Count)
return;

sphereList[index] = new BoundingSphere(go.transform.position, sphereList[index].radius);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,7 @@ private GameObject CreateItemMesh(ItemInstance item, GameObject go, GameObject p

if (mrm != null)
{
return _meshService.CreateVob(item.Visual, mrm, parent: parent, rootGo: go, useColliderCache: true);
return _meshService.CreateVob(item.Visual, mrm, parent: parent, rootGo: go, useColliderCache: true, useTextureArray: false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you put it to false? Each item's mesh should be cached already so that you can use the texture array. 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swords and bows when taken out of the loot socket had 'earth' texture - Weapons exclusive to an NPC's inventory were never pre-cached into the world TextureArray, causing the shader to fall back to array index 0 (a terrain texture). Fixed by passing useTextureArray: false

}

// shortbow (itrw_bow_l_01) has no mrm, but has mmb
Expand Down
9 changes: 8 additions & 1 deletion Assets/Gothic-Core/Scripts/Models/Config/DeveloperConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,9 @@ public class DebugChannelTypesCollection : CollectionWrapper<DeveloperConfigEnum
[OverrideLabel("Enable NPCs & Monsters")]
public bool EnableNpcs;

[Tooltip("Enable player→NPC melee combat (hit detection, damage, hurt/death animations). WIP - debug damage values only.")]
public bool EnableCombatSystem;

[ConditionalField(fieldToCheck: nameof(EnableNpcs), compareValues: true)]
public bool EnableNpcMeshCulling = true;

Expand All @@ -197,7 +200,11 @@ public class DebugChannelTypesCollection : CollectionWrapper<DeveloperConfigEnum
[Tooltip("WIP - Not production ready.")]
[ConditionalField(fieldToCheck: nameof(EnableNpcs), compareValues: true)]
public bool EnableNpcEyeBlinking;


[Separator("WIP")]
[Tooltip("Enable looting dead NPCs/monsters: grab a dead NPC to open a loot panel with their Daedalus inventory. WIP.")]
public bool EnableNpcLooting;

[Separator("Debug")]
[Tooltip("Draw wireframe boxes for all NPC bone colliders. Green = active (attack window), White = inactive. Works in all builds including release.")]
public bool ShowNpcColliders;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public void SetDynamicValue(GameObject go, int shaderProperty, float shaderValue
ActivateDynamicRenderers(entry);

// Finally set the new values.
entry.Renderers.ForEach(i => i.sharedMaterial.SetFloat(shaderProperty, shaderValue));
entry.Renderers.ForEach(i => { if (i != null) i.sharedMaterial.SetFloat(shaderProperty, shaderValue); });

// And we add the property to the list of "changed" properties.
entry.AlteredShaderProperties.Add(shaderProperty);
Expand All @@ -70,7 +70,7 @@ public void ResetDynamicValue(GameObject go, int shaderProperty, float shaderVal
}

// Reset values
entry.Renderers.ForEach(i => i.sharedMaterial.SetFloat(shaderProperty, shaderValue));
entry.Renderers.ForEach(i => { if (i != null) i.sharedMaterial.SetFloat(shaderProperty, shaderValue); });
entry.AlteredShaderProperties.Remove(shaderProperty);

if (entry.AlteredShaderProperties.IsEmpty())
Expand Down Expand Up @@ -144,6 +144,9 @@ private void ActivateDynamicRenderers(CacheEntry entry)

for (var i = 0; i < entry.Renderers.Count; i++)
{
if (entry.Renderers[i] == null)
continue;

var dynamicMaterial = entry.DynamicMaterials[i];

// It's a shader we didn't touch.
Expand All @@ -165,12 +168,15 @@ private void DeactivateDynamicRenderers(CacheEntry entry)

for (var i = 0; i < entry.Renderers.Count; i++)
{
if (entry.Renderers[i] == null)
continue;

var defaultMaterial = entry.DefaultMaterials[i];

// It's a shader we didn't touch.
if (defaultMaterial == null)
continue;

entry.Renderers[i].sharedMaterial = defaultMaterial;
}

Expand Down
29 changes: 28 additions & 1 deletion Assets/Gothic-Core/Scripts/Services/Npc/FightService.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
using Gothic.Core.Adapters.UI.StatusBars;
using Gothic.Core.Domain.Npc.Actions.AnimationActions;
using Gothic.Core.Logging;
using Gothic.Core.Manager;
using Gothic.Core.Models.Container;
using Gothic.Core.Models.Vm;
using Gothic.Core.Services.Config;
using Gothic.Core.Services.Vm;
using Gothic.Core.Services.World;
using Reflex.Attributes;
using UnityEngine;
using ZenKit.Daedalus;
using Logger = Gothic.Core.Logging.Logger;

namespace Gothic.Core.Services.Npc
{
Expand All @@ -18,21 +21,28 @@ public class FightService
[Inject] private AnimationService _animationService;
[Inject] private PhysicsService _physicsService;
[Inject] private NpcHelperService _npcHelperService;
[Inject] private readonly ConfigService _configService;

public void Init()
{
if (!_configService.Dev.EnableCombatSystem)
return;

GlobalEventDispatcher.FightHit.AddListener(OnHit);
}

private void OnHit(NpcContainer attacker, NpcContainer target, Vector3 __)
{
Logger.Log($"[FightService.OnHit] *** {attacker.Instance.GetName(NpcNameSlot.Slot0)} HIT {target.Instance.GetName(NpcNameSlot.Slot0)}", LogCat.Npc);
if (OnHitUpdateHealth(attacker, target))
{
Logger.Log($"[FightService.OnHit] {target.Instance.GetName(NpcNameSlot.Slot0)} is DEAD", LogCat.Npc);
target.Props.BodyState = VmGothicEnums.BodyState.BsDead;
OnDyingChangeAnimation(target);
}
else
{
Logger.Log($"[FightService.OnHit] {target.Instance.GetName(NpcNameSlot.Slot0)} took damage, playing hurt animation", LogCat.Npc);
OnHitChangeAnimation(target);
OnHitPlaySound(target);
}
Expand All @@ -46,17 +56,34 @@ private bool OnHitUpdateHealth(NpcContainer attacker, NpcContainer target)
{
// FIXME - We need to handle this via power and skill level of attacker, not weapon alone.
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;
if (damage <= 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you find a weapon with <0 damage? Which one was it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uriziel :D

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well uriziel has got 0 damage not less than zero but <= is a arithmethic check habit

damage = 10; // debug: force minimum 10 until proper damage calculation is implemented

Logger.Log($"[FightService.OnHitUpdateHealth] {target.Instance.GetName(NpcNameSlot.Slot0)}: {hitPoints} - {damage} dmg", LogCat.Npc);

hitPoints -= damage;

Logger.Log($"[FightService.OnHitUpdateHealth] {target.Instance.GetName(NpcNameSlot.Slot0)} HP after: {hitPoints}/{maxHP}", LogCat.Npc);

target.Vob.SetAttribute((int)NpcAttribute.HitPoints, hitPoints);

target.Go.GetComponentInChildren<StatusBarAdapter>(true)?.SetFillAmount(hitPoints, target.Vob.GetAttribute((int)NpcAttribute.HitPointsMax));
var statusBar = target.Go.GetComponentInChildren<StatusBarAdapter>(true);
if (statusBar != null)
{
Logger.Log($"[FightService.OnHitUpdateHealth] Updating HP bar for {target.Instance.GetName(NpcNameSlot.Slot0)}", LogCat.Npc);
statusBar.SetFillAmount(hitPoints, maxHP);
}
else
{
Logger.LogWarning($"[FightService.OnHitUpdateHealth] No StatusBar found for {target.Instance.GetName(NpcNameSlot.Slot0)}", LogCat.Npc);
}

return hitPoints <= 0;
}
Expand Down
24 changes: 24 additions & 0 deletions Assets/Gothic-Core/Scripts/Services/Npc/NpcInventoryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,30 @@ public List<ContentItem> GetInventoryItems(NpcInstance npc, VmGothicEnums.InvCat
return _vobService.UnpackItems(npcVob.GetPacked((int)category));
}

/// <summary>
/// Returns all items across every category. Each InvCats slot only exists in ZenKit if
/// SetPacked was previously called for it — accessing a missing slot throws from native code.
/// This method silently skips slots that were never initialized.
/// </summary>
public List<ContentItem> GetAllInventoryItems(NpcInstance npc)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good one. I like this method. Short, pinpointed, and working. :-)

{
var items = new List<ContentItem>();
foreach (VmGothicEnums.InvCats cat in System.Enum.GetValues(typeof(VmGothicEnums.InvCats)))
{
if (cat == VmGothicEnums.InvCats.InvCatMax)
continue;
try
{
items.AddRange(GetInventoryItems(npc, cat));
}
catch
{
// Slot was never initialized for this NPC — expected when a category has no items
}
}
return items;
}

public int ExtNpcHasItems(NpcInstance npc, int itemId)
{
var npcVob = npc.GetUserData()!.Vob;
Expand Down
13 changes: 10 additions & 3 deletions Assets/Gothic-Core/Scripts/Services/World/WayNetService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,16 @@ public FreePoint FindNearestFreePoint(Vector3 lookupPosition, string fpNamePart,

public DijkstraWaypoint[] FindFastestPath(string startWaypoint, string endWaypoint)
{
// Get the start and end waypoints from the DijkstraWaypoints dictionary
var startDijkstraWaypoint = _gameStateService.DijkstraWaypoints[startWaypoint];
var endDijkstraWaypoint = _gameStateService.DijkstraWaypoints[endWaypoint];
if (!_gameStateService.DijkstraWaypoints.ContainsKey(startWaypoint))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you find errors for this waypoint topic? Out of curiousity: Which NPC?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KeyNotFoundException: The given key 'SPAWN_MOLELRAT_TOTU_LEFT_PLAT4' was not present in the dictionary.
System.Collections.Generic.Dictionary`2[TKey,TValue].get_Item (TKey key)
Gothic.Core.Creator.WayNetService.FindFastestPath (string startWaypoint, string endWaypoint)
Gothic.Core.Domain.Npc.Actions.AnimationActions.GoToWp.Start ()
Gothic.Core.Adapters.Npc.AiHandler.PlayNextAnimation (...)
Gothic.Core.Adapters.Npc.AiHandler.Update ()
...
NullReferenceException: Object reference not set to an instance of an object
Gothic.Core.Domain.Npc.Actions.AnimationActions.GoToWp.GetWalkDestination ()
Gothic.Core.Domain.Npc.Actions.AnimationActions.AbstractWalkAnimationAction2.IsDestinationReached ()

Molerats had SPAWN_MOLELRAT_TOTU_LEFT_PLAT4 set as their navigation target, but this waypoint only exists as a spawn point — it's not registered as a navigation node in the WayNet. FindFastestPath was doing a direct dictionary lookup (dict[key]), which threw a KeyNotFoundException, and the returned null then caused a cascade NullReferenceException in GoToWp.GetWalkDestination. Fixed by guarding with ContainsKey before the lookup and returning null early with a warning log.

{
Logger.LogWarning($"FindFastestPath: start waypoint '{startWaypoint}' not found in WayNet.", LogCat.Npc);
return null;
}
if (!_gameStateService.DijkstraWaypoints.TryGetValue(endWaypoint, out var endDijkstraWaypoint))
{
Logger.LogWarning($"FindFastestPath: end waypoint '{endWaypoint}' not found in WayNet.", LogCat.Npc);
return null;
}

// Initialize the previousNodes dictionary to keep track of the path
var previousNodes = new Dictionary<string, DijkstraWaypoint>();
Expand Down
11 changes: 9 additions & 2 deletions Assets/Gothic-VR/Resources/VR/Prefabs/Vobs/oCItem/Weapon.prefab
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Transform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!96 &4431916770342467661
TrailRenderer:
serializedVersion: 3
serializedVersion: 4
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
Expand All @@ -53,6 +53,8 @@ TrailRenderer:
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
Expand All @@ -74,9 +76,11 @@ TrailRenderer:
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
m_Time: 0.77
m_PreviewTimeScale: 1
m_Parameters:
Expand Down Expand Up @@ -135,7 +139,6 @@ TrailRenderer:
shadowBias: 0.5
generateLightingData: 0
m_MinVertexDistance: 0.1
m_MaskInteraction: 0
m_Autodestruct: 0
m_Emitting: 1
m_ApplyActiveColorSpace: 1
Expand Down Expand Up @@ -191,6 +194,10 @@ PrefabInstance:
propertyPath: m_LocalEulerAnglesHint.z
value: 0
objectReference: {fileID: 0}
- target: {fileID: 5406461743051542922, guid: b8c776d1865a6014385673931a399ebc, type: 3}
propertyPath: m_PresetInfoIsWorld
value: 1
objectReference: {fileID: 0}
- target: {fileID: 6503575194566409312, guid: b8c776d1865a6014385673931a399ebc, type: 3}
propertyPath: m_Name
value: Weapon
Expand Down
Loading