Game/DesertPumpGame.cs
namespace DesertPump;
/// <summary>
/// The whole game. Holds the money, the water and the current pump, and is the only
/// thing allowed to change them - the HUD just calls into here and renders the result.
/// </summary>
[Title( "Desert Pump Game" )]
[Category( "Desert Pump" )]
[Icon( "water_drop" )]
public sealed partial class DesertPumpGame : Component
{
/// <summary>The active game, so the HUD doesn't need a wired-up reference.</summary>
public static DesertPumpGame Current { get; private set; }
[Property, Group( "Save" )]
public bool SaveEnabled { get; set; } = true;
[Property, Group( "Save" )]
public string SaveFile { get; set; } = "desertpump.json";
/// <summary>Seconds between autosaves. Progress also saves on shutdown.</summary>
[Property, Group( "Save" ), Range( 2f, 60f )]
public float SaveInterval { get; set; } = 5f;
/// <summary>Longest stretch of time away that still pays out idle water.</summary>
[Property, Group( "Save" ), Range( 0f, 24f )]
public float MaxIdleHours { get; set; } = 4f;
[Property, Group( "Audio" )] public SoundEvent PumpSound { get; set; }
[Property, Group( "Audio" )] public SoundEvent SellSound { get; set; }
[Property, Group( "Audio" )] public SoundEvent UpgradeSound { get; set; }
[Property, Group( "Audio" )] public SoundEvent DeniedSound { get; set; }
/// <summary>Played on a surge. Falls back to the upgrade sting when it's left empty.</summary>
[Property, Group( "Audio" )] public SoundEvent SurgeSound { get; set; }
public double Coins { get; private set; }
public double Water { get; private set; }
/// <summary>Index into <see cref="PumpModel.All"/>.</summary>
public int PumpLevel { get; private set; }
/// <summary>Shop upgrade levels, indexed by <see cref="UpgradeKind"/>.</summary>
readonly int[] upgradeLevels = new int[3];
public bool Muted => PumpSettings.Current.Muted;
public long TotalPumps { get; private set; }
public double TotalEarned { get; private set; }
public double TotalLitresSold { get; private set; }
/// <summary>Litres the pump produced while the game was closed, for the welcome-back note.</summary>
public double IdleCatchUp { get; private set; }
public PumpModel CurrentPump => PumpModel.Get( PumpLevel );
public PumpTier Tier => CurrentPump.Tier;
public PumpModel NextPump => PumpLevel < PumpModel.MaxIndex ? PumpModel.Get( PumpLevel + 1 ) : null;
public bool IsMaxed => NextPump is null;
// ---- effective stats: the tier's base numbers times whatever the shop has sold you
/// <summary>Tank size including the Water Tank upgrade.</summary>
public double TankCapacity => CurrentPump.Tank * MultiplierOf( UpgradeKind.Storage );
/// <summary>Litres per manual pump, including Pump Power.</summary>
public double LitresPerClick => CurrentPump.PerClick * MultiplierOf( UpgradeKind.Power );
/// <summary>Coins per litre, including Market Contacts.</summary>
public double PricePerLitre => CurrentPump.PricePerLitre * MultiplierOf( UpgradeKind.Value );
public bool TankFull => Water >= TankCapacity - 0.0001;
public float TankFraction => TankCapacity <= 0 ? 0f : (float)Math.Clamp( Water / TankCapacity, 0d, 1d );
/// <summary>What the tank is worth right now.</summary>
public double SaleValue => Water * PricePerLitre;
public bool CanSell => Water > 0.0001;
public bool CanUpgrade => NextPump is not null && Coins >= NextPump.Cost;
public int LevelOf( UpgradeKind kind ) => upgradeLevels[(int)kind];
public float MultiplierOf( UpgradeKind kind ) => UpgradeTrack.Get( kind ).MultiplierAt( LevelOf( kind ) );
public bool IsMaxLevel( UpgradeKind kind ) => LevelOf( kind ) >= UpgradeTrack.Get( kind ).MaxLevel;
/// <summary>Cost of the next level, or infinity once the track is maxed.</summary>
public double CostOf( UpgradeKind kind )
{
return IsMaxLevel( kind )
? double.PositiveInfinity
: UpgradeTrack.Get( kind ).CostAt( LevelOf( kind ) );
}
public bool CanBuy( UpgradeKind kind ) => !IsMaxLevel( kind ) && Coins >= CostOf( kind );
/// <summary>
/// Fired on an ordinary manual pump, with the litres gained. A pump that came up a
/// surge fires <see cref="Surged"/> instead - exactly one of the two per click.
/// </summary>
public Action<double> Pumped { get; set; }
/// <summary>Fired on a sale, with the coins gained.</summary>
public Action<double> Sold { get; set; }
/// <summary>Fired after buying a new pump, with the tier we moved to.</summary>
public Action<PumpModel> Upgraded { get; set; }
/// <summary>Fired when an action was refused - tank full, broke, nothing to sell.</summary>
public Action<string> Refused { get; set; }
/// <summary>Fired after buying a shop upgrade.</summary>
public Action<UpgradeTrack> Purchased { get; set; }
TimeSince timeSinceSave;
/// <summary>
/// True once <see cref="Load"/> has run, which only happens in play mode. The copy of
/// this component living in the editor's scene never wakes, so it sits on default
/// zeroes - and without this guard its shutdown save would write those zeroes straight
/// over a real run. Never write state we haven't read.
/// </summary>
bool loaded;
protected override void OnAwake()
{
Current = this;
Load();
}
/// <summary>
/// Quitting tears the scene down, and which of these fires first depends on how you
/// left - closing the window, stopping play mode, or loading another scene. Saving
/// from both, plus after every earn and spend, means there's no way out that loses
/// a run.
/// </summary>
protected override void OnDisabled()
{
Save();
}
protected override void OnDestroy()
{
Save();
if ( Current == this )
Current = null;
}
protected override void OnUpdate()
{
// A code hotload wipes statics without re-running OnAwake, which would leave
// Current null and the HUD showing "no game in the scene" for the rest of the
// session. Cheaper to re-claim it than to debug that every time.
if ( Current != this ) Current = this;
// This is a mouse game - without this the cursor gets locked to the view in
// play mode and none of the buttons can be clicked.
Mouse.Visibility = MouseVisibility.Visible;
// Space bar is a second pump button so you're not glued to the mouse.
if ( Input.Pressed( "Jump" ) )
{
Pump();
}
// The pump and everyone you've hired, together.
var passive = TotalWaterPerSecond * Time.Delta;
if ( passive > 0f )
{
AddWater( passive );
}
TickPressure();
TickGusher();
TickTree();
if ( PumpSettings.Current.AutoSell && TankFull )
{
SellAll();
}
if ( SaveEnabled && timeSinceSave > SaveInterval )
{
Save();
}
}
/// <summary>
/// One manual pump. Returns the litres actually gained - zero means the tank was already full.
/// </summary>
/// <remarks>
/// Pressure and surges only exist on this path. Idle water, workers and the tree all
/// go through <see cref="AddWater"/> untouched, so hands-off income is exactly what
/// it always was.
/// </remarks>
public double Pump()
{
if ( TankFull )
{
Refuse( "Tank is full - sell your water" );
return 0;
}
var surge = RollSurge();
var litres = LitresPerClick * PressureBonus * (surge ? SurgeMultiplier : 1f);
var gained = AddWater( litres );
// Pressure builds even on the click that overflowed the tank - the rhythm is the
// player's, and dropping it because the tank happened to be full feels like a bug.
BumpPressure();
TotalPumps++;
if ( surge )
{
SurgeCount++;
Play( SurgeSound ?? UpgradeSound );
Surged?.Invoke( gained );
}
else
{
Play( PumpSound );
Pumped?.Invoke( gained );
}
return gained;
}
/// <summary>
/// Empty the tank into coins. Returns what we earned.
/// </summary>
public double SellAll()
{
if ( !CanSell )
{
Refuse( "No water to sell" );
return 0;
}
var litres = Water;
var earned = SaleValue;
Water = 0;
Coins += earned;
TotalEarned += earned;
TotalLitresSold += litres;
IdleCatchUp = 0;
Play( SellSound );
Sold?.Invoke( earned );
Save();
return earned;
}
/// <summary>
/// Buy the next pump in the list. Water carries over, clamped to the new tank.
/// </summary>
public bool BuyNextPump()
{
var next = NextPump;
if ( next is null )
{
Refuse( "You own every pump in the desert" );
return false;
}
if ( Coins < next.Cost )
{
Refuse( $"Need {Numbers.Short( next.Cost - Coins )} more coins" );
return false;
}
Coins -= next.Cost;
PumpLevel = next.Index;
Water = Math.Min( Water, TankCapacity );
Play( UpgradeSound );
Upgraded?.Invoke( next );
Save();
return true;
}
/// <summary>Hand over coins from outside the sell loop - rewards, console commands.</summary>
public void AddCoins( double amount )
{
// A NaN or infinity in here poisons the save and every number on screen with
// no way back, so refuse it at the door rather than storing it.
if ( amount <= 0 || double.IsNaN( amount ) || double.IsInfinity( amount ) )
return;
Coins += amount;
TotalEarned += amount;
}
/// <summary>
/// Buy one level of a shop upgrade. Returns false and complains if you can't afford it.
/// </summary>
public bool BuyUpgrade( UpgradeKind kind )
{
var track = UpgradeTrack.Get( kind );
if ( IsMaxLevel( kind ) )
{
Refuse( $"{track.Name} is fully upgraded" );
return false;
}
var cost = CostOf( kind );
if ( Coins < cost )
{
Refuse( $"Need {Numbers.Short( cost - Coins )} more coins" );
return false;
}
Coins -= cost;
upgradeLevels[(int)kind]++;
// A smaller tank than the water in it can't happen, but a bigger one is free room.
Water = Math.Min( Water, TankCapacity );
Play( UpgradeSound );
Purchased?.Invoke( track );
Save();
return true;
}
public void ToggleMute()
{
var settings = PumpSettings.Current;
settings.Muted = !settings.Muted;
settings.Save();
}
/// <summary>Wipe the save and start over from the hand pump.</summary>
public void ResetProgress()
{
Coins = 0;
Water = 0;
PumpLevel = 0;
Array.Clear( upgradeLevels );
ownedBackgrounds.Clear();
ownedBackgrounds.Add( BackgroundStyle.DefaultId );
SelectedBackground = BackgroundStyle.DefaultId;
lastDailyDay = 0;
DailyStreak = 0;
Array.Clear( workerLevels );
TreeHeight = 0;
TreeWater = 0;
treeGrowsAt = 0;
TotalPumps = 0;
TotalEarned = 0;
TotalLitresSold = 0;
IdleCatchUp = 0;
Pressure = 0;
SurgeCount = 0;
Save();
}
/// <summary>Adds water up to the tank limit and returns how much actually fit.</summary>
double AddWater( double amount )
{
var before = Water;
Water = Math.Min( TankCapacity, Water + amount );
return Water - before;
}
void Refuse( string reason )
{
Play( DeniedSound );
Refused?.Invoke( reason );
}
void Play( SoundEvent sound )
{
var volume = PumpSettings.Current.EffectiveSfx;
if ( sound is null || volume <= 0f )
return;
var handle = Sound.Play( sound, (Sandbox.Audio.Mixer)null );
if ( handle.IsValid() )
{
handle.Volume = volume;
}
}
void Load()
{
timeSinceSave = 0;
if ( !SaveEnabled )
return;
// From here on we own the file - a missing save just means a fresh run.
loaded = true;
var save = ReadSave( SaveFile );
// Nothing came back but there is a file on disk, so it is unreadable rather
// than absent - a write cut short by a crash, most likely. The backup below is
// one session behind, which beats handing the player a fresh run and no
// explanation. This is the half of that feature that was missing: the backup
// was being written every load and never once read.
if ( save is null && FileSystem.Data.FileExists( SaveFile ) )
{
save = ReadSave( SaveFile + ".bak" );
Log.Warning( save is null
? $"{SaveFile} is unreadable and so is its backup - starting a fresh run."
: $"{SaveFile} is unreadable - restored the backup from the last session." );
}
if ( save is null )
return;
// Stash the run as we found it. One session of history is enough to rescue a
// player whose save gets eaten by a bug, and it costs one small write per load.
FileSystem.Data.WriteJson( SaveFile + ".bak", save );
Coins = Math.Max( 0, save.Coins );
PumpLevel = Math.Clamp( save.PumpLevel, 0, PumpModel.MaxIndex );
ownedBackgrounds.Clear();
ownedBackgrounds.Add( BackgroundStyle.DefaultId );
foreach ( var id in save.OwnedBackgrounds ?? new List<string>() )
{
ownedBackgrounds.Add( id );
}
SelectedBackground = OwnsBackground( save.SelectedBackground )
? save.SelectedBackground
: BackgroundStyle.DefaultId;
lastDailyDay = save.LastDailyDay;
Array.Clear( workerLevels );
var levels = save.WorkerLevels ?? new List<int>();
for ( var i = 0; i < Math.Min( levels.Count, workerLevels.Length ); i++ )
{
workerLevels[i] = Math.Clamp( levels[i], 0, WorkerType.MaxLevel );
}
TreeHeight = Math.Max( 0, save.TreeHeight );
TreeWater = Math.Max( 0, save.TreeWater );
treeGrowsAt = save.TreeGrowsAt;
DailyStreak = save.DailyStreak;
upgradeLevels[(int)UpgradeKind.Storage] = ClampLevel( save.StorageLevel, UpgradeKind.Storage );
upgradeLevels[(int)UpgradeKind.Power] = ClampLevel( save.PowerLevel, UpgradeKind.Power );
upgradeLevels[(int)UpgradeKind.Value] = ClampLevel( save.ValueLevel, UpgradeKind.Value );
// Levels have to be in before the tank is clamped, or a full tank gets trimmed
// to the un-upgraded size on every load.
Water = Math.Clamp( save.Water, 0, TankCapacity );
TotalPumps = save.TotalPumps;
TotalEarned = save.TotalEarned;
TotalLitresSold = save.TotalLitresSold;
PayIdleTime( save.SavedAt );
}
/// <summary>
/// Read one save file, or null if it isn't there and null if it's rubbish.
/// </summary>
/// <remarks>
/// ReadJsonOrDefault already returns the default on a missing or malformed file, so
/// the catch is only for what it doesn't cover - a locked or unreadable file. Either
/// way a broken save must never take the game down with it: that turns "you lost
/// your progress" into "the game no longer starts".
/// </remarks>
PumpSave ReadSave( string path )
{
try
{
return FileSystem.Data.ReadJsonOrDefault<PumpSave>( path, null );
}
catch ( Exception e )
{
Log.Warning( $"Couldn't read {path}: {e.Message}" );
return null;
}
}
static int ClampLevel( int level, UpgradeKind kind ) =>
Math.Clamp( level, 0, UpgradeTrack.Get( kind ).MaxLevel );
/// <summary>Runs the passive pump for the time we were away, capped so it can't be farmed.</summary>
void PayIdleTime( long savedAt )
{
if ( savedAt <= 0 || TotalWaterPerSecond <= 0 )
return;
var away = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - savedAt;
if ( away <= 0 )
return;
var seconds = Math.Min( away, (long)(MaxIdleHours * 3600f) );
IdleCatchUp = AddWater( TotalWaterPerSecond * seconds );
}
void Save()
{
timeSinceSave = 0;
if ( !SaveEnabled )
return;
// Never overwrite a real run with state we never loaded. See the field comment.
if ( !loaded )
return;
FileSystem.Data.WriteJson( SaveFile, new PumpSave
{
Coins = Coins,
Water = Water,
PumpLevel = PumpLevel,
OwnedBackgrounds = ownedBackgrounds.ToList(),
SelectedBackground = SelectedBackground,
LastDailyDay = lastDailyDay,
WorkerLevels = workerLevels.ToList(),
TreeHeight = TreeHeight,
TreeWater = TreeWater,
TreeGrowsAt = treeGrowsAt,
DailyStreak = DailyStreak,
StorageLevel = LevelOf( UpgradeKind.Storage ),
PowerLevel = LevelOf( UpgradeKind.Power ),
ValueLevel = LevelOf( UpgradeKind.Value ),
Muted = Muted,
TotalPumps = TotalPumps,
TotalEarned = TotalEarned,
TotalLitresSold = TotalLitresSold,
SavedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
} );
}
}