Compare commits

...

4 Commits

Author SHA1 Message Date
billsonnn 2c77d752f7 Changes 2024-04-01 23:20:12 -04:00
billsonnn 1664baef92 Continue avatar editor changes 2024-04-01 23:19:57 -04:00
billsonnn c659074eb3 Update InfiniteScroll 2024-04-01 23:19:38 -04:00
billsonnn d675258adb Begin new avatar editor 2024-04-01 19:26:36 -04:00
25 changed files with 1775 additions and 444 deletions

View File

@ -10,7 +10,7 @@
"eslint": "eslint src --ext .ts,.tsx"
},
"dependencies": {
"@tanstack/react-virtual": "3.0.0-alpha.0",
"@tanstack/react-virtual": "3.2.0",
"react": "^18.2.0",
"react-bootstrap": "^2.2.2",
"react-dom": "^18.2.0",

View File

@ -0,0 +1,189 @@
import { AvatarFigurePartType, AvatarScaleType, AvatarSetType, GetAssetManager, GetAvatarRenderManager, IFigurePart, IGraphicAsset, IPartColor, NitroAlphaFilter, NitroContainer, NitroSprite, TextureUtils } from '@nitrots/nitro-renderer';
import { FigureData } from './FigureData';
import { IAvatarEditorCategoryPartItem } from './IAvatarEditorCategoryPartItem';
export class AvatarEditorThumbnailsHelper
{
private static THUMBNAIL_CACHE: Map<string, string> = new Map();
private static THUMB_DIRECTIONS: number[] = [ 2, 6, 0, 4, 3, 1 ];
private static ALPHA_FILTER: NitroAlphaFilter = new NitroAlphaFilter({ alpha: 0.2 });
private static DRAW_ORDER: string[] = [
AvatarFigurePartType.LEFT_HAND_ITEM,
AvatarFigurePartType.LEFT_HAND,
AvatarFigurePartType.LEFT_SLEEVE,
AvatarFigurePartType.LEFT_COAT_SLEEVE,
AvatarFigurePartType.BODY,
AvatarFigurePartType.SHOES,
AvatarFigurePartType.LEGS,
AvatarFigurePartType.CHEST,
AvatarFigurePartType.CHEST_ACCESSORY,
AvatarFigurePartType.COAT_CHEST,
AvatarFigurePartType.CHEST_PRINT,
AvatarFigurePartType.WAIST_ACCESSORY,
AvatarFigurePartType.RIGHT_HAND,
AvatarFigurePartType.RIGHT_SLEEVE,
AvatarFigurePartType.RIGHT_COAT_SLEEVE,
AvatarFigurePartType.HEAD,
AvatarFigurePartType.FACE,
AvatarFigurePartType.EYES,
AvatarFigurePartType.HAIR,
AvatarFigurePartType.HAIR_BIG,
AvatarFigurePartType.FACE_ACCESSORY,
AvatarFigurePartType.EYE_ACCESSORY,
AvatarFigurePartType.HEAD_ACCESSORY,
AvatarFigurePartType.HEAD_ACCESSORY_EXTRA,
AvatarFigurePartType.RIGHT_HAND_ITEM,
];
private static getThumbnailKey(setType: string, part: IAvatarEditorCategoryPartItem): string
{
return `${ setType }-${ part.partSet.id }`;
}
public static clearCache(): void
{
this.THUMBNAIL_CACHE.clear();
}
public static async build(setType: string, part: IAvatarEditorCategoryPartItem, useColors: boolean, partColors: IPartColor[], isDisabled: boolean = false): Promise<string>
{
if(!setType || !setType.length || !part || !part.partSet || !part.partSet.parts || !part.partSet.parts.length) return null;
const thumbnailKey = this.getThumbnailKey(setType, part);
const cached = this.THUMBNAIL_CACHE.get(thumbnailKey);
if(cached) return cached;
const buildContainer = (part: IAvatarEditorCategoryPartItem, useColors: boolean, partColors: IPartColor[], isDisabled: boolean = false) =>
{
const container = new NitroContainer();
const parts = part.partSet.parts.concat().sort(this.sortByDrawOrder);
for(const part of parts)
{
if(!part) continue;
let asset: IGraphicAsset = null;
let direction = 0;
let hasAsset = false;
while(!hasAsset && (direction < AvatarEditorThumbnailsHelper.THUMB_DIRECTIONS.length))
{
const assetName = `${ FigureData.SCALE }_${ FigureData.STD }_${ part.type }_${ part.id }_${ AvatarEditorThumbnailsHelper.THUMB_DIRECTIONS[direction] }_${ FigureData.DEFAULT_FRAME }`;
asset = GetAssetManager().getAsset(assetName);
if(asset && asset.texture)
{
hasAsset = true;
}
else
{
direction++;
}
}
if(!hasAsset) continue;
const x = asset.offsetX;
const y = asset.offsetY;
const sprite = new NitroSprite(asset.texture);
sprite.position.set(x, y);
if(useColors && (part.colorLayerIndex > 0) && partColors && partColors.length)
{
const color = partColors[(part.colorLayerIndex - 1)];
if(color) sprite.tint = color.rgb;
}
if(isDisabled) container.filters = [ AvatarEditorThumbnailsHelper.ALPHA_FILTER ];
container.addChild(sprite);
}
return container;
}
return new Promise(async (resolve, reject) =>
{
const resetFigure = async (figure: string) =>
{
const container = buildContainer(part, useColors, partColors, isDisabled);
const imageUrl = await TextureUtils.generateImageUrl(container);
AvatarEditorThumbnailsHelper.THUMBNAIL_CACHE.set(thumbnailKey, imageUrl);
resolve(imageUrl);
}
const figureContainer = GetAvatarRenderManager().createFigureContainer(`${ setType }-${ part.partSet.id }`);
if(!GetAvatarRenderManager().isFigureContainerReady(figureContainer))
{
GetAvatarRenderManager().downloadAvatarFigure(figureContainer, {
resetFigure,
dispose: null,
disposed: false
});
}
else
{
resetFigure(null);
}
});
}
public static async buildForFace(figureString: string, isDisabled: boolean = false): Promise<string>
{
if(!figureString || !figureString.length) return null;
const thumbnailKey = figureString;
const cached = this.THUMBNAIL_CACHE.get(thumbnailKey);
if(cached) return cached;
return new Promise(async (resolve, reject) =>
{
const resetFigure = async (figure: string) =>
{
const avatarImage = GetAvatarRenderManager().createAvatarImage(figure, AvatarScaleType.LARGE, null, { resetFigure, dispose: null, disposed: false });
const texture = avatarImage.processAsTexture(AvatarSetType.HEAD, false);
const sprite = new NitroSprite(texture);
if(isDisabled) sprite.filters = [ AvatarEditorThumbnailsHelper.ALPHA_FILTER ];
const imageUrl = await TextureUtils.generateImageUrl({
target: sprite
});
sprite.destroy();
avatarImage.dispose();
if(!avatarImage.isPlaceholder()) AvatarEditorThumbnailsHelper.THUMBNAIL_CACHE.set(thumbnailKey, imageUrl);
resolve(imageUrl);
}
resetFigure(figureString);
});
}
private static sortByDrawOrder(a: IFigurePart, b: IFigurePart): number
{
const indexA = AvatarEditorThumbnailsHelper.DRAW_ORDER.indexOf(a.type);
const indexB = AvatarEditorThumbnailsHelper.DRAW_ORDER.indexOf(b.type);
if(indexA < indexB) return -1;
if(indexA > indexB) return 1;
if(a.index < b.index) return -1;
if(a.index > b.index) return 1;
return 0;
}
}

View File

@ -0,0 +1,9 @@
import { IPartColor } from '@nitrots/nitro-renderer';
import { IAvatarEditorCategoryPartItem } from './IAvatarEditorCategoryPartItem';
export interface IAvatarEditorCategory
{
setType: string;
partItems: IAvatarEditorCategoryPartItem[];
colorItems: IPartColor[][];
}

View File

@ -0,0 +1,10 @@
import { IFigurePartSet } from '@nitrots/nitro-renderer';
export interface IAvatarEditorCategoryPartItem
{
id?: number;
partSet?: IFigurePartSet;
usesColor?: boolean;
maxPaletteCount?: number;
isClear?: boolean;
}

View File

@ -1,6 +1,7 @@
export * from './AvatarEditorAction';
export * from './AvatarEditorGridColorItem';
export * from './AvatarEditorGridPartItem';
export * from './AvatarEditorThumbnailsHelper';
export * from './AvatarEditorUtilities';
export * from './BodyModel';
export * from './CategoryBaseModel';
@ -8,6 +9,8 @@ export * from './CategoryData';
export * from './FigureData';
export * from './FigureGenerator';
export * from './HeadModel';
export * from './IAvatarEditorCategory';
export * from './IAvatarEditorCategoryModel';
export * from './IAvatarEditorCategoryPartItem';
export * from './LegModel';
export * from './TorsoModel';

View File

@ -0,0 +1,79 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import { FC, Fragment, ReactElement, useEffect, useRef } from 'react';
import { Base } from './Base';
import { Flex } from './Flex';
interface InfiniteGridProps<T = any>
{
rows: T[];
columnCount: number;
overscan?: number;
itemRender?: (item: T) => ReactElement;
}
export const InfiniteGrid: FC<InfiniteGridProps> = props =>
{
const { rows = [], columnCount = 4, overscan = 5, itemRender = null } = props;
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: Math.ceil(rows.length / columnCount),
overscan,
getScrollElement: () => parentRef.current,
estimateSize: () => 45,
});
useEffect(() =>
{
if(!rows || !rows.length) return;
virtualizer.scrollToIndex(0);
}, [ rows, virtualizer ]);
const items = virtualizer.getVirtualItems();
return (
<Base innerRef={ parentRef } fit position="relative" style={ { overflowY: 'auto' } }>
<div
style={ {
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative'
} }>
<Flex
column
gap={ 1 }
style={ {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${ items[0]?.start ?? 0 }px)`
} }>
{ items.map(virtualRow => (
<div
key={ virtualRow.key + 'a' }
data-index={ virtualRow.index }
ref={ virtualizer.measureElement }
style={ {
display: 'grid',
gap: '0.25rem',
minHeight: virtualRow.index === 0 ? 45 : virtualRow.size,
gridTemplateColumns: `repeat(${ columnCount }, 1fr)`
} }>
{ Array.from(Array(columnCount)).map((e,i) =>
{
const item = rows[i + (virtualRow.index * columnCount)];
if(!item) return <Fragment
key={ virtualRow.index + i + 'b' } />;
return itemRender(item);
}) }
</div>
)) }
</Flex>
</div>
</Base>
);
}

View File

@ -1,5 +1,5 @@
import { useVirtual } from '@tanstack/react-virtual';
import { FC, Fragment, ReactElement, useEffect, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { FC, ReactElement, useRef, useState } from 'react';
import { Base } from './Base';
interface InfiniteScrollProps<T = any>
@ -14,50 +14,42 @@ export const InfiniteScroll: FC<InfiniteScrollProps> = props =>
{
const { rows = [], overscan = 5, scrollToBottom = false, rowRender = null } = props;
const [ scrollIndex, setScrollIndex ] = useState<number>(rows.length - 1);
const elementRef = useRef<HTMLDivElement>(null);
const parentRef = useRef<HTMLDivElement>(null);
const { virtualItems = [], totalSize = 0, scrollToIndex = null } = useVirtual({
parentRef: elementRef,
size: rows.length,
overscan
const virtualizer = useVirtualizer({
count: rows.length,
overscan,
getScrollElement: () => parentRef.current,
estimateSize: () => 45,
});
const paddingTop = (virtualItems.length > 0) ? (virtualItems?.[0]?.start || 0) : 0
const paddingBottom = (virtualItems.length > 0) ? (totalSize - (virtualItems?.[virtualItems.length - 1]?.end || 0)) : 0;
useEffect(() =>
{
if(!scrollToBottom) return;
scrollToIndex(scrollIndex);
}, [ scrollToBottom, scrollIndex, scrollToIndex ]);
const items = virtualizer.getVirtualItems();
return (
<Base fit innerRef={ elementRef } position="relative" overflow="auto">
{ (paddingTop > 0) &&
<Base fit innerRef={ parentRef } position="relative" overflow="auto">
<div
style={ {
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative'
} }>
<div
style={ { minHeight: `${ paddingTop }px` } } /> }
{ virtualItems.map(item =>
{
const row = rows[item.index];
if (!row) return (
<Fragment
key={ item.key } />
);
return (
<div
key={ item.key }
data-index={ item.index }
ref={ item.measureRef }>
{ rowRender(row) }
</div>
)
}) }
{ (paddingBottom > 0) &&
<div
style={ { minHeight: `${ paddingBottom }px` } } /> }
style={ {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${ items[0]?.start ?? 0 }px)`
} }>
{ items.map((virtualRow) => (
<div
key={ virtualRow.key }
data-index={ virtualRow.index }
ref={ virtualizer.measureElement }>
{ rowRender(rows[virtualRow.index]) }
</div>
)) }
</div>
</div>
</Base>
);
}

