From 78aedc4faab767fd426ddf2f53c2c43b14f65ca1 Mon Sep 17 00:00:00 2001 From: duckietm Date: Wed, 29 Apr 2026 13:20:13 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=86=95=20Effect=20selection=20in=20user?= =?UTF-8?q?=20dropdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MainView.tsx | 2 + .../AvatarEffectPreviewView.tsx | 76 +++++++++ .../avatar-effects/AvatarEffectsView.tsx | 156 ++++++++++++++++++ src/components/avatar-effects/index.ts | 2 + .../menu/AvatarInfoWidgetOwnAvatarView.tsx | 6 + 5 files changed, 242 insertions(+) create mode 100644 src/components/avatar-effects/AvatarEffectPreviewView.tsx create mode 100644 src/components/avatar-effects/AvatarEffectsView.tsx create mode 100644 src/components/avatar-effects/index.ts diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 5309364..70f2fd3 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -4,6 +4,7 @@ import { FC, useEffect, useState } from 'react'; import { useNitroEvent } from '../hooks'; import { AchievementsView } from './achievements/AchievementsView'; import { AvatarEditorView } from './avatar-editor'; +import { AvatarEffectsView } from './avatar-effects'; import { CameraWidgetView } from './camera/CameraWidgetView'; import { CampaignView } from './campaign/CampaignView'; import { CatalogView } from './catalog/CatalogView'; @@ -105,6 +106,7 @@ export const MainView: FC<{}> = props => + diff --git a/src/components/avatar-effects/AvatarEffectPreviewView.tsx b/src/components/avatar-effects/AvatarEffectPreviewView.tsx new file mode 100644 index 0000000..b68d7d0 --- /dev/null +++ b/src/components/avatar-effects/AvatarEffectPreviewView.tsx @@ -0,0 +1,76 @@ +import { GetRoomEngine, RoomPreviewer } from '@nitrots/nitro-renderer'; +import { CSSProperties, FC, useEffect, useState } from 'react'; +import { LayoutRoomPreviewerView } from '../../common'; + +interface AvatarEffectPreviewViewProps +{ + figure: string; + gender: string; + direction: number; + effect: number; + height?: number; + zoom?: number; +} + +export const AvatarEffectPreviewView: FC = props => +{ + const { figure = '', gender = 'M', direction = 4, effect = 0, height = 280, zoom = 1 } = props; + const [ roomPreviewer, setRoomPreviewer ] = useState(null); + + const renderHeight = Math.floor(height / zoom); + + useEffect(() => + { + const previewer = new RoomPreviewer(GetRoomEngine(), ++RoomPreviewer.PREVIEW_COUNTER); + setRoomPreviewer(previewer); + + return () => + { + previewer.dispose(); + setRoomPreviewer(null); + }; + }, []); + + useEffect(() => + { + if(!roomPreviewer || !figure) return; + + roomPreviewer.addAvatarIntoRoom(figure, effect); + roomPreviewer.updateObjectUserFigure(figure, gender); + }, [ roomPreviewer, figure, gender, effect ]); + + useEffect(() => + { + if(!roomPreviewer) return; + roomPreviewer.updateAvatarDirection(direction, direction); + }, [ roomPreviewer, direction ]); + + if(!roomPreviewer) return null; + + if(zoom === 1) + { + return ; + } + + const outerStyle: CSSProperties = { + position: 'absolute', + inset: 0, + overflow: 'hidden' + }; + + const innerStyle: CSSProperties = { + width: `${ 100 / zoom }%`, + height: `${ 100 / zoom }%`, + transform: `scale(${ zoom })`, + transformOrigin: 'top left', + imageRendering: 'pixelated' + }; + + return ( +
+
+ +
+
+ ); +}; diff --git a/src/components/avatar-effects/AvatarEffectsView.tsx b/src/components/avatar-effects/AvatarEffectsView.tsx new file mode 100644 index 0000000..9070495 --- /dev/null +++ b/src/components/avatar-effects/AvatarEffectsView.tsx @@ -0,0 +1,156 @@ +import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { FC, useCallback, useEffect, useState } from 'react'; +import { FaChevronLeft, FaChevronRight } from 'react-icons/fa'; +import { LocalizeText, SendMessageComposer } from '../../api'; +import { Button, Column, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common'; +import { AvatarEffectPreviewView } from './AvatarEffectPreviewView'; + +interface EffectMapEntry +{ + id: string; + lib: string; + type: string; + revision?: string | number; +} + +const DEFAULT_DIRECTION = 4; + +export const AvatarEffectsView: FC<{}> = () => +{ + const [ isVisible, setIsVisible ] = useState(false); + const [ effects, setEffects ] = useState([]); + const [ loadError, setLoadError ] = useState(null); + const [ selectedId, setSelectedId ] = useState(0); + const [ direction, setDirection ] = useState(DEFAULT_DIRECTION); + + 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: 'avatar-effects/' + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, []); + + useEffect(() => + { + if(!isVisible || effects.length || loadError) return; + + const url = GetConfiguration().getValue('avatar.effectmap.url'); + if(!url) + { + setLoadError('Effect map URL is not configured.'); + return; + } + + let cancelled = false; + (async () => + { + try + { + const response = await fetch(url); + if(!response.ok) throw new Error(`HTTP ${ response.status }`); + const json = await response.json(); + if(cancelled) return; + + const list: EffectMapEntry[] = Array.isArray(json?.effects) + ? json.effects.filter((e: EffectMapEntry) => e?.type === 'fx' && /^\d+$/.test(String(e.id))) + : []; + + list.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10)); + setEffects(list); + } + catch(error) + { + if(!cancelled) setLoadError(String((error as Error).message ?? error)); + } + })(); + + return () => { cancelled = true; }; + }, [ isVisible, effects.length, loadError ]); + + const session = GetSessionDataManager(); + const figure = session?.figure ?? ''; + const gender = session?.gender ?? 'M'; + + const rotateFigure = useCallback((delta: number) => + { + setDirection(prev => + { + let next = prev + delta; + if(next < AvatarDirectionAngle.MIN_DIRECTION) next = AvatarDirectionAngle.MAX_DIRECTION; + if(next > AvatarDirectionAngle.MAX_DIRECTION) next = AvatarDirectionAngle.MIN_DIRECTION; + return next; + }); + }, []); + + const applySelectedEffect = useCallback(() => + { + if(!selectedId) return; + SendMessageComposer(new AvatarEffectActivatedComposer(selectedId)); + setIsVisible(false); + }, [ selectedId ]); + + const onClose = useCallback(() => setIsVisible(false), []); + + if(!isVisible) return null; + + return ( + + + + +
+ +
+ + +
+
+ +
+ + { loadError &&
{ loadError }
} + { !loadError && !effects.length &&
{ LocalizeText('generic.loading') || 'Loading…' }
} + { !!effects.length && +
+ { effects.map(effect => + { + const id = parseInt(effect.id, 10); + const isSelected = (id === selectedId); + return ( + + ); + }) } +
+ } +
+
+
+ ); +}; diff --git a/src/components/avatar-effects/index.ts b/src/components/avatar-effects/index.ts new file mode 100644 index 0000000..17f1000 --- /dev/null +++ b/src/components/avatar-effects/index.ts @@ -0,0 +1,2 @@ +export * from './AvatarEffectPreviewView'; +export * from './AvatarEffectsView'; diff --git a/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetOwnAvatarView.tsx b/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetOwnAvatarView.tsx index 4eb0756..1ad76e2 100644 --- a/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetOwnAvatarView.tsx +++ b/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetOwnAvatarView.tsx @@ -55,6 +55,9 @@ export const AvatarInfoWidgetOwnAvatarView: FC processAction('change_looks') }> { LocalizeText('widget.memenu.myclothes') } + processAction('avatar_effect') }> + { LocalizeText('product.type.effect') } + { (HasHabboClub() && !isRidingHorse) && processAction('dance_menu') }>