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:
@@ -0,0 +1,6 @@
|
||||
export * from './types'
|
||||
export * from './use-skin-preview-animation'
|
||||
export * from './use-skin-preview-controls'
|
||||
export * from './use-skin-preview-fit'
|
||||
export * from './use-skin-preview-loading'
|
||||
export * from './use-skin-preview-scene'
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface SkinPreviewAnimationConfig {
|
||||
baseAnimation: string
|
||||
randomAnimations: string[]
|
||||
randomAnimationInterval?: number
|
||||
transitionDuration?: number
|
||||
}
|
||||
|
||||
export type SkinPreviewFraming = 'page' | 'modal'
|
||||
|
||||
export interface SkinPreviewFitPadding {
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
left: number
|
||||
}
|
||||
|
||||
export interface SkinPreviewFitLock {
|
||||
containerSize: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
modelCenter: SkinPreviewTuple
|
||||
modelSize: SkinPreviewTuple
|
||||
padding: SkinPreviewFitPadding
|
||||
rotation: number
|
||||
}
|
||||
|
||||
export type SkinPreviewTuple = [number, number, number]
|
||||
@@ -0,0 +1,406 @@
|
||||
import { useRenderLoop } from '@tresjs/core'
|
||||
import * as THREE from 'three'
|
||||
import { computed, type ComputedRef, type Ref, ref, watch } from 'vue'
|
||||
|
||||
import type { SkinPreviewAnimationConfig } from './types'
|
||||
|
||||
type AnimationFinishedListener = (
|
||||
event: THREE.AnimationMixerEventMap['finished'] & {
|
||||
readonly type: 'finished'
|
||||
readonly target: THREE.AnimationMixer
|
||||
},
|
||||
) => void
|
||||
|
||||
export const INTERACT_ANIMATION_NAME = 'interact'
|
||||
|
||||
const INTERACT_VISIBLE_DURATION_SECONDS = 0.5
|
||||
const CLICK_IMPULSE_MAX_ENERGY = 5
|
||||
const CLICK_IMPULSE_ENERGY_PER_CLICK = 1
|
||||
const CLICK_IMPULSE_DECAY_PER_SECOND = 6
|
||||
const CLICK_IMPULSE_BASE_SPEED = 18
|
||||
const CLICK_IMPULSE_SPEED_BOOST = 7
|
||||
const CLICK_IMPULSE_OFFSET_X = 0.035
|
||||
const CLICK_IMPULSE_ROTATION_Z = 0.055
|
||||
const CLICK_IMPULSE_SCALE_X = 0.018
|
||||
const CLICK_IMPULSE_SCALE_Y = 0.025
|
||||
const DAMAGE_FLASH_DURATION_SECONDS = 0.2
|
||||
const DAMAGE_FLASH_REPEAT_DELAY_SECONDS = 0.5
|
||||
const DAMAGE_FLASH_MAX_INTENSITY = 0.7
|
||||
|
||||
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
|
||||
|
||||
export function useSkinPreviewAnimation(
|
||||
animationConfig: MaybeReadonlyRef<SkinPreviewAnimationConfig | undefined>,
|
||||
) {
|
||||
const mixer = ref<THREE.AnimationMixer | null>(null)
|
||||
const actions = ref<Record<string, THREE.AnimationAction>>({})
|
||||
const clock = new THREE.Clock()
|
||||
const currentAnimation = ref<string>('')
|
||||
const randomAnimationTimer = ref<number | null>(null)
|
||||
const lastRandomAnimation = ref<string>('')
|
||||
const animationFinishedListeners: AnimationFinishedListener[] = []
|
||||
|
||||
const clickImpulseEnergy = ref(0)
|
||||
const clickImpulsePhase = ref(0)
|
||||
const clickImpulseOffsetX = ref(0)
|
||||
const clickImpulseRotationZ = ref(0)
|
||||
const clickImpulseScaleX = ref(1)
|
||||
const clickImpulseScaleY = ref(1)
|
||||
const damageFlashIntensity = ref(0)
|
||||
|
||||
let damageFlashRemainingSeconds = 0
|
||||
let damageFlashCooldownSeconds = 0
|
||||
|
||||
const baseAnimation = computed(() => animationConfig.value?.baseAnimation ?? '')
|
||||
const randomAnimations = computed(() => animationConfig.value?.randomAnimations ?? [])
|
||||
const transitionDuration = computed(() => animationConfig.value?.transitionDuration || 0.3)
|
||||
|
||||
function initializeAnimations(loadedScene: THREE.Object3D, clips: THREE.AnimationClip[]) {
|
||||
if (!clips || clips.length === 0) {
|
||||
console.warn('No animation clips found in the model')
|
||||
return
|
||||
}
|
||||
|
||||
mixer.value = new THREE.AnimationMixer(loadedScene)
|
||||
clock.start()
|
||||
actions.value = {}
|
||||
|
||||
clips.forEach((clip) => {
|
||||
if (clip.name === INTERACT_ANIMATION_NAME) {
|
||||
clip.duration = INTERACT_VISIBLE_DURATION_SECONDS
|
||||
}
|
||||
|
||||
const action = mixer.value!.clipAction(clip)
|
||||
|
||||
action.setLoop(THREE.LoopOnce, 1)
|
||||
action.clampWhenFinished = true
|
||||
actions.value[clip.name] = action
|
||||
})
|
||||
|
||||
if (baseAnimation.value && actions.value[baseAnimation.value]) {
|
||||
actions.value[baseAnimation.value].setLoop(THREE.LoopRepeat, Infinity)
|
||||
playAnimation(baseAnimation.value, true)
|
||||
setupRandomAnimationLoop()
|
||||
} else {
|
||||
console.warn(`Base animation "${baseAnimation.value}" not found`)
|
||||
|
||||
const firstAnimationName = Object.keys(actions.value)[0]
|
||||
if (firstAnimationName) {
|
||||
actions.value[firstAnimationName].setLoop(THREE.LoopRepeat, Infinity)
|
||||
playAnimation(firstAnimationName, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function playAnimation(name: string, immediate = false) {
|
||||
if (!mixer.value || !actions.value[name]) {
|
||||
console.warn(`Animation "${name}" not found!`)
|
||||
return false
|
||||
}
|
||||
|
||||
const action = actions.value[name]
|
||||
|
||||
if (currentAnimation.value === name && action.isRunning() && name !== baseAnimation.value) {
|
||||
console.log(`Animation "${name}" is already running, ignoring request`)
|
||||
return false
|
||||
}
|
||||
|
||||
Object.entries(actions.value).forEach(([actionName, actionInstance]) => {
|
||||
if (actionName !== name && actionInstance.isRunning()) {
|
||||
actionInstance.fadeOut(transitionDuration.value)
|
||||
}
|
||||
})
|
||||
|
||||
action.reset()
|
||||
|
||||
if (name === baseAnimation.value) {
|
||||
action.setLoop(THREE.LoopRepeat, Infinity)
|
||||
} else {
|
||||
action.setLoop(THREE.LoopOnce, 1)
|
||||
action.clampWhenFinished = true
|
||||
|
||||
const onFinished: AnimationFinishedListener = (event) => {
|
||||
if (event.action === action) {
|
||||
removeAnimationFinishedListener(onFinished)
|
||||
if (currentAnimation.value === name && baseAnimation.value) {
|
||||
action.fadeOut(transitionDuration.value)
|
||||
const baseAction = actions.value[baseAnimation.value]
|
||||
if (baseAction) {
|
||||
baseAction.reset()
|
||||
baseAction.fadeIn(transitionDuration.value)
|
||||
baseAction.play()
|
||||
currentAnimation.value = baseAnimation.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addAnimationFinishedListener(onFinished)
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
action.setEffectiveWeight(1)
|
||||
} else {
|
||||
action.fadeIn(transitionDuration.value)
|
||||
}
|
||||
action.play()
|
||||
|
||||
if (immediate) {
|
||||
mixer.value.update(0)
|
||||
}
|
||||
|
||||
currentAnimation.value = name
|
||||
return true
|
||||
}
|
||||
|
||||
function setupRandomAnimationLoop() {
|
||||
const interval = animationConfig.value?.randomAnimationInterval || 10000
|
||||
|
||||
function scheduleNextAnimation() {
|
||||
if (randomAnimationTimer.value) {
|
||||
clearTimeout(randomAnimationTimer.value)
|
||||
}
|
||||
|
||||
randomAnimationTimer.value = window.setTimeout(() => {
|
||||
if (randomAnimations.value.length > 0 && currentAnimation.value === baseAnimation.value) {
|
||||
const availableAnimations = randomAnimations.value.filter(
|
||||
(anim) => anim !== lastRandomAnimation.value,
|
||||
)
|
||||
const animationsToChooseFrom =
|
||||
availableAnimations.length > 0 ? availableAnimations : randomAnimations.value
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * animationsToChooseFrom.length)
|
||||
const randomAnimationName = animationsToChooseFrom[randomIndex]
|
||||
|
||||
if (actions.value[randomAnimationName]) {
|
||||
lastRandomAnimation.value = randomAnimationName
|
||||
playRandomAnimation(randomAnimationName)
|
||||
}
|
||||
} else {
|
||||
scheduleNextAnimation()
|
||||
}
|
||||
}, interval)
|
||||
}
|
||||
|
||||
scheduleNextAnimation()
|
||||
}
|
||||
|
||||
function playRandomAnimation(name: string) {
|
||||
if (!mixer.value || !actions.value[name]) {
|
||||
console.warn(`Animation "${name}" not found!`)
|
||||
return
|
||||
}
|
||||
|
||||
const action = actions.value[name]
|
||||
|
||||
if (currentAnimation.value === name && action.isRunning()) {
|
||||
console.log(`Animation "${name}" is already running, ignoring request`)
|
||||
return
|
||||
}
|
||||
|
||||
const baseAction = baseAnimation.value ? actions.value[baseAnimation.value] : undefined
|
||||
if (baseAction?.isRunning()) {
|
||||
baseAction.fadeOut(transitionDuration.value)
|
||||
}
|
||||
|
||||
action.reset()
|
||||
action.setLoop(THREE.LoopOnce, 1)
|
||||
action.clampWhenFinished = true
|
||||
action.setEffectiveTimeScale(1)
|
||||
action.fadeIn(transitionDuration.value)
|
||||
action.play()
|
||||
|
||||
currentAnimation.value = name
|
||||
|
||||
const onFinished: AnimationFinishedListener = (event) => {
|
||||
if (event.action === action) {
|
||||
removeAnimationFinishedListener(onFinished)
|
||||
if (currentAnimation.value === name && baseAnimation.value) {
|
||||
action.fadeOut(transitionDuration.value)
|
||||
const nextBaseAction = actions.value[baseAnimation.value]
|
||||
if (nextBaseAction) {
|
||||
nextBaseAction.reset()
|
||||
nextBaseAction.setEffectiveTimeScale(1)
|
||||
nextBaseAction.fadeIn(transitionDuration.value)
|
||||
nextBaseAction.play()
|
||||
currentAnimation.value = baseAnimation.value
|
||||
|
||||
setupRandomAnimationLoop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addAnimationFinishedListener(onFinished)
|
||||
}
|
||||
|
||||
function playInteractAnimation() {
|
||||
if (actions.value[INTERACT_ANIMATION_NAME]) {
|
||||
playRandomAnimation(INTERACT_ANIMATION_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
function playClickInteraction() {
|
||||
addClickImpulse()
|
||||
playInteractAnimation()
|
||||
}
|
||||
|
||||
function addClickImpulse() {
|
||||
clickImpulseEnergy.value = Math.min(
|
||||
CLICK_IMPULSE_MAX_ENERGY,
|
||||
clickImpulseEnergy.value + CLICK_IMPULSE_ENERGY_PER_CLICK,
|
||||
)
|
||||
|
||||
if (clickImpulseEnergy.value >= CLICK_IMPULSE_MAX_ENERGY && damageFlashCooldownSeconds <= 0) {
|
||||
triggerDamageFlash()
|
||||
}
|
||||
}
|
||||
|
||||
function updateClickImpulse(delta: number) {
|
||||
const energy = Math.max(0, clickImpulseEnergy.value - CLICK_IMPULSE_DECAY_PER_SECOND * delta)
|
||||
clickImpulseEnergy.value = energy
|
||||
|
||||
if (energy <= 0) {
|
||||
clickImpulseOffsetX.value = 0
|
||||
clickImpulseRotationZ.value = 0
|
||||
clickImpulseScaleX.value = 1
|
||||
clickImpulseScaleY.value = 1
|
||||
return
|
||||
}
|
||||
|
||||
const intensity = energy / CLICK_IMPULSE_MAX_ENERGY
|
||||
clickImpulsePhase.value +=
|
||||
delta * (CLICK_IMPULSE_BASE_SPEED + energy * CLICK_IMPULSE_SPEED_BOOST)
|
||||
|
||||
const shake = Math.sin(clickImpulsePhase.value) * intensity
|
||||
const squash = Math.abs(Math.sin(clickImpulsePhase.value * 1.7)) * intensity
|
||||
|
||||
clickImpulseOffsetX.value = shake * CLICK_IMPULSE_OFFSET_X
|
||||
clickImpulseRotationZ.value = shake * CLICK_IMPULSE_ROTATION_Z
|
||||
clickImpulseScaleX.value = 1 + squash * CLICK_IMPULSE_SCALE_X
|
||||
clickImpulseScaleY.value = 1 - squash * CLICK_IMPULSE_SCALE_Y
|
||||
}
|
||||
|
||||
function triggerDamageFlash() {
|
||||
damageFlashRemainingSeconds = DAMAGE_FLASH_DURATION_SECONDS
|
||||
damageFlashCooldownSeconds = DAMAGE_FLASH_DURATION_SECONDS + DAMAGE_FLASH_REPEAT_DELAY_SECONDS
|
||||
damageFlashIntensity.value = DAMAGE_FLASH_MAX_INTENSITY
|
||||
}
|
||||
|
||||
function updateDamageFlash(delta: number) {
|
||||
damageFlashCooldownSeconds = Math.max(0, damageFlashCooldownSeconds - delta)
|
||||
|
||||
if (damageFlashRemainingSeconds <= 0) {
|
||||
damageFlashIntensity.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
damageFlashRemainingSeconds = Math.max(0, damageFlashRemainingSeconds - delta)
|
||||
damageFlashIntensity.value =
|
||||
DAMAGE_FLASH_MAX_INTENSITY * (damageFlashRemainingSeconds / DAMAGE_FLASH_DURATION_SECONDS)
|
||||
}
|
||||
|
||||
function stopAnimations() {
|
||||
if (mixer.value) {
|
||||
mixer.value.stopAllAction()
|
||||
}
|
||||
currentAnimation.value = ''
|
||||
}
|
||||
|
||||
function getAvailableAnimations(): string[] {
|
||||
return Object.keys(actions.value)
|
||||
}
|
||||
|
||||
function clearRandomAnimationTimer() {
|
||||
if (randomAnimationTimer.value) {
|
||||
clearTimeout(randomAnimationTimer.value)
|
||||
randomAnimationTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function addAnimationFinishedListener(listener: AnimationFinishedListener) {
|
||||
mixer.value?.addEventListener('finished', listener)
|
||||
animationFinishedListeners.push(listener)
|
||||
}
|
||||
|
||||
function removeAnimationFinishedListener(
|
||||
listener: AnimationFinishedListener,
|
||||
targetMixer = mixer.value,
|
||||
) {
|
||||
targetMixer?.removeEventListener('finished', listener)
|
||||
|
||||
const index = animationFinishedListeners.indexOf(listener)
|
||||
if (index !== -1) {
|
||||
animationFinishedListeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function clearAnimationFinishedListeners(targetMixer = mixer.value) {
|
||||
animationFinishedListeners.forEach((listener) => {
|
||||
targetMixer?.removeEventListener('finished', listener)
|
||||
})
|
||||
animationFinishedListeners.length = 0
|
||||
}
|
||||
|
||||
function cleanupAnimationState(root: THREE.Object3D | null) {
|
||||
clearRandomAnimationTimer()
|
||||
|
||||
const currentMixer = mixer.value
|
||||
if (currentMixer) {
|
||||
clearAnimationFinishedListeners(currentMixer)
|
||||
currentMixer.stopAllAction()
|
||||
|
||||
if (root) {
|
||||
currentMixer.uncacheRoot(root)
|
||||
}
|
||||
}
|
||||
|
||||
mixer.value = null
|
||||
actions.value = {}
|
||||
currentAnimation.value = ''
|
||||
lastRandomAnimation.value = ''
|
||||
damageFlashRemainingSeconds = 0
|
||||
damageFlashCooldownSeconds = 0
|
||||
damageFlashIntensity.value = 0
|
||||
}
|
||||
|
||||
watch(
|
||||
() => animationConfig.value,
|
||||
(newConfig) => {
|
||||
clearRandomAnimationTimer()
|
||||
|
||||
if (mixer.value && newConfig?.baseAnimation && actions.value[newConfig.baseAnimation]) {
|
||||
playAnimation(newConfig.baseAnimation)
|
||||
setupRandomAnimationLoop()
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const { onLoop } = useRenderLoop()
|
||||
onLoop(() => {
|
||||
const delta = clock.getDelta()
|
||||
|
||||
if (mixer.value) {
|
||||
mixer.value.update(delta)
|
||||
}
|
||||
|
||||
updateClickImpulse(delta)
|
||||
updateDamageFlash(delta)
|
||||
})
|
||||
|
||||
return {
|
||||
clickImpulseOffsetX,
|
||||
clickImpulseRotationZ,
|
||||
clickImpulseScaleX,
|
||||
clickImpulseScaleY,
|
||||
cleanupAnimationState,
|
||||
currentAnimation,
|
||||
damageFlashIntensity,
|
||||
getAvailableAnimations,
|
||||
initializeAnimations,
|
||||
playAnimation,
|
||||
playClickInteraction,
|
||||
stopAnimations,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { type ComputedRef, type Ref, ref } from 'vue'
|
||||
|
||||
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
|
||||
|
||||
export function useSkinPreviewControls({
|
||||
initialRotation,
|
||||
onClickWithoutDrag,
|
||||
}: {
|
||||
initialRotation: MaybeReadonlyRef<number | undefined>
|
||||
onClickWithoutDrag: () => void
|
||||
}) {
|
||||
const modelRotation = ref((initialRotation.value ?? 15.75) + Math.PI)
|
||||
const isDragging = ref(false)
|
||||
const previousX = ref(0)
|
||||
const hasDragged = ref(false)
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
||||
isDragging.value = true
|
||||
previousX.value = event.clientX
|
||||
hasDragged.value = false
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (!isDragging.value) return
|
||||
const deltaX = event.clientX - previousX.value
|
||||
modelRotation.value += deltaX * 0.01
|
||||
previousX.value = event.clientX
|
||||
hasDragged.value = true
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent) {
|
||||
isDragging.value = false
|
||||
|
||||
const target = event.currentTarget as HTMLElement
|
||||
if (target.hasPointerCapture(event.pointerId)) {
|
||||
target.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
function onCanvasClick() {
|
||||
if (!hasDragged.value) {
|
||||
onClickWithoutDrag()
|
||||
}
|
||||
|
||||
hasDragged.value = false
|
||||
}
|
||||
|
||||
function ignoreControlClick(event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
return {
|
||||
ignoreControlClick,
|
||||
modelRotation,
|
||||
onCanvasClick,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
computed,
|
||||
type ComputedRef,
|
||||
type CSSProperties,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
type Ref,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue'
|
||||
|
||||
import type {
|
||||
SkinPreviewFitLock,
|
||||
SkinPreviewFitPadding,
|
||||
SkinPreviewFraming,
|
||||
SkinPreviewTuple,
|
||||
} from './types'
|
||||
|
||||
const FRAMING_PRESETS = {
|
||||
page: {
|
||||
fov: 35,
|
||||
zoom: 0.96,
|
||||
padding: { top: 0.2, right: 0.14, bottom: 0.3, left: 0.14 },
|
||||
},
|
||||
modal: {
|
||||
fov: 35,
|
||||
zoom: 1,
|
||||
padding: { top: 0.1, right: 0.1, bottom: 0.18, left: 0.1 },
|
||||
},
|
||||
} satisfies Record<
|
||||
SkinPreviewFraming,
|
||||
{ fov: number; zoom: number; padding: SkinPreviewFitPadding }
|
||||
>
|
||||
|
||||
const PREVIEW_CONTROLS_FOOT_OFFSET = 64
|
||||
const SUBTITLE_CONTROLS_OFFSET = 48
|
||||
const NAMETAG_HEAD_OFFSET = 16
|
||||
|
||||
function cloneModelTuple(tuple: SkinPreviewTuple): SkinPreviewTuple {
|
||||
return [tuple[0], tuple[1], tuple[2]]
|
||||
}
|
||||
|
||||
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
|
||||
|
||||
export function useSkinPreviewFit({
|
||||
containerElement,
|
||||
fit,
|
||||
lockFit,
|
||||
framing,
|
||||
fitZoom,
|
||||
fitPadding,
|
||||
scale,
|
||||
fov,
|
||||
modelRotation,
|
||||
nametag,
|
||||
hasSubtitle,
|
||||
hasNametagBadge,
|
||||
subtitleWrapped,
|
||||
modelCenter,
|
||||
modelSize,
|
||||
isModelLoaded,
|
||||
}: {
|
||||
containerElement: MaybeReadonlyRef<HTMLElement | null>
|
||||
fit: MaybeReadonlyRef<boolean | undefined>
|
||||
lockFit: MaybeReadonlyRef<boolean | undefined>
|
||||
framing: MaybeReadonlyRef<SkinPreviewFraming | undefined>
|
||||
fitZoom: MaybeReadonlyRef<number | undefined>
|
||||
fitPadding: MaybeReadonlyRef<Partial<SkinPreviewFitPadding> | undefined>
|
||||
scale: MaybeReadonlyRef<number | undefined>
|
||||
fov: MaybeReadonlyRef<number | undefined>
|
||||
modelRotation: MaybeReadonlyRef<number>
|
||||
nametag: MaybeReadonlyRef<string | undefined>
|
||||
hasSubtitle: MaybeReadonlyRef<boolean>
|
||||
hasNametagBadge: MaybeReadonlyRef<boolean>
|
||||
subtitleWrapped: MaybeReadonlyRef<boolean>
|
||||
modelCenter: MaybeReadonlyRef<SkinPreviewTuple>
|
||||
modelSize: MaybeReadonlyRef<SkinPreviewTuple>
|
||||
isModelLoaded: MaybeReadonlyRef<boolean>
|
||||
}) {
|
||||
const containerSize = ref({ width: 1, height: 1 })
|
||||
const fitLock = ref<SkinPreviewFitLock | null>(null)
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
|
||||
const fitEnabled = computed(() => {
|
||||
if (fit.value !== undefined) return fit.value
|
||||
return scale.value === undefined && fov.value === undefined
|
||||
})
|
||||
const currentFraming = computed<SkinPreviewFraming>(() => framing.value ?? 'page')
|
||||
const lockFitEnabled = computed(() => currentFraming.value === 'page' || (lockFit.value ?? true))
|
||||
const legacyScale = computed(() => scale.value ?? 1)
|
||||
const legacyFov = computed(() => fov.value ?? 40)
|
||||
|
||||
const hasUsableFitSize = computed(
|
||||
() => containerSize.value.width > 1 && containerSize.value.height > 1,
|
||||
)
|
||||
const hasResolvedFit = computed(
|
||||
() =>
|
||||
!fitEnabled.value || (lockFitEnabled.value ? fitLock.value !== null : hasUsableFitSize.value),
|
||||
)
|
||||
|
||||
const fitContainerSize = computed(() =>
|
||||
lockFitEnabled.value
|
||||
? (fitLock.value?.containerSize ?? containerSize.value)
|
||||
: containerSize.value,
|
||||
)
|
||||
const fitModelCenter = computed(() =>
|
||||
lockFitEnabled.value ? (fitLock.value?.modelCenter ?? modelCenter.value) : modelCenter.value,
|
||||
)
|
||||
const fitModelSize = computed(() =>
|
||||
lockFitEnabled.value ? (fitLock.value?.modelSize ?? modelSize.value) : modelSize.value,
|
||||
)
|
||||
const fitModelRotation = computed(() =>
|
||||
lockFitEnabled.value ? (fitLock.value?.rotation ?? modelRotation.value) : modelRotation.value,
|
||||
)
|
||||
|
||||
const resolvedFitPadding = computed<SkinPreviewFitPadding>(() => {
|
||||
const preset = FRAMING_PRESETS[currentFraming.value].padding
|
||||
|
||||
return {
|
||||
top: Math.max(preset.top, hasNametagBadge.value ? 0.28 : nametag.value ? 0.2 : 0),
|
||||
right: preset.right,
|
||||
bottom: Math.max(preset.bottom, hasSubtitle.value ? 0.28 : preset.bottom),
|
||||
left: preset.left,
|
||||
...(fitPadding.value ?? {}),
|
||||
}
|
||||
})
|
||||
const fitResolvedPadding = computed(() =>
|
||||
lockFitEnabled.value
|
||||
? (fitLock.value?.padding ?? resolvedFitPadding.value)
|
||||
: resolvedFitPadding.value,
|
||||
)
|
||||
|
||||
const modelOffset = computed<SkinPreviewTuple>(() => {
|
||||
if (!fitEnabled.value) return [0, 0, 0]
|
||||
|
||||
const [x, y, z] = fitModelCenter.value
|
||||
return [-x, -y, -z]
|
||||
})
|
||||
|
||||
const modelGroupPosition = computed<SkinPreviewTuple>(() => {
|
||||
if (fitEnabled.value) return [0, 0, 0]
|
||||
return [0, -0.05 * legacyScale.value, 1.95]
|
||||
})
|
||||
|
||||
const modelGroupScale = computed<SkinPreviewTuple>(() => {
|
||||
if (fitEnabled.value) return [1, 1, 1]
|
||||
|
||||
const resolvedScale = 0.8 * legacyScale.value
|
||||
return [resolvedScale, resolvedScale, resolvedScale]
|
||||
})
|
||||
|
||||
const fittedCamera = computed(() => {
|
||||
const width = Math.max(fitContainerSize.value.width, 1)
|
||||
const height = Math.max(fitContainerSize.value.height, 1)
|
||||
const aspect = width / height
|
||||
const preset = FRAMING_PRESETS[currentFraming.value]
|
||||
const padding = fitResolvedPadding.value
|
||||
|
||||
const usableWidth = Math.max(width * (1 - padding.left - padding.right), 1)
|
||||
const usableHeight = Math.max(height * (1 - padding.top - padding.bottom), 1)
|
||||
|
||||
const [sizeX, sizeY, sizeZ] = fitModelSize.value
|
||||
const halfWidth = Math.sqrt((sizeX / 2) ** 2 + (sizeZ / 2) ** 2)
|
||||
const halfHeight = sizeY / 2
|
||||
|
||||
const resolvedFov = fov.value ?? preset.fov
|
||||
const verticalFov = THREE.MathUtils.degToRad(resolvedFov)
|
||||
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspect)
|
||||
|
||||
const paddedHalfWidth = halfWidth * (width / usableWidth)
|
||||
const paddedHalfHeight = halfHeight * (height / usableHeight)
|
||||
const zoom = Math.max((fitZoom.value ?? 1) * preset.zoom, 0.01)
|
||||
|
||||
const distance =
|
||||
Math.max(
|
||||
paddedHalfHeight / Math.tan(verticalFov / 2),
|
||||
paddedHalfWidth / Math.tan(horizontalFov / 2),
|
||||
) / zoom
|
||||
|
||||
const visibleHalfHeight = distance * Math.tan(verticalFov / 2)
|
||||
const targetY = -(padding.bottom - padding.top) * visibleHalfHeight
|
||||
|
||||
return {
|
||||
fov: resolvedFov,
|
||||
position: [0, targetY, -distance] as SkinPreviewTuple,
|
||||
target: [0, targetY, 0] as SkinPreviewTuple,
|
||||
}
|
||||
})
|
||||
|
||||
const cameraConfig = computed(() => {
|
||||
if (fitEnabled.value) return fittedCamera.value
|
||||
|
||||
return {
|
||||
fov: legacyFov.value,
|
||||
position: [0, 1.5, -3.25] as SkinPreviewTuple,
|
||||
target: modelCenter.value,
|
||||
}
|
||||
})
|
||||
|
||||
const modelFeetTop = computed(() => {
|
||||
if (!fitEnabled.value) return null
|
||||
|
||||
const height = Math.max(containerSize.value.height, 1)
|
||||
const [, sizeY] = fitModelSize.value
|
||||
const { fov: resolvedFov, position, target } = cameraConfig.value
|
||||
const distance = Math.max(Math.abs(position[2] - target[2]), 0.001)
|
||||
const verticalFov = THREE.MathUtils.degToRad(resolvedFov)
|
||||
const modelFeetY = -sizeY / 2
|
||||
const projectedY =
|
||||
(modelFeetY - target[1]) / distance / Math.max(Math.tan(verticalFov / 2), 0.001)
|
||||
const topPercent = THREE.MathUtils.clamp(((1 - projectedY) / 2) * 100, 0, 100)
|
||||
|
||||
return (topPercent / 100) * height
|
||||
})
|
||||
|
||||
const previewControlsTop = computed(() =>
|
||||
modelFeetTop.value === null ? null : modelFeetTop.value + PREVIEW_CONTROLS_FOOT_OFFSET,
|
||||
)
|
||||
|
||||
const previewControlsPositionStyle = computed<CSSProperties>(() => {
|
||||
if (!fitEnabled.value || currentFraming.value !== 'page' || previewControlsTop.value === null) {
|
||||
return {
|
||||
bottom: currentFraming.value === 'modal' ? '6%' : 'calc(15% + 64px)',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
top: `${previewControlsTop.value}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const subtitlePositionStyle = computed<CSSProperties>(() => {
|
||||
if (!fitEnabled.value || currentFraming.value !== 'page' || previewControlsTop.value === null) {
|
||||
return {
|
||||
bottom:
|
||||
currentFraming.value === 'modal'
|
||||
? '6%'
|
||||
: subtitleWrapped.value
|
||||
? 'calc(15% - 32px)'
|
||||
: '15%',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
top: `${previewControlsTop.value + SUBTITLE_CONTROLS_OFFSET}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const nametagTop = computed(() => {
|
||||
if (!fitEnabled.value) return '18%'
|
||||
|
||||
const height = Math.max(containerSize.value.height, 1)
|
||||
const [sizeX, sizeY, sizeZ] = fitModelSize.value
|
||||
const { fov: resolvedFov, position, target } = cameraConfig.value
|
||||
const verticalFov = THREE.MathUtils.degToRad(resolvedFov)
|
||||
const modelTopY = sizeY / 2
|
||||
const halfX = sizeX / 2
|
||||
const halfZ = sizeZ / 2
|
||||
const sinRotation = Math.sin(fitModelRotation.value)
|
||||
const cosRotation = Math.cos(fitModelRotation.value)
|
||||
const modelTopZ = -Math.abs(halfX * sinRotation) - Math.abs(halfZ * cosRotation)
|
||||
const distance = Math.max(Math.abs(position[2] - target[2]) + modelTopZ, 0.001)
|
||||
const projectedY =
|
||||
(modelTopY - target[1]) / distance / Math.max(Math.tan(verticalFov / 2), 0.001)
|
||||
const topPercent = ((1 - projectedY) / 2) * 100
|
||||
|
||||
return `${(topPercent / 100) * height - NAMETAG_HEAD_OFFSET}px`
|
||||
})
|
||||
|
||||
const spotlightY = computed(() => {
|
||||
if (!fitEnabled.value) return -0.1 * legacyScale.value
|
||||
|
||||
const [, sizeY] = fitModelSize.value
|
||||
return -sizeY / 2 - 0.02
|
||||
})
|
||||
|
||||
const spotlightPosition = computed<SkinPreviewTuple>(() => [
|
||||
0,
|
||||
spotlightY.value,
|
||||
fitEnabled.value ? 0 : 2,
|
||||
])
|
||||
|
||||
const spotlightScale = computed<SkinPreviewTuple>(() => {
|
||||
if (!fitEnabled.value) {
|
||||
const resolvedScale = 0.75 * legacyScale.value
|
||||
return [resolvedScale, resolvedScale, resolvedScale]
|
||||
}
|
||||
|
||||
const [sizeX, , sizeZ] = fitModelSize.value
|
||||
const radius = Math.max(sizeX, sizeZ, 1) * 0.8
|
||||
return [radius, radius, radius]
|
||||
})
|
||||
|
||||
function lockFitState() {
|
||||
if (!fitEnabled.value || !lockFitEnabled.value || fitLock.value || !isModelLoaded.value) return
|
||||
|
||||
const { width, height } = containerSize.value
|
||||
if (width <= 1 || height <= 1) return
|
||||
|
||||
fitLock.value = {
|
||||
containerSize: { width, height },
|
||||
modelCenter: cloneModelTuple(modelCenter.value),
|
||||
modelSize: cloneModelTuple(modelSize.value),
|
||||
padding: { ...resolvedFitPadding.value },
|
||||
rotation: modelRotation.value,
|
||||
}
|
||||
}
|
||||
|
||||
function resetFitLockForLayoutChange() {
|
||||
if (!fitEnabled.value || !lockFitEnabled.value) return
|
||||
|
||||
fitLock.value = null
|
||||
lockFitState()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = containerElement.value
|
||||
if (!el) return
|
||||
|
||||
resizeObserver = new ResizeObserver(([entry]) => {
|
||||
const { width, height } = entry.contentRect
|
||||
const nextContainerSize = {
|
||||
width: Math.max(width, 1),
|
||||
height: Math.max(height, 1),
|
||||
}
|
||||
const didContainerSizeChange =
|
||||
nextContainerSize.width !== containerSize.value.width ||
|
||||
nextContainerSize.height !== containerSize.value.height
|
||||
|
||||
containerSize.value = nextContainerSize
|
||||
|
||||
if (didContainerSizeChange) {
|
||||
resetFitLockForLayoutChange()
|
||||
}
|
||||
})
|
||||
|
||||
resizeObserver.observe(el)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => isModelLoaded.value,
|
||||
(loaded) => {
|
||||
if (loaded) lockFitState()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => lockFitEnabled.value,
|
||||
() => {
|
||||
fitLock.value = null
|
||||
lockFitState()
|
||||
},
|
||||
)
|
||||
|
||||
watch(fitEnabled, () => {
|
||||
fitLock.value = null
|
||||
lockFitState()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
cameraConfig,
|
||||
currentFraming,
|
||||
fitEnabled,
|
||||
hasResolvedFit,
|
||||
legacyScale,
|
||||
modelGroupPosition,
|
||||
modelGroupScale,
|
||||
modelOffset,
|
||||
nametagTop,
|
||||
previewControlsPositionStyle,
|
||||
spotlightPosition,
|
||||
spotlightScale,
|
||||
subtitlePositionStyle,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { computed, type ComputedRef, onUnmounted, type Ref, ref, watch } from 'vue'
|
||||
|
||||
const LOADING_INDICATOR_DELAY_MS = 200
|
||||
const LOADING_INDICATOR_MIN_MS = 250
|
||||
|
||||
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
|
||||
|
||||
export function useSkinPreviewLoading(isReady: MaybeReadonlyRef<boolean>) {
|
||||
const showLoading = ref(false)
|
||||
const isPreviewVisible = computed(() => isReady.value && !showLoading.value)
|
||||
let loadingIndicatorDelayTimer: number | null = null
|
||||
let loadingIndicatorMinTimer: number | null = null
|
||||
let loadingIndicatorShownAt = 0
|
||||
|
||||
function clearLoadingIndicatorDelayTimer() {
|
||||
if (loadingIndicatorDelayTimer !== null) {
|
||||
clearTimeout(loadingIndicatorDelayTimer)
|
||||
loadingIndicatorDelayTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearLoadingIndicatorMinTimer() {
|
||||
if (loadingIndicatorMinTimer !== null) {
|
||||
clearTimeout(loadingIndicatorMinTimer)
|
||||
loadingIndicatorMinTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function hideLoadingIndicatorAfterMinimum() {
|
||||
const visibleFor = Date.now() - loadingIndicatorShownAt
|
||||
const remaining = LOADING_INDICATOR_MIN_MS - visibleFor
|
||||
|
||||
if (remaining <= 0) {
|
||||
showLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loadingIndicatorMinTimer = window.setTimeout(() => {
|
||||
showLoading.value = false
|
||||
loadingIndicatorMinTimer = null
|
||||
}, remaining)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => isReady.value,
|
||||
(ready) => {
|
||||
clearLoadingIndicatorDelayTimer()
|
||||
|
||||
if (ready) {
|
||||
if (showLoading.value) {
|
||||
clearLoadingIndicatorMinTimer()
|
||||
hideLoadingIndicatorAfterMinimum()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
clearLoadingIndicatorMinTimer()
|
||||
|
||||
if (showLoading.value || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
loadingIndicatorDelayTimer = window.setTimeout(() => {
|
||||
loadingIndicatorDelayTimer = null
|
||||
|
||||
if (isReady.value) {
|
||||
return
|
||||
}
|
||||
|
||||
showLoading.value = true
|
||||
loadingIndicatorShownAt = Date.now()
|
||||
}, LOADING_INDICATOR_DELAY_MS)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
clearLoadingIndicatorDelayTimer()
|
||||
clearLoadingIndicatorMinTimer()
|
||||
})
|
||||
|
||||
return {
|
||||
isPreviewVisible,
|
||||
showLoading,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useGLTF } from '@tresjs/cientos'
|
||||
import { useTexture } from '@tresjs/core'
|
||||
import * as THREE from 'three'
|
||||
import { clone as cloneSkeleton } from 'three/examples/jsm/utils/SkeletonUtils.js'
|
||||
import {
|
||||
type ComputedRef,
|
||||
markRaw,
|
||||
onBeforeMount,
|
||||
onUnmounted,
|
||||
type Ref,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
} from 'vue'
|
||||
|
||||
import {
|
||||
applyCapeTexture,
|
||||
applyTexture,
|
||||
createTransparentTexture,
|
||||
loadTexture as loadSkinTexture,
|
||||
} from '#ui/utils/webgl/skin-rendering.ts'
|
||||
|
||||
import type { SkinPreviewTuple } from './types'
|
||||
|
||||
const SKIN_LAYER_DEPTH_BIAS = -1
|
||||
|
||||
function configureSkinPreviewMesh(mesh: THREE.Mesh) {
|
||||
const isSkinLayer = mesh.name.endsWith('_Layer')
|
||||
mesh.renderOrder = 0
|
||||
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
materials.forEach((material) => {
|
||||
if (!(material instanceof THREE.MeshStandardMaterial) || material.name === 'cape') return
|
||||
|
||||
material.transparent = false
|
||||
material.alphaTest = 0.1
|
||||
material.depthTest = true
|
||||
material.depthWrite = true
|
||||
material.polygonOffset = isSkinLayer
|
||||
material.polygonOffsetFactor = isSkinLayer ? SKIN_LAYER_DEPTH_BIAS : 0
|
||||
material.polygonOffsetUnits = isSkinLayer ? SKIN_LAYER_DEPTH_BIAS : 0
|
||||
material.needsUpdate = true
|
||||
})
|
||||
}
|
||||
|
||||
function cloneSceneForRenderer(source: THREE.Object3D) {
|
||||
const cloned = cloneSkeleton(source)
|
||||
|
||||
cloned.traverse((object) => {
|
||||
const mesh = object as THREE.Mesh
|
||||
if (!mesh.isMesh || !mesh.material) return
|
||||
|
||||
mesh.material = Array.isArray(mesh.material)
|
||||
? mesh.material.map((material) => material.clone())
|
||||
: mesh.material.clone()
|
||||
|
||||
configureSkinPreviewMesh(mesh)
|
||||
})
|
||||
|
||||
return markRaw(cloned)
|
||||
}
|
||||
|
||||
function disposeSceneMaterials(root: THREE.Object3D | null) {
|
||||
if (!root) return
|
||||
|
||||
const materials = new Set<THREE.Material>()
|
||||
|
||||
root.traverse((object) => {
|
||||
const mesh = object as THREE.Mesh
|
||||
if (!mesh.isMesh || !mesh.material) return
|
||||
|
||||
const meshMaterials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
meshMaterials.forEach((material) => materials.add(material))
|
||||
})
|
||||
|
||||
materials.forEach((material) => material.dispose())
|
||||
}
|
||||
|
||||
function getVisibleMeshBox(root: THREE.Object3D): THREE.Box3 | null {
|
||||
root.updateWorldMatrix(true, true)
|
||||
|
||||
const result = new THREE.Box3()
|
||||
const meshBox = new THREE.Box3()
|
||||
let found = false
|
||||
|
||||
root.traverse((object) => {
|
||||
const mesh = object as THREE.Mesh
|
||||
if (!mesh.isMesh || !mesh.geometry || mesh.visible === false) return
|
||||
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
if (materials.length && materials.every((material) => material.visible === false)) return
|
||||
|
||||
if (!mesh.geometry.boundingBox) {
|
||||
mesh.geometry.computeBoundingBox()
|
||||
}
|
||||
|
||||
if (!mesh.geometry.boundingBox) return
|
||||
|
||||
meshBox.copy(mesh.geometry.boundingBox).applyMatrix4(mesh.matrixWorld)
|
||||
result.union(meshBox)
|
||||
found = true
|
||||
})
|
||||
|
||||
return found && !result.isEmpty() ? result.clone() : null
|
||||
}
|
||||
|
||||
type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
|
||||
|
||||
export function useSkinPreviewScene({
|
||||
selectedModelSrc,
|
||||
textureSrc,
|
||||
capeSrc,
|
||||
initializeAnimations,
|
||||
cleanupAnimationState,
|
||||
}: {
|
||||
selectedModelSrc: MaybeReadonlyRef<string>
|
||||
textureSrc: MaybeReadonlyRef<string>
|
||||
capeSrc: MaybeReadonlyRef<string | undefined>
|
||||
initializeAnimations: (loadedScene: THREE.Object3D, clips: THREE.AnimationClip[]) => void
|
||||
cleanupAnimationState: (root: THREE.Object3D | null) => void
|
||||
}) {
|
||||
const scene = shallowRef<THREE.Object3D | null>(null)
|
||||
const lastCapeSrc = ref<string | undefined>(undefined)
|
||||
const loadedModelSrc = ref<string | undefined>(undefined)
|
||||
const loadedTextureSrc = ref<string | undefined>(undefined)
|
||||
const loadedCapeSrc = ref<string | undefined>(undefined)
|
||||
const texture = shallowRef<THREE.Texture | null>(null)
|
||||
const capeTexture = shallowRef<THREE.Texture | null>(null)
|
||||
const transparentTexture = createTransparentTexture()
|
||||
const modelCenter = ref<SkinPreviewTuple>([0, 1, 0])
|
||||
const modelSize = ref<SkinPreviewTuple>([1, 2, 1])
|
||||
const isModelLoaded = ref(false)
|
||||
const isTextureLoaded = ref(false)
|
||||
let modelLoadVersion = 0
|
||||
let textureLoadVersion = 0
|
||||
let capeLoadVersion = 0
|
||||
let isUnmounted = false
|
||||
|
||||
function applyTextureToLoadedModel() {
|
||||
if (
|
||||
!scene.value ||
|
||||
!texture.value ||
|
||||
loadedModelSrc.value !== selectedModelSrc.value ||
|
||||
loadedTextureSrc.value !== textureSrc.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
applyTexture(scene.value, texture.value)
|
||||
}
|
||||
|
||||
function applyCapeTextureToLoadedModel() {
|
||||
if (!scene.value || loadedModelSrc.value !== selectedModelSrc.value) return
|
||||
|
||||
applyCapeTexture(
|
||||
scene.value,
|
||||
loadedCapeSrc.value === capeSrc.value ? capeTexture.value : null,
|
||||
transparentTexture,
|
||||
)
|
||||
}
|
||||
|
||||
async function loadModel(src: string) {
|
||||
const loadVersion = ++modelLoadVersion
|
||||
|
||||
try {
|
||||
isModelLoaded.value = false
|
||||
const { scene: loadedScene, animations } = await useGLTF(src)
|
||||
const clonedScene = cloneSceneForRenderer(loadedScene)
|
||||
if (isUnmounted || loadVersion !== modelLoadVersion) {
|
||||
disposeSceneMaterials(clonedScene)
|
||||
return
|
||||
}
|
||||
|
||||
const previousScene = scene.value
|
||||
cleanupAnimationState(previousScene)
|
||||
disposeSceneMaterials(previousScene)
|
||||
scene.value = clonedScene
|
||||
loadedModelSrc.value = src
|
||||
|
||||
applyTextureToLoadedModel()
|
||||
|
||||
applyCapeTextureToLoadedModel()
|
||||
|
||||
if (animations && animations.length > 0) {
|
||||
initializeAnimations(clonedScene, animations)
|
||||
}
|
||||
|
||||
updateModelInfo()
|
||||
isModelLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error('Failed to load model:', error)
|
||||
if (!isUnmounted && loadVersion === modelLoadVersion) {
|
||||
isModelLoaded.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndApplyTexture(src: string) {
|
||||
if (!src) return null
|
||||
|
||||
try {
|
||||
try {
|
||||
return await loadSkinTexture(src)
|
||||
} catch {
|
||||
const tex = await useTexture([src])
|
||||
tex.colorSpace = THREE.SRGBColorSpace
|
||||
tex.flipY = false
|
||||
tex.magFilter = THREE.NearestFilter
|
||||
tex.minFilter = THREE.NearestFilter
|
||||
return tex
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load texture:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndApplyCapeTexture(src: string | undefined) {
|
||||
if (src === lastCapeSrc.value) return
|
||||
|
||||
const loadVersion = ++capeLoadVersion
|
||||
lastCapeSrc.value = src
|
||||
|
||||
let loadedCapeTexture: THREE.Texture | null = null
|
||||
if (src) {
|
||||
loadedCapeTexture = await loadAndApplyTexture(src)
|
||||
}
|
||||
if (isUnmounted || loadVersion !== capeLoadVersion) return
|
||||
|
||||
capeTexture.value = loadedCapeTexture
|
||||
loadedCapeSrc.value = src
|
||||
applyCapeTextureToLoadedModel()
|
||||
}
|
||||
|
||||
function updateModelInfo() {
|
||||
const box = scene.value ? getVisibleMeshBox(scene.value) : null
|
||||
|
||||
if (!box) {
|
||||
modelCenter.value = [0, 1, 0]
|
||||
modelSize.value = [1, 2, 1]
|
||||
return
|
||||
}
|
||||
|
||||
const center = new THREE.Vector3()
|
||||
const size = new THREE.Vector3()
|
||||
|
||||
box.getCenter(center)
|
||||
box.getSize(size)
|
||||
|
||||
modelCenter.value = [center.x, center.y, center.z]
|
||||
modelSize.value = [Math.max(size.x, 0.001), Math.max(size.y, 0.001), Math.max(size.z, 0.001)]
|
||||
}
|
||||
|
||||
watch(
|
||||
() => selectedModelSrc.value,
|
||||
(src) => loadModel(src),
|
||||
)
|
||||
watch(
|
||||
() => textureSrc.value,
|
||||
async (newSrc) => {
|
||||
const loadVersion = ++textureLoadVersion
|
||||
|
||||
isTextureLoaded.value = false
|
||||
const loadedTexture = await loadAndApplyTexture(newSrc)
|
||||
if (isUnmounted || loadVersion !== textureLoadVersion) return
|
||||
|
||||
texture.value = loadedTexture
|
||||
loadedTextureSrc.value = newSrc
|
||||
applyTextureToLoadedModel()
|
||||
isTextureLoaded.value = true
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => capeSrc.value,
|
||||
async (newCapeSrc) => {
|
||||
await loadAndApplyCapeTexture(newCapeSrc)
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeMount(async () => {
|
||||
try {
|
||||
isTextureLoaded.value = false
|
||||
texture.value = await loadAndApplyTexture(textureSrc.value)
|
||||
loadedTextureSrc.value = textureSrc.value
|
||||
isTextureLoaded.value = true
|
||||
|
||||
await loadModel(selectedModelSrc.value)
|
||||
|
||||
if (capeSrc.value) {
|
||||
await loadAndApplyCapeTexture(capeSrc.value)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize skin preview:', error)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
isUnmounted = true
|
||||
modelLoadVersion++
|
||||
textureLoadVersion++
|
||||
capeLoadVersion++
|
||||
|
||||
cleanupAnimationState(scene.value)
|
||||
disposeSceneMaterials(scene.value)
|
||||
scene.value = null
|
||||
transparentTexture.dispose()
|
||||
})
|
||||
|
||||
return {
|
||||
isModelLoaded,
|
||||
isTextureLoaded,
|
||||
modelCenter,
|
||||
modelSize,
|
||||
scene,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref, watch, watchEffect } from 'vue'
|
||||
|
||||
export interface ScrollViewportOptions {
|
||||
onScroll?: () => void
|
||||
onResize?: () => void
|
||||
}
|
||||
|
||||
export interface VirtualScrollOptions {
|
||||
itemHeight: number
|
||||
bufferSize?: number
|
||||
@@ -10,45 +15,35 @@ export interface VirtualScrollOptions {
|
||||
nearEndThreshold?: number
|
||||
}
|
||||
|
||||
export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptions) {
|
||||
const {
|
||||
itemHeight,
|
||||
bufferSize = 5,
|
||||
initialItemCount = 20,
|
||||
enabled,
|
||||
onNearEnd,
|
||||
nearEndThreshold = 0.2,
|
||||
} = options
|
||||
export function findScrollableAncestor(element: HTMLElement | null): HTMLElement | Window {
|
||||
if (!element) return window
|
||||
|
||||
let current: HTMLElement | null = element.parentElement
|
||||
while (current) {
|
||||
const { overflowY } = getComputedStyle(current)
|
||||
if (overflowY === 'auto' || overflowY === 'scroll') {
|
||||
return current
|
||||
}
|
||||
current = current.parentElement
|
||||
}
|
||||
return window
|
||||
}
|
||||
|
||||
export function getScrollTop(container: HTMLElement | Window): number {
|
||||
return container instanceof Window ? window.scrollY : container.scrollTop
|
||||
}
|
||||
|
||||
export function getViewportHeight(container: HTMLElement | Window): number {
|
||||
return container instanceof Window ? window.innerHeight : container.clientHeight
|
||||
}
|
||||
|
||||
export function useScrollViewport(options: ScrollViewportOptions = {}) {
|
||||
const listContainer = ref<HTMLElement | null>(null)
|
||||
const scrollContainer = ref<HTMLElement | Window | null>(null)
|
||||
const scrollTop = ref(0)
|
||||
const viewportHeight = ref(0)
|
||||
const containerOffset = ref(0)
|
||||
|
||||
const totalHeight = computed(() => items.value.length * itemHeight)
|
||||
|
||||
function findScrollableAncestor(element: HTMLElement | null): HTMLElement | Window {
|
||||
if (!element) return window
|
||||
|
||||
let current: HTMLElement | null = element.parentElement
|
||||
while (current) {
|
||||
const { overflowY } = getComputedStyle(current)
|
||||
if (overflowY === 'auto' || overflowY === 'scroll') {
|
||||
return current
|
||||
}
|
||||
current = current.parentElement
|
||||
}
|
||||
return window
|
||||
}
|
||||
|
||||
function getScrollTop(container: HTMLElement | Window): number {
|
||||
return container instanceof Window ? window.scrollY : container.scrollTop
|
||||
}
|
||||
|
||||
function getViewportHeight(container: HTMLElement | Window): number {
|
||||
return container instanceof Window ? window.innerHeight : container.clientHeight
|
||||
}
|
||||
const relativeScrollTop = computed(() => Math.max(0, scrollTop.value - containerOffset.value))
|
||||
|
||||
function updateContainerOffset() {
|
||||
const listEl = listContainer.value
|
||||
@@ -71,6 +66,77 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
|
||||
updateContainerOffset()
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (scrollContainer.value) {
|
||||
scrollTop.value = getScrollTop(scrollContainer.value)
|
||||
updateContainerOffset()
|
||||
}
|
||||
|
||||
options.onScroll?.()
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
syncScrollState()
|
||||
options.onResize?.()
|
||||
}
|
||||
|
||||
watchEffect((onCleanup) => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const listEl = listContainer.value
|
||||
if (!listEl) return
|
||||
|
||||
const container = findScrollableAncestor(listEl)
|
||||
scrollContainer.value = container
|
||||
syncScrollState()
|
||||
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
window.addEventListener('resize', handleResize, { passive: true })
|
||||
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
if (!(container instanceof Window)) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
syncScrollState()
|
||||
})
|
||||
resizeObserver.observe(container)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
containerOffset,
|
||||
listContainer,
|
||||
relativeScrollTop,
|
||||
scrollContainer,
|
||||
scrollTop,
|
||||
syncScrollState,
|
||||
updateContainerOffset,
|
||||
viewportHeight,
|
||||
}
|
||||
}
|
||||
|
||||
export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptions) {
|
||||
const {
|
||||
itemHeight,
|
||||
bufferSize = 5,
|
||||
initialItemCount = 20,
|
||||
enabled,
|
||||
onNearEnd,
|
||||
nearEndThreshold = 0.2,
|
||||
} = options
|
||||
|
||||
const { listContainer, relativeScrollTop, scrollContainer, syncScrollState, viewportHeight } =
|
||||
useScrollViewport({
|
||||
onScroll: checkNearEnd,
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => items.value.length * itemHeight)
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
if (enabled && !enabled.value) {
|
||||
return { start: 0, end: items.value.length }
|
||||
@@ -80,9 +146,7 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
|
||||
return { start: 0, end: Math.min(items.value.length, initialItemCount) }
|
||||
}
|
||||
|
||||
const relativeScrollTop = Math.max(0, scrollTop.value - containerOffset.value)
|
||||
|
||||
const start = Math.floor(relativeScrollTop / itemHeight)
|
||||
const start = Math.floor(relativeScrollTop.value / itemHeight)
|
||||
const visibleCount = Math.ceil(viewportHeight.value / itemHeight)
|
||||
const rangeSize = visibleCount + bufferSize * 2
|
||||
|
||||
@@ -117,52 +181,10 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (scrollContainer.value) {
|
||||
scrollTop.value = getScrollTop(scrollContainer.value)
|
||||
updateContainerOffset()
|
||||
}
|
||||
checkNearEnd()
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
syncScrollState()
|
||||
}
|
||||
|
||||
// Re-sync scroll state when items change to avoid stale scrollTop/offset
|
||||
watch(items, () => {
|
||||
syncScrollState()
|
||||
})
|
||||
|
||||
watchEffect((onCleanup) => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const listEl = listContainer.value
|
||||
if (!listEl) return
|
||||
|
||||
const container = findScrollableAncestor(listEl)
|
||||
scrollContainer.value = container
|
||||
syncScrollState()
|
||||
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
window.addEventListener('resize', handleResize, { passive: true })
|
||||
|
||||
// Use ResizeObserver for element scroll containers
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
if (!(container instanceof Window)) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
syncScrollState()
|
||||
})
|
||||
resizeObserver.observe(container)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
listContainer,
|
||||
totalHeight,
|
||||
|
||||
Reference in New Issue
Block a user