diff --git a/Assets/Img/Garden.png b/Assets/Img/Garden.png new file mode 100644 index 0000000..efbefee Binary files /dev/null and b/Assets/Img/Garden.png differ diff --git a/Assets/Img/Garden/Plants/Rose/Red/Mature.png b/Assets/Img/Garden/Plants/Rose/Red/Mature.png new file mode 100644 index 0000000..2683080 Binary files /dev/null and b/Assets/Img/Garden/Plants/Rose/Red/Mature.png differ diff --git a/Assets/Img/Garden/Plants/Rose/Red/Seed.png b/Assets/Img/Garden/Plants/Rose/Red/Seed.png new file mode 100644 index 0000000..1d8cb76 Binary files /dev/null and b/Assets/Img/Garden/Plants/Rose/Red/Seed.png differ diff --git a/Assets/Img/Garden/Plants/Rose/Red/Sprout.png b/Assets/Img/Garden/Plants/Rose/Red/Sprout.png new file mode 100644 index 0000000..eb31bd8 Binary files /dev/null and b/Assets/Img/Garden/Plants/Rose/Red/Sprout.png differ diff --git a/Pages/Garden.php b/Pages/Garden.php new file mode 100644 index 0000000..51daee1 --- /dev/null +++ b/Pages/Garden.php @@ -0,0 +1,53 @@ + + + + + + + + + + +
+

Garden

+ +
+ Dew: + 0 +
+ +
+
+

Plots

+ +
+
+ +
+

Seeds

+ +
+
+
+ +

+ Select a seed, then choose an empty plot. +

