Commit 195da081 authored by 陈冲's avatar 陈冲
parents 770652bb 27013171
<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, 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
}
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[]>([])
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 rankScore = computed<number | string>(() => {
const currentPlayer = rankList.value
.slice(0, 10)
.find(item => item.rank === rank.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_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 = ''
}
function setDivDescVisible(visible: boolean) {
isDivDescVisible.value = visible
}
function stopGameCountdown() {
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
}
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 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)
}
function startGameView() {
stopGameCountdown()
score.value = 0
rank.value = 0
rankList.value = []
isGameOver.value = false
showGameRank.value = false
currentView.value = 'playing'
startGameCountdown()
}
function showGameOverRank() {
stopGameCountdown()
setDivDescVisible(false)
// 保持在 playing 视图,结果页由 PlayingView 内部渲染
isGameOver.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) => {
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 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
isGameOver.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)
}
// // TODO_LOCAL: 本地模拟游戏开始,稍后部署上线前删除此段
// if ($is_run_local()) {
// showGameRule.value = false
// currentView.value = 'playing'
// startGameCountdown()
// }
})
onBeforeUnmount(() => {
_offSocketMessage.value?.()
stopGame6Music()
if (guFrameTimer) {
window.clearTimeout(guFrameTimer)
}
stopGameCountdown()
if (token.value) {
window.removeEventListener('beforeunload', confirmRefresh)
}
})
const showGameRuleHandler = () => {
showGameRule.value = !showGameRule.value;
}
const renderBG = () => {
if (currentView.value == 'playing') {
return `${imageUrls.bg2} center/cover`
}
return `${imageUrls.bg} center/cover`;
}
const rankCloseHandler = () => {
showGameRank.value = false
isGameOver.value = false
resetToLoadingView()
}
</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>
</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="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>
</template>
<style scoped>
.rule-container {
.rule-txt-container {
background: v-bind('imageUrls.rule') center / cover no-repeat;
width: 692px;
height: 797px;
margin-top: -20%;
transform: scale(0.5);
.rule-title {
text-align: center;
color: white;
font-weight: bold;
font-size: 26pt;
line-height: 50pt;
letter-spacing: 4px;
}
.rule-txt {
font-size: 26pt;
padding: 40px;
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.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);
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;
}
.row-2 {
color: #633911;
.rank-row:nth-child(odd) {
background: #F2E2AE;
}
.rank-row:nth-child(even) {
background: #FCEEBF;
}
.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;
border-radius: 29px;
background: v-bind('imageUrls.close')center/cover;
transform: scale(0.7);
margin-top: 500px;
}
</style>
<script setup lang="ts">
import { $getWechat } from '@/commons/utils.ts'
import { onMounted, ref } from 'vue';
defineProps<{
imageUrls: Record<string, string>
userJoinStatus: boolean
}>();
const nickname = ref('');
const avatar = ref('');
onMounted(() => {
const wechat = $getWechat();
nickname.value = wechat.nickname;
avatar.value = wechat.avatar;
});
</script>
<template>
<div class="h5-page game-stage">
<div class="game-desc" @click="$emit('touchGameRule')">游戏规则</div>
<!-- <div class="img-logo"></div> -->
<div class="img-loadingbg1">游戏等待开始倒计时60秒</div>
<div class="img-loadingbg2">游戏即将开始 敬请期待</div>
<div class="txt-bottom" v-if="userJoinStatus">您已成功加入游戏<br />等待主持人开始</div>
</div>
</template>
<style scoped>
.img-loadingbg1 {
font-size: 27pt;
width: 502px;
height: 90px;
background: v-bind('imageUrls.loadingBg1') center/cover;
border-radius: 45px;
position: absolute;
top: 513px;
left: 50%;
margin-left: -251px;
text-align: center;
line-height: 90px;
font-weight: bold;
color: #B90103;
letter-spacing: 2pt;
}
.img-loadingbg2 {
width: 100%;
height: 126px;
background: v-bind('imageUrls.loadingBg2') center/cover;
position: absolute;
top: 50%;
left: 50%;
font-size: 46px;
text-align: center;
line-height: 126px;
color: white;
letter-spacing: 2pt;
transform: translate(-50%, -50%);
}
.txt-bottom {
position: absolute;
bottom: 130px;
width: 100%;
line-height: 40px;
color: white;
font-size: 28px;
text-align: center;
}
.game-stage {
position: absolute;
width: 750px;
height: 1624px;
padding: 0;
}
.game-desc {
position: absolute;
top: 40%;
left: var(--stage-viewport-left, 0);
display: flex;
width: 58px;
height: 158px;
padding-top: 10px;
align-items: center;
justify-content: center;
border-radius: 0 12px 12px 0;
background: rgba(40, 12, 8, 0.52);
color: rgba(255, 255, 255, 0.9);
font-size: 25px;
letter-spacing: 10px;
line-height: 1.25;
text-align: center;
text-orientation: upright;
transform: translateY(-50%);
writing-mode: vertical-rl;
}
.bg-bottom {
position: absolute;
bottom: 0;
left: var(--stage-viewport-left, 0);
width: var(--stage-viewport-width, 750px);
height: 479px;
background: v-bind('imageUrls.bg2') center/cover;
opacity: 0.6;
}
.img-logo {
position: absolute;
top: 66px;
left: 20px;
width: 428px;
height: 77px;
background: v-bind('imageUrls.logo') center/cover;
}
.img-title {
position: absolute;
top: 200px;
left: 50%;
width: 609px;
height: 243px;
background: v-bind('imageUrls.title') center/cover;
transform: translateX(-50%);
}
.img-avatar {
position: absolute;
top: 480px;
left: 50%;
/* width: 148px;
height: 148px;
background: v-bind('avatar') center/cover; */
transform: translateX(-50%);
}
.txt-nickname {
position: absolute;
top: 640px;
left: 50%;
color: #E00000;
font-size: 25pt;
font-weight: bold;
transform: translateX(-50%);
}
.txt-loading {
position: absolute;
top: 710px;
left: 50%;
width: 288px;
color: #E00000;
font-size: 25pt;
font-weight: bold;
text-align: center;
transform: translateX(-50%);
}
.loading-gu {
position: absolute;
bottom: 100px;
left: 50%;
width: 600px;
height: 534px;
background: v-bind('imageUrls.gu') center/cover;
transform: translateX(-50%);
}
</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'
const props = defineProps<{
imageUrls: Record<string, string>
countdownInterval: number
isDivDescVisible: boolean
tick: number
rank: number
isGameOver: boolean
rankList: Array<{ rank: number; nickname: string; avatar: string; score: number | string }>
userRank: number
userScore: number | string
userNickname: string
userAvatar: string
}>();
const emit = defineEmits<{
touch: [mole: boolean]
scoreChange: [delta: number]
rankClose: []
}>();
// ========== 常量 ==========
const ROAD_TOP = 421.9;
const ROAD_HEIGHT = 1624 - ROAD_TOP;
const HORSE_W = 131;
const HORSE_H = 285;
const HORSE_BOTTOM_PCT = 19.11;
const ITEM_SIZE = 80;
const GAME_DURATION = 60;
const LEFT_LANE_X = 170;
const RIGHT_LANE_X = 470;
// ========== 马帧 ==========
const HORSE_FRAME_COUNT = 21;
const horseFrames = Array.from({ length: HORSE_FRAME_COUNT }, (_, i) =>
assetUrl(`game3/horse/1-ma_${String(i).padStart(2, '0')}.png`)
);
// ========== 金币/炸弹帧 ==========
const JINBI_FRAME_COUNT = 25;
const jinbiFrames = Array.from({ length: JINBI_FRAME_COUNT }, (_, i) =>
assetUrl(`game3/jinbi/1-yuanbao_${String(i).padStart(2, '0')}.png`)
);
const BOM_FRAME_COUNT = 28;
const bomFrames = Array.from({ length: BOM_FRAME_COUNT }, (_, i) =>
assetUrl(`game3/bom/1-zha_${String(i).padStart(2, '0')}.png`)
);
const fudaiUrl = assetUrl('game3/icon_fudai.webp');
const dileiUrl = assetUrl('game3/icon_dilei.webp');
// ========== 马状态 ==========
const horseLane = ref<'left' | 'right'>('left');
const horseFrameIndex = ref(0);
const horseX = ref(LEFT_LANE_X);
const horseTargetX = ref(LEFT_LANE_X);
const switchToLeft = () => {
if (horseLane.value !== 'left') {
horseLane.value = 'left';
horseTargetX.value = LEFT_LANE_X;
}
};
const switchToRight = () => {
if (horseLane.value !== 'right') {
horseLane.value = 'right';
horseTargetX.value = RIGHT_LANE_X;
}
};
// ========== 键盘控制 ==========
const handleKeydown = (e: KeyboardEvent) => {
if (!props.isDivDescVisible) return;
if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') {
switchToLeft();
} else if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') {
switchToRight();
}
};
// ========== 路背景加速滚动 ==========
const roadOffset = ref(0);
let roadRafId: number | undefined;
let roadLastTs = 0;
const BASE_ROAD_SPEED = 200;
const MAX_ROAD_SPEED = 700;
const gameElapsed = ref(0);
const currentRoadSpeed = ref(BASE_ROAD_SPEED);
const startRoadScroll = () => {
stopRoadScroll();
roadLastTs = 0;
gameElapsed.value = 0;
const startTs = performance.now();
const tick = (ts: number) => {
if (!props.isDivDescVisible) {
roadRafId = requestAnimationFrame(tick);
return;
}
if (!roadLastTs) roadLastTs = ts;
const dt = Math.min(ts - roadLastTs, 100);
roadLastTs = ts;
gameElapsed.value = (ts - startTs) / 1000;
const progress = Math.min(gameElapsed.value / GAME_DURATION, 1);
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);
};
roadRafId = requestAnimationFrame(tick);
};
const stopRoadScroll = () => {
if (roadRafId) {
cancelAnimationFrame(roadRafId);
roadRafId = undefined;
}
};
// ========== 持续道具生成系统 ==========
type ItemKind = 'coin' | 'bomb';
interface RoadItem {
id: number;
kind: ItemKind;
lane: 'left' | 'right';
y: number;
collected: boolean;
animFrame: number;
animTimer: ReturnType<typeof setTimeout> | undefined;
}
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;
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.55 ? 'coin' : 'bomb';
// 概率分配车道,但避免与前一个同车道道具太近
let 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 newItem: RoadItem = {
id: itemIdCounter++,
kind,
lane,
y: -ITEM_SIZE,
collected: false,
animFrame: 0,
animTimer: undefined,
};
roadItems.value.push(newItem);
scheduleNextItem();
}
function stopItemGen() {
if (genTimerId) {
clearTimeout(genTimerId);
genTimerId = undefined;
}
}
// 道具 y 已是 screen-space(content-bg 坐标系),清理逻辑直接判断
function cleanupOffscreenItems() {
roadItems.value = roadItems.value.filter(item => {
// item.y 超出 content-bg 底部即删除,超出顶部也删除
return item.y < ROAD_HEIGHT && item.y > -ITEM_SIZE;
});
}
// ========== 碰撞检测 ==========
// 马匹在屏幕上的Y坐标(从 content-bg 顶部算起,horse 使用 bottom: 19.11% 定位)
const horseCSSBottom = ROAD_HEIGHT * (1 - HORSE_BOTTOM_PCT / 100);
const horseCSSTop = horseCSSBottom - HORSE_H;
const checkCollisions = () => {
const horseCenterX = horseX.value + HORSE_W / 2;
const horseTopCenterY = horseCSSTop + HORSE_H * 0.15;
const horseRadius = HORSE_W * 0.35;
for (const item of roadItems.value) {
if (item.collected) continue;
const itemX = item.lane === 'left' ? LEFT_LANE_X : RIGHT_LANE_X;
// item.y 已经是屏幕坐标(content-bg 内),无需加 roadOffset
const itemCenterX = itemX + ITEM_SIZE / 2;
const itemCenterY = item.y + ITEM_SIZE / 2;
const dist = Math.hypot(horseCenterX - itemCenterX, horseTopCenterY - itemCenterY);
if (dist < horseRadius + ITEM_SIZE * 0.35) {
item.collected = true;
playCollectEffect(item, itemX, item.y);
}
}
};
const playCollectEffect = (item: RoadItem, x: number, y: number) => {
const isCoin = item.kind === 'coin';
const delta = isCoin ? 5 : -3;
if (isCoin) {
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 frameDuration = 1000 / totalFrames;
// 碰撞动画(大幅放大尺寸)
effectAnims.value.push({ id: animId, kind: item.kind, frame: 0, x, y });
let frameIdx = 0;
const tick = () => {
frameIdx++;
if (frameIdx >= totalFrames) {
effectAnims.value = effectAnims.value.filter(a => a.id !== animId);
return;
}
const anim = effectAnims.value.find(a => a.id === animId);
if (anim) anim.frame = frameIdx;
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,
y: y,
score: delta,
});
setTimeout(() => {
const idx = scoreEffects.value.findIndex(e => e.id === effectId);
if (idx >= 0) scoreEffects.value.splice(idx, 1);
}, 800);
};
// ========== 主循环 ==========
let mainRafId: number | undefined;
let mainLastTs = 0;
let perSecondTimer: ReturnType<typeof setInterval> | undefined;
let horseFrameAccum = 0;
const startMainLoop = () => {
stopMainLoop();
mainLastTs = 0;
horseFrameAccum = 0;
const tick = (ts: number) => {
if (!props.isDivDescVisible) {
mainRafId = requestAnimationFrame(tick);
return;
}
if (!mainLastTs) mainLastTs = ts;
const dt = Math.min(ts - mainLastTs, 200);
mainLastTs = ts;
horseFrameAccum += dt;
const horseInterval = 80 * BASE_ROAD_SPEED / currentRoadSpeed.value;
while (horseFrameAccum >= horseInterval) {
horseFrameAccum -= horseInterval;
horseFrameIndex.value = (horseFrameIndex.value + 1) % HORSE_FRAME_COUNT;
}
const lerpFactor = 1 - Math.pow(0.001, dt / 1000);
horseX.value += (horseTargetX.value - horseX.value) * lerpFactor;
// 先移动所有道具
for (const item of roadItems.value) {
if (!item.collected) {
item.y += currentRoadSpeed.value * dt / 1000;
}
}
// 清理已离开屏幕的道具,避免 roadOffset 回绕后旧道具闪现
cleanupOffscreenItems();
// 最后再检测碰撞,确保只检测屏幕内确实可见的道具
checkCollisions();
mainRafId = requestAnimationFrame(tick);
};
mainRafId = requestAnimationFrame(tick);
perSecondTimer = setInterval(() => {
if (props.isDivDescVisible) {
emit('scoreChange', 2);
}
}, 1000);
};
const stopMainLoop = () => {
if (mainRafId) {
cancelAnimationFrame(mainRafId);
mainRafId = undefined;
}
if (perSecondTimer) {
clearInterval(perSecondTimer);
perSecondTimer = undefined;
}
};
// ========== 生命周期 ==========
onMounted(() => {
window.addEventListener('keydown', handleKeydown);
nextTick(() => {
startRoadScroll();
startMainLoop();
scheduleNextItem();
});
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeydown);
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) => {
if (visible) {
nextTick(() => {
roadItems.value = [];
effectAnims.value = [];
scoreEffects.value = [];
roadOffset.value = 0;
gameElapsed.value = 0;
startRoadScroll();
startMainLoop();
scheduleNextItem();
});
} 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-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"
:src="item.kind === 'coin' ? fudaiUrl : dileiUrl"
class="road-item"
:class="`item-lane-${item.lane}`"
:style="{
top: `${item.y}px`,
}"
/>
</template>
<!-- 碰撞特效 -->
<template v-for="anim in effectAnims" :key="anim.id">
<img
:src="(anim.kind === 'coin' ? jinbiFrames : bomFrames)[anim.frame]"
class="effect-anim"
:style="{ left: `${anim.x}px`, top: `${anim.y}px` }"
/>
</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
v-for="(url, idx) in horseFrames"
:key="idx"
:src="url"
class="horse-frame"
:class="{ active: idx === horseFrameIndex }"
alt=""
/>
</div>
</div>
<Transition name="div-desc-slide" appear>
<div v-if="isDivDescVisible" class="div-desc">
<span class="desc-clock" aria-hidden="true"></span>
<span class="desc-time">{{ countdownInterval }}</span>
</div>
</Transition>
<div class="img-logo"></div>
<div class="img-hydt"></div>
<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>
<style scoped>
.game-stage {
position: absolute;
width: 750px;
height: 1624px;
padding: 0;
}
.only-bg {
position: absolute;
top: 0;
left: var(--stage-viewport-left, 0);
width: var(--stage-viewport-width, 750px);
height: 421.9px;
background: v-bind('imageUrls.bg3') center / cover no-repeat;
}
.content-bg {
position: absolute;
top: 421.9px;
left: var(--stage-viewport-left, 0);
width: var(--stage-viewport-width, 750px);
height: 1202.1px;
overflow: hidden;
z-index: 1;
}
.road-track {
width: 100%;
position: absolute;
top: -1202.1px;
left: 0;
display: flex;
flex-direction: column;
}
.road-inner {
width: 100%;
height: 1202.1px;
background: v-bind('imageUrls.bg4') center / cover no-repeat;
flex-shrink: 0;
}
.road-item {
position: absolute;
width: 80px;
height: 80px;
z-index: 5;
pointer-events: none;
}
.item-lane-left {
left: 170px;
}
.item-lane-right {
left: 470px;
}
.effect-anim {
position: absolute;
width: 250px;
height: 250px;
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 {
position: absolute;
width: 131px;
height: 285px;
z-index: 8;
}
.horse-frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
opacity: 0;
pointer-events: none;
}
.horse-frame.active {
opacity: 1;
}
.img-hydt {
position: absolute;
width: 454px;
height: 181px;
top: 174px;
left: 52%;
transform: translate(-50%, -50%);
background: v-bind('imageUrls.hydt') center / cover;
}
.img-left {
position: absolute;
width: 128px;
height: 128px;
left: 144px;
bottom: 5.48%;
z-index: 20;
background: v-bind('imageUrls.left') center / cover no-repeat;
cursor: pointer;
}
.img-right {
position: absolute;
width: 128px;
height: 128px;
right: 144px;
bottom: 5.48%;
z-index: 20;
background: v-bind('imageUrls.right') center / cover no-repeat;
cursor: pointer;
}
.img-hammer {
width: 148px;
height: 148px;
background: v-bind('imageUrls.hammer') center / cover;
position: absolute;
left: 50%;
bottom: 100px;
transform: translate(-50%, 0);
}
.div-desc {
position: absolute;
top: 150px;
left: var(--stage-viewport-left, 0);
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
width: 180px;
height: 80px;
border-radius: 0 56px 56px 0;
background: rgba(40, 12, 8, 0.48);
color: #fff;
.desc-clock {
position: absolute;
left: 20px;
width: 38px;
height: 44px;
border-radius: 50%;
background: v-bind('imageUrls.clock') center / cover;
}
.desc-time {
position: absolute;
right: 40px;
color: white;
font-size: 35pt;
line-height: 1;
}
}
.div-desc-slide-enter-active {
transition:
transform 360ms ease-out 220ms,
opacity 360ms ease-out 220ms;
}
.div-desc-slide-leave-active {
transition:
transform 260ms ease-in,
opacity 260ms ease-in;
}
.div-desc-slide-enter-from,
.div-desc-slide-leave-to {
opacity: 0;
transform: translateX(-110%);
}
.div-desc-slide-enter-to,
.div-desc-slide-leave-from {
opacity: 1;
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;
left: 20px;
width: 428px;
height: 77px;
background: v-bind('imageUrls.logo') center / cover;
}
</style>
<!-- <script setup lang="ts">
import { $getWechat } from '@/commons/utils';
import { onMounted, ref } from 'vue';
defineProps<{
imageUrls: Record<string, string>
tick: number
rank: number
}>()
defineEmits<{
showRank: []
replay: []
back: []
}>()
const nickname = ref('');
const avatar = ref('');
onMounted(() => {
const wechat = $getWechat();
nickname.value = wechat.nickname;
avatar.value = wechat.avatar;
});
</script>
<template>
<div class="h5-page game-stage">
<div class="img-logo"></div>
</div>
</template>
<style scoped>
.game-stage {
position: absolute;
width: 750px;
height: 1624px;
padding: 0;
}
.img-logo {
position: absolute;
top: 66px;
left: 50%;
width: 428px;
height: 77px;
background: v-bind('imageUrls.logo') center/cover;
transform: translateX(-50%);
}
</style> -->
......@@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from 'vue-router'
import { $read } from '@/commons/utils'
import Loading from '@/pages/Loading.vue'
import Game1 from '@/pages/game1/Game.vue'
import Game3 from '@/pages/game3/Game3.vue'
import Game4 from '@/pages/game4/Game4.vue'
import Game5 from '@/pages/game5/Game5.vue'
import Game6 from '@/pages/game6/Game.vue'
......@@ -31,6 +32,11 @@ const router = createRouter({
// },
},
{
path: '/game3',
name: 'Game3',
component: Game3,
},
{
path: '/game4',
name: 'Game4',
component: Game4,
......
<script setup lang="ts">
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import Loading from './views/LoadingView.vue'
import Rank1 from './views/Rank1View.vue'
import Rank2 from './views/Rank2View.vue'
import { useAdminGameSocket } from '@/composables/useAdminGameSocket'
import type Player from '@/commons/player.ts'
import { playApplauseMusic, playGame6Music, stopAllMusic, stopGame6Music } from '@/commons/music'
import { $confirm, $remove_socket_storage, $toast } from '@/commons/utils.ts'
const router = useRouter()
type GameScreen = 'loading' | 'rank1' | 'rank2'
const PLAYER_LIMIT = 20
const RANK_LIMIT = 10
const emptyLoadingPlayer = () => ({
score: '-',
telephone: '',
userid: '',
wechat_id: '',
nickname: '虚位以待',
avatar: '',
})
const emptyRankPlayer = (index: number): Player => ({
wechat_id: `empty-${index}`,
nickname: '虚位以待',
avatar: '',
score: '-',
})
const screen = ref<GameScreen>('loading')
const loadingPlayers = ref<any[]>(Array.from({ length: PLAYER_LIMIT }, emptyLoadingPlayer))
const playerCount = ref(0)
const rankPlayers = ref<Player[]>(Array.from({ length: RANK_LIMIT }, (_, index) => emptyRankPlayer(index)))
const rank1Ref = ref<InstanceType<typeof Rank1> | null>(null)
let isPlayingStartAnimation = false
const confirmRefresh = (event: BeforeUnloadEvent) => {
event.preventDefault()
event.returnValue = ''
}
const renderPlayers = (data: any) => {
const players = Array.isArray(data?.list) ? data.list : []
playerCount.value = Number(data?.count ?? players.length)
loadingPlayers.value = Array.from(
{ length: PLAYER_LIMIT },
(_, index) => players[index] ?? emptyLoadingPlayer(),
)
}
const updateRankPlayers = (data: any) => {
if (!Array.isArray(data)) return
rankPlayers.value = Array.from(
{ length: RANK_LIMIT },
(_, index) => data[index] ?? emptyRankPlayer(index),
)
}
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 ?? '-',
})
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)
},
)
}
const resetRoundState = () => {
playerCount.value = 0
loadingPlayers.value = Array.from({ length: PLAYER_LIMIT }, emptyLoadingPlayer)
rankPlayers.value = Array.from({ length: RANK_LIMIT }, (_, index) => emptyRankPlayer(index))
isPlayingStartAnimation = false
}
const gotoLoading = (reset = false) => {
if (reset) {
resetRoundState()
}
screen.value = 'loading'
}
const gotoRank1WithIntro = async () => {
if (isPlayingStartAnimation) return
isPlayingStartAnimation = true
seedRankPlayersFromLoading()
screen.value = 'rank1'
await nextTick()
await rank1Ref.value?.startIntro()
isPlayingStartAnimation = false
}
function handleRoomState(data: any) {
if (Array.isArray(data?.list)) {
renderPlayers(data)
}
switch (data?.status) {
case 0:
gotoLoading(true)
createRoom(6)
break
case 1:
gotoLoading()
break
case 2:
seedRankPlayersFromLoading()
screen.value = 'rank1'
break
case 3:
screen.value = 'rank2'
break
}
}
const gameId='game3'
const { startGame, createRoom, backRoom, closeRoom } = useAdminGameSocket({
gameId,
onRoomState: handleRoomState,
onRoomJoin: (data) => {
renderPlayers(data)
gotoLoading()
},
onGameStart: gotoRank1WithIntro,
onRoomRank: updateRankPlayers,
onRelogin: async () => {
$remove_socket_storage();
await router.replace('/')
}
})
const startGameWithMusic = () => {
if (playerCount.value == 0) {
// alert('房间中没有玩家')
$toast('房间中没有玩家');
return;
}
startGame()
}
const nextRound = () => {
gotoLoading(true)
//重开房间
backRoom(6)
}
const nextRoundWithMusic = () => {
nextRound()
}
const backHandler = async() => {
const ok = await $confirm({
title: '提示',
message: '是否关闭当前房间回到首页',
confirmText: '确定',
cancelText: '取消',
})
if (ok) {
closeRoom();
stopAllMusic()
router.replace('/main');
}
}
onMounted(() => {
window.addEventListener('beforeunload', confirmRefresh)
playGame6Music()
})
onUnmounted(() => {
stopAllMusic();
window.removeEventListener('beforeunload', confirmRefresh)
})
watch(screen, (value) => {
// console.log(value);
if(value == 'rank2'){
playApplauseMusic();
}else if(value == 'loading'){
playGame6Music();
}
})
</script>
<template>
<Loading v-if="screen === 'loading'" :players="loadingPlayers" :player-count="playerCount"
@start="startGameWithMusic" @back="backHandler" />
<Rank1 v-else ref="rank1Ref" :rank-list="rankPlayers" @start="startGameWithMusic" @next="nextRoundWithMusic" @back="backHandler" />
</template>
<script setup lang="ts">
import { ref } from 'vue'
import router from '@/router';
import { $format_str } from '@/commons/utils.ts';
import DesignStage from '@/components/DesignStage.vue'
import { assetUrl, cssAssetUrl } from '@/commons/assets.ts';
defineProps<{
players: any[];
playerCount: number;
}>();
const emit = defineEmits<{
start: [];
back:[];
}>();
const imageUrls = {
bg: cssAssetUrl('game3/bg.webp'),
logo: cssAssetUrl('game1/logo.png'),
title1: cssAssetUrl('game3/title.webp'),
title2: cssAssetUrl('game1/title2.png'),
qrcode: assetUrl('game3/qrcode.png'),
// gu: cssAssetUrl('game1/gu.png'),
// bg: cssAssetUrl('game1/bg.png'),
// logo: cssAssetUrl('game1/logo.png'),
// title1: cssAssetUrl('game1/title1.png'),
// title2: cssAssetUrl('game1/title2.png'),
// qrcode: assetUrl('game1/qrcode.png'),
// gu: cssAssetUrl('game1/gu.png'),
imgMole: cssAssetUrl('game3/left.webp'),
imgRabbit: cssAssetUrl('game3/right.webp'),
bottomLayers: cssAssetUrl('game1/bottom-layers.png'),
avatarAnimation: cssAssetUrl('avatar-red_animation.png'),
avatar: cssAssetUrl('avatar-red.png'),
line: cssAssetUrl('game1/line.png'),
// start1: cssAssetUrl('game1/start-1.png'),
// start2: cssAssetUrl('game1/start-2.png'),
start: cssAssetUrl('game1/start.png'),
back: cssAssetUrl('game1/back.png'),
};
const isStartPressed = ref(false);
const pressStartButton = () => {
isStartPressed.value = true;
emit('start');
};
const releaseStartButton = () => {
isStartPressed.value = false;
};
const renderAvatar = (item: any | null) => {
if (item && item.avatar) {
return `background: url("${String(item.avatar).replace(/"/g, '\\"')}") center / cover no-repeat;`
}
return `background: ${imageUrls.avatar} center / cover no-repeat;`
}
</script>
<template>
<DesignStage>
<div class="bg">
<div class="img-logo"></div>
<div class="img-title1"></div>
<!-- <div class="img-title2"></div> -->
<div class="img-qrcode-container">
<div class="img-qrcode"><img :src="imageUrls.qrcode" /></div>
<div class="txt-qrcode">微信扫码参与</div>
</div>
<div class="img-mole"></div>
<div class="img-rabbit"></div>
<div class="list-container">
<div class="txt-count">当前在线人数 <span>{{ playerCount }}</span></div>
<div class="list-container-left">
<div v-for="(item, index) in players" :key="item.userid || item.wechat_id || `empty-${index}`">
<div>
<div class="img-avatar-container">
<div class="img-avatar" :style="`${renderAvatar(item)}`"></div>
</div>
<div class="txt-nickname">{{ $format_str(item.nickname, 12) }}</div>
</div>
</div>
</div>
<div class="list-container-line"></div>
<div class="list-container-right">
<!-- <div class="btn-start" :class="{ pressed: isStartPressed }" @pointerdown="pressStartButton"
@pointerup="releaseStartButton" @pointerleave="releaseStartButton"
@pointercancel="releaseStartButton"></div> -->
<div class="btn-start" @click="pressStartButton"></div>
<div class="btn-back" @click="emit('back')"></div>
</div>
</div>
</div>
</DesignStage>
</template>
<style scoped>
.bg {
width: 1920px;
height: 1080px;
background: v-bind('imageUrls.bg') center / cover no-repeat;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.img-logo {
background: v-bind('imageUrls.logo');
background-size: cover;
width: 428px;
height: 77px;
position: absolute;
left: 37px;
top: 82px;
}
.img-title1 {
background: v-bind('imageUrls.title1');
width: 910px;
height: 437px;
/* margin: 0 auto; */
position: absolute;
left: 511px;
top:0;
}
.img-title2 {
background: v-bind('imageUrls.title2');
width: 643px;
height: 140px;
margin: 0 auto;
position: relative;
top: 80px;
}
.img-qrcode-container {
width: 144px;
height: 177px;
position: absolute;
z-index: 1;
top: 40px;
right: 40px;
.img-qrcode {
width: 100%;
height: 144px;
border-radius: 10px;
img {
width: 147px;
height: 147px;
}
}
.txt-qrcode {
font-size: 24px;
color: white;
}
}
.img-mole {
background: v-bind('imageUrls.imgMole') center / cover no-repeat;
width: 344px;
height: 354px;
position: absolute;
top: 311px;
left: 144px;
}
.img-rabbit {
background: v-bind('imageUrls.imgRabbit') center / cover no-repeat;
width: 633px;
height: 367px;
position: absolute;
top: 293px;
left: 1260px;
}
.list-container {
background: v-bind('imageUrls.bottomLayers');
width: 1800px;
height: 397px;
position: absolute;
left: 60px;
bottom: 60px;
border: 3px solid #AA0000;
border-radius: 10px;
.txt-count {
font-size: 36px;
margin-left: 30px;
margin-top: 20px;
color: #AA0000;
font-weight: bold;
>span {
margin-left: 20px;
font-size: 48px;
position: relative;
top: 5px;
}
}
.list-container-left {
float: left;
display: grid;
grid-template-columns: repeat(10, 1fr);
row-gap: 20px;
width: 73%;
margin-top: 30px;
height: 250px;
align-content: center;
.img-avatar-container {
position: relative;
margin: 0 auto;
width: 72px;
height: 72px;
border-radius: 36px;
}
.img-avatar-container::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: v-bind('imageUrls.avatarAnimation') center / cover no-repeat;
animation: avatar-rotate 10s linear infinite;
}
.img-avatar {
position: relative;
top: 6px;
z-index: 1;
margin: 0 auto;
width: 60px;
height: 60px;
border-radius: 30px;
/* background: v-bind('imageUrls.avatar'); */
}
.txt-nickname {
color: #666;
font-size: 20px;
text-align: center;
}
}
.list-container-line {
float: left;
width: 1px;
height: 281px;
margin-left: 50px;
background: v-bind('imageUrls.line') center no-repeat;
}
.list-container-right {
float: left;
.btn-start {
width: 316px;
height: 105px;
margin-left: 60px;
margin-top: 10px;
background: v-bind('imageUrls.start');
cursor: pointer;
}
/* .btn-start.pressed {
background: v-bind('imageUrls.start2');
} */
.btn-back {
width: 316px;
height: 105px;
margin-left: 60px;
margin-top: 30px;
background: v-bind('imageUrls.back');
cursor: pointer;
}
}
}
</style>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import DesignStage from '../../../components/DesignStage.vue'
import type Player from '../../../commons/player.ts'
import { $format_str, $is_run_local } from '@/commons/utils.ts'
import { assetUrl, cssAssetUrl } from '@/commons/assets.ts'
import { playApplauseMusic, stopApplauseMusic } from '@/commons/music'
const props = defineProps<{
rankList: Player[];
}>();
const emit = defineEmits<{
start: [];
next: [];
back: [];
}>();
const RANK_LIMIT = 10;
const designStage = ref<InstanceType<typeof DesignStage> | null>(null);
const gameStarted = ref(false);
const isGameRunning = ref(false);
const showResult = ref(false);
const imageUrls = {
bg: cssAssetUrl('game3/bg2.webp'),
logo: cssAssetUrl('game1/logo.png'),
title: cssAssetUrl('game3/title2.webp'),
username: cssAssetUrl('game4/username.svg'),
defaultAvatar: assetUrl('game4/default-avator.svg'),
resultBg: cssAssetUrl('game6/bg3.png'),
resultTitle: cssAssetUrl('game6/title3.png'),
podium: cssAssetUrl('game6/podium.png'),
vip: cssAssetUrl('game6/vip.png'),
avatar: cssAssetUrl('avatar-yellow.png'),
avatarAnimation: cssAssetUrl('avatar-yellow_animation.png'),
rankIcon: cssAssetUrl('game6/no4.png'),
next: cssAssetUrl('game1/next.png'),
back: cssAssetUrl('game1/back.png'),
};
// ========== 马匹帧配置(只用 ma1/ma2/ma3,不用 ma4) ==========
const HORSE_SIZE = 280;
const HORSE_START_X = -HORSE_SIZE;
const HORSE_END_X = 1920;
const horseFrameConfigs = [
{ type: 1, dir: 'game3/ma1', prefix: '1ma_', startIndex: 24, count: 12 },
{ type: 2, dir: 'game3/ma2', prefix: '2ma_', startIndex: 24, count: 12 },
{ type: 3, dir: 'game3/ma3', prefix: '3ma_', startIndex: 12, count: 14 },
];
const HORSE_TYPES = [1, 2, 3] as const;
const horseFrames: Record<number, string[]> = {};
const horseFrameCount: Record<number, number> = {};
for (const config of horseFrameConfigs) {
horseFrames[config.type] = Array.from({ length: config.count }, (_, i) =>
assetUrl(`${config.dir}/${config.prefix}${String(config.startIndex + i).padStart(5, '0')}.png`),
);
horseFrameCount[config.type] = config.count;
}
interface Horse {
id: string;
horseType: number;
lane: number;
frameIndex: number;
frameTimer: number;
startTime: number;
durationMs: number;
x: number;
nickname: string;
avatar: string;
}
const horses = ref<Horse[]>([]);
let horseIdCounter = 0;
// ========== 排名相关 ==========
const emptyPlayer = (index: number): Player => ({
wechat_id: `empty-${index}`,
nickname: '-',
avatar: '',
score: 0,
});
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;
};
watch(
() => props.rankList,
(rankList) => {
console.log('[game3 Rank1View] websocket rankList 数据:', JSON.parse(JSON.stringify(rankList)));
updateRankList(rankList);
},
{ immediate: true, deep: true },
);
// ========== 马匹动画循环 (RAF 驱动帧切换 + 位移) ==========
let horseRafId: number | undefined;
let horseLastTs = 0;
const HORSE_FRAME_TICK_MS = 48;
const startHorseLoop = () => {
stopHorseLoop();
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;
const fc = horseFrameCount[horse.horseType] ?? 12;
horse.frameTimer += dt;
while (horse.frameTimer >= HORSE_FRAME_TICK_MS) {
horse.frameTimer -= HORSE_FRAME_TICK_MS;
horse.frameIndex = (horse.frameIndex + 1) % fc;
}
}
horseRafId = requestAnimationFrame(tick);
};
horseRafId = requestAnimationFrame(tick);
};
const stopHorseLoop = () => {
if (horseRafId) {
cancelAnimationFrame(horseRafId);
horseRafId = undefined;
}
};
// ========== 生成马匹 ==========
const LANE_TOP_POSITIONS = [380, 530, 680];
const generateHorses = () => {
const validPlayers = list.value.filter(p => !p.wechat_id?.startsWith('empty-') && p.nickname !== '-');
const count = Math.max(validPlayers.length, 3);
const newHorses: Horse[] = [];
const now = performance.now();
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 * 2500 + Math.random() * 1000;
const durationMs = (30 + Math.random() * 30) * 1000;
const player = validPlayers[i] ?? { nickname: `骑手${i + 1}`, avatar: '' };
newHorses.push({
id: `horse-${++horseIdCounter}`,
horseType: type,
lane,
frameIndex: Math.floor(Math.random() * (horseFrameCount[type] ?? 12)),
frameTimer: 0,
startTime: now + delayMs,
durationMs,
x: HORSE_START_X,
nickname: player.nickname,
avatar: player.avatar || '',
});
}
horses.value = newHorses;
};
// ========== 预加载马匹帧 ==========
const preloadHorseFrames = () => {
for (const frames of Object.values(horseFrames)) {
for (const url of frames) {
const img = new Image();
img.src = url;
}
}
};
// ========== 游戏流程 ==========
const startIntro = async () => {
showResult.value = false;
gameStarted.value = false;
isGameRunning.value = false;
horses.value = [];
horseIdCounter = 0;
stopApplauseMusic();
preloadHorseFrames();
const countdownPromise = designStage.value?.startCountdown(3, 60);
await new Promise((r) => setTimeout(r, 3500));
gameStarted.value = true;
isGameRunning.value = true;
generateHorses();
startHorseLoop();
await countdownPromise;
stopGame();
};
const stopGame = () => {
isGameRunning.value = false;
showResult.value = true;
stopHorseLoop();
playApplauseMusic();
console.log('[game3 Rank1View] 游戏结束,显示结果页');
};
defineExpose({
startIntro,
});
// ========== 头像样式 ==========
const horseAvatarStyle = (horse: Horse) => {
if (horse.avatar) {
return { backgroundImage: `url(${horse.avatar})`, backgroundSize: 'cover' };
}
return { backgroundImage: `url(${imageUrls.defaultAvatar})`, backgroundSize: 'cover' };
};
const renderAvatar = (item: any | null) => {
if (item && item.avatar) {
return `background: url("${String(item.avatar).replace(/"/g, '\\"')}") center / cover no-repeat;`
}
return `background: ${imageUrls.avatar} center / cover no-repeat;`
};
onMounted(() => {
preloadHorseFrames();
if ($is_run_local()) {
gameStarted.value = true;
isGameRunning.value = true;
generateHorses();
startHorseLoop();
}
});
onUnmounted(() => {
stopGame();
stopApplauseMusic();
});
</script>
<template>
<DesignStage ref="designStage">
<div class="screen-container" :class="{ paused: !gameStarted }">
<!-- 背景层:无缝滚动 -->
<div class="bg-track">
<div class="bg-inner"></div>
<div class="bg-inner"></div>
</div>
<!-- Logo & Title -->
<div class="img-logo"></div>
<div class="img-title"></div>
<!-- 马匹 -->
<template v-if="gameStarted && !showResult">
<div
v-for="horse in horses"
:key="horse.id"
class="horse-wrapper"
:style="{
top: `${LANE_TOP_POSITIONS[horse.lane]}px`,
transform: `translateX(${horse.x}px)`,
}"
>
<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>
<div class="horse-shadow"></div>
<div class="horse-avatar" :style="horseAvatarStyle(horse)"></div>
<div class="horse-username">
<span class="username-text">{{ $format_str(horse.nickname, 8) }}</span>
</div>
</div>
</template>
<!-- 结果页(参考 game6 Rank2View) -->
<div v-if="showResult" class="result-overlay">
<div class="result-bg"></div>
<div class="result-logo"></div>
<div class="result-title"></div>
<div class="podium">
<div class="img-vip"></div>
<div class="rank-no1">
<div class="avatar" :style="`${renderAvatar(list[0])}`"></div>
<div class="nickname">{{ list[0]?.nickname }}</div>
</div>
<div class="rank-no2">
<div class="avatar" :style="`${renderAvatar(list[1])}`"></div>
<div class="nickname">{{ list[1]?.nickname }}</div>
</div>
<div class="rank-no3">
<div class="avatar" :style="`${renderAvatar(list[2])}`"></div>
<div class="nickname">{{ list[2]?.nickname }}</div>
</div>
</div>
<div class="rank-container">
<div v-for="(item, index) in list.slice(3)" :key="item.userid || item.wechat_id || `rank-${index}`">
<div class="no-icon">{{ index + 4 }}</div>
<div>
<div class="img-avatar-container">
<div class="img-avatar" :style="`${renderAvatar(list[index + 3])}`"></div>
</div>
<div class="txt-nickname">{{ item.nickname }}</div>
</div>
</div>
</div>
<div class="btn-next" @click="emit('next')"></div>
<div class="btn-back" @click="emit('back')"></div>
</div>
</div>
</DesignStage>
</template>
<style scoped>
.screen-container {
width: 1920px;
height: 1080px;
position: relative;
overflow: hidden;
}
.screen-container.paused .bg-track {
animation-play-state: paused;
}
/* ========== 背景无缝滚动 ========== */
.bg-track {
width: 3840px;
height: 1080px;
position: absolute;
top: 0;
left: 0;
display: flex;
animation: bg-scroll-left 30s linear infinite;
z-index: 0;
}
.bg-inner {
width: 1920px;
height: 1080px;
background: v-bind('imageUrls.bg') center / cover no-repeat;
flex-shrink: 0;
}
@keyframes bg-scroll-left {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-1920px);
}
}
.img-logo {
background: v-bind('imageUrls.logo');
background-size: cover;
width: 428px;
height: 77px;
position: absolute;
left: 37px;
top: 82px;
z-index: 20;
}
.img-title {
background: v-bind('imageUrls.title') center / cover no-repeat;
width: 544px;
height: 208px;
position: absolute;
left: 679px;
top: 52px;
z-index: 20;
}
/* ========== 马匹 ========== */
.horse-wrapper {
position: absolute;
left: 0;
width: 280px;
height: 280px;
z-index: 15;
}
.horse-sprite {
position: relative;
width: 280px;
height: 280px;
}
.horse-frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
opacity: 0;
pointer-events: none;
z-index: 1;
}
.horse-frame.active {
opacity: 1;
}
.horse-shadow {
position: absolute;
bottom: -4px;
left: 50%;
transform: translateX(-50%);
width: 180px;
height: 24px;
background: radial-gradient(ellipse, rgba(0, 0, 0, 0.4) 0%, transparent 70%);
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
/* ========== 马背用户信息 ========== */
.horse-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
position: absolute;
top: 40px;
left: 10px;
border: 2px solid #FFCF44;
z-index: 2;
}
.horse-username {
background: v-bind('imageUrls.username');
background-size: contain;
background-repeat: no-repeat;
width: 120px;
height: 44px;
position: absolute;
top: 92px;
left: 0px;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.username-text {
position: absolute;
top: 2px;
color: #000;
font-size: 16px;
font-weight: bold;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-left: 4px;
box-sizing: border-box;
}
/* ========== 结果页(参考 game6 Rank2View) ========== */
.result-overlay {
width: 1920px;
height: 1080px;
position: absolute;
top: 0;
left: 0;
z-index: 100;
}
.result-bg {
width: 1920px;
height: 1080px;
background: v-bind('imageUrls.resultBg') center / cover no-repeat;
position: absolute;
top: 0;
left: 0;
}
.result-logo {
background: v-bind('imageUrls.logo');
background-size: cover;
width: 428px;
height: 77px;
position: absolute;
left: 37px;
top: 82px;
z-index: 1;
}
.result-title {
background: v-bind('imageUrls.resultTitle') center / cover no-repeat;
width: 831px;
height: 120px;
position: absolute;
left: 545px;
top: 64px;
z-index: 1;
}
.podium {
background: v-bind('imageUrls.podium') no-repeat center;
width: 1920px;
height: 740px;
position: absolute;
bottom: 0;
left: 0;
z-index: 1;
.img-vip {
width: 969px;
height: 238px;
position: absolute;
left: 480px;
top: -60px;
background: v-bind('imageUrls.vip') center / cover no-repeat;
}
.rank-no1 {
position: relative;
.avatar {
width: 160px;
height: 160px;
border-radius: 80px;
position: absolute;
left: 884px;
top: -7px;
}
.nickname {
width: 220px;
height: 52px;
background: linear-gradient(0deg, #FFBF5A 0%, #FFE47A 100%);
border: 1px solid #FFF9DC;
text-align: center;
line-height: 46px;
position: absolute;
left: 850px;
top: 136px;
z-index: 1;
border-radius: 26px;
font-size: 30px;
color: #4B1A14;
}
}
.rank-no2 {
position: relative;
.avatar {
width: 144px;
height: 144px;
border-radius: 72px;
position: absolute;
left: 488px;
top: 27px;
}
.nickname {
width: 220px;
height: 52px;
background: linear-gradient(0deg, #AEA6A7 0%, #EAE4DF 100%);
border: 1px solid white;
color: #383636;
text-align: center;
line-height: 52px;
position: absolute;
left: 447px;
top: 160px;
z-index: 1;
border-radius: 23px;
font-size: 30px;
}
}
.rank-no3 {
position: relative;
.avatar {
width: 144px;
height: 144px;
border-radius: 72px;
position: absolute;
left: 1297px;
top: 26px;
}
.nickname {
width: 220px;
height: 52px;
background: linear-gradient(0deg, #F3AE76 0%, #F9CDA3 100%);
color: #6F3A0E;
text-align: center;
line-height: 46px;
position: absolute;
left: 1255px;
top: 160px;
z-index: 1;
border-radius: 26px;
font-size: 30px;
border: 1px solid #FEFEFD;
}
}
}
.rank-container {
display: flex;
align-items: center;
width: 1570px;
height: 115px;
position: absolute;
bottom: 263px;
left: 200px;
z-index: 2;
> div {
flex: 1;
display: grid;
width: 100px;
height: 100%;
.no-icon {
background: v-bind('imageUrls.rankIcon') no-repeat center;
width: 42px;
height: 47px;
position: absolute;
text-align: center;
line-height: 42px;
color: #CC7F36;
font-weight: bold;
top: 50px;
}
.img-avatar-container {
position: relative;
margin: 0 auto;
margin-top: 10px;
width: 72px;
height: 72px;
border-radius: 36px;
transform: scale(1.6);
top: 50px;
}
.img-avatar-container::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: v-bind('imageUrls.avatarAnimation') center / cover no-repeat;
animation: avatar-rotate 10s linear infinite;
}
.img-avatar {
position: relative;
top: 6px;
z-index: 1;
margin: 0 auto;
width: 60px;
height: 60px;
border-radius: 30px;
background: v-bind('imageUrls.avatar') center / cover no-repeat;
}
.txt-nickname {
color: #F9EBCB;
font-size: 22px;
text-align: center;
position: absolute;
top: 170px;
margin-left: 68px;
}
}
}
.btn-next {
position: absolute;
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;
}
@keyframes avatar-rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
<script setup lang="ts">
import { ref, watch } from 'vue'
import DesignStage from '../../../components/DesignStage.vue'
import type Player from '../../../commons/player.ts'
import { cssAssetUrl } from '@/commons/assets.ts';
const props = defineProps<{
rankList: Player[];
}>();
const emit = defineEmits<{
next: [];
back: [];
}>();
const FINAL_RANK_LIMIT = 10;
const imageUrls = {
bg: cssAssetUrl('game6/bg3.png'),
logo: cssAssetUrl('game1/logo.png'),
title: cssAssetUrl('game6/title3.png'),
podium: cssAssetUrl('game6/podium.png'),
vip: cssAssetUrl('game6/vip.png'),
avatar: cssAssetUrl('avatar-yellow.png'),
avatarAnimation: cssAssetUrl('avatar-yellow_animation.png'),
rankIcon: cssAssetUrl('game6/no4.png'),
next: cssAssetUrl('game1/next.png'),
back: cssAssetUrl('game1/back.png'),
};
const emptyPlayer = (index: number): Player => ({
active: false,
wechat_id: `empty-${index}`,
nickname: '虚位以待',
avatar: '',
score: 0,
});
const list = ref<Player[]>(Array.from({ length: FINAL_RANK_LIMIT }, (_, index) => emptyPlayer(index)));
const updateFinalRank = (rankList: Player[]) => {
list.value = Array.from(
{ length: FINAL_RANK_LIMIT },
(_, index) => rankList[index] ?? emptyPlayer(index),
);
};
watch(
() => props.rankList,
(rankList) => updateFinalRank(rankList),
{ immediate: true, deep: true },
);
const renderAvatar = (item: any | null) => {
if (item && item.avatar) {
return `background: url('${item.avatar}') center / cover no-repeat;`
}
return `background: ${imageUrls.avatar} center / cover no-repeat;`
}
</script>
<template>
<DesignStage>
<div class="bg">
<div class="img-logo"></div>
<div class="img-title"></div>
<div class="img-title2"></div>
<div class="podium">
<div class="img-vip"></div>
<div class="rank-no1">
<div class="avatar" :style="`${renderAvatar(list[0])}`">
</div>
<div class="nickname">{{ list[0]?.nickname }}</div>
</div>
<div class="rank-no2">
<div class="avatar" :style="`${renderAvatar(list[1])}`">
</div>
<div class="nickname">{{ list[1]?.nickname }}</div>
</div>
<div class="rank-no3">
<div class="avatar" :style="`${renderAvatar(list[2])}`">
</div>
<div class="nickname">{{ list[2]?.nickname }}</div>
</div>
</div>
<div class="rank-container">
<div v-for="(item, index) in list.slice(3)" :key="item.userid || item.wechat_id || `rank-${index}`">
<div class="no-icon">{{ index + 4 }}</div>
<div>
<div class="img-avatar-container">
<div class="img-avatar" :style="`${renderAvatar(list[index + 3])}`"></div>
</div>
<div class="txt-nickname">{{ item.nickname }}</div>
</div>
</div>
</div>
<div class="btn-next" @click="emit('next')"></div>
<div class="btn-back" @click="emit('back')"></div>
</div>
</DesignStage>
</template>
<style scoped>
.bg {
width: 1920px;
height: 1080px;
background: v-bind('imageUrls.bg') center / cover no-repeat;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.img-logo {
background: v-bind('imageUrls.logo');
background-size: cover;
width: 428px;
height: 77px;
position: absolute;
left: 37px;
top: 82px;
}
.img-title {
background: v-bind('imageUrls.title') center / cover no-repeat;
width: 831px;
height: 120px;
margin: 0 auto;
margin-top: 64px;
}
/* .img-title2 {
background: v-bind('imageUrls.title2') no-repeat center;
width: 457px;
height: 57px;
margin: 0 auto;
margin-top: 37px;
} */
.podium {
background: v-bind('imageUrls.podium') no-repeat center;
width: 1920px;
height: 740px;
position: absolute;
bottom: 0;
left: 0;
.img-vip {
width: 969px;
height: 238px;
position: absolute;
left: 480px;
top: -60px;
background: v-bind('imageUrls.vip') center / cover no-repeat;
}
.rank-no1 {
position: relative;
.avatar {
width: 160px;
height: 160px;
border-radius: 80px;
position: absolute;
left: 884px;
top: -7px;
}
.nickname {
width: 220px;
height: 52px;
background: linear-gradient(0deg, #FFBF5A 0%, #FFE47A 100%);
border: 1px solid #FFF9DC;
text-align: center;
line-height: 46px;
position: absolute;
left: 850px;
top: 136px;
z-index: 1;
border-radius: 26px;
font-size: 30px;
color: #4B1A14;
}
}
.rank-no2 {
position: relative;
.avatar {
width: 144px;
height: 144px;
border-radius: 72px;
position: absolute;
left: 488px;
top: 27px;
/* border: 1px solid red; */
}
.nickname {
width: 220px;
height: 52px;
background: linear-gradient(0deg, #AEA6A7 0%, #EAE4DF 100%);
border: 1px solid white;
color: #383636;
text-align: center;
line-height: 52px;
position: absolute;
left: 447px;
top: 160px;
z-index: 1;
border-radius: 23px;
font-size: 30px;
}
}
.rank-no3 {
position: relative;
.avatar {
width: 144px;
height: 144px;
border-radius: 72px;
position: absolute;
left: 1297px;
top: 26px;
}
.nickname {
width: 220px;
height: 52px;
background: linear-gradient(0deg, #F3AE76 0%, #F9CDA3 100%);
color: #6F3A0E;
text-align: center;
line-height: 46px;
position: absolute;
left: 1255px;
top: 160px;
z-index: 1;
border-radius: 26px;
font-size: 30px;
border: 1px solid #FEFEFD;
}
}
}
.rank-container {
display: flex;
align-items: center;
width: 1570px;
height: 115px;
position: absolute;
bottom: 263px;
left: 200px;
>div {
flex: 1;
display: grid;
width: 100px;
height: 100%;
.no-icon {
background: v-bind('imageUrls.rankIcon') no-repeat center;
width: 42px;
height: 47px;
position: absolute;
text-align: center;
line-height: 42px;
color: #CC7F36;
font-weight: bold;
top: 50px;
}
.img-avatar-container {
position: relative;
margin: 0 auto;
margin-top: 10px;
width: 72px;
height: 72px;
border-radius: 36px;
transform: scale(1.6);
top: 50px;
}
.img-avatar-container::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: v-bind('imageUrls.avatarAnimation') center / cover no-repeat;
animation: avatar-rotate 10s linear infinite;
}
.img-avatar {
position: relative;
top: 6px;
z-index: 1;
margin: 0 auto;
width: 60px;
height: 60px;
border-radius: 30px;
background: v-bind('imageUrls.avatar') center / cover no-repeat;
}
.txt-nickname {
color: #F9EBCB;
font-size: 22px;
text-align: center;
position: absolute;
top: 170px;
text-align: center;
/* left:10px; */
margin-left: 68px;
}
}
}
.btn-next {
position: absolute;
bottom: 50px;
left: 620px;
width: 316px;
height: 105px;
cursor: pointer;
background: v-bind('imageUrls.next') center / cover no-repeat;
}
.btn-back {
position: absolute;
bottom: 50px;
left: 1020px;
width: 316px;
height: 105px;
cursor: pointer;
background: v-bind('imageUrls.back') center / cover no-repeat;
}
</style>
......@@ -4,6 +4,7 @@ import { $read } from '@/commons/utils'
import Login from '@/pages/Login.vue'
import Main from '@/pages/Main.vue'
import Game1 from '@/pages/game1/Game.vue'
import Game3 from '@/pages/game3/Game3.vue'
import Game4 from '@/pages/game4/Game4.vue'
import Game5 from '@/pages/game5/Game5.vue'
import Game6 from '@/pages/game6/Game.vue'
......@@ -37,6 +38,14 @@ const router = createRouter({
},
},
{
path: '/game3',
name: 'Game3',
component: Game3,
meta: {
requiresAuth: true,
},
},
{
path: '/game4',
name: 'Game4',
component: Game4,
......
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