refactor: migrate to common eslint+prettier configs (#4168)

* refactor: migrate to common eslint+prettier configs

* fix: prettier frontend

* feat: config changes

* fix: lint issues

* fix: lint

* fix: type imports

* fix: cyclical import issue

* fix: lockfile

* fix: missing dep

* fix: switch to tabs

* fix: continue switch to tabs

* fix: rustfmt parity

* fix: moderation lint issue

* fix: lint issues

* fix: ui intl

* fix: lint issues

* Revert "fix: rustfmt parity"

This reverts commit cb99d2376c321d813d4b7fc7e2a213bb30a54711.

* feat: revert last rs
This commit is contained in:
Cal H.
2025-08-14 21:48:38 +01:00
committed by GitHub
parent 82697278dc
commit 2aabcf36ee
702 changed files with 101360 additions and 102020 deletions

View File

@@ -2,23 +2,23 @@
import { computed } from 'vue'
const emit = defineEmits<{
(e: 'select'): void
(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,
},
defineProps<{
name: string | undefined
id: string
texture: string
isEquipped?: boolean
selected?: boolean
faded?: boolean
}>(),
{
isEquipped: false,
selected: undefined,
faded: false,
},
)
console.log(props)
@@ -27,82 +27,82 @@ 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>
<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;
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;
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%);
// 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%);
// 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%);
// 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;
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%);
}
.highlighted-glow::before {
box-shadow: inset 0 0 2px 4px color-mix(in srgb, var(--color-brand), transparent 10%);
}
}
</style>

View File

@@ -1,63 +1,63 @@
<script setup lang="ts">
const emit = defineEmits<{
(e: 'click'): void
(e: 'click'): void
}>()
withDefaults(
defineProps<{
tooltip?: string
highlighted?: boolean
}>(),
{
tooltip: undefined,
highlighted: false,
},
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>
<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;
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%);
}
.highlighted-glow::before {
box-shadow: inset 0 0 2px 2px color-mix(in srgb, var(--color-brand), transparent 10%);
}
}
</style>

View File

@@ -2,141 +2,141 @@
import { ref } from 'vue'
const emit = defineEmits<{
(e: 'select'): void
(e: 'edit', event: MouseEvent): void
(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,
},
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),
forward: Boolean(props.forwardImageSrc),
backward: Boolean(props.backwardImageSrc),
})
function onImageLoad(type: 'forward' | 'backward') {
imagesLoaded.value[type] = true
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-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>
<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-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>
<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;
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;
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;
}
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.skin-btn-bg {
background: var(--color-gradient-button-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);
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);
filter: brightness(1.15);
}
.with-shadow img {
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4));
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4));
}
.skin-button__image-parent img {
transition: filter 200ms ease-in-out;
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));
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));
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4));
}
</style>

View File

@@ -2,14 +2,14 @@
import { ref } from 'vue'
withDefaults(
defineProps<{
selected?: boolean
tooltip?: string
}>(),
{
selected: false,
tooltip: undefined,
},
defineProps<{
selected?: boolean
tooltip?: string
}>(),
{
selected: false,
tooltip: undefined,
},
)
const emit = defineEmits<{ (e: 'click', event: MouseEvent): void }>()
@@ -18,50 +18,50 @@ 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
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>
<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);
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);
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);
filter: brightness(1.15);
}
</style>

View File

