|
|
<template> <div class="lottery-3d-container"> <div ref="threeContainer" class="three-container"></div>
<!-- 分页指示器 --> <div v-if="totalPages > 1" class="page-indicator"> 第 {{ currentPage + 1 }} 页 / 共 {{ totalPages }} 页 </div>
<!-- 滚动提示 --> <!-- <div v-if="totalPages > 1 && currentPage === 0" class="scroll-hint"> 向下滚动查看更多 </div> <div v-if="totalPages > 1 && currentPage === totalPages - 1" class="scroll-hint"> 向上滚动查看上一页 </div> --> </div> </template>
<script setup> import { ref, onMounted, onBeforeUnmount, defineExpose, watch, computed, } from "vue"; import * as THREE from "three"; import { CSS3DRenderer, CSS3DObject, } from "three/examples/jsm/renderers/CSS3DRenderer.js"; // import { TrackballControls } from 'three/examples/jsm/controls/TrackballControls.js'; // 移除拖拽控件
import TWEEN from "@tweenjs/tween.js"; import { NUMBER_MATRIX } from "../../../utils/config.js"; import CardItem from "./CardItem.vue"; import { createApp } from "vue"; import { getUserListApi } from "../../../api/API"; import { useLotteryStore } from "../../../store/lottery"; const lotteryStore = useLotteryStore();
const winners = computed({ get: () => lotteryStore.winners, set: (val) => lotteryStore.setWinners(val), });
const threeContainer = ref(null); let renderer, scene, camera, animationId; // let controls; // 移除controls
// 全局变量存储当前选中的卡片索引数组
let globalCardIndexes = [];
// 分页相关变量
const currentPage = ref(0); const totalPages = ref(0); const cardsPerPage = 10; // 每页显示的卡片数量
let isPageTransitioning = false; // 防止页面切换时的重复操作
// 3D卡片与目标
const threeDCards = []; const targets = { table: [], sphere: [], }; function swapCardContents() { // 确保有足够卡片且不在抽奖状态
if (threeDCards.length < 2 || globalCardIndexes.length > 0) return;
// 随机选择两张不同的卡片
let indexA = Math.floor(Math.random() * threeDCards.length); let indexB = Math.floor(Math.random() * threeDCards.length); while (indexA === indexB) { indexB = Math.floor(Math.random() * threeDCards.length); }
const cardA = threeDCards[indexA].element; const cardB = threeDCards[indexB].element;
// 保存原始内容(如果尚未保存)
if (!cardA.dataset.originalContent) { cardA.dataset.originalContent = cardA.innerHTML; } if (!cardB.dataset.originalContent) { cardB.dataset.originalContent = cardB.innerHTML; }
// 交换内容并添加动画效果
[cardA.innerHTML, cardB.innerHTML] = [cardB.innerHTML, cardA.innerHTML]; cardA.classList.add('swap-animation'); cardB.classList.add('swap-animation');
// 动画结束后移除动画类
setTimeout(() => { cardA.classList.remove('swap-animation'); cardB.classList.remove('swap-animation'); }, 500); } function createElement(css = "", text = "") { const dom = document.createElement("div"); dom.className = css; dom.innerHTML = text; return dom; }
function createCard(user, isBold, id, showTable, company) { // 使用 CardItem 组件动态渲染为 DOM 节点
const container = document.createElement("div"); const app = createApp(CardItem, { id, user, isBold, showTable, company, // highlight, prize 可后续补充
}); app.mount(container); return container.firstElementChild; }
function createCards(member, length, showTable, position, config) { let index = 0; for (let i = 0; i < config.ROW_COUNT; i++) { for (let j = 0; j < config.COLUMN_COUNT; j++) { // 4. 判断是否高亮
const isBold = (config.HIGHLIGHT_CELL || []).includes(j + "-" + i); const element = createCard( member[index % length], isBold, index, showTable, config.COMPANY ); const object = new CSS3DObject(element); object.position.x = Math.random() * 4000 - 2000; object.position.y = Math.random() * 4000 - 2000; object.position.z = Math.random() * 4000 - 2000; scene.add(object); threeDCards.push(object); const targetObject = new THREE.Object3D(); targetObject.position.x = j * 140 - position.x; targetObject.position.y = -(i * 180) + position.y; targets.table.push(targetObject); index++; } } }
function createSphereTargets() { const vector = new THREE.Vector3(); for (let i = 0, l = threeDCards.length; i < l; i++) { const phi = Math.acos(-1 + (2 * i) / l); const theta = Math.sqrt(l * Math.PI) * phi; const object = new THREE.Object3D(); object.position.setFromSphericalCoords(600, phi, theta); object.position.y -= 200; // 向下偏移200px
// 修正朝向计算:让卡牌朝向球体中心点
vector.set(0, -200, 0); // 球体中心点,Y轴偏移与上面保持一致
object.lookAt(vector); targets.sphere.push(object); } }
// 动画与切换相关方法
function switchScreen(type) { if (highlightTimeout) { clearTimeout(highlightTimeout); highlightTimeout = null; } // 示例:enter/table/sphere 切换
if (type === "enter") { transform(targets.table, 2000, () => { addHighlight(); highlightTimeout = null; }); // 动画结束后加高亮
} else { transform(targets.sphere, 2000, () => removeHighlight()); // 动画结束后移除高亮
} }
function transform(targetsArr, duration, onComplete) { for (let i = 0; i < threeDCards.length; i++) { const object = threeDCards[i]; const target = targetsArr[i]; new TWEEN.Tween(object.position) .to( { x: target.position.x, y: target.position.y, z: target.position.z, }, Math.random() * duration + duration ) .easing(TWEEN.Easing.Exponential.InOut) .start(); new TWEEN.Tween(object.rotation) .to( { x: target.rotation.x, y: target.rotation.y, z: target.rotation.z, }, Math.random() * duration + duration ) .easing(TWEEN.Easing.Exponential.InOut) .start(); } new TWEEN.Tween({}) .to({}, duration * 2) .onUpdate(() => render()) .onComplete(() => { if (onComplete) onComplete(); }) .start(); }
function selectCard(selectedCardIndex, currentLuckys, duration = 600) { if (highlightTimeout) { clearTimeout(highlightTimeout); highlightTimeout = null; } removeHighlight(); // 开始抽奖前移除高亮
console.log("selectCard called:", { selectedCardIndex, currentLuckys, duration, }); return new Promise((resolve) => { const width = 140;
// 计算总页数
totalPages.value = Math.ceil(currentLuckys.length / cardsPerPage); currentPage.value = 0;
// 为每页计算位置信息
const pageLocates = [];
for (let page = 0; page < totalPages.value; page++) { const startIndex = page * cardsPerPage; const endIndex = Math.min( (page + 1) * cardsPerPage, currentLuckys.length ); const pageCount = endIndex - startIndex;
const pageLocate = [];
// 根据当前页的人数决定排列方式
if (pageCount > 5) { // 大于5个分两排显示(与抽10人时的排列相同)
const yPosition = [-87, 87]; const mid = Math.ceil(pageCount / 2); let tag = -(mid - 1) / 2;
for (let i = 0; i < mid; i++) { pageLocate.push({ x: tag * width, y: yPosition[0], }); tag++; }
tag = -(pageCount - mid - 1) / 2; for (let i = mid; i < pageCount; i++) { pageLocate.push({ x: tag * width, y: yPosition[1], }); tag++; } } else { // 小于等于5个一排显示(与抽不足10人时的排列相同)
let tag = -(pageCount - 1) / 2; for (let i = 0; i < pageCount; i++) { pageLocate.push({ x: tag * width, y: 0, }); tag++; } }
pageLocates.push(pageLocate); }
console.log("pageLocates calculated:", pageLocates);
// 初始化所有卡片位置(隐藏超出第一页的卡片)
selectedCardIndex.forEach((cardIndex, index) => { changeCard(cardIndex, currentLuckys[index]); const object = threeDCards[cardIndex];
// 计算卡片应该在第几页
const cardPage = Math.floor(index / cardsPerPage); const isVisible = cardPage === 0;
// 计算在当前页中的索引
const pageIndex = index % cardsPerPage; const pageLocate = pageLocates[cardPage][pageIndex];
// 设置初始位置:第一页的卡片正常显示,其他页的卡片从下方隐藏
let initialY; if (isVisible) { initialY = pageLocate.y; } else { // 非第一页的卡片从下方隐藏(为后续向上飞出做准备)
initialY = pageLocate.y - 1000; }
new TWEEN.Tween(object.position) .to( { x: pageLocate.x, y: initialY, z: 2200, }, Math.random() * duration + duration ) .easing(TWEEN.Easing.Exponential.InOut) .start();
new TWEEN.Tween(object.rotation) .to( { x: 0, y: 0, z: 0, }, Math.random() * duration + duration ) .easing(TWEEN.Easing.Exponential.InOut) .start();
object.element.classList.add("prize"); });
// 保存页面位置信息到全局变量,供switchPage使用
window.pageLocates = pageLocates;
new TWEEN.Tween({}) .to({}, duration * 2) .onUpdate(() => render()) .start() .onComplete(() => { console.log("selectCard animation completed"); // 如果有多页,添加鼠标滚轮事件监听
if (totalPages.value > 1) { addWheelListener(); } resolve(); }); }); }
// 分页切换函数
function switchPage(direction) { if (isPageTransitioning || totalPages.value <= 1) return;
const newPage = direction === "next" ? currentPage.value + 1 : currentPage.value - 1; if (newPage < 0 || newPage >= totalPages.value) return;
isPageTransitioning = true; const duration = 800;
// 使用保存的页面位置信息
const pageLocates = window.pageLocates; if (!pageLocates) { console.error("页面位置信息未找到"); isPageTransitioning = false; return; } console.log("globalCardIndexes", globalCardIndexes); // 动画切换卡片位置
globalCardIndexes.forEach((cardIndex, index) => { const object = threeDCards[cardIndex]; const cardPage = Math.floor(index / cardsPerPage); const isVisible = cardPage === newPage; const wasVisible = cardPage === currentPage.value;
// 计算在当前页中的索引
const pageIndex = index % cardsPerPage; const pageLocate = pageLocates[cardPage][pageIndex];
// 根据切换方向决定动画效果
let targetY; if (isVisible) { // 当前页要显示的卡片
if (direction === "next") { // 索引增大:从下方飞出
targetY = pageLocate.y; } else { // 索引减少:从上方飞出
targetY = pageLocate.y; } } else { // 当前页要隐藏的卡片
if (direction === "next") { // 索引增大:向上飞走
targetY = pageLocate.y + 1000; } else { // 索引减少:向下飞走
targetY = pageLocate.y - 1000; } }
// 设置起始位置
let startY; if (wasVisible) { // 当前页的卡片从当前位置开始
startY = object.position.y; } else { // 非当前页的卡片从隐藏位置开始
if (direction === "next") { // 索引增大:从下方开始
startY = pageLocate.y - 1000; } else { // 索引减少:从上方开始
startY = pageLocate.y + 1000; } }
// 先设置起始位置
object.position.y = startY;
new TWEEN.Tween(object.position) .to( { x: pageLocate.x, y: targetY, z: 2200, }, duration ) .easing(TWEEN.Easing.Cubic.InOut) .start(); });
new TWEEN.Tween({}) .to({}, duration) .onUpdate(() => render()) .onComplete(() => { currentPage.value = newPage; isPageTransitioning = false; console.log( `切换到第 ${currentPage.value + 1} 页,共 ${totalPages.value} 页` ); }) .start(); }
// 鼠标滚轮事件处理
function handleWheel(event) { if (isPageTransitioning || totalPages.value <= 1) return;
event.preventDefault();
if (event.deltaY > 0) { // 向下滚动,显示下一页
switchPage("next"); } else if (event.deltaY < 0) { // 向上滚动,显示上一页
switchPage("prev"); } }
// 添加鼠标滚轮事件监听器
function addWheelListener() { if (threeContainer.value) { threeContainer.value.addEventListener("wheel", handleWheel, { passive: false, }); } }
// 移除鼠标滚轮事件监听器
function removeWheelListener() { if (threeContainer.value) { threeContainer.value.removeEventListener("wheel", handleWheel); } }
function resetCard(selectedCardIndex, duration = 500) { if (!selectedCardIndex || selectedCardIndex.length === 0) { return Promise.resolve(); }
// 移除鼠标滚轮事件监听器
removeWheelListener();
// 重置分页状态
currentPage.value = 0; totalPages.value = 0; isPageTransitioning = false;
// 清理保存的页面位置信息
if (window.pageLocates) { delete window.pageLocates; }
// 清空全局卡片索引数组
globalCardIndexes = [];
selectedCardIndex.forEach((index) => { const object = threeDCards[index]; const target = targets.sphere[index];
new TWEEN.Tween(object.position) .to( { x: target.position.x, y: target.position.y, z: target.position.z, }, Math.random() * duration + duration ) .easing(TWEEN.Easing.Exponential.InOut) .start();
new TWEEN.Tween(object.rotation) .to( { x: target.rotation.x, y: target.rotation.y, z: target.rotation.z, }, Math.random() * duration + duration ) .easing(TWEEN.Easing.Exponential.InOut) .start(); });
return new Promise((resolve) => { new TWEEN.Tween({}) .to({}, duration * 2) .onUpdate(() => render()) .start() .onComplete(() => { selectedCardIndex.forEach((index) => { const object = threeDCards[index]; // 恢复原始内容
if (object.element.dataset.originalContent) { object.element.innerHTML = object.element.dataset.originalContent; delete object.element.dataset.originalContent; } object.element.classList.remove("prize"); }); resolve(); }); }); }
function changeCard(cardIndex, user) { // 保存到全局变量数组
if (!globalCardIndexes.includes(cardIndex)) { globalCardIndexes.push(cardIndex); }
const card = threeDCards[cardIndex].element;
// 保存原始内容,以便后续恢复
if (!card.dataset.originalContent) { card.dataset.originalContent = card.innerHTML; }
// 设置中奖内容 - 适配后端返回的数据格式
// 后端返回的数据格式: { jwcode: "5412", username: "猪八戒22" }
const jwcode = user.jwcode || user[0] || ""; const username = user.username || user[1] || ""; const company = user.company || user[2] || "PSST"; card.innerHTML = `<div style="font-size: 30px; font-weight: bold; color: #ffffff; text-align: center; display: flex; justify-content: center; align-items: center; width: 100%; height: 100%;">${jwcode}</div>`;
// 添加中奖样式类
card.classList.add("prize"); }
function changeCard1() { console.log("执行取消高光,当前卡片索引:", globalCardIndexes);
// 移除鼠标滚轮事件监听器
removeWheelListener();
// 重置分页状态
currentPage.value = 0; totalPages.value = 0; isPageTransitioning = false;
// 清理保存的页面位置信息
if (window.pageLocates) { delete window.pageLocates; }
globalCardIndexes.forEach((cardIndex) => { const card = threeDCards[cardIndex].element; // console.log('取消卡片', cardIndex, '的高光');
// 恢复原始内容
if (card.dataset.originalContent) { card.innerHTML = card.dataset.originalContent; delete card.dataset.originalContent; }
// 移除prize类,让CardItem组件的样式重新生效
card.classList.remove("prize"); }); // 清空数组
globalCardIndexes = []; }
function shine(cardIndex, color) { const card = threeDCards[cardIndex].element; card.style.backgroundColor = color || `rgba(0,127,127,${Math.random() * 0.7 + 0.25})`; }
// 响应式高亮索引
const highlightedIndexes = ref([]);
// 替换 addHighlight 和 removeHighlight 为响应式写法
function addHighlight(indexes = null) { if (indexes) { highlightedIndexes.value = [...indexes]; } else { // 默认高亮所有 .lightitem
highlightedIndexes.value = threeDCards // .map((obj, idx) =>
// obj.element.classList.contains("lightitem") ? idx : null
// )
.filter((idx) => idx !== null); } }
function removeHighlight(indexes = null) { if (indexes) { highlightedIndexes.value = highlightedIndexes.value.filter( (i) => !indexes.includes(i) ); } else { highlightedIndexes.value = []; } }
// 监听高亮索引变化并同步到DOM
watch(highlightedIndexes, (newVal) => { threeDCards.forEach((cardObj, idx) => { if (newVal.includes(idx)) { cardObj.element.classList.add("highlight"); } else { cardObj.element.classList.remove("highlight"); } }); });
let rotateObj = null; let highlightTimeout = null;
function rotateBallStart() { return new Promise((resolve) => { if (!scene) return resolve(); scene.rotation.y = 0; rotateObj = new TWEEN.Tween(scene.rotation) .to({ y: Math.PI * 6 * 1000 }, 3000 * 1000) .onUpdate(() => render()) .onComplete(() => resolve()) .start(); }); }
function rotateBallStop() { return new Promise((resolve) => { if (!scene || !rotateObj) return resolve(); rotateObj.stop(); // 完全还原原生补偿动画逻辑
const currentY = scene.rotation.y; const targetY = Math.ceil(currentY / (2 * Math.PI)) * 2 * Math.PI; const deltaY = Math.abs(targetY - currentY); const duration = 500 + 1000 * (deltaY / Math.PI); new TWEEN.Tween(scene.rotation) .to({ y: targetY }, duration) .easing(TWEEN.Easing.Cubic.Out) .onUpdate(() => render()) .onComplete(() => { scene.rotation.y = 0; render(); resolve(); }) .start(); }); }
function getTotalCards() { return threeDCards.length; }
onMounted(async () => { // 初始化 3D 场景
scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera( 40, window.innerWidth / window.innerHeight, 1, 10000 ); camera.position.z = 3000; // camera.position.y = 250; // 整体上移10px
// 或
// scene.position.y = 10; // 整体上移10px
renderer = new CSS3DRenderer(); renderer.setSize(window.innerWidth, window.innerHeight); if (threeContainer.value) { threeContainer.value.appendChild(renderer.domElement); }
// 2. 生成高亮坐标(以数字8为例)
let text = "0123"; let step = 5; let xoffset = 1; let yoffset = 1; let highlight = []; text.split("").forEach((n) => { highlight = highlight.concat( NUMBER_MATRIX[n].map((item) => { return `${item[0] + xoffset}-${item[1] + yoffset}`; }) ); xoffset += step; }); // const highlightCells = NUMBER_MATRIX["0"].map(([x, y]) => `${x}-${y}`);
const config = { ROW_COUNT: 7, // 数字矩阵是5行
COLUMN_COUNT: 21, // 数字矩阵是4列
HIGHLIGHT_CELL: highlight, COMPANY: "演示公司", };
const userList = await getUserListApi(); console.log("3D调用一次接口", userList); // lotteryStore.setWinners(userList);
// console.log("userList", userList);
// 将用户数据转换为兼容格式,用于3D卡片显示
const member = userList.data.map((item) => [ item.jwcode, item.username, "PSST", ]);
// 将用户列表存储到store中,用于卡牌文字切换
const userNames = userList.data.map( (item) => item.jwcode || item.username || "" ); lotteryStore.setAllUsers(userNames);
const length = member.length; const showTable = true; const position = { x: (100 * config.COLUMN_COUNT - 20) / 2, y: (120 * config.ROW_COUNT - 20) / 2, }; createCards(member, length, showTable, position, config); // 3. 传递高亮配置
createSphereTargets();
// 先渲染散落状态
render(); animate();
// 延迟后自动聚集到界面中间
setTimeout(() => { switchScreen("enter"); }, 500);
window.addEventListener("resize", onWindowResize); // swapInterval.value = setInterval(swapCardContents, swapIntervalTime.value);
});
onBeforeUnmount(() => { window.removeEventListener("resize", onWindowResize); removeWheelListener(); // 移除鼠标滚轮事件监听器
if (animationId) cancelAnimationFrame(animationId); if (highlightTimeout) { clearTimeout(highlightTimeout); highlightTimeout = null; } // if (swapInterval.value) {
// clearInterval(swapInterval.value);
// swapInterval.value = null;
// }
});
function render() { renderer.render(scene, camera); }
function animate() { animationId = requestAnimationFrame(animate); TWEEN.update(); // controls.update(); // 移除controls
render(); }
function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); render(); }
defineExpose({ resetCard, addHighlight, switchScreen, rotateBallStart, rotateBallStop, selectCard, getTotalCards, changeCard1, }); </script>
<style scoped> .lottery-3d-container { width: 100vw; height: 100vh; overflow: hidden; position: relative; } .three-container { width: 100%; height: 100%; }
/* 分页指示器样式 */ .page-indicator { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 1000; background: rgba(0, 0, 0, 0.7); color: white; padding: 8px 16px; border-radius: 20px; font-size: 14px; pointer-events: none; }
/* 滚动提示样式 */ .scroll-hint { position: absolute; bottom: 60px; left: 50%; transform: translateX(-50%); z-index: 1000; background: rgba(0, 0, 0, 0.7); color: white; padding: 8px 16px; border-radius: 20px; font-size: 12px; pointer-events: none; animation: fadeInOut 2s ease-in-out infinite; }
.price-font { font-size: 16px; font-weight: bold; color: #ffffff; text-align: center; display: flex; justify-content: center; align-items: center; width: 100%; height: 100%; }
@keyframes fadeInOut { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; } } </style>
|