View File

@ -2,21 +2,22 @@ export * from './AutoGrid';
export * from './Base';
export * from './Button';
export * from './ButtonGroup';
export * from './card';
export * from './card/accordion';
export * from './card/tabs';
export * from './classNames';
export * from './Column';
export * from './draggable-window';
export * from './Flex';
export * from './FormGroup';
export * from './Grid';
export * from './GridContext';
export * from './HorizontalRule';
export * from './InfiniteGrid';
export * from './InfiniteScroll';
export * from './Text';
export * from './card';
export * from './card/accordion';
export * from './card/tabs';
export * from './classNames';
export * from './draggable-window';
export * from './layout';
export * from './layout/limited-edition';
export * from './Text';
export * from './transitions';
export * from './types';
export * from './utils';

View File

@ -0,0 +1,336 @@
.nitro-avatar-editor-spritesheet {
background: url('@/assets/images/avatareditor/avatar-editor-spritesheet.png') transparent no-repeat;
&.arrow-left-icon {
width: 28px;
height: 21px;
background-position: -226px -131px;
}
&.arrow-right-icon {
width: 28px;
height: 21px;
background-position: -226px -162px;
}
&.ca-icon {
width: 25px;
height: 25px;
background-position: -226px -61px;
&.selected {
width: 25px;
height: 25px;
background-position: -226px -96px;
}
}
&.cc-icon {
width: 31px;
height: 29px;
background-position: -145px -5px;
&.selected {
width: 31px;
height: 29px;
background-position: -145px -44px;
}
}
&.ch-icon {
width: 29px;
height: 24px;
background-position: -186px -39px;
&.selected {
width: 29px;
height: 24px;
background-position: -186px -73px;
}
}
&.clear-icon {
width: 27px;
height: 27px;
background-position: -145px -157px;
}
&.cp-icon {
width: 30px;
height: 24px;
background-position: -145px -264px;
&.selected {
width: 30px;
height: 24px;
background-position: -186px -5px;
}
}
&.ea-icon {
width: 35px;
height: 16px;
background-position: -226px -193px;
&.selected {
width: 35px;
height: 16px;
background-position: -226px -219px;
}
}
&.fa-icon {
width: 27px;
height: 20px;
background-position: -186px -137px;
&.selected {
width: 27px;
height: 20px;
background-position: -186px -107px;
}
}
&.female-icon {
width: 18px;
height: 27px;
background-position: -186px -202px;
&.selected {
width: 18px;
height: 27px;
background-position: -186px -239px;
}
}
&.ha-icon {
width: 25px;
height: 22px;
background-position: -226px -245px;
&.selected {
width: 25px;
height: 22px;
background-position: -226px -277px;
}
}
&.he-icon {
width: 31px;
height: 27px;
background-position: -145px -83px;
&.selected {
width: 31px;
height: 27px;
background-position: -145px -120px;
}
}
&.hr-icon {
width: 29px;
height: 25px;
background-position: -145px -194px;
&.selected {
width: 29px;
height: 25px;
background-position: -145px -229px;
}
}
&.lg-icon {
width: 19px;
height: 20px;
background-position: -303px -45px;
&.selected {
width: 19px;
height: 20px;
background-position: -303px -75px;
}
}
&.loading-icon {
width: 21px;
height: 25px;
background-position: -186px -167px;
}
&.male-icon {
width: 21px;
height: 21px;
background-position: -186px -276px;
&.selected {
width: 21px;
height: 21px;
background-position: -272px -5px;
}
}
&.sellable-icon {
width: 17px;
height: 15px;
background-position: -303px -105px;
}
&.sh-icon {
width: 37px;
height: 10px;
background-position: -303px -5px;
&.selected {
width: 37px;
height: 10px;
background-position: -303px -25px;
}
}
&.spotlight-icon {
width: 130px;
height: 305px;
background-position: -5px -5px;
}
&.wa-icon {
width: 36px;
height: 18px;
background-position: -226px -5px;
&.selected {
width: 36px;
height: 18px;
background-position: -226px -33px;
}
}
}
.nitro-avatar-editor-wardrobe-figure-preview {
background-color: $pale-sky;
overflow: hidden;
z-index: 1;
.avatar-image {
position: absolute;
bottom: -15px;
margin: 0 auto;
z-index: 4;
}
.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;
}
&:after {
position: absolute;
content: '';
top: 75%;
bottom: 0;
left: 0;
right: 0;
border-radius: 50%;
background-color: $pale-sky;
box-shadow: 0 0 8px 2px rgba($white,.6);
transform: scale(2);
}
.button-container {
position: absolute;
bottom: 0;
z-index: 5;
}
}
.nitro-avatar-editor {
width: $avatar-editor-width;
height: $avatar-editor-height;
.category-item {
height: 40px;
}
.figure-preview-container {
position: relative;
height: 100%;
background-color: $pale-sky;
overflow: hidden;
z-index: 1;
.arrow-container {
position: absolute;
width: 100%;
margin: 0 auto;
padding: 0 10px;
display: flex;
justify-content: space-between;
bottom: 12px;
z-index: 5;
.icon {
cursor: pointer;
}
}
.avatar-image {
position: absolute;
left: 0;
right: 0;
bottom: 50px;
margin: 0 auto;
z-index: 4;
}
.avatar-spotlight {
position: absolute;
top: -10px;
left: 0;
right: 0;
margin: 0 auto;
opacity: 0.3;
pointer-events: none;
z-index: 3;
}
.avatar-shadow {
position: absolute;
left: 0;
right: 0;
bottom: 15px;
width: 70px;
height: 30px;
margin: 0 auto;
border-radius: 100%;
background-color: rgba(0, 0, 0, 0.20);
z-index: 2;
}
&:after {
position: absolute;
content: '';
top: 75%;
bottom: 0;
left: 0;
right: 0;
border-radius: 50%;
background-color: $pale-sky;
box-shadow: 0 0 8px 2px rgba($white,.6);
transform: scale(2);
}
}
}

