Add badge drag & drop system for InfoStand and inventory

- Drag & drop badges between slots in InfoStand (own user only)
- Mini badge picker on empty slot click with search
- Swap badges between occupied slots
- Hover animation (scale, glow) on badge slots
- Configurable group slot (user.badges.group.slot.enabled)
- Support for 6 badge slots when group slot disabled
- Race condition fix with localChangeRef
- Fixed-size array logic to prevent badge disappearing

Co-Authored-By: medievalshell <medievalshell@users.noreply.github.com>
This commit is contained in:
simoleo89
2026-03-15 20:48:05 +01:00
parent 2a29d3d08c
commit 38f38d7209
11 changed files with 1152 additions and 55 deletions
+99 -9
View File
@@ -1,5 +1,5 @@
import { BadgeReceivedEvent, BadgesEvent, RequestBadgesComposer, SetActivatedBadgesComposer } from '@nitrots/nitro-renderer';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useBetween } from 'use-between';
import { GetConfigurationValue, SendMessageComposer, UnseenItemCategory } from '../../api';
import { useMessageEvent } from '../events';
@@ -17,9 +17,18 @@ const useInventoryBadgesState = () =>
const { isUnseen = null, resetCategory = null } = useInventoryUnseenTracker();
const maxBadgeCount = GetConfigurationValue<number>('user.badges.max.slots', 5);
const localChangeRef = useRef(false);
const isWearingBadge = (badgeCode: string) => (activeBadgeCodes.indexOf(badgeCode) >= 0);
const canWearBadges = () => (activeBadgeCodes.length < maxBadgeCount);
const sendActiveBadges = (badges: string[]) =>
{
localChangeRef.current = true;
const composer = new SetActivatedBadgesComposer();
for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(badges[i] ?? '');
SendMessageComposer(composer);
};
const toggleBadge = (badgeCode: string) =>
{
setActiveBadgeCodes(prevValue =>
@@ -30,7 +39,7 @@ const useInventoryBadgesState = () =>
if(index === -1)
{
if(!canWearBadges()) return prevValue;
if(newValue.length >= maxBadgeCount) return prevValue;
newValue.push(badgeCode);
}
@@ -39,11 +48,7 @@ const useInventoryBadgesState = () =>
newValue.splice(index, 1);
}
const composer = new SetActivatedBadgesComposer();
for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(newValue[i] ?? '');
SendMessageComposer(composer);
sendActiveBadges(newValue);
return newValue;
});
@@ -77,7 +82,16 @@ const useInventoryBadgesState = () =>
return newValue;
});
setActiveBadgeCodes(parser.getActiveBadgeCodes());
// Skip overwriting activeBadgeCodes if we recently made a local change
if(localChangeRef.current)
{
localChangeRef.current = false;
}
else
{
setActiveBadgeCodes(parser.getActiveBadgeCodes());
}
setBadgeCodes(allBadgeCodes);
});
@@ -141,7 +155,83 @@ const useInventoryBadgesState = () =>
setNeedsUpdate(false);
}, [ isVisible, needsUpdate ]);
return { badgeCodes, activeBadgeCodes, selectedBadgeCode, setSelectedBadgeCode, isWearingBadge, canWearBadges, toggleBadge, getBadgeId, activate, deactivate };
const setBadgeAtSlot = (badgeCode: string, slotIndex: number) =>
{
setActiveBadgeCodes(prevValue =>
{
// Build a fixed-size array of maxBadgeCount slots
const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null);
// Remove badge if already in another slot
const existingIndex = slots.indexOf(badgeCode);
if(existingIndex >= 0) slots[existingIndex] = null;
// Place badge at target slot
slots[slotIndex] = badgeCode;
// Compact: remove nulls, keep order
const result = slots.filter(Boolean) as string[];
sendActiveBadges(result);
return result;
});
};
const removeBadge = (badgeCode: string) =>
{
setActiveBadgeCodes(prevValue =>
{
const result = prevValue.filter(code => code !== badgeCode);
sendActiveBadges(result);
return result;
});
};
const reorderBadges = (fromIndex: number, toIndex: number) =>
{
setActiveBadgeCodes(prevValue =>
{
if(fromIndex === toIndex) return prevValue;
if(fromIndex >= prevValue.length) return prevValue;
const newValue = [ ...prevValue ];
const [ moved ] = newValue.splice(fromIndex, 1);
newValue.splice(toIndex, 0, moved);
sendActiveBadges(newValue);
return newValue;
});
};
const swapBadges = (fromIndex: number, toIndex: number) =>
{
setActiveBadgeCodes(prevValue =>
{
if(fromIndex === toIndex) return prevValue;
// Build fixed-size array so swap works even with empty slots
const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null);
// Swap the two slots
const temp = slots[fromIndex];
slots[fromIndex] = slots[toIndex];
slots[toIndex] = temp;
// Compact: remove nulls, keep order
const result = slots.filter(Boolean) as string[];
sendActiveBadges(result);
return result;
});
};
const requestBadges = () =>
{
SendMessageComposer(new RequestBadgesComposer());
};
return { badgeCodes, activeBadgeCodes, selectedBadgeCode, setSelectedBadgeCode, isWearingBadge, canWearBadges, toggleBadge, getBadgeId, setBadgeAtSlot, removeBadge, reorderBadges, swapBadges, requestBadges, activate, deactivate };
};
export const useInventoryBadges = () => useBetween(useInventoryBadgesState);
+15 -5
View File
@@ -116,12 +116,22 @@ const useChatInputWidgetState = () =>
(async () =>
{
const image = new Image();
try
{
const imageUrl = await TextureUtils.generateImageUrl(texture);
if (!imageUrl) return;
image.src = await TextureUtils.generateImageUrl(texture);
const newWindow = window.open('');
newWindow.document.write(image.outerHTML);
const link = document.createElement('a');
link.href = imageUrl;
link.download = `room_${ roomSession.roomId }_${ Date.now() }.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
catch (e)
{
console.warn('[Screenshot] Failed:', e);
}
})();
return null;
case ':pickall':