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
+
+
+
+
+
+ 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;
+}