You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

886 lines
23 KiB

<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: 20px; 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>