mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
ccebcad8a8
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.
296 lines
14 KiB
TypeScript
296 lines
14 KiB
TypeScript
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, 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' ];
|
|
const RIM = '#4c606c';
|
|
const WHEEL_SIZE = 420;
|
|
const ICON_RADIUS = 150;
|
|
const FULL_TURNS = 5;
|
|
|
|
// 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<{}> = () =>
|
|
{
|
|
const [ isVisible, setIsVisible ] = useState(false);
|
|
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 = {
|
|
linkReceived: (url: string) =>
|
|
{
|
|
const parts = url.split('/');
|
|
if(parts.length < 2) return;
|
|
|
|
switch(parts[1])
|
|
{
|
|
case 'show': setIsVisible(true); return;
|
|
case 'hide': setIsVisible(false); return;
|
|
case 'toggle': setIsVisible(prev => !prev); return;
|
|
}
|
|
},
|
|
eventUrlPrefix: 'fortune-wheel/',
|
|
};
|
|
|
|
AddLinkEventTracker(linkTracker);
|
|
|
|
return () => RemoveLinkEventTracker(linkTracker);
|
|
}, []);
|
|
|
|
useEffect(() =>
|
|
{
|
|
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(() =>
|
|
{
|
|
if(pendingPrizeId < 0) return;
|
|
|
|
const list = prizesRef.current;
|
|
const idx = list.findIndex(prize => prize.id === pendingPrizeId);
|
|
|
|
if(!list.length || (idx < 0))
|
|
{
|
|
finishSpin();
|
|
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);
|
|
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;
|
|
|
|
const background = useMemo(() =>
|
|
{
|
|
if(!prizes.length) return SLICE_COLORS[0];
|
|
|
|
const stops = prizes.map((_, i) => `${ SLICE_COLORS[i % 2] } ${ i * sliceAngle }deg ${ (i + 1) * sliceAngle }deg`).join(', ');
|
|
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-[780px] max-w-[96vw]" uniqueKey="fortune-wheel">
|
|
<NitroCard.Header headerText={ LocalizeText('wheel.title') } onCloseClick={ () => setIsVisible(false) } />
|
|
<NitroCard.Content>
|
|
<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: wheelTransition } }
|
|
onTransitionEnd={ handleTransitionEnd }>
|
|
{ prizes.map((_, i) => (
|
|
<div
|
|
key={ `divider-${ i }` }
|
|
className="absolute bottom-1/2 left-1/2 origin-bottom"
|
|
style={ { width: '2px', height: `${ WHEEL_SIZE / 2 }px`, transform: `translateX(-1px) rotate(${ i * sliceAngle }deg)`, background: 'rgba(76,96,108,0.3)' } } />
|
|
)) }
|
|
{ prizes.map((prize, i) =>
|
|
{
|
|
const centerAngle = ((i + 0.5) * sliceAngle);
|
|
return (
|
|
<div
|
|
key={ prize.id }
|
|
className="absolute left-1/2 top-1/2"
|
|
style={ { transform: `rotate(${ centerAngle }deg) translateY(-${ ICON_RADIUS }px) rotate(-${ centerAngle }deg)` } }>
|
|
<div className="-translate-x-1/2 -translate-y-1/2">
|
|
{ renderPrizeIcon(prize) }
|
|
</div>
|
|
</div>);
|
|
}) }
|
|
</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" className="flex-wrap justify-center">
|
|
<button
|
|
disabled={ !canSpin }
|
|
onClick={ () => spin() }
|
|
className="cursor-pointer rounded bg-[#3a7bb5] px-4 py-2 font-bold text-white hover:bg-[#336ea3] disabled:cursor-default disabled:opacity-40">
|
|
{ LocalizeText('wheel.spin') }
|
|
</button>
|
|
<button
|
|
onClick={ () => buySpin() }
|
|
className="flex cursor-pointer items-center gap-1 rounded bg-[#6b7884] px-3 py-2 text-white hover:bg-[#5e6a75]">
|
|
{ LocalizeText('wheel.buy') } { spinCost }
|
|
<LayoutCurrencyIcon type={ spinCostType } />
|
|
</button>
|
|
{ canManage &&
|
|
<button
|
|
onClick={ () => setIsSettingsOpen(true) }
|
|
className="cursor-pointer rounded bg-[#8a6b3a] px-3 py-2 font-bold text-white hover:bg-[#735730]">
|
|
{ LocalizeText('wheel.settings') }
|
|
</button> }
|
|
</Flex>
|
|
</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] 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">
|
|
<LayoutAvatarImageView figure={ win.look } headOnly direction={ 2 } style={ { backgroundSize: 'auto', backgroundPosition: '-22px -32px' } } />
|
|
</div>
|
|
<Column gap={ 0 } className="min-w-0">
|
|
<Text bold truncate className="text-[#1f2d34]">{ win.username }</Text>
|
|
<Text small truncate className="text-[#2f6f95]">{ win.prizeLabel }</Text>
|
|
</Column>
|
|
</Flex>
|
|
)) }
|
|
{ !recentWins.length &&
|
|
<Text small className="text-black/50">{ LocalizeText('wheel.winners.empty') }</Text> }
|
|
</Column>
|
|
</Column>
|
|
</Flex>
|
|
{ revealPrize &&
|
|
<WheelWinReveal prize={ revealPrize } onDismiss={ () => setRevealPrize(null) } /> }
|
|
</div>
|
|
</NitroCard.Content>
|
|
{ canManage && isSettingsOpen &&
|
|
<FortuneWheelSettingsView onClose={ () => setIsSettingsOpen(false) } /> }
|
|
</NitroCard>
|
|
);
|
|
};
|