commit ce3596efaa76a2bee6f562b763582be3d1f22e64 Author: Gabriel Kaszewski Date: Mon Oct 13 12:10:45 2025 +0200 Add attribute system with core stats and gameplay components diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39c9242 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ +.idea/ diff --git a/BrickFramework.sln b/BrickFramework.sln new file mode 100644 index 0000000..84be1ff --- /dev/null +++ b/BrickFramework.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCore", "GameCore\GameCore.csproj", "{2C58F49A-5F31-4D55-87B6-01C904B60E93}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2C58F49A-5F31-4D55-87B6-01C904B60E93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2C58F49A-5F31-4D55-87B6-01C904B60E93}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2C58F49A-5F31-4D55-87B6-01C904B60E93}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2C58F49A-5F31-4D55-87B6-01C904B60E93}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/GameCore/.gitignore b/GameCore/.gitignore new file mode 100644 index 0000000..1835d34 --- /dev/null +++ b/GameCore/.gitignore @@ -0,0 +1,2 @@ +bin/ +.idea/ \ No newline at end of file diff --git a/GameCore/Attributes/Attribute.cs b/GameCore/Attributes/Attribute.cs new file mode 100644 index 0000000..dcf14a0 --- /dev/null +++ b/GameCore/Attributes/Attribute.cs @@ -0,0 +1,31 @@ +namespace GameCore.Attributes; + +/// +/// Defines all possible stats an entity can have. +/// +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 +} \ No newline at end of file diff --git a/GameCore/Attributes/AttributeComponent.cs b/GameCore/Attributes/AttributeComponent.cs new file mode 100644 index 0000000..3542d3c --- /dev/null +++ b/GameCore/Attributes/AttributeComponent.cs @@ -0,0 +1,42 @@ +using GameCore.ECS.Interfaces; + +namespace GameCore.Attributes; + +/// +/// 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. +/// +public class AttributeComponent : IComponent +{ + public readonly Dictionary BaseValues = new(); + public readonly Dictionary CurrentValues = new(); + + public event Action? 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); + } +} \ No newline at end of file diff --git a/GameCore/Attributes/AttributeSystem.cs b/GameCore/Attributes/AttributeSystem.cs new file mode 100644 index 0000000..cd21d0a --- /dev/null +++ b/GameCore/Attributes/AttributeSystem.cs @@ -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(); + + foreach (var entity in entities) + { + var attributes = world.GetComponent(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); + } + } +} \ No newline at end of file diff --git a/GameCore/Combat/AttackType.cs b/GameCore/Combat/AttackType.cs new file mode 100644 index 0000000..57ef08d --- /dev/null +++ b/GameCore/Combat/AttackType.cs @@ -0,0 +1,8 @@ +namespace GameCore.Combat; + +public enum AttackType +{ + Hitscan = 0, + Projectile = 1, + Melee = 2 +} \ No newline at end of file diff --git a/GameCore/Combat/DamageEventComponent.cs b/GameCore/Combat/DamageEventComponent.cs new file mode 100644 index 0000000..358b2b3 --- /dev/null +++ b/GameCore/Combat/DamageEventComponent.cs @@ -0,0 +1,15 @@ +using GameCore.ECS; +using GameCore.ECS.Interfaces; + +namespace GameCore.Combat; + +/// +/// 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. +/// +public class DamageEventComponent(Entity target, float amount) : IComponent +{ + public Entity Target { get; set; } = target; + public float Amount { get; set; } = amount; +} \ No newline at end of file diff --git a/GameCore/Combat/DamageSystem.cs b/GameCore/Combat/DamageSystem.cs new file mode 100644 index 0000000..e8175ed --- /dev/null +++ b/GameCore/Combat/DamageSystem.cs @@ -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(OnDamageDealt); + } + + public void Update(World world, float deltaTime) + { + var entitiesWithHealth = world.GetEntitiesWith(); + foreach (var entity in entitiesWithHealth) + { + if (world.GetComponent(entity) != null) continue; + + var attributes = world.GetComponent(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(e.Target); + targetAttributes?.ModifyValue(Attribute.Health, -e.Amount); + } +} \ No newline at end of file diff --git a/GameCore/Combat/DeathComponent.cs b/GameCore/Combat/DeathComponent.cs new file mode 100644 index 0000000..3c4ec3c --- /dev/null +++ b/GameCore/Combat/DeathComponent.cs @@ -0,0 +1,13 @@ +using GameCore.ECS.Interfaces; + +namespace GameCore.Combat; + +/// +/// 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. +/// +public class DeathComponent : IComponent +{ +} \ No newline at end of file diff --git a/GameCore/Combat/DestructionSystem.cs b/GameCore/Combat/DestructionSystem.cs new file mode 100644 index 0000000..4589759 --- /dev/null +++ b/GameCore/Combat/DestructionSystem.cs @@ -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(); + foreach (var entity in entitiesWithDeath) + { + world.PublishEvent(new EntityDiedEvent(entity)); + + world.DestroyEntity(entity); + } + } +} \ No newline at end of file diff --git a/GameCore/Combat/EffectExecutionSystem.cs b/GameCore/Combat/EffectExecutionSystem.cs new file mode 100644 index 0000000..c51290c --- /dev/null +++ b/GameCore/Combat/EffectExecutionSystem.cs @@ -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(); + } +} \ No newline at end of file diff --git a/GameCore/Combat/Effects/BulkProjectileEffect.cs b/GameCore/Combat/Effects/BulkProjectileEffect.cs new file mode 100644 index 0000000..c030228 --- /dev/null +++ b/GameCore/Combat/Effects/BulkProjectileEffect.cs @@ -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(context.Owner); + if (ownerInput == null) + return; + + var ownerRotation = context.World.GetComponent(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 + )); + } + } +} \ No newline at end of file diff --git a/GameCore/Combat/Effects/DamageEffect.cs b/GameCore/Combat/Effects/DamageEffect.cs new file mode 100644 index 0000000..bed497d --- /dev/null +++ b/GameCore/Combat/Effects/DamageEffect.cs @@ -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)); + } +} \ No newline at end of file diff --git a/GameCore/Combat/Effects/EffectContext.cs b/GameCore/Combat/Effects/EffectContext.cs new file mode 100644 index 0000000..de91a2f --- /dev/null +++ b/GameCore/Combat/Effects/EffectContext.cs @@ -0,0 +1,10 @@ +using GameCore.ECS; + +namespace GameCore.Combat.Effects; + +public class EffectContext +{ + public Entity Owner; + public Entity? Target; + public World World; +} \ No newline at end of file diff --git a/GameCore/Combat/Effects/HitscanEffect.cs b/GameCore/Combat/Effects/HitscanEffect.cs new file mode 100644 index 0000000..c65decc --- /dev/null +++ b/GameCore/Combat/Effects/HitscanEffect.cs @@ -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(context.Owner); + var weapon = context.World.GetComponent(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); + } + } +} \ No newline at end of file diff --git a/GameCore/Combat/Interfaces/IEffect.cs b/GameCore/Combat/Interfaces/IEffect.cs new file mode 100644 index 0000000..a476018 --- /dev/null +++ b/GameCore/Combat/Interfaces/IEffect.cs @@ -0,0 +1,8 @@ +using GameCore.Combat.Effects; + +namespace GameCore.Combat.Interfaces; + +public interface IEffect +{ + void Execute(EffectContext context); +} \ No newline at end of file diff --git a/GameCore/Combat/ProjectileCleanupSystem.cs b/GameCore/Combat/ProjectileCleanupSystem.cs new file mode 100644 index 0000000..7949883 --- /dev/null +++ b/GameCore/Combat/ProjectileCleanupSystem.cs @@ -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(); + foreach (var projectile in projectiles) + { + var projectileData = world.GetComponent(projectile); + var position = world.GetComponent(projectile); + var velocity = world.GetComponent(projectile); + + if (projectileData == null || position == null || velocity == null) + continue; + + projectileData.Lifetime -= deltaTime; + if (projectileData.Lifetime <= 0f) world.AddComponent(projectile, new DeathComponent()); + } + } +} \ No newline at end of file diff --git a/GameCore/Combat/ProjectileComponent.cs b/GameCore/Combat/ProjectileComponent.cs new file mode 100644 index 0000000..31553b0 --- /dev/null +++ b/GameCore/Combat/ProjectileComponent.cs @@ -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 OnHitEffects { get; set; } = []; +} \ No newline at end of file diff --git a/GameCore/Combat/ProjectileInitializationSystem.cs b/GameCore/Combat/ProjectileInitializationSystem.cs new file mode 100644 index 0000000..8b03c2a --- /dev/null +++ b/GameCore/Combat/ProjectileInitializationSystem.cs @@ -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(OnEntitySpawned); + } + + public void Update(World world, float deltaTime) + { + } + + private void OnEntitySpawned(EntitySpawnedEvent e) + { + var projComp = _world.GetComponent(e.NewEntity); + if (projComp == null) return; + + var ownerWeapon = _world.GetComponent(e.Owner); + if (ownerWeapon != null) + { + projComp.Owner = e.Owner; + projComp.OnHitEffects = ownerWeapon.OnHitEffects; + } + } +} \ No newline at end of file diff --git a/GameCore/Combat/ProjectileSystem.cs b/GameCore/Combat/ProjectileSystem.cs new file mode 100644 index 0000000..52f3c68 --- /dev/null +++ b/GameCore/Combat/ProjectileSystem.cs @@ -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(); + foreach (var projectile in projectiles) + { + var velocity = world.GetComponent(projectile); + var position = world.GetComponent(projectile); + var projectileData = world.GetComponent(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; + } + } +} \ No newline at end of file diff --git a/GameCore/Combat/WeaponComponent.cs b/GameCore/Combat/WeaponComponent.cs new file mode 100644 index 0000000..1e07dfe --- /dev/null +++ b/GameCore/Combat/WeaponComponent.cs @@ -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 OnFireEffects { get; set; } = []; + public List OnHitEffects { get; set; } = []; + + public float CooldownTimer { get; set; } = 0f; +} \ No newline at end of file diff --git a/GameCore/Combat/WeaponSystem.cs b/GameCore/Combat/WeaponSystem.cs new file mode 100644 index 0000000..2da37f9 --- /dev/null +++ b/GameCore/Combat/WeaponSystem.cs @@ -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(); + foreach (var entity in entities) + { + var input = world.GetComponent(entity); + var weapon = world.GetComponent(entity); + var position = world.GetComponent(entity); + var rotation = world.GetComponent(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; + } + } + } +} \ No newline at end of file diff --git a/GameCore/Config/SimulationConfig.cs b/GameCore/Config/SimulationConfig.cs new file mode 100644 index 0000000..6d66c39 --- /dev/null +++ b/GameCore/Config/SimulationConfig.cs @@ -0,0 +1,6 @@ +namespace GameCore.Config; + +public class SimulationConfig +{ + public float GravityStrength { get; set; } = 9.81f; +} \ No newline at end of file diff --git a/GameCore/ECS/Entity.cs b/GameCore/ECS/Entity.cs new file mode 100644 index 0000000..2e8aad5 --- /dev/null +++ b/GameCore/ECS/Entity.cs @@ -0,0 +1,11 @@ +namespace GameCore.ECS; + +/// +/// 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. +/// +public readonly struct Entity(int id) +{ + public readonly int Id = id; +} \ No newline at end of file diff --git a/GameCore/ECS/HitResult.cs b/GameCore/ECS/HitResult.cs new file mode 100644 index 0000000..a95dafa --- /dev/null +++ b/GameCore/ECS/HitResult.cs @@ -0,0 +1,7 @@ +namespace GameCore.ECS; + +public struct HitResult +{ + public bool DidHit; + public Entity HitEntity; +} \ No newline at end of file diff --git a/GameCore/ECS/Interfaces/IComponent.cs b/GameCore/ECS/Interfaces/IComponent.cs new file mode 100644 index 0000000..585fc49 --- /dev/null +++ b/GameCore/ECS/Interfaces/IComponent.cs @@ -0,0 +1,12 @@ +namespace GameCore.ECS.Interfaces; + +/// +/// 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. +/// +public interface IComponent +{ + +} \ No newline at end of file diff --git a/GameCore/ECS/Interfaces/IEntityPresenter.cs b/GameCore/ECS/Interfaces/IEntityPresenter.cs new file mode 100644 index 0000000..e6ee0e8 --- /dev/null +++ b/GameCore/ECS/Interfaces/IEntityPresenter.cs @@ -0,0 +1,6 @@ +namespace GameCore.ECS.Interfaces; + +public interface IEntityPresenter +{ + public Entity CoreEntity { get; set; } +} \ No newline at end of file diff --git a/GameCore/ECS/Interfaces/IPresenterComponent.cs b/GameCore/ECS/Interfaces/IPresenterComponent.cs new file mode 100644 index 0000000..f124c86 --- /dev/null +++ b/GameCore/ECS/Interfaces/IPresenterComponent.cs @@ -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); +} \ No newline at end of file diff --git a/GameCore/ECS/Interfaces/ISystem.cs b/GameCore/ECS/Interfaces/ISystem.cs new file mode 100644 index 0000000..b43fe2e --- /dev/null +++ b/GameCore/ECS/Interfaces/ISystem.cs @@ -0,0 +1,17 @@ +namespace GameCore.ECS.Interfaces; + +/// +/// 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. +/// +public interface ISystem +{ + /// + /// The main update method for a system, called once per game loop. + /// + /// A reference to the main World object to query for entities and components. + /// The time elapsed since the last frame. + void Update(World world, float deltaTime); +} \ No newline at end of file diff --git a/GameCore/ECS/Interfaces/IWorldQuery.cs b/GameCore/ECS/Interfaces/IWorldQuery.cs new file mode 100644 index 0000000..697ef80 --- /dev/null +++ b/GameCore/ECS/Interfaces/IWorldQuery.cs @@ -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); +} \ No newline at end of file diff --git a/GameCore/ECS/PresenterData.cs b/GameCore/ECS/PresenterData.cs new file mode 100644 index 0000000..86f2394 --- /dev/null +++ b/GameCore/ECS/PresenterData.cs @@ -0,0 +1,10 @@ +using GameCore.ECS.Interfaces; + +namespace GameCore.ECS; + +public readonly struct PresenterData(Entity entity, IEntityPresenter presenter, List components) +{ + public readonly Entity Entity = entity; + public readonly IEntityPresenter Presenter = presenter; + public readonly List Components = components; +} \ No newline at end of file diff --git a/GameCore/ECS/World.cs b/GameCore/ECS/World.cs new file mode 100644 index 0000000..5dd5936 --- /dev/null +++ b/GameCore/ECS/World.cs @@ -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; + +/// +/// 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. +/// +public class World(IInputService inputService, IWorldQuery worldQuery, SimulationConfig config) +{ + private readonly Dictionary> _componentsByEntityId = new(); + private readonly EventBus _eventBus = new(); + private readonly List _systems = []; + + public readonly SimulationConfig Config = config; + public readonly IInputService InputService = inputService; + public readonly IWorldQuery WorldQuery = worldQuery; + private int _nextEntityId; + + /// + /// Creates a new, unique entity. + /// + /// The newly created Entity. + public Entity CreateEntity() + { + var id = _nextEntityId++; + _componentsByEntityId[id] = []; + return new Entity(id); + } + + public void DestroyEntity(Entity entity) + { + _componentsByEntityId.Remove(entity.Id); + } + + /// + /// Adds a component to a given entity. + /// + public void AddComponent(Entity entity, IComponent component) + { + _componentsByEntityId[entity.Id].Add(component); + } + + public void RemoveComponent(Entity entity) where T : class, IComponent + { + if (!_componentsByEntityId.TryGetValue(entity.Id, out var components)) return; + + var componentToRemove = components.OfType().FirstOrDefault(); + if (componentToRemove != null) components.Remove(componentToRemove); + } + + /// + /// Retrieves a specific type of component from an entity. + /// + /// The component instance, or null if the entity doesn't have it. + public T? GetComponent(Entity entity) where T : class, IComponent + { + return _componentsByEntityId.TryGetValue(entity.Id, out var components) + ? components.OfType().FirstOrDefault() + : null; + } + + /// + /// Finds all entities that have a specific component type. + /// + public IEnumerable GetEntitiesWith() where T : IComponent + { + return from pair in _componentsByEntityId + where pair.Value.Any(c => c is T) + select new Entity(pair.Key); + } + + /// + /// Registers a system to be run in the game loop. + /// + public void RegisterSystem(ISystem system) + { + _systems.Add(system); + } + + /// + /// Runs a single tick of the game simulation, updating all registered systems. + /// + 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(Action handler) where T : IEvent + { + _eventBus.Subscribe(handler); + } + + public void Unsubscribe(Action handler) where T : IEvent + { + _eventBus.Unsubscribe(handler); + } +} \ No newline at end of file diff --git a/GameCore/Events/DamageDealtEvent.cs b/GameCore/Events/DamageDealtEvent.cs new file mode 100644 index 0000000..9c276df --- /dev/null +++ b/GameCore/Events/DamageDealtEvent.cs @@ -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; +} \ No newline at end of file diff --git a/GameCore/Events/EntityDiedEvent.cs b/GameCore/Events/EntityDiedEvent.cs new file mode 100644 index 0000000..4e2d3f0 --- /dev/null +++ b/GameCore/Events/EntityDiedEvent.cs @@ -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; +} \ No newline at end of file diff --git a/GameCore/Events/EntitySpawnedEvent.cs b/GameCore/Events/EntitySpawnedEvent.cs new file mode 100644 index 0000000..d1015f8 --- /dev/null +++ b/GameCore/Events/EntitySpawnedEvent.cs @@ -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; +} \ No newline at end of file diff --git a/GameCore/Events/EventBus.cs b/GameCore/Events/EventBus.cs new file mode 100644 index 0000000..9cccb20 --- /dev/null +++ b/GameCore/Events/EventBus.cs @@ -0,0 +1,46 @@ +using GameCore.Events.Interfaces; + +namespace GameCore.Events; + +public class EventBus +{ + private readonly Queue _eventQueue = new(); + private readonly Dictionary>> _subscribers = new(); + + public void Subscribe(Action listener) where T : IEvent + { + var eventType = typeof(T); + if (!_subscribers.ContainsKey(eventType)) _subscribers[eventType] = []; + + _subscribers[eventType].Add(e => listener((T)e)); + } + + public void Unsubscribe(Action listener) where T : IEvent + { + var eventType = typeof(T); + if (!_subscribers.TryGetValue(eventType, out var listeners)) return; + + listeners.RemoveAll(e => e.Equals((Action)(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); + } + } +} \ No newline at end of file diff --git a/GameCore/Events/Interfaces/IEvent.cs b/GameCore/Events/Interfaces/IEvent.cs new file mode 100644 index 0000000..697ced7 --- /dev/null +++ b/GameCore/Events/Interfaces/IEvent.cs @@ -0,0 +1,6 @@ +namespace GameCore.Events.Interfaces; + +public interface IEvent +{ + +} \ No newline at end of file diff --git a/GameCore/Events/SpawnEntityEvent.cs b/GameCore/Events/SpawnEntityEvent.cs new file mode 100644 index 0000000..214b435 --- /dev/null +++ b/GameCore/Events/SpawnEntityEvent.cs @@ -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; +} \ No newline at end of file diff --git a/GameCore/Events/WeaponFiredEvent.cs b/GameCore/Events/WeaponFiredEvent.cs new file mode 100644 index 0000000..942078b --- /dev/null +++ b/GameCore/Events/WeaponFiredEvent.cs @@ -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; +} \ No newline at end of file diff --git a/GameCore/GameCore.csproj b/GameCore/GameCore.csproj new file mode 100644 index 0000000..9941e09 --- /dev/null +++ b/GameCore/GameCore.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/GameCore/Input/InputStateComponent.cs b/GameCore/Input/InputStateComponent.cs new file mode 100644 index 0000000..3572b1a --- /dev/null +++ b/GameCore/Input/InputStateComponent.cs @@ -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; } +} \ No newline at end of file diff --git a/GameCore/Input/Interfaces/IInputService.cs b/GameCore/Input/Interfaces/IInputService.cs new file mode 100644 index 0000000..6390d09 --- /dev/null +++ b/GameCore/Input/Interfaces/IInputService.cs @@ -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; } +} \ No newline at end of file diff --git a/GameCore/Input/PlayerInputSystem.cs b/GameCore/Input/PlayerInputSystem.cs new file mode 100644 index 0000000..de1296f --- /dev/null +++ b/GameCore/Input/PlayerInputSystem.cs @@ -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(); + if (!playerEntities.Any()) return; + + var playerEntity = playerEntities.First(); + var inputState = world.GetComponent(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; + } +} \ No newline at end of file diff --git a/GameCore/Math/Vector3.cs b/GameCore/Math/Vector3.cs new file mode 100644 index 0000000..6157dd9 --- /dev/null +++ b/GameCore/Math/Vector3.cs @@ -0,0 +1,79 @@ +namespace GameCore.Math; + +/// +/// 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. +/// +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); + } +} \ No newline at end of file diff --git a/GameCore/Movement/CharacterStateComponent.cs b/GameCore/Movement/CharacterStateComponent.cs new file mode 100644 index 0000000..ad759b9 --- /dev/null +++ b/GameCore/Movement/CharacterStateComponent.cs @@ -0,0 +1,8 @@ +using GameCore.ECS.Interfaces; + +namespace GameCore.Movement; + +public class CharacterStateComponent : IComponent +{ + public bool IsOnFloor { get; set; } +} \ No newline at end of file diff --git a/GameCore/Movement/GravitySystem.cs b/GameCore/Movement/GravitySystem.cs new file mode 100644 index 0000000..71201a7 --- /dev/null +++ b/GameCore/Movement/GravitySystem.cs @@ -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(); + foreach (var entity in entities) + { + var velocity = world.GetComponent(entity); + var characterState = world.GetComponent(entity); + + if (velocity == null || characterState == null) + continue; + + if (!characterState.IsOnFloor) velocity.DesiredVelocity.Y -= world.Config.GravityStrength * deltaTime; + } + } +} \ No newline at end of file diff --git a/GameCore/Movement/GroundMovementSystem.cs b/GameCore/Movement/GroundMovementSystem.cs new file mode 100644 index 0000000..89af08f --- /dev/null +++ b/GameCore/Movement/GroundMovementSystem.cs @@ -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(); + foreach (var entity in entities) + { + var input = world.GetComponent(entity); + var velocity = world.GetComponent(entity); + var attributes = world.GetComponent(entity); + var rotation = world.GetComponent(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; + } +} \ No newline at end of file diff --git a/GameCore/Movement/JumpSystem.cs b/GameCore/Movement/JumpSystem.cs new file mode 100644 index 0000000..83bb0e6 --- /dev/null +++ b/GameCore/Movement/JumpSystem.cs @@ -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(); + foreach (var entity in entities) + { + var input = world.GetComponent(entity); + var velocity = world.GetComponent(entity); + var attributes = world.GetComponent(entity); + var characterState = world.GetComponent(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); + } + } + } +} \ No newline at end of file diff --git a/GameCore/Movement/RotationComponent.cs b/GameCore/Movement/RotationComponent.cs new file mode 100644 index 0000000..884420b --- /dev/null +++ b/GameCore/Movement/RotationComponent.cs @@ -0,0 +1,9 @@ +using GameCore.ECS.Interfaces; +using GameCore.Math; + +namespace GameCore.Movement; + +public class RotationComponent : IComponent +{ + public Vector3 Rotation; +} \ No newline at end of file diff --git a/GameCore/Movement/RotationSystem.cs b/GameCore/Movement/RotationSystem.cs new file mode 100644 index 0000000..6fae4d8 --- /dev/null +++ b/GameCore/Movement/RotationSystem.cs @@ -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(); + foreach (var entity in entities) + { + var input = world.GetComponent(entity); + var rotation = world.GetComponent(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); + } + } +} \ No newline at end of file diff --git a/GameCore/Physics/PositionComponent.cs b/GameCore/Physics/PositionComponent.cs new file mode 100644 index 0000000..ffe9947 --- /dev/null +++ b/GameCore/Physics/PositionComponent.cs @@ -0,0 +1,12 @@ +using GameCore.ECS.Interfaces; +using GameCore.Math; + +namespace GameCore.Physics; + +/// +/// Stores the 3D position of an entity in the game world. +/// +public class PositionComponent : IComponent +{ + public Vector3 Position; +} \ No newline at end of file diff --git a/GameCore/Physics/VelocityComponent.cs b/GameCore/Physics/VelocityComponent.cs new file mode 100644 index 0000000..12434da --- /dev/null +++ b/GameCore/Physics/VelocityComponent.cs @@ -0,0 +1,14 @@ +using GameCore.ECS.Interfaces; +using GameCore.Math; + +namespace GameCore.Physics; + +/// +/// Stores the 3D velocity of an entity. +/// This component is used by the PhysicsSystem to update the PositionComponent. +/// +public class VelocityComponent : IComponent +{ + public Vector3 ActualVelocity; + public Vector3 DesiredVelocity; +} \ No newline at end of file diff --git a/GameCore/Player/PlayerComponent.cs b/GameCore/Player/PlayerComponent.cs new file mode 100644 index 0000000..d17d3fa --- /dev/null +++ b/GameCore/Player/PlayerComponent.cs @@ -0,0 +1,7 @@ +using GameCore.ECS.Interfaces; + +namespace GameCore.Player; + +public class PlayerComponent : IComponent +{ +} \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..2ddda36 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "8.0.0", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file