Begin new avatar editor

This commit is contained in:
billsonnn 2024-04-01 19:26:36 -04:00
parent 382cb665d8
commit d675258adb
24 changed files with 1241 additions and 63 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,152 @@
import { AvatarFigurePartType, 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 async build(setType: string, part: IAvatarEditorCategoryPartItem, useColors: boolean, 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, 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;
let partColor: IPartColor = null;
if(useColors && (part.colorLayerIndex > 0))
{
//const color = this._partColors[(part.colorLayerIndex - 1)];
//if(color) partColor = color;
}
const sprite = new NitroSprite(asset.texture);
sprite.position.set(x, y);
if(partColor) sprite.tint = partColor.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, isDisabled);
const url = await TextureUtils.generateImageUrl(container);
AvatarEditorThumbnailsHelper.THUMBNAIL_CACHE.set(thumbnailKey, url);
resolve(url);
}
const figureContainer = GetAvatarRenderManager().createFigureContainer(`${ setType }-${ part.partSet.id }`);
if(!GetAvatarRenderManager().isFigureContainerReady(figureContainer))
{
GetAvatarRenderManager().downloadAvatarFigure(figureContainer, {
resetFigure,
dispose: null,
disposed: false
});
}
else
{
resetFigure(null);
}
});
}
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,69 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import { FC, Fragment, ReactElement, 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,
});
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 => (
<Flex
gap={ 1 }
key={ virtualRow.key + 'a' }
data-index={ virtualRow.index }
ref={ virtualizer.measureElement }
style={ {
minHeight: virtualRow.index === 0 ? 45 : virtualRow.size
} }>
{ 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);
}) }
</Flex>
)) }
</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,43 @@ 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={ { minHeight: `${ paddingTop }px` } } /> }
{ virtualItems.map(item =>
{
const row = rows[item.index];
if (!row) return (
<Fragment
key={ item.key } />
);
return (
style={ {
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative'
} }>
<div
key={ item.key }
data-index={ item.index }
ref={ item.measureRef }>
{ rowRender(row) }
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>
)
}) }
{ (paddingBottom > 0) &&
<div
style={ { minHeight: `${ paddingBottom }px` } } /> }
</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,109 @@
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 { 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);
}, []);
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,41 @@
import { FC, useEffect, useState } from 'react';
import { AvatarEditorThumbnailsHelper, GetConfigurationValue, IAvatarEditorCategoryPartItem } from '../../../../api';
import { LayoutCurrencyIcon, LayoutGridItem, LayoutGridItemProps } from '../../../../common';
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 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);
const url = await AvatarEditorThumbnailsHelper.build(setType, partItem, partItem.usesColor, isHC);
if(url && url.length) setAssetUrl(url);
}
loadImage();
}, [ setType, partItem ]);
if(!partItem || !partItem.partSet) return null;
return (
<LayoutGridItem itemImage={ (partItem.isClear ? undefined : assetUrl) } itemActive={ isSelected } style={ { width: '100%' } } { ...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, selectPart } = 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 => selectPart(category.setType, item.partSet?.id ?? -1) } style={ { width: ~~(100 / columnCount) + '%' } } />
)
} } />
);
}

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, selectColor } = 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 => selectColor(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 @@
export * from './useAvatarEditor';

View File

@ -0,0 +1,233 @@
import { AvatarEditorFigureCategory, GetAvatarRenderManager, IFigurePartSet, IPartColor } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useBetween } from 'use-between';
import { FigureData, GetClubMemberLevel, GetConfigurationValue, IAvatarEditorCategory, IAvatarEditorCategoryPartItem } from '../../api';
const MAX_PALETTES: number = 2;
const useAvatarEditorState = () =>
{
const [ avatarModels, setAvatarModels ] = useState<{ [index: string]: IAvatarEditorCategory[] }>({});
const [ activeModelKey, setActiveModelKey ] = useState<string>('');
const [ selectedParts, setSelectedParts ] = useState<{ [index: string]: number }>({});
const [ selectedColors, setSelectedColors ] = useState<{ [index: string]: { [index: number]: number }}>({});
const [ maxPaletteCount, setMaxPaletteCount ] = useState<number>(1);
const clubItemsFirst = useMemo(() => GetConfigurationValue<boolean>('avatareditor.show.clubitems.first', true), []);
const clubItemsDimmed = useMemo(() => GetConfigurationValue<boolean>('avatareditor.show.clubitems.dimmed', true), []);
const activeModel = useMemo(() => (avatarModels[activeModelKey] ?? null), [ activeModelKey, avatarModels ]);
const selectPart = 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);
setSelectedParts(prevValue =>
{
const newValue = { ...prevValue };
newValue[setType] = partItem.id;
return newValue;
});
}, [ activeModel ]);
const selectColor = 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;
setSelectedColors(prevValue =>
{
const newValue = { ...prevValue };
if(!newValue[setType]) newValue[setType] = {};
if(!newValue[setType][paletteId]) newValue[setType][paletteId] = -1;
newValue[setType][paletteId] = partColor.id;
return newValue;
})
}, [ activeModel ]);
useEffect(() =>
{
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[] = [];
if(clubItemsDimmed)
{
//mandatorySetIds = GetAvatarRenderManager().getMandatoryAvatarPartSetIds(this.CURRENT_FIGURE.gender, 2);
}
else
{
//mandatorySetIds = GetAvatarRenderManager().getMandatoryAvatarPartSetIds(this.CURRENT_FIGURE.gender, clubMemberLevel);
}
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) 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(clubItemsFirst ? clubSorter : noobSorter);
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] = [];
console.log(newAvatarModels);
setAvatarModels(newAvatarModels);
setActiveModelKey(AvatarEditorFigureCategory.GENERIC);
}, [ clubItemsDimmed, clubItemsFirst ]);
return { avatarModels, activeModelKey, setActiveModelKey, selectedParts, selectedColors, maxPaletteCount, selectPart, selectColor };
}
export const useAvatarEditor = () => useBetween(useAvatarEditorState);
const clubSorter = (a: { partSet: IFigurePartSet, usesColor: boolean, isClear?: boolean }, b: { partSet: IFigurePartSet, usesColor: boolean, isClear?: boolean }) =>
{
const clubLevelA = (!a.partSet ? 9999999999 : a.partSet.clubLevel);
const clubLevelB = (!b.partSet ? 9999999999 : 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(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;
}
const noobSorter = (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(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;
}

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';

View File

@ -180,7 +180,7 @@
dependencies:
"@babel/helper-plugin-utils" "^7.24.0"
"@babel/runtime@^7.16.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.22.5", "@babel/runtime@^7.23.2", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.8.7":
"@babel/runtime@^7.21.0", "@babel/runtime@^7.22.5", "@babel/runtime@^7.23.2", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.8.7":
version "7.24.1"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.1.tgz#431f9a794d173b53720e69a6464abc6f0e2a5c57"
integrity sha512-+BIznRzyqBf+2wCTxcKE3wDjfGeCoVE61KSHGpkzqrLi8qxqFwBeUFyId2cxkTmm55fzDGnm0+yCxaxygrLUnQ==
@ -445,11 +445,6 @@
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
"@reach/observe-rect@^1.1.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.2.0.tgz#d7a6013b8aafcc64c778a0ccb83355a11204d3b2"
integrity sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==
"@react-aria/ssr@^3.5.0":
version "3.9.2"
resolved "https://registry.yarnpkg.com/@react-aria/ssr/-/ssr-3.9.2.tgz#01b756965cd6e32b95217f968f513eb3bd6ee44b"
@ -551,13 +546,17 @@
dependencies:
tslib "^2.4.0"
"@tanstack/react-virtual@3.0.0-alpha.0":
version "3.0.0-alpha.0"
resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.0.0-alpha.0.tgz#2ca5a75fa609eca2b2ba622024d3aa3ee097bc30"
integrity sha512-WpHU/dt34NwZZ8qtiE05TF+nX/b1W6qrWZarO+s8jJFpPVicrTbJKp5Bjt4eSJuk7aYw272oEfsH3ABBRgj+3A==
"@tanstack/react-virtual@3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.2.0.tgz#fb70f9c6baee753a5a0f7618ac886205d5a02af9"
integrity sha512-OEdMByf2hEfDa6XDbGlZN8qO6bTjlNKqjM3im9JG+u3mCL8jALy0T/67oDI001raUUPh1Bdmfn4ZvPOV5knpcg==
dependencies:
"@babel/runtime" "^7.16.7"
"@reach/observe-rect" "^1.1.0"
"@tanstack/virtual-core" "3.2.0"
"@tanstack/virtual-core@3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.2.0.tgz#874d36135e4badce2719e7bdc556ce240cbaff14"
integrity sha512-P5XgYoAw/vfW65byBbJQCw+cagdXDT/qH6wmABiLt4v4YBT2q2vqCOhihe+D1Nt325F/S/0Tkv6C5z0Lv+VBQQ==
"@types/babel__core@^7.20.5":
version "7.20.5"