Skip to content

Commit

Permalink
implemented proof of concept for unanimated avatar model in preview
Browse files Browse the repository at this point in the history
  • Loading branch information
DerKatsche committed Dec 5, 2024
1 parent a3d20d1 commit 45d853a
Show file tree
Hide file tree
Showing 13 changed files with 202 additions and 99 deletions.
Binary file modified src/Assets/3dModels/sharedModels/avatar/avatarSkeleton.glb
Binary file not shown.
Binary file not shown.
38 changes: 0 additions & 38 deletions src/Components/Core/Domain/AvatarModels/AvatarModelLookups.ts

This file was deleted.

84 changes: 51 additions & 33 deletions src/Components/Core/Domain/AvatarModels/AvatarModelTypes.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,54 @@
export enum AvatarHairModels {
Dreads_Topback = "Dreads_Topback",
Dreads_Bandana = "Dreads_Bandana",
Dreads_Downknot = "Dreads_Downknot",
Backhead = "Backhead",
Long = "Long",
Long_Bangs = "Long_Bangs",
Long_Clampback = "Long_Clampback",
Long_Wavy = "Long_Wavy",
Medium_Dut = "Medium_Dut",
Medium_Napoleon = "Medium_Napoleon",
Medium_Ponytail = "Medium_Ponytail",
Medium_Pushed_Back = "Medium_Pushed_Back",
Medium_Shaggy = "Medium_Shaggy",
Medium_Straight = "Medium_Straight",
Short_Bald = "Short_Bald",
Short_Balding = "Short_Balding",
Short_Baldtop = "Short_Baldtop",
Short_Receding = "Short_Receding",
Short_Curly = "Short_Curly",
Short_Faux = "Short_Faux",
Short_FringeParted = "Short_FringeParted",
Short_FrontParting = "Short_FrontParting",
// Short_Nerdy = "Short_Nerdy", // thumbnail still missing
Short_Pigtails = "Short_Pigtails",
Short_PigtailsPunk = "Short_PigtailsPunk",
Short_SideFling = "Short_SideFling",
Short_SideFringe = "Short_SideFringe",
Short_SideParting = "Short_SideParting",
Short_Vanilla = "Short_Vanilla",
}

export enum AvatarBeardModels {}
// None
export const AvatarNoneModel = {
None: "none",
} as const;

// Hair
export const OAvatarHairModels = {
MediumPonytail: "hairMediumPonytail",
MediumStraight: "hairMediumStraight",

//TODO: add more hair models when assets are available
// DreadsTopback: "hairDreadsTopback",
// DreadsBandana: "hairDreadsBandana",
// DreadsDownknot: "hairDreadsDownknot",
// Backhead: "hairBackhead",
// Long: "hairLong",
// LongBangs: "hairLongBangs",
// LongClampback: "hairLongClampback",
// LongWavy: "hairLongWavy",
// MediumDut: "hairMediumDut",
// MediumNapoleon: "hairMediumNapoleon",
// MediumPushedBack: "hairMediumPushedBack",
// MediumShaggy: "hairMediumShaggy",
// ShortBald: "hairShortBald",
// ShortBalding: "hairShortBalding",
// ShortBaldtop: "hairShortBaldtop",
// ShortReceding: "hairShortReceding",
// ShortCurly: "hairShortCurly",
// ShortFaux: "hairShortFaux",
// ShortFringeParted: "hairShortFringeParted",
// ShortFrontParting: "hairShortFrontParting",
// ShortPigtails: "hairShortPigtails",
// ShortPigtailsPunk: "hairShortPigtailsPunk",
// ShortSideFling: "hairShortSideFling",
// ShortSideFringe: "hairShortSideFringe",
// ShortSideParting: "hairShortSideParting",
// ShortVanilla: "hairShortVanilla",
} as const;
export type AvatarHairModels =
| (typeof AvatarNoneModel)[keyof typeof AvatarNoneModel]
| (typeof OAvatarHairModels)[keyof typeof OAvatarHairModels];

