Remove the action cooldown system, implement a stamina regen system.

This commit is contained in:
Ryan Lanny Jenkins 2025-06-04 21:59:31 -05:00
parent d5b950b541
commit 54b9ec842a
10 changed files with 160 additions and 222 deletions

View file

@ -1,31 +1,32 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { styled } from '@linaria/react'
import { useGameTick } from './hooks/useGameTick'
import Field from './components/Field'
import Warehouse from './components/Warehouse'
import Market from './components/Market'
import Temple from './components/Temple'
import { StaminaDisplay } from './components/StaminaDisplay'
import { useSaveSystem } from './store/useSaveSystem'
import { Console } from './components/Console'
import { GodModal } from './components/GodModal'
import { import {
Tabs, Tabs,
TabsContent, TabsContent,
TabsList, TabsList,
TabsTrigger, TabsTrigger,
} from './components/CustomTabs' } from './components/CustomTabs'
import { useGameTick } from './hooks/useGameTick'
import Field from './components/Field'
import Warehouse from './components/Warehouse'
import Market from './components/Market'
import Temple from './components/Temple'
import { ActionCooldown } from './components/ActionCooldown'
import { useSaveSystem } from './store/useSaveSystem'
import { Console } from './components/Console'
import { GodModal } from './components/GodModal'
const appContainerStyle: React.CSSProperties = { const AppContainer = styled.div`
maxWidth: '1200px', max-width: 1200px;
margin: '0 auto', margin: 0 auto;
padding: '2rem', padding: 4rem 2rem 2rem;
} `
const tabsListStyles: React.CSSProperties = { const StyledTabsList = styled(TabsList)`
display: 'grid', display: grid;
width: '100%', width: 100%;
gridTemplateColumns: 'repeat(4, 1fr)', grid-template-columns: repeat(4, 1fr);
} `
function App() { function App() {
useGameTick() useGameTick()
@ -57,15 +58,15 @@ function App() {
return ( return (
<div> <div>
<ActionCooldown /> <StaminaDisplay />
<div style={appContainerStyle}> <AppContainer>
<Tabs value={activeTab} onValueChange={setActiveTab}> <Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList style={tabsListStyles}> <StyledTabsList>
<TabsTrigger value="fields">Fields (1)</TabsTrigger> <TabsTrigger value="fields">Fields (1)</TabsTrigger>
<TabsTrigger value="warehouse">Warehouse (2)</TabsTrigger> <TabsTrigger value="warehouse">Warehouse (2)</TabsTrigger>
<TabsTrigger value="market">Market (3)</TabsTrigger> <TabsTrigger value="market">Market (3)</TabsTrigger>
<TabsTrigger value="temple">Temple (4)</TabsTrigger> <TabsTrigger value="temple">Temple (4)</TabsTrigger>
</TabsList> </StyledTabsList>
<TabsContent value="fields"> <TabsContent value="fields">
<Field /> <Field />
</TabsContent> </TabsContent>
@ -80,7 +81,7 @@ function App() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<Console /> <Console />
</div> </AppContainer>
{isGodModalOpen && <GodModal onClose={() => setIsGodModalOpen(false)} />} {isGodModalOpen && <GodModal onClose={() => setIsGodModalOpen(false)} />}
</div> </div>
) )

View file

@ -1,110 +0,0 @@
import { useEffect, useState } from 'react'
import { styled } from '@linaria/react'
import { useGameStore } from '../store/useGameStore'
const CooldownContainer = styled.div`
position: fixed;
top: 1rem;
left: 1rem;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
`
const DialSVG = styled.svg`
width: 100%;
height: 100%;
`
const DialBackground = styled.circle`
fill: #e5e7eb;
`
const DialProgress = styled.path`
fill: #3b82f6;
`
const calculatePieWedge = (progress: number, size: number) => {
// Convert progress to go counter-clockwise from top
const adjustedProgress = 100 - progress
const center = size / 2
const radius = size / 2 - 1
const angle = (adjustedProgress / 100) * 360
// Always start from top (90 degrees in SVG coordinates)
const startAngle = 90
const endAngle = startAngle + angle
// Convert angles to radians
const startAngleRad = (startAngle * Math.PI) / 180
const endAngleRad = (endAngle * Math.PI) / 180
// Calculate points
const startX = center + radius * Math.cos(startAngleRad)
const startY = center - radius * Math.sin(startAngleRad)
const endX = center + radius * Math.cos(endAngleRad)
const endY = center - radius * Math.sin(endAngleRad)
// Create the arc flag
const largeArcFlag = angle <= 180 ? '0' : '1'
// Create the SVG path - moving counter-clockwise
return `
M ${center},${center}
L ${startX},${startY}
A ${radius} ${radius} 0 ${largeArcFlag} 0 ${endX} ${endY}
L ${center},${center}
`.trim()
}
export const ActionCooldown = () => {
const { actionCooldown, setActionCooldown } = useGameStore()
const [progress, setProgress] = useState(0)
useEffect(() => {
if (actionCooldown <= 0) {
setProgress(0)
return
}
const startTime = Date.now()
const duration = actionCooldown
const updateProgress = () => {
const elapsed = Date.now() - startTime
const newProgress = Math.min(100, (elapsed / duration) * 100)
if (newProgress >= 100) {
setProgress(0)
setActionCooldown(0)
return
}
setProgress(newProgress)
requestAnimationFrame(updateProgress)
}
const animationFrame = requestAnimationFrame(updateProgress)
return () => {
cancelAnimationFrame(animationFrame)
}
}, [actionCooldown, setActionCooldown])
// Don't render if there's no active cooldown
if (actionCooldown <= 0) {
return null
}
return (
<CooldownContainer>
<DialSVG viewBox="0 0 32 32">
<DialBackground cx="16" cy="16" r="15" />
<DialProgress d={calculatePieWedge(progress, 32)} />
</DialSVG>
</CooldownContainer>
)
}

View file

@ -85,8 +85,8 @@ export const Tabs: React.FC<TabsProps> = ({ value, onValueChange, children }) =>
) )
} }
export const TabsList: React.FC<TabsListProps> = ({ children, style }) => { export const TabsList: React.FC<TabsListProps> = ({ children, ...props }) => {
return <div style={{ ...tabsListContainerStyle, ...style }}>{children}</div> return <div style={{ ...tabsListContainerStyle }} {...props}>{children}</div>
} }
export const TabsTrigger: React.FC<TabsTriggerProps> = ({ export const TabsTrigger: React.FC<TabsTriggerProps> = ({

View file

@ -82,7 +82,7 @@ const RecipeDetails = styled.div`
` `
const EquipmentModal = ({ equipmentId, onClose }: EquipmentModalProps) => { const EquipmentModal = ({ equipmentId, onClose }: EquipmentModalProps) => {
const { inventory, configureEquipment, useEquipment, actionCooldown, equipment } = useGameStore() const { inventory, configureEquipment, useEquipment, stamina, equipment } = useGameStore()
const equipmentInstance = equipment[equipmentId] const equipmentInstance = equipment[equipmentId]
const equipmentDef = EQUIPMENT[equipmentInstance.type] const equipmentDef = EQUIPMENT[equipmentInstance.type]
@ -99,7 +99,7 @@ const EquipmentModal = ({ equipmentId, onClose }: EquipmentModalProps) => {
const canUse = const canUse =
!equipmentInstance.isProcessing && !equipmentInstance.isProcessing &&
actionCooldown === 0 && stamina >= 1 &&
selectedRecipe && selectedRecipe &&
(inventory[selectedRecipe.inputItem] || 0) >= selectedRecipe.inputAmount (inventory[selectedRecipe.inputItem] || 0) >= selectedRecipe.inputAmount
@ -163,14 +163,14 @@ const EquipmentModal = ({ equipmentId, onClose }: EquipmentModalProps) => {
Output: {selectedRecipe.outputAmount} {ITEMS[selectedRecipe.outputItem].name} Output: {selectedRecipe.outputAmount} {ITEMS[selectedRecipe.outputItem].name}
</p> </p>
<p>Processing time: {selectedRecipe.processTicks} ticks</p> <p>Processing time: {selectedRecipe.processTicks} ticks</p>
<p>Cooldown: {selectedRecipe.cooldownDuration / 1000} seconds</p> <p>Stamina cost: 1</p>
</Section> </Section>
<ActionButton <ActionButton
disabled={!canUse} disabled={!canUse}
onClick={handleUse} onClick={handleUse}
> >
{equipmentInstance.isProcessing ? 'Processing...' : 'Use'} {equipmentInstance.isProcessing ? 'Processing...' : stamina < 1 ? 'No Stamina' : 'Use'}
</ActionButton> </ActionButton>
</> </>
)} )}

View file

@ -187,7 +187,7 @@ const FieldComponent: React.FC = () => {
harvest, harvest,
remove, remove,
inventory, inventory,
actionCooldown, stamina,
} = useGameStore() } = useGameStore()
const [selectedSeed, setSelectedSeed] = useState<string | null>(null) const [selectedSeed, setSelectedSeed] = useState<string | null>(null)
@ -199,7 +199,7 @@ const FieldComponent: React.FC = () => {
} | null>(null) } | null>(null)
const handlePlotClick = (row: number, col: number) => { const handlePlotClick = (row: number, col: number) => {
if (actionCooldown > 0) return if (stamina < 1) return
switch (selectedTool) { switch (selectedTool) {
case 'plant': case 'plant':

View file

@ -0,0 +1,66 @@
import { styled } from '@linaria/react'
import { useGameStore } from '../store/useGameStore'
const StaminaContainer = styled.div`
position: fixed;
top: 1rem;
left: 1rem;
width: 200px;
height: 24px;
display: flex;
justify-content: left;
background-color: #4b5563; /* Grey for depleted stamina */
border: 3px solid #000;
border-radius: 4px;
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.2);
overflow: hidden;
z-index: 2;
`
const StaminaFill = styled.div<{ width: number }>`
height: 100%;
width: ${props => props.width + '%'};
background-color: #22c55e;
transition: width 0.2s linear;
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 50%,
rgba(0, 0, 0, 0.1) 100%
);
}
`
const StaminaText = styled.div`
position: absolute;
width: 100%;
text-align: center;
color: #fff;
font-weight: bold;
text-shadow: 1px 1px 0 #000;
font-size: 0.875rem;
z-index: 1;
`
export const StaminaDisplay = () => {
const { stamina, maxStamina } = useGameStore()
const staminaPercentage = (stamina / maxStamina) * 100
return (
<StaminaContainer>
<StaminaFill width={staminaPercentage} />
<StaminaText>
{stamina}/{maxStamina}
</StaminaText>
</StaminaContainer>
)
}

View file

@ -114,7 +114,7 @@ const TempleComponent: React.FC = () => {
purchaseUpgrade, purchaseUpgrade,
pray, pray,
piety, piety,
actionCooldown, stamina,
} = useGameStore() } = useGameStore()
const handlePurchase = (upgradeId: string) => { const handlePurchase = (upgradeId: string) => {
@ -186,9 +186,9 @@ const TempleComponent: React.FC = () => {
<PrayerSection> <PrayerSection>
<PietyDisplay>Piety Level: {piety}</PietyDisplay> <PietyDisplay>Piety Level: {piety}</PietyDisplay>
<PrayerButton onClick={() => pray()} disabled={actionCooldown > 0}> <PrayerButton onClick={() => pray()} disabled={stamina < 1}>
🙏 Pray to the Gods 🙏 Pray to the Gods
{actionCooldown > 0 && ` (${(actionCooldown / 1000).toFixed(1)}s)`} {stamina < 1 && ' (No Stamina)'}
</PrayerButton> </PrayerButton>
</PrayerSection> </PrayerSection>

View file

@ -3,7 +3,7 @@ import { CropDefinitions, Upgrade, Equipment } from '../types'
export const INITIAL_CASH = 50 export const INITIAL_CASH = 50
export const INITIAL_FIELD_SIZE = 3 export const INITIAL_FIELD_SIZE = 3
export const COOLDOWN_DURATION = 2000 // 2 seconds in milliseconds export const COOLDOWN_DURATION = 2000 // 2 seconds in milliseconds
export const TICK_INTERVAL = 12000 // 12 seconds in milliseconds export const TICK_INTERVAL = 5000
export const GAME_SPEEDS = { export const GAME_SPEEDS = {
NORMAL: 1, NORMAL: 1,
FAST: 5, FAST: 5,
@ -68,7 +68,7 @@ export const UPGRADES: Record<string, Upgrade> = {
aqueous_vigor_1: { aqueous_vigor_1: {
id: 'aqueous_vigor_1', id: 'aqueous_vigor_1',
name: 'Aqueous Vigor I', name: 'Aqueous Vigor I',
description: 'Reduces watering cooldown by 25%', description: 'Reduces watering stamina cost by 25%',
cost: [ cost: [
{ {
itemId: 'celery', itemId: 'celery',
@ -80,7 +80,7 @@ export const UPGRADES: Record<string, Upgrade> = {
id: 'aqueous_vigor_2', id: 'aqueous_vigor_2',
name: 'Aqueous Vigor II', name: 'Aqueous Vigor II',
description: description:
'Reduces watering cooldown by an additional 25% (requires Aqueous Vigor I)', 'Reduces watering stamina cost by an additional 25% (requires Aqueous Vigor I)',
cost: [ cost: [
{ {
itemId: 'celery', itemId: 'celery',

View file

@ -7,7 +7,6 @@ import {
INITIAL_INVENTORY, INITIAL_INVENTORY,
CROPS, CROPS,
INITIAL_GAME_SPEED, INITIAL_GAME_SPEED,
COOLDOWN_DURATION,
ITEMS, ITEMS,
UPGRADES, UPGRADES,
EQUIPMENT, EQUIPMENT,
@ -29,27 +28,6 @@ const initializeField = (size: number): PlotState[][] => {
const SAVE_SLOT_PREFIX = 'dionysian_idle_save_' const SAVE_SLOT_PREFIX = 'dionysian_idle_save_'
const saveGame = (slot: number, state: GameState) => {
try {
const saveData = JSON.stringify(state)
localStorage.setItem(`${SAVE_SLOT_PREFIX}${slot}`, saveData)
return true
} catch (error) {
console.error('Failed to save game:', error)
return false
}
}
const loadGame = (slot: number): GameState | null => {
try {
const saveData = localStorage.getItem(`${SAVE_SLOT_PREFIX}${slot}`)
if (!saveData) return null
return JSON.parse(saveData)
} catch (error) {
console.error('Failed to load game:', error)
return null
}
}
export const hasSaveInSlot = (slot: number): boolean => { export const hasSaveInSlot = (slot: number): boolean => {
return !!localStorage.getItem(`${SAVE_SLOT_PREFIX}${slot}`) return !!localStorage.getItem(`${SAVE_SLOT_PREFIX}${slot}`)
@ -105,7 +83,6 @@ export const useGameStore = create<
tick: () => void tick: () => void
upgradeField: () => void upgradeField: () => void
setGameSpeed: (speed: number) => void setGameSpeed: (speed: number) => void
setActionCooldown: (cooldown: number) => void
buyItem: (itemId: string) => void buyItem: (itemId: string) => void
sellItem: (itemId: string) => void sellItem: (itemId: string) => void
purchaseUpgrade: (upgradeId: string) => void purchaseUpgrade: (upgradeId: string) => void
@ -130,7 +107,8 @@ export const useGameStore = create<
fieldUpgradeCosts: FIELD_UPGRADE_COSTS, fieldUpgradeCosts: FIELD_UPGRADE_COSTS,
plots: initializeField(INITIAL_FIELD_SIZE), plots: initializeField(INITIAL_FIELD_SIZE),
gameSpeed: INITIAL_GAME_SPEED, gameSpeed: INITIAL_GAME_SPEED,
actionCooldown: 0, stamina: 10,
maxStamina: 10,
tickCount: 0, tickCount: 0,
consoleMessages: [], consoleMessages: [],
purchasedUpgrades: [], purchasedUpgrades: [],
@ -140,9 +118,9 @@ export const useGameStore = create<
milestones: {}, milestones: {},
plant: (row, col, cropId) => { plant: (row, col, cropId) => {
const { plots, inventory, actionCooldown } = get() const { plots, inventory, stamina } = get()
if (actionCooldown > 0) { if (stamina < 1) {
return return
} }
@ -160,36 +138,36 @@ export const useGameStore = create<
} }
state.inventory[seedId] = state.inventory[seedId] - 1 state.inventory[seedId] = state.inventory[seedId] - 1
state.actionCooldown = COOLDOWN_DURATION state.stamina = Math.max(0, state.stamina - 1)
}), }),
) )
} }
}, },
water: (row, col) => { water: (row, col) => {
const { plots, actionCooldown, purchasedUpgrades } = get() const { plots, stamina, purchasedUpgrades } = get()
if (actionCooldown > 0) {
return
}
const plot = plots[row][col] const plot = plots[row][col]
if (plot.current && plot.moisture < 1) { if (plot.current && plot.moisture < 1) {
// Calculate cooldown reduction from upgrades (multiplicative) // Calculate stamina cost reduction from upgrades (multiplicative)
let cooldownMultiplier = 1 let staminaMultiplier = 1
if (purchasedUpgrades.includes('aqueous_vigor_1')) { if (purchasedUpgrades.includes('aqueous_vigor_1')) {
cooldownMultiplier *= 0.75 // 25% reduction staminaMultiplier *= 0.75 // 25% reduction
} }
if (purchasedUpgrades.includes('aqueous_vigor_2')) { if (purchasedUpgrades.includes('aqueous_vigor_2')) {
cooldownMultiplier *= 0.75 // Additional 25% reduction staminaMultiplier *= 0.75 // Additional 25% reduction
} }
const finalCooldown = Math.max(0, COOLDOWN_DURATION * cooldownMultiplier) const staminaCost = Math.max(1, Math.floor(staminaMultiplier))
if (stamina < staminaCost) {
return
}
set( set(
produce((state) => { produce((state) => {
state.plots[row][col].moisture = 1 state.plots[row][col].moisture = 1
state.actionCooldown = finalCooldown state.stamina = Math.max(0, state.stamina - staminaCost)
// Handle water diffusion to adjacent plots // Handle water diffusion to adjacent plots
const diffusionAmount = purchasedUpgrades.includes('aqua_diffundere_2') const diffusionAmount = purchasedUpgrades.includes('aqua_diffundere_2')
@ -199,26 +177,24 @@ export const useGameStore = create<
: 0 : 0
if (diffusionAmount > 0) { if (diffusionAmount > 0) {
// Check and water adjacent plots (up, right, down, left) // Apply diffusion to adjacent plots
const adjacentPositions = [ const adjacentCoords = [
[row - 1, col], // up [row - 1, col],
[row, col + 1], // right [row + 1, col],
[row + 1, col], // down [row, col - 1],
[row, col - 1], // left [row, col + 1],
] ]
adjacentPositions.forEach(([adjRow, adjCol]) => { adjacentCoords.forEach(([r, c]) => {
// Check if the adjacent position is within bounds
if ( if (
adjRow >= 0 && r >= 0 &&
adjRow < state.plots.length && r < state.plots.length &&
adjCol >= 0 && c >= 0 &&
adjCol < state.plots[0].length c < state.plots[0].length
) { ) {
const adjPlot = state.plots[adjRow][adjCol] state.plots[r][c].moisture = Math.min(
state.plots[adjRow][adjCol].moisture = Math.min(
1, 1,
adjPlot.moisture + diffusionAmount state.plots[r][c].moisture + diffusionAmount,
) )
} }
}) })
@ -229,9 +205,9 @@ export const useGameStore = create<
}, },
harvest: (row, col) => { harvest: (row, col) => {
const { plots, actionCooldown } = get() const { plots, stamina } = get()
if (actionCooldown > 0) { if (stamina < 1) {
return return
} }
@ -261,7 +237,7 @@ export const useGameStore = create<
0, 0,
state.plots[row][col].fertility - crop.fertilityDepletion, state.plots[row][col].fertility - crop.fertilityDepletion,
) )
state.actionCooldown = COOLDOWN_DURATION state.stamina = Math.max(0, state.stamina - 1)
// Track milestone for crop harvest // Track milestone for crop harvest
const milestoneKey = `crops_harvested_${crop.id}` const milestoneKey = `crops_harvested_${crop.id}`
@ -272,9 +248,9 @@ export const useGameStore = create<
}, },
remove: (row, col) => { remove: (row, col) => {
const { plots, actionCooldown } = get() const { plots, stamina } = get()
if (actionCooldown > 0) { if (stamina < 1) {
return return
} }
@ -283,7 +259,7 @@ export const useGameStore = create<
set( set(
produce((state) => { produce((state) => {
state.plots[row][col].current = undefined state.plots[row][col].current = undefined
state.actionCooldown = COOLDOWN_DURATION state.stamina = Math.max(0, state.stamina - 1)
}), }),
) )
} }
@ -292,6 +268,18 @@ export const useGameStore = create<
tick: () => { tick: () => {
set( set(
produce((state) => { produce((state) => {
// Regenerate stamina
if (state.stamina < state.maxStamina) {
const oldStamina = state.stamina
state.stamina = Math.min(state.maxStamina, state.stamina + 1)
// Increment milestone counter for stamina gained
if (state.stamina > oldStamina) {
const staminaGained = state.stamina - oldStamina
state.milestones.stamina_gained = (state.milestones.stamina_gained || 0) + staminaGained
}
}
// Update plots // Update plots
state.plots.forEach((row: PlotState[], rowIndex: number) => { state.plots.forEach((row: PlotState[], rowIndex: number) => {
row.forEach((plot: PlotState, colIndex: number) => { row.forEach((plot: PlotState, colIndex: number) => {
@ -412,14 +400,6 @@ export const useGameStore = create<
) )
}, },
setActionCooldown: (cooldown) => {
set(
produce((state) => {
state.actionCooldown = cooldown
}),
)
},
buyItem: (itemId) => { buyItem: (itemId) => {
const { cash, landPurchases } = get() const { cash, landPurchases } = get()
const item = ITEMS[itemId] const item = ITEMS[itemId]
@ -529,9 +509,9 @@ export const useGameStore = create<
}, },
pray: () => { pray: () => {
const { actionCooldown, piety } = get() const { stamina, piety } = get()
if (actionCooldown > 0) { if (stamina < 1) {
return return
} }
@ -562,7 +542,7 @@ export const useGameStore = create<
} }
} }
state.actionCooldown = 3000 state.stamina = Math.max(0, state.stamina - 1)
}), }),
) )
}, },
@ -614,13 +594,13 @@ export const useGameStore = create<
}, },
useEquipment: (equipmentId) => { useEquipment: (equipmentId) => {
const { equipment, inventory, actionCooldown } = get() const { equipment, inventory, stamina } = get()
const equipmentInstance = equipment[equipmentId] const equipmentInstance = equipment[equipmentId]
if ( if (
!equipmentInstance || !equipmentInstance ||
equipmentInstance.isProcessing || equipmentInstance.isProcessing ||
actionCooldown > 0 || stamina < 1 ||
!equipmentInstance.selectedRecipeId !equipmentInstance.selectedRecipeId
) { ) {
return return
@ -645,7 +625,7 @@ export const useGameStore = create<
// Start processing // Start processing
state.equipment[equipmentId].isProcessing = true state.equipment[equipmentId].isProcessing = true
state.equipment[equipmentId].progress = 0 state.equipment[equipmentId].progress = 0
state.actionCooldown = recipe.cooldownDuration state.stamina = Math.max(0, state.stamina - 1)
}), }),
) )
}, },

View file

@ -85,7 +85,8 @@ export interface GameState {
fieldUpgradeCosts: number[] fieldUpgradeCosts: number[]
plots: PlotState[][] plots: PlotState[][]
gameSpeed: number gameSpeed: number
actionCooldown: number stamina: number
maxStamina: number
consoleMessages: ConsoleMessage[] consoleMessages: ConsoleMessage[]
purchasedUpgrades: string[] purchasedUpgrades: string[]
piety: number piety: number