diff --git a/public/configuration/UITexts_en.json5.example b/public/configuration/UITexts_en.json5.example index 63fb47c..473c4df 100644 --- a/public/configuration/UITexts_en.json5.example +++ b/public/configuration/UITexts_en.json5.example @@ -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', diff --git a/public/configuration/UITexts_it.json5.example b/public/configuration/UITexts_it.json5.example index 2b4c6da..5ab9eed 100644 --- a/public/configuration/UITexts_it.json5.example +++ b/public/configuration/UITexts_it.json5.example @@ -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', diff --git a/public/configuration/UITexts_nl.json5.example b/public/configuration/UITexts_nl.json5.example index 8bbedba..9763667 100644 --- a/public/configuration/UITexts_nl.json5.example +++ b/public/configuration/UITexts_nl.json5.example @@ -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', diff --git a/src/components/fortune-wheel/FortuneWheelSettingsView.tsx b/src/components/fortune-wheel/FortuneWheelSettingsView.tsx index 628eeb0..a759463 100644 --- a/src/components/fortune-wheel/FortuneWheelSettingsView.tsx +++ b/src/components/fortune-wheel/FortuneWheelSettingsView.tsx @@ -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 = ({ on const updateRow = (id: number, patch: Partial) => 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 ( = ({ on { LocalizeText('rarevalues.editor.value') } { LocalizeText('rarevalues.editor.weight') } { LocalizeText('rarevalues.editor.label') } + { editRows.map(row => ( @@ -128,11 +144,23 @@ export const FortuneWheelSettingsView: FC = ({ 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]" /> + )) } { !editRows.length && { LocalizeText('wheel.settings.empty') } } + - - { canManage && - } - - - - { LocalizeText('wheel.winners') } - - { recentWins.map((win, i) => ( - -
- + 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 }` } } /> +
+ { prizes.map((_, i) => ( +
+ )) } + { prizes.map((prize, i) => + { + const centerAngle = ((i + 0.5) * sliceAngle); + return ( +
+
+ { renderPrizeIcon(prize) } +
+
); + }) }
- - { win.username } - { win.prizeLabel } - - - )) } - { !recentWins.length && - { LocalizeText('wheel.winners.empty') } } +
+
+
+ { LocalizeText('wheel.free.today', [ 'count' ], [ freeSpins.toString() ]) } + { LocalizeText('wheel.extra', [ 'count' ], [ extraSpins.toString() ]) } + + + + { canManage && + } + - - + + { LocalizeText('wheel.winners') } + + { recentWins.map((win, i) => ( + +
+ +
+ + { win.username } + { win.prizeLabel } + +
+ )) } + { !recentWins.length && + { LocalizeText('wheel.winners.empty') } } +
+
+ + { revealPrize && + setRevealPrize(null) } /> } +
{ canManage && isSettingsOpen && setIsSettingsOpen(false) } /> } diff --git a/src/components/fortune-wheel/WheelWinReveal.tsx b/src/components/fortune-wheel/WheelWinReveal.tsx new file mode 100644 index 0000000..09e1fbd --- /dev/null +++ b/src/components/fortune-wheel/WheelWinReveal.tsx @@ -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 = ({ 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 ( +
+
+
🍀
+
{ LocalizeText('wheel.win.nothing') }
+
+
+ ); + } + + const isRare = tier === 'rare'; + + return ( +
+ + + { isRare && CONFETTI.map((piece, i) => ( + + )) } + + { isRare && +
{ LocalizeText('wheel.win.jackpot') }
} + +
+ { renderPrizeIcon(prize, true) } +
+ +
{ LocalizeText('wheel.win.title') }
+ { !!prize.label && +
{ prize.label }
} +
+ ); +}; diff --git a/src/components/fortune-wheel/wheelPrizeIcon.tsx b/src/components/fortune-wheel/wheelPrizeIcon.tsx new file mode 100644 index 0000000..db71111 --- /dev/null +++ b/src/components/fortune-wheel/wheelPrizeIcon.tsx @@ -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 ; + case 'badge': + return
; + case 'credits': + return ( + +
+ { prize.amount } +
); + case 'points': + return ( + +
+ { prize.amount } +
); + case 'spin': + return +{ prize.amount }; + default: + return ; + } +}; diff --git a/src/components/fortune-wheel/wheelPrizeTier.test.ts b/src/components/fortune-wheel/wheelPrizeTier.test.ts new file mode 100644 index 0000000..cf5f329 --- /dev/null +++ b/src/components/fortune-wheel/wheelPrizeTier.test.ts @@ -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'); + }); +}); diff --git a/src/components/fortune-wheel/wheelPrizeTier.ts b/src/components/fortune-wheel/wheelPrizeTier.ts new file mode 100644 index 0000000..83a7d2f --- /dev/null +++ b/src/components/fortune-wheel/wheelPrizeTier.ts @@ -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'; + } +}; diff --git a/src/hooks/fortune-wheel/useFortuneWheel.ts b/src/hooks/fortune-wheel/useFortuneWheel.ts index c280289..75ad3f0 100644 --- a/src/hooks/fortune-wheel/useFortuneWheel.ts +++ b/src/hooks/fortune-wheel/useFortuneWheel.ts @@ -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([]); + // 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(null); + useMessageEvent(WheelAdminPrizesEvent, event => { setAdminPrizes(event.getParser().prizes); @@ -35,13 +44,21 @@ const useFortuneWheelState = () => useMessageEvent(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, 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()), []);