GrandmaGifts.cs
namespace NoChillquarium;
/// <summary>UI prop for the gift item popup.</summary>
public enum GrandmaGiftProp
{
None,
/// <summary>New-game establishing shot — trailer park before the tank.</summary>
Establishing,
Tank,
Wallet,
/// <summary>Combine / fusion unlock (janitor intro).</summary>
Fusion,
/// <summary>Fight unlock (lot bully challenge).</summary>
Fight,
/// <summary>Adventure Mode unlock (flush) — last ladder intro.</summary>
Adventure
}
/// <summary>Where we are in a gift sequence.</summary>
public enum GrandmaGiftPhase
{
/// <summary>Playing; watching for triggers.</summary>
Idle,
/// <summary>RPG typewriter dialogue.</summary>
Dialogue,
/// <summary>Item received popup after dialogue.</summary>
ItemPopup
}
/// <summary>One lot character gift/event: trigger → dialogue → item popup.</summary>
public sealed class GrandmaGiftDef
{
public string Id { get; init; }
public string[] Lines { get; init; }
public string ItemTitle { get; init; }
public string ItemBody { get; init; }
public GrandmaGiftProp Prop { get; init; }
public string PopupButton { get; init; } = "Got it!";
/// <summary>Dialogue nameplate (default Grandma).</summary>
public string Speaker { get; init; } = "Grandma";
/// <summary>Portrait sprite id (default ui_grandma).</summary>
public string PortraitSprite { get; init; } = "ui_grandma";
/// <summary>Optional CSS theme on the dialogue shell (e.g. janitor).</summary>
public string ThemeClass { get; init; } = "";
/// <summary>Min total playtime before auto-check can fire.</summary>
public float MinPlaytime { get; init; }
/// <summary>
/// Seconds of playtime that must pass after the previous gift claim before this one can fire.
/// Lets the player use the last unlock before the next intro. 0 = no gap (onboarding chain).
/// </summary>
public float MinGapAfterPrior { get; init; }
/// <summary>Optional: must already have received this gift id.</summary>
public string RequiresGiftId { get; init; }
/// <summary>Extra world condition (null = only playtime / flags).</summary>
public Func<bool> ExtraReady { get; init; }
/// <summary>
/// Base Ð paid when the scene finishes (item popup closed).
/// Long scenes get a per-line bonus on top — sitting through dialogue pays.
/// </summary>
public double DogeReward { get; init; }
/// <summary>Extra karma on claim (on top of anything in OnClaimed).</summary>
public float KarmaReward { get; init; }
/// <summary>Run when the player closes the item popup (after cash/karma grant).</summary>
public Action OnClaimed { get; init; }
}
/// <summary>
/// Play around → hit a gift trigger → Grandma dialogue → item popup.
/// One-shot per save. Extend <see cref="Catalog"/> for more gifts.
/// </summary>
public static class GrandmaGifts
{
/// <summary>Fast typewriter — click still skips / advances hard.</summary>
public const float TypeCharsPerSecond = 96f;
static readonly HashSet<string> _received = new( StringComparer.OrdinalIgnoreCase );
static GrandmaGiftPhase _phase = GrandmaGiftPhase.Idle;
static GrandmaGiftDef _active;
static int _line;
static int _visibleChars;
static float _typeTimer;
static int _version;
/// <summary>Seconds of active play this run (for “play a little first”).</summary>
static float _sessionPlay;
/// <summary>Economy.PlaytimeSeconds at last gift claim (persisted) — enforces MinGapAfterPrior.</summary>
static float _lastGiftClaimPlaytime;
/// <summary>One recovery attempt if tank gift was claimed but glass is empty.</summary>
static bool _triedTankRecovery;
/// <summary>Default gap between mid-ladder gifts when a def uses the shared floor.</summary>
public const float DefaultLadderGap = 35f;
public static int Version => _version;
public static GrandmaGiftPhase Phase => _phase;
public static bool IsBusy => _phase != GrandmaGiftPhase.Idle;
public static bool IsDialogue => _phase == GrandmaGiftPhase.Dialogue;
public static bool IsItemPopup => _phase == GrandmaGiftPhase.ItemPopup;
public static GrandmaGiftDef Active => _active;
public static string ActiveId => _active?.Id ?? "";
public static GrandmaGiftProp ActiveProp => _active?.Prop ?? GrandmaGiftProp.None;
public static int LineIndex => _line;
public static int LineCount => _active?.Lines?.Length ?? 0;
public static string Speaker =>
string.IsNullOrWhiteSpace( _active?.Speaker ) ? "Grandma" : _active.Speaker;
/// <summary>Empty string = no portrait (You monologue). Null/default → Grandma.</summary>
public static string PortraitSprite
{
get
{
if ( _active is null )
return "ui_grandma";
// Explicit empty on the def = hide portrait.
if ( _active.PortraitSprite is not null && _active.PortraitSprite.Length == 0 )
return "";
return string.IsNullOrWhiteSpace( _active.PortraitSprite ) ? "ui_grandma" : _active.PortraitSprite;
}
}
public static string ThemeClass => _active?.ThemeClass ?? "";
/// <summary>CSS scene class for the dialogue backdrop (avoid multi-line ternaries in Razor).</summary>
public static string SceneCssClass => ActiveProp switch
{
GrandmaGiftProp.Establishing => "scene-lot",
GrandmaGiftProp.Wallet => "scene-wallet",
GrandmaGiftProp.Fusion => "scene-fusion",
GrandmaGiftProp.Fight => "scene-fight",
GrandmaGiftProp.Adventure => "scene-adventure",
_ => "scene-tank"
};
/// <summary>ITEM GET vs NEW MOVE / CHALLENGE kicker.</summary>
public static string PopupKicker => ActiveProp switch
{
GrandmaGiftProp.Establishing => "SCHOOL NOTICE",
GrandmaGiftProp.Fusion => "NEW MOVE",
GrandmaGiftProp.Fight => "CHALLENGE",
GrandmaGiftProp.Adventure => "NEW MODE",
_ => "ITEM GET"
};
/// <summary>Adventure Mode intro claimed (or mid-empire save). Gates Flush UI.</summary>
public static bool HasAdventureIntro => HasReceived( "adventure_intro" );
/// <summary>Playtime left before the next gap-gated gift can fire (0 = free).</summary>
public static float GiftGapSecondsLeft
{
get
{
if ( _lastGiftClaimPlaytime <= 0f )
return 0f;
// Next pending gift's gap — use the largest still-relevant ladder gap as UI soft hint.
var need = NextPendingMinGap();
if ( need <= 0f )
return 0f;
var readyAt = _lastGiftClaimPlaytime + need;
return MathF.Max( 0f, readyAt - Economy.PlaytimeSeconds );
}
}
static float NextPendingMinGap()
{
var best = 0f;
foreach ( var g in Catalog )
{
if ( g is null || _received.Contains( g.Id ) )
continue;
if ( g.MinGapAfterPrior > best )
best = g.MinGapAfterPrior;
// Only care about the first unreceived mid-ladder gift in order.
if ( g.MinGapAfterPrior > 0f )
return g.MinGapAfterPrior;
}
return best;
}
/// <summary>Porch tank exists — HUD / glass after Grandma gifts it.</summary>
public static bool HasTank => HasReceived( "fish_tank" );
/// <summary>Pre-game trailer-lot inner monologue (looking around before tank).</summary>
public static bool IsLookingAround =>
IsBusy && ActiveProp == GrandmaGiftProp.Establishing;
public static IReadOnlyList<GrandmaGiftDef> Catalog { get; } = new[]
{
// Jr-high troublemaker walks home with a suspension form (Bully-energy kid, not a sad softie).
new GrandmaGiftDef
{
Id = "lot_establishing",
MinPlaytime = 0.15f,
Speaker = "You",
PortraitSprite = "ui_you",
ThemeClass = "you-theme",
ItemTitle = "Suspension Form",
ItemBody = "Jr high paper. Warm. Embarrassing. Show Grandma.",
Prop = GrandmaGiftProp.Establishing,
PopupButton = "Show Grandma",
Lines = new[]
{
"Suspended. Again. Form's damp, gravel's eating my shoes. Hi. Yeah. You're the one with the mouse. Don't make it weird. This is my life.",
"Brad from 4B is already drafting the rumor like it's a screenplay. His toilet runs all night. Spoilers: that's a plant. Think about it later.",
"Porch light's on. Cookies and judgment. She's gonna roast me, then help. That's the deal. That's the whole movie. Credits after cookies.",
},
DogeReward = 3,
KarmaReward = 1f,
OnClaimed = () =>
{
TankSim.ShowBanner( "Form ready. Chin up. She already knows. She always knows." );
}
},
new GrandmaGiftDef
{
Id = "fish_tank",
MinPlaytime = 0f,
RequiresGiftId = "lot_establishing",
Speaker = "Grandma",
PortraitSprite = "ui_grandma",
ItemTitle = "Your First Fish Tank",
ItemBody = "Porch glass + two goldfish. Don't drown them.",
Prop = GrandmaGiftProp.Tank,
PopupButton = "Thanks, Grandma",
DogeReward = 4,
KarmaReward = 2f,
Lines = new[]
{
"Suspended again? Wipe your feet. I've been madder about burnt toast. Sit. Don't narrate. Breathe.",
"I don't lecture. I gift. First fish tank — two goldfish. Feed 'em or they become supper, and I will make that a Tuesday.",
"You're still my punk. Don't drown them. Love you. Go be useful trouble. Cookies are coming. Act like you live here.",
},
OnClaimed = () =>
{
TankSim.GrantTrailerLotTank();
StorySystem.OpenFirstCardAfterPrologue();
FeedSystem.Grant( FoodDef.Flakes, announce: false );
TankSim.ShowBanner( "Flakes in the bag. Tools → Feed. She packed you a lunch like you're still employable." );
}
},
new GrandmaGiftDef
{
Id = "cold_wallet",
// First feed → wallet almost immediately. Numbers on screen = the hook.
MinPlaytime = 12f,
MinGapAfterPrior = 10f,
RequiresGiftId = "fish_tank",
Speaker = "Grandma",
PortraitSprite = "ui_grandma",
ExtraReady = () =>
StorySystem.HasFedOnce
|| Economy.LifetimeEarned >= 3
|| StorySystem.HasPassedBeat( "feed" ),
ItemTitle = "Dogecoin Cold Wallet",
ItemBody = "Grandpa's 2014 miner. Tank keeps it ticking. Funeral canceled.",
Prop = GrandmaGiftProp.Wallet,
PopupButton = "Take wallet",
DogeReward = 10,
KarmaReward = 1f,
Lines = new[]
{
"Your grandpa mined this junk in 2014. Tower's still under the porch. I ran the filter through it so the box would shut up. Worked. Sort of. Now it's your problem.",
"Fish swim, pump pulls, leftover dog money drips into this tin. Was for my funeral. Plans change. Don't look at me like that. I'm not dying for your upgrade path.",
"Yours now. Fill the glass, fill the tin. Food. Not detention candy. Tap it if your brain falls out. Bingo's at seven. I am serious about bingo.",
},
OnClaimed = () =>
{
StorySystem.NotifyWalletGiftClaimed();
}
},
// Fusion intro — after a little porch play, not a five-minute detention.
new GrandmaGiftDef
{
Id = "janitor_fusion",
MinPlaytime = 70f,
MinGapAfterPrior = 55f,
RequiresGiftId = "cold_wallet",
Speaker = "Janitor · outside",
PortraitSprite = "ui_janitor",
ThemeClass = "theme-janitor",
ExtraReady = () =>
Progression.CombineUnlocked
&& TankSim.TotalFishCount >= 2
&& !StorySystem.HasCombinedOnce
&& Economy.LifetimeEarned >= 12
&& (StorySystem.HasSoldOnce
|| StorySystem.HasTrainedOnce
|| TankSim.TotalFishCount >= 3
|| StorySystem.HasPassedBeat( "sell_tip" )
|| Economy.PlaytimeSeconds >= 90f),
ItemTitle = "Combine",
ItemBody = "Fish menu → Combine. Mash two fish (or a fish + a flamingo). Pays Ð.",
Prop = GrandmaGiftProp.Fusion,
PopupButton = "Got it",
DogeReward = 14,
KarmaReward = 0.5f,
Lines = new[]
{
"*tap tap* Don't mind the face. I don't pry. I lurk. Professionally. I'm not even supposed to be on this shift. Brad only respects monsters. I am also a suggestion.",
"Mmm. Lot janitor. Stick two fish together. Or a fish and a flamingo. Pays Ð. Grandma'll call it ugly. That's a compliment where I'm from. Where I'm from is a closet.",
"Fish menu. Combine. Click another. Explodes? Your floor. I mop. The pipes are later. I'll be back. I always am. That's the fun part. HR knows. HR is me.",
},
OnClaimed = () =>
{
Chaos.Add( 1.5f );
}
},
// Brad — after train + combine practice, without multi-minute dead air.
new GrandmaGiftDef
{
Id = "lot_bully_fight",
MinPlaytime = 110f,
MinGapAfterPrior = 60f,
RequiresGiftId = "cold_wallet",
Speaker = "Brad",
PortraitSprite = "ui_bully",
ThemeClass = "theme-bully",
ExtraReady = () =>
StorySystem.HasTrainedOnce
&& BattleSystem.CanAffordAnyFight()
&& BattleSystem.Wins + BattleSystem.Losses == 0
// Practice combine first — risk two fish before garage receipts.
&& (StorySystem.HasCombinedOnce || TankSim.AbominationsCreated > 0)
&& Economy.LifetimeEarned >= 22,
ItemTitle = "Fight",
ItemBody = "Fight → Garage Guppies. Ð entry. Brad talks receipts.",
Prop = GrandmaGiftProp.Fight,
PopupButton = "You're on",
DogeReward = 16,
KarmaReward = 1f,
Lines = new[]
{
"Yo. Brad. Unit 4B. This is the garage circuit, not your hallway slap-fight fanfic. That porch glass? Homework. Wipe the smile off. I can hear it from here.",
"Ð entry. One fish. Winner takes the pot. Your grandma already bet against me. That's rude. That's laminated. I laminate rude.",
"Fight → Garage Guppies. Bring a sweaty one. Or the ugly science project the window guy made. I keep receipts. I keep copies of the receipts.",
},
OnClaimed = () =>
{
Chaos.Add( 2f );
TankSim.ShowBanner( "Brad's garage is open. Make him laminate a loss. He will. That's his whole personality." );
}
},
// Adventure — last; after aboms + a fight, still inside the fun window.
new GrandmaGiftDef
{
Id = "adventure_intro",
MinPlaytime = 180f,
MinGapAfterPrior = 90f,
RequiresGiftId = "cold_wallet",
Speaker = "You",
PortraitSprite = "ui_you",
ThemeClass = "you-theme",
ExtraReady = () =>
{
var practicedCombine = StorySystem.HasCombinedOnce || TankSim.AbominationsCreated > 0;
var practicedFight = BattleSystem.Wins + BattleSystem.Losses >= 1;
return practicedCombine
&& practicedFight
&& TankSim.AbominationsCreated >= 1
&& Economy.LifetimeEarned >= 45
&& TankSim.TotalFishCount >= 1
&& StorySystem.HasFedOnce;
},
ItemTitle = "Adventure Mode",
ItemBody = "Fish menu → Flush. Die → faucet home. Survive → more maps.",
Prop = GrandmaGiftProp.Adventure,
PopupButton = "Alright",
DogeReward = 24,
KarmaReward = 0.5f,
Lines = new[]
{
"The pipes under the lot go farther than the trailer. I heard 4B's toilet on the walk home. Yeah. That was a plant. You're welcome.",
"Garage was a box. This is the rest of Sunny Pines. Flush a trained fish. They die, kitchen faucet coughs them home hurt. She won't look up. That's love. That's also denial.",
"Survive and more maps open. Fish menu → Flush. The toilet is a dungeon now. That's our life. That's the lot. Don't tell marketing.",
},
OnClaimed = () =>
{
Chaos.Add( 2f );
}
},
};
public static bool HasReceived( string id ) =>
!string.IsNullOrEmpty( id ) && _received.Contains( id );
public static string CurrentLine
{
get
{
if ( _active?.Lines is null || _line < 0 || _line >= _active.Lines.Length )
return "";
return _active.Lines[_line];
}
}
public static string VisibleText
{
get
{
var full = CurrentLine;
if ( string.IsNullOrEmpty( full ) )
return "";
return full[..Math.Clamp( _visibleChars, 0, full.Length )];
}
}
public static bool LineFullyTyped
{
get
{
var full = CurrentLine;
return string.IsNullOrEmpty( full ) || _visibleChars >= full.Length;
}
}
public static bool IsLastLine =>
_active?.Lines is not null && _line >= _active.Lines.Length - 1;
public static string AdvanceHint
{
get
{
if ( _phase == GrandmaGiftPhase.ItemPopup )
return _active?.PopupButton ?? "Got it!";
// One click always moves the scene forward (skip type + next, or claim).
if ( ActiveProp == GrandmaGiftProp.Establishing )
return IsLastLine ? "click · porch" : "click · walk";
return IsLastLine ? "Click · done" : "Click · next";
}
}
public static string ItemTitle => _active?.ItemTitle ?? "";
public static string ItemBody => _active?.ItemBody ?? "";
public static string PopupButton => _active?.PopupButton ?? "Got it!";
/// <summary>
/// Preformatted LCD text for gift UI. Never build "Ð@expr" in Razor — S&box prints it raw.
/// </summary>
public static string WalletBalanceLabel =>
"D" + Economy.FormatDoge( Economy.Balance );
// ---- Lifecycle ----
public static void ResetForNewGame()
{
_received.Clear();
_sessionPlay = 0f;
_lastGiftClaimPlaytime = 0f;
_triedTankRecovery = false;
ClearActive();
}
public static void Clear()
{
_sessionPlay = 0f;
// Keep _lastGiftClaimPlaytime across soft clears mid-session; Load/Reset own it.
_triedTankRecovery = false;
ClearActive();
}
static void ClearActive()
{
_phase = GrandmaGiftPhase.Idle;
_active = null;
_line = 0;
_visibleChars = 0;
_typeTimer = 0f;
_version++;
}
public static void Load( SaveData data )
{
_received.Clear();
_sessionPlay = 0f;
_lastGiftClaimPlaytime = MathF.Max( 0f, data?.LastGiftClaimPlaytime ?? 0f );
ClearActive();
if ( data?.GrandmaGiftsReceived is null )
{
_version++;
return;
}
foreach ( var id in data.GrandmaGiftsReceived )
{
if ( !string.IsNullOrWhiteSpace( id ) )
_received.Add( id.Trim() );
}
// Old saves: already past tutorial — mark starter gifts done so they don't re-fire.
if ( data.StoryDone || data.PlaytimeSeconds > 90f || data.LifetimeEarned > 40
|| (data.FreshFish is { Count: > 0 }) || (data.Fish is { Count: > 0 }) )
{
_received.Add( "lot_establishing" );
_received.Add( "fish_tank" );
_received.Add( "cold_wallet" );
}
if ( data.StoryDone || data.StoryCombinedOnce || data.AbominationsCreated > 0 )
_received.Add( "janitor_fusion" );
if ( data.StoryDone || data.BattleWins + data.BattleLosses > 0 )
_received.Add( "lot_bully_fight" );
// Old / mid-empire saves: already flushing or deep in free play — skip re-intro.
if ( data.StoryDone || data.AdventureFlushed || data.PlaytimeSeconds > 600f
|| (data.BattleWins + data.BattleLosses >= 2
&& (data.StoryCombinedOnce || data.AbominationsCreated > 0)) )
_received.Add( "adventure_intro" );
// If we seeded gifts on old saves without a claim stamp, don't force a long gap.
if ( _lastGiftClaimPlaytime <= 0f && _received.Count > 0 )
_lastGiftClaimPlaytime = MathF.Max( 0f, Economy.PlaytimeSeconds - DefaultLadderGap );
// Recover: tank gift marked received but fish never spawned (pre-fix save / failed grant).
if ( _received.Contains( "fish_tank" ) && TankSim.TotalFishCount == 0 && TankSim.IsActive )
TankSim.GrantTrailerLotTank();
_version++;
}
public static void WriteToSave( SaveData data )
{
if ( data is null )
return;
data.GrandmaGiftsReceived = _received.ToList();
data.LastGiftClaimPlaytime = _lastGiftClaimPlaytime;
}
// ---- Triggers ----
/// <summary>
/// Call from play tick. After a little playtime, auto-fires ready gifts.
/// </summary>
public static void Tick( float dt )
{
if ( dt <= 0f || !TankSim.IsActive )
return;
if ( _phase == GrandmaGiftPhase.Dialogue )
{
TickTypewriter( dt );
return;
}
if ( _phase != GrandmaGiftPhase.Idle )
return;
// Don't interrupt other full-screen UI — or a Golden/Rainbow stamp.
if ( StorySystem.CardOpen || ShopOpenGuard() || BattleSystem.IsOpen
|| TrainingSystem.TrainOpen || TrainingSystem.InjectOpen || MethRush.Open
|| BuyReveal.IsActive )
return;
_sessionPlay += dt;
TryAutoTrigger();
// Safety net (once per session): claimed tank but empty glass.
if ( !_triedTankRecovery
&& _phase == GrandmaGiftPhase.Idle
&& _received.Contains( "fish_tank" )
&& TankSim.TotalFishCount == 0 )
{
_triedTankRecovery = true;
TankSim.GrantTrailerLotTank();
}
}
static bool ShopOpenGuard() => Shop.UiOpen;
static void TryAutoTrigger()
{
try
{
foreach ( var gift in Catalog )
{
if ( !IsReady( gift ) )
continue;
BeginGift( gift );
return;
}
}
catch ( Exception e )
{
Log.Warning( $"[NO-CHILLquarium] GrandmaGifts trigger failed: {e.Message}" );
}
}
public static bool IsReady( GrandmaGiftDef gift )
{
if ( gift is null || string.IsNullOrEmpty( gift.Id ) )
return false;
if ( _received.Contains( gift.Id ) )
return false;
if ( _phase != GrandmaGiftPhase.Idle )
return false;
if ( _sessionPlay < gift.MinPlaytime && Economy.PlaytimeSeconds < gift.MinPlaytime )
return false;
// Space ladder intros — player should play between unlocks.
if ( gift.MinGapAfterPrior > 0f && _lastGiftClaimPlaytime > 0f )
{
if ( Economy.PlaytimeSeconds < _lastGiftClaimPlaytime + gift.MinGapAfterPrior )
return false;
}
if ( !string.IsNullOrEmpty( gift.RequiresGiftId ) && !_received.Contains( gift.RequiresGiftId ) )
return false;
if ( gift.ExtraReady is not null )
{
try
{
if ( !gift.ExtraReady() )
return false;
}
catch ( Exception e )
{
Log.Warning( $"[NO-CHILLquarium] GrandmaGifts ExtraReady ({gift.Id}): {e.Message}" );
return false;
}
}
return true;
}
/// <summary>True when skill gates pass but the play-gap is still cooling down.</summary>
public static bool IsWaitingOnGiftGap( GrandmaGiftDef gift )
{
if ( gift is null || string.IsNullOrEmpty( gift.Id ) || _received.Contains( gift.Id ) )
return false;
if ( gift.MinGapAfterPrior <= 0f || _lastGiftClaimPlaytime <= 0f )
return false;
if ( Economy.PlaytimeSeconds >= _lastGiftClaimPlaytime + gift.MinGapAfterPrior )
return false;
// Soft check: prior gift + base playtime OK, ExtraReady not required (avoid false "waiting").
if ( !string.IsNullOrEmpty( gift.RequiresGiftId ) && !_received.Contains( gift.RequiresGiftId ) )
return false;
if ( _sessionPlay < gift.MinPlaytime && Economy.PlaytimeSeconds < gift.MinPlaytime )
return false;
return true;
}
/// <summary>Force a gift (debug / story hooks). No-op if already received or busy.</summary>
public static bool TryTrigger( string id )
{
if ( _phase != GrandmaGiftPhase.Idle )
return false;
GrandmaGiftDef gift = null;
for ( var i = 0; i < Catalog.Count; i++ )
{
if ( string.Equals( Catalog[i].Id, id, StringComparison.OrdinalIgnoreCase ) )
{
gift = Catalog[i];
break;
}
}
if ( gift is null || _received.Contains( gift.Id ) )
return false;
BeginGift( gift );
return true;
}
/// <summary>Begin a gift only when its gates say it's ready. Quest-pill path.</summary>
public static bool TryBeginIfReady( string id )
{
if ( _phase != GrandmaGiftPhase.Idle || string.IsNullOrEmpty( id ) )
return false;
for ( var i = 0; i < Catalog.Count; i++ )
{
var gift = Catalog[i];
if ( gift is null || !string.Equals( gift.Id, id, StringComparison.OrdinalIgnoreCase ) )
continue;
if ( !IsReady( gift ) )
return false;
BeginGift( gift );
return true;
}
return false;
}
static void BeginGift( GrandmaGiftDef gift )
{
_active = gift;
_phase = GrandmaGiftPhase.Dialogue;
_line = 0;
_visibleChars = 0;
_typeTimer = 0f;
_version++;
GameAudio.PlayUi( GameAudio.Notice );
}
static void TickTypewriter( float dt )
{
if ( LineFullyTyped )
return;
_typeTimer += dt;
var step = 1f / TypeCharsPerSecond;
while ( _typeTimer >= step && !LineFullyTyped )
{
_typeTimer -= step;
_visibleChars++;
_version++;
}
}
// ---- Input ----
/// <summary>
/// One click = real progress. Skip typewriter and advance a line,
/// or finish the scene (claim gift) on the last line — no extra gift-modal hop.
/// </summary>
public static void Advance()
{
if ( _phase == GrandmaGiftPhase.ItemPopup )
{
ClaimItem();
return;
}
if ( _phase != GrandmaGiftPhase.Dialogue || _active is null )
return;
// Always show full current line first (skip typewriter).
if ( !LineFullyTyped )
{
_visibleChars = CurrentLine.Length;
_typeTimer = 0f;
}
// Same click: leave this line.
if ( !IsLastLine )
{
_line++;
// Next line appears fully — fewer clicks, still readable.
_visibleChars = CurrentLine.Length;
_typeTimer = 0f;
_version++;
// No click SFX per line — it spams on every dialogue tap.
return;
}
// Last line → claim immediately (skip ITEM GET modal).
// Tank fish still spawn before HasTank is needed next frame.
if ( _active.Prop == GrandmaGiftProp.Tank )
TankSim.GrantTrailerLotTank();
ClaimItem();
}
static void ClaimItem()
{
var gift = _active;
if ( gift is null )
{
ClearActive();
return;
}
_received.Add( gift.Id );
// Stamp claim time so MinGapAfterPrior can breathe before the next intro.
_lastGiftClaimPlaytime = MathF.Max( _lastGiftClaimPlaytime, Economy.PlaytimeSeconds );
// Sitting through dialogue pays — base tip + bonus for long scenes.
var doge = DialogueDogePayout( gift );
var karma = gift.KarmaReward;
if ( doge > 0 )
Economy.Add( doge );
if ( karma != 0f )
Karma.Add( karma );
try
{
gift.OnClaimed?.Invoke();
}
catch ( Exception e )
{
Log.Warning( $"[NO-CHILLquarium] Grandma gift claim failed ({gift.Id}): {e.Message}" );
}
// Banner after OnClaimed so tank/wallet grants don't bury the tip.
if ( doge > 0 )
{
var tip = DialogueTipBanner( gift, doge );
if ( !string.IsNullOrEmpty( tip ) )
TankSim.ShowBanner( tip );
if ( doge >= 10 )
GameAudio.PlayDogeBark();
else
GameAudio.PlayCoin();
}
else
{
GameAudio.PlayUi( GameAudio.Confirm );
}
ClearActive();
SaveGame.TrySave( quiet: true );
// Chain the next ready gift immediately (tank after the walk home).
TryAutoTrigger();
}
/// <summary>
/// Ð for finishing a gift scene. Longer line counts pay more so long monologues feel worth it.
/// </summary>
public static double DialogueDogePayout( GrandmaGiftDef gift )
{
if ( gift is null )
return 0;
var lines = gift.Lines?.Length ?? 0;
// Every line past 4 is a small tip (long lectures pay better).
var longBonus = Math.Max( 0, lines - 4 ) * 1.5;
var basePay = gift.DogeReward;
if ( basePay <= 0 && lines > 0 )
basePay = 2 + lines; // fallback for future gifts
return Math.Max( 0, Math.Round( basePay + longBonus, 1 ) );
}
static string DialogueTipBanner( GrandmaGiftDef gift, double doge )
{
var pay = Economy.FormatDoge( doge );
return gift.Prop switch
{
GrandmaGiftProp.Establishing => $"Chin up. +Ð{pay} in pocket change.",
GrandmaGiftProp.Tank => $"Tank + goldfish. Lecture tip +Ð{pay}.",
GrandmaGiftProp.Wallet => $"Grandpa's tin. The box ticked. +Ð{pay}.",
GrandmaGiftProp.Fusion => $"Window tip. Combine unlocked. +Ð{pay}.",
GrandmaGiftProp.Fight => $"Brad opened the garage. Seed cash +Ð{pay}.",
GrandmaGiftProp.Adventure => $"Adventure unlocked. Monologue tip +Ð{pay}. Flush when bored.",
_ => $"+Ð{pay} for listening."
};
}
}