View File

@ -0,0 +1,114 @@
import { AddLinkEventTracker, AvatarEditorFigureCategory, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaDice, FaTrash, FaUndo } from 'react-icons/fa';
import { AvatarEditorAction, LocalizeText } from '../../api';
import { Button, ButtonGroup, Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
import { useAvatarEditor } from '../../hooks';
import { AvatarEditorModelView } from './views/AvatarEditorModelView';
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 AvatarEditorNewView: FC<{}> = props =>
{
const [ isVisible, setIsVisible ] = useState(false);
const { setIsVisible: setEditorVisibility, avatarModels, activeModelKey, setActiveModelKey } = useAvatarEditor();
const processAction = (action: string) =>
{
switch(action)
{
case AvatarEditorAction.ACTION_CLEAR:
return;
case AvatarEditorAction.ACTION_RESET:
return;
case AvatarEditorAction.ACTION_RANDOMIZE:
return;
case AvatarEditorAction.ACTION_SAVE:
return;
}
}
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(prevValue => !prevValue);
return;
}
},
eventUrlPrefix: 'avatar-editor/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
useEffect(() =>
{
setEditorVisibility(isVisible)
}, [ isVisible, setEditorVisibility ]);
if(!isVisible) return null;
return (
<NitroCardView uniqueKey="avatar-editor" className="nitro-avatar-editor">
<NitroCardHeaderView headerText={ LocalizeText('avatareditor.title') } onCloseClick={ event => setIsVisible(false) } />
<NitroCardTabsView>
{ Object.keys(avatarModels).map(modelKey =>
{
const isActive = (activeModelKey === modelKey);
return (
<NitroCardTabsItemView key={ modelKey } isActive={ isActive } onClick={ event => setActiveModelKey(modelKey) }>
{ LocalizeText(`avatareditor.category.${ modelKey }`) }
</NitroCardTabsItemView>
);
}) }
</NitroCardTabsView>
<NitroCardContentView>
<Grid>
<Column size={ 9 } overflow="hidden">
{ ((activeModelKey.length > 0) && (activeModelKey !== AvatarEditorFigureCategory.WARDROBE)) &&
<AvatarEditorModelView name={ activeModelKey } categories={ avatarModels[activeModelKey] } /> }
{ (activeModelKey === AvatarEditorFigureCategory.WARDROBE) }
</Column>
<Column size={ 3 } overflow="hidden">
{ /* <AvatarEditorFigurePreviewView figureData={ figureData } /> */ }
<Column grow gap={ 1 }>
<ButtonGroup>
<Button variant="secondary" onClick={ event => processAction(AvatarEditorAction.ACTION_RESET) }>
<FaUndo className="fa-icon" />
</Button>
<Button variant="secondary" onClick={ event => processAction(AvatarEditorAction.ACTION_CLEAR) }>
<FaTrash className="fa-icon" />
</Button>
<Button variant="secondary" onClick={ event => processAction(AvatarEditorAction.ACTION_RANDOMIZE) }>
<FaDice className="fa-icon" />
</Button>
</ButtonGroup>
<Button className="w-100" variant="success" onClick={ event => processAction(AvatarEditorAction.ACTION_SAVE) }>
{ LocalizeText('avatareditor.save') }
</Button>
</Column>
</Column>
</Grid>
</NitroCardContentView>
</NitroCardView>
);
}

