891 lines
35 KiB
Vue
891 lines
35 KiB
Vue
<template>
|
||
<view class="container" :class="{ 'screen-shake': screenShaking }">
|
||
<!-- 背景 -->
|
||
<view class="bg-dark-grid"></view>
|
||
|
||
<!-- 连接中状态 -->
|
||
<view v-if="!isConnected" class="loading-screen">
|
||
<view class="loading-content">
|
||
<view class="loading-spinner">📡</view>
|
||
<text class="loading-text">正在寻找基站...</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 匹配大厅 -->
|
||
<view v-else-if="!gameState" class="lobby-screen">
|
||
<view class="bg-decoration"></view>
|
||
|
||
<view class="lobby-content fadeInUp">
|
||
<text class="game-title">动物扫雷大作战</text>
|
||
|
||
<view v-if="isMatching" class="match-box glass-card">
|
||
<view class="scanner-box pulse">
|
||
<view class="scanner-line"></view>
|
||
<text class="match-spinner">🦁</text>
|
||
</view>
|
||
<text class="match-status">正在匹配全球玩家...</text>
|
||
|
||
<view class="match-info">
|
||
<view class="timer-row">
|
||
<text class="timer-label">等待时长</text>
|
||
<text class="timer-value">{{ matchingTimer }}s</text>
|
||
</view>
|
||
|
||
<view class="status-tip">
|
||
<text v-if="matchingTimer <= 30" class="tip-normal">⚡ 正在接入星际网道</text>
|
||
<text v-else-if="matchingTimer <= 60" class="tip-warning">⏳ 搜索频率增强中...</text>
|
||
<text v-else class="tip-error">⚠️ 信号微弱,请重试</text>
|
||
</view>
|
||
|
||
<view class="btn-secondary cancel-btn" @tap="cancelMatchmaking">
|
||
取消匹配
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-else class="start-box">
|
||
<view class="game-intro-card glass-card">
|
||
<view class="intro-item">
|
||
<text class="intro-icon">🎮</text>
|
||
<text class="intro-text">{{ matchPlayerCount }}人经典竞技模式</text>
|
||
</view>
|
||
<view class="intro-item">
|
||
<text class="intro-icon">🏆</text>
|
||
<text class="intro-text">胜者赢取全额奖励</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="btn-primary start-btn" @tap="startMatchmaking">
|
||
<text class="btn-text">🚀 开始匹配</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 游戏日志 -->
|
||
<view class="log-section glass-card">
|
||
<view class="log-header">
|
||
<text class="log-title">通讯终端 (LIVE)</text>
|
||
<view class="online-indicator"></view>
|
||
</view>
|
||
<scroll-view scroll-y class="mini-logs" :scroll-top="logsScrollTop">
|
||
<view v-for="log in logs" :key="log.id" class="log-item" :class="'log-' + log.type">
|
||
<text class="log-time">[{{ log.time }}]</text>
|
||
<text class="log-content">{{ log.content }}</text>
|
||
</view>
|
||
<view v-if="logs.length === 0" class="log-empty">等待操作指令...</view>
|
||
</scroll-view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 游戏主界面 -->
|
||
<view v-else class="game-screen">
|
||
<!-- 顶部状态栏 -->
|
||
<view class="game-header">
|
||
<view class="round-badge">Round {{ gameState.globalTurnCount || gameState.round || 0 }}</view>
|
||
<text class="header-title">动物扫雷</text>
|
||
<view class="header-actions">
|
||
<view class="btn-icon" @tap="showGuide = true">📜</view>
|
||
<view class="btn-icon" :class="{ active: debugMode }" @tap="debugMode = !debugMode">🐛</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="game-layout">
|
||
<!-- 对手列表 (横向滚动) -->
|
||
<scroll-view scroll-x class="opponents-bar">
|
||
<view class="opponents-list">
|
||
<view
|
||
v-for="p in opponents"
|
||
:key="p.userId"
|
||
class="player-card opponent"
|
||
:class="{
|
||
'active-turn': gameState.gameStarted && gameState.turnOrder[gameState.currentTurnIndex] === p.userId,
|
||
'damaged': damagedPlayers.includes(p.userId),
|
||
'healed': healedPlayers.includes(p.userId)
|
||
}"
|
||
>
|
||
<text class="avatar">{{ getPlayerAvatar(p) }}</text>
|
||
<view class="player-info">
|
||
<text class="username">{{ p.username || '对手' }}</text>
|
||
<view class="hp-bar">
|
||
<text
|
||
v-for="(n, i) in p.maxHp"
|
||
:key="i"
|
||
class="heart"
|
||
:class="{ filled: i < p.hp }"
|
||
>{{ i < p.hp ? '❤️' : '🤍' }}</text>
|
||
</view>
|
||
<view class="status-icons">
|
||
<text v-if="p.shield" class="icon">🛡️</text>
|
||
<text v-if="p.poisoned" class="icon">☠️</text>
|
||
<text v-if="p.curse" class="icon">👻</text>
|
||
<text v-if="p.revive" class="icon">💖</text>
|
||
<text v-if="p.timeBombTurns > 0" class="icon pulse">⏰{{p.timeBombTurns}}</text>
|
||
<text v-if="p.skipTurn" class="icon">⏭️</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 玩家身上的飘字 -->
|
||
<view v-for="l in getPlayerLabels(p.userId)" :key="l.id" class="float-label" :class="'text-' + l.type" style="top: 20%; left: 50%; transform: translateX(-50%);">
|
||
{{ l.text }}
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<!-- 棋盘区 -->
|
||
<view class="board-area">
|
||
<view class="turn-indicator" v-if="gameState.gameStarted">
|
||
<view class="turn-badge" :class="{ 'my-turn pulse': isMyTurn }">
|
||
{{ isMyTurn ? '您的回合' : '等待对手...' }}
|
||
</view>
|
||
<view class="timer-badge" :class="{ urgent: turnTimer < 5 }">
|
||
⏱️ {{ turnTimer }}s
|
||
</view>
|
||
<view v-if="isSpectator" class="spectator-badge">围观中</view>
|
||
</view>
|
||
|
||
<view class="timer-progress-bg" v-if="gameState.gameStarted">
|
||
<view
|
||
class="timer-progress-fill"
|
||
:style="{ width: (turnTimer / 15 * 100) + '%' }"
|
||
:class="turnTimer < 5 ? 'bg-red' : (turnTimer < 10 ? 'bg-yellow' : 'bg-green')"
|
||
></view>
|
||
</view>
|
||
|
||
<view class="grid-board" :style="{ gridTemplateColumns: 'repeat(' + gameState.gridSize + ', 1fr)' }">
|
||
<view
|
||
v-for="(cell, i) in gameState.grid"
|
||
:key="i"
|
||
class="grid-cell"
|
||
:class="[
|
||
cell.revealed ? (cell.type === 'bomb' ? 'type-bomb explosion' : (cell.type === 'item' ? 'type-item item-' + (cell.itemId || 'generic') : 'type-empty')) : 'bg-slate-800',
|
||
{
|
||
'revealed': cell.revealed,
|
||
'has-magnifier': myPlayer && myPlayer.revealedCells && myPlayer.revealedCells[i]
|
||
}
|
||
]"
|
||
@tap="handleCellClick(i)"
|
||
>
|
||
<view v-if="cell.revealed">
|
||
<text v-if="cell.type === 'bomb'" class="cell-icon">💣</text>
|
||
<text v-else-if="cell.type === 'empty'" class="cell-num" :style="{ color: getNumColor(cell.neighborBombs) }">
|
||
{{ cell.neighborBombs > 0 ? cell.neighborBombs : '' }}
|
||
</text>
|
||
<text v-else-if="cell.type === 'item'" class="cell-icon">{{ getItemIcon(cell.itemId) }}</text>
|
||
</view>
|
||
<view v-else-if="myPlayer && myPlayer.revealedCells && myPlayer.revealedCells[i]" class="magnifier-mark">
|
||
<text class="cell-icon magnifier-content">{{ getContentIcon(myPlayer.revealedCells[i]) }}</text>
|
||
<text class="magnifier-badge">🔍</text>
|
||
</view>
|
||
|
||
<!-- 格子上的飘字 -->
|
||
<view v-for="l in getCellLabels(i)" :key="l.id" class="float-label" :class="'text-' + l.type">
|
||
{{ l.text }}
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 底部面板 -->
|
||
<view class="bottom-panel">
|
||
<view v-if="myPlayer" class="player-card me"
|
||
:class="{
|
||
'active-turn': isMyTurn,
|
||
'damaged': damagedPlayers.includes(myPlayer.userId),
|
||
'healed': healedPlayers.includes(myPlayer.userId)
|
||
}"
|
||
>
|
||
<text class="avatar lg">{{ getPlayerAvatar(myPlayer) }}</text>
|
||
<view class="player-info">
|
||
<text class="username font-bold">我</text>
|
||
<view class="hp-bar">
|
||
<text
|
||
v-for="(n, i) in myPlayer.maxHp"
|
||
:key="i"
|
||
class="heart"
|
||
:class="{ filled: i < myPlayer.hp }"
|
||
>{{ i < myPlayer.hp ? '❤️' : '🤍' }}</text>
|
||
</view>
|
||
<view class="status-icons">
|
||
<text v-if="myPlayer.shield" class="icon">🛡️</text>
|
||
<text v-if="myPlayer.poisoned" class="icon">☠️</text>
|
||
<text v-if="myPlayer.curse" class="icon">👻</text>
|
||
<text v-if="myPlayer.revive" class="icon">💖</text>
|
||
<text v-if="myPlayer.timeBombTurns > 0" class="icon pulse">⏰{{myPlayer.timeBombTurns}}</text>
|
||
<text v-if="myPlayer.skipTurn" class="icon">⏭️</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 我身上的飘字 -->
|
||
<view v-for="l in getPlayerLabels(myPlayer.userId)" :key="l.id" class="float-label" :class="'text-' + l.type" style="top: -20rpx; left: 50%; transform: translateX(-50%);">
|
||
{{ l.text }}
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 迷你日志栏 -->
|
||
<scroll-view scroll-y class="game-logs" :scroll-top="logsScrollTop">
|
||
<view v-for="log in logs" :key="log.id" class="log-line" :class="'log-' + log.type">
|
||
{{ log.content }}
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 结算弹窗 - 移至根级,确保不被 game-screen 的销毁影响 -->
|
||
<view v-if="showResultModal" class="modal-overlay" style="z-index: 9999;">
|
||
<view class="modal-content glass-card">
|
||
<text class="modal-emoji">{{ getGameResultEmoji() }}</text>
|
||
<text class="modal-title">{{ getGameResultTitle() }}</text>
|
||
<view class="result-details" v-if="gameState && gameState.players">
|
||
<text class="detail-text">{{ getResultMessage() }}</text>
|
||
</view>
|
||
<view
|
||
class="btn-primary"
|
||
:class="{ disabled: isRefreshing }"
|
||
@tap="refreshAndPlayAgain"
|
||
>
|
||
<text class="btn-text">🚀 再来一局</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
|
||
|
||
<!-- 玩法说明弹窗 -->
|
||
<view v-if="showGuide" class="modal-overlay" @tap="showGuide = false">
|
||
<view class="guide-modal glass-card" @tap.stop>
|
||
<view class="guide-header">
|
||
<text class="guide-title">核心战书</text>
|
||
<view class="close-btn" @tap="showGuide = false">✕</view>
|
||
</view>
|
||
<scroll-view scroll-y class="guide-body">
|
||
<text class="section-title">🛡️ 道具百科</text>
|
||
<view class="guide-grid">
|
||
<view class="guide-item" v-for="(item, i) in guideItems" :key="'i'+i">
|
||
<text class="icon">{{ item.i }}</text>
|
||
<view class="desc">
|
||
<text class="name">{{ item.n }}</text>
|
||
<text class="detail">{{ item.d }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<text class="section-title" style="margin-top: 32rpx;">🐾 角色天赋</text>
|
||
<view class="guide-grid">
|
||
<view class="guide-item" v-for="(item, i) in characterGuides" :key="'c'+i">
|
||
<text class="icon">{{ item.i }}</text>
|
||
<view class="desc">
|
||
<text class="name">{{ item.n }}</text>
|
||
<text class="detail">{{ item.d }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 全局飘字 -->
|
||
<view v-for="l in globalLabels" :key="l.id" class="float-label global" :style="{ top: l.y + 'px', left: l.x + 'px' }" :class="'text-' + l.type">
|
||
{{ l.text }}
|
||
</view>
|
||
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { nakamaManager } from '../../../utils/nakamaManager.js';
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
gameState: null,
|
||
logs: [],
|
||
isConnected: false,
|
||
isMatching: false,
|
||
matchId: null,
|
||
myUserId: null,
|
||
floatingLabels: [],
|
||
showGuide: false,
|
||
debugMode: false,
|
||
matchingTimer: 0,
|
||
matchPlayerCount: 4,
|
||
turnTimer: 15,
|
||
screenShaking: false,
|
||
damagedPlayers: [],
|
||
healedPlayers: [],
|
||
isRefreshing: false,
|
||
logsScrollTop: 0,
|
||
isSpectator: false,
|
||
showSettlement: false,
|
||
settlementWinnerId: '',
|
||
showResultModal: false,
|
||
// Timers
|
||
matchInterval: null,
|
||
turnInterval: null,
|
||
|
||
guideItems: [
|
||
{ i: '💊', n: '医疗包', d: '回复1点HP并清除中毒' },
|
||
{ i: '🛡️', n: '护盾', d: '抵挡下次伤害' },
|
||
{ i: '🔍', n: '放大镜', d: '透视1个格子' },
|
||
{ i: '🔪', n: '匕首', d: '对随机对手造成1点伤害' },
|
||
{ i: '⚡', n: '闪电', d: '全员造成1点伤害' },
|
||
{ i: '⏭️', n: '好人卡', d: '获得护盾,跳过回合' },
|
||
{ i: '💖', n: '复活', d: 'HP归零时复活' },
|
||
{ i: '⏰', n: '定时炸弹', d: '3回合后爆炸' },
|
||
{ i: '☠️', n: '毒药', d: '对手中毒每步扣血' },
|
||
{ i: '👻', n: '诅咒', d: '特定操作触发扣血' },
|
||
{ i: '📦', n: '宝箱', d: '随机大奖' },
|
||
],
|
||
characterGuides: [
|
||
{ i: '🐶', n: '小狗', d: '忠诚:每移动一定步数必触发放大镜效果' },
|
||
{ i: '🐘', n: '大象', d: '执拗:无法回复生命,但基础HP更高' },
|
||
{ i: '🐯', n: '虎哥', d: '猛攻:匕首进化为全屏范围伤害' },
|
||
{ i: '🐵', n: '猴子', d: '敏锐:每次点击都有概率发现香蕉(回血)' },
|
||
{ i: '🦥', n: '树懒', d: '迟缓:翻到炸弹时伤害减半(扣1点)' },
|
||
{ i: '🦛', n: '河马', d: '大胃:无法直接捡起道具卡' },
|
||
]
|
||
}
|
||
},
|
||
computed: {
|
||
myPlayer() {
|
||
if (!this.gameState || !this.gameState.players || !this.myUserId) return null;
|
||
return this.gameState.players[this.myUserId];
|
||
},
|
||
opponents() {
|
||
if (!this.gameState || !this.gameState.players || !this.myUserId) return [];
|
||
return Object.values(this.gameState.players).filter(p => p.userId !== this.myUserId);
|
||
},
|
||
isMyTurn() {
|
||
if (!this.gameState) return false;
|
||
return this.gameState.turnOrder[this.gameState.currentTurnIndex] === this.myUserId;
|
||
},
|
||
globalLabels() {
|
||
return this.floatingLabels.filter(l => l.cellIndex === undefined && l.targetUserId === undefined);
|
||
},
|
||
// 判断是否应该显示结算弹窗
|
||
shouldShowResultModal() {
|
||
// 有游戏状态且游戏已结束且有获胜者
|
||
if (!this.gameState) return false;
|
||
|
||
// 检查 gameStarted 是否为 false(游戏已结束)
|
||
const isGameOver = this.gameState.gameStarted === false;
|
||
|
||
// 检查是否有获胜者
|
||
const hasWinner = !!this.gameState.winnerId;
|
||
|
||
console.log('[shouldShowResultModal] 检查弹窗显示条件:', {
|
||
isGameOver,
|
||
hasWinner,
|
||
winnerId: this.gameState.winnerId,
|
||
myUserId: this.myUserId,
|
||
shouldShow: isGameOver && hasWinner
|
||
});
|
||
|
||
return isGameOver && hasWinner;
|
||
}
|
||
},
|
||
watch: {
|
||
shouldShowResultModal(newVal) {
|
||
console.log('[WATCH] shouldShowResultModal changed:', newVal);
|
||
if (newVal) {
|
||
this.showResultModal = true;
|
||
}
|
||
},
|
||
logs() {
|
||
this.$nextTick(() => {
|
||
this.logsScrollTop = 99999 + Math.random();
|
||
});
|
||
},
|
||
'gameState.currentTurnIndex'() {
|
||
this.resetTurnTimer();
|
||
},
|
||
'gameState.gameStarted'(val) {
|
||
if (val) this.resetTurnTimer();
|
||
}
|
||
},
|
||
onLoad(options) {
|
||
this.fetchGameConfig();
|
||
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) {
|
||
this.initNakama(game_token, decodeURIComponent(nakama_server || ''), decodeURIComponent(nakama_key || ''));
|
||
} else {
|
||
uni.showToast({ title: '参数错误', icon: 'none' });
|
||
}
|
||
},
|
||
onUnload() {
|
||
this.cleanup();
|
||
},
|
||
methods: {
|
||
async fetchGameConfig() {
|
||
try {
|
||
const res = await new Promise((resolve, reject) => {
|
||
uni.request({
|
||
url: 'https://game.1024tool.vip/api/internal/game/minesweeper/config',
|
||
header: { 'X-Internal-Key': 'bindbox-internal-secret-2024' },
|
||
success: (res) => resolve(res),
|
||
fail: (err) => reject(err)
|
||
})
|
||
});
|
||
if (res.statusCode === 200 && res.data) {
|
||
const config = res.data;
|
||
if (config.match_player_count && config.match_player_count >= 2) {
|
||
this.matchPlayerCount = config.match_player_count;
|
||
}
|
||
}
|
||
} catch(e) {
|
||
console.warn('Config fetch failed');
|
||
}
|
||
},
|
||
getCellLabels(idx) {
|
||
return this.floatingLabels.filter(l => l.cellIndex === idx);
|
||
},
|
||
getPlayerLabels(userId) {
|
||
return this.floatingLabels.filter(l => l.targetUserId === userId);
|
||
},
|
||
addLog(type, content) {
|
||
const now = new Date();
|
||
const timeStr = now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString().padStart(2, '0');
|
||
const id = Date.now() + Math.random().toString();
|
||
this.logs.push({ id, type, content, time: timeStr });
|
||
if (this.logs.length > 50) this.logs.shift();
|
||
},
|
||
spawnLabel(x, y, text, type, cellIndex, targetUserId) {
|
||
const id = Date.now() + Math.random().toString();
|
||
this.floatingLabels.push({ id, x, y, text, type, cellIndex, targetUserId });
|
||
setTimeout(() => {
|
||
this.floatingLabels = this.floatingLabels.filter(l => l.id !== id);
|
||
}, 1000);
|
||
},
|
||
async initNakama(token, server, key) {
|
||
try {
|
||
const serverUrl = server || 'wss://game.1024tool.vip';
|
||
const serverKey = key || 'defaultkey';
|
||
nakamaManager.initClient(serverUrl, serverKey);
|
||
const session = await nakamaManager.authenticateWithGameToken(token);
|
||
this.myUserId = session.user_id;
|
||
this.isConnected = true;
|
||
this.addLog('system', '✅ 已连接到远程节点');
|
||
// 先设置监听器,再继续后续操作
|
||
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) {
|
||
this.addLog('system', '❌ 通讯异常: ' + e.message);
|
||
}
|
||
},
|
||
setupSocketListeners() {
|
||
nakamaManager.setListeners({
|
||
onmatchmakermatched: async (matched) => {
|
||
// ========== 控制台日志 - 匹配成功 ==========
|
||
console.log('=== 匹配成功事件 ===');
|
||
console.log('[匹配时间]', new Date().toLocaleString());
|
||
console.log('[Match ID]', matched.match_id);
|
||
console.log('[Match Token]', matched.token ? '存在' : '不存在 (权威模式通常不需要)');
|
||
console.log('[完整匹配对象]', JSON.stringify(matched, null, 2));
|
||
console.log('==================');
|
||
|
||
this.addLog('system', `📡 信号锁定!同步中...`);
|
||
this.isMatching = false;
|
||
clearInterval(this.matchInterval);
|
||
|
||
// 保存匹配信息用于可能的重连
|
||
this.pendingMatchId = matched.match_id;
|
||
this.pendingMatchToken = matched.token;
|
||
|
||
// 带重试的 joinMatch
|
||
const maxRetries = 3;
|
||
for (let i = 0; i < maxRetries; i++) {
|
||
try {
|
||
console.log(`[尝试 ${i + 1}/${maxRetries}] 开始加入游戏房间...`);
|
||
const match = await nakamaManager.joinMatch(matched.match_id, matched.token);
|
||
|
||
// ========== 控制台日志 - 加入成功 ==========
|
||
console.log('=== 成功加入游戏房间 ===');
|
||
console.log('[房间 ID]', match.match_id);
|
||
console.log('[我的用户 ID]', this.myUserId);
|
||
console.log('[房间对象]', JSON.stringify(match, null, 2));
|
||
console.log('======================');
|
||
|
||
this.matchId = match.match_id;
|
||
this.addLog('system', `成功接入战局`);
|
||
setTimeout(() => {
|
||
console.log('[发送状态请求] 请求初始游戏状态...');
|
||
nakamaManager.sendMatchState(match.match_id, 100, JSON.stringify({ action: 'getState' }));
|
||
}, 100);
|
||
return; // 成功,退出重试循环
|
||
} catch (e) {
|
||
// ========== 控制台日志 - 加入失败 ==========
|
||
console.error(`[尝试 ${i + 1}/${maxRetries} 失败]`, e.message);
|
||
console.error('[错误详情]', e);
|
||
|
||
this.addLog('system', `⚠️ 接入尝试 ${i + 1}/${maxRetries} 失败: ${e.message}`);
|
||
if (i < maxRetries - 1) {
|
||
console.log(`等待 1 秒后重试...`);
|
||
await new Promise(r => setTimeout(r, 1000)); // 等待1秒后重试
|
||
}
|
||
}
|
||
}
|
||
|
||
// ========== 控制台日志 - 最终失败 ==========
|
||
console.error('=== 加入游戏房间最终失败 ===');
|
||
console.error('[Match ID]', matched.match_id);
|
||
console.error('[重试次数]', maxRetries);
|
||
console.error('============================');
|
||
|
||
this.addLog('system', `❌ 接入失败,请检查网络后重试`);
|
||
},
|
||
onmatchdata: (matchData) => {
|
||
const opCode = matchData.op_code;
|
||
// NakamaManager 已内部处理了 utf8 解码,直接 parse 即可
|
||
// const data = JSON.parse(new TextDecoder().decode(matchData.data));
|
||
const data = typeof matchData.data === 'string' ? JSON.parse(matchData.data) : matchData.data;
|
||
|
||
// ========== 控制台日志 - 接收游戏数据 ==========
|
||
console.log('=== 接收游戏数据 ===');
|
||
console.log('[OpCode]', opCode, `(${this.getOpCodeName(opCode)})`);
|
||
console.log('[数据]', data);
|
||
console.log('[时间]', new Date().toLocaleString());
|
||
console.log('====================');
|
||
|
||
this.handleGameData(opCode, data);
|
||
},
|
||
ondisconnect: async () => {
|
||
// ========== 控制台日志 - 断开连接 ==========
|
||
console.warn('=== 连接断开 ===');
|
||
console.warn('[断开时间]', new Date().toLocaleString());
|
||
console.warn('[Match ID]', this.matchId);
|
||
console.warn('===============');
|
||
|
||
this.addLog('system', `⚠️ 连接断开,尝试重连中...`);
|
||
this.isConnected = false;
|
||
|
||
// 自动重连
|
||
try {
|
||
console.log('[重连] 开始重新连接...');
|
||
await nakamaManager.authenticateWithGameToken(this.gameToken);
|
||
this.isConnected = true;
|
||
|
||
console.log('[重连] 认证成功');
|
||
|
||
this.addLog('system', `✅ 重连成功`);
|
||
|
||
// 如果有进行中的比赛,尝试重新加入
|
||
if (this.matchId) {
|
||
console.log('[重连] 尝试重新加入游戏房间:', this.matchId);
|
||
const match = await nakamaManager.joinMatch(this.matchId);
|
||
console.log('[重连] 成功重新加入游戏房间');
|
||
|
||
this.addLog('system', `✅ 已重新加入战局`);
|
||
nakamaManager.sendMatchState(this.matchId, 100, JSON.stringify({ action: 'getState' }));
|
||
}
|
||
} catch (e) {
|
||
console.error('[重连] 失败:', e);
|
||
this.addLog('system', `❌ 重连失败: ${e.message}`);
|
||
}
|
||
}
|
||
});
|
||
},
|
||
handleGameData(opCode, data) {
|
||
// 关键游戏状态的控制台日志
|
||
if (opCode === 1) {
|
||
// ========== 控制台日志 - 游戏开始 ==========
|
||
console.log('🎮 ========== 游戏开始 ==========');
|
||
console.log('[玩家数量]', Object.keys(data.players || {}).length);
|
||
console.log('[我的位置]', data.turnOrder?.indexOf(this.myUserId));
|
||
console.log('[网格大小]', data.gridSize);
|
||
console.log('[游戏状态]', data);
|
||
console.log('[DEBUG] myUserId:', this.myUserId);
|
||
console.log('[DEBUG] turnOrder:', data.turnOrder);
|
||
console.log('[DEBUG] currentTurnIndex:', data.currentTurnIndex);
|
||
console.log('[DEBUG] 当前回合玩家:', data.turnOrder?.[data.currentTurnIndex]);
|
||
console.log('[DEBUG] isMyTurn?', data.turnOrder?.[data.currentTurnIndex] === this.myUserId);
|
||
console.log('================================');
|
||
} else if (opCode === 6) {
|
||
// ========== 控制台日志 - 游戏结束 ==========
|
||
console.log('🏁 ========== 游戏结束 ==========');
|
||
console.log('[获胜者]', data.winnerId);
|
||
console.log('[是否胜利]', data.winnerId === this.myUserId);
|
||
console.log('[最终状态]', data);
|
||
console.log('================================');
|
||
}
|
||
|
||
if (opCode === 1) {
|
||
this.gameState = data;
|
||
this.addLog('system', '战局开始,准备翻格!');
|
||
} else if (opCode === 2) {
|
||
// 状态更新 - 检测 HP 变化触发特效
|
||
const prevState = this.gameState;
|
||
if (prevState && prevState.players) {
|
||
Object.keys(data.players || {}).forEach(uid => {
|
||
const prevP = prevState.players[uid];
|
||
const newP = data.players[uid];
|
||
if (!prevP || !newP) return;
|
||
|
||
const hpDiff = newP.hp - prevP.hp;
|
||
if (hpDiff < 0) {
|
||
// 受伤
|
||
const dmg = Math.abs(hpDiff);
|
||
this.spawnLabel(0, 0, `-${dmg}`, 'damage', undefined, uid);
|
||
this.triggerDamageEffect(uid, dmg);
|
||
} else if (hpDiff > 0) {
|
||
// 治疗
|
||
const heal = Math.abs(hpDiff);
|
||
this.spawnLabel(0, 0, `+${heal}`, 'heal', undefined, uid);
|
||
this.healedPlayers.push(uid);
|
||
setTimeout(() => this.healedPlayers = this.healedPlayers.filter(id => id !== uid), 600);
|
||
}
|
||
});
|
||
}
|
||
this.gameState = data;
|
||
} else if (opCode === 5) {
|
||
this.handleEvent(data);
|
||
} else if (opCode === 6) {
|
||
// 游戏结束 - 确保正确设置游戏状态
|
||
console.log('[游戏结束] 接收到游戏结束数据:', data);
|
||
|
||
const winnerId = data.winnerId || (data.gameState && data.gameState.winnerId) || '';
|
||
this.settlementWinnerId = winnerId;
|
||
this.showSettlement = !!winnerId;
|
||
|
||
if (data.gameState) {
|
||
// 更新游戏状态并标记为已结束
|
||
this.gameState = {
|
||
...data.gameState,
|
||
gameStarted: false, // 明确标记游戏已结束
|
||
winnerId: winnerId
|
||
};
|
||
} else if (this.gameState) {
|
||
this.gameState.gameStarted = false;
|
||
if (winnerId) this.gameState.winnerId = winnerId;
|
||
} else {
|
||
this.gameState = {
|
||
...data,
|
||
gameStarted: false,
|
||
winnerId: winnerId
|
||
};
|
||
}
|
||
|
||
// 确保 winnerId 存在于全局状态中以便计算属性使用
|
||
if (this.gameState && !this.gameState.winnerId && winnerId) {
|
||
this.gameState.winnerId = winnerId;
|
||
}
|
||
|
||
console.log('[游戏结束] 最终游戏状态:', this.gameState);
|
||
console.log('[游戏结束] winnerId:', winnerId);
|
||
|
||
// 根据结果显示不同的日志消息
|
||
let endMsg = '';
|
||
if (winnerId === 'draw') {
|
||
endMsg = '平局:无人幸存';
|
||
} else if (winnerId === this.myUserId) {
|
||
endMsg = '🎉 您获得了胜利!';
|
||
} else {
|
||
endMsg = '💀 很遗憾失败了';
|
||
}
|
||
this.addLog('system', endMsg);
|
||
|
||
// 添加震动反馈
|
||
if (winnerId === this.myUserId) {
|
||
uni.vibrateShort({ type: 'success' });
|
||
} else if (winnerId === 'draw') {
|
||
uni.vibrateShort({ type: 'warning' });
|
||
} else {
|
||
uni.vibrateShort({ type: 'fail' });
|
||
}
|
||
}
|
||
},
|
||
handleEvent(event) {
|
||
if (event.type === 'damage' || event.type === 'heal' || event.type === 'item' || event.type === 'ability') {
|
||
const isMe = event.targetUserId === this.myUserId;
|
||
const iAmTarget = isMe;
|
||
const attackerName = event.playerName || '对手';
|
||
const itemDisplayName = this.guideItems.find(i => i.i === event.itemId)?.n || event.itemId;
|
||
|
||
let msg = '';
|
||
if (event.type === 'item') msg = `发现了 ${itemDisplayName}`;
|
||
else if (event.type === 'damage') msg = `受到了 ${event.value} 点伤害`;
|
||
else if (event.type === 'heal') msg = `回复了 ${event.value} 点生命`;
|
||
|
||
if (isMe) this.addLog('effect', `指令提示: ${msg}`);
|
||
else this.addLog('effect', `${attackerName.substring(0,8)}: ${msg}`);
|
||
|
||
if (event.type === 'damage' || event.type === 'item') {
|
||
if (iAmTarget && event.value) this.spawnLabel(0, 0, `-${event.value}`, 'damage', undefined, this.myUserId);
|
||
}
|
||
// 对于 opCode 5 的 damage 事件,也触发特效(通常 opCode 2 会覆盖,但多触发一次无妨,作为保险)
|
||
if (event.type === 'damage' && iAmTarget) this.triggerDamageEffect(this.myUserId, event.value);
|
||
}
|
||
},
|
||
triggerDamageEffect(uid, amount) {
|
||
this.damagedPlayers.push(uid);
|
||
setTimeout(() => this.damagedPlayers = this.damagedPlayers.filter(id => id !== uid), 600);
|
||
if (uid === this.myUserId) {
|
||
this.screenShaking = true;
|
||
setTimeout(() => this.screenShaking = false, 400);
|
||
}
|
||
},
|
||
async startMatchmaking() {
|
||
if (this.isMatching) return;
|
||
try {
|
||
console.log('--- UI Click: Start Matchmaking ---');
|
||
this.isMatching = true;
|
||
this.matchingTimer = 0;
|
||
this.logs = [];
|
||
this.addLog('system', '🚀 发射匹配脉冲...');
|
||
|
||
clearInterval(this.matchInterval);
|
||
this.matchInterval = setInterval(() => this.matchingTimer++, 1000);
|
||
|
||
await nakamaManager.findMatch(this.matchPlayerCount, this.matchPlayerCount);
|
||
console.log('Matchmaker ticket requested');
|
||
} catch (e) {
|
||
console.error('Matchmaking error:', e);
|
||
this.isMatching = false;
|
||
clearInterval(this.matchInterval);
|
||
this.addLog('system', '❌ 发射失败');
|
||
}
|
||
},
|
||
cancelMatchmaking() {
|
||
this.isMatching = false;
|
||
clearInterval(this.matchInterval);
|
||
this.addLog('system', '已切断匹配信号');
|
||
},
|
||
handleCellClick(idx) {
|
||
if (this.isSpectator) return;
|
||
if (!this.gameState || !this.gameState.gameStarted) return;
|
||
if (!this.isMyTurn) return;
|
||
if (this.gameState.grid[idx].revealed) return;
|
||
nakamaManager.sendMatchState(this.matchId, 3, JSON.stringify({ index: idx }));
|
||
},
|
||
refreshAndPlayAgain() {
|
||
uni.navigateBack();
|
||
},
|
||
resetTurnTimer() {
|
||
this.turnTimer = 15;
|
||
clearInterval(this.turnInterval);
|
||
this.turnInterval = setInterval(() => {
|
||
if (this.turnTimer > 0) this.turnTimer--;
|
||
}, 1000);
|
||
},
|
||
cleanup() {
|
||
clearInterval(this.matchInterval);
|
||
clearInterval(this.turnInterval);
|
||
nakamaManager.disconnect();
|
||
},
|
||
getItemIcon(itemId) {
|
||
const map = { medkit: '💊', bomb_timer: '⏰', poison: '☠️', shield: '🛡️', skip: '⏭️', magnifier: '🔍', knife: '🔪', revive: '💖', lightning: '⚡', chest: '📦', curse: '👻' };
|
||
return map[itemId] || '🎁';
|
||
},
|
||
getContentIcon(content) {
|
||
if (content === 'bomb') return '💣';
|
||
if (content === 'empty') return '✅';
|
||
return this.getItemIcon(content);
|
||
},
|
||
getPlayerAvatar(player) {
|
||
// 优先从 character 字段获取 emoji
|
||
const charAvatars = {
|
||
dog: '🐶',
|
||
elephant: '🐘',
|
||
tiger: '🐯',
|
||
monkey: '🐵',
|
||
sloth: '🦥',
|
||
hippo: '🦛',
|
||
cat: '🐱',
|
||
chicken: '🐔'
|
||
};
|
||
if (player.character && charAvatars[player.character]) {
|
||
return charAvatars[player.character];
|
||
}
|
||
|
||
// 如果服务器直接发送了 avatar 字段
|
||
if (player.avatar) return player.avatar;
|
||
|
||
// 降级:根据 userId 确定性分配
|
||
const avatars = ['🐶', '🐘', '🐯', '🐵', '🦥', '🦛'];
|
||
const seed = player.userId || player.username || player.id || '';
|
||
let hash = 0;
|
||
for (let i = 0; i < seed.length; i++) {
|
||
hash = ((hash << 5) - hash) + seed.charCodeAt(i);
|
||
hash = hash & hash; // 转换为32位整数
|
||
}
|
||
const index = Math.abs(hash) % avatars.length;
|
||
return avatars[index];
|
||
},
|
||
getNumColor(n) { return ['','blue','green','red','purple','orange'][n] || 'black'; },
|
||
getOpCodeName(code) {
|
||
const map = {
|
||
1: '游戏开始',
|
||
2: '状态更新',
|
||
3: '玩家点击格子',
|
||
5: '游戏事件',
|
||
6: '游戏结束',
|
||
100: '请求游戏状态'
|
||
};
|
||
return map[code] || '未知';
|
||
},
|
||
// 获取结算详情信息
|
||
getResultMessage() {
|
||
if (!this.gameState || !this.gameState.players) return '';
|
||
|
||
const winnerId = this.gameState.winnerId;
|
||
|
||
// 平局情况
|
||
if (winnerId === 'draw') {
|
||
return '所有玩家都倒下了,本局为平局!';
|
||
}
|
||
|
||
const winner = this.gameState.players[winnerId];
|
||
const winnerName = winner ? (winner.username || '对手') : '未知';
|
||
|
||
if (winnerId === this.myUserId) {
|
||
return '恭喜您战胜了所有对手,获得胜利!';
|
||
} else {
|
||
return `${winnerName.substring(0, 10)} 获得了胜利,下次好运!`;
|
||
}
|
||
},
|
||
// 获取游戏结果表情
|
||
getGameResultEmoji() {
|
||
if (!this.gameState || !this.gameState.winnerId) return '💀';
|
||
|
||
if (this.gameState.winnerId === 'draw') {
|
||
return '🤝';
|
||
} else if (this.gameState.winnerId === this.myUserId) {
|
||
return '🏆';
|
||
} else {
|
||
return '💀';
|
||
}
|
||
},
|
||
// 获取游戏结果标题
|
||
getGameResultTitle() {
|
||
if (!this.gameState || !this.gameState.winnerId) return '很遗憾失败了';
|
||
|
||
if (this.gameState.winnerId === 'draw') {
|
||
return '平局';
|
||
} else if (this.gameState.winnerId === this.myUserId) {
|
||
return '胜利!';
|
||
} else {
|
||
return '很遗憾失败了';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style src="./play.scss" lang="scss"></style>
|