Merge pull request #181 from simoleo89/feat/fortune-wheel-improvements

feat(fortune-wheel): celebration reveal, spin animation & prize editor add/remove
This commit is contained in:
DuckieTM
2026-05-31 15:44:42 +02:00
committed by GitHub
10 changed files with 502 additions and 107 deletions
@@ -642,6 +642,9 @@
'wheel.buy': 'Buy spin',
'wheel.winners': 'Latest winners',
'wheel.winners.empty': 'No winners yet',
'wheel.win.title': 'You won!',
'wheel.win.jackpot': '★ Jackpot ★',
'wheel.win.nothing': 'Better luck next time!',
// ------------------------------------------------------------------------
// Soundboard
@@ -674,6 +677,8 @@
'rarevalues.editor.weight': 'Chance',
'rarevalues.editor.label': 'Label',
'rarevalues.editor.save': 'Save',
'rarevalues.editor.add': '+ Add prize',
'rarevalues.editor.remove': 'Remove prize',
'rarevalues.editor.cat.item': 'Furni (ID)',
'rarevalues.editor.cat.spin': 'Extra spins',
'rarevalues.editor.cat.nothing': 'Nothing',
@@ -642,6 +642,9 @@
'wheel.buy': 'Acquista giro',
'wheel.winners': 'Ultimi vincitori',
'wheel.winners.empty': 'Ancora nessun vincitore',
'wheel.win.title': 'Hai vinto!',
'wheel.win.jackpot': '★ Jackpot ★',
'wheel.win.nothing': 'Sarà per la prossima!',
// ------------------------------------------------------------------------
// Soundboard
@@ -674,6 +677,8 @@
'rarevalues.editor.weight': 'Probabilità',
'rarevalues.editor.label': 'Etichetta',
'rarevalues.editor.save': 'Salva',
'rarevalues.editor.add': '+ Aggiungi premio',
'rarevalues.editor.remove': 'Rimuovi premio',
'rarevalues.editor.cat.item': 'Arredo (ID)',
'rarevalues.editor.cat.spin': 'Giri extra',
'rarevalues.editor.cat.nothing': 'Niente',
@@ -644,6 +644,9 @@
'wheel.buy': 'Draaibeurt kopen',
'wheel.winners': 'Laatste winnaars',
'wheel.winners.empty': 'Nog geen winnaars',
'wheel.win.title': 'Gewonnen!',
'wheel.win.jackpot': '★ Jackpot ★',
'wheel.win.nothing': 'Volgende keer beter!',
// ------------------------------------------------------------------------
// Soundboard
@@ -676,6 +679,8 @@
'rarevalues.editor.weight': 'Kans',
'rarevalues.editor.label': 'Label',
'rarevalues.editor.save': 'Opslaan',
'rarevalues.editor.add': '+ Prijs toevoegen',
'rarevalues.editor.remove': 'Prijs verwijderen',
'rarevalues.editor.cat.item': 'Meubel (ID)',
'rarevalues.editor.cat.spin': 'Extra draaien',
'rarevalues.editor.cat.nothing': 'Niets',
@@ -46,7 +46,9 @@ const prizeToNum = (prize: IWheelAdminPrize): number =>
const rowToEdit = (row: EditRow): IWheelAdminPrizeEdit =>
{
const base = { id: row.id, weight: row.weight, label: row.label };
// Locally-added rows carry a negative temp id; the server treats id <= 0
// as "insert a new prize", so collapse them to 0 on the wire.
const base = { id: row.id > 0 ? row.id : 0, weight: row.weight, label: row.label };
switch(row.category)
{
@@ -88,6 +90,19 @@ export const FortuneWheelSettingsView: FC<FortuneWheelSettingsViewProps> = ({ on
const updateRow = (id: number, patch: Partial<EditRow>) =>
setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row));
const removeRow = (id: number) =>
setEditRows(prev => prev.filter(row => row.id !== id));
const addRow = () =>
setEditRows(prev =>
{
// New rows get a decreasing negative temp id so React keys stay
// stable and updateRow/removeRow keep matching before the save
// round-trips real ids back from the server.
const tempId = Math.min(0, ...prev.map(row => row.id)) - 1;
return [ ...prev, { id: tempId, category: 'item', num: 0, weight: 1, label: '' } ];
});
return (
<NitroCard className="w-[480px] h-[520px]" uniqueKey="fortune-wheel-settings">
<NitroCard.Header
@@ -100,6 +115,7 @@ export const FortuneWheelSettingsView: FC<FortuneWheelSettingsViewProps> = ({ on
<span className="w-16">{ LocalizeText('rarevalues.editor.value') }</span>
<span className="w-12">{ LocalizeText('rarevalues.editor.weight') }</span>
<span className="grow">{ LocalizeText('rarevalues.editor.label') }</span>
<span className="w-6" />
</Flex>
<Column gap={ 1 } overflow="auto" className="grow">
{ editRows.map(row => (
@@ -128,11 +144,23 @@ export const FortuneWheelSettingsView: FC<FortuneWheelSettingsViewProps> = ({ on
value={ row.label }
onChange={ event => updateRow(row.id, { label: event.target.value }) }
className="min-w-0 grow rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" />
<button
type="button"
title={ LocalizeText('rarevalues.editor.remove') }
onClick={ () => removeRow(row.id) }
className="flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded bg-[#d9534f] font-bold leading-none text-white hover:bg-[#c44440]">×</button>
</Flex>
)) }
{ !editRows.length &&
<Text small className="text-black/50">{ LocalizeText('wheel.settings.empty') }</Text> }
</Column>
<button
type="button"
disabled={ editRows.length >= 64 }
onClick={ addRow }
className="cursor-pointer rounded border border-dashed border-[#3a7bb5] px-4 py-1.5 text-sm font-bold text-[#3a7bb5] hover:bg-[#3a7bb5]/10 disabled:cursor-default disabled:opacity-40">
{ LocalizeText('rarevalues.editor.add') }
</button>
<button
type="button"
disabled={ !editRows.length }
+131 -38
View File
@@ -1,10 +1,12 @@
import { AddLinkEventTracker, GetRoomEngine, ILinkEventTracker, IWheelPrize, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { AddLinkEventTracker, ILinkEventTracker, IWheelPrize, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, TransitionEvent, useEffect, useMemo, useRef, useState } from 'react';
import { LocalizeText } from '../../api';
import { Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutImage, Text } from '../../common';
import { Column, Flex, LayoutAvatarImageView, LayoutCurrencyIcon, Text } from '../../common';
import { useFortuneWheel, useHasPermission } from '../../hooks';
import { NitroCard } from '../../layout';
import { FortuneWheelSettingsView } from './FortuneWheelSettingsView';
import { WheelWinReveal } from './WheelWinReveal';
import { renderPrizeIcon } from './wheelPrizeIcon';
// Stock UI palette (white / light-blue / grey / black).
const SLICE_COLORS = [ '#eef2f5', '#c3dcec' ];
@@ -13,32 +15,14 @@ const WHEEL_SIZE = 420;
const ICON_RADIUS = 150;
const FULL_TURNS = 5;
const renderPrizeIcon = (prize: IWheelPrize) =>
{
switch(prize.type)
{
case 'item':
return <LayoutImage imageUrl={ GetRoomEngine().getFurnitureFloorIconUrl(prize.spriteId) } className="h-9 w-9 bg-contain bg-center bg-no-repeat" />;
case 'badge':
return <LayoutBadgeImageView badgeCode={ prize.badgeCode } />;
case 'credits':
return (
<Column alignItems="center" gap={ 0 }>
<LayoutCurrencyIcon type={ -1 } />
<span className="text-[10px] font-bold text-[#2a3a42]">{ prize.amount }</span>
</Column>);
case 'points':
return (
<Column alignItems="center" gap={ 0 }>
<LayoutCurrencyIcon type={ prize.pointsType } />
<span className="text-[10px] font-bold text-[#2a3a42]">{ prize.amount }</span>
</Column>);
case 'spin':
return <span className="text-xs font-bold text-[#2a3a42]">+{ prize.amount }</span>;
default:
return <span className="text-xs font-bold text-[#2a3a42]/60"></span>;
}
};
// Spin motion (wind-back → fast spin past target → settle back).
const WINDBACK_DEG = 14;
const OVERSHOOT_DEG = 16;
const WINDBACK_MS = 250;
const SPIN_MS = 4000;
const SETTLE_MS = 550;
type SpinPhase = 'idle' | 'windback' | 'spin' | 'settle';
export const FortuneWheelView: FC<{}> = () =>
{
@@ -46,11 +30,28 @@ export const FortuneWheelView: FC<{}> = () =>
const [ isSettingsOpen, setIsSettingsOpen ] = useState(false);
const { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin } = useFortuneWheel();
const canManage = useHasPermission('acc_wheeladmin');
const [ rotation, setRotation ] = useState(0);
const [ phase, setPhase ] = useState<SpinPhase>('idle');
const [ revealPrize, setRevealPrize ] = useState<IWheelPrize | null>(null);
const [ wheelScale, setWheelScale ] = useState(1);
const rotationRef = useRef(0);
const targetRef = useRef(0);
const phaseRef = useRef<SpinPhase>('idle');
const wonPrizeRef = useRef<IWheelPrize | null>(null);
const prizesRef = useRef<IWheelPrize[]>([]);
const wheelHostRef = useRef<HTMLDivElement>(null);
prizesRef.current = prizes;
const reducedMotion = useMemo(() => (typeof window !== 'undefined') && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches, []);
const setSpinPhase = (next: SpinPhase) =>
{
phaseRef.current = next;
setPhase(next);
};
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
@@ -79,6 +80,24 @@ export const FortuneWheelView: FC<{}> = () =>
if(isVisible) open();
}, [ isVisible, open ]);
// Keep the wheel fitting its container on narrow viewports without
// rewriting the px-based slice/icon math: measure the available width
// and scale the whole wheel down to fit.
useEffect(() =>
{
const host = wheelHostRef.current;
if(!host || (typeof ResizeObserver === 'undefined')) return;
const observer = new ResizeObserver(entries =>
{
const width = entries[0]?.contentRect.width ?? WHEEL_SIZE;
setWheelScale(Math.min(1, width / WHEEL_SIZE));
});
observer.observe(host);
return () => observer.disconnect();
}, [ isVisible ]);
// Drive the spin animation when the server reports the winning slice.
useEffect(() =>
{
@@ -93,14 +112,69 @@ export const FortuneWheelView: FC<{}> = () =>
return;
}
wonPrizeRef.current = list[idx];
const sliceAngle = 360 / list.length;
const centerAngle = ((idx + 0.5) * sliceAngle);
const current = rotationRef.current;
const target = (current - (current % 360)) + (FULL_TURNS * 360) + (360 - centerAngle);
targetRef.current = target;
if(reducedMotion)
{
// Single straightforward move to the target, no flourish.
setSpinPhase('spin');
rotationRef.current = target;
setRotation(target);
}, [ pendingPrizeId, finishSpin ]);
return;
}
// Phase 1: tiny anticipation wind-back before the spin.
setSpinPhase('windback');
const back = current - WINDBACK_DEG;
rotationRef.current = back;
setRotation(back);
}, [ pendingPrizeId, finishSpin, reducedMotion ]);
const finishReveal = () =>
{
setSpinPhase('idle');
setRevealPrize(wonPrizeRef.current);
finishSpin();
};
const handleTransitionEnd = (event: TransitionEvent<HTMLDivElement>) =>
{
// Only react to the wheel's own transform transition finishing. Child
// elements (prize icons, badges) can emit their own bubbling
// transitionend events; without this guard they'd advance the spin
// phase machine early and reveal the prize before the wheel stops.
if((event.target !== event.currentTarget) || (event.propertyName !== 'transform')) return;
switch(phaseRef.current)
{
case 'windback':
// Phase 2: spin fast, overshooting the target slightly.
setSpinPhase('spin');
rotationRef.current = targetRef.current + OVERSHOOT_DEG;
setRotation(rotationRef.current);
return;
case 'spin':
if(reducedMotion)
{
finishReveal();
return;
}
// Phase 3: settle back from the overshoot onto the target.
setSpinPhase('settle');
rotationRef.current = targetRef.current;
setRotation(rotationRef.current);
return;
case 'settle':
finishReveal();
return;
}
};
const sliceAngle = prizes.length ? (360 / prizes.length) : 0;
@@ -112,24 +186,39 @@ export const FortuneWheelView: FC<{}> = () =>
return `conic-gradient(${ stops })`;
}, [ prizes, sliceAngle ]);
const wheelTransition = useMemo(() =>
{
switch(phase)
{
case 'windback': return `transform ${ WINDBACK_MS }ms ease-in`;
case 'spin': return `transform ${ SPIN_MS }ms cubic-bezier(0.12,0.78,0.2,1)`;
case 'settle': return `transform ${ SETTLE_MS }ms ease-out`;
default: return 'none';
}
}, [ phase ]);
if(!isVisible) return null;
const canSpin = ((freeSpins + extraSpins) > 0) && !isSpinning && (prizes.length > 0);
return (
<NitroCard className="w-[800px] max-w-[96vw]" uniqueKey="fortune-wheel">
<NitroCard className="w-[780px] max-w-[96vw]" uniqueKey="fortune-wheel">
<NitroCard.Header headerText={ LocalizeText('wheel.title') } onCloseClick={ () => setIsVisible(false) } />
<NitroCard.Content>
<Flex gap={ 3 }>
<Column alignItems="center" gap={ 2 } className="shrink-0">
<div className="relative" style={ { width: WHEEL_SIZE, height: WHEEL_SIZE } }>
<div className="relative">
<Flex gap={ 3 } className="flex-col sm:flex-row">
<Column alignItems="center" gap={ 2 } className="w-full shrink-0 sm:w-[420px]">
<div ref={ wheelHostRef } className="relative w-full" style={ { height: WHEEL_SIZE * wheelScale } }>
<div
className="absolute left-1/2 top-0"
style={ { width: WHEEL_SIZE, height: WHEEL_SIZE, transform: `translateX(-50%) scale(${ wheelScale })`, transformOrigin: 'top center' } }>
<div
className="absolute left-1/2 -top-2 z-20 -translate-x-1/2 drop-shadow-[0_2px_2px_rgba(0,0,0,0.25)]"
style={ { width: 0, height: 0, borderLeft: '14px solid transparent', borderRight: '14px solid transparent', borderTop: `28px solid ${ RIM }` } } />
<div
className="absolute inset-0 rounded-full shadow-[0_0_0_4px_rgba(0,0,0,0.12),inset_0_0_18px_rgba(0,0,0,0.1)]"
style={ { background, border: `8px solid ${ RIM }`, transform: `rotate(${ rotation }deg)`, transition: isSpinning ? 'transform 4.5s cubic-bezier(0.15,0.85,0.25,1)' : 'none' } }
onTransitionEnd={ () => { if(isSpinning) finishSpin(); } }>
style={ { background, border: `8px solid ${ RIM }`, transform: `rotate(${ rotation }deg)`, transition: wheelTransition } }
onTransitionEnd={ handleTransitionEnd }>
{ prizes.map((_, i) => (
<div
key={ `divider-${ i }` }
@@ -152,9 +241,10 @@ export const FortuneWheelView: FC<{}> = () =>
</div>
<div className="absolute left-1/2 top-1/2 z-10 h-14 w-14 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#eef2f5] shadow-[0_0_8px_rgba(0,0,0,0.25)]" style={ { border: `4px solid ${ RIM }` } } />
</div>
</div>
<Text bold className="text-[#2f6f95]">{ LocalizeText('wheel.free.today', [ 'count' ], [ freeSpins.toString() ]) }</Text>
<Text small className="text-[#33424c]">{ LocalizeText('wheel.extra', [ 'count' ], [ extraSpins.toString() ]) }</Text>
<Flex gap={ 2 } alignItems="center">
<Flex gap={ 2 } alignItems="center" className="flex-wrap justify-center">
<button
disabled={ !canSpin }
onClick={ () => spin() }
@@ -177,7 +267,7 @@ export const FortuneWheelView: FC<{}> = () =>
</Column>
<Column gap={ 2 } className="min-w-[300px] grow rounded-lg border border-black/10 bg-black/5 p-3">
<Text bold className="text-base text-[#33424c]">{ LocalizeText('wheel.winners') }</Text>
<Column gap={ 1 } overflow="auto" className="h-[440px]">
<Column gap={ 1 } overflow="auto" className="h-[440px] max-h-[60vh]">
{ recentWins.map((win, i) => (
<Flex key={ i } alignItems="center" gap={ 2 } className="rounded border-b border-black/10 py-1.5 hover:bg-black/5">
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded bg-black/5">
@@ -194,6 +284,9 @@ export const FortuneWheelView: FC<{}> = () =>
</Column>
</Column>
</Flex>
{ revealPrize &&
<WheelWinReveal prize={ revealPrize } onDismiss={ () => setRevealPrize(null) } /> }
</div>
</NitroCard.Content>
{ canManage && isSettingsOpen &&
<FortuneWheelSettingsView onClose={ () => setIsSettingsOpen(false) } /> }
@@ -0,0 +1,101 @@
import { IWheelPrize } from '@nitrots/nitro-renderer';
import { FC, useEffect } from 'react';
import { LocalizeText } from '../../api';
import { renderPrizeIcon } from './wheelPrizeIcon';
import { getPrizeTier } from './wheelPrizeTier';
interface WheelWinRevealProps
{
prize: IWheelPrize;
onDismiss: () => void;
}
const CONFETTI_COLORS = [ '#ffd34d', '#4fc3f7', '#ff7b7b', '#7bff9e', '#c08bff', '#ffa94d' ];
const CONFETTI_COUNT = 40;
// Precomputed once at module load (not during render) so the React Compiler
// purity rules stay happy and there's no per-mount cost. A fixed spread of
// 40 pieces with staggered delays reads as a lively burst either way.
const CONFETTI = Array.from({ length: CONFETTI_COUNT }, (_, i) => ({
left: Math.random() * 100,
delay: Math.random() * 0.5,
duration: 1.8 + (Math.random() * 1.4),
drift: (Math.random() - 0.5) * 160,
color: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
width: 6 + Math.round(Math.random() * 4)
}));
// How long each tier lingers before auto-dismissing (ms).
const AUTO_DISMISS = { none: 2000, common: 2400, rare: 4000 } as const;
export const WheelWinReveal: FC<WheelWinRevealProps> = ({ prize, onDismiss }) =>
{
const tier = getPrizeTier(prize);
useEffect(() =>
{
const timer = window.setTimeout(onDismiss, AUTO_DISMISS[tier]);
return () => window.clearTimeout(timer);
}, [ tier, onDismiss ]);
// The "nothing" slice gets a quiet, non-celebratory message.
if(tier === 'none')
{
return (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/35" onClick={ onDismiss }>
<div className="rounded-xl bg-white px-6 py-4 text-center shadow-2xl" style={ { animation: 'wheelPop .45s cubic-bezier(.18,.89,.32,1.28)' } }>
<div className="text-3xl">🍀</div>
<div className="mt-1 font-bold text-[#33424c]">{ LocalizeText('wheel.win.nothing') }</div>
</div>
</div>
);
}
const isRare = tier === 'rare';
return (
<div
className={ `absolute inset-0 z-40 flex flex-col items-center justify-center gap-2 ${ isRare ? 'bg-black/70' : 'bg-black/40' }` }
onClick={ onDismiss }>
<style>{ `
@keyframes wheelPop { from { transform: scale(.35); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes wheelConfettiFall {
0% { transform: translate(0, -10%) rotate(0deg); opacity: 1; }
100% { transform: translate(var(--drift), 320px) rotate(720deg); opacity: 0; }
}
@keyframes wheelGlow {
0%,100% { box-shadow: 0 0 18px 4px rgba(255,211,77,.55); }
50% { box-shadow: 0 0 30px 10px rgba(255,211,77,.9); }
}
` }</style>
{ isRare && CONFETTI.map((piece, i) => (
<span
key={ i }
className="pointer-events-none absolute top-0"
style={ {
left: `${ piece.left }%`,
width: `${ piece.width }px`,
height: `${ piece.width + 4 }px`,
background: piece.color,
borderRadius: '2px',
['--drift' as any]: `${ piece.drift }px`,
animation: `wheelConfettiFall ${ piece.duration }s ${ piece.delay }s linear forwards`
} } />
)) }
{ isRare &&
<div className="text-sm font-black uppercase tracking-[0.2em] text-[#ffd34d] drop-shadow">{ LocalizeText('wheel.win.jackpot') }</div> }
<div
className="flex h-32 w-32 items-center justify-center rounded-full bg-white shadow-2xl"
style={ { animation: `wheelPop .5s cubic-bezier(.18,.89,.32,1.28)${ isRare ? ', wheelGlow 1.4s ease-in-out .5s infinite' : '' }` } }>
{ renderPrizeIcon(prize, true) }
</div>
<div className="mt-1 text-lg font-black text-white drop-shadow">{ LocalizeText('wheel.win.title') }</div>
{ !!prize.label &&
<div className="rounded-full bg-white/95 px-4 py-1 text-sm font-bold text-[#20313a] shadow">{ prize.label }</div> }
</div>
);
};
@@ -0,0 +1,36 @@
import { GetRoomEngine, IWheelPrize } from '@nitrots/nitro-renderer';
import { ReactNode } from 'react';
import { Column, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutImage } from '../../common';
// Shared prize-icon renderer used both on the wheel slices (small) and in the
// win-reveal overlay (large). Keeping it in one place means the two stay
// visually consistent when prize types change.
export const renderPrizeIcon = (prize: IWheelPrize, large = false): ReactNode =>
{
const imageClass = large ? 'h-20 w-20' : 'h-9 w-9';
const amountClass = large ? 'text-xl font-bold text-[#2a3a42]' : 'text-[10px] font-bold text-[#2a3a42]';
switch(prize.type)
{
case 'item':
return <LayoutImage imageUrl={ GetRoomEngine().getFurnitureFloorIconUrl(prize.spriteId) } className={ `${ imageClass } bg-contain bg-center bg-no-repeat` } />;
case 'badge':
return <div className={ large ? 'scale-[1.8]' : '' }><LayoutBadgeImageView badgeCode={ prize.badgeCode } /></div>;
case 'credits':
return (
<Column alignItems="center" gap={ 0 }>
<div className={ large ? 'scale-150' : '' }><LayoutCurrencyIcon type={ -1 } /></div>
<span className={ amountClass }>{ prize.amount }</span>
</Column>);
case 'points':
return (
<Column alignItems="center" gap={ 0 }>
<div className={ large ? 'scale-150' : '' }><LayoutCurrencyIcon type={ prize.pointsType } /></div>
<span className={ amountClass }>{ prize.amount }</span>
</Column>);
case 'spin':
return <span className={ large ? 'text-2xl font-bold text-[#2a3a42]' : 'text-xs font-bold text-[#2a3a42]' }>+{ prize.amount }</span>;
default:
return <span className={ large ? 'text-2xl font-bold text-[#2a3a42]/60' : 'text-xs font-bold text-[#2a3a42]/60' }></span>;
}
};
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { CREDITS_RARE_THRESHOLD, getPrizeTier, POINTS_RARE_THRESHOLD } from './wheelPrizeTier';
const makePrize = (overrides: Partial<{ type: string; amount: number }>) => ({
id: 1,
type: 'credits',
spriteId: 0,
badgeCode: '',
amount: 0,
pointsType: -1,
label: '',
...overrides
}) as any;
describe('getPrizeTier', () =>
{
it('returns "common" for a null prize (defensive default)', () =>
{
expect(getPrizeTier(null)).toBe('common');
});
it('classifies the "nothing" slice as "none"', () =>
{
expect(getPrizeTier(makePrize({ type: 'nothing' }))).toBe('none');
});
it('classifies items and badges as "rare"', () =>
{
expect(getPrizeTier(makePrize({ type: 'item' }))).toBe('rare');
expect(getPrizeTier(makePrize({ type: 'badge' }))).toBe('rare');
});
it('classifies a free spin as "common"', () =>
{
expect(getPrizeTier(makePrize({ type: 'spin', amount: 1 }))).toBe('common');
});
it('tiers credits by the threshold', () =>
{
expect(getPrizeTier(makePrize({ type: 'credits', amount: CREDITS_RARE_THRESHOLD - 1 }))).toBe('common');
expect(getPrizeTier(makePrize({ type: 'credits', amount: CREDITS_RARE_THRESHOLD }))).toBe('rare');
expect(getPrizeTier(makePrize({ type: 'credits', amount: CREDITS_RARE_THRESHOLD + 1000 }))).toBe('rare');
});
it('tiers points by the threshold', () =>
{
expect(getPrizeTier(makePrize({ type: 'points', amount: POINTS_RARE_THRESHOLD - 1 }))).toBe('common');
expect(getPrizeTier(makePrize({ type: 'points', amount: POINTS_RARE_THRESHOLD }))).toBe('rare');
});
it('falls back to "common" for unknown prize types', () =>
{
expect(getPrizeTier(makePrize({ type: 'mystery-future-type' }))).toBe('common');
});
});
@@ -0,0 +1,41 @@
import { IWheelPrize } from '@nitrots/nitro-renderer';
// Group A is client-only: the player-facing prize payload (WheelDataEvent /
// IWheelPrize) carries no `weight`, so we can't read the server's real spin
// odds here. We approximate rarity from the data the client already has —
// the prize `type` and `amount`. A future cross-component change (Group B)
// can pass the true weight through and replace this heuristic.
//
// none → the "lose" slice. Quiet message, no celebration.
// common → low-value wins (a free spin, a small credit/point payout).
// Light reveal: prize card pops in, no confetti.
// rare → items, badges, or large currency payouts. Full celebration
// overlay with confetti.
export type WheelPrizeTier = 'none' | 'common' | 'rare';
// Currency payouts at or above these amounts are treated as "rare". These are
// deliberately conservative defaults; tune per hotel if needed.
export const CREDITS_RARE_THRESHOLD = 500;
export const POINTS_RARE_THRESHOLD = 100;
export const getPrizeTier = (prize: IWheelPrize | null): WheelPrizeTier =>
{
if(!prize) return 'common';
switch(prize.type)
{
case 'nothing':
return 'none';
case 'item':
case 'badge':
return 'rare';
case 'spin':
return 'common';
case 'credits':
return prize.amount >= CREDITS_RARE_THRESHOLD ? 'rare' : 'common';
case 'points':
return prize.amount >= POINTS_RARE_THRESHOLD ? 'rare' : 'common';
default:
return 'common';
}
};
+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()), []);