586 lines
19 KiB
JavaScript
586 lines
19 KiB
JavaScript
let ws = null, playerId = '', roomId = '', state = null, selected = [];
|
|
let token = '', userId = '', username = '', nickname = '';
|
|
let loginCaptchaId = '', regCaptchaId = '';
|
|
const $ = id => document.getElementById(id);
|
|
|
|
function show(id) {
|
|
document.querySelectorAll('.screen').forEach(s => s.classList.add('hidden'));
|
|
$(id).classList.remove('hidden');
|
|
}
|
|
|
|
function chat(name, msg, sys) {
|
|
const p = document.createElement('p');
|
|
p.innerHTML = sys ? '<span class="sys">' + msg + '</span>' : '<span class="pn">' + name + ':</span> ' + msg;
|
|
$('chatMsgs').appendChild(p);
|
|
$('chatMsgs').scrollTop = 1e6;
|
|
}
|
|
|
|
function authHeaders() {
|
|
return {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer ' + token
|
|
};
|
|
}
|
|
|
|
async function getCaptcha(type) {
|
|
try {
|
|
const res = await fetch('/api/auth/captcha');
|
|
const d = await res.json();
|
|
if (d.code === 0) {
|
|
if (type === 'login') {
|
|
loginCaptchaId = d.data.captchaId;
|
|
$('loginCaptchaImg').src = d.data.image;
|
|
} else {
|
|
regCaptchaId = d.data.captchaId;
|
|
$('regCaptchaImg').src = d.data.image;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to get captcha:', e);
|
|
}
|
|
}
|
|
|
|
async function login() {
|
|
const usernameVal = $('loginUsername').value.trim();
|
|
const passwordVal = $('loginPassword').value;
|
|
const captchaVal = $('loginCaptcha').value.trim().toUpperCase();
|
|
|
|
if (!usernameVal || !passwordVal || !captchaVal) {
|
|
alert('请填写完整信息');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/api/auth/login', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
username: usernameVal,
|
|
password: passwordVal,
|
|
captcha: captchaVal,
|
|
captchaId: loginCaptchaId
|
|
})
|
|
});
|
|
const d = await res.json();
|
|
if (d.code === 0) {
|
|
token = d.data.token;
|
|
userId = d.data.userId;
|
|
username = d.data.username;
|
|
nickname = d.data.nickname;
|
|
localStorage.setItem('token', token);
|
|
localStorage.setItem('userId', userId);
|
|
localStorage.setItem('username', username);
|
|
localStorage.setItem('nickname', nickname);
|
|
showLobby();
|
|
} else {
|
|
alert(d.message);
|
|
getCaptcha('login');
|
|
}
|
|
} catch (e) {
|
|
alert('登录失败: ' + e.message);
|
|
getCaptcha('login');
|
|
}
|
|
}
|
|
|
|
async function register() {
|
|
const usernameVal = $('regUsername').value.trim();
|
|
const passwordVal = $('regPassword').value;
|
|
const nicknameVal = $('regNickname').value.trim();
|
|
const captchaVal = $('regCaptcha').value.trim().toUpperCase();
|
|
|
|
if (!usernameVal || !passwordVal || !nicknameVal || !captchaVal) {
|
|
alert('请填写完整信息');
|
|
return;
|
|
}
|
|
|
|
if (usernameVal.length < 3 || usernameVal.length > 20) {
|
|
alert('用户名需要3-20个字符');
|
|
return;
|
|
}
|
|
|
|
if (passwordVal.length < 6) {
|
|
alert('密码至少6位');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/api/auth/register', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
username: usernameVal,
|
|
password: passwordVal,
|
|
nickname: nicknameVal,
|
|
captcha: captchaVal,
|
|
captchaId: regCaptchaId
|
|
})
|
|
});
|
|
const d = await res.json();
|
|
if (d.code === 0) {
|
|
token = d.data.token;
|
|
userId = d.data.userId;
|
|
username = d.data.username;
|
|
nickname = d.data.nickname;
|
|
localStorage.setItem('token', token);
|
|
localStorage.setItem('userId', userId);
|
|
localStorage.setItem('username', username);
|
|
localStorage.setItem('nickname', nickname);
|
|
showLobby();
|
|
} else {
|
|
alert(d.message);
|
|
getCaptcha('register');
|
|
}
|
|
} catch (e) {
|
|
alert('注册失败: ' + e.message);
|
|
getCaptcha('register');
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
try {
|
|
await fetch('/api/auth/logout', {
|
|
method: 'POST',
|
|
headers: authHeaders()
|
|
});
|
|
} catch (e) {}
|
|
token = '';
|
|
userId = '';
|
|
username = '';
|
|
nickname = '';
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('userId');
|
|
localStorage.removeItem('username');
|
|
localStorage.removeItem('nickname');
|
|
show('auth');
|
|
getCaptcha('login');
|
|
}
|
|
|
|
async function validateToken() {
|
|
if (!token) return false;
|
|
try {
|
|
const res = await fetch('/api/auth/validate', {
|
|
headers: authHeaders()
|
|
});
|
|
const d = await res.json();
|
|
if (d.code === 0) {
|
|
userId = d.data.userId;
|
|
username = d.data.username;
|
|
nickname = d.data.nickname;
|
|
return true;
|
|
}
|
|
} catch (e) {}
|
|
return false;
|
|
}
|
|
|
|
async function checkCurrentRoom() {
|
|
try {
|
|
const res = await fetch('/api/rooms/current', {
|
|
headers: authHeaders()
|
|
});
|
|
const d = await res.json();
|
|
if (d.code === 0 && d.data && d.data.roomId) {
|
|
roomId = d.data.roomId;
|
|
playerId = d.data.playerId;
|
|
return true;
|
|
}
|
|
} catch (e) {}
|
|
return false;
|
|
}
|
|
|
|
async function showLobby() {
|
|
$('welcomeUser').textContent = '欢迎, ' + nickname;
|
|
$('playerName').value = nickname;
|
|
show('lobby');
|
|
}
|
|
|
|
async function create() {
|
|
const name = $('playerName').value.trim();
|
|
if (!name) { alert('请输入昵称'); return; }
|
|
try {
|
|
const res = await fetch('/api/rooms', {
|
|
method: 'POST',
|
|
headers: authHeaders(),
|
|
body: JSON.stringify({playerName: name, maxPlayers: +$('maxPlayers').value})
|
|
});
|
|
const d = await res.json();
|
|
if (d.code === 0) {
|
|
roomId = d.data.roomId;
|
|
playerId = d.data.playerId;
|
|
connect();
|
|
} else {
|
|
alert(d.message);
|
|
}
|
|
} catch (e) {
|
|
alert('创建失败: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function join() {
|
|
const name = $('playerName').value.trim();
|
|
const rid = $('roomIdInput').value.trim().toLowerCase();
|
|
if (!name) { alert('请输入昵称'); return; }
|
|
if (!rid) { alert('请输入房间号'); return; }
|
|
try {
|
|
const res = await fetch('/api/rooms/' + rid + '/join', {
|
|
method: 'POST',
|
|
headers: authHeaders(),
|
|
body: JSON.stringify({playerName: name})
|
|
});
|
|
const d = await res.json();
|
|
if (d.code === 0) {
|
|
roomId = d.data.roomId;
|
|
playerId = d.data.playerId;
|
|
connect();
|
|
} else {
|
|
alert(d.message);
|
|
}
|
|
} catch (e) {
|
|
alert('加入失败: ' + e.message);
|
|
}
|
|
}
|
|
|
|
async function leaveRoom() {
|
|
try {
|
|
await fetch('/api/rooms/leave', {
|
|
method: 'POST',
|
|
headers: authHeaders()
|
|
});
|
|
} catch (e) {}
|
|
if (ws) ws.close();
|
|
show('lobby');
|
|
roomId = '';
|
|
playerId = '';
|
|
state = null;
|
|
selected = [];
|
|
}
|
|
|
|
function connect() {
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
ws = new WebSocket(proto + '//' + location.host + '/api/ws?roomId=' + roomId + '&playerId=' + playerId);
|
|
ws.onopen = function() { chat('', '已连接', true); };
|
|
ws.onmessage = function(e) {
|
|
const msg = JSON.parse(e.data);
|
|
if (msg.type === 'state') render(msg.data);
|
|
else if (msg.type === 'gameOver') showGameOver(msg.data);
|
|
else if (msg.type === 'chat') chat(msg.data.playerName, msg.data.message);
|
|
else if (msg.type === 'leave') chat('', '有玩家离开房间', true);
|
|
else if (msg.type === 'error') chat('', msg.data, true);
|
|
};
|
|
ws.onerror = function() { chat('', '连接错误', true); };
|
|
ws.onclose = function() { chat('', '断开连接', true); };
|
|
}
|
|
|
|
function send(type, data) {
|
|
if (ws && ws.readyState === 1) {
|
|
ws.send(JSON.stringify({type: type, playerId: playerId, roomId: roomId, data: data}));
|
|
}
|
|
}
|
|
|
|
function render(s) {
|
|
state = s;
|
|
$('displayRoomId').textContent = s.id;
|
|
$('roundNum').textContent = s.roundCount;
|
|
var me = null;
|
|
for (var i = 0; i < s.players.length; i++) {
|
|
if (s.players[i].id === playerId) { me = s.players[i]; break; }
|
|
}
|
|
|
|
if (s.state === 0) {
|
|
show('waiting');
|
|
var html = '';
|
|
for (var i = 0; i < s.players.length; i++) {
|
|
var p = s.players[i];
|
|
html += '<div class="player-card' + (p.isReady ? ' ready' : '') + '">';
|
|
html += '<div class="name">' + p.name + (p.id === playerId ? ' (你)' : '') + '</div>';
|
|
html += '<div class="status">' + (p.isReady ? '已准备' : '等待中') + '</div></div>';
|
|
}
|
|
$('playerList').innerHTML = html;
|
|
$('readyBtn').textContent = (me && me.isReady) ? '取消准备' : '准备';
|
|
} else if (s.state === 1) {
|
|
show('game');
|
|
var cur = s.players[s.currentTurn];
|
|
$('turnInfo').textContent = cur.id === playerId ? '轮到你出牌' : '等待 ' + cur.name + ' 出牌';
|
|
|
|
var othersHtml = '';
|
|
for (var i = 0; i < s.players.length; i++) {
|
|
var p = s.players[i];
|
|
if (p.id === playerId) continue;
|
|
othersHtml += '<div class="player-card' + (s.currentTurn === i ? ' current' : '') + '">';
|
|
othersHtml += '<div class="name">' + p.name + '</div>';
|
|
othersHtml += '<div class="status">' + p.cardCount + '张</div></div>';
|
|
}
|
|
$('others').innerHTML = othersHtml;
|
|
|
|
if (s.lastPlay) {
|
|
$('lastPlay').classList.remove('hidden');
|
|
var lpName = '';
|
|
for (var i = 0; i < s.players.length; i++) {
|
|
if (s.players[i].id === s.lastPlay.playerId) { lpName = s.players[i].name; break; }
|
|
}
|
|
$('lastPlayer').textContent = lpName;
|
|
$('lastCards').innerHTML = s.lastPlay.cards.map(function(c) { return makeCardHtml(c, true); }).join('');
|
|
} else {
|
|
$('lastPlay').classList.add('hidden');
|
|
}
|
|
renderMyCards(me);
|
|
updateBtns();
|
|
} else if (s.state === 2) {
|
|
show('waiting');
|
|
var html = '';
|
|
for (var i = 0; i < s.players.length; i++) {
|
|
var p = s.players[i];
|
|
html += '<div class="player-card">';
|
|
html += '<div class="name">' + p.name + '</div>';
|
|
html += '<div class="status">' + (p.isReady ? '已准备' : '等待中') + '</div></div>';
|
|
}
|
|
$('playerList').innerHTML = html;
|
|
$('readyBtn').textContent = (me && me.isReady) ? '取消准备' : '准备';
|
|
}
|
|
}
|
|
|
|
function getCardClass(c) {
|
|
if (c.suit === 5) return 'super';
|
|
if (c.suit === 4) return 'joker';
|
|
return c.suit < 2 ? 'red' : 'black';
|
|
}
|
|
|
|
function getCardText(c) {
|
|
if (c.suit === 5) return '超人强';
|
|
if (c.suit === 4) return c.value === 16 ? '小王' : '大王';
|
|
var suits = ['♥', '♦', '♣', '♠'];
|
|
var vals = ['', '', '', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A', '2'];
|
|
return suits[c.suit] + vals[c.value];
|
|
}
|
|
|
|
function makeCardHtml(c, small) {
|
|
return '<div class="card ' + getCardClass(c) + (small ? ' sm' : '') + '">' + getCardText(c) + '</div>';
|
|
}
|
|
|
|
function renderMyCards(me) {
|
|
var el = $('myCards');
|
|
el.innerHTML = '';
|
|
if (!me || !me.cards) return;
|
|
for (var i = 0; i < me.cards.length; i++) {
|
|
(function(c) {
|
|
var key = c.suit + '_' + c.value;
|
|
var div = document.createElement('div');
|
|
div.className = 'card ' + getCardClass(c) + (selected.indexOf(key) >= 0 ? ' selected' : '');
|
|
div.textContent = getCardText(c);
|
|
div.onclick = function() {
|
|
var idx = selected.indexOf(key);
|
|
if (idx === -1) { selected.push(key); div.classList.add('selected'); }
|
|
else { selected.splice(idx, 1); div.classList.remove('selected'); }
|
|
updateBtns();
|
|
};
|
|
el.appendChild(div);
|
|
})(me.cards[i]);
|
|
}
|
|
}
|
|
|
|
function getValueCounts(cards) {
|
|
var counts = {};
|
|
for (var i = 0; i < cards.length; i++) {
|
|
var v = cards[i].value;
|
|
counts[v] = (counts[v] || 0) + 1;
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
function getCardType(cards) {
|
|
var n = cards.length;
|
|
if (n === 0) return 0;
|
|
|
|
var isRocket = n === 2 && cards.some(function(c) { return c.suit === 4 && c.value === 16; })
|
|
&& cards.some(function(c) { return c.suit === 4 && c.value === 17; });
|
|
if (isRocket) return 9;
|
|
|
|
var counts = getValueCounts(cards);
|
|
var keys = Object.keys(counts);
|
|
|
|
if (n === 1) return 1;
|
|
if (n === 2 && keys.length === 1) return 2;
|
|
if (n === 3 && keys.length === 1) return 8;
|
|
if (n === 4 && keys.length === 1) return 8;
|
|
if (n === 4 && keys.length === 2) {
|
|
for (var k in counts) { if (counts[k] === 3) return 4; }
|
|
}
|
|
|
|
var sorted = cards.slice().sort(function(a, b) { return a.value - b.value; });
|
|
var isSeq = true;
|
|
for (var i = 1; i < sorted.length; i++) {
|
|
if (sorted[i].value !== sorted[i-1].value + 1 || sorted[i].value >= 15) { isSeq = false; break; }
|
|
}
|
|
if (n >= 3 && keys.length === n && isSeq) return 6;
|
|
|
|
var allPairs = true;
|
|
for (var k in counts) { if (counts[k] !== 2 || parseInt(k) >= 15) allPairs = false; }
|
|
var pairVals = keys.map(function(k) { return parseInt(k); }).sort(function(a,b){return a-b;});
|
|
var seqPairs = true;
|
|
for (var i = 1; i < pairVals.length; i++) {
|
|
if (pairVals[i] !== pairVals[i-1] + 1) seqPairs = false;
|
|
}
|
|
if (n >= 4 && n % 2 === 0 && allPairs && seqPairs) return 7;
|
|
|
|
return 0;
|
|
}
|
|
|
|
function canPlay(cards, lastPlay) {
|
|
var cardType = getCardType(cards);
|
|
if (cardType === 0) return false;
|
|
if (!lastPlay) return true;
|
|
|
|
if (cardType === 9) return true;
|
|
if (cardType === 8) {
|
|
if (lastPlay.cardType === 8) {
|
|
var myMain = 0, lastMain = 0;
|
|
var myCounts = getValueCounts(cards);
|
|
var lastCounts = getValueCounts(lastPlay.cards);
|
|
for (var k in myCounts) { if (myCounts[k] >= 3 && parseInt(k) > myMain) myMain = parseInt(k); }
|
|
for (var k in lastCounts) { if (lastCounts[k] >= 3 && parseInt(k) > lastMain) lastMain = parseInt(k); }
|
|
return myMain > lastMain;
|
|
}
|
|
return lastPlay.cardType !== 9;
|
|
}
|
|
if (lastPlay.cardType === 8 || lastPlay.cardType === 9) return false;
|
|
if (cardType !== lastPlay.cardType || cards.length !== lastPlay.cards.length) return false;
|
|
|
|
var myMain = 0, lastMain = 0;
|
|
var myCounts = getValueCounts(cards);
|
|
var lastCounts = getValueCounts(lastPlay.cards);
|
|
var myMaxC = 0, lastMaxC = 0;
|
|
for (var k in myCounts) {
|
|
if (myCounts[k] > myMaxC || (myCounts[k] === myMaxC && parseInt(k) > myMain)) {
|
|
myMaxC = myCounts[k]; myMain = parseInt(k);
|
|
}
|
|
}
|
|
for (var k in lastCounts) {
|
|
if (lastCounts[k] > lastMaxC || (lastCounts[k] === lastMaxC && parseInt(k) > lastMain)) {
|
|
lastMaxC = lastCounts[k]; lastMain = parseInt(k);
|
|
}
|
|
}
|
|
return myMain > lastMain;
|
|
}
|
|
|
|
function updateBtns() {
|
|
var myTurn = state && state.players[state.currentTurn].id === playerId;
|
|
var cards = getSelectedCards();
|
|
var validPlay = selected.length > 0 && canPlay(cards, state ? state.lastPlay : null);
|
|
$('playBtn').disabled = !myTurn || !validPlay;
|
|
var canPass = state && state.lastPlay && state.lastPlay.playerId !== playerId;
|
|
$('passBtn').disabled = !myTurn || !canPass;
|
|
}
|
|
|
|
function getSelectedCards() {
|
|
if (!state) return [];
|
|
var me = null;
|
|
for (var i = 0; i < state.players.length; i++) {
|
|
if (state.players[i].id === playerId) { me = state.players[i]; break; }
|
|
}
|
|
if (!me || !me.cards) return [];
|
|
return me.cards.filter(function(c) { return selected.indexOf(c.suit + '_' + c.value) >= 0; });
|
|
}
|
|
|
|
function play() {
|
|
if (selected.length === 0) return;
|
|
var cards = getSelectedCards();
|
|
if (!canPlay(cards, state ? state.lastPlay : null)) {
|
|
chat('', '牌型无效或管不上', true);
|
|
return;
|
|
}
|
|
send('play', {cards: cards});
|
|
selected = [];
|
|
}
|
|
|
|
function pass() { send('pass', {}); }
|
|
|
|
function toggleReady() {
|
|
var me = null;
|
|
if (state) {
|
|
for (var i = 0; i < state.players.length; i++) {
|
|
if (state.players[i].id === playerId) { me = state.players[i]; break; }
|
|
}
|
|
}
|
|
send('ready', {ready: me ? !me.isReady : true});
|
|
}
|
|
|
|
function leave() {
|
|
leaveRoom();
|
|
}
|
|
|
|
function showGameOver(d) {
|
|
var w = null;
|
|
if (state) {
|
|
for (var i = 0; i < state.players.length; i++) {
|
|
if (state.players[i].id === d.winnerId) { w = state.players[i]; break; }
|
|
}
|
|
}
|
|
$('winnerText').textContent = w ? (w.id === playerId ? '你赢了!' : w.name + ' 获胜') : '游戏结束';
|
|
$('gameOver').classList.remove('hidden');
|
|
}
|
|
|
|
function again() {
|
|
$('gameOver').classList.add('hidden');
|
|
send('ready', {ready: false});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', async function() {
|
|
$('loginTab').onclick = function() {
|
|
$('loginTab').classList.add('active');
|
|
$('registerTab').classList.remove('active');
|
|
$('loginForm').classList.remove('hidden');
|
|
$('registerForm').classList.add('hidden');
|
|
getCaptcha('login');
|
|
};
|
|
$('registerTab').onclick = function() {
|
|
$('registerTab').classList.add('active');
|
|
$('loginTab').classList.remove('active');
|
|
$('registerForm').classList.remove('hidden');
|
|
$('loginForm').classList.add('hidden');
|
|
getCaptcha('register');
|
|
};
|
|
$('loginCaptchaImg').onclick = function() { getCaptcha('login'); };
|
|
$('regCaptchaImg').onclick = function() { getCaptcha('register'); };
|
|
$('loginBtn').onclick = login;
|
|
$('registerBtn').onclick = register;
|
|
$('logoutBtn').onclick = logout;
|
|
|
|
$('createBtn').onclick = create;
|
|
$('joinBtn').onclick = join;
|
|
$('readyBtn').onclick = toggleReady;
|
|
$('leaveBtn').onclick = leaveRoom;
|
|
$('playBtn').onclick = play;
|
|
$('passBtn').onclick = pass;
|
|
$('againBtn').onclick = again;
|
|
$('chatBtn').onclick = function() {
|
|
var v = $('chatInput').value.trim();
|
|
if (v) { send('chat', v); $('chatInput').value = ''; }
|
|
};
|
|
$('chatInput').onkeypress = function(e) { if (e.key === 'Enter') $('chatBtn').click(); };
|
|
$('roomIdInput').onkeypress = function(e) { if (e.key === 'Enter') join(); };
|
|
|
|
token = localStorage.getItem('token') || '';
|
|
userId = localStorage.getItem('userId') || '';
|
|
username = localStorage.getItem('username') || '';
|
|
nickname = localStorage.getItem('nickname') || '';
|
|
|
|
if (token) {
|
|
var valid = await validateToken();
|
|
if (valid) {
|
|
var inRoom = await checkCurrentRoom();
|
|
if (inRoom) {
|
|
connect();
|
|
return;
|
|
}
|
|
showLobby();
|
|
return;
|
|
}
|
|
localStorage.removeItem('token');
|
|
localStorage.removeItem('userId');
|
|
localStorage.removeItem('username');
|
|
localStorage.removeItem('nickname');
|
|
}
|
|
|
|
show('auth');
|
|
getCaptcha('login');
|
|
});
|