Commit 9f38461e authored by 陈冲's avatar 陈冲

feat: 添加game2

parent 415fbd0f
......@@ -57,7 +57,7 @@ export function $read<T>(key: string | StorageRecord, defaultValue?: T): T | Sto
}
export function $format_str(str: string, num: number) {
if (str.length <= num - 2) {
if (str.length - 2 < num) {
return str;
}
return str.substring(0, num) + '...';
......
......@@ -94,7 +94,7 @@ export function useGameSocket(options: GameSocketOptions) {
}, 200);
return;
}
debugger
// debugger
return;
}
......
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { cssAssetUrl } from '@/commons/assets.ts'
import MobileStage from '@/components/MobileStage.vue'
import { useGameSocket, joinSharedRoom, sendGameMessage, userJoinStatus } from '@/composables/useGameSocket'
import LoadingView from './views/LoadingView.vue'
import PlayingView from './views/PlayingView.vue'
import { $format_str, $getWechat, $toast } from '@/commons/utils.ts'
import { playGame6Music, stopGame6Music } from '@/commons/music';
type GameView = 'loading' | 'playing'
type RankPlayer = {
rank: number
nickname: string
avatar: string
score: number | string
}
const imageUrls: Record<string, string> = {
bg: cssAssetUrl('game2/bg.png'),
logo: cssAssetUrl('logo.png'),
close: cssAssetUrl('game1/close.png'),
clock: cssAssetUrl('game1/clock.png'),
bg2: cssAssetUrl('game2/bg2.png'),
rule: cssAssetUrl('game1/rule.png'),
coin: cssAssetUrl('game2/coin.png'),
process: cssAssetUrl('game2/process.png'),
coinIcon: cssAssetUrl('game2/coin-icon.png'),
rank: cssAssetUrl('game6/rank.png'),
ranklist: cssAssetUrl('game6/ranklist.png'),
}
const wechat = $getWechat()
const currentView = ref<GameView>('loading')
const countdownInterval = ref(60)
const isDivDescVisible = ref(false)
const score = ref(0)
const rank = ref(0) //排名
const rankList = ref<RankPlayer[]>([])
const displayRankList = computed<RankPlayer[]>(() => {
const list = rankList.value.slice(0, 10)
return Array.from({ length: 10 }, (_, index) => {
return list[index] ?? {
rank: index + 1,
nickname: '-',
avatar: '',
score: '-',
}
})
})
const rankScore = computed<number | string>(() => {
const currentPlayer = rankList.value
.slice(0, 10)
.find(item => item.rank === rank.value)
return currentPlayer?.score ?? score.value
})
// const guFrames: [string, string, string] = [imageUrls.gu01, imageUrls.gu02, imageUrls.gu03]
// const currentGu = ref(imageUrls.gu01)
const isGuPlaying = ref(false)
const token = ref(wechat?.token ?? '')
const nickname = ref(wechat?.nickname ?? '')
const avatar = ref(wechat?.avatar ?? '')
const showGameRule = ref(true)
const showGameRank = ref(false)
const mobileStageRef = ref<InstanceType<typeof MobileStage> | null>(null)
const _offSocketMessage = ref<(() => void) | undefined>()
let guFrameTimer: ReturnType<typeof window.setTimeout> | undefined
let gameCountdownTimer: ReturnType<typeof window.setTimeout> | undefined
const confirmRefresh = (event: BeforeUnloadEvent) => {
event.preventDefault()
event.returnValue = ''
}
function setDivDescVisible(visible: boolean) {
isDivDescVisible.value = visible
}
function stopGameCountdown() {
if (gameCountdownTimer) {
window.clearTimeout(gameCountdownTimer)
gameCountdownTimer = undefined
}
}
function submitScore(save: boolean = false, currentTick = score.value) {
//最后提交保存在数据库中
if (save) {
//item_num 游戏项目(1到6)
//wechat 原始token
sendGameMessage('submit_score_save', {
score: currentTick,
wechat: wechat.token_origin,
item_num: 2,
nickname: wechat.nickname,
avatar: wechat.avatar,
rank: rank.value,
})
} else {
sendGameMessage('submit_score', { score: currentTick })
}
}
function startGameCountdown(seconds = 60) {
stopGameCountdown()
countdownInterval.value = seconds
setDivDescVisible(true)
const tickCountdown = () => {
countdownInterval.value -= 1
if (countdownInterval.value <= 0) {
countdownInterval.value = 0
gameCountdownTimer = undefined
setDivDescVisible(false)
submitScore(true)
showGameOverRank()
return
}
gameCountdownTimer = window.setTimeout(tickCountdown, 1000)
}
gameCountdownTimer = window.setTimeout(tickCountdown, 1000)
}
function resetToLoadingView() {
stopGameCountdown()
if (guFrameTimer) {
window.clearTimeout(guFrameTimer)
guFrameTimer = undefined
}
score.value = 0
rank.value = 0
rankList.value = []
countdownInterval.value = 60
// currentGu.value = guFrames[0]
isGuPlaying.value = false
userJoinStatus.value = false
showGameRank.value = false
currentView.value = 'loading'
setDivDescVisible(false)
}
function startGameView() {
stopGameCountdown()
score.value = 0
rank.value = 0
rankList.value = []
// currentGu.value = guFrames[0]
showGameRank.value = false
currentView.value = 'playing'
startGameCountdown()
}
function showGameOverRank() {
stopGameCountdown()
setDivDescVisible(false)
currentView.value = 'loading'
showGameRank.value = true
userJoinStatus.value = false;
}
const touchHandler = (currentScore: number) => {
score.value = currentScore
submitScore(false, currentScore)
}
if (wechat) {
const gameId = 'game2';
const { offSocketMessage } = useGameSocket({
gameId,
auth: {
userid: wechat.token,
},
onConnect: () => {
window.setTimeout(() => {
const joined = joinSharedRoom(nickname.value, token.value, avatar.value, gameId)
}, 200)
},
onGameStart: async () => {
showGameRule.value = false;
await mobileStageRef.value?.startCountdown()
startGameView()
},
onScoreSubmitted: (is_save, data) => {
if (!is_save) {//非保存状态下
const nextRank = Number(typeof data === 'object' ? data?.rank : data)
if (Number.isFinite(nextRank) && nextRank > 0) {
rank.value = nextRank
}
} else if (typeof data === 'object' && data) {
if (Array.isArray(data.list)) {
rankList.value = data.list.map((item: any, index: number) => ({
rank: Number(item?.rank ?? index + 1),
nickname: String(item?.nickname ?? ''),
avatar: String(item?.avatar ?? ''),
score: Number(item?.score ?? 0),
}))
}
const nextRank = Number(data.rank)
if (Number.isFinite(nextRank) && nextRank > 0) {
rank.value = nextRank
}
}
},
onRescoreSubmitted: (_recount) => {
submitScore(true);
},
onGameRecover: (data) => {
const recoveredRank = Number(data?.rank)
const recoveredScore = Number(data?.score)
const recoveredSeconds = Number(data?.remainingSeconds)
rank.value = Number.isFinite(recoveredRank) && recoveredRank > 0 ? recoveredRank : 0
score.value = Number.isFinite(recoveredScore) && recoveredScore > 0 ? recoveredScore : 0
showGameRule.value = false
showGameRank.value = false
currentView.value = 'playing'
startGameCountdown(
Number.isFinite(recoveredSeconds) && recoveredSeconds > 0 ? recoveredSeconds : 0,
)
},
onRoomClosed: () => {
stopGameCountdown()
currentView.value = 'loading'
setDivDescVisible(false)
$toast('管理员关闭了游戏房间')
},
onRoomBack: () => {
console.log('onRoomBack');
resetToLoadingView()
window.setTimeout(() => {
joinSharedRoom(nickname.value, token.value, avatar.value, gameId)
}, 200)
},
})
_offSocketMessage.value = offSocketMessage
}
onMounted(() => {
// currentView.value = 'playing'
document.title = '互动游戏 - 福运当头'
if (token.value) {
window.addEventListener('beforeunload', confirmRefresh)
}
})
onBeforeUnmount(() => {
_offSocketMessage.value?.()
stopGame6Music()
if (guFrameTimer) {
window.clearTimeout(guFrameTimer)
}
stopGameCountdown()
if (token.value) {
window.removeEventListener('beforeunload', confirmRefresh)
}
})
const showGameRuleHandler = () => {
showGameRule.value = !showGameRule.value;
}
const renderBG = () => {
if (currentView.value == 'playing') {
return `${imageUrls.bg2} center/cover`
}
return `${imageUrls.bg} center/cover`;
}
const rankCloseHandler = () => {
showGameRank.value = false
}
</script>
<template>
<MobileStage v-if="token" :showGameRule="showGameRule" :showGameRank="showGameRank" ref="mobileStageRef"
:background="`${renderBG()}`">
<template #gameRule>
<div class="rule-container">
<div class="rule-txt-container">
<div class="rule-title">游戏规则</div>
<div class="rule-txt">
1. 主持人开赛即可转动金币,<b>顺时针转动一圈</b>获得5分;<br />
2. 限时比拼手速,结束后按积分排名;<br />
3. 高分用户有机会领取新疆福彩定制周边;<br />
4. 禁止外挂作弊,违规账号取消成绩与领奖资格;<br /><br />
祝您游戏愉快!
</div>
<div class="rule-close" @click="showGameRuleHandler"></div>
</div>
</div>
</template>
<template #gameRank>
<div class="ranklist-container">
<div class="txt-title">排行</div>
<div class="list-container">
<div class="row row-1">
<div class="col-1">排名</div>
<div class="col-2">用户</div>
<div class="col-3">成绩</div>
</div>
<div class="row-2">
<div class="row rank-row" v-for="(item, index) in displayRankList"
:key="`${item.rank}-${index}`">
<div class="col-1">
<div>{{ item.rank }}</div>
</div>
<div class="col-2">
<div class="avatar"
:style="item.avatar ? { backgroundImage: `url(${item.avatar})` } : {}"></div>
<div>{{ $format_str(item.nickname, 12) }}</div>
</div>
<div class="col-3">{{ item.score }}</div>
</div>
</div>
<div class="row row-3">
<div class="col-1">{{ rank > 0 ? rank : '未上榜' }}</div>
<div class="col-2">
<div class="avatar" :style="{ backgroundImage: `url(${avatar})` }"></div>
<div>{{ nickname }}</div>
</div>
<div class="col-3">{{ rankScore }}</div>
</div>
</div>
<div class="img-close" @click="rankCloseHandler"></div>
</div>
</template>
<LoadingView v-if="currentView === 'loading'" :image-urls="imageUrls" :user-join-status="userJoinStatus"
@touchGameRule="showGameRuleHandler" />
<PlayingView v-else :image-urls="imageUrls" :tick="score" :rank="rank" :countdown-interval="countdownInterval"
:is-div-desc-visible="isDivDescVisible" @touch="touchHandler" />
</MobileStage>
<div v-else style="text-align: center; width: 100vw; height: 100vh; line-height: 30; font-size: 20px;">
请使用微信扫码进入游戏
</div>
</template>
<style scoped>
.rule-container {
.rule-txt-container {
background: v-bind('imageUrls.rule') center / cover no-repeat;
width: 692px;
height: 797px;
margin-top: -20%;
transform: scale(0.5);
.rule-title {
text-align: center;
color: white;
font-weight: bold;
font-size: 26pt;
line-height: 50pt;
letter-spacing: 4px;
}
.rule-txt {
font-size: 26pt;
padding: 40px;
margin-top: 10px;
color: #AA0000;
>b {
font-weight: bold;
}
}
}
.rule-close {
width: 57px;
height: 57px;
background: v-bind('imageUrls.close') center / cover no-repeat;
transform: translateX(-50%) scale(1.4);
position: absolute;
top: 0;
left: 100%;
}
}
.ranklist-container {
width: 747px;
height: 1084px;
background: v-bind('imageUrls.ranklist');
transform: translate(-50%, -50%) scale(0.45);
left: 50%;
top: 50%;
position: absolute;
.txt-title {
width: 100%;
text-align: center;
font-size: 50px;
margin-top: 35px;
font-weight: bold;
color: #AA0000;
letter-spacing: 2px;
}
.list-container {
width: calc(100% - 90px);
height: calc(100% - 210px);
position: absolute;
left: 40px;
top: 140px;
text-align: center;
height: 73px;
line-height: 73px;
font-size: 32px;
.row {
display: grid;
grid-template-columns: 150px auto 150px;
align-items: center;
}
.row-1 {
color: #AA0000;
font-weight: bold;
background: linear-gradient(-84deg, #FFD396 0%, #FFF3D1 53%, #FFD59C 100%);
border-radius: 20px 20px 0px 0px;
}
.row-2 {
color: #633911;
.rank-row:nth-child(odd) {
background: #F2E2AE;
}
.rank-row:nth-child(even) {
background: #FCEEBF;
}
.col-1 {
position: relative;
}
}
.row-3 {
background: linear-gradient(0deg, #F4B64C 0%, #F7C96F 100%);
border-radius: 0px 0px 20px 20px;
}
.col-2 {
display: flex;
align-items: center;
justify-content: center;
gap: 18px;
.avatar {
width: 48px;
height: 48px;
flex: 0 0 auto;
border-radius: 50%;
background-position: center;
background-size: cover;
}
}
}
}
.img-close {
/* width: 57px;
height: 57px;
border-radius: 29px;
background: v-bind('imageUrls.close')center/cover;
transform: scale(0.7);
margin-top: 500px; */
width: 57px;
height: 57px;
background: v-bind('imageUrls.close') center / cover no-repeat;
transform: translateX(-50%) scale(1.4);
position: absolute;
top: 0;
left: 100%;
}
</style>
<script setup lang="ts">
import { $getWechat } from '@/commons/utils.ts'
import { onMounted, ref } from 'vue';
defineProps<{
imageUrls: Record<string, string>
userJoinStatus: boolean
}>();
const nickname = ref('');
const avatar = ref('');
onMounted(() => {
const wechat = $getWechat();
nickname.value = wechat.nickname;
avatar.value = wechat.avatar;
});
</script>
<template>
<div class="h5-page game-stage">
<div class="game-desc" @click="$emit('touchGameRule')">游戏规则</div>
<div class="img-logo"></div>
<div class="img-coin"></div>
<div class="txt-bottom" v-if="userJoinStatus">您已成功加入游戏<br />等待主持人开始</div>
</div>
</template>
<style scoped>
.img-coin {
background: v-bind('imageUrls.coin') center / cover no-repeat;
width: 744px;
height: 756px;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) scale(0.6);
transform-origin: center;
}
.txt-bottom {
position: absolute;
bottom: 130px;
width: 100%;
line-height: 40px;
color: white;
font-size: 28px;
text-align: center;
animation: loading-text-scale 1.2s ease-in-out infinite;
}
@keyframes loading-text-scale {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.08);
}
}
.game-stage {
position: absolute;
width: 750px;
height: 1624px;
padding: 0;
}
.game-desc {
position: absolute;
top: 40%;
left: var(--stage-viewport-left, 0);
display: flex;
width: 58px;
height: 158px;
padding-top: 10px;
align-items: center;
justify-content: center;
border-radius: 0 12px 12px 0;
background: rgba(40, 12, 8, 0.52);
color: rgba(255, 255, 255, 0.9);
font-size: 25px;
letter-spacing: 10px;
line-height: 1.25;
text-align: center;
text-orientation: upright;
transform: translateY(-50%);
writing-mode: vertical-rl;
}
.bg-bottom {
position: absolute;
bottom: 0;
left: var(--stage-viewport-left, 0);
width: var(--stage-viewport-width, 750px);
height: 479px;
background: v-bind('imageUrls.bg2') center/cover;
opacity: 0.6;
}
.img-logo {
position: absolute;
top: 66px;
left: 20px;
width: 428px;
height: 77px;
background: v-bind('imageUrls.logo') center/cover;
}
.img-title {
position: absolute;
top: 200px;
left: 50%;
width: 609px;
height: 243px;
background: v-bind('imageUrls.title') center/cover;
transform: translateX(-50%);
}
.img-avatar {
position: absolute;
top: 480px;
left: 50%;
/* width: 148px;
height: 148px;
background: v-bind('avatar') center/cover; */
transform: translateX(-50%);
}
.txt-nickname {
position: absolute;
top: 640px;
left: 50%;
color: #E00000;
font-size: 25pt;
font-weight: bold;
transform: translateX(-50%);
}
.txt-loading {
position: absolute;
top: 710px;
left: 50%;
width: 288px;
color: #E00000;
font-size: 25pt;
font-weight: bold;
text-align: center;
transform: translateX(-50%);
}
.loading-gu {
position: absolute;
bottom: 100px;
left: 50%;
width: 600px;
height: 534px;
background: v-bind('imageUrls.gu') center/cover;
transform: translateX(-50%);
}
</style>
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
defineProps<{
imageUrls: Record<string, string>
countdownInterval: number
isDivDescVisible: boolean
tick: number
rank: number
// currentGu: string
}>()
const emit = defineEmits<{
touch: [value: number]
}>()
const score = ref(0)
const FULL_ROTATION = Math.PI * 2
const DRAG_RESISTANCE = 0.82
const INERTIA_FRICTION = 0.94
const MIN_INERTIA_VELOCITY = 0.05
const coinRef = ref<HTMLElement | null>(null)
const coinRotation = ref(0)
let activePointerId: number | null = null
let previousAngle = 0
let clockwiseAngle = 0
let angularVelocity = 0
let inertiaFrame: number | undefined
function getPointerAngle(event: PointerEvent) {
const rect = coinRef.value?.getBoundingClientRect()
if (!rect) return 0
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
return Math.atan2(event.clientY - centerY, event.clientX - centerX)
}
function normalizeAngle(angle: number) {
if (angle > Math.PI) return angle - FULL_ROTATION
if (angle < -Math.PI) return angle + FULL_ROTATION
return angle
}
function startRotation(event: PointerEvent) {
if (activePointerId !== null) return
stopInertia()
activePointerId = event.pointerId
previousAngle = getPointerAngle(event)
clockwiseAngle = 0
angularVelocity = 0
coinRef.value?.setPointerCapture(event.pointerId)
}
function rotateCoin(event: PointerEvent) {
if (event.pointerId !== activePointerId) return
const currentAngle = getPointerAngle(event)
const angleDelta = normalizeAngle(currentAngle - previousAngle)
previousAngle = currentAngle
if (angleDelta <= 0) {
angularVelocity = 0
return
}
const rotationDelta = angleDelta * 180 / Math.PI * DRAG_RESISTANCE
coinRotation.value += rotationDelta
angularVelocity = angularVelocity * 0.55 + rotationDelta * 0.45
clockwiseAngle += angleDelta
while (clockwiseAngle >= FULL_ROTATION) {
clockwiseAngle -= FULL_ROTATION
score.value += 5
emit('touch', score.value)
}
}
function stopRotation(event: PointerEvent) {
if (event.pointerId !== activePointerId) return
if (coinRef.value?.hasPointerCapture(event.pointerId)) {
coinRef.value.releasePointerCapture(event.pointerId)
}
activePointerId = null
clockwiseAngle = 0
startInertia()
}
function stopInertia() {
if (inertiaFrame !== undefined) {
cancelAnimationFrame(inertiaFrame)
inertiaFrame = undefined
}
}
function startInertia() {
stopInertia()
angularVelocity = Math.max(0, angularVelocity)
const animate = () => {
angularVelocity *= INERTIA_FRICTION
if (Math.abs(angularVelocity) < MIN_INERTIA_VELOCITY) {
angularVelocity = 0
inertiaFrame = undefined
return
}
coinRotation.value += angularVelocity
inertiaFrame = requestAnimationFrame(animate)
}
inertiaFrame = requestAnimationFrame(animate)
}
onBeforeUnmount(stopInertia)
</script>
<template>
<div class="h5-page game-stage">
<Transition name="div-desc-slide" appear>
<div v-if="isDivDescVisible" class="div-desc">
<span class="desc-clock" aria-hidden="true"></span>
<span class="desc-time">{{ countdownInterval }}</span>
</div>
</Transition>
<div class="img-logo"></div>
<div class="img-hydt"></div>
<div class="rank-content">
<div class="txt-left">
<div>排名</div>
<div class="txt-bold">{{ rank }}</div>
</div>
<div class="txt-right">
<div>实时得分</div>
<div class="txt-bold">{{ tick }}</div>
</div>
</div>
<div ref="coinRef" class="img-coin" :style="{ '--coin-rotation': `${coinRotation}deg` }"
@pointerdown="startRotation" @pointermove="rotateCoin" @pointerup="stopRotation"
@pointercancel="stopRotation"></div>
<!-- <div class="img-process">
<div class="txt-process">{{ score }}</div>
<div class="img-coin-icon"></div>
</div> -->
</div>
</template>
<style scoped>
.game-stage {
position: absolute;
width: 750px;
height: 1624px;
padding: 0;
}
.div-desc {
position: absolute;
top: 150px;
left: var(--stage-viewport-left, 0);
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
width: 180px;
height: 80px;
border-radius: 0 56px 56px 0;
background: rgba(40, 12, 8, 0.48);
color: #fff;
.desc-clock {
position: absolute;
left: 20px;
width: 38px;
height: 44px;
border-radius: 50%;
background: v-bind('imageUrls.clock') center/cover;
}
.desc-time {
position: absolute;
right: 40px;
color: white;
font-size: 35pt;
line-height: 1;
}
}
.rank-content {
width: 600px;
height: 140px;
background: v-bind('imageUrls.rank') center/cover;
position: absolute;
left: 50%;
top: 570px;
display: flex;
align-items: center;
justify-content: space-between;
transform: translate(-50%, -50%);
font-size: 24px;
color: #A10A06;
.txt-bold {
font-size: 40px;
font-weight: bold;
}
>div {
display: flex;
width: 50%;
height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
}
}
/*
.txt-tick {
position: absolute;
left: 500px;
top: 267px;
font-size: 70px;
font-weight: bold;
color: #BF1D19;
} */
.img-coin {
background: v-bind('imageUrls.coin') center / cover no-repeat;
width: 744px;
height: 756px;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) scale(0.5) rotate(var(--coin-rotation, 0deg));
transform-origin: center;
touch-action: none;
user-select: none;
cursor: grab;
will-change: transform;
margin-top:60px;
}
.img-coin:active {
cursor: grabbing;
}
/* .img-process {
background: v-bind('imageUrls.process') center / cover no-repeat;
width: 662px;
height: 82px;
position: absolute;
bottom: 110px;
left: 50%;
transform: translateX(-50%);
.txt-process {
font-size: 30px;
width: 100%;
text-align: center;
position: absolute;
top: 38px;
color: white;
font-weight: bold;
}
}
.img-coin-icon {
background: v-bind('imageUrls.coinIcon') center / cover no-repeat;
width: 132px;
height: 67px;
position: absolute;
top: -10px;
} */
</style>
......@@ -340,8 +340,8 @@ const rankCloseHandler = () => {
<div class="col-3">{{ rankScore }}</div>
</div>
</div>
</div>
<div class="img-close" @click="rankCloseHandler"></div>
</div>
</template>
<LoadingView v-if="currentView === 'loading'" :image-urls="imageUrls" :user-join-status="userJoinStatus"
@touchGameRule="showGameRuleHandler" />
......@@ -472,11 +472,18 @@ const rankCloseHandler = () => {
}
.img-close {
width: 57px;
/* width: 57px;
height: 57px;
border-radius: 29px;
background: v-bind('imageUrls.close')center/cover;
transform: scale(0.7);
margin-top: 500px;
margin-top: 500px; */
width: 57px;
height: 57px;
background: v-bind('imageUrls.close') center / cover no-repeat;
transform: translateX(-50%) scale(1.4);
position: absolute;
top: 0;
left: 100%;
}
</style>
......@@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from 'vue-router'
import { $read } from '@/commons/utils'
import Loading from '@/pages/Loading.vue'
import Game1 from '@/pages/game1/Game.vue'
import Game2 from '@/pages/game2/Game.vue'
import Game3 from '@/pages/game3/Game3.vue'
import Game4 from '@/pages/game4/Game4.vue'
import Game5 from '@/pages/game5/Game5.vue'
......@@ -14,10 +15,6 @@ const getWechatId = () => {
const router = createRouter({
history: createWebHashHistory(),
routes: [
// {
// path: '/',
// redirect: () => (getWechatId() ? '/game1' : '/login'),
// },
{
path: '/loading',
name: 'Loading',
......@@ -27,9 +24,11 @@ const router = createRouter({
path: '/game1',
name: 'Game1',
component: Game1,
// meta: {
// requiresAuth: true,
// },
},
{
path: '/game2',
name: 'Game2',
component: Game2,
},
{
path: '/game3',
......@@ -51,10 +50,6 @@ const router = createRouter({
name: 'Game6',
component: Game6,
}
// {
// path: '/:pathMatch(.*)*',
// redirect: '/',
// },
],
})
......
import game1MusicUrl from '@/assets/images/game1.mp3'
import game2MusicUrl from '@/assets/images/game2.mp3'
import game4MusicUrl from '@/assets/images/cmyf-bgm.mp3'
import game5MusicUrl from '@/assets/images/qcnf-bgm.mp3'
import game6MusicUrl from '@/assets/images/game6.mp3'
......@@ -15,7 +16,7 @@ const countdownAudios = new Set<HTMLAudioElement>()
export function stopAllMusic() {
stopApplauseMusic();
stopGame1Music();
// stopGame2Music();
stopGame2Music();
// stopGame3Music();
stopGame4Music();
stopGame5Music();
......@@ -80,6 +81,24 @@ export function stopGame1Music() {
backgroundMusic?.pause()
}
export async function playGame2Music() {
const audio = getBackgroundMusic(game2MusicUrl)
try {
await audio.play()
window.removeEventListener('pointerdown', playGame2Music)
window.removeEventListener('keydown', playGame2Music)
} catch {
window.addEventListener('pointerdown', playGame2Music, { once: true })
window.addEventListener('keydown', playGame2Music, { once: true })
}
}
export function stopGame2Music() {
window.removeEventListener('pointerdown', playGame2Music)
window.removeEventListener('keydown', playGame2Music)
backgroundMusic?.pause()
}
export async function playGame4Music() {
const audio = getBackgroundMusic(game4MusicUrl)
......
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, watch } 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 { playApplauseMusic, playGame2Music, stopAllMusic } from '@/commons/music'
import { $confirm, $remove_socket_storage, $toast } 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) => {
// closeRoom();
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
}
const gameId = 'game2'
const { startGame, createRoom, backRoom, closeRoom } = useAdminGameSocket({
gameId,
onRoomState: (data: any) => {
if (Array.isArray(data?.list)) {
renderPlayers(data)
}
switch (data?.status) {
case 0:
gotoLoading(true)
createRoom(2)
break
case 1:
// if(data.gameId && data.gameId!=gameId){
// return;
// }
gotoLoading()
break
case 2:
seedRankPlayersFromLoading()
debugger
screen.value = 'rank1'
break
case 3:
debugger
screen.value = 'rank2'
break
}
},
onRoomJoin: (data) => {
renderPlayers(data)
gotoLoading()
},
onGameStart: gotoRank1WithIntro,
onRoomRank: updateRankPlayers,
onRelogin: async () => {
$remove_socket_storage();
await router.replace('/')
}
})
const startGameWithMusic = () => {
if (playerCount.value == 0) {
// alert('房间中没有玩家')
$toast('房间中没有玩家');
return;
}
startGame()
}
const nextRound = () => {
gotoLoading(true)
//重开房间
backRoom(2)
}
const nextRoundWithMusic = () => {
nextRound()
}
const backHandler = async () => {
const ok = await $confirm({
title: '提示',
message: '是否关闭当前房间回到首页',
confirmText: '确定',
cancelText: '取消',
})
if (ok) {
closeRoom();
stopAllMusic()
router.replace('/main');
}
}
onMounted(() => {
window.addEventListener('beforeunload', confirmRefresh)
playGame2Music()
})
onUnmounted(() => {
stopAllMusic()
window.removeEventListener('beforeunload', confirmRefresh)
})
watch(screen, (value) => {
// console.log(value);
if (value == 'rank2') {
playApplauseMusic();
} else if (value == 'loading') {
playGame2Music();
}
})
</script>
<template>
<Loading v-if="screen === 'loading'" :players="loadingPlayers" :player-count="playerCount"
@start="startGameWithMusic" @back="backHandler" />
<Rank1 v-else-if="screen === 'rank1'" ref="rank1Ref" :rank-list="rankPlayers" @start="startGameWithMusic" />
<Rank2 v-else :rank-list="rankPlayers" @next="nextRoundWithMusic" @back="backHandler" />
</template>
<script setup lang="ts">
import { ref } from 'vue'
import router from '@/router';
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: [];
back: [];
}>();
const imageUrls = {
bg: cssAssetUrl('game2/bg.png'),
logo: cssAssetUrl('game1/logo.png'),
title1: cssAssetUrl('game2/title.png'),
qrcode: assetUrl('game6/qrcode.png'),
// gu: cssAssetUrl('game1/gu.png'),
// bg: cssAssetUrl('game1/bg.png'),
// logo: cssAssetUrl('game1/logo.png'),
// title1: cssAssetUrl('game1/title1.png'),
// title2: cssAssetUrl('game1/title2.png'),
// qrcode: assetUrl('game1/qrcode.png'),
// gu: cssAssetUrl('game1/gu.png'),
// imgMole: cssAssetUrl('game6/mole.png'),
// imgRabbit: cssAssetUrl('game6/rabbit.png'),
bottomLayers: cssAssetUrl('game1/bottom-layers.png'),
avatarAnimation: cssAssetUrl('avatar-red_animation.png'),
avatar: cssAssetUrl('avatar-red.png'),
line: cssAssetUrl('game1/line.png'),
// start1: cssAssetUrl('game1/start-1.png'),
// start2: cssAssetUrl('game1/start-2.png'),
start: cssAssetUrl('game1/start.png'),
back: cssAssetUrl('game1/back.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="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, 8) }}</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 class="btn-start" @click="pressStartButton"></div>
<div class="btn-back" @click="emit('back')"></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: 831px;
height: 314px;
margin: 0 auto;
position: relative;
top: 110px;
}
/* .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;
}
.txt-qrcode {
font-size: 24px;
color: white;
}
}
.list-container {
background: v-bind('imageUrls.bottomLayers');
width: 1800px;
height: 397px;
position: absolute;
left: 60px;
bottom: 60px;
border: 3px solid #AA0000;
border-radius: 10px;
.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') center / cover no-repeat;
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: 10px;
background: v-bind('imageUrls.start');
cursor: pointer;
}
/* .btn-start.pressed {
background: v-bind('imageUrls.start2');
} */
.btn-back {
width: 316px;
height: 105px;
margin-left: 60px;
margin-top: 30px;
background: v-bind('imageUrls.back');
cursor: pointer;
}
}
}
</style>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } 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 } from '@/commons/assets.ts';
const props = defineProps<{
rankList: Player[];
}>();
const emit = defineEmits<{
start: [];
}>();
const HORSE_COUNT = 6;
const HORSE_FRAME_COUNT = 91;
const HORSE_FRAME_SIZE = 150;
const HORSE_SPRITE_COLUMNS = 10;
const HORSE_FRAME_DURATION = 50;
const RACE_TICK_DURATION = 50;
const RACE_DURATION = 60_000;
const FINISH_POSITION_GAP = 55;
const RANK_RUNNING_GAP = 45;
const RANK_SPEED_FACTORS = [1.04, 1.025, 1.01, 0.995, 0.98, 0.965];
const RACE_START_X = -225;
const TRACK_MAX_X = 1595;
const BACKGROUND_WIDTH = 2358;
const STAGE_WIDTH = 1920;
const designStage = ref<InstanceType<typeof DesignStage> | null>(null);
const horseFrameIndex = ref(0);
const raceElapsed = ref(0);
const raceStarted = ref(false);
const rankedPlayerKeys = ref<string[]>([]);
const testRankList = ref<Player[]>([]);
let horseFrameTimer: ReturnType<typeof setInterval> | undefined;
let raceTimer: ReturnType<typeof setInterval> | undefined;
let raceStartTimer: ReturnType<typeof setTimeout> | undefined;
type Racer = {
key: string;
lane: number;
player: Player;
x: number;
speed: number;
};
const START_COUNTDOWN_SECONDS = 3;
const racers = ref<Racer[]>([]);
const imageUrls = {
bg: cssAssetUrl('game2/bg2.png'),
logo: cssAssetUrl('game1/logo.png'),
title: cssAssetUrl('game2/title.png'),
fireworks: cssAssetUrl('game1/fireworks.png'),
// fireworks: cssAssetUrl('game1/fireworks.png'),
// titleRank: cssAssetUrl('game1/title-rank.png'),
rankNo4: cssAssetUrl('game6/no4.png'),
rankNo1: cssAssetUrl('game6/no1.png'),
rankNo2: cssAssetUrl('game6/no2.png'),
rankNo3: cssAssetUrl('game6/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: cssAssetUrl('avatar-yellow.png'),
avatarAnimation: cssAssetUrl('avatar-yellow_animation.png'),
mole2: cssAssetUrl('game6/mole2.png'),
rankContainer: cssAssetUrl('game6/rank-container.png'),
land: cssAssetUrl('game6/land.png'),
moleShow: cssAssetUrl('game6/mole_show.png'),
moleHide: cssAssetUrl('game6/mole_hide.png'),
rabbitShow: cssAssetUrl('game6/rabbit_show.png'),
rabbitHide: cssAssetUrl('game6/rabbit_hide.png'),
};
const horseSprites = [
cssAssetUrl('game2/horse1.png'),
cssAssetUrl('game2/horse2.png'),
cssAssetUrl('game2/horse3.png'),
];
const playerKey = (player: Player, index = 0) => (
String(player.userid || player.wechat_id || player.telephone || `${player.nickname}-${index}`)
);
const isRealPlayer = (player: Player) => (
Boolean(player)
&& !String(player.wechat_id || '').startsWith('empty-')
&& player.nickname !== '-'
);
const renderRaceBackground = () => {
const progress = Math.min(raceElapsed.value / RACE_DURATION, 1);
const backgroundX = -(BACKGROUND_WIDTH - STAGE_WIDTH) * progress;
return {
backgroundImage: imageUrls.bg,
backgroundPosition: `${backgroundX}px center`,
};
};
const targetXForRank = (rankIndex: number) => {
const safeRankIndex = Math.max(0, Math.min(rankIndex, HORSE_COUNT - 1));
const progress = Math.min(raceElapsed.value / RACE_DURATION, 1);
const runningGap = RANK_RUNNING_GAP * Math.sin(Math.PI * progress);
const finishGap = FINISH_POSITION_GAP * progress;
return RACE_START_X
+ (TRACK_MAX_X - RACE_START_X) * progress
- safeRankIndex * (runningGap + finishGap);
};
const shuffledLanes = () => {
const lanes = Array.from({ length: HORSE_COUNT }, (_, lane) => lane);
for (let index = lanes.length - 1; index > 0; index -= 1) {
const randomIndex = Math.floor(Math.random() * (index + 1));
[lanes[index], lanes[randomIndex]] = [lanes[randomIndex]!, lanes[index]!];
}
return lanes;
};
const syncRacers = (rankList: Player[]) => {
const topPlayers = rankList.filter(isRealPlayer).slice(0, HORSE_COUNT);
const rankedEntries = topPlayers.map((player, index) => ({
key: playerKey(player, index),
player,
}));
const topKeys = rankedEntries.map(({ key }) => key);
const topPlayerMap = new Map(
rankedEntries.map(({ key, player }) => [key, player]),
);
const previousRacersByLane = new Map(
racers.value.map((racer) => [racer.lane, racer]),
);
const retained = racers.value
.filter((racer) => topPlayerMap.has(racer.key))
.map((racer) => ({ ...racer, player: topPlayerMap.get(racer.key)! }));
const retainedKeys = new Set(retained.map((racer) => racer.key));
const availableLanes = Array.from(
{ length: HORSE_COUNT },
(_, lane) => lane,
).filter((lane) => !retained.some((racer) => racer.lane === lane));
const freeLanes = racers.value.length === 0 ? shuffledLanes() : availableLanes;
const newcomers = rankedEntries
.filter(({ key }) => !retainedKeys.has(key))
.map(({ key, player }, index) => ({
key,
player,
lane: freeLanes[index]!,
x: previousRacersByLane.get(freeLanes[index]!)?.x
?? RACE_START_X,
speed: previousRacersByLane.get(freeLanes[index]!)?.speed ?? 0,
}));
racers.value = [...retained, ...newcomers]
.sort((a, b) => a.lane - b.lane);
rankedPlayerKeys.value = topKeys;
};
const updateRacePositions = () => {
if (raceElapsed.value >= RACE_DURATION) {
racers.value.forEach((racer) => {
racer.speed = 0;
});
return;
}
racers.value.forEach((racer) => {
const rankIndex = rankedPlayerKeys.value.indexOf(racer.key);
const effectiveRank = rankIndex >= 0 ? rankIndex : HORSE_COUNT - 1;
const targetX = targetXForRank(effectiveRank);
const speedFactor = RANK_SPEED_FACTORS[effectiveRank] ?? RANK_SPEED_FACTORS.at(-1)!;
const desiredSpeed = Math.max((targetX - racer.x) * 0.18 * speedFactor, 0);
racer.speed += (desiredSpeed - racer.speed) * 0.24;
racer.x = Math.min(racer.x + Math.max(racer.speed, 0), TRACK_MAX_X);
});
};
const resetRace = (startImmediately = true) => {
raceElapsed.value = 0;
raceStarted.value = startImmediately;
const lanes = shuffledLanes();
const leaderKey = rankedPlayerKeys.value[0];
const leaderIndex = racers.value.findIndex((racer) => racer.key === leaderKey);
if (leaderIndex >= 0 && lanes[leaderIndex] === 0 && lanes.length > 1) {
[lanes[leaderIndex], lanes[1]] = [lanes[1]!, lanes[leaderIndex]!];
}
racers.value.forEach((racer) => {
racer.lane = lanes[racers.value.indexOf(racer)]!;
racer.x = RACE_START_X;
racer.speed = 0;
});
racers.value.sort((a, b) => a.lane - b.lane);
};
const renderHorseFrame = (racer: Racer) => {
const column = horseFrameIndex.value % HORSE_SPRITE_COLUMNS;
const row = Math.floor(horseFrameIndex.value / HORSE_SPRITE_COLUMNS);
return {
backgroundImage: horseSprites[racer.lane % horseSprites.length],
backgroundPosition: `${-column * HORSE_FRAME_SIZE}px ${-row * HORSE_FRAME_SIZE}px`,
transform: `translateX(${racer.x}px) scale(1.5)`,
};
};
const startGame = () => {
if ($is_run_local()) {
emit('start');
}
};
const createTestPlayers = () => Array.from({ length: 7 }, (_, index): Player => ({
userid: `test-player-${index + 1}`,
wechat_id: `test-player-${index + 1}`,
nickname: `测试玩家${index + 1}`,
avatar: '',
score: 700 - index * 100,
}));
const updateTestRank = () => {
if (!$is_run_local()) return;
if (testRankList.value.length === 0) {
testRankList.value = createTestPlayers();
syncRacers(testRankList.value);
resetRace();
return;
}
const player = testRankList.value[Math.floor(Math.random() * testRankList.value.length)];
if (player) {
player.score = Number(player.score || 0) + 80 + Math.floor(Math.random() * 180);
}
testRankList.value = [...testRankList.value].sort(
(a, b) => Number(b.score || 0) - Number(a.score || 0),
);
syncRacers(testRankList.value);
};
onMounted(() => {
horseFrameTimer = setInterval(() => {
horseFrameIndex.value = (horseFrameIndex.value + 1) % HORSE_FRAME_COUNT;
}, HORSE_FRAME_DURATION);
raceTimer = setInterval(() => {
if (!raceStarted.value) return;
raceElapsed.value = Math.min(raceElapsed.value + RACE_TICK_DURATION, RACE_DURATION);
updateRacePositions();
}, RACE_TICK_DURATION);
});
onBeforeUnmount(() => {
if (horseFrameTimer) {
clearInterval(horseFrameTimer);
}
if (raceTimer) {
clearInterval(raceTimer);
}
if (raceStartTimer) {
clearTimeout(raceStartTimer);
}
});
const startIntro = async () => {
resetRace(false);
const countdown = designStage.value?.startCountdown(START_COUNTDOWN_SECONDS, 60);
raceStartTimer = setTimeout(() => {
raceStarted.value = true;
raceStartTimer = undefined;
}, START_COUNTDOWN_SECONDS * 1000);
await countdown;
};
defineExpose({
startIntro,
});
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;`
}
watch(
() => props.rankList,
(rankList) => syncRacers(rankList),
{ immediate: true, deep: true },
);
</script>
<template>
<DesignStage ref="designStage">
<div class="bg" :style="renderRaceBackground()">
<div v-if="$is_run_local()" class="test-click-layer"></div>
<div class="img-logo"></div>
<div class="img-title"></div>
<div class="horse-container">
<div v-for="racer in racers" :key="racer.lane" class="horse-lane">
<div class="horse" :style="renderHorseFrame(racer)">
<div class="name-container">
<div class="nickname">{{ racer.player.nickname }}</div>
<div class="img-avatar" :style="renderAvatar(racer.player)"></div>
</div>
</div>
</div>
</div>
</div>
</DesignStage>
</template>
<style scoped>
.bg {
width: 1920px;
height: 1080px;
background-repeat: no-repeat;
background-size: 2358px 1080px;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.test-click-layer {
position: absolute;
inset: 0;
z-index: 1;
}
.img-logo {
background: v-bind('imageUrls.logo');
background-size: cover;
width: 428px;
height: 77px;
position: absolute;
left: 37px;
top: 82px;
z-index: 2;
}
.img-title {
background: v-bind('imageUrls.title') center / cover no-repeat;
width: 831px;
height: 325px;
margin: 0 auto;
margin-top: 37px;
z-index: 2;
}
.horse-container {
position: absolute;
bottom: 0px;
left: 0;
display: flex;
flex-direction: column;
width: 1920px;
height: 500px;
z-index: 2;
pointer-events: none;
}
.horse-lane {
position: relative;
flex: 1;
min-height: 0;
}
.horse {
position: absolute;
left: 0;
bottom: 0;
width: 150px;
height: 150px;
background-repeat: no-repeat;
background-size: 1500px 1500px;
transform-origin: left bottom;
transition: transform 50ms linear;
.name-container {
display: flex;
align-items: center;
gap: 8px;
width: 150px;
height: 42px;
position: absolute;
left: -100px;
top: 50px;
.nickname {
max-width: 150px;
padding: 4px 8px;
color: #fff;
background: rgb(0 0 0 / 55%);
border-radius: 12px;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 20px;
}
.img-avatar {
flex: 0 0 42px;
width: 42px;
height: 42px;
border: 2px solid #fff;
border-radius: 50%;
}
}
}
</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: [];
back: [];
}>();
const FINAL_RANK_LIMIT = 10;
const imageUrls = {
bg: cssAssetUrl('game2/bg3.png'),
logo: cssAssetUrl('game1/logo.png'),
title: cssAssetUrl('game6/title3.png'),
title2: cssAssetUrl('game2/title2.png'),
podium: cssAssetUrl('game6/podium.png'),
vip: cssAssetUrl('game6/vip.png'),
avatar: cssAssetUrl('avatar-yellow.png'),
avatarAnimation: cssAssetUrl('avatar-yellow_animation.png'),
rankIcon: cssAssetUrl('game6/no4.png'),
next: cssAssetUrl('game1/next.png'),
back: cssAssetUrl('game1/back.png'),
};
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: ${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="img-vip"></div>
<div class="rank-no1">
<div class="avatar" :style="`${renderAvatar(list[0])}`">
</div>
<div class="nickname">{{ list[0]?.nickname }}</div>
</div>
<div class="rank-no2">
<div class="avatar" :style="`${renderAvatar(list[1])}`">
</div>
<div class="nickname">{{ list[1]?.nickname }}</div>
</div>
<div class="rank-no3">
<div class="avatar" :style="`${renderAvatar(list[2])}`">
</div>
<div class="nickname">{{ list[2]?.nickname }}</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" :style="`${renderAvatar(list[index + 3])}`"></div>
</div>
<div class="txt-nickname">{{ item.nickname }}</div>
</div>
</div>
</div>
<div class="btn-next" @click="emit('next')"></div>
<div class="btn-back" @click="emit('back')"></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') center / cover no-repeat;
width: 831px;
height: 120px;
margin: 0 auto;
margin-top: 64px;
}
.img-title2 {
background: v-bind('imageUrls.title2') no-repeat center;
width: 436px;
height: 54px;
margin: 0 auto;
margin-top: 37px;
}
.podium {
background: v-bind('imageUrls.podium') no-repeat center;
width: 1920px;
height: 740px;
position: absolute;
bottom: 0;
left: 0;
.img-vip {
width: 969px;
height: 238px;
position: absolute;
left: 480px;
top: -60px;
background: v-bind('imageUrls.vip') center / cover no-repeat;
}
.rank-no1 {
position: relative;
.avatar {
width: 160px;
height: 160px;
border-radius: 80px;
position: absolute;
left: 884px;
top: -7px;
}
.nickname {
width: 220px;
height: 52px;
background: linear-gradient(0deg, #FFBF5A 0%, #FFE47A 100%);
border: 1px solid #FFF9DC;
text-align: center;
line-height: 46px;
position: absolute;
left: 850px;
top: 136px;
z-index: 1;
border-radius: 26px;
font-size: 30px;
color: #4B1A14;
}
}
.rank-no2 {
position: relative;
.avatar {
width: 144px;
height: 144px;
border-radius: 72px;
position: absolute;
left: 488px;
top: 27px;
/* border: 1px solid red; */
}
.nickname {
width: 220px;
height: 52px;
background: linear-gradient(0deg, #AEA6A7 0%, #EAE4DF 100%);
border: 1px solid white;
color: #383636;
text-align: center;
line-height: 52px;
position: absolute;
left: 447px;
top: 160px;
z-index: 1;
border-radius: 23px;
font-size: 30px;
}
}
.rank-no3 {
position: relative;
.avatar {
width: 144px;
height: 144px;
border-radius: 72px;
position: absolute;
left: 1297px;
top: 26px;
}
.nickname {
width: 220px;
height: 52px;
background: linear-gradient(0deg, #F3AE76 0%, #F9CDA3 100%);
color: #6F3A0E;
text-align: center;
line-height: 46px;
position: absolute;
left: 1255px;
top: 160px;
z-index: 1;
border-radius: 26px;
font-size: 30px;
border: 1px solid #FEFEFD;
}
}
}
.rank-container {
display: flex;
align-items: center;
width: 1570px;
height: 115px;
position: absolute;
bottom: 263px;
left: 200px;
>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: 42px;
color: #CC7F36;
font-weight: bold;
top: 50px;
}
.img-avatar-container {
position: relative;
margin: 0 auto;
margin-top: 10px;
width: 72px;
height: 72px;
border-radius: 36px;
transform: scale(1.6);
top: 50px;
}
.img-avatar-container::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: v-bind('imageUrls.avatarAnimation') center / cover no-repeat;
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: #F9EBCB;
font-size: 22px;
text-align: center;
position: absolute;
top: 170px;
text-align: center;
/* left:10px; */
margin-left: 68px;
}
}
}
.btn-next {
position: absolute;
bottom: 50px;
left: 620px;
width: 316px;
height: 105px;
cursor: pointer;
background: v-bind('imageUrls.next') center / cover no-repeat;
}
.btn-back {
position: absolute;
bottom: 50px;
left: 1020px;
width: 316px;
height: 105px;
cursor: pointer;
background: v-bind('imageUrls.back') center / cover no-repeat;
}
</style>
......@@ -4,6 +4,7 @@ import { $read } from '@/commons/utils'
import Login from '@/pages/Login.vue'
import Main from '@/pages/Main.vue'
import Game1 from '@/pages/game1/Game.vue'
import Game2 from '@/pages/game2/Game.vue'
import Game3 from '@/pages/game3/Game3.vue'
import Game4 from '@/pages/game4/Game4.vue'
import Game5 from '@/pages/game5/Game5.vue'
......@@ -38,6 +39,14 @@ const router = createRouter({
},
},
{
path: '/game2',
name: 'Game2',
component: Game2,
meta: {
requiresAuth: true,
},
},
{
path: '/game3',
name: 'Game3',
component: Game3,
......
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