View File

@ -0,0 +1,30 @@
import { FC, useMemo } from 'react';
import { Base, BaseProps } from '../../../common';
type AvatarIconType = 'male' | 'female' | 'clear' | 'sellable' | string;
export interface AvatarEditorIconProps extends BaseProps<HTMLDivElement>
{
icon: AvatarIconType;
selected?: boolean;
}
export const AvatarEditorIcon: FC<AvatarEditorIconProps> = props =>
{
const { icon = null, selected = false, classNames = [], children = null, ...rest } = props;
const getClassNames = useMemo(() =>
{
const newClassNames: string[] = [ 'nitro-avatar-editor-spritesheet' ];
if(icon && icon.length) newClassNames.push(icon + '-icon');
if(selected) newClassNames.push('selected');
if(classNames.length) newClassNames.push(...classNames);
return newClassNames;
}, [ icon, selected, classNames ]);
return <Base classNames={ getClassNames } { ...rest } />
}

View File

@ -0,0 +1,85 @@
import { AvatarEditorFigureCategory } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { FigureData, IAvatarEditorCategory } from '../../../api';
import { Column, Flex, Grid } from '../../../common';
import { useAvatarEditor } from '../../../hooks';
import { AvatarEditorIcon } from './AvatarEditorIcon';
import { AvatarEditorFigureSetView } from './figure-set';
import { AvatarEditorPaletteSetView } from './palette-set';
export const AvatarEditorModelView: FC<{
name: string,
categories: IAvatarEditorCategory[]
}> = props =>
{
const { name = '', categories = [] } = props;
const [ activeSetType, setActiveSetType ] = useState<string>('');
const { maxPaletteCount = 1 } = useAvatarEditor();
const activeCategory = useMemo(() =>
{
return categories.find(category => category.setType === activeSetType) ?? null;
}, [ categories, activeSetType ]);
const setGender = (gender: string) =>
{
//
}
useEffect(() =>
{
if(!activeCategory) return;
// we need to run this when we change which parts r selected
/* for(const partItem of activeCategory.partItems)
{
if(!partItem || !part.isSelected) continue;
setMaxPaletteCount(part.maxColorIndex || 1);
break;
} */
}, [ activeCategory ])
useEffect(() =>
{
if(!categories || !categories.length) return;
setActiveSetType(categories[0]?.setType)
}, [ categories ]);
if(!activeCategory) return null;
return (
<Grid>
<Column size={ 2 }>
{ (name === AvatarEditorFigureCategory.GENERIC) &&
<>
<Flex center pointer className="category-item" onClick={ event => setGender(FigureData.MALE) }>
<AvatarEditorIcon icon="male" selected={ false } />
</Flex>
<Flex center pointer className="category-item" onClick={ event => setGender(FigureData.FEMALE) }>
<AvatarEditorIcon icon="female" selected={ false } />
</Flex>
</> }
{ (name !== AvatarEditorFigureCategory.GENERIC) && (categories.length > 0) && categories.map(category =>
{
return (
<Flex center pointer key={ category.setType } className="category-item" onClick={ event => setActiveSetType(category.setType) }>
<AvatarEditorIcon icon={ category.setType } selected={ (activeSetType === category.setType) } />
</Flex>
);
}) }
</Column>
<Column size={ 5 } overflow="hidden">
<AvatarEditorFigureSetView category={ activeCategory } />
</Column>
<Column size={ 5 } overflow="hidden">
{ (maxPaletteCount >= 1) &&
<AvatarEditorPaletteSetView category={ activeCategory } paletteIndex={ 0 } /> }
{ (maxPaletteCount === 2) &&
<AvatarEditorPaletteSetView category={ activeCategory } paletteIndex={ 1 } /> }
</Column>
</Grid>
);
}

