Commit 7c0c20c7 authored by 董政锦's avatar 董政锦

Merge branch 'game5-0606' into 'main'

Game5 0606 See merge request !8
parents 4096eb60 b480ffa9
...@@ -166,7 +166,7 @@ function handleRoomBack() { ...@@ -166,7 +166,7 @@ function handleRoomBack() {
} }
const touchHandler = () => { const touchHandler = () => {
tick.value += 1 tick.value += 5
submitScore() submitScore()
} }
...@@ -232,11 +232,12 @@ onBeforeUnmount(() => { ...@@ -232,11 +232,12 @@ onBeforeUnmount(() => {
<div class="rule-title">游戏规则</div> <div class="rule-title">游戏规则</div>
<div class="rule-txt"> <div class="rule-txt">
<!-- 微信用户通过扫描大屏幕二维码参与,可容纳多人同时进行,摇晃手机控制赛马奔跑,摇晃越快赛马跑得越快,游戏结束按分值高低先后排名,有机会获得新疆福彩定制周边礼品一份。 --> <!-- 微信用户通过扫描大屏幕二维码参与,可容纳多人同时进行,摇晃手机控制赛马奔跑,摇晃越快赛马跑得越快,游戏结束按分值高低先后排名,有机会获得新疆福彩定制周边礼品一份。 -->
1. 主持人开赛即可摇手机,单次摇晃得 5 分; 1. 主持人开赛即可摇手机,单次摇晃得 5 分;<br />
2. 得分越高大屏幕上面的马跑得越快; 2. 得分越高大屏幕上面的马跑得越快;<br />
3. 限时比拼摇速,结束后按积分排名; 3. 限时比拼摇速,结束后按积分排名;<br />
4. 高分用户有机会领取新疆福彩定制周边; 4. 高分用户有机会领取新疆福彩定制周边;<br />
5. 禁止外挂作弊,违规账号取消成绩与领奖资格; 5. 禁止外挂作弊,违规账号取消成绩与领奖资格;<br /><br />
祝您游戏愉快!
</div> </div>
</div> </div>
<div class="rule-close"> <div class="rule-close">
...@@ -246,7 +247,8 @@ onBeforeUnmount(() => { ...@@ -246,7 +247,8 @@ onBeforeUnmount(() => {
</template> </template>
<LoadingView v-if="currentView === 'loading'" :image-urls="imageUrls" :user-join-status="userJoinStatus" <LoadingView v-if="currentView === 'loading'" :image-urls="imageUrls" :user-join-status="userJoinStatus"
@touchGameRule="showGameRuleHandler" /> @touchGameRule="showGameRuleHandler" />
<PlayingView v-else-if="currentView === 'playing'" :image-urls="imageUrls" :tick="tick" :rank="rank" <!-- <PlayingView v-else :image-urls="imageUrls" :tick="tick" :rank="rank" -->
<PlayingView v-else-if="currentView === 'playing'" :image-urls="imageUrls" :tick="tick" :rank="rank"
:currentHorse="currentHorse" :currentYaoyiyao="currentYaoyiyao" :countdown-interval="countdownInterval" :currentHorse="currentHorse" :currentYaoyiyao="currentYaoyiyao" :countdown-interval="countdownInterval"
:is-div-desc-visible="isDivDescVisible" @touch="touchHandler" /> :is-div-desc-visible="isDivDescVisible" @touch="touchHandler" />
<ScoreView v-else :image-urls="imageUrls" :tick="tick" :level="level" @replay="replayGame" <ScoreView v-else :image-urls="imageUrls" :tick="tick" :level="level" @replay="replayGame"
......
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue' import { onBeforeUnmount, onMounted, ref } from 'vue'
import shakeSound from '@/assets/images/game4/yaoyiyao.mp3' import shakeSound from '@/assets/images/game4/yaoyiyao.mp3'
const props = defineProps<{ const props = defineProps<{
...@@ -16,6 +16,10 @@ const emit = defineEmits<{ ...@@ -16,6 +16,10 @@ const emit = defineEmits<{
touch: [] touch: []
}>() }>()
// ========== iOS 设备检测 ==========
const isIOS = typeof (DeviceMotionEvent as any).requestPermission === 'function'
const showIOSHint = ref(isIOS)
// ========== 摇一摇 ========== // ========== 摇一摇 ==========
const shakeCooldown = 300 const shakeCooldown = 300
let lastShakeTime = 0 let lastShakeTime = 0
...@@ -33,6 +37,37 @@ let wasShaking = false ...@@ -33,6 +37,37 @@ let wasShaking = false
let audioContext: AudioContext | null = null let audioContext: AudioContext | null = null
let shakeAudioBuffer: AudioBuffer | null = null let shakeAudioBuffer: AudioBuffer | null = null
// iOS 13+/iPadOS 需要主动请求 DeviceMotion 权限
let motionPermissionGranted = false
// 摇一摇图片抖动动画状态
const isYaoyiyaoShaking = ref(false)
let yaoyiyaoShakeTimer: ReturnType<typeof setTimeout> | null = null
const requestMotionPermission = async (): Promise<boolean> => {
// 检查是否需要权限请求(iOS 12.2+)
if (typeof (DeviceMotionEvent as any).requestPermission === 'function') {
try {
const state = await (DeviceMotionEvent as any).requestPermission()
if (state === 'granted') {
console.log('[摇一摇] iOS DeviceMotion 权限已授予')
motionPermissionGranted = true
// 权限拿到后再注册监听
window.addEventListener('devicemotion', handleDeviceMotion)
return true
}
console.warn('[摇一摇] iOS DeviceMotion 权限被拒绝:', state)
return false
} catch (err) {
console.error('[摇一摇] iOS 权限请求失败:', err)
return false
}
}
// Android / 旧版 iOS:无需额外权限
motionPermissionGranted = true
return true
}
const initAudio = async () => { const initAudio = async () => {
try { try {
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)() audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
...@@ -82,6 +117,13 @@ const handleDeviceMotion = (ev: DeviceMotionEvent) => { ...@@ -82,6 +117,13 @@ const handleDeviceMotion = (ev: DeviceMotionEvent) => {
if (now - lastShakeTime < shakeCooldown) return if (now - lastShakeTime < shakeCooldown) return
lastShakeTime = now lastShakeTime = now
// 摇一摇图片抖动动画
isYaoyiyaoShaking.value = true
if (yaoyiyaoShakeTimer) clearTimeout(yaoyiyaoShakeTimer)
yaoyiyaoShakeTimer = setTimeout(() => {
isYaoyiyaoShaking.value = false
}, 320)
playShakeSound() playShakeSound()
// 摇晃时打印日志:输出将要通过 websocket 提交的数据 // 摇晃时打印日志:输出将要通过 websocket 提交的数据
...@@ -105,8 +147,6 @@ const handleDeviceMotion = (ev: DeviceMotionEvent) => { ...@@ -105,8 +147,6 @@ const handleDeviceMotion = (ev: DeviceMotionEvent) => {
} }
} }
window.addEventListener('devicemotion', handleDeviceMotion)
// ========== 生命周期 ========== // ========== 生命周期 ==========
onMounted(async () => { onMounted(async () => {
...@@ -115,6 +155,15 @@ onMounted(async () => { ...@@ -115,6 +155,15 @@ onMounted(async () => {
} catch (error) { } catch (error) {
console.error('初始化失败:', error) console.error('初始化失败:', error)
} }
// 非 iOS 设备直接注册监听;iOS 需等用户点击后通过 requestMotionPermission 注册
if (typeof (DeviceMotionEvent as any).requestPermission !== 'function') {
window.addEventListener('devicemotion', handleDeviceMotion)
motionPermissionGranted = true
console.log('[摇一摇] DeviceMotion 监听已启动(Android/旧版iOS)')
} else {
console.log('[摇一摇] 等待用户点击以请求 iOS DeviceMotion 权限…')
}
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
...@@ -122,7 +171,20 @@ onBeforeUnmount(() => { ...@@ -122,7 +171,20 @@ onBeforeUnmount(() => {
if (audioContext) { if (audioContext) {
audioContext.close() audioContext.close()
} }
if (yaoyiyaoShakeTimer) {
clearTimeout(yaoyiyaoShakeTimer)
}
}) })
// 点击马触发:iOS 先请求权限 + 触发射分
const onTapHorse = async () => {
if (!motionPermissionGranted) {
await requestMotionPermission()
// 权限请求完成(无论结果)隐藏 iOS 提示
showIOSHint.value = false
}
emit('touch')
}
</script> </script>
<template> <template>
...@@ -141,9 +203,19 @@ onBeforeUnmount(() => { ...@@ -141,9 +203,19 @@ onBeforeUnmount(() => {
<div>{{ tick }}</div> <div>{{ tick }}</div>
<div>当前排名: &nbsp;&nbsp;<label>{{ rank }}</label>&nbsp;</div> <div>当前排名: &nbsp;&nbsp;<label>{{ rank }}</label>&nbsp;</div>
</div> </div>
<!-- iOS 权限提示蒙层 -->
<Transition name="ios-hint-fade">
<div v-if="showIOSHint" class="ios-hint-overlay" @click="onTapHorse">
<div class="ios-hint-content">
<div class="ios-hint-title">👇点击下方赛马</div>
<div class="ios-hint-desc">启用摇一摇功能开始游戏</div>
<div class="ios-hint-arrow"></div>
</div>
</div>
</Transition>
<!-- <div class="bg-bottom"></div> --> <!-- <div class="bg-bottom"></div> -->
<div class="bg-yaoyiyao" :style="{ background: `${currentYaoyiyao} center/cover` }"></div> <div class="bg-yaoyiyao" :class="{ 'is-shaking': isYaoyiyaoShaking }" :style="{ background: `${currentYaoyiyao} center/cover` }"></div>
<div class="play-horse" :style="{ background: `${currentHorse} center/cover` }" @click="$emit('touch')"></div> <div class="play-horse" :class="{ 'horse-clickable': showIOSHint }" :style="{ background: `${currentHorse} center/cover` }" @click="onTapHorse"></div>
</div> </div>
</template> </template>
...@@ -297,14 +369,98 @@ onBeforeUnmount(() => { ...@@ -297,14 +369,98 @@ onBeforeUnmount(() => {
width: 514.51px; width: 514.51px;
height: 229px; height: 229px;
transform: translateX(-50%); transform: translateX(-50%);
transition: transform 0.08s ease-out;
}
/* 摇一摇图片抖动动画 */
.bg-yaoyiyao.is-shaking {
animation: yaoyiyao-shake 0.32s ease-in-out;
}
@keyframes yaoyiyao-shake {
0% { transform: translateX(-50%) rotate(0deg); }
15% { transform: translateX(-50%) rotate(-8deg); }
30% { transform: translateX(-50%) rotate(8deg); }
45% { transform: translateX(-50%) rotate(-6deg); }
60% { transform: translateX(-50%) rotate(6deg); }
75% { transform: translateX(-50%) rotate(-3deg); }
90% { transform: translateX(-50%) rotate(3deg); }
100% { transform: translateX(-50%) rotate(0deg); }
} }
.play-horse { .play-horse {
position: absolute; position: absolute;
bottom: 164px; bottom: 164px;
left: 50%; left: 50%;
width: 597px; width: 597px;
height: 679px; height: 679px;
/* transform: translateX(-50%) scale(2.4); */
transform: translateX(-50%); transform: translateX(-50%);
} }
/* 马匹点击引导动画 - 脉冲呼吸效果 */
.horse-clickable {
animation: horse-pulse 1.2s ease-in-out infinite;
}
@keyframes horse-pulse {
0%, 100% { filter: brightness(1) drop-shadow(0 0 8px rgba(255, 215, 0, 0)); }
50% { filter: brightness(1.12) drop-shadow(0 0 24px rgba(255, 215, 0, 0.6)); }
}
/* ===== iOS 权限提示蒙层 ===== */
.ios-hint-overlay {
position: absolute;
top: 0;
bottom: 0;
left: var(--stage-viewport-left, 0);
width: var(--stage-viewport-width, 750px);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.ios-hint-content {
text-align: center;
color: #fff;
animation: ios-hint-bounce 1.6s ease-in-out infinite;
}
.ios-hint-title {
font-size: 36pt;
font-weight: bold;
margin-bottom: 16px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
}
.ios-hint-desc {
font-size: 22pt;
opacity: 0.85;
margin-bottom: 20px;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
}
.ios-hint-arrow {
font-size: 40pt;
animation: ios-arrow-bounce 0.8s ease-in-out infinite alternate;
}
@keyframes ios-hint-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
@keyframes ios-arrow-bounce {
from { transform: translateY(0); opacity: 1; }
to { transform: translateY(16px); opacity: 0.3; }
}
.ios-hint-fade-enter-active {
transition: opacity 0.4s ease;
}
.ios-hint-fade-leave-active {
transition: opacity 0.3s ease;
}
.ios-hint-fade-enter-from,
.ios-hint-fade-leave-to {
opacity: 0;
}
</style> </style>
...@@ -105,16 +105,20 @@ function submitScore(save: boolean = false) { ...@@ -105,16 +105,20 @@ function submitScore(save: boolean = false) {
if (save) { if (save) {
//item_num 游戏项目(1到6) //item_num 游戏项目(1到6)
//wechat 原始token //wechat 原始token
sendGameMessage('submit_score_save', { const data = {
score: tick.value, score: tick.value,
wechat: wechat.token_origin, wechat: wechat.token_origin,
item_num: 1, item_num: 1,
nickname: wechat.nickname, nickname: wechat.nickname,
avatar: wechat.avatar, avatar: wechat.avatar,
rank: rank.value, rank: rank.value,
}) }
console.log('[WebSocket] send submit_score_save:', JSON.stringify(data, null, 2))
sendGameMessage('submit_score_save', data)
} else { } else {
sendGameMessage('submit_score', { score: tick.value }) const data = { score: tick.value }
console.log('[WebSocket] send submit_score:', JSON.stringify(data))
sendGameMessage('submit_score', data)
} }
} }
...@@ -191,34 +195,11 @@ function handleRoomBack() { ...@@ -191,34 +195,11 @@ function handleRoomBack() {
}, 200) }, 200)
} }
const touchHandler = () => { const hitHandler = (score: number) => {
if (isGuPlaying.value) { tick.value += score
return console.log('[WebSocket] 圈中马!得分:', score, '当前总分:', tick.value)
}
tick.value += 5
submitScore() submitScore()
playGame1Music(); playGame1Music()
isGuPlaying.value = true
let frameIndex = 0
const playNextFrame = () => {
currentGu.value = guFrames[frameIndex] ?? imageUrls.gu01
frameIndex += 1
if (frameIndex >= guFrames.length) {
guFrameTimer = window.setTimeout(() => {
currentGu.value = guFrames[0]
isGuPlaying.value = false
guFrameTimer = undefined
}, 200)
return
}
guFrameTimer = window.setTimeout(playNextFrame, 200)
}
playNextFrame()
} }
if (wechat) { if (wechat) {
...@@ -350,10 +331,10 @@ const rankCloseHandler = () => { ...@@ -350,10 +331,10 @@ const rankCloseHandler = () => {
</template> </template>
<LoadingView v-if="currentView === 'loading'" :image-urls="imageUrls" :user-join-status="userJoinStatus" <LoadingView v-if="currentView === 'loading'" :image-urls="imageUrls" :user-join-status="userJoinStatus"
@touchGameRule="showGameRuleHandler" /> @touchGameRule="showGameRuleHandler" />
<PlayingView v-else-if="currentView === 'playing'" :image-urls="imageUrls" :tick="tick" :rank="rank" <!-- <PlayingView v-else :image-urls="imageUrls" :tick="tick" :rank="rank" -->
<PlayingView v-else-if="currentView === 'playing'" :image-urls="imageUrls" :tick="tick" :rank="rank"
:current-gu="currentGu" :countdown-interval="countdownInterval" :is-div-desc-visible="isDivDescVisible" :current-gu="currentGu" :countdown-interval="countdownInterval" :is-div-desc-visible="isDivDescVisible"
@touch="touchHandler" /> @hit="hitHandler" />
<!-- <PlayingView v-else :image-urls="imageUrls" :tick="tick" :rank="rank" -->
<!-- <ScoreView v-else :image-urls="imageUrls" :rank="rank" :tick="tick" /> --> <!-- <ScoreView v-else :image-urls="imageUrls" :rank="rank" :tick="tick" /> -->
</MobileStage> </MobileStage>
<div v-else style="text-align: center; width: 100vw; height: 100vh; line-height: 30; font-size: 20px;"> <div v-else style="text-align: center; width: 100vw; height: 100vh; line-height: 30; font-size: 20px;">
......
...@@ -32,6 +32,13 @@ const imageUrls = { ...@@ -32,6 +32,13 @@ const imageUrls = {
const isStartPressed = ref(false); const isStartPressed = ref(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;`
}
const pressStartButton = () => { const pressStartButton = () => {
isStartPressed.value = true; isStartPressed.value = true;
emit("start"); emit("start");
...@@ -66,7 +73,7 @@ const releaseStartButton = () => { ...@@ -66,7 +73,7 @@ const releaseStartButton = () => {
> >
<div> <div>
<div class="img-avatar-container"> <div class="img-avatar-container">
<div class="img-avatar" :style="`background:url('${item.avatar}') center / cover no-repeat;`"></div> <div class="img-avatar" :style="`${renderAvatar(item)}`"></div>
</div> </div>
<div class="txt-nickname"> <div class="txt-nickname">
{{ $format_str(item.nickname, 12) }} {{ $format_str(item.nickname, 12) }}
...@@ -235,7 +242,6 @@ const releaseStartButton = () => { ...@@ -235,7 +242,6 @@ const releaseStartButton = () => {
width: 60px; width: 60px;
height: 60px; height: 60px;
border-radius: 30px; border-radius: 30px;
background: v-bind("imageUrls.avatar");
} }
.txt-nickname { .txt-nickname {
......
...@@ -46,7 +46,9 @@ const confirmRefresh = (event: BeforeUnloadEvent) => { ...@@ -46,7 +46,9 @@ const confirmRefresh = (event: BeforeUnloadEvent) => {
const renderPlayers = (data: any) => { const renderPlayers = (data: any) => {
const players = Array.isArray(data?.list) ? data.list : [] const players = Array.isArray(data?.list) ? data.list : []
playerCount.value = Number(data?.count ?? players.length) const count = Number(data?.count ?? players.length)
console.log('[WebSocket] 玩家列表更新 - 在线人数:', count, '玩家数:', players.length, '数据:', JSON.stringify(players.map((p: any) => ({ nickname: p.nickname, userid: p.userid ?? p.wechat_id }))))
playerCount.value = count
loadingPlayers.value = Array.from( loadingPlayers.value = Array.from(
{ length: PLAYER_LIMIT }, { length: PLAYER_LIMIT },
(_, index) => players[index] ?? emptyLoadingPlayer(), (_, index) => players[index] ?? emptyLoadingPlayer(),
...@@ -114,6 +116,7 @@ const gotoRank1WithIntro = async () => { ...@@ -114,6 +116,7 @@ const gotoRank1WithIntro = async () => {
} }
function handleRoomState(data: any) { function handleRoomState(data: any) {
console.log('[WebSocket] 房间状态更新 - status:', data?.status, '数据:', JSON.stringify(data))
if (Array.isArray(data?.list)) { if (Array.isArray(data?.list)) {
renderPlayers(data) renderPlayers(data)
} }
...@@ -140,11 +143,18 @@ const { startGame, createRoom, backRoom } = useAdminGameSocket({ ...@@ -140,11 +143,18 @@ const { startGame, createRoom, backRoom } = useAdminGameSocket({
gameId: 'game5', gameId: 'game5',
onRoomState: handleRoomState, onRoomState: handleRoomState,
onRoomJoin: (data) => { onRoomJoin: (data) => {
console.log('[WebSocket] 玩家加入房间 - 数据:', JSON.stringify(data))
renderPlayers(data) renderPlayers(data)
gotoLoading() gotoLoading()
}, },
onGameStart: gotoRank1WithIntro, onGameStart: () => {
onRoomRank: updateRankPlayers, console.log('[WebSocket] 游戏开始!')
gotoRank1WithIntro()
},
onRoomRank: (data) => {
console.log('[WebSocket] 排名更新 - 数据:', JSON.stringify(data))
updateRankPlayers(data)
},
onRelogin: async () => { onRelogin: async () => {
$remove_socket_storage(); $remove_socket_storage();
await router.replace('/') await router.replace('/')
......
...@@ -107,6 +107,7 @@ watch( ...@@ -107,6 +107,7 @@ watch(
() => props.rankList, () => props.rankList,
(rankList) => { (rankList) => {
if (rankList && rankList.length > 0) { if (rankList && rankList.length > 0) {
console.log('[Rank1View] 排名列表更新 - 数据:', JSON.stringify(rankList.map(p => ({ nickname: p.nickname, score: p.score }))))
updateRankList(rankList); updateRankList(rankList);
} }
}, },
...@@ -121,11 +122,13 @@ const startIntro = async () => { ...@@ -121,11 +122,13 @@ const startIntro = async () => {
horses.value = []; horses.value = [];
horseIdCounter = 0; horseIdCounter = 0;
console.log('[Rank1View] 游戏流程启动 - 开始 3s 倒计时 + 60s 游戏')
// 启动完整倒计时:3s 开场 + 60s 游戏 // 启动完整倒计时:3s 开场 + 60s 游戏
const countdownPromise = designStage.value?.startCountdown(3, 60); const countdownPromise = designStage.value?.startCountdown(3, 60);
// 等待 3-2-1 开场倒计时结束(~3.5s 后安全触发) // 等待 3-2-1 开场倒计时结束(~3.5s 后安全触发)
await new Promise((r) => setTimeout(r, 3500)); await new Promise((r) => setTimeout(r, 3500));
console.log('[Rank1View] 倒计时结束,马匹开始奔跑')
// Phase 2: 倒计时结束 → 游戏正式开始,背景动画+马匹启动 // Phase 2: 倒计时结束 → 游戏正式开始,背景动画+马匹启动
gameStarted.value = true; gameStarted.value = true;
...@@ -139,6 +142,7 @@ const startIntro = async () => { ...@@ -139,6 +142,7 @@ const startIntro = async () => {
}; };
const stopGame = () => { const stopGame = () => {
console.log('[Rank1View] 游戏结束 - 60s 倒计时到')
isGameRunning.value = false; isGameRunning.value = false;
showResult.value = true; showResult.value = true;
stopHorseLoop(); stopHorseLoop();
......
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