Commit 0638a2a7 authored by 陈冲's avatar 陈冲
parents 740e86b2 26176f71
This source diff could not be displayed because it is too large. You can view the blob instead.
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import Loading from './views/LoadingView.vue'
import Rank1 from './views/Rank1View.vue'
import Rank2 from './views/Rank2View.vue'
import { useAdminGameSocket } from '@/composables/useAdminGameSocket'
import type Player from '@/commons/player.ts'
import { playGame1Music } from '@/commons/music'
import { $remove_socket_storage } from '@/commons/utils.ts'
const router = useRouter()
type GameScreen = 'loading' | 'rank1' | 'rank2'
const PLAYER_LIMIT = 20
const RANK_LIMIT = 10
const emptyLoadingPlayer = () => ({
score: '-',
telephone: '',
userid: '',
wechat_id: '',
nickname: '虚位以待',
avatar: '',
})
const emptyRankPlayer = (index: number): Player => ({
wechat_id: `empty-${index}`,
nickname: '虚位以待',
avatar: '',
score: '-',
})
const screen = ref<GameScreen>('loading')
const loadingPlayers = ref<any[]>(Array.from({ length: PLAYER_LIMIT }, emptyLoadingPlayer))
const playerCount = ref(0)
const rankPlayers = ref<Player[]>(Array.from({ length: RANK_LIMIT }, (_, index) => emptyRankPlayer(index)))
const rank1Ref = ref<InstanceType<typeof Rank1> | null>(null)
let isPlayingStartAnimation = false
const confirmRefresh = (event: BeforeUnloadEvent) => {
event.preventDefault()
event.returnValue = ''
}
const renderPlayers = (data: any) => {
const players = Array.isArray(data?.list) ? data.list : []
playerCount.value = Number(data?.count ?? players.length)
loadingPlayers.value = Array.from(
{ length: PLAYER_LIMIT },
(_, index) => players[index] ?? emptyLoadingPlayer(),
)
}
const updateRankPlayers = (data: any) => {
if (!Array.isArray(data)) return
rankPlayers.value = Array.from(
{ length: RANK_LIMIT },
(_, index) => data[index] ?? emptyRankPlayer(index),
)
}
const playerToRankPlayer = (player: any, index: number): Player => ({
telephone: player?.telephone ?? '',
userid: player?.userid ?? '',
wechat_id: player?.wechat_id || `empty-${index}`,
nickname: player?.nickname || '虚位以待',
avatar: player?.avatar || '',
score: player?.score ?? '-',
})
const seedRankPlayersFromLoading = () => {
rankPlayers.value = Array.from(
{ length: RANK_LIMIT },
(_, index) => {
const player = loadingPlayers.value[index]
if (!player?.userid && !player?.wechat_id) {
return emptyRankPlayer(index)
}
return playerToRankPlayer(player, index)
},
)
}
const resetRoundState = () => {
playerCount.value = 0
loadingPlayers.value = Array.from({ length: PLAYER_LIMIT }, emptyLoadingPlayer)
rankPlayers.value = Array.from({ length: RANK_LIMIT }, (_, index) => emptyRankPlayer(index))
isPlayingStartAnimation = false
}
const gotoLoading = (reset = false) => {
if (reset) {
resetRoundState()
}
screen.value = 'loading'
}
const gotoRank1WithIntro = async () => {
if (isPlayingStartAnimation) return
isPlayingStartAnimation = true
seedRankPlayersFromLoading()
screen.value = 'rank1'
await nextTick()
await rank1Ref.value?.startIntro()
screen.value = 'rank2'
isPlayingStartAnimation = false
}
function handleRoomState(data: any) {
if (Array.isArray(data?.list)) {
renderPlayers(data)
}
switch (data?.status) {
case 0:
gotoLoading(true)
createRoom(1)
break
case 1:
gotoLoading()
break
case 2:
seedRankPlayersFromLoading()
screen.value = 'rank1'
break
case 3:
screen.value = 'rank2'
break
}
}
const { startGame, createRoom, backRoom } = useAdminGameSocket({
gameId: 'game1',
onRoomState: handleRoomState,
onRoomJoin: (data) => {
renderPlayers(data)
gotoLoading()
},
onGameStart: gotoRank1WithIntro,
onRoomRank: updateRankPlayers,
onRelogin: async () => {
$remove_socket_storage();
await router.replace('/')
}
})
const startGameWithMusic = () => {
if (playerCount.value == 0) {
alert('房间中没有玩家')
return;
}
playGame1Music()
startGame()
}
const nextRound = () => {
gotoLoading(true)
//重开房间
backRoom(1)
}
const nextRoundWithMusic = () => {
playGame1Music()
nextRound()
}
onMounted(() => {
window.addEventListener('beforeunload', confirmRefresh)
playGame1Music()
})
onUnmounted(() => {
window.removeEventListener('beforeunload', confirmRefresh)
})
</script>
<template>
<Loading v-if="screen !== 'loading'" :players="loadingPlayers" :player-count="playerCount"
@start="startGameWithMusic" />
<!-- <Rank1 v-else-if="screen === 'rank1'" ref="rank1Ref" :rank-list="rankPlayers" @start="startGameWithMusic" -->
<Rank1 v-else ref="rank1Ref" :rank-list="rankPlayers" @start="startGameWithMusic"
@next="nextRoundWithMusic" />
<!-- <Rank2 v-else :rank-list="rankPlayers" @next="nextRoundWithMusic" /> -->
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { $format_str } from '@/commons/utils.ts';
import DesignStage from '@/components/DesignStage.vue'
import { assetUrl, cssAssetUrl } from '@/commons/assets.ts';
defineProps<{
players: any[];
playerCount: number;
}>();
const emit = defineEmits<{
start: [];
}>();
const imageUrls = {
bg: cssAssetUrl('game5/bg.svg'),
logo: cssAssetUrl('game1/logo.png'),
title1: cssAssetUrl('game5/title1.webp'),
// title2: cssAssetUrl('game1/title2.png'),
qrcode: assetUrl('game5/qrcode.png'),
left: cssAssetUrl('game5/left.webp'),
right: cssAssetUrl('game5/right.webp'),
bottomLayers: cssAssetUrl('game1/bottom-layers.png'),
avatarAnimation: cssAssetUrl('avatar_animation.png'),
avatar: cssAssetUrl('avatar.png'),
line: cssAssetUrl('game1/line.png'),
start1: cssAssetUrl('game1/start-1.png'),
start2: cssAssetUrl('game1/start-2.png'),
};
const isStartPressed = ref(false);
const pressStartButton = () => {
isStartPressed.value = true;
emit('start');
};
const releaseStartButton = () => {
isStartPressed.value = false;
};
const renderAvatar = (item: any | null) => {
if (item && item.avatar) {
return `background: url("${String(item.avatar).replace(/"/g, '\\"')}") center / cover no-repeat;`
}
return `background: ${imageUrls.avatar} center / cover no-repeat;`
}
</script>
<template>
<DesignStage>
<div class="bg">
<div class="img-logo"></div>
<div class="img-title1"></div>
<!-- <div class="img-title2"></div> -->
<div class="img-qrcode-container">
<div class="img-qrcode"><img :src="imageUrls.qrcode" /></div>
<div class="txt-qrcode">微信扫码参与</div>
</div>
<div class="img-left"></div>
<div class="img-right"></div>
<div class="list-container">
<div class="txt-count">当前在线人数 <span>{{ playerCount }}</span></div>
<div class="list-container-left">
<div v-for="(item, index) in players" :key="item.userid || item.wechat_id || `empty-${index}`">
<div>
<div class="img-avatar-container">
<div class="img-avatar" :style="`${renderAvatar(item)}`"></div>
</div>
<div class="txt-nickname">{{ $format_str(item.nickname, 12) }}</div>
</div>
</div>
</div>
<div class="list-container-line"></div>
<div class="list-container-right">
<div
class="btn-start"
:class="{ pressed: isStartPressed }"
@pointerdown="pressStartButton"
@pointerup="releaseStartButton"
@pointerleave="releaseStartButton"
@pointercancel="releaseStartButton"
></div>
</div>
</div>
</div>
</DesignStage>
</template>
<style scoped>
.bg {
width: 1920px;
height: 1080px;
background: v-bind('imageUrls.bg') center / cover no-repeat;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.img-logo {
background: v-bind('imageUrls.logo');
background-size: cover;
width: 428px;
height: 77px;
position: absolute;
left: 37px;
top: 82px;
}
.img-title1 {
background: v-bind('imageUrls.title1');
width: 891px;
height: 309px;
margin: 0 auto;
position: relative;
top: 60px;
}
/* .img-title2 {
background: v-bind('imageUrls.title2');
width: 643px;
height: 140px;
margin: 0 auto;
position: relative;
top: 80px;
} */
.img-qrcode-container {
width: 144px;
height: 177px;
position: absolute;
z-index: 1;
top: 40px;
right: 40px;
.img-qrcode {
width: 100%;
height: 144px;
border-radius: 10px;
img {
width: 147px;
height: 147px;
}
}
.txt-qrcode {
font-size: 24px;
color: white;
}
}
.img-left {
background: v-bind('imageUrls.left') no-repeat;
width: 531px;
height: 418px;
position: absolute;
top: 193px;
left: 47px;
}
.img-right {
background: v-bind('imageUrls.right') no-repeat;
width: 423px;
height: 419px;
position: absolute;
top: 193px;
right: 50px;
}
.list-container {
background: v-bind('imageUrls.bottomLayers');
width: 1800px;
height: 397px;
position: absolute;
left: 60px;
bottom: 60px;
.txt-count {
font-size: 36px;
margin-left: 30px;
margin-top: 20px;
color: #AA0000;
font-weight: bold;
>span {
margin-left: 20px;
font-size: 48px;
position: relative;
top: 5px;
}
}
.list-container-left {
float: left;
display: grid;
grid-template-columns: repeat(10, 1fr);
row-gap: 20px;
width: 73%;
margin-top: 30px;
height: 250px;
align-content: center;
.img-avatar-container {
position: relative;
margin: 0 auto;
width: 72px;
height: 72px;
border-radius: 36px;
}
.img-avatar-container::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: v-bind('imageUrls.avatarAnimation');
animation: avatar-rotate 10s linear infinite;
}
.img-avatar {
position: relative;
top: 6px;
z-index: 1;
margin: 0 auto;
width: 60px;
height: 60px;
border-radius: 30px;
/* background: v-bind('imageUrls.avatar'); */
}
.txt-nickname {
color: #666;
font-size: 20px;
text-align: center;
}
}
.list-container-line {
float: left;
width: 1px;
height: 281px;
margin-left: 50px;
background: v-bind('imageUrls.line') center no-repeat;
}
.list-container-right {
float: left;
.btn-start {
width: 316px;
height: 105px;
margin-left: 60px;
margin-top: 80px;
background: v-bind('imageUrls.start1');
cursor: pointer;
}
.btn-start.pressed {
background: v-bind('imageUrls.start2');
}
}
}
</style>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from "vue";
import DesignStage from "../../../components/DesignStage.vue";
import type Player from "@/commons/player.ts";
import { $is_run_local } from "@/commons/utils.ts";
import { cssAssetUrl, assetUrl } from "@/commons/assets.ts";
const props = defineProps<{
rankList: Player[];
}>();
const emit = defineEmits<{
start: [];
next: [];
}>();
const RANK_LIMIT = 10;
const designStage = ref<InstanceType<typeof DesignStage> | null>(null);
const rankEffects = ref<Record<string, "up" | "down" | "new">>({});
const gameStarted = ref(false);
const showResult = ref(false);
const imageUrls = {
bg: cssAssetUrl("game5/bg.svg"),
logo: cssAssetUrl("game1/logo.png"),
title: cssAssetUrl("game5/title3.webp"),
titleRank: cssAssetUrl("game1/title-rank.png"),
rankNo4: cssAssetUrl("game1/no4.png"),
rankNo1: cssAssetUrl("game1/no1.png"),
rankNo2: cssAssetUrl("game1/no2.png"),
rankNo3: cssAssetUrl("game1/no3.png"),
rankIcon: cssAssetUrl("game1/no-icon.png"),
rankNo1Icon: cssAssetUrl("game1/no1-icon.png"),
rankNo2Icon: cssAssetUrl("game1/no2-icon.png"),
rankNo3Icon: cssAssetUrl("game1/no3-icon.png"),
avatar: assetUrl("avatar.png"),
};
// ========== 马匹相关 ==========
const HORSE_TYPES = [1, 2, 3, 4] as const;
const HORSE_FRAME_COUNT = 21; // 每匹马 21 帧(00-20)
interface Horse {
id: string;
horseType: number; // 1-4 颜色
lane: "top" | "bottom";
xPercent: number; // 0=屏幕左侧外, 100=屏幕右侧外
frameIndex: number; // 当前帧 0-20
frameTimer: number; // 帧切换计时器
}
const horses = ref<Horse[]>([]);
let horseIdCounter = 0;
/** 获取指定马颜色和帧的图片 URL */
const getHorseFrameUrl = (type: number, frame: number) => {
const padded = String(frame).padStart(2, "0");
return assetUrl(`game5/horse${type}/no-circle/qcnf-ma${type}_${padded}.png`);
};
/** 马的水平像素位移:xPercent→屏幕px */
const horseTranslateX = (xPercent: number) => {
// 从左侧-330px 跑到右侧 1920px,总行程 2250px
return -330 + xPercent * 22.5;
};
const isGameRunning = ref(false);
const emptyPlayer = (index: number): Player => ({
wechat_id: `empty-${index}`,
nickname: "-",
avatar: "",
score: 0,
});
const list = ref<Player[]>(
Array.from({ length: RANK_LIMIT }, (_, index) => emptyPlayer(index)),
);
const playerKey = (player: Player, index = 0) =>
player.userid || player.wechat_id || `empty-${index}`;
// ========== 排名更新 ==========
const updateRankList = (rankList: Player[]) => {
const oldRanks = new Map(
list.value.map((player, index) => [playerKey(player, index), index]),
);
const next = Array.from(
{ length: RANK_LIMIT },
(_, index) => rankList[index] ?? emptyPlayer(index),
);
const effects: Record<string, "up" | "down" | "new"> = {};
next.forEach((player, index) => {
const key = playerKey(player, index);
if (!key || key.startsWith("empty-")) return;
const oldIndex = oldRanks.get(key);
if (oldIndex === undefined) effects[key] = "new";
else if (oldIndex > index) effects[key] = "up";
else if (oldIndex < index) effects[key] = "down";
});
list.value = next;
rankEffects.value = {};
requestAnimationFrame(() => {
rankEffects.value = effects;
});
};
watch(
() => props.rankList,
(rankList) => {
if (rankList && rankList.length > 0) {
updateRankList(rankList);
}
},
{ immediate: true, deep: true },
);
// ========== startIntro:倒计时 + 游戏流程 ==========
const startIntro = async () => {
showResult.value = false;
gameStarted.value = false;
isGameRunning.value = false;
horses.value = [];
horseIdCounter = 0;
// 启动完整倒计时:3s 开场 + 60s 游戏
const countdownPromise = designStage.value?.startCountdown(3, 60);
// 等待 3-2-1 开场倒计时结束(~3.5s 后安全触发)
await new Promise((r) => setTimeout(r, 3500));
// Phase 2: 倒计时结束 → 游戏正式开始,背景动画+马匹启动
gameStarted.value = true;
isGameRunning.value = true;
startHorseLoop();
startSpawning();
// 等待 60s 游戏倒计时完全结束
await countdownPromise;
stopGame();
};
const stopGame = () => {
isGameRunning.value = false;
showResult.value = true;
stopHorseLoop();
stopSpawning();
};
// ========== 马匹动画循环 (~60fps) ==========
let horseLoopTimer: ReturnType<typeof setInterval> | undefined;
const FRAME_TICK_MS = 16;
const HORSE_FRAME_INTERVAL = 3; // 每 3 tick 切一帧 → ~20fps 奔马动画
const HORSE_SPEED_PERCENT_PER_SEC = 100 / 60; // 60s 跑完全程
const startHorseLoop = () => {
stopHorseLoop();
horseLoopTimer = setInterval(() => {
if (!isGameRunning.value) return;
for (let i = horses.value.length - 1; i >= 0; i--) {
const horse = horses.value[i]!;
horse.xPercent += HORSE_SPEED_PERCENT_PER_SEC * (FRAME_TICK_MS / 1000);
horse.frameTimer++;
if (horse.frameTimer >= HORSE_FRAME_INTERVAL) {
horse.frameTimer = 0;
horse.frameIndex = (horse.frameIndex + 1) % HORSE_FRAME_COUNT;
}
// 跑出右边界后移除
if (horse.xPercent >= 120) {
horses.value.splice(i, 1);
}
}
}, FRAME_TICK_MS);
};
const stopHorseLoop = () => {
if (horseLoopTimer) {
clearInterval(horseLoopTimer);
horseLoopTimer = undefined;
}
};
// ========== 随机生成马匹 ==========
let spawnTimer: ReturnType<typeof setTimeout> | undefined;
const spawnHorse = () => {
const type = HORSE_TYPES[Math.floor(Math.random() * HORSE_TYPES.length)]!;
const lane = Math.random() > 0.5 ? "top" : "bottom";
horses.value.push({
id: `horse-${++horseIdCounter}`,
horseType: type,
lane,
xPercent: -15, // 从左侧屏幕外进场
frameIndex: Math.floor(Math.random() * HORSE_FRAME_COUNT),
frameTimer: 0,
});
};
const startSpawning = () => {
stopSpawning();
// 开局立刻出 1~2 匹
spawnHorse();
if (Math.random() > 0.3) spawnHorse();
const scheduleNext = () => {
if (!isGameRunning.value) return;
const delay = 2000 + Math.random() * 6000; // 2~8s 随机间隔
spawnTimer = setTimeout(() => {
if (!isGameRunning.value) return;
// 场上最多 8 匹,每次出 1~2 匹
if (horses.value.length < 8) {
spawnHorse();
if (Math.random() > 0.5 && horses.value.length < 8) spawnHorse();
}
scheduleNext();
}, delay);
};
scheduleNext();
};
const stopSpawning = () => {
if (spawnTimer) {
clearTimeout(spawnTimer);
spawnTimer = undefined;
}
};
onMounted(() => {
if ($is_run_local()) {
gameStarted.value = true;
isGameRunning.value = true;
startHorseLoop();
startSpawning();
}
});
onUnmounted(() => {
stopGame();
stopHorseLoop();
stopSpawning();
});
defineExpose({
startIntro,
});
// ========== 头像渲染 ==========
const avatarStyle = (item: Player) => {
const avatar = item.avatar || imageUrls.avatar;
return {
backgroundImage: `url("${avatar}")`,
};
};
// ========== rank item 样式 class ==========
const rankItemClass = (index: number) => {
if (index === 0) return "rank-top-1";
if (index === 1) return "rank-top-2";
if (index === 2) return "rank-top-3";
return "";
};
</script>
<template>
<DesignStage ref="designStage">
<div class="screen-container" :class="{ paused: !gameStarted || showResult }">
<!-- 背景层 -->
<div class="bg">
<div class="img-logo"></div>
<div class="img-title"></div>
<!-- 排名列表 -->
<!-- <div class="rank-container">
<div class="rank-txt"></div>
<div class="rank-list">
<TransitionGroup name="rank">
<div
v-for="(item, index) in list"
:key="playerKey(item, index)"
:class="[
'rank-item',
rankItemClass(index),
rankEffects[playerKey(item, index)] || '',
]"
>
<div class="rank-icon">{{ index + 1 }}</div>
<div class="rank-avatar" :style="avatarStyle(item)"></div>
<div class="rank-nickname">{{ item.nickname }}</div>
<div class="rank-score">{{ item.score }}</div>
</div>
</TransitionGroup>
</div>
</div> -->
<!-- 底部跑道背景(倒计时结束后滚动) -->
<div class="runway-track">
<div class="runway-inner"></div>
<div class="runway-inner"></div>
</div>
<!-- 马匹(倒计时结束后出现) -->
<template v-if="gameStarted">
<div
v-for="horse in horses"
:key="horse.id"
class="horse-wrapper"
:class="`horse-lane-${horse.lane}`"
:style="{ transform: `translateX(${horseTranslateX(horse.xPercent)}px)` }"
>
<img
class="horse-img"
:src="getHorseFrameUrl(horse.horseType, horse.frameIndex)"
alt="horse"
/>
</div>
</template>
</div>
</div>
</DesignStage>
</template>
<style scoped>
.screen-container {
width: 1920px;
height: 1080px;
position: relative;
overflow: hidden;
}
.screen-container.paused .runway-track {
animation-play-state: paused;
}
.bg {
width: 1920px;
height: 1080px;
background: v-bind("imageUrls.bg") center / cover no-repeat;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.img-logo {
background: v-bind("imageUrls.logo");
background-size: cover;
width: 428px;
height: 77px;
position: absolute;
left: 37px;
top: 82px;
z-index: 20;
}
.img-title {
background: v-bind("imageUrls.title") no-repeat;
width: 675px;
height: 209px;
margin: 0 auto;
margin-top: 37px;
position: relative;
z-index: 20;
}
/* ========== 排名容器 ========== */
.rank-container {
width: 503px;
height: 810px;
background: #fff;
opacity: 0.9;
border-radius: 30px;
position: absolute;
left: 50%;
top: 210px;
transform: translateX(-50%);
z-index: 10;
overflow: hidden;
}
.rank-txt {
background: v-bind("imageUrls.titleRank");
width: 304px;
height: 40px;
margin: 10px auto auto auto;
}
.rank-list {
position: relative;
}
.rank-item {
background: v-bind("imageUrls.rankNo4");
width: 468px;
height: 68px;
margin: 6px auto 0 auto;
display: grid;
grid-template-columns: 95px 68px 1fr 100px;
align-items: center;
color: #e00000;
font-weight: bold;
}
.rank-item.rank-top-1 {
background: v-bind("imageUrls.rankNo1");
}
.rank-item.rank-top-2 {
background: v-bind("imageUrls.rankNo2");
}
.rank-item.rank-top-3 {
background: v-bind("imageUrls.rankNo3");
}
.rank-icon {
background: v-bind("imageUrls.rankIcon") center / cover no-repeat;
width: 40px;
height: 40px;
margin: auto auto auto 20px;
text-align: center;
color: white;
font-weight: bold;
font-size: 22px;
line-height: 40px;
}
.rank-item.rank-top-1 .rank-icon {
background: v-bind("imageUrls.rankNo1Icon") center / cover no-repeat;
width: 60px;
height: 38px;
margin: auto auto auto 10px;
}
.rank-item.rank-top-2 .rank-icon {
background: v-bind("imageUrls.rankNo2Icon") center / cover no-repeat;
width: 60px;
height: 38px;
margin: auto auto auto 10px;
}
.rank-item.rank-top-3 .rank-icon {
background: v-bind("imageUrls.rankNo3Icon") center / cover no-repeat;
width: 60px;
height: 38px;
margin: auto auto auto 10px;
}
.rank-avatar {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
width: 48px;
height: 48px;
border-radius: 24px;
margin-left: 10px;
}
.rank-nickname {
text-indent: 10px;
}
.rank-score {
text-align: right;
margin-right: 30px;
}
/* ========== 过渡动画 ========== */
.rank-move {
transition: transform 0.45s ease;
}
.rank-enter-active,
.rank-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.rank-leave-active {
position: absolute;
left: 17px;
}
.rank-enter-from,
.rank-leave-to {
opacity: 0;
transform: translateY(90px);
}
.rank-item.up,
.rank-item.new {
animation: rank-up-flash 0.8s ease;
}
.rank-item.down {
animation: rank-down-flash 0.8s ease;
}
/* ========== 底部跑道背景 ========== */
.runway-track {
width: 7680px;
height: 543px;
position: absolute;
bottom: 0;
left: 0;
display: flex;
animation: bg-scroll-left 20s linear infinite;
z-index: 2;
}
.runway-inner {
width: 3840px;
height: 543px;
background: url("@/assets/images/game5/paodao.webp") 0 0 / 3840px 543px
no-repeat;
flex-shrink: 0;
}
@keyframes bg-scroll-left {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-3840px);
}
}
/* ========== 马匹 ========== */
.horse-wrapper {
position: absolute;
left: 0;
width: 330px;
height: 300px;
will-change: transform;
z-index: 5;
}
.horse-wrapper.horse-lane-top {
bottom: 320px;
}
.horse-wrapper.horse-lane-bottom {
bottom: 40px;
}
/* 马脚下阴影 */
.horse-wrapper::after {
content: "";
position: absolute;
bottom: -4px;
left: 50%;
transform: translateX(-50%);
width: 180px;
height: 24px;
background: radial-gradient(ellipse, rgba(0, 0, 0, 0.45) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
}
.horse-img {
width: 330px;
height: 300px;
object-fit: contain;
position: relative;
z-index: 1;
}
@keyframes rank-up-flash {
0% {
background-color: #ffffcc;
}
50% {
background-color: #ffff99;
}
100% {
background-color: transparent;
}
}
@keyframes rank-down-flash {
0% {
background-color: #ffe0e0;
}
50% {
background-color: #ffcccc;
}
100% {
background-color: transparent;
}
}
</style>
<script setup lang="ts">
import { ref, watch } from 'vue'
import DesignStage from '../../../components/DesignStage.vue'
import type Player from '../../../commons/player.ts'
import { cssAssetUrl } from '@/commons/assets.ts';
const props = defineProps<{
rankList: Player[];
}>();
const emit = defineEmits<{
next: [];
}>();
const FINAL_RANK_LIMIT = 10;
const imageUrls = {
bg: cssAssetUrl('game5/bg.svg'),
logo: cssAssetUrl('game1/logo.png'),
title: cssAssetUrl('game1/title3.png'),
title2: cssAssetUrl('game1/title4.png'),
podium: cssAssetUrl('game5/podium.webp'),
fireworks: cssAssetUrl('game1/fireworks.png'),
avatar: cssAssetUrl('game5/avatar.webp'),
guan: cssAssetUrl('game5/guan.webp'),
guan1: cssAssetUrl('game1/guan1.png'),
pai1: cssAssetUrl('game1/pai1.png'),
guan2: cssAssetUrl('game1/guan3.png'),
pai2: cssAssetUrl('game1/pai2.png'),
guan3: cssAssetUrl('game1/guan2.png'),
pai3: cssAssetUrl('game1/pai3.png'),
rankIcon: cssAssetUrl('game5/no-icon.webp'),
avatarAnimation: cssAssetUrl('avatar_animation.png'),
next: cssAssetUrl('game5/next-1.webp'),
};
const emptyPlayer = (index: number): Player => ({
active: false,
wechat_id: `empty-${index}`,
nickname: '虚位以待',
avatar: '',
score: 0,
});
const list = ref<Player[]>(Array.from({ length: FINAL_RANK_LIMIT }, (_, index) => emptyPlayer(index)));
const updateFinalRank = (rankList: Player[]) => {
list.value = Array.from(
{ length: FINAL_RANK_LIMIT },
(_, index) => rankList[index] ?? emptyPlayer(index),
);
};
watch(
() => props.rankList,
(rankList) => updateFinalRank(rankList),
{ immediate: true, deep: true },
);
const renderAvatar = (item: any | null) => {
if (item && item.avatar) {
return `background: url('${item.avatar}') center / cover no-repeat;`
}
return `background: v-bind('imageUrls.avatar') center / cover no-repeat;`
}
</script>
<template>
<DesignStage>
<div class="bg">
<div class="img-logo"></div>
<div class="img-title"></div>
<!-- <div class="img-title2"></div> -->
<div class="podium">
<div class="rank-no1">
<div class="gu-burst"></div>
<div class="avatar" :style="`${renderAvatar(list[0])}`">
</div>
<div class="nickname">{{ list[0]?.nickname }}</div>
<div class="guan"></div>
<!-- <div class="pai"></div> -->
</div>
<div class="rank-no2">
<div class="gu-burst"></div>
<div class="avatar" :style="`${renderAvatar(list[1])}`">
</div>
<div class="nickname">{{ list[1]?.nickname }}</div>
<!-- <div class="guan"></div> -->
<!-- <div class="pai"></div> -->
</div>
<div class="rank-no3">
<div class="gu-burst"></div>
<div class="avatar" :style="`${renderAvatar(list[2])}`">
</div>
<div class="nickname">{{ list[2]?.nickname }}</div>
<!-- <div class="guan"></div> -->
<!-- <div class="pai"></div> -->
</div>
</div>
<div class="rank-container">
<div v-for="(item, index) in list.slice(3)" :key="item.userid || item.wechat_id || `rank-${index}`">
<div class="no-icon">{{ index + 4 }}</div>
<div>
<div class="img-avatar-container">
<div class="img-avatar"></div>
</div>
<div class="txt-nickname">{{ item.nickname }}</div>
</div>
</div>
</div>
<div class="btn-next" @click="emit('next')"></div>
</div>
</DesignStage>
</template>
<style scoped>
.bg {
width: 1920px;
height: 1080px;
background: v-bind('imageUrls.bg') center / cover no-repeat;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.img-logo {
background: v-bind('imageUrls.logo');
background-size: cover;
width: 428px;
height: 77px;
position: absolute;
left: 37px;
top: 82px;
}
.img-title {
background: v-bind('imageUrls.title');
width: 967px;
height: 122px;
margin: 0 auto;
margin-top: 37px;
}
.img-title2 {
background: v-bind('imageUrls.title2') no-repeat center;
width: 457px;
height: 57px;
margin: 0 auto;
margin-top: 37px;
}
.podium {
background: v-bind('imageUrls.podium') no-repeat center;
width: 1920px;
height: 800px;
position: absolute;
bottom: 0px;
/* left: 287px; */
.gu-burst {
width: 200px;
height: 200px;
position: absolute;
z-index: 1;
border-radius: 50%;
opacity: 1;
transform: scale(0.6);
transform-origin: center center;
background: v-bind('imageUrls.fireworks') center / cover no-repeat;
pointer-events: none;
animation: gu-burst-expand 1.2s ease-out infinite;
}
.rank-no1 {
position: relative;
.gu-burst {
left: 861px;
top: -72px;
}
.avatar {
/* background: v-bind('imageUrls.avatar') center / cover no-repeat; */
width: 120px;
height: 120px;
border-radius: 60px;
position: absolute;
left: 906px;
top: 76px;
}
.nickname {
width: 220px;
height: 52px;
background: linear-gradient(0deg, #FFBF5A 0%, #FFE47A 100%);
border-radius: 26px;
border: 1px solid #FFF9DC;
font-size: 30px;
color: #4B1A14;
text-align: center;
line-height: 46px;
position: absolute;
left: 856px;
top: 190px;
z-index: 1;
border-radius: 23px;
}
.guan {
/* background: v-bind('imageUrls.guan1');
width: 64px;
height: 64px;
position: absolute;
left: 877px;
top: 61px; */
background: v-bind('imageUrls.guan');
width: 969px;
height: 238px;
position: absolute;
left: 482px;
top: -13px;
}
.pai {
background: v-bind('imageUrls.pai1');
width: 212px;
height: 200px;
position: absolute;
left: 570px;
top: 40px;
}
}
.rank-no2 {
position: relative;
.gu-burst {
left: 456px;
top: -26px;
}
.avatar {
background: v-bind('imageUrls.avatar') center / cover no-repeat;
width: 144px;
height: 144px;
border-radius: 60px;
position: absolute;
left: 491.1px;
top: 74px;
}
.nickname {
text-align: center;
/* line-height: 46px; */
width: 220px;
height: 52px;
background: linear-gradient(0deg, #AEA6A7 0%, #EAE4DF 100%);
border-radius: 26px;
border: 1px solid #FFFFFF;
font-size: 30px;
color: #383636;
position: absolute;
left: 461px;
top: 221px;
z-index: 1;
border-radius: 23px;
}
.guan {
background: v-bind('imageUrls.guan2') no-repeat center;
width: 96px;
height: 96px;
position: absolute;
left: 469px;
top: 70px;
}
.pai {
background: v-bind('imageUrls.pai2') no-repeat center;
width: 212px;
height: 200px;
position: absolute;
left: 140px;
top: 50px;
}
}
.rank-no3 {
position: relative;
.gu-burst {
left: 1261px;
top: -37px;
}
.avatar {
background: v-bind('imageUrls.avatar') center / cover no-repeat;
width: 144px;
height: 144px;
border-radius: 60px;
position: absolute;
left: 1300px;
top: 74px;
}
.nickname {
width: 220px;
height: 52px;
background: linear-gradient(0deg, #F3AE76 0%, #F9CDA3 100%);
border-radius: 26px;
border: 1px solid #FEFEFD;
font-size: 30px;
color: #6F3A0E;
text-align: center;
line-height: 46px;
position: absolute;
left: 1240px;
top: 221px;
z-index: 1;
border-radius: 23px;
}
.guan {
background: v-bind('imageUrls.guan3') no-repeat center;
width: 96px;
height: 96px;
position: absolute;
left: 1241px;
top: 70px;
}
.pai {
background: v-bind('imageUrls.pai3') no-repeat center;
width: 212px;
height: 200px;
position: absolute;
right: 130px;
top: 50px;
}
}
}
.rank-container {
display: flex;
align-items: center;
width: 1270px;
height: 115px;
position: absolute;
bottom: 243px;
left: 325px;
>div {
flex: 1;
display: grid;
width: 100px;
height: 100%;
.no-icon {
background: v-bind('imageUrls.rankIcon') no-repeat center;
width: 42px;
height: 47px;
position: absolute;
text-align: center;
line-height: 40px;
color: white;
font-weight: bold;
}
.img-avatar-container {
position: relative;
margin: 0 auto;
margin-top: 10px;
width: 72px;
height: 72px;
border-radius: 36px;
}
.img-avatar-container::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: v-bind('imageUrls.avatarAnimation');
animation: avatar-rotate 10s linear infinite;
}
.img-avatar {
position: relative;
top: 6px;
z-index: 1;
margin: 0 auto;
width: 60px;
height: 60px;
border-radius: 30px;
background: v-bind('imageUrls.avatar') center / cover no-repeat;
}
.txt-nickname {
color: white;
font-size: 20px;
text-align: center;
}
}
}
.btn-next {
position: absolute;
bottom: 100px;
left: 820px;
cursor: pointer;
background: v-bind('imageUrls.next') center / cover no-repeat;
width: 318px;
height: 95px;
}
</style>
...@@ -3,6 +3,7 @@ import { $read } from '@/commons/utils' ...@@ -3,6 +3,7 @@ import { $read } from '@/commons/utils'
import Main from '@/pages/Main.vue' import Main from '@/pages/Main.vue'
import Game1 from '@/pages/game1/Game.vue' import Game1 from '@/pages/game1/Game.vue'
import Game4 from '@/pages/game4/Game4.vue' import Game4 from '@/pages/game4/Game4.vue'
import Game5 from '@/pages/game5/Game5.vue'
import Game6 from '@/pages/game6/Game.vue' import Game6 from '@/pages/game6/Game.vue'
const router = createRouter({ const router = createRouter({
...@@ -34,6 +35,14 @@ const router = createRouter({ ...@@ -34,6 +35,14 @@ const router = createRouter({
}, },
}, },
{ {
path: '/game5',
name: 'Game5',
component: Game5,
meta: {
requiresAuth: true,
},
},
{
path: '/game6', path: '/game6',
name: 'Game6', name: 'Game6',
component: Game6, component: Game6,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment