Add attribute system with core stats and gameplay components

This commit is contained in:
2025-10-13 12:10:45 +02:00
commit ce3596efaa
55 changed files with 1161 additions and 0 deletions

2
GameCore/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
bin/
.idea/

View File

@@ -0,0 +1,31 @@
namespace GameCore.Attributes;
/// <summary>
/// Defines all possible stats an entity can have.
/// </summary>
public enum Attribute
{
// Core Stats
Health,
MaxHealth,
Armor,
MoveSpeed,
Acceleration,
Friction,
JumpHeight,
// Combat Stats
Damage,
AttackSpeed,
AttackRange,
MeleeDamage, // Multiplier
RangedDamage, // Multiplier
// Progression
Level,
Experience,
ExperienceToNextLevel,
// Misc
Luck
}

View File

@@ -0,0 +1,42 @@
using GameCore.ECS.Interfaces;
namespace GameCore.Attributes;
/// <summary>
/// A component that stores all gameplay-relevant stats for an entity.
/// This design is adapted directly from the excellent CharacterAttributes class
/// in the 'broberry' project. It's a flexible, data-driven way to manage stats.
/// </summary>
public class AttributeComponent : IComponent
{
public readonly Dictionary<Attribute, float> BaseValues = new();
public readonly Dictionary<Attribute, float> CurrentValues = new();
public event Action<Attribute, float>? OnAttributeChanged;
public void SetBaseValue(Attribute attr, float value)
{
BaseValues[attr] = value;
SetCurrentValue(attr, value);
}
public float GetValue(Attribute attr)
{
return CurrentValues.GetValueOrDefault(attr, 0f);
}
public void ModifyValue(Attribute attr, float delta)
{
var newValue = GetValue(attr) + delta;
SetCurrentValue(attr, newValue);
}
public void SetCurrentValue(Attribute attr, float value, bool force = false)
{
if (CurrentValues.TryGetValue(attr, out var oldValue) && !(System.Math.Abs(oldValue - value) > 0.001f) &&
!force) return;
CurrentValues[attr] = value;
OnAttributeChanged?.Invoke(attr, value);
}
}

View File

@@ -0,0 +1,23 @@
using GameCore.ECS;
using GameCore.ECS.Interfaces;
namespace GameCore.Attributes;
public class AttributeSystem : ISystem
{
public void Update(World world, float deltaTime)
{
var entities = world.GetEntitiesWith<AttributeComponent>();
foreach (var entity in entities)
{
var attributes = world.GetComponent<AttributeComponent>(entity);
if (attributes == null) continue;
var maxHealth = attributes.GetValue(Attribute.MaxHealth);
var currentHealth = attributes.GetValue(Attribute.Health);
if (currentHealth > maxHealth) attributes.SetCurrentValue(Attribute.Health, maxHealth);
}
}
}

View File

@@ -0,0 +1,8 @@
namespace GameCore.Combat;
public enum AttackType
{
Hitscan = 0,
Projectile = 1,
Melee = 2
}

View File

@@ -0,0 +1,15 @@
using GameCore.ECS;
using GameCore.ECS.Interfaces;
namespace GameCore.Combat;
/// <summary>
/// A temporary, event-like component. When this component is added to an entity,
/// the DamageSystem will process it, apply damage to the target, and then
/// immediately remove this component. This simulates a one-time damage event.
/// </summary>
public class DamageEventComponent(Entity target, float amount) : IComponent
{
public Entity Target { get; set; } = target;
public float Amount { get; set; } = amount;
}

View File

@@ -0,0 +1,38 @@
using GameCore.Attributes;
using GameCore.ECS;
using GameCore.ECS.Interfaces;
using GameCore.Events;
using Attribute = GameCore.Attributes.Attribute;
namespace GameCore.Combat;
public class DamageSystem : ISystem
{
private readonly World _world;
public DamageSystem(World world)
{
_world = world;
world.Subscribe<DamageDealtEvent>(OnDamageDealt);
}
public void Update(World world, float deltaTime)
{
var entitiesWithHealth = world.GetEntitiesWith<AttributeComponent>();
foreach (var entity in entitiesWithHealth)
{
if (world.GetComponent<DeathComponent>(entity) != null) continue;
var attributes = world.GetComponent<AttributeComponent>(entity);
if (attributes == null) continue;
if (attributes.GetValue(Attribute.Health) <= 0) world.AddComponent(entity, new DeathComponent());
}
}
private void OnDamageDealt(DamageDealtEvent e)
{
var targetAttributes = _world.GetComponent<AttributeComponent>(e.Target);
targetAttributes?.ModifyValue(Attribute.Health, -e.Amount);
}
}

View File

@@ -0,0 +1,13 @@
using GameCore.ECS.Interfaces;
namespace GameCore.Combat;
/// <summary>
/// A simple "marker" component. When the DamageSystem determines an entity's
/// health has dropped to 0 or below, it adds this component to the entity.
/// Other systems (or the presenter layer) can then react to this, for example,
/// by playing a death animation, creating a ragdoll, and eventually destroying the entity.
/// </summary>
public class DeathComponent : IComponent
{
}

View File

@@ -0,0 +1,19 @@
using GameCore.ECS;
using GameCore.ECS.Interfaces;
using GameCore.Events;
namespace GameCore.Combat;
public class DestructionSystem : ISystem
{
public void Update(World world, float deltaTime)
{
var entitiesWithDeath = world.GetEntitiesWith<DeathComponent>();
foreach (var entity in entitiesWithDeath)
{
world.PublishEvent(new EntityDiedEvent(entity));
world.DestroyEntity(entity);
}
}
}

View File

@@ -0,0 +1,12 @@
using GameCore.ECS;
using GameCore.ECS.Interfaces;
namespace GameCore.Combat;
public class EffectExecutionSystem : ISystem
{
public void Update(World world, float deltaTime)
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,48 @@
using GameCore.Combat.Interfaces;
using GameCore.Events;
using GameCore.Input;
using GameCore.Movement;
namespace GameCore.Combat.Effects;
public class BulkProjectileEffect(string archetypeId, int count, float spreadAngle, float speed) : IEffect
{
private static readonly Random _random = new();
public void Execute(EffectContext context)
{
var ownerInput = context.World.GetComponent<InputStateComponent>(context.Owner);
if (ownerInput == null)
return;
var ownerRotation = context.World.GetComponent<RotationComponent>(context.Owner);
if (ownerRotation == null) return;
var spreadRadians = spreadAngle * (float)System.Math.PI / 180f;
for (var i = 0; i < count; i++)
{
var direction = ownerInput.MuzzleDirection;
if (spreadRadians > 0f)
{
var randomYaw = ((float)_random.NextDouble() * 2f - 1f) * spreadRadians;
var randomPitch = ((float)_random.NextDouble() * 2f - 1f) * spreadRadians;
direction = context.World.WorldQuery.RotateVectorByYaw(direction, randomYaw);
direction.Y += (float)System.Math.Sin(randomPitch);
direction = direction.Normalize();
}
var initialVelocity = direction * speed;
context.World.PublishEvent(new SpawnEntityEvent(
archetypeId,
ownerInput.MuzzlePosition,
ownerRotation.Rotation,
context.Owner,
initialVelocity
));
}
}
}

View File

@@ -0,0 +1,15 @@
using GameCore.Combat.Interfaces;
using GameCore.Events;
namespace GameCore.Combat.Effects;
public class DamageEffect(float amount) : IEffect
{
public void Execute(EffectContext context)
{
if (context.Target == null)
return;
context.World.PublishEvent(new DamageDealtEvent(context.Target.Value, amount));
}
}

View File

@@ -0,0 +1,10 @@
using GameCore.ECS;
namespace GameCore.Combat.Effects;
public class EffectContext
{
public Entity Owner;
public Entity? Target;
public World World;
}

View File

@@ -0,0 +1,30 @@
using GameCore.Combat.Interfaces;
using GameCore.Input;
namespace GameCore.Combat.Effects;
public class HitscanEffect(float range) : IEffect
{
public void Execute(EffectContext context)
{
var input = context.World.GetComponent<InputStateComponent>(context.Owner);
var weapon = context.World.GetComponent<WeaponComponent>(context.Owner);
if (input == null || weapon == null) return;
var targetPos = input.MuzzlePosition + input.MuzzleDirection * range;
var hit = context.World.WorldQuery.Raycast(input.MuzzlePosition, targetPos, context.Owner);
if (hit.DidHit)
{
var hitContext = new EffectContext
{
World = context.World,
Owner = context.Owner,
Target = hit.HitEntity
};
foreach (var effect in weapon.OnHitEffects) effect.Execute(hitContext);
}
}
}

View File

@@ -0,0 +1,8 @@
using GameCore.Combat.Effects;
namespace GameCore.Combat.Interfaces;
public interface IEffect
{
void Execute(EffectContext context);
}

View File

@@ -0,0 +1,25 @@
using GameCore.ECS;
using GameCore.ECS.Interfaces;
using GameCore.Physics;
namespace GameCore.Combat;
public class ProjectileCleanupSystem : ISystem
{
public void Update(World world, float deltaTime)
{
var projectiles = world.GetEntitiesWith<ProjectileComponent>();
foreach (var projectile in projectiles)
{
var projectileData = world.GetComponent<ProjectileComponent>(projectile);
var position = world.GetComponent<PositionComponent>(projectile);
var velocity = world.GetComponent<VelocityComponent>(projectile);
if (projectileData == null || position == null || velocity == null)
continue;
projectileData.Lifetime -= deltaTime;
if (projectileData.Lifetime <= 0f) world.AddComponent(projectile, new DeathComponent());
}
}
}

View File

@@ -0,0 +1,13 @@
using GameCore.Combat.Interfaces;
using GameCore.ECS;
using GameCore.ECS.Interfaces;
namespace GameCore.Combat;
public class ProjectileComponent : IComponent
{
public Entity Owner { get; set; }
public float Lifetime { get; set; }
public List<IEffect> OnHitEffects { get; set; } = [];
}

View File

@@ -0,0 +1,33 @@
using GameCore.ECS;
using GameCore.ECS.Interfaces;
using GameCore.Events;
namespace GameCore.Combat;
public class ProjectileInitializationSystem : ISystem
{
private readonly World _world;
public ProjectileInitializationSystem(World world)
{
_world = world;
_world.Subscribe<EntitySpawnedEvent>(OnEntitySpawned);
}
public void Update(World world, float deltaTime)
{
}
private void OnEntitySpawned(EntitySpawnedEvent e)
{
var projComp = _world.GetComponent<ProjectileComponent>(e.NewEntity);
if (projComp == null) return;
var ownerWeapon = _world.GetComponent<WeaponComponent>(e.Owner);
if (ownerWeapon != null)
{
projComp.Owner = e.Owner;
projComp.OnHitEffects = ownerWeapon.OnHitEffects;
}
}
}

View File

@@ -0,0 +1,43 @@
using GameCore.Combat.Effects;
using GameCore.ECS;
using GameCore.ECS.Interfaces;
using GameCore.Physics;
namespace GameCore.Combat;
public class ProjectileSystem : ISystem
{
public void Update(World world, float deltaTime)
{
var projectiles = world.GetEntitiesWith<ProjectileComponent>();
foreach (var projectile in projectiles)
{
var velocity = world.GetComponent<VelocityComponent>(projectile);
var position = world.GetComponent<PositionComponent>(projectile);
var projectileData = world.GetComponent<ProjectileComponent>(projectile);
if (velocity == null || position == null || projectileData == null)
continue;
var newPosition = position.Position + velocity.DesiredVelocity * deltaTime;
var hit = world.WorldQuery.Raycast(position.Position, newPosition, projectileData.Owner);
if (hit.DidHit)
{
var hitContext = new EffectContext
{
World = world,
Owner = projectileData.Owner,
Target = hit.HitEntity
};
foreach (var effect in projectileData.OnHitEffects) effect.Execute(hitContext);
world.AddComponent(projectile, new DeathComponent());
continue;
}
position.Position = newPosition;
}
}
}

View File

@@ -0,0 +1,14 @@
using GameCore.Combat.Interfaces;
using GameCore.ECS.Interfaces;
namespace GameCore.Combat;
public class WeaponComponent : IComponent
{
public float FireRate { get; set; } = 1f;
public List<IEffect> OnFireEffects { get; set; } = [];
public List<IEffect> OnHitEffects { get; set; } = [];
public float CooldownTimer { get; set; } = 0f;
}

View File

@@ -0,0 +1,39 @@
using GameCore.Combat.Effects;
using GameCore.ECS;
using GameCore.ECS.Interfaces;
using GameCore.Events;
using GameCore.Input;
using GameCore.Movement;
using GameCore.Physics;
namespace GameCore.Combat;
public class WeaponSystem : ISystem
{
public void Update(World world, float deltaTime)
{
var entities = world.GetEntitiesWith<InputStateComponent>();
foreach (var entity in entities)
{
var input = world.GetComponent<InputStateComponent>(entity);
var weapon = world.GetComponent<WeaponComponent>(entity);
var position = world.GetComponent<PositionComponent>(entity);
var rotation = world.GetComponent<RotationComponent>(entity);
if (input == null || weapon == null || position == null || rotation == null)
continue;
if (weapon.CooldownTimer > 0f) weapon.CooldownTimer -= deltaTime;
if (input.IsFiring && weapon.CooldownTimer <= 0f)
{
var context = new EffectContext { World = world, Owner = entity };
foreach (var effect in weapon.OnFireEffects) effect.Execute(context);
world.PublishEvent(new WeaponFiredEvent(entity, input.MuzzlePosition));
weapon.CooldownTimer = 1f / weapon.FireRate;
}
}
}
}

View File

@@ -0,0 +1,6 @@
namespace GameCore.Config;
public class SimulationConfig
{
public float GravityStrength { get; set; } = 9.81f;
}

11
GameCore/ECS/Entity.cs Normal file
View File

@@ -0,0 +1,11 @@
namespace GameCore.ECS;
/// <summary>
/// Represents a unique object in the game world.
/// It's a simple struct containing an ID to keep it lightweight.
/// All data associated with an Entity is stored in Components.
/// </summary>
public readonly struct Entity(int id)
{
public readonly int Id = id;
}

View File

@@ -0,0 +1,7 @@
namespace GameCore.ECS;
public struct HitResult
{
public bool DidHit;
public Entity HitEntity;
}

View File

@@ -0,0 +1,12 @@
namespace GameCore.ECS.Interfaces;
/// <summary>
/// A marker interface for all components.
/// Components are simple data containers (structs or classes) that hold the state
/// for a specific aspect of an Entity (e.g., its position, health, or inventory).
/// They contain no logic.
/// </summary>
public interface IComponent
{
}

View File

@@ -0,0 +1,6 @@
namespace GameCore.ECS.Interfaces;
public interface IEntityPresenter
{
public Entity CoreEntity { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace GameCore.ECS.Interfaces;
public interface IPresenterComponent
{
void Initialize(Entity coreEntity, World world);
void SyncToPresentation(float delta);
void SyncToCore(float delta);
}

View File

@@ -0,0 +1,17 @@
namespace GameCore.ECS.Interfaces;
/// <summary>
/// The contract for all game logic systems.
/// Systems are stateless classes that contain all the logic.
/// They operate on entities that possess a specific set of components.
/// For example, a MovementSystem would operate on entities with PositionComponent and VelocityComponent.
/// </summary>
public interface ISystem
{
/// <summary>
/// The main update method for a system, called once per game loop.
/// </summary>
/// <param name="world">A reference to the main World object to query for entities and components.</param>
/// <param name="deltaTime">The time elapsed since the last frame.</param>
void Update(World world, float deltaTime);
}

View File

@@ -0,0 +1,9 @@
using GameCore.Math;
namespace GameCore.ECS.Interfaces;
public interface IWorldQuery
{
HitResult Raycast(Vector3 from, Vector3 to, Entity? ownerToExclude = null);
Vector3 RotateVectorByYaw(Vector3 vector, float yawRadians);
}

View File

@@ -0,0 +1,10 @@
using GameCore.ECS.Interfaces;
namespace GameCore.ECS;
public readonly struct PresenterData(Entity entity, IEntityPresenter presenter, List<IPresenterComponent> components)
{
public readonly Entity Entity = entity;
public readonly IEntityPresenter Presenter = presenter;
public readonly List<IPresenterComponent> Components = components;
}

113
GameCore/ECS/World.cs Normal file
View File

@@ -0,0 +1,113 @@
using GameCore.Config;
using GameCore.ECS.Interfaces;
using GameCore.Events;
using GameCore.Events.Interfaces;
using GameCore.Input.Interfaces;
namespace GameCore.ECS;
/// <summary>
/// The central container for the entire game state.
/// It manages all entities, components, and systems, and orchestrates the main game loop.
/// This class is the primary point of interaction for the Engine/Presentation layer.
/// </summary>
public class World(IInputService inputService, IWorldQuery worldQuery, SimulationConfig config)
{
private readonly Dictionary<int, List<IComponent>> _componentsByEntityId = new();
private readonly EventBus _eventBus = new();
private readonly List<ISystem> _systems = [];
public readonly SimulationConfig Config = config;
public readonly IInputService InputService = inputService;
public readonly IWorldQuery WorldQuery = worldQuery;
private int _nextEntityId;
/// <summary>
/// Creates a new, unique entity.
/// </summary>
/// <returns>The newly created Entity.</returns>
public Entity CreateEntity()
{
var id = _nextEntityId++;
_componentsByEntityId[id] = [];
return new Entity(id);
}
public void DestroyEntity(Entity entity)
{
_componentsByEntityId.Remove(entity.Id);
}
/// <summary>
/// Adds a component to a given entity.
/// </summary>
public void AddComponent(Entity entity, IComponent component)
{
_componentsByEntityId[entity.Id].Add(component);
}
public void RemoveComponent<T>(Entity entity) where T : class, IComponent
{
if (!_componentsByEntityId.TryGetValue(entity.Id, out var components)) return;
var componentToRemove = components.OfType<T>().FirstOrDefault();
if (componentToRemove != null) components.Remove(componentToRemove);
}
/// <summary>
/// Retrieves a specific type of component from an entity.
/// </summary>
/// <returns>The component instance, or null if the entity doesn't have it.</returns>
public T? GetComponent<T>(Entity entity) where T : class, IComponent
{
return _componentsByEntityId.TryGetValue(entity.Id, out var components)
? components.OfType<T>().FirstOrDefault()
: null;
}
/// <summary>
/// Finds all entities that have a specific component type.
/// </summary>
public IEnumerable<Entity> GetEntitiesWith<T>() where T : IComponent
{
return from pair in _componentsByEntityId
where pair.Value.Any(c => c is T)
select new Entity(pair.Key);
}
/// <summary>
/// Registers a system to be run in the game loop.
/// </summary>
public void RegisterSystem(ISystem system)
{
_systems.Add(system);
}
/// <summary>
/// Runs a single tick of the game simulation, updating all registered systems.
/// </summary>
public void Update(float deltaTime)
{
foreach (var system in _systems) system.Update(this, deltaTime);
}
public void PublishEvent(IEvent gameEvent)
{
_eventBus.Publish(gameEvent);
}
public void ProcessEvents()
{
_eventBus.ProcessEvents();
}
public void Subscribe<T>(Action<T> handler) where T : IEvent
{
_eventBus.Subscribe(handler);
}
public void Unsubscribe<T>(Action<T> handler) where T : IEvent
{
_eventBus.Unsubscribe(handler);
}
}

View File

@@ -0,0 +1,10 @@
using GameCore.ECS;
using GameCore.Events.Interfaces;
namespace GameCore.Events;
public readonly struct DamageDealtEvent(Entity target, float amount) : IEvent
{
public readonly Entity Target = target;
public readonly float Amount = amount;
}

View File

@@ -0,0 +1,9 @@
using GameCore.ECS;
using GameCore.Events.Interfaces;
namespace GameCore.Events;
public readonly struct EntityDiedEvent(Entity entity) : IEvent
{
public readonly Entity Entity = entity;
}

View File

@@ -0,0 +1,11 @@
using GameCore.ECS;
using GameCore.Events.Interfaces;
namespace GameCore.Events;
public readonly struct EntitySpawnedEvent(Entity newEntity, Entity owner, string archetypeId) : IEvent
{
public readonly Entity NewEntity = newEntity;
public readonly Entity Owner = owner;
public readonly string ArchetypeId = archetypeId;
}

View File

@@ -0,0 +1,46 @@
using GameCore.Events.Interfaces;
namespace GameCore.Events;
public class EventBus
{
private readonly Queue<IEvent> _eventQueue = new();
private readonly Dictionary<Type, List<Action<IEvent>>> _subscribers = new();
public void Subscribe<T>(Action<T> listener) where T : IEvent
{
var eventType = typeof(T);
if (!_subscribers.ContainsKey(eventType)) _subscribers[eventType] = [];
_subscribers[eventType].Add(e => listener((T)e));
}
public void Unsubscribe<T>(Action<T> listener) where T : IEvent
{
var eventType = typeof(T);
if (!_subscribers.TryGetValue(eventType, out var listeners)) return;
listeners.RemoveAll(e => e.Equals((Action<IEvent>)(e1 => listener((T)e1))));
if (listeners.Count == 0) _subscribers.Remove(eventType);
}
public void Publish(IEvent gameEvent)
{
_eventQueue.Enqueue(gameEvent);
}
public void ProcessEvents()
{
while (_eventQueue.Count > 0)
{
var gameEvent = _eventQueue.Dequeue();
var eventType = gameEvent.GetType();
if (!_subscribers.TryGetValue(eventType, out var listeners)) continue;
foreach (var listener in listeners)
listener.Invoke(gameEvent);
}
}
}

View File

@@ -0,0 +1,6 @@
namespace GameCore.Events.Interfaces;
public interface IEvent
{
}

View File

@@ -0,0 +1,20 @@
using GameCore.ECS;
using GameCore.Events.Interfaces;
using GameCore.Math;
namespace GameCore.Events;
public readonly struct SpawnEntityEvent(
string archetypeId,
Vector3 position,
Vector3 rotation,
Entity owner,
Vector3? initialVelocity = null)
: IEvent
{
public readonly string ArchetypeId = archetypeId;
public readonly Vector3 Position = position;
public readonly Vector3 Rotation = rotation;
public readonly Entity Owner = owner;
public readonly Vector3? InitialVelocity = initialVelocity;
}

View File

@@ -0,0 +1,11 @@
using GameCore.ECS;
using GameCore.Events.Interfaces;
using GameCore.Math;
namespace GameCore.Events;
public readonly struct WeaponFiredEvent(Entity owner, Vector3 muzzlePosition) : IEvent
{
public readonly Entity Owner = owner;
public readonly Vector3 MuzzlePosition = muzzlePosition;
}

13
GameCore/GameCore.csproj Normal file
View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Folder Include="Factories\"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,16 @@
using GameCore.ECS.Interfaces;
using GameCore.Input.Interfaces;
using GameCore.Math;
namespace GameCore.Input;
public class InputStateComponent : IComponent, IInputService
{
public Vector3 MuzzlePosition { get; set; }
public Vector3 MuzzleDirection { get; set; }
public bool IsFiring { get; set; }
public bool IsInteracting { get; set; }
public bool IsJumping { get; set; }
public Vector3 MoveDirection { get; set; }
public Vector3 LookDirection { get; set; }
}

View File

@@ -0,0 +1,12 @@
using GameCore.Math;
namespace GameCore.Input.Interfaces;
public interface IInputService
{
public bool IsFiring { get; }
public bool IsInteracting { get; }
public bool IsJumping { get; }
public Vector3 MoveDirection { get; }
public Vector3 LookDirection { get; }
}

View File

@@ -0,0 +1,24 @@
using GameCore.ECS;
using GameCore.ECS.Interfaces;
using GameCore.Player;
namespace GameCore.Input;
public class PlayerInputSystem : ISystem
{
public void Update(World world, float deltaTime)
{
var playerEntities = world.GetEntitiesWith<PlayerComponent>();
if (!playerEntities.Any()) return;
var playerEntity = playerEntities.First();
var inputState = world.GetComponent<InputStateComponent>(playerEntity);
if (inputState == null) return;
inputState.MoveDirection = world.InputService.MoveDirection.Normalize();
inputState.LookDirection = world.InputService.LookDirection;
inputState.IsJumping = world.InputService.IsJumping;
inputState.IsInteracting = world.InputService.IsInteracting;
inputState.IsFiring = world.InputService.IsFiring;
}
}

79
GameCore/Math/Vector3.cs Normal file
View File

@@ -0,0 +1,79 @@
namespace GameCore.Math;
/// <summary>
/// A simple, engine-agnostic 3D vector struct.
/// We use our own to avoid dependencies on Godot or Unity's vector types
/// within the GameCore library, ensuring it remains portable.
/// </summary>
public struct Vector3
{
public float X;
public float Y;
public float Z;
public static readonly Vector3 Zero = new(0, 0, 0);
public Vector3(float x, float y, float z)
{
X = x;
Y = y;
Z = z;
}
public static Vector3 operator +(Vector3 a, Vector3 b)
{
return new Vector3(a.X + b.X, a.Y + b.Y, a.Z + b.Z);
}
public static Vector3 operator -(Vector3 a, Vector3 b)
{
return new Vector3(a.X - b.X, a.Y - b.Y, a.Z - b.Z);
}
public static Vector3 operator *(Vector3 a, float d)
{
return new Vector3(a.X * d, a.Y * d, a.Z * d);
}
public override string ToString()
{
return $"({X}, {Y}, {Z})";
}
public override bool Equals(object obj)
{
if (obj is not Vector3 other)
return false;
return X == other.X && Y == other.Y && Z == other.Z;
}
public override int GetHashCode()
{
return HashCode.Combine(X, Y, Z);
}
public static bool operator ==(Vector3 a, Vector3 b)
{
return a.Equals(b);
}
public static bool operator !=(Vector3 a, Vector3 b)
{
return !a.Equals(b);
}
public float Length()
{
return (float)System.Math.Sqrt(X * X + Y * Y + Z * Z);
}
public Vector3 Normalize()
{
var length = Length();
if (length == 0)
return Zero;
return new Vector3(X / length, Y / length, Z / length);
}
}

View File

@@ -0,0 +1,8 @@
using GameCore.ECS.Interfaces;
namespace GameCore.Movement;
public class CharacterStateComponent : IComponent
{
public bool IsOnFloor { get; set; }
}

View File

@@ -0,0 +1,23 @@
using GameCore.ECS;
using GameCore.ECS.Interfaces;
using GameCore.Physics;
namespace GameCore.Movement;
public class GravitySystem : ISystem
{
public void Update(World world, float deltaTime)
{
var entities = world.GetEntitiesWith<VelocityComponent>();
foreach (var entity in entities)
{
var velocity = world.GetComponent<VelocityComponent>(entity);
var characterState = world.GetComponent<CharacterStateComponent>(entity);
if (velocity == null || characterState == null)
continue;
if (!characterState.IsOnFloor) velocity.DesiredVelocity.Y -= world.Config.GravityStrength * deltaTime;
}
}
}

View File

@@ -0,0 +1,61 @@
using GameCore.Attributes;
using GameCore.ECS;
using GameCore.ECS.Interfaces;
using GameCore.Input;
using GameCore.Math;
using GameCore.Physics;
using Attribute = GameCore.Attributes.Attribute;
namespace GameCore.Movement;
public class GroundMovementSystem : ISystem
{
public void Update(World world, float deltaTime)
{
var entities = world.GetEntitiesWith<InputStateComponent>();
foreach (var entity in entities)
{
var input = world.GetComponent<InputStateComponent>(entity);
var velocity = world.GetComponent<VelocityComponent>(entity);
var attributes = world.GetComponent<AttributeComponent>(entity);
var rotation = world.GetComponent<RotationComponent>(entity);
if (input == null || velocity == null || attributes == null || rotation == null)
continue;
var moveSpeed = attributes.GetValue(Attribute.MoveSpeed);
var acceleration = attributes.GetValue(Attribute.Acceleration);
var friction = attributes.GetValue(Attribute.Friction);
var yaw = rotation.Rotation.Y;
var rotatedDir = world.WorldQuery.RotateVectorByYaw(input.MoveDirection, yaw);
var targetVelocity = new Vector3(
rotatedDir.X * moveSpeed,
velocity.DesiredVelocity.Y,
rotatedDir.Z * moveSpeed
);
if (rotatedDir.Length() >= 0.1f)
{
velocity.DesiredVelocity =
MoveToward(velocity.DesiredVelocity, targetVelocity, acceleration * deltaTime);
}
else
{
var frictionVec = new Vector3(velocity.DesiredVelocity.X, 0f, velocity.DesiredVelocity.Z);
frictionVec = MoveToward(frictionVec, Vector3.Zero, friction * deltaTime);
velocity.DesiredVelocity.X = frictionVec.X;
velocity.DesiredVelocity.Z = frictionVec.Z;
}
}
}
private Vector3 MoveToward(Vector3 from, Vector3 to, float delta)
{
var diff = to - from;
if (diff.Length() <= delta)
return to;
return from + diff.Normalize() * delta;
}
}

View File

@@ -0,0 +1,32 @@
using GameCore.Attributes;
using GameCore.ECS;
using GameCore.ECS.Interfaces;
using GameCore.Input;
using GameCore.Physics;
using Attribute = GameCore.Attributes.Attribute;
namespace GameCore.Movement;
public class JumpSystem : ISystem
{
public void Update(World world, float deltaTime)
{
var entities = world.GetEntitiesWith<InputStateComponent>();
foreach (var entity in entities)
{
var input = world.GetComponent<InputStateComponent>(entity);
var velocity = world.GetComponent<VelocityComponent>(entity);
var attributes = world.GetComponent<AttributeComponent>(entity);
var characterState = world.GetComponent<CharacterStateComponent>(entity);
if (input == null || velocity == null || attributes == null || characterState == null)
continue;
if (input.IsJumping && characterState.IsOnFloor)
{
var jumpHeight = attributes.GetValue(Attribute.JumpHeight);
velocity.DesiredVelocity.Y = (float)System.Math.Sqrt(2f * world.Config.GravityStrength * jumpHeight);
}
}
}
}

View File

@@ -0,0 +1,9 @@
using GameCore.ECS.Interfaces;
using GameCore.Math;
namespace GameCore.Movement;
public class RotationComponent : IComponent
{
public Vector3 Rotation;
}

View File

@@ -0,0 +1,29 @@
using GameCore.ECS;
using GameCore.ECS.Interfaces;
using GameCore.Input;
namespace GameCore.Movement;
public class RotationSystem : ISystem
{
private const float MinPitch = -(float)System.Math.PI / 2f;
private const float MaxPitch = (float)System.Math.PI / 2f;
public void Update(World world, float deltaTime)
{
var entities = world.GetEntitiesWith<InputStateComponent>();
foreach (var entity in entities)
{
var input = world.GetComponent<InputStateComponent>(entity);
var rotation = world.GetComponent<RotationComponent>(entity);
if (input == null || rotation == null)
continue;
rotation.Rotation.Y += input.LookDirection.Y;
rotation.Rotation.X += input.LookDirection.X;
rotation.Rotation.X = System.Math.Clamp(rotation.Rotation.X, MinPitch, MaxPitch);
}
}
}

View File

@@ -0,0 +1,12 @@
using GameCore.ECS.Interfaces;
using GameCore.Math;
namespace GameCore.Physics;
/// <summary>
/// Stores the 3D position of an entity in the game world.
/// </summary>
public class PositionComponent : IComponent
{
public Vector3 Position;
}

View File

@@ -0,0 +1,14 @@
using GameCore.ECS.Interfaces;
using GameCore.Math;
namespace GameCore.Physics;
/// <summary>
/// Stores the 3D velocity of an entity.
/// This component is used by the PhysicsSystem to update the PositionComponent.
/// </summary>
public class VelocityComponent : IComponent
{
public Vector3 ActualVelocity;
public Vector3 DesiredVelocity;
}

View File

@@ -0,0 +1,7 @@
using GameCore.ECS.Interfaces;
namespace GameCore.Player;
public class PlayerComponent : IComponent
{
}