From 10f08c670386f96df88a9445de6969a90a1efddb Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 26 Feb 2026 13:44:22 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=86=99=20Redone=20the=20avatar=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../images/avatareditor/avatar_shadow.png | Bin 0 -> 1805 bytes .../images/avatareditor/color_frame.png | Bin 0 -> 1661 bytes .../avatareditor/color_frame_active.png | Bin 0 -> 1651 bytes src/assets/images/avatareditor/hc_icon.png | Bin 0 -> 1708 bytes .../images/avatareditor/wardrobe_bg.png | Bin 0 -> 1822 bytes .../images/avatareditor/wardrobe_user_bg.png | Bin 0 -> 1700 bytes src/assets/images/wardrobe/hd.png | Bin 0 -> 225 bytes src/assets/images/wardrobe/head.png | Bin 0 -> 330 bytes src/assets/images/wardrobe/legs.png | Bin 0 -> 231 bytes src/assets/images/wardrobe/torso.png | Bin 0 -> 247 bytes src/assets/images/wardrobe/wardrobe.png | Bin 0 -> 256 bytes .../avatar-editor/AvatarEditorModelView.tsx | 73 ++++--- .../avatar-editor/AvatarEditorView.tsx | 42 ++-- .../AvatarEditorFigureSetItemView.tsx | 16 +- .../figure-set/AvatarEditorFigureSetView.tsx | 2 +- .../AvatarEditorAdvancedColorView.tsx | 82 ++++++++ .../AvatarEditorPaletteSetItemView.tsx | 5 +- .../AvatarEditorPaletteSetView.tsx | 2 +- .../avatar-editor/palette-set/index.ts | 1 + src/css/index.css | 192 ++++++++++++++++++ src/layout/InfiniteGrid.tsx | 9 +- tailwind.config.js | 25 ++- 22 files changed, 382 insertions(+), 67 deletions(-) create mode 100644 src/assets/images/avatareditor/avatar_shadow.png create mode 100644 src/assets/images/avatareditor/color_frame.png create mode 100644 src/assets/images/avatareditor/color_frame_active.png create mode 100644 src/assets/images/avatareditor/hc_icon.png create mode 100644 src/assets/images/avatareditor/wardrobe_bg.png create mode 100644 src/assets/images/avatareditor/wardrobe_user_bg.png create mode 100644 src/assets/images/wardrobe/hd.png create mode 100644 src/assets/images/wardrobe/head.png create mode 100644 src/assets/images/wardrobe/legs.png create mode 100644 src/assets/images/wardrobe/torso.png create mode 100644 src/assets/images/wardrobe/wardrobe.png create mode 100644 src/components/avatar-editor/palette-set/AvatarEditorAdvancedColorView.tsx diff --git a/src/assets/images/avatareditor/avatar_shadow.png b/src/assets/images/avatareditor/avatar_shadow.png new file mode 100644 index 0000000000000000000000000000000000000000..33397f11f98b677b53a5d726700cc6bffdc308a0 GIT binary patch literal 1805 zcmcIlTWB0r7@nqSi8U$cLo_~3CRJK=c5XX6J441AcXL_ntZ}=<7^&v$nKQdXcIJ#T zv)N4^QWIKHilU$o8mWYWXp4Q2VuA%PsEAKqAox-%q7_7>K@(pT>Y2S+qz!p-;LJH^ z<~!g2{g?UYx$*31&%V3%Q54ma8B6ELoF;?m+Dq;~e{g(@Ot*Mrj|CLfdv`Q;WpW2L zC~D7Fc78IPG>&KxJ27D50*cifkDw`P@Nmroa0Z2R0TpdmXRcoSkfCi;XC9Re-tbbW zWRK1J=;(Yl59eo~YBGoKrw3~qA#hL#=$cb@1FfbrbzY6EqsJUW*CFAI&J0Bc=}BXp zPGKL>N=#rO&-1jR#tOWZEFhJ?j~01R-~^EqWL6Y3QPu>RZe9$r=bM(6OOG_Ik)6(z z!qC$=u3D|es!|O5MNUvvmE%QD6j_2`gE=<@HP#IVS`29vK;QO48@qJG2nx6o>I~7^ zaKiE0v~JK06bTtu10E;D_{gai&@i@#I!+rMgu}DMN>lF*iG%!{hqxRHaK(pccowUz8P9r0&WqJ2Ffv!J6zOg8j0cl zo3r}6>zYyvPC8w*Y&Ti5X}4+Q5^A3`;qP;AoFEPS$4q)CUt0X_wTJe9LU*SfccQX> z>+14?+xxyfQ0drn`?+}S^5rh$^h^BLcb=KtSUXnko%-U3TJBV3=`&_v<(+3X6DzMw z1f{O=xQRzzKG>H^J$GUR*sC@0_No-Mcmy^e>B}-Hf(%Df?uB zN{#dkO#Hc+%}%{}C3U({|6cWm8rRb>A-p?`a7!>KO*()VuV^fwouFi-ZY^UKnw*ZQwb bcmCe__}``R_1c9(^ux*wXVd2&oL>3|SPe>u literal 0 HcmV?d00001 diff --git a/src/assets/images/avatareditor/color_frame.png b/src/assets/images/avatareditor/color_frame.png new file mode 100644 index 0000000000000000000000000000000000000000..ed546d712403eae8c4d59ceb08f669e3ffa1d004 GIT binary patch literal 1661 zcmcIlPl()97*7u_Yq19@7VIgcR$4U4%gbak360A-*>wh+b(tM-iy*#v?`0} zJF|<`QbaA(Lp|7wC-JI?x9ULzE#55k*n?;BpyELfLj5v-Vv%;4i-DKCm-l?qHAZi_CeCuGN zOAmIa!<3ch%AhDxSJy|Qkv1|l zk@a=MaU30C9b**`YQCQoWUMCnsX2qsbDBkI5s5@D8A(s<7M=oHr%uFaMVsWaKtag# zF-dhpL#0!5U=SP*jpGWP7i)W9Wv2Im#Cdl=<$8zbVmG6FZI36#skt#mtF!5DHk^3M zsLqEx23QWljLK9x5QWGG;vs%i+m$z|+srsAMAj8zI8W8)oJGc_hUGH>p;1!uo-H-a zj&Pq8+*2TLSVc9}KwZOdE##W`8H8PgrqBRtfsukN1{N#|_ZNW%f!j{V zS`;z2QRk>;fuMSg+G@?_Tm|b)bugut$?BBW>l0mY-WPPY3}AVFTDnY7;HYBc*6LQz zVa!$uHEfmfnyEH8Ye0P>(;~e(Vj-Thy_7}p^^oEHtfeXjjHZbQc8IMaE40)aW0u-$ zgj}^lT*DzIloMN#8FfRE#RMuPib@OfzT7>~-{j=q;vmN;?{GAU!1cQ&=v-Vni~&+J{IlP+t1O zPqR3UN)L(V)Xy!Y_fM>Q<}q})0NYz$-<&)(>wv{ z44UvC*MHxF2ENtySG#Af)4%WCTWPjmz4g|Tviid(PhPwB-OZoy7cX`^x^j> W-@Kjt^-=kQXs>PfpFRKDm45*IeGn7? literal 0 HcmV?d00001 diff --git a/src/assets/images/avatareditor/color_frame_active.png b/src/assets/images/avatareditor/color_frame_active.png new file mode 100644 index 0000000000000000000000000000000000000000..41391525237958d1bddfe0281aa63048a05b24cf GIT binary patch literal 1651 zcmcIlJ&)W(7NLqXT9VGKPBVnJX(@+v7^gnCd+SFa$HWKkGy)i8YjhNM_Hw$lx6#LY z+t?*w^}Kv;>T!aEmPno^h7ih* ztA;RYhSY_x$p$oZO*b^Xp%}VnG(5c_R~O*+auRvH_8XNozVgASEHh8jCX)^_v$RB0B`wY@8QQeKd7PCoOJ%``Lbg--fNNbikz_S(T2z7JA=9QP z({vRIrfA_mp6b zj%mVS1sA5*rm_($SutV<_)%}y-elfdPEpD7K4ar$sy3G_vSF%*d^SKhP6hAFLQ{2w zwoyra!1HD(&{A~R*DVh^uR_CvaDfYWArMreLm(N6quGDrg23yhMTt^OyKNtEEovMS zFT^3m(6AK>&4yyRAyN>rETw^M*RY#*gF5D15kKE%ct^xdoL^KdVVHB&q48YPbzq1i z#feN&sTNXP2*?C`5z7XBcgXWoeSE@h;q_{a=yS6EGj1`r>!9m^C3z~J4kzh zb|0_&aC!g#lvhE;Ba{v)@0c18zG$MeEK;I9oUQrZ16^nd=dDdl>#oWdbzi0_r_OoT zz3^@L7w_1EZoAcQeuV$NcW?ElKWBITF`qgCKD)8-be_Pr6;qG~`k3YTr`>l^xj$C~1 W^!rzzxYiQCh3@)B`v>)9d=QPtOrwL-BXg3szhTa+pksWba!k@naZ7qXd2e*fS9 z_y3rGUS6p_HoxnhU6LftSC-4G{4UnF&hF&@bMJq;M{MHdXHrSJ^L}y7R95f1C`mV; z4QgxIn)if*S(rnF`83xJV~&=j{Rg`-!s|4XecA{jSGn-XNktBbt2}Lb(2Gm787%h` zdZ=Hm;r=?diE{9Oyua&kf{b-J!H;C1*~JXRHX0Lj)}Wl=bk*SrdtEU(UCX4bCqV6#g3|W zI-Oj{$g!lMYPM~wP*-&wa0EzuQHHu8O81Q!$~47E5N834WWk7h*3MjoYaIp=#uM5o z9VLpVOzomr)pAe-H3oX#_Ruh#pwnz=lY1HIy)JQD>%~-Er73GCm@aM7DBCx7M)1To zZYQllqy($9MMDltxiD?wDsHljrA>B&I3}+r(Zo(Ep(tZXjj`6aR4Zc^SvPXJ{Gf+$ z5DDIggr<=REu)OO3a^_EU><0&rWuZDI)=UgbqB&B)Z@KCP==-hGZNH$(?C7XsYGdp zB1|h~SK*>^K|maf_{D-{BS3xK26>1H@R3b`NwI0!wx!j5bD)dcmlk@`J z$0SFNMJ;XyA+UXHfFjQOz}M?O;Ag-vts?aE#2B+ZnFRdwpw_jlLKWeRhDIU5umDKj zDu6seMSyIR0-NgCFzR`#BhEvxQ)fwtc&7v*YEU(f8jAd{ni6ZV#AAf#+z|cD*?APp zLCTBQ8=a6tbYrw?1@fTn9E3%;y9yR%rbHP%4z82E>2wZhJ59<-w<-P*ma=-*K?z-K z@Jvt9Uh0kHr)YC~@za(5e-%GUEpDQyLHPhx6%mdq2FVIoCcz4ZE8pHXd6U)hv z<(tWvrV*!3_z=G3=g+S40e-krUaZ~y){!^9oBtBtUV8Vv*U!3t?f&(S4?o|#K#x3r zdgapB_rlVdQ|42z-1Naq)w`~|cxkKf=IOmZd@pbPw)^el=QrNzEtKcZe0BU;{qo$= z*UsB7Y<)3P{X1J~pLBovxVH8C?6>RZe*AN%HyfY0`pvODmuKX{u?y0bbI)De@yiaU cf9keBP8@jkf&Ru(@ja<5Rm&ee^8Dd{0FzWI`v3p{ literal 0 HcmV?d00001 diff --git a/src/assets/images/avatareditor/wardrobe_bg.png b/src/assets/images/avatareditor/wardrobe_bg.png new file mode 100644 index 0000000000000000000000000000000000000000..83fb613280cbdaeea35f6a79a60b462c7bb4019f GIT binary patch literal 1822 zcmcIlZEVzJ9Pi8|fE$t-QI{BMH*|~i`gXm$HXO^mo8BQEb30`aKkRAy++DY}Pi?#1 zZ4m?^5{z#_Mf8I)nwc0Cl!Q-Md$_0jvBtH{YYBpAOsA5)_`W_kSFgg?jq^(Y-_|&(T^>Pf*c6;~>E6~q z2;%ndjZDAauWXk!WQBl^R2V8+4n`BimX4wWv?1t|D$E&nocev@3`H7xoZ2ZWtl}hK z-bjtPuy3q8qm2z|l1_DOCASo1OkhDDkVVV1J-HaCO1v^&2d^25EJ6ICIMo?AB>R;f zGJ#x3MnfE}u`EkQrI5;IV=9!`%_PtA9K-PpC(=A8^PMQ@eO2?0V7b+upg(e z)^ZS*Q=_%LN}_Pem?Cf(F2n{wRe_4KJk+vkXwUB&!CoqQZ%XWCMjgoXLJtkQ8tfW@ zw%<~9rfW4@XV^7Mk?I-)O=w}5hlO!9S56-J$jhT!#8KO>MdQc`7Xlx-8HCJgsd}m` zBrk+`vP}V+VF$eJfu@QHOadRqDO@+6X2UecWw?kOmSiFPFw4s<=F{(69U zBWs{a$BeP8Dn=rLPO|`TbXX8nS`|2n=Jbdb%Z7z$1OSTEH93o13*b&MERchYW9KOH zUo{D2B3D5=&bbiuGiJw8qzn%iZ?y7-^ucE;E7KrLZ6^aQ==M0J1!aaf`tobzCfQq= z&a!9R7#V^A=l=Tb1g`GK^=@qmWb1V5C7+GHY%1Zygim#+r%L6+H@c?D0ARHzb zH08oD|E*bR?}njNh2u#VENep+Kh(xFv@x}dhw%Jw=l9_O{%1PbnTfqT{`wEczakqF zuYPd7_tH|w(d&I*99q9^BE7IJ@qXsm&JTY%ISs04Hp;IH8~BfU%tEe zPU^}1t!Ku*zPIJzM}05A9po>K$OM|RJw_OlxC1~LBDn^%1}xar#Ev%l_5kDU8XY214M?6uiL zT-)NpXN z?ezG`x9jg}+qgKn_t|fEPt2Xj4xW#^^!J7BJLi{vK8M=xJMr|KU8oEGVCk;zR-oj;4T%e)wvG8VU7LPSW?m_aEk}@L#%1hpYJj+s$ zCyTmD5km5$Q>>$4styi%M#c#1nvONyQnBu0%hfG;a40-p>Id#(`T0RKepgbOd7ikM z)^4|pZKKH2hNe4?qamzeta5~!b>p0L)HpjhWGF*M(=f?H7R!Q>)Y)oYQn=QB5>Yau zjk7_ac*(R5Ni@BPL{dYb=WP#-q7gdF=hk?Xf!=!(XSHqu+9G6ZHKj1O2629H7|f?5 z-()py^)mIT1}%sirBQ$o|g1sY(Jc)qv`T9Z(2t1A19*(Pb4IX;ZvsIo z7?`Ri3LLeL3AezpCv6&-6nsVYsT;5~B79Q9h%`V;;)Wvst7ew9Sn4sK*W3{E%-MO| zN|^EHbq8O_33z2-wL-Z!b}pe}+Di%*Z5DC*kHdRZZ#HW)xp8RzBgI`Z>o%(&}-#h-iU7awm Sd?o&Ur0U#!`NGlDZ~Oy>`6CMe literal 0 HcmV?d00001 diff --git a/src/assets/images/wardrobe/hd.png b/src/assets/images/wardrobe/hd.png new file mode 100644 index 0000000000000000000000000000000000000000..307d6f799a28c8df46717d9f9c3f7e474c18f1f2 GIT binary patch literal 225 zcmeAS@N?(olHy`uVBq!ia0vp^qChOh!VDzuu06#K5(w}Kadj{70W!ZGEeDbhXL|m5 z6T3!9yyMjMqd-1WNswPKg9C$q&k7)~($mE;q+(9(xzoHY20X5T{+x?n9ys+z*wTyf zg9jsrxr4yp=?#a~a|EV|rx&Kq?0BKAH1FHbKWVb3pZXpDck#^GgzI}4o=)(H3b)Qa zzIfHwX1{x9{)DHNJ!6?6yJ>yxnsv5sLv`=HZMdDfQ#Q%i=}2=hpXTq+=^IURSQX_u UE|es%1v-Mk)78&qol`;+0Az|+#{d8T literal 0 HcmV?d00001 diff --git a/src/assets/images/wardrobe/head.png b/src/assets/images/wardrobe/head.png new file mode 100644 index 0000000000000000000000000000000000000000..977012d216225b6d6ff49e9841a7ff37cbcf94ad GIT binary patch literal 330 zcmV-Q0k!^#P)y$*sf5WsPKgeE=#e$E_1+)g}=GkPJcN-DDf*-Zy_b?|NNp{T^N&|m&d({r5H zQsi`2uTK?G2bTVJ^`Kv1)(`No6%{da%J<~Ckd&Ksq c*Z<7#2}h($07*qoM6N<$g0|p@-v9sr literal 0 HcmV?d00001 diff --git a/src/assets/images/wardrobe/legs.png b/src/assets/images/wardrobe/legs.png new file mode 100644 index 0000000000000000000000000000000000000000..5978a3af7891061e2070aae69b55a831b8ca0c15 GIT binary patch literal 231 zcmeAS@N?(olHy`uVBq!ia0vp^qChOh!VDzuu06#Kq!a^uLR^8gdx4K_*K#1kq;k4J ze$TA3sNGY`6@&7>9WBpvv)H$4haFIksU*lR*ny$#Rg*1{SM2HH7*a83?s;3T1_K_} z2TkH1cp94)2mE;{cW`G_Ta);P3mYveSo5=!wsyQoYWwi<#02%0UCTrkmHEA1TWEZ{ zdhN2y*Mj_J*X&;}U>%XMv3i2Q0uQr`xfVRy&l!YHemc6!OYYN|KgN#^C;xeSI7a#W bj?;W*;(AX{zGB!2w4TA!)z4*}Q$iB}^-)@= literal 0 HcmV?d00001 diff --git a/src/assets/images/wardrobe/torso.png b/src/assets/images/wardrobe/torso.png new file mode 100644 index 0000000000000000000000000000000000000000..a58918ba26e8d9d2ecfa5f5752dd3c34192584de GIT binary patch literal 247 zcmeAS@N?(olHy`uVBq!ia0vp^3P3Ez!VDyX%bUxAlxToYh^u>n50H60A^=Fft0}m6 z{^&weV;fz&O+Y?VNswPKg9C$q&k7)~-_yl0q+-s|3uk!`EAX%;gvTyeAuVD4+)PlC zVeh@b2G$e*tgrlWIm=cl#BOJMlS4^qv&=%V%|4Hc&pq;N13Kvs=B-YY-5_7^?}_~DWd^S8&n**xPGj(N^>bP0l+XkKJWyIg literal 0 HcmV?d00001 diff --git a/src/assets/images/wardrobe/wardrobe.png b/src/assets/images/wardrobe/wardrobe.png new file mode 100644 index 0000000000000000000000000000000000000000..6913da7491b587a4d035a758cc8aba1e4bcb233c GIT binary patch literal 256 zcmeAS@N?(olHy`uVBq!ia0vp^T0ktv!VDx=uuZ%Sq=W)|LR^8gdw~y-JkcC^ZFBz8 zD9@6!2jqc#rjj7PU}J`+A1gS3yxE>Ejv*CuaxdNGZB^iDdwAVXL0aI8jzXS-@Q(im z?1C%))Pz6r)XZf{6MOa0bG`OC%V~ZS1)7##zx(^M#Mgq~J~LmupSLo?^1Wl??qiY@ zESL_933*%h?)Z6l+S3&hH7j}+1-l8RiUdAuJ~e}1%xsN_No!=p(=!=H@!u|1?KL@h zOD#KN@|3gNJ=ZJ7vd=C#^U3d~YOc+cSN7ST*thi<#iSkQcms4MgQu&X%Q~loCIEW) BW7+@! literal 0 HcmV?d00001 diff --git a/src/components/avatar-editor/AvatarEditorModelView.tsx b/src/components/avatar-editor/AvatarEditorModelView.tsx index 53a49ab..b9ee095 100644 --- a/src/components/avatar-editor/AvatarEditorModelView.tsx +++ b/src/components/avatar-editor/AvatarEditorModelView.tsx @@ -1,10 +1,11 @@ import { AvatarEditorFigureCategory, AvatarFigurePartType, FigureDataContainer } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useMemo, useState } from 'react'; -import { IAvatarEditorCategory } from '../../api'; +import { CreateLinkEvent, GetClubMemberLevel, GetConfigurationValue, IAvatarEditorCategory } from '../../api'; +import { LayoutCurrencyIcon } from '../../common'; import { useAvatarEditor } from '../../hooks'; import { AvatarEditorIcon } from './AvatarEditorIcon'; import { AvatarEditorFigureSetView } from './figure-set'; -import { AvatarEditorPaletteSetView } from './palette-set'; +import { AvatarEditorAdvancedColorView, AvatarEditorPaletteSetView } from './palette-set'; export const AvatarEditorModelView: FC<{ name: string, @@ -14,6 +15,8 @@ export const AvatarEditorModelView: FC<{ const { name = '', categories = [] } = props; const [ didChange, setDidChange ] = useState(false); const [ activeSetType, setActiveSetType ] = useState(''); + const [ advancedColorMode, setAdvancedColorMode ] = useState(false); + const hasHC = GetClubMemberLevel() > 0; const { maxPaletteCount = 1, gender = null, setGender = null, selectedColorParts = null, getFirstSelectableColor = null, selectEditorColor = null } = useAvatarEditor(); const activeCategory = useMemo(() => @@ -46,34 +49,56 @@ export const AvatarEditorModelView: FC<{ if(!activeCategory) return null; return ( -
-
+
+ { /* ── Category / gender selector row ── */ } +
{ (name === AvatarEditorFigureCategory.GENERIC) && - <> -
setGender(AvatarFigurePartType.MALE) }> - -
-
setGender(AvatarFigurePartType.FEMALE) }> - -
- } - { (name !== AvatarEditorFigureCategory.GENERIC) && (categories.length > 0) && categories.map(category => - { - return ( -
selectSet(category.setType) }> - + <> +
setGender(AvatarFigurePartType.MALE) }> +
- ); - }) } +
setGender(AvatarFigurePartType.FEMALE) }> + +
+ } + { (name !== AvatarEditorFigureCategory.GENERIC) && categories.map(category => +
selectSet(category.setType) }> + +
+ ) }
-
- + +
+
-
+ +
+ +
+ + { /* ── Colour palette row at bottom ── */ } +
{ (maxPaletteCount >= 1) && - } +
+ { advancedColorMode + ? + : } +
} { (maxPaletteCount === 2) && - } +
+ { advancedColorMode + ? + : } +
}
); diff --git a/src/components/avatar-editor/AvatarEditorView.tsx b/src/components/avatar-editor/AvatarEditorView.tsx index 0e97dbe..f5c7e63 100644 --- a/src/components/avatar-editor/AvatarEditorView.tsx +++ b/src/components/avatar-editor/AvatarEditorView.tsx @@ -2,19 +2,18 @@ import { AddLinkEventTracker, AvatarEditorFigureCategory, GetSessionDataManager, import { FC, useEffect, useState } from 'react'; import { FaDice, FaRedo, FaTrash } from 'react-icons/fa'; import { AvatarEditorAction, LocalizeText, SendMessageComposer } from '../../api'; -import { Button, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; +import { Button, ButtonGroup, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; import { useAvatarEditor } from '../../hooks'; import { AvatarEditorFigurePreviewView } from './AvatarEditorFigurePreviewView'; import { AvatarEditorModelView } from './AvatarEditorModelView'; import { AvatarEditorWardrobeView } from './AvatarEditorWardrobeView'; -const DEFAULT_MALE_FIGURE: string = 'hr-100.hd-180-7.ch-215-66.lg-270-79.sh-305-62.ha-1002-70.wa-2007'; -const DEFAULT_FEMALE_FIGURE: string = 'hr-515-33.hd-600-1.ch-635-70.lg-716-66-62.sh-735-68'; - export const AvatarEditorView: FC<{}> = props => { const [ isVisible, setIsVisible ] = useState(false); - const { setIsVisible: setEditorVisibility, avatarModels, activeModelKey, setActiveModelKey, loadAvatarData, getFigureStringWithFace, gender, figureSetIds = [], randomizeCurrentFigure = null, getFigureString = null } = useAvatarEditor(); + const { setIsVisible: setEditorVisibility, avatarModels, activeModelKey, setActiveModelKey, loadAvatarData, getFigureStringWithFace, gender, randomizeCurrentFigure = null, getFigureString = null } = useAvatarEditor(); + + const isWardrobeOpen = (activeModelKey === AvatarEditorFigureCategory.WARDROBE); const processAction = (action: string) => { @@ -74,48 +73,53 @@ export const AvatarEditorView: FC<{}> = props => if(!isVisible) return null; return ( - + setIsVisible(false) } /> - + { Object.keys(avatarModels).map(modelKey => { const isActive = (activeModelKey === modelKey); + const isWardrobe = (modelKey === AvatarEditorFigureCategory.WARDROBE); return ( setActiveModelKey(modelKey) }> - { LocalizeText(`avatareditor.category.${ modelKey }`) } +
); }) } - -
- { ((activeModelKey.length > 0) && (activeModelKey !== AvatarEditorFigureCategory.WARDROBE)) && +
+ { /* left: model view or wardrobe */ } +
+ { (activeModelKey.length > 0 && !isWardrobeOpen) && } - { (activeModelKey === AvatarEditorFigureCategory.WARDROBE) && + { isWardrobeOpen && }
-
+ { /* right: preview + actions */ } +
-
- - - -
+
- +
); diff --git a/src/components/avatar-editor/figure-set/AvatarEditorFigureSetItemView.tsx b/src/components/avatar-editor/figure-set/AvatarEditorFigureSetItemView.tsx index f2d5a74..fb33358 100644 --- a/src/components/avatar-editor/figure-set/AvatarEditorFigureSetItemView.tsx +++ b/src/components/avatar-editor/figure-set/AvatarEditorFigureSetItemView.tsx @@ -1,6 +1,6 @@ import { AvatarFigurePartType } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; -import { AvatarEditorThumbnailsHelper, GetConfigurationValue, IAvatarEditorCategoryPartItem } from '../../../api'; +import { AvatarEditorThumbnailsHelper, GetClubMemberLevel, GetConfigurationValue, IAvatarEditorCategoryPartItem } from '../../../api'; import { LayoutCurrencyIcon, LayoutGridItemProps } from '../../../common'; import { useAvatarEditor } from '../../../hooks'; import { InfiniteGrid } from '../../../layout'; @@ -17,7 +17,9 @@ export const AvatarEditorFigureSetItemView: FC<{ const [ assetUrl, setAssetUrl ] = useState(''); const { selectedColorParts = null, getFigureStringWithFace = null } = useAvatarEditor(); - const isHC = !GetConfigurationValue('hc.disabled', false) && ((partItem.partSet?.clubLevel ?? 0) > 0); + const clubLevel = partItem.partSet?.clubLevel ?? 0; + const isHC = !GetConfigurationValue('hc.disabled', false) && (clubLevel > 0); + const isLocked = isHC && (GetClubMemberLevel() < clubLevel); useEffect(() => { @@ -25,17 +27,19 @@ export const AvatarEditorFigureSetItemView: FC<{ const loadImage = async () => { - const isHC = !GetConfigurationValue('hc.disabled', false) && ((partItem.partSet?.clubLevel ?? 0) > 0); + const partClubLevel = partItem.partSet?.clubLevel ?? 0; + const partIsHC = !GetConfigurationValue('hc.disabled', false) && (partClubLevel > 0); + const partIsLocked = partIsHC && (GetClubMemberLevel() < partClubLevel); let url: string = null; if(setType === AvatarFigurePartType.HEAD) { - url = await AvatarEditorThumbnailsHelper.buildForFace(getFigureStringWithFace(partItem.id), isHC); + url = await AvatarEditorThumbnailsHelper.buildForFace(getFigureStringWithFace(partItem.id), partIsLocked); } else { - url = await AvatarEditorThumbnailsHelper.build(setType, partItem, partItem.usesColor, selectedColorParts[setType] ?? null, isHC); + url = await AvatarEditorThumbnailsHelper.build(setType, partItem, partItem.usesColor, selectedColorParts[setType] ?? null, partIsLocked); } if(url && url.length) setAssetUrl(url); @@ -47,7 +51,7 @@ export const AvatarEditorFigureSetItemView: FC<{ if(!partItem) return null; return ( - + { !partItem.isClear && isHC && } { partItem.isClear && } { !partItem.isClear && partItem.partSet.isSellable && } diff --git a/src/components/avatar-editor/figure-set/AvatarEditorFigureSetView.tsx b/src/components/avatar-editor/figure-set/AvatarEditorFigureSetView.tsx index 9a5b543..87da8c7 100644 --- a/src/components/avatar-editor/figure-set/AvatarEditorFigureSetView.tsx +++ b/src/components/avatar-editor/figure-set/AvatarEditorFigureSetView.tsx @@ -29,7 +29,7 @@ export const AvatarEditorFigureSetView: FC<{ }; return ( - columnCount={ columnCount } itemRender={ (item: IAvatarEditorCategoryPartItem) => + columnCount={ columnCount } estimateSize={ 50 } itemRender={ (item: IAvatarEditorCategoryPartItem) => { if(!item) return null; diff --git a/src/components/avatar-editor/palette-set/AvatarEditorAdvancedColorView.tsx b/src/components/avatar-editor/palette-set/AvatarEditorAdvancedColorView.tsx new file mode 100644 index 0000000..da4c12f --- /dev/null +++ b/src/components/avatar-editor/palette-set/AvatarEditorAdvancedColorView.tsx @@ -0,0 +1,82 @@ +import { IPartColor } from '@nitrots/nitro-renderer'; +import { FC, useCallback, useMemo, useRef } from 'react'; +import { ColorUtils, GetClubMemberLevel, IAvatarEditorCategory } from '../../../api'; +import { useAvatarEditor } from '../../../hooks'; + +const findNearestColor = (hex: string, colors: IPartColor[]): IPartColor | null => +{ + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + const maxLevel = GetClubMemberLevel(); + let nearest: IPartColor | null = null; + let minDist = Infinity; + + for(const color of colors) + { + if(color.clubLevel > maxLevel) continue; + + const cr = (color.rgb >> 16) & 0xFF; + const cg = (color.rgb >> 8) & 0xFF; + const cb = color.rgb & 0xFF; + const dist = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2; + + if(dist < minDist) { minDist = dist; nearest = color; } + } + + return nearest; +}; + +export const AvatarEditorAdvancedColorView: FC<{ + category: IAvatarEditorCategory; + paletteIndex: number; +}> = ({ category, paletteIndex }) => +{ + const { selectedColorParts = null, selectEditorColor = null } = useAvatarEditor(); + const inputRef = useRef(null); + + const selectedColor = useMemo(() => + { + if(!selectedColorParts?.[category?.setType]?.[paletteIndex]) return null; + + return selectedColorParts[category.setType][paletteIndex]; + }, [ category, selectedColorParts, paletteIndex ]); + + const hexColor = useMemo(() => + ColorUtils.makeColorNumberHex((selectedColor?.rgb ?? 0) & 0xFFFFFF), + [ selectedColor ]); + + const handleChange = useCallback((e: React.ChangeEvent) => + { + const colors = category?.colorItems?.[paletteIndex]; + + if(!colors) return; + + const nearest = findNearestColor(e.target.value, colors); + + if(nearest) selectEditorColor(category.setType, paletteIndex, nearest.id); + }, [ category, paletteIndex, selectEditorColor ]); + + return ( +
+
inputRef.current?.click() } + > + +
+ + { hexColor.toUpperCase() } + +
+
+
+ ); +}; diff --git a/src/components/avatar-editor/palette-set/AvatarEditorPaletteSetItemView.tsx b/src/components/avatar-editor/palette-set/AvatarEditorPaletteSetItemView.tsx index 87dd5f5..8757c6a 100644 --- a/src/components/avatar-editor/palette-set/AvatarEditorPaletteSetItemView.tsx +++ b/src/components/avatar-editor/palette-set/AvatarEditorPaletteSetItemView.tsx @@ -1,6 +1,6 @@ import { ColorConverter, IPartColor } from '@nitrots/nitro-renderer'; import { FC } from 'react'; -import { GetConfigurationValue } from '../../../api'; +import { GetClubMemberLevel, GetConfigurationValue } from '../../../api'; import { LayoutCurrencyIcon, LayoutGridItemProps } from '../../../common'; import { InfiniteGrid } from '../../../layout'; @@ -16,9 +16,10 @@ export const AvatarEditorPaletteSetItem: FC<{ if(!partColor) return null; const isHC = !GetConfigurationValue('hc.disabled', false) && (partColor.clubLevel > 0); + const isLocked = isHC && (GetClubMemberLevel() < partColor.clubLevel); return ( - + { isHC && } ); diff --git a/src/components/avatar-editor/palette-set/AvatarEditorPaletteSetView.tsx b/src/components/avatar-editor/palette-set/AvatarEditorPaletteSetView.tsx index d40c8d1..d19c6d4 100644 --- a/src/components/avatar-editor/palette-set/AvatarEditorPaletteSetView.tsx +++ b/src/components/avatar-editor/palette-set/AvatarEditorPaletteSetView.tsx @@ -24,7 +24,7 @@ export const AvatarEditorPaletteSetView: FC<{ }; return ( - columnCount={ columnCount } itemRender={ (item: IPartColor) => + columnCount={ columnCount } estimateSize={ 18 } squareItems itemRender={ (item: IPartColor) => { if(!item) return null; diff --git a/src/components/avatar-editor/palette-set/index.ts b/src/components/avatar-editor/palette-set/index.ts index 977e5b9..cc8fd78 100644 --- a/src/components/avatar-editor/palette-set/index.ts +++ b/src/components/avatar-editor/palette-set/index.ts @@ -1,2 +1,3 @@ +export * from './AvatarEditorAdvancedColorView'; export * from './AvatarEditorPaletteSetItemView'; export * from './AvatarEditorPaletteSetView'; diff --git a/src/css/index.css b/src/css/index.css index fac5340..be65781 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -7,6 +7,41 @@ src: url("@/assets/webfonts/Ubuntu-C.ttf"); } +@font-face { + font-family: Habbo; + src: url("@/assets/webfonts/Habbo.ttf"); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: Habbo; + src: url("@/assets/webfonts/Habbo-b.ttf"); + font-weight: bold; + font-style: normal; +} + +@font-face { + font-family: Habbo; + src: url("@/assets/webfonts/Habbo-i.ttf"); + font-weight: normal; + font-style: italic; +} + +@font-face { + font-family: Habbo; + src: url("@/assets/webfonts/Habbo-m.ttf"); + font-weight: 500; + font-style: normal; +} + +@font-face { + font-family: Habbo; + src: url("@/assets/webfonts/Habbo-t.ttf"); + font-weight: 100; + font-style: normal; +} + html, body { padding: 0; @@ -792,4 +827,161 @@ body { .bg-card-tabs { background-color: #185d79; /* Match bg-card-header */ +} + +/* ── Avatar Editor ─────────────────────────────────────────────────────── */ + +.color-picker-frame { + border-image-source: url('@/assets/images/avatareditor/color_frame.png'); + border-image-slice: 6 6 6 6 fill; + border-image-width: 6px 6px 6px 6px; + width: 14px; + height: 21px; + border-radius: 4px; + + &.active, + &:hover { + border-image-source: url('@/assets/images/avatareditor/color_frame_active.png'); + height: 21px; + margin-top: -2px; + } +} + +.hc-icon { + background-image: url('@/assets/images/avatareditor/hc_icon.png'); + height: 9px; + width: 10px; + bottom: 2px; + left: 2px; +} + +.avatar-parts { + border: none !important; + height: 42px; + width: 42px; + background-position: center; + background-repeat: no-repeat; + border-radius: 2rem !important; + overflow: visible !important; + background-color: transparent; + + &:hover { + box-shadow: 0 0 0 3px #dbdad5 !important; + background-color: #cecdc8 !important; + } + + &:active, + &.part-selected { + box-shadow: 0 0 0 3px #c5c3c0 !important; + background-color: #b1b1b1 !important; + } +} + +.avatar-container { + padding: 3px; + height: 50px; + border-radius: 0.3rem; + background-color: #a7a6a2; + width: 48px; +} + +.avatar-parts-container { + height: 70%; +} + +.avatar-color-palette-container { + height: 30%; + width: 100% !important; +} + +.dual-palette { + display: flex !important; + flex-direction: row !important; +} + +.avatar-editor-palette-set-view { + padding-right: 15px !important; + flex-grow: 1; +} + +.action-buttons { + gap: 5px; +} + +/* ── Avatar Editor tab icons ───────────────────────────────────────────── */ + +.avatar-editor-tabs { + position: relative; + + .tab { + background-position-x: center; + background-position-y: center; + background-repeat: no-repeat; + width: 34px; + height: 22px; + } + + .hd { background-image: url('@/assets/images/wardrobe/hd.png'); } + .head { background-image: url('@/assets/images/wardrobe/head.png'); } + .torso { background-image: url('@/assets/images/wardrobe/torso.png'); } + .legs { background-image: url('@/assets/images/wardrobe/legs.png'); } + + .tab-wardrobe { + width: 40px; + height: 28px; + background-size: 38px 28px; + background-image: url('@/assets/images/wardrobe/wardrobe.png'); + background-repeat: no-repeat; + background-position: center; + filter: contrast(1.2) brightness(1.05); + } +} + +/* ── Avatar Editor misc ─────────────────────────────────────────────────── */ + + +.saved-outfits-title { + color: #a7a6a2; + font-weight: bold; +} + +.nitro-avatar-editor-wardrobe-container { + background-color: #cacaca; + border-radius: 0.3rem; + border: solid 1px #000; + width: 100%; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + padding: 10px 12px 10px 0; + overflow-y: auto; +} + +.nitro-avatar-editor-wardrobe-figure-preview { + border-image-source: url('@/assets/images/avatareditor/wardrobe_user_bg.png'); + border-image-slice: 4 4 4 4 fill; + border-image-width: 4px 4px 4px 4px; + background-color: transparent; + overflow: hidden; + position: relative; + + .avatar-shadow { + position: absolute; + left: 0; + right: 0; + bottom: 25px; + width: 40px; + height: 20px; + margin: 0 auto; + border-radius: 100%; + background-color: rgba(0, 0, 0, 0.20); + z-index: 2; + } + + .button-container { + position: absolute; + bottom: 0; + z-index: 5; + width: 100%; + } } \ No newline at end of file diff --git a/src/layout/InfiniteGrid.tsx b/src/layout/InfiniteGrid.tsx index 534baef..7e8111f 100644 --- a/src/layout/InfiniteGrid.tsx +++ b/src/layout/InfiniteGrid.tsx @@ -9,12 +9,13 @@ type Props = { columnCount: number; overscan?: number; estimateSize?: number; + squareItems?: boolean; itemRender?: (item: T, index?: number) => ReactElement; } const InfiniteGridRoot = (props: Props) => { - const { items = [], columnCount = 4, overscan = 5, estimateSize = 45, itemRender = null } = props; + const { items = [], columnCount = 4, overscan = 5, estimateSize = 45, squareItems = false, itemRender = null } = props; const parentRef = useRef(null); const virtualizer = useVirtualizer({ @@ -72,7 +73,7 @@ const InfiniteGridRoot = (props: Props) => className={ `grid grid-cols-${ columnCount } gap-1 absolute top-0 left-0 last:pb-0 w-full` } data-index={ virtualRow.index } style={ { - height: virtualRow.size, + ...(!squareItems && { height: virtualRow.size }), transform: `translateY(${ virtualRow.start }px)` } }> { Array.from(Array(columnCount)).map((e, i) => @@ -144,7 +145,9 @@ const InfiniteGridItem = forwardRef 0)) && 'unique-item', itemUniqueSoldout && 'sold-out', itemUnseen && ' bg-green-500 bg-opacity-40', diff --git a/tailwind.config.js b/tailwind.config.js index 9ac9330..1932f7b 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,6 +1,8 @@ /** @type {import('tailwindcss').Config} */ -const { generateShades } = require('./css-utils/CSSColorUtils'); +const { + generateShades +} = require('./css-utils/CSSColorUtils'); const colors = { 'toolbar': '#555555', @@ -58,7 +60,6 @@ const boxShadow = { 'room-previewer': '-2px -2px rgba(0, 0, 0, 0.4), inset 3px 3px rgba(0, 0, 0, 0.2);' }; - module.exports = { theme: { extend: { @@ -71,9 +72,9 @@ module.exports = { '4xl': '2.441rem', '5xl': '3.052rem', }, - + fontFamily: { - sans: [ 'Ubuntu' ], + sans: ['Ubuntu'], }, colors: generateShades(colors), boxShadow, @@ -83,15 +84,15 @@ module.exports = { spacing: { 'card-header': '33px', 'card-tabs': '33px', - 'navigator-w': '420px', - 'navigator-h': '440px', + 'navigator-w': '480px', + 'navigator-h': '520px', 'inventory-w': '528px', 'inventory-h': '320px' }, borderRadius: { - + '3': '0.3rem', - + }, zIndex: { 'toolbar': '', @@ -127,7 +128,9 @@ module.exports = { 'col-span-9', 'col-span-10', 'col-span-11', - 'col-span-12', + 'col-span-12', + 'grid-cols-13', + 'grid-cols-14', 'grid-rows-1', 'grid-rows-2', 'grid-rows-3', @@ -146,8 +149,8 @@ module.exports = { darkMode: 'class', variants: { extend: { - divideColor: [ 'group-hover' ], - backgroundColor: [ 'group-focus' ], + divideColor: ['group-hover'], + backgroundColor: ['group-focus'], } }, plugins: [