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,6 +247,7 @@ onBeforeUnmount(() => {
</template>
<LoadingView v-if="currentView === 'loading'" :image-urls="imageUrls" :user-join-status="userJoinStatus"
@touchGameRule="showGameRuleHandler" />
<!-- <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" />
......
<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 :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;">
......
<script setup lang="ts">
defineProps<{
import { assetUrl } from '@/commons/assets.ts'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = defineProps<{
imageUrls: Record<string, string>
countdownInterval: number
isDivDescVisible: boolean
......@@ -8,13 +11,409 @@ defineProps<{
currentGu: string
}>()
defineEmits<{
touch: []
const emit = defineEmits<{
hit: [score: number]
}>()
// ── 圈圈丢出动画帧 (31帧, 原图325×733 → 渲染宽165) ──
const circleAnimFrames = Array.from({ length: 31 }, (_, i) =>
assetUrl(`game5/circle-animation/qcnf-quan_${String(i).padStart(2, '0')}.png`)
)
// ── 四种马的帧 (各21帧, 原图196×204 → 渲染180×160) ──
const horseFrames: Record<number, string[]> = {}
for (let h = 1; h <= 4; h++) {
horseFrames[h] = Array.from({ length: 21 }, (_, i) =>
assetUrl(`game5/horse${h}/no-circle/qcnf-ma${h}_${String(i).padStart(2, '0')}.png`)
)
}
// ── 布局常量 ──
const STAGE_WIDTH = 750
const STAGE_HEIGHT = 1624
const RUNWAY_TOP = 696
const RUNWAY_HEIGHT = 306
const RUNWAY_HALF_Y = RUNWAY_TOP + RUNWAY_HEIGHT / 2 // 849
const HORSE_WIDTH = 180
const HORSE_HEIGHT = 200 // 原图196×204→渲染180×200,留足跳跃帧头部空间
const HORSE_Y = RUNWAY_TOP + (RUNWAY_HEIGHT - HORSE_HEIGHT) / 2 // 749
const CIRCLE_THROW_MS = 1200 // 圈圈飞行时长
const HIT_FADE_MS = 400 // 被套中后马消失延迟
// ── 难度递增参数:随游戏时间从 0→60s 指数曲线变化 ──
const HORSE_SPEED_MIN = 40 // px/s 初始速度
const HORSE_SPEED_MAX = 280 // px/s 最大速度(60s 时,7倍提升)
const SPAWN_MIN_START = 3000 // ms 初始最短生成间隔
const SPAWN_MIN_END = 800 // ms 最终最短生成间隔
const SPAWN_MAX_START = 6000 // ms 初始最长生成间隔
const SPAWN_MAX_END = 1800 // ms 最终最长生成间隔
const MAX_HORSES_ON_SCREEN = 8 // 场上最多马匹数
// 马动画帧率控制(参考 Rank1View:每 N 个 raf tick 切一帧 → ~20fps)
const HORSE_FRAME_TICK = 3
// circle-outer: bottom:72, height:345 → outerTop = 1624 - 72 - 345 = 1207
// circle-inner 圆心 Y = outerTop + 345/2 = 1379.5
const CIRCLE_CENTER_X = 375
const CIRCLE_CENTER_Y = STAGE_HEIGHT - 72 - 345 + 345 / 2 // 1379.5
const CIRCLE_ANIM_WIDTH = 165
const CIRCLE_ANIM_HEIGHT = Math.round(733 * (CIRCLE_ANIM_WIDTH / 325)) // ≈ 372
// ── 类型 ──
interface Horse {
id: number
type: number
x: number
frameIdx: number
frameTimer: number // 帧切换滴答计数器(每帧+1,满 HORSE_FRAME_TICK 切帧)
alive: boolean
dying: boolean
dieAt: number
}
interface CircleThrow {
y: number
frameIdx: number
startY: number
startTime: number
}
interface HitEffect {
id: number
x: number
y: number
score: number
}
// ── 响应式状态 ──
const horses = ref<Horse[]>([])
const circleThrow = ref<CircleThrow | null>(null)
const isCircleFlying = ref(false)
const hitEffects = ref<HitEffect[]>([])
// ── 内部变量 ──
let horseIdSeq = 0
let hitEffectIdSeq = 0
let raf = 0
let prevTs = 0
let spawnAccum = 0
let nextSpawn = 2000
let gameElapsed = 0 // 游戏已进行时间 ms(用于难度递增)
const HIT_EFFECT_MS = 800 // "+10" 动画持续时长
// ── 游戏状态 ──
const gameStarted = ref(false) // 3-2-1 倒计时完成标志,控制动画启停
const gameActive = ref(true) // 游戏进行中(倒计时到 0 后变 false)
const preCountdown = ref(3) // 开场倒计时 3/2/1
const preCountdownKey = ref(0) // 强制动画重触发
const preCountdownVisible = ref(false) // 开场倒计时可见
let preCountdownTimer: ReturnType<typeof setTimeout> | undefined
// ── Web Audio API 音效 ──
let audioContext: AudioContext | null = null
function getAudioContext(): AudioContext {
if (!audioContext) {
audioContext = new AudioContext()
}
if (audioContext.state === 'suspended') {
audioContext.resume()
}
return audioContext
}
/** 丢圈"咻"声 — 短促上升频率扫频 */
function playThrowSound() {
try {
const ctx = getAudioContext()
const now = ctx.currentTime
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.connect(gain)
gain.connect(ctx.destination)
osc.type = 'sine'
osc.frequency.setValueAtTime(400, now)
osc.frequency.exponentialRampToValueAtTime(800, now + 0.08)
osc.frequency.exponentialRampToValueAtTime(200, now + 0.2)
gain.gain.setValueAtTime(0.25, now)
gain.gain.exponentialRampToValueAtTime(0.01, now + 0.2)
osc.start(now)
osc.stop(now + 0.2)
} catch { /* ignore audio errors */ }
}
/** 套中"叮"声 — 两个简短的上升音 */
function playHitSound() {
try {
const ctx = getAudioContext()
const now = ctx.currentTime
// 第一音 880 Hz
const o1 = ctx.createOscillator()
const g1 = ctx.createGain()
o1.connect(g1)
g1.connect(ctx.destination)
o1.type = 'sine'
o1.frequency.setValueAtTime(880, now)
g1.gain.setValueAtTime(0.3, now)
g1.gain.exponentialRampToValueAtTime(0.01, now + 0.25)
o1.start(now)
o1.stop(now + 0.25)
// 第二音 1320 Hz 延时 0.08s
const o2 = ctx.createOscillator()
const g2 = ctx.createGain()
o2.connect(g2)
g2.connect(ctx.destination)
o2.type = 'sine'
o2.frequency.setValueAtTime(1320, now + 0.08)
g2.gain.setValueAtTime(0.22, now + 0.08)
g2.gain.exponentialRampToValueAtTime(0.01, now + 0.35)
o2.start(now + 0.08)
o2.stop(now + 0.35)
} catch { /* ignore audio errors */ }
}
// ── 清理全部音效(组件卸载时) ──
function disposeAudioContext() {
if (audioContext) {
audioContext.close().catch(() => {})
audioContext = null
}
}
// ── 难度系数(0~1,游戏 60s,指数曲线让后期明显加速) ──
function difficultyFactor(): number {
return Math.min(gameElapsed / 60000, 1)
}
// ── 当前马速度(二次曲线:前期缓、后期猛) ──
function currentSpeed(): number {
const t = difficultyFactor()
// 二次曲线:t² 让前期平缓、30s后加速明显
return HORSE_SPEED_MIN + (HORSE_SPEED_MAX - HORSE_SPEED_MIN) * t * t
}
// ── 生成一匹马 ──
function spawnHorse() {
const type = Math.floor(Math.random() * 4) + 1
horses.value.push({
id: ++horseIdSeq,
type,
x: -HORSE_WIDTH - Math.random() * 200,
frameIdx: Math.floor(Math.random() * 21), // 随机起始帧,避免多马同帧
frameTimer: 0,
alive: true,
dying: false,
dieAt: 0,
})
}
// ── 点击丢出圈圈 ──
function throwCircle() {
if (!gameActive.value || !gameStarted.value || isCircleFlying.value) return
isCircleFlying.value = true
playThrowSound()
circleThrow.value = {
y: CIRCLE_CENTER_Y,
frameIdx: 0,
startY: CIRCLE_CENTER_Y,
startTime: performance.now(),
}
}
// ── 碰撞检测(收紧:圈心必须落在马身体中间区域) ──
function checkHit(ts: number): boolean {
if (!circleThrow.value) return false
const ct = circleThrow.value
const cx = CIRCLE_CENTER_X
const cy = ct.y
// 命中区域:马身体宽度 20%~80%、高度 20%~80%(中间 60% 区域)
const hitMarginX = HORSE_WIDTH * 0.2
const hitMarginY = HORSE_HEIGHT * 0.2
const halfHitW = HORSE_WIDTH / 2 - hitMarginX
const halfHitH = HORSE_HEIGHT / 2 - hitMarginY
for (const h of horses.value) {
if (!h.alive || h.dying) continue
const horseCenterX = h.x + HORSE_WIDTH / 2
const horseCenterY = HORSE_Y + HORSE_HEIGHT / 2
if (
Math.abs(cx - horseCenterX) <= halfHitW &&
Math.abs(cy - horseCenterY) <= halfHitH
) {
h.dying = true
h.dieAt = ts
circleThrow.value = null
isCircleFlying.value = false
// 在马头顶生成 "+10" 飘字效果
const effectId = ++hitEffectIdSeq
hitEffects.value.push({
id: effectId,
x: horseCenterX,
y: HORSE_Y,
score: 10,
})
setTimeout(() => {
const idx = hitEffects.value.findIndex(e => e.id === effectId)
if (idx >= 0) hitEffects.value.splice(idx, 1)
}, HIT_EFFECT_MS)
playHitSound()
emit('hit', 10)
return true
}
}
return false
}
// ── 主循环 ──
function loop(ts: number) {
if (!gameActive.value) {
// 游戏结束:保持最后一帧,不再更新动画
raf = requestAnimationFrame(loop)
return
}
if (!prevTs) prevTs = ts
const dt = Math.min(ts - prevTs, 50)
prevTs = ts
gameElapsed += dt
// 1. 更新飞行中的圈圈
if (circleThrow.value) {
const ct = circleThrow.value
const elapsed = ts - ct.startTime
const progress = Math.min(elapsed / CIRCLE_THROW_MS, 1)
ct.y = ct.startY - (ct.startY - RUNWAY_HALF_Y) * progress
ct.frameIdx = Math.min(Math.floor(progress * 31), 30)
if (checkHit(ts)) {
// 命中:已处理
} else if (progress >= 1) {
// 落空
circleThrow.value = null
isCircleFlying.value = false
}
}
// 2. 更新马匹位置(速度随难度递增,帧率控制防闪烁)
const speed = currentSpeed()
for (let i = horses.value.length - 1; i >= 0; i--) {
const h = horses.value[i]!
if (h.dying) {
if (ts - h.dieAt >= HIT_FADE_MS) {
horses.value.splice(i, 1)
}
continue
}
h.x += speed * (dt / 1000)
// 帧率控制:每 HORSE_FRAME_TICK 次循环切换一帧(约 20fps)
h.frameTimer++
if (h.frameTimer >= HORSE_FRAME_TICK) {
h.frameTimer = 0
h.frameIdx = (h.frameIdx + 1) % 21
}
if (h.x > STAGE_WIDTH + 120) {
horses.value.splice(i, 1)
}
}
// 3. 生成新马(难度递增:间隔越来越短、数量受上限约束)
spawnAccum += dt
if (spawnAccum >= nextSpawn) {
spawnAccum = 0
const t = difficultyFactor()
const t2 = t * t // 二次曲线与速度同步
const minSpawn = SPAWN_MIN_START + (SPAWN_MIN_END - SPAWN_MIN_START) * t2
const maxSpawn = SPAWN_MAX_START + (SPAWN_MAX_END - SPAWN_MAX_START) * t2
nextSpawn = minSpawn + Math.random() * (maxSpawn - minSpawn)
if (horses.value.length < MAX_HORSES_ON_SCREEN) {
spawnHorse()
}
}
raf = requestAnimationFrame(loop)
}
// ── 预加载所有帧图片,避免运行时切换帧的闪烁 ──
function preloadFrames() {
// 圈圈动画 31 帧
for (const url of circleAnimFrames) {
const img = new Image()
img.src = url
}
// 四种马各 21 帧
for (const frames of Object.values(horseFrames)) {
for (const url of frames) {
const img = new Image()
img.src = url
}
}
}
// ── 开场 3-2-1 倒计时 ──
function startPreGameCountdown() {
preCountdown.value = 3
preCountdownVisible.value = true
const tick = () => {
preCountdownKey.value++
if (preCountdown.value <= 1) {
preCountdownVisible.value = false
preCountdownTimer = undefined
// 倒计时结束 → 启动游戏
gameStarted.value = true
gameActive.value = true
prevTs = performance.now()
spawnHorse()
setTimeout(spawnHorse, 800)
setTimeout(spawnHorse, 1600)
raf = requestAnimationFrame(loop)
return
}
preCountdown.value--
preCountdownTimer = setTimeout(tick, 1000)
}
preCountdownTimer = setTimeout(tick, 1000)
}
function stopPreCountdown() {
if (preCountdownTimer) {
clearTimeout(preCountdownTimer)
preCountdownTimer = undefined
}
}
// ── 监听父组件倒计时,到 0 时暂停游戏 ──
watch(() => props.countdownInterval, (val) => {
if (val <= 0 && gameActive.value) {
gameActive.value = false
isCircleFlying.value = false
circleThrow.value = null
}
})
onMounted(() => {
preloadFrames()
// 先启动音频上下文(需用户首次交互后 resume,此处尝试提前激活)
try { getAudioContext() } catch { /* ignore */ }
// 开场 3-2-1 倒计时
startPreGameCountdown()
})
onBeforeUnmount(() => {
cancelAnimationFrame(raf)
stopPreCountdown()
disposeAudioContext()
})
</script>
<template>
<div class="h5-page game-stage">
<div class="game-stage">
<!-- 开场 3-2-1 倒计时遮罩 -->
<div v-if="preCountdownVisible" class="pre-countdown-overlay">
<div :key="preCountdownKey" class="pre-countdown-number">{{ preCountdown }}</div>
</div>
<Transition name="div-desc-slide" appear>
<div v-if="isDivDescVisible" class="div-desc">
<span class="desc-clock" aria-hidden="true"></span>
......@@ -33,23 +432,98 @@ defineEmits<{
<div class="txt-bold">{{ tick }}</div>
</div>
</div>
<!-- <div class="bg-bottom"></div> -->
<div class="paodao-runway"></div>
<div class="circle-outer" @click="$emit('touch')">
<div class="paodao-runway-container">
<div class="paodao-runway" :class="{ 'paodao-paused': !gameActive }"></div>
</div>
<!-- 奔跑的马匹(改用 <img> 防闪烁,参考 Rank1View) -->
<template v-if="gameStarted">
<div
v-for="horse in horses"
:key="horse.id"
class="horse"
:class="{ 'horse-dying': horse.dying }"
:style="{ transform: `translateX(${horse.x}px)` }"
>
<img
class="horse-img"
:src="horseFrames[horse.type]?.[horse.frameIdx] ?? ''"
alt="horse"
/>
<div class="horse-shadow" :class="{ 'shadow-dying': horse.dying }"></div>
</div>
</template>
<!-- 套中加分飘字效果 -->
<div
v-for="effect in hitEffects"
:key="effect.id"
class="hit-effect"
:style="{ left: effect.x + 'px', top: effect.y + 'px' }"
>+{{ effect.score }}</div>
<!-- 飞行中的圈圈动画 -->
<div
v-if="gameStarted && circleThrow"
class="circle-throw"
:style="{
transform: `translate(${CIRCLE_CENTER_X - CIRCLE_ANIM_WIDTH / 2}px, ${circleThrow.y - CIRCLE_ANIM_HEIGHT / 2}px)`,
width: CIRCLE_ANIM_WIDTH + 'px',
height: CIRCLE_ANIM_HEIGHT + 'px',
backgroundImage: `url(${circleAnimFrames[circleThrow.frameIdx]})`,
}"
></div>
<div class="circle-outer" :class="{ 'circle-disabled': !gameActive || !gameStarted, 'circle-paused': !gameActive }" @click="throwCircle">
<div class="circle-inner"></div>
</div>
<div class="play-click">点击按钮,开始套圈</div>
<div class="play-click">{{ !gameStarted ? '准备开始…' : !gameActive ? '游戏结束' : '点击按钮,开始套圈' }}</div>
</div>
</template>
<style scoped>
.game-stage {
position: absolute;
left: 0;
top: 0;
width: 750px;
height: 1624px;
margin: 0;
padding: 0;
}
/* ── 开场 3-2-1 倒计时遮罩 ── */
/* 必须使用 MobileStage 的 viewport CSS 变量,才能覆盖缩放产生的侧边空白 */
.pre-countdown-overlay {
position: absolute;
left: var(--stage-viewport-left);
top: var(--stage-viewport-top);
width: var(--stage-viewport-width);
height: var(--stage-viewport-height);
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
pointer-events: none;
}
.pre-countdown-number {
color: #FFD700;
font-size: 200px;
font-weight: 900;
line-height: 1;
text-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
animation: countdown-pop 0.9s ease both;
}
@keyframes countdown-pop {
0% { opacity: 0; transform: scale(0.3); }
25% { opacity: 1; transform: scale(1.15); }
55% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(0.8); }
}
.div-desc {
position: absolute;
top: 150px;
......@@ -108,16 +582,6 @@ defineEmits<{
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: 66px;
......@@ -150,6 +614,7 @@ defineEmits<{
transform: translate(-50%, -50%);
font-size: 24px;
color: #A10A06;
z-index: 2;
.txt-bold {
font-size: 40px;
......@@ -166,27 +631,119 @@ defineEmits<{
}
}
.paodao-runway {
/* ── 跑道容器:撑满 viewport 宽度并裁剪溢出 ── */
.paodao-runway-container {
position: absolute;
left: 0;
left: var(--stage-viewport-left, 0);
top: 696px;
width: 750px;
width: var(--stage-viewport-width, 750px);
height: 306px;
overflow: hidden;
z-index: 2;
}
/* ── 跑道内层:2x 宽度通过 transform 滚动,适配任意 viewport 宽 ── */
.paodao-runway {
width: 200%;
height: 100%;
background: v-bind('imageUrls.paodao') repeat-x;
background-size: auto 100%;
animation: runway-scroll 8s linear infinite;
}
@keyframes runway-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
/* ── 跑道暂停 ── */
.paodao-paused {
animation-play-state: paused;
}
/* ── 马匹 ── */
.horse {
position: absolute;
left: 0;
top: v-bind('HORSE_Y + "px"');
width: 180px;
height: 200px;
z-index: 3;
will-change: transform;
}
.horse-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.horse-dying {
opacity: 0;
transition: opacity 0.3s ease, transform 0.3s ease;
}
/* ── 马匹脚底阴影 ── */
.horse-shadow {
position: absolute;
bottom: -10px;
left: 50%;
width: 110px;
height: 18px;
transform: translateX(-50%);
background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.35) 0%, rgba(0, 0, 0, 0.10) 40%, rgba(0, 0, 0, 0) 72%);
border-radius: 50%;
pointer-events: none;
}
.shadow-dying {
opacity: 0;
transition: opacity 0.3s ease;
}
/* ── 套中加分飘字 ── */
.hit-effect {
position: absolute;
z-index: 20;
color: #FFD700;
font-size: 40px;
font-weight: 900;
pointer-events: none;
text-shadow: 0 3px 6px rgba(0, 0, 0, 0.6);
animation: score-float 0.8s ease-out forwards;
white-space: nowrap;
transform: translate(-50%, -100%);
}
@keyframes score-float {
0% {
background-position-x: 0;
opacity: 1;
transform: translate(-50%, -100%) scale(0.5);
}
30% {
opacity: 1;
transform: translate(-50%, -130%) scale(1.1);
}
100% {
background-position-x: -750px;
opacity: 0;
transform: translate(-50%, -220%) scale(0.8);
}
}
/* ── 飞行中的圈圈 ── */
.circle-throw {
position: absolute;
left: 0;
top: 0;
background-position: center;
background-size: contain;
background-repeat: no-repeat;
z-index: 10;
pointer-events: none;
will-change: transform, background-image;
image-rendering: auto;
}
.circle-outer {
position: absolute;
left: 50%;
......@@ -196,6 +753,16 @@ defineEmits<{
background: v-bind('imageUrls.circleBg') center/cover;
transform: translateX(-50%);
animation: circle-spin 3s linear infinite;
z-index: 4;
}
.circle-disabled {
pointer-events: none;
opacity: 0.5;
}
.circle-paused {
animation-play-state: paused;
}
@keyframes circle-spin {
......@@ -227,5 +794,6 @@ defineEmits<{
font-weight: 400;
font-size: 26px;
color: #FFFFFF;
z-index: 5;
}
</style>
......@@ -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