arcteryx-game/src/pages/Page1.vue
2025-08-18 21:39:34 +08:00

633 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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: -1%;
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>