This commit is contained in:
2025-07-12 16:43:26 +08:00
parent 075fb02263
commit cb8d35f4b6
22 changed files with 1375 additions and 1098 deletions
+159 -6
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref } from 'vue';
import { ref, watch } from 'vue';
import Game from './pages/Game.vue';
import Page1 from './pages/Page1.vue';
import assets from './assets';
import Loader from './pages/Loader.vue';
const stage = ref(0);
@@ -15,26 +16,174 @@ const userData = ref({
function startExploration(payload: { region: string; store: string; username: string }) {
userData.value = payload;
stage.value = 1;
setTimeout(() => {
showPage1.value = false;
}, 1000);
}
function cloudUp() {
document.querySelectorAll('.cloud').forEach((ele) => {
ele.animate([
{ top: '300vw' },
], {
duration: 1000,
easing: 'ease-in',
fill: 'forwards',
});
});
}
function cloudDown() {
document.querySelectorAll('.cloud.all').forEach((ele) => {
ele.animate([
{ top: '-50vw' },
// @ts-ignore
{ top: ele.style.top },
], {
duration: 1000,
easing: 'ease-in',
fill: 'forwards',
});
});
}
let audio = new Audio(assets.bgm);
audio.loop = true;
audio.volume = 0.5;
const playingBgm = ref(false);
watch(playingBgm, (val) => {
if (val) {
audio.play();
} else {
audio.pause();
}
});
//std radio is 2462 / 1179
const scale = ref(1);
const onResize = () => {
let radio = window.innerHeight / window.innerWidth;
let stdRadio = 2462 / 1179;
if (radio < stdRadio * 0.88) {
scale.value = radio / stdRadio;
} else {
scale.value = 1;
}
};
window.addEventListener('resize', onResize);
onResize();
const showPage1 = ref(true);
</script>
<template>
<link v-for="ani in Object.values(assets.ani)" rel="preload" :href="ani" as="fetch">
<main>
<Page1 class="page" v-if="stage == 0" @start-exploration="startExploration"/>
<Game class="page" v-if="stage == 1" :userdata="userData"/>
<main :style="{ scale: scale }">
<div class="bg"> </div>
<img class="cloud cloud-top" src="./assets/game/云.png" v-for="c in [
{ w: 60, t: 10, l: 75, all: true },
{ w: 75, t: 55, l: -45 },
{ w: 40, t: 105, l: 35 },
]" :style="{ width: `${c.w}vw`, top: `${c.t}vw`, left: `${c.l}vw` }"
:class="{ all: c.all, left: c.l < 0, right: c.l > 0 }"></img>
<img class="cloud cloud-bottom" src="./assets/game/云.png" v-for="c in [
{ w: 75, t: 165, l: -35, all: true },
{ w: 100, t: 144, l: 60, all: true },
]" :style="{ width: `${c.w}vw`, top: `${c.t}vw`, left: `${c.l}vw` }"
:class="{ all: c.all, left: c.l < 0, right: c.l > 0 }"></img>
<Loader class="page" v-if="stage == -1" @loaded="stage = 0" />
<Page1 class="page page-1" v-if="stage >= 0&&showPage1" @start-exploration="startExploration"
:style="{ opacity: stage === 0 ? 1 : 0 }" />
<Game class="page" v-if="stage == 1" :userdata="userData"
@cloud-down="cloudDown" @cloud-up="cloudUp" />
<div class="overlay page">
<div class="bgm"
style="position: absolute; top: 16vw;right: 8vw;width: 8vw; cursor: pointer;pointer-events: all;">
<img src="./assets/开启音乐.png" @click="playingBgm = false" alt=""
v-if="playingBgm">
<img src="./assets/关掉音乐.png" @click="playingBgm = true" alt="" v-else>
</div>
</div>
</main>
</template>
<style scoped>
.overlay {
pointer-events: none;
}
main {
height: 100vh;
height: 100dvh;
width: 100vw;
overflow: hidden;
overflow: visible;
position: relative;
}
.bgm {
img {
width: 100%;
}
}
@keyframes left-slide-in {
from {
transform: translateX(-300%);
}
to {
transform: translateX(0);
}
}
@keyframes right-slide-in {
from {
transform: translateX(300%);
}
to {
transform: translateX(0);
}
}
.cloud {
position: absolute;
&.left {
animation: left-slide-in 0.8s forwards;
}
&.right {
animation: right-slide-in 0.8s forwards;
}
}
.bg {
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
height: calc(2462 / 1179 * 100vw);
width: 100%;
z-index: -1;
background-image: url('./assets/game/蓝天.png');
background-size: cover;
background-position: center;
}
.page {
position: absolute;
top: 50%;
@@ -42,6 +191,10 @@ main {
transform: translateY(-50%);
width: 100%;
height: calc(2462 / 1179 * 100vw);
}
.page-1{
transition: 0.7s all;
}
</style>
Binary file not shown.
+2 -1
View File
@@ -42,5 +42,6 @@ export default {
[new URL('./game/metal_0.png', import.meta.url).href,],
[new URL('./game/metal_1.png', import.meta.url).href,],
[new URL('./game/metal_2.png', import.meta.url).href,]
]
],
bgm: new URL('./bgm.mp3', import.meta.url).href,
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+92 -67
View File
@@ -35,7 +35,7 @@ const props = withDefaults(defineProps<Props>(), {
autoPlay: true
})
// 响应式状态 (不变)
// 响应式状态
const loading = ref(false)
const error = ref('')
const progress = ref(0)
@@ -53,6 +53,9 @@ const currentLoopCount = ref(0)
const animationId = ref<number>()
const lastFrameTime = ref(0)
const pendingJumpTo = ref<string>()
// 【新增】: 用于存储 soft jump 的 Promise resolve 函数
const pendingJumpResolver = ref<((success: boolean) => void) | null>(null)
// loadImageSequence 函数 (不变)
const loadImageSequence = async (zipUrl: string) => {
@@ -68,7 +71,7 @@ const loadImageSequence = async (zipUrl: string) => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
111
const zipBlob = await response.blob()
progress.value = 30
console.log(`加载压缩包: ${zipUrl}, 大小: ${(zipBlob.size / 1024).toFixed(2)} KB`);
@@ -144,10 +147,6 @@ const drawFrame = (frameIndex: number) => {
if (!canvasRef.value || !loadedImages.value.length) return
const safeIndex = Math.max(0, Math.min(frameIndex, loadedImages.value.length - 1))
if (props.log) {
// console.log(`绘制帧: ${safeIndex}, 总帧数: ${loadedImages.value.length}`);
}
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
if (!ctx) return
@@ -160,12 +159,17 @@ const drawFrame = (frameIndex: number) => {
}
// =================================================================
// 核心修复区域 1: jumpToRule 函数
// 核心修改区域 1: jumpToRule 函数
// =================================================================
const jumpToRule = (ruleName: string) => {
const jumpToRule = (ruleName: string): boolean => {
const ruleIndex = findRuleIndex(ruleName)
if (ruleIndex === -1) {
console.warn(`未找到名为 "${ruleName}" 的规则`)
// 如果有待处理的Promise,则拒绝它
if (pendingJumpResolver.value) {
pendingJumpResolver.value(false); // resolve(false) 表示失败
pendingJumpResolver.value = null;
}
return false
}
if (props.log) console.log(`跳转到规则 "${ruleName}" (索引: ${ruleIndex})`);
@@ -185,7 +189,6 @@ const jumpToRule = (ruleName: string) => {
}
}
// 【修复】: 如果规则是反向的,则从结束帧开始播放
const endFrame = targetRule.endFrame ?? (startFrame + targetRule.frame - 1);
currentFrame.value = targetRule.reverse ? endFrame : startFrame;
@@ -201,12 +204,16 @@ const jumpToRule = (ruleName: string) => {
lastFrameTime.value = performance.now()
animate()
// 【修改】: 跳转成功,兑现Promise
if (pendingJumpResolver.value) {
pendingJumpResolver.value(true);
pendingJumpResolver.value = null;
}
return true
}
// =================================================================
// 核心修复区域 2: startAnimation 函数
// =================================================================
// startAnimation 函数 (不变)
const startAnimation = () => {
if (!props.rules.length || !loadedImages.value.length || isPlaying.value) return
@@ -225,7 +232,6 @@ const startAnimation = () => {
}
}
// 【修复】: 如果规则是反向的,则从结束帧开始播放
const endFrame = currentRule.endFrame ?? (startFrame + currentRule.frame - 1);
currentFrame.value = currentRule.reverse ? endFrame : startFrame;
@@ -235,9 +241,7 @@ const startAnimation = () => {
animate()
}
// =================================================================
// 核心修复区域 3: animate 函数
// =================================================================
// animate 函数 (不变)
const animate = () => {
if (!isPlaying.value || currentRuleIndex.value >= props.rules.length) {
animationId.value = undefined;
@@ -253,7 +257,6 @@ const animate = () => {
if (now - lastFrameTime.value >= frameDuration) {
lastFrameTime.value = now - (now - lastFrameTime.value) % frameDuration;
// 1. 计算当前规则的帧范围
let startFrame = currentRule.startFrame ?? 0;
if (currentRule.startFrame === undefined) {
for (let i = 0; i < currentRuleIndex.value; i++) {
@@ -262,78 +265,61 @@ const animate = () => {
}
const endFrame = currentRule.endFrame ?? (startFrame + currentRule.frame - 1);
// 2. 计算下一帧
let nextFrame = currentFrame.value + (currentRule.reverse ? -1 : 1);
// 3. 检查是否超出当前规则的边界
const isEndOfRuleSegment = currentRule.reverse ? (nextFrame < startFrame) : (nextFrame > endFrame);
if (isEndOfRuleSegment) {
// 到达边界,完成了一轮循环
currentLoopCount.value++;
if (pendingJumpTo.value) {
const targetRule = pendingJumpTo.value;
pendingJumpTo.value = undefined;
if (props.log) console.log(`软跳转触发,目标规则 "${targetRule}"`);
jumpToRule(targetRule);
return;
}
const isRuleFinished = currentRule.loop > 0 && currentLoopCount.value >= currentRule.loop;
if (isRuleFinished) {
// 规则已完成,准备过渡到下一条
if (pendingJumpTo.value) {
const targetRule = pendingJumpTo.value;
pendingJumpTo.value = undefined;
jumpToRule(targetRule);
return; // jumpToRule会启动新的animate, 故此处直接返回
}
if (currentRule.pauseAfter) {
isPaused.value = true;
isPlaying.value = false;
if(props.log) console.log(`规则 "${currentRule.name}" 完成,暂停等待跳转指令`);
// 定格在当前规则的最后一帧
drawFrame(currentRule.reverse ? startFrame : endFrame);
return;
}
// 自动进入下一个规则
currentRuleIndex.value++;
currentLoopCount.value = 0;
if (currentRuleIndex.value >= props.rules.length) {
// 所有规则播放完毕
isPlaying.value = false;
if(props.log) console.log('所有规则播放完毕');
// 定格在最后一个规则的最后一帧
drawFrame(currentRule.reverse ? startFrame : endFrame);
return;
}
// 【修复】: 为下一个规则设置正确的起始帧
const nextRule = props.rules[currentRuleIndex.value];
let nextRuleStartFrame = nextRule.startFrame ?? 0;
if (nextRule.startFrame === undefined) {
// 使用刚结束的规则的结束帧的下一帧作为起点
nextRuleStartFrame = endFrame + 1;
}
let nextRuleStartFrame = nextRule.startFrame ?? (endFrame + 1);
const nextRuleEndFrame = nextRule.endFrame ?? (nextRuleStartFrame + nextRule.frame - 1);
// 根据新规则的 reverse 属性设置 currentFrame
currentFrame.value = nextRule.reverse ? nextRuleEndFrame : nextRuleStartFrame;
if(props.log) console.log(`进入下一规则 "${nextRule.name}", 实际开始帧: ${currentFrame.value}`);
} else {
// 规则未完成(无限循环或循环次数未到),循环播放
// 【修复】: 重置循环时,也需要根据 reverse 属性设置正确的帧
currentFrame.value = currentRule.reverse ? endFrame : startFrame;
}
} else {
// 仍在当前规则内,正常播放
currentFrame.value = nextFrame;
}
// 4. 绘制计算出的最终帧
drawFrame(currentFrame.value);
}
};
// stopAnimation 函数 (不变)
// stopAnimation 函数 (增加清理逻辑)
const stopAnimation = () => {
isPlaying.value = false
isPaused.value = false
@@ -341,21 +327,27 @@ const stopAnimation = () => {
cancelAnimationFrame(animationId.value)
animationId.value = undefined
}
// 【修改】: 清理待处理的跳转
if (pendingJumpResolver.value) {
pendingJumpResolver.value(false); // 动画停止,视为跳转失败
pendingJumpResolver.value = null;
}
pendingJumpTo.value = undefined;
}
// resetAnimation 函数 (不变)
// resetAnimation 函数 (增加清理逻辑)
const resetAnimation = () => {
stopAnimation()
currentRuleIndex.value = 0
currentFrame.value = 0
currentLoopCount.value = 0
pendingJumpTo.value = undefined
// pendingJumpTo 和 pendingJumpResolver 已在 stopAnimation 中清理
if (loadedImages.value.length > 0) {
drawFrame(0)
}
}
// togglePlayback, setJumpTarget, jumpToRuleImmediately, resumeFromPause (不变)
// togglePlayback, resumeFromPause (不变)
const togglePlayback = () => {
if (isPlaying.value) {
stopAnimation()
@@ -364,18 +356,6 @@ const togglePlayback = () => {
}
}
const setJumpTarget = (ruleName: string) => {
if (isPaused.value) {
jumpToRule(ruleName)
} else {
pendingJumpTo.value = ruleName
}
}
const jumpToRuleImmediately = (ruleName: string) => {
return jumpToRule(ruleName)
}
const resumeFromPause = () => {
if (isPaused.value) {
isPaused.value = false
@@ -385,12 +365,55 @@ const resumeFromPause = () => {
}
}
// =================================================================
// 核心修改区域 2: 跳转方法
// =================================================================
/**
* 立即跳转到指定规则,会打断当前动画。
* @param {string} ruleName 要跳转到的规则名。
*/
const jumpTo = (ruleName: string) => {
return jumpToRule(ruleName)
}
// watch 和 onMounted (微调清理逻辑)
/**
* “软”跳转。该跳转会等到当前规则循环结束后再执行。
* 如果动画已暂停或停止,则立即跳转。
* @param {string} ruleName 要跳转到的规则名。
* @returns {Promise<boolean>} 一个Promise,当成功跳转到目标规则时 resolve(true),否则 resolve(false)。
*/
const jumpToSoftly = (ruleName: string): Promise<boolean> => {
return new Promise((resolve) => {
const ruleIndex = findRuleIndex(ruleName);
if (ruleIndex === -1) {
console.warn(`无法设置软跳转:未找到规则 "${ruleName}"。`);
resolve(false);
return;
}
// 如果之前有待处理的跳转,先将其标记为失败
if (pendingJumpResolver.value) {
pendingJumpResolver.value(false);
}
// 设置新的 Promise resolver
pendingJumpResolver.value = resolve;
if (isPaused.value || !isPlaying.value) {
if(props.log) console.log(`动画已暂停或停止。立即执行跳转到 "${ruleName}"。`);
// jumpToRule 将会调用并清理 pendingJumpResolver
jumpToRule(ruleName);
} else if (isPlaying.value) {
if(props.log) console.log(`已计划软跳转到 "${ruleName}"。将在当前循环结束后触发。`);
pendingJumpTo.value = ruleName;
}
});
}
// watch 和生命周期钩子 (不变)
watch(() => props.url, (newUrl) => {
if (newUrl) {
stopAnimation()
// 清理旧的图片数据
resetAnimation() // 使用 reset 来确保清理
images.value.forEach(url => URL.revokeObjectURL(url));
images.value = [];
loadedImages.value = [];
@@ -408,16 +431,16 @@ watch(() => props.rules, () => {
}, { deep: true })
onBeforeUnmount(() => {
stopAnimation();
// 组件卸载时,确保释放所有Blob URL
resetAnimation(); // 使用 reset 来确保清理
images.value.forEach(url => URL.revokeObjectURL(url));
});
// findRuleIndex 和 defineExpose (不变)
// findRuleIndex (不变)
const findRuleIndex = (ruleName: string): number => {
return props.rules.findIndex(rule => rule.name === ruleName)
}
// defineExpose (修改)
defineExpose({
images,
loadedImages,
@@ -434,8 +457,10 @@ defineExpose({
reset: resetAnimation,
toggle: togglePlayback,
resume: resumeFromPause,
jumpTo: jumpToRuleImmediately,
setJumpTarget,
/** 立即跳转,会打断当前动画。 */
jumpTo,
/** 在当前动画循环结束后跳转,返回一个Promise。 */
jumpToSoftly,
drawFrame: (frameIndex: number) => drawFrame(frameIndex),
findRuleIndex,
getRules: () => props.rules,
-168
View File
@@ -1,168 +0,0 @@
<!-- AniLoader.vue -->
<template>
<!-- This component is renderless; it just provides context -->
<slot :is-all-loaded="isAllLoaded" :progress="overallProgress"></slot>
</template>
<script setup lang="ts">
import { ref, provide, onMounted, computed, watch } from 'vue'
import JSZip from 'jszip'
import { animationStore, AnimationStoreKey, type AnimationData } from './animation-store'
interface Asset {
id: string; // A unique identifier for the animation, e.g., 'character-jump'
url: string; // The URL to the .zip file
}
interface Props {
assets: Asset[];
autoLoad?: boolean; // Start loading immediately on mount
}
const props = withDefaults(defineProps<Props>(), {
autoLoad: true,
})
const totalAssets = ref(props.assets.length);
const loadedAssetsCount = ref(0);
// Provide the reactive store to all descendant components
provide(AnimationStoreKey, animationStore)
const isAllLoaded = computed(() => {
if (totalAssets.value === 0) return true;
// Check if every asset in the store that should be loaded is actually loaded.
return props.assets.every(asset => animationStore.get(asset.id)?.status === 'loaded');
});
const overallProgress = computed(() => {
if (totalAssets.value === 0) return 100;
let totalProgress = 0;
for (const asset of props.assets) {
totalProgress += animationStore.get(asset.id)?.progress ?? 0;
}
return totalProgress / totalAssets.value;
});
const loadAndProcessAnimation = async (id: string, url: string) => {
// Prevent re-loading if already loaded or loading
if (animationStore.has(id) && animationStore.get(id)?.status !== 'error') {
if(animationStore.get(id)?.status === 'loaded') {
loadedAssetsCount.value++;
}
return;
}
// Set initial state in the store
animationStore.set(id, {
status: 'loading',
frames: [],
width: 0,
height: 0,
totalFrames: 0,
progress: 0,
});
const updateProgress = (p: number) => {
const data = animationStore.get(id);
if (data) data.progress = p;
};
try {
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
updateProgress(15);
const zipBlob = await response.blob()
const zip = new JSZip()
const zipData = await zip.loadAsync(zipBlob)
updateProgress(30);
const imageFiles = Object.keys(zipData.files)
.filter(name => !zipData.files[name].dir && /\.(webp|png|jpg)$/i.test(name))
.sort((a, b) => {
const numA = parseInt(a.match(/(\d+)\.\w+$/)?.[1] || '0')
const numB = parseInt(b.match(/(\d+)\.\w+$/)?.[1] || '0')
return numA - numB
})
if (imageFiles.length === 0) throw new Error('No supported image files found in ZIP.')
const blobUrls = await Promise.all(
imageFiles.map(name => zipData.files[name].async('blob').then(URL.createObjectURL))
);
updateProgress(50);
const imageDataArray: ImageData[] = [];
const offscreenCanvas = document.createElement('canvas');
const offscreenCtx = offscreenCanvas.getContext('2d', { willReadFrequently: true });
if (!offscreenCtx) throw new Error("Failed to create offscreen canvas context.");
let canvasWidth = 0, canvasHeight = 0;
for (let i = 0; i < blobUrls.length; i++) {
const img = new Image();
img.src = blobUrls[i];
await new Promise<void>((resolve, reject) => {
img.onload = () => {
if (i === 0) {
canvasWidth = img.width;
canvasHeight = img.height;
offscreenCanvas.width = canvasWidth;
offscreenCanvas.height = canvasHeight;
}
offscreenCtx.clearRect(0, 0, canvasWidth, canvasHeight);
offscreenCtx.drawImage(img, 0, 0, canvasWidth, canvasHeight);
imageDataArray.push(offscreenCtx.getImageData(0, 0, canvasWidth, canvasHeight));
URL.revokeObjectURL(img.src);
resolve();
};
img.onerror = reject;
});
updateProgress(50 + (i + 1) / blobUrls.length * 50);
}
// Update store with final data
animationStore.set(id, {
status: 'loaded',
frames: imageDataArray,
width: canvasWidth,
height: canvasHeight,
totalFrames: imageDataArray.length,
progress: 100
});
loadedAssetsCount.value++;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown loading error';
console.error(`Failed to load animation "${id}":`, err);
animationStore.set(id, {
status: 'error',
error: message,
frames: [], width: 0, height: 0, totalFrames: 0, progress: 0
});
}
}
const loadAll = () => {
props.assets.forEach(asset => loadAndProcessAnimation(asset.id, asset.url));
}
// Watch for changes in assets prop to dynamically load new ones
watch(() => props.assets, (newAssets) => {
totalAssets.value = newAssets.length;
if (props.autoLoad) {
loadAll();
}
}, { deep: true, immediate: true });
// Expose the loader function for manual triggering
defineExpose({
loadAll,
isAllLoaded,
animationStore
});
</script>
-302
View File
@@ -1,302 +0,0 @@
<!-- AniPlayer.vue -->
<template>
<div class="ani-ele">
<div v-if="loading">Loading Animation...</div>
<div v-else-if="error">{{ error }}</div>
<canvas v-else ref="canvasRef" :width="canvasWidth" :height="canvasHeight"></canvas>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onBeforeUnmount, inject, computed, nextTick } from 'vue'
import { AnimationStoreKey } from './animation-store'
// Rules interface remains the same
interface AnimationRule {
name: string; frame: number; duration?: number; loop: number;
startFrame?: number; endFrame?: number; reverse?: boolean;
pauseAfter?: boolean;
}
interface Props {
animationId: string; // The ID to look up in the store
rules: AnimationRule[];
width?: number; // Optional override for width
height?: number; // Optional override for height
autoPlay?: boolean;
log?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
autoPlay: true,
});
// Inject the central store
const animationStore = inject(AnimationStoreKey);
if (!animationStore) {
throw new Error("AniPlayer must be used within an AniLoader provider.");
}
// --- Reactive state derived from the store ---
const animationData = computed(() => animationStore.get(props.animationId));
const loading = computed(() => !animationData.value || animationData.value.status === 'loading');
const error = computed(() => animationData.value?.status === 'error' ? animationData.value.error : '');
const loadedImages = computed(() => animationData.value?.frames ?? []);
// Canvas dimensions are derived from loaded data but can be overridden by props
const canvasWidth = computed(() => props.width ?? animationData.value?.width ?? 300);
const canvasHeight = computed(() => props.height ?? animationData.value?.height ?? 150);
// --- Playback State (mostly unchanged) ---
const canvasRef = ref<HTMLCanvasElement>()
const isPlaying = ref(false)
const isPaused = ref(false)
const currentRuleIndex = ref(0)
const currentFrame = ref(0)
const currentLoopCount = ref(0)
const animationId = ref<number>()
const lastFrameTime = ref(0)
const pendingJumpTo = ref<string>()
// --- Core Logic (unchanged, but now operates on computed refs) ---
const drawFrame = (frameIndex: number) => {
if (!canvasRef.value || loadedImages.value.length === 0) return;
const safeIndex = Math.max(0, Math.min(frameIndex, loadedImages.value.length - 1));
const ctx = canvasRef.value.getContext('2d');
if (!ctx) return;
const imageData = loadedImages.value[safeIndex];
if (imageData) {
ctx.putImageData(imageData, 0, 0);
}
};
const jumpToRule = (ruleName: string) => { /* ... (code is identical to previous version) ... */
const ruleIndex = findRuleIndex(ruleName)
if (ruleIndex === -1) {
console.warn(`未找到名为 "${ruleName}" 的规则`)
return false
}
if (props.log) console.log(`跳转到规则 "${ruleName}" (索引: ${ruleIndex})`);
if (isPlaying.value) {
stopAnimation()
}
currentRuleIndex.value = ruleIndex
currentLoopCount.value = 0
const targetRule = props.rules[ruleIndex]
let startFrame = targetRule.startFrame ?? 0
if (targetRule.startFrame === undefined) {
for (let i = 0; i < ruleIndex; i++) {
startFrame += props.rules[i].frame
}
}
const endFrame = targetRule.endFrame ?? (startFrame + targetRule.frame - 1);
currentFrame.value = targetRule.reverse ? endFrame : startFrame;
if (props.log) {
console.log(`跳转到规则 "${ruleName}", 实际开始帧: ${currentFrame.value}, 规则帧范围: ${startFrame} - ${endFrame}`);
}
drawFrame(currentFrame.value)
isPaused.value = false
pendingJumpTo.value = undefined
isPlaying.value = true
lastFrameTime.value = performance.now()
animate()
return true
};
const startAnimation = () => { /* ... (code is identical to previous version) ... */
if (!props.rules.length || loadedImages.value.length === 0 || isPlaying.value) return
isPlaying.value = true
isPaused.value = false
if (currentRuleIndex.value >= props.rules.length) {
resetAnimation()
}
const currentRule = props.rules[currentRuleIndex.value]
let startFrame = currentRule.startFrame ?? 0
if (currentRule.startFrame === undefined) {
for (let i = 0; i < currentRuleIndex.value; i++) {
startFrame += props.rules[i].frame
}
}
const endFrame = currentRule.endFrame ?? (startFrame + currentRule.frame - 1);
currentFrame.value = currentRule.reverse ? endFrame : startFrame;
if (props.log) console.log(`开始播放规则 "${currentRule.name}", 实际开始帧: ${currentFrame.value}`);
lastFrameTime.value = performance.now()
animate()
};
const animate = () => { /* ... (code is identical to previous version) ... */
if (!isPlaying.value || currentRuleIndex.value >= props.rules.length) {
animationId.value = undefined;
return;
}
animationId.value = requestAnimationFrame(animate);
const now = performance.now();
const currentRule = props.rules[currentRuleIndex.value];
const frameDuration = currentRule.duration ?? 33;
if (now - lastFrameTime.value >= frameDuration) {
lastFrameTime.value = now - (now - lastFrameTime.value) % frameDuration;
let startFrame = currentRule.startFrame ?? 0;
if (currentRule.startFrame === undefined) {
for (let i = 0; i < currentRuleIndex.value; i++) {
startFrame += props.rules[i].frame;
}
}
const endFrame = currentRule.endFrame ?? (startFrame + currentRule.frame - 1);
let nextFrame = currentFrame.value + (currentRule.reverse ? -1 : 1);
const isEndOfRuleSegment = currentRule.reverse ? (nextFrame < startFrame) : (nextFrame > endFrame);
if (isEndOfRuleSegment) {
currentLoopCount.value++;
const isRuleFinished = currentRule.loop > 0 && currentLoopCount.value >= currentRule.loop;
if (isRuleFinished) {
if (pendingJumpTo.value) {
const targetRule = pendingJumpTo.value;
pendingJumpTo.value = undefined;
jumpToRule(targetRule);
return;
}
if (currentRule.pauseAfter) {
isPaused.value = true;
isPlaying.value = false;
if (props.log) console.log(`规则 "${currentRule.name}" 完成,暂停等待跳转指令`);
drawFrame(currentRule.reverse ? startFrame : endFrame);
return;
}
currentRuleIndex.value++;
currentLoopCount.value = 0;
if (currentRuleIndex.value >= props.rules.length) {
isPlaying.value = false;
if (props.log) console.log('所有规则播放完毕');
drawFrame(currentRule.reverse ? startFrame : endFrame);
return;
}
const nextRule = props.rules[currentRuleIndex.value];
let nextRuleStartFrame = nextRule.startFrame ?? 0;
if (nextRule.startFrame === undefined) {
nextRuleStartFrame = endFrame + 1;
}
const nextRuleEndFrame = nextRule.endFrame ?? (nextRuleStartFrame + nextRule.frame - 1);
currentFrame.value = nextRule.reverse ? nextRuleEndFrame : nextRuleStartFrame;
if (props.log) console.log(`进入下一规则 "${nextRule.name}", 实际开始帧: ${currentFrame.value}`);
} else {
currentFrame.value = currentRule.reverse ? endFrame : startFrame;
}
} else {
currentFrame.value = nextFrame;
}
drawFrame(currentFrame.value);
}
};
const stopAnimation = () => { /* ... (code is identical) ... */
isPlaying.value = false
isPaused.value = false
if (animationId.value) {
cancelAnimationFrame(animationId.value)
animationId.value = undefined
}
};
const resetAnimation = () => { /* ... (code is identical) ... */
stopAnimation()
currentRuleIndex.value = 0
currentFrame.value = 0
currentLoopCount.value = 0
pendingJumpTo.value = undefined
if (loadedImages.value.length > 0) {
drawFrame(0)
}
};
const findRuleIndex = (ruleName: string): number => { /* ... (code is identical) ... */
return props.rules.findIndex(rule => rule.name === ruleName)
};
// Other exposed methods...
const togglePlayback = () => { if (isPlaying.value) { stopAnimation() } else { startAnimation() } };
const setJumpTarget = (ruleName: string) => { if (isPaused.value) { jumpToRule(ruleName) } else { pendingJumpTo.value = ruleName } };
const jumpToRuleImmediately = (ruleName: string) => { return jumpToRule(ruleName) };
const resumeFromPause = () => { if (isPaused.value) { isPaused.value = false; isPlaying.value = true; lastFrameTime.value = performance.now(); animate() } };
// Watch for the data to become available to start the animation
watch(loadedImages, (newFrames, oldFrames) => {
// Only trigger if we're transitioning from 'no frames' to 'having frames'
if (newFrames.length > 0 && oldFrames.length === 0) {
resetAnimation();
// Use nextTick to ensure canvas is rendered before drawing
nextTick(() => {
drawFrame(0); // Draw the first frame immediately
if (props.autoPlay) {
startAnimation();
}
});
}
});
// Watch for rules changing
watch(() => props.rules, () => {
if (loadedImages.value.length > 0) {
resetAnimation();
if (props.autoPlay) {
startAnimation();
}
}
}, { deep: true });
onBeforeUnmount(() => {
stopAnimation();
});
defineExpose({
// Expose control methods
play: startAnimation,
pause: stopAnimation,
reset: resetAnimation,
toggle: togglePlayback,
resume: resumeFromPause,
jumpTo: jumpToRuleImmediately,
setJumpTarget,
drawFrame,
// Expose state
isPlaying,
isPaused,
currentFrame,
getCurrentRule: () => props.rules[currentRuleIndex.value],
});
</script>
<style scoped>
.ani-ele {
/* Add styling for loading/error states if desired */
min-height: 100px;
/* Example */
display: flex;
justify-content: center;
align-items: center;
}
canvas {
width: 100%;
height: auto;
object-fit: contain;
}
</style>
-23
View File
@@ -1,23 +0,0 @@
// src/composables/animation-store.ts (or wherever you prefer)
import { reactive } from 'vue'
import type { InjectionKey } from 'vue'
// Defines the data structure for a single loaded animation
export interface AnimationData {
status: 'idle' | 'loading' | 'loaded' | 'error';
frames: ImageData[];
width: number;
height: number;
totalFrames: number;
error?: string;
progress: number;
}
// Defines the store, which is a map from a unique animation ID to its data
export type AnimationStore = Map<string, AnimationData>
// Create a reactive store instance
export const animationStore = reactive<AnimationStore>(new Map())
// Create a unique Symbol as the injection key. This is best practice.
export const AnimationStoreKey: InjectionKey<AnimationStore> = Symbol('AnimationStore')
+299 -182
View File
@@ -16,15 +16,15 @@ const sunEle = useTemplateRef('sun-ani');
const sunEndEle = useTemplateRef('sun-ani-end');
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
const emit = defineEmits(['cloudUp', 'cloudDown', 'restart']);
async function shoot() {
//sunEle.value?.jumpTo('天上飞')
sunEle.value?.jumpTo('蓄力飞')
await document.querySelector('.sun-ani-wrapper')?.animate([
{ transform: 'translateY(0)' },
{ transform: 'translateY(8%)' },
{ transform: 'translateY(0) translateX(-50%)' },
{ transform: 'translateY(8%) translateX(-50%)' },
], {
duration: 900,
easing: 'linear',
@@ -41,15 +41,7 @@ async function shoot() {
}).finished.then(() => {
document.querySelector('.arrow')?.remove();
});
document.querySelectorAll('.cloud').forEach((ele) => {
ele.animate([
{ top: '300vw' },
], {
duration: 1000,
easing: 'ease-in',
fill: 'forwards',
});
});
emit('cloudUp');
document.querySelector('.bed')?.animate([
{ transform: 'translateY(0)' },
{ transform: 'translateY(150%)' },
@@ -61,38 +53,30 @@ async function shoot() {
});
await document.querySelector('.sun-ani-wrapper')?.animate([
{ transform: 'translateY(8%)' },
{ transform: 'translateY(-130%)' },
{ transform: 'translateY(8%) translateX(-50%)', width: '60vw' },
{ transform: 'translateY(-180%) translateX(-50%)', width: '50vw' },
], {
duration: 3000,
easing: 'ease-in-out',
duration: 3200,
easing: 'cubic-bezier(0.99, 0.13, 0.35, 0.74)',
fill: 'forwards',
}).finished
document.querySelectorAll('.cloud.all').forEach((ele) => {
ele.animate([
{ top: '-50vw' },
// @ts-ignore
{ top: ele.style.top },
], {
duration: 1000,
easing: 'ease-in',
fill: 'forwards',
});
});
canAction.value = true;
gameLoop();
emit('cloudDown');
await document.querySelector('.sun-ani-wrapper')?.animate([
{ transform: 'translateY(-130%)' },
{ transform: 'translateY(-70%)' },
{ transform: 'translateY(-180%) translateX(-50%)' },
{ transform: 'translateY(-70%) translateX(-50%)' },
], {
duration: 2200,
easing: 'ease',
duration: 1200,
easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)',
fill: 'forwards',
}).finished
/* await wait(1000) */
canAction.value = true;
gameLoop();
}
const gPos: Ref<'left' | 'center' | 'right'> = ref('center');
const gDeg = computed(() => ({
@@ -101,7 +85,7 @@ const gDeg = computed(() => ({
right: 8
})[gPos.value]);
const SPEED = 1.2;
const SPEED = 1.8;
const transitionOn = ref(true);
let lastTime = 0;
@@ -118,7 +102,9 @@ const calcGiftDis = () => {
} else return 0;
}
const collisionThreshold = 20; // vw 单位
const collisionThreshold = 10; // vw 单位
const isPlayingDropAni = ref(false);
function gameLoop(currentTime = 0) {
let pauseFlag = false;
@@ -127,7 +113,7 @@ function gameLoop(currentTime = 0) {
giftPos.value = [null, null];
giftShow.value = false;
lastTime = currentTime;
if (giftProgress.value >= giftList.length ) {
if (giftProgress.value >= giftList.length) {
// 游戏结束逻辑
console.log('游戏结束!耗时:', timeSpent.value / 1000, '秒');
gameEnd()
@@ -141,55 +127,94 @@ function gameLoop(currentTime = 0) {
timeSpent.value += deltaTime
// 移动距离 = 速度 * 时间间隔 / 16.67 (目标帧率约60fps)
sunPos.value[1] -= SPEED * 0.3 * (deltaTime / 16.67);
if (sunPos.value[1] < -100) {
if (sunPos.value[1] < -50) {
transitionOn.value = false;
sunPos.value[1] = 154;
} else {
if (!isPlayingDropAni.value)
(async () => {
isPlayingDropAni.value = true;
// sunPos.y to -150, than y to 174 and drop to 124
transitionOn.value = true;
while (sunPos.value[1] > -150) {
// 逐渐加速
sunPos.value[1] -= SPEED * (deltaTime / 16.67) * 4;
await wait(16.67 / 2);
}
sunPos.value[1] = -150;
// 等待0.5秒
transitionOn.value = false;
await wait(500);
sunPos.value[1] = 124;
await wait(0);
transitionOn.value = true;
while (sunPos.value[1] > 66) {
sunPos.value[1] -= SPEED * (deltaTime / 16.67) * 6;
await wait(16.67 / 2);
}
// 最终位置 y = 66
sunPos.value[1] = 66;
isPlayingDropAni.value = false;
})();
} else if (!isPlayingDropAni.value) {
transitionOn.value = true;
sunPos.value[1] -= SPEED * 0.3 * (deltaTime / 16.67);
}
if (giftPos.value[0] === null) {
// 一直生成礼物,直到距离大于 collisionThreshold * 1.5
while (true) {
// randomly generate gift
// x: -30 - 30
// y: -20 - 60
giftPos.value[0] = Math.random() * 60 - 30;
giftPos.value[1] = Math.random() * 80 - 20;
if (calcGiftDis() > collisionThreshold * 1.5) {
break; // 距离足够远,退出循环
}
}
}
const moveGift = () => {
if (giftPos.value[0] === null) {
// 一直生成礼物,直到距离大于 collisionThreshold * 1.5
while (true) {
// 生成起始点
giftStartPos.value[0] = Math.random() * 60 - 30;
giftStartPos.value[1] = Math.random() * 80 - 20;
// gift slowly fly to the edge
if (giftPos.value[0] !== null && giftPos.value[1] !== null) {
const giftSpeed = 0.3 * (deltaTime / 16.67);
// 生成终点
giftEndPos.value[0] = Math.random() * 60 - 30;
giftEndPos.value[1] = Math.random() * 80 - 20;
// 根据礼物的位置决定移动方向
if (giftPos.value[0] < 0) {
// 向左边缘移动
giftPos.value[0] -= giftSpeed * 0.2;
if (giftPos.value[0] < -45) {
giftPos.value[0] = -45; // 到达左边缘后停止
}
} else {
// 向右边缘移动
giftPos.value[0] += giftSpeed * 0.2;
if (giftPos.value[0] > 45) {
giftPos.value[0] = 45; // 到达右边缘后停止
// 初始位置为起始点
giftPos.value[0] = giftStartPos.value[0];
giftPos.value[1] = giftStartPos.value[1];
// 重置移动进度和方向
giftMoveProgress.value = 0;
giftMoveDirection.value = 1;
if (calcGiftDis() > collisionThreshold * 1.5) {
break; // 距离足够远,退出循环
}
}
}
// y 方向始终向上移动
giftPos.value[1] += giftSpeed * 0.3;
if (giftPos.value[1] > 60) {
giftPos.value[1] = 60; // 到达上边缘后停止
// gift move between two points
if (giftPos.value[0] !== null && giftPos.value[1] !== null) {
const giftSpeed = 0.8 * (deltaTime / 16.67); // 速度加快 (原来是0.3)
// 更新移动进度
giftMoveProgress.value += giftSpeed * giftMoveDirection.value * 0.01;
// 检查是否到达端点,如果是则反转方向
if (giftMoveProgress.value >= 1) {
giftMoveProgress.value = 1;
giftMoveDirection.value = -1;
} else if (giftMoveProgress.value <= 0) {
giftMoveProgress.value = 0;
giftMoveDirection.value = 1;
}
// 使用线性插值计算当前位置
giftPos.value[0] = giftStartPos.value[0] + (giftEndPos.value[0] - giftStartPos.value[0]) * giftMoveProgress.value;
giftPos.value[1] = giftStartPos.value[1] + (giftEndPos.value[1] - giftStartPos.value[1]) * giftMoveProgress.value;
}
}
moveGift();
// 计算太阳和礼物之间的距离
const distance = calcGiftDis()
@@ -199,9 +224,7 @@ function gameLoop(currentTime = 0) {
console.log('太阳收集到礼物!', { sunPos: sunPos.value, giftPos: giftPos.value, distance });
pauseFlag = true; // 暂停游戏循环
// TODO: 在这里添加碰撞后的逻辑
// 例如:播放音效、更新分数、显示特效、切换到下一个礼物等
giftShow.value = true; // 隐藏礼物
giftShow.value = true; // 显示展示礼物界面
document.querySelector('.gift-item.show')?.animate([
{ transform: 'scale(1)' },
{ transform: 'scale(0)' },
@@ -210,7 +233,7 @@ function gameLoop(currentTime = 0) {
easing: 'cubic-bezier(0.42,-0.98, 0.4, 1)',
fill: 'forwards',
}).finished.then(() => {
giftPos.value = [null, null]; // 隐藏礼物
giftPos.value = [null, null]; // 重置游戏中礼物
});
}
@@ -230,6 +253,18 @@ function gToggle(pos: 'left' | 'center' | 'right') {
fill: 'forwards',
});
if (isPlayingDropAni.value) {
// 如果正在下落动画中,直接返回
return;
}
if (sunPos.value[0] < -36
|| sunPos.value[0] > 36
|| sunPos.value[1] > 70) {
// 太阳位置不在范围内,直接返回
return;
}
const windAniMap: Record<'left' | 'center' | 'right', [number, [number, number], [number, number]]> = {
left: [
-60,
@@ -270,6 +305,12 @@ function gToggle(pos: 'left' | 'center' | 'right') {
})[pos].forEach((v, i) => {
sunPos.value[i] += v * SPEED * 2;
});
if (sunPos.value[0] < -36) {
sunPos.value[0] = -36;
} else if (sunPos.value[0] > 36) {
sunPos.value[0] = 36;
}
}
const giftShow = ref(false);
@@ -280,6 +321,10 @@ const canAction = ref(false);
const sunPos = ref([0, 0]) // x, y
const giftPos: Ref<[number | null, number | null]> = ref([null, null]); // x, y
const giftStartPos: Ref<[number, number]> = ref([0, 0]); // 起始点
const giftEndPos: Ref<[number, number]> = ref([0, 0]); // 终点
const giftMoveProgress = ref(0); // 移动进度 0-1
const giftMoveDirection = ref(1); // 移动方向 1正向 -1反向
const giftList = assets.icons;
@@ -469,6 +514,30 @@ const showEndAni = ref(false);
async function gameEnd() {
fetch('/api/score', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
region: props.userdata.region,
store: props.userdata.store,
name: props.userdata.username,
time: timeSpent.value / 1000,
})
}).then(_ => {
fetch('/api/score', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
.then(res => res.json())
.then(data => {
leaderBoard.value = data;
});
})
document.querySelector('.bed')?.animate([
{ transform: 'translateY(150%)' },
{ transform: 'translateY(0)' },
@@ -485,14 +554,15 @@ async function gameEnd() {
img.style.animationDirection = 'reverse';
});
await document.querySelector('.sun-ani-wrapper')?.animate([
{ transform: 'translateY(8%)', left: '19%', bottom: '23%' },
document.querySelector('.sun-ani-wrapper')?.animate([
{ transform: 'translateY(8%) translateX(-50%)', left: '50%', bottom: '23%', width: '60vw' },
], {
duration: 700,
duration: 1800,
easing: 'ease-in-out',
fill: 'forwards',
}).finished
isGameEnd.value = true;
// 模拟图标聚集并获取最终位置
@@ -501,13 +571,7 @@ async function gameEnd() {
await wait(0)
floatIcons.value = finalPositions;
const element = document.querySelector('.sun-ani-wrapper');
if (!element) return;
await element.animate(
{ transform: 'translateY(8%)' },
{ duration: 2500, easing: 'ease-in-out', fill: 'forwards' }
).finished;
await wait(1800)
showEndAni.value = true;
sunEndEle.value?.jumpTo('all');
@@ -530,12 +594,6 @@ async function gameEnd() {
}
/* setTimeout(() => {
canAction.value = true
gameEnd()
}, 100); */
const leaderBoard = ref([
{ name: '始祖鸟', time: 50, region: '奥莱北区', store: "上海始祖鸟阿尔法中心" },
@@ -559,15 +617,13 @@ const showScore = ref(false)
const showLastPage = ref(false);
const sunAniEndRules = ref([
{ name: 'all', frame: 165, loop: 1, pauseAfter: false, duration: 33 },
{ name: '左右跳', frame: 66, loop: 0, pauseAfter: true, duration: 33 },
{ name: '落下', frame: 32, loop: 1, pauseAfter: false, duration: 33 },
{ name: 'loop', frame: 33, loop: 0, pauseAfter: true, duration: 33 },
])
async function finishCollect() {
document.querySelector('.bar-container')?.animate([
{ transform: 'translateY(300%)' }], {
duration: 500,
easing: 'ease-in-out',
fill: 'forwards',
})
document.querySelector('.finish-collect')?.animate([
{ transform: 'scale(1)' },
{ transform: 'scale(0)' },
@@ -591,48 +647,98 @@ async function finishCollect() {
easing: 'ease-in-out',
fill: 'forwards',
}).finished
await sunEndEle.value?.jumpToSoftly('落下');
document.querySelector('.bar-container')?.animate([
{ transform: 'translateY(300%)' }], {
duration: 500,
easing: 'ease-in-out',
fill: 'forwards',
})
showLastPage.value = true;
}
// 重置游戏状态的函数
function resetGame() {
return
// 重置所有响应式状态到初始值
gPos.value = 'center';
transitionOn.value = true;
lastTime = 0;
giftShow.value = false;
canAction.value = false;
sunPos.value = [0, 0];
giftPos.value = [null, null];
giftStartPos.value = [0, 0];
giftEndPos.value = [0, 0];
giftMoveProgress.value = 0;
giftMoveDirection.value = 1;
giftProgress.value = 0;
isGameEnd.value = false;
timeSpent.value = 0;
showEndAni.value = false;
showScore.value = false;
showLastPage.value = false;
// 重新生成图标位置
floatIcons.value = generateIconPositions();
// 重置动画元素到初始状态
sunEle.value?.jumpTo('起飞前');
sunEndEle.value?.jumpTo('loop');
// 清除所有动画和样式
const elementsToReset = [
'.bed', '.sun-ani-wrapper', '.p-machine', '.action img',
'.gift-item', '.wind', '.bar-container', '.finish-collect',
'.wecare-title', '.lines'
];
elementsToReset.forEach(selector => {
const element = document.querySelector(selector);
if (element instanceof HTMLElement) {
element.getAnimations().forEach(animation => animation.cancel());
element.style.animation = '';
element.style.transform = '';
element.style.opacity = '';
}
});
}
</script>
<template>
<div class="page game">
<div class="public">
<div class="bg"> </div>
<img class="cloud" src="../assets/game/云.png" v-for="c in [
{ w: 60, t: 10, l: 75, all: true },
{ w: 75, t: 55, l: -45 },
{ w: 40, t: 105, l: 35 },
]" :style="{ width: `${c.w}vw`, top: `${c.t}vw`, left: `${c.l}vw` }" :class="{ all: c.all }"></img>
<img src="../assets/game/床.png" alt="" class="abs bed" style="width: 66vw;bottom: 12%;left: 17%;">
<img src="../assets/game/床.png" alt="" class="abs bed"
style="width: 66vw;bottom: 12%;left: 17%;">
<AniEle :url="assets.ani.下拉蓄力提示" ref="aniEle" :height="334" :width="337" :rules="[
{ name: '蓄力', frame: 241, loop: 2 },
]" class="abs arrow" style="bottom: 7%;width: 30vw;left: 35%;" @pointerdown.once="shoot" />
<img class="cloud" src="../assets/game/云.png" v-for="c in [
{ w: 75, t: 165, l: -35, all: true },
{ w: 100, t: 144, l: 60, all: true },
]" :style="{ width: `${c.w}vw`, top: `${c.t}vw`, left: `${c.l}vw` }" :class="{ all: c.all }"></img>
<AniEle :url="assets.ani.下拉蓄力提示" ref="aniEle" :height="334"
:width="337" :rules="[
{ name: '蓄力', frame: 241, loop: 2 },
]" class="abs arrow" style="bottom: 7%;width: 30vw;left: 35%;"
@pointerdown.once="shoot" />
<div class="sun-ani-wrapper abs" v-if="!showEndAni" :style="{
width: '60vw',
zIndex: 99,
pointerEvents: 'none',
bottom: `calc(21% + ${sunPos[1]}vw)`,
left: `calc(19% + ${sunPos[0]}vw)`,
left: `calc(49% + ${sunPos[0]}vw)`,
transform: `translateX(-50%)`,
transition: transitionOn ? 'all 0.1s ease' : undefined
}">
<!-- AniEle 组件现在只关心自己的内容,不再有复杂的外部样式 -->
<AniEle :url="assets.ani.小太阳总" ref="sun-ani" class="sun-ani" :height="1800" :width="1600" :rules="[
{ name: '起飞前', frame: 90, loop: 0, pauseAfter: true, duration: 33 },
{ name: '蓄力飞', frame: 181, loop: 1, pauseAfter: false, duration: 33 },
{ name: '天上飞', frame: 90, loop: 0, pauseAfter: true, duration: 33 },
{ name: '落下', frame: 393, loop: 0, pauseAfter: false, duration: 33 },
]" style="width: 100%; height: auto;" />
<AniEle :url="assets.ani.小太阳总" ref="sun-ani" class="sun-ani"
:height="1800" :width="1600" :rules="[
{ name: '起飞前', frame: 90, loop: 0, pauseAfter: true, duration: 33 },
{ name: '蓄力飞', frame: 181, loop: 1, pauseAfter: false, duration: 33 },
{ name: '天上飞', frame: 90, loop: 0, pauseAfter: true, duration: 33 },
{ name: '落下', frame: 393, loop: 0, pauseAfter: false, duration: 33 },
]" style="width: 100%; height: auto;" />
</div>
</div>
@@ -641,48 +747,58 @@ async function finishCollect() {
style="bottom: -40%; width: 66vw; left: 17%; transform-origin: 50% 63%; transition: all 0.2s;"
:style="{ transform: `rotate(${gDeg}deg)` }" />
<div class="action">
<img draggable="false" :style="{ animationDelay: '0.1s' }" src="../assets/game/左.png" alt=""
@click="gToggle('left')">
<img draggable="false" :style="{ animationDelay: '0.2s' }" src="../assets/game/中.png" alt=""
<img draggable="false" :style="{ animationDelay: '0.1s' }"
src="../assets/game/左.png" alt="" @click="gToggle('left')">
<img draggable="false" :style="{ animationDelay: '0.2s' }"
src="../assets/game/中.png" alt=""
@click="gToggle('center')">
<img draggable="false" :style="{ animationDelay: '0.0s' }" src="../assets/game/右.png" alt=""
@click="gToggle('right')">
<img draggable="false" :style="{ animationDelay: '0.0s' }"
src="../assets/game/右.png" alt="" @click="gToggle('right')">
</div>
<img class="abs gift-item" v-for="(gift, index) in giftList" :src="gift[0]" style="width: 30vw;"
v-if="!isGameEnd" :style="{
<img class="abs gift-item" v-for="(gift, index) in giftList"
:src="gift[0]" style="width: 20vw;" v-if="!isGameEnd" :style="{
display: giftProgress == index ? 'block' : 'none',
bottom: `calc(48% + ${giftPos[1] ?? 0}vw)`, left: `calc(34.5% + ${giftPos[0] ?? 0}vw)`, transition: transitionOn ? `all 0.1s ease` : undefined
}" alt="" :class="{ show: giftProgress == index, hide: giftProgress != index }">
bottom: `calc(45% + ${giftPos[1] ?? 0}vw)`, left: `calc(39.5% + ${giftPos[0] ?? 0}vw)`, transition: transitionOn ? `all 0.1s ease` : undefined
}" alt=""
:class="{ show: giftProgress == index, hide: giftProgress != index }">
<img src="../assets//game/操作提示.png" class="abs prompt" alt="" style="width: 31%; bottom: 18%; left: 34%; ">
<img src="../assets//game/操作提示.png" class="abs prompt" alt=""
style="width: 31%; bottom: 18%; left: 34%; ">
<img src="../assets/game/风.png" alt="" class="abs wind">
<img src="../assets/game/wecare.png" alt="" class="abs wecare" style="width: 24vw;
<img src="../assets/game/wecare.png" alt="" class="abs wecare"
style="width: 24vw;
left: 50%; top: 6%; transform: translateX(-50%);">
<div class="dot" v-if="!isGameEnd">
<div v-for="_ in Array(giftProgress)" class="dot-item finished"></div>
<div v-for="_ in Array(giftList.length - giftProgress)" class="dot-item"></div>
<div v-for="(_, index) in giftList" :key="index"
class="dot-item" :class="{ finished: index < giftProgress }"
@click="giftProgress = index">
</div>
</div>
</div>
<div class="game-end abs" v-if="isGameEnd" style="z-index: 2; inset: 0; height: 100%; width: 100%;">
<div class="icon-cloud" style="position: absolute; left: 50%; top: 20%;">
<div class="game-end abs" v-if="isGameEnd"
style="z-index: 2; inset: 0; height: 100%; width: 100%;">
<div class="icon-cloud"
style="position: absolute; left: 50%; top: 20%;">
<img v-for="icon in floatIcons" :src="icon.src" alt="" :style="{
top: `${icon.top}vw`,
left: `${icon.left}vw`,
animationDelay: `${icon.delay}ms`,
}">
</div>
<img src="../assets/game/wecare.png" alt="" class="abs wecare" style="width: 24vw;
<img src="../assets/game/wecare.png" alt="" class="abs wecare"
style="width: 24vw;
left: 50%; top: 6%; transform: translateX(-50%);">
<img src="../assets/game/wecare标题.png" alt="" class="abs wecare-title"
style="width: 72%;left: 14%;top: 12%;">
<img src="../assets/game/wecare标题.png" alt=""
class="abs wecare-title" style="width: 72%;left: 14%;top: 12%;">
<div class="scoreboard" v-if="showScore">
<div class="bar-container">
<div class="bar" v-for="(item, index) in [leaderBoard[1], leaderBoard[0], leaderBoard[2]]"
<div class="bar"
v-for="(item, index) in [leaderBoard[1], leaderBoard[0], leaderBoard[2]]"
:key="index" :class="{
'first': index === 1, 'second': index === 0, 'third': index === 2
}">
@@ -693,7 +809,9 @@ async function finishCollect() {
</div>
</div>
<div class="lines">
<div class="line" v-for="(item, index) in leaderBoard.slice(3, 7)" :key="index"
<div class="line"
v-for="(item, index) in leaderBoard.slice(3, 7)"
:key="index"
:style="{ animationDelay: index * 100 + 400 + 'ms' }">
<div class="rank">{{ index + 4 }}</div>
<div class="name">{{ item.name }}</div>
@@ -703,46 +821,53 @@ async function finishCollect() {
</div>
</div>
<div class="sun-ani-end-wrapper abs" style="pointer-events: none; inset: 0; width: 100%;z-index: 2;"
<div class="sun-ani-end-wrapper abs"
style="pointer-events: none; inset: 0; width: 100%;z-index: 2;"
:style="{ display: showEndAni ? 'block' : 'none' }">
<!-- AniEle 组件现在只关心自己的内容,不再有复杂的外部样式 -->
<AniEle :url="assets.ani.后端效果" ref="sun-ani-end" class="sun-ani-end" :height="2462" :width="1179"
:rules="[
{ name: 'all', frame: 263, loop: 1, pauseAfter: false, duration: 33 },
{ name: 'loop', frame: 33, loop: 0, pauseAfter: true, duration: 33 },
]" style="width: 100%; height: auto;" />
<AniEle :url="assets.ani.后端效果" ref="sun-ani-end"
class="sun-ani-end" :height="2462" :width="1179"
:rules="sunAniEndRules"
style="width: 100%; height: auto;" />
</div>
<img src="../assets/game/完成收集.png" alt="" v-if="showScore" class="finish-collect abs" @click.once="finishCollect"
<img src="../assets/game/完成收集.png" alt="" v-if="showScore"
class="finish-collect abs" @click.once="finishCollect"
style="bottom: 8%;left: 33%;width: 34vw;animation: scale-in 0.3s ease-out;">
</div>
<div class="last-page" v-if="showLastPage"
style="z-index: 0;position: absolute; inset: 0;height: 100%; width: 100%;">
style="z-index: 0;position: absolute; inset: 0;height: 100%; width: 100%; pointer-events: none;">
<img src="../assets/game/end_right_bg.png" alt="" style="position: absolute;top: 29.3%;left: 37.2%;width: 57.4%; height: 38.1%;
animation: fade-in 3s ease-in-out forwards; opacity: 0;">
">
<AniEle :url="assets.ani.结尾" ref="gui-ani-end" class="gui-ani-end" :height="2462" :width="1179" :rules="[
{ name: 'all', frame: 105, loop: 1, pauseAfter: false, duration: 16 },
]" style="width: 100%; height: auto;position: absolute;inset: 0;pointer-events: none;" />
<AniEle :url="assets.ani.结尾" ref="gui-ani-end" class="gui-ani-end"
:height="2462" :width="1179" :rules="[
{ name: 'all', frame: 105, loop: 1, pauseAfter: false, duration: 16 },
]"
style="width: 100%; height: auto;position: absolute;inset: 0;pointer-events: none;" />
<AniEle :url="assets.ani.主标出现" :width="1015" :height="336"
:rules="[{ name: 'main', frame: 41, loop: 1, pauseAfter: true, duration: 33, reverse: false }]"
style="position: absolute;top: 13%;width: 84%;left: 8%; pointer-events:none;" ref="main-logo" />
<img src="../assets/game/床.png" alt="" class="abs last-bed" style="width: 66vw;bottom: -19%;left: 17%;">
style="position: absolute;top: 13%;width: 84%;left: 8%; pointer-events:none;"
ref="main-logo" />
<img src="../assets/game/床.png" alt="" class="abs last-bed"
style="width: 66vw;bottom: -19%;left: 17%;">
<img src="../assets/game/分享海报.png" alt="" class="abs"
style="width: 34vw;bottom: -19%;left: 15%;animation: last-btn-in 0.3s ease-out forwards;">
<img src="../assets/game/再玩一次.png" alt="" class="abs"
style="width: 34vw;bottom: -19%;left: 51%;animation: last-btn-in 0.3s ease-out forwards; animation-delay: 200ms;">
@click="resetGame"
style="width: 34vw;bottom: -19%;left: 51%;animation: last-btn-in 0.3s ease-out forwards; animation-delay: 200ms; cursor: pointer;">
<div class="time-spent abs" style="left: 10%; top:44%;width: 24%; display: flex;
align-items: center; flex-direction: column;">
<div class="line-1"
style="display: flex; align-items: center; gap: 1vw; animation: line-in 0.5s ease-out 0.6s forwards;transform: translateX(-210%);;">
<div class="username" style="font-size: 2.9vw;">{{ userdata.username }}</div>
style="display: flex; align-items: center; gap: 1vw; animation: line-in 0.5s ease-out 0.6s forwards;transform: translateX(-210%);">
<div class="username" style="font-size: 2.9vw;">{{
userdata.username }}</div>
<div class="cost-info"
style="font-size: 2.9vw; color: white; display: flex;align-items: center;justify-content: center;background-color: black;padding: 0 1.2vw; height: 4.5vw; border-radius: 4vw;">
用时</div>
@@ -750,13 +875,17 @@ async function finishCollect() {
<div class="time"
style="font-size: 9vw;animation: line-in 0.5s ease-out 0.8s forwards;transform: translateX(-210%);">
{{ fmtTime(timeSpent/1000) }}</div>
{{ fmtTime(timeSpent / 1000) }}</div>
</div>
<div class="region-area abs" style="left: 10%; top:30%;width: 24%;height: 10%;gap:2vw; display: flex;animation: line-in 0.5s ease-out 0.4s forwards;transform: translateX(-210%);
<div class="region-area abs"
style="left: 10%; top:30%;width: 24%;height: 10%;gap:2vw; display: flex;animation: line-in 0.5s ease-out 0.4s forwards;transform: translateX(-210%);
align-items: center; flex-direction: column;justify-content: center;">
<div class="region" style="width: 20vw; background-color: white;font-size: 3vw;text-align: center;height: 4.5vw;line-height: 4.5vw; border-radius: 4.5vw;">{{ userdata.region }}</div>
<div class="store" style="font-size: 2.8vw; color: gray;">{{ userdata.store }}</div>
<div class="region"
style="width: 20vw; background-color: white;font-size: 3vw;text-align: center;height: 4.5vw;line-height: 4.5vw; border-radius: 4.5vw;">
{{ userdata.region }}</div>
<div class="store" style="font-size: 2.8vw; color: gray;">{{
userdata.store }}</div>
</div>
</div>
@@ -764,9 +893,10 @@ async function finishCollect() {
<div v-if="giftShow" class="gift-popup">
<img :src="giftList[giftProgress][1]" alt="" class="abs" style="width: 82%; left: 9%; top: 11%;">
<img src="../assets/game/收下福利.png" alt="" class="abs" style="width: 14%; left: 43%; top: 54%;"
@click="gameLoop()">
<img :src="giftList[giftProgress][1]" alt="" class="abs"
style="width: 82%; left: 9%; top: 11%;">
<img src="../assets/game/收下福利.png" alt="" class="abs"
style="width: 14%; left: 43%; top: 54%;" @click="gameLoop()">
</div>
@@ -1111,17 +1241,4 @@ async function finishCollect() {
animation: scale-in 0.4s ease-in-out forwards;
}
}
.bg {
position: absolute;
inset: 0;
height: 100%;
width: 100%;
z-index: -1;
background-image: url('../assets/game/蓝天.png');
}
.cloud {
position: absolute;
}
</style>
+99
View File
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { ref } from 'vue';
import assets from '../assets';
const progress = ref(0);
const total = Object.keys(assets.ani).length;
let completedFiles = 0;
// 使用 XMLHttpRequest 下载单个文件并跟踪进度
const downloadFile = (url: string, key: string): Promise<void> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'blob';
// 跟踪下载进度
xhr.onprogress = (event) => {
if (event.lengthComputable) {
const fileProgress = (event.loaded / event.total) * 100;
// 计算总体进度:已完成文件的进度 + 当前文件的进度
const totalProgress = (completedFiles + fileProgress / 100) / total * 100;
progress.value = Math.min(100, totalProgress);
}
};
xhr.onload = () => {
if (xhr.status === 200) {
const blob = xhr.response;
const objectURL = URL.createObjectURL(blob);
assets.ani[key as keyof typeof assets.ani] = objectURL;
completedFiles++;
progress.value = Math.min(100, completedFiles / total * 100);
resolve();
} else {
reject(new Error(`资源加载失败: ${url}, 状态码: ${xhr.status}`));
}
};
xhr.onerror = () => {
reject(new Error(`网络错误,无法加载: ${url}`));
};
xhr.send();
});
};
const emit = defineEmits(['loaded']);
// 开始加载所有资源
(async () => {
try {
for (const key in assets.ani) {
if (Object.prototype.hasOwnProperty.call(assets.ani, key)) {
const url = assets.ani[key as keyof typeof assets.ani];
await downloadFile(url, key);
}
}
console.log('所有资源加载完成:', assets);
// 触发加载完成事件
await document.querySelector('.loader-container')?.animate([
{ opacity: 1 },
{ opacity: 0 }
], {
duration: 200,
easing: 'ease-in',
fill: 'forwards',
}).finished;
emit('loaded');
} catch (error) {
console.error('资源加载失败:', error);
if (confirm('资源加载失败,是否重试?' + error)) {
window.location.reload();
}
}
})();
</script>
<template>
<div class="loader-container">
<img src="../assets/loader/wecare.png" alt=""
style="position: absolute; left: 50%; top:40%; transform: translateX(-50%); width: 40vw; ">
<div class="progress-container"
style="position: absolute; left: 50%; top: 48%; transform: translateX(-50%); width: 40vw; background-color: white; height: 2vw; border-radius: 2vw; overflow: hidden;">
<div class="progress"
style="height: 100%; background-color: #FF7B1D;transition: all 0.1s;"
:style="{ width: progress + '%' }"></div>
</div>
<div class="info"
style="position: absolute; left: 50%; top: 52%; transform: translateX(-50%); font-size: 3vw;">
加载中...</div>
</div>
</template>
+288 -334
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
import { onMounted, onUnmounted, ref } from 'vue';
import assets from '../assets';
import { regions, type RegionData, type Store } from '../data';
@@ -19,14 +19,16 @@ const showStoreSelector = ref(false);
const selectedRegion = ref<string>('');
const selectedStores = ref<Store[]>([]);
const selectedStoreIndex = ref(0);
const selectedBubbleEl = ref<HTMLElement | null>(null);
const selectionInProgress = ref(false);
// 区域映射
const regionKeyMap: { [key: string]: keyof RegionData | 'combined' } = {
'大北区': '大北区',
'大东区': '大东区',
'大南区&免税': 'combined', // 需要合并大南区和免税
'大南区&免税': 'combined',
'大西区': '大西区',
'奥莱': 'combined', // 需要合并奥莱北区和奥莱东区
'奥莱': 'combined',
'博物馆': '博物馆',
};
@@ -49,17 +51,13 @@ function getRegionStores(regionKey: string): Store[] {
let observer: IntersectionObserver | null = null;
function initializeStoreSelector() {
// 等待DOM渲染完成
setTimeout(() => {
const storeItems = document.querySelectorAll('.store-item');
if (storeItems.length === 0) return;
// 创建 Intersection Observer
observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// 找到正在相交的元素的索引
const index = Array.from(storeItems).indexOf(entry.target);
if (index !== -1 && index !== selectedStoreIndex.value) {
selectedStoreIndex.value = index;
@@ -68,11 +66,10 @@ function initializeStoreSelector() {
});
}, {
root: document.querySelector('.store-list'),
rootMargin: '-45% 0px -45% 0px', // 只有在中间10%区域的元素会被检测到
rootMargin: '-45% 0px -45% 0px',
threshold: 0.5
});
// 观察所有店铺项目
storeItems.forEach(item => {
observer?.observe(item);
});
@@ -81,7 +78,6 @@ function initializeStoreSelector() {
function selectStore(index: number) {
selectedStoreIndex.value = index;
// 滚动到选中的项目,让 scroll-snap 自动处理定位
const storeItems = document.querySelectorAll('.store-item');
if (storeItems[index]) {
storeItems[index].scrollIntoView({
@@ -91,161 +87,79 @@ function selectStore(index: number) {
}
}
// 清理 observer
function cleanupObserver() {
if (observer) {
observer.disconnect();
observer = null;
}
}
const emit = defineEmits<{
(e: 'startExploration', payload: { region: string; store: string, username: string }): void;
}>();
function startExploration() {
// 这里可以添加开始探索的逻辑
const noticeToInputName = ref(false);
async function startExploration() {
console.log('开始探索:', selectedStores.value[selectedStoreIndex.value]);
if (!selectedRegion.value || selectedStores.value.length === 0) {
alert('请选择一个区域和店铺!');
return;
}
if ((document.querySelector('.name-input') as HTMLInputElement).value.trim() === '') {
alert('请输入你的名字!');
const nameInput = document.querySelector('.name-input') as HTMLInputElement;
if (nameInput.value.trim() === '') {
noticeToInputName.value = true;
return;
}
startExplorationRules.value[0].reverse = true;
startExplorationRules.value[0].reverse = true;
// 2. 将放大的气泡恢复原状
let bubble = selectedBubbleEl.value;
if (!bubble) return;
bubble.classList.remove('zoomed');
bubble.style.transform = 'translate3d(-50%, -50%, 0) scale(0)';
document.querySelector('.sun-wrap')?.classList.add('fade-out');
emit('startExploration', {
region: selectedRegion.value,
store: selectedStores.value[selectedStoreIndex.value].店铺,
username: (document.querySelector('.name-input') as HTMLInputElement).value
username: nameInput.value
});
}
// 动画管理
let fadeInAnimations: Animation[] = [];
let swapInAnimations: Animation[] = [];
let bubbleDropAnimations: Animation[] = [];
// 保存所有动画的数组
let allAnimations: Animation[] = [];
// 淡入动画
function createFadeInAnimation(element: Element, delay: number = 0) {
const animation = element.animate([
{ opacity: 0 },
{ opacity: 1 }
], {
duration: 500,
easing: 'ease-in-out',
delay: delay * 1000,
fill: 'forwards'
});
fadeInAnimations.push(animation);
allAnimations.push(animation);
return animation;
}
// Swap in 动画(渐进显示)
function createSwapInAnimation(element: Element, delay: number = 0) {
const animation = element.animate([
{ clipPath: 'inset(0 100% 0 0)' },
{ clipPath: 'inset(0 0% 0 0)' }
], {
duration: 500,
easing: 'ease-in-out',
delay: delay * 1000,
fill: 'forwards'
});
swapInAnimations.push(animation);
allAnimations.push(animation);
return animation;
}
// 气泡下降动画
function createBubbleDropAnimation(element: Element, startTop: string, startLeft: string, endTop: string, endLeft: string, delay: number = 0, duration: number = 600) {
const animation = element.animate([
{
top: startTop,
left: startLeft,
transform: 'translate3d(-50%, -50%, 0) scale(0.1)'
},
{
top: endTop,
left: endLeft,
transform: 'translate3d(-50%, -50%, 0) scale(1)'
}
], {
duration: duration,
easing: 'ease-out',
delay: (delay + 1) * 1000,
fill: 'forwards'
});
bubbleDropAnimations.push(animation);
allAnimations.push(animation);
return animation;
}
// 动画相关的 refs
const mainLogoRules = ref([
{ name: 'main', frame: 41, loop: 1, pauseAfter: true, duration: 33, reverse: false }
]);
const selectInfoRules = ref([{ name: 'main', frame: 26, loop: 1, duration: 33, reverse: false }]);
const startExplorationRules = ref([{ name: 'start', frame: 19, loop: 1, duration: 33, reverse: false }]);
// 反向播放所有动画来淡出
function reverseAllAnimations() {
mainLogoRules.value[0].reverse = true;
selectInfoRules.value[0].reverse = true;
allAnimations.forEach(animation => {
// 确保动画已经完成
if (animation.playState === 'finished') {
animation.reverse();
}
});
}
// 淡出所有动画(备用方案)
function fadeOutAllAnimations() {
const allElements = document.querySelectorAll('.fade-in, .swap-in, .scale-in-ctn, .scale-in-txt, .region-bubble');
allElements.forEach((element) => {
element.animate([
{ opacity: 1 },
{ opacity: 0 }
], {
duration: 1000,
easing: 'ease-in-out',
fill: 'forwards'
});
});
}
// 暴露函数供外部使用
(window as any).reverseAllAnimations = reverseAllAnimations;
(window as any).fadeOutAllAnimations = fadeOutAllAnimations;
const showSelectInfo = ref(false);
const showSun = ref(false);
// Refs for DOM elements to control their styles
const logoEl = ref<HTMLElement | null>(null);
const mainLogoWrapperEl = ref<HTMLElement | null>(null);
const selectInfoContainerEl = ref<HTMLElement | null>(null);
onMounted(() => {
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// 等待一帧确保所有元素都已渲染
requestAnimationFrame(async () => {
// 初始化logo淡入动画
const logo = document.querySelector('.logo');
if (logo) {
createFadeInAnimation(logo, 2);
}
// --- 初始化动画 ---
// 初始化swap-in动画
const swapInElements = document.querySelectorAll('.swap-in');
swapInElements.forEach((element) => {
const htmlElement = element as HTMLElement;
const delay = parseFloat(htmlElement.style.getPropertyValue('--base-delay')?.replace('s', '') || '0');
createSwapInAnimation(element, delay);
});
// 1. Logo 淡入
setTimeout(() => {
if (logoEl.value) logoEl.value.style.opacity = '1';
}, 2000);
// 2. 主标题和选择提示的容器淡入 (AniEle本身播放,我们控制其容器)
// 3. 气泡下落动画
const regionBubbles = document.querySelectorAll<HTMLElement>('.region-bubble');
// 初始化气泡动画
const regionBubbles = document.querySelectorAll('.region-bubble');
regionBubbles.forEach((bubble, index) => {
const region = regionsPos[index];
const startTop = `${region.t + 50 - 200 - (100 - region.t)}%`;
@@ -253,87 +167,136 @@ onMounted(() => {
const endTop = `${region.t + 50}%`;
const endLeft = `${region.l + 50}%`;
// 设置初始位置
(bubble as HTMLElement).style.top = startTop;
(bubble as HTMLElement).style.left = startLeft;
// 设置初始离屏位置
Object.assign(bubble.style, {
top: startTop,
left: startLeft,
transform: 'translate3d(-50%, -50%, 0) scale(0.1)',
transitionDuration: `0ms`
});
createBubbleDropAnimation(bubble, startTop, startLeft, endTop, endLeft, 0, 600 + region.ad * 100);
// 延迟触发动画
setTimeout(() => {
Object.assign(bubble.style, {
top: endTop,
left: endLeft,
transform: 'translate3d(-50%, -50%, 0) scale(1)',
// 点击事件
const pDown = () => {
bubble.animate([
{ transform: 'translate3d(-50%, -50%, 0) scale(1)', filter: 'brightness(1)' },
{ transform: 'translate3d(-50%,-50%, 0) scale(0.92)', filter: 'brightness(0.9)' },
], {
duration: 500,
easing: 'ease-in-out',
fill: 'forwards'
transitionDuration: `${600 + region.ad * 100}ms`
});
}
}, 1000); // 基础延迟
// --- 绑定事件 ---
const pDown = () => {
if (selectionInProgress.value) return;
bubble.style.filter = 'brightness(0.9)';
bubble.style.transform = 'translate3d(-50%,-50%, 0) scale(0.92)';
};
const pUp = () => {
// 设置选中的区域和店铺数据
if (selectionInProgress.value) return;
selectionInProgress.value = true;
selectedBubbleEl.value = bubble;
selectedRegion.value = region.tt;
selectedStores.value = getRegionStores(region.key);
selectedStoreIndex.value = 0;
bubble.querySelector('span')?.animate([
{ opacity: 1 },
{ opacity: 0 },
], {
duration: 1000,
easing: 'ease-in-out',
fill: 'forwards'
});
// 放大选中的气泡
bubble.classList.add('zoomed');
bubble.style.filter = 'brightness(1)';
const scaleAnimation = bubble.animate([
{ transform: 'translate3d(-50%, -50%, 0) scale(0.92)', filter: 'brightness(0.9)' },
{ transform: 'translate3d(-50%, -50%, 0) scale(4)', filter: 'brightness(1)', top: '53%', left: '50%' },
], {
duration: 1000,
easing: 'ease-in-out',
fill: 'forwards'
});
// 隐藏气泡文字
const span = bubble.querySelector('span');
if (span) span.style.opacity = '0';
// 反向播放所有动画来淡出其他元素
reverseAllAnimations();
// 淡出其他UI元素
if (logoEl.value) logoEl.value.style.opacity = '0';
mainLogoRules.value[0].reverse = true;
selectInfoRules.value[0].reverse = true;
// 淡出其他气泡
regionBubbles.forEach((otherBubble, otherIndex) => {
if (otherIndex == index) return
otherBubble.animate([
{ opacity: 1 },
{ opacity: 0 },
], {
duration: 1000,
easing: 'ease-in-out',
fill: 'forwards'
});
regionBubbles.forEach(otherBubble => {
if (otherBubble !== bubble) {
otherBubble.style.transform = 'translate3d(-50%, -50%, 0) scale(0)';
otherBubble.style.opacity = '0';
} else {
otherBubble.style.transitionTimingFunction = 'cubic-bezier(0.71, 0.14, 0.27, 1.55)';
setTimeout(() => {
otherBubble.style.transitionTimingFunction = 'ease';
}, 1000);
}
});
// 动画完成后显示选择界面
scaleAnimation.addEventListener('finish', () => {
setTimeout(() => {
showStoreSelector.value = true;
// 初始化 Intersection Observer
initializeStoreSelector();
});
bubble.removeEventListener('pointerdown', pDown);
}
}, 1000); // 等待放大动画完成
};
bubble.addEventListener('pointerdown', pDown);
bubble.addEventListener('pointerup', pUp, { once: true });
bubble.addEventListener('pointerup', pUp);
});
await wait(1300);
showSelectInfo.value = true;
// 等待showSelectInfo渲染后,设置其容器淡入
await wait(10);
if (selectInfoContainerEl.value) selectInfoContainerEl.value.style.opacity = '1';
await wait(300);
showSun.value = true;
});
});
// 组件卸载时清理 observer
function goBackToRegionSelection() {
if (!selectedBubbleEl.value) return;
selectionInProgress.value = true; // 加锁防止重复操作
mainLogoRules.value[0].reverse = false;
selectInfoRules.value[0].reverse = false;
// 1. 隐藏店铺选择器
showStoreSelector.value = false;
cleanupObserver();
const bubble = selectedBubbleEl.value;
// 2. 将放大的气泡恢复原状
bubble.classList.remove('zoomed');
// 3. 恢复气泡文字
const span = bubble.querySelector('span');
if (span) span.style.opacity = '1';
// 4. 恢复其他UI元素
if (logoEl.value) logoEl.value.style.opacity = '1';
if (mainLogoWrapperEl.value) mainLogoWrapperEl.value.style.opacity = '1';
if (selectInfoContainerEl.value) selectInfoContainerEl.value.style.opacity = '1';
// 5. 恢复其他气泡
const allBubbles = document.querySelectorAll<HTMLElement>('.region-bubble');
allBubbles.forEach(otherBubble => {
if (otherBubble !== bubble) {
otherBubble.style.opacity = '1';
otherBubble.style.transform = 'translate3d(-50%, -50%, 0) scale(1)';
}
});
// 6. 动画结束后重置状态
setTimeout(() => {
selectedRegion.value = '';
selectedStores.value = [];
selectedStoreIndex.value = 0;
selectedBubbleEl.value = null;
selectionInProgress.value = false; // 解锁
}, 1000); // 等待动画完成
}
onUnmounted(() => {
cleanupObserver();
});
@@ -342,40 +305,41 @@ onUnmounted(() => {
<template>
<section class="p1">
<img :src="assets.标准logo" alt="" class="logo" style="top: 6%;left: 8%;width: 12%;opacity: 0;">
<img :src="assets.p1.蓝天" alt="" class="bg">
<img :src="assets.标准logo" alt="" class="logo" ref="logoEl">
<AniEle :url="assets.ani.主标出现" :width="1015" :height="336" :rules="mainLogoRules"
style="top: 10%;width: 84%;left: 8%;" />
<div class="main-logo-wrapper" ref="mainLogoWrapperEl">
<AniEle :url="assets.ani.主标出现" :width="1015" :height="336"
:rules="mainLogoRules" style="top: 10%;width: 84%;left: 8%;" />
</div>
<div class="sun-warp" v-if="showSun">
<AniEle :url="assets.ani.P1太阳总" :width="1000" :height="1000"
:rules="[{ name: '下落', frame: 69, loop: 1, duration: 33 }, { name: '循环', frame: 75, loop: 0, duration: 33, reverse: false }]" />
</div>
<!-- <img :src="assets.p1.太阳" alt="" style="top: 30%;left: 5%;width: 26%;"> -->
<div style="width: 86vw;height: 86vw;top: 47%; left: 50%;transform: translate3d(-50%, -50%, 0); z-index: -1;">
<div v-for="i in regionsPos" :style="{
<div
style="width: 86vw;height: 86vw;top: 47%; left: 50%;transform: translate3d(-50%, -50%, 0); z-index: -1;">
<div v-for="i in regionsPos" :key="i.key" :style="{
width: i.w + '%',
height: i.h + '%',
transform: 'translate3d(-50%, -50%, 0) scale(0)',
}" class="region-bubble">
<img :src="i.src" alt="" style="height: 100%;width: 100%;">
<img :src="i.src" alt="" style="height: 100%;width: 100%;"
draggable="false">
<span
style="font-size: 4.5vw;left: 50%;transform: translate(-50%, -50%);font-weight: 400;width: fit-content;"
:style="{ color: i.tc || '#000', top: (i.trt ?? 0) + 50 + '%' }">
*{{ i.tt }}
</span>
</div>
</div>
<div style="inset: 0;width: 100%;top: 52%;pointer-events: none;" v-if="showSelectInfo">
<AniEle :url="assets.ani.点击选择阵营" :width="1179" :height="2462" ref="click-select-ani"
<div class="select-info-container" ref="selectInfoContainerEl"
v-if="showSelectInfo">
<AniEle :url="assets.ani.点击选择阵营" :width="1179" :height="2462"
:rules="selectInfoRules" />
</div>
<!-- 店铺选择界面 -->
<div v-if="showStoreSelector" class="store-selector">
<div class="store-selector-content">
@@ -383,15 +347,16 @@ onUnmounted(() => {
<div class="store-list-container">
<div class="store-list relative">
<!-- 顶部填充,确保第一个元素能滚动到中间 -->
<div class="scroll-padding relative"></div>
<div v-for="(store, index) in selectedStores" :key="store.店铺号" class="store-item relative"
:class="{ active: index === selectedStoreIndex }" @click="selectStore(index)">
<div class="store-name relative">{{ store.店铺 }}</div>
<div v-for="(store, index) in selectedStores"
:key="store.店铺号" class="store-item relative"
:class="{ active: index === selectedStoreIndex }"
@click="selectStore(index)">
<div class="store-name relative">{{ store.店铺 }}
</div>
</div>
<!-- 底部填充,确保最后一个元素能滚动到中间 -->
<div class="scroll-padding relative"></div>
</div>
</div>
@@ -402,16 +367,80 @@ onUnmounted(() => {
<input type="text" class="name-input" placeholder="">
</div>
</div>
<img class="input-your-name-notice"
src="../assets/p1/请填写你的名字.png" alt=""
v-if="noticeToInputName">
<AniEle :url="assets.ani.开始探索" :width="548" :height="143" :rules="startExplorationRules" class="start-button relative"
<AniEle :url="assets.ani.开始探索" :width="548" :height="143"
:rules="startExplorationRules" class="start-button"
@click="startExploration">
</AniEle>
</div>
<img src="../assets/返回按钮.png" alt="返回"
@click="goBackToRegionSelection" class="back-button" />
</div>
</section>
</template>
<style scoped lang="scss">
/* --- Base Styles and Transitions --- */
.logo {
top: 6%;
left: 8%;
width: 12%;
opacity: 0;
transition: opacity 0.5s ease-in-out;
}
.main-logo-wrapper {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.select-info-container {
position: absolute;
inset: 0;
width: 100%;
top: 52%;
pointer-events: none;
opacity: 0;
transition: opacity 1s ease-in-out;
}
.region-bubble {
position: absolute;
cursor: pointer;
// transition-duration is set dynamically in script
transition-property: top, left, transform, opacity;
transition-timing-function: ease;
will-change: top, left, transform, opacity;
&.zoomed {
top: 53% !important;
left: 50% !important;
transform: translate3d(-50%, -50%, 0) scale(4) !important;
transition-duration: 1000ms !important;
}
span {
font-size: 4.5vw;
left: 50%;
transform: translate(-50%, -50%);
font-weight: 400;
width: fit-content;
position: absolute;
opacity: 1;
transition: opacity 1s ease-in-out;
}
}
/* --- Other Styles --- */
@keyframes sun-drop {
0% {
transform: translateY(-90vw);
@@ -431,95 +460,45 @@ onUnmounted(() => {
animation: sun-drop 1s cubic-bezier(0.54, 0.46, 0.44, 1.28) forwards;
}
@mixin center {
display: flex;
justify-content: center;
align-items: center;
}
.tag {
width: max-content;
padding: 0.5vw .8vw;
}
.green {
@include center;
background-color: #85AF4C;
color: transparent;
font-weight: bold;
width: max-content;
&>span,
&>svg {
@keyframes fade-in {
from {
opacity: 0;
}
&.fake {
background-color: transparent;
color: #FFF;
&>span,
&>svg {
opacity: 1;
}
to {
opacity: 1;
}
}
.white {
@include center;
background-color: #FFF;
font-size: 5vw;
font-weight: bold;
color: #000;
}
.black {
@include center;
background-color: #000;
font-size: 11vw;
font-weight: bold;
color: transparent;
&.fake {
background-color: transparent;
color: #FFF;
}
}
.region-bubble {
position: absolute;
transform: translate3d(-50%, -50%, 0);
}
span,
div {
position: absolute;
}
div,
img {
position: absolute;
}
.bg {
position: absolute;
z-index: -11;
inset: 0;
width: 100%;
object-fit: cover;
}
/* 店铺选择界面样式 */
.store-selector {
position: fixed;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
z-index: 10;
animation: fade-in 0.5s ease-in-out;
}
.back-button {
position: absolute;
top: 16vw;
left: 8vw;
width: 8vw;
animation: fade-in 0.3s ease-in-out;
cursor: pointer;
pointer-events: all;
z-index: 111;
}
.store-selector-content {
top: 15%;
top: 10%;
left: 5%;
width: 90%;
text-align: center;
@@ -535,19 +514,11 @@ img {
.store-list-container {
margin-bottom: 12vw;
height: 40vh;
height: 35vh;
overflow: hidden;
position: relative;
mask-image: linear-gradient(to bottom,
transparent 0%,
black 15%,
black 85%,
transparent 100%);
-webkit-mask-image: linear-gradient(to bottom,
transparent 0%,
black 15%,
black 85%,
transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 85%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 15%, black 85%, transparent 100%);
}
.store-list {
@@ -556,11 +527,17 @@ img {
scroll-behavior: smooth;
scroll-snap-type: y mandatory;
padding: 0;
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
.scroll-padding {
height: calc(20vh - 4vw);
/* 容器高度的一半减去项目高度的一半 */
flex-shrink: 0;
}
@@ -570,7 +547,6 @@ img {
display: flex;
justify-content: center;
align-items: center;
border-radius: 8vw;
scroll-snap-align: center;
transition: all 0.3s ease;
@@ -587,29 +563,32 @@ img {
font-weight: bold;
}
.center-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
height: 10vw;
pointer-events: none;
z-index: 2;
background: rgba(255, 255, 255, 0.05);
}
.name-input-section {
margin-bottom: 8vw;
margin-bottom: 2vw;
.title {
font-size: 5vw;
color: #333;
margin-bottom: 4vw;
font-weight: normal;
}
}
.name-input-section .title {
font-size: 5vw;
font-weight: 600;
color: #333;
margin-bottom: 4vw;
font-weight: normal;
@keyframes pop-up {
from {
scale: 0;
}
to {
scale: 1;
}
}
.input-your-name-notice {
width: 40%;
left: 30%;
animation: pop-up 0.3s ease-out forwards;
transform-origin: top center;
}
.input-container {
@@ -622,47 +601,22 @@ img {
width: 100%;
padding: 2vw 0;
font-size: 4.5vw;
background: transparent;
background: transparent url('../assets/p1/输入框.png') center / contain no-repeat;
border: none;
outline: none;
color: #333;
text-align: center;
background: url('../assets/p1/输入框.png');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
.action-buttons {
display: flex;
justify-content: center;
align-items: center;
gap: 4vw;
}
.start-button {
width: 40vw;
left: 30%;
cursor: pointer;
transition: all 0.1s ease;
}
transition: transform 0.1s ease;
bottom: -30%;
.start-button:hover {
transform: scale(1.05);
}
.start-button .arrow {
font-size: 5vw;
font-weight: normal;
}
/* 隐藏滚动条 */
.store-list::-webkit-scrollbar {
display: none;
}
.store-list {
-ms-overflow-style: none;
scrollbar-width: none;
&:hover {
transform: scale(1.05);
}
}
</style>