feat: 实现多行多列雪碧图的动画加载

This commit is contained in:
2026-01-20 22:46:55 +08:00
parent 8211e0b91b
commit 98c1d50ca5
2 changed files with 148 additions and 1 deletions

View File

@@ -0,0 +1,112 @@
<template>
<view class="sprite-animator" :style="spriteStyle" />
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
const props = withDefaults(defineProps<{
src: string
frameWidth: number
frameHeight: number
totalFrames: number
columns: number
fps?: number
displayWidth?: number
autoplay?: boolean
loop?: boolean
}>(), {
fps: 12,
autoplay: true,
loop: true,
})
/* JS 精确逐帧方案(使用 setInterval确保整数偏移、无插值 */
const currentFrame = ref(0)
let intervalId: number | null = null
const play = () => {
stop()
const safeFps = Math.max(1, Math.floor(props.fps || 12))
const durationPerFrame = 1000 / safeFps
// 强制整数像素偏移,使用 setInterval 在各运行时更可靠
const setter = (globalThis as any).setInterval || setInterval
intervalId = setter(() => {
const safeTotal = Math.max(1, Math.floor(props.totalFrames || 0))
if (currentFrame.value >= safeTotal - 1) {
if (props.loop) {
currentFrame.value = 0
} else {
stop()
return
}
} else {
currentFrame.value++
}
}, durationPerFrame) as unknown as number
}
const stop = () => {
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
}
onMounted(() => {
console.log('[SpriteAnimator] mounted', { autoplay: props.autoplay, fps: props.fps })
if (props.autoplay) play()
})
onUnmounted(() => stop())
const spriteStyle = computed(() => {
const {
src,
frameWidth: rawFrameWidth,
frameHeight: rawFrameHeight,
totalFrames: rawTotalFrames,
columns: rawColumns,
} = props
const frameWidth = Math.max(1, Math.floor(rawFrameWidth || 0))
const frameHeight = Math.max(1, Math.floor(rawFrameHeight || 0))
const totalFrames = Math.max(1, Math.floor(rawTotalFrames || 0))
let columns = Math.floor(Number(rawColumns) || 0)
if (!columns || columns <= 0) columns = Math.min(1, totalFrames)
columns = Math.min(columns, totalFrames)
const displayWidth = props.displayWidth || frameWidth
const rows = Math.ceil(totalFrames / columns)
const imageWidth = columns * frameWidth
const imageHeight = rows * frameHeight
const scale = displayWidth / frameWidth
const displayHeight = Math.round(frameHeight * scale)
const scaledImageWidth = Math.round(imageWidth * scale)
const scaledImageHeight = Math.round(imageHeight * scale)
if (currentFrame.value < 0) currentFrame.value = 0
if (currentFrame.value >= totalFrames) currentFrame.value = totalFrames - 1
const col = currentFrame.value % columns
const row = Math.floor(currentFrame.value / columns)
const offsetX = Math.round(col * frameWidth * scale)
const offsetY = Math.round(row * frameHeight * scale)
const styleStr = `width: ${Math.round(displayWidth)}px; height: ${displayHeight}px; background-image: url("${src}"); background-repeat: no-repeat; background-size: ${scaledImageWidth}px ${scaledImageHeight}px; background-position: -${offsetX}px -${offsetY}px;`
return styleStr as any
})
// 对外暴露
defineExpose({ play, stop, currentFrame })
</script>
<style scoped>
.sprite-animator {
display: block;
will-change: background-position;
}
</style>

View File

@@ -3,7 +3,17 @@ v
<view class="welcome-content border-box p-12"> <view class="welcome-content border-box p-12">
<view class="wrap rounded-20"> <view class="wrap rounded-20">
<view class="flex flex-items-center flex-justify-between border-box pl-12 pr-12"> <view class="flex flex-items-center flex-justify-between border-box pl-12 pr-12">
<view class="ip" :style="getStyle"></view> <!-- <view class="ip" :style="getStyle"></view> -->
<SpriteAnimator
class="welcome-animator"
:src="spriteStyle.welcomeImageUrl"
:frameWidth="spriteStyle.frameWidth"
:frameHeight="spriteStyle.frameHeight"
:totalFrames="spriteStyle.totalFrames"
:columns="spriteStyle.columns"
:displayWidth="spriteStyle.displayWidth"
:fps="16"
/>
<view class="welcome-text font-size-14 font-500 font-family-misans-vf color-171717 line-height-24"> <view class="welcome-text font-size-14 font-500 font-family-misans-vf color-171717 line-height-24">
{{ welcomeContent }} {{ welcomeContent }}
</view> </view>
@@ -18,6 +28,7 @@ v
import { defineProps, computed, getCurrentInstance, defineExpose } from "vue"; import { defineProps, computed, getCurrentInstance, defineExpose } from "vue";
import { getCurrentConfig } from "@/constant/base"; import { getCurrentConfig } from "@/constant/base";
import ChatMoreTips from "../ChatMoreTips/index.vue"; import ChatMoreTips from "../ChatMoreTips/index.vue";
import SpriteAnimator from "@/components/Sprite/SpriteAnimator.vue";
const props = defineProps({ const props = defineProps({
mainPageDataModel: { mainPageDataModel: {
@@ -51,6 +62,30 @@ const initPageImages = computed(() => {
const config = getCurrentConfig(); const config = getCurrentConfig();
const spriteStyle = computed(() => {
const images = initPageImages.value;
return {
welcomeImageUrl: 'https://oss.nianxx.cn/mp/static/version_101/zn/zn_large.png',
frameWidth: 395,
frameHeight: 335,
totalFrames: 71,
columns: 1,
displayWidth: 158,
};
});
// "ipLargeImage":"https://oss.nianxx.cn/mp/static/version_101/dh/dh_large.png",
// "ipLargeImageWidth": 395,
// "ipLargeImageHeight": 335,
// "ipLargeTotalFrames": 71,
// "ipLargeColumns": 1,
// "ipSmallImage":"https://oss.nianxx.cn/mp/static/version_101/dh/dh_small.png",
// "ipSmallImageWidth": 395,
// "ipSmallImageHeight": 335,
// "ipSmallTotalFrames": 71,
// "ipSmallColumns": 1
const getStyle = computed(() => { const getStyle = computed(() => {
const images = initPageImages.value; const images = initPageImages.value;
const style = { const style = {