Add press equipment, and first recipe of olive oil

This commit is contained in:
Ryan Lanny Jenkins 2025-05-26 20:41:32 -05:00
parent 5724da535a
commit 72b287e421
7 changed files with 718 additions and 108 deletions

View file

@ -0,0 +1,181 @@
import { styled } from '@linaria/react'
import { useGameStore } from '../store/useGameStore'
import { EQUIPMENT, ITEMS } from '../constants'
import Modal from './Modal'
interface EquipmentModalProps {
equipmentId: string
onClose: () => void
}
const Section = styled.div`
margin-bottom: 1.5rem;
`
const SectionTitle = styled.h3`
font-size: 1.1rem;
font-weight: 500;
margin-bottom: 0.5rem;
`
const ActionButton = styled.button<{ disabled?: boolean }>`
background-color: ${props => props.disabled ? '#9ca3af' : '#4f46e5'};
color: white;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: none;
cursor: ${props => props.disabled ? 'not-allowed' : 'pointer'};
font-size: 1rem;
width: 100%;
margin-top: 1rem;
`
const ProgressBar = styled.div`
width: 100%;
height: 0.5rem;
background-color: #e5e7eb;
border-radius: 0.25rem;
margin-top: 0.5rem;
overflow: hidden;
`
const ProgressFill = styled.div<{ width: number }>`
height: 100%;
background-color: #4f46e5;
transition: width 0.1s linear;
width: ${props => props.width}%;
`
const RecipeButton = styled.button<{ selected?: boolean; disabled?: boolean }>`
background-color: ${props => props.selected ? '#e0e7ff' : '#f3f4f6'};
border: 1px solid ${props => props.selected ? '#4f46e5' : '#d1d5db'};
border-radius: 0.375rem;
padding: 0.75rem;
margin-bottom: 0.5rem;
width: 100%;
text-align: left;
cursor: ${props => props.disabled ? 'not-allowed' : 'pointer'};
display: flex;
align-items: center;
gap: 0.5rem;
opacity: ${props => props.disabled ? 0.5 : 1};
`
const RecipeIcon = styled.span`
font-size: 1.25rem;
`
const RecipeInfo = styled.div`
flex: 1;
`
const RecipeName = styled.div`
font-weight: 500;
margin-bottom: 0.25rem;
`
const RecipeDetails = styled.div`
font-size: 0.875rem;
color: #6b7280;
`
const EquipmentModal = ({ equipmentId, onClose }: EquipmentModalProps) => {
const { inventory, configureEquipment, useEquipment, actionCooldown, equipment } = useGameStore()
const equipmentInstance = equipment[equipmentId]
const equipmentDef = EQUIPMENT[equipmentInstance.type]
const handleUse = () => {
useEquipment(equipmentInstance.id)
}
const handleRecipeSelect = (recipeId: string) => {
configureEquipment(equipmentInstance.id, recipeId)
}
const selectedRecipe = equipmentDef.recipes.find(r => r.id === equipmentInstance.selectedRecipeId)
const progress = selectedRecipe ? (equipmentInstance.progress / selectedRecipe.processTicks) * 100 : 0
const canUse =
!equipmentInstance.isProcessing &&
actionCooldown === 0 &&
selectedRecipe &&
(inventory[selectedRecipe.inputItem] || 0) >= selectedRecipe.inputAmount
const modalTitle = (
<>
<span>{equipmentDef.emoji}</span>
{equipmentDef.name}
</>
)
return (
<Modal onClose={onClose} title={modalTitle}>
<Section>
<SectionTitle>Select Recipe</SectionTitle>
{equipmentDef.recipes.map((recipe) => (
<RecipeButton
key={recipe.id}
selected={recipe.id === equipmentInstance.selectedRecipeId}
onClick={() => handleRecipeSelect(recipe.id)}
disabled={equipmentInstance.isProcessing}
>
<RecipeIcon>
{ITEMS[recipe.inputItem].emoji} {ITEMS[recipe.outputItem].emoji}
</RecipeIcon>
<RecipeInfo>
<RecipeName>{recipe.name}</RecipeName>
<RecipeDetails>
{recipe.inputAmount} {ITEMS[recipe.inputItem].name} {recipe.outputAmount} {ITEMS[recipe.outputItem].name}
<br />
Time: {recipe.processTicks} ticks, Cooldown: {recipe.cooldownDuration / 1000}s
</RecipeDetails>
</RecipeInfo>
</RecipeButton>
))}
</Section>
{selectedRecipe && (
<>
<Section>
<SectionTitle>Status</SectionTitle>
{equipmentInstance.isProcessing ? (
<>
<p>Processing {selectedRecipe.name}...</p>
<ProgressBar>
<ProgressFill width={progress} />
</ProgressBar>
</>
) : (
<p>Ready to use</p>
)}
</Section>
<Section>
<SectionTitle>Requirements</SectionTitle>
<p>
Input: {selectedRecipe.inputAmount} {ITEMS[selectedRecipe.inputItem].name}
{' '}
(You have: {inventory[selectedRecipe.inputItem] || 0})
</p>
<p>
Output: {selectedRecipe.outputAmount} {ITEMS[selectedRecipe.outputItem].name}
</p>
<p>Processing time: {selectedRecipe.processTicks} ticks</p>
<p>Cooldown: {selectedRecipe.cooldownDuration / 1000} seconds</p>
</Section>
<ActionButton
disabled={!canUse}
onClick={handleUse}
>
{equipmentInstance.isProcessing ? 'Processing...' : 'Use'}
</ActionButton>
</>
)}
</Modal>
)
}
export default EquipmentModal

