feat: 为扫雷游戏添加房间列表功能,支持加入和围观现有对局。

This commit is contained in:
邹方成 2026-01-03 17:57:54 +08:00
parent a304e66e75
commit 45190e1004
6 changed files with 445 additions and 16 deletions

View File

@ -50,6 +50,13 @@
> >
<text class="enter-btn-text">{{ entering ? '正在进入...' : (ticketCount > 0 ? '立即开局' : '资格不足') }}</text> <text class="enter-btn-text">{{ entering ? '正在进入...' : (ticketCount > 0 ? '立即开局' : '资格不足') }}</text>
</view> </view>
<view
class="btn-secondary"
style="margin-top: 24rpx; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 55rpx; height: 110rpx; display: flex; align-items: center; justify-content: center;"
@tap="goRoomList"
>
<text style="color: #94a3b8; font-size: 32rpx; font-weight: 600;">📡 对战列表 / 围观</text>
</view>
</view> </view>
</view> </view>
</template> </template>
@ -120,6 +127,28 @@ export default {
this.entering = false this.entering = false
this.loadTickets() this.loadTickets()
} }
},
async goRoomList() {
// nakama room-list URL
try {
const res = await authRequest({
url: '/api/app/games/enter',
method: 'POST',
data: {
game_code: this.gameCode
}
})
const gameToken = encodeURIComponent(res.game_token)
const nakamaServer = encodeURIComponent(res.nakama_server)
const nakamaKey = encodeURIComponent(res.nakama_key)
uni.navigateTo({
url: `/pages-game/game/minesweeper/room-list?game_token=${gameToken}&nakama_server=${nakamaServer}&nakama_key=${nakamaKey}`
})
} catch (e) {
uni.showToast({ title: '无法获取对战列表', icon: 'none' })
}
} }
} }
} }

View File

@ -901,6 +901,16 @@
} }
} }
.spectator-badge {
padding: $spacing-sm $spacing-md;
background: rgba($brand-secondary, 0.2);
border: 1px solid rgba($brand-secondary, 0.4);
border-radius: $radius-round;
font-size: $font-sm;
font-weight: 700;
color: $brand-secondary;
}
.timer-progress-bg { .timer-progress-bg {
width: 660rpx; width: 660rpx;
height: 6rpx; height: 6rpx;

View File

@ -98,7 +98,7 @@
:key="p.userId" :key="p.userId"
class="player-card opponent" class="player-card opponent"
:class="{ :class="{
'active-turn': gameState.turnOrder[gameState.currentTurnIndex] === p.userId, 'active-turn': gameState.gameStarted && gameState.turnOrder[gameState.currentTurnIndex] === p.userId,
'damaged': damagedPlayers.includes(p.userId), 'damaged': damagedPlayers.includes(p.userId),
'healed': healedPlayers.includes(p.userId) 'healed': healedPlayers.includes(p.userId)
}" }"
@ -141,6 +141,7 @@
<view class="timer-badge" :class="{ urgent: turnTimer < 5 }"> <view class="timer-badge" :class="{ urgent: turnTimer < 5 }">
{{ turnTimer }}s {{ turnTimer }}s
</view> </view>
<view v-if="isSpectator" class="spectator-badge">围观中</view>
</view> </view>
<view class="timer-progress-bg" v-if="gameState.gameStarted"> <view class="timer-progress-bg" v-if="gameState.gameStarted">
@ -232,6 +233,7 @@
</view> </view>
<!-- 结算弹窗 --> <!-- 结算弹窗 -->
<<<<<<< HEAD
<view v-if="shouldShowResultModal" class="modal-overlay"> <view v-if="shouldShowResultModal" class="modal-overlay">
<view class="modal-content glass-card"> <view class="modal-content glass-card">
<text class="modal-emoji">{{ getGameResultEmoji() }}</text> <text class="modal-emoji">{{ getGameResultEmoji() }}</text>
@ -241,6 +243,14 @@
</view> </view>
<view <view
class="btn-primary" class="btn-primary"
=======
<view v-if="showSettlement" class="modal-overlay">
<view class="modal-content glass-card">
<text class="modal-emoji">{{ settlementWinnerId === 'draw' ? '🤝' : (settlementWinnerId === myUserId ? '🏆' : '💀') }}</text>
<text class="modal-title">{{ settlementWinnerId === 'draw' ? '平局' : (settlementWinnerId === myUserId ? '胜利!' : '很遗憾失败了') }}</text>
<view
class="btn-primary"
>>>>>>> 5ec793a (feat: 为扫雷游戏添加房间列表功能支持加入和围观现有对局)
:class="{ disabled: isRefreshing }" :class="{ disabled: isRefreshing }"
@tap="refreshAndPlayAgain" @tap="refreshAndPlayAgain"
> >
@ -313,6 +323,9 @@ export default {
healedPlayers: [], healedPlayers: [],
isRefreshing: false, isRefreshing: false,
logsScrollTop: 0, logsScrollTop: 0,
isSpectator: false,
showSettlement: false,
settlementWinnerId: '',
// Timers // Timers
matchInterval: null, matchInterval: null,
turnInterval: null, turnInterval: null,
@ -393,7 +406,10 @@ export default {
}, },
onLoad(options) { onLoad(options) {
this.fetchGameConfig(); this.fetchGameConfig();
const { game_token, nakama_server, nakama_key } = options; const { game_token, nakama_server, nakama_key, match_id, is_spectator } = options;
if (is_spectator) this.isSpectator = true;
if (match_id) this.matchId = match_id;
if (game_token) { if (game_token) {
this.initNakama(game_token, decodeURIComponent(nakama_server || ''), decodeURIComponent(nakama_key || '')); this.initNakama(game_token, decodeURIComponent(nakama_server || ''), decodeURIComponent(nakama_key || ''));
} else { } else {
@ -455,6 +471,20 @@ export default {
this.addLog('system', '✅ 已连接到远程节点'); this.addLog('system', '✅ 已连接到远程节点');
// //
this.setupSocketListeners(); this.setupSocketListeners();
//
if (this.matchId) {
this.addLog('system', this.isSpectator ? '🔭 正在切入观察视角...' : '🚪 正在进入指定战局...');
try {
await nakamaManager.joinMatch(this.matchId);
this.addLog('system', '✅ 接入成功');
setTimeout(() => {
nakamaManager.sendMatchState(this.matchId, 100, JSON.stringify({ action: 'getState' }));
}, 100);
} catch(err) {
this.addLog('system', '❌ 接入失败: ' + err.message);
}
}
} catch (e) { } catch (e) {
this.addLog('system', '❌ 通讯异常: ' + e.message); this.addLog('system', '❌ 通讯异常: ' + e.message);
} }
@ -629,45 +659,51 @@ export default {
// - // -
console.log('[游戏结束] 接收到游戏结束数据:', data); console.log('[游戏结束] 接收到游戏结束数据:', data);
const winnerId = data.winnerId || (data.gameState && data.gameState.winnerId) || '';
this.settlementWinnerId = winnerId;
this.showSettlement = !!winnerId;
if (data.gameState) { if (data.gameState) {
// //
this.gameState = { this.gameState = {
...data.gameState, ...data.gameState,
gameStarted: false, // gameStarted: false, //
winnerId: data.winnerId || data.gameState.winnerId winnerId: winnerId
}; };
} else if (this.gameState) {
this.gameState.gameStarted = false;
if (winnerId) this.gameState.winnerId = winnerId;
} else { } else {
this.gameState = { this.gameState = {
...data, ...data,
gameStarted: false // gameStarted: false,
winnerId: winnerId
}; };
} }
// winnerId // winnerId 便使
if (!this.gameState.winnerId && data.winnerId) { if (this.gameState && !this.gameState.winnerId && winnerId) {
this.gameState.winnerId = data.winnerId; this.gameState.winnerId = winnerId;
} }
console.log('[游戏结束] 最终游戏状态:', this.gameState); console.log('[游戏结束] 最终游戏状态:', this.gameState);
console.log('[游戏结束] winnerId:', this.gameState.winnerId); console.log('[游戏结束] winnerId:', winnerId);
console.log('[游戏结束] gameStarted:', this.gameState.gameStarted);
console.log('[游戏结束] 是否显示弹窗:', !this.gameState.gameStarted && !!this.gameState.winnerId);
// //
let endMsg = ''; let endMsg = '';
if (this.gameState.winnerId === 'draw') { if (winnerId === 'draw') {
endMsg = '平局:无人幸存'; endMsg = '平局:无人幸存';
} else if (this.gameState.winnerId === this.myUserId) { } else if (winnerId === this.myUserId) {
endMsg = '🎉 您获得了胜利!'; endMsg = '🎉 您获得了胜利!';
} else { } else {
endMsg = '💀 很遗憾失败了'; endMsg = '💀 很遗憾失败了';
} }
this.addLog('system', `战局结束:${endMsg}`); this.addLog('system', endMsg);
// //
if (this.gameState.winnerId === this.myUserId) { if (winnerId === this.myUserId) {
uni.vibrateShort({ type: 'success' }); uni.vibrateShort({ type: 'success' });
} else if (this.gameState.winnerId === 'draw') { } else if (winnerId === 'draw') {
uni.vibrateShort({ type: 'warning' }); uni.vibrateShort({ type: 'warning' });
} else { } else {
uni.vibrateShort({ type: 'fail' }); uni.vibrateShort({ type: 'fail' });
@ -731,6 +767,7 @@ export default {
this.addLog('system', '已切断匹配信号'); this.addLog('system', '已切断匹配信号');
}, },
handleCellClick(idx) { handleCellClick(idx) {
if (this.isSpectator) return;
if (!this.gameState || !this.gameState.gameStarted) return; if (!this.gameState || !this.gameState.gameStarted) return;
if (!this.isMyTurn) return; if (!this.isMyTurn) return;
if (this.gameState.grid[idx].revealed) return; if (this.gameState.grid[idx].revealed) return;

View File

@ -0,0 +1,319 @@
<template>
<view class="page">
<view class="bg-decoration"></view>
<view class="header">
<text class="title">实时对战信号</text>
<view class="refresh-text-btn" :class="{ loading: loading }" @tap="loadRooms">
{{ loading ? '同步中...' : '刷新信号' }}
</view>
</view>
<scroll-view scroll-y class="content" @refresherrefresh="loadRooms" :refresher-enabled="true" :refresher-triggered="isRefreshing">
<view v-if="rooms.length > 0" class="room-list">
<view v-for="room in rooms" :key="room.match_id" class="room-card glass-card fadeInUp">
<view class="room-main">
<view class="room-info">
<view class="room-header">
<text class="room-id">房间 #{{ room.match_id.split('.')[0].substring(0, 6) }}</text>
<view class="status-badge" :class="room.started ? 'started' : 'waiting'">
{{ room.started ? '进行中' : '等待中' }}
</view>
</view>
<view class="room-stats">
<view class="stat-item">
<text class="stat-icon">👥</text>
<text class="stat-text">{{ room.player_count }}/{{ room.max_players }} 玩家</text>
</view>
<view class="stat-item">
<text class="stat-icon">📡</text>
<text class="stat-text">延迟: {{ Math.floor(Math.random() * 50) + 20 }}ms</text>
</view>
</view>
</view>
<view class="room-actions">
<view v-if="!room.started && room.player_count < room.max_players" class="btn-action join" @tap="joinRoom(room)">
<text class="action-text">加入</text>
</view>
<view class="btn-action watch" @tap="watchRoom(room)">
<text class="action-text">围观</text>
</view>
</view>
</view>
</view>
</view>
<view v-else-if="!loading" class="empty-box">
<view class="empty-icon">🛰</view>
<text class="empty-text">未监测到活跃战局</text>
<view class="btn-primary start-new" @tap="goBack">去发起匹配</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { nakamaManager } from '../../../utils/nakamaManager.js';
import { authRequest } from '../../../utils/request.js';
export default {
data() {
return {
rooms: [],
loading: false,
isRefreshing: false,
gameToken: '',
nakamaServer: '',
nakamaKey: ''
}
},
onLoad(options) {
this.gameToken = options.game_token;
this.nakamaServer = decodeURIComponent(options.nakama_server || '');
this.nakamaKey = decodeURIComponent(options.nakama_key || '');
this.initAndLoad();
},
methods: {
async initAndLoad() {
this.loading = true;
try {
if (!nakamaManager.isConnected) {
nakamaManager.initClient(this.nakamaServer || 'wss://game.1024tool.vip', this.nakamaKey || 'defaultkey');
await nakamaManager.authenticateWithGameToken(this.gameToken);
}
await this.loadRooms();
} catch (e) {
uni.showToast({ title: '连接通讯中心失败', icon: 'none' });
} finally {
this.loading = false;
}
},
async loadRooms() {
this.isRefreshing = true;
try {
const res = await nakamaManager.rpc('list_matches', {});
this.rooms = res || [];
} catch (e) {
console.error('Failed to load rooms', e);
} finally {
this.isRefreshing = false;
this.loading = false;
}
},
goBack() {
uni.navigateBack();
},
joinRoom(room) {
// MatchID play.vue
uni.navigateTo({
url: `/pages-game/game/minesweeper/play?match_id=${room.match_id}&game_token=${encodeURIComponent(this.gameToken)}&nakama_server=${encodeURIComponent(this.nakamaServer)}&nakama_key=${encodeURIComponent(this.nakamaKey)}`
});
},
watchRoom(room) {
uni.navigateTo({
url: `/pages-game/game/minesweeper/play?match_id=${room.match_id}&is_spectator=1&game_token=${encodeURIComponent(this.gameToken)}&nakama_server=${encodeURIComponent(this.nakamaServer)}&nakama_key=${encodeURIComponent(this.nakamaKey)}`
});
}
}
}
</script>
<style lang="scss" scoped>
@import '@/uni.scss';
.page {
min-height: 100vh;
background-color: #0f172a;
color: #f8fafc;
display: flex;
flex-direction: column;
}
.bg-decoration {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 400rpx;
background: radial-gradient(circle at 50% 0%, rgba(59, 130, 246, 0.15) 0%, transparent 70%);
z-index: 0;
}
.header {
position: relative;
z-index: 10;
padding: 100rpx 40rpx 40rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
font-size: 38rpx;
font-weight: 800;
letter-spacing: 2rpx;
color: #f8fafc;
}
.refresh-text-btn {
padding: 12rpx 24rpx;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12rpx;
font-size: 24rpx;
color: #94a3b8;
transition: all 0.2s;
&.loading {
opacity: 0.6;
pointer-events: none;
}
&:active {
background: rgba(255, 255, 255, 0.1);
transform: scale(0.95);
}
}
.content {
flex: 1;
padding: 0 30rpx;
box-sizing: border-box;
}
.room-list {
padding-bottom: 60rpx;
}
.room-card {
margin-bottom: 24rpx;
padding: 32rpx;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.room-main {
display: flex;
justify-content: space-between;
align-items: center;
}
.room-header {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.room-id {
font-size: 28rpx;
font-weight: 600;
color: #94a3b8;
margin-right: 16rpx;
}
.status-badge {
padding: 4rpx 16rpx;
border-radius: 20rpx;
font-size: 20rpx;
font-weight: 700;
&.waiting {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
&.started {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
}
.room-stats {
display: flex;
gap: 24rpx;
}
.stat-item {
display: flex;
align-items: center;
}
.stat-icon {
font-size: 24rpx;
margin-right: 8rpx;
}
.stat-text {
font-size: 24rpx;
color: #cbd5e1;
}
.room-actions {
display: flex;
gap: 16rpx;
}
.btn-action {
padding: 16rpx 32rpx;
border-radius: 12rpx;
font-size: 24rpx;
font-weight: 700;
transition: all 0.2s;
&.join {
background: #3b82f6;
color: white;
box-shadow: 0 4rpx 12rpx rgba(59, 130, 246, 0.3);
}
&.watch {
background: rgba(255, 255, 255, 0.1);
color: #f8fafc;
border: 1px solid rgba(255, 255, 255, 0.2);
}
&:active {
transform: scale(0.95);
}
}
.empty-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 200rpx;
}
.empty-icon {
font-size: 120rpx;
margin-bottom: 40rpx;
opacity: 0.5;
}
.empty-text {
font-size: 32rpx;
color: #64748b;
margin-bottom: 60rpx;
}
.start-new {
width: 320rpx;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fadeInUp {
animation: fadeInUp 0.4s ease-out both;
}
</style>

View File

@ -189,6 +189,14 @@
} }
} }
}, },
{
"path": "game/minesweeper/room-list",
"style": {
"navigationStyle": "default",
"navigationBarTitleText": "对战列表",
"disableScroll": true
}
},
{ {
"path": "game/webview", "path": "game/webview",
"style": { "style": {

View File

@ -282,12 +282,16 @@ class NakamaManager {
await this.authenticateWithGameToken(this.gameToken); await this.authenticateWithGameToken(this.gameToken);
} }
if (!this.gameToken) {
throw new Error('Missing game token in manager');
}
console.log('[Nakama] Adding to matchmaker:', minCount, '-', maxCount); console.log('[Nakama] Adding to matchmaker:', minCount, '-', maxCount);
const response = await this._send({ const response = await this._send({
matchmaker_add: { matchmaker_add: {
min_count: minCount || 2, min_count: minCount || 2,
max_count: maxCount || 2, max_count: maxCount || 2,
query: '*', query: '+properties.game_token:*',
string_properties: { game_token: this.gameToken } string_properties: { game_token: this.gameToken }
} }
}); });
@ -315,6 +319,28 @@ class NakamaManager {
return response.match; return response.match;
} }
/**
* 调用 RPC
*/
async rpc(id, payload) {
console.log('[Nakama] RPC call:', id);
const response = await this._send({
rpc: {
id: id,
payload: typeof payload === 'string' ? payload : JSON.stringify(payload)
}
});
if (response.rpc && response.rpc.payload) {
try {
return JSON.parse(response.rpc.payload);
} catch (e) {
return response.rpc.payload;
}
}
return response;
}
/** /**
* 发送游戏状态 * 发送游戏状态
*/ */