feat(fortune-wheel): celebration reveal, spin animation, prize editor add/remove

Player experience:
- Tiered win celebration overlay (WheelWinReveal): quiet message for the
  "nothing" slice, lighter reveal for common prizes, full confetti +
  jackpot glow for rare ones. Rarity classified client-side by type +
  amount (wheelPrizeTier), shared icon rendering (wheelPrizeIcon).
- Three-phase spin motion (wind-back -> overshoot -> settle) with a
  reduced-motion fast path; responsive wheel scaling via ResizeObserver.

Reveal-timing fix:
- The server pushes the refreshed winners list (which already contains the
  just-won prize) the instant it answers the spin, ~5s before the wheel
  stops. useFortuneWheel now buffers that update mid-spin and flushes it in
  finishSpin so the prize is no longer spoiled in the winners panel.
- handleTransitionEnd only reacts to the wheel's own transform transition,
  so a child icon's bubbling transitionend can't advance the spin phase
  machine early.

Prize editor (admin):
- Add/Remove prize buttons in FortuneWheelSettingsView. New rows carry a
  negative temp id collapsed to 0 on the wire (server inserts); removed rows
  are simply omitted (server soft-disables). Requires the matching emulator
  change to WheelManager.savePrize / WheelAdminSavePrizesEvent.

i18n: wheel.win.* and rarevalues.editor.add/remove in en/it/nl.
This commit is contained in:
simoleo89
2026-05-31 10:47:51 +02:00
parent fa71e8eb4a
commit ccebcad8a8
10 changed files with 502 additions and 107 deletions
+28 -2
View File
@@ -1,5 +1,5 @@
import { IWheelAdminPrize, IWheelAdminPrizeEdit, IWheelPrize, IWheelRecentWin, WheelAdminGetPrizesComposer, WheelAdminPrizesEvent, WheelAdminSavePrizesComposer, WheelBuySpinComposer, WheelDataEvent, WheelOpenComposer, WheelRecentWinsEvent, WheelResultEvent, WheelSpinComposer } from '@nitrots/nitro-renderer';
import { useCallback, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { useBetween } from 'use-between';
import { SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
@@ -18,6 +18,15 @@ const useFortuneWheelState = () =>
const [ isSpinning, setIsSpinning ] = useState(false);
const [ adminPrizes, setAdminPrizes ] = useState<IWheelAdminPrize[]>([]);
// While the wheel is animating we hold back the recent-wins refresh: the
// server pushes the updated list (which already contains the just-won
// prize) the instant it answers the spin, ~5s before the wheel actually
// stops. Showing it immediately would spoil the result in the winners
// panel. We buffer it here and flush it in finishSpin (called when the
// reveal fires).
const spinAnimatingRef = useRef(false);
const bufferedWinsRef = useRef<IWheelRecentWin[] | null>(null);
useMessageEvent<WheelAdminPrizesEvent>(WheelAdminPrizesEvent, event =>
{
setAdminPrizes(event.getParser().prizes);
@@ -35,13 +44,21 @@ const useFortuneWheelState = () =>
useMessageEvent<WheelResultEvent>(WheelResultEvent, event =>
{
// Set synchronously before the recent-wins packet (sent right after by
// the server) is processed, so its handler knows a spin is animating.
spinAnimatingRef.current = true;
setPendingPrizeId(event.getParser().prizeId);
setIsSpinning(true);
});
useMessageEvent<WheelRecentWinsEvent>(WheelRecentWinsEvent, event =>
{
setRecentWins(event.getParser().wins);
const wins = event.getParser().wins;
// Mid-spin: stash the refreshed list and reveal it once the wheel
// stops. Otherwise (initial open, other refreshes) apply immediately.
if(spinAnimatingRef.current) bufferedWinsRef.current = wins;
else setRecentWins(wins);
});
const open = useCallback(() => SendMessageComposer(new WheelOpenComposer()), []);
@@ -56,8 +73,17 @@ const useFortuneWheelState = () =>
const buySpin = useCallback(() => SendMessageComposer(new WheelBuySpinComposer()), []);
const finishSpin = useCallback(() =>
{
spinAnimatingRef.current = false;
setIsSpinning(false);
setPendingPrizeId(-1);
// Flush the winners list that arrived during the spin, now that the
// reveal has happened.
if(bufferedWinsRef.current)
{
setRecentWins(bufferedWinsRef.current);
bufferedWinsRef.current = null;
}
}, []);
const loadAdminPrizes = useCallback(() => SendMessageComposer(new WheelAdminGetPrizesComposer()), []);