Skip to content

Commit 551d313

Browse files
committed
✨ feat(谁是卧底): 添加游戏规则
1 parent 2e1e137 commit 551d313

File tree

4 files changed

+147
-37
lines changed

4 files changed

+147
-37
lines changed

game/backend/src/games/spy.ts

Lines changed: 72 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,72 @@ export default function onRoom(room: Room) {
3939

4040
const vote: RoomPlayer[] = [];
4141
const votePlayers: RoomPlayer[] = [];
42+
const TURN_TIMEOUT = 5 * 60 * 1000; // 5 minutes
43+
44+
function startTurn(player: RoomPlayer) {
45+
currentTalkPlayer = player;
46+
room.emit('command', { type: 'talk', data: { player } });
47+
room.emit('command', { type: 'talk-countdown', data: { seconds: TURN_TIMEOUT / 1000 } });
48+
49+
if (talkTimeout) clearTimeout(talkTimeout);
50+
talkTimeout = setTimeout(() => {
51+
room.emit('message', `[系统消息]: 玩家 ${player.name} 发言超时,判定死亡。`);
52+
handlePlayerDeath(player);
53+
}, TURN_TIMEOUT);
54+
}
55+
56+
function handlePlayerDeath(deadPlayer: RoomPlayer) {
57+
if (talkTimeout) {
58+
clearTimeout(talkTimeout);
59+
talkTimeout = null;
60+
}
61+
62+
room.emit('command', { type: 'dead', data: { player: deadPlayer } });
63+
const deadIndex = alivePlayers.findIndex((p) => p.id == deadPlayer.id);
64+
if (deadIndex > -1) alivePlayers.splice(deadIndex, 1);
65+
66+
if (deadPlayer.name == spyPlayer.name) {
67+
room.emit('message', `[系统消息]: 玩家 ${deadPlayer.name} 死亡。间谍死亡。玩家胜利。`);
68+
room.validPlayers.forEach((player) => {
69+
if (!alivePlayers.some(p => p.id === player.id)) alivePlayers.push(player);
70+
});
71+
room.end();
72+
} else if (alivePlayers.length == 2) {
73+
room.emit('message', `[系统消息]: 玩家 ${deadPlayer.name} 死亡。间谍 ${spyPlayer.name} 胜利。`);
74+
room.validPlayers.forEach((player) => {
75+
if (!alivePlayers.some(p => p.id === player.id)) alivePlayers.push(player);
76+
});
77+
room.end();
78+
} else {
79+
// If game continues
80+
if (gameStatus === 'voting') {
81+
// If death happened during voting (e.g. voted out), we need to start next round
82+
gameStatus = 'talking';
83+
startTurn(alivePlayers[0]);
84+
room.emit('message', `[系统消息]: 玩家 ${deadPlayer.name} 死亡。游戏继续。玩家 ${alivePlayers[0].name} 发言。`);
85+
} else {
86+
// If death happened during talking (timeout), move to next player
87+
// We need to find who is next. Since deadPlayer is removed, we need to be careful.
88+
// If deadPlayer was currentTalkPlayer, we need the next one in list.
89+
// But alivePlayers is already updated.
90+
// If deadPlayer was at index i, the new player at index i is the next one.
91+
// Unless deadPlayer was last, then index i is out of bounds?
92+
// Actually, handleTalkEnd logic was: find index, take index+1.
93+
// Here we removed the player. So the player at `deadIndex` is the next player.
94+
95+
let nextPlayer = alivePlayers[deadIndex];
96+
if (!nextPlayer) {
97+
// If we reached end of list, start voting
98+
room.emit('message', `[系统消息]: 所有玩家都已发言,投票开始。`);
99+
room.emit('command', { type: 'vote' });
100+
gameStatus = 'voting';
101+
} else {
102+
startTurn(nextPlayer);
103+
room.emit('message', `[系统消息]: 玩家 ${deadPlayer.name} 死亡。游戏继续。玩家 ${nextPlayer.name} 发言。`);
104+
}
105+
}
106+
}
107+
}
42108

43109
function handleTalkEnd(sender: RoomPlayer) {
44110
if (talkTimeout) {
@@ -56,9 +122,9 @@ export default function onRoom(room: Room) {
56122
gameStatus = 'voting';
57123
return;
58124
}
59-
currentTalkPlayer = nextPlayer;
125+
60126
room.emit('message', `[系统消息]: 玩家 ${sender.name} 发言结束。玩家 ${nextPlayer.name} 开始发言。`);
61-
room.emit('command', { type: 'talk', data: { player: nextPlayer } });
127+
startTurn(nextPlayer);
62128
}
63129

64130
room.on('player-command', (message: MessagePackage) => {
@@ -150,14 +216,9 @@ export default function onRoom(room: Room) {
150216
room.emit('message', `[系统消息]: 玩家 ${maxVotePlayer.map(p => p!.name).join(',')} 投票相同。无人死亡。`);
151217
vote.splice(0, vote.length);
152218
votePlayers.splice(0, votePlayers.length);
153-
currentTalkPlayer = alivePlayers[0];
154-
room.emit('command', { type: 'talk', data: { player: currentTalkPlayer } });
155219
gameStatus = 'talking';
156-
room.emit('message', `[系统消息]: 游戏继续。玩家 ${currentTalkPlayer.name} 发言。`);
157-
if (talkTimeout) {
158-
clearTimeout(talkTimeout);
159-
talkTimeout = null;
160-
}
220+
room.emit('message', `[系统消息]: 游戏继续。玩家 ${alivePlayers[0].name} 发言。`);
221+
startTurn(alivePlayers[0]);
161222
return;
162223
}
163224

@@ -166,27 +227,7 @@ export default function onRoom(room: Room) {
166227
gameStatus = 'waiting';
167228

168229
const deadPlayer = maxVotePlayer[0]!;
169-
room.emit('command', { type: 'dead', data: { player: deadPlayer } });
170-
alivePlayers.splice(alivePlayers.findIndex((p) => p.id == deadPlayer.id), 1);
171-
172-
if (deadPlayer.name == spyPlayer.name) {
173-
room.emit('message', `[系统消息]: 玩家 ${deadPlayer.name} 死亡。间谍死亡。玩家胜利。`);
174-
} else if (alivePlayers.length == 2) {
175-
room.emit('message', `[系统消息]: 玩家 ${deadPlayer.name} 死亡。间谍 ${spyPlayer.name} 胜利。`);
176-
} else {
177-
gameStatus = 'talking';
178-
currentTalkPlayer = alivePlayers[0];
179-
room.emit('command', { type: 'talk', data: { player: currentTalkPlayer } });
180-
if (talkTimeout) {
181-
clearTimeout(talkTimeout);
182-
talkTimeout = null;
183-
}
184-
return room.emit('message', `[系统消息]: 玩家 ${deadPlayer.name} 死亡。游戏继续。`);
185-
}
186-
room.validPlayers.forEach((player, index) => {
187-
alivePlayers.push(player);
188-
})
189-
room.end();
230+
handlePlayerDeath(deadPlayer);
190231
break;
191232
case 'status': {
192233
const playerIndex = room.validPlayers.findIndex((p) => p.id == message.data.id);
@@ -240,8 +281,8 @@ export default function onRoom(room: Room) {
240281
alivePlayers.push(player);
241282
})
242283
room.emit('message', `[系统消息]: 游戏开始。玩家 ${room.validPlayers[0].name} 首先发言。`);
243-
room.emit('command', { type: 'talk', data: { player: currentTalkPlayer = room.validPlayers[0] } });
244284
gameStatus = 'talking';
285+
startTurn(room.validPlayers[0]);
245286
}).on('end', () => {
246287
console.log("room end");
247288
if (talkTimeout) {

game/frontend/src/components/spy/SpyRoom.vue

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,14 @@
118118
</div>
119119

120120
<!-- 聊天 -->
121-
<div v-if="roomPlayer.role === 'player'" class="group flex gap-2">
121+
<div v-if="roomPlayer.role === 'player'" class="group flex gap-2 items-center">
122+
<button
123+
@click="showRules = !showRules"
124+
class="icon-btn text-secondary hover:text-primary"
125+
title="游戏规则"
126+
>
127+
<Icon icon="mdi:information-outline" />
128+
</button>
122129
<input
123130
v-model="msg"
124131
type="text"
@@ -128,6 +135,32 @@
128135
/>
129136
<button @click="sendMessage" :disabled="!canSpeak">发送</button>
130137
</div>
138+
139+
<!-- 规则弹窗 -->
140+
<div v-if="showRules" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" @click.self="showRules = false">
141+
<div class="bg-surface p-6 rounded-lg shadow-xl max-w-md w-full mx-4">
142+
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
143+
<Icon icon="mdi:book-open-variant" /> 游戏规则
144+
</h3>
145+
<ul class="space-y-2 text-sm text-secondary">
146+
<li>1. 玩家分为平民和卧底,平民词语相同,卧底词语不同。</li>
147+
<li>2. 玩家轮流发言,描述自己的词语,但不能直接说出词语。</li>
148+
<li>3. <strong>发言计时机制:</strong>
149+
<ul class="pl-4 mt-1 list-disc">
150+
<li>轮到发言时,有 <strong>5分钟</strong> 时间准备和输入。</li>
151+
<li>超时未发言将被判定为死亡(出局)。</li>
152+
<li>一旦开始发言(发送消息),剩余时间将缩短为 <strong>30秒</strong>。</li>
153+
<li>30秒内未结束发言(点击结束按钮),系统将自动结束你的发言。</li>
154+
</ul>
155+
</li>
156+
<li>4. 所有玩家发言结束后进行投票,票数最多者出局。</li>
157+
<li>5. 卧底出局则平民胜利,仅剩2人且含卧底则卧底胜利。</li>
158+
</ul>
159+
<button @click="showRules = false" class="mt-6 w-full bg-border text-primary py-2 rounded hover:bg-primary/90">
160+
知道了
161+
</button>
162+
</div>
163+
</div>
131164
</section>
132165

133166
<section class="bg-surface-light/30 p-3 rounded h-48 overflow-auto border border-border/50 flex-1">
@@ -158,6 +191,7 @@ const word = ref('')
158191
const roomMessages = ref<string[]>([])
159192
const currentPlayer = computed(() => props.roomPlayer.id)
160193
const countdown = ref(0)
194+
const showRules = ref(false)
161195
let countdownTimer: any = null
162196
163197
const voting = computed(() => gameStatus.value === 'voting')
@@ -186,7 +220,17 @@ function onCommand(cmd: any) {
186220
gameStatus.value = 'talking'
187221
if (countdownTimer) clearInterval(countdownTimer)
188222
countdown.value = 0
189-
break
223+
if (currentTalkPlayer.value?.id === currentPlayer.value) {
224+
// 如果是自己发言,开始倒计时
225+
countdown.value = 300
226+
countdownTimer = setInterval(() => {
227+
countdown.value--
228+
if (countdown.value <= 0) {
229+
clearInterval(countdownTimer)
230+
}
231+
}, 1000)
232+
}
233+
break;
190234
case 'talk-countdown':
191235
countdown.value = cmd.data.seconds
192236
if (countdownTimer) clearInterval(countdownTimer)

game/frontend/src/style.css

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,11 @@ input:disabled {
4848
@apply bg-transparent text-base text-primary border-none border-border rounded-full p-0 cursor-pointer transition-all duration-200 inline-flex items-center justify-center;
4949
}
5050

51-
.icon-btn:hover {
51+
.icon-btn-hidden {
52+
@apply bg-transparent text-base text-primary border-none border-border rounded-full p-0 cursor-pointer transition-all duration-200 items-center justify-center;
53+
}
54+
55+
.icon-btn:hover, .icon-btn-hidden:hover {
5256
@apply -m-2 p-2 bg-border;
5357
}
5458

game/frontend/src/views/Home.vue

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,17 @@
66
<button @click="isSidebarOpen = true" class="text-lg icon-btn">
77
<Icon icon="mingcute:menu-fill" />
88
</button>
9-
<span class="font-medium truncate">欢迎回来~ {{ gameStore.player?.name }}</span>
9+
<div
10+
v-if="gameStore.player?.avatar"
11+
class="w-[1.2em] h-[1.2em] rounded-full bg-surface border border-border flex items-center justify-center text-xl font-bold relative"
12+
>
13+
<img
14+
:src="gameStore.player?.avatar"
15+
alt="avatar"
16+
class="w-full h-full object-cover rounded-full"
17+
/>
18+
</div>
19+
<span class="font-medium truncate">{{ gameStore.player?.name }}</span>
1020
</div>
1121
<button
1222
@click="handleLogout"
@@ -39,10 +49,21 @@
3949
>
4050
<div class="flex justify-between items-center pb-2 border-b border-border">
4151
<!-- PC端折叠按钮 -->
42-
<button @click="isDesktopSidebarCollapsed = true" class="icon-btn hidden md:flex">
52+
<button @click="isDesktopSidebarCollapsed = true" class="icon-btn-hidden hidden md:flex">
4353
<Icon icon="ep:fold" />
4454
</button>
45-
<span class="font-medium">欢迎回来~ {{ gameStore.player?.name }}</span>
55+
<div
56+
v-if="gameStore.player?.avatar"
57+
class="w-[1.2em] h-[1.2em] rounded-full bg-surface border border-border flex items-center justify-center text-xl font-bold relative"
58+
>
59+
<img
60+
v-if="gameStore.player?.avatar"
61+
:src="gameStore.player?.avatar"
62+
alt="avatar"
63+
class="w-full h-full object-cover rounded-full"
64+
/>
65+
</div>
66+
<span class="font-medium">{{ gameStore.player?.name }}</span>
4667
<div class="flex items-center gap-2">
4768
<button
4869
@click="handleLogout"

0 commit comments

Comments
 (0)