Add web game: Node.js backend + Vue.js frontend

This commit is contained in:
karylab_agents
2026-07-30 09:09:58 +08:00
parent f30c1d9e99
commit d8834b95d3
15 changed files with 3720 additions and 160 deletions
+1 -160
View File
@@ -1,163 +1,4 @@
# MacOS stuf
*.DS_Store
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
node_modules/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>騙子酒館 - 線上多人心理博弈遊戲</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1162
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "liars-bar-game-client",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.3.4",
"pinia": "^2.1.6",
"socket.io-client": "^4.7.2",
"naive-ui": "^2.34.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",
"vite": "^4.4.9"
}
}
+74
View File
@@ -0,0 +1,74 @@
<template>
<n-config-provider>
<n-message-provider>
<div class="app-container">
<header class="app-header">
<h1>🎰 騙子酒館</h1>
<n-tag :type="isConnected ? 'success' : 'error'" :bordered="false">
{{ isConnected ? '已連線' : '未連線' }}
</n-tag>
</header>
<main class="app-main">
<Lobby v-if="!roomId" />
<GameTable v-else />
</main>
</div>
</n-message-provider>
</n-config-provider>
</template>
<script setup>
import { useGameStore } from './stores/game'
import { storeToRefs } from 'pinia'
import { NConfigProvider, NMessageProvider, NTag } from 'naive-ui'
import Lobby from './components/Lobby.vue'
import GameTable from './components/GameTable.vue'
const gameStore = useGameStore()
const { isConnected, roomId } = storeToRefs(gameStore)
// 啟動時連線
gameStore.connect()
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft JhengHei', sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
}
.app-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 2px solid #333;
margin-bottom: 20px;
}
.app-header h1 {
font-size: 2em;
background: linear-gradient(45deg, #f093fb, #f5576c);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.app-main {
min-height: 60vh;
}
</style>
+146
View File
@@ -0,0 +1,146 @@
<template>
<div class="game-table">
<n-card :title="`房間: ${roomId}`" class="game-card">
<n-space vertical :size="20">
<!-- 等待室 -->
<template v-if="!gameState">
<n-h3>等待室</n-h3>
<n-list>
<n-list-item v-for="player in roomPlayers" :key="player">
<n-space align="center">
<n-avatar>{{ player.substring(0, 2) }}</n-avatar>
<div>{{ player.substring(0, 8) }}...</div>
<n-tag v-if="player === roomId" type="warning">房主</n-tag>
</n-space>
</n-list-item>
</n-list>
<n-button
type="primary"
size="large"
block
@click="startGame"
>
開始遊戲
</n-button>
</template>
<!-- 遊戲中 -->
<template v-else>
<!-- 玩家列表 -->
<n-list>
<n-list-item v-for="player in players" :key="player.id">
<n-space align="center">
<n-avatar :style="{ backgroundColor: player.alive ? '#4caf50' : '#f44336' }">
{{ player.id.substring(0, 2) }}
</n-avatar>
<div>
<div>{{ player.id.substring(0, 8) }}...</div>
<n-tag :type="player.alive ? 'success' : 'error'" size="small">
{{ player.alive ? '存活' : '死亡' }}
</n-tag>
<n-tag size="small">手牌: {{ player.handSize }}</n-tag>
</div>
</n-space>
</n-list-item>
</n-list>
<!-- 遊戲資訊 -->
<n-descriptions label-placement="left" bordered>
<n-descriptions-item label="目標牌">
<n-tag type="warning" size="large">{{ targetCard }}</n-tag>
</n-descriptions-item>
<n-descriptions-item label="回合數">
{{ roundNumber }}
</n-descriptions-item>
<n-descriptions-item label="當前玩家">
<n-tag type="info">{{ currentPlayerId?.substring(0, 8) }}...</n-tag>
</n-descriptions-item>
</n-descriptions>
<!-- 已出牌 -->
<n-space v-if="playedCards.length > 0">
<n-tag v-for="(card, index) in playedCards" :key="index" type="success">
{{ card.card }}
</n-tag>
</n-space>
<!-- 操作按鈕 -->
<n-space justify="center">
<n-button
v-for="(card, index) in myHand"
:key="index"
type="primary"
size="large"
:disabled="!isMyTurn"
@click="playCard(index)"
>
出牌: {{ card }}
</n-button>
</n-space>
<n-space justify="center">
<n-button
type="error"
size="large"
:disabled="!isMyTurn || playedCards.length === 0"
@click="challenge"
>
質疑
</n-button>
</n-space>
</template>
</n-space>
</n-card>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useGameStore } from '../stores/game'
import { storeToRefs } from 'pinia'
import { NCard, NSpace, NList, NListItem, NAvatar, NTag, NDescriptions, NDescriptionsItem, NButton, NH3 } from 'naive-ui'
const gameStore = useGameStore()
const { roomId, gameState, room } = storeToRefs(gameStore)
const roomPlayers = computed(() => room.value?.players || [])
const players = computed(() => gameState.value?.players || [])
const targetCard = computed(() => gameState.value?.targetCard)
const roundNumber = computed(() => gameState.value?.roundNumber)
const currentPlayerId = computed(() => gameState.value?.currentPlayerId)
const playedCards = computed(() => gameState.value?.playedCards || [])
const myHand = computed(() => {
if (!gameState.value) return []
const me = players.value.find(p => p.id === gameStore.playerId)
return me ? Array(me.handSize).fill('?') : []
})
const isMyTurn = computed(() => {
return currentPlayerId.value === gameStore.playerId
})
const startGame = () => {
gameStore.startGame()
}
const playCard = (index) => {
gameStore.playCard(index)
}
const challenge = () => {
gameStore.challenge()
}
</script>
<style scoped>
.game-table {
max-width: 800px;
margin: 0 auto;
}
.game-card {
background: #16213e;
border: 1px solid #333;
}
</style>
+99
View File
@@ -0,0 +1,99 @@
<template>
<div class="lobby">
<n-card title="🎮 遊戲大廳" class="lobby-card">
<n-space vertical :size="20">
<n-button type="primary" size="large" @click="createRoom">
建立房間
</n-button>
<n-divider>或加入現有房間</n-divider>
<n-space align="center">
<n-input
v-model:value="joinRoomId"
placeholder="輸入房間代碼"
class="room-input"
/>
<n-button type="primary" @click="joinRoom">
加入
</n-button>
</n-space>
<n-divider>現有房間</n-divider>
<n-list v-if="rooms.length > 0">
<n-list-item v-for="room in rooms" :key="room.id">
<n-space justify="space-between" align="center">
<div>
<n-tag type="info">房間: {{ room.id }}</n-tag>
<n-tag>{{ room.players }}/{{ room.maxPlayers }} </n-tag>
</div>
<n-button
size="small"
@click="joinRoom(room.id)"
:disabled="room.players >= room.maxPlayers"
>
加入
</n-button>
</n-space>
</n-list-item>
</n-list>
<n-empty v-else description="目前沒有房間" />
</n-space>
</n-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useGameStore } from '../stores/game'
import { NCard, NButton, NSpace, NDivider, NInput, NList, NListItem, NTag, NEmpty } from 'naive-ui'
const gameStore = useGameStore()
const joinRoomId = ref('')
const rooms = ref([])
const createRoom = () => {
gameStore.createRoom()
}
const joinRoom = (roomId) => {
const id = roomId || joinRoomId.value
if (id) {
gameStore.joinRoom(id)
}
}
// 載入房間列表
const loadRooms = async () => {
try {
const response = await fetch('/api/rooms')
rooms.value = await response.json()
} catch (error) {
console.error('載入房間失敗:', error)
}
}
onMounted(() => {
loadRooms()
// 每 5 秒更新房間列表
setInterval(loadRooms, 5000)
})
</script>
<style scoped>
.lobby {
max-width: 600px;
margin: 0 auto;
}
.lobby-card {
background: #16213e;
border: 1px solid #333;
}
.room-input {
flex: 1;
}
</style>
+12
View File
@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { create } from 'naive-ui'
const app = createApp(App)
const pinia = createPinia()
const naive = create()
app.use(pinia)
app.use(naive)
app.mount('#app')
+102
View File
@@ -0,0 +1,102 @@
import { defineStore } from 'pinia'
import { io } from 'socket.io-client'
export const useGameStore = defineStore('game', {
state: () => ({
socket: null,
playerId: null,
roomId: null,
gameState: null,
room: null,
isConnected: false,
error: null
}),
actions: {
// 連線到伺服器
connect() {
this.socket = io('http://localhost:3000', {
transports: ['websocket']
})
this.socket.on('connect', () => {
this.playerId = this.socket.id
this.isConnected = true
console.log('已連線:', this.playerId)
})
this.socket.on('disconnect', () => {
this.isConnected = false
console.log('斷線')
})
this.socket.on('error', (data) => {
this.error = data.message
console.error('錯誤:', data.message)
})
// 房間事件
this.socket.on('roomCreated', (room) => {
this.room = room
this.roomId = room.id
})
this.socket.on('playerJoined', (room) => {
this.room = room
})
this.socket.on('gameStarted', (gameState) => {
this.gameState = gameState
})
this.socket.on('cardPlayed', (data) => {
this.gameState = data.gameState
})
this.socket.on('challenged', (data) => {
this.gameState = data.gameState
})
this.socket.on('playerDisconnected', (data) => {
this.room = data.room
})
},
// 建立房間
createRoom() {
this.socket.emit('createRoom')
},
// 加入房間
joinRoom(roomId) {
this.socket.emit('joinRoom', { roomId })
},
// 開始遊戲
startGame() {
this.socket.emit('startGame', { roomId: this.roomId })
},
// 出牌
playCard(cardIndex) {
this.socket.emit('playCard', {
roomId: this.roomId,
cardIndex
})
},
// 質疑
challenge() {
this.socket.emit('challenge', { roomId: this.roomId })
},
// 斷開連線
disconnect() {
if (this.socket) {
this.socket.disconnect()
this.socket = null
this.isConnected = false
}
}
}
})
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
})
+305
View File
@@ -0,0 +1,305 @@
// 遊戲規則常數
const CARDS = {
Q: 6,
K: 6,
A: 6,
JOKER: 2
};
const HAND_SIZE = 5;
const REVOLVER_CHAMBERS = 6;
const BULLET_COUNT = 1;
class GameEngine {
constructor() {
this.players = new Map();
this.currentPlayerId = null;
this.targetCard = null;
this.playedCards = [];
this.gameLog = [];
this.roundNumber = 0;
this.deck = [];
this.isGameOver = false;
this.playersWhoPlayedThisRound = new Set();
}
// 初始化遊戲
init(playerIds) {
this.players.clear();
this.currentPlayerId = null;
this.targetCard = null;
this.playedCards = [];
this.gameLog = [];
this.roundNumber = 0;
this.isGameOver = false;
this.playersWhoPlayedThisRound.clear();
// 建立牌組
this.deck = this.createDeck();
this.shuffleDeck(this.deck);
// 發牌
for (const id of playerIds) {
this.players.set(id, {
id,
hand: this.deck.splice(0, HAND_SIZE),
revolver: this.createRevolver(),
alive: true,
score: 0
});
}
// 設定目標牌
this.setTargetCard();
// 設定第一位玩家
this.currentPlayerId = this.getNextAlivePlayer();
return this.getGameState();
}
// 建立牌組
createDeck() {
const deck = [];
for (let i = 0; i < CARDS.Q; i++) deck.push('Q');
for (let i = 0; i < CARDS.K; i++) deck.push('K');
for (let i = 0; i < CARDS.A; i++) deck.push('A');
for (let i = 0; i < CARDS.JOKER; i++) deck.push('JOKER');
return deck;
}
// 洗牌
shuffleDeck(deck) {
for (let i = deck.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[deck[i], deck[j]] = [deck[j], deck[i]];
}
return deck;
}
// 建立左輪手槍
createRevolver() {
const chambers = Array(REVOLVER_CHAMBERS).fill(false);
const bulletIndex = Math.floor(Math.random() * REVOLVER_CHAMBERS);
chambers[bulletIndex] = true;
return {
chambers,
currentChamber: 0,
hasBullet: false
};
}
// 設定目標牌
setTargetCard() {
const targetCards = ['Q', 'K', 'A'];
this.targetCard = targetCards[Math.floor(Math.random() * targetCards.length)];
}
// 取得下一位存活玩家
getNextAlivePlayer() {
const alivePlayers = Array.from(this.players.values()).filter(p => p.alive);
if (alivePlayers.length === 0) return null;
// 如果只剩一人,遊戲結束
if (alivePlayers.length === 1) {
this.isGameOver = true;
return alivePlayers[0].id;
}
// 找當前玩家之後的第一位存活者
const playerIds = Array.from(this.players.keys());
const currentIndex = playerIds.indexOf(this.currentPlayerId);
for (let i = 1; i <= playerIds.length; i++) {
const nextIndex = (currentIndex + i) % playerIds.length;
const nextPlayer = this.players.get(playerIds[nextIndex]);
if (nextPlayer && nextPlayer.alive) {
return nextPlayer.id;
}
}
return alivePlayers[0].id;
}
// 出牌
playCard(playerId, cardIndex) {
if (this.isGameOver) {
return { error: '遊戲已結束' };
}
const player = this.players.get(playerId);
if (!player || !player.alive) {
return { error: '玩家已死亡' };
}
// 檢查輪次
if (playerId !== this.currentPlayerId) {
return { error: '不是你的回合' };
}
if (cardIndex < 0 || cardIndex >= player.hand.length) {
return { error: '無效的牌' };
}
const card = player.hand[cardIndex];
player.hand.splice(cardIndex, 1);
this.playedCards.push({ playerId, card });
this.playersWhoPlayedThisRound.add(playerId);
// 移動到下一位玩家
this.currentPlayerId = this.getNextAlivePlayer();
// 檢查是否所有存活玩家都出過牌
const alivePlayers = Array.from(this.players.values()).filter(p => p.alive);
if (this.playersWhoPlayedThisRound.size >= alivePlayers.length) {
this.endRound();
}
return {
success: true,
card,
remainingCards: player.hand.length,
nextPlayerId: this.currentPlayerId
};
}
// 結束回合
endRound() {
this.playedCards = [];
this.playersWhoPlayedThisRound.clear();
this.roundNumber++;
this.setTargetCard();
}
// 質疑
challenge(challengerId) {
if (this.isGameOver) {
return { error: '遊戲已結束' };
}
const challenger = this.players.get(challengerId);
if (!challenger || !challenger.alive) {
return { error: '挑戰者已死亡' };
}
// 檢查出牌者
const lastPlay = this.playedCards[this.playedCards.length - 1];
if (!lastPlay) {
return { error: '沒有出牌可質疑' };
}
// 檢查是否下家
if (challengerId !== this.currentPlayerId) {
return { error: '不是你的回合' };
}
const player = this.players.get(lastPlay.playerId);
// 判斷質疑結果
const isBluff = this.isBluff(lastPlay.card);
const result = {
challengerId,
playerId: lastPlay.playerId,
isBluff,
challengedCard: lastPlay.card
};
if (isBluff) {
// 質疑正確,出牌者開槍
result.shooter = lastPlay.playerId;
this.fireRevolver(lastPlay.playerId);
} else {
// 質疑錯誤,挑戰者開槍
result.shooter = challengerId;
this.fireRevolver(challengerId);
}
// 清空已出牌
this.playedCards = [];
this.playersWhoPlayedThisRound.clear();
return result;
}
// 判斷是否吹牛
isBluff(card) {
// 如果出牌不是目標牌,就是吹牛
return card !== this.targetCard;
}
// 開槍
fireRevolver(playerId) {
const player = this.players.get(playerId);
if (!player) return;
const revolver = player.revolver;
const hasBullet = revolver.chambers[revolver.currentChamber];
// 移動到下一倉
revolver.currentChamber = (revolver.currentChamber + 1) % REVOLVER_CHAMBERS;
if (hasBullet) {
player.alive = false;
player.revolver = this.createRevolver(); // 重置手槍
}
// 檢查遊戲是否結束
this.checkGameOver();
return {
playerId,
hasBullet,
alive: player.alive,
isGameOver: this.isGameOver
};
}
// 檢查遊戲是否結束
checkGameOver() {
const alivePlayers = Array.from(this.players.values()).filter(p => p.alive);
if (alivePlayers.length <= 1) {
this.isGameOver = true;
}
}
// 移除玩家(離線處理)
removePlayer(playerId) {
const player = this.players.get(playerId);
if (player) {
player.alive = false;
this.checkGameOver();
}
}
// 取得遊戲狀態
getGameState() {
const state = {
players: [],
currentPlayerId: this.currentPlayerId,
targetCard: this.targetCard,
playedCards: this.playedCards,
roundNumber: this.roundNumber,
isGameOver: this.isGameOver
};
for (const [id, player] of this.players) {
state.players.push({
id,
handSize: player.hand.length,
alive: player.alive,
revolverChambers: player.revolver.currentChamber
});
}
return state;
}
// 取得玩家手牌(回傳副本,防止篡改)
getPlayerHand(playerId) {
const player = this.players.get(playerId);
if (!player) return [];
return [...player.hand]; // 回傳副本
}
}
module.exports = GameEngine;
+112
View File
@@ -0,0 +1,112 @@
const { v4: uuidv4 } = require('uuid');
const GameEngine = require('./engine');
class RoomManager {
constructor() {
this.rooms = new Map();
}
// 建立房間
createRoom(hostId) {
const roomId = uuidv4().substring(0, 8);
const room = {
id: roomId,
hostId,
players: [hostId],
engine: null,
gameState: null,
status: 'waiting', // waiting, playing, ended
maxPlayers: 4, // 20 張牌 ÷ 5 張 = 最多 4 人
minPlayers: 2
};
this.rooms.set(roomId, room);
return room;
}
// 加入房間
joinRoom(roomId, playerId) {
const room = this.rooms.get(roomId);
if (!room) {
return { error: '房間不存在' };
}
if (room.status !== 'waiting') {
return { error: '遊戲已開始,無法加入' };
}
if (room.players.length >= room.maxPlayers) {
return { error: '房間已滿' };
}
if (room.players.includes(playerId)) {
return { error: '已在房間中' };
}
room.players.push(playerId);
return { success: true, room };
}
// 開始遊戲
startGame(roomId) {
const room = this.rooms.get(roomId);
if (!room) {
return { error: '房間不存在' };
}
if (room.players.length < room.minPlayers) {
return { error: '人數不足' };
}
if (room.status !== 'waiting') {
return { error: '遊戲已開始' };
}
const engine = new GameEngine();
const gameState = engine.init(room.players);
room.engine = engine; // 儲存引擎
room.gameState = gameState;
room.status = 'playing';
return { success: true, gameState };
}
// 取得房間
getRoom(roomId) {
return this.rooms.get(roomId);
}
// 列出房間
listRooms() {
const rooms = [];
for (const [id, room] of this.rooms) {
rooms.push({
id: room.id,
players: room.players.length,
maxPlayers: room.maxPlayers,
status: room.status
});
}
return rooms;
}
// 移除玩家
removePlayer(playerId) {
for (const [roomId, room] of this.rooms) {
if (room.players.includes(playerId)) {
room.players = room.players.filter(id => id !== playerId);
// 如果房間人數不足,結束遊戲
if (room.players.length < room.minPlayers && room.status === 'playing') {
room.status = 'ended';
}
return { roomId, room };
}
}
return null;
}
}
module.exports = RoomManager;
+163
View File
@@ -0,0 +1,163 @@
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const RoomManager = require('./game/room');
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: "*",
methods: ["GET", "POST"]
}
});
const roomManager = new RoomManager();
// 中間件
app.use(express.json());
// 驗證房間權限
function validateRoom(socket, roomId) {
if (!socket.rooms.has(roomId)) {
socket.emit('error', { message: '無權限操作此房間' });
return false;
}
return true;
}
// Socket.IO 連線
io.on('connection', (socket) => {
console.log(`玩家連線: ${socket.id}`);
// 建立房間
socket.on('createRoom', (data) => {
const room = roomManager.createRoom(socket.id);
socket.join(room.id);
socket.data = { roomId: room.id };
io.to(room.id).emit('roomCreated', room);
console.log(`房間建立: ${room.id}`);
});
// 加入房間
socket.on('joinRoom', (data) => {
const { roomId } = data;
const result = roomManager.joinRoom(roomId, socket.id);
if (result.error) {
socket.emit('error', { message: result.error });
return;
}
socket.join(roomId);
socket.data = { roomId };
io.to(roomId).emit('playerJoined', result.room);
console.log(`玩家加入房間: ${roomId}`);
});
// 開始遊戲
socket.on('startGame', (data) => {
const { roomId } = data;
if (!validateRoom(socket, roomId)) return;
const result = roomManager.startGame(roomId);
if (result.error) {
socket.emit('error', { message: result.error });
return;
}
io.to(roomId).emit('gameStarted', result.gameState);
console.log(`遊戲開始: ${roomId}`);
});
// 出牌
socket.on('playCard', (data) => {
const { roomId, cardIndex } = data;
if (!validateRoom(socket, roomId)) return;
const room = roomManager.getRoom(roomId);
if (!room || !room.engine) {
socket.emit('error', { message: '遊戲未開始' });
return;
}
const result = room.engine.playCard(socket.id, cardIndex);
if (result.error) {
socket.emit('error', { message: result.error });
return;
}
// 廣播出牌結果
io.to(roomId).emit('cardPlayed', {
playerId: socket.id,
cardIndex,
nextPlayerId: result.nextPlayerId,
gameState: room.engine.getGameState()
});
});
// 質疑
socket.on('challenge', (data) => {
const { roomId } = data;
if (!validateRoom(socket, roomId)) return;
const room = roomManager.getRoom(roomId);
if (!room || !room.engine) {
socket.emit('error', { message: '遊戲未開始' });
return;
}
const result = room.engine.challenge(socket.id);
if (result.error) {
socket.emit('error', { message: result.error });
return;
}
// 廣播質疑結果
io.to(roomId).emit('challenged', {
challengerId: socket.id,
result,
gameState: room.engine.getGameState()
});
});
// 斷線處理
socket.on('disconnect', () => {
console.log(`玩家斷線: ${socket.id}`);
// 清理房間
const removed = roomManager.removePlayer(socket.id);
if (removed) {
io.to(removed.roomId).emit('playerDisconnected', {
playerId: socket.id,
room: removed.room
});
}
});
});
// REST API
app.get('/api/rooms', (req, res) => {
res.json(roomManager.listRooms());
});
app.post('/api/rooms', (req, res) => {
const { hostId } = req.body;
const room = roomManager.createRoom(hostId);
res.json(room);
});
// 啟動伺服器
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`伺服器啟動在 http://localhost:${PORT}`);
});
module.exports = { app, io };
+1479
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "liars-bar-game",
"version": "1.0.0",
"description": "Liars Bar - 線上多人心理博弈遊戲",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js"
},
"dependencies": {
"express": "^4.18.2",
"socket.io": "^4.7.2",
"uuid": "^9.0.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}