11 Commits
43 changed files with 6381 additions and 5 deletions
+13 -1
View File
@@ -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/
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>騙子酒館 - 線上多人心理博弈遊戲</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1162
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "liars-bar-game-client",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.3.4",
"pinia": "^2.1.6",
"socket.io-client": "^4.7.2",
"naive-ui": "^2.34.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",
"vite": "^4.4.9"
}
}
+74
View File
@@ -0,0 +1,74 @@
<template>
<n-config-provider>
<n-message-provider>
<div class="app-container">
<header class="app-header">
<h1>🎰 騙子酒館</h1>
<n-tag :type="isConnected ? 'success' : 'error'" :bordered="false">
{{ isConnected ? '已連線' : '未連線' }}
</n-tag>
</header>
<main class="app-main">
<Lobby v-if="!roomId" />
<GameTable v-else />
</main>
</div>
</n-message-provider>
</n-config-provider>
</template>
<script setup>
import { useGameStore } from './stores/game'
import { storeToRefs } from 'pinia'
import { NConfigProvider, NMessageProvider, NTag } from 'naive-ui'
import Lobby from './components/Lobby.vue'
import GameTable from './components/GameTable.vue'
const gameStore = useGameStore()
const { isConnected, roomId } = storeToRefs(gameStore)
// 啟動時連線
gameStore.connect()
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft JhengHei', sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
}
.app-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 2px solid #333;
margin-bottom: 20px;
}
.app-header h1 {
font-size: 2em;
background: linear-gradient(45deg, #f093fb, #f5576c);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.app-main {
min-height: 60vh;
}
</style>
+213
View File
@@ -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>
+99
View File
@@ -0,0 +1,99 @@
<template>
<div class="lobby">
<n-card title="🎮 遊戲大廳" class="lobby-card">
<n-space vertical :size="20">
<n-button type="primary" size="large" @click="createRoom">
建立房間
</n-button>
<n-divider>或加入現有房間</n-divider>
<n-space align="center">
<n-input
v-model:value="joinRoomId"
placeholder="輸入房間代碼"
class="room-input"
/>
<n-button type="primary" @click="joinRoom">
加入
</n-button>
</n-space>
<n-divider>現有房間</n-divider>
<n-list v-if="rooms.length > 0">
<n-list-item v-for="room in rooms" :key="room.id">
<n-space justify="space-between" align="center">
<div>
<n-tag type="info">房間: {{ room.id }}</n-tag>
<n-tag>{{ room.players }}/{{ room.maxPlayers }} </n-tag>
</div>
<n-button
size="small"
@click="joinRoom(room.id)"
:disabled="room.players >= room.maxPlayers"
>
加入
</n-button>
</n-space>
</n-list-item>
</n-list>
<n-empty v-else description="目前沒有房間" />
</n-space>
</n-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useGameStore } from '../stores/game'
import { NCard, NButton, NSpace, NDivider, NInput, NList, NListItem, NTag, NEmpty } from 'naive-ui'
const gameStore = useGameStore()
const joinRoomId = ref('')
const rooms = ref([])
const createRoom = () => {
gameStore.createRoom()
}
const joinRoom = (roomId) => {
const id = roomId || joinRoomId.value
if (id) {
gameStore.joinRoom(id)
}
}
// 載入房間列表
const loadRooms = async () => {
try {
const response = await fetch('/api/rooms')
rooms.value = await response.json()
} catch (error) {
console.error('載入房間失敗:', error)
}
}
onMounted(() => {
loadRooms()
// 每 5 秒更新房間列表
setInterval(loadRooms, 5000)
})
</script>
<style scoped>
.lobby {
max-width: 600px;
margin: 0 auto;
}
.lobby-card {
background: #16213e;
border: 1px solid #333;
}
.room-input {
flex: 1;
}
</style>
+12
View File
@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { create } from 'naive-ui'
const app = createApp(App)
const pinia = createPinia()
const naive = create()
app.use(pinia)
app.use(naive)
app.mount('#app')
+149
View File
@@ -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
}
}
}
})
+26
View File
@@ -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": []
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
})
@@ -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
```
### 🥈 備用方案 ALiblibAI / SeaArt(雲端版 ComfyUI
**適用情境**:沒有 GPU 或不想安裝複雜環境
**優點:**
- 雲端運行,無需 GPU
- 內建 IP-Adapter + ControlNet,角色一致性接近 ComfyUI
- LiblibAI 是中國平台,中文介面 + 大量中文模型
- 免費層可先測試效果
### 🥉 備用方案 BMidjourney v6.1(快速原型)
**適用情境**:快速測試角色變體概念,不做大量生產
**缺點**:無法精確控制姿態(對遊戲動畫是致命傷);Q 版角色 `--cref` 效果不穩定
---
## 四、批量生產工作流
### 階段 1:快速驗證(1-2 天)
```
├─ 安裝 ComfyUI + ComfyUI-Manager
├─ 下載必要模型:
│ ├─ 底層模型:SDXL 1.0 或動漫專用模型(Anything V5、CounterfeitXL
│ ├─ IP-Adapterip-adapter-plus-face_sdxl_vit-h.bin
│ └─ ControlNetcontrolnet-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/月 | $302 個月) |
| Midjourney | $0 | $10/月 | $202 個月) |
### 推薦方案
**有 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.xxCUDA 版本 ≥ 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.xxCUDA 版本 ≥ 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 weight0.7 → 0.9
2. 使用 `ip-adapter-plus-face` 而非 `ip-adapter-plus`
3. 確保立繪圖片品質高、清晰
4. 減少 CFG scale7 → 5
### 11.3 ControlNet 姿勢不準確
**問題**:生成的姿勢與參考圖不符
**解決方案**
1. 提高 ControlNet strength0.7 → 0.9
2. 使用更清晰的姿勢參考圖
3. 確保 DWpose 模型正確載入
4. 嘗試不同的 ControlNet 模型
### 11.4 生成速度慢
**問題**:單張圖超過 30 秒
**解決方案**
1. 減少 steps30 → 20
2. 使用更快的 samplerdpmpp_2m → euler
3. 關閉其他佔用 GPU 的程式
4. 更新 NVIDIA 驅動至最新版本
### 11.5 圖片品質不佳
**問題**:生成的圖片模糊或變形
**解決方案**
1. 增加 steps20 → 30
2. 提高 CFG scale5 → 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
View File
@@ -0,0 +1,183 @@
# 專案交接文件
> **專案**liars-bar-gameAI 版騙子酒館 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_agentsFork 具 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. **環境操作許可制**:寫入/安裝/執行前先列清單、取得授權才觸發
+3
View File
@@ -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 行, 影片二)
+129
View File
@@ -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 註解。
### Commit2026-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 與瀏覽器實測仍需在一般終端機(見「四」);結果補錄於此後續章節。
+78
View File
@@ -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 直接
+21 -4
View File
@@ -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 = [
{
+2
View File
@@ -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)
+2
View File
@@ -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)
+2
View File
@@ -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()
+2
View File
@@ -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"
+3
View File
@@ -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)
+19
View File
@@ -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")
+22
View File
@@ -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
}
+28
View File
@@ -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
+54
View File
@@ -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
}
+32
View File
@@ -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
+34
View File
@@ -0,0 +1,34 @@
# 一鍵:commit 未提交變更 + push 到 karylab_agents forkremote: 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 到 forkkarylab -> karylab_agents=='
& $gitCmd push karylab feat/web-game
if ($LASTEXITCODE -ne 0) { throw 'git push 失敗' }
Write-Host ''
Write-Host '== 完成 =='
& $gitCmd log --oneline -3
+34
View File
@@ -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
+43
View File
@@ -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
+8
View File
@@ -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);
+55
View File
@@ -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 + '');
});
+96
View File
@@ -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())
+33
View File
@@ -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);
+35
View File
@@ -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);
+217
View File
@@ -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('牌組總張數 = 人數 x52/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);
+109
View File
@@ -0,0 +1,109 @@
// 多人端對端整合測試:連真實伺服器,驗證「多張出牌 → 下家挑戰 → 手牌更新」流程。
// 使用:先啟動 servernode 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}/3isBluff=${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);
});
+360
View File
@@ -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;
+122
View File
@@ -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
View File
@@ -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 };
+1479
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "liars-bar-game",
"version": "1.0.0",
"description": "Liars Bar - 線上多人心理博弈遊戲",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js"
},
"dependencies": {
"express": "^4.18.2",
"socket.io": "^4.7.2",
"uuid": "^9.0.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}