diff --git a/Assets/Img/Garden/Plants/Rose/Red/1.png b/Assets/Img/Garden/Plants/RedRose/1.png similarity index 100% rename from Assets/Img/Garden/Plants/Rose/Red/1.png rename to Assets/Img/Garden/Plants/RedRose/1.png diff --git a/Assets/Img/Garden/Plants/Rose/Red/2.png b/Assets/Img/Garden/Plants/RedRose/2.png similarity index 100% rename from Assets/Img/Garden/Plants/Rose/Red/2.png rename to Assets/Img/Garden/Plants/RedRose/2.png diff --git a/Assets/Img/Garden/Plants/Rose/Red/3.png b/Assets/Img/Garden/Plants/RedRose/3.png similarity index 100% rename from Assets/Img/Garden/Plants/Rose/Red/3.png rename to Assets/Img/Garden/Plants/RedRose/3.png diff --git a/Build.php b/Build.php index 1f2bce5..b75e1f8 100644 --- a/Build.php +++ b/Build.php @@ -117,6 +117,7 @@ function GeneratePlantImageManifest( return; } + foreach ( new DirectoryIterator( $PlantsDirectory @@ -133,143 +134,78 @@ function GeneratePlantImageManifest( $PlantName = $PlantDirectory->getFilename(); - $Manifest[$PlantName] = []; + $Images = []; foreach ( new DirectoryIterator( $PlantDirectory->getPathname() ) - as $VariantDirectory + as $Image + ) { + if (!$Image->isFile()) { + continue; + } + + if ( + preg_match( + "/^([0-9]+)\\.png$/i", + $Image->getFilename(), + $Matches + ) !== 1 + ) { + continue; + } + + $ImageNumber = + (int) $Matches[1]; + + if ($ImageNumber < 1) { + continue; + } + + $Images[$ImageNumber] = + "/Assets/Img/Garden/Plants/" . + $PlantName . + "/" . + $Image->getFilename(); + } + + + ksort( + $Images, + SORT_NUMERIC + ); + + + $ExpectedNumber = 1; + + foreach ( + array_keys($Images) + as $ImageNumber ) { if ( - $VariantDirectory->isDot() || - !$VariantDirectory->isDir() + $ImageNumber !== + $ExpectedNumber ) { - continue; + throw new RuntimeException( + "Missing plant image " . + $ExpectedNumber . + ".png in " . + $PlantName + ); } - $VariantName = - $VariantDirectory->getFilename(); - - $Images = []; - - - foreach ( - new DirectoryIterator( - $VariantDirectory->getPathname() - ) - as $Image - ) { - if (!$Image->isFile()) { - continue; - } - - if ( - preg_match( - "/^([0-9]+)\.png$/i", - $Image->getFilename(), - $Matches - ) !== 1 - ) { - continue; - } - - $ImageNumber = - (int) $Matches[1]; - - if ($ImageNumber < 1) { - continue; - } - - $Images[$ImageNumber] = - "/Assets/Img/Garden/Plants/" . - $PlantName . - "/" . - $VariantName . - "/" . - $Image->getFilename(); - } - - - /* - * Sort numerically: - * - * 1.png - * 2.png - * ... - * 10.png - * - * instead of: - * - * 1.png - * 10.png - * 2.png - */ - - ksort( - $Images, - SORT_NUMERIC - ); - - - /* - * Require consecutive numbering. - */ - - $ExpectedNumber = 1; - - foreach ( - array_keys($Images) - as $ImageNumber - ) { - if ( - $ImageNumber !== - $ExpectedNumber - ) { - throw new RuntimeException( - "Missing plant image " . - $ExpectedNumber . - ".png in " . - $PlantName . - "/" . - $VariantName - ); - } - - $ExpectedNumber++; - } - - - if (count($Images) === 0) { - continue; - } - - $Manifest[ - $PlantName - ][ - $VariantName - ] = array_values( - $Images - ); + $ExpectedNumber++; } - if ( - count( - $Manifest[$PlantName] - ) === 0 - ) { - unset( - $Manifest[$PlantName] - ); - } else { - ksort( - $Manifest[$PlantName], - SORT_NATURAL | - SORT_FLAG_CASE - ); + if (count($Images) === 0) { + continue; } + + $Manifest[$PlantName] = + array_values($Images); } @@ -305,7 +241,6 @@ function GeneratePlantImageManifest( file_put_contents( $ManifestDirectory . "/PlantImages.js", - "const PlantImages = " . $Json . ";" . diff --git a/Pages/Bits/SubNavbar.php b/Pages/Bits/SubNavbar.php index 1876c8b..4b1ac8d 100644 --- a/Pages/Bits/SubNavbar.php +++ b/Pages/Bits/SubNavbar.php @@ -35,6 +35,17 @@ $SubNavbarCurrent ??= null; ENT_QUOTES, "UTF-8" ) ?>" + + aria-current="" + > "/Pages/Garden.html", + "Shop" => "/Pages/Shop.html", "Plots" => "#Plots", "Seeds" => "#Seeds", "Player" => "#Player", "Leaderboard" => "#Leaderboard" ]; -$SubNavbarCurrent = "Plots"; +$SubNavbarCurrent = "Garden"; ?> @@ -28,6 +31,9 @@ $SubNavbarCurrent = "Plots";
Dew: 0 +
+ Next harvest: + Nothing planted
@@ -127,6 +133,7 @@ $SubNavbarCurrent = "Plots"; + diff --git a/Pages/Profile.php b/Pages/Profile.php index ad2ea51..a9388a7 100644 --- a/Pages/Profile.php +++ b/Pages/Profile.php @@ -261,6 +261,8 @@ $SubNavbarCurrent = "Player"; + + diff --git a/Pages/Shop.php b/Pages/Shop.php new file mode 100644 index 0000000..3c109f1 --- /dev/null +++ b/Pages/Shop.php @@ -0,0 +1,76 @@ + "/Pages/Garden.html", + "Shop" => "/Pages/Shop.html", + "Seeds" => "#Seeds" +]; + +$SubNavbarCurrent = "Shop"; + +?> + + + + + + + + + + +
+

Garden shop

+ +
+ Dew: + 0 +
+ +

+ Buy seeds here, then take them to the Garden + to plant them. Seeds are consumable: planting + one removes it from your inventory. +

+ +

+ Plants discovered through mutations become + purchasable automatically. Their seed price is + based on the cheapest recipe you've discovered, + plus 10 Dew per hour of mutation time. +

+ +

+ Harvesting a plant returns 150% of its current + seed price, rounded up. +

+ +
+

Seeds

+ +
+
+ +

+ Choose a seed to buy. +

+
+ + + + + + + + + + diff --git a/Scripts/Garden/Economy.js b/Scripts/Garden/Economy.js new file mode 100644 index 0000000..599076e --- /dev/null +++ b/Scripts/Garden/Economy.js @@ -0,0 +1,578 @@ +const MutationTimeFeePerHour = 10; + +const PlantKeysById = new Map(); +const PlantsById = new Map(); +const MutationsById = new Map(); + + +for ( + const [PlantKey, Plant] + of Object.entries(Plants) +) { + if (PlantsById.has(Plant.Id)) { + throw new Error( + "Duplicate plant ID: " + + Plant.Id + ); + } + + PlantKeysById.set( + Plant.Id, + PlantKey + ); + + PlantsById.set( + Plant.Id, + Plant + ); +} + + +for ( + const Mutation + of Object.values(MutationSets) +) { + if (MutationsById.has(Mutation.Id)) { + throw new Error( + "Duplicate mutation ID: " + + Mutation.Id + ); + } + + MutationsById.set( + Mutation.Id, + Mutation + ); +} + + +function GetPlantKeyById( + PlantId +) { + return PlantKeysById.get( + Number(PlantId) + ) ?? null; +} + + +function GetPlantById( + PlantId +) { + return PlantsById.get( + Number(PlantId) + ) ?? null; +} + + +function GetMutationById( + MutationId +) { + return MutationsById.get( + Number(MutationId) + ) ?? null; +} + + +function HasDiscoveredPlant( + SaveData, + PlantId +) { + return SaveData.Discoveries.Plants.includes( + Number(PlantId) + ); +} + + +function HasDiscoveredMutation( + SaveData, + MutationId +) { + return SaveData.Discoveries.Mutations.includes( + Number(MutationId) + ); +} + + +function DiscoverPlant( + SaveData, + PlantId +) { + PlantId = Number(PlantId); + + if ( + !HasDiscoveredPlant( + SaveData, + PlantId + ) + ) { + SaveData.Discoveries.Plants.push( + PlantId + ); + } +} + + +function DiscoverMutation( + SaveData, + MutationId +) { + MutationId = Number( + MutationId + ); + + if ( + !HasDiscoveredMutation( + SaveData, + MutationId + ) + ) { + SaveData.Discoveries.Mutations.push( + MutationId + ); + } +} + + +function IsPlantAvailableInShop( + SaveData, + Plant +) { + if ( + Plant.Shop?.StartingPlant === + true + ) { + return true; + } + + return HasDiscoveredPlant( + SaveData, + Plant.Id + ); +} + + +function GetSeedCount( + SaveData, + PlantId +) { + return Number( + SaveData.Inventory.Seeds[ + String(PlantId) + ] ?? 0 + ); +} + + +function AddSeed( + SaveData, + PlantId, + Amount = 1 +) { + const InventoryKey = + String(PlantId); + + SaveData.Inventory.Seeds[ + InventoryKey + ] ??= 0; + + SaveData.Inventory.Seeds[ + InventoryKey + ] += Amount; +} + + +function TakeSeed( + SaveData, + PlantId, + Amount = 1 +) { + const CurrentAmount = + GetSeedCount( + SaveData, + PlantId + ); + + if (CurrentAmount < Amount) { + return false; + } + + SaveData.Inventory.Seeds[ + String(PlantId) + ] = CurrentAmount - Amount; + + return true; +} + + +function GetPlantShopCost( + SaveData, + PlantId, + Cache = new Map(), + Visiting = new Set() +) { + PlantId = Number(PlantId); + + if (Cache.has(PlantId)) { + return Cache.get(PlantId); + } + + const Plant = + GetPlantById( + PlantId + ); + + if (Plant === null) { + return null; + } + + if ( + Plant.Shop?.StartingPlant === + true + ) { + const BaseCost = Number( + Plant.Shop.BaseCost + ); + + if ( + !Number.isFinite(BaseCost) || + BaseCost < 0 + ) { + return null; + } + + const Cost = + Math.ceil(BaseCost); + + Cache.set( + PlantId, + Cost + ); + + return Cost; + } + + if ( + !HasDiscoveredPlant( + SaveData, + PlantId + ) + ) { + return null; + } + + if (Visiting.has(PlantId)) { + return null; + } + + Visiting.add( + PlantId + ); + + let CheapestCost = null; + + + for ( + const Mutation + of Object.values( + MutationSets + ) + ) { + if ( + !HasDiscoveredMutation( + SaveData, + Mutation.Id + ) + ) { + continue; + } + + if ( + !Mutation.Relations + ?.PlantsCreated + ?.includes( + PlantId + ) + ) { + continue; + } + + const IngredientCost = + GetMutationIngredientCost( + SaveData, + Mutation, + Cache, + Visiting + ); + + if (IngredientCost === null) { + continue; + } + + const MutationCost = + IngredientCost + + GetMutationTimeFee( + Mutation + ); + + if ( + CheapestCost === null || + MutationCost < CheapestCost + ) { + CheapestCost = + MutationCost; + } + } + + + Visiting.delete( + PlantId + ); + + Cache.set( + PlantId, + CheapestCost + ); + + return CheapestCost; +} + + +function GetMutationIngredientCost( + SaveData, + Mutation, + Cache, + Visiting +) { + let TotalCost = 0; + + + for ( + const Row + of Mutation.Pattern ?? [] + ) { + for ( + const Matcher + of Row + ) { + if ( + Matcher === null || + Matcher === "Empty" + ) { + continue; + } + + const MatcherCost = + GetMatcherSeedCost( + SaveData, + Matcher, + Cache, + Visiting + ); + + if (MatcherCost === null) { + return null; + } + + TotalCost += + MatcherCost; + } + } + + + return TotalCost; +} + + +function GetMatcherSeedCost( + SaveData, + Matcher, + Cache, + Visiting +) { + if (typeof Matcher === "string") { + const Plant = + Plants[Matcher]; + + if (Plant === undefined) { + return null; + } + + return GetPlantShopCost( + SaveData, + Plant.Id, + Cache, + Visiting + ); + } + + + if ( + typeof Matcher !== "object" || + Matcher === null + ) { + return null; + } + + + if ( + typeof Matcher.Plant === + "string" + ) { + const Plant = + Plants[Matcher.Plant]; + + if (Plant === undefined) { + return null; + } + + return GetPlantShopCost( + SaveData, + Plant.Id, + Cache, + Visiting + ); + } + + + let CheapestCost = null; + + + for ( + const Plant + of Object.values(Plants) + ) { + if ( + !IsPlantAvailableInShop( + SaveData, + Plant + ) + ) { + continue; + } + + if ( + !DoesPlantMatchRecipeMatcher( + Plant, + Matcher + ) + ) { + continue; + } + + const Cost = + GetPlantShopCost( + SaveData, + Plant.Id, + Cache, + Visiting + ); + + if (Cost === null) { + continue; + } + + if ( + CheapestCost === null || + Cost < CheapestCost + ) { + CheapestCost = Cost; + } + } + + + return CheapestCost; +} + + +function DoesPlantMatchRecipeMatcher( + Plant, + Matcher +) { + const Tags = + Plant.Tags ?? []; + + if ( + Array.isArray( + Matcher.Tags + ) && + !Matcher.Tags.every( + Tag => + Tags.includes(Tag) + ) + ) { + return false; + } + + if ( + Array.isArray( + Matcher.TagsAny + ) && + Matcher.TagsAny.length > 0 && + !Matcher.TagsAny.some( + Tag => + Tags.includes(Tag) + ) + ) { + return false; + } + + if ( + Array.isArray( + Matcher.TagsNot + ) && + Matcher.TagsNot.some( + Tag => + Tags.includes(Tag) + ) + ) { + return false; + } + + return true; +} + + +function GetMutationTimeFee( + Mutation +) { + const MutationTime = Number( + Mutation.MutationTime ?? + Mutation.Cooldown ?? + 0 + ); + + if ( + !Number.isFinite( + MutationTime + ) || + MutationTime <= 0 + ) { + return 0; + } + + return Math.ceil( + MutationTime / + 3600000 * + MutationTimeFeePerHour + ); +} + + +function GetPlantHarvestReward( + SaveData, + PlantId +) { + const Cost = + GetPlantShopCost( + SaveData, + PlantId + ); + + if (Cost === null) { + return null; + } + + return Math.ceil( + Cost * 1.5 + ); +} diff --git a/Scripts/Garden/Garden.js b/Scripts/Garden/Garden.js index 497f49a..ce14d30 100644 --- a/Scripts/Garden/Garden.js +++ b/Scripts/Garden/Garden.js @@ -1,62 +1,34 @@ let GameSave; -let SelectedSeed = "Rose"; +let SelectedSeedId = null; const GardenUpdateInterval = 1000; async function StartGame() { GameSave = await LoadGame(); - NormalizeSave(); + EnsureSelectedSeed(); RenderGame(); setInterval( - RenderGarden, + () => { + RenderGarden(); + RenderNextHarvest(); + }, GardenUpdateInterval ); + await RenderUser(); await RenderLeaderboard(); } -function NormalizeSave() { - GameSave.Currency ??= {}; - GameSave.Currency.Dew ??= 0; - - GameSave.Seeds ??= {}; - GameSave.Seeds.Unlocked ??= [ - "Rose" - ]; - - GameSave.Statistics ??= {}; - GameSave.Statistics.CurrencyEarned ??= {}; - - GameSave.Statistics.CurrencyEarned.Dew ??= - GameSave.Currency.Dew; - - GameSave.Garden ??= { - Width: 3, - Height: 3, - Plots: [] - }; - - const RequiredPlots = - GameSave.Garden.Width * - GameSave.Garden.Height; - - while ( - GameSave.Garden.Plots.length < - RequiredPlots - ) { - GameSave.Garden.Plots.push(null); - } -} - - function RenderGame() { + EnsureSelectedSeed(); RenderCurrency(); RenderSeeds(); RenderGarden(); + RenderNextHarvest(); } @@ -67,7 +39,46 @@ function RenderCurrency() { ); DewAmount.textContent = - GameSave.Currency.Dew; + GameSave.Currency.Dew + .toLocaleString(); +} + + +function EnsureSelectedSeed() { + if ( + SelectedSeedId !== null && + GetSeedCount( + GameSave, + SelectedSeedId + ) > 0 + ) { + return; + } + + SelectedSeedId = null; + + const SeedIds = Object.keys( + GameSave.Inventory.Seeds + ) + .map(Number) + .filter( + PlantId => + GetSeedCount( + GameSave, + PlantId + ) > 0 && + GetPlantById( + PlantId + ) !== null + ) + .sort( + (A, B) => A - B + ); + + if (SeedIds.length > 0) { + SelectedSeedId = + SeedIds[0]; + } } @@ -79,14 +90,72 @@ function RenderSeeds() { SeedList.replaceChildren(); + const SeedIds = Object.keys( + GameSave.Inventory.Seeds + ) + .map(Number) + .filter( + PlantId => + GetSeedCount( + GameSave, + PlantId + ) > 0 && + GetPlantById( + PlantId + ) !== null + ) + .sort( + (A, B) => A - B + ); + + + if (SeedIds.length === 0) { + const EmptyMessage = + document.createElement( + "p" + ); + + EmptyMessage.className = + "SeedEmpty"; + + EmptyMessage.append( + "You don't have any seeds. Visit the " + ); + + const ShopLink = + document.createElement( + "a" + ); + + ShopLink.href = + "/Pages/Shop.html"; + + ShopLink.textContent = + "shop"; + + EmptyMessage.append( + ShopLink, + "." + ); + + SeedList.appendChild( + EmptyMessage + ); + + return; + } + + for ( const PlantId - of GameSave.Seeds.Unlocked + of SeedIds ) { const Plant = - Plants[PlantId]; + GetPlantById( + PlantId + ); - if (Plant === undefined) { + if (Plant === null) { continue; } @@ -101,11 +170,16 @@ function RenderSeeds() { Button.type = "button"; Button.textContent = - Plant.Name; + Plant.Name + + " ×" + + GetSeedCount( + GameSave, + PlantId + ); if ( PlantId === - SelectedSeed + SelectedSeedId ) { Button.setAttribute( "aria-current", @@ -116,7 +190,7 @@ function RenderSeeds() { Button.addEventListener( "click", () => { - SelectedSeed = + SelectedSeedId = PlantId; RenderSeeds(); @@ -180,10 +254,6 @@ function CreatePlotElement( Button.type = "button"; - /* - * Empty plot. - */ - if (Plot === null) { Button.classList.add( "GardenPlotEmpty" @@ -215,10 +285,6 @@ function CreatePlotElement( } - /* - * Plant image. - */ - const ImagePath = GetPlantImage( Plot, @@ -243,13 +309,24 @@ function CreatePlotElement( Button.appendChild( Image ); + } else { + const MissingImage = + document.createElement( + "span" + ); + + MissingImage.className = + "GardenPlantMissing"; + + MissingImage.textContent = + Plant.Name; + + Button.appendChild( + MissingImage + ); } - /* - * Mature plants can be harvested. - */ - if ( IsPlantMature( Plot, @@ -288,6 +365,7 @@ function CreatePlotElement( "% grown"; } + return Button; } @@ -303,34 +381,63 @@ async function PlantSeed( return; } + EnsureSelectedSeed(); + + if (SelectedSeedId === null) { + SetGardenMessage( + "You don't have any seeds to plant." + ); + + return; + } + + + const Plant = + GetPlantById( + SelectedSeedId + ); + + const PlantKey = + GetPlantKeyById( + SelectedSeedId + ); + if ( - !GameSave.Seeds.Unlocked - .includes( - SelectedSeed - ) + Plant === null || + PlantKey === null ) { return; } + + if ( + !TakeSeed( + GameSave, + SelectedSeedId + ) + ) { + EnsureSelectedSeed(); + RenderSeeds(); + + return; + } + + GameSave.Garden.Plots[ PlotIndex ] = { - Plant: SelectedSeed, - - AddedTags: [], - - VisualVariant: null, - + Plant: PlantKey, PlantedAt: Date.now() }; + SetGardenMessage( "Planted " + - Plants[SelectedSeed].Name + + Plant.Name + "." ); - RenderGarden(); + RenderGame(); await SaveGame( GameSave @@ -354,6 +461,7 @@ async function HarvestPlant( Plants[Plot.Plant]; if ( + Plant === undefined || !IsPlantMature( Plot, Plant @@ -362,22 +470,40 @@ async function HarvestPlant( return; } - GiveReward( - Plant.Reward - ); + + const RewardAmount = + GetPlantHarvestReward( + GameSave, + Plant.Id + ); + + if (RewardAmount === null) { + SetGardenMessage( + "Couldn't calculate the harvest reward for " + + Plant.Name + + "." + ); + + return; + } + + + GiveReward({ + Currency: "Dew", + Amount: RewardAmount + }); GameSave.Garden.Plots[ PlotIndex ] = null; + SetGardenMessage( "Harvested " + Plant.Name + " for " + - Plant.Reward.Amount + - " " + - Plant.Reward.Currency + - "." + RewardAmount + + " Dew." ); RenderGame(); @@ -437,63 +563,17 @@ function GetPlantGrowthProgress( ); } + function GetPlantTags( Plot, Plant ) { return [ - ...Plant.Tags, - ...(Plot.AddedTags ?? []) + ...(Plant.Tags ?? []) ]; } -function GetPlantVariant( - Plot, - Plant -) { - if ( - Plot.VisualVariant !== - null - ) { - return Plot.VisualVariant; - } - - const Tags = - GetPlantTags( - Plot, - Plant - ); - - const Variants = [ - ...Plant.Variants - ].sort( - (A, B) => - B.Priority - - A.Priority - ); - - for ( - const Variant - of Variants - ) { - const Matches = - Variant.Tags.every( - Tag => - Tags.includes( - Tag - ) - ); - - if (Matches) { - return Variant.Name; - } - } - - return "Default"; -} - - function IsPlantMature( Plot, Plant @@ -511,74 +591,9 @@ function GetPlantImages( Plot, Plant ) { - const Variants = - PlantImages[ - Plot.Plant - ]; - - if (Variants === undefined) { - return []; - } - - - const Variant = - GetPlantVariant( - Plot, - Plant - ); - - - /* - * Preferred variant. - */ - - if ( - Array.isArray( - Variants[Variant] - ) && - Variants[Variant].length > 0 - ) { - return Variants[ - Variant - ]; - } - - - /* - * Default visual fallback. - */ - - if ( - Array.isArray( - Variants.Default - ) && - Variants.Default.length > 0 - ) { - return Variants.Default; - } - - - /* - * Absolute fallback: - * use the first variant that has images. - */ - - for ( - const Images - of Object.values( - Variants - ) - ) { - if ( - Array.isArray(Images) && - Images.length > 0 - ) { - return Images; - } - } - - - return []; + return PlantImages[ + Plot.Plant + ] ?? []; } @@ -642,6 +657,198 @@ function SetGardenMessage( } +function GetNextHarvestTime() { + let NextHarvestTime = null; + + + for ( + const Plot + of GameSave.Garden.Plots + ) { + if (Plot === null) { + continue; + } + + + const Plant = + Plants[Plot.Plant]; + + if (Plant === undefined) { + continue; + } + + + const HarvestTime = + Plot.PlantedAt + + Plant.GrowthTime; + + + if ( + HarvestTime <= + Date.now() + ) { + return Date.now(); + } + + + if ( + NextHarvestTime === null || + HarvestTime < + NextHarvestTime + ) { + NextHarvestTime = + HarvestTime; + } + } + + + return NextHarvestTime; +} + + +function RenderNextHarvest() { + const Element = + document.getElementById( + "NextHarvest" + ); + + if (Element === null) { + return; + } + + + const HarvestTime = + GetNextHarvestTime(); + + + if (HarvestTime === null) { + Element.textContent = + "Nothing planted"; + + Element.removeAttribute( + "title" + ); + + return; + } + + + const Remaining = + HarvestTime - + Date.now(); + + + if (Remaining <= 0) { + Element.textContent = + "Ready now!"; + + Element.removeAttribute( + "title" + ); + + return; + } + + + Element.textContent = + FormatRemainingTime( + Remaining + ); + + + const HarvestDate = + new Date( + HarvestTime + ); + + + Element.title = + "Ready at " + + HarvestDate.toLocaleTimeString( + [], + { + hour: "2-digit", + minute: "2-digit" + } + ); +} + + +function FormatRemainingTime( + Milliseconds +) { + const TotalSeconds = + Math.ceil( + Milliseconds / + 1000 + ); + + const Days = + Math.floor( + TotalSeconds / + 86400 + ); + + const Hours = + Math.floor( + ( + TotalSeconds % + 86400 + ) / + 3600 + ); + + const Minutes = + Math.floor( + ( + TotalSeconds % + 3600 + ) / + 60 + ); + + const Seconds = + TotalSeconds % + 60; + + + if (Days > 0) { + return ( + Days + + "d " + + Hours + + "h" + ); + } + + + if (Hours > 0) { + return ( + Hours + + "h " + + Minutes + + "m" + ); + } + + + if (Minutes > 0) { + return ( + Minutes + + "m " + + Seconds + + "s" + ); + } + + + return ( + Seconds + + "s" + ); +} + + document.addEventListener( "DOMContentLoaded", StartGame diff --git a/Scripts/Garden/Mutations.js b/Scripts/Garden/Mutations.js index 2ecea00..fa60c37 100644 --- a/Scripts/Garden/Mutations.js +++ b/Scripts/Garden/Mutations.js @@ -1,10 +1,24 @@ const MutationSets = { - DarkRose: { - Name: "Dark Rose", + RoseDarkening: { + Id: 1, + Name: "Rose darkening", + + Description: + "A mutation that seems to corrupt roses.", Chance: 0.1, Cooldown: 120000, + Relations: { + PlantsUsed: [ + 1 + ], + + PlantsCreated: [ + 2 + ] + }, + Pattern: [ [ { @@ -35,6 +49,60 @@ const MutationSets = { ] ], + Failure: "Keep" + }, + + RoseDyePurple: { + Id: 2, + Name: "Rose dying purple", + + Description: + "A colourful mutation that makes a purple rose.", + + Chance: 1.0, + Cooldown: 100000, + + Relations: { + PlantsUsed: [ + 1, + 3 + ], + + PlantsCreated: [ + 4 + ] + }, + + Pattern: [ + [ + { + Tags: [ + "Rose", + "Red" + ], + Capture: "ParentA" + }, + + "Empty", + + { + Tags: [ + "Rose", + "Blue" + ], + Capture: "ParentB" + } + ] + ], + + Success: [ + [ + "Empty", + "PurpleRose", + "Empty" + ] + ], + Failure: "Keep" } }; diff --git a/Scripts/Garden/Plants.js b/Scripts/Garden/Plants.js index 488e326..829d2b1 100644 --- a/Scripts/Garden/Plants.js +++ b/Scripts/Garden/Plants.js @@ -1,6 +1,10 @@ const Plants = { - Rose: { - Name: "Rose", + RedRose: { + Id: 1, + Name: "Red Rose", + + Description: + "A common red rose.", Tags: [ "Rose", @@ -8,29 +12,74 @@ const Plants = { "Red" ], - GrowthTime: 60000, - - Variants: [ - { - Name: "Red", - Tags: [ - "Red" - ], - Priority: 10 - }, - - { - Name: "Default", - Tags: [], - Priority: 0 - } - ], + GrowthTime: 3600000, Effects: [], - Reward: { - Currency: "Dew", - Amount: 5 + Shop: { + StartingPlant: true, + BaseCost: 10 + } + }, + + DarkRose: { + Id: 2, + Name: "Dark Rose", + + Description: + "A jet-black rose.", + + Tags: [ + "Rose", + "Flower", + "Black" + ], + + GrowthTime: 4000000, + + Effects: [], + + Shop: { + StartingPlant: false + } + }, + + BlueRose: { + Id: 3, + Name: "Blue Rose", + + Tags: [ + "Rose", + "Flower", + "Blue" + ], + + GrowthTime: 3600000, + + Effects: [], + + Shop: { + StartingPlant: true, + BaseCost: 15 + } + }, + + PurpleRose: { + Id: 4, + Name: "Purple Rose", + + Tags: [ + "Rose", + "Flower", + "Purple" + ], + + GrowthTime: 3600000, + + Effects: [], + + Shop: { + StartingPlant: false } } }; diff --git a/Scripts/Garden/Save.js b/Scripts/Garden/Save.js index 2ea285d..bdd485f 100644 --- a/Scripts/Garden/Save.js +++ b/Scripts/Garden/Save.js @@ -5,8 +5,45 @@ const ApiUrl = "https://api.srhtnin.garden"; function CreateNewSave() { + const StartingSeeds = {}; + const StartingPlants = []; + + if ( + typeof Plants !== + "undefined" + ) { + for ( + const Plant + of Object.values(Plants) + ) { + if ( + Plant.Shop + ?.StartingPlant !== + true + ) { + continue; + } + + StartingSeeds[ + String(Plant.Id) + ] = 1; + + StartingPlants.push( + Plant.Id + ); + } + } + + if ( + StartingPlants.length === 0 + ) { + StartingSeeds["1"] = 1; + StartingPlants.push(1); + } + + return { - Version: 1, + Version: 2, Revision: 0, LastSavedAt: Date.now(), @@ -17,13 +54,17 @@ function CreateNewSave() { Statistics: { CurrencyEarned: { Dew: 0 - } + }, + + CurrencySpent: { + Dew: 0 + }, + + SeedsPurchased: 0 }, - Seeds: { - Unlocked: [ - "Rose" - ] + Inventory: { + Seeds: StartingSeeds }, Upgrades: { @@ -37,10 +78,7 @@ function CreateNewSave() { }, Discoveries: { - Plants: [ - "Rose" - ], - + Plants: StartingPlants, Mutations: [] }, @@ -49,6 +87,445 @@ function CreateNewSave() { } +function NormalizeSaveData( + SaveData +) { + SaveData.Version = Math.max( + Number( + SaveData.Version ?? 1 + ), + 2 + ); + + SaveData.Revision = Number( + SaveData.Revision ?? 0 + ); + + SaveData.LastSavedAt = Number( + SaveData.LastSavedAt ?? + Date.now() + ); + + + SaveData.Currency ??= {}; + SaveData.Currency.Dew = Number( + SaveData.Currency.Dew ?? 0 + ); + + + SaveData.Statistics ??= {}; + SaveData.Statistics.CurrencyEarned ??= {}; + SaveData.Statistics.CurrencySpent ??= {}; + + SaveData.Statistics.CurrencyEarned.Dew ??= + SaveData.Currency.Dew; + + SaveData.Statistics.CurrencySpent.Dew ??= 0; + SaveData.Statistics.SeedsPurchased ??= 0; + + + const HadSeedInventory = + SaveData.Inventory + ?.Seeds !== undefined; + + SaveData.Inventory ??= {}; + SaveData.Inventory.Seeds ??= {}; + + + /* + * Migrate the old infinite seed unlock list + * into the new finite seed inventory. + */ + + for ( + const LegacyPlant + of SaveData.Seeds + ?.Unlocked ?? [] + ) { + const PlantId = + ResolveLegacyPlantId( + LegacyPlant + ); + + if (PlantId === null) { + continue; + } + + SaveData.Inventory.Seeds[ + String(PlantId) + ] ??= 1; + } + + + /* + * Existing saves predate inventory, so give them + * one seed for each starting plant as a migration + * safety net. New saves already contain these. + */ + + if (!HadSeedInventory) { + for ( + const Plant + of GetStartingPlantDefinitions() + ) { + SaveData.Inventory.Seeds[ + String(Plant.Id) + ] ??= 1; + } + } + + + for ( + const InventoryKey + of Object.keys( + SaveData.Inventory.Seeds + ) + ) { + const Amount = Math.max( + 0, + Math.floor( + Number( + SaveData.Inventory.Seeds[ + InventoryKey + ] ?? 0 + ) + ) + ); + + SaveData.Inventory.Seeds[ + InventoryKey + ] = Amount; + } + + + delete SaveData.Seeds; + + + SaveData.Discoveries ??= {}; + + SaveData.Discoveries.Plants = + NormalizePlantDiscoveries( + SaveData.Discoveries.Plants ?? [] + ); + + SaveData.Discoveries.Mutations = + NormalizeMutationDiscoveries( + SaveData.Discoveries.Mutations ?? [] + ); + + + for ( + const Plant + of GetStartingPlantDefinitions() + ) { + if ( + !SaveData.Discoveries.Plants + .includes( + Plant.Id + ) + ) { + SaveData.Discoveries.Plants.push( + Plant.Id + ); + } + } + + SaveData.Discoveries.Plants.sort( + (A, B) => A - B + ); + + SaveData.Discoveries.Mutations.sort( + (A, B) => A - B + ); + + + SaveData.Upgrades ??= {}; + SaveData.Upgrades.PlotSize ??= 0; + + + SaveData.Garden ??= { + Width: 3, + Height: 3, + Plots: [] + }; + + SaveData.Garden.Width = Math.max( + 1, + Number( + SaveData.Garden.Width ?? 3 + ) + ); + + SaveData.Garden.Height = Math.max( + 1, + Number( + SaveData.Garden.Height ?? 3 + ) + ); + + SaveData.Garden.Plots ??= []; + + + for ( + let PlotIndex = 0; + PlotIndex < + SaveData.Garden.Plots.length; + PlotIndex++ + ) { + const Plot = + SaveData.Garden.Plots[ + PlotIndex + ]; + + if (Plot === null) { + continue; + } + + if ( + Plot.Plant === "Rose" + ) { + Plot.Plant = + "RedRose"; + } + + if ( + typeof Plot.Plant === + "number" + ) { + const PlantKey = + ResolvePlantKeyById( + Plot.Plant + ); + + if (PlantKey !== null) { + Plot.Plant = PlantKey; + } + } + + Plot.PlantedAt = Number( + Plot.PlantedAt ?? + Date.now() + ); + + delete Plot.VisualVariant; + delete Plot.AddedTags; + } + + + const RequiredPlots = + SaveData.Garden.Width * + SaveData.Garden.Height; + + while ( + SaveData.Garden.Plots.length < + RequiredPlots + ) { + SaveData.Garden.Plots.push(null); + } + + if ( + SaveData.Garden.Plots.length > + RequiredPlots + ) { + SaveData.Garden.Plots.length = + RequiredPlots; + } + + + SaveData.MutationCooldowns ??= {}; + + + return SaveData; +} + + +function GetStartingPlantDefinitions() { + if ( + typeof Plants === + "undefined" + ) { + return []; + } + + return Object.values(Plants) + .filter( + Plant => + Plant.Shop + ?.StartingPlant === + true + ); +} + + +function ResolveLegacyPlantId( + Value +) { + if ( + typeof Value === + "number" + ) { + return Value; + } + + if ( + typeof Value !== + "string" + ) { + return null; + } + + const LegacyPlantIds = { + Rose: 1, + RedRose: 1, + DarkRose: 2, + BlueRose: 3, + PurpleRose: 4 + }; + + if ( + LegacyPlantIds[Value] !== + undefined + ) { + return LegacyPlantIds[Value]; + } + + if ( + typeof Plants !== + "undefined" && + Plants[Value] !== undefined + ) { + return Plants[Value].Id; + } + + const NumericValue = + Number(Value); + + if ( + Number.isInteger( + NumericValue + ) && + NumericValue > 0 + ) { + return NumericValue; + } + + return null; +} + + +function ResolvePlantKeyById( + PlantId +) { + if ( + typeof Plants === + "undefined" + ) { + return null; + } + + for ( + const [PlantKey, Plant] + of Object.entries(Plants) + ) { + if ( + Plant.Id === + Number(PlantId) + ) { + return PlantKey; + } + } + + return null; +} + + +function NormalizePlantDiscoveries( + Values +) { + const PlantIds = []; + + for ( + const Value + of Values + ) { + const PlantId = + ResolveLegacyPlantId( + Value + ); + + if ( + PlantId !== null && + !PlantIds.includes( + PlantId + ) + ) { + PlantIds.push( + PlantId + ); + } + } + + return PlantIds; +} + + +function NormalizeMutationDiscoveries( + Values +) { + const MutationIds = []; + + for ( + const Value + of Values + ) { + let MutationId = null; + + if ( + typeof Value === + "number" + ) { + MutationId = Value; + } else if ( + typeof Value === + "string" && + typeof MutationSets !== + "undefined" && + MutationSets[Value] !== + undefined + ) { + MutationId = + MutationSets[Value].Id; + } else { + const NumericValue = + Number(Value); + + if ( + Number.isInteger( + NumericValue + ) && + NumericValue > 0 + ) { + MutationId = + NumericValue; + } + } + + if ( + MutationId !== null && + !MutationIds.includes( + MutationId + ) + ) { + MutationIds.push( + MutationId + ); + } + } + + return MutationIds; +} + + function GetSaveKey() { let SaveKey = localStorage.getItem( SaveKeyName @@ -96,7 +573,9 @@ function ReadLocalSave() { } try { - return JSON.parse(RawSave); + return NormalizeSaveData( + JSON.parse(RawSave) + ); } catch (Error) { console.error( "Couldn't read local save:", @@ -185,10 +664,17 @@ async function ReadRemoteSave( return null; } - return Result.SaveData; + return NormalizeSaveData( + Result.SaveData + ); } + async function SaveGame(SaveData) { + NormalizeSaveData( + SaveData + ); + SaveData.Revision++; SaveData.LastSavedAt = Date.now(); @@ -304,5 +790,7 @@ async function LoadGame() { return RemoteSave; } + WriteLocalSave(LocalSave); + return LocalSave; } diff --git a/Scripts/Garden/Shop.js b/Scripts/Garden/Shop.js new file mode 100644 index 0000000..d37ea7c --- /dev/null +++ b/Scripts/Garden/Shop.js @@ -0,0 +1,504 @@ +let ShopSave; +let ShopPurchasePending = false; + + +async function StartShop() { + ShopSave = await LoadGame(); + + RenderShop(); +} + + +function RenderShop() { + RenderShopCurrency(); + RenderShopSeeds(); +} + + +function RenderShopCurrency() { + const DewAmount = + document.getElementById( + "ShopDewAmount" + ); + + if (DewAmount === null) { + return; + } + + DewAmount.textContent = + ShopSave.Currency.Dew + .toLocaleString(); +} + + +function RenderShopSeeds() { + const SeedList = + document.getElementById( + "ShopSeedList" + ); + + SeedList.replaceChildren(); + + + const AvailablePlants = + Object.values(Plants) + .filter( + Plant => + IsPlantAvailableInShop( + ShopSave, + Plant + ) + ) + .sort( + (A, B) => + A.Id - B.Id + ); + + + if ( + AvailablePlants.length === 0 + ) { + const EmptyMessage = + document.createElement( + "p" + ); + + EmptyMessage.className = + "ShopEmpty"; + + EmptyMessage.textContent = + "No seeds are available yet."; + + SeedList.appendChild( + EmptyMessage + ); + + return; + } + + + for ( + const Plant + of AvailablePlants + ) { + SeedList.appendChild( + CreateShopSeedCard( + Plant + ) + ); + } +} + + +function CreateShopSeedCard( + Plant +) { + const Card = + document.createElement( + "article" + ); + + Card.className = + "ShopItem"; + + + const Header = + document.createElement( + "div" + ); + + Header.className = + "ShopItemHeader"; + + + const Number = + document.createElement( + "span" + ); + + Number.className = + "ShopItemNumber"; + + Number.textContent = + "No. " + + String(Plant.Id).padStart( + 3, + "0" + ); + + + const Name = + document.createElement( + "h3" + ); + + Name.textContent = + Plant.Name; + + + Header.append( + Number, + Name + ); + + + const Body = + document.createElement( + "div" + ); + + Body.className = + "ShopItemBody"; + + + const Visual = + CreateShopPlantVisual( + Plant + ); + + + const Details = + document.createElement( + "div" + ); + + Details.className = + "ShopItemDetails"; + + + if ( + typeof Plant.Description === + "string" && + Plant.Description.length > 0 + ) { + const Description = + document.createElement( + "p" + ); + + Description.textContent = + Plant.Description; + + Details.appendChild( + Description + ); + } + + + const Cost = + GetPlantShopCost( + ShopSave, + Plant.Id + ); + + const Reward = + GetPlantHarvestReward( + ShopSave, + Plant.Id + ); + + + Details.appendChild( + CreateShopStat( + "Seed cost", + Cost === null + ? "Unavailable" + : Cost.toLocaleString() + + " Dew" + ) + ); + + Details.appendChild( + CreateShopStat( + "Harvest reward", + Reward === null + ? "Unavailable" + : Reward.toLocaleString() + + " Dew" + ) + ); + + Details.appendChild( + CreateShopStat( + "Inventory", + GetSeedCount( + ShopSave, + Plant.Id + ).toLocaleString() + ) + ); + + + Body.append( + Visual, + Details + ); + + + const BuyButton = + document.createElement( + "button" + ); + + BuyButton.className = + "ShopBuyButton"; + + BuyButton.type = "button"; + + BuyButton.textContent = + Cost === null + ? "Unavailable" + : "Buy seed - " + + Cost.toLocaleString() + + " Dew"; + + BuyButton.disabled = + Cost === null || + ShopSave.Currency.Dew < + Cost || + ShopPurchasePending; + + BuyButton.addEventListener( + "click", + () => { + BuyPlantSeed( + Plant.Id + ); + } + ); + + + Card.append( + Header, + Body, + BuyButton + ); + + + return Card; +} + + +function CreateShopPlantVisual( + Plant +) { + const Visual = + document.createElement( + "div" + ); + + Visual.className = + "ShopPlantVisual"; + + + const PlantKey = + GetPlantKeyById( + Plant.Id + ); + + const Images = + PlantKey === null + ? [] + : PlantImages[ + PlantKey + ] ?? []; + + + if (Images.length === 0) { + const Missing = + document.createElement( + "span" + ); + + Missing.className = + "ShopPlantMissing"; + + Missing.textContent = + "No image"; + + Visual.appendChild( + Missing + ); + + return Visual; + } + + + const Image = + document.createElement( + "img" + ); + + Image.className = + "ShopPlantImage"; + + Image.src = + Images[ + Images.length - 1 + ]; + + Image.alt = + Plant.Name; + + Visual.appendChild( + Image + ); + + + return Visual; +} + + +function CreateShopStat( + Name, + Value +) { + const Row = + document.createElement( + "p" + ); + + Row.className = + "ShopStat"; + + + const Label = + document.createElement( + "span" + ); + + Label.className = + "ShopStatName"; + + Label.textContent = + Name + ":"; + + + const Amount = + document.createElement( + "strong" + ); + + Amount.textContent = + Value; + + + Row.append( + Label, + Amount + ); + + + return Row; +} + + +async function BuyPlantSeed( + PlantId +) { + if (ShopPurchasePending) { + return; + } + + + const Plant = + GetPlantById( + PlantId + ); + + if ( + Plant === null || + !IsPlantAvailableInShop( + ShopSave, + Plant + ) + ) { + return; + } + + + const Cost = + GetPlantShopCost( + ShopSave, + PlantId + ); + + if (Cost === null) { + SetShopMessage( + "That seed doesn't have a purchasable recipe yet." + ); + + return; + } + + + if ( + ShopSave.Currency.Dew < + Cost + ) { + SetShopMessage( + "You don't have enough Dew for " + + Plant.Name + + "." + ); + + return; + } + + + ShopPurchasePending = true; + + ShopSave.Currency.Dew -= + Cost; + + ShopSave.Statistics.CurrencySpent.Dew += + Cost; + + ShopSave.Statistics.SeedsPurchased++; + + AddSeed( + ShopSave, + PlantId + ); + + + SetShopMessage( + "Bought a " + + Plant.Name + + " seed for " + + Cost + + " Dew." + ); + + RenderShop(); + + + await SaveGame( + ShopSave + ); + + + ShopPurchasePending = false; + RenderShop(); +} + + +function SetShopMessage( + Message +) { + const Element = + document.getElementById( + "ShopMessage" + ); + + if (Element !== null) { + Element.textContent = + Message; + } +} + + +document.addEventListener( + "DOMContentLoaded", + StartShop +); diff --git a/Styles/Style.css b/Styles/Style.css index 1926fba..38ee956 100644 --- a/Styles/Style.css +++ b/Styles/Style.css @@ -1060,3 +1060,237 @@ select { align-items: flex-start; } } + +.SeedEmpty { + margin: 0; + + color: var(--Subtext1); +} + +.GardenPlantMissing { + display: flex; + + width: 100%; + height: 100%; + padding: 0.35rem; + + align-items: center; + justify-content: center; + + color: var(--Subtext1); + + font-size: 0.75rem; + line-height: 1.1; + text-align: center; +} + +.ShopStatus { + margin-bottom: 1.5rem; + padding: 0.75rem 1rem; + + background-color: var(--Mantle); + border: 3px solid var(--Surface2); +} + +.ShopSection { + margin-top: 1.5rem; +} + +.ShopSeedList { + display: grid; + grid-template-columns: + repeat( + auto-fit, + minmax(15rem, 1fr) + ); + gap: 1rem; +} + +.ShopItem { + display: flex; + flex-direction: column; + gap: 0.75rem; + + padding: 0.75rem; + + background-color: var(--Mantle); + border: 4px solid var(--Mauve); + + box-shadow: + 3px 3px 0 + var(--Crust); +} + +.ShopItemHeader { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + + align-items: baseline; + + padding-bottom: 0.5rem; + + border-bottom: + 3px solid + var(--Surface2); +} + +.ShopItemHeader h3 { + margin: 0; +} + +.ShopItemNumber { + color: var(--Overlay2); +} + +.ShopItemBody { + display: grid; + grid-template-columns: 5rem minmax(0, 1fr); + gap: 0.75rem; + + align-items: start; +} + +.ShopPlantVisual { + display: flex; + + width: 5rem; + height: 5rem; + + align-items: center; + justify-content: center; + + overflow: hidden; + + background-color: var(--Base); + border: 3px solid var(--Surface2); +} + +.ShopPlantImage { + display: block; + + width: 100%; + height: 100%; + + object-fit: contain; + image-rendering: pixelated; +} + +.ShopPlantMissing { + padding: 0.35rem; + + color: var(--Overlay1); + + font-size: 0.75rem; + text-align: center; +} + +.ShopItemDetails p:last-child { + margin-bottom: 0; +} + +.ShopStat { + display: flex; + justify-content: space-between; + gap: 0.75rem; + + margin-bottom: 0.4rem; +} + +.ShopStatName { + color: var(--Subtext1); +} + +.ShopBuyButton { + width: 100%; + padding: 0.65rem 0.8rem; + + cursor: pointer; + + color: var(--Text); + background-color: var(--Surface0); + + border: 3px solid var(--Surface2); + border-radius: 0; + + box-shadow: + 3px 3px 0 + var(--Crust); + + appearance: none; +} + +.ShopBuyButton:hover:not(:disabled) { + color: var(--Green); + background-color: var(--Surface1); + + border-color: var(--Green); + + transform: translate(1px, 1px); + + box-shadow: + 2px 2px 0 + var(--Crust); +} + +.ShopBuyButton:active:not(:disabled) { + transform: translate(2px, 2px); + + box-shadow: + 1px 1px 0 + var(--Crust); +} + +.ShopBuyButton:focus-visible { + outline: 3px solid var(--Yellow); + outline-offset: 3px; +} + +.ShopBuyButton:disabled { + cursor: not-allowed; + + color: var(--Overlay1); + background-color: var(--Surface0); + + border-color: var(--Surface1); + + box-shadow: none; +} + +.ShopEmpty { + margin: 0; + padding: 1rem; + + color: var(--Subtext1); + background-color: var(--Mantle); + + border: 3px solid var(--Surface2); +} + +#ShopMessage { + margin-top: 1rem; + margin-bottom: 0; + padding: 0.75rem; + + color: var(--Subtext1); + background-color: var(--Mantle); + + border-left: + 4px solid + var(--Blue); +} + +@media (max-width: 700px) { + .ShopSeedList { + grid-template-columns: 1fr; + } + + .ShopItemBody { + grid-template-columns: 4.5rem minmax(0, 1fr); + } + + .ShopPlantVisual { + width: 4.5rem; + height: 4.5rem; + } +}