1253 lines
37 KiB
JavaScript
1253 lines
37 KiB
JavaScript
/**
|
||
* game.js
|
||
*
|
||
* 狼人杀主要游戏逻辑文件
|
||
*
|
||
* 依赖:
|
||
* - waiting_room.js 中的 room 状态 (status, players, maxPlayers)
|
||
* - websocket.js 提供的 broadcastMessage, sendMessageToUser 等方法
|
||
* - db.js 进行数据库写入 (mysql2 pool)
|
||
*
|
||
* 导出:
|
||
* - startGame(room): 在 waiting_room.js 里被调用,开始游戏流程
|
||
* - router: 在 server.js 中挂载,用于提供各种接口 (/sheriff_join, /chat, /vote, 等)
|
||
*/
|
||
|
||
const express = require('express');
|
||
const router = express.Router();
|
||
const { broadcastMessage, sendMessageToUser } = require('./websocket');
|
||
const pool = require('./db');
|
||
const { getRoom } = require('./room');
|
||
const room = getRoom();
|
||
|
||
// 身份映射表
|
||
const roleTranslation = {
|
||
seer: '预言家',
|
||
wolf: '狼人',
|
||
villager: '平民',
|
||
witch: '女巫',
|
||
hunter: '猎人',
|
||
};
|
||
|
||
// ======== 全局或模块级变量 ========
|
||
|
||
// 每次开始游戏时重新初始化
|
||
let gameData = {
|
||
isGameStarted: false, // 标记游戏是否已开始
|
||
createTime: null, // 记录游戏开始时间 (房间锁定时)
|
||
finishTime: null, // 记录游戏结束时间
|
||
|
||
// 聊天记录
|
||
chats: [], // 这里存储所有聊天记录 [{ userId, message, timestamp }, ...]
|
||
|
||
// 玩家角色分配信息: userId => { role, isAlive, isSheriff, ... }
|
||
// 例如: rolesMap[123] = { role: 'wolf', isAlive: true, isSheriff: false, ... }
|
||
rolesMap: {},
|
||
|
||
// 警长相关
|
||
sheriffCandidates: [], // 报名竞选警长的玩家列表 (userId 数组)
|
||
sheriffVotes: {}, // 记录投票情况 sheriffVotes[voterId] = candidateId
|
||
isSheriffElected: false, // 是否已选出警长
|
||
|
||
// 白天、黑夜循环控制
|
||
dayCount: 1,
|
||
isNight: false, // 当前是否是夜晚
|
||
|
||
// 其他游戏过程所需的临时记录
|
||
wolfVotes: {}, // 狼人夜晚刀人投票
|
||
seerChoice: null, // 预言家查验目标
|
||
witchSaveAvailable: true, // 女巫是否还有解药
|
||
witchKillAvailable: true, // 女巫是否还有毒药
|
||
witchSaveTarget: null, // 女巫要救的人
|
||
witchKillTarget: null, // 女巫要毒的人
|
||
hunterKillTarget: null, // 猎人带走的目标(如果被狼人杀死时可带走)
|
||
dayVotes: {}, // 白天放逐投票 dayVotes[voterId] = targetId
|
||
|
||
// 再次警长平票重投的一些标记
|
||
reSheriffVoteNeeded: false, // 是否需要重新警长投票
|
||
reSheriffCandidates: [],
|
||
};
|
||
|
||
// ======== 工具函数 & 倒计时广播 ========
|
||
|
||
function getNameById(userId) {
|
||
const player = room.players.find((p) => p.userId === userId);
|
||
return player ? player.username : `玩家${userId}`; // 如果未找到,返回默认值
|
||
}
|
||
|
||
|
||
/**
|
||
* 广播倒计时消息
|
||
* @param {number} seconds 倒计时时长
|
||
* @param {string} messagePrefix 提示语前缀,如 "警长竞选倒计时"
|
||
* @param {Function} callback 倒计时结束后的回调
|
||
*/
|
||
function broadcastCountdown(seconds, messagePrefix, callback) {
|
||
let remaining = seconds;
|
||
const interval = setInterval(() => {
|
||
if (remaining <= 0) {
|
||
clearInterval(interval);
|
||
callback && callback();
|
||
} else {
|
||
broadcastMessage('系统', {
|
||
type: 'countdown',
|
||
message: `${messagePrefix}:${remaining}秒`,
|
||
remaining,
|
||
});
|
||
remaining--;
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
/**
|
||
* 向游戏的聊天记录中添加一条消息,并广播给所有玩家
|
||
* @param {string|number} userId
|
||
* @param {string} content 聊天内容
|
||
*/
|
||
function addChatMessage(userId, content) {
|
||
const timestamp = new Date().toISOString();
|
||
gameData.chats.push({ userId, message: content, timestamp });
|
||
// 广播最新的 chats
|
||
broadcastMessage('系统', {
|
||
type: 'chat_update',
|
||
chats: gameData.chats,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 返回房间中存活玩家
|
||
* @param {object} room
|
||
* @returns {Array<{userId, ...}>}
|
||
*/
|
||
function getAlivePlayers(room) {
|
||
return room.players.filter((p) => {
|
||
const roleData = gameData.rolesMap[p.userId];
|
||
return roleData && roleData.isAlive;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 判断游戏是否结束,若结束返回胜利阵营信息
|
||
*
|
||
* 新的胜利条件:
|
||
* - 狼人胜利:
|
||
* 1. 所有平民(villager)全死。
|
||
* 2. 所有神职(seer、witch、hunter)全死。
|
||
* - 好人胜利:
|
||
* 1. 所有狼人(wolf)全死。
|
||
*
|
||
* @param {object} room
|
||
* @returns {{isEnd: boolean, winner: number|null}} winner: 0 狼人胜, 1 好人胜, null 尚未决定
|
||
*/
|
||
function checkWinCondition(room) {
|
||
let wolfCount = 0;
|
||
let villagerCount = 0; // 仅统计平民(villager)
|
||
let specialRolesCount = 0; // 统计神职(seer、witch、hunter)
|
||
|
||
for (const p of room.players) {
|
||
const r = gameData.rolesMap[p.userId];
|
||
if (!r || !r.isAlive) continue;
|
||
|
||
if (r.role === 'wolf') {
|
||
wolfCount++;
|
||
} else if (r.role === 'villager') {
|
||
villagerCount++;
|
||
} else if (['seer', 'witch', 'hunter'].includes(r.role)) {
|
||
specialRolesCount++;
|
||
}
|
||
}
|
||
|
||
// 检查好人是否胜利
|
||
if (wolfCount === 0) {
|
||
return { isEnd: true, winner: 1 }; // 好人胜利
|
||
}
|
||
|
||
// 检查狼人是否胜利
|
||
if (villagerCount === 0 || specialRolesCount === 0) {
|
||
return { isEnd: true, winner: 0 }; // 狼人胜利
|
||
}
|
||
|
||
return { isEnd: false, winner: null };
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 游戏结束时的数据库写入操作
|
||
* @param {object} room
|
||
* @param {number} winner 0狼胜 / 1好人胜
|
||
*/
|
||
async function endGameAndSaveToDB(room, winner) {
|
||
gameData.finishTime = new Date();
|
||
broadcastMessage('系统', {
|
||
type: 'game_end',
|
||
winner,
|
||
rolesMap: gameData.rolesMap,
|
||
message: `游戏结束!${winner === 0 ? '狼人阵营获胜' : '好人阵营获胜'}`,
|
||
});
|
||
|
||
// 写入数据库 plays 表
|
||
let playId;
|
||
try {
|
||
const [result] = await pool.execute(
|
||
`INSERT INTO plays (create_time, finish_time, winner, chats) VALUES (?, ?, ?, ?)`,
|
||
[
|
||
gameData.createTime,
|
||
gameData.finishTime,
|
||
winner,
|
||
JSON.stringify(gameData.chats),
|
||
]
|
||
);
|
||
playId = result.insertId;
|
||
} catch (err) {
|
||
console.error('写入 plays 表出错:', err);
|
||
// 即使出错,也继续
|
||
}
|
||
|
||
// 写入 play_user 表
|
||
if (playId) {
|
||
const promises = room.players.map((p) => {
|
||
const r = gameData.rolesMap[p.userId];
|
||
if (!r) return Promise.resolve();
|
||
|
||
// 根据阵营确定获胜关系
|
||
const isWolf = r.role === 'wolf';
|
||
const isWinner =
|
||
(winner === 0 && isWolf) || // 狼人阵营获胜,狼人都算赢
|
||
(winner === 1 && !isWolf); // 好人阵营获胜,非狼人都算赢
|
||
|
||
return pool.execute(
|
||
`INSERT INTO play_user (play_id, user_id, identity, is_sheriff, is_alive, is_winner)
|
||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||
[
|
||
playId,
|
||
p.userId,
|
||
r.role,
|
||
r.isSheriff ? 1 : 0,
|
||
r.isAlive ? 1 : 0,
|
||
isWinner ? 1 : 0,
|
||
]
|
||
);
|
||
});
|
||
await Promise.all(promises).catch((e) =>
|
||
console.error('写入 play_user 表出错:', e)
|
||
);
|
||
}
|
||
|
||
// 结束后倒计时 10 秒,将所有玩家自动退出房间
|
||
broadcastCountdown(10, '游戏结束,10秒后将自动退出房间', () => {
|
||
// 这里可以直接调用 waiting_room.js 的 removePlayerFromRoom
|
||
// 或者可以直接清空 room.players
|
||
broadcastMessage('系统', {
|
||
type: 'game_end_exit',
|
||
message: '所有玩家将退出房间',
|
||
});
|
||
room.players.length = 0; // 直接清空
|
||
room.status = 'closed'; // 重置房间状态
|
||
gameData.isGameStarted = false;
|
||
});
|
||
}
|
||
|
||
|
||
// ======== 核心逻辑函数 ========
|
||
|
||
/**
|
||
* 分配角色
|
||
* @param {object} room
|
||
*/
|
||
function assignRoles(room) {
|
||
// 8人:3狼,1预言家,1女巫,1猎人,2平民
|
||
const roles = ['wolf', 'wolf', 'wolf', 'seer', 'witch', 'hunter', 'villager', 'villager'];
|
||
|
||
// 随机洗牌
|
||
for (let i = roles.length - 1; i > 0; i--) {
|
||
const rand = Math.floor(Math.random() * (i + 1));
|
||
[roles[i], roles[rand]] = [roles[rand], roles[i]];
|
||
}
|
||
|
||
// 为 room.players 逐个分配
|
||
room.players.forEach((p, index) => {
|
||
const role = roles[index];
|
||
gameData.rolesMap[p.userId] = {
|
||
role,
|
||
isAlive: true,
|
||
isSheriff: false,
|
||
};
|
||
});
|
||
|
||
// 通知各玩家身份
|
||
// 先把狼人的 id 全部取出来
|
||
const wolfIds = Object.entries(gameData.rolesMap)
|
||
.filter(([, val]) => val.role === 'wolf')
|
||
.map(([key]) => key);
|
||
|
||
for (let [userId, data] of Object.entries(gameData.rolesMap)) {
|
||
userId = Number(userId); // 转为数字
|
||
if (data.role === 'wolf') {
|
||
// 发送给狼人的消息:队友有哪些
|
||
sendMessageToUser(userId, {
|
||
type: 'role_info',
|
||
message: '你的身份是:狼人',
|
||
wolfFriends: wolfIds.map(Number), // 转成数字数组
|
||
});
|
||
} else {
|
||
|
||
|
||
|
||
// 发给好人
|
||
sendMessageToUser(userId, {
|
||
type: 'role_info',
|
||
message: `你的身份是:${roleTranslation[data.role] || '未知身份'}`,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 开始游戏(房间锁定后调用)
|
||
* @param {object} room waiting_room.js 中的 room 对象
|
||
*/
|
||
function startGame(room) {
|
||
if (room.status !== 'locked') {
|
||
console.warn('房间未锁定,无法开始游戏');
|
||
return;
|
||
}
|
||
|
||
// 初始化 gameData
|
||
gameData.isGameStarted = true;
|
||
gameData.createTime = new Date();
|
||
gameData.chats = [];
|
||
gameData.rolesMap = {};
|
||
gameData.sheriffCandidates = [];
|
||
gameData.sheriffVotes = {};
|
||
gameData.isSheriffElected = false;
|
||
gameData.dayCount = 1;
|
||
gameData.isNight = false;
|
||
gameData.wolfVotes = {};
|
||
gameData.seerChoice = null;
|
||
gameData.witchSaveAvailable = true;
|
||
gameData.witchKillAvailable = true;
|
||
gameData.witchSaveTarget = null;
|
||
gameData.witchKillTarget = null;
|
||
gameData.hunterKillTarget = null;
|
||
gameData.dayVotes = {};
|
||
gameData.reSheriffVoteNeeded = false;
|
||
gameData.reSheriffCandidates = [];
|
||
|
||
// 1. 分配角色
|
||
assignRoles(room);
|
||
|
||
// 2. 广播 5秒 准备时间后进入警长竞选
|
||
broadcastMessage('系统', {
|
||
type: 'game_start',
|
||
message: '游戏开始!5秒后进入警长竞选...',
|
||
});
|
||
|
||
broadcastCountdown(5, '警长竞选准备', () => {
|
||
// === 新增:让前端有一丝缓冲(3秒)再进下一个环节 ===
|
||
setTimeout(() => {
|
||
// 进入警长竞选
|
||
enterSheriffElection(room);
|
||
}, 3000);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 进入警长竞选 (广播 15 秒倒计时,玩家可点击 /sheriff_join 接口)
|
||
*/
|
||
function enterSheriffElection(room) {
|
||
broadcastMessage('系统', {
|
||
type: 'sheriff_election',
|
||
message: '警长竞选开始!想要竞选的玩家请在 15 秒内报名!',
|
||
});
|
||
|
||
broadcastCountdown(15, '警长竞选报名', () => {
|
||
// === 新增:让前端有一丝缓冲 ===
|
||
setTimeout(() => {
|
||
// 报名结束,判断是否有人竞选
|
||
if (gameData.sheriffCandidates.length === 0) {
|
||
broadcastMessage('系统', {
|
||
type: 'sheriff_election',
|
||
message: '无人竞选警长,直接进入黑夜。',
|
||
});
|
||
// 直接进入夜晚
|
||
setTimeout(() => {
|
||
enterNight(room);
|
||
}, 3000); // 再给一点缓冲
|
||
} else {
|
||
// 存在候选人,开始警长竞选发言
|
||
broadcastMessage('系统', {
|
||
type: 'sheriff_election',
|
||
candidates: gameData.sheriffCandidates,
|
||
message: '以下玩家参与了警长竞选:' + gameData.sheriffCandidates.map(getNameById).join(','),
|
||
});
|
||
|
||
// 竞选发言
|
||
startSheriffSpeech(room, [...gameData.sheriffCandidates]);
|
||
}
|
||
}, 3000);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 警长竞选发言,一个一个来,每人 15 秒
|
||
* @param {object} room
|
||
* @param {Array<number>} candidates 剩余需要发言的候选人
|
||
*/
|
||
function startSheriffSpeech(room, candidates) {
|
||
if (candidates.length === 0) {
|
||
// 发言结束,进入投票
|
||
broadcastMessage('系统', {
|
||
type: 'sheriff_election',
|
||
message: '所有候选人发言完毕,进入警长投票阶段',
|
||
});
|
||
|
||
// === 新增:3秒缓冲后进入投票 ===
|
||
setTimeout(() => {
|
||
startSheriffVote(room);
|
||
}, 3000);
|
||
|
||
return;
|
||
}
|
||
|
||
const current = candidates.shift();
|
||
const currentName = getNameById(current);
|
||
// 广播轮到谁发言
|
||
broadcastMessage('系统', {
|
||
type: 'sheriff_speech',
|
||
userId: current,
|
||
message: `轮到玩家 ${currentName} 发言(15秒)`,
|
||
});
|
||
|
||
broadcastCountdown(15, `玩家${currentName}发言`, () => {
|
||
// 结束后马上切下一个
|
||
startSheriffSpeech(room, candidates);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 开始警长投票,时限15秒
|
||
*/
|
||
function startSheriffVote(room) {
|
||
// 获取存活玩家
|
||
const alivePlayers = getAlivePlayers(room).map(p => p.userId);
|
||
|
||
// 只允许【存活且未参与竞选】的玩家投票
|
||
const allowedVoters = alivePlayers.filter(
|
||
id => !gameData.sheriffCandidates.includes(id)
|
||
);
|
||
|
||
// 广播:允许投票的玩家 & 候选人
|
||
broadcastMessage('系统', {
|
||
type: 'sheriff_vote',
|
||
allowedVoters,
|
||
candidates: gameData.sheriffCandidates,
|
||
message: '现在开始警长投票!有 15 秒时间!(只有未参加竞选的人可投票)'
|
||
});
|
||
|
||
// 15秒倒计时
|
||
broadcastCountdown(15, '警长投票', () => {
|
||
// === 新增:3秒缓冲再开始结算 ===
|
||
setTimeout(() => {
|
||
// 收集投票结果
|
||
const voteCount = {};
|
||
for (let voterId of Object.keys(gameData.sheriffVotes)) {
|
||
const candidateId = gameData.sheriffVotes[voterId];
|
||
if (!voteCount[candidateId]) {
|
||
voteCount[candidateId] = 0;
|
||
}
|
||
voteCount[candidateId]++;
|
||
}
|
||
|
||
// 找出最高票
|
||
let maxVotes = 0;
|
||
let winners = [];
|
||
Object.entries(voteCount).forEach(([candidateId, vCount]) => {
|
||
const numericCandidateId = Number(candidateId); // 转换为数字
|
||
if (vCount > maxVotes) {
|
||
maxVotes = vCount;
|
||
winners = [numericCandidateId]; // 存储数字类型
|
||
} else if (vCount === maxVotes) {
|
||
winners.push(numericCandidateId); // 添加到 winners
|
||
}
|
||
});
|
||
|
||
// 如果没人投票,则随机选
|
||
if (Object.keys(voteCount).length === 0) {
|
||
winners = [
|
||
gameData.sheriffCandidates[
|
||
Math.floor(Math.random() * gameData.sheriffCandidates.length)
|
||
]
|
||
];
|
||
}
|
||
|
||
if (winners.length > 1) {
|
||
// 平票,需要重新发言投票
|
||
|
||
// 调试
|
||
// console.log('平票玩家 ID:', winners);
|
||
// console.log('玩家列表:', room.players);
|
||
|
||
broadcastMessage('系统', {
|
||
type: 'sheriff_vote_result',
|
||
detail: gameData.sheriffVotes,
|
||
message: `警长投票平票,平票玩家:${winners
|
||
.map((id) => getNameById(id))
|
||
.join('、')},重新开始发言再投票`
|
||
});
|
||
|
||
// 重置投票结果
|
||
gameData.sheriffVotes = {};
|
||
// 重新进入发言
|
||
startSheriffSpeech(room, winners.map(x => Number(x)));
|
||
} else {
|
||
// 选出警长
|
||
const sheriffId = Number(winners[0]);
|
||
const sheriffName = getNameById(sheriffId);
|
||
gameData.rolesMap[sheriffId].isSheriff = true;
|
||
gameData.isSheriffElected = true;
|
||
broadcastMessage('系统', {
|
||
type: 'sheriff_elected',
|
||
sheriffId,
|
||
detail: gameData.sheriffVotes,
|
||
message: `警长诞生!玩家 ${sheriffName} 当选`
|
||
});
|
||
|
||
// 5秒后进入夜晚
|
||
broadcastCountdown(5, '警长选举结束,5秒后进入夜晚', () => {
|
||
// === 新增:再给 3 秒缓冲 ===
|
||
setTimeout(() => {
|
||
enterNight(room);
|
||
}, 3000);
|
||
});
|
||
}
|
||
}, 3000);
|
||
});
|
||
}
|
||
|
||
|
||
/**
|
||
* 进入夜晚
|
||
* @param {object} room
|
||
*/
|
||
function enterNight(room) {
|
||
// 先检查当前是否已经是夜晚
|
||
if (gameData.isNight) {
|
||
// 日志警告可以帮助调试:发现有代码重复调用了 enterNight
|
||
console.warn('[enterNight] 已经是夜晚,忽略本次调用');
|
||
return;
|
||
}
|
||
gameData.isNight = true;
|
||
broadcastMessage('系统', {
|
||
type: 'night',
|
||
message: '现在进入夜晚',
|
||
});
|
||
|
||
// 这里先给一个“夜晚开始前缓冲”
|
||
setTimeout(() => {
|
||
// 1. 狼人行动,15 秒
|
||
broadcastCountdown(15, '狼人行动时间', () => {
|
||
// 这里再加 3 秒缓冲后再结算
|
||
setTimeout(() => {
|
||
resolveWolfAction(room);
|
||
}, 3000);
|
||
});
|
||
|
||
// 给狼人发送可刀人信息
|
||
for (let [userId, data] of Object.entries(gameData.rolesMap)) {
|
||
if (data.role === 'wolf' && data.isAlive) {
|
||
const alivePlayers = getAlivePlayers(room).map((p) => p.userId);
|
||
sendMessageToUser(Number(userId), {
|
||
type: 'wolf_action',
|
||
message: '请选择要刀的玩家',
|
||
alivePlayers,
|
||
});
|
||
}
|
||
}
|
||
}, 3000);
|
||
}
|
||
|
||
/**
|
||
* 结算狼人刀人结果
|
||
*/
|
||
function resolveWolfAction(room) {
|
||
// 统计wolfVotes
|
||
let voteMap = {};
|
||
for (let [wolfId, targetId] of Object.entries(gameData.wolfVotes)) {
|
||
if (!voteMap[targetId]) voteMap[targetId] = 0;
|
||
voteMap[targetId]++;
|
||
}
|
||
// 找到最高票
|
||
let maxVote = 0;
|
||
let victims = [];
|
||
for (let [targetId, count] of Object.entries(voteMap)) {
|
||
if (count > maxVote) {
|
||
maxVote = count;
|
||
victims = [targetId];
|
||
} else if (count === maxVote) {
|
||
victims.push(targetId);
|
||
}
|
||
}
|
||
|
||
let killedByWolf = null;
|
||
if (maxVote === 0) {
|
||
// 狼人都没行动
|
||
} else if (victims.length === 1) {
|
||
killedByWolf = Number(victims[0]);
|
||
} else {
|
||
// 平票随机
|
||
killedByWolf = Number(victims[Math.floor(Math.random() * victims.length)]);
|
||
}
|
||
|
||
// 告知狼人结果
|
||
for (let [userId, data] of Object.entries(gameData.rolesMap)) {
|
||
if (data.role === 'wolf' && data.isAlive) {
|
||
sendMessageToUser(Number(userId), {
|
||
type: 'wolf_result',
|
||
killedPlayer: killedByWolf,
|
||
message: killedByWolf
|
||
? `狼人集体决定刀 ${getNameById(killedByWolf)}`
|
||
: '无人被刀',
|
||
});
|
||
}
|
||
}
|
||
|
||
// 清空狼投票数据
|
||
gameData.wolfVotes = {};
|
||
|
||
// 2. 预言家行动,10 秒
|
||
// === 在狼人结算完后再等 3 秒再开始预言家行动 ===
|
||
setTimeout(() => {
|
||
broadcastCountdown(10, '预言家行动时间', () => {
|
||
// 回调再等 3 秒再真正执行
|
||
setTimeout(() => {
|
||
resolveSeerAction(room, killedByWolf);
|
||
}, 3000);
|
||
});
|
||
|
||
// 给预言家单独发送可查验目标
|
||
for (let [userId, data] of Object.entries(gameData.rolesMap)) {
|
||
if (data.role === 'seer' && data.isAlive) {
|
||
const alivePlayers = getAlivePlayers(room).map((p) => p.userId);
|
||
sendMessageToUser(Number(userId), {
|
||
type: 'seer_action',
|
||
message: '请选择要查验的玩家',
|
||
alivePlayers,
|
||
});
|
||
}
|
||
}
|
||
}, 3000);
|
||
}
|
||
|
||
/**
|
||
* 结算预言家查验结果
|
||
*/
|
||
function resolveSeerAction(room, killedByWolf) {
|
||
const seerChoice = gameData.seerChoice;
|
||
if (seerChoice) {
|
||
// 直接给预言家发回结果
|
||
const seerId = Object.entries(gameData.rolesMap).find(
|
||
([, r]) => r.role === 'seer' && r.isAlive
|
||
);
|
||
if (seerId) {
|
||
const [seerUserId] = seerId;
|
||
|
||
// 映射角色为通用的 "wolf" 或 "good"
|
||
const chosenRole = gameData.rolesMap[seerChoice]?.role;
|
||
const roleMap = {
|
||
wolf: 'wolf',
|
||
villager: 'good',
|
||
seer: 'good',
|
||
witch: 'good',
|
||
hunter: 'good',
|
||
};
|
||
const mappedRole = roleMap[chosenRole] || 'unknown'; // 默认为 "unknown"
|
||
|
||
sendMessageToUser(Number(seerUserId), {
|
||
type: 'seer_result',
|
||
userId: seerChoice,
|
||
role: mappedRole,
|
||
});
|
||
}
|
||
}
|
||
|
||
|
||
// 清空
|
||
gameData.seerChoice = null;
|
||
|
||
// 3. 女巫行动,15 秒
|
||
// === 同样加一个 3 秒缓冲再给女巫倒计时 ===
|
||
setTimeout(() => {
|
||
broadcastCountdown(15, '女巫行动时间', () => {
|
||
setTimeout(() => {
|
||
resolveWitchAction(room, killedByWolf);
|
||
}, 3000);
|
||
});
|
||
|
||
// 女巫可操作
|
||
for (let [userId, data] of Object.entries(gameData.rolesMap)) {
|
||
if (data.role === 'witch' && data.isAlive) {
|
||
const alivePlayers = getAlivePlayers(room).map((p) => p.userId);
|
||
// 通知女巫今晚死亡的是谁
|
||
sendMessageToUser(Number(userId), {
|
||
type: 'witch_info',
|
||
killedByWolf,
|
||
hasSave: gameData.witchSaveAvailable,
|
||
hasKill: gameData.witchKillAvailable,
|
||
alivePlayers,
|
||
});
|
||
}
|
||
}
|
||
}, 3000);
|
||
}
|
||
|
||
/**
|
||
* 结算女巫结果
|
||
*/
|
||
function resolveWitchAction(room, killedByWolf) {
|
||
let finalKilledByWolf = killedByWolf;
|
||
|
||
// 女巫使用了解药
|
||
if (gameData.witchSaveTarget !== null && gameData.witchSaveTarget === killedByWolf) {
|
||
if (gameData.witchSaveAvailable) {
|
||
finalKilledByWolf = null; // 被救活
|
||
gameData.witchSaveAvailable = false; // 消耗了解药
|
||
} else {
|
||
console.warn("女巫试图使用解药,但解药已消耗!");
|
||
}
|
||
}
|
||
|
||
// 女巫使用了毒药
|
||
let witchKilled = null;
|
||
if (gameData.witchKillTarget !== null) {
|
||
if (gameData.witchKillAvailable) {
|
||
witchKilled = gameData.witchKillTarget; // 记录女巫毒杀的玩家
|
||
gameData.witchKillAvailable = false; // 消耗了毒药
|
||
} else {
|
||
console.warn("女巫试图使用毒药,但毒药已消耗!");
|
||
}
|
||
}
|
||
|
||
// 统计当晚死亡的玩家列表
|
||
let diedAtNight = [];
|
||
if (finalKilledByWolf) {
|
||
diedAtNight.push(finalKilledByWolf);
|
||
}
|
||
if (witchKilled && witchKilled !== finalKilledByWolf) {
|
||
diedAtNight.push(witchKilled);
|
||
}
|
||
|
||
// 检查猎人是否被杀
|
||
let hunterDieByWolf = false;
|
||
for (let diedId of diedAtNight) {
|
||
if (gameData.rolesMap[diedId].role === 'hunter') {
|
||
if (diedId === finalKilledByWolf) {
|
||
hunterDieByWolf = true; // 猎人被狼人杀
|
||
}
|
||
}
|
||
}
|
||
|
||
// 更新死亡状态
|
||
diedAtNight.forEach((id) => {
|
||
if (gameData.rolesMap[id]) {
|
||
gameData.rolesMap[id].isAlive = false;
|
||
}
|
||
});
|
||
|
||
// 进入白天前,先广播夜晚结果
|
||
if (diedAtNight.length === 0) {
|
||
broadcastMessage('系统', {
|
||
type: 'night_result',
|
||
message: '这是个平安夜,无人死亡。',
|
||
});
|
||
} else {
|
||
broadcastMessage('系统', {
|
||
type: 'night_result',
|
||
diedPlayers: diedAtNight.map(Number), // 玩家 ID 转换为数字数组
|
||
message: `夜晚结束,玩家 ${diedAtNight
|
||
.map((id) => getNameById(Number(id)))
|
||
.join(',')} 死亡。`,
|
||
});
|
||
}
|
||
|
||
// 检查胜负条件
|
||
const { isEnd, winner } = checkWinCondition(room);
|
||
if (isEnd) {
|
||
endGameAndSaveToDB(room, winner);
|
||
return;
|
||
}
|
||
|
||
// 通知猎人死亡
|
||
if (hunterDieByWolf) {
|
||
const hunterId = Object.entries(gameData.rolesMap).find(([id, data]) => {
|
||
return data.role === 'hunter' && !data.isAlive;
|
||
});
|
||
if (hunterId) {
|
||
const [deadHunterId] = hunterId;
|
||
broadcastMessage('系统', {
|
||
type: 'hunter_dead',
|
||
userId: Number(deadHunterId),
|
||
message: `猎人 ${getNameById(Number(deadHunterId))} 被杀,可带走一位玩家!10秒内选择!`,
|
||
});
|
||
|
||
// 新增:等待 10 秒后自动进入白天
|
||
setTimeout(() => {
|
||
// 在进入白天前,再检查一下有没有结束
|
||
const { isEnd, winner } = checkWinCondition(room);
|
||
if (isEnd) {
|
||
endGameAndSaveToDB(room, winner);
|
||
} else {
|
||
// 如果猎人还没操作就超时,或者他已经操作但游戏没结束
|
||
enterDay(room);
|
||
}
|
||
}, 10000);
|
||
|
||
return;
|
||
}
|
||
}
|
||
|
||
|
||
// 如果猎人不能带人或没有猎人死亡,直接进入白天
|
||
// === 夜晚结束后给 3 秒缓冲 ===
|
||
setTimeout(() => {
|
||
enterDay(room);
|
||
}, 3000);
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* 进入白天
|
||
* @param {object} room
|
||
*/
|
||
function enterDay(room) {
|
||
// 如果已经不是夜晚,就说明现在是白天或正在白天
|
||
if (!gameData.isNight) {
|
||
console.warn('[enterDay] 已经是白天,忽略本次调用');
|
||
return;
|
||
}
|
||
gameData.isNight = false;
|
||
gameData.dayCount++;
|
||
broadcastMessage('系统', {
|
||
type: 'day',
|
||
message: `天亮了,现在是第 ${gameData.dayCount} 天白天`,
|
||
});
|
||
|
||
// 白天流程:从警长开始顺序发言 -> 投票放逐
|
||
const alive = getAlivePlayers(room).map((p) => p.userId);
|
||
let startIndex = 0;
|
||
|
||
// 如果有警长且存活,则从警长开始
|
||
const sheriffId = Object.entries(gameData.rolesMap).find(
|
||
([, val]) => val.isSheriff && val.isAlive
|
||
);
|
||
if (sheriffId) {
|
||
const [sId] = sheriffId;
|
||
const idx = alive.indexOf(Number(sId));
|
||
if (idx !== -1) {
|
||
startIndex = idx;
|
||
}
|
||
}
|
||
|
||
// 根据 players 顺序做个循环队列
|
||
const speakingOrder = [...alive.slice(startIndex), ...alive.slice(0, startIndex)];
|
||
broadcastMessage('系统', {
|
||
type: 'day_speech',
|
||
speakingOrder,
|
||
message: '现在开始白天发言,每人 15 秒',
|
||
});
|
||
|
||
// === 白天开始前也给一点缓冲 ===
|
||
setTimeout(() => {
|
||
daySpeech(room, speakingOrder);
|
||
}, 3000);
|
||
}
|
||
|
||
function daySpeech(room, list) {
|
||
if (list.length === 0) {
|
||
// 投票目标(第一轮为所有存活玩家,后续为平票玩家)
|
||
const voteTargets = getAlivePlayers(room).map((player) => ({
|
||
userId: player.userId,
|
||
username: player.username,
|
||
}));
|
||
|
||
// 广播进入投票放逐环节的消息,并附加投票对象
|
||
broadcastMessage('系统', {
|
||
type: 'day_vote',
|
||
message: '所有玩家发言结束,进入投票放逐环节,15秒后结束',
|
||
voteTargets,
|
||
});
|
||
|
||
// 开始投票
|
||
startDayVote(room, voteTargets);
|
||
return;
|
||
}
|
||
|
||
const current = list.shift();
|
||
const currentName = getNameById(current);
|
||
|
||
// 广播当前玩家发言
|
||
broadcastMessage('系统', {
|
||
type: 'day_speech_turn',
|
||
userId: current,
|
||
message: `轮到玩家 ${currentName} 发言(15秒)`,
|
||
});
|
||
|
||
// 倒计时15秒,继续下一位玩家
|
||
broadcastCountdown(15, `玩家${currentName}发言`, () => {
|
||
daySpeech(room, list);
|
||
});
|
||
}
|
||
|
||
|
||
function startDayVote(room, voteTargets) {
|
||
// 清空 dayVotes
|
||
gameData.dayVotes = {};
|
||
|
||
// 投票目标为传入的目标列表
|
||
const voteTargetIds = voteTargets.map((p) => p.userId);
|
||
|
||
broadcastCountdown(15, '放逐投票', () => {
|
||
// 结算投票前再加 3 秒缓冲
|
||
setTimeout(() => {
|
||
const voteCount = {};
|
||
for (let voterId of Object.keys(gameData.dayVotes)) {
|
||
const targetId = gameData.dayVotes[voterId];
|
||
if (!voteCount[targetId]) {
|
||
voteCount[targetId] = 0;
|
||
}
|
||
// 如果投票人是警长,票数+2
|
||
if (gameData.rolesMap[voterId].isSheriff) {
|
||
voteCount[targetId] += 2;
|
||
} else {
|
||
voteCount[targetId]++;
|
||
}
|
||
}
|
||
|
||
// 找到最高票
|
||
let maxVotes = 0;
|
||
let victims = [];
|
||
Object.entries(voteCount).forEach(([targetId, count]) => {
|
||
if (count > maxVotes) {
|
||
maxVotes = count;
|
||
victims = [targetId];
|
||
} else if (count === maxVotes) {
|
||
victims.push(targetId);
|
||
}
|
||
});
|
||
|
||
if (Object.keys(voteCount).length === 0) {
|
||
// 无人投票,作废
|
||
victims = [];
|
||
}
|
||
|
||
if (victims.length === 1) {
|
||
// 放逐该玩家
|
||
const exiled = Number(victims[0]);
|
||
gameData.rolesMap[exiled].isAlive = false;
|
||
|
||
// 放逐完,先检查一下游戏是不是已经满足结束条件
|
||
const { isEnd, winner } = checkWinCondition(room);
|
||
if (isEnd) {
|
||
endGameAndSaveToDB(room, winner);
|
||
return;
|
||
}
|
||
|
||
// 检查是否是猎人
|
||
const isHunter = gameData.rolesMap[exiled]?.role === 'hunter';
|
||
|
||
// 广播放逐通知
|
||
broadcastMessage('系统', {
|
||
type: 'day_vote_result',
|
||
detail: gameData.dayVotes,
|
||
exiled,
|
||
isHunter,
|
||
message: `投票结束,玩家 ${getNameById(exiled)} 被放逐`,
|
||
});
|
||
|
||
// 15秒遗言
|
||
broadcastCountdown(15, `玩家${getNameById(exiled)}的遗言`, () => {
|
||
// 遗言结束,检查胜利条件
|
||
const { isEnd, winner } = checkWinCondition(room);
|
||
if (isEnd) {
|
||
endGameAndSaveToDB(room, winner);
|
||
return;
|
||
}
|
||
|
||
if (isHunter) {
|
||
// 广播猎人技能通知
|
||
broadcastMessage('系统', {
|
||
type: 'hunter_dead',
|
||
userId: exiled,
|
||
message: `猎人 ${getNameById(exiled)} 被放逐,可带走一位玩家!10秒内选择!`,
|
||
});
|
||
|
||
// 这里给猎人 10 秒时间,如果他不调用 /hunter_kill,那 10 秒后自动进夜晚
|
||
setTimeout(() => {
|
||
// 10 秒后先检查游戏是否已经结束(也许猎人秒选了带走人,导致游戏提前结束)
|
||
const { isEnd, winner } = checkWinCondition(room);
|
||
if (isEnd) {
|
||
endGameAndSaveToDB(room, winner);
|
||
} else {
|
||
// 如果游戏还没结束,进入夜晚
|
||
setTimeout(() => {
|
||
enterNight(room);
|
||
}, 3000);
|
||
}
|
||
}, 10000);
|
||
} else {
|
||
// 如果不是猎人,直接进入夜晚
|
||
setTimeout(() => {
|
||
enterNight(room);
|
||
}, 3000);
|
||
}
|
||
});
|
||
} else {
|
||
// 没人被放逐
|
||
broadcastMessage('系统', {
|
||
type: 'day_vote_result',
|
||
detail: gameData.dayVotes,
|
||
message: '无人被放逐。',
|
||
});
|
||
// 直接夜晚
|
||
setTimeout(() => {
|
||
enterNight(room);
|
||
}, 3000);
|
||
}
|
||
}, 3000);
|
||
});
|
||
}
|
||
|
||
|
||
// ======== 路由接口 ========
|
||
|
||
// 1. 玩家参与警长竞选
|
||
router.post('/sheriff_join', (req, res) => {
|
||
const { userId } = req.body;
|
||
if (!gameData.isGameStarted) {
|
||
return res.status(400).json({ message: '游戏尚未开始' });
|
||
}
|
||
if (gameData.isSheriffElected) {
|
||
return res.status(400).json({ message: '警长已选出,无法再竞选' });
|
||
}
|
||
if (!gameData.rolesMap[userId] || !gameData.rolesMap[userId].isAlive) {
|
||
return res.status(400).json({ message: '玩家不存在或已死亡' });
|
||
}
|
||
if (!gameData.sheriffCandidates.includes(userId)) {
|
||
gameData.sheriffCandidates.push(userId);
|
||
|
||
// 获取玩家名称
|
||
const userName = getNameById(userId);
|
||
|
||
// 广播通知
|
||
broadcastMessage('系统', {
|
||
type: 'sheriff_join',
|
||
userId,
|
||
message: `玩家 ${userName} 参与了警长竞选`,
|
||
});
|
||
}
|
||
|
||
return res.json({ message: '成功参与警长竞选' });
|
||
});
|
||
|
||
// 2. 聊天接口(统一的聊天)
|
||
router.post('/chat', (req, res) => {
|
||
const { userId, content } = req.body;
|
||
if (!gameData.isGameStarted) {
|
||
return res.status(400).json({ message: '游戏尚未开始' });
|
||
}
|
||
// if (!gameData.rolesMap[userId] || !gameData.rolesMap[userId].isAlive) {
|
||
// return res.status(400).json({ message: '你已死亡,无法发言' });
|
||
// }
|
||
// 添加聊天记录并广播
|
||
addChatMessage(userId, content);
|
||
|
||
return res.json({ message: '发送成功' });
|
||
});
|
||
|
||
// 3. 警长投票接口
|
||
router.post('/sheriff_vote', (req, res) => {
|
||
const { voterId, candidateId } = req.body;
|
||
if (!gameData.isGameStarted) {
|
||
return res.status(400).json({ message: '游戏尚未开始' });
|
||
}
|
||
|
||
// 判断该玩家是否存活
|
||
if (!gameData.rolesMap[voterId] || !gameData.rolesMap[voterId].isAlive) {
|
||
return res.status(400).json({ message: '你已死亡,无法投票' });
|
||
}
|
||
|
||
// 判断该玩家是否在警长候选人中。如果在,就禁止投票
|
||
if (gameData.sheriffCandidates.includes(voterId)) {
|
||
return res.status(403).json({ message: '你正在竞选警长,无法投票' });
|
||
}
|
||
|
||
// 判断 candidateId 是否是候选人
|
||
if (!gameData.sheriffCandidates.includes(candidateId)) {
|
||
return res.status(400).json({ message: '该玩家不在候选名单中' });
|
||
}
|
||
|
||
// 记录投票
|
||
gameData.sheriffVotes[voterId] = candidateId;
|
||
|
||
return res.json({ message: '投票成功' });
|
||
});
|
||
|
||
|
||
// 4. 狼人选择刀人
|
||
router.post('/wolf_choose', (req, res) => {
|
||
const { wolfId, targetId } = req.body;
|
||
if (!gameData.isGameStarted) {
|
||
return res.status(400).json({ message: '游戏尚未开始' });
|
||
}
|
||
const roleData = gameData.rolesMap[wolfId];
|
||
if (!roleData || roleData.role !== 'wolf' || !roleData.isAlive) {
|
||
return res.status(403).json({ message: '你不是活着的狼人,无法刀人' });
|
||
}
|
||
// 记录刀人选择
|
||
gameData.wolfVotes[wolfId] = targetId;
|
||
|
||
// 同步给其他狼人的消息
|
||
for (let [userId, data] of Object.entries(gameData.rolesMap)) {
|
||
if (data.role === 'wolf' && data.isAlive && Number(userId) !== Number(wolfId)) {
|
||
sendMessageToUser(Number(userId), {
|
||
type: 'wolf_notice',
|
||
message: `队友 ${wolfId} 选择刀玩家 ${targetId}`,
|
||
});
|
||
}
|
||
}
|
||
|
||
return res.json({ message: '狼人刀人选择成功' });
|
||
});
|
||
|
||
// 5. 预言家查验
|
||
router.post('/seer_look', (req, res) => {
|
||
const { seerId, targetId } = req.body;
|
||
if (!gameData.isGameStarted) {
|
||
return res.status(400).json({ message: '游戏尚未开始' });
|
||
}
|
||
const roleData = gameData.rolesMap[seerId];
|
||
if (!roleData || roleData.role !== 'seer' || !roleData.isAlive) {
|
||
return res.status(403).json({ message: '你不是活着的预言家,无法查验' });
|
||
}
|
||
gameData.seerChoice = targetId;
|
||
return res.json({ message: '查验目标已记录' });
|
||
});
|
||
|
||
// 6. 女巫救人
|
||
router.post('/witch_save', (req, res) => {
|
||
const { witchId, userId } = req.body;
|
||
if (!gameData.isGameStarted) {
|
||
return res.status(400).json({ message: '游戏尚未开始' });
|
||
}
|
||
const roleData = gameData.rolesMap[witchId];
|
||
if (!roleData || roleData.role !== 'witch' || !roleData.isAlive) {
|
||
return res.status(403).json({ message: '你不是活着的女巫,无法救人' });
|
||
}
|
||
if (!gameData.witchSaveAvailable) {
|
||
return res.status(400).json({ message: '解药已使用' });
|
||
}
|
||
gameData.witchSaveTarget = userId;
|
||
return res.json({ message: '已选择救人' });
|
||
});
|
||
|
||
// 7. 女巫毒人
|
||
router.post('/witch_kill', (req, res) => {
|
||
const { witchId, userId } = req.body;
|
||
if (!gameData.isGameStarted) {
|
||
return res.status(400).json({ message: '游戏尚未开始' });
|
||
}
|
||
const roleData = gameData.rolesMap[witchId];
|
||
if (!roleData || roleData.role !== 'witch' || !roleData.isAlive) {
|
||
return res.status(403).json({ message: '你不是活着的女巫,无法毒人' });
|
||
}
|
||
if (!gameData.witchKillAvailable) {
|
||
return res.status(400).json({ message: '毒药已使用' });
|
||
}
|
||
gameData.witchKillTarget = userId;
|
||
return res.json({ message: '已选择毒人' });
|
||
});
|
||
|
||
// 8. 猎人带走人
|
||
router.post('/hunter_kill', (req, res) => {
|
||
const { hunterId, userId } = req.body;
|
||
if (!gameData.isGameStarted) {
|
||
return res.status(400).json({ message: '游戏尚未开始' });
|
||
}
|
||
const roleData = gameData.rolesMap[hunterId];
|
||
console.log(`roleData: ${JSON.stringify(roleData)}`);
|
||
|
||
if (!roleData || roleData.role !== 'hunter') {
|
||
return res.status(403).json({ message: '你不是猎人' });
|
||
}
|
||
// 只有被狼刀死的猎人可以带人
|
||
if (roleData.isAlive) {
|
||
return res.status(400).json({ message: '你还没死,不能带走别人' });
|
||
}
|
||
// 这里就直接处理带走逻辑
|
||
if (!gameData.rolesMap[userId] || !gameData.rolesMap[userId].isAlive) {
|
||
return res.status(400).json({ message: '对方已死亡或不存在' });
|
||
}
|
||
|
||
// 猎人带走目标玩家
|
||
gameData.rolesMap[userId].isAlive = false;
|
||
|
||
// 广播猎人带走玩家的消息
|
||
broadcastMessage('系统', {
|
||
type: 'hunter_kill',
|
||
hunterId,
|
||
userId,
|
||
message: `猎人 ${getNameById(hunterId)} 带走了玩家 ${getNameById(userId)}!`,
|
||
});
|
||
|
||
// 检查游戏胜负条件
|
||
const { isEnd, winner } = checkWinCondition(room);
|
||
if (isEnd) {
|
||
// 游戏结束
|
||
endGameAndSaveToDB(room, winner);
|
||
return res.json({
|
||
message: '操作成功,游戏已结束',
|
||
result: { isEnd, winner },
|
||
});
|
||
}
|
||
|
||
// 如果游戏没结束,就继续游戏进程
|
||
// 判断是白天死(投票死)还是夜晚死(狼刀死)
|
||
if (gameData.isNight) {
|
||
// 如果是夜晚死,猎人开枪带人后进入白天
|
||
setTimeout(() => {
|
||
enterDay(room);
|
||
}, 3000);
|
||
} else {
|
||
// 如果是白天死,猎人开枪带人后进入夜晚
|
||
setTimeout(() => {
|
||
enterNight(room);
|
||
}, 3000);
|
||
}
|
||
|
||
return res.json({ message: '操作成功,继续游戏' });
|
||
});
|
||
|
||
// 9. 白天投票放逐
|
||
router.post('/vote', (req, res) => {
|
||
const { voterId, targetId } = req.body;
|
||
if (!gameData.isGameStarted) {
|
||
return res.status(400).json({ message: '游戏尚未开始' });
|
||
}
|
||
if (!gameData.rolesMap[voterId] || !gameData.rolesMap[voterId].isAlive) {
|
||
return res.status(400).json({ message: '你已死亡,无法投票' });
|
||
}
|
||
if (!gameData.rolesMap[targetId] || !gameData.rolesMap[targetId].isAlive) {
|
||
return res.status(400).json({ message: '目标已死亡或不存在' });
|
||
}
|
||
// 记录
|
||
gameData.dayVotes[voterId] = targetId;
|
||
return res.json({ message: '投票成功' });
|
||
});
|
||
|
||
// ======== 导出 ========
|
||
module.exports = {
|
||
router,
|
||
startGame,
|
||
};
|