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
+1
View File
@@ -67,6 +67,7 @@ export function CardView({
return ( return (
<div <div
className={classes} className={classes}
data-card-id={card.id || undefined}
onClick={disabled ? undefined : onClick} onClick={disabled ? undefined : onClick}
role={onClick ? 'button' : undefined} role={onClick ? 'button' : undefined}
onMouseEnter={preview ? undefined : (e) => setHover({ x: e.clientX, y: e.clientY })} onMouseEnter={preview ? undefined : (e) => setHover({ x: e.clientX, y: e.clientY })}
+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 type { Card, ClientMessage, GameView, PlayerView } from '../types'
import { CardView } from './CardView' import { CardView } from './CardView'
interface Flyer {
key: string
emoji: string
x: number
y: number
}
interface Props { interface Props {
view: GameView view: GameView
you: PlayerView you: PlayerView
@@ -23,6 +31,44 @@ export function ShopPhase({ view, you, send }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [you.deck]) }, [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) { function toggle(id: string) {
setSelected((sel) => setSelected((sel) =>
sel.includes(id) ? sel.filter((s) => s !== id) : [...sel, id], 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') selectedCards.every((c) => c.kind === 'pet')
function act(msg: ClientMessage) { 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) send(msg)
setSelected([]) setSelected([])
} }
@@ -193,6 +247,25 @@ export function ShopPhase({ view, you, send }: Props) {
</div> </div>
</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> </div>
) )
} }
+25
View File
@@ -1048,6 +1048,31 @@ h3 {
animation-name: summon-from-left; animation-name: summon-from-left;
} }
/* Apple/bee spawned in the shop, flying out from the creature that made it. */
@keyframes spawn-fly {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.4) rotate(-10deg);
}
20% {
opacity: 1;
transform: translate(-50%, -85%) scale(1.35) rotate(4deg);
}
100% {
opacity: 0;
transform: translate(-50%, -165%) scale(1) rotate(0);
}
}
.spawn-flyer {
position: fixed;
z-index: 60;
font-size: 2rem;
pointer-events: none;
animation: spawn-fly 1000ms ease-out forwards;
filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.5));
}
/* --- foods waiting for a pet --- */ /* --- foods waiting for a pet --- */
.pending-foods { .pending-foods {