mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
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:
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
rotationRef.current = target;
|
||||
setRotation(target);
|
||||
}, [ pendingPrizeId, finishSpin ]);
|
||||
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;
|
||||
|
||||
@@ -112,88 +186,107 @@ 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="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(); } }>
|
||||
{ prizes.map((_, i) => (
|
||||
<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
|
||||
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>
|
||||
<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">
|
||||
<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]">
|
||||
{ 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' } } />
|
||||
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>
|
||||
<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> }
|
||||
<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>
|
||||
</Flex>
|
||||
<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) } /> }
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
};
|
||||
@@ -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()), []);
|
||||
|
||||
Reference in New Issue
Block a user