@@ -1,52 +1,52 @@
<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>
<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="true"
: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>
<TresCanvas
shadows
alpha
:antialias="true"
: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
<!-- <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]"
@@ -59,121 +59,122 @@
:depth-write="false"
/>
</TresMesh> -->
</Group>
</Suspense>
</Group>
</Suspense>
<Suspense>
<EffectComposerPmndrs>
<FXAAPmndrs />
</EffectComposerPmndrs>
</Suspense>
<Suspense>
<EffectComposerPmndrs>
<FXAAPmndrs />
</EffectComposerPmndrs>
</Suspense>
<Suspense>
<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]" />
<TresShaderMaterial v-bind="radialSpotlightShader" />
</TresMesh>
</Suspense>
<Suspense>
<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]" />
<TresShaderMaterial v-bind="radialSpotlightShader" />
</TresMesh>
</Suspense>
<TresPerspectiveCamera
:make-default.camel="true"
:fov="fov"
:position="[0, 1.5, -3.25]"
:look-at="target"
/>
<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>
<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>
<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 { EffectComposerPmndrs, FXAAPmndrs } from '@tresjs/post-processing'
import {
shallowRef,
ref,
computed,
watch,
markRaw,
onBeforeMount,
onUnmounted,
toRefs,
useTemplateRef,
} from 'vue'
import {
applyTexture,
applyCapeTexture,
createTransparentTexture,
loadTexture as loadSkinTexture,
} from '@modrinth/utils'
import { useDynamicFontSize } from '../../composables'
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 * as THREE from 'three'
import {
computed,
markRaw,
onBeforeMount,
onUnmounted,
ref,
shallowRef,
toRefs,
useTemplateRef,
watch,
} from 'vue'
import { useDynamicFontSize } from '../../composables'
interface AnimationConfig {
baseAnimation: string
randomAnimations: string[]
randomAnimationInterval?: number
transitionDuration?: number
baseAnimation: string
randomAnimations: string[]
randomAnimationInterval?: number
transitionDuration?: number
}
const props = withDefaults(
defineProps<{
textureSrc: string
capeSrc?: string
variant?: 'SLIM' | 'CLASSIC' | 'UNKNOWN'
nametag?: string
scale?: number
fov?: number
initialRotation?: number
animationConfig?: AnimationConfig
}>(),
{
variant: 'CLASSIC',
scale: 1,
fov: 40,
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,
}),
},
defineProps<{
textureSrc: string
capeSrc?: string
variant?: 'SLIM' | 'CLASSIC' | 'UNKNOWN'
nametag?: string
scale?: number
fov?: number
initialRotation?: number
animationConfig?: AnimationConfig
}>(),
{
variant: 'CLASSIC',
scale: 1,
fov: 40,
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',
containerElement: skinPreviewContainer,
text: nametagText,
baseFontSize: 1.8,
minFontSize: 1.25,
maxFontSize: 2,
padding: 24,
fontFamily: 'inherit',
})
const selectedModelSrc = computed(() =>
props.variant === 'SLIM' ? SlimPlayerModel : ClassicPlayerModel,
props.variant === 'SLIM' ? SlimPlayerModel : ClassicPlayerModel,
)
const scene = shallowRef<THREE.Object3D | null>(null)
@@ -194,22 +195,22 @@ 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: `
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: `
fragmentShader: `
uniform vec3 innerColor;
uniform vec3 outerColor;
uniform float innerOpacity;
@@ -236,281 +237,281 @@ const radialSpotlightShader = computed(() => ({
gl_FragColor = vec4(color, opacity);
}
`,
transparent: true,
depthWrite: false,
depthTest: false,
transparent: true,
depthWrite: false,
depthTest: false,
}))
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
}
if (!clips || clips.length === 0) {
console.warn('No animation clips found in the model')
return
}
mixer.value = new THREE.AnimationMixer(loadedScene)
actions.value = {}
mixer.value = new THREE.AnimationMixer(loadedScene)
actions.value = {}
clips.forEach((clip) => {
const action = mixer.value!.clipAction(clip)
clips.forEach((clip) => {
const action = mixer.value!.clipAction(clip)
action.setLoop(THREE.LoopOnce, 1)
action.clampWhenFinished = true
actions.value[clip.name] = action
})
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`)
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)
}
}
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
}
if (!mixer.value || !actions.value[name]) {
console.warn(`Animation "${name}" not found!`)
return false
}
const action = actions.value[name]
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
}
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
const transitionDuration = props.animationConfig.transitionDuration || 0.3
Object.entries(actions.value).forEach(([actionName, actionInstance]) => {
if (actionName !== name && actionInstance.isRunning()) {
actionInstance.fadeOut(transitionDuration)
}
})
Object.entries(actions.value).forEach(([actionName, actionInstance]) => {
if (actionName !== name && actionInstance.isRunning()) {
actionInstance.fadeOut(transitionDuration)
}
})
action.reset()
action.reset()
if (name === baseAnimation.value) {
action.setLoop(THREE.LoopRepeat, Infinity)
} else {
action.setLoop(THREE.LoopOnce, 1)
action.clampWhenFinished = true
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
}
}
}
// 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)
}
mixer.value.addEventListener('finished', onFinished)
}
action.fadeIn(transitionDuration)
action.play()
action.fadeIn(transitionDuration)
action.play()
currentAnimation.value = name
return true
currentAnimation.value = name
return true
}
function setupRandomAnimationLoop() {
const interval = props.animationConfig.randomAnimationInterval || 10000
const interval = props.animationConfig.randomAnimationInterval || 10000
function scheduleNextAnimation() {
if (randomAnimationTimer.value) {
clearTimeout(randomAnimationTimer.value)
}
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,
)
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
// 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]
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)
}
if (actions.value[randomAnimationName]) {
lastRandomAnimation.value = randomAnimationName
playRandomAnimation(randomAnimationName)
}
} else {
// If not in base animation, wait and try again
scheduleNextAnimation()
}
}, interval)
}
scheduleNextAnimation()
scheduleNextAnimation()
}
function playRandomAnimation(name: string) {
if (!mixer.value || !actions.value[name]) {
console.warn(`Animation "${name}" not found!`)
return
}
if (!mixer.value || !actions.value[name]) {
console.warn(`Animation "${name}" not found!`)
return
}
const action = actions.value[name]
const action = actions.value[name]
if (currentAnimation.value === name && action.isRunning()) {
console.log(`Animation "${name}" is already running, ignoring request`)
return
}
if (currentAnimation.value === name && action.isRunning()) {
console.log(`Animation "${name}" is already running, ignoring request`)
return
}
const transitionDuration = props.animationConfig.transitionDuration || 0.3
const transitionDuration = props.animationConfig.transitionDuration || 0.3
if (baseAnimation.value && actions.value[baseAnimation.value].isRunning()) {
actions.value[baseAnimation.value].fadeOut(transitionDuration)
}
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()
action.reset()
action.setLoop(THREE.LoopOnce, 1)
action.clampWhenFinished = true
action.fadeIn(transitionDuration)
action.play()
currentAnimation.value = name
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
// 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()
}
}
}
// Schedule the next random animation after returning to base
setupRandomAnimationLoop()
}
}
}
mixer.value.addEventListener('finished', onFinished)
mixer.value.addEventListener('finished', onFinished)
}
function stopAnimations() {
if (mixer.value) {
mixer.value.stopAllAction()
}
currentAnimation.value = ''
if (mixer.value) {
mixer.value.stopAllAction()
}
currentAnimation.value = ''
}
function getAvailableAnimations(): string[] {
return Object.keys(actions.value)
return Object.keys(actions.value)
}
defineExpose({
playAnimation,
stopAnimations,
getAvailableAnimations,
getCurrentAnimation: () => currentAnimation.value,
playAnimation,
stopAnimations,
getAvailableAnimations,
getCurrentAnimation: () => currentAnimation.value,
})
const { onLoop } = useRenderLoop()
onLoop(() => {
if (mixer.value) {
mixer.value.update(clock.getDelta())
}
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)
try {
isModelLoaded.value = false
const { scene: loadedScene, animations } = await useGLTF(src)
scene.value = markRaw(loadedScene)
if (texture.value) {
applyTexture(scene.value, texture.value)
}
if (texture.value) {
applyTexture(scene.value, texture.value)
}
applyCapeTexture(scene.value, capeTexture.value, transparentTexture)
applyCapeTexture(scene.value, capeTexture.value, transparentTexture)
if (animations && animations.length > 0) {
initializeAnimations(loadedScene, animations)
}
if (animations && animations.length > 0) {
initializeAnimations(loadedScene, animations)
}
updateModelInfo()
isModelLoaded.value = true
} catch (error) {
console.error('Failed to load model:', error)
isModelLoaded.value = false
}
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
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
}
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
if (src === lastCapeSrc.value) return
lastCapeSrc.value = src
lastCapeSrc.value = src
if (src) {
capeTexture.value = await loadAndApplyTexture(src)
} else {
capeTexture.value = null
}
if (src) {
capeTexture.value = await loadAndApplyTexture(src)
} else {
capeTexture.value = null
}
if (scene.value) {
applyCapeTexture(scene.value, capeTexture.value, transparentTexture)
}
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)
}
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)
@@ -521,108 +522,108 @@ 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
;(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
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)
isDragging.value = false
;(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId)
}
function onCanvasClick() {
if (!hasDragged.value) {
if (actions.value['interact']) {
playRandomAnimation('interact')
}
}
if (!hasDragged.value) {
if (actions.value['interact']) {
playRandomAnimation('interact')
}
}
hasDragged.value = false
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
},
() => 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)
},
() => props.capeSrc,
async (newCapeSrc) => {
await loadAndApplyCapeTexture(newCapeSrc)
},
)
watch(
() => props.animationConfig,
(newConfig) => {
if (randomAnimationTimer.value) {
clearTimeout(randomAnimationTimer.value)
randomAnimationTimer.value = null
}
() => 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 },
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
try {
isTextureLoaded.value = false
texture.value = await loadAndApplyTexture(props.textureSrc)
isTextureLoaded.value = true
await loadModel(selectedModelSrc.value)
await loadModel(selectedModelSrc.value)
if (props.capeSrc) {
await loadAndApplyCapeTexture(props.capeSrc)
}
} catch (error) {
console.error('Failed to initialize skin preview:', error)
}
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 (randomAnimationTimer.value) {
clearTimeout(randomAnimationTimer.value)
}
if (mixer.value) {
mixer.value.stopAllAction()
mixer.value = null
}
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);
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>