View File

@ -0,0 +1,53 @@
import { FC, useEffect, useState } from 'react';
import { AvatarEditorThumbnailsHelper, FigureData, GetConfigurationValue, IAvatarEditorCategoryPartItem } from '../../../../api';
import { LayoutCurrencyIcon, LayoutGridItem, LayoutGridItemProps } from '../../../../common';
import { useAvatarEditor } from '../../../../hooks';
import { AvatarEditorIcon } from '../AvatarEditorIcon';
export const AvatarEditorFigureSetItemView: FC<{
setType: string;
partItem: IAvatarEditorCategoryPartItem;
isSelected: boolean;
} & LayoutGridItemProps> = props =>
{
const { setType = null, partItem = null, isSelected = false, ...rest } = props;
const [ assetUrl, setAssetUrl ] = useState<string>('');
const { selectedColorParts = null, getFigureStringWithFace = null } = useAvatarEditor();
const isHC = !GetConfigurationValue<boolean>('hc.disabled', false) && ((partItem.partSet?.clubLevel ?? 0) > 0);
useEffect(() =>
{
if(!setType || !setType.length || !partItem) return;
const loadImage = async () =>
{
const isHC = !GetConfigurationValue<boolean>('hc.disabled', false) && ((partItem.partSet?.clubLevel ?? 0) > 0);
let url: string = null;
if(setType === FigureData.FACE)
{
url = await AvatarEditorThumbnailsHelper.buildForFace(getFigureStringWithFace(partItem.id), isHC);
}
else
{
url = await AvatarEditorThumbnailsHelper.build(setType, partItem, partItem.usesColor, selectedColorParts[setType] ?? null, isHC);
}
if(url && url.length) setAssetUrl(url);
}
loadImage();
}, [ setType, partItem, selectedColorParts, getFigureStringWithFace ]);
if(!partItem) return null;
return (
<LayoutGridItem itemImage={ (partItem.isClear ? undefined : assetUrl) } itemActive={ isSelected } style={ { width: '100%', 'flex': '1' } } { ...rest }>
{ !partItem.isClear && isHC && <LayoutCurrencyIcon className="position-absolute end-1 bottom-1" type="hc" /> }
{ partItem.isClear && <AvatarEditorIcon icon="clear" /> }
{ !partItem.isClear && partItem.partSet.isSellable && <AvatarEditorIcon icon="sellable" position="absolute" className="end-1 bottom-1" /> }
</LayoutGridItem>
);
}

View File

@ -0,0 +1,36 @@
import { FC, useRef } from 'react';
import { IAvatarEditorCategory, IAvatarEditorCategoryPartItem } from '../../../../api';
import { InfiniteGrid } from '../../../../common';
import { useAvatarEditor } from '../../../../hooks';
import { AvatarEditorFigureSetItemView } from './AvatarEditorFigureSetItemView';
export const AvatarEditorFigureSetView: FC<{
category: IAvatarEditorCategory
}> = props =>
{
const { category = null } = props;
const { selectedParts = null, selectEditorPart } = useAvatarEditor();
const elementRef = useRef<HTMLDivElement>(null);
const isPartItemSelected = (partItem: IAvatarEditorCategoryPartItem) =>
{
if(!category || !category.setType || !selectedParts || !selectedParts[category.setType]) return false;
const partId = selectedParts[category.setType];
return (partId === partItem.id);
}
const columnCount = 3;
return (
<InfiniteGrid rows={ category.partItems } columnCount={ columnCount } overscan={ 5 } itemRender={ (item: IAvatarEditorCategoryPartItem) =>
{
if(!item) return null;
return (
<AvatarEditorFigureSetItemView key={ item.id } setType={ category.setType } partItem={ item } isSelected={ isPartItemSelected(item) } onClick={ event => selectEditorPart(category.setType, item.partSet?.id ?? -1) } />
)
} } />
);
}

