forked from karylab/liars-bar-game
Add web game: Node.js backend + Vue.js frontend
This commit is contained in:
+1
-160
@@ -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/
|
||||
|
||||
@@ -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>
|
||||
Generated
+1162
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
@@ -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
@@ -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 };
|
||||
Generated
+1479
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user