DionysianIdle/src/store/useGameStore.ts

649 lines
18 KiB
TypeScript

import { create } from 'zustand'
import { produce } from 'immer'
import {
INITIAL_CASH,
INITIAL_FIELD_SIZE,
FIELD_UPGRADE_COSTS,
INITIAL_INVENTORY,
CROPS,
INITIAL_GAME_SPEED,
ITEMS,
UPGRADES,
EQUIPMENT,
TICK_INTERVAL,
secondsToTicks,
} from '../constants'
import { GameState, PlotState, Equipment } from '../types'
const initializeField = (size: number): PlotState[][] => {
return Array(size)
.fill(0)
.map(() =>
Array(size)
.fill(0)
.map(() => ({
moisture: 0,
fertility: 1,
})),
)
}
const SAVE_SLOT_PREFIX = 'dionysian_idle_save_'
export const hasSaveInSlot = (slot: number): boolean => {
return !!localStorage.getItem(`${SAVE_SLOT_PREFIX}${slot}`)
}
const addConsoleMessage = (state: GameState, text: string) =>
(state.consoleMessages = [
{
id: Math.random().toString(36).substring(7),
text,
timestamp: Date.now(),
},
...state.consoleMessages,
].slice(0, 50))
const progressRandomImmaturePlot = (
state: GameState,
growthAmount: number,
): boolean => {
// Find all immature plants
const immaturePlots: { row: number; col: number }[] = []
state.plots.forEach((row, rowIndex) => {
row.forEach((plot, colIndex) => {
if (plot.current && !plot.current.mature) {
immaturePlots.push({ row: rowIndex, col: colIndex })
}
})
})
if (immaturePlots.length > 0) {
// Select a random immature plot and progress it
const randomPlot =
immaturePlots[Math.floor(Math.random() * immaturePlots.length)]
const plot = state.plots[randomPlot.row][randomPlot.col]
const crop = CROPS[plot.current!.cropId]
const growthTicks = secondsToTicks(crop.growthTimeSeconds)
const actualGrowthAmount = growthTicks * growthAmount
plot.current!.progress = Math.min(
growthTicks,
plot.current!.progress + actualGrowthAmount,
)
return true
}
return false
}
export const useGameStore = create<
GameState & {
plant: (row: number, col: number, cropId: string) => void
water: (row: number, col: number) => void
harvest: (row: number, col: number) => void
remove: (row: number, col: number) => void
tick: () => void
upgradeField: () => void
setGameSpeed: (speed: number) => void
buyItem: (itemId: string) => void
sellItem: (itemId: string) => void
purchaseUpgrade: (upgradeId: string) => void
pray: () => void
buyEquipment: (equipmentId: string) => void
configureEquipment: (equipmentId: string, recipeId: string) => void
useEquipment: (equipmentId: string) => void
addCash: (amount: number) => void
addConsoleMessage: (text: string) => void
}
>((set, get) => {
// Expose store methods to window for the save system
;(window as any).gameStore = {
getState: get,
setState: set
}
return {
cash: INITIAL_CASH,
inventory: INITIAL_INVENTORY,
fieldSize: INITIAL_FIELD_SIZE,
maxFieldSize: INITIAL_FIELD_SIZE + FIELD_UPGRADE_COSTS.length,
fieldUpgradeCosts: FIELD_UPGRADE_COSTS,
plots: initializeField(INITIAL_FIELD_SIZE),
gameSpeed: INITIAL_GAME_SPEED,
stamina: 10,
maxStamina: 10,
tickCount: 0,
consoleMessages: [],
purchasedUpgrades: [],
piety: 50,
landPurchases: 0,
equipment: {},
milestones: {},
plant: (row, col, cropId) => {
const { plots, inventory, stamina } = get()
if (stamina < 1) {
return
}
const plot = plots[row][col]
const crop = CROPS[cropId]
const seedId = crop.seedType
if (!plot.current && inventory[seedId] && inventory[seedId] > 0) {
set(
produce((state) => {
state.plots[row][col].current = {
cropId: cropId,
progress: 0,
mature: false,
}
state.inventory[seedId] = state.inventory[seedId] - 1
state.stamina = Math.max(0, state.stamina - 1)
}),
)
}
},
water: (row, col) => {
const { plots, stamina, purchasedUpgrades } = get()
const plot = plots[row][col]
if (plot.current && plot.moisture < 1) {
// Calculate stamina cost reduction from upgrades (multiplicative)
let staminaMultiplier = 1
if (purchasedUpgrades.includes('aqueous_vigor_1')) {
staminaMultiplier *= 0.75 // 25% reduction
}
if (purchasedUpgrades.includes('aqueous_vigor_2')) {
staminaMultiplier *= 0.75 // Additional 25% reduction
}
const staminaCost = staminaMultiplier
if (stamina < staminaCost) {
return
}
set(
produce((state) => {
state.plots[row][col].moisture = 1
state.stamina = Math.max(0, state.stamina - staminaCost)
// Handle water diffusion to adjacent plots
const diffusionAmount = purchasedUpgrades.includes('aqua_diffundere_2')
? 0.3
: purchasedUpgrades.includes('aqua_diffundere_1')
? 0.1
: 0
if (diffusionAmount > 0) {
// Apply diffusion to adjacent plots
const adjacentCoords = [
[row - 1, col],
[row + 1, col],
[row, col - 1],
[row, col + 1],
]
adjacentCoords.forEach(([r, c]) => {
if (
r >= 0 &&
r < state.plots.length &&
c >= 0 &&
c < state.plots[0].length
) {
state.plots[r][c].moisture = Math.min(
1,
state.plots[r][c].moisture + diffusionAmount,
)
}
})
}
}),
)
}
},
harvest: (row, col) => {
const { plots, stamina } = get()
if (stamina < 1) {
return
}
const plot = plots[row][col]
if (plot.current && plot.current.mature) {
const crop = CROPS[plot.current.cropId]
const yieldItem = crop.yieldType
const yieldAmount = crop.yield
set(
produce((state) => {
if (crop.isPerennial) {
// For perennial crops, reset progress instead of clearing
const regrowthTicks = secondsToTicks(crop.regrowthTimeSeconds!)
state.plots[row][col].current = {
cropId: plot.current!.cropId,
progress: regrowthTicks,
mature: false,
}
} else {
// For regular crops, clear the plot
state.plots[row][col].current = undefined
}
state.inventory[yieldItem] =
(state.inventory[yieldItem] || 0) + yieldAmount
state.plots[row][col].fertility = Math.max(
0,
state.plots[row][col].fertility - crop.fertilityDepletion,
)
state.stamina = Math.max(0, state.stamina - 1)
// Track milestone for crop harvest
const milestoneKey = `crops_harvested_${crop.id}`
state.milestones[milestoneKey] = (state.milestones[milestoneKey] || 0) + yieldAmount
}),
)
}
},
remove: (row, col) => {
const { plots, stamina } = get()
if (stamina < 1) {
return
}
const plot = plots[row][col]
if (plot.current) {
set(
produce((state) => {
state.plots[row][col].current = undefined
state.stamina = Math.max(0, state.stamina - 1)
}),
)
}
},
tick: () => {
set(
produce((state) => {
// Regenerate stamina
state.stamina = Math.min(
state.maxStamina,
state.stamina + 0.5,
)
// Process each plot
state.plots.forEach((row: PlotState[], rowIndex: number) => {
row.forEach((plot: PlotState, colIndex: number) => {
// Regenerate fertility every 100 ticks
if (state.tickCount % 100 === 0) {
state.plots[rowIndex][colIndex].fertility = Math.min(
1,
plot.fertility + 0.01,
)
}
if (plot.current && !plot.current.mature) {
const crop = CROPS[plot.current.cropId]
const growthTicks = secondsToTicks(crop.growthTimeSeconds)
const waterNeeded = crop.waterPerSecond * (TICK_INTERVAL / 1000)
// Only grow if fertility is above 0.2
if (plot.moisture >= waterNeeded && plot.fertility >= 0.2) {
let growthRate = 1
// Half growth rate if fertility is between 0.2 and 0.5
if (plot.fertility < 0.5) {
growthRate = 0.5
}
const newProgress = plot.current.progress + growthRate
const mature = newProgress >= growthTicks
state.plots[rowIndex][colIndex].moisture =
plot.moisture - waterNeeded
state.plots[rowIndex][colIndex].current.progress = newProgress
// If the plot just became mature, add a message
if (mature && !plot.current.mature) {
addConsoleMessage(
state,
`Plot (${rowIndex + 1},${colIndex + 1}) ${crop.name} is ready to harvest!`,
)
}
state.plots[rowIndex][colIndex].current.mature = mature
}
}
})
})
// Update equipment progress
Object.entries(state.equipment).forEach(([, equipment]) => {
// TODO: this type cast shouldn't be necessary
const equipmentInstance = equipment as Equipment
if (equipmentInstance.isProcessing) {
const recipe = EQUIPMENT[equipmentInstance.type].recipes.find(
r => r.id === equipmentInstance.selectedRecipeId
)
if (!recipe) return
equipmentInstance.progress += 1
// Check if processing is complete
if (equipmentInstance.progress >= recipe.processTicks) {
equipmentInstance.isProcessing = false
equipmentInstance.progress = 0
// Add output items
state.inventory[recipe.outputItem] =
(state.inventory[recipe.outputItem] || 0) +
recipe.outputAmount
addConsoleMessage(
state,
`${EQUIPMENT[equipmentInstance.type].name} produced ${recipe.outputAmount} ${ITEMS[recipe.outputItem].name}`
)
}
}
})
state.tickCount = state.tickCount + 1
}),
)
},
upgradeField: () => {
set(
produce((state) => {
if (state.fieldSize >= state.maxFieldSize) {
return
}
const upgradeIndex = state.fieldSize - INITIAL_FIELD_SIZE
const cost = state.fieldUpgradeCosts[upgradeIndex]
if (state.cash < cost) {
return
}
const newSize = state.fieldSize + 1
state.cash = state.cash - cost
state.fieldSize = newSize
state.plots = initializeField(newSize)
}),
)
},
setGameSpeed: (speed) => {
set(
produce((state) => {
state.gameSpeed = speed
}),
)
},
buyItem: (itemId) => {
const { cash, landPurchases } = get()
const item = ITEMS[itemId]
if (!item || item.buyPrice === null || item.buyPrice === undefined) {
return
}
// Calculate price for Additional Land based on number of purchases
let price = item.buyPrice
if (itemId === 'additional_land') {
price = Math.floor(100 * Math.pow(1.5, landPurchases))
}
if (cash < price) {
return
}
set(
produce((state) => {
state.cash -= price
// Special handling for Additional Land
if (itemId === 'additional_land') {
state.landPurchases += 1
if (state.fieldSize < state.maxFieldSize) {
const newSize = state.fieldSize + 1
// Add a new row
state.plots.push(
Array(newSize)
.fill(0)
.map(() => ({
moisture: 0,
fertility: 1,
})),
)
// Add a new column to each existing row
state.plots.forEach((row: PlotState[]) => {
row.push({
moisture: 0,
fertility: 1,
})
})
state.fieldSize = newSize
addConsoleMessage(state, 'Field size increased!')
}
} else {
state.inventory[itemId] = (state.inventory[itemId] || 0) + 1
}
}),
)
},
sellItem: (itemId) => {
const { inventory } = get()
const item = ITEMS[itemId]
if (
!item ||
item.sellPrice === null ||
!inventory[itemId] ||
inventory[itemId] <= 0
) {
return
}
set(
produce((state) => {
state.cash += item.sellPrice
state.inventory[itemId] -= 1
}),
)
},
purchaseUpgrade: (upgradeId) => {
const { inventory, purchasedUpgrades } = get()
const upgrade = UPGRADES[upgradeId]
if (!upgrade || purchasedUpgrades.includes(upgradeId)) {
return
}
// Check if player can afford all costs
const canAfford = upgrade.cost.every(
(cost) =>
inventory[cost.itemId] !== undefined &&
inventory[cost.itemId] >= cost.amount,
)
if (!canAfford) {
return
}
set(
produce((state) => {
// Deduct all costs
upgrade.cost.forEach((cost) => {
state.inventory[cost.itemId] -= cost.amount
})
state.purchasedUpgrades.push(upgradeId)
addConsoleMessage(state, `Purchased upgrade: ${upgrade.name}`)
// Handle stamina upgrades
if (upgradeId === 'stamina_1') {
state.maxStamina += 5
state.stamina = Math.min(state.maxStamina, state.stamina + 5)
} else if (upgradeId === 'stamina_2') {
state.maxStamina += 8
state.stamina = Math.min(state.maxStamina, state.stamina + 8)
} else if (upgradeId === 'stamina_3') {
state.maxStamina += 12
state.stamina = Math.min(state.maxStamina, state.stamina + 12)
} else if (upgradeId === 'stamina_4') {
state.maxStamina += 15
state.stamina = Math.min(state.maxStamina, state.stamina + 15)
}
}),
)
},
pray: () => {
const { stamina, piety } = get()
if (stamina < 1) {
return
}
set(
produce((state) => {
const roll = Math.random()
const result = roll * piety
if (result < 50 || roll < 0.3) {
state.piety += 1
addConsoleMessage(
state,
'Nothing happens, but you feel the approval of the gods',
)
} else if (result > 100) {
if (progressRandomImmaturePlot(state, 0.3)) {
addConsoleMessage(
state,
'The gods have bestowed a significant blessing on one of your crops',
)
}
} else if (result > 50) {
if (progressRandomImmaturePlot(state, 0.1)) {
addConsoleMessage(
state,
'The gods have bestowed a minor blessing on one of your crops',
)
}
}
state.stamina = Math.max(0, state.stamina - 1)
}),
)
},
buyEquipment: (equipmentId) => {
const { cash, equipment } = get()
const equipmentDef = EQUIPMENT[equipmentId]
if (!equipmentDef || cash < equipmentDef.cost) {
return
}
// Generate a unique ID for this instance of equipment
const instanceId = `${equipmentId}_${Object.keys(equipment).length + 1}`
set(
produce((state) => {
state.cash -= equipmentDef.cost
state.equipment[instanceId] = {
id: instanceId,
type: equipmentId,
progress: 0,
isProcessing: false,
}
addConsoleMessage(state, `Purchased ${equipmentDef.name}`)
}),
)
},
configureEquipment: (equipmentId, recipeId) => {
const { equipment } = get()
const equipmentInstance = equipment[equipmentId]
if (!equipmentInstance || equipmentInstance.isProcessing) {
return
}
// Verify the recipe exists for this equipment
const recipe = EQUIPMENT[equipmentInstance.type].recipes.find(r => r.id === recipeId)
if (!recipe) {
return
}
set(
produce((state) => {
state.equipment[equipmentId].selectedRecipeId = recipeId
}),
)
},
useEquipment: (equipmentId) => {
const { equipment, inventory, stamina } = get()
const equipmentInstance = equipment[equipmentId]
if (
!equipmentInstance ||
equipmentInstance.isProcessing ||
stamina < 1 ||
!equipmentInstance.selectedRecipeId
) {
return
}
const recipe = EQUIPMENT[equipmentInstance.type].recipes.find(r => r.id === equipmentInstance.selectedRecipeId)
if (!recipe) {
return
}
// Check if we have enough input items
const inputCount = inventory[recipe.inputItem] || 0
if (inputCount < recipe.inputAmount) {
return
}
set(
produce((state) => {
// Deduct input items
state.inventory[recipe.inputItem] -= recipe.inputAmount
// Start processing
state.equipment[equipmentId].isProcessing = true
state.equipment[equipmentId].progress = 0
state.stamina = Math.max(0, state.stamina - 1)
}),
)
},
addCash: (amount) => {
set(
produce((state) => { state.cash += amount }),
)
},
addConsoleMessage: (text: string) => {
addConsoleMessage(get(), text)
},
}
})