View File

@ -0,0 +1,2 @@
export * from './AvatarEditorFigureSetItemView';
export * from './AvatarEditorFigureSetView';

View File

@ -0,0 +1,27 @@
import { ColorConverter, IPartColor } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { GetConfigurationValue } from '../../../../api';
import { LayoutCurrencyIcon, LayoutGridItem, LayoutGridItemProps } from '../../../../common';
export interface AvatarEditorPaletteSetItemProps extends LayoutGridItemProps
{
setType: string;
partColor: IPartColor;
isSelected: boolean;
}
// its disabled if its hc and you dont have it
export const AvatarEditorPaletteSetItem: FC<AvatarEditorPaletteSetItemProps> = props =>
{
const { setType = null, partColor = null, isSelected = false, ...rest } = props;
if(!partColor) return null;
const isHC = !GetConfigurationValue<boolean>('hc.disabled', false) && (partColor.clubLevel > 0);
return (
<LayoutGridItem itemHighlight itemColor={ ColorConverter.int2rgb(partColor.rgb) } itemActive={ isSelected } className="clear-bg" { ...rest }>
{ isHC && <LayoutCurrencyIcon className="position-absolute end-1 bottom-1" type="hc" /> }
</LayoutGridItem>
);
}

View File

@ -0,0 +1,33 @@
import { IPartColor } from '@nitrots/nitro-renderer';
import { FC, useRef } from 'react';
import { IAvatarEditorCategory } from '../../../../api';
import { AutoGrid } from '../../../../common';
import { useAvatarEditor } from '../../../../hooks';
import { AvatarEditorPaletteSetItem } from './AvatarEditorPaletteSetItemView';
export const AvatarEditorPaletteSetView: FC<{
category: IAvatarEditorCategory,
paletteIndex: number;
}> = props =>
{
const { category = null, paletteIndex = -1 } = props;
const paletteSet = category?.colorItems[paletteIndex] ?? null;
const { selectedColors = null, selectEditorColor } = useAvatarEditor();
const elementRef = useRef<HTMLDivElement>(null);
const isPartColorSelected = (partColor: IPartColor) =>
{
if(!category || !category.setType || !selectedColors || !selectedColors[category.setType] || !selectedColors[category.setType][paletteIndex]) return false;
const colorId = selectedColors[category.setType][paletteIndex];
return (colorId === partColor.id);
}
return (
<AutoGrid innerRef={ elementRef } gap={ 1 } columnCount={ 5 } columnMinWidth={ 30 }>
{ (paletteSet.length > 0) && paletteSet.map(item =>
<AvatarEditorPaletteSetItem key={ item.id } setType={ category.setType } partColor={ item } isSelected={ isPartColorSelected(item) } onClick={ event => selectEditorColor(category.setType, paletteIndex, item.id) } />) }
</AutoGrid>
);
}

View File

@ -0,0 +1,2 @@
export * from './AvatarEditorPaletteSetItemView';
export * from './AvatarEditorPaletteSetView';

View File

