mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 13:16:38 +00:00
feat: add Ears mod support to skin selector (#6775)
* feat: add Ears mod support to skin selector * fix: lint * feat: ears mod notice + toggle + fix uv issues * fix: lint
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -11,6 +11,7 @@
|
||||
<SkinPreviewRenderer
|
||||
:variant="variant"
|
||||
:texture-src="previewSkin || ''"
|
||||
:ears-texture-src="uploadedTextureUrl?.original ?? currentSkin?.texture"
|
||||
:cape-src="selectedCapeTexture"
|
||||
framing="modal"
|
||||
:initial-rotation="Math.PI / 8"
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
|
||||
import {
|
||||
applyCapeTexture,
|
||||
applyEarsMod,
|
||||
createTransparentTexture,
|
||||
disposeCaches,
|
||||
loadTexture,
|
||||
removeEarsMod,
|
||||
setupSkinModel,
|
||||
} from '@modrinth/ui'
|
||||
import * as THREE from 'three'
|
||||
@@ -71,12 +74,13 @@ class BatchSkinRenderer {
|
||||
textureUrl: string,
|
||||
modelUrl: string,
|
||||
capeUrl?: string,
|
||||
earsTextureUrl?: string,
|
||||
): Promise<RawRenderResult> {
|
||||
this.initializeRenderer()
|
||||
|
||||
this.clearScene()
|
||||
|
||||
await this.setupModel(modelUrl, textureUrl, capeUrl)
|
||||
await this.setupModel(modelUrl, textureUrl, capeUrl, earsTextureUrl)
|
||||
|
||||
const headPart = this.currentModel!.getObjectByName('Head')
|
||||
let lookAtTarget: [number, number, number]
|
||||
@@ -123,17 +127,29 @@ class BatchSkinRenderer {
|
||||
})
|
||||
}
|
||||
|
||||
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
|
||||
private async setupModel(
|
||||
modelUrl: string,
|
||||
textureUrl: string,
|
||||
capeUrl?: string,
|
||||
earsTextureUrl?: string,
|
||||
): Promise<void> {
|
||||
if (!this.scene) {
|
||||
throw new Error('Renderer not initialized')
|
||||
}
|
||||
|
||||
const { model } = await setupSkinModel(modelUrl, textureUrl, capeUrl)
|
||||
const [{ model }, earsTexture] = await Promise.all([
|
||||
setupSkinModel(modelUrl, textureUrl, capeUrl),
|
||||
earsTextureUrl ? loadTexture(earsTextureUrl) : Promise.resolve(null),
|
||||
])
|
||||
|
||||
if (!capeUrl) {
|
||||
applyCapeTexture(model, null, this.getTransparentTexture())
|
||||
}
|
||||
|
||||
if (earsTexture) {
|
||||
applyEarsMod(model, earsTexture)
|
||||
}
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.add(model)
|
||||
group.position.set(0, 0.3, 1.95)
|
||||
@@ -154,6 +170,7 @@ class BatchSkinRenderer {
|
||||
private clearScene(): void {
|
||||
if (!this.scene || !this.currentModel) return
|
||||
|
||||
removeEarsMod(this.currentModel)
|
||||
this.scene.remove(this.currentModel)
|
||||
this.currentModel.clear()
|
||||
this.currentModel = null
|
||||
@@ -193,6 +210,7 @@ function getModelUrlForVariant(variant: string): string {
|
||||
export const skinBlobUrlMap = reactive(new Map<string, RenderResult>())
|
||||
export const headBlobUrlMap = reactive(new Map<string, string>())
|
||||
const DEBUG_MODE = false
|
||||
const SKIN_PREVIEW_RENDER_VERSION = 'ears-1'
|
||||
|
||||
let sharedRenderer: BatchSkinRenderer | null = null
|
||||
let latestPreviewGeneration = 0
|
||||
@@ -205,6 +223,10 @@ function getSharedRenderer(): BatchSkinRenderer {
|
||||
return sharedRenderer
|
||||
}
|
||||
|
||||
export function getSkinPreviewKey(skin: Skin): string {
|
||||
return `${SKIN_PREVIEW_RENDER_VERSION}+${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
}
|
||||
|
||||
export function disposeSharedRenderer(): void {
|
||||
if (sharedRenderer) {
|
||||
sharedRenderer.dispose()
|
||||
@@ -217,7 +239,7 @@ export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
|
||||
const validHeadKeys = new Set<string>()
|
||||
|
||||
for (const skin of skins) {
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
const key = getSkinPreviewKey(skin)
|
||||
const headKey = `${skin.texture_key}-head`
|
||||
validKeys.add(key)
|
||||
validHeadKeys.add(headKey)
|
||||
@@ -382,9 +404,7 @@ async function generateSkinPreviewsForGeneration(
|
||||
const isCurrentGeneration = () => generation === latestPreviewGeneration
|
||||
|
||||
try {
|
||||
const skinKeys = skins.map(
|
||||
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
|
||||
)
|
||||
const skinKeys = skins.map(getSkinPreviewKey)
|
||||
const headKeys = skins.map((skin) => `${skin.texture_key}-head`)
|
||||
|
||||
const [cachedSkinPreviews, cachedHeadPreviews] = await Promise.all([
|
||||
@@ -415,7 +435,7 @@ async function generateSkinPreviewsForGeneration(
|
||||
for (const skin of skins) {
|
||||
if (!isCurrentGeneration()) return
|
||||
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
const key = getSkinPreviewKey(skin)
|
||||
|
||||
if (skinBlobUrlMap.has(key)) {
|
||||
if (DEBUG_MODE) {
|
||||
@@ -443,6 +463,7 @@ async function generateSkinPreviewsForGeneration(
|
||||
await get_normalized_skin_texture(skin),
|
||||
modelUrl,
|
||||
cape?.texture,
|
||||
skin.texture,
|
||||
)
|
||||
|
||||
if (!isCurrentGeneration()) return
|
||||
|
||||
@@ -542,6 +542,9 @@
|
||||
"app.skins.dropped-file-error.title": {
|
||||
"message": "Error processing file"
|
||||
},
|
||||
"app.skins.ears-feature-notice": {
|
||||
"message": "This skin uses features from the {ears} mod"
|
||||
},
|
||||
"app.skins.edit-button": {
|
||||
"message": "Edit skin"
|
||||
},
|
||||
@@ -668,6 +671,12 @@
|
||||
"app.skins.title": {
|
||||
"message": "Skin selector"
|
||||
},
|
||||
"app.skins.toggle-ears-features-off": {
|
||||
"message": "Toggle off"
|
||||
},
|
||||
"app.skins.toggle-ears-features-on": {
|
||||
"message": "Toggle on"
|
||||
},
|
||||
"app.update-popup.body.download-complete": {
|
||||
"message": "Modrinth App v{version} has finished downloading. Reload to update now, or automatically when you close Modrinth App."
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
SkinPreviewRenderer,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { arrayBufferToBase64 } from '@modrinth/utils'
|
||||
@@ -26,13 +27,18 @@ import { computedAsync } from '@vueuse/core'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import EarsModIcon from '@/assets/skins/ears-mod.png'
|
||||
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
|
||||
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { check_reachable, get_default_user, login as login_flow, users } from '@/helpers/auth'
|
||||
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import {
|
||||
generateSkinPreviews,
|
||||
getSkinPreviewKey,
|
||||
skinBlobUrlMap,
|
||||
} from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
|
||||
import {
|
||||
equip_skin,
|
||||
@@ -60,6 +66,7 @@ type VirtualSkinSectionListExpose = {
|
||||
|
||||
const PENDING_SKIN_REFRESH_DELAY_MS = 11_000
|
||||
const DEFAULT_SKIN_SECTION_SORT_ORDER = ['Default skins', 'Modrinth Pride']
|
||||
const EARS_NOTICE_PLACEHOLDER = '__EARS_MOD_NAME__'
|
||||
const messages = defineMessages({
|
||||
skinSelectorTitle: {
|
||||
id: 'app.skins.title',
|
||||
@@ -163,6 +170,18 @@ const messages = defineMessages({
|
||||
id: 'app.skins.preview.edit-button',
|
||||
defaultMessage: 'Edit skin',
|
||||
},
|
||||
earsFeatureNotice: {
|
||||
id: 'app.skins.ears-feature-notice',
|
||||
defaultMessage: 'This skin uses features from the {ears} mod',
|
||||
},
|
||||
toggleEarsFeaturesOff: {
|
||||
id: 'app.skins.toggle-ears-features-off',
|
||||
defaultMessage: 'Toggle off',
|
||||
},
|
||||
toggleEarsFeaturesOn: {
|
||||
id: 'app.skins.toggle-ears-features-on',
|
||||
defaultMessage: 'Toggle on',
|
||||
},
|
||||
excitedRinthbotAlt: {
|
||||
id: 'app.skins.sign-in.rinthbot-alt',
|
||||
defaultMessage: 'Excited Modrinth Bot',
|
||||
@@ -204,8 +223,30 @@ const currentUserId = ref<string | undefined>(undefined)
|
||||
const username = computed(() => currentUser.value?.profile?.name ?? undefined)
|
||||
const selectedSkin = ref<Skin | null>(null)
|
||||
const isApplyingSkin = ref(false)
|
||||
const earsFeaturesEnabled = ref(true)
|
||||
const selectedSkinHasEarsFeatures = ref(false)
|
||||
|
||||
const originalSelectedSkin = ref<Skin | null>(null)
|
||||
const earsFeatureNoticeParts = computed(() => {
|
||||
const notice = formatMessage(messages.earsFeatureNotice, {
|
||||
ears: EARS_NOTICE_PLACEHOLDER,
|
||||
})
|
||||
const placeholderIndex = notice.indexOf(EARS_NOTICE_PLACEHOLDER)
|
||||
|
||||
if (placeholderIndex === -1) {
|
||||
return {
|
||||
before: notice,
|
||||
after: '',
|
||||
hasEarsLink: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
before: notice.slice(0, placeholderIndex),
|
||||
after: notice.slice(placeholderIndex + EARS_NOTICE_PLACEHOLDER.length),
|
||||
hasEarsLink: true,
|
||||
}
|
||||
})
|
||||
|
||||
const savedSkins = computed(() => {
|
||||
try {
|
||||
@@ -755,8 +796,7 @@ async function loadCurrentUser() {
|
||||
}
|
||||
|
||||
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
return skinBlobUrlMap.get(key)
|
||||
return skinBlobUrlMap.get(getSkinPreviewKey(skin))
|
||||
}
|
||||
|
||||
async function login() {
|
||||
@@ -940,6 +980,10 @@ watch(
|
||||
() => {},
|
||||
)
|
||||
|
||||
watch(selectedSkin, () => {
|
||||
earsFeaturesEnabled.value = true
|
||||
})
|
||||
|
||||
watch(isSkinManagementReadOnly, (readOnly) => {
|
||||
if (readOnly) {
|
||||
isDraggingSkinFile.value = false
|
||||
@@ -1035,9 +1079,12 @@ await loadSkins()
|
||||
<SkinPreviewRenderer
|
||||
:cape-src="capeTexture"
|
||||
:texture-src="skinTexture || ''"
|
||||
:ears-texture-src="selectedSkin?.texture"
|
||||
:variant="skinVariant"
|
||||
:nametag="skinNametag"
|
||||
:initial-rotation="Math.PI / 8"
|
||||
:ears-enabled="earsFeaturesEnabled"
|
||||
@ears-features-detected="selectedSkinHasEarsFeatures = $event"
|
||||
>
|
||||
<template v-if="hasPendingSkinChange" #nametag-badge>
|
||||
<div
|
||||
@@ -1049,36 +1096,126 @@ await loadSkins()
|
||||
</template>
|
||||
<template #subtitle>
|
||||
<div
|
||||
v-if="hasPendingSkinChange"
|
||||
class="flex max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-2 px-2"
|
||||
class="skin-preview-subtitle flex w-full flex-col items-center gap-6"
|
||||
:class="{ 'has-ears-features': selectedSkinHasEarsFeatures }"
|
||||
>
|
||||
<button
|
||||
class="flex h-10 min-w-0 cursor-pointer 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 transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
|
||||
:disabled="isApplyingSkin || isSkinManagementReadOnly"
|
||||
@click="resetSelectedSkin"
|
||||
<div
|
||||
v-if="hasPendingSkinChange"
|
||||
class="skin-preview-actions flex w-full items-center justify-center gap-1.5"
|
||||
:class="selectedSkinHasEarsFeatures ? 'flex-nowrap' : 'flex-wrap'"
|
||||
>
|
||||
<RotateCounterClockwiseIcon />
|
||||
{{ formatMessage(commonMessages.resetButton) }}
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="
|
||||
selectedSkinHasEarsFeatures
|
||||
? formatMessage(commonMessages.resetButton)
|
||||
: undefined
|
||||
"
|
||||
class="skin-preview-action-button flex h-10 min-w-0 cursor-pointer 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 transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
|
||||
:disabled="isApplyingSkin || isSkinManagementReadOnly"
|
||||
:aria-label="formatMessage(commonMessages.resetButton)"
|
||||
@click="resetSelectedSkin"
|
||||
>
|
||||
<RotateCounterClockwiseIcon />
|
||||
<span class="skin-preview-action-label">
|
||||
{{ formatMessage(commonMessages.resetButton) }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="
|
||||
selectedSkinHasEarsFeatures ? formatMessage(messages.applyButton) : undefined
|
||||
"
|
||||
class="skin-preview-action-button flex h-10 min-w-0 cursor-pointer 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 transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
|
||||
:disabled="isApplyingSkin || isSkinManagementReadOnly"
|
||||
:aria-label="formatMessage(messages.applyButton)"
|
||||
@click="applySelectedSkin"
|
||||
>
|
||||
<SpinnerIcon v-if="isApplyingSkin" class="animate-spin" />
|
||||
<CheckIcon v-else />
|
||||
<span class="skin-preview-action-label">
|
||||
{{ formatMessage(messages.applyButton) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="flex h-10 min-w-0 cursor-pointer 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 transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
|
||||
:disabled="isApplyingSkin || isSkinManagementReadOnly"
|
||||
@click="applySelectedSkin"
|
||||
v-else
|
||||
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
|
||||
:disabled="!selectedSkin || isSkinManagementReadOnly"
|
||||
@click="(e: MouseEvent) => selectedSkin && editSkinModal?.show(e, selectedSkin)"
|
||||
>
|
||||
<SpinnerIcon v-if="isApplyingSkin" class="animate-spin" />
|
||||
<CheckIcon v-else />
|
||||
{{ formatMessage(messages.applyButton) }}
|
||||
<EditIcon />
|
||||
{{ formatMessage(messages.editSkinButton) }}
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="selectedSkinHasEarsFeatures"
|
||||
class="ears-feature-notice box-border flex w-full max-w-[340px] items-center justify-center gap-1.5 px-2"
|
||||
>
|
||||
<div class="ears-feature-copy flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<img
|
||||
:src="EarsModIcon"
|
||||
alt=""
|
||||
class="size-10 shrink-0 rounded-[7px] border border-solid border-surface-5 object-cover"
|
||||
/>
|
||||
<p
|
||||
class="ears-feature-description m-0 min-w-0 flex-1 text-sm font-medium leading-5 text-primary"
|
||||
>
|
||||
{{ earsFeatureNoticeParts.before
|
||||
}}<router-link
|
||||
v-if="earsFeatureNoticeParts.hasEarsLink"
|
||||
to="/project/mfzaZK3Z"
|
||||
class="text-inherit underline"
|
||||
>Ears</router-link
|
||||
>{{ earsFeatureNoticeParts.after }}
|
||||
</p>
|
||||
<router-link
|
||||
to="/project/mfzaZK3Z"
|
||||
class="ears-feature-compact-label hidden min-w-0 flex-1 text-sm font-medium leading-5 text-primary underline"
|
||||
>Ears</router-link
|
||||
>
|
||||
</div>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
class="ears-feature-toggle-button !h-10 !rounded-[14px] !px-4 shadow-md"
|
||||
:aria-pressed="earsFeaturesEnabled"
|
||||
:aria-label="
|
||||
formatMessage(
|
||||
earsFeaturesEnabled
|
||||
? messages.toggleEarsFeaturesOff
|
||||
: messages.toggleEarsFeaturesOn,
|
||||
)
|
||||
"
|
||||
@click="earsFeaturesEnabled = !earsFeaturesEnabled"
|
||||
>
|
||||
{{
|
||||
formatMessage(
|
||||
earsFeaturesEnabled
|
||||
? messages.toggleEarsFeaturesOff
|
||||
: messages.toggleEarsFeaturesOn,
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<Toggle
|
||||
v-model="earsFeaturesEnabled"
|
||||
v-tooltip="
|
||||
formatMessage(
|
||||
earsFeaturesEnabled
|
||||
? messages.toggleEarsFeaturesOff
|
||||
: messages.toggleEarsFeaturesOn,
|
||||
)
|
||||
"
|
||||
small
|
||||
class="ears-feature-toggle-switch"
|
||||
:aria-label="
|
||||
formatMessage(
|
||||
earsFeaturesEnabled
|
||||
? messages.toggleEarsFeaturesOff
|
||||
: messages.toggleEarsFeaturesOn,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
|
||||
:disabled="!selectedSkin || isSkinManagementReadOnly"
|
||||
@click="(e: MouseEvent) => selectedSkin && editSkinModal?.show(e, selectedSkin)"
|
||||
>
|
||||
<EditIcon />
|
||||
{{ formatMessage(messages.editSkinButton) }}
|
||||
</button>
|
||||
</template>
|
||||
</SkinPreviewRenderer>
|
||||
</div>
|
||||
@@ -1147,6 +1284,51 @@ await loadSkins()
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.skin-preview-subtitle {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.ears-feature-toggle-switch {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@container (max-width: 300px) {
|
||||
.skin-preview-subtitle {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.has-ears-features .skin-preview-action-button {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
width: 2.5rem;
|
||||
}
|
||||
|
||||
.has-ears-features .skin-preview-action-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ears-feature-toggle-button {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ears-feature-notice {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.ears-feature-description {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ears-feature-compact-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ears-feature-toggle-switch {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.skin-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2.5fr);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import type { StorybookConfig } from '@storybook/vue3-vite'
|
||||
import { mergeConfig } from 'vite'
|
||||
|
||||
const storybookDirectory = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const config: StorybookConfig = {
|
||||
framework: {
|
||||
name: '@storybook/vue3-vite',
|
||||
@@ -16,7 +19,7 @@ const config: StorybookConfig = {
|
||||
mergeConfig(config, {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@modrinth/api-client': path.resolve(__dirname, '../../api-client/src/index.ts'),
|
||||
'@modrinth/api-client': path.resolve(storybookDirectory, '../../api-client/src/index.ts'),
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
class="absolute left-0 right-0 z-10 flex items-center justify-center pointer-events-none"
|
||||
:style="subtitlePositionStyle"
|
||||
>
|
||||
<div ref="subtitleElement" class="pointer-events-auto" @click="ignoreControlClick">
|
||||
<div ref="subtitleElement" class="w-full pointer-events-auto" @click="ignoreControlClick">
|
||||
<slot name="subtitle" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,6 +133,7 @@ import { createRadialSpotlightShader, syncDamageFlashShader } from './skin-previ
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
textureSrc: string
|
||||
earsTextureSrc?: string
|
||||
capeSrc?: string
|
||||
variant?: 'SLIM' | 'CLASSIC' | 'UNKNOWN'
|
||||
nametag?: string
|
||||
@@ -147,9 +148,11 @@ const props = withDefaults(
|
||||
fov?: number
|
||||
initialRotation?: number
|
||||
animationConfig?: SkinPreviewAnimationConfig
|
||||
earsEnabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
variant: 'CLASSIC',
|
||||
earsTextureSrc: undefined,
|
||||
capeSrc: undefined,
|
||||
initialRotation: 15.75,
|
||||
nametag: undefined,
|
||||
@@ -157,6 +160,7 @@ const props = withDefaults(
|
||||
lockFit: true,
|
||||
framing: 'page',
|
||||
fitZoom: 1,
|
||||
earsEnabled: true,
|
||||
animationConfig: () => ({
|
||||
baseAnimation: 'idle',
|
||||
randomAnimations: ['idle_sub_1', 'idle_sub_2', 'idle_sub_3'],
|
||||
@@ -166,6 +170,10 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
earsFeaturesDetected: [detected: boolean]
|
||||
}>()
|
||||
|
||||
const skinPreviewContainer = useTemplateRef<HTMLElement>('skinPreviewContainer')
|
||||
const subtitleElement = useTemplateRef<HTMLElement>('subtitleElement')
|
||||
const slots = useSlots()
|
||||
@@ -259,13 +267,16 @@ const {
|
||||
},
|
||||
})
|
||||
|
||||
const { isModelLoaded, isTextureLoaded, modelCenter, modelSize, scene } = useSkinPreviewScene({
|
||||
selectedModelSrc,
|
||||
textureSrc: toRef(props, 'textureSrc'),
|
||||
capeSrc: toRef(props, 'capeSrc'),
|
||||
initializeAnimations,
|
||||
cleanupAnimationState,
|
||||
})
|
||||
const { hasEarsFeatures, isModelLoaded, isTextureLoaded, modelCenter, modelSize, scene } =
|
||||
useSkinPreviewScene({
|
||||
selectedModelSrc,
|
||||
textureSrc: toRef(props, 'textureSrc'),
|
||||
earsTextureSrc: toRef(props, 'earsTextureSrc'),
|
||||
capeSrc: toRef(props, 'capeSrc'),
|
||||
earsEnabled: toRef(props, 'earsEnabled'),
|
||||
initializeAnimations,
|
||||
cleanupAnimationState,
|
||||
})
|
||||
|
||||
function syncDamageFlashShaderMaterials() {
|
||||
syncDamageFlashShader(scene.value, damageFlashIntensity.value)
|
||||
@@ -310,6 +321,13 @@ const { isPreviewVisible, showLoading } = useSkinPreviewLoading(isReady)
|
||||
onMounted(observeSubtitleElement)
|
||||
|
||||
watch(hasSubtitle, () => nextTick(observeSubtitleElement), { flush: 'post' })
|
||||
watch(
|
||||
hasEarsFeatures,
|
||||
(detected) => {
|
||||
emit('earsFeaturesDetected', detected)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
watch(scene, syncDamageFlashShaderMaterials, { immediate: true })
|
||||
watch(damageFlashIntensity, syncDamageFlashShaderMaterials)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export * from './server-backups-queue'
|
||||
export * from './server-console'
|
||||
export * from './server-manage-core-runtime'
|
||||
export * from './server-permissions'
|
||||
export { applyEarsMod, removeEarsMod } from './skin-rendering/use-ears-mod-features'
|
||||
export * from './sticky-observer'
|
||||
export * from './terminal'
|
||||
export * from './use-loading-bar-token'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ import {
|
||||
} from '#ui/utils/webgl/skin-rendering.ts'
|
||||
|
||||
import type { SkinPreviewTuple } from './types'
|
||||
import { applyEarsMod, removeEarsMod } from './use-ears-mod-features'
|
||||
|
||||
const SKIN_LAYER_DEPTH_BIAS = -1
|
||||
|
||||
@@ -109,13 +110,17 @@ type MaybeReadonlyRef<T> = Ref<T> | ComputedRef<T>
|
||||
export function useSkinPreviewScene({
|
||||
selectedModelSrc,
|
||||
textureSrc,
|
||||
earsTextureSrc,
|
||||
capeSrc,
|
||||
earsEnabled,
|
||||
initializeAnimations,
|
||||
cleanupAnimationState,
|
||||
}: {
|
||||
selectedModelSrc: MaybeReadonlyRef<string>
|
||||
textureSrc: MaybeReadonlyRef<string>
|
||||
earsTextureSrc: MaybeReadonlyRef<string | undefined>
|
||||
capeSrc: MaybeReadonlyRef<string | undefined>
|
||||
earsEnabled: MaybeReadonlyRef<boolean>
|
||||
initializeAnimations: (loadedScene: THREE.Object3D, clips: THREE.AnimationClip[]) => void
|
||||
cleanupAnimationState: (root: THREE.Object3D | null) => void
|
||||
}) {
|
||||
@@ -123,16 +128,20 @@ export function useSkinPreviewScene({
|
||||
const lastCapeSrc = ref<string | undefined>(undefined)
|
||||
const loadedModelSrc = ref<string | undefined>(undefined)
|
||||
const loadedTextureSrc = ref<string | undefined>(undefined)
|
||||
const loadedEarsTextureSrc = ref<string | undefined>(undefined)
|
||||
const loadedCapeSrc = ref<string | undefined>(undefined)
|
||||
const texture = shallowRef<THREE.Texture | null>(null)
|
||||
const earsTexture = 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)
|
||||
const hasEarsFeatures = ref(false)
|
||||
let modelLoadVersion = 0
|
||||
let textureLoadVersion = 0
|
||||
let earsTextureLoadVersion = 0
|
||||
let capeLoadVersion = 0
|
||||
let isUnmounted = false
|
||||
|
||||
@@ -147,6 +156,18 @@ export function useSkinPreviewScene({
|
||||
}
|
||||
|
||||
applyTexture(scene.value, texture.value)
|
||||
const featureTextureSrc = earsTextureSrc.value
|
||||
const featureTexture = featureTextureSrc ? earsTexture.value : texture.value
|
||||
if (
|
||||
!featureTexture ||
|
||||
(featureTextureSrc && loadedEarsTextureSrc.value !== featureTextureSrc)
|
||||
) {
|
||||
removeEarsMod(scene.value)
|
||||
hasEarsFeatures.value = false
|
||||
return
|
||||
}
|
||||
|
||||
hasEarsFeatures.value = applyEarsMod(scene.value, featureTexture, earsEnabled.value)
|
||||
}
|
||||
|
||||
function applyCapeTextureToLoadedModel() {
|
||||
@@ -173,6 +194,7 @@ export function useSkinPreviewScene({
|
||||
|
||||
const previousScene = scene.value
|
||||
cleanupAnimationState(previousScene)
|
||||
removeEarsMod(previousScene)
|
||||
disposeSceneMaterials(previousScene)
|
||||
scene.value = clonedScene
|
||||
loadedModelSrc.value = src
|
||||
@@ -232,6 +254,19 @@ export function useSkinPreviewScene({
|
||||
applyCapeTextureToLoadedModel()
|
||||
}
|
||||
|
||||
async function loadAndApplyEarsTexture(src: string | undefined) {
|
||||
const loadVersion = ++earsTextureLoadVersion
|
||||
hasEarsFeatures.value = false
|
||||
|
||||
const loadedEarsTexture = src ? await loadAndApplyTexture(src) : null
|
||||
if (isUnmounted || loadVersion !== earsTextureLoadVersion) return
|
||||
|
||||
earsTexture.value = loadedEarsTexture
|
||||
loadedEarsTextureSrc.value = src
|
||||
applyTextureToLoadedModel()
|
||||
updateModelInfo()
|
||||
}
|
||||
|
||||
function updateModelInfo() {
|
||||
const box = scene.value ? getVisibleMeshBox(scene.value) : null
|
||||
|
||||
@@ -261,6 +296,7 @@ export function useSkinPreviewScene({
|
||||
const loadVersion = ++textureLoadVersion
|
||||
|
||||
isTextureLoaded.value = false
|
||||
hasEarsFeatures.value = false
|
||||
const loadedTexture = await loadAndApplyTexture(newSrc)
|
||||
if (isUnmounted || loadVersion !== textureLoadVersion) return
|
||||
|
||||
@@ -270,6 +306,19 @@ export function useSkinPreviewScene({
|
||||
isTextureLoaded.value = true
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => earsTextureSrc.value,
|
||||
async (newEarsTextureSrc) => {
|
||||
await loadAndApplyEarsTexture(newEarsTextureSrc)
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => earsEnabled.value,
|
||||
() => {
|
||||
applyTextureToLoadedModel()
|
||||
updateModelInfo()
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => capeSrc.value,
|
||||
async (newCapeSrc) => {
|
||||
@@ -284,6 +333,7 @@ export function useSkinPreviewScene({
|
||||
loadedTextureSrc.value = textureSrc.value
|
||||
isTextureLoaded.value = true
|
||||
|
||||
await loadAndApplyEarsTexture(earsTextureSrc.value)
|
||||
await loadModel(selectedModelSrc.value)
|
||||
|
||||
if (capeSrc.value) {
|
||||
@@ -298,15 +348,18 @@ export function useSkinPreviewScene({
|
||||
isUnmounted = true
|
||||
modelLoadVersion++
|
||||
textureLoadVersion++
|
||||
earsTextureLoadVersion++
|
||||
capeLoadVersion++
|
||||
|
||||
cleanupAnimationState(scene.value)
|
||||
removeEarsMod(scene.value)
|
||||
disposeSceneMaterials(scene.value)
|
||||
scene.value = null
|
||||
transparentTexture.dispose()
|
||||
})
|
||||
|
||||
return {
|
||||
hasEarsFeatures,
|
||||
isModelLoaded,
|
||||
isTextureLoaded,
|
||||
modelCenter,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CheckIcon, EyeIcon, RotateCounterClockwiseIcon } from '@modrinth/assets'
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
import SkinPreviewRenderer from '../../components/skin/SkinPreviewRenderer.vue'
|
||||
|
||||
@@ -60,6 +60,76 @@ type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const FileUpload: Story = {
|
||||
render: () => ({
|
||||
components: { SkinPreviewRenderer },
|
||||
setup() {
|
||||
const textureSrc = ref(steveSkin)
|
||||
const fileName = ref('Steve skin')
|
||||
let objectUrl: string | undefined
|
||||
|
||||
function revokeObjectUrl() {
|
||||
if (!objectUrl) return
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
objectUrl = undefined
|
||||
}
|
||||
|
||||
function uploadSkin(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
revokeObjectUrl()
|
||||
objectUrl = URL.createObjectURL(file)
|
||||
textureSrc.value = objectUrl
|
||||
fileName.value = file.name
|
||||
}
|
||||
|
||||
function resetSkin() {
|
||||
revokeObjectUrl()
|
||||
textureSrc.value = steveSkin
|
||||
fileName.value = 'Steve skin'
|
||||
}
|
||||
|
||||
onBeforeUnmount(revokeObjectUrl)
|
||||
|
||||
return {
|
||||
fileName,
|
||||
resetSkin,
|
||||
textureSrc,
|
||||
uploadSkin,
|
||||
}
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex flex-wrap items-start gap-6">
|
||||
<div class="h-[80vh] min-h-[32rem] max-h-[48rem] w-[22rem]">
|
||||
<SkinPreviewRenderer :texture-src="textureSrc" variant="CLASSIC" />
|
||||
</div>
|
||||
<div class="flex w-72 flex-col gap-3 rounded-xl bg-surface-2 p-4">
|
||||
<label class="text-sm font-semibold text-contrast" for="skin-preview-upload">
|
||||
Upload skin
|
||||
</label>
|
||||
<input
|
||||
id="skin-preview-upload"
|
||||
class="text-sm text-primary file:mr-3 file:rounded-lg file:border-0 file:bg-surface-4 file:px-3 file:py-2 file:text-primary"
|
||||
type="file"
|
||||
accept="image/png"
|
||||
@change="uploadSkin"
|
||||
/>
|
||||
<div class="truncate text-sm text-secondary">{{ fileName }}</div>
|
||||
<button
|
||||
class="rounded-lg border-0 bg-surface-4 px-4 py-2 text-primary"
|
||||
type="button"
|
||||
@click="resetSkin"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const RepeatInteract: Story = {
|
||||
render: (args) => ({
|
||||
components: { SkinPreviewRenderer },
|
||||
|
||||
@@ -13,6 +13,8 @@ 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()
|
||||
const SKIN_TEXTURE_SIZE = 64
|
||||
const SKIN_UV_INSET_VERSION = 1
|
||||
|
||||
export async function loadModel(modelUrl: string): Promise<GLTF> {
|
||||
if (modelCache.has(modelUrl)) {
|
||||
@@ -153,12 +155,58 @@ function setCommonMaterialProperties(mat: THREE.MeshStandardMaterial): void {
|
||||
}
|
||||
}
|
||||
|
||||
function insetSkinGeometryUvs(geometry: THREE.BufferGeometry): void {
|
||||
if (geometry.userData.skinUvInsetVersion === SKIN_UV_INSET_VERSION) return
|
||||
|
||||
const uv = geometry.getAttribute('uv')
|
||||
if (!uv || uv.itemSize < 2 || uv.count % 4 !== 0) return
|
||||
|
||||
for (let faceStart = 0; faceStart < uv.count; faceStart += 4) {
|
||||
let minU = Infinity
|
||||
let maxU = -Infinity
|
||||
let minV = Infinity
|
||||
let maxV = -Infinity
|
||||
|
||||
for (let vertex = faceStart; vertex < faceStart + 4; vertex++) {
|
||||
const u = uv.getX(vertex)
|
||||
const v = uv.getY(vertex)
|
||||
minU = Math.min(minU, u)
|
||||
maxU = Math.max(maxU, u)
|
||||
minV = Math.min(minV, v)
|
||||
maxV = Math.max(maxV, v)
|
||||
}
|
||||
|
||||
const insetMinU = (Math.round(minU * SKIN_TEXTURE_SIZE) + 0.5) / SKIN_TEXTURE_SIZE
|
||||
const insetMaxU = (Math.round(maxU * SKIN_TEXTURE_SIZE) - 0.5) / SKIN_TEXTURE_SIZE
|
||||
const insetMinV = (Math.round(minV * SKIN_TEXTURE_SIZE) + 0.5) / SKIN_TEXTURE_SIZE
|
||||
const insetMaxV = (Math.round(maxV * SKIN_TEXTURE_SIZE) - 0.5) / SKIN_TEXTURE_SIZE
|
||||
|
||||
for (let vertex = faceStart; vertex < faceStart + 4; vertex++) {
|
||||
const u = uv.getX(vertex)
|
||||
const v = uv.getY(vertex)
|
||||
const insetU = u === minU ? insetMinU : u === maxU ? insetMaxU : u
|
||||
const insetV = v === minV ? insetMinV : v === maxV ? insetMaxV : v
|
||||
uv.setXY(vertex, insetU, insetV)
|
||||
}
|
||||
}
|
||||
|
||||
uv.needsUpdate = true
|
||||
geometry.userData.skinUvInsetVersion = SKIN_UV_INSET_VERSION
|
||||
}
|
||||
|
||||
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 isSkinLayer = mesh.name.endsWith('_Layer')
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
const usesSkinTexture = materials.some(
|
||||
(mat) => mat instanceof THREE.MeshStandardMaterial && mat.name !== 'cape',
|
||||
)
|
||||
|
||||
if (usesSkinTexture) {
|
||||
insetSkinGeometryUvs(mesh.geometry)
|
||||
}
|
||||
|
||||
materials.forEach((mat: THREE.Material) => {
|
||||
if (mat instanceof THREE.MeshStandardMaterial) {
|
||||
|
||||
Reference in New Issue
Block a user