feat: batched skin rendering - remove vzge references (apart from capes, wip)
This commit is contained in:
committed by
Alejandro González
parent
d7cf1d232b
commit
c5813a9435
287
apps/app-frontend/src/helpers/rendering/batchSkinRenderer.ts
Normal file
287
apps/app-frontend/src/helpers/rendering/batchSkinRenderer.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader, GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
import {Skin, determineModelType, get_available_skins} from '../skins';
|
||||
import {reactive} from "vue";
|
||||
|
||||
interface RenderResult {
|
||||
forwards: string;
|
||||
backwards: string;
|
||||
}
|
||||
|
||||
class BatchSkinRenderer {
|
||||
private renderer: THREE.WebGLRenderer;
|
||||
private readonly scene: THREE.Scene;
|
||||
private readonly camera: THREE.PerspectiveCamera;
|
||||
private modelCache: Map<string, GLTF> = new Map();
|
||||
private textureCache: Map<string, THREE.Texture> = new Map();
|
||||
private readonly width: number;
|
||||
private readonly height: number;
|
||||
private currentModel: THREE.Group | null = null;
|
||||
|
||||
constructor(width: number = 215, height: number = 645) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
|
||||
// Create canvas and renderer
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
canvas: canvas,
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
preserveDrawingBuffer: true
|
||||
});
|
||||
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
this.renderer.toneMapping = THREE.NoToneMapping;
|
||||
this.renderer.setClearColor(0x000000, 0);
|
||||
this.renderer.setSize(width, height);
|
||||
|
||||
this.scene = new THREE.Scene();
|
||||
this.camera = new THREE.PerspectiveCamera(40, width/height, 0.1, 1000);
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 2);
|
||||
this.scene.add(ambientLight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a GLTF model with caching
|
||||
*/
|
||||
private async loadModel(modelUrl: string): Promise<GLTF> {
|
||||
if (this.modelCache.has(modelUrl)) {
|
||||
return this.modelCache.get(modelUrl)!;
|
||||
}
|
||||
|
||||
const loader = new GLTFLoader();
|
||||
return new Promise<GLTF>((resolve, reject) => {
|
||||
loader.load(
|
||||
modelUrl,
|
||||
(gltf) => {
|
||||
this.modelCache.set(modelUrl, gltf);
|
||||
resolve(gltf);
|
||||
},
|
||||
undefined,
|
||||
reject
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a texture with caching
|
||||
*/
|
||||
private async loadTexture(textureUrl: string): Promise<THREE.Texture> {
|
||||
if (this.textureCache.has(textureUrl)) {
|
||||
return this.textureCache.get(textureUrl)!;
|
||||
}
|
||||
|
||||
return new Promise<THREE.Texture>((resolve) => {
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
textureLoader.load(textureUrl, (texture) => {
|
||||
// Apply texture settings
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.flipY = false;
|
||||
texture.magFilter = THREE.NearestFilter;
|
||||
texture.minFilter = THREE.NearestFilter;
|
||||
|
||||
this.textureCache.set(textureUrl, texture);
|
||||
resolve(texture);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a texture to all meshes in a model
|
||||
*/
|
||||
private 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) {
|
||||
mat.map = texture;
|
||||
mat.metalness = 0;
|
||||
mat.color.set(0xffffff);
|
||||
mat.toneMapped = false;
|
||||
mat.roughness = 1;
|
||||
mat.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders both forward and backward views of a skin
|
||||
*/
|
||||
public async renderSkin(textureUrl: string, modelUrl: string): Promise<RenderResult> {
|
||||
await this.setupModel(modelUrl, textureUrl);
|
||||
|
||||
// Create a bounding box for the entire model
|
||||
const bbox = new THREE.Box3().setFromObject(this.currentModel!);
|
||||
|
||||
// Calculate model dimensions
|
||||
const boxCenter = bbox.getCenter(new THREE.Vector3());
|
||||
const boxSize = bbox.getSize(new THREE.Vector3());
|
||||
|
||||
// Calculate optimal camera distance based on model height and canvas aspect ratio
|
||||
const aspectRatio = this.height / this.width;
|
||||
const verticalFOVRadians = this.camera.fov * Math.PI / 180;
|
||||
const cameraDistance = (boxSize.y * 1.5) / Math.tan(verticalFOVRadians / 2);
|
||||
|
||||
// Position camera perfectly front and back, with no side angle
|
||||
const frontCameraPos: [number, number, number] = [
|
||||
0, // No x-offset for straight-on view
|
||||
boxCenter.y + (boxSize.y * 0.1), // Slightly above center to frame face better
|
||||
boxCenter.z - cameraDistance // Front view (negative z)
|
||||
];
|
||||
|
||||
const backCameraPos: [number, number, number] = [
|
||||
0, // No x-offset for straight-on view
|
||||
boxCenter.y + (boxSize.y * 0.1), // Same height as front
|
||||
boxCenter.z + cameraDistance // Back view (positive z)
|
||||
];
|
||||
|
||||
// Look at the center of the model (vertically centered on face/upper torso)
|
||||
const lookAtPos: [number, number, number] = [
|
||||
boxCenter.x,
|
||||
boxCenter.y - (boxSize.y * 0.7),
|
||||
boxCenter.z
|
||||
];
|
||||
|
||||
// Pass these positions to renderView with the lookAt target
|
||||
const [forwards, backwards] = await Promise.all([
|
||||
this.renderView(frontCameraPos, lookAtPos),
|
||||
this.renderView(backCameraPos, lookAtPos)
|
||||
]);
|
||||
|
||||
return { forwards, backwards };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a view of the model and returns a blob URL
|
||||
* Updated to accept lookAt position
|
||||
*/
|
||||
private async renderView(cameraPosition: [number, number, number], lookAtPosition: [number, number, number]): Promise<string> {
|
||||
this.camera.position.set(...cameraPosition);
|
||||
this.camera.lookAt(...lookAtPosition);
|
||||
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
this.renderer.domElement.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
resolve(url);
|
||||
} else {
|
||||
reject(new Error("Failed to create blob from canvas"));
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a model with texture in the scene
|
||||
*/
|
||||
private async setupModel(modelUrl: string, textureUrl: string): Promise<void> {
|
||||
// Clean up previous model if it exists
|
||||
if (this.currentModel) {
|
||||
this.scene.remove(this.currentModel);
|
||||
}
|
||||
|
||||
const [gltf, texture] = await Promise.all([
|
||||
this.loadModel(modelUrl),
|
||||
this.loadTexture(textureUrl)
|
||||
]);
|
||||
|
||||
// Clone the model to avoid modifying the cached one
|
||||
const model = gltf.scene.clone();
|
||||
this.applyTexture(model, texture);
|
||||
|
||||
// Setup group and positioning
|
||||
const group = new THREE.Group();
|
||||
group.add(model);
|
||||
group.position.set(0, -0.5, 1.95);
|
||||
group.scale.set(0.8, 0.8, 0.8);
|
||||
|
||||
this.scene.add(group);
|
||||
this.currentModel = group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
public dispose(): void {
|
||||
Array.from(this.textureCache.values()).forEach(texture => {
|
||||
texture.dispose();
|
||||
});
|
||||
|
||||
this.renderer.dispose();
|
||||
this.textureCache.clear();
|
||||
this.modelCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate model URL based on skin variant
|
||||
*/
|
||||
function getModelUrlForVariant(variant: string): string {
|
||||
switch (variant) {
|
||||
case 'SLIM':
|
||||
return '/src/assets/models/slim_player.gltf';
|
||||
case 'CLASSIC':
|
||||
case 'UNKNOWN':
|
||||
default:
|
||||
return '/src/assets/models/classic_player.gltf';
|
||||
}
|
||||
}
|
||||
|
||||
export const map = reactive(new Map<string, RenderResult>());
|
||||
|
||||
/**
|
||||
* Generates skin previews for an array of skins
|
||||
* Renders both front and back views for each skin
|
||||
*
|
||||
* @param skins - Array of Skin objects to render
|
||||
* @returns A map of skin texture keys to their rendered front and back views
|
||||
*/
|
||||
export async function generateSkinPreviews(skins: Skin[]): Promise<void> {
|
||||
const renderer = new BatchSkinRenderer(215, 645);
|
||||
|
||||
try {
|
||||
// Process each skin
|
||||
for (const skin of skins) {
|
||||
// Skip if already in result map
|
||||
if (map.has(skin.texture_key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine model variant if unknown
|
||||
let variant = skin.variant;
|
||||
if (variant === 'UNKNOWN') {
|
||||
try {
|
||||
variant = await determineModelType(skin.texture);
|
||||
} catch (error) {
|
||||
console.error(`Failed to determine model type for skin ${skin.texture_key}:`, error);
|
||||
variant = 'CLASSIC'; // Fall back to classic
|
||||
}
|
||||
}
|
||||
|
||||
const modelUrl = getModelUrlForVariant(variant);
|
||||
|
||||
// Render the skin
|
||||
const renderResult = await renderer.renderSkin(skin.texture, modelUrl);
|
||||
|
||||
// Store in result map and cache
|
||||
map.set(skin.texture_key, renderResult);
|
||||
}
|
||||
} finally {
|
||||
// Clean up renderer resources
|
||||
renderer.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader, GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
|
||||
interface CameraSettings {
|
||||
fov: number;
|
||||
position: THREE.Vector3 | [number, number, number];
|
||||
lookAt?: THREE.Vector3 | [number, number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a screenshot of a 3D skin model with the given texture and returns a blob URL
|
||||
* Uses OffscreenCanvas for rendering and specifically handles GLTF models
|
||||
*
|
||||
* @param skinTexture - URL to the skin texture
|
||||
* @param gltfModelUrl - URL to the GLTF/GLB model
|
||||
* @param cameraSettings - Camera settings including fov and position
|
||||
* @param width - Width of the output image
|
||||
* @param height - Height of the output image
|
||||
* @returns Promise that resolves to a blob URL of the screenshot
|
||||
*/
|
||||
export async function bakeSkinRender(
|
||||
skinTexture: string,
|
||||
gltfModelUrl: string,
|
||||
cameraSettings: CameraSettings,
|
||||
width: number = 512,
|
||||
height: number = 512
|
||||
): Promise<string> {
|
||||
if (typeof OffscreenCanvas === 'undefined') {
|
||||
throw new Error('OffscreenCanvas is not supported in this browser');
|
||||
}
|
||||
|
||||
let canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
canvas: canvas,
|
||||
antialias: false,
|
||||
alpha: true,
|
||||
preserveDrawingBuffer: true
|
||||
});
|
||||
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.toneMapping = THREE.NoToneMapping;
|
||||
renderer.setSize(width, height);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
cameraSettings.fov,
|
||||
width / height,
|
||||
0.1,
|
||||
1000
|
||||
);
|
||||
|
||||
if (Array.isArray(cameraSettings.position)) {
|
||||
camera.position.set(...cameraSettings.position);
|
||||
} else {
|
||||
camera.position.copy(cameraSettings.position);
|
||||
}
|
||||
|
||||
if (cameraSettings.lookAt) {
|
||||
if (Array.isArray(cameraSettings.lookAt)) {
|
||||
camera.lookAt(new THREE.Vector3(...cameraSettings.lookAt));
|
||||
} else {
|
||||
camera.lookAt(cameraSettings.lookAt);
|
||||
}
|
||||
} else {
|
||||
camera.lookAt(0, 0, 0);
|
||||
}
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 2);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const [gltfResult, texture] = await Promise.all([
|
||||
loadGLTFModel(gltfModelUrl),
|
||||
loadTexture(skinTexture)
|
||||
]);
|
||||
|
||||
const model = gltfResult.scene;
|
||||
|
||||
applyTextureSettings(texture);
|
||||
|
||||
applyTextureToModel(model, texture);
|
||||
|
||||
const group = new THREE.Group();
|
||||
group.add(model);
|
||||
|
||||
group.position.set(0, -0.05, 1.95);
|
||||
group.scale.set(0.8, 0.8, 0.8);
|
||||
scene.add(group);
|
||||
|
||||
renderer.render(scene, camera);
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
resolve(url);
|
||||
} else {
|
||||
canvas.remove();
|
||||
reject(new Error("Failed to make blob."));
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
async function loadGLTFModel(url: string): Promise<GLTF> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const loader = new GLTFLoader();
|
||||
loader.load(
|
||||
url,
|
||||
(gltf) => resolve(gltf),
|
||||
undefined,
|
||||
(error) => reject(error)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTexture(url: string): Promise<THREE.Texture> {
|
||||
return new Promise((resolve) => {
|
||||
const textureLoader = new THREE.TextureLoader();
|
||||
textureLoader.load(url, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function applyTextureSettings(texture: THREE.Texture): void {
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.flipY = false;
|
||||
texture.magFilter = THREE.NearestFilter;
|
||||
texture.minFilter = THREE.NearestFilter;
|
||||
}
|
||||
|
||||
function applyTextureToModel(root: THREE.Object3D, texture: THREE.Texture): void {
|
||||
if (!root) return;
|
||||
|
||||
root.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) {
|
||||
mat.map = texture;
|
||||
mat.metalness = 0;
|
||||
mat.color.set(0xffffff);
|
||||
mat.toneMapped = false;
|
||||
mat.roughness = 1;
|
||||
mat.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: temp remove
|
||||
export async function testWithSteve() {
|
||||
const result = await bakeSkinRender(
|
||||
"/src/assets/skins/steve.png",
|
||||
"/src/assets/models/classic_player.gltf",
|
||||
{
|
||||
fov: 40,
|
||||
position: [0, 0, -3.25]
|
||||
},
|
||||
800, 600
|
||||
);
|
||||
|
||||
console.log(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { UpdatedIcon, PlusIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, SkinPreviewRenderer } from '@modrinth/ui'
|
||||
import { ButtonStyled, SkinPreviewRenderer, SkinButton, SkinLikeTextButton } from '@modrinth/ui'
|
||||
import {ref, computed, useTemplateRef, onMounted} from 'vue'
|
||||
import SkinButton from '@/components/ui/skin/SkinButton.vue'
|
||||
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
|
||||
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
|
||||
import { handleError } from '@/store/notifications'
|
||||
@@ -17,6 +16,7 @@ import { get as getCreds } from '@/helpers/mr_auth'
|
||||
import { get_user } from '@/helpers/cache'
|
||||
import type { Cape, Skin } from '@/helpers/skins.ts'
|
||||
import {get_default_user, users} from "@/helpers/auth";
|
||||
import {generateSkinPreviews, map} from "@/helpers/rendering/batchSkinRenderer.ts";
|
||||
|
||||
const editSkinModal = useTemplateRef('editSkinModal')
|
||||
const selectCapeModal = useTemplateRef('selectCapeModal')
|
||||
@@ -45,9 +45,8 @@ async function loadCapes() {
|
||||
}
|
||||
|
||||
async function loadSkins() {
|
||||
console.log(skins.value)
|
||||
skins.value = (await get_available_skins().catch(handleError)) ?? []
|
||||
console.log(skins.value)
|
||||
generateSkinPreviews(skins.value);
|
||||
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null;
|
||||
}
|
||||
|
||||
@@ -68,10 +67,10 @@ async function loadUsername() {
|
||||
const defaultId = await get_default_user()
|
||||
const allAccounts = await users();
|
||||
const current = allAccounts.find(acc => acc.profile.id === defaultId)
|
||||
username.value = current?.profile.name ?? null
|
||||
username.value = current?.profile?.name ?? undefined
|
||||
} catch (e) {
|
||||
handleError(e)
|
||||
username.value = null
|
||||
username.value = undefined
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -112,20 +111,21 @@ async function loadUsername() {
|
||||
<div class="flex flex-col gap-6 add-perspective">
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="text-lg font-bold m-0 text-primary">Saved skins</h2>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
class="flex flex-col gap-3 active:scale-95 hover:brightness-125 font-medium text-primary items-center justify-center border-2 border-transparent border-solid cursor-pointer h-40 bg-button-bg rounded-xl"
|
||||
@click="editSkinModal?.show"
|
||||
>
|
||||
<PlusIcon class="w-6 h-6" />
|
||||
<div class="flex flex-row flex-wrap gap-2">
|
||||
<SkinLikeTextButton @click="editSkinModal?.show" class="max-w-[9vw]" tooltip="Add a skin">
|
||||
<template #icon>
|
||||
<PlusIcon />
|
||||
</template>
|
||||
Add a skin
|
||||
</button>
|
||||
</SkinLikeTextButton>
|
||||
|
||||
<SkinButton
|
||||
v-for="skin in savedSkins"
|
||||
class="max-w-[9vw]"
|
||||
:key="`saved-skin-${skin.texture_key}`"
|
||||
editable
|
||||
:skin="skin"
|
||||
:forward-image-src="map.get(skin.texture_key)?.forwards ?? ''"
|
||||
:backward-image-src="map.get(skin.texture_key)?.backwards ?? ''"
|
||||
:selected="selectedSkin === skin"
|
||||
@select="changeSkin(skin)"
|
||||
@edit="e => editSkinModal?.show(e, skin)"
|
||||
@@ -135,12 +135,15 @@ async function loadUsername() {
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="text-lg font-bold m-0 text-primary">Default skins</h2>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div class="flex flex-row flex-wrap gap-2">
|
||||
<SkinButton
|
||||
v-for="skin in defaultSkins"
|
||||
class="max-w-[9vw]"
|
||||
:key="`default-skin-${skin.texture_key}`"
|
||||
:skin="skin"
|
||||
:forward-image-src="map.get(skin.texture_key)?.forwards ?? ''"
|
||||
:backward-image-src="map.get(skin.texture_key)?.backwards ?? ''"
|
||||
:selected="selectedSkin === skin"
|
||||
:tooltip="skin.name"
|
||||
@select="changeSkin(skin)"
|
||||
@edit="e => editSkinModal?.show(e, skin)"
|
||||
/>
|
||||
|
||||
@@ -103,6 +103,8 @@ export { default as ModrinthServersPurchaseModal } from './billing/ModrinthServe
|
||||
export { default as SkinPreviewRenderer } from "./skin/SkinPreviewRenderer.vue"
|
||||
export { default as CapeButton } from "./skin/CapeButton.vue"
|
||||
export { default as CapeLikeTextButton } from "./skin/CapeLikeTextButton.vue"
|
||||
export { default as SkinButton } from "./skin/SkinButton.vue"
|
||||
export { default as SkinLikeTextButton } from "./skin/SkinLikeTextButton.vue"
|
||||
|
||||
// Version
|
||||
export { default as VersionChannelIndicator } from './version/VersionChannelIndicator.vue'
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { ButtonStyled, commonMessages } from '@modrinth/ui'
|
||||
import { EditIcon } from '@modrinth/assets'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import type { Cape, Skin } from '@/helpers/skins.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -13,49 +12,28 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
skin: Skin
|
||||
forwardImageSrc: string
|
||||
backwardImageSrc: string
|
||||
selected: boolean
|
||||
defaultCape?: Cape
|
||||
editable?: boolean
|
||||
tooltip?: string
|
||||
}>(), {
|
||||
defaultCape: undefined,
|
||||
editable: false,
|
||||
})
|
||||
|
||||
const base64Prefix = 'data:image/png;base64,'
|
||||
const mcUrlRegex = /texture\/([a-fA-F0-9]+)$/
|
||||
|
||||
const texture = computed(() => {
|
||||
const mcTextureMatch = props.skin.texture.match(mcUrlRegex)
|
||||
|
||||
if (mcTextureMatch) {
|
||||
return mcTextureMatch[1]
|
||||
} else if (props.skin.texture.startsWith(base64Prefix)) {
|
||||
return props.skin.texture.split(base64Prefix)[1]
|
||||
} else {
|
||||
return props.skin.texture
|
||||
}
|
||||
})
|
||||
|
||||
const slim = computed(() => props.skin.variant === 'Slim')
|
||||
|
||||
const skinUrl = computed(
|
||||
() => `https://vzge.me/bust/${texture.value}.png?no=ears${slim.value ? '&slim' : ''}`,
|
||||
)
|
||||
const backUrl = computed(() => `${skinUrl.value}&y=130`)
|
||||
|
||||
const pressed = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="skin-button__parent group flex relative border-2 border-solid transform-3d rotate-y-90 transition-all h-40 p-0 bg-transparent rounded-xl overflow-hidden"
|
||||
class="skin-button__parent group flex relative border-2 border-solid transform-3d rotate-y-90 transition-all p-0 bg-transparent rounded-xl overflow-hidden w-full aspect-[2/3]"
|
||||
:class="[
|
||||
selected ? `border-brand` : 'border-transparent',
|
||||
{
|
||||
'scale-95': pressed,
|
||||
},
|
||||
]"
|
||||
v-tooltip="tooltip ?? undefined"
|
||||
>
|
||||
<button
|
||||
class="absolute inset-0 rounded-xl cursor-pointer p-0 border-none group-hover:brightness-125"
|
||||
@@ -65,21 +43,21 @@ const pressed = ref(false)
|
||||
@mouseleave="pressed = false"
|
||||
@click="emit('select')"
|
||||
></button>
|
||||
<span class="skin-button__image-parent pointer-events-none w-full h-full">
|
||||
<span class="skin-button__image-parent pointer-events-none w-full h-full flex flex-col justify-end">
|
||||
<img
|
||||
alt=""
|
||||
:src="skinUrl"
|
||||
class="skin-button__image-facing rounded-xl object-contain object-bottom w-full h-full mt-auto mx-auto"
|
||||
:src="forwardImageSrc"
|
||||
class="skin-button__image-facing rounded-xl object-contain w-full h-auto mx-auto mb-0"
|
||||
/>
|
||||
<img
|
||||
alt=""
|
||||
:src="backUrl"
|
||||
class="skin-button__image-away rounded-xl object-contain object-bottom w-full h-full mt-auto mx-auto"
|
||||
:src="backwardImageSrc"
|
||||
class="skin-button__image-away rounded-xl object-contain w-full h-auto mx-auto mb-0"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
v-if="editable"
|
||||
class="absolute pointer-events-none inset-0 flex items-end p-2 translate-y-4 -translate-x-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="absolute pointer-events-none inset-0 flex items-end justify-center p-2 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"
|
||||
>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
26
packages/ui/src/components/skin/SkinLikeTextButton.vue
Normal file
26
packages/ui/src/components/skin/SkinLikeTextButton.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
const pressed = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative w-full aspect-[2/3] border-2 border-transparent rounded-xl overflow-hidden"
|
||||
:class="{ 'scale-95': pressed }"
|
||||
>
|
||||
<button
|
||||
class="absolute inset-0 bg-button-bg rounded-xl cursor-pointer p-0 border-none hover:brightness-125"
|
||||
@mousedown="pressed = true"
|
||||
@mouseup="pressed = false"
|
||||
@mouseleave="pressed = false"
|
||||
></button>
|
||||
<div class="relative w-full h-full flex flex-col items-center justify-center pointer-events-none">
|
||||
<div class="mb-2">
|
||||
<slot name="icon"></slot>
|
||||
</div>
|
||||
<span class="text-md text-center px-2">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user