From 61aceaa42209a9a8b11fa84444beada02c172e07 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 28 May 2026 02:39:02 +0200 Subject: [PATCH] feat: rare values panel + fortune wheel UI + prize editor Toolbar buttons, FortuneWheelView (animated wheel, prize icons, recent winners), RareValuesView (diamond price guide + staff prize-editor tab), furni infostand value line, useFortuneWheel/useRareValues hooks, it/en text examples. --- .../configuration/rarevalues-texts-en.example | 6 + .../configuration/rarevalues-texts-it.example | 6 + public/configuration/wheel-texts-en.example | 9 + public/configuration/wheel-texts-it.example | 9 + src/components/MainView.tsx | 4 + .../fortune-wheel/FortuneWheelView.tsx | 191 ++++++++++++++ src/components/rare-values/RareValuesView.tsx | 234 ++++++++++++++++++ .../infostand/InfoStandWidgetFurniView.tsx | 17 +- src/components/toolbar/ToolbarView.tsx | 12 + src/css/icons/icons.css | 14 ++ src/hooks/fortune-wheel/useFortuneWheel.ts | 69 ++++++ src/hooks/index.ts | 2 + src/hooks/rare-values/useRareValues.ts | 31 +++ 13 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 public/configuration/rarevalues-texts-en.example create mode 100644 public/configuration/rarevalues-texts-it.example create mode 100644 public/configuration/wheel-texts-en.example create mode 100644 public/configuration/wheel-texts-it.example create mode 100644 src/components/fortune-wheel/FortuneWheelView.tsx create mode 100644 src/components/rare-values/RareValuesView.tsx create mode 100644 src/hooks/fortune-wheel/useFortuneWheel.ts create mode 100644 src/hooks/rare-values/useRareValues.ts diff --git a/public/configuration/rarevalues-texts-en.example b/public/configuration/rarevalues-texts-en.example new file mode 100644 index 0000000..e9d6368 --- /dev/null +++ b/public/configuration/rarevalues-texts-en.example @@ -0,0 +1,6 @@ +{ + "rarevalues.title": "Rare Values", + "rarevalues.loading": "Loading values…", + "rarevalues.empty": "No rares found", + "rarevalues.infostand.label": "Value:" +} diff --git a/public/configuration/rarevalues-texts-it.example b/public/configuration/rarevalues-texts-it.example new file mode 100644 index 0000000..62fef05 --- /dev/null +++ b/public/configuration/rarevalues-texts-it.example @@ -0,0 +1,6 @@ +{ + "rarevalues.title": "Valore Rari", + "rarevalues.loading": "Caricamento valori…", + "rarevalues.empty": "Nessun raro trovato", + "rarevalues.infostand.label": "Valore:" +} diff --git a/public/configuration/wheel-texts-en.example b/public/configuration/wheel-texts-en.example new file mode 100644 index 0000000..71b3b1b --- /dev/null +++ b/public/configuration/wheel-texts-en.example @@ -0,0 +1,9 @@ +{ + "wheel.title": "Fortune Wheel", + "wheel.free.today": "You have %count% free spins today!", + "wheel.extra": "Extra spins: %count%", + "wheel.spin": "SPIN", + "wheel.buy": "Buy spin", + "wheel.winners": "Latest winners", + "wheel.winners.empty": "No winners yet" +} diff --git a/public/configuration/wheel-texts-it.example b/public/configuration/wheel-texts-it.example new file mode 100644 index 0000000..f5bb934 --- /dev/null +++ b/public/configuration/wheel-texts-it.example @@ -0,0 +1,9 @@ +{ + "wheel.title": "Ruota della Fortuna", + "wheel.free.today": "Hai %count% giri gratis oggi!", + "wheel.extra": "Giri extra: %count%", + "wheel.spin": "GIRA", + "wheel.buy": "Compra giro", + "wheel.winners": "Ultimi vincitori", + "wheel.winners.empty": "Ancora nessun vincitore" +} diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 071abcb..494e948 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -24,6 +24,8 @@ import { HcCenterView } from './hc-center/HcCenterView'; import { HelpView } from './help/HelpView'; import { HotelView } from './hotel-view/HotelView'; import { HousekeepingView } from './housekeeping/HousekeepingView'; +import { RareValuesView } from './rare-values/RareValuesView'; +import { FortuneWheelView } from './fortune-wheel/FortuneWheelView'; import { InventoryView } from './inventory/InventoryView'; import { ModToolsView } from './mod-tools/ModToolsView'; import { NavigatorView } from './navigator/NavigatorView'; @@ -176,6 +178,8 @@ export const MainView: FC<{}> = props => + + ); diff --git a/src/components/fortune-wheel/FortuneWheelView.tsx b/src/components/fortune-wheel/FortuneWheelView.tsx new file mode 100644 index 0000000..a66af12 --- /dev/null +++ b/src/components/fortune-wheel/FortuneWheelView.tsx @@ -0,0 +1,191 @@ +import { AddLinkEventTracker, GetRoomEngine, ILinkEventTracker, IWheelPrize, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useMemo, useRef, useState } from 'react'; +import { LocalizeText } from '../../api'; +import { Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutImage, Text } from '../../common'; +import { useFortuneWheel } from '../../hooks'; +import { NitroCard } from '../../layout'; + +// 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; + +const renderPrizeIcon = (prize: IWheelPrize) => +{ + 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 ; + } +}; + +export const FortuneWheelView: FC<{}> = () => +{ + const [ isVisible, setIsVisible ] = useState(false); + const { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin } = useFortuneWheel(); + const [ rotation, setRotation ] = useState(0); + const rotationRef = useRef(0); + const prizesRef = useRef([]); + prizesRef.current = prizes; + + 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 ]); + + // 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; + } + + 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); + + rotationRef.current = target; + setRotation(target); + }, [ pendingPrizeId, finishSpin ]); + + 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 ]); + + if(!isVisible) return null; + + const canSpin = ((freeSpins + extraSpins) > 0) && !isSpinning && (prizes.length > 0); + + return ( + + setIsVisible(false) } /> + + + +
+
+
{ if(isSpinning) finishSpin(); } }> + { prizes.map((_, i) => ( +
+ )) } + { prizes.map((prize, i) => + { + const centerAngle = ((i + 0.5) * sliceAngle); + return ( +
+
+ { renderPrizeIcon(prize) } +
+
); + }) } +
+
+
+ { LocalizeText('wheel.free.today', [ 'count' ], [ freeSpins.toString() ]) } + { LocalizeText('wheel.extra', [ 'count' ], [ extraSpins.toString() ]) } + + + + + + + { LocalizeText('wheel.winners') } + + { recentWins.map((win, i) => ( + +
+ +
+ + { win.username } + { win.prizeLabel } + +
+ )) } + { !recentWins.length && + { LocalizeText('wheel.winners.empty') } } +
+
+ + + + ); +}; diff --git a/src/components/rare-values/RareValuesView.tsx b/src/components/rare-values/RareValuesView.tsx new file mode 100644 index 0000000..5cb4ce7 --- /dev/null +++ b/src/components/rare-values/RareValuesView.tsx @@ -0,0 +1,234 @@ +import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, IRareValue, IWheelAdminPrize, IWheelAdminPrizeEdit, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useMemo, useState } from 'react'; +import { LocalizeFormattedNumber, LocalizeText } from '../../api'; +import { Column, Flex, LayoutCurrencyIcon, LayoutImage, Text } from '../../common'; +import { useFortuneWheel, useHasPermission, useRareValues } from '../../hooks'; +import { NitroCard, NitroInput } from '../../layout'; + +interface RareValueRow +{ + spriteId: number; + name: string; + iconUrl: string; + value: IRareValue; +} + +interface EditRow +{ + id: number; + category: string; + num: number; + weight: number; + label: string; +} + +const CATEGORIES: { key: string; label: string }[] = [ + { key: 'item', label: 'Raro (ID)' }, + { key: 'diamanti', label: 'Diamanti' }, + { key: 'duckets', label: 'Duckets' }, + { key: 'crediti', label: 'Crediti' }, + { key: 'giri', label: 'Giri extra' }, + { key: 'nulla', label: 'Nulla' } +]; + +const prizeToCategory = (prize: IWheelAdminPrize): string => +{ + switch(prize.type) + { + case 'item': return 'item'; + case 'points': return (prize.pointsType === 5) ? 'diamanti' : 'duckets'; + case 'credits': return 'crediti'; + case 'spin': return 'giri'; + default: return 'nulla'; + } +}; + +const prizeToNum = (prize: IWheelAdminPrize): number => + (prize.type === 'item') ? (parseInt(prize.value) || 0) : prize.amount; + +const rowToEdit = (row: EditRow): IWheelAdminPrizeEdit => +{ + const base = { id: row.id, weight: row.weight, label: row.label }; + + switch(row.category) + { + case 'item': return { ...base, type: 'item', value: String(row.num), amount: 1, pointsType: 0 }; + case 'diamanti': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 5 }; + case 'duckets': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 0 }; + case 'crediti': return { ...base, type: 'credits', value: '', amount: row.num, pointsType: 0 }; + case 'giri': return { ...base, type: 'spin', value: '', amount: row.num, pointsType: 0 }; + default: return { ...base, type: 'nothing', value: '', amount: 0, pointsType: 0 }; + } +}; + +export const RareValuesView: FC<{}> = () => +{ + const [ isVisible, setIsVisible ] = useState(false); + const [ tab, setTab ] = useState<'values' | 'editor'>('values'); + const [ searchValue, setSearchValue ] = useState(''); + const { values = null, loaded = false } = useRareValues(); + const { adminPrizes = [], loadAdminPrizes = null, saveAdminPrizes = null } = useFortuneWheel(); + const canEdit = useHasPermission('acc_supporttool'); + const [ editRows, setEditRows ] = useState([]); + + 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: 'rare-values/', + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, []); + + useEffect(() => + { + if(isVisible && (tab === 'editor') && canEdit && loadAdminPrizes) loadAdminPrizes(); + }, [ isVisible, tab, canEdit, loadAdminPrizes ]); + + useEffect(() => + { + setEditRows(adminPrizes.map(prize => ({ id: prize.id, category: prizeToCategory(prize), num: prizeToNum(prize), weight: prize.weight, label: prize.label }))); + }, [ adminPrizes ]); + + const rows = useMemo(() => + { + if(!values) return []; + + const list: RareValueRow[] = []; + + values.forEach((value, spriteId) => + { + if(value.points <= 0) return; + + const floorData = GetSessionDataManager().getFloorItemData(spriteId); + const wallData = floorData ? null : GetSessionDataManager().getWallItemData(spriteId); + const data = (floorData ?? wallData); + + if(!data) return; + + const iconUrl = (floorData + ? GetRoomEngine().getFurnitureFloorIconUrl(spriteId) + : GetRoomEngine().getFurnitureWallIconUrl(spriteId)); + + list.push({ spriteId, name: (data.name || data.className || `#${ spriteId }`), iconUrl, value }); + }); + + list.sort((a, b) => (b.value.points - a.value.points)); + + return list; + }, [ values ]); + + const filtered = useMemo(() => + { + const query = searchValue.trim().toLocaleLowerCase(); + + if(!query) return rows; + + return rows.filter(row => row.name.toLocaleLowerCase().includes(query)); + }, [ rows, searchValue ]); + + if(!isVisible) return null; + + const updateRow = (id: number, patch: Partial) => + setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row)); + + return ( + + setIsVisible(false) } /> + { canEdit && + + setTab('values') }> + { LocalizeText('rarevalues.title') } + + setTab('editor') }> + { LocalizeText('rarevalues.editor.tab') } + + } + + { (tab === 'values' || !canEdit) && + + setSearchValue(event.target.value) } /> + + { !loaded && + { LocalizeText('rarevalues.loading') } } + { (loaded && !filtered.length) && + { LocalizeText('rarevalues.empty') } } + { filtered.map(row => ( + + + { row.name } + + { LocalizeFormattedNumber(row.value.points) } + + + + )) } + + } + + { (tab === 'editor' && canEdit) && + + + { LocalizeText('rarevalues.editor.type') } + { LocalizeText('rarevalues.editor.value') } + { LocalizeText('rarevalues.editor.weight') } + { LocalizeText('rarevalues.editor.label') } + + + { editRows.map(row => ( + + + updateRow(row.id, { num: parseInt(event.target.value) || 0 }) } + className="w-16 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34] disabled:opacity-40" /> + updateRow(row.id, { weight: parseInt(event.target.value) || 0 }) } + className="w-12 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" /> + 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]" /> + + )) } + + + } + + + ); +}; diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx index da4db6c..d2a861c 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx @@ -3,8 +3,8 @@ import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { FaCrosshairs, FaTimes } from 'react-icons/fa'; import { GrFormNextLink, GrRotateLeft, GrRotateRight } from 'react-icons/gr'; import { AvatarInfoFurni, GetGroupInformation, LocalizeText, SendMessageComposer } from '../../../../../api'; -import { Button, Column, Flex, LayoutBadgeImageView, LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomObjectImageView, Text, UserProfileIconView } from '../../../../../common'; -import { useHasPermission, useMessageEvent, useNitroEvent, useRoom, useWiredTools } from '../../../../../hooks'; +import { Button, Column, Flex, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomObjectImageView, Text, UserProfileIconView } from '../../../../../common'; +import { useHasPermission, useMessageEvent, useNitroEvent, useRareValues, useRoom, useWiredTools } from '../../../../../hooks'; import { NitroInput } from '../../../../../layout'; interface InfoStandWidgetFurniViewProps @@ -23,6 +23,8 @@ export const InfoStandWidgetFurniView: FC = props const { roomSession = null } = useRoom(); const { openInspectionForFurni, showInspectButton } = useWiredTools(); const isModerator = useHasPermission('acc_anyroomowner'); + const { getValue: getRareValue } = useRareValues(); + const rareValue = useMemo(() => (avatarInfo ? getRareValue(avatarInfo.spriteId) : null), [ avatarInfo, getRareValue ]); const [ pickupMode, setPickupMode ] = useState(0); const [ canMove, setCanMove ] = useState(false); @@ -563,6 +565,17 @@ export const InfoStandWidgetFurniView: FC = props X: { itemLocation.x } · Y: { itemLocation.y } · H: { itemLocation.z < 0.01 ? 0 : itemLocation.z }
} + { (rareValue && rareValue.points > 0) && + <> +
+ + { LocalizeText('rarevalues.infostand.label') } + + { rareValue.points } + + + + } { godMode && <>
diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index 6bcc9a3..e088a62 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -250,6 +250,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { (getFullCount > 0) && } + + CreateLinkEvent('rare-values/toggle') } className="tb-icon" /> + + + CreateLinkEvent('fortune-wheel/toggle') } className="tb-icon" /> + { (isInRoom && showToolbarButton) && @@ -358,6 +364,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { (getFullCount > 0) && } + + CreateLinkEvent('rare-values/toggle') } className="tb-icon" /> + + + CreateLinkEvent('fortune-wheel/toggle') } className="tb-icon" /> + +{ + const [ freeSpins, setFreeSpins ] = useState(0); + const [ extraSpins, setExtraSpins ] = useState(0); + const [ spinCost, setSpinCost ] = useState(0); + const [ spinCostType, setSpinCostType ] = useState(-1); + const [ prizes, setPrizes ] = useState([]); + const [ recentWins, setRecentWins ] = useState([]); + const [ pendingPrizeId, setPendingPrizeId ] = useState(-1); + const [ isSpinning, setIsSpinning ] = useState(false); + const [ adminPrizes, setAdminPrizes ] = useState([]); + + useMessageEvent(WheelAdminPrizesEvent, event => + { + setAdminPrizes(event.getParser().prizes); + }); + + useMessageEvent(WheelDataEvent, event => + { + const parser = event.getParser(); + setFreeSpins(parser.freeSpins); + setExtraSpins(parser.extraSpins); + setSpinCost(parser.spinCost); + setSpinCostType(parser.spinCostType); + setPrizes(parser.prizes); + }); + + useMessageEvent(WheelResultEvent, event => + { + setPendingPrizeId(event.getParser().prizeId); + setIsSpinning(true); + }); + + useMessageEvent(WheelRecentWinsEvent, event => + { + setRecentWins(event.getParser().wins); + }); + + const open = useCallback(() => SendMessageComposer(new WheelOpenComposer()), []); + const spin = useCallback(() => + { + setIsSpinning(prev => + { + if(!prev) SendMessageComposer(new WheelSpinComposer()); + return prev; + }); + }, []); + const buySpin = useCallback(() => SendMessageComposer(new WheelBuySpinComposer()), []); + const finishSpin = useCallback(() => + { + setIsSpinning(false); + setPendingPrizeId(-1); + }, []); + + const loadAdminPrizes = useCallback(() => SendMessageComposer(new WheelAdminGetPrizesComposer()), []); + const saveAdminPrizes = useCallback((prizes: IWheelAdminPrizeEdit[]) => SendMessageComposer(new WheelAdminSavePrizesComposer(prizes)), []); + + return { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin, adminPrizes, loadAdminPrizes, saveAdminPrizes }; +}; + +export const useFortuneWheel = () => useBetween(useFortuneWheelState); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index a369b37..4b3c1c3 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,6 +4,7 @@ export * from './camera'; export * from './catalog'; export * from './chat-history'; export * from './events'; +export * from './fortune-wheel/useFortuneWheel'; export * from './friends'; export * from './game-center'; export * from './groups'; @@ -14,6 +15,7 @@ export * from './mod-tools'; export * from './navigator'; export * from './notification'; export * from './purse'; +export * from './rare-values/useRareValues'; export * from './rooms'; export * from './rooms/engine'; export * from './rooms/promotes'; diff --git a/src/hooks/rare-values/useRareValues.ts b/src/hooks/rare-values/useRareValues.ts new file mode 100644 index 0000000..d048db1 --- /dev/null +++ b/src/hooks/rare-values/useRareValues.ts @@ -0,0 +1,31 @@ +import { IRareValue, RareValuesEvent, RequestRareValuesComposer } from '@nitrots/nitro-renderer'; +import { useCallback, useEffect, useState } from 'react'; +import { useBetween } from 'use-between'; +import { SendMessageComposer } from '../../api'; +import { useMessageEvent } from '../events'; + +// spriteId -> catalog value, fetched once from the server (RareValuesComposer). +// Shared across all consumers via useBetween so the request fires a single time. +// Read by both the furni infostand and the toolbar "Valore Rari" panel. +const useRareValuesState = () => +{ + const [ values, setValues ] = useState>(() => new Map()); + const [ loaded, setLoaded ] = useState(false); + + useMessageEvent(RareValuesEvent, event => + { + setValues(event.getParser().values); + setLoaded(true); + }); + + useEffect(() => + { + SendMessageComposer(new RequestRareValuesComposer()); + }, []); + + const getValue = useCallback((spriteId: number): IRareValue => (values.get(spriteId) ?? null), [ values ]); + + return { values, loaded, getValue }; +}; + +export const useRareValues = () => useBetween(useRareValuesState);