// Beards
export const OAvatarBeardModels = {
//TODO: add more beard models when assets are available
Medium: "beardMedium",
MustacheHogan: "beardMustacheHogan",
} as const;
export type AvatarBeardModels =
| (typeof AvatarNoneModel)[keyof typeof AvatarNoneModel]
| (typeof OAvatarBeardModels)[keyof typeof OAvatarBeardModels];

export enum AvatarHeadgearModels {}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,44 @@
import TileGridLayout from "~ReactComponents/GeneralComponents/TileLayout/TileGridLayout";
import AvatarEditorCategoryContentProps from "./AvatarEditorCategoryContentProps";
import { useTranslation } from "react-i18next";
import { AvatarHairModels } from "src/Components/Core/Domain/AvatarModels/AvatarModelTypes";
import AvatarColorPalette from "src/Components/Core/Domain/AvatarModels/AvatarColorPalette";
import {
OAvatarBeardModels,
AvatarHairModels,
AvatarNoneModel,
AvatarBeardModels,
OAvatarHairModels,
} from "../../../../../Core/Domain/AvatarModels/AvatarModelTypes";
import AvatarColorPalette from "../../../../../Core/Domain/AvatarModels/AvatarColorPalette";
import { useState } from "react";
import ColorPickerButton from "~ReactComponents/GeneralComponents/ColorPicker/ColorPickerButton";
import ColorPickerModal from "~ReactComponents/GeneralComponents/ColorPicker/ColorPickerModal";

