Battle formulas: action values as stat formulas, costs from stat pools, MP gauge for party members, action schema
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
69390b7743
20 files changed
+724
No files matched your search
@@ -0,0 +1,77 @@
|
||||
namespace Cthangover.Mods.BattleFormulas
|
||||
{
|
||||
using System;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Mods.Stats;
|
||||
using Cthangover.Mods.Stats.Formulas;
|
||||
|
||||
/// <summary>
|
||||
/// What a battle formula can read: <c>a.*</c> is the one acting, <c>b.*</c> the target.
|
||||
/// Each side answers its stat sheet (<c>a.str</c>, <c>b.mag</c>, <c>a.level</c>) and the
|
||||
/// live battle attributes under fixed names — <c>hp</c>, <c>hp_max</c>, <c>attack</c>,
|
||||
/// <c>defence</c>, <c>point</c> — so a formula may hit harder on a wounded target
|
||||
/// (<c>b.hp < b.hp_max / 2</c>) whatever stats the game defines.
|
||||
/// </summary>
|
||||
public class BattleFormulaScope : IFormulaScope
|
||||
{
|
||||
private readonly Character user;
|
||||
private readonly Character target;
|
||||
|
||||
/// <summary>Scope of <paramref name="user"/> acting on <paramref name="target"/> (either may be null).</summary>
|
||||
public BattleFormulaScope(Character user, Character target, Random random = null)
|
||||
{
|
||||
this.user = user;
|
||||
this.target = target;
|
||||
Random = random ?? Random.Shared;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Random Random { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGet(string name, out double value)
|
||||
{
|
||||
value = 0;
|
||||
if (name == null || name.Length < 3 || name[1] != '.')
|
||||
return false;
|
||||
var side = name[0] switch
|
||||
{
|
||||
'a' => user,
|
||||
'b' => target,
|
||||
_ => null
|
||||
};
|
||||
return side != null && Read(side, name.Substring(2), out value);
|
||||
}
|
||||
|
||||
/// <summary>A value of one side: live attributes first, then its stat sheet.</summary>
|
||||
public static bool Read(Character character, string key, out double value)
|
||||
{
|
||||
var attributes = character.Attributes;
|
||||
switch (key)
|
||||
{
|
||||
case "hp":
|
||||
value = attributes?.Health?.Value ?? 0;
|
||||
return true;
|
||||
case "hp_max":
|
||||
value = attributes?.Health?.BaseValue ?? 0;
|
||||
return true;
|
||||
case "attack":
|
||||
value = attributes?.Attack?.Value ?? 0;
|
||||
return true;
|
||||
case "defence":
|
||||
value = attributes?.Defence?.Value ?? 0;
|
||||
return true;
|
||||
case "point":
|
||||
value = attributes?.Point?.Value ?? 0;
|
||||
return true;
|
||||
}
|
||||
var sheet = StatsService.Sheet(character.ID);
|
||||
if (key == "level")
|
||||
{
|
||||
value = character.Level > 0 ? character.Level : sheet.Level;
|
||||
return true;
|
||||
}
|
||||
return sheet.TryGet(key, out value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Cthangover.Mods.BattleFormulas
|
||||
{
|
||||
using Cthangover.Core.Events;
|
||||
using Cthangover.Core.Mods;
|
||||
|
||||
/// <summary>Registers the formula actions when resources are ready and hooks the battle start.</summary>
|
||||
public class BattleFormulasInitializer : IModInitializer
|
||||
{
|
||||
private static bool subscribed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OnModLoaded(string modId)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
FormulaActions.Refresh();
|
||||
if (subscribed)
|
||||
return;
|
||||
subscribed = true;
|
||||
ModEventBus.Subscribe<SceneRouteActivatingEvent>(BattleStatsBridge.OnRouteActivating);
|
||||
ModEventBus.Subscribe<BattleStartedEvent>(BattleStatsBridge.OnBattleStarted);
|
||||
ModEventBus.Subscribe<StatsChangedEvent>(PoolGauge.OnStatsChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace Cthangover.Mods.BattleFormulas
|
||||
{
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Battle;
|
||||
using Cthangover.Core.Events;
|
||||
using Cthangover.Mods.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// The moment a battle starts: formula actions are (re)registered, the party's bound
|
||||
/// stats are synced (a member may have joined in the same dialog that started the
|
||||
/// fight), and enemies whose templates carry stats data (<c>Stats</c>,
|
||||
/// <c>Archetype</c> or <c>Traits</c>) get their battle attributes from their sheets. An
|
||||
/// enemy without stats data keeps its template's numbers, so old content fights as before.
|
||||
/// </summary>
|
||||
public static class BattleStatsBridge
|
||||
{
|
||||
/// <summary>
|
||||
/// The battle route is about to load: the battle scene builds its sides and HUD from
|
||||
/// the attributes right after this, so they get their stats now.
|
||||
/// </summary>
|
||||
public static void OnRouteActivating(SceneRouteActivatingEvent evt)
|
||||
{
|
||||
if (evt?.LogicalName == "Battle")
|
||||
OnBattleStarted(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler of <see cref="BattleStartedEvent"/> (also run on the route activation);
|
||||
/// every step is idempotent, so running twice changes nothing.
|
||||
/// </summary>
|
||||
public static void OnBattleStarted(BattleStartedEvent evt)
|
||||
{
|
||||
FormulaActions.Refresh();
|
||||
StatSync.SyncRoster();
|
||||
foreach (var enemy in BattleRequest.Current?.EnemiesCards?.Where(e => e != null) ?? Enumerable.Empty<Core.Characters.Character>())
|
||||
{
|
||||
if (StatsService.Participates(enemy.ID))
|
||||
StatBindings.Apply(StatsService.Sheet(enemy.ID), enemy.Attributes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
namespace Cthangover.Mods.BattleFormulas
|
||||
{
|
||||
using System;
|
||||
using Cthangover.Core.Cards.StatusEffect;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.Utils;
|
||||
using Cthangover.Mods.Stats;
|
||||
using Cthangover.Mods.Stats.Formulas;
|
||||
|
||||
/// <summary>
|
||||
/// Runs a battle action whose numbers are data. An <c>"Kind": "action"</c> entity with
|
||||
/// these values needs no C# of its own:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Formula=</c> the amount (<c>a.mag * 3 - b.mdf + rand(0, 4)</c>), never below 0;</item>
|
||||
/// <item><c>Effect=damage</c> (default) or <c>heal</c>;</item>
|
||||
/// <item><c>Cost=mp:5</c> spends a pool stat of a party member (enemies pay nothing);
|
||||
/// <c>RequiredPoint=</c> spends action points as every core does;</item>
|
||||
/// <item><c>Pierce=1</c> ignores the target's shield (the consumable defence attribute
|
||||
/// the cards build up), otherwise the shield absorbs first, as in both cores;</item>
|
||||
/// <item><c>State=effect/physics/stun</c> (+ <c>StateTurns=</c>, <c>StateChance=</c> percent) puts a
|
||||
/// status effect on the target.</item>
|
||||
/// </list>
|
||||
/// Status effect hooks see the damage like any core's damage, so passives and statuses
|
||||
/// of other mods keep working. Executors are registered globally, which is why the same
|
||||
/// skill works in the FF core and in the card core: each core's own provider is asked
|
||||
/// first and falls back to the global ones for ids it does not know.
|
||||
/// </summary>
|
||||
public class FormulaActionExecutor : ActionBase
|
||||
{
|
||||
private readonly string id;
|
||||
|
||||
/// <summary>Executor of the action entity <paramref name="actionId"/>.</summary>
|
||||
public FormulaActionExecutor(string actionId)
|
||||
{
|
||||
id = actionId;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ID => id;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ChangedAttributes Execute(ActionCharacter action, Character user, Character target)
|
||||
{
|
||||
if (user == null || action == null)
|
||||
return new ChangedAttributes { Result = false };
|
||||
if (target == null || action.Type == ActionCharacterType.ToSelf)
|
||||
target = user;
|
||||
// The pool is only checked first and spent after the action points, so a refused
|
||||
// action never costs mana.
|
||||
if (!PayCost(action, user, false) || !CheckRequiredAndUsePoint(action, user))
|
||||
return new ChangedAttributes { Result = false };
|
||||
PayCost(action, user, true);
|
||||
|
||||
var amount = Math.Max(0, (int)Math.Round(Formula.Evaluate(action.GetStr("Formula"), new BattleFormulaScope(user, target)), MidpointRounding.AwayFromZero));
|
||||
var result = string.Equals(action.GetStr("Effect"), "heal", StringComparison.OrdinalIgnoreCase)
|
||||
? Heal(target, amount)
|
||||
: Damage(action, user, target, amount);
|
||||
ApplyState(action, target);
|
||||
GameLogger.Log("BATTLE", $"formula action '{id}': {user.ID} -> {target.ID}, amount {amount}, damage {result.Target.Damage}", LogLevel.Debug);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ChangedAttributes Damage(ActionCharacter action, Character user, Character target, int amount)
|
||||
{
|
||||
StatusEffectService.Of(target).OnTakeDamage(user, ref amount);
|
||||
StatusEffectService.Of(user).OnDealDamage(target, ref amount);
|
||||
|
||||
var shield = 0;
|
||||
var pierce = action.GetInt("Pierce") != 0 || user.HasPassiveEffect("IgnoreArmor");
|
||||
if (!pierce && target.Attributes.Defence.Value > 0)
|
||||
{
|
||||
shield = Math.Min(target.Attributes.Defence.Value, amount);
|
||||
amount -= shield;
|
||||
target.Attributes.Defence.Value -= shield;
|
||||
}
|
||||
target.Attributes.Health.Value = Math.Max(0, target.Attributes.Health.Value - amount);
|
||||
return new ChangedAttributes { Result = true, Target = { Damage = amount, Defence = -shield } };
|
||||
}
|
||||
|
||||
private static ChangedAttributes Heal(Character target, int amount)
|
||||
{
|
||||
var health = target.Attributes.Health;
|
||||
var healed = Math.Max(0, Math.Min(amount, health.BaseValue - health.Value));
|
||||
health.Value += healed;
|
||||
return new ChangedAttributes { Result = true, Target = { Damage = healed } };
|
||||
}
|
||||
|
||||
private static bool PayCost(ActionCharacter action, Character user, bool spend)
|
||||
{
|
||||
var cost = action.GetStr("Cost");
|
||||
if (string.IsNullOrWhiteSpace(cost) || StatsService.RosterEntry(user.ID) == null)
|
||||
return true;
|
||||
var colon = cost.IndexOf(':');
|
||||
if (colon <= 0 || !int.TryParse(cost.Substring(colon + 1), out var amount))
|
||||
{
|
||||
if (!spend)
|
||||
GameLogger.Log("BATTLE", $"action '{action.ID}': Cost must look like 'mp:5', got '{cost}'", LogLevel.Error);
|
||||
return true;
|
||||
}
|
||||
var stat = cost.Substring(0, colon).Trim();
|
||||
if (spend)
|
||||
return StatPools.Spend(user.ID, stat, amount);
|
||||
if (StatPools.Current(user.ID, stat) >= amount)
|
||||
return true;
|
||||
GameLogger.Log("BATTLE", $"{user.ID} lacks {stat} for '{action.ID}' ({StatPools.Current(user.ID, stat)} < {amount})", LogLevel.Warning);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void ApplyState(ActionCharacter action, Character target)
|
||||
{
|
||||
var state = action.GetStr("State");
|
||||
if (string.IsNullOrWhiteSpace(state) || target.Attributes.Health.Value <= 0)
|
||||
return;
|
||||
var chance = action.Properties.Values.ContainsKey("StateChance") ? action.GetInt("StateChance") : 100;
|
||||
if (Random.Shared.Next(100) >= chance)
|
||||
return;
|
||||
var turns = action.Properties.Values.ContainsKey("StateTurns") ? action.GetInt("StateTurns") : -1;
|
||||
StatusEffectService.Of(target).Add(state.Trim(), turns);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace Cthangover.Mods.BattleFormulas
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Battle.Actions;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
using Cthangover.Core.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// Finds the action entities that carry a <c>Formula=</c> value and registers a
|
||||
/// <see cref="FormulaActionExecutor"/> for each as a global battle executor. Runs when
|
||||
/// mod resources are ready and again as every battle starts, so an action added in an
|
||||
/// editor or by a freshly loaded mod is playable in the next battle.
|
||||
/// <para/>
|
||||
/// The hub keys executors by action id and replaces on re-registration, so a reload
|
||||
/// simply re-points the ids at this assembly's executors.
|
||||
/// </summary>
|
||||
public static class FormulaActions
|
||||
{
|
||||
private const string FormulaPrefix = "Formula=";
|
||||
|
||||
private static object snapshot;
|
||||
|
||||
/// <summary>The ids of the loaded formula actions.</summary>
|
||||
public static List<string> Ids()
|
||||
=> Entities.Collect<ActionCharacterInfo>().Values
|
||||
.Where(IsFormulaAction).Select(a => a.ID).OrderBy(id => id, System.StringComparer.Ordinal).ToList();
|
||||
|
||||
/// <summary>Whether an action entity is driven by a formula.</summary>
|
||||
public static bool IsFormulaAction(ActionCharacterInfo info)
|
||||
=> info?.Values != null && info.Values.Any(v => v != null && v.StartsWith(FormulaPrefix, System.StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>Registers an executor per formula action (only when the data snapshot changed).</summary>
|
||||
public static void Refresh()
|
||||
{
|
||||
var current = Entities.Collect<ActionCharacterInfo>();
|
||||
if (ReferenceEquals(current, snapshot))
|
||||
return;
|
||||
snapshot = current;
|
||||
var ids = Ids();
|
||||
foreach (var id in ids)
|
||||
ActionExecutorHub.Instance.RegisterGlobal(new FormulaActionExecutor(id));
|
||||
GameLogger.Log("BATTLE", $"battle formulas: {ids.Count} formula action(s) registered");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace Cthangover.Mods.BattleFormulas
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Battle;
|
||||
using Cthangover.Core.Characters;
|
||||
using Cthangover.Core.Events;
|
||||
using Cthangover.Mods.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// The pool stats (<c>"Pool": true</c>, like mana) of party members who take part in
|
||||
/// the stats, as bars of the battle cores: current = what is left after spending, max =
|
||||
/// the sheet value. Hidden stats and enemies (they never pay costs) get none.
|
||||
/// </summary>
|
||||
public class PoolGauge : IBattleUnitGauge
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public int Order => 100;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<BattleGauge> Read(Character unit)
|
||||
{
|
||||
if (unit?.ID == null || StatsService.RosterEntry(unit.ID) == null || !StatsService.Participates(unit.ID))
|
||||
yield break;
|
||||
var sheet = StatsService.Sheet(unit.ID);
|
||||
foreach (var def in StatsService.Definitions.Where(d => d.Pool && !d.Hidden && sheet.Has(d.ID)))
|
||||
{
|
||||
yield return new BattleGauge
|
||||
{
|
||||
Id = def.ID,
|
||||
Label = string.IsNullOrWhiteSpace(def.Short) ? def.Name ?? def.ID : def.Short,
|
||||
Current = (int)Math.Round(StatPools.Current(unit.ID, def.ID), MidpointRounding.AwayFromZero),
|
||||
Max = sheet.Rounded(def.ID),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stats changed (mana spent or refilled): the cores redraw that unit's bars.</summary>
|
||||
public static void OnStatsChanged(StatsChangedEvent evt) => BattleUnitGauges.NotifyChanged(evt?.CharacterId);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user