Enhanced loot drops system

This commit is contained in:
MaddoScientisto 2026-03-01 19:14:34 +01:00
commit acc61f9a0e
12 changed files with 661 additions and 443 deletions

View file

@ -2,6 +2,7 @@
using System.Linq;
using Cirno.Scripts.Controllers;
using Cirno.Scripts.Resources;
using Cirno.Scripts.Resources.Loot;
using Cirno.Scripts.Utils;
using Cirno.Scripts.Weapons;
using Godot;
@ -30,12 +31,19 @@ public partial class Destructible3D : StaticBody3D, IDestructible
[Export] public Node Target { get; private set; }
[Export] public string TargetGroup { get; protected set; }
[ExportCategory("Loot Drops")]
[Export] public Array<LootDrop> LootDrops { get; set; } = [];
/// <summary>Radius of the circle in which dropped items are scattered uniformly.</summary>
[Export] public float LootScatterRadius { get; set; } = 1.0f;
/// <summary>Initial upward speed applied to each dropped item so it pops up before falling.</summary>
[Export] public float LootLaunchUpSpeed { get; set; } = 3.0f;
[Signal]
public delegate void ExplodedEventHandler();
private float _currentHealth = 0f;
private bool _isDestroyed = false;
private readonly RandomNumberGenerator _rng = new();
public override void _Ready()
{
@ -86,6 +94,7 @@ public partial class Destructible3D : StaticBody3D, IDestructible
CreateExplosion();
CreateParticles();
CreateDebris();
DropLoot();
QueueFree();
@ -137,6 +146,11 @@ public partial class Destructible3D : StaticBody3D, IDestructible
particle.Emitting = true;
}
private void DropLoot()
{
LootDropHelper.SpawnDrops(LootDrops, this, GlobalPosition, LootScatterRadius, LootLaunchUpSpeed, _rng);
}
public void Hit(float damage, DamageType damageType = DamageType.Neutral)
{

View file

@ -2,74 +2,58 @@
using Cirno.Scripts.Components.FSM.Enemy._3D;
using Cirno.Scripts.Enums;
using Cirno.Scripts.Resources;
using Cirno.Scripts.Resources.Loot;
using Godot;
namespace Cirno.Scripts.Components.Actors._3D;
public partial class EnemyDropModule3D : ModuleBase<EnemyState, CharacterBody3D>
{
[Export] public EnemyStorage3D StorageModule { get; private set; }
/// <summary>Radius of the circle in which dropped items are scattered uniformly.</summary>
[Export] public float LootScatterRadius { get; set; } = 1.0f;
/// <summary>Initial upward speed applied to each dropped item so it pops up before falling.</summary>
[Export] public float LootLaunchUpSpeed { get; set; } = 3.0f;
private bool _initialized = false;
private bool _enabled = false;
private RandomNumberGenerator _rng = new ();
private RandomNumberGenerator _rng = new();
public override void EnterState(EnemyState state)
{
_enabled = true;
foreach (var loot in StorageModule.EnemyData.LootDrops)
{
if (loot is not { Item: not null }) continue;
var roll = _rng.RandfRange(0f, 100f); // Generate a number between 0 and 100
if (roll <= loot.Chance) // Compare with drop chance
{
DropItem(loot.Item);
}
}
LootDropHelper.SpawnDrops(
StorageModule.LootDrops,
_machine.MainObject,
_machine.MainObject.GlobalPosition,
LootScatterRadius,
LootLaunchUpSpeed,
_rng);
}
private void DropItem(LootItem item)
{
if (!string.IsNullOrWhiteSpace(item.DropScenePath3D))
{
item.Spawn3D(_machine.MainObject);
GD.Print($"Dropped item: {item.ItemName}");
//var scene = GD.Load<PackedScene>(item.DropScenePath3D);
//_actor.CreateSibling<Node3D>(scene);
}
else
{
GD.Print($"Skipping Item with missing path: {item.ItemName}");
}
}
public override void ExitState(EnemyState state)
{
_enabled = false;
}
private IStateMachine<EnemyState, CharacterBody3D> _machine;
public override void Init(IStateMachine<EnemyState, CharacterBody3D> machine)
{
if (_initialized) return;
_machine = machine;
}
public override void Process(double delta)
{
}
public override void PhysicsProcess(double delta)
{
}
}
}