const hairThumbnails = Object.values(AvatarHairModels).map((type) => ({
const noneThumbnail = {
type: AvatarNoneModel.None,
image: require("../../../../../../Assets/avatarEditorThumbnails/hair/hairstyles/Hair_Backhead.png"),
};

const hairThumbnails = Object.values(OAvatarHairModels).map<{
type: AvatarHairModels; // use union type with AvatarNoneModel
image: any;
}>((type) => ({
type: type,
image: require(
`../../../../../../Assets/avatarEditorThumbnails/hair/hairstyles/Hair_${type}.png`,
`../../../../../../Assets/avatarEditorThumbnails/hair/hairstyles/${type}.png`,
),
}));
hairThumbnails.unshift(noneThumbnail);

const beardThumbnailImages = require.context(
"../../../../../../Assets/avatarEditorThumbnails/hair/beards",
);
const beardThumbnailImageList = beardThumbnailImages
.keys()
.map((key) => beardThumbnailImages(key));
const beardThumbnails = Object.values(OAvatarBeardModels).map<{
type: AvatarBeardModels; // use union type with AvatarNoneModel
image: any;
}>((type) => ({
type: type,
image: require(
`../../../../../../Assets/avatarEditorThumbnails/hair/beards/${type}.png`,
),
}));
beardThumbnails.unshift(noneThumbnail);

export default function AvatarEditorHairCategory(
props: AvatarEditorCategoryContentProps,
Expand Down Expand Up @@ -51,14 +70,16 @@ export default function AvatarEditorHairCategory(
<h1 className="text-2xl font-bold">{translate("beardsTitle")}</h1>
</div>
<TileGridLayout
tileContents={beardThumbnailImageList.map((image, index) => ({
tileContents={beardThumbnails.map((thumbnail, index) => ({
id: index,
image,
image: thumbnail.image,
}))}
columns={5}
mobileColumns={3}
onTileClick={(id) => {
console.log(id);
props.controller.onAvatarConfigChanged({
beard: beardThumbnails[id].type,
});
}}
/>
<div className="w-full p-2 m-2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,25 @@ export default class AvatarEditorPreviewModelPresenter
constructor(private viewModel: AvatarEditorPreviewModelViewModel) {}

onAvatarConfigChanged(newAvatarConfig: AvatarConfigTO): void {
this.viewModel.avatarConfig.Value = newAvatarConfig;
if (!this.viewModel.currentAvatarConfig.Value) {
this.viewModel.avatarConfigDiff.Value = newAvatarConfig;
this.viewModel.currentAvatarConfig.Value = newAvatarConfig;
return;
}

console.log("Avatar config changed", newAvatarConfig);
// compute difference between new and current avatar config
let difference: any = {}; // actually Partial<AvatarConfigTO>, but TS doesn't like that
for (const key in newAvatarConfig) {
const typedKey = key as keyof AvatarConfigTO;
if (
newAvatarConfig[typedKey] !==
this.viewModel.currentAvatarConfig.Value[typedKey]
)
difference[typedKey] = newAvatarConfig[typedKey];
}

// TODO: Implement the logic to update the avatar model based on the new config
this.viewModel.avatarConfigDiff.Value =
difference as Partial<AvatarConfigTO>;
this.viewModel.currentAvatarConfig.Value = newAvatarConfig;
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import AvatarEditorPreviewModelViewModel from "./AvatarEditorPreviewModelViewModel";
import { Mesh } from "@babylonjs/core";
import { ISceneLoaderAsyncResult, Mesh, TransformNode } from "@babylonjs/core";
import IScenePresenter from "../../../Babylon/SceneManagement/IScenePresenter";
import CoreDIContainer from "~DependencyInjection/CoreDIContainer";
import SCENE_TYPES, {
ScenePresenterFactory,
} from "~DependencyInjection/Scenes/SCENE_TYPES";
import AvatarEditorPreviewSceneDefinition from "../AvatarEditorPreviewSceneDefinition";
import { AvatarNoneModel } from "src/Components/Core/Domain/AvatarModels/AvatarModelTypes";

const modelLink = require("../../../../../../Assets/3dModels/sharedModels/3DModel_Avatar_male.glb");
const baseModelLink = require("../../../../../../Assets/3dModels/sharedModels/avatar/avatarSkeleton.glb");

export default class AvatarEditorPreviewModelView {
private scenePresenter: IScenePresenter;
Expand All @@ -16,12 +17,80 @@ export default class AvatarEditorPreviewModelView {
this.scenePresenter = CoreDIContainer.get<ScenePresenterFactory>(
SCENE_TYPES.ScenePresenterFactory,
)(AvatarEditorPreviewSceneDefinition);

viewModel.avatarConfigDiff.subscribe(() => {
this.onAvatarConfigChanged();
});
}

async asyncSetup(): Promise<void> {
const result = await this.scenePresenter.loadGLTFModel(modelLink);
const result = await this.scenePresenter.loadGLTFModel(baseModelLink);
this.viewModel.baseModelMeshes = result.meshes as Mesh[];

this.viewModel.baseModelMeshes[0].position.y = -1;

this.viewModel.hairAnchorNode = this.getAnchorNodeByName(
result,
"anker_hair",
)!;
this.viewModel.beardAnchorNode = this.getAnchorNodeByName(
result,
"anker_beard",
)!;
}

private getAnchorNodeByName(
loadingResults: ISceneLoaderAsyncResult,
name: string,
): TransformNode | undefined {
return loadingResults.transformNodes.find((mesh) => mesh.name === name);
}

private onAvatarConfigChanged(): void {
if (this.viewModel.avatarConfigDiff.Value.beard)
this.updateModel(
this.viewModel.avatarConfigDiff.Value.beard,
"beards",
this.viewModel.beardMeshes,
this.viewModel.beardAnchorNode,
);
if (this.viewModel.avatarConfigDiff.Value.hair)
this.updateModel(
this.viewModel.avatarConfigDiff.Value.hair,
"hair",
this.viewModel.hairMeshes,
this.viewModel.hairAnchorNode,
);
}

private async updateModel<T>(
newModel: T,
modelFolder: string,
modelMap: Map<T, Mesh[]>,
anchorNode: TransformNode,
): Promise<void> {
// hide all meshes and return if no model is selected
if (newModel === AvatarNoneModel.None) {
modelMap.forEach((meshes) => {
meshes.forEach((mesh) => (mesh.isVisible = false));
});
return;
}

// load model if not already loaded
if (!modelMap.has(newModel)) {
const result = await this.scenePresenter.loadGLTFModel(
require(
`../../../../../../Assets/3dModels/sharedModels/avatar/${modelFolder}/${newModel}.glb`,
),
);
modelMap.set(newModel, result.meshes as Mesh[]);
result.meshes[0].parent = anchorNode;
}

// set all meshes to invisible except the new model
modelMap.forEach((meshes, type) => {
const newIsVisible = type === newModel;
meshes.forEach((mesh) => (mesh.isVisible = newIsVisible));
});
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
import { Mesh } from "@babylonjs/core";
import AvatarConfigTO from "src/Components/Core/Application/DataTransferObjects/AvatarConfigTO";
import Observable from "src/Lib/Observable";
import { Mesh, TransformNode } from "@babylonjs/core";
import AvatarConfigTO from "../../../../../Core/Application/DataTransferObjects/AvatarConfigTO";
import {
AvatarBeardModels,
AvatarHairModels,
} from "../../../../../Core/Domain/AvatarModels/AvatarModelTypes";
import Observable from "../../../../../../Lib/Observable";

export default class AvatarEditorPreviewModelViewModel {
baseModelMeshes: Mesh[];

avatarConfig: Observable<AvatarConfigTO> = new Observable<AvatarConfigTO>();
// anchor nodes
hairAnchorNode: TransformNode;
beardAnchorNode: TransformNode;

// mesh maps
hairMeshes: Map<AvatarHairModels, Mesh[]> = new Map();
beardMeshes: Map<AvatarBeardModels, Mesh[]> = new Map();

currentAvatarConfig: Observable<AvatarConfigTO> =
new Observable<AvatarConfigTO>();
avatarConfigDiff: Observable<Partial<AvatarConfigTO>> = new Observable<
Partial<AvatarConfigTO>
>();
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import CoreDIContainer from "../../../../../../src/Components/Core/DependencyInj
import AvatarConfigTO from "../../../../Core/Application/DataTransferObjects/AvatarConfigTO";
import ILoggerPort from "../../../../Core/Application/Ports/Interfaces/ILoggerPort";
import CORE_TYPES from "../../../../Core/DependencyInjection/CoreTypes";
import { AvatarHairModels } from "../../../../Core/Domain/AvatarModels/AvatarModelTypes";
import { OAvatarHairModels } from "../../../../Core/Domain/AvatarModels/AvatarModelTypes";
import AvatarEntity from "../../../../Core/Domain/Entities/AvatarEntity";
import IEntityContainer from "../../../../Core/Domain/EntityContainer/IEntityContainer";
import { mock } from "jest-mock-extended";
Expand Down Expand Up @@ -66,7 +66,7 @@ describe("UpdateAvatarConfigUseCase", () => {
const userDataEntity = {
avatar: {
roundness: 0,
hair: AvatarHairModels.PLACEHOLDER,
hair: OAvatarHairModels.MediumPonytail,
} as AvatarEntity,
};

Expand All @@ -75,7 +75,9 @@ describe("UpdateAvatarConfigUseCase", () => {
systemUnderTest.execute(avatarConfig);

expect(userDataEntity.avatar.roundness).toEqual(0.5);
expect(userDataEntity.avatar.hair).toEqual(AvatarHairModels.PLACEHOLDER);
expect(userDataEntity.avatar.hair).toEqual(
OAvatarHairModels.MediumPonytail,
);
});

test("calls onAvatarConfigChanged on avatar port", () => {
Expand All @@ -85,7 +87,7 @@ describe("UpdateAvatarConfigUseCase", () => {
const userDataEntity = {
avatar: {
roundness: 0,
hair: AvatarHairModels.PLACEHOLDER,
hair: OAvatarHairModels.MediumPonytail,
} as AvatarEntity,
};

Expand Down

0 comments on commit 45d853a

Please sign in to comment.