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:
@@ -330,6 +330,7 @@ async function setupApp() {
|
||||
locale,
|
||||
telemetry,
|
||||
collapsed_navigation,
|
||||
hide_nametag_skins_page,
|
||||
advanced_rendering,
|
||||
onboarded,
|
||||
default_page,
|
||||
@@ -360,6 +361,7 @@ async function setupApp() {
|
||||
themeStore.setThemeState(theme)
|
||||
themeStore.collapsedNavigation = collapsed_navigation
|
||||
themeStore.advancedRendering = advanced_rendering
|
||||
themeStore.hideNametagSkinsPage = hide_nametag_skins_page
|
||||
themeStore.toggleSidebar = toggle_sidebar
|
||||
themeStore.devMode = developer_mode
|
||||
themeStore.featureFlags = feature_flags
|
||||
@@ -1227,7 +1229,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
>
|
||||
<CompassIcon />
|
||||
</NavButton>
|
||||
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
|
||||
<NavButton v-tooltip.right="'Skin selector'" to="/skins">
|
||||
<ChangeSkinIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
|
||||
@@ -150,7 +150,10 @@ async function refreshValues() {
|
||||
if (equippedSkin.value) {
|
||||
try {
|
||||
const headUrl = await getPlayerHeadUrl(equippedSkin.value)
|
||||
headUrlCache.value.set(equippedSkin.value.texture_key, headUrl)
|
||||
headUrlCache.value = new Map(headUrlCache.value).set(
|
||||
equippedSkin.value.texture_key,
|
||||
headUrl,
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('Failed to get head render for equipped skin:', error)
|
||||
}
|
||||
@@ -160,12 +163,24 @@ async function refreshValues() {
|
||||
}
|
||||
}
|
||||
|
||||
async function setEquippedSkin(skin: Skin) {
|
||||
equippedSkin.value = skin
|
||||
|
||||
try {
|
||||
const headUrl = await getPlayerHeadUrl(skin)
|
||||
headUrlCache.value = new Map(headUrlCache.value).set(skin.texture_key, headUrl)
|
||||
} catch (error) {
|
||||
console.warn('Failed to get head render for equipped skin:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function setLoginDisabled(value: boolean) {
|
||||
loginDisabled.value = value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refreshValues,
|
||||
setEquippedSkin,
|
||||
setLoginDisabled,
|
||||
loginDisabled,
|
||||
})
|
||||
|
||||
@@ -160,7 +160,16 @@ watch(
|
||||
</h2>
|
||||
<p class="m-0 mt-1">{{ formatMessage(messages.hideNametagDescription) }}</p>
|
||||
</div>
|
||||
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
|
||||
<Toggle
|
||||
id="hide-nametag-skins-page"
|
||||
:model-value="themeStore.hideNametagSkinsPage"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
themeStore.hideNametagSkinsPage = !!e
|
||||
settings.hide_nametag_skins_page = themeStore.hideNametagSkinsPage
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="os !== 'MacOS'" class="mt-6 flex items-center justify-between gap-4">
|
||||
|
||||
@@ -1,149 +1,242 @@
|
||||
<template>
|
||||
<UploadSkinModal ref="uploadModal" />
|
||||
<ModalWrapper ref="modal" @on-hide="resetState">
|
||||
<NewModal ref="modal" :on-hide="handleModalHide">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">
|
||||
{{ mode === 'edit' ? 'Editing skin' : 'Adding a skin' }}
|
||||
{{ formatMessage(mode === 'edit' ? messages.editSkinTitle : messages.addSkinTitle) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<div class="max-h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
|
||||
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
|
||||
<SkinPreviewRenderer
|
||||
:variant="variant"
|
||||
:texture-src="previewSkin || ''"
|
||||
:cape-src="selectedCapeTexture"
|
||||
:scale="1.4"
|
||||
:fov="50"
|
||||
:initial-rotation="Math.PI / 8"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="h-[25rem] w-[16rem] min-w-[16rem] flex-shrink-0 md:self-center">
|
||||
<SkinPreviewRenderer
|
||||
:variant="variant"
|
||||
:texture-src="previewSkin || ''"
|
||||
:cape-src="selectedCapeTexture"
|
||||
framing="modal"
|
||||
:initial-rotation="Math.PI / 8"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full min-h-[20rem]">
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Texture</h2>
|
||||
<section v-if="mode === 'edit' && canEditTextureAndModel">
|
||||
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.textureSection) }}</h2>
|
||||
<ButtonStyled>
|
||||
<button @click="openUploadSkinModal"><UploadIcon /> Replace texture</button>
|
||||
<button class="!shadow-none" @click="openTextureFileBrowser">
|
||||
<UploadIcon /> {{ formatMessage(messages.replaceTextureButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<input
|
||||
ref="textureFileInput"
|
||||
type="file"
|
||||
accept="image/png"
|
||||
class="hidden"
|
||||
@change="onTextureFileInputChange"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Arm style</h2>
|
||||
<section v-if="canEditTextureAndModel">
|
||||
<h2 class="text-base font-semibold mb-2">
|
||||
{{ formatMessage(messages.armStyleSection) }}
|
||||
</h2>
|
||||
<RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']">
|
||||
<template #default="{ item }">
|
||||
{{ item === 'CLASSIC' ? 'Wide' : 'Slim' }}
|
||||
{{
|
||||
formatMessage(item === 'CLASSIC' ? messages.wideArmStyle : messages.slimArmStyle)
|
||||
}}
|
||||
</template>
|
||||
</RadioButtons>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-base font-semibold mb-2">Cape</h2>
|
||||
<div class="flex gap-2">
|
||||
<CapeButton
|
||||
v-if="defaultCape"
|
||||
:id="defaultCape.id"
|
||||
:texture="defaultCape.texture"
|
||||
:name="undefined"
|
||||
:selected="!selectedCape"
|
||||
faded
|
||||
@select="selectCape(undefined)"
|
||||
<h2 class="text-base font-semibold mb-2">{{ formatMessage(messages.capeSection) }}</h2>
|
||||
<div class="relative w-fit max-w-full">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-6"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-6"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<span>Use default cape</span>
|
||||
</CapeButton>
|
||||
<CapeLikeTextButton v-else :highlighted="!selectedCape" @click="selectCape(undefined)">
|
||||
<span>Use default cape</span>
|
||||
</CapeLikeTextButton>
|
||||
<div
|
||||
v-if="showCapeTopFade"
|
||||
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-6 bg-gradient-to-b from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<CapeButton
|
||||
v-for="cape in visibleCapeList"
|
||||
:id="cape.id"
|
||||
:key="cape.id"
|
||||
:texture="cape.texture"
|
||||
:name="cape.name || 'Cape'"
|
||||
:selected="selectedCape?.id === cape.id"
|
||||
@select="selectCape(cape)"
|
||||
/>
|
||||
|
||||
<CapeLikeTextButton
|
||||
v-if="(capes?.length ?? 0) > 2"
|
||||
tooltip="View more capes"
|
||||
@mouseup="openSelectCapeModal"
|
||||
<div
|
||||
ref="capeListRef"
|
||||
class="grid grid-cols-[repeat(4,max-content)] auto-rows-max gap-2 overflow-y-auto pr-1"
|
||||
:style="{ maxHeight: capeListMaxHeight }"
|
||||
@scroll="checkCapeScrollState"
|
||||
>
|
||||
<template #icon><ChevronRightIcon /></template>
|
||||
<span>More</span>
|
||||
</CapeLikeTextButton>
|
||||
<CapeLikeTextButton
|
||||
:tooltip="formatMessage(messages.noCapeTooltip)"
|
||||
:highlighted="!selectedCape"
|
||||
@click="selectCape(undefined)"
|
||||
>
|
||||
<template #icon><XIcon /></template>
|
||||
<span>{{ formatMessage(messages.noneCapeOption) }}</span>
|
||||
</CapeLikeTextButton>
|
||||
|
||||
<CapeButton
|
||||
v-for="cape in sortedCapes"
|
||||
:id="cape.id"
|
||||
:key="cape.id"
|
||||
:texture="cape.texture"
|
||||
:name="cape.name || formatMessage(messages.capeFallbackName)"
|
||||
:selected="selectedCape?.id === cape.id"
|
||||
@select="selectCape(cape)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-6"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-6"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showCapeBottomFade"
|
||||
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-6 bg-gradient-to-t from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-12">
|
||||
<ButtonStyled color="brand">
|
||||
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
|
||||
<SpinnerIcon v-if="isSaving" class="animate-spin" />
|
||||
<CheckIcon v-else-if="mode === 'new'" />
|
||||
<SaveIcon v-else />
|
||||
{{ mode === 'new' ? 'Add skin' : 'Save skin' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button :disabled="isSaving" @click="hide"><XIcon />Cancel</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
|
||||
<SelectCapeModal
|
||||
ref="selectCapeModal"
|
||||
:capes="capes || []"
|
||||
@select="handleCapeSelected"
|
||||
@cancel="handleCapeCancel"
|
||||
/>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button :disabled="isSaving" @click="hide">
|
||||
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
|
||||
<SpinnerIcon v-if="isSaving" class="animate-spin" />
|
||||
<CheckIcon v-else-if="mode === 'new'" />
|
||||
<SaveIcon v-else />
|
||||
{{ formatMessage(mode === 'new' ? messages.addSkinButton : messages.saveSkinButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
SaveIcon,
|
||||
SpinnerIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { CheckIcon, SaveIcon, SpinnerIcon, UploadIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
CapeButton,
|
||||
CapeLikeTextButton,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
RadioButtons,
|
||||
SkinPreviewRenderer,
|
||||
useScrollIndicator,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { arrayBufferToBase64 } from '@modrinth/utils'
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
|
||||
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
|
||||
import {
|
||||
add_and_equip_custom_skin,
|
||||
type Cape,
|
||||
determineModelType,
|
||||
equip_skin,
|
||||
get_normalized_skin_texture,
|
||||
remove_custom_skin,
|
||||
normalize_skin_texture,
|
||||
save_custom_skin,
|
||||
type Skin,
|
||||
type SkinModel,
|
||||
type SkinTextureUrl,
|
||||
unequip_skin,
|
||||
} from '@/helpers/skins.ts'
|
||||
|
||||
const CAPE_LIST_MAX_HEIGHT = 334
|
||||
const messages = defineMessages({
|
||||
editSkinTitle: {
|
||||
id: 'app.skins.modal.edit-title',
|
||||
defaultMessage: 'Editing skin',
|
||||
},
|
||||
addSkinTitle: {
|
||||
id: 'app.skins.modal.add-title',
|
||||
defaultMessage: 'Adding a skin',
|
||||
},
|
||||
textureSection: {
|
||||
id: 'app.skins.modal.texture-section',
|
||||
defaultMessage: 'Texture',
|
||||
},
|
||||
replaceTextureButton: {
|
||||
id: 'app.skins.modal.replace-texture-button',
|
||||
defaultMessage: 'Replace texture',
|
||||
},
|
||||
armStyleSection: {
|
||||
id: 'app.skins.modal.arm-style-section',
|
||||
defaultMessage: 'Arm style',
|
||||
},
|
||||
wideArmStyle: {
|
||||
id: 'app.skins.modal.arm-style-wide',
|
||||
defaultMessage: 'Wide',
|
||||
},
|
||||
slimArmStyle: {
|
||||
id: 'app.skins.modal.arm-style-slim',
|
||||
defaultMessage: 'Slim',
|
||||
},
|
||||
capeSection: {
|
||||
id: 'app.skins.modal.cape-section',
|
||||
defaultMessage: 'Cape',
|
||||
},
|
||||
noCapeTooltip: {
|
||||
id: 'app.skins.modal.no-cape-tooltip',
|
||||
defaultMessage: 'No cape',
|
||||
},
|
||||
noneCapeOption: {
|
||||
id: 'app.skins.modal.none-cape-option',
|
||||
defaultMessage: 'None',
|
||||
},
|
||||
capeFallbackName: {
|
||||
id: 'app.skins.modal.cape-fallback-name',
|
||||
defaultMessage: 'Cape',
|
||||
},
|
||||
savingTooltip: {
|
||||
id: 'app.skins.modal.saving-tooltip',
|
||||
defaultMessage: 'Saving...',
|
||||
},
|
||||
uploadSkinFirstTooltip: {
|
||||
id: 'app.skins.modal.upload-skin-first-tooltip',
|
||||
defaultMessage: 'Upload a skin first!',
|
||||
},
|
||||
makeEditFirstTooltip: {
|
||||
id: 'app.skins.modal.make-edit-first-tooltip',
|
||||
defaultMessage: 'Make an edit to the skin first!',
|
||||
},
|
||||
addSkinButton: {
|
||||
id: 'app.skins.modal.add-skin-button',
|
||||
defaultMessage: 'Add skin',
|
||||
},
|
||||
saveSkinButton: {
|
||||
id: 'app.skins.modal.save-skin-button',
|
||||
defaultMessage: 'Save skin',
|
||||
},
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const modal = useTemplateRef('modal')
|
||||
const selectCapeModal = useTemplateRef('selectCapeModal')
|
||||
const textureFileInput = useTemplateRef<HTMLInputElement>('textureFileInput')
|
||||
const capeListRef = ref<HTMLElement | null>(null)
|
||||
const capeListMaxHeight = ref(`${CAPE_LIST_MAX_HEIGHT}px`)
|
||||
const mode = ref<'new' | 'edit'>('new')
|
||||
const currentSkin = ref<Skin | null>(null)
|
||||
const shouldRestoreModal = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const uploadedTextureUrl = ref<SkinTextureUrl | null>(null)
|
||||
@@ -151,10 +244,49 @@ const previewSkin = ref<string>('')
|
||||
|
||||
const variant = ref<SkinModel>('CLASSIC')
|
||||
const selectedCape = ref<Cape | undefined>(undefined)
|
||||
const props = defineProps<{ capes?: Cape[]; defaultCape?: Cape }>()
|
||||
const props = defineProps<{ capes?: Cape[] }>()
|
||||
|
||||
const selectedCapeTexture = computed(() => selectedCape.value?.texture)
|
||||
const visibleCapeList = ref<Cape[]>([])
|
||||
const canEditTextureAndModel = computed(() => currentSkin.value?.source !== 'default')
|
||||
const {
|
||||
showTopFade: showCapeTopFade,
|
||||
showBottomFade: showCapeBottomFade,
|
||||
checkScrollState: checkCapeScrollState,
|
||||
forceCheck: forceCapeScrollCheck,
|
||||
} = useScrollIndicator(capeListRef)
|
||||
|
||||
let capeListLayoutFrame: number | null = null
|
||||
function updateCapeListLayout() {
|
||||
const capeList = capeListRef.value
|
||||
const modalContent = capeList?.closest('[data-modal-content]') as HTMLElement | null
|
||||
|
||||
if (!capeList || !modalContent) {
|
||||
capeListMaxHeight.value = `${CAPE_LIST_MAX_HEIGHT}px`
|
||||
forceCapeScrollCheck()
|
||||
return
|
||||
}
|
||||
|
||||
const availableHeight =
|
||||
modalContent.getBoundingClientRect().bottom - capeList.getBoundingClientRect().top
|
||||
|
||||
capeListMaxHeight.value = `${Math.min(
|
||||
CAPE_LIST_MAX_HEIGHT,
|
||||
Math.max(0, Math.floor(availableHeight)),
|
||||
)}px`
|
||||
|
||||
nextTick(() => forceCapeScrollCheck())
|
||||
}
|
||||
|
||||
function refreshCapeListLayout() {
|
||||
if (capeListLayoutFrame !== null) {
|
||||
cancelAnimationFrame(capeListLayoutFrame)
|
||||
}
|
||||
|
||||
capeListLayoutFrame = requestAnimationFrame(() => {
|
||||
capeListLayoutFrame = null
|
||||
updateCapeListLayout()
|
||||
})
|
||||
}
|
||||
|
||||
const sortedCapes = computed(() => {
|
||||
return [...(props.capes || [])].sort((a, b) => {
|
||||
@@ -164,32 +296,6 @@ const sortedCapes = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
function initVisibleCapeList() {
|
||||
if (!props.capes || props.capes.length === 0) {
|
||||
visibleCapeList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
if (visibleCapeList.value.length === 0) {
|
||||
if (selectedCape.value) {
|
||||
const otherCape = getSortedCapeExcluding(selectedCape.value.id)
|
||||
visibleCapeList.value = otherCape ? [selectedCape.value, otherCape] : [selectedCape.value]
|
||||
} else {
|
||||
visibleCapeList.value = getSortedCapes(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSortedCapes(count: number): Cape[] {
|
||||
if (!sortedCapes.value || sortedCapes.value.length === 0) return []
|
||||
return sortedCapes.value.slice(0, count)
|
||||
}
|
||||
|
||||
function getSortedCapeExcluding(excludeId: string): Cape | undefined {
|
||||
if (!sortedCapes.value || sortedCapes.value.length <= 1) return undefined
|
||||
return sortedCapes.value.find((cape) => cape.id !== excludeId)
|
||||
}
|
||||
|
||||
async function loadPreviewSkin() {
|
||||
if (uploadedTextureUrl.value) {
|
||||
previewSkin.value = uploadedTextureUrl.value.normalized
|
||||
@@ -221,9 +327,13 @@ const disableSave = computed(
|
||||
)
|
||||
|
||||
const saveTooltip = computed(() => {
|
||||
if (isSaving.value) return 'Saving...'
|
||||
if (mode.value === 'new' && !uploadedTextureUrl.value) return 'Upload a skin first!'
|
||||
if (mode.value === 'edit' && !hasEdits.value) return 'Make an edit to the skin first!'
|
||||
if (isSaving.value) return formatMessage(messages.savingTooltip)
|
||||
if (mode.value === 'new' && !uploadedTextureUrl.value) {
|
||||
return formatMessage(messages.uploadSkinFirstTooltip)
|
||||
}
|
||||
if (mode.value === 'edit' && !hasEdits.value) {
|
||||
return formatMessage(messages.makeEditFirstTooltip)
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -234,11 +344,13 @@ function resetState() {
|
||||
previewSkin.value = ''
|
||||
variant.value = 'CLASSIC'
|
||||
selectedCape.value = undefined
|
||||
visibleCapeList.value = []
|
||||
shouldRestoreModal.value = false
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
function handleModalHide() {
|
||||
setTimeout(() => resetState(), 250)
|
||||
}
|
||||
|
||||
async function show(e: MouseEvent, skin?: Skin) {
|
||||
mode.value = skin ? 'edit' : 'new'
|
||||
currentSkin.value = skin ?? null
|
||||
@@ -249,12 +361,11 @@ async function show(e: MouseEvent, skin?: Skin) {
|
||||
variant.value = 'CLASSIC'
|
||||
selectedCape.value = undefined
|
||||
}
|
||||
visibleCapeList.value = []
|
||||
initVisibleCapeList()
|
||||
|
||||
await loadPreviewSkin()
|
||||
|
||||
modal.value?.show(e)
|
||||
nextTick(() => refreshCapeListLayout())
|
||||
}
|
||||
|
||||
async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
|
||||
@@ -263,98 +374,54 @@ async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
variant.value = await determineModelType(skinTextureUrl.original)
|
||||
selectedCape.value = undefined
|
||||
visibleCapeList.value = []
|
||||
initVisibleCapeList()
|
||||
|
||||
await loadPreviewSkin()
|
||||
|
||||
modal.value?.show(e)
|
||||
nextTick(() => refreshCapeListLayout())
|
||||
}
|
||||
|
||||
async function restoreWithNewTexture(skinTextureUrl: SkinTextureUrl) {
|
||||
async function setUploadedTexture(skinTextureUrl: SkinTextureUrl) {
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
await loadPreviewSkin()
|
||||
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
}
|
||||
nextTick(() => refreshCapeListLayout())
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
setTimeout(() => resetState(), 250)
|
||||
}
|
||||
|
||||
function selectCape(cape: Cape | undefined) {
|
||||
if (cape && selectedCape.value?.id !== cape.id) {
|
||||
const isInVisibleList = visibleCapeList.value.some((c) => c.id === cape.id)
|
||||
if (!isInVisibleList && visibleCapeList.value.length > 0) {
|
||||
visibleCapeList.value.splice(0, 1, cape)
|
||||
|
||||
if (visibleCapeList.value.length > 1 && visibleCapeList.value[1].id === cape.id) {
|
||||
const otherCape = getSortedCapeExcluding(cape.id)
|
||||
if (otherCape) {
|
||||
visibleCapeList.value.splice(1, 1, otherCape)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedCape.value = cape
|
||||
}
|
||||
|
||||
function handleCapeSelected(cape: Cape | undefined) {
|
||||
selectCape(cape)
|
||||
function openTextureFileBrowser() {
|
||||
textureFileInput.value?.click()
|
||||
}
|
||||
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
async function onTextureFileInputChange(e: Event) {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
const file = files?.[0]
|
||||
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function handleCapeCancel() {
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
modal.value?.show()
|
||||
shouldRestoreModal.value = false
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function openSelectCapeModal(e: MouseEvent) {
|
||||
if (!selectCapeModal.value) return
|
||||
|
||||
shouldRestoreModal.value = true
|
||||
modal.value?.hide()
|
||||
|
||||
setTimeout(() => {
|
||||
selectCapeModal.value?.show(
|
||||
e,
|
||||
currentSkin.value?.texture_key,
|
||||
selectedCape.value,
|
||||
previewSkin.value,
|
||||
variant.value,
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function openUploadSkinModal(e: MouseEvent) {
|
||||
shouldRestoreModal.value = true
|
||||
modal.value?.hide()
|
||||
emit('open-upload-modal', e)
|
||||
}
|
||||
|
||||
function restoreModal() {
|
||||
if (shouldRestoreModal.value) {
|
||||
setTimeout(() => {
|
||||
const fakeEvent = new MouseEvent('click')
|
||||
modal.value?.show(fakeEvent)
|
||||
shouldRestoreModal.value = false
|
||||
}, 500)
|
||||
try {
|
||||
const originalSkinTexUrl = `data:image/png;base64,${arrayBufferToBase64(
|
||||
await file.arrayBuffer(),
|
||||
)}`
|
||||
const skinTextureNormalized = await normalize_skin_texture(originalSkinTexUrl)
|
||||
await setUploadedTexture({
|
||||
original: originalSkinTexUrl,
|
||||
normalized: `data:image/png;base64,${arrayBufferToBase64(skinTextureNormalized)}`,
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
if (textureFileInput.value) {
|
||||
textureFileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,17 +437,32 @@ async function save() {
|
||||
textureUrl = currentSkin.value!.texture
|
||||
}
|
||||
|
||||
await unequip_skin()
|
||||
|
||||
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
|
||||
|
||||
if (mode.value === 'new') {
|
||||
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
emit('saved')
|
||||
const addedSkin = await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
emit('saved', {
|
||||
applied: true,
|
||||
skin: addedSkin,
|
||||
})
|
||||
} else {
|
||||
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
|
||||
await remove_custom_skin(currentSkin.value!)
|
||||
emit('saved')
|
||||
const updatedSkin = await save_custom_skin(
|
||||
currentSkin.value!,
|
||||
bytes,
|
||||
variant.value,
|
||||
selectedCape.value,
|
||||
!!uploadedTextureUrl.value && textureUrl !== currentSkin.value?.texture,
|
||||
)
|
||||
|
||||
if (currentSkin.value?.is_equipped) {
|
||||
await equip_skin(updatedSkin)
|
||||
}
|
||||
|
||||
emit('saved', {
|
||||
applied: !!currentSkin.value?.is_equipped,
|
||||
skin: updatedSkin,
|
||||
previousSkin: currentSkin.value!,
|
||||
})
|
||||
}
|
||||
|
||||
hide()
|
||||
@@ -393,28 +475,53 @@ async function save() {
|
||||
|
||||
watch([uploadedTextureUrl, currentSkin], async () => {
|
||||
await loadPreviewSkin()
|
||||
refreshCapeListLayout()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.capes,
|
||||
() => {
|
||||
initVisibleCapeList()
|
||||
nextTick(() => refreshCapeListLayout())
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
capeListRef,
|
||||
(capeList, _, onCleanup) => {
|
||||
if (!capeList) return
|
||||
|
||||
const modalContent = capeList.closest('[data-modal-content]')
|
||||
const resizeObserver = new ResizeObserver(() => refreshCapeListLayout())
|
||||
|
||||
if (modalContent instanceof HTMLElement) {
|
||||
resizeObserver.observe(modalContent)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', refreshCapeListLayout, { passive: true })
|
||||
refreshCapeListLayout()
|
||||
|
||||
onCleanup(() => {
|
||||
resizeObserver.disconnect()
|
||||
window.removeEventListener('resize', refreshCapeListLayout)
|
||||
|
||||
if (capeListLayoutFrame !== null) {
|
||||
cancelAnimationFrame(capeListLayoutFrame)
|
||||
capeListLayoutFrame = null
|
||||
}
|
||||
})
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'saved'): void
|
||||
(event: 'saved', options: { applied: boolean; skin?: Skin; previousSkin?: Skin }): void
|
||||
(event: 'deleted', skin: Skin): void
|
||||
(event: 'open-upload-modal', mouseEvent: MouseEvent): void
|
||||
}>()
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
showNew,
|
||||
restoreWithNewTexture,
|
||||
hide,
|
||||
shouldRestoreModal,
|
||||
restoreModal,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
CapeButton,
|
||||
CapeLikeTextButton,
|
||||
ScrollablePanel,
|
||||
SkinPreviewRenderer,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import type { Cape, SkinModel } from '@/helpers/skins.ts'
|
||||
|
||||
const modal = useTemplateRef('modal')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', cape: Cape | undefined): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
capes: Cape[]
|
||||
}>()
|
||||
|
||||
const sortedCapes = computed(() => {
|
||||
return [...props.capes].sort((a, b) => {
|
||||
const nameA = (a.name || '').toLowerCase()
|
||||
const nameB = (b.name || '').toLowerCase()
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
})
|
||||
|
||||
const currentSkinId = ref<string | undefined>()
|
||||
const currentSkinTexture = ref<string | undefined>()
|
||||
const currentSkinVariant = ref<SkinModel>('CLASSIC')
|
||||
const currentCapeTexture = computed<string | undefined>(() => currentCape.value?.texture)
|
||||
const currentCape = ref<Cape | undefined>()
|
||||
|
||||
function show(
|
||||
e: MouseEvent,
|
||||
skinId?: string,
|
||||
selected?: Cape,
|
||||
skinTexture?: string,
|
||||
variant?: SkinModel,
|
||||
) {
|
||||
currentSkinId.value = skinId
|
||||
currentSkinTexture.value = skinTexture
|
||||
currentSkinVariant.value = variant || 'CLASSIC'
|
||||
currentCape.value = selected
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
function select() {
|
||||
emit('select', currentCape.value)
|
||||
hide()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
function updateSelectedCape(cape: Cape | undefined) {
|
||||
currentCape.value = cape
|
||||
}
|
||||
|
||||
function onModalHide() {
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<ModalWrapper ref="modal" @on-hide="onModalHide">
|
||||
<template #title>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-lg font-extrabold text-heading">Change cape</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<div class="max-h-[25rem] h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
|
||||
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
|
||||
<SkinPreviewRenderer
|
||||
v-if="currentSkinTexture"
|
||||
:cape-src="currentCapeTexture"
|
||||
:texture-src="currentSkinTexture"
|
||||
:variant="currentSkinVariant"
|
||||
:scale="1.4"
|
||||
:fov="50"
|
||||
:initial-rotation="Math.PI + Math.PI / 8"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full my-auto">
|
||||
<ScrollablePanel class="max-h-[20rem] max-w-[30rem] mb-5 h-full">
|
||||
<div class="flex flex-wrap gap-2 justify-center content-start overflow-y-auto h-full">
|
||||
<CapeLikeTextButton
|
||||
tooltip="No Cape"
|
||||
:highlighted="!currentCape"
|
||||
@click="updateSelectedCape(undefined)"
|
||||
>
|
||||
<template #icon>
|
||||
<XIcon />
|
||||
</template>
|
||||
<span>None</span>
|
||||
</CapeLikeTextButton>
|
||||
<CapeButton
|
||||
v-for="cape in sortedCapes"
|
||||
:id="cape.id"
|
||||
:key="cape.id"
|
||||
:name="cape.name"
|
||||
:texture="cape.texture"
|
||||
:selected="currentCape?.id === cape.id"
|
||||
@select="updateSelectedCape(cape)"
|
||||
/>
|
||||
</div>
|
||||
</ScrollablePanel>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="select">
|
||||
<CheckIcon />
|
||||
Select
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="hide">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
@@ -1,141 +0,0 @@
|
||||
<template>
|
||||
<ModalWrapper ref="modal" @on-hide="hide(true)">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast"> Upload skin texture </span>
|
||||
</template>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="border-2 border-dashed border-highlight-gray rounded-xl h-[173px] flex flex-col items-center justify-center p-8 cursor-pointer bg-button-bg hover:bg-button-hover transition-colors relative"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<p class="mx-auto mb-0 text-primary font-bold text-lg text-center flex items-center gap-2">
|
||||
<UploadIcon /> Select skin texture file
|
||||
</p>
|
||||
<p class="mx-auto mt-0 text-secondary text-sm text-center">
|
||||
Drag and drop or click here to browse
|
||||
</p>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/png"
|
||||
class="hidden"
|
||||
@change="handleInputFileChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UploadIcon } from '@modrinth/assets'
|
||||
import { injectNotificationManager } from '@modrinth/ui'
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview'
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { get_dragged_skin_data } from '@/helpers/skins'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const modal = ref()
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const unlisten = ref<() => void>()
|
||||
const modalVisible = ref(false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'uploaded', data: ArrayBuffer): void
|
||||
(e: 'canceled'): void
|
||||
}>()
|
||||
|
||||
function show(e?: MouseEvent) {
|
||||
modal.value?.show(e)
|
||||
modalVisible.value = true
|
||||
setupDragDropListener()
|
||||
}
|
||||
|
||||
function hide(emitCanceled = false) {
|
||||
modal.value?.hide()
|
||||
modalVisible.value = false
|
||||
cleanupDragDropListener()
|
||||
resetState()
|
||||
if (emitCanceled) {
|
||||
emit('canceled')
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleInputFileChange(e: Event) {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
if (!files || files.length === 0) {
|
||||
return
|
||||
}
|
||||
const file = files[0]
|
||||
const buffer = await file.arrayBuffer()
|
||||
await processData(buffer)
|
||||
}
|
||||
|
||||
async function setupDragDropListener() {
|
||||
try {
|
||||
if (modalVisible.value) {
|
||||
await cleanupDragDropListener()
|
||||
unlisten.value = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||
if (event.payload.type !== 'drop') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.payload.paths || event.payload.paths.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = event.payload.paths[0]
|
||||
|
||||
try {
|
||||
const data = await get_dragged_skin_data(filePath)
|
||||
await processData(data.buffer)
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
title: 'Error processing file',
|
||||
text: error instanceof Error ? error.message : 'Failed to read the dropped file.',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set up drag and drop listener:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupDragDropListener() {
|
||||
if (unlisten.value) {
|
||||
unlisten.value()
|
||||
unlisten.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function processData(buffer: ArrayBuffer) {
|
||||
emit('uploaded', buffer)
|
||||
hide()
|
||||
}
|
||||
|
||||
watch(modalVisible, (isVisible) => {
|
||||
if (isVisible) {
|
||||
setupDragDropListener()
|
||||
} else {
|
||||
cleanupDragDropListener()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupDragDropListener()
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,410 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon, EditIcon, PlusIcon, TrashIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
SkinButton,
|
||||
SkinLikeTextButton,
|
||||
useScrollViewport,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useElementSize, useWindowSize } from '@vueuse/core'
|
||||
import { computed, nextTick, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import type { Skin } from '@/helpers/skins.ts'
|
||||
|
||||
type SkinSectionKind = 'saved' | 'default'
|
||||
type SkinLikeTextButtonExpose = {
|
||||
getRootElement: () => HTMLElement | null | undefined
|
||||
}
|
||||
type AddSkinButtonRef = SkinLikeTextButtonExpose | SkinLikeTextButtonExpose[]
|
||||
|
||||
interface DefaultSkinSection {
|
||||
title: string
|
||||
skins: Skin[]
|
||||
}
|
||||
|
||||
interface SkinSection {
|
||||
key: string
|
||||
title: string
|
||||
kind: SkinSectionKind
|
||||
skins: Skin[]
|
||||
}
|
||||
|
||||
interface VirtualSkinSection {
|
||||
section: SkinSection
|
||||
top: number
|
||||
index: number
|
||||
}
|
||||
|
||||
const SKIN_CARD_ASPECT_WIDTH = 31
|
||||
const SKIN_CARD_ASPECT_HEIGHT = 40
|
||||
const SKIN_GRID_GAP = 12
|
||||
const SKIN_SECTION_FIRST_SPACING = 4
|
||||
const SKIN_SECTION_SPACING = 24
|
||||
const SKIN_SECTION_HEADER_HEIGHT = 28
|
||||
const SKIN_SECTION_CONTENT_SPACING = 8
|
||||
const SKIN_SECTION_OVERSCAN = 900
|
||||
const FALLBACK_CARD_WIDTH = 220
|
||||
const messages = defineMessages({
|
||||
savedSkinsSection: {
|
||||
id: 'app.skins.section.saved-skins',
|
||||
defaultMessage: 'Saved skins',
|
||||
},
|
||||
addSkinButton: {
|
||||
id: 'app.skins.add-button',
|
||||
defaultMessage: 'Add skin',
|
||||
},
|
||||
dragAndDropSubtitle: {
|
||||
id: 'app.skins.add-button.drag-and-drop',
|
||||
defaultMessage: 'Drag and drop',
|
||||
},
|
||||
editSkinButton: {
|
||||
id: 'app.skins.edit-button',
|
||||
defaultMessage: 'Edit skin',
|
||||
},
|
||||
deleteSkinButton: {
|
||||
id: 'app.skins.delete-button',
|
||||
defaultMessage: 'Delete skin',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
savedSkins: Skin[]
|
||||
defaultSkinSections: DefaultSkinSection[]
|
||||
getBakedSkinTextures: (skin: Skin) => RenderResult | undefined
|
||||
isSkinSelected: (skin: Skin) => boolean
|
||||
isSkinActive: (skin: Skin) => boolean
|
||||
isAddSkinButtonDragActive: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [skin: Skin]
|
||||
edit: [skin: Skin, event: MouseEvent]
|
||||
delete: [skin: Skin]
|
||||
'add-skin': []
|
||||
'add-skin-dragenter': [event: DragEvent]
|
||||
'add-skin-dragover': [event: DragEvent]
|
||||
'add-skin-dragleave': [event: DragEvent]
|
||||
'add-skin-drop': [event: DragEvent]
|
||||
}>()
|
||||
|
||||
const addSkinButton = useTemplateRef<AddSkinButtonRef>('addSkinButton')
|
||||
const { formatMessage } = useVIntl()
|
||||
const { listContainer, relativeScrollTop, scrollContainer, viewportHeight } = useScrollViewport()
|
||||
const openSectionKeys = ref<Set<string>>(new Set())
|
||||
const hasSettledInitialLayout = ref(false)
|
||||
const knownSectionKeys = new Set<string>()
|
||||
let enableLayoutTransitionsFrame: number | null = null
|
||||
let isEnableLayoutTransitionsScheduled = false
|
||||
let isUnmounted = false
|
||||
|
||||
const { width: listWidth } = useElementSize(listContainer)
|
||||
const { width: windowWidth } = useWindowSize()
|
||||
|
||||
const columnCount = computed(() => {
|
||||
if (windowWidth.value >= 2050) {
|
||||
return 6
|
||||
}
|
||||
|
||||
if (windowWidth.value >= 1750) {
|
||||
return 5
|
||||
}
|
||||
|
||||
if (windowWidth.value >= 1300) {
|
||||
return 4
|
||||
}
|
||||
|
||||
return 3
|
||||
})
|
||||
|
||||
const cardWidth = computed(() => {
|
||||
if (listWidth.value <= 0) {
|
||||
return FALLBACK_CARD_WIDTH
|
||||
}
|
||||
|
||||
const gapsWidth = (columnCount.value - 1) * SKIN_GRID_GAP
|
||||
return Math.max(0, (listWidth.value - gapsWidth) / columnCount.value)
|
||||
})
|
||||
|
||||
const cardHeight = computed(
|
||||
() => (cardWidth.value * SKIN_CARD_ASPECT_HEIGHT) / SKIN_CARD_ASPECT_WIDTH,
|
||||
)
|
||||
|
||||
const sections = computed<SkinSection[]>(() => [
|
||||
{
|
||||
key: 'saved-skins',
|
||||
title: formatMessage(messages.savedSkinsSection),
|
||||
kind: 'saved',
|
||||
skins: props.savedSkins,
|
||||
},
|
||||
...props.defaultSkinSections.map((section) => ({
|
||||
key: defaultSkinSectionKey(section.title),
|
||||
title: section.title,
|
||||
kind: 'default' as const,
|
||||
skins: section.skins,
|
||||
})),
|
||||
])
|
||||
|
||||
const sectionLayouts = computed(() => {
|
||||
const layouts: Array<{ section: SkinSection; top: number; height: number; index: number }> = []
|
||||
let top = 0
|
||||
|
||||
sections.value.forEach((section, index) => {
|
||||
const height = getSectionHeightEstimate(section, index)
|
||||
layouts.push({ section, top, height, index })
|
||||
top += height
|
||||
})
|
||||
|
||||
return layouts
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => {
|
||||
const lastSection = sectionLayouts.value[sectionLayouts.value.length - 1]
|
||||
return lastSection ? lastSection.top + lastSection.height : 0
|
||||
})
|
||||
|
||||
const visibleSections = computed<VirtualSkinSection[]>(() => {
|
||||
if (!listContainer.value || !scrollContainer.value) {
|
||||
return sectionLayouts.value.slice(0, 4)
|
||||
}
|
||||
|
||||
const viewportStart = Math.max(0, relativeScrollTop.value - SKIN_SECTION_OVERSCAN)
|
||||
const viewportEnd = relativeScrollTop.value + viewportHeight.value + SKIN_SECTION_OVERSCAN
|
||||
|
||||
return sectionLayouts.value
|
||||
.filter((layout) => layout.top + layout.height >= viewportStart && layout.top <= viewportEnd)
|
||||
.map(({ section, top, index }) => ({ section, top, index }))
|
||||
})
|
||||
|
||||
watch(
|
||||
sections,
|
||||
(nextSections) => {
|
||||
const sectionKeys = new Set(nextSections.map((section) => section.key))
|
||||
const openKeys = new Set(openSectionKeys.value)
|
||||
|
||||
for (const section of nextSections) {
|
||||
if (!knownSectionKeys.has(section.key)) {
|
||||
knownSectionKeys.add(section.key)
|
||||
openKeys.add(section.key)
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of knownSectionKeys) {
|
||||
if (!sectionKeys.has(key)) {
|
||||
knownSectionKeys.delete(key)
|
||||
openKeys.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
openSectionKeys.value = openKeys
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
listWidth,
|
||||
(width) => {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
width <= 0 ||
|
||||
hasSettledInitialLayout.value ||
|
||||
isEnableLayoutTransitionsScheduled
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
isEnableLayoutTransitionsScheduled = true
|
||||
void nextTick(() => {
|
||||
if (isUnmounted) return
|
||||
|
||||
enableLayoutTransitionsFrame = window.requestAnimationFrame(() => {
|
||||
if (isUnmounted) return
|
||||
|
||||
enableLayoutTransitionsFrame = window.requestAnimationFrame(() => {
|
||||
if (isUnmounted) return
|
||||
|
||||
hasSettledInitialLayout.value = true
|
||||
enableLayoutTransitionsFrame = null
|
||||
isEnableLayoutTransitionsScheduled = false
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
isUnmounted = true
|
||||
|
||||
if (enableLayoutTransitionsFrame !== null) {
|
||||
window.cancelAnimationFrame(enableLayoutTransitionsFrame)
|
||||
}
|
||||
})
|
||||
|
||||
function defaultSkinSectionKey(title: string) {
|
||||
return `default-skins-${title}`
|
||||
}
|
||||
|
||||
function skinKey(skin: Skin, prefix: string) {
|
||||
return `${prefix}-${skin.source}-${skin.texture_key}-${skin.variant}-${skin.cape_id ?? 'no-cape'}`
|
||||
}
|
||||
|
||||
function isSectionOpen(key: string) {
|
||||
return openSectionKeys.value.has(key)
|
||||
}
|
||||
|
||||
function setSectionOpen(key: string, open: boolean) {
|
||||
const openKeys = new Set(openSectionKeys.value)
|
||||
|
||||
if (open) {
|
||||
openKeys.add(key)
|
||||
} else {
|
||||
openKeys.delete(key)
|
||||
}
|
||||
|
||||
openSectionKeys.value = openKeys
|
||||
}
|
||||
|
||||
function getSectionHeightEstimate(section: SkinSection, index: number) {
|
||||
const spacing = index === 0 ? SKIN_SECTION_FIRST_SPACING : SKIN_SECTION_SPACING
|
||||
|
||||
if (!isSectionOpen(section.key)) {
|
||||
return spacing + SKIN_SECTION_HEADER_HEIGHT
|
||||
}
|
||||
|
||||
const cardCount = section.kind === 'saved' ? section.skins.length + 1 : section.skins.length
|
||||
const rowCount = Math.ceil(cardCount / columnCount.value)
|
||||
const gridHeight = rowCount * cardHeight.value + Math.max(0, rowCount - 1) * SKIN_GRID_GAP
|
||||
|
||||
return spacing + SKIN_SECTION_HEADER_HEIGHT + SKIN_SECTION_CONTENT_SPACING + gridHeight
|
||||
}
|
||||
|
||||
function getAddSkinButtonElement() {
|
||||
const button = Array.isArray(addSkinButton.value)
|
||||
? addSkinButton.value.find((candidate) => candidate.getRootElement())
|
||||
: addSkinButton.value
|
||||
|
||||
return button?.getRootElement()
|
||||
}
|
||||
|
||||
defineExpose({ getAddSkinButtonElement })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="listContainer"
|
||||
class="relative w-full"
|
||||
:style="{ height: `${totalHeight}px`, overflowAnchor: 'none' }"
|
||||
>
|
||||
<div
|
||||
v-for="{ section, top, index } in visibleSections"
|
||||
:key="section.key"
|
||||
class="absolute inset-x-0"
|
||||
:class="[
|
||||
index === 0 ? 'pt-1' : 'pt-6',
|
||||
hasSettledInitialLayout
|
||||
? 'transition-transform duration-300 ease-in-out will-change-transform motion-reduce:transition-none'
|
||||
: '',
|
||||
]"
|
||||
:style="{ transform: `translateY(${top}px)` }"
|
||||
>
|
||||
<Accordion
|
||||
button-class="group flex w-full items-center gap-[6px] bg-transparent m-0 p-0 border-none cursor-pointer text-left"
|
||||
content-class="pt-2"
|
||||
:open-by-default="isSectionOpen(section.key)"
|
||||
@on-open="setSectionOpen(section.key, true)"
|
||||
@on-close="setSectionOpen(section.key, false)"
|
||||
>
|
||||
<template #title>
|
||||
{{ section.title }}
|
||||
</template>
|
||||
<template #button="{ open }">
|
||||
<DropdownIcon
|
||||
class="size-6 shrink-0 text-primary transition-transform duration-300"
|
||||
:class="{ 'rotate-180': open }"
|
||||
/>
|
||||
<span class="min-w-0 text-xl font-semibold leading-7 text-primary">
|
||||
{{ section.title }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="section.kind === 'saved'"
|
||||
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
|
||||
>
|
||||
<SkinLikeTextButton
|
||||
ref="addSkinButton"
|
||||
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
dropzone
|
||||
:drag-active="isAddSkinButtonDragActive"
|
||||
@click="emit('add-skin')"
|
||||
@dragenter="emit('add-skin-dragenter', $event)"
|
||||
@dragover="emit('add-skin-dragover', $event)"
|
||||
@dragleave="emit('add-skin-dragleave', $event)"
|
||||
@drop="emit('add-skin-drop', $event)"
|
||||
>
|
||||
<template #icon>
|
||||
<PlusIcon class="size-8" />
|
||||
</template>
|
||||
{{ formatMessage(messages.addSkinButton) }}
|
||||
<template #subtitle>{{ formatMessage(messages.dragAndDropSubtitle) }}</template>
|
||||
</SkinLikeTextButton>
|
||||
|
||||
<SkinButton
|
||||
v-for="skin in section.skins"
|
||||
:key="skinKey(skin, 'saved-skin')"
|
||||
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
||||
:selected="isSkinSelected(skin)"
|
||||
:active="isSkinActive(skin)"
|
||||
@select="emit('select', skin)"
|
||||
>
|
||||
<template #overlay-buttons>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:aria-label="formatMessage(messages.editSkinButton)"
|
||||
class="pointer-events-auto"
|
||||
@click.stop="(event: MouseEvent) => emit('edit', skin, event)"
|
||||
>
|
||||
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-show="!skin.is_equipped" circular color="red">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.deleteSkinButton)"
|
||||
:aria-label="formatMessage(messages.deleteSkinButton)"
|
||||
class="!rounded-[100%] pointer-events-auto"
|
||||
@click.stop="emit('delete', skin)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</SkinButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
|
||||
>
|
||||
<SkinButton
|
||||
v-for="skin in section.skins"
|
||||
:key="skinKey(skin, section.key)"
|
||||
class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
||||
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
|
||||
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
|
||||
:selected="isSkinSelected(skin)"
|
||||
:active="isSkinActive(skin)"
|
||||
:tooltip="skin.name"
|
||||
@select="emit('select', skin)"
|
||||
/>
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,9 +3,8 @@ import {
|
||||
applyCapeTexture,
|
||||
createTransparentTexture,
|
||||
disposeCaches,
|
||||
loadTexture,
|
||||
setupSkinModel,
|
||||
} from '@modrinth/utils'
|
||||
} from '@modrinth/ui'
|
||||
import * as THREE from 'three'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
@@ -29,6 +28,7 @@ class BatchSkinRenderer {
|
||||
private scene: THREE.Scene | null = null
|
||||
private camera: THREE.PerspectiveCamera | null = null
|
||||
private currentModel: THREE.Group | null = null
|
||||
private transparentTexture: THREE.Texture | null = null
|
||||
private readonly width: number
|
||||
private readonly height: number
|
||||
|
||||
@@ -52,6 +52,7 @@ class BatchSkinRenderer {
|
||||
})
|
||||
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
this.renderer.shadowMap.enabled = false
|
||||
this.renderer.toneMapping = THREE.NoToneMapping
|
||||
this.renderer.toneMappingExposure = 10.0
|
||||
this.renderer.setClearColor(0x000000, 0)
|
||||
@@ -62,7 +63,7 @@ class BatchSkinRenderer {
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
|
||||
directionalLight.castShadow = true
|
||||
directionalLight.castShadow = false
|
||||
directionalLight.position.set(2, 4, 3)
|
||||
this.scene.add(ambientLight)
|
||||
this.scene.add(directionalLight)
|
||||
@@ -112,9 +113,19 @@ class BatchSkinRenderer {
|
||||
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
|
||||
const dataUrl = this.renderer.domElement.toDataURL('image/webp', 0.9)
|
||||
const response = await fetch(dataUrl)
|
||||
return await response.blob()
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
this.renderer!.domElement.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
} else {
|
||||
reject(new Error('Failed to create blob from rendered canvas'))
|
||||
}
|
||||
},
|
||||
'image/webp',
|
||||
0.9,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
|
||||
@@ -122,14 +133,10 @@ class BatchSkinRenderer {
|
||||
throw new Error('Renderer not initialized')
|
||||
}
|
||||
|
||||
const { model } = await setupSkinModel(modelUrl, textureUrl)
|
||||
const { model } = await setupSkinModel(modelUrl, textureUrl, capeUrl)
|
||||
|
||||
if (capeUrl) {
|
||||
const capeTexture = await loadTexture(capeUrl)
|
||||
applyCapeTexture(model, capeTexture)
|
||||
} else {
|
||||
const transparentTexture = createTransparentTexture()
|
||||
applyCapeTexture(model, null, transparentTexture)
|
||||
if (!capeUrl) {
|
||||
applyCapeTexture(model, null, this.getTransparentTexture())
|
||||
}
|
||||
|
||||
const group = new THREE.Group()
|
||||
@@ -141,39 +148,38 @@ class BatchSkinRenderer {
|
||||
this.currentModel = group
|
||||
}
|
||||
|
||||
private clearScene(): void {
|
||||
if (!this.scene) return
|
||||
|
||||
while (this.scene.children.length > 0) {
|
||||
const child = this.scene.children[0]
|
||||
this.scene.remove(child)
|
||||
|
||||
if (child instanceof THREE.Mesh) {
|
||||
if (child.geometry) child.geometry.dispose()
|
||||
if (child.material) {
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material.forEach((material) => material.dispose())
|
||||
} else {
|
||||
child.material.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
private getTransparentTexture(): THREE.Texture {
|
||||
if (!this.transparentTexture) {
|
||||
this.transparentTexture = createTransparentTexture()
|
||||
}
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
|
||||
directionalLight.castShadow = true
|
||||
directionalLight.position.set(2, 4, 3)
|
||||
this.scene.add(ambientLight)
|
||||
this.scene.add(directionalLight)
|
||||
return this.transparentTexture
|
||||
}
|
||||
|
||||
private clearScene(): void {
|
||||
if (!this.scene || !this.currentModel) return
|
||||
|
||||
this.scene.remove(this.currentModel)
|
||||
this.currentModel.clear()
|
||||
this.currentModel = null
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.clearScene()
|
||||
|
||||
if (this.transparentTexture) {
|
||||
this.transparentTexture.dispose()
|
||||
this.transparentTexture = null
|
||||
}
|
||||
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose()
|
||||
}
|
||||
|
||||
this.renderer = null
|
||||
this.scene = null
|
||||
this.camera = null
|
||||
|
||||
disposeCaches()
|
||||
}
|
||||
}
|
||||
@@ -194,6 +200,9 @@ export const headBlobUrlMap = reactive(new Map<string, string>())
|
||||
const DEBUG_MODE = false
|
||||
|
||||
let sharedRenderer: BatchSkinRenderer | null = null
|
||||
let latestPreviewGeneration = 0
|
||||
let previewGenerationQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
function getSharedRenderer(): BatchSkinRenderer {
|
||||
if (!sharedRenderer) {
|
||||
sharedRenderer = new BatchSkinRenderer()
|
||||
@@ -356,7 +365,27 @@ export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
|
||||
return await generateHeadRender(skin)
|
||||
}
|
||||
|
||||
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
|
||||
export function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
|
||||
const generation = ++latestPreviewGeneration
|
||||
const skinsSnapshot = [...skins]
|
||||
const capesSnapshot = [...capes]
|
||||
|
||||
const generationPromise = previewGenerationQueue.then(() =>
|
||||
generateSkinPreviewsForGeneration(skinsSnapshot, capesSnapshot, generation),
|
||||
)
|
||||
|
||||
previewGenerationQueue = generationPromise.catch(() => {})
|
||||
|
||||
return generationPromise
|
||||
}
|
||||
|
||||
async function generateSkinPreviewsForGeneration(
|
||||
skins: Skin[],
|
||||
capes: Cape[],
|
||||
generation: number,
|
||||
): Promise<void> {
|
||||
const isCurrentGeneration = () => generation === latestPreviewGeneration
|
||||
|
||||
try {
|
||||
const skinKeys = skins.map(
|
||||
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
|
||||
@@ -368,6 +397,8 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
|
||||
headStorage.batchRetrieve(headKeys),
|
||||
])
|
||||
|
||||
if (!isCurrentGeneration()) return
|
||||
|
||||
for (let i = 0; i < skins.length; i++) {
|
||||
const skinKey = skinKeys[i]
|
||||
const headKey = headKeys[i]
|
||||
@@ -388,6 +419,8 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
|
||||
}
|
||||
|
||||
for (const skin of skins) {
|
||||
if (!isCurrentGeneration()) return
|
||||
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
|
||||
if (skinBlobUrlMap.has(key)) {
|
||||
@@ -419,6 +452,8 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
|
||||
cape?.texture,
|
||||
)
|
||||
|
||||
if (!isCurrentGeneration()) return
|
||||
|
||||
const renderResult: RenderResult = {
|
||||
forwards: URL.createObjectURL(rawRenderResult.forwards),
|
||||
backwards: URL.createObjectURL(rawRenderResult.backwards),
|
||||
@@ -439,9 +474,12 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
|
||||
}
|
||||
} finally {
|
||||
disposeSharedRenderer()
|
||||
await cleanupUnusedPreviews(skins)
|
||||
|
||||
await skinPreviewStorage.debugCalculateStorage()
|
||||
await headStorage.debugCalculateStorage()
|
||||
if (isCurrentGeneration()) {
|
||||
await cleanupUnusedPreviews(skins)
|
||||
|
||||
await skinPreviewStorage.debugCalculateStorage()
|
||||
await headStorage.debugCalculateStorage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ export interface Cape {
|
||||
id: string
|
||||
name: string
|
||||
texture: string
|
||||
is_default: boolean
|
||||
is_equipped: boolean
|
||||
}
|
||||
|
||||
@@ -15,6 +14,7 @@ export type SkinSource = 'default' | 'custom_external' | 'custom'
|
||||
export interface Skin {
|
||||
texture_key: string
|
||||
name?: string
|
||||
section?: string
|
||||
variant: SkinModel
|
||||
cape_id?: string
|
||||
texture: string
|
||||
@@ -121,17 +121,11 @@ export async function get_available_skins(): Promise<Skin[]> {
|
||||
export async function add_and_equip_custom_skin(
|
||||
textureBlob: Uint8Array,
|
||||
variant: SkinModel,
|
||||
capeOverride?: Cape,
|
||||
): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
|
||||
cape?: Cape,
|
||||
): Promise<Skin> {
|
||||
return await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
|
||||
textureBlob,
|
||||
variant,
|
||||
capeOverride,
|
||||
})
|
||||
}
|
||||
|
||||
export async function set_default_cape(cape?: Cape): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|set_default_cape', {
|
||||
cape,
|
||||
})
|
||||
}
|
||||
@@ -148,6 +142,22 @@ export async function remove_custom_skin(skin: Skin): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function save_custom_skin(
|
||||
skin: Skin,
|
||||
textureBlob: Uint8Array,
|
||||
variant: SkinModel,
|
||||
cape: Cape | undefined,
|
||||
replaceTexture: boolean,
|
||||
): Promise<Skin> {
|
||||
return await invoke('plugin:minecraft-skins|save_custom_skin', {
|
||||
skin,
|
||||
textureBlob,
|
||||
variant,
|
||||
cape,
|
||||
replaceTexture,
|
||||
})
|
||||
}
|
||||
|
||||
export async function get_normalized_skin_texture(skin: Skin): Promise<string> {
|
||||
const data = await normalize_skin_texture(skin.texture)
|
||||
const base64 = arrayBufferToBase64(data)
|
||||
@@ -162,6 +172,16 @@ export async function unequip_skin(): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|unequip_skin')
|
||||
}
|
||||
|
||||
export async function flush_pending_skin_change(): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|flush_pending_skin_change')
|
||||
}
|
||||
|
||||
export async function flush_pending_skin_change_for_profile(profileId: string): Promise<void> {
|
||||
await invoke('plugin:minecraft-skins|flush_pending_skin_change_for_profile', {
|
||||
profileId,
|
||||
})
|
||||
}
|
||||
|
||||
export async function get_dragged_skin_data(path: string): Promise<Uint8Array> {
|
||||
const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path })
|
||||
return new Uint8Array(data)
|
||||
|
||||
@@ -332,6 +332,138 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Resource management"
|
||||
},
|
||||
"app.skins.add-button": {
|
||||
"message": "Add skin"
|
||||
},
|
||||
"app.skins.add-button.drag-and-drop": {
|
||||
"message": "Drag and drop"
|
||||
},
|
||||
"app.skins.apply-button": {
|
||||
"message": "Apply"
|
||||
},
|
||||
"app.skins.delete-button": {
|
||||
"message": "Delete skin"
|
||||
},
|
||||
"app.skins.delete-modal.description": {
|
||||
"message": "This will permanently delete the selected skin. This action cannot be undone."
|
||||
},
|
||||
"app.skins.delete-modal.title": {
|
||||
"message": "Are you sure you want to delete this skin?"
|
||||
},
|
||||
"app.skins.dropped-file-error.text": {
|
||||
"message": "Failed to read the dropped file."
|
||||
},
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Error processing file"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Edit skin"
|
||||
},
|
||||
"app.skins.modal.add-skin-button": {
|
||||
"message": "Add skin"
|
||||
},
|
||||
"app.skins.modal.add-title": {
|
||||
"message": "Adding a skin"
|
||||
},
|
||||
"app.skins.modal.arm-style-section": {
|
||||
"message": "Arm style"
|
||||
},
|
||||
"app.skins.modal.arm-style-slim": {
|
||||
"message": "Slim"
|
||||
},
|
||||
"app.skins.modal.arm-style-wide": {
|
||||
"message": "Wide"
|
||||
},
|
||||
"app.skins.modal.cape-fallback-name": {
|
||||
"message": "Cape"
|
||||
},
|
||||
"app.skins.modal.cape-section": {
|
||||
"message": "Cape"
|
||||
},
|
||||
"app.skins.modal.edit-title": {
|
||||
"message": "Editing skin"
|
||||
},
|
||||
"app.skins.modal.make-edit-first-tooltip": {
|
||||
"message": "Make an edit to the skin first!"
|
||||
},
|
||||
"app.skins.modal.no-cape-tooltip": {
|
||||
"message": "No cape"
|
||||
},
|
||||
"app.skins.modal.none-cape-option": {
|
||||
"message": "None"
|
||||
},
|
||||
"app.skins.modal.replace-texture-button": {
|
||||
"message": "Replace texture"
|
||||
},
|
||||
"app.skins.modal.save-skin-button": {
|
||||
"message": "Save skin"
|
||||
},
|
||||
"app.skins.modal.saving-tooltip": {
|
||||
"message": "Saving..."
|
||||
},
|
||||
"app.skins.modal.texture-section": {
|
||||
"message": "Texture"
|
||||
},
|
||||
"app.skins.modal.upload-skin-first-tooltip": {
|
||||
"message": "Upload a skin first!"
|
||||
},
|
||||
"app.skins.preview.edit-button": {
|
||||
"message": "Edit skin"
|
||||
},
|
||||
"app.skins.previewing-badge": {
|
||||
"message": "Previewing"
|
||||
},
|
||||
"app.skins.rate-limit.text": {
|
||||
"message": "You're changing your skin too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again."
|
||||
},
|
||||
"app.skins.rate-limit.title": {
|
||||
"message": "Slow down!"
|
||||
},
|
||||
"app.skins.section.builders-and-biomes": {
|
||||
"message": "Builders & Biomes"
|
||||
},
|
||||
"app.skins.section.chase-the-skies": {
|
||||
"message": "Chase the Skies"
|
||||
},
|
||||
"app.skins.section.default-skins": {
|
||||
"message": "Default skins"
|
||||
},
|
||||
"app.skins.section.minecon-earth-2017": {
|
||||
"message": "MINECON Earth 2017"
|
||||
},
|
||||
"app.skins.section.mounts-of-mayhem": {
|
||||
"message": "Mounts of Mayhem"
|
||||
},
|
||||
"app.skins.section.saved-skins": {
|
||||
"message": "Saved skins"
|
||||
},
|
||||
"app.skins.section.striding-hero": {
|
||||
"message": "Striding Hero"
|
||||
},
|
||||
"app.skins.section.the-copper-age": {
|
||||
"message": "The Copper Age"
|
||||
},
|
||||
"app.skins.section.the-garden-awakens": {
|
||||
"message": "The Garden Awakens"
|
||||
},
|
||||
"app.skins.section.tiny-takeover": {
|
||||
"message": "Tiny Takeover"
|
||||
},
|
||||
"app.skins.sign-in.button": {
|
||||
"message": "Sign In"
|
||||
},
|
||||
"app.skins.sign-in.description": {
|
||||
"message": "Please sign into your Minecraft account to use the skin management features of the Modrinth app."
|
||||
},
|
||||
"app.skins.sign-in.rinthbot-alt": {
|
||||
"message": "Excited Modrinth Bot"
|
||||
},
|
||||
"app.skins.sign-in.title": {
|
||||
"message": "Please sign in"
|
||||
},
|
||||
"app.skins.title": {
|
||||
"message": "Skin selector"
|
||||
},
|
||||
"app.update-popup.body": {
|
||||
"message": "Modrinth App v{version} is ready to install! Reload to update now, or automatically when you close Modrinth App."
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -86,10 +86,10 @@ export default new createRouter({
|
||||
},
|
||||
{
|
||||
path: '/skins',
|
||||
name: 'Skins',
|
||||
name: 'Skin selector',
|
||||
component: Pages.Skins,
|
||||
meta: {
|
||||
breadcrumb: [{ name: 'Skins' }],
|
||||
breadcrumb: [{ name: 'Skin selector' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ export type ColorTheme = (typeof THEME_OPTIONS)[number]
|
||||
export type ThemeStore = {
|
||||
selectedTheme: ColorTheme
|
||||
advancedRendering: boolean
|
||||
hideNametagSkinsPage: boolean
|
||||
toggleSidebar: boolean
|
||||
|
||||
devMode: boolean
|
||||
@@ -30,6 +31,7 @@ export type ThemeStore = {
|
||||
export const DEFAULT_THEME_STORE: ThemeStore = {
|
||||
selectedTheme: 'dark',
|
||||
advancedRendering: true,
|
||||
hideNametagSkinsPage: false,
|
||||
toggleSidebar: false,
|
||||
|
||||
devMode: false,
|
||||
|
||||
+3
-1
@@ -114,10 +114,12 @@ fn main() {
|
||||
"get_available_capes",
|
||||
"get_available_skins",
|
||||
"add_and_equip_custom_skin",
|
||||
"set_default_cape",
|
||||
"equip_skin",
|
||||
"remove_custom_skin",
|
||||
"save_custom_skin",
|
||||
"unequip_skin",
|
||||
"flush_pending_skin_change",
|
||||
"flush_pending_skin_change_for_profile",
|
||||
"normalize_skin_texture",
|
||||
"get_dragged_skin_data",
|
||||
])
|
||||
|
||||
@@ -11,10 +11,12 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
get_available_capes,
|
||||
get_available_skins,
|
||||
add_and_equip_custom_skin,
|
||||
set_default_cape,
|
||||
equip_skin,
|
||||
remove_custom_skin,
|
||||
save_custom_skin,
|
||||
unequip_skin,
|
||||
flush_pending_skin_change,
|
||||
flush_pending_skin_change_for_profile,
|
||||
normalize_skin_texture,
|
||||
get_dragged_skin_data,
|
||||
])
|
||||
@@ -37,29 +39,19 @@ pub async fn get_available_skins() -> Result<Vec<Skin>> {
|
||||
Ok(minecraft_skins::get_available_skins().await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape_override)`
|
||||
/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape)`
|
||||
///
|
||||
/// See also: [minecraft_skins::add_and_equip_custom_skin]
|
||||
#[tauri::command]
|
||||
pub async fn add_and_equip_custom_skin(
|
||||
texture_blob: Bytes,
|
||||
variant: MinecraftSkinVariant,
|
||||
cape_override: Option<Cape>,
|
||||
) -> Result<()> {
|
||||
Ok(minecraft_skins::add_and_equip_custom_skin(
|
||||
texture_blob,
|
||||
variant,
|
||||
cape_override,
|
||||
cape: Option<Cape>,
|
||||
) -> Result<Skin> {
|
||||
Ok(
|
||||
minecraft_skins::add_and_equip_custom_skin(texture_blob, variant, cape)
|
||||
.await?,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|set_default_cape', cape)`
|
||||
///
|
||||
/// See also: [minecraft_skins::set_default_cape]
|
||||
#[tauri::command]
|
||||
pub async fn set_default_cape(cape: Option<Cape>) -> Result<()> {
|
||||
Ok(minecraft_skins::set_default_cape(cape).await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|equip_skin', skin)`
|
||||
@@ -78,6 +70,27 @@ pub async fn remove_custom_skin(skin: Skin) -> Result<()> {
|
||||
Ok(minecraft_skins::remove_custom_skin(skin).await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|save_custom_skin', skin, texture_blob, variant, cape, replace_texture)`
|
||||
///
|
||||
/// See also: [minecraft_skins::save_custom_skin]
|
||||
#[tauri::command]
|
||||
pub async fn save_custom_skin(
|
||||
skin: Skin,
|
||||
texture_blob: Bytes,
|
||||
variant: MinecraftSkinVariant,
|
||||
cape: Option<Cape>,
|
||||
replace_texture: bool,
|
||||
) -> Result<Skin> {
|
||||
Ok(minecraft_skins::save_custom_skin(
|
||||
skin,
|
||||
texture_blob,
|
||||
variant,
|
||||
cape,
|
||||
replace_texture,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|unequip_skin')`
|
||||
///
|
||||
/// See also: [minecraft_skins::unequip_skin]
|
||||
@@ -86,6 +99,27 @@ pub async fn unequip_skin() -> Result<()> {
|
||||
Ok(minecraft_skins::unequip_skin().await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|flush_pending_skin_change')`
|
||||
///
|
||||
/// See also: [minecraft_skins::flush_pending_skin_change]
|
||||
#[tauri::command]
|
||||
pub async fn flush_pending_skin_change() -> Result<()> {
|
||||
Ok(minecraft_skins::flush_pending_skin_change().await?)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|flush_pending_skin_change_for_profile', profile_id)`
|
||||
///
|
||||
/// See also: [minecraft_skins::flush_pending_skin_change_for_profile]
|
||||
#[tauri::command]
|
||||
pub async fn flush_pending_skin_change_for_profile(
|
||||
profile_id: uuid::Uuid,
|
||||
) -> Result<()> {
|
||||
Ok(
|
||||
minecraft_skins::flush_pending_skin_change_for_profile(profile_id)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
/// `invoke('plugin:minecraft-skins|normalize_skin_texture')`
|
||||
///
|
||||
/// See also: [minecraft_skins::normalize_skin_texture]
|
||||
|
||||
+12
-2
@@ -270,10 +270,20 @@ fn main() {
|
||||
Ok(app) => {
|
||||
app.run(|app, event| {
|
||||
#[cfg(not(any(feature = "updater", target_os = "macos")))]
|
||||
drop((app, event));
|
||||
let _ = app;
|
||||
|
||||
if matches!(&event, tauri::RunEvent::ExitRequested { .. })
|
||||
&& let Err(error) = tauri::async_runtime::block_on(
|
||||
theseus::minecraft_skins::flush_pending_skin_change(),
|
||||
)
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to flush pending Minecraft skin change before exit: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
if matches!(event, tauri::RunEvent::Exit) {
|
||||
if matches!(&event, tauri::RunEvent::Exit) {
|
||||
let update_data = app.state::<PendingUpdateData>().inner();
|
||||
let should_restart = State::get_if_initialized()
|
||||
.map(|s| {
|
||||
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ? AND variant = ? AND cape_id IS ?",
|
||||
"query": "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "faa8437519571b147b0135054847674be5035f385e0d85e759d4bbf9bca54f20"
|
||||
"hash": "08908e54884b79705500501389344f3dc52fc81d34b0e9a44f5b9bede487cfa6"
|
||||
}
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22"
|
||||
}
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "INSERT OR REPLACE INTO default_minecraft_capes (minecraft_user_uuid, id) VALUES (?, ?)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944"
|
||||
}
|
||||
Generated
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id AS 'id: Hyphenated' FROM default_minecraft_capes WHERE minecraft_user_uuid = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id: Hyphenated",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246"
|
||||
}
|
||||
Generated
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "texture_key",
|
||||
"ordinal": 0,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "variant: MinecraftSkinVariant",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "cape_id: Hyphenated",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a0b0ff0ae4b88d5df9d15d3427ab4e9a6ff21cffdc9c2f3d6860e245949d313d"
|
||||
}
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
DROP TABLE IF EXISTS default_minecraft_capes;
|
||||
|
||||
-- Keep only one saved skin per Minecraft account and texture.
|
||||
-- variant and cape_id are settings on that saved skin, not part of the skin identity.
|
||||
DELETE FROM custom_minecraft_skins
|
||||
WHERE rowid NOT IN (
|
||||
SELECT MAX(rowid)
|
||||
FROM custom_minecraft_skins
|
||||
GROUP BY minecraft_user_uuid, texture_key
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX custom_minecraft_skins_one_per_texture
|
||||
ON custom_minecraft_skins (minecraft_user_uuid, texture_key);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,16 @@ use crate::{minecraft_skins::SkinSource, state::MinecraftSkinVariant};
|
||||
|
||||
use super::super::super::Skin;
|
||||
|
||||
const DEFAULT_SKINS_SECTION: &str = "Default skins";
|
||||
const MINECON_EARTH_2017_SKIN_PACK_SECTION: &str = "MINECON Earth 2017";
|
||||
const BUILDERS_AND_BIOMES_SKIN_PACK_SECTION: &str = "Builders & Biomes";
|
||||
const STRIDING_HERO_SKIN_PACK_SECTION: &str = "Striding Hero";
|
||||
const THE_GARDEN_AWAKENS_SKIN_PACK_SECTION: &str = "The Garden Awakens";
|
||||
const CHASE_THE_SKIES_SKIN_PACK_SECTION: &str = "Chase the Skies";
|
||||
const THE_COPPER_AGE_SKIN_PACK_SECTION: &str = "The Copper Age";
|
||||
const MOUNTS_OF_MAYHEM_SKIN_PACK_SECTION: &str = "Mounts of Mayhem";
|
||||
const TINY_TAKEOVER_SKIN_PACK_SECTION: &str = "Tiny Takeover";
|
||||
|
||||
/// A list of default Minecraft skins to make available to the user, created by Mojang.
|
||||
pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
//
|
||||
@@ -16,6 +26,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
vec![Skin {
|
||||
texture_key: Arc::from("46acd06e8483b176e8ea39fc12fe105eb3a2a4970f5100057e9d84d4b60bdfa7"),
|
||||
name: Some(Arc::from("Alex")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -27,6 +38,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("1abc803022d8300ab7578b189294cce39622d9a404cdc00d3feacfdf45be6981"),
|
||||
name: Some(Arc::from("Alex")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -38,6 +50,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("6ac6ca262d67bcfb3dbc924ba8215a18195497c780058a5749de674217721892"),
|
||||
name: Some(Arc::from("Ari")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -49,6 +62,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("4c05ab9e07b3505dc3ec11370c3bdce5570ad2fb2b562e9b9dd9cf271f81aa44"),
|
||||
name: Some(Arc::from("Ari")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -60,6 +74,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("fece7017b1bb13926d1158864b283b8b930271f80a90482f174cca6a17e88236"),
|
||||
name: Some(Arc::from("Efe")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -71,6 +86,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("daf3d88ccb38f11f74814e92053d92f7728ddb1a7955652a60e30cb27ae6659f"),
|
||||
name: Some(Arc::from("Efe")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -82,6 +98,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("226c617fde5b1ba569aa08bd2cb6fd84c93337532a872b3eb7bf66bdd5b395f8"),
|
||||
name: Some(Arc::from("Kai")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -93,6 +110,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("e5cdc3243b2153ab28a159861be643a4fc1e3c17d291cdd3e57a7f370ad676f3"),
|
||||
name: Some(Arc::from("Kai")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -104,6 +122,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("7cb3ba52ddd5cc82c0b050c3f920f87da36add80165846f479079663805433db"),
|
||||
name: Some(Arc::from("Makena")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -115,6 +134,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("dc0fcfaf2aa040a83dc0de4e56058d1bbb2ea40157501f3e7d15dc245e493095"),
|
||||
name: Some(Arc::from("Makena")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -126,6 +146,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("6c160fbd16adbc4bff2409e70180d911002aebcfa811eb6ec3d1040761aea6dd"),
|
||||
name: Some(Arc::from("Noor")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -137,6 +158,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("90e75cd429ba6331cd210b9bd19399527ee3bab467b5a9f61cb8a27b177f6789"),
|
||||
name: Some(Arc::from("Noor")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -148,6 +170,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("d5c4ee5ce20aed9e33e866c66caa37178606234b3721084bf01d13320fb2eb3f"),
|
||||
name: Some(Arc::from("Steve")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -159,6 +182,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("31f477eb1a7beee631c2ca64d06f8f68fa93a3386d04452ab27f43acdf1b60cb"),
|
||||
name: Some(Arc::from("Steve")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -170,6 +194,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("b66bc80f002b10371e2fa23de6f230dd5e2f3affc2e15786f65bc9be4c6eb71a"),
|
||||
name: Some(Arc::from("Sunny")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -181,6 +206,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("a3bd16079f764cd541e072e888fe43885e711f98658323db0f9a6045da91ee7a"),
|
||||
name: Some(Arc::from("Sunny")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -192,6 +218,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("eee522611005acf256dbd152e992c60c0bb7978cb0f3127807700e478ad97664"),
|
||||
name: Some(Arc::from("Zuri")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -203,6 +230,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("f5dddb41dcafef616e959c2817808e0be741c89ffbfed39134a13e75b811863d"),
|
||||
name: Some(Arc::from("Zuri")),
|
||||
section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -220,6 +248,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("6c25523e7dabfcaf0dbe32d90fd0c001d5d57ac66206a0595defe9be5947ff08"),
|
||||
name: Some(Arc::from("Globe Alex")),
|
||||
section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -231,6 +260,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("66206c8f51d13d2d31c54696a58a3e8bcd1e5e7db9888d331d0753129324e4f1"),
|
||||
name: Some(Arc::from("Party Alex")),
|
||||
section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Slim,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -242,6 +272,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("6acf91326bd116ce889e461ddb57e92ace07a8367dbd2d191075078fccc3c727"),
|
||||
name: Some(Arc::from("Cardboard Cosplayer")),
|
||||
section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -253,6 +284,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("b9f7facdca2bf4772fa168e1c3cf7b020124eb1fc82118307d426da1b88c32c5"),
|
||||
name: Some(Arc::from("Creeper Cosplayer")),
|
||||
section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -264,6 +296,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("b7393199a84eb9e932efa8dda6829423875eb65af76cb82912ade62f93996b9c"),
|
||||
name: Some(Arc::from("Creeper Piñata Cosplayer")),
|
||||
section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -275,6 +308,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("7cbe449d9d37c111a07a902e322d3869d98790c48f1fa16a24bcbe2d8d73808b"),
|
||||
name: Some(Arc::from("Sheep Cosplayer")),
|
||||
section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -286,6 +320,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("b182ad5783a343be3e202ac35902270a8d31042fdfd48b849fc99a55a1b60a91"),
|
||||
name: Some(Arc::from("Cake Steve")),
|
||||
section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -297,6 +332,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("c05e396bbf744082122f77b7277af390d11d2d4e93dd2f8c67942ca9626db24d"),
|
||||
name: Some(Arc::from("Party Steve")),
|
||||
section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -311,6 +347,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("2007b66a99ae905c81f339e2a0a4bf4b99e9454a485d5164e3e1051c3036ad70"),
|
||||
name: Some(Arc::from("Barn Builder")),
|
||||
section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -322,6 +359,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("59f2872323bf515aa8d84c00931fbf8170b2cec5138961527c09ffcd06ca4ab2"),
|
||||
name: Some(Arc::from("Bee-Friender")),
|
||||
section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -333,6 +371,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("7cd85127cbc710a1c9a53c6bb3474f59995c222b9d8c57b293993cc2d8a225aa"),
|
||||
name: Some(Arc::from("Bee-Friender (Alternate)")),
|
||||
section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -344,6 +383,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("5e4e09eccbce11e701c51bb64b102d688a6ac4018c725dd2b780210aee101b31"),
|
||||
name: Some(Arc::from("Buff Butcher")),
|
||||
section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -355,6 +395,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("d66ed86ce96a1b63c30f1baac762f638717930866474ac4fce697cdbd0bd6fbb"),
|
||||
name: Some(Arc::from("Buff Butcher (Alternate)")),
|
||||
section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -366,6 +407,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("b9e9d1b51b4be289b9525d4decd798cb7912e920bac8846a2df70e9ff4f0b1d8"),
|
||||
name: Some(Arc::from("Homestead Healer")),
|
||||
section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -377,6 +419,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("83e283ab33558baa2cd0184d2e85f090c795a797bdbcb2cc47230c27f23fe9b1"),
|
||||
name: Some(Arc::from("Pig Whisperer")),
|
||||
section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -388,6 +431,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("e1fc44f1d69fd2864df7b80618a38af4170d4800f2df4fbde81c17b74b2a818b"),
|
||||
name: Some(Arc::from("Pig Whisperer (Alternate)")),
|
||||
section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -399,6 +443,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("25dc6421d47cad8e2bdf93f56fae9ab06fcfe218c8645c1775ae2e4563c065ad"),
|
||||
name: Some(Arc::from("Ranch Ranger")),
|
||||
section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -413,6 +458,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("721c05483a435d4362047ccb62e075ef5f001aa63a7e0e2afe03e60759bab91d"),
|
||||
name: Some(Arc::from("Snowfeather")),
|
||||
section: Some(Arc::from(STRIDING_HERO_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -424,6 +470,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("b914cf5106aaa82409fdd9213fbdb1479b4d65aecc5d5e22b1f25e5744c4c4f7"),
|
||||
name: Some(Arc::from("Stray")),
|
||||
section: Some(Arc::from(STRIDING_HERO_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -435,6 +482,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("5eb077c54ecfc7e760c36add887b68859d7a3160d331580ff859f7353d959151"),
|
||||
name: Some(Arc::from("Strider")),
|
||||
section: Some(Arc::from(STRIDING_HERO_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -446,6 +494,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("b271a744ef479018927575952621b110b9c11f62730a95729af7e8591cf8dbf6"),
|
||||
name: Some(Arc::from("Villager 1")),
|
||||
section: Some(Arc::from(STRIDING_HERO_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -457,6 +506,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("748923629fed7c6ec9462016b4480fa3cff8c16e82ee6fe26d4b707f4de10060"),
|
||||
name: Some(Arc::from("Villager 2")),
|
||||
section: Some(Arc::from(STRIDING_HERO_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -468,6 +518,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("3d996abc69ea70a20442855e429bf44b45111f9818d0f8c46272e12d12bec218"),
|
||||
name: Some(Arc::from("Wither Skeleton")),
|
||||
section: Some(Arc::from(STRIDING_HERO_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -482,6 +533,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("6f8fc677cdcd4c6eed67d90c08d23162abc3a3a85357c7636fdf80d874aa857f"),
|
||||
name: Some(Arc::from("Pale Lumberjack")),
|
||||
section: Some(Arc::from(THE_GARDEN_AWAKENS_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -493,6 +545,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("9a0af2b1fd9659480d43132db95cd7d459d1a66480fe42150e132d03b9731573"),
|
||||
name: Some(Arc::from("Creaking")),
|
||||
section: Some(Arc::from(THE_GARDEN_AWAKENS_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -507,6 +560,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("8409954698b6c7741460fdd85d6ec6a5e0a9ad04ade7e2c72c913f02936a607d"),
|
||||
name: Some(Arc::from("Ghast Pilot")),
|
||||
section: Some(Arc::from(CHASE_THE_SKIES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -518,6 +572,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("e12d98dab548e92cad7ac80f92d8fefbb9ca7a1af94aa4f428daf6ef723aa8e0"),
|
||||
name: Some(Arc::from("Ghast Swimmer")),
|
||||
section: Some(Arc::from(CHASE_THE_SKIES_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -533,6 +588,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("33aef79a4ca986a2971057d35046e71dce326b645353e6f92d56e1c4bb3b0073"),
|
||||
name: Some(Arc::from("Copper Chemist")),
|
||||
section: Some(Arc::from(THE_COPPER_AGE_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -544,6 +600,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("514b10ff7bc50dd01b5438632815b9e27cfe54064d1a28ef6014f3309d278b38"),
|
||||
name: Some(Arc::from("Copper Welder")),
|
||||
section: Some(Arc::from(THE_COPPER_AGE_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -558,6 +615,7 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
Skin {
|
||||
texture_key: Arc::from("e0bae80c765f9ef3c3050dd72ea4d4bc53ae00e39c8a376a886d419abdc5dd84"),
|
||||
name: Some(Arc::from("Zombie Horse Onesie")),
|
||||
section: Some(Arc::from(MOUNTS_OF_MAYHEM_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
@@ -565,5 +623,43 @@ pub static DEFAULT_SKINS: LazyLock<Vec<Skin>> = LazyLock::new(|| {
|
||||
).unwrap()),
|
||||
source: SkinSource::Default,
|
||||
is_equipped: false,
|
||||
},
|
||||
// Tiny Takeover skin pack
|
||||
Skin {
|
||||
texture_key: Arc::from("890044fb07cbca79bb9ffec4d2f15cdd1053e4b554e9a02469e9d0b271f3fdfa"),
|
||||
name: Some(Arc::from("Baby Bee")),
|
||||
section: Some(Arc::from(TINY_TAKEOVER_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
"data:image/png;base64,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"
|
||||
).unwrap()),
|
||||
source: SkinSource::Default,
|
||||
is_equipped: false,
|
||||
},
|
||||
Skin {
|
||||
texture_key: Arc::from("8d0484011053097a9809f14c0301166981369b3a660150afea1e753ae7e54685"),
|
||||
name: Some(Arc::from("Baby Axolotl")),
|
||||
section: Some(Arc::from(TINY_TAKEOVER_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
"data:image/png;base64,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"
|
||||
).unwrap()),
|
||||
source: SkinSource::Default,
|
||||
is_equipped: false,
|
||||
},
|
||||
// Dandelion Onesie skin pack
|
||||
Skin {
|
||||
texture_key: Arc::from("b240795e214270b5b864cea3cbbcbac2fae60abed5de10229a7567510713355b"),
|
||||
name: Some(Arc::from("Dandelion Onesie")),
|
||||
section: Some(Arc::from(TINY_TAKEOVER_SKIN_PACK_SECTION)),
|
||||
variant: MinecraftSkinVariant::Classic,
|
||||
cape_id: None,
|
||||
texture: Arc::from(Url::try_from(
|
||||
"data:image/png;base64,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"
|
||||
).unwrap()),
|
||||
source: SkinSource::Default,
|
||||
is_equipped: false,
|
||||
}]
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ pub async fn url_to_data_stream(
|
||||
let response = INSECURE_REQWEST_CLIENT
|
||||
.get(url.as_str())
|
||||
.header("Accept", "image/png")
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.and_then(|response| response.error_for_status())?;
|
||||
|
||||
@@ -917,6 +917,8 @@ async fn run_credentials(
|
||||
}
|
||||
}
|
||||
|
||||
crate::minecraft_skins::flush_pending_skin_change().await?;
|
||||
|
||||
crate::launcher::launch_minecraft(
|
||||
&java_args,
|
||||
&env_args,
|
||||
|
||||
@@ -48,11 +48,6 @@ pub(crate) async fn connect(
|
||||
async fn stale_data_cleanup(pool: &Pool<Sqlite>) -> crate::Result<()> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)"
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)"
|
||||
)
|
||||
|
||||
@@ -20,7 +20,6 @@ use serde_json::json;
|
||||
use sha2::Digest;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::future::Future;
|
||||
use std::hash::{BuildHasherDefault, DefaultHasher};
|
||||
use std::io;
|
||||
@@ -217,6 +216,34 @@ pub(super) static PROFILE_CACHE: Mutex<
|
||||
HashMap<Uuid, ProfileCacheEntry, BuildHasherDefault<DefaultHasher>>,
|
||||
> = Mutex::const_new(HashMap::with_hasher(BuildHasherDefault::new()));
|
||||
|
||||
const ONLINE_PROFILE_CACHE_MAX_AGE: std::time::Duration =
|
||||
std::time::Duration::from_secs(60);
|
||||
const ONLINE_PROFILE_LIVE_STATE_MAX_AGE: std::time::Duration =
|
||||
std::time::Duration::from_secs(5);
|
||||
const ONLINE_PROFILE_AUTH_ERROR_BACKOFF: std::time::Duration =
|
||||
std::time::Duration::from_secs(60);
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum OnlineProfileCacheIntent {
|
||||
NormalRead,
|
||||
LiveStateRead,
|
||||
RefreshFromMojang,
|
||||
}
|
||||
|
||||
impl OnlineProfileCacheIntent {
|
||||
fn max_age(self) -> std::time::Duration {
|
||||
match self {
|
||||
Self::NormalRead => ONLINE_PROFILE_CACHE_MAX_AGE,
|
||||
Self::LiveStateRead => ONLINE_PROFILE_LIVE_STATE_MAX_AGE,
|
||||
Self::RefreshFromMojang => std::time::Duration::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
fn can_use_stale_on_fetch_error(self) -> bool {
|
||||
matches!(self, Self::LiveStateRead)
|
||||
}
|
||||
}
|
||||
|
||||
impl Credentials {
|
||||
/// Refreshes the authentication tokens for this user if they are expired, or
|
||||
/// very close to expiration.
|
||||
@@ -268,92 +295,133 @@ impl Credentials {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns online profile data when the cached copy is still recent enough.
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn online_profile(&self) -> Option<Arc<MinecraftProfile>> {
|
||||
let mut profile_cache = PROFILE_CACHE.lock().await;
|
||||
self.online_profile_with_cache_intent(
|
||||
OnlineProfileCacheIntent::NormalRead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
loop {
|
||||
match profile_cache.entry(self.offline_profile.id) {
|
||||
Entry::Occupied(entry) => {
|
||||
match entry.get() {
|
||||
ProfileCacheEntry::Hit(profile)
|
||||
if profile.is_fresh() =>
|
||||
{
|
||||
return Some(Arc::clone(profile));
|
||||
}
|
||||
ProfileCacheEntry::Hit(_) => {
|
||||
// The profile is stale, so remove it and try again
|
||||
entry.remove();
|
||||
continue;
|
||||
}
|
||||
// Auth errors must be handled with a backoff strategy because it
|
||||
// has been experimentally found that Mojang quickly rate limits
|
||||
// the profile data endpoint on repeated attempts with bad auth
|
||||
ProfileCacheEntry::AuthErrorBackoff {
|
||||
likely_expired_token,
|
||||
last_attempt,
|
||||
} if &self.access_token != likely_expired_token
|
||||
|| Instant::now()
|
||||
.saturating_duration_since(*last_attempt)
|
||||
> std::time::Duration::from_secs(60) =>
|
||||
{
|
||||
entry.remove();
|
||||
continue;
|
||||
}
|
||||
ProfileCacheEntry::AuthErrorBackoff { .. } => {
|
||||
return None;
|
||||
}
|
||||
/// Returns profile data recent enough for skin and cape state.
|
||||
///
|
||||
/// Reuses a profile read from the last few seconds so opening the skins page
|
||||
/// does not send several identical Mojang requests.
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn online_profile_fresh(&self) -> Option<Arc<MinecraftProfile>> {
|
||||
self.online_profile_with_cache_intent(
|
||||
OnlineProfileCacheIntent::LiveStateRead,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fetches the online profile from Mojang after a skin or cape change.
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn refresh_online_profile(
|
||||
&self,
|
||||
) -> Option<Arc<MinecraftProfile>> {
|
||||
self.online_profile_with_cache_intent(
|
||||
OnlineProfileCacheIntent::RefreshFromMojang,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn online_profile_with_cache_intent(
|
||||
&self,
|
||||
cache_intent: OnlineProfileCacheIntent,
|
||||
) -> Option<Arc<MinecraftProfile>> {
|
||||
let max_age = cache_intent.max_age();
|
||||
let stale_profile = {
|
||||
let mut profile_cache = PROFILE_CACHE.lock().await;
|
||||
let mut remove_cached_entry = false;
|
||||
|
||||
let stale_profile = if let Some(cache_entry) =
|
||||
profile_cache.get(&self.offline_profile.id)
|
||||
{
|
||||
match cache_entry {
|
||||
ProfileCacheEntry::Hit(profile)
|
||||
if profile.is_fresh(max_age) =>
|
||||
{
|
||||
return Some(Arc::clone(profile));
|
||||
}
|
||||
ProfileCacheEntry::Hit(profile) => {
|
||||
Some(Arc::clone(profile))
|
||||
}
|
||||
// Auth errors must be handled with a backoff strategy because it
|
||||
// has been experimentally found that Mojang quickly rate limits
|
||||
// the profile data endpoint on repeated attempts with bad auth
|
||||
ProfileCacheEntry::AuthErrorBackoff {
|
||||
likely_expired_token,
|
||||
last_attempt,
|
||||
} if &self.access_token != likely_expired_token
|
||||
|| Instant::now()
|
||||
.saturating_duration_since(*last_attempt)
|
||||
> ONLINE_PROFILE_AUTH_ERROR_BACKOFF =>
|
||||
{
|
||||
remove_cached_entry = true;
|
||||
None
|
||||
}
|
||||
ProfileCacheEntry::AuthErrorBackoff { .. } => {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
match minecraft_profile(&self.access_token).await {
|
||||
Ok(profile) => {
|
||||
let profile = Arc::new(profile);
|
||||
let cache_entry =
|
||||
ProfileCacheEntry::Hit(Arc::clone(&profile));
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// When fetching a profile for the first time, the player UUID may
|
||||
// be unknown (i.e., set to a dummy value), so make sure we don't
|
||||
// cache it in the wrong place
|
||||
if entry.key() != &profile.id {
|
||||
profile_cache.insert(profile.id, cache_entry);
|
||||
} else {
|
||||
entry.insert(cache_entry);
|
||||
}
|
||||
if remove_cached_entry {
|
||||
profile_cache.remove(&self.offline_profile.id);
|
||||
}
|
||||
|
||||
return Some(profile);
|
||||
}
|
||||
Err(
|
||||
err @ MinecraftAuthenticationError::DeserializeResponse {
|
||||
status_code: StatusCode::UNAUTHORIZED,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
tracing::warn!(
|
||||
"Failed to fetch online profile for UUID {} likely due to stale credentials, backing off: {err}",
|
||||
self.offline_profile.id
|
||||
);
|
||||
stale_profile
|
||||
};
|
||||
|
||||
// We have to assume the player UUID key we have is correct here, which
|
||||
// should always be the case assuming a non-adversarial server. In any
|
||||
// case, any cache poisoning is inconsequential due to the entry expiration
|
||||
// and the fact that we use at most one single dummy UUID
|
||||
entry.insert(ProfileCacheEntry::AuthErrorBackoff {
|
||||
likely_expired_token: self.access_token.clone(),
|
||||
last_attempt: Instant::now(),
|
||||
});
|
||||
match minecraft_profile(&self.access_token).await {
|
||||
Ok(profile) => {
|
||||
let profile = Arc::new(profile);
|
||||
let cache_entry = ProfileCacheEntry::Hit(Arc::clone(&profile));
|
||||
|
||||
return None;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"Failed to fetch online profile for UUID {}: {err}",
|
||||
self.offline_profile.id
|
||||
);
|
||||
let mut profile_cache = PROFILE_CACHE.lock().await;
|
||||
if self.offline_profile.id != profile.id {
|
||||
profile_cache.remove(&self.offline_profile.id);
|
||||
}
|
||||
profile_cache.insert(profile.id, cache_entry);
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(profile)
|
||||
}
|
||||
Err(
|
||||
err @ MinecraftAuthenticationError::DeserializeResponse {
|
||||
status_code: StatusCode::UNAUTHORIZED,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
tracing::warn!(
|
||||
"Failed to fetch online profile for UUID {} likely due to stale credentials, backing off: {err}",
|
||||
self.offline_profile.id
|
||||
);
|
||||
|
||||
let mut profile_cache = PROFILE_CACHE.lock().await;
|
||||
profile_cache.insert(
|
||||
self.offline_profile.id,
|
||||
ProfileCacheEntry::AuthErrorBackoff {
|
||||
likely_expired_token: self.access_token.clone(),
|
||||
last_attempt: Instant::now(),
|
||||
},
|
||||
);
|
||||
|
||||
None
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"Failed to fetch online profile for UUID {}: {err}",
|
||||
self.offline_profile.id
|
||||
);
|
||||
|
||||
if cache_intent.can_use_stale_on_fetch_error() {
|
||||
stale_profile
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -717,6 +785,8 @@ impl DeviceTokenPair {
|
||||
const MICROSOFT_CLIENT_ID: &str = "00000000402b5328";
|
||||
const AUTH_REPLY_URL: &str = "https://login.live.com/oauth20_desktop.srf";
|
||||
const REQUESTED_SCOPE: &str = "service::user.auth.xboxlive.com::MBI_SSL";
|
||||
pub const MINECRAFT_SERVICES_USER_AGENT: &str =
|
||||
"Modrinth App (support@modrinth.com; https://modrinth.com/app)";
|
||||
|
||||
pub struct RequestWithDate<T> {
|
||||
pub date: DateTime<Utc>,
|
||||
@@ -1051,6 +1121,7 @@ async fn minecraft_token(
|
||||
INSECURE_REQWEST_CLIENT
|
||||
.post("https://api.minecraftservices.com/launcher/login")
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
|
||||
.json(&json!({
|
||||
"platform": "PC_LAUNCHER",
|
||||
"xtoken": format!("XBL3.0 x={uhs};{token}"),
|
||||
@@ -1224,10 +1295,10 @@ impl MinecraftProfile {
|
||||
/// from the Mojang API: the vanilla launcher was seen refreshing profile
|
||||
/// data every 60 seconds when re-entering the skin selection screen, and
|
||||
/// external applications may change this data at any time.
|
||||
fn is_fresh(&self) -> bool {
|
||||
fn is_fresh(&self, max_age: std::time::Duration) -> bool {
|
||||
self.fetch_time.is_some_and(|last_profile_fetch_time| {
|
||||
Instant::now().saturating_duration_since(last_profile_fetch_time)
|
||||
< std::time::Duration::from_secs(60)
|
||||
< max_age
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1279,6 +1350,7 @@ async fn minecraft_profile(
|
||||
INSECURE_REQWEST_CLIENT
|
||||
.get("https://api.minecraftservices.com/minecraft/profile")
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
|
||||
.bearer_auth(token)
|
||||
// Profiles may be refreshed periodically in response to user actions,
|
||||
// so we want each refresh to be fast
|
||||
@@ -1327,12 +1399,13 @@ async fn minecraft_entitlements(
|
||||
token: &str,
|
||||
) -> Result<MinecraftEntitlements, MinecraftAuthenticationError> {
|
||||
let res = auth_retry(|| {
|
||||
INSECURE_REQWEST_CLIENT
|
||||
.get(format!("https://api.minecraftservices.com/entitlements/license?requestId={}", Uuid::new_v4()))
|
||||
.header("Accept", "application/json")
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
})
|
||||
INSECURE_REQWEST_CLIENT
|
||||
.get(format!("https://api.minecraftservices.com/entitlements/license?requestId={}", Uuid::new_v4()))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
})
|
||||
.await.map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftEntitlements })?;
|
||||
|
||||
let status = res.status();
|
||||
|
||||
@@ -5,81 +5,31 @@ use super::MinecraftSkinVariant;
|
||||
|
||||
pub mod mojang_api;
|
||||
|
||||
/// Represents the default cape for a Minecraft player.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DefaultMinecraftCape {
|
||||
/// The UUID of a cape for a Minecraft player, which comes from its profile.
|
||||
///
|
||||
/// This UUID may or may not be different for every player, even if they refer to the same cape.
|
||||
pub id: Uuid,
|
||||
}
|
||||
|
||||
impl DefaultMinecraftCape {
|
||||
pub async fn set(
|
||||
minecraft_user_id: Uuid,
|
||||
cape_id: Uuid,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
let cape_id = cape_id.as_hyphenated();
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO default_minecraft_capes (minecraft_user_uuid, id) VALUES (?, ?)",
|
||||
minecraft_user_id, cape_id
|
||||
)
|
||||
.execute(&mut *db.acquire().await?)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
minecraft_user_id: Uuid,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<Option<Self>> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
|
||||
Ok(sqlx::query_as!(
|
||||
Self,
|
||||
"SELECT id AS 'id: Hyphenated' FROM default_minecraft_capes WHERE minecraft_user_uuid = ?",
|
||||
minecraft_user_id
|
||||
)
|
||||
.fetch_optional(&mut *db.acquire().await?)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
minecraft_user_id: Uuid,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid = ?",
|
||||
minecraft_user_id
|
||||
)
|
||||
.execute(&mut *db.acquire().await?)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a custom skin for a Minecraft player.
|
||||
/// Represents a saved skin row for a Minecraft player.
|
||||
///
|
||||
/// The same player and `texture_key` always point to the same saved skin.
|
||||
/// Changing the model variant or cape updates that saved skin instead of
|
||||
/// creating a second copy. Bundled default skins with a cape are also stored
|
||||
/// here so the cape can stay associated with the default skin card.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CustomMinecraftSkin {
|
||||
/// The key for the texture skin, which is akin to a hash that identifies it.
|
||||
/// The key for the skin texture, which is akin to a hash that identifies it.
|
||||
pub texture_key: String,
|
||||
/// The variant of the skin model.
|
||||
pub variant: MinecraftSkinVariant,
|
||||
/// The UUID of the cape that this skin uses, which should match one of the
|
||||
/// cape UUIDs the player has in its profile.
|
||||
///
|
||||
/// If `None`, the skin does not have an explicit cape set, and the default
|
||||
/// cape for this player, if any, should be used.
|
||||
/// If `None`, the skin is saved without a cape.
|
||||
pub cape_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
struct CustomMinecraftSkinRow {
|
||||
texture_key: String,
|
||||
variant: MinecraftSkinVariant,
|
||||
cape_id: Option<Hyphenated>,
|
||||
}
|
||||
|
||||
impl CustomMinecraftSkin {
|
||||
pub async fn add(
|
||||
minecraft_user_id: Uuid,
|
||||
@@ -95,24 +45,59 @@ impl CustomMinecraftSkin {
|
||||
let mut transaction = db.begin().await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)",
|
||||
texture_key, texture
|
||||
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
minecraft_user_id,
|
||||
texture_key
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)",
|
||||
minecraft_user_id, texture_key, variant, cape_id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
"INSERT OR REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)",
|
||||
texture_key, texture
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)",
|
||||
minecraft_user_id, texture_key, variant, cape_id
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_by_texture(
|
||||
minecraft_user_id: Uuid,
|
||||
texture_key: &str,
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<Option<Self>> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
|
||||
sqlx::query_as!(
|
||||
CustomMinecraftSkinRow,
|
||||
"SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' \
|
||||
FROM custom_minecraft_skins \
|
||||
WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
minecraft_user_id,
|
||||
texture_key
|
||||
)
|
||||
.fetch_optional(&mut *db.acquire().await?)
|
||||
.await?
|
||||
.map(|row| {
|
||||
Ok(Self {
|
||||
texture_key: row.texture_key,
|
||||
variant: row.variant,
|
||||
cape_id: row.cape_id.map(Uuid::from),
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub async fn get_many(
|
||||
minecraft_user_id: Uuid,
|
||||
offset: u32,
|
||||
@@ -165,12 +150,11 @@ impl CustomMinecraftSkin {
|
||||
db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let minecraft_user_id = minecraft_user_id.as_hyphenated();
|
||||
let cape_id = self.cape_id.map(|id| id.hyphenated());
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM custom_minecraft_skins \
|
||||
WHERE minecraft_user_uuid = ? AND texture_key = ? AND variant = ? AND cape_id IS ?",
|
||||
minecraft_user_id, self.texture_key, self.variant, cape_id
|
||||
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
|
||||
minecraft_user_id,
|
||||
self.texture_key
|
||||
)
|
||||
.execute(&mut *db.acquire().await?)
|
||||
.await?;
|
||||
|
||||
@@ -10,7 +10,10 @@ use super::MinecraftSkinVariant;
|
||||
use crate::{
|
||||
ErrorKind,
|
||||
data::Credentials,
|
||||
state::{MinecraftProfile, PROFILE_CACHE, ProfileCacheEntry},
|
||||
state::{
|
||||
MINECRAFT_SERVICES_USER_AGENT, MinecraftProfile, PROFILE_CACHE,
|
||||
ProfileCacheEntry,
|
||||
},
|
||||
util::fetch::INSECURE_REQWEST_CLIENT,
|
||||
};
|
||||
|
||||
@@ -24,12 +27,13 @@ impl MinecraftCapeOperation {
|
||||
) -> crate::Result<()> {
|
||||
update_profile_cache_from_response(
|
||||
INSECURE_REQWEST_CLIENT
|
||||
.put("https://api.minecraftservices.com/minecraft/profile/capes/active")
|
||||
.header("Content-Type", "application/json; charset=utf-8")
|
||||
.header("Accept", "application/json")
|
||||
.bearer_auth(&credentials.access_token)
|
||||
.json(&json!({
|
||||
"capeId": cape_id.hyphenated(),
|
||||
.put("https://api.minecraftservices.com/minecraft/profile/capes/active")
|
||||
.header("Content-Type", "application/json; charset=utf-8")
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
|
||||
.bearer_auth(&credentials.access_token)
|
||||
.json(&json!({
|
||||
"capeId": cape_id.hyphenated(),
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@@ -42,12 +46,13 @@ impl MinecraftCapeOperation {
|
||||
|
||||
pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> {
|
||||
update_profile_cache_from_response(
|
||||
INSECURE_REQWEST_CLIENT
|
||||
.delete("https://api.minecraftservices.com/minecraft/profile/capes/active")
|
||||
.header("Accept", "application/json")
|
||||
.bearer_auth(&credentials.access_token)
|
||||
.send()
|
||||
.await
|
||||
INSECURE_REQWEST_CLIENT
|
||||
.delete("https://api.minecraftservices.com/minecraft/profile/capes/active")
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
|
||||
.bearer_auth(&credentials.access_token)
|
||||
.send()
|
||||
.await
|
||||
.and_then(|response| response.error_for_status())?
|
||||
)
|
||||
.await;
|
||||
@@ -64,7 +69,7 @@ impl MinecraftSkinOperation {
|
||||
credentials: &Credentials,
|
||||
texture: TextureStream,
|
||||
variant: MinecraftSkinVariant,
|
||||
) -> crate::Result<()>
|
||||
) -> crate::Result<Option<Arc<MinecraftProfile>>>
|
||||
where
|
||||
TextureStream: TryStream + Send + 'static,
|
||||
TextureStream::Error: Into<Box<dyn Error + Send + Sync>>,
|
||||
@@ -91,12 +96,13 @@ impl MinecraftSkinOperation {
|
||||
.file_name("skin.png"),
|
||||
);
|
||||
|
||||
update_profile_cache_from_response(
|
||||
let profile = update_profile_cache_from_response(
|
||||
INSECURE_REQWEST_CLIENT
|
||||
.post(
|
||||
"https://api.minecraftservices.com/minecraft/profile/skins",
|
||||
)
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
|
||||
.bearer_auth(&credentials.access_token)
|
||||
.multipart(form)
|
||||
.send()
|
||||
@@ -105,17 +111,18 @@ impl MinecraftSkinOperation {
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> {
|
||||
update_profile_cache_from_response(
|
||||
INSECURE_REQWEST_CLIENT
|
||||
.delete("https://api.minecraftservices.com/minecraft/profile/skins/active")
|
||||
.header("Accept", "application/json")
|
||||
.bearer_auth(&credentials.access_token)
|
||||
.send()
|
||||
.await
|
||||
INSECURE_REQWEST_CLIENT
|
||||
.delete("https://api.minecraftservices.com/minecraft/profile/skins/active")
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
|
||||
.bearer_auth(&credentials.access_token)
|
||||
.send()
|
||||
.await
|
||||
.and_then(|response| response.error_for_status())?
|
||||
)
|
||||
.await;
|
||||
@@ -124,19 +131,24 @@ impl MinecraftSkinOperation {
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_profile_cache_from_response(response: reqwest::Response) {
|
||||
async fn update_profile_cache_from_response(
|
||||
response: reqwest::Response,
|
||||
) -> Option<Arc<MinecraftProfile>> {
|
||||
let Some(mut profile) = response.json::<MinecraftProfile>().await.ok()
|
||||
else {
|
||||
tracing::warn!(
|
||||
"Failed to parse player profile from skin or cape operation response, not updating profile cache"
|
||||
);
|
||||
return;
|
||||
return None;
|
||||
};
|
||||
|
||||
profile.fetch_time = Some(Instant::now());
|
||||
let profile = Arc::new(profile);
|
||||
|
||||
PROFILE_CACHE
|
||||
.lock()
|
||||
.await
|
||||
.insert(profile.id, ProfileCacheEntry::Hit(Arc::new(profile)));
|
||||
.insert(profile.id, ProfileCacheEntry::Hit(Arc::clone(&profile)));
|
||||
|
||||
Some(profile)
|
||||
}
|
||||
|
||||
@@ -373,6 +373,7 @@ import _TrashExclamationIcon from './icons/trash-exclamation.svg?component'
|
||||
import _TriangleAlertIcon from './icons/triangle-alert.svg?component'
|
||||
import _UnderlineIcon from './icons/underline.svg?component'
|
||||
import _UndoIcon from './icons/undo.svg?component'
|
||||
import _UnfoldHorizontalIcon from './icons/unfold-horizontal.svg?component'
|
||||
import _UnknownIcon from './icons/unknown.svg?component'
|
||||
import _UnknownDonationIcon from './icons/unknown-donation.svg?component'
|
||||
import _UnlinkIcon from './icons/unlink.svg?component'
|
||||
@@ -765,6 +766,7 @@ export const TrashExclamationIcon = _TrashExclamationIcon
|
||||
export const TriangleAlertIcon = _TriangleAlertIcon
|
||||
export const UnderlineIcon = _UnderlineIcon
|
||||
export const UndoIcon = _UndoIcon
|
||||
export const UnfoldHorizontalIcon = _UnfoldHorizontalIcon
|
||||
export const UnknownIcon = _UnknownIcon
|
||||
export const UnknownDonationIcon = _UnknownDonationIcon
|
||||
export const UnlinkIcon = _UnlinkIcon
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-unfold-horizontal"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M16 12h6" />
|
||||
<path d="M8 12H2" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 8v2" />
|
||||
<path d="M12 14v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m19 15 3-3-3-3" />
|
||||
<path d="m5 9-3 3 3 3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -70,7 +70,7 @@ CSS custom properties are defined in `packages/assets/styles/variables.scss` wit
|
||||
|
||||
## Storybook
|
||||
|
||||
When modifying a component in `src/components/`, you must also update its corresponding Storybook story in `src/stories/` to reflect the changes. If a story file doesn't exist yet, create one. Stories should cover the component's key states and variants.
|
||||
When modifying a component in `src/components/`, you must also update its corresponding Storybook story in `src/stories/` to reflect the changes. If a story file doesn't exist yet, create one. Stories should cover the component's key states and variants - do not make or modify a storybook unless the user asks for it or skip if it's incredibly obvious one should not be needed (e.g minor changes or styling changes DO NOT need a storybook edit)
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { EditIcon, TrashIcon } from '@modrinth/assets'
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import ButtonStyled from '../../components/base/ButtonStyled.vue'
|
||||
import SkinButton from '../../components/skin/SkinButton.vue'
|
||||
|
||||
const frontImage = `data:image/svg+xml,${encodeURIComponent(`
|
||||
<svg width="114" height="176" viewBox="0 0 114 176" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="43" y="4" width="28" height="30" fill="#5A3828"/>
|
||||
<rect x="36" y="18" width="42" height="38" fill="#B97A57"/>
|
||||
<rect x="43" y="28" width="8" height="6" fill="#1B1B20"/>
|
||||
<rect x="63" y="28" width="8" height="6" fill="#1B1B20"/>
|
||||
<rect x="50" y="40" width="14" height="5" fill="#684432"/>
|
||||
<rect x="28" y="57" width="58" height="60" fill="#2693C7"/>
|
||||
<rect x="18" y="63" width="18" height="54" fill="#35A8D8"/>
|
||||
<rect x="78" y="63" width="18" height="54" fill="#1E7FAC"/>
|
||||
<rect x="22" y="117" width="14" height="43" fill="#B97A57"/>
|
||||
<rect x="78" y="117" width="14" height="43" fill="#A96A4D"/>
|
||||
<rect x="36" y="117" width="18" height="55" fill="#263F5E"/>
|
||||
<rect x="60" y="117" width="18" height="55" fill="#1D334F"/>
|
||||
</svg>
|
||||
)}`)}`
|
||||
|
||||
const backImage = `data:image/svg+xml,${encodeURIComponent(`
|
||||
<svg width="114" height="176" viewBox="0 0 114 176" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="36" y="10" width="42" height="46" fill="#5A3828"/>
|
||||
<rect x="28" y="57" width="58" height="60" fill="#1E7FAC"/>
|
||||
<rect x="18" y="63" width="18" height="54" fill="#2693C7"/>
|
||||
<rect x="78" y="63" width="18" height="54" fill="#19759E"/>
|
||||
<rect x="22" y="117" width="14" height="43" fill="#A96A4D"/>
|
||||
<rect x="78" y="117" width="14" height="43" fill="#A96A4D"/>
|
||||
<rect x="36" y="117" width="18" height="55" fill="#1D334F"/>
|
||||
<rect x="60" y="117" width="18" height="55" fill="#162B45"/>
|
||||
</svg>
|
||||
)}`)}`
|
||||
|
||||
const meta = {
|
||||
title: 'Skin/SkinButton',
|
||||
component: SkinButton,
|
||||
args: {
|
||||
forwardImageSrc: frontImage,
|
||||
backwardImageSrc: backImage,
|
||||
selected: false,
|
||||
active: false,
|
||||
tooltip: 'Steve',
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { SkinButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="w-[156px]">
|
||||
<SkinButton v-bind="args" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
} satisfies Meta<typeof SkinButton>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Selected: Story = {
|
||||
args: {
|
||||
selected: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const ActiveUnselected: Story = {
|
||||
args: {
|
||||
active: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithActions: Story = {
|
||||
render: (args) => ({
|
||||
components: { ButtonStyled, EditIcon, SkinButton, TrashIcon },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="w-[156px]">
|
||||
<SkinButton v-bind="args">
|
||||
<template #overlay-buttons>
|
||||
<ButtonStyled color="brand">
|
||||
<button class="pointer-events-auto">
|
||||
<EditIcon /> Edit
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular color="red">
|
||||
<button class="pointer-events-auto" aria-label="Delete skin">
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</SkinButton>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { PlusIcon } from '@modrinth/assets'
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import SkinLikeTextButton from '../../components/skin/SkinLikeTextButton.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Skin/SkinLikeTextButton',
|
||||
component: SkinLikeTextButton,
|
||||
args: {
|
||||
selected: false,
|
||||
tooltip: 'Add a skin',
|
||||
dragActive: false,
|
||||
dropzone: false,
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { PlusIcon, SkinLikeTextButton },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="w-[156px]">
|
||||
<SkinLikeTextButton v-bind="args">
|
||||
<template #icon>
|
||||
<PlusIcon class="size-8" />
|
||||
</template>
|
||||
Add skin
|
||||
<template #subtitle>Drag and drop</template>
|
||||
</SkinLikeTextButton>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
} satisfies Meta<typeof SkinLikeTextButton>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const Selected: Story = {
|
||||
args: {
|
||||
selected: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const DragActive: Story = {
|
||||
args: {
|
||||
dragActive: true,
|
||||
dropzone: true,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { CheckIcon, EyeIcon, RotateCounterClockwiseIcon } from '@modrinth/assets'
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import SkinPreviewRenderer from '../../components/skin/SkinPreviewRenderer.vue'
|
||||
|
||||
const steveSkin =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAE5klEQVR4Xu1aMWsUQRgVVJCAoIIgglaJBG2UGIKCOU0hJHZKijRBsAnaWSjaiNjESgtTpT0bmxQWNvkJ+U9n3ube8ubNt7e5xOzdxX3wmNmZb/fmvflmdtm9M2dqcOfmxR44N325KFnn8ecXDwbSrzdxoODO7auleNTdgHfP5hKeKgNUsJff1zqZeBJ9fr2Jg4vW9Ef5Xxiga9+XAkRWLYFTYYDOuGYDxIMU6psf2r6tL54OA3wPUEIkBFeVfr2Jg4r2DRGk2OWZSwnZ7tcbO2hqU9j0tQtJyjMLlGqCnqPX0WtEcSh9PI0jGiBIIVpX8V7yvEg04/z6aPPxNA4V4QNm2/ar5d7vD+u9P1/eFOWvt2tF2/2ZK1ksyGO2aZ0msM3H0zg4IB2oziSEQjCEUzyJvkhUdE3WGc9jH0/j0IGxxC0M4iACQoHnj76WolEHcIwYtOGc6Fo0NDJoLAzwGaIgJWf89eO7BTUDlBTlM6/7gxqCYx9P4+BAUHLmeQ//8XKxfKDBre390nzBpRtTSQxvecwEvaYb4Mc+nsbBpzYV7yaghGiYwJKiaZCey+vpk+Gt61MJ2e7jGTke3vvaUy4sLBScnZ0t6PEZ9vbK5aHGog19fn2nXy7Dzk6vZLebLNHiN44LHxCF0wiPz7AvUsWrCSdhgO5H+B0PHxo+oKNkwEkbMLW7mxkwVhlwoktgX3S0BI6cAU/mt3sgfhzl04c/S7KNJan957a2EurMYFCl+P7Aq+LBpF2FiuCSbPv0KaWfUwcKPKwB2g9isOc3NwuWgvpCmZbeHsUnIr2N7S4ehOiNjQPSAI2rwyCBnh3eD0LI2Y8fC6Je/nC/9DaPdxPcrEwQ2T3ImNAAPacOgwRWGaB1iiHrBHl8IoyiVISnuJPi1QRlHVwgj6M27SNdUDLjZoDOfmRAtva7B5scyQ1Vy4h6juvN4Ab4DEeG6LGndGlAX4gb4PGeIaX4/rHe4nTH1zYVrfHF5luHyABfBoP6KYr0FNZZ9dgo3s+NRKkRkQFad70ZKKxKYJ0BGKSKKTJAZ5TZ0Bfk8aEBEh8ZoEaoASg9xvVm8PRW4UqPIylIxVG4i2OMxyUGiHjE6Ky6OBqgRniM682wsrLSA31zczLOqaIiM1Sgx5YGiGgVD6pYFxeZ4P2uNwMebzudTiJqdXU1E4oYj0Vdl4gapplDIyjMY3T5aAwyscoAzQw3QGNdbwY+41OcC6TwKA51NYDUZcSSM+rxvtcwhu0qxkWCfmv0GNfbokWLFi1atJgc4AVoFf3xmU+dSUyLFi1atGjRokVT8FdpQ39c1XeE+/S3QB4+dnADhv68bgbwTc+hX3GNGm7AUTOAj7qaARNpwLEyYL+uGTCWS0Df/vpLTu/TfsZkHzODbwS6JDKOGocRyWzwPjD5wqsGDDJB20YNNwClG6D9dQaUH0uqBLsho4YLdJHer22FAXXf9+uoWdI3xt8bVPGfGOjieRy1aR/p3/b9Q4d/8FCiz8fTOKoE+2xrv8arGL3f687vt8Kxui1GAuuWgNINGCRc+1j38TQOF4gyMiAyB4xS3YW7AUofT+Pw9PeZ1vZomURr3EUOygofT+Pgl2QKrKJ/bifrxFcJHxsD+LlcRQ3z/4LIABfsx9rm42kc0f8GVCCFR3GoQ0jVPuBZEfX5eIbFX3srPNN8aUvJAAAAAElFTkSuQmCC'
|
||||
|
||||
function svgDataUrl(svg: string) {
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
const greenCape = svgDataUrl(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="32"><rect width="64" height="32" fill="#1f8f2f"/><rect x="1" y="1" width="10" height="16" fill="#59d957"/><rect x="3" y="4" width="6" height="10" fill="#11551d"/></svg>',
|
||||
)
|
||||
const pinkCape = svgDataUrl(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="32"><rect width="64" height="32" fill="#d94fa3"/><rect x="1" y="1" width="10" height="16" fill="#ff9bd4"/><rect x="4" y="3" width="3" height="12" fill="#fff0fa"/></svg>',
|
||||
)
|
||||
|
||||
const meta = {
|
||||
title: 'Skin/SkinPreviewRenderer',
|
||||
component: SkinPreviewRenderer,
|
||||
args: {
|
||||
textureSrc: steveSkin,
|
||||
variant: 'CLASSIC',
|
||||
initialRotation: Math.PI / 8,
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { CheckIcon, EyeIcon, RotateCounterClockwiseIcon, SkinPreviewRenderer },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="h-[80vh] min-h-[32rem] max-h-[48rem] w-[22rem]">
|
||||
<SkinPreviewRenderer v-bind="args" nametag="Steve">
|
||||
<template #nametag-badge>
|
||||
<div class="flex items-center justify-center gap-1.5 rounded-full border border-solid border-brand-blue bg-bg-blue px-3 py-1 text-base font-semibold leading-6 text-brand-blue">
|
||||
<EyeIcon class="size-5 shrink-0" /> Previewing
|
||||
</div>
|
||||
</template>
|
||||
<template #subtitle>
|
||||
<div class="flex max-w-40 flex-wrap items-center justify-center gap-2 px-2">
|
||||
<button class="flex h-10 items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 text-contrast shadow-md">
|
||||
<RotateCounterClockwiseIcon class="size-5" /> Reset
|
||||
</button>
|
||||
<button class="flex h-10 items-center justify-center gap-2 rounded-[14px] border-0 bg-brand px-4 py-2.5 text-base font-semibold leading-5 text-[rgba(0,0,0,0.9)] shadow-md">
|
||||
<CheckIcon class="size-5" /> Apply
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</SkinPreviewRenderer>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
} satisfies Meta<typeof SkinPreviewRenderer>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const RepeatInteract: Story = {
|
||||
render: (args) => ({
|
||||
components: { SkinPreviewRenderer },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="h-[80vh] min-h-[32rem] max-h-[48rem] w-[22rem]">
|
||||
<SkinPreviewRenderer v-bind="args" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const DamageFlash: Story = {
|
||||
render: (args) => ({
|
||||
components: { SkinPreviewRenderer },
|
||||
setup() {
|
||||
type SkinPreviewRendererExpose = {
|
||||
playClickInteraction: () => void
|
||||
}
|
||||
|
||||
const preview = ref<SkinPreviewRendererExpose | null>(null)
|
||||
|
||||
function triggerDamageFlash() {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
preview.value?.playClickInteraction()
|
||||
}
|
||||
}
|
||||
|
||||
return { args, preview, triggerDamageFlash }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex items-start gap-6">
|
||||
<div class="h-[80vh] min-h-[32rem] max-h-[48rem] w-[22rem]">
|
||||
<SkinPreviewRenderer ref="preview" v-bind="args" />
|
||||
</div>
|
||||
<button class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-primary" @click="triggerDamageFlash">
|
||||
Trigger damage flash
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const AnimationControls: Story = {
|
||||
render: (args) => ({
|
||||
components: { SkinPreviewRenderer },
|
||||
setup() {
|
||||
type SkinPreviewRendererExpose = {
|
||||
getAvailableAnimations: () => string[]
|
||||
getCurrentAnimation: () => string
|
||||
playAnimation: (name: string) => boolean
|
||||
stopAnimations: () => void
|
||||
}
|
||||
|
||||
const preview = ref<SkinPreviewRendererExpose | null>(null)
|
||||
const availableAnimations = ref<string[]>([])
|
||||
const currentAnimation = ref('')
|
||||
|
||||
function refreshAnimations() {
|
||||
availableAnimations.value = preview.value?.getAvailableAnimations() ?? []
|
||||
currentAnimation.value = preview.value?.getCurrentAnimation() ?? ''
|
||||
}
|
||||
|
||||
function playAnimation(name: string) {
|
||||
preview.value?.playAnimation(name)
|
||||
currentAnimation.value = name
|
||||
}
|
||||
|
||||
return {
|
||||
args,
|
||||
availableAnimations,
|
||||
currentAnimation,
|
||||
playAnimation,
|
||||
preview,
|
||||
refreshAnimations,
|
||||
}
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex items-start gap-6">
|
||||
<div class="h-[80vh] min-h-[32rem] max-h-[48rem] w-[22rem]">
|
||||
<SkinPreviewRenderer ref="preview" v-bind="args" />
|
||||
</div>
|
||||
<div class="flex max-w-[18rem] flex-col gap-2">
|
||||
<button class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-primary" @click="refreshAnimations">
|
||||
Refresh animations
|
||||
</button>
|
||||
<div class="text-sm text-secondary">Current: {{ currentAnimation || 'none' }}</div>
|
||||
<button
|
||||
v-for="name in availableAnimations"
|
||||
:key="name"
|
||||
class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-left text-primary"
|
||||
@click="playAnimation(name)"
|
||||
>
|
||||
{{ name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const ResponsiveFit: Story = {
|
||||
args: {
|
||||
lockFit: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const WrappedSubtitle: Story = {
|
||||
render: (args) => ({
|
||||
components: { CheckIcon, RotateCounterClockwiseIcon, SkinPreviewRenderer },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="h-[42rem] w-[15rem]">
|
||||
<SkinPreviewRenderer v-bind="args" nametag="Steve">
|
||||
<template #subtitle>
|
||||
<div class="flex max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-2 px-2">
|
||||
<button class="flex h-10 items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 text-contrast shadow-md">
|
||||
<RotateCounterClockwiseIcon class="size-5" /> Reset
|
||||
</button>
|
||||
<button class="flex h-10 items-center justify-center gap-2 rounded-[14px] border-0 bg-brand px-4 py-2.5 text-base font-semibold leading-5 text-[rgba(0,0,0,0.9)] shadow-md">
|
||||
<CheckIcon class="size-5" /> Apply
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</SkinPreviewRenderer>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const LayerSeparation: Story = {
|
||||
args: {
|
||||
fitZoom: 1.5,
|
||||
framing: 'modal',
|
||||
initialRotation: Math.PI / 10,
|
||||
},
|
||||
render: (args) => ({
|
||||
components: { SkinPreviewRenderer },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="h-[28rem] w-[18rem]">
|
||||
<SkinPreviewRenderer v-bind="args" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const FramingModes: Story = {
|
||||
render: () => ({
|
||||
components: { CheckIcon, SkinPreviewRenderer },
|
||||
setup() {
|
||||
return { steveSkin }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex flex-wrap items-start gap-8">
|
||||
<div class="h-[32rem] w-[18rem]">
|
||||
<SkinPreviewRenderer :texture-src="steveSkin" variant="CLASSIC" framing="page" nametag="Steve">
|
||||
<template #subtitle>
|
||||
<button class="flex h-10 items-center justify-center gap-2 rounded-[14px] border-0 bg-brand px-4 py-2.5 text-base font-semibold leading-5 text-[rgba(0,0,0,0.9)] shadow-md">
|
||||
<CheckIcon class="size-5" /> Apply
|
||||
</button>
|
||||
</template>
|
||||
</SkinPreviewRenderer>
|
||||
</div>
|
||||
<div class="h-[25rem] w-[16rem]">
|
||||
<SkinPreviewRenderer :texture-src="steveSkin" variant="CLASSIC" framing="modal" />
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const CapeSwitching: Story = {
|
||||
render: () => ({
|
||||
components: { SkinPreviewRenderer },
|
||||
setup() {
|
||||
const capeSrc = ref<string | undefined>()
|
||||
return { capeSrc, greenCape, pinkCape, steveSkin }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex items-start gap-6">
|
||||
<div class="h-[25rem] w-[16rem]">
|
||||
<SkinPreviewRenderer
|
||||
:texture-src="steveSkin"
|
||||
:cape-src="capeSrc"
|
||||
variant="CLASSIC"
|
||||
framing="modal"
|
||||
:initial-rotation="Math.PI / 8"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-primary" @click="capeSrc = undefined">
|
||||
None
|
||||
</button>
|
||||
<button class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-primary" @click="capeSrc = greenCape">
|
||||
Green cape
|
||||
</button>
|
||||
<button class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-primary" @click="capeSrc = pinkCape">
|
||||
Pink cape
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
@@ -13,3 +13,4 @@ export * from './tag-messages'
|
||||
export * from './truncate'
|
||||
export * from './version-compatibility'
|
||||
export * from './vue-children'
|
||||
export * from './webgl/skin-rendering'
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import * as THREE from 'three'
|
||||
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
|
||||
export interface SkinRendererConfig {
|
||||
textureColorSpace?: THREE.ColorSpace
|
||||
textureFlipY?: boolean
|
||||
textureMagFilter?: THREE.MagnificationTextureFilter
|
||||
textureMinFilter?: THREE.MinificationTextureFilter
|
||||
}
|
||||
|
||||
const modelCache: Map<string, GLTF> = new Map()
|
||||
const modelPromiseCache: Map<string, Promise<GLTF>> = new Map()
|
||||
const textureCache: Map<string, THREE.Texture> = new Map()
|
||||
const texturePromiseCache: Map<string, Promise<THREE.Texture>> = new Map()
|
||||
|
||||
export async function loadModel(modelUrl: string): Promise<GLTF> {
|
||||
if (modelCache.has(modelUrl)) {
|
||||
return modelCache.get(modelUrl)!
|
||||
}
|
||||
|
||||
if (modelPromiseCache.has(modelUrl)) {
|
||||
return modelPromiseCache.get(modelUrl)!
|
||||
}
|
||||
|
||||
const loader = new GLTFLoader()
|
||||
const promise = new Promise<GLTF>((resolve, reject) => {
|
||||
loader.load(
|
||||
modelUrl,
|
||||
(gltf) => {
|
||||
modelCache.set(modelUrl, gltf)
|
||||
resolve(gltf)
|
||||
},
|
||||
undefined,
|
||||
reject,
|
||||
)
|
||||
}).finally(() => {
|
||||
modelPromiseCache.delete(modelUrl)
|
||||
})
|
||||
|
||||
modelPromiseCache.set(modelUrl, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
export async function loadTexture(
|
||||
textureUrl: string,
|
||||
config: SkinRendererConfig = {},
|
||||
): Promise<THREE.Texture> {
|
||||
const cacheKey = `${textureUrl}_${JSON.stringify(config)}`
|
||||
|
||||
if (textureCache.has(cacheKey)) {
|
||||
return textureCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
if (texturePromiseCache.has(cacheKey)) {
|
||||
return texturePromiseCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
const textureLoader = new THREE.TextureLoader()
|
||||
const promise = new Promise<THREE.Texture>((resolve, reject) => {
|
||||
textureLoader.load(
|
||||
textureUrl,
|
||||
(texture) => {
|
||||
texture.colorSpace = config.textureColorSpace ?? THREE.SRGBColorSpace
|
||||
texture.flipY = config.textureFlipY ?? false
|
||||
texture.magFilter = config.textureMagFilter ?? THREE.NearestFilter
|
||||
texture.minFilter = config.textureMinFilter ?? THREE.NearestFilter
|
||||
|
||||
textureCache.set(cacheKey, texture)
|
||||
resolve(texture)
|
||||
},
|
||||
undefined,
|
||||
reject,
|
||||
)
|
||||
}).finally(() => {
|
||||
texturePromiseCache.delete(cacheKey)
|
||||
})
|
||||
|
||||
texturePromiseCache.set(cacheKey, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
function applyMap(mat: THREE.MeshStandardMaterial, texture: THREE.Texture | null): boolean {
|
||||
const hadMap = mat.map !== null
|
||||
const hasMap = texture !== null
|
||||
|
||||
if (mat.map !== texture) {
|
||||
mat.map = texture
|
||||
}
|
||||
|
||||
return hadMap !== hasMap
|
||||
}
|
||||
|
||||
function setShaderMaterialProperties(
|
||||
mat: THREE.MeshStandardMaterial,
|
||||
properties: {
|
||||
alphaTest: number
|
||||
flatShading: boolean
|
||||
side: THREE.Side
|
||||
toneMapped: boolean
|
||||
transparent?: boolean
|
||||
},
|
||||
): boolean {
|
||||
let needsUpdate = false
|
||||
|
||||
if (mat.alphaTest !== properties.alphaTest) {
|
||||
mat.alphaTest = properties.alphaTest
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
if (mat.flatShading !== properties.flatShading) {
|
||||
mat.flatShading = properties.flatShading
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
if (mat.side !== properties.side) {
|
||||
mat.side = properties.side
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
if (mat.toneMapped !== properties.toneMapped) {
|
||||
mat.toneMapped = properties.toneMapped
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
if (properties.transparent !== undefined && mat.transparent !== properties.transparent) {
|
||||
mat.transparent = properties.transparent
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
return needsUpdate
|
||||
}
|
||||
|
||||
function setCommonMaterialProperties(mat: THREE.MeshStandardMaterial): void {
|
||||
if (mat.metalness !== 0) {
|
||||
mat.metalness = 0
|
||||
}
|
||||
|
||||
if (mat.color.getHex() !== 0xffffff) {
|
||||
mat.color.set(0xffffff)
|
||||
}
|
||||
|
||||
if (mat.roughness !== 1) {
|
||||
mat.roughness = 1
|
||||
}
|
||||
|
||||
if (!mat.depthTest) {
|
||||
mat.depthTest = true
|
||||
}
|
||||
|
||||
if (!mat.depthWrite) {
|
||||
mat.depthWrite = true
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): void {
|
||||
model.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
const mesh = child as THREE.Mesh
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
|
||||
materials.forEach((mat: THREE.Material) => {
|
||||
if (mat instanceof THREE.MeshStandardMaterial) {
|
||||
if (mat.name !== 'cape') {
|
||||
const mapNeedsUpdate = applyMap(mat, texture)
|
||||
const propertiesNeedUpdate = setShaderMaterialProperties(mat, {
|
||||
alphaTest: 0.1,
|
||||
flatShading: true,
|
||||
side: THREE.FrontSide,
|
||||
toneMapped: false,
|
||||
transparent: false,
|
||||
})
|
||||
|
||||
setCommonMaterialProperties(mat)
|
||||
|
||||
if (mapNeedsUpdate || propertiesNeedUpdate) {
|
||||
mat.needsUpdate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function applyCapeTexture(
|
||||
model: THREE.Object3D,
|
||||
texture: THREE.Texture | null,
|
||||
transparentTexture?: THREE.Texture,
|
||||
): void {
|
||||
model.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
const mesh = child as THREE.Mesh
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
|
||||
materials.forEach((mat: THREE.Material) => {
|
||||
if (mat instanceof THREE.MeshStandardMaterial) {
|
||||
if (mat.name === 'cape') {
|
||||
const nextMap = texture || transparentTexture || null
|
||||
const mapNeedsUpdate = applyMap(mat, nextMap)
|
||||
const propertiesNeedUpdate = setShaderMaterialProperties(mat, {
|
||||
alphaTest: 0.1,
|
||||
flatShading: true,
|
||||
side: THREE.DoubleSide,
|
||||
toneMapped: false,
|
||||
transparent: !texture || !!transparentTexture,
|
||||
})
|
||||
|
||||
setCommonMaterialProperties(mat)
|
||||
|
||||
if (mapNeedsUpdate || propertiesNeedUpdate) {
|
||||
mat.needsUpdate = true
|
||||
}
|
||||
|
||||
mat.visible = !!texture
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function findBodyNode(model: THREE.Object3D): THREE.Object3D | null {
|
||||
let bodyNode: THREE.Object3D | null = null
|
||||
|
||||
model.traverse((node) => {
|
||||
if (node.name === 'Body') {
|
||||
bodyNode = node
|
||||
}
|
||||
})
|
||||
|
||||
return bodyNode
|
||||
}
|
||||
|
||||
export function createTransparentTexture(): THREE.Texture {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = canvas.height = 1
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
|
||||
ctx.clearRect(0, 0, 1, 1)
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas)
|
||||
texture.needsUpdate = true
|
||||
texture.colorSpace = THREE.SRGBColorSpace
|
||||
texture.flipY = false
|
||||
texture.magFilter = THREE.NearestFilter
|
||||
texture.minFilter = THREE.NearestFilter
|
||||
|
||||
return texture
|
||||
}
|
||||
|
||||
export async function setupSkinModel(
|
||||
modelUrl: string,
|
||||
textureUrl: string,
|
||||
capeTextureUrl?: string,
|
||||
config: SkinRendererConfig = {},
|
||||
): Promise<{
|
||||
model: THREE.Object3D
|
||||
bodyNode: THREE.Object3D | null
|
||||
}> {
|
||||
const [gltf, texture] = await Promise.all([loadModel(modelUrl), loadTexture(textureUrl, config)])
|
||||
|
||||
const model = gltf.scene.clone()
|
||||
applyTexture(model, texture)
|
||||
|
||||
if (capeTextureUrl) {
|
||||
const capeTexture = await loadTexture(capeTextureUrl, config)
|
||||
applyCapeTexture(model, capeTexture)
|
||||
}
|
||||
|
||||
const bodyNode = findBodyNode(model)
|
||||
|
||||
return { model, bodyNode }
|
||||
}
|
||||
|
||||
export function disposeCaches(): void {
|
||||
Array.from(textureCache.values()).forEach((texture) => {
|
||||
texture.dispose()
|
||||
})
|
||||
|
||||
textureCache.clear()
|
||||
texturePromiseCache.clear()
|
||||
modelCache.clear()
|
||||
modelPromiseCache.clear()
|
||||
}
|
||||
@@ -4,7 +4,6 @@ export * from './licenses'
|
||||
export * from './parse'
|
||||
export * from './projects'
|
||||
export * from './servers'
|
||||
export * from './three/skin-rendering'
|
||||
export * from './types'
|
||||
export * from './users'
|
||||
export * from './utils'
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import * as THREE from 'three'
|
||||
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
|
||||
export interface SkinRendererConfig {
|
||||
textureColorSpace?: THREE.ColorSpace
|
||||
textureFlipY?: boolean
|
||||
textureMagFilter?: THREE.MagnificationTextureFilter
|
||||
textureMinFilter?: THREE.MinificationTextureFilter
|
||||
}
|
||||
|
||||
const modelCache: Map<string, GLTF> = new Map()
|
||||
const textureCache: Map<string, THREE.Texture> = new Map()
|
||||
|
||||
export async function loadModel(modelUrl: string): Promise<GLTF> {
|
||||
if (modelCache.has(modelUrl)) {
|
||||
return modelCache.get(modelUrl)!
|
||||
}
|
||||
|
||||
const loader = new GLTFLoader()
|
||||
return new Promise<GLTF>((resolve, reject) => {
|
||||
loader.load(
|
||||
modelUrl,
|
||||
(gltf) => {
|
||||
modelCache.set(modelUrl, gltf)
|
||||
resolve(gltf)
|
||||
},
|
||||
undefined,
|
||||
reject,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadTexture(
|
||||
textureUrl: string,
|
||||
config: SkinRendererConfig = {},
|
||||
): Promise<THREE.Texture> {
|
||||
const cacheKey = `${textureUrl}_${JSON.stringify(config)}`
|
||||
|
||||
if (textureCache.has(cacheKey)) {
|
||||
return textureCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
return new Promise<THREE.Texture>((resolve) => {
|
||||
const textureLoader = new THREE.TextureLoader()
|
||||
textureLoader.load(textureUrl, (texture) => {
|
||||
texture.colorSpace = config.textureColorSpace ?? THREE.SRGBColorSpace
|
||||
texture.flipY = config.textureFlipY ?? false
|
||||
texture.magFilter = config.textureMagFilter ?? THREE.NearestFilter
|
||||
texture.minFilter = config.textureMinFilter ?? THREE.NearestFilter
|
||||
|
||||
textureCache.set(cacheKey, texture)
|
||||
resolve(texture)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): void {
|
||||
model.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
const mesh = child as THREE.Mesh
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
|
||||
materials.forEach((mat: THREE.Material) => {
|
||||
if (mat instanceof THREE.MeshStandardMaterial) {
|
||||
if (mat.name !== 'cape') {
|
||||
mat.map = texture
|
||||
mat.metalness = 0
|
||||
mat.color.set(0xffffff)
|
||||
mat.toneMapped = false
|
||||
mat.flatShading = true
|
||||
mat.roughness = 1
|
||||
mat.needsUpdate = true
|
||||
mat.depthTest = true
|
||||
mat.side = THREE.DoubleSide
|
||||
mat.alphaTest = 0.1
|
||||
mat.depthWrite = true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function applyCapeTexture(
|
||||
model: THREE.Object3D,
|
||||
texture: THREE.Texture | null,
|
||||
transparentTexture?: THREE.Texture,
|
||||
): void {
|
||||
model.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
const mesh = child as THREE.Mesh
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
|
||||
materials.forEach((mat: THREE.Material) => {
|
||||
if (mat instanceof THREE.MeshStandardMaterial) {
|
||||
if (mat.name === 'cape') {
|
||||
mat.map = texture || transparentTexture || null
|
||||
mat.transparent = !texture || transparentTexture ? true : false
|
||||
mat.metalness = 0
|
||||
mat.color.set(0xffffff)
|
||||
mat.toneMapped = false
|
||||
mat.flatShading = true
|
||||
mat.roughness = 1
|
||||
mat.needsUpdate = true
|
||||
mat.depthTest = true
|
||||
mat.depthWrite = true
|
||||
mat.side = THREE.DoubleSide
|
||||
mat.alphaTest = 0.1
|
||||
mat.visible = !!texture
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function findBodyNode(model: THREE.Object3D): THREE.Object3D | null {
|
||||
let bodyNode: THREE.Object3D | null = null
|
||||
|
||||
model.traverse((node) => {
|
||||
if (node.name === 'Body') {
|
||||
bodyNode = node
|
||||
}
|
||||
})
|
||||
|
||||
return bodyNode
|
||||
}
|
||||
|
||||
export function createTransparentTexture(): THREE.Texture {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = canvas.height = 1
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
|
||||
ctx.clearRect(0, 0, 1, 1)
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas)
|
||||
texture.needsUpdate = true
|
||||
texture.colorSpace = THREE.SRGBColorSpace
|
||||
texture.flipY = false
|
||||
texture.magFilter = THREE.NearestFilter
|
||||
texture.minFilter = THREE.NearestFilter
|
||||
|
||||
return texture
|
||||
}
|
||||
|
||||
export async function setupSkinModel(
|
||||
modelUrl: string,
|
||||
textureUrl: string,
|
||||
capeTextureUrl?: string,
|
||||
config: SkinRendererConfig = {},
|
||||
): Promise<{
|
||||
model: THREE.Object3D
|
||||
bodyNode: THREE.Object3D | null
|
||||
}> {
|
||||
const [gltf, texture] = await Promise.all([loadModel(modelUrl), loadTexture(textureUrl, config)])
|
||||
|
||||
const model = gltf.scene.clone()
|
||||
applyTexture(model, texture)
|
||||
|
||||
if (capeTextureUrl) {
|
||||
const capeTexture = await loadTexture(capeTextureUrl, config)
|
||||
applyCapeTexture(model, capeTexture)
|
||||
}
|
||||
|
||||
const bodyNode = findBodyNode(model)
|
||||
|
||||
return { model, bodyNode }
|
||||
}
|
||||
|
||||
export function disposeCaches(): void {
|
||||
Array.from(textureCache.values()).forEach((texture) => {
|
||||
texture.dispose()
|
||||
})
|
||||
|
||||
textureCache.clear()
|
||||
modelCache.clear()
|
||||
}
|
||||
Reference in New Issue
Block a user