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.
This commit is contained in:
medievalshell
2026-05-28 02:39:02 +02:00
parent 00fbdc6f6d
commit 61aceaa422
13 changed files with 602 additions and 2 deletions
@@ -0,0 +1,6 @@
{
"rarevalues.title": "Rare Values",
"rarevalues.loading": "Loading values…",
"rarevalues.empty": "No rares found",
"rarevalues.infostand.label": "Value:"
}
@@ -0,0 +1,6 @@
{
"rarevalues.title": "Valore Rari",
"rarevalues.loading": "Caricamento valori…",
"rarevalues.empty": "Nessun raro trovato",
"rarevalues.infostand.label": "Valore:"
}
@@ -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"
}
@@ -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"
}
+4
View File
@@ -24,6 +24,8 @@ import { HcCenterView } from './hc-center/HcCenterView';
import { HelpView } from './help/HelpView'; import { HelpView } from './help/HelpView';
import { HotelView } from './hotel-view/HotelView'; import { HotelView } from './hotel-view/HotelView';
import { HousekeepingView } from './housekeeping/HousekeepingView'; import { HousekeepingView } from './housekeeping/HousekeepingView';
import { RareValuesView } from './rare-values/RareValuesView';
import { FortuneWheelView } from './fortune-wheel/FortuneWheelView';
import { InventoryView } from './inventory/InventoryView'; import { InventoryView } from './inventory/InventoryView';
import { ModToolsView } from './mod-tools/ModToolsView'; import { ModToolsView } from './mod-tools/ModToolsView';
import { NavigatorView } from './navigator/NavigatorView'; import { NavigatorView } from './navigator/NavigatorView';
@@ -176,6 +178,8 @@ export const MainView: FC<{}> = props =>
<GameCenterView /> <GameCenterView />
<FloorplanEditorView /> <FloorplanEditorView />
<FurniEditorView /> <FurniEditorView />
<RareValuesView />
<FortuneWheelView />
<ExternalPluginLoader /> <ExternalPluginLoader />
</> </>
); );
@@ -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 <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>;
}
};
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<IWheelPrize[]>([]);
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 (
<NitroCard className="w-[800px] 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
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>
</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' } } />
</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>
</NitroCard.Content>
</NitroCard>
);
};
@@ -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<EditRow[]>([]);
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<RareValueRow[]>(() =>
{
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<RareValueRow[]>(() =>
{
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<EditRow>) =>
setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row));
return (
<NitroCard className="w-[420px] h-[480px]" uniqueKey="rare-values">
<NitroCard.Header
headerText={ LocalizeText('rarevalues.title') }
onCloseClick={ () => setIsVisible(false) } />
{ canEdit &&
<NitroCard.Tabs>
<NitroCard.TabItem isActive={ tab === 'values' } onClick={ () => setTab('values') }>
<Text>{ LocalizeText('rarevalues.title') }</Text>
</NitroCard.TabItem>
<NitroCard.TabItem isActive={ tab === 'editor' } onClick={ () => setTab('editor') }>
<Text>{ LocalizeText('rarevalues.editor.tab') }</Text>
</NitroCard.TabItem>
</NitroCard.Tabs> }
<NitroCard.Content>
{ (tab === 'values' || !canEdit) &&
<Column gap={ 2 } className="h-full p-1">
<NitroInput
placeholder={ LocalizeText('generic.search') }
value={ searchValue }
onChange={ event => setSearchValue(event.target.value) } />
<Column gap={ 0 } overflow="auto" className="grow">
{ !loaded &&
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.loading') }</Text> }
{ (loaded && !filtered.length) &&
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.empty') }</Text> }
{ filtered.map(row => (
<Flex key={ row.spriteId } alignItems="center" gap={ 2 } className="border-b border-black/10 py-1.5 hover:bg-black/5">
<LayoutImage imageUrl={ row.iconUrl } className="h-10 w-10 shrink-0 bg-contain bg-center bg-no-repeat" />
<Text truncate className="grow text-[#1f2d34]">{ row.name }</Text>
<Flex alignItems="center" gap={ 1 } className="shrink-0">
<Text bold textEnd className="text-[#2f6f95]">{ LocalizeFormattedNumber(row.value.points) }</Text>
<LayoutCurrencyIcon type={ row.value.pointsType } />
</Flex>
</Flex>
)) }
</Column>
</Column> }
{ (tab === 'editor' && canEdit) &&
<Column gap={ 1 } className="h-full p-1">
<Flex gap={ 1 } className="px-1 text-[11px] font-bold text-black/60">
<span className="w-28">{ LocalizeText('rarevalues.editor.type') }</span>
<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>
</Flex>
<Column gap={ 1 } overflow="auto" className="grow">
{ editRows.map(row => (
<Flex key={ row.id } alignItems="center" gap={ 1 } className="border-b border-black/10 pb-1">
<select
value={ row.category }
onChange={ event => updateRow(row.id, { category: event.target.value }) }
className="w-28 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]">
{ CATEGORIES.map(cat => <option key={ cat.key } value={ cat.key }>{ cat.label }</option>) }
</select>
<input
type="number"
value={ row.num }
disabled={ row.category === 'nulla' }
onChange={ event => 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" />
<input
type="number"
value={ row.weight }
onChange={ event => 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]" />
<input
type="text"
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]" />
</Flex>
)) }
</Column>
<button
type="button"
onClick={ () => saveAdminPrizes?.(editRows.map(rowToEdit)) }
className="cursor-pointer rounded bg-[#3a7bb5] px-4 py-2 font-bold text-white hover:bg-[#336ea3]">
{ LocalizeText('rarevalues.editor.save') }
</button>
</Column> }
</NitroCard.Content>
</NitroCard>
);
};
@@ -3,8 +3,8 @@ import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaCrosshairs, FaTimes } from 'react-icons/fa'; import { FaCrosshairs, FaTimes } from 'react-icons/fa';
import { GrFormNextLink, GrRotateLeft, GrRotateRight } from 'react-icons/gr'; import { GrFormNextLink, GrRotateLeft, GrRotateRight } from 'react-icons/gr';
import { AvatarInfoFurni, GetGroupInformation, LocalizeText, SendMessageComposer } from '../../../../../api'; import { AvatarInfoFurni, GetGroupInformation, LocalizeText, SendMessageComposer } from '../../../../../api';
import { Button, Column, Flex, LayoutBadgeImageView, LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomObjectImageView, Text, UserProfileIconView } from '../../../../../common'; import { Button, Column, Flex, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomObjectImageView, Text, UserProfileIconView } from '../../../../../common';
import { useHasPermission, useMessageEvent, useNitroEvent, useRoom, useWiredTools } from '../../../../../hooks'; import { useHasPermission, useMessageEvent, useNitroEvent, useRareValues, useRoom, useWiredTools } from '../../../../../hooks';
import { NitroInput } from '../../../../../layout'; import { NitroInput } from '../../../../../layout';
interface InfoStandWidgetFurniViewProps interface InfoStandWidgetFurniViewProps
@@ -23,6 +23,8 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
const { roomSession = null } = useRoom(); const { roomSession = null } = useRoom();
const { openInspectionForFurni, showInspectButton } = useWiredTools(); const { openInspectionForFurni, showInspectButton } = useWiredTools();
const isModerator = useHasPermission('acc_anyroomowner'); 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 [ pickupMode, setPickupMode ] = useState(0);
const [ canMove, setCanMove ] = useState(false); const [ canMove, setCanMove ] = useState(false);
@@ -563,6 +565,17 @@ export const InfoStandWidgetFurniView: FC<InfoStandWidgetFurniViewProps> = props
<Text small textBreak variant="white">X: { itemLocation.x } · Y: { itemLocation.y } · H: { itemLocation.z < 0.01 ? 0 : itemLocation.z }</Text> <Text small textBreak variant="white">X: { itemLocation.x } · Y: { itemLocation.y } · H: { itemLocation.z < 0.01 ? 0 : itemLocation.z }</Text>
</div> </div>
</> } </> }
{ (rareValue && rareValue.points > 0) &&
<>
<hr className="m-0 bg-[#0003] border-0 opacity-[.5] h-px" />
<Flex alignItems="center" gap={ 2 }>
<Text small variant="white">{ LocalizeText('rarevalues.infostand.label') }</Text>
<Flex alignItems="center" gap={ 1 }>
<Text small variant="white">{ rareValue.points }</Text>
<LayoutCurrencyIcon type={ rareValue.pointsType } />
</Flex>
</Flex>
</> }
{ godMode && { godMode &&
<> <>
<hr className="m-0 bg-[#0003] border-0 opacity-[.5] h-px" /> <hr className="m-0 bg-[#0003] border-0 opacity-[.5] h-px" />
+12
View File
@@ -250,6 +250,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
{ (getFullCount > 0) && { (getFullCount > 0) &&
<LayoutItemCountView count={ getFullCount } className="absolute -right-1 top-0" /> } <LayoutItemCountView count={ getFullCount } className="absolute -right-1 top-0" /> }
</motion.div> </motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="rare-values" onClick={ () => CreateLinkEvent('rare-values/toggle') } className="tb-icon" />
</motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="fortune-wheel" onClick={ () => CreateLinkEvent('fortune-wheel/toggle') } className="tb-icon" />
</motion.div>
{ (isInRoom && showToolbarButton) && { (isInRoom && showToolbarButton) &&
<motion.div variants={ itemVariants }> <motion.div variants={ itemVariants }>
<ToolbarItemView icon="wired-tools" onClick={ openMonitor } className="tb-icon" /> <ToolbarItemView icon="wired-tools" onClick={ openMonitor } className="tb-icon" />
@@ -358,6 +364,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
{ (getFullCount > 0) && { (getFullCount > 0) &&
<LayoutItemCountView count={ getFullCount } className="absolute -right-1 top-0" /> } <LayoutItemCountView count={ getFullCount } className="absolute -right-1 top-0" /> }
</motion.div> </motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="rare-values" onClick={ () => CreateLinkEvent('rare-values/toggle') } className="tb-icon" />
</motion.div>
<motion.div variants={ itemVariants }>
<ToolbarItemView icon="fortune-wheel" onClick={ () => CreateLinkEvent('fortune-wheel/toggle') } className="tb-icon" />
</motion.div>
</motion.div> </motion.div>
<motion.div <motion.div
variants={ containerVariants } variants={ containerVariants }
+14
View File
@@ -77,6 +77,20 @@
height: 34px; height: 34px;
} }
.nitro-icon.icon-rare-values {
background-image: url("@/assets/images/toolbar/icons/catalog.png");
width: 37px;
height: 36px;
filter: hue-rotate(280deg) saturate(1.4);
}
.nitro-icon.icon-fortune-wheel {
background-image: url("@/assets/images/toolbar/icons/game.png");
width: 44px;
height: 25px;
filter: hue-rotate(300deg) saturate(1.6);
}
.nitro-icon.icon-housekeeping { .nitro-icon.icon-housekeeping {
background-image: url("@/assets/images/toolbar/icons/modtools.png"); background-image: url("@/assets/images/toolbar/icons/modtools.png");
width: 29px; width: 29px;
@@ -0,0 +1,69 @@
import { IWheelAdminPrize, IWheelAdminPrizeEdit, IWheelPrize, IWheelRecentWin, WheelAdminGetPrizesComposer, WheelAdminPrizesEvent, WheelAdminSavePrizesComposer, WheelBuySpinComposer, WheelDataEvent, WheelOpenComposer, WheelRecentWinsEvent, WheelResultEvent, WheelSpinComposer } from '@nitrots/nitro-renderer';
import { useCallback, useState } from 'react';
import { useBetween } from 'use-between';
import { SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
// Fortune wheel state + actions. Shared via useBetween so the event listeners
// register once regardless of how many components read it.
const useFortuneWheelState = () =>
{
const [ freeSpins, setFreeSpins ] = useState(0);
const [ extraSpins, setExtraSpins ] = useState(0);
const [ spinCost, setSpinCost ] = useState(0);
const [ spinCostType, setSpinCostType ] = useState(-1);
const [ prizes, setPrizes ] = useState<IWheelPrize[]>([]);
const [ recentWins, setRecentWins ] = useState<IWheelRecentWin[]>([]);
const [ pendingPrizeId, setPendingPrizeId ] = useState<number>(-1);
const [ isSpinning, setIsSpinning ] = useState(false);
const [ adminPrizes, setAdminPrizes ] = useState<IWheelAdminPrize[]>([]);
useMessageEvent<WheelAdminPrizesEvent>(WheelAdminPrizesEvent, event =>
{
setAdminPrizes(event.getParser().prizes);
});
useMessageEvent<WheelDataEvent>(WheelDataEvent, event =>
{
const parser = event.getParser();
setFreeSpins(parser.freeSpins);
setExtraSpins(parser.extraSpins);
setSpinCost(parser.spinCost);
setSpinCostType(parser.spinCostType);
setPrizes(parser.prizes);
});
useMessageEvent<WheelResultEvent>(WheelResultEvent, event =>
{
setPendingPrizeId(event.getParser().prizeId);
setIsSpinning(true);
});
useMessageEvent<WheelRecentWinsEvent>(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);
+2
View File
@@ -4,6 +4,7 @@ export * from './camera';
export * from './catalog'; export * from './catalog';
export * from './chat-history'; export * from './chat-history';
export * from './events'; export * from './events';
export * from './fortune-wheel/useFortuneWheel';
export * from './friends'; export * from './friends';
export * from './game-center'; export * from './game-center';
export * from './groups'; export * from './groups';
@@ -14,6 +15,7 @@ export * from './mod-tools';
export * from './navigator'; export * from './navigator';
export * from './notification'; export * from './notification';
export * from './purse'; export * from './purse';
export * from './rare-values/useRareValues';
export * from './rooms'; export * from './rooms';
export * from './rooms/engine'; export * from './rooms/engine';
export * from './rooms/promotes'; export * from './rooms/promotes';
+31
View File
@@ -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<Map<number, IRareValue>>(() => new Map());
const [ loaded, setLoaded ] = useState(false);
useMessageEvent<RareValuesEvent>(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);