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

Merge branch 'fix-修复音乐通用js文件' into 'main'

build: 图片压缩; See merge request !17
parents 00dea6cc 5629f083
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.
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.
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.
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.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { cssAssetUrl } from "@/commons/assets.ts";
import MobileStage from "@/components/MobileStage.vue";
import {
useGameSocket,
sendGameMessage,
userJoinStatus,
joinSharedRoom,
} from "@/composables/useGameSocket";
import LoadingView from "./views/LoadingView.vue";
import PlayingView from "./views/PlayingView.vue";
import { $getWechat, $toast, $is_run_local } from "@/commons/utils.ts";
import { playGame3Music, stopAllMusic } from "@/commons/music";
// ⚠ 发布上线时设为 true,本地测试时设为 false
const IS_PRODUCTION = true;
type GameView = "loading" | "playing";
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { cssAssetUrl } from '@/commons/assets.ts'
import MobileStage from '@/components/MobileStage.vue'
import { useGameSocket, joinSharedRoom, sendGameMessage, userJoinStatus } from '@/composables/useGameSocket'
import LoadingView from './views/LoadingView.vue'
import PlayingView from './views/PlayingView.vue'
import { $getWechat, $toast, $is_run_local } from '@/commons/utils.ts'
import { playGame6Music, stopGame6Music } from '@/commons/music';
type GameView = 'loading' | 'playing'
type RankPlayer = {
rank: number;
nickname: string;
avatar: string;
score: number | string;
};
rank: number
nickname: string
avatar: string
score: number | string
}
const imageUrls: Record<string, string> = {
bg: cssAssetUrl("game3/bg.webp"),
bg3: cssAssetUrl("game3/bg3.webp"),
left: cssAssetUrl("game3/icon_zuo.webp"),
right: cssAssetUrl("game3/icon_you.webp"),
bg4: cssAssetUrl("game3/pic_paodao.webp"),
logo: cssAssetUrl("logo.png"),
loadingBg1: cssAssetUrl("game6/loadingbg1.png"),
loadingBg2: cssAssetUrl("game6/loadingbg2.png"),
close: cssAssetUrl("game1/close.png"),
clock: cssAssetUrl("game1/clock.png"),
bg2: cssAssetUrl("game6/bg2.png"),
hydt: cssAssetUrl("game3/title3.webp"),
rank: cssAssetUrl("game6/rank.png"),
moleShowSprite: cssAssetUrl("game6/mole_show.png"),
moleHideSprite: cssAssetUrl("game6/mole_hide.png"),
moleHideprite: cssAssetUrl("game6/mole_hit.png"),
rabbitFaintprite: cssAssetUrl("game6/rabbit_faint.png"),
rabbitHideprite: cssAssetUrl("game6/rabbit_hide.png"),
rabbitshowprite: cssAssetUrl("game6/rabbit_show.png"),
land: cssAssetUrl("game6/land.png"),
hammer: cssAssetUrl("game6/hammer.png"),
ranklist: cssAssetUrl("game6/ranklist.png"),
rule: cssAssetUrl("game1/rule.png"),
};
const wechat = $getWechat();
const currentView = ref<GameView>("loading");
const countdownInterval = ref(60);
const isDivDescVisible = ref(false);
const isGameOver = ref(false);
const score = ref(0);
const rank = ref(0); //排名
const rankList = ref<RankPlayer[]>([]);
bg: cssAssetUrl('game3/bg.webp'),
bg3: cssAssetUrl('game3/bg3.webp'),
left: cssAssetUrl('game3/icon_zuo.webp'),
right: cssAssetUrl('game3/icon_you.webp'),
bg4: cssAssetUrl('game3/pic_paodao.webp'),
logo: cssAssetUrl('logo.png'),
loadingBg1: cssAssetUrl('game6/loadingbg1.png'),
loadingBg2: cssAssetUrl('game6/loadingbg2.png'),
close: cssAssetUrl('game1/close.png'),
clock: cssAssetUrl('game1/clock.png'),
bg2: cssAssetUrl('game6/bg2.png'),
hydt: cssAssetUrl('game3/title3.webp'),
rank: cssAssetUrl('game6/rank.png'),
moleShowSprite: cssAssetUrl('game6/mole_show.png'),
moleHideSprite: cssAssetUrl('game6/mole_hide.png'),
moleHideprite: cssAssetUrl('game6/mole_hit.png'),
rabbitFaintprite: cssAssetUrl('game6/rabbit_faint.png'),
rabbitHideprite: cssAssetUrl('game6/rabbit_hide.png'),
rabbitshowprite: cssAssetUrl('game6/rabbit_show.png'),
land: cssAssetUrl('game6/land.png'),
hammer: cssAssetUrl('game6/hammer.png'),
ranklist: cssAssetUrl('game6/ranklist.png'),
rule: cssAssetUrl('game1/rule.png')
}
const wechat = $getWechat()
const currentView = ref<GameView>('loading')
const countdownInterval = ref(60)
const isDivDescVisible = ref(false)
const score = ref(0)
const rank = ref(0) //排名
const rankList = ref<RankPlayer[]>([])
const displayRankList = computed<RankPlayer[]>(() => {
const list = rankList.value.slice(0, 10);
return Array.from({ length: 10 }, (_, index) => {
return (
list[index] ?? {
rank: index + 1,
nickname: "-",
avatar: "",
score: "-",
}
);
});
});
const list = rankList.value.slice(0, 10)
return Array.from({ length: 10 }, (_, index) => {
return list[index] ?? {
rank: index + 1,
nickname: '-',
avatar: '',
score: '-',
}
})
})
const rankScore = computed<number | string>(() => {
const currentPlayer = rankList.value
.slice(0, 10)
.find((item) => item.rank === rank.value);
const currentPlayer = rankList.value
.slice(0, 10)
.find(item => item.rank === rank.value)
return currentPlayer?.score ?? score.value;
});
return currentPlayer?.score ?? score.value
})
// const guFrames: [string, string, string] = [imageUrls.gu01, imageUrls.gu02, imageUrls.gu03]
// const currentGu = ref(imageUrls.gu01)
const isGuPlaying = ref(false);
const token = ref(wechat?.token ?? "");
const nickname = ref(wechat?.nickname ?? "");
const avatar = ref(wechat?.avatar ?? "");
const showGameRule = ref(true);
const showGameRank = ref(false);
const isLocalMode = !IS_PRODUCTION && $is_run_local();
const mobileStageRef = ref<InstanceType<typeof MobileStage> | null>(null);
const _offSocketMessage = ref<(() => void) | undefined>();
let guFrameTimer: ReturnType<typeof window.setTimeout> | undefined;
let gameCountdownTimer: ReturnType<typeof window.setTimeout> | undefined;
const isGuPlaying = ref(false)
const token = ref(wechat?.token ?? '')
const nickname = ref(wechat?.nickname ?? '')
const avatar = ref(wechat?.avatar ?? '')
const showGameRule = ref(true)
const showGameRank = ref(false)
const isLocalMode = $is_run_local()
const mobileStageRef = ref<InstanceType<typeof MobileStage> | null>(null)
const _offSocketMessage = ref<(() => void) | undefined>()
let guFrameTimer: ReturnType<typeof window.setTimeout> | undefined
let gameCountdownTimer: ReturnType<typeof window.setTimeout> | undefined
const confirmRefresh = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "";
};
event.preventDefault()
event.returnValue = ''
}
function setDivDescVisible(visible: boolean) {
isDivDescVisible.value = visible;
isDivDescVisible.value = visible
}
function stopGameCountdown() {
if (gameCountdownTimer) {
window.clearTimeout(gameCountdownTimer);
gameCountdownTimer = undefined;
}
if (gameCountdownTimer) {
window.clearTimeout(gameCountdownTimer)
gameCountdownTimer = undefined
}
}
function submitScore(save: boolean = false, currentTick = score.value) {
if (!wechat && !isLocalMode) return;
if (save) {
if (isLocalMode && !wechat) {
console.log("[game3 本地模式] 提交最终分数:", currentTick);
return;
}
sendGameMessage("submit_score_save", {
score: currentTick,
wechat: wechat.token_origin,
item_num: 3,
nickname: wechat.nickname,
avatar: wechat.avatar,
rank: rank.value,
});
} else {
if (isLocalMode && !wechat) {
console.log("[game3 本地模式] 实时分数:", currentTick);
return;
if (!wechat && !isLocalMode) return
if (save) {
if (isLocalMode && !wechat) {
console.log('[game3 本地模式] 提交最终分数:', currentTick)
return
}
sendGameMessage('submit_score_save', {
score: currentTick,
wechat: wechat.token_origin,
item_num: 3,
nickname: wechat.nickname,
avatar: wechat.avatar,
rank: rank.value,
})
} else {
if (isLocalMode && !wechat) {
console.log('[game3 本地模式] 实时分数:', currentTick)
return
}
sendGameMessage('submit_score', { score: currentTick })
}
sendGameMessage("submit_score", { score: currentTick });
}
}
function startGameCountdown(seconds = 60) {
stopGameCountdown();
countdownInterval.value = seconds;
setDivDescVisible(true);
const tickCountdown = () => {
countdownInterval.value -= 1;
if (countdownInterval.value <= 0) {
countdownInterval.value = 0;
gameCountdownTimer = undefined;
setDivDescVisible(false);
submitScore(true);
showGameOverRank();
return;
}
gameCountdownTimer = window.setTimeout(tickCountdown, 1000);
};
gameCountdownTimer = window.setTimeout(tickCountdown, 1000);
}
function syncGameCountdown(seconds: number) {
const syncedSeconds = Math.max(0, Math.floor(seconds));
if (countdownInterval.value - syncedSeconds <= 1) return;
stopGameCountdown()
countdownInterval.value = seconds
setDivDescVisible(true)
const tickCountdown = () => {
countdownInterval.value -= 1
if (countdownInterval.value <= 0) {
countdownInterval.value = 0
gameCountdownTimer = undefined
setDivDescVisible(false)
submitScore(true)
showGameOverRank()
return
}
if (syncedSeconds === 0) {
countdownInterval.value = 0;
return;
}
gameCountdownTimer = window.setTimeout(tickCountdown, 1000)
}
startGameCountdown(syncedSeconds);
gameCountdownTimer = window.setTimeout(tickCountdown, 1000)
}
function resetToLoadingView() {
stopGameCountdown();
if (guFrameTimer) {
window.clearTimeout(guFrameTimer);
guFrameTimer = undefined;
}
score.value = 0;
rank.value = 0;
rankList.value = [];
countdownInterval.value = 60;
isGuPlaying.value = false;
isGameOver.value = false;
userJoinStatus.value = false;
showGameRank.value = false;
currentView.value = "loading";
setDivDescVisible(false);
stopGameCountdown()
if (guFrameTimer) {
window.clearTimeout(guFrameTimer)
guFrameTimer = undefined
}
score.value = 0
rank.value = 0
rankList.value = []
countdownInterval.value = 60
// currentGu.value = guFrames[0]
isGuPlaying.value = false
userJoinStatus.value = false
showGameRank.value = false
currentView.value = 'loading'
setDivDescVisible(false)
}
function startGameView() {
stopGameCountdown();
score.value = 0;
rank.value = 0;
rankList.value = [];
isGameOver.value = false;
showGameRank.value = false;
currentView.value = "playing";
startGameCountdown();
stopGameCountdown()
score.value = 0
rank.value = 0
rankList.value = []
// currentGu.value = guFrames[0]
showGameRank.value = false
currentView.value = 'playing'
startGameCountdown()
}
function showGameOverRank() {
stopGameCountdown();
setDivDescVisible(false);
currentView.value = "loading";
showGameRank.value = true;
userJoinStatus.value = false;
stopGameCountdown()
setDivDescVisible(false)
currentView.value = 'loading'
showGameRank.value = true
userJoinStatus.value = false
if (isLocalMode && !wechat) {
console.log('[game3 本地模式] 游戏结束,最终分数:', score.value)
}
}
const touchHandler = (mole: boolean) => {
let scoreDelta = mole ? 5 : -3;
let nextTick = score.value + scoreDelta
if (nextTick < 0) nextTick = 0
score.value = nextTick
if (mole) {
playGame6Music(1);
} else {
playGame6Music(2);
}
submitScore(false, nextTick)
}
const scoreChangeHandler = (delta: number) => {
if (delta > 0) {
playGame3Music(1);
} else {
playGame3Music(2);
}
let nextTick = score.value + delta;
if (nextTick < 0) nextTick = 0;
score.value = nextTick;
submitScore(false, nextTick);
};
let nextTick = score.value + delta
if (nextTick < 0) nextTick = 0
score.value = nextTick
submitScore(false, nextTick)
}
if (wechat) {
const gameId = "game3";
const { offSocketMessage } = useGameSocket({
gameId,
auth: {
userid: wechat.token,
},
onConnect: () => {
window.setTimeout(() => {
const joined = joinSharedRoom(
nickname.value,
token.value,
avatar.value,
gameId,
);
}, 200);
},
onGameStart: async () => {
showGameRule.value = false;
await mobileStageRef.value?.startCountdown();
startGameView();
},
onScoreSubmitted: (is_save, data) => {
if (!is_save) {
//非保存状态下
const nextScore = Number(typeof data === "object" ? data?.score : NaN);
const nextRank = Number(typeof data === "object" ? data?.rank : data);
const remainingSeconds = Number(
typeof data === "object" ? data?.remainingSeconds : NaN,
);
if (Number.isFinite(nextScore) && nextScore >= 0) {
score.value = nextScore;
}
if (Number.isFinite(nextRank) && nextRank > 0) {
rank.value = nextRank;
}
if (Number.isFinite(remainingSeconds) && remainingSeconds >= 0) {
syncGameCountdown(remainingSeconds);
}
} else if (typeof data === "object" && data) {
if (Array.isArray(data.list)) {
rankList.value = data.list.map((item: any, index: number) => ({
rank: Number(item?.rank ?? index + 1),
nickname: String(item?.nickname ?? ""),
avatar: String(item?.avatar ?? ""),
score: Number(item?.score ?? 0),
}));
}
const nextRank = Number(data.rank);
if (Number.isFinite(nextRank) && nextRank > 0) {
rank.value = nextRank;
}
}
return { code: "MSYF", score: score.value };
},
onRescoreSubmitted: (_recount) => {
submitScore(true);
},
onGameRecover: (data) => {
const recoveredRank = Number(data?.rank);
const recoveredScore = Number(data?.score);
const recoveredSeconds = Number(data?.remainingSeconds);
rank.value =
Number.isFinite(recoveredRank) && recoveredRank > 0 ? recoveredRank : 0;
score.value =
Number.isFinite(recoveredScore) && recoveredScore > 0
? recoveredScore
: 0;
showGameRule.value = false;
showGameRank.value = false;
isGameOver.value = false;
currentView.value = "playing";
startGameCountdown(
Number.isFinite(recoveredSeconds) && recoveredSeconds > 0
? recoveredSeconds
: 0,
);
},
onRoomClosed: () => {
stopGameCountdown();
currentView.value = "loading";
setDivDescVisible(false);
$toast("管理员关闭了游戏房间");
},
onRoomBack: () => {},
});
_offSocketMessage.value = offSocketMessage;
const gameId = 'game3';
const { offSocketMessage } = useGameSocket({
gameId,
auth: {
userid: wechat.token,
},
onConnect: () => {
window.setTimeout(() => {
const joined = joinSharedRoom(nickname.value, token.value, avatar.value, gameId)
}, 200)
},
onGameStart: async () => {
showGameRule.value = false;
await mobileStageRef.value?.startCountdown()
startGameView()
},
onScoreSubmitted: (is_save, data) => {
if (!is_save) {//非保存状态下
const nextRank = Number(typeof data === 'object' ? data?.rank : data)
if (Number.isFinite(nextRank) && nextRank > 0) {
rank.value = nextRank
}
} else if (typeof data === 'object' && data) {
if (Array.isArray(data.list)) {
rankList.value = data.list.map((item: any, index: number) => ({
rank: Number(item?.rank ?? index + 1),
nickname: String(item?.nickname ?? ''),
avatar: String(item?.avatar ?? ''),
score: Number(item?.score ?? 0),
}))
}
const nextRank = Number(data.rank)
if (Number.isFinite(nextRank) && nextRank > 0) {
rank.value = nextRank
}
}
},
onRescoreSubmitted: (_recount) => {
submitScore(true);
},
onGameRecover: (data) => {
const recoveredRank = Number(data?.rank)
const recoveredScore = Number(data?.score)
const recoveredSeconds = Number(data?.remainingSeconds)
rank.value = Number.isFinite(recoveredRank) && recoveredRank > 0 ? recoveredRank : 0
score.value = Number.isFinite(recoveredScore) && recoveredScore > 0 ? recoveredScore : 0
showGameRule.value = false
showGameRank.value = false
currentView.value = 'playing'
startGameCountdown(
Number.isFinite(recoveredSeconds) && recoveredSeconds > 0 ? recoveredSeconds : 0,
)
},
onRoomClosed: () => {
stopGameCountdown()
currentView.value = 'loading'
setDivDescVisible(false)
$toast('管理员关闭了该房间')
},
onRoomBack: () => {
resetToLoadingView()
window.setTimeout(() => {
joinSharedRoom(nickname.value, token.value, avatar.value, gameId)
}, 200)
},
})
_offSocketMessage.value = offSocketMessage
}
onMounted(() => {
document.title = "互动游戏 - 马上有福";
if (token.value) {
window.addEventListener("beforeunload", confirmRefresh);
}
// 本地测试模式:自动模拟游戏开始(IS_PRODUCTION=false 时生效)
if (isLocalMode) {
showGameRule.value = false;
currentView.value = "playing";
startGameCountdown();
}
});
document.title = '小游戏 - 福运当头'
if (token.value) {
window.addEventListener('beforeunload', confirmRefresh)
}
// TODO_LOCAL: 本地模拟游戏开始,稍后部署上线前删除此段
if ($is_run_local()) {
showGameRule.value = false
currentView.value = 'playing'
startGameCountdown()
}
})
onBeforeUnmount(() => {
_offSocketMessage.value?.();
stopAllMusic();
if (guFrameTimer) {
window.clearTimeout(guFrameTimer);
}
stopGameCountdown();
if (token.value) {
window.removeEventListener("beforeunload", confirmRefresh);
}
});
_offSocketMessage.value?.()
stopGame6Music()
if (guFrameTimer) {
window.clearTimeout(guFrameTimer)
}
stopGameCountdown()
if (token.value) {
window.removeEventListener('beforeunload', confirmRefresh)
}
})
const showGameRuleHandler = () => {
showGameRule.value = !showGameRule.value;
};
showGameRule.value = !showGameRule.value;
}
const renderBG = () => {
if (currentView.value == "playing") {
return `${imageUrls.bg2} center/cover`;
}
return `${imageUrls.bg} center/cover`;
};
if (currentView.value == 'playing') {
return `${imageUrls.bg2} center/cover`
}
return `${imageUrls.bg} center/cover`;
}
const rankCloseHandler = () => {
showGameRank.value = false;
isGameOver.value = false;
resetToLoadingView();
};
showGameRank.value = false
}
</script>
<template>
<MobileStage
v-if="token || isLocalMode"
:showGameRule="showGameRule"
:showGameRank="showGameRank"
ref="mobileStageRef"
:background="`${renderBG()}`"
>
<template #gameRule>
<div class="rule-container">
<div class="rule-txt-container">
<div class="rule-title">游戏规则</div>
<div class="rule-txt">
1. 开赛左右操控小马,接福袋 + 5 分、碰地雷 - 3 分,每秒行进 + 2
分;<br />
2. 限时闯关比拼得分,结束后按积分排名;<br />
3. 高分用户有机会领取新疆福彩定制周边;<br />
4. 禁止外挂作弊,违规账号取消成绩与领奖资格;<br /><br />
祝您游戏愉快!
</div>
<div class="rule-close" @click="showGameRuleHandler"></div>
</div>
</div>
</template>
<template #gameRank>
<div class="ranklist-container">
<div class="txt-title">排行</div>
<div class="list-container">
<div class="row row-1">
<div class="col-1">排名</div>
<div class="col-2">用户</div>
<div class="col-3">成绩</div>
</div>
<div class="row-2">
<div
class="row rank-row"
v-for="(item, index) in displayRankList"
:key="`${item.rank}-${index}`"
>
<div class="col-1">
<div>{{ item.rank }}</div>
</div>
<div class="col-2">
<div
class="avatar"
:style="
item.avatar
? { backgroundImage: `url(${item.avatar})` }
: {}
"
></div>
<div>{{ item.nickname }}</div>
</div>
<div class="col-3">{{ item.score }}</div>
<MobileStage v-if="token || isLocalMode" :showGameRule="showGameRule" :showGameRank="showGameRank" ref="mobileStageRef"
:background="`${renderBG()}`">
<template #gameRule>
<div class="rule-container">
<div class="rule-txt-container">
<div class="rule-title">游戏规则</div>
<div class="rule-txt">
1. 开赛左右操控小马,接福袋 + 5 分、碰地雷 - 3 分,每秒行进 + 2 分;<br />
2. 限时闯关比拼得分,结束后按积分排名;<br />
3. 高分用户有机会领取新疆福彩定制周边;<br />
4. 禁止外挂作弊,违规账号取消成绩与领奖资格;<br /><br />
祝您游戏愉快!
</div>
<div class="rule-close" @click="showGameRuleHandler"></div>
</div>
</div>
</div>
<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>{{ nickname }}</div>
</template>
<template #gameRank>
<div class="ranklist-container">
<div class="txt-title">排行</div>
<div class="list-container">
<div class="row row-1">
<div class="col-1">排名</div>
<div class="col-2">用户</div>
<div class="col-3">成绩</div>
</div>
<div class="row-2">
<div class="row rank-row" v-for="(item, index) in displayRankList"
:key="`${item.rank}-${index}`">
<div class="col-1">
<div>{{ item.rank }}</div>
</div>
<div class="col-2">
<div class="avatar"
:style="item.avatar ? { backgroundImage: `url(${item.avatar})` } : {}"></div>
<div>{{ item.nickname }}</div>
</div>
<div class="col-3">{{ item.score }}</div>
</div>
</div>
<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>{{ nickname }}</div>
</div>
<div class="col-3">{{ rankScore }}</div>
</div>
</div>
</div>
<div class="col-3">{{ rankScore }}</div>
</div>
</div>
<div class="img-close" @click="rankCloseHandler"></div>
</div>
</template>
<LoadingView
v-if="currentView === 'loading'"
:image-urls="imageUrls"
:user-join-status="userJoinStatus"
@touchGameRule="showGameRuleHandler"
/>
<PlayingView
v-else
:image-urls="imageUrls"
:tick="score"
:rank="rank"
:countdown-interval="countdownInterval"
:is-div-desc-visible="isDivDescVisible"
@score-change="scoreChangeHandler"
@rank-close="rankCloseHandler"
/>
<!-- <LoadingView v-if="currentView === 'loading'" :image-urls="imageUrls" :user-join-status="userJoinStatus"
<div class="img-close" @click="rankCloseHandler"></div>
</template>
<LoadingView v-if="currentView === 'loading'" :image-urls="imageUrls" :user-join-status="userJoinStatus"
@touchGameRule="showGameRuleHandler" />
<PlayingView v-else :image-urls="imageUrls" :tick="score" :rank="rank" :countdown-interval="countdownInterval"
:is-div-desc-visible="isDivDescVisible" :is-game-over="isGameOver"
:rank-list="displayRankList" :user-rank="rank" :user-score="rankScore"
:user-nickname="nickname" :user-avatar="avatar"
@touch="touchHandler" @score-change="scoreChangeHandler" @rank-close="rankCloseHandler" /> -->
</MobileStage>
<div
v-else
style="
text-align: center;
width: 100vw;
height: 100vh;
line-height: 30;
font-size: 20px;
"
>
请使用微信扫码进入游戏
</div>
:is-div-desc-visible="isDivDescVisible" @touch="touchHandler" @score-change="scoreChangeHandler" />
</MobileStage>
<div v-else style="text-align: center; width: 100vw; height: 100vh; line-height: 30; font-size: 20px;">
请使用微信扫码进入游戏
</div>
</template>
<style scoped>
.rule-container {
......@@ -485,115 +405,107 @@ const rankCloseHandler = () => {
margin-top: 10px;
color: #AA0000;
}
}
.rule-close {
width: 57px;
height: 57px;
background: v-bind('imageUrls.close') center / cover no-repeat;
transform: translateX(-50%) scale(1.5);
position: absolute;
top: -73px;
left: 100%;
margin-left: -48px;
}
.rule-close {
width: 57px;
height: 57px;
background: v-bind('imageUrls.close') center / cover no-repeat;
transform: translateX(-50%) scale(1.4);
position: absolute;
bottom: -75px;
left: 50%;
}
}
.ranklist-container {
width: 747px;
height: 1084px;
background: v-bind("imageUrls.ranklist");
transform: translate(-50%, -50%) scale(0.45);
left: 50%;
top: 50%;
position: absolute;
.txt-title {
width: 100%;
text-align: center;
font-size: 50px;
margin-top: 35px;
font-weight: bold;
color: #aa0000;
letter-spacing: 2px;
}
.list-container {
width: calc(100% - 90px);
height: calc(100% - 210px);
width: 747px;
height: 1084px;
background: v-bind('imageUrls.ranklist');
transform: translate(-50%, -50%) scale(0.45);
left: 50%;
top: 50%;
position: absolute;
left: 40px;
top: 140px;
text-align: center;
height: 73px;
line-height: 73px;
font-size: 32px;
.row {
display: grid;
grid-template-columns: 150px auto 150px;
align-items: center;
}
.row-1 {
color: #aa0000;
font-weight: bold;
background: linear-gradient(
-84deg,
#ffd396 0%,
#fff3d1 53%,
#ffd59c 100%
);
border-radius: 20px 20px 0px 0px;
.txt-title {
width: 100%;
text-align: center;
font-size: 50px;
margin-top: 35px;
font-weight: bold;
color: #AA0000;
letter-spacing: 2px;
}
.row-2 {
color: #633911;
.list-container {
width: calc(100% - 90px);
height: calc(100% - 210px);
position: absolute;
left: 40px;
top: 140px;
text-align: center;
height: 73px;
line-height: 73px;
font-size: 32px;
.row {
display: grid;
grid-template-columns: 150px auto 150px;
align-items: center;
}
.rank-row:nth-child(odd) {
background: #f2e2ae;
}
.row-1 {
color: #AA0000;
font-weight: bold;
background: linear-gradient(-84deg, #FFD396 0%, #FFF3D1 53%, #FFD59C 100%);
border-radius: 20px 20px 0px 0px;
}
.rank-row:nth-child(even) {
background: #fceebf;
}
.row-2 {
color: #633911;
.col-1 {
position: relative;
}
}
.rank-row:nth-child(odd) {
background: #F2E2AE;
}
.row-3 {
background: linear-gradient(0deg, #f4b64c 0%, #f7c96f 100%);
border-radius: 0px 0px 20px 20px;
}
.rank-row:nth-child(even) {
background: #FCEEBF;
}
.col-2 {
display: flex;
align-items: center;
justify-content: center;
gap: 18px;
.avatar {
width: 48px;
height: 48px;
flex: 0 0 auto;
border-radius: 50%;
background-position: center;
background-size: cover;
}
.col-1 {
position: relative;
}
}
.row-3 {
background: linear-gradient(0deg, #F4B64C 0%, #F7C96F 100%);
border-radius: 0px 0px 20px 20px;
}
.col-2 {
display: flex;
align-items: center;
justify-content: center;
gap: 18px;
.avatar {
width: 48px;
height: 48px;
flex: 0 0 auto;
border-radius: 50%;
background-position: center;
background-size: cover;
}
}
}
}
}
.img-close {
width: 57px;
height: 57px;
background: v-bind('imageUrls.close') center / cover no-repeat;
transform: translateX(-50%) scale(1.5);
position: absolute;
top: -35px;
left: 100%;
margin-left: -58px;
border-radius: 29px;
background: v-bind('imageUrls.close')center/cover;
transform: scale(0.7);
margin-top: 500px;
}
</style>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch, nextTick, computed } from 'vue';
import { assetUrl } from '@/commons/assets.ts'
// import { playGame6Music } from '@/commons/music'
import { playGame6Music } from '@/commons/music'
const props = defineProps<{
imageUrls: Record<string, string>
......@@ -12,9 +12,8 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
// touch: [mole: boolean]
touch: [mole: boolean]
scoreChange: [delta: number]
rankClose: []
}>();
// ========== 常量 ==========
......@@ -26,26 +25,8 @@ const HORSE_BOTTOM_PCT = 19.11;
const ITEM_SIZE = 80;
const GAME_DURATION = 60;
// 设计基准跑道 X 位置(750px 设计稿下的值,窄屏设备上会自动居中偏移)
const DESIGN_LEFT_LANE_X = 150;
const DESIGN_RIGHT_LANE_X = 500;
// 运行时根据视口计算的真实 X 位置(适配不同屏幕宽度)
const leftLaneX = ref(DESIGN_LEFT_LANE_X);
const rightLaneX = ref(DESIGN_RIGHT_LANE_X);
/** 按视口宽度计算 750px 设计区域在 content-bg 中的居中偏移 */
const updateLanePositions = () => {
const vw = window.visualViewport?.width ?? window.innerWidth;
const vh = window.visualViewport?.height ?? window.innerHeight;
// MobileStage contain 模式下 scale = min(scaleX, scaleY),手机通常由高度决定
const scaleY = vh / 1624;
if (!scaleY || scaleY <= 0) return;
const stageViewportWidth = vw / scaleY;
const offset = Math.max(0, (stageViewportWidth - 750) / 2);
leftLaneX.value = DESIGN_LEFT_LANE_X + offset;
rightLaneX.value = DESIGN_RIGHT_LANE_X + offset;
};
const LEFT_LANE_X = 170;
const RIGHT_LANE_X = 470;
// ========== 马帧 ==========
const HORSE_FRAME_COUNT = 21;
......@@ -67,34 +48,24 @@ const bomFrames = Array.from({ length: BOM_FRAME_COUNT }, (_, i) =>
const fudaiUrl = assetUrl('game3/icon_fudai.webp');
const dileiUrl = assetUrl('game3/icon_dilei.webp');
// ========== 预加载碰撞动画帧(解决 4G 慢网下动画图片未加载的问题) ==========
const preloadFrames = (urls: string[]) => {
for (const url of urls) {
const img = new Image();
img.src = url;
}
};
preloadFrames(jinbiFrames);
preloadFrames(bomFrames);
// ========== 马状态 ==========
const horseLane = ref<'left' | 'right'>('left');
const horseFrameIndex = ref(0);
const horseX = ref(leftLaneX.value);
const horseTargetX = ref(leftLaneX.value);
const horseX = ref(LEFT_LANE_X);
const horseTargetX = ref(LEFT_LANE_X);
const switchToLeft = () => {
if (horseLane.value !== 'left') {
horseLane.value = 'left';
horseTargetX.value = leftLaneX.value;
horseTargetX.value = LEFT_LANE_X;
}
};
const switchToRight = () => {
if (horseLane.value !== 'right') {
horseLane.value = 'right';
horseTargetX.value = rightLaneX.value;
horseTargetX.value = RIGHT_LANE_X;
}
};
......@@ -112,8 +83,8 @@ const handleKeydown = (e: KeyboardEvent) => {
const roadOffset = ref(0);
let roadRafId: number | undefined;
let roadLastTs = 0;
const BASE_ROAD_SPEED = 200;
const MAX_ROAD_SPEED = 700;
const BASE_ROAD_SPEED = 80;
const MAX_ROAD_SPEED = 400;
const gameElapsed = ref(0);
const currentRoadSpeed = ref(BASE_ROAD_SPEED);
......@@ -137,9 +108,6 @@ const startRoadScroll = () => {
currentRoadSpeed.value = BASE_ROAD_SPEED + (MAX_ROAD_SPEED - BASE_ROAD_SPEED) * progress;
roadOffset.value += currentRoadSpeed.value * dt / 1000;
if (roadOffset.value >= ROAD_HEIGHT) {
roadOffset.value -= ROAD_HEIGHT;
}
roadRafId = requestAnimationFrame(tick);
};
......@@ -162,7 +130,6 @@ interface RoadItem {
lane: 'left' | 'right';
y: number;
collected: boolean;
colliding: boolean; // 防止同一帧内重复触发碰撞特效
animFrame: number;
animTimer: ReturnType<typeof setTimeout> | undefined;
}
......@@ -170,79 +137,47 @@ interface RoadItem {
const roadItems = ref<RoadItem[]>([]);
const effectAnims = ref<{ id: number; kind: ItemKind; frame: number; x: number; y: number }[]>([]);
interface ScoreEffect {
id: number
x: number
y: number
score: number // 正数表示加分,负数表示扣分
}
const scoreEffects = ref<ScoreEffect[]>([]);
let scoreEffectIdSeq = 0;
let itemIdCounter = 0;
let genTimerId: ReturnType<typeof setTimeout> | undefined;
let lastItemY = 0;
const MIN_ITEM_SPACING = 250;
const MAX_ITEM_SPACING = 400;
const ITEM_GEN_INTERVAL = 1500;
function seededRandom(seed: number) {
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
return x - Math.floor(x);
}
function scheduleNextItem() {
if (!props.isDivDescVisible) return;
// 游戏越接近尾声,生成间隔越短(1800ms → 350ms),加上随机抖动
const progress = Math.min(gameElapsed.value / GAME_DURATION, 1);
const base = 1800 - 1450 * progress;
const jitter = (Math.random() - 0.5) * 800; // ±400ms 随机
const interval = Math.max(250, base + jitter);
genTimerId = setTimeout(generateNewItem, interval);
}
function generateNewItem() {
if (!props.isDivDescVisible) return;
const seed = Date.now() + itemIdCounter;
const kind: ItemKind = seededRandom(seed) < 0.75 ? 'coin' : 'bomb';
// 概率分配车道,但避免与前一个同车道道具太近
let lane: 'left' | 'right' = seededRandom(seed + 1000) < 0.5 ? 'left' : 'right';
const kind: ItemKind = seededRandom(seed) < 0.55 ? 'coin' : 'bomb';
const lane: 'left' | 'right' = seededRandom(seed + 1000) < 0.5 ? 'left' : 'right';
// 找到该车道还在顶部附近(y < 200)的未收集道具
const hasNearbyInLane = (ln: 'left' | 'right') =>
roadItems.value.some(it => !it.collected && it.lane === ln && it.y < 200);
if (hasNearbyInLane(lane) && !hasNearbyInLane(lane === 'left' ? 'right' : 'left')) {
// 首选车道已有最近道具,强制换到另一车道
lane = lane === 'left' ? 'right' : 'left';
}
const spacing = MIN_ITEM_SPACING + seededRandom(seed + 2000) * (MAX_ITEM_SPACING - MIN_ITEM_SPACING);
const startY = -ITEM_SIZE;
const newItem: RoadItem = {
id: itemIdCounter++,
kind,
lane,
y: -ITEM_SIZE,
y: startY,
collected: false,
colliding: false,
animFrame: 0,
animTimer: undefined,
};
roadItems.value.push(newItem);
scheduleNextItem();
}
function stopItemGen() {
if (genTimerId) {
clearTimeout(genTimerId);
genTimerId = undefined;
}
setTimeout(generateNewItem, ITEM_GEN_INTERVAL);
}
// 道具 y 已是 screen-space(content-bg 坐标系),清理逻辑直接判断
function cleanupOffscreenItems() {
const screenBottom = roadOffset.value + ROAD_HEIGHT;
roadItems.value = roadItems.value.filter(item => {
// item.y 超出 content-bg 底部即删除,超出顶部也删除
return item.y < ROAD_HEIGHT && item.y > -ITEM_SIZE;
const itemBottom = item.y + ITEM_SIZE;
return itemBottom > roadOffset.value - 100 && item.y < screenBottom + 100;
});
}
......@@ -250,53 +185,45 @@ function cleanupOffscreenItems() {
// ========== 碰撞检测 ==========
// 马匹在屏幕上的Y坐标(从 content-bg 顶部算起,horse 使用 bottom: 19.11% 定位)
const horseCSSBottom = ROAD_HEIGHT * (1 - HORSE_BOTTOM_PCT / 100);
const horseCSSTop = horseCSSBottom - HORSE_H;
const horseBottomY = ROAD_HEIGHT * (HORSE_BOTTOM_PCT / 100);
const horseTopY = horseBottomY - HORSE_H;
const checkCollisions = () => {
const horseCenterX = horseX.value + HORSE_W / 2;
const horseTopCenterY = horseCSSTop + HORSE_H * 0.15;
const horseTopCenterY = horseTopY + HORSE_H * 0.15;
const horseRadius = HORSE_W * 0.35;
for (const item of roadItems.value) {
if (item.collected || item.colliding) continue;
if (item.collected) continue;
const itemX = item.lane === 'left' ? leftLaneX.value : rightLaneX.value;
// item.y 已经是屏幕坐标(content-bg 内),无需加 roadOffset
const itemX = item.lane === 'left' ? LEFT_LANE_X : RIGHT_LANE_X;
const itemVisualY = item.y;
const itemCenterX = itemX + ITEM_SIZE / 2;
const itemCenterY = item.y + ITEM_SIZE / 2;
const itemCenterY = itemVisualY + ITEM_SIZE / 2;
const dist = Math.hypot(horseCenterX - itemCenterX, horseTopCenterY - itemCenterY);
if (dist < horseRadius + ITEM_SIZE * 0.35) {
// 立即标记碰撞中,防止后续帧重复触发
item.colliding = true;
// 先启动碰撞特效动画,延迟隐藏道具让动画和道具短暂重叠过渡
playCollectEffect(item, itemX, item.y);
setTimeout(() => {
item.collected = true;
}, 100);
item.collected = true;
playCollectEffect(item, itemX, itemVisualY);
}
}
};
const playCollectEffect = (item: RoadItem, x: number, y: number) => {
const isCoin = item.kind === 'coin';
const delta = isCoin ? 5 : -3;
if (isCoin) {
if (item.kind === 'coin') {
emit('scoreChange', 5);
playGame6Music(1);
} else {
emit('scoreChange', -3);
playGame6Music(2);
}
const animId = Date.now() + Math.random();
const totalFrames = isCoin ? JINBI_FRAME_COUNT : BOM_FRAME_COUNT;
const totalFrames = item.kind === 'coin' ? JINBI_FRAME_COUNT : BOM_FRAME_COUNT;
const frameDuration = 1000 / totalFrames;
// 碰撞动画(大幅放大尺寸,x 往右偏移让动画居中于视觉碰撞点)
effectAnims.value.push({ id: animId, kind: item.kind, frame: 0, x: x + 65, y });
effectAnims.value.push({ id: animId, kind: item.kind, frame: 0, x, y });
let frameIdx = 0;
const tick = () => {
......@@ -310,19 +237,6 @@ const playCollectEffect = (item: RoadItem, x: number, y: number) => {
item.animTimer = setTimeout(tick, frameDuration);
};
item.animTimer = setTimeout(tick, frameDuration);
// 积分飘字效果(参考 game5 套中圈 "+10" 动画)
const effectId = ++scoreEffectIdSeq;
scoreEffects.value.push({
id: effectId,
x: x + ITEM_SIZE / 2 + 25,
y: y,
score: delta,
});
setTimeout(() => {
const idx = scoreEffects.value.findIndex(e => e.id === effectId);
if (idx >= 0) scoreEffects.value.splice(idx, 1);
}, 800);
};
// ========== 主循环 ==========
......@@ -347,28 +261,24 @@ const startMainLoop = () => {
mainLastTs = ts;
horseFrameAccum += dt;
const horseInterval = 80 * BASE_ROAD_SPEED / currentRoadSpeed.value;
while (horseFrameAccum >= horseInterval) {
horseFrameAccum -= horseInterval;
while (horseFrameAccum >= 48) {
horseFrameAccum -= 48;
horseFrameIndex.value = (horseFrameIndex.value + 1) % HORSE_FRAME_COUNT;
}
const lerpFactor = 1 - Math.pow(0.001, dt / 1000);
horseX.value += (horseTargetX.value - horseX.value) * lerpFactor;
// 先移动所有道具
checkCollisions();
for (const item of roadItems.value) {
if (!item.collected) {
item.y += currentRoadSpeed.value * dt / 1000;
}
}
// 清理已离开屏幕的道具,避免 roadOffset 回绕后旧道具闪现
cleanupOffscreenItems();
// 最后再检测碰撞,确保只检测屏幕内确实可见的道具
checkCollisions();
mainRafId = requestAnimationFrame(tick);
};
mainRafId = requestAnimationFrame(tick);
......@@ -395,32 +305,21 @@ const stopMainLoop = () => {
// ========== 生命周期 ==========
onMounted(() => {
window.addEventListener('keydown', handleKeydown);
window.addEventListener('resize', updateLanePositions);
window.visualViewport?.addEventListener('resize', updateLanePositions);
updateLanePositions();
// 初始化马匹在左侧跑道(使用计算后的实际 X 位置)
horseX.value = leftLaneX.value;
horseTargetX.value = leftLaneX.value;
nextTick(() => {
startRoadScroll();
startMainLoop();
scheduleNextItem();
setTimeout(generateNewItem, 1000);
});
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('resize', updateLanePositions);
window.visualViewport?.removeEventListener('resize', updateLanePositions);
stopRoadScroll();
stopMainLoop();
stopItemGen();
for (const item of roadItems.value) {
if (item.animTimer) clearTimeout(item.animTimer);
}
roadItems.value = [];
effectAnims.value = [];
scoreEffects.value = [];
});
watch(() => props.isDivDescVisible, (visible) => {
......@@ -428,50 +327,35 @@ watch(() => props.isDivDescVisible, (visible) => {
nextTick(() => {
roadItems.value = [];
effectAnims.value = [];
scoreEffects.value = [];
roadOffset.value = 0;
gameElapsed.value = 0;
updateLanePositions();
horseX.value = leftLaneX.value;
horseTargetX.value = leftLaneX.value;
startRoadScroll();
startMainLoop();
scheduleNextItem();
setTimeout(generateNewItem, 1000);
});
} else {
stopRoadScroll();
stopMainLoop();
stopItemGen();
for (const item of roadItems.value) {
if (item.animTimer) clearTimeout(item.animTimer);
}
roadItems.value = [];
effectAnims.value = [];
scoreEffects.value = [];
}
}, { immediate: true });
// 游戏结束:暂停全部动画,显示结果页
// watch(() => props.isGameOver, (over) => {
// if (over) {
// stopRoadScroll();
// stopMainLoop();
// stopItemGen();
// }
// });
</script>
<template>
<div class="h5-page game-stage">
<div class="only-bg"></div>
<div class="content-bg">
<div class="road-track" :style="{ transform: `translateY(${roadOffset}px)` }">
<div class="road-track" :style="{ transform: `translateY(${roadOffset % ROAD_HEIGHT}px)` }">
<div class="road-inner"></div>
<div class="road-inner"></div>
<div class="road-inner"></div>
</div>
<!-- 道具(屏幕坐标系,独立于跑道滚动) -->
<!-- 道具 -->
<template v-for="item in roadItems" :key="item.id">
<img
v-if="!item.collected"
......@@ -479,7 +363,7 @@ watch(() => props.isDivDescVisible, (visible) => {
class="road-item"
:class="`item-lane-${item.lane}`"
:style="{
top: `${item.y}px`,
top: `${item.y + roadOffset}px`,
}"
/>
</template>
......@@ -493,15 +377,6 @@ watch(() => props.isDivDescVisible, (visible) => {
/>
</template>
<!-- 积分飘字 -->
<div
v-for="ef in scoreEffects"
:key="ef.id"
class="score-float"
:class="ef.score > 0 ? 'score-plus' : 'score-minus'"
:style="{ left: ef.x + 'px', top: ef.y + 'px' }"
>{{ ef.score > 0 ? '+' : '' }}{{ ef.score }}</div>
<!-- 马 -->
<div class="horse-wrapper" :style="{ left: `${horseX}px`, bottom: `${HORSE_BOTTOM_PCT}%` }">
<img
......@@ -526,43 +401,6 @@ watch(() => props.isDivDescVisible, (visible) => {
<div class="img-hammer"></div>
<div class="img-left" @click="switchToLeft"></div>
<div class="img-right" @click="switchToRight"></div>
<!-- 游戏结束结果页覆盖层 -->
<!-- <Transition name="rank-fade">
<div v-if="isGameOver" class="rank-overlay">
<div class="ranklist-container">
<div class="txt-title">排行</div>
<div class="list-container">
<div class="row row-1">
<div class="col-1">排名</div>
<div class="col-2">用户</div>
<div class="col-3">成绩</div>
</div>
<div class="row-2">
<div class="row rank-row" v-for="(item, index) in rankList" :key="`${item.rank}-${index}`">
<div class="col-1">
<div>{{ item.rank }}</div>
</div>
<div class="col-2">
<div class="avatar" :style="item.avatar ? { backgroundImage: `url(${item.avatar})` } : {}"></div>
<div>{{ item.nickname }}</div>
</div>
<div class="col-3">{{ item.score }}</div>
</div>
</div>
<div class="row row-3">
<div class="col-1">{{ userRank > 0 ? userRank : '未上榜' }}</div>
<div class="col-2">
<div class="avatar" :style="{ backgroundImage: `url(${userAvatar})` }"></div>
<div>{{ userNickname }}</div>
</div>
<div class="col-3">{{ userScore }}</div>
</div>
</div>
</div>
<div class="img-rank-close" @click="$emit('rankClose')"></div>
</div>
</Transition> -->
</div>
</template>
......@@ -596,7 +434,7 @@ watch(() => props.isDivDescVisible, (visible) => {
.road-track {
width: 100%;
position: absolute;
top: -1202.1px;
top: 0;
left: 0;
display: flex;
flex-direction: column;
......@@ -611,67 +449,26 @@ watch(() => props.isDivDescVisible, (visible) => {
.road-item {
position: absolute;
/* width: 80px;
height: 80px; */
width: 77px;
height: 96px;
width: 80px;
height: 80px;
z-index: 5;
pointer-events: none;
}
.item-lane-left {
/* 当 content-bg 宽于 750px 设计稿时,自动居中 750px 游戏区域 */
left: calc((var(--stage-viewport-width, 750px) - 750px) / 2 + 170px);
left: 170px;
}
.item-lane-right {
left: calc((var(--stage-viewport-width, 750px) - 750px) / 2 + 520px);
left: 470px;
}
.effect-anim {
position: absolute;
width: 250px;
height: 250px;
width: 120px;
height: 120px;
z-index: 10;
pointer-events: none;
transform: translate(-50%, -50%);
}
/* 积分飘字动画 */
.score-float {
position: absolute;
z-index: 25;
font-size: 56px;
font-weight: 900;
pointer-events: none;
white-space: nowrap;
transform: translate(-50%, -100%);
animation: score-float-up 0.8s ease-out forwards;
}
.score-plus {
color: #FFD700;
text-shadow: 0 3px 8px rgba(0, 0, 0, 0.7), 0 0 20px rgba(255, 215, 0, 0.5);
}
.score-minus {
color: #FF4444;
text-shadow: 0 3px 8px rgba(0, 0, 0, 0.7), 0 0 20px rgba(255, 68, 68, 0.5);
}
@keyframes score-float-up {
0% {
opacity: 1;
transform: translate(-50%, -100%) scale(0.5);
}
25% {
opacity: 1;
transform: translate(-50%, -140%) scale(1.15);
}
100% {
opacity: 0;
transform: translate(-50%, -260%) scale(0.8);
}
}
.horse-wrapper {
......@@ -794,114 +591,6 @@ watch(() => props.isDivDescVisible, (visible) => {
transform: translateX(0);
}
/* 游戏结束结果页覆盖层 */
.rank-overlay {
position: absolute;
inset: 0;
z-index: 100;
background: rgba(0, 0, 0, 0.65);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.ranklist-container {
width: 550px;
background: v-bind('imageUrls.ranklist') center / 100% 100% no-repeat;
border-radius: 16px;
padding: 60px 30px 30px;
display: flex;
flex-direction: column;
align-items: center;
.txt-title {
font-size: 36px;
font-weight: bold;
color: #AA0000;
letter-spacing: 2px;
margin-bottom: 16px;
}
.list-container {
width: 100%;
text-align: center;
line-height: 56px;
font-size: 24px;
.row {
display: grid;
grid-template-columns: 100px auto 120px;
align-items: center;
}
.row-1 {
color: #AA0000;
font-weight: bold;
background: linear-gradient(-84deg, #FFD396 0%, #FFF3D1 53%, #FFD59C 100%);
border-radius: 12px 12px 0 0;
}
.row-2 {
color: #633911;
max-height: 440px;
overflow-y: auto;
.rank-row:nth-child(odd) {
background: #F2E2AE;
}
.rank-row:nth-child(even) {
background: #FCEEBF;
}
}
.row-3 {
background: linear-gradient(0deg, #F4B64C 0%, #F7C96F 100%);
border-radius: 0 0 12px 12px;
font-weight: bold;
}
.col-2 {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
.avatar {
width: 40px;
height: 40px;
flex: 0 0 auto;
border-radius: 50%;
background-position: center;
background-size: cover;
}
}
}
}
.img-rank-close {
width: 48px;
height: 48px;
margin-top: 24px;
border-radius: 50%;
background: v-bind('imageUrls.close') center / cover no-repeat;
cursor: pointer;
}
.rank-fade-enter-active {
transition: opacity 0.35s ease-out;
}
.rank-fade-leave-active {
transition: opacity 0.25s ease-in;
}
.rank-fade-enter-from,
.rank-fade-leave-to {
opacity: 0;
}
.img-logo {
position: absolute;
top: 46px;
......
......@@ -12,21 +12,21 @@ const gameId = 'game4';
type GameView = 'loading' | 'playing' | 'score'
const imageUrls = {
bg: cssAssetUrl('game4/h5-game4-bg.svg'),
bg: cssAssetUrl('game4/h5-game4-bg.webp'),
bg2: cssAssetUrl('game1/bg2.png'),
playBg: cssAssetUrl('game4/h5-game4-bg2.svg'),
logo: cssAssetUrl('game4/logo.svg'),
playBg: cssAssetUrl('game4/h5-game4-bg2.webp'),
logo: cssAssetUrl('game4/logo.webp'),
time: cssAssetUrl('game4/h5-game4-clock.svg'),
title: cssAssetUrl('game4/h5-game4-title1.svg'),
title2: cssAssetUrl('game4/h5-game4-title2.svg'),
title: cssAssetUrl('game4/h5-game4-title1.webp'),
title2: cssAssetUrl('game4/h5-game4-title2.webp'),
// avatarBg: cssAssetUrl('game4/h5-game4-join.svg'),
userBg: cssAssetUrl('game4/user-bg.svg'),
startBg: cssAssetUrl('game4/start-bg.svg'),
gift: cssAssetUrl('game4/h5-game4-gift.svg'),
userBg: cssAssetUrl('game4/user-bg.webpp'),
startBg: cssAssetUrl('game4/start-bg.webp'),
gift: cssAssetUrl('game4/h5-game4-gift.webp'),
avatar: cssAssetUrl('game1/avatar.png'),
gu: cssAssetUrl('game4/h5-game4-horse.svg'),
gu: cssAssetUrl('game4/h5-game4-horse.webp'),
clock: cssAssetUrl('game1/clock.png'),
horse: cssAssetUrl('game4/h5-game4-horse.svg'),
horse: cssAssetUrl('game4/h5-game4-horse.webp'),
yaoyiyao: cssAssetUrl('game4/h5-game4-yaoyiyao.svg'),
scoreBg: cssAssetUrl('game1/score-bg.png'),
back: cssAssetUrl('game4/back.svg'),
......
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.
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.
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.
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.
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.
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.
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -11,7 +11,7 @@ const password = ref('')
const imageUrls = {
bg1: cssAssetUrl('main-bg1.png'),
logo: cssAssetUrl('game1/logo.png'),
title1: cssAssetUrl('main-title.png'),
title1: cssAssetUrl('main-title.webp'),
}
async function login() {
......
......@@ -8,7 +8,7 @@ import { onMounted, onUnmounted, ref } from 'vue';
const imageUrls = {
bg1: cssAssetUrl('main-bg1.png'),
logo: cssAssetUrl('game1/logo.png'),
title1: cssAssetUrl('main-title.png'),
title1: cssAssetUrl('main-title.webp'),
icon1: cssAssetUrl('icon1.png'),
icon2: cssAssetUrl('icon2.png'),
......
......@@ -42,7 +42,7 @@ const imageUrls = {
// ========== 马匹帧配置(只用 ma1/ma2/ma3,不用 ma4) ==========
const HORSE_SIZE = 280;
const HORSE_START_X = -HORSE_SIZE;
const HORSE_START_X = -100;
const HORSE_END_X = 1920;
const HORSE_Y_OFFSET = 50; // 马匹整体偏移
......@@ -341,7 +341,6 @@ const startIntro = async () => {
isGameRunning.value = false;
horses.value = [];
horseIdCounter = 0;
// 重置加速状态
transientBoosts.value = {};
Object.keys(prevScores).forEach(k => delete prevScores[k]);
preloadHorseFrames();
......@@ -349,11 +348,45 @@ const startIntro = async () => {
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);
console.log('[game3 Rank1View] 倒计时结束,马匹开始奔跑');
gameStarted.value = true;
isGameRunning.value = true;
updateHorses(list.value);
const validPlayers = list.value.filter(p => {
const key = getPlayerKey(p);
return key && !key.startsWith('empty-') && p.nickname !== '-' && p.nickname !== '虚位以待';
});
if (validPlayers.length > 0) {
updateHorses(list.value);
} else {
const count = 8 + Math.floor(Math.random() * 3);
const now = performance.now();
const newHorses: Horse[] = [];
for (let i = 0; i < count; i++) {
const type = HORSE_TYPES[i % HORSE_TYPES.length]!;
const lane = i % LANE_TOP_POSITIONS.length;
const delayMs = i * 50 + Math.random() * 50;
const durationMs = (60 + Math.random() * 40) * 1000;
newHorses.push({
id: `horse-${++horseIdCounter}`,
horseType: type,
lane,
frameIndex: Math.floor(Math.random() * (horseFrameCount[type] ?? 12)),
frameTimer: 0,
x: HORSE_START_X,
xPercent: 0,
boost: 0,
nickname: `马匹${i + 1}`,
avatar: '',
playerKey: `auto-${i}`,
});
}
horses.value = newHorses;
console.log('[game3 Rank1View] 生成', count, '匹默认马');
}
startHorseLoop();
await countdownPromise;
......
......@@ -14,14 +14,14 @@ const emit = defineEmits<{
}>();
const imageUrls = {
bg: cssAssetUrl("game4/bg.svg"),
bg: cssAssetUrl("game4/bg.webp"),
logo: cssAssetUrl("game1/logo.png"),
title1: cssAssetUrl("game4/title1.svg"),
title1: cssAssetUrl("game4/title1.webp"),
title2: cssAssetUrl("game4/title2.svg"),
qrcode: assetUrl("game4/qrcode.png"),
gift: cssAssetUrl("game4/gift.svg"),
gu: cssAssetUrl("game4/big-brum.svg"),
smallGu: cssAssetUrl("game4/small-brum.svg"),
gift: cssAssetUrl("game4/gift.webp"),
gu: cssAssetUrl("game4/big-brum.webp"),
smallGu: cssAssetUrl("game4/small-brum.webp"),
bottomLayers: cssAssetUrl("game1/bottom-layers.png"),
avatarAnimation: cssAssetUrl("avatar_animation.png"),
avatar: cssAssetUrl("avatar.png"),
......
......@@ -5,7 +5,7 @@ import type Player from "@/commons/player.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");
const defaultAvatarUrl = assetUrl("game4/default-avator.webp");
const props = defineProps<{
rankList: Player[];
......@@ -25,22 +25,22 @@ const imageUrls = {
bgTop: cssAssetUrl("game4/bg-top.webp"),
bgBottom: cssAssetUrl("game4/bg-bottom.webp"),
logo: cssAssetUrl("game1/logo.png"),
titleBg: cssAssetUrl("game4/horse-title-bg.svg"),
bigBrum: cssAssetUrl("game4/big-brum.svg"),
titleBg: cssAssetUrl("game4/horse-title-bg.webp"),
bigBrum: cssAssetUrl("game4/big-brum.webp"),
username: cssAssetUrl("game4/username.svg"),
avatar: cssAssetUrl("game4/default-avator.svg"),
resultBg: cssAssetUrl("game4/resultBg.svg"),
avatar: cssAssetUrl("game4/default-avator.webp"),
resultBg: cssAssetUrl("game4/resultbg.webp"),
next: cssAssetUrl("game1/next.png"),
back: cssAssetUrl("game1/back.png"),
// 结果弹窗 - podium 相关资源(复用 game1)
podium: cssAssetUrl("game4/podium.svg"),
podium: cssAssetUrl("game4/podium.webp"),
fireworks: cssAssetUrl("game1/fireworks.png"),
guan1: cssAssetUrl("game1/guan1.png"),
pai1: cssAssetUrl("game4/pai1.svg"),
pai1: cssAssetUrl("game4/pai1.webp"),
guan2: cssAssetUrl("game1/guan2.png"),
pai2: cssAssetUrl("game4/pai2.svg"),
pai2: cssAssetUrl("game4/pai2.webp"),
guan3: cssAssetUrl("game1/guan3.png"),
pai3: cssAssetUrl("game4/pai3.svg"),
pai3: cssAssetUrl("game4/pai3.webp"),
rankIcon: cssAssetUrl("game1/no-icon.png"),
avatarAnimation: cssAssetUrl("avatar_animation.png"),
};
......@@ -52,12 +52,12 @@ const TOTAL_DISTANCE_PX = 2160; // -120px ~ 2040px
const BASE_DURATION_SEC = 220;
const BASE_SPEED_PERCENT_PER_SEC = 100 / BASE_DURATION_SEC; // ≈ 0.455%/s 基准速度
// 马匹精灵图帧动画(horse.png 共 12 帧,改用 JS 切帧,避免 CSS steps 在部分浏览器不生效)
// 马匹精灵图帧动画(horse.webp 共 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");
const horseSpriteUrl = assetUrl("game4/horse.webp");
// ========== 瞬时加速脉冲机制 ==========
// H5 每次摇一摇发送累计积分,PC 端检测 score 增长 → 触发短暂加速脉冲 → 迅速衰减
......@@ -398,25 +398,32 @@ const startIntro = async () => {
gameStarted.value = false;
isGameRunning.value = false;
stopApplauseMusic();
// 重置瞬时加速和积分追踪
transientBoosts.value = {};
Object.keys(prevScores).forEach((k) => delete prevScores[k]);
// 启动完整倒计时:3s 开场 + 3s 游戏(DesignStage 内部管理两个阶段的 UI)
const countdownPromise = designStage.value?.startCountdown(3, 60);
// 等待 3-2-1 开场倒计时结束(~3.5s 后安全触发)
await new Promise((r) => setTimeout(r, 3500));
// Phase 2: 倒计时结束 → 游戏正式开始,背景动画+马匹启动
gameStarted.value = true;
isGameRunning.value = true;
if (horses.value.length === 0) {
const count = 8 + Math.floor(Math.random() * 3);
const mockPlayers: Player[] = Array.from({ length: count }, (_, i) => ({
userid: `auto-${i}`,
nickname: mockNicknames[i] || `马匹${i + 1}`,
avatar: mockAvatars[i] || '',
score: Math.floor(Math.random() * 60) + 10,
}));
updateHorses(mockPlayers);
console.log('[game4 Rank1View] 生成', count, '匹默认马');
}
startGameLoop();
if ($is_local_mode()) {
startMockBoost();
}
// 等待 60s 游戏倒计时完全结束 → 才出现结果页
await countdownPromise;
stopGame();
};
......@@ -442,7 +449,7 @@ const renderAvatar = (item: any) => {
if (item && item.avatar) {
return `background: url('${item.avatar}') center / cover no-repeat;`;
}
// 无头像时用 default-avator.svg 占位(白底 + 居中 cover)
// 无头像时用 default-avator 占位(白底 + 居中 cover)
return `background: #fff url('${defaultAvatarUrl}') center / cover no-repeat;`;
};
......
......@@ -14,7 +14,7 @@ const emit = defineEmits<{
const FINAL_RANK_LIMIT = 10;
const imageUrls = {
bg: cssAssetUrl("game4/bg.svg"),
bg: cssAssetUrl("game4/bg.webp"),
logo: cssAssetUrl("game1/logo.png"),
title: cssAssetUrl("game1/title3.png"),
title2: cssAssetUrl("game1/title4.png"),
......
......@@ -14,7 +14,7 @@ const emit = defineEmits<{
}>();
const imageUrls = {
bg: cssAssetUrl('game5/bg.svg'),
bg: cssAssetUrl('game5/bg.webp'),
logo: cssAssetUrl('game1/logo.png'),
title1: cssAssetUrl('game5/title1.webp'),
// title2: cssAssetUrl('game1/title2.png'),
......
......@@ -22,7 +22,7 @@ const showResult = ref(false);
const imageUrls = {
runway: cssAssetUrl("game5/paodao.webp"),
bg: cssAssetUrl("game5/bg.svg"),
bg: cssAssetUrl("game5/bg.webp"),
logo: cssAssetUrl("game1/logo.png"),
title: cssAssetUrl("game5/title3.webp"),
rankNo4: cssAssetUrl("game6/no4.png"),
......
......@@ -15,7 +15,7 @@ const emit = defineEmits<{
const FINAL_RANK_LIMIT = 10;
const imageUrls = {
bg: cssAssetUrl('game5/bg.svg'),
bg: cssAssetUrl('game5/bg.webp'),
logo: cssAssetUrl('game1/logo.png'),
title: cssAssetUrl('game5/title.webp'),
title2: cssAssetUrl('game5/title2.webp'),
......
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