fix: skins QA problems + flow change (#6216)

* fix: skins backend bugs + apply flow

* fix: caching structure

* feat: collapse already duplicated skins + fix moj api spam

* fix: doc

* fix: flatten migrations

* feat: remove default cape/cape override concept

* fix: fmt + lint

* feat: remove SelectCapeModal for inline cape list

* feat: qa

* feat: virtualisation of skins sections + fix texture/model cache

* fix: lint

* fix: virt bugs + renderer fixes

* fix: qa bugs

* fix: doc

* fix: re-add click impulse anim from prototypes + re-add interact anim length cap

* fix: regressions

* devex: split up SkinPreviewrenderer

* fix: lint

* fix: introduce dynamic mode in virtual-scroll.ts

* feat: qa

* fix: nametag bug + remove minecon skin pack suffix

* feat: pain (literally)

* feat: user agent on moj reqs

* feat: impl per account flush queue for operations

* fix: breadcrumb

* chore: i18n pass

* fix: lint + prep + check

* fix: misalignments
This commit is contained in:
Calum H.
2026-05-27 23:22:24 +01:00
committed by GitHub
parent 64edf2ddeb
commit 84b91f32f8
55 changed files with 5651 additions and 2138 deletions
@@ -84,6 +84,7 @@
<div
ref="scrollContainer"
data-modal-content
:class="[
'flex-1 min-h-0',
props.noPadding ? '' : 'overflow-y-auto p-6 !pb-1 sm:pb-6',
@@ -112,7 +113,9 @@
<div
v-else
data-modal-content
:class="[
'min-h-0',
props.noPadding ? '' : 'overflow-y-auto p-6',
{ 'pt-12': props.mergeHeader && closable && !props.noPadding },
]"
@@ -18,26 +18,31 @@ withDefaults(
<template>
<button
v-tooltip="tooltip"
class="block border-0 m-0 p-0 bg-transparent group cursor-pointer"
type="button"
class="cape-like-text-button group m-0 block cursor-pointer border-0 bg-transparent p-0"
:aria-label="tooltip"
:aria-pressed="highlighted"
@click="emit('click')"
>
<span
:class="[
'block rounded-lg group-active:scale-95 transition-all border-2 relative',
highlighted
? 'border-brand highlighted-glow'
: 'border-transparent brightness-95 group-hover:brightness-100',
'relative block overflow-hidden rounded-lg border-0 p-[3px] shadow-[var(--shadow-button)] transition-[transform,background,color,filter] duration-200 group-active:scale-95 group-hover:brightness-[--hover-brightness] group-focus-visible:brightness-[--hover-brightness]',
highlighted ? 'bg-brand text-brand' : 'text-primary [background:var(--color-button-bg)]',
]"
>
<span class="block p-[3px] rounded-lg bg-button-bg">
<span
class="flex flex-col p-4 items-center justify-center aspect-[10/16] w-[60px] min-h-[96px] rounded-[5px] bg-black/10 relative overflow-hidden text-primary z-10"
>
<div class="mb-1">
<span
:class="[
'relative z-10 block aspect-[10/16] min-h-[96px] w-[60px] overflow-hidden rounded-[5px]',
highlighted
? '[background:linear-gradient(var(--color-brand-highlight),var(--color-brand-highlight)),var(--color-button-bg)]'
: '[background:var(--color-button-bg)]',
]"
>
<span class="absolute inset-0 flex flex-col items-center justify-center text-center">
<span class="mb-1 flex items-center justify-center leading-none">
<slot name="icon"></slot>
</div>
<span class="text-xs">
</span>
<span class="block text-xs leading-none">
<slot></slot>
</span>
</span>
@@ -45,19 +50,3 @@ withDefaults(
</span>
</button>
</template>
<style lang="scss" scoped>
.highlighted-glow::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
}
@supports (background-color: color-mix(in srgb, transparent, transparent)) {
.highlighted-glow::before {
box-shadow: inset 0 0 2px 2px color-mix(in srgb, var(--color-brand), transparent 10%);
}
}
</style>
+97 -31
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch } from 'vue'
const emit = defineEmits<{
(e: 'select'): void
@@ -11,61 +11,87 @@ const props = withDefaults(
forwardImageSrc?: string
backwardImageSrc?: string
selected: boolean
active?: boolean
tooltip?: string
}>(),
{
forwardImageSrc: undefined,
backwardImageSrc: undefined,
active: false,
tooltip: undefined,
},
)
const imagesLoaded = ref({
forward: Boolean(props.forwardImageSrc),
backward: Boolean(props.backwardImageSrc),
forward: false,
backward: false,
})
function onImageLoad(type: 'forward' | 'backward') {
imagesLoaded.value[type] = true
}
watch(
() => props.forwardImageSrc,
() => {
imagesLoaded.value.forward = false
},
)
watch(
() => props.backwardImageSrc,
() => {
imagesLoaded.value.backward = false
},
)
</script>
<template>
<div
v-tooltip="tooltip ?? undefined"
class="group flex relative overflow-hidden rounded-xl border-solid border-2 transition-colors duration-200"
:class="[selected ? 'border-brand' : 'border-transparent hover:border-inverted']"
class="skin-button group relative flex items-end justify-center overflow-hidden border border-solid transition-[border-color,box-shadow] duration-200 focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-brand"
:class="[
selected ? 'skin-button--selected' : '',
{ 'skin-button--with-actions': $slots['overlay-buttons'] },
]"
>
<button
class="skin-btn-bg absolute inset-0 cursor-pointer p-0 border-none group-hover:brightness-125"
:class="selected ? 'selected' : ''"
class="absolute inset-0 z-10 cursor-pointer border-none bg-transparent p-0"
:aria-label="tooltip ? `Select ${tooltip}` : 'Select skin'"
:aria-pressed="selected"
@click="emit('select')"
></button>
<span
v-if="active && !selected"
class="pointer-events-none absolute right-3 top-3 z-20 size-3 rounded-full border-2 border-solid border-surface-3 bg-green"
></span>
<div
v-if="!(imagesLoaded.forward && imagesLoaded.backward)"
class="skeleton-loader w-full h-full"
class="skeleton-loader h-full w-full"
>
<div class="skeleton absolute inset-0 aspect-[5/7]"></div>
</div>
<span
v-show="imagesLoaded.forward && imagesLoaded.backward"
:key="`${selected}-${active}`"
:class="[
'skin-button__image-parent pointer-events-none w-full h-full grid [transform-style:preserve-3d] transition-transform duration-500 group-hover:[transform:rotateY(180deg)] place-items-stretch with-shadow',
'skin-button__image-parent pointer-events-none relative z-0 mb-[1.5px] grid place-items-stretch with-shadow',
]"
>
<img
alt=""
:src="forwardImageSrc"
class="skin-button__image-facing object-contain w-full h-full [backface-visibility:hidden] col-start-1 row-start-1"
class="skin-button__image-facing col-start-1 row-start-1 h-full w-full object-contain"
height="504"
@load="onImageLoad('forward')"
/>
<img
alt=""
:src="backwardImageSrc"
class="skin-button__image-away object-contain w-full h-full [backface-visibility:hidden] [transform:rotateY(180deg)] col-start-1 row-start-1"
class="skin-button__image-away col-start-1 row-start-1 h-full w-full object-contain"
height="504"
@load="onImageLoad('backward')"
/>
@@ -73,7 +99,7 @@ function onImageLoad(type: 'forward' | 'backward') {
<span
v-if="$slots['overlay-buttons']"
class="pointer-events-none absolute inset-0 flex items-end justify-start p-1 gap-1 translate-y-4 scale-75 opacity-0 transition-all group-hover:opacity-100 group-hover:scale-100 group-hover:translate-y-0 group-hover:translate-x-0"
class="pointer-events-none absolute inset-x-0 bottom-3 z-30 flex translate-y-2 items-center justify-start gap-1.5 px-3 opacity-0 transition-all duration-200 group-focus-within:translate-y-0 group-focus-within:opacity-100 group-hover:translate-y-0 group-hover:opacity-100"
>
<slot name="overlay-buttons" />
</span>
@@ -82,7 +108,7 @@ function onImageLoad(type: 'forward' | 'backward') {
<style scoped lang="scss">
.skeleton-loader {
aspect-ratio: 5 / 7;
aspect-ratio: 31 / 40;
}
.skeleton {
@@ -105,24 +131,68 @@ function onImageLoad(type: 'forward' | 'backward') {
}
}
.skin-btn-bg {
background: var(--color-gradient-button-bg);
.skin-button {
aspect-ratio: 31 / 40;
border-color: var(--surface-4);
border-radius: 20px;
background: var(--surface-3);
isolation: isolate;
box-shadow:
0 1px 1px rgba(0, 0, 0, 0.25),
0 1px 2px rgba(0, 0, 0, 0.15);
}
.skin-btn-bg.selected {
background:
linear-gradient(
157.61deg,
var(--color-brand) -76.68%,
rgba(27, 217, 106, 0.534) -38.61%,
rgba(12, 89, 44, 0.6) 100.4%
),
var(--color-bg);
.skin-button::after {
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
content: '';
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(37, 39, 45, 0.2) 100%);
}
.skin-btn-bg.selected:hover,
.group:hover .skin-btn-bg.selected {
filter: brightness(1.15);
.skin-button:hover,
.skin-button:focus-within,
.skin-button--with-actions:hover,
.skin-button--with-actions:focus-within {
border-color: var(--surface-5);
background: var(--surface-4);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.25),
0 1px 4px rgba(0, 0, 0, 0.15);
}
.skin-button--selected,
.skin-button--selected:hover,
.skin-button--selected:focus-within {
border-color: var(--color-brand);
background: var(--color-brand-highlight);
}
.skin-button__image-parent {
width: 100%;
height: 95%;
transform: rotateY(0deg) translateZ(0);
transform-style: preserve-3d;
transition: transform 500ms cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
.skin-button:hover .skin-button__image-parent {
transform: rotateY(180deg) translateZ(0);
}
.skin-button__image-facing,
.skin-button__image-away {
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
transform: translateZ(0.1px);
}
.skin-button__image-away {
transform: rotateY(180deg) translateZ(0.1px);
}
.with-shadow img {
@@ -136,8 +206,4 @@ function onImageLoad(type: 'forward' | 'backward') {
.group:hover .skin-button__image-parent img {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
.with-shadow img {
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4));
}
</style>
@@ -1,68 +1,87 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, useTemplateRef } from 'vue'
withDefaults(
const props = withDefaults(
defineProps<{
selected?: boolean
tooltip?: string
dragActive?: boolean
dropzone?: boolean
}>(),
{
selected: false,
tooltip: undefined,
dragActive: false,
dropzone: false,
},
)
const emit = defineEmits<{ (e: 'click', event: MouseEvent): void }>()
const emit = defineEmits<{
(e: 'click', event: MouseEvent): void
(e: 'dragenter' | 'dragover' | 'dragleave' | 'drop', event: DragEvent): void
}>()
const pressed = ref(false)
const root = useTemplateRef<HTMLElement>('root')
const isHighlighted = computed(() => props.selected || props.dragActive)
function handleDragEvent(
eventName: 'dragenter' | 'dragover' | 'dragleave' | 'drop',
event: DragEvent,
) {
if (props.dropzone) {
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy'
}
}
emit(eventName, event)
}
function getRootElement() {
return root.value
}
defineExpose({ getRootElement })
</script>
<template>
<div
ref="root"
v-tooltip="tooltip ?? undefined"
class="group relative overflow-hidden rounded-xl border-2 transition-all duration-200"
:class="[selected ? 'border-brand' : 'border-transparent hover:border-inverted']"
class="group relative flex flex-col items-center justify-center overflow-hidden rounded-[20px] border border-dashed transition-[background,border-color,box-shadow] duration-200 focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-brand"
:class="[
isHighlighted
? 'border-brand bg-brand-highlight'
: 'border-surface-5 bg-surface-2 hover:bg-surface-3',
]"
@dragenter="handleDragEvent('dragenter', $event)"
@dragover="handleDragEvent('dragover', $event)"
@dragleave="handleDragEvent('dragleave', $event)"
@drop="handleDragEvent('drop', $event)"
>
<button
class="skin-btn-bg absolute inset-0 cursor-pointer p-0 border-none group-hover:brightness-125 transition-all duration-200"
:class="selected ? 'selected' : ''"
@mousedown="pressed = true"
@mouseup="pressed = false"
@mouseleave="pressed = false"
type="button"
:aria-label="tooltip ?? undefined"
class="absolute inset-0 z-0 cursor-pointer border-none bg-transparent p-0"
@click="(e) => emit('click', e)"
></button>
<div
class="relative w-full h-full flex flex-col items-center justify-center pointer-events-none z-10"
class="pointer-events-none relative z-10 flex h-full w-full flex-col items-center justify-center gap-4 px-3 text-center"
:class="dragActive ? 'text-brand' : 'text-contrast'"
>
<div v-if="$slots.icon" class="mb-2">
<div v-if="$slots.icon" class="size-8">
<slot name="icon" />
</div>
<span class="text-md text-center px-2 text-primary">
<slot />
</span>
<div class="flex flex-col items-center gap-0.5 whitespace-nowrap">
<span class="text-base font-semibold leading-6">
<slot />
</span>
<span v-if="$slots.subtitle" class="text-sm font-medium leading-5 text-primary">
<slot name="subtitle" />
</span>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.skin-btn-bg {
background: var(--color-gradient-button-bg);
}
.skin-btn-bg.selected {
background:
linear-gradient(
157.61deg,
var(--color-brand) -76.68%,
rgba(27, 217, 106, 0.534) -38.61%,
rgba(12, 89, 44, 0.6) 100.4%
),
var(--color-bg);
}
.skin-btn-bg.selected:hover,
.group:hover .skin-btn-bg.selected {
filter: brightness(1.15);
}
</style>
@@ -1,79 +1,79 @@
<template>
<!-- eslint-disable vue/no-undef-components -->
<div ref="skinPreviewContainer" class="relative w-full h-full cursor-grab" @click="onCanvasClick">
<div
ref="skinPreviewContainer"
class="relative w-full h-full overflow-visible cursor-grab"
@click="onCanvasClick"
>
<div
class="absolute bottom-[18%] left-0 right-0 flex flex-col justify-center items-center mb-2 pointer-events-none z-10 gap-2"
class="absolute left-0 right-0 z-10 flex items-center justify-center pointer-events-none"
:style="previewControlsPositionStyle"
>
<span class="text-primary text-xs px-2 py-1 rounded-full backdrop-blur-sm">
<span
class="flex items-center justify-center gap-1.5 text-base font-medium leading-6 text-primary"
>
<UnfoldHorizontalIcon class="size-5 shrink-0" />
Drag to rotate
</span>
</div>
<div
class="absolute bottom-[10%] left-0 right-0 flex justify-center items-center pointer-events-auto z-10"
v-if="$slots.subtitle"
class="absolute left-0 right-0 z-10 flex items-center justify-center pointer-events-none"
:style="subtitlePositionStyle"
>
<slot name="subtitle" />
<div ref="subtitleElement" class="pointer-events-auto" @click="ignoreControlClick">
<slot name="subtitle" />
</div>
</div>
<div
v-if="nametag"
class="absolute top-[18%] left-1/2 transform -translate-x-1/2 px-3 py-1 rounded-md pointer-events-none z-10 font-minecraft text-gray nametag-bg transition-all duration-200"
:style="{ fontSize: nametagFontSize }"
v-if="nametag || $slots['nametag-badge']"
class="absolute left-1/2 pointer-events-none z-10"
:style="nametagStyle"
>
{{ nametagText }}
<div
v-if="$slots['nametag-badge']"
class="absolute bottom-[calc(100%+1rem)] left-1/2 flex -translate-x-1/2 items-center justify-center"
>
<slot name="nametag-badge" />
</div>
<div v-if="nametag" class="px-3 py-1 rounded-md font-minecraft text-gray nametag-bg">
{{ nametagText }}
</div>
</div>
<TresCanvas
shadows
alpha
:antialias="true"
:dpr="rendererDpr"
:renderer-options="{
outputColorSpace: THREE.SRGBColorSpace,
toneMapping: THREE.NoToneMapping,
toneMappingExposure: 10.0,
}"
class="transition-opacity duration-500"
:class="{ 'opacity-0': !isReady, 'opacity-100': isReady }"
:class="{ 'opacity-0': !isPreviewVisible, 'opacity-100': isPreviewVisible }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointerleave="onPointerUp"
>
<Suspense>
<Group>
<Group
:rotation="[0, modelRotation, 0]"
:position="[0, -0.05 * scale, 1.95]"
:scale="[0.8 * scale, 0.8 * scale, 0.8 * scale]"
>
<Group
:rotation="animatedModelGroupRotation"
:position="animatedModelGroupPosition"
:scale="animatedModelGroupScale"
>
<Group :position="modelOffset">
<primitive v-if="scene" :object="scene" />
</Group>
<!-- <TresMesh
:position="[0, -0.095 * scale, 2]"
:rotation="[-Math.PI / 2, 0, 0]"
:scale="[0.4 * 0.75 * scale, 0.4 * 0.75 * scale, 0.4 * 0.75 * scale]"
>
<TresCircleGeometry :args="[1, 128]" />
<TresMeshBasicMaterial
color="#000000"
:opacity="0.5"
transparent
:depth-write="false"
/>
</TresMesh> -->
</Group>
</Suspense>
<Suspense>
<EffectComposerPmndrs>
<FXAAPmndrs />
</EffectComposerPmndrs>
</Suspense>
<Suspense>
<TresMesh
:position="[0, -0.1 * scale, 2]"
:position="spotlightPosition"
:rotation="[-Math.PI / 2, 0, 0]"
:scale="[0.75 * scale, 0.75 * scale, 0.75 * scale]"
:scale="spotlightScale"
>
<TresCircleGeometry :args="[1, 128]" />
<TresShaderMaterial v-bind="radialSpotlightShader" />
@@ -82,57 +82,53 @@
<TresPerspectiveCamera
:make-default.camel="true"
:fov="fov"
:position="[0, 1.5, -3.25]"
:look-at="target"
:fov="cameraConfig.fov"
:position="cameraConfig.position"
:look-at="cameraConfig.target"
/>
<TresAmbientLight :intensity="2" />
<TresDirectionalLight :position="[-3, 4, -2]" :intensity="1.2" :cast-shadow="true" />
<TresDirectionalLight :position="[-3, 4, -2]" :intensity="1.2" />
</TresCanvas>
<div
v-if="!isReady"
class="w-full h-full flex items-center justify-center transition-opacity duration-500"
:class="{ 'opacity-100': !isReady, 'opacity-0': isReady }"
>
<div v-if="showLoading" class="absolute inset-0 flex items-center justify-center">
<div class="text-primary">Loading...</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
import {
applyCapeTexture,
applyTexture,
createTransparentTexture,
loadTexture as loadSkinTexture,
} from '@modrinth/utils'
import { useGLTF } from '@tresjs/cientos'
import { TresCanvas, useRenderLoop, useTexture } from '@tresjs/core'
import { EffectComposerPmndrs, FXAAPmndrs } from '@tresjs/post-processing'
import { ClassicPlayerModel, SlimPlayerModel, UnfoldHorizontalIcon } from '@modrinth/assets'
import { TresCanvas } from '@tresjs/core'
import * as THREE from 'three'
import {
computed,
markRaw,
onBeforeMount,
nextTick,
onMounted,
onUnmounted,
ref,
shallowRef,
toRefs,
toRef,
useSlots,
useTemplateRef,
watch,
} from 'vue'
import { useDynamicFontSize } from '../../composables'
import type {
SkinPreviewAnimationConfig,
SkinPreviewFitPadding,
SkinPreviewFraming,
SkinPreviewTuple,
} from '#ui/composables/skin-rendering'
import {
useSkinPreviewAnimation,
useSkinPreviewControls,
useSkinPreviewFit,
useSkinPreviewLoading,
useSkinPreviewScene,
} from '#ui/composables/skin-rendering'
interface AnimationConfig {
baseAnimation: string
randomAnimations: string[]
randomAnimationInterval?: number
transitionDuration?: number
}
import { useDynamicFontSize } from '../../composables'
import { createRadialSpotlightShader, syncDamageFlashShader } from './skin-preview-shader'
const props = withDefaults(
defineProps<{
@@ -140,18 +136,27 @@ const props = withDefaults(
capeSrc?: string
variant?: 'SLIM' | 'CLASSIC' | 'UNKNOWN'
nametag?: string
fit?: boolean
lockFit?: boolean
framing?: SkinPreviewFraming
fitZoom?: number
fitPadding?: Partial<SkinPreviewFitPadding>
/** @deprecated Manual framing fallback. */
scale?: number
/** @deprecated Manual framing fallback, or auto-fit FOV override when fit=true. */
fov?: number
initialRotation?: number
animationConfig?: AnimationConfig
animationConfig?: SkinPreviewAnimationConfig
}>(),
{
variant: 'CLASSIC',
scale: 1,
fov: 40,
capeSrc: undefined,
initialRotation: 15.75,
nametag: undefined,
fit: undefined,
lockFit: true,
framing: 'page',
fitZoom: 1,
animationConfig: () => ({
baseAnimation: 'idle',
randomAnimations: ['idle_sub_1', 'idle_sub_2', 'idle_sub_3'],
@@ -162,7 +167,155 @@ const props = withDefaults(
)
const skinPreviewContainer = useTemplateRef<HTMLElement>('skinPreviewContainer')
const subtitleElement = useTemplateRef<HTMLElement>('subtitleElement')
const slots = useSlots()
const nametagText = computed(() => props.nametag)
const hasSubtitle = computed(() => Boolean(slots.subtitle))
const hasNametagBadge = computed(() => Boolean(slots['nametag-badge']))
const isSubtitleWrapped = ref(false)
const selectedModelSrc = computed(() =>
props.variant === 'SLIM' ? SlimPlayerModel : ClassicPlayerModel,
)
let subtitleResizeObserver: ResizeObserver | undefined
function getSubtitleLayoutRoot(element: HTMLElement) {
const elementChildren = Array.from(element.children).filter(
(child): child is HTMLElement => child instanceof HTMLElement,
)
return elementChildren.length === 1 ? elementChildren[0] : element
}
function updateSubtitleWrapped() {
const element = subtitleElement.value
if (!element) {
isSubtitleWrapped.value = false
return
}
const layoutRoot = getSubtitleLayoutRoot(element)
const children = Array.from(layoutRoot.children).filter(
(child): child is HTMLElement => child instanceof HTMLElement,
)
if (children.length < 2) {
isSubtitleWrapped.value = false
return
}
const firstTop = children[0].getBoundingClientRect().top
isSubtitleWrapped.value = children.some(
(child) => Math.abs(child.getBoundingClientRect().top - firstTop) > 1,
)
}
function observeSubtitleElement() {
subtitleResizeObserver?.disconnect()
const element = subtitleElement.value
if (!element) {
isSubtitleWrapped.value = false
return
}
const layoutRoot = getSubtitleLayoutRoot(element)
subtitleResizeObserver = new ResizeObserver(updateSubtitleWrapped)
subtitleResizeObserver.observe(element)
if (layoutRoot !== element) {
subtitleResizeObserver.observe(layoutRoot)
}
void nextTick(updateSubtitleWrapped)
}
const {
cleanupAnimationState,
clickImpulseOffsetX,
clickImpulseRotationZ,
clickImpulseScaleX,
clickImpulseScaleY,
currentAnimation,
damageFlashIntensity,
getAvailableAnimations,
initializeAnimations,
playAnimation,
playClickInteraction,
stopAnimations,
} = useSkinPreviewAnimation(toRef(props, 'animationConfig'))
const {
ignoreControlClick,
modelRotation,
onCanvasClick,
onPointerDown,
onPointerMove,
onPointerUp,
} = useSkinPreviewControls({
initialRotation: toRef(props, 'initialRotation'),
onClickWithoutDrag: () => {
playClickInteraction()
},
})
const { isModelLoaded, isTextureLoaded, modelCenter, modelSize, scene } = useSkinPreviewScene({
selectedModelSrc,
textureSrc: toRef(props, 'textureSrc'),
capeSrc: toRef(props, 'capeSrc'),
initializeAnimations,
cleanupAnimationState,
})
function syncDamageFlashShaderMaterials() {
syncDamageFlashShader(scene.value, damageFlashIntensity.value)
}
const {
cameraConfig,
fitEnabled,
hasResolvedFit,
modelGroupPosition,
modelGroupScale,
modelOffset,
nametagTop,
previewControlsPositionStyle,
spotlightPosition,
spotlightScale,
subtitlePositionStyle,
} = useSkinPreviewFit({
containerElement: computed(() => skinPreviewContainer.value),
fit: toRef(props, 'fit'),
lockFit: toRef(props, 'lockFit'),
framing: toRef(props, 'framing'),
fitZoom: toRef(props, 'fitZoom'),
fitPadding: toRef(props, 'fitPadding'),
scale: toRef(props, 'scale'),
fov: toRef(props, 'fov'),
modelRotation,
nametag: toRef(props, 'nametag'),
hasSubtitle,
hasNametagBadge,
subtitleWrapped: isSubtitleWrapped,
modelCenter,
modelSize,
isModelLoaded,
})
const rendererDpr: [number, number] = [1, 1.5]
const radialSpotlightShader = createRadialSpotlightShader()
const isReady = computed(() => isModelLoaded.value && isTextureLoaded.value && hasResolvedFit.value)
const { isPreviewVisible, showLoading } = useSkinPreviewLoading(isReady)
onMounted(observeSubtitleElement)
watch(hasSubtitle, () => nextTick(observeSubtitleElement), { flush: 'post' })
watch(scene, syncDamageFlashShaderMaterials, { immediate: true })
watch(damageFlashIntensity, syncDamageFlashShaderMaterials)
onUnmounted(() => {
subtitleResizeObserver?.disconnect()
})
const { fontSize: nametagFontSize } = useDynamicFontSize({
containerElement: skinPreviewContainer,
@@ -174,445 +327,35 @@ const { fontSize: nametagFontSize } = useDynamicFontSize({
fontFamily: 'inherit',
})
const selectedModelSrc = computed(() =>
props.variant === 'SLIM' ? SlimPlayerModel : ClassicPlayerModel,
)
const scene = shallowRef<THREE.Object3D | null>(null)
const lastCapeSrc = ref<string | undefined>(undefined)
const texture = shallowRef<THREE.Texture | null>(null)
const capeTexture = shallowRef<THREE.Texture | null>(null)
const transparentTexture = createTransparentTexture()
const isModelLoaded = ref(false)
const isTextureLoaded = ref(false)
const isReady = computed(() => isModelLoaded.value && isTextureLoaded.value)
const mixer = ref<THREE.AnimationMixer | null>(null)
const actions = ref<Record<string, THREE.AnimationAction>>({})
const clock = new THREE.Clock()
const currentAnimation = ref<string>('')
const randomAnimationTimer = ref<number | null>(null)
const lastRandomAnimation = ref<string>('')
const radialSpotlightShader = computed(() => ({
uniforms: {
innerColor: { value: new THREE.Color(0x000000) },
outerColor: { value: new THREE.Color(0xffffff) },
innerOpacity: { value: 0.3 },
outerOpacity: { value: 0.0 },
falloffPower: { value: 1.2 },
shadowRadius: { value: 7 },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 innerColor;
uniform vec3 outerColor;
uniform float innerOpacity;
uniform float outerOpacity;
uniform float falloffPower;
uniform float shadowRadius;
varying vec2 vUv;
void main() {
vec2 center = vec2(0.5, 0.5);
float dist = distance(vUv, center) * 2.0;
// Create shadow in the center
float shadowFalloff = 1.0 - smoothstep(0.0, shadowRadius, dist);
// Create overall spotlight falloff
float spotlightFalloff = 1.0 - smoothstep(0.0, 1.0, pow(dist, falloffPower));
// Combine both effects
vec3 color = mix(outerColor, innerColor, shadowFalloff);
float opacity = mix(outerOpacity, innerOpacity * shadowFalloff, spotlightFalloff);
gl_FragColor = vec4(color, opacity);
}
`,
transparent: true,
depthWrite: false,
depthTest: false,
const nametagStyle = computed(() => ({
fontSize: nametagFontSize.value,
top: nametagTop.value,
transform: fitEnabled.value ? 'translate(-50%, calc(-100% - 0.75rem))' : 'translateX(-50%)',
}))
const { baseAnimation, randomAnimations } = toRefs(props.animationConfig)
const animatedModelGroupRotation = computed<SkinPreviewTuple>(() => [
0,
modelRotation.value,
clickImpulseRotationZ.value,
])
function initializeAnimations(loadedScene: THREE.Object3D, clips: THREE.AnimationClip[]) {
if (!clips || clips.length === 0) {
console.warn('No animation clips found in the model')
return
}
const animatedModelGroupPosition = computed<SkinPreviewTuple>(() => {
const [x, y, z] = modelGroupPosition.value
return [x + clickImpulseOffsetX.value, y, z]
})
mixer.value = new THREE.AnimationMixer(loadedScene)
actions.value = {}
clips.forEach((clip) => {
const action = mixer.value!.clipAction(clip)
action.setLoop(THREE.LoopOnce, 1)
action.clampWhenFinished = true
actions.value[clip.name] = action
})
if (baseAnimation.value && actions.value[baseAnimation.value]) {
actions.value[baseAnimation.value].setLoop(THREE.LoopRepeat, Infinity)
playAnimation(baseAnimation.value)
setupRandomAnimationLoop()
} else {
console.warn(`Base animation "${baseAnimation.value}" not found`)
const firstAnimationName = Object.keys(actions.value)[0]
if (firstAnimationName) {
actions.value[firstAnimationName].setLoop(THREE.LoopRepeat, Infinity)
playAnimation(firstAnimationName)
}
}
}
function playAnimation(name: string) {
if (!mixer.value || !actions.value[name]) {
console.warn(`Animation "${name}" not found!`)
return false
}
const action = actions.value[name]
if (currentAnimation.value === name && action.isRunning() && name !== baseAnimation.value) {
console.log(`Animation "${name}" is already running, ignoring request`)
return false
}
const transitionDuration = props.animationConfig.transitionDuration || 0.3
Object.entries(actions.value).forEach(([actionName, actionInstance]) => {
if (actionName !== name && actionInstance.isRunning()) {
actionInstance.fadeOut(transitionDuration)
}
})
action.reset()
if (name === baseAnimation.value) {
action.setLoop(THREE.LoopRepeat, Infinity)
} else {
action.setLoop(THREE.LoopOnce, 1)
action.clampWhenFinished = true
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onFinished = (event: any) => {
if (event.action === action) {
mixer.value?.removeEventListener('finished', onFinished)
if (currentAnimation.value === name && baseAnimation.value) {
action.fadeOut(transitionDuration)
const baseAction = actions.value[baseAnimation.value]
baseAction.reset()
baseAction.fadeIn(transitionDuration)
baseAction.play()
currentAnimation.value = baseAnimation.value
}
}
}
mixer.value.addEventListener('finished', onFinished)
}
action.fadeIn(transitionDuration)
action.play()
currentAnimation.value = name
return true
}
function setupRandomAnimationLoop() {
const interval = props.animationConfig.randomAnimationInterval || 10000
function scheduleNextAnimation() {
if (randomAnimationTimer.value) {
clearTimeout(randomAnimationTimer.value)
}
randomAnimationTimer.value = window.setTimeout(() => {
if (randomAnimations.value.length > 0 && currentAnimation.value === baseAnimation.value) {
const availableAnimations = randomAnimations.value.filter(
(anim) => anim !== lastRandomAnimation.value,
)
// If all animations have been used, reset and use the full list
const animationsToChooseFrom =
availableAnimations.length > 0 ? availableAnimations : randomAnimations.value
const randomIndex = Math.floor(Math.random() * animationsToChooseFrom.length)
const randomAnimationName = animationsToChooseFrom[randomIndex]
if (actions.value[randomAnimationName]) {
lastRandomAnimation.value = randomAnimationName
playRandomAnimation(randomAnimationName)
}
} else {
// If not in base animation, wait and try again
scheduleNextAnimation()
}
}, interval)
}
scheduleNextAnimation()
}
function playRandomAnimation(name: string) {
if (!mixer.value || !actions.value[name]) {
console.warn(`Animation "${name}" not found!`)
return
}
const action = actions.value[name]
if (currentAnimation.value === name && action.isRunning()) {
console.log(`Animation "${name}" is already running, ignoring request`)
return
}
const transitionDuration = props.animationConfig.transitionDuration || 0.3
if (baseAnimation.value && actions.value[baseAnimation.value].isRunning()) {
actions.value[baseAnimation.value].fadeOut(transitionDuration)
}
action.reset()
action.setLoop(THREE.LoopOnce, 1)
action.clampWhenFinished = true
action.fadeIn(transitionDuration)
action.play()
currentAnimation.value = name
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onFinished = (event: any) => {
if (event.action === action) {
mixer.value?.removeEventListener('finished', onFinished)
if (currentAnimation.value === name && baseAnimation.value) {
action.fadeOut(transitionDuration)
const baseAction = actions.value[baseAnimation.value]
baseAction.reset()
baseAction.fadeIn(transitionDuration)
baseAction.play()
currentAnimation.value = baseAnimation.value
// Schedule the next random animation after returning to base
setupRandomAnimationLoop()
}
}
}
mixer.value.addEventListener('finished', onFinished)
}
function stopAnimations() {
if (mixer.value) {
mixer.value.stopAllAction()
}
currentAnimation.value = ''
}
function getAvailableAnimations(): string[] {
return Object.keys(actions.value)
}
const animatedModelGroupScale = computed<SkinPreviewTuple>(() => {
const [x, y, z] = modelGroupScale.value
return [x * clickImpulseScaleX.value, y * clickImpulseScaleY.value, z]
})
defineExpose({
playAnimation,
playClickInteraction,
stopAnimations,
getAvailableAnimations,
getCurrentAnimation: () => currentAnimation.value,
})
const { onLoop } = useRenderLoop()
onLoop(() => {
if (mixer.value) {
mixer.value.update(clock.getDelta())
}
})
async function loadModel(src: string) {
try {
isModelLoaded.value = false
const { scene: loadedScene, animations } = await useGLTF(src)
scene.value = markRaw(loadedScene)
if (texture.value) {
applyTexture(scene.value, texture.value)
}
applyCapeTexture(scene.value, capeTexture.value, transparentTexture)
if (animations && animations.length > 0) {
initializeAnimations(loadedScene, animations)
}
updateModelInfo()
isModelLoaded.value = true
} catch (error) {
console.error('Failed to load model:', error)
isModelLoaded.value = false
}
}
async function loadAndApplyTexture(src: string) {
if (!src) return null
try {
try {
return await loadSkinTexture(src)
} catch {
const tex = await useTexture([src])
tex.colorSpace = THREE.SRGBColorSpace
tex.flipY = false
tex.magFilter = THREE.NearestFilter
tex.minFilter = THREE.NearestFilter
return tex
}
} catch (error) {
console.error('Failed to load texture:', error)
return null
}
}
async function loadAndApplyCapeTexture(src: string | undefined) {
if (src === lastCapeSrc.value) return
lastCapeSrc.value = src
if (src) {
capeTexture.value = await loadAndApplyTexture(src)
} else {
capeTexture.value = null
}
if (scene.value) {
applyCapeTexture(scene.value, capeTexture.value, transparentTexture)
}
}
const centre = ref<[number, number, number]>([0, 1, 0])
const modelHeight = ref(1.4)
function updateModelInfo() {
if (!scene.value) return
try {
const bbox = new THREE.Box3().setFromObject(scene.value)
const mid = new THREE.Vector3()
bbox.getCenter(mid)
centre.value = [mid.x, mid.y, mid.z]
modelHeight.value = bbox.max.y - bbox.min.y
} catch (error) {
console.error('Failed to update model info:', error)
}
}
const target = computed(() => centre.value)
const modelRotation = ref(props.initialRotation + Math.PI)
const isDragging = ref(false)
const previousX = ref(0)
const hasDragged = ref(false)
function onPointerDown(event: PointerEvent) {
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
isDragging.value = true
previousX.value = event.clientX
hasDragged.value = false
}
function onPointerMove(event: PointerEvent) {
if (!isDragging.value) return
const deltaX = event.clientX - previousX.value
modelRotation.value += deltaX * 0.01
previousX.value = event.clientX
hasDragged.value = true
}
function onPointerUp(event: PointerEvent) {
isDragging.value = false
;(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId)
}
function onCanvasClick() {
if (!hasDragged.value) {
if (actions.value['interact']) {
playRandomAnimation('interact')
}
}
hasDragged.value = false
}
watch(selectedModelSrc, (src) => loadModel(src))
watch(
() => props.textureSrc,
async (newSrc) => {
isTextureLoaded.value = false
texture.value = await loadAndApplyTexture(newSrc)
if (scene.value && texture.value) {
applyTexture(scene.value, texture.value)
}
isTextureLoaded.value = true
},
)
watch(
() => props.capeSrc,
async (newCapeSrc) => {
await loadAndApplyCapeTexture(newCapeSrc)
},
)
watch(
() => props.animationConfig,
(newConfig) => {
if (randomAnimationTimer.value) {
clearTimeout(randomAnimationTimer.value)
randomAnimationTimer.value = null
}
if (mixer.value && newConfig.baseAnimation && actions.value[newConfig.baseAnimation]) {
playAnimation(newConfig.baseAnimation)
setupRandomAnimationLoop()
}
},
{ deep: true },
)
onBeforeMount(async () => {
try {
isTextureLoaded.value = false
texture.value = await loadAndApplyTexture(props.textureSrc)
isTextureLoaded.value = true
await loadModel(selectedModelSrc.value)
if (props.capeSrc) {
await loadAndApplyCapeTexture(props.capeSrc)
}
} catch (error) {
console.error('Failed to initialize skin preview:', error)
}
})
onUnmounted(() => {
if (randomAnimationTimer.value) {
clearTimeout(randomAnimationTimer.value)
}
if (mixer.value) {
mixer.value.stopAllAction()
mixer.value = null
}
})
</script>
<style scoped lang="scss">
@@ -0,0 +1,115 @@
import * as THREE from 'three'
type DamageFlashMaterial = THREE.MeshStandardMaterial & {
userData: THREE.MeshStandardMaterial['userData'] & {
damageFlashShader?: THREE.Shader
damageFlashShaderInstalled?: boolean
}
}
const DAMAGE_FLASH_COLOR = new THREE.Color(0xbd2f2f)
const DAMAGE_FLASH_SHADER_KEY = 'skin-preview-damage-flash'
export function createRadialSpotlightShader() {
return {
uniforms: {
innerColor: { value: new THREE.Color(0x000000) },
outerColor: { value: new THREE.Color(0xffffff) },
innerOpacity: { value: 0.3 },
outerOpacity: { value: 0.0 },
falloffPower: { value: 1.2 },
shadowRadius: { value: 7 },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 innerColor;
uniform vec3 outerColor;
uniform float innerOpacity;
uniform float outerOpacity;
uniform float falloffPower;
uniform float shadowRadius;
varying vec2 vUv;
void main() {
vec2 center = vec2(0.5, 0.5);
float dist = distance(vUv, center) * 2.0;
float shadowFalloff = 1.0 - smoothstep(0.0, shadowRadius, dist);
float spotlightFalloff = 1.0 - smoothstep(0.0, 1.0, pow(dist, falloffPower));
vec3 color = mix(outerColor, innerColor, shadowFalloff);
float opacity = mix(outerOpacity, innerOpacity * shadowFalloff, spotlightFalloff);
gl_FragColor = vec4(color, opacity);
}
`,
transparent: true,
depthWrite: false,
depthTest: false,
}
}
function installDamageFlashShader(material: THREE.MeshStandardMaterial, intensity: number) {
const damageMaterial = material as DamageFlashMaterial
if (damageMaterial.userData.damageFlashShaderInstalled) {
return
}
const previousOnBeforeCompile = material.onBeforeCompile.bind(material)
const previousCustomProgramCacheKey = material.customProgramCacheKey.bind(material)
material.onBeforeCompile = (shader, renderer) => {
previousOnBeforeCompile(shader, renderer)
shader.uniforms.uDamageFlashIntensity = { value: intensity }
shader.uniforms.uDamageFlashColor = { value: DAMAGE_FLASH_COLOR }
shader.fragmentShader = shader.fragmentShader.replace(
'#include <common>',
'#include <common>\nuniform float uDamageFlashIntensity;\nuniform vec3 uDamageFlashColor;',
)
shader.fragmentShader = shader.fragmentShader.replace(
'#include <dithering_fragment>',
'gl_FragColor.rgb = mix(gl_FragColor.rgb, uDamageFlashColor, uDamageFlashIntensity * gl_FragColor.a);\n#include <dithering_fragment>',
)
damageMaterial.userData.damageFlashShader = shader
}
material.customProgramCacheKey = () =>
`${previousCustomProgramCacheKey()}|${DAMAGE_FLASH_SHADER_KEY}`
damageMaterial.userData.damageFlashShaderInstalled = true
material.needsUpdate = true
}
function syncDamageFlashMaterial(material: THREE.MeshStandardMaterial, intensity: number) {
installDamageFlashShader(material, intensity)
const shader = (material as DamageFlashMaterial).userData.damageFlashShader
if (shader) {
shader.uniforms.uDamageFlashIntensity.value = intensity
}
}
export function syncDamageFlashShader(scene: THREE.Object3D | null, intensity: number) {
if (!scene) return
scene.traverse((object) => {
const mesh = object as THREE.Mesh
if (!mesh.isMesh || !mesh.material) return
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
materials.forEach((material) => {
if (!(material instanceof THREE.MeshStandardMaterial) || material.name === 'cape') return
syncDamageFlashMaterial(material, intensity)
})
})
}
@@ -0,0 +1,6 @@
export * from './types'
export * from './use-skin-preview-animation'
export * from './use-skin-preview-controls'
export * from './use-skin-preview-fit'
export * from './use-skin-preview-loading'
export * from './use-skin-preview-scene'
@@ -0,0 +1,28 @@
export interface SkinPreviewAnimationConfig {
baseAnimation: string
randomAnimations: string[]
randomAnimationInterval?: number
transitionDuration?: number
}
export type SkinPreviewFraming = 'page' | 'modal'
export interface SkinPreviewFitPadding {
top: number
right: number
bottom: number
left: number
}
export interface SkinPreviewFitLock {
containerSize: {
width: number
height: number
}
modelCenter: SkinPreviewTuple
modelSize: SkinPreviewTuple
padding: SkinPreviewFitPadding
rotation: number
}
export type SkinPreviewTuple = [number, number, number]
@@ -0,0 +1,406 @@
import { useRenderLoop } from '@tresjs/core'
import * as THREE from 'three'
import { computed, type ComputedRef, type Ref, ref, watch } from 'vue'
import type { SkinPreviewAnimationConfig } from './types'
type AnimationFinishedListener = (
event: THREE.AnimationMixerEventMap['finished'] & {
readonly type: 'finished'
readonly target: THREE.AnimationMixer
},
) => void
export const INTERACT_ANIMATION_NAME = 'interact'
const INTERACT_VISIBLE_DURATION_SECONDS = 0.5
const CLICK_IMPULSE_MAX_ENERGY = 5
const CLICK_IMPULSE_ENERGY_PER_CLICK = 1
const CLICK_IMPULSE_DECAY_PER_SECOND = 6
const CLICK_IMPULSE_BASE_SPEED = 18
const CLICK_IMPULSE_SPEED_BOOST = 7
const CLICK_IMPULSE_OFFSET_X = 0.035
const CLICK_IMPULSE_ROTATION_Z = 0.055
const CLICK_IMPULSE_SCALE_X = 0.018
const CLICK_IMPULSE_SCALE_Y = 0.025
const DAMAGE_FLASH_DURATION_SECONDS = 0.2
const DAMAGE_FLASH_REPEAT_DELAY_SECONDS = 0.5
const DAMAGE_FLASH_MAX_INTENSITY = 0.7
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
export function useSkinPreviewAnimation(
animationConfig: MaybeReadonlyRef<SkinPreviewAnimationConfig | undefined>,
) {
const mixer = ref<THREE.AnimationMixer | null>(null)
const actions = ref<Record<string, THREE.AnimationAction>>({})
const clock = new THREE.Clock()
const currentAnimation = ref<string>('')
const randomAnimationTimer = ref<number | null>(null)
const lastRandomAnimation = ref<string>('')
const animationFinishedListeners: AnimationFinishedListener[] = []
const clickImpulseEnergy = ref(0)
const clickImpulsePhase = ref(0)
const clickImpulseOffsetX = ref(0)
const clickImpulseRotationZ = ref(0)
const clickImpulseScaleX = ref(1)
const clickImpulseScaleY = ref(1)
const damageFlashIntensity = ref(0)
let damageFlashRemainingSeconds = 0
let damageFlashCooldownSeconds = 0
const baseAnimation = computed(() => animationConfig.value?.baseAnimation ?? '')
const randomAnimations = computed(() => animationConfig.value?.randomAnimations ?? [])
const transitionDuration = computed(() => animationConfig.value?.transitionDuration || 0.3)
function initializeAnimations(loadedScene: THREE.Object3D, clips: THREE.AnimationClip[]) {
if (!clips || clips.length === 0) {
console.warn('No animation clips found in the model')
return
}
mixer.value = new THREE.AnimationMixer(loadedScene)
clock.start()
actions.value = {}
clips.forEach((clip) => {
if (clip.name === INTERACT_ANIMATION_NAME) {
clip.duration = INTERACT_VISIBLE_DURATION_SECONDS
}
const action = mixer.value!.clipAction(clip)
action.setLoop(THREE.LoopOnce, 1)
action.clampWhenFinished = true
actions.value[clip.name] = action
})
if (baseAnimation.value && actions.value[baseAnimation.value]) {
actions.value[baseAnimation.value].setLoop(THREE.LoopRepeat, Infinity)
playAnimation(baseAnimation.value, true)
setupRandomAnimationLoop()
} else {
console.warn(`Base animation "${baseAnimation.value}" not found`)
const firstAnimationName = Object.keys(actions.value)[0]
if (firstAnimationName) {
actions.value[firstAnimationName].setLoop(THREE.LoopRepeat, Infinity)
playAnimation(firstAnimationName, true)
}
}
}
function playAnimation(name: string, immediate = false) {
if (!mixer.value || !actions.value[name]) {
console.warn(`Animation "${name}" not found!`)
return false
}
const action = actions.value[name]
if (currentAnimation.value === name && action.isRunning() && name !== baseAnimation.value) {
console.log(`Animation "${name}" is already running, ignoring request`)
return false
}
Object.entries(actions.value).forEach(([actionName, actionInstance]) => {
if (actionName !== name && actionInstance.isRunning()) {
actionInstance.fadeOut(transitionDuration.value)
}
})
action.reset()
if (name === baseAnimation.value) {
action.setLoop(THREE.LoopRepeat, Infinity)
} else {
action.setLoop(THREE.LoopOnce, 1)
action.clampWhenFinished = true
const onFinished: AnimationFinishedListener = (event) => {
if (event.action === action) {
removeAnimationFinishedListener(onFinished)
if (currentAnimation.value === name && baseAnimation.value) {
action.fadeOut(transitionDuration.value)
const baseAction = actions.value[baseAnimation.value]
if (baseAction) {
baseAction.reset()
baseAction.fadeIn(transitionDuration.value)
baseAction.play()
currentAnimation.value = baseAnimation.value
}
}
}
}
addAnimationFinishedListener(onFinished)
}
if (immediate) {
action.setEffectiveWeight(1)
} else {
action.fadeIn(transitionDuration.value)
}
action.play()
if (immediate) {
mixer.value.update(0)
}
currentAnimation.value = name
return true
}
function setupRandomAnimationLoop() {
const interval = animationConfig.value?.randomAnimationInterval || 10000
function scheduleNextAnimation() {
if (randomAnimationTimer.value) {
clearTimeout(randomAnimationTimer.value)
}
randomAnimationTimer.value = window.setTimeout(() => {
if (randomAnimations.value.length > 0 && currentAnimation.value === baseAnimation.value) {
const availableAnimations = randomAnimations.value.filter(
(anim) => anim !== lastRandomAnimation.value,
)
const animationsToChooseFrom =
availableAnimations.length > 0 ? availableAnimations : randomAnimations.value
const randomIndex = Math.floor(Math.random() * animationsToChooseFrom.length)
const randomAnimationName = animationsToChooseFrom[randomIndex]
if (actions.value[randomAnimationName]) {
lastRandomAnimation.value = randomAnimationName
playRandomAnimation(randomAnimationName)
}
} else {
scheduleNextAnimation()
}
}, interval)
}
scheduleNextAnimation()
}
function playRandomAnimation(name: string) {
if (!mixer.value || !actions.value[name]) {
console.warn(`Animation "${name}" not found!`)
return
}
const action = actions.value[name]
if (currentAnimation.value === name && action.isRunning()) {
console.log(`Animation "${name}" is already running, ignoring request`)
return
}
const baseAction = baseAnimation.value ? actions.value[baseAnimation.value] : undefined
if (baseAction?.isRunning()) {
baseAction.fadeOut(transitionDuration.value)
}
action.reset()
action.setLoop(THREE.LoopOnce, 1)
action.clampWhenFinished = true
action.setEffectiveTimeScale(1)
action.fadeIn(transitionDuration.value)
action.play()
currentAnimation.value = name
const onFinished: AnimationFinishedListener = (event) => {
if (event.action === action) {
removeAnimationFinishedListener(onFinished)
if (currentAnimation.value === name && baseAnimation.value) {
action.fadeOut(transitionDuration.value)
const nextBaseAction = actions.value[baseAnimation.value]
if (nextBaseAction) {
nextBaseAction.reset()
nextBaseAction.setEffectiveTimeScale(1)
nextBaseAction.fadeIn(transitionDuration.value)
nextBaseAction.play()
currentAnimation.value = baseAnimation.value
setupRandomAnimationLoop()
}
}
}
}
addAnimationFinishedListener(onFinished)
}
function playInteractAnimation() {
if (actions.value[INTERACT_ANIMATION_NAME]) {
playRandomAnimation(INTERACT_ANIMATION_NAME)
}
}
function playClickInteraction() {
addClickImpulse()
playInteractAnimation()
}
function addClickImpulse() {
clickImpulseEnergy.value = Math.min(
CLICK_IMPULSE_MAX_ENERGY,
clickImpulseEnergy.value + CLICK_IMPULSE_ENERGY_PER_CLICK,
)
if (clickImpulseEnergy.value >= CLICK_IMPULSE_MAX_ENERGY && damageFlashCooldownSeconds <= 0) {
triggerDamageFlash()
}
}
function updateClickImpulse(delta: number) {
const energy = Math.max(0, clickImpulseEnergy.value - CLICK_IMPULSE_DECAY_PER_SECOND * delta)
clickImpulseEnergy.value = energy
if (energy <= 0) {
clickImpulseOffsetX.value = 0
clickImpulseRotationZ.value = 0
clickImpulseScaleX.value = 1
clickImpulseScaleY.value = 1
return
}
const intensity = energy / CLICK_IMPULSE_MAX_ENERGY
clickImpulsePhase.value +=
delta * (CLICK_IMPULSE_BASE_SPEED + energy * CLICK_IMPULSE_SPEED_BOOST)
const shake = Math.sin(clickImpulsePhase.value) * intensity
const squash = Math.abs(Math.sin(clickImpulsePhase.value * 1.7)) * intensity
clickImpulseOffsetX.value = shake * CLICK_IMPULSE_OFFSET_X
clickImpulseRotationZ.value = shake * CLICK_IMPULSE_ROTATION_Z
clickImpulseScaleX.value = 1 + squash * CLICK_IMPULSE_SCALE_X
clickImpulseScaleY.value = 1 - squash * CLICK_IMPULSE_SCALE_Y
}
function triggerDamageFlash() {
damageFlashRemainingSeconds = DAMAGE_FLASH_DURATION_SECONDS
damageFlashCooldownSeconds = DAMAGE_FLASH_DURATION_SECONDS + DAMAGE_FLASH_REPEAT_DELAY_SECONDS
damageFlashIntensity.value = DAMAGE_FLASH_MAX_INTENSITY
}
function updateDamageFlash(delta: number) {
damageFlashCooldownSeconds = Math.max(0, damageFlashCooldownSeconds - delta)
if (damageFlashRemainingSeconds <= 0) {
damageFlashIntensity.value = 0
return
}
damageFlashRemainingSeconds = Math.max(0, damageFlashRemainingSeconds - delta)
damageFlashIntensity.value =
DAMAGE_FLASH_MAX_INTENSITY * (damageFlashRemainingSeconds / DAMAGE_FLASH_DURATION_SECONDS)
}
function stopAnimations() {
if (mixer.value) {
mixer.value.stopAllAction()
}
currentAnimation.value = ''
}
function getAvailableAnimations(): string[] {
return Object.keys(actions.value)
}
function clearRandomAnimationTimer() {
if (randomAnimationTimer.value) {
clearTimeout(randomAnimationTimer.value)
randomAnimationTimer.value = null
}
}
function addAnimationFinishedListener(listener: AnimationFinishedListener) {
mixer.value?.addEventListener('finished', listener)
animationFinishedListeners.push(listener)
}
function removeAnimationFinishedListener(
listener: AnimationFinishedListener,
targetMixer = mixer.value,
) {
targetMixer?.removeEventListener('finished', listener)
const index = animationFinishedListeners.indexOf(listener)
if (index !== -1) {
animationFinishedListeners.splice(index, 1)
}
}
function clearAnimationFinishedListeners(targetMixer = mixer.value) {
animationFinishedListeners.forEach((listener) => {
targetMixer?.removeEventListener('finished', listener)
})
animationFinishedListeners.length = 0
}
function cleanupAnimationState(root: THREE.Object3D | null) {
clearRandomAnimationTimer()
const currentMixer = mixer.value
if (currentMixer) {
clearAnimationFinishedListeners(currentMixer)
currentMixer.stopAllAction()
if (root) {
currentMixer.uncacheRoot(root)
}
}
mixer.value = null
actions.value = {}
currentAnimation.value = ''
lastRandomAnimation.value = ''
damageFlashRemainingSeconds = 0
damageFlashCooldownSeconds = 0
damageFlashIntensity.value = 0
}
watch(
() => animationConfig.value,
(newConfig) => {
clearRandomAnimationTimer()
if (mixer.value && newConfig?.baseAnimation && actions.value[newConfig.baseAnimation]) {
playAnimation(newConfig.baseAnimation)
setupRandomAnimationLoop()
}
},
{ deep: true },
)
const { onLoop } = useRenderLoop()
onLoop(() => {
const delta = clock.getDelta()
if (mixer.value) {
mixer.value.update(delta)
}
updateClickImpulse(delta)
updateDamageFlash(delta)
})
return {
clickImpulseOffsetX,
clickImpulseRotationZ,
clickImpulseScaleX,
clickImpulseScaleY,
cleanupAnimationState,
currentAnimation,
damageFlashIntensity,
getAvailableAnimations,
initializeAnimations,
playAnimation,
playClickInteraction,
stopAnimations,
}
}
@@ -0,0 +1,61 @@
import { type ComputedRef, type Ref, ref } from 'vue'
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
export function useSkinPreviewControls({
initialRotation,
onClickWithoutDrag,
}: {
initialRotation: MaybeReadonlyRef<number | undefined>
onClickWithoutDrag: () => void
}) {
const modelRotation = ref((initialRotation.value ?? 15.75) + Math.PI)
const isDragging = ref(false)
const previousX = ref(0)
const hasDragged = ref(false)
function onPointerDown(event: PointerEvent) {
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
isDragging.value = true
previousX.value = event.clientX
hasDragged.value = false
}
function onPointerMove(event: PointerEvent) {
if (!isDragging.value) return
const deltaX = event.clientX - previousX.value
modelRotation.value += deltaX * 0.01
previousX.value = event.clientX
hasDragged.value = true
}
function onPointerUp(event: PointerEvent) {
isDragging.value = false
const target = event.currentTarget as HTMLElement
if (target.hasPointerCapture(event.pointerId)) {
target.releasePointerCapture(event.pointerId)
}
}
function onCanvasClick() {
if (!hasDragged.value) {
onClickWithoutDrag()
}
hasDragged.value = false
}
function ignoreControlClick(event: MouseEvent) {
event.stopPropagation()
}
return {
ignoreControlClick,
modelRotation,
onCanvasClick,
onPointerDown,
onPointerMove,
onPointerUp,
}
}
@@ -0,0 +1,380 @@
import * as THREE from 'three'
import {
computed,
type ComputedRef,
type CSSProperties,
onMounted,
onUnmounted,
type Ref,
ref,
watch,
} from 'vue'
import type {
SkinPreviewFitLock,
SkinPreviewFitPadding,
SkinPreviewFraming,
SkinPreviewTuple,
} from './types'
const FRAMING_PRESETS = {
page: {
fov: 35,
zoom: 0.96,
padding: { top: 0.2, right: 0.14, bottom: 0.3, left: 0.14 },
},
modal: {
fov: 35,
zoom: 1,
padding: { top: 0.1, right: 0.1, bottom: 0.18, left: 0.1 },
},
} satisfies Record<
SkinPreviewFraming,
{ fov: number; zoom: number; padding: SkinPreviewFitPadding }
>
const PREVIEW_CONTROLS_FOOT_OFFSET = 64
const SUBTITLE_CONTROLS_OFFSET = 48
const NAMETAG_HEAD_OFFSET = 16
function cloneModelTuple(tuple: SkinPreviewTuple): SkinPreviewTuple {
return [tuple[0], tuple[1], tuple[2]]
}
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
export function useSkinPreviewFit({
containerElement,
fit,
lockFit,
framing,
fitZoom,
fitPadding,
scale,
fov,
modelRotation,
nametag,
hasSubtitle,
hasNametagBadge,
subtitleWrapped,
modelCenter,
modelSize,
isModelLoaded,
}: {
containerElement: MaybeReadonlyRef<HTMLElement | null>
fit: MaybeReadonlyRef<boolean | undefined>
lockFit: MaybeReadonlyRef<boolean | undefined>
framing: MaybeReadonlyRef<SkinPreviewFraming | undefined>
fitZoom: MaybeReadonlyRef<number | undefined>
fitPadding: MaybeReadonlyRef<Partial<SkinPreviewFitPadding> | undefined>
scale: MaybeReadonlyRef<number | undefined>
fov: MaybeReadonlyRef<number | undefined>
modelRotation: MaybeReadonlyRef<number>
nametag: MaybeReadonlyRef<string | undefined>
hasSubtitle: MaybeReadonlyRef<boolean>
hasNametagBadge: MaybeReadonlyRef<boolean>
subtitleWrapped: MaybeReadonlyRef<boolean>
modelCenter: MaybeReadonlyRef<SkinPreviewTuple>
modelSize: MaybeReadonlyRef<SkinPreviewTuple>
isModelLoaded: MaybeReadonlyRef<boolean>
}) {
const containerSize = ref({ width: 1, height: 1 })
const fitLock = ref<SkinPreviewFitLock | null>(null)
let resizeObserver: ResizeObserver | undefined
const fitEnabled = computed(() => {
if (fit.value !== undefined) return fit.value
return scale.value === undefined && fov.value === undefined
})
const currentFraming = computed<SkinPreviewFraming>(() => framing.value ?? 'page')
const lockFitEnabled = computed(() => currentFraming.value === 'page' || (lockFit.value ?? true))
const legacyScale = computed(() => scale.value ?? 1)
const legacyFov = computed(() => fov.value ?? 40)
const hasUsableFitSize = computed(
() => containerSize.value.width > 1 && containerSize.value.height > 1,
)
const hasResolvedFit = computed(
() =>
!fitEnabled.value || (lockFitEnabled.value ? fitLock.value !== null : hasUsableFitSize.value),
)
const fitContainerSize = computed(() =>
lockFitEnabled.value
? (fitLock.value?.containerSize ?? containerSize.value)
: containerSize.value,
)
const fitModelCenter = computed(() =>
lockFitEnabled.value ? (fitLock.value?.modelCenter ?? modelCenter.value) : modelCenter.value,
)
const fitModelSize = computed(() =>
lockFitEnabled.value ? (fitLock.value?.modelSize ?? modelSize.value) : modelSize.value,
)
const fitModelRotation = computed(() =>
lockFitEnabled.value ? (fitLock.value?.rotation ?? modelRotation.value) : modelRotation.value,
)
const resolvedFitPadding = computed<SkinPreviewFitPadding>(() => {
const preset = FRAMING_PRESETS[currentFraming.value].padding
return {
top: Math.max(preset.top, hasNametagBadge.value ? 0.28 : nametag.value ? 0.2 : 0),
right: preset.right,
bottom: Math.max(preset.bottom, hasSubtitle.value ? 0.28 : preset.bottom),
left: preset.left,
...(fitPadding.value ?? {}),
}
})
const fitResolvedPadding = computed(() =>
lockFitEnabled.value
? (fitLock.value?.padding ?? resolvedFitPadding.value)
: resolvedFitPadding.value,
)
const modelOffset = computed<SkinPreviewTuple>(() => {
if (!fitEnabled.value) return [0, 0, 0]
const [x, y, z] = fitModelCenter.value
return [-x, -y, -z]
})
const modelGroupPosition = computed<SkinPreviewTuple>(() => {
if (fitEnabled.value) return [0, 0, 0]
return [0, -0.05 * legacyScale.value, 1.95]
})
const modelGroupScale = computed<SkinPreviewTuple>(() => {
if (fitEnabled.value) return [1, 1, 1]
const resolvedScale = 0.8 * legacyScale.value
return [resolvedScale, resolvedScale, resolvedScale]
})
const fittedCamera = computed(() => {
const width = Math.max(fitContainerSize.value.width, 1)
const height = Math.max(fitContainerSize.value.height, 1)
const aspect = width / height
const preset = FRAMING_PRESETS[currentFraming.value]
const padding = fitResolvedPadding.value
const usableWidth = Math.max(width * (1 - padding.left - padding.right), 1)
const usableHeight = Math.max(height * (1 - padding.top - padding.bottom), 1)
const [sizeX, sizeY, sizeZ] = fitModelSize.value
const halfWidth = Math.sqrt((sizeX / 2) ** 2 + (sizeZ / 2) ** 2)
const halfHeight = sizeY / 2
const resolvedFov = fov.value ?? preset.fov
const verticalFov = THREE.MathUtils.degToRad(resolvedFov)
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspect)
const paddedHalfWidth = halfWidth * (width / usableWidth)
const paddedHalfHeight = halfHeight * (height / usableHeight)
const zoom = Math.max((fitZoom.value ?? 1) * preset.zoom, 0.01)
const distance =
Math.max(
paddedHalfHeight / Math.tan(verticalFov / 2),
paddedHalfWidth / Math.tan(horizontalFov / 2),
) / zoom
const visibleHalfHeight = distance * Math.tan(verticalFov / 2)
const targetY = -(padding.bottom - padding.top) * visibleHalfHeight
return {
fov: resolvedFov,
position: [0, targetY, -distance] as SkinPreviewTuple,
target: [0, targetY, 0] as SkinPreviewTuple,
}
})
const cameraConfig = computed(() => {
if (fitEnabled.value) return fittedCamera.value
return {
fov: legacyFov.value,
position: [0, 1.5, -3.25] as SkinPreviewTuple,
target: modelCenter.value,
}
})
const modelFeetTop = computed(() => {
if (!fitEnabled.value) return null
const height = Math.max(containerSize.value.height, 1)
const [, sizeY] = fitModelSize.value
const { fov: resolvedFov, position, target } = cameraConfig.value
const distance = Math.max(Math.abs(position[2] - target[2]), 0.001)
const verticalFov = THREE.MathUtils.degToRad(resolvedFov)
const modelFeetY = -sizeY / 2
const projectedY =
(modelFeetY - target[1]) / distance / Math.max(Math.tan(verticalFov / 2), 0.001)
const topPercent = THREE.MathUtils.clamp(((1 - projectedY) / 2) * 100, 0, 100)
return (topPercent / 100) * height
})
const previewControlsTop = computed(() =>
modelFeetTop.value === null ? null : modelFeetTop.value + PREVIEW_CONTROLS_FOOT_OFFSET,
)
const previewControlsPositionStyle = computed<CSSProperties>(() => {
if (!fitEnabled.value || currentFraming.value !== 'page' || previewControlsTop.value === null) {
return {
bottom: currentFraming.value === 'modal' ? '6%' : 'calc(15% + 64px)',
}
}
return {
top: `${previewControlsTop.value}px`,
}
})
const subtitlePositionStyle = computed<CSSProperties>(() => {
if (!fitEnabled.value || currentFraming.value !== 'page' || previewControlsTop.value === null) {
return {
bottom:
currentFraming.value === 'modal'
? '6%'
: subtitleWrapped.value
? 'calc(15% - 32px)'
: '15%',
}
}
return {
top: `${previewControlsTop.value + SUBTITLE_CONTROLS_OFFSET}px`,
}
})
const nametagTop = computed(() => {
if (!fitEnabled.value) return '18%'
const height = Math.max(containerSize.value.height, 1)
const [sizeX, sizeY, sizeZ] = fitModelSize.value
const { fov: resolvedFov, position, target } = cameraConfig.value
const verticalFov = THREE.MathUtils.degToRad(resolvedFov)
const modelTopY = sizeY / 2
const halfX = sizeX / 2
const halfZ = sizeZ / 2
const sinRotation = Math.sin(fitModelRotation.value)
const cosRotation = Math.cos(fitModelRotation.value)
const modelTopZ = -Math.abs(halfX * sinRotation) - Math.abs(halfZ * cosRotation)
const distance = Math.max(Math.abs(position[2] - target[2]) + modelTopZ, 0.001)
const projectedY =
(modelTopY - target[1]) / distance / Math.max(Math.tan(verticalFov / 2), 0.001)
const topPercent = ((1 - projectedY) / 2) * 100
return `${(topPercent / 100) * height - NAMETAG_HEAD_OFFSET}px`
})
const spotlightY = computed(() => {
if (!fitEnabled.value) return -0.1 * legacyScale.value
const [, sizeY] = fitModelSize.value
return -sizeY / 2 - 0.02
})
const spotlightPosition = computed<SkinPreviewTuple>(() => [
0,
spotlightY.value,
fitEnabled.value ? 0 : 2,
])
const spotlightScale = computed<SkinPreviewTuple>(() => {
if (!fitEnabled.value) {
const resolvedScale = 0.75 * legacyScale.value
return [resolvedScale, resolvedScale, resolvedScale]
}
const [sizeX, , sizeZ] = fitModelSize.value
const radius = Math.max(sizeX, sizeZ, 1) * 0.8
return [radius, radius, radius]
})
function lockFitState() {
if (!fitEnabled.value || !lockFitEnabled.value || fitLock.value || !isModelLoaded.value) return
const { width, height } = containerSize.value
if (width <= 1 || height <= 1) return
fitLock.value = {
containerSize: { width, height },
modelCenter: cloneModelTuple(modelCenter.value),
modelSize: cloneModelTuple(modelSize.value),
padding: { ...resolvedFitPadding.value },
rotation: modelRotation.value,
}
}
function resetFitLockForLayoutChange() {
if (!fitEnabled.value || !lockFitEnabled.value) return
fitLock.value = null
lockFitState()
}
onMounted(() => {
const el = containerElement.value
if (!el) return
resizeObserver = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect
const nextContainerSize = {
width: Math.max(width, 1),
height: Math.max(height, 1),
}
const didContainerSizeChange =
nextContainerSize.width !== containerSize.value.width ||
nextContainerSize.height !== containerSize.value.height
containerSize.value = nextContainerSize
if (didContainerSizeChange) {
resetFitLockForLayoutChange()
}
})
resizeObserver.observe(el)
})
watch(
() => isModelLoaded.value,
(loaded) => {
if (loaded) lockFitState()
},
)
watch(
() => lockFitEnabled.value,
() => {
fitLock.value = null
lockFitState()
},
)
watch(fitEnabled, () => {
fitLock.value = null
lockFitState()
})
onUnmounted(() => {
resizeObserver?.disconnect()
})
return {
cameraConfig,
currentFraming,
fitEnabled,
hasResolvedFit,
legacyScale,
modelGroupPosition,
modelGroupScale,
modelOffset,
nametagTop,
previewControlsPositionStyle,
spotlightPosition,
spotlightScale,
subtitlePositionStyle,
}
}
@@ -0,0 +1,87 @@
import { computed, type ComputedRef, onUnmounted, type Ref, ref, watch } from 'vue'
const LOADING_INDICATOR_DELAY_MS = 200
const LOADING_INDICATOR_MIN_MS = 250
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
export function useSkinPreviewLoading(isReady: MaybeReadonlyRef<boolean>) {
const showLoading = ref(false)
const isPreviewVisible = computed(() => isReady.value && !showLoading.value)
let loadingIndicatorDelayTimer: number | null = null
let loadingIndicatorMinTimer: number | null = null
let loadingIndicatorShownAt = 0
function clearLoadingIndicatorDelayTimer() {
if (loadingIndicatorDelayTimer !== null) {
clearTimeout(loadingIndicatorDelayTimer)
loadingIndicatorDelayTimer = null
}
}
function clearLoadingIndicatorMinTimer() {
if (loadingIndicatorMinTimer !== null) {
clearTimeout(loadingIndicatorMinTimer)
loadingIndicatorMinTimer = null
}
}
function hideLoadingIndicatorAfterMinimum() {
const visibleFor = Date.now() - loadingIndicatorShownAt
const remaining = LOADING_INDICATOR_MIN_MS - visibleFor
if (remaining <= 0) {
showLoading.value = false
return
}
loadingIndicatorMinTimer = window.setTimeout(() => {
showLoading.value = false
loadingIndicatorMinTimer = null
}, remaining)
}
watch(
() => isReady.value,
(ready) => {
clearLoadingIndicatorDelayTimer()
if (ready) {
if (showLoading.value) {
clearLoadingIndicatorMinTimer()
hideLoadingIndicatorAfterMinimum()
}
return
}
clearLoadingIndicatorMinTimer()
if (showLoading.value || typeof window === 'undefined') {
return
}
loadingIndicatorDelayTimer = window.setTimeout(() => {
loadingIndicatorDelayTimer = null
if (isReady.value) {
return
}
showLoading.value = true
loadingIndicatorShownAt = Date.now()
}, LOADING_INDICATOR_DELAY_MS)
},
{ immediate: true },
)
onUnmounted(() => {
clearLoadingIndicatorDelayTimer()
clearLoadingIndicatorMinTimer()
})
return {
isPreviewVisible,
showLoading,
}
}
@@ -0,0 +1,316 @@
import { useGLTF } from '@tresjs/cientos'
import { useTexture } from '@tresjs/core'
import * as THREE from 'three'
import { clone as cloneSkeleton } from 'three/examples/jsm/utils/SkeletonUtils.js'
import {
type ComputedRef,
markRaw,
onBeforeMount,
onUnmounted,
type Ref,
ref,
shallowRef,
watch,
} from 'vue'
import {
applyCapeTexture,
applyTexture,
createTransparentTexture,
loadTexture as loadSkinTexture,
} from '#ui/utils/webgl/skin-rendering.ts'
import type { SkinPreviewTuple } from './types'
const SKIN_LAYER_DEPTH_BIAS = -1
function configureSkinPreviewMesh(mesh: THREE.Mesh) {
const isSkinLayer = mesh.name.endsWith('_Layer')
mesh.renderOrder = 0
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
materials.forEach((material) => {
if (!(material instanceof THREE.MeshStandardMaterial) || material.name === 'cape') return
material.transparent = false
material.alphaTest = 0.1
material.depthTest = true
material.depthWrite = true
material.polygonOffset = isSkinLayer
material.polygonOffsetFactor = isSkinLayer ? SKIN_LAYER_DEPTH_BIAS : 0
material.polygonOffsetUnits = isSkinLayer ? SKIN_LAYER_DEPTH_BIAS : 0
material.needsUpdate = true
})
}
function cloneSceneForRenderer(source: THREE.Object3D) {
const cloned = cloneSkeleton(source)
cloned.traverse((object) => {
const mesh = object as THREE.Mesh
if (!mesh.isMesh || !mesh.material) return
mesh.material = Array.isArray(mesh.material)
? mesh.material.map((material) => material.clone())
: mesh.material.clone()
configureSkinPreviewMesh(mesh)
})
return markRaw(cloned)
}
function disposeSceneMaterials(root: THREE.Object3D | null) {
if (!root) return
const materials = new Set<THREE.Material>()
root.traverse((object) => {
const mesh = object as THREE.Mesh
if (!mesh.isMesh || !mesh.material) return
const meshMaterials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
meshMaterials.forEach((material) => materials.add(material))
})
materials.forEach((material) => material.dispose())
}
function getVisibleMeshBox(root: THREE.Object3D): THREE.Box3 | null {
root.updateWorldMatrix(true, true)
const result = new THREE.Box3()
const meshBox = new THREE.Box3()
let found = false
root.traverse((object) => {
const mesh = object as THREE.Mesh
if (!mesh.isMesh || !mesh.geometry || mesh.visible === false) return
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
if (materials.length && materials.every((material) => material.visible === false)) return
if (!mesh.geometry.boundingBox) {
mesh.geometry.computeBoundingBox()
}
if (!mesh.geometry.boundingBox) return
meshBox.copy(mesh.geometry.boundingBox).applyMatrix4(mesh.matrixWorld)
result.union(meshBox)
found = true
})
return found && !result.isEmpty() ? result.clone() : null
}
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
export function useSkinPreviewScene({
selectedModelSrc,
textureSrc,
capeSrc,
initializeAnimations,
cleanupAnimationState,
}: {
selectedModelSrc: MaybeReadonlyRef<string>
textureSrc: MaybeReadonlyRef<string>
capeSrc: MaybeReadonlyRef<string | undefined>
initializeAnimations: (loadedScene: THREE.Object3D, clips: THREE.AnimationClip[]) => void
cleanupAnimationState: (root: THREE.Object3D | null) => void
}) {
const scene = shallowRef<THREE.Object3D | null>(null)
const lastCapeSrc = ref<string | undefined>(undefined)
const loadedModelSrc = ref<string | undefined>(undefined)
const loadedTextureSrc = ref<string | undefined>(undefined)
const loadedCapeSrc = ref<string | undefined>(undefined)
const texture = shallowRef<THREE.Texture | null>(null)
const capeTexture = shallowRef<THREE.Texture | null>(null)
const transparentTexture = createTransparentTexture()
const modelCenter = ref<SkinPreviewTuple>([0, 1, 0])
const modelSize = ref<SkinPreviewTuple>([1, 2, 1])
const isModelLoaded = ref(false)
const isTextureLoaded = ref(false)
let modelLoadVersion = 0
let textureLoadVersion = 0
let capeLoadVersion = 0
let isUnmounted = false
function applyTextureToLoadedModel() {
if (
!scene.value ||
!texture.value ||
loadedModelSrc.value !== selectedModelSrc.value ||
loadedTextureSrc.value !== textureSrc.value
) {
return
}
applyTexture(scene.value, texture.value)
}
function applyCapeTextureToLoadedModel() {
if (!scene.value || loadedModelSrc.value !== selectedModelSrc.value) return
applyCapeTexture(
scene.value,
loadedCapeSrc.value === capeSrc.value ? capeTexture.value : null,
transparentTexture,
)
}
async function loadModel(src: string) {
const loadVersion = ++modelLoadVersion
try {
isModelLoaded.value = false
const { scene: loadedScene, animations } = await useGLTF(src)
const clonedScene = cloneSceneForRenderer(loadedScene)
if (isUnmounted || loadVersion !== modelLoadVersion) {
disposeSceneMaterials(clonedScene)
return
}
const previousScene = scene.value
cleanupAnimationState(previousScene)
disposeSceneMaterials(previousScene)
scene.value = clonedScene
loadedModelSrc.value = src
applyTextureToLoadedModel()
applyCapeTextureToLoadedModel()
if (animations && animations.length > 0) {
initializeAnimations(clonedScene, animations)
}
updateModelInfo()
isModelLoaded.value = true
} catch (error) {
console.error('Failed to load model:', error)
if (!isUnmounted && loadVersion === modelLoadVersion) {
isModelLoaded.value = false
}
}
}
async function loadAndApplyTexture(src: string) {
if (!src) return null
try {
try {
return await loadSkinTexture(src)
} catch {
const tex = await useTexture([src])
tex.colorSpace = THREE.SRGBColorSpace
tex.flipY = false
tex.magFilter = THREE.NearestFilter
tex.minFilter = THREE.NearestFilter
return tex
}
} catch (error) {
console.error('Failed to load texture:', error)
return null
}
}
async function loadAndApplyCapeTexture(src: string | undefined) {
if (src === lastCapeSrc.value) return
const loadVersion = ++capeLoadVersion
lastCapeSrc.value = src
let loadedCapeTexture: THREE.Texture | null = null
if (src) {
loadedCapeTexture = await loadAndApplyTexture(src)
}
if (isUnmounted || loadVersion !== capeLoadVersion) return
capeTexture.value = loadedCapeTexture
loadedCapeSrc.value = src
applyCapeTextureToLoadedModel()
}
function updateModelInfo() {
const box = scene.value ? getVisibleMeshBox(scene.value) : null
if (!box) {
modelCenter.value = [0, 1, 0]
modelSize.value = [1, 2, 1]
return
}
const center = new THREE.Vector3()
const size = new THREE.Vector3()
box.getCenter(center)
box.getSize(size)
modelCenter.value = [center.x, center.y, center.z]
modelSize.value = [Math.max(size.x, 0.001), Math.max(size.y, 0.001), Math.max(size.z, 0.001)]
}
watch(
() => selectedModelSrc.value,
(src) => loadModel(src),
)
watch(
() => textureSrc.value,
async (newSrc) => {
const loadVersion = ++textureLoadVersion
isTextureLoaded.value = false
const loadedTexture = await loadAndApplyTexture(newSrc)
if (isUnmounted || loadVersion !== textureLoadVersion) return
texture.value = loadedTexture
loadedTextureSrc.value = newSrc
applyTextureToLoadedModel()
isTextureLoaded.value = true
},
)
watch(
() => capeSrc.value,
async (newCapeSrc) => {
await loadAndApplyCapeTexture(newCapeSrc)
},
)
onBeforeMount(async () => {
try {
isTextureLoaded.value = false
texture.value = await loadAndApplyTexture(textureSrc.value)
loadedTextureSrc.value = textureSrc.value
isTextureLoaded.value = true
await loadModel(selectedModelSrc.value)
if (capeSrc.value) {
await loadAndApplyCapeTexture(capeSrc.value)
}
} catch (error) {
console.error('Failed to initialize skin preview:', error)
}
})
onUnmounted(() => {
isUnmounted = true
modelLoadVersion++
textureLoadVersion++
capeLoadVersion++
cleanupAnimationState(scene.value)
disposeSceneMaterials(scene.value)
scene.value = null
transparentTexture.dispose()
})
return {
isModelLoaded,
isTextureLoaded,
modelCenter,
modelSize,
scene,
}
}
+100 -78
View File
@@ -1,6 +1,11 @@
import type { Ref } from 'vue'
import { computed, ref, watch, watchEffect } from 'vue'
export interface ScrollViewportOptions {
onScroll?: () => void
onResize?: () => void
}
export interface VirtualScrollOptions {
itemHeight: number
bufferSize?: number
@@ -10,45 +15,35 @@ export interface VirtualScrollOptions {
nearEndThreshold?: number
}
export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptions) {
const {
itemHeight,
bufferSize = 5,
initialItemCount = 20,
enabled,
onNearEnd,
nearEndThreshold = 0.2,
} = options
export function findScrollableAncestor(element: HTMLElement | null): HTMLElement | Window {
if (!element) return window
let current: HTMLElement | null = element.parentElement
while (current) {
const { overflowY } = getComputedStyle(current)
if (overflowY === 'auto' || overflowY === 'scroll') {
return current
}
current = current.parentElement
}
return window
}
export function getScrollTop(container: HTMLElement | Window): number {
return container instanceof Window ? window.scrollY : container.scrollTop
}
export function getViewportHeight(container: HTMLElement | Window): number {
return container instanceof Window ? window.innerHeight : container.clientHeight
}
export function useScrollViewport(options: ScrollViewportOptions = {}) {
const listContainer = ref<HTMLElement | null>(null)
const scrollContainer = ref<HTMLElement | Window | null>(null)
const scrollTop = ref(0)
const viewportHeight = ref(0)
const containerOffset = ref(0)
const totalHeight = computed(() => items.value.length * itemHeight)
function findScrollableAncestor(element: HTMLElement | null): HTMLElement | Window {
if (!element) return window
let current: HTMLElement | null = element.parentElement
while (current) {
const { overflowY } = getComputedStyle(current)
if (overflowY === 'auto' || overflowY === 'scroll') {
return current
}
current = current.parentElement
}
return window
}
function getScrollTop(container: HTMLElement | Window): number {
return container instanceof Window ? window.scrollY : container.scrollTop
}
function getViewportHeight(container: HTMLElement | Window): number {
return container instanceof Window ? window.innerHeight : container.clientHeight
}
const relativeScrollTop = computed(() => Math.max(0, scrollTop.value - containerOffset.value))
function updateContainerOffset() {
const listEl = listContainer.value
@@ -71,6 +66,77 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
updateContainerOffset()
}
function handleScroll() {
if (scrollContainer.value) {
scrollTop.value = getScrollTop(scrollContainer.value)
updateContainerOffset()
}
options.onScroll?.()
}
function handleResize() {
syncScrollState()
options.onResize?.()
}
watchEffect((onCleanup) => {
if (typeof window === 'undefined') return
const listEl = listContainer.value
if (!listEl) return
const container = findScrollableAncestor(listEl)
scrollContainer.value = container
syncScrollState()
container.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('resize', handleResize, { passive: true })
let resizeObserver: ResizeObserver | undefined
if (!(container instanceof Window)) {
resizeObserver = new ResizeObserver(() => {
syncScrollState()
})
resizeObserver.observe(container)
}
onCleanup(() => {
container.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
resizeObserver?.disconnect()
})
})
return {
containerOffset,
listContainer,
relativeScrollTop,
scrollContainer,
scrollTop,
syncScrollState,
updateContainerOffset,
viewportHeight,
}
}
export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptions) {
const {
itemHeight,
bufferSize = 5,
initialItemCount = 20,
enabled,
onNearEnd,
nearEndThreshold = 0.2,
} = options
const { listContainer, relativeScrollTop, scrollContainer, syncScrollState, viewportHeight } =
useScrollViewport({
onScroll: checkNearEnd,
})
const totalHeight = computed(() => items.value.length * itemHeight)
const visibleRange = computed(() => {
if (enabled && !enabled.value) {
return { start: 0, end: items.value.length }
@@ -80,9 +146,7 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
return { start: 0, end: Math.min(items.value.length, initialItemCount) }
}
const relativeScrollTop = Math.max(0, scrollTop.value - containerOffset.value)
const start = Math.floor(relativeScrollTop / itemHeight)
const start = Math.floor(relativeScrollTop.value / itemHeight)
const visibleCount = Math.ceil(viewportHeight.value / itemHeight)
const rangeSize = visibleCount + bufferSize * 2
@@ -117,52 +181,10 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
}
}
function handleScroll() {
if (scrollContainer.value) {
scrollTop.value = getScrollTop(scrollContainer.value)
updateContainerOffset()
}
checkNearEnd()
}
function handleResize() {
syncScrollState()
}
// Re-sync scroll state when items change to avoid stale scrollTop/offset
watch(items, () => {
syncScrollState()
})
watchEffect((onCleanup) => {
if (typeof window === 'undefined') return
const listEl = listContainer.value
if (!listEl) return
const container = findScrollableAncestor(listEl)
scrollContainer.value = container
syncScrollState()
container.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('resize', handleResize, { passive: true })
// Use ResizeObserver for element scroll containers
let resizeObserver: ResizeObserver | undefined
if (!(container instanceof Window)) {
resizeObserver = new ResizeObserver(() => {
syncScrollState()
})
resizeObserver.observe(container)
}
onCleanup(() => {
container.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
resizeObserver?.disconnect()
})
})
return {
listContainer,
totalHeight,
@@ -0,0 +1,102 @@
import { EditIcon, TrashIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import SkinButton from '../../components/skin/SkinButton.vue'
const frontImage = `data:image/svg+xml,${encodeURIComponent(`
<svg width="114" height="176" viewBox="0 0 114 176" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="43" y="4" width="28" height="30" fill="#5A3828"/>
<rect x="36" y="18" width="42" height="38" fill="#B97A57"/>
<rect x="43" y="28" width="8" height="6" fill="#1B1B20"/>
<rect x="63" y="28" width="8" height="6" fill="#1B1B20"/>
<rect x="50" y="40" width="14" height="5" fill="#684432"/>
<rect x="28" y="57" width="58" height="60" fill="#2693C7"/>
<rect x="18" y="63" width="18" height="54" fill="#35A8D8"/>
<rect x="78" y="63" width="18" height="54" fill="#1E7FAC"/>
<rect x="22" y="117" width="14" height="43" fill="#B97A57"/>
<rect x="78" y="117" width="14" height="43" fill="#A96A4D"/>
<rect x="36" y="117" width="18" height="55" fill="#263F5E"/>
<rect x="60" y="117" width="18" height="55" fill="#1D334F"/>
</svg>
)}`)}`
const backImage = `data:image/svg+xml,${encodeURIComponent(`
<svg width="114" height="176" viewBox="0 0 114 176" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="36" y="10" width="42" height="46" fill="#5A3828"/>
<rect x="28" y="57" width="58" height="60" fill="#1E7FAC"/>
<rect x="18" y="63" width="18" height="54" fill="#2693C7"/>
<rect x="78" y="63" width="18" height="54" fill="#19759E"/>
<rect x="22" y="117" width="14" height="43" fill="#A96A4D"/>
<rect x="78" y="117" width="14" height="43" fill="#A96A4D"/>
<rect x="36" y="117" width="18" height="55" fill="#1D334F"/>
<rect x="60" y="117" width="18" height="55" fill="#162B45"/>
</svg>
)}`)}`
const meta = {
title: 'Skin/SkinButton',
component: SkinButton,
args: {
forwardImageSrc: frontImage,
backwardImageSrc: backImage,
selected: false,
active: false,
tooltip: 'Steve',
},
render: (args) => ({
components: { SkinButton },
setup() {
return { args }
},
template: /* html */ `
<div class="w-[156px]">
<SkinButton v-bind="args" />
</div>
`,
}),
} satisfies Meta<typeof SkinButton>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Selected: Story = {
args: {
selected: true,
},
}
export const ActiveUnselected: Story = {
args: {
active: true,
},
}
export const WithActions: Story = {
render: (args) => ({
components: { ButtonStyled, EditIcon, SkinButton, TrashIcon },
setup() {
return { args }
},
template: /* html */ `
<div class="w-[156px]">
<SkinButton v-bind="args">
<template #overlay-buttons>
<ButtonStyled color="brand">
<button class="pointer-events-auto">
<EditIcon /> Edit
</button>
</ButtonStyled>
<ButtonStyled circular color="red">
<button class="pointer-events-auto" aria-label="Delete skin">
<TrashIcon />
</button>
</ButtonStyled>
</template>
</SkinButton>
</div>
`,
}),
}
@@ -0,0 +1,50 @@
import { PlusIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SkinLikeTextButton from '../../components/skin/SkinLikeTextButton.vue'
const meta = {
title: 'Skin/SkinLikeTextButton',
component: SkinLikeTextButton,
args: {
selected: false,
tooltip: 'Add a skin',
dragActive: false,
dropzone: false,
},
render: (args) => ({
components: { PlusIcon, SkinLikeTextButton },
setup() {
return { args }
},
template: /* html */ `
<div class="w-[156px]">
<SkinLikeTextButton v-bind="args">
<template #icon>
<PlusIcon class="size-8" />
</template>
Add skin
<template #subtitle>Drag and drop</template>
</SkinLikeTextButton>
</div>
`,
}),
} satisfies Meta<typeof SkinLikeTextButton>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Selected: Story = {
args: {
selected: true,
},
}
export const DragActive: Story = {
args: {
dragActive: true,
dropzone: true,
},
}
@@ -0,0 +1,273 @@
import { CheckIcon, EyeIcon, RotateCounterClockwiseIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import SkinPreviewRenderer from '../../components/skin/SkinPreviewRenderer.vue'
const steveSkin =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAE5klEQVR4Xu1aMWsUQRgVVJCAoIIgglaJBG2UGIKCOU0hJHZKijRBsAnaWSjaiNjESgtTpT0bmxQWNvkJ+U9n3ube8ubNt7e5xOzdxX3wmNmZb/fmvflmdtm9M2dqcOfmxR44N325KFnn8ecXDwbSrzdxoODO7auleNTdgHfP5hKeKgNUsJff1zqZeBJ9fr2Jg4vW9Ef5Xxiga9+XAkRWLYFTYYDOuGYDxIMU6psf2r6tL54OA3wPUEIkBFeVfr2Jg4r2DRGk2OWZSwnZ7tcbO2hqU9j0tQtJyjMLlGqCnqPX0WtEcSh9PI0jGiBIIVpX8V7yvEg04/z6aPPxNA4V4QNm2/ar5d7vD+u9P1/eFOWvt2tF2/2ZK1ksyGO2aZ0msM3H0zg4IB2oziSEQjCEUzyJvkhUdE3WGc9jH0/j0IGxxC0M4iACQoHnj76WolEHcIwYtOGc6Fo0NDJoLAzwGaIgJWf89eO7BTUDlBTlM6/7gxqCYx9P4+BAUHLmeQ//8XKxfKDBre390nzBpRtTSQxvecwEvaYb4Mc+nsbBpzYV7yaghGiYwJKiaZCey+vpk+Gt61MJ2e7jGTke3vvaUy4sLBScnZ0t6PEZ9vbK5aHGog19fn2nXy7Dzk6vZLebLNHiN44LHxCF0wiPz7AvUsWrCSdhgO5H+B0PHxo+oKNkwEkbMLW7mxkwVhlwoktgX3S0BI6cAU/mt3sgfhzl04c/S7KNJan957a2EurMYFCl+P7Aq+LBpF2FiuCSbPv0KaWfUwcKPKwB2g9isOc3NwuWgvpCmZbeHsUnIr2N7S4ehOiNjQPSAI2rwyCBnh3eD0LI2Y8fC6Je/nC/9DaPdxPcrEwQ2T3ImNAAPacOgwRWGaB1iiHrBHl8IoyiVISnuJPi1QRlHVwgj6M27SNdUDLjZoDOfmRAtva7B5scyQ1Vy4h6juvN4Ab4DEeG6LGndGlAX4gb4PGeIaX4/rHe4nTH1zYVrfHF5luHyABfBoP6KYr0FNZZ9dgo3s+NRKkRkQFad70ZKKxKYJ0BGKSKKTJAZ5TZ0Bfk8aEBEh8ZoEaoASg9xvVm8PRW4UqPIylIxVG4i2OMxyUGiHjE6Ky6OBqgRniM682wsrLSA31zczLOqaIiM1Sgx5YGiGgVD6pYFxeZ4P2uNwMebzudTiJqdXU1E4oYj0Vdl4gapplDIyjMY3T5aAwyscoAzQw3QGNdbwY+41OcC6TwKA51NYDUZcSSM+rxvtcwhu0qxkWCfmv0GNfbokWLFi1atJgc4AVoFf3xmU+dSUyLFi1atGjRokVT8FdpQ39c1XeE+/S3QB4+dnADhv68bgbwTc+hX3GNGm7AUTOAj7qaARNpwLEyYL+uGTCWS0Df/vpLTu/TfsZkHzODbwS6JDKOGocRyWzwPjD5wqsGDDJB20YNNwClG6D9dQaUH0uqBLsho4YLdJHer22FAXXf9+uoWdI3xt8bVPGfGOjieRy1aR/p3/b9Q4d/8FCiz8fTOKoE+2xrv8arGL3f687vt8Kxui1GAuuWgNINGCRc+1j38TQOF4gyMiAyB4xS3YW7AUofT+Pw9PeZ1vZomURr3EUOygofT+Pgl2QKrKJ/bifrxFcJHxsD+LlcRQ3z/4LIABfsx9rm42kc0f8GVCCFR3GoQ0jVPuBZEfX5eIbFX3srPNN8aUvJAAAAAElFTkSuQmCC'
function svgDataUrl(svg: string) {
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
const greenCape = svgDataUrl(
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="32"><rect width="64" height="32" fill="#1f8f2f"/><rect x="1" y="1" width="10" height="16" fill="#59d957"/><rect x="3" y="4" width="6" height="10" fill="#11551d"/></svg>',
)
const pinkCape = svgDataUrl(
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="32"><rect width="64" height="32" fill="#d94fa3"/><rect x="1" y="1" width="10" height="16" fill="#ff9bd4"/><rect x="4" y="3" width="3" height="12" fill="#fff0fa"/></svg>',
)
const meta = {
title: 'Skin/SkinPreviewRenderer',
component: SkinPreviewRenderer,
args: {
textureSrc: steveSkin,
variant: 'CLASSIC',
initialRotation: Math.PI / 8,
},
render: (args) => ({
components: { CheckIcon, EyeIcon, RotateCounterClockwiseIcon, SkinPreviewRenderer },
setup() {
return { args }
},
template: /* html */ `
<div class="h-[80vh] min-h-[32rem] max-h-[48rem] w-[22rem]">
<SkinPreviewRenderer v-bind="args" nametag="Steve">
<template #nametag-badge>
<div class="flex items-center justify-center gap-1.5 rounded-full border border-solid border-brand-blue bg-bg-blue px-3 py-1 text-base font-semibold leading-6 text-brand-blue">
<EyeIcon class="size-5 shrink-0" /> Previewing
</div>
</template>
<template #subtitle>
<div class="flex max-w-40 flex-wrap items-center justify-center gap-2 px-2">
<button class="flex h-10 items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 text-contrast shadow-md">
<RotateCounterClockwiseIcon class="size-5" /> Reset
</button>
<button class="flex h-10 items-center justify-center gap-2 rounded-[14px] border-0 bg-brand px-4 py-2.5 text-base font-semibold leading-5 text-[rgba(0,0,0,0.9)] shadow-md">
<CheckIcon class="size-5" /> Apply
</button>
</div>
</template>
</SkinPreviewRenderer>
</div>
`,
}),
} satisfies Meta<typeof SkinPreviewRenderer>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const RepeatInteract: Story = {
render: (args) => ({
components: { SkinPreviewRenderer },
setup() {
return { args }
},
template: /* html */ `
<div class="h-[80vh] min-h-[32rem] max-h-[48rem] w-[22rem]">
<SkinPreviewRenderer v-bind="args" />
</div>
`,
}),
}
export const DamageFlash: Story = {
render: (args) => ({
components: { SkinPreviewRenderer },
setup() {
type SkinPreviewRendererExpose = {
playClickInteraction: () => void
}
const preview = ref<SkinPreviewRendererExpose | null>(null)
function triggerDamageFlash() {
for (let i = 0; i < 5; i++) {
preview.value?.playClickInteraction()
}
}
return { args, preview, triggerDamageFlash }
},
template: /* html */ `
<div class="flex items-start gap-6">
<div class="h-[80vh] min-h-[32rem] max-h-[48rem] w-[22rem]">
<SkinPreviewRenderer ref="preview" v-bind="args" />
</div>
<button class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-primary" @click="triggerDamageFlash">
Trigger damage flash
</button>
</div>
`,
}),
}
export const AnimationControls: Story = {
render: (args) => ({
components: { SkinPreviewRenderer },
setup() {
type SkinPreviewRendererExpose = {
getAvailableAnimations: () => string[]
getCurrentAnimation: () => string
playAnimation: (name: string) => boolean
stopAnimations: () => void
}
const preview = ref<SkinPreviewRendererExpose | null>(null)
const availableAnimations = ref<string[]>([])
const currentAnimation = ref('')
function refreshAnimations() {
availableAnimations.value = preview.value?.getAvailableAnimations() ?? []
currentAnimation.value = preview.value?.getCurrentAnimation() ?? ''
}
function playAnimation(name: string) {
preview.value?.playAnimation(name)
currentAnimation.value = name
}
return {
args,
availableAnimations,
currentAnimation,
playAnimation,
preview,
refreshAnimations,
}
},
template: /* html */ `
<div class="flex items-start gap-6">
<div class="h-[80vh] min-h-[32rem] max-h-[48rem] w-[22rem]">
<SkinPreviewRenderer ref="preview" v-bind="args" />
</div>
<div class="flex max-w-[18rem] flex-col gap-2">
<button class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-primary" @click="refreshAnimations">
Refresh animations
</button>
<div class="text-sm text-secondary">Current: {{ currentAnimation || 'none' }}</div>
<button
v-for="name in availableAnimations"
:key="name"
class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-left text-primary"
@click="playAnimation(name)"
>
{{ name }}
</button>
</div>
</div>
`,
}),
}
export const ResponsiveFit: Story = {
args: {
lockFit: false,
},
}
export const WrappedSubtitle: Story = {
render: (args) => ({
components: { CheckIcon, RotateCounterClockwiseIcon, SkinPreviewRenderer },
setup() {
return { args }
},
template: /* html */ `
<div class="h-[42rem] w-[15rem]">
<SkinPreviewRenderer v-bind="args" nametag="Steve">
<template #subtitle>
<div class="flex max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-2 px-2">
<button class="flex h-10 items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 text-contrast shadow-md">
<RotateCounterClockwiseIcon class="size-5" /> Reset
</button>
<button class="flex h-10 items-center justify-center gap-2 rounded-[14px] border-0 bg-brand px-4 py-2.5 text-base font-semibold leading-5 text-[rgba(0,0,0,0.9)] shadow-md">
<CheckIcon class="size-5" /> Apply
</button>
</div>
</template>
</SkinPreviewRenderer>
</div>
`,
}),
}
export const LayerSeparation: Story = {
args: {
fitZoom: 1.5,
framing: 'modal',
initialRotation: Math.PI / 10,
},
render: (args) => ({
components: { SkinPreviewRenderer },
setup() {
return { args }
},
template: /* html */ `
<div class="h-[28rem] w-[18rem]">
<SkinPreviewRenderer v-bind="args" />
</div>
`,
}),
}
export const FramingModes: Story = {
render: () => ({
components: { CheckIcon, SkinPreviewRenderer },
setup() {
return { steveSkin }
},
template: /* html */ `
<div class="flex flex-wrap items-start gap-8">
<div class="h-[32rem] w-[18rem]">
<SkinPreviewRenderer :texture-src="steveSkin" variant="CLASSIC" framing="page" nametag="Steve">
<template #subtitle>
<button class="flex h-10 items-center justify-center gap-2 rounded-[14px] border-0 bg-brand px-4 py-2.5 text-base font-semibold leading-5 text-[rgba(0,0,0,0.9)] shadow-md">
<CheckIcon class="size-5" /> Apply
</button>
</template>
</SkinPreviewRenderer>
</div>
<div class="h-[25rem] w-[16rem]">
<SkinPreviewRenderer :texture-src="steveSkin" variant="CLASSIC" framing="modal" />
</div>
</div>
`,
}),
}
export const CapeSwitching: Story = {
render: () => ({
components: { SkinPreviewRenderer },
setup() {
const capeSrc = ref<string | undefined>()
return { capeSrc, greenCape, pinkCape, steveSkin }
},
template: /* html */ `
<div class="flex items-start gap-6">
<div class="h-[25rem] w-[16rem]">
<SkinPreviewRenderer
:texture-src="steveSkin"
:cape-src="capeSrc"
variant="CLASSIC"
framing="modal"
:initial-rotation="Math.PI / 8"
/>
</div>
<div class="flex flex-col gap-2">
<button class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-primary" @click="capeSrc = undefined">
None
</button>
<button class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-primary" @click="capeSrc = greenCape">
Green cape
</button>
<button class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-primary" @click="capeSrc = pinkCape">
Pink cape
</button>
</div>
</div>
`,
}),
}
+1
View File
@@ -13,3 +13,4 @@ export * from './tag-messages'
export * from './truncate'
export * from './version-compatibility'
export * from './vue-children'
export * from './webgl/skin-rendering'
@@ -0,0 +1,284 @@
import * as THREE from 'three'
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
export interface SkinRendererConfig {
textureColorSpace?: THREE.ColorSpace
textureFlipY?: boolean
textureMagFilter?: THREE.MagnificationTextureFilter
textureMinFilter?: THREE.MinificationTextureFilter
}
const modelCache: Map<string, GLTF> = new Map()
const modelPromiseCache: Map<string, Promise<GLTF>> = new Map()
const textureCache: Map<string, THREE.Texture> = new Map()
const texturePromiseCache: Map<string, Promise<THREE.Texture>> = new Map()
export async function loadModel(modelUrl: string): Promise<GLTF> {
if (modelCache.has(modelUrl)) {
return modelCache.get(modelUrl)!
}
if (modelPromiseCache.has(modelUrl)) {
return modelPromiseCache.get(modelUrl)!
}
const loader = new GLTFLoader()
const promise = new Promise<GLTF>((resolve, reject) => {
loader.load(
modelUrl,
(gltf) => {
modelCache.set(modelUrl, gltf)
resolve(gltf)
},
undefined,
reject,
)
}).finally(() => {
modelPromiseCache.delete(modelUrl)
})
modelPromiseCache.set(modelUrl, promise)
return promise
}
export async function loadTexture(
textureUrl: string,
config: SkinRendererConfig = {},
): Promise<THREE.Texture> {
const cacheKey = `${textureUrl}_${JSON.stringify(config)}`
if (textureCache.has(cacheKey)) {
return textureCache.get(cacheKey)!
}
if (texturePromiseCache.has(cacheKey)) {
return texturePromiseCache.get(cacheKey)!
}
const textureLoader = new THREE.TextureLoader()
const promise = new Promise<THREE.Texture>((resolve, reject) => {
textureLoader.load(
textureUrl,
(texture) => {
texture.colorSpace = config.textureColorSpace ?? THREE.SRGBColorSpace
texture.flipY = config.textureFlipY ?? false
texture.magFilter = config.textureMagFilter ?? THREE.NearestFilter
texture.minFilter = config.textureMinFilter ?? THREE.NearestFilter
textureCache.set(cacheKey, texture)
resolve(texture)
},
undefined,
reject,
)
}).finally(() => {
texturePromiseCache.delete(cacheKey)
})
texturePromiseCache.set(cacheKey, promise)
return promise
}
function applyMap(mat: THREE.MeshStandardMaterial, texture: THREE.Texture | null): boolean {
const hadMap = mat.map !== null
const hasMap = texture !== null
if (mat.map !== texture) {
mat.map = texture
}
return hadMap !== hasMap
}
function setShaderMaterialProperties(
mat: THREE.MeshStandardMaterial,
properties: {
alphaTest: number
flatShading: boolean
side: THREE.Side
toneMapped: boolean
transparent?: boolean
},
): boolean {
let needsUpdate = false
if (mat.alphaTest !== properties.alphaTest) {
mat.alphaTest = properties.alphaTest
needsUpdate = true
}
if (mat.flatShading !== properties.flatShading) {
mat.flatShading = properties.flatShading
needsUpdate = true
}
if (mat.side !== properties.side) {
mat.side = properties.side
needsUpdate = true
}
if (mat.toneMapped !== properties.toneMapped) {
mat.toneMapped = properties.toneMapped
needsUpdate = true
}
if (properties.transparent !== undefined && mat.transparent !== properties.transparent) {
mat.transparent = properties.transparent
needsUpdate = true
}
return needsUpdate
}
function setCommonMaterialProperties(mat: THREE.MeshStandardMaterial): void {
if (mat.metalness !== 0) {
mat.metalness = 0
}
if (mat.color.getHex() !== 0xffffff) {
mat.color.set(0xffffff)
}
if (mat.roughness !== 1) {
mat.roughness = 1
}
if (!mat.depthTest) {
mat.depthTest = true
}
if (!mat.depthWrite) {
mat.depthWrite = true
}
}
export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): void {
model.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
materials.forEach((mat: THREE.Material) => {
if (mat instanceof THREE.MeshStandardMaterial) {
if (mat.name !== 'cape') {
const mapNeedsUpdate = applyMap(mat, texture)
const propertiesNeedUpdate = setShaderMaterialProperties(mat, {
alphaTest: 0.1,
flatShading: true,
side: THREE.FrontSide,
toneMapped: false,
transparent: false,
})
setCommonMaterialProperties(mat)
if (mapNeedsUpdate || propertiesNeedUpdate) {
mat.needsUpdate = true
}
}
}
})
}
})
}
export function applyCapeTexture(
model: THREE.Object3D,
texture: THREE.Texture | null,
transparentTexture?: THREE.Texture,
): void {
model.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
materials.forEach((mat: THREE.Material) => {
if (mat instanceof THREE.MeshStandardMaterial) {
if (mat.name === 'cape') {
const nextMap = texture || transparentTexture || null
const mapNeedsUpdate = applyMap(mat, nextMap)
const propertiesNeedUpdate = setShaderMaterialProperties(mat, {
alphaTest: 0.1,
flatShading: true,
side: THREE.DoubleSide,
toneMapped: false,
transparent: !texture || !!transparentTexture,
})
setCommonMaterialProperties(mat)
if (mapNeedsUpdate || propertiesNeedUpdate) {
mat.needsUpdate = true
}
mat.visible = !!texture
}
}
})
}
})
}
export function findBodyNode(model: THREE.Object3D): THREE.Object3D | null {
let bodyNode: THREE.Object3D | null = null
model.traverse((node) => {
if (node.name === 'Body') {
bodyNode = node
}
})
return bodyNode
}
export function createTransparentTexture(): THREE.Texture {
const canvas = document.createElement('canvas')
canvas.width = canvas.height = 1
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
ctx.clearRect(0, 0, 1, 1)
const texture = new THREE.CanvasTexture(canvas)
texture.needsUpdate = true
texture.colorSpace = THREE.SRGBColorSpace
texture.flipY = false
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
return texture
}
export async function setupSkinModel(
modelUrl: string,
textureUrl: string,
capeTextureUrl?: string,
config: SkinRendererConfig = {},
): Promise<{
model: THREE.Object3D
bodyNode: THREE.Object3D | null
}> {
const [gltf, texture] = await Promise.all([loadModel(modelUrl), loadTexture(textureUrl, config)])
const model = gltf.scene.clone()
applyTexture(model, texture)
if (capeTextureUrl) {
const capeTexture = await loadTexture(capeTextureUrl, config)
applyCapeTexture(model, capeTexture)
}
const bodyNode = findBodyNode(model)
return { model, bodyNode }
}
export function disposeCaches(): void {
Array.from(textureCache.values()).forEach((texture) => {
texture.dispose()
})
textureCache.clear()
texturePromiseCache.clear()
modelCache.clear()
modelPromiseCache.clear()
}