Fly shop apples out from the creature that made them.

This commit is contained in:
Greyson Parrelli
2026-07-23 08:10:18 -04:00
parent 63b64bc1d8
commit 996bb861a9
3 changed files with 100 additions and 1 deletions
+74 -1
View File
@@ -1,7 +1,15 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { Card, ClientMessage, GameView, PlayerView } from '../types'
import { CardView } from './CardView'
interface Flyer {
key: string
emoji: string
x: number
y: number
}
interface Props {
view: GameView
you: PlayerView
@@ -23,6 +31,44 @@ export function ShopPhase({ view, you, send }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [you.deck])
// --- spawn animation: apples/bees fly out from the creature that made them.
// The log tells us which card (source) spawned what; we fly an emoji from
// that card's on-screen position. For sells the source is already gone, so
// we grab its rect at click time (see act()).
const seenSeqRef = useRef<number | null>(null)
const capturedRef = useRef<Map<string, DOMRect>>(new Map())
const [flyers, setFlyers] = useState<Flyer[]>([])
useEffect(() => {
const log = view.log ?? []
const last = log.length ? log[log.length - 1].seq : 0
// First render just marks where we came in, so we never replay history.
if (seenSeqRef.current === null) {
seenSeqRef.current = last
return
}
if (last <= seenSeqRef.current) return
const fresh = log.filter((e) => e.seq > seenSeqRef.current! && e.spawn && e.source)
seenSeqRef.current = last
const added: Flyer[] = []
for (const e of fresh) {
const src = e.source!
let rect: DOMRect | undefined
const el = document.querySelector(`[data-card-id="${src}"]`)
if (el) rect = el.getBoundingClientRect()
else if (capturedRef.current.has(src)) rect = capturedRef.current.get(src)
capturedRef.current.delete(src)
if (!rect) continue
added.push({
key: `spawn-${e.seq}`,
emoji: e.spawn === 'bee' ? '🐝' : '🍎',
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
})
}
if (added.length) setFlyers((f) => [...f, ...added])
}, [view.log])
function toggle(id: string) {
setSelected((sel) =>
sel.includes(id) ? sel.filter((s) => s !== id) : [...sel, id],
@@ -43,6 +89,14 @@ export function ShopPhase({ view, you, send }: Props) {
selectedCards.every((c) => c.kind === 'pet')
function act(msg: ClientMessage) {
// Sold cards vanish before the apple entry arrives, so remember where they
// were now, keyed by id, for the spawn animation to fly from.
if (msg.type === 'sell') {
for (const id of msg.cards) {
const el = document.querySelector(`[data-card-id="${id}"]`)
if (el) capturedRef.current.set(id, el.getBoundingClientRect())
}
}
send(msg)
setSelected([])
}
@@ -193,6 +247,25 @@ export function ShopPhase({ view, you, send }: Props) {
</div>
</div>
)}
{flyers.length > 0 &&
createPortal(
<>
{flyers.map((fl) => (
<div
key={fl.key}
className="spawn-flyer"
style={{ left: fl.x, top: fl.y }}
onAnimationEnd={() =>
setFlyers((f) => f.filter((x) => x.key !== fl.key))
}
>
{fl.emoji}
</div>
))}
</>,
document.body,
)}
</div>
)
}