Add a basic implementation of the market tab.

This commit is contained in:
Ryan Lanny Jenkins 2025-05-18 09:37:08 -05:00
parent 345bb0f44f
commit d4dda5b3e4
6 changed files with 339 additions and 2 deletions

View file

@ -3,6 +3,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from './components/CustomTab
import { useGameTick } from './hooks/useGameTick';
import Field from './components/Field';
import Warehouse from './components/Warehouse';
import Market from './components/Market';
import { ActionCooldown } from './components/ActionCooldown';
const appContainerStyle: React.CSSProperties = {
@ -28,7 +29,7 @@ function App() {
<TabsList style={tabsListStyles}>
<TabsTrigger value="fields">Fields</TabsTrigger>
<TabsTrigger value="warehouse">Warehouse</TabsTrigger>
<TabsTrigger value="market" disabled>Market</TabsTrigger>
<TabsTrigger value="market">Market</TabsTrigger>
<TabsTrigger value="temple" disabled>Temple</TabsTrigger>
</TabsList>
<TabsContent value="fields">
@ -37,6 +38,9 @@ function App() {
<TabsContent value="warehouse">
<Warehouse />
</TabsContent>
<TabsContent value="market">
<Market />
</TabsContent>
</Tabs>
</div>
</div>

View file

@ -0,0 +1,53 @@
import React, { useEffect, useState } from 'react';
interface FloatingMessageProps {
message: string;
startPosition: { x: number, y: number };
onComplete: () => void;
}
const FloatingMessage: React.FC<FloatingMessageProps> = ({ message, startPosition, onComplete }) => {
const [position, setPosition] = useState({ y: startPosition.y });
const [opacity, setOpacity] = useState(1);
useEffect(() => {
const startTime = Date.now();
const duration = 1000; // 1 second animation
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
setPosition({ y: startPosition.y - progress * 50 });
setOpacity(1 - progress);
if (progress < 1) {
requestAnimationFrame(animate);
} else {
onComplete();
}
};
const animationFrame = requestAnimationFrame(animate);
return () => {
cancelAnimationFrame(animationFrame);
};
}, [startPosition, onComplete]);
return (
<div style={{
position: 'absolute',
left: startPosition.x,
top: position.y,
opacity,
fontWeight: 'bold',
pointerEvents: 'none',
zIndex: 100
}}>
{message}
</div>
);
};
export default FloatingMessage;

220
src/components/Market.tsx Normal file
View file

@ -0,0 +1,220 @@
import React, { useState } from 'react';
import { useGameStore } from '../store/useGameStore';
import { MARKET_ITEMS } from '../constants';
import FloatingMessage from './FloatingMessage';
import { styled } from '@linaria/react';
const MarketContainer = styled.div`
padding: 1rem;
`;
const Title = styled.h2`
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 1rem;
text-align: center;
`;
const CashDisplay = styled.div`
text-align: center;
margin-bottom: 1.5rem;
padding: 1rem;
background-color: #dcfce7;
border-radius: 0.375rem;
`;
const CashAmount = styled.h3`
font-size: 1.25rem;
font-weight: 500;
`;
const SectionTitle = styled.h3`
font-size: 1.25rem;
font-weight: 500;
margin-bottom: 0.5rem;
margin-top: 1.5rem;
`;
const MarketTable = styled.table`
width: 100%;
border-collapse: separate;
border-spacing: 0 0.5rem;
`;
const MarketItemRow = styled.tr`
background-color: #f3f4f6;
border-radius: 0.5rem;
`;
const MarketItemCell = styled.td`
padding: 0.75rem;
text-align: center;
`;
const TableHeader = styled.th`
padding: 0.75rem;
text-align: center;
`;
const ItemIcon = styled.span`
font-size: 1.5rem;
margin-right: 0.5rem;
`;
const ActionButton = styled.button<{ disabled?: boolean }>`
padding: 0.5rem 1rem;
border-radius: 0.25rem;
background-color: ${props => props.disabled ? '#9ca3af' : '#3b82f6'};
color: white;
font-weight: bold;
cursor: ${props => props.disabled ? 'not-allowed' : 'pointer'};
border: none;
`;
interface FloatingMessageData {
id: string;
message: string;
position: { x: number, y: number };
}
const MarketComponent: React.FC = () => {
const { inventory, cash, buyItem, sellItem } = useGameStore();
const [floatingMessages, setFloatingMessages] = useState<FloatingMessageData[]>([]);
const handleBuy = (itemId: string, e: React.MouseEvent) => {
const item = MARKET_ITEMS[itemId];
if (!item || item.buyPrice === null || cash < item.buyPrice) return;
buyItem(itemId);
const rect = (e.target as HTMLElement).getBoundingClientRect();
const position = { x: rect.left + rect.width / 2, y: rect.top };
setFloatingMessages([
...floatingMessages,
{
id: `buy-${itemId}-${Date.now()}`,
message: `+1 ${item.emoji} ${item.name}`,
position
}
]);
};
const handleSell = (itemId: string, e: React.MouseEvent) => {
const item = MARKET_ITEMS[itemId];
if (!item || item.sellPrice === null || !inventory[itemId] || inventory[itemId] <= 0) return;
sellItem(itemId);
const rect = (e.target as HTMLElement).getBoundingClientRect();
const position = { x: rect.left + rect.width / 2, y: rect.top };
setFloatingMessages([
...floatingMessages,
{
id: `sell-${itemId}-${Date.now()}`,
message: `-1 ${item.emoji} ${item.name}`,
position
}
]);
};
const removeFloatingMessage = (messageId: string) => {
setFloatingMessages(floatingMessages.filter(msg => msg.id !== messageId));
};
const sellableItems = Object.entries(MARKET_ITEMS)
.filter(([_, item]) => item.sellPrice !== null && inventory[item.id] && inventory[item.id] > 0)
.map(([_, item]) => item);
const buyableItems = Object.entries(MARKET_ITEMS)
.filter(([_, item]) => item.buyPrice !== null)
.map(([_, item]) => item);
return (
<MarketContainer>
<Title>Market</Title>
<CashDisplay>
<CashAmount>Cash: ${cash.toFixed(2)}</CashAmount>
</CashDisplay>
{sellableItems.length > 0 && (
<>
<SectionTitle>Sell Items</SectionTitle>
<MarketTable>
<thead>
<tr>
<TableHeader>Item</TableHeader>
<TableHeader>Sell Price</TableHeader>
<TableHeader>Quantity</TableHeader>
<TableHeader>Action</TableHeader>
</tr>
</thead>
<tbody>
{sellableItems.map(item => (
<MarketItemRow key={`sell-${item.id}`}>
<MarketItemCell>
<ItemIcon>{item.emoji}</ItemIcon>
{item.name}
</MarketItemCell>
<MarketItemCell>${item.sellPrice}</MarketItemCell>
<MarketItemCell>{inventory[item.id] || 0}</MarketItemCell>
<MarketItemCell>
<ActionButton
onClick={(e) => handleSell(item.id, e)}
disabled={!inventory[item.id] || inventory[item.id] <= 0}
>
Sell
</ActionButton>
</MarketItemCell>
</MarketItemRow>
))}
</tbody>
</MarketTable>
</>
)}
<SectionTitle>Buy Items</SectionTitle>
<MarketTable>
<thead>
<tr>
<TableHeader>Item</TableHeader>
<TableHeader>Buy Price</TableHeader>
<TableHeader>Action</TableHeader>
</tr>
</thead>
<tbody>
{buyableItems.map(item => (
<MarketItemRow key={`buy-${item.id}`}>
<MarketItemCell>
<ItemIcon>{item.emoji}</ItemIcon>
{item.name}
</MarketItemCell>
<MarketItemCell>${item.buyPrice}</MarketItemCell>
<MarketItemCell>
<ActionButton
onClick={(e) => handleBuy(item.id, e)}
disabled={item.buyPrice ? cash < item.buyPrice : true}
>
Buy
</ActionButton>
</MarketItemCell>
</MarketItemRow>
))}
</tbody>
</MarketTable>
{floatingMessages.map(msg => (
<FloatingMessage
key={msg.id}
message={msg.message}
startPosition={msg.position}
onComplete={() => removeFloatingMessage(msg.id)}
/>
))}
</MarketContainer>
);
};
export default MarketComponent;

View file

@ -33,3 +33,18 @@ export const CROPS: CropDefinitions = {
export const INITIAL_INVENTORY = {
'celery_seed': 8
};
export interface MarketItem {
id: string;
name: string;
emoji: string;
buyPrice: number | null; // null means not buyable
sellPrice: number | null; // null means not sellable
}
export const MARKET_ITEMS: Record<string, MarketItem> = {
'celery_seed': { id: 'celery_seed', name: 'Celery Seeds', emoji: '🌰', buyPrice: 1, sellPrice: null },
'corn_seed': { id: 'corn_seed', name: 'Corn Seeds', emoji: '🌰', buyPrice: 5, sellPrice: null },
'celery': { id: 'celery', name: 'Celery', emoji: '🥬', buyPrice: null, sellPrice: 5 },
'corn': { id: 'corn', name: 'Corn', emoji: '🌽', buyPrice: null, sellPrice: 8 },
};

View file

@ -7,7 +7,8 @@ import {
INITIAL_INVENTORY,
CROPS,
INITIAL_GAME_SPEED,
COOLDOWN_DURATION
COOLDOWN_DURATION,
MARKET_ITEMS
} from '../constants';
import { GameState, PlotState } from '../types';
@ -28,6 +29,8 @@ export const useGameStore = create<GameState & {
upgradeField: () => void;
setGameSpeed: (speed: number) => void;
setActionCooldown: (cooldown: number) => void;
buyItem: (itemId: string) => void;
sellItem: (itemId: string) => void;
}>((set, get) => ({
cash: INITIAL_CASH,
inventory: INITIAL_INVENTORY,
@ -193,5 +196,37 @@ export const useGameStore = create<GameState & {
set(produce(state => {
state.actionCooldown = cooldown;
}));
},
buyItem: (itemId) => {
const { cash } = get();
const item = MARKET_ITEMS[itemId];
if (!item || item.buyPrice === null) {
return;
}
if (cash < item.buyPrice) {
return;
}
set(produce(state => {
state.cash -= item.buyPrice;
state.inventory[itemId] = (state.inventory[itemId] || 0) + 1;
}));
},
sellItem: (itemId) => {
const { inventory } = get();
const item = MARKET_ITEMS[itemId];
if (!item || item.sellPrice === null || !inventory[itemId] || inventory[itemId] <= 0) {
return;
}
set(produce(state => {
state.cash += item.sellPrice;
state.inventory[itemId] -= 1;
}));
}
}));

View file

@ -36,3 +36,13 @@ export interface GameState {
gameSpeed: number;
actionCooldown: number;
}
export interface MarketTransaction {
itemId: string;
amount: number;
type: 'buy' | 'sell';
position: {
x: number;
y: number;
};
}