Commit 8f17f3eb authored by 董政锦's avatar 董政锦

Merge branch '20260609-test' into 'main'

20260609 test See merge request !9
parents 40a1a7b0 8e75da47
{
"clawMode.mode": "editor"
}
\ No newline at end of file
......@@ -176,14 +176,12 @@ onBeforeUnmount(() => {
}
})
// 点击马触发:iOS 先请求权限 + 触发射分
// 点击马触发:iOS 先请求权限(不触发得分)
const onTapHorse = async () => {
if (!motionPermissionGranted) {
await requestMotionPermission()
// 权限请求完成(无论结果)隐藏 iOS 提示
showIOSHint.value = false
}
emit('touch')
}
</script>
......
......@@ -308,7 +308,7 @@ const rankCloseHandler = () => {
<div class="row row-3">
<div class="col-1">{{ rank > 0 ? rank : '未上榜' }}</div>
<div class="col-2">
<div class="avatar" :style="{ backgroundImage: `url(${avatar})` }"></div>
<div class="avatar" :style="{ backgroundImage: `url(${avatar})`,top: '27px' }"></div>
<div>{{ nickname }}</div>
</div>
<div class="col-3">{{ tick }}</div>
......
......@@ -20,11 +20,11 @@ onMounted(() => {
<div class="game-desc" @click="$emit('touchGameRule')">游戏规则</div>
<div class="img-logo"></div>
<div class="img-title"></div>
<div class="img-avatar" :style="`background: url(${avatar});border-radius:66px;width:132px;height:132px;`">
</div>
<!-- <div class="img-avatar" :style="`background: url(${avatar});border-radius:66px;width:132px;height:132px;`">
</div> -->
<div class="txt-contdown">游戏等待开始倒计时&nbsp;<b style="font-weight: 900;">60</b>&nbsp;</div>
<!-- <div class="txt-contdown"></div> -->
<div class="txt-nickname">{{ nickname }}</div>
<!-- <div class="txt-nickname">{{ nickname }}</div> -->
<div class="txt-loading">您已成功加入游戏等待主持人开始</div>
<div class="txt-loading2">游戏即将开始 敬请期待</div>
<!-- <div class="bg-bottom"></div> -->
......
......@@ -41,8 +41,8 @@ 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 HORSE_SPEED_MIN = 30 // px/s 初始速度
const HORSE_SPEED_MAX = 200 // px/s 最大速度(60s 时,7倍提升)
const SPAWN_MIN_START = 3000 // ms 初始最短生成间隔
const SPAWN_MIN_END = 800 // ms 最终最短生成间隔
const SPAWN_MAX_START = 6000 // ms 初始最长生成间隔
......@@ -103,12 +103,8 @@ let gameElapsed = 0 // 游戏已进行时间 ms(用于难度递增)
const HIT_EFFECT_MS = 800 // "+10" 动画持续时长
// ── 游戏状态 ──
const gameStarted = ref(false) // 3-2-1 倒计时完成标志,控制动画启停
const gameStarted = ref(true) // MobileStage 已完成 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
......@@ -350,39 +346,6 @@ function preloadFrames() {
}
}
// ── 开场 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) {
......@@ -396,24 +359,22 @@ onMounted(() => {
preloadFrames()
// 先启动音频上下文(需用户首次交互后 resume,此处尝试提前激活)
try { getAudioContext() } catch { /* ignore */ }
// 开场 3-2-1 倒计时
startPreGameCountdown()
// MobileStage 已完成 3-2-1,游戏直接开始
prevTs = performance.now()
spawnHorse()
setTimeout(spawnHorse, 800)
setTimeout(spawnHorse, 1600)
raf = requestAnimationFrame(loop)
})
onBeforeUnmount(() => {
cancelAnimationFrame(raf)
stopPreCountdown()
disposeAudioContext()
})
</script>
<template>
<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>
......@@ -492,38 +453,6 @@ onBeforeUnmount(() => {
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;
......@@ -648,7 +577,7 @@ onBeforeUnmount(() => {
height: 100%;
background: v-bind('imageUrls.paodao') repeat-x;
background-size: auto 100%;
animation: runway-scroll 8s linear infinite;
animation: runway-scroll 12s linear infinite;
}
@keyframes runway-scroll {
......
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
import game1MusicUrl from '@/assets/images/game1.mp3'
import game4MusicUrl from '@/assets/images/game1.mp3' // 需要改成正式的路径
import game5MusicUrl from '@/assets/images/game1.mp3' // 需要改成正式的路径
import game4MusicUrl from '@/assets/images/cmyf-bgm.mp3'
import game5MusicUrl from '@/assets/images/qcnf-bgm.mp3'
import game6MusicUrl from '@/assets/images/game6.mp3'
import go321 from '@/assets/images/321go.mp3'
import applause from '@/assets/images/applause.mp3'
......@@ -117,7 +117,7 @@ export function stopGame4Music() {
}
export async function playGame5Music() {
const audio = getBackgroundMusic(game4MusicUrl)
const audio = getBackgroundMusic(game5MusicUrl)
try {
await audio.play()
......
......@@ -111,7 +111,7 @@ const gotoRank1WithIntro = async () => {
screen.value = 'rank1'
await nextTick()
await rank1Ref.value?.startIntro()
screen.value = 'rank2'
// 结果页已在 Rank1View 内部渲染(showResult + result-overlay),不需要切到 rank2
isPlayingStartAnimation = false
}
......@@ -176,7 +176,7 @@ const { startGame, createRoom, backRoom, closeRoom } = useAdminGameSocket({
screen.value = 'rank1'
break
case 3:
screen.value = 'rank2'
screen.value = 'rank1'
break
}
},
......@@ -437,6 +437,6 @@ watch(screen, (value) => {
<Loading v-if="screen === 'loading'" :players="loadingPlayers" :player-count="playerCount"
@start="startGameWithMusic" @back="backHandler" />
<Rank1 v-if="screen === 'rank1'" ref="rank1Ref" :rank-list="rankPlayers" :horse-boosts="horseBoosts"
@start="startGameWithMusic" @next="nextRound" />
@start="startGameWithMusic" @next="nextRound" @back="backHandler" />
<!-- <Rank2 v-else-if="screen === 'rank2'" :rank-list="rankPlayers" @next="nextRound" /> -->
</template>
......@@ -4,6 +4,7 @@ import DesignStage from "../../../components/DesignStage.vue";
import type Player from "@/commons/player.ts";
import { $is_run_local } from "@/commons/utils.ts";
import { cssAssetUrl, assetUrl } from "@/commons/assets.ts";
import { playApplauseMusic, stopApplauseMusic } from "@/commons/music";
const defaultAvatarUrl = assetUrl("game4/default-avator.svg");
const props = defineProps<{
......@@ -15,6 +16,7 @@ const props = defineProps<{
const emit = defineEmits<{
start: [];
next: [];
back: [];
}>();
const designStage = ref<InstanceType<typeof DesignStage> | null>(null);
......@@ -26,33 +28,41 @@ const imageUrls = {
username: cssAssetUrl("game4/username.svg"),
avatar: cssAssetUrl("game4/default-avator.svg"),
resultBg: cssAssetUrl("game4/resultBg.svg"),
next: cssAssetUrl("game4/next-1.svg"),
next: cssAssetUrl("game1/next.png"),
back: cssAssetUrl("game1/back.png"),
// 结果弹窗 - podium 相关资源(复用 game1)
podium: cssAssetUrl("game4/podium.svg"),
fireworks: cssAssetUrl("game1/fireworks.png"),
guan1: cssAssetUrl("game1/guan1.png"),
pai1: cssAssetUrl("game4/pai1.svg"),
guan2: cssAssetUrl("game1/guan3.png"),
guan2: cssAssetUrl("game1/guan2.png"),
pai2: cssAssetUrl("game4/pai2.svg"),
guan3: cssAssetUrl("game1/guan2.png"),
guan3: cssAssetUrl("game1/guan3.png"),
pai3: cssAssetUrl("game4/pai3.svg"),
rankIcon: cssAssetUrl("game1/no-icon.png"),
avatarAnimation: cssAssetUrl("avatar_animation.png"),
};
// ========== 速度基准:330秒从屏幕左侧跑到右侧 ==========
// 60s 游戏 × 基准速度(0.303%/s) = 18.2% → 不加速几乎原地
// 60s 游戏 × 最大5.5x(1.667%/s) = 100% → 全程猛摇刚好到达终点
// ========== 速度基准 ==========
// 60s 游戏 × 基准速度(0.455%/s) = 27.3% → 不加速跑约 1/4 路程
// 60s 游戏 × 最大3.5x(1.59%/s) = 95.5% → 猛摇才能接近终点
const TOTAL_DISTANCE_PX = 2160; // -120px ~ 2040px
const BASE_DURATION_SEC = 330;
const BASE_SPEED_PERCENT_PER_SEC = 100 / BASE_DURATION_SEC; // ≈ 0.303%/s 基准速度
const BASE_DURATION_SEC = 220;
const BASE_SPEED_PERCENT_PER_SEC = 100 / BASE_DURATION_SEC; // ≈ 0.455%/s 基准速度
// 马匹精灵图帧动画(horse.png 共 12 帧,改用 JS 切帧,避免 CSS steps 在部分浏览器不生效)
const HORSE_FRAME_COUNT = 12;
const HORSE_FRAME_TICK_MS = 48; // ~20fps
const HORSE_SPRITE_OFFSET_X = -17;
const HORSE_SPRITE_FRAME_WIDTH = 120;
const horseSpriteUrl = assetUrl("game4/horse.png");
// ========== 瞬时加速脉冲机制 ==========
// H5 每次摇一摇发送累计积分,PC 端检测 score 增长 → 触发短暂加速脉冲 → 迅速衰减
// 快速连摇时脉冲可叠加(每次 +1.0),最高不超过 4.5(即 5.5x 基准速度)
const BOOST_PULSE = 1.0; // 每次操作的加速幅度(+100% 额外速度,即 2x 基准)
const MAX_PULSE = 4.5; // 最大叠加脉冲上限(即 5.5x 基准速度)
const BOOST_DECAY = 0.985; // 每帧衰减系数(60fps 下约 1.5s 衰减到 40%,约 3s 衰减归零)
const BOOST_DECAY = 0.992; // 每帧衰减系数(60fps 下约 3.6s 衰减到 60%,约 9s 衰减归零)
/** 瞬时加速值: { [userid]: currentBoost },每帧衰减 */
const transientBoosts = ref<Record<string, number>>({});
......@@ -99,6 +109,8 @@ interface Horse {
boost: number; // boost=0 按基准60秒跑完,boost越大越快
nickname: string;
avatar: string;
frameIndex: number;
frameTimer: number;
}
interface RankItem {
......@@ -156,6 +168,8 @@ const updateHorses = (players: Player[]) => {
boost: existing?.boost ?? 0,
nickname: player.nickname,
avatar: player.avatar || "",
frameIndex: existing?.frameIndex ?? Math.floor(Math.random() * HORSE_FRAME_COUNT),
frameTimer: existing?.frameTimer ?? 0,
};
});
};
......@@ -217,49 +231,60 @@ watch(
{ deep: true },
);
// ========== 游戏主循环 (~60fps) ==========
let gameLoopTimer: ReturnType<typeof setInterval> | undefined;
const TICK_MS = 16;
const TICK_SEC = TICK_MS / 1000;
// ========== 游戏主循环 (requestAnimationFrame 驱动位置 + 帧动画) ==========
let gameRafId: number | undefined;
let gameLastTs = 0;
const startGameLoop = () => {
stopGameLoop();
gameLoopTimer = setInterval(() => {
if (!isGameRunning.value) return;
gameLastTs = 0;
const tick = (ts: number) => {
if (!isGameRunning.value) {
gameRafId = requestAnimationFrame(tick);
return;
}
if (!gameLastTs) gameLastTs = ts;
const dt = Math.min(ts - gameLastTs, 200);
gameLastTs = ts;
const newTransientBoosts: Record<string, number> = {};
horses.value.forEach((horse) => {
if (horse.xPercent >= 100) return;
// 瞬时加速值(来自积分增长脉冲,每帧衰减中)
const boost = transientBoosts.value[horse.id] || 0;
horse.boost = boost;
// 实际速度 = 基准速度 × (1 + 瞬时boost)
// 最大5.5x时刚好在60s游戏结束时到达100%
horse.xPercent = Math.min(
100,
horse.xPercent +
BASE_SPEED_PERCENT_PER_SEC * (1 + boost) * TICK_SEC,
BASE_SPEED_PERCENT_PER_SEC * (1 + boost) * (dt / 1000),
);
// 衰减瞬时加速:每帧 × 衰减系数
const decayed = boost * BOOST_DECAY;
horse.frameTimer += dt;
while (horse.frameTimer >= HORSE_FRAME_TICK_MS) {
horse.frameTimer -= HORSE_FRAME_TICK_MS;
horse.frameIndex = (horse.frameIndex + 1) % HORSE_FRAME_COUNT;
}
const decayed = boost * Math.pow(BOOST_DECAY, dt / (1000 / 60));
if (decayed > 0.005) {
newTransientBoosts[horse.id] = decayed;
}
});
// 更新衰减后的加速值
transientBoosts.value = newTransientBoosts;
}, TICK_MS);
gameRafId = requestAnimationFrame(tick);
};
gameRafId = requestAnimationFrame(tick);
};
const stopGameLoop = () => {
if (gameLoopTimer) {
clearInterval(gameLoopTimer);
gameLoopTimer = undefined;
if (gameRafId) {
cancelAnimationFrame(gameRafId);
gameRafId = undefined;
}
};
......@@ -321,6 +346,9 @@ const stopGame = () => {
stopGameLoop();
stopMockBoost();
// 播放喝彩音效
playApplauseMusic();
// 计算最终排名
finalRankList.value = computeFinalRank();
......@@ -345,6 +373,7 @@ const startIntro = async () => {
finalRankList.value = [];
gameStarted.value = false;
isGameRunning.value = false;
stopApplauseMusic();
// 重置瞬时加速和积分追踪
transientBoosts.value = {};
Object.keys(prevScores).forEach((k) => delete prevScores[k]);
......@@ -373,6 +402,17 @@ const horseAvatarStyle = (horse: Horse) => {
return { backgroundImage: `url(${horse.avatar})`, backgroundSize: "cover" };
};
const horseSpriteStyle = (horse: Horse) => ({
backgroundImage: `url(${horseSpriteUrl})`,
backgroundPosition: `${HORSE_SPRITE_OFFSET_X - horse.frameIndex * HORSE_SPRITE_FRAME_WIDTH}px 0`,
backgroundSize: "1472px 109px",
});
const preloadHorseSprite = () => {
const img = new Image();
img.src = horseSpriteUrl;
};
// 结果弹窗头像渲染(参考 game1 Rank2View)
const renderAvatar = (item: any) => {
if (item && item.avatar) {
......@@ -390,6 +430,7 @@ const podiumPlayers = (): RankItem[] => {
};
onMounted(() => {
preloadHorseSprite();
if ($is_run_local()) {
mockHorses();
gameStarted.value = true;
......@@ -433,7 +474,9 @@ defineExpose({
transform: `translateX(${horseTranslateX(horse.xPercent)}px)`,
}"
>
<div class="horse"></div>
<div class="horse" :style="horseSpriteStyle(horse)">
<div class="horse-shadow"></div>
</div>
<div class="horse-username">
<span class="username-text">{{ horse.nickname }}</span>
</div>
......@@ -475,6 +518,7 @@ defineExpose({
</div>
<div class="btn-next" @click="emit('next')"></div>
<div class="btn-back" @click="emit('back')"></div>
</div>
</div>
</DesignStage>
......@@ -535,14 +579,14 @@ defineExpose({
top: 0;
left: 0;
display: flex;
animation: bg-scroll-left 20s linear infinite;
animation: bg-scroll-left 40s linear infinite;
z-index: 1;
}
.bg-top-inner {
width: 3840px;
height: 1080px;
background: url("@/assets/images/game4/bg-top.svg") 0 0 / 3840px 1080px
background: url("@/assets/images/game4/bg-top.webp") 0 0 / 3840px 1080px
no-repeat;
flex-shrink: 0;
}
......@@ -554,14 +598,14 @@ defineExpose({
bottom: 0;
left: 0;
display: flex;
animation: bg-scroll-left 20s linear infinite;
animation: bg-scroll-left 40s linear infinite;
z-index: 2;
}
.bg-bottom-inner {
width: 3840px;
height: 577px;
background: url("@/assets/images/game4/bg-bottom.svg") 0 0 / 3840px 577px
background: url("@/assets/images/game4/bg-bottom.webp") 0 0 / 3840px 577px
no-repeat;
flex-shrink: 0;
}
......@@ -577,9 +621,21 @@ defineExpose({
.horse {
width: 120px;
height: 109px;
background: url("@/assets/images/game4/horse.png") -17px 0 / 1472px 109px
no-repeat;
animation: horse-run 0.6s steps(12) infinite;
background-repeat: no-repeat;
position: relative;
}
.horse-shadow {
position: absolute;
bottom: -2px;
left: 50%;
transform: translateX(-50%);
width: 120px;
height: 18px;
background: radial-gradient(ellipse, rgba(0, 0, 0, 0.45) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.horse-username {
......@@ -590,7 +646,7 @@ defineExpose({
height: 40px;
position: absolute;
top: 10px;
left: -24px;
left: -22px;
display: flex;
align-items: center;
justify-content: center;
......@@ -602,6 +658,12 @@ defineExpose({
color: #000;
font-size: 14px;
font-weight: bold;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-left: 4px;
box-sizing: border-box;
}
.horse-avatar {
......@@ -624,14 +686,6 @@ defineExpose({
}
}
@keyframes horse-run {
0% {
background-position: -17px 0;
}
100% {
background-position: -1457px 0;
}
}
/* ========== 结果弹窗 ========== */
.result-overlay {
......@@ -713,7 +767,7 @@ defineExpose({
.guan {
background: v-bind("imageUrls.guan1");
width: 64px; height: 64px;
position: absolute; left: 580px; top: -153px;
position: absolute; top: -160px; left: 581px;
}
.pai {
background: v-bind("imageUrls.pai1");
......@@ -722,55 +776,55 @@ defineExpose({
}
}
/* 第2名 (左边) */
/* 第2名 (原 rank-no3 样式) */
.rank-no2 {
.gu-burst { left: 318px; top: -120px; }
.gu-burst { left: 908px; top: -81px; }
.avatar {
width: 120px; height: 120px; border-radius: 60px;
position: absolute; left: 333px; top: -17px;
position: absolute; left: 920px; top: 25px;
}
.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;
position: absolute; left: 897px; top: 136px;
z-index: 1; border-radius: 23px;
}
.guan {
background: v-bind("imageUrls.guan2") no-repeat center;
background: v-bind("imageUrls.guan3") no-repeat center;
width: 96px; height: 96px;
position: absolute; left: 289px; top: -54px;
position: absolute; left: 877px; top: -14px;
}
.pai {
background: v-bind("imageUrls.pai2") no-repeat center;
background: v-bind("imageUrls.pai3") no-repeat center;
width: 212px; height: 200px;
position: absolute; left: 140px; top: 50px;
position: absolute; right: 130px; top: 50px;
}
}
/* 第3名 (右边) */
/* 第3名 (原 rank-no2 样式) */
.rank-no3 {
.gu-burst { left: 908px; top: -81px; }
.gu-burst { left: 318px; top: -120px; }
.avatar {
width: 120px; height: 120px; border-radius: 60px;
position: absolute; left: 920px; top: 25px;
position: absolute; left: 333px; top: -17px;
}
.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;
position: absolute; top: 95px;left: 309px;
z-index: 1; border-radius: 23px;
}
.guan {
background: v-bind("imageUrls.guan3") no-repeat center;
background: v-bind("imageUrls.guan2") no-repeat center;
width: 96px; height: 96px;
position: absolute; left: 877px; top: -14px;
position: absolute; left: 289px; top: -54px;
}
.pai {
background: v-bind("imageUrls.pai3") no-repeat center;
background: v-bind("imageUrls.pai2") no-repeat center;
width: 212px; height: 200px;
position: absolute; right: 130px; top: 50px;
position: absolute; left: 140px; top: 50px;
}
}
......@@ -847,12 +901,22 @@ defineExpose({
/* ========== btn-next ========== */
.btn-next {
position: absolute;
bottom: 100px;
left: 820px;
bottom: 50px;
left: 620px;
width: 316px;
height: 105px;
cursor: pointer;
background: v-bind("imageUrls.next") center / cover no-repeat;
z-index: 2;
}
.btn-back {
position: absolute;
bottom: 50px;
left: 1020px;
width: 316px;
height: 105px;
cursor: pointer;
background: v-bind("imageUrls.back") center / cover no-repeat;
z-index: 2;
}
</style>
......@@ -222,5 +222,5 @@ watch(screen, (value) => {
<Rank1 v-else-if="screen === 'rank1'" ref="rank1Ref" :rank-list="rankPlayers" @start="startGameWithMusic"
@next="nextRoundWithMusic" />
<!-- <Rank1 v-else ref="rank1Ref" :rank-list="rankPlayers" @start="startGameWithMusic" -->
<Rank2 v-else :rank-list="rankPlayers" @next="nextRoundWithMusic" />
<Rank2 v-else :rank-list="rankPlayers" @next="nextRoundWithMusic" @back="backHandler" />
</template>
......@@ -36,31 +36,33 @@ const imageUrls = {
// ========== 马匹相关 ==========
const HORSE_TYPES = [1, 2, 3, 4] as const;
const HORSE_FRAME_COUNT = 21; // 每匹马 21 帧(00-20)
const HORSE_WIDTH = 200;
const HORSE_START_X = -330; // 左侧屏外起点
const HORSE_END_X = 1920 - HORSE_WIDTH; // 右边缘贴齐屏幕右侧(1720px)
// 预构建四种马的帧 URL(与 H5 PlayingView 一致,避免运行时拼路径)
const horseFrames: Record<number, string[]> = {};
for (let h = 1; h <= 4; h++) {
horseFrames[h] = Array.from({ length: HORSE_FRAME_COUNT }, (_, i) =>
assetUrl(`game5/horse${h}/no-circle/qcnf-ma${h}_${String(i).padStart(2, "0")}.png`),
);
}
interface Horse {
id: string;
horseType: number; // 1-4 颜色
lane: "top" | "bottom";
xPercent: number; // 0=屏幕左侧外, 100=屏幕右侧外
frameIndex: number; // 当前帧 0-20
frameTimer: number; // 帧切换计时器
startTime: number; // 开始移动的时间戳(ms)
durationMs: number; // 跑完全程时长(ms)
x: number; // 当前 X 位置
}
const horses = ref<Horse[]>([]);
let horseIdCounter = 0;
/** 获取指定马颜色和帧的图片 URL */
const getHorseFrameUrl = (type: number, frame: number) => {
const padded = String(frame).padStart(2, "0");
return assetUrl(`game5/horse${type}/no-circle/qcnf-ma${type}_${padded}.png`);
};
/** 马的水平像素位移:xPercent→屏幕px */
const horseTranslateX = (xPercent: number) => {
// 从左侧-330px 跑到右侧 1920px,总行程 2250px
return -330 + xPercent * 22.5;
};
const isGameRunning = ref(false);
const emptyPlayer = (index: number): Player => ({
wechat_id: `empty-${index}`,
......@@ -109,6 +111,7 @@ watch(
if (rankList && rankList.length > 0) {
console.log('[Rank1View] 排名列表更新 - 数据:', JSON.stringify(rankList.map(p => ({ nickname: p.nickname, score: p.score }))))
updateRankList(rankList);
}
},
{ immediate: true, deep: true },
......@@ -121,6 +124,7 @@ const startIntro = async () => {
isGameRunning.value = false;
horses.value = [];
horseIdCounter = 0;
preloadHorseFrames(); // 确保马匹帧图已预加载
console.log('[Rank1View] 游戏流程启动 - 开始 3s 倒计时 + 60s 游戏')
// 启动完整倒计时:3s 开场 + 60s 游戏
......@@ -133,8 +137,8 @@ const startIntro = async () => {
// Phase 2: 倒计时结束 → 游戏正式开始,背景动画+马匹启动
gameStarted.value = true;
isGameRunning.value = true;
generateRandomHorses();
startHorseLoop();
startSpawning();
// 等待 60s 游戏倒计时完全结束
await countdownPromise;
......@@ -146,101 +150,105 @@ const stopGame = () => {
isGameRunning.value = false;
showResult.value = true;
stopHorseLoop();
stopSpawning();
};
// ========== 马匹动画循环 (~60fps) ==========
let horseLoopTimer: ReturnType<typeof setInterval> | undefined;
const FRAME_TICK_MS = 16;
const HORSE_FRAME_INTERVAL = 3; // 每 3 tick 切一帧 → ~20fps 奔马动画
const HORSE_SPEED_PERCENT_PER_SEC = 100 / 60; // 60s 跑完全程
// ========== 马匹动画循环 (RAF 仅驱动脚步切帧,位移由 CSS animation 负责) ==========
let horseRafId: number | undefined;
let horseLastTs = 0;
const HORSE_FRAME_TICK_MS = 48; // 每帧间隔 ~20fps
const startHorseLoop = () => {
stopHorseLoop();
horseLoopTimer = setInterval(() => {
if (!isGameRunning.value) return;
for (let i = horses.value.length - 1; i >= 0; i--) {
const horse = horses.value[i]!;
horse.xPercent += HORSE_SPEED_PERCENT_PER_SEC * (FRAME_TICK_MS / 1000);
horse.frameTimer++;
if (horse.frameTimer >= HORSE_FRAME_INTERVAL) {
horse.frameTimer = 0;
horseLastTs = 0;
const tick = (ts: number) => {
if (!isGameRunning.value) {
horseRafId = requestAnimationFrame(tick);
return;
}
if (!horseLastTs) horseLastTs = ts;
const dt = Math.min(ts - horseLastTs, 200);
horseLastTs = ts;
for (const horse of horses.value) {
if (ts < horse.startTime) continue;
const elapsed = ts - horse.startTime;
const progress = Math.min(elapsed / horse.durationMs, 1);
horse.x = HORSE_START_X + (HORSE_END_X - HORSE_START_X) * progress;
horse.frameTimer += dt;
while (horse.frameTimer >= HORSE_FRAME_TICK_MS) {
horse.frameTimer -= HORSE_FRAME_TICK_MS;
horse.frameIndex = (horse.frameIndex + 1) % HORSE_FRAME_COUNT;
}
// 跑出右边界后移除
if (horse.xPercent >= 120) {
horses.value.splice(i, 1);
}
}
}, FRAME_TICK_MS);
horseRafId = requestAnimationFrame(tick);
};
horseRafId = requestAnimationFrame(tick);
};
const stopHorseLoop = () => {
if (horseLoopTimer) {
clearInterval(horseLoopTimer);
horseLoopTimer = undefined;
if (horseRafId) {
cancelAnimationFrame(horseRafId);
horseRafId = undefined;
}
};
// ========== 随机生成马匹 ==========
let spawnTimer: ReturnType<typeof setTimeout> | undefined;
const spawnHorse = () => {
const type = HORSE_TYPES[Math.floor(Math.random() * HORSE_TYPES.length)]!;
const lane = Math.random() > 0.5 ? "top" : "bottom";
horses.value.push({
id: `horse-${++horseIdCounter}`,
horseType: type,
lane,
xPercent: -15, // 从左侧屏幕外进场
frameIndex: Math.floor(Math.random() * HORSE_FRAME_COUNT),
frameTimer: 0,
});
};
// ========== 自动随机生成 8-10 匹马 ==========
let gameStartTime = 0;
const generateRandomHorses = () => {
const count = 8 + Math.floor(Math.random() * 3); // 8-10
const lanes: ("top" | "bottom")[] = ["top", "bottom"];
const newHorses: Horse[] = [];
const now = performance.now();
for (let i = 0; i < count; i++) {
const type = HORSE_TYPES[i % HORSE_TYPES.length]!;
const lane = lanes[i % 2]!;
const delayMs = i * 2500 + Math.random() * 1000; // 每匹马间隔2.5秒起步,加随机1秒
const durationMs = (40 + Math.random() * 40) * 1000; // 40-80秒,速度浮动更大
newHorses.push({
id: `horse-${++horseIdCounter}`,
horseType: type,
lane,
frameIndex: Math.floor(Math.random() * HORSE_FRAME_COUNT),
frameTimer: 0,
startTime: now + delayMs,
durationMs,
x: HORSE_START_X,
});
}
const startSpawning = () => {
stopSpawning();
// 开局立刻出 1~2 匹
spawnHorse();
if (Math.random() > 0.3) spawnHorse();
const scheduleNext = () => {
if (!isGameRunning.value) return;
const delay = 2000 + Math.random() * 6000; // 2~8s 随机间隔
spawnTimer = setTimeout(() => {
if (!isGameRunning.value) return;
// 场上最多 8 匹,每次出 1~2 匹
if (horses.value.length < 8) {
spawnHorse();
if (Math.random() > 0.5 && horses.value.length < 8) spawnHorse();
}
scheduleNext();
}, delay);
};
scheduleNext();
horses.value = newHorses;
};
const stopSpawning = () => {
if (spawnTimer) {
clearTimeout(spawnTimer);
spawnTimer = undefined;
// ========== 预加载所有马匹帧图片(防止切帧时闪烁/白屏)==========
const preloadHorseFrames = () => {
for (const frames of Object.values(horseFrames)) {
for (const url of frames) {
const img = new Image();
img.src = url;
}
}
};
onMounted(() => {
preloadHorseFrames();
if ($is_run_local()) {
gameStarted.value = true;
isGameRunning.value = true;
generateRandomHorses();
startHorseLoop();
startSpawning();
}
});
onUnmounted(() => {
stopGame();
stopHorseLoop();
stopSpawning();
});
defineExpose({
......@@ -322,13 +330,19 @@ const rankIcon = (index: number) => {
:key="horse.id"
class="horse-wrapper"
:class="`horse-lane-${horse.lane}`"
:style="{ transform: `translateX(${horseTranslateX(horse.xPercent)}px)` }"
:style="{ transform: `translateX(${horse.x}px)` }"
>
<img
class="horse-img"
:src="getHorseFrameUrl(horse.horseType, horse.frameIndex)"
alt="horse"
/>
<div class="horse-sprite">
<img
v-for="(url, frameIdx) in horseFrames[horse.horseType]"
:key="frameIdx"
:src="url"
class="horse-frame"
:class="{ active: frameIdx === horse.frameIndex }"
alt=""
/>
<div class="horse-shadow"></div>
</div>
</div>
</template>
</div>
......@@ -344,7 +358,8 @@ const rankIcon = (index: number) => {
overflow: hidden;
}
.screen-container.paused .runway-track {
.screen-container.paused .runway-track,
.screen-container.paused .horse-running {
animation-play-state: paused;
}
......@@ -393,7 +408,7 @@ const rankIcon = (index: number) => {
position: absolute;
left: 44px;
top: 280px;
z-index: 10;
z-index: 5;
> div {
box-sizing: border-box;
......@@ -503,40 +518,53 @@ const rankIcon = (index: number) => {
.horse-wrapper {
position: absolute;
left: 0;
width: 330px;
height: 300px;
will-change: transform;
width: 200px;
height: 190px;
z-index: 15;
}
.horse-wrapper.horse-lane-top {
bottom: 320px;
bottom: 350px;
}
.horse-wrapper.horse-lane-bottom {
bottom: 40px;
bottom: 60px;
}
.horse-sprite {
position: relative;
width: 200px;
height: 190px;
}
/* 叠层帧图:预加载后仅切换 opacity,避免 background-image 换 URL 闪烁 */
.horse-frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
pointer-events: none;
z-index: 1;
}
.horse-frame.active {
opacity: 1;
}
/* 马脚下阴影 */
.horse-wrapper::after {
content: "";
.horse-shadow {
position: absolute;
bottom: -4px;
bottom: -2px;
left: 50%;
transform: translateX(-50%);
width: 180px;
height: 24px;
width: 120px;
height: 18px;
background: radial-gradient(ellipse, rgba(0, 0, 0, 0.45) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
}
.horse-img {
width: 330px;
height: 300px;
object-fit: contain;
position: relative;
z-index: 1;
z-index: 0;
}
@keyframes rank-up-flash {
......
......@@ -10,6 +10,7 @@ const props = defineProps<{
const emit = defineEmits<{
next: [];
back: [];
}>();
const FINAL_RANK_LIMIT = 10;
......@@ -30,7 +31,8 @@ const imageUrls = {
pai3: cssAssetUrl('game1/pai3.png'),
rankIcon: cssAssetUrl('game5/no-icon.webp'),
avatarAnimation: cssAssetUrl('avatar_animation.png'),
next: cssAssetUrl('game5/next-1.webp'),
next: cssAssetUrl('game1/next.png'),
back: cssAssetUrl('game1/back.png'),
};
const emptyPlayer = (index: number): Player => ({
......@@ -60,7 +62,7 @@ const renderAvatar = (item: any | null) => {
if (item && item.avatar) {
return `background: url('${item.avatar}') center / cover no-repeat;`
}
return `background: v-bind('imageUrls.avatar') center / cover no-repeat;`
return `background: ${imageUrls.avatar} center / cover no-repeat;`
}
</script>
......@@ -103,7 +105,7 @@ const renderAvatar = (item: any | null) => {
<div class="no-icon">{{ index + 4 }}</div>
<div>
<div class="img-avatar-container">
<div class="img-avatar"></div>
<div class="img-avatar" :style="`${renderAvatar(list[index + 3])}`"></div>
</div>
<div class="txt-nickname">{{ item.nickname }}</div>
</div>
......@@ -111,6 +113,7 @@ const renderAvatar = (item: any | null) => {
</div>
<div class="btn-next" @click="emit('next')"></div>
<div class="btn-back" @click="emit('back')"></div>
</div>
</DesignStage>
</template>
......@@ -155,10 +158,10 @@ const renderAvatar = (item: any | null) => {
.podium {
background: v-bind('imageUrls.podium') no-repeat center;
width: 1920px;
height: 800px;
height: 740px;
position: absolute;
bottom: 0px;
/* left: 287px; */
left: 0px;
.gu-burst {
width: 200px;
......@@ -180,16 +183,18 @@ const renderAvatar = (item: any | null) => {
.gu-burst {
left: 861px;
top: -72px;
z-index: 3;
}
.avatar {
/* background: v-bind('imageUrls.avatar') center / cover no-repeat; */
width: 120px;
height: 120px;
border-radius: 60px;
width: 160px;
height: 160px;
border-radius: 80px;
position: absolute;
left: 906px;
top: 76px;
left: 884px;
top: -7px;
z-index: 2;
}
.nickname {
......@@ -203,25 +208,19 @@ const renderAvatar = (item: any | null) => {
text-align: center;
line-height: 46px;
position: absolute;
left: 856px;
top: 190px;
z-index: 1;
left: 850px;
top: 160px;
z-index: 2;
border-radius: 23px;
}
.guan {
/* background: v-bind('imageUrls.guan1');
width: 64px;
height: 64px;
position: absolute;
left: 877px;
top: 61px; */
background: v-bind('imageUrls.guan');
width: 969px;
height: 238px;
position: absolute;
left: 482px;
top: -13px;
left: 480px;
top: -60px;
}
.pai {
......@@ -244,18 +243,17 @@ const renderAvatar = (item: any | null) => {
}
.avatar {
background: v-bind('imageUrls.avatar') center / cover no-repeat;
width: 144px;
height: 144px;
border-radius: 60px;
position: absolute;
left: 491.1px;
top: 74px;
left: 488px;
top: 27px;
}
.nickname {
text-align: center;
/* line-height: 46px; */
line-height: 52px;
width: 220px;
height: 52px;
background: linear-gradient(0deg, #AEA6A7 0%, #EAE4DF 100%);
......@@ -264,8 +262,8 @@ const renderAvatar = (item: any | null) => {
font-size: 30px;
color: #383636;
position: absolute;
left: 461px;
top: 221px;
left: 447px;
top: 184px;
z-index: 1;
border-radius: 23px;
}
......@@ -298,13 +296,12 @@ const renderAvatar = (item: any | null) => {
}
.avatar {
background: v-bind('imageUrls.avatar') center / cover no-repeat;
width: 144px;
height: 144px;
border-radius: 60px;
position: absolute;
left: 1300px;
top: 74px;
left: 1297px;
top: 27px;
}
.nickname {
......@@ -318,8 +315,8 @@ const renderAvatar = (item: any | null) => {
text-align: center;
line-height: 46px;
position: absolute;
left: 1240px;
top: 221px;
left: 1255px;
top: 160px;
z-index: 1;
border-radius: 23px;
}
......@@ -409,11 +406,20 @@ const renderAvatar = (item: any | null) => {
.btn-next {
position: absolute;
bottom: 100px;
left: 820px;
bottom: 50px;
left: 620px;
width: 316px;
height: 105px;
cursor: pointer;
background: v-bind('imageUrls.next') center / cover no-repeat;
width: 318px;
height: 95px;
}
.btn-back {
position: absolute;
bottom: 50px;
left: 1020px;
width: 316px;
height: 105px;
cursor: pointer;
background: v-bind('imageUrls.back') center / cover no-repeat;
}
</style>
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