Commit 0d467745 authored by 董政锦's avatar 董政锦

style: game3的h5端游戏页面;

parent 2f5cc1aa
<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 { $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('game3/bg.webp'),
bg3: cssAssetUrl('game3/bg3.webp'),
bg4: cssAssetUrl('game3/pic_paodao.webp'),
logo: cssAssetUrl('logo.png'),
loadingBg1: cssAssetUrl('game6/loadingbg1.png'),
loadingBg2: cssAssetUrl('game6/loadingbg2.png'),
close: cssAssetUrl('game1/close.png'),
clock: cssAssetUrl('game1/clock.png'),
bg2: cssAssetUrl('game6/bg2.png'),
hydt: cssAssetUrl('game3/title3.webp'),
rank: cssAssetUrl('game6/rank.png'),
moleShowSprite: cssAssetUrl('game6/mole_show.png'),
moleHideSprite: cssAssetUrl('game6/mole_hide.png'),
moleHideprite: cssAssetUrl('game6/mole_hit.png'),
rabbitFaintprite: cssAssetUrl('game6/rabbit_faint.png'),
rabbitHideprite: cssAssetUrl('game6/rabbit_hide.png'),
rabbitshowprite: cssAssetUrl('game6/rabbit_show.png'),
land: cssAssetUrl('game6/land.png'),
hammer: cssAssetUrl('game6/hammer.png'),
ranklist: cssAssetUrl('game6/ranklist.png'),
rule: cssAssetUrl('game1/rule.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: 6,
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 = (mole: boolean) => {
let scoreDelta = mole ? 5 : 0;
let nextTick = score.value + scoreDelta
score.value = nextTick
if (mole) {
playGame6Music(1);
submitScore(false, nextTick)
} else {
playGame6Music(2);
}
}
if (wechat) {
const gameId = 'game3';
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: () => {
resetToLoadingView()
window.setTimeout(() => {
joinSharedRoom(nickname.value, token.value, avatar.value, gameId)
}, 200)
},
})
_offSocketMessage.value = offSocketMessage
}
onMounted(() => {
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. 开赛左右操控小马,接福袋 + 5 分、碰地雷 - 3 分,每秒行进 + 2 分;<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>{{ item.nickname }}</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>
<div class="img-close" @click="rankCloseHandler"></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;
}
}
.rule-close {
width: 57px;
height: 57px;
background: v-bind('imageUrls.close') center / cover no-repeat;
transform: translateX(-50%) scale(1.4);
position: absolute;
bottom: -75px;
left: 50%;
}
}
.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;
}
</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-loadingbg1">游戏等待开始倒计时60秒</div>
<div class="img-loadingbg2">游戏即将开始 敬请期待</div>
<div class="txt-bottom" v-if="userJoinStatus">您已成功加入游戏<br />等待主持人开始</div>
</div>
</template>
<style scoped>
.img-loadingbg1 {
font-size: 27pt;
width: 502px;
height: 90px;
background: v-bind('imageUrls.loadingBg1') center/cover;
border-radius: 45px;
position: absolute;
top: 474px;
left: 50%;
margin-left: -251px;
text-align: center;
line-height: 90px;
font-weight: bold;
color: #B90103;
letter-spacing: 2pt;
}
.img-loadingbg2 {
width: 100%;
height: 126px;
background: v-bind('imageUrls.loadingBg2') center/cover;
position: absolute;
top: 50%;
left: 50%;
font-size: 46px;
text-align: center;
line-height: 126px;
color: white;
letter-spacing: 2pt;
transform: translate(-50%, -50%);
}
.txt-bottom {
position: absolute;
bottom: 130px;
width: 100%;
line-height: 40px;
color: white;
font-size: 28px;
text-align: center;
}
.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, onMounted, ref } from 'vue';
defineProps<{
imageUrls: Record<string, string>
countdownInterval: number
isDivDescVisible: boolean
tick: number
rank: number
// currentGu: string
}>()
const emit = defineEmits<{
touch: [mole: boolean]
}>()
type TargetKind = 'mole' | 'rabbit'
type TargetState = 'idle' | 'show' | 'hide' | 'hit'
type TargetItem = {
id: number
kind: TargetKind | null
state: TargetState
showMs: number
hideMs: number
hitMs: number
timer?: ReturnType<typeof window.setTimeout>
}
const list = ref<TargetItem[]>([]);
const moleContainerRef = ref<HTMLElement | null>(null)
for (let i = 0; i < 9; i++) {
list.value.push({
id: i,
kind: null,
state: 'idle',
showMs: 650,
hideMs: 350,
hitMs: 700,
})
}
let spawnTimer: ReturnType<typeof window.setTimeout> | undefined
function clearTargetTimer(item: TargetItem) {
if (item.timer) {
window.clearTimeout(item.timer)
item.timer = undefined
}
}
function setTargetState(index: number, state: TargetState) {
const item = list.value[index]
if (!item) {
return
}
item.state = state
}
function resetTarget(index: number) {
const item = list.value[index]
if (!item) {
return
}
clearTargetTimer(item)
item.kind = null
item.state = 'idle'
}
function playTargetLifecycle(index: number, kind: TargetKind, duration = 1000) {
const item = list.value[index]
if (!item) {
return
}
clearTargetTimer(item)
item.kind = kind
item.state = 'show'
item.showMs = Math.round(duration * 0.65)
item.hideMs = Math.max(1, duration - item.showMs)
item.hitMs = kind === 'mole' ? 740 : 660
item.timer = window.setTimeout(() => {
setTargetState(index, 'hide')
item.timer = window.setTimeout(() => {
resetTarget(index)
}, item.hideMs)
}, item.showMs)
}
function randomTargetKind(): TargetKind {
return Math.random() < 0.75 ? 'mole' : 'rabbit'
}
function randomCount(min = 1, max = 3) {
return Math.floor(Math.random() * (max - min + 1)) + min
}
function spawnRandomTargets(duration = 1000) {
const idleIndexes = list.value
.map((item, index) => item.state === 'idle' ? index : -1)
.filter(index => index >= 0)
.sort(() => Math.random() - 0.5)
const count = Math.min(randomCount(1, 3), idleIndexes.length)
for (const index of idleIndexes.slice(0, count)) {
playTargetLifecycle(index, randomTargetKind(), duration)
}
}
function scheduleRandomTargets() {
spawnRandomTargets(1000)
spawnTimer = window.setTimeout(scheduleRandomTargets, 1200)
}
function handleTargetTouch(index: number) {
const item = list.value[index]
if (!item || item.state === 'idle' || item.state === 'hit') {
return
}
emit('touch', item.kind === 'mole')
clearTargetTimer(item)
item.state = 'hit'
item.timer = window.setTimeout(() => {
resetTarget(index)
}, item.hitMs)
}
function handleBoardTouch(event: MouseEvent) {
const container = moleContainerRef.value
if (!container) {
return
}
const rect = container.getBoundingClientRect()
const x = ((event.clientX - rect.left) / rect.width) * 750
const y = ((event.clientY - rect.top) / rect.height) * 600
const colWidth = 250
const rowGap = 70
const rowHeight = (600 - rowGap * 2) / 3
const hitRadius = 225
let targetIndex = -1
let targetDistance = Number.POSITIVE_INFINITY
list.value.forEach((item, index) => {
if (!item.kind || item.state === 'idle' || item.state === 'hit') {
return
}
const col = index % 3
const row = Math.floor(index / 3)
const centerX = col * colWidth + colWidth / 2
const centerY = row * (rowHeight + rowGap) + rowHeight / 2
const distance = Math.hypot(x - centerX, y - centerY)
if (distance <= hitRadius && distance < targetDistance) {
targetIndex = index
targetDistance = distance
}
})
if (targetIndex >= 0) {
handleTargetTouch(targetIndex)
}
}
onMounted(() => {
scheduleRandomTargets()
})
onBeforeUnmount(() => {
if (spawnTimer) {
window.clearTimeout(spawnTimer)
}
for (const item of list.value) {
clearTargetTimer(item)
}
})
</script>
<template>
<div class="h5-page game-stage ">
<div class="only-bg"></div>
<div class="content-bg" :class="{ scrolling: isDivDescVisible }">
<div class="road-track">
<div class="road-inner"></div>
<div class="road-inner"></div>
</div>
</div>
<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="img-hammer"></div>
<!-- <div class="play-content">
<div>当前击鼓次数</div>
<div>{{ tick }}</div>
<div>当前排名: &nbsp;&nbsp;<label>{{ rank }}</label>&nbsp;</div>
</div> -->
<!-- <div class="bg-bottom"></div> -->
<!-- <div class="play-gu" :style="{ background: `${currentGu} center/cover` }" @click="$emit('touch')"></div> -->
</div>
</template>
<style scoped>
.game-stage {
position: absolute;
width: 750px;
height: 1624px;
padding: 0;
}
.only-bg{
position: absolute;
top: 0;
left: var(--stage-viewport-left, 0);
width: var(--stage-viewport-width, 750px);
height: 421.9px;
background: v-bind('imageUrls.bg3') center/cover no-repeat;
}
.content-bg{
position: absolute;
top: 421.9px;
left: var(--stage-viewport-left, 0);
width: var(--stage-viewport-width, 750px);
height: calc(1624px - 421.9px);
overflow: hidden;
z-index: 1;
}
.road-track {
width: 100%;
height: calc(1624px - 421.9px);
position: absolute;
top: 0;
left: 0;
display: flex;
flex-direction: column;
animation: road-scroll-up 8s linear infinite;
/* animation-play-state: paused; */
}
.content-bg.scrolling .road-track {
animation-play-state: running;
}
.road-inner {
width: 100%;
height: calc(1624px - 421.9px);
background: v-bind('imageUrls.bg4') center / cover no-repeat;
flex-shrink: 0;
}
@keyframes road-scroll-up {
0% {
transform: translateY(calc(-1 * (1624px - 421.9px)));
}
100% {
transform: translateY(0);
}
}
.img-hydt {
position: absolute;
width: 454px;
height: 181px;
top: 174px;
left: 52%;
transform: translate(-50%, -50%);
background: v-bind('imageUrls.hydt') center/cover;
}
.rank-content {
width: 600px;
height: 140px;
background: v-bind('imageUrls.rank') center/cover;
position: absolute;
left: 50%;
top: 480px;
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;
}
}
.img-hammer {
width: 148px;
height: 148px;
background: v-bind('imageUrls.hammer') center/cover;
position: absolute;
left: 50%;
bottom: 100px;
transform: translate(-50%, 0);
}
.mole-container {
position: absolute;
width: 750px;
height: 600px;
row-gap: 70px;
top: 560px;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
place-items: center;
>div {
position: relative;
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
}
}
.img-land,
.img-mole {
position: absolute;
top: 50%;
left: 50%;
width: 150px;
height: 150px;
transform: translate(-50%, -50%) scale(3);
transform-origin: center;
}
.img-mole {
display: block;
pointer-events: none;
}
.img-land,
.img-mole {
background-position: 0 0;
background-repeat: no-repeat;
background-size: auto 150px;
}
.img-land {
background-image: v-bind('imageUrls.land');
pointer-events: none;
}
.img-mole.is-mole.is-show {
background-image: v-bind('imageUrls.moleShowSprite');
animation: sprite-show var(--show-duration, 650ms) steps(31) forwards;
}
.img-mole.is-mole.is-hide {
background-image: v-bind('imageUrls.moleHideSprite');
animation: sprite-hide var(--hide-duration, 350ms) steps(16) forwards;
}
.img-mole.is-mole.is-hit {
background-image: v-bind('imageUrls.moleHideprite');
animation: sprite-mole-hit var(--hit-duration, 740ms) steps(37) forwards;
}
.img-mole.is-rabbit.is-show {
background-image: v-bind('imageUrls.rabbitshowprite');
animation: sprite-show var(--show-duration, 650ms) steps(31) forwards;
}
.img-mole.is-rabbit.is-hide {
background-image: v-bind('imageUrls.rabbitHideprite');
animation: sprite-hide var(--hide-duration, 350ms) steps(16) forwards;
}
.img-mole.is-rabbit.is-hit {
background-image: v-bind('imageUrls.rabbitFaintprite');
animation: sprite-rabbit-hit var(--hit-duration, 660ms) steps(33) forwards;
}
@keyframes sprite-show {
from {
background-position: 0 0;
}
to {
background-position: -4650px 0;
}
}
@keyframes sprite-hide {
from {
background-position: 0 0;
}
to {
background-position: -2400px 0;
}
}
@keyframes sprite-mole-hit {
from {
background-position: 0 0;
}
to {
background-position: -5550px 0;
}
}
@keyframes sprite-rabbit-hit {
from {
background-position: 0 0;
}
to {
background-position: -4950px 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;
}
}
.div-desc-slide-enter-active {
transition:
transform 360ms ease-out 220ms,
opacity 360ms ease-out 220ms;
}
.div-desc-slide-leave-active {
transition:
transform 260ms ease-in,
opacity 260ms ease-in;
}
.div-desc-slide-enter-from,
.div-desc-slide-leave-to {
opacity: 0;
transform: translateX(-110%);
}
.div-desc-slide-enter-to,
.div-desc-slide-leave-from {
opacity: 1;
transform: translateX(0);
}
.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: 46px;
left: 20px;
width: 428px;
height: 77px;
background: v-bind('imageUrls.logo') center/cover;
/* transform: translateX(-50%); */
}
.play-content {
margin-top: 240px;
margin-left: 40px;
margin-right: 40px;
padding: 30px 0;
border-radius: 25px;
background: rgba(40, 12, 8, 0.52);
color: white;
font-size: 28pt;
text-align: center;
}
.play-content>div:nth-child(2) {
display: inline-block;
margin: 10px 0;
background: linear-gradient(180deg, #FFE68D 0%, #FAD500 100%);
background-clip: text;
color: transparent;
font-size: 48pt;
font-weight: bold;
transform: scaleY(1.2);
transform-origin: center;
-webkit-background-clip: text;
}
.play-content>div:nth-child(3) {
position: relative;
top: -10px;
}
.play-content>div:nth-child(3) label {
color: white;
font-size: 42pt;
}
.play-gu {
position: absolute;
bottom: 350px;
left: 50%;
width: 400px;
height: 400px;
transform: translateX(-50%) scale(2.4);
}
</style>
<!-- <script setup lang="ts">
import { $getWechat } from '@/commons/utils';
import { onMounted, ref } from 'vue';
defineProps<{
imageUrls: Record<string, string>
tick: number
rank: number
}>()
defineEmits<{
showRank: []
replay: []
back: []
}>()
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="img-logo"></div>
</div>
</template>
<style scoped>
.game-stage {
position: absolute;
width: 750px;
height: 1624px;
padding: 0;
}
.img-logo {
position: absolute;
top: 66px;
left: 50%;
width: 428px;
height: 77px;
background: v-bind('imageUrls.logo') center/cover;
transform: translateX(-50%);
}
</style> -->
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import { $read } from '@/commons/utils' import { $read } from '@/commons/utils'
import Game1 from '@/pages/game1/Game.vue' import Game1 from '@/pages/game1/Game.vue'
import Game3 from '@/pages/game3/Game3.vue'
import Game4 from '@/pages/game4/Game4.vue' import Game4 from '@/pages/game4/Game4.vue'
import Game5 from '@/pages/game5/Game5.vue' import Game5 from '@/pages/game5/Game5.vue'
import Game6 from '@/pages/game6/Game.vue' import Game6 from '@/pages/game6/Game.vue'
...@@ -25,6 +26,11 @@ const router = createRouter({ ...@@ -25,6 +26,11 @@ const router = createRouter({
// }, // },
}, },
{ {
path: '/game3',
name: 'Game3',
component: Game3,
},
{
path: '/game4', path: '/game4',
name: 'Game4', name: 'Game4',
component: Game4, component: Game4,
......
...@@ -109,7 +109,7 @@ const gotoRank1WithIntro = async () => { ...@@ -109,7 +109,7 @@ const gotoRank1WithIntro = async () => {
screen.value = 'rank1' screen.value = 'rank1'
await nextTick() await nextTick()
await rank1Ref.value?.startIntro() await rank1Ref.value?.startIntro()
screen.value = 'rank2'
isPlayingStartAnimation = false isPlayingStartAnimation = false
} }
...@@ -135,7 +135,7 @@ function handleRoomState(data: any) { ...@@ -135,7 +135,7 @@ function handleRoomState(data: any) {
break break
} }
} }
const gameId='game6' const gameId='game3'
const { startGame, createRoom, backRoom, closeRoom } = useAdminGameSocket({ const { startGame, createRoom, backRoom, closeRoom } = useAdminGameSocket({
gameId, gameId,
onRoomState: handleRoomState, onRoomState: handleRoomState,
...@@ -207,6 +207,5 @@ watch(screen, (value) => { ...@@ -207,6 +207,5 @@ watch(screen, (value) => {
<template> <template>
<Loading v-if="screen === 'loading'" :players="loadingPlayers" :player-count="playerCount" <Loading v-if="screen === 'loading'" :players="loadingPlayers" :player-count="playerCount"
@start="startGameWithMusic" @back="backHandler" /> @start="startGameWithMusic" @back="backHandler" />
<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" @back="backHandler" />
<Rank2 v-else :rank-list="rankPlayers" @next="nextRoundWithMusic" @back="backHandler" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue' import { ref, watch, onMounted, onUnmounted } from 'vue'
import DesignStage from '../../../components/DesignStage.vue' import DesignStage from '../../../components/DesignStage.vue'
import type Player from '../../../commons/player.ts' import type Player from '../../../commons/player.ts'
import { $format_str, $is_run_local } from '@/commons/utils.ts'; import { $format_str, $is_run_local } from '@/commons/utils.ts'
import { assetUrl, cssAssetUrl } from '@/commons/assets.ts'; import { assetUrl, cssAssetUrl } from '@/commons/assets.ts'
import { playApplauseMusic, stopApplauseMusic } from '@/commons/music'
const props = defineProps<{ const props = defineProps<{
rankList: Player[]; rankList: Player[];
...@@ -11,62 +12,72 @@ const props = defineProps<{ ...@@ -11,62 +12,72 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
start: []; start: [];
next: [];
back: [];
}>(); }>();
const RANK_LIMIT = 10; const RANK_LIMIT = 10;
const START_COUNTDOWN_SECONDS = 3;
const GU_FRAME_DURATION = 300;
const GU_LOOP_DELAY = 1000;
const MOLE_CELL_COUNT = 9;
const MOLE_LAND_DURATION = 5000;
const MOLE_SHOW_DURATION = 1200;
const MOLE_HIDE_DURATION = 650;
const designStage = ref<InstanceType<typeof DesignStage> | null>(null); const designStage = ref<InstanceType<typeof DesignStage> | null>(null);
const rankEffects = ref<Record<string, "up" | "down" | "new">>({}); const gameStarted = ref(false);
const guFrameIndex = ref(0); const isGameRunning = ref(false);
const guBurstVisible = ref(false); const showResult = ref(false);
const guBurstKey = ref(0);
let guFramesReady: Promise<void> | undefined;
let guTimer: ReturnType<typeof setTimeout> | undefined;
let guBurstTimer: ReturnType<typeof setTimeout> | undefined;
// const loadGuFrames = (dir: string) => Array.from(
// { length: 10 },
// (_, index) => assetUrl(`${dir}/${String(index + 1).padStart(2, '0')}.png`),
// );
// const guFrames = [
// loadGuFrames('game1/gu'),
// loadGuFrames('game1/gu2'),
// ];
const imageUrls = { const imageUrls = {
bg: cssAssetUrl('game6/bg2.png'), bg: cssAssetUrl('game3/bg2.webp'),
logo: cssAssetUrl('game1/logo.png'), logo: cssAssetUrl('game1/logo.png'),
title: cssAssetUrl('game6/title2.png'), title: cssAssetUrl('game3/title2.webp'),
fireworks: cssAssetUrl('game1/fireworks.png'), username: cssAssetUrl('game4/username.svg'),
defaultAvatar: assetUrl('game4/default-avator.svg'),
// fireworks: cssAssetUrl('game1/fireworks.png'), resultBg: cssAssetUrl('game6/bg3.png'),
// titleRank: cssAssetUrl('game1/title-rank.png'), resultTitle: cssAssetUrl('game6/title3.png'),
rankNo4: cssAssetUrl('game6/no4.png'), podium: cssAssetUrl('game6/podium.png'),
rankNo1: cssAssetUrl('game6/no1.png'), vip: cssAssetUrl('game6/vip.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'), avatar: cssAssetUrl('avatar-yellow.png'),
avatarAnimation: cssAssetUrl('avatar-yellow_animation.png'), avatarAnimation: cssAssetUrl('avatar-yellow_animation.png'),
mole2: cssAssetUrl('game6/mole2.png'), rankIcon: cssAssetUrl('game6/no4.png'),
rankContainer: cssAssetUrl('game6/rank-container.png'), next: cssAssetUrl('game1/next.png'),
land: cssAssetUrl('game6/land.png'), back: cssAssetUrl('game1/back.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'),
}; };
// ========== 马匹帧配置(只用 ma1/ma2/ma3,不用 ma4) ==========
const HORSE_SIZE = 280;
const HORSE_START_X = -HORSE_SIZE;
const HORSE_END_X = 1920;
const horseFrameConfigs = [
{ type: 1, dir: 'game3/ma1', prefix: '1ma_', startIndex: 24, count: 12 },
{ type: 2, dir: 'game3/ma2', prefix: '2ma_', startIndex: 24, count: 12 },
{ type: 3, dir: 'game3/ma3', prefix: '3ma_', startIndex: 12, count: 14 },
];
const HORSE_TYPES = [1, 2, 3] as const;
const horseFrames: Record<number, string[]> = {};
const horseFrameCount: Record<number, number> = {};
for (const config of horseFrameConfigs) {
horseFrames[config.type] = Array.from({ length: config.count }, (_, i) =>
assetUrl(`${config.dir}/${config.prefix}${String(config.startIndex + i).padStart(5, '0')}.png`),
);
horseFrameCount[config.type] = config.count;
}
interface Horse {
id: string;
horseType: number;
lane: number;
frameIndex: number;
frameTimer: number;
startTime: number;
durationMs: number;
x: number;
nickname: string;
avatar: string;
}
const horses = ref<Horse[]>([]);
let horseIdCounter = 0;
// ========== 排名相关 ==========
const emptyPlayer = (index: number): Player => ({ const emptyPlayer = (index: number): Player => ({
wechat_id: `empty-${index}`, wechat_id: `empty-${index}`,
nickname: '-', nickname: '-',
...@@ -76,325 +87,294 @@ const emptyPlayer = (index: number): Player => ({ ...@@ -76,325 +87,294 @@ const emptyPlayer = (index: number): Player => ({
const list = ref<Player[]>(Array.from({ length: RANK_LIMIT }, (_, index) => emptyPlayer(index))); 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 playerKey = (player: Player, index = 0) => player.userid || player.wechat_id || `empty-${index}`;
const mockRankStep = ref(0);
type MoleKind = "land" | "mole" | "rabbit";
type MolePhase = "land" | "show" | "hide";
type MoleCell = {
kind: MoleKind;
phase: MolePhase;
animationKey: number;
};
const moleCells = ref<MoleCell[]>(
Array.from({ length: MOLE_CELL_COUNT }, () => ({
kind: "land",
phase: "land",
animationKey: 0,
})),
);
const moleTimers: Array<ReturnType<typeof setTimeout> | undefined> = [];
const makeMockPlayer = (id: number, score: number): Player => ({
wechat_id: `mock-${id}`,
nickname: `Player ${id}`,
avatar: '',
score,
});
const mockRankRounds: Player[][] = [
[
makeMockPlayer(1, 980),
makeMockPlayer(2, 900),
makeMockPlayer(3, 860),
makeMockPlayer(4, 820),
makeMockPlayer(5, 780),
makeMockPlayer(6, 720),
makeMockPlayer(7, 680),
makeMockPlayer(8, 640),
makeMockPlayer(9, 600),
makeMockPlayer(10, 560),
],
[
makeMockPlayer(4, 1040),
makeMockPlayer(1, 1010),
makeMockPlayer(7, 960),
makeMockPlayer(2, 940),
makeMockPlayer(11, 880),
makeMockPlayer(3, 850),
makeMockPlayer(5, 830),
makeMockPlayer(8, 790),
makeMockPlayer(6, 760),
makeMockPlayer(10, 700),
],
[
makeMockPlayer(7, 1180),
makeMockPlayer(11, 1090),
makeMockPlayer(4, 1060),
makeMockPlayer(12, 1000),
makeMockPlayer(1, 980),
makeMockPlayer(8, 930),
makeMockPlayer(2, 890),
makeMockPlayer(5, 850),
makeMockPlayer(13, 810),
makeMockPlayer(3, 780),
],
];
const updateRankList = (rankList: Player[]) => { 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 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; list.value = next;
rankEffects.value = {};
requestAnimationFrame(() => {
rankEffects.value = effects;
});
}; };
watch( watch(
() => props.rankList, () => props.rankList,
(rankList) => updateRankList(rankList), (rankList) => {
console.log('[game3 Rank1View] websocket rankList 数据:', JSON.parse(JSON.stringify(rankList)));
updateRankList(rankList);
},
{ immediate: true, deep: true }, { immediate: true, deep: true },
); );
const playMockRankAnimation = () => { // ========== 马匹动画循环 (RAF 驱动帧切换 + 位移) ==========
if ($is_run_local()) { let horseRafId: number | undefined;
updateRankList(mockRankRounds[mockRankStep.value] ?? mockRankRounds[0] ?? []); let horseLastTs = 0;
mockRankStep.value = (mockRankStep.value + 1) % mockRankRounds.length; const HORSE_FRAME_TICK_MS = 48;
}
};
const startGame = () => { const startHorseLoop = () => {
if ($is_run_local()) { stopHorseLoop();
emit('start'); horseLastTs = 0;
const tick = (ts: number) => {
if (!isGameRunning.value) {
horseRafId = requestAnimationFrame(tick);
return;
} }
}; if (!horseLastTs) horseLastTs = ts;
const dt = Math.min(ts - horseLastTs, 200);
horseLastTs = ts;
const randomMoleKind = (includeLand = true): MoleKind => { for (const horse of horses.value) {
const kinds: MoleKind[] = includeLand ? ["land", "mole", "rabbit"] : ["mole", "rabbit"]; if (ts < horse.startTime) continue;
return kinds[Math.floor(Math.random() * kinds.length)] ?? "land";
};
const clearMoleTimer = (index: number) => { const elapsed = ts - horse.startTime;
const timer = moleTimers[index]; const progress = Math.min(elapsed / horse.durationMs, 1);
if (timer) { horse.x = HORSE_START_X + (HORSE_END_X - HORSE_START_X) * progress;
clearTimeout(timer);
moleTimers[index] = undefined; const fc = horseFrameCount[horse.horseType] ?? 12;
horse.frameTimer += dt;
while (horse.frameTimer >= HORSE_FRAME_TICK_MS) {
horse.frameTimer -= HORSE_FRAME_TICK_MS;
horse.frameIndex = (horse.frameIndex + 1) % fc;
}
} }
};
const setMoleCell = (index: number, kind: MoleKind, phase: MolePhase) => { horseRafId = requestAnimationFrame(tick);
const next = [...moleCells.value];
const current = next[index];
next[index] = {
kind,
phase,
animationKey: (current?.animationKey ?? 0) + 1,
}; };
moleCells.value = next; horseRafId = requestAnimationFrame(tick);
}; };
const scheduleMoleCell = (index: number, kind = randomMoleKind()) => { const stopHorseLoop = () => {
clearMoleTimer(index); if (horseRafId) {
cancelAnimationFrame(horseRafId);
if (kind === "land") { horseRafId = undefined;
setMoleCell(index, "land", "land");
moleTimers[index] = setTimeout(() => scheduleMoleCell(index, randomMoleKind(false)), MOLE_LAND_DURATION);
return;
} }
setMoleCell(index, kind, "show");
moleTimers[index] = setTimeout(() => {
setMoleCell(index, kind, "hide");
moleTimers[index] = setTimeout(() => scheduleMoleCell(index, "land"), MOLE_HIDE_DURATION);
}, MOLE_SHOW_DURATION);
}; };
const moleCellClass = (cell: MoleCell) => [ // ========== 生成马匹 ==========
`mole-${cell.kind}`, const LANE_TOP_POSITIONS = [380, 530, 680];
`mole-${cell.phase}`,
]; const generateHorses = () => {
const validPlayers = list.value.filter(p => !p.wechat_id?.startsWith('empty-') && p.nickname !== '-');
onMounted(() => { const count = Math.max(validPlayers.length, 3);
moleCells.value.forEach((_, index) => { const newHorses: Horse[] = [];
moleTimers[index] = setTimeout( const now = performance.now();
() => scheduleMoleCell(index, randomMoleKind()),
Math.floor(Math.random() * MOLE_LAND_DURATION), for (let i = 0; i < count; i++) {
); const type = HORSE_TYPES[i % HORSE_TYPES.length]!;
const lane = i % LANE_TOP_POSITIONS.length;
const delayMs = i * 2500 + Math.random() * 1000;
const durationMs = (30 + Math.random() * 30) * 1000;
const player = validPlayers[i] ?? { nickname: `骑手${i + 1}`, avatar: '' };
newHorses.push({
id: `horse-${++horseIdCounter}`,
horseType: type,
lane,
frameIndex: Math.floor(Math.random() * (horseFrameCount[type] ?? 12)),
frameTimer: 0,
startTime: now + delayMs,
durationMs,
x: HORSE_START_X,
nickname: player.nickname,
avatar: player.avatar || '',
}); });
}); }
onBeforeUnmount(() => { horses.value = newHorses;
moleTimers.forEach((_, index) => clearMoleTimer(index)); };
});
// const stopPlayerGu = () => { // ========== 预加载马匹帧 ==========
// if (guTimer) { const preloadHorseFrames = () => {
// clearTimeout(guTimer); for (const frames of Object.values(horseFrames)) {
// guTimer = undefined; for (const url of frames) {
// } const img = new Image();
// if (guBurstTimer) { img.src = url;
// clearTimeout(guBurstTimer); }
// guBurstTimer = undefined; }
// } };
// guBurstVisible.value = false;
// guFrameIndex.value = 0;
// }
// const preloadGuFrames = () => {
// if (!guFramesReady) {
// guFramesReady = Promise.all(guFrames.flat().map((src) => {
// const img = new Image();
// img.src = src;
// return img.decode
// ? img.decode().catch(() => undefined)
// : new Promise<void>((resolve) => {
// img.onload = () => resolve();
// img.onerror = () => resolve();
// });
// })).then(() => undefined);
// }
// return guFramesReady;
// }
// const playGuLoopDelayAnimation = () => {
// if (guBurstTimer) {
// clearTimeout(guBurstTimer);
// guBurstTimer = undefined;
// }
// guBurstVisible.value = false;
// requestAnimationFrame(() => {
// guBurstKey.value += 1;
// guBurstVisible.value = true;
// guBurstTimer = setTimeout(() => {
// guBurstVisible.value = false;
// guBurstTimer = undefined;
// }, GU_LOOP_DELAY);
// });
// }
// const playerGu = async () => {
// stopPlayerGu();
// const frameCount = Math.min(...guFrames.map((frames) => frames.length));
// if (frameCount <= 0) return;
// await preloadGuFrames();
// let frameIndex = 0;
// const tick = () => {
// guFrameIndex.value = frameIndex;
// if (frameIndex >= frameCount - 1) {
// frameIndex = 0;
// playGuLoopDelayAnimation();
// guTimer = setTimeout(tick, GU_LOOP_DELAY);
// return;
// }
// frameIndex += 1;
// guTimer = setTimeout(tick, GU_FRAME_DURATION);
// }
// tick();
// }
// ========== 游戏流程 ==========
const startIntro = async () => { const startIntro = async () => {
// playerGu(); showResult.value = false;
await designStage.value?.startCountdown(START_COUNTDOWN_SECONDS, 60); gameStarted.value = false;
// stopPlayerGu(); isGameRunning.value = false;
horses.value = [];
horseIdCounter = 0;
stopApplauseMusic();
preloadHorseFrames();
const countdownPromise = designStage.value?.startCountdown(3, 60);
await new Promise((r) => setTimeout(r, 3500));
gameStarted.value = true;
isGameRunning.value = true;
generateHorses();
startHorseLoop();
await countdownPromise;
stopGame();
};
const stopGame = () => {
isGameRunning.value = false;
showResult.value = true;
stopHorseLoop();
playApplauseMusic();
console.log('[game3 Rank1View] 游戏结束,显示结果页');
}; };
defineExpose({ defineExpose({
startIntro, startIntro,
}); });
// ========== 头像样式 ==========
const horseAvatarStyle = (horse: Horse) => {
if (horse.avatar) {
return { backgroundImage: `url(${horse.avatar})`, backgroundSize: 'cover' };
}
return { backgroundImage: `url(${imageUrls.defaultAvatar})`, backgroundSize: 'cover' };
};
const renderAvatar = (item: any | null) => { const renderAvatar = (item: any | null) => {
if (item && item.avatar) { if (item && item.avatar) {
return `background: url("${String(item.avatar).replace(/"/g, '\\"')}") center / cover no-repeat;` return `background: url("${String(item.avatar).replace(/"/g, '\\"')}") center / cover no-repeat;`
} }
return `background: ${imageUrls.avatar} center / cover no-repeat;` return `background: ${imageUrls.avatar} center / cover no-repeat;`
} };
const rankIcon = (index: number) => { onMounted(() => {
const icons = [ preloadHorseFrames();
imageUrls.rankNo1, if ($is_run_local()) {
imageUrls.rankNo2, gameStarted.value = true;
imageUrls.rankNo3, isGameRunning.value = true;
]; generateHorses();
let w = 42; startHorseLoop();
let h = 47;
let l = -20;
let t = -20;
if (index <= 2) {
w = 65;
h = 48;
l = -30;
t = -20;
} }
return { });
background: `${icons[index] ?? imageUrls.rankNo4} center / cover no-repeat`,
width: w + 'px', onUnmounted(() => {
height: h + 'px', stopGame();
left: l + 'px', stopApplauseMusic();
top: t + 'px', });
lineHeight: h + 'px',
color: index <= 2 ? 'white' : '#CC7F36',
};
}
</script> </script>
<template> <template>
<DesignStage ref="designStage"> <DesignStage ref="designStage">
<div class="bg"> <div class="screen-container" :class="{ paused: !gameStarted }">
<!-- 背景层:无缝滚动 -->
<div class="bg-track">
<div class="bg-inner"></div>
<div class="bg-inner"></div>
</div>
<!-- Logo & Title -->
<div class="img-logo"></div> <div class="img-logo"></div>
<div class="img-title"></div> <div class="img-title"></div>
<div class="img-mole"></div>
<div class="rank-container"> <!-- 马匹 -->
<div v-for="(item, index) in list" :key="playerKey(item, index)"> <template v-if="gameStarted && !showResult">
<div> <div
<div class="rank-icon" :style="rankIcon(index)">{{ index + 1 }}</div> v-for="horse in horses"
<div class="rank-item"> :key="horse.id"
<div> class="horse-wrapper"
<div class="img-avatar-container"> :style="{
<div class="img-avatar" :style="`${renderAvatar(item)}`"></div> top: `${LANE_TOP_POSITIONS[horse.lane]}px`,
transform: `translateX(${horse.x}px)`,
}"
>
<div class="horse-sprite">
<img
v-for="(url, frameIdx) in horseFrames[horse.horseType]"
:key="frameIdx"
:src="url"
class="horse-frame"
:class="{ active: frameIdx === horse.frameIndex }"
alt=""
/>
</div>
<div class="horse-shadow"></div>
<div class="horse-avatar" :style="horseAvatarStyle(horse)"></div>
<div class="horse-username">
<span class="username-text">{{ $format_str(horse.nickname, 8) }}</span>
</div> </div>
<div class="txt-nickname">{{ $format_str(item.nickname, 12) }}</div>
<div class="txt-score">{{ item.score }}</div>
</div> </div>
</template>
<!-- 结果页(参考 game6 Rank2View) -->
<div v-if="showResult" class="result-overlay">
<div class="result-bg"></div>
<div class="result-logo"></div>
<div class="result-title"></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>
<div class="rank-no2">
<div class="avatar" :style="`${renderAvatar(list[1])}`"></div>
<div class="nickname">{{ list[1]?.nickname }}</div>
</div> </div>
<div class="rank-no3">
<div class="avatar" :style="`${renderAvatar(list[2])}`"></div>
<div class="nickname">{{ list[2]?.nickname }}</div>
</div> </div>
</div> </div>
<div class="mole-container">
<div v-for="(cell, index) in moleCells" :key="index" class="mole-hole" :class="moleCellClass(cell)"> <div class="rank-container">
<div :key="cell.animationKey" class="mole-sprite"></div> <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>
</div> </div>
<div class="btn-next" @click="emit('next')"></div>
<div class="btn-back" @click="emit('back')"></div>
</div>
</div>
</DesignStage> </DesignStage>
</template> </template>
<style scoped> <style scoped>
.bg { .screen-container {
width: 1920px; width: 1920px;
height: 1080px; height: 1080px;
background: v-bind('imageUrls.bg') center / cover no-repeat;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative; position: relative;
overflow: hidden;
}
.screen-container.paused .bg-track {
animation-play-state: paused;
}
/* ========== 背景无缝滚动 ========== */
.bg-track {
width: 3840px;
height: 1080px;
position: absolute;
top: 0;
left: 0;
display: flex;
animation: bg-scroll-left 30s linear infinite;
z-index: 0;
}
.bg-inner {
width: 1920px;
height: 1080px;
background: v-bind('imageUrls.bg') center / cover no-repeat;
flex-shrink: 0;
}
@keyframes bg-scroll-left {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-1920px);
}
} }
.img-logo { .img-logo {
...@@ -405,223 +385,347 @@ const rankIcon = (index: number) => { ...@@ -405,223 +385,347 @@ const rankIcon = (index: number) => {
position: absolute; position: absolute;
left: 37px; left: 37px;
top: 82px; top: 82px;
z-index: 20;
} }
.img-title { .img-title {
background: v-bind('imageUrls.title') center / cover no-repeat; background: v-bind('imageUrls.title') center / cover no-repeat;
width: 675px; width: 544px;
height: 281px; height: 208px;
margin: 0 auto; position: absolute;
margin-top: 37px; left: 679px;
top: 52px;
z-index: 20;
} }
.img-mole { /* ========== 马匹 ========== */
background: v-bind('imageUrls.mole2') center / cover no-repeat; .horse-wrapper {
width: 189px;
height: 228px;
position: absolute; position: absolute;
left: 144px; left: 0;
top: 141px; width: 280px;
height: 280px;
z-index: 15;
} }
.rank-container { .horse-sprite {
background: v-bind('imageUrls.rankContainer') center / cover no-repeat; position: relative;
display: grid; width: 280px;
grid-template-columns: repeat(10, minmax(0, 1fr)); height: 280px;
align-items: center; }
column-gap: 42px;
box-sizing: border-box;
padding: 30px;
width: 1832px;
height: 244px;
position: absolute;
left: 44px;
top: 348px;
>div { .horse-frame {
box-sizing: border-box; position: absolute;
text-align: center; inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain;
opacity: 0;
pointer-events: none;
z-index: 1;
}
>div { .horse-frame.active {
box-sizing: border-box; opacity: 1;
width: 100%; }
height: 100%;
background: #A10A06;
border-radius: 5px;
position: relative;
.rank-icon { .horse-shadow {
position: absolute; position: absolute;
color: #CC7F36; bottom: -4px;
font-weight: bold; left: 50%;
} transform: translateX(-50%);
width: 180px;
.rank-icon:nth-child(1), height: 24px;
.rank-icon:nth-child(2), background: radial-gradient(ellipse, rgba(0, 0, 0, 0.4) 0%, transparent 70%);
.rank-icon:nth-child(3) { border-radius: 50%;
color: white; pointer-events: none;
} z-index: 0;
} }
}
/* ========== 马背用户信息 ========== */
.horse-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
position: absolute;
top: 40px;
left: 10px;
border: 2px solid #FFCF44;
z-index: 2;
}
.img-avatar-container { .horse-username {
position: relative; background: v-bind('imageUrls.username');
margin: 0 auto; background-size: contain;
width: 90px; background-repeat: no-repeat;
height: 90px; width: 120px;
border-radius: 36px; height: 44px;
/* transform: scale(1.3); */ position: absolute;
top: 18px top: 92px;
} left: 0px;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.img-avatar-container::before { .username-text {
content: "";
position: absolute; position: absolute;
inset: 0; top: 2px;
border-radius: inherit; color: #000;
background: v-bind('imageUrls.avatarAnimation'); font-size: 16px;
animation: avatar-rotate 10s linear infinite; font-weight: bold;
} max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-left: 4px;
box-sizing: border-box;
}
.img-avatar { /* ========== 结果页(参考 game6 Rank2View) ========== */
width: 80px; .result-overlay {
height: 80px; width: 1920px;
border-radius: 40px; height: 1080px;
position: absolute; position: absolute;
left: 6px; top: 0;
top: 5px; left: 0;
} z-index: 100;
}
.txt-nickname { .result-bg {
width: 1920px;
height: 1080px;
background: v-bind('imageUrls.resultBg') center / cover no-repeat;
position: absolute; position: absolute;
bottom: 40px; top: 0;
text-align: center; left: 0;
width: 100%; }
color: #F9EBCB;
}
.txt-score { .result-logo {
background: v-bind('imageUrls.logo');
background-size: cover;
width: 428px;
height: 77px;
position: absolute; position: absolute;
bottom: 5px; left: 37px;
text-align: center; top: 82px;
width: 100%; z-index: 1;
color: #F9EBCB;
}
} }
.mole-container { .result-title {
background: v-bind('imageUrls.resultTitle') center / cover no-repeat;
width: 831px;
height: 120px;
position: absolute; position: absolute;
top: 200px; left: 545px;
/* inset: 0; */ top: 64px;
pointer-events: none; z-index: 1;
}
.mole-hole { .podium {
background: v-bind('imageUrls.podium') no-repeat center;
width: 1920px;
height: 740px;
position: absolute; position: absolute;
width: 500px; bottom: 0;
height: 500px; left: 0;
transform: translate(-125px, -125px); z-index: 1;
overflow: hidden;
.mole-sprite { .img-vip {
width: 500px; width: 969px;
height: 500px; height: 238px;
background-repeat: no-repeat; position: absolute;
background-position: 0 0; left: 480px;
background-size: auto 500px; top: -60px;
background: v-bind('imageUrls.vip') center / cover no-repeat;
} }
&.mole-land .mole-sprite { .rank-no1 {
background: v-bind('imageUrls.land') center / cover no-repeat; position: relative;
}
&.mole-mole.mole-show .mole-sprite { .avatar {
background-image: v-bind('imageUrls.moleShow'); width: 160px;
animation: mole-show 1.2s steps(30) both; height: 160px;
border-radius: 80px;
position: absolute;
left: 884px;
top: -7px;
} }
&.mole-mole.mole-hide .mole-sprite { .nickname {
background-image: v-bind('imageUrls.moleHide'); width: 220px;
animation: mole-hide 0.65s steps(15) both; 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;
} }
&.mole-rabbit.mole-show .mole-sprite {
background-image: v-bind('imageUrls.rabbitShow');
animation: mole-show 1.2s steps(30) both;
} }
&.mole-rabbit.mole-hide .mole-sprite { .rank-no2 {
background-image: v-bind('imageUrls.rabbitHide'); position: relative;
animation: mole-hide 0.65s steps(15) both;
}
&:nth-child(1) { .avatar {
left: 247px; width: 144px;
top: 312px; height: 144px;
border-radius: 72px;
position: absolute;
left: 488px;
top: 27px;
} }
&:nth-child(2) { .nickname {
left: 547px; width: 220px;
top: 300px; 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;
}
} }
&:nth-child(3) { .rank-no3 {
left: 847px; position: relative;
top: 312px;
.avatar {
width: 144px;
height: 144px;
border-radius: 72px;
position: absolute;
left: 1297px;
top: 26px;
} }
&:nth-child(4) { .nickname {
left: 1147px; width: 220px;
top: 300px; 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;
z-index: 2;
> div {
flex: 1;
display: grid;
width: 100px;
height: 100%;
&:nth-child(5) { .no-icon {
left: 1447px; background: v-bind('imageUrls.rankIcon') no-repeat center;
top: 312px; width: 42px;
height: 47px;
position: absolute;
text-align: center;
line-height: 42px;
color: #CC7F36;
font-weight: bold;
top: 50px;
} }
&:nth-child(6) { .img-avatar-container {
left: 397px; position: relative;
top: 480px; margin: 0 auto;
margin-top: 10px;
width: 72px;
height: 72px;
border-radius: 36px;
transform: scale(1.6);
top: 50px;
} }
&:nth-child(7) { .img-avatar-container::before {
left: 697px; content: "";
top: 460px; position: absolute;
inset: 0;
border-radius: inherit;
background: v-bind('imageUrls.avatarAnimation') center / cover no-repeat;
animation: avatar-rotate 10s linear infinite;
} }
&:nth-child(8) { .img-avatar {
left: 997px; position: relative;
top: 460px; top: 6px;
z-index: 1;
margin: 0 auto;
width: 60px;
height: 60px;
border-radius: 30px;
background: v-bind('imageUrls.avatar') center / cover no-repeat;
} }
&:nth-child(9) { .txt-nickname {
left: 1297px; color: #F9EBCB;
top: 480px; font-size: 22px;
text-align: center;
position: absolute;
top: 170px;
margin-left: 68px;
} }
} }
} }
@keyframes mole-show { .btn-next {
from { position: absolute;
background-position-x: 0; bottom: 50px;
} left: 620px;
width: 316px;
height: 105px;
cursor: pointer;
background: v-bind('imageUrls.next') center / cover no-repeat;
z-index: 2;
}
to { .btn-back {
background-position-x: -15000px; position: absolute;
} bottom: 50px;
left: 1020px;
width: 316px;
height: 105px;
cursor: pointer;
background: v-bind('imageUrls.back') center / cover no-repeat;
z-index: 2;
} }
@keyframes mole-hide { @keyframes avatar-rotate {
from { 0% {
background-position-x: 0; transform: rotate(0deg);
} }
100% {
to { transform: rotate(360deg);
background-position-x: -7500px;
} }
} }
</style> </style>
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