+
+ + + + + + + diff --git a/Scripts/Garden/Garden.js b/Scripts/Garden/Garden.js new file mode 100644 index 0000000..2297795 --- /dev/null +++ b/Scripts/Garden/Garden.js @@ -0,0 +1,558 @@ +let GameSave; +let SelectedSeed = "Rose"; + +const GardenUpdateInterval = 1000; + + +async function StartGame() { + GameSave = await LoadGame(); + + NormalizeSave(); + + RenderGame(); + + setInterval( + RenderGarden, + GardenUpdateInterval + ); +} + + +function NormalizeSave() { + GameSave.Currency ??= {}; + GameSave.Currency.Dew ??= 0; + + GameSave.Seeds ??= {}; + GameSave.Seeds.Unlocked ??= [ + "Rose" + ]; + + 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() { + RenderCurrency(); + RenderSeeds(); + RenderGarden(); +} + + +function RenderCurrency() { + const DewAmount = + document.getElementById( + "DewAmount" + ); + + DewAmount.textContent = + GameSave.Currency.Dew; +} + + +function RenderSeeds() { + const SeedList = + document.getElementById( + "SeedList" + ); + + SeedList.replaceChildren(); + + for ( + const PlantId + of GameSave.Seeds.Unlocked + ) { + const Plant = + Plants[PlantId]; + + if (Plant === undefined) { + continue; + } + + const Button = + document.createElement( + "button" + ); + + Button.className = + "SeedButton"; + + Button.type = "button"; + + Button.textContent = + Plant.Name; + + if ( + PlantId === + SelectedSeed + ) { + Button.setAttribute( + "aria-current", + "true" + ); + } + + Button.addEventListener( + "click", + () => { + SelectedSeed = + PlantId; + + RenderSeeds(); + + SetGardenMessage( + "Selected " + + Plant.Name + + "." + ); + } + ); + + SeedList.appendChild( + Button + ); + } +} + + +function RenderGarden() { + const GardenGrid = + document.getElementById( + "GardenGrid" + ); + + GardenGrid.style.setProperty( + "--GardenWidth", + GameSave.Garden.Width + ); + + GardenGrid.replaceChildren(); + + GameSave.Garden.Plots.forEach( + (Plot, PlotIndex) => { + const PlotElement = + CreatePlotElement( + Plot, + PlotIndex + ); + + GardenGrid.appendChild( + PlotElement + ); + } + ); +} + + +function CreatePlotElement( + Plot, + PlotIndex +) { + const Button = + document.createElement( + "button" + ); + + Button.className = + "GardenPlot"; + + Button.type = "button"; + + if (Plot === null) { + Button.classList.add( + "GardenPlotEmpty" + ); + + Button.title = + "Empty plot"; + + Button.addEventListener( + "click", + () => { + PlantSeed( + PlotIndex + ); + } + ); + + return Button; + } + + + const Plant = + Plants[Plot.Plant]; + + if (Plant === undefined) { + Button.textContent = "?"; + + return Button; + } + + + const Stage = + GetPlantStage( + Plot, + Plant + ); + + const Image = + document.createElement( + "img" + ); + + Image.className = + "GardenPlant"; + + Image.alt = + Plant.Name + + " - " + + Stage; + + SetPlantImage( + Image, + Plot, + Plant, + Stage + ); + + Button.appendChild( + Image + ); + + + if (Stage === "Mature") { + Button.classList.add( + "GardenPlotMature" + ); + + Button.title = + "Harvest " + + Plant.Name; + + Button.addEventListener( + "click", + () => { + HarvestPlant( + PlotIndex + ); + } + ); + } else { + const Progress = + GetPlantGrowthProgress( + Plot, + Plant + ); + + Button.title = + Plant.Name + + " - " + + Math.floor( + Progress * 100 + ) + + "% grown"; + } + + return Button; +} + + +async function PlantSeed( + PlotIndex +) { + if ( + GameSave.Garden.Plots[ + PlotIndex + ] !== null + ) { + return; + } + + if ( + !GameSave.Seeds.Unlocked + .includes( + SelectedSeed + ) + ) { + return; + } + + GameSave.Garden.Plots[ + PlotIndex + ] = { + Plant: SelectedSeed, + + AddedTags: [], + + VisualVariant: null, + + PlantedAt: Date.now() + }; + + SetGardenMessage( + "Planted " + + Plants[SelectedSeed].Name + + "." + ); + + RenderGarden(); + + await SaveGame( + GameSave + ); +} + + +async function HarvestPlant( + PlotIndex +) { + const Plot = + GameSave.Garden.Plots[ + PlotIndex + ]; + + if (Plot === null) { + return; + } + + const Plant = + Plants[Plot.Plant]; + + if ( + GetPlantStage( + Plot, + Plant + ) !== "Mature" + ) { + return; + } + + GiveReward( + Plant.Reward + ); + + GameSave.Garden.Plots[ + PlotIndex + ] = null; + + SetGardenMessage( + "Harvested " + + Plant.Name + + " for " + + Plant.Reward.Amount + + " " + + Plant.Reward.Currency + + "." + ); + + RenderGame(); + + await SaveGame( + GameSave + ); +} + + +function GiveReward( + Reward +) { + GameSave.Currency[ + Reward.Currency + ] ??= 0; + + GameSave.Currency[ + Reward.Currency + ] += Reward.Amount; +} + + +function GetPlantGrowthProgress( + Plot, + Plant +) { + if ( + Plant.GrowthTime <= 0 + ) { + return 1; + } + + const Age = + Date.now() - + Plot.PlantedAt; + + return Math.min( + Age / + Plant.GrowthTime, + 1 + ); +} + + +function GetPlantStage( + Plot, + Plant +) { + const Progress = + GetPlantGrowthProgress( + Plot, + Plant + ); + + if (Progress < 1 / 3) { + return "Seed"; + } + + if (Progress < 2 / 3) { + return "Sprout"; + } + + return "Mature"; +} + + +function GetPlantTags( + Plot, + Plant +) { + return [ + ...Plant.Tags, + ...(Plot.AddedTags ?? []) + ]; +} + + +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 SetPlantImage( + Image, + Plot, + Plant, + Stage +) { + const Variant = + GetPlantVariant( + Plot, + Plant + ); + + const BasePath = + "/Assets/Img/Garden/Plants/" + + Plot.Plant + + "/" + + Variant + + "/"; + + + /* + * Try the requested stage first. + * + * If that image doesn't exist, + * Mature.png becomes the fallback. + */ + + const Candidates = [ + BasePath + Stage + ".png", + BasePath + "Mature.png" + ]; + + let CandidateIndex = 0; + + Image.src = + Candidates[ + CandidateIndex + ]; + + Image.addEventListener( + "error", + () => { + CandidateIndex++; + + if ( + CandidateIndex < + Candidates.length + ) { + Image.src = + Candidates[ + CandidateIndex + ]; + + return; + } + + Image.remove(); + } + ); +} + + +function SetGardenMessage( + Message +) { + document.getElementById( + "GardenMessage" + ).textContent = Message; +} + + +document.addEventListener( + "DOMContentLoaded", + StartGame +); diff --git a/Scripts/Garden/Mutations.js b/Scripts/Garden/Mutations.js new file mode 100644 index 0000000..2ecea00 --- /dev/null +++ b/Scripts/Garden/Mutations.js @@ -0,0 +1,40 @@ +const MutationSets = { + DarkRose: { + Name: "Dark Rose", + + Chance: 0.1, + Cooldown: 120000, + + Pattern: [ + [ + { + Tags: ["Rose"], + Capture: "ParentA" + }, + "Empty" + ], + + [ + "Empty", + { + Tags: ["Rose"], + Capture: "ParentB" + } + ] + ], + + Success: [ + [ + "Empty", + "DarkRose" + ], + + [ + "DarkRose", + "Empty" + ] + ], + + Failure: "Keep" + } +}; diff --git a/Scripts/Garden/Plants.js b/Scripts/Garden/Plants.js new file mode 100644 index 0000000..488e326 --- /dev/null +++ b/Scripts/Garden/Plants.js @@ -0,0 +1,36 @@ +const Plants = { + Rose: { + Name: "Rose", + + Tags: [ + "Rose", + "Flower", + "Red" + ], + + GrowthTime: 60000, + + Variants: [ + { + Name: "Red", + Tags: [ + "Red" + ], + Priority: 10 + }, + + { + Name: "Default", + Tags: [], + Priority: 0 + } + ], + + Effects: [], + + Reward: { + Currency: "Dew", + Amount: 5 + } + } +}; diff --git a/Scripts/Garden/Save.js b/Scripts/Garden/Save.js new file mode 100644 index 0000000..5e9afed --- /dev/null +++ b/Scripts/Garden/Save.js @@ -0,0 +1,299 @@ +const SaveKeyName = "SarahtoninGardenSaveKey"; +const LocalSaveName = "SarahtoninGarden"; + +const ApiUrl = "https://api.srhtnin.garden"; + + +function CreateNewSave() { + return { + Version: 1, + Revision: 0, + LastSavedAt: Date.now(), + + Currency: { + Dew: 0 + }, + + Seeds: { + Unlocked: [ + "Rose" + ] + }, + + Upgrades: { + PlotSize: 0 + }, + + Garden: { + Width: 3, + Height: 3, + Plots: Array(9).fill(null) + }, + + Discoveries: { + Plants: [ + "Rose" + ], + + Mutations: [] + }, + + MutationCooldowns: {} + }; +} + + +function GetSaveKey() { + let SaveKey = localStorage.getItem( + SaveKeyName + ); + + if (SaveKey !== null) { + return SaveKey; + } + + const Bytes = new Uint8Array(32); + + crypto.getRandomValues(Bytes); + + SaveKey = Array.from( + Bytes, + Byte => Byte + .toString(16) + .padStart(2, "0") + ).join(""); + + localStorage.setItem( + SaveKeyName, + SaveKey + ); + + return SaveKey; +} + + +function WriteLocalSave(SaveData) { + localStorage.setItem( + LocalSaveName, + JSON.stringify(SaveData) + ); +} + + +function ReadLocalSave() { + const RawSave = localStorage.getItem( + LocalSaveName + ); + + if (RawSave === null) { + return null; + } + + try { + return JSON.parse(RawSave); + } catch (Error) { + console.error( + "Couldn't read local save:", + Error + ); + + return null; + } +} + + +async function WriteRemoteSave(SaveData) { + const Response = await fetch( + ApiUrl + "/Save.php", + { + method: "POST", + + headers: { + "Content-Type": "application/json" + }, + + body: JSON.stringify({ + SaveKey: GetSaveKey(), + SaveData: SaveData + }) + } + ); + + if (!Response.ok) { + throw new Error( + "Save API returned HTTP " + + Response.status + ); + } + + const Result = await Response.json(); + + if (!Result.Success) { + throw new Error( + Result.Error ?? + "Remote save failed." + ); + } + + return Result; +} + + +async function ReadRemoteSave() { + const Response = await fetch( + ApiUrl + "/Load.php", + { + method: "POST", + + headers: { + "Content-Type": "application/json" + }, + + body: JSON.stringify({ + SaveKey: GetSaveKey() + }) + } + ); + + if (!Response.ok) { + throw new Error( + "Load API returned HTTP " + + Response.status + ); + } + + const Result = await Response.json(); + + if (!Result.Success) { + throw new Error( + Result.Error ?? + "Remote load failed." + ); + } + + if (!Result.Exists) { + return null; + } + + return Result.SaveData; +} + + +async function SaveGame(SaveData) { + SaveData.Revision++; + SaveData.LastSavedAt = Date.now(); + + WriteLocalSave(SaveData); + + try { + await WriteRemoteSave(SaveData); + } catch (Error) { + console.error( + "Couldn't synchronize garden save:", + Error + ); + } +} + + +async function LoadGame() { + const LocalSave = ReadLocalSave(); + + let RemoteSave = null; + + try { + RemoteSave = await ReadRemoteSave(); + } catch (Error) { + console.error( + "Couldn't retrieve remote save:", + Error + ); + } + + + if ( + LocalSave === null && + RemoteSave === null + ) { + const NewSave = CreateNewSave(); + + WriteLocalSave(NewSave); + + try { + await WriteRemoteSave(NewSave); + } catch (Error) { + console.error( + "Couldn't create remote save:", + Error + ); + } + + return NewSave; + } + + + if ( + LocalSave !== null && + RemoteSave === null + ) { + try { + await WriteRemoteSave(LocalSave); + } catch (Error) { + console.error( + "Couldn't upload local save:", + Error + ); + } + + return LocalSave; + } + + + if ( + LocalSave === null && + RemoteSave !== null + ) { + WriteLocalSave(RemoteSave); + + return RemoteSave; + } + + + if ( + LocalSave.Revision > + RemoteSave.Revision + ) { + try { + await WriteRemoteSave(LocalSave); + } catch (Error) { + console.error( + "Couldn't update remote save:", + Error + ); + } + + return LocalSave; + } + + + if ( + RemoteSave.Revision > + LocalSave.Revision + ) { + WriteLocalSave(RemoteSave); + + return RemoteSave; + } + + + if ( + RemoteSave.LastSavedAt > + LocalSave.LastSavedAt + ) { + WriteLocalSave(RemoteSave); + + return RemoteSave; + } + + return LocalSave; +}