1
0
Fork 0

feat(poker): chip-flight animation between pot and viewer on hand settle

Co-authored-by: Sisyphus <sisyphus@ohmyopencode.dev>
This commit is contained in:
Jason Fraley 2026-09-14 19:26:30 -04:00
parent 0ddc090acb
commit 169f938b05
2 changed files with 122 additions and 1 deletions

View file

@ -1414,6 +1414,78 @@
overflow: hidden;
}
/* Poker chip flight: every chip starts stacked at one origin and launches
one after another (130ms stagger, fill-mode both holds unlaunched chips
in the pile). Wins drop from the pot plaque (~40% of the felt) down to
the viewer card spot (bottom 7rem, so about 14rem of travel); losses fly
the reverse leg from the card spot up to the pot. Chips are the shared
.bj-chip pieces; the geometry is poker-scoped because blackjack flies
between different anchors. */
.poker-chip-flight {
position: absolute;
left: 50%;
z-index: 35;
width: 1.6rem;
height: 0.95rem;
transform: translateX(-50%);
pointer-events: none;
}
.poker-chip-flight-win {
top: 40%;
}
.poker-chip-flight-loss {
bottom: 7.6rem;
}
.poker-chip-flight .bj-chip {
bottom: auto;
left: 0;
}
.poker-chip-flight-win .bj-chip {
animation: poker-chip-fly-win 700ms ease-in both;
}
.poker-chip-flight-loss .bj-chip {
animation: poker-chip-fly-loss 700ms ease-in both;
}
@keyframes poker-chip-fly-win {
0% {
opacity: 1;
transform: translateY(0) scale(1);
}
70% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(14rem) scale(0.7);
}
}
@keyframes poker-chip-fly-loss {
0% {
opacity: 1;
transform: translateY(0) scale(1);
}
70% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(-14rem) scale(0.7);
}
}
@media (prefers-reduced-motion: reduce) {
.poker-chip-flight {
display: none;
}
}
/* Fullscreen contain-fit: absolute with zero insets + auto margins sizes the
felt to the largest 4:3 rectangle inside the viewport area (the same rules
browsers use for replaced elements), so it can never collapse to content

View file

@ -1,7 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { chipFlightChips, type FlightChip } from "../blackjack/BlackjackChipStack";
import type { Card, PublicPlayerView } from "@/lib/minigames/poker";
import type { PokerSnapshot } from "@/app/(frontend)/minigames/poker/actions";
import { Badge } from "@/components/ui/badge";
@ -122,6 +123,33 @@ export function PokerFeltCanvas({
return () => query.removeEventListener("change", update);
}, []);
// Chip flight: on the settle transition the viewer's net chips fly between
// the pot plaque (felt center) and the viewer card spot: winnings drop
// from the pot, lost stakes climb to it. The hand number guards against
// refiring while the snapshot settles through showdown and hand-complete.
const previousSettleHandRef = useRef<number | null>(null);
const [chipFlights, setChipFlights] = useState<
{ key: number; direction: "win" | "loss"; chips: FlightChip[] }[]
>([]);
useEffect(() => {
const settling = view.phase === "showdown" || view.phase === "hand-complete";
if (!settling || !result || result.netChange === 0) {
previousSettleHandRef.current = null;
return;
}
if (previousSettleHandRef.current === view.handNumber) return;
previousSettleHandRef.current = view.handNumber;
setChipFlights([
{
key: Date.now(),
direction: result.netChange > 0 ? "win" : "loss",
chips: chipFlightChips(Math.abs(result.netChange)),
},
]);
const timer = setTimeout(() => setChipFlights([]), 2400);
return () => clearTimeout(timer);
}, [view.phase, view.handNumber, result]);
return (
<div className="poker-table-stage">
<div
@ -272,6 +300,27 @@ export function PokerFeltCanvas({
/>
)}
{chipFlights.map((flight) => (
<div
key={flight.key}
className={cn(
"poker-chip-flight",
flight.direction === "win"
? "poker-chip-flight-win"
: "poker-chip-flight-loss",
)}
aria-hidden="true"
>
{flight.chips.map((chip, chipIndex) => (
<span
key={chip.key}
className={cn("bj-chip", chip.tone)}
style={{ animationDelay: `${chipIndex * 130}ms` }}
/>
))}
</div>
))}
{viewerPopups.length > 0 && (
<div className="poker-popup-stack poker-popup-viewer-spot" aria-hidden="true">
{viewerPopups.map((popup) => (