From 9de8af0d2b1db07cad49c15c720f70e9798430b9 Mon Sep 17 00:00:00 2001 From: Sarahtonin Date: Sat, 5 Sep 2026 16:50:52 +0200 Subject: [PATCH] Added effects engine. --- Pages/AdminMassPlants.php | 1 + Pages/AdminPlants.php | 1 + Pages/Garden.php | 1 + Scripts/Garden/AdminValidation.js | 151 +++ Scripts/Garden/FunctionalEffects.js | 1339 +++++++++++++++++++++++++++ Scripts/Garden/Garden.js | 51 +- Scripts/Garden/Save.js | 73 +- Scripts/Garden/Simulation.js | 70 +- Styles/Style.css | 9 + 9 files changed, 1684 insertions(+), 12 deletions(-) create mode 100644 Scripts/Garden/FunctionalEffects.js diff --git a/Pages/AdminMassPlants.php b/Pages/AdminMassPlants.php index 01d866e..40bde6d 100644 --- a/Pages/AdminMassPlants.php +++ b/Pages/AdminMassPlants.php @@ -211,6 +211,7 @@ $PageSection = "Admin"; + diff --git a/Pages/AdminPlants.php b/Pages/AdminPlants.php index 7e4cff7..761df61 100644 --- a/Pages/AdminPlants.php +++ b/Pages/AdminPlants.php @@ -443,6 +443,7 @@ $PageSection = "Admin"; + diff --git a/Pages/Garden.php b/Pages/Garden.php index 1bb4d18..49b23c5 100644 --- a/Pages/Garden.php +++ b/Pages/Garden.php @@ -290,6 +290,7 @@ $PageSection = "Garden"; + diff --git a/Scripts/Garden/AdminValidation.js b/Scripts/Garden/AdminValidation.js index b6944e2..6511570 100644 --- a/Scripts/Garden/AdminValidation.js +++ b/Scripts/Garden/AdminValidation.js @@ -58,6 +58,153 @@ function ValidateAdminKey( } +function ValidateAdminFunctionalEffects( + Effects +) { + const Functional = + Effects?.Functional; + + if (Functional === undefined) { + return true; + } + + if (!Array.isArray(Functional)) { + throw new Error( + "Effects.Functional must be an array." + ); + } + + if (Functional.length > 32) { + throw new Error( + "Effects.Functional cannot contain more than 32 effects." + ); + } + + const SeenKeys = new Set(); + + for ( + let EffectIndex = 0; + EffectIndex < Functional.length; + EffectIndex++ + ) { + const Effect = + Functional[EffectIndex]; + + if (!IsAdminPlainObject(Effect)) { + throw new Error( + "Functional effect " + + (EffectIndex + 1) + + " must be an object." + ); + } + + ValidateAdminKey( + String( + Effect.Type ?? "" + ).trim(), + "Functional effect type" + ); + + if (Effect.Key !== undefined) { + const EffectKey = String( + Effect.Key ?? "" + ).trim(); + + ValidateAdminKey( + EffectKey, + "Functional effect key" + ); + + if (SeenKeys.has(EffectKey)) { + throw new Error( + "Functional effect keys must be unique within a plant." + ); + } + + SeenKeys.add(EffectKey); + } + + for (const Field of [ + "Area", + "Source", + "Target" + ]) { + if (Effect[Field] === undefined) { + continue; + } + + const Area = String( + Effect[Field] + ); + + if ( + typeof FunctionalEffectAreas !== + "undefined" && + !FunctionalEffectAreas.includes( + Area + ) + ) { + throw new Error( + "Functional effect " + + Field.toLowerCase() + + " must be Front, Behind, Cardinal, Adjacent or Farm." + ); + } + } + + if (Effect.Cooldown !== undefined) { + ValidateAdminInteger( + Number( + Effect.Cooldown + ), + "Functional effect cooldown", + typeof FunctionalEffectMinimumCooldown !== + "undefined" + ? FunctionalEffectMinimumCooldown + : 1000 + ); + } + + if (Effect.Amount !== undefined) { + const Amount = Number( + Effect.Amount + ); + + if (!Number.isFinite(Amount)) { + throw new Error( + "Functional effect amount must be a number." + ); + } + } + + if ( + Effect.RequireMature !== undefined && + typeof Effect.RequireMature !== + "boolean" + ) { + throw new Error( + "Functional effect RequireMature must be On or Off." + ); + } + + if ( + typeof IsFunctionalEffectActive === + "function" && + IsFunctionalEffectActive( + Effect + ) && + Effect.Cooldown === undefined + ) { + throw new Error( + "Active functional effects need a cooldown." + ); + } + } + + return true; +} + + function ValidateAdminPlant( Plant, Catalogue, @@ -205,6 +352,10 @@ function ValidateAdminPlant( ); } + ValidateAdminFunctionalEffects( + Plant.Effects + ); + if ( Plant.DirectionalSprites !== undefined && typeof Plant.DirectionalSprites !== "boolean" diff --git a/Scripts/Garden/FunctionalEffects.js b/Scripts/Garden/FunctionalEffects.js new file mode 100644 index 0000000..44b0c63 --- /dev/null +++ b/Scripts/Garden/FunctionalEffects.js @@ -0,0 +1,1339 @@ +const FunctionalEffectAreas = [ + "Front", + "Behind", + "Cardinal", + "Adjacent", + "Farm" +]; + +const FunctionalEffectActiveTypes = [ + "Harvest", + "Duplicate", + "Plant", + "Replant" +]; + +const FunctionalEffectPassiveTypes = [ + "GrowthAdvance", + "DisableMutation", + "DisableHarvest", + "MutationChanceBonus", + "CooldownReduction", + "HarvestYieldBonus" +]; + +const FunctionalEffectTypes = [ + ...FunctionalEffectActiveTypes, + ...FunctionalEffectPassiveTypes +]; + +const FunctionalEffectMinimumCooldown = 1000; + +const FunctionalEffectHandlers = new Map(); + +const FunctionalDirectionOffsets = { + North: [0, -1], + East: [1, 0], + South: [0, 1], + West: [-1, 0] +}; + +const FunctionalCardinalOffsets = [ + [0, -1], + [1, 0], + [0, 1], + [-1, 0] +]; + +const FunctionalAdjacentOffsets = [ + [0, -1], + [1, -1], + [1, 0], + [1, 1], + [0, 1], + [-1, 1], + [-1, 0], + [-1, -1] +]; + + +function IsFunctionalPlainObject( + Value +) { + return ( + Value !== null && + typeof Value === "object" && + !Array.isArray(Value) + ); +} + + +function GetPlantFunctionalEffects( + Plant +) { + const Functional = + Plant?.Effects?.Functional; + + if (!Array.isArray(Functional)) { + return []; + } + + return Functional.filter( + Effect => + IsFunctionalPlainObject( + Effect + ) && + typeof Effect.Type === + "string" && + Effect.Type.trim().length > 0 + ); +} + + +function IsFunctionalPlant( + Plant +) { + return ( + GetPlantFunctionalEffects( + Plant + ).length > 0 + ); +} + + +function IsKnownFunctionalEffectType( + Type +) { + return FunctionalEffectTypes.includes( + String(Type ?? "") + ); +} + + +function IsFunctionalEffectActive( + Effect +) { + return FunctionalEffectActiveTypes.includes( + String(Effect?.Type ?? "") + ); +} + + +function IsFunctionalEffectPassive( + Effect +) { + return FunctionalEffectPassiveTypes.includes( + String(Effect?.Type ?? "") + ); +} + + +function GetFunctionalEffectArea( + Effect, + Field = "Area", + Fallback = "Front" +) { + const Area = String( + Effect?.[Field] ?? Fallback + ); + + return FunctionalEffectAreas.includes( + Area + ) + ? Area + : Fallback; +} + + +function DoesFunctionalEffectRequireMaturity( + Effect +) { + return Effect?.RequireMature !== false; +} + + +function GetFunctionalEffectCooldown( + Effect +) { + const Cooldown = Number( + Effect?.Cooldown ?? 0 + ); + + if ( + !Number.isFinite(Cooldown) || + Cooldown <= 0 + ) { + return 0; + } + + return Math.max( + FunctionalEffectMinimumCooldown, + Math.floor(Cooldown) + ); +} + + +function GetResolvedFunctionalEffectCooldown( + SaveData, + GardenIndex, + PlotIndex, + Effect, + AtTime +) { + return GetFunctionalEffectCooldown( + Effect + ); +} + + +function GetFunctionalEffectAmount( + Effect, + Fallback = 0 +) { + const Amount = Number( + Effect?.Amount + ); + + return Number.isFinite(Amount) + ? Amount + : Fallback; +} + + +function GetFunctionalEffectKey( + Effect, + EffectIndex +) { + const ExplicitKey = + typeof Effect?.Key === "string" + ? Effect.Key.trim() + : ""; + + if (ExplicitKey.length > 0) { + return ExplicitKey; + } + + return ( + String( + Effect?.Type ?? "Effect" + ) + + ":" + + String(EffectIndex) + ); +} + + +function RegisterFunctionalEffectHandler( + Type, + Handler +) { + const NormalizedType = + String(Type ?? "").trim(); + + if ( + NormalizedType.length === 0 || + typeof Handler !== "function" + ) { + return false; + } + + FunctionalEffectHandlers.set( + NormalizedType, + Handler + ); + + return true; +} + + +function UnregisterFunctionalEffectHandler( + Type +) { + return FunctionalEffectHandlers.delete( + String(Type ?? "") + ); +} + + +function GetFunctionalEffectHandler( + Type +) { + return FunctionalEffectHandlers.get( + String(Type ?? "") + ) ?? null; +} + + +function HasFunctionalEffectHandler( + Type +) { + return ( + GetFunctionalEffectHandler( + Type + ) !== null + ); +} + + +function GetFunctionalGarden( + SaveData, + GardenIndex +) { + if ( + !Array.isArray( + SaveData?.Gardens + ) + ) { + return null; + } + + return SaveData.Gardens[ + GardenIndex + ] ?? null; +} + + +function GetFunctionalPlotCoordinates( + Garden, + PlotIndex +) { + const Width = Math.max( + 1, + Math.floor( + Number(Garden?.Width) || 1 + ) + ); + + return { + X: PlotIndex % Width, + Y: Math.floor( + PlotIndex / Width + ) + }; +} + + +function GetFunctionalPlotIndex( + Garden, + X, + Y +) { + const Width = Math.max( + 1, + Math.floor( + Number(Garden?.Width) || 1 + ) + ); + + const Height = Math.max( + 1, + Math.floor( + Number(Garden?.Height) || 1 + ) + ); + + if ( + X < 0 || + Y < 0 || + X >= Width || + Y >= Height + ) { + return null; + } + + return Y * Width + X; +} + + +function GetFunctionalPlotRotation( + Plot +) { + const Rotation = String( + Plot?.Rotation ?? "East" + ); + + return FunctionalDirectionOffsets[ + Rotation + ] !== undefined + ? Rotation + : "East"; +} + + +function GetFunctionalOffsetIndexes( + Garden, + PlotIndex, + Offsets +) { + const Coordinates = + GetFunctionalPlotCoordinates( + Garden, + PlotIndex + ); + + const Results = []; + + for (const [OffsetX, OffsetY] of Offsets) { + const TargetIndex = + GetFunctionalPlotIndex( + Garden, + Coordinates.X + OffsetX, + Coordinates.Y + OffsetY + ); + + if (TargetIndex !== null) { + Results.push(TargetIndex); + } + } + + return Results; +} + + +function GetFunctionalAreaPlotIndexes( + SaveData, + GardenIndex, + OriginPlotIndex, + Area, + Rotation = null +) { + const Garden = + GetFunctionalGarden( + SaveData, + GardenIndex + ); + + if (Garden === null) { + return []; + } + + const NormalizedArea = + FunctionalEffectAreas.includes( + Area + ) + ? Area + : "Front"; + + if (NormalizedArea === "Farm") { + return Array.from( + { + length: + Garden.Plots?.length ?? 0 + }, + (_, PlotIndex) => PlotIndex + ); + } + + if (NormalizedArea === "Cardinal") { + return GetFunctionalOffsetIndexes( + Garden, + OriginPlotIndex, + FunctionalCardinalOffsets + ); + } + + if (NormalizedArea === "Adjacent") { + return GetFunctionalOffsetIndexes( + Garden, + OriginPlotIndex, + FunctionalAdjacentOffsets + ); + } + + const Direction = + Rotation === null + ? GetFunctionalPlotRotation( + Garden.Plots?.[ + OriginPlotIndex + ] + ) + : GetFunctionalPlotRotation({ + Rotation + }); + + const [DirectionX, DirectionY] = + FunctionalDirectionOffsets[ + Direction + ]; + + const Multiplier = + NormalizedArea === "Behind" + ? -1 + : 1; + + return GetFunctionalOffsetIndexes( + Garden, + OriginPlotIndex, + [[ + DirectionX * Multiplier, + DirectionY * Multiplier + ]] + ); +} + + +function GetFunctionalAreaTargets( + SaveData, + GardenIndex, + OriginPlotIndex, + Area, + Rotation = null +) { + const Garden = + GetFunctionalGarden( + SaveData, + GardenIndex + ); + + if (Garden === null) { + return []; + } + + return GetFunctionalAreaPlotIndexes( + SaveData, + GardenIndex, + OriginPlotIndex, + Area, + Rotation + ).map( + PlotIndex => ({ + GardenIndex, + PlotIndex, + Plot: + Garden.Plots[ + PlotIndex + ] ?? null + }) + ); +} + + +function IsFunctionalPlantMatureAtTime( + Plot, + Plant, + AtTime +) { + if ( + Plot === null || + Plant === null || + Plant === undefined + ) { + return false; + } + + const GrowthTime = Math.max( + 0, + Number( + Plant.GrowthTime ?? 0 + ) || 0 + ); + + return ( + Number(AtTime) - + Number( + Plot.PlantedAt ?? 0 + ) >= GrowthTime + ); +} + + +function EnsureFunctionalCooldowns( + Plot +) { + if ( + Plot === null || + typeof Plot !== "object" || + Array.isArray(Plot) + ) { + return null; + } + + if ( + !IsFunctionalPlainObject( + Plot.FunctionalCooldowns + ) + ) { + Plot.FunctionalCooldowns = {}; + } + + return Plot.FunctionalCooldowns; +} + + +function NormalizeFunctionalCooldownEntry( + Value +) { + if ( + Number.isFinite( + Number(Value) + ) + ) { + return { + StartedAt: 0, + Until: Math.max( + 0, + Math.floor( + Number(Value) + ) + ) + }; + } + + if (!IsFunctionalPlainObject(Value)) { + return null; + } + + const StartedAt = Math.max( + 0, + Math.floor( + Number( + Value.StartedAt ?? 0 + ) || 0 + ) + ); + + const Until = Math.max( + StartedAt, + Math.floor( + Number( + Value.Until ?? 0 + ) || 0 + ) + ); + + if (Until <= 0) { + return null; + } + + return { + StartedAt, + Until + }; +} + + +function GetFunctionalEffectCooldownState( + Plot, + Effect, + EffectIndex, + AtTime +) { + const Cooldowns = + EnsureFunctionalCooldowns( + Plot + ); + + if (Cooldowns === null) { + return null; + } + + const EffectKey = + GetFunctionalEffectKey( + Effect, + EffectIndex + ); + + const Entry = + NormalizeFunctionalCooldownEntry( + Cooldowns[EffectKey] + ); + + if (Entry === null) { + delete Cooldowns[EffectKey]; + return null; + } + + Cooldowns[EffectKey] = Entry; + + const Duration = Math.max( + 1, + Entry.Until - + Entry.StartedAt + ); + + const Progress = Math.max( + 0, + Math.min( + ( + Number(AtTime) - + Entry.StartedAt + ) / Duration, + 1 + ) + ); + + return { + Key: EffectKey, + StartedAt: Entry.StartedAt, + Until: Entry.Until, + Remaining: Math.max( + 0, + Entry.Until - + Number(AtTime) + ), + Progress, + Ready: + Number(AtTime) >= + Entry.Until + }; +} + + +function StartFunctionalEffectCooldown( + Plot, + Effect, + EffectIndex, + AtTime, + ResolvedCooldown = null +) { + const Cooldown = + ResolvedCooldown === null + ? GetFunctionalEffectCooldown( + Effect + ) + : Math.max( + 0, + Math.floor( + Number( + ResolvedCooldown + ) || 0 + ) + ); + + if (Cooldown <= 0) { + return null; + } + + const Cooldowns = + EnsureFunctionalCooldowns( + Plot + ); + + if (Cooldowns === null) { + return null; + } + + const EffectKey = + GetFunctionalEffectKey( + Effect, + EffectIndex + ); + + const StartedAt = Math.max( + 0, + Math.floor( + Number(AtTime) || 0 + ) + ); + + Cooldowns[EffectKey] = { + StartedAt, + Until: + StartedAt + Cooldown + }; + + return GetFunctionalEffectCooldownState( + Plot, + Effect, + EffectIndex, + AtTime + ); +} + + +function IsFunctionalEffectOnCooldown( + Plot, + Effect, + EffectIndex, + AtTime +) { + const State = + GetFunctionalEffectCooldownState( + Plot, + Effect, + EffectIndex, + AtTime + ); + + return ( + State !== null && + !State.Ready + ); +} + + +function GetFunctionalPlantCooldownDisplayState( + Plot, + Plant, + AtTime +) { + if ( + !IsFunctionalPlantMatureAtTime( + Plot, + Plant, + AtTime + ) + ) { + return null; + } + + let BestState = null; + + const Effects = + GetPlantFunctionalEffects( + Plant + ); + + Effects.forEach( + (Effect, EffectIndex) => { + if ( + !IsFunctionalEffectActive( + Effect + ) + ) { + return; + } + + const State = + GetFunctionalEffectCooldownState( + Plot, + Effect, + EffectIndex, + AtTime + ); + + if ( + State === null || + State.Ready + ) { + return; + } + + if ( + BestState === null || + State.Until < + BestState.Until + ) { + BestState = { + ...State, + Effect, + EffectIndex + }; + } + } + ); + + return BestState; +} + + +function GetFunctionalEffectsAffectingPlot( + SaveData, + GardenIndex, + TargetPlotIndex, + Type, + AtTime +) { + const Garden = + GetFunctionalGarden( + SaveData, + GardenIndex + ); + + if (Garden === null) { + return []; + } + + const Results = []; + + Garden.Plots.forEach( + (SourcePlot, SourcePlotIndex) => { + if (SourcePlot === null) { + return; + } + + const SourcePlant = + Plants?.[ + SourcePlot.Plant + ]; + + if (SourcePlant === undefined) { + return; + } + + const Effects = + GetPlantFunctionalEffects( + SourcePlant + ); + + Effects.forEach( + (Effect, EffectIndex) => { + if ( + String( + Effect.Type + ) !== String(Type) + ) { + return; + } + + if ( + DoesFunctionalEffectRequireMaturity( + Effect + ) && + !IsFunctionalPlantMatureAtTime( + SourcePlot, + SourcePlant, + AtTime + ) + ) { + return; + } + + const Area = + GetFunctionalEffectArea( + Effect + ); + + const Targets = + GetFunctionalAreaPlotIndexes( + SaveData, + GardenIndex, + SourcePlotIndex, + Area + ); + + if ( + !Targets.includes( + TargetPlotIndex + ) + ) { + return; + } + + Results.push({ + GardenIndex, + SourcePlotIndex, + SourcePlot, + SourcePlant, + Effect, + EffectIndex + }); + } + ); + } + ); + + return Results; +} + + +function GetStrongestFunctionalEffectAffectingPlot( + SaveData, + GardenIndex, + TargetPlotIndex, + Type, + AtTime +) { + const Effects = + GetFunctionalEffectsAffectingPlot( + SaveData, + GardenIndex, + TargetPlotIndex, + Type, + AtTime + ); + + if (Effects.length === 0) { + return null; + } + + return Effects.reduce( + (Best, Candidate) => + Best === null || + GetFunctionalEffectAmount( + Candidate.Effect + ) > + GetFunctionalEffectAmount( + Best.Effect + ) + ? Candidate + : Best, + null + ); +} + + +function GetStrongestFunctionalEffectAffectingAnyPlot( + SaveData, + GardenIndex, + TargetPlotIndexes, + Type, + AtTime +) { + let Best = null; + + for (const PlotIndex of TargetPlotIndexes) { + const Candidate = + GetStrongestFunctionalEffectAffectingPlot( + SaveData, + GardenIndex, + PlotIndex, + Type, + AtTime + ); + + if ( + Candidate !== null && + ( + Best === null || + GetFunctionalEffectAmount( + Candidate.Effect + ) > + GetFunctionalEffectAmount( + Best.Effect + ) + ) + ) { + Best = Candidate; + } + } + + return Best; +} + + +function CreateFunctionalProcessingResult() { + return { + Attempted: false, + Changed: false, + Activations: [] + }; +} + + +function MergeFunctionalHandlerResult( + Result, + HandlerResult +) { + if (HandlerResult === true) { + Result.Changed = true; + return; + } + + if (!IsFunctionalPlainObject(HandlerResult)) { + return; + } + + Result.Changed = + Result.Changed || + HandlerResult.Changed === true; +} + + +function ProcessFunctionalEffectsAtTime( + SaveData, + AtTime +) { + const Result = + CreateFunctionalProcessingResult(); + + if ( + !Array.isArray( + SaveData?.Gardens + ) + ) { + return Result; + } + + for ( + let GardenIndex = 0; + GardenIndex < SaveData.Gardens.length; + GardenIndex++ + ) { + const Garden = + SaveData.Gardens[ + GardenIndex + ]; + + for ( + let PlotIndex = 0; + PlotIndex < Garden.Plots.length; + PlotIndex++ + ) { + const Plot = + Garden.Plots[ + PlotIndex + ]; + + if (Plot === null) { + continue; + } + + const Plant = + Plants?.[Plot.Plant]; + + if (Plant === undefined) { + continue; + } + + const Effects = + GetPlantFunctionalEffects( + Plant + ); + + for ( + let EffectIndex = 0; + EffectIndex < Effects.length; + EffectIndex++ + ) { + const Effect = + Effects[EffectIndex]; + + if ( + !IsFunctionalEffectActive( + Effect + ) + ) { + continue; + } + + if ( + DoesFunctionalEffectRequireMaturity( + Effect + ) && + !IsFunctionalPlantMatureAtTime( + Plot, + Plant, + AtTime + ) + ) { + continue; + } + + const Handler = + GetFunctionalEffectHandler( + Effect.Type + ); + + if (Handler === null) { + continue; + } + + if ( + IsFunctionalEffectOnCooldown( + Plot, + Effect, + EffectIndex, + AtTime + ) + ) { + continue; + } + + Result.Attempted = true; + + const ResolvedCooldown = + GetResolvedFunctionalEffectCooldown( + SaveData, + GardenIndex, + PlotIndex, + Effect, + AtTime + ); + + StartFunctionalEffectCooldown( + Plot, + Effect, + EffectIndex, + AtTime, + ResolvedCooldown + ); + + const HandlerResult = + Handler({ + SaveData, + GardenIndex, + Garden, + PlotIndex, + Plot, + Plant, + Effect, + EffectIndex, + AtTime, + GetTargets: ( + Area = null + ) => + GetFunctionalAreaTargets( + SaveData, + GardenIndex, + PlotIndex, + Area ?? + GetFunctionalEffectArea( + Effect + ) + ) + }); + + MergeFunctionalHandlerResult( + Result, + HandlerResult + ); + + Result.Activations.push({ + GardenIndex, + PlotIndex, + PlantKey: + Plot.Plant, + Type: + Effect.Type, + EffectIndex, + AtTime + }); + + if ( + Garden.Plots[PlotIndex] !== + Plot + ) { + break; + } + } + } + } + + return Result; +} + + +function GetNextFunctionalEffectEventTime( + SaveData, + CurrentTime, + TargetTime +) { + if ( + !Array.isArray( + SaveData?.Gardens + ) + ) { + return null; + } + + let NextTime = null; + + for ( + let GardenIndex = 0; + GardenIndex < SaveData.Gardens.length; + GardenIndex++ + ) { + const Garden = + SaveData.Gardens[ + GardenIndex + ]; + + for ( + let PlotIndex = 0; + PlotIndex < Garden.Plots.length; + PlotIndex++ + ) { + const Plot = + Garden.Plots[ + PlotIndex + ]; + + if (Plot === null) { + continue; + } + + const Plant = + Plants?.[Plot.Plant]; + + if (Plant === undefined) { + continue; + } + + const Effects = + GetPlantFunctionalEffects( + Plant + ); + + if (Effects.length === 0) { + continue; + } + + const MaturityTime = + Number( + Plot.PlantedAt ?? 0 + ) + + Math.max( + 0, + Number( + Plant.GrowthTime ?? 0 + ) || 0 + ); + + for ( + let EffectIndex = 0; + EffectIndex < Effects.length; + EffectIndex++ + ) { + const Effect = + Effects[EffectIndex]; + + if ( + !IsFunctionalEffectActive( + Effect + ) || + !HasFunctionalEffectHandler( + Effect.Type + ) + ) { + continue; + } + + let EventTime; + + if ( + DoesFunctionalEffectRequireMaturity( + Effect + ) && + MaturityTime > CurrentTime + ) { + EventTime = MaturityTime; + } else { + const CooldownState = + GetFunctionalEffectCooldownState( + Plot, + Effect, + EffectIndex, + CurrentTime + ); + + if ( + CooldownState === null || + CooldownState.Ready + ) { + EventTime = CurrentTime; + } else { + EventTime = + CooldownState.Until; + } + } + + if ( + EventTime <= CurrentTime || + EventTime > TargetTime + ) { + continue; + } + + if ( + NextTime === null || + EventTime < NextTime + ) { + NextTime = EventTime; + } + } + } + } + + return NextTime; +} diff --git a/Scripts/Garden/Garden.js b/Scripts/Garden/Garden.js index 3dddc34..b0ec523 100644 --- a/Scripts/Garden/Garden.js +++ b/Scripts/Garden/Garden.js @@ -1669,11 +1669,35 @@ function CreatePlotElement( Plant ); + const CooldownState = + Progress >= 1 && + typeof GetFunctionalPlantCooldownDisplayState === + "function" + ? GetFunctionalPlantCooldownDisplayState( + Plot, + Plant, + GetSimulationTime( + GameSave + ) + ) + : null; + + const BorderProgress = + CooldownState === null + ? Progress + : CooldownState.Progress; + Tile.style.setProperty( "--GrowthProgress", - `${Progress * 100}%` + `${BorderProgress * 100}%` ); + if (CooldownState !== null) { + Tile.classList.add( + "GardenPlotFunctionalCooldown" + ); + } + const ImagePath = GetPlantImage( @@ -2004,6 +2028,31 @@ function CreateGardenPlotTimer( } + if ( + Progress >= 1 && + typeof GetFunctionalPlantCooldownDisplayState === + "function" + ) { + const CooldownState = + GetFunctionalPlantCooldownDisplayState( + Plot, + Plant, + GetSimulationTime( + GameSave + ) + ); + + if (CooldownState !== null) { + Timer.textContent = + FormatGardenRemainingTime( + CooldownState.Remaining + ); + + return Timer; + } + } + + if (Progress >= 1) { Timer.textContent = "Ready"; diff --git a/Scripts/Garden/Save.js b/Scripts/Garden/Save.js index 1f62223..6760195 100644 --- a/Scripts/Garden/Save.js +++ b/Scripts/Garden/Save.js @@ -15,7 +15,7 @@ const MaximumGardenNameLength = 32; const StartingGardenWidth = 4; const StartingGardenHeight = 4; -const CurrentSaveVersion = 7; +const CurrentSaveVersion = 8; function CreateSimulationRandomState() { @@ -288,6 +288,77 @@ function NormalizeGardenData( ? Plot.Rotation : "East"; + if ( + Plot.FunctionalCooldowns === null || + typeof Plot.FunctionalCooldowns !== + "object" || + Array.isArray( + Plot.FunctionalCooldowns + ) + ) { + Plot.FunctionalCooldowns = {}; + } + + for ( + const [CooldownKey, CooldownValue] + of Object.entries( + Plot.FunctionalCooldowns + ) + ) { + let StartedAt = 0; + let Until = 0; + + if ( + CooldownValue !== null && + typeof CooldownValue === + "object" && + !Array.isArray( + CooldownValue + ) + ) { + StartedAt = Math.max( + 0, + Math.floor( + Number( + CooldownValue.StartedAt ?? 0 + ) || 0 + ) + ); + + Until = Math.max( + StartedAt, + Math.floor( + Number( + CooldownValue.Until ?? 0 + ) || 0 + ) + ); + } else { + Until = Math.max( + 0, + Math.floor( + Number( + CooldownValue + ) || 0 + ) + ); + } + + if (Until <= 0) { + delete Plot.FunctionalCooldowns[ + CooldownKey + ]; + continue; + } + + Plot.FunctionalCooldowns[ + CooldownKey + ] = { + StartedAt, + Until + }; + } + delete Plot.VisualVariant; delete Plot.AddedTags; } diff --git a/Scripts/Garden/Simulation.js b/Scripts/Garden/Simulation.js index 5f20a1e..59909a2 100644 --- a/Scripts/Garden/Simulation.js +++ b/Scripts/Garden/Simulation.js @@ -91,6 +91,28 @@ function MergeSimulationMutationResult( } +function MergeSimulationFunctionalResult( + Target, + Source +) { + if (Source === null || Source === undefined) { + return; + } + + Target.FunctionalAttempted = + Target.FunctionalAttempted || + Source.Attempted === true; + + Target.Changed = + Target.Changed || + Source.Changed === true; + + Target.FunctionalActivations.push( + ...(Source.Activations ?? []) + ); +} + + function CreateSimulationResult( StartTime, TargetTime @@ -100,6 +122,7 @@ function CreateSimulationResult( TargetTime, Advanced: TargetTime > StartTime, Attempted: false, + FunctionalAttempted: false, Changed: false, Dirty: false, CaughtUp: StartTime >= TargetTime, @@ -107,7 +130,8 @@ function CreateSimulationResult( Steps: 0, SuccessfulMutations: [], NewlyDiscoveredMutations: [], - NewlyDiscoveredPlants: [] + NewlyDiscoveredPlants: [], + FunctionalActivations: [] }; } @@ -135,6 +159,7 @@ function FinalizeSimulationResult( Result.Dirty = Result.Attempted || + Result.FunctionalAttempted || Result.Changed; return Result; @@ -324,18 +349,27 @@ function GetNextSimulationEventTime( TargetTime ); - if (MaturityTime === null) { - return MutationTime; - } + const FunctionalTime = + typeof GetNextFunctionalEffectEventTime === + "function" + ? GetNextFunctionalEffectEventTime( + SaveData, + CurrentTime, + TargetTime + ) + : null; - if (MutationTime === null) { - return MaturityTime; - } - - return Math.min( + const EventTimes = [ MaturityTime, - MutationTime + MutationTime, + FunctionalTime + ].filter( + Time => Time !== null ); + + return EventTimes.length === 0 + ? null + : Math.min(...EventTimes); } @@ -387,6 +421,22 @@ function AdvanceGameSimulation( MutationResult ); + if ( + typeof ProcessFunctionalEffectsAtTime === + "function" + ) { + const FunctionalResult = + ProcessFunctionalEffectsAtTime( + SaveData, + CurrentTime + ); + + MergeSimulationFunctionalResult( + Result, + FunctionalResult + ); + } + if (CurrentTime >= SafeTargetTime) { Result.CaughtUp = true; break; diff --git a/Styles/Style.css b/Styles/Style.css index d1d978d..97f2929 100644 --- a/Styles/Style.css +++ b/Styles/Style.css @@ -754,6 +754,15 @@ input, button, textarea, select { --GrowthColour: var(--Yellow); } +.GardenPlotFunctionalCooldown, +.GardenPlotFunctionalCooldown:hover { + --GrowthColour: var(--Blue); +} + +.GardenPlotFunctionalCooldown .GardenPlotTimer { + color: var(--Blue); +} + .SeedList { display: flex; gap: 0.5rem;