Game/Upgrades.cs
namespace Monolith;
public enum UpgradeKind
{
DrillSpeed,
BlastRadius,
ChargeChance,
ChargeRadius,
DustYield,
Drones,
Demolition,
// Appended, never inserted. UpgradeKind doubles as the index into the saved level array, so
// adding one in the middle would silently reassign every existing player's upgrades.
DroneRate,
}
public sealed class UpgradeDef
{
public UpgradeKind Kind;
public string Name;
public string Description;
public double BaseCost;
/// <summary>Human readable value at a given level, for the HUD.</summary>
public Func<int, string> Format;
/// <summary>
/// What the NEXT level actually changes, stated as numbers.
///
/// "Shortens the delay between shots" does not tell you whether to spend on it. "0.91s to
/// 0.71s between shots" does. Every purchase in the panel shows its own before and after so
/// the decision can be made without arithmetic.
/// </summary>
public Func<int, string> Gain;
}
public static class Upgrades
{
public static readonly UpgradeDef[] All =
{
new()
{
Kind = UpgradeKind.DrillSpeed,
Name = "Rate of Fire",
Description = "Shortens the delay between shots.",
BaseCost = Tuning.CostDrillSpeed,
// Shown as a DELAY, not a rate. "0.91s between shots" tells you what the opening
// actually feels like; "1.1/s" does not, and the first upgrade is the one that has
// to read as an obvious improvement.
Format = lvl => $"{1f / MathF.Max( 0.01f, DrillSpeed( lvl ) ):0.00}s between shots",
Gain = lvl => $"{Delay( lvl ):0.00}s to {Delay( lvl + 1 ):0.00}s between shots "
+ $"({DrillSpeed( lvl ):0.00} to {DrillSpeed( lvl + 1 ):0.00} shots/sec)",
},
new()
{
Kind = UpgradeKind.BlastRadius,
Name = "Blast Radius",
Description = "How much of the monolith each shot takes.",
BaseCost = Tuning.CostBlastRadius,
Format = lvl => $"{CubesInSphere( BlastRadius( lvl ) ):N0} cubes",
Gain = lvl => $"{CubesInSphere( BlastRadius( lvl ) ):N0} to "
+ $"{CubesInSphere( BlastRadius( lvl + 1 ) ):N0} cubes per shot "
+ $"(radius {BlastRadius( lvl ):0.00} to {BlastRadius( lvl + 1 ):0.00})",
},
new()
{
Kind = UpgradeKind.ChargeChance,
Name = "Instability",
Description = "Seeds the shape with volatile blocks. Hit them directly.",
BaseCost = Tuning.CostChargeChance,
Format = lvl => $"{VolatileChance( lvl ) * 100f:0.0}% of blocks volatile",
Gain = lvl => $"{VolatileChance( lvl ) * 100f:0.0}% to "
+ $"{VolatileChance( lvl + 1 ) * 100f:0.0}% of blocks volatile",
},
new()
{
Kind = UpgradeKind.ChargeRadius,
Name = "Detonation",
Description = "How hard a volatile block goes off when you hit it.",
BaseCost = Tuning.CostChargeRadius,
Format = lvl => $"{CubesInSphere( VolatileRadius( lvl ) ):N0} cubes",
Gain = lvl => $"{CubesInSphere( VolatileRadius( lvl ) ):N0} to "
+ $"{CubesInSphere( VolatileRadius( lvl + 1 ) ):N0} cubes per volatile hit",
},
new()
{
Kind = UpgradeKind.DustYield,
Name = "Dust Yield",
Description = "Dust gained per cube removed.",
BaseCost = Tuning.CostDustYield,
Format = lvl => $"x{DustYield( lvl ):0.00}",
Gain = lvl => $"x{DustYield( lvl ):0.00} to x{DustYield( lvl + 1 ):0.00} dust per cube "
+ $"(+{Tuning.DustYieldPerLevel * 100f:0}% of base)",
},
new()
{
Kind = UpgradeKind.Drones,
Name = "Drones",
Description = "Autonomous miners that fire without you. More of them, in the arch overhead.",
BaseCost = Tuning.CostDrones,
Format = lvl => $"{lvl} active",
Gain = lvl => $"{lvl} to {lvl + 1} drones. Total drone output "
+ $"{DroneOutput( lvl, LiveLevel( UpgradeKind.DroneRate ) ):0.00} to "
+ $"{DroneOutput( lvl + 1, LiveLevel( UpgradeKind.DroneRate ) ):0.00} shots/sec "
+ $"at your current cadence, each at {Tuning.DroneRadiusScale * 100f:0}% blast",
},
new()
{
Kind = UpgradeKind.DroneRate,
Name = "Drone Cadence",
Description = "How fast each drone fires. Cheaper than another drone, and it lifts every one you own.",
BaseCost = Tuning.CostDroneRate,
Format = lvl => $"{DroneFireRate( lvl ):0.00}/s each",
Gain = lvl => $"each drone {DroneFireRate( lvl ):0.00} to {DroneFireRate( lvl + 1 ):0.00} "
+ $"shots/sec. Across your {LiveLevel( UpgradeKind.Drones )} drones that is "
+ $"{DroneOutput( LiveLevel( UpgradeKind.Drones ), lvl ):0.00} to "
+ $"{DroneOutput( LiveLevel( UpgradeKind.Drones ), lvl + 1 ):0.00} shots/sec",
},
new()
{
Kind = UpgradeKind.Demolition,
Name = "Demolition Charge",
Description = "Right click to plant, left click to detonate. 10s cooldown, always. "
+ "Sized as a share of the stage, so it never stops mattering.",
BaseCost = Tuning.CostDemolition,
// Quoted as a PERCENTAGE now, because that is what the ability actually does. A cube
// count would be a different number on every stage and would tell you nothing about
// whether the upgrade was worth buying.
Format = lvl => $"{DemolitionFraction( lvl ) * 100f:0.0}% of the stage",
Gain = lvl => $"{DemolitionFraction( lvl ) * 100f:0.0}% to "
+ $"{DemolitionFraction( lvl + 1 ) * 100f:0.0}% of the stage per detonation "
+ $"(cooldown stays {Tuning.DemolitionCooldown:0}s, "
+ $"capped at {Tuning.DemolitionMonolithFractionMax * 100f:0.#}% on the Monolith)",
},
};
/// <summary>Seconds between shots at a level. The reciprocal of the fire rate.</summary>
public static float Delay( int level ) => 1f / MathF.Max( 0.01f, DrillSpeed( level ) );
/// <summary>Bolts actually drawn for a given projectile count.</summary>
public static int VisibleProjectiles( int projectiles )
=> Math.Clamp( projectiles, 1, Tuning.MaxVisibleProjectiles );
/// <summary>
/// Radius multiplier that pays back the bolts we chose not to draw.
///
/// Total volume removed should not change just because we stopped spawning an object per
/// prestige. N bolts each of radius r remove roughly N * r^3, so folding the surplus into a
/// single radius means scaling by the CUBE ROOT of the ratio. Getting this wrong by using
/// the ratio directly would have made a capped shot wildly stronger than the volley it
/// replaced.
/// </summary>
public static float MultiShotRadiusScale( int projectiles )
{
int drawn = VisibleProjectiles( projectiles );
if ( projectiles <= drawn )
return 1f;
return MathF.Cbrt( projectiles / (float)drawn );
}
/// <summary>Shots per second for a single drone at a given Drone Cadence level.</summary>
public static float DroneFireRate( int level )
=> Tuning.DroneFireRateBase + Tuning.DroneFireRatePerLevel * level;
/// <summary>
/// Total shots per second from the whole flock. The number that actually matters when
/// deciding between another drone and a faster one, which is why both hint lines quote it.
/// </summary>
public static float DroneOutput( int droneLevel, int rateLevel )
=> droneLevel * DroneFireRate( rateLevel );
/// <summary>
/// The player's CURRENT level of another upgrade, so a hint can talk about the real trade
/// rather than a hypothetical one. Drones and Drone Cadence multiply each other, so quoting
/// either in isolation would be misleading in exactly the case the choice matters.
/// </summary>
private static int LiveLevel( UpgradeKind kind )
=> PlayerProgress.Local.IsValid() ? PlayerProgress.Local.LevelOf( kind ) : 0;
public static UpgradeDef Get( UpgradeKind kind ) => All.First( x => x.Kind == kind );
/// <summary>
/// Cost growth is PER UPGRADE, not global. An upgrade whose value scales with the cube of
/// a radius has to get expensive far faster than one that scales linearly, or it dominates
/// every purchase decision and the economy runs away.
/// </summary>
public static double GrowthOf( UpgradeKind kind ) => kind switch
{
UpgradeKind.DrillSpeed => Tuning.GrowthDrillSpeed,
UpgradeKind.BlastRadius => Tuning.GrowthBlastRadius,
UpgradeKind.ChargeChance => Tuning.GrowthChargeChance,
UpgradeKind.ChargeRadius => Tuning.GrowthChargeRadius,
UpgradeKind.DustYield => Tuning.GrowthDustYield,
UpgradeKind.Drones => Tuning.GrowthDrones,
UpgradeKind.DroneRate => Tuning.GrowthDroneRate,
UpgradeKind.Demolition => Tuning.GrowthDemolition,
_ => Tuning.UpgradeCostGrowth,
};
public static double CostAt( UpgradeDef def, int level )
=> def.BaseCost * Math.Pow( GrowthOf( def.Kind ), level );
// ---------------------------------------------------------------- curves
// No clamps anywhere. See the note in Tuning: an upgrade that reads "MAX" is a dead end,
// and every one of these has to stay worth buying at level 500.
public static float DrillSpeed( int level )
=> Tuning.DrillSpeedBase + Tuning.DrillSpeedPerLevel * level;
public static float BlastRadius( int level )
=> Tuning.BlastRadiusBase + Tuning.BlastRadiusPerLevel * level;
/// <summary>
/// Fraction of blocks in the shape that are volatile. Approaches a ceiling without ever
/// reaching it, so there is always another level worth buying.
/// </summary>
public static float VolatileChance( int level )
{
float t = 1f - 1f / (1f + Tuning.VolatileChanceRate * level);
return Tuning.VolatileChanceBase
+ (Tuning.VolatileChanceMax - Tuning.VolatileChanceBase) * t;
}
/// <summary>Blast radius when a volatile block is hit squarely.</summary>
public static float VolatileRadius( int level )
=> Tuning.VolatileRadiusBase + Tuning.VolatileRadiusPerLevel * level;
public static float DustYield( int level )
=> Tuning.DustYieldBase + Tuning.DustYieldPerLevel * level;
/// <summary>
/// Fraction of the stage a charge removes. Approaches
/// <see cref="Tuning.DemolitionFractionMax"/> without reaching it, so this upgrade can never
/// show "MAX" and the increments shrink forever.
/// </summary>
public static float DemolitionFraction( int level )
{
float t = 1f - 1f / (1f + Tuning.DemolitionFractionRate * level);
return Tuning.DemolitionFractionBase
+ (Tuning.DemolitionFractionMax - Tuning.DemolitionFractionBase) * t;
}
/// <summary>
/// The fraction actually applied, given where you are mining.
///
/// The shared Monolith clamps hard: see `Tuning.DemolitionMonolithFractionMax`. A tenth of a
/// 16.7M cube rock per click would turn the game's long communal goal into ten button presses.
/// </summary>
public static float DemolitionFractionFor( int level, bool inMonolith )
{
float fraction = DemolitionFraction( level );
return inMonolith
? MathF.Min( fraction, Tuning.DemolitionMonolithFractionMax )
: fraction;
}
/// <summary>
/// Blast radius, in voxels, that would contain <paramref name="cubes"/> of solid rock.
///
/// Inverting the sphere volume is what turns "a tenth of the stage" into something the blast
/// code can use, since everything downstream works in radii. Solid packing is assumed, so a
/// charge detonated at the SURFACE removes noticeably less than the quoted fraction. That is
/// the whole reason burying it is still the skill.
/// </summary>
public static float RadiusForCubes( double cubes )
{
if ( cubes <= 1 )
return 0.5f;
return (float)Math.Cbrt( cubes * 3.0 / (4.0 * Math.PI) );
}
/// <summary>Cubes a charge is sized to remove against a stage of the given size.</summary>
public static double DemolitionCubes( int level, long stageCubes, bool inMonolith )
=> Math.Max( 1.0, stageCubes * DemolitionFractionFor( level, inMonolith ) );
/// <summary>Approximate voxel count in a sphere of the given radius, for HUD copy.</summary>
public static long CubesInSphere( float radius )
{
if ( radius < 0.5f ) return 1;
return (long)(4.0 / 3.0 * Math.PI * radius * radius * radius);
}
}