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

feat: game4 PC端马的进度(基础速度+得分加速度);

parent cb184464
...@@ -262,7 +262,7 @@ const startGameWithMusic = () => { ...@@ -262,7 +262,7 @@ const startGameWithMusic = () => {
console.log('[Game4] startGameWithMusic called, local mode:', $is_local_mode(), 'playerCount:', playerCount.value) console.log('[Game4] startGameWithMusic called, local mode:', $is_local_mode(), 'playerCount:', playerCount.value)
// 确保背景音乐播放(参考 game2 的 watch 模式,但直接在点击时调用更可靠) // 确保背景音乐播放(参考 game2 的 watch 模式,但直接在点击时调用更可靠)
playGame4Music() playGame4Music()
// 本地模拟已关闭,如需开启请取消以下注释 // // 本地模拟已关闭,如需开启请取消以下注释
// if ($is_local_mode()) { // if ($is_local_mode()) {
// console.log('[Game4] 本地模式,直接进入赛马画面') // console.log('[Game4] 本地模式,直接进入赛马画面')
// gotoRank1WithIntro() // gotoRank1WithIntro()
...@@ -279,7 +279,7 @@ const startGameWithMusic = () => { ...@@ -279,7 +279,7 @@ const startGameWithMusic = () => {
const nextRound = () => { const nextRound = () => {
// 立刻停止喝彩音乐并移除指针事件监听,防止点击按钮时重新触发 playApplauseMusic // 立刻停止喝彩音乐并移除指针事件监听,防止点击按钮时重新触发 playApplauseMusic
stopApplauseMusic() stopApplauseMusic()
// 本地模拟已关闭,如需开启请取消以下注释 // // 本地模拟已关闭,如需开启请取消以下注释
// if ($is_local_mode()) { // if ($is_local_mode()) {
// gotoLoading(true) // gotoLoading(true)
// return // return
...@@ -299,7 +299,7 @@ const nextRoundWithMusic = () => { ...@@ -299,7 +299,7 @@ const nextRoundWithMusic = () => {
const backHandler = async() => { const backHandler = async() => {
// 立刻停止喝彩音乐并移除指针事件监听,防止点击按钮时重新触发 playApplauseMusic // 立刻停止喝彩音乐并移除指针事件监听,防止点击按钮时重新触发 playApplauseMusic
stopApplauseMusic() stopApplauseMusic()
// 本地模拟已关闭,如需开启请取消以下注释 // // 本地模拟已关闭,如需开启请取消以下注释
// if ($is_local_mode()) { // if ($is_local_mode()) {
// stopAllMusic() // stopAllMusic()
// router.replace('/main') // router.replace('/main')
......
...@@ -45,12 +45,16 @@ const imageUrls = { ...@@ -45,12 +45,16 @@ const imageUrls = {
avatarAnimation: cssAssetUrl("avatar_animation.png"), avatarAnimation: cssAssetUrl("avatar_animation.png"),
}; };
// ========== 速度基准 ========== // ========== 速度基准(满速马在游戏结束前 1~2 秒刚好跑到右边缘)==========
// 60s 游戏 × 基准速度(0.455%/s) = 27.3% → 不加速跑约 1/4 路程 // MAX_PULSE=2.5 → 最大倍率 = 1+2.5 = 3.5x
// 60s 游戏 × 最大3.5x(1.59%/s) = 95.5% → 猛摇才能接近终点 // BASE_DURATION_SEC = 240 → 基准速度 0.417%/s
// 全程最大 boost: 0.417*3.5*60 = 87.5%(理论),实测约 58 秒到右边缘
// 不加速(boost=0): 0.417*60 = 25% → 时间地板推到 55%
// 一般活动(boost≈1): 0.417*2*60 = 50% → 地板 55%
// 活跃玩家(boost≈2): 0.417*3*60 = 75% → 大幅领先
const TOTAL_DISTANCE_PX = 2160; // -120px ~ 2040px const TOTAL_DISTANCE_PX = 2160; // -120px ~ 2040px
const BASE_DURATION_SEC = 220; const BASE_DURATION_SEC = 240;
const BASE_SPEED_PERCENT_PER_SEC = 100 / BASE_DURATION_SEC; // ≈ 0.455%/s 基准速度 const BASE_SPEED_PERCENT_PER_SEC = 100 / BASE_DURATION_SEC; // ≈ 0.417%/s 基准速度
// 马匹帧配置(使用 game3 的马匹) // 马匹帧配置(使用 game3 的马匹)
const HORSE_SIZE = 280; const HORSE_SIZE = 280;
...@@ -73,48 +77,59 @@ for (const config of horseFrameConfigs) { ...@@ -73,48 +77,59 @@ for (const config of horseFrameConfigs) {
const HORSE_FRAME_TICK_MS = 24; // ~40fps const HORSE_FRAME_TICK_MS = 24; // ~40fps
// ========== 瞬时加速脉冲机制 ========== // ========== 速度/加速机制 ==========
// H5 每次摇一摇发送累计积分,PC 端检测 score 增长 → 触发短暂加速脉冲 → 迅速衰减 // H5 每次摇一摇 → WebSocket 推送累计总分 → 按总分比例直接计算 boost
// 快速连摇时脉冲可叠加(每次 +1.0),最高不超过 4.5(即 5.5x 基准速度) // 高分跑快、低分跑慢:boost = (自身分数 / 当前最高分) × MAX_PULSE
const BOOST_PULSE = 1.0; // 每次操作的加速幅度(+100% 额外速度,即 2x 基准 // 最高分玩家 boost=MAX_PULSE=2.5(即 3.5x 基准速度 ≈ 58 秒到右边缘,对标 mock 满速马
const MAX_PULSE = 4.5; // 最大叠加脉冲上限(即 5.5x 基准速度) const MAX_PULSE = 2.5; // 最大加速倍率上限
const BOOST_DECAY = 0.992; // 每帧衰减系数(60fps 下约 3.6s 衰减到 60%,约 9s 衰减归零 const BOOST_DECAY = 0.994; // 每帧衰减系数(停止摇一摇后逐渐减速,半衰期约 1.9s
/** 瞬时加速值: { [userid]: currentBoost },每帧衰减 */ /** 瞬时加速值: { [userid]: currentBoost },每帧衰减(真实 websocket 数据专用) */
const transientBoosts = ref<Record<string, number>>({}); const transientBoosts = ref<Record<string, number>>({});
/** 上一轮各玩家累计积分,用于检测增长 */ /** mock 模式下每匹马的固定全程 boost 值(不衰减),null 表示非 mock 模式 */
const prevScores: Record<string, number> = {}; const mockFixedBoosts = ref<Record<string, number> | null>(null);
/** 检测 rankList 中哪些玩家 score 增长了 → 触发瞬时加速脉冲 */ /**
const detectScoreChanges = (players: Player[]) => { * 根据 rankList 中的累计总分数,按比例直接计算每匹马的 boost(不再使用增量)
* - 找出所有有效玩家中的最高分
* - 每匹马的 boost = (自身分数 / 最高分) × MAX_PULSE
* - 最高分玩家享受 boost=2.5 满速,低分按比例减速
*/
const recalculateBoostsFromScores = (players: Player[]) => {
if (!players || players.length === 0) return; if (!players || players.length === 0) return;
if (mockFixedBoosts.value) return; // mock 模式下不干预
let hasNewBoost = false; // 收集有效玩家及其分数、ID
const updated = { ...transientBoosts.value }; const entries: { id: string; score: number; nickname: string }[] = [];
players.forEach((p, index) => { players.forEach((p, index) => {
// WebSocket 数据无 userid/wechat_id,用 avatar 作为稳定标识 if (!p.nickname || p.nickname === "虚位以待" || p.nickname === "-") return;
const id = p.userid || p.wechat_id || p.avatar || `player-${index}`; if (p.wechat_id && String(p.wechat_id).startsWith("empty-")) return;
if (!id || id.startsWith("empty-")) return; const id = p.userid || p.wechat_id || (p.avatar ? `avt:${p.avatar}` : `horse-${index}`);
const newScore = Number(p.score ?? 0); const score = Number(p.score ?? 0);
const prev = prevScores[id] ?? 0; if (isNaN(score) || score <= 0) return;
entries.push({ id, score, nickname: p.nickname || "" });
if (newScore > prev) { });
// 积分增长 = 玩家又摇了一次 → 叠加瞬时加速脉冲(连摇可累积到 MAX_PULSE)
const current = updated[id] || 0; if (entries.length === 0) return;
updated[id] = Math.min(current + BOOST_PULSE, MAX_PULSE);
hasNewBoost = true; // 当前最高分作为满速基准
console.log( const maxScore = Math.max(...entries.map(e => e.score));
`[Rank1View] 🚀 ${p.nickname} score ${prev}${newScore}, boost: ${current.toFixed(1)}+${BOOST_PULSE}=${updated[id].toFixed(1)}`, if (maxScore <= 0) return;
);
}
prevScores[id] = newScore; const updated: Record<string, number> = {};
let logLines: string[] = [];
entries.forEach(({ id, score, nickname }) => {
// 按比例计算:最高分→2.5,其他人等比缩放
const boost = parseFloat(((score / maxScore) * MAX_PULSE).toFixed(2));
updated[id] = boost;
logLines.push(`${nickname}: score=${score} → boost=${boost.toFixed(2)}`);
}); });
if (hasNewBoost) { transientBoosts.value = updated;
transientBoosts.value = updated; console.log(
} `[Rank1View] 🏇 按总分比例计算 boost(最高分=${maxScore}, MAX_PULSE=${MAX_PULSE}):\n ${logLines.join("\n ")}`,
);
}; };
interface Horse { interface Horse {
...@@ -161,6 +176,11 @@ const fullRankList = computed<RankItem[]>(() => { ...@@ -161,6 +176,11 @@ const fullRankList = computed<RankItem[]>(() => {
/** 倒计时结束后才为 true,控制背景动画和马匹渲染 */ /** 倒计时结束后才为 true,控制背景动画和马匹渲染 */
const gameStarted = ref(false); const gameStarted = ref(false);
/** 游戏已运行时间(ms),用于时间比例最小进度保障(参考 game2) */
const gameElapsedMs = ref(0);
/** 时间比例最小进度百分比:确保无加速时马匹也随倒计时自然推进,但不会喧宾夺主 */
const MIN_PROGRESS_RATIO = 55; // 55% - 纯时间推进确保不落后太多,留给 boost 足够的竞争空间
// const LANE_POSITIONS = [7, 115, 235, 345, 455]; // const LANE_POSITIONS = [7, 115, 235, 345, 455];
const LANE_POSITIONS = [0, 108, 228, 338, 448]; const LANE_POSITIONS = [0, 108, 228, 338, 448];
...@@ -242,8 +262,8 @@ watch( ...@@ -242,8 +262,8 @@ watch(
console.log("[Rank1View] watch rankList 触发,players:", players?.length, JSON.stringify(players?.slice(0, 2))); console.log("[Rank1View] watch rankList 触发,players:", players?.length, JSON.stringify(players?.slice(0, 2)));
if (players && players.length > 0) { if (players && players.length > 0) {
updateHorses(players); updateHorses(players);
// 检测积分增长 → 触发瞬时加速脉冲 // 按总分比例直接计算 boost(高分快、低分慢)
detectScoreChanges(players); recalculateBoostsFromScores(players);
} }
}, },
{ immediate: true, deep: true }, { immediate: true, deep: true },
...@@ -254,43 +274,21 @@ const horseTranslateX = (xPercent: number) => { ...@@ -254,43 +274,21 @@ const horseTranslateX = (xPercent: number) => {
return -120 + (xPercent / 100) * TOTAL_DISTANCE_PX; return -120 + (xPercent / 100) * TOTAL_DISTANCE_PX;
}; };
// ========== 接收 H5 传来的 acceleration / boost 数据 ========== // ========== 接收 H5 传来的 horseBoosts 数据(由 Game4.vue onAny 解析 WebSocket 提供) ==========
// 注意:rankList 的 score 增长检测是主通道,此处作为兜底 // 注意:加速计算已统一由 rankList watch → recalculateBoostsFromScores 处理
watch( // horseBoosts prop 保留用于日后扩展(如直接传递 boost 值),当前不再单独 watch
() => props.horseBoosts,
(boosts) => {
if (!boosts || Object.keys(boosts).length === 0) return;
let hasNew = false;
const updated = { ...transientBoosts.value };
Object.keys(boosts).forEach((id) => {
const val = Number(boosts[id] ?? 0);
if (val > 0) {
// 每次收到有效数据都叠加脉冲(连摇可累积到 MAX_PULSE)
const current = updated[id] || 0;
updated[id] = Math.min(current + BOOST_PULSE, MAX_PULSE);
hasNew = true;
}
});
if (hasNew) {
transientBoosts.value = updated;
console.log(
`[Rank1View] horseBoosts 触发瞬时加速 (${new Date().toLocaleTimeString()}):`,
JSON.parse(JSON.stringify(transientBoosts.value)),
);
}
},
{ deep: true },
);
// ========== 游戏主循环 (requestAnimationFrame 驱动位置 + 帧动画) ========== // ========== 游戏主循环 (requestAnimationFrame 驱动位置 + 帧动画) ==========
let gameRafId: number | undefined; let gameRafId: number | undefined;
let gameLastTs = 0; let gameLastTs = 0;
let boostRecalcTimerMs = 0;
const BOOST_RECALC_INTERVAL_MS = 3000; // 每 3 秒用最新 rankList 重算 boost(防 WebSocket 断流导致 boost 过时)
const startGameLoop = () => { const startGameLoop = () => {
stopGameLoop(); stopGameLoop();
gameLastTs = 0; gameLastTs = 0;
gameElapsedMs.value = 0;
boostRecalcTimerMs = 0;
const tick = (ts: number) => { const tick = (ts: number) => {
if (!isGameRunning.value) { if (!isGameRunning.value) {
...@@ -301,12 +299,35 @@ const startGameLoop = () => { ...@@ -301,12 +299,35 @@ const startGameLoop = () => {
const dt = Math.min(ts - gameLastTs, 200); const dt = Math.min(ts - gameLastTs, 200);
gameLastTs = ts; gameLastTs = ts;
// 累计游戏运行时间(用于时间比例最小进度保障,参考 game2)
gameElapsedMs.value += dt;
const GAME_TOTAL_MS = 60_000; // 总游戏时间 60s
const elapsedRatio = Math.min(gameElapsedMs.value / GAME_TOTAL_MS, 1);
// 时间比例最小进度:确保即使无任何加速数据,马匹也会随倒计时推进
const minProgress = elapsedRatio * MIN_PROGRESS_RATIO;
// 每 3 秒用最新 rankList 重算 boost(兜底:防止 WebSocket 断流导致 boost 过时衰减至 0)
boostRecalcTimerMs += dt;
if (boostRecalcTimerMs >= BOOST_RECALC_INTERVAL_MS) {
boostRecalcTimerMs = 0;
recalculateBoostsFromScores(props.rankList);
}
const newTransientBoosts: Record<string, number> = {}; const newTransientBoosts: Record<string, number> = {};
const fixedBoosts = mockFixedBoosts.value; // mock 模式下的固定 boost(null = 非 mock)
horses.value.forEach((horse) => { horses.value.forEach((horse) => {
// 时间比例最小进度保障:马匹位置不低于 elapsedRatio * MIN_PROGRESS_RATIO%
if (horse.xPercent < minProgress) {
horse.xPercent = minProgress;
}
if (horse.xPercent >= 100) return; if (horse.xPercent >= 100) return;
const boost = transientBoosts.value[horse.id] || 0; // mock 模式:使用固定 boost,不参与衰减;真实模式:使用 transientBoosts 并逐帧衰减
const isMock = fixedBoosts && horse.id in fixedBoosts;
const boost = isMock
? (fixedBoosts[horse.id] ?? 0)
: (transientBoosts.value[horse.id] || 0);
horse.boost = boost; horse.boost = boost;
horse.xPercent = Math.min( horse.xPercent = Math.min(
...@@ -322,9 +343,12 @@ const startGameLoop = () => { ...@@ -322,9 +343,12 @@ const startGameLoop = () => {
horse.frameIndex = (horse.frameIndex + 1) % fc; horse.frameIndex = (horse.frameIndex + 1) % fc;
} }
const decayed = boost * Math.pow(BOOST_DECAY, dt / (1000 / 60)); // mock 马匹 boost 不衰减,真实马匹正常衰减
if (decayed > 0.005) { if (!isMock) {
newTransientBoosts[horse.id] = decayed; const decayed = boost * Math.pow(BOOST_DECAY, dt / (1000 / 60));
if (decayed > 0.005) {
newTransientBoosts[horse.id] = decayed;
}
} }
}); });
...@@ -342,30 +366,30 @@ const stopGameLoop = () => { ...@@ -342,30 +366,30 @@ const stopGameLoop = () => {
} }
}; };
// ========== 本地 mock 加速 ========== // ========== 本地 mock 加速(每匹马分配不同的固定全程 boost,均匀分布 0~MAX_PULSE) ==========
let mockBoostTimer: ReturnType<typeof setInterval> | undefined; let mockBoostTimer: ReturnType<typeof setInterval> | undefined;
const startMockBoost = () => { const startMockBoost = () => {
stopMockBoost(); stopMockBoost();
// 基于马匹顺序计算速度系数:排名靠前的马(小索引)基础快,末尾的慢 const totalHorses = horses.value.length;
const horseRates: Record<string, number> = {}; const fixedBoosts: Record<string, number> = {};
horses.value.forEach((h, i) => {
horseRates[h.id] = 0.3 + (i / Math.max(horses.value.length - 1, 1)) * 0.7; // 按索引均匀分配 boost:第一批 (i=0) boost=0 不加速,最后一批 (i=n-1) boost=MAX_PULSE 满加速
// 满加速马 60s 刚好到 100%,便于观察不同 boost 等级对应的终点位置
horses.value.forEach((horse, i) => {
fixedBoosts[horse.id] =
totalHorses <= 1
? MAX_PULSE / 2
: parseFloat(((i / (totalHorses - 1)) * MAX_PULSE).toFixed(2));
}); });
mockFixedBoosts.value = fixedBoosts;
mockBoostTimer = setInterval(() => { console.log(
if (!isGameRunning.value) return; '[Rank1View] 🎯 固定 mock boost 分配(0 ~ MAX_PULSE 均匀分布):',
const updated = { ...transientBoosts.value }; horses.value.map(h => `${h.nickname}: boost=${fixedBoosts[h.id]?.toFixed(2)} 60s 预计进度=${(BASE_SPEED_PERCENT_PER_SEC * (1 + (fixedBoosts[h.id] ?? 0)) * 60).toFixed(0)}%`).join('\n '),
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));
});
transientBoosts.value = updated;
}, 1500);
}; };
const stopMockBoost = () => { const stopMockBoost = () => {
mockFixedBoosts.value = null;
if (mockBoostTimer) { if (mockBoostTimer) {
clearInterval(mockBoostTimer); clearInterval(mockBoostTimer);
mockBoostTimer = undefined; mockBoostTimer = undefined;
...@@ -435,7 +459,7 @@ const startIntro = async () => { ...@@ -435,7 +459,7 @@ const startIntro = async () => {
console.log("[Rank1View] startIntro 开始,当前 rankList:", props.rankList?.length, JSON.stringify(props.rankList?.slice(0, 2))); console.log("[Rank1View] startIntro 开始,当前 rankList:", props.rankList?.length, JSON.stringify(props.rankList?.slice(0, 2)));
console.log("[Rank1View] 当前 horses 数量:", horses.value.length); console.log("[Rank1View] 当前 horses 数量:", horses.value.length);
// 本地模拟已关闭,如需开启请取消以下注释 // // 本地模拟已关闭,如需开启请取消以下注释
// if ($is_local_mode()) { // if ($is_local_mode()) {
// mockHorses(); // mockHorses();
// } // }
...@@ -445,7 +469,7 @@ const startIntro = async () => { ...@@ -445,7 +469,7 @@ const startIntro = async () => {
isGameRunning.value = false; isGameRunning.value = false;
stopApplauseMusic(); stopApplauseMusic();
transientBoosts.value = {}; transientBoosts.value = {};
Object.keys(prevScores).forEach((k) => delete prevScores[k]); mockFixedBoosts.value = null;
const countdownPromise = designStage.value?.startCountdown(3, 60); const countdownPromise = designStage.value?.startCountdown(3, 60);
await new Promise((r) => setTimeout(r, 3500)); await new Promise((r) => setTimeout(r, 3500));
...@@ -469,9 +493,9 @@ const startIntro = async () => { ...@@ -469,9 +493,9 @@ const startIntro = async () => {
// } // }
startGameLoop(); startGameLoop();
if ($is_local_mode()) { // if ($is_local_mode()) {
startMockBoost(); // startMockBoost();
} // }
await countdownPromise; await countdownPromise;
stopGame(); stopGame();
...@@ -666,7 +690,7 @@ defineExpose({ ...@@ -666,7 +690,7 @@ defineExpose({
top: 0; top: 0;
left: 0; left: 0;
display: flex; display: flex;
animation: bg-scroll-left 20s linear infinite; animation: bg-scroll-left 22s linear infinite;
z-index: 1; z-index: 1;
} }
...@@ -685,7 +709,7 @@ defineExpose({ ...@@ -685,7 +709,7 @@ defineExpose({
bottom: 0; bottom: 0;
left: 0; left: 0;
display: flex; display: flex;
animation: bg-scroll-left 20s linear infinite; animation: bg-scroll-left 22s linear infinite;
z-index: 2; z-index: 2;
} }
......
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