Added shop, inventory, fixed plant and mutation definitions, and more..

This commit is contained in:
sarah committed 2026-08-30 20:47:28 +02:00
1 parent cd2759ce4e
commit 97204f2d3f
15 files changed
+2510 -351

No files matched your search

+58 -123
View File
@@ -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 .
";" .
+11
View File
@@ -35,6 +35,17 @@ $SubNavbarCurrent ??= null;
ENT_QUOTES,
"UTF-8"
) ?>"
<?php if (
$SubNavbarCurrent ===
$Name
): ?>
aria-current="<?= str_starts_with(
$Url,
"#"
)
? "location"
: "page" ?>"
<?php endif; ?>
>
<span
class="SubNavbarPrefix"
+8 -1
View File
@@ -1,15 +1,18 @@
<?php
$PageTitle = "Garden";
$PageSection = "Garden";
$SubNavbarItems = [
"Garden" => "/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";
<div class="GardenStatus">
<strong>Dew:</strong>
<span id="DewAmount">0</span>
<br>
<strong>Next harvest:</strong>
<span id="NextHarvest">Nothing planted</span>
</div>
<div class="GardenLayout">
@@ -127,6 +133,7 @@ $SubNavbarCurrent = "Plots";
<script src="/Scripts/Garden/Plants.js"></script>
<script src="/Scripts/Garden/Mutations.js"></script>
<script src="/Scripts/Garden/Save.js"></script>
<script src="/Scripts/Garden/Economy.js"></script>
<script src="/Scripts/Garden/Users.js"></script>
<script src="/Scripts/Garden/Leaderboard.js"></script>
<script src="/Scripts/Garden/Garden.js"></script>
+2
View File
@@ -261,6 +261,8 @@ $SubNavbarCurrent = "Player";
<script src="/Scripts/SubNavbar.js"></script>
<script src="/Scripts/Garden/Plants.js"></script>
<script src="/Scripts/Garden/Mutations.js"></script>
<script src="/Scripts/Garden/Save.js"></script>
<script src="/Scripts/Garden/Users.js"></script>
<script src="/Scripts/Garden/Profile.js"></script>
+76
View File
@@ -0,0 +1,76 @@
<?php
$PageTitle = "Shop";
$PageSection = "Garden";
$SubNavbarItems = [
"Garden" => "/Pages/Garden.html",
"Shop" => "/Pages/Shop.html",
"Seeds" => "#Seeds"
];
$SubNavbarCurrent = "Shop";
?>
<!DOCTYPE html>
<html lang="en" data-palette="CatppuccinMocha">
<?php require __DIR__ . "/Bits/Head.php"; ?>
<body>
<?php require __DIR__ . "/Bits/Navbar.php"; ?>
<?php require __DIR__ . "/Bits/SubNavbar.php"; ?>
<main class="MainContainer">
<h1>Garden shop</h1>
<div class="ShopStatus">
<strong>Dew:</strong>
<span id="ShopDewAmount">0</span>
</div>
<p>
Buy seeds here, then take them to the Garden
to plant them. Seeds are consumable: planting
one removes it from your inventory.
</p>
<p>
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.
</p>
<p>
Harvesting a plant returns 150% of its current
seed price, rounded up.
</p>
<section
id="Seeds"
class="ShopSection"
>
<h2>Seeds</h2>
<div
id="ShopSeedList"
class="ShopSeedList"
></div>
</section>
<p id="ShopMessage">
Choose a seed to buy.
</p>
</main>
<script src="/Scripts/SubNavbar.js"></script>
<script src="/Scripts/Garden/PlantImages.js"></script>
<script src="/Scripts/Garden/Plants.js"></script>
<script src="/Scripts/Garden/Mutations.js"></script>
<script src="/Scripts/Garden/Save.js"></script>
<script src="/Scripts/Garden/Economy.js"></script>
<script src="/Scripts/Garden/Shop.js"></script>
</body>
</html>
+578
View File
@@ -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
);
}
+398 -191
View File
@@ -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
+70 -2
View File
@@ -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"
}
};
+71 -22
View File
@@ -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
}
}
};
+500 -12
View File
@@ -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;
}
+504
View File
@@ -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
);
+234
View File
@@ -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;
}
}