Commit d0bec691 authored by 董政锦's avatar 董政锦

fix: game4 背景映音乐

parent 83aad560
......@@ -197,15 +197,15 @@ watch(
);
// ========== 速度基准(对齐 game4 时间驱动模型) ==========
// 基础速度大幅下调,确保马匹在 60s 游戏时间内不会过早跑出屏幕
// 60s 游戏内,即使满加速也最多跑 ~75% 路程,确保马匹始终可见
const TOTAL_DISTANCE_PX = HORSE_END_X - HORSE_START_X; // 2200px
const BASE_DURATION_SEC = 150; // 基准时长 150s,即无加速时跑完全程需 150s
const BASE_SPEED_PX_PER_SEC = TOTAL_DISTANCE_PX / BASE_DURATION_SEC; // ≈ 14.7px/s
const BASE_DURATION_SEC = 200; // 基准时长 200s,即无加速时跑完全程需 200s
const BASE_SPEED_PX_PER_SEC = TOTAL_DISTANCE_PX / BASE_DURATION_SEC; // 11.0px/s
// 瞬时加速脉冲机制(对齐 game4)
const BOOST_PULSE = 0.3; // 每次 score 增长对应的额外速度倍率
const MAX_BOOST = 3.0; // 最大加速倍率
const BOOST_DECAY = 0.992; // 每帧衰减系数(60fps 约 3.6s 到 60%
const BOOST_PULSE = 0.15; // 每次 score 增长对应的额外速度倍率(从 0.3 下调,减缓加速累积)
const MAX_BOOST = 1.5; // 最大加速倍率(从 3.0 下调,确保满速时 80s 才能跑完 2200px)
const BOOST_DECAY = 0.990; // 每帧衰减系数(微微加快衰减,避免 boost 持续高位
// ========== 马匹动画循环 (RAF 驱动:时间驱动位移 + 帧动画) ==========
let horseRafId: number | undefined;
......
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { playApplauseMusic, playGame4Music, stopAllMusic, stopGame4Music } from '@/commons/music'
import { playApplauseMusic, playGame4Music, stopAllMusic, stopApplauseMusic, stopGame4Music } from '@/commons/music'
import Loading from "./views/LoadingView.vue";
import Rank1 from "./views/Rank1View.vue";
import Rank2 from "./views/Rank2View.vue";
......@@ -8,7 +8,7 @@ import { useAdminGameSocket } from "@/composables/useAdminGameSocket";
import { onSocketMessage } from "@/commons/ws";
import type Player from "@/commons/player.ts";
import { useRouter } from 'vue-router'
import { $confirm, $remove_socket_storage, $notify, $toast } from "@/commons/utils.ts";
import { $confirm, $is_local_mode, $remove_socket_storage, $notify, $toast } from "@/commons/utils.ts";
const router = useRouter()
......@@ -108,6 +108,9 @@ const gotoRank1WithIntro = async () => {
isPlayingStartAnimation = true
seedRankPlayersFromLoading()
// 对齐 game3:进入赛马画面前确保背景音乐播放中
// 必须在 startIntro 之前调用,因为 startIntro 内部会 stopApplauseMusic() 误伤 BGM
playGame4Music()
screen.value = 'rank1'
await nextTick()
await rank1Ref.value?.startIntro()
......@@ -193,16 +196,37 @@ const { startGame, createRoom, backRoom, closeRoom } = useAdminGameSocket({
})
const startGameWithMusic = () => {
// 本地调试模式:跳过 WebSocket,直接进入赛马画面(对齐 game3)
// 注意:不在这里调 playGame4Music(),因为 onMounted 中已注册 pointerdown 监听器,
// 点击按钮时 pointerdown 事件先于 click 触发,会自动播放 BGM。
// 如果在此额外调用 playGame4Music(),会导致 stopAllMusic 销毁并重建 Audio,
// 在复杂时序下可能 play() 失败,需要再点一次页面才能听到音乐。
if ($is_local_mode()) {
gotoRank1WithIntro()
return
}
if (playerCount.value == 0) {
// alert('房间中没有玩家')
$toast('房间中没有玩家');
return;
}
// 对齐 game3:点击"开始挑战"时确保背景音乐播放中
playGame4Music()
startGame()
}
const nextRound = () => {
// 立刻停止喝彩音乐并移除指针事件监听,防止点击按钮时重新触发 playApplauseMusic
stopApplauseMusic()
if ($is_local_mode()) {
// 本地模式:回到 loading 页,用户点击"开始挑战"后再进入赛马(对齐 game3)
gotoLoading(true)
return
}
gotoLoading(true)
// 等待 Rank1View 卸载完成后再确保 BGM 播放(对齐 game3 的 watch 模式)
// 用 nextTick 确保在组件卸载、watch 触发的 BGM 之后再次保证
nextTick().then(() => playGame4Music())
//重开房间
backRoom(4)
}
......@@ -212,6 +236,13 @@ const nextRoundWithMusic = () => {
}
const backHandler = async() => {
// 立刻停止喝彩音乐并移除指针事件监听,防止点击按钮时重新触发 playApplauseMusic
stopApplauseMusic()
if ($is_local_mode()) {
stopAllMusic()
router.replace('/main')
return
}
const ok = await $confirm({
title: '提示',
message: '是否关闭当前房间回到首页',
......
......@@ -2,7 +2,7 @@
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import DesignStage from "../../../components/DesignStage.vue";
import type Player from "@/commons/player.ts";
import { $is_run_local } from "@/commons/utils.ts";
import { $is_local_mode } from "@/commons/utils.ts";
import { cssAssetUrl, assetUrl } from "@/commons/assets.ts";
import { playApplauseMusic, stopApplauseMusic } from "@/commons/music";
const defaultAvatarUrl = assetUrl("game4/default-avator.svg");
......@@ -176,12 +176,26 @@ const updateHorses = (players: Player[]) => {
});
};
const mockAvatars = [
'https://api.dicebear.com/9.x/fun-emoji/svg?seed=Felix',
'https://api.dicebear.com/9.x/fun-emoji/svg?seed=Aneka',
'https://api.dicebear.com/9.x/fun-emoji/svg?seed=Salem',
'https://api.dicebear.com/9.x/fun-emoji/svg?seed=Midnight',
'https://api.dicebear.com/9.x/fun-emoji/svg?seed=Lucky',
'https://api.dicebear.com/9.x/fun-emoji/svg?seed=Bandit',
'https://api.dicebear.com/9.x/fun-emoji/svg?seed=Misty',
'https://api.dicebear.com/9.x/fun-emoji/svg?seed=Pepper',
'https://api.dicebear.com/9.x/fun-emoji/svg?seed=Oreo',
'https://api.dicebear.com/9.x/fun-emoji/svg?seed=Tiger',
];
const mockNicknames = ['疾风骑士', '闪电侠', '追风者', '千里马', '飞毛腿', '烈焰驹', '暴风战士', '神行者', '银鞍客', '踏雪无痕'];
const mockHorses = () => {
const mockPlayers: Player[] = Array.from({ length: 10 }, (_, i) => ({
userid: `mock-${i}`,
nickname: `玩家${i + 1}`,
avatar: "",
score: Math.floor(Math.random() * 1000),
nickname: mockNicknames[i] || `玩家${i + 1}`,
avatar: mockAvatars[i] || '',
score: Math.floor(Math.random() * 60) + 10,
}));
updateHorses(mockPlayers);
};
......@@ -294,15 +308,24 @@ const stopGameLoop = () => {
let mockBoostTimer: ReturnType<typeof setInterval> | undefined;
const startMockBoost = () => {
stopMockBoost();
// 基于马匹顺序计算速度系数:排名靠前的马(小索引)基础快,末尾的慢
const horseRates: Record<string, number> = {};
horses.value.forEach((h, i) => {
horseRates[h.id] = 0.3 + (i / Math.max(horses.value.length - 1, 1)) * 0.7;
});
mockBoostTimer = setInterval(() => {
if (!isGameRunning.value) return;
// 随机选 1~3 匹马触发加速
const count = 1 + Math.floor(Math.random() * 3);
const shuffled = [...horses.value].sort(() => Math.random() - 0.5);
shuffled.slice(0, count).forEach((horse) => {
horse.boost = Math.min(3, horse.boost + 0.3 + Math.random() * 0.7);
const updated = { ...transientBoosts.value };
horses.value.forEach((horse) => {
const rate = horseRates[horse.id] ?? (0.3 + Math.random() * 0.7);
const current = updated[horse.id] || 0;
// 偶尔减速制造节奏变化
const decay = Math.random() > 0.75 ? -0.4 : 0;
updated[horse.id] = Math.max(0, Math.min(current + BOOST_PULSE * rate * 0.3 + decay, MAX_PULSE));
});
}, 2000);
transientBoosts.value = updated;
}, 1500);
};
const stopMockBoost = () => {
if (mockBoostTimer) {
......@@ -368,7 +391,7 @@ const stopGame = () => {
};
const startIntro = async () => {
if ($is_run_local()) {
if ($is_local_mode()) {
mockHorses();
}
showResult.value = false;
......@@ -380,8 +403,8 @@ const startIntro = async () => {
transientBoosts.value = {};
Object.keys(prevScores).forEach((k) => delete prevScores[k]);
// 启动完整倒计时:3s 开场 + 60s 游戏(DesignStage 内部管理两个阶段的 UI)
const countdownPromise = designStage.value?.startCountdown(3, 60);
// 启动完整倒计时:3s 开场 + 3s 游戏(DesignStage 内部管理两个阶段的 UI)
const countdownPromise = designStage.value?.startCountdown(3, 3);
// 等待 3-2-1 开场倒计时结束(~3.5s 后安全触发)
await new Promise((r) => setTimeout(r, 3500));
......@@ -390,7 +413,7 @@ const startIntro = async () => {
gameStarted.value = true;
isGameRunning.value = true;
startGameLoop();
if ($is_run_local()) {
if ($is_local_mode()) {
startMockBoost();
}
......@@ -427,23 +450,23 @@ const renderAvatar = (item: any) => {
// 获取 podium 上三名选手(不足补空)
const podiumPlayers = (): RankItem[] => {
const result = fullRankList.value.slice(0, 3);
// 按 podium 视觉顺序:2 1 3
// 按 podium 视觉顺序:2 1 3(第二名在左,第三名在右)
return [result[1]!, result[0]!, result[2]!];
};
onMounted(() => {
preloadHorseSprite();
if ($is_run_local()) {
mockHorses();
gameStarted.value = true;
isGameRunning.value = true;
startGameLoop();
startMockBoost();
}
// 本地模式:Game4.vue onMounted 会调用 startIntro() 来控制完整流程
// 此处不自动开始,避免与 startIntro 冲突
});
onUnmounted(() => {
stopGame();
// 仅清理游戏资源,不操作音乐(音乐由 Game4.vue 统一管理)
// 注意:不能调 stopApplauseMusic(),因为 backgroundMusic 是全局共享的,
// 组件卸载时 Game4.vue 的 watch 可能已将其切换为 BGM,pause 会误伤 BGM
isGameRunning.value = false;
stopGameLoop();
stopMockBoost();
});
defineExpose({
......@@ -493,7 +516,7 @@ defineExpose({
<div
v-for="(item, idx) in podiumPlayers()"
:key="item.userid || `podium-${idx}`"
:class="['rank-no', `rank-no${idx === 1 ? 1 : idx === 0 ? 2 : 3}`]"
:class="['rank-no', `rank-no${idx === 1 ? 1 : idx === 0 ? 3 : 2}`]"
>
<div class="gu-burst"></div>
<div class="avatar" :style="renderAvatar(item)"></div>
......@@ -778,7 +801,7 @@ defineExpose({
}
}
/* 第2名 (原 rank-no3 样式) */
/* 第2名 */
.rank-no2 {
.gu-burst { left: 908px; top: -81px; }
.avatar {
......@@ -787,7 +810,7 @@ defineExpose({
}
.nickname {
width: 167px; height: 46px;
background: #E2E2E2; color: #AFAFAF;
background: #CA9600; color: white;
text-align: center; line-height: 46px;
position: absolute; left: 897px; top: 136px;
z-index: 1; border-radius: 23px;
......@@ -804,7 +827,7 @@ defineExpose({
}
}
/* 第3名 (原 rank-no2 样式) */
/* 第3名 */
.rank-no3 {
.gu-burst { left: 318px; top: -120px; }
.avatar {
......@@ -813,7 +836,7 @@ defineExpose({
}
.nickname {
width: 167px; height: 46px;
background: #CA9600; color: white;
background: #E2E2E2; color: #AFAFAF;
text-align: center; line-height: 46px;
position: absolute; top: 95px;left: 309px;
z-index: 1; border-radius: 23px;
......
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