633 lines
18 KiB
Vue
633 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, onUnmounted, ref } from 'vue';
|
||
import assets from '../assets';
|
||
|
||
import { regions, type RegionData, type Store } from '../data';
|
||
import AniEle from '../components/AniEle.vue';
|
||
|
||
import AudioEffects from '../assets/sounds'
|
||
|
||
const regionsPos = [
|
||
{ w: 68.8, h: 68.8, l: -1, t: 0, src: assets.p1.大北区, tt: '大北区', trt: -13, ad: 6, key: '大北区' },
|
||
{ w: 73.3, h: 73.3, l: 44, t: 20, src: assets.p1.大东区, tt: '大东区', ad: 3, key: '大东区' },
|
||
{ w: 62.3, h: 63.7, l: -32, t: 72, src: assets.p1['大南区&免税'], tt: '大南区&免税', trt: 5, ad: 3, key: '大南区&免税' },
|
||
{ w: 59.6, h: 59.6, l: -37, t: 15, src: assets.p1.大西区, tt: '大西区', ad: 5, key: '大西区' },
|
||
{ w: 52.1, h: 52, l: 3, t: 40, src: assets.p1.奥莱, tt: '奥莱', ad: 2, key: '奥莱' },
|
||
{ w: 62.7, h: 62.7, l: 33, t: 74, src: assets.p1.博物馆, tt: '博物馆', tc: '#fff', ad: 0, key: '博物馆' },
|
||
]
|
||
|
||
// 响应式状态
|
||
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',
|
||
'博物馆': '博物馆',
|
||
};
|
||
|
||
// 获取区域数据
|
||
function getRegionStores(regionKey: string): Store[] {
|
||
const mappedKey = regionKeyMap[regionKey];
|
||
if (mappedKey === 'combined') {
|
||
if (regionKey === '大南区&免税') {
|
||
return [...regions.大南区, ...regions.免税];
|
||
} else if (regionKey === '奥莱') {
|
||
return [...regions.奥莱北区, ...regions.奥莱东区];
|
||
}
|
||
} else if (mappedKey) {
|
||
return regions[mappedKey];
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// 拖拽选择逻辑 - 使用 Intersection Observer
|
||
let observer: IntersectionObserver | null = null;
|
||
|
||
function initializeStoreSelector() {
|
||
setTimeout(() => {
|
||
const storeItems = document.querySelectorAll('.store-item');
|
||
if (storeItems.length === 0) return;
|
||
|
||
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;
|
||
}
|
||
}
|
||
});
|
||
}, {
|
||
root: document.querySelector('.store-list'),
|
||
rootMargin: '-45% 0px -45% 0px',
|
||
threshold: 0.5
|
||
});
|
||
|
||
storeItems.forEach(item => {
|
||
observer?.observe(item);
|
||
});
|
||
}, 100);
|
||
}
|
||
|
||
function selectStore(index: number) {
|
||
selectedStoreIndex.value = index;
|
||
const storeItems = document.querySelectorAll('.store-item');
|
||
if (storeItems[index]) {
|
||
storeItems[index].scrollIntoView({
|
||
behavior: 'smooth',
|
||
block: 'center'
|
||
});
|
||
}
|
||
}
|
||
|
||
function cleanupObserver() {
|
||
if (observer) {
|
||
observer.disconnect();
|
||
observer = null;
|
||
}
|
||
}
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'startExploration', payload: { region: string; store: string, username: string }): void;
|
||
}>();
|
||
|
||
const noticeToInputName = ref(false);
|
||
async function startExploration() {
|
||
console.log('开始探索:', selectedStores.value[selectedStoreIndex.value]);
|
||
AudioEffects.play("按钮音效");
|
||
if (!selectedRegion.value || selectedStores.value.length === 0) {
|
||
alert('请选择一个区域和店铺!');
|
||
return;
|
||
}
|
||
const nameInput = document.querySelector('.name-input') as HTMLInputElement;
|
||
if (nameInput.value.trim() === '') {
|
||
noticeToInputName.value = true;
|
||
return;
|
||
}
|
||
|
||
emit('startExploration', {
|
||
region: selectedRegion.value,
|
||
store: selectedStores.value[selectedStoreIndex.value].店铺,
|
||
username: nameInput.value
|
||
});
|
||
|
||
await new Promise(resolve => setTimeout(resolve, 400)); // 等待动画结束
|
||
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');
|
||
|
||
|
||
}
|
||
|
||
|
||
// 动画相关的 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 }]);
|
||
|
||
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 () => {
|
||
// --- 初始化动画 ---
|
||
|
||
// 1. Logo 淡入
|
||
setTimeout(() => {
|
||
if (logoEl.value) logoEl.value.style.opacity = '1';
|
||
}, 2000);
|
||
|
||
// 2. 主标题和选择提示的容器淡入 (AniEle本身播放,我们控制其容器)
|
||
|
||
// 3. 气泡下落动画
|
||
const regionBubbles = document.querySelectorAll<HTMLElement>('.region-bubble');
|
||
|
||
regionBubbles.forEach((bubble, index) => {
|
||
const region = regionsPos[index];
|
||
const startTop = `${region.t + 50 - 200 - (100 - region.t)}%`;
|
||
const startLeft = `${region.l / 2 + 50}%`;
|
||
const endTop = `${region.t + 50}%`;
|
||
const endLeft = `${region.l + 50}%`;
|
||
|
||
// 设置初始离屏位置
|
||
Object.assign(bubble.style, {
|
||
top: startTop,
|
||
left: startLeft,
|
||
transform: 'translate3d(-50%, -50%, 0) scale(0.1)',
|
||
transitionDuration: `0ms`
|
||
});
|
||
|
||
// 延迟触发动画
|
||
setTimeout(() => {
|
||
Object.assign(bubble.style, {
|
||
top: endTop,
|
||
left: endLeft,
|
||
transform: 'translate3d(-50%, -50%, 0) scale(1)',
|
||
|
||
transitionDuration: `${600 + region.ad * 100}ms`
|
||
});
|
||
}, 1000); // 基础延迟
|
||
|
||
// --- 绑定事件 ---
|
||
const pDown = () => {
|
||
AudioEffects.play("按钮音效");
|
||
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.classList.add('zoomed');
|
||
bubble.style.filter = 'brightness(1)';
|
||
|
||
// 隐藏气泡文字
|
||
const span = bubble.querySelector('span');
|
||
if (span) span.style.opacity = '0';
|
||
|
||
// 淡出其他UI元素
|
||
if (logoEl.value) logoEl.value.style.opacity = '0';
|
||
|
||
mainLogoRules.value[0].reverse = true;
|
||
selectInfoRules.value[0].reverse = true;
|
||
|
||
// 淡出其他气泡
|
||
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);
|
||
}
|
||
});
|
||
|
||
// 动画完成后显示选择界面
|
||
setTimeout(() => {
|
||
showStoreSelector.value = true;
|
||
initializeStoreSelector();
|
||
}, 1000); // 等待放大动画完成
|
||
};
|
||
|
||
bubble.addEventListener('pointerdown', pDown);
|
||
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;
|
||
});
|
||
});
|
||
|
||
|
||
function goBackToRegionSelection() {
|
||
AudioEffects.play("按钮音效");
|
||
if (!selectedBubbleEl.value) return;
|
||
selectionInProgress.value = true; // 加锁防止重复操作
|
||
|
||
mainLogoRules.value[0].reverse = false;
|
||
AudioEffects.play("标题出现")
|
||
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();
|
||
});
|
||
|
||
</script>
|
||
|
||
<template>
|
||
<section class="p1">
|
||
<img :src="assets.标准logo" alt="" class="logo" ref="logoEl">
|
||
|
||
<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>
|
||
|
||
<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 + '%',
|
||
}" class="region-bubble">
|
||
|
||
<img :src="i.src" alt="" style="height: 100%;width: 100%;"
|
||
draggable="false">
|
||
<span
|
||
:style="{ top: (i.trt ?? 0) + 50 + '%' }">
|
||
*{{ i.tt }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<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">
|
||
<h2 class="region-title">你来自{{ selectedRegion }}的</h2>
|
||
|
||
<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>
|
||
|
||
<div class="scroll-padding relative"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="name-input-section relative">
|
||
<div class="title relative">请输入你的中文名字</div>
|
||
<div class="input-container relative">
|
||
<input type="text" class="name-input" placeholder="">
|
||
</div>
|
||
</div>
|
||
<img class="input-your-name-notice"
|
||
src="../assets/p1/请填写你的名字.webp" alt=""
|
||
v-if="noticeToInputName">
|
||
|
||
<AniEle :url="assets.ani.开始探索" :width="548" :height="143"
|
||
:rules="startExplorationRules" class="start-button"
|
||
@click="startExploration">
|
||
</AniEle>
|
||
</div>
|
||
|
||
<img src="../assets/返回按钮.webp" 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: max-content;
|
||
position: absolute;
|
||
opacity: 1;
|
||
transition: opacity 1s ease-in-out;
|
||
|
||
}
|
||
}
|
||
|
||
|
||
/* --- Other Styles --- */
|
||
|
||
@keyframes sun-drop {
|
||
0% {
|
||
transform: translateY(-90vw);
|
||
}
|
||
|
||
100% {
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
.sun-warp {
|
||
top: 32%;
|
||
width: 24%;
|
||
left: 4.5%;
|
||
z-index: -1;
|
||
transform: translateY(-90vw);
|
||
animation: sun-drop 1s cubic-bezier(0.54, 0.46, 0.44, 1.28) forwards;
|
||
}
|
||
|
||
@keyframes fade-in {
|
||
from {
|
||
opacity: 0;
|
||
}
|
||
|
||
to {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
span,
|
||
div,
|
||
img {
|
||
position: absolute;
|
||
}
|
||
|
||
.store-selector {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
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: 10%;
|
||
left: 5%;
|
||
width: 90%;
|
||
text-align: center;
|
||
color: #333;
|
||
}
|
||
|
||
.region-title {
|
||
font-size: 6vw;
|
||
font-weight: 400;
|
||
margin-bottom: 6vw;
|
||
color: #333;
|
||
}
|
||
|
||
.store-list-container {
|
||
margin-bottom: 10vw;
|
||
height: 60vw;
|
||
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%);
|
||
}
|
||
|
||
.store-list {
|
||
height: 100%;
|
||
overflow-y: auto;
|
||
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;
|
||
}
|
||
|
||
.store-item {
|
||
width: 50vw;
|
||
height: 8vw;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
border-radius: 8vw;
|
||
scroll-snap-align: center;
|
||
transition: all 0.3s ease;
|
||
cursor: pointer;
|
||
opacity: 0.6;
|
||
font-size: 4vw;
|
||
margin: 1vw auto;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.store-item.active {
|
||
background: rgba(255, 255, 255, 0.9);
|
||
opacity: 1;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.name-input-section {
|
||
margin-bottom: 2vw;
|
||
|
||
.title {
|
||
font-size: 4.5vw;
|
||
color: #000;
|
||
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 {
|
||
position: relative;
|
||
width: 55%;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.name-input {
|
||
width: 100%;
|
||
padding: 2vw 0;
|
||
font-size: 4.5vw;
|
||
background: transparent url('../assets/p1/输入框.webp') center / contain no-repeat;
|
||
border: none;
|
||
outline: none;
|
||
color: #333;
|
||
text-align: center;
|
||
}
|
||
|
||
.start-button {
|
||
width: 40vw;
|
||
left: 30%;
|
||
cursor: pointer;
|
||
transition: transform 0.1s ease;
|
||
bottom: -30%;
|
||
|
||
&:hover {
|
||
transform: scale(1.05);
|
||
}
|
||
}
|
||
</style> |