feat(app): skins frontend (#3657)

* chore: typo fix and formatting tidyups

* refactor(theseus): extend auth subsystem to fetch complete user profiles

* chore: fix new `prettier` lints

* chore: document differences between similar `Credentials` methods

* chore: remove dead `profile_run_credentials` plugin command

* feat(app): skin selector backend

* enh(app/skin-selector): better DB intension through deferred FKs, further PNG validations

* chore: fix comment typo spotted by Copilot

* fix: less racy auth token refresh logic

This may help with issues reported by users where the access token is
invalid and can't be used to join servers over long periods of time.

* tweak(app-lib): improve consistency of skin field serialization case

* fix(app-lib/minecraft_skins): fix custom skin removal from DB not working

* Begin skins frontend

* Cape preview

* feat: start on SkinPreviewRenderer

* feat: setting for nametag

* feat: hide nametag setting (sql)

* fix: positioning of meshes

* fix: lighting

* fix: allow dragging off-bounds

* fix: better color mapping

* feat: hide nametag setting (impl)

* feat: Start on edit modal + cape button cleanup + renderer fixes

* feat: Finish new skin modal

* feat: finish cape modal

* feat: skin rendering on load

* fix: logic for Skins.vue

* fix: types

* fix: types (for modal + renderer)

* feat: Editing?

* fix: renderer not updating variant

* fix: mojang username not modrinth username

* feat: batched skin rendering - remove vzge references (apart from capes, wip)

* feat: fix sizing on SkinButton and SkinLikeButton, also implement bust positioning

* feat: capes in preview renderer & baked renders

* fix: lint fixes

* refactor: Start on cleanup and polish

* fix: hide error notification when logged out

* revert: .gltf formatting

* chore(app-frontend): fix typos

* fix(app-lib): delay account skin data deletion to next reboot

This gives users an opportunity to not unexpectedly lose skin data in
case they log off on accident.

* fix: login button & provide/inject AccountsCard

* polish: skin buttons

* fix: imports

* polish: use figma values

* polish: tweak underneath shadow

* polish: cursor grab

* polish: remove green bg from CapeLikeTextButton when selected.

* polish: modal tweaks

* polish: grid tweaks + start on upload skin modal

* polish: drag and drop file flow

* polish: button positioning in SkinButton

* fix: lint issues

* polish: deduplicate model+cape stuff and fix layout

* fix: lint issues

* fix: camel case requirement for make-default

* polish: use indexed db to persist skin previews

* fix: lint issues

* polish: add skin icon sizing

* polish: theme fixes

* feat: animation system for skin preview renderer

* feat(app/minecraft_skins): save current custom external skin when equipping skins

* fix: cape button & dynamic nametag sizing

* feat(theseus): add `normalize_skin_texture` Tauri command

This command lets the app frontend opt in to normalizing the texture of
any skin, which may be in either the legacy 64x32 or newer 64x64 format,
to the newer 64x64 format for display purposes.

* chore: Rust build fixes

* feat: start impl of skin normalization on frontend

* feat(theseus): change parameter type of `normalize_skin_texture` Tauri command

* fix: normalization

* fix(theseus): make new `normalize_skin_texture` command usable

* feat: finish normalization impl

* fix: vueuse issue

* fix: use optimistic approach when changing skins/capes.

* fix: nametag cleanup + scroll fix

* fix: edit modal computedAsync not fast enough for skin preview renderer

* feat: classic player model animations

* chore: fix new Clippy lint

* fix(app-lib): actually delete custom skins with no cape overrides

* fix(app-lib): handle repeated addition of the same skin properly

* refactor(app-lib): simplify DB connection logic a little

* fix: various improvements

* feat: slim animations

* fix: z-fighting on models

* fix: shading + lighting improvements

* fix: shadows

* fix: polish

* fix: polish

* fix: accounts card not having the right head

* fix: lint issues

* fix: build issue

* feat: drag and drop func

* fix: temp disable drag and drop in the modal

* Revert "fix: temp disable drag and drop in the modal"

This reverts commit 33500c564e3f85e6c0a2e83dd9700deda892004d.

* fix: drag and drop working

* fix: lint

* fix: better media queries

* feat(app/skins): revert current custom external skin storing on equip

This reverts commit 0155262ddd081c8677654619a09e814088fdd8b0.

* regen pnpm lock

* pnpm fix

* Make default capes a little more clear

* Lint

---------

Co-authored-by: Alejandro González <me@alegon.dev>
Co-authored-by: Prospector <prospectordev@gmail.com>
This commit is contained in:
IMB11
2025-07-02 21:32:15 +01:00
committed by GitHub
parent 94a7d13af8
commit f95d0d78f2
90 changed files with 6105 additions and 233 deletions

View File

@@ -55,6 +55,7 @@ onUnmounted(() => {
}
})
function updateFade(scrollTop, offsetHeight, scrollHeight) {
console.log(scrollTop, offsetHeight, scrollHeight)
scrollableAtBottom.value = Math.ceil(scrollTop + offsetHeight) >= scrollHeight
scrollableAtTop.value = scrollTop <= 0
}
@@ -64,6 +65,18 @@ function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
</script>
<style lang="scss" scoped>
@property --_top-fade-height {
syntax: '<length-percentage>';
inherits: false;
initial-value: 0%;
}
@property --_bottom-fade-height {
syntax: '<length-percentage>';
inherits: false;
initial-value: 0%;
}
.scrollable-pane-wrapper {
display: flex;
flex-direction: column;
@@ -75,27 +88,25 @@ function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
display: flex;
overflow: hidden;
position: relative;
transition:
--_top-fade-height 0.05s linear,
--_bottom-fade-height 0.05s linear;
--_fade-height: 4rem;
--_fade-height: 3rem;
mask-image: linear-gradient(
transparent,
rgb(0 0 0 / 100%) var(--_top-fade-height, 0%),
rgb(0 0 0 / 100%) calc(100% - var(--_bottom-fade-height, 0%)),
transparent 100%
);
&.top-fade {
mask-image: linear-gradient(transparent, rgb(0 0 0 / 100%) var(--_fade-height));
--_top-fade-height: var(--_fade-height);
}
&.bottom-fade {
mask-image: linear-gradient(
rgb(0 0 0 / 100%) calc(100% - var(--_fade-height)),
transparent 100%
);
}
&.top-fade.bottom-fade {
mask-image: linear-gradient(
transparent,
rgb(0 0 0 / 100%) var(--_fade-height),
rgb(0 0 0 / 100%) calc(100% - var(--_fade-height)),
transparent 100%
);
--_bottom-fade-height: var(--_fade-height);
}
}
.scrollable-pane {

View File

@@ -102,6 +102,13 @@ export { default as PurchaseModal } from './billing/PurchaseModal.vue'
export { default as AddPaymentMethodModal } from './billing/AddPaymentMethodModal.vue'
export { default as ModrinthServersPurchaseModal } from './billing/ModrinthServersPurchaseModal.vue'
// Skins
export { default as SkinPreviewRenderer } from './skin/SkinPreviewRenderer.vue'
export { default as CapeButton } from './skin/CapeButton.vue'
export { default as CapeLikeTextButton } from './skin/CapeLikeTextButton.vue'
export { default as SkinButton } from './skin/SkinButton.vue'
export { default as SkinLikeTextButton } from './skin/SkinLikeTextButton.vue'
// Version
export { default as VersionChannelIndicator } from './version/VersionChannelIndicator.vue'
export { default as VersionFilterControl } from './version/VersionFilterControl.vue'

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { computed } from 'vue'
const emit = defineEmits<{
(e: 'select'): void
}>()
const props = withDefaults(
defineProps<{
name: string | undefined
id: string
texture: string
isEquipped?: boolean
selected?: boolean
faded?: boolean
}>(),
{
isEquipped: false,
selected: undefined,
faded: false,
},
)
console.log(props)
const highlighted = computed(() => props.selected ?? props.isEquipped)
</script>
<template>
<button
v-tooltip="name"
class="block border-0 m-0 p-0 bg-transparent group cursor-pointer"
:aria-label="name"
@click="emit('select')"
>
<span
:class="
highlighted
? `bg-brand highlighted-outer-glow`
: `bg-button-bg brightness-95 group-hover:brightness-100`
"
class="relative block p-[3px] rounded-lg border-0 group-active:scale-95 transition-all"
>
<span
class="block magical-cape-transform rounded-[5px]"
:class="{
'highlighted-inner-shadow': highlighted,
'brightness-[0.3] contrast-[0.8]': faded,
}"
>
<img :src="texture" alt="" />
</span>
<span
v-if="$slots.default || $slots.icon"
class="p-4 absolute inset-0 flex items-center justify-center text-primary font-medium"
>
<span class="mb-1">
<slot name="icon"></slot>
</span>
<span class="text-xs">
<slot></slot>
</span>
</span>
</span>
</button>
</template>
<style lang="scss" scoped>
.magical-cape-transform {
aspect-ratio: 10 / 16;
position: relative;
overflow: hidden;
box-sizing: content-box;
width: 60px;
min-height: 96px;
}
.magical-cape-transform img {
position: absolute;
object-fit: cover;
image-rendering: pixelated;
// scales image up so that the target area of the texture (10x16) is 100% of the container
width: calc(64 / 10 * 100%);
height: calc(32 / 16 * 100%);
// offsets the image so that the target area is in the container
left: calc(1 / 10 * -100%);
top: calc(1 / 16 * -100%);
// scale the image up a little bit to avoid edges from the surrounding texture due to rounding
scale: 1.01;
transform-origin: calc(10 / 2 / 64 * 100%) calc(16 / 2 / 32 * 100%);
}
.highlighted-inner-shadow::before {
content: '';
position: absolute;
inset: 0;
box-shadow: inset 0 0 4px 4px rgba(0, 0, 0, 0.4);
z-index: 2;
}
@supports (background-color: color-mix(in srgb, transparent, transparent)) {
.highlighted-glow::before {
box-shadow: inset 0 0 2px 4px color-mix(in srgb, var(--color-brand), transparent 10%);
}
}
</style>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
const emit = defineEmits<{
(e: 'click'): void
}>()
withDefaults(
defineProps<{
tooltip?: string
highlighted?: boolean
}>(),
{
tooltip: undefined,
highlighted: false,
},
)
</script>
<template>
<button
v-tooltip="tooltip"
class="block border-0 m-0 p-0 bg-transparent group cursor-pointer"
:aria-label="tooltip"
@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',
]"
>
<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">
<slot name="icon"></slot>
</div>
<span class="text-xs">
<slot></slot>
</span>
</span>
</span>
</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>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits<{
(e: 'select'): void
(e: 'edit', event: MouseEvent): void
}>()
const props = withDefaults(
defineProps<{
forwardImageSrc?: string
backwardImageSrc?: string
selected: boolean
tooltip?: string
}>(),
{
forwardImageSrc: undefined,
backwardImageSrc: undefined,
tooltip: undefined,
},
)
const imagesLoaded = ref({
forward: Boolean(props.forwardImageSrc),
backward: Boolean(props.backwardImageSrc),
})
function onImageLoad(type: 'forward' | 'backward') {
imagesLoaded.value[type] = true
}
</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']"
>
<button
class="skin-btn-bg absolute inset-0 cursor-pointer p-0 border-none group-hover:brightness-125"
:class="selected ? 'selected' : ''"
@click="emit('select')"
></button>
<div
v-if="!(imagesLoaded.forward && imagesLoaded.backward)"
class="skeleton-loader w-full h-full"
>
<div class="skeleton absolute inset-0 aspect-[5/7]"></div>
</div>
<span
v-show="imagesLoaded.forward && imagesLoaded.backward"
: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',
]"
>
<img
alt=""
:src="forwardImageSrc"
class="skin-button__image-facing object-contain w-full h-full [backface-visibility:hidden] col-start-1 row-start-1"
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"
height="504"
@load="onImageLoad('backward')"
/>
</span>
<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"
>
<slot name="overlay-buttons" />
</span>
</div>
</template>
<style scoped lang="scss">
.skeleton-loader {
aspect-ratio: 5 / 7;
}
.skeleton {
background: linear-gradient(
90deg,
var(--color-bg) 25%,
var(--color-raised-bg) 50%,
var(--color-bg) 75%
);
background-size: 200% 100%;
animation: wave 1500ms infinite linear;
}
@keyframes wave {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.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);
}
.with-shadow img {
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4));
}
.skin-button__image-parent img {
transition: filter 200ms ease-in-out;
}
.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>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import { ref } from 'vue'
withDefaults(
defineProps<{
selected?: boolean
tooltip?: string
}>(),
{
selected: false,
tooltip: undefined,
},
)
const emit = defineEmits<{ (e: 'click', event: MouseEvent): void }>()
const pressed = ref(false)
</script>
<template>
<div
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']"
>
<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"
@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"
>
<div v-if="$slots.icon" class="mb-2">
<slot name="icon" />
</div>
<span class="text-md text-center px-2 text-primary">
<slot />
</span>
</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>

