You've already forked AstralRinth
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:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user