649 lines
18 KiB
TypeScript
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)
|
|
},
|
|
}
|
|
})
|