Improve the floating message API to better handle multiple concurrent messages

This commit is contained in:
Ryan Lanny Jenkins 2025-05-18 11:04:14 -05:00
parent e21042d08b
commit 9a9ec999ff
4 changed files with 87 additions and 99 deletions

View file

@ -1,59 +0,0 @@
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

View file

@ -0,0 +1,74 @@
import React, { useEffect, useState, useImperativeHandle, forwardRef } from 'react'
interface Message {
id: string
text: string
position: { x: number; y: number }
}
export interface FloatingMessagesHandle {
addMessage: (text: string, position: { x: number; y: number }) => void
}
const FloatingMessages = forwardRef<FloatingMessagesHandle>((_, ref) => {
const [messages, setMessages] = useState<Message[]>([])
useImperativeHandle(ref, () => ({
addMessage: (text: string, position: { x: number; y: number }) => {
const newMessage: Message = {
id: Math.random().toString(36).substring(2),
text,
position,
}
setMessages(prev => [...prev, newMessage])
}
}))
const handleAnimationEnd = (messageId: string) => {
setMessages(prev => prev.filter(msg => msg.id !== messageId))
}
return (
<>
<style>
{`
@keyframes floatUp {
0% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(-50px);
opacity: 0;
}
}
.floating-message {
position: absolute;
font-weight: bold;
pointer-events: none;
z-index: 100;
animation: floatUp 1s ease-out forwards;
}
`}
</style>
{messages.map(message => (
<div
key={message.id}
className="floating-message"
style={{
left: message.position.x,
top: message.position.y,
}}
onAnimationEnd={() => handleAnimationEnd(message.id)}
>
{message.text}
</div>
))}
</>
)
})
FloatingMessages.displayName = 'FloatingMessages'
export default FloatingMessages

View file

@ -1,7 +1,7 @@
import React, { useState } from 'react' import React, { useRef } from 'react'
import { useGameStore } from '../store/useGameStore' import { useGameStore } from '../store/useGameStore'
import { MARKET_ITEMS } from '../constants' import { MARKET_ITEMS } from '../constants'
import FloatingMessage from './FloatingMessage' import FloatingMessage, { FloatingMessagesHandle } from './FloatingMessages'
import { styled } from '@linaria/react' import { styled } from '@linaria/react'
const MarketContainer = styled.div` const MarketContainer = styled.div`
@ -71,17 +71,9 @@ const ActionButton = styled.button<{ disabled?: boolean }>`
border: none; border: none;
` `
interface FloatingMessageData {
id: string
message: string
position: { x: number; y: number }
}
const MarketComponent: React.FC = () => { const MarketComponent: React.FC = () => {
const { inventory, cash, buyItem, sellItem } = useGameStore() const { inventory, cash, buyItem, sellItem } = useGameStore()
const [floatingMessages, setFloatingMessages] = useState< const floatingMessagesRef = useRef<FloatingMessagesHandle>(null)
FloatingMessageData[]
>([])
const handleBuy = (itemId: string, e: React.MouseEvent) => { const handleBuy = (itemId: string, e: React.MouseEvent) => {
const item = MARKET_ITEMS[itemId] const item = MARKET_ITEMS[itemId]
@ -92,14 +84,10 @@ const MarketComponent: React.FC = () => {
const rect = (e.target as HTMLElement).getBoundingClientRect() const rect = (e.target as HTMLElement).getBoundingClientRect()
const position = { x: rect.left + rect.width / 2, y: rect.top } const position = { x: rect.left + rect.width / 2, y: rect.top }
setFloatingMessages([ floatingMessagesRef.current?.addMessage(
...floatingMessages, `+1 ${item.emoji} ${item.name}`,
{ position
id: `buy-${itemId}-${Date.now()}`, )
message: `+1 ${item.emoji} ${item.name}`,
position,
},
])
} }
const handleSell = (itemId: string, e: React.MouseEvent) => { const handleSell = (itemId: string, e: React.MouseEvent) => {
@ -117,18 +105,10 @@ const MarketComponent: React.FC = () => {
const rect = (e.target as HTMLElement).getBoundingClientRect() const rect = (e.target as HTMLElement).getBoundingClientRect()
const position = { x: rect.left + rect.width / 2, y: rect.top } const position = { x: rect.left + rect.width / 2, y: rect.top }
setFloatingMessages([ floatingMessagesRef.current?.addMessage(
...floatingMessages, `-1 ${item.emoji} ${item.name}`,
{ position
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) const sellableItems = Object.entries(MARKET_ITEMS)
@ -216,14 +196,7 @@ const MarketComponent: React.FC = () => {
</tbody> </tbody>
</MarketTable> </MarketTable>
{floatingMessages.map((msg) => ( <FloatingMessage ref={floatingMessagesRef} />
<FloatingMessage
key={msg.id}
message={msg.message}
startPosition={msg.position}
onComplete={() => removeFloatingMessage(msg.id)}
/>
))}
</MarketContainer> </MarketContainer>
) )
} }

View file

@ -1,7 +1,7 @@
import { CropDefinitions } from '../types' import { CropDefinitions } from '../types'
export const INITIAL_CASH = 50 export const INITIAL_CASH = 50
export const INITIAL_FIELD_SIZE = 4 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 = 12000 // 12 seconds in milliseconds
export const GAME_SPEEDS = { export const GAME_SPEEDS = {