feat: add dedicated Memento avatar
This commit is contained in:
parent
175691ab32
commit
be77e778d1
@ -66,7 +66,17 @@
|
||||
"required": ["type", "width", "height", "depth", "roundness"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": ["sphere", "mickey", "cursor", "cube", "capsule", "cylinder", "cone", "diamond"]
|
||||
"enum": [
|
||||
"sphere",
|
||||
"mickey",
|
||||
"cursor",
|
||||
"cube",
|
||||
"capsule",
|
||||
"cylinder",
|
||||
"cone",
|
||||
"diamond",
|
||||
"ghost"
|
||||
]
|
||||
},
|
||||
"width": { "$ref": "#/$defs/dimension" },
|
||||
"height": { "$ref": "#/$defs/dimension" },
|
||||
@ -122,6 +132,7 @@
|
||||
"additionalProperties": false,
|
||||
"required": ["surface", "position", "rotation"],
|
||||
"properties": {
|
||||
"layer": { "enum": ["auto", "front", "back"] },
|
||||
"surface": { "$ref": "#/$defs/nodeSurface" },
|
||||
"position": { "$ref": "#/$defs/position" },
|
||||
"rotation": { "$ref": "#/$defs/rotation" }
|
||||
|
||||
@ -43,11 +43,12 @@ export type SurfaceDefinition<TType extends SurfaceType = SurfaceType> = {
|
||||
baseRoundness?: number
|
||||
}
|
||||
|
||||
export type BodyNodeSurfaceType = Exclude<SurfaceType, 'mickey' | 'cursor'>
|
||||
export type BodyNodeSurfaceType = Exclude<SurfaceType, 'mickey' | 'cursor' | 'ghost'>
|
||||
export type PrimarySurfaceDefinition = SurfaceDefinition<SurfaceType>
|
||||
export type BodyNodeSurfaceDefinition = SurfaceDefinition<BodyNodeSurfaceType>
|
||||
|
||||
export type AvatarBodyNodeDefinition = {
|
||||
layer?: 'auto' | 'front' | 'back'
|
||||
surface: BodyNodeSurfaceDefinition
|
||||
position: [number, number, number]
|
||||
rotation: [number, number, number]
|
||||
|
||||
@ -1117,6 +1117,87 @@ const projectedCapsulePath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
||||
return smoothHullPath(convexHull([...ellipsePoints(top), ...ellipsePoints(bottom)]))
|
||||
}
|
||||
|
||||
const projectedGhostPath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
||||
const halfWidth = surface.width / 2
|
||||
const halfHeight = surface.height / 2
|
||||
const notchCenter = surface.width / 6
|
||||
const notchHalfWidth = surface.width * 0.018
|
||||
const sideStartY = -halfHeight * 0.08
|
||||
const lobeStartY = halfHeight * 0.5
|
||||
const notchTopY = halfHeight * 0.4
|
||||
const bottomY = halfHeight
|
||||
|
||||
const point = (x: number, y: number) => projectLocalPoint(pose, [x, y, 0])
|
||||
const format = ([x, y]: Point3) => `${x.toFixed(2)} ${y.toFixed(2)}`
|
||||
const move = (x: number, y: number) => `M${format(point(x, y))}`
|
||||
const line = (x: number, y: number) => `L${format(point(x, y))}`
|
||||
const cubicTo = (
|
||||
firstX: number,
|
||||
firstY: number,
|
||||
secondX: number,
|
||||
secondY: number,
|
||||
endX: number,
|
||||
endY: number
|
||||
) =>
|
||||
`C${format(point(firstX, firstY))} ${format(point(secondX, secondY))} ${format(point(endX, endY))}`
|
||||
|
||||
return [
|
||||
move(-halfWidth, sideStartY),
|
||||
cubicTo(-halfWidth, -halfHeight * 0.62, -halfWidth * 0.56, -halfHeight, 0, -halfHeight),
|
||||
cubicTo(halfWidth * 0.56, -halfHeight, halfWidth, -halfHeight * 0.62, halfWidth, sideStartY),
|
||||
line(halfWidth, lobeStartY),
|
||||
cubicTo(halfWidth, bottomY * 0.82, halfWidth * 0.82, bottomY, halfWidth * 0.66, bottomY),
|
||||
cubicTo(
|
||||
halfWidth * 0.49,
|
||||
bottomY,
|
||||
notchCenter + notchHalfWidth,
|
||||
bottomY * 0.82,
|
||||
notchCenter + notchHalfWidth,
|
||||
lobeStartY
|
||||
),
|
||||
line(notchCenter + notchHalfWidth, notchTopY),
|
||||
cubicTo(
|
||||
notchCenter + notchHalfWidth,
|
||||
notchTopY - 6,
|
||||
notchCenter - notchHalfWidth,
|
||||
notchTopY - 6,
|
||||
notchCenter - notchHalfWidth,
|
||||
notchTopY
|
||||
),
|
||||
line(notchCenter - notchHalfWidth, lobeStartY),
|
||||
cubicTo(notchCenter - notchHalfWidth, bottomY * 0.82, halfWidth * 0.17, bottomY, 0, bottomY),
|
||||
cubicTo(
|
||||
-halfWidth * 0.17,
|
||||
bottomY,
|
||||
-notchCenter + notchHalfWidth,
|
||||
bottomY * 0.82,
|
||||
-notchCenter + notchHalfWidth,
|
||||
lobeStartY
|
||||
),
|
||||
line(-notchCenter + notchHalfWidth, notchTopY),
|
||||
cubicTo(
|
||||
-notchCenter + notchHalfWidth,
|
||||
notchTopY - 6,
|
||||
-notchCenter - notchHalfWidth,
|
||||
notchTopY - 6,
|
||||
-notchCenter - notchHalfWidth,
|
||||
notchTopY
|
||||
),
|
||||
line(-notchCenter - notchHalfWidth, lobeStartY),
|
||||
cubicTo(
|
||||
-notchCenter - notchHalfWidth,
|
||||
bottomY * 0.82,
|
||||
-halfWidth * 0.49,
|
||||
bottomY,
|
||||
-halfWidth * 0.66,
|
||||
bottomY
|
||||
),
|
||||
cubicTo(-halfWidth * 0.82, bottomY, -halfWidth, bottomY * 0.82, -halfWidth, lobeStartY),
|
||||
line(-halfWidth, sideStartY),
|
||||
'Z',
|
||||
].join('')
|
||||
}
|
||||
|
||||
const headPath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
||||
if (surface.type === 'sphere' || surface.type === 'mickey') {
|
||||
const exactPath = projectedEllipsoidPath(pose, surface)
|
||||
@ -1133,6 +1214,7 @@ const headPath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
||||
if (surface.type === 'cone') return projectedConePath(pose, surface)
|
||||
if (surface.type === 'cube') return projectedCubePath(pose, surface)
|
||||
if (surface.type === 'diamond') return projectedDiamondPath(pose, surface)
|
||||
if (surface.type === 'ghost') return projectedGhostPath(pose, surface)
|
||||
|
||||
const key = surfaceCacheKey(surface)
|
||||
let localSamples = headSamplesCache.get(key)
|
||||
@ -1222,7 +1304,10 @@ const accessoryLayers = (pose: AvatarPose, nodes: BodyNode[]) => {
|
||||
id: node.id,
|
||||
path: accessoryPath(pose, node),
|
||||
depth,
|
||||
front: depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO,
|
||||
front:
|
||||
node.layer === 'front' ||
|
||||
(node.layer !== 'back' &&
|
||||
depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO),
|
||||
}
|
||||
})
|
||||
.sort((left, right) => left.depth - right.depth)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { Point3 } from './geometry'
|
||||
|
||||
export type SurfaceType =
|
||||
'sphere' | 'mickey' | 'cursor' | 'cube' | 'capsule' | 'cylinder' | 'cone' | 'diamond'
|
||||
'sphere' | 'mickey' | 'cursor' | 'cube' | 'capsule' | 'cylinder' | 'cone' | 'diamond' | 'ghost'
|
||||
|
||||
export type SurfaceConfig = {
|
||||
type: SurfaceType
|
||||
@ -44,6 +44,7 @@ export const surfacePresets: Record<SurfaceType, SurfaceConfig> = {
|
||||
baseRoundness: 0.45,
|
||||
},
|
||||
diamond: { type: 'diamond', width: 235, height: 260, depth: 215, roundness: 0 },
|
||||
ghost: { type: 'ghost', width: 250, height: 245, depth: 190, roundness: 1 },
|
||||
}
|
||||
|
||||
export const surfaceLabels: Record<SurfaceType, string> = {
|
||||
@ -55,6 +56,7 @@ export const surfaceLabels: Record<SurfaceType, string> = {
|
||||
cylinder: 'Cylindre',
|
||||
cone: 'Cône',
|
||||
diamond: 'Diamant',
|
||||
ghost: 'Ghost',
|
||||
}
|
||||
|
||||
const signedPower = (value: number, exponent: number) =>
|
||||
@ -336,6 +338,7 @@ export const surfacePointAt = (
|
||||
case 'diamond':
|
||||
return diamond(config, longitude, latitude)
|
||||
case 'capsule':
|
||||
case 'ghost':
|
||||
return capsule(config, longitude, latitude)
|
||||
case 'cone': {
|
||||
const progress = (latitude + Math.PI / 2) / Math.PI
|
||||
@ -534,7 +537,8 @@ export const surfaceFrontSampleAt = (
|
||||
case 'cube':
|
||||
return lpFrontSample(config, x, y, cubeExponent(config), cubeNormal)
|
||||
|
||||
case 'capsule': {
|
||||
case 'capsule':
|
||||
case 'ghost': {
|
||||
const capRadiusY = Math.min(radiusX, radiusY)
|
||||
const straightHalf = Math.max(0, radiusY - capRadiusY)
|
||||
const capCenterY = y < -straightHalf ? -straightHalf : y > straightHalf ? straightHalf : y
|
||||
|
||||
@ -4,6 +4,7 @@ import { surfacePresets } from './surfaces'
|
||||
import type { Expression } from './geometry'
|
||||
import { isBodyMotion, isEyeMotion } from './ambientMotion'
|
||||
import {
|
||||
createInitialSequences,
|
||||
normalizeSequencesForExpressions,
|
||||
parseSequences,
|
||||
type AvatarSequence,
|
||||
@ -45,7 +46,25 @@ export type AvatarEyeDefaults = Pick<
|
||||
| 'leftAngle'
|
||||
| 'rightAngle'
|
||||
>
|
||||
export const defaultAvatarColors: AvatarColors = { body: '#5b7fe5', eyes: '#111316' }
|
||||
export const deslopAccentColors = {
|
||||
red: '#ff5558',
|
||||
orange: '#ff9d45',
|
||||
yellow: '#ffda1a',
|
||||
green: '#59e075',
|
||||
mint: '#1adec9',
|
||||
teal: '#1ad7e3',
|
||||
cyan: '#50d7fe',
|
||||
blue: '#1a9cff',
|
||||
indigo: '#7c89ff',
|
||||
purple: '#df48f3',
|
||||
pink: '#ff4b6f',
|
||||
brown: '#be9675',
|
||||
} as const
|
||||
|
||||
export const defaultAvatarColors: AvatarColors = {
|
||||
body: deslopAccentColors.indigo,
|
||||
eyes: deslopAccentColors.indigo,
|
||||
}
|
||||
export const defaultAvatarRenderStyle: AvatarRenderStyle = { type: 'vector' }
|
||||
export const defaultPixelRenderStyle: PixelRenderStyle = {
|
||||
type: 'pixel',
|
||||
@ -79,6 +98,336 @@ const parseColors = (value: unknown): AvatarColors => {
|
||||
}
|
||||
}
|
||||
|
||||
const bundledAvatarColorMigrations: Record<string, { from: AvatarColors; to: AvatarColors }> = {
|
||||
'primitive-cube': {
|
||||
from: { body: deslopAccentColors.orange, eyes: deslopAccentColors.orange },
|
||||
to: { body: deslopAccentColors.brown, eyes: deslopAccentColors.brown },
|
||||
},
|
||||
'primitive-ghost': {
|
||||
from: { body: deslopAccentColors.blue, eyes: deslopAccentColors.blue },
|
||||
to: { body: deslopAccentColors.orange, eyes: deslopAccentColors.orange },
|
||||
},
|
||||
strobi: {
|
||||
from: { body: '#5b7fe5', eyes: '#111316' },
|
||||
to: { body: deslopAccentColors.indigo, eyes: deslopAccentColors.cyan },
|
||||
},
|
||||
'avatar-4fe2d1bd-cf46-4e5e-a62d-d6b60be519ed': {
|
||||
from: { body: '#e6855c', eyes: '#ffffff' },
|
||||
to: { body: deslopAccentColors.orange, eyes: deslopAccentColors.yellow },
|
||||
},
|
||||
'avatar-295e74a7-5d70-4d61-83d4-7beebb22bdd8': {
|
||||
from: { body: '#ffcf24', eyes: '#000000' },
|
||||
to: { body: deslopAccentColors.yellow, eyes: deslopAccentColors.orange },
|
||||
},
|
||||
'avatar-1786600724626': {
|
||||
from: { body: '#55b6c3', eyes: '#111316' },
|
||||
to: { body: deslopAccentColors.teal, eyes: deslopAccentColors.mint },
|
||||
},
|
||||
'avatar-7874f78a-93ec-4536-a3b6-bb53ed744efd': {
|
||||
from: { body: '#000000', eyes: '#ffffff' },
|
||||
to: { body: deslopAccentColors.purple, eyes: deslopAccentColors.pink },
|
||||
},
|
||||
'avatar-1b2ee9c6-a6c5-4054-87e7-fec24f285269': {
|
||||
from: { body: '#e69a5c', eyes: '#111316' },
|
||||
to: { body: deslopAccentColors.brown, eyes: deslopAccentColors.orange },
|
||||
},
|
||||
'avatar-b6362e59-81a3-4334-a399-a721b23cf553': {
|
||||
from: { body: '#ffc2e9', eyes: '#3e4e65' },
|
||||
to: { body: deslopAccentColors.pink, eyes: deslopAccentColors.purple },
|
||||
},
|
||||
'avatar-fafdaf4d-2071-41d6-9d42-7d34670956f0': {
|
||||
from: { body: '#c9cbcf', eyes: '#111316' },
|
||||
to: { body: deslopAccentColors.cyan, eyes: deslopAccentColors.blue },
|
||||
},
|
||||
'avatar-2739f2c2-a5b4-45d9-8915-c9d6101d4d3b': {
|
||||
from: { body: '#e65c5c', eyes: '#111316' },
|
||||
to: { body: deslopAccentColors.red, eyes: deslopAccentColors.orange },
|
||||
},
|
||||
'avatar-4b9ea0c1-286f-4aa1-b053-61fcc416ba7e': {
|
||||
from: { body: '#dbe2f5', eyes: '#111316' },
|
||||
to: { body: deslopAccentColors.blue, eyes: deslopAccentColors.indigo },
|
||||
},
|
||||
}
|
||||
|
||||
export const simpleBundledAvatarIds = [
|
||||
'avatar-4b9ea0c1-286f-4aa1-b053-61fcc416ba7e',
|
||||
'avatar-2739f2c2-a5b4-45d9-8915-c9d6101d4d3b',
|
||||
'avatar-1786600724626',
|
||||
'avatar-295e74a7-5d70-4d61-83d4-7beebb22bdd8',
|
||||
'primitive-sphere',
|
||||
'primitive-cube',
|
||||
'primitive-capsule',
|
||||
'primitive-cylinder',
|
||||
'primitive-cone',
|
||||
'primitive-diamond',
|
||||
'primitive-ghost',
|
||||
'primitive-willy',
|
||||
] as const
|
||||
|
||||
const simpleBundledAvatarOrder = new Map(simpleBundledAvatarIds.map((id, index) => [id, index]))
|
||||
|
||||
const retiredBundledAvatarIds = new Set([
|
||||
'strobi',
|
||||
'avatar-4fe2d1bd-cf46-4e5e-a62d-d6b60be519ed',
|
||||
'avatar-7874f78a-93ec-4536-a3b6-bb53ed744efd',
|
||||
'avatar-1b2ee9c6-a6c5-4054-87e7-fec24f285269',
|
||||
'avatar-b6362e59-81a3-4334-a399-a721b23cf553',
|
||||
'avatar-fafdaf4d-2071-41d6-9d42-7d34670956f0',
|
||||
])
|
||||
|
||||
const primitiveBundledAvatarSpecs = [
|
||||
['primitive-sphere', 'Sphere', 'sphere', deslopAccentColors.indigo, 1],
|
||||
['primitive-cube', 'Cube', 'cube', deslopAccentColors.brown, 0.78],
|
||||
['primitive-capsule', 'Capsule', 'capsule', deslopAccentColors.green, 1],
|
||||
['primitive-cylinder', 'Cylinder', 'cylinder', deslopAccentColors.mint, 0.82],
|
||||
['primitive-cone', 'Cone', 'cone', deslopAccentColors.purple, 0.82],
|
||||
['primitive-diamond', 'Diamond', 'diamond', deslopAccentColors.pink, 1],
|
||||
['primitive-ghost', 'Memento', 'ghost', deslopAccentColors.orange, 1],
|
||||
] as const
|
||||
|
||||
const mementoBehavior = (behavior?: AvatarBehaviorLibrary): AvatarBehaviorLibrary => ({
|
||||
expressions: (behavior?.expressions ?? initialExpressions).map(expression => {
|
||||
const fixedColorExpression = { ...expression }
|
||||
delete fixedColorExpression.bodyColor
|
||||
delete fixedColorExpression.eyeColor
|
||||
return fixedColorExpression
|
||||
}),
|
||||
sequences: (behavior?.sequences ?? createInitialSequences()).map(sequence => ({
|
||||
...sequence,
|
||||
steps: sequence.steps.map(step => ({ ...step })),
|
||||
blink: { ...sequence.blink },
|
||||
})),
|
||||
})
|
||||
|
||||
const primitiveBundledAvatars: StudioAvatar[] = [
|
||||
...primitiveBundledAvatarSpecs.map(([id, name, surface, color, scale]) => ({
|
||||
id,
|
||||
name,
|
||||
body: {
|
||||
primary: {
|
||||
...surfacePresets[surface],
|
||||
width: surfacePresets[surface].width * scale,
|
||||
height: surfacePresets[surface].height * scale,
|
||||
depth: surfacePresets[surface].depth * scale,
|
||||
},
|
||||
nodes: [],
|
||||
},
|
||||
colors: { body: color, eyes: color },
|
||||
eyes:
|
||||
surface === 'ghost'
|
||||
? {
|
||||
...defaultAvatarEyes,
|
||||
positionYLeft: defaultAvatarEyes.positionYLeft - 20,
|
||||
positionYRight: defaultAvatarEyes.positionYRight - 20,
|
||||
}
|
||||
: { ...defaultAvatarEyes },
|
||||
renderStyle: { ...defaultAvatarRenderStyle },
|
||||
...(surface === 'ghost' ? { behavior: mementoBehavior() } : {}),
|
||||
})),
|
||||
{
|
||||
id: 'primitive-willy',
|
||||
name: 'Willy',
|
||||
body: {
|
||||
primary: {
|
||||
...surfacePresets.capsule,
|
||||
width: 122,
|
||||
height: 190,
|
||||
depth: 115,
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 'willy-left-sphere',
|
||||
name: 'Left cap',
|
||||
surface: {
|
||||
...surfacePresets.cone,
|
||||
width: 112,
|
||||
height: 58,
|
||||
depth: 100,
|
||||
morphRoundness: 1.2,
|
||||
tipRoundness: 2,
|
||||
baseRoundness: 2,
|
||||
},
|
||||
position: [-47, 95, -22],
|
||||
rotation: [0, 0, 0],
|
||||
},
|
||||
{
|
||||
id: 'willy-right-sphere',
|
||||
name: 'Right cap',
|
||||
surface: {
|
||||
...surfacePresets.cone,
|
||||
width: 112,
|
||||
height: 58,
|
||||
depth: 100,
|
||||
morphRoundness: 1.2,
|
||||
tipRoundness: 2,
|
||||
baseRoundness: 2,
|
||||
},
|
||||
position: [47, 95, -22],
|
||||
rotation: [0, 0, 0],
|
||||
},
|
||||
{
|
||||
id: 'willy-cap',
|
||||
name: 'Rounded cap',
|
||||
layer: 'front',
|
||||
surface: {
|
||||
...surfacePresets.cone,
|
||||
width: 178,
|
||||
height: 86,
|
||||
depth: 156,
|
||||
morphRoundness: 1.2,
|
||||
tipRoundness: 2,
|
||||
baseRoundness: 2,
|
||||
},
|
||||
position: [0, -94, 0],
|
||||
rotation: [0, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
colors: { body: deslopAccentColors.red, eyes: deslopAccentColors.red },
|
||||
eyes: { ...defaultAvatarEyes },
|
||||
renderStyle: { ...defaultAvatarRenderStyle },
|
||||
},
|
||||
]
|
||||
|
||||
const originalSimpleBundledAvatarIds = new Set<string>(simpleBundledAvatarIds.slice(0, 4))
|
||||
|
||||
const clonePrimitiveBundledAvatar = (avatar: StudioAvatar): StudioAvatar => ({
|
||||
...avatar,
|
||||
body: {
|
||||
primary: { ...avatar.body.primary },
|
||||
nodes: avatar.body.nodes.map(node => ({
|
||||
...node,
|
||||
surface: { ...node.surface },
|
||||
position: [...node.position],
|
||||
rotation: [...node.rotation],
|
||||
})),
|
||||
},
|
||||
colors: { ...avatar.colors },
|
||||
eyes: { ...avatar.eyes },
|
||||
renderStyle: { ...avatar.renderStyle },
|
||||
...(avatar.behavior ? { behavior: mementoBehavior(avatar.behavior) } : {}),
|
||||
})
|
||||
|
||||
export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLibrary => {
|
||||
if (!library.avatars.some(avatar => originalSimpleBundledAvatarIds.has(avatar.id))) return library
|
||||
const resizedAvatars = library.avatars.map(avatar => {
|
||||
const bundled = primitiveBundledAvatars.find(candidate => candidate.id === avatar.id)
|
||||
if (!bundled) return avatar
|
||||
const migratedNodes = avatar.body.nodes.map(node => {
|
||||
const bundledNode = bundled.body.nodes.find(candidate => candidate.id === node.id)
|
||||
if (!bundledNode || avatar.id !== 'primitive-willy') return node
|
||||
const isLegacyLowerSphere =
|
||||
(node.id === 'willy-left-sphere' || node.id === 'willy-right-sphere') &&
|
||||
node.surface.type === 'sphere' &&
|
||||
node.surface.width === 108 &&
|
||||
node.surface.height === 96 &&
|
||||
node.surface.depth === 100
|
||||
const isLegacyTopCap =
|
||||
node.id === 'willy-cap' &&
|
||||
((node.surface.width === 150 &&
|
||||
node.surface.height === 72 &&
|
||||
node.surface.depth === 135 &&
|
||||
node.position[2] === 12) ||
|
||||
(node.surface.width === 166 && node.surface.height === 80 && node.surface.depth === 148))
|
||||
if (!isLegacyLowerSphere && !isLegacyTopCap) return node
|
||||
return {
|
||||
...bundledNode,
|
||||
surface: { ...bundledNode.surface },
|
||||
position: [...bundledNode.position] as typeof bundledNode.position,
|
||||
rotation: [...bundledNode.rotation] as typeof bundledNode.rotation,
|
||||
}
|
||||
})
|
||||
const existingNodeIds = new Set(migratedNodes.map(node => node.id))
|
||||
const missingBundledNodes = bundled.body.nodes.filter(node => !existingNodeIds.has(node.id))
|
||||
const avatarWithCurrentNodes = missingBundledNodes.length
|
||||
? {
|
||||
...avatar,
|
||||
body: {
|
||||
...avatar.body,
|
||||
nodes: [
|
||||
...migratedNodes,
|
||||
...missingBundledNodes.map(node => ({
|
||||
...node,
|
||||
surface: { ...node.surface },
|
||||
position: [...node.position] as typeof node.position,
|
||||
rotation: [...node.rotation] as typeof node.rotation,
|
||||
})),
|
||||
],
|
||||
},
|
||||
}
|
||||
: migratedNodes.some((node, index) => node !== avatar.body.nodes[index])
|
||||
? { ...avatar, body: { ...avatar.body, nodes: migratedNodes } }
|
||||
: avatar
|
||||
const avatarWithCurrentName =
|
||||
avatarWithCurrentNodes.id === 'primitive-ghost' && avatarWithCurrentNodes.name === 'Ghost'
|
||||
? { ...avatarWithCurrentNodes, name: bundled.name }
|
||||
: avatarWithCurrentNodes
|
||||
const avatarWithCurrentEyes =
|
||||
avatarWithCurrentName.id === 'primitive-ghost' &&
|
||||
[defaultAvatarEyes.positionYLeft, defaultAvatarEyes.positionYLeft - 12].includes(
|
||||
avatarWithCurrentName.eyes.positionYLeft
|
||||
) &&
|
||||
[defaultAvatarEyes.positionYRight, defaultAvatarEyes.positionYRight - 12].includes(
|
||||
avatarWithCurrentName.eyes.positionYRight
|
||||
)
|
||||
? { ...avatarWithCurrentName, eyes: { ...bundled.eyes } }
|
||||
: avatarWithCurrentName
|
||||
const avatarWithFixedColorBehavior =
|
||||
avatarWithCurrentEyes.id === 'primitive-ghost'
|
||||
? { ...avatarWithCurrentEyes, behavior: mementoBehavior(avatarWithCurrentEyes.behavior) }
|
||||
: avatarWithCurrentEyes
|
||||
const original = surfacePresets[bundled.body.primary.type]
|
||||
const primary = avatarWithFixedColorBehavior.body.primary
|
||||
const stillUsesOriginalSize =
|
||||
primary.type === original.type &&
|
||||
primary.width === original.width &&
|
||||
primary.height === original.height &&
|
||||
primary.depth === original.depth
|
||||
const stillUsesPreviousWillyBody =
|
||||
avatar.id === 'primitive-willy' &&
|
||||
primary.type === 'capsule' &&
|
||||
primary.width === 122 &&
|
||||
primary.height === 210 &&
|
||||
primary.depth === 115
|
||||
if (!stillUsesOriginalSize && !stillUsesPreviousWillyBody) return avatarWithFixedColorBehavior
|
||||
return {
|
||||
...avatarWithFixedColorBehavior,
|
||||
body: { ...avatarWithFixedColorBehavior.body, primary: { ...bundled.body.primary } },
|
||||
}
|
||||
})
|
||||
const existingIds = new Set(resizedAvatars.map(avatar => avatar.id))
|
||||
const avatars = [
|
||||
...resizedAvatars,
|
||||
...primitiveBundledAvatars
|
||||
.filter(avatar => !existingIds.has(avatar.id))
|
||||
.map(clonePrimitiveBundledAvatar),
|
||||
].sort((left, right) => {
|
||||
const leftOrder = simpleBundledAvatarOrder.get(
|
||||
left.id as (typeof simpleBundledAvatarIds)[number]
|
||||
)
|
||||
const rightOrder = simpleBundledAvatarOrder.get(
|
||||
right.id as (typeof simpleBundledAvatarIds)[number]
|
||||
)
|
||||
if (leftOrder === undefined && rightOrder === undefined) return 0
|
||||
if (leftOrder === undefined) return 1
|
||||
if (rightOrder === undefined) return -1
|
||||
return leftOrder - rightOrder
|
||||
})
|
||||
return { ...library, avatars }
|
||||
}
|
||||
|
||||
export const migrateBundledAvatarColors = (id: string, colors: AvatarColors): AvatarColors => {
|
||||
const migration = bundledAvatarColorMigrations[id]
|
||||
if (
|
||||
!migration ||
|
||||
colors.body.toLowerCase() !== migration.from.body.toLowerCase() ||
|
||||
colors.eyes.toLowerCase() !== migration.from.eyes.toLowerCase()
|
||||
) {
|
||||
return colors
|
||||
}
|
||||
return { ...migration.to }
|
||||
}
|
||||
|
||||
const finiteBounded = (value: unknown, fallback: number, min: number, max: number) =>
|
||||
typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.min(max, Math.max(min, value))
|
||||
@ -263,26 +612,45 @@ export const parseAvatarLibrary = (
|
||||
const parsed = value as Partial<AvatarLibrary> | null
|
||||
if (!parsed || !Array.isArray(parsed.avatars) || !parsed.avatars.length) return fallback
|
||||
const seenIds = new Set<string>()
|
||||
const avatars = parsed.avatars
|
||||
const parsedAvatars = parsed.avatars
|
||||
.filter(avatar => {
|
||||
if (!avatar || typeof avatar.id !== 'string' || typeof avatar.name !== 'string')
|
||||
return false
|
||||
if (retiredBundledAvatarIds.has(avatar.id)) return false
|
||||
if (seenIds.has(avatar.id)) return false
|
||||
seenIds.add(avatar.id)
|
||||
return true
|
||||
})
|
||||
.map(avatar => {
|
||||
const behavior = parseAvatarBehavior(avatar.behavior, baseBehavior)
|
||||
const migratedColors = migrateBundledAvatarColors(avatar.id, parseColors(avatar.colors))
|
||||
const colors = simpleBundledAvatarOrder.has(
|
||||
avatar.id as (typeof simpleBundledAvatarIds)[number]
|
||||
)
|
||||
? { body: migratedColors.body, eyes: migratedColors.body }
|
||||
: migratedColors
|
||||
return {
|
||||
id: avatar.id,
|
||||
name: avatar.name,
|
||||
body: parseAvatarBody(avatar.body, surfacePresets.sphere),
|
||||
colors: parseColors(avatar.colors),
|
||||
colors,
|
||||
eyes: parseAvatarEyeDefaults(avatar.eyes),
|
||||
renderStyle: parseAvatarRenderStyle(avatar.renderStyle),
|
||||
...(behavior ? { behavior } : {}),
|
||||
}
|
||||
})
|
||||
const avatars = parsedAvatars.sort((left, right) => {
|
||||
const leftOrder = simpleBundledAvatarOrder.get(
|
||||
left.id as (typeof simpleBundledAvatarIds)[number]
|
||||
)
|
||||
const rightOrder = simpleBundledAvatarOrder.get(
|
||||
right.id as (typeof simpleBundledAvatarIds)[number]
|
||||
)
|
||||
if (leftOrder === undefined && rightOrder === undefined) return 0
|
||||
if (leftOrder === undefined) return 1
|
||||
if (rightOrder === undefined) return -1
|
||||
return leftOrder - rightOrder
|
||||
})
|
||||
if (!avatars.length) return fallback
|
||||
const activeAvatarId = avatars.some(avatar => avatar.id === parsed.activeAvatarId)
|
||||
? parsed.activeAvatarId!
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,4 +1,4 @@
|
||||
import { createAvatar } from '@/features/avatar/avatars'
|
||||
import { createAvatar, defaultAvatarEyes } from '@/features/avatar/avatars'
|
||||
import { createInitialSequences } from '@/features/animation/sequences'
|
||||
import { initialExpressions } from '@/features/avatar/presets'
|
||||
import { createAvatarDefinition } from '@/features/avatar/avatarDefinition'
|
||||
@ -32,11 +32,64 @@ describe('Studio document', () => {
|
||||
it('loads the bundled Studio snapshot when no local project exists', () => {
|
||||
const document = loadStudioDocument(storage())
|
||||
|
||||
expect(document.library.avatars).toHaveLength(10)
|
||||
expect(document.library.avatars).toHaveLength(12)
|
||||
expect(document.library.activeAvatarId).toBe(document.library.avatars[0].id)
|
||||
expect(document.library.avatars[0].name).toBe('Strobi')
|
||||
expect(document.library.avatars.map(avatar => avatar.name)).toEqual([
|
||||
'Onee',
|
||||
'Cubee',
|
||||
'Nova',
|
||||
'Citrus',
|
||||
'Sphere',
|
||||
'Cube',
|
||||
'Capsule',
|
||||
'Cylinder',
|
||||
'Cone',
|
||||
'Diamond',
|
||||
'Memento',
|
||||
'Willy',
|
||||
])
|
||||
expect(
|
||||
document.library.avatars.every(avatar => avatar.colors.body === avatar.colors.eyes)
|
||||
).toBe(true)
|
||||
expect(document.library.avatars.slice(4).map(avatar => avatar.body.primary.type)).toEqual([
|
||||
'sphere',
|
||||
'cube',
|
||||
'capsule',
|
||||
'cylinder',
|
||||
'cone',
|
||||
'diamond',
|
||||
'ghost',
|
||||
'capsule',
|
||||
])
|
||||
expect(document.library.avatars.at(-1)?.body.nodes).toHaveLength(3)
|
||||
const memento = document.library.avatars.find(avatar => avatar.id === 'primitive-ghost')
|
||||
expect(memento?.colors).toEqual({ body: '#ff9d45', eyes: '#ff9d45' })
|
||||
expect(
|
||||
document.library.avatars
|
||||
.filter(avatar => avatar.id !== 'primitive-ghost')
|
||||
.every(avatar => avatar.colors.body !== '#ff9d45')
|
||||
).toBe(true)
|
||||
expect(memento?.eyes.positionYLeft).toBe(defaultAvatarEyes.positionYLeft - 20)
|
||||
expect(memento?.eyes.positionYRight).toBe(defaultAvatarEyes.positionYRight - 20)
|
||||
expect(
|
||||
memento?.behavior?.expressions.every(
|
||||
expression => !expression.bodyColor && !expression.eyeColor
|
||||
)
|
||||
).toBe(true)
|
||||
expect(document.expressions).toHaveLength(27)
|
||||
expect(document.sequences).toHaveLength(23)
|
||||
expect(document.sequences.find(sequence => sequence.id === 'angry')?.steps).toHaveLength(2)
|
||||
expect(
|
||||
document.sequences
|
||||
.find(sequence => sequence.id === 'angry')
|
||||
?.steps.map(step => step.expressionId)
|
||||
).toEqual(['expression-07', 'expression-3d2bed26-f97c-477d-922f-77600cb10e92'])
|
||||
expect(document.sequences.find(sequence => sequence.id === 'scared')?.steps).toHaveLength(2)
|
||||
expect(
|
||||
document.sequences
|
||||
.find(sequence => sequence.id === 'scared')
|
||||
?.steps.map(step => step.expressionId)
|
||||
).toEqual(['expression-03', 'expression-5220eaee-32fe-4bd8-ad31-432189534cc8'])
|
||||
expect(document.expressions.every(expression => expression.semanticKey)).toBe(true)
|
||||
expect(document.sequences.every(sequence => sequence.semanticKey)).toBe(true)
|
||||
expect(document.playback).toEqual({ stateId: 'proud', playing: true })
|
||||
@ -55,6 +108,81 @@ describe('Studio document', () => {
|
||||
expect(loadStudioDocument(storage(JSON.stringify(localDocument)))).toEqual(localDocument)
|
||||
})
|
||||
|
||||
it('restores standalone built-in animations from the temporary idle pairs', () => {
|
||||
const fallback = loadStudioDocument(storage())
|
||||
const pairedDocument: StudioDocument = {
|
||||
...fallback,
|
||||
sequences: fallback.sequences.map(sequence =>
|
||||
sequence.id === 'idle'
|
||||
? sequence
|
||||
: {
|
||||
...sequence,
|
||||
id: `idle-${sequence.id}`,
|
||||
semanticKey: `idle-${sequence.id}`,
|
||||
name: `idle + ${sequence.name}`,
|
||||
}
|
||||
),
|
||||
playback: { stateId: 'idle-proud', playing: true },
|
||||
}
|
||||
|
||||
const restored = loadStudioDocument(storage(JSON.stringify(pairedDocument)))
|
||||
|
||||
expect(restored.sequences.map(sequence => sequence.id)).toEqual(
|
||||
fallback.sequences.map(sequence => sequence.id)
|
||||
)
|
||||
expect(restored.playback).toEqual({ stateId: 'proud', playing: true })
|
||||
})
|
||||
|
||||
it('removes retired bundled avatars from a locally saved project', () => {
|
||||
const fallback = loadStudioDocument(storage())
|
||||
const retired = createAvatar('Freddy')
|
||||
retired.id = 'avatar-4fe2d1bd-cf46-4e5e-a62d-d6b60be519ed'
|
||||
const localDocument: StudioDocument = {
|
||||
...fallback,
|
||||
library: {
|
||||
activeAvatarId: retired.id,
|
||||
avatars: [retired, ...fallback.library.avatars],
|
||||
},
|
||||
}
|
||||
|
||||
const document = loadStudioDocument(storage(JSON.stringify(localDocument)))
|
||||
|
||||
expect(document.library.avatars.map(avatar => avatar.name)).toEqual([
|
||||
'Onee',
|
||||
'Cubee',
|
||||
'Nova',
|
||||
'Citrus',
|
||||
'Sphere',
|
||||
'Cube',
|
||||
'Capsule',
|
||||
'Cylinder',
|
||||
'Cone',
|
||||
'Diamond',
|
||||
'Memento',
|
||||
'Willy',
|
||||
])
|
||||
expect(document.library.activeAvatarId).toBe(document.library.avatars[0].id)
|
||||
})
|
||||
|
||||
it('renames the bundled ghost character in an existing local project', () => {
|
||||
const fallback = loadStudioDocument(storage())
|
||||
const legacy = structuredClone(fallback)
|
||||
const character = legacy.library.avatars.find(avatar => avatar.id === 'primitive-ghost')
|
||||
if (!character) throw new Error('Bundled ghost character not found')
|
||||
character.name = 'Ghost'
|
||||
character.eyes.positionYLeft = defaultAvatarEyes.positionYLeft
|
||||
character.eyes.positionYRight = defaultAvatarEyes.positionYRight
|
||||
|
||||
const document = loadStudioDocument(storage(JSON.stringify(legacy)))
|
||||
|
||||
expect(document.library.avatars.find(avatar => avatar.id === 'primitive-ghost')?.name).toBe(
|
||||
'Memento'
|
||||
)
|
||||
expect(
|
||||
document.library.avatars.find(avatar => avatar.id === 'primitive-ghost')?.eyes.positionYLeft
|
||||
).toBe(defaultAvatarEyes.positionYLeft - 20)
|
||||
})
|
||||
|
||||
it('restores bundled semantic keys in a legacy local project', () => {
|
||||
const fallback = loadStudioDocument(storage())
|
||||
const legacy = structuredClone(fallback)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user