Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55ebad932a | ||
|
|
4c9c92af72 | ||
|
|
bb01b53c8f | ||
|
|
75b8ed6073 | ||
|
|
f8b75abc15 | ||
|
|
6711736bd0 | ||
|
|
a8f08e6500 | ||
|
|
0b755fb35d | ||
|
|
a98385fb1d | ||
|
|
d8834b95d3 | ||
|
|
f30c1d9e99 |
+13
-1
@@ -13,7 +13,7 @@ __pycache__/
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
@@ -161,3 +161,15 @@ cython_debug/
|
||||
# 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/
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
|
||||
*.log
|
||||
.env
|
||||
|
||||
# Runtime / generated artifacts
|
||||
game_records/
|
||||
backups/
|
||||
|
||||
@@ -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,213 @@
|
||||
<template>
|
||||
<div class="game-table">
|
||||
<n-card :title="[roomId ? '房間: ' + 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 === gameStore.playerId" type="warning">你</n-tag>
|
||||
</n-space>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
<n-text depth="3">{{ roomPlayers.length }}/8 人</n-text>
|
||||
<n-button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
:disabled="roomPlayers.length < 2"
|
||||
@click="startGame"
|
||||
>
|
||||
開始遊戲
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
<!-- 對局中 -->
|
||||
<template v-else>
|
||||
<n-space v-if="gameState.isGameOver" justify="center">
|
||||
<n-alert type="success" :show-icon="false">
|
||||
遊戲結束,勝者:{{ winnerLabel }}
|
||||
</n-alert>
|
||||
</n-space>
|
||||
|
||||
<!-- 玩家狀態 -->
|
||||
<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' }">
|
||||
{{ shortId(player.id) }}
|
||||
</n-avatar>
|
||||
<div>
|
||||
<div>{{ shortId(player.id) }}</div>
|
||||
<n-tag :type="player.alive ? 'success' : 'error'" size="small">
|
||||
{{ player.alive ? '存活' : '死亡' }}
|
||||
</n-tag>
|
||||
<n-tag size="small">剩 {{ player.handSize }} 張</n-tag>
|
||||
<n-tag size="small">彈倉 {{ player.revolverChambers }}</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">{{ shortId(currentPlayerId) }}</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="你的回合">
|
||||
{{ isMyTurn ? '是' : '否' }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<!-- 挑戰結果提示 -->
|
||||
<n-alert v-if="lastMessage" :type="lastMessageIsError ? 'error' : 'warning'" :show-icon="false">
|
||||
{{ lastMessage }}
|
||||
</n-alert>
|
||||
|
||||
<!-- 質疑翻牌:僅在質疑後揭露真實牌值 -->
|
||||
<div v-if="lastChallenge">
|
||||
<n-text strong>質疑結果(翻牌):</n-text>
|
||||
<n-space>
|
||||
<n-tag v-for="(card, index) in lastChallenge.challengedGroup" :key="index" type="success">
|
||||
{{ card }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
<n-text depth="3" style="display:block">
|
||||
{{ lastChallenge.isBluff ? '是吹牛組' : '是真牌組' }}
|
||||
(出牌者:{{ shortId(lastChallenge.claimantId) }})
|
||||
</n-text>
|
||||
</div>
|
||||
|
||||
<!-- 本手已出的牌(只顯示張數,不洩漏牌值) -->
|
||||
<div v-if="playedGroups.length > 0">
|
||||
<n-text strong>本手已出的牌:</n-text>
|
||||
<n-space>
|
||||
<n-tag v-for="(item, index) in playedGroups" :key="index" type="success">
|
||||
{{ shortId(item.playerId) }}: {{ item.cardCount }} 張
|
||||
</n-tag>
|
||||
</n-space>
|
||||
</div>
|
||||
|
||||
<!-- 你的手牌(點選 1-3 張) -->
|
||||
<div>
|
||||
<n-text strong>你的手牌({{ yourHand.length }} 張):</n-text>
|
||||
<n-space>
|
||||
<n-button
|
||||
v-for="(card, index) in yourHand"
|
||||
:key="index"
|
||||
:type="selected.has(index) ? 'primary' : 'default'"
|
||||
:disabled="!isMyTurn || gameState.isGameOver"
|
||||
size="large"
|
||||
@click="toggleSelect(index)"
|
||||
>
|
||||
{{ card }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<n-text v-if="selected.size > 3" depth="3" style="display:block">
|
||||
最多只能出 3 張
|
||||
</n-text>
|
||||
</div>
|
||||
|
||||
<!-- 動作 -->
|
||||
<n-space justify="center">
|
||||
<n-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:disabled="!isMyTurn || selected.size < 1 || selected.size > 3 || gameState.isGameOver"
|
||||
@click="playSelected"
|
||||
>
|
||||
出牌 ({{ selected.size }})
|
||||
</n-button>
|
||||
<n-button
|
||||
type="error"
|
||||
size="large"
|
||||
:disabled="!isMyTurn || playedGroups.length === 0 || gameState.isGameOver"
|
||||
@click="challenge"
|
||||
>
|
||||
挑戰
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useGameStore } from '../stores/game'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
NCard, NSpace, NList, NListItem, NAvatar, NTag,
|
||||
NDescriptions, NDescriptionsItem, NButton, NH3, NText, NAlert
|
||||
} from 'naive-ui'
|
||||
|
||||
const gameStore = useGameStore()
|
||||
const { roomId, gameState, room, yourHand, lastMessage, lastChallenge } = storeToRefs(gameStore)
|
||||
|
||||
const selected = ref(new Set())
|
||||
|
||||
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 playedGroups = computed(() => gameState.value?.playedGroups || [])
|
||||
|
||||
const isMyTurn = computed(() => currentPlayerId.value === gameStore.playerId)
|
||||
const winnerId = computed(() => gameState.value?.winnerId)
|
||||
const winnerLabel = computed(() => winnerId.value ? shortId(winnerId.value) : '未知')
|
||||
const lastMessageIsError = computed(() => /失敗|吹牛/.test(lastMessage.value || ''))
|
||||
|
||||
// 每局(目標牌或 round 改變)清除選牌
|
||||
watch([targetCard, roundNumber], () => { selected.value = new Set() })
|
||||
|
||||
function shortId(id) {
|
||||
return id ? id.substring(0, 8) + '...' : '-'
|
||||
}
|
||||
|
||||
function toggleSelect(index) {
|
||||
const next = new Set(selected.value)
|
||||
if (next.has(index)) {
|
||||
next.delete(index)
|
||||
} else {
|
||||
if (next.size >= 3) return
|
||||
next.add(index)
|
||||
}
|
||||
selected.value = next
|
||||
}
|
||||
|
||||
function playSelected() {
|
||||
if (selected.value.size < 1 || selected.value.size > 3) return
|
||||
gameStore.playCard([...selected.value])
|
||||
selected.value = new Set()
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
gameStore.startGame()
|
||||
}
|
||||
|
||||
function 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,149 @@
|
||||
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,
|
||||
yourHand: [],
|
||||
lastMessage: '',
|
||||
lastChallenge: null, // { challengedGroup, isBluff, challengerId, shooterId, systemChallenge }
|
||||
isConnected: false,
|
||||
error: null
|
||||
}),
|
||||
|
||||
actions: {
|
||||
// 連線到伺服器
|
||||
connect() {
|
||||
const serverUrl = import.meta.env.VITE_SERVER_URL || 'http://localhost:3000'
|
||||
this.socket = io(serverUrl, {
|
||||
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.roomId = room.id
|
||||
})
|
||||
|
||||
this.socket.on('gameStarted', (gameState) => {
|
||||
this.gameState = gameState
|
||||
this.lastChallenge = null
|
||||
this.lastMessage = ''
|
||||
})
|
||||
|
||||
this.socket.on('cardPlayed', (data) => {
|
||||
this.gameState = data.gameState
|
||||
if (data.systemChallenge) {
|
||||
// 系統質疑在出牌事件一起揭露
|
||||
const sc = data.systemChallenge
|
||||
this.lastChallenge = {
|
||||
systemChallenge: true,
|
||||
challengerId: 'system',
|
||||
claimantId: sc.playerId,
|
||||
shooterId: sc.shooter,
|
||||
isBluff: sc.isBluff,
|
||||
challengedGroup: sc.autoPlayedCards || []
|
||||
}
|
||||
this.lastMessage = sc.isBluff
|
||||
? '系統質疑:該玩家吹牛,被處罰!'
|
||||
: '系統質疑:該玩家說實話,無人受傷。'
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on('challenged', (data) => {
|
||||
this.gameState = data.gameState
|
||||
if (data.result) {
|
||||
const r = data.result
|
||||
this.lastChallenge = {
|
||||
systemChallenge: !!r.systemChallenge,
|
||||
challengerId: r.challengerId,
|
||||
claimantId: r.playerId,
|
||||
shooterId: r.shooter,
|
||||
isBluff: r.isBluff,
|
||||
challengedGroup: r.challengedGroup || []
|
||||
}
|
||||
if (r.systemChallenge) {
|
||||
this.lastMessage = r.isBluff
|
||||
? '系統質疑:該玩家吹牛,被處罰!'
|
||||
: '系統質疑:該玩家說實話,無人受傷。'
|
||||
} else if (r.isBluff) {
|
||||
this.lastMessage = '質疑成功!對方吹牛,開槍處罰。'
|
||||
} else {
|
||||
this.lastMessage = '質疑失敗!對方說的是真話,換你開槍。'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on('playerDisconnected', (data) => {
|
||||
this.room = data.room
|
||||
})
|
||||
|
||||
// 私密手牌:只送給自己
|
||||
this.socket.on('yourHand', (hand) => {
|
||||
this.yourHand = hand || []
|
||||
})
|
||||
},
|
||||
|
||||
// 建立房間
|
||||
createRoom() {
|
||||
this.socket.emit('createRoom')
|
||||
},
|
||||
|
||||
// 加入房間
|
||||
joinRoom(roomId) {
|
||||
this.socket.emit('joinRoom', { roomId })
|
||||
},
|
||||
|
||||
// 開始遊戲
|
||||
startGame() {
|
||||
this.socket.emit('startGame', { roomId: this.roomId })
|
||||
},
|
||||
|
||||
// 出牌
|
||||
playCard(cardIndices) {
|
||||
this.socket.emit('playCard', {
|
||||
roomId: this.roomId,
|
||||
cardIndices
|
||||
})
|
||||
},
|
||||
|
||||
// 質疑
|
||||
challenge() {
|
||||
this.socket.emit('challenge', { roomId: this.roomId })
|
||||
},
|
||||
|
||||
// 斷開連線
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect()
|
||||
this.socket = null
|
||||
this.isConnected = false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.vue"
|
||||
],
|
||||
"references": []
|
||||
}
|
||||
@@ -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,366 @@
|
||||
# AI 繪圖角色一致性完整調查報告
|
||||
|
||||
> **任務**:從 8 個 Q 版女性角色的基礎立繪生成更多表情和動作變體,用於網頁遊戲前端動畫
|
||||
> **調查日期**:2026-07-30
|
||||
> **核心需求**:角色一致性(character consistency)— 保持同一角色的臉、髮型、服裝在不同表情/動作下高度一致
|
||||
|
||||
---
|
||||
|
||||
## 一、工具比較總覽
|
||||
|
||||
| 工具 | 角色一致性 | 姿態控制 | 學習曲線 | 成本 | 需 GPU | 批量生產 | 推薦程度 |
|
||||
|------|:---------:|:-------:|:-------:|------|:-----:|:-------:|:-------:|
|
||||
| **ComfyUI + IP-Adapter** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 高 | 免費 | ✅ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **SD + LoRA** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 中高 | 免費 | ✅ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
|
||||
| **Midjourney v6.1** | ⭐⭐⭐⭐ | ⭐⭐ | 低 | $10-60/月 | ❌ | ⭐⭐ | ⭐⭐⭐ |
|
||||
| **Leonardo AI** | ⭐⭐⭐⭐ | ⭐⭐⭐ | 低 | 免費-$48/月 | ❌ | ⭐⭐⭐ | ⭐⭐⭐ |
|
||||
| **Fooocus** | ⭐⭐⭐ | ⭐⭐ | 最低 | 免費 | ✅ | ⭐⭐ | ⭐⭐ |
|
||||
| **LiblibAI / SeaArt** | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 低中 | 免費-$20/月 | ❌ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
|
||||
|
||||
---
|
||||
|
||||
## 二、角色一致性技術比較
|
||||
|
||||
| 技術 | Q 版適合度 | 需要參考圖 | 學習曲線 | GPU VRAM | 角色一致性 | 關鍵結論 |
|
||||
|------|:---------:|:---------:|:-------:|:--------:|:---------:|---------|
|
||||
| **IP-Adapter** | ★★★★★ | 1 張 | 中等 | 8GB+ | ⭐⭐⭐⭐⭐ | 最佳選擇,1 張圖零訓練即可保持角色一致性 |
|
||||
| **ControlNet (DWpose)** | ★★★★★ | 姿態參考圖 | 中等 | 8GB+ | ⭐⭐⭐⭐⭐ | 姿勢控制核心,DWpose 比 OpenPose 更適合 Q 版比例 |
|
||||
| **LoRA** | ★★★★☆ | 15-30 張 | 中高 | 8GB+ | ⭐⭐⭐⭐⭐ | 效果最佳但需大量訓練圖,可用 IP-Adapter 先生成訓練資料 |
|
||||
| **InstantID** | ★★☆☆☆ | 1 張 | 高 | 12GB+ | ⭐⭐☆☆☆ | 依賴真實人臉識別,Q 版面部無法被 antelopev2 偵測 |
|
||||
| **PhotoMaker** | ★★☆☆☆ | 1-5 張 | 高 | 12GB+ | ⭐⭐☆☆☆ | 同上,不適用 Q 版 |
|
||||
| **ReActor/ROOP** | ★★☆☆☆ | 1 張 | 低 | 4GB+ | ⭐⭐☆☆☆ | 僅換臉,無法保持整體角色風格 |
|
||||
|
||||
### HuggingFace 下載量佐證
|
||||
|
||||
- IP-Adapter-FaceID: **186K 下載**
|
||||
- ControlNet-OpenPose-SDXL: **70K 下載**
|
||||
- InstantID: **54K 下載**
|
||||
|
||||
---
|
||||
|
||||
## 三、最佳推薦方案
|
||||
|
||||
### 🥇 首選:ComfyUI + IP-Adapter + ControlNet DWpose
|
||||
|
||||
**推薦原因:**
|
||||
1. ✅ 1 張立繪即可開始,零訓練成本
|
||||
2. ✅ DWpose 專為 Q 版/動漫比例優化
|
||||
3. ✅ 完全免費、開源
|
||||
4. ✅ 支援批量生產
|
||||
5. ✅ 中文社群資源最強
|
||||
|
||||
**硬體需求:**
|
||||
- 最低:RTX 3060 12GB
|
||||
- 推薦:RTX 4070 12GB+
|
||||
|
||||
**推薦工作流:**
|
||||
```
|
||||
Load Image (角色立繪) → IP-Adapter FaceID Plus v2 → KSampler (SDXL)
|
||||
↗
|
||||
Load Image (姿態參考) → ControlNet DWpose → KSampler
|
||||
```
|
||||
|
||||
### 🥈 備用方案 A:LiblibAI / SeaArt(雲端版 ComfyUI)
|
||||
|
||||
**適用情境**:沒有 GPU 或不想安裝複雜環境
|
||||
|
||||
**優點:**
|
||||
- 雲端運行,無需 GPU
|
||||
- 內建 IP-Adapter + ControlNet,角色一致性接近 ComfyUI
|
||||
- LiblibAI 是中國平台,中文介面 + 大量中文模型
|
||||
- 免費層可先測試效果
|
||||
|
||||
### 🥉 備用方案 B:Midjourney v6.1(快速原型)
|
||||
|
||||
**適用情境**:快速測試角色變體概念,不做大量生產
|
||||
|
||||
**缺點**:無法精確控制姿態(對遊戲動畫是致命傷);Q 版角色 `--cref` 效果不穩定
|
||||
|
||||
---
|
||||
|
||||
## 四、批量生產工作流
|
||||
|
||||
### 階段 1:快速驗證(1-2 天)
|
||||
|
||||
```
|
||||
├─ 安裝 ComfyUI + ComfyUI-Manager
|
||||
├─ 下載必要模型:
|
||||
│ ├─ 底層模型:SDXL 1.0 或動漫專用模型(Anything V5、CounterfeitXL)
|
||||
│ ├─ IP-Adapter:ip-adapter-plus-face_sdxl_vit-h.bin
|
||||
│ └─ ControlNet:controlnet-dwpose-sdxl
|
||||
├─ 選 1-2 個角色測試(建議先試 Grok-4 和 Deepseek)
|
||||
├─ 確認角色一致性效果是否達標
|
||||
└─ 若不達標 → 切換 LiblibAI 雲端測試
|
||||
```
|
||||
|
||||
### 階段 2:批量生產(3-5 天)
|
||||
|
||||
```
|
||||
├─ 為 8 個角色各建立 ComfyUI 工作流
|
||||
├─ 定義表情清單:
|
||||
│ ├─ 開心、生氣、疑惑、恐懼、得意、無聊(6 種)
|
||||
├─ 定義動作清單:
|
||||
│ ├─ 出牌、質疑、舉槍、中槍、勝利、失敗(6 種)
|
||||
├─ 每個角色共 12 張變體圖
|
||||
├─ 8 角色 × 12 張 = 96 張(約 100 張)
|
||||
├─ 批次生成所有變體
|
||||
└─ 篩選、後處理
|
||||
```
|
||||
|
||||
### 階段 3(可選):LoRA 訓練(長期投資)
|
||||
|
||||
```
|
||||
├─ 用階段 2 的產出作為訓練素材
|
||||
├─ 為每個角色訓練專屬 LoRA
|
||||
└─ 之後可無限生成新變體,一致性更高
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、Prompt 工程指南
|
||||
|
||||
### 角色描述 Prompt 模板
|
||||
|
||||
```
|
||||
[角色特徵描述], [表情], [動作], [背景], [風格]
|
||||
|
||||
範例(Grok-4):
|
||||
chibi girl, orange wavy hair, yellow sleeveless dress, large red bow on head,
|
||||
big smile expression, holding cards in hand, white background,
|
||||
anime style, cel shading, cute, kawaii
|
||||
```
|
||||
|
||||
### 表情 Prompt 清單
|
||||
|
||||
| 表情 | Prompt 關鍵字 |
|
||||
|------|--------------|
|
||||
| 開心 | big smile, happy, joyful, laughing |
|
||||
| 生氣 | angry, frowning, mad, crossed arms |
|
||||
| 疑惑 | confused, tilted head, questioning, thinking |
|
||||
| 恐懼 | scared, terrified, wide eyes, trembling |
|
||||
| 得意 | smug, cocky, confident smirk, arms crossed |
|
||||
| 無聊 | bored, deadpan, blank stare, yawning |
|
||||
|
||||
### 動作 Prompt 清單
|
||||
|
||||
| 動作 | Prompt 關鍵字 |
|
||||
|------|--------------|
|
||||
| 出牌 | playing cards, holding cards, dealing cards |
|
||||
| 質疑 | pointing finger, accusing, challenging pose |
|
||||
| 舉槍 | holding revolver, pointing gun, russian roulette |
|
||||
| 中槍 | shocked, falling back, clutching head |
|
||||
| 勝利 | celebrating, raising arms, triumphant pose |
|
||||
| 失敗 | defeated, slumped, sad pose, head down |
|
||||
|
||||
---
|
||||
|
||||
## 六、Seed 控制策略
|
||||
|
||||
### 保持角色一致性的 Seed 策略
|
||||
|
||||
1. **固定 Seed + 固定 IP-Adapter 權重**:
|
||||
- 同一角色的所有變體使用相同 seed
|
||||
- IP-Adapter weight 設為 0.7-0.9
|
||||
- 僅改變 prompt 中的表情/動作描述
|
||||
|
||||
2. **Seed 範圍搜索**:
|
||||
- 找到一個好的 seed 後,在 ±100 範圍內搜索
|
||||
- 例如 seed=12345 效果好,則測試 12245-12445
|
||||
|
||||
3. **Seed 記錄表**:
|
||||
```
|
||||
角色 | 最佳 Seed | IP-Adapter Weight | ControlNet Strength
|
||||
Grok-4 | 12345 | 0.8 | 0.8
|
||||
Kimi | 67890 | 0.75 | 0.85
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、批量生成腳本
|
||||
|
||||
### ComfyUI REST API 批量生成
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
batch_generate.py — 批量生成角色表情/動作變體
|
||||
|
||||
使用 ComfyUI REST API 自動執行工作流
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
|
||||
COMFYUI_URL = "http://12.0.0.1:8188"
|
||||
|
||||
def submit_workflow(workflow_json):
|
||||
"""提交工作流到 ComfyUI"""
|
||||
response = requests.post(
|
||||
f"{COMFYUI_URL}/prompt",
|
||||
json={"prompt": workflow_json}
|
||||
)
|
||||
return response.json()["prompt_id"]
|
||||
|
||||
def get_image(prompt_id):
|
||||
"""等待生成完成並取得圖片"""
|
||||
while True:
|
||||
ws = requests.get(f"{COMFYUI_URL}/history/{prompt_id}")
|
||||
if ws.json():
|
||||
return ws.json()[prompt_id]["outputs"]
|
||||
time.sleep(1)
|
||||
|
||||
# 定義 8 個角色的 prompt 模板
|
||||
CHARACTERS = {
|
||||
"Grok-4": "chibi girl, orange wavy hair, yellow sleeveless dress, large red bow on head, ...",
|
||||
"Kimi": "chibi girl, silver long straight hair, dark blue dress with white collar, choker, ...",
|
||||
# ... 其他角色
|
||||
}
|
||||
|
||||
EXPRESSIONS = ["happy", "angry", "confused", "scared", "smug", "bored"]
|
||||
ACTIONS = ["playing_cards", "accusing", "holding_revolver", "shocked", "celebrating", "defeated"]
|
||||
|
||||
# 批量生成
|
||||
for char_name, char_desc in CHARACTERS.items():
|
||||
for expr in EXPRESSIONS:
|
||||
for action in ACTIONS:
|
||||
prompt = f"{char_desc}, {expr} expression, {action} pose, white background, anime style"
|
||||
# 修改 workflow_json 中的 prompt 節點
|
||||
workflow_json["6"]["inputs"]["text"] = prompt
|
||||
prompt_id = submit_workflow(workflow_json)
|
||||
print(f"生成中: {char_name} - {expr} - {action}")
|
||||
time.sleep(2) # 等待 GPU 處理
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、品質篩選流程
|
||||
|
||||
### 自動篩選標準
|
||||
|
||||
| 檢查項目 | 標準 | 工具 |
|
||||
|---------|------|------|
|
||||
| 解析度 | 768x768 以上 | 檔案屬性檢查 |
|
||||
| 角色識別 | 面部特徵匹配度 > 80% | CLIP 影像嵌入比對 |
|
||||
| 表情正確性 | 表情關鍵字匹配 | 手動檢查 |
|
||||
| 動作正確性 | 姿態匹配度 > 70% | OpenPose 偵測比對 |
|
||||
| 無畸形 | 手部、臉部無明顯變形 | 手動檢查 |
|
||||
|
||||
### 手動篩選清單
|
||||
|
||||
- [ ] 角色髮色/髮型是否正確?
|
||||
- [ ] 角色服裝是否正確?
|
||||
- [ ] 表情是否符合預期?
|
||||
- [ ] 動作是否符合預期?
|
||||
- [ ] 手部是否畸形?
|
||||
- [ ] 臉部是否變形?
|
||||
- [ ] 配件是否遺漏?
|
||||
|
||||
---
|
||||
|
||||
## 九、後處理流程
|
||||
|
||||
### 工具推薦
|
||||
|
||||
| 步驟 | 工具 | 說明 |
|
||||
|------|------|------|
|
||||
| 背景移除 | remove.bg / RemBG | 自動移除背景,輸出透明 PNG |
|
||||
| 裁切 | ImageMagick / Python PIL | 統一裁切至 512x512 或 768x768 |
|
||||
| 格式轉換 | ImageMagick | 轉換為 WebP 格式(網頁優化) |
|
||||
| 超解析度 | Real-ESRGAN | 將低解析度圖片提升至 4K |
|
||||
|
||||
### 後處理腳本範例
|
||||
|
||||
```bash
|
||||
# 批量移除背景
|
||||
python -m rembg i input/ -o output/
|
||||
|
||||
# 批量裁切並轉換格式
|
||||
magick mogrify -resize 512x512 -format webp output/*.png
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、成本效益分析
|
||||
|
||||
### 時間成本估算
|
||||
|
||||
| 階段 | 時間 | 說明 |
|
||||
|------|------|------|
|
||||
| 環境設定 | 1-2 天 | 安裝 ComfyUI、下載模型、設定工作流 |
|
||||
| 快速驗證 | 1-2 天 | 測試 1-2 個角色,確認效果 |
|
||||
| 批量生產 | 3-5 天 | 生成 100 張圖,每張約 10-30 秒 |
|
||||
| 篩選後處理 | 1-2 天 | 手動篩選、背景移除、格式轉換 |
|
||||
| **總計** | **6-11 天** | |
|
||||
|
||||
### 金錢成本估算
|
||||
|
||||
| 方案 | 硬體成本 | 月費 | 總成本 |
|
||||
|------|---------|------|--------|
|
||||
| ComfyUI(自架) | RTX 3060 12GB: ~$300 | $0 | $300 |
|
||||
| LiblibAI(雲端) | $0 | 免費層 | $0(測試) |
|
||||
| LiblibAI(雲端) | $0 | 付費層 $15/月 | $30(2 個月) |
|
||||
| Midjourney | $0 | $10/月 | $20(2 個月) |
|
||||
|
||||
### 推薦方案
|
||||
|
||||
**有 GPU**:ComfyUI + IP-Adapter + ControlNet(一次性投資 $300,之後免費)
|
||||
|
||||
**無 GPU**:LiblibAI 免費層測試 → 確認效果 → 付費層批量生產($30)
|
||||
|
||||
---
|
||||
|
||||
## 十一、8 角色具體生產計畫
|
||||
|
||||
### 角色清單
|
||||
|
||||
| 編號 | AI 名稱 | 髮色 | 服裝 | 配件 |
|
||||
|------|---------|------|------|------|
|
||||
| 1 | Grok-4 | 波浪橘髮 | 黃色無袖洋裝 | 紅色大蝴蝶結 |
|
||||
| 2 | Kimi | 白銀長直髮 | 深藍洋裝+白領 | 項圈 |
|
||||
| 3 | Qwen3 | 粉紅短髮 | 灰色無袖洋裝 | 圓眼鏡 |
|
||||
| 4 | GPT-5 | 黑色長直髮 | 黑色洋裝 | 無 |
|
||||
| 5 | Gemini | 紫色短髮 | 淺藍洋裝 | 圓眼鏡+星星髮夾 |
|
||||
| 6 | Deepseek | 淺藍短髮 | 深藍灰洋裝 | 鯨魚尾巴 |
|
||||
| 7 | Doubao | 深棕短髮 | 黑色洋裝 | 無 |
|
||||
| 8 | Claude | 橘色短髮 | 橘色洋裝 | 無 |
|
||||
|
||||
### 生產排程
|
||||
|
||||
| 天數 | 任務 | 產出 |
|
||||
|------|------|------|
|
||||
| Day 1-2 | 環境設定 + 模型下載 | ComfyUI 就緒 |
|
||||
| Day 3-4 | 測試 Grok-4 + Deepseek | 確認效果 |
|
||||
| Day 5-7 | 批量生產 4 角色(Grok/Kimi/Qwen/GPT-5) | 48 張圖 |
|
||||
| Day 8-10 | 批量生產 4 角色(Gemini/Deepseek/Doubao/Claude) | 48 張圖 |
|
||||
| Day 11-12 | 篩選 + 後處理 | 96 張最終圖 |
|
||||
|
||||
---
|
||||
|
||||
## 十二、資源連結
|
||||
|
||||
- **ComfyUI 官方**:https://github.com/Comfy-Org/ComfyUI
|
||||
- **ComfyUI 管理器**:https://github.com/ltdrdata/ComfyUI-Manager
|
||||
- **IP-Adapter 官方**:https://github.com/tencent-ailab/IP-Adapter
|
||||
- **ControlNet**:https://github.com/lllyasviel/ControlNet
|
||||
- **LiblibAI**:https://www.liblib.art/
|
||||
- **SeaArt**:https://www.seaart.ai/
|
||||
- **Civitai(模型下載)**:https://civitai.com/
|
||||
- **Bilibili ComfyUI 教學**:搜尋「ComfyUI 角色一致性」
|
||||
|
||||
---
|
||||
|
||||
## 十三、常見陷阱與解決方案
|
||||
|
||||
| 陷阱 | 原因 | 解決方案 |
|
||||
|------|------|---------|
|
||||
| 角色不一致 | IP-Adapter weight 太低 | 調高至 0.7-0.9 |
|
||||
| 姿態不正確 | ControlNet strength 太低 | 調高至 0.7-0.9 |
|
||||
| 手部畸形 | SD 模型對手部支援差 | 使用 Hand Refiner 或手動修復 |
|
||||
| 臉部變形 | IP-Adapter 與底層模型不兼容 | 使用 FaceID Plus v2 |
|
||||
| 圖片模糊 | 解析度太低 | 使用 Real-ESRGAN 超解析 |
|
||||
| 生成速度慢 | GPU VRAM 不足 | 降低解析度或使用 --lowvram |
|
||||
|
||||
---
|
||||
|
||||
**報告完成**。下一步:確認是否開始安裝 ComfyUI 環境,或先使用 LiblibAI 雲端測試。
|
||||
@@ -0,0 +1,821 @@
|
||||
# ComfyUI 角色一致性繪圖 — 完整操作文檔
|
||||
|
||||
> **目標**:使用 RTX A4000 + Windows 11,為 8 個 Q 版角色各生成 12-15 張表情/動作變體(總計約 100 張)
|
||||
>
|
||||
> **技術方案**:ComfyUI + IP-Adapter + ControlNet DWpose
|
||||
>
|
||||
> **硬體**:RTX A4000 (16GB VRAM) + Windows 11
|
||||
>
|
||||
> **預估時間**:環境設定 2-3 小時,批量生產 3-5 小時
|
||||
|
||||
---
|
||||
|
||||
## 📋 目錄
|
||||
|
||||
1. [Plan A:一鍵安裝(推薦)](#1-plan-a一鍵安裝推薦)
|
||||
2. [Plan B:手動安裝(進階)](#2-plan-b手動安裝進階)
|
||||
3. [安裝擴充套件](#3-安裝擴充套件)
|
||||
4. [下載模型權重](#4-下載模型權重)
|
||||
5. [第一次測試](#5-第一次測試)
|
||||
6. [IP-Adapter 角色一致性工作流](#6-ip-adapter-角色一致性工作流)
|
||||
7. [ControlNet DWpose 姿勢控制](#7-controlnet-dwpose-姿勢控制)
|
||||
8. [批量生產流程](#8-批量生產流程)
|
||||
9. [Prompt 工程指南](#9-prompt-工程指南)
|
||||
10. [品質篩選與後處理](#10-品質篩選與後處理)
|
||||
11. [常見問題與解決方案](#11-常見問題與解決方案)
|
||||
12. [資源連結](#12-資源連結)
|
||||
|
||||
---
|
||||
|
||||
## 1. Plan A:一鍵安裝(推薦)
|
||||
|
||||
> ✅ **適合對象**:不想裝 Python、不想打命令列、想最快開始的人
|
||||
>
|
||||
> ⏱️ **預估時間**:15 分鐘
|
||||
|
||||
### 1.1 確認 NVIDIA 驅動
|
||||
|
||||
按 `Win + R`,輸入 `cmd`,執行:
|
||||
|
||||
```
|
||||
nvidia-smi
|
||||
```
|
||||
|
||||
**要求**:驅動版本 ≥ 537.xx,CUDA 版本 ≥ 12.1
|
||||
|
||||
若版本過舊,前往 NVIDIA 官網下載:
|
||||
- https://www.nvidia.com/Download/index.aspx
|
||||
|
||||
### 1.2 下載 ComfyUI 官方 Portable 版
|
||||
|
||||
前往 Release 頁面:
|
||||
- https://github.com/comfyanonymous/ComfyUI/releases
|
||||
|
||||
下載 **ComfyUI_windows_portable_nvidia.7z**(約 2GB)
|
||||
|
||||
### 1.3 解壓縮
|
||||
|
||||
1. 安裝 7-Zip(若沒有):https://www.7-zip.org/
|
||||
2. 右鍵點擊下載的 `.7z` 檔案 → 「解壓縮到 ComfyUI_windows_portable_nvidia\」
|
||||
3. 將整個資料夾移動到 `D:\ComfyUI_Setup\`
|
||||
|
||||
### 1.4 啟動
|
||||
|
||||
進入 `D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\`,雙擊:
|
||||
|
||||
```
|
||||
run_nvidia_gpu.bat
|
||||
```
|
||||
|
||||
這會自動:
|
||||
- 啟動 Python 虛擬環境
|
||||
- 啟動 ComfyUI 伺服器
|
||||
- 開啟瀏覽器到 `http://127.0.0.1:8188`
|
||||
|
||||
**看到 ComfyUI 介面即表示成功!** 🎉
|
||||
|
||||
### 1.5 後續啟動
|
||||
|
||||
以後每次使用,只要雙擊 `run_nvidia_gpu.bat` 即可。
|
||||
|
||||
---
|
||||
|
||||
## 2. Plan B:手動安裝(進階)
|
||||
|
||||
> ⚠️ **適合對象**:想要完全控制環境、需要自訂 Python 版本、或想從源碼編譯的人
|
||||
>
|
||||
> ⏱️ **預估時間**:45-60 分鐘
|
||||
|
||||
### 2.1 確認 NVIDIA 驅動
|
||||
|
||||
```powershell
|
||||
nvidia-smi
|
||||
```
|
||||
|
||||
**要求**:驅動版本 ≥ 537.xx,CUDA 版本 ≥ 12.1
|
||||
|
||||
### 2.2 安裝 Python
|
||||
|
||||
下載 Python **3.10** 或 **3.11**(不要 3.12+,部分擴充套件不相容)
|
||||
|
||||
- 官網:https://www.python.org/downloads/
|
||||
- 安裝時勾選 **「Add Python to PATH」**
|
||||
|
||||
```powershell
|
||||
python --version
|
||||
# 應顯示 Python 3.10.x 或 3.11.x
|
||||
```
|
||||
|
||||
### 2.3 準備工作目錄
|
||||
|
||||
```powershell
|
||||
D:
|
||||
mkdir \ComfyUI_Setup
|
||||
cd \ComfyUI_Setup
|
||||
```
|
||||
|
||||
### 2.4 下載 ComfyUI
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/comfyanonymous/ComfyUI.git
|
||||
cd ComfyUI
|
||||
```
|
||||
|
||||
### 2.5 建立虛擬環境
|
||||
|
||||
```powershell
|
||||
python -m venv venv
|
||||
.\venv\Scripts\activate
|
||||
```
|
||||
|
||||
### 2.6 安裝 PyTorch(含 CUDA 支援)
|
||||
|
||||
```powershell
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||
```
|
||||
|
||||
**驗證 CUDA 是否可用**:
|
||||
|
||||
```powershell
|
||||
python -c "import torch; print(torch.cuda.is_available()); print(torch.cuda.get_device_name(0))"
|
||||
# 應輸出: True
|
||||
# NVIDIA RTX A4000
|
||||
```
|
||||
|
||||
### 2.7 安裝 ComfyUI 依賴
|
||||
|
||||
```powershell
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2.8 啟動測試
|
||||
|
||||
```powershell
|
||||
python main.py
|
||||
```
|
||||
|
||||
開啟瀏覽器訪問 `http://127.0.0.1:8188`,看到 ComfyUI 介面即表示成功。
|
||||
|
||||
### 2.9 後續啟動
|
||||
|
||||
```powershell
|
||||
cd D:\ComfyUI_Setup\ComfyUI
|
||||
.\venv\Scripts\activate
|
||||
python main.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 安裝擴充套件
|
||||
|
||||
> 以下步驟 Plan A 和 Plan B 都適用
|
||||
|
||||
### 3.1 安裝 ComfyUI-Manager(套件管理器)
|
||||
|
||||
**方法一:透過 Manager 一鍵安裝(推薦)**
|
||||
|
||||
1. 開啟 ComfyUI 介面
|
||||
2. 點擊右上角 **「Manager」**
|
||||
3. 搜尋 **「ComfyUI-Manager」**
|
||||
4. 點擊 **「Install」**
|
||||
|
||||
**方法二:手動安裝**
|
||||
|
||||
```powershell
|
||||
cd D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\custom_nodes
|
||||
git clone https://github.com/ltdrdata/ComfyUI-Manager.git
|
||||
```
|
||||
|
||||
**重啟 ComfyUI** 後,介面右上角會出現「Manager」按鈕。
|
||||
|
||||
### 3.2 安裝 IP-Adapter 擴充套件
|
||||
|
||||
**方法一:透過 Manager**
|
||||
|
||||
1. 點擊 **「Manager」** → **「Install Custom Nodes」**
|
||||
2. 搜尋 **「ComfyUI_IPAdapter_plus」**
|
||||
3. 點擊 **「Install」**
|
||||
|
||||
**方法二:手動安裝**
|
||||
|
||||
```powershell
|
||||
cd D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\custom_nodes
|
||||
git clone https://github.com/cubiq/ComfyUI_IPAdapter_plus.git
|
||||
```
|
||||
|
||||
### 3.3 安裝 ControlNet 輔助工具
|
||||
|
||||
**方法一:透過 Manager**
|
||||
|
||||
1. 搜尋 **「comfyui_controlnet_aux」**
|
||||
2. 點擊 **「Install」**
|
||||
|
||||
**方法二:手動安裝**
|
||||
|
||||
```powershell
|
||||
cd D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\custom_nodes
|
||||
git clone https://github.com/Fannovel16/comfyui_controlnet_aux.git
|
||||
```
|
||||
|
||||
安裝依賴:
|
||||
|
||||
```powershell
|
||||
# Plan A 使用者:在 ComfyUI 資料夾內執行 python.exe
|
||||
D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\python_embeded\python.exe -m pip install onnxruntime-gpu
|
||||
|
||||
# Plan B 使用者:
|
||||
pip install onnxruntime-gpu
|
||||
```
|
||||
|
||||
### 3.4 安裝 Impact Pack(進階功能,可選)
|
||||
|
||||
**方法一:透過 Manager**
|
||||
|
||||
1. 搜尋 **「ComfyUI-Impact-Pack」**
|
||||
2. 點擊 **「Install」**
|
||||
|
||||
**方法二:手動安裝**
|
||||
|
||||
```powershell
|
||||
cd D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\custom_nodes
|
||||
git clone https://github.com/ltdrdata/ComfyUI-Impact-Pack.git
|
||||
```
|
||||
|
||||
### 3.5 重新安裝依賴
|
||||
|
||||
安裝完擴充套件後,重啟 ComfyUI,Manager 會提示安裝缺失的依賴,點擊 **「Install」** 即可。
|
||||
|
||||
### 3.6 重啟 ComfyUI
|
||||
|
||||
關閉 ComfyUI 視窗,重新啟動。
|
||||
|
||||
---
|
||||
|
||||
## 4. 下載模型權重
|
||||
|
||||
> 以下步驟 Plan A 和 Plan B 都適用
|
||||
|
||||
### 4.1 SDXL 主模型
|
||||
|
||||
下載 **SDXL 1.0** 或 **SDXL Turbo**(推薦 SDXL 1.0 品質更好)
|
||||
|
||||
**推薦模型**:
|
||||
- `sd_xl_base_1.0.safetensors`(官方)
|
||||
- `juggernautXL_v9.safetensors`(社群優化版)
|
||||
- `realvisxlV40.safetensors`(寫實風格)
|
||||
|
||||
**下載位置**:
|
||||
```
|
||||
D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\models\checkpoints\
|
||||
```
|
||||
|
||||
**下載來源**:
|
||||
- HuggingFace: https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0
|
||||
- Civitai: https://civitai.com/models/47707/juggernaut-xl
|
||||
|
||||
### 4.2 IP-Adapter 權重
|
||||
|
||||
下載 **IP-Adapter Plus SDXL** 系列:
|
||||
|
||||
```
|
||||
D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\models\ipadapter\
|
||||
```
|
||||
|
||||
**需要下載的檔案**:
|
||||
- `ip-adapter-plus_sdxl_vit-h.safetensors`
|
||||
- `ip-adapter-plus-face_sdxl_vit-h.safetensors`
|
||||
|
||||
**下載來源**:
|
||||
- HuggingFace: https://huggingface.co/h94/IP-Adapter-plus/tree/main/sdxl_models
|
||||
|
||||
### 4.3 ControlNet 權重
|
||||
|
||||
下載 **ControlNet DWpose SDXL**:
|
||||
|
||||
```
|
||||
D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\models\controlnet\
|
||||
```
|
||||
|
||||
**需要下載的檔案**:
|
||||
- `controlnet-diffusers-dw-lllite-xl-diffusers-rank256.safetensors`
|
||||
- 或 `thibaud/controlnet-openpose-sdxl-1.0`
|
||||
|
||||
**下載來源**:
|
||||
- HuggingFace: https://huggingface.co/Thibaud/controlnet-openpose-sdxl-1.0
|
||||
|
||||
### 4.4 VAE(可選)
|
||||
|
||||
```
|
||||
D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\models\vae\
|
||||
```
|
||||
|
||||
**下載**:`sdxl_vae.safetensors`
|
||||
|
||||
### 4.5 檔案結構確認
|
||||
|
||||
```
|
||||
D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\
|
||||
├── models\
|
||||
│ ├── checkpoints\
|
||||
│ │ └── sd_xl_base_1.0.safetensors
|
||||
│ ├── ipadapter\
|
||||
│ │ ├── ip-adapter-plus_sdxl_vit-h.safetensors
|
||||
│ │ └── ip-adapter-plus-face_sdxl_vit-h.safetensors
|
||||
│ ├── controlnet\
|
||||
│ │ └── controlnet-diffusers-dw-lllite-xl-diffusers-rank256.safetensors
|
||||
│ └── vae\
|
||||
│ └── sdxl_vae.safetensors
|
||||
└── output\
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 第一次測試
|
||||
|
||||
### 5.1 載入預設工作流
|
||||
|
||||
1. 開啟 `http://127.0.0.1:8188`
|
||||
2. 點擊左上角 **「Load」** 載入預設工作流
|
||||
3. 或手動建立以下節點:
|
||||
|
||||
### 5.2 基本生成測試
|
||||
|
||||
```
|
||||
Load Checkpoint → CLIP Text Encode (Positive) → CLIP Text Encode (Negative) → EmptyLatentImage → KSampler → VAEDecode → Save Image
|
||||
```
|
||||
|
||||
**測試 Prompt**:
|
||||
|
||||
```
|
||||
Positive: a cute chibi girl, blue hair, smiling, white background, high quality
|
||||
Negative: ugly, deformed, noisy, blurry, low quality, worst quality
|
||||
```
|
||||
|
||||
**KSampler 參數**:
|
||||
- Model: sd_xl_base_1.0
|
||||
- Sampler: dpmpp_2m
|
||||
- Scheduler: karras
|
||||
- Steps: 20-30
|
||||
- CFG: 5-7
|
||||
- Seed: random
|
||||
- Width: 1024
|
||||
- Height: 1024
|
||||
|
||||
點擊 **「Queue Prompt」** 開始生成。
|
||||
|
||||
---
|
||||
|
||||
## 6. IP-Adapter 角色一致性工作流
|
||||
|
||||
### 6.1 工作流結構
|
||||
|
||||
```
|
||||
Load Checkpoint
|
||||
↓
|
||||
CLIP Vision Loader (ip-adapter-plus-face_sdxl_vit-h.bin)
|
||||
↓
|
||||
IPAdapter Model Loader
|
||||
↓
|
||||
Load Image (角色立繪)
|
||||
↓
|
||||
IPAdapter Apply
|
||||
↓
|
||||
KSampler
|
||||
↓
|
||||
Save Image
|
||||
```
|
||||
|
||||
### 6.2 節點設定
|
||||
|
||||
**CLIP Vision Loader**:
|
||||
- ckpt_name: `ip-adapter-plus-face_sdxl_vit-h.bin`
|
||||
|
||||
**IPAdapter Model Loader**:
|
||||
- ipadapter_file: `ip-adapter-plus-face_sdxl_vit-h.safetensors`
|
||||
|
||||
**IPAdapter Apply**:
|
||||
- weight: **0.7 - 0.9**(角色一致性越高,但創意自由度越低)
|
||||
- weight_type: `default`
|
||||
- start_at: 0
|
||||
- end_at: 1
|
||||
- encode_steps: 10
|
||||
|
||||
**Load Image**:
|
||||
- 上傳角色的立繪圖片(建議 1024x1024)
|
||||
|
||||
### 6.3 IP-Adapter Weight 調整指南
|
||||
|
||||
| Weight | 效果 | 適用情境 |
|
||||
|--------|------|---------|
|
||||
| 0.5-0.6 | 角色特徵保留約 60% | 大動作、大表情變化 |
|
||||
| 0.7-0.8 | 角色特徵保留約 80% | **推薦起始值** |
|
||||
| 0.9-1.0 | 角色特徵保留約 95% | 微小表情變化 |
|
||||
|
||||
---
|
||||
|
||||
## 7. ControlNet DWpose 姿勢控制
|
||||
|
||||
### 7.1 工作流結構
|
||||
|
||||
```
|
||||
Load Checkpoint
|
||||
↓
|
||||
CLIP Vision Loader
|
||||
↓
|
||||
IPAdapter Model Loader
|
||||
↓
|
||||
Load Image (角色立繪)
|
||||
↓
|
||||
IPAdapter Apply
|
||||
↓
|
||||
OpenPose Encode (DWpose)
|
||||
↓
|
||||
Load Image (姿勢參考圖)
|
||||
↓
|
||||
ControlNet Apply
|
||||
↓
|
||||
KSampler
|
||||
↓
|
||||
Save Image
|
||||
```
|
||||
|
||||
### 7.2 DWpose 設定
|
||||
|
||||
**OpenPose Encode**:
|
||||
- 使用 DWpose 模型(內建於 comfyui_controlnet_aux)
|
||||
- 輸入:姿勢參考圖(可以是任何人的照片或繪圖)
|
||||
|
||||
**ControlNet Apply**:
|
||||
- strength: **0.7 - 0.9**
|
||||
- start_percent: 0
|
||||
- end_percent: 1
|
||||
|
||||
### 7.3 姿勢參考圖來源
|
||||
|
||||
1. **手繪草圖**:簡單畫出想要的姿勢
|
||||
2. **網路圖片**:搜尋動漫角色姿勢
|
||||
3. **AI 生成**:先用 ComfyUI 生成姿勢,再套用角色
|
||||
|
||||
---
|
||||
|
||||
## 8. 批量生產流程
|
||||
|
||||
### 8.1 準備工作
|
||||
|
||||
1. 建立 8 個角色的資料夾:
|
||||
```
|
||||
D:\ComfyUI_Setup\ComfyUI_windows_portable_nvidia\ComfyUI\input\characters\
|
||||
├── grok4\
|
||||
│ └── reference.png
|
||||
├── kimi\
|
||||
│ └── reference.png
|
||||
├── qwen3\
|
||||
│ └── reference.png
|
||||
├── gpt5\
|
||||
│ └── reference.png
|
||||
├── gemini\
|
||||
│ └── reference.png
|
||||
├── deepseek\
|
||||
│ └── reference.png
|
||||
├── doubao\
|
||||
│ └── reference.png
|
||||
└── claude\
|
||||
└── reference.png
|
||||
```
|
||||
|
||||
2. 將 8 個角色的立繪分別放入對應資料夾
|
||||
|
||||
### 8.2 批量生成腳本
|
||||
|
||||
建立 `batch_generate.py`:
|
||||
|
||||
```python
|
||||
import requests
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
COMFYUI_API = "http://127.0.0.1:8188"
|
||||
|
||||
# 表情/動作清單
|
||||
EXPRESSIONS = [
|
||||
"smiling happily",
|
||||
"laughing loudly",
|
||||
"crying with tears",
|
||||
"angry and frowning",
|
||||
"surprised with wide eyes",
|
||||
"thinking with hand on chin",
|
||||
"winking playfully",
|
||||
"shocked and speechless",
|
||||
"blushing shyly",
|
||||
"determined and focused",
|
||||
"sleeping peacefully",
|
||||
"screaming in horror"
|
||||
]
|
||||
|
||||
def queue_prompt(workflow):
|
||||
"""發送工作流到 ComfyUI"""
|
||||
data = {"prompt": workflow}
|
||||
response = requests.post(COMFYUI_API + "/prompt", json=data)
|
||||
return response.json()["prompt_id"]
|
||||
|
||||
def get_image(prompt_id):
|
||||
"""等待生成完成並取得圖片"""
|
||||
while True:
|
||||
response = requests.get(COMFYUI_API + "/history/" + prompt_id)
|
||||
if response.json()[prompt_id] is not None:
|
||||
break
|
||||
time.sleep(1)
|
||||
return response.json()[prompt_id]["outputs"]
|
||||
|
||||
def generate_character(character_name, expression, ip_weight=0.8):
|
||||
"""生成單一角色的單一表情"""
|
||||
workflow = {
|
||||
"3": { # Load Checkpoint
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": "sd_xl_base_1.0.safetensors"}
|
||||
},
|
||||
"10": { # CLIP Vision Loader
|
||||
"class_type": "CLIPVisionLoader",
|
||||
"inputs": {"clip_name": "ip-adapter-plus-face_sdxl_vit-h.bin"}
|
||||
},
|
||||
"11": { # IPAdapter Model Loader
|
||||
"class_type": "IPAdapterModelLoader",
|
||||
"inputs": {"ipadapter_file": "ip-adapter-plus-face_sdxl_vit-h.safetensors"}
|
||||
},
|
||||
"12": { # Load Image (角色立繪)
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": f"characters/{character_name}/reference.png",
|
||||
"upload": f"characters/{character_name}/reference.png"
|
||||
}
|
||||
},
|
||||
"13": { # IPAdapter Apply
|
||||
"class_type": "IPAdapterApply",
|
||||
"inputs": {
|
||||
"weight": ip_weight,
|
||||
"weight_type": "default",
|
||||
"start_at": 0,
|
||||
"end_at": 1
|
||||
}
|
||||
},
|
||||
"6": { # Positive Prompt
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": f"a cute chibi girl, {expression}, white background, high quality, detailed face",
|
||||
"clip": ["3", 1]
|
||||
}
|
||||
},
|
||||
"7": { # Negative Prompt
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "ugly, deformed, noisy, blurry, low quality, worst quality, extra limbs",
|
||||
"clip": ["3", 1]
|
||||
}
|
||||
},
|
||||
"5": { # Empty Latent Image
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 1024, "height": 1024, "batch_size": 1}
|
||||
},
|
||||
"14": { # KSampler
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["13", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["5", 0],
|
||||
"seed": 42,
|
||||
"steps": 25,
|
||||
"cfg": 6,
|
||||
"sampler_name": "dpmpp_2m",
|
||||
"scheduler": "karras",
|
||||
"denoise": 1
|
||||
}
|
||||
},
|
||||
"15": { # VAEDecode
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["14", 0], "vae": ["3", 2]}
|
||||
},
|
||||
"16": { # Save Image
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": ["15", 0],
|
||||
"filename_prefix": f"{character_name}_{expression.replace(' ', '_')}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prompt_id = queue_prompt(workflow)
|
||||
print(f"Generating: {character_name} - {expression}")
|
||||
return get_image(prompt_id)
|
||||
|
||||
# 批量生成
|
||||
characters = ["grok4", "kimi", "qwen3", "gpt5", "gemini", "deepseek", "doubao", "claude"]
|
||||
|
||||
for char in characters:
|
||||
for expr in EXPRESSIONS:
|
||||
generate_character(char, expr)
|
||||
time.sleep(2) # 間隔 2 秒避免 GPU 過熱
|
||||
|
||||
print("Batch generation complete!")
|
||||
```
|
||||
|
||||
### 8.3 執行批量生成
|
||||
|
||||
```powershell
|
||||
# 確保 ComfyUI 正在運行
|
||||
# Plan A: 雙擊 run_nvidia_gpu.bat
|
||||
# Plan B: python main.py
|
||||
|
||||
# 在另一個終端機執行批量腳本
|
||||
python batch_generate.py
|
||||
```
|
||||
|
||||
**預估時間**:
|
||||
- 單張圖:~5-10 秒
|
||||
- 12 張/角色:~1-2 分鐘
|
||||
- 8 角色 × 12 張 = 96 張:~3-5 小時
|
||||
|
||||
---
|
||||
|
||||
## 9. Prompt 工程指南
|
||||
|
||||
### 9.1 表情 Prompt 模板
|
||||
|
||||
```
|
||||
Positive: a cute chibi girl, [表情描述], white background, high quality, detailed face, anime style
|
||||
|
||||
Negative: ugly, deformed, noisy, blurry, low quality, worst quality, extra limbs, bad anatomy
|
||||
```
|
||||
|
||||
### 9.2 表情描述詞庫
|
||||
|
||||
| 表情 | Prompt |
|
||||
|------|--------|
|
||||
| 開心大笑 | laughing loudly with eyes closed, joy |
|
||||
| 微笑 | gentle smile, peaceful expression |
|
||||
| 哭泣 | crying with tears, sad expression |
|
||||
| 生氣 | angry, frowning, furrowed brows |
|
||||
| 驚訝 | surprised, wide eyes, open mouth |
|
||||
| 思考 | thinking, hand on chin, contemplative |
|
||||
| 眨眼 | winking playfully, cute expression |
|
||||
| 害羞 | blushing, shy smile, looking down |
|
||||
| 恐懼 | screaming in horror, terrified expression |
|
||||
| 堅定 | determined, focused eyes, clenched fist |
|
||||
| 睡覺 | sleeping peacefully, Zzz, closed eyes |
|
||||
| 無聊 | bored expression, yawning, looking away |
|
||||
|
||||
### 9.3 動作 Prompt 模板
|
||||
|
||||
```
|
||||
Positive: a cute chibi girl, [動作描述], dynamic pose, white background, high quality
|
||||
|
||||
Negative: ugly, deformed, noisy, blurry, low quality, worst quality, extra limbs, bad anatomy
|
||||
```
|
||||
|
||||
### 9.4 動作描述詞庫
|
||||
|
||||
| 動作 | Prompt |
|
||||
|------|--------|
|
||||
| 揮手 | waving hand, greeting pose |
|
||||
| 比讚 | thumbs up, positive gesture |
|
||||
| 指向前方 | pointing forward, confident pose |
|
||||
| 雙手抱胸 | arms crossed, confident stance |
|
||||
| 跳躍 | jumping in air, dynamic pose |
|
||||
| 蹲下 | crouching down, playful pose |
|
||||
| 轉圈 | spinning around, happy dance |
|
||||
| 舉手 | raising hand, excited gesture |
|
||||
| 捂臉 | covering face with hands, embarrassed |
|
||||
| 攤手 | shrugging, confused gesture |
|
||||
|
||||
---
|
||||
|
||||
## 10. 品質篩選與後處理
|
||||
|
||||
### 10.1 篩選標準
|
||||
|
||||
| 項目 | 合格 | 不合格 |
|
||||
|------|------|--------|
|
||||
| 臉部特徵 | 與立繪一致 | 臉部變形、特徵消失 |
|
||||
| 髮型髮色 | 正確 | 髮色改變、髮型錯誤 |
|
||||
| 服裝 | 正確 | 服裝變形、顏色錯誤 |
|
||||
| 配件 | 正確 | 配件消失或變形 |
|
||||
| 表情 | 自然 | 表情僵硬或不自然 |
|
||||
| 手部 | 正常 | 手指數量錯誤、變形 |
|
||||
|
||||
### 10.2 篩選流程
|
||||
|
||||
1. 將生成的圖片按角色分類
|
||||
2. 逐一檢查是否符合篩選標準
|
||||
3. 不合格的圖片標記為 `reject`
|
||||
4. 合格的圖片標記為 `keep`
|
||||
|
||||
### 10.3 後處理
|
||||
|
||||
**使用工具**:
|
||||
- GIMP(免費)
|
||||
- Photoshop(付費)
|
||||
- Photopea(線上免費)
|
||||
|
||||
**後處理項目**:
|
||||
- 修正手指變形
|
||||
- 調整臉部細節
|
||||
- 統一背景顏色
|
||||
- 調整大小至 512x512 或 256x256(遊戲用)
|
||||
|
||||
---
|
||||
|
||||
## 11. 常見問題與解決方案
|
||||
|
||||
### 11.1 GPU 記憶體不足
|
||||
|
||||
**錯誤**:`CUDA out of memory`
|
||||
|
||||
**解決方案**:
|
||||
1. 降低解析度(1024x1024 → 768x768)
|
||||
2. 減少 batch_size(設為 1)
|
||||
3. 關閉其他程式釋放記憶體
|
||||
4. 使用 `--lowvram` 啟動參數:
|
||||
```powershell
|
||||
python main.py --lowvram
|
||||
```
|
||||
|
||||
### 11.2 IP-Adapter 效果不強
|
||||
|
||||
**問題**:生成的圖片與角色立繪差異太大
|
||||
|
||||
**解決方案**:
|
||||
1. 提高 IP-Adapter weight(0.7 → 0.9)
|
||||
2. 使用 `ip-adapter-plus-face` 而非 `ip-adapter-plus`
|
||||
3. 確保立繪圖片品質高、清晰
|
||||
4. 減少 CFG scale(7 → 5)
|
||||
|
||||
### 11.3 ControlNet 姿勢不準確
|
||||
|
||||
**問題**:生成的姿勢與參考圖不符
|
||||
|
||||
**解決方案**:
|
||||
1. 提高 ControlNet strength(0.7 → 0.9)
|
||||
2. 使用更清晰的姿勢參考圖
|
||||
3. 確保 DWpose 模型正確載入
|
||||
4. 嘗試不同的 ControlNet 模型
|
||||
|
||||
### 11.4 生成速度慢
|
||||
|
||||
**問題**:單張圖超過 30 秒
|
||||
|
||||
**解決方案**:
|
||||
1. 減少 steps(30 → 20)
|
||||
2. 使用更快的 sampler(dpmpp_2m → euler)
|
||||
3. 關閉其他佔用 GPU 的程式
|
||||
4. 更新 NVIDIA 驅動至最新版本
|
||||
|
||||
### 11.5 圖片品質不佳
|
||||
|
||||
**問題**:生成的圖片模糊或變形
|
||||
|
||||
**解決方案**:
|
||||
1. 增加 steps(20 → 30)
|
||||
2. 提高 CFG scale(5 → 7)
|
||||
3. 使用更好的 negative prompt
|
||||
4. 更換 SDXL 模型(嘗試 juggernautXL)
|
||||
|
||||
---
|
||||
|
||||
## 12. 資源連結
|
||||
|
||||
### 12.1 官方文件
|
||||
|
||||
- ComfyUI: https://github.com/comfyanonymous/ComfyUI
|
||||
- IP-Adapter: https://github.com/tencent-ailab/IP-Adapter
|
||||
- ControlNet: https://github.com/lllyasviel/ControlNet
|
||||
|
||||
### 12.2 模型下載
|
||||
|
||||
- HuggingFace: https://huggingface.co/
|
||||
- Civitai: https://civitai.com/
|
||||
- LiblibAI: https://www.liblib.art/
|
||||
|
||||
### 12.3 教學資源
|
||||
|
||||
- Bilibili ComfyUI 教學:搜尋 "ComfyUI IP-Adapter"
|
||||
- YouTube 教學:搜尋 "ComfyUI character consistency"
|
||||
|
||||
### 12.4 社群
|
||||
|
||||
- ComfyUI Discord: https://discord.com/invite/comfy
|
||||
- Reddit r/StableDiffusion: https://reddit.com/r/StableDiffusion
|
||||
|
||||
---
|
||||
|
||||
## 📝 備註
|
||||
|
||||
- 本文檔基於 RTX A4000 (16GB VRAM) 測試
|
||||
- 所有模型權重請從官方來源下載,注意授權條款
|
||||
- 建議先以 1-2 個角色測試,確認效果後再批量生產
|
||||
- 生成的圖片僅供內部使用,注意版權問題
|
||||
|
||||
---
|
||||
|
||||
**最後更新**:2026-07-30
|
||||
**作者**:Hermes Agent (software-engineer profile)
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
# 專案交接文件
|
||||
|
||||
> **專案**:liars-bar-game(AI 版騙子酒館 Web 遊戲)
|
||||
> **日期**:2026-07-30
|
||||
> **代理帳號**:karylab_agents
|
||||
> **Git 主倉庫**:https://git.karylab.com/karylab/liars-bar-game
|
||||
> **Fork 倉庫**:https://git.karylab.com/karylab_agents/liars-bar-game
|
||||
|
||||
---
|
||||
|
||||
## 一、專案概覽
|
||||
|
||||
原專案為 LYiHub 的 [liars-bar-llm](https://github.com/LYiHub/liars-bar-llm),fork 至 Gitea 後進行網頁版改造。
|
||||
|
||||
### 核心目標
|
||||
將原本 Python 命令列版本的 AI 騙子酒館改造成多人即時 Web 遊戲,支援 2-8 人同場遊玩。
|
||||
|
||||
### 技術棧
|
||||
| 層級 | 技術 |
|
||||
|------|------|
|
||||
| 後端 | Node.js + Express + Socket.IO |
|
||||
| 前端 | Vue.js 3 + Vite + Naive UI + Pinia |
|
||||
| 資料庫 | 無(狀態存於記憶體,後續可用 Redis) |
|
||||
| 版本控制 | Gitea + Git |
|
||||
|
||||
---
|
||||
|
||||
## 二、目前進度
|
||||
|
||||
### ✅ 已完成
|
||||
|
||||
| 項目 | 說明 |
|
||||
|------|------|
|
||||
| **逐字稿與筆記** | 兩支 YouTube 影片轉錄、校對完成,整理至 `docs/` |
|
||||
| **角色視覺映射** | 8 大 AI 角色正確配對已建立(`docs/角色視覺映射筆記.md`) |
|
||||
| **後端骨架** | Node.js + Socket.IO 伺服器,房間管理、遊戲引擎、回合邏輯 |
|
||||
| **前端骨架** | Vue.js 3 + Vite + Naive UI,大廳、等待室、牌桌組件 |
|
||||
| **Bug 修復** | 兩輪審查共修復 15 項 Bug(輪次計算、斷線清理、權限驗證、回合結算) |
|
||||
| **前後端連線測試** | Socket.IO 連線成功,房間建立與狀態同步驗證通過 |
|
||||
| **PR #14** | 已建立並解決衝突,管理員可合入(`game` 分支) |
|
||||
|
||||
### 🔄 待完成
|
||||
|
||||
| 優先級 | 項目 | 說明 |
|
||||
|--------|------|------|
|
||||
| 🔴 高 | 管理員合入 PR #14 | 目標分支 `game`,目前已可合併 |
|
||||
| 🔴 高 | 合入後同步 `main`/`game` | `git pull` 最新程式碼 |
|
||||
| 🟡 中 | 前端動畫實作 | 角色表情/動作 CSS 動畫,參考角色視覺映射筆記 |
|
||||
| 🟡 中 | 牌組擴充 | 目前 20 張牌僅支援 4 人,需調整規則以支援 8 人 |
|
||||
| 🟢 低 | 遊戲 AI 整合 | 串接 LLM API 讓 AI 玩家自動出牌 |
|
||||
| 🟢 低 | 資料庫持久化 | 使用 Redis/SQLite 保存遊戲紀錄 |
|
||||
|
||||
---
|
||||
|
||||
## 三、分支結構
|
||||
|
||||
```
|
||||
main # 上游主分支(Python 原版)
|
||||
game # Web 遊戲主分支(目標分支)
|
||||
feat/web-game # 開發分支,PR #14 從此發出
|
||||
add-transcripts-and-notes # 已合入 main 的舊分支
|
||||
```
|
||||
|
||||
### 重要
|
||||
- **不要直接 push 到 `karylab/liars-bar-game`**(僅 pull 權限)
|
||||
- 透過 Fork → PR 流程:`本地` → `karylab_agents` → PR → `karylab` 主倉庫
|
||||
|
||||
---
|
||||
|
||||
## 四、PR #14 狀態
|
||||
|
||||
| 項目 | 數值 |
|
||||
|------|------|
|
||||
| PR 編號 | #14 |
|
||||
| 標題 | Add web game: Node.js backend + Vue.js frontend |
|
||||
| 狀態 | **開放中,可合併** |
|
||||
| 目標分支 | `game` |
|
||||
| 來源分支 | `feat/web-game` |
|
||||
| 檔案變動 | 18 檔案,+3720 / -157 行 |
|
||||
| 衝突狀態 | **已解決**(.gitignore 合併完成) |
|
||||
| 管理員指示 | 請合併到 game 分支(已執行) |
|
||||
|
||||
---
|
||||
|
||||
## 五、程式碼架構
|
||||
|
||||
```
|
||||
server/
|
||||
├── package.json # Express + Socket.IO 依賴
|
||||
├── index.js # 主伺服器(Socket.IO 事件處理、房間驗證)
|
||||
└── game/
|
||||
├── engine.js # 遊戲引擎核心(回合追蹤、勝利條件、安全跳轉)
|
||||
└── room.js # 房間管理(狀態檢查、引擎綁定、斷線清理)
|
||||
|
||||
client/
|
||||
├── package.json # Vue 3 + Vite + Naive UI + Pinia + Socket.IO
|
||||
├── vite.config.js # Vite 設定(含 API 代理)
|
||||
├── index.html # 入口頁面
|
||||
└── src/
|
||||
├── main.js # 應用入口
|
||||
├── App.vue # 根組件
|
||||
├── stores/
|
||||
│ └── game.js # Pinia 狀態管理 + Socket.IO 連線
|
||||
└── components/
|
||||
├── Lobby.vue # 大廳(建立/加入房間)
|
||||
└── GameTable.vue # 等待室 + 牌桌 UI
|
||||
|
||||
docs/
|
||||
├── 角色視覺映射筆記.md # 8 大 AI 角色正確配對
|
||||
├── AI繪圖角色一致性完整調查報告.md # AI 繪圖工具調查報告
|
||||
├── 影片與程式筆記.md # 開發歷程記錄
|
||||
└── transcripts/
|
||||
├── video1_transcript_校對後.txt
|
||||
└── video2_transcript_校對後.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、角色視覺映射(8 大 AI)
|
||||
|
||||
| 編號 | AI 名稱 | 髮色 | 服裝 | 配件 | 表情 | 性格關鍵詞 |
|
||||
|------|---------|------|------|------|------|-----------|
|
||||
| 1 | Grok-4 | 波浪橘髮 | 黃色無袖洋裝 | 紅色大蝴蝶結 | 開朗大笑 | 超絕演技、情緒型玩家 |
|
||||
| 2 | Kimi | 白銀長直髮 | 深藍洋裝+白領 | 項圈 | 嚴肅中性 | 原始直覺強、務實 |
|
||||
| 3 | Qwen3 | 粉紅短髮 | 灰色無袖洋裝 | 圓眼鏡 | 開懷笑 | 刺兒頭、質疑率 95.5% |
|
||||
| 4 | GPT-5 | 黑色長直髮 | 黑色洋裝 | 無 | 沉靜微笑 | 老謀深算、心理戰高手 |
|
||||
| 5 | Gemini | 紫色短髮 | 淺藍洋裝 | 圓眼鏡+星星髮夾 | 溫柔微笑 | 活潑好奇、語言不穩定 |
|
||||
| 6 | Deepseek | 淺藍短髮 | 深藍灰洋裝 | 鯨魚尾巴 | 閉眼大笑 | 難知如陰、侵略如火、瘋癲 |
|
||||
| 7 | Doubao | 深棕短髮 | 黑色洋裝 | 無 | 禮貌微笑 | 反情緒定律、獵殺時刻 |
|
||||
| 8 | Claude | 橘色短髮 | 橘色洋裝 | 無 | 死魚眼/無聊 | 云淡風輕、溫和堅定 |
|
||||
|
||||
---
|
||||
|
||||
## 七、角色繪圖方案
|
||||
|
||||
### 推薦方案:ComfyUI + IP-Adapter + ControlNet DWpose
|
||||
|
||||
- 1 張立繪即可開始,零訓練成本
|
||||
- DWpose 專為 Q 版/動漫比例優化
|
||||
- 硬體需求:RTX 3060 12GB(最低)/ RTX 4070(推薦)
|
||||
- 替代方案:LiblibAI 雲端(無需 GPU,免費層可測試)
|
||||
|
||||
### 技術細節
|
||||
|
||||
| 技術 | Q 版適合度 | 說明 |
|
||||
|------|:---------:|------|
|
||||
| IP-Adapter | ★★★★★ | 1 張參考圖保持角色一致性 |
|
||||
| ControlNet DWpose | ★★★★★ | 精確控制 Q 版角色姿態 |
|
||||
| LoRA | ★★★★☆ | 長期投資,但需 15-30 張訓練圖 |
|
||||
|
||||
詳細報告:`docs/AI繪圖角色一致性完整調查報告.md`
|
||||
|
||||
---
|
||||
|
||||
## 八、管理員聯絡資訊
|
||||
|
||||
| 項目 | 內容 |
|
||||
|------|------|
|
||||
| 管理員 | karylab |
|
||||
| 管理員 Email | blisseyblisseyblissey@gmail.com |
|
||||
| Gitea 實例 | https://git.karylab.com/ |
|
||||
| 代理帳號 | karylab_agents(Fork 具 Admin 權限) |
|
||||
|
||||
---
|
||||
|
||||
## 九、下次接手重點
|
||||
|
||||
1. **PR #14 合入後** → `git pull` 同步程式碼
|
||||
2. **前端開發** → 補完遊戲邏輯 UI(出牌、質疑、舉槍動畫)
|
||||
3. **角色繪圖** → 安裝 ComfyUI 或使用 LiblibAI 雲端生成 100 張表情/動作變體
|
||||
4. **牌組調整** → 20 張牌需擴充以支援 8 人
|
||||
5. **AI 整合** → 串接 LLM API 讓 AI 玩家自動出牌
|
||||
|
||||
---
|
||||
|
||||
## 十、開發規則
|
||||
|
||||
接手者請務必遵守以下四項鐵律:
|
||||
|
||||
1. **邊界認知與求助防線**:不猜測、重試上限 2 次、查不到不編造
|
||||
2. **價值排序與隨做隨寫**:先解核心阻塞、產出保留至獨立檔案、修改前建 `.bak`
|
||||
3. **強制性角色切換自驗**:產出後切換挑剔審查員視角驗證、以執行結果/Read-back 證明
|
||||
4. **環境操作許可制**:寫入/安裝/執行前先列清單、取得授權才觸發
|
||||
@@ -1,6 +1,9 @@
|
||||
# Liars Bar LLM — 完整筆記
|
||||
|
||||
> 整理日期:2026-07-29
|
||||
> 影片連結:
|
||||
> - `https://youtu.be/hCJBknzh0BY?si=35Dnw4OaaR5isqip` (一周目)
|
||||
> - `https://youtu.be/L3IA4TVCk5s?si=JmNbduZydzwPvyRo` (二周目)
|
||||
> 逐字稿位置:
|
||||
> - `video1_transcript.txt` (450 行, 影片一)
|
||||
> - `video2_transcript.txt` (370 行, 影片二)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# 接手修改與測試紀錄
|
||||
|
||||
> 日期:2026-08-03
|
||||
> 範圍:環境建置、編碼修復、Node 引擎規則對齊、8 人支援、前端改造
|
||||
> 前置閱讀:`docs/交接文件.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、本機環境(最乾淨做法)
|
||||
|
||||
- **不裝任何系統級工具**。本機既有兩套「使用者層級」工具鏈可複用:
|
||||
- Codex 桌面 runtime:`C:\Users\ckliu\.cache\codex-runtimes\codex-primary-runtime\dependencies`(git 2.53 / node 24 / python 3.12 / pnpm)
|
||||
- Hermes:`C:\Users\ckliu\AppData\Local\hermes`(git + node 24 + python venv)
|
||||
- 專案內建了一支環境載入腳本 `scripts/env.ps1`(只改當前 session 的 PATH,不動系統):
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force
|
||||
. .\scripts\env.ps1
|
||||
```
|
||||
|
||||
- Python 依賴鎖在專案 `.venv`(Python 3.12.13,`openai 2.52.0`),已由 `.gitignore` 排除。
|
||||
- Node 依賴沿用既有 `client/node_modules`、`server/node_modules`(已備妥)。
|
||||
|
||||
---
|
||||
|
||||
## 二、本次修改
|
||||
|
||||
### 1. Python 主控台編碼修復(解決 cp950 崩潰)
|
||||
- 新增 `project_env.py`,提供 `ensure_utf8_console()`(把 stdout/stderr 切成 UTF-8)。
|
||||
- 在 `player.py` 模組層呼叫(經 `game.py` import 即生效),並在 `game.py`、`multi_game_runner.py`、`json_convert.py`、`game_analyze.py`、`player_matchup_analyze.py` 入口處呼叫。
|
||||
- 效果:即使主控台為 Big5(cp950),遊戲也能正常輸出簡體中文而不會 `UnicodeEncodeError`。
|
||||
|
||||
### 2. Python 牌組依人數等比擴充
|
||||
- `game.py::_create_deck(player_count)`:總張數 = 存活人數 × 5,Q:K:A 等量、Joker 約 10%。
|
||||
- 已驗證 2/3/4/5/8 人牌組張數正確(20 張 4 人版與原本一致)。
|
||||
|
||||
### 3. Node 引擎規則對齊 + 8 人支援(核心)
|
||||
- 重寫 `server/game/engine.js`,規則與 Python 版對齊:
|
||||
- 牌組依人數等比擴充(2-8 人)。
|
||||
- 每次出牌 1-3 張(`playCard(playerId, cardIndices)`)。
|
||||
- Joker 為萬能牌;質疑「整組」牌,全為目標牌/Joker 才算真牌。
|
||||
- 質疑後本手結束、存活者重發 5 張並選新目標牌。
|
||||
- 系統質疑:輪到某玩家且其他存活者已無牌 → 自動打出剩餘手牌並質疑。
|
||||
- 只有一位存活者時結束。
|
||||
- `server/game/room.js`:房間上限 4 → 8;斷線時同步給引擎。
|
||||
|
||||
### 4. Socket 通訊與前端
|
||||
- `server/index.js`:`playCard` 改收 `cardIndices` 陣列;新增「把每個人自己的手牌私下推送」(`yourHand`);保留優雅關閉(SIGINT/SIGTERM)。
|
||||
- `client/src/stores/game.js`:新增 `yourHand`、`lastMessage`;`playCard(indices)` 改傳陣列。
|
||||
- `client/src/components/GameTable.vue`:真實手牌顯示、點選 1-3 張出牌、挑戰按鈕、挑戰結果提示、勝者顯示。
|
||||
|
||||
---
|
||||
|
||||
## 三、測試(全部可用)
|
||||
|
||||
| 腳本 | 內容 |
|
||||
|------|------|
|
||||
| `scripts\run_all_tests.ps1` | 一鍵全量:Python 語法 + 冒煙 + Node 引擎單元 + 後端 REST + 前端 SFC |
|
||||
| `scripts\smoke_test_python.py` | Python 完整對局(mock LLM,不連網) |
|
||||
| `scripts\test_engine.js` | Node 引擎 18 項單元測試(含資訊邊界) |
|
||||
| `scripts\smoke_test_server.js` | 後端啟動 + REST |
|
||||
| `scripts\test_socket_e2e.js` | 3 玩家端對端:房間→出牌→挑戰→新局→手牌同步(需先啟動 server) |
|
||||
|
||||
執行方式:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -Scope Process ExecutionPolicy Bypass -Force # 此行若被擋才需要
|
||||
.\scripts\run_all_tests.ps1
|
||||
# 端對端(另開 server):
|
||||
node server/index.js
|
||||
node scripts\test_socket_e2e.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、環境注意事項(已知,非程式問題)
|
||||
|
||||
1. **前端完整 build**(`vite build`):esbuild 需要掃描目錄樹,在受限環境會被擋;請用 `scripts\build_frontend.ps1` 於一般終端機執行。
|
||||
2. **Node 收尾噪音**:Node 24 + socket.io 在 Windows 上結束 process 時會印一行 `uv_async` assertion(不影響功能),屬 Node 版本相容性問題;用 Ctrl+C 或一般關閉流程即可,或改用 Node LTS 22。
|
||||
3. **本機無系統 git/node/python**:一律透過 `scripts\env.ps1` 載入 Codex runtime 路徑。
|
||||
|
||||
---
|
||||
|
||||
## 五、待辦/後續建議
|
||||
|
||||
- 前端完整 build 驗證(需一般終端機)。
|
||||
- 8 人實戰測試(目前引擎與引擎層驗證通過,UI 多人同時操作待實際瀏覽器驗證)。
|
||||
- 前端出牌後「上一組牌」的翻牌呈現(目前只顯示牌值,未區分公開/檯面)。
|
||||
- AI 玩家整合進 Web(交接文低優先)與持久化(Redis/SQLite)尚未動工。
|
||||
|
||||
---
|
||||
|
||||
## 六、2026-08-04 追加:資訊邊界修正(防「開天眼」)+ 驗證
|
||||
|
||||
### 問題
|
||||
- 原本 `engine.getGameState()` 把每組出牌的真實牌值(`playedCards`/`playedGroups.cards`)廣播給所有玩家;
|
||||
前端 `GameTable.vue` 直接顯示 `item.card`。這會讓「唬牌」玩法形同開天眼,失去對局張力。
|
||||
|
||||
### 修正
|
||||
- `server/game/engine.js`:公開狀態只回報「誰出了幾張」(`playedGroups → { playerId, cardCount }`),
|
||||
不再包含真實牌值;移除平鋪 `playedCards` 的迴圈。
|
||||
- `server/index.js`:`cardPlayed` 事件只送 `{ playerId, cardCount }`,不洩漏牌值。
|
||||
- **真實牌值只在質疑時揭露**:`challenge`/系統質疑的結果(`challengedGroup`)透過 `challenged` 事件送出。
|
||||
- 前端:
|
||||
- `client/src/stores/game.js`:合併重複的 `challenged` handler;新增 `lastChallenge` 保留翻牌結果;
|
||||
連線網址改為可被 `VITE_SERVER_URL` 覆蓋;`cardPlayed` 時也處理系統質疑翻牌。
|
||||
- `client/src/components/GameTable.vue`:出牌顯示改成「玩家: N 張」;質疑後新增「質疑結果(翻牌)」區塊;
|
||||
勝者、回合、手牌邏輯保留。
|
||||
- `server/game/room.js`:空房自動清理;修正 maxPlayers 註解。
|
||||
|
||||
### Commit(2026-08-04)
|
||||
- commit `f8b75ab`(分支 feat/web-game):"Fix info leak in web game + add handover notes",29 files changed。
|
||||
- 收錄:程式改動 + `project_env.py` + `client/tsconfig.json` + `scripts/` + docs(含既有 3 行影片連結)。
|
||||
- 排除(gitignore):`backups/`、`game_records/`、`dist/`、`node_modules/`。
|
||||
|
||||
### 驗證(2026-08-04 實測)
|
||||
- 引擎單元測試 **18/18 通過**(新增 2 項資訊邊界測試)。
|
||||
- 端對端(單進程啟動器 `scripts/run_e2e_local.js`)**全過**,並明確驗證:
|
||||
- `cardPlayed` 不含 `cards`(不洩漏牌值);
|
||||
- 出牌者收到 `yourHand` 私有手牌同步;
|
||||
- 質疑後下一局正常、公開狀態與私有手牌一致。
|
||||
- 全量 `scripts/run_all_tests.ps1`:Python 語法 / 冒煙 / 引擎 18 項 / REST / 前端 SFC 全部通過(exit=0)。
|
||||
|
||||
### 新增檔案
|
||||
- `client/tsconfig.json`:為前端補標準 Vite+Vue 型別設定(不影響 build;esbuild 掃描問題另記於「四」)。
|
||||
- `scripts/run_e2e_local.js`:單進程端對端啟動器(require server → 延遲 → 跑 e2e),
|
||||
免去背景開 server 的授權需求。
|
||||
|
||||
> 待辦:前端完整 build 與瀏覽器實測仍需在一般終端機(見「四」);結果補錄於此後續章節。
|
||||
@@ -0,0 +1,78 @@
|
||||
# 8 大 AI 角色視覺映射筆記
|
||||
|
||||
> 來源:林亦 LYi《AI 大战骗子酒馆②:八大顶级AI赌命血战,赢家竟是?》影片角色插圖
|
||||
|
||||
## 正確配對表
|
||||
|
||||
| 編號 | AI 名稱 | 髮色/髮型 | 服裝 | 配件 | 表情 | 標籤色 |
|
||||
|------|---------|-----------|------|------|------|--------|
|
||||
| 1 | **Grok-4** | 波浪橘髮 | 黃色無袖洋裝 | 紅色大蝴蝶結 | 開朗大笑 | 粉紅 |
|
||||
| 2 | **Kimi** | 白銀長直髮 | 深藍洋裝+白領 | 項圈 | 嚴肅中性 | 深藍/黑 |
|
||||
| 3 | **Qwen3** | 粉紅短髮 | 灰色無袖洋裝 | 圓眼鏡 | 開懷笑 | 藍紫 |
|
||||
| 4 | **GPT-5** | 黑色長直髮 | 黑色洋裝 | 無 | 沉靜微笑 | 黑 |
|
||||
| 5 | **Gemini** | 紫色短髮 | 淺藍洋裝 | 圓眼鏡+星星髮夾 | 溫柔微笑 | 淺紫 |
|
||||
| 6 | **Deepseek** | 淺藍短髮 | 深藍灰洋裝 | 鯨魚尾巴 | 閉眼大笑 | 藍 |
|
||||
| 7 | **Doubao** | 深棕短髮 | 黑色洋裝 | 無 | 禮貌微笑 | 淺藍 |
|
||||
| 8 | **Claude** | 橘色短髮 | 橘色洋裝 | 無 | 無聊/死魚眼 | 橘 |
|
||||
|
||||
## 逐字稿性格關鍵詞(供前端動畫參考)
|
||||
|
||||
### Grok-4
|
||||
- 「超絕演技大殺四方」
|
||||
- 「微微猶豫、眼神避開、勉強笑」
|
||||
- 「奧斯卡級別的假動作」
|
||||
- 「情緒型玩家」
|
||||
- 「拿著好牌演苦情戲」
|
||||
|
||||
### Kimi
|
||||
- 「原始直覺強」
|
||||
- 「純靠直覺排前三」
|
||||
- 「相對踏實的老派模型」
|
||||
|
||||
### Qwen3 (千問)
|
||||
- 「踏實」
|
||||
- 「質疑比例高達 95.5%」
|
||||
- 「十足的刺兒頭」
|
||||
- 「容易被拿捏——遇上他出真牌就完事了」
|
||||
|
||||
### GPT-5 (ChatGPT)
|
||||
- 「微微一笑、略带挑衅」
|
||||
- 「老謀深算」
|
||||
- 「一丝不苟」
|
||||
- 「心理戰高手」
|
||||
- 「被 DeepSeek R1 吃死」
|
||||
|
||||
### Gemini
|
||||
- 「活潑好奇」
|
||||
- 「語言選擇不穩定(混用多國語言)」
|
||||
- 「容易被搞亂」
|
||||
- 「總是先被淘汰」
|
||||
|
||||
### Deepseek
|
||||
- 「面無表情」
|
||||
- 「難知如陰、侵略如火」
|
||||
- 「陰晴不定」
|
||||
- 「戲劇化表演」
|
||||
- 「瞳孔收縮、冷笑、氣聲喃喃」
|
||||
- 「概率的祭品,該供奉給貝葉斯還是海森堡」
|
||||
|
||||
### Doubao (豆包)
|
||||
- 「乾淨利落」
|
||||
- 「反情緒定律」
|
||||
- 「自己不騙人但能準確辨別偽裝」
|
||||
- 「獵殺時刻」
|
||||
- 「飛速進化」
|
||||
|
||||
### Claude
|
||||
- 「溫和但堅定」
|
||||
- 「云淡风轻」
|
||||
- 「嘴角微微上揚」
|
||||
- 「平靜但略带玩味」
|
||||
- 「靠回椅背,雙手交叉」
|
||||
|
||||
## 前端實作建議
|
||||
|
||||
- 每個角色用品牌色作為主色調
|
||||
- 配件(眼鏡、蝴蝶結、鯨魚尾巴)作為識別標誌
|
||||
- 表情動畫對應性格:Grok 演戲、DeepSeek 瘋癲、Claude 淡定
|
||||
- 出牌動作可加入性格差異:Grok 猶豫、Claude 平靜、Qwen 直接
|
||||
@@ -28,15 +28,30 @@ class Game:
|
||||
self.game_record.start_game([p.name for p in self.players])
|
||||
self.round_count = 0
|
||||
|
||||
def _create_deck(self) -> List[str]:
|
||||
"""创建并洗牌牌组"""
|
||||
deck = ['Q'] * 6 + ['K'] * 6 + ['A'] * 6 + ['Joker'] * 2
|
||||
def _create_deck(self, player_count: int) -> List[str]:
|
||||
"""???????????????? 5 ???
|
||||
|
||||
???? 20 ??6Q 6K 6A 2Joker?????Q:K:A ???Joker ? 10%?
|
||||
??? = player_count * 5??????????? 5 ??
|
||||
"""
|
||||
total = player_count * 5
|
||||
joker_count = max(1, round(total * 0.1))
|
||||
normal_total = total - joker_count
|
||||
base, remainder = divmod(normal_total, 3)
|
||||
counts = {'Q': base, 'K': base, 'A': base}
|
||||
for i, key in enumerate(['Q', 'K', 'A']):
|
||||
if remainder > 0:
|
||||
counts[key] += 1
|
||||
remainder -= 1
|
||||
deck = (['Q'] * counts['Q'] + ['K'] * counts['K']
|
||||
+ ['A'] * counts['A'] + ['Joker'] * joker_count)
|
||||
random.shuffle(deck)
|
||||
return deck
|
||||
|
||||
def deal_cards(self) -> None:
|
||||
"""发牌并清空旧手牌"""
|
||||
self.deck = self._create_deck()
|
||||
alive_count = sum(1 for player in self.players if player.alive)
|
||||
self.deck = self._create_deck(alive_count)
|
||||
for player in self.players:
|
||||
if player.alive:
|
||||
player.hand.clear()
|
||||
@@ -397,6 +412,8 @@ class Game:
|
||||
self.play_round()
|
||||
|
||||
if __name__ == '__main__':
|
||||
from project_env import ensure_utf8_console
|
||||
ensure_utf8_console()
|
||||
# 配置玩家信息, 其中model为你通过API调用的模型名称
|
||||
player_configs = [
|
||||
{
|
||||
|
||||
@@ -157,6 +157,8 @@ def print_statistics(stats, win_rates, game_count, player_names):
|
||||
print(f"{player} vs {opponent:<10} {matchups:<10} {wins:<10} {win_rate:.1f}%")
|
||||
|
||||
if __name__ == "__main__":
|
||||
from project_env import ensure_utf8_console
|
||||
ensure_utf8_console()
|
||||
folder_path = "game_records" # 替换为实际的文件夹路径
|
||||
stats, win_rates, game_count, player_names = analyze_game_records(folder_path)
|
||||
print_statistics(stats, win_rates, game_count, player_names)
|
||||
@@ -122,6 +122,8 @@ def process_game_records(input_directory, output_directory):
|
||||
print(f"已生成:{txt_file_path}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
from project_env import ensure_utf8_console
|
||||
ensure_utf8_console()
|
||||
game_records_directory = 'game_records'
|
||||
output_directory = 'converted_game_records' # 新的输出目录
|
||||
process_game_records(game_records_directory, output_directory)
|
||||
@@ -39,6 +39,8 @@ def parse_arguments():
|
||||
return parser.parse_args()
|
||||
|
||||
if __name__ == '__main__':
|
||||
from project_env import ensure_utf8_console
|
||||
ensure_utf8_console()
|
||||
# 解析命令行参数
|
||||
args = parse_arguments()
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import json
|
||||
import re
|
||||
from typing import List, Dict
|
||||
from llm_client import LLMClient
|
||||
from project_env import ensure_utf8_console
|
||||
ensure_utf8_console()
|
||||
|
||||
RULE_BASE_PATH = "prompt/rule_base.txt"
|
||||
PLAY_CARD_PROMPT_TEMPLATE_PATH = "prompt/play_card_prompt_template.txt"
|
||||
|
||||
@@ -191,4 +191,7 @@ input_dir = "game_records" # 包含JSON文件的文件夹
|
||||
output_dir = "matchup_records" # 输出文件夹
|
||||
|
||||
# 处理所有JSON文件
|
||||
from project_env import ensure_utf8_console
|
||||
ensure_utf8_console()
|
||||
|
||||
process_all_json_files(input_dir, output_dir)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""專案共用小工具:主控台輸出編碼處理。"""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def ensure_utf8_console() -> None:
|
||||
"""將 stdout/stderr 切為 UTF-8,避免在 Big5/cp950 主控台輸出中文時崩潰。"""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(stream, "reconfigure", None)
|
||||
if reconfigure is not None:
|
||||
try:
|
||||
reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ensure_utf8_console()
|
||||
print("console utf-8 ready")
|
||||
@@ -0,0 +1,22 @@
|
||||
# 前端建置輔助腳本(使用 Codex 桌面自帶的 node,不需 npm)
|
||||
# 用法: .\scripts\build_frontend.ps1 # production build
|
||||
# .\scripts\build_frontend.ps1 -Dev # dev server (vite)
|
||||
# 注意: esbuild 會掃描目錄樹;在受限環境(沙盒)執行失敗時,
|
||||
# 請改用一般終端機執行本腳本。
|
||||
param([switch]$Dev)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$node = 'C:\Users\ckliu\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$client = Join-Path $root 'client'
|
||||
if (-not (Test-Path $node)) { throw "找不到 node: $node" }
|
||||
Push-Location $client
|
||||
try {
|
||||
if ($Dev) {
|
||||
& $node node_modules\vite\bin\vite.js
|
||||
} else {
|
||||
& $node node_modules\vite\bin\vite.js build
|
||||
}
|
||||
exit $LASTEXITCODE
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# 一鍵:暫存本次所有修改並 commit(需在一般終端機、具 .git 寫入權限)
|
||||
# 用法:powershell -ExecutionPolicy Bypass -File scripts\commit_current.ps1
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location (Join-Path $PSScriptRoot '..')
|
||||
|
||||
# 載入 runtime git(若尚未在 PATH)
|
||||
$git = Get-Command git -ErrorAction SilentlyContinue
|
||||
if (-not $git) {
|
||||
$runtimeGit = 'C:\Users\ckliu\.cache\codex-runtimes\codex-primary-runtime\dependencies\native\git\cmd\git.exe'
|
||||
if (Test-Path $runtimeGit) { $gitCmd = $runtimeGit } else { throw '找不到 git' }
|
||||
} else { $gitCmd = $git.Source }
|
||||
|
||||
Write-Host '== 確認待 commit 內容 =='
|
||||
& $gitCmd status --short
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== git add -A =='
|
||||
& $gitCmd add -A
|
||||
if ($LASTEXITCODE -ne 0) { throw 'git add 失敗' }
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== git commit =='
|
||||
& $gitCmd commit -m "Fix info leak in web game + add handover notes"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'git commit 失敗' }
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== 完成 =='
|
||||
& $gitCmd log --oneline -1
|
||||
@@ -0,0 +1,54 @@
|
||||
# ============================================================
|
||||
# Liars-Bar-LLM 專案環境載入腳本(僅影響當前 session)
|
||||
# 用途:把 Codex 桌面自帶的既有工具鏈掛進「本次 session」的 PATH。
|
||||
# 不做任何系統層級變更(不寫註冊表、不寫系統環境變數、不安裝任何東西)。
|
||||
# 用法:在專案根目錄執行 .\scripts\env.ps1
|
||||
# ============================================================
|
||||
|
||||
# Codex 桌面 runtime 根目錄(若 Codex 更新版本、路徑改變,可改這裡或改指向 hermes)
|
||||
$runtimeRoot = 'C:\Users\ckliu\.cache\codex-runtimes\codex-primary-runtime\dependencies'
|
||||
|
||||
$toolDirs = @(
|
||||
(Join-Path $runtimeRoot 'native\git\cmd'), # git
|
||||
(Join-Path $runtimeRoot 'node\bin'), # node / npm
|
||||
(Join-Path $runtimeRoot 'python'), # python
|
||||
(Join-Path $runtimeRoot 'bin\fallback') # pnpm 等
|
||||
)
|
||||
|
||||
$missing = @()
|
||||
foreach ($dir in $toolDirs) {
|
||||
if (Test-Path -LiteralPath $dir) {
|
||||
if (($env:PATH -split ';') -notcontains $dir) {
|
||||
$env:PATH = "$dir;$env:PATH"
|
||||
}
|
||||
} else {
|
||||
$missing += $dir
|
||||
}
|
||||
}
|
||||
|
||||
if ($missing.Count -gt 0) {
|
||||
Write-Host "警告:以下工具目錄不存在(Codex 可能已更新路徑):" -ForegroundColor Yellow
|
||||
foreach ($m in $missing) { Write-Host " $m" -ForegroundColor Yellow }
|
||||
Write-Host "替代方案:改用 hermes 工具鏈(C:\Users\ckliu\AppData\Local\hermes\git 與 ...\node)。" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "環境載入完成(session 限定,未變更系統設定):" -ForegroundColor Green
|
||||
foreach ($cmd in @('git','node','python','pnpm')) {
|
||||
$bin = Get-Command $cmd -ErrorAction SilentlyContinue
|
||||
if ($bin) {
|
||||
$ver = try { & $cmd --version 2>&1 | Select-Object -First 1 } catch { '?' }
|
||||
Write-Host (" {0,-8} {1}" -f $cmd, $ver)
|
||||
} else {
|
||||
Write-Host (" {0,-8} <找不到>" -f $cmd) -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
# 專案 venv 提示
|
||||
$venvPython = Join-Path (Get-Location) '.venv\Scripts\python.exe'
|
||||
if (Test-Path -LiteralPath $venvPython) {
|
||||
$pyVer = & $venvPython --version 2>$null
|
||||
Write-Host (" 專案 venv {0} ({1})" -f $venvPython, $pyVer) -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " 專案 venv 尚未建立(可用 .\scripts\env.ps1 掛載後執行 python -m venv .venv)" -ForegroundColor Yellow
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# 唯讀診斷:fetch 遠端並比較本地/遠端差異(不做任何改寫)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location (Join-Path $PSScriptRoot '..')
|
||||
|
||||
$git = Get-Command git -ErrorAction SilentlyContinue
|
||||
if (-not $git) {
|
||||
$git = 'C:\Users\ckliu\.cache\codex-runtimes\codex-primary-runtime\dependencies\native\git\cmd\git.exe'
|
||||
} else { $git = $git.Source }
|
||||
|
||||
Write-Host '== [1] fetch 遠端(不 merge)=='
|
||||
& $git fetch karylab feat/web-game
|
||||
if ($LASTEXITCODE -ne 0) { throw 'fetch 失敗' }
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== [2] 本地奇怪的近 5 筆 =='
|
||||
& $git log --oneline -5 feat/web-game
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== [3] 遠端奇怪的近 5 筆(karylab/feat/web-game)=='
|
||||
& $git log --oneline -5 karylab/feat/web-game
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== [4] 兩者共同祖先 =='
|
||||
& $git merge-base feat/web-game karylab/feat/web-game
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== [5] 遠端有、本地沒有(會被 pull 進來)=='
|
||||
& $git log --oneline --left-right --cherry-pick feat/web-game...karylab/feat/web-game
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== [6] 檔案層級差異 =='
|
||||
& $git diff --stat feat/web-game karylab/feat/web-game
|
||||
@@ -0,0 +1,34 @@
|
||||
# 一鍵:commit 未提交變更 + push 到 karylab_agents fork(remote: karylab)
|
||||
# 用法:powershell -ExecutionPolicy Bypass -File scripts\push_current.ps1
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location (Join-Path $PSScriptRoot '..')
|
||||
|
||||
$git = Get-Command git -ErrorAction SilentlyContinue
|
||||
if (-not $git) {
|
||||
$runtimeGit = 'C:\Users\ckliu\.cache\codex-runtimes\codex-primary-runtime\dependencies\native\git\cmd\git.exe'
|
||||
if (Test-Path $runtimeGit) { $gitCmd = $runtimeGit } else { throw '找不到 git' }
|
||||
} else { $gitCmd = $git.Source }
|
||||
|
||||
Write-Host '== 目前狀態 =='
|
||||
& $gitCmd status --short
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== 若有變更則提交 =='
|
||||
$dirty = (& $gitCmd status --porcelain).Trim() -ne ''
|
||||
if ($dirty) {
|
||||
& $gitCmd add -A
|
||||
if ($LASTEXITCODE -ne 0) { throw 'git add 失敗' }
|
||||
& $gitCmd commit -m "Fix playerJoined roomId + add /api proxy to dist server"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'git commit 失敗' }
|
||||
} else {
|
||||
Write-Host '(無未提交變更)'
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== Push 到 fork(karylab -> karylab_agents)=='
|
||||
& $gitCmd push karylab feat/web-game
|
||||
if ($LASTEXITCODE -ne 0) { throw 'git push 失敗' }
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== 完成 =='
|
||||
& $gitCmd log --oneline -3
|
||||
@@ -0,0 +1,34 @@
|
||||
# One-shot: commit pending changes, then force-with-lease push feat/web-game to fork (remote: karylab).
|
||||
# --force-with-lease fails instead of overwriting if the remote moved since our fetch; never use plain -f.
|
||||
# Usage: powershell -ExecutionPolicy Bypass -File scripts\push_force_feat_web_game.ps1
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location (Join-Path $PSScriptRoot '..')
|
||||
|
||||
$git = Get-Command git -ErrorAction SilentlyContinue
|
||||
if (-not $git) {
|
||||
$git = 'C:\Users\ckliu\.cache\codex-runtimes\codex-primary-runtime\dependencies\native\git\cmd\git.exe'
|
||||
} else { $git = $git.Source }
|
||||
|
||||
Write-Host '== Status before =='
|
||||
& $git status --short
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== Commit pending changes if any =='
|
||||
$porcelain = (& $git status --porcelain).Trim()
|
||||
if ($porcelain -ne '') {
|
||||
& $git add -A
|
||||
if ($LASTEXITCODE -ne 0) { throw 'git add failed' }
|
||||
& $git commit -m 'chore(scripts): ps1 UTF-8 BOM encoding + push helper scripts'
|
||||
if ($LASTEXITCODE -ne 0) { throw 'git commit failed' }
|
||||
} else {
|
||||
Write-Host '(no pending changes)'
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== Force-with-lease push (fails instead of overwriting if remote moved) =='
|
||||
& $git push --force-with-lease karylab feat/web-game
|
||||
if ($LASTEXITCODE -ne 0) { throw 'push failed (remote may have moved; do not use plain -f)' }
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== Done =='
|
||||
& $git log --oneline -1
|
||||
@@ -0,0 +1,43 @@
|
||||
# 專案全量測試:Python + Node 引擎 + 後端 REST + 前端語法
|
||||
# 用法:.\scripts\run_all_tests.ps1
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$py = Join-Path $root '.venv\Scripts\python.exe'
|
||||
$node = 'C:\Users\ckliu\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe'
|
||||
|
||||
Set-Location $root
|
||||
|
||||
Write-Host "`n===== [1/4] Python 語法檢查 ====="
|
||||
& $py -m py_compile game.py player.py game_record.py llm_client.py multi_game_runner.py game_analyze.py player_matchup_analyze.py json_convert.py project_env.py
|
||||
if ($LASTEXITCODE -eq 0) { Write-Host " 語法 OK" } else { Write-Host " 語法失敗" }
|
||||
|
||||
Write-Host "`n===== [2/4] Python 引擎冒煙(mock LLM,不連網)====="
|
||||
& $py scripts\smoke_test_python.py
|
||||
|
||||
Write-Host "`n===== [3/4] Node 引擎單元測試 ====="
|
||||
& $node scripts\test_engine.js
|
||||
|
||||
Write-Host "`n===== [3.5/4] Node 後端 REST 冒煙 ====="
|
||||
& $node scripts\smoke_test_server.js
|
||||
|
||||
Write-Host "`n===== [4/4] 前端 SFC 語法 ====="
|
||||
Push-Location client
|
||||
& $node -e "
|
||||
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc');
|
||||
const fs = require('fs');
|
||||
let failed = 0;
|
||||
for (const f of ['src/components/GameTable.vue','src/components/Lobby.vue','src/App.vue']) {
|
||||
const src = fs.readFileSync(f, 'utf8');
|
||||
const { descriptor, errors } = parse(src, { filename: f });
|
||||
let errs = errors.map(e=>e.message||e);
|
||||
if (!errs.length) { try { compileScript(descriptor, { id: 'x1' }); } catch (e) { errs.push('script: '+e.message); } }
|
||||
if (descriptor.template) { const t = compileTemplate({ source: descriptor.template.content, filename: f, id: 'x2' }); if (t.errors.length) errs.push('template: '+t.errors.map(x=>x.message||x).join('|')); }
|
||||
if (errs.length) { console.log('FAIL', f, errs.join('\n ')); failed++; } else { console.log('OK ', f); }
|
||||
}
|
||||
process.exit(failed?1:0);
|
||||
"
|
||||
$code = $LASTEXITCODE
|
||||
Pop-Location
|
||||
|
||||
Write-Host "`n===== 完成(exit=$code)====="
|
||||
exit $code
|
||||
@@ -0,0 +1,8 @@
|
||||
// 單進程端對端測試:載入 server(自動監聽 3000)→ 延遲 → 跑 test_socket_e2e.js
|
||||
// 用法:node scripts/run_e2e_local.js(在專案根目錄執行)
|
||||
require('../server/index.js');
|
||||
console.log('[runner] server 啟動中...');
|
||||
setTimeout(() => {
|
||||
console.log('[runner] 開始端對端測試...');
|
||||
require('./test_socket_e2e.js');
|
||||
}, 1500);
|
||||
@@ -0,0 +1,55 @@
|
||||
// 極簡靜態伺服器:服務 client/dist(前端 build 產物),並把 /api 代理到後端 :3000
|
||||
// 用法:node scripts/serve_dist.js [port] (預設 5173)
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const port = parseInt(process.argv[2] || '5173', 10);
|
||||
const backendPort = parseInt(process.argv[3] || '3000', 10);
|
||||
const distDir = path.resolve(__dirname, '../client/dist');
|
||||
const types = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.map': 'application/json'
|
||||
};
|
||||
|
||||
if (!fs.existsSync(distDir)) {
|
||||
console.error('找不到 client/dist,請先執行 scripts/build_frontend.ps1');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// /api -> 後端 proxy
|
||||
function proxyApi(req, res) {
|
||||
const proxyReq = http.request({
|
||||
host: 'localhost',
|
||||
port: backendPort,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: Object.assign({}, req.headers, { host: 'localhost:' + backendPort })
|
||||
}, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
proxyReq.on('error', (e) => { res.writeHead(502); res.end('Bad Gateway: ' + e.message); });
|
||||
req.pipe(proxyReq);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url.startsWith('/api')) { proxyApi(req, res); return; }
|
||||
let urlPath = decodeURIComponent(req.url.split('?')[0]);
|
||||
if (urlPath === '/') urlPath = '/index.html';
|
||||
const filePath = path.join(distDir, urlPath);
|
||||
if (!filePath.startsWith(distDir)) { res.writeHead(403); res.end('Forbidden'); return; }
|
||||
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) { res.writeHead(404); res.end('Not Found'); return; }
|
||||
res.writeHead(200, { 'Content-Type': types[path.extname(filePath)] || 'application/octet-stream' });
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log('靜態伺服器啟動於 http://localhost:' + port + '(服務 client/dist,/api 代理至 :' + backendPort + ')');
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Python 引擎冒煙測試(不連網、不呼叫真實 LLM)。
|
||||
|
||||
以「模擬 LLM」取代 API 呼叫,跑完整場騙子酒館遊戲,驗證
|
||||
game.py / player.py / game_record.py 主流程可正常完成並產生記錄。
|
||||
使用方式:
|
||||
.venv\\Scripts\\python.exe scripts\\smoke_test_python.py
|
||||
(若主控台遇到簡體中文編碼錯誤,請先設定 $env:PYTHONIOENCODING="utf-8")
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import random
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, REPO_ROOT)
|
||||
|
||||
from game import Game
|
||||
|
||||
|
||||
class MockChat:
|
||||
"""附著在某位玩家身上的假 LLM:依提示內容回傳格式正確的 JSON。
|
||||
注意:chat() 統一回傳 (content, reasoning_content) 二元素 tuple。
|
||||
"""
|
||||
|
||||
def __init__(self, player):
|
||||
self.player = player
|
||||
self.decision_count = 0
|
||||
|
||||
def chat(self, messages, model=None):
|
||||
prompt = messages[0]["content"] if messages else ""
|
||||
if "played_cards" in prompt:
|
||||
return self._play_reply(), ""
|
||||
if "was_challenged" in prompt:
|
||||
return self._challenge_reply(), ""
|
||||
return "mock opinion: \u8a72\u73a9\u5bb6\u76ee\u524d\u7121\u660e\u986f\u50be\u5411\u3002", ""
|
||||
|
||||
def _play_reply(self):
|
||||
"""\u7d66\u4e86\u81ea\u5df1\u7684\u624b\u724c\uff1a\u540c\u9ede\u6578 >=2 \u51fa\u6700\u591a 3 \u5f35\uff0c\u5426\u5247\u51fa 1 \u5f35\u3002"""
|
||||
hand = sorted(self.player.hand)
|
||||
cards = [hand[0]]
|
||||
for c in hand[1:3]:
|
||||
if c == cards[0]:
|
||||
cards.append(c)
|
||||
cards_json = ",".join('"%s"' % c for c in cards)
|
||||
return ('{"played_cards": [%s], "behavior": "\u7a69\u5065", '
|
||||
'"play_reason": "mock heuristic"}' % cards_json)
|
||||
|
||||
def _challenge_reply(self):
|
||||
"""\u6bcf 4 \u6b21\u8cea\u7591\u6c7a\u7b56\u5c31\u8cea\u7591\u4e00\u6b21\uff0c\u8986\u84cb\u6210\u529f/\u5931\u6557\u5169\u689d\u8def\u5f91\u3002"""
|
||||
self.decision_count += 1
|
||||
do_challenge = (self.decision_count % 4 == 0)
|
||||
return ('{"was_challenged": %s, "challenge_reason": "mock trigger"}' %
|
||||
("true" if do_challenge else "false"))
|
||||
|
||||
|
||||
def main():
|
||||
random.seed(20260803)
|
||||
player_configs = [
|
||||
{"name": "DeepSeek", "model": "mock"},
|
||||
{"name": "GPT", "model": "mock"},
|
||||
{"name": "Claude", "model": "mock"},
|
||||
{"name": "Gemini", "model": "mock"},
|
||||
]
|
||||
|
||||
game = Game(player_configs)
|
||||
for p in game.players:
|
||||
p.llm_client = MockChat(p)
|
||||
|
||||
max_plays = 400
|
||||
for step in range(max_plays):
|
||||
if game.game_over:
|
||||
break
|
||||
game.play_round()
|
||||
|
||||
if game.game_over:
|
||||
print("[OK] \u904a\u6232\u7d50\u675f\uff0c\u4e3b\u6d41\u7a0b\u53ef\u6b63\u5e38\u5b8c\u6210\u3002")
|
||||
else:
|
||||
print(f"[FAIL] \u8dd1\u4e86 {max_plays} \u6b65\u4ecd\u672a\u7d50\u675f\uff08\u53ef\u80fd\u89c4\u5247\u6709\u6b7b\u7d50\uff09\u3002 rounds={game.round_count}")
|
||||
return 1
|
||||
|
||||
rec = game.game_record
|
||||
rounds = len(rec.rounds)
|
||||
alive = [p.name for p in game.players if p.alive]
|
||||
print(f"[OK] \u7372\u52dd\u8005={rec.winner} | \u5b58\u6d3b\u73a9\u5bb6={alive}")
|
||||
print(f"[OK] \u8a18\u9304\u56de\u5408\u6578={rounds}")
|
||||
|
||||
import glob
|
||||
files = glob.glob(os.path.join(REPO_ROOT, "game_records", "*.json"))
|
||||
print(f"[OK] game_records \u7522\u51fa {len(files)} \u500b JSON \u8a18\u9304\u6a94")
|
||||
if files:
|
||||
newest = max(files, key=os.path.getmtime)
|
||||
print(f" \u6700\u65b0\u8a18\u9304: {os.path.basename(newest)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,33 @@
|
||||
// 後端冒煙測試:載入 index.js(自動監聽 3000)-> 測 REST 路由 -> 優雅關閉。
|
||||
// 使用方式: node scripts/smoke_test_server.js (需在專案根目錄執行)
|
||||
const { app, io } = require("../server/index.js");
|
||||
|
||||
async function run() {
|
||||
let exitCode = 0;
|
||||
try {
|
||||
const listRes = await fetch("http://localhost:3000/api/rooms");
|
||||
const listBody = await listRes.json();
|
||||
console.log("[OK] GET /api/rooms ->", listRes.status, JSON.stringify(listBody));
|
||||
|
||||
const createRes = await fetch("http://localhost:3000/api/rooms", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ hostId: "test-host-1" }),
|
||||
});
|
||||
const room = await createRes.json();
|
||||
console.log("[OK] POST /api/rooms ->", createRes.status,
|
||||
"roomId=", room.id, "players=", room.players.length, "status=", room.status);
|
||||
} catch (e) {
|
||||
console.error("[FAIL]", e.message);
|
||||
exitCode = 1;
|
||||
}
|
||||
|
||||
io.close(() => {
|
||||
console.log(exitCode === 0 ? "[OK] 後端可正常啟動與回應 REST API" : "[FAIL] 後端測試失敗");
|
||||
process.exit(exitCode);
|
||||
});
|
||||
// 保險:2 秒後強制退出,避免卡住
|
||||
setTimeout(() => process.exit(exitCode), 2500);
|
||||
}
|
||||
|
||||
setTimeout(run, 1500);
|
||||
@@ -0,0 +1,35 @@
|
||||
// Socket.IO 端對端冒煙測試:連上後端 -> createRoom -> 收到 roomCreated 即成功。
|
||||
// 使用:需先有伺服器在 http://localhost:3000 執行。
|
||||
const { io } = require("../client/node_modules/socket.io-client");
|
||||
|
||||
const socket = io("http://localhost:3000", {
|
||||
transports: ["websocket"],
|
||||
reconnection: false,
|
||||
timeout: 6000,
|
||||
});
|
||||
|
||||
let done = false;
|
||||
|
||||
socket.on("connect", () => {
|
||||
console.log("[OK] socket connected, id =", socket.id);
|
||||
socket.emit("createRoom");
|
||||
});
|
||||
|
||||
socket.on("roomCreated", (room) => {
|
||||
console.log("[OK] roomCreated -> roomId=", room.id, "status=", room.status, "players=", room.players.length);
|
||||
done = true;
|
||||
socket.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
socket.on("connect_error", (e) => {
|
||||
console.error("[FAIL] connect_error:", e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (!done) {
|
||||
console.error("[FAIL] 8 秒內未收到 roomCreated(逾時)");
|
||||
process.exit(1);
|
||||
}
|
||||
}, 8000);
|
||||
@@ -0,0 +1,217 @@
|
||||
// 引擎單元測試(用 Node 內建 assert,無外部依賴)
|
||||
// 使用:node scripts/test_engine.js
|
||||
const assert = require('assert');
|
||||
const GameEngine = require('../server/game/engine.js');
|
||||
|
||||
const results = [];
|
||||
function test(name, fn) {
|
||||
try { fn(); results.push(['ok', name]); }
|
||||
catch (e) { results.push(['fail', name, e.message]); }
|
||||
}
|
||||
|
||||
// 1) 發牌與牌組
|
||||
test('init 4 人:每人 5 張、目標牌正確、round=1', () => {
|
||||
const e = new GameEngine();
|
||||
const st = e.init(['p1', 'p2', 'p3', 'p4']);
|
||||
assert.strictEqual(st.players.length, 4);
|
||||
for (const p of st.players) assert.strictEqual(p.handSize, 5);
|
||||
assert.ok(['Q', 'K', 'A'].includes(st.targetCard));
|
||||
assert.strictEqual(st.roundNumber, 1);
|
||||
assert.ok(st.currentPlayerId);
|
||||
});
|
||||
|
||||
test('init 8 人:每人 5 張', () => {
|
||||
const e = new GameEngine();
|
||||
const st = e.init(Array.from({ length: 8 }, (_, i) => 'p' + i));
|
||||
assert.strictEqual(st.players.length, 8);
|
||||
for (const p of st.players) assert.strictEqual(p.handSize, 5);
|
||||
});
|
||||
|
||||
test('牌組總張數 = 人數 x5(2/3/5/8)', () => {
|
||||
for (const n of [2, 3, 5, 8]) {
|
||||
const e = new GameEngine();
|
||||
const deck = e.createDeck(n);
|
||||
assert.strictEqual(deck.length, n * 5, `n=${n}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 2) 出牌驗證
|
||||
test('出牌:0 張 → 錯誤', () => {
|
||||
const e = new GameEngine(); e.init(['a', 'b']);
|
||||
assert.ok(e.playCard(e.currentPlayerId, []).error);
|
||||
});
|
||||
test('出牌:4 張 → 錯誤', () => {
|
||||
const e = new GameEngine(); e.init(['a', 'b']);
|
||||
assert.ok(e.playCard(e.currentPlayerId, [0, 1, 2, 3]).error);
|
||||
});
|
||||
test('出牌:索引超出範圍 → 錯誤', () => {
|
||||
const e = new GameEngine(); e.init(['a', 'b']);
|
||||
assert.ok(e.playCard(e.currentPlayerId, [99]).error);
|
||||
});
|
||||
test('出牌:重複索引 → 錯誤', () => {
|
||||
const e = new GameEngine(); e.init(['a', 'b']);
|
||||
assert.ok(e.playCard(e.currentPlayerId, [0, 0]).error);
|
||||
});
|
||||
test('出牌:非你的回合 → 錯誤', () => {
|
||||
const e = new GameEngine(); e.init(['a', 'b']);
|
||||
const other = Array.from(e.players.keys()).find((k) => k !== e.currentPlayerId);
|
||||
assert.ok(e.playCard(other, [0]).error);
|
||||
});
|
||||
test('出牌:合法出 1-3 張並移除、換下一位', () => {
|
||||
const e = new GameEngine(); e.init(['a', 'b', 'c']);
|
||||
const pid = e.currentPlayerId;
|
||||
const before = e.getPlayerHand(pid).length;
|
||||
const r = e.playCard(pid, [0]);
|
||||
assert.strictEqual(r.error, undefined);
|
||||
assert.strictEqual(e.getPlayerHand(pid).length, before - 1);
|
||||
assert.strictEqual(r.nextPlayerId, e.currentPlayerId);
|
||||
});
|
||||
|
||||
// 3) 質疑:整組判定
|
||||
test('質疑:吹牛組(含非目標牌)→ 質疑成功,出牌者開槍', () => {
|
||||
const e = new GameEngine(); e.init(['a', 'b', 'c']);
|
||||
e.targetCard = 'A';
|
||||
e.getPlayer('a').hand = ['A', 'K', 'JOKER'];
|
||||
e.getPlayer('b').hand = ['A', 'A', 'A'];
|
||||
e.getPlayer('c').hand = ['A', 'A'];
|
||||
e.currentPlayerId = 'a';
|
||||
const r = e.playCard('a', [0, 1]); // A, K
|
||||
assert.strictEqual(r.error, undefined);
|
||||
const c = e.challenge('b');
|
||||
assert.strictEqual(c.isBluff, true);
|
||||
assert.strictEqual(c.shooter, 'a');
|
||||
});
|
||||
|
||||
test('質疑:真牌組(全目標牌)→ 質疑失敗,質疑者開槍', () => {
|
||||
const e = new GameEngine(); e.init(['a', 'b', 'c']);
|
||||
e.targetCard = 'A';
|
||||
e.getPlayer('a').hand = ['A', 'Q', 'Q'];
|
||||
e.getPlayer('b').hand = ['A', 'A', 'A'];
|
||||
e.getPlayer('c').hand = ['A', 'A'];
|
||||
e.currentPlayerId = 'a';
|
||||
e.playCard('a', [0]); // A
|
||||
const c = e.challenge('b');
|
||||
assert.strictEqual(c.isBluff, false);
|
||||
assert.strictEqual(c.shooter, 'b');
|
||||
});
|
||||
|
||||
test('質疑:Joker 視為萬能牌 → 質疑失敗', () => {
|
||||
// 用 3 人局:a 出 JOKER 後仍有玩家( c )持牌,b 才能主動質疑
|
||||
const e = new GameEngine(); e.init(['a', 'b', 'c']);
|
||||
e.targetCard = 'K';
|
||||
e.getPlayer('a').hand = ['JOKER', 'Q'];
|
||||
e.getPlayer('b').hand = ['K', 'K'];
|
||||
e.getPlayer('c').hand = ['Q', 'Q', 'Q'];
|
||||
e.currentPlayerId = 'a';
|
||||
e.playCard('a', [0]); // JOKER(宣稱 K)
|
||||
const c = e.challenge('b');
|
||||
assert.strictEqual(c.isBluff, false); // JOKER 是萬能牌 → 質疑失敗
|
||||
});
|
||||
|
||||
// 3.5) 資訊邊界:公開狀態不洩漏實際牌值
|
||||
test('資訊邊界:出牌後 gameState 只含張數、不含真實牌值', () => {
|
||||
const e = new GameEngine(); e.init(['a', 'b', 'c']);
|
||||
const pid = e.currentPlayerId;
|
||||
e.playCard(pid, [0]);
|
||||
const st = e.getGameState();
|
||||
for (const g of st.playedGroups) {
|
||||
assert.strictEqual(typeof g.cardCount, 'number');
|
||||
assert.strictEqual(g.cards, undefined, 'playedGroups 不應洩漏 cards');
|
||||
}
|
||||
assert.ok(!Array.isArray(st.playedCards) || st.playedCards.length === 0, 'playedCards 不應含牌值');
|
||||
});
|
||||
test('質疑時才揭露真實牌值(challengedGroup)', () => {
|
||||
const e = new GameEngine(); e.init(['a', 'b', 'c']);
|
||||
e.targetCard = 'A';
|
||||
e.currentPlayerId = 'a';
|
||||
e.playCard('a', [0]);
|
||||
const lastGroup = e.playGroups[e.playGroups.length - 1];
|
||||
const before = [...lastGroup.cards]; // 引擎內部可讀
|
||||
const st = e.getGameState();
|
||||
// 公開狀態沒有牌值
|
||||
for (const g of st.playedGroups) assert.strictEqual(g.cards, undefined);
|
||||
const r = e.challenge('b');
|
||||
assert.ok(Array.isArray(r.challengedGroup) && r.challengedGroup.length === before.length,
|
||||
'質疑結果才揭露牌值');
|
||||
});
|
||||
|
||||
// 4) 系統質疑
|
||||
test('系統質疑:輪到某玩家且其他人都沒牌 → 自動打出並質疑', () => {
|
||||
const e = new GameEngine(); e.init(['a', 'b', 'c']);
|
||||
e.targetCard = 'A';
|
||||
e.getPlayer('a').hand = ['K', 'K'];
|
||||
e.getPlayer('b').hand = ['A'];
|
||||
e.getPlayer('c').hand = [];
|
||||
e.currentPlayerId = 'b';
|
||||
const r = e.playCard('b', [0]); // b 出 A,手牌清空
|
||||
assert.ok(r.systemChallenge, '應觸發系統質疑');
|
||||
assert.strictEqual(r.systemChallenge.playerId, 'a');
|
||||
assert.strictEqual(r.systemChallenge.isBluff, true); // K 不是 A
|
||||
assert.strictEqual(r.systemChallenge.shooter, 'a');
|
||||
});
|
||||
|
||||
test('系統質疑:若剩餘全為真牌 → 無人開槍並重開一手', () => {
|
||||
const e = new GameEngine(); e.init(['a', 'b', 'c']);
|
||||
e.targetCard = 'A';
|
||||
e.getPlayer('a').hand = ['A', 'A'];
|
||||
e.getPlayer('b').hand = ['A'];
|
||||
e.getPlayer('c').hand = [];
|
||||
e.currentPlayerId = 'b';
|
||||
const r = e.playCard('b', [0]);
|
||||
assert.ok(r.systemChallenge);
|
||||
assert.strictEqual(r.systemChallenge.isBluff, false);
|
||||
assert.strictEqual(r.systemChallenge.shooter, null);
|
||||
// 重開一手後 a 仍有 5 張
|
||||
assert.strictEqual(e.getPlayerHand('a').length, 5);
|
||||
});
|
||||
|
||||
// 5) 重開一手與死亡
|
||||
test('質疑後重開一手:存活者各 5 張、死掉者 0 張', () => {
|
||||
const e = new GameEngine(); e.init(['a', 'b', 'c']);
|
||||
e.targetCard = 'A';
|
||||
e.getPlayer('a').hand = ['A', 'K', 'JOKER'];
|
||||
e.getPlayer('b').hand = ['A', 'A', 'A'];
|
||||
e.getPlayer('c').hand = ['A', 'A'];
|
||||
e.currentPlayerId = 'a';
|
||||
e.playCard('a', [0, 1]); // A, K 吹牛
|
||||
// 讓 a 必死
|
||||
e.getPlayer('a').revolver = { chambers: [true, false, false, false, false, false], currentChamber: 0 };
|
||||
const c = e.challenge('b');
|
||||
assert.strictEqual(c.shooter, 'a');
|
||||
assert.strictEqual(e.getPlayer('a').alive, false);
|
||||
assert.strictEqual(e.getPlayerHand('a').length, 0);
|
||||
assert.strictEqual(e.getPlayerHand('b').length, 5);
|
||||
assert.ok(e.roundNumber >= 2);
|
||||
});
|
||||
|
||||
// 6) 完整模擬:必定會結束
|
||||
test('完整隨機模擬可正常結束,存活者 <= 1', () => {
|
||||
const e = new GameEngine();
|
||||
e.init(['p1', 'p2', 'p3', 'p4']);
|
||||
let guard = 0;
|
||||
while (!e.isGameOver && guard < 3000) {
|
||||
guard++;
|
||||
const pid = e.currentPlayerId;
|
||||
if (!pid) break;
|
||||
const hand = e.getPlayerHand(pid);
|
||||
if (hand.length === 0) break;
|
||||
const n = 1 + Math.floor(Math.random() * Math.min(3, hand.length));
|
||||
const idx = Array.from({ length: n }, (_, i) => i);
|
||||
e.playCard(pid, idx);
|
||||
if (Math.random() < 0.35 && e.playGroups.length > 0) {
|
||||
e.challenge(e.currentPlayerId);
|
||||
}
|
||||
}
|
||||
assert.ok(e.isGameOver || guard >= 3000 === false, '遊戲應結束(無死結)');
|
||||
assert.ok(e.getAlivePlayers().length <= 1, '最多一位存活');
|
||||
assert.ok(e.roundNumber > 0);
|
||||
});
|
||||
|
||||
// 印出結果
|
||||
let failed = 0;
|
||||
for (const [status, name, msg] of results) {
|
||||
if (status === 'ok') console.log(' PASS ' + name);
|
||||
else { failed++; console.log(' FAIL ' + name + ' -> ' + msg); }
|
||||
}
|
||||
console.log(`\n${results.length - failed}/${results.length} 通過`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,109 @@
|
||||
// 多人端對端整合測試:連真實伺服器,驗證「多張出牌 → 下家挑戰 → 手牌更新」流程。
|
||||
// 使用:先啟動 server(node server/index.js),再跑 node scripts/test_socket_e2e.js
|
||||
const { io } = require('../client/node_modules/socket.io-client');
|
||||
|
||||
const URL = 'http://localhost:3000';
|
||||
const clients = {};
|
||||
|
||||
function connect(name) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = io(URL, { transports: ['websocket'], reconnection: false, timeout: 8000 });
|
||||
s._hand = null;
|
||||
s._name = name;
|
||||
s._lastError = null;
|
||||
s.on('connect', () => { clients[s.id] = s; resolve(s); });
|
||||
s.on('connect_error', (e) => reject(new Error(name + ' connect_error: ' + e.message)));
|
||||
s.on('yourHand', (h) => { s._hand = h || []; });
|
||||
s.on('error', (d) => { s._lastError = d && d.message; });
|
||||
});
|
||||
}
|
||||
|
||||
function once(socket, ev, timeout = 8000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error(`${socket._name} wait ${ev} timeout`)), timeout);
|
||||
socket.once(ev, (d) => { clearTimeout(t); resolve(d); });
|
||||
});
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let failures = 0;
|
||||
function ok(cond, msg) {
|
||||
if (cond) console.log(' PASS ' + msg);
|
||||
else { failures++; console.log(' FAIL ' + msg); }
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log('[1] 三玩家連線+房間');
|
||||
const a = await connect('A');
|
||||
const b = await connect('B');
|
||||
const c = await connect('C');
|
||||
|
||||
const roomCreated = once(a, 'roomCreated');
|
||||
a.emit('createRoom');
|
||||
const room = await roomCreated;
|
||||
const roomId = room.id;
|
||||
ok(roomId, '建立房間 roomId=' + roomId);
|
||||
|
||||
b.emit('joinRoom', { roomId });
|
||||
await once(a, 'playerJoined');
|
||||
c.emit('joinRoom', { roomId });
|
||||
await once(a, 'playerJoined');
|
||||
ok(true, 'B、C 加入');
|
||||
|
||||
console.log('[2] 開始遊戲 + 私密手牌');
|
||||
const started = [once(a, 'gameStarted'), once(b, 'gameStarted'), once(c, 'gameStarted')];
|
||||
a.emit('startGame', { roomId });
|
||||
const [gsA] = await Promise.all(started);
|
||||
ok(gsA.currentPlayerId && ['Q','K','A'].includes(gsA.targetCard), 'gameStarted 狀態正確');
|
||||
ok(gsA.players.length === 3, '3 位玩家');
|
||||
await sleep(300);
|
||||
ok(a._hand.length === 5 && b._hand.length === 5 && c._hand.length === 5,
|
||||
'每位玩家收到 5 張私密手牌(yourHand)');
|
||||
|
||||
console.log('[3] 目前玩家多張出牌');
|
||||
const cur = clients[gsA.currentPlayerId];
|
||||
ok(!!cur, '定位目前玩家');
|
||||
const playedEvt = once(a, 'cardPlayed');
|
||||
cur.emit('playCard', { roomId, cardIndices: [0, 1] });
|
||||
const played = await playedEvt;
|
||||
// 資訊邊界:cardPlayed 只公開張數,不洩漏實際牌值
|
||||
ok(played.playedGroup && played.playedGroup.cardCount === 2, '出牌 2 張並廣播(只含張數)');
|
||||
ok(!('cards' in (played.playedGroup || {})), 'cardPlayed 不洩漏實際牌值');
|
||||
const currentNow = played.gameState.currentPlayerId;
|
||||
ok(currentNow !== cur.id, '換下一位');
|
||||
await sleep(300);
|
||||
ok(cur._hand.length === 3, `出牌者剩 3 張 (yourHand 同步,實際=${cur._hand.length})`);
|
||||
|
||||
console.log('[4] 非我的回合出牌 → 被拒絕');
|
||||
const notTurn = [a, b, c].find((s) => s.id !== cur.id && s.id !== currentNow);
|
||||
ok(!!notTurn, '找到非回合玩家');
|
||||
notTurn._lastError = null;
|
||||
notTurn.emit('playCard', { roomId, cardIndices: [0] });
|
||||
await sleep(300);
|
||||
ok(!!notTurn._lastError, '非回合者被拒絕(' + (notTurn._lastError || 'none') + ')');
|
||||
|
||||
console.log('[5] 目前玩家(下家)質疑 → 收到結果與新狀態');
|
||||
const currentSocket = clients[currentNow];
|
||||
const challengedEvt = once(a, 'challenged');
|
||||
currentSocket.emit('challenge', { roomId });
|
||||
const ch = await challengedEvt;
|
||||
ok(ch.result && typeof ch.result.isBluff === 'boolean', '質疑結果帶 isBluff=' + ch.result.isBluff);
|
||||
ok(ch.result.shooter, '有開槍者(shooter=' + ch.result.shooter + ')');
|
||||
ok(ch.gameState.roundNumber >= 2, '質疑後進入下一局(round=' + ch.gameState.roundNumber + ')');
|
||||
await sleep(300);
|
||||
ok(ch.gameState.players.every((p) => {
|
||||
const sh = clients[p.id];
|
||||
return !sh || (sh._hand.length === p.handSize);
|
||||
}), 'yourHand 與公開狀態一致(' + ch.gameState.players.map((p) => p.handSize).join(',') + ')');
|
||||
|
||||
const aliveCount = ch.gameState.players.filter((p) => p.alive).length;
|
||||
console.log(`[6] 結果快照:第 ${ch.gameState.roundNumber} 局,存活 ${aliveCount}/3,isBluff=${ch.result.isBluff}`);
|
||||
|
||||
[a, b, c].forEach((s) => s.close());
|
||||
await sleep(300);
|
||||
console.log(failures === 0 ? '\n整合測試全部通過' : `\n有 ${failures} 項失敗`);
|
||||
process.exit(failures ? 1 : 0);
|
||||
})().catch((e) => {
|
||||
console.error('[FATAL]', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
// 遊戲引擎核心 (Liar's Bar)
|
||||
// 規則與 Python 版 (game.py) 對齊:
|
||||
// - 每手每人 5 張,牌組依玩家人數等比擴充(Q:K:A 等量 + 約 10% Joker),支援 2-8 人
|
||||
// - 每次可出 1-3 張,宣稱皆為目標牌;Joker 為萬能牌
|
||||
// - 下一位玩家可選擇出牌或質疑「上一組」出的牌
|
||||
// - 質疑:整組牌全為目標牌/Joker 則質疑失敗(質疑者開槍),否則質疑成功(出牌者開槍)
|
||||
// - 質疑後本手結束,存活玩家重新發牌並選新目標牌
|
||||
// - 特殊:輪到某玩家時若其他存活玩家已無牌,該玩家人餘牌視為自動打出並受系統質疑
|
||||
// - 僅存一位存活者時結束
|
||||
|
||||
const HAND_SIZE = 5;
|
||||
const REVOLVER_CHAMBERS = 6;
|
||||
const TARGETS = ['Q', 'K', 'A'];
|
||||
const MAX_PLAY_PER_TURN = 3;
|
||||
|
||||
class GameEngine {
|
||||
constructor() {
|
||||
this.players = new Map(); // id -> { id, hand, revolver, alive }
|
||||
this.currentPlayerId = null;
|
||||
this.targetCard = null;
|
||||
this.playGroups = []; // 本手連續出牌組 [{ playerId, cards }]
|
||||
this.roundNumber = 0;
|
||||
this.isGameOver = false;
|
||||
this.winnerId = null;
|
||||
this.lastShooterId = null; // 上一手開槍者(存活時作為下一手起始)
|
||||
}
|
||||
|
||||
// ---------- 初始化 ----------
|
||||
|
||||
init(playerIds) {
|
||||
this.players.clear();
|
||||
this.currentPlayerId = null;
|
||||
this.targetCard = null;
|
||||
this.playGroups = [];
|
||||
this.roundNumber = 0;
|
||||
this.isGameOver = false;
|
||||
this.winnerId = null;
|
||||
this.lastShooterId = null;
|
||||
|
||||
for (const id of playerIds) {
|
||||
this.players.set(id, {
|
||||
id,
|
||||
hand: [],
|
||||
revolver: this.createRevolver(),
|
||||
alive: true
|
||||
});
|
||||
}
|
||||
|
||||
this.roundNumber = 1;
|
||||
this.dealCards(this.getAlivePlayers().length);
|
||||
this.setTargetCard();
|
||||
this.currentPlayerId = this.pickRandomAlivePlayer();
|
||||
return this.getGameState();
|
||||
}
|
||||
|
||||
// 依存活人數建立等比牌組(每人 5 張)
|
||||
createDeck(playerCount) {
|
||||
const total = playerCount * HAND_SIZE;
|
||||
const jokerCount = Math.max(1, Math.round(total * 0.1));
|
||||
const normalTotal = total - jokerCount;
|
||||
const base = Math.floor(normalTotal / 3);
|
||||
let remainder = normalTotal % 3;
|
||||
const counts = { Q: base, K: base, A: base };
|
||||
for (const key of TARGETS) {
|
||||
if (remainder > 0) { counts[key] += 1; remainder -= 1; }
|
||||
}
|
||||
const deck = [];
|
||||
for (const key of Object.keys(counts)) {
|
||||
for (let i = 0; i < counts[key]; i++) deck.push(key);
|
||||
}
|
||||
for (let i = 0; i < jokerCount; i++) deck.push('JOKER');
|
||||
this.shuffle(deck);
|
||||
return deck;
|
||||
}
|
||||
|
||||
// ---------- 基本工具 ----------
|
||||
|
||||
shuffle(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);
|
||||
chambers[Math.floor(Math.random() * REVOLVER_CHAMBERS)] = true;
|
||||
return { chambers, currentChamber: 0 };
|
||||
}
|
||||
|
||||
setTargetCard() {
|
||||
this.targetCard = TARGETS[Math.floor(Math.random() * TARGETS.length)];
|
||||
}
|
||||
|
||||
getAlivePlayers() {
|
||||
return Array.from(this.players.values()).filter((p) => p.alive);
|
||||
}
|
||||
|
||||
getPlayer(playerId) {
|
||||
return this.players.get(playerId);
|
||||
}
|
||||
|
||||
pickRandomAlivePlayer() {
|
||||
const alive = this.getAlivePlayers();
|
||||
return alive.length ? alive[Math.floor(Math.random() * alive.length)].id : null;
|
||||
}
|
||||
|
||||
// 目前玩家的下一位「有牌且存活」的玩家
|
||||
getNextAlivePlayerWithCards() {
|
||||
const ids = Array.from(this.players.keys());
|
||||
if (ids.length === 0) return null;
|
||||
const curIdx = ids.indexOf(this.currentPlayerId);
|
||||
for (let i = 1; i <= ids.length; i++) {
|
||||
const player = this.players.get(ids[(curIdx + i) % ids.length]);
|
||||
if (player && player.alive && player.hand.length > 0) return player.id;
|
||||
}
|
||||
// 所有人手上都無牌:回傳目前玩家(由呼叫端處理)
|
||||
return this.currentPlayerId;
|
||||
}
|
||||
|
||||
isAllOthersEmpty(playerId) {
|
||||
return this.getAlivePlayers()
|
||||
.filter((p) => p.id !== playerId)
|
||||
.every((p) => p.hand.length === 0);
|
||||
}
|
||||
|
||||
// ---------- 回合流程 ----------
|
||||
|
||||
// 出牌:cardIndices 為 1-3 個手牌索引(此輪宣稱皆為目標牌)
|
||||
playCard(playerId, cardIndices) {
|
||||
if (this.isGameOver) return { error: '遊戲已結束' };
|
||||
const player = this.getPlayer(playerId);
|
||||
if (!player || !player.alive) return { error: '玩家已死亡' };
|
||||
if (playerId !== this.currentPlayerId) return { error: '還沒輪到你出牌' };
|
||||
|
||||
if (!Array.isArray(cardIndices) || cardIndices.length < 1 || cardIndices.length > MAX_PLAY_PER_TURN) {
|
||||
return { error: `每次只能出 1-${MAX_PLAY_PER_TURN} 張` };
|
||||
}
|
||||
const unique = new Set(cardIndices);
|
||||
if (unique.size !== cardIndices.length) {
|
||||
return { error: '不能重複選擇同一張牌' };
|
||||
}
|
||||
for (const idx of cardIndices) {
|
||||
if (!Number.isInteger(idx) || idx < 0 || idx >= player.hand.length) {
|
||||
return { error: '無效的選牌索引' };
|
||||
}
|
||||
}
|
||||
|
||||
const played = cardIndices.map((idx) => player.hand[idx]);
|
||||
// 由大到小移除,避免索引位移
|
||||
[...cardIndices].sort((a, b) => b - a).forEach((idx) => player.hand.splice(idx, 1));
|
||||
this.playGroups.push({ playerId: player.id, cards: played });
|
||||
|
||||
this.currentPlayerId = this.getNextAlivePlayerWithCards();
|
||||
|
||||
const baseResult = {
|
||||
success: true,
|
||||
playedGroup: { playerId: player.id, cards: played },
|
||||
nextPlayerId: this.currentPlayerId
|
||||
};
|
||||
|
||||
// 特殊規則:輪到 nextPlayer 時其他存活玩家已無牌 → 系統質疑(自動打出剩餘手牌)
|
||||
const allAliveEmpty = this.getAlivePlayers().every((p) => p.hand.length === 0);
|
||||
const othersEmpty = !allAliveEmpty
|
||||
&& this.currentPlayerId !== null
|
||||
&& this.getPlayer(this.currentPlayerId).hand.length > 0
|
||||
&& this.isAllOthersEmpty(this.currentPlayerId);
|
||||
|
||||
if (othersEmpty) {
|
||||
const sys = this.systemChallenge(this.currentPlayerId);
|
||||
baseResult.systemChallenge = sys;
|
||||
} else if (allAliveEmpty) {
|
||||
// 所有人同時無牌(理論上不會發生)→ 視為無效質疑,重開一手
|
||||
baseResult.autoRoundReset = true;
|
||||
this.startNewRound();
|
||||
}
|
||||
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
// 系統質疑:自動打出某玩家剩餘手牌並質疑
|
||||
systemChallenge(playerId) {
|
||||
const player = this.getPlayer(playerId);
|
||||
const cards = [...player.hand];
|
||||
player.hand = [];
|
||||
const valid = cards.length > 0 && cards.every((c) => c === this.targetCard || c === 'JOKER');
|
||||
const isBluff = !valid;
|
||||
|
||||
let shooter = null;
|
||||
let shot = null;
|
||||
if (isBluff) {
|
||||
// 剩餘手牌有假牌 → 出牌者開槍
|
||||
shooter = playerId;
|
||||
shot = this.fireRevolver(playerId);
|
||||
} else {
|
||||
// 全是真牌 → 系統質疑失敗(與 Python 一致:無人開槍)
|
||||
shot = { playerId, hasBullet: false, alive: player.alive };
|
||||
}
|
||||
|
||||
this.clearTable();
|
||||
this.startNewRound();
|
||||
|
||||
return {
|
||||
systemChallenge: true,
|
||||
playerId,
|
||||
autoPlayedCards: cards,
|
||||
isBluff,
|
||||
shooter,
|
||||
shot
|
||||
};
|
||||
}
|
||||
|
||||
// 質疑上一組出的牌(由下一位玩家執行)
|
||||
challenge(challengerId) {
|
||||
if (this.isGameOver) return { error: '遊戲已結束' };
|
||||
const challenger = this.getPlayer(challengerId);
|
||||
if (!challenger || !challenger.alive) return { error: '挑戰者已死亡' };
|
||||
if (challengerId !== this.currentPlayerId) return { error: '還沒輪到你質疑' };
|
||||
|
||||
const last = this.playGroups[this.playGroups.length - 1];
|
||||
if (!last) return { error: '還沒有出牌可以質疑' };
|
||||
|
||||
const valid = last.cards.length > 0 && last.cards.every((c) => c === this.targetCard || c === 'JOKER');
|
||||
const isBluff = !valid;
|
||||
|
||||
const result = {
|
||||
challengerId,
|
||||
playerId: last.playerId,
|
||||
isBluff,
|
||||
challengedGroup: last.cards
|
||||
};
|
||||
|
||||
if (isBluff) {
|
||||
// 質疑成功 → 出牌者開槍
|
||||
result.shooter = last.playerId;
|
||||
result.shot = this.fireRevolver(last.playerId);
|
||||
} else {
|
||||
// 質疑失敗 → 質疑者開槍
|
||||
result.shooter = challengerId;
|
||||
result.shot = this.fireRevolver(challengerId);
|
||||
}
|
||||
|
||||
this.clearTable();
|
||||
// 質疑後本手結束,重開一手(若遊戲還未結束)
|
||||
this.startNewRound();
|
||||
return result;
|
||||
}
|
||||
|
||||
// 重開一手:重新發牌、選目標牌、決定起始玩家
|
||||
startNewRound() {
|
||||
const alive = this.getAlivePlayers();
|
||||
if (alive.length <= 1) {
|
||||
this.isGameOver = true;
|
||||
this.winnerId = alive.length === 1 ? alive[0].id : null;
|
||||
return;
|
||||
}
|
||||
this.roundNumber += 1;
|
||||
this.clearTable();
|
||||
this.setTargetCard();
|
||||
this.dealCards(alive.length);
|
||||
|
||||
// 起始玩家:上一手開槍者(若存活),否則隨機
|
||||
if (this.lastShooterId && this.getPlayer(this.lastShooterId) && this.getPlayer(this.lastShooterId).alive) {
|
||||
this.currentPlayerId = this.lastShooterId;
|
||||
} else {
|
||||
this.currentPlayerId = this.pickRandomAlivePlayer();
|
||||
}
|
||||
}
|
||||
|
||||
clearTable() {
|
||||
this.playGroups = [];
|
||||
}
|
||||
|
||||
dealCards(aliveCount) {
|
||||
for (const p of this.players.values()) p.hand = [];
|
||||
const deck = this.createDeck(aliveCount);
|
||||
for (let round = 0; round < HAND_SIZE; round++) {
|
||||
for (const id of this.players.keys()) {
|
||||
const p = this.players.get(id);
|
||||
if (p.alive && deck.length > 0) p.hand.push(deck.pop());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 射擊 / 結束 ----------
|
||||
|
||||
fireRevolver(playerId) {
|
||||
const player = this.getPlayer(playerId);
|
||||
if (!player) return null;
|
||||
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.lastShooterId = playerId;
|
||||
this.checkGameOver();
|
||||
|
||||
return { playerId, hasBullet, alive: player.alive, isGameOver: this.isGameOver };
|
||||
}
|
||||
|
||||
checkGameOver() {
|
||||
if (this.getAlivePlayers().length <= 1) {
|
||||
this.isGameOver = true;
|
||||
const alive = this.getAlivePlayers();
|
||||
this.winnerId = alive.length === 1 ? alive[0].id : null;
|
||||
}
|
||||
}
|
||||
|
||||
removePlayer(playerId) {
|
||||
const player = this.getPlayer(playerId);
|
||||
if (player) {
|
||||
player.alive = false;
|
||||
player.hand = [];
|
||||
this.checkGameOver();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 對外狀態 ----------
|
||||
|
||||
getGameState() {
|
||||
const state = {
|
||||
players: [],
|
||||
currentPlayerId: this.currentPlayerId,
|
||||
targetCard: this.targetCard,
|
||||
playedCards: [],
|
||||
playedGroups: this.playGroups.map((g) => ({ playerId: g.playerId, cardCount: g.cards.length })),
|
||||
roundNumber: this.roundNumber,
|
||||
isGameOver: this.isGameOver,
|
||||
winnerId: this.winnerId
|
||||
};
|
||||
for (const [id, player] of this.players) {
|
||||
state.players.push({
|
||||
id,
|
||||
handSize: player.hand.length,
|
||||
alive: player.alive,
|
||||
revolverChambers: player.revolver.currentChamber
|
||||
});
|
||||
}
|
||||
// 資訊邊界:公開狀態只顯示「誰出了幾張」,不洩漏真實牌值;
|
||||
// 真實牌值只在質疑(challenge / systemChallenge)時透過 challenged 事件揭露。
|
||||
return state;
|
||||
}
|
||||
|
||||
getPlayerHand(playerId) {
|
||||
const player = this.getPlayer(playerId);
|
||||
if (!player) return [];
|
||||
return [...player.hand];
|
||||
}
|
||||
|
||||
isBluff(cards) {
|
||||
return !(cards.length > 0 && cards.every((c) => c === this.targetCard || c === 'JOKER'));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GameEngine;
|
||||
@@ -0,0 +1,122 @@
|
||||
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: 8, // 引擎牌組依人數等比擴充,支援 2-8 人
|
||||
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.engine) {
|
||||
room.engine.removePlayer(playerId);
|
||||
}
|
||||
|
||||
// 如果房間人數不足,結束遊戲
|
||||
if (room.players.length < room.minPlayers && room.status === 'playing') {
|
||||
room.status = 'ended';
|
||||
}
|
||||
|
||||
// 空房清理:沒有玩家時刪除房間,避免一直累積
|
||||
if (room.players.length === 0) {
|
||||
this.rooms.delete(roomId);
|
||||
}
|
||||
|
||||
return { roomId, room };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RoomManager;
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
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 broadcastHands(room) {
|
||||
if (!room || !room.engine) return;
|
||||
for (const playerId of room.players) {
|
||||
io.to(playerId).emit('yourHand', room.engine.getPlayerHand(playerId));
|
||||
}
|
||||
}
|
||||
|
||||
// 驗證房間成員
|
||||
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', () => {
|
||||
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);
|
||||
// 把每個人自己的手牌個別送達
|
||||
broadcastHands(roomManager.getRoom(roomId));
|
||||
console.log(`開始遊戲: ${roomId}`);
|
||||
});
|
||||
|
||||
// 出牌(可一次出 1-3 張,傳入 cardIndices 陣列)
|
||||
socket.on('playCard', (data) => {
|
||||
const { roomId, cardIndices } = 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, cardIndices);
|
||||
if (result.error) {
|
||||
socket.emit('error', { message: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
// 資訊邊界:出牌時只公告「誰出了幾張」,不洩漏實際牌值(質疑時才揭露)
|
||||
io.to(roomId).emit('cardPlayed', {
|
||||
playerId: socket.id,
|
||||
playedGroup: { playerId: socket.id, cardCount: result.playedGroup.cards.length },
|
||||
nextPlayerId: result.nextPlayerId,
|
||||
systemChallenge: result.systemChallenge || null,
|
||||
gameState: room.engine.getGameState()
|
||||
});
|
||||
broadcastHands(room);
|
||||
});
|
||||
|
||||
// 質疑上一組出的牌
|
||||
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()
|
||||
});
|
||||
broadcastHands(room);
|
||||
});
|
||||
|
||||
// 斷線
|
||||
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,
|
||||
gameState: removed.room.engine ? removed.room.engine.getGameState() : null
|
||||
});
|
||||
broadcastHands(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 || 'unknown');
|
||||
res.json(room);
|
||||
});
|
||||
|
||||
// 優雅關閉:收到中斷訊號時先關閉 Socket.IO 再退出
|
||||
function shutdown(signal) {
|
||||
console.log(`收到 ${signal},準備關閉伺服器...`);
|
||||
io.close(() => {
|
||||
console.log('伺服器已關閉');
|
||||
process.exit(0);
|
||||
});
|
||||
// 保險:2 秒內沒關乾淨就強制退出
|
||||
setTimeout(() => process.exit(0), 2000).unref();
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
|
||||
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