View file

@ -7,16 +7,70 @@ namespace Cirno.Scripts.Interactables;
public partial class ItemPickup3D : Interactable3D
{
private const float GravityAccel = 9.8f;
// How far below the item to probe for the floor each physics step.
private const float FloorProbeDistance = 10f;
[Export] public Array<LootItem> LootTable = [];
/// <summary>
/// Distance from the item's origin to its bottom edge.
/// Lifts the item above the floor surface so it doesn't clip into it.
/// Default matches the GenericItem3D collision shape: offset (0.065) + half cylinder height (0.269).
/// </summary>
[Export] public float GroundOffset { get; set; } = 0.204f;
private bool _autoPickup = false;
private bool _simulating = false;
private Vector3 _velocity = Vector3.Zero;
public bool AutoPickup => _autoPickup;
public override void _Ready()
{
_autoPickup = LootTable.Any(x => x.AutoPickup);
}
/// <summary>
/// Applies an initial velocity and enables gravity simulation so the pickup
/// arcs through the air and settles on the floor.
/// </summary>
public void Launch(Vector3 velocity)
{
_velocity = velocity;
_simulating = true;
}
public override void _PhysicsProcess(double delta)
{
if (!_simulating) return;
_velocity.Y -= GravityAccel * (float)delta;
var nextPosition = GlobalPosition + _velocity * (float)delta;
// Cast downward to detect the floor so we don't sink through it.
var spaceState = GetWorld3D()?.DirectSpaceState;
var query = PhysicsRayQueryParameters3D.Create(
GlobalPosition,
GlobalPosition + Vector3.Down * FloorProbeDistance,
// Exclude the item's own collision layer (layer 32 per scene) and hit static geometry only.
0b1
);
query.Exclude = [GetRid()];
var hit = spaceState?.IntersectRay(query) ?? [];
if (hit.Count > 0)
{
var floorY = hit["position"].AsVector3().Y;
if (nextPosition.Y - GroundOffset <= floorY)
{
nextPosition.Y = floorY + GroundOffset;
_simulating = false;
_velocity = Vector3.Zero;
}
}
GlobalPosition = nextPosition;
}
public override bool Activate(ActivationType activationType = ActivationType.Toggle)

View file

@ -1,4 +1,4 @@
using Godot;
using Godot;
namespace Cirno.Scripts.Resources.Loot;
@ -8,7 +8,10 @@ public partial class LootDrop : Resource
{
[Export]
public LootItem Item { get; set; }
[Export(PropertyHint.None, "suffix:%")]
public float Chance { get; set; }
/// <summary>How many pickup instances to spawn when this drop is triggered.</summary>
[Export] public int Count { get; set; } = 1;
}

View file

@ -0,0 +1,68 @@
using System.Collections.Generic;
using System.Linq;
using Godot;
namespace Cirno.Scripts.Resources.Loot;
/// <summary>
/// Shared logic for spawning and scattering <see cref="LootDrop"/> entries in the 3D world.
/// Used by both destructibles and enemies so the distribution behaviour stays consistent.
/// </summary>
public static class LootDropHelper
{
/// <summary>
/// Rolls each drop's chance, then spawns the resulting pickups scattered uniformly
/// in a circle around <paramref name="center"/> with an outward + upward launch velocity
/// so they arc through the air and fall to the floor.
/// </summary>
/// <param name="drops">The list of possible drops to evaluate.</param>
/// <param name="anchor">Node used as the spawn parent / sibling reference.</param>
/// <param name="center">World-space centre of the scatter circle.</param>
/// <param name="scatterRadius">Radius of the circle in which items are spread.</param>
/// <param name="launchUpSpeed">Initial upward speed applied to each pickup.</param>
/// <param name="rng">Caller-owned RNG instance so seeds are consistent per object.</param>
public static void SpawnDrops(
IEnumerable<LootDrop> drops,
Node3D anchor,
Vector3 center,
float scatterRadius,
float launchUpSpeed,
RandomNumberGenerator rng)
{
var dropList = drops?.ToList();
if (dropList is not { Count: > 0 }) return;
// Count total spawns up front so golden-angle spacing is proportional to the full set.
var totalSpawns = dropList
.Where(d => d?.Item is not null)
.Sum(d => Mathf.Max(d.Count, 1));
var itemIndex = 0;
foreach (var drop in dropList)
{
if (drop?.Item is null) continue;
var roll = rng.RandfRange(0f, 100f);
if (roll > drop.Chance) continue;
var spawnCount = Mathf.Max(drop.Count, 1);
for (var i = 0; i < spawnCount; i++)
{
// Golden-angle placement gives uniform, non-clustering distribution.
itemIndex++;
var angle = itemIndex * Mathf.Tau / 1.618033988f;
var radius = scatterRadius * Mathf.Sqrt((float)itemIndex / Mathf.Max(totalSpawns, 1));
var offset = new Vector3(Mathf.Cos(angle) * radius, 0f, Mathf.Sin(angle) * radius);
var spawnPos = center + offset;
var lateralDir = offset.LengthSquared() > 0f ? offset.Normalized() : Vector3.Right;
var launch = lateralDir * (scatterRadius * 0.5f) + Vector3.Up * launchUpSpeed;
drop.Item.Spawn3D(anchor, spawnPosition: spawnPos, launchVelocity: launch);
}
}
}
}

View file

@ -0,0 +1 @@
uid://cmpwy17x132io

View file

@ -55,31 +55,43 @@ public partial class LootItem : Resource
return spawnedItem;
}
public ItemPickup3D Spawn3D(Node3D sibling, bool dropAsChild = false)
/// <summary>
/// Spawns this item as an <see cref="ItemPickup3D"/> in the 3D world.
/// </summary>
/// <param name="sibling">Reference node used to determine parent and default spawn position.</param>
/// <param name="dropAsChild">If true, spawns as a child of <paramref name="sibling"/>; otherwise as a sibling.</param>
/// <param name="spawnPosition">
/// Optional world-space position override. When null, the position of <paramref name="sibling"/> is used.
/// Pass a custom value to scatter drops instead of stacking them all at the same point.
/// </param>
/// <param name="launchVelocity">Optional initial velocity applied to the pickup so it arcs and falls to the floor.</param>
public ItemPickup3D Spawn3D(Node3D sibling, bool dropAsChild = false, Vector3? spawnPosition = null, Vector3? launchVelocity = null)
{
if (string.IsNullOrWhiteSpace(DropScenePath3D)) return null;
var itemScene = GD.Load<PackedScene>(DropScenePath3D);
var spawnedItem = itemScene.Instantiate<ItemPickup3D>();
spawnedItem.Name = this.ItemKey;
var position = spawnPosition ?? sibling.GlobalPosition;
if (dropAsChild)
{
CallDeferred(MethodName.DeferredSpawn3D, sibling, spawnedItem, sibling.GlobalPosition);
//sibling.CallDeferred(Node.MethodName.AddChild, spawnedItem);
//sibling.AddChild(spawnedItem);
CallDeferred(MethodName.DeferredSpawn3D, sibling, spawnedItem, position);
}
else
{
CallDeferred(MethodName.DeferredSpawn3D, sibling.GetParentNode3D(), spawnedItem, sibling.GlobalPosition);
//sibling.GetParent().CallDeferred(Node.MethodName.AddChild, spawnedItem);
//sibling.GetParent().AddChild(spawnedItem);
CallDeferred(MethodName.DeferredSpawn3D, sibling.GetParentNode3D(), spawnedItem, position);
}
//spawnedItem.GlobalPosition = sibling.GlobalPosition;
spawnedItem.LootTable.Add(this);
spawnedItem.SetSprite(InventorySprite);
if (launchVelocity.HasValue)
{
spawnedItem.Launch(launchVelocity.Value);
}
return spawnedItem;
}