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

fix: 修复大屏马的生成数据和动画;

parent 2a8c14a9
......@@ -55,36 +55,48 @@ const renderPlayers = (data: any) => {
}
const updateRankPlayers = (data: any) => {
if (!Array.isArray(data)) return
// 兼容多种数据格式:纯数组 / {list:[]} / {players:[]} / {data:[]}
let arr: any[] | null = null;
if (Array.isArray(data)) {
arr = data;
} else if (Array.isArray(data?.list)) {
arr = data.list;
} else if (Array.isArray(data?.players)) {
arr = data.players;
} else if (Array.isArray(data?.data)) {
arr = data.data;
}
rankPlayers.value = Array.from(
{ length: RANK_LIMIT },
(_, index) => data[index] ?? emptyRankPlayer(index),
)
}
if (!arr || arr.length === 0) {
console.warn('[game3] updateRankPlayers: 无法解析排行数据', data);
return;
}
const playerToRankPlayer = (player: any, index: number): Player => ({
telephone: player?.telephone ?? '',
userid: player?.userid ?? '',
wechat_id: player?.wechat_id || `empty-${index}`,
nickname: player?.nickname || '虚位以待',
avatar: player?.avatar || '',
score: player?.score ?? '-',
})
// 打印第一条原始数据的所有字段名,用于排查字段不匹配问题
console.log('[game3] updateRankPlayers: 收到排行数据', arr.length, '条, 第1条字段:', Object.keys(arr[0] || {}), ', 第1条值:', JSON.parse(JSON.stringify(arr[0])));
// 将 WS 数据标准化为 Player 格式(同时保留 userid / wechat_id)
// ⚠️ 当 WS 返回的数据没有 userid/openid/wechat_id 等ID字段时,用 avatar 作为唯一标识
const normalized = arr.map((item: any, i: number) => {
const hasId = item.userid || item.openid || item.user_id || item.wechat_id;
const avatar = item.avatar || item.headimgurl || '';
const nickname = item.nickname || item.name || '虚位以待';
const isRealPlayer = nickname && nickname !== '-' && nickname !== '虚位以待';
const wechat_id = hasId || (isRealPlayer && avatar ? `avt:${avatar}` : `empty-${i}`);
return {
...item,
userid: hasId || (isRealPlayer && avatar ? `avt:${avatar}` : ''),
wechat_id,
nickname,
avatar,
score: item.score ?? 0,
};
});
const seedRankPlayersFromLoading = () => {
rankPlayers.value = Array.from(
{ length: RANK_LIMIT },
(_, index) => {
const player = loadingPlayers.value[index]
if (!player?.userid && !player?.wechat_id) {
return emptyRankPlayer(index)
}
return playerToRankPlayer(player, index)
},
)
(_, index) => normalized[index] ?? emptyRankPlayer(index),
);
}
const resetRoundState = () => {
......@@ -107,7 +119,7 @@ const gotoRank1WithIntro = async () => {
if (isPlayingStartAnimation) return
isPlayingStartAnimation = true
seedRankPlayersFromLoading()
// ⚠️ 不要用 seedRankPlayersFromLoading,由 WS room_rank_result 驱动马匹数据
screen.value = 'rank1'
await nextTick()
await rank1Ref.value?.startIntro()
......@@ -129,8 +141,16 @@ function handleRoomState(data: any) {
gotoLoading()
break
case 2:
seedRankPlayersFromLoading()
// 重连/中途进房时游戏已在运行,直接进入赛马画面
if (isPlayingStartAnimation) break;
isPlayingStartAnimation = true;
screen.value = 'rank1'
console.log('[game3] handleRoomState: status=2, 直接启动赛马动画');
nextTick(() => {
rank1Ref.value?.startIntro().finally(() => {
isPlayingStartAnimation = false;
});
});
break
case 3:
screen.value = 'rank2'
......@@ -143,24 +163,58 @@ const { startGame, createRoom, backRoom, closeRoom } = useAdminGameSocket({
onAny: (evt, payload) => {
// 兜底处理所有非标准事件,解析玩家实时分数(对齐 game4)
const data = payload?.data ?? payload;
if (!Array.isArray(data)) return;
// 尝试从 data 中提取数组
let arr: any[] | null = null;
if (Array.isArray(data)) {
arr = data;
} else if (Array.isArray(data?.list)) {
arr = data.list;
} else if (Array.isArray(data?.data)) {
arr = data.data;
}
if (arr && arr.length > 0) {
const boosts: Record<string, number> = {};
let hasBoost = false;
const normalized = arr.map((item: any, i: number) => {
const hasId = item.userid || item.openid || item.user_id || item.wechat_id;
const avatar = item.avatar || item.headimgurl || '';
const nickname = item.nickname || item.name || '虚位以待';
const isRealPlayer = nickname && nickname !== '-' && nickname !== '虚位以待';
const wechat_id = hasId || (isRealPlayer && avatar ? `avt:${avatar}` : `empty-${i}`);
return {
...item,
userid: hasId || (isRealPlayer && avatar ? `avt:${avatar}` : ''),
wechat_id,
};
});
data.forEach((item: any) => {
const id = item.userid || item.wechat_id || item.avatar || "";
if (!id) return;
const hasAnyPlayer = normalized.some((item: any) => {
const id = item.userid || item.wechat_id || '';
return !!id && !id.startsWith('empty-');
});
if (hasAnyPlayer) {
// 同时更新 rankPlayers(确保马匹列表有真实玩家)
rankPlayers.value = Array.from(
{ length: RANK_LIMIT },
(_, index) => normalized[index] ?? emptyRankPlayer(index),
);
}
normalized.forEach((item: any) => {
const id = item.userid || item.wechat_id || '';
if (!id || id.startsWith('empty-')) return;
const raw = item.score ?? item.boost ?? item.acceleration ?? item.speed ?? item.progress;
if (raw === undefined || raw === null) return;
const val = Number(raw);
if (isNaN(val)) return;
boosts[id] = Math.max(0, val);
hasBoost = true;
});
if (hasBoost) {
horseBoosts.value = { ...boosts };
if (Object.keys(boosts).length > 0) {
horseBoosts.value = { ...horseBoosts.value, ...boosts };
}
}
},
onRoomState: handleRoomState,
......@@ -232,5 +286,7 @@ watch(screen, (value) => {
<template>
<Loading v-if="screen === 'loading'" :players="loadingPlayers" :player-count="playerCount"
@start="startGameWithMusic" @back="backHandler" />
<Rank1 v-else ref="rank1Ref" :rank-list="rankPlayers" :horse-boosts="horseBoosts" @start="startGameWithMusic" @next="nextRoundWithMusic" @back="backHandler" />
<Rank1 v-if="screen === 'rank1'" ref="rank1Ref" :rank-list="rankPlayers" :horse-boosts="horseBoosts"
@start="startGameWithMusic" @next="nextRoundWithMusic" @back="backHandler" />
<Rank2 v-if="screen === 'rank2'" :rank-list="rankPlayers" @next="nextRoundWithMusic" @back="backHandler" />
</template>
......@@ -44,6 +44,7 @@ const imageUrls = {
const HORSE_SIZE = 280;
const HORSE_START_X = -HORSE_SIZE;
const HORSE_END_X = 1920;
const HORSE_Y_OFFSET = 80; // 马匹整体向下偏移,补偿帧图片中马匹偏上的空白区域
const horseFrameConfigs = [
{ type: 1, dir: 'game3/ma1', prefix: '1ma_', startIndex: 24, count: 12 },
......@@ -68,11 +69,13 @@ interface Horse {
lane: number;
frameIndex: number;
frameTimer: number;
x: number;
targetX: number;
x: number; // 像素位置(用于渲染 translateX)
xPercent: number; // 进度百分比 0~100(对齐 game4 时间驱动模型)
boost: number; // 瞬时加速值,每帧衰减
nickname: string;
avatar: string;
wechat_id: string; // 用于匹配 WebSocket 实时排名数据
/** 稳定标识,优先 wechat_id 再 userid,用于匹配 WebSocket 数据 */
playerKey: string;
}
const horses = ref<Horse[]>([]);
......@@ -87,21 +90,63 @@ const emptyPlayer = (index: number): Player => ({
});
const list = ref<Player[]>(Array.from({ length: RANK_LIMIT }, (_, index) => emptyPlayer(index)));
const playerKey = (player: Player, index = 0) => player.userid || player.wechat_id || `empty-${index}`;
const updateRankList = (rankList: Player[]) => {
const next = Array.from({ length: RANK_LIMIT }, (_, index) => rankList[index] ?? emptyPlayer(index));
list.value = next;
};
// 玩家实时分数(合并 rankList + horseBoosts 双通道,对齐 game4 模式)
const liveScores = ref<Record<string, number>>({});
// ========== 瞬时加速脉冲(对齐 game4 detectScoreChanges) ==========
const transientBoosts = ref<Record<string, number>>({});
const prevScores: Record<string, number> = {};
/** 获取玩家的稳定标识(优先 wechat_id,其次 userid,最后用 avatar 回退) */
const getPlayerKey = (player: Player, index = 0): string => {
const key = player?.wechat_id || player?.userid || '';
if (key && !key.startsWith('empty-')) return key;
// 如果 WS 数据没有ID字段,用 avatar URL 作为唯一标识(每个用户头像各不相同)
if (player?.nickname && player.nickname !== '-' && player.nickname !== '虚位以待' && player?.avatar) {
return `avt:${player.avatar}`;
}
return `empty-${index}`;
};
/** 检测 rankList 中哪些玩家 score 增长了 → 触发瞬时加速脉冲 */
const detectScoreChanges = (players: Player[]) => {
if (!players || players.length === 0) return;
let hasNewBoost = false;
const updated = { ...transientBoosts.value };
players.forEach((p, index) => {
const key = getPlayerKey(p, index);
if (key.startsWith('empty-')) return;
const newScore = Number(p.score ?? 0);
if (isNaN(newScore)) return;
const prev = prevScores[key] ?? 0;
if (newScore > prev) {
const current = updated[key] || 0;
updated[key] = Math.min(current + BOOST_PULSE, MAX_BOOST);
hasNewBoost = true;
console.log(`[game3 Rank1View] 🚀 ${p.nickname} score ${prev}${newScore}, boost: ${updated[key].toFixed(1)}`);
}
prevScores[key] = newScore;
});
if (hasNewBoost) {
transientBoosts.value = updated;
}
};
watch(
() => props.rankList,
(rankList) => {
console.log('[game3 Rank1View] websocket rankList 数据:', JSON.parse(JSON.stringify(rankList)));
console.log('[game3 Rank1View] websocket rankList 数据:', JSON.parse(JSON.stringify(rankList?.slice(0, 3))));
updateRankList(rankList);
// 检测积分增长 → 触发瞬时加速脉冲(对齐 game4)
detectScoreChanges(rankList);
// 游戏进行中,实时更新马匹玩家列表(处理加入/离开)
if (isGameRunning.value) {
updateHorses(rankList);
......@@ -110,32 +155,63 @@ watch(
{ immediate: true, deep: true },
);
// 直接监听 list,确保游戏运行时任何 list 变化都触发马匹刷新
watch(
list,
(newList) => {
const validCount = newList.filter(p => getPlayerKey(p).length > 0 && !getPlayerKey(p).startsWith('empty-')).length;
console.log('[game3 Rank1View] list 变化, 有效玩家数:', validCount, 'isGameRunning:', isGameRunning.value);
if (isGameRunning.value && validCount > 0) {
updateHorses(newList);
}
},
{ deep: true },
);
watch(
() => props.horseBoosts,
(boosts) => {
if (!boosts || Object.keys(boosts).length === 0) return;
const merged = { ...liveScores.value };
for (const [id, score] of Object.entries(boosts)) {
// 取较大的值,因为不同通道可能收到不同时间点的数据
if (score > (merged[id] ?? 0)) {
merged[id] = score;
let hasNew = false;
const updated = { ...transientBoosts.value };
Object.entries(boosts).forEach(([id, val]) => {
const score = Number(val ?? 0);
if (score > 0) {
const current = updated[id] || 0;
updated[id] = Math.min(current + BOOST_PULSE, MAX_BOOST);
hasNew = true;
}
});
if (hasNew) {
transientBoosts.value = updated;
console.log('[game3 Rank1View] horseBoosts 触发加速:', JSON.parse(JSON.stringify(transientBoosts.value)));
}
liveScores.value = merged;
},
{ deep: true },
);
// ========== 马匹动画循环 (RAF 驱动帧切换 + 分数驱动位移) ==========
// ========== 速度基准(对齐 game4 时间驱动模型) ==========
// 基础速度大幅下调,确保马匹在 60s 游戏时间内不会过早跑出屏幕
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
// 瞬时加速脉冲机制(对齐 game4)
const BOOST_PULSE = 0.3; // 每次 score 增长对应的额外速度倍率
const MAX_BOOST = 3.0; // 最大加速倍率
const BOOST_DECAY = 0.992; // 每帧衰减系数(60fps 约 3.6s 到 60%)
// ========== 马匹动画循环 (RAF 驱动:时间驱动位移 + 帧动画) ==========
let horseRafId: number | undefined;
let horseLastTs = 0;
const HORSE_FRAME_TICK_MS = 48;
// 慢速 lerp,分数变化时马匹平滑过渡(约 2.5s 拉到位)
const HORSE_LERP_SPEED = 2.5;
const startHorseLoop = () => {
stopHorseLoop();
horseLastTs = 0;
let logCounter = 0;
const tick = (ts: number) => {
if (!isGameRunning.value) {
......@@ -147,36 +223,29 @@ const startHorseLoop = () => {
horseLastTs = ts;
if (horses.value.length === 0) {
if (logCounter++ % 60 === 0) {
console.log('[game3 Rank1View] RAF: horses 为空, gameStarted:', gameStarted.value, 'isGameRunning:', isGameRunning.value);
}
horseRafId = requestAnimationFrame(tick);
return;
}
// 优先用 liveScores(onAny 实时数据),回退到 list 分数
const getScore = (wechatId: string): number => {
const liveScore = liveScores.value[wechatId];
if (typeof liveScore === 'number' && liveScore > 0) return liveScore;
const player = list.value.find(p => p.wechat_id === wechatId);
return typeof player?.score === 'number' ? player.score : 0;
};
const newBoosts: Record<string, number> = {};
// 计算当前最高分
let maxScore = 0;
for (const horse of horses.value) {
const s = getScore(horse.wechat_id);
if (s > maxScore) maxScore = s;
}
if (maxScore <= 0) maxScore = 1;
if (horse.xPercent >= 100) continue;
for (const horse of horses.value) {
const score = getScore(horse.wechat_id);
// 从 transientBoosts 读取实时加速值
const boost = transientBoosts.value[horse.playerKey] || 0;
horse.boost = boost;
// 进度 = 最小 5% + 根据分数占比最大 95%
const progress = 0.05 + (score / maxScore) * 0.95;
horse.targetX = HORSE_START_X + (HORSE_END_X - HORSE_START_X) * progress;
// 基础速度 + 加速 → 推进百分比
const effectiveSpeed = BASE_SPEED_PX_PER_SEC * (1 + boost);
const deltaPx = effectiveSpeed * (dt / 1000);
horse.xPercent = Math.min(100, horse.xPercent + (deltaPx / TOTAL_DISTANCE_PX) * 100);
// 平滑 lerp,分数变化不跳变
const lerpFactor = 1 - Math.pow(0.001, dt / (1000 * HORSE_LERP_SPEED));
horse.x += (horse.targetX - horse.x) * lerpFactor;
// 像素位置 = 起始位置 + 百分比 * 总距离
horse.x = HORSE_START_X + (horse.xPercent / 100) * TOTAL_DISTANCE_PX;
// 帧动画
const fc = horseFrameCount[horse.horseType] ?? 12;
......@@ -185,8 +254,15 @@ const startHorseLoop = () => {
horse.frameTimer -= HORSE_FRAME_TICK_MS;
horse.frameIndex = (horse.frameIndex + 1) % fc;
}
// 加速衰减
const decayed = boost * Math.pow(BOOST_DECAY, dt / (1000 / 60));
if (decayed > 0.001) {
newBoosts[horse.playerKey] = decayed;
}
}
transientBoosts.value = newBoosts;
horseRafId = requestAnimationFrame(tick);
};
horseRafId = requestAnimationFrame(tick);
......@@ -204,23 +280,27 @@ const LANE_TOP_POSITIONS = [480, 640, 800];
const updateHorses = (players: Player[]) => {
const validPlayers = players.filter(p => {
if (!p || !p.wechat_id) return false;
if (p.wechat_id.startsWith('empty-')) return false;
const key = getPlayerKey(p);
if (!key || key.startsWith('empty-')) return false;
if (p.nickname === '-' || p.nickname === '虚位以待') return false;
return true;
});
console.log('[game3 Rank1View] updateHorses: 输入', players.length, '条, 有效', validPlayers.length, '条',
validPlayers.slice(0, 3).map(p => ({ key: getPlayerKey(p), name: p.nickname, score: p.score })));
if (validPlayers.length === 0) {
console.warn('[game3 Rank1View] updateHorses: 无有效玩家,horses 清空');
horses.value = [];
return;
}
// 保留已有马匹的 x 位置,防止重新生成时位置跳变
const existingMap = new Map(horses.value.map(h => [h.wechat_id, h]));
// 保留已有马匹的 x/xPercent/boost,防止重新生成时位置跳变
const existingMap = new Map(horses.value.map(h => [h.playerKey, h]));
horses.value = validPlayers.map((player, index) => {
const wid = player.wechat_id || '';
const existing = existingMap.get(wid);
const key = getPlayerKey(player, index);
const existing = existingMap.get(key);
return {
id: existing?.id || `horse-${++horseIdCounter}`,
horseType: HORSE_TYPES[index % HORSE_TYPES.length]!,
......@@ -228,12 +308,15 @@ const updateHorses = (players: Player[]) => {
frameIndex: existing?.frameIndex ?? Math.floor(Math.random() * (horseFrameCount[HORSE_TYPES[index % HORSE_TYPES.length]!] ?? 12)),
frameTimer: existing?.frameTimer ?? 0,
x: existing?.x ?? HORSE_START_X,
targetX: existing?.targetX ?? HORSE_START_X,
xPercent: existing?.xPercent ?? 0,
boost: existing?.boost ?? 0,
nickname: player.nickname || '',
avatar: player.avatar || '',
wechat_id: wid,
playerKey: key,
};
});
console.log('[game3 Rank1View] updateHorses: 生成', horses.value.length, '匹马');
};
// ========== 预加载马匹帧 ==========
......@@ -248,17 +331,23 @@ const preloadHorseFrames = () => {
// ========== 游戏流程 ==========
const startIntro = async () => {
console.log('[game3 Rank1View] startIntro 开始');
showResult.value = false;
gameStarted.value = false;
isGameRunning.value = false;
horses.value = [];
horseIdCounter = 0;
// 重置加速状态
transientBoosts.value = {};
Object.keys(prevScores).forEach(k => delete prevScores[k]);
stopApplauseMusic();
preloadHorseFrames();
const countdownPromise = designStage.value?.startCountdown(3, 60);
await new Promise((r) => setTimeout(r, 3500));
const validCount = list.value.filter(p => getPlayerKey(p).length > 0 && !getPlayerKey(p).startsWith('empty-')).length;
console.log('[game3 Rank1View] startIntro: 3.5s 后, list 有效玩家数:', validCount);
gameStarted.value = true;
isGameRunning.value = true;
updateHorses(list.value);
......@@ -300,27 +389,27 @@ onMounted(() => {
if ($is_run_local()) {
// 本地模式:用模拟玩家填充 list,驱动马匹渲染
list.value = [
{ wechat_id: 'local-1', nickname: '骑手一', avatar: '', score: 45 },
{ wechat_id: 'local-2', nickname: '骑手二', avatar: '', score: 38 },
{ wechat_id: 'local-3', nickname: '骑手三', avatar: '', score: 30 },
{ wechat_id: 'local-4', nickname: '骑手四', avatar: '', score: 22 },
{ wechat_id: 'local-5', nickname: '骑手五', avatar: '', score: 15 },
{ userid: 'local-1', nickname: '骑手一', avatar: '', score: 45 },
{ userid: 'local-2', nickname: '骑手二', avatar: '', score: 38 },
{ userid: 'local-3', nickname: '骑手三', avatar: '', score: 30 },
{ userid: 'local-4', nickname: '骑手四', avatar: '', score: 22 },
{ userid: 'local-5', nickname: '骑手五', avatar: '', score: 15 },
];
gameStarted.value = true;
isGameRunning.value = true;
updateHorses(list.value);
startHorseLoop();
// 模拟分数变化
// 模拟分数变化 → 驱动 detectScoreChanges
let simTick = 0;
const simInterval = setInterval(() => {
if (!isGameRunning.value) { clearInterval(simInterval); return; }
simTick++;
// 随机涨分
for (const p of list.value) {
if (typeof p.score === 'number') {
p.score += Math.floor(Math.random() * 3);
}
}
detectScoreChanges(list.value);
}, 1500);
}
});
......@@ -351,7 +440,7 @@ onUnmounted(() => {
:key="horse.id"
class="horse-wrapper"
:style="{
top: `${LANE_TOP_POSITIONS[horse.lane]}px`,
top: `${(LANE_TOP_POSITIONS[horse.lane] ?? 560) + HORSE_Y_OFFSET}px`,
transform: `translateX(${horse.x}px)`,
}"
>
......@@ -556,7 +645,7 @@ onUnmounted(() => {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-left: 4px;
padding-left: 6px;
box-sizing: border-box;
}
......
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