View File

@@ -0,0 +1,666 @@
<template>
<div ref="skinPreviewContainer" class="relative w-full h-full 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"
>
<span class="text-primary text-xs px-2 py-1 rounded-full backdrop-blur-sm">
Drag to rotate
</span>
</div>
<div
class="absolute bottom-[10%] left-0 right-0 flex justify-center items-center pointer-events-auto z-10"
>
<slot name="subtitle" />
</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 }"
>
{{ nametagText }}
</div>
<TresCanvas
shadows
alpha
:antialias="antialias"
:renderer-options="{
outputColorSpace: THREE.SRGBColorSpace,
toneMapping: THREE.NoToneMapping,
toneMappingExposure: 10.0,
}"
class="transition-opacity duration-500"
:class="{ 'opacity-0': !isReady, 'opacity-100': isReady }"
@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]"
>
<primitive v-if="scene" :object="scene" />
</Group>
<TresMesh
:position="[0, -0.095 * scale, 2]"
:rotation="[-Math.PI / 2, 0, 0]"
:scale="[0.5 * 0.75 * scale, 0.5 * 0.75 * scale, 0.5 * 0.75 * scale]"
>
<TresCircleGeometry :args="[1, 128]" />
<TresMeshBasicMaterial
color="#000000"
:opacity="0.2"
transparent
:depth-write="false"
/>
</TresMesh>
<TresMesh
:position="[0, -0.1 * scale, 2]"
:rotation="[-Math.PI / 2, 0, 0]"
:scale="[0.75 * scale, 0.75 * scale, 0.75 * scale]"
>
<TresCircleGeometry :args="[1, 128]" />
<TresMeshBasicMaterial
:map="radialTexture"
transparent
:depth-write="false"
:blending="THREE.AdditiveBlending"
/>
</TresMesh>
</Group>
</Suspense>
<TresPerspectiveCamera
:make-default.camel="true"
:fov="fov"
:position="[0, 1.5, -3.25]"
:look-at="target"
/>
<TresAmbientLight :intensity="2" />
<TresDirectionalLight :position="[2, 4, 3]" :intensity="1.2" :cast-shadow="true" />
</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 class="text-primary">Loading...</div>
</div>
</div>
</template>
<script setup lang="ts">
import * as THREE from 'three'
import { useGLTF } from '@tresjs/cientos'
import { useTexture, TresCanvas, useRenderLoop } from '@tresjs/core'
import {
shallowRef,
ref,
computed,
watch,
markRaw,
onBeforeMount,
onUnmounted,
toRefs,
useTemplateRef,
} from 'vue'
import {
applyTexture,
applyCapeTexture,
attachCapeToBody,
findBodyNode,
createTransparentTexture,
loadTexture as loadSkinTexture,
} from '@modrinth/utils'
import { useDynamicFontSize } from '../../composables'
interface AnimationConfig {
baseAnimation: string
randomAnimations: string[]
randomAnimationInterval?: number
transitionDuration?: number
}
const props = withDefaults(
defineProps<{
textureSrc: string
slimModelSrc: string
wideModelSrc: string
capeModelSrc?: string
capeSrc?: string
variant?: 'SLIM' | 'CLASSIC' | 'UNKNOWN'
nametag?: string
antialias?: boolean
scale?: number
fov?: number
initialRotation?: number
animationConfig?: AnimationConfig
}>(),
{
variant: 'CLASSIC',
antialias: false,
scale: 1,
fov: 40,
capeModelSrc: '',
capeSrc: undefined,
initialRotation: 15.75,
nametag: undefined,
animationConfig: () => ({
baseAnimation: 'idle',
randomAnimations: ['idle_sub_1', 'idle_sub_2', 'idle_sub_3'],
randomAnimationInterval: 8000,
transitionDuration: 0.2,
}),
},
)
const skinPreviewContainer = useTemplateRef<HTMLElement>('skinPreviewContainer')
const nametagText = computed(() => props.nametag)
const { fontSize: nametagFontSize } = useDynamicFontSize({
containerElement: skinPreviewContainer,
text: nametagText,
baseFontSize: 1.8,
minFontSize: 1.25,
maxFontSize: 2,
padding: 24,
fontFamily: 'inherit',
})
const selectedModelSrc = computed(() =>
props.variant === 'SLIM' ? props.slimModelSrc : props.wideModelSrc,
)
const scene = shallowRef<THREE.Object3D | null>(null)
const capeScene = shallowRef<THREE.Object3D | null>(null)
const bodyNode = shallowRef<THREE.Object3D | null>(null)
const capeAttached = ref(false)
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 { baseAnimation, randomAnimations } = toRefs(props.animationConfig)
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)
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)
}
defineExpose({
playAnimation,
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)
texture.value.needsUpdate = true
}
bodyNode.value = findBodyNode(loadedScene)
capeAttached.value = false
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 loadCape(src: string) {
if (!src) {
capeScene.value = null
return
}
try {
const { scene: loadedCape } = await useGLTF(src)
capeScene.value = markRaw(loadedCape)
applyCapeTexture(capeScene.value, capeTexture.value, transparentTexture)
if (bodyNode.value && !capeAttached.value) {
attachCapeToBodyWrapper()
}
} catch (error) {
console.error('Failed to load cape:', error)
capeScene.value = null
}
}
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 (capeScene.value) {
applyCapeTexture(capeScene.value, capeTexture.value, transparentTexture)
}
if (capeScene.value && bodyNode.value) {
if (!src && capeAttached.value && capeScene.value.parent) {
capeScene.value.parent.remove(capeScene.value)
capeAttached.value = false
} else if (src && !capeAttached.value) {
attachCapeToBodyWrapper()
}
}
}
function attachCapeToBodyWrapper() {
if (!bodyNode.value || !capeScene.value || capeAttached.value) return
attachCapeToBody(bodyNode.value, capeScene.value)
capeAttached.value = true
}
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
}
const radialTexture = createRadialTexture(512)
radialTexture.minFilter = THREE.LinearFilter
radialTexture.magFilter = THREE.LinearFilter
radialTexture.wrapS = radialTexture.wrapT = THREE.ClampToEdgeWrapping
function createRadialTexture(size: number): THREE.CanvasTexture {
const canvas = document.createElement('canvas')
canvas.width = canvas.height = size
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
const grad = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2)
grad.addColorStop(0, 'rgba(119,119,119,0.1)')
grad.addColorStop(0.9, 'rgba(255,255,255,0)')
ctx.fillStyle = grad
ctx.fillRect(0, 0, size, size)
return new THREE.CanvasTexture(canvas)
}
watch(
[bodyNode, capeScene, isModelLoaded],
([newBodyNode, newCapeScene, modelLoaded]) => {
if (newBodyNode && newCapeScene && modelLoaded && !capeAttached.value) {
attachCapeToBodyWrapper()
}
},
{ immediate: true },
)
watch(capeScene, (newCapeScene) => {
if (newCapeScene && bodyNode.value && isModelLoaded.value && !capeAttached.value) {
attachCapeToBodyWrapper()
}
})
watch(selectedModelSrc, (src) => loadModel(src))
watch(
() => props.capeModelSrc,
(src) => src && loadCape(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)
}
if (props.capeModelSrc) {
await loadCape(props.capeModelSrc)
}
} 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">
.nametag-bg {
background: linear-gradient(
308.68deg,
rgba(50, 50, 50, 0.2) -52.46%,
rgba(100, 100, 100, 0.2) 94.75%
),
rgba(0, 0, 0, 0.2);
box-shadow:
inset -0.5px -0.5px 0px rgba(0, 0, 0, 0.25),
inset 0.5px 0.5px 0px rgba(255, 255, 255, 0.05);
}
</style>