Files
shoot-miniprograms/src/pages/battle-room.vue

822 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { ref, onMounted, computed, onBeforeUnmount } from "vue";
import { onShow, onLoad, onShareAppMessage } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import PlayerSeats from "@/components/PlayerSeats.vue";
import GuideTwo from "@/components/GuideTwo.vue";
import SButton from "@/components/SButton.vue";
import Avatar from "@/components/Avatar.vue";
import ScreenHint from "@/components/ScreenHint.vue";
import {
getRoomAPI,
destroyRoomAPI,
exitRoomAPI,
startRoomAPI,
chooseTeamAPI,
getReadyAPI,
kickPlayerAPI,
} from "@/apis";
import { MESSAGETYPES, MESSAGETYPESV2 } from "@/constants";
import useStore from "@/store";
import { storeToRefs } from "pinia";
const store = useStore();
const { user } = storeToRefs(store);
const room = ref({});
const roomNumber = ref("");
const owner = ref({});
const opponent = ref({});
const players = ref([]);
const blueTeam = ref([]);
const redTeam = ref([]);
/**
* 根据对战类型和房间人数动态生成页面标题
* battleType=1: 组队对战count 2/4/6 分别对应 1v1/2v2/3v3
* battleType=2: 多人乱斗
*/
const battleTitle = computed(() => {
if (!room.value.battleType) return "好友约战";
if (room.value.battleType === 2) return "多人乱斗";
const half = room.value.count / 2;
return `${half}v${half}对抗赛`;
});
const ready = ref(false);
const allReady = ref(false);
const timer = ref(null);
const goBattle = ref(false);
/**
* 从服务端刷新当前房间数据,更新成员列表、准备状态等信息
* 仅在 roomNumber 有效且房间未开始时执行
*/
async function refreshRoomData() {
if (!roomNumber.value) return;
const result = await getRoomAPI(roomNumber.value);
if (result.started) return;
room.value = result;
owner.value = {};
opponent.value = {};
const members = result.members || [];
if (members.length === result.count) {
allReady.value = members.every((m) => !!m.userInfo.state);
}
members.some((m) => {
if (m.userInfo.id === user.value.id) {
ready.value = !!m.userInfo.state;
return true;
}
return false;
});
members.some((m) => {
if (m.userInfo.id === result.creator) {
owner.value = {
id: m.userInfo.id,
name: m.userInfo.name,
avatar: m.userInfo.avatar,
rankLvl: m.userInfo.rankLvl,
ready: !!m.userInfo.state,
};
return true;
}
return false;
});
if (result.battleType === 1 && result.count === 2) {
result.members.forEach((m) => {
if (m.userInfo.id !== owner.value.id) {
opponent.value = {
id: m.userInfo.id,
name: m.userInfo.name,
avatar: m.userInfo.avatar,
rankLvl: m.userInfo.rankLvl,
ready: !!m.userInfo.state,
};
}
});
} else if (result.battleType === 2) {
players.value = [];
const ownerIndex = result.members.findIndex(
(m) => m.userInfo.id === result.creator
);
if (ownerIndex !== -1) {
players.value.push(result.members[ownerIndex].userInfo);
} else {
players.value.push({});
}
result.members.forEach((m, index) => {
if (ownerIndex !== index) players.value.push(m.userInfo);
});
} else {
players.value = new Array(result.count).fill({});
refreshMembers(result.members);
}
if (timer.value) clearInterval(timer.value);
// timer.value = setTimeout(refreshRoomData, 2000);
}
const startGame = async () => {
const result = await startRoomAPI(room.value.number);
};
const getReady = async () => {
await getReadyAPI(roomNumber.value);
};
const refreshMembers = (members = []) => {
blueTeam.value = [];
redTeam.value = [];
members.forEach((m, index) => {
players.value[index] = { ...m.userInfo, groupType: m.groupType };
if (m.groupType === 1) {
blueTeam.value.push({ ...m.userInfo, groupType: 1 });
} else if (m.groupType === 2) {
redTeam.value.push({ ...m.userInfo, groupType: 2 });
}
});
for (let i = 0; i < room.value.count / 2; i++) {
if (!blueTeam.value[i]) blueTeam.value[i] = {};
if (!redTeam.value[i]) redTeam.value[i] = {};
}
};
async function onReceiveMessage(message) {
if (Array.isArray(message)) {
message.forEach((msg) => {
if (msg.roomNumber !== roomNumber.value) return;
if (msg.constructor === MESSAGETYPES.UserEnterRoom) {
refreshRoomData();
} else if (msg.constructor === MESSAGETYPES.UserExitRoom) {
if (msg.userId === user.value.id) {
uni.showToast({
title: "你被踢出房间",
icon: "none",
});
setTimeout(() => {
uni.navigateBack();
}, 1000);
} else {
refreshRoomData();
}
} else if (msg.constructor === MESSAGETYPES.TeamUpdate) {
refreshRoomData();
} else if (msg.constructor === MESSAGETYPES.SomeoneIsReady) {
refreshRoomData();
} else if (msg.constructor === MESSAGETYPES.RoomDestroy) {
uni.showToast({
title: "房间已解散",
icon: "none",
});
setTimeout(() => {
uni.navigateBack();
}, 1000);
}
});
} else if (message.type === MESSAGETYPESV2.AboutToStart) {
goBattle.value = true;
if (message.mode <= 3) {
uni.setStorageSync("blue-team", message.teams[1].players || []);
uni.setStorageSync("red-team", message.teams[2].players || []);
uni.redirectTo({
url: "/pages/team-battle?battleId=" + message.matchId,
});
} else {
uni.redirectTo({
url: "/pages/melee-battle?battleId=" + message.matchId,
});
}
}
}
const chooseTeam = async (team) => {
const result = await chooseTeamAPI(roomNumber.value, team);
refreshMembers(result.members);
};
const destroyRoom = async () => {
if (roomNumber.value) await destroyRoomAPI(roomNumber.value);
};
const exitRoom = async () => {
uni.navigateBack();
};
/** 待确认踢出的玩家信息 */
const playerToKick = ref(null);
/** 控制踢出确认弹窗的显示状态 */
const showKickConfirm = ref(false);
/**
* 点击踢出按钮,弹出二次确认弹窗
* @param {object} player - 被踢的玩家信息
*/
const removePlayer = (player) => {
playerToKick.value = player;
showKickConfirm.value = true;
};
/** 确认踢出:调用 API 并关闭弹窗 */
const confirmKick = async () => {
if (!playerToKick.value) return;
await kickPlayerAPI(roomNumber.value, playerToKick.value.id);
showKickConfirm.value = false;
playerToKick.value = null;
};
/** 取消踢出:关闭弹窗 */
const cancelKick = () => {
showKickConfirm.value = false;
playerToKick.value = null;
};
const canClick = computed(() => {
if (ready.value) return false;
const { members = [] } = room.value;
if (members.length < 2) return false;
if (
owner.value.id === user.value.id &&
members.some((m) => !m.userInfo.state && m.userInfo.id !== owner.value.id)
)
return false;
return true;
});
onShareAppMessage(() => {
return {
title: "邀请您进入房间对战",
path: "/pages/friend-battle?roomID=" + roomNumber.value,
imageUrl: "",
};
});
/**
* onShow 生命周期:页面显示时刷新房间数据
* 注uni-app 中 onShow 可能早于 onLoad 执行,此时 roomNumber 尚未赋值,
* refreshRoomData 会提前 return。
*/
onShow(() => {
goBattle.value = false;
refreshRoomData();
});
/**
* 页面加载时解析路由参数,初始化房间号
* - 存入本地 ref供页面内部逻辑使用
* - 同步到 Pinia Store供 Header 组件展示房号胶囊)
*/
onLoad(async (options) => {
if (options.roomNumber) {
roomNumber.value = options.roomNumber;
store.updateRoomNumber(options.roomNumber);
}
});
/**
* 组件挂载后:保持屏幕常亮、注册 WebSocket 消息监听
* 房间号已通过 onLoad 同步到 StoreHeader 从 Store 直接读取,无需额外通知
*/
onMounted(() => {
uni.setKeepScreenOn({
keepScreenOn: true,
});
uni.$on("socket-inbox", onReceiveMessage);
});
onBeforeUnmount(() => {
uni.setKeepScreenOn({
keepScreenOn: false,
});
uni.$off("socket-inbox", onReceiveMessage);
if (!goBattle.value) exitRoomAPI(roomNumber.value);
if (timer.value) clearInterval(timer.value);
timer.value = null;
});
</script>
<template>
<Container :title="battleTitle">
<view class="standby-phase">
<GuideTwo>
<view class="battle-guide">
<view class="guide-tips">
<text>弓箭手们人都到齐了吗?</text>
<text v-if="room.battleType === 1">{{
`${room.count / 2}v${room.count / 2}比赛即将开始!`
}}</text>
<text v-if="room.battleType === 2">大乱斗即将开始! </text>
</view>
</view>
</GuideTwo>
<view v-if="room.battleType === 1 && room.count === 2" class="team-mode">
<image src="https://static.shelingxingqiu.com/attachment/2025-08-05/dbua9nuf5fyeph7cxi.png" mode="widthFix" />
<view>
<view v-if="owner.id" class="player" :style="{ transform: 'translateY(-60px)' }">
<Avatar :src="owner.avatar" :size="60" />
<text>管理员</text>
<text>{{ owner.name }}</text>
</view>
<view v-else class="no-player" :style="{ transform: 'translateY(-60px)' }">
<image src="../static/question-mark.png" mode="widthFix" />
</view>
<image src="../static/versus.png" mode="widthFix" />
<view v-if="opponent.id" class="player" :style="{ transform: 'translateY(60px)' }">
<Avatar :src="opponent.avatar" :size="60" />
<text class="ready" :style="{ opacity: opponent.ready ? 1 : 0 }">
已准备
</text>
<text>{{ opponent.name }}</text>
<button v-if="owner.id === user.id" hover-class="none" class="remove-player"
@click="() => removePlayer(opponent)">
<image src="../static/close-white.png" mode="widthFix" />
</button>
</view>
<view class="no-player" v-else>
<image src="../static/question-mark.png" mode="widthFix" />
</view>
</view>
</view>
<block v-if="room.battleType === 1 && room.count >= 4">
<view class="all-players">
<image src="https://static.shelingxingqiu.com/attachment/2025-08-13/dc0x1p59iab6cvbhqc.png" mode="widthFix" />
<image v-if="room.count === 4" src="../static/title-2v2.png" mode="widthFix" />
<image v-if="room.count === 6" src="../static/title-3v3.png" mode="widthFix" />
<view>
<view v-for="(item, index) in players" :key="index">
<Avatar v-if="item.id" :src="item.avatar" :size="36" />
<text v-if="owner.id === item.id">管理员</text>
<button v-if="owner.id !== item.id && item.id" hover-class="none" class="remove-player"
@click="() => removePlayer(item)" :style="{ top: '-10rpx', right: '-10rpx' }">
<image src="../static/close-white.png" mode="widthFix" />
</button>
</view>
</view>
</view>
<view class="choose-side">
<view>
<view v-for="(item, index) in redTeam" :key="index" class="choose-side-left-item">
<button hover-class="none" v-if="item.id === user.id" @click="chooseTeam(0)">
<image src="../static/close-grey.png" mode="widthFix" />
</button>
<text class="truncate">{{ item.name || "我要加入" }}</text>
<view v-if="item.id">
<Avatar :src="item.avatar" :size="36" />
<text :style="{ opacity: !!item.state ? 1 : 0 }">已准备</text>
</view>
<button v-else hover-class="none" @click="chooseTeam(2)">
<image src="../static/add-grey.png" mode="widthFix" />
</button>
</view>
</view>
<view>
<view v-for="(item, index) in blueTeam" :key="index" class="choose-side-right-item">
<view v-if="item.id">
<Avatar :src="item.avatar" :size="36" />
<text :style="{ opacity: !!item.state ? 1 : 0 }">已准备</text>
</view>
<button v-else hover-class="none" @click="chooseTeam(1)">
<image src="../static/add-grey.png" mode="widthFix" />
</button>
<text class="truncate">{{ item.name || "我要加入" }}</text>
<button hover-class="none" v-if="item.id === user.id" @click="chooseTeam(0)">
<image src="../static/close-grey.png" mode="widthFix" />
</button>
</view>
</view>
</view>
</block>
<PlayerSeats v-if="room.battleType === 2" :total="room.count || 10" :players="players"
:removePlayer="removePlayer" />
<view>
<SButton :disabled="!canClick" :onClick="getReady">
{{
allReady.value
? "即将进入对局..."
: owner.id === user.id
? "开始对局"
: "我准备好了"
}}
</SButton>
<text class="tips">所有人准备好后由房主点击开始</text>
</view>
</view>
</Container>
<!-- 踢出玩家二次确认弹窗不传 onClose屏蔽 X 关闭按钮 -->
<ScreenHint :show="showKickConfirm">
<view class="kick-confirm">
<!-- 弹窗标题玩家名字高亮显示 -->
<text class="kick-confirm__title">
是否把<text style="color: #F7D550">{{ playerToKick && playerToKick.name }}</text>移出房间
</text>
<!-- 按钮区确认在左取消在右 -->
<view class="kick-confirm__btns">
<button hover-class="none" class="kick-confirm__btn kick-confirm__btn--confirm" @click="confirmKick">确定</button>
<button hover-class="none" class="kick-confirm__btn kick-confirm__btn--cancel" @click="cancelKick">取消</button>
</view>
</view>
</ScreenHint>
</template>
<style scoped>
.standby-phase {
width: 100%;
height: calc(100% - 40px);
overflow-x: hidden;
}
.tips {
color: #fff9;
width: 100%;
text-align: center;
display: block;
margin-top: 10px;
font-size: 12px;
}
.player-unknow {
width: 40px;
height: 40px;
margin: 0 10px;
border: 1px solid #fff3;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: #69686866;
}
.player-unknow>image {
width: 40%;
}
.team-mode {
width: calc(100vw - 30px);
height: 125vw;
margin: 0 15px 15px 15px;
}
.team-mode>image:first-child {
position: absolute;
width: calc(100vw - 30px);
z-index: -1;
}
.team-mode>view {
display: flex;
justify-content: center;
align-items: center;
height: 95%;
}
.player {
width: 70px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
transform: translateY(-60px);
color: #fff;
font-size: 14px;
position: relative;
}
.player>image {
width: 70px;
height: 70px;
border-radius: 50%;
background-color: #ccc;
margin-bottom: 5px;
}
.player>text:nth-child(2) {
color: #000;
background-color: #fed847;
font-size: 16rpx;
border-radius: 20rpx;
line-height: 26rpx;
padding: 0 10rpx;
margin-top: -16rpx;
position: relative;
}
.player>text:nth-child(3) {
margin-top: 6rpx;
}
.player>text {
max-width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
.player .ready {
background-color: #2c261fb3 !important;
border: 1rpx solid #a3793f66 !important;
color: #fed847 !important;
}
/* 踢出确认弹窗内容 */
.kick-confirm {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20rpx 30rpx;
color: #fff;
}
.kick-confirm__title {
font-size: 30rpx;
font-weight: 500;
text-align: center;
line-height: 1.5;
/* 标题与按钮区间距 */
margin-bottom: 58rpx;
}
.kick-confirm__btns {
display: flex;
/* 两个按钮间距 */
gap: 16rpx;
}
/* 按钮公共基础样式:固定宽高与圆角 */
.kick-confirm__btn {
font-size: 28rpx;
width: 232rpx;
height: 70rpx;
border-radius: 44rpx;
line-height: 70rpx;
display: flex;
align-items: center;
justify-content: center;
}
/* 取消按钮:白色半透明背景(用 rgba 避免 opacity 平铺到文字) */
.kick-confirm__btn--cancel {
background: rgba(255, 255, 255, 0.2);
border: none;
color: #FFFFFF;
font-weight: 500;
font-size: 28rpx;
text-align: center;
border-radius: 44rpx;
}
.kick-confirm__btn--cancel::after {
border: none;
}
/* 确认按钮:黄色实心背景 */
.kick-confirm__btn--confirm {
background: #FED847;
border: none;
color: #000000;
font-weight: 500;
font-size: 28rpx;
text-align: center;
border-radius: 44rpx;
}
.kick-confirm__btn--confirm::after {
border: none;
}
.remove-player {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(0deg, #996c29b8 0%, #201e1aaf 100%);
position: absolute;
top: 0;
right: 0;
}
.remove-player>image {
width: 90%;
height: 90%;
}
.team-mode>view>image:nth-child(2) {
width: 120px;
}
.no-player {
width: 70px;
height: 70px;
border-radius: 50%;
background-color: #ccc;
display: flex;
justify-content: center;
align-items: center;
transform: translateY(60px);
}
.no-player>image {
width: 20px;
margin-right: 2px;
}
.btns {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.battle-guide {
display: flex;
align-items: center;
justify-content: space-between;
}
.battle-guide>button:last-child {
color: #fed847;
border: 1px solid #fed847;
margin-right: 10px;
padding: 5px 12px;
border-radius: 20px;
position: relative;
font-size: 28rpx;
}
.all-players {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
height: 83vw;
}
.all-players>image:first-child {
position: absolute;
width: 100%;
}
.all-players>image:nth-child(2) {
width: 25vw;
position: relative;
}
.all-players>view {
position: relative;
width: 42vw;
height: 42vw;
margin-top: 7vw;
}
.all-players>view>view {
position: absolute;
left: 50%;
top: 50%;
}
/* 4个头像 - 正方形排列 */
.all-players>view>view:nth-child(1):nth-last-child(4) {
transform: translate(-50%, -50%) rotate(-30deg) translateY(-21.5vw) rotate(30deg);
}
.all-players>view>view:nth-child(2):nth-last-child(3) {
transform: translate(-50%, -50%) rotate(-120deg) translateY(-21.5vw) rotate(120deg);
}
.all-players>view>view:nth-child(3):nth-last-child(2) {
transform: translate(-50%, -50%) rotate(-210deg) translateY(-21.5vw) rotate(210deg);
}
.all-players>view>view:nth-child(4):nth-last-child(1) {
transform: translate(-50%, -50%) rotate(-300deg) translateY(-21.5vw) rotate(300deg);
}
/* 6个头像 - 六边形排列 */
.all-players>view>view:nth-child(1):nth-last-child(6) {
transform: translate(-50%, -50%) rotate(-30deg) translateY(-21vw) rotate(30deg);
}
.all-players>view>view:nth-child(2):nth-last-child(5) {
transform: translate(-50%, -50%) rotate(-90deg) translateY(-21vw) rotate(90deg);
}
.all-players>view>view:nth-child(3):nth-last-child(4) {
transform: translate(-50%, -50%) rotate(-150deg) translateY(-21vw) rotate(150deg);
}
.all-players>view>view:nth-child(4):nth-last-child(3) {
transform: translate(-50%, -50%) rotate(-210deg) translateY(-21vw) rotate(210deg);
}
.all-players>view>view:nth-child(5):nth-last-child(2) {
transform: translate(-50%, -50%) rotate(-270deg) translateY(-21vw) rotate(270deg);
}
.all-players>view>view:nth-child(6):nth-last-child(1) {
transform: translate(-50%, -50%) rotate(-330deg) translateY(-21vw) rotate(330deg);
}
.all-players>view>view>text {
position: absolute;
background-color: #fed847;
font-size: 8px;
border-radius: 10px;
padding: 1px 0px;
bottom: -20%;
left: calc(50% - 15px);
width: 30px;
text-align: center;
color: #333;
}
.choose-side {
display: flex;
}
.choose-side>view {
width: 50%;
}
.choose-side>view:first-child>view {
background: linear-gradient(270deg, #6a1212 0%, rgba(74, 0, 0, 0) 100%);
}
.choose-side>view:last-child>view {
background: linear-gradient(270deg, rgba(13, 0, 74, 0) 0%, #172a86 100%);
}
.choose-side-left-item,
.choose-side-right-item {
display: flex;
align-items: center;
color: #fff;
border-radius: 12px;
padding: 10px;
align-items: center;
margin: 10px 5px;
position: relative;
}
.choose-side-left-item {
justify-content: flex-end;
}
.choose-side-left-item>text,
.choose-side-right-item>text {
margin: 10px;
max-width: 100px;
font-size: 14px;
}
.choose-side-left-item>button:first-child,
.choose-side-right-item>button:last-child {
position: absolute;
top: 0;
}
.choose-side-left-item>button:first-child>image,
.choose-side-right-item>button:last-child>image {
width: 28px;
}
.choose-side-left-item>button:first-child {
left: 0;
}
.choose-side-right-item>button:last-child {
right: 0;
}
.choose-side-left-item>button:last-child,
.choose-side-right-item>button:first-child {
background-color: #fff;
border-radius: 50%;
width: 38px;
height: 38px;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 14rpx;
}
.choose-side-left-item>button:last-child>image,
.choose-side-right-item>button:first-child>image {
width: 18px;
}
.choose-side-left-item>view,
.choose-side-right-item>view {
display: flex;
flex-direction: column;
align-items: center;
}
.choose-side-left-item>view>text,
.choose-side-right-item>view>text {
font-size: 16rpx;
border-radius: 20rpx;
line-height: 26rpx;
padding: 0 10rpx;
margin-top: -16rpx;
position: relative;
background-color: #2c261fb3;
border: 1rpx solid #a3793f66;
color: #fed847;
}
</style>