@ -1,4 +1,4 @@
import { AddLinkEventTracker, AvatarEditorFigureCategory, FigureSetIdsMessageEvent, GetAvatarRenderManager, GetSessionDataManager, GetWardrobeMessageComposer, IAvatarFigureContainer, ILinkEventTracker, RemoveLinkEventTracker, UserFigureComposer, UserWardrobePageEvent } from '@nitrots/nitro-renderer';
import { AvatarEditorFigureCategory, FigureSetIdsMessageEvent, GetAvatarRenderManager, GetSessionDataManager, GetWardrobeMessageComposer, IAvatarFigureContainer, UserFigureComposer, UserWardrobePageEvent } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaDice, FaTrash, FaUndo } from 'react-icons/fa';
import { AvatarEditorAction, AvatarEditorUtilities, BodyModel, FigureData, GetClubMemberLevel, GetConfigurationValue, HeadModel, IAvatarEditorCategoryModel, LegModel, LocalizeText, SendMessageComposer, TorsoModel, generateRandomFigure } from '../../api';
@ -148,7 +148,7 @@ export const AvatarEditorView: FC<{}> = props =>
setFigureData(figures.get(gender));
}, [ figures ]);
useEffect(() =>
/* useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
@ -176,7 +176,7 @@ export const AvatarEditorView: FC<{}> = props =>
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
}, []); */
useEffect(() =>
{

View File

@ -3,6 +3,7 @@ import { FC, useEffect, useState } from 'react';
import { Base, TransitionAnimation, TransitionAnimationTypes } from '../../common';
import { useNitroEvent } from '../../hooks';
import { AchievementsView } from '../achievements/AchievementsView';
import { AvatarEditorNewView } from '../avatar-editor-new/AvatarEditorView';
import { AvatarEditorView } from '../avatar-editor/AvatarEditorView';
import { CameraWidgetView } from '../camera/CameraWidgetView';
import { CampaignView } from '../campaign/CampaignView';
@ -89,6 +90,7 @@ export const MainView: FC<{}> = props =>
<ChatHistoryView />
<WiredView />
<AvatarEditorView />
<AvatarEditorNewView />
<AchievementsView />
<NavigatorView />
<InventoryView />

View File

@ -0,0 +1,2 @@
export * from './useAvatarEditor';
export * from './useFigureData';

View File

@ -0,0 +1,244 @@
import { AvatarEditorFigureCategory, FigureSetIdsMessageEvent, GetAvatarRenderManager, GetSessionDataManager, IFigurePartSet, IPartColor } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useBetween } from 'use-between';
import { AvatarEditorThumbnailsHelper, FigureData, GetClubMemberLevel, IAvatarEditorCategory, IAvatarEditorCategoryPartItem } from '../../api';
import { useMessageEvent } from '../events';
import { useFigureData } from './useFigureData';
const MAX_PALETTES: number = 2;
const useAvatarEditorState = () =>
{
const [ isVisible, setIsVisible ] = useState<boolean>(false);
const [ avatarModels, setAvatarModels ] = useState<{ [index: string]: IAvatarEditorCategory[] }>({});
const [ activeModelKey, setActiveModelKey ] = useState<string>('');
const [ maxPaletteCount, setMaxPaletteCount ] = useState<number>(1);
const [ figureSetIds, setFigureSetIds ] = useState<number[]>([]);
const [ boundFurnitureNames, setBoundFurnitureNames ] = useState<string[]>([]);
const { gender, selectedParts, selectedColors, loadAvatarData, selectPart, selectColor, getFigureStringWithFace } = useFigureData();
const activeModel = useMemo(() => (avatarModels[activeModelKey] ?? null), [ activeModelKey, avatarModels ]);
const selectedColorParts = useMemo(() =>
{
const colorSets: { [index: string]: IPartColor[] } = {};
for(const setType of Object.keys(selectedColors))
{
if(!selectedColors[setType]) continue;
const parts: IPartColor[] = [];
for(const paletteId of Object.keys(selectedColors[setType]))
{
const partColor = activeModel.find(category => (category.setType === setType))?.colorItems[paletteId]?.find(partColor => (partColor.id === selectedColors[setType][paletteId]));
if(partColor) parts.push(partColor);
}
colorSets[setType] = parts;
}
return colorSets;
}, [ activeModel, selectedColors ]);
const selectEditorPart = useCallback((setType: string, partId: number) =>
{
if(!setType || !setType.length) return;
const category = activeModel.find(category => (category.setType === setType));
if(!category || !category.partItems || !category.partItems.length) return;
const partItem = category.partItems.find(partItem => partItem.id === partId);
if(!partItem) return;
if(partItem.isClear)
{
// clear the part
return;
}
if(GetClubMemberLevel() < partItem.partSet.clubLevel) return;
setMaxPaletteCount(partItem.maxPaletteCount || 1);
selectPart(setType, partId);
}, [ activeModel, selectPart ]);
const selectEditorColor = useCallback((setType: string, paletteId: number, colorId: number) =>
{
if(!setType || !setType.length) return;
const category = activeModel.find(category => (category.setType === setType));
if(!category || !category.colorItems || !category.colorItems.length) return;
const palette = category.colorItems[paletteId];
if(!palette || !palette.length) return;
const partColor = palette.find(partColor => (partColor.id === colorId));
if(!partColor) return;
if(GetClubMemberLevel() < partColor.clubLevel) return;
selectColor(setType, paletteId, colorId);
}, [ activeModel, selectColor ]);
useMessageEvent<FigureSetIdsMessageEvent>(FigureSetIdsMessageEvent, event =>
{
const parser = event.getParser();
setFigureSetIds(parser.figureSetIds);
setBoundFurnitureNames(parser.boundsFurnitureNames);
});
useEffect(() =>
{
AvatarEditorThumbnailsHelper.clearCache();
}, [ selectedColorParts ]);
useEffect(() =>
{
if(!isVisible) return;
const newAvatarModels: { [index: string]: IAvatarEditorCategory[] } = {};
const buildCategory = (setType: string) =>
{
const partItems: IAvatarEditorCategoryPartItem[] = [];
const colorItems: IPartColor[][] = [];
for(let i = 0; i < MAX_PALETTES; i++) colorItems.push([]);
const set = GetAvatarRenderManager().structureData.getSetType(setType);
const palette = GetAvatarRenderManager().structureData.getPalette(set.paletteID);
if(!set || !palette) return null;
for(const partColor of palette.colors.getValues())
{
if(!partColor || !partColor.isSelectable) continue;
for(let i = 0; i < MAX_PALETTES; i++) colorItems[i].push(partColor);
// TODO - check what this does
/* if(setType !== FigureData.FACE)
{
let i = 0;
while(i < colorIds.length)
{
if(partColor.id === colorIds[i]) partColors[i] = partColor;
i++;
}
} */
}
let mandatorySetIds: string[] = GetAvatarRenderManager().getMandatoryAvatarPartSetIds(gender, GetClubMemberLevel());
const isntMandatorySet = (mandatorySetIds.indexOf(setType) === -1);
if(isntMandatorySet) partItems.push({ id: -1, isClear: true });
const usesColor = (setType !== FigureData.FACE);
const partSets = set.partSets;
for(let i = (partSets.length); i >= 0; i--)
{
const partSet = partSets.getWithIndex(i);
if(!partSet || !partSet.isSelectable || ((partSet.gender !== gender) && (partSet.gender !== FigureData.UNISEX))) continue;
if(partSet.isSellable && figureSetIds.indexOf(partSet.id) === -1) continue;
let maxPaletteCount = 0;
for(const part of partSet.parts) maxPaletteCount = Math.max(maxPaletteCount, part.colorLayerIndex);
partItems.push({ id: partSet.id, partSet, usesColor, maxPaletteCount });
}
partItems.sort(partSorter(false));
for(let i = 0; i < MAX_PALETTES; i++) colorItems[i].sort(colorSorter);
return { setType, partItems, colorItems };
}
newAvatarModels[AvatarEditorFigureCategory.GENERIC] = [ FigureData.FACE ].map(setType => buildCategory(setType));
newAvatarModels[AvatarEditorFigureCategory.HEAD] = [ FigureData.HAIR, FigureData.HAT, FigureData.HEAD_ACCESSORIES, FigureData.EYE_ACCESSORIES, FigureData.FACE_ACCESSORIES ].map(setType => buildCategory(setType));
newAvatarModels[AvatarEditorFigureCategory.TORSO] = [ FigureData.SHIRT, FigureData.CHEST_PRINTS, FigureData.JACKET, FigureData.CHEST_ACCESSORIES ].map(setType => buildCategory(setType));
newAvatarModels[AvatarEditorFigureCategory.LEGS] = [ FigureData.TROUSERS, FigureData.SHOES, FigureData.TROUSER_ACCESSORIES ].map(setType => buildCategory(setType));
newAvatarModels[AvatarEditorFigureCategory.WARDROBE] = [];
setAvatarModels(newAvatarModels);
setActiveModelKey(AvatarEditorFigureCategory.GENERIC);
}, [ isVisible, gender, figureSetIds ]);
useEffect(() =>
{
if(!isVisible) return;
loadAvatarData(GetSessionDataManager().figure, GetSessionDataManager().gender);
}, [ isVisible, loadAvatarData ]);
return { isVisible, setIsVisible, avatarModels, activeModelKey, setActiveModelKey, selectedParts, selectedColors, maxPaletteCount, selectedColorParts, selectEditorPart, selectEditorColor, getFigureStringWithFace };
}
export const useAvatarEditor = () => useBetween(useAvatarEditorState);
const partSorter = (hcFirst: boolean) =>
{
return (a: { partSet: IFigurePartSet, usesColor: boolean, isClear?: boolean }, b: { partSet: IFigurePartSet, usesColor: boolean, isClear?: boolean }) =>
{
const clubLevelA = (!a.partSet ? -1 : a.partSet.clubLevel);
const clubLevelB = (!b.partSet ? -1 : b.partSet.clubLevel);
const isSellableA = (!a.partSet ? false : a.partSet.isSellable);
const isSellableB = (!b.partSet ? false : b.partSet.isSellable);
if(isSellableA && !isSellableB) return 1;
if(isSellableB && !isSellableA) return -1;
if(hcFirst)
{
if(clubLevelA > clubLevelB) return -1;
if(clubLevelA < clubLevelB) return 1;
}
else
{
if(clubLevelA < clubLevelB) return -1;
if(clubLevelA > clubLevelB) return 1;
}
if(a.partSet.id < b.partSet.id) return -1;
if(a.partSet.id > b.partSet.id) return 1;
return 0;
}
}
const colorSorter = (a: IPartColor, b: IPartColor) =>
{
const clubLevelA = (!a ? -1 : a.clubLevel);
const clubLevelB = (!b ? -1 : b.clubLevel);
if(clubLevelA < clubLevelB) return -1;
if(clubLevelA > clubLevelB) return 1;
if(a.index < b.index) return -1;
if(a.index > b.index) return 1;
return 0;
}

View File

@ -0,0 +1,114 @@
import { useCallback, useState } from 'react';
import { FigureData } from '../../api';
const useFigureDataState = () =>
{
const [ selectedParts, setSelectedParts ] = useState<{ [index: string]: number }>({});
const [ selectedColors, setSelectedColors ] = useState<{ [index: string]: number[] }>({});
const [ gender, setGender ] = useState<string>(FigureData.MALE);
const loadAvatarData = useCallback((figureString: string, gender: string) =>
{
const parse = (figure: string) =>
{
const sets = figure.split('.');
if(!sets || !sets.length) return;
const partSets: { [index: string]: number } = {};
const colorSets: { [index: string]: number[] } = {};
for(const set of sets)
{
const parts = set.split('-');
if(!parts.length) continue;
const setType = parts[0];
const partId = parseInt(parts[1]);
const colorIds: number[] = [];
let offset = 2;
while(offset < parts.length)
{
colorIds.push(parseInt(parts[offset]));
offset++;
}
if(!colorIds.length) colorIds.push(0);
if(partId >= 0) partSets[setType] = partId;
if(colorIds.length) colorSets[setType] = colorIds;
}
return { partSets, colorSets };
}
const { partSets, colorSets } = parse(figureString);
setSelectedParts(partSets);
setSelectedColors(colorSets);
setGender(gender);
}, []);
const selectPart = useCallback((setType: string, partId: number) =>
{
if(!setType || !setType.length) return;
setSelectedParts(prevValue =>
{
const newValue = { ...prevValue };
newValue[setType] = partId;
return newValue;
});
}, []);
const selectColor = useCallback((setType: string, paletteId: number, colorId: number) =>
{
if(!setType || !setType.length) return;
setSelectedColors(prevValue =>
{
const newValue = { ...prevValue };
if(!newValue[setType]) newValue[setType] = [];
if(!newValue[setType][paletteId]) newValue[setType][paletteId] = 0;
newValue[setType][paletteId] = colorId;
return newValue;
})
}, []);
const getFigureStringWithFace = useCallback((overridePartId: number, override: boolean = true) =>
{
const figureSets = [ FigureData.FACE ].map(setType =>
{
// Determine the part ID, with an option to override if the set type matches.
let partId = (setType === FigureData.FACE && override) ? overridePartId : selectedParts[setType];
const colors = selectedColors[setType] || [];
// Construct the figure set string, including the type, part ID, and any colors.
let figureSet = `${ setType }-${ partId }`;
if (partId >= 0)
{
figureSet += colors.map(color => `-${ color }`).join('');
}
return figureSet;
});
// Join all figure sets with '.', ensuring to only add '.' between items, not at the end.
return figureSets.join('.');
}, [ selectedParts, selectedColors ]);
return { selectedParts, selectedColors, gender, loadAvatarData, selectPart, selectColor, getFigureStringWithFace };
}
export const useFigureData = useFigureDataState;

View File

@ -1,5 +1,6 @@
export * from './UseMountEffect';
export * from './achievements';
export * from './avatar-editor';
export * from './camera';
export * from './catalog';
export * from './chat-history';
@ -14,6 +15,10 @@ export * from './navigator';
export * from './notification';
export * from './purse';
export * from './rooms';
export * from './rooms/engine';
export * from './rooms/promotes';
export * from './rooms/widgets';
export * from './rooms/widgets/furniture';
export * from './session';
export * from './useLocalStorage';
export * from './useSharedVisibility';

749
yarn.lock

File diff suppressed because it is too large Load Diff