diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index f4e54b11..b380c2a0 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -2,7 +2,7 @@ name: Build And Test on: [push] env: - BuildVersion: '0.43.${{github.run_number}}' + BuildVersion: '0.44.${{github.run_number}}' SolutionFile: 'src/OpenRpg.sln' DemoProject: 'src/OpenRpg.Demos.Web/OpenRpg.Demos.Web.csproj' EditorProject: 'src/OpenRpg.Editor/OpenRpg.Editor.csproj' @@ -14,10 +14,10 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Setup .NET 9.0 + - name: Setup .NET 10.0 uses: actions/setup-dotnet@v2 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: .Net Restore run: dotnet restore ${{ env.SolutionFile }} @@ -63,10 +63,10 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Setup .NET 9.0 + - name: Setup .NET 10.0 uses: actions/setup-dotnet@v2 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Build Web Demo run: dotnet publish -c Release -o demo "${{ env.DemoProject }}" @@ -95,10 +95,10 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Setup .NET 9.0 + - name: Setup .NET 10.0 uses: actions/setup-dotnet@v2 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Build Editor App run: dotnet publish -c Release -r win-x64 --sc /p:Version=${{ env.BuildVersion }} -o /editor "${{ env.EditorProject }}" diff --git a/build/pack.bat b/build/pack.bat index bce98d80..afab125c 100644 --- a/build/pack.bat +++ b/build/pack.bat @@ -1,4 +1,4 @@ -set version=0.29.00 +set version=0.43.00 dotnet pack ../src/OpenRpg.Core -c Release -o ../../_dist /p:version=%version% dotnet pack ../src/OpenRpg.Items -c Release -o ../../_dist /p:version=%version% dotnet pack ../src/OpenRpg.Combat -c Release -o ../../_dist /p:version=%version% @@ -11,4 +11,5 @@ dotnet pack ../src/OpenRpg.Genres.Fantasy -c Release -o ../../_dist /p:version=% dotnet pack ../src/OpenRpg.Cards -c Release -o ../../_dist /p:version=%version% dotnet pack ../src/OpenRpg.CurveFunctions -c Release -o ../../_dist /p:version=%version% dotnet pack ../src/OpenRpg.Tags -c Release -o ../../_dist /p:version=%version% -dotnet pack ../src/OpenRpg.Items.TradeSkills -c Release -o ../../_dist /p:version=%version% \ No newline at end of file +dotnet pack ../src/OpenRpg.Items.TradeSkills -c Release -o ../../_dist /p:version=%version% +dotnet pack ../src/OpenRpg.AdviceEngine.Rx -c Release -o ../../_dist /p:version=%version% \ No newline at end of file diff --git a/docs/quests.md b/docs/quests.md index 8d96ec4b..0c32d661 100644 --- a/docs/quests.md +++ b/docs/quests.md @@ -2,6 +2,118 @@ The quests project builds upon the `OpenRpg.Items` project to allow a way to express quests that can be carried out by players within the game. -A `Quest` is a `Template` that generally contains information about the `Objective`, the `Rewards` as well as other metadata as well as a way to track the state of completed quests and faction information. +A `QuestTemplate` is a `Template` that generally contains information about the `Objectives`, the `Rewards` as well as other metadata and a way to track the state of completed quests and faction information. -> It is recommended you look over the interactive web demo for a more in depth example and explanation of this project. \ No newline at end of file +> It is recommended you look over the interactive web demo for a more in depth example and explanation of this project. + +## Quest Structure + +```csharp +var quest = new QuestTemplate +{ + Id = 1, + NameLocaleId = "Goblin Hunt", + DescriptionLocaleId = "Defeat 3 goblins terrorising the village.", + IsRepeatable = false, + Objectives = new List { ... }, + Rewards = new List { ... }, + Gifts = new List { ... }, // items given at quest start + Variables = new QuestTemplateVariables() // stores Requirements at key 4003 +}; +``` + +### Objectives + +Each `Objective` has an `ObjectiveType` (int) and an `Association` (AssociatedId + AssociatedValue). + +| Type | Constant | Meaning | +|------|----------|---------| +| 1 | `TriggerObjective` | A trigger must be set | +| 2 | `ItemObjective` | Collect N items (AssociatedId = item template id, AssociatedValue = count) | +| 3 | `LevelObjective` | Reach a level (AssociatedValue = required level) | +| 4 | `ClassObjective` | Have a class (AssociatedId = class template id) | +| 5 | `EffectObjective` | Have an active effect (AssociatedId = effect id) | +| 6 | `QuestObjective` | Complete another quest (AssociatedId = quest id) | +| 30 | `CurrencyObjective` | Collect N currency (AssociatedValue = amount) | +| 31 | `EnemyDefeatedObjective` | Defeat N enemies (AssociatedId = enemy id, AssociatedValue = count) | +| 32 | `EnemySightedObjective` | Sight N enemies (AssociatedId = enemy id, AssociatedValue = count) | + +### Rewards + +Rewards have a `RewardType`, `RewardChance`, and `Association`. Common types include experience, currency, and item rewards. + +### Requirements + +Requirements live on `quest.Variables.Requirements` (key 4003) and gate quest acceptance. They are checked via `ICharacterRequirementChecker`. + +## Objective Verification + +The `ICharacterObjectiveChecker` follows the same pattern as `ICharacterRequirementChecker` — it has four overloads for different contexts: + +```csharp +public interface ICharacterObjectiveChecker : IObjectiveChecker +{ + // Stateless checks — verified from live character state + bool IsObjectiveMet(Character character, Objective objective); // level, class, effects + bool IsObjectiveMet(IQuestState state, Objective objective); // quest prerequisites + bool IsObjectiveMet(ITriggerState state, Objective objective); // trigger objectives + + // Stateful checks — verified from accumulated progress + bool IsObjectiveMet(IObjectiveState state, Objective objective, int questId, int objectiveIndex); +} +``` + +**Stateless objectives** (trigger, quest, level, class, effect) are checked against live game state each time. + +**Stateful objectives** (items, kills, currency) compare accumulated progress against the required threshold. + +### Batch Checking + +```csharp +// Check all objectives for a quest across all four contexts +var allMet = ObjectiveChecker.AreObjectivesMet(character, questData); +``` + +## Objective State Tracking + +`IObjectiveState` stores per-quest-per-objective progress as a dictionary of composite keys to int counts. + +The composite key combines `questId` and `objectiveIndex` into a single int: `(questId << 16) | objectiveIndex`. + +```csharp +// Access via entity variables (optional — can also use standalone) +character.Variables.ObjectiveState.GetObjectiveProgress(questId, objectiveIndex); +character.Variables.ObjectiveState.AddObjectiveProgress(questId, objectiveIndex, 1); +character.Variables.ObjectiveState.IsObjectiveComplete(questId, objectiveIndex, requiredAmount); +character.Variables.ObjectiveState.ClearQuestObjectives(questId, quest.Objectives.Count); +``` + +Progress is **push-based** — game code explicitly calls `AddObjectiveProgress` when events happen (enemy killed, item collected, etc.). + +## QuestData + +`QuestData` is a proper `ITemplateData` instance that bundles quest definition, state, and objective state. It links to the template via `TemplateId` (like `ItemData`): + +```csharp +var questData = new QuestData(quest.Id, QuestStateTypes.QuestActive); +// questData.TemplateId — links to the QuestTemplate +// questData.State — current state (NotStarted/Active/Complete) +// questData.ObjectiveState — per-objective progress +``` + +## Typical Usage + +```csharp +// Accept a quest +var questData = new QuestData(quest.Id, QuestStateTypes.QuestActive); +questData.ObjectiveState.ClearQuestObjectives(quest.Id, quest.Objectives.Count); + +// Track progress (called by game code when events happen) +questData.ObjectiveState.AddObjectiveProgress(quest.Id, objectiveIndex, 1); + +// Check if all objectives are met +var canComplete = ObjectiveChecker.AreObjectivesMet(character, quest, questData); + +// Complete the quest +questData.State = QuestStateTypes.QuestComplete; +``` diff --git a/src/OpenRpg.AdviceEngine.Rx/OpenRpg.AdviceEngine.Rx.csproj b/src/OpenRpg.AdviceEngine.Rx/OpenRpg.AdviceEngine.Rx.csproj index 2150f822..4e0c8b55 100644 --- a/src/OpenRpg.AdviceEngine.Rx/OpenRpg.AdviceEngine.Rx.csproj +++ b/src/OpenRpg.AdviceEngine.Rx/OpenRpg.AdviceEngine.Rx.csproj @@ -8,8 +8,8 @@ https://github.com/openrpg/OpenRpg A Utility AI flavoured framework to provide advice about what entities should do rpg game-development xna monogame unity godot rx - netstandard2.1 - 8 + net10.0 + 14 @@ -17,7 +17,7 @@ - + diff --git a/src/OpenRpg.AdviceEngine/Applicators/IApplicator.cs b/src/OpenRpg.AdviceEngine/Applicators/IApplicator.cs index 32ba3292..8ace2920 100644 --- a/src/OpenRpg.AdviceEngine/Applicators/IApplicator.cs +++ b/src/OpenRpg.AdviceEngine/Applicators/IApplicator.cs @@ -1,12 +1,13 @@ +using System.Collections.Generic; using OpenRpg.Core.Requirements; -using OpenRpg.Entities.Requirements; namespace OpenRpg.AdviceEngine.Applicators { - public interface IApplicator : IHasRequirements + public interface IApplicator { int Priority { get; } bool CanApplyTo(IAgent agent); void ApplyTo(IAgent agent); + IReadOnlyCollection Requirements { get; } } } \ No newline at end of file diff --git a/src/OpenRpg.AdviceEngine/Extensions/UtilityExtensions.cs b/src/OpenRpg.AdviceEngine/Extensions/UtilityExtensions.cs index 172f832d..93de8e44 100644 --- a/src/OpenRpg.AdviceEngine/Extensions/UtilityExtensions.cs +++ b/src/OpenRpg.AdviceEngine/Extensions/UtilityExtensions.cs @@ -8,24 +8,14 @@ public static class UtilityExtensions { public static float CalculateScore(this IReadOnlyCollection> variableUtilities) { return CalculateScore(variableUtilities.Select(x => x.Value).ToArray()); } - - public static float CalculateScore(this IReadOnlyCollection utilities) - { - var score = 1.0f; - var compensationFactor = (float)(1.0 - 1.0 / utilities.Count); - foreach (var utility in utilities) - { - var modification = (float)((1.0 - utility) * compensationFactor); - var scaledUtility = utility + (modification * utility); - score *= scaledUtility; - } - return score; - } public static float CalculateScore(params float[] utilities) + { return CalculateScore(utilities as IReadOnlyCollection); } + + public static float CalculateScore(this IReadOnlyCollection utilities) { var score = 1.0f; - var compensationFactor = (float)(1.0 - 1.0 / utilities.Length); + var compensationFactor = (float)(1.0 - 1.0 / utilities.Count); foreach (var utility in utilities) { var modification = (float)((1.0 - utility) * compensationFactor); diff --git a/src/OpenRpg.AdviceEngine/OpenRpg.AdviceEngine.csproj b/src/OpenRpg.AdviceEngine/OpenRpg.AdviceEngine.csproj index 57e5c187..15692de4 100644 --- a/src/OpenRpg.AdviceEngine/OpenRpg.AdviceEngine.csproj +++ b/src/OpenRpg.AdviceEngine/OpenRpg.AdviceEngine.csproj @@ -8,8 +8,8 @@ https://github.com/openrpg/OpenRpg.AdviceEngine A Utility AI flavoured framework to provide advice about what entities should do rpg game-development xna monogame unity godot - netstandard2.1 - 8 + net10.0 + 14 diff --git a/src/OpenRpg.Cards/Effects/CardEffects.cs b/src/OpenRpg.Cards/Effects/CardEffects.cs index feadb764..487e5b7a 100644 --- a/src/OpenRpg.Cards/Effects/CardEffects.cs +++ b/src/OpenRpg.Cards/Effects/CardEffects.cs @@ -2,15 +2,15 @@ using System.Collections.Generic; using OpenRpg.Core.Common; using OpenRpg.Core.Effects; -using OpenRpg.Entities.Effects; namespace OpenRpg.Cards.Effects { - public class CardEffects : IHasDataId, IHasLocaleDescription, IHasEffects + public class CardEffects : IHasDataId, IHasLocaleDescription { public int Id { get; set; } public string NameLocaleId { get; set; } public string DescriptionLocaleId { get; set; } + public IReadOnlyCollection Effects { get; set; } = Array.Empty(); } } \ No newline at end of file diff --git a/src/OpenRpg.Cards/Genres/AbilityCard.cs b/src/OpenRpg.Cards/Genres/AbilityCard.cs index 48ab32c3..93a16268 100644 --- a/src/OpenRpg.Cards/Genres/AbilityCard.cs +++ b/src/OpenRpg.Cards/Genres/AbilityCard.cs @@ -4,7 +4,6 @@ using OpenRpg.Cards.Types; using OpenRpg.Combat.Abilities; using OpenRpg.Core.Effects; -using OpenRpg.Entities.Effects; namespace OpenRpg.Cards.Genres { diff --git a/src/OpenRpg.Cards/Genres/ClassCard.cs b/src/OpenRpg.Cards/Genres/ClassCard.cs index cb9e6aae..4bc590b3 100644 --- a/src/OpenRpg.Cards/Genres/ClassCard.cs +++ b/src/OpenRpg.Cards/Genres/ClassCard.cs @@ -1,10 +1,11 @@ using OpenRpg.Cards.Genres.Conventions; using OpenRpg.Cards.Types; using OpenRpg.Entities.Classes.Templates; +using OpenRpg.Entities.Classes.Variables; namespace OpenRpg.Cards.Genres { - public class ClassCard : GenericDataCardWithEffects + public class ClassCard : TemplateDataCardWithEffects { public override int CardType => CardTypes.ClassCard; diff --git a/src/OpenRpg.Cards/Genres/Conventions/GenericDataCard.cs b/src/OpenRpg.Cards/Genres/Conventions/GenericDataCard.cs index c75083ef..a3018584 100644 --- a/src/OpenRpg.Cards/Genres/Conventions/GenericDataCard.cs +++ b/src/OpenRpg.Cards/Genres/Conventions/GenericDataCard.cs @@ -2,7 +2,6 @@ using OpenRpg.Cards.Variables; using OpenRpg.Core.Common; using OpenRpg.Core.Effects; -using OpenRpg.Entities.Effects; namespace OpenRpg.Cards.Genres.Conventions { diff --git a/src/OpenRpg.Cards/Genres/Conventions/GenericDataCardWithEffects.cs b/src/OpenRpg.Cards/Genres/Conventions/GenericDataCardWithEffects.cs deleted file mode 100644 index 0b65e5b7..00000000 --- a/src/OpenRpg.Cards/Genres/Conventions/GenericDataCardWithEffects.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; -using OpenRpg.Core.Common; -using OpenRpg.Core.Effects; -using OpenRpg.Entities.Effects; - -namespace OpenRpg.Cards.Genres.Conventions -{ - public abstract class GenericDataCardWithEffects : GenericDataCard - where T : IHasLocaleDescription, IHasEffects - { - protected GenericDataCardWithEffects(T data) : base(data) - { } - - public override IReadOnlyCollection Effects => Data.Effects; - } -} \ No newline at end of file diff --git a/src/OpenRpg.Cards/Genres/Conventions/TemplateDataCardWithEffects.cs b/src/OpenRpg.Cards/Genres/Conventions/TemplateDataCardWithEffects.cs new file mode 100644 index 00000000..2e83aa06 --- /dev/null +++ b/src/OpenRpg.Cards/Genres/Conventions/TemplateDataCardWithEffects.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using OpenRpg.Core.Common; +using OpenRpg.Core.Effects; +using OpenRpg.Core.Templates; +using OpenRpg.Core.Templates.Variables; +using OpenRpg.Entities.Effects; +using OpenRpg.Entities.Extensions; + +namespace OpenRpg.Cards.Genres.Conventions +{ + public abstract class TemplateDataCardWithEffects : GenericDataCard + where T : ITemplate + where TTemplateVars : ITemplateVariables + { + protected TemplateDataCardWithEffects(T data) : base(data) + { } + + public override IReadOnlyCollection Effects => Data.Variables.Effects; + } +} \ No newline at end of file diff --git a/src/OpenRpg.Cards/Genres/EffectCard.cs b/src/OpenRpg.Cards/Genres/EffectCard.cs index 5c341e0a..3e8733b3 100644 --- a/src/OpenRpg.Cards/Genres/EffectCard.cs +++ b/src/OpenRpg.Cards/Genres/EffectCard.cs @@ -1,12 +1,16 @@ -using OpenRpg.Cards.Effects; +using System.Collections.Generic; +using OpenRpg.Cards.Effects; using OpenRpg.Cards.Genres.Conventions; using OpenRpg.Cards.Types; +using OpenRpg.Core.Effects; namespace OpenRpg.Cards.Genres { - public class EffectCard : GenericDataCardWithEffects + public class EffectCard : GenericDataCard { public override int CardType => CardTypes.EffectCard; + + public override IReadOnlyCollection Effects => Data.Effects; public EffectCard(CardEffects data) : base(data) { diff --git a/src/OpenRpg.Cards/Genres/ItemCard.cs b/src/OpenRpg.Cards/Genres/ItemCard.cs index bd5d9b5c..f27281ea 100644 --- a/src/OpenRpg.Cards/Genres/ItemCard.cs +++ b/src/OpenRpg.Cards/Genres/ItemCard.cs @@ -3,6 +3,7 @@ using OpenRpg.Cards.Variables; using OpenRpg.Core.Effects; using OpenRpg.Entities.Effects; +using OpenRpg.Entities.Extensions; using OpenRpg.Items; namespace OpenRpg.Cards.Genres @@ -18,6 +19,6 @@ public ItemCard(Item item) public string NameLocaleId => Data.Template.NameLocaleId; public string DescriptionLocaleId => Data.Template.DescriptionLocaleId; - public IReadOnlyCollection Effects => Data.Template.Effects; + public IReadOnlyCollection Effects => Data.Template.Variables.Effects; } } \ No newline at end of file diff --git a/src/OpenRpg.Cards/Genres/RaceCard.cs b/src/OpenRpg.Cards/Genres/RaceCard.cs index bcb5af9e..de051fc9 100644 --- a/src/OpenRpg.Cards/Genres/RaceCard.cs +++ b/src/OpenRpg.Cards/Genres/RaceCard.cs @@ -1,10 +1,11 @@ using OpenRpg.Cards.Genres.Conventions; using OpenRpg.Cards.Types; using OpenRpg.Entities.Races.Templates; +using OpenRpg.Entities.Races.Variables; namespace OpenRpg.Cards.Genres { - public class RaceCard : GenericDataCardWithEffects + public class RaceCard : TemplateDataCardWithEffects { public override int CardType => CardTypes.RaceCard; diff --git a/src/OpenRpg.Cards/ICard.cs b/src/OpenRpg.Cards/ICard.cs index 06cf63b8..464258f5 100644 --- a/src/OpenRpg.Cards/ICard.cs +++ b/src/OpenRpg.Cards/ICard.cs @@ -1,16 +1,18 @@ -using OpenRpg.Cards.Variables; +using System.Collections.Generic; +using OpenRpg.Cards.Variables; using OpenRpg.Core.Common; using OpenRpg.Core.Effects; using OpenRpg.Core.Variables.General; -using OpenRpg.Entities.Effects; namespace OpenRpg.Cards { /// /// The Card interface provides a way to wrap up underlying objects and express them as playable cards /// - public interface ICard : IHasLocaleDescription, IHasEffects, IHasVariables + public interface ICard : IHasLocaleDescription, IHasVariables { int CardType { get; } + + IReadOnlyCollection Effects { get; } } } \ No newline at end of file diff --git a/src/OpenRpg.Cards/OpenRpg.Cards.csproj b/src/OpenRpg.Cards/OpenRpg.Cards.csproj index ac6c473b..d542ff47 100644 --- a/src/OpenRpg.Cards/OpenRpg.Cards.csproj +++ b/src/OpenRpg.Cards/OpenRpg.Cards.csproj @@ -2,14 +2,14 @@ 0.0.0 - netstandard2.1 + net10.0 OpenRpg.Cards Grofit (LP) https://github.com/openrpg/OpenRpg/blob/master/LICENSE https://github.com/openrpg/OpenRpg Adds a basic starting point for wrapping objects as cards for card game scenarios rpg game-development xna monogame unity godot - 8 + 14 diff --git a/src/OpenRpg.Combat/Attacks/Attack.cs b/src/OpenRpg.Combat/Attacks/Attack.cs index dd11b5fb..63f0545f 100644 --- a/src/OpenRpg.Combat/Attacks/Attack.cs +++ b/src/OpenRpg.Combat/Attacks/Attack.cs @@ -3,16 +3,5 @@ namespace OpenRpg.Combat.Attacks { - public class Attack - { - public bool IsCritical { get; set; } - public IReadOnlyList Damages { get; set; } = Array.Empty(); - - public Attack(){} - public Attack(IReadOnlyList damages, bool isCritical = false) - { - Damages = damages; - IsCritical = isCritical; - } - } + public record Attack(bool IsCritical, IReadOnlyList Damages); } \ No newline at end of file diff --git a/src/OpenRpg.Combat/Attacks/Damage.cs b/src/OpenRpg.Combat/Attacks/Damage.cs index b1e4df00..1134ff5c 100644 --- a/src/OpenRpg.Combat/Attacks/Damage.cs +++ b/src/OpenRpg.Combat/Attacks/Damage.cs @@ -1,16 +1,4 @@ namespace OpenRpg.Combat.Attacks { - public class Damage - { - public int Type { get; set; } - public float Value { get; set; } - - public Damage(){} - - public Damage(int type, float value) - { - Type = type; - Value = value; - } - } + public record Damage(int Type, float Value); } \ No newline at end of file diff --git a/src/OpenRpg.Combat/Effects/DefaultActiveEffects.cs b/src/OpenRpg.Combat/Effects/DefaultActiveEffects.cs index 91c1e112..8eadbb76 100644 --- a/src/OpenRpg.Combat/Effects/DefaultActiveEffects.cs +++ b/src/OpenRpg.Combat/Effects/DefaultActiveEffects.cs @@ -20,7 +20,7 @@ public class DefaultActiveEffects : IActiveEffects public IReadOnlyCollection ActiveEffects => InternalActiveEffects.Values; public IReadOnlyCollection Effects => InternalActiveEffects.Values - .Where(x => x.IsPassiveEffect()) + .Where(x => x.IsPassiveEffect) .Select(x => x.ToEffect()) .ToArray(); diff --git a/src/OpenRpg.Combat/Effects/IActiveEffects.cs b/src/OpenRpg.Combat/Effects/IActiveEffects.cs index 5ce01ac1..3239ea6a 100644 --- a/src/OpenRpg.Combat/Effects/IActiveEffects.cs +++ b/src/OpenRpg.Combat/Effects/IActiveEffects.cs @@ -11,7 +11,7 @@ namespace OpenRpg.Combat.Effects /// The active effects allows us to wrap up concerns for managing active effects and abstracting away certain details /// like the stacking etc /// - public interface IActiveEffects : IHasEffects, IHasVariables + public interface IActiveEffects : IHasVariables { event EventHandler EffectAdded; event EventHandler EffectTriggered; diff --git a/src/OpenRpg.Combat/Extensions/ActiveEffectsExtensions.cs b/src/OpenRpg.Combat/Extensions/ActiveEffectsExtensions.cs index a2c8e948..5b399e70 100644 --- a/src/OpenRpg.Combat/Extensions/ActiveEffectsExtensions.cs +++ b/src/OpenRpg.Combat/Extensions/ActiveEffectsExtensions.cs @@ -1,32 +1,34 @@ using OpenRpg.Combat.Effects; using OpenRpg.Core.Effects; -using OpenRpg.Entities.Effects; namespace OpenRpg.Combat.Extensions { public static class ActiveEffectsExtensions { - public static bool IsPassiveEffect(this ActiveEffect activeEffect) - { return IsPassiveEffect(activeEffect.StaticEffect); } - - public static bool IsPassiveEffect(this TimedStaticEffect staticEffect) - { return staticEffect.Frequency == 0; } + extension(ActiveEffect effect) + { + public bool IsPassiveEffect => effect.StaticEffect.IsPassiveEffect; + public float StackedPotency => GetStackedPotency(effect); + public int TicksSoFar => (int)(effect.ActiveTime / effect.StaticEffect.Frequency); + } + + extension(TimedStaticEffect effect) + { + public bool IsPassiveEffect => effect.Frequency == 0; + } - public static float GetStackedPotency(this ActiveEffect activeEffect) + public static float GetStackedPotency(ActiveEffect activeEffect) { var stacks = activeEffect.Stacks > 0 ? activeEffect.Stacks : 1; return activeEffect.StaticEffect.Potency * stacks; } - public static int TicksSoFar(this ActiveEffect activeEffect) - { return (int)(activeEffect.ActiveTime / activeEffect.StaticEffect.Frequency); } - public static StaticEffect ToEffect(this ActiveEffect activeEffect) { return new StaticEffect() { EffectType = activeEffect.StaticEffect.EffectType, - Potency = activeEffect.GetStackedPotency(), + Potency = activeEffect.StackedPotency, Requirements = activeEffect.StaticEffect.Requirements }; } diff --git a/src/OpenRpg.Combat/Extensions/CombatAbilityTemplateVariablesExtensions.cs b/src/OpenRpg.Combat/Extensions/CombatAbilityTemplateVariablesExtensions.cs index 3f87d9fa..8ef2ba88 100644 --- a/src/OpenRpg.Combat/Extensions/CombatAbilityTemplateVariablesExtensions.cs +++ b/src/OpenRpg.Combat/Extensions/CombatAbilityTemplateVariablesExtensions.cs @@ -7,19 +7,57 @@ namespace OpenRpg.Combat.Extensions { public static class CombatAbilityTemplateVariablesExtensions { - public static float Cooldown(this AbilityTemplateVariables vars) => vars.GetFloatOrDefault(CombatAbilityTemplateVariableTypes.Cooldown, 0); - public static void Cooldown(this AbilityTemplateVariables vars, float value) => vars[CombatAbilityTemplateVariableTypes.Cooldown] = value; + public static bool HasCooldown(this AbilityTemplateVariables vars) => vars.ContainsKey(CombatAbilityTemplateVariableTypes.Cooldown); + public static bool HasDamage(this AbilityTemplateVariables vars) => vars.ContainsKey(CombatAbilityTemplateVariableTypes.Damage); + public static bool HasRange(this AbilityTemplateVariables vars) => vars.ContainsKey(CombatAbilityTemplateVariableTypes.Range); + public static bool HasAttackSize(this AbilityTemplateVariables vars) => vars.ContainsKey(CombatAbilityTemplateVariableTypes.AttackSize); + public static bool HasTargetType(this AbilityTemplateVariables vars) => vars.ContainsKey(CombatAbilityTemplateVariableTypes.TargetType); + public static bool HasMultiHit(this AbilityTemplateVariables vars) => vars.ContainsKey(CombatAbilityTemplateVariableTypes.MultiHit); + public static bool HasTargetCount(this AbilityTemplateVariables vars) => vars.ContainsKey(CombatAbilityTemplateVariableTypes.TargetCount); - public static Damage Damage(this AbilityTemplateVariables vars) => vars.GetAsOrDefault(CombatAbilityTemplateVariableTypes.Damage, () => new Damage(0, 0)); - public static void Damage(this AbilityTemplateVariables vars, Damage damage) => vars[CombatAbilityTemplateVariableTypes.Damage] = damage; - - public static float Range(this AbilityTemplateVariables vars) => vars.GetFloatOrDefault(CombatAbilityTemplateVariableTypes.Range, 0); - public static void Range(this AbilityTemplateVariables vars, float value) => vars[CombatAbilityTemplateVariableTypes.Range] = value; - - public static float AttackSize(this AbilityTemplateVariables vars) => vars.GetFloatOrDefault(CombatAbilityTemplateVariableTypes.AttackSize, 0); - public static void AttackSize(this AbilityTemplateVariables vars, float value) => vars[CombatAbilityTemplateVariableTypes.AttackSize] = value; - - public static int TargetType(this AbilityTemplateVariables vars) => vars.GetIntOrDefault(CombatAbilityTemplateVariableTypes.TargetType, 0); - public static void TargetType(this AbilityTemplateVariables vars, int value) => vars[CombatAbilityTemplateVariableTypes.TargetType] = value; + extension(AbilityTemplateVariables vars) + { + public float Cooldown + { + get => vars.GetFloatOrDefault(CombatAbilityTemplateVariableTypes.Cooldown, 0); + set => vars[CombatAbilityTemplateVariableTypes.Cooldown] = value; + } + + public Damage Damage + { + get => vars.GetAsOrDefault(CombatAbilityTemplateVariableTypes.Damage, () => new Damage(0, 0)); + set => vars[CombatAbilityTemplateVariableTypes.Damage] = value; + } + + public float Range + { + get => vars.GetFloatOrDefault(CombatAbilityTemplateVariableTypes.Range, 0); + set => vars[CombatAbilityTemplateVariableTypes.Range] = value; + } + + public float AttackSize + { + get => vars.GetFloatOrDefault(CombatAbilityTemplateVariableTypes.AttackSize, 0); + set => vars[CombatAbilityTemplateVariableTypes.AttackSize] = value; + } + + public int TargetType + { + get => vars.GetIntOrDefault(CombatAbilityTemplateVariableTypes.TargetType, 0); + set => vars[CombatAbilityTemplateVariableTypes.TargetType] = value; + } + + public int MultiHit + { + get => vars.GetIntOrDefault(CombatAbilityTemplateVariableTypes.MultiHit, 0); + set => vars[CombatAbilityTemplateVariableTypes.MultiHit] = value; + } + + public int TargetCount + { + get => vars.GetIntOrDefault(CombatAbilityTemplateVariableTypes.TargetCount, 0); + set => vars[CombatAbilityTemplateVariableTypes.TargetCount] = value; + } + } } } \ No newline at end of file diff --git a/src/OpenRpg.Combat/Extensions/CombatEntityVariableExtensions.cs b/src/OpenRpg.Combat/Extensions/CombatEntityVariableExtensions.cs index 98771a1f..808a2721 100644 --- a/src/OpenRpg.Combat/Extensions/CombatEntityVariableExtensions.cs +++ b/src/OpenRpg.Combat/Extensions/CombatEntityVariableExtensions.cs @@ -1,7 +1,6 @@ -using System.Collections.Generic; -using OpenRpg.Combat.Abilities; using OpenRpg.Combat.Effects; using OpenRpg.Combat.Types; +using OpenRpg.Core.Extensions; using OpenRpg.Entities.Entity.Variables; namespace OpenRpg.Combat.Extensions @@ -13,11 +12,14 @@ public static class CombatEntityVariableExtensions { public static bool HasActiveEffects(this EntityVariables vars) { return vars.ContainsKey(CombatEntityVariableTypes.ActiveEffects); } - - public static IActiveEffects ActiveEffects(this EntityVariables vars) - { return vars[CombatEntityVariableTypes.ActiveEffects] as IActiveEffects; } - public static void ActiveEffects(this EntityVariables vars, IActiveEffects activeEffects) - { vars[CombatEntityVariableTypes.ActiveEffects] = activeEffects; } + extension(EntityVariables vars) + { + public IActiveEffects ActiveEffects + { + get => vars.GetAsOrDefaultAndSet(CombatEntityVariableTypes.ActiveEffects, () => new DefaultActiveEffects()); + set => vars[CombatEntityVariableTypes.ActiveEffects] = value; + } + } } } \ No newline at end of file diff --git a/src/OpenRpg.Combat/Extensions/CombatTemplateVariableExtensions.cs b/src/OpenRpg.Combat/Extensions/CombatTemplateVariableExtensions.cs index f2cd4f75..0f7162c7 100644 --- a/src/OpenRpg.Combat/Extensions/CombatTemplateVariableExtensions.cs +++ b/src/OpenRpg.Combat/Extensions/CombatTemplateVariableExtensions.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using OpenRpg.Combat.Abilities; using OpenRpg.Combat.Types; @@ -11,11 +10,16 @@ public static class CombatTemplateVariableExtensions { public static bool HasAbilities(this ITemplateVariables vars) => vars.ContainsKey(CombatTemplateVariableTypes.Abilities); + + extension(ITemplateVariables vars) + { + public IReadOnlyCollection Abilities + { + get => vars.GetAsOrDefaultAndSet(CombatTemplateVariableTypes.Abilities, () => new List()); + set => vars[CombatTemplateVariableTypes.Abilities] = value; + } + } - public static IReadOnlyCollection Abilities(this ITemplateVariables vars) => - vars.GetAsOrDefault(CombatTemplateVariableTypes.Abilities, Array.Empty); - - public static void Abilities(this ITemplateVariables vars, IReadOnlyCollection abilities) => - vars[CombatTemplateVariableTypes.Abilities] = abilities; + } } \ No newline at end of file diff --git a/src/OpenRpg.Combat/Extensions/ITemplateAccessorExtensions.cs b/src/OpenRpg.Combat/Extensions/ITemplateAccessorExtensions.cs new file mode 100644 index 00000000..e17dff45 --- /dev/null +++ b/src/OpenRpg.Combat/Extensions/ITemplateAccessorExtensions.cs @@ -0,0 +1,17 @@ +using OpenRpg.Combat.Abilities; +using OpenRpg.Core.Templates; + +namespace OpenRpg.Combat.Extensions +{ + public static class ITemplateAccessorExtensions + { + public static AbilityTemplate GetAbilityTemplate(this ITemplateAccessor templateAccessor, int abilityTemplateId) + { return templateAccessor.Get(abilityTemplateId); } + + public static Ability ToInstance(this ITemplateAccessor templateAccessor, AbilityData abilityData) + { + var template = templateAccessor.Get(abilityData.TemplateId); + return new Ability() { Data = abilityData, Template = template }; + } + } +} \ No newline at end of file diff --git a/src/OpenRpg.Combat/OpenRpg.Combat.csproj b/src/OpenRpg.Combat/OpenRpg.Combat.csproj index 6bf19d92..b2568cef 100644 --- a/src/OpenRpg.Combat/OpenRpg.Combat.csproj +++ b/src/OpenRpg.Combat/OpenRpg.Combat.csproj @@ -2,14 +2,14 @@ 0.0.0 - netstandard2.1 + net10.0 OpenRpg.Combat Grofit (LP) https://github.com/openrpg/OpenRpg/blob/master/LICENSE https://github.com/openrpg/OpenRpg Adds basic combat related types for building off rpg game-development xna monogame unity godot - 8 + 14 diff --git a/src/OpenRpg.Combat/Processors/Modifiers/AddAttackRandomnessModifier.cs b/src/OpenRpg.Combat/Processors/Modifiers/AddAttackRandomnessModifier.cs index eb580acc..665fdb87 100644 --- a/src/OpenRpg.Combat/Processors/Modifiers/AddAttackRandomnessModifier.cs +++ b/src/OpenRpg.Combat/Processors/Modifiers/AddAttackRandomnessModifier.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using OpenRpg.Combat.Attacks; using OpenRpg.Core.Utils; @@ -17,10 +18,14 @@ public float GenerateRandomFrom(float maximumValue, float startFrom = 0.75f) public Attack ModifyValue(Attack attack) { + var damages = new List(); foreach (var damage in attack.Damages) - { damage.Value = GenerateRandomFrom(damage.Value); } - - return attack; + { + var newValue = GenerateRandomFrom(damage.Value); + if(newValue >= 0) + { damages.Add(damage with { Value = newValue }); } + } + return attack with { Damages = damages }; } } } \ No newline at end of file diff --git a/src/OpenRpg.Combat/Processors/Modifiers/AttackModifierPipeline.cs b/src/OpenRpg.Combat/Processors/Modifiers/AttackModifierPipeline.cs new file mode 100644 index 00000000..a7e4b1fb --- /dev/null +++ b/src/OpenRpg.Combat/Processors/Modifiers/AttackModifierPipeline.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using OpenRpg.Combat.Attacks; + +namespace OpenRpg.Combat.Processors.Modifiers +{ + /// + /// Runs a list of in order against an , + /// honoring each modifier's short-circuit. + /// + public sealed class AttackModifierPipeline + { + public IReadOnlyList Modifiers { get; } + + public AttackModifierPipeline(IReadOnlyList modifiers) + { + Modifiers = modifiers; + } + + public Attack Apply(Attack attack) + { + var current = attack; + foreach (var modifier in Modifiers) + { + if (!modifier.ShouldApply(current)) { continue; } + current = modifier.ModifyValue(current); + } + return current; + } + + public IReadOnlyList ApplyModifiers(Attack attack) + { + var steps = new List { new(attack, null, true) }; + var current = attack; + foreach (var modifier in Modifiers) + { + if (!modifier.ShouldApply(current)) + { + steps.Add(new AttackModifierStep(current, modifier, false)); + continue; + } + current = modifier.ModifyValue(current); + steps.Add(new AttackModifierStep(current, modifier, true)); + } + return steps; + } + } +} diff --git a/src/OpenRpg.Combat/Processors/Modifiers/AttackModifierStep.cs b/src/OpenRpg.Combat/Processors/Modifiers/AttackModifierStep.cs new file mode 100644 index 00000000..d5f06883 --- /dev/null +++ b/src/OpenRpg.Combat/Processors/Modifiers/AttackModifierStep.cs @@ -0,0 +1,12 @@ +using OpenRpg.Combat.Attacks; + +namespace OpenRpg.Combat.Processors.Modifiers; + +/// +/// One snapshot of an attack as it flows through the pipeline, with metadata +/// about which modifier produced it and whether it was applied. +/// +/// The attack state at this step. +/// The modifier that produced this step, or null for the base attack. +/// True if the modifier ran and produced this snapshot, false if it was skipped. +public record AttackModifierStep(Attack Snapshot, IAttackModifier? Modifier, bool WasApplied); \ No newline at end of file diff --git a/src/OpenRpg.Combat/Types/CombatAbilityTemplateVariableTypes.cs b/src/OpenRpg.Combat/Types/CombatAbilityTemplateVariableTypes.cs index caecd255..e7239d4d 100644 --- a/src/OpenRpg.Combat/Types/CombatAbilityTemplateVariableTypes.cs +++ b/src/OpenRpg.Combat/Types/CombatAbilityTemplateVariableTypes.cs @@ -9,5 +9,7 @@ public interface CombatAbilityTemplateVariableTypes public static readonly int Range = 3; public static readonly int AttackSize = 4; public static readonly int TargetType = 5; + public static readonly int MultiHit = 6; + public static readonly int TargetCount = 7; } } \ No newline at end of file diff --git a/src/OpenRpg.Combat/Types/CombatRequirementTypes.cs b/src/OpenRpg.Combat/Types/CombatRequirementTypes.cs new file mode 100644 index 00000000..b3ddcee1 --- /dev/null +++ b/src/OpenRpg.Combat/Types/CombatRequirementTypes.cs @@ -0,0 +1,8 @@ +using OpenRpg.Entities.Types; + +namespace OpenRpg.Combat.Types; + +public interface CombatRequirementTypes : CoreRequirementTypes +{ + public static int ActiveEffectRequirement = 40; +} \ No newline at end of file diff --git a/src/OpenRpg.Combat/Types/CombatTemplateVariableTypes.cs b/src/OpenRpg.Combat/Types/CombatTemplateVariableTypes.cs index c8da1e78..405e932e 100644 --- a/src/OpenRpg.Combat/Types/CombatTemplateVariableTypes.cs +++ b/src/OpenRpg.Combat/Types/CombatTemplateVariableTypes.cs @@ -1,3 +1,6 @@ +using OpenRpg.Combat.Abilities; +using OpenRpg.Core.Variables; + namespace OpenRpg.Combat.Types { public interface CombatTemplateVariableTypes @@ -6,6 +9,7 @@ public interface CombatTemplateVariableTypes public static int Unknown = 0; // For adding the notion of procedural effects + [CollectionElementType(typeof(AbilityData))] public static int Abilities = 6000; } } \ No newline at end of file diff --git a/src/OpenRpg.Core/Associations/Association.cs b/src/OpenRpg.Core/Associations/Association.cs index c1e441dd..f9e7f934 100644 --- a/src/OpenRpg.Core/Associations/Association.cs +++ b/src/OpenRpg.Core/Associations/Association.cs @@ -1,6 +1,6 @@ namespace OpenRpg.Core.Associations { - public struct Association + public record Association { /// /// The required id of whatever is associated diff --git a/src/OpenRpg.Core/Effects/IEffect.cs b/src/OpenRpg.Core/Effects/IEffect.cs index e5029a5e..c9848d76 100644 --- a/src/OpenRpg.Core/Effects/IEffect.cs +++ b/src/OpenRpg.Core/Effects/IEffect.cs @@ -6,7 +6,7 @@ namespace OpenRpg.Core.Effects /// /// Generic effect interface that lets us all agree every effect has a type and requirements /// - public interface IEffect : IHasRequirements + public interface IEffect { /// /// The effect type to apply @@ -16,6 +16,6 @@ public interface IEffect : IHasRequirements /// /// Requirements required for this object to function/be used /// - new IReadOnlyCollection Requirements { get; set; } + IReadOnlyCollection Requirements { get; set; } } } \ No newline at end of file diff --git a/src/OpenRpg.Core/Effects/IHasEffects.cs b/src/OpenRpg.Core/Effects/IHasEffects.cs deleted file mode 100644 index 9d5f7336..00000000 --- a/src/OpenRpg.Core/Effects/IHasEffects.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Collections.Generic; - -namespace OpenRpg.Core.Effects -{ - public interface IHasEffects - { - IReadOnlyCollection Effects { get; } - } -} \ No newline at end of file diff --git a/src/OpenRpg.Core/Extensions/CollectionExtensions.cs b/src/OpenRpg.Core/Extensions/CollectionExtensions.cs index a2ece362..8aeef574 100644 --- a/src/OpenRpg.Core/Extensions/CollectionExtensions.cs +++ b/src/OpenRpg.Core/Extensions/CollectionExtensions.cs @@ -5,6 +5,11 @@ namespace OpenRpg.Core.Extensions { public static class CollectionExtensions { + extension(IReadOnlyCollection) + { + public static IReadOnlyCollection Empty() => Array.Empty(); + } + public static void ForEach(this IDictionary dictionary, Action action) { foreach(var pair in dictionary) @@ -32,5 +37,18 @@ public static int IndexOf( this IReadOnlyList collection, T elementToFind } return -1; } + + public static IEnumerable DistinctBy + (this IEnumerable source, Func keySelector) + { + var seenKeys = new HashSet(); + foreach (var element in source) + { + if (seenKeys.Add(keySelector(element))) + { + yield return element; + } + } + } } } \ No newline at end of file diff --git a/src/OpenRpg.Core/Extensions/IVariableExtensions.cs b/src/OpenRpg.Core/Extensions/IVariableExtensions.cs index 0dcf4197..1a3f5035 100644 --- a/src/OpenRpg.Core/Extensions/IVariableExtensions.cs +++ b/src/OpenRpg.Core/Extensions/IVariableExtensions.cs @@ -67,12 +67,25 @@ public static bool GetBoolOrDefault(this IVariables vars, int variableKe public static T GetAs(this IVariables vars, int variableKey) where T : class { return vars.Get(variableKey) as T; } - + public static T GetAsOrDefault(this IVariables vars, int variableKey, Func defaultValueFactory) where T : class { if (vars.ContainsKey(variableKey)) { return vars.GetAs(variableKey) ?? defaultValueFactory(); } - return defaultValueFactory(); + return defaultValueFactory(); + } + + public static T GetAsOrDefaultAndSet(this IVariables vars, int variableKey, Func defaultValueFactory) where T : class + { + if (vars.ContainsKey(variableKey)) + { + var result = vars.GetAs(variableKey); + if(result is not null) { return result; } + } + + var instance = defaultValueFactory(); + vars[variableKey] = instance; + return instance; } } } \ No newline at end of file diff --git a/src/OpenRpg.Core/Extensions/RangeExtensions.cs b/src/OpenRpg.Core/Extensions/RangeExtensions.cs new file mode 100644 index 00000000..8b8bd742 --- /dev/null +++ b/src/OpenRpg.Core/Extensions/RangeExtensions.cs @@ -0,0 +1,20 @@ +using OpenRpg.Core.Utils; + +namespace OpenRpg.Core.Extensions; + +public static class RangeExtensions +{ + extension(Range range) + { + public bool IsWithinRange(int value) => value >= range.Min && value <= range.Max; + public bool IsOutsideRange(int value) => value > range.Max || value < range.Min; + } + + extension(RangeF range) + { + public bool IsWithinRange(float value) => value >= range.Min && value <= range.Max; + public bool IsOutsideRange(float value) => value > range.Max || value < range.Min; + public bool IsWithinRange(int value) => value >= range.Min && value <= range.Max; + public bool IsOutsideRange(int value) => value > range.Max || value < range.Min; + } +} \ No newline at end of file diff --git a/src/OpenRpg.Core/OpenRpg.Core.csproj b/src/OpenRpg.Core/OpenRpg.Core.csproj index 0e58b9ed..0eb2263e 100644 --- a/src/OpenRpg.Core/OpenRpg.Core.csproj +++ b/src/OpenRpg.Core/OpenRpg.Core.csproj @@ -2,14 +2,14 @@ 0.0.0 - netstandard2.1 + net10.0 OpenRpg.Core Grofit (LP) https://github.com/openrpg/OpenRpg/blob/master/LICENSE https://github.com/openrpg/OpenRpg An open source set of base components to build RPG games rpg game-development xna monogame unity godot - 8 + 14 diff --git a/src/OpenRpg.Core/Requirements/IHasRequirements.cs b/src/OpenRpg.Core/Requirements/IHasRequirements.cs deleted file mode 100644 index 3696d700..00000000 --- a/src/OpenRpg.Core/Requirements/IHasRequirements.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Collections.Generic; - -namespace OpenRpg.Core.Requirements -{ - /// - /// Implies the implementation has requirements before it can function/be used - /// - public interface IHasRequirements - { - /// - /// Requirements required for this object to function/be used - /// - IReadOnlyCollection Requirements { get; } - } -} \ No newline at end of file diff --git a/src/OpenRpg.Core/Variables/CollectionElementTypeAttribute.cs b/src/OpenRpg.Core/Variables/CollectionElementTypeAttribute.cs new file mode 100644 index 00000000..180f14fa --- /dev/null +++ b/src/OpenRpg.Core/Variables/CollectionElementTypeAttribute.cs @@ -0,0 +1,11 @@ +using System; + +namespace OpenRpg.Core.Variables +{ + [AttributeUsage(AttributeTargets.Field)] + public class CollectionElementTypeAttribute : Attribute + { + public Type ElementType { get; } + public CollectionElementTypeAttribute(Type elementType) => ElementType = elementType; + } +} diff --git a/src/OpenRpg.CurveFunctions/OpenRpg.CurveFunctions.csproj b/src/OpenRpg.CurveFunctions/OpenRpg.CurveFunctions.csproj index c623a572..dfb9464b 100644 --- a/src/OpenRpg.CurveFunctions/OpenRpg.CurveFunctions.csproj +++ b/src/OpenRpg.CurveFunctions/OpenRpg.CurveFunctions.csproj @@ -2,14 +2,14 @@ 0.0.0 - netstandard2.1 + net10.0 OpenRpg.CurveFunctions Grofit (LP) https://github.com/openrpg/OpenRpg/blob/master/LICENSE https://github.com/openrpg/OpenRpg Adds premade curve functions for scaling values or other use cases rpg game-development xna monogame unity godot - 8 + 14 diff --git a/src/OpenRpg.Data.Database/OpenRpg.Data.Database.csproj b/src/OpenRpg.Data.Database/OpenRpg.Data.Database.csproj index b1f32b01..dea68c9e 100644 --- a/src/OpenRpg.Data.Database/OpenRpg.Data.Database.csproj +++ b/src/OpenRpg.Data.Database/OpenRpg.Data.Database.csproj @@ -2,14 +2,14 @@ 0.0.0 - netstandard2.1 + net10.0 OpenRpg.Data.Database Grofit (LP) https://github.com/openrpg/OpenRpg/blob/master/LICENSE https://github.com/openrpg/OpenRpg An SQL database implementation of the OpenRpg data layer using Dapper rpg game-development repository data xna monogame unity godot - 8 + 14 @@ -18,7 +18,7 @@ - + diff --git a/src/OpenRpg.Data.InMemory/InMemoryDataSource.cs b/src/OpenRpg.Data.InMemory/InMemoryDataSource.cs index a12b6f96..31fad5ac 100644 --- a/src/OpenRpg.Data.InMemory/InMemoryDataSource.cs +++ b/src/OpenRpg.Data.InMemory/InMemoryDataSource.cs @@ -22,16 +22,26 @@ public InMemoryDataSource(Dictionary> database protected InMemoryDataSource() {} + private Dictionary GetOrCreateStore(Type type) + { + if (!Database.TryGetValue(type, out var dict)) + { + dict = new Dictionary(); + Database[type] = dict; + } + return dict; + } + public T Get(object id) => (T)Database[typeof(T)][id]; - public IEnumerable GetAll() => Database[typeof(T)].Values.Cast(); - public void Update(T data, object id) => Database[typeof(T)][id] = data; - public bool Delete(object id) => Database[typeof(T)].Remove(id); - public bool Exists(object id) => Database[typeof(T)].ContainsKey(id); + public IEnumerable GetAll() => GetOrCreateStore(typeof(T)).Values.Cast(); + public void Update(T data, object id) => GetOrCreateStore(typeof(T))[id] = data; + public bool Delete(object id) => Database.TryGetValue(typeof(T), out var dict) && dict.Remove(id); + public bool Exists(object id) => Database.TryGetValue(typeof(T), out var dict) && dict.ContainsKey(id); public void Create(T data, object id = null) { if(id == null) { throw new ArgumentNullException(nameof(id), "In Memory DB Requires explicit keys on creation"); } - Database[typeof(T)].Add(id, data); + GetOrCreateStore(typeof(T)).Add(id, data); } public void Dispose() diff --git a/src/OpenRpg.Data.InMemory/OpenRpg.Data.InMemory.csproj b/src/OpenRpg.Data.InMemory/OpenRpg.Data.InMemory.csproj index 2261e073..2a83a29d 100644 --- a/src/OpenRpg.Data.InMemory/OpenRpg.Data.InMemory.csproj +++ b/src/OpenRpg.Data.InMemory/OpenRpg.Data.InMemory.csproj @@ -2,14 +2,14 @@ 0.0.0 - netstandard2.1 + net10.0 OpenRpg.Data.InMemory Grofit (LP) https://github.com/openrpg/OpenRpg/blob/master/LICENSE https://github.com/openrpg/OpenRpg An in memory implementation of the OpenRpg data layer rpg game-development repository data xna monogame unity godot - 8 + 14 diff --git a/src/OpenRpg.Data.LiteDB/OpenRpg.Data.LiteDB.csproj b/src/OpenRpg.Data.LiteDB/OpenRpg.Data.LiteDB.csproj index 8f25d9be..6ca3fda3 100644 --- a/src/OpenRpg.Data.LiteDB/OpenRpg.Data.LiteDB.csproj +++ b/src/OpenRpg.Data.LiteDB/OpenRpg.Data.LiteDB.csproj @@ -2,14 +2,14 @@ 0.0.0 - netstandard2.1 + net10.0 OpenRpg.Data.LiteDB Grofit (LP) https://github.com/openrpg/OpenRpg/blob/master/LICENSE https://github.com/openrpg/OpenRpg A LiteDB implementation of the OpenRpg data layer rpg game-development repository data xna monogame unity godot - 8 + 14 diff --git a/src/OpenRpg.Data/OpenRpg.Data.csproj b/src/OpenRpg.Data/OpenRpg.Data.csproj index f049cf0e..a333174f 100644 --- a/src/OpenRpg.Data/OpenRpg.Data.csproj +++ b/src/OpenRpg.Data/OpenRpg.Data.csproj @@ -2,14 +2,14 @@ 0.0.0 - netstandard2.1 + net10.0 OpenRpg.Data Grofit (LP) https://github.com/openrpg/OpenRpg/blob/master/LICENSE https://github.com/openrpg/OpenRpg A set of data oriented patterns that can simplify managing your game data with OpenRpg rpg game-development repository data xna monogame unity godot - 8 + 14 diff --git a/src/OpenRpg.Demos.Battler/.config/dotnet-tools.json b/src/OpenRpg.Demos.Battler/.config/dotnet-tools.json new file mode 100644 index 00000000..a07174a7 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/.config/dotnet-tools.json @@ -0,0 +1,36 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-mgcb": { + "version": "3.8.4.1", + "commands": [ + "mgcb" + ] + }, + "dotnet-mgcb-editor": { + "version": "3.8.4.1", + "commands": [ + "mgcb-editor" + ] + }, + "dotnet-mgcb-editor-linux": { + "version": "3.8.4.1", + "commands": [ + "mgcb-editor-linux" + ] + }, + "dotnet-mgcb-editor-windows": { + "version": "3.8.4.1", + "commands": [ + "mgcb-editor-windows" + ] + }, + "dotnet-mgcb-editor-mac": { + "version": "3.8.4.1", + "commands": [ + "mgcb-editor-mac" + ] + } + } +} \ No newline at end of file diff --git a/src/OpenRpg.Demos.Battler/.vscode/launch.json b/src/OpenRpg.Demos.Battler/.vscode/launch.json new file mode 100644 index 00000000..bf68b6e1 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "C#: OpenRpg.Demos.Battler Debug", + "type": "dotnet", + "request": "launch", + "projectPath": "${workspaceFolder}/OpenRpg.Demos.Battler.csproj" + } + ], + } \ No newline at end of file diff --git a/src/OpenRpg.Demos.Battler/Code/BattlerGame.cs b/src/OpenRpg.Demos.Battler/Code/BattlerGame.cs new file mode 100644 index 00000000..c6979a99 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/BattlerGame.cs @@ -0,0 +1,142 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Gum.Wireframe; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameGum; +using OpenRpg.Data; +using OpenRpg.Demos.Battler.Code.Scenes; +using OpenRpg.Demos.Battler.Code.Scenes.CharacterMenu; +using OpenRpg.Demos.Battler.Code.Scenes.PartyCreate; +using OpenRpg.Demos.Battler.Code.Services.Game; +using OpenRpg.Entities.Classes.Templates; +using OpenRpg.Entities.Entity.Templates; +using OpenRpg.Entities.Races.Templates; +using OpenRpg.Items.Templates; +using OpenRpg.Localization.Data.DataSources; +using OpenRpg.Projects.Loaders; + +namespace OpenRpg.Demos.Battler.Code; + +public class BattlerGame : Game +{ + private readonly IServiceProvider _serviceProvider; + private readonly ISceneManager _sceneManager; + private GraphicsDeviceManager _graphics; + private SpriteBatch _spriteBatch; + private GameServices _gameServices; + private volatile bool _projectLoaded; + + public BattlerGame(IServiceProvider serviceProvider) + { + _graphics = new GraphicsDeviceManager(this); + _graphics.PreferredBackBufferWidth = 800; + _graphics.PreferredBackBufferHeight = 600; + _graphics.ApplyChanges(); + _serviceProvider = serviceProvider; + _gameServices = serviceProvider.GetRequiredService() as GameServices; + _sceneManager = serviceProvider.GetRequiredService(); + _sceneManager.RequestExit = Exit; + + Content.RootDirectory = "Content"; + _gameServices.GetContentManager = Content; + IsMouseVisible = true; + } + + protected override void Initialize() + { + base.Initialize(); + + _gameServices.GetSpriteBatch = _spriteBatch; + _gameServices.GetGraphicsDeviceManager = _graphics; + + Task.Run(LoadProjectData); + } + + protected override void LoadContent() + { + _spriteBatch = new SpriteBatch(GraphicsDevice); + _gameServices.GetSpriteBatch = _spriteBatch; + + GumService.Default.Initialize(this); + GraphicalUiElement.CanvasWidth = 800; + GraphicalUiElement.CanvasHeight = 600; + } + + protected override void Update(GameTime gameTime) + { + if (_projectLoaded && _sceneManager.ActiveScene == null) + { + var partyCreateScene = _serviceProvider.GetRequiredService(); + _ = _sceneManager.SetScene(partyCreateScene); + } + + _sceneManager.Update(gameTime); + GumService.Default.Update(gameTime); + base.Update(gameTime); + } + + protected override void Draw(GameTime gameTime) + { + GraphicsDevice.Clear(Color.CornflowerBlue); + + if (_projectLoaded) + _sceneManager.Draw(gameTime, _spriteBatch); + + RenderingLibrary.SystemManagers.Default.Draw(); + + if (_projectLoaded) + { + _spriteBatch.Begin(samplerState: SamplerState.PointClamp); + _sceneManager.DrawUI(gameTime, _spriteBatch); + _spriteBatch.End(); + } + + base.Draw(gameTime); + } + + private async Task LoadProjectData() + { + try + { + var dataLoader = _serviceProvider.GetRequiredService(); + var projectPath = Path.Combine(AppContext.BaseDirectory, @"Content/Project/project.json"); + var projectContext = await dataLoader.Load(projectPath); + var project = projectContext.Project; + var dataSource = _serviceProvider.GetRequiredService(); + + var items = dataSource.GetAll().Count(); + var monsters = dataSource.GetAll().Count(); + var races = dataSource.GetAll().Count(); + var classes = dataSource.GetAll().Count(); + var localeDataSource = _serviceProvider.GetRequiredService(); + var locales = string.Join(", ", localeDataSource.GetLocaleCodes()); + + Console.WriteLine(""); + Console.WriteLine("=== OpenRpg Battler Demo ==="); + Console.WriteLine($"Project loaded: v{project.Version} [{project.Type}]"); + Console.WriteLine($"Items: {items}"); + Console.WriteLine($"Monsters: {monsters}"); + Console.WriteLine($"Races: {races}"); + Console.WriteLine($"Classes: {classes}"); + Console.WriteLine($"Locales: {locales}"); + Console.WriteLine(""); + Console.WriteLine("OpenRpg services initialized successfully."); + Console.WriteLine(""); + } + catch (Exception ex) + { + Console.WriteLine(""); + Console.WriteLine("=== FAILED TO LOAD PROJECT ==="); + Console.WriteLine($"Error: {ex.Message}"); + Console.WriteLine($"Stack: {ex.StackTrace}"); + Console.WriteLine(""); + } + + _projectLoaded = true; + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Builders/GameCharacterBuilder.cs b/src/OpenRpg.Demos.Battler/Code/Builders/GameCharacterBuilder.cs new file mode 100644 index 00000000..1b697cea --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Builders/GameCharacterBuilder.cs @@ -0,0 +1,31 @@ +using OpenRpg.Core.Utils; +using OpenRpg.Data; +using OpenRpg.Entities.Classes.Templates; +using OpenRpg.Entities.Extensions; +using OpenRpg.Genres.Characters; +using OpenRpg.Genres.Fantasy.Builders; +using OpenRpg.Genres.Populators.Entity; + +namespace OpenRpg.Demos.Battler.Code.Builders; + +public class GameCharacterBuilder : FantasyCharacterBuilder +{ + private readonly IDataSource _dataSource; + + public GameCharacterBuilder(IRandomizer randomizer, ICharacterPopulator characterPopulator, IDataSource dataSource) + : base(randomizer, characterPopulator) + { + _dataSource = dataSource; + } + + protected override void PostProcessCharacter(Character character) + { + if (character.Variables.HasClass()) + { + var template = _dataSource.Get(character.Variables.Class.TemplateId); + if (template?.Variables.HasAssetCode() == true) + character.Variables.AssetCode = template.Variables.AssetCode; + } + base.PostProcessCharacter(character); + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Extensions/IServiceCollectionExtensions.cs b/src/OpenRpg.Demos.Battler/Code/Extensions/IServiceCollectionExtensions.cs new file mode 100644 index 00000000..20618bdb --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Extensions/IServiceCollectionExtensions.cs @@ -0,0 +1,111 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using OpenRpg.Combat.Abilities; +using OpenRpg.Combat.Attacks; +using OpenRpg.Combat.Processors.Attacks.Entity; +using OpenRpg.Combat.Types; +using OpenRpg.Core.Effects; +using OpenRpg.Core.Requirements; +using OpenRpg.Entities.Types; +using OpenRpg.Items.Types; +using OpenRpg.Projects.Json.Convertors; +using OpenRpg.Core.Templates; +using OpenRpg.Core.Utils; +using OpenRpg.Data; +using OpenRpg.Data.InMemory; +using OpenRpg.Demos.Battler.Code.Scenes; +using OpenRpg.Demos.Battler.Code.Scenes.Battle; +using OpenRpg.Demos.Battler.Code.Scenes.CharacterMenu; +using OpenRpg.Demos.Battler.Code.Scenes.PartyCreate; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Providers; +using OpenRpg.Demos.Battler.Code.Services; +using OpenRpg.Demos.Battler.Code.Services.Game; +using OpenRpg.Items.Loot; +using OpenRpg.Entities.Entity.Populators.State; +using OpenRpg.Entities.Entity.Populators.Stats; +using OpenRpg.Genres.Effects; +using OpenRpg.Genres.Fantasy.Combat; +using OpenRpg.Genres.Fantasy.Equippables.Validators; +using OpenRpg.Genres.Fantasy.State.Populators; +using OpenRpg.Genres.Fantasy.Stats.Populators; +using OpenRpg.Genres.Populators.Entity; +using OpenRpg.Genres.Requirements; +using OpenRpg.Items.Equippables.Slots; +using OpenRpg.Localization.Data.DataSources; +using OpenRpg.Projects.Json.Loaders; +using OpenRpg.Projects.Json.Loaders.Locales; +using OpenRpg.Projects.Json.Loaders.Templates; +using OpenRpg.Projects.Loaders; +using OpenRpg.Projects.Loaders.Locales; +using OpenRpg.Projects.Loaders.Templates; +using OpenRpg.Projects.Loaders.Projects; +using OpenRpg.Projects.Services; + +namespace OpenRpg.Demos.Battler.Code.Extensions; + +public static class IServiceCollectionExtensions +{ + public static IServiceCollection WithOpenRpgFramework(this IServiceCollection services) + { + services.AddSingleton(x => new DefaultRandomizer(new Random())); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } + + public static IServiceCollection WithOpenRpgProject(this IServiceCollection services) + { + VariablesConverter.RegisterKey(CoreTemplateVariableTypes.Effects, typeof(IEffect)); + VariablesConverter.RegisterKey(CoreTemplateVariableTypes.Requirements, typeof(Requirement)); + VariablesConverter.RegisterKey(CombatAbilityTemplateVariableTypes.Damage, typeof(Damage)); + VariablesConverter.RegisterKey(CombatTemplateVariableTypes.Abilities, typeof(AbilityData)); + VariablesConverter.RegisterKey(LootTableEntryVariableTypes.Requirements, typeof(Requirement)); + VariablesConverter.RegisterKey(ItemEntityTemplateVariableTypes.LootTable, typeof(LootTableData)); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } + + public static IServiceCollection WithMonoGameServices(this IServiceCollection services) + { + services.AddSingleton(); + + return services; + } + + public static IServiceCollection WithSceneServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + return services; + } +} \ No newline at end of file diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/BattleScene.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/BattleScene.cs new file mode 100644 index 00000000..341ad4b2 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/BattleScene.cs @@ -0,0 +1,384 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using OpenRpg.Combat.Processors.Attacks.Entity; +using OpenRpg.Data; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Combat; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Providers; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Rendering; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.UI; +using OpenRpg.Demos.Battler.Code.Services; +using OpenRpg.Demos.Battler.Code.Services.Game; +using OpenRpg.Genres.Requirements; +using OpenRpg.Items.Templates; +using OpenRpg.Localization.Data.DataSources; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle; + +public class BattleScene : IScene +{ + private readonly IPersistentGameState _gameState; + private readonly IEnemyFormationProvider _enemyFormationProvider; + private readonly IGameServices _gameServices; + private readonly ISceneManager _sceneManager; + private readonly IServiceProvider _serviceProvider; + private readonly IDataSource _dataSource; + private readonly ICharacterRequirementChecker _requirementChecker; + private readonly ILocaleDataSource _localeDataSource; + private readonly IEntityAttackGenerator _attackGenerator; + private readonly IEntityAttackProcessor _attackProcessor; + private readonly ILootService _lootService; + private readonly TurnManager _turnManager; + private readonly SpriteCache _spriteCache = new(); + private readonly EntityRenderer _entityRenderer = new(); + private readonly List _floatingNumbers = []; + + private BattleBottomPanelUi _bottomPanel; + private TurnOrderUi _turnOrderUi; + private CombatLogUi _combatLogUi; + private CommandMenuUi _commandMenu; + private KeyboardState _previousKeyboard; + private double _totalTime; + private SpriteFont _font; + private bool _loaded; + + // State for post-battle results + private List _lootItems = []; + private string _victoryMessage = ""; + + public List Party { get; private set; } = []; + public List Enemies { get; private set; } = []; + + public BattleScene( + IPersistentGameState gameState, + IEnemyFormationProvider enemyFormationProvider, + IGameServices gameServices, + ISceneManager sceneManager, + IServiceProvider serviceProvider, + IDataSource dataSource, + ICharacterRequirementChecker requirementChecker, + ILocaleDataSource localeDataSource, + IEntityAttackGenerator attackGenerator, + IEntityAttackProcessor attackProcessor, + ILootService lootService) + { + _gameState = gameState; + _enemyFormationProvider = enemyFormationProvider; + _gameServices = gameServices; + _sceneManager = sceneManager; + _serviceProvider = serviceProvider; + _dataSource = dataSource; + _requirementChecker = requirementChecker; + _localeDataSource = localeDataSource; + _attackGenerator = attackGenerator; + _attackProcessor = attackProcessor; + _lootService = lootService; + _turnManager = new TurnManager(_dataSource, _requirementChecker, _localeDataSource, _attackGenerator, _attackProcessor); + } + + public async Task LoadAsync() + { + try + { + _loaded = false; + _floatingNumbers.Clear(); + + Party = _gameState.Party; + Enemies = _enemyFormationProvider.GenerateFormation(); + LayoutEntities(); + + foreach (var e in Party.Concat(Enemies)) + e.OriginPosition = e.Position; + + var content = _gameServices.GetContentManager; + _spriteCache.LoadSprites(Party.Concat(Enemies), content); + _turnManager.OnDamageDealt += OnDamageDealt; + _turnManager.Start(Party, Enemies); + + _font = content.Load("Fonts/KenneyPixel"); + _turnOrderUi = new TurnOrderUi(_font); + _bottomPanel = new BattleBottomPanelUi(); + _bottomPanel.Update(Party, Enemies); + _turnOrderUi.Update(_turnManager.TurnOrder, _turnManager.CurrentTurnIndex); + _combatLogUi = new CombatLogUi(); + _combatLogUi.Update(""); + + _commandMenu = new CommandMenuUi(); + _commandMenu.OnActionConfirmed += OnPlayerActionConfirmed; + + _loaded = true; + } + catch (Exception ex) + { + Console.WriteLine($"[BattleScene] FAILED TO LOAD: {ex.Message}"); + Console.WriteLine(ex.StackTrace); + } + } + + public void Unload() + { + _turnManager.OnDamageDealt -= OnDamageDealt; + _commandMenu?.Hide(); + _turnOrderUi.Unload(); + _bottomPanel.Unload(); + _combatLogUi.Unload(); + _entityRenderer.Dispose(); + _font = null; + } + + private void OnDamageDealt(BattleEntity target, int damage, bool isCrit) + { + var slotW = 80; + var cx = target.Position.X + slotW / 2; + var cy = target.Position.Y - 10; + _floatingNumbers.Add(new FloatingDamageNumber( + new Vector2(cx, cy), damage, isCrit, isHealing: damage < 0)); + } + + public void Update(GameTime gameTime) + { + if (!_loaded) return; + _totalTime += gameTime.ElapsedGameTime.TotalSeconds; + + if (_turnManager.CurrentPhase == TurnManager.Phase.GameOver) + { + // On first frame of game over, collect loot if player won + if (_lootItems.Count == 0 && _turnManager.WinningTeam == Team.Player) + { + var deadEnemies = Enemies.Where(e => !e.IsAlive).ToList(); + _lootItems = _lootService.GenerateLoot(deadEnemies); + _gameState.SharedInventory.AddRange(_lootItems); + _victoryMessage = BuildLootMessage(); + } + + var kstate = Keyboard.GetState(); + if (InputHelper.IsKeyJustPressed(kstate, _previousKeyboard, Keys.Space) || + InputHelper.IsKeyJustPressed(kstate, _previousKeyboard, Keys.Enter)) + { + if (_turnManager.WinningTeam == Team.Enemy) + { + // On loss, return to party creation for a fresh start + _gameState.ResetParty(); + var partyCreateScene = _serviceProvider.GetRequiredService(); + _ = _sceneManager.SetScene(partyCreateScene); + } + else + { + // On victory, return to character menu with preserved party and loot + var menuScene = _serviceProvider.GetRequiredService(); + _ = _sceneManager.SetScene(menuScene); + } + } + _previousKeyboard = kstate; + return; + } + + AnimateEntities(); + _turnManager.Update(gameTime.ElapsedGameTime.TotalSeconds); + + var dt = (float)gameTime.ElapsedGameTime.TotalSeconds; + for (var i = _floatingNumbers.Count - 1; i >= 0; i--) + { + _floatingNumbers[i].Update(dt); + if (_floatingNumbers[i].IsExpired) + _floatingNumbers.RemoveAt(i); + } + + if (_turnManager.IsInPlayerInput) + { + var currentKeyboard = Keyboard.GetState(); + + if (_commandMenu.IsHidden) + { + var aliveEnemies = Enemies.Where(e => e.IsAlive).ToList(); + var aliveParty = Party.Where(e => e.IsAlive).ToList(); + var abilities = _turnManager.GetAvailableAbilities(_turnManager.CurrentAttacker); + _commandMenu.Show(new CommandMenuContext + { + Attacker = _turnManager.CurrentAttacker, + AliveEnemies = aliveEnemies, + Abilities = abilities, + LocaleDataSource = _localeDataSource, + InventoryItems = _gameState.SharedInventory, + AliveParty = aliveParty, + DataSource = _dataSource, + AllParty = Party + }); + } + + _commandMenu.HandleInput(currentKeyboard, _previousKeyboard); + _previousKeyboard = currentKeyboard; + + _turnManager.HighlightedTargets = _commandMenu.PreviewTargets; + } + else if (!_commandMenu.IsHidden) + { + _commandMenu.Hide(); + _turnManager.HighlightedTargets = []; + } + + _bottomPanel.Update(Party, Enemies); + _turnOrderUi.Update(_turnManager.TurnOrder, _turnManager.CurrentTurnIndex, GetPulseBrightness()); + _combatLogUi.Update(_turnManager.LastActionMessage); + } + + private void OnPlayerActionConfirmed(PlayerAction action) + { + // Deduct used items from shared inventory immediately + if (action.Type == ActionType.UseItem && action.UsedItem != null) + { + var index = _gameState.SharedInventory.FindIndex(i => i.TemplateId == action.UsedItem.TemplateId); + if (index >= 0) + _gameState.SharedInventory.RemoveAt(index); + } + + _turnManager.SubmitPlayerAction(action); + } + + public void Draw(GameTime gameTime, SpriteBatch spriteBatch) + { + if (!_loaded) return; + _entityRenderer.EnsureTextures(spriteBatch.GraphicsDevice); + spriteBatch.Begin(samplerState: SamplerState.PointClamp); + + _entityRenderer.DrawBackground(spriteBatch); + _entityRenderer.DrawEntities(spriteBatch, Enemies, _turnManager, _totalTime); + _entityRenderer.DrawEntities(spriteBatch, Party, _turnManager, _totalTime); + + spriteBatch.End(); + } + + public void DrawUI(GameTime gameTime, SpriteBatch spriteBatch) + { + if (!_loaded) return; + + foreach (var fn in _floatingNumbers) + { + var text = fn.IsCrit ? $"CRIT! {fn.Damage}" : (fn.IsHealing ? $"+{fn.Damage}" : fn.Damage.ToString()); + var size = _font.MeasureString(text); + var pos = new Vector2(fn.Position.X - size.X / 2, fn.Position.Y); + var color = fn.Color * fn.Opacity; + TextHelper.DrawStringWithSpacing(spriteBatch, _font, text, pos, color); + } + + _combatLogUi.Draw(spriteBatch, _font); + _turnOrderUi.Draw(spriteBatch); + _bottomPanel.Draw(spriteBatch, _font); + + _commandMenu.Draw(spriteBatch, _font); + + if (_turnManager.CurrentPhase == TurnManager.Phase.GameOver) + DrawResultsOverlay(spriteBatch); + } + + private float GetPulseBrightness() + { + if (_turnManager.CurrentPhase != TurnManager.Phase.TurnDwell && + _turnManager.CurrentPhase != TurnManager.Phase.PlayerInput) return 0; + return (float)(0.5 + Math.Sin(_totalTime * 10) * 0.5); + } + + private void AnimateEntities() + { + for (var i = 0; i < Enemies.Count; i++) + { + var e = Enemies[i]; + if (!e.IsAlive) continue; + var phase = i * 1.3; + var offX = Math.Sin(_totalTime * 1.2 + phase) * 2.0; + var offY = Math.Cos(_totalTime * 0.9 + phase) * 1.5; + e.Position = e.OriginPosition + new Vector2((float)offX, (float)offY); + } + + for (var i = 0; i < Party.Count; i++) + { + var e = Party[i]; + if (!e.IsAlive) continue; + var phase = i * 1.7 + 3.0; + var offX = Math.Sin(_totalTime * 1.0 + phase) * 2.0; + var offY = Math.Cos(_totalTime * 1.1 + phase) * 1.5; + e.Position = e.OriginPosition + new Vector2((float)offX, (float)offY); + } + } + + private string BuildLootMessage() + { + if (_lootItems.Count == 0) + return "No items were dropped."; + + var itemNames = new List(); + foreach (var item in _lootItems) + { + var template = _dataSource.Get(item.TemplateId); + var name = template != null + ? _localeDataSource.Get("en-gb", template.NameLocaleId) + : $"Item #{item.TemplateId}"; + itemNames.Add(name); + } + + return $"Loot collected: {string.Join(", ", itemNames)}"; + } + + private void DrawResultsOverlay(SpriteBatch sb) + { + var isVictory = _turnManager.WinningTeam == Team.Player; + + // Full-screen dark overlay + _entityRenderer.DrawOverlay(sb); + + // Title + var title = isVictory ? "VICTORY!" : "DEFEATED"; + var titleColor = isVictory ? Color.LightGreen : Color.OrangeRed; + + TextHelper.DrawStringWithSpacing(sb, _font, title, + new Vector2(400, 140), titleColor, centered: true); + + if (isVictory && _lootItems.Count > 0) + { + // Subtitle + TextHelper.DrawStringWithSpacing(sb, _font, "LOOT COLLECTED:", + new Vector2(400, 190), new Color(200, 200, 150), centered: true); + + var lootY = 220; + foreach (var item in _lootItems) + { + var template = _dataSource.Get(item.TemplateId); + var name = template != null + ? _localeDataSource.Get("en-gb", template.NameLocaleId) + : $"Item #{item.TemplateId}"; + TextHelper.DrawStringWithSpacing(sb, _font, $"- {name}", + new Vector2(400, lootY), Color.White, centered: true); + lootY += 22; + } + } + else if (isVictory) + { + TextHelper.DrawStringWithSpacing(sb, _font, "No items were dropped.", + new Vector2(400, 200), new Color(140, 140, 150), centered: true); + } + else + { + TextHelper.DrawStringWithSpacing(sb, _font, "Your party has fallen...", + new Vector2(400, 200), new Color(180, 120, 120), centered: true); + } + + // Continue prompt + TextHelper.DrawStringWithSpacing(sb, _font, "Press Enter to continue", + new Vector2(400, 480), new Color(180, 180, 200), centered: true); + } + + private void LayoutEntities() + { + foreach (var entity in Enemies) + entity.Position = new Vector2(30 + entity.SlotInRow * 90, 30 + entity.Row * 100); + + foreach (var entity in Party) + entity.Position = new Vector2(600 + entity.SlotInRow * 100, 30 + entity.Row * 100); + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/AbilityExecutor.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/AbilityExecutor.cs new file mode 100644 index 00000000..b1f721f1 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/AbilityExecutor.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenRpg.Combat.Abilities; +using OpenRpg.Combat.Attacks; +using OpenRpg.Combat.Extensions; +using OpenRpg.Combat.Processors.Attacks.Entity; +using OpenRpg.Combat.Types; +using OpenRpg.Core.Extensions; +using OpenRpg.Core.Requirements; +using OpenRpg.Data; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Genres.Extensions; +using OpenRpg.Genres.Fantasy.Extensions; +using OpenRpg.Genres.Fantasy.Types; +using OpenRpg.Genres.Types; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.UI; +using OpenRpg.Entities.Types; +using OpenRpg.Genres.Requirements; +using OpenRpg.Localization.Data.DataSources; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.Combat; + +public class AbilityExecutor +{ + private readonly IDataSource _dataSource; + private readonly ICharacterRequirementChecker _requirementChecker; + private readonly ILocaleDataSource _localeDataSource; + private readonly IEntityAttackGenerator _attackGenerator; + private readonly IEntityAttackProcessor _attackProcessor; + + private List _party; + private List _enemies; + + public record AbilityResult( + IReadOnlyList Targets, + string Message, + IReadOnlyList<(BattleEntity Target, int Amount, bool IsCrit)> DamageEvents); + + public AbilityExecutor( + IDataSource dataSource, + ICharacterRequirementChecker requirementChecker, + ILocaleDataSource localeDataSource, + IEntityAttackGenerator attackGenerator, + IEntityAttackProcessor attackProcessor) + { + _dataSource = dataSource; + _requirementChecker = requirementChecker; + _localeDataSource = localeDataSource; + _attackGenerator = attackGenerator; + _attackProcessor = attackProcessor; + } + + public void Start(List party, List enemies) + { + _party = party; + _enemies = enemies; + } + + public List<(AbilityTemplate Template, int ManaCost, bool CanAfford)> GetAvailableAbilities(BattleEntity entity) + { + var result = new List<(AbilityTemplate, int, bool)>(); + foreach (var (template, manaCost) in GetValidAbilities(entity)) + { + var canAfford = entity.Mana >= manaCost; + result.Add((template, manaCost, canAfford)); + } + return result; + } + + public List<(AbilityTemplate Template, int ManaCost)> GetValidAbilities(BattleEntity entity) + { + if (!entity.Entity.Variables.ContainsKey(CombatTemplateVariableTypes.Abilities)) + return []; + + var abilities = entity.Entity.Variables.GetAs>(CombatTemplateVariableTypes.Abilities); + if (abilities == null || abilities.Count == 0) + return []; + + var valid = new List<(AbilityTemplate, int)>(); + + foreach (var abilityData in abilities) + { + var template = _dataSource.Get(abilityData.TemplateId); + if (template == null) continue; + + var requirements = template.Variables.GetAsOrDefault>( + CoreTemplateVariableTypes.Requirements, () => Array.Empty()); + if (!_requirementChecker.AreRequirementsMet(entity.Entity, requirements)) + continue; + + var manaCost = template.Variables.GetIntOrDefault(FantasyAbilityTemplateVariableTypes.ManaCost, 0); + valid.Add((template, manaCost)); + } + + return valid; + } + + public bool TryPickAndExecuteAbility(BattleEntity entity, out AbilityResult result) + { + result = null; + var affordable = GetValidAbilities(entity) + .Where(x => entity.Mana >= x.ManaCost) + .ToList(); + + if (affordable.Count == 0) return false; + + var rng = new Random(); + var pick = affordable[rng.Next(affordable.Count)]; + result = ExecuteAbility(entity, pick.Template); + return true; + } + + public AbilityResult ExecuteAbility(BattleEntity attacker, AbilityTemplate template, List specificTargets = null) + { + var baseDamage = template.Variables.GetAsOrDefault(CombatAbilityTemplateVariableTypes.Damage, () => new Damage(0, 0)); + var isHealing = TargetResolver.IsHealing(baseDamage); + var manaCost = template.Variables.GetIntOrDefault(FantasyAbilityTemplateVariableTypes.ManaCost, 0); + + attacker.Entity.State.DeductMana(manaCost, attacker.MaxMana); + + var selected = TargetResolver.ResolveTargets(template, attacker, specificTargets, _party, _enemies); + if (selected.Count == 0) + return new AbilityResult([], "", []); + + var attackerName = NameHelper.NormalizeName(attacker.Name); + var abilityName = _localeDataSource.Get("en-gb", template.NameLocaleId); + var damageEvents = new List<(BattleEntity, int, bool)>(); + var totalValue = 0; + + if (isHealing) + { + foreach (var target in selected) + { + var healValue = (int)Math.Max(1, baseDamage.Value); + var newHp = Math.Min(target.Hp + healValue, target.MaxHp); + var actualHeal = newHp - target.Hp; + target.Hp = newHp; + damageEvents.Add((target, -actualHeal, false)); + totalValue += actualHeal; + } + + var healMsg = CombatLogBuilder.BuildAbilityMessage(attackerName, abilityName, selected, totalValue, false, true); + return new AbilityResult(selected, healMsg, damageEvents); + } + else + { + var damageCopy = new Damage(baseDamage.Type, baseDamage.Value); + var attack = _attackGenerator.GenerateAttack(damageCopy, attacker.Entity.Stats); + + foreach (var target in selected) + { + var processed = _attackProcessor.ProcessAttack(attack, target.Entity.Stats); + var dmg = (int)Math.Max(1, processed.DamageDone.Sum(d => d.Value)); + target.Entity.State.DeductHealth(dmg); + damageEvents.Add((target, dmg, attack.IsCritical)); + totalValue += dmg; + } + + var msg = CombatLogBuilder.BuildAbilityMessage(attackerName, abilityName, selected, totalValue, attack.IsCritical, false); + return new AbilityResult(selected, msg, damageEvents); + } + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/BasicAttackExecutor.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/BasicAttackExecutor.cs new file mode 100644 index 00000000..fea65d4f --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/BasicAttackExecutor.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenRpg.Combat.Processors.Attacks; +using OpenRpg.Combat.Processors.Attacks.Entity; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Genres.Extensions; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.Combat; + +/// +/// Handles basic attack execution. Parallel to AbilityExecutor for consistency. +/// +public class BasicAttackExecutor +{ + private static readonly Random _rng = new(); + private readonly IEntityAttackGenerator _attackGenerator; + private readonly IEntityAttackProcessor _attackProcessor; + + public record AttackResult( + BattleEntity Target, + int Damage, + bool IsCrit, + string Message); + + public BasicAttackExecutor(IEntityAttackGenerator attackGenerator, IEntityAttackProcessor attackProcessor) + { + _attackGenerator = attackGenerator; + _attackProcessor = attackProcessor; + } + + public AttackResult ExecuteBasicAttack( + BattleEntity attacker, + BattleEntity specificTarget, + List enemyPool) + { + var aliveTargets = enemyPool.Where(e => e.IsAlive).ToList(); + + BattleEntity target; + if (specificTarget != null && specificTarget.IsAlive) + target = specificTarget; + else + target = aliveTargets[_rng.Next(aliveTargets.Count)]; + + var attack = _attackGenerator.GenerateAttack(attacker.Entity.Stats); + var processed = _attackProcessor.ProcessAttack(attack, target.Entity.Stats); + var totalDamage = (int)Math.Max(1, processed.DamageDone.Sum(d => d.Value)); + + target.Entity.State.DeductHealth(totalDamage); + + var attackerName = UI.NameHelper.NormalizeName(attacker.Name); + var targetName = UI.NameHelper.NormalizeName(target.Name); + var message = CombatLogBuilder.BuildAttackMessage(attackerName, targetName, totalDamage, attack.IsCritical); + + return new AttackResult(target, totalDamage, attack.IsCritical, message); + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/BattleResultsHandler.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/BattleResultsHandler.cs new file mode 100644 index 00000000..478e8e9f --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/BattleResultsHandler.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Linq; +using OpenRpg.Data; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Demos.Battler.Code.Services; +using OpenRpg.Demos.Battler.Code.Services.Game; +using OpenRpg.Items.Templates; +using OpenRpg.Localization.Data.DataSources; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.Combat; + +/// +/// Handles post-battle results: loot generation, victory/defeat routing, and results message building. +/// Extracted from BattleScene for separation of concerns. +/// +public class BattleResultsHandler +{ + private readonly ILootService _lootService; + private readonly IPersistentGameState _gameState; + private readonly IDataSource _dataSource; + private readonly ILocaleDataSource _localeDataSource; + + public List LootItems { get; private set; } = []; + public string VictoryMessage { get; private set; } = ""; + + public BattleResultsHandler( + ILootService lootService, + IPersistentGameState gameState, + IDataSource dataSource, + ILocaleDataSource localeDataSource) + { + _lootService = lootService; + _gameState = gameState; + _dataSource = dataSource; + _localeDataSource = localeDataSource; + } + + public void CollectLoot(List deadEnemies) + { + LootItems = _lootService.GenerateLoot(deadEnemies); + _gameState.SharedInventory.AddRange(LootItems); + VictoryMessage = BuildLootMessage(); + } + + public void Reset() + { + LootItems = []; + VictoryMessage = ""; + } + + private string BuildLootMessage() + { + if (LootItems.Count == 0) + return "No items were dropped."; + + var itemNames = new List(); + foreach (var item in LootItems) + { + var template = _dataSource.Get(item.TemplateId); + var name = template != null + ? _localeDataSource.Get("en-gb", template.NameLocaleId) + : $"Item #{item.TemplateId}"; + itemNames.Add(name); + } + + return $"Loot collected: {string.Join(", ", itemNames)}"; + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/CombatLogBuilder.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/CombatLogBuilder.cs new file mode 100644 index 00000000..56ba0f18 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/CombatLogBuilder.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Linq; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.UI; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.Combat; + +public static class CombatLogBuilder +{ + public static string BuildAbilityMessage(string attackerName, string abilityName, List targets, int totalValue, bool isCrit, bool isHealing) + { + var targetNames = targets.Select(t => NameHelper.NormalizeName(t.Name)).ToList(); + var avg = targets.Count > 0 ? totalValue / targets.Count : 0; + var targetList = targets.Count == 1 + ? targetNames[0] + : string.Join(", ", targetNames); + + if (isHealing) + { + return targets.Count == 1 + ? $"{attackerName} uses {abilityName} on {targetList}, healing {avg} HP!" + : $"{attackerName} uses {abilityName} on {targetList}, healing {avg} HP each!"; + } + + var critSuffix = isCrit ? " (CRIT!)" : ""; + return targets.Count == 1 + ? $"{attackerName} uses {abilityName} on {targetList} for {avg} damage{critSuffix}" + : $"{attackerName} uses {abilityName} on {targetList} for {avg} damage each{critSuffix}"; + } + + public static string BuildAttackMessage(string attackerName, string targetName, int damage, bool isCrit) + { + var critSuffix = isCrit ? " (CRIT!)" : ""; + return $"{attackerName} attacks {targetName} for {damage} damage{critSuffix}"; + } + + public static string BuildItemMessage(string attackerName, string itemName, string targetName, int healAmount, int manaAmount, int reviveAmount) + { + if (healAmount > 0) + return $"{attackerName} uses {itemName} on {targetName}, healing {healAmount} HP!"; + if (manaAmount > 0) + return $"{attackerName} uses {itemName} on {targetName}, restoring {manaAmount} MP!"; + if (reviveAmount > 0) + return $"{attackerName} uses {itemName} on {targetName}, reviving with {reviveAmount} HP!"; + return $"{attackerName} uses {itemName} on {targetName}."; + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/ItemEffectApplier.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/ItemEffectApplier.cs new file mode 100644 index 00000000..0ac32b18 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/ItemEffectApplier.cs @@ -0,0 +1,79 @@ +using System; +using OpenRpg.Core.Effects; +using OpenRpg.Core.Extensions; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Entities.Extensions; +using OpenRpg.Genres.Fantasy.Extensions; +using OpenRpg.Genres.Fantasy.Types; +using OpenRpg.Genres.Extensions; +using OpenRpg.Genres.Types; +using OpenRpg.Items.Templates; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.Combat; + +public class ItemEffectApplier +{ + public record ApplyResult(int HealAmount, int ManaAmount, int ReviveAmount); + + public static bool HasLifeRestoreEffect(ItemTemplate template) + { + if (template.Variables.Effects == null) return false; + foreach (var effect in template.Variables.Effects) + { + if (effect is StaticEffect se && + (se.EffectType == GenreEffectTypes.LifeRestoreAmount || + se.EffectType == GenreEffectTypes.LifeRestorePercentage)) + return true; + } + return false; + } + + public ApplyResult ApplyItemEffects(ItemData itemData, BattleEntity target, ItemTemplate template) + { + var healAmount = 0; + var manaAmount = 0; + var reviveAmount = 0; + + if (template.Variables.Effects == null) return new ApplyResult(0, 0, 0); + + foreach (var effect in template.Variables.Effects) + { + if (effect is not StaticEffect se) continue; + + if (se.EffectType == GenreEffectTypes.HealthRestoreAmount) + { + healAmount = (int)se.Potency; + var newHp = Math.Min(target.Hp + healAmount, target.MaxHp); + var actualHeal = newHp - target.Hp; + target.Hp = newHp; + } + else if (se.EffectType == GenreEffectTypes.HealthRestorePercentage) + { + healAmount = (int)(target.MaxHp * se.Potency); + var newHp = Math.Min(target.Hp + healAmount, target.MaxHp); + var actualHeal = newHp - target.Hp; + target.Hp = newHp; + } + else if (se.EffectType == FantasyEffectTypes.ManaRestoreAmount) + { + manaAmount = (int)se.Potency; + var newMp = (int)Math.Min(target.Mana + manaAmount, target.MaxMana); + target.Entity.State.Mana = newMp; + } + else if (se.EffectType == GenreEffectTypes.LifeRestoreAmount) + { + var reviveHp = (int)se.Potency; + target.Entity.State.RestoreLife(reviveHp, target.MaxHp); + reviveAmount = target.Hp; + } + else if (se.EffectType == GenreEffectTypes.LifeRestorePercentage) + { + var reviveHp = (int)(target.MaxHp * se.Potency); + target.Entity.State.RestoreLife(reviveHp, target.MaxHp); + reviveAmount = target.Hp; + } + } + + return new ApplyResult(healAmount, manaAmount, reviveAmount); + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/TargetResolver.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/TargetResolver.cs new file mode 100644 index 00000000..54623840 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/TargetResolver.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenRpg.Combat.Abilities; +using OpenRpg.Combat.Attacks; +using OpenRpg.Combat.Extensions; +using OpenRpg.Combat.Types; +using OpenRpg.Core.Extensions; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Genres.Fantasy.Types; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.Combat; + +/// +/// Resolves targets for abilities based on template data (TargetType, TargetCount, Damage type). +/// Shared between AbilityExecutor and CommandMenuUi to eliminate duplication. +/// +public static class TargetResolver +{ + public static bool IsHealing(AbilityTemplate template) + { + var damage = template.Variables.GetAsOrDefault( + CombatAbilityTemplateVariableTypes.Damage, () => new Damage(0, 0)); + return damage.Type == FantasyDamageTypes.LightDamage; + } + + public static bool IsHealing(Damage damage) + { + return damage.Type == FantasyDamageTypes.LightDamage; + } + + public static List ResolveTargets( + AbilityTemplate template, + BattleEntity attacker, + List specificTargets, + List party, + List enemies) + { + var isHealing = IsHealing(template); + var targetType = template.Variables.GetIntOrDefault(CombatAbilityTemplateVariableTypes.TargetType, 1); + var targetCount = template.Variables.GetIntOrDefault(CombatAbilityTemplateVariableTypes.TargetCount, 1); + + if (specificTargets != null && specificTargets.Count > 0) + { + return specificTargets.Where(t => t.IsAlive).ToList(); + } + + var pool = isHealing + ? (attacker.Team == Team.Player ? party : enemies) + : (attacker.Team == Team.Player ? enemies : party); + + var aliveTargets = pool.Where(e => e.IsAlive).ToList(); + var actualTargetCount = targetType == CombatTargetTypes.MultipleTarget + ? Math.Min(targetCount, aliveTargets.Count) : 1; + + if (actualTargetCount == 0) return []; + + var rng = new Random(); + return aliveTargets.OrderBy(_ => rng.Next()).Take(actualTargetCount).ToList(); + } + + public static List ResolvePreviewTargets( + AbilityTemplate template, + List aliveParty, + List aliveEnemies) + { + var isHealing = IsHealing(template); + var targetType = template.Variables.GetIntOrDefault(CombatAbilityTemplateVariableTypes.TargetType, 1); + + if (targetType != CombatTargetTypes.MultipleTarget) + { return []; } + + var pool = isHealing ? aliveParty : aliveEnemies; + if (pool == null) { return []; } + + var targetCount = template.Variables.GetIntOrDefault(CombatAbilityTemplateVariableTypes.TargetCount, 1); + var actualCount = Math.Min(targetCount, pool.Count); + return pool.Take(actualCount).ToList(); + } + + public static List GetTargetPool( + bool isHealing, + BattleEntity attacker, + List party, + List enemies) + { + return isHealing + ? (attacker.Team == Team.Player ? party : enemies) + : (attacker.Team == Team.Player ? enemies : party); + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/TurnManager.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/TurnManager.cs new file mode 100644 index 00000000..094cd2f4 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Combat/TurnManager.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenRpg.Combat.Abilities; +using OpenRpg.Combat.Processors.Attacks.Entity; +using OpenRpg.Data; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Demos.Battler.Code.Types; +using OpenRpg.Genres.Extensions; +using OpenRpg.Genres.Fantasy.Extensions; +using OpenRpg.Genres.Requirements; +using OpenRpg.Items.Templates; +using OpenRpg.Localization.Data.DataSources; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.Combat; + +public class TurnManager +{ + private static readonly Random _rng = new(); + private readonly IDataSource _dataSource; + private readonly ILocaleDataSource _localeDataSource; + private readonly AbilityExecutor _abilityExecutor; + private readonly BasicAttackExecutor _basicAttackExecutor; + private readonly ItemEffectApplier _itemEffectApplier; + private List _party; + private List _enemies; + private double _phaseTimer; + + public enum Phase { Idle, TurnDwell, TargetFlash, GameOver, PlayerInput } + public Phase CurrentPhase { get; private set; } = Phase.Idle; + public BattleEntity CurrentAttacker { get; private set; } + public IReadOnlyList CurrentTargets { get; private set; } = []; + public Team WinningTeam { get; private set; } + public int CurrentTurnIndex { get; private set; } = -1; + public string LastActionMessage { get; private set; } = ""; + public List TurnOrder { get; private set; } = []; + public PlayerAction PendingAction { get; set; } + public bool IsInPlayerInput => CurrentPhase == Phase.PlayerInput; + public List HighlightedTargets { get; set; } = []; + + public event Action OnDamageDealt; + + public TurnManager( + IDataSource dataSource, + ICharacterRequirementChecker requirementChecker, + ILocaleDataSource localeDataSource, + IEntityAttackGenerator attackGenerator, + IEntityAttackProcessor attackProcessor) + { + _dataSource = dataSource; + _localeDataSource = localeDataSource; + _abilityExecutor = new AbilityExecutor(dataSource, requirementChecker, localeDataSource, attackGenerator, attackProcessor); + _basicAttackExecutor = new BasicAttackExecutor(attackGenerator, attackProcessor); + _itemEffectApplier = new ItemEffectApplier(); + } + + public void Start(List party, List enemies) + { + _party = party; + _enemies = enemies; + _abilityExecutor.Start(party, enemies); + TurnOrder = party.Concat(enemies).OrderByDescending(e => e.Initiative).ToList(); + CurrentTurnIndex = -1; + CurrentPhase = Phase.Idle; + _phaseTimer = 0; + LastActionMessage = ""; + } + + public void Update(double dt) + { + switch (CurrentPhase) + { + case Phase.Idle: + var nextPhase = AdvanceTurn(); + if (nextPhase != Phase.Idle) + { + CurrentPhase = nextPhase; + _phaseTimer = 0; + } + break; + + case Phase.TurnDwell: + _phaseTimer += dt; + if (_phaseTimer >= BattlerConstants.TurnDwellSeconds) + { + ExecuteAction(); + CurrentPhase = Phase.TargetFlash; + _phaseTimer = 0; + } + break; + + case Phase.PlayerInput: + break; + + case Phase.TargetFlash: + _phaseTimer += dt; + if (_phaseTimer >= BattlerConstants.TargetFlashSeconds) + { + CurrentPhase = CheckGameOver() ? Phase.GameOver : Phase.Idle; + _phaseTimer = 0; + } + break; + } + } + + private Phase AdvanceTurn() + { + var count = TurnOrder.Count; + for (var i = 0; i < count; i++) + { + CurrentTurnIndex = (CurrentTurnIndex + 1) % count; + CurrentAttacker = TurnOrder[CurrentTurnIndex]; + if (!CurrentAttacker.IsAlive) continue; + + var targets = CurrentAttacker.Team == Team.Player ? _enemies : _party; + if (!targets.Any(e => e.IsAlive)) continue; + + return CurrentAttacker.Team == Team.Player + ? Phase.PlayerInput + : Phase.TurnDwell; + } + return Phase.Idle; + } + + private void ExecuteAction() + { + if (CurrentAttacker.Team == Team.Player && PendingAction != null) + { + var action = PendingAction; + PendingAction = null; + switch (action.Type) + { + case ActionType.BasicAttack: + ExecuteBasicAttack(action.Targets?.FirstOrDefault()); + return; + case ActionType.Ability: + if (action.Ability != null) + { + var result = _abilityExecutor.ExecuteAbility(CurrentAttacker, action.Ability, action.Targets); + ApplyAbilityResult(result); + return; + } + break; + case ActionType.UseItem: + if (action.UsedItem != null && action.Targets?.Count > 0) + { + UseItemOnTarget(action.UsedItem, action.Targets[0]); + return; + } + break; + } + } + + if (CurrentAttacker.Team == Team.Player) + { + if (_abilityExecutor.TryPickAndExecuteAbility(CurrentAttacker, out var result)) + { + ApplyAbilityResult(result); + return; + } + } + + ExecuteBasicAttack(); + } + + public void SubmitPlayerAction(PlayerAction action) + { + if (CurrentPhase != Phase.PlayerInput) return; + PendingAction = action; + ExecuteAction(); + CurrentPhase = Phase.TargetFlash; + _phaseTimer = 0; + } + + public List<(AbilityTemplate Template, int ManaCost, bool CanAfford)> GetAvailableAbilities(BattleEntity entity) + { + return _abilityExecutor.GetAvailableAbilities(entity); + } + + private void ApplyAbilityResult(AbilityExecutor.AbilityResult result) + { + CurrentTargets = result.Targets; + LastActionMessage = result.Message; + foreach (var (target, amount, isCrit) in result.DamageEvents) + OnDamageDealt?.Invoke(target, amount, isCrit); + } + + private void ExecuteBasicAttack(BattleEntity specificTarget = null) + { + var targets = CurrentAttacker.Team == Team.Player ? _enemies : _party; + var result = _basicAttackExecutor.ExecuteBasicAttack(CurrentAttacker, specificTarget, targets); + + CurrentTargets = [result.Target]; + OnDamageDealt?.Invoke(result.Target, result.Damage, result.IsCrit); + LastActionMessage = result.Message; + } + + private void UseItemOnTarget(ItemData itemData, BattleEntity target) + { + var template = _dataSource.Get(itemData.TemplateId); + if (template == null) + { + LastActionMessage = "Item not found!"; + return; + } + + var result = _itemEffectApplier.ApplyItemEffects(itemData, target, template); + var itemName = _localeDataSource.Get("en-gb", template.NameLocaleId); + var attackerName = UI.NameHelper.NormalizeName(CurrentAttacker.Name); + var targetName = UI.NameHelper.NormalizeName(target.Name); + + LastActionMessage = CombatLogBuilder.BuildItemMessage(attackerName, itemName, targetName, result.HealAmount, result.ManaAmount, result.ReviveAmount); + + if (result.HealAmount > 0 || result.ReviveAmount > 0 || result.ManaAmount > 0) + OnDamageDealt?.Invoke(target, -(result.HealAmount + result.ReviveAmount), false); + } + + private bool CheckGameOver() + { + if (_party.All(e => !e.IsAlive)) + { + WinningTeam = Team.Enemy; + return true; + } + if (_enemies.All(e => !e.IsAlive)) + { + WinningTeam = Team.Player; + return true; + } + return false; + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Models/ActionType.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Models/ActionType.cs new file mode 100644 index 00000000..1aa0ece9 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Models/ActionType.cs @@ -0,0 +1,3 @@ +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; + +public enum ActionType { BasicAttack, Ability, UseItem, Flee } diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Models/BattleEntity.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Models/BattleEntity.cs new file mode 100644 index 00000000..8c098761 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Models/BattleEntity.cs @@ -0,0 +1,27 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using OpenRpg.Genres.Characters; +using OpenRpg.Genres.Extensions; +using OpenRpg.Genres.Fantasy.Extensions; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; + +public class BattleEntity +{ + public required Character Entity { get; set; } + public string Name { get; set; } = string.Empty; + public string AssetCode { get; set; } = string.Empty; + public Team Team { get; set; } + public int Row { get; set; } + public int SlotInRow { get; set; } + public Vector2 Position { get; set; } + public Vector2 OriginPosition { get; set; } + public int Hp { get => Entity.State.Health; set => Entity.State.Health = value; } + public int MaxHp => Entity.Stats.MaxHealth; + public int Initiative => (int)Entity.Stats.MovementSpeed; + public int AttackDamage => (int)Entity.Stats.Damage; + public int Mana => (int)Entity.State.Mana; + public int MaxMana => (int)Entity.Stats.MaxMana; + public bool IsAlive => !Entity.State.IsDead; + public Texture2D Sprite { get; set; } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Models/CommandMenuContext.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Models/CommandMenuContext.cs new file mode 100644 index 00000000..8ffb63ce --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Models/CommandMenuContext.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using OpenRpg.Combat.Abilities; +using OpenRpg.Data; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Items.Templates; +using OpenRpg.Localization.Data.DataSources; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.UI; + +/// +/// Context for showing the command menu during battle. Consolidates the 8 parameters +/// that were previously passed to Show() individually. +/// +public record CommandMenuContext +{ + public required BattleEntity Attacker { get; init; } + public required List AliveEnemies { get; init; } + public required List<(AbilityTemplate Template, int ManaCost, bool CanAfford)> Abilities { get; init; } + public required ILocaleDataSource LocaleDataSource { get; init; } + public List InventoryItems { get; init; } + public List AliveParty { get; init; } + public IDataSource DataSource { get; init; } + public List AllParty { get; init; } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Models/FloatingDamageNumber.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Models/FloatingDamageNumber.cs new file mode 100644 index 00000000..9f8e5183 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Models/FloatingDamageNumber.cs @@ -0,0 +1,35 @@ +using System; +using Microsoft.Xna.Framework; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; + +public class FloatingDamageNumber +{ + public Vector2 Position { get; set; } + public int Damage { get; set; } + public float Lifetime { get; set; } + public float MaxLifetime { get; set; } + public bool IsCrit { get; set; } + public bool IsHealing { get; set; } + public Color Color { get; set; } + public float Opacity => MathHelper.Clamp(Lifetime / MaxLifetime, 0, 1); + + public FloatingDamageNumber(Vector2 position, int damage, bool isCrit, bool isHealing = false) + { + Position = position; + Damage = Math.Abs(damage); + IsCrit = isCrit; + IsHealing = isHealing; + MaxLifetime = isCrit ? 1.2f : 1.0f; + Lifetime = MaxLifetime; + Color = isHealing ? Color.LightGreen : (isCrit ? Color.Gold : Color.Red); + } + + public void Update(float dt) + { + Lifetime -= dt; + Position = new Vector2(Position.X, Position.Y - dt * (IsCrit ? 60f : 40f)); + } + + public bool IsExpired => Lifetime <= 0; +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Models/PlayerAction.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Models/PlayerAction.cs new file mode 100644 index 00000000..2d867b67 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Models/PlayerAction.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using OpenRpg.Combat.Abilities; +using OpenRpg.Items.Templates; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; + +public class PlayerAction +{ + public ActionType Type { get; set; } + public AbilityTemplate Ability { get; set; } + public ItemData UsedItem { get; set; } + public List Targets { get; set; } = []; +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Models/Team.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Models/Team.cs new file mode 100644 index 00000000..d9c115ee --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Models/Team.cs @@ -0,0 +1,3 @@ +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; + +public enum Team { Player, Enemy } diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Providers/EnemyFormationProvider.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Providers/EnemyFormationProvider.cs new file mode 100644 index 00000000..f9950963 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Providers/EnemyFormationProvider.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenRpg.Data; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Demos.Battler.Code.Types; +using OpenRpg.Entities.Entity.Templates; +using OpenRpg.Entities.Extensions; +using OpenRpg.Genres.Characters; +using OpenRpg.Genres.Extensions; +using OpenRpg.Genres.Populators.Entity; +using OpenRpg.Localization.Data.DataSources; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.Providers; + +public class EnemyFormationProvider : IEnemyFormationProvider +{ + private readonly IDataSource _dataSource; + private readonly ILocaleDataSource _localeDataSource; + private readonly ICharacterPopulator _characterPopulator; + private readonly Random _random = new(); + + public EnemyFormationProvider(IDataSource dataSource, ILocaleDataSource localeDataSource, ICharacterPopulator characterPopulator) + { + _dataSource = dataSource; + _localeDataSource = localeDataSource; + _characterPopulator = characterPopulator; + } + + public List GenerateFormation() + { + var allMonsters = _dataSource.GetAll().ToList(); + var count = BattlerConstants.DefaultEnemyCount; + var entities = new List(); + + for (var i = 0; i < count; i++) + { + var template = allMonsters[_random.Next(allMonsters.Count)]; + var name = _localeDataSource.Get("en-gb", template.NameLocaleId); + var assetCode = template.Variables.HasAssetCode() ? template.Variables.AssetCode : ""; + + var character = new Character(); + character.TemplateId = template.Id; + character.NameLocaleId = template.NameLocaleId; + character.Variables.AssetCode = assetCode; + _characterPopulator.Populate(character, refreshState: true); + + entities.Add(new BattleEntity + { + Entity = character, + Name = name, + AssetCode = assetCode, + Team = Team.Enemy, + SlotInRow = i < 3 ? 1 : 0, + Row = i % 3 + }); + } + + return entities; + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Providers/IEnemyFormationProvider.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Providers/IEnemyFormationProvider.cs new file mode 100644 index 00000000..0df4c383 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Providers/IEnemyFormationProvider.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.Providers; + +public interface IEnemyFormationProvider +{ + List GenerateFormation(); +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Providers/IPartyProvider.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Providers/IPartyProvider.cs new file mode 100644 index 00000000..daa66d86 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Providers/IPartyProvider.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.Providers; + +public interface IPartyProvider +{ + List BuildParty(); +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Providers/PartyProvider.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Providers/PartyProvider.cs new file mode 100644 index 00000000..7583b6a3 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Providers/PartyProvider.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Linq; +using OpenRpg.Combat.Extensions; +using OpenRpg.Combat.Types; +using OpenRpg.Data; +using OpenRpg.Demos.Battler.Code.Builders; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Demos.Battler.Code.Types; +using OpenRpg.Entities.Classes.Templates; +using OpenRpg.Entities.Extensions; +using OpenRpg.Localization.Data.DataSources; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.Providers; + +public class PartyProvider : IPartyProvider +{ + private readonly IDataSource _dataSource; + private readonly ILocaleDataSource _localeDataSource; + private readonly GameCharacterBuilder _characterBuilder; + + public PartyProvider(IDataSource dataSource, ILocaleDataSource localeDataSource, GameCharacterBuilder characterBuilder) + { + _dataSource = dataSource; + _localeDataSource = localeDataSource; + _characterBuilder = characterBuilder; + } + + public List BuildParty() + { + var entities = new List(); + var slotIndex = 0; + var partyIds = ClassLookups.GetRandomPartyIds(); + + foreach (var classId in partyIds) + { + var template = _dataSource.Get(classId); + if (template == null) continue; + + var name = _localeDataSource.Get("en-gb", template.NameLocaleId); + + var character = _characterBuilder + .CreateNew() + .WithRaceId(RaceLookups.Human) + .WithClassId(classId, BattlerConstants.DefaultCharacterLevel) + .WithName(name) + .Build(); + + character.NameLocaleId = template.NameLocaleId; + var assetCode = character.Variables.AssetCode; + + if (template.Variables.HasAbilities()) + character.Variables[CombatTemplateVariableTypes.Abilities] = template.Variables.Abilities.ToList(); + + var row = slotIndex < 2 ? 0 : 1; + + entities.Add(new BattleEntity + { + Entity = character, + Name = name, + AssetCode = assetCode, + Team = Team.Player, + Row = row, + SlotInRow = slotIndex % 2 + }); + + slotIndex++; + } + + return entities; + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Rendering/EntityRenderer.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Rendering/EntityRenderer.cs new file mode 100644 index 00000000..4574cc74 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Rendering/EntityRenderer.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Combat; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.UI; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.Rendering; + +public class EntityRenderer +{ + private Texture2D _pixel; + + public void EnsureTextures(GraphicsDevice gd) + { + if (_pixel != null) return; + _pixel = new Texture2D(gd, 1, 1); + _pixel.SetData([Color.White]); + } + + public void DrawBackground(SpriteBatch sb) + { + sb.Draw(_pixel, new Rectangle(0, 0, 800, 600), Palette.Background); + sb.Draw(_pixel, new Rectangle(399, 0, 2, 344), Palette.Divider); + } + + public void DrawEntities(SpriteBatch sb, List entities, TurnManager turnManager, double totalTime) + { + foreach (var entity in entities) + DrawEntity(sb, entity, turnManager, totalTime); + } + + public void DrawOverlay(SpriteBatch sb) + { + sb.Draw(_pixel, new Rectangle(0, 0, 800, 600), Palette.GameOverOverlay); + } + + public void Dispose() + { + _pixel?.Dispose(); + } + + private void DrawEntity(SpriteBatch sb, BattleEntity entity, TurnManager turnManager, double totalTime) + { + if (entity.Sprite != null) + DrawSprite(sb, entity, turnManager, totalTime); + else + DrawFallbackRect(sb, entity); + + if (entity.IsAlive) + DrawHpBar(sb, entity); + + if ((turnManager.CurrentPhase == TurnManager.Phase.TurnDwell || turnManager.CurrentPhase == TurnManager.Phase.PlayerInput) && entity == turnManager.CurrentAttacker) + DrawTurnArrow(sb, entity, totalTime, Color.Gold); + + if (turnManager.HighlightedTargets?.Contains(entity) == true) + DrawTurnArrow(sb, entity, totalTime, Color.Red); + } + + private static void DrawSprite(SpriteBatch sb, BattleEntity entity, TurnManager turnManager, double totalTime) + { + var tex = entity.Sprite; + var slotW = 80; + var slotH = 60; + var scale = Math.Min((float)slotW / tex.Width, (float)slotH / tex.Height); + var w = (int)(tex.Width * scale); + var h = (int)(tex.Height * scale); + var x = (int)entity.Position.X + (slotW - w) / 2; + var y = (int)entity.Position.Y + (slotH - h) / 2; + var effects = entity.Team == Team.Player ? SpriteEffects.FlipHorizontally : SpriteEffects.None; + + var color = entity.IsAlive ? Color.White : Color.Gray * 0.35f; + sb.Draw(tex, new Rectangle(x, y, w, h), null, color, 0f, Vector2.Zero, effects, 0f); + + if (turnManager.CurrentPhase == TurnManager.Phase.TargetFlash && turnManager.CurrentTargets?.Contains(entity) == true) + sb.Draw(tex, new Rectangle(x, y, w, h), null, Color.Red * 0.5f, 0f, Vector2.Zero, effects, 0f); + } + + private void DrawFallbackRect(SpriteBatch sb, BattleEntity entity) + { + var rect = new Rectangle((int)entity.Position.X, (int)entity.Position.Y, 80, 60); + var color = entity.IsAlive + ? (entity.Team == Team.Player ? Palette.PlayerRect : Palette.EnemyRect) + : (entity.Team == Team.Player ? Palette.PlayerRectDead : Palette.EnemyRectDead); + + sb.Draw(_pixel, rect, color); + + if (entity.IsAlive) + sb.Draw(_pixel, new Rectangle(rect.X, rect.Y, rect.Width, 2), Palette.SpriteHighlight); + } + + private void DrawHpBar(SpriteBatch sb, BattleEntity entity) + { + var barX = (int)entity.Position.X; + var barY = (int)entity.Position.Y + 64; + var barWidth = 80; + var barHeight = 5; + + sb.Draw(_pixel, new Rectangle(barX, barY, barWidth, barHeight), Palette.HpBarBg); + + var ratio = (float)entity.Hp / entity.MaxHp; + var fillWidth = (int)(barWidth * ratio); + if (fillWidth > 0) + sb.Draw(_pixel, new Rectangle(barX, barY, fillWidth, barHeight), Palette.RatioToColor(ratio)); + } + + private void DrawTurnArrow(SpriteBatch sb, BattleEntity entity, double totalTime, Color color) + { + var slotW = 80; + var tweenY = Math.Sin(totalTime * 6) * 4; + var cx = (int)(entity.Position.X + slotW / 2); + var baseY = (int)(entity.Position.Y - 18 + tweenY); + + sb.Draw(_pixel, new Rectangle(cx - 4, baseY, 9, 2), color); + sb.Draw(_pixel, new Rectangle(cx - 3, baseY + 3, 7, 2), color); + sb.Draw(_pixel, new Rectangle(cx - 2, baseY + 6, 5, 2), color); + sb.Draw(_pixel, new Rectangle(cx - 1, baseY + 9, 3, 2), color); + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Rendering/Palette.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Rendering/Palette.cs new file mode 100644 index 00000000..e8eca028 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Rendering/Palette.cs @@ -0,0 +1,83 @@ +using Microsoft.Xna.Framework; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.Rendering; + +public static class Palette +{ + // HP bar colors + public static readonly Color HpGreen = new(80, 200, 60); + public static readonly Color HpYellow = new(220, 200, 40); + public static readonly Color HpRed = new(200, 40, 40); + public static readonly Color HpBarBg = new(30, 30, 30); + + // Entity colors + public static readonly Color Background = new(20, 20, 30); + public static readonly Color PlayerRect = new(30, 60, 140); + public static readonly Color PlayerRectDead = new(20, 25, 40); + public static readonly Color EnemyRect = new(140, 30, 30); + public static readonly Color EnemyRectDead = new(40, 20, 20); + + // Battle panel colors + public static readonly Color PanelBg = new(10, 10, 25); + public static readonly Color PanelHpBarBg = new(40, 40, 50); + public static readonly Color EnemyName = new(220, 150, 150); + public static readonly Color PartyName = new(150, 180, 220); + public static readonly Color HpText = new(200, 200, 200); + public static readonly Color MpText = new(100, 160, 255); + + // Turn order strip colors + public static readonly Color StripBg = new Color(10, 10, 25) * 0.85f; + public static readonly Color CurrentChip = new(70, 70, 95); + public static readonly Color FutureChip = new(35, 35, 50); + public static readonly Color PastChip = new(18, 18, 28); + public static readonly Color ChipPartyTint = new(80, 140, 220); + public static readonly Color ChipEnemyTint = new(220, 80, 80); + public static readonly Color CurrentText = Color.White; + public static readonly Color FutureText = new(180, 180, 190); + public static readonly Color PastText = new(60, 60, 70); + + // Menu colors + public static readonly Color MenuBg = new Color(10, 10, 25) * 0.92f; + public static readonly Color MenuItemBg = new Color(20, 20, 40) * 0.5f; + public static readonly Color MenuItemSelected = new(60, 60, 90); + public static readonly Color MenuTooltip = new(200, 200, 180); + public static readonly Color MenuItemNormal = new(180, 180, 190); + public static readonly Color MenuBackColor = new(180, 180, 100); + public static readonly Color MenuCannotAfford = new(120, 60, 60); + + // Combat log + public static readonly Color CombatLogBg = new Color(10, 10, 25) * 0.85f; + + // Divider + public static readonly Color Divider = new(60, 60, 80); + + // Panel borders + public static readonly Color PanelBorder = new(55, 60, 90); + public static readonly Color PanelBorderSelected = new(100, 120, 160); + public static readonly Color PanelTitleBar = new(25, 30, 50); + public static readonly Color PanelTitleBorder = new(55, 60, 90); + + // General UI + public static readonly Color UiTitle = new(220, 200, 120); + public static readonly Color UiAccent = new(140, 160, 220); + public static readonly Color UiButtonBg = new(20, 25, 35); + public static readonly Color UiButtonSelected = new(60, 60, 90); + public static readonly Color UiTextBody = new(180, 180, 190); + public static readonly Color UiTextLabel = new(140, 140, 150); + public static readonly Color UiTextBright = new(210, 210, 220); + public static readonly Color UiHelpText = new(140, 140, 150); + public static readonly Color UiSectionTitle = new(160, 180, 200); + + // Game over + public static readonly Color GameOverOverlay = Color.Black * 0.6f; + + // Entity sprite fallback highlight + public static readonly Color SpriteHighlight = Color.White * 0.2f; + + public static Color RatioToColor(float ratio) + { + if (ratio > 0.5f) return HpGreen; + if (ratio > 0.25f) return HpYellow; + return HpRed; + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Rendering/SpriteCache.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Rendering/SpriteCache.cs new file mode 100644 index 00000000..3809124f --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/Rendering/SpriteCache.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.Rendering; + +public class SpriteCache +{ + private readonly Dictionary _cache = []; + + public void LoadSprites(IEnumerable entities, ContentManager content) + { + foreach (var entity in entities) + { + if (_cache.TryGetValue(entity.AssetCode, out var tex)) + { + entity.Sprite = tex; + continue; + } + + var subDir = entity.Team == Team.Player ? "Players" : "Enemies"; + var assetPath = $"Sprites/{subDir}/{entity.AssetCode}"; + + try + { + tex = content.Load(assetPath); + _cache[entity.AssetCode] = tex; + entity.Sprite = tex; + } + catch (ContentLoadException) + { + System.Diagnostics.Debug.WriteLine($"Missing sprite: {assetPath}"); + } + } + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/BattleBottomPanelUi.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/BattleBottomPanelUi.cs new file mode 100644 index 00000000..2115a890 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/BattleBottomPanelUi.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using MonoGameGum; +using MonoGameGum.GueDeriving; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Rendering; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.UI; + +public class BattleBottomPanelUi +{ + private readonly ColoredRectangleRuntime _panelBg; + private readonly List _enemyHpBarBgs = []; + private readonly List _enemyHpBarFills = []; + private readonly List _partyHpBarBgs = []; + private readonly List _partyHpBarFills = []; + + private readonly List _enemyNames = []; + private readonly List _enemyHps = []; + private readonly List _enemyMaxHps = []; + private readonly List _enemyAlive = []; + private readonly List _partyNames = []; + private readonly List _partyHps = []; + private readonly List _partyMaxHps = []; + private readonly List _partyManas = []; + private readonly List _partyMaxManas = []; + private readonly List _partyAlive = []; + + private const int MaxEnemyRows = 6; + private const int MaxPartyRows = 4; + private const int RowStartY = 414; + private const int RowSpacing = 24; + + public BattleBottomPanelUi() + { + _panelBg = new ColoredRectangleRuntime(); + _panelBg.Width = 800; + _panelBg.Height = 220; + _panelBg.X = 0; + _panelBg.Y = 380; + _panelBg.SetRectColor(Palette.PanelBg * 0.9f); + _panelBg.AddToRoot(); + + for (var i = 0; i < MaxEnemyRows; i++) + { + var y = RowStartY + i * RowSpacing; + _enemyHpBarBgs.Add(CreateHpBarBg(150, y)); + _enemyHpBarFills.Add(CreateHpBarFill(150, y)); + } + + for (var i = 0; i < MaxPartyRows; i++) + { + var y = RowStartY + i * RowSpacing; + _partyHpBarBgs.Add(CreateHpBarBg(548, y)); + _partyHpBarFills.Add(CreateHpBarFill(548, y)); + } + } + + public void Update(List party, List enemies) + { + UpdateEnemyRows(enemies); + UpdatePartyRows(party); + } + + private void UpdateEnemyRows(List enemies) + { + _enemyNames.Clear(); + _enemyHps.Clear(); + _enemyMaxHps.Clear(); + _enemyAlive.Clear(); + + for (var i = 0; i < MaxEnemyRows; i++) + { + var visible = i < enemies.Count; + _enemyHpBarBgs[i].Visible = visible; + _enemyHpBarFills[i].Visible = visible; + + if (visible) + { + var e = enemies[i]; + _enemyNames.Add(e.Name); + _enemyHps.Add(e.Hp); + _enemyMaxHps.Add(e.MaxHp); + _enemyAlive.Add(e.IsAlive); + UpdateHpBarFill(_enemyHpBarFills[i], e); + } + else + { + _enemyNames.Add(""); + _enemyHps.Add(0); + _enemyMaxHps.Add(0); + _enemyAlive.Add(false); + } + } + } + + private void UpdatePartyRows(List party) + { + _partyNames.Clear(); + _partyHps.Clear(); + _partyMaxHps.Clear(); + _partyManas.Clear(); + _partyMaxManas.Clear(); + _partyAlive.Clear(); + + for (var i = 0; i < MaxPartyRows; i++) + { + var visible = i < party.Count; + _partyHpBarBgs[i].Visible = visible; + _partyHpBarFills[i].Visible = visible; + + if (visible) + { + var e = party[i]; + _partyNames.Add(e.Name); + _partyHps.Add(e.Hp); + _partyMaxHps.Add(e.MaxHp); + _partyManas.Add(e.Mana); + _partyMaxManas.Add(e.MaxMana); + _partyAlive.Add(e.IsAlive); + UpdateHpBarFill(_partyHpBarFills[i], e); + } + else + { + _partyNames.Add(""); + _partyHps.Add(0); + _partyMaxHps.Add(0); + _partyManas.Add(0); + _partyMaxManas.Add(0); + _partyAlive.Add(false); + } + } + } + + private static void UpdateHpBarFill(ColoredRectangleRuntime fill, BattleEntity e) + { + var ratio = e.MaxHp > 0 ? (float)e.Hp / e.MaxHp : 0f; + fill.Width = ratio * 100; + fill.SetRectColor(Palette.RatioToColor(ratio)); + } + + public void Draw(SpriteBatch sb, SpriteFont font) + { + var headerY = 392; + TextHelper.DrawStringWithSpacing(sb, font, "ENEMIES", new Vector2(6, headerY), Color.White); + TextHelper.DrawStringWithSpacing(sb, font, "PARTY", new Vector2(404, headerY), Color.White); + + for (var i = 0; i < MaxEnemyRows; i++) + { + if (!_enemyHpBarBgs[i].Visible) continue; + + var y = RowStartY + i * RowSpacing; + TextHelper.DrawStringWithSpacing(sb, font, NameHelper.NormalizeName(_enemyNames[i]), + new Vector2(6, y), _enemyAlive[i] ? Palette.EnemyName : Color.Gray); + var hpText = $"{_enemyHps[i]}/{_enemyMaxHps[i]}"; + TextHelper.DrawStringWithSpacing(sb, font, hpText, new Vector2(254, y), Palette.HpText); + } + + for (var i = 0; i < MaxPartyRows; i++) + { + if (!_partyHpBarBgs[i].Visible) continue; + + var y = RowStartY + i * RowSpacing; + TextHelper.DrawStringWithSpacing(sb, font, NameHelper.NormalizeName(_partyNames[i]), + new Vector2(404, y), _partyAlive[i] ? Palette.PartyName : Color.Gray); + var hpText = $"{_partyHps[i]}/{_partyMaxHps[i]}"; + TextHelper.DrawStringWithSpacing(sb, font, hpText, new Vector2(652, y), Palette.HpText); + if (_partyMaxManas[i] > 0) + { + var mpText = $"MP:{_partyManas[i]}/{_partyMaxManas[i]}"; + TextHelper.DrawStringWithSpacing(sb, font, mpText, new Vector2(652, y + 12), Palette.MpText); + } + } + } + + public void Unload() + { + _panelBg.RemoveFromRoot(); + foreach (var r in _enemyHpBarBgs) r.RemoveFromRoot(); + foreach (var r in _enemyHpBarFills) r.RemoveFromRoot(); + foreach (var r in _partyHpBarBgs) r.RemoveFromRoot(); + foreach (var r in _partyHpBarFills) r.RemoveFromRoot(); + } + + private static ColoredRectangleRuntime CreateHpBarBg(int x, int y) + { + var rect = new ColoredRectangleRuntime(); + rect.X = x; + rect.Y = y + 2; + rect.Width = 100; + rect.Height = 14; + rect.SetRectColor(Palette.PanelHpBarBg); + rect.AddToRoot(); + return rect; + } + + private static ColoredRectangleRuntime CreateHpBarFill(int x, int y) + { + var rect = new ColoredRectangleRuntime(); + rect.X = x; + rect.Y = y + 2; + rect.Width = 100; + rect.Height = 14; + rect.SetRectColor(Palette.HpGreen); + rect.AddToRoot(); + return rect; + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/CombatLogUi.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/CombatLogUi.cs new file mode 100644 index 00000000..ac0fdb22 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/CombatLogUi.cs @@ -0,0 +1,46 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using MonoGameGum; +using MonoGameGum.GueDeriving; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Rendering; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.UI; + +public class CombatLogUi +{ + private readonly ColoredRectangleRuntime _bg; + private string _message = ""; + + private const int BarY = 310; + private const int BarH = 34; + private const int TextX = 6; + private const int TextY = 316; + + public CombatLogUi() + { + _bg = new ColoredRectangleRuntime + { + X = 0, + Y = BarY, + Width = 800, + Height = BarH + }; + _bg.SetRectColor(Palette.CombatLogBg); + _bg.AddToRoot(); + } + + public void Update(string message) + { + _message = message; + } + + public void Draw(SpriteBatch sb, SpriteFont font) + { + TextHelper.DrawStringWithSpacing(sb, font, _message, new Vector2(TextX, TextY), Color.White); + } + + public void Unload() + { + _bg.RemoveFromRoot(); + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/CommandMenuUi.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/CommandMenuUi.cs new file mode 100644 index 00000000..c45a19c8 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/CommandMenuUi.cs @@ -0,0 +1,594 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using MonoGameGum; +using MonoGameGum.GueDeriving; +using OpenRpg.Combat.Abilities; +using OpenRpg.Combat.Attacks; +using OpenRpg.Combat.Extensions; +using OpenRpg.Combat.Types; +using OpenRpg.Core.Extensions; +using OpenRpg.Core.Requirements; +using OpenRpg.Data; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Combat; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Rendering; +using OpenRpg.Entities.Types; +using OpenRpg.Genres.Types; +using OpenRpg.Items.Templates; +using OpenRpg.Localization.Data.DataSources; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.UI; + +public class CommandMenuUi +{ + public enum MenuScreen { Hidden, MainMenu, AbilitySelect, TargetSelect, ItemSelect, ItemTargetSelect } + + private MenuScreen _currentScreen = MenuScreen.Hidden; + private int _selectedIndex; + private BattleEntity _currentAttacker; + private List _aliveEnemies; + private List _aliveParty; + private List _allParty; + private List<(AbilityTemplate Template, int ManaCost, bool CanAfford)> _availableAbilities; + private AbilityTemplate _selectedAbility; + private ILocaleDataSource _localeDataSource; + private IDataSource _dataSource; + private string[] _currentItems = []; + + // Item usage state + private List _inventoryItems; + private ItemData _selectedItemData; + private ItemTemplate _selectedItemTemplate; + private bool _targetingParty; + + private ColoredRectangleRuntime _menuBg; + private ColoredRectangleRuntime _tooltipBg; + private readonly List _itemRects = []; + + private const int MenuX = 290; + private const int MenuW = 220; + private const int MenuItemH = 24; + private const int MenuPadding = 8; + private const int TooltipH = 22; + private const int TooltipGap = 4; + + private int _menuY; + private string _tooltipText = ""; + + public event Action OnActionConfirmed; + public MenuScreen CurrentScreen => _currentScreen; + public bool IsHidden => _currentScreen == MenuScreen.Hidden; + + public List PreviewTargets + { + get + { + if (_currentScreen == MenuScreen.TargetSelect) + { + var pool = _targetingParty ? _aliveParty : _aliveEnemies; + if (pool != null && _selectedIndex < pool.Count) + return [pool[_selectedIndex]]; + return []; + } + + if (_currentScreen == MenuScreen.AbilitySelect && _selectedIndex < _availableAbilities.Count) + { + var (template, _, canAfford) = _availableAbilities[_selectedIndex]; + if (!canAfford) return []; + return TargetResolver.ResolvePreviewTargets(template, _aliveParty, _aliveEnemies); + } + + return []; + } + } + + public void Show(CommandMenuContext context) + { + Show(context.Attacker, context.AliveEnemies, context.Abilities, context.LocaleDataSource, + context.InventoryItems, context.AliveParty, context.DataSource, context.AllParty); + } + + public void Show( + BattleEntity attacker, + List aliveEnemies, + List<(AbilityTemplate Template, int ManaCost, bool CanAfford)> abilities, + ILocaleDataSource localeDataSource, + List inventoryItems = null, + List aliveParty = null, + IDataSource dataSource = null, + List allParty = null) + { + _currentAttacker = attacker; + _aliveEnemies = aliveEnemies; + _availableAbilities = abilities; + _selectedAbility = null; + _targetingParty = false; + _localeDataSource = localeDataSource; + _inventoryItems = inventoryItems; + _aliveParty = aliveParty; + _dataSource = dataSource; + _allParty = allParty; + + SwitchToScreen(MenuScreen.MainMenu, BuildMainMenuItems()); + } + + private void SwitchToScreen(MenuScreen screen, string[] items) + { + _currentScreen = screen; + _selectedIndex = 0; + _currentItems = items; + BuildGumElements(); + } + + private string[] BuildMainMenuItems() + { + return [$"Attack", $"Ability", $"Items"]; + } + + private string[] BuildAbilityItems() + { + var items = new List(); + foreach (var (template, manaCost, _) in _availableAbilities) + { + var name = _localeDataSource?.Get("en-gb", template.NameLocaleId) ?? $"Ability {template.Id}"; + items.Add($"{name} (MP:{manaCost})"); + } + items.Add("Back"); + return [.. items]; + } + + private string[] BuildTargetItems() + { + var pool = _targetingParty ? _aliveParty : _aliveEnemies; + var items = (pool ?? []).Select(e => e.Name).ToList(); + items.Add("Back"); + return [.. items]; + } + + private string[] BuildItemItems() + { + var items = new List(); + if (_inventoryItems != null) + { + foreach (var itemData in _inventoryItems) + { + var template = _dataSource?.Get(itemData.TemplateId); + var name = template != null + ? _localeDataSource?.Get("en-gb", template.NameLocaleId) ?? $"Item #{itemData.TemplateId}" + : $"Item #{itemData.TemplateId}"; + items.Add(name); + } + } + items.Add("Back"); + return [.. items]; + } + + private string[] BuildItemTargetItems() + { + if (_selectedItemTemplate == null) + { + var fallback = _aliveParty?.Select(e => e.Name).ToList() ?? []; + if (fallback.Count == 0) fallback.Add("(No valid targets)"); + fallback.Add("Back"); + return [.. fallback]; + } + + var isLifeRestore = HasLifeRestoreEffect(_selectedItemTemplate); + var pool = isLifeRestore ? _allParty?.Where(e => !e.IsAlive).ToList() : _aliveParty; + + var items = (pool ?? []).Select(e => + { + var name = e.Name; + if (!e.IsAlive) name += " (Dead)"; + return name; + }).ToList(); + + if (items.Count == 0) + items.Add(isLifeRestore ? "(No dead members)" : "(No valid targets)"); + items.Add("Back"); + return [.. items]; + } + + private static bool HasLifeRestoreEffect(ItemTemplate template) + { + return ItemEffectApplier.HasLifeRestoreEffect(template); + } + + private void BuildGumElements() + { + ClearGumElements(); + + var itemCount = _currentItems.Length; + var menuH = itemCount * MenuItemH + MenuPadding * 2; + _menuY = (310 - menuH) / 2 + 30; + + _tooltipText = GetCurrentDescription(); + var hasTooltip = !string.IsNullOrEmpty(_tooltipText); + + if (hasTooltip) + { + var tooltipY = _menuY - TooltipH - TooltipGap; + _tooltipBg = new ColoredRectangleRuntime + { + X = MenuX, + Y = tooltipY, + Width = MenuW, + Height = TooltipH + }; + _tooltipBg.SetRectColor(Palette.MenuBg); + _tooltipBg.AddToRoot(); + } + + _menuBg = new ColoredRectangleRuntime + { + X = MenuX, + Y = _menuY, + Width = MenuW, + Height = menuH + }; + _menuBg.SetRectColor(Palette.MenuBg); + _menuBg.AddToRoot(); + + for (var i = 0; i < itemCount; i++) + { + var rect = new ColoredRectangleRuntime + { + X = MenuX + 3, + Y = _menuY + MenuPadding + i * MenuItemH, + Width = MenuW - 6, + Height = MenuItemH - 2 + }; + rect.SetRectColor(Palette.MenuItemBg); + rect.AddToRoot(); + _itemRects.Add(rect); + } + + UpdateSelectionHighlight(); + } + + private void UpdateSelectionHighlight() + { + for (var i = 0; i < _itemRects.Count; i++) + { + _itemRects[i].SetRectColor( + i == _selectedIndex + ? Palette.MenuItemSelected + : Palette.MenuItemBg); + } + } + + public void Hide() + { + _currentScreen = MenuScreen.Hidden; + ClearGumElements(); + } + + private void ClearGumElements() + { + _tooltipBg?.RemoveFromRoot(); + _tooltipBg = null; + _menuBg?.RemoveFromRoot(); + _menuBg = null; + foreach (var r in _itemRects) + r.RemoveFromRoot(); + _itemRects.Clear(); + } + + private string GetCurrentDescription() + { + if (_currentScreen == MenuScreen.AbilitySelect && _selectedIndex < _availableAbilities.Count) + { + var (template, _, canAfford) = _availableAbilities[_selectedIndex]; + if (!canAfford) + return "Not enough MP!"; + return _localeDataSource?.Get("en-gb", template.DescriptionLocaleId) ?? ""; + } + + if (_currentScreen == MenuScreen.TargetSelect && _selectedAbility != null) + { + var name = _localeDataSource?.Get("en-gb", _selectedAbility.NameLocaleId) ?? $"Ability {_selectedAbility.Id}"; + return $"Choose target for {name}"; + } + + if (_currentScreen == MenuScreen.TargetSelect && _selectedAbility == null) + return "Choose target for Attack"; + + if (_currentScreen == MenuScreen.ItemSelect && _selectedIndex < (_inventoryItems?.Count ?? 0)) + { + var template = _dataSource?.Get(_inventoryItems[_selectedIndex].TemplateId); + if (template != null) + return _localeDataSource?.Get("en-gb", template.DescriptionLocaleId) ?? ""; + return ""; + } + + if (_currentScreen == MenuScreen.ItemTargetSelect) + { + var itemName = _selectedItemTemplate != null + ? _localeDataSource?.Get("en-gb", _selectedItemTemplate.NameLocaleId) ?? "Item" + : "Item"; + return $"Use {itemName} on which party member?"; + } + + return ""; + } + + public void HandleInput(KeyboardState current, KeyboardState previous) + { + if (IsHidden) return; + + if (InputHelper.IsKeyJustPressed(current, previous, Keys.Up)) + { + _selectedIndex = _selectedIndex > 0 ? _selectedIndex - 1 : _currentItems.Length - 1; + UpdateSelectionHighlight(); + _tooltipText = GetCurrentDescription(); + } + else if (InputHelper.IsKeyJustPressed(current, previous, Keys.Down)) + { + _selectedIndex = (_selectedIndex + 1) % _currentItems.Length; + UpdateSelectionHighlight(); + _tooltipText = GetCurrentDescription(); + } + else if (InputHelper.IsKeyJustPressed(current, previous, Keys.Enter) || + InputHelper.IsKeyJustPressed(current, previous, Keys.Space)) + { + ConfirmSelection(); + } + else if (InputHelper.IsKeyJustPressed(current, previous, Keys.Escape) || + InputHelper.IsKeyJustPressed(current, previous, Keys.Back)) + { + GoBack(); + } + } + + private void ConfirmSelection() + { + switch (_currentScreen) + { + case MenuScreen.MainMenu: + HandleMainMenuConfirm(); + break; + case MenuScreen.AbilitySelect: + HandleAbilityConfirm(); + break; + case MenuScreen.TargetSelect: + HandleTargetConfirm(); + break; + case MenuScreen.ItemSelect: + HandleItemConfirm(); + break; + case MenuScreen.ItemTargetSelect: + HandleItemTargetConfirm(); + break; + } + } + + private void HandleMainMenuConfirm() + { + switch (_selectedIndex) + { + case 0: + _selectedAbility = null; + _targetingParty = false; + SwitchToScreen(MenuScreen.TargetSelect, BuildTargetItems()); + break; + case 1: + SwitchToScreen(MenuScreen.AbilitySelect, BuildAbilityItems()); + break; + case 2: + // Items - show inventory + if (_inventoryItems == null || _inventoryItems.Count == 0) + return; // No items to use + _selectedIndex = 0; + SwitchToScreen(MenuScreen.ItemSelect, BuildItemItems()); + break; + } + } + + private void HandleAbilityConfirm() + { + var abilityCount = _availableAbilities.Count; + if (_selectedIndex == abilityCount) + { + SwitchToScreen(MenuScreen.MainMenu, BuildMainMenuItems()); + return; + } + + var (template, _, canAfford) = _availableAbilities[_selectedIndex]; + if (!canAfford) return; + + _selectedAbility = template; + + var isHealing = TargetResolver.IsHealing(template); + var targetType = template.Variables.GetIntOrDefault(CombatAbilityTemplateVariableTypes.TargetType, 1); + + if (targetType == CombatTargetTypes.MultipleTarget) + { + var pool = isHealing ? _aliveParty : _aliveEnemies; + if (pool == null || pool.Count == 0) return; + var targetCount = template.Variables.GetIntOrDefault(CombatAbilityTemplateVariableTypes.TargetCount, 1); + var actualCount = Math.Min(targetCount, pool.Count); + var targets = pool.Take(actualCount).ToList(); + FireAction(new PlayerAction + { + Type = ActionType.Ability, + Ability = template, + Targets = targets + }); + } + else + { + _targetingParty = isHealing; + SwitchToScreen(MenuScreen.TargetSelect, BuildTargetItems()); + } + } + + private void HandleTargetConfirm() + { + var pool = _targetingParty ? _aliveParty : _aliveEnemies; + var poolCount = pool?.Count ?? 0; + + if (_selectedIndex == poolCount) + { + var backScreen = _selectedAbility != null ? MenuScreen.AbilitySelect : MenuScreen.MainMenu; + var backItems = _selectedAbility != null ? BuildAbilityItems() : BuildMainMenuItems(); + _targetingParty = false; + SwitchToScreen(backScreen, backItems); + return; + } + + var target = pool![_selectedIndex]; + + FireAction(_selectedAbility != null + ? new PlayerAction { Type = ActionType.Ability, Ability = _selectedAbility, Targets = [target] } + : new PlayerAction { Type = ActionType.BasicAttack, Targets = [target] }); + } + + private void HandleItemConfirm() + { + var itemCount = _inventoryItems?.Count ?? 0; + if (_selectedIndex == itemCount) + { + SwitchToScreen(MenuScreen.MainMenu, BuildMainMenuItems()); + return; + } + + if (_inventoryItems == null || _selectedIndex >= _inventoryItems.Count) return; + + _selectedItemData = _inventoryItems[_selectedIndex]; + _selectedItemTemplate = _dataSource?.Get(_selectedItemData.TemplateId); + + if (_selectedItemTemplate == null) return; + + _selectedIndex = 0; + SwitchToScreen(MenuScreen.ItemTargetSelect, BuildItemTargetItems()); + } + + private void HandleItemTargetConfirm() + { + var isLifeRestore = _selectedItemTemplate != null && HasLifeRestoreEffect(_selectedItemTemplate); + var pool = isLifeRestore ? _allParty?.Where(e => !e.IsAlive).ToList() : _aliveParty; + var targetCount = pool?.Count ?? 0; + + if (pool == null || _selectedIndex >= targetCount) + { + // Back to item select + _selectedIndex = 0; + SwitchToScreen(MenuScreen.ItemSelect, BuildItemItems()); + return; + } + + if (_selectedItemData == null) return; + + var target = pool[_selectedIndex]; + var itemCopy = new ItemData { TemplateId = _selectedItemData.TemplateId }; + + FireAction(new PlayerAction + { + Type = ActionType.UseItem, + Targets = [target], + UsedItem = itemCopy + }); + } + + private void GoBack() + { + switch (_currentScreen) + { + case MenuScreen.MainMenu: + if (_aliveEnemies.Count > 0) + FireAction(new PlayerAction { Type = ActionType.BasicAttack, Targets = [_aliveEnemies[0]] }); + break; + case MenuScreen.AbilitySelect: + SwitchToScreen(MenuScreen.MainMenu, BuildMainMenuItems()); + break; + case MenuScreen.TargetSelect: + _targetingParty = false; + if (_selectedAbility != null) + SwitchToScreen(MenuScreen.AbilitySelect, BuildAbilityItems()); + else + SwitchToScreen(MenuScreen.MainMenu, BuildMainMenuItems()); + break; + case MenuScreen.ItemSelect: + SwitchToScreen(MenuScreen.MainMenu, BuildMainMenuItems()); + break; + case MenuScreen.ItemTargetSelect: + _selectedIndex = 0; + SwitchToScreen(MenuScreen.ItemSelect, BuildItemItems()); + break; + } + } + + private void FireAction(PlayerAction action) + { + OnActionConfirmed?.Invoke(action); + Hide(); + } + + public void Draw(SpriteBatch sb, SpriteFont font) + { + if (IsHidden) return; + + if (!string.IsNullOrEmpty(_tooltipText)) + { + var tooltipY = _menuY - TooltipH - TooltipGap; + TextHelper.DrawStringWithSpacing(sb, font, _tooltipText, + new Vector2(MenuX + MenuPadding, tooltipY + 3), Palette.MenuTooltip); + } + + var x = MenuX + MenuPadding; + var baseY = _menuY + MenuPadding; + + for (var i = 0; i < _currentItems.Length; i++) + { + var y = baseY + i * MenuItemH + 2; + var isSelected = i == _selectedIndex; + + Color color; + if (_currentScreen == MenuScreen.AbilitySelect && i < _availableAbilities.Count) + { + if (!_availableAbilities[i].CanAfford) + color = Palette.MenuCannotAfford; + else if (isSelected) + color = Color.White; + else + color = Palette.MenuItemNormal; + } + else if (IsBackItem(i)) + { + color = Palette.MenuBackColor; + } + else if (isSelected) + { + color = Color.White; + } + else + { + color = Palette.MenuItemNormal; + } + + TextHelper.DrawStringWithSpacing(sb, font, _currentItems[i], new Vector2(x, y), color); + } + } + + private bool IsBackItem(int index) + { + if (_currentScreen == MenuScreen.AbilitySelect && index == _availableAbilities.Count) return true; + if (_currentScreen == MenuScreen.TargetSelect) + { + var poolCount = _targetingParty ? (_aliveParty?.Count ?? 0) : (_aliveEnemies?.Count ?? 0); + if (index == poolCount) return true; + } + if (_currentScreen == MenuScreen.ItemSelect && index == (_inventoryItems?.Count ?? 0)) return true; + if (_currentScreen == MenuScreen.ItemTargetSelect) + { + var isLifeRestore = _selectedItemTemplate != null && HasLifeRestoreEffect(_selectedItemTemplate); + var pool = isLifeRestore ? _allParty?.Where(e => !e.IsAlive).ToList() : _aliveParty; + if (index == (pool?.Count ?? 0)) return true; + } + return false; + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/GumExtensions.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/GumExtensions.cs new file mode 100644 index 00000000..7f90bea0 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/GumExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.Xna.Framework; +using MonoGameGum.GueDeriving; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.UI; + +public static class GumExtensions +{ + public static void SetRectColor(this ColoredRectangleRuntime rect, Color color) + { + rect.Red = color.R; + rect.Green = color.G; + rect.Blue = color.B; + rect.Alpha = color.A; + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/InputHelper.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/InputHelper.cs new file mode 100644 index 00000000..7f5f89c7 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/InputHelper.cs @@ -0,0 +1,11 @@ +using Microsoft.Xna.Framework.Input; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.UI; + +public static class InputHelper +{ + public static bool IsKeyJustPressed(KeyboardState current, KeyboardState previous, Keys key) + { + return current.IsKeyDown(key) && !previous.IsKeyDown(key); + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/NameHelper.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/NameHelper.cs new file mode 100644 index 00000000..ab01ec5d --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/NameHelper.cs @@ -0,0 +1,11 @@ +using System.Text.RegularExpressions; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.UI; + +public static class NameHelper +{ + public static string NormalizeName(string name) + { + return Regex.Replace(name, "(?<=[a-z])(?=[A-Z])", " "); + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/TextHelper.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/TextHelper.cs new file mode 100644 index 00000000..90885619 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/TextHelper.cs @@ -0,0 +1,60 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.UI; + +public static class TextHelper +{ + private const int SpaceGap = 4; + private const int ShadowOffset = 1; + private static readonly Color ShadowColor = Color.Black * 0.55f; + + public static void DrawStringWithSpacing(SpriteBatch sb, SpriteFont font, string text, Vector2 position, Color color, bool centered = false, bool drawShadow = true) + { + if (string.IsNullOrEmpty(text)) return; + + var x = (int)position.X; + var y = (int)position.Y; + + if (centered) + { + var totalWidth = MeasureStringWidth(font, text); + x -= totalWidth / 2; + } + + var words = text.Split(' '); + + if (drawShadow) + { + var sx = x + ShadowOffset; + var sy = y + ShadowOffset; + foreach (var word in words) + { + if (word.Length == 0) continue; + sb.DrawString(font, word, new Vector2(sx, sy), ShadowColor); + sx += (int)font.MeasureString(word).X + SpaceGap; + } + } + + foreach (var word in words) + { + if (word.Length == 0) continue; + sb.DrawString(font, word, new Vector2(x, y), color); + x += (int)font.MeasureString(word).X + SpaceGap; + } + } + + public static int MeasureStringWidth(SpriteFont font, string text) + { + if (string.IsNullOrEmpty(text)) return 0; + var words = text.Split(' '); + var total = 0; + foreach (var word in words) + { + if (word.Length == 0) continue; + total += (int)font.MeasureString(word).X + SpaceGap; + } + return total > 0 ? total - SpaceGap : 0; + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/TurnOrderUi.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/TurnOrderUi.cs new file mode 100644 index 00000000..c0eafd09 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/Battle/UI/TurnOrderUi.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using MonoGameGum; +using MonoGameGum.GueDeriving; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Rendering; + +namespace OpenRpg.Demos.Battler.Code.Scenes.Battle.UI; + +public class TurnOrderUi +{ + private readonly ColoredRectangleRuntime _stripBg; + private readonly List _chipBgs = []; + private readonly List _chipLabels = []; + private readonly SpriteFont _font; + private int _currentIndex; + private float _pulseBrightness; + + private const int StripY = 344; + private const int StripHeight = 36; + private const int ChipGap = 8; + private const int ChipPaddingX = 8; + private const int MaxSlots = 10; + private const int TextY = StripY + 6; + + public TurnOrderUi(SpriteFont font) + { + _font = font; + + _stripBg = new ColoredRectangleRuntime + { + X = 0, + Y = StripY, + Width = 800, + Height = StripHeight + }; + _stripBg.SetRectColor(Palette.StripBg); + _stripBg.AddToRoot(); + + for (var i = 0; i < MaxSlots; i++) + { + var chipBg = new ColoredRectangleRuntime + { + X = 0, + Y = StripY + 3, + Width = 0, + Height = StripHeight - 6 + }; + chipBg.SetRectColor(Palette.FutureChip); + chipBg.AddToRoot(); + _chipBgs.Add(chipBg); + _chipLabels.Add(""); + } + } + + public void Update(List allEntities, int currentTurnIndex, float pulseBrightness = 0) + { + _currentIndex = currentTurnIndex; + _pulseBrightness = pulseBrightness; + _chipLabels.Clear(); + + var totalGapWidth = ChipGap * (Math.Min(allEntities.Count, MaxSlots) - 1); + var availableWidth = 800 - totalGapWidth; + var totalTextWidth = 0f; + var labelWidths = new float[Math.Min(allEntities.Count, MaxSlots)]; + + for (var i = 0; i < MaxSlots; i++) + { + var visible = i < allEntities.Count; + _chipBgs[i].Visible = visible; + + if (!visible) + { + _chipLabels.Add(""); + continue; + } + + var entity = allEntities[i]; + var isCurrent = i == currentTurnIndex; + var teamColor = entity.Team == Team.Player ? Palette.ChipPartyTint : Palette.ChipEnemyTint; + var label = isCurrent ? $"> {NameHelper.NormalizeName(entity.Name)}" : NameHelper.NormalizeName(entity.Name); + + _chipLabels.Add(label); + labelWidths[i] = _font.MeasureString(label).X; + + if (visible) + totalTextWidth += labelWidths[i]; + + if (isCurrent && pulseBrightness > 0) + { + var factor = pulseBrightness * 0.5f; + var r = (byte)(Palette.CurrentChip.R + (255 - Palette.CurrentChip.R) * factor); + var g = (byte)(Palette.CurrentChip.G + (255 - Palette.CurrentChip.G) * factor); + var b = (byte)(Palette.CurrentChip.B + (255 - Palette.CurrentChip.B) * factor); + _chipBgs[i].Red = r; + _chipBgs[i].Green = g; + _chipBgs[i].Blue = b; + } + else if (isCurrent) + _chipBgs[i].SetRectColor(Palette.CurrentChip); + else if (i < currentTurnIndex) + _chipBgs[i].SetRectColor(Palette.PastChip); + else + _chipBgs[i].SetRectColor(Palette.FutureChip); + + _chipBgs[i].Red = (byte)(_chipBgs[i].Red * 0.7 + teamColor.R * 0.3); + _chipBgs[i].Green = (byte)(_chipBgs[i].Green * 0.7 + teamColor.G * 0.3); + _chipBgs[i].Blue = (byte)(_chipBgs[i].Blue * 0.7 + teamColor.B * 0.3); + } + + var scale = totalTextWidth > 0 && totalTextWidth > availableWidth + ? availableWidth / totalTextWidth : 1f; + + var cursorX = 2; + for (var i = 0; i < MaxSlots && i < _chipLabels.Count; i++) + { + if (!_chipBgs[i].Visible) continue; + + var chipW = (int)(labelWidths[i] * scale) + ChipPaddingX * 2; + _chipBgs[i].X = cursorX; + _chipBgs[i].Width = chipW; + cursorX += chipW + ChipGap; + } + } + + public void Draw(SpriteBatch sb) + { + for (var i = 0; i < MaxSlots && i < _chipLabels.Count; i++) + { + if (!_chipBgs[i].Visible) continue; + + var label = _chipLabels[i]; + if (string.IsNullOrEmpty(label)) continue; + + var x = _chipBgs[i].X + ChipPaddingX; + var y = TextY; + + Color color; + if (i == _currentIndex && _pulseBrightness > 0) + { + var t = _pulseBrightness * 0.75f; + color = new Color( + (byte)(Palette.CurrentText.R + (255 - Palette.CurrentText.R) * t), + (byte)(Palette.CurrentText.G + (255 - Palette.CurrentText.G) * t), + (byte)(Palette.CurrentText.B + (255 - Palette.CurrentText.B) * t)); + } + else if (i == _currentIndex) + color = Palette.CurrentText; + else if (i < _currentIndex) + color = Palette.PastText; + else + color = Palette.FutureText; + + TextHelper.DrawStringWithSpacing(sb, _font, label, new Vector2(x, y), color); + } + } + + public void Unload() + { + _stripBg.RemoveFromRoot(); + foreach (var bg in _chipBgs) + bg.RemoveFromRoot(); + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/CharacterMenu/CharacterMenuScene.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/CharacterMenu/CharacterMenuScene.cs new file mode 100644 index 00000000..c7832519 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/CharacterMenu/CharacterMenuScene.cs @@ -0,0 +1,964 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using OpenRpg.Core.Effects; +using OpenRpg.Core.Extensions; +using OpenRpg.Data; +using OpenRpg.Entities.Extensions; +using OpenRpg.Demos.Battler.Code.Scenes; +using OpenRpg.Demos.Battler.Code.Scenes.Battle; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Combat; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Rendering; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.UI; +using OpenRpg.Demos.Battler.Code.Services.Game; +using OpenRpg.Demos.Battler.Code.Types; +using OpenRpg.Entities.Classes.Templates; +using OpenRpg.Genres.Extensions; +using OpenRpg.Genres.Fantasy.Extensions; +using OpenRpg.Genres.Fantasy.Types; +using OpenRpg.Genres.Populators.Entity; +using OpenRpg.Genres.Types; +using OpenRpg.Items.Equippables.Slots; +using OpenRpg.Items.Extensions; +using OpenRpg.Items.Templates; +using OpenRpg.Localization.Data.DataSources; + +namespace OpenRpg.Demos.Battler.Code.Scenes.CharacterMenu; + +public class CharacterMenuScene : IScene +{ + private enum Screen { PartySelect, CharacterDetail, EquipmentSelect, Inventory, ItemTargetSelect } + + private Screen _currentScreen = Screen.PartySelect; + private int _selectedIndex; + + // State for equipment browsing + private int _selectedCharacter; + private int _selectedSlotIndex; // index into SlotOrder + + private readonly IPersistentGameState _gameState; + private readonly ISceneManager _sceneManager; + private readonly IServiceProvider _serviceProvider; + private readonly IGameServices _gameServices; + private readonly IDataSource _dataSource; + private readonly ILocaleDataSource _localeDataSource; + private readonly IEquipmentSlotValidator _slotValidator; + private readonly ICharacterPopulator _characterPopulator; + private readonly EquipmentService _equipmentService; + + private bool _loaded; + private SpriteFont _font; + private Texture2D _pixel; + private KeyboardState _previousKeyboard; + + // For EquipmentSelect: the items available for the current slot + private int _browsingSlotType; + private List<(ItemData Data, ItemTemplate Template)> _candidateItems = []; + + // For ItemTargetSelect: the item being used on a party member + private ItemData _itemToUseData; + private ItemTemplate _itemToUseTemplate; + + // Equipment slot definitions + private static readonly int[] SlotOrder = + [ + FantasyEquipmentSlotTypes.MainHandSlot, + FantasyEquipmentSlotTypes.OffHandSlot, + FantasyEquipmentSlotTypes.HeadSlot, + FantasyEquipmentSlotTypes.UpperBodySlot, + FantasyEquipmentSlotTypes.LowerBodySlot, + FantasyEquipmentSlotTypes.FootSlot, + FantasyEquipmentSlotTypes.NeckSlot, + FantasyEquipmentSlotTypes.BackSlot, + FantasyEquipmentSlotTypes.WristSlot, + FantasyEquipmentSlotTypes.Ring1Slot, + FantasyEquipmentSlotTypes.Ring2Slot, + ]; + + private static readonly Dictionary SlotNames = new() + { + { FantasyEquipmentSlotTypes.MainHandSlot, "Main Hand" }, + { FantasyEquipmentSlotTypes.OffHandSlot, "Off Hand" }, + { FantasyEquipmentSlotTypes.HeadSlot, "Head" }, + { FantasyEquipmentSlotTypes.UpperBodySlot, "Body" }, + { FantasyEquipmentSlotTypes.LowerBodySlot, "Legs" }, + { FantasyEquipmentSlotTypes.FootSlot, "Feet" }, + { FantasyEquipmentSlotTypes.NeckSlot, "Neck" }, + { FantasyEquipmentSlotTypes.BackSlot, "Back" }, + { FantasyEquipmentSlotTypes.WristSlot, "Wrists" }, + { FantasyEquipmentSlotTypes.Ring1Slot, "Ring 1" }, + { FantasyEquipmentSlotTypes.Ring2Slot, "Ring 2" }, + }; + + public CharacterMenuScene( + IPersistentGameState gameState, + ISceneManager sceneManager, + IServiceProvider serviceProvider, + IGameServices gameServices, + IDataSource dataSource, + ILocaleDataSource localeDataSource, + IEquipmentSlotValidator slotValidator, + ICharacterPopulator characterPopulator) + { + _gameState = gameState; + _sceneManager = sceneManager; + _serviceProvider = serviceProvider; + _gameServices = gameServices; + _dataSource = dataSource; + _localeDataSource = localeDataSource; + _slotValidator = slotValidator; + _characterPopulator = characterPopulator; + _equipmentService = new EquipmentService(dataSource, localeDataSource, slotValidator, characterPopulator, gameState); + } + + public Task LoadAsync() + { + try + { + _loaded = false; + _gameState.InitializeParty(); + + var content = _gameServices.GetContentManager; + _font = content.Load("Fonts/KenneyPixel"); + _pixel = new Texture2D(_gameServices.GetSpriteBatch.GraphicsDevice, 1, 1); + _pixel.SetData([Color.White]); + + // Load sprites for display + foreach (var e in _gameState.Party) + { + try + { + var path = $"Sprites/Players/{e.AssetCode}"; + e.Sprite = content.Load(path); + } + catch + { + Console.WriteLine($"[CharacterMenu] No sprite for {e.AssetCode}"); + } + } + + ResetToPartySelect(); + _loaded = true; + } + catch (Exception ex) + { + Console.WriteLine($"[CharacterMenu] FAILED TO LOAD: {ex.Message}"); + Console.WriteLine(ex.StackTrace); + } + + return Task.CompletedTask; + } + + public void Unload() + { + _pixel?.Dispose(); + _pixel = null; + _font = null; + } + + public void Update(GameTime gameTime) + { + if (!_loaded) return; + + var currentKeyboard = Keyboard.GetState(); + + if (InputHelper.IsKeyJustPressed(currentKeyboard, _previousKeyboard, Keys.Up)) + { + _selectedIndex = Math.Max(0, _selectedIndex - 1); + } + else if (InputHelper.IsKeyJustPressed(currentKeyboard, _previousKeyboard, Keys.Down)) + { + _selectedIndex = Math.Min(GetItemCount() - 1, _selectedIndex + 1); + } + else if (InputHelper.IsKeyJustPressed(currentKeyboard, _previousKeyboard, Keys.Enter) || + InputHelper.IsKeyJustPressed(currentKeyboard, _previousKeyboard, Keys.Space)) + { + ConfirmSelection(); + } + else if (InputHelper.IsKeyJustPressed(currentKeyboard, _previousKeyboard, Keys.Escape) || + InputHelper.IsKeyJustPressed(currentKeyboard, _previousKeyboard, Keys.Back)) + { + GoBack(); + } + + _previousKeyboard = currentKeyboard; + } + + public void Draw(GameTime gameTime, SpriteBatch spriteBatch) + { + // World layer - nothing for character menu + } + + public void DrawUI(GameTime gameTime, SpriteBatch spriteBatch) + { + if (!_loaded) return; + + switch (_currentScreen) + { + case Screen.PartySelect: DrawPartySelect(spriteBatch); break; + case Screen.CharacterDetail: DrawCharacterDetail(spriteBatch); break; + case Screen.EquipmentSelect: DrawEquipmentSelect(spriteBatch); break; + case Screen.Inventory: DrawInventory(spriteBatch); break; + case Screen.ItemTargetSelect: DrawItemTargetSelect(spriteBatch); break; + } + + // Help text at bottom + TextHelper.DrawStringWithSpacing(spriteBatch, _font, + "Up/Down Navigate | Enter Select | Esc Back", + new Vector2(400, 575), Palette.UiHelpText, centered: true); + } + + // ======================================================================== + // Item Counts + // ======================================================================== + + private int GetItemCount() + { + return _currentScreen switch + { + Screen.PartySelect => _gameState.Party.Count + 2, // party members + Inventory + Proceed to Battle + Screen.CharacterDetail => SlotOrder.Length + 1, // equipment slots + Back + Screen.EquipmentSelect => GetEquipmentItemCount(), + Screen.Inventory => _gameState.SharedInventory.Count + 1, // items + Back + Screen.ItemTargetSelect => GetItemTargetSelectCount(), + _ => 0 + }; + } + + private int GetEquipmentItemCount() + { + var slots = _gameState.Party[_selectedCharacter].Entity.Variables.Equipment?.Slots; + var hasCurrent = slots?.Get(_browsingSlotType) != null; + return (hasCurrent ? 1 : 0) + _candidateItems.Count + 1; // [Unequip] + items + Back + } + + // ======================================================================== + // Party Select + // ======================================================================== + + private void ResetToPartySelect() + { + _currentScreen = Screen.PartySelect; + _selectedIndex = 0; + _selectedCharacter = 0; + _selectedSlotIndex = 0; + } + + private void DrawPartySelect(SpriteBatch sb) + { + var panelX = 20; + var panelW = 760; + var titleY = 8; + + TextHelper.DrawStringWithSpacing(sb, _font, "CHARACTER MENU", + new Vector2(400, titleY), Color.White, centered: true); + + var startY = 40; + var itemH = 70; + + UiHelper.DrawPanel(sb, _pixel, panelX, startY, panelW, _gameState.Party.Count * itemH + 60, Palette.PanelBg); + + for (var i = 0; i < _gameState.Party.Count; i++) + { + var entity = _gameState.Party[i]; + var y = startY + 6 + i * itemH; + var isSelected = i == _selectedIndex; + + if (isSelected) + UiHelper.DrawSelectionHighlight(sb, _pixel, panelX + 4, y - 2, panelW - 8, itemH - 4); + + // Sprite + if (entity.Sprite != null) + { + var destRect = new Rectangle(panelX + 14, y + 4, 40, 40); + sb.Draw(entity.Sprite, destRect, null, Color.White, 0, Vector2.Zero, + SpriteEffects.FlipHorizontally, 0); + } + + var textX = panelX + 64; + TextHelper.DrawStringWithSpacing(sb, _font, entity.Name, + new Vector2(textX, y + 2), Palette.PartyName); + + // Class info + var classTemplate = _dataSource.Get(entity.Entity.Variables.Class.TemplateId); + var className = classTemplate != null + ? _localeDataSource.Get("en-gb", classTemplate.NameLocaleId) + : "Unknown"; + TextHelper.DrawStringWithSpacing(sb, _font, className, + new Vector2(textX, y + 22), new Color(160, 160, 170)); + + if (!entity.IsAlive) + { + TextHelper.DrawStringWithSpacing(sb, _font, "DEFEATED", + new Vector2(panelW - 80, y + 14), Palette.HpRed); + continue; + } + + // HP + var hpRatio = (float)entity.Hp / entity.MaxHp; + var hpColor = Palette.RatioToColor(hpRatio); + var barW = 130f; + var barH = 8f; + var barX = panelW - 190; + var barY = y + 6; + + DrawRect(sb, (int)barX, (int)barY, (int)barW, (int)barH, Palette.HpBarBg); + DrawRect(sb, (int)barX, (int)barY, (int)(barW * hpRatio), (int)barH, hpColor); + TextHelper.DrawStringWithSpacing(sb, _font, $"HP {entity.Hp}/{entity.MaxHp}", + new Vector2(barX, barY + barH + 2), hpColor); + + // MP + var mpRatio = entity.MaxMana > 0 ? (float)entity.Mana / entity.MaxMana : 0; + var mpBarY = barY + barH + 28; + DrawRect(sb, (int)barX, (int)mpBarY, (int)barW, (int)barH, Palette.HpBarBg); + DrawRect(sb, (int)barX, (int)mpBarY, (int)(barW * mpRatio), (int)barH, Palette.MpText); + TextHelper.DrawStringWithSpacing(sb, _font, $"MP {entity.Mana}/{entity.MaxMana}", + new Vector2(barX, mpBarY + barH + 2), Palette.MpText); + } + + // Inventory button + var invY = startY + _gameState.Party.Count * itemH + 10; + var isInvSelected = _selectedIndex == _gameState.Party.Count; + + UiHelper.DrawButton(sb, _pixel, + panelX + 180, invY - 4, panelW - 360, 28, + isInvSelected, + fillColor: new Color(20, 25, 40), + selectedFillColor: new Color(40, 55, 80)); + + TextHelper.DrawStringWithSpacing(sb, _font, $">>> INVENTORY <<<", + new Vector2(400, invY + 4), + isInvSelected ? Color.LightBlue : new Color(120, 160, 200), centered: true); + + // Proceed to Battle button + var proceedY = invY + 36; + var isProceedSelected = _selectedIndex == _gameState.Party.Count + 1; + + UiHelper.DrawButton(sb, _pixel, + panelX + 180, proceedY - 4, panelW - 360, 34, + isProceedSelected, + fillColor: new Color(20, 45, 25), + selectedFillColor: new Color(40, 90, 50), + borderColor: new Color(30, 60, 35), + selectedBorderColor: new Color(80, 160, 100)); + + TextHelper.DrawStringWithSpacing(sb, _font, ">>> PROCEED TO BATTLE <<<", + new Vector2(400, proceedY + 4), + isProceedSelected ? Color.Lime : new Color(100, 200, 100), centered: true); + } + + // ======================================================================== + // Character Detail + // ======================================================================== + + private void DrawCharacterDetail(SpriteBatch sb) + { + var entity = _gameState.Party[_selectedCharacter]; + if (entity == null) return; + + TextHelper.DrawStringWithSpacing(sb, _font, $"CHARACTER: {entity.Name.ToUpper()}", + new Vector2(400, 8), Color.White, centered: true); + + // Background panel + var panelX = 20; + var panelY = 36; + var panelW = 760; + var panelH = 420; + + UiHelper.DrawPanel(sb, _pixel, panelX, panelY, panelW, panelH, Palette.PanelBg); + + // Left: sprite + basic info + var lx = panelX + 20; + + if (entity.Sprite != null) + { + var destRect = new Rectangle(lx, panelY + 10, 64, 48); + sb.Draw(entity.Sprite, destRect, null, Color.White, 0, Vector2.Zero, + SpriteEffects.FlipHorizontally, 0); + } + + var classTemplate = _dataSource.Get(entity.Entity.Variables.Class.TemplateId); + var className = classTemplate != null + ? _localeDataSource.Get("en-gb", classTemplate.NameLocaleId) + : "Unknown"; + + TextHelper.DrawStringWithSpacing(sb, _font, entity.Name, + new Vector2(lx, panelY + 64), Palette.PartyName); + TextHelper.DrawStringWithSpacing(sb, _font, $"{className} Lv.1", + new Vector2(lx, panelY + 84), new Color(160, 160, 170)); + + // Stats columns + var col1X = lx + 120; + var col2X = lx + 340; + var statY = panelY + 10; + + DrawStat(sb, col1X, statY, "HP", $"{entity.Hp}/{entity.MaxHp}", Palette.HpGreen); + DrawStat(sb, col2X, statY, "MP", $"{entity.Mana}/{entity.MaxMana}", Palette.MpText); + DrawStat(sb, col1X, statY + 20, "Attack", entity.AttackDamage.ToString(), Color.White); + DrawStat(sb, col2X, statY + 20, "Defense", ((int)entity.Entity.Stats.Defense).ToString(), Color.White); + DrawStat(sb, col1X, statY + 40, "Speed", entity.Initiative.ToString(), Color.White); + DrawStat(sb, col2X, statY + 40, "Range", ((int)entity.Entity.Stats.AttackRange).ToString(), Color.White); + DrawStat(sb, col1X, statY + 60, "Crit %", $"{entity.Entity.Stats.CriticalDamageChance:F1}%", Color.White); + DrawStat(sb, col2X, statY + 60, "Crit Mult", $"{entity.Entity.Stats.CriticalDamageMultiplier:F1}x", Color.White); + + // Attributes + var attrY = panelY + 100; + TextHelper.DrawStringWithSpacing(sb, _font, "ATTRIBUTES", + new Vector2(lx, attrY), Palette.UiSectionTitle); + DrawStat(sb, lx, attrY + 20, "STR", ((int)entity.Entity.Stats.Strength).ToString(), new Color(240, 180, 80)); + DrawStat(sb, lx + 140, attrY + 20, "DEX", ((int)entity.Entity.Stats.Dexterity).ToString(), new Color(80, 200, 80)); + DrawStat(sb, lx + 280, attrY + 20, "CON", ((int)entity.Entity.Stats.Constitution).ToString(), new Color(180, 120, 80)); + DrawStat(sb, lx, attrY + 40, "INT", ((int)entity.Entity.Stats.Intelligence).ToString(), new Color(80, 140, 240)); + DrawStat(sb, lx + 140, attrY + 40, "WIS", ((int)entity.Entity.Stats.Wisdom).ToString(), new Color(140, 180, 220)); + DrawStat(sb, lx + 280, attrY + 40, "CHA", ((int)entity.Entity.Stats.Charisma).ToString(), new Color(220, 140, 220)); + + // Equipment section (interactive items) + var equipY = panelY + 192; + TextHelper.DrawStringWithSpacing(sb, _font, "EQUIPMENT (select to change)", + new Vector2(lx, equipY), Palette.UiSectionTitle); + + var slots = entity.Entity.Variables.Equipment?.Slots; + for (var i = 0; i < SlotOrder.Length; i++) + { + var slotType = SlotOrder[i]; + var slotName = SlotNames.GetValueOrDefault(slotType, $"Slot {slotType}"); + var itemData = slots?.Get(slotType); + var itemName = "Empty"; + if (itemData != null) + { + var itemTemplate = _dataSource.Get(itemData.TemplateId); + itemName = itemTemplate != null + ? _localeDataSource.Get("en-gb", itemTemplate.NameLocaleId) + : $"Item #{itemData.TemplateId}"; + } + + var isSelected = i == _selectedIndex; + var slotColor = isSelected ? Color.Lime : new Color(180, 180, 190); + if (itemData == null && !isSelected) + slotColor = new Color(100, 100, 110); + + // Two columns for slots + var col = i < 6 ? lx : lx + 280; + var row = i < 6 ? i : i - 6; + var sy = equipY + 18 + row * 19; + + TextHelper.DrawStringWithSpacing(sb, _font, + $"{slotName}: {itemName}", new Vector2(col, sy), slotColor); + } + + // Back + var backY = panelY + panelH - 24; + var isBack = _selectedIndex == SlotOrder.Length; + TextHelper.DrawStringWithSpacing(sb, _font, "[ Back ]", + new Vector2(lx, backY), isBack ? Color.White : Palette.MenuBackColor); + } + + // ======================================================================== + // Equipment Select + // ======================================================================== + + private void RefreshCandidates() + { + _candidateItems = _equipmentService.GetCandidateItems(_browsingSlotType); + } + + private void DrawEquipmentSelect(SpriteBatch sb) + { + var entity = _gameState.Party[_selectedCharacter]; + var slotName = SlotNames.GetValueOrDefault(_browsingSlotType, "Unknown Slot"); + + TextHelper.DrawStringWithSpacing(sb, _font, + $"EQUIP: {entity.Name.ToUpper()} - {slotName}", + new Vector2(400, 8), Color.White, centered: true); + + var panelX = 80; + var panelY = 40; + var panelW = 640; + var panelH = 440; + + UiHelper.DrawPanel(sb, _pixel, panelX, panelY, panelW, panelH, Palette.PanelBg); + UiHelper.DrawTitleBar(sb, _pixel, panelX + 1, panelY + 1, panelW - 2, 20); + + TextHelper.DrawStringWithSpacing(sb, _font, + "Select an item to equip:", + new Vector2(panelX + 20, panelY + 24), Palette.UiTextBody); + + var slots = entity.Entity.Variables.Equipment?.Slots; + var currentItem = slots?.Get(_browsingSlotType); + var hasCurrent = currentItem != null; + + var y = panelY + 40; + var idx = 0; + + // Unequip option if slot is occupied + if (hasCurrent) + { + var isSel = _selectedIndex == idx; + var unequipColor = isSel ? new Color(255, 200, 100) : new Color(180, 160, 100); + TextHelper.DrawStringWithSpacing(sb, _font, + $"[Unequip] ({GetItemName(currentItem)})", + new Vector2(panelX + 20, y), unequipColor); + y += 26; + idx++; + } + + // Available items from inventory + if (_candidateItems.Count == 0) + { + TextHelper.DrawStringWithSpacing(sb, _font, + "(No compatible items in inventory)", + new Vector2(panelX + 20, y), new Color(100, 100, 110)); + y += 26; + idx++; + } + else + { + foreach (var (itemData, template) in _candidateItems) + { + var isSel = _selectedIndex == idx; + var itemName = _localeDataSource.Get("en-gb", template.NameLocaleId); + + // Check for stat bonuses + var bonusText = GetItemBonusText(template); + + var textColor = isSel ? Color.Lime : new Color(180, 180, 190); + TextHelper.DrawStringWithSpacing(sb, _font, + $"{itemName}{bonusText}", + new Vector2(panelX + 20, y), textColor); + y += 26; + idx++; + } + } + + // Back + var backY = panelY + panelH - 28; + var isBack = _selectedIndex == idx; + TextHelper.DrawStringWithSpacing(sb, _font, "[ Back ]", + new Vector2(panelX + 20, backY), + isBack ? Color.White : Palette.MenuBackColor); + } + + private string GetItemBonusText(ItemTemplate template) + { + return _equipmentService.GetItemBonusText(template); + } + + // ======================================================================== + // Inventory Screen + // ======================================================================== + + private void DrawInventory(SpriteBatch sb) + { + TextHelper.DrawStringWithSpacing(sb, _font, "SHARED INVENTORY", + new Vector2(400, 8), Color.White, centered: true); + + var panelX = 40; + var panelY = 36; + var panelW = 720; + var panelH = 440; + + UiHelper.DrawPanel(sb, _pixel, panelX, panelY, panelW, panelH, Palette.PanelBg); + UiHelper.DrawTitleBar(sb, _pixel, panelX + 1, panelY + 1, panelW - 2, 20); + + var items = _gameState.SharedInventory; + if (items.Count == 0) + { + TextHelper.DrawStringWithSpacing(sb, _font, "(No items in inventory)", + new Vector2(panelX + 20, panelY + 20), new Color(100, 100, 110)); + } + else + { + var y = panelY + 12; + for (var i = 0; i < items.Count; i++) + { + var itemData = items[i]; + var template = _dataSource.Get(itemData.TemplateId); + var name = template != null + ? _localeDataSource.Get("en-gb", template.NameLocaleId) + : $"Item #{itemData.TemplateId}"; + + var isSelected = i == _selectedIndex; + if (isSelected) + UiHelper.DrawSelectionHighlight(sb, _pixel, panelX + 4, y - 2, panelW - 8, 22); + + var baseColor = isSelected ? Color.White : new Color(180, 180, 190); + + // Determine item type label + var typeLabel = template != null ? GetItemTypeLabel(template.ItemType) : ""; + + TextHelper.DrawStringWithSpacing(sb, _font, name, + new Vector2(panelX + 16, y), baseColor); + + if (!string.IsNullOrEmpty(typeLabel)) + { + TextHelper.DrawStringWithSpacing(sb, _font, typeLabel, + new Vector2(panelX + panelW - 140, y), new Color(140, 140, 150)); + } + + y += 24; + } + + // Show selected item's stats/bonuses at bottom + if (_selectedIndex < items.Count) + { + var template = _dataSource.Get(items[_selectedIndex].TemplateId); + if (template != null) + { + var bonusText = GetItemBonusText(template); + var desc = _localeDataSource.Get("en-gb", template.DescriptionLocaleId); + if (!string.IsNullOrEmpty(desc)) + { + TextHelper.DrawStringWithSpacing(sb, _font, desc, + new Vector2(panelX + 16, panelY + panelH - 48), new Color(160, 160, 170)); + } + + if (!string.IsNullOrEmpty(bonusText)) + { + TextHelper.DrawStringWithSpacing(sb, _font, bonusText, + new Vector2(panelX + 16, panelY + panelH - 28), new Color(200, 200, 150)); + } + } + } + } + + // Back + var backY = panelY + panelH - 8; + var isBack = _selectedIndex == items.Count; + TextHelper.DrawStringWithSpacing(sb, _font, "[ Back ]", + new Vector2(panelX + 16, backY), + isBack ? Color.White : Palette.MenuBackColor); + } + + private static string GetItemTypeLabel(int itemType) + { + return itemType switch + { + BattlerConstants.ItemTypes.Weapon => "Weapon", + BattlerConstants.ItemTypes.Head => "Head", + BattlerConstants.ItemTypes.Body => "Body", + BattlerConstants.ItemTypes.Legs => "Legs", + BattlerConstants.ItemTypes.Back => "Back", + BattlerConstants.ItemTypes.Feet => "Feet", + BattlerConstants.ItemTypes.Wrist => "Wrist", + BattlerConstants.ItemTypes.Neck => "Neck", + BattlerConstants.ItemTypes.Ring => "Ring", + BattlerConstants.ItemTypes.OffHand => "Off Hand", + BattlerConstants.ItemTypes.Consumable => "Consumable", + _ => "" + }; + } + + // ======================================================================== + // Item Target Select (item usage in character menu) + // ======================================================================== + + private void DrawItemTargetSelect(SpriteBatch sb) + { + var itemName = _itemToUseTemplate != null + ? _localeDataSource.Get("en-gb", _itemToUseTemplate.NameLocaleId) + : "Item"; + + var isLifeRestore = _itemToUseTemplate != null && HasLifeRestoreEffect(_itemToUseTemplate); + + TextHelper.DrawStringWithSpacing(sb, _font, + $"USE {itemName.ToUpper()} ON WHICH MEMBER?", + new Vector2(400, 8), Color.White, centered: true); + + var panelX = 120; + var panelY = 40; + var panelW = 560; + var panelH = 400; + + UiHelper.DrawPanel(sb, _pixel, panelX, panelY, panelW, panelH, Palette.PanelBg); + UiHelper.DrawTitleBar(sb, _pixel, panelX + 1, panelY + 1, panelW - 2, 20); + + var y = panelY + 16; + var idx = 0; + + foreach (var member in _gameState.Party) + { + if (isLifeRestore ? member.IsAlive : !member.IsAlive) + { + // For heal items: skip dead members; for life-restore: skip alive members + continue; + } + + var isSelected = _selectedIndex == idx; + if (isSelected) + UiHelper.DrawSelectionHighlight(sb, _pixel, panelX + 6, y - 2, panelW - 12, 24); + + var displayName = member.Name; + var hpMpText = !member.IsAlive + ? "(Dead)" + : $"(HP: {member.Hp}/{member.MaxHp} MP: {member.Mana}/{member.MaxMana})"; + + var color = isSelected + ? Color.White + : member.IsAlive + ? new Color(180, 180, 190) + : Color.Gray; + + TextHelper.DrawStringWithSpacing(sb, _font, + $"{displayName} {hpMpText}", + new Vector2(panelX + 20, y), color); + + y += 28; + idx++; + } + + // Back + var backY = panelY + panelH - 28; + var isBack = _selectedIndex == idx; + TextHelper.DrawStringWithSpacing(sb, _font, "[ Back ]", + new Vector2(panelX + 20, backY), + isBack ? Color.White : Palette.MenuBackColor); + } + + // ======================================================================== + // Input Handlers + // ======================================================================== + + private void ConfirmSelection() + { + switch (_currentScreen) + { + case Screen.PartySelect: + HandlePartySelectConfirm(); + break; + case Screen.CharacterDetail: + HandleCharacterDetailConfirm(); + break; + case Screen.EquipmentSelect: + HandleEquipmentConfirm(); + break; + case Screen.Inventory: + HandleInventoryConfirm(); + break; + case Screen.ItemTargetSelect: + HandleItemTargetConfirm(); + break; + } + } + + private void HandlePartySelectConfirm() + { + if (_selectedIndex < _gameState.Party.Count) + { + _selectedCharacter = _selectedIndex; + _selectedIndex = 0; + _currentScreen = Screen.CharacterDetail; + } + else if (_selectedIndex == _gameState.Party.Count) + { + // Inventory button + _selectedIndex = 0; + _currentScreen = Screen.Inventory; + } + else + { + ProceedToBattle(); + } + } + + private void HandleCharacterDetailConfirm() + { + if (_selectedIndex < SlotOrder.Length) + { + // Start equipment selection for this slot + _selectedSlotIndex = _selectedIndex; + _browsingSlotType = SlotOrder[_selectedSlotIndex]; + _selectedIndex = 0; + + RefreshCandidates(); + _currentScreen = Screen.EquipmentSelect; + } + else + { + // Back + ResetToPartySelect(); + } + } + + private void HandleEquipmentConfirm() + { + var entity = _gameState.Party[_selectedCharacter]; + var slots = entity.Entity.Variables.Equipment?.Slots; + if (slots == null) return; + + var currentItem = slots.Get(_browsingSlotType); + var hasCurrent = currentItem != null; + var idx = 0; + + // Unequip? + if (hasCurrent) + { + if (_selectedIndex == idx) + { + _equipmentService.UnequipItem(entity, _browsingSlotType); + _selectedIndex = 0; + RefreshCandidates(); + return; + } + idx++; + } + + // Inventory items? + if (_selectedIndex < idx + _candidateItems.Count) + { + var itemIndex = _selectedIndex - idx; + var (itemData, _) = _candidateItems[itemIndex]; + + _equipmentService.EquipItem(entity, _browsingSlotType, itemData); + + // Return to character detail after equipping + _selectedIndex = _selectedSlotIndex; + _currentScreen = Screen.CharacterDetail; + return; + } + + // Back to character detail + _selectedIndex = _selectedSlotIndex; + _currentScreen = Screen.CharacterDetail; + } + + private void HandleInventoryConfirm() + { + var itemCount = _gameState.SharedInventory.Count; + if (_selectedIndex == itemCount) + { + // Back to party select + _selectedIndex = _gameState.Party.Count; + _currentScreen = Screen.PartySelect; + return; + } + + // Selecting a consumable item -> choose a target to use it on + var itemData = _gameState.SharedInventory[_selectedIndex]; + var template = _dataSource.Get(itemData.TemplateId); + if (template != null && template.ItemType == BattlerConstants.ItemTypes.Consumable) + { + _itemToUseData = itemData; + _itemToUseTemplate = template; + _selectedIndex = 0; + _currentScreen = Screen.ItemTargetSelect; + } + // Non-consumable items are just viewing info (no action needed) + } + + private void HandleItemTargetConfirm() + { + var isLifeRestore = _itemToUseTemplate != null && HasLifeRestoreEffect(_itemToUseTemplate); + var targetPool = isLifeRestore + ? _gameState.Party.Where(e => !e.IsAlive).ToList() + : _gameState.Party.Where(e => e.IsAlive).ToList(); + + if (_selectedIndex >= targetPool.Count) + { + // Back to inventory + _selectedIndex = _gameState.SharedInventory.IndexOf(_itemToUseData); + if (_selectedIndex < 0) _selectedIndex = 0; + _currentScreen = Screen.Inventory; + return; + } + + if (_itemToUseData == null || _itemToUseTemplate == null) return; + + var target = targetPool[_selectedIndex]; + + // Apply item effects to the target + ApplyItemToTarget(target); + + // Remove the item from inventory + var invIndex = _gameState.SharedInventory.FindIndex(i => i.TemplateId == _itemToUseData.TemplateId); + if (invIndex >= 0) + _gameState.SharedInventory.RemoveAt(invIndex); + + // Return to inventory + _selectedIndex = Math.Min(invIndex >= 0 ? invIndex : 0, _gameState.SharedInventory.Count); + _currentScreen = Screen.Inventory; + _itemToUseData = null; + _itemToUseTemplate = null; + } + + private void ApplyItemToTarget(BattleEntity target) + { + if (_itemToUseTemplate == null) return; + var applier = new ItemEffectApplier(); + applier.ApplyItemEffects(_itemToUseData, target, _itemToUseTemplate); + } + + private static bool HasLifeRestoreEffect(ItemTemplate template) + { + return ItemEffectApplier.HasLifeRestoreEffect(template); + } + + private int GetItemTargetSelectCount() + { + var isLifeRestore = _itemToUseTemplate != null && HasLifeRestoreEffect(_itemToUseTemplate); + if (isLifeRestore) + return _gameState.Party.Count(e => !e.IsAlive) + 1; // dead members + Back + return _gameState.Party.Count(e => e.IsAlive) + 1; // alive members + Back + } + + private void GoBack() + { + switch (_currentScreen) + { + case Screen.PartySelect: + // At top level - Escape exits the game + _sceneManager.RequestExit?.Invoke(); + break; + case Screen.CharacterDetail: + ResetToPartySelect(); + break; + case Screen.EquipmentSelect: + _selectedIndex = _selectedSlotIndex; + _currentScreen = Screen.CharacterDetail; + break; + case Screen.Inventory: + _selectedIndex = _gameState.Party.Count; + _currentScreen = Screen.PartySelect; + break; + case Screen.ItemTargetSelect: + _selectedIndex = _gameState.SharedInventory.IndexOf(_itemToUseData); + if (_selectedIndex < 0) _selectedIndex = 0; + _currentScreen = Screen.Inventory; + _itemToUseData = null; + _itemToUseTemplate = null; + break; + } + } + + private void ProceedToBattle() + { + var battleScene = _serviceProvider.GetRequiredService(); + _ = _sceneManager.SetScene(battleScene); + } + + // Simple filled rect for HP/MP bars and small elements + private void DrawRect(SpriteBatch sb, int x, int y, int w, int h, Color color) + { + if (_pixel != null) + sb.Draw(_pixel, new Rectangle(x, y, w, h), color); + } + + private void DrawStat(SpriteBatch sb, int x, int y, string label, string value, Color valueColor) + { + TextHelper.DrawStringWithSpacing(sb, _font, $"{label}:", + new Vector2(x, y), Palette.UiTextLabel); + var labelW = TextHelper.MeasureStringWidth(_font, $"{label}:"); + TextHelper.DrawStringWithSpacing(sb, _font, value, + new Vector2(x + labelW + 6, y), valueColor); + } + + private string GetItemName(ItemData itemData) + { + return _equipmentService.GetItemName(itemData); + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/CharacterMenu/EquipmentService.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/CharacterMenu/EquipmentService.cs new file mode 100644 index 00000000..840b2b17 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/CharacterMenu/EquipmentService.cs @@ -0,0 +1,115 @@ +using System.Collections.Generic; +using System.Linq; +using OpenRpg.Data; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Demos.Battler.Code.Services.Game; +using OpenRpg.Entities.Extensions; +using OpenRpg.Genres.Populators.Entity; +using OpenRpg.Items.Equippables.Slots; +using OpenRpg.Items.Extensions; +using OpenRpg.Items.Templates; +using OpenRpg.Localization.Data.DataSources; + +namespace OpenRpg.Demos.Battler.Code.Scenes.CharacterMenu; + +/// +/// Handles equipment-related operations: equipping, unequipping, and candidate filtering. +/// Extracted from CharacterMenuScene for testability and separation of concerns. +/// +public class EquipmentService +{ + private readonly IDataSource _dataSource; + private readonly ILocaleDataSource _localeDataSource; + private readonly IEquipmentSlotValidator _slotValidator; + private readonly ICharacterPopulator _characterPopulator; + private readonly IPersistentGameState _gameState; + + public EquipmentService( + IDataSource dataSource, + ILocaleDataSource localeDataSource, + IEquipmentSlotValidator slotValidator, + ICharacterPopulator characterPopulator, + IPersistentGameState gameState) + { + _dataSource = dataSource; + _localeDataSource = localeDataSource; + _slotValidator = slotValidator; + _characterPopulator = characterPopulator; + _gameState = gameState; + } + + public List<(ItemData Data, ItemTemplate Template)> GetCandidateItems(int slotType) + { + var candidates = new List<(ItemData, ItemTemplate)>(); + foreach (var itemData in _gameState.SharedInventory) + { + var template = _dataSource.Get(itemData.TemplateId); + if (template == null) continue; + if (_slotValidator.CanEquipItemType(slotType, template.ItemType)) + candidates.Add((itemData, template)); + } + return candidates; + } + + public void EquipItem(BattleEntity character, int slotType, ItemData newItem) + { + var slots = character.Entity.Variables.Equipment?.Slots; + if (slots == null) return; + + var currentItem = slots.Get(slotType); + + // Remove new item from inventory + _gameState.SharedInventory.Remove(newItem); + + // Add old item back to inventory if one was equipped + if (currentItem != null) + _gameState.SharedInventory.Add(currentItem); + + // Equip the new item + slots[slotType] = newItem; + _characterPopulator.Populate(character.Entity, refreshState: false); + } + + public void UnequipItem(BattleEntity character, int slotType) + { + var slots = character.Entity.Variables.Equipment?.Slots; + if (slots == null) return; + + var currentItem = slots.Get(slotType); + if (currentItem == null) return; + + _gameState.SharedInventory.Add(currentItem); + slots[slotType] = null; + _characterPopulator.Populate(character.Entity, refreshState: false); + } + + public string GetItemName(ItemData itemData) + { + var template = _dataSource.Get(itemData.TemplateId); + return template != null + ? _localeDataSource.Get("en-gb", template.NameLocaleId) + : $"Item #{itemData.TemplateId}"; + } + + public string GetItemBonusText(ItemTemplate template) + { + var parts = new List(); + if (template.Variables.Effects == null) return ""; + + foreach (var effect in template.Variables.Effects) + { + if (effect is not OpenRpg.Core.Effects.StaticEffect se) continue; + if (se.EffectType == Types.BattlerConstants.EffectTypes.DamageBonus) + parts.Add($" ATK+{se.Potency}"); + else if (se.EffectType == Types.BattlerConstants.EffectTypes.DefenseBonus) + parts.Add($" DEF+{se.Potency}"); + else if (se.EffectType == Types.BattlerConstants.EffectTypes.HealthBonus) + parts.Add($" HP+{se.Potency}"); + else if (se.EffectType == Types.BattlerConstants.EffectTypes.MovementSpeedBonus) + parts.Add($" SPD+{se.Potency}"); + else if (se.EffectType == Types.BattlerConstants.EffectTypes.UnarmedDamageBonus) + parts.Add($" ATK+{se.Potency}"); + } + return parts.Count > 0 ? string.Join("", parts) : ""; + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/IScene.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/IScene.cs new file mode 100644 index 00000000..e5b23822 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/IScene.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace OpenRpg.Demos.Battler.Code.Scenes; + +public interface IScene +{ + Task LoadAsync(); + void Unload(); + void Update(GameTime gameTime); + void Draw(GameTime gameTime, SpriteBatch spriteBatch); + void DrawUI(GameTime gameTime, SpriteBatch spriteBatch); +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/ISceneManager.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/ISceneManager.cs new file mode 100644 index 00000000..6291e37c --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/ISceneManager.cs @@ -0,0 +1,17 @@ +#nullable enable +using System; +using System.Threading.Tasks; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace OpenRpg.Demos.Battler.Code.Scenes; + +public interface ISceneManager +{ + IScene? ActiveScene { get; } + Task SetScene(IScene scene); + void Update(GameTime gameTime); + void Draw(GameTime gameTime, SpriteBatch spriteBatch); + void DrawUI(GameTime gameTime, SpriteBatch spriteBatch); + Action? RequestExit { get; set; } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/PartyCreate/PartyCreateScene.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/PartyCreate/PartyCreateScene.cs new file mode 100644 index 00000000..aafc84ab --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/PartyCreate/PartyCreateScene.cs @@ -0,0 +1,507 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using OpenRpg.Data; +using OpenRpg.Demos.Battler.Code.Builders; +using OpenRpg.Demos.Battler.Code.Scenes; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Rendering; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.UI; +using OpenRpg.Demos.Battler.Code.Scenes.CharacterMenu; +using OpenRpg.Demos.Battler.Code.Services.Game; +using OpenRpg.Demos.Battler.Code.Types; +using OpenRpg.Entities.Classes.Templates; +using OpenRpg.Entities.Extensions; +using OpenRpg.Genres.Extensions; +using OpenRpg.Genres.Fantasy.Extensions; +using OpenRpg.Localization.Data.DataSources; + +namespace OpenRpg.Demos.Battler.Code.Scenes.PartyCreate; + +public class PartyCreateScene : IScene +{ + private readonly IDataSource _dataSource; + private readonly ILocaleDataSource _localeDataSource; + private readonly GameCharacterBuilder _characterBuilder; + private readonly ISceneManager _sceneManager; + private readonly IServiceProvider _serviceProvider; + private readonly IPersistentGameState _gameState; + private readonly IGameServices _gameServices; + + private bool _loaded; + private SpriteFont _font; + private Texture2D _pixel; + private KeyboardState _previousKeyboard; + + // Pre-computed stat previews for all 5 classes + private readonly List _classPreviews = []; + + // The classes the player has chosen (in order) + private readonly List _chosenClassIds = []; + + // Which class in the available list has focus (index into AllClassIds order) + private int _selectedClassIndex; + + private class ClassPreviewInfo + { + public int ClassId; + public string Name; + public string Description; + public string AssetCode; + public int MaxHp; + public int Attack; + public int Defense; + public int Speed; + public int MaxMana; + public int Strength; + public int Dexterity; + public int Constitution; + public int Intelligence; + public int Wisdom; + public int Charisma; + } + + public PartyCreateScene( + IDataSource dataSource, + ILocaleDataSource localeDataSource, + GameCharacterBuilder characterBuilder, + ISceneManager sceneManager, + IServiceProvider serviceProvider, + IPersistentGameState gameState, + IGameServices gameServices) + { + _dataSource = dataSource; + _localeDataSource = localeDataSource; + _characterBuilder = characterBuilder; + _sceneManager = sceneManager; + _serviceProvider = serviceProvider; + _gameState = gameState; + _gameServices = gameServices; + } + + public Task LoadAsync() + { + try + { + _loaded = false; + _chosenClassIds.Clear(); + _selectedClassIndex = 0; + + var content = _gameServices.GetContentManager; + _font = content.Load("Fonts/KenneyPixel"); + _pixel = new Texture2D(_gameServices.GetSpriteBatch.GraphicsDevice, 1, 1); + _pixel.SetData([Color.White]); + + // Pre-build characters for each class to extract real stats + _classPreviews.Clear(); + foreach (var classId in ClassLookups.AllClassIds) + { + var template = _dataSource.Get(classId); + if (template == null) continue; + + var name = _localeDataSource.Get("en-gb", template.NameLocaleId); + var desc = _localeDataSource.Get("en-gb", template.DescriptionLocaleId); + + var character = _characterBuilder + .CreateNew() + .WithRaceId(RaceLookups.Human) + .WithClassId(classId, 1) + .WithName(name) + .Build(); + + var assetCode = character.Variables.AssetCode; + + _classPreviews.Add(new ClassPreviewInfo + { + ClassId = classId, + Name = name, + Description = desc, + AssetCode = assetCode, + MaxHp = character.Stats.MaxHealth, + Attack = (int)character.Stats.Damage, + Defense = (int)character.Stats.Defense, + Speed = (int)character.Stats.MovementSpeed, + MaxMana = (int)character.Stats.MaxMana, + Strength = (int)character.Stats.Strength, + Dexterity = (int)character.Stats.Dexterity, + Constitution = (int)character.Stats.Constitution, + Intelligence = (int)character.Stats.Intelligence, + Wisdom = (int)character.Stats.Wisdom, + Charisma = (int)character.Stats.Charisma + }); + } + + _loaded = true; + } + catch (Exception ex) + { + Console.WriteLine($"[PartyCreate] FAILED TO LOAD: {ex.Message}"); + Console.WriteLine(ex.StackTrace); + } + + return Task.CompletedTask; + } + + public void Unload() + { + _pixel?.Dispose(); + _pixel = null; + _font = null; + } + + public void Update(GameTime gameTime) + { + if (!_loaded) return; + + var currentKeyboard = Keyboard.GetState(); + + var isPartyFull = _chosenClassIds.Count >= 4; + // When full: classes (0-4) + Proceed (5) + Random (6) + // When not full: classes (0-4) + Random (5) + var randomIndex = ClassLookups.AllClassIds.Length + (isPartyFull ? 1 : 0); + var proceedIndex = ClassLookups.AllClassIds.Length; + var maxIndex = randomIndex; + + if (InputHelper.IsKeyJustPressed(currentKeyboard, _previousKeyboard, Keys.Up)) + { + if (_selectedClassIndex == randomIndex) + { + // Random → Proceed (if full) or last class + _selectedClassIndex = isPartyFull ? proceedIndex : ClassLookups.AllClassIds.Length - 1; + } + else if (isPartyFull && _selectedClassIndex == proceedIndex) + { + _selectedClassIndex = ClassLookups.AllClassIds.Length - 1; + } + else + { + _selectedClassIndex = Math.Max(0, _selectedClassIndex - 1); + } + } + else if (InputHelper.IsKeyJustPressed(currentKeyboard, _previousKeyboard, Keys.Down)) + { + if (_selectedClassIndex < ClassLookups.AllClassIds.Length - 1) + { + _selectedClassIndex = _selectedClassIndex + 1; + } + else if (_selectedClassIndex == ClassLookups.AllClassIds.Length - 1) + { + // Last class → Proceed (if full) or Random + _selectedClassIndex = isPartyFull ? proceedIndex : randomIndex; + } + else if (isPartyFull && _selectedClassIndex == proceedIndex) + { + _selectedClassIndex = randomIndex; + } + else + { + _selectedClassIndex = Math.Min(maxIndex, _selectedClassIndex + 1); + } + } + else if (InputHelper.IsKeyJustPressed(currentKeyboard, _previousKeyboard, Keys.Enter) || + InputHelper.IsKeyJustPressed(currentKeyboard, _previousKeyboard, Keys.Space)) + { + if (_selectedClassIndex == randomIndex) + { + // Random party — fill remaining slots randomly + FillRemainingRandomly(); + FinalizeParty(); + } + else if (isPartyFull && _selectedClassIndex == proceedIndex) + { + // Proceed with current party + FinalizeParty(); + } + else + { + // Try to add the selected class to the party + var classId = GetSelectedClassId(); + if (classId > 0 && !_chosenClassIds.Contains(classId)) + { + _chosenClassIds.Add(classId); + _selectedClassIndex = 0; + } + } + } + else if (InputHelper.IsKeyJustPressed(currentKeyboard, _previousKeyboard, Keys.Escape) || + InputHelper.IsKeyJustPressed(currentKeyboard, _previousKeyboard, Keys.Back)) + { + if (_chosenClassIds.Count > 0) + { + // Undo the last selection + _chosenClassIds.RemoveAt(_chosenClassIds.Count - 1); + if (_selectedClassIndex > ClassLookups.AllClassIds.Length - 1) + _selectedClassIndex = ClassLookups.AllClassIds.Length - 1; + } + } + + _previousKeyboard = currentKeyboard; + } + + public void Draw(GameTime gameTime, SpriteBatch spriteBatch) + { + // World layer - nothing for party creation + } + + public void DrawUI(GameTime gameTime, SpriteBatch spriteBatch) + { + if (!_loaded) return; + + TextHelper.DrawStringWithSpacing(spriteBatch, _font, "CREATE YOUR PARTY", + new Vector2(400, 8), Color.White, centered: true); + + var isPartyFull = _chosenClassIds.Count >= BattlerConstants.MaxPartySize; + var panelY = 36; + var panelH = 275; + + DrawAvailableClassesPanel(spriteBatch, panelY, panelH, isPartyFull); + DrawYourPartyPanel(spriteBatch, panelY, panelH, isPartyFull); + DrawStatsPreviewPanel(spriteBatch, panelY + panelH + 15); + + var helpY = 565; + var helpText = isPartyFull + ? "Up/Down Navigate | Enter Proceed | Back Undo" + : "Up/Down Browse | Enter Select / Random | Back Undo"; + TextHelper.DrawStringWithSpacing(spriteBatch, _font, + helpText, + new Vector2(400, helpY), Palette.UiHelpText, centered: true); + } + + private void DrawAvailableClassesPanel(SpriteBatch sb, int panelY, int panelH, bool isPartyFull) + { + var leftX = 20; + var leftW = 370; + + UiHelper.DrawPanel(sb, _pixel, leftX, panelY, leftW, panelH, Palette.PanelBg); + UiHelper.DrawTitleBar(sb, _pixel, leftX + 1, panelY + 1, leftW - 2, 20); + + TextHelper.DrawStringWithSpacing(sb, _font, "AVAILABLE CLASSES", + new Vector2(leftX + 12, panelY + 4), Palette.UiSectionTitle); + + var listY = panelY + 28; + for (var i = 0; i < _classPreviews.Count; i++) + { + var info = _classPreviews[i]; + var isSelected = i == _selectedClassIndex && !isPartyFull; + var isChosen = _chosenClassIds.Contains(info.ClassId); + + if (isSelected) + UiHelper.DrawSelectionHighlight(sb, _pixel, leftX + 4, listY - 2, leftW - 8, 22); + + var prefix = isSelected ? "> " : " "; + var suffix = isChosen ? " (selected)" : ""; + + var textColor = isChosen + ? new Color(100, 160, 100) + : isSelected + ? Color.Lime + : new Color(180, 180, 190); + + TextHelper.DrawStringWithSpacing(sb, _font, + $"{prefix}{info.Name}{suffix}", + new Vector2(leftX + 16, listY), textColor); + + listY += 24; + } + + var selectedInfo = GetSelectedPreview(); + if (selectedInfo != null) + { + TextHelper.DrawStringWithSpacing(sb, _font, + selectedInfo.Description, + new Vector2(leftX + 12, panelY + panelH - 50), + Palette.UiTextLabel); + + var spriteLabel = $"[Sprite: {selectedInfo.AssetCode}]"; + TextHelper.DrawStringWithSpacing(sb, _font, + spriteLabel, + new Vector2(leftX + 12, panelY + panelH - 28), + Palette.UiTextLabel * 0.7f); + } + } + + private void DrawYourPartyPanel(SpriteBatch sb, int panelY, int panelH, bool isPartyFull) + { + var rightX = 410; + var rightW = 370; + + UiHelper.DrawPanel(sb, _pixel, rightX, panelY, rightW, panelH, Palette.PanelBg); + UiHelper.DrawTitleBar(sb, _pixel, rightX + 1, panelY + 1, rightW - 2, 20); + + TextHelper.DrawStringWithSpacing(sb, _font, "YOUR PARTY", + new Vector2(rightX + 12, panelY + 4), Palette.UiSectionTitle); + + var slotY = panelY + 28; + for (var i = 0; i < BattlerConstants.MaxPartySize; i++) + { + var isFilled = i < _chosenClassIds.Count; + var slotText = isFilled ? GetClassName(_chosenClassIds[i]) : $"---"; + + var slotColor = isFilled + ? new Color(180, 230, 180) + : new Color(80, 80, 90); + + TextHelper.DrawStringWithSpacing(sb, _font, + $"{i + 1}. {slotText}", + new Vector2(rightX + 20, slotY), slotColor); + + slotY += 28; + } + + var statusY = panelY + 148; + if (_chosenClassIds.Count > 0) + { + TextHelper.DrawStringWithSpacing(sb, _font, + $"[{_chosenClassIds.Count}/{BattlerConstants.MaxPartySize} selected]", + new Vector2(rightX + 12, statusY), + new Color(160, 200, 160)); + } + + var buttonStartY = statusY + 20; + if (isPartyFull) + { + var proceedY = buttonStartY; + var isProceedSel = _selectedClassIndex == ClassLookups.AllClassIds.Length; + + UiHelper.DrawButton(sb, _pixel, + rightX + 20, proceedY - 4, rightW - 40, 30, + isProceedSel, + fillColor: new Color(20, 45, 25), + selectedFillColor: new Color(40, 90, 50), + borderColor: new Color(30, 60, 35), + selectedBorderColor: new Color(80, 160, 100)); + + TextHelper.DrawStringWithSpacing(sb, _font, + ">>> PROCEED <<<", + new Vector2(rightX + rightW / 2, proceedY + 2), + isProceedSel ? Color.Lime : new Color(100, 200, 100), + centered: true); + + buttonStartY = proceedY + 36; + } + + var randomSelIndex = isPartyFull + ? ClassLookups.AllClassIds.Length + 1 + : ClassLookups.AllClassIds.Length; + var isRandomSel = _selectedClassIndex == randomSelIndex; + + UiHelper.DrawButton(sb, _pixel, + rightX + 20, buttonStartY - 4, rightW - 40, 30, + isRandomSel); + + TextHelper.DrawStringWithSpacing(sb, _font, + _chosenClassIds.Count > 0 ? "[ Random Party ]" : ">>> RANDOM PARTY <<<", + new Vector2(rightX + rightW / 2, buttonStartY + 2), + isRandomSel ? Color.LightBlue : new Color(130, 140, 160), + centered: true); + } + + private void DrawStatsPreviewPanel(SpriteBatch sb, int statsY) + { + var leftX = 20; + var statsH = 190; + var selectedInfo = GetSelectedPreview(); + + UiHelper.DrawPanel(sb, _pixel, leftX, statsY, 760, statsH, Palette.PanelBg); + UiHelper.DrawTitleBar(sb, _pixel, leftX + 1, statsY + 1, 758, 20); + + TextHelper.DrawStringWithSpacing(sb, _font, + $"{selectedInfo?.Name ?? "?"} STATS", + new Vector2(leftX + 12, statsY + 4), Palette.UiSectionTitle); + + if (selectedInfo != null) + { + var col1X = leftX + 20; + var col2X = leftX + 280; + var col3X = leftX + 540; + var statRowY = statsY + 30; + + DrawStat(sb, col1X, statRowY, "HP", selectedInfo.MaxHp.ToString(), Palette.HpGreen); + DrawStat(sb, col2X, statRowY, "MP", selectedInfo.MaxMana.ToString(), Palette.MpText); + DrawStat(sb, col3X, statRowY, "Attack", selectedInfo.Attack.ToString(), Color.White); + + DrawStat(sb, col1X, statRowY + 24, "Defense", selectedInfo.Defense.ToString(), Color.White); + DrawStat(sb, col2X, statRowY + 24, "Speed", selectedInfo.Speed.ToString(), Color.White); + + TextHelper.DrawStringWithSpacing(sb, _font, "ATTRIBUTES", + new Vector2(leftX + 12, statRowY + 56), Palette.UiSectionTitle); + + DrawStat(sb, col1X, statRowY + 78, "STR", selectedInfo.Strength.ToString(), new Color(240, 180, 80)); + DrawStat(sb, col2X, statRowY + 78, "DEX", selectedInfo.Dexterity.ToString(), new Color(80, 200, 80)); + DrawStat(sb, col3X, statRowY + 78, "CON", selectedInfo.Constitution.ToString(), new Color(180, 120, 80)); + + DrawStat(sb, col1X, statRowY + 102, "INT", selectedInfo.Intelligence.ToString(), new Color(80, 140, 240)); + DrawStat(sb, col2X, statRowY + 102, "WIS", selectedInfo.Wisdom.ToString(), new Color(140, 180, 220)); + DrawStat(sb, col3X, statRowY + 102, "CHA", selectedInfo.Charisma.ToString(), new Color(220, 140, 220)); + } + } + + // ======================================================================== + // Helpers + // ======================================================================== + + private int GetSelectedClassId() + { + if (_selectedClassIndex >= 0 && _selectedClassIndex < _classPreviews.Count) + return _classPreviews[_selectedClassIndex].ClassId; + return 0; + } + + private ClassPreviewInfo GetSelectedPreview() + { + // When browsing after party is full, we still want to show a preview + // Use the valid class index, clamped + var idx = Math.Min(_selectedClassIndex, _classPreviews.Count - 1); + if (idx >= 0 && idx < _classPreviews.Count) + return _classPreviews[idx]; + return null; + } + + private string GetClassName(int classId) + { + var info = _classPreviews.FirstOrDefault(c => c.ClassId == classId); + return info?.Name ?? $"Class #{classId}"; + } + + private void FillRemainingRandomly() + { + var remaining = ClassLookups.AllClassIds + .Where(id => !_chosenClassIds.Contains(id)) + .ToList(); + + var rng = new Random(); + while (_chosenClassIds.Count < 4 && remaining.Count > 0) + { + var idx = rng.Next(remaining.Count); + _chosenClassIds.Add(remaining[idx]); + remaining.RemoveAt(idx); + } + } + + private void FinalizeParty() + { + // Initialize the game state with the chosen party composition + _gameState.InitializeParty(_chosenClassIds.ToArray()); + + // Transition to Character Menu + var menuScene = _serviceProvider.GetRequiredService(); + _ = _sceneManager.SetScene(menuScene); + } + + // ======================================================================== + // Drawing Helpers + // ======================================================================== + + private void DrawStat(SpriteBatch sb, int x, int y, string label, string value, Color valueColor) + { + TextHelper.DrawStringWithSpacing(sb, _font, $"{label}:", + new Vector2(x, y), Palette.UiTextLabel); + var labelW = TextHelper.MeasureStringWidth(_font, $"{label}:"); + TextHelper.DrawStringWithSpacing(sb, _font, value, + new Vector2(x + labelW + 6, y), valueColor); + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/SceneManager.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/SceneManager.cs new file mode 100644 index 00000000..4925e9fd --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/SceneManager.cs @@ -0,0 +1,24 @@ +#nullable enable +using System; +using System.Threading.Tasks; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace OpenRpg.Demos.Battler.Code.Scenes; + +public class SceneManager : ISceneManager +{ + public IScene? ActiveScene { get; private set; } + public Action? RequestExit { get; set; } + + public async Task SetScene(IScene scene) + { + ActiveScene?.Unload(); + ActiveScene = scene; + await scene.LoadAsync(); + } + + public void Update(GameTime gameTime) => ActiveScene?.Update(gameTime); + public void Draw(GameTime gameTime, SpriteBatch spriteBatch) => ActiveScene?.Draw(gameTime, spriteBatch); + public void DrawUI(GameTime gameTime, SpriteBatch spriteBatch) => ActiveScene?.DrawUI(gameTime, spriteBatch); +} diff --git a/src/OpenRpg.Demos.Battler/Code/Scenes/UiHelper.cs b/src/OpenRpg.Demos.Battler/Code/Scenes/UiHelper.cs new file mode 100644 index 00000000..eb757320 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Scenes/UiHelper.cs @@ -0,0 +1,69 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Rendering; + +namespace OpenRpg.Demos.Battler.Code.Scenes; + +/// +/// Shared UI drawing utilities for panels, buttons, borders, and highlights. +/// All scenes should use these for consistent visual styling. +/// +public static class UiHelper +{ + /// + /// Draw a filled rectangle panel with a 1-pixel border. + /// + public static void DrawPanel(SpriteBatch sb, Texture2D pixel, int x, int y, int w, int h, Color fillColor, Color? borderColor = null) + { + // Fill + sb.Draw(pixel, new Rectangle(x, y, w, h), fillColor); + + // Border + var border = borderColor ?? Color.Lerp(fillColor, Color.White, 0.25f); + sb.Draw(pixel, new Rectangle(x, y, w, 1), border); // top + sb.Draw(pixel, new Rectangle(x, y + h - 1, w, 1), border); // bottom + sb.Draw(pixel, new Rectangle(x, y, 1, h), border); // left + sb.Draw(pixel, new Rectangle(x + w - 1, y, 1, h), border); // right + } + + /// + /// Draw a button background with border. Selected state has brighter fill and border. + /// + public static void DrawButton(SpriteBatch sb, Texture2D pixel, int x, int y, int w, int h, + bool isSelected, Color? fillColor = null, Color? selectedFillColor = null, + Color? borderColor = null, Color? selectedBorderColor = null) + { + var fill = isSelected + ? (selectedFillColor ?? new Color(60, 60, 90)) + : (fillColor ?? new Color(20, 25, 35)); + var border = isSelected + ? (selectedBorderColor ?? new Color(100, 120, 160)) + : (borderColor ?? new Color(45, 50, 65)); + + DrawPanel(sb, pixel, x, y, w, h, fill, border); + } + + /// + /// Draw a highlight rectangle (e.g., for selected list items). + /// + public static void DrawSelectionHighlight(SpriteBatch sb, Texture2D pixel, int x, int y, int w, int h) + { + sb.Draw(pixel, new Rectangle(x, y, w, h), new Color(60, 60, 90)); + // Lighter top/left border for a slight 3D inset look + sb.Draw(pixel, new Rectangle(x, y, w, 1), new Color(90, 100, 140)); + sb.Draw(pixel, new Rectangle(x, y, 1, h), new Color(90, 100, 140)); + sb.Draw(pixel, new Rectangle(x, y + h - 1, w, 1), new Color(35, 35, 55)); + sb.Draw(pixel, new Rectangle(x + w - 1, y, 1, h), new Color(35, 35, 55)); + } + + /// + /// Draw a section title bar across the top width of a panel. + /// + public static void DrawTitleBar(SpriteBatch sb, Texture2D pixel, int x, int y, int w, int h, + Color? fillColor = null, Color? borderColor = null) + { + var fill = fillColor ?? new Color(25, 30, 50); + var border = borderColor ?? new Color(55, 60, 90); + DrawPanel(sb, pixel, x, y, w, h, fill, border); + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Services/Game/GameServices.cs b/src/OpenRpg.Demos.Battler/Code/Services/Game/GameServices.cs new file mode 100644 index 00000000..b1e20de0 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Services/Game/GameServices.cs @@ -0,0 +1,12 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; + +namespace OpenRpg.Demos.Battler.Code.Services.Game; + +public class GameServices : IGameServices +{ + public SpriteBatch GetSpriteBatch { get; set; } = null; + public GraphicsDeviceManager GetGraphicsDeviceManager { get; set; } = null; + public ContentManager GetContentManager { get; set; } = null; +} \ No newline at end of file diff --git a/src/OpenRpg.Demos.Battler/Code/Services/Game/IGameServices.cs b/src/OpenRpg.Demos.Battler/Code/Services/Game/IGameServices.cs new file mode 100644 index 00000000..196ca335 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Services/Game/IGameServices.cs @@ -0,0 +1,12 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; + +namespace OpenRpg.Demos.Battler.Code.Services.Game; + +public interface IGameServices +{ + SpriteBatch GetSpriteBatch { get; } + GraphicsDeviceManager GetGraphicsDeviceManager { get; } + ContentManager GetContentManager { get; } +} \ No newline at end of file diff --git a/src/OpenRpg.Demos.Battler/Code/Services/Game/IPersistentGameState.cs b/src/OpenRpg.Demos.Battler/Code/Services/Game/IPersistentGameState.cs new file mode 100644 index 00000000..914a0fce --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Services/Game/IPersistentGameState.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Items.Templates; + +namespace OpenRpg.Demos.Battler.Code.Services.Game; + +public interface IPersistentGameState +{ + List Party { get; } + List SharedInventory { get; } + void InitializeParty(); + void InitializeParty(int[] classIds); + void FullHealParty(); + void ResetParty(); + bool IsInitialized { get; } + BattleEntity GetCharacter(int index); +} diff --git a/src/OpenRpg.Demos.Battler/Code/Services/Game/PersistentGameState.cs b/src/OpenRpg.Demos.Battler/Code/Services/Game/PersistentGameState.cs new file mode 100644 index 00000000..957b6c16 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Services/Game/PersistentGameState.cs @@ -0,0 +1,161 @@ +using System.Collections.Generic; +using System.Linq; +using OpenRpg.Combat.Extensions; +using OpenRpg.Combat.Types; +using OpenRpg.Data; +using OpenRpg.Demos.Battler.Code.Builders; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Demos.Battler.Code.Types; +using OpenRpg.Entities.Classes.Templates; +using OpenRpg.Entities.Extensions; +using OpenRpg.Genres.Fantasy.Extensions; +using OpenRpg.Localization.Data.DataSources; +using OpenRpg.Items.Templates; + +namespace OpenRpg.Demos.Battler.Code.Services.Game; + +public class PersistentGameState : IPersistentGameState +{ + private readonly IDataSource _dataSource; + private readonly ILocaleDataSource _localeDataSource; + private readonly GameCharacterBuilder _characterBuilder; + private List _party; + private List _sharedInventory; + + public bool IsInitialized => _party != null; + + public PersistentGameState(IDataSource dataSource, ILocaleDataSource localeDataSource, GameCharacterBuilder characterBuilder) + { + _dataSource = dataSource; + _localeDataSource = localeDataSource; + _characterBuilder = characterBuilder; + } + + public List Party + { + get + { + if (!IsInitialized) InitializeParty(); + return _party; + } + } + + public List SharedInventory + { + get + { + if (!IsInitialized) InitializeParty(); + return _sharedInventory; + } + } + + public BattleEntity GetCharacter(int index) + { + if (!IsInitialized) InitializeParty(); + return index >= 0 && index < _party.Count ? _party[index] : null; + } + + public void InitializeParty() + { + // Idempotent — only initializes once. This preserves loot and party state + // between battles. Call ReinitializeParty() to force a full reset. + if (_party != null) return; + + var partyIds = ClassLookups.GetRandomPartyIds(); + BuildPartyFromIds(partyIds); + } + + public void InitializeParty(int[] classIds) + { + // Allows the party creation scene to specify exact class composition. + // Always resets — no idempotent guard, because this is called fresh from the creation scene. + ResetParty(); + BuildPartyFromIds(classIds); + } + + private void BuildPartyFromIds(int[] classIds) + { + var entities = new List(); + var slotIndex = 0; + + foreach (var classId in classIds) + { + var template = _dataSource.Get(classId); + if (template == null) continue; + + var name = _localeDataSource.Get("en-gb", template.NameLocaleId); + + var character = _characterBuilder + .CreateNew() + .WithRaceId(RaceLookups.Human) + .WithClassId(classId, 1) + .WithName(name) + .Build(); + + character.NameLocaleId = template.NameLocaleId; + var assetCode = character.Variables.AssetCode; + + if (template.Variables.HasAbilities()) + character.Variables[CombatTemplateVariableTypes.Abilities] = template.Variables.Abilities.ToList(); + + entities.Add(new BattleEntity + { + Entity = character, + Name = name, + AssetCode = assetCode, + Team = Team.Player, + Row = slotIndex < 2 ? 0 : 1, + SlotInRow = slotIndex % 2 + }); + + slotIndex++; + } + + _party = entities; + _sharedInventory = new List(); + AddStarterItems(); + } + + public void FullHealParty() + { + if (_party == null) return; + foreach (var entity in _party) + { + entity.Hp = entity.MaxHp; + entity.Entity.State.Mana = entity.MaxMana; + } + } + + public void ResetParty() + { + _party = null; + _sharedInventory = null; + } + + private void AddStarterItems() + { + var potionTemplate = _dataSource.Get(BattlerConstants.PotionTemplateId); + if (potionTemplate != null) + { + var potion = new ItemData { TemplateId = potionTemplate.Id }; + for (var i = 0; i < BattlerConstants.StarterPotionCount; i++) + _sharedInventory.Add(potion); + } + + var etherTemplate = _dataSource.Get(BattlerConstants.EtherTemplateId); + if (etherTemplate != null) + { + var ether = new ItemData { TemplateId = etherTemplate.Id }; + for (var i = 0; i < BattlerConstants.StarterEtherCount; i++) + _sharedInventory.Add(ether); + } + + var pDownTemplate = _dataSource.Get(BattlerConstants.PhoenixDownTemplateId); + if (pDownTemplate != null) + { + var pDown = new ItemData { TemplateId = pDownTemplate.Id }; + for (var i = 0; i < BattlerConstants.StarterPhoenixDownCount; i++) + _sharedInventory.Add(pDown); + } + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Services/ILootService.cs b/src/OpenRpg.Demos.Battler/Code/Services/ILootService.cs new file mode 100644 index 00000000..897969e4 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Services/ILootService.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Items.Templates; + +namespace OpenRpg.Demos.Battler.Code.Services; + +public interface ILootService +{ + /// + /// Generates loot from a list of defeated enemies. + /// Rolls each enemy's loot table and returns the items that dropped. + /// + List GenerateLoot(IReadOnlyList defeatedEnemies); +} diff --git a/src/OpenRpg.Demos.Battler/Code/Services/LootService.cs b/src/OpenRpg.Demos.Battler/Code/Services/LootService.cs new file mode 100644 index 00000000..7c45c428 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Services/LootService.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using OpenRpg.Data; +using OpenRpg.Demos.Battler.Code.Scenes.Battle.Models; +using OpenRpg.Entities.Entity.Templates; +using OpenRpg.Items.Extensions; +using OpenRpg.Items.Loot; +using OpenRpg.Items.Templates; + +namespace OpenRpg.Demos.Battler.Code.Services; + +public class LootService : ILootService +{ + private readonly IDataSource _dataSource; + private readonly ILootTableProcessor _lootTableProcessor; + + public LootService(IDataSource dataSource, ILootTableProcessor lootTableProcessor) + { + _dataSource = dataSource; + _lootTableProcessor = lootTableProcessor; + } + + public List GenerateLoot(IReadOnlyList defeatedEnemies) + { + var loot = new List(); + + foreach (var enemy in defeatedEnemies) + { + var template = _dataSource.Get(enemy.Entity.TemplateId); + if (template == null) continue; + if (!template.Variables.HasLootTable()) continue; + + var lootData = template.Variables.LootTable; + loot.AddRange(_lootTableProcessor.GetLoot(lootData)); + } + + return loot; + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Types/BattlerConstants.cs b/src/OpenRpg.Demos.Battler/Code/Types/BattlerConstants.cs new file mode 100644 index 00000000..d93c9bf1 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Types/BattlerConstants.cs @@ -0,0 +1,55 @@ +namespace OpenRpg.Demos.Battler.Code.Types; + +/// +/// Shared constants for the Battler demo. Replaces magic numbers scattered across multiple files. +/// +public static class BattlerConstants +{ + // Party configuration + public const int MaxPartySize = 4; + public const int DefaultCharacterLevel = 1; + + // Battle configuration + public const int DefaultEnemyCount = 6; + public const double TurnDwellSeconds = 0.8; + public const double TargetFlashSeconds = 0.3; + + // Starter item template IDs + public const int PotionTemplateId = 20; + public const int EtherTemplateId = 22; + public const int PhoenixDownTemplateId = 23; + public const int StarterPotionCount = 3; + public const int StarterEtherCount = 1; + public const int StarterPhoenixDownCount = 1; + + // Item type IDs (from Fantasy plugin) + public static class ItemTypes + { + public const int Weapon = 2; + public const int Head = 30; + public const int Body = 31; + public const int Legs = 32; + public const int Back = 33; + public const int Feet = 34; + public const int Wrist = 35; + public const int Neck = 36; + public const int Ring = 37; + public const int OffHand = 50; + public const int Consumable = 60; + } + + // Effect type IDs (from Fantasy/Genre plugins) + public static class EffectTypes + { + public const int DamageBonus = 1; + public const int DefenseBonus = 21; + public const int MovementSpeedBonus = 44; + public const int HealthBonus = 60; + public const int HealthRestoreAmount = 62; + public const int HealthRestorePercentage = 63; + public const int ManaRestoreAmount = 236; + public const int LifeRestoreAmount = 64; + public const int LifeRestorePercentage = 65; + public const int UnarmedDamageBonus = 263; + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Types/ClassLookups.cs b/src/OpenRpg.Demos.Battler/Code/Types/ClassLookups.cs new file mode 100644 index 00000000..da0ed944 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Types/ClassLookups.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; + +namespace OpenRpg.Demos.Battler.Code.Types; + +public static class ClassLookups +{ + public const int Warrior = 1; + public const int Monk = 2; + public const int WhiteMage = 3; + public const int Thief = 4; + public const int BlackMage = 5; + + public static readonly int[] AllClassIds = [Warrior, Monk, WhiteMage, Thief, BlackMage]; + + private static readonly Random _rng = new(); + + public static int[] GetRandomPartyIds(int count = 4) + { + var pool = new List(AllClassIds); + var result = new int[count]; + for (var i = 0; i < count; i++) + { + var idx = _rng.Next(pool.Count); + result[i] = pool[idx]; + pool.RemoveAt(idx); + } + return result; + } +} diff --git a/src/OpenRpg.Demos.Battler/Code/Types/RaceLookups.cs b/src/OpenRpg.Demos.Battler/Code/Types/RaceLookups.cs new file mode 100644 index 00000000..9dfad0ae --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Code/Types/RaceLookups.cs @@ -0,0 +1,6 @@ +namespace OpenRpg.Demos.Battler.Code.Types; + +public static class RaceLookups +{ + public const int Human = 1; +} diff --git a/src/OpenRpg.Demos.Battler/Content/.mgcontent b/src/OpenRpg.Demos.Battler/Content/.mgcontent new file mode 100644 index 00000000..721df667 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Content/.mgcontent @@ -0,0 +1,12 @@ + + + HiDef + Windows + + + E:/Code/open-source/openrpg/OpenRpg/src/OpenRpg.Demos.Battler/Content/Fonts/KenneyPixel.spritefont + + + + + \ No newline at end of file diff --git a/src/OpenRpg.Demos.Battler/Content/.mgstats b/src/OpenRpg.Demos.Battler/Content/.mgstats new file mode 100644 index 00000000..2e8bd34f --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Content/.mgstats @@ -0,0 +1,2 @@ +Source File,Dest File,Processor Type,Content Type,Source File Size,Dest File Size,Build Seconds +"E:/Code/open-source/openrpg/OpenRpg/src/OpenRpg.Demos.Battler/Content/Fonts/KenneyPixel.spritefont","E:/Code/open-source/openrpg/OpenRpg/src/OpenRpg.Demos.Battler/Content/bin/Windows/Content/Fonts/KenneyPixel.xnb","FontDescriptionProcessor","SpriteFontContent",447,14873,0.1530076 diff --git a/src/OpenRpg.Demos.Battler/Content/Content.mgcb b/src/OpenRpg.Demos.Battler/Content/Content.mgcb new file mode 100644 index 00000000..c16eb13d --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Content/Content.mgcb @@ -0,0 +1,125 @@ +#----------------------------- Global Properties ----------------------------# + +/outputDir:bin/$(Platform) +/intermediateDir:obj/$(Platform) +/platform:Windows +/config: +/profile:HiDef +/compress:False + +#---------------------------------- Content ---------------------------------# + +Sprites/Players/class-warrior.png +/importer:TextureImporter +/processor:TextureProcessor +/processorParam:ColorKeyColor=255,0,255,255 +/processorParam:ColorKeyEnabled=True +/processorParam:GenerateMipmaps=False +/processorParam:PremultiplyAlpha=False +/processorParam:ResizeToPowerOfTwo=False +/processorParam:MakeSquare=False +/processorParam:TextureFormat=Color + +Sprites/Players/class-monk.png +/importer:TextureImporter +/processor:TextureProcessor +/processorParam:ColorKeyColor=255,0,255,255 +/processorParam:ColorKeyEnabled=True +/processorParam:GenerateMipmaps=False +/processorParam:PremultiplyAlpha=False +/processorParam:ResizeToPowerOfTwo=False +/processorParam:MakeSquare=False +/processorParam:TextureFormat=Color + +Sprites/Players/class-thief.png +/importer:TextureImporter +/processor:TextureProcessor +/processorParam:ColorKeyColor=255,0,255,255 +/processorParam:ColorKeyEnabled=True +/processorParam:GenerateMipmaps=False +/processorParam:PremultiplyAlpha=False +/processorParam:ResizeToPowerOfTwo=False +/processorParam:MakeSquare=False +/processorParam:TextureFormat=Color + +Sprites/Players/class-black-mage.png +/importer:TextureImporter +/processor:TextureProcessor +/processorParam:ColorKeyColor=255,0,255,255 +/processorParam:ColorKeyEnabled=True +/processorParam:GenerateMipmaps=False +/processorParam:PremultiplyAlpha=False +/processorParam:ResizeToPowerOfTwo=False +/processorParam:MakeSquare=False +/processorParam:TextureFormat=Color + +Sprites/Players/class-white-mage.png +/importer:TextureImporter +/processor:TextureProcessor +/processorParam:ColorKeyColor=255,0,255,255 +/processorParam:ColorKeyEnabled=True +/processorParam:GenerateMipmaps=False +/processorParam:PremultiplyAlpha=False +/processorParam:ResizeToPowerOfTwo=False +/processorParam:MakeSquare=False +/processorParam:TextureFormat=Color + +Sprites/Enemies/monster-goblin.png +/importer:TextureImporter +/processor:TextureProcessor +/processorParam:ColorKeyColor=255,0,255,255 +/processorParam:ColorKeyEnabled=True +/processorParam:GenerateMipmaps=False +/processorParam:PremultiplyAlpha=False +/processorParam:ResizeToPowerOfTwo=False +/processorParam:MakeSquare=False +/processorParam:TextureFormat=Color + +Sprites/Enemies/monster-bat.png +/importer:TextureImporter +/processor:TextureProcessor +/processorParam:ColorKeyColor=255,0,255,255 +/processorParam:ColorKeyEnabled=True +/processorParam:GenerateMipmaps=False +/processorParam:PremultiplyAlpha=False +/processorParam:ResizeToPowerOfTwo=False +/processorParam:MakeSquare=False +/processorParam:TextureFormat=Color + +Sprites/Enemies/monster-wolf.png +/importer:TextureImporter +/processor:TextureProcessor +/processorParam:ColorKeyColor=255,0,255,255 +/processorParam:ColorKeyEnabled=True +/processorParam:GenerateMipmaps=False +/processorParam:PremultiplyAlpha=False +/processorParam:ResizeToPowerOfTwo=False +/processorParam:MakeSquare=False +/processorParam:TextureFormat=Color + +Sprites/Enemies/monster-skeleton.png +/importer:TextureImporter +/processor:TextureProcessor +/processorParam:ColorKeyColor=255,0,255,255 +/processorParam:ColorKeyEnabled=True +/processorParam:GenerateMipmaps=False +/processorParam:PremultiplyAlpha=False +/processorParam:ResizeToPowerOfTwo=False +/processorParam:MakeSquare=False +/processorParam:TextureFormat=Color + +Sprites/Enemies/monster-slime.png +/importer:TextureImporter +/processor:TextureProcessor +/processorParam:ColorKeyColor=255,0,255,255 +/processorParam:ColorKeyEnabled=True +/processorParam:GenerateMipmaps=False +/processorParam:PremultiplyAlpha=False +/processorParam:ResizeToPowerOfTwo=False +/processorParam:MakeSquare=False +/processorParam:TextureFormat=Color + +#begin Fonts/KenneyPixel.spritefont +/importer:FontDescriptionImporter +/processor:FontDescriptionProcessor +#end diff --git a/src/OpenRpg.Demos.Battler/Content/Fonts/Kenney Pixel.ttf b/src/OpenRpg.Demos.Battler/Content/Fonts/Kenney Pixel.ttf new file mode 100644 index 00000000..e6978d7d Binary files /dev/null and b/src/OpenRpg.Demos.Battler/Content/Fonts/Kenney Pixel.ttf differ diff --git a/src/OpenRpg.Demos.Battler/Content/Fonts/KenneyPixel.mgcontent b/src/OpenRpg.Demos.Battler/Content/Fonts/KenneyPixel.mgcontent new file mode 100644 index 00000000..f0e493e0 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Content/Fonts/KenneyPixel.mgcontent @@ -0,0 +1,14 @@ + + + E:/Code/open-source/openrpg/OpenRpg/src/OpenRpg.Demos.Battler/Content/Fonts/KenneyPixel.spritefont + 2026-05-20T13:59:43.6523122+01:00 + E:/Code/open-source/openrpg/OpenRpg/src/OpenRpg.Demos.Battler/Content/bin/Windows/Content/Fonts/KenneyPixel.xnb + 2026-05-20T14:04:14.5481392+01:00 + FontDescriptionImporter + 2025-10-20T15:25:32+01:00 + FontDescriptionProcessor + 2025-10-20T15:25:32+01:00 + + + + \ No newline at end of file diff --git a/src/OpenRpg.Demos.Battler/Content/Fonts/KenneyPixel.spritefont b/src/OpenRpg.Demos.Battler/Content/Fonts/KenneyPixel.spritefont new file mode 100644 index 00000000..50e3b642 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Content/Fonts/KenneyPixel.spritefont @@ -0,0 +1,15 @@ + + + + Kenney Pixel.ttf + 16 + 0 + + + + + ~ + + + + diff --git a/src/OpenRpg.Demos.Battler/Content/Project/locales/en-gb.json b/src/OpenRpg.Demos.Battler/Content/Project/locales/en-gb.json new file mode 100644 index 00000000..0c040628 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Content/Project/locales/en-gb.json @@ -0,0 +1,89 @@ +{ + "LocaleCode": "en-gb", + "LocaleData": { + "race-human-name": "Human", + "race-human-description": "A well-rounded race of resilient and adaptable people.", + + "class-warrior-name": "Warrior", + "class-warrior-description": "A master of arms with unmatched strength and fortitude.", + "class-monk-name": "Monk", + "class-monk-description": "A disciple of martial arts who fights with bare hands.", + "class-thief-name": "Thief", + "class-thief-description": "A cunning rogue with swift hands and quicker feet.", + "class-white-mage-name": "White Mage", + "class-white-mage-description": "A practitioner of healing magics and holy light.", + "class-black-mage-name": "Black Mage", + "class-black-mage-description": "A wielder of destructive elemental sorcery.", + + "monster-goblin-name": "Goblin", + "monster-goblin-description": "A small, green-skinned menace that lurks in dark places.", + "monster-bat-name": "Bat", + "monster-bat-description": "A nocturnal creature of the caves, swift but fragile.", + "monster-wolf-name": "Wolf", + "monster-wolf-description": "A fierce predator that hunts in packs.", + "monster-skeleton-name": "Skeleton", + "monster-skeleton-description": "The reanimated bones of a fallen warrior.", + "monster-slime-name": "Slime", + "monster-slime-description": "A gelatinous blob that oozes along the dungeon floor.", + + "item-bronze-sword-name": "Bronze Sword", + "item-bronze-sword-description": "A sturdy bronze blade, suitable for novice adventurers.", + "item-iron-sword-name": "Iron Sword", + "item-iron-sword-description": "A reliable iron blade favoured by seasoned warriors.", + "item-mythril-sword-name": "Mythril Sword", + "item-mythril-sword-description": "A rare and lightweight sword forged from mythril ore.", + "item-staff-name": "Staff", + "item-staff-description": "A wooden staff imbued with latent magical energy.", + "item-knuckles-name": "Knuckles", + "item-knuckles-description": "Reinforced hand-guards for devastating unarmed strikes.", + "item-short-bow-name": "Short Bow", + "item-short-bow-description": "A compact bow for striking foes from a distance.", + + "item-leather-cap-name": "Leather Cap", + "item-leather-cap-description": "A simple cap made of cured leather.", + "item-bronze-helm-name": "Bronze Helm", + "item-bronze-helm-description": "A bronze helm offering solid head protection.", + "item-iron-helm-name": "Iron Helm", + "item-iron-helm-description": "A sturdy iron helm with reinforced plating.", + "item-leather-armor-name": "Leather Armor", + "item-leather-armor-description": "A flexible tunic of treated leather hides.", + "item-chain-mail-name": "Chain Mail", + "item-chain-mail-description": "Interlocking metal rings that turn aside blades.", + "item-plate-armor-name": "Plate Armor", + "item-plate-armor-description": "Heavy steel plating offering superior protection.", + "item-leather-gloves-name": "Leather Gloves", + "item-leather-gloves-description": "Padded leather gloves for hand protection.", + "item-leather-boots-name": "Leather Boots", + "item-leather-boots-description": "Sturdy leather boots for long journeys.", + + "item-potion-name": "Potion", + "item-potion-description": "A red liquid that restores 30 HP.", + "item-hi-potion-name": "Hi-Potion", + "item-hi-potion-description": "A potent red liquid that restores 80 HP.", + "item-ether-name": "Ether", + "item-ether-description": "A blue liquid that restores 30 MP.", + "item-phoenix-down-name": "Phoenix Down", + "item-phoenix-down-description": "A blessed feather said to hold the power of revival.", + + "ability-slash": "Slash", + "ability-slash-desc": "A sweeping strike that hits multiple front-row enemies.", + "ability-power-strike": "Power Strike", + "ability-power-strike-desc": "A powerful overhand strike that deals heavy damage.", + "ability-chi-blast": "Chi Blast", + "ability-chi-blast-desc": "A burst of inner energy that strikes all enemies.", + "ability-focus-strike": "Focus Strike", + "ability-focus-strike-desc": "A precise, focused blow that deals massive damage.", + "ability-backstab": "Backstab", + "ability-backstab-desc": "A vicious strike from the shadows.", + "ability-poison-blade": "Poison Blade", + "ability-poison-blade-desc": "A venomous slash that poisons the target.", + "ability-fire-bolt": "Fire Bolt", + "ability-fire-bolt-desc": "A bolt of searing fire.", + "ability-ice-storm": "Ice Storm", + "ability-ice-storm-desc": "A freezing storm that assails all enemies.", + "ability-cure": "Cure", + "ability-cure-desc": "Heals a single ally for a moderate amount of HP.", + "ability-cura": "Cura", + "ability-cura-desc": "Heals the entire party for a small amount of HP." + } +} diff --git a/src/OpenRpg.Demos.Battler/Content/Project/project.json b/src/OpenRpg.Demos.Battler/Content/Project/project.json new file mode 100644 index 00000000..6df1c884 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Content/Project/project.json @@ -0,0 +1,9 @@ +{ + "Version": "1.0.1", + "Type": "json", + "Metadata": {}, + "Plugins": [ + { "Id": "default", "Version": "1.0.0" }, + { "Id": "fantasy", "Version": "1.0.0" } + ] +} diff --git a/src/OpenRpg.Demos.Battler/Content/Project/templates/AbilityTemplate.json b/src/OpenRpg.Demos.Battler/Content/Project/templates/AbilityTemplate.json new file mode 100644 index 00000000..1431627c --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Content/Project/templates/AbilityTemplate.json @@ -0,0 +1,142 @@ +[ + { + "Id": 1, + "NameLocaleId": "ability-slash", + "DescriptionLocaleId": "ability-slash-desc", + "Variables": { + "2": { "Type": 50, "Value": 10.0 }, + "5": 2, + "7": 3, + "40": 4, + "4003": [ + { "RequirementType": 2, "Association": { "AssociatedId": 1, "AssociatedValue": 0 } } + ] + } + }, + { + "Id": 2, + "NameLocaleId": "ability-power-strike", + "DescriptionLocaleId": "ability-power-strike-desc", + "Variables": { + "2": { "Type": 50, "Value": 22.0 }, + "5": 1, + "7": 1, + "40": 8, + "4003": [ + { "RequirementType": 2, "Association": { "AssociatedId": 1, "AssociatedValue": 0 } } + ] + } + }, + { + "Id": 3, + "NameLocaleId": "ability-chi-blast", + "DescriptionLocaleId": "ability-chi-blast-desc", + "Variables": { + "2": { "Type": 51, "Value": 8.0 }, + "5": 2, + "7": 6, + "40": 5, + "4003": [ + { "RequirementType": 2, "Association": { "AssociatedId": 2, "AssociatedValue": 0 } } + ] + } + }, + { + "Id": 4, + "NameLocaleId": "ability-focus-strike", + "DescriptionLocaleId": "ability-focus-strike-desc", + "Variables": { + "2": { "Type": 51, "Value": 28.0 }, + "5": 1, + "7": 1, + "40": 10, + "4003": [ + { "RequirementType": 2, "Association": { "AssociatedId": 2, "AssociatedValue": 0 } } + ] + } + }, + { + "Id": 5, + "NameLocaleId": "ability-backstab", + "DescriptionLocaleId": "ability-backstab-desc", + "Variables": { + "2": { "Type": 52, "Value": 25.0 }, + "5": 1, + "7": 1, + "40": 4, + "4003": [ + { "RequirementType": 2, "Association": { "AssociatedId": 4, "AssociatedValue": 0 } } + ] + } + }, + { + "Id": 6, + "NameLocaleId": "ability-poison-blade", + "DescriptionLocaleId": "ability-poison-blade-desc", + "Variables": { + "2": { "Type": 52, "Value": 12.0 }, + "5": 1, + "7": 1, + "40": 3, + "4003": [ + { "RequirementType": 2, "Association": { "AssociatedId": 4, "AssociatedValue": 0 } } + ] + } + }, + { + "Id": 7, + "NameLocaleId": "ability-fire-bolt", + "DescriptionLocaleId": "ability-fire-bolt-desc", + "Variables": { + "2": { "Type": 80, "Value": 22.0 }, + "5": 1, + "7": 1, + "40": 15, + "4003": [ + { "RequirementType": 2, "Association": { "AssociatedId": 5, "AssociatedValue": 0 } } + ] + } + }, + { + "Id": 8, + "NameLocaleId": "ability-ice-storm", + "DescriptionLocaleId": "ability-ice-storm-desc", + "Variables": { + "2": { "Type": 81, "Value": 12.0 }, + "5": 2, + "7": 6, + "40": 22, + "4003": [ + { "RequirementType": 2, "Association": { "AssociatedId": 5, "AssociatedValue": 0 } } + ] + } + }, + { + "Id": 9, + "NameLocaleId": "ability-cure", + "DescriptionLocaleId": "ability-cure-desc", + "Variables": { + "2": { "Type": 84, "Value": 45.0 }, + "5": 1, + "7": 1, + "40": 8, + "4003": [ + { "RequirementType": 2, "Association": { "AssociatedId": 3, "AssociatedValue": 0 } } + ] + } + }, + { + "Id": 10, + "NameLocaleId": "ability-cura", + "DescriptionLocaleId": "ability-cura-desc", + "Variables": { + "2": { "Type": 84, "Value": 18.0 }, + "5": 2, + "7": 4, + "40": 14, + "4003": [ + { "RequirementType": 2, "Association": { "AssociatedId": 3, "AssociatedValue": 0 } } + ] + } + } +] diff --git a/src/OpenRpg.Demos.Battler/Content/Project/templates/ClassTemplate.json b/src/OpenRpg.Demos.Battler/Content/Project/templates/ClassTemplate.json new file mode 100644 index 00000000..6718ed5e --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Content/Project/templates/ClassTemplate.json @@ -0,0 +1,116 @@ +[ + { + "Id": 1, + "NameLocaleId": "class-warrior-name", + "DescriptionLocaleId": "class-warrior-description", + "Variables": { + "9000": "class-warrior", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 220, "Potency": 3.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 224, "Potency": 2.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 222, "Potency": 1.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 260, "Potency": 8.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 1, "Potency": 5.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 21, "Potency": 5.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 60, "Potency": 50.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 44, "Potency": 3.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 234, "Potency": 10.0, "Requirements": [] } + ], + "4003": [], + "6000": [ + { "TemplateId": 1, "Variables": {} }, + { "TemplateId": 2, "Variables": {} } + ] + } + }, + { + "Id": 2, + "NameLocaleId": "class-monk-name", + "DescriptionLocaleId": "class-monk-description", + "Variables": { + "9000": "class-monk", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 220, "Potency": 2.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 222, "Potency": 3.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 224, "Potency": 1.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 263, "Potency": 8.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 1, "Potency": 3.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 60, "Potency": 30.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 44, "Potency": 6.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 234, "Potency": 15.0, "Requirements": [] } + ], + "4003": [], + "6000": [ + { "TemplateId": 3, "Variables": {} }, + { "TemplateId": 4, "Variables": {} } + ] + } + }, + { + "Id": 3, + "NameLocaleId": "class-white-mage-name", + "DescriptionLocaleId": "class-white-mage-description", + "Variables": { + "9000": "class-white-mage", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 226, "Potency": 3.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 228, "Potency": 3.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 224, "Potency": 1.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 354, "Potency": 6.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 1, "Potency": 2.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 234, "Potency": 40.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 62, "Potency": 10.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 44, "Potency": 2.0, "Requirements": [] } + ], + "4003": [], + "6000": [ + { "TemplateId": 9, "Variables": {} }, + { "TemplateId": 10, "Variables": {} } + ] + } + }, + { + "Id": 4, + "NameLocaleId": "class-thief-name", + "DescriptionLocaleId": "class-thief-description", + "Variables": { + "9000": "class-thief", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 222, "Potency": 3.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 44, "Potency": 2.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 220, "Potency": 1.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 261, "Potency": 6.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 1, "Potency": 4.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 3, "Potency": 5.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 5, "Potency": 2.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 234, "Potency": 5.0, "Requirements": [] } + ], + "4003": [], + "6000": [ + { "TemplateId": 5, "Variables": {} }, + { "TemplateId": 6, "Variables": {} } + ] + } + }, + { + "Id": 5, + "NameLocaleId": "class-black-mage-name", + "DescriptionLocaleId": "class-black-mage-description", + "Variables": { + "9000": "class-black-mage", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 226, "Potency": 4.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 228, "Potency": 2.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 222, "Potency": 1.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 350, "Potency": 8.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 234, "Potency": 30.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 44, "Potency": 2.0, "Requirements": [] } + ], + "4003": [], + "6000": [ + { "TemplateId": 7, "Variables": {} }, + { "TemplateId": 8, "Variables": {} } + ] + } + } +] diff --git a/src/OpenRpg.Demos.Battler/Content/Project/templates/EntityTemplate.json b/src/OpenRpg.Demos.Battler/Content/Project/templates/EntityTemplate.json new file mode 100644 index 00000000..01f62bd8 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Content/Project/templates/EntityTemplate.json @@ -0,0 +1,110 @@ +[ + { + "Id": 1, + "NameLocaleId": "monster-goblin-name", + "DescriptionLocaleId": "monster-goblin-description", + "Variables": { + "9000": "monster-goblin", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 60, "Potency": 30.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 1, "Potency": 8.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 21, "Potency": 5.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 44, "Potency": 3.0, "Requirements": [] } + ], + "4003": [], + "11": { + "AvailableLoot": [ + { "ItemData": { "TemplateId": 20 }, "Variables": { "1": 0.3 } }, + { "ItemData": { "TemplateId": 1 }, "Variables": { "1": 0.1 } }, + { "ItemData": { "TemplateId": 10 }, "Variables": { "1": 0.1 } } + ] + } + } + }, + { + "Id": 2, + "NameLocaleId": "monster-bat-name", + "DescriptionLocaleId": "monster-bat-description", + "Variables": { + "9000": "monster-bat", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 60, "Potency": 15.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 1, "Potency": 5.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 21, "Potency": 2.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 44, "Potency": 8.0, "Requirements": [] } + ], + "4003": [], + "11": { + "AvailableLoot": [ + { "ItemData": { "TemplateId": 20 }, "Variables": { "1": 0.2 } }, + { "ItemData": { "TemplateId": 17 }, "Variables": { "1": 0.1 } } + ] + } + } + }, + { + "Id": 3, + "NameLocaleId": "monster-wolf-name", + "DescriptionLocaleId": "monster-wolf-description", + "Variables": { + "9000": "monster-wolf", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 60, "Potency": 40.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 1, "Potency": 12.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 21, "Potency": 4.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 44, "Potency": 6.0, "Requirements": [] } + ], + "4003": [], + "11": { + "AvailableLoot": [ + { "ItemData": { "TemplateId": 20 }, "Variables": { "1": 0.3 } }, + { "ItemData": { "TemplateId": 16 }, "Variables": { "1": 0.1 } }, + { "ItemData": { "TemplateId": 6 }, "Variables": { "1": 0.05 } } + ] + } + } + }, + { + "Id": 4, + "NameLocaleId": "monster-skeleton-name", + "DescriptionLocaleId": "monster-skeleton-description", + "Variables": { + "9000": "monster-skeleton", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 60, "Potency": 50.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 1, "Potency": 10.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 21, "Potency": 8.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 44, "Potency": 3.0, "Requirements": [] } + ], + "4003": [], + "11": { + "AvailableLoot": [ + { "ItemData": { "TemplateId": 21 }, "Variables": { "1": 0.2 } }, + { "ItemData": { "TemplateId": 11 }, "Variables": { "1": 0.15 } }, + { "ItemData": { "TemplateId": 2 }, "Variables": { "1": 0.08 } }, + { "ItemData": { "TemplateId": 14 }, "Variables": { "1": 0.1 } } + ] + } + } + }, + { + "Id": 5, + "NameLocaleId": "monster-slime-name", + "DescriptionLocaleId": "monster-slime-description", + "Variables": { + "9000": "monster-slime", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 60, "Potency": 20.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 1, "Potency": 3.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 21, "Potency": 1.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 44, "Potency": 2.0, "Requirements": [] } + ], + "4003": [], + "11": { + "AvailableLoot": [ + { "ItemData": { "TemplateId": 20 }, "Variables": { "1": 0.5 } } + ] + } + } + } +] diff --git a/src/OpenRpg.Demos.Battler/Content/Project/templates/ItemTemplate.json b/src/OpenRpg.Demos.Battler/Content/Project/templates/ItemTemplate.json new file mode 100644 index 00000000..cc49ca6d --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Content/Project/templates/ItemTemplate.json @@ -0,0 +1,311 @@ +[ + { + "Id": 1, + "ItemType": 2, + "NameLocaleId": "item-bronze-sword-name", + "DescriptionLocaleId": "item-bronze-sword-description", + "ModificationAllowances": [], + "Variables": { + "1": 2, + "2": 50, + "3": 5, + "9000": "bronze-sword", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 1, "Potency": 6.0, "Requirements": [] } + ], + "4003": [] + } + }, + { + "Id": 2, + "ItemType": 2, + "NameLocaleId": "item-iron-sword-name", + "DescriptionLocaleId": "item-iron-sword-description", + "ModificationAllowances": [], + "Variables": { + "1": 2, + "2": 120, + "3": 6, + "9000": "iron-sword", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 1, "Potency": 10.0, "Requirements": [] } + ], + "4003": [] + } + }, + { + "Id": 3, + "ItemType": 2, + "NameLocaleId": "item-mythril-sword-name", + "DescriptionLocaleId": "item-mythril-sword-description", + "ModificationAllowances": [], + "Variables": { + "1": 3, + "2": 300, + "3": 4, + "9000": "mythril-sword", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 1, "Potency": 16.0, "Requirements": [] } + ], + "4003": [] + } + }, + { + "Id": 4, + "ItemType": 2, + "NameLocaleId": "item-staff-name", + "DescriptionLocaleId": "item-staff-description", + "ModificationAllowances": [], + "Variables": { + "1": 2, + "2": 80, + "3": 3, + "9000": "staff", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 1, "Potency": 4.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 226, "Potency": 2.0, "Requirements": [] } + ], + "4003": [] + } + }, + { + "Id": 5, + "ItemType": 2, + "NameLocaleId": "item-knuckles-name", + "DescriptionLocaleId": "item-knuckles-description", + "ModificationAllowances": [], + "Variables": { + "1": 2, + "2": 100, + "3": 2, + "9000": "knuckles", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 263, "Potency": 8.0, "Requirements": [] } + ], + "4003": [] + } + }, + { + "Id": 6, + "ItemType": 2, + "NameLocaleId": "item-short-bow-name", + "DescriptionLocaleId": "item-short-bow-description", + "ModificationAllowances": [], + "Variables": { + "1": 2, + "2": 110, + "3": 3, + "9000": "short-bow", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 1, "Potency": 7.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 90, "Potency": 2.0, "Requirements": [] } + ], + "4003": [] + } + }, + { + "Id": 10, + "ItemType": 30, + "NameLocaleId": "item-leather-cap-name", + "DescriptionLocaleId": "item-leather-cap-description", + "ModificationAllowances": [], + "Variables": { + "1": 2, + "2": 30, + "3": 1, + "9000": "leather-cap", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 21, "Potency": 2.0, "Requirements": [] } + ], + "4003": [] + } + }, + { + "Id": 11, + "ItemType": 30, + "NameLocaleId": "item-bronze-helm-name", + "DescriptionLocaleId": "item-bronze-helm-description", + "ModificationAllowances": [], + "Variables": { + "1": 2, + "2": 80, + "3": 3, + "9000": "bronze-helm", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 21, "Potency": 4.0, "Requirements": [] } + ], + "4003": [] + } + }, + { + "Id": 12, + "ItemType": 30, + "NameLocaleId": "item-iron-helm-name", + "DescriptionLocaleId": "item-iron-helm-description", + "ModificationAllowances": [], + "Variables": { + "1": 3, + "2": 180, + "3": 4, + "9000": "iron-helm", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 21, "Potency": 6.0, "Requirements": [] } + ], + "4003": [] + } + }, + { + "Id": 13, + "ItemType": 31, + "NameLocaleId": "item-leather-armor-name", + "DescriptionLocaleId": "item-leather-armor-description", + "ModificationAllowances": [], + "Variables": { + "1": 2, + "2": 60, + "3": 3, + "9000": "leather-armor", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 21, "Potency": 5.0, "Requirements": [] } + ], + "4003": [] + } + }, + { + "Id": 14, + "ItemType": 31, + "NameLocaleId": "item-chain-mail-name", + "DescriptionLocaleId": "item-chain-mail-description", + "ModificationAllowances": [], + "Variables": { + "1": 2, + "2": 150, + "3": 6, + "9000": "chain-mail", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 21, "Potency": 8.0, "Requirements": [] } + ], + "4003": [] + } + }, + { + "Id": 15, + "ItemType": 31, + "NameLocaleId": "item-plate-armor-name", + "DescriptionLocaleId": "item-plate-armor-description", + "ModificationAllowances": [], + "Variables": { + "1": 3, + "2": 350, + "3": 10, + "9000": "plate-armor", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 21, "Potency": 12.0, "Requirements": [] } + ], + "4003": [] + } + }, + { + "Id": 16, + "ItemType": 35, + "NameLocaleId": "item-leather-gloves-name", + "DescriptionLocaleId": "item-leather-gloves-description", + "ModificationAllowances": [], + "Variables": { + "1": 2, + "2": 20, + "3": 1, + "9000": "leather-gloves", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 21, "Potency": 1.0, "Requirements": [] } + ], + "4003": [] + } + }, + { + "Id": 17, + "ItemType": 34, + "NameLocaleId": "item-leather-boots-name", + "DescriptionLocaleId": "item-leather-boots-description", + "ModificationAllowances": [], + "Variables": { + "1": 2, + "2": 25, + "3": 1, + "9000": "leather-boots", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 21, "Potency": 1.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 44, "Potency": 1.0, "Requirements": [] } + ], + "4003": [] + } + }, + { + "Id": 20, + "ItemType": 60, + "NameLocaleId": "item-potion-name", + "DescriptionLocaleId": "item-potion-description", + "ModificationAllowances": [], + "Variables": { + "1": 2, + "2": 50, + "3": 1, + "9000": "potion", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 63, "Potency": 0.3, "Requirements": [] } + ], + "4003": [] + } + }, + { + "Id": 21, + "ItemType": 60, + "NameLocaleId": "item-hi-potion-name", + "DescriptionLocaleId": "item-hi-potion-description", + "ModificationAllowances": [], + "Variables": { + "1": 3, + "2": 150, + "3": 1, + "9000": "hi-potion", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 62, "Potency": 80.0, "Requirements": [] } + ], + "4003": [] + } + }, + { + "Id": 22, + "ItemType": 60, + "NameLocaleId": "item-ether-name", + "DescriptionLocaleId": "item-ether-description", + "ModificationAllowances": [], + "Variables": { + "1": 3, + "2": 100, + "3": 1, + "9000": "ether", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 236, "Potency": 30.0, "Requirements": [] } + ], + "4003": [] + } + }, + { + "Id": 23, + "ItemType": 60, + "NameLocaleId": "item-phoenix-down-name", + "DescriptionLocaleId": "item-phoenix-down-description", + "ModificationAllowances": [], + "Variables": { + "1": 4, + "2": 200, + "3": 1, + "9000": "phoenix-down", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 77, "Potency": 0.5, "Requirements": [] } + ], + "4003": [] + } + } +] diff --git a/src/OpenRpg.Demos.Battler/Content/Project/templates/RaceTemplate.json b/src/OpenRpg.Demos.Battler/Content/Project/templates/RaceTemplate.json new file mode 100644 index 00000000..eaa72ede --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Content/Project/templates/RaceTemplate.json @@ -0,0 +1,19 @@ +[ + { + "Id": 1, + "NameLocaleId": "race-human-name", + "DescriptionLocaleId": "race-human-description", + "Variables": { + "9000": "race-human", + "4002": [ + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 40, "Potency": 2.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 220, "Potency": 2.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 222, "Potency": 2.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 224, "Potency": 2.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 226, "Potency": 2.0, "Requirements": [] }, + { "$type": "OpenRpg.Core.Effects.StaticEffect, OpenRpg.Core", "EffectType": 228, "Potency": 2.0, "Requirements": [] } + ], + "4003": [] + } + } +] diff --git a/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/monster-bat.png b/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/monster-bat.png new file mode 100644 index 00000000..93866d10 Binary files /dev/null and b/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/monster-bat.png differ diff --git a/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/monster-goblin.png b/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/monster-goblin.png new file mode 100644 index 00000000..705fa2d3 Binary files /dev/null and b/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/monster-goblin.png differ diff --git a/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/monster-skeleton.png b/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/monster-skeleton.png new file mode 100644 index 00000000..fca32b86 Binary files /dev/null and b/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/monster-skeleton.png differ diff --git a/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/monster-slime.png b/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/monster-slime.png new file mode 100644 index 00000000..179f42fd Binary files /dev/null and b/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/monster-slime.png differ diff --git a/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/monster-wolf.png b/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/monster-wolf.png new file mode 100644 index 00000000..be9422cd Binary files /dev/null and b/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/monster-wolf.png differ diff --git a/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/tile_0096.png b/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/tile_0096.png new file mode 100644 index 00000000..ed3029ba Binary files /dev/null and b/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/tile_0096.png differ diff --git a/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/tile_0111.png b/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/tile_0111.png new file mode 100644 index 00000000..564b39cc Binary files /dev/null and b/src/OpenRpg.Demos.Battler/Content/Sprites/Enemies/tile_0111.png differ diff --git a/src/OpenRpg.Demos.Battler/Content/Sprites/Players/class-black-mage.png b/src/OpenRpg.Demos.Battler/Content/Sprites/Players/class-black-mage.png new file mode 100644 index 00000000..83aef729 Binary files /dev/null and b/src/OpenRpg.Demos.Battler/Content/Sprites/Players/class-black-mage.png differ diff --git a/src/OpenRpg.Demos.Battler/Content/Sprites/Players/class-monk.png b/src/OpenRpg.Demos.Battler/Content/Sprites/Players/class-monk.png new file mode 100644 index 00000000..94aa932d Binary files /dev/null and b/src/OpenRpg.Demos.Battler/Content/Sprites/Players/class-monk.png differ diff --git a/src/OpenRpg.Demos.Battler/Content/Sprites/Players/class-thief.png b/src/OpenRpg.Demos.Battler/Content/Sprites/Players/class-thief.png new file mode 100644 index 00000000..6026b281 Binary files /dev/null and b/src/OpenRpg.Demos.Battler/Content/Sprites/Players/class-thief.png differ diff --git a/src/OpenRpg.Demos.Battler/Content/Sprites/Players/class-warrior.png b/src/OpenRpg.Demos.Battler/Content/Sprites/Players/class-warrior.png new file mode 100644 index 00000000..33953716 Binary files /dev/null and b/src/OpenRpg.Demos.Battler/Content/Sprites/Players/class-warrior.png differ diff --git a/src/OpenRpg.Demos.Battler/Content/Sprites/Players/class-white-mage.png b/src/OpenRpg.Demos.Battler/Content/Sprites/Players/class-white-mage.png new file mode 100644 index 00000000..2278e3ae Binary files /dev/null and b/src/OpenRpg.Demos.Battler/Content/Sprites/Players/class-white-mage.png differ diff --git a/src/OpenRpg.Demos.Battler/Content/Sprites/Players/tile_0085.png b/src/OpenRpg.Demos.Battler/Content/Sprites/Players/tile_0085.png new file mode 100644 index 00000000..5f12f941 Binary files /dev/null and b/src/OpenRpg.Demos.Battler/Content/Sprites/Players/tile_0085.png differ diff --git a/src/OpenRpg.Demos.Battler/Content/Sprites/Players/tile_0086.png b/src/OpenRpg.Demos.Battler/Content/Sprites/Players/tile_0086.png new file mode 100644 index 00000000..0a1cb832 Binary files /dev/null and b/src/OpenRpg.Demos.Battler/Content/Sprites/Players/tile_0086.png differ diff --git a/src/OpenRpg.Demos.Battler/Content/Sprites/Players/tile_0087.png b/src/OpenRpg.Demos.Battler/Content/Sprites/Players/tile_0087.png new file mode 100644 index 00000000..1b476473 Binary files /dev/null and b/src/OpenRpg.Demos.Battler/Content/Sprites/Players/tile_0087.png differ diff --git a/src/OpenRpg.Demos.Battler/Content/Sprites/Players/tile_0088.png b/src/OpenRpg.Demos.Battler/Content/Sprites/Players/tile_0088.png new file mode 100644 index 00000000..b0106b2d Binary files /dev/null and b/src/OpenRpg.Demos.Battler/Content/Sprites/Players/tile_0088.png differ diff --git a/src/OpenRpg.Demos.Battler/Icon.ico b/src/OpenRpg.Demos.Battler/Icon.ico new file mode 100644 index 00000000..7d9dec18 Binary files /dev/null and b/src/OpenRpg.Demos.Battler/Icon.ico differ diff --git a/src/OpenRpg.Demos.Battler/OpenRpg.Demos.Battler.csproj b/src/OpenRpg.Demos.Battler/OpenRpg.Demos.Battler.csproj new file mode 100644 index 00000000..685c77e7 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/OpenRpg.Demos.Battler.csproj @@ -0,0 +1,38 @@ + + + WinExe + net10.0-windows + Major + false + false + true + true + + + app.manifest + Icon.ico + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + + + \ No newline at end of file diff --git a/src/OpenRpg.Demos.Battler/Program.cs b/src/OpenRpg.Demos.Battler/Program.cs new file mode 100644 index 00000000..29545053 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/Program.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.DependencyInjection; +using OpenRpg.Demos.Battler.Code; +using OpenRpg.Demos.Battler.Code.Extensions; + +var provider = new ServiceCollection() + .WithOpenRpgFramework() + .WithOpenRpgProject() + .WithMonoGameServices() + .WithSceneServices() + .BuildServiceProvider(); + +using var game = new BattlerGame(provider); +game.Run(); diff --git a/src/OpenRpg.Demos.Battler/README.md b/src/OpenRpg.Demos.Battler/README.md new file mode 100644 index 00000000..a49086d3 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/README.md @@ -0,0 +1,187 @@ +# OpenRpg Battler Demo + +A retro turn-based combat demo built with **MonoGame** + **Gum** on top of the **OpenRpg** framework. + +The demo exists to show how OpenRpg's data-driven systems work in practice: game content is defined entirely in JSON files, loaded at startup, and then everything from stats to abilities to combat flows through OpenRpg related functionality. + +Controls during battle: +| Key | Action | +|-----|--------| +| **Up / Down** | Navigate menu items | +| **Enter / Space** | Confirm selection | +| **Escape / Back** | Go back, or default attack on first enemy | +| **Space / Enter** | (On game over screen) Restart battle | + +## How It Uses OpenRpg + +### The Game Data Pipeline + +If you have not used the editor before then take a look at that, it's a great way to create and edit game content. + +This makes use of a project file that describes the rpg content, and loads it at runtime into the underlying data sources (Locale and Templates). + +All game content lives in `Content/Project/`: + +``` +Content/Project/ +├── project.json # Project manifest (version, plugins) +├── locales/ +│ └── en-gb.json # Strings with locale IDs +└── templates/ + ├── RaceTemplate.json # Race definitions (e.g. Human) + ├── ClassTemplate.json # Character classes (Warrior, Monk, etc.) + ├── EntityTemplate.json # Monster templates + ├── ItemTemplate.json # Items + └── AbilityTemplate.json # Abilities (Slash, FireBolt, etc.) +``` + +At startup, `BattlerGame` loads `project.json` via the `FileDataLoader` service. This triggers the JSON pipeline: + +1. **JsonTemplateLoader** reads each template file and deserializes JSON into typed objects using Newtonsoft.Json with OpenRpg's custom `VariablesConverter` +2. **JsonTemplateDatastorePopulator** populates the `InMemoryDataSource` with RaceTemplate, ClassTemplate, EntityTemplate, ItemTemplate, and AbilityTemplate instances +3. **InMemoryDataSource** becomes the central `IDataSource` service — the rest of the game queries it by ID to look up templates + +> Some of this information is only relevant if you are using the JSON related project loader, you can store the project data in any format as long as there is a suitable loader available +> or even bypass the loader entirely and just populate the `IDataSource` directly with your own data at runtime. + +Feel free to change the data inside the project, for example if you want to change how much HP a Warrior has? Edit the `HealthBonusAmount` effect in `ClassTemplate.json`. Want to add a new ability? Add an entry to `AbilityTemplate.json`. + +### How Templates Become Characters + +When a battle starts: + +**Party members** go through `GameCharacterBuilder` (a subclass of `FantasyCharacterBuilder`): +1. Creates a `Character` with a Race ID (Human) and Class ID (e.g. Warrior) +2. The **CharacterEffectProcessor** computes effects from the class template (stat bonuses, mana bonuses, etc.) +3. **FantasyStatsPopulator** converts those effects into concrete stats: MaxHealth, Damage, MovementSpeed, MaxMana, attributes, etc. +4. **FantasyStatePopulator** sets initial state: Health = MaxHealth, Mana = MaxMana +5. Abilities from the class template are copied to the character's Variables + +**Monsters** use the same pipeline but via `ICharacterPopulator.Populate()` directly with an `EntityTemplate`. + +> We currently dont use Race/Class on the monsters EntityTemplate, but the system supports it if you wanted to give entities (monsters or npcs) specific races/classes/equipment etc. + +### The DI Wiring + +The demo wires everything up in `Program.cs` using four DI related extension methods: + +```csharp +.WithOpenRpgFramework() // Battle services, generators, populators +.WithOpenRpgProject() // JSON data pipeline, InMemoryDataSource +.WithMonoGameServices() // MonoGame wrappers (SpriteBatch, Content) +.WithSceneServices() // Scene management, party/enemy providers, BattleScene +``` +## Code Architecture Tour + +``` +Code/ +├── Program.cs # Entry point — DI setup, kicks off the game +├── BattlerGame.cs # Game loop: loads project, delegates to SceneManager +├── Builders/ +│ └── GameCharacterBuilder.cs # FantasyCharacterBuilder subclass (copies AssetCode) +├── Extensions/ +│ └── IServiceCollectionExtensions.cs # All DI registration methods +├── Scenes/ +│ ├── IScene.cs / ISceneManager.cs / SceneManager.cs # Scene lifecycle abstraction +│ └── Battle/ # Everything battle-related +│ ├── BattleScene.cs # Orchestrator — wires providers, UI, combat together +│ ├── Combat/ +│ │ └── TurnManager.cs # Turn state machine + ability/attack execution +│ ├── Models/ +│ │ ├── BattleEntity.cs # Wraps a Character with position, sprite, HP delegation +│ │ ├── PlayerAction.cs # Data class for player commands +│ │ ├── FloatingDamageNumber.cs # Float-up damage text animation +│ │ ├── Team.cs # Player / Enemy enum +│ │ └── ActionType.cs # BasicAttack / Ability / UseItem / Flee enum +│ ├── Providers/ +│ │ ├── IPartyProvider.cs / PartyProvider.cs # Builds 4 random party members +│ │ └── IEnemyFormationProvider.cs / EnemyFormationProvider.cs # Builds 6 monsters +│ ├── Rendering/ +│ │ ├── EntityRenderer.cs # Draws sprites, HP bars, arrows, backgrounds +│ │ ├── SpriteCache.cs # Loads and caches textures from ContentManager +│ │ └── Palette.cs # Shared color constants +│ └── UI/ +│ ├── CombatLogUi.cs # Action message strip +│ ├── TurnOrderUi.cs # Turn order chips +│ ├── BattleBottomPanelUi.cs # HP/MP bar panel +│ ├── CommandMenuUi.cs # Fight / Ability / Target selection menus +│ ├── TextHelper.cs # Kenney Pixel space-width workaround +│ ├── GumExtensions.cs # SetRectColor() helper +│ ├── NameHelper.cs # PascalCase → spaced words +│ └── InputHelper.cs # Keyboard edge detection +├── Services/Game/ +│ └── IGameServices.cs / GameServices.cs # MonoGame service bag +└── Types/ + ├── ClassLookups.cs # Class ID constants + GetRandomPartyIds() + └── RaceLookups.cs # Race ID constants +``` + +### How a Turn Works + +This isnt really specific to OpenRpg, but it's a good example of how the combat system works. + +``` +Idle + → AdvanceTurn (find next alive entity by Initiative order) + ├─ Party turn → PlayerInput (keyboard menu via CommandMenuUi) + │ └─ BattleScene calls SubmitPlayerAction() → ExecuteAction() + └─ Enemy turn → TurnDwell (0.8s auto) + + TurnDwell (0.8s): gold arrow bobs above attacker + └─ ExecuteAction(): + ├─ Player command: BasicAttack → ExecuteBasicAttack() + │ or Ability → ExecuteAbility() + └─ Enemy: auto basic attack with random target + + ExecuteAbility: + ├─ Clone template Damage object + ├─ FantasyAttackGenerator.GenerateAttack() → adds stat bonuses, ±5%, crit check + ├─ DefaultAttackProcessor.ProcessAttack() → subtracts target defenses + ├─ Deduct HP (min 1), deduct mana + └─ Set LastActionMessage + + → TargetFlash (0.3s): red flash on hit targets + → CheckGameOver → if both alive, back to Idle +``` + +### The Attack Pipeline + +All damage flows through OpenRpg's combat pipeline: + +``` +Damage { Type: Fire(80), Value: 22 } + → FantasyAttackGenerator + ├─ Adds attacker's FireDamage stat bonus + ├─ ±5% random variance + ├─ Critical hit check (multiply by CriticalDamageMultiplier) + └─ Returns Attack (array of typed damages + IsCritical flag) + → DefaultAttackProcessor + ├─ Looks up target's defense for each damage type + ├─ damage = max(0, damage - defense) + └─ Returns ProcessedAttack with final DamageDone +``` + +This means an ability like **FireBolt** (Fire damage 22) will also benefit from any +FireDamage stats the caster has, and the target's FireDefense will reduce it. + +--- + +## How to Customize the Demo + +### Add a New Class +1. Add an entry to `Content/Project/templates/ClassTemplate.json` with a unique ID, name locale ID, asset code, and effects (stat bonuses, mana) +2. Add its sprite to `Content/Sprites/Players/` and add a Content Pipeline entry in `Content.mgcb` +3. Add the class ID constant to `Code/Types/ClassLookups.cs` +4. (Optionally) Create abilities that require this class in `AbilityTemplate.json` + +### Add a New Monster +1. Add an entry to `Content/Project/templates/EntityTemplate.json` with a unique ID, name, asset code, and effects +2. Add its sprite to `Content/Sprites/Enemies/` and add a Content Pipeline entry + +### Add a New Ability +1. Add an entry to `Content/Project/templates/AbilityTemplate.json` with damage, target type/count, mana cost. +2. Ensure the class template lists this ability ID in its `Variables.Abilities` array + +> We currently have requirements in place on abilities so that they can only be used by a specific class, but this isnt mandatory. + +### Tweak Stats +All stats come from **effects** on templates. For example, the Warrior class has an effect with type `HealthBonusAmount` (60) which gives +60 MaxHP. Change the value there, or add more effects (Strength, Damage, etc.). \ No newline at end of file diff --git a/src/OpenRpg.Demos.Battler/app.manifest b/src/OpenRpg.Demos.Battler/app.manifest new file mode 100644 index 00000000..fd83c584 --- /dev/null +++ b/src/OpenRpg.Demos.Battler/app.manifest @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + + diff --git a/src/OpenRpg.Demos.Infrastructure/DI/OpenRpgDataModule.cs b/src/OpenRpg.Demos.Infrastructure/DI/OpenRpgDataModule.cs index 68b783c5..9657a994 100644 --- a/src/OpenRpg.Demos.Infrastructure/DI/OpenRpgDataModule.cs +++ b/src/OpenRpg.Demos.Infrastructure/DI/OpenRpgDataModule.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Microsoft.Extensions.DependencyInjection; +using OpenRpg.Combat.Abilities; using OpenRpg.Core.Templates; using OpenRpg.Data; using OpenRpg.Data.InMemory; @@ -9,10 +10,14 @@ using OpenRpg.Demos.Infrastructure.Extensions; using OpenRpg.Entities.Classes.Templates; using OpenRpg.Entities.Races.Templates; +using OpenRpg.Entities.Modifications.Templates; using OpenRpg.Items.Templates; using OpenRpg.Items.TradeSkills.Templates; +using OpenRpg.Quests; +using OpenRpg.Quests.Factions; using OpenRpg.Localization.Data.DataSources; using OpenRpg.Localization.Data.Repositories; +using OpenRpg.Demos.Infrastructure.Services; namespace OpenRpg.Demos.Infrastructure.DI { @@ -26,6 +31,9 @@ public void Setup(IServiceCollection services) services.AddSingleton(); services.AddSingleton(x => new LocaleRepository(x.GetService(), "en-gb")); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } public InMemoryDataSource GenerateDataSource() @@ -36,6 +44,10 @@ public InMemoryDataSource GenerateDataSource() data.Add(typeof(ItemTemplate), new ItemTemplateDataGenerator().GenerateDictionary()); data.Add(typeof(ItemGatheringTemplate), new GatheringTemplateDataGenerator().GenerateDictionary()); data.Add(typeof(ItemCraftingTemplate), new CraftingTemplateDataGenerator().GenerateDictionary()); + data.Add(typeof(ItemModificationTemplate), new ItemModificationTemplateDataGenerator().GenerateDictionary()); + data.Add(typeof(QuestTemplate), new QuestStateDataGenerator().GenerateDictionary()); + data.Add(typeof(DefaultFaction), new FactionDataGenerator().GenerateDictionary()); + data.Add(typeof(AbilityTemplate), new AbilityTemplateDataGenerator().GenerateDictionary()); return new InMemoryDataSource(data); } diff --git a/src/OpenRpg.Demos.Infrastructure/DI/OpenRpgModule.cs b/src/OpenRpg.Demos.Infrastructure/DI/OpenRpgModule.cs index 9ab3bcbd..24504944 100644 --- a/src/OpenRpg.Demos.Infrastructure/DI/OpenRpgModule.cs +++ b/src/OpenRpg.Demos.Infrastructure/DI/OpenRpgModule.cs @@ -1,23 +1,29 @@ using System; +using System.Collections.Generic; using Microsoft.Extensions.DependencyInjection; using OpenRpg.Combat.Processors.Attacks; using OpenRpg.Combat.Processors.Attacks.Entity; using OpenRpg.Core.Utils; using OpenRpg.Demos.Infrastructure.Scheduling; -using OpenRpg.Entities.Effects.Processors; using OpenRpg.Entities.Entity.Populators.State; using OpenRpg.Entities.Entity.Populators.Stats; using OpenRpg.Entities.Stats.Variables; using OpenRpg.Genres.Effects; +using OpenRpg.Genres.Fantasy.Builders; using OpenRpg.Genres.Fantasy.Combat; +using OpenRpg.Genres.Fantasy.Equippables.Validators; using OpenRpg.Genres.Fantasy.Requirements; -using OpenRpg.Genres.Fantasy.State; +using OpenRpg.Genres.Fantasy.Objectives; using OpenRpg.Genres.Fantasy.State.Populators; -using OpenRpg.Genres.Fantasy.Stats; using OpenRpg.Genres.Fantasy.Stats.Populators; using OpenRpg.Genres.Populators.Entity; using OpenRpg.Genres.Populators.Entity.Stats; using OpenRpg.Genres.Requirements; +using OpenRpg.Genres.Objectives; +using OpenRpg.Items.Equippables.Slots; +using OpenRpg.Items.Loot; +using OpenRpg.Tags; +using OpenRpg.Tags.Data; namespace OpenRpg.Demos.Infrastructure.DI { @@ -30,11 +36,51 @@ public void Setup(IServiceCollection services) services.AddSingleton(new FantasyStatsPopulator([new DamageStatPopulator(), new DefenseStatPopulator()])); services.AddSingleton(); services.AddSingleton(x => new DefaultRandomizer(new Random())); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton, DefaultAttackProcessor>(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(CreateTagRegistry()); + } + + private static ITagRegistry CreateTagRegistry() + { + // Tag IDs used in the demo + const int Armour = 1, Weapon = 2, Heavy = 3, Light = 4, Metal = 5, Wood = 6, Leather = 7, Fire = 8, Ice = 9, Consumable = 10; + + var registry = new TagRegistry(); + registry.AddRelationship(Armour, Heavy, 0.5f); + registry.AddRelationship(Armour, Light, 0.5f); + registry.AddRelationship(Armour, Metal, 0.4f); + registry.AddRelationship(Armour, Wood, 0.2f); + registry.AddRelationship(Armour, Leather, 0.6f); + + registry.AddRelationship(Weapon, Heavy, 0.3f); + registry.AddRelationship(Weapon, Light, 0.7f); + registry.AddRelationship(Weapon, Metal, 0.8f); + registry.AddRelationship(Weapon, Wood, 0.4f); + + registry.AddRelationship(Heavy, Metal, 0.6f); + registry.AddRelationship(Heavy, Wood, 0.3f); + registry.AddRelationship(Heavy, Leather, -0.3f); + + registry.AddRelationship(Light, Leather, 0.7f); + registry.AddRelationship(Light, Wood, 0.3f); + registry.AddRelationship(Light, Metal, -0.2f); + + registry.AddRelationship(Fire, Heavy, -0.3f); + registry.AddRelationship(Fire, Light, 0.3f); + + registry.AddRelationship(Ice, Heavy, 0.3f); + registry.AddRelationship(Ice, Light, -0.3f); + + return registry; } } } \ No newline at end of file diff --git a/src/OpenRpg.Demos.Infrastructure/Data/AbilityTemplateDataGenerator.cs b/src/OpenRpg.Demos.Infrastructure/Data/AbilityTemplateDataGenerator.cs new file mode 100644 index 00000000..9b077bdc --- /dev/null +++ b/src/OpenRpg.Demos.Infrastructure/Data/AbilityTemplateDataGenerator.cs @@ -0,0 +1,190 @@ +using System.Collections.Generic; +using OpenRpg.Combat.Abilities; +using OpenRpg.Combat.Attacks; +using OpenRpg.Combat.Extensions; +using OpenRpg.Combat.Types; +using OpenRpg.Core.Associations; +using OpenRpg.Core.Requirements; +using OpenRpg.Demos.Infrastructure.Lookups; +using OpenRpg.Entities.Extensions; +using OpenRpg.Entities.Types; +using OpenRpg.Genres.Fantasy.Extensions; +using OpenRpg.Genres.Fantasy.Types; + +namespace OpenRpg.Demos.Infrastructure.Data +{ + /// + /// Mirrors the Battler demo's 10-ability roster (Slash, Power Strike, Chi Blast, + /// Focus Strike, Backstab, Poison Blade, Fire Bolt, Ice Storm, Cure, Cura) so + /// the web demo can show every TargetType and a class-gated requirement + /// in the ability execution sandbox. + /// + public class AbilityTemplateDataGenerator : IDataGenerator + { + public const int Slash = 1; + public const int PowerStrike = 2; + public const int ChiBlast = 3; + public const int FocusStrike = 4; + public const int Backstab = 5; + public const int PoisonBlade = 6; + public const int FireBolt = 7; + public const int IceStorm = 8; + public const int Cure = 9; + public const int Cura = 10; + + public IEnumerable GenerateData() + { + return new[] + { + MakeSlash(), + MakePowerStrike(), + MakeChiBlast(), + MakeFocusStrike(), + MakeBackstab(), + MakePoisonBlade(), + MakeFireBolt(), + MakeIceStorm(), + MakeCure(), + MakeCura(), + }; + } + + public AbilityTemplate MakeSlash() + { + var template = NewFighterAbility(Slash, "ability-slash", "ability-slash-desc", + new Damage(FantasyDamageTypes.SlashingDamage, 10f), + targetType: CombatTargetTypes.MultipleTarget, targetCount: 3, manaCost: 4); + template.Variables.AssetCode = "ability-slash"; + return template; + } + + public AbilityTemplate MakePowerStrike() + { + var template = NewFighterAbility(PowerStrike, "ability-power-strike", "ability-power-strike-desc", + new Damage(FantasyDamageTypes.SlashingDamage, 22f), + targetType: CombatTargetTypes.SingleTarget, targetCount: 1, manaCost: 8); + template.Variables.AssetCode = "ability-power-strike"; + return template; + } + + public AbilityTemplate MakeChiBlast() + { + var template = NewMageAbility(ChiBlast, "ability-chi-blast", "ability-chi-blast-desc", + new Damage(FantasyDamageTypes.BluntDamage, 8f), + targetType: CombatTargetTypes.MultipleTarget, targetCount: 6, manaCost: 5); + template.Variables.AssetCode = "ability-chi-blast"; + return template; + } + + public AbilityTemplate MakeFocusStrike() + { + var template = NewMageAbility(FocusStrike, "ability-focus-strike", "ability-focus-strike-desc", + new Damage(FantasyDamageTypes.BluntDamage, 28f), + targetType: CombatTargetTypes.SingleTarget, targetCount: 1, manaCost: 10); + template.Variables.AssetCode = "ability-focus-strike"; + return template; + } + + public AbilityTemplate MakeBackstab() + { + var template = NewFighterAbility(Backstab, "ability-backstab", "ability-backstab-desc", + new Damage(FantasyDamageTypes.PiercingDamage, 25f), + targetType: CombatTargetTypes.SingleTarget, targetCount: 1, manaCost: 4); + template.Variables.AssetCode = "ability-backstab"; + return template; + } + + public AbilityTemplate MakePoisonBlade() + { + var template = NewFighterAbility(PoisonBlade, "ability-poison-blade", "ability-poison-blade-desc", + new Damage(FantasyDamageTypes.PiercingDamage, 12f), + targetType: CombatTargetTypes.SingleTarget, targetCount: 1, manaCost: 3); + template.Variables.AssetCode = "ability-poison-blade"; + return template; + } + + public AbilityTemplate MakeFireBolt() + { + var template = NewMageAbility(FireBolt, "ability-fire-bolt", "ability-fire-bolt-desc", + new Damage(FantasyDamageTypes.FireDamage, 22f), + targetType: CombatTargetTypes.SingleTarget, targetCount: 1, manaCost: 15); + template.Variables.AssetCode = "ability-fire-bolt"; + return template; + } + + public AbilityTemplate MakeIceStorm() + { + var template = NewMageAbility(IceStorm, "ability-ice-storm", "ability-ice-storm-desc", + new Damage(FantasyDamageTypes.IceDamage, 12f), + targetType: CombatTargetTypes.MultipleTarget, targetCount: 6, manaCost: 22); + template.Variables.AssetCode = "ability-ice-storm"; + return template; + } + + public AbilityTemplate MakeCure() + { + var template = NewHealingAbility(Cure, "ability-cure", "ability-cure-desc", + new Damage(FantasyDamageTypes.LightDamage, 45f), + targetType: CombatTargetTypes.SingleTarget, targetCount: 1, manaCost: 8); + template.Variables.AssetCode = "ability-cure"; + return template; + } + + public AbilityTemplate MakeCura() + { + var template = NewHealingAbility(Cura, "ability-cura", "ability-cura-desc", + new Damage(FantasyDamageTypes.LightDamage, 18f), + targetType: CombatTargetTypes.MultipleTarget, targetCount: 4, manaCost: 14); + template.Variables.AssetCode = "ability-cura"; + return template; + } + + private static AbilityTemplate NewFighterAbility(int id, string nameLocaleId, string descLocaleId, + Damage damage, int targetType, int targetCount, int manaCost) + { + return NewClassGatedAbility(id, nameLocaleId, descLocaleId, damage, targetType, targetCount, manaCost, + ClassTypeLookups.Fighter); + } + + private static AbilityTemplate NewMageAbility(int id, string nameLocaleId, string descLocaleId, + Damage damage, int targetType, int targetCount, int manaCost) + { + return NewClassGatedAbility(id, nameLocaleId, descLocaleId, damage, targetType, targetCount, manaCost, + ClassTypeLookups.Mage); + } + + private static AbilityTemplate NewHealingAbility(int id, string nameLocaleId, string descLocaleId, + Damage damage, int targetType, int targetCount, int manaCost) + { + return NewClassGatedAbility(id, nameLocaleId, descLocaleId, damage, targetType, targetCount, manaCost, + ClassTypeLookups.Mage); + } + + private static AbilityTemplate NewClassGatedAbility(int id, string nameLocaleId, string descLocaleId, + Damage damage, int targetType, int targetCount, int manaCost, int requiredClassId) + { + var template = new AbilityTemplate + { + Id = id, + NameLocaleId = nameLocaleId, + DescriptionLocaleId = descLocaleId, + }; + template.Variables.Damage = damage; + template.Variables.Cooldown = 1.0f; + template.Variables.Range = 7.0f; + template.Variables.AttackSize = 2.0f; + template.Variables.TargetType = targetType; + template.Variables.TargetCount = targetCount; + template.Variables.ManaCost = manaCost; + template.Variables.Requirements = new[] + { + new Requirement + { + RequirementType = CoreRequirementTypes.ClassRequirement, + Association = new Association(requiredClassId, 0) + } + }; + return template; + } + } +} diff --git a/src/OpenRpg.Demos.Infrastructure/Data/ClassTemplateDataGenerator.cs b/src/OpenRpg.Demos.Infrastructure/Data/ClassTemplateDataGenerator.cs index 58636bed..923367ac 100644 --- a/src/OpenRpg.Demos.Infrastructure/Data/ClassTemplateDataGenerator.cs +++ b/src/OpenRpg.Demos.Infrastructure/Data/ClassTemplateDataGenerator.cs @@ -36,9 +36,9 @@ public ClassTemplate GenerateFighterClass() Id = ClassTypeLookups.Fighter, NameLocaleId = "Fighter", DescriptionLocaleId = "Super tough, hits things", - Effects = effects }; - classTemplate.Variables.AssetCode("class-fighter"); + classTemplate.Variables.AssetCode = "class-fighter"; + classTemplate.Variables.Effects = effects; return classTemplate; } @@ -56,10 +56,10 @@ public ClassTemplate GenerateMageClass() { Id = ClassTypeLookups.Mage, NameLocaleId = "Mage", - DescriptionLocaleId = "Powerful magic users", - Effects = effects + DescriptionLocaleId = "Powerful magic users" }; - classTemplate.Variables.AssetCode("class-mage"); + classTemplate.Variables.AssetCode = "class-mage"; + classTemplate.Variables.Effects = effects; return classTemplate; } diff --git a/src/OpenRpg.Demos.Infrastructure/Data/CraftingTemplateDataGenerator.cs b/src/OpenRpg.Demos.Infrastructure/Data/CraftingTemplateDataGenerator.cs index d1556b00..6b312439 100644 --- a/src/OpenRpg.Demos.Infrastructure/Data/CraftingTemplateDataGenerator.cs +++ b/src/OpenRpg.Demos.Infrastructure/Data/CraftingTemplateDataGenerator.cs @@ -1,9 +1,8 @@ using System.Collections.Generic; using OpenRpg.Core.Associations; using OpenRpg.Core.Requirements; -using OpenRpg.Core.Utils; using OpenRpg.Demos.Infrastructure.Lookups; -using OpenRpg.Entities.Requirements; +using OpenRpg.Entities.Extensions; using OpenRpg.Genres.Fantasy.Types; using OpenRpg.Items.TradeSkills; using OpenRpg.Items.TradeSkills.Extensions; @@ -25,44 +24,57 @@ public IEnumerable GenerateData() public ItemCraftingTemplate MakeCopperIngotCraftingTemplate() { var inputItemEntry = new TradeSkillItemEntry() { TemplateId = ItemTemplateLookups.CopperOre }; - inputItemEntry.Variables.Amount(5); + inputItemEntry.Variables.Amount = 5; var outputItemEntry = new TradeSkillItemEntry() { TemplateId = ItemTemplateLookups.CopperIngot }; - outputItemEntry.Variables.Amount(1); + outputItemEntry.Variables.Amount = 1; - return new ItemCraftingTemplate() + var template = new ItemCraftingTemplate() { Id = ItemCraftingTemplateLookups.CopperIngot, - SkillType = FantasyCraftingTradeSkillTypes.Smelting, - SkillDifficulty = 0, - TimeToComplete = 2.0f, InputItems = new List() { inputItemEntry }, OutputItems = new List() { outputItemEntry }, }; + template.Variables.SkillType = FantasyTradeSkillTypes.Smelting; + template.Variables.TimeToAction = 2.0f; + return template; } public ItemCraftingTemplate MakeCopperSwordCraftingTemplate() { var inputItem1Entry = new TradeSkillItemEntry() { TemplateId = ItemTemplateLookups.CopperIngot }; - inputItem1Entry.Variables.Amount(2); + inputItem1Entry.Variables.Amount = 2; var inputItem2Entry = new TradeSkillItemEntry() { TemplateId = ItemTemplateLookups.OakLog }; - inputItem2Entry.Variables.Amount(1); + inputItem2Entry.Variables.Amount = 1; var outputItemEntry = new TradeSkillItemEntry() { TemplateId = ItemTemplateLookups.CopperSword }; - outputItemEntry.Variables.Amount(1); + outputItemEntry.Variables.Amount = 1; - return new ItemCraftingTemplate() + var craftingTemplate = new ItemCraftingTemplate() { Id = ItemCraftingTemplateLookups.CopperSword, - SkillType = FantasyCraftingTradeSkillTypes.Smithing, - SkillDifficulty = 10, - TimeToComplete = 2.0f, InputItems = new List() { inputItem1Entry, inputItem2Entry }, - OutputItems = new List() { outputItemEntry }, - Requirements = new [] + OutputItems = new List() { outputItemEntry } + }; + craftingTemplate.Variables.SkillType = FantasyTradeSkillTypes.Smithing; + craftingTemplate.Variables.TimeToAction = 2.0f; + craftingTemplate.Variables.Requirements = + [ + new Requirement + { + RequirementType = FantasyRequirementTypes.TradeSkillRequirement, + Association = new Association(FantasyTradeSkillTypes.Smithing, 10) + } + ]; + + craftingTemplate.Variables.Requirements = new[] + { + new Requirement { - new Requirement { RequirementType = FantasyRequirementTypes.TradeSkillRequirement, Association = new Association(FantasyCraftingTradeSkillTypes.Smithing, 5) } + RequirementType = FantasyRequirementTypes.TradeSkillRequirement, + Association = new Association(FantasyTradeSkillTypes.Smithing, 5) } }; + return craftingTemplate; } } \ No newline at end of file diff --git a/src/OpenRpg.Demos.Infrastructure/Data/FactionDataGenerator.cs b/src/OpenRpg.Demos.Infrastructure/Data/FactionDataGenerator.cs new file mode 100644 index 00000000..bfc5a5ee --- /dev/null +++ b/src/OpenRpg.Demos.Infrastructure/Data/FactionDataGenerator.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using OpenRpg.Quests.Factions; + +namespace OpenRpg.Demos.Infrastructure.Data +{ + public class FactionDataGenerator : IDataGenerator + { + public const int MerchantsGuildId = 1; + public const int CityGuardId = 2; + public const int ThievesGuildId = 3; + + public IEnumerable GenerateData() + { + return new[] + { + MakeMerchantsGuild(), + MakeCityGuard(), + MakeThievesGuild() + }; + } + + private DefaultFaction MakeMerchantsGuild() + { + return new DefaultFaction + { + Id = MerchantsGuildId, + NameLocaleId = "Merchants Guild", + DescriptionLocaleId = "A powerful trade organisation that controls most commerce in the region. They value fair dealing and reliable trade partners." + }; + } + + private DefaultFaction MakeCityGuard() + { + return new DefaultFaction + { + Id = CityGuardId, + NameLocaleId = "City Guard", + DescriptionLocaleId = "The official law enforcement arm of the city, maintaining order and safety. They respect those who uphold the law." + }; + } + + private DefaultFaction MakeThievesGuild() + { + return new DefaultFaction + { + Id = ThievesGuildId, + NameLocaleId = "Thieves Guild", + DescriptionLocaleId = "A shadowy network of rogues and smugglers operating in the city's underbelth. Trust is earned through discrete transactions." + }; + } + } +} diff --git a/src/OpenRpg.Demos.Infrastructure/Data/GatheringTemplateDataGenerator.cs b/src/OpenRpg.Demos.Infrastructure/Data/GatheringTemplateDataGenerator.cs index d10a33bd..fc60df5e 100644 --- a/src/OpenRpg.Demos.Infrastructure/Data/GatheringTemplateDataGenerator.cs +++ b/src/OpenRpg.Demos.Infrastructure/Data/GatheringTemplateDataGenerator.cs @@ -1,9 +1,8 @@ using System.Collections.Generic; using OpenRpg.Core.Associations; using OpenRpg.Core.Requirements; -using OpenRpg.Core.Utils; using OpenRpg.Demos.Infrastructure.Lookups; -using OpenRpg.Entities.Requirements; +using OpenRpg.Entities.Extensions; using OpenRpg.Genres.Fantasy.Types; using OpenRpg.Items.TradeSkills; using OpenRpg.Items.TradeSkills.Extensions; @@ -26,50 +25,56 @@ public IEnumerable GenerateData() public ItemGatheringTemplate MakeCopperOreGatheringTemplate() { var itemEntry = new TradeSkillItemEntry() { TemplateId = ItemTemplateLookups.CopperOre }; - itemEntry.Variables.Amount(1); + itemEntry.Variables.Amount = 1; - return new ItemGatheringTemplate() + var template = new ItemGatheringTemplate() { Id = ItemGatheringTemplateLookups.CopperOre, - SkillType = FantasyGatheringTradeSkillTypes.Mining, - SkillDifficulty = 5, - TimeToComplete = 1.0f, OutputItems = new List() { itemEntry } }; + template.Variables.SkillType = FantasyTradeSkillTypes.Mining; + template.Variables.TimeToAction = 1.0f; + return template; } public ItemGatheringTemplate MakeIronOreGatheringTemplate() { var itemEntry = new TradeSkillItemEntry() { TemplateId = ItemTemplateLookups.IronOre }; - itemEntry.Variables.Amount(1); + itemEntry.Variables.Amount = 1; - return new ItemGatheringTemplate() + var template = new ItemGatheringTemplate() { Id = ItemGatheringTemplateLookups.IronOre, - SkillType = FantasyGatheringTradeSkillTypes.Mining, - SkillDifficulty = 15, - TimeToComplete = 1.0f, - OutputItems = new List() { itemEntry }, - Requirements = new [] - { - new Requirement { RequirementType = FantasyRequirementTypes.TradeSkillRequirement, Association = new Association(FantasyGatheringTradeSkillTypes.Mining, 10) } - }, + OutputItems = new List() { itemEntry } }; + template.Variables.SkillType = FantasyTradeSkillTypes.Mining; + template.Variables.TimeToAction = 1.0f; + template.Variables.Requirements = + [ + new Requirement + { + RequirementType = FantasyRequirementTypes.TradeSkillRequirement, + Association = new Association(FantasyTradeSkillTypes.Mining, 10) + } + ]; + return template; } public ItemGatheringTemplate MakeOakLogGatheringTemplate() { var itemEntry = new TradeSkillItemEntry() { TemplateId = ItemTemplateLookups.OakLog }; - itemEntry.Variables.Amount(1); + itemEntry.Variables.Amount = 1; - return new ItemGatheringTemplate() + var template = new ItemGatheringTemplate() { Id = ItemGatheringTemplateLookups.OakLog, - SkillType = FantasyGatheringTradeSkillTypes.Logging, - SkillDifficulty = 0, - TimeToComplete = 1.0f, OutputItems = new List() { itemEntry } }; + + template.Variables.SkillType = FantasyTradeSkillTypes.Logging; + template.Variables.TimeToAction = 1.0f; + + return template; } } diff --git a/src/OpenRpg.Demos.Infrastructure/Data/ItemModificationTemplateDataGenerator.cs b/src/OpenRpg.Demos.Infrastructure/Data/ItemModificationTemplateDataGenerator.cs new file mode 100644 index 00000000..28ad5c50 --- /dev/null +++ b/src/OpenRpg.Demos.Infrastructure/Data/ItemModificationTemplateDataGenerator.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; +using OpenRpg.Core.Effects; +using OpenRpg.Entities.Effects; +using OpenRpg.Entities.Extensions; +using OpenRpg.Entities.Modifications.Templates; +using OpenRpg.Genres.Fantasy.Types; +using OpenRpg.Items.Templates; + +namespace OpenRpg.Demos.Infrastructure.Data +{ + public class ItemModificationTemplateDataGenerator : IDataGenerator + { + public const int FireGemId = 1; + public const int FrostGemId = 2; + public const int PowerEnchantmentId = 3; + public const int SpeedRuneId = 4; + + public IEnumerable GenerateData() + { + return new[] + { + MakeFireGem(), + MakeFrostGem(), + MakePowerEnchantment(), + MakeSpeedRune() + }; + } + + private ItemModificationTemplate MakeFireGem() + { + var template = new ItemModificationTemplate + { + Id = FireGemId, + NameLocaleId = "Fire Gem", + DescriptionLocaleId = "A glowing red gem that adds fire damage to weapons", + ModificationType = FantasyModificationTypes.GemModification + }; + template.Variables.Effects = new[] + { + new StaticEffect { EffectType = FantasyEffectTypes.FireDamageAmount, Potency = 15.0f } + }; + return template; + } + + private ItemModificationTemplate MakeFrostGem() + { + var template = new ItemModificationTemplate + { + Id = FrostGemId, + NameLocaleId = "Frost Gem", + DescriptionLocaleId = "A shimmering blue gem that adds ice damage to weapons", + ModificationType = FantasyModificationTypes.GemModification + }; + template.Variables.Effects = new[] + { + new StaticEffect { EffectType = FantasyEffectTypes.IceDamageAmount, Potency = 12.0f } + }; + return template; + } + + private ItemModificationTemplate MakePowerEnchantment() + { + var template = new ItemModificationTemplate + { + Id = PowerEnchantmentId, + NameLocaleId = "Power Enchantment", + DescriptionLocaleId = "A magical enchantment that boosts strength and raw damage", + ModificationType = FantasyModificationTypes.EnchantmentModification + }; + template.Variables.Effects = new[] + { + new StaticEffect { EffectType = FantasyEffectTypes.StrengthBonusAmount, Potency = 5.0f }, + new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 10.0f } + }; + return template; + } + + private ItemModificationTemplate MakeSpeedRune() + { + var template = new ItemModificationTemplate + { + Id = SpeedRuneId, + NameLocaleId = "Speed Rune", + DescriptionLocaleId = "An ancient rune that enhances dexterity and evasion", + ModificationType = FantasyModificationTypes.RuneModification + }; + template.Variables.Effects = new[] + { + new StaticEffect { EffectType = FantasyEffectTypes.DexterityBonusAmount, Potency = 8.0f } + }; + return template; + } + } +} diff --git a/src/OpenRpg.Demos.Infrastructure/Data/ItemTemplateDataGenerator.cs b/src/OpenRpg.Demos.Infrastructure/Data/ItemTemplateDataGenerator.cs index ed025f64..135dffa1 100644 --- a/src/OpenRpg.Demos.Infrastructure/Data/ItemTemplateDataGenerator.cs +++ b/src/OpenRpg.Demos.Infrastructure/Data/ItemTemplateDataGenerator.cs @@ -11,17 +11,23 @@ using OpenRpg.Genres.Fantasy.Types; using OpenRpg.Items.Extensions; using OpenRpg.Items.Templates; +using OpenRpg.Tags; namespace OpenRpg.Demos.Infrastructure.Data { public class ItemTemplateDataGenerator : IDataGenerator { + public const int ArmourTag = 1, WeaponTag = 2, HeavyTag = 3, LightTag = 4, MetalTag = 5, WoodTag = 6, LeatherTag = 7, FireTag = 8, IceTag = 9, ConsumableTag = 10; + public IEnumerable GenerateData() { return new [] { MakeRubbishSword(), MakeSuperSword(), + MakeChest(), + MakeBoots(), + MakeHelm(), MakePotion(), MakeJunkPotion(), MakeCopperIngot(), @@ -40,16 +46,17 @@ public ItemTemplate MakeRubbishSword() NameLocaleId = "Sword", DescriptionLocaleId = "A really bad looking sword, can slay things though", ItemType = FantasyItemTypes.GenericWeapon, - ModificationAllowances = Array.Empty(), - Requirements = Array.Empty(), - Effects = new[] - { - new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 30.0f } - } + ModificationAllowances = Array.Empty() + }; + template.Variables.QualityType = FantasyItemQualityTypes.JunkQuality; + template.Variables.Value = 10; + template.Variables.AssetCode = "sword"; + template.Variables.MaxStacks = 1; + template.Variables.Effects = new[] + { + new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 30.0f } }; - template.Variables.QualityType(FantasyItemQualityTypes.JunkQuality); - template.Variables.Value(10); - template.Variables.AssetCode("sword"); + template.Variables.Tags = new TagList(WeaponTag, HeavyTag, MetalTag); return template; } @@ -71,13 +78,85 @@ private ItemTemplate MakeSuperSword() NameLocaleId = "Super Sword", DescriptionLocaleId = "So fancy it could slice through stone", ItemType = FantasyItemTypes.GenericWeapon, - ModificationAllowances = Array.Empty(), - Requirements = Array.Empty(), - Effects = swordEffects + ModificationAllowances = Array.Empty() + }; + template.Variables.QualityType = FantasyItemQualityTypes.LegendaryQuality; + template.Variables.Value = 10000; + template.Variables.AssetCode = "sword"; + template.Variables.Effects = swordEffects; + template.Variables.Tags = new TagList(WeaponTag, HeavyTag, MetalTag, FireTag); + + return template; + } + + private ItemTemplate MakeChest() + { + var chestEffects = new[] + { + new StaticEffect { EffectType = FantasyEffectTypes.DefenseBonusAmount, Potency = 20.0f }, + new StaticEffect { EffectType = FantasyEffectTypes.DexterityBonusAmount, Potency = 1.0f } + }; + + var template = new ItemTemplate + { + Id = ItemTemplateLookups.Chest, + NameLocaleId = "Generic Chest Piece", + DescriptionLocaleId = "Its so generic you cannot ascertain what its made of, but its certainly a chest piece", + ItemType = FantasyItemTypes.UpperBodyArmour }; - template.Variables.QualityType(FantasyItemQualityTypes.EpicQuality); - template.Variables.Value(10000); - template.Variables.AssetCode("sword"); + template.Variables.QualityType = FantasyItemQualityTypes.CommonQuality; + template.Variables.Value = 100; + template.Variables.AssetCode = "chest"; + template.Variables.Effects = chestEffects; + template.Variables.Tags = new TagList(ArmourTag, HeavyTag, LeatherTag); + + return template; + } + + private ItemTemplate MakeBoots() + { + var bootsEffects = new[] + { + new StaticEffect { EffectType = FantasyEffectTypes.DefenseBonusAmount, Potency = 10.0f }, + new StaticEffect { EffectType = FantasyEffectTypes.DexterityBonusAmount, Potency = 1.0f } + }; + + var template = new ItemTemplate + { + Id = ItemTemplateLookups.Boots, + NameLocaleId = "Muddy Boots", + DescriptionLocaleId = "In a way, the dried mud and clay provides extra defense", + ItemType = FantasyItemTypes.FootArmour + }; + template.Variables.QualityType = FantasyItemQualityTypes.JunkQuality; + template.Variables.Value = 10; + template.Variables.AssetCode = "boots"; + template.Variables.Effects = bootsEffects; + template.Variables.Tags = new TagList(ArmourTag, LightTag, LeatherTag); + + return template; + } + + private ItemTemplate MakeHelm() + { + var helmEffects = new[] + { + new StaticEffect { EffectType = FantasyEffectTypes.DefenseBonusAmount, Potency = 10.0f }, + new StaticEffect { EffectType = FantasyEffectTypes.StrengthBonusAmount, Potency = 1.0f } + }; + + var template = new ItemTemplate + { + Id = ItemTemplateLookups.Helm, + NameLocaleId = "Magic Helmet", + DescriptionLocaleId = "Wearing it makes you feel stronger", + ItemType = FantasyItemTypes.HeadItem + }; + template.Variables.QualityType = FantasyItemQualityTypes.RareQuality; + template.Variables.Value = 50; + template.Variables.AssetCode = "helm"; + template.Variables.Effects = helmEffects; + template.Variables.Tags = new TagList(ArmourTag, HeavyTag, MetalTag); return template; } @@ -90,17 +169,17 @@ public ItemTemplate MakePotion() NameLocaleId = "Healing Potion", DescriptionLocaleId = "A sketchy looking potion, lets hope it heals you", ItemType = FantasyItemTypes.Potions, - ModificationAllowances = Array.Empty(), - Requirements = Array.Empty(), - Effects = new[] - { + ModificationAllowances = Array.Empty() + }; + template.Variables.QualityType = FantasyItemQualityTypes.UncommonQuality; + template.Variables.Value = 20; + template.Variables.MaxStacks = 5; + template.Variables.AssetCode = "potion"; + template.Variables.Effects = new[] + { new StaticEffect { EffectType = FantasyEffectTypes.HealthRestoreAmount, Potency = 30.0f } - } }; - template.Variables.QualityType(FantasyItemQualityTypes.UncommonQuality); - template.Variables.Value(20); - template.Variables.MaxStacks(5); - template.Variables.AssetCode("potion"); + template.Variables.Tags = new TagList(ConsumableTag); return template; } @@ -113,17 +192,17 @@ public ItemTemplate MakeJunkPotion() NameLocaleId = "Junk Potion Combi-Deal", DescriptionLocaleId = "Who knows whats in this...", ItemType = FantasyItemTypes.Potions, - ModificationAllowances = Array.Empty(), - Requirements = Array.Empty(), - Effects = new[] - { + ModificationAllowances = Array.Empty() + }; + template.Variables.QualityType = FantasyItemQualityTypes.JunkQuality; + template.Variables.Value = 0; + template.Variables.MaxStacks = 5; + template.Variables.AssetCode = "potion-2"; + template.Variables.Effects = new[] + { new StaticEffect { EffectType = FantasyEffectTypes.HealthRestoreAmount, Potency = -5.0f } - } }; - template.Variables.QualityType(FantasyItemQualityTypes.JunkQuality); - template.Variables.Value(0); - template.Variables.MaxStacks(5); - template.Variables.AssetCode("potion-2"); + template.Variables.Tags = new TagList(ConsumableTag); return template; } @@ -136,14 +215,13 @@ public ItemTemplate MakeCopperIngot() NameLocaleId = "Copper Ingot", DescriptionLocaleId = "A block of refined copper", ItemType = FantasyItemTypes.GenericItem, - ModificationAllowances = Array.Empty(), - Requirements = Array.Empty(), - Effects = Array.Empty(), - }; - template.Variables.QualityType(FantasyItemQualityTypes.CommonQuality); - template.Variables.Value(5); - template.Variables.MaxStacks(20); - template.Variables.AssetCode("copper-ingot"); + ModificationAllowances = Array.Empty() + }; + template.Variables.QualityType = FantasyItemQualityTypes.CommonQuality; + template.Variables.Value = 5; + template.Variables.MaxStacks = 20; + template.Variables.AssetCode = "copper-ingot"; + template.Variables.Tags = new TagList(MetalTag); return template; } @@ -156,14 +234,13 @@ public ItemTemplate MakeCopperOre() NameLocaleId = "Copper Ore", DescriptionLocaleId = "A chunk of raw copper ore", ItemType = FantasyItemTypes.GenericItem, - ModificationAllowances = Array.Empty(), - Requirements = Array.Empty(), - Effects = Array.Empty(), - }; - template.Variables.QualityType(FantasyItemQualityTypes.CommonQuality); - template.Variables.Value(1); - template.Variables.MaxStacks(20); - template.Variables.AssetCode("copper-ore"); + ModificationAllowances = Array.Empty() + }; + template.Variables.QualityType = FantasyItemQualityTypes.CommonQuality; + template.Variables.Value = 1; + template.Variables.MaxStacks = 20; + template.Variables.AssetCode = "copper-ore"; + template.Variables.Tags = new TagList(MetalTag); return template; } @@ -176,14 +253,13 @@ public ItemTemplate MakeIronOre() NameLocaleId = "Iron Ore", DescriptionLocaleId = "A chunk of raw iron ore", ItemType = FantasyItemTypes.GenericItem, - ModificationAllowances = Array.Empty(), - Requirements = Array.Empty(), - Effects = Array.Empty(), - }; - template.Variables.QualityType(FantasyItemQualityTypes.CommonQuality); - template.Variables.Value(1); - template.Variables.MaxStacks(20); - template.Variables.AssetCode("iron-ore"); + ModificationAllowances = Array.Empty() + }; + template.Variables.QualityType = FantasyItemQualityTypes.CommonQuality; + template.Variables.Value = 1; + template.Variables.MaxStacks = 20; + template.Variables.AssetCode = "iron-ore"; + template.Variables.Tags = new TagList(MetalTag); return template; } @@ -196,14 +272,13 @@ public ItemTemplate MakeOakLog() NameLocaleId = "Oak Log", DescriptionLocaleId = "A log cut from an Oak tree", ItemType = FantasyItemTypes.GenericItem, - ModificationAllowances = Array.Empty(), - Requirements = Array.Empty(), - Effects = Array.Empty(), - }; - template.Variables.QualityType(FantasyItemQualityTypes.CommonQuality); - template.Variables.Value(1); - template.Variables.MaxStacks(20); - template.Variables.AssetCode("oak-log"); + ModificationAllowances = Array.Empty() + }; + template.Variables.QualityType = FantasyItemQualityTypes.CommonQuality; + template.Variables.Value = 1; + template.Variables.MaxStacks = 20; + template.Variables.AssetCode = "oak-log"; + template.Variables.Tags = new TagList(WoodTag); return template; } @@ -213,20 +288,20 @@ public ItemTemplate MakeCopperSword() var template = new ItemTemplate { Id = ItemTemplateLookups.CopperSword, - NameLocaleId = "A crafted copper sword", + NameLocaleId = "Copper Sword", DescriptionLocaleId = "An oak hilt with a copper blade", ItemType = FantasyItemTypes.GenericWeapon, - ModificationAllowances = Array.Empty(), - Requirements = Array.Empty(), - Effects = new[] - { - new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 50 } - } - }; - template.Variables.QualityType(FantasyItemQualityTypes.CommonQuality); - template.Variables.Value(50); - template.Variables.MaxStacks(1); - template.Variables.AssetCode("copper-sword"); + ModificationAllowances = Array.Empty() + }; + template.Variables.QualityType = FantasyItemQualityTypes.CommonQuality; + template.Variables.Value = 50; + template.Variables.MaxStacks = 1; + template.Variables.AssetCode = "copper-sword"; + template.Variables.Effects = new[] + { + new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 50 } + }; + template.Variables.Tags = new TagList(WeaponTag, LightTag, MetalTag); return template; } diff --git a/src/OpenRpg.Demos.Infrastructure/Data/LocaleDataGenerator.cs b/src/OpenRpg.Demos.Infrastructure/Data/LocaleDataGenerator.cs index 56763aa0..42832e27 100644 --- a/src/OpenRpg.Demos.Infrastructure/Data/LocaleDataGenerator.cs +++ b/src/OpenRpg.Demos.Infrastructure/Data/LocaleDataGenerator.cs @@ -27,7 +27,10 @@ public class LocaleDataGenerator : IDataGenerator public static readonly string CardTypesTextKey = "types-cards-"; public static readonly string UtilityTypesTextKey = "types-ai-utility-"; public static readonly string AdviceTypesTextKey = "types-ai-advice-"; - + public static readonly string TradeSkillTypesTextKey = "types-trade-skill-"; + public static readonly string FactionNameTextKey = "factions-name-"; + public static readonly string FactionDescTextKey = "factions-desc-"; + public IEnumerable GenerateData() { var localeDataset = new LocaleDataset { LocaleCode = "en-gb" }; @@ -44,6 +47,9 @@ public IEnumerable GenerateData() GenerateCardTypeLocaleText(localeDataset); GenerateUtilityTypeLocaleText(localeDataset); GenerateAdviceTypeLocaleText(localeDataset); + GenerateTradeSkillTypeLocaleText(localeDataset); + GenerateFactionLocaleText(localeDataset); + GenerateAbilityLocaleText(localeDataset); return new[] { localeDataset }; } @@ -132,5 +138,43 @@ public void GenerateAdviceTypeLocaleText(LocaleDataset localeDataset) GetTypeFieldsDictionary() .ForEach((key, value) => localeDataset.LocaleData.Add(GetKeyFor(AdviceTypesTextKey, key), value)); } + + public void GenerateTradeSkillTypeLocaleText(LocaleDataset localeDataset) + { + GetTypeFieldsDictionary() + .ForEach((key, value) => localeDataset.LocaleData.Add(GetKeyFor(TradeSkillTypesTextKey, key), value)); + } + + public void GenerateFactionLocaleText(LocaleDataset localeDataset) + { + localeDataset.LocaleData.Add(GetKeyFor(FactionNameTextKey, FactionDataGenerator.MerchantsGuildId), "Merchants Guild"); + localeDataset.LocaleData.Add(GetKeyFor(FactionDescTextKey, FactionDataGenerator.MerchantsGuildId), "A powerful trade organisation that controls most commerce in the region."); + localeDataset.LocaleData.Add(GetKeyFor(FactionNameTextKey, FactionDataGenerator.CityGuardId), "City Guard"); + localeDataset.LocaleData.Add(GetKeyFor(FactionDescTextKey, FactionDataGenerator.CityGuardId), "The official law enforcement arm of the city."); + localeDataset.LocaleData.Add(GetKeyFor(FactionNameTextKey, FactionDataGenerator.ThievesGuildId), "Thieves Guild"); + localeDataset.LocaleData.Add(GetKeyFor(FactionDescTextKey, FactionDataGenerator.ThievesGuildId), "A shadowy network of rogues and smugglers operating in the underbelth."); + } + + public void GenerateAbilityLocaleText(LocaleDataset localeDataset) + { + var pairs = new (string Key, string Name, string Description)[] + { + ("ability-slash", "Slash", "A sweeping strike that hits multiple front-row enemies."), + ("ability-power-strike", "Power Strike", "A powerful overhand strike that deals heavy damage."), + ("ability-chi-blast", "Chi Blast", "A burst of inner energy that strikes all enemies."), + ("ability-focus-strike", "Focus Strike", "A precise, focused blow that deals massive damage."), + ("ability-backstab", "Backstab", "A vicious strike from the shadows."), + ("ability-poison-blade", "Poison Blade", "A venomous slash that poisons the target."), + ("ability-fire-bolt", "Fire Bolt", "A bolt of searing fire."), + ("ability-ice-storm", "Ice Storm", "A freezing storm that assails all enemies."), + ("ability-cure", "Cure", "Heals a single ally for a moderate amount of HP."), + ("ability-cura", "Cura", "Heals the entire party for a small amount of HP."), + }; + foreach (var pair in pairs) + { + localeDataset.LocaleData.Add(pair.Key, pair.Name); + localeDataset.LocaleData.Add(pair.Key + "-desc", pair.Description); + } + } } } \ No newline at end of file diff --git a/src/OpenRpg.Demos.Infrastructure/Data/QuestStateDataGenerator.cs b/src/OpenRpg.Demos.Infrastructure/Data/QuestStateDataGenerator.cs new file mode 100644 index 00000000..f778df88 --- /dev/null +++ b/src/OpenRpg.Demos.Infrastructure/Data/QuestStateDataGenerator.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using OpenRpg.Core.Associations; +using OpenRpg.Core.Requirements; +using OpenRpg.Demos.Infrastructure.Lookups; +using OpenRpg.Entities.Extensions; +using OpenRpg.Genres.Fantasy.Types; +using OpenRpg.Genres.Types; +using OpenRpg.Quests; +using OpenRpg.Quests.Types; + +namespace OpenRpg.Demos.Infrastructure.Data +{ + public class QuestStateDataGenerator : IDataGenerator + { + public const int FindArtifactQuestId = 10; + public const int DefeatGoblinQuestId = 20; + public const int DeliverLetterQuestId = 30; + + public const int SpokenToElderTriggerId = 1; + public const int FoundMapTriggerId = 2; + + public IEnumerable GenerateData() + { + return new[] + { + MakeFindArtifactQuest(), + MakeDefeatGoblinQuest(), + MakeDeliverLetterQuest() + }; + } + + private QuestTemplate MakeFindArtifactQuest() + { + var quest = new QuestTemplate + { + Id = FindArtifactQuestId, + NameLocaleId = "Find the Lost Artifact", + DescriptionLocaleId = "The Elder knows of an ancient artifact hidden in the ruins. Speak to him first to learn its location.", + IsRepeatable = false, + Objectives = new List + { + new() { ObjectiveType = ObjectiveTypes.TriggerObjective, Association = new Association(FoundMapTriggerId, 1) } + }, + Rewards = new List + { + new() { RewardType = FantasyRewardTypes.ExperienceReward, Association = new Association(0, 200) }, + new() { RewardType = GenreRewardTypes.CurrencyReward, Association = new Association(0, 150) } + }, + Variables = new Quests.Variables.QuestTemplateVariables() + }; + quest.Variables.Requirements = new[] + { + new OpenRpg.Core.Requirements.Requirement + { + RequirementType = QuestRequirementTypes.TriggerRequirement, + Association = new Association(SpokenToElderTriggerId, 1) + } + }; + return quest; + } + + private QuestTemplate MakeDefeatGoblinQuest() + { + var quest = new QuestTemplate + { + Id = DefeatGoblinQuestId, + NameLocaleId = "Defeat the Goblin Chief", + DescriptionLocaleId = "The goblins have been raiding caravans. Slay their chief to end the threat.", + IsRepeatable = false, + Objectives = new List + { + new() { ObjectiveType = GenresObjectiveTypes.EnemyDefeatedObjective, Association = new Association(99, 3) } + }, + Rewards = new List + { + new() { RewardType = FantasyRewardTypes.ExperienceReward, Association = new Association(0, 300) }, + new() { RewardType = GenreRewardTypes.ItemReward, Association = new Association(ItemTemplateLookups.SuperSword, 1) } + }, + Variables = new Quests.Variables.QuestTemplateVariables() + }; + return quest; + } + + private QuestTemplate MakeDeliverLetterQuest() + { + var quest = new QuestTemplate + { + Id = DeliverLetterQuestId, + NameLocaleId = "Deliver the Letter", + DescriptionLocaleId = "A simple errand — deliver a sealed letter to the merchant on the other side of town.", + IsRepeatable = true, + Objectives = new List + { + new() { ObjectiveType = ObjectiveTypes.ItemObjective, Association = new Association(ItemTemplateLookups.Chest, 1) } + }, + Rewards = new List + { + new() { RewardType = FantasyRewardTypes.ExperienceReward, Association = new Association(0, 50) }, + new() { RewardType = GenreRewardTypes.CurrencyReward, Association = new Association(0, 25) } + }, + Variables = new Quests.Variables.QuestTemplateVariables() + }; + return quest; + } + } +} diff --git a/src/OpenRpg.Demos.Infrastructure/Data/RaceTemplateDataGenerator.cs b/src/OpenRpg.Demos.Infrastructure/Data/RaceTemplateDataGenerator.cs index 4e83f17b..b8d99a30 100644 --- a/src/OpenRpg.Demos.Infrastructure/Data/RaceTemplateDataGenerator.cs +++ b/src/OpenRpg.Demos.Infrastructure/Data/RaceTemplateDataGenerator.cs @@ -39,9 +39,9 @@ public RaceTemplate GenerateHumanTemplate() Id = RaceTypeLookups.Human, NameLocaleId = "Human", DescriptionLocaleId = "Humans are the most common of all races", - Effects = effects }; - raceTemplate.Variables.AssetCode("race-human"); + raceTemplate.Variables.AssetCode = "race-human"; + raceTemplate.Variables.Effects = effects; return raceTemplate; } @@ -67,10 +67,10 @@ public RaceTemplate GenerateElfTemplate() { Id = RaceTypeLookups.Elf, NameLocaleId = "Elf", - DescriptionLocaleId = "Elves are pretty common, have pointy ears too", - Effects = effects + DescriptionLocaleId = "Elves are pretty common, have pointy ears too" }; - raceTemplate.Variables.AssetCode("race-elf"); + raceTemplate.Variables.AssetCode = "race-elf"; + raceTemplate.Variables.Effects = effects; return raceTemplate; } @@ -95,10 +95,10 @@ public RaceTemplate GenerateDwarfTemplate() { Id = RaceTypeLookups.Dwarf, NameLocaleId = "Dwarf", - DescriptionLocaleId = "Dwarves are strong and hardy", - Effects = effects + DescriptionLocaleId = "Dwarves are strong and hardy" }; - raceTemplate.Variables.AssetCode("race-dwarf"); + raceTemplate.Variables.AssetCode = "race-dwarf"; + raceTemplate.Variables.Effects = effects; return raceTemplate; } } diff --git a/src/OpenRpg.Demos.Infrastructure/Data/TraderDataGenerator.cs b/src/OpenRpg.Demos.Infrastructure/Data/TraderDataGenerator.cs new file mode 100644 index 00000000..f8af6c7a --- /dev/null +++ b/src/OpenRpg.Demos.Infrastructure/Data/TraderDataGenerator.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using OpenRpg.Demos.Infrastructure.Lookups; +using OpenRpg.Demos.Infrastructure.Trading; +using OpenRpg.Items.TradeSkills.Trading; + +namespace OpenRpg.Demos.Infrastructure.Data +{ + public static class TraderDataGenerator + { + public const int GoldStartingAmount = 500; + + public static Trader CreateWeaponMerchant() + { + return new Trader("Weapon Merchant Gruk", new[] + { + new ItemTradeEntry { ItemTemplateId = ItemTemplateLookups.Sword, BuyRate = 1.5f, SellRate = 0.4f }, + new ItemTradeEntry { ItemTemplateId = ItemTemplateLookups.SuperSword, BuyRate = 1.2f, SellRate = 0.5f }, + new ItemTradeEntry { ItemTemplateId = ItemTemplateLookups.CopperSword, BuyRate = 1.4f, SellRate = 0.45f }, + new ItemTradeEntry { ItemTemplateId = ItemTemplateLookups.Helm, BuyRate = 1.3f, SellRate = 0.4f }, + new ItemTradeEntry { ItemTemplateId = ItemTemplateLookups.HealingPotion, BuyRate = 1.0f, SellRate = 0.5f } + }); + } + } +} diff --git a/src/OpenRpg.Demos.Infrastructure/Extensions/TypeExtensions.cs b/src/OpenRpg.Demos.Infrastructure/Extensions/TypeExtensions.cs index ab43880f..12ef55b2 100644 --- a/src/OpenRpg.Demos.Infrastructure/Extensions/TypeExtensions.cs +++ b/src/OpenRpg.Demos.Infrastructure/Extensions/TypeExtensions.cs @@ -7,13 +7,17 @@ namespace OpenRpg.Demos.Infrastructure.Extensions; public static class TypeExtensions { - public static IDictionary GetTypeFieldsDictionary(this Type typeObject) + public static Dictionary GetTypeFieldsDictionary(this Type typeObject, bool ignoreZero = true) { var relatedInterfaceTypes = typeObject.GetInterfaces().ToList(); relatedInterfaceTypes.Add(typeObject); - return relatedInterfaceTypes + var results = relatedInterfaceTypes .SelectMany(x => x.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)) .ToDictionary(x => (int)x.GetValue(null), x => x.Name.UnPascalCase()); + + if(ignoreZero) { results.Remove(0); } + + return results; } } \ No newline at end of file diff --git a/src/OpenRpg.Demos.Infrastructure/Lookups/ItemTemplateLookups.cs b/src/OpenRpg.Demos.Infrastructure/Lookups/ItemTemplateLookups.cs index db350f66..bcc3db21 100644 --- a/src/OpenRpg.Demos.Infrastructure/Lookups/ItemTemplateLookups.cs +++ b/src/OpenRpg.Demos.Infrastructure/Lookups/ItemTemplateLookups.cs @@ -8,6 +8,9 @@ public static class ItemTemplateLookups public static int SuperSword = 2; public static int HealingPotion = 3; public static int JunkPotion = 4; + public static int Chest = 5; + public static int Boots = 6; + public static int Helm = 7; public static int CopperOre = 10; public static int IronOre = 11; diff --git a/src/OpenRpg.Demos.Infrastructure/OpenRpg.Demos.Infrastructure.csproj b/src/OpenRpg.Demos.Infrastructure/OpenRpg.Demos.Infrastructure.csproj index 43b41259..0fb3b93b 100644 --- a/src/OpenRpg.Demos.Infrastructure/OpenRpg.Demos.Infrastructure.csproj +++ b/src/OpenRpg.Demos.Infrastructure/OpenRpg.Demos.Infrastructure.csproj @@ -1,13 +1,13 @@  - net9.0 - 13 + net10.0 + 14 false - + @@ -21,9 +21,9 @@ - + - + diff --git a/src/OpenRpg.Demos.Infrastructure/Services/AbilityExecutionDemoService.cs b/src/OpenRpg.Demos.Infrastructure/Services/AbilityExecutionDemoService.cs new file mode 100644 index 00000000..755f20bc --- /dev/null +++ b/src/OpenRpg.Demos.Infrastructure/Services/AbilityExecutionDemoService.cs @@ -0,0 +1,364 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenRpg.Combat.Abilities; +using OpenRpg.Combat.Attacks; +using OpenRpg.Combat.Extensions; +using OpenRpg.Combat.Processors.Attacks.Entity; +using OpenRpg.Combat.Types; +using OpenRpg.Core.Extensions; +using OpenRpg.Core.Requirements; +using OpenRpg.Demos.Infrastructure.Data; +using OpenRpg.Demos.Infrastructure.Lookups; +using OpenRpg.Entities.Classes; +using OpenRpg.Entities.Entity.Variables; +using OpenRpg.Entities.Extensions; +using OpenRpg.Entities.Stats.Variables; +using OpenRpg.Genres.Characters; +using OpenRpg.Genres.Fantasy.Extensions; +using OpenRpg.Genres.Fantasy.Types; +using OpenRpg.Genres.Requirements; + +namespace OpenRpg.Demos.Infrastructure.Services +{ + /// + /// 1v-many combat sandbox that mirrors the Battler demo's AbilityExecutor + + /// TargetResolver end-to-end. Casters use the library's attack generators + /// and processors; targets take defense-subtracted damage from the + /// ; heals are a flat-value restore. + /// + public class AbilityExecutionDemoService : IAbilityExecutionDemoService + { + private const int MaxLogEntries = 50; + + private readonly IEntityAttackGenerator _attackGenerator; + private readonly IEntityAttackProcessor _attackProcessor; + private readonly ICharacterRequirementChecker _requirementChecker; + private readonly List _abilityTemplates; + private readonly Dictionary> _requirementsByTemplateId; + private readonly List _party; + private readonly List _enemies; + private readonly List _combatLog = new(); + + public IReadOnlyList Party => _party.Select((c, i) => MakeCasterSnapshot(c, i)).ToList(); + public IReadOnlyList Enemies => _enemies.Select((c, i) => MakeTargetSnapshot(c, i)).ToList(); + public IReadOnlyList AvailableAbilities { get; } + public IReadOnlyList CombatLog => _combatLog; + public IReadOnlyList LastCastEnemyTargetIndexes { get; private set; } = Array.Empty(); + public IReadOnlyList LastCastAllyTargetIndexes { get; private set; } = Array.Empty(); + public int LastCastAbilityId { get; private set; } + public SandboxSnapshot InitialState { get; } + + public AbilityExecutionDemoService( + IEntityAttackGenerator attackGenerator, + IEntityAttackProcessor attackProcessor, + ICharacterRequirementChecker requirementChecker) + { + _attackGenerator = attackGenerator; + _attackProcessor = attackProcessor; + _requirementChecker = requirementChecker; + + _abilityTemplates = new AbilityTemplateDataGenerator().GenerateData().ToList(); + _requirementsByTemplateId = _abilityTemplates.ToDictionary( + t => t.Id, + t => t.Variables.Requirements?.ToList() ?? new List()); + + _party = SeedParty(); + _enemies = SeedEnemies(); + + AvailableAbilities = _abilityTemplates.Select(MakeAbilityOption).ToList(); + InitialState = new SandboxSnapshot(Party, Enemies); + } + + public IReadOnlyList GetAbilitiesForCaster(int casterIndex) + { + var caster = _party.ElementAtOrDefault(casterIndex); + if (caster == null) { return Array.Empty(); } + return AvailableAbilities + .Where(a => CasterMeetsClassRequirement(caster, a.Id)) + .ToList(); + } + + public bool CanCasterUseAbility(int casterIndex, int abilityTemplateId) + { + var caster = _party.ElementAtOrDefault(casterIndex); + if (caster == null || !caster.IsAlive) { return false; } + var ability = AvailableAbilities.FirstOrDefault(a => a.Id == abilityTemplateId); + if (ability == null) { return false; } + if (ability.ManaCost > caster.CurrentMana) { return false; } + return CasterMeetsClassRequirement(caster, abilityTemplateId); + } + + private bool CasterMeetsClassRequirement(Combatant caster, int abilityTemplateId) + { + if (!_requirementsByTemplateId.TryGetValue(abilityTemplateId, out var requirements) || requirements.Count == 0) + { return true; } + var stubCharacter = new Character { Variables = new EntityVariables() }; + stubCharacter.Variables.Class = new ClassData { TemplateId = caster.ClassId }; + return _requirementChecker.AreRequirementsMet(stubCharacter, requirements); + } + + public void Reset() + { + foreach (var combatant in _party) { combatant.RestoreFromSeed(); } + foreach (var combatant in _enemies) { combatant.RestoreFromSeed(); } + _combatLog.Clear(); + LastCastEnemyTargetIndexes = Array.Empty(); + LastCastAllyTargetIndexes = Array.Empty(); + LastCastAbilityId = 0; + Log("RESET: sandbox restored from seed"); + } + + public bool CastAbility(int casterIndex, int abilityTemplateId) + { + var caster = _party.ElementAtOrDefault(casterIndex); + if (caster == null) { Log($" ! no caster at index {casterIndex}"); return false; } + var template = _abilityTemplates.FirstOrDefault(t => t.Id == abilityTemplateId); + if (template == null) { Log($" ! no ability with id {abilityTemplateId}"); return false; } + LastCastAbilityId = abilityTemplateId; + + Log($"CAST: {caster.Name} attempts {template.NameLocaleId} (id={template.Id})"); + if (!caster.IsAlive) { Log(" ! caster is dead"); return false; } + + var manaCost = template.Variables.GetIntOrDefault(FantasyAbilityTemplateVariableTypes.ManaCost, 0); + if (caster.CurrentMana < manaCost) + { + Log($" ! not enough mana (have {caster.CurrentMana}, need {manaCost})"); + return false; + } + + if (_requirementsByTemplateId.TryGetValue(abilityTemplateId, out var requirements)) + { + var stubCharacter = new Character { Variables = new EntityVariables() }; + stubCharacter.Variables.Class = new ClassData { TemplateId = caster.ClassId }; + if (requirements.Count > 0 && !_requirementChecker.AreRequirementsMet(stubCharacter, requirements)) + { + Log($" ! {caster.Name} does not meet the class requirements (class id {caster.ClassId})"); + return false; + } + } + + var targetType = template.Variables.GetIntOrDefault(CombatAbilityTemplateVariableTypes.TargetType, 1); + var targetCount = template.Variables.GetIntOrDefault(CombatAbilityTemplateVariableTypes.TargetCount, 1); + var isHealing = template.Variables.Damage.Type == FantasyDamageTypes.LightDamage; + + var pool = isHealing ? (IReadOnlyList)_party : _enemies; + Log($" - target pool: {(isHealing ? "party" : "enemies")}, TargetType={targetType}, TargetCount={targetCount}"); + + var aliveInPool = pool.Where(e => e.IsAlive).ToList(); + var resolved = targetType == CombatTargetTypes.MultipleTarget + ? aliveInPool.Take(targetCount).ToList() + : aliveInPool.Take(1).ToList(); + + if (resolved.Count == 0) + { + Log(" ! no valid targets in pool"); + return false; + } + + if (isHealing) + { + ApplyHealing(caster, template, resolved, manaCost); + LastCastAllyTargetIndexes = resolved.Select(t => _party.IndexOf(t)).ToList(); + LastCastEnemyTargetIndexes = Array.Empty(); + } + else + { + ApplyDamage(caster, template, resolved, manaCost); + LastCastEnemyTargetIndexes = resolved.Select(t => _enemies.IndexOf(t)).ToList(); + LastCastAllyTargetIndexes = Array.Empty(); + } + + return true; + } + + private void ApplyDamage(Combatant caster, AbilityTemplate template, List targets, int manaCost) + { + var baseDamage = template.Variables.Damage; + var attack = _attackGenerator.GenerateAttack(baseDamage, caster.Stats); + var targetCount = targets.Count; + var splitFraction = targetCount > 0 ? 1.0f / targetCount : 0f; + Log($" - generated attack: crit={attack.IsCritical}, base={baseDamage.Value} type={baseDamage.Type}, split 1/{targetCount} across {targetCount} target(s)"); + + foreach (var target in targets) + { + var processed = _attackProcessor.ProcessAttack(attack, target.Stats); + var rawDone = processed.DamageDone.Sum(d => d.Value); + var splitDone = rawDone * splitFraction; + var finalDamage = Math.Max(1, (int)Math.Round(splitDone)); + var prevHp = target.CurrentHp; + target.CurrentHp = Math.Max(0, target.CurrentHp - finalDamage); + var critPrefix = attack.IsCritical ? "CRIT! " : ""; + Log($" > {critPrefix}{target.Name} takes {finalDamage} damage (HP {prevHp} -> {target.CurrentHp}){(target.IsAlive ? "" : " (killed)")}"); + } + + caster.CurrentMana = Math.Max(0, caster.CurrentMana - manaCost); + Log($" - {caster.Name} mana: {caster.CurrentMana + manaCost} -> {caster.CurrentMana}"); + } + + private void ApplyHealing(Combatant caster, AbilityTemplate template, List targets, int manaCost) + { + var healAmount = Math.Max(1, (int)template.Variables.Damage.Value); + Log($" - heal value: {healAmount} HP, applied to {targets.Count} target(s)"); + + foreach (var target in targets) + { + var prevHp = target.CurrentHp; + target.CurrentHp = Math.Min(target.MaxHp, target.CurrentHp + healAmount); + var actualHeal = target.CurrentHp - prevHp; + Log($" > {target.Name} heals {actualHeal} HP (HP {prevHp} -> {target.CurrentHp})"); + } + + caster.CurrentMana = Math.Max(0, caster.CurrentMana - manaCost); + Log($" - {caster.Name} mana: {caster.CurrentMana + manaCost} -> {caster.CurrentMana}"); + } + + private void Log(string message) + { + var stamp = DateTime.Now.ToString("HH:mm:ss"); + _combatLog.Insert(0, $"[{stamp}] {message}"); + if (_combatLog.Count > MaxLogEntries) { _combatLog.RemoveRange(MaxLogEntries, _combatLog.Count - MaxLogEntries); } + } + + private static CasterSnapshot MakeCasterSnapshot(Combatant combatant, int index) => + combatant.IsCaster + ? new CasterSnapshot(index, combatant.Name, combatant.ClassId, combatant.ClassName, + combatant.MaxHp, combatant.CurrentHp, combatant.MaxMana, combatant.CurrentMana, combatant.IsAlive) + : new CasterSnapshot(index, combatant.Name, 0, "Enemy", combatant.MaxHp, combatant.CurrentHp, 0, 0, combatant.IsAlive); + + private static TargetSnapshot MakeTargetSnapshot(Combatant combatant, int index) => + new TargetSnapshot(index, combatant.Name, combatant.MaxHp, combatant.CurrentHp, combatant.IsAlive); + + private static AbilityOption MakeAbilityOption(AbilityTemplate template) => + new AbilityOption( + template.Id, + template.NameLocaleId, + template.DescriptionLocaleId, + template.Variables.GetIntOrDefault(CombatAbilityTemplateVariableTypes.TargetType, 1), + template.Variables.GetIntOrDefault(CombatAbilityTemplateVariableTypes.TargetCount, 1), + template.Variables.GetIntOrDefault(FantasyAbilityTemplateVariableTypes.ManaCost, 0), + template); + + private static List SeedParty() + { + return new List + { + new Combatant("Aldric", 0, ClassTypeLookups.Fighter, "Fighter", 120, 20, isEnemy: false) + { + Stats = FighterStats(slashing: 5, blunt: 1, piercing: 1) + }, + new Combatant("Mira", 1, ClassTypeLookups.Mage, "Mage", 80, 100, isEnemy: false) + { + Stats = MageStats(fire: 8, ice: 8) + }, + new Combatant("Tess", 2, ClassTypeLookups.Mage, "Mage", 80, 100, isEnemy: false) + { + Stats = MageStats(fire: 4, ice: 4) + }, + new Combatant("Borin", 3, ClassTypeLookups.Fighter, "Fighter", 140, 20, isEnemy: false) + { + Stats = FighterStats(slashing: 4, blunt: 2, piercing: 2) + }, + }; + } + + private static List SeedEnemies() + { + return new List + { + new Combatant("Goblin", 0, 0, "Enemy", 30, 0, isEnemy: true) + { + Stats = EnemyStats(slashing: 2, blunt: 1, piercing: 1, fire: 1, ice: 1, wind: 1, earth: 1, light: 1, dark: 1) + }, + new Combatant("Bat", 1, 0, "Enemy", 15, 0, isEnemy: true) + { + Stats = EnemyStats(slashing: 1, blunt: 1, piercing: 1, fire: 1, ice: 1, wind: 2, earth: 1, light: 1, dark: 1) + }, + new Combatant("Wolf", 2, 0, "Enemy", 40, 0, isEnemy: true) + { + Stats = EnemyStats(slashing: 2, blunt: 1, piercing: 1, fire: 1, ice: 1, wind: 1, earth: 4, light: 1, dark: 1) + }, + new Combatant("Skeleton", 3, 0, "Enemy", 50, 0, isEnemy: true) + { + Stats = EnemyStats(slashing: 5, blunt: 3, piercing: 3, fire: 1, ice: 1, wind: 1, earth: 2, light: 1, dark: 2) + }, + new Combatant("Slime", 4, 0, "Enemy", 20, 0, isEnemy: true) + { + Stats = EnemyStats(slashing: 1, blunt: 5, piercing: 1, fire: 1, ice: 1, wind: 1, earth: 1, light: 1, dark: 1) + }, + }; + } + + private static EntityStatsVariables FighterStats(float slashing, float blunt, float piercing) + { + var stats = new EntityStatsVariables(); + stats.SlashingDamage = slashing; + stats.BluntDamage = blunt; + stats.PiercingDamage = piercing; + return stats; + } + + private static EntityStatsVariables MageStats(float fire, float ice) + { + var stats = new EntityStatsVariables(); + stats.FireDamage = fire; + stats.IceDamage = ice; + return stats; + } + + private static EntityStatsVariables EnemyStats(float slashing, float blunt, float piercing, + float fire, float ice, float wind, float earth, float light, float dark) + { + var stats = new EntityStatsVariables(); + stats.SlashingDefense = slashing; + stats.BluntDefense = blunt; + stats.PiercingDefense = piercing; + stats.FireDefense = fire; + stats.IceDefense = ice; + stats.WindDefense = wind; + stats.EarthDefense = earth; + stats.LightDefense = light; + stats.DarkDefense = dark; + return stats; + } + + private class Combatant + { + private readonly int _seedHp; + private readonly int _seedMana; + + public Combatant(string name, int index, int classId, string className, int maxHp, int maxMana, bool isEnemy) + { + Name = name; + Index = index; + ClassId = classId; + ClassName = className; + MaxHp = maxHp; + CurrentHp = maxHp; + MaxMana = maxMana; + CurrentMana = maxMana; + IsCaster = !isEnemy; + _seedHp = maxHp; + _seedMana = maxMana; + } + + public string Name { get; } + public int Index { get; } + public int ClassId { get; } + public string ClassName { get; } + public int MaxHp { get; } + public int MaxMana { get; } + public int CurrentHp { get; set; } + public int CurrentMana { get; set; } + public bool IsCaster { get; } + public bool IsAlive => CurrentHp > 0; + public EntityStatsVariables Stats { get; set; } = new(); + + public void RestoreFromSeed() + { + CurrentHp = _seedHp; + CurrentMana = _seedMana; + } + } + } +} diff --git a/src/OpenRpg.Demos.Infrastructure/Services/IAbilityExecutionDemoService.cs b/src/OpenRpg.Demos.Infrastructure/Services/IAbilityExecutionDemoService.cs new file mode 100644 index 00000000..a2f54693 --- /dev/null +++ b/src/OpenRpg.Demos.Infrastructure/Services/IAbilityExecutionDemoService.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using OpenRpg.Combat.Abilities; + +namespace OpenRpg.Demos.Infrastructure.Services +{ + /// + /// Sandbox-backed ability-execution playground. Owns a private party of casters and a + /// private enemy formation so casts here cannot leak into other demo pages. Every cast + /// runs through the library's attack generators + processors (mirroring the Battler + /// demo's AbilityExecutor + TargetResolver) and logs each step into an + /// audit trail. + /// + public interface IAbilityExecutionDemoService + { + /// Friendly casters in the sandbox, in display order. Recomputed on each read so HP/MP reflect current state. + IReadOnlyList Party { get; } + + /// Enemy formation in the sandbox, in display order. Recomputed on each read so HP reflects current state. + IReadOnlyList Enemies { get; } + + /// All ability templates the demo knows about. + IReadOnlyList AvailableAbilities { get; } + + /// + /// Returns the subset of the given caster can use + /// (class requirement met, alive, has enough mana for at least the base cost). + /// + IReadOnlyList GetAbilitiesForCaster(int casterIndex); + + /// True if the given caster can currently use the given ability (alive, class ok, has mana). + bool CanCasterUseAbility(int casterIndex, int abilityTemplateId); + + /// Templated state used to seed the sandbox. Captured so can restore it. + SandboxSnapshot InitialState { get; } + + /// Templated log of every cast step and outcome. Most recent first. + IReadOnlyList CombatLog { get; } + + /// The list of target indexes that were hit on the most recent cast. Used by the page to highlight rows. + IReadOnlyList LastCastEnemyTargetIndexes { get; } + + /// The list of caster indexes that were healed on the most recent cast (heals target allies). + IReadOnlyList LastCastAllyTargetIndexes { get; } + + /// The id of the ability cast on the most recent cast, or 0 if none yet. + int LastCastAbilityId { get; } + + void Reset(); + + /// + /// Casts an ability. Logs the resolve-target, mana-deduct, attack-generate, and damage-apply + /// steps into . Returns true if the cast committed (regardless + /// of whether the targets were in range and took damage). + /// + bool CastAbility(int casterIndex, int abilityTemplateId); + } + + /// Read-only snapshot of a friendly caster for rendering. + public record CasterSnapshot( + int Index, + string Name, + int ClassId, + string ClassName, + int MaxHp, + int CurrentHp, + int MaxMana, + int CurrentMana, + bool IsAlive); + + /// Read-only snapshot of an enemy target for rendering. + public record TargetSnapshot( + int Index, + string Name, + int MaxHp, + int CurrentHp, + bool IsAlive); + + /// Wraps an with the resolved name for the dropdown. + public record AbilityOption(int Id, string Name, string Description, int TargetType, int TargetCount, int ManaCost, AbilityTemplate Template); + + /// Initial sandbox state used to restore the demo on . + public record SandboxSnapshot(IReadOnlyList Party, IReadOnlyList Enemies); +} diff --git a/src/OpenRpg.Demos.Infrastructure/Services/IInventoryTransactionDemoService.cs b/src/OpenRpg.Demos.Infrastructure/Services/IInventoryTransactionDemoService.cs new file mode 100644 index 00000000..39c1bc89 --- /dev/null +++ b/src/OpenRpg.Demos.Infrastructure/Services/IInventoryTransactionDemoService.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using OpenRpg.Items.Inventories; +using OpenRpg.Items.Templates; + +namespace OpenRpg.Demos.Infrastructure.Services +{ + /// + /// Sandbox-backed inventory-transaction playground. Owns a private Inventory so CRUD + /// operations here cannot leak into other demo pages. Every transaction call is logged + /// into an audit trail for inspection. + /// + public interface IInventoryTransactionDemoService + { + /// The sandbox inventory. Read-only access for rendering. + Inventory Sandbox { get; } + + /// Item templates the demo is allowed to operate on. + IReadOnlyList AvailableTemplates { get; } + + /// Items currently queued as additions on the staged transaction. + IReadOnlyList StagedAdditions { get; } + + /// Items currently queued as removals on the staged transaction. + IReadOnlyList StagedRemovals { get; } + + /// Timestamped log of every transaction step and outcome. + IReadOnlyList AuditLog { get; } + + void Reset(); + + /// Queues a removal. is 0 for non-amounted items. + void QueueRemoval(int itemTemplateId, int amount); + + /// Queues an addition. is 0 for non-amounted items. + void QueueAddition(int itemTemplateId, int amount); + + /// Clears the staged additions/removals without applying. + void ClearStaging(); + + /// + /// Applies the staged transaction. Logs every step (queued operations, attempts, rollback, + /// final result) into . + /// + /// True if all changes committed; false if anything rolled back. + bool ApplyStaged(); + } +} diff --git a/src/OpenRpg.Demos.Infrastructure/Services/IPersistenceDemoService.cs b/src/OpenRpg.Demos.Infrastructure/Services/IPersistenceDemoService.cs new file mode 100644 index 00000000..48247725 --- /dev/null +++ b/src/OpenRpg.Demos.Infrastructure/Services/IPersistenceDemoService.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using OpenRpg.Core.Common; +using OpenRpg.Data; + +namespace OpenRpg.Demos.Infrastructure.Services +{ + /// + /// Provides a sandboxed CRUD playground that operates on a private copy of the demo + /// template data, isolated from the shared IDataSource used by the rest of the app. + /// + public interface IPersistenceDemoService + { + /// + /// Frozen snapshot of the seed data captured at construction. Used by . + /// + IReadOnlyDictionary SeedSnapshot { get; } + + /// + /// The internal repository wired to the sandboxed InMemoryDataSource. Exposed so + /// callers can demonstrate the raw IRepository.Query(IQuery<T>) path in addition + /// to the typed helpers. + /// + IRepository Repository { get; } + + /// + /// Rebuilds the sandboxed store from the captured seed snapshot. + /// + void Reset(); + + IReadOnlyCollection GetAll() where T : class, IHasDataId; + T Get(object id) where T : class, IHasDataId; + bool Exists(object id) where T : class, IHasDataId; + + /// + /// Persists . If its is 0, a new id + /// is allocated via . + /// + T Create(T entity) where T : class, IHasDataId; + + T Update(T entity) where T : class, IHasDataId; + bool Delete(object id) where T : class, IHasDataId; + + /// + /// Returns the next available id for , which is Max(existing) + 1 + /// (or 1 if the store is empty). + /// + int NextIdFor() where T : class, IHasDataId; + } +} diff --git a/src/OpenRpg.Demos.Infrastructure/Services/InventoryTransactionDemoService.cs b/src/OpenRpg.Demos.Infrastructure/Services/InventoryTransactionDemoService.cs new file mode 100644 index 00000000..c79e3c1e --- /dev/null +++ b/src/OpenRpg.Demos.Infrastructure/Services/InventoryTransactionDemoService.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenRpg.Core.Templates; +using OpenRpg.Demos.Infrastructure.Data; +using OpenRpg.Demos.Infrastructure.Lookups; +using OpenRpg.Items.Extensions; +using OpenRpg.Items.Inventories; +using OpenRpg.Items.Templates; +using OpenRpg.Items.Variables; + +namespace OpenRpg.Demos.Infrastructure.Services +{ + /// + /// Sandbox-backed inventory-transaction playground. Holds a private Inventory plus + /// a list of item templates the user can stage transactions against. Each Apply + /// runs through the library's IInventoryTransaction and logs every step into an + /// audit trail. + /// + public class InventoryTransactionDemoService : IInventoryTransactionDemoService + { + /// + /// Slot cap on the sandbox inventory. Set high enough that the "Successful Trade" + /// scenario (consume 3 ore + add 1 ingot) fits comfortably, but tight enough that + /// the "Failed Addition" scenario (try to add 4 stackable-1 items when there's + /// only one slot free) actually trips the slot check. + /// + public const int SandboxMaxSlots = 4; + + private readonly List _stagedAdditions = new(); + private readonly List _stagedRemovals = new(); + private readonly List _auditLog = new(); + private readonly List _initialItems; + + public Inventory Sandbox { get; private set; } = new(); + public IReadOnlyList AvailableTemplates { get; } + public IReadOnlyList StagedAdditions => _stagedAdditions; + public IReadOnlyList StagedRemovals => _stagedRemovals; + public IReadOnlyList AuditLog => _auditLog; + + private readonly ITemplateAccessor _templateAccessor; + + public InventoryTransactionDemoService(ITemplateAccessor templateAccessor) + { + _templateAccessor = templateAccessor; + AvailableTemplates = new ItemTemplateDataGenerator().GenerateData().ToList(); + _initialItems = SeedInventory(); + Reset(); + } + + public void Reset() + { + Sandbox = new Inventory { Items = new List() }; + Sandbox.Variables[OpenRpg.Items.Types.InventoryVariableTypes.MaxSlots] = SandboxMaxSlots; + foreach (var initial in _initialItems) + { + Sandbox.Items.Add(initial.Clone()); + } + ClearStaging(); + _auditLog.Clear(); + Log("RESET: sandbox restored from seed (MaxSlots=" + SandboxMaxSlots + ")"); + } + + public void QueueRemoval(int itemTemplateId, int amount) + { + var data = new ItemData { TemplateId = itemTemplateId }; + if (amount > 0) { data.Variables.Amount = amount; } + _stagedRemovals.Add(data); + Log($" + staged removal: template {itemTemplateId}" + (amount > 0 ? $" x{amount}" : "")); + } + + public void QueueAddition(int itemTemplateId, int amount) + { + var data = new ItemData { TemplateId = itemTemplateId }; + if (amount > 0) { data.Variables.Amount = amount; } + _stagedAdditions.Add(data); + Log($" + staged addition: template {itemTemplateId}" + (amount > 0 ? $" x{amount}" : "")); + } + + public void ClearStaging() + { + if (_stagedRemovals.Count > 0 || _stagedAdditions.Count > 0) + { Log(" + staging cleared (no changes applied)"); } + _stagedRemovals.Clear(); + _stagedAdditions.Clear(); + } + + public bool ApplyStaged() + { + if (_stagedRemovals.Count == 0 && _stagedAdditions.Count == 0) + { + Log(" ! nothing to apply (staging is empty)"); + return false; + } + + Log($"APPLY: {_stagedRemovals.Count} removals, {_stagedAdditions.Count} additions"); + var transaction = Sandbox.CreateTransaction(_templateAccessor); + transaction.RemoveItems(_stagedRemovals.ToArray()); + transaction.AddItems(_stagedAdditions.ToArray()); + + var succeeded = transaction.ApplyChanges(); + Log(succeeded ? " => committed" : " => rolled back (no changes applied)"); + _stagedRemovals.Clear(); + _stagedAdditions.Clear(); + return succeeded; + } + + private void Log(string message) + { + var stamp = DateTime.Now.ToString("HH:mm:ss"); + _auditLog.Insert(0, $"[{stamp}] {message}"); + if (_auditLog.Count > 50) + { _auditLog.RemoveRange(50, _auditLog.Count - 50); } + } + + private static List SeedInventory() + { + return new List + { + AmountStack(ItemTemplateLookups.Sword, 1), + AmountStack(ItemTemplateLookups.HealingPotion, 3), + AmountStack(ItemTemplateLookups.CopperOre, 8) + }; + } + + private static ItemData AmountStack(int templateId, int amount) + { + var data = new ItemData { TemplateId = templateId }; + data.Variables.Amount = amount; + return data; + } + } +} diff --git a/src/OpenRpg.Demos.Infrastructure/Services/PersistenceDemoService.cs b/src/OpenRpg.Demos.Infrastructure/Services/PersistenceDemoService.cs new file mode 100644 index 00000000..fc39635d --- /dev/null +++ b/src/OpenRpg.Demos.Infrastructure/Services/PersistenceDemoService.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenRpg.Core.Common; +using OpenRpg.Data; +using OpenRpg.Data.Conventions.Extensions; +using OpenRpg.Data.InMemory; +using OpenRpg.Demos.Infrastructure.Data; +using OpenRpg.Demos.Infrastructure.Extensions; + +namespace OpenRpg.Demos.Infrastructure.Services +{ + /// + /// Sandbox-backed persistence playground. Holds a private InMemoryDataSource and Repository + /// so CRUD operations here cannot leak into the shared IRepository used by other demo pages. + /// + public class PersistenceDemoService : IPersistenceDemoService + { + private readonly Dictionary> _initialSeed; + private InMemoryDataSource _sandboxDataSource; + + public IReadOnlyDictionary SeedSnapshot { get; } + + public IRepository Repository { get; private set; } + + public PersistenceDemoService() + { + _initialSeed = BuildInitialSeed(); + SeedSnapshot = FreezeSeedSnapshot(_initialSeed); + RebuildSandbox(); + } + + public void Reset() => RebuildSandbox(); + + public IReadOnlyCollection GetAll() where T : class, IHasDataId + { + return Repository.GetAll().ToList(); + } + + public T Get(object id) where T : class, IHasDataId + { + return Repository.Get(id); + } + + public bool Exists(object id) where T : class, IHasDataId + { + return Repository.Exists(id); + } + + public T Create(T entity) where T : class, IHasDataId + { + if (entity.Id == 0) + { + var newId = NextIdFor(); + SetEntityId(entity, newId); + } + return Repository.Create(entity); + } + + public T Update(T entity) where T : class, IHasDataId + { + return Repository.Update(entity); + } + + public bool Delete(object id) where T : class, IHasDataId + { + return Repository.Delete(id); + } + + public int NextIdFor() where T : class, IHasDataId + { + var existing = Repository.GetAll().ToList(); + return existing.Count == 0 ? 1 : existing.Max(e => e.Id) + 1; + } + + private void RebuildSandbox() + { + var copy = new Dictionary>(); + foreach (var kvp in _initialSeed) + { + var inner = new Dictionary(); + foreach (var entry in kvp.Value) + { + inner[entry.Key] = entry.Value; + } + copy[kvp.Key] = inner; + } + + _sandboxDataSource = new InMemoryDataSource(copy); + Repository = new Repository(_sandboxDataSource); + } + + private static Dictionary> BuildInitialSeed() + { + var data = new Dictionary>(); + AddIfRegistered(data, typeof(OpenRpg.Entities.Races.Templates.RaceTemplate), new Data.RaceTemplateDataGenerator()); + AddIfRegistered(data, typeof(OpenRpg.Items.Templates.ItemTemplate), new Data.ItemTemplateDataGenerator()); + AddIfRegistered(data, typeof(OpenRpg.Quests.QuestTemplate), new Data.QuestStateDataGenerator()); + return data; + } + + private static void AddIfRegistered(Dictionary> data, Type type, IDataGenerator generator) + where T : IHasDataId + { + if (!data.ContainsKey(type)) + { + data.Add(type, generator.GenerateDictionary()); + } + } + + private static IReadOnlyDictionary FreezeSeedSnapshot(Dictionary> seed) + { + var snapshot = new Dictionary(seed.Count); + foreach (var kvp in seed) + { + snapshot[kvp.Key] = new Dictionary(kvp.Value); + } + return snapshot; + } + + private static void SetEntityId(T entity, int newId) where T : IHasDataId + { + var prop = typeof(T).GetProperty(nameof(IHasDataId.Id)); + if (prop != null && prop.CanWrite) + { + prop.SetValue(entity, newId); + } + } + } +} diff --git a/src/OpenRpg.Demos.Infrastructure/Templates/ManualTemplateAccessor.cs b/src/OpenRpg.Demos.Infrastructure/Templates/ManualTemplateAccessor.cs index 6cfd2575..267c5efc 100644 --- a/src/OpenRpg.Demos.Infrastructure/Templates/ManualTemplateAccessor.cs +++ b/src/OpenRpg.Demos.Infrastructure/Templates/ManualTemplateAccessor.cs @@ -18,6 +18,12 @@ public void AddTemplate(ITemplate template) TemplateData[templateType].Add(template.Id, template); } + public void AddTemplates(IEnumerable templates) + { + foreach(var template in templates) + { AddTemplate(template); } + } + public void AddTemplate(T template) where T : ITemplate { var templateType = typeof(T); diff --git a/src/OpenRpg.Demos.Infrastructure/Trading/Trader.cs b/src/OpenRpg.Demos.Infrastructure/Trading/Trader.cs new file mode 100644 index 00000000..81589c5c --- /dev/null +++ b/src/OpenRpg.Demos.Infrastructure/Trading/Trader.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using OpenRpg.Items.TradeSkills.Trading; + +namespace OpenRpg.Demos.Infrastructure.Trading +{ + public class Trader : ITrader + { + public IReadOnlyList Items { get; } + public string Name { get; } + + public Trader(string name, IReadOnlyList items) + { + Name = name; + Items = items; + } + } +} diff --git a/src/OpenRpg.Demos.Infrastructure/Types/ViewVariableTypes.cs b/src/OpenRpg.Demos.Infrastructure/Types/ViewVariableTypes.cs deleted file mode 100644 index f2231370..00000000 --- a/src/OpenRpg.Demos.Infrastructure/Types/ViewVariableTypes.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace OpenRpg.Demos.Infrastructure.Types -{ - public class ViewVariableTypes - { - public static int AssetCode = 5000; - } -} \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/Components/Cards/BasicCard.razor b/src/OpenRpg.Demos.Web/Components/Cards/BasicCard.razor index 357c39bf..04fec2d8 100644 --- a/src/OpenRpg.Demos.Web/Components/Cards/BasicCard.razor +++ b/src/OpenRpg.Demos.Web/Components/Cards/BasicCard.razor @@ -60,7 +60,10 @@ { var itemData = (Card as EquipmentCard).Data.Data; var itemTemplate = TemplateAccessor.GetItemTemplate(itemData.TemplateId); - return itemTemplate.Effects.GetStaticEffects().OrderByDescending(x => x.Potency).FirstOrDefault(); + return itemTemplate.Variables.Effects + .GetStaticEffects() + .OrderByDescending(x => x.Potency) + .FirstOrDefault(); } } diff --git a/src/OpenRpg.Demos.Web/Components/Characters/BasicCharacter.razor b/src/OpenRpg.Demos.Web/Components/Characters/BasicCharacter.razor index e8b23e62..e4305c56 100644 --- a/src/OpenRpg.Demos.Web/Components/Characters/BasicCharacter.razor +++ b/src/OpenRpg.Demos.Web/Components/Characters/BasicCharacter.razor @@ -1,31 +1,41 @@ @using OpenRpg.Core.Effects @using OpenRpg.Genres.Characters @using OpenRpg.Core.Templates -@using OpenRpg.Entities.Effects @using OpenRpg.Entities.Extensions +@using OpenRpg.Items.Extensions
@if (Character != null) { + Race="@TemplateAccessor.ToInstance(Character.Variables.Race)" + Class="@TemplateAccessor.ToInstance(Character.Variables.Class)" />


-
-
-

Race Effects

- -
-
-

Class Effects

- -
-
+ + @if (Character.Variables.HasRace()) + { + + + + } + @if (Character.Variables.HasClass()) + { + + + + } + @if (Character.Variables.HasEquipment()) + { + + + + } + }
@@ -38,8 +48,11 @@ public ITemplateAccessor TemplateAccessor { get; set; } public IReadOnlyCollection GetRaceEffects() - { return TemplateAccessor.GetRaceTemplate(Character.Variables.Race().TemplateId).Effects.GetStaticEffects(); } + { return TemplateAccessor.GetRaceTemplate(Character.Variables.Race.TemplateId).Variables.Effects.GetStaticEffects(); } public IReadOnlyCollection GetClassEffects() - { return TemplateAccessor.GetClassTemplate(Character.Variables.Class().TemplateId).Effects.GetStaticEffects(); ; } + { return TemplateAccessor.GetClassTemplate(Character.Variables.Class.TemplateId).Variables.Effects.GetStaticEffects(); } + + public IReadOnlyCollection GetEquipmentEffects() + { return Character.Variables.Equipment.GetEffects(TemplateAccessor).ToArray().GetStaticEffects(); } } \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/Components/Characters/CharacterDetails.razor b/src/OpenRpg.Demos.Web/Components/Characters/CharacterDetails.razor index 109697a5..a43ab9d3 100644 --- a/src/OpenRpg.Demos.Web/Components/Characters/CharacterDetails.razor +++ b/src/OpenRpg.Demos.Web/Components/Characters/CharacterDetails.razor @@ -6,7 +6,7 @@

@(Name ?? "Unnamed Person")

@if(Class != null) { -

@($"Level {Class?.Data.Variables.Level().ToString() ?? "1"} {Race.Template?.NameLocaleId ?? "Unknown Race"} {Class?.Template?.NameLocaleId ?? "Unknown Class"}")

+

@($"Level {Class?.Data.Variables.Level.ToString() ?? "1"} {Race.Template?.NameLocaleId ?? "Unknown Race"} {Class?.Template?.NameLocaleId ?? "Unknown Class"}")

} @if(@Classes != null) { @@ -15,7 +15,7 @@ @foreach (var _class in @Classes) {
- +
} diff --git a/src/OpenRpg.Demos.Web/Components/Characters/CharacterStats.razor b/src/OpenRpg.Demos.Web/Components/Characters/CharacterStats.razor index dd572442..7cc2facc 100644 --- a/src/OpenRpg.Demos.Web/Components/Characters/CharacterStats.razor +++ b/src/OpenRpg.Demos.Web/Components/Characters/CharacterStats.razor @@ -4,37 +4,40 @@ @if (Stats != null) { -

Core Stats

-
-
- -
-
- -
-
- -
-
- -
-
- -
-
+ @if (ShowCoreStats) + { +

Core Stats

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ }

Combat Stats

- +
- +
- +
- +
} @@ -42,4 +45,7 @@ @code { [Parameter] public EntityStatsVariables Stats { get; set; } + + [Parameter] + public bool ShowCoreStats { get; set; } = true; } \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/Components/Characters/CharacterVitals.razor b/src/OpenRpg.Demos.Web/Components/Characters/CharacterVitals.razor index 6688f457..6db86e95 100644 --- a/src/OpenRpg.Demos.Web/Components/Characters/CharacterVitals.razor +++ b/src/OpenRpg.Demos.Web/Components/Characters/CharacterVitals.razor @@ -9,11 +9,11 @@

Health

- +

Mana

- +
} diff --git a/src/OpenRpg.Demos.Web/Components/Charts/ScalingFunctionChart.razor b/src/OpenRpg.Demos.Web/Components/Charts/ScalingFunctionChart.razor index 344f7c23..eef18b42 100644 --- a/src/OpenRpg.Demos.Web/Components/Charts/ScalingFunctionChart.razor +++ b/src/OpenRpg.Demos.Web/Components/Charts/ScalingFunctionChart.razor @@ -11,18 +11,28 @@ Stroke="new SeriesStroke() { Width = 2, Color = ChartColor }" YValue="@(e => e.OutputValue)" XValue="@(e => e.InputValue)"/> + @if (HasHighlight) + { + + } @code { [Parameter] public string Title { get; set; } - + [Parameter] public ScalingFunction ScalingFunction { get; set; } [Parameter] public bool ShowLabels { get; set; } = true; - + [Parameter] public string ChartColor { get; set; } = "#FF4560"; @@ -31,21 +41,43 @@ [Parameter] public string YAxisName { get; set; } = "Output"; - + + [Parameter] + public float? HighlightValue { get; set; } + + [Parameter] + public string HighlightColor { get; set; } = "#2c3e50"; + public List Data { get; set; } = new(); - + public List HighlightData { get; set; } = new(); + public bool HasHighlight => HighlightValue.HasValue; + public ApexChartOptions ChartOptions { get; set; } = new(); public ApexChart ChartInstance { get; set; } - + void PopulatePoints() { - Data.Clear(); - var newPoints = FixedDataRanges.NormalizedZeroToOne + Data = FixedDataRanges.NormalizedZeroToOne .Denormalize(ScalingFunction.InputScale.Min, ScalingFunction.InputScale.Max) .Select(GenerateDataPoint) - .ToArray(); - - Data.AddRange(newPoints); + .ToList(); + + if (HighlightValue.HasValue) + { + var clampedInput = Math.Clamp(HighlightValue.Value, + ScalingFunction.InputScale.Min, + ScalingFunction.InputScale.Max); + HighlightData = new List + { + new DataPoint( + (decimal)clampedInput, + (decimal)ScalingFunction.Plot(clampedInput)) + }; + } + else + { + HighlightData = new List(); + } } DataPoint GenerateDataPoint(float input) @@ -96,6 +128,9 @@ public async Task Refresh() { PopulatePoints(); - await ChartInstance.RenderAsync(); + if (ChartInstance != null) + { + await ChartInstance.UpdateSeriesAsync(false); + } } } \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/Components/Equipment/BasicEquipment.razor b/src/OpenRpg.Demos.Web/Components/Equipment/BasicEquipment.razor new file mode 100644 index 00000000..604277b4 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Components/Equipment/BasicEquipment.razor @@ -0,0 +1,76 @@ +@using OpenRpg.Core.Templates +@using OpenRpg.Items.Extensions +@using OpenRpg.Genres.Characters +@using OpenRpg.Genres.Fantasy.Types +@using OpenRpg.Items +@using OpenRpg.Items.Equippables + + +
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+ +@code { + [Parameter] + public Equipment Equipment { get; set; } + + [Parameter] + public Character Character { get; set; } + + [Parameter] + public EventCallback OnItemInteraction { get; set; } + + [Parameter] + public ITemplateAccessor TemplateAccessor { get; set; } + + public Item? GetItemFromSlot(int slotType) + { + if(!Equipment.HasItemEquipped(slotType)) + { return null; } + + var itemData = Equipment.GetItemInSlot(slotType); + if(itemData == null) { return null; } + + return TemplateAccessor.ToInstance(itemData); + } + + public void Refresh() + { + StateHasChanged(); + } + +} \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/Components/Equipment/EquipmentIcon.razor b/src/OpenRpg.Demos.Web/Components/Equipment/EquipmentIcon.razor new file mode 100644 index 00000000..0f4644bd --- /dev/null +++ b/src/OpenRpg.Demos.Web/Components/Equipment/EquipmentIcon.razor @@ -0,0 +1,20 @@ +@using OpenRpg.Genres.Characters +@using OpenRpg.Items + +@if (Item == null) +{ +
+ +
+} +else +{ + +} + +@code { + [Parameter] public Item? Item { get; set; } = null; + + [Parameter] + public Character Character { get; set; } +} \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/Components/Generic/Accordion.razor b/src/OpenRpg.Demos.Web/Components/Generic/Accordion.razor new file mode 100644 index 00000000..6db61c08 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Components/Generic/Accordion.razor @@ -0,0 +1,32 @@ + + +@code { + + [Parameter] + public bool IsExpanded { get; set; } = false; + + [Parameter] + public string HeaderClasses { get; set; } = "is-info"; + + [Parameter] + public string ContentClasses { get; set; } = ""; + + [Parameter] + public string ContainerClasses { get; set; } = string.Empty; + + [Parameter] + public RenderFragment ChildContent { get; set; } + + [Parameter] + public string Title { get; set; } +} \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/Components/Generic/EffectList.razor b/src/OpenRpg.Demos.Web/Components/Generic/EffectList.razor index a0d152e7..1ec95114 100644 --- a/src/OpenRpg.Demos.Web/Components/Generic/EffectList.razor +++ b/src/OpenRpg.Demos.Web/Components/Generic/EffectList.razor @@ -43,7 +43,12 @@ public Character Character { get; set; } public string GetEffectText(int effectType) - { return LocaleRepository.Get(LocaleDataGenerator.GetKeyFor(LocaleDataGenerator.EffectTextKey, effectType)); } + { + var key = LocaleDataGenerator.GetKeyFor(LocaleDataGenerator.EffectTextKey, effectType); + if (LocaleRepository.Exists(key)) + { return LocaleRepository.Get(key); } + return effectType.ToString(); + } public bool HasRequirements(IEffect staticEffect) { @@ -64,7 +69,7 @@ if(!HasRequirements(staticEffect)) { return true; } - return RequirementsChecker.AreRequirementsMet(Character, staticEffect); + return RequirementsChecker.AreRequirementsMet(Character, staticEffect.Requirements); } public bool IsRequirementMet(Requirement requirement) diff --git a/src/OpenRpg.Demos.Web/Components/Inventories/BasicInventory.razor b/src/OpenRpg.Demos.Web/Components/Inventories/BasicInventory.razor index 7c817d18..43310603 100644 --- a/src/OpenRpg.Demos.Web/Components/Inventories/BasicInventory.razor +++ b/src/OpenRpg.Demos.Web/Components/Inventories/BasicInventory.razor @@ -5,7 +5,7 @@ @using OpenRpg.Items @using OpenRpg.Items.Inventories -@for (var i = 0; i < Inventory.Variables.MaxSlots(); i+=SlotsPerRow) +@for (var i = 0; i < Inventory.Variables.MaxSlots; i+=SlotsPerRow) {
@for (var row = 0; row < SlotsPerRow; row++) @@ -20,7 +20,7 @@
}
- @(Inventory.Items.Count) @(Inventory.Variables.ContainsKey(InventoryVariableTypes.MaxSlots) ? $"/ {Inventory.Variables.MaxSlots()}" : "") Items + @(Inventory.Items.Count) @(Inventory.Variables.ContainsKey(InventoryVariableTypes.MaxSlots) ? $"/ {Inventory.Variables.MaxSlots}" : "") Items
@code { diff --git a/src/OpenRpg.Demos.Web/Components/Items/ItemDetails.razor b/src/OpenRpg.Demos.Web/Components/Items/ItemDetails.razor index 1fefb47a..67184ca9 100644 --- a/src/OpenRpg.Demos.Web/Components/Items/ItemDetails.razor +++ b/src/OpenRpg.Demos.Web/Components/Items/ItemDetails.razor @@ -2,9 +2,7 @@ @using OpenRpg.Core.Templates @using OpenRpg.Genres.Fantasy.Types @using OpenRpg.Items.Extensions -@using OpenRpg.Items.Templates @using OpenRpg.Items.Types -@using OpenRpg.Demos.Infrastructure.Extensions @using OpenRpg.Entities.Extensions @using OpenRpg.Genres.Characters @using OpenRpg.Genres.Extensions @@ -27,7 +25,7 @@
- +
@@ -57,11 +55,11 @@
} - @if (Item.Template.Requirements.Any()) + @if (Item.Template.Variables.HasRequirements()) {
Requirements
- +
} @@ -97,11 +95,11 @@ } public float TotalDamage => Effects.GetStaticEffects() - .Where(x => x.IsDamagingEffect() || x.EffectType == FantasyEffectTypes.DamageBonusAmount) + .Where(x => x.IsDamagingEffect || x.EffectType == FantasyEffectTypes.DamageBonusAmount) .Sum(x => x.Potency); public float TotalDefense => Effects.GetStaticEffects() - .Where(x => x.IsDefensiveEffect() || x.EffectType == FantasyEffectTypes.DefenseBonusAmount) + .Where(x => x.IsDefensiveEffect || x.EffectType == FantasyEffectTypes.DefenseBonusAmount) .Sum(x => x.Potency); @@ -120,14 +118,14 @@ protected override void OnParametersSet() { - var areRequirementsMet = RequirementsChecker.AreRequirementsMet(Character, Item.Template); + var areRequirementsMet = RequirementsChecker.AreRequirementsMet(Character, Item.Template.Variables.Requirements); var requirementsClass = !areRequirementsMet ? "has-background-danger-light" : ""; cardClasses = $"item-border item-quality-{_itemQualityTypeId} {requirementsClass}"; Effects = Item.Data.GetEffects(OverriddenTemplateAccessor != null ? OverriddenTemplateAccessor : TemplateAccessor).ToArray(); if (Item.Template.Variables.ContainsKey(ItemTemplateVariableTypes.QualityType)) - { _itemQualityTypeId = Item.Template.Variables.QualityType(); } + { _itemQualityTypeId = Item.Template.Variables.QualityType; } base.OnParametersSet(); } diff --git a/src/OpenRpg.Demos.Web/Components/Items/ItemIcon.razor b/src/OpenRpg.Demos.Web/Components/Items/ItemIcon.razor index 6ff4968c..38fda023 100644 --- a/src/OpenRpg.Demos.Web/Components/Items/ItemIcon.razor +++ b/src/OpenRpg.Demos.Web/Components/Items/ItemIcon.razor @@ -6,7 +6,7 @@ @using OpenRpg.Genres.Characters
- +
@(_itemAmount > 0 ? $"{_itemAmount}" : "")
@@ -28,12 +28,12 @@ protected override void OnParametersSet() { if (Item.Data.Variables != null && Item.Data.Variables.ContainsKey(ItemVariableTypes.Amount)) - { _itemAmount = Item.Data.Variables.Amount(); } + { _itemAmount = Item.Data.Variables.Amount; } else { _itemAmount = 0;} if (Item.Template.Variables.ContainsKey(ItemTemplateVariableTypes.QualityType)) - { _itemQualityTypeId = Item.Template.Variables.QualityType(); } + { _itemQualityTypeId = Item.Template.Variables.QualityType; } base.OnParametersSet(); } diff --git a/src/OpenRpg.Demos.Web/Components/Items/ItemTemplateDetails.razor b/src/OpenRpg.Demos.Web/Components/Items/ItemTemplateDetails.razor index b90919d6..835100a9 100644 --- a/src/OpenRpg.Demos.Web/Components/Items/ItemTemplateDetails.razor +++ b/src/OpenRpg.Demos.Web/Components/Items/ItemTemplateDetails.razor @@ -1,11 +1,7 @@ @using OpenRpg.Core.Effects -@using OpenRpg.Core.Templates @using OpenRpg.Genres.Fantasy.Types @using OpenRpg.Items.Extensions @using OpenRpg.Items.Templates -@using OpenRpg.Items.Types -@using OpenRpg.Demos.Infrastructure.Extensions -@using OpenRpg.Entities.Effects @using OpenRpg.Entities.Extensions @using OpenRpg.Genres.Characters @using OpenRpg.Genres.Extensions @@ -15,7 +11,6 @@ @inject ILocaleRepository LocaleRepository; @inject ICharacterRequirementChecker RequirementsChecker -@inject ITemplateAccessor TemplateAccessor

@(ItemTemplate.NameLocaleId ?? "Unnamed Item")

@@ -26,18 +21,18 @@
- + @($
@if (IsWeapon) { -

@(ItemTemplate.Effects.GetStaticEffects().Where(x => x.EffectType == FantasyEffectTypes.DamageBonusAmount).Sum(x => x.Potency))

+

@(SumDamage())

Damage
} @if (IsArmour) { -

@(ItemTemplate.Effects.GetStaticEffects().Where(x => x.EffectType == FantasyEffectTypes.DefenseBonusAmount).Sum(x => x.Potency))

+

@(SumDefense())

Defense
}
@@ -56,11 +51,11 @@
} - @if (ItemTemplate.Requirements.Any()) + @if (ItemTemplate.Variables.HasRequirements()) {
Requirements
- +
}
@@ -75,6 +70,22 @@ private bool IsWeapon => ItemTemplate.ItemType == FantasyItemTypes.GenericWeapon; + public int SumDamage() + { + return (int)ItemTemplate.Variables.Effects + .GetStaticEffects() + .Where(x => x.EffectType == FantasyEffectTypes.DamageBonusAmount) + .Sum(x => x.Potency); + } + + public int SumDefense() + { + return (int)ItemTemplate.Variables.Effects + .GetStaticEffects() + .Where(x => x.EffectType == FantasyEffectTypes.DefenseBonusAmount) + .Sum(x => x.Potency); + } + private bool IsArmour { get @@ -93,9 +104,9 @@ { get { - if (IsWeapon) { return ItemTemplate.Effects.GetStaticEffects().Where(x => x.EffectType != FantasyEffectTypes.DamageBonusAmount); } - if (IsArmour) { return ItemTemplate.Effects.GetStaticEffects().Where(x => x.EffectType != FantasyEffectTypes.DefenseBonusAmount); } - return ItemTemplate.Effects.GetStaticEffects(); + if (IsWeapon) { return ItemTemplate.Variables.Effects.GetStaticEffects().Where(x => x.EffectType != FantasyEffectTypes.DamageBonusAmount); } + if (IsArmour) { return ItemTemplate.Variables.Effects.GetStaticEffects().Where(x => x.EffectType != FantasyEffectTypes.DefenseBonusAmount); } + return ItemTemplate.Variables.Effects.GetStaticEffects(); } } @@ -105,7 +116,7 @@ protected override void OnParametersSet() { - var areRequirementsMet = RequirementsChecker.AreRequirementsMet(Character, ItemTemplate); + var areRequirementsMet = RequirementsChecker.AreRequirementsMet(Character, ItemTemplate.Variables.Requirements); var requirementsClass = !areRequirementsMet ? "has-background-danger-light" : ""; cardClasses = $"item-border item-quality-{_itemQualityTypeId} {requirementsClass}"; @@ -114,8 +125,8 @@ protected override void OnInitialized() { - if (ItemTemplate.Variables.ContainsKey(ItemTemplateVariableTypes.QualityType)) - { _itemQualityTypeId = ItemTemplate.Variables.QualityType(); } + if (ItemTemplate.Variables.HasQualityType()) + { _itemQualityTypeId = ItemTemplate.Variables.QualityType; } } diff --git a/src/OpenRpg.Demos.Web/Components/Quests/QuestDetails.razor b/src/OpenRpg.Demos.Web/Components/Quests/QuestDetails.razor index 971a1dce..8fb46789 100644 --- a/src/OpenRpg.Demos.Web/Components/Quests/QuestDetails.razor +++ b/src/OpenRpg.Demos.Web/Components/Quests/QuestDetails.razor @@ -1,4 +1,5 @@ @using OpenRpg.Core.Templates +@using OpenRpg.Entities.Extensions @using OpenRpg.Genres.Characters @using OpenRpg.Genres.Fantasy.Types @using OpenRpg.Items @@ -13,12 +14,12 @@

@(Quest.NameLocaleId ?? "Unnamed Quest")

@(Quest.DescriptionLocaleId ?? "No Description")

- @if (Quest.Requirements.Any()) + @if (Quest.Variables.HasRequirements()) {

Requirements

- +
} @if (Quest.Gifts.Any()) @@ -80,7 +81,7 @@ @code { - [Parameter] public Quest Quest { get; set; } + [Parameter] public QuestTemplate Quest { get; set; } [Parameter] public Character Character { get; set; } [Parameter] public ITemplateAccessor TemplateAccessor { get; set; } @@ -91,7 +92,7 @@ TemplateId = itemTemplateId, Variables = new ItemVariables() }; - item.Variables.Amount(itemAmount); + item.Variables.Amount = itemAmount; return TemplateAccessor.ToInstance(item); } } \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/Components/TradeSkills/ItemCraftingTemplateDetails.razor b/src/OpenRpg.Demos.Web/Components/TradeSkills/ItemCraftingTemplateDetails.razor new file mode 100644 index 00000000..b3988c31 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Components/TradeSkills/ItemCraftingTemplateDetails.razor @@ -0,0 +1,102 @@ +@using OpenRpg.Core.Templates +@using OpenRpg.Entities.Extensions +@using OpenRpg.Items.Extensions +@using OpenRpg.Items.Templates +@using OpenRpg.Genres.Characters +@using OpenRpg.Items.TradeSkills.Extensions +@using OpenRpg.Items.TradeSkills.Templates +@using OpenRpg.Localization.Data.Extensions +@using OpenRpg.Localization.Data.Repositories + +@inject ILocaleRepository LocaleRepository; + + +

@(Name)

+ @if (!string.IsNullOrEmpty(ItemCraftingTemplate.DescriptionLocaleId)) + { +
@(ItemCraftingTemplate.DescriptionLocaleId)
+ } +
+
+ Requires +
+
+
+
+ Makes +
+
+
+
+
+ @foreach (var itemEntry in ItemCraftingTemplate.InputItems) + { +
+ +
+ } +
+
+
+ => +
+
+
+ @foreach (var itemEntry in ItemCraftingTemplate.OutputItems) + { +
+ +
+ } +
+
+
+ @if (ItemCraftingTemplate.Variables.HasTradeSkillRequirements()) + { + @foreach (var requirement in ItemCraftingTemplate.Variables.GetTradeSkillRequirements()) + { +
+ Requires @(LocaleRepository.Get(LocaleDataGenerator.GetKeyFor(LocaleDataGenerator.TradeSkillTypesTextKey, requirement.Association.AssociatedId))) @(requirement.Association.AssociatedValue) +
+ } + } +
+ +@code { + + [Parameter] + public ItemCraftingTemplate ItemCraftingTemplate { get; set; } + + [Parameter] + public ITemplateAccessor TemplateAccessor { get; set; } + + [Parameter] + public Character Character { get; set; } + + private string Name { get; set; } + + private Dictionary InputTemplates { get; set; } = new(); + private Dictionary OutputTemplates { get; set; } = new(); + + protected override void OnInitialized() + { + foreach (var inputItem in ItemCraftingTemplate.InputItems) + { + var inputItemTemplate = TemplateAccessor.GetItemTemplate(inputItem.TemplateId); + InputTemplates.Add(inputItemTemplate.Id, inputItemTemplate); + } + + foreach (var outputItem in ItemCraftingTemplate.OutputItems) + { + var outputItemTemplate = TemplateAccessor.GetItemTemplate(outputItem.TemplateId); + OutputTemplates.Add(outputItemTemplate.Id, outputItemTemplate); + } + + if (string.IsNullOrEmpty(ItemCraftingTemplate.NameLocaleId)) + { + var outputItemTemplate = OutputTemplates.Values.First(); + Name = $"Craft {outputItemTemplate.NameLocaleId}"; + } + else { Name = ItemCraftingTemplate.NameLocaleId; } + } +} \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/Components/TradeSkills/TradeSkillItemIcon.razor b/src/OpenRpg.Demos.Web/Components/TradeSkills/TradeSkillItemIcon.razor index 173fb54d..db807df1 100644 --- a/src/OpenRpg.Demos.Web/Components/TradeSkills/TradeSkillItemIcon.razor +++ b/src/OpenRpg.Demos.Web/Components/TradeSkills/TradeSkillItemIcon.razor @@ -8,10 +8,10 @@ @using OpenRpg.Items.TradeSkills.Extensions
- +
@(_itemAmount > 0 ? $"{_itemAmount}" : "")
- +
@@ -33,13 +33,13 @@ protected override void OnParametersSet() { - if (Item.Variables != null && Item.Variables.ContainsKey(ItemVariableTypes.Amount)) - { _itemAmount = Item.Variables.Amount(); } + if (Item.Variables != null && Item.Variables.HasAmount()) + { _itemAmount = Item.Variables.Amount; } else { _itemAmount = 0;} - if (ItemTemplate.Variables.ContainsKey(ItemTemplateVariableTypes.QualityType)) - { _itemQualityTypeId = ItemTemplate.Variables.QualityType(); } + if (ItemTemplate.Variables.HasQualityType()) + { _itemQualityTypeId = ItemTemplate.Variables.QualityType; } base.OnParametersSet(); } diff --git a/src/OpenRpg.Demos.Web/Extensions/EffectExtensions.cs b/src/OpenRpg.Demos.Web/Extensions/EffectExtensions.cs index f5b23b61..842fba93 100644 --- a/src/OpenRpg.Demos.Web/Extensions/EffectExtensions.cs +++ b/src/OpenRpg.Demos.Web/Extensions/EffectExtensions.cs @@ -10,7 +10,7 @@ public static class EffectExtensions public static int[] PercentageEffectTypeCache = GetAllPercentageEffectTypeIds(typeof(FantasyEffectTypes)); public static string GetPotencySymbol(float potency) - { return potency > 0 ? "+" : "-"; } + { return potency > 0 ? "+" : ""; } public static string GeneratePotencyText(this IEffect effect) { diff --git a/src/OpenRpg.Demos.Web/OpenRpg.Demos.Web.csproj b/src/OpenRpg.Demos.Web/OpenRpg.Demos.Web.csproj index 88439617..432d1b2d 100644 --- a/src/OpenRpg.Demos.Web/OpenRpg.Demos.Web.csproj +++ b/src/OpenRpg.Demos.Web/OpenRpg.Demos.Web.csproj @@ -1,19 +1,19 @@ - net9.0 + net10.0 enable enable - 13 + 14 false - - - - - + + + + + @@ -31,7 +31,7 @@ - + diff --git a/src/OpenRpg.Demos.Web/Pages/AdviceEngine/ExampleSetup.razor b/src/OpenRpg.Demos.Web/Pages/AdviceEngine/ExampleSetup.razor index d6f56f61..4ee24052 100644 --- a/src/OpenRpg.Demos.Web/Pages/AdviceEngine/ExampleSetup.razor +++ b/src/OpenRpg.Demos.Web/Pages/AdviceEngine/ExampleSetup.razor @@ -68,7 +68,7 @@ {

@((agent.OwnerContext as Character).NameLocaleId)

- +
} @@ -345,7 +345,7 @@ public override IConsideration CreateConsideration(IAgent agent) { - var healthValueAccessor = new ManualValueAccessor((context, _) => (context as Character).State.Health()); + var healthValueAccessor = new ManualValueAccessor((context, _) => (context as Character).State.Health); return new ValueBasedConsideration(new UtilityKey(UtilityVariableTypes.LowHealth), healthValueAccessor, PresetClampers.ZeroToHundred, PresetCurves.InverseLinear); } } @@ -500,7 +500,7 @@ void ApplyRandoms(Character character) { character.Variables[PositionVariable] = GetRandomPositionValue(); - character.State.Health(GetRandomHealthValue()); + character.State.Health = GetRandomHealthValue(); } foreach (var agent in Agents) diff --git a/src/OpenRpg.Demos.Web/Pages/Cards/BasicCardComponents.razor b/src/OpenRpg.Demos.Web/Pages/Cards/BasicCardComponents.razor index 7a80f88a..bc8c5627 100644 --- a/src/OpenRpg.Demos.Web/Pages/Cards/BasicCardComponents.razor +++ b/src/OpenRpg.Demos.Web/Pages/Cards/BasicCardComponents.razor @@ -109,15 +109,15 @@ Id = 1, NameLocaleId = "Sword", DescriptionLocaleId = "A really bad looking sword, can slay things though", - ItemType = FantasyItemTypes.GenericWeapon, - Effects = new [] - { - new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 30.0f } - } + ItemType = FantasyItemTypes.GenericWeapon + }; + template.Variables.QualityType = FantasyItemQualityTypes.JunkQuality; + template.Variables.Value = 10; + template.Variables.AssetCode = "sword"; + template.Variables.Effects = new[] + { + new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 30.0f } }; - template.Variables.QualityType(FantasyItemQualityTypes.JunkQuality); - template.Variables.Value(10); - template.Variables.AssetCode("sword"); var itemData = new ItemData { TemplateId = template.Id }; var item = TemplateAccessor.ToInstance(itemData); diff --git a/src/OpenRpg.Demos.Web/Pages/Combat/Abilities.razor b/src/OpenRpg.Demos.Web/Pages/Combat/Abilities.razor new file mode 100644 index 00000000..36bb8983 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Combat/Abilities.razor @@ -0,0 +1,203 @@ +@page "/combat/abilities" + +@using OpenRpg.Combat.Abilities +@using OpenRpg.Combat.Attacks +@using OpenRpg.Combat.Processors.Attacks.Entity +@using OpenRpg.Combat.Types +@using OpenRpg.Combat.Extensions +@using OpenRpg.Core.Templates +@using OpenRpg.Entities.Extensions +@using OpenRpg.Genres.Characters +@using OpenRpg.Genres.Effects +@using OpenRpg.Genres.Fantasy.Types +@using OpenRpg.Genres.Fantasy.Extensions +@using OpenRpg.Localization.Data.Extensions +@using OpenRpg.Localization.Data.Repositories + +@inject DemoCharacterBuilder CharacterBuilder +@inject IEntityAttackGenerator AttackGenerator +@inject ILocaleRepository LocaleRepository +@inject ITemplateAccessor TemplateAccessor; +@inject ICharacterEffectProcessor EffectProcessor; + + + ## Abilities + + Quite often your game may need to do more than just "attack" you may have special moves like casing a fireball, or backstab. + + `Abilities` attempt to express this notion in a data driven way so you can separate the *description* of the ability from the *logic* of the ability. + + For example lets make an example **Fireball** ability template. + + ```csharp + var abilityTemplate = new AbilityTemplate() + { + Id = AbilityTypes.Fireball, + NameLocaleId = "Fireball", + DescriptionLocaleId = "Shoots fireballs towards nearby enemies", + }; + abilityTemplate.Variables.Cooldown = 1.0f; + abilityTemplate.Variables.Damage = new Damage(FantasyDamageTypes.FireDamage, 30); + abilityTemplate.Variables.Range = 7.0f; + abilityTemplate.Variables.AttackSize = 2.0f; + abilityTemplate.Variables.TargetType = CombatTargetTypes.AreaOfEffect; + abilityTemplate.Variables.ManaCost = 10; + abilityTemplate.Variables.AssetCode = "ability-fireball"; + return abilityTemplate; + ``` + + There is a lot going on there, but we can see it seems to have a quick-ish cooldown, does 30 fire damage, can be fired a long range, does a medium AoE in 2.0f unit area and costs 10 mana. + + > There are other things you can set such as `TargetCount` for abilities that can only hit fixed numbers of targets, or Stamina/Health costs and many more. + + This alone is great and provides a way to describe your `Abilities` but it needs **something** to exist to actually let something use the abilities. + +
+ + ## Thats not quite as easy to do + + Ok so this is one of those parts of the system where there is no "out the box" solution to the problem, this is mainly because there isn't a huge amount of commonality between use cases. + + For example if you were making a 3rd person souls-like and you wanted to use the above `Fireball` ability you would need to spawn an actual fireball in the world and have it move towards the target/position checking to see if it collides with anything along the way, then do effects for the AoE and collision checks in a 2.0f unit radius then generate an attack for the ability and apply it using the normal combat attack processing logic. + + If you were a 2d turn based RPG you may have a simpler job as you can probably just do some flash effect to pretend the fireball hit, but then you need to work out how to turn 2.0f units into adjacent units being hit etc then formulate the attack and process. + + > As you can see almost all scenarios require information related to positions/collision detection and animation systems which are all vastly different per engine/framework so its not really possible to provide useful abstractions within the library, but we can at least express the data and the components needed for these more complex engine/game specific processes. + + So as you can see for all the varying scenarios you have, the only real thing that would happen for all is you creating an `Attack` from the ability like: + + ```csharp + var fireballAbilityData = new AbilityData { TemplateId = AbilityTypes.Fireball }; + var fireballAbility = TemplateAccessor.ToInstance(fireballAbilityData); + + // Generate attack for the character using the ability + var attack = AttackGenerator.GenerateAttack(fireballAbility, Character.Stats); + // process attack on target + ``` + + This seems to do some of the work for us, but what if our ability should only do 50% damage to adjacent/aoe targets and 150% damage to the main target? what if there is a chance of the targets getting the *Burning* debuff? what if we have some other ability that is passive and makes all corpses touched by fire explode? + + As you can see the **execution** of an ability requires too much game/design context and runtime data that are not expressed within OpenRpg, so all we can do is help you express the fundamentals of an ability then write bespoke handlers for each ability that take into account your game/design scenarios/requirements. + + > Some things to keep in mind at a high level is that `Abilities` are often asynchronous things, once they start lots of things may happen before its deemed as **done** so structure your handler in such a way that it can handle this. + +
+ + ## An **almost** real world example + + Here is some code from a real world Unity project (slightly altered) that shows how a fireball is handled: + + ```csharp + public class FireballAbilityExecutor : AbilityExecutor + { + public override int AbilityTemplateId { get; } = AbilityTypeLookups.Fireball; + + public IPooledProjectileLifecycleHandler ProjectileLifecycleHandler { get; } + + public SpriteAnimationsAsset FireballVfx { get; } + public SpriteAnimationsAsset FireballHitVfx { get; } + + public FireballAbilityExecutor(VfxLinkedAnimationAsset vfxLinkedAnimationAsset, IPooledEffectService pooledEffectService, GameAttackGenerator attackGenerator, ITemplateAccessor templateAccessor, IPhysicsChecker physicsChecker, IDistanceChecker distanceChecker, IEntityAttackProcessor entityAttackProcessor, IPooledPopupTextService popupTextService, IEventSystem eventSystem, IEntityComponentAccessor entityComponentAccessor, IPooledProjectileLifecycleHandler projectileLifecycleHandler) : base(vfxLinkedAnimationAsset, pooledEffectService, attackGenerator, templateAccessor, physicsChecker, distanceChecker, entityAttackProcessor, popupTextService, eventSystem, entityComponentAccessor) + { + ProjectileLifecycleHandler = projectileLifecycleHandler; + FireballVfx = VfxLinkedAnimationAsset.GetDataFor(VfxEffectTypes.Fireball).SpriteAnimation; + FireballHitVfx = VfxLinkedAnimationAsset.GetDataFor(VfxEffectTypes.FireballHit).SpriteAnimation; + } + + public override bool AttemptSkill(Entity ownerEntity, TransientAbilityState abilityData, Character character, Transform2D transform, bool isEnemy) + { + var range = AbilityTemplate.Variables.Range() + character.Stats.ScaledAttackRange(); + var allTargets = GetEntitiesWithinRadius(transform.Position, range, isEnemy); + + var targets = allTargets + .Take(AbilityTemplate.Variables.TargetCount()) + .ToArray(); + + if(targets.Length == 0) { return false; } + + foreach (var targetEntityId in targets) + { + var projectileData = new ProjectileSetupConfig() + { + MaxLifetimeInSeconds = 4.0f, + MovementSpeed = 4.0f, + OwnerEntity = ownerEntity, + TargetPlayers = isEnemy, + TargetTransform = EntityComponentAccessor.GetComponent<Transform2DComponent>(targetEntityId).Transform, + ProjectileRadius = 0.1f, + SpawnPosition = transform.Position, + TargetActiveState = EntityComponentAccessor.GetComponent<ActiveComponent>(targetEntityId), + SpriteAnimation = FireballVfx + }; + ProjectileLifecycleHandler.HandleSpawn(projectileData, x => OnProjectileHit(ownerEntity, x)); + } + + return true; + } + + public void OnProjectileHit(Entity ownerEntity, ProjectileFinishedEventArgs args) + { + ProjectileLifecycleHandler.HandleRecycle(args.ProjectileEntity); + if(args.TargetEntity is null) { return; } + + var character = EntityComponentAccessor.GetComponent<CharacterComponent>(ownerEntity).Character; + var attack = AttackGenerator.GenerateAttack(AbilityTemplate.Variables.Damage().Clone(), character.Stats); + PooledEffectService.ShowEffectAt(FireballHitVfx, args.ProjectilePosition.ToUnity(), rotation: args.ProjectileRotation); + TakeDamage(attack, args.TargetEntity.Value, ownerEntity); + } + } + ``` + + Just to be clear this isn't code you can drop into your project and off you go, it is taking into account 2d positions/collisions, async handling of projectiles and vfx etc and lots of other custom logic. + + It does however give a rough high level example showing the sort of things you may need to think about and how you can express them. + + > Unfortunately the base `AbilityExecutor` is too large to show here, but its a custom class that for **that project** wraps up stuff like positional + +
+ + See [Ability Targeting & Damage Execution](combat/ability-targeting) for an interactive sandbox that runs the targeting + damage pieces from the Battler demo's executor end-to-end against a 1v-many party. + + +@code { + + public Character Character; + public AbilityTemplate FireballTemplate; + public Ability FireballAbility; + public Attack _randomAttack; + + protected override void OnInitialized() + { + Character = CharacterBuilder.CreateNew().Build(); + FireballTemplate = MakeFireballTemplate(); + var fireballAbilityData = new AbilityData { TemplateId = 1 }; + FireballAbility = new Ability { Data = fireballAbilityData, Template = FireballTemplate }; + GenerateAttack(); + + base.OnInitialized(); + } + + protected void GenerateAttack() + { + _randomAttack = AttackGenerator.GenerateAttack(FireballAbility, Character.Stats); + } + + public AbilityTemplate MakeFireballTemplate() + { + var abilityTemplate = new AbilityTemplate() + { + Id = 1, + NameLocaleId = "Fireball", + DescriptionLocaleId = "Shoots fireballs towards nearby enemies", + }; + abilityTemplate.Variables.Cooldown = 1.0f; + abilityTemplate.Variables.Damage = new Damage(FantasyDamageTypes.FireDamage, 30); + abilityTemplate.Variables.Range = 7.0f; + abilityTemplate.Variables.AttackSize = 2.0f; + abilityTemplate.Variables.TargetType = CombatTargetTypes.AreaOfEffect; + abilityTemplate.Variables.ManaCost = 10; + abilityTemplate.Variables.AssetCode = "ability-fireball"; + return abilityTemplate; + } + +} diff --git a/src/OpenRpg.Demos.Web/Pages/Combat/AbilityTargeting.razor b/src/OpenRpg.Demos.Web/Pages/Combat/AbilityTargeting.razor new file mode 100644 index 00000000..d76c480f --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Combat/AbilityTargeting.razor @@ -0,0 +1,275 @@ +@page "/combat/ability-targeting" + +@using OpenRpg.Combat.Abilities +@using OpenRpg.Combat.Processors.Attacks.Entity +@using OpenRpg.Combat.Types +@using OpenRpg.Combat.Extensions +@using OpenRpg.Demos.Infrastructure.Services +@using OpenRpg.Genres.Fantasy.Extensions +@using OpenRpg.Localization.Data.Extensions +@using OpenRpg.Localization.Data.Repositories + +@inject IAbilityExecutionDemoService AbilityDemo; +@inject ILocaleRepository LocaleRepository; + + + ## Ability Targeting & Damage Execution + + The [Abilities](combat/abilities) page covers what an `AbilityTemplate` looks like and why the *execution* of an ability is mostly a game-specific concern. This page focuses on the **targeting and damage pieces you can solve in OpenRpg** by running a 1v-many combat sandbox that mirrors the Battler demo's `AbilityExecutor` and `TargetResolver` end-to-end. + + Library code that does the heavy lifting here: + + - `TargetType` (1=Single, 2=Multiple, 5=AoE, ...) and `TargetCount` decide **who** gets hit. + - `IEntityAttackGenerator.GenerateAttack(Ability, stats)` produces an `Attack` from the ability's base damage plus the caster's stat bonuses. + - `IEntityAttackProcessor.ProcessAttack(Attack, target.Stats)` subtracts the defender's per-type defense and returns the damage done. + + The sandbox below does the same flow end-to-end on a private party of 4 casters and 5 enemies. Casters use the library's attack generator and processor against the enemy pool; heals target the ally pool. Each cast runs through the same target-resolution + damage-application pattern as the Battler demo, just without the positional/animation code. + +
+ + + + ### Caster Selection + + Pick a party member, then pick an ability. Class-gated abilities (Fighter, Mage) will be disabled if the chosen caster cannot meet the requirement. Mana cost is deducted on cast; HP changes are visible in the targets panel below. + +
+
+
Caster
+
+ +
+
+
+
Ability
+
+ +
+
+
+
+ + +
+ @if (_castError != null) + { +
+ Cast blocked. @_castError +
+ } +
+
+ + + + ### Targets + + HP bars for the party (left) and enemy formation (right). Targets highlighted with a blue bar were hit by the most recent damage ability; targets with a green bar were healed by the most recent healing ability. Use **Reset Sandbox** to restore HP and MP to their seeded values. + +
+
+
Party
+ @foreach (var caster in AbilityDemo.Party) + { + var isHealTarget = AbilityDemo.LastCastAllyTargetIndexes.Contains(caster.Index); +
+
+ @caster.Name + @caster.ClassName +
+
HP @caster.CurrentHp / @caster.MaxHp - MP @caster.CurrentMana / @caster.MaxMana
+ @caster.CurrentHp +
+ } +
+
+
Enemies
+ @foreach (var enemy in AbilityDemo.Enemies) + { + var isDamageTarget = AbilityDemo.LastCastEnemyTargetIndexes.Contains(enemy.Index); +
+
+ @enemy.Name + @(enemy.IsAlive ? "" : "(dead)") +
+
HP @enemy.CurrentHp / @enemy.MaxHp
+ @enemy.CurrentHp +
+ } +
+
+
+
+ + + + ### Damage Split For Multi-Target Abilities + + For abilities with `TargetType = MultipleTarget` (Slash, Chi Blast, Ice Storm, Cura) the executor picks the first N alive targets in the pool and splits the attack's damage evenly across them. The math is: + + ```csharp + var targetCount = Math.Min(ability.Variables.TargetCount, aliveEnemiesInPool.Count); + var splitFraction = 1.0f / targetCount; + var perTargetDamage = Math.Max(1, (int)Math.Round(rawDamage * splitFraction)); + ``` + + This is one of many possible interpretations. The library exposes `TargetType` and `TargetCount` as data; your executor is free to implement any spread rule: equal split, primary-target + diminishing returns, cone falloff, or per-target weighting by proximity. The Battler demo's executor skips the split entirely (every target takes the full rolled damage), so check it side by side with this demo to see two valid interpretations of the same `TargetType=MultipleTarget` template. + + +
+ + + + ### Combat Log + + Timestamped trace of every cast. Most recent first. The library's attack generator + processor only return an `Attack` + a `ProcessedAttack`; the per-target split, mana deduction, heal application, and hit/kill labelling are all done by the demo service wrapping those calls. + + @if (AbilityDemo.CombatLog.Count == 0) + { +

No casts yet. Pick a caster + ability and press Cast Ability.

+ } + else + { +
@string.Join("\n", AbilityDemo.CombatLog)
+ } +
+
+ + + ## Cost & Cooldown + + Each ability carries a flat `ManaCost`. The executor deducts it on cast (visible in the caster's MP column above). The library also exposes `Cooldown`, `Range`, and `AttackSize` per ability, but **turn-based cooldown tracking is out of scope for the executor**; it is the responsibility of your turn manager. The Battler demo's `TurnManager` keeps a per-ability cooldown dictionary across turns; if you want that, copy the same pattern. + + The full cast loop the demo runs is roughly equivalent to the Battler's `AbilityExecutor.ExecuteAbility`: + + ```csharp + var baseDamage = template.Variables.Damage; + var isHealing = baseDamage.Type == FantasyDamageTypes.LightDamage; + var manaCost = template.Variables.ManaCost; + caster.Mana = Math.Max(0, caster.Mana - manaCost); + + var pool = isHealing ? party : enemies; + var resolved = pool.Where(e => e.IsAlive) + .Take(template.Variables.TargetType == CombatTargetTypes.MultipleTarget + ? template.Variables.TargetCount : 1) + .ToList(); + + foreach (var target in resolved) + { + var attack = attackGenerator.GenerateAttack(baseDamage, caster.Stats); + var processed = attackProcessor.ProcessAttack(attack, target.Stats); + var split = 1.0f / resolved.Count; + target.Hp = Math.Max(0, target.Hp - (int)Math.Round(processed.DamageDone.Sum(d => d.Value) * split)); + } + ``` + + The actual Battler executor skips the `split` line; this demo adds it to show how the variables in an ability template let you express and control a multi-target damage rule. + +
+ +@code { + + private int _selectedCasterIndex = 0; + private int _selectedAbilityId = 1; + private string _castError; + + private CasterSnapshot? CurrentCaster => AbilityDemo.Party.ElementAtOrDefault(_selectedCasterIndex); + private AbilityOption? CurrentAbility => UsableAbilities.FirstOrDefault(a => a.Id == _selectedAbilityId); + + private IReadOnlyList UsableAbilities => AbilityDemo.GetAbilitiesForCaster(_selectedCasterIndex); + + private bool CanCastSelected => AbilityDemo.CanCasterUseAbility(_selectedCasterIndex, _selectedAbilityId); + + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) + { + SnapSelectedAbilityToFirstUsable(); + } + } + + private void OnCasterChanged() + { + SnapSelectedAbilityToFirstUsable(); + StateHasChanged(); + } + + private void SnapSelectedAbilityToFirstUsable() + { + if (UsableAbilities.Count == 0) { _selectedAbilityId = 0; return; } + if (UsableAbilities.All(a => a.Id != _selectedAbilityId)) + { + _selectedAbilityId = UsableAbilities[0].Id; + } + } + + private void CastSelected() + { + _castError = null; + var ability = CurrentAbility; + var caster = CurrentCaster; + if (ability == null || caster == null) { return; } + if (!caster.IsAlive) { _castError = $"{caster.Name} is dead."; return; } + if (ability.ManaCost > caster.CurrentMana) { _castError = $"{caster.Name} does not have enough mana (need {ability.ManaCost}, have {caster.CurrentMana})."; return; } + + AbilityDemo.CastAbility(_selectedCasterIndex, _selectedAbilityId); + } + + private void ResetSandbox() + { + AbilityDemo.Reset(); + _castError = null; + SnapSelectedAbilityToFirstUsable(); + } + + private string ResolveAbilityName(AbilityOption ability) + { + try { return LocaleRepository.Get(ability.Name); } + catch { return ability.Name; } + } + + private static string TargetTypeLabel(int targetType) => targetType switch + { + 1 => "Single", + 2 => "Multi", + 3 => "Pos", + 4 => "Multi-Pos", + 5 => "AoE", + 6 => "Multi-AoE", + _ => "?", + }; + + private static string HpBarClass(int current, int max) + { + if (max <= 0) { return "is-dark"; } + if (current <= 0) { return "is-dark"; } + var pct = (double)current / max; + if (pct < 0.25) { return "is-danger"; } + if (pct < 0.5) { return "is-warning"; } + return "is-success"; + } + +} diff --git a/src/OpenRpg.Demos.Web/Pages/Combat/Buffs.razor b/src/OpenRpg.Demos.Web/Pages/Combat/Buffs.razor index f7fd86b4..fb2ae701 100644 --- a/src/OpenRpg.Demos.Web/Pages/Combat/Buffs.razor +++ b/src/OpenRpg.Demos.Web/Pages/Combat/Buffs.razor @@ -206,16 +206,16 @@ public void OnEffectTriggered(ActiveEffect activeEffect) { - var effectTypeText = activeEffect.IsBeneficialEffect() ? "healing" : "damage"; - var logEntry = new LogEntry(activeEffect.IsBeneficialEffect(), activeEffect.IsPassiveEffect(), - $"Triggered {activeEffect.StaticEffect.NameLocaleId} with {activeEffect.GetStackedPotency()} {effectTypeText}"); + var effectTypeText = activeEffect.IsBeneficialEffect ? "healing" : "damage"; + var logEntry = new LogEntry(activeEffect.IsBeneficialEffect, activeEffect.IsPassiveEffect, + $"Triggered {activeEffect.StaticEffect.NameLocaleId} with {activeEffect.StackedPotency} {effectTypeText}"); LogOutput.Add(logEntry); } public void OnEffectExpired(ActiveEffect activeEffect) { - var logEntry = new LogEntry(activeEffect.IsBeneficialEffect(), activeEffect.IsPassiveEffect(), + var logEntry = new LogEntry(activeEffect.IsBeneficialEffect, activeEffect.IsPassiveEffect, $"Expired {activeEffect.StaticEffect.NameLocaleId}"); LogOutput.Add(logEntry); @@ -223,7 +223,7 @@ public void OnEffectAdded(ActiveEffect activeEffect) { - var logEntry = new LogEntry(activeEffect.IsBeneficialEffect(), activeEffect.IsPassiveEffect(), + var logEntry = new LogEntry(activeEffect.IsBeneficialEffect, activeEffect.IsPassiveEffect, $"Added {activeEffect.StaticEffect.NameLocaleId}"); LogOutput.Add(logEntry); @@ -237,7 +237,7 @@ if (alreadyContainsEffect) { - var logEntry = new LogEntry(effectToApply.IsBeneficialEffect(), effectToApply.IsPassiveEffect(), + var logEntry = new LogEntry(effectToApply.IsBeneficialEffect, effectToApply.IsPassiveEffect, $"Stacked/Reset {effectToApply.NameLocaleId}"); LogOutput.Add(logEntry); diff --git a/src/OpenRpg.Demos.Web/Pages/Combat/Damages.razor b/src/OpenRpg.Demos.Web/Pages/Combat/Damages.razor index 27850c29..91a5bee8 100644 --- a/src/OpenRpg.Demos.Web/Pages/Combat/Damages.razor +++ b/src/OpenRpg.Demos.Web/Pages/Combat/Damages.razor @@ -1,49 +1,509 @@ @page "/combat/damages" + +@using OpenRpg.Combat.Attacks +@using OpenRpg.Combat.Processors.Attacks +@using OpenRpg.Entities.Stats.Variables +@using OpenRpg.Genres.Fantasy.Extensions +@using OpenRpg.Genres.Fantasy.Types + +@inject IAttackProcessor AttackProcessor + ## Damages, Attacks & Defenses - Before we wade into combat its worth quickly covering what damages/defenses are and how they work as well as how they relate to attacks, - as the combat system is heavily based upon the concept of damages and attacks. + The combat system is built on three small records. A `Damage` is `(int Type, float Value)`: a + damage type id and an amount. An `Attack` is `(bool IsCritical, IReadOnlyList<Damage> Damages)`: + a multi-type bundle that may deal several kinds of damage in one hit (e.g. a flaming sword deals + slashing + fire). "Defense" is not an object; it is a per-type float on the target's + `EntityStatsVariables` (e.g. `FireDefense`, `IceDefense`). + + The library ships a single `IAttackProcessor<T>` that takes an `Attack` and the defender's + stats and returns a `ProcessedAttack` with the damage done and damage defended per type. The + defence model is **subtractive**: each `Defense` stat is subtracted from the incoming + `Damage`, clamped at 0. There is no built-in type-vs-type effectiveness multiplier (Fire 2x + vs Earth etc.); that is a *what-if* layer you could add on top, and Section 3 below visualises + it without claiming it is library behaviour. -
- - ### `Damage` component - This is quite a simple object and purely contains the type of damage with the value of the damage. +
- The concept behind it is that an overall attack can be broken down into various types of damage which may be from different - sources. For example lets say you have a flaming sword, rather than just do a blanket 10 damage you may do 8 slashing damage - and 2 fire damage. + +
+
+

Attack

+
+ +
+
+ +
+
+
- > As with most examples we are drawing off the pre-made example damage types which can be found within the [OpenRpg.Fantasy](https://github.com/openrpg/OpenRpg.Fantasy/blob/master/src/OpenRpg.Genres.Fantasy/Types/DamageTypes.cs) repo. +
+ +
- You are free to have as few or many damage types as you want, as ultimately its just an `int` describing the type of the damage - then the amount of associated damage that goes with that. - - The damage is just the start of the combat journey though as damage itself is just seen as the raw damage you have output, - it needs to be processed further down the line against a potential target to see how much of that damage is actually applied. - -
- - ### `Attack` component - The Attack object contains all the applicable damage types and their amounts via the `Damage` object. - - The idea is that you generate an `Attack` for a character which can then be applied to 1-N sources, so this way you may - have your flaming sword dealing 8 slashing and 2 fire damage as an AoE to 3 targets who are in range, however each of - those 3 targets may have different defense so after its all been processed you may do far less damage to each of the - targets. - - > We will cover attacks more in the next section as we need to discuss `IAttackGenerator` and `IAttackProcessor` which - will give us some insight into how you can create your own damage handling mechanics. - -
- - ### `Defenses` - So now we have the notion of `Damage` and `Attack` object and we can see how we generate an attack from someone, however - we also need the notion of `Defenses` which act as the mitigation amount of a specific damage. - - Given the previous example you may have an attack with 8 slashing and 2 fire damage, but you may have 5 fire defense - and 3 slashing defense meaning once the mitigation has been applied you would only take 5 slashing damage and 0 fire damage. - + @if (_customAttackMode) + { +
+ + @for (var i = 0; i < _customAttackRows.Count; i++) + { + var rowIndex = i; +
+
+
+ +
+
+
+ +
+
+ +
+
+ } + +
+ } + +
+ +
+
+ +
+

Target Defenses

+
+ +
+
+ +
+
+
+ +
+ + @foreach (var slot in _elementalDefenseSlots) + { +
+
+ @slot.Name +
+
+ +
+
+ } +
+
+ +
+

Library Result

+
+ + + + + + + + + + + @foreach (var row in _libraryRows) + { + + + + + + + } + + + + + + + +
TypeInputDefendedDamage Done
@row.TypeName@row.Input.ToString("0.##")@row.Defended.ToString("0.##")@row.DamageDone.ToString("0.##")
Total@_libraryInputTotal.ToString("0.##")@_libraryDefendedTotal.ToString("0.##")@_libraryTotal.ToString("0.##")
+
+
+
+ + > The same idea applies to physical damage types (Slashing, Blunt, Piercing, Unarmed) and the generic `Damage` type. They are all just integer ids with the same shape, and `DefaultAttackProcessor` treats them identically. The elemental types are the focus here because they have the more interesting interaction story. + +
+
+ + +

Elemental Effectiveness (what-if layer)

+

+ The library does not model type-vs-type matchup multipliers. The grid below is a *what-if* layer + you could stack on top of the subtractive defense above. Click a cell to cycle: + 0.5x → 1x → 2x → 0.5x. All cells start at 1x. +

+ +
+ + + + + @foreach (var defenderType in _elementalTypeIds) + { + + } + + + + @foreach (var attackerType in _elementalTypeIds) + { + + + @foreach (var defenderType in _elementalTypeIds) + { + var multiplier = _multipliers[attackerType][defenderType]; + var cellClass = multiplier < 1.0f ? "has-background-danger is-light" : + multiplier > 1.0f ? "has-background-success is-light" : ""; + + } + + } + +
Attacker \ Defender@NameFor(defenderType)
@NameFor(attackerType) + @multiplier.ToString("0.##")x +
+
+ +

If multipliers were applied

+

+ Takes the attack from the playground above, multiplies each Damage by the + matrix entry for its type, then re-runs the library's defense pipeline. +

+
+ + + + + + + + + + + + @foreach (var row in _whatIfRows) + { + + + + + + + + } + + + + + + + + +
TypeOriginalMultiplierAfter MultiplierAfter Defense
@row.TypeName@row.Original.ToString("0.##")@row.MultiplierText@row.AfterMultiplier.ToString("0.##")@row.AfterDefense.ToString("0.##")
Total@_whatIfOriginalTotal.ToString("0.##")@_whatIfAfterMultiplierTotal.ToString("0.##")@_whatIfTotal.ToString("0.##")
+
+
+
@code { -} \ No newline at end of file + private record DamageTypeName(int Id, string Name); + + private class AttackRow + { + public int Type { get; set; } + public float Value { get; set; } + public AttackRow() { } + public AttackRow(int type, float value) { Type = type; Value = value; } + } + + private class DefenseSlot + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public float Value { get; set; } + public DefenseSlot() { } + public DefenseSlot(int id, string name, float value) { Id = id; Name = name; Value = value; } + } + + private record LibraryRow(int TypeId, string TypeName, float Input, float Defended, float DamageDone); + + private record WhatIfRow(int TypeId, string TypeName, float Original, float Multiplier, float AfterMultiplier, float AfterDefense) + { + public string MultiplierText => Multiplier == 1.0f ? "1x" : Multiplier + "x"; + public string MultiplierClass => Multiplier < 1.0f ? "has-text-danger" : Multiplier > 1.0f ? "has-text-success" : ""; + } + + private static readonly int[] _elementalTypeIds = + { + FantasyDamageTypes.FireDamage, + FantasyDamageTypes.IceDamage, + FantasyDamageTypes.EarthDamage, + FantasyDamageTypes.WindDamage, + FantasyDamageTypes.LightDamage, + FantasyDamageTypes.DarkDamage, + }; + + private readonly List _allDamageTypeNames = new() + { + new(FantasyDamageTypes.FireDamage, "Fire"), + new(FantasyDamageTypes.IceDamage, "Ice"), + new(FantasyDamageTypes.EarthDamage, "Earth"), + new(FantasyDamageTypes.WindDamage, "Wind"), + new(FantasyDamageTypes.LightDamage, "Light"), + new(FantasyDamageTypes.DarkDamage, "Dark"), + }; + + private string NameFor(int typeId) => _allDamageTypeNames.FirstOrDefault(e => e.Id == typeId)?.Name ?? $"#{typeId}"; + + private string _selectedAttackPreset = "flaming-sword"; + private bool _customAttackMode; + private bool _isCritical; + private List _customAttackRows = new(); + + private string _selectedTargetPreset = "unarmored"; + private List _elementalDefenseSlots = new(); + + private Dictionary> _multipliers = new(); + private static readonly float[] _multiplierCycle = { 0.5f, 1.0f, 2.0f }; + + private List _libraryRows = new(); + private float _libraryInputTotal; + private float _libraryDefendedTotal; + private float _libraryTotal; + + private List _whatIfRows = new(); + private float _whatIfOriginalTotal; + private float _whatIfAfterMultiplierTotal; + private float _whatIfTotal; + private float _multiplierDelta; + + protected override void OnInitialized() + { + InitMultipliers(); + ApplyAttackPreset(); + ApplyTargetPreset(); + } + + private void InitMultipliers() + { + _multipliers.Clear(); + foreach (var attacker in _elementalTypeIds) + { + var row = new Dictionary(); + foreach (var defender in _elementalTypeIds) { row[defender] = 1.0f; } + _multipliers[attacker] = row; + } + } + + private void CycleMultiplier(int attacker, int defender) + { + var current = _multipliers[attacker][defender]; + var index = Array.IndexOf(_multiplierCycle, current); + var next = _multiplierCycle[(index + 1) % _multiplierCycle.Length]; + _multipliers[attacker][defender] = next; + RecomputeAll(); + } + + private void ApplyAttackPreset() + { + if (_selectedAttackPreset == "fireball") + { + _customAttackMode = false; + _customAttackRows = new() { new(FantasyDamageTypes.FireDamage, 30f) }; + } + else if (_selectedAttackPreset == "meteor-strike") + { + _customAttackMode = false; + _customAttackRows = new() + { + new(FantasyDamageTypes.FireDamage, 20f), + new(FantasyDamageTypes.EarthDamage, 10f), + }; + } + else if (_selectedAttackPreset == "blizzard") + { + _customAttackMode = false; + _customAttackRows = new() + { + new(FantasyDamageTypes.IceDamage, 20f), + new(FantasyDamageTypes.WindDamage, 5f), + }; + } + else + { + _customAttackMode = true; + if (_customAttackRows.Count == 0) + { + _customAttackRows = new() { new(FantasyDamageTypes.FireDamage, 10f) }; + } + } + RecomputeAll(); + } + + private void SyncCustomAttackRows() => RecomputeAll(); + + private void AddAttackRow() + { + _customAttackRows.Add(new(FantasyDamageTypes.FireDamage, 5f)); + RecomputeAll(); + } + + private void RemoveAttackRow(int index) + { + if (index >= 0 && index < _customAttackRows.Count) + { + _customAttackRows.RemoveAt(index); + RecomputeAll(); + } + } + + private void ApplyTargetPreset() + { + if (_selectedTargetPreset == "custom") + { + if (_elementalDefenseSlots.Count == 0) { _elementalDefenseSlots = MakeDefaultDefenseSlots(0f); } + } + else if (_selectedTargetPreset == "unarmored") + { + _elementalDefenseSlots = MakeDefaultDefenseSlots(0f); + } + else if (_selectedTargetPreset == "iron-golem") + { + _elementalDefenseSlots = MakeDefaultDefenseSlots(0f); + SetDefense(FantasyDamageTypes.EarthDamage, 15f); + SetDefense(FantasyDamageTypes.FireDamage, 2f); + SetDefense(FantasyDamageTypes.IceDamage, 2f); + SetDefense(FantasyDamageTypes.WindDamage, 2f); + SetDefense(FantasyDamageTypes.LightDamage, 1f); + SetDefense(FantasyDamageTypes.DarkDamage, 1f); + } + else if (_selectedTargetPreset == "frost-wyrm") + { + _elementalDefenseSlots = MakeDefaultDefenseSlots(0f); + SetDefense(FantasyDamageTypes.IceDamage, 12f); + SetDefense(FantasyDamageTypes.WindDamage, 5f); + SetDefense(FantasyDamageTypes.FireDamage, 0f); + SetDefense(FantasyDamageTypes.EarthDamage, 3f); + SetDefense(FantasyDamageTypes.LightDamage, 4f); + SetDefense(FantasyDamageTypes.DarkDamage, 4f); + } + RecomputeAll(); + } + + private List MakeDefaultDefenseSlots(float v) => new() + { + new(FantasyDamageTypes.FireDamage, "Fire", v), + new(FantasyDamageTypes.IceDamage, "Ice", v), + new(FantasyDamageTypes.EarthDamage, "Earth", v), + new(FantasyDamageTypes.WindDamage, "Wind", v), + new(FantasyDamageTypes.LightDamage, "Light", v), + new(FantasyDamageTypes.DarkDamage, "Dark", v), + }; + + private void SetDefense(int type, float value) + { + var idx = _elementalDefenseSlots.FindIndex(s => s.Id == type); + if (idx >= 0) + { + _elementalDefenseSlots[idx].Value = value; + } + } + + private void RecomputeAll() + { + var attack = BuildAttack(); + var defenseStats = BuildDefenseStats(); + + var processed = AttackProcessor.ProcessAttack(attack, defenseStats); + var inputByType = attack.Damages.ToDictionary(d => d.Type, d => d.Value); + var defendedByType = processed.DamageDefended.ToDictionary(d => d.Type, d => d.Value); + var doneByType = processed.DamageDone.ToDictionary(d => d.Type, d => d.Value); + + _libraryRows = inputByType.Keys + .OrderBy(t => t) + .Select(t => new LibraryRow(t, NameFor(t), inputByType[t], defendedByType.GetValueOrDefault(t, 0f), doneByType.GetValueOrDefault(t, 0f))) + .ToList(); + _libraryInputTotal = _libraryRows.Sum(r => r.Input); + _libraryDefendedTotal = _libraryRows.Sum(r => r.Defended); + _libraryTotal = _libraryRows.Sum(r => r.DamageDone); + + var multipliedDamages = attack.Damages + .Select(d => new Damage(d.Type, _multipliers.TryGetValue(d.Type, out var row) && row.TryGetValue(d.Type, out var m) ? d.Value * m : d.Value)) + .ToList(); + var multipliedAttack = new Attack(_isCritical, multipliedDamages); + var multipliedProcessed = AttackProcessor.ProcessAttack(multipliedAttack, defenseStats); + var multipliedByType = multipliedAttack.Damages.ToDictionary(d => d.Type, d => d.Value); + var multipliedDefendedByType = multipliedProcessed.DamageDefended.ToDictionary(d => d.Type, d => d.Value); + var multipliedDoneByType = multipliedProcessed.DamageDone.ToDictionary(d => d.Type, d => d.Value); + + _whatIfRows = attack.Damages + .Select(d => d.Type) + .Distinct() + .OrderBy(t => t) + .Select(t => + { + var original = inputByType[t]; + var mult = _multipliers.TryGetValue(t, out var row) && row.TryGetValue(t, out var m) ? m : 1.0f; + var afterMult = multipliedByType.GetValueOrDefault(t, 0f); + var afterDef = multipliedDoneByType.GetValueOrDefault(t, 0f); + return new WhatIfRow(t, NameFor(t), original, mult, afterMult, afterDef); + }) + .ToList(); + _whatIfOriginalTotal = _whatIfRows.Sum(r => r.Original); + _whatIfAfterMultiplierTotal = _whatIfRows.Sum(r => r.AfterMultiplier); + _whatIfTotal = _whatIfRows.Sum(r => r.AfterDefense); + _multiplierDelta = _whatIfTotal - _libraryTotal; + } + + private Attack BuildAttack() => new(_isCritical, _customAttackRows.Select(r => new Damage(r.Type, r.Value)).ToList()); + + private EntityStatsVariables BuildDefenseStats() + { + var stats = new EntityStatsVariables(); + foreach (var slot in _elementalDefenseSlots) + { + if (slot.Id == FantasyDamageTypes.FireDamage) { stats.FireDefense = slot.Value; } + else if (slot.Id == FantasyDamageTypes.IceDamage) { stats.IceDefense = slot.Value; } + else if (slot.Id == FantasyDamageTypes.EarthDamage) { stats.EarthDefense = slot.Value; } + else if (slot.Id == FantasyDamageTypes.WindDamage) { stats.WindDefense = slot.Value; } + else if (slot.Id == FantasyDamageTypes.LightDamage) { stats.LightDefense = slot.Value; } + else if (slot.Id == FantasyDamageTypes.DarkDamage) { stats.DarkDefense = slot.Value; } + } + return stats; + } +} diff --git a/src/OpenRpg.Demos.Web/Pages/Combat/ModifierPipeline.razor b/src/OpenRpg.Demos.Web/Pages/Combat/ModifierPipeline.razor new file mode 100644 index 00000000..132d0815 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Combat/ModifierPipeline.razor @@ -0,0 +1,475 @@ +@page "/combat/modifier-pipeline" + +@using OpenRpg.Combat.Attacks +@using OpenRpg.Combat.Processors.Attacks.Entity +@using OpenRpg.Combat.Processors.Modifiers +@using OpenRpg.Core.Templates +@using OpenRpg.Core.Utils +@using OpenRpg.Entities.Entity.Populators.Stats +@using OpenRpg.Entities.Stats.Variables +@using OpenRpg.Genres.Characters +@using OpenRpg.Genres.Effects +@using OpenRpg.Genres.Fantasy.Combat.Modifiers +@using OpenRpg.Genres.Fantasy.Types +@using OpenRpg.Localization.Data.Extensions +@using OpenRpg.Localization.Data.Repositories + +@inject DemoCharacterBuilder CharacterBuilder +@inject IEntityAttackGenerator AttackGenerator +@inject IRandomizer Randomizer +@inject IEntityStatPopulator StatPopulator +@inject ICharacterEffectProcessor EffectProcessor +@inject ILocaleRepository LocaleRepository +@inject ITemplateAccessor TemplateAccessor + +Combat Modifier Pipeline + + + ## Combat Modifier Pipeline + + The library ships an `IAttackModifier` interface for "in flight" attack + transformations, but deliberately does **not** include a default consumer + that chains them. The interface and two built-in implementations exist + in `OpenRpg.Combat` and `OpenRpg.Genres.Fantasy.Combat.Modifiers`; it is + the caller's job to compose and run them. This page demonstrates the + full workflow: the contract, the built-ins, the chain composition, and + the step-by-step diff so you can see exactly what each modifier does + to the attack as it flows through. + + +
+ + + ### The `IAttackModifier` contract + + An `IAttackModifier` has two methods: + + ```csharp + public interface IAttackModifier + { + bool ShouldApply(Attack attack); + Attack ModifyValue(Attack attack); + } + ``` + + `ShouldApply(Attack)` is the short-circuit hook. A modifier can opt + out per-attack (e.g. "only apply if the attack is critical"). The + built-ins always return `true`, but the contract supports conditional + application. + + `ModifyValue(Attack)` returns a **new** `Attack`. `Attack` is a record + (`public record Attack(bool IsCritical, IReadOnlyList<Damage> Damages)`) + so it is immutable. You must use the `with` expression to build a + modified copy. + + +
+ + + ### The built-in modifiers + + Two implementations ship with the library: + + #### `AddAttackRandomnessModifier` (`OpenRpg.Combat`) + Multiplies every `Damage.Value` by a random factor in `[0.75, 1.0]`. + Useful for giving attacks some swing without rewriting the generator. + Constructor: `AddAttackRandomnessModifier(IRandomizer randomizer)`. + + #### `RemoveDamageTypeModifier` (`OpenRpg.Genres.Fantasy`) + Strips a single damage type out of the attack's damage list. Useful + for "nullified magic zone" scenarios, anti-magic shields, or + light-damage-as-healing-when-typed-as-damage situations. + Constructor: `RemoveDamageTypeModifier(int damageType)`. + + > **Note:** `FantasyAttackGenerator` already does its own 5% variance + > and critical-hit handling internally (see `ApplyVariance` and + > `AttemptToCritical`). Although as with everything you are free + > to mix and match functionality as you want to. + + +
+ +
+
+ +

Character (drives the base attack)

+ Randomize Character +
+ +
+
+
+
+ +

Modifier Chain

+

+ Active: @_chainEntries.Count(e => e.IsEnabled) of @_chainEntries.Count +  Order: @ChainOrderSummary +

+

+ Drag a row by its handle to reorder. Uncheck to skip. +

+ @foreach (var entry in _chainEntries) + { +
+
+ + +
+
+ } + Reset to default order +
+ + +

Attack Output (after the modifier chain)

+ Generate New Attack + @if (_finalAttack != null) + { +

IsCritical: @_finalAttack.IsCritical

+

Damages:

+
    + @foreach (var damage in _finalAttack.Damages) + { +
  • @damage.Value.ToString("F1") @GetDamageTypeName(damage.Type)
  • + } +
+

+ Total damage: @_finalAttack.Damages.Sum(d => d.Value).ToString("F1") +

+ } + else + { +

Click "Generate New Attack" to roll a fresh base attack and run the chain.

+ } +
+
+
+ +
+ + +

Step-by-step diff

+

+ Each row shows the state of the attack after one modifier in + the chain has run. Removed damages fade out, scaled damages + show before/after, and skipped modifiers are marked. +

+ @if (_steps == null || _steps.Count == 0) + { +

No base attack yet. Click "Generate New Attack" in the Attack Output section above.

+ } + else + { +
+ + + + + + + + + + + + + @{ + var stepIndex = 0; + } + @foreach (var step in _steps) + { + var prevStep = stepIndex == 0 ? null : _steps[stepIndex - 1]; + var damages = step.Snapshot.Damages.ToList(); + var prevDamages = prevStep?.Snapshot.Damages.ToList() ?? new List(); + var rowCount = damages.Count == 0 ? 1 : damages.Count; + + @if (damages.Count == 0) + { + + + + + + + + + } + else + { + var dmgIndex = 0; + @foreach (var damage in damages) + { + var prevMatch = prevDamages.FirstOrDefault(p => p.Type == damage.Type); + var beforeText = prevDamages.Count == 0 + ? "\u2014" + : (prevMatch.Type == 0 + ? "removed" + : prevMatch.Value.ToString("F1")); + var beforeColor = prevMatch.Type == 0 + ? "has-text-danger" + : "has-text-dark"; + + + @if (dmgIndex == 0) + { + + + + } + + + + + dmgIndex++; + } + } + stepIndex++; + } + +
StepModifierStatusDamage TypeBeforeAfter
@stepIndex@(step.Modifier?.GetType().Name ?? "Base")@(step.WasApplied ? "applied" : "skipped")no damages
@stepIndex@(step.Modifier?.GetType().Name ?? "Base")@(step.WasApplied ? "applied" : "skipped")@GetDamageTypeName(damage.Type)@beforeText@damage.Value.ToString("F1")
+
+ } +
+ +
+ + + ### Writing your own modifier + + The simplest conditional modifier is one that only fires on critical + hits. The custom modifier below gates on `ShouldApply` and is included + in the chain above: + + ```csharp + public class DoubleDamageIfCriticalModifier : IAttackModifier + { + public bool ShouldApply(Attack attack) => attack.IsCritical; + + public Attack ModifyValue(Attack attack) + { + var doubled = attack.Damages + .Select(d => d with { Value = d.Value * 2f }) + .ToList(); + return attack with { Damages = doubled }; + } + } + ``` + + Drop it into the chain and the step-by-step diff will show it skip + every non-critical attack and double the damages on critical ones. + + +
+ + + ### The chain helper + + `OpenRpg.Combat.Processors.Modifiers.AttackModifierPipeline` is a small + reusable class that runs a list of modifiers in order, respecting each + modifier's `ShouldApply` short-circuit: + + ```csharp + var modifiers = new IAttackModifier[] + { + new AddAttackRandomnessModifier(randomizer), + new RemoveDamageTypeModifier(FantasyDamageTypes.FireDamage), + new DoubleDamageIfCriticalModifier() + }; + + var pipeline = new AttackModifierPipeline(modifiers); + var finalAttack = pipeline.Apply(baseAttack); + + // Or for a per-step diff: + var steps = pipeline.ApplyModifiers(baseAttack); + foreach (var step in steps) + { + Console.WriteLine($"{step.Modifier?.GetType().Name ?? "Base"}: {step.WasApplied}"); + } + ``` + + `ApplyModifiers` returns `IReadOnlyList<AttackModifierStep>` where + each `AttackModifierStep` carries the snapshot, the modifier that + produced it (null for the base), and a `WasApplied` flag. + + +
+ + + ### Why `IAttackProcessor` doesnt use Modifiers + + The library deliberately leaves chaining to the caller. There is no + `IAttackProcessor` implementation that takes a list of modifiers, and + that is by design: the modifier chain is a place where game-specific + decisions live. + + - **Ordering matters.** `RemoveDamageTypeModifier(Fire)` runs before or + after `AddAttackRandomnessModifier` and you get different outcomes. + - **Conditional application is per-game.** A "double damage on crit" + modifier is useful in some games, useless in others. + - **Scope varies.** A modifier chain might apply to PvP only, or to + raid bosses only, or to a specific quest. Hardcoding a single + pipeline into `IAttackProcessor` would force every consumer to + decide these things the same way. + + If you want a fixed pipeline, wrap it in your own + `IAttackProcessor` decorator that internally runs the chain before + delegating to `DefaultAttackProcessor`. The `IAttackProcessor` does + the math; your decorator decides what shape of attack reaches it. + + +@code { + public Character _attacker; + public Attack _baseAttack; + public Attack _finalAttack; + public IReadOnlyList _steps = new List(); + public List _chainEntries = new(); + public AttackModifierPipeline _pipeline; + + public string ChainOrderSummary + { + get + { + var activeNames = _chainEntries + .Where(e => e.IsEnabled) + .Select(e => e.Name) + .ToList(); + return activeNames.Count == 0 + ? "(no active modifiers)" + : string.Join(", ", activeNames); + } + } + + private ChainEntry _draggedEntry; + + public class ChainEntry + { + public IAttackModifier Modifier { get; init; } + public string Name { get; init; } + public string Description { get; init; } + public bool IsEnabled { get; set; } = true; + } + + public class DoubleDamageIfCriticalModifier : IAttackModifier + { + public bool ShouldApply(Attack attack) => attack.IsCritical; + public Attack ModifyValue(Attack attack) => + attack with + { + Damages = attack.Damages + .Select(d => d with { Value = d.Value * 2f }) + .ToList() + }; + } + + protected override void OnInitialized() + { + _attacker = CharacterBuilder.CreateNew().Build(); + var effects = EffectProcessor.ComputeEffects(_attacker); + StatPopulator.Populate(_attacker.Stats, effects, null); + _baseAttack = AttackGenerator.GenerateAttack(_attacker.Stats); + + _chainEntries = new List + { + new() + { + Modifier = new AddAttackRandomnessModifier(Randomizer), + Name = "Add Attack Randomness", + Description = "Multiplies every damage by a random factor in [0.75, 1.0]." + }, + new() + { + Modifier = new RemoveDamageTypeModifier(FantasyDamageTypes.FireDamage), + Name = "Remove Fire Damage", + Description = "Strips Fire damage from the attack." + }, + new() + { + Modifier = new RemoveDamageTypeModifier(FantasyDamageTypes.DarkDamage), + Name = "Remove Dark Damage", + Description = "Strips Dark damage from the attack." + }, + new() + { + Modifier = new DoubleDamageIfCriticalModifier(), + Name = "Double Damage If Critical", + Description = "Skipped on non-crit attacks. Doubles all damages on crits." + } + }; + + RebuildPipeline(); + base.OnInitialized(); + } + + public void RandomizeCharacter() + { + _attacker = CharacterBuilder.CreateNew().Build(); + var effects = EffectProcessor.ComputeEffects(_attacker); + StatPopulator.Populate(_attacker.Stats, effects, null); + GenerateAttack(); + } + + public void GenerateAttack() + { + _baseAttack = AttackGenerator.GenerateAttack(_attacker.Stats); + RebuildPipeline(); + } + + public void ResetChain() + { + OnInitialized(); + } + + public void RebuildPipeline() + { + var activeModifiers = _chainEntries + .Where(e => e.IsEnabled) + .Select(e => e.Modifier) + .ToList(); + _pipeline = new AttackModifierPipeline(activeModifiers); + + if (_baseAttack == null) + { + _steps = new List(); + _finalAttack = null; + return; + } + + _steps = _pipeline.ApplyModifiers(_baseAttack); + _finalAttack = _pipeline.Apply(_baseAttack); + } + + public void OnDragStart(ChainEntry entry) + { + _draggedEntry = entry; + } + + public void OnDrop(ChainEntry target) + { + if (_draggedEntry == null || _draggedEntry == target) { return; } + var from = _chainEntries.IndexOf(_draggedEntry); + var to = _chainEntries.IndexOf(target); + if (from < 0 || to < 0) { return; } + _chainEntries.RemoveAt(from); + _chainEntries.Insert(to, _draggedEntry); + _draggedEntry = null; + RebuildPipeline(); + } + + public string GetDamageTypeName(int damageType) + { + var damageLocaleKey = LocaleDataGenerator.GetKeyFor(LocaleDataGenerator.DamageTypesTextKey, damageType); + return LocaleRepository.Get(damageLocaleKey); + } +} diff --git a/src/OpenRpg.Demos.Web/Pages/Curves/AdvancedEffects.razor b/src/OpenRpg.Demos.Web/Pages/Curves/AdvancedEffects.razor index 5ff8e6d7..305dc68c 100644 --- a/src/OpenRpg.Demos.Web/Pages/Curves/AdvancedEffects.razor +++ b/src/OpenRpg.Demos.Web/Pages/Curves/AdvancedEffects.razor @@ -188,8 +188,8 @@
+ Race="@TemplateAccessor.ToInstance(ExampleCharacter.Variables.Race)" + Class="@TemplateAccessor.ToInstance(ExampleCharacter.Variables.Class)"/>
@@ -211,8 +211,8 @@ public int CharacterLevel { - get => ExampleCharacter.Variables.Class().Variables.Level(); - set => ExampleCharacter.Variables.Class().Variables.Level(value); + get => ExampleCharacter.Variables.Class.Variables.Level; + set => ExampleCharacter.Variables.Class.Variables.Level = value; } protected override void OnInitialized() @@ -229,65 +229,65 @@ var pretendFighterClassTemplate = new ClassTemplate { Id = 5, - NameLocaleId = "Fighter", - Effects = new IEffect[] + NameLocaleId = "Fighter" + }; + pretendFighterClassTemplate.Variables.Effects = new IEffect[] + { + new ScaledEffect() { - new ScaledEffect() - { - EffectType = FantasyEffectTypes.StrengthBonusAmount, - ScalingType = CoreEffectScalingTypes.Level, - PotencyFunction = new ScalingFunction(PresetCurves.Linear, 2, 5, 1, 10) - }, - new StaticEffect() - { - EffectType = FantasyEffectTypes.StrengthBonusPercentage, - Potency = 0.2f - } + EffectType = FantasyEffectTypes.StrengthBonusAmount, + ScalingType = CoreEffectScalingTypes.Level, + PotencyFunction = new ScalingFunction(PresetCurves.Linear, 2, 5, 1, 10) + }, + new StaticEffect() + { + EffectType = FantasyEffectTypes.StrengthBonusPercentage, + Potency = 0.2f } }; var pretendHumanRace = new RaceTemplate { Id = 2, - NameLocaleId = "Human", - Effects = new[] + NameLocaleId = "Human" + }; + pretendHumanRace.Variables.Effects = new[] + { + new ScaledEffect() { - new ScaledEffect() - { - EffectType = FantasyEffectTypes.AllAttributeBonusAmount, - ScalingType = CoreEffectScalingTypes.Level, - PotencyFunction = new ScalingFunction(PresetCurves.Linear, 7, 10, 1, 10) - } + EffectType = FantasyEffectTypes.AllAttributeBonusAmount, + ScalingType = CoreEffectScalingTypes.Level, + PotencyFunction = new ScalingFunction(PresetCurves.Linear, 7, 10, 1, 10) } }; var pretendWeapon = new ItemTemplate { Id = 23, - NameLocaleId = "Super Fighter Sword", - Effects = new IEffect[] + NameLocaleId = "Super Fighter Sword" + }; + pretendWeapon.Variables.Effects = new IEffect[] + { + new StaticEffect() { - new StaticEffect() - { - EffectType = FantasyEffectTypes.StrengthBonusAmount, - Potency = 2 - }, - new ScaledEffect() - { - EffectType = FantasyEffectTypes.DamageBonusAmount, - ScalingType = CoreEffectScalingTypes.StatIndex, - ScalingIndex = FantasyEntityStatsVariableTypes.Strength, - PotencyFunction = new ScalingFunction(PresetCurves.Linear, 10, 30, 10, 20) - } + EffectType = FantasyEffectTypes.StrengthBonusAmount, + Potency = 2 + }, + new ScaledEffect() + { + EffectType = FantasyEffectTypes.DamageBonusAmount, + ScalingType = CoreEffectScalingTypes.StatIndex, + ScalingIndex = FantasyEntityStatsVariableTypes.Strength, + PotencyFunction = new ScalingFunction(PresetCurves.Linear, 10, 30, 10, 20) } }; var equipment = new Equipment(); - equipment.MainHandSlot(new ItemData() { TemplateId = pretendWeapon.Id }); + equipment.MainHandSlot = new ItemData() { TemplateId = pretendWeapon.Id }; - ExampleCharacter.Variables.Race(new RaceData() { TemplateId = pretendHumanRace.Id }); - ExampleCharacter.Variables.Class(new ClassData() { TemplateId = pretendFighterClassTemplate.Id }); - ExampleCharacter.Variables.Equipment(equipment); + ExampleCharacter.Variables.Race = new RaceData() { TemplateId = pretendHumanRace.Id }; + ExampleCharacter.Variables.Class = new ClassData() { TemplateId = pretendFighterClassTemplate.Id }; + ExampleCharacter.Variables.Equipment = equipment; TemplateAccessor.AddTemplate(pretendHumanRace); TemplateAccessor.AddTemplate(pretendFighterClassTemplate); diff --git a/src/OpenRpg.Demos.Web/Pages/Data/PersistenceDemo.razor b/src/OpenRpg.Demos.Web/Pages/Data/PersistenceDemo.razor new file mode 100644 index 00000000..7cfb9ef3 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Data/PersistenceDemo.razor @@ -0,0 +1,742 @@ +@page "/data/persistence" + +@using System +@using System.Collections.Generic +@using System.Linq +@using OpenRpg.Core.Common +@using OpenRpg.Core.Effects +@using OpenRpg.Data +@using OpenRpg.Data.Conventions.Queries +@using OpenRpg.Demos.Infrastructure.Services +@using OpenRpg.Entities.Effects +@using OpenRpg.Entities.Extensions +@using OpenRpg.Entities.Races.Templates +@using OpenRpg.Genres.Fantasy.Types +@using OpenRpg.Items.Extensions +@using OpenRpg.Items.Templates +@using OpenRpg.Quests +@using OpenRpg.Quests.Extensions + +@inject IPersistenceDemoService Persistence + + + ## Persistence Layer + + OpenRpg separates **how data is stored** from **how it's accessed** through three layers: + + - **`IDataSource`**: generic CRUD primitive (`Get`, `GetAll`, `Create`, `Update`, `Delete`, `Exists`). + Implementations include `InMemoryDataSource`, `LiteDBDataSource`, and `DatabaseDataSource`. + - **`IRepository`**: wraps an `IDataSource` and dispatches `IQuery<T>` objects. The repository + is the only thing application code should depend on. + - **`IQuery<T>`**: a command object describing a single operation (e.g. `GetAllEntityQuery<T>`, + `CreateEntityQuery<T>`). Lets you build a fluent, loggable pipeline of read/write operations. + + The same `IRepository` code drives every backend: swap the `IDataSource` at the composition root + to move from in-memory testing to LiteDB or a relational database. + +
+ + + ### Pluggable Backends + + The same `IRepository` API works against all three implementations. The only thing that changes + is how the `IDataSource` is constructed and registered. + + #### `InMemoryDataSource` (used in this demo) + Tests, prototypes, single-process. No external dependencies. + + ```csharp + var inMemoryRepo = new Repository( + new InMemoryDataSource(new Dictionary<Type, Dictionary<object, object>>())); + ``` + + #### `LiteDBDataSource` + Embedded NoSQL, file-based, single-file deployments. Needs a file path, so it only runs on + server / desktop, not in Blazor WebAssembly. + + ```csharp + var liteDbRepo = new Repository( + new LiteDBDataSource( + new LiteDatabase("game-data.db"), + new Dictionary<Type, string> { { typeof(ItemTemplate), "items" } })); + ``` + + #### `DatabaseDataSource` + Server-side relational DBs (Postgres, MySQL, SQLite, SQL Server). Adapter sits on top of + Dapper/EF Core/ADO.NET: you provide the SQL, the data source handles the per-type queries. + + ```csharp + var dbRepo = new Repository(new DatabaseDataSource(connectionFactory, mappings)); + ``` + + The application code is identical for all three, only the constructor argument changes: + + ```csharp + var sword = inMemoryRepo.Get<ItemTemplate>(1); + ``` + +
+ + + + ### Sandbox Notice + This page uses a **private** `InMemoryDataSource` owned by `IPersistenceDemoService`, so + mutations here don't affect other pages (Character Builder, Quest Tracker, etc.). Use the + Reset Sandbox button below to restore the original seed. + +
+
+ + Sandbox entries: @_totalEntries + Selected type: @_selectedType.Name +
+
+
+ + + + ### Type Selector + The `IRepository` API is generic over `T`, so the same CRUD code works for every `IHasDataId` + type. Pick one to drive the panels below. + +
+
+
+
+ +
+
+
+
+
+ + + + ### Get All + Calls repository.GetAll<T>() (or the underlying + GetAllEntityQuery<T>) and lists every entry in the sandbox. + +
+ + @if (_lastList is not null) + { +
+ + + + + + + + + + @foreach (var row in _lastList) + { + + + + + + } + +
IdNameKey Fields
@row.Id@row.Name@row.Summary
+
+ } +
+
+ +
+
+ + + ### Get By Id + Calls repository.Get<T>(id) and shows the matching entity. + +
+
+
+ +
+
+ +
+
+ @if (_lastSingleEntity is not null) + { +
+ Found: @_lastSingleEntity +
+ } + @if (_lastSingleEntityMissed) + { +
+ Not found: no @_selectedType.Name with id @_lastGetByIdValue +
+ } +
+
+
+ + + ### Exists + Calls repository.Exists<T>(id): true if the sandbox has the entity. + +
+
+
+ +
+
+ +
+
+ @if (_lastExistsResult.HasValue) + { + @if (_lastExistsResult.Value) + { +
+ Yes: id @_lastExistsIdValue exists +
+ } + else + { +
+ No: id @_lastExistsIdValue is not in the sandbox +
+ } + } +
+
+
+
+ + + + ### Create + Builds a new entity, allocates the next id (via Max(existing) + 1), and calls + repository.Create(entity). For `ItemTemplate` the form also writes a base + effect to demonstrate variable mutation. + +
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+ @if (_selectedType == typeof(ItemTemplate)) + { +
+ +
+ +
+
+
+ +
+ +
+
+ } +
+
+ +
+
+
+
+ @if (_lastCreated is not null) + { +
+ Created: +
@_lastCreated
+
+ } +
+
+
+
+ + + + ### Update + Loads an existing entity by id, applies the form's edited values, and calls + repository.Update(entity). + +
+
+
+
+
+ +
+
+ +
+
+ @if (_updateTarget is not null) + { +
+ +
+ +
+
+
+ +
+ +
+
+ @if (_selectedType == typeof(ItemTemplate)) + { +
+ +
+ +
+
+ } +
+
+ +
+
+ } +
+
+ @if (_lastUpdated is not null) + { +
+ Updated: +
@_lastUpdated
+
+ } +
+
+
+
+ + + + ### Delete + Calls repository.Delete<T>(id) and shows the entity that was removed. + +
+
+
+ +
+
+ +
+
+ @if (_lastDeleted is not null) + { +
+ Deleted: +
@_lastDeleted
+
+ } + @if (_lastDeleteMissed) + { +
+ No-op: no @_selectedType.Name with id @_lastDeleteIdValue +
+ } +
+
+ + + + ### Operation Log + Each panel above appends a timestamped line below. This is useful when you want to wire up + a real auditing or replay layer on top of the same `IRepository`. + +
+ @if (_opLog.Count == 0) + { +

No operations yet. Try Get All, Create, or Update above.

+ } + else + { +
@string.Join("\n", _opLog)
+ } +
+ +@code { + private readonly List _availableTypes = new() + { + typeof(RaceTemplate), + typeof(ItemTemplate), + typeof(QuestTemplate) + }; + + private string _selectedTypeName = typeof(RaceTemplate).FullName; + private Type _selectedType = typeof(RaceTemplate); + + private string _getByIdValue = ""; + private string _existsIdValue = ""; + private string _updateIdValue = ""; + private string _deleteIdValue = ""; + + private string _createName = ""; + private string _createDescription = ""; + private int _createValue = 50; + private int _createPotency = 10; + + private int? _loadedUpdateId; + private string _updateName = ""; + private string _updateDescription = ""; + private int _updateValue; + + private List? _lastList; + private string? _lastSingleEntity; + private bool _lastSingleEntityMissed; + private string? _lastGetByIdValue; + + private bool? _lastExistsResult; + private string? _lastExistsIdValue; + + private string? _lastCreated; + private string? _lastUpdated; + private string? _lastDeleted; + private bool _lastDeleteMissed; + private string? _lastDeleteIdValue; + + private object? _updateTarget; + + private readonly List _opLog = new(); + + private int _totalEntries => CountAll(); + + private record EntityRow(int Id, string Name, string Summary); + + protected override void OnInitialized() + { + DoGetAll(); + } + + private void OnTypeChanged() + { + _selectedType = _availableTypes.First(t => t.FullName == _selectedTypeName); + _lastList = null; + _lastSingleEntity = null; + _lastSingleEntityMissed = false; + _lastExistsResult = null; + _lastCreated = null; + _lastUpdated = null; + _lastDeleted = null; + _lastDeleteMissed = false; + _updateTarget = null; + _loadedUpdateId = null; + DoGetAll(); + } + + private void ResetSandbox() + { + Persistence.Reset(); + Log($"[RESET] sandbox restored from seed snapshot"); + OnTypeChanged(); + } + + private void DoGetAll() + { + var rows = InvokeTyped>(nameof(IPersistenceDemoService.GetAll)) + .Cast() + .Select(e => new EntityRow(e.Id, GetName(e), GetSummary(e))) + .OrderBy(r => r.Id) + .ToList(); + + _lastList = rows; + Log($"GetAll<{_selectedType.Name}>() -> {rows.Count} items"); + StateHasChanged(); + } + + private void DoGetById(string rawId) + { + if (!TryParseId(rawId, out var id)) + { + _lastSingleEntity = null; + _lastSingleEntityMissed = true; + _lastGetByIdValue = rawId; + return; + } + + var entity = InvokeTyped(nameof(IPersistenceDemoService.Get), id); + _lastGetByIdValue = rawId; + if (entity is null) + { + _lastSingleEntity = null; + _lastSingleEntityMissed = true; + Log($"Get<{_selectedType.Name}>({id}) -> not found"); + } + else + { + _lastSingleEntity = FormatEntity(entity); + _lastSingleEntityMissed = false; + Log($"Get<{_selectedType.Name}>({id}) -> {entity.Id}"); + } + StateHasChanged(); + } + + private void DoExists(string rawId) + { + if (!TryParseId(rawId, out var id)) + { + _lastExistsResult = null; + _lastExistsIdValue = rawId; + return; + } + + var result = InvokeTyped(nameof(IPersistenceDemoService.Exists), id); + _lastExistsResult = result; + _lastExistsIdValue = rawId; + Log($"Exists<{_selectedType.Name}>({id}) -> {result}"); + StateHasChanged(); + } + + private void DoCreate() + { + if (string.IsNullOrWhiteSpace(_createName)) + { + Log($"Create<{_selectedType.Name}>() -> skipped (name required)"); + return; + } + + var entity = BuildNewEntity(); + var created = InvokeTyped(nameof(IPersistenceDemoService.Create), entity) as IHasDataId; + if (created is not null) + { + _lastCreated = FormatEntity(created); + Log($"Create<{_selectedType.Name}>() -> id {created.Id}"); + } + DoGetAll(); + StateHasChanged(); + } + + private void LoadForUpdate(string rawId) + { + if (!TryParseId(rawId, out var id)) + { + return; + } + + var entity = InvokeTyped(nameof(IPersistenceDemoService.Get), id); + if (entity is null) + { + _updateTarget = null; + _loadedUpdateId = null; + Log($"LoadForUpdate({id}) -> not found"); + StateHasChanged(); + return; + } + + _loadedUpdateId = id; + _updateTarget = entity; + _updateName = GetName(entity); + _updateDescription = GetDescription(entity); + if (entity is ItemTemplate item) + { + _updateValue = item.Variables.Value; + } + StateHasChanged(); + } + + private void DoUpdate() + { + if (_updateTarget is null || _loadedUpdateId is null) + { + return; + } + + ApplyEdits(_updateTarget); + var updated = InvokeTyped(nameof(IPersistenceDemoService.Update), _updateTarget) as IHasDataId; + if (updated is not null) + { + _lastUpdated = FormatEntity(updated); + Log($"Update<{_selectedType.Name}>({updated.Id}) -> saved"); + } + DoGetAll(); + StateHasChanged(); + } + + private void DoDelete(string rawId) + { + if (!TryParseId(rawId, out var id)) + { + _lastDeleteMissed = true; + _lastDeleteIdValue = rawId; + return; + } + + var existing = InvokeTyped(nameof(IPersistenceDemoService.Get), id); + var result = InvokeTyped(nameof(IPersistenceDemoService.Delete), id); + _lastDeleteIdValue = rawId; + if (result && existing is not null) + { + _lastDeleted = FormatEntity(existing); + _lastDeleteMissed = false; + Log($"Delete<{_selectedType.Name}>({id}) -> removed"); + } + else + { + _lastDeleted = null; + _lastDeleteMissed = true; + Log($"Delete<{_selectedType.Name}>({id}) -> not found"); + } + DoGetAll(); + StateHasChanged(); + } + + private object BuildNewEntity() + { + if (_selectedType == typeof(RaceTemplate)) + { + var t = new RaceTemplate { NameLocaleId = _createName, DescriptionLocaleId = _createDescription }; + t.Variables.AssetCode = "race-custom"; + t.Variables.Effects = Array.Empty(); + return t; + } + if (_selectedType == typeof(ItemTemplate)) + { + var t = new ItemTemplate + { + NameLocaleId = _createName, + DescriptionLocaleId = _createDescription, + ItemType = FantasyItemTypes.GenericWeapon, + ModificationAllowances = Array.Empty() + }; + t.Variables.AssetCode = "item-custom"; + t.Variables.Value = _createValue; + t.Variables.QualityType = FantasyItemQualityTypes.CommonQuality; + t.Variables.Effects = new[] + { + new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = _createPotency } + }; + return t; + } + if (_selectedType == typeof(QuestTemplate)) + { + return new QuestTemplate + { + NameLocaleId = _createName, + DescriptionLocaleId = _createDescription, + IsRepeatable = false + }; + } + throw new InvalidOperationException($"Unsupported type {_selectedType.FullName}"); + } + + private void ApplyEdits(object entity) + { + if (entity is RaceTemplate race) + { + race.NameLocaleId = _updateName; + race.DescriptionLocaleId = _updateDescription; + return; + } + if (entity is ItemTemplate item) + { + item.NameLocaleId = _updateName; + item.DescriptionLocaleId = _updateDescription; + item.Variables.Value = _updateValue; + return; + } + if (entity is QuestTemplate quest) + { + quest.NameLocaleId = _updateName; + quest.DescriptionLocaleId = _updateDescription; + } + } + + private T InvokeTyped(string methodName, params object[] args) + { + var method = typeof(IPersistenceDemoService).GetMethod(methodName)! + .MakeGenericMethod(_selectedType); + return (T)method.Invoke(Persistence, args)!; + } + + private int CountAll() + { + return _availableTypes.Sum(t => + { + var method = typeof(IPersistenceDemoService).GetMethod(nameof(IPersistenceDemoService.GetAll))! + .MakeGenericMethod(t); + var list = (System.Collections.IEnumerable)method.Invoke(Persistence, null); + var count = 0; + foreach (var _ in list) { count++; } + return count; + }); + } + + private static bool TryParseId(string raw, out int id) + { + return int.TryParse(raw, out id); + } + + private static string GetName(IHasDataId entity) + { + return entity switch + { + RaceTemplate r => r.NameLocaleId ?? "(unnamed)", + ItemTemplate i => i.NameLocaleId ?? "(unnamed)", + QuestTemplate q => q.NameLocaleId ?? "(unnamed)", + _ => entity.GetType().Name + }; + } + + private static string GetDescription(IHasDataId entity) + { + return entity switch + { + RaceTemplate r => r.DescriptionLocaleId ?? string.Empty, + ItemTemplate i => i.DescriptionLocaleId ?? string.Empty, + QuestTemplate q => q.DescriptionLocaleId ?? string.Empty, + _ => string.Empty + }; + } + + private static string GetSummary(IHasDataId entity) + { + return entity switch + { + RaceTemplate r => $"Effects={r.Variables.Effects?.Count ?? 0}, AssetCode={r.Variables.AssetCode}", + ItemTemplate i => $"Value={i.Variables.Value}, ItemType={i.ItemType}, Effects={i.Variables.Effects?.Count ?? 0}", + QuestTemplate q => $"Objectives={q.Objectives?.Count ?? 0}, Rewards={q.Rewards?.Count ?? 0}, Repeatable={q.IsRepeatable}", + _ => string.Empty + }; + } + + private static string FormatEntity(IHasDataId entity) + { + var name = GetName(entity); + var desc = GetDescription(entity); + var summary = GetSummary(entity); + return $"Id={entity.Id}, Name={name}\n Description: {desc}\n {summary}"; + } + + private void Log(string message) + { + var stamp = DateTime.Now.ToString("HH:mm:ss"); + _opLog.Insert(0, $"[{stamp}] {message}"); + if (_opLog.Count > 50) + { + _opLog.RemoveRange(50, _opLog.Count - 50); + } + } +} diff --git a/src/OpenRpg.Demos.Web/Pages/Enemies/AdvancedEnemy.razor b/src/OpenRpg.Demos.Web/Pages/Enemies/AdvancedEnemy.razor new file mode 100644 index 00000000..34192457 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Enemies/AdvancedEnemy.razor @@ -0,0 +1,242 @@ +@page "/enemies/advanced-enemy" + +@using OpenRpg.Core.Effects +@using OpenRpg.Core.Extensions +@using OpenRpg.Core.Templates +@using OpenRpg.Core.Utils +@using OpenRpg.CurveFunctions +@using OpenRpg.CurveFunctions.Scaling +@using OpenRpg.Demos.Infrastructure.Templates +@using OpenRpg.Entities.Classes +@using OpenRpg.Entities.Classes.Templates +@using OpenRpg.Entities.Effects +@using OpenRpg.Entities.Effects.Processors +@using OpenRpg.Entities.Entity +@using OpenRpg.Entities.Entity.Populators.Stats +@using OpenRpg.Entities.Entity.Templates +@using OpenRpg.Entities.Extensions +@using OpenRpg.Entities.Types +@using OpenRpg.Genres.Characters +@using OpenRpg.Genres.Effects +@using OpenRpg.Genres.Extensions +@using OpenRpg.Genres.Requirements +@using OpenRpg.Genres.Types + +@inject ICharacterRequirementChecker RequirementChecker; +@inject IEntityStatPopulator StatPopulator + + + ## Advanced Enemies + Now we have made basic static enemies, what about if we wanted to create some form of scaling enemy? + + We have already made procedural items and seen how we can make effects that scale with other values, so if we mix this all together we can make enemies that can be scaled. + +
+ + + ## Creating Scaling Enemies + First of all lets build some scaling effects that can be used as a basis. + + ```csharp + // Lets make a simple level scaling function + public class LevelScaledFunction : ScalingFunction + { + // We can provide output min/max but always scale the input levels from 1-100 + public LevelScaledFunction(RangeF minMaxStat) : base(PresetCurves.Linear, minMaxStat, new RangeF(1, 100)){} + } + + var healthRange = new RangeF(100, 10000); // Level 1 should have 100, Level 100 should have 10000 HP + ScaledEffects.Add(new ScaledEffect() + { + EffectType = GenreEffectTypes.HealthBonusAmount, + ScalingType = CoreEffectScalingTypes.Level, + PotencyFunction = new LevelScaledFunction(healthRange) + }); + + var damageRange = new RangeF(10, 1000); // 10-1000 damage based on level + ScaledEffects.Add(new ScaledEffect() + { + EffectType = GenreEffectTypes.DamageBonusAmount, + ScalingType = CoreEffectScalingTypes.Level, + PotencyFunction = new LevelScaledFunction(damageRange) + }); + + var experienceRange = new RangeF(10, 1000); // 10-1000 experience based on level + ScaledEffects.Add(new ScaledEffect() + { + EffectType = GenreEffectTypes.ExperienceRestoreAmount, + ScalingType = CoreEffectScalingTypes.Level, + PotencyFunction = new LevelScaledFunction(experienceRange) + }); + ``` + + This allows us to scale the HP/Damage and XP rewards based on the level of the enemy, which is pretty simple to express given out the box functionality. + + However before we continue we need to make a better way of processing the effects as our hacky approach in the previous one wont fly with scaled effects, but no need to worry we can just extend the default `EntityEffectProcessor`. + + ```csharp + public class EnemyEffectProcessor : EntityEffectProcessor<Enemy> + { + public EnemyEffectProcessor(ITemplateAccessor templateAccessor, ICharacterRequirementChecker requirementChecker) : base(templateAccessor, requirementChecker) + {} + + public override ComputedEffects ComputeEffects(Enemy entity) + { + // Compute any generic effect providers on the Entity (i.e class/race etc) + var computedEffects = base.ComputeEffects(entity); + + // Get our template for the enemy and compute the effects for that explicitly + var enemyTemplate = TemplateAccessor.Get<EnemyTemplate>(entity.TemplateId); + ComputeEffects(enemyTemplate, entity, computedEffects); + + return computedEffects; + } + } + ``` + + Now we are in a position to use it all to make the enemies, so lets give it a whirl. + +
+ +
+
+ + ## Creating The Enemies + + So much like the basic enemy example we will create our enemies but give them the scaled effects and use the effect processor, we also now need to give them a level so it will scale correctly. + + ```csharp + var bat1 = new Enemy() { TemplateId = EnemyTypes.Bat }; + bat1.Variables.Level = 1; + bat1.NameLocaleId = $"{BatTemplate.NameLocaleId} - Lv {bat1.Variables.Level}"; + + var computedEffects1 = EffectProcessor.ComputeEffects(bat1); + StatPopulator.Populate(bat1.Stats, computedEffects1, []); + bat1.State.Health = bat1.Stats.MaxHealth; + + var bat2 = new Enemy() { TemplateId = EnemyTypes.Bat }; + bat2.Variables.Level = 100; + bat2.NameLocaleId = $"{BatTemplate.NameLocaleId} - Lv {bat2.Variables.Level}"; + + var computedEffects2 = EffectProcessor.ComputeEffects(bat2); + StatPopulator.Populate(bat2.Stats, computedEffects2, []); + bat2.State.Health = bat2.Stats.MaxHealth; + ``` + + All done, now when we show it you can see the damage is a LOT higher when level 100, and if you mouse over the HP you can see the Health is higher too. + + We can use this to scale anything on the enemy, so you can make it as advanced as you want to. + +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+
+
+ + + ## Optimising Effect Computing + + There are a few things you can do to reduce overhead in managing effect computation, for example so far we are computing the effects on each individual enemy, but in reality as they are all based off templates, you can just compute it once per level and re-use the stats. + + This way you just pay the price for computing stats for each enemies level once up front, and just manually assign the `Stats` for the given level. + + > There is some gotchas here, such as if you need to account for buffs at runtime you may need to keep the runtime computed effects separate from the template ones and merge them before processing. + + It is also worth noting that we are using `Level` on the `Enemy` directly, but the codebase supports class levels or even multi class levels etc, so you can have enemies or npcs based on classes/races like characters too. + +
+ +@code { + + public static class EnemyTypes + { + public static int Bat = 1; + } + + public class LevelScaledFunction : ScalingFunction + { + public LevelScaledFunction(RangeF minMaxStat) : base(PresetCurves.Linear, minMaxStat, new RangeF(1, 100)) + { + } + } + + private EntityTemplate BatTemplate; + private Character Bat1, Bat2; + private ManualTemplateAccessor ManualStatTemplateAccessor { get; } = new(); + private CharacterEffectProcessor EffectProcessor; + private List ScaledEffects = new(); + + protected override void OnInitialized() + { + EffectProcessor = new CharacterEffectProcessor(ManualStatTemplateAccessor, RequirementChecker); + + var healthRange = new RangeF(100, 10000); + ScaledEffects.Add(new ScaledEffect() + { + EffectType = GenreEffectTypes.HealthBonusAmount, + ScalingType = CoreEffectScalingTypes.Level, + PotencyFunction = new LevelScaledFunction(healthRange) + }); + + var damageRange = new RangeF(10, 1000); + ScaledEffects.Add(new ScaledEffect() + { + EffectType = GenreEffectTypes.DamageBonusAmount, + ScalingType = CoreEffectScalingTypes.Level, + PotencyFunction = new LevelScaledFunction(damageRange) + }); + + var experienceRange = new RangeF(10, 1000); + ScaledEffects.Add(new ScaledEffect() + { + EffectType = GenreEffectTypes.ExperienceRestoreAmount, + ScalingType = CoreEffectScalingTypes.Level, + PotencyFunction = new LevelScaledFunction(experienceRange) + }); + + BatTemplate = new EntityTemplate + { + Id = EnemyTypes.Bat, + NameLocaleId = "Bat", + DescriptionLocaleId = "Its a big bat with leathery wings" + }; + BatTemplate.Variables.Effects = ScaledEffects; + + ManualStatTemplateAccessor.AddTemplate(BatTemplate); + + Bat1 = new Character() { TemplateId = EnemyTypes.Bat }; + Bat1.Variables.Level = 1; + Bat1.NameLocaleId = $"{BatTemplate.NameLocaleId} - Lv {Bat1.Variables.Level}"; + + var computedEffects1 = EffectProcessor.ComputeEffects(Bat1); + StatPopulator.Populate(Bat1.Stats, computedEffects1, []); + Bat1.State.Health = Bat1.Stats.MaxHealth; + + + Bat2 = new Character() { TemplateId = EnemyTypes.Bat }; + Bat2.Variables.Level = 100; + Bat2.NameLocaleId = $"{BatTemplate.NameLocaleId} - Lv {Bat2.Variables.Level}"; + + var computedEffects2 = EffectProcessor.ComputeEffects(Bat2); + StatPopulator.Populate(Bat2.Stats, computedEffects2, []); + Bat2.State.Health = Bat2.Stats.MaxHealth; + + base.OnInitialized(); + } + +} \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/Pages/Enemies/BasicEnemy.razor b/src/OpenRpg.Demos.Web/Pages/Enemies/BasicEnemy.razor new file mode 100644 index 00000000..b790578c --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Enemies/BasicEnemy.razor @@ -0,0 +1,163 @@ +@page "/enemies/basic-enemy" + +@using OpenRpg.Core.Effects +@using OpenRpg.Core.Extensions +@using OpenRpg.Entities.Effects.Processors +@using OpenRpg.Entities.Entity.Populators.Stats +@using OpenRpg.Entities.Entity.Templates +@using OpenRpg.Entities.Extensions +@using OpenRpg.Genres.Characters +@using OpenRpg.Genres.Extensions +@using OpenRpg.Genres.Types + +@inject IEntityStatPopulator StatPopulator + + + ## Enemies + So far we have discussed characters, quests and combat etc, and thats all great, but almost all RPGs need some notion of enemies/monsters/npcs right? + + Thing is out the box we don't have any formal structures explicitly for enemies etc, from a data perspective they are just entities/characters with a template for the most part. + + So with that being the case why do we even have a chapter on this? well because there is some differences in most common use cases between enemies and characters, for example: + - Enemies are often not unique are likely based off templates + - Enemies often give rewards when killed, so may have loot tables rather than inventories etc + - Enemies often end up being short lived and are often not player controlled + + There are many other differences, and these may be more/less apparent in different scenarios, but ultimately we can express enemies with the current structures we have. + +
+ + + ## Expressing Enemy Templates + Given what we discuss above lets have a look at a super simple enemy template we could make, again this is mainly up to you to decide what an enemy needs to be. + + Any template has the notion of `Effects` available on its `Variables` which we can just assign directly and as with all `Templates` it has an Id/Name/Description. + + So we could create a Bat enemy using this template like so: + + ```csharp + var batTemplate = new EntityTemplate + { + Id = EnemyTypes.Bat, + NameLocaleId = "Bat", + DescriptionLocaleId = "Its a big bat with leathery wings" + } + batTemplate.Variables.Effects = [ + new StaticEffect() { EffectType = GenreEffectTypes.HealthBonusAmount, Potency = 100 }, // Give it 100 hp + new StaticEffect() { EffectType = GenreEffectTypes.DamageBonusAmount, Potency = 10 }, // and 10 damage + new StaticEffect() { EffectType = GenreEffectTypes.ExperienceRestoreAmount, Potency = 5 }, // gives 5 xp when killed + ]; + ``` + + > We are keeping things SUPER simple here, but maybe you want your enemies to have races/classes/equipment that can be used to provide effects + > there is nothing stopping you adding that, or any other responsibilities an `Enemy` may have, like a loot table or abilities an enemy has etc. + + Ok so that was simple enough, so now we have expressed the template that drives how the enemies stats will be generated how do we actually make instances from the templates? + +
+ + + ## Creating Instances of Enemies + + So at this point we have a template describing what a Bat is, but we need to have some data container to store the runtime instance of a Bat. + + As mentioned before an Enemy is just an `Entity`/`Character` but they are basically instances of templates, so we have interfaces/classes for that already. + + So lets make a couple of bats, what would that look like? + + ```csharp + var bat1 = new Character() { TemplateId = EnemyTypes.Bat, NameLocaleId = $"{BatTemplate.NameLocaleId} 1" }; + var bat2 = new Character() { TemplateId = EnemyTypes.Bat, NameLocaleId = $"{BatTemplate.NameLocaleId} 2" }; + ``` + + Huzzah! but don't rejoice too quickly as this is just part of the battle, much like with other entities we need to Populate their stats/states. + +
+ +
+
+ + ## Populating Enemy Stats/State + This can be as complex as you want it to be, but for now lets keep things simple and do the minimum needed to populate the stats/state for `bat1` and `bat2`. + + ```csharp + // Create and populate barebones computed effects + var computedEffects = new ComputedEffects(); + batTemplate.Effects + .Cast<StaticEffect>() + .ForEach(x => computedEffects.Add(x.EffectType, x.Potency)); + + // Grab any old IEntityStatPopulator to use to populate stats + StatPopulator.Populate(bat1.Stats, computedEffects, []); + // Then manually apply the max health to the health state + Bat1.State.Health = bat1.Stats.MaxHealth; + + // Same as above but lets start the bat with 50% hp for lols + StatPopulator.Populate(Bat2.Stats, computedEffects, []); + Bat2.State.Health = Bat2.Stats.MaxHealth/2; + ``` + + Then if we look over to the side we can see the two instances of the Bat enemies, and you could battle them individually etc. + + > This shows a REALLY simple way of handling enemies, but in reality you will probably want to do more to streamline this, and we can also take some other shortcuts to make more advanced enemy handling scenarios. + +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+
+ +@code { + + public static class EnemyTypes + { + public static int Bat = 1; + } + + private EntityTemplate BatTemplate; + private Character Bat1, Bat2; + + protected override void OnInitialized() + { + BatTemplate = new EntityTemplate + { + Id = EnemyTypes.Bat, + NameLocaleId = "Bat", + DescriptionLocaleId = "Its a big bat with leathery wings" + }; + BatTemplate.Variables.Effects = [ + new StaticEffect() { EffectType = GenreEffectTypes.HealthBonusAmount, Potency = 100 }, // Give it 100 hp + new StaticEffect() { EffectType = GenreEffectTypes.DamageBonusAmount, Potency = 10 }, // and 10 damage + new StaticEffect() { EffectType = GenreEffectTypes.ExperienceRestoreAmount, Potency = 5 }, // gives 5 xp when killed + ]; + + var computedEffects = new ComputedEffects(); + BatTemplate.Variables.Effects + .Cast() + .ForEach(x => computedEffects.Add(x.EffectType, x.Potency)); + + Bat1 = new Character() { TemplateId = EnemyTypes.Bat, NameLocaleId = $"{BatTemplate.NameLocaleId} 1" }; + StatPopulator.Populate(Bat1.Stats, computedEffects, []); + Bat1.State.Health = Bat1.Stats.MaxHealth; + + Bat2 = new Character() { TemplateId = EnemyTypes.Bat, NameLocaleId = $"{BatTemplate.NameLocaleId} 2" }; + StatPopulator.Populate(Bat2.Stats, computedEffects, []); + Bat2.State.Health = Bat2.Stats.MaxHealth/2; + + base.OnInitialized(); + } +} \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/Pages/Entities/BasicEntityComponents.razor b/src/OpenRpg.Demos.Web/Pages/Entities/BasicEntityComponents.razor index c4e41aa5..1df59e87 100644 --- a/src/OpenRpg.Demos.Web/Pages/Entities/BasicEntityComponents.razor +++ b/src/OpenRpg.Demos.Web/Pages/Entities/BasicEntityComponents.razor @@ -82,9 +82,9 @@
- +
@@ -129,17 +129,17 @@
- +
@code { - private Entity SimpleEntity = new Entity(); - private Entity MultiClassEntity = new Entity(); + private EntityData _simpleEntityData = new EntityData(); + private EntityData _multiClassEntityData = new EntityData(); private RaceTemplate HobbitRace = new RaceTemplate { Id = 1, NameLocaleId = "Hobbit" }; @@ -156,26 +156,26 @@ TemplateAccessor.AddTemplate(WraithClass); TemplateAccessor.AddTemplate(RingBearerClass); - SimpleEntity.NameLocaleId = "Frodo Baggins"; - SimpleEntity.Variables.Race(new RaceData { TemplateId = HobbitRace.Id }); - SimpleEntity.Variables.Class(new ClassData { TemplateId = FighterClass.Id }); + _simpleEntityData.NameLocaleId = "Frodo Baggins"; + _simpleEntityData.Variables.Race = new RaceData { TemplateId = HobbitRace.Id }; + _simpleEntityData.Variables.Class = new ClassData { TemplateId = FighterClass.Id }; - MultiClassEntity.NameLocaleId = "Frodo Baggins"; - MultiClassEntity.Variables.Race(new RaceData { TemplateId = HobbitRace.Id }); + _multiClassEntityData.NameLocaleId = "Frodo Baggins"; + _multiClassEntityData.Variables.Race = new RaceData { TemplateId = HobbitRace.Id }; var multiClass = new MultiClasses(); var level2Fighter =new ClassData { TemplateId = FighterClass.Id }; - level2Fighter.Variables.Level(2); + level2Fighter.Variables.Level = 2; multiClass.AddClass(level2Fighter); var level1Wraith = new ClassData { TemplateId = WraithClass.Id }; multiClass.AddClass(level1Wraith); var level3RingBearer = new ClassData { TemplateId = RingBearerClass.Id }; - level3RingBearer.Variables.Level(3); + level3RingBearer.Variables.Level = 3; multiClass.AddClass(level3RingBearer); - MultiClassEntity.Variables.MultiClass(multiClass); + _multiClassEntityData.Variables.MultiClass = multiClass; base.OnInitialized(); } } \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/Pages/Entities/CharacterPersistence.razor b/src/OpenRpg.Demos.Web/Pages/Entities/CharacterPersistence.razor index 65f9278f..7452600d 100644 --- a/src/OpenRpg.Demos.Web/Pages/Entities/CharacterPersistence.razor +++ b/src/OpenRpg.Demos.Web/Pages/Entities/CharacterPersistence.razor @@ -87,7 +87,7 @@ _jsonSettings = new JsonSerializerSettings() { TypeNameHandling = TypeNameHandling.Auto, - ContractResolver = new IgnorePropertiesResolver(new[] { nameof(Entity.Stats) }) + ContractResolver = new IgnorePropertiesResolver(new[] { nameof(EntityData.Stats) }) }; RandomizeCharacter(); diff --git a/src/OpenRpg.Demos.Web/Pages/Entities/CharacterStatsComponents.razor b/src/OpenRpg.Demos.Web/Pages/Entities/CharacterStatsComponents.razor index 658c4ca7..0b50a021 100644 --- a/src/OpenRpg.Demos.Web/Pages/Entities/CharacterStatsComponents.razor +++ b/src/OpenRpg.Demos.Web/Pages/Entities/CharacterStatsComponents.razor @@ -61,8 +61,8 @@
+ Race="@ManualStatTemplateAccessor.ToInstance(ManualStatsExample.Variables.Race)" + Class="@ManualStatTemplateAccessor.ToInstance(ManualStatsExample.Variables.Class)" />

@@ -158,8 +158,8 @@
+ Race="@AutoStatTemplateAccessor.ToInstance(AutoStatsExample.Variables.Race)" + Class="@AutoStatTemplateAccessor.ToInstance(AutoStatsExample.Variables.Class)" />

@@ -168,19 +168,19 @@

Race Effects

- +

Class Effects

- +

+ Race="@AutoStatTemplateAccessor.ToInstance(AutoStatsExample2.Variables.Race)" + Class="@AutoStatTemplateAccessor.ToInstance(AutoStatsExample2.Variables.Class)" />

@@ -189,11 +189,11 @@

Race Effects

- +

Class Effects

- +
@@ -222,25 +222,25 @@ ManualStatsExample.NameLocaleId = "Frodo Baggins"; - ManualStatsExample.Variables.Race(new RaceData { TemplateId = hobbitRace.Id }); - ManualStatsExample.Variables.Class(new ClassData { TemplateId = fighterClass.Id }); + ManualStatsExample.Variables.Race = new RaceData { TemplateId = hobbitRace.Id }; + ManualStatsExample.Variables.Class = new ClassData { TemplateId = fighterClass.Id }; ManualStatsExample.Stats = new EntityStatsVariables(); - ManualStatsExample.Stats.MaxHealth(100); - ManualStatsExample.State.Health(50); - ManualStatsExample.Stats.MaxMana(10); - ManualStatsExample.State.Mana(7); - - ManualStatsExample.Stats.Strength(11); - ManualStatsExample.Stats.Dexterity(12); - ManualStatsExample.Stats.Intelligence(12); - ManualStatsExample.Stats.Wisdom(18); - ManualStatsExample.Stats.Charisma(14); - - ManualStatsExample.Stats.Damage(10.0f); - ManualStatsExample.Stats.Defense(8.0f); - ManualStatsExample.Stats.DarkDamage(1); - ManualStatsExample.Stats.DarkDefense(25); + ManualStatsExample.Stats.MaxHealth = 100; + ManualStatsExample.State.Health = 50; + ManualStatsExample.Stats.MaxMana = 10; + ManualStatsExample.State.Mana = 7; + + ManualStatsExample.Stats.Strength = 11; + ManualStatsExample.Stats.Dexterity = 12; + ManualStatsExample.Stats.Intelligence = 12; + ManualStatsExample.Stats.Wisdom = 18; + ManualStatsExample.Stats.Charisma = 14; + + ManualStatsExample.Stats.Damage = 10.0f; + ManualStatsExample.Stats.Defense = 8.0f; + ManualStatsExample.Stats.DarkDamage = 1; + ManualStatsExample.Stats.DarkDefense = 25; } protected void SetupAutoStats1() @@ -271,19 +271,21 @@ new StaticEffect {Potency = 50, EffectType = FantasyEffectTypes.HealthBonusAmount} }; - var hobbitRace = new RaceTemplate { NameLocaleId = "Hobbit", Effects = raceEffects }; - var fighterClass = new ClassTemplate { NameLocaleId = "Fighter", Effects = classEffects }; + var hobbitRace = new RaceTemplate { NameLocaleId = "Hobbit" }; + hobbitRace.Variables.Effects = raceEffects; + var fighterClass = new ClassTemplate { NameLocaleId = "Fighter" }; + fighterClass.Variables.Effects = classEffects; AutoStatTemplateAccessor.AddTemplate(hobbitRace); AutoStatTemplateAccessor.AddTemplate(fighterClass); AutoStatsExample.NameLocaleId = "Frodo Baggins"; - AutoStatsExample.Variables.Race(new RaceData { TemplateId = hobbitRace.Id }); - AutoStatsExample.Variables.Class(new ClassData{ TemplateId = fighterClass.Id }); + AutoStatsExample.Variables.Race = new RaceData { TemplateId = hobbitRace.Id }; + AutoStatsExample.Variables.Class = new ClassData{ TemplateId = fighterClass.Id }; AutoStatsExample2.NameLocaleId = "Peregrin Took"; - AutoStatsExample2.Variables.Race(new RaceData { TemplateId = hobbitRace.Id }); - AutoStatsExample2.Variables.Class(new ClassData { TemplateId = fighterClass.Id }); + AutoStatsExample2.Variables.Race = new RaceData { TemplateId = hobbitRace.Id }; + AutoStatsExample2.Variables.Class = new ClassData { TemplateId = fighterClass.Id }; var statsCalculator = new FantasyStatsPopulator(); var computedEffects1 = EffectProcessor.ComputeEffects(AutoStatsExample); @@ -296,13 +298,13 @@ public IReadOnlyCollection GetEffectsFor(RaceData race) { var template = AutoStatTemplateAccessor.GetRaceTemplate(race.TemplateId); - return template.Effects.GetStaticEffects(); + return template.Variables.Effects.GetStaticEffects(); } public IReadOnlyCollection GetEffectsFor(ClassData @class) { var template = AutoStatTemplateAccessor.GetClassTemplate(@class.TemplateId); - return template.Effects.GetStaticEffects(); + return template.Variables.Effects.GetStaticEffects(); } protected override void OnInitialized() diff --git a/src/OpenRpg.Demos.Web/Pages/Entities/DefaultEntityClasses.razor b/src/OpenRpg.Demos.Web/Pages/Entities/DefaultEntityClasses.razor index d196981c..1cb5ab6a 100644 --- a/src/OpenRpg.Demos.Web/Pages/Entities/DefaultEntityClasses.razor +++ b/src/OpenRpg.Demos.Web/Pages/Entities/DefaultEntityClasses.razor @@ -142,24 +142,24 @@ _manualCharacter = new Character(); _manualCharacter.NameLocaleId = "John Doe"; - _manualCharacter.Variables.Race(new RaceData { TemplateId = RaceTypeLookups.Human }); - _manualCharacter.Variables.Class(new ClassData { TemplateId = ClassTypeLookups.Fighter }); - - _manualCharacter.Stats.MaxHealth(100); - _manualCharacter.State.Health(50); - _manualCharacter.Stats.MaxMana(10); - _manualCharacter.State.Mana(7); - - _manualCharacter.Stats.Strength(11); - _manualCharacter.Stats.Dexterity(12); - _manualCharacter.Stats.Intelligence(12); - _manualCharacter.Stats.Wisdom(18); - _manualCharacter.Stats.Charisma(14); - - _manualCharacter.Stats.Damage(10); - _manualCharacter.Stats.Defense(8); - _manualCharacter.Stats.DarkDamage(1); - _manualCharacter.Stats.DarkDefense(25); + _manualCharacter.Variables.Race = new RaceData { TemplateId = RaceTypeLookups.Human }; + _manualCharacter.Variables.Class = new ClassData { TemplateId = ClassTypeLookups.Fighter }; + + _manualCharacter.Stats.MaxHealth = 100; + _manualCharacter.State.Health = 50; + _manualCharacter.Stats.MaxMana = 10; + _manualCharacter.State.Mana = 7; + + _manualCharacter.Stats.Strength = 11; + _manualCharacter.Stats.Dexterity = 12; + _manualCharacter.Stats.Intelligence = 12; + _manualCharacter.Stats.Wisdom = 18; + _manualCharacter.Stats.Charisma = 14; + + _manualCharacter.Stats.Damage = 10; + _manualCharacter.Stats.Defense = 8; + _manualCharacter.Stats.DarkDamage = 1; + _manualCharacter.Stats.DarkDefense = 25; RandomizeCharacter(); diff --git a/src/OpenRpg.Demos.Web/Pages/Entities/ExampleCharacterBuilder.razor b/src/OpenRpg.Demos.Web/Pages/Entities/ExampleCharacterBuilder.razor index 17ae7760..f2d8ec67 100644 --- a/src/OpenRpg.Demos.Web/Pages/Entities/ExampleCharacterBuilder.razor +++ b/src/OpenRpg.Demos.Web/Pages/Entities/ExampleCharacterBuilder.razor @@ -67,7 +67,7 @@
@race.NameLocaleId
@race.DescriptionLocaleId
- + Select @(race.NameLocaleId)
@@ -90,7 +90,7 @@
@classTemplate.NameLocaleId
@classTemplate.DescriptionLocaleId
- + Select @(classTemplate.NameLocaleId)
diff --git a/src/OpenRpg.Demos.Web/Pages/Inventory/BasicEquipmentComponents.razor b/src/OpenRpg.Demos.Web/Pages/Inventory/BasicEquipmentComponents.razor new file mode 100644 index 00000000..f576b743 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Inventory/BasicEquipmentComponents.razor @@ -0,0 +1,237 @@ +@page "/inventory/equipment-components" + +@using OpenRpg.Core.Templates +@using OpenRpg.Demos.Infrastructure.Lookups +@using OpenRpg.Demos.Web.Components.Equipment +@using OpenRpg.Genres.Characters +@using OpenRpg.Genres.Fantasy.Extensions +@using OpenRpg.Genres.Fantasy.Types +@using OpenRpg.Genres.Populators.Entity +@using OpenRpg.Items +@using OpenRpg.Items.Equippables +@using OpenRpg.Items.Equippables.Slots +@using OpenRpg.Items.Extensions +@using Inventory = OpenRpg.Items.Inventories.Inventory +@using OpenRpg.Items.Templates + +@inject IJSRuntime JSRuntime +@inject ITemplateAccessor TemplateAccessor +@inject IEquipmentSlotValidator EquipmentSlotValidator; +@inject DemoCharacterBuilder CharacterBuilder; +@inject ICharacterPopulator CharacterPopulator; + + + ## Basic Equipment Components + Most RPG games have the notion of equipment, be it simple things like a weapon you have equipped, or a whole plethora of attire with different effects. + + It also may not even be a character as such, in Sci-Fi settings you may have your ship equipped with different weapons/wings/armour etc. + + So to support all these use cases we expose a flexible equipment system which focuses on the notion of `Slots` which can support various item types which have `IEquipmentSlotValidators` setup to gate what can/cant be equipped in each slot. + + +
+ + ## Example Equipment + Like the `Inventory` example, let have a quick example of how equipment can look/function in action then we can discuss whats going on. + + At the heart of it we express the slots we want the user to be able to equip into, in this example we have head, chest, main hand, off hand and feet. + + We can then click on items in the inventory and have them be equipped into the slots we have defined, or if we click on the equipped items they will be unequipped. + + > We are using the `Fantasy` Genre slot types here, which also support shoulders, rings, back, lower body slots etc, but you don't have to use all those slots + > you can also use different slot types for custom scenarios, or more advanced setups. Ultimately the `EquipmentSlots` are just a `IVariable` like everything else + > so expose whatever extensions you want on there. + + +
+
+
+ +
+
Inventory
+
Click the item to equip it
+
+
+ +
+
+
+ +
+
Equipment
+
Click the item to unequip it
+
+
+ +
+
+
Check the character stats/effects to see how equipping items changes things.
+
+
+
+
+ + + +
+
+
+ + ## Equipment Setup + It is all driven by the `Equipment` object, which is available on the characters variables (i.e `character.Variables.Equipment`). + + This `Equipment` object provides a: + - `Slots` property which is a dictionary of `ItemData` objects, which are keyed off of the slot type. + - `Variables` property which is just like any other variables object and you can store what you want here + + The main idea is that you would decide what slots you want to support then add them i.e: + + ```csharp + // Add a head and foot slot + Equipment.Slots[FantasyEquipmentSlotTypes.HeadSlot] = null; + Equipment.Slots[FantasyEquipmentSlotTypes.FootSlot] = null; + ``` + + > As mentioned before we are using the `Fantasy` Genre for a lot of the existing types, but you can use whatever you want, there is also a premade `Equipment.PopulateFantasySlots()` extension which will auto populate all fantasy slots for you. + + + ## Equipping and Unequipping + + Once we have setup whatever equipment slots we need, we can then start equipping and unequipping items, out the box we have extension methods that let us do this but to use them you also need to implement `IEquipmentSlotValidator` which provides a high level way to verify is a slot can receive an item of a given type etc. + + > This validator is not the *only* thing that decides if something can be equipped, this just checks to see if the item type matches is applicable, but there can also be things such as `Requirements` or other custom logic that will stop an item being equipped using the out the box logic. + + As an example of showing all this code together we can do something like: + + ```csharp + var equipment = new Equipment(); // Make equipment object + equipment.PopulateFantasySlots(); // Populate slots for Fantasy scenario + + var itemToEquip = new ItemData { TemplateId = ItemTemplateLookups.Sword }; // Make item to equip + var itemTemplate = TemplateAccessor.GetTemplate(itemToEquip.TemplateId); // Get its template + + var equipmentSlotValidator = new FantasyCharacterEquipmentSlotValidator(); // Create a Fantasy flavour slot validator + + // This will return true if the item could be equipped/false if not (there is also an overload that takes IRequirementChecker and Context) + var success = equipment.AttemptEquipSlot(equipmentSlotValidator, FantasyEquipmentSlotTypes.MainHandSlot, itemToEquip, itemTemplate); + + // unequippedItem will be null if it couldnt be de-equipped or there was nothing in the slot to begin with + var unequippedItem = equipment.AttemptUnequipSlot(FantasyEquipmentSlotTypes.MainHandSlot); + ``` + + You can completely side step this and make your own suite of methods for handing equipment, but this shows a bare bones way of handling it without much effort. + + In more complex scenarios where you may be going over domain boundries i.e asking a server if they can equip something etc you would possibly need to wrap some of this logic with your own, but as with everything we try to give a reasonable out the box solution to the problem. + + > It is not shown in the above but the assumption is that you would probably have an `Inventory` in scope on the `Character` or whatever to put the unequipped items in or take them from, its up to you how you manage your items etc, the equipment just cares that you provide it the data it requires, see the source code of the interactive example above for how to handle this sort of scenario on the `Character` directly. + +@code { + private Item _rubbishSwordItem, _superSwordItem, _chestArmorItem, _bootsItem, _helmItem; + private Character _character; + private BasicInventory _inventoryRef; + private BasicEquipment _equipmentRef; + + protected override void OnInitialized() + { + _rubbishSwordItem = MakeRubbishSword(); + _superSwordItem = MakeSuperSword(); + _chestArmorItem = MakeChest(); + _bootsItem = MakeBoots(); + _helmItem = MakeHelm(); + + var inventory = new Inventory(); + inventory.Variables.MaxSlots = 16; + inventory.Items.Add(_rubbishSwordItem.Data); + inventory.Items.Add(_superSwordItem.Data); + inventory.Items.Add(_chestArmorItem.Data); + inventory.Items.Add(_bootsItem.Data); + inventory.Items.Add(_helmItem.Data); + + _character = CharacterBuilder.CreateNew().Build(); + _character.Variables.Inventory = inventory; + _character.Variables.Equipment = new Equipment(); + _character.Variables.Equipment.PopulateFantasySlots(); + + base.OnInitialized(); + } + + private Item MakeRubbishSword() + { + var item = new ItemData { TemplateId = ItemTemplateLookups.Sword }; + return TemplateAccessor.ToInstance(item); + } + + private Item MakeSuperSword() + { + var item = new ItemData { TemplateId = ItemTemplateLookups.SuperSword }; + return TemplateAccessor.ToInstance(item); + } + + private Item MakeChest() + { + var item = new ItemData { TemplateId = ItemTemplateLookups.Chest }; + return TemplateAccessor.ToInstance(item); + } + + private Item MakeHelm() + { + var item = new ItemData { TemplateId = ItemTemplateLookups.Helm }; + return TemplateAccessor.ToInstance(item); + } + + private Item MakeBoots() + { + var item = new ItemData { TemplateId = ItemTemplateLookups.Boots }; + return TemplateAccessor.ToInstance(item); + } + + public void AttemptEquip(Item itemToEquip) + { + var itemSlotType = GetItemSlotFor(itemToEquip); + if(_character.Variables.Equipment.HasItemEquipped(itemSlotType)) + { AttemptUnequip(itemSlotType); } + + var hasEquipped = _character.Variables.Equipment.AttemptEquipSlot(EquipmentSlotValidator, itemSlotType, itemToEquip.Data, itemToEquip.Template); + if (hasEquipped) + { _character.Variables.Inventory.AttemptRemoveItem(itemToEquip.Data); } + + Refresh(); + } + + public void Refresh() + { + CharacterPopulator.Populate(_character); + + _inventoryRef.Refresh(); + _equipmentRef.Refresh(); + } + + public void AttemptUnequip(Item itemToEquip) + { + var itemSlotType = GetItemSlotFor(itemToEquip); + AttemptUnequip(itemSlotType); + } + + public void AttemptUnequip(int slotType) + { + var unequippedItemData = _character.Variables.Equipment.AttemptUnequipSlot(slotType); + + var unequippedItem = TemplateAccessor.ToInstance(unequippedItemData); + if (unequippedItemData != null) + { _character.Variables.Inventory.AttemptAddItem(unequippedItem); } + + Refresh(); + } + + public int GetItemSlotFor(Item item) + { + var itemType = item.Template.ItemType; + if(itemType == FantasyItemTypes.HeadItem) { return FantasyEquipmentSlotTypes.HeadSlot; } + if(itemType == FantasyItemTypes.UpperBodyArmour) { return FantasyEquipmentSlotTypes.UpperBodySlot; } + if(itemType == FantasyItemTypes.GenericWeapon) { return FantasyEquipmentSlotTypes.MainHandSlot; } + if(itemType == FantasyItemTypes.FootArmour) { return FantasyEquipmentSlotTypes.FootSlot; } + return FantasyEquipmentSlotTypes.UnknownSlot; + } +} \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/Pages/Inventory/BasicInventoryComponents.razor b/src/OpenRpg.Demos.Web/Pages/Inventory/BasicInventoryComponents.razor index 485396a6..a8d02ceb 100644 --- a/src/OpenRpg.Demos.Web/Pages/Inventory/BasicInventoryComponents.razor +++ b/src/OpenRpg.Demos.Web/Pages/Inventory/BasicInventoryComponents.razor @@ -1,4 +1,4 @@ -@page "/inventory/basic-components" +@page "/inventory/inventory-components" @using OpenRpg.Core.Templates @using OpenRpg.Demos.Infrastructure.Lookups @@ -33,16 +33,6 @@
-
- -
-
Inventory
-
Click the item to drop it
-
-
- -
-
@@ -67,6 +57,16 @@
+
+ +
+
Inventory
+
Click the item to drop it
+
+
+ +
+

@@ -107,7 +107,7 @@ _potionItem = MakePotion(); _junkPotions = MakeJunkPotion(); _inventory = new Inventory(); - _inventory.Variables.MaxSlots(32); + _inventory.Variables.MaxSlots = 32; base.OnInitialized(); } @@ -144,14 +144,14 @@ private Item MakePotion() { var item = new ItemData { TemplateId = ItemTemplateLookups.HealingPotion }; - item.Variables.Amount(1); + item.Variables.Amount = 1; return TemplateAccessor.ToInstance(item); } private Item MakeJunkPotion() { var item = new ItemData { TemplateId = ItemTemplateLookups.JunkPotion }; - item.Variables.Amount(3); + item.Variables.Amount = 3; return TemplateAccessor.ToInstance(item); } diff --git a/src/OpenRpg.Demos.Web/Pages/Inventory/EquipmentSlotValidators.razor b/src/OpenRpg.Demos.Web/Pages/Inventory/EquipmentSlotValidators.razor new file mode 100644 index 00000000..5db9e116 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Inventory/EquipmentSlotValidators.razor @@ -0,0 +1,381 @@ +@page "/inventory/slot-validators" + +@using OpenRpg.Genres.Fantasy.Equippables.Validators +@using OpenRpg.Genres.Fantasy.Types +@using OpenRpg.Genres.Scifi.Equippables.CharacterSlots +@using OpenRpg.Genres.Scifi.Equippables.ShipSlots +@using OpenRpg.Genres.Scifi.Types +@using OpenRpg.Genres.Types +@using OpenRpg.Items.Equippables.Slots +@using OpenRpg.Items.Templates + +Equipment Slot Validators + + + ## Equipment Slot Validators + + `IEquipmentSlotValidator` is the **policy object** that answers one question: *"can an item of + type **I** go into a slot of type **S**?"* It's a single method, returning a `bool`: + + ```csharp + public interface IEquipmentSlotValidator + { + bool CanEquipItemType(int slotType, int itemType); + } + ``` + + That's the whole interface. The equipment system itself (`Equipment.Slots`, `AttemptEquipSlot`, + `AttemptUnequipSlot`) knows nothing about fantasy armour, sci-fi weapons, or ship engines; it + just consults whichever `IEquipmentSlotValidator` you've registered. The knowledge of *which + items belong in which slots* lives in the validator, not the equipment code. + +
+ + + ### The Two Use Cases + + The validator is the same one method, but it shows up in two distinct places: + + **1. Hard gate inside `EquipmentExtensions.AttemptEquipSlot`** the equip operation vetoes + itself if the (slot, item) pair is invalid: + + ```csharp + public static bool AttemptEquipSlot(this Equipment equipment, IEquipmentSlotValidator slotValidator, + int slotType, ItemData itemData, ItemTemplate template) + { + if (!equipment.HasSlot(slotType)) { return false; } + if (equipment.HasItemEquipped(slotType)) { return false; } + if (!slotValidator.CanEquipItemType(slotType, template.ItemType)) { return false; } // (the gate) + equipment.Slots[slotType] = itemData; + return true; + } + ``` + + **2. Soft filter for UI ("show me what I can equip here")** given an empty slot, filter the + inventory by asking the validator which items it would accept. This is the + `EquipmentService.GetCandidateItems` pattern from the Battler demo: + + ```csharp + foreach (var itemData in inventory) + { + var template = dataSource.GetItemTemplate(itemData.TemplateId); + if (template == null) continue; + if (slotValidator.CanEquipItemType(slotType, template.ItemType)) // (same call) + candidates.Add((itemData, template)); + } + ``` + + The interactive below demonstrates use case #2: pick a validator, pick a slot, and watch the + same inventory get filtered through three different policies. + +
+ + + + ### Interactive: Same Inventory, Three Policies + + Pick a validator on the tabs below. The slot list on the left and the candidate filter on + the right update to reflect the active policy. The inventory items never change only the + filter does. + + > Click a slot in the list to filter the inventory through the active policy. + +
+
+ @for (var i = 0; i < _validators.Count; i++) + { + var index = i; + var isActive = _activeValidatorIndex == index; + + } +
+

+ Active policy: @_validators[_activeValidatorIndex].ClassName — + @_validators[_activeValidatorIndex].DesignNote +

+
+
+
+
Slots for active policy
+
+ + + + + + + + + @foreach (var slot in _validators[_activeValidatorIndex].Slots) + { + var captured = slot; + + + + + } + +
SlotAccepts item type
+ @captured.FriendlyName +
+ id: @captured.SlotType +
+ @captured.AcceptedItemTypeName +
+
+
+
+
Sandbox inventory: Filtered by active slot
+

+ @AcceptedSummaryText() +

+
+ + + + + + + + + + @foreach (var entry in _sandboxItems) + { + var accepts = _validators[_activeValidatorIndex].Validator + .CanEquipItemType(_activeSlot, entry.ItemType); + + + + + + } + +
ItemItem typeAccepted?
+ @entry.Name + + @entry.ItemType + + — @ItemTypeName(entry.ItemType) + + + @if (accepts) + { + ✓ accepts + } + else + { + ✗ rejects + } +
+
+
+
+
+
+ + + + ### Write Your Own + + The interface is one method. Here's a skeleton for, say, a monster RPG with a single body + slot that accepts any wearable item: + + ```csharp + public class MonsterEquipmentSlotValidator : IEquipmentSlotValidator + { + public bool CanEquipItemType(int slotType, int itemType) + { + if (slotType != MonsterSlotTypes.BodySlot) return false; + return itemType switch + { + FantasyItemTypes.HeadItem => true, + FantasyItemTypes.UpperBodyArmour => true, + FantasyItemTypes.LowerBodyArmour => true, + FantasyItemTypes.FootArmour => true, + FantasyItemTypes.WristItem => true, + _ => false + }; + } + } + ``` + + Register it the same way as any other service: + + ```csharp + services.AddSingleton<IEquipmentSlotValidator, MonsterEquipmentSlotValidator>(); + ``` + + > The rest of the equipment system — `Equipment`, `EquipmentSlots`, the + > `AttemptEquipSlot` extension — doesn't need to know you made a swap. That's the + > whole point of the policy object. + + + +@code { + private int _activeValidatorIndex; + private int _activeSlot; + private List _validators = new(); + private List _sandboxItems = new(); + + protected override void OnInitialized() + { + _validators = new List + { + new ValidatorEntry( + TabLabel: "Fantasy Character", + ClassName: nameof(FantasyCharacterEquipmentSlotValidator), + DesignNote: "Each slot accepts exactly one item type", + Validator: new FantasyCharacterEquipmentSlotValidator(), + Slots: new List + { + new SlotEntry(FantasyEquipmentSlotTypes.HeadSlot, "Head", OwnItemTypeName(FantasyItemTypes.HeadItem, "Fantasy")), + new SlotEntry(FantasyEquipmentSlotTypes.BackSlot, "Back", OwnItemTypeName(FantasyItemTypes.BackArmour, "Fantasy")), + new SlotEntry(FantasyEquipmentSlotTypes.UpperBodySlot, "Upper Body", OwnItemTypeName(FantasyItemTypes.UpperBodyArmour, "Fantasy")), + new SlotEntry(FantasyEquipmentSlotTypes.LowerBodySlot, "Lower Body", OwnItemTypeName(FantasyItemTypes.LowerBodyArmour, "Fantasy")), + new SlotEntry(FantasyEquipmentSlotTypes.WristSlot, "Wrists", OwnItemTypeName(FantasyItemTypes.WristItem, "Fantasy")), + new SlotEntry(FantasyEquipmentSlotTypes.NeckSlot, "Neck", OwnItemTypeName(FantasyItemTypes.NeckItem, "Fantasy")), + new SlotEntry(FantasyEquipmentSlotTypes.MainHandSlot, "Main Hand", OwnItemTypeName(FantasyItemTypes.GenericWeapon, "Shared")), + new SlotEntry(FantasyEquipmentSlotTypes.OffHandSlot, "Off Hand", OwnItemTypeName(FantasyItemTypes.OffhandItem, "Fantasy")), + new SlotEntry(FantasyEquipmentSlotTypes.Ring1Slot, "Ring 1", OwnItemTypeName(FantasyItemTypes.RingItem, "Fantasy")), + new SlotEntry(FantasyEquipmentSlotTypes.Ring2Slot, "Ring 2", OwnItemTypeName(FantasyItemTypes.RingItem, "Fantasy")), + new SlotEntry(FantasyEquipmentSlotTypes.FootSlot, "Feet", OwnItemTypeName(FantasyItemTypes.FootArmour, "Fantasy")) + }), + new ValidatorEntry( + TabLabel: "SciFi Character", + ClassName: nameof(SciFiCharacterEquipmentSlotValidator), + DesignNote: "Two broad slots, each accepts a single broad item type", + Validator: new SciFiCharacterEquipmentSlotValidator(), + Slots: new List + { + new SlotEntry(ScifiEntityEquipmentSlotTypes.WeaponSlot, "Weapon", OwnItemTypeName(ScifiItemTypes.GenericWeapon, "Shared")), + new SlotEntry(ScifiEntityEquipmentSlotTypes.ArmourSlot, "Armour", OwnItemTypeName(ScifiItemTypes.Armour, "SciFi")) + }), + new ValidatorEntry( + TabLabel: "SciFi Ship", + ClassName: nameof(SciFiShipEquipmentSlotValidator), + DesignNote: "Six weapon slots and four misc slots, each sharing the same item type", + Validator: new SciFiShipEquipmentSlotValidator(), + Slots: new List + { + new SlotEntry(ShipEquipmentSlotTypes.EngineSlot, "Engine", OwnItemTypeName(ShipItemTypes.Engine, "Ship")), + new SlotEntry(ShipEquipmentSlotTypes.HullArmourSlot, "Hull Armour",OwnItemTypeName(ShipItemTypes.HullArmour, "Ship")), + new SlotEntry(ShipEquipmentSlotTypes.ShieldSlot, "Shield", OwnItemTypeName(ShipItemTypes.Shield, "Ship")), + new SlotEntry(ShipEquipmentSlotTypes.WingsSlot, "Wings", OwnItemTypeName(ShipItemTypes.Wings, "Ship")), + new SlotEntry(ShipEquipmentSlotTypes.WeaponSlot1, "Weapon 1", OwnItemTypeName(ShipItemTypes.GenericWeapon, "Shared")), + new SlotEntry(ShipEquipmentSlotTypes.WeaponSlot2, "Weapon 2", OwnItemTypeName(ShipItemTypes.GenericWeapon, "Shared")), + new SlotEntry(ShipEquipmentSlotTypes.WeaponSlot3, "Weapon 3", OwnItemTypeName(ShipItemTypes.GenericWeapon, "Shared")), + new SlotEntry(ShipEquipmentSlotTypes.WeaponSlot4, "Weapon 4", OwnItemTypeName(ShipItemTypes.GenericWeapon, "Shared")), + new SlotEntry(ShipEquipmentSlotTypes.WeaponSlot5, "Weapon 5", OwnItemTypeName(ShipItemTypes.GenericWeapon, "Shared")), + new SlotEntry(ShipEquipmentSlotTypes.WeaponSlot6, "Weapon 6", OwnItemTypeName(ShipItemTypes.GenericWeapon, "Shared")), + new SlotEntry(ShipEquipmentSlotTypes.MiscSlot1, "Misc 1", OwnItemTypeName(ShipItemTypes.Misc, "Ship")), + new SlotEntry(ShipEquipmentSlotTypes.MiscSlot2, "Misc 2", OwnItemTypeName(ShipItemTypes.Misc, "Ship")), + new SlotEntry(ShipEquipmentSlotTypes.MiscSlot3, "Misc 3", OwnItemTypeName(ShipItemTypes.Misc, "Ship")), + new SlotEntry(ShipEquipmentSlotTypes.MiscSlot4, "Misc 4", OwnItemTypeName(ShipItemTypes.Misc, "Ship")) + }) + }; + + _sandboxItems = new List + { + new SandboxItem("Rusted Sword", FantasyItemTypes.GenericWeapon), + new SandboxItem("Iron Helm", FantasyItemTypes.HeadItem), + new SandboxItem("Leather Chest", FantasyItemTypes.UpperBodyArmour), + new SandboxItem("Cloth Trousers", FantasyItemTypes.LowerBodyArmour), + new SandboxItem("Tattered Cape", FantasyItemTypes.BackArmour), + new SandboxItem("Worn Boots", FantasyItemTypes.FootArmour), + new SandboxItem("Leather Bracers", FantasyItemTypes.WristItem), + new SandboxItem("Copper Amulet", FantasyItemTypes.NeckItem), + new SandboxItem("Silver Ring", FantasyItemTypes.RingItem), + new SandboxItem("Wooden Shield", FantasyItemTypes.OffhandItem), + new SandboxItem("Plasma Rifle", ScifiItemTypes.GenericWeapon), + new SandboxItem("Titanium Plating", ScifiItemTypes.Armour), + new SandboxItem("Mk3 Engine", ShipItemTypes.Engine), + new SandboxItem("Hull Plate", ShipItemTypes.HullArmour), + new SandboxItem("Deflector Shield", ShipItemTypes.Shield), + new SandboxItem("Stealth Wings", ShipItemTypes.Wings), + new SandboxItem("Laser Cannon", ShipItemTypes.GenericWeapon), + new SandboxItem("Holo Emitter", ShipItemTypes.Misc) + }; + + _activeSlot = _validators[_activeValidatorIndex].Slots[0].SlotType; + } + + private void SetValidator(int index) + { + _activeValidatorIndex = index; + _activeSlot = _validators[index].Slots[0].SlotType; + } + + private void SetSlot(int slotType) => _activeSlot = slotType; + + private string AcceptedSummaryText() + { + var slot = _validators[_activeValidatorIndex].Slots + .FirstOrDefault(s => s.SlotType == _activeSlot); + var acceptsCount = _sandboxItems.Count(entry => + _validators[_activeValidatorIndex].Validator.CanEquipItemType(_activeSlot, entry.ItemType)); + var slotName = slot?.FriendlyName ?? $"slot #{_activeSlot}"; + return $"{acceptsCount} of {_sandboxItems.Count} items in your inventory can be equipped into {slotName}."; + } + + private static readonly List<(int Id, string Name, string Source)> ItemTypeNameEntries = new() + { + (GenresItemTypes.GenericWeapon, "GenericWeapon", "Shared"), + (GenresItemTypes.GenericItem, "GenericItem", "Shared"), + (GenresItemTypes.QuestItem, "QuestItem", "Shared"), + (FantasyItemTypes.HeadItem, "HeadItem", "Fantasy"), + (FantasyItemTypes.UpperBodyArmour, "UpperBodyArmour", "Fantasy"), + (FantasyItemTypes.LowerBodyArmour, "LowerBodyArmour", "Fantasy"), + (FantasyItemTypes.BackArmour, "BackArmour", "Fantasy"), + (FantasyItemTypes.FootArmour, "FootArmour", "Fantasy"), + (FantasyItemTypes.WristItem, "WristItem", "Fantasy"), + (FantasyItemTypes.NeckItem, "NeckItem", "Fantasy"), + (FantasyItemTypes.RingItem, "RingItem", "Fantasy"), + (FantasyItemTypes.OffhandItem, "OffhandItem", "Fantasy"), + (FantasyItemTypes.Potions, "Potions", "Fantasy"), + (ScifiItemTypes.Armour, "Armour", "SciFi"), + (ScifiItemTypes.Consumable, "Consumable", "SciFi"), + (ShipItemTypes.Wings, "Wings", "Ship"), + (ShipItemTypes.HullArmour, "HullArmour", "Ship"), + (ShipItemTypes.Shield, "Shield", "Ship"), + (ShipItemTypes.Engine, "Engine", "Ship"), + (ShipItemTypes.Misc, "Misc", "Ship"), + (ShipItemTypes.Consumable, "Consumable", "Ship") + }; + + private static string ItemTypeName(int itemType) + { + var matches = ItemTypeNameEntries + .Where(e => e.Id == itemType) + .Select(e => $"{e.Name} ({e.Source})") + .ToList(); + + return matches.Count == 0 ? $"#{itemType}" : string.Join(" / ", matches); + } + + private static string OwnItemTypeName(int itemType, string source) + { + var match = ItemTypeNameEntries.FirstOrDefault(e => e.Id == itemType && e.Source == source); + if (match == default) { return $"#{itemType}"; } + var isShared = ItemTypeNameEntries.Any(e => e.Id == itemType && e.Source != source); + return isShared ? $"{match.Name} (Shared)" : match.Name; + } + + private record ValidatorEntry( + string TabLabel, + string ClassName, + string DesignNote, + IEquipmentSlotValidator Validator, + List Slots); + + private record SlotEntry(int SlotType, string FriendlyName, string AcceptedItemTypeName); + + private record SandboxItem(string Name, int ItemType); +} diff --git a/src/OpenRpg.Demos.Web/Pages/Inventory/InventoryTransactions.razor b/src/OpenRpg.Demos.Web/Pages/Inventory/InventoryTransactions.razor new file mode 100644 index 00000000..b24f1e31 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Inventory/InventoryTransactions.razor @@ -0,0 +1,299 @@ +@page "/inventory/transactions" + +@using System +@using System.Collections.Generic +@using System.Linq +@using OpenRpg.Demos.Infrastructure.Lookups +@using OpenRpg.Demos.Infrastructure.Services +@using OpenRpg.Items.Extensions +@using OpenRpg.Items.Inventories +@using OpenRpg.Items.Templates + +@inject IInventoryTransactionDemoService TxDemo + + + ## Inventory Transactions + + The `IInventoryTransaction` interface provides a way to make multiple changes to an inventory in + a single action: either **all succeed or all fail and no changes are made**. This is the + canonical pattern for atomic multi-step operations like "swap a stack + give quest reward + take + crafting inputs in one rollback-able unit". + +
+ + + ### How It Works + + 1. **Queue operations** with `AddItem(ItemData)` / `RemoveItem(ItemData)`. Nothing touches the + real inventory yet. + 2. **Pre-check** runs `Removals.All(Inventory.HasItem)` to fail fast if any removal target is + missing. + 3. **Apply removals** in order. If any fails, all previous removals are rolled back. + 4. **Apply additions** in order. If any fails, all additions are rolled back **and** the + previous removals are re-applied. + 5. `ApplyChanges()` returns a single `bool` indicating success. + + ```csharp + var succeeded = inventory.CreateTransaction(templateAccessor) + .RemoveItems(new[] { oreToConsume }) + .AddItems(new[] { ingotToReceive }) + .ApplyChanges(); + ``` + +
+ + + + ### Sandbox State + The demo operates on a **private** `Inventory` owned by the demo service, so mutations here + don't affect other pages. Use **Reset Sandbox Reset** to restore the original seed. + +
+
+ +
+
+ + + + + + + + + + @foreach (var row in SandboxRows()) + { + + + + + + } + +
Template IdNameAmount
@row.TemplateId@row.Name@row.Amount
+
+
+
+ + + + ### Queue Operations + Stage a removal and an addition, then Apply to commit (or roll back) the + transaction as a single atomic unit. + +
+
+
Removal
+
+
+
+ +
+
+
+ +
+
+ +
+
+
+
+
Addition
+
+
+
+ +
+
+
+ +
+
+ +
+
+
+
+ +
+
+
Staged Removals
+ @if (TxDemo.StagedRemovals.Count == 0) + { +

(none staged)

+ } + else + { +
    + @foreach (var item in TxDemo.StagedRemovals) + { +
  • template @item.TemplateId x@(item.Variables.HasAmount() ? item.Variables.Amount.ToString() : "1")
  • + } +
+ } +
+
+
Staged Additions
+ @if (TxDemo.StagedAdditions.Count == 0) + { +

(none staged)

+ } + else + { +
    + @foreach (var item in TxDemo.StagedAdditions) + { +
  • template @item.TemplateId x@(item.Variables.HasAmount() ? item.Variables.Amount.ToString() : "1")
  • + } +
+ } +
+
+ +
+ + +
+ @if (_lastApplySucceeded.HasValue) + { + @if (_lastApplySucceeded.Value) + { +
+ Committed. All staged changes applied to the inventory. +
+ } + else + { +
+ Rolled back. Pre-check failed or a mid-transaction operation could not be applied. Inventory is unchanged. +
+ } + } +
+
+ + + + ### Pre-defined Scenarios + Buttons that pre-stage common patterns so you don't have to fill in the form. + +
+ + + +
+

+ Note: the "Failed Addition" scenario requires the inventory to have at least one item + already, since the transaction would otherwise succeed by adding a new stack. Reset the + sandbox first if you've drained it. +

+
+
+ + + + ### Audit Log + Timestamped log of every transaction step. The library's `IInventoryTransaction` only + returns a final `bool`, so this log is generated by the demo service wrapping each call. + +
+ @if (TxDemo.AuditLog.Count == 0) + { +

No operations yet. Try one of the scenarios above.

+ } + else + { +
@string.Join("\n", TxDemo.AuditLog)
+ } +
+ +@code { + private int _removalTemplateId = ItemTemplateLookups.CopperOre; + private int _removalAmount = 3; + private int _additionTemplateId = ItemTemplateLookups.CopperIngot; + private int _additionAmount = 1; + private bool? _lastApplySucceeded; + + private void ResetSandbox() + { + TxDemo.Reset(); + _lastApplySucceeded = null; + } + + private void QueueRemoval() => TxDemo.QueueRemoval(_removalTemplateId, _removalAmount); + private void QueueAddition() => TxDemo.QueueAddition(_additionTemplateId, _additionAmount); + private void ClearStaging() + { + TxDemo.ClearStaging(); + _lastApplySucceeded = null; + } + + private void ApplyStaged() + { + _lastApplySucceeded = TxDemo.ApplyStaged(); + } + + private void RunSuccessfulTrade() + { + TxDemo.ClearStaging(); + TxDemo.QueueRemoval(ItemTemplateLookups.CopperOre, 3); + TxDemo.QueueAddition(ItemTemplateLookups.CopperIngot, 1); + _lastApplySucceeded = TxDemo.ApplyStaged(); + } + + private void RunFailedRemoval() + { + TxDemo.ClearStaging(); + TxDemo.QueueRemoval(ItemTemplateLookups.SuperSword, 1); + _lastApplySucceeded = TxDemo.ApplyStaged(); + } + + private void RunFailedAddition() + { + TxDemo.ClearStaging(); + // CopperSword has MaxStacks=1, so each addition tries to create a new stack. + // Sandbox starts at 3 items with MaxSlots=4, so we can fit one more new stack + // before the third addition trips the slot check and triggers rollback. + TxDemo.QueueRemoval(ItemTemplateLookups.CopperOre, 1); + for (var i = 0; i < 4; i++) + { + TxDemo.QueueAddition(ItemTemplateLookups.CopperSword, 1); + } + _lastApplySucceeded = TxDemo.ApplyStaged(); + } + + private record SandboxRow(int TemplateId, string Name, int Amount); + + private IEnumerable SandboxRows() + { + var byTemplate = TxDemo.Sandbox.Items + .GroupBy(x => x.TemplateId) + .OrderBy(g => g.Key); + + foreach (var group in byTemplate) + { + var amount = group.Sum(x => x.Variables.HasAmount() ? x.Variables.Amount : 1); + var template = TxDemo.AvailableTemplates.FirstOrDefault(t => t.Id == group.Key); + var name = template?.NameLocaleId ?? "(unknown)"; + yield return new SandboxRow(group.Key, name, amount); + } + } +} diff --git a/src/OpenRpg.Demos.Web/Pages/Combat/Looting.razor b/src/OpenRpg.Demos.Web/Pages/Inventory/LootTableComponents.razor similarity index 72% rename from src/OpenRpg.Demos.Web/Pages/Combat/Looting.razor rename to src/OpenRpg.Demos.Web/Pages/Inventory/LootTableComponents.razor index a534e2c5..af6c7b11 100644 --- a/src/OpenRpg.Demos.Web/Pages/Combat/Looting.razor +++ b/src/OpenRpg.Demos.Web/Pages/Inventory/LootTableComponents.razor @@ -1,4 +1,4 @@ -@page "/combat/looting" +@page "/inventory/looting" @using OpenRpg.Core.Templates @using OpenRpg.Core.Utils @@ -9,22 +9,21 @@ @using OpenRpg.Items.Loot @inject ITemplateAccessor TemplateAccessor -@inject IRandomizer Randomizer +@inject ILootTableProcessor LootTableProcessor; - ## Looting - So far we have covered the basic interactions that make up combat through attacking and defending. There is another - layer we can build on top of `ICharacter` at this point which is `IEnemy` which basically implies that a character - also has a loot table that can be accessed. + ## Loot Tables + So far we have covered the basic inventories, which can be used as a basic fixed set of items to provide if a + creature/entity dies, but we have a more specific notion for that known as `ILootTable`.
- ### `ILootTable` interface - This interface exposes `AvailableLoot` which houses all the loot that can possibly be dropped as well as a method + ### `LootTableData` + This model contains `AvailableLoot` which houses all the loot that can possibly be dropped as well as a method `GetLoot` which returns random drops for that loot table. - The idea here is that you setup the loot tables ahead of time and assign them to your `IEnemy` implementations, - from there when the enemy has died you can just call `GetLoot`, which allocates you the drops. + The idea here is that you setup the loot tables ahead of time and assign them to your `EntityTemplate`, + from there when the enemy has died you can just get a `ILootTableProcessor` and call `GetLoot`, which allocates you the drops. > One thing you may notice here is that all the loot is items, however in your scenarios you may want to give xp or gold as drops. To do this its recommended you wrap the gold/xp in an item and expose it as a custom variable @@ -32,9 +31,9 @@
- ### `ILootTableEntry` interface + ### `LootTableEntry` - So the loot table itself needs you to provide it `ILootTableEntry` instances which wrap the item instance as well as + So the loot table itself needs you to provide it `LootTableEntry` instances which wrap the item instance as well as the variables associated, such as `DropRate` or `IsUnique`. > As with all `Variable` style objects you can add whatever variables you want to these entries, out the box there is @@ -43,7 +42,7 @@ Here is an example of creating a simple loot entry for an item: ```csharp - IItem potionItem = GeneratePotion(); // Gets an IItem for the potion + ItemData potionItem = GeneratePotion(); // Gets an IItem for the potion var lootEntryVariables = new LootTableEntryVariables(); lootEntryVariables.DropRate(1.0f); // 100% drop @@ -53,7 +52,6 @@ Variables = lootEntryVariables }; ``` -
@@ -72,16 +70,15 @@ var swordItem = GenerateSword(); var swordLootEntry = swordItem.GenerateLootTableEntry(0.25f); // Give it a 25% chance of dropping - var lootTable = new DefaultLootTable - { - Randomizer = Randomizer, // You can provide any IRandomizer instance - AvailableLoot = new[] { potionLootEntry, swordLootEntry } + var lootTable = new LootTableData + { + AvailableLoot = new[] { potionLootEntry, swordLootEntry } } ``` Now that we have a loot table we can query it and get loot from it. - > You would normally use the `IEnemy` interface to attach loot to enemies but you can use them outside of + > You would normally use the `EntityTemplate.Variables.LootTable` to attach loot to enemies but you can use them outside of that or however you want really
@@ -103,26 +100,25 @@
@code { - public ILootTable _lootTable; - public List _currentLoot = new List(); + public LootTableData LootTable; + public List _currentLoot = new(); protected override void OnInitialized() { - _lootTable = CreateLootTable(); + LootTable = CreateLootTable(); base.OnInitialized(); } - public ILootTable CreateLootTable() + public LootTableData CreateLootTable() { var potionItem = GeneratePotion(); var potionLootEntry = potionItem.GenerateLootTableEntry(); var swordItem = GenerateSword(); var swordLootEntry = swordItem.GenerateLootTableEntry(0.25f); - return new DefaultLootTable + return new LootTableData { - Randomizer = Randomizer, AvailableLoot = new[] {potionLootEntry, swordLootEntry} }; } @@ -147,7 +143,7 @@ public void GetRandomLoot() { - var loot = _lootTable.GetLoot(); + var loot = LootTableProcessor.GetLoot(LootTable); _currentLoot.Clear(); _currentLoot.AddRange(loot); } diff --git a/src/OpenRpg.Demos.Web/Pages/Inventory/TradingDemo.razor b/src/OpenRpg.Demos.Web/Pages/Inventory/TradingDemo.razor new file mode 100644 index 00000000..31b5ec05 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Inventory/TradingDemo.razor @@ -0,0 +1,230 @@ +@page "/inventory/trading" + +@using OpenRpg.Core.Templates +@using OpenRpg.Demos.Infrastructure.Data +@using OpenRpg.Entities.Extensions +@using OpenRpg.Genres.Characters +@using OpenRpg.Items +@using OpenRpg.Items.Extensions +@using OpenRpg.Items.Inventories +@using OpenRpg.Items.Templates +@using OpenRpg.Items.TradeSkills.Trading + +@using OpenRpg.Demos.Infrastructure.Trading + +@inject ITemplateAccessor TemplateAccessor +@inject DemoCharacterBuilder CharacterBuilder + + + ## Trading & Shop + + The trading system uses `ITrader` to define a merchant's stock, with `ItemTradeEntry` entries + that specify `BuyRate` and `SellRate` multipliers against the base item value. This lets you + create merchants with different pricing. A weaponsmith might charge 1.5× base value but only + buy items at 0.4×, while a general store offers fairer rates. + + - **Buy Price** = `ItemTemplate.Variables.Value × BuyRate` + - **Sell Price** = `ItemTemplate.Variables.Value × SellRate` + +
+ + + ## Interactive Trading Demo + + Below is a weapon merchant with various items. You start with **500 gold**. Buy items by + clicking the buy button (deducts gold, adds to inventory). Sell items by clicking them in + your inventory (removes item, credits gold). + +
+ +
+
+ +

@_trader.Name

+
Stock for sale
+
+ + @foreach (var entry in _trader.Items) + { + var template = TemplateAccessor.GetItemTemplate(entry.ItemTemplateId); + var buyPrice = GetBuyPrice(template, entry); + var canAfford = _playerGold >= buyPrice; + var entryRef = entry; + +
+
+
+
@template.NameLocaleId
+

@template.DescriptionLocaleId

+

+ Base value: @template.Variables.Valueg + | + Rate: @entry.BuyRate.ToString("F1")× +

+
+
+ @buyPrice gold +
+ +
+
+
+ } +
+
+ +
+ +

Your Inventory

+
+ +
+ Gold: @_playerGold +
+ + @if (!_playerInventory.Items.Any()) + { +

No items yet. Buy something!

+ } + else + { + @foreach (var itemData in _playerInventory.Items) + { + var template = TemplateAccessor.GetItemTemplate(itemData.TemplateId); + var sellPrice = GetSellPrice(template, itemData); + var itemDataRef = itemData; + +
+
+
+
@template.NameLocaleId
+

+ Sell value: @sellPriceg + | + Rate: @GetSellRate(itemData)× +

+
+
+ +
+
+
+ } + } +
+ + @if (_log.Any()) + { + +

Transaction Log

+
+ @foreach (var entry in _log.TakeLast(10).Reverse()) + { +

@entry.Text

+ } +
+
+ } +
+
+
+ + + ### How It Works + + ```csharp + // Create a trader with stock and rates + var trader = new Trader("Weapon Merchant", new[] + { + new ItemTradeEntry { ItemTemplateId = swordId, BuyRate = 1.5f, SellRate = 0.4f } + }); + + // Calculate prices from base value + var template = templateAccessor.GetItemTemplate(entry.ItemTemplateId); + var buyPrice = (int)(template.Variables.Value * entry.BuyRate); + var sellPrice = (int)(template.Variables.Value * entry.SellRate); + + // Buy: check gold, add to inventory, deduct gold + if (playerGold >= buyPrice) + { + inventory.AttemptAddItem(new Item { Data = new ItemData { TemplateId = entry.ItemTemplateId } }); + playerGold -= buyPrice; + } + + // Sell: remove from inventory, credit gold + inventory.AttemptRemoveItem(item.Data); + playerGold += sellPrice; + ``` + + +@code { + private Character _character; + private Trader _trader; + private Inventory _playerInventory; + private int _playerGold; + private readonly List _log = new(); + + protected override void OnInitialized() + { + _character = CharacterBuilder.CreateNew().Build(); + _trader = TraderDataGenerator.CreateWeaponMerchant(); + _playerInventory = _character.Variables.Inventory; + _playerInventory.Variables.MaxSlots = 20; + _playerGold = TraderDataGenerator.GoldStartingAmount; + base.OnInitialized(); + } + + private int GetBuyPrice(ItemTemplate template, ItemTradeEntry entry) + { + return (int)(template.Variables.Value * entry.BuyRate); + } + + private int GetSellPrice(ItemTemplate template, ItemData itemData) + { + var sellRate = GetSellRate(itemData); + return (int)(template.Variables.Value * sellRate); + } + + private float GetSellRate(ItemData itemData) + { + var entry = _trader.Items.FirstOrDefault(x => x.ItemTemplateId == itemData.TemplateId); + return entry?.SellRate ?? 0.5f; + } + + private void BuyItem(ItemTradeEntry entry) + { + var template = TemplateAccessor.GetItemTemplate(entry.ItemTemplateId); + var buyPrice = GetBuyPrice(template, entry); + + if (_playerGold < buyPrice) { return; } + + var itemData = new ItemData { TemplateId = entry.ItemTemplateId }; + var item = new Item { Data = itemData, Template = template }; + + if (!_playerInventory.AttemptAddItem(item)) { return; } + + _playerGold -= buyPrice; + _log.Add(new LogEntry($"Bought {template.NameLocaleId} for {buyPrice}g", "has-text-success")); + StateHasChanged(); + } + + private void SellItem(ItemData itemData) + { + var template = TemplateAccessor.GetItemTemplate(itemData.TemplateId); + var sellPrice = GetSellPrice(template, itemData); + + if (!_playerInventory.AttemptRemoveItem(itemData)) { return; } + + _playerGold += sellPrice; + _log.Add(new LogEntry($"Sold {template.NameLocaleId} for {sellPrice}g", "has-text-warning")); + StateHasChanged(); + } + + private record LogEntry(string Text, string CssClass); +} diff --git a/src/OpenRpg.Demos.Web/Pages/Items/BasicItemComponents.razor b/src/OpenRpg.Demos.Web/Pages/Items/BasicItemComponents.razor index 3b48dff6..7d92b7c4 100644 --- a/src/OpenRpg.Demos.Web/Pages/Items/BasicItemComponents.razor +++ b/src/OpenRpg.Demos.Web/Pages/Items/BasicItemComponents.razor @@ -141,15 +141,15 @@ Id = 1, NameLocaleId = "Sword", DescriptionLocaleId = "A really bad looking sword, can slay things though", - ItemType = FantasyItemTypes.GenericWeapon, - Effects = new [] - { - new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 30.0f } - } + ItemType = FantasyItemTypes.GenericWeapon + }; + template.Variables.QualityType = FantasyItemQualityTypes.JunkQuality; + template.Variables.Value = 10; + template.Variables.AssetCode = "sword"; + template.Variables.Effects = new[] + { + new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 30.0f } }; - template.Variables.QualityType(FantasyItemQualityTypes.JunkQuality); - template.Variables.Value(10); - template.Variables.AssetCode("sword"); return template; } @@ -170,12 +170,12 @@ Id = 2, NameLocaleId = "Super Sword", DescriptionLocaleId = "So fancy it could slice through stone", - ItemType = FantasyItemTypes.GenericWeapon, - Effects = swordEffects + ItemType = FantasyItemTypes.GenericWeapon }; - template.Variables.QualityType(FantasyItemQualityTypes.EpicQuality); - template.Variables.Value(10000); - template.Variables.AssetCode("sword"); + template.Variables.QualityType = FantasyItemQualityTypes.EpicQuality; + template.Variables.Value = 10000; + template.Variables.AssetCode = "sword"; + template.Variables.Effects = swordEffects; return template; } diff --git a/src/OpenRpg.Demos.Web/Pages/Items/ItemsWithModifications.razor b/src/OpenRpg.Demos.Web/Pages/Items/ItemsWithModifications.razor index d8c735cd..9f469aba 100644 --- a/src/OpenRpg.Demos.Web/Pages/Items/ItemsWithModifications.razor +++ b/src/OpenRpg.Demos.Web/Pages/Items/ItemsWithModifications.razor @@ -1,216 +1,334 @@ @page "/items/item-modifications" -@using OpenRpg.Core.Associations +@using System +@using System.Collections.Generic +@using System.Linq @using OpenRpg.Core.Effects -@using OpenRpg.Core.Requirements @using OpenRpg.Core.Templates -@using OpenRpg.Core.Utils -@using OpenRpg.Demos.Infrastructure.Extensions -@using OpenRpg.Demos.Infrastructure.Lookups +@using OpenRpg.Demos.Infrastructure.Data @using OpenRpg.Entities.Effects @using OpenRpg.Entities.Extensions -@using OpenRpg.Entities.Requirements -@using OpenRpg.Genres.Characters +@using OpenRpg.Entities.Modifications +@using OpenRpg.Entities.Modifications.Templates @using OpenRpg.Genres.Fantasy.Types @using OpenRpg.Items.Extensions @using OpenRpg.Items.Templates +@using OpenRpg.Localization.Data.Repositories -@inject DemoCharacterBuilder CharacterBuilder; -@inject ITemplateAccessor TemplateAccessor; +@inject ITemplateAccessor TemplateAccessor +@inject ILocaleRepository LocaleRepository - ## Modifications In General - Modifications allow more effects to be added to an item via a pla - - Characters data - - Game State data - - So for example lets say you have a sword that the player can only use once he has beaten a boss or got to a certain level. - You would need to be able to verify these requirements against the characters data as well as the game state data. As - in some cases you may have multiple characters in party based games, so while you would only have 1 game state tied to the - player, each character can have different class/race/level/stat data etc. - - So for now lets ignore game state data, as we will cover that in quests and instead look at requirements that the character - may or may not meet. + ## Item Modifications + Modifications allow items to be customised at runtime by adding gems, enchantments, or runes to modification slots. + Each modification adds its own effects which are aggregated into the item's total stats. + + The system has three parts: + 1. **`ModificationAllowances`** on the item template: defines which modification types and how many can be applied (the "sockets") + 2. **`ItemModificationData`** on the item instance: stores which modifications have been applied + 3. **`ItemModificationTemplate`**: defines the effects each modification provides
+ - ## Random Character vs Item Requirements - So in this scenario here we will let you randomly generate a character (As we have already covered how to make and manage characters), - and have 2 items to see how the item requirements are met/failed based upon the characters data. + ### Modification Allowances + An item template declares its modification slots via `ModificationAllowances`. Each slot specifies a + `ModificationType` (gem, enchantment, rune) and how many of that type are allowed. -
- Regenerate Character +
+
+
Sword With Slots
+

This sword has 2 gem slots and 1 enchantment slot:

+
+ Gem × 2 + Enchantment × 1 +
+
+
+
Basic Sword
+

This sword has no modification slots:

+
+ No slots +
+
+
-
-
-
- + + + + ### Interactive Demo: Apply Modifications + Click a modification below to apply it to an available slot on the sword. Click an applied modification to remove it. + Watch how the effects change as modifications are added and removed. + +
+
+
+
@_swordTemplate.NameLocaleId
+
+
+ Sword +
+
+ +
Base Effects
+ + + @if (_appliedModifications.Any()) + { +
Modification Effects
+ @foreach (var mod in _appliedModifications) + { + var modTemplate = GetModificationTemplate(mod.TemplateId); +
+ @(modTemplate?.NameLocaleId ?? "Unknown"): + @foreach (var effect in modTemplate?.Variables.Effects?.OfType() ?? Enumerable.Empty()) + { + @FormatEffect(effect) + } + +
+ } + } + +
Total Effects (@_totalEffects.Count())
+ +
+
+
Available Modifications
+
+ @foreach (var mod in _allModificationTemplates) + { + var isApplied = _appliedModifications.Any(a => a.TemplateId == mod.Id); + var canApply = !isApplied && CanApplyModification(mod); + + @(mod.NameLocaleId) + + } +
+ +
Available Slots
+
+ @foreach (var slot in _swordTemplate.ModificationAllowances) + { + var typeName = GetModificationTypeName(slot.ModificationType); + var appliedCount = _appliedModifications.Count(a => GetModificationTemplate(a.TemplateId)?.ModificationType == slot.ModificationType); + + @typeName: @appliedCount / @slot.AmountAllowed used + + } +
+
-
- -
- + +
+ + + + ### A Basic Sword (No Slots) + Not all items support modifications. This sword has no `ModificationAllowances` so nothing can be applied. + +
+
+
+
@_basicSwordTemplate.NameLocaleId
+
+
+ Basic Sword +
+
+
Effects
+ +
+
+
Modification Slots
+
+ None +
+

This item has no modification slots. It cannot be customised.

+
-
+

+ - ## The Requirements Checking - So we have 2 bits of code running here, one is: + ## How It Works In Code + #### Defining modification slots on an item template ```csharp - var areRequirementsMet = RequirementsChecker.AreRequirementsMet(Character, Item.Template); + var swordTemplate = new ItemTemplate + { + ModificationAllowances = new[] + { + new ModificationAllowance { ModificationType = FantasyModificationTypes.GemModification, AmountAllowed = 2 }, + new ModificationAllowance { ModificationType = FantasyModificationTypes.EnchantmentModification, AmountAllowed = 1 } + } + }; ``` - This is an extension method that checks the item templates requirements against the character and if not met you get the red - border and background, then at the requirement level there is: - + #### Applying a modification to an item instance ```csharp - var isRequirementMet = RequirementsChecker.IsRequirementMet(Character, requirement); + var itemData = new ItemData { TemplateId = swordTemplate.Id }; + var modifications = new List<ItemModificationData> + { + new ItemModificationData(fireGemTemplate.Id) + }; + itemData.Modifications = modifications; ``` - This checks an individial requirement and colours it red or green on the item requirement listing. - - As almost anything can have requirements applied, be it races, classes, items, quests, modifications, effects etc you can really - customize how the player can make use of and unlock certain items via progression/stats etc. We didn't cover one of the other parts - of requirements checking which is quest related, but we will do that later when we learn more about quests. + #### Resolving total effects + ```csharp + // Aggregates base effects + procedural effects + modification effects + var allEffects = itemData.GetEffects(templateAccessor); + ``` -
- - - ## Effect Requirements - Just to really show some of the configurability here lets also add in an effect based requirement, so the item - has a basic requirement at item level, but also has some effects which are locked until requirements are met. - - In this instance you could potentially use the item but have some of the effects locked out. - - > You may notice the characters stats are not changing as the item is not equipped on the character so its not - factoring those details into the characters stats at all. - -
- Regenerate Character -
-
-
-
- -
-
- -
-
@code { - private Character _randomCharacter; - private ItemTemplate _item1WithRequirement, _item2WithRequirements, _item3WithEffectRequirements; + private ItemTemplate _swordTemplate; + private ItemTemplate _basicSwordTemplate; + private List _appliedModifications = new(); + private IReadOnlyCollection _allModificationTemplates; + private IReadOnlyCollection _baseEffects = []; + private IReadOnlyCollection _totalEffects = []; + private IReadOnlyCollection _basicSwordEffects = []; + private int _swordQuality; + private int _basicSwordQuality; protected override void OnInitialized() { - RandomizeCharacter(); - _item1WithRequirement = MakeItem1WithRequirement(); - _item2WithRequirements = MakeItem2WithRequirement(); - _item3WithEffectRequirements = MakeItem3WithRequirement(); + _allModificationTemplates = TemplateAccessor.GetAll().ToList(); + + _swordTemplate = MakeSwordWithSlots(); + _basicSwordTemplate = MakeBasicSword(); + + _swordQuality = _swordTemplate.Variables.QualityType; + _basicSwordQuality = _basicSwordTemplate.Variables.QualityType; - base.OnInitialized(); + RecalculateEffects(); + _basicSwordEffects = _basicSwordTemplate.Variables.Effects.GetStaticEffects(); } - private ItemTemplate MakeItem1WithRequirement() + private ItemTemplate MakeSwordWithSlots() { var template = new ItemTemplate { - Id = 1, - NameLocaleId = "Staff Of Hitting", - DescriptionLocaleId = "A staff for hitting things, got gold bits on but still looks rubbish", + Id = 9001, + NameLocaleId = "Socketed Sword", + DescriptionLocaleId = "A finely crafted sword with magical sockets for gems and enchantments", ItemType = FantasyItemTypes.GenericWeapon, - Requirements = new[] + ModificationAllowances = new[] { - new Requirement {RequirementType = FantasyRequirementTypes.StrengthRequirement, Association = new Association(0, 10) } - }, - Effects = new[] - { - new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 30.0f }, - new StaticEffect { EffectType = FantasyEffectTypes.StrengthBonusAmount, Potency = 3.0f } + new ModificationAllowance { ModificationType = FantasyModificationTypes.GemModification, AmountAllowed = 2 }, + new ModificationAllowance { ModificationType = FantasyModificationTypes.EnchantmentModification, AmountAllowed = 1 } } }; - template.Variables.QualityType(FantasyItemQualityTypes.UncommonQuality); - template.Variables.Value(150); - template.Variables.AssetCode("staff"); - + template.Variables.QualityType = FantasyItemQualityTypes.RareQuality; + template.Variables.Value = 500; + template.Variables.AssetCode = "sword"; + template.Variables.Effects = new[] + { + new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 45.0f }, + new StaticEffect { EffectType = FantasyEffectTypes.StrengthBonusAmount, Potency = 3.0f } + }; return template; } - private ItemTemplate MakeItem2WithRequirement() + private ItemTemplate MakeBasicSword() { var template = new ItemTemplate { - Id = 1, - NameLocaleId = "Wizardy Wand", - DescriptionLocaleId = "A super special wand with some gem on the end that can only be used by the strongest of wizardy woos", + Id = 9002, + NameLocaleId = "Iron Sword", + DescriptionLocaleId = "A plain iron sword with no magical sockets", ItemType = FantasyItemTypes.GenericWeapon, - Requirements = new Requirement[] - { - new Requirement { RequirementType = FantasyRequirementTypes.ClassRequirement, Association = new Association(ClassTypeLookups.Mage, 3) }, - }, - Effects = new[] - { - new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 15.0f }, - new StaticEffect { EffectType = FantasyEffectTypes.DarkDamageAmount, Potency = 30.0f }, - new StaticEffect { EffectType = FantasyEffectTypes.IntelligenceBonusAmount, Potency = 5.0f }, - new StaticEffect { EffectType = FantasyEffectTypes.ManaBonusAmount, Potency = 40.0f } - } + ModificationAllowances = Array.Empty() + }; + template.Variables.QualityType = FantasyItemQualityTypes.CommonQuality; + template.Variables.Value = 50; + template.Variables.AssetCode = "sword"; + template.Variables.Effects = new[] + { + new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 20.0f } }; - template.Variables.QualityType(FantasyItemQualityTypes.RareQuality); - template.Variables.Value(300); - template.Variables.AssetCode("wand"); - return template; } - private ItemTemplate MakeItem3WithRequirement() + private void ApplyModification(ItemModificationTemplate modTemplate) { - var template = new ItemTemplate + var modData = new ItemModificationData(modTemplate.Id); + _appliedModifications.Add(modData); + RecalculateEffects(); + } + + private void RemoveModification(ItemModificationData modData) + { + _appliedModifications.Remove(modData); + RecalculateEffects(); + } + + private bool CanApplyModification(ItemModificationTemplate modTemplate) + { + var matchingSlot = _swordTemplate.ModificationAllowances + .FirstOrDefault(s => s.ModificationType == modTemplate.ModificationType); + if (matchingSlot == null) return false; + + var currentCount = _appliedModifications.Count(a => + GetModificationTemplate(a.TemplateId)?.ModificationType == modTemplate.ModificationType); + return currentCount < matchingSlot.AmountAllowed; + } + + private void RecalculateEffects() + { + _baseEffects = _swordTemplate.Variables.Effects.GetStaticEffects(); + + var modEffects = new List(); + foreach (var mod in _appliedModifications) { - Id = 1, - NameLocaleId = "Sword For The Strong", - DescriptionLocaleId = "A sword that can only unlock its full power for those with high strength", - ItemType = FantasyItemTypes.GenericWeapon, - Requirements = new[] - { - new Requirement { RequirementType = FantasyRequirementTypes.StrengthRequirement, Association = new Association(0, 8) }, - }, - Effects = new[] - { - new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 20 }, - new StaticEffect { EffectType = FantasyEffectTypes.DefenseBonusAmount, Potency = 5.0f, Requirements = new [] - { - new Requirement{RequirementType = FantasyRequirementTypes.StrengthRequirement, Association = new Association(0, 9)} - }}, - new StaticEffect { EffectType = FantasyEffectTypes.DefenseBonusAmount, Potency = 7.5f, Requirements = new [] - { - new Requirement{RequirementType = FantasyRequirementTypes.StrengthRequirement, Association = new Association(0, 10) } - }}, - new StaticEffect { EffectType = FantasyEffectTypes.DefenseBonusAmount, Potency = 10.0f, Requirements = new [] + var modTemplate = GetModificationTemplate(mod.TemplateId); + if (modTemplate?.Variables?.Effects != null) { - new Requirement{RequirementType = FantasyRequirementTypes.StrengthRequirement, Association = new Association(0, 11)} - }}, - new StaticEffect { EffectType = FantasyEffectTypes.DefenseBonusAmount, Potency = 12.5f, Requirements = new [] - { - new Requirement{RequirementType = FantasyRequirementTypes.StrengthRequirement, Association = new Association(0, 12)} - }}, + modEffects.AddRange(modTemplate.Variables.Effects); } - }; - template.Variables.QualityType(FantasyItemQualityTypes.EpicQuality); - template.Variables.Value(1000); - template.Variables.AssetCode("sword"); + } - return template; + _totalEffects = _baseEffects.Concat(modEffects).ToList(); + } + + private ItemModificationTemplate GetModificationTemplate(int templateId) + { + return _allModificationTemplates.FirstOrDefault(t => t.Id == templateId); } - public void RandomizeCharacter() + private string GetModificationTypeName(int modificationType) { - _randomCharacter = CharacterBuilder.CreateNew().Build(); - StateHasChanged(); + if (modificationType == FantasyModificationTypes.GemModification) return "Gem"; + if (modificationType == FantasyModificationTypes.EnchantmentModification) return "Enchantment"; + if (modificationType == FantasyModificationTypes.RuneModification) return "Rune"; + return "Unknown"; } -} \ No newline at end of file + private string FormatEffect(StaticEffect effect) + { + var effectType = effect.EffectType; + string effectName; + + if (effectType == FantasyEffectTypes.DamageBonusAmount) effectName = "Damage"; + else if (effectType == FantasyEffectTypes.StrengthBonusAmount) effectName = "STR"; + else if (effectType == FantasyEffectTypes.DexterityBonusAmount) effectName = "DEX"; + else if (effectType == FantasyEffectTypes.IntelligenceBonusAmount) effectName = "INT"; + else if (effectType == FantasyEffectTypes.ConstitutionBonusAmount) effectName = "CON"; + else if (effectType == FantasyEffectTypes.FireDamageAmount) effectName = "Fire Damage"; + else if (effectType == FantasyEffectTypes.IceDamageAmount) effectName = "Ice Damage"; + else if (effectType == FantasyEffectTypes.EarthDamageAmount) effectName = "Earth Damage"; + else if (effectType == FantasyEffectTypes.WindDamageAmount) effectName = "Wind Damage"; + else if (effectType == FantasyEffectTypes.LightDamageAmount) effectName = "Light Damage"; + else if (effectType == FantasyEffectTypes.DarkDamageAmount) effectName = "Dark Damage"; + else effectName = $"Effect-{effectType}"; + + return $"+{effect.Potency:F0} {effectName}"; + } +} diff --git a/src/OpenRpg.Demos.Web/Pages/Items/ItemsWithRequirements.razor b/src/OpenRpg.Demos.Web/Pages/Items/ItemsWithRequirements.razor index 4d2753ab..4722296f 100644 --- a/src/OpenRpg.Demos.Web/Pages/Items/ItemsWithRequirements.razor +++ b/src/OpenRpg.Demos.Web/Pages/Items/ItemsWithRequirements.razor @@ -126,19 +126,19 @@ NameLocaleId = "Staff Of Hitting", DescriptionLocaleId = "A staff for hitting things, got gold bits on but still looks rubbish", ItemType = FantasyItemTypes.GenericWeapon, - Requirements = new[] - { - new Requirement {RequirementType = FantasyRequirementTypes.StrengthRequirement, Association = new Association(0, 10) } - }, - Effects = new[] - { - new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 30.0f }, - new StaticEffect { EffectType = FantasyEffectTypes.StrengthBonusAmount, Potency = 3.0f } - } }; - template.Variables.QualityType(FantasyItemQualityTypes.UncommonQuality); - template.Variables.Value(150); - template.Variables.AssetCode("staff"); + template.Variables.QualityType = FantasyItemQualityTypes.UncommonQuality; + template.Variables.Value = 150; + template.Variables.AssetCode = "staff"; + template.Variables.Effects = new[] + { + new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 30.0f }, + new StaticEffect { EffectType = FantasyEffectTypes.StrengthBonusAmount, Potency = 3.0f } + }; + template.Variables.Requirements = new[] + { + new Requirement { RequirementType = FantasyRequirementTypes.StrengthRequirement, Association = new Association(0, 10) } + }; return template; } @@ -150,22 +150,22 @@ Id = 1, NameLocaleId = "Wizardy Wand", DescriptionLocaleId = "A super special wand with some gem on the end that can only be used by the strongest of wizardy woos", - ItemType = FantasyItemTypes.GenericWeapon, - Requirements = new[] - { - new Requirement { RequirementType = FantasyRequirementTypes.ClassRequirement, Association = new Association(ClassTypeLookups.Mage, 3)}, - }, - Effects = new[] - { - new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 15.0f }, - new StaticEffect { EffectType = FantasyEffectTypes.DarkDamageAmount, Potency = 30.0f }, - new StaticEffect { EffectType = FantasyEffectTypes.IntelligenceBonusAmount, Potency = 5.0f }, - new StaticEffect { EffectType = FantasyEffectTypes.ManaBonusAmount, Potency = 40.0f } - } + ItemType = FantasyItemTypes.GenericWeapon + }; + template.Variables.QualityType = FantasyItemQualityTypes.RareQuality; + template.Variables.Value = 300; + template.Variables.AssetCode = "wand"; + template.Variables.Effects = new[] + { + new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 15.0f }, + new StaticEffect { EffectType = FantasyEffectTypes.DarkDamageAmount, Potency = 30.0f }, + new StaticEffect { EffectType = FantasyEffectTypes.IntelligenceBonusAmount, Potency = 5.0f }, + new StaticEffect { EffectType = FantasyEffectTypes.ManaBonusAmount, Potency = 40.0f } + }; + template.Variables.Requirements = new[] + { + new Requirement { RequirementType = FantasyRequirementTypes.ClassRequirement, Association = new Association(ClassTypeLookups.Mage, 3) }, }; - template.Variables.QualityType(FantasyItemQualityTypes.RareQuality); - template.Variables.Value(300); - template.Variables.AssetCode("wand"); return template; } @@ -177,35 +177,48 @@ Id = 1, NameLocaleId = "Sword For The Strong", DescriptionLocaleId = "A sword that can only unlock its full power for those with high strength", - ItemType = FantasyItemTypes.GenericWeapon, - Requirements = new[] + ItemType = FantasyItemTypes.GenericWeapon + }; + template.Variables.QualityType = FantasyItemQualityTypes.EpicQuality; + template.Variables.Value = 1000; + template.Variables.AssetCode = "sword"; + template.Variables.Effects = new[] + { + new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 20 }, + new StaticEffect { - new Requirement { RequirementType = FantasyRequirementTypes.StrengthRequirement, Association = new Association(0, 8) }, + EffectType = FantasyEffectTypes.DefenseBonusAmount, Potency = 5.0f, Requirements = new[] + { + new Requirement { RequirementType = FantasyRequirementTypes.StrengthRequirement, Association = new Association(0, 9) } + } }, - Effects = new[] - { - new StaticEffect { EffectType = FantasyEffectTypes.DamageBonusAmount, Potency = 20 }, - new StaticEffect { EffectType = FantasyEffectTypes.DefenseBonusAmount, Potency = 5.0f, Requirements = new [] - { - new Requirement{RequirementType = FantasyRequirementTypes.StrengthRequirement, Association = new Association(0, 9) } - }}, - new StaticEffect { EffectType = FantasyEffectTypes.DefenseBonusAmount, Potency = 7.5f, Requirements = new [] + new StaticEffect { - new Requirement{RequirementType = FantasyRequirementTypes.StrengthRequirement, Association = new Association(0, 10) } - }}, - new StaticEffect { EffectType = FantasyEffectTypes.DefenseBonusAmount, Potency = 10.0f, Requirements = new [] + EffectType = FantasyEffectTypes.DefenseBonusAmount, Potency = 7.5f, Requirements = new[] + { + new Requirement { RequirementType = FantasyRequirementTypes.StrengthRequirement, Association = new Association(0, 10) } + } + }, + new StaticEffect { - new Requirement{RequirementType = FantasyRequirementTypes.StrengthRequirement, Association = new Association(0, 11) } - }}, - new StaticEffect { EffectType = FantasyEffectTypes.DefenseBonusAmount, Potency = 12.5f, Requirements = new [] + EffectType = FantasyEffectTypes.DefenseBonusAmount, Potency = 10.0f, Requirements = new[] + { + new Requirement { RequirementType = FantasyRequirementTypes.StrengthRequirement, Association = new Association(0, 11) } + } + }, + new StaticEffect { - new Requirement{RequirementType = FantasyRequirementTypes.StrengthRequirement, Association = new Association(0, 12) } - }}, - } + EffectType = FantasyEffectTypes.DefenseBonusAmount, Potency = 12.5f, Requirements = new[] + { + new Requirement { RequirementType = FantasyRequirementTypes.StrengthRequirement, Association = new Association(0, 12) } + } + }, + }; + + template.Variables.Requirements = new[] + { + new Requirement { RequirementType = FantasyRequirementTypes.StrengthRequirement, Association = new Association(0, 8) }, }; - template.Variables.QualityType(FantasyItemQualityTypes.EpicQuality); - template.Variables.Value(1000); - template.Variables.AssetCode("sword"); return template; } diff --git a/src/OpenRpg.Demos.Web/Pages/Localization/BasicLocaleComponents.razor b/src/OpenRpg.Demos.Web/Pages/Localization/BasicLocaleComponents.razor new file mode 100644 index 00000000..95543696 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Localization/BasicLocaleComponents.razor @@ -0,0 +1,97 @@ +@page "/localization/basic-components" + + + ## Localization Components + + The `OpenRpg.Localization` library provides a locale-aware text resolution system. Rather than hardcoding display strings + on templates and entities, you store **locale codes** (keys) and resolve them at runtime via a `ILocaleRepository`. + + This approach means one template can serve any language; just swap the locale dataset. + +
+
+
+ + ### Core Architecture + + The system is layered into three parts: + + 1. **`LocaleDataset`**: a mapping of string keys to translated text for a single locale code (e.g. `"en-gb"`). + 2. **`ILocaleDataSource`**: storage abstraction for one or more locale datasets. The demo uses `InMemoryLocaleDataSource`. + 3. **`ILocaleRepository`**: the consumer-facing API. Holds a `CurrentLocaleCode` and resolves keys via extension methods. + + #### How it works in practice + + Templates store locale codes, not display text: + + ```csharp + var swordTemplate = new ItemTemplate(); + swordTemplate.NameLocaleId = "item-sword-name"; + swordTemplate.DescriptionLocaleId = "item-sword-desc"; + ``` + + To display the name, you resolve it through the repository: + + ```csharp + var displayName = LocaleRepository.Get("item-sword-name"); + // => "Long Sword" (if en-gb dataset has that key) + ``` + + #### Switching Languages + + Calling `ChangeLocale()` swaps the active dataset for all subsequent lookups: + + ```csharp + LocaleRepository.ChangeLocale("fr-fr"); + var frenchName = LocaleRepository.Get("item-sword-name"); + ``` + +
+ + ### Query Pattern + + The repository uses a **Command/Query** separation pattern. Instead of methods directly on the repository, + operations are encapsulated as query objects: + + ```csharp + // Direct query usage + var name = repository.Query(new GetLocaleQuery("item-sword-name")); + + // Or use the extension method (recommended) + var name = repository.Get("item-sword-name"); + ``` + +
+
+ + ### Extension Methods + + The `RepositoryExtensions` class provides a clean API over the query pattern: + + - **`Get(id)`**: resolve a single locale key to its display text. + - **`GetAll(ids...)`**: resolve multiple keys at once. + - **`Create(id, text)`**: add a new key-value pair to the current locale. + - **`Update(id, text)`**: overwrite an existing key with new text. + - **`Delete(id)`**: remove a key from the current locale. + - **`Exists(id)`**: check if a key exists without throwing. + - **`GetDataset()`**: retrieve the entire locale dataset. + +
+ + ### Demo Locale Data + + The web demo pre-generates locale data for `en-gb` using `LocaleDataGenerator`. It reflects over + the type constant classes and creates human-readable labels: + + - `FantasyEffectTypes.HealthBonusAmount` → key `types-effects-100` → `"Health"` + - `FantasyItemTypes.Sword` → key `types-item-type-300` → `"Sword"` + - `FantasyDamageTypes.Fire` → key `types-damage-900` → `"Fire"` + + Keys follow the pattern `"{prefix}{intId}"` where the int ID matches the static const fields on the Types classes. + +
+
+ +@code { + +} diff --git a/src/OpenRpg.Demos.Web/Pages/Localization/LocaleDemo.razor b/src/OpenRpg.Demos.Web/Pages/Localization/LocaleDemo.razor new file mode 100644 index 00000000..8846f676 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Localization/LocaleDemo.razor @@ -0,0 +1,265 @@ +@page "/localization/locale-demo" +@using OpenRpg.Localization +@using OpenRpg.Localization.Data.DataSources +@using OpenRpg.Localization.Data.Repositories +@using OpenRpg.Localization.Data.Extensions +@inject ILocaleRepository LocaleRepository +@inject ILocaleDataSource LocaleDataSource + +
+
+ + ## Interactive Locale Demo + + To show a more real example of locale information and usage, you can see all current setup locales and also add new locales/entries. + + In the real world you would likely use the OpenRpg Editor or your own tooling to manage locales etc, but this gives a good interactive example of how it all hangs together. + +
+ + + ### Locale Browser: @_currentLocale + +
+
+ + + + +
+
+
+
+ All + @foreach (var cat in _categories) + { + @cat + } +
+
+
+ + + + + + + + + @foreach (var entry in PagedEntries) + { + + + + + } + +
KeyValue
@entry.Key@entry.Value
+
+
+
+ @FilteredEntries.Count entries total +
+
+
+ + @_page / @TotalPages + +
+
+
+
+
+
+ + + ### Locale Settings + + You can switch the active locale below, or add a new one if you want. + +
+ @foreach (var locale in _availableLocales) + { + @locale + } +
+
+
+ +
+
+ +
+
+ @if (!string.IsNullOrEmpty(_localeSwitchResult)) + { +
@_localeSwitchResult
+ } +
+
+ + + ### Add Locale Entry + + Adding to: **@_currentLocale** + +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ @if (!string.IsNullOrEmpty(_createResult)) + { +
@_createResult
+ } +
+
+
+ +@code { + private ILocaleDataSource _localeDataSource; + private List _availableLocales = new(); + private string _currentLocale = string.Empty; + private string _newLocaleCodeInput = string.Empty; + private string _localeSwitchResult; + + private Dictionary _allEntries = new(); + private List _categories = new(); + private string _searchTerm = string.Empty; + private string _categoryFilter; + private int _page = 1; + private const int PageSize = 50; + private string _newLocaleKey; + private string _newLocaleText; + private string _createResult; + + private List> FilteredEntries + { + get + { + var entries = _allEntries.AsEnumerable(); + + if (!string.IsNullOrEmpty(_categoryFilter)) + entries = entries.Where(e => e.Key.StartsWith(_categoryFilter)); + + if (!string.IsNullOrEmpty(_searchTerm)) + entries = entries.Where(e => + e.Key.Contains(_searchTerm, StringComparison.OrdinalIgnoreCase) || + e.Value.Contains(_searchTerm, StringComparison.OrdinalIgnoreCase)); + + return entries.ToList(); + } + } + + private int TotalPages => Math.Max(1, (int)Math.Ceiling(FilteredEntries.Count / (double)PageSize)); + + private List> PagedEntries => + FilteredEntries.Skip((_page - 1) * PageSize).Take(PageSize).ToList(); + + protected override void OnInitialized() + { + base.OnInitialized(); + _localeDataSource = LocaleRepository.DataSource; + _currentLocale = LocaleRepository.CurrentLocaleCode; + RefreshAvailableLocales(); + LoadLocaleData(); + } + + private void RefreshAvailableLocales() + { + _availableLocales = LocaleDataSource.GetLocaleCodes().OrderBy(x => x).ToList(); + } + + private void LoadLocaleData() + { + _page = 1; + + var dataset = LocaleRepository.GetDataset(); + _allEntries = dataset.LocaleData + .OrderBy(e => e.Key) + .ToDictionary(e => e.Key, e => e.Value); + + _categories = _allEntries.Keys + .Select(k => + { + var lastDash = k.LastIndexOf('-'); + return lastDash > 0 ? k[..lastDash] : k; + }) + .Distinct() + .OrderBy(c => c) + .ToList(); + } + + private void SwitchLocale(string locale) + { + LocaleRepository.ChangeLocale(locale); + _currentLocale = locale; + _localeSwitchResult = $"Switched to '{locale}'."; + LoadLocaleData(); + } + + private void AddNewLocale() + { + _localeSwitchResult = null; + + if (string.IsNullOrWhiteSpace(_newLocaleCodeInput)) + { + _localeSwitchResult = "Please enter a locale code."; + return; + } + + var localeCode = _newLocaleCodeInput.Trim().ToLowerInvariant(); + + if (_availableLocales.Contains(localeCode)) + { + _localeSwitchResult = $"Locale '{localeCode}' already exists."; + return; + } + + _localeDataSource.GetLocaleDataset(localeCode); + RefreshAvailableLocales(); + _localeSwitchResult = $"Created locale '{localeCode}'."; + _newLocaleCodeInput = string.Empty; + } + + private void CreateLocaleEntry() + { + _createResult = null; + + if (string.IsNullOrWhiteSpace(_newLocaleKey) || string.IsNullOrWhiteSpace(_newLocaleText)) + { + _createResult = "All fields are required."; + return; + } + + try + { + if (LocaleRepository.Exists(_newLocaleKey)) + { + LocaleRepository.Update(_newLocaleKey, _newLocaleText); + _createResult = $"Updated '{_newLocaleKey}' in '{_currentLocale}'."; + } + else + { + LocaleRepository.Create(_newLocaleKey, _newLocaleText); + _createResult = $"Created '{_newLocaleKey}' in '{_currentLocale}'."; + } + + LoadLocaleData(); + } + catch (Exception) + { + _createResult = $"Failed to add entry to '{_currentLocale}'."; + } + } +} diff --git a/src/OpenRpg.Demos.Web/Pages/Other/Scenarios.razor b/src/OpenRpg.Demos.Web/Pages/Other/Scenarios.razor new file mode 100644 index 00000000..43b5e52e --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Other/Scenarios.razor @@ -0,0 +1,92 @@ +@page "/other/scenarios" + + + ## Game/Genre Scenarios + + As mentioned throughout these examples, this framework is meant to provide the building blocks to be able to express almost any kind of RPG game, so with that in mind lets look at some common types of RPG games and the sort of high level view of how we would express those games with this library. + +
+
+
+ + ### Action RPG - i.e Diablo, Torchlight + + These sort of games are generally about looting procedural items and fighting somewhat procedural enemies, the combat revolves around damage derived from ability/item synergies based on affixes/powers on powerful items. + + #### Overall Approach + - Games often ignore notion of `Race` + - Unique Player `Class` templates, scaling damage/defense effects off given attributes + - Paragon board is collection of `Effects` with `Requirements` for previous nodes tracked against `Entity` + - `Enemy` templates use `Procedural Effects` for level scaling + - `Classes` has access to various `Abilities` but can only actively use X amount + - `Items` are generally procedural with unique `Effect Types` to provide affix/powers + - `Items` can use `Modifications Allowances` to support adding Gems, which wrap `Effects` + - `Inventory` is slot based with fixed slots with additional `Size` metadata, also added to `Item Templates` + - `Equipment` generally matches that of `Fantasy Genre` equipment slots + - `Quests` can express repeatable dynamic events + - `Attacks` are often `Ability` based with `Cooldowns` + +
+
+ + ### CRPG - i.e Baldur's Gate, Pillars of Eternity + + These sort of games are generally more about story and characters, the combat often split into melee or spell based abilities with party based mechanics. + + #### Overall Approach + - `Race` should be used and provide unique bonuses + - `Class` templates have fixed `Effects` and can often use `MultiClass` functionality + - Notion of Feats/Perks can be expressed via unique `Effects` tracked against `Entity` + - `Enemy` templates use `Scaled Effects` for level scaling + - `Abilities` are often fixed per `Class` + - `Item Templates` are generally static + - `Inventory` is weight based + - `Quests` are unique and main focus of the narrative drive + - `Factions` are important as part of narrative decisions + - `Attacks` are a mix of base attacks and `Ability` with custom `Usage` counts metadata + +
+
+
+
+ + ### JRPG - i.e Wild Arms, Grandia + + Like CPRGs they are more story based but often have simpler `Character` mechanics often have more of a fixed + + #### Overall Approach + - Games often ignore notion of `Race` + - `Class` templates have fixed `Effects` and can often use `MultiClass` functionality + - Notion of Feats/Perks can be expressed via unique `Effects` tracked against `Entity` + - `Enemy` templates use `Scaled Effects` for level scaling + - `Abilities` are often fixed per `Class` and known as Spells/Skills + - `Item Templates` are generally static + - `Inventory` is unlimited + - Games often ignore notion of `Quests` but likely tracks game trigger/progress via `GameState` + - `Attacks` are a mix of base attacks and `Ability` with Mana Cost + +
+
+ + ### Souls-like - i.e Dark Souls, Elden Ring + + Like CPRGs they are more story based but often have simpler `Character` mechanics often have more of a fixed + + #### Overall Approach + - Games often ignore notion of `Race` + - Games often ignore notion of `Class` + - Level up attributes are custom `Effects` tracked against `Entity` + - `Enemy` templates are generally static + - `Items` would scale damage/defense `Effects` based on attributes + - Spells would be expressed as `Abilities` + - `Item Templates` are generally static + - `Inventory` has no limit + - Games often ignore notion of `Quests` but likely tracks game trigger/progress via `GameState` + +
+
+
+ +@code { + +} \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/Pages/Other/TagsDemo.razor b/src/OpenRpg.Demos.Web/Pages/Other/TagsDemo.razor new file mode 100644 index 00000000..7b335fb4 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Other/TagsDemo.razor @@ -0,0 +1,389 @@ +@page "/other/tags" + +@using OpenRpg.Core.Extensions +@using OpenRpg.Core.Templates +@using OpenRpg.Core.Variables +@using OpenRpg.Demos.Infrastructure.Data +@using OpenRpg.Entities.Extensions +@using OpenRpg.Items.Templates +@using OpenRpg.Tags +@using OpenRpg.Tags.Data +@using OpenRpg.Tags.Extensions + +@inject ITagRegistry TagRegistry +@inject ITemplateAccessor TemplateAccessor + + + ## Tags & Weighted Scoring + + The tag system provides a lightweight way to categorise and relate entities using integer IDs. + Tags are stored in a `TagList` (extends `List<int>`) at variable key 9001 on every entity, + template, and data object in the system; they are universally inherited. + + The real power comes from `TagRegistry`, which defines weighted relationships between tags. + This lets you express semantic similarity. For example, "armour" correlates positively with + "metal" and "leather", but negatively with "wood". The scoring pipeline uses these relationships + to rank how well an entity matches a set of source tags. + +
+ + + + ## Items & Their Tags + + Each item below has tags assigned. These are stored on the template's `Variables` using the + universal `Tags` property (key 9001). + +
+ @foreach (var item in _items) + { +
+
+
@item.NameLocaleId
+

@item.DescriptionLocaleId

+
+ @foreach (var tagId in GetItemTags(item)) + { + @GetTagName(tagId) + } + @if (!GetItemTags(item).Any()) + { + No tags + } +
+
+
+ } +
+
+
+ + + + ## Tag Relationships + + The `TagRegistry` defines weighted relationships between tags. A positive weight means the + tags are semantically similar (e.g., Armour +0.6 Leather), while a negative weight means + they are opposed (e.g., Heavy -0.3 Leather). Select a source tag to see its relationships. + +
+ +
+
+ +
+
+
+ + @if (_selectedSourceTag > 0) + { + var related = TagRegistry.GetRelatedTags(_selectedSourceTag).OrderByDescending(x => x.Weight).ToArray(); +
+ @foreach (var rel in related) + { + var weightClass = rel.Weight > 0 ? "is-success" : rel.Weight < 0 ? "is-danger" : "is-light"; + + @GetTagName(rel.Tag): @(rel.Weight >= 0 ? "+" : "")@rel.Weight.ToString("F2") + + } + @if (!related.Any()) + { + No relationships defined + } +
+ } +
+
+ + + ## Interactive Filtering + + Select one or more tags to filter items. Uses the `ContainingTags` extension method which + returns only items that contain **all** of the selected tags. + +
+ +
+
+ +

Filter by Tags

+
+ +
+ +
+ @foreach (var tagId in _allTagIds) + { + var isSelected = _selectedFilterTags.Contains(tagId); + + } +
+
+ +
+
+ + +
+
+ + @if (_filteredItems.Any()) + { +
Matching Items (@_filteredItems.Count)
+ @foreach (var item in _filteredItems) + { +
+
+
+ @item.NameLocaleId +
+ @foreach (var tagId in GetItemTags(item)) + { + var isFilterTag = _selectedFilterTags.Contains(tagId); + @GetTagName(tagId) + } +
+
+
+
+ } + } + else if (_hasFiltered) + { +
+ No items match the selected tags. +
+ } +
+
+ +
+ +

Weighted Scoring

+
Uses the same tag selection to rank items by score
+
+ + @if (_scoredResults.Any()) + { +
Ranked Results
+ @foreach (var result in _scoredResults) + { + var item = _items.FirstOrDefault(i => i.Id == (int)result.ContextualTags.Context); + var scoreClass = result.Score > 0.5 ? "is-success" : result.Score > 0 ? "is-info" : "is-light"; +
+
+
+ @item?.NameLocaleId +
+
+ + Score: @result.Score.ToString("F2") + +
+
+
+ } + } + else if (_hasFiltered) + { +
+ No scored results. Try selecting tags that items have. +
+ } + else + { +
+ Select tags on the left and click "Filter Items" to see scored results. +
+ } +
+
+
+
+ + + + ## Depth Traversal + + `GetRelatedTags(tags, depth)` walks the relationship graph N levels deep. At depth 1, it + finds direct relationships. At depth 2, it follows those to find indirect connections. + Using **Fire** as the source tag shows this clearly: it only has 2 direct relationships, + but deeper traversal discovers Metal, Wood, and Leather through intermediate hops. + +
+ +
+ @GetTagName(_depthSourceTag) +
+
+ +
+ @for (var depth = 1; depth <= 3; depth++) + { + var results = TagRegistry.GetRelatedTags(new[] { _depthSourceTag }, depth).OrderByDescending(x => x.Weight).ToArray(); +
+
Depth @depth
+ @if (depth == 1) + { +

Direct relationships only

+ } + else if (depth == 2) + { +

Follows depth 1 tags one more hop

+ } + else + { +

Three levels of indirection

+ } +
+ @foreach (var rel in results) + { + var weightClass = rel.Weight > 0 ? "is-success" : rel.Weight < 0 ? "is-danger" : "is-light"; + + @GetTagName(rel.Tag): @(rel.Weight >= 0 ? "+" : "")@rel.Weight.ToString("F2") + + } + @if (!results.Any()) + { + None + } +
+
+ } +
+
+
+ + + ### How It Works + + ```csharp + // Tags are stored on any variable container + var itemTags = itemTemplate.Variables.Tags; // TagList : List<int> + + // Define weighted relationships in a TagRegistry + var registry = new TagRegistry(); + registry.AddRelationship(armourTag, metalTag, 0.4f); + registry.AddRelationship(armourTag, leatherTag, 0.6f); + + // Get related tags (averages weights across source tags) + var related = registry.GetRelatedTags(armourTag, heavyTag); + + // Get related tags traversing 2 levels deep + var deepRelated = registry.GetRelatedTags(tags, depth: 2); + + // Score items against source tags + var contextualTags = items.Select(i => new ContextualTags(i.Id, i.Variables.Tags)); + var scored = registry.GetTaggedScoresFor(contextualTags, armourTag, heavyTag); + + // Filter items containing specific tags + var matching = contextualTags.ContainingTags(armourTag, metalTag); + ``` + + +@code { + private List _items = new(); + private List _allTagIds = new(); + private HashSet _selectedFilterTags = new(); + private List _filteredItems = new(); + private bool _hasFiltered = false; + private List _scoredResults = new(); + + private int _selectedSourceTag = 0; + + private int _depthSourceTag = 8; // Fire + + private readonly Dictionary _tagNames = new() + { + { 1, "Armour" }, { 2, "Weapon" }, { 3, "Heavy" }, { 4, "Light" }, + { 5, "Metal" }, { 6, "Wood" }, { 7, "Leather" }, { 8, "Fire" }, + { 9, "Ice" }, { 10, "Consumable" } + }; + + protected override void OnInitialized() + { + _items = TemplateAccessor.GetAll().ToList(); + _allTagIds = _tagNames.Keys.OrderBy(x => x).ToList(); + base.OnInitialized(); + } + + private string GetTagName(int tagId) => _tagNames.TryGetValue(tagId, out var name) ? name : $"Tag {tagId}"; + + private TagList GetItemTags(ItemTemplate item) + { + return ((IVariables)item.Variables).GetAsOrDefaultAndSet(9001, () => new TagList()); + } + + private string GetTagClass(int tagId) + { + return tagId switch + { + 1 => "is-info", // Armour + 2 => "is-danger", // Weapon + 3 => "is-dark", // Heavy + 4 => "is-link", // Light + 5 => "is-link", // Metal + 6 => "is-warning", // Wood + 7 => "is-success", // Leather + 8 => "is-danger", // Fire + 9 => "is-info", // Ice + 10 => "is-success", // Consumable + _ => "is-light" + }; + } + + private void OnSourceTagChanged(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out var tagId)) + { + _selectedSourceTag = tagId; + } + } + + private void ToggleFilterTag(int tagId) + { + if (_selectedFilterTags.Contains(tagId)) + _selectedFilterTags.Remove(tagId); + else + _selectedFilterTags.Add(tagId); + } + + private void FilterItems() + { + _hasFiltered = true; + var tagArray = _selectedFilterTags.ToArray(); + var contextualTags = _items.Select(i => new ContextualTags(i.Id, GetItemTags(i))); + + _filteredItems = contextualTags + .ContainingTags(tagArray) + .Select(ct => _items.First(i => i.Id == (int)ct.Context)) + .ToList(); + + _scoredResults = TagRegistry + .GetTaggedScoresFor(contextualTags, tagArray) + .OrderByDescending(x => x.Score) + .ToList(); + } + + private void ClearFilter() + { + _selectedFilterTags.Clear(); + _filteredItems.Clear(); + _scoredResults.Clear(); + _hasFiltered = false; + } +} diff --git a/src/OpenRpg.Demos.Web/Pages/Procedural/EffectResolution.razor b/src/OpenRpg.Demos.Web/Pages/Procedural/EffectResolution.razor new file mode 100644 index 00000000..5162a43b --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Procedural/EffectResolution.razor @@ -0,0 +1,291 @@ +@page "/procedural/effect-resolution" + +@using OpenRpg.Core.Effects +@using OpenRpg.Core.Utils +@using OpenRpg.CurveFunctions +@using OpenRpg.CurveFunctions.Scaling +@using OpenRpg.Entities.Effects +@using OpenRpg.Entities.Extensions +@using OpenRpg.Entities.Procedural.Effects +@using OpenRpg.Entities.Procedural.Effects.Builders +@using OpenRpg.Entities.Types +@using OpenRpg.Genres.Fantasy.Types + +Procedural Effect Resolution + + + ## Procedural Effect Resolution + + In the [Procedural Items](/procedural/procedural-items) demo we used + `GenerateProceduralInstance(randomizer)` to roll a *random* procedural value + and resolve it into effects. The library also exposes a *deterministic* + counterpart that takes the procedural value as input and resolves it directly: + + ```csharp + IReadOnlyCollection<IEffect> GenerateEffectsFrom( + this ProceduralEffects proceduralEffects, int proceduralValue); + ``` + + This is the function used for level scaling, deterministic loot tables, + save-game reproducibility, and any other case where the procedural value is + already known. This page lets you drive that input value with a slider and + watch the resolved effect list update in real time. + + +
+ +
+
+ +

Procedural Value

+
+ +
+ +
+
+
+

+ Low (10) +

+

+ Mid (50) +

+

+ High (95) +

+
+
+ +
+

+ The narrow-banded effect demonstrates the IsOutsideRange + filter inside GenerateEffectsFrom: if the procedural value + falls outside the effect's input scale, the effect is skipped entirely. +

+
+
+
+ +

Resolved Effects (@ResolvedEffects.Count)

+ @if (ResolvedEffects.Count == 0) + { +

No effects resolved for this procedural value.

+ } + else + { +
+ + + + + + + + + + @foreach (var (effect, sourceLabel, inputRange) in ResolvedRows) + { + + + + + + } + +
Effect TypePotencyInput Range
@GetEffectTypeName(effect.EffectType)@effect.Potency.ToString("F2")@inputRange
+
+ } +

+ GenerateEffectsFrom only returns effects whose + GroupType is Primary. Optional effects are the + domain of the random path (GenerateProceduralInstance). +

+
+
+
+ +
+ + +

Blunt Damage Bell Curve

+

+ The black dot marks the current procedural value. At v=@ProceduralValue + the resolved blunt damage potency is + @BluntScalingFunction.Plot(ProceduralValue).ToString("F2"). + Notice the bell-curve shape: only mid-range values (around 50) approach the + upper end of the output range (30), while low and high values stay near the + bottom (20). +

+ +
+ +
+ + + ### Random path vs deterministic path + + The library exposes two functions for procedural content, and they have different + responsibilities. + + #### `GenerateProceduralEffectAssociations(proceduralEffects, randomizer)` + Takes an `IRandomizer`, returns a random-count list of `Association` records pointing + into the template. Used for random loot drops and randomized crafting. + ```csharp + IReadOnlyCollection<Association> GenerateProceduralEffectAssociations( + this ProceduralEffects proceduralEffects, IRandomizer randomizer); + ``` + + #### `GenerateEffectsFrom(proceduralEffects, proceduralValue)` + Takes a known 0-100 value, returns a list of `IEffect` with potencies already plotted. + Used for loot tables, level scaling, and save-game replay. + ```csharp + IReadOnlyCollection<IEffect> GenerateEffectsFrom( + this ProceduralEffects proceduralEffects, int proceduralValue); + ``` + + The [Procedural Items](/procedural/procedural-items) page exercises the first. + This page exercises the second. + + ### Why primary effects only + + The deterministic path is intentionally restricted to `Primary` effects because + primary effects are the "this item always has X" baseline, while optional + effects are the "this item may have Y" randomization pool. Mixing them in the + deterministic path would make the same procedural value resolve to different + effect lists on different calls, defeating the purpose of determinism. + + +@code { + public int ProceduralValue { get; set; } = 50; + public bool IncludeNarrowBand { get; set; } = true; + + public ProceduralEffects MainEffects { get; set; } + public ProceduralEffects NarrowBandEffects { get; set; } + public ScalingFunction BluntScalingFunction { get; set; } + + public IRefreshable Chart { get; set; } + + public float ProceduralValueAsFloat => ProceduralValue; + + public IReadOnlyCollection ResolvedEffects + { + get + { + var main = MainEffects.GenerateEffectsFrom(ProceduralValue); + if (!IncludeNarrowBand) + { + return main; + } + var narrow = NarrowBandEffects.GenerateEffectsFrom(ProceduralValue); + return main.Concat(narrow).ToList(); + } + } + + public IReadOnlyList<(StaticEffect Effect, string Source, string InputRange)> ResolvedRows + { + get + { + var rows = new List<(StaticEffect, string, string)>(); + foreach (var effect in MainEffects.Effects.Where(x => x.GroupType == CoreProceduralGroupTypes.Primary)) + { + var range = effect.PotencyFunction.InputScale; + rows.Add((new StaticEffect + { + EffectType = effect.EffectType, + Potency = effect.PotencyFunction.Plot(ProceduralValue) + }, "Main", FormatRange(range))); + } + if (IncludeNarrowBand) + { + foreach (var effect in NarrowBandEffects.Effects.Where(x => x.GroupType == CoreProceduralGroupTypes.Primary)) + { + var range = effect.PotencyFunction.InputScale; + rows.Add((new StaticEffect + { + EffectType = effect.EffectType, + Potency = effect.PotencyFunction.Plot(ProceduralValue) + }, "Narrow", FormatRange(range))); + } + } + return rows; + } + } + + protected override void OnInitialized() + { + MainEffects = ProceduralEffectsBuilder + .Create() + .WithPrimaryEffect(new ScaledEffect + { + EffectType = FantasyEffectTypes.BluntDamageAmount, + PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(20, 30), RangeF.ZeroToOneHundred), + ScalingType = CoreEffectScalingTypes.Value + }) + .WithPrimaryEffect(new ScaledEffect + { + EffectType = FantasyEffectTypes.StrengthBonusAmount, + PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(1, 3), RangeF.ZeroToOneHundred), + ScalingType = CoreEffectScalingTypes.Value + }) + .Build(); + + NarrowBandEffects = ProceduralEffectsBuilder + .Create() + .WithPrimaryEffect(new ScaledEffect + { + EffectType = FantasyEffectTypes.FireDamageAmount, + PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(1, 5), new RangeF(30, 70)), + ScalingType = CoreEffectScalingTypes.Value + }) + .Build(); + + BluntScalingFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(20, 30), RangeF.ZeroToOneHundred); + + base.OnInitialized(); + } + + public async Task OnProceduralValueChanged() + { + if (Chart != null) + { + // TODO: No idea why this needs a double refresh, but it won't work otherwise + await Chart.Refresh(); + StateHasChanged(); + await Chart.Refresh(); + StateHasChanged(); + } + } + + public async Task SetValue(int value) + { + ProceduralValue = value; + await OnProceduralValueChanged(); + } + + public string GetEffectTypeName(int effectType) + { + if (effectType == FantasyEffectTypes.BluntDamageAmount) { return "Blunt Damage"; } + if (effectType == FantasyEffectTypes.PiercingDamageAmount) { return "Piercing Damage"; } + if (effectType == FantasyEffectTypes.StrengthBonusAmount) { return "Strength Bonus"; } + if (effectType == FantasyEffectTypes.FireDamageAmount) { return "Fire Damage"; } + return $"Effect #{effectType}"; + } + + public static string FormatRange(RangeF range) + { + return $"[{range.Min:F0} - {range.Max:F0}]"; + } +} diff --git a/src/OpenRpg.Demos.Web/Pages/Procedural/PatternGeneratedItems.razor b/src/OpenRpg.Demos.Web/Pages/Procedural/PatternGeneratedItems.razor new file mode 100644 index 00000000..5f6c1fe2 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Procedural/PatternGeneratedItems.razor @@ -0,0 +1,394 @@ +@page "/procedural/pattern-generated-items" + +@using OpenRpg.Core.Utils +@using OpenRpg.CurveFunctions +@using OpenRpg.CurveFunctions.Scaling +@using OpenRpg.Demos.Infrastructure.Extensions +@using OpenRpg.Demos.Infrastructure.Templates +@using OpenRpg.Demos.Web.Pages.Procedural.Patterns +@using OpenRpg.Entities.Effects +@using OpenRpg.Entities.Procedural.Effects.Builders +@using OpenRpg.Entities.Procedural.Patterns +@using OpenRpg.Entities.Types +@using OpenRpg.Genres.Fantasy.Types +@using OpenRpg.Items.TradeSkills.Templates +@using OpenRpg.Items.Templates + +@inject IRandomizer Randomizer; + + + ## Pattern Generation + This is still experimental, but you can generate templates with a given pattern like so + + +
+ +

Generated Item Templates

+ +
+
+ @foreach (var itemTemplate in _ores) + { +
+ +
+ } +
+
+
+ + +
+
+ @foreach (var itemTemplate in _ingots) + { +
+ +
+ } +
+
+
+ + +
+
+ @foreach (var itemTemplate in _swords) + { +
+ +
+ } +
+
+
+ + +
+
+ @foreach (var itemTemplate in _armours) + { +
+ +
+ } +
+
+
+ + +
+
+ @foreach (var itemTemplate in _logs) + { +
+ +
+ } +
+
+
+ + +
+
+ @foreach (var itemTemplate in _lumber) + { +
+ +
+ } +
+
+
+ + +
+
+ @foreach (var itemTemplate in _bows) + { +
+ +
+ } +
+
+
+ +

Crafting Templates

+ + +
+
+ @foreach (var craftingTemplate in _ingotCrafting) + { +
+ +
+ } +
+
+
+ + +
+
+ @foreach (var craftingTemplate in _swordCrafting) + { +
+ +
+ } +
+
+
+ + +
+
+ @foreach (var craftingTemplate in _armourCrafting) + { +
+ +
+ } +
+
+
+ +@code { + + private ItemTemplatePatternGenerator _itemTemplatePatternGenerator; + private CraftingTemplatePatternGenerator _craftingTemplatePatternGenerator; + private IReadOnlyCollection _ores, _ingots, _swords, _armours, _logs, _lumber, _bows; + private IReadOnlyCollection _ingotCrafting, _swordCrafting, _armourCrafting, _lumberCrafting; + public ManualTemplateAccessor TemplateAccessor = new (); + + public interface ItemPatternTypes + { + public static int Unknown = 0; + + public static int Ore = 1; + public static int Ingot = 2; + public static int Sword = 3; + public static int Armour = 4; + public static int Bows = 5; + + public static int Log = 10; + public static int Lumber = 11; + } + + public interface MetalItemPatternLookups + { + public static int Unknown = 0; + + public static int Copper = 1; + public static int Bronze = 2; + public static int Silver = 3; + public static int Gold = 4; + public static int Iron = 5; + public static int Steel = 6; + public static int Mythril = 7; + } + + public interface WoodItemPatternLookups + { + public static int Unknown = 0; + + public static int Maple = 1; + public static int Ash = 2; + public static int Elm = 3; + public static int Oak = 4; + } + + public static Dictionary MetalLookups = typeof(MetalItemPatternLookups).GetTypeFieldsDictionary(); + public static Dictionary WoodLookups = typeof(WoodItemPatternLookups).GetTypeFieldsDictionary(); + + protected override void OnInitialized() + { + base.OnInitialized(); + + _itemTemplatePatternGenerator = new ItemTemplatePatternGenerator(); + _craftingTemplatePatternGenerator = new CraftingTemplatePatternGenerator(TemplateAccessor); + + GenerateMetalItemTemplates(); + GenerateWoodItemTemplates(); + GenerateItemCraftingTemplates(); + } + + private void GenerateMetalItemTemplates() + { + var patternIds = MetalLookups.Keys.ToArray(); + var localeGenerator = new FixedPatternLocaleGenerator() { NamePattern = "{0} {1}", DescriptionPattern = "{0} {1}", PatternNames = MetalLookups }; + + _ores = _itemTemplatePatternGenerator.Generate(new ItemTemplatePatternGeneratorConfig() + { + PatternIds = patternIds, + PatternTypeId = ItemPatternTypes.Ore, + LocaleGenerator = localeGenerator, + TypeCode = "Ore", + StartingId = 0, + ItemType = FantasyItemTypes.GenericItem + }); + TemplateAccessor.AddTemplates(_ores); + + _ingots = _itemTemplatePatternGenerator.Generate(new ItemTemplatePatternGeneratorConfig() + { + PatternIds = patternIds, + PatternTypeId = ItemPatternTypes.Ingot, + LocaleGenerator = localeGenerator, + TypeCode = "Ingot", + StartingId = 20, + ItemType = FantasyItemTypes.GenericItem + }); + TemplateAccessor.AddTemplates(_ingots); + + + var swordEffects = ProceduralEffectsBuilder.Create() + .WithPrimaryEffect(new ScaledEffect() + { + EffectType = FantasyEffectTypes.DamageBonusAmount, + PotencyFunction = new ScalingFunction(PresetCurves.Linear, new RangeF(10, 100), new RangeF(0, patternIds.Length)), + ScalingType = CoreEffectScalingTypes.Value + }) + .Build(); + + _swords = _itemTemplatePatternGenerator.Generate(new ItemTemplatePatternGeneratorConfig() + { + PatternIds = patternIds, + PatternTypeId = ItemPatternTypes.Sword, + LocaleGenerator = localeGenerator, + TypeCode = "Sword", + StartingId = 40, + ItemType = FantasyItemTypes.GenericWeapon, + Effects = swordEffects + }); + TemplateAccessor.AddTemplates(_swords); + + var armourEffects = ProceduralEffectsBuilder.Create() + .WithPrimaryEffect(new ScaledEffect() + { + EffectType = FantasyEffectTypes.DefenseBonusAmount, + PotencyFunction = new ScalingFunction(PresetCurves.Linear, new RangeF(10, 100), new RangeF(0, patternIds.Length)), + ScalingType = CoreEffectScalingTypes.Value + }) + .WithPrimaryEffect(new ScaledEffect() + { + EffectType = FantasyEffectTypes.HealthBonusAmount, + PotencyFunction = new ScalingFunction(PresetCurves.Linear, new RangeF(10, 20), new RangeF(4, patternIds.Length)), + ScalingType = CoreEffectScalingTypes.Value + }) + .Build(); + + _armours = _itemTemplatePatternGenerator.Generate(new ItemTemplatePatternGeneratorConfig() + { + PatternIds = patternIds, + PatternTypeId = ItemPatternTypes.Armour, + LocaleGenerator = localeGenerator, + TypeCode = "Armor", + StartingId = 60, + ItemType = FantasyItemTypes.UpperBodyArmour, + Effects = armourEffects + }); + TemplateAccessor.AddTemplates(_armours); + } + + private void GenerateWoodItemTemplates() + { + var patternIds = WoodLookups.Keys.ToArray(); + var localeGenerator = new FixedPatternLocaleGenerator() { NamePattern = "{0} {1}", DescriptionPattern = "{0} {1}", PatternNames = WoodLookups }; + + _logs = _itemTemplatePatternGenerator.Generate(new ItemTemplatePatternGeneratorConfig() + { + PatternIds = patternIds, + PatternTypeId = ItemPatternTypes.Log, + LocaleGenerator = localeGenerator, + TypeCode = "Log", + StartingId = 100, + ItemType = FantasyItemTypes.GenericItem + }); + TemplateAccessor.AddTemplates(_logs); + + _lumber = _itemTemplatePatternGenerator.Generate(new ItemTemplatePatternGeneratorConfig() + { + PatternIds = patternIds, + PatternTypeId = ItemPatternTypes.Lumber, + LocaleGenerator = localeGenerator, + TypeCode = "Lumber", + StartingId = 120, + ItemType = FantasyItemTypes.GenericItem + }); + TemplateAccessor.AddTemplates(_lumber); + + var bowEffects = ProceduralEffectsBuilder.Create() + .WithPrimaryEffect(new ScaledEffect() + { + EffectType = FantasyEffectTypes.DamageBonusAmount, + PotencyFunction = new ScalingFunction(PresetCurves.Linear, new RangeF(10, 100), new RangeF(0, patternIds.Length)), + ScalingType = CoreEffectScalingTypes.Value + }) + .Build(); + + _bows = _itemTemplatePatternGenerator.Generate(new ItemTemplatePatternGeneratorConfig() + { + PatternIds = patternIds, + PatternTypeId = ItemPatternTypes.Bows, + LocaleGenerator = localeGenerator, + TypeCode = "Bow", + StartingId = 140, + ItemType = FantasyItemTypes.GenericWeapon, + Effects = bowEffects + }); + TemplateAccessor.AddTemplates(_bows); + + } + + private void GenerateItemCraftingTemplates() + { + var patternIds = MetalLookups.Keys.ToArray(); + var metalLocaleGenerator = new FixedPatternLocaleGenerator() { NamePattern = "{0} {1}", DescriptionPattern = "{0} {1}", PatternNames = MetalLookups }; + + _ingotCrafting = _craftingTemplatePatternGenerator.Generate(new ItemCraftingTemplatePatternGeneratorConfig() + { + PatternIds = patternIds, + StartingId = 0, + SkillType = FantasyTradeSkillTypes.Smelting, + SkillScore = new ScalingFunction(PresetCurves.Linear, new RangeF(0, 200), new RangeF(0, patternIds.Length)), + AmountRequired = 5, + InputPatternTypeId = ItemPatternTypes.Ore, + AmountOutput = 1, + OutputPatternTypeId = ItemPatternTypes.Ingot, + PatternTypeId = ItemPatternTypes.Ingot, + LocaleGenerator = metalLocaleGenerator + }); + + _swordCrafting = _craftingTemplatePatternGenerator.Generate(new ItemCraftingTemplatePatternGeneratorConfig() + { + PatternIds = patternIds, + StartingId = 20, + SkillType = FantasyTradeSkillTypes.Smithing, + SkillScore = new ScalingFunction(PresetCurves.Linear, new RangeF(0, 200), new RangeF(0, patternIds.Length)), + AmountRequired = 4, + InputPatternTypeId = ItemPatternTypes.Ingot, + AmountOutput = 1, + OutputPatternTypeId = ItemPatternTypes.Sword, + PatternTypeId = ItemPatternTypes.Sword, + LocaleGenerator = metalLocaleGenerator + }); + + _armourCrafting = _craftingTemplatePatternGenerator.Generate(new ItemCraftingTemplatePatternGeneratorConfig() + { + PatternIds = patternIds, + StartingId = 40, + SkillType = FantasyTradeSkillTypes.Smithing, + SkillScore = new ScalingFunction(PresetCurves.Linear, new RangeF(0, 200), new RangeF(0, patternIds.Length)), + AmountRequired = 4, + InputPatternTypeId = ItemPatternTypes.Ingot, + AmountOutput = 1, + OutputPatternTypeId = ItemPatternTypes.Armour, + PatternTypeId = ItemPatternTypes.Armour, + LocaleGenerator = metalLocaleGenerator + }); + } + +} \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/Pages/Procedural/Patterns/CraftingTemplatePatternGenerator.cs b/src/OpenRpg.Demos.Web/Pages/Procedural/Patterns/CraftingTemplatePatternGenerator.cs new file mode 100644 index 00000000..83d6ee34 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Procedural/Patterns/CraftingTemplatePatternGenerator.cs @@ -0,0 +1,80 @@ +using OpenRpg.Core.Associations; +using OpenRpg.Core.Requirements; +using OpenRpg.Core.Templates; +using OpenRpg.Entities.Extensions; +using OpenRpg.Entities.Procedural.Patterns; +using OpenRpg.Genres.Fantasy.Types; +using OpenRpg.Items.Templates; +using OpenRpg.Items.TradeSkills; +using OpenRpg.Items.TradeSkills.Templates; +using OpenRpg.Items.TradeSkills.Extensions; + +namespace OpenRpg.Demos.Web.Pages.Procedural.Patterns; + +public class CraftingTemplatePatternGenerator : ITemplatePatternGenerator +{ + public ITemplateAccessor TemplateAccessor { get; } + + public CraftingTemplatePatternGenerator(ITemplateAccessor templateAccessor) + { + TemplateAccessor = templateAccessor; + } + + public IReadOnlyCollection Generate(ItemCraftingTemplatePatternGeneratorConfig config) + { + var itemCraftingTemplates = new List(); + var id = config.StartingId; + var patternIds = config.PatternIds; + + for (var i = 0; i < patternIds.Length; i++) + { + var patternId = patternIds[i]; + + var relatedPatternItemTemplates = TemplateAccessor + .GetAll() + .Where(x => x.Variables.PatternId == patternId) + .ToArray(); + + var inputTemplate = relatedPatternItemTemplates.First(x => x.Variables.PatternTypeId == config.InputPatternTypeId); + var inputItemEntry = new TradeSkillItemEntry { TemplateId = inputTemplate.Id }; + inputItemEntry.Variables.Amount = config.AmountRequired; + var inputItems = new List { inputItemEntry }; + + var outputTemplate = relatedPatternItemTemplates.First(x => x.Variables.PatternTypeId == config.OutputPatternTypeId); + var outputItemEntry = new TradeSkillItemEntry { TemplateId = outputTemplate.Id }; + outputItemEntry.Variables.Amount = config.AmountOutput; + var outputItems = new List { outputItemEntry }; + + var nameLocaleId = config.LocaleGenerator.GenerateNameLocaleId(patternId, config.TypeCode); + var descriptionLocaleId = config.LocaleGenerator.GenerateDescriptionLocaleId(patternId, config.TypeCode); + + var template = new ItemCraftingTemplate(); + template.Id = id++; + // TODO: Need to fix how this gets generated + //template.NameLocaleId = nameLocaleId; + //template.DescriptionLocaleId = descriptionLocaleId; + template.InputItems = inputItems; + template.OutputItems = outputItems; + template.Variables.PatternId = patternId; + template.Variables.PatternTypeId = config.PatternTypeId; + template.Variables.SkillType = config.SkillType; + + var skillDifficulty = (int)Math.Round(config.SkillScore.Plot(i)); + if(skillDifficulty > 0) + { + template.Variables.Requirements = new List + { + new() + { + RequirementType = FantasyRequirementTypes.TradeSkillRequirement, + Association = new Association(config.SkillType, skillDifficulty) + } + }; + } + + itemCraftingTemplates.Add(template); + } + + return itemCraftingTemplates; + } +} \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/Pages/Procedural/Patterns/ItemCraftingTemplatePatternGeneratorConfig.cs b/src/OpenRpg.Demos.Web/Pages/Procedural/Patterns/ItemCraftingTemplatePatternGeneratorConfig.cs new file mode 100644 index 00000000..54f4a5c3 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Procedural/Patterns/ItemCraftingTemplatePatternGeneratorConfig.cs @@ -0,0 +1,15 @@ +using OpenRpg.CurveFunctions.Scaling; +using OpenRpg.Entities.Procedural.Patterns; + +namespace OpenRpg.Demos.Web.Pages.Procedural.Patterns; + +public class ItemCraftingTemplatePatternGeneratorConfig : PatternGeneratorVariables +{ + public int StartingId { get; set; } + public int SkillType { get; set; } + public ScalingFunction SkillScore { get; set; } + public int InputPatternTypeId { get; set; } + public int OutputPatternTypeId { get; set; } + public int AmountRequired { get; set; } + public int AmountOutput { get; set; } +} \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/Pages/Procedural/Patterns/ItemTemplatePatternGenerator.cs b/src/OpenRpg.Demos.Web/Pages/Procedural/Patterns/ItemTemplatePatternGenerator.cs new file mode 100644 index 00000000..9c9f2efc --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Procedural/Patterns/ItemTemplatePatternGenerator.cs @@ -0,0 +1,45 @@ +using OpenRpg.Entities.Extensions; +using OpenRpg.Entities.Procedural.Patterns; +using OpenRpg.Genres.Fantasy.Types; +using OpenRpg.Items.Templates; +using OpenRpg.Items.Extensions; + +namespace OpenRpg.Demos.Web.Pages.Procedural.Patterns; + +public class ItemTemplatePatternGenerator : ITemplatePatternGenerator +{ + public IReadOnlyCollection Generate(ItemTemplatePatternGeneratorConfig config) + { + var itemTemplates = new List(); + var id = config.StartingId; + var patternIds = config.PatternIds; + for (var i = 0; i < patternIds.Length; i++) + { + var patternId = patternIds[i]; + var nameLocaleId = config.LocaleGenerator.GenerateNameLocaleId(patternId, config.TypeCode); + var descriptionLocaleId = config.LocaleGenerator.GenerateDescriptionLocaleId(patternId, config.TypeCode); + + var template = new ItemTemplate + { + Id = id++, + NameLocaleId = nameLocaleId, + DescriptionLocaleId = descriptionLocaleId, + ItemType = config.ItemType + }; + template.Variables.AssetCode = nameLocaleId.ToLower().Replace(" ", "-"); + template.Variables.QualityType = FantasyItemQualityTypes.CommonQuality; + template.Variables.PatternTypeId = config.PatternTypeId; + template.Variables.PatternId = patternId; + + if (config.Effects is not null) + { + var resultingEffects = config.Effects.GenerateEffectsFrom(i); + template.Variables.Effects = resultingEffects; + } + + itemTemplates.Add(template); + } + + return itemTemplates; + } +} \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/Pages/Procedural/Patterns/ItemTemplatePatternGeneratorConfig.cs b/src/OpenRpg.Demos.Web/Pages/Procedural/Patterns/ItemTemplatePatternGeneratorConfig.cs new file mode 100644 index 00000000..0ac86582 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Procedural/Patterns/ItemTemplatePatternGeneratorConfig.cs @@ -0,0 +1,11 @@ +using OpenRpg.Entities.Procedural.Effects; +using OpenRpg.Entities.Procedural.Patterns; + +namespace OpenRpg.Demos.Web.Pages.Procedural.Patterns; + +public class ItemTemplatePatternGeneratorConfig : PatternGeneratorVariables +{ + public int StartingId { get; set; } + public int ItemType { get; set; } + public ProceduralEffects Effects { get; set; } +} \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/Pages/Procedural/ProceduralItems.razor b/src/OpenRpg.Demos.Web/Pages/Procedural/ProceduralItems.razor index 3819298c..84c562df 100644 --- a/src/OpenRpg.Demos.Web/Pages/Procedural/ProceduralItems.razor +++ b/src/OpenRpg.Demos.Web/Pages/Procedural/ProceduralItems.razor @@ -1,4 +1,5 @@ @page "/procedural/procedural-items" + @using Newtonsoft.Json @using OpenRpg.Core.Utils @using OpenRpg.CurveFunctions @@ -6,12 +7,11 @@ @using OpenRpg.Demos.Infrastructure.Templates @using OpenRpg.Entities.Effects @using OpenRpg.Entities.Extensions -@using OpenRpg.Entities.Procedural +@using OpenRpg.Entities.Procedural.Effects.Builders @using OpenRpg.Entities.Types @using OpenRpg.Genres.Fantasy.Types @using OpenRpg.Items @using OpenRpg.Items.Extensions -@using Range = OpenRpg.Core.Utils.Range @using OpenRpg.Items.Templates @inject IRandomizer Randomizer; @@ -37,21 +37,46 @@ To start with we need to setup our `ProceduralEffects` configuration, here is an example: ```csharp - var possibleEffects = new List<GroupedEffect> - { - new() { GroupType = CoreProceduralGroupTypes.Primary, EffectType = FantasyEffectTypes.BluntDamageAmount, PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(20, 30), RangeF.ZeroToOneHundred), ScalingType = CoreEffectScalingTypes.Value }, - new() { GroupType = CoreProceduralGroupTypes.Primary, EffectType = FantasyEffectTypes.PiercingDamageAmount, PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(20, 30), RangeF.ZeroToOneHundred), ScalingType = CoreEffectScalingTypes.Value }, - new() { EffectType = FantasyEffectTypes.StrengthBonusAmount, PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(1, 3), RangeF.ZeroToOneHundred), ScalingType = CoreEffectScalingTypes.Value }, - new() { EffectType = FantasyEffectTypes.DexterityBonusAmount, PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(1, 3), RangeF.ZeroToOneHundred), ScalingType = CoreEffectScalingTypes.Value }, - new() { EffectType = FantasyEffectTypes.FireDamageAmount, PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(1, 5), RangeF.ZeroToOneHundred), ScalingType = CoreEffectScalingTypes.Value }, - new() { EffectType = FantasyEffectTypes.WindDamageAmount, PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(1, 5), RangeF.ZeroToOneHundred), ScalingType = CoreEffectScalingTypes.Value }, - }; - - var proceduralEffects = new ProceduralEffects() - { - EffectAmount = new Range(1, 3), - Effects = possibleEffects, - }; + var proceduralEffects = ProceduralEffectsBuilder + .Create() + .WithPrimaryEffect(new ScaledEffect() + { + EffectType = FantasyEffectTypes.BluntDamageAmount, + PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(20, 30), RangeF.ZeroToOneHundred), + ScalingType = CoreEffectScalingTypes.Value + }) + .WithPrimaryEffect(new ScaledEffect() + { + EffectType = FantasyEffectTypes.PiercingDamageAmount, + PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(20, 30), RangeF.ZeroToOneHundred), + ScalingType = CoreEffectScalingTypes.Value + }) + .WithOptionalEffect(new ScaledEffect() + { + EffectType = FantasyEffectTypes.StrengthBonusAmount, + PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(1, 3), RangeF.ZeroToOneHundred), + ScalingType = CoreEffectScalingTypes.Value + }) + .WithOptionalEffect(new ScaledEffect() + { + EffectType = FantasyEffectTypes.DexterityBonusAmount, + PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(1, 3), RangeF.ZeroToOneHundred), + ScalingType = CoreEffectScalingTypes.Value + }) + .WithOptionalEffect(new ScaledEffect() + { + EffectType = FantasyEffectTypes.FireDamageAmount, + PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(1, 5), RangeF.ZeroToOneHundred), + ScalingType = CoreEffectScalingTypes.Value + }) + .WithOptionalEffect(new ScaledEffect() + { + EffectType = FantasyEffectTypes.WindDamageAmount, + PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(1, 5), RangeF.ZeroToOneHundred), + ScalingType = CoreEffectScalingTypes.Value + }) + .WithEffectAmount(1, 3) + .Build(); ``` I know its a lot of code but its quite simple in whats going on, its making 2 `Primary` effects to choose from, which means you will ALWAYS get 1 of them @@ -66,11 +91,14 @@ ```csharp var proceduralTemplate = new ItemTemplate() { - Id = 1, - NameLocaleId = "Magical Random Sword", - ItemType = FantasyItemTypes.GenericWeapon + Id = 1, + NameLocaleId = "Magical Random Sword", + ItemType = FantasyItemTypes.GenericWeapon }; proceduralTemplate.Variables.ProceduralEffects(proceduralEffects); + + // Then we call + var proceduralItemData = proceduralItemTemplate.GenerateProceduralInstance(Randomizer); ``` Now we have done that, we can call `proceduralTemplate.GenerateProceduralInstance(Randomizer)` which will use the procedural config to randomly @@ -144,23 +172,49 @@ protected override void OnInitialized() { TemplateAccessor = new ManualTemplateAccessor(); - var possibleEffects = new List - { - new() { GroupType = CoreProceduralGroupTypes.Primary, EffectType = FantasyEffectTypes.BluntDamageAmount, PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(20, 30), RangeF.ZeroToOneHundred), ScalingType = CoreEffectScalingTypes.Value }, - new() { GroupType = CoreProceduralGroupTypes.Primary, EffectType = FantasyEffectTypes.PiercingDamageAmount, PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(20, 30), RangeF.ZeroToOneHundred), ScalingType = CoreEffectScalingTypes.Value }, - new() { EffectType = FantasyEffectTypes.StrengthBonusAmount, PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(1, 3), RangeF.ZeroToOneHundred), ScalingType = CoreEffectScalingTypes.Value }, - new() { EffectType = FantasyEffectTypes.DexterityBonusAmount, PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(1, 3), RangeF.ZeroToOneHundred), ScalingType = CoreEffectScalingTypes.Value }, - new() { EffectType = FantasyEffectTypes.FireDamageAmount, PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(1, 5), RangeF.ZeroToOneHundred), ScalingType = CoreEffectScalingTypes.Value }, - new() { EffectType = FantasyEffectTypes.WindDamageAmount, PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(1, 5), RangeF.ZeroToOneHundred), ScalingType = CoreEffectScalingTypes.Value }, - }; - - BluntScalingFunction = possibleEffects[0].PotencyFunction; - var proceduralEffects = new ProceduralEffects() - { - EffectAmount = new Range(1, 3), - Effects = possibleEffects, - }; + var proceduralEffects = ProceduralEffectsBuilder + .Create() + .WithPrimaryEffect(new ScaledEffect() + { + EffectType = FantasyEffectTypes.BluntDamageAmount, + PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(20, 30), RangeF.ZeroToOneHundred), + ScalingType = CoreEffectScalingTypes.Value + }) + .WithPrimaryEffect(new ScaledEffect() + { + EffectType = FantasyEffectTypes.PiercingDamageAmount, + PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(20, 30), RangeF.ZeroToOneHundred), + ScalingType = CoreEffectScalingTypes.Value + }) + .WithOptionalEffect(new ScaledEffect() + { + EffectType = FantasyEffectTypes.StrengthBonusAmount, + PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(1, 3), RangeF.ZeroToOneHundred), + ScalingType = CoreEffectScalingTypes.Value + }) + .WithOptionalEffect(new ScaledEffect() + { + EffectType = FantasyEffectTypes.DexterityBonusAmount, + PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(1, 3), RangeF.ZeroToOneHundred), + ScalingType = CoreEffectScalingTypes.Value + }) + .WithOptionalEffect(new ScaledEffect() + { + EffectType = FantasyEffectTypes.FireDamageAmount, + PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(1, 5), RangeF.ZeroToOneHundred), + ScalingType = CoreEffectScalingTypes.Value + }) + .WithOptionalEffect(new ScaledEffect() + { + EffectType = FantasyEffectTypes.WindDamageAmount, + PotencyFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(1, 5), RangeF.ZeroToOneHundred), + ScalingType = CoreEffectScalingTypes.Value + }) + .WithEffectAmount(1, 3) + .Build(); + + BluntScalingFunction = new ScalingFunction(PresetCurves.BellCurve, new RangeF(20, 30), RangeF.ZeroToOneHundred); proceduralItemTemplate = new ItemTemplate() { @@ -168,9 +222,9 @@ NameLocaleId = "Magical Random Sword", ItemType = FantasyItemTypes.GenericWeapon }; - proceduralItemTemplate.Variables.AssetCode("sword"); - proceduralItemTemplate.Variables.ProceduralEffects(proceduralEffects); - proceduralItemTemplate.Variables.QualityType(FantasyItemQualityTypes.EpicQuality); + proceduralItemTemplate.Variables.AssetCode = "sword"; + proceduralItemTemplate.Variables.ProceduralEffects = proceduralEffects; + proceduralItemTemplate.Variables.QualityType = FantasyItemQualityTypes.EpicQuality; TemplateAccessor.AddTemplate(proceduralItemTemplate); RefreshProceduralItem(); diff --git a/src/OpenRpg.Demos.Web/Pages/Quests/BasicQuestComponents.razor b/src/OpenRpg.Demos.Web/Pages/Quests/BasicQuestComponents.razor index 54400cc4..36b05e0b 100644 --- a/src/OpenRpg.Demos.Web/Pages/Quests/BasicQuestComponents.razor +++ b/src/OpenRpg.Demos.Web/Pages/Quests/BasicQuestComponents.razor @@ -9,6 +9,7 @@ @using OpenRpg.Genres.Fantasy.Types @using OpenRpg.Genres.Types @using OpenRpg.Quests +@using OpenRpg.Quests.Variables @inject ITemplateAccessor TemplateAccessor @@ -77,6 +78,39 @@ > There are many ways you can link together quests or have atomic quests which are simple to start and finish, you can also extend them with your own meta data via the variables if needed. + + ### Objective Verification + + The library provides an `ICharacterObjectiveChecker` that verifies objectives against four contexts: + + - **Character**: checks level, class, active effects (stateless) + - **Quest State**: checks if prerequisite quests are complete (stateless) + - **Trigger State**: checks if trigger objectives are met (stateless) + - **Objective State**: checks accumulated progress like kill counts and item collections (stateful) + + ```csharp + // Check a single objective against all contexts + var met = ObjectiveChecker.IsObjectiveMet(character, objective); + + // Batch check all objectives for a quest + var allMet = ObjectiveChecker.AreObjectivesMet(character, questData); + ``` + + ### Objective State Tracking + + For objectives that track accumulated progress (killing enemies, collecting items), the `IObjectiveState` + stores per-quest-per-objective progress counts: + + ```csharp + // Update progress when game events happen + character.Variables.ObjectiveState.AddObjectiveProgress(questId, objectiveIndex, 1); + + // Check if an objective is complete + var complete = character.Variables.ObjectiveState.IsObjectiveComplete(questId, objectiveIndex, requiredAmount); + ``` + + > Progress is push-based: your game code explicitly calls `AddObjectiveProgress` when events happen + > (enemy killed, item picked up, etc). This keeps the system flexible for any event source. ### Rewards Rewards are what the player will get from completing the quest, and you may not always want to show all of the rewards based on their type. In most cases @@ -124,7 +158,7 @@ @code { - public Quest _basicQuest; + public QuestTemplate _basicQuest; protected override void OnInitialized() { @@ -132,9 +166,9 @@ base.OnInitialized(); } - private Quest CreateBasicQuest() + private QuestTemplate CreateBasicQuest() { - var quest = new Quest + var quest = new QuestTemplate { Id = 1, NameLocaleId = "A Simple Quest", @@ -150,7 +184,7 @@ new() {RewardType = FantasyRewardTypes.CurrencyReward, Association = new Association(0, 50)} } }; - quest.Variables.AssetCode("default-quest"); + quest.Variables.AssetCode = "default-quest"; return quest; } diff --git a/src/OpenRpg.Demos.Web/Pages/Quests/FactionReputationDemo.razor b/src/OpenRpg.Demos.Web/Pages/Quests/FactionReputationDemo.razor new file mode 100644 index 00000000..b5af74b8 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Quests/FactionReputationDemo.razor @@ -0,0 +1,266 @@ +@page "/quests/faction-reputation" + +@using OpenRpg.Core.Associations +@using OpenRpg.Core.Requirements +@using OpenRpg.Data +@using OpenRpg.Demos.Infrastructure.Data +@using OpenRpg.Entities.Extensions +@using OpenRpg.Genres.Characters +@using OpenRpg.Genres.Extensions +@using OpenRpg.Genres.Requirements +@using OpenRpg.Localization.Data.Repositories +@using OpenRpg.Quests +@using OpenRpg.Quests.Extensions +@using OpenRpg.Quests.Factions +@using OpenRpg.Quests.State +@using OpenRpg.Quests.Types + +@inject DemoCharacterBuilder CharacterBuilder +@inject ICharacterRequirementChecker RequirementChecker +@inject ILocaleRepository LocaleRepository +@inject IDataSource DataSource + + + ## Factions & Reputation + + The faction system allows you to track how an entity is perceived by different organisations in the world. + Each faction is a `DefaultFaction` template with an ID, name, and description. An entity's standing with + each faction is stored as a reputation score in a `FactionReputation` dictionary (faction ID → reputation int). + + Reputation is accessed via `EntityVariables.FactionReputation` (entity variable key 40) and mutated + with the `AddReputation()` extension method. You can gate quests, dialogue, or content behind faction + reputation thresholds using `FactionStateRequirement` (requirement type 62). + +
+ + + ## Reputation Tiers + + Reputation scores can range from negative (hostile) to positive (allied). A common convention: + + - **-100 to -51**: Hostile. Attacks on sight. + - **-50 to -11**: Unfriendly. Higher prices, limited services. + - **-10 to 10**: Neutral. Default standing. + - **11 to 50**: Friendly. Discounts, access to faction quests. + - **51 to 100**: Allied. Full trust, unique rewards. + +
+ + + ## Interactive Faction Demo + + Below is a character with three factions. Use the buttons to gain or lose reputation, then test + whether the character meets a reputation threshold using the requirement checker. + +
+ +
+
+ +

Character Standing

+
@_character.NameLocaleId
+
+ + @foreach (var faction in _factions) + { + var rep = GetReputation(faction.Id); + var repClass = GetReputationClass(rep); + var factionVar = faction; + +
+
+
+
@faction.NameLocaleId
+

@faction.DescriptionLocaleId

+
+
+ @rep +
+
+
+
+ +
+
+ +
+
+ +
+
+
+ } +
+
+ +
+ +

Requirement Checker

+
Test if the character meets a faction reputation requirement
+
+ +
+ +
+
+ +
+
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+ + @if (_requirementResult.HasValue) + { +
+ @(_requirementResult.Value ? "Requirement Met" : "Requirement Not Met") +

+ Faction: @_factions.First(f => f.Id == _selectedFactionId).NameLocaleId | + Required: >= @_requiredReputation | + Current: @GetReputation(_selectedFactionId) +

+
+ } +
+ + +

Quest with Faction Gate

+
This quest requires Friendly standing with the Merchants Guild
+
+ +
+
Special Trade Discount
+

The Merchants Guild offers a special discount to trusted allies.

+
+

+ Requirement: Merchants Guild reputation >= 30 +

+

+ Your standing: + + @GetReputation(FactionDataGenerator.MerchantsGuildId) + +

+
+ +
+
+
+
+
+ + + ### How It Works + + ```csharp + // Access faction reputation on an entity + var factionRep = character.Variables.FactionReputation; + + // Add reputation (starts at 0 for new factions) + factionRep.AddReputation(FactionDataGenerator.MerchantsGuildId, 10); + + // Check current reputation + var rep = factionRep[FactionDataGenerator.MerchantsGuildId]; // 10 + + // Create a faction requirement + var requirement = new Requirement + { + RequirementType = QuestRequirementTypes.FactionStateRequirement, + Association = new Association(FactionDataGenerator.MerchantsGuildId, 30) + }; + + // Check if character meets the requirement + var met = RequirementChecker.IsRequirementMet(character, requirement); + ``` + +
+ + + ### Key Types + + #### DefaultFaction + Faction template: holds Id, NameLocaleId, and DescriptionLocaleId. Stored in `IDataSource` and loaded at startup. + + #### FactionReputation + A `Variables<int>` dictionary mapping faction ID to reputation score. Accessed via `EntityVariables.FactionReputation` (entity variable key 40). + + #### FactionReputationExtensions + Provides `AddReputation(factionId, amount)` for convenient reputation mutation. Delegates to `AddValue()` under the hood. + + #### QuestRequirementTypes.FactionStateRequirement + Requirement type ID 62. Uses `AssociatedId` as the faction ID and `AssociatedValue` as the minimum reputation threshold. + + +@code { + private Character _character; + private List _factions = new(); + private int _selectedFactionId = FactionDataGenerator.MerchantsGuildId; + private int _requiredReputation = 30; + private bool? _requirementResult; + + private bool _canAcceptGuildQuest => GetReputation(FactionDataGenerator.MerchantsGuildId) >= 30; + + protected override void OnInitialized() + { + _character = CharacterBuilder.CreateNew().Build(); + _factions = DataSource.GetAll().ToList(); + base.OnInitialized(); + } + + private int GetReputation(int factionId) + { + if (!_character.Variables.HasFactionReputation()) return 0; + var rep = _character.Variables.FactionReputation; + if (!rep.ContainsKey(factionId)) return 0; + return rep[factionId]; + } + + private void AddReputation(int factionId, int amount) + { + _character.Variables.FactionReputation.AddReputation(factionId, amount); + StateHasChanged(); + } + + private void ResetReputation(int factionId) + { + _character.Variables.FactionReputation[factionId] = 0; + StateHasChanged(); + } + + private void CheckRequirement() + { + var requirement = new Requirement + { + RequirementType = QuestRequirementTypes.FactionStateRequirement, + Association = new Association(_selectedFactionId, _requiredReputation) + }; + _requirementResult = RequirementChecker.IsRequirementMet(_character, requirement); + } + + private string GetReputationClass(int rep) + { + if (rep < -50) return "is-danger"; + if (rep < -10) return "is-warning"; + if (rep <= 10) return "is-light"; + if (rep <= 50) return "is-info"; + return "is-success"; + } +} diff --git a/src/OpenRpg.Demos.Web/Pages/Quests/ObjectiveTrackingDemo.razor b/src/OpenRpg.Demos.Web/Pages/Quests/ObjectiveTrackingDemo.razor new file mode 100644 index 00000000..d52ba305 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Quests/ObjectiveTrackingDemo.razor @@ -0,0 +1,430 @@ +@page "/quests/objective-tracking" + +@using OpenRpg.Core.Associations +@using OpenRpg.Core.Requirements +@using OpenRpg.Demos.Infrastructure.Data +@using OpenRpg.Demos.Infrastructure.Lookups +@using OpenRpg.Entities.Extensions +@using OpenRpg.Genres.Characters +@using OpenRpg.Genres.Extensions +@using OpenRpg.Genres.Fantasy.Types +@using OpenRpg.Genres.Objectives +@using OpenRpg.Genres.Requirements +@using OpenRpg.Genres.Types +@using OpenRpg.Quests +@using OpenRpg.Quests.Extensions +@using OpenRpg.Quests.Objectives +@using OpenRpg.Quests.State +@using OpenRpg.Quests.Types +@using OpenRpg.Quests.Variables +@using QuestVars = OpenRpg.Quests.Variables.QuestTemplateVariables + +@inject DemoCharacterBuilder CharacterBuilder +@inject ICharacterObjectiveChecker ObjectiveChecker + + + ## Objective Tracking + + Now that we understand quest structure and state, we need to verify whether a player has completed each objective. + The `ICharacterObjectiveChecker` provides a formalised system for this, following the same pattern as `ICharacterRequirementChecker`. + + ### Stateless vs Stateful Objectives + + **Stateless objectives** are verified against live game state each time they are checked: + - `TriggerObjective`: is the trigger set? + - `QuestObjective`: is the prerequisite quest complete? + - `LevelObjective`: is the character high enough level? + - `ClassObjective`: does the character have the right class? + - `EffectObjective`: does the character have the active effect? + + **Stateful objectives** track accumulated progress over time: + - `ItemObjective`: have you collected N items? + - `EnemyDefeatedObjective`: have you defeated N enemies? + - `CurrencyObjective`: have you collected N currency? + +
+ +
+
+ +

How The Checker Works

+
+ + The `ICharacterObjectiveChecker` has four overloads: + + ```csharp + // Character context: level, class, effects + IsObjectiveMet(Character, Objective) + + // Quest state context: prerequisite quests + IsObjectiveMet(IReadOnlyList<QuestData>, Objective) + + // Trigger context: trigger objectives + IsObjectiveMet(ITriggerState, Objective) + + // Objective state context: kill counts, item collections + IsObjectiveMet(ObjectiveState, Objective, questId, objectiveIndex) + ``` + +
+
+
+ +

Batch Checking

+
+ + Use `AreObjectivesMet` to check all objectives across all contexts at once: + + ```csharp + // Checks character + questState + triggerState + objectiveState + var allMet = ObjectiveChecker + .AreObjectivesMet(character, questTemplate, questData); + ``` + + This is what gates the "Complete Quest" button. + +
+
+
+
+ + + ## Interactive Objective Demo + + Below are five quests showcasing different objective types. Select a quest to see its objectives, + check their status, and simulate progress for stateful objectives. + +
+ +
+
+ +

Quest Log

+
Click a quest to interact with it
+
+ @foreach (var questData in _quests) + { + var template = _questTemplates[questData.TemplateId]; + var state = questData.State; + var stateName = GetStateName(state); + var stateClass = GetStateClass(state); + var isSelected = _selectedQuest?.TemplateId == questData.TemplateId; + var qd = questData; + +
+
+
+
@template.NameLocaleId
+

@template.DescriptionLocaleId

+
+
+ @stateName +
+
+
+ } +
+
+
+ + @if (_selectedQuest != null) + { + var selectedTemplate = _questTemplates[_selectedQuest.TemplateId]; + var state = _selectedQuest.State; +

@selectedTemplate.NameLocaleId

+
State: @GetStateName(state)
+
+ + @if (selectedTemplate.Objectives.Any()) + { +
Objectives
+ @foreach (var (obj, index) in selectedTemplate.Objectives.Select((o, i) => (o, i))) + { + var progress = _selectedQuest.GetObjectiveProgress(index); + var charMet = ObjectiveChecker.IsObjectiveMet(_character, obj); + var questMet = ObjectiveChecker.IsObjectiveMet(_character.Variables.ActiveQuests, obj); + var trigMet = ObjectiveChecker.IsObjectiveMet(_character.Variables.TriggerState, obj); + var objMet = ObjectiveChecker.IsObjectiveMet(_selectedQuest.ObjectiveState, obj, selectedTemplate.Id, index); + var allMet = charMet && questMet && trigMet && objMet; +
+
+
+ @(allMet ? "Done" : "Todo") +  @GetObjectiveText(obj, progress) +
+ @if (state == QuestStateTypes.QuestActive) + { +
+ @if (IsStatefulObjective(obj)) + { + + +1 + + } + else if (obj.ObjectiveType == ObjectiveTypes.TriggerObjective) + { + var triggered = _character.Variables.TriggerState.HasTriggered(obj.Association.AssociatedId); + + @(triggered ? "Triggered" : "Set Trigger") + + } + else if (obj.ObjectiveType == ObjectiveTypes.LevelObjective) + { + + Set Level @obj.Association.AssociatedValue + + } + else if (obj.ObjectiveType == ObjectiveTypes.QuestObjective) + { + var prereqQuest = _character.Variables.ActiveQuests.FirstOrDefault(q => q.TemplateId == obj.Association.AssociatedId); + var prereqComplete = prereqQuest?.State == QuestStateTypes.QuestComplete; + + @(prereqComplete ? "Done" : "Complete Quest") + + } +
+ } +
+
+ Character + QuestState + Trigger + ObjectiveState +
+
+ } +
+ } + +
+ @if (state == QuestStateTypes.QuestNotStarted) + { + + Accept Quest + + } + else if (state == QuestStateTypes.QuestActive) + { + var canComplete = ObjectiveChecker.AreObjectivesMet(_character, _questTemplates[_selectedQuest.TemplateId], _selectedQuest); + + Complete Quest + + } + else + { + Quest Completed + } +
+ } + else + { +

Select a Quest

+
Click a quest on the left to see details
+ } +
+
+
+
+ + + ## Push-Based Progress Updates + + Progress on stateful objectives is updated explicitly by game code. This keeps the system flexible; + your game decides when to push progress, whether that's from an event system, a combat handler, or anything else. + + ```csharp + // When the player kills an enemy + questData.AddObjectiveProgress(objectiveIndex, 1); + + // When the player collects items + questData.AddObjectiveProgress(objectiveIndex, amount); + + // Check if an objective is complete + var complete = questData.IsObjectiveComplete(objectiveIndex, requiredAmount); + + // Reset progress when accepting a quest + questData.ClearObjectives(quest.Objectives.Count); + ``` + +
+ + + ## QuestData Lifecycle + + `QuestData` consolidates the quest template, its runtime state, and objective progress into a single object: + + ```csharp + // Create when accepting a quest + var questData = new QuestData(template.Id); + questData.ClearObjectives(template.Objectives.Count); + + // During gameplay: push progress + questData.AddObjectiveProgress(0, 1); + + // Check completion + var canComplete = ObjectiveChecker.AreObjectivesMet(character, questTemplate, questData); + + // Complete + questData.State = QuestStateTypes.QuestComplete; + ``` + + > The `QuestData` class is a simple wrapper. You can store it wherever makes sense for your game + > (on the entity, in a player session, in a quest log service, etc). The `ObjectiveState` is + > standalone and can be used independently of `QuestData` if needed. + + +@code { + private Character _character; + private List _quests = new(); + private QuestData _selectedQuest; + private Dictionary _questTemplates = new(); + + private const int GoblinKillQuestId = 100; + private const int HerbCollectionQuestId = 200; + private const int TalkToElderQuestId = 300; + private const int ReachLevelQuestId = 400; + private const int ProveWorthQuestId = 500; + + private const int ElderTriggerId = 10; + + protected override void OnInitialized() + { + _character = CharacterBuilder.CreateNew().Build(); + _quests = CreateQuests(); + base.OnInitialized(); + } + + private List CreateQuests() + { + var goblinQuest = MakeQuest(GoblinKillQuestId, "Goblin Hunt", "Defeat 3 goblins terrorising the village outskirts.", + new List { new() { ObjectiveType = GenresObjectiveTypes.EnemyDefeatedObjective, Association = new Association(99, 3) } }, + new List { new() { RewardType = FantasyRewardTypes.ExperienceReward, Association = new Association(0, 300) } }); + + var herbQuest = MakeQuest(HerbCollectionQuestId, "Herb Collection", "Gather 5 moonshade herbs for the village healer.", + new List { new() { ObjectiveType = ObjectiveTypes.ItemObjective, Association = new Association(ItemTemplateLookups.HealingPotion, 5) } }, + new List { new() { RewardType = GenreRewardTypes.CurrencyReward, Association = new Association(0, 100) } }); + + var elderQuest = MakeQuest(TalkToElderQuestId, "Speak to the Elder", "The Elder has information about the goblin threat. Speak to him first.", + new List { new() { ObjectiveType = ObjectiveTypes.TriggerObjective, Association = new Association(ElderTriggerId, 1) } }, + new List { new() { RewardType = FantasyRewardTypes.ExperienceReward, Association = new Association(0, 50) } }); + + var levellingQuest = MakeQuest(ReachLevelQuestId, "Prove Your Strength", "Reach level 5 to join the warrior guild.", + new List { new() { ObjectiveType = ObjectiveTypes.LevelObjective, Association = new Association(0, 5) } }, + new List { new() { RewardType = FantasyRewardTypes.ExperienceReward, Association = new Association(0, 200) } }); + + var guildQuest = MakeQuest(ProveWorthQuestId, "Guild Initiation", "Complete the Goblin Hunt to prove your worth to the guild.", + new List { new() { ObjectiveType = ObjectiveTypes.QuestObjective, Association = new Association(GoblinKillQuestId, 1) } }, + new List { new() { RewardType = GenreRewardTypes.CurrencyReward, Association = new Association(0, 500) } }); + + return new List + { + new() { TemplateId = goblinQuest.Id }, + new() { TemplateId = herbQuest.Id }, + new() { TemplateId = elderQuest.Id }, + new() { TemplateId = levellingQuest.Id }, + new() { TemplateId = guildQuest.Id } + }; + } + + private QuestTemplate MakeQuest(int id, string name, string description, List objectives, List rewards) + { + var template = new QuestTemplate + { + Id = id, + NameLocaleId = name, + DescriptionLocaleId = description, + IsRepeatable = false, + Objectives = objectives, + Rewards = rewards, + Variables = new QuestVars() + }; + _questTemplates[id] = template; + return template; + } + + private void SelectQuest(QuestData questData) => _selectedQuest = questData; + + private void AcceptQuest(QuestData questData) + { + var template = _questTemplates[questData.TemplateId]; + questData.State = QuestStateTypes.QuestActive; + questData.ClearObjectives(template.Objectives.Count); + } + + private void CompleteQuest(QuestData questData) + { + questData.State = QuestStateTypes.QuestComplete; + } + + private void SimulateProgress(QuestData questData, int objectiveIndex) + { + questData.AddObjectiveProgress(objectiveIndex, 1); + } + + private void ToggleTrigger(int triggerId) + { + var current = _character.Variables.TriggerState.HasTriggered(triggerId); + _character.Variables.TriggerState.SetTrigger(triggerId, !current); + } + + private void SetLevel(int level) + { + if (_character.Variables.HasClass()) + { _character.Variables.Class.Variables.Level = level; } + } + + private void CompletePrerequisiteQuest(int questId) + { + var questData = _character.Variables.ActiveQuests.FirstOrDefault(q => q.TemplateId == questId); + if (questData != null) + { questData.State = QuestStateTypes.QuestComplete; } + } + + private bool IsStatefulObjective(Objective obj) + { + return obj.ObjectiveType == ObjectiveTypes.ItemObjective + || obj.ObjectiveType == GenresObjectiveTypes.EnemyDefeatedObjective + || obj.ObjectiveType == GenresObjectiveTypes.EnemySightedObjective + || obj.ObjectiveType == GenresObjectiveTypes.CurrencyObjective; + } + + private string GetStateName(int state) + { + if (state == QuestStateTypes.QuestNotStarted) return "Not Started"; + if (state == QuestStateTypes.QuestActive) return "Active"; + if (state == QuestStateTypes.QuestComplete) return "Complete"; + return "Unknown"; + } + + private string GetStateClass(int state) + { + if (state == QuestStateTypes.QuestNotStarted) return "is-light"; + if (state == QuestStateTypes.QuestActive) return "is-warning"; + if (state == QuestStateTypes.QuestComplete) return "is-success"; + return "is-dark"; + } + + private string GetObjectiveText(Objective obj, int progress) + { + var required = obj.Association.AssociatedValue; + if (obj.ObjectiveType == ObjectiveTypes.TriggerObjective) + { + var triggered = _character.Variables.TriggerState.HasTriggered(obj.Association.AssociatedId); + return $"Trigger objective {(triggered ? "(set)" : "(not set)")}"; + } + if (obj.ObjectiveType == ObjectiveTypes.LevelObjective) return $"Reach level {required}"; + if (obj.ObjectiveType == ObjectiveTypes.ClassObjective) return "Change class"; + if (obj.ObjectiveType == ObjectiveTypes.EffectObjective) return "Gain an effect"; + if (obj.ObjectiveType == ObjectiveTypes.QuestObjective) + { + var prereqQuest = _character.Variables.ActiveQuests.FirstOrDefault(q => q.TemplateId == obj.Association.AssociatedId); + var complete = prereqQuest?.State == QuestStateTypes.QuestComplete; + return $"Complete prerequisite quest {(complete ? "(done)" : "(pending)")}"; + } + if (obj.ObjectiveType == ObjectiveTypes.ItemObjective) return $"Collect items ({progress}/{required})"; + if (obj.ObjectiveType == GenresObjectiveTypes.EnemyDefeatedObjective) return $"Defeat enemies ({progress}/{required})"; + if (obj.ObjectiveType == GenresObjectiveTypes.EnemySightedObjective) return $"Sight enemies ({progress}/{required})"; + if (obj.ObjectiveType == GenresObjectiveTypes.CurrencyObjective) return $"Collect currency ({progress}/{required})"; + return "Complete objective"; + } +} diff --git a/src/OpenRpg.Demos.Web/Pages/Quests/QuestStateDemo.razor b/src/OpenRpg.Demos.Web/Pages/Quests/QuestStateDemo.razor new file mode 100644 index 00000000..9abddfb8 --- /dev/null +++ b/src/OpenRpg.Demos.Web/Pages/Quests/QuestStateDemo.razor @@ -0,0 +1,477 @@ +@page "/quests/quest-state" + +@using OpenRpg.Core.Associations +@using OpenRpg.Core.Templates +@using OpenRpg.Core.Requirements +@using OpenRpg.Demos.Infrastructure.Data +@using OpenRpg.Demos.Infrastructure.Lookups +@using OpenRpg.Entities.Extensions +@using OpenRpg.Genres.Characters +@using OpenRpg.Genres.Extensions +@using OpenRpg.Genres.Fantasy.Types +@using OpenRpg.Genres.Objectives +@using OpenRpg.Genres.Requirements +@using OpenRpg.Genres.Types +@using OpenRpg.Localization.Data.Repositories +@using OpenRpg.Quests +@using OpenRpg.Quests.Extensions +@using OpenRpg.Quests.Objectives +@using OpenRpg.Quests.State +@using OpenRpg.Quests.Types +@using OpenRpg.Quests.Variables +@using QuestVars = OpenRpg.Quests.Variables.QuestTemplateVariables + +@inject DemoCharacterBuilder CharacterBuilder +@inject ICharacterRequirementChecker RequirementChecker +@inject ICharacterObjectiveChecker ObjectiveChecker +@inject ILocaleRepository LocaleRepository +@inject ITemplateAccessor TemplateAccessor + + + ## Quest State & Objectives + + Now that we understand quest structure, we need to track which quests a player has accepted, completed, or not started yet, + and track progress on each objective within those quests. + + The library uses a `QuestData` object per quest, stored in a list on the entity's variables: + + - **`List<QuestData>`** (ActiveQuests): each `QuestData` holds a template ID, state int, and per-objective progress + - **`ITriggerState`**: maps trigger IDs to booleans (dialogue flags, story gates) + + Objective progress is tracked within each `QuestData` using a composite key that combines template ID and objective index. + The `ICharacterObjectiveChecker` verifies objectives against character state, triggers, quest list, and objective progress: + + ```csharp + // Check if all objectives are met for a quest + var allMet = ObjectiveChecker.AreObjectivesMet(character, questTemplate, questData); + + // Update progress for a stateful objective (e.g., killed 1 more goblin) + questData.AddObjectiveProgress(objectiveIndex, 1); + ``` + +
+ + + ## Interactive Quest Tracker + + Below is a demo showing three quests with different objective types. You can accept quests, simulate progress, + and complete them. The objective checker validates completion using the same pattern as requirement checking. + +
+ +
+
+ +

Quest Log

+
Click a quest to interact with it
+
+ @foreach (var questData in _quests) + { + var state = questData.State; + var stateName = GetStateName(state); + var stateClass = GetStateClass(state); + var template = _questTemplates[questData.TemplateId]; + var isSelected = _selectedQuest?.TemplateId == questData.TemplateId; + var qd = questData; + +
+
+
+
@template.NameLocaleId
+

@template.DescriptionLocaleId

+
+
+ @stateName +
+
+
+ } +
+
+
+ + @if (_selectedQuest != null) + { + var selectedTemplate = _questTemplates[_selectedQuest.TemplateId]; + var state = _selectedQuest.State; +

@selectedTemplate.NameLocaleId

+
State: @GetStateName(state)
+
+ + @if (selectedTemplate.Variables.HasRequirements()) + { +
Requirements
+ @foreach (var req in selectedTemplate.Variables.Requirements) + { + var met = RequirementChecker.AreRequirementsMet(_character, new[] { req }); +
+
+ @(met ? "Met" : "Not Met") +  @GetRequirementText(req) +
+
+ } +
+ } + + @if (selectedTemplate.Objectives.Any()) + { +
Objectives
+ @foreach (var (obj, index) in selectedTemplate.Objectives.Select((o, i) => (o, i))) + { + var progress = _selectedQuest.GetObjectiveProgress(index); + var isMet = ObjectiveChecker.IsObjectiveMet(_character, obj) + && ObjectiveChecker.IsObjectiveMet(_character.Variables.ActiveQuests, obj) + && ObjectiveChecker.IsObjectiveMet(_character.Variables.TriggerState, obj) + && ObjectiveChecker.IsObjectiveMet(_selectedQuest.ObjectiveState, obj, selectedTemplate.Id, index); +
+
+ @(isMet ? "Done" : "Todo") +  @GetObjectiveText(obj, progress) +
+ @if (state == QuestStateTypes.QuestActive && !isMet && IsStatefulObjective(obj)) + { + + } +
+ } +
+ } + + @if (selectedTemplate.Rewards.Any()) + { +
Rewards
+ @foreach (var reward in selectedTemplate.Rewards) + { +
+
+ Reward +  @GetRewardText(reward) +
+
+ } +
+ } + +
+ @if (state == QuestStateTypes.QuestNotStarted) + { + var canAccept = CanAcceptQuest(selectedTemplate); + + Accept Quest + + } + else if (state == QuestStateTypes.QuestActive) + { + var canComplete = ObjectiveChecker.AreObjectivesMet(_character, selectedTemplate, _selectedQuest); + + Complete Quest + + } + else + { + Quest Completed + } +
+ } + else + { +

Select a Quest

+
Click a quest on the left to see details
+ } +
+
+
+
+ + + ## Trigger System + + Triggers are boolean flags that represent story events ("spoken to NPC", "found the map", "unlocked the door"). + They gate quest acceptance and progression. Toggle the triggers below to see how they affect the quest requirements. + +
+ +
+
+ +

Trigger Flags

+
Toggle triggers to see requirement changes
+
+
+ + +
+
+ + +
+
+
+
+ +

Requirement Status

+
Live validation against current triggers
+
+ @{ + var elderReq = new Requirement { RequirementType = QuestRequirementTypes.TriggerRequirement, Association = new Association(QuestStateDataGenerator.SpokenToElderTriggerId, 1) }; + var mapReq = new Requirement { RequirementType = QuestRequirementTypes.TriggerRequirement, Association = new Association(QuestStateDataGenerator.FoundMapTriggerId, 1) }; + var elderMet = RequirementChecker.IsRequirementMet(_character.Variables.TriggerState, elderReq); + var mapMet = RequirementChecker.IsRequirementMet(_character.Variables.TriggerState, mapReq); + } +
+
+ @(elderMet ? "Met" : "Not Met") +  Spoken to Elder (Trigger 1) +
+
+
+
+ @(mapMet ? "Met" : "Not Met") +  Found the Map (Trigger 2) +
+
+
+
+
+
+ + + ## How Requirement Checking Works + + The `ICharacterRequirementChecker` has three overloads: one for character properties, one for quest state, and one for triggers. + The `AreRequirementsMet` extension method checks all three at once (character, quest list, and trigger state) + by pulling the active quests and trigger state from the character: + + ```csharp + // Checks character + quest state + trigger requirements in one call + var allMet = RequirementChecker.AreRequirementsMet(character, quest.Variables.Requirements); + ``` + + The quest state checker (`IsRequirementMet(IReadOnlyList<QuestData>, Requirement)`) validates that a quest is in the expected state. + The trigger checker (`IsRequirementMet(ITriggerState, Requirement)`) validates that trigger flags match expected values. + + > Its worth noting that this is for simple scenarios where the quest/trigger state is tracked against an individual character + > however it could be that your game is party based and instead there is a player/game level quest state/trigger which would + > need to use the separate requirement checker methods for quest/trigger state etc. + +
+ + + ## How Objective Checking Works + + The `ICharacterObjectiveChecker` follows the same pattern as requirements: four overloads checking different contexts. + + ```csharp + // Stateless checks: verified from live character state + ObjectiveChecker.IsObjectiveMet(character, objective) // level, class, effects + ObjectiveChecker.IsObjectiveMet(quests, objective) // quest completion prerequisites + ObjectiveChecker.IsObjectiveMet(triggerState, objective) // trigger-based objectives + + // Stateful checks: verified from accumulated progress + ObjectiveChecker.IsObjectiveMet(objectiveState, objective, questId, objectiveIndex) // item counts, kill counts + + // Batch check: all objectives for a quest + var allMet = ObjectiveChecker.AreObjectivesMet(character, questTemplate, questData); + ``` + + Progress is pushed explicitly by game code when events happen: + + ```csharp + // When player kills an enemy + questData.AddObjectiveProgress(objectiveIndex, 1); + + // When player collects an item + questData.AddObjectiveProgress(objectiveIndex, amount); + + // Check if objective is complete + var complete = questData.IsObjectiveComplete(objectiveIndex, requiredAmount); + ``` + +
+ + + ## Setting State in Code + + Here is how you would typically manage quest state in a real game: + + ```csharp + // Accept a quest + questData.State = QuestStateTypes.QuestActive; + questData.ClearObjectives(questTemplate.Objectives.Count); + + // Track objective progress (push-based) + questData.AddObjectiveProgress(0, 1); // +1 to first objective + + // Complete a quest + questData.State = QuestStateTypes.QuestComplete; + ``` + + +@code { + private Character _character; + private List _quests = new(); + private QuestData _selectedQuest; + private Dictionary _questTemplates = new(); + + private bool _spokenToElder = false; + private bool _foundMap = false; + + protected override void OnInitialized() + { + _character = CharacterBuilder.CreateNew().Build(); + _quests = CreateQuests(); + base.OnInitialized(); + } + + private List CreateQuests() + { + var artifactQuest = MakeQuest(QuestStateDataGenerator.FindArtifactQuestId, "Find the Lost Artifact", "The Elder knows of an ancient artifact hidden in the ruins. Speak to him first to learn its location.", + new List { new() { ObjectiveType = ObjectiveTypes.TriggerObjective, Association = new Association(QuestStateDataGenerator.FoundMapTriggerId, 1) } }, + new List + { + new() { RewardType = FantasyRewardTypes.ExperienceReward, Association = new Association(0, 200) }, + new() { RewardType = GenreRewardTypes.CurrencyReward, Association = new Association(0, 150) } + }, + new List { new() { RequirementType = QuestRequirementTypes.TriggerRequirement, Association = new Association(QuestStateDataGenerator.SpokenToElderTriggerId, 1) } }); + + var goblinQuest = MakeQuest(QuestStateDataGenerator.DefeatGoblinQuestId, "Defeat the Goblin Chief", "The goblins have been raiding caravans. Slay their chief to end the threat.", + new List { new() { ObjectiveType = GenresObjectiveTypes.EnemyDefeatedObjective, Association = new Association(99, 3) } }, + new List + { + new() { RewardType = FantasyRewardTypes.ExperienceReward, Association = new Association(0, 300) }, + new() { RewardType = GenreRewardTypes.ItemReward, Association = new Association(ItemTemplateLookups.SuperSword, 1) } + }); + + var letterQuest = MakeQuest(QuestStateDataGenerator.DeliverLetterQuestId, "Deliver the Letter", "A simple errand. Deliver a sealed letter to the merchant on the other side of town.", + new List { new() { ObjectiveType = ObjectiveTypes.ItemObjective, Association = new Association(ItemTemplateLookups.Chest, 1) } }, + new List + { + new() { RewardType = FantasyRewardTypes.ExperienceReward, Association = new Association(0, 50) }, + new() { RewardType = GenreRewardTypes.CurrencyReward, Association = new Association(0, 25) } + }); + + return new List + { + new() { TemplateId = artifactQuest.Id }, + new() { TemplateId = goblinQuest.Id }, + new() { TemplateId = letterQuest.Id } + }; + } + + private QuestTemplate MakeQuest(int id, string name, string description, List objectives, List rewards, List requirements = null) + { + var template = new QuestTemplate + { + Id = id, + NameLocaleId = name, + DescriptionLocaleId = description, + IsRepeatable = false, + Objectives = objectives, + Rewards = rewards, + Variables = new QuestVars() + }; + if (requirements != null) + { + template.Variables.Requirements = requirements; + } + _questTemplates[id] = template; + return template; + } + + private void SelectQuest(QuestData questData) + { + _selectedQuest = questData; + } + + private bool CanAcceptQuest(QuestTemplate quest) + { + if (quest.Variables.HasRequirements()) + { return RequirementChecker.AreRequirementsMet(_character, quest.Variables.Requirements); } + return true; + } + + private void AcceptQuest(QuestData questData) + { + var template = _questTemplates[questData.TemplateId]; + questData.State = QuestStateTypes.QuestActive; + questData.ClearObjectives(template.Objectives.Count); + } + + private void CompleteQuest(QuestData questData) + { + questData.State = QuestStateTypes.QuestComplete; + } + + private void SimulateProgress(QuestData questData, int objectiveIndex) + { + questData.AddObjectiveProgress(objectiveIndex, 1); + } + + private void ToggleTrigger(int triggerId, ref bool currentValue) + { + currentValue = !currentValue; + _character.Variables.TriggerState.SetTrigger(triggerId, currentValue); + } + + private string GetStateName(int state) + { + if (state == QuestStateTypes.QuestNotStarted) return "Not Started"; + if (state == QuestStateTypes.QuestActive) return "Active"; + if (state == QuestStateTypes.QuestComplete) return "Complete"; + return "Unknown"; + } + + private string GetStateClass(int state) + { + if (state == QuestStateTypes.QuestNotStarted) return "is-light"; + if (state == QuestStateTypes.QuestActive) return "is-warning"; + if (state == QuestStateTypes.QuestComplete) return "is-success"; + return "is-dark"; + } + + private string GetRequirementText(Requirement req) + { + if (req.RequirementType == QuestRequirementTypes.TriggerRequirement) + { + var triggerName = req.Association.AssociatedId == QuestStateDataGenerator.SpokenToElderTriggerId ? "Spoken to Elder" : "Found the Map"; + return $"Requires: {triggerName}"; + } + if (req.RequirementType == QuestRequirementTypes.QuestStateRequirement) + { + return $"Requires quest state: {req.Association.AssociatedValue}"; + } + return $"Requirement type {req.RequirementType}"; + } + + private bool IsStatefulObjective(Objective obj) + { + return obj.ObjectiveType == ObjectiveTypes.ItemObjective + || obj.ObjectiveType == GenresObjectiveTypes.EnemyDefeatedObjective + || obj.ObjectiveType == GenresObjectiveTypes.EnemySightedObjective + || obj.ObjectiveType == GenresObjectiveTypes.CurrencyObjective; + } + + private string GetObjectiveText(Objective obj, int progress) + { + var required = obj.Association.AssociatedValue; + if (obj.ObjectiveType == ObjectiveTypes.TriggerObjective) return "Complete a trigger objective"; + if (obj.ObjectiveType == ObjectiveTypes.ItemObjective) return $"Collect required items ({progress}/{required})"; + if (obj.ObjectiveType == GenresObjectiveTypes.EnemyDefeatedObjective) return $"Defeat enemies ({progress}/{required})"; + if (obj.ObjectiveType == GenresObjectiveTypes.EnemySightedObjective) return $"Sight enemies ({progress}/{required})"; + if (obj.ObjectiveType == GenresObjectiveTypes.CurrencyObjective) return $"Collect currency ({progress}/{required})"; + return "Complete objective"; + } + + private string GetRewardText(Reward reward) + { + if (reward.RewardType == FantasyRewardTypes.ExperienceReward) return $"{reward.Association.AssociatedValue} Experience"; + if (reward.RewardType == GenreRewardTypes.CurrencyReward) return $"{reward.Association.AssociatedValue} Gold"; + if (reward.RewardType == GenreRewardTypes.ItemReward) return $"Item: {reward.Association.AssociatedId}"; + return "Unknown reward"; + } +} diff --git a/src/OpenRpg.Demos.Web/Pages/TradeSkills/CraftingTradeSkills.razor b/src/OpenRpg.Demos.Web/Pages/TradeSkills/CraftingTradeSkills.razor index bcd25f76..d24ac0ef 100644 --- a/src/OpenRpg.Demos.Web/Pages/TradeSkills/CraftingTradeSkills.razor +++ b/src/OpenRpg.Demos.Web/Pages/TradeSkills/CraftingTradeSkills.razor @@ -1,7 +1,6 @@ @page "/trade-skills/crafting" @using OpenRpg.Core.Templates -@using OpenRpg.Items.TradeSkills @using OpenRpg.Demos.Infrastructure.Lookups @using OpenRpg.Items.TradeSkills.Extensions @using OpenRpg.Genres.Characters @@ -10,7 +9,9 @@ @using OpenRpg.Items.TradeSkills.Calculator @using OpenRpg.Items.TradeSkills.Templates @using OpenRpg.Core.Utils +@using OpenRpg.Entities.Extensions @using OpenRpg.Genres.Fantasy.Extensions +@using OpenRpg.Genres.Fantasy.Types @using OpenRpg.Items.Extensions @using OpenRpg.Items.Templates @using OpenRpg.Items.TradeSkills.State @@ -47,21 +48,9 @@ In this case we want to craft `Copper Ingots` which require 5x`Copper Ore` per ingot. -
- @foreach (var inputItem in CopperIngotCraftingTemplate.InputItems) - { -
- -
- } -
Makes
- @foreach (var outputItem in CopperIngotCraftingTemplate.OutputItems) - { -
- -
- } -
+
+ +
@@ -79,21 +68,9 @@ so click the buttons below to give yourself some `Copper Ingots` and `Oak Logs` and keep leveling your smithing skill to 10. -
- @foreach (var inputItem in CopperSwordCraftingTemplate.InputItems) - { -
- -
- } -
Makes
- @foreach (var outputItem in CopperSwordCraftingTemplate.OutputItems) - { -
- -
- } -
+
+ +
@@ -123,7 +100,7 @@
- +
@if (IsCrafting) @@ -154,7 +131,7 @@ public bool IsCrafting { get; set; } public ItemCraftingTemplate CurrentTemplate { get; set; } - public float CurrentGatherTime => (CurrentTemplate?.TimeToComplete / 2 ?? 0.0f); + public float CurrentGatherTime => (CurrentTemplate?.Variables.TimeToAction / 2 ?? 0.0f); public string CurrentCraftName { get @@ -166,9 +143,9 @@ public bool CanCraftCopperIngot => CraftingInventory.HasItemsRequiredFor(CopperIngotCraftingTemplate); public bool HasMatsForCopperSword => CraftingInventory.HasItemsRequiredFor(CopperSwordCraftingTemplate); - public bool HasSkillsForCopperSword => RequirementsChecker.AreRequirementsMet(DummyCharacter, CopperSwordCraftingTemplate); + public bool HasSkillsForCopperSword => RequirementsChecker.AreRequirementsMet(DummyCharacter, CopperSwordCraftingTemplate.Variables.Requirements); - public TradeSkillState TradeSkillState => DummyCharacter.Variables.TradeSkillState(); + public TradeSkillState TradeSkillState => DummyCharacter.Variables.TradeSkillState; protected override void OnInitialized() { @@ -177,11 +154,11 @@ CopperSwordCraftingTemplate = TemplateAccessor.GetItemCraftingTemplate(ItemCraftingTemplateLookups.CopperSword); CopperIngotCraftingTemplate = TemplateAccessor.GetItemCraftingTemplate(ItemCraftingTemplateLookups.CopperIngot); - CraftingInventory.Variables.MaxSlots(50); + CraftingInventory.Variables.MaxSlots = 50; GiveCopperOre(10); - DummyCharacter.Variables.TradeSkillState(new TradeSkillState()); - TradeSkillState.Smithing(1); + DummyCharacter.Variables.TradeSkillState = new TradeSkillState(); + TradeSkillState.Smithing = 1; base.OnInitialized(); } @@ -189,7 +166,7 @@ public void GiveCopperOre(int amount = 20) { var copperOreItem = TemplateAccessor.ToInstance(new ItemData { TemplateId = ItemTemplateLookups.CopperOre }); - copperOreItem.Data.Variables.Amount(amount); + copperOreItem.Data.Variables.Amount = amount; CraftingInventory.AttemptAddItem(copperOreItem); CraftingInventoryRef?.Refresh(); } @@ -197,7 +174,7 @@ public void GiveOakLog(int amount = 1) { var oakLog = TemplateAccessor.ToInstance(new ItemData { TemplateId = ItemTemplateLookups.OakLog }); - oakLog.Data.Variables.Amount(amount); + oakLog.Data.Variables.Amount = amount; CraftingInventory.AttemptAddItem(oakLog); CraftingInventoryRef.Refresh(); } @@ -226,8 +203,9 @@ CurrentTemplate.CraftFrom(CraftingInventory, TemplateAccessor); ActionBarRef?.Reset(); - var currentSkill = TradeSkillState.Smithing(); - var tradeSkillPoints = TradeSkillCalculator.CalculateSkillUpPointsFor(currentSkill, CurrentTemplate.SkillDifficulty); + var currentSkillScore = TradeSkillState.Smithing; + var skillDifficulty = CurrentTemplate.Variables.Requirements.GetTradeSkillRequirementValue(FantasyTradeSkillTypes.Smithing); + var tradeSkillPoints = TradeSkillCalculator.CalculateSkillUpPointsFor(currentSkillScore, skillDifficulty); TradeSkillState.AddSmithing(tradeSkillPoints); CraftingInventoryRef.Refresh(); diff --git a/src/OpenRpg.Demos.Web/Pages/TradeSkills/GatheringTradeSkills.razor b/src/OpenRpg.Demos.Web/Pages/TradeSkills/GatheringTradeSkills.razor index a767873b..e47aea3e 100644 --- a/src/OpenRpg.Demos.Web/Pages/TradeSkills/GatheringTradeSkills.razor +++ b/src/OpenRpg.Demos.Web/Pages/TradeSkills/GatheringTradeSkills.razor @@ -10,9 +10,12 @@ @using OpenRpg.Items.TradeSkills.Calculator @using OpenRpg.Items.TradeSkills.Templates @using OpenRpg.Core.Utils +@using OpenRpg.Entities.Extensions @using OpenRpg.Genres.Fantasy.Extensions +@using OpenRpg.Genres.Fantasy.Types @using OpenRpg.Items.Extensions @using OpenRpg.Items.TradeSkills.State +@using OpenRpg.Items.TradeSkills.Types @using Inventory = OpenRpg.Items.Inventories.Inventory; @@ -115,7 +118,7 @@ > To make it quicker to test we have disabled the gathering time so you gather immediately unlike previous example
- +
@@ -180,7 +183,7 @@ public bool IsGathering { get; set; } public ItemGatheringTemplate CurrentTemplate { get; set; } - public float CurrentGatherTime => (CurrentTemplate?.TimeToComplete ?? 0.0f); + public float CurrentGatherTime => (CurrentTemplate?.Variables.TimeToAction ?? 0.0f); public string CurrentGatherName { get @@ -190,25 +193,25 @@ } } - public bool CanMineIron => RequirementsChecker.AreRequirementsMet(DummyCharacter, IronOreGatheringTemplate); + public bool CanMineIron => RequirementsChecker.AreRequirementsMet(DummyCharacter, IronOreGatheringTemplate.Variables.Requirements); public TradeSkillItemEntry CopperOreTradeSkillItemEntry => CopperOreGatheringTemplate.OutputItems.First(); public TradeSkillItemEntry OakLogTradeSkillItemEntry => OakLogGatheringTemplate.OutputItems.First(); public TradeSkillItemEntry IronOreTradeSkillItemEntry => IronOreGatheringTemplate.OutputItems.First(); - public TradeSkillState TradeSkillState => DummyCharacter.Variables.TradeSkillState(); + public TradeSkillState TradeSkillState => DummyCharacter.Variables.TradeSkillState; protected override void OnInitialized() { - TradeSkillCalculator = new TradeSkillCalculator(Randomizer) { RandomnessVariance = 0.2f }; + TradeSkillCalculator = new TradeSkillCalculator(Randomizer) { RandomnessVariance = 0.2f, MaximumSkillDifference = 20 }; - Example1Inventory.Variables.MaxSlots(10); - Example2Inventory.Variables.MaxSlots(10); + Example1Inventory.Variables.MaxSlots = 10; + Example2Inventory.Variables.MaxSlots = 10; CopperOreGatheringTemplate = TemplateAccessor.GetItemGatheringTemplate(ItemGatheringTemplateLookups.CopperOre); OakLogGatheringTemplate = TemplateAccessor.GetItemGatheringTemplate(ItemGatheringTemplateLookups.OakLog); IronOreGatheringTemplate = TemplateAccessor.GetItemGatheringTemplate(ItemGatheringTemplateLookups.IronOre); - DummyCharacter.Variables.TradeSkillState(new TradeSkillState()); - TradeSkillState.Mining(1); + DummyCharacter.Variables.TradeSkillState = new TradeSkillState(); + TradeSkillState.Mining = 1; base.OnInitialized(); } @@ -228,8 +231,9 @@ CurrentTemplate.GatherInto(Example2Inventory, TemplateAccessor); Example2InventoryRef.Refresh(); - var currentMiningSkill = TradeSkillState.Mining(); - var tradeSkillPoints = TradeSkillCalculator.CalculateSkillUpPointsFor(currentMiningSkill, CurrentTemplate.SkillDifficulty); + var currentSkillScore = TradeSkillState.Mining; + var skillDifficulty = CurrentTemplate.Variables.Requirements.GetTradeSkillRequirementValue(FantasyTradeSkillTypes.Mining); + var tradeSkillPoints = TradeSkillCalculator.CalculateSkillUpPointsFor(currentSkillScore, skillDifficulty); TradeSkillState.AddMining(tradeSkillPoints); InvokeAsync(StateHasChanged); diff --git a/src/OpenRpg.Demos.Web/Shared/NavMenu.razor b/src/OpenRpg.Demos.Web/Shared/NavMenu.razor index 9be3b5cd..f8a95062 100644 --- a/src/OpenRpg.Demos.Web/Shared/NavMenu.razor +++ b/src/OpenRpg.Demos.Web/Shared/NavMenu.razor @@ -19,11 +19,17 @@ @@ -36,15 +42,20 @@ @@ -64,6 +75,14 @@ + + + @@ -73,6 +92,24 @@
  • Applicators
  • Example Usecase
  • + + + + + + + + + + diff --git a/src/OpenRpg.Demos.Web/wwwroot/css/site.less b/src/OpenRpg.Demos.Web/wwwroot/css/site.less index 5d018651..21909ffd 100644 --- a/src/OpenRpg.Demos.Web/wwwroot/css/site.less +++ b/src/OpenRpg.Demos.Web/wwwroot/css/site.less @@ -79,22 +79,24 @@ ul.menu-list li.has-text-light { .item-quality-1 { border-color: #9d9d9d; } .item-quality-2 { border-color: #000000; } -.item-quality-3 { border-color: #1eff00; } -.item-quality-10 { border-color: #0070dd; } -.item-quality-11 { border-color: #a335ee; } -.item-quality-12 { border-color: #e6cc80; } +.item-quality-3 { border-color: #0070dd; } +.item-quality-10 { border-color: #1eff00; } +.item-quality-11 { border-color: #0070dd; } +.item-quality-12 { border-color: #a335ee; } .item-quality-13 { border-color: #ff8000; } -.item-quality-14 { border-color: #00ccff; } +.item-quality-14 { border-color: #e89eff; } +.item-quality-15 { border-color: #00ccff; } .title.item-quality-1 { color: #000000 !important; } .title.item-quality-2 { color: #000000 !important; } -.title.item-quality-3 { color: #1eff00 !important; } -.title.item-quality-10 { color: #0070dd !important; } -.title.item-quality-11 { color: #a335ee !important; } -.title.item-quality-12 { color: #e6cc80 !important; } +.title.item-quality-3 { color: #0070dd !important; } +.title.item-quality-10 { color: #1eff00 !important; } +.title.item-quality-11 { color: #0070dd !important; } +.title.item-quality-12 { color: #a335ee !important; } .title.item-quality-13 { color: #ff8000 !important; } -.title.item-quality-14 { color: #00ccff !important; } +.title.item-quality-14 { color: #e89eff !important; } +.title.item-quality-15 { color: #00ccff !important; } .item-icon .item-icon-summary { display: none; @@ -303,4 +305,31 @@ ul.menu-list li.has-text-light { .content pre { white-space: pre-wrap; +} + + +.accordion +{ + &.is-collapsed > div + { + display: none; + } + + &.is-expanded { + & > a + { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + & > div + { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + } + + & > a > span.tag + { + background-color: rgba(255,255,255,0.5); + } } \ No newline at end of file diff --git a/src/OpenRpg.Demos.Web/wwwroot/images/items/boots.png b/src/OpenRpg.Demos.Web/wwwroot/images/items/boots.png new file mode 100644 index 00000000..079ab415 Binary files /dev/null and b/src/OpenRpg.Demos.Web/wwwroot/images/items/boots.png differ diff --git a/src/OpenRpg.Demos.Web/wwwroot/images/items/chest.png b/src/OpenRpg.Demos.Web/wwwroot/images/items/chest.png new file mode 100644 index 00000000..52b109a5 Binary files /dev/null and b/src/OpenRpg.Demos.Web/wwwroot/images/items/chest.png differ diff --git a/src/OpenRpg.Demos.Web/wwwroot/images/items/helm.png b/src/OpenRpg.Demos.Web/wwwroot/images/items/helm.png new file mode 100644 index 00000000..cdc3f9f2 Binary files /dev/null and b/src/OpenRpg.Demos.Web/wwwroot/images/items/helm.png differ diff --git a/src/OpenRpg.Demos.Web/wwwroot/index.html b/src/OpenRpg.Demos.Web/wwwroot/index.html index 6f8ebe55..0f9b6ea6 100644 --- a/src/OpenRpg.Demos.Web/wwwroot/index.html +++ b/src/OpenRpg.Demos.Web/wwwroot/index.html @@ -6,7 +6,7 @@ OpenRpg.Demos.Web - + diff --git a/src/OpenRpg.Editor.Core/Components/Accordion.razor b/src/OpenRpg.Editor.Core/Components/Accordion.razor index 55ec4c26..20fd9d57 100644 --- a/src/OpenRpg.Editor.Core/Components/Accordion.razor +++ b/src/OpenRpg.Editor.Core/Components/Accordion.razor @@ -1,8 +1,14 @@ -
    - - @((MarkupString)Title) +
    + + + @((MarkupString)Title) + @if (BadgeCount.HasValue) + { + @BadgeCount.Value + } + - +
    @@ -29,4 +35,27 @@ [Parameter] public string Title { get; set; } + + [Parameter] + public int? BadgeCount { get; set; } + + private bool _isExpanded; + private bool _initialized; + + protected override void OnInitialized() + { + _isExpanded = IsExpanded; + _initialized = true; + } + + protected override void OnParametersSet() + { + if (!_initialized) + _isExpanded = IsExpanded; + } + + private void Toggle() + { + _isExpanded = !_isExpanded; + } } \ No newline at end of file diff --git a/src/OpenRpg.Editor.Core/Components/Modal/Modal.razor b/src/OpenRpg.Editor.Core/Components/Modal/Modal.razor index 5637d4ec..70aec7ce 100644 --- a/src/OpenRpg.Editor.Core/Components/Modal/Modal.razor +++ b/src/OpenRpg.Editor.Core/Components/Modal/Modal.razor @@ -1,7 +1,7 @@