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

fix: game4 背景映音乐

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