View file

@ -2,6 +2,7 @@ import React, { useState } from 'react'
import { useGameStore } from '../store/useGameStore'
import { CROPS } from '../constants'
import { FieldTool, PlotState } from '../types'
import Modal from './Modal'
const fieldContainerStyle: React.CSSProperties = {
display: 'flex',
@ -203,12 +204,7 @@ const PlotInfoModal: React.FC<PlotInfoModalProps> = ({ plot, onClose }) => {
const formatPercentage = (value: number) => `${Math.round(value * 100)}%`
return (
<div style={modalOverlayStyle} onClick={onClose}>
<div style={modalContentStyle} onClick={(e) => e.stopPropagation()}>
<button style={modalCloseButtonStyle} onClick={onClose}>
×
</button>
<h3 style={{ marginTop: 0 }}>Plot Information</h3>
<Modal onClose={onClose} title="Plot Information">
<div style={plotInfoStyle}>
<div style={plotInfoItemStyle}>
<span>Current Crop:</span>
@ -244,8 +240,7 @@ const PlotInfoModal: React.FC<PlotInfoModalProps> = ({ plot, onClose }) => {
</div>
)}
</div>
</div>
</div>
</Modal>
)
}

79
src/components/Modal.tsx Normal file
View file

@ -0,0 +1,79 @@
import React from 'react'
import { styled } from '@linaria/react'
interface ModalProps {
onClose: () => void
children: React.ReactNode
title?: React.ReactNode
maxWidth?: string
minWidth?: string
}
const ModalOverlay = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
`
const ModalContent = styled.div<{ maxWidth?: string; minWidth?: string }>`
background-color: white;
padding: 2rem;
border-radius: 0.5rem;
min-width: ${props => props.minWidth || '300px'};
max-width: ${props => props.maxWidth || '500px'};
width: 90%;
position: relative;
`
const CloseButton = styled.button`
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.5rem;
border: none;
background: none;
cursor: pointer;
font-size: 1.5rem;
color: #6b7280;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.25rem;
&:hover {
background-color: #f3f4f6;
}
`
const Title = styled.h2`
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding-right: 2rem;
`
const Modal: React.FC<ModalProps> = ({ onClose, children, title, maxWidth, minWidth }) => {
return (
<ModalOverlay onClick={onClose}>
<ModalContent onClick={(e) => e.stopPropagation()} maxWidth={maxWidth} minWidth={minWidth}>
<CloseButton onClick={onClose}>×</CloseButton>
{title && <Title>{title}</Title>}
{children}
</ModalContent>
</ModalOverlay>
)
}
export default Modal

View file

@ -1,68 +1,139 @@
import React from 'react'
import React, { useState } from 'react'
import { styled } from '@linaria/react'
import { useGameStore } from '../store/useGameStore'
import { ITEMS } from '../constants'
import { ITEMS, EQUIPMENT } from '../constants'
import EquipmentModal from './EquipmentModal'
import { Equipment } from '../types'
const warehouseContainerStyle: React.CSSProperties = {
padding: '1rem',
}
const WarehouseContainer = styled.div`
padding: 1rem;
`
const titleStyle: React.CSSProperties = {
fontSize: '1.5rem',
fontWeight: 'bold',
marginBottom: '1rem',
textAlign: 'center',
}
const Title = styled.h2`
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 1rem;
text-align: center;
`
const cashDisplayStyle: React.CSSProperties = {
textAlign: 'center',
marginBottom: '1.5rem',
padding: '1rem',
backgroundColor: '#dcfce7',
borderRadius: '0.375rem',
}
const CashDisplay = styled.div`
text-align: center;
margin-bottom: 1.5rem;
padding: 1rem;
background-color: #dcfce7;
border-radius: 0.375rem;
`
const cashAmountStyle: React.CSSProperties = {
fontSize: '1.25rem',
fontWeight: 500,
}
const CashAmount = styled.h3`
font-size: 1.25rem;
font-weight: 500;
`
const inventoryTitleStyle: React.CSSProperties = {
fontSize: '1.25rem',
fontWeight: 500,
marginBottom: '0.5rem',
}
const InventoryTitle = styled.h3`
font-size: 1.25rem;
font-weight: 500;
margin-bottom: 0.5rem;
`
const getInventoryGridStyle = (): React.CSSProperties => {
const style: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '1rem',
marginTop: '1rem',
}
const InventoryGrid = styled.div`
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-top: 1rem;
`
return style
}
const InventoryItem = styled.div`
background-color: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
`
const inventoryItemStyle: React.CSSProperties = {
backgroundColor: '#f3f4f6',
border: '1px solid #d1d5db',
borderRadius: '0.5rem',
padding: '1rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
}
const ItemIcon = styled.div`
font-size: 1.5rem;
margin-bottom: 0.5rem;
`
const itemIconStyle: React.CSSProperties = {
fontSize: '1.5rem',
marginBottom: '0.5rem',
}
const ItemName = styled.h4`
font-weight: 500;
margin-bottom: 0.25rem;
`
const itemNameStyle: React.CSSProperties = {
fontWeight: 500,
marginBottom: '0.25rem',
}
const EquipmentTitle = styled.h3`
font-size: 1.25rem;
font-weight: 500;
margin-top: 2rem;
margin-bottom: 0.5rem;
`
const EquipmentGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
margin-top: 1rem;
`
const EquipmentItem = styled.div`
background-color: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
cursor: pointer;
position: relative;
`
const ProgressBarContainer = styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
height: 0.25rem;
background-color: #e5e7eb;
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
overflow: hidden;
`
const ProgressFill = styled.div<{ width: string }>`
height: 100%;
background-color: #4f46e5;
width: ${props => props.width};
`
const EquipmentIcon = styled.div`
font-size: 2rem;
margin-bottom: 0.5rem;
`
const EquipmentName = styled.h4`
font-weight: 500;
margin-bottom: 0.25rem;
`
const EquipmentStatus = styled.p`
font-size: 0.875rem;
color: #6b7280;
`
const BuyEquipmentButton = styled.button<{ disabled?: boolean }>`
background-color: ${props => props.disabled ? '#9ca3af' : '#4f46e5'};
color: white;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: none;
cursor: ${props => props.disabled ? 'not-allowed' : 'pointer'};
font-size: 1rem;
width: 100%;
margin-top: 1rem;
`
const formatItemName = (id: string) => {
return ITEMS[id]?.name || id
@ -73,31 +144,104 @@ const getItemIcon = (id: string) => {
}
const WarehouseComponent: React.FC = () => {
const { inventory, cash } = useGameStore()
const { inventory, cash, equipment, buyEquipment } = useGameStore()
const [selectedEquipment, setSelectedEquipment] = useState<Equipment | null>(null)
const handleEquipmentClick = (equipment: Equipment) => {
setSelectedEquipment(equipment)
}
const handleBuyEquipment = (equipmentId: string) => {
buyEquipment(equipmentId)
}
return (
<div style={warehouseContainerStyle}>
<h2 style={titleStyle}>Warehouse</h2>
<WarehouseContainer>
<Title>Warehouse</Title>
<div style={cashDisplayStyle}>
<h3 style={cashAmountStyle}>Cash: ${cash.toFixed(2)}</h3>
</div>
<CashDisplay>
<CashAmount>Cash: ${cash.toFixed(2)}</CashAmount>
</CashDisplay>
<h3 style={inventoryTitleStyle}>Inventory:</h3>
<InventoryTitle>Inventory:</InventoryTitle>
<div style={getInventoryGridStyle()}>
<InventoryGrid>
{Object.entries(inventory).map(
([id, count]) =>
count > 0 && (
<div key={id} style={inventoryItemStyle}>
<div style={itemIconStyle}>{getItemIcon(id)}</div>
<h4 style={itemNameStyle}>{formatItemName(id)}</h4>
<InventoryItem key={id}>
<ItemIcon>{getItemIcon(id)}</ItemIcon>
<ItemName>{formatItemName(id)}</ItemName>
<p>Quantity: {count}</p>
</div>
</InventoryItem>
),
)}
</div>
</div>
</InventoryGrid>
<EquipmentTitle>Equipment:</EquipmentTitle>
<EquipmentGrid>
{/* Available equipment to buy */}
{Object.entries(EQUIPMENT).map(([id, equipmentDef]) => {
const canBuy = cash >= equipmentDef.cost
return (
<EquipmentItem key={`buy_${id}`}>
<EquipmentIcon>{equipmentDef.emoji}</EquipmentIcon>
<EquipmentName>{equipmentDef.name}</EquipmentName>
<EquipmentStatus>Cost: ${equipmentDef.cost}</EquipmentStatus>
<BuyEquipmentButton
disabled={!canBuy}
onClick={(e) => {
e.stopPropagation()
handleBuyEquipment(id)
}}
>
Buy
</BuyEquipmentButton>
</EquipmentItem>
)
})}
{/* Owned equipment */}
{Object.entries(equipment).map(([id, equipmentInstance]) => {
const recipe = equipmentInstance.selectedRecipeId
? EQUIPMENT[equipmentInstance.type].recipes.find(r => r.id === equipmentInstance.selectedRecipeId)
: null
const progress = recipe && equipmentInstance.isProcessing
? (equipmentInstance.progress / recipe.processTicks) * 100
: 0
return (
<EquipmentItem
key={id}
onClick={() => handleEquipmentClick(equipmentInstance)}
>
{equipmentInstance.isProcessing && (
<ProgressBarContainer>
<ProgressFill width={`${progress}%`} />
</ProgressBarContainer>
)}
<EquipmentIcon>{equipmentInstance.emoji}</EquipmentIcon>
<EquipmentName>{equipmentInstance.name}</EquipmentName>
<EquipmentStatus>
{equipmentInstance.isProcessing
? 'Processing...'
: equipmentInstance.selectedRecipeId
? `Ready (${recipe?.name})`
: 'Not configured'}
</EquipmentStatus>
</EquipmentItem>
)
})}
</EquipmentGrid>
{selectedEquipment && (
<EquipmentModal
equipmentId={selectedEquipment.id}
onClose={() => setSelectedEquipment(null)}
/>
)}
</WarehouseContainer>
)
}

View file

@ -1,4 +1,4 @@
import { CropDefinitions, Upgrade } from '../types'
import { CropDefinitions, Upgrade, Equipment } from '../types'
export const INITIAL_CASH = 50
export const INITIAL_FIELD_SIZE = 3
@ -116,6 +116,40 @@ export interface Item {
sellPrice: number | null // null means not sellable
}
export const EQUIPMENT: Record<string, Equipment> = {
press: {
id: 'press',
type: 'press',
name: 'Press',
emoji: '🛢️',
cost: 100,
recipes: [
{
id: 'olive_oil',
name: 'Olive Oil',
inputItem: 'olive',
inputAmount: 10,
outputItem: 'olive_oil',
outputAmount: 1,
processTicks: 10,
cooldownDuration: 10000, // 10 seconds
},
{
id: 'grape_juice',
name: 'Grape Juice',
inputItem: 'grape',
inputAmount: 20,
outputItem: 'grape_juice',
outputAmount: 1,
processTicks: 20,
cooldownDuration: 15000, // 15 seconds
},
],
progress: 0,
isProcessing: false,
},
}
export const ITEMS: Record<string, Item> = {
additional_land: {
id: 'additional_land',
@ -159,4 +193,32 @@ export const ITEMS: Record<string, Item> = {
buyPrice: 15,
sellPrice: null,
},
olive_oil: {
id: 'olive_oil',
name: 'Olive Oil',
emoji: '🫒',
buyPrice: null,
sellPrice: 50,
},
grape: {
id: 'grape',
name: 'Grape',
emoji: '🍇',
buyPrice: null,
sellPrice: 20,
},
grape_juice: {
id: 'grape_juice',
name: 'Grape Juice',
emoji: '🍇juice',
buyPrice: null,
sellPrice: 100,
},
wine: {
id: 'wine',
name: 'Wine',
emoji: '🍷',
buyPrice: null,
sellPrice: 100,
},
}

View file

@ -10,8 +10,9 @@ import {
COOLDOWN_DURATION,
ITEMS,
UPGRADES,
EQUIPMENT,
} from '../constants'
import { GameState, PlotState } from '../types'
import { GameState, PlotState, Equipment } from '../types'
const initializeField = (size: number): PlotState[][] => {
return Array(size)
@ -112,6 +113,9 @@ export const useGameStore = create<
loadFromSlot: (slot: number) => void
purchaseUpgrade: (upgradeId: string) => void
pray: () => void
buyEquipment: (equipmentId: string) => void
configureEquipment: (equipmentId: string, recipeId: string) => void
useEquipment: (equipmentId: string) => void
}
>((set, get) => ({
cash: INITIAL_CASH,
@ -127,6 +131,7 @@ export const useGameStore = create<
purchasedUpgrades: [],
piety: 50,
landPurchases: 0,
equipment: {},
assignCrop: (row, col, cropId) => {
set(
@ -341,6 +346,36 @@ export const useGameStore = create<
})
})
// Update equipment progress
Object.entries(state.equipment).forEach(([id, 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
}),
)
@ -479,6 +514,9 @@ export const useGameStore = create<
loadFromSlot,
purchaseUpgrade,
pray,
buyEquipment,
configureEquipment,
useEquipment,
...gameState
} = state
saveGame(slot, gameState)
@ -560,4 +598,87 @@ export const useGameStore = create<
}),
)
},
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, actionCooldown } = get()
const equipmentInstance = equipment[equipmentId]
if (
!equipmentInstance ||
equipmentInstance.isProcessing ||
actionCooldown > 0 ||
!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.actionCooldown = recipe.cooldownDuration
}),
)
},
}))

View file

@ -51,6 +51,33 @@ export interface Upgrade {
cost: UpgradeCost[]
}
export interface Recipe {
id: string
name: string
inputItem: string
inputAmount: number
outputItem: string
outputAmount: number
processTicks: number
cooldownDuration: number
}
export interface Equipment {
id: string
type: string
name: string
emoji: string
cost: number
recipes: Recipe[]
selectedRecipeId?: string
progress: number
isProcessing: boolean
}
export interface EquipmentState {
[id: string]: Equipment
}
export interface GameState {
cash: number
inventory: Record<string, number>
@ -64,6 +91,7 @@ export interface GameState {
purchasedUpgrades: string[]
piety: number
landPurchases: number
equipment: EquipmentState
}
export interface MarketTransaction {