Commit c1dbe1ba authored by 陈冲's avatar 陈冲
parents f4049020 7c0c20c7
......@@ -166,7 +166,7 @@ function handleRoomBack() {
}
const touchHandler = () => {
tick.value += 1
tick.value += 5
submitScore()
}
......@@ -232,11 +232,12 @@ onBeforeUnmount(() => {
<div class="rule-title">游戏规则</div>
<div class="rule-txt">
<!-- 微信用户通过扫描大屏幕二维码参与,可容纳多人同时进行,摇晃手机控制赛马奔跑,摇晃越快赛马跑得越快,游戏结束按分值高低先后排名,有机会获得新疆福彩定制周边礼品一份。 -->
1. 主持人开赛即可摇手机,单次摇晃得 5 分;
2. 得分越高大屏幕上面的马跑得越快;
3. 限时比拼摇速,结束后按积分排名;
4. 高分用户有机会领取新疆福彩定制周边;
5. 禁止外挂作弊,违规账号取消成绩与领奖资格;
1. 主持人开赛即可摇手机,单次摇晃得 5 分;<br />
2. 得分越高大屏幕上面的马跑得越快;<br />
3. 限时比拼摇速,结束后按积分排名;<br />
4. 高分用户有机会领取新疆福彩定制周边;<br />
5. 禁止外挂作弊,违规账号取消成绩与领奖资格;<br /><br />
祝您游戏愉快!
</div>
</div>
<div class="rule-close">
......@@ -246,7 +247,8 @@ onBeforeUnmount(() => {
</template>
<LoadingView v-if="currentView === 'loading'" :image-urls="imageUrls" :user-join-status="userJoinStatus"
@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"
:is-div-desc-visible="isDivDescVisible" @touch="touchHandler" />
<ScoreView v-else :image-urls="imageUrls" :tick="tick" :level="level" @replay="replayGame"
......
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from 'vue'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import shakeSound from '@/assets/images/game4/yaoyiyao.mp3'
const props = defineProps<{
......@@ -16,6 +16,10 @@ const emit = defineEmits<{
touch: []
}>()
// ========== iOS 设备检测 ==========
const isIOS = typeof (DeviceMotionEvent as any).requestPermission === 'function'
const showIOSHint = ref(isIOS)
// ========== 摇一摇 ==========
const shakeCooldown = 300
let lastShakeTime = 0
......@@ -33,6 +37,37 @@ let wasShaking = false
let audioContext: AudioContext | 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 () => {
try {
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
......@@ -82,6 +117,13 @@ const handleDeviceMotion = (ev: DeviceMotionEvent) => {
if (now - lastShakeTime < shakeCooldown) return
lastShakeTime = now
// 摇一摇图片抖动动画
isYaoyiyaoShaking.value = true
if (yaoyiyaoShakeTimer) clearTimeout(yaoyiyaoShakeTimer)
yaoyiyaoShakeTimer = setTimeout(() => {
isYaoyiyaoShaking.value = false
}, 320)
playShakeSound()
// 摇晃时打印日志:输出将要通过 websocket 提交的数据
......@@ -105,8 +147,6 @@ const handleDeviceMotion = (ev: DeviceMotionEvent) => {
}
}
window.addEventListener('devicemotion', handleDeviceMotion)
// ========== 生命周期 ==========
onMounted(async () => {
......@@ -115,6 +155,15 @@ onMounted(async () => {
} catch (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(() => {
......@@ -122,7 +171,20 @@ onBeforeUnmount(() => {
if (audioContext) {
audioContext.close()
}
if (yaoyiyaoShakeTimer) {
clearTimeout(yaoyiyaoShakeTimer)
}
})
// 点击马触发:iOS 先请求权限 + 触发射分
const onTapHorse = async () => {
if (!motionPermissionGranted) {
await requestMotionPermission()
// 权限请求完成(无论结果)隐藏 iOS 提示
showIOSHint.value = false
}
emit('touch')
}
</script>
<template>
......@@ -141,9 +203,19 @@ onBeforeUnmount(() => {
<div>{{ tick }}</div>
<div>当前排名: &nbsp;&nbsp;<label>{{ rank }}</label>&nbsp;</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-yaoyiyao" :style="{ background: `${currentYaoyiyao} center/cover` }"></div>
<div class="play-horse" :style="{ background: `${currentHorse} center/cover` }" @click="$emit('touch')"></div>
<div class="bg-yaoyiyao" :class="{ 'is-shaking': isYaoyiyaoShaking }" :style="{ background: `${currentYaoyiyao} center/cover` }"></div>
<div class="play-horse" :class="{ 'horse-clickable': showIOSHint }" :style="{ background: `${currentHorse} center/cover` }" @click="onTapHorse"></div>
</div>
</template>
......@@ -297,14 +369,98 @@ onBeforeUnmount(() => {
width: 514.51px;
height: 229px;
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 {
position: absolute;
bottom: 164px;
left: 50%;
width: 597px;
height: 679px;
/* transform: translateX(-50%) scale(2.4); */
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>
......@@ -105,16 +105,20 @@ function submitScore(save: boolean = false) {
if (save) {
//item_num 游戏项目(1到6)
//wechat 原始token
sendGameMessage('submit_score_save', {
const data = {
score: tick.value,
wechat: wechat.token_origin,
item_num: 1,
nickname: wechat.nickname,
avatar: wechat.avatar,
rank: rank.value,
})
}
console.log('[WebSocket] send submit_score_save:', JSON.stringify(data, null, 2))
sendGameMessage('submit_score_save', data)
} 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() {
}, 200)
}
const touchHandler = () => {
if (isGuPlaying.value) {
return
}
tick.value += 5
const hitHandler = (score: number) => {
tick.value += score
console.log('[WebSocket] 圈中马!得分:', score, '当前总分:', tick.value)
submitScore()
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()
playGame1Music()
}
if (wechat) {
......@@ -350,10 +331,10 @@ const rankCloseHandler = () => {
</template>
<LoadingView v-if="currentView === 'loading'" :image-urls="imageUrls" :user-join-status="userJoinStatus"
@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"
@touch="touchHandler" />
<!-- <PlayingView v-else :image-urls="imageUrls" :tick="tick" :rank="rank" -->
@hit="hitHandler" />
<!-- <ScoreView v-else :image-urls="imageUrls" :rank="rank" :tick="tick" /> -->
</MobileStage>
<div v-else style="text-align: center; width: 100vw; height: 100vh; line-height: 30; font-size: 20px;">
......
......@@ -32,6 +32,13 @@ const imageUrls = {
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 = () => {
isStartPressed.value = true;
emit("start");
......@@ -66,7 +73,7 @@ const releaseStartButton = () => {
>
<div>
<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 class="txt-nickname">
{{ $format_str(item.nickname, 12) }}
......@@ -235,7 +242,6 @@ const releaseStartButton = () => {
width: 60px;
height: 60px;
border-radius: 30px;
background: v-bind("imageUrls.avatar");
}
.txt-nickname {
......
......@@ -46,7 +46,9 @@ const confirmRefresh = (event: BeforeUnloadEvent) => {
const renderPlayers = (data: any) => {
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(
{ length: PLAYER_LIMIT },
(_, index) => players[index] ?? emptyLoadingPlayer(),
......@@ -114,6 +116,7 @@ const gotoRank1WithIntro = async () => {
}
function handleRoomState(data: any) {
console.log('[WebSocket] 房间状态更新 - status:', data?.status, '数据:', JSON.stringify(data))
if (Array.isArray(data?.list)) {
renderPlayers(data)
}
......@@ -137,14 +140,21 @@ function handleRoomState(data: any) {
}
const { startGame, createRoom, backRoom } = useAdminGameSocket({
gameId: 'game1',
gameId: 'game5',
onRoomState: handleRoomState,
onRoomJoin: (data) => {
console.log('[WebSocket] 玩家加入房间 - 数据:', JSON.stringify(data))
renderPlayers(data)
gotoLoading()
},
onGameStart: gotoRank1WithIntro,
onRoomRank: updateRankPlayers,
onGameStart: () => {
console.log('[WebSocket] 游戏开始!')
gotoRank1WithIntro()
},
onRoomRank: (data) => {
console.log('[WebSocket] 排名更新 - 数据:', JSON.stringify(data))
updateRankPlayers(data)
},
onRelogin: async () => {
$remove_socket_storage();
await router.replace('/')
......
......@@ -107,6 +107,7 @@ watch(
() => props.rankList,
(rankList) => {
if (rankList && rankList.length > 0) {
console.log('[Rank1View] 排名列表更新 - 数据:', JSON.stringify(rankList.map(p => ({ nickname: p.nickname, score: p.score }))))
updateRankList(rankList);
}
},
......@@ -121,11 +122,13 @@ const startIntro = async () => {
horses.value = [];
horseIdCounter = 0;
console.log('[Rank1View] 游戏流程启动 - 开始 3s 倒计时 + 60s 游戏')
// 启动完整倒计时:3s 开场 + 60s 游戏
const countdownPromise = designStage.value?.startCountdown(3, 60);
// 等待 3-2-1 开场倒计时结束(~3.5s 后安全触发)
await new Promise((r) => setTimeout(r, 3500));
console.log('[Rank1View] 倒计时结束,马匹开始奔跑')
// Phase 2: 倒计时结束 → 游戏正式开始,背景动画+马匹启动
gameStarted.value = true;
......@@ -139,6 +142,7 @@ const startIntro = async () => {
};
const stopGame = () => {
console.log('[Rank1View] 游戏结束 - 60s 倒计时到')
isGameRunning.value = false;
showResult.value = true;
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