967 lines
38 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

<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"
:class="{
'safe-zone': myPlayer.revealedCells[i] === 'empty',
'bomb-zone': myPlayer.revealedCells[i] === 'bomb'
}"
>
<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;" @tap="closeResultModal">
<view class="modal-content glass-card" @tap.stop>
<view class="modal-close-btn" @tap="closeResultModal"></view>
<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,
gameToken: null,
stableUserId: 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, uid } = options;
if (is_spectator) this.isSpectator = true;
if (match_id) this.matchId = match_id;
if (uid) this.stableUserId = uid;
if (game_token) {
this.initNakama(game_token, decodeURIComponent(nakama_server || ''), decodeURIComponent(nakama_key || ''), uid);
} 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, stableUid = null) {
try {
const serverUrl = server || 'wss://game.1024tool.vip';
const serverKey = key || 'defaultkey';
nakamaManager.initClient(serverUrl, serverKey);
this.gameToken = token;
const session = await nakamaManager.authenticateWithGameToken(token, stableUid);
this.myUserId = session.user_id;
this.isConnected = true;
this.addLog('system', '✅ 已连接到远程节点');
this.setupSocketListeners();
// 跨设备对局发现逻辑:调用 RPC 询问服务器我当前是否有正在进行的战局
if (!this.matchId) {
try {
const activeMatch = await nakamaManager.rpc('find_my_match', {});
if (activeMatch && activeMatch.match_id) {
console.log('[RPC发现] 发现服务器端活跃对局:', activeMatch.match_id);
this.matchId = activeMatch.match_id;
}
} catch (rpcErr) {
console.warn('[RPC发现] 检索活跃对局失败:', rpcErr);
}
}
// 跨页面对局自愈逻辑:如果 RPC 没找到,尝试从本地缓存读取(作为兜底)
if (!this.matchId) {
const lastMatchId = uni.getStorageSync('minesweeper_last_match_id');
if (lastMatchId) {
console.log('[自愈] 发现未完成对局缓存:', lastMatchId);
this.matchId = lastMatchId;
}
}
// 如果有比赛 ID来自 URL 或 缓存),尝试加入
if (this.matchId) {
this.addLog('system', this.isSpectator ? '🔭 正在切入观察视角...' : '🚪 正在恢复战局...');
try {
await nakamaManager.joinMatch(this.matchId);
// 加入成功,确保缓存是最新的
uni.setStorageSync('minesweeper_last_match_id', this.matchId);
this.addLog('system', '✅ 接入成功');
setTimeout(() => {
nakamaManager.sendMatchState(this.matchId, 100, JSON.stringify({ action: 'getState' }));
}, 100);
} catch(err) {
console.error('[自愈] 重连失败:', err);
this.addLog('system', '❌ 自动恢复失败 (对局可能已结束)');
this.matchId = null;
uni.removeStorageSync('minesweeper_last_match_id');
}
}
} 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;
uni.setStorageSync('minesweeper_last_match_id', 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 {
if (this._isReconnecting) return;
this._isReconnecting = true;
console.log('[重连] 开始重新连接...');
const session = await nakamaManager.authenticateWithGameToken(this.gameToken, this.stableUserId);
this.myUserId = session.user_id;
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}`);
} finally {
this._isReconnecting = false;
}
}
});
},
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;
const limit = data.turnDuration || 15;
this.resetTurnTimer(limit, limit);
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);
}
});
}
// 收到状态更新时重置计时器(使用服务器时间校准)
if (data.gameStarted) {
const serverTime = data.serverTime || Math.floor(Date.now() / 1000);
const elapsed = serverTime - (data.lastMoveTimestamp || serverTime);
const limit = data.turnDuration || 15;
const remaining = Math.max(0, limit - elapsed);
this.resetTurnTimer(remaining, limit);
}
// 关键修复:必须更新本地游戏状态
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;
uni.removeStorageSync('minesweeper_last_match_id');
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);
// 添加震动反馈
// 微信小程序只支持 type: 'success' 或不传参数
if (winnerId === this.myUserId) {
uni.vibrateShort({ type: 'success' });
} else if (winnerId === 'draw') {
uni.vibrateShort(); // 平局使用普通震动
} else {
uni.vibrateShort(); // 失败使用普通震动
}
}
},
handleEvent(event) {
if (event.type === 'damage' || event.type === 'heal' || event.type === 'item' || event.type === 'ability') {
const isMe = event.playerID === this.myUserId || event.targetUserId === this.myUserId;
const iAmTarget = event.targetUserId === this.myUserId;
const attackerName = event.playerName || '系统';
const itemDisplayName = this.guideItems.find(i => i.i === event.itemId)?.n || event.itemId;
// 优先使用服务端传来的 message 字段
let msg = event.message;
if (!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 事件,也触发特效
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?.gameStarted) {
uni.showToast({ title: '游戏尚未开始', icon: 'none' });
return;
}
if (this.showResultModal || this.showSettlement) return;
if (!this.isMyTurn) {
uni.showToast({ title: '等待对方行动...', icon: 'none' });
return;
}
if (this.gameState.grid[idx].revealed) return;
// 发送移动指令
nakamaManager.sendMatchState(this.matchId, 3, JSON.stringify({ index: idx }));
},
refreshAndPlayAgain() {
uni.removeStorageSync('minesweeper_last_match_id');
uni.navigateBack();
},
closeResultModal() {
this.showResultModal = false;
},
resetTurnTimer(initialTime, limit) {
const turnLimit = limit || (this.gameState?.turnDuration) || 15;
this.turnTimer = Math.floor(initialTime !== undefined ? initialTime : turnLimit);
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>