feat(visual): 酒館場景風牌桌 + 8 Q 版角色座位 + 氛圍動畫 #17

Open
karylab_agents wants to merge 2 commits from karylab_agents/liars-bar-game:feat/tavern-visual into game
6 changed files with 1235 additions and 230 deletions
+91
View File
@@ -0,0 +1,91 @@
// 8 個 AI 品牌擬人角色設定(佔位版)
// 資料來源:docs/角色視覺映射筆記.md
// 注意:目前用品牌色 + 性格做 CSS/SVG 佔位;正式立繪待 ComfyUI 生成後以 <img> 替換。
export const CHARACTERS = [
{
id: 'grok4',
name: 'Grok-4',
brand: '粉紅',
color: '#ff7eb6',
hair: '#f28d3f',
desc: '超絕演技、情緒型玩家、拿好牌演苦情戲',
trait: '演戲',
emoji: '🎭'
},
{
id: 'kimi',
name: 'Kimi',
brand: '深藍',
color: '#3b5bdb',
hair: '#d9e2ff',
desc: '原始直覺強、相對踏實的老派',
trait: '老派',
emoji: '🤔'
},
{
id: 'qwen3',
name: 'Qwen3',
brand: '藍紫',
color: '#845ef7',
hair: '#f7a8c8',
desc: '踏實、質疑比例極高、十足刺兒頭',
trait: '刺兒頭',
emoji: '🔍'
},
{
id: 'gpt5',
name: 'GPT-5',
brand: '黑',
color: '#495057',
hair: '#212529',
desc: '老謀深算、心理戰高手、想足',
trait: '沉穩',
emoji: '🧠'
},
{
id: 'gemini',
name: 'Gemini',
brand: '淺紫',
color: '#b197fc',
hair: '#b384f7',
desc: '活潑好奇、容易亂、總先被淘汰',
trait: '好奇',
emoji: '✨'
},
{
id: 'deepseek',
name: 'Deepseek',
brand: '藍',
color: '#339af0',
hair: '#74c0fc',
desc: '面無表情、侵略如火、戲劇化表演',
trait: '瘋癲',
emoji: '🎲'
},
{
id: 'doubao',
name: 'Doubao',
brand: '淺藍',
color: '#66d9e8',
hair: '#8b5a2b',
desc: '乾淨利落、反情緒、獵殺時刻',
trait: '冷靜',
emoji: '🎯'
},
{
id: 'claude',
name: 'Claude',
brand: '橘',
color: '#ffa94d',
hair: '#ff922b',
desc: '溫和堅定、雲淡風輕、嘴角微揚',
trait: '淡定',
emoji: '🍵'
}
]
// 依 player 索引分配角色(讓每個座位有固定 Q 版角色)
export function characterFor(index) {
return CHARACTERS[index % CHARACTERS.length]
}
+418 -176
View File
@@ -1,151 +1,129 @@
<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.originalPlayerId || gameStore.playerId)" type="warning"></n-tag>
</n-space>
</n-list-item>
</n-list>
<n-text depth="3">{{ roomPlayers.length }}/8 </n-text>
<div class="tavern">
<!-- 背景氛圍 CSS 燭光 + 木紋 -->
<div class="tavern-bg">
<div class="candle c1"></div>
<div class="candle c2"></div>
<div class="candle c3"></div>
<div class="dust"></div>
</div>
<!-- 等待室模式顯示進房玩家 -->
<div v-if="!gameState" class="waiting overlay-panel">
<h2 class="waiting-title">🔥 等待開局</h2>
<div class="waiting-players">
<div
v-for="(p, i) in roomPlayers"
:key="i"
class="waiting-player"
:style="{ '--c': characterFor(i).color }"
>
<span class="wp-emoji">{{ characterFor(i).emoji }}</span>
<span>{{ characterFor(i).name }}</span>
<span v-if="p === (gameStore.originalPlayerId || gameStore.playerId)" class="me-tag"></span>
</div>
</div>
<p class="count">{{ roomPlayers.length }}/8 </p>
<n-button
type="primary"
size="large"
:disabled="roomPlayers.length < 2 || gameStore.actionPending"
@click="startGame"
>
🍻 開始遊戲
</n-button>
</div>
<!-- 遊戲桌模式 -->
<div v-else class="game-scene">
<!-- 木桌 + 桌面 -->
<div class="table-wrap">
<div class="table-felt">
<!-- 目標牌 -->
<div class="target-card">
<span class="tc-label">目標</span>
<span class="tc-value">{{ targetCard }}</span>
</div>
<!-- 回合資訊 -->
<div class="round-info">
<span> {{ roundNumber }} </span>
<span v-if="!gameState.isGameOver">輪到{{ currentLabel }}</span>
<span v-else class="winner-text">🏆 {{ winnerLabel }} 獲勝</span>
</div>
<!-- 已出牌組 -->
<div v-if="playedGroups.length > 0" class="played-area">
<div v-for="(item, index) in playedGroups" :key="index" class="played-chip">
{{ characterFor(seatIndexOf(item.playerId)).emoji }} {{ item.cardCount }}
</div>
</div>
<!-- 質疑結果 -->
<div v-if="lastChallenge" class="challenge-banner" :class="lastChallenge.isBluff ? 'bluff' : 'truth'">
{{ challengeText }}
</div>
<!-- 系統訊息 -->
<transition name="fade">
<div v-if="lastGameMessage" class="msg-line">{{ lastGameMessage }}</div>
</transition>
</div>
</div>
<!-- 角色座位環繞 -->
<PlayerSeat
v-for="(player, i) in players"
:key="player.id"
:seat-index="i"
:player="player"
:is-current="player.id === currentPlayerId"
:is-me="player.id === (gameStore.originalPlayerId || gameStore.playerId)"
:just-played="justPlayedId === player.id"
:current-player-id="currentPlayerId || ''"
/>
</div>
<!-- 底部操作區你的手牌 + 動作 -->
<div v-if="gameState" class="action-deck">
<template v-if="gameState.isGameOver">
<n-button type="primary" size="large" :disabled="gameStore.actionPending" @click="restartGame">
🔄 再來一局
</n-button>
</template>
<template v-else>
<div class="hand">
<button
v-for="(card, index) in yourHand"
:key="index"
class="hand-card"
:class="{ sel: selected.has(index) }"
:disabled="!isMyTurn || gameState.isGameOver"
@click="toggleSelect(index)"
>
{{ card }}
</button>
</div>
<div class="actions">
<n-button
type="primary"
size="large"
block
:disabled="roomPlayers.length < 2 || gameStore.actionPending"
@click="startGame"
:disabled="!isMyTurn || selected.size < 1 || gameState.isGameOver || gameStore.actionPending"
@click="playSelected"
>
開始遊戲
出牌 ({{ selected.size }})
</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-button
type="primary"
size="large"
:disabled="gameStore.actionPending"
@click="restartGame"
>
再來一局
</n-button>
</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 || gameStore.actionPending"
@click="playSelected"
>
出牌 ({{ selected.size }})
</n-button>
<n-button
type="error"
size="large"
:disabled="!isMyTurn || playedGroups.length === 0 || gameState.isGameOver || gameStore.actionPending"
@click="challenge"
>
挑戰
</n-button>
</n-space>
</template>
</n-space>
</n-card>
<n-button
type="error"
size="large"
:disabled="!isMyTurn || playedGroups.length === 0 || gameState.isGameOver || gameStore.actionPending"
@click="challenge"
>
🚨 挑戰
</n-button>
</div>
</template>
</div>
</div>
</template>
@@ -153,15 +131,16 @@
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'
import { NButton } from 'naive-ui'
import PlayerSeat from './PlayerSeat.vue'
import { characterFor } from '../characters'
const gameStore = useGameStore()
const { roomId, gameState, room, yourHand, lastMessage, lastChallenge } = storeToRefs(gameStore)
const selected = ref(new Set())
const justPlayedId = ref('')
let flashTimer = null
const roomPlayers = computed(() => room.value?.players || [])
const players = computed(() => gameState.value?.players || [])
@@ -173,53 +152,316 @@ const playedGroups = computed(() => gameState.value?.playedGroups || [])
const isMyTurn = computed(() => currentPlayerId.value === (gameStore.originalPlayerId || gameStore.playerId))
const winnerId = computed(() => gameState.value?.winnerId)
const winnerLabel = computed(() => winnerId.value ? shortId(winnerId.value) : '未知')
const lastMessageIsError = computed(() => /失敗|吹牛/.test(lastMessage.value || ''))
const currentLabel = computed(() => currentPlayerId.value ? shortId(currentPlayerId.value) : '-')
const lastGameMessage = computed(() => lastMessage.value || '')
const challengeText = computed(() => {
const c = lastChallenge.value
if (!c) return ''
const who = c.systemChallenge ? '系統' : shortId(c.challengerId)
return c.isBluff ? `${who} 質疑:吹牛被逮!` : `${who} 質疑失敗——是真話!`
})
// 依 player id 找座位
function seatIndexOf(playerId) {
return players.value.findIndex((p) => p.id === playerId)
}
// 出牌瞬間標記該座位拋牌動畫
watch(() => gameState.value?.playedGroups?.length, (n, old) => {
if (n && n > old) {
const g = playedGroups.value[playedGroups.value.length - 1]
if (g) {
justPlayedId.value = g.playerId
if (flashTimer) clearTimeout(flashTimer)
flashTimer = setTimeout(() => { justPlayedId.value = '' }, 1000)
}
}
})
// 每局(目標牌或 round 改變)清除選牌
watch([targetCard, roundNumber], () => { selected.value = new Set() })
function shortId(id) {
return id ? id.substring(0, 8) + '...' : '-'
return id ? id.substring(0, 4) + '' : '-'
}
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)
}
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 restartGame() {
gameStore.restartGame()
}
function challenge() {
gameStore.challenge()
}
function startGame() { gameStore.startGame() }
function restartGame() { gameStore.restartGame() }
function challenge() { gameStore.challenge() }
</script>
<style scoped>
.game-table {
max-width: 800px;
margin: 0 auto;
.tavern {
position: relative;
width: 100%;
min-height: 72vh;
}
.game-card {
background: #16213e;
border: 1px solid #333;
/* ---- 酒館氛圍背景 ---- */
.tavern-bg {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
overflow: hidden;
border-radius: 18px;
background:
radial-gradient(ellipse at 50% 40%, #2a2030 0%, #17121c 55%, #0d0a12 100%);
box-shadow: inset 0 0 90px rgba(0, 0, 0, 0.9);
}
</style>
.tavern-bg::before {
content: '';
position: absolute;
inset: 0;
background-image:
repeating-linear-gradient(92deg, rgba(120, 80, 40, 0.06) 0 3px, transparent 3px 22px);
background-size: 100% 100%;
opacity: 0.5;
}
.candle {
position: absolute;
bottom: 8%;
width: 8px;
height: 22px;
border-radius: 3px;
background: #7a5a3a;
}
.candle::before {
content: '';
position: absolute;
top: -14px;
left: 2px;
width: 4px;
height: 14px;
border-radius: 50%;
background: radial-gradient(circle at 50% 70%, #fff6c0, #ffb84d 60%, transparent);
filter: blur(1px);
animation: flick 0.4s ease-in-out infinite alternate;
}
.candle::after {
content: '';
position: absolute;
top: -26px;
left: -16px;
width: 40px;
height: 30px;
border-radius: 50%;
background: radial-gradient(closest-side, rgba(255, 190, 90, 0.22), transparent);
filter: blur(6px);
animation: flick 0.7s ease-in-out infinite alternate;
}
.c1 { left: 12%; }
.c2 { right: 12%; }
.c3 { left: 50%; transform: translateX(-50%); top: 72%; }
.dust {
position: absolute;
inset: 0;
background-image:
radial-gradient(1px 1px at 20% 30%, rgba(255, 255, 255, 0.25), transparent),
radial-gradient(1px 1px at 70% 20%, rgba(255, 255, 255, 0.18), transparent),
radial-gradient(1px 1px at 40% 70%, rgba(255, 255, 255, 0.14), transparent),
radial-gradient(1.5px 1.5px at 85% 60%, rgba(255, 255, 255, 0.2), transparent);
animation: dustFloat 9s ease-in-out infinite;
}
/* ---- 等待室 ---- */
.overlay-panel {
position: relative;
z-index: 2;
min-height: 50vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
background: rgba(10, 8, 14, 0.55);
border-radius: 18px;
backdrop-filter: blur(3px);
}
.waiting-title {
font-size: 1.6em;
color: #ffd79a;
text-shadow: 0 0 14px rgba(255, 160, 60, 0.5);
margin: 0;
}
.waiting-players {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
max-width: 560px;
}
.waiting-player {
display: flex;
align-items: center;
gap: 6px;
background: rgba(0, 0, 0, 0.45);
border: 1px solid color-mix(in srgb, var(--c) 60%, transparent);
border-radius: 20px;
padding: 6px 12px;
color: #eee;
font-size: 13px;
animation: popIn 0.5s ease-out both;
}
.wp-emoji { font-size: 18px; }
.me-tag { background: #ffd43b; color: #222; border-radius: 8px; padding: 0 4px; font-weight: 700; font-size: 10px; }
.count { color: #aaa; margin: 0; }
/* ---- 遊戲桌 ---- */
.game-scene {
position: absolute;
inset: 0;
z-index: 1;
}
.table-wrap {
position: absolute;
left: 50%;
top: 52%;
transform: translate(-50%, -50%);
width: min(720px, 92%);
height: 320px;
}
.table-felt {
position: absolute;
inset: 0;
border-radius: 46% 46% 46% 46%;
background:
radial-gradient(ellipse at 40% 30%, #2f7d4f, #1e5a3a 60%, #144229 100%);
box-shadow:
0 22px 40px rgba(0, 0, 0, 0.65),
inset 0 0 0 10px #5a3d22,
inset 0 0 0 14px #3a2a18,
inset 0 0 70px rgba(0, 0, 0, 0.55);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
animation: tableIn 0.8s cubic-bezier(0.34, 1.3, 0.64, 1) both;
}
.round-info {
display: flex;
gap: 14px;
color: #ffe9c8;
font-size: 14px;
text-shadow: 0 1px 3px #000;
}
.winner-text { color: #ffd43b; font-weight: 700; }
.target-card {
display: flex;
align-items: center;
gap: 8px;
background: rgba(0, 0, 0, 0.35);
padding: 6px 16px;
border-radius: 12px;
}
.tc-label { color: #ffd79a; font-size: 13px; }
.tc-value {
font-size: 26px;
font-weight: 800;
color: #fff;
letter-spacing: 1px;
text-shadow: 0 0 10px rgba(255, 200, 90, 0.6);
}
.played-area {
display: flex;
gap: 10px;
min-height: 26px;
}
.played-chip {
background: rgba(0, 0, 0, 0.45);
padding: 3px 10px;
border-radius: 14px;
color: #fff;
font-size: 13px;
animation: popIn 0.4s ease-out both;
}
.challenge-banner {
padding: 6px 14px;
border-radius: 10px;
font-size: 14px;
font-weight: 700;
}
.challenge-banner.bluff { background: rgba(220, 60, 60, 0.75); color: #fff; }
.challenge-banner.truth { background: rgba(70, 160, 110, 0.75); color: #fff; }
.msg-line {
color: #ffdfa0;
font-size: 13px;
min-height: 18px;
text-shadow: 0 1px 2px #000;
}
/* ---- 底部操作區 ---- */
.action-deck {
position: relative;
z-index: 3;
margin-top: 440px;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 20px;
background: linear-gradient(180deg, rgba(20, 15, 25, 0.6), rgba(10, 8, 14, 0.85));
border-radius: 18px;
}
.hand {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
perspective: 600px;
}
.hand-card {
width: 64px;
height: 90px;
border: 2px solid #c9a86a;
border-radius: 10px;
background: linear-gradient(150deg, #fff8ec, #f0e2c4);
color: #5a3d22;
font-size: 20px;
font-weight: 800;
cursor: pointer;
transform-style: preserve-3d;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4);
}
.hand-card:hover { transform: translateY(-8px) rotateX(12deg); }
.hand-card.sel {
transform: translateY(-16px) rotateX(8deg);
border-color: #ffd43b;
box-shadow: 0 0 16px rgba(255, 212, 59, 0.8);
}
.actions {
display: flex;
gap: 14px;
}
@keyframes flick {
from { transform: translateY(1px) scaleY(1); opacity: 0.85; }
to { transform: translateY(-2px) scaleY(1.25); opacity: 1; }
}
@keyframes dustFloat {
0%, 100% { transform: translateY(0); opacity: 0.6; }
50% { transform: translateY(-14px); opacity: 1; }
}
@keyframes popIn {
from { transform: scale(0.6); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes tableIn {
from { transform: scale(0.75); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.4s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
+165 -54
View File
@@ -1,72 +1,72 @@
<template>
<div class="lobby">
<n-card title="🎮 遊戲大廳" class="lobby-card">
<n-space vertical :size="20">
<n-button type="primary" size="large" @click="createRoom">
<div class="lobby-bg">
<div class="candle c1"></div>
<div class="candle c2"></div>
<div class="candle c3"></div>
</div>
<div class="lobby-panel">
<div class="brand">
<span class="brand-icon">🎰</span>
<h1 class="brand-title">騙子酒館</h1>
<p class="brand-sub">八位 AI 賭徒誰的謊言活到最後</p>
<div class="char-row">
<span v-for="c in CHARACTER_PREVIEW" :key="c.id" class="char-bubble" :style="{ '--c': c.color }">{{ c.emoji }}</span>
</div>
</div>
<div class="panel-body">
<n-button type="primary" size="large" block @click="createRoom">
建立房間
</n-button>
<n-divider>或加入現有房間</n-divider>
<n-space align="center">
<n-input
v-model:value="joinRoomId"
<div class="join-row">
<n-input
v-model:value="joinRoomId"
placeholder="輸入房間代碼"
class="room-input"
/>
<n-button type="primary" @click="joinRoom">
加入
</n-button>
</n-space>
<n-button type="primary" @click="joinRoom">加入</n-button>
</div>
<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 v-if="rooms.length > 0" class="room-list">
<div v-for="room in rooms" :key="room.id" class="room-item">
<span class="room-id">房間: {{ room.id }}</span>
<span class="room-count">{{ room.players }}/{{ room.maxPlayers }} </span>
<n-button size="small" @click="joinRoom(room.id)" :disabled="room.players >= room.maxPlayers">
加入
</n-button>
</div>
</div>
<n-empty v-else description="─ 尚無酒局 ─" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useGameStore } from '../stores/game'
import { NCard, NButton, NSpace, NDivider, NInput, NList, NListItem, NTag, NEmpty } from 'naive-ui'
import { NButton, NInput, NSpace, NDivider, NEmpty } from 'naive-ui'
import { CHARACTERS } from '../characters'
const gameStore = useGameStore()
const joinRoomId = ref('')
const rooms = ref([])
let roomTimer = null
const createRoom = () => {
gameStore.createRoom()
}
const CHARACTER_PREVIEW = CHARACTERS.slice(0, 8)
const createRoom = () => { gameStore.createRoom() }
const joinRoom = (roomId) => {
const id = roomId || joinRoomId.value
if (id) {
gameStore.joinRoom(id)
}
if (id) gameStore.joinRoom(id)
}
// 載入房間列表
const loadRooms = async () => {
try {
const response = await fetch('/api/rooms')
@@ -75,30 +75,141 @@ const loadRooms = async () => {
console.error('載入房間失敗:', error)
}
}
onMounted(() => {
loadRooms()
// 每 5 秒更新房間列表
roomTimer = setInterval(loadRooms, 5000)
})
onBeforeUnmount(() => {
if (roomTimer) clearInterval(roomTimer)
})
onBeforeUnmount(() => { if (roomTimer) clearInterval(roomTimer) })
</script>
<style scoped>
.lobby {
max-width: 600px;
position: relative;
max-width: 640px;
margin: 0 auto;
}
.lobby-bg {
position: absolute;
inset: 0;
z-index: 0;
border-radius: 18px;
overflow: hidden;
background:
radial-gradient(ellipse at 50% 20%, #33243a 0%, #1a1420 60%, #0d0a12 100%);
box-shadow: inset 0 0 90px rgba(0, 0, 0, 0.85);
}
.candle {
position: absolute;
bottom: 10%;
width: 8px;
height: 22px;
border-radius: 3px;
background: #7a5a3a;
}
.candle::before {
content: '';
position: absolute;
top: -14px;
left: 2px;
width: 4px;
height: 14px;
border-radius: 50%;
background: radial-gradient(circle at 50% 70%, #fff6c0, #ffb84d 60%, transparent);
filter: blur(1px);
animation: flick 0.45s ease-in-out infinite alternate;
}
.candle::after {
content: '';
position: absolute;
top: -26px;
left: -18px;
width: 44px;
height: 32px;
border-radius: 50%;
background: radial-gradient(closest-side, rgba(255, 190, 90, 0.2), transparent);
filter: blur(7px);
animation: flick 0.7s ease-in-out infinite alternate;
}
.c1 { left: 8%; }
.c2 { right: 8%; }
.c3 { left: 46%; top: 78%; }
.lobby-card {
background: #16213e;
.lobby-panel {
position: relative;
z-index: 1;
padding: 28px 24px 24px;
}
.brand {
text-align: center;
margin-bottom: 18px;
}
.brand-icon { font-size: 46px; display: block; }
.brand-title {
font-size: 2.2em;
margin: 6px 0 2px;
background: linear-gradient(45deg, #f093fb, #f5576c);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: 3px;
}
.brand-sub {
color: #c9b288;
font-size: 13px;
margin: 0 0 12px;
}
.char-row {
display: flex;
justify-content: center;
gap: 10px;
}
.char-bubble {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
border-radius: 50%;
background: color-mix(in srgb, var(--c) 25%, #1a1420);
border: 1px solid color-mix(in srgb, var(--c) 70%, transparent);
animation: bob 3.2s ease-in-out infinite;
animation-delay: calc(var(--i, 0) * 0.4s);
}
.panel-body {
background: rgba(10, 8, 14, 0.55);
backdrop-filter: blur(3px);
border-radius: 14px;
padding: 18px;
}
.join-row {
display: flex;
gap: 10px;
}
.room-input { flex: 1; }
.room-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.room-item {
display: flex;
align-items: center;
gap: 12px;
justify-content: space-between;
background: rgba(0, 0, 0, 0.4);
padding: 8px 12px;
border-radius: 10px;
border: 1px solid #333;
}
.room-id { color: #9ec5fe; font-size: 13px; }
.room-count { color: #aaa; font-size: 13px; }
.room-input {
flex: 1;
@keyframes flick {
from { transform: translateY(1px) scaleY(1); opacity: 0.85; }
to { transform: translateY(-2px) scaleY(1.25); opacity: 1; }
}
@keyframes bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
</style>
+272
View File
@@ -0,0 +1,272 @@
<template>
<div
class="player-seat"
:class="{
'is-current': isCurrent,
'is-dead': isDead,
'is-me': isMe
}"
:style="seatStyle"
>
<!-- 光圈當前回合 -->
<div v-if="isCurrent" class="seat-glow"></div>
<!-- Q 版角色佔位立繪正式圖檔後換成 <img> -->
<div class="portrait" :style="portraitStyle" :aria-label="label">
<div class="portrait-face">
<span class="portrait-hair back" :style="{ background: character.hair }"></span>
<span class="portrait-face-oval" :style="{ background: '#ffe0bd' }"></span>
<span class="portrait-hair front" :style="{ background: character.hair }"></span>
<span class="portrait-eyes">
<i></i><i></i>
</span>
<span class="portrait-mouth" :class="moodClass"></span>
</div>
<span class="portrait-emoji">{{ character.emoji }}</span>
<!-- 出牌的拋牌動畫 -->
<span v-if="justPlayed" class="played-flash">🃏</span>
</div>
<!-- 名牌 -->
<div class="nameplate">
<span class="nm">{{ shortLabel }}</span>
<span v-if="isMe" class="me-tag"></span>
<span class="mood">{{ moodLabel }}</span>
</div>
<!-- 狀態條 -->
<div class="meta">
<span class="chip">🖐 {{ handSize }}</span>
<span class="chip" :class="{ dead: isDead }">{{ isDead ? '💀' : '🔫 ' + revolver }}</span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { characterFor } from '../characters'
const props = defineProps({
// 座位在桌上的位置(0..7
seatIndex: { type: Number, required: true },
// 玩家資料 { id, alive, handSize, revolverChambers }
player: { type: Object, default: null },
isCurrent: { type: Boolean, default: false },
isMe: { type: Boolean, default: false },
justPlayed: { type: Boolean, default: false },
currentPlayerId: { type: String, default: '' }
})
const character = computed(() => characterFor(props.seatIndex))
// 以座標定位每個座位(橢圓環繞),走 CSS custom property 交由 style 綁定
const seatStyle = computed(() => {
const angle = (props.seatIndex / 8) * Math.PI * 2
// 橢圓半徑(桌寬一半、桌高一半),座位放桌外圈
const rx = 46 // %
const ry = 30 // %(垂直較扁)
// -90deg 從上方(你的位置)開始順時針排
const rad = angle - Math.PI / 2
const cx = 50 + rx * Math.cos(rad)
const cy = 50 + ry * Math.sin(rad)
const flip = cy > 58 ? 1 : 0
return {
'--sx': cx + '%',
'--sy': cy + '%',
'--flip': flip,
'--brand': props.player?.alive === false ? '#555' : character.value.color
}
})
const isDead = computed(() => props.player && props.player.alive === false)
const handSize = computed(() => props.player?.handSize ?? '-')
const revolver = computed(() => props.player?.revolverChambers ?? '-')
const shortLabel = computed(() => {
if (!props.player) return '空位'
const raw = props.player.label || props.player.name || props.player.id || ''
return raw.length > 8 ? raw.substring(0, 6) + '…' : raw
})
const label = computed(() => (props.player ? shortLabel.value + '' + character.value.name + '' : '空位'))
const moodLabel = computed(() => (props.player ? character.value.trait : ''))
const moodClass = computed(() => {
if (!props.player) return ''
if (props.player.alive === false) return 'mood-dead'
return 'mood-' + character.value.trait
})
</script>
<style scoped>
.player-seat {
position: absolute;
left: var(--sx);
top: var(--sy);
transform: translate(-50%, -50%) scaleX(var(--flip));
display: flex;
flex-direction: column;
align-items: center;
width: 96px;
transition: filter 0.3s;
z-index: 5;
}
.player-seat.is-me .portrait {
box-shadow: 0 0 0 3px #ffd43b;
border-radius: 14px;
}
.player-seat.is-dead .portrait {
filter: grayscale(1) brightness(0.65);
}
.seat-glow {
position: absolute;
bottom: -6px;
width: 84px;
height: 18px;
border-radius: 50%;
background: radial-gradient(closest-side, var(--brand), transparent);
filter: blur(4px);
animation: glowPulse 1.6s ease-in-out infinite;
}
.portrait {
position: relative;
width: 72px;
height: 84px;
border-radius: 12px;
background: linear-gradient(160deg, #2b2b3a, #1c1c2a);
border: 2px solid color-mix(in srgb, var(--brand) 70%, #888);
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
animation: idleBreathe 3.6s ease-in-out infinite;
cursor: default;
}
.player-seat.is-current .portrait {
border-color: #ffd43b;
animation: idleBreathe 1.8s ease-in-out infinite;
}
.portrait-face {
position: relative;
width: 44px;
height: 52px;
margin-top: 6px;
}
.portrait-hair.back {
position: absolute;
inset: 0 4px 6px 4px;
border-radius: 50%;
}
.portrait-face-oval {
position: absolute;
left: 8px;
top: 8px;
width: 28px;
height: 34px;
border-radius: 48%;
z-index: 1;
}
.portrait-hair.front {
position: absolute;
left: 4px;
top: 0;
width: 36px;
height: 22px;
border-radius: 60% 60% 40% 40%;
z-index: 2;
}
.portrait-eyes {
position: absolute;
left: 5px;
top: 26px;
width: 34px;
display: flex;
justify-content: space-between;
z-index: 3;
animation: blink 4.8s infinite;
}
.portrait-eyes i {
width: 5px;
height: 7px;
background: #222;
border-radius: 50%;
}
.portrait-mouth {
position: absolute;
left: 16px;
top: 40px;
width: 12px;
height: 6px;
border-radius: 50%;
background: #b3484c;
z-index: 3;
transform: rotate(8deg);
}
.mood-grin { transform: rotate(0deg); border-radius: 3px; }
.mood-cool { transform: rotate(-6deg); }
.mood-dead { background: #666; }
.portrait-emoji {
position: absolute;
right: -8px;
top: -8px;
font-size: 16px;
filter: drop-shadow(0 1px 2px #000);
}
.played-flash {
position: absolute;
font-size: 26px;
animation: flyUp 0.9s ease-out forwards;
z-index: 6;
}
.nameplate {
margin-top: 6px;
display: flex;
align-items: center;
gap: 4px;
background: rgba(0, 0, 0, 0.55);
padding: 2px 8px;
border-radius: 20px;
font-size: 11px;
color: #eee;
white-space: nowrap;
}
.me-tag {
background: #ffd43b;
color: #222;
border-radius: 8px;
padding: 0 4px;
font-weight: 700;
font-size: 10px;
}
.mood {
color: var(--brand);
font-size: 10px;
}
.meta {
margin-top: 3px;
display: flex;
gap: 5px;
}
.chip {
background: rgba(0, 0, 0, 0.5);
padding: 1px 6px;
border-radius: 10px;
font-size: 10px;
color: #fff;
}
.chip.dead { color: #ff8787; }
@keyframes idleBreathe {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-4px) scale(1.02); }
}
@keyframes glowPulse {
0%, 100% { opacity: 0.35; }
50% { opacity: 0.9; }
}
@keyframes blink {
0%, 90%, 100% { transform: scaleY(1); }
93%, 96% { transform: scaleY(0.1); }
}
@keyframes flyUp {
0% { transform: translateY(0) scale(0.6); opacity: 1; }
100% { transform: translateY(-46px) scale(1.3); opacity: 0; }
}
</style>
@@ -0,0 +1,198 @@
# RTX 508016GB・Blackwell sm_120)+ ComfyUI 開源 2D 卡通角色動畫方案
**調查日期:** 2026-08-07
**調查者:** Hermes 研究子代理(minimax-ai-game-visual 工作流系列 #10
**目標硬體:** RTX 508016GB GDDR7、Blackwell sm_120、CUDA、Windows)+ 自家 ComfyUI
**既有管線:** ComfyUI IP-Adapter ControlNet DWpose(角色一致性),有 8 個 2D/Q 版/卡通女性立繪
**需求:** 開源免費、在地執行,把 8 個 2D 角色做成「待機小動作迴圈」(眨眼、髮絲、呼吸、微笑)與其他動畫素材,輸出轉網頁用 WebM(VP9)/APNG。
> **誠實標註原則:** 本文所有「可查證硬規格」均有資料來源;無法從權威來源確證的數字一律標「未查到」。風格/配方判斷明確標「建議」。
---
## 摘要
1. **在地 16GB 跑得動、且最適合「2D/cel/toon 待機迴圈」的主力是「AnimateDiffSD1.5 底模+2D LoRA)」**——它是在靜態圖模型上加一層「時間一致性」motion module,風格完全由你選的 2D 底模/LoRA 決定,最契合你既有的 8 張 2D 立繪,VRAM 需求極低(SD1.5 案例遠低於 16GB,安全)。→ **給 2D 待機迴圈使用的首選**
2. **做「更強動作/720p」的高階圖生視訊,2026 年 8 月當紅且官方在 ComfyUI 原生支援的是 Alibaba **Wan2.2**Apache-2.0,可商用)**`Wan2.2-TI2V-5B`(混合 T2V+I2V720P@24fps,官方明言可在 4090 等消費卡上跑,ComfyUI 原生 offload 下「5B 版在 8GB 就能放得下」)、`Wan2.2-I2V-A14B`14B MoE480p/720p,需靠 fp8/GGUFoffload)。→ **給「高品質動作」的首選**
3. **RTX 5080/Blackwellsm_120)的關鍵設定**:必須用支援 Blackwell 的 PyTorchCUDA 12.8+ComfyUI 官方現行文件對 NVIDIA 建議 **torch cu130**)。用舊版 torch/cu124 會直接跑不動或極慢。→ 更新到最新 ComfyUI 隨附的 cu128/cu130 環境即可。
4. **推薦工作流(8 角色→待機迴圈→WebM/APNG)分三階段**A) AnimateDiff 做 2D 無縫迴圈(最省、風格最準);B) Wan2.2 5B I2V 做較高動態(480p 優先,16GB 安全);C) 輸出用 ComfyUI 內建 `SaveAnimatedPNG`→APNG,或用 ffmpeg 轉 `WebM(VP9 + alpha)`。全程 16GB 安全。
5. **16GB 上「跑不動/不建議」的模型**Mochi-1(單卡官方 ~60GBComfyUI 優化後 <20GB16GB 太勉強)、HunyuanVideo720p@129f 官方表列 60GB;改用 **HunyuanVideo-1.5**,官方最低 ~14GB(含 offload),才在 16GB 邊緣可行)、Wan2.2 14B 720p(需較重量化/offload,非首選;降落 480p 或用 5B)。
6. **與 4090/5070 比較**508016GB、Blackwell)在「開源視訊模型」是 2026 年消費卡甜點位——比 409024GBVRAM 少但 Blackwell FP8/FP4 算力與耗電更好;比 507012GB)VRAM 多、14B 模型較可行。量化的需求:越高階模型越要靠 fp8/GGUF,這是省 VRAM 但劣化品質的雙面刃。
7. **陷阱**720p+長幀數+堆疊 ControlNet/IP-Adapter 最容易爆 VRAM;無縫迴圈要「首幀=末幀」或 Latent Loop,否則接縫明顯;網頁透明背景要用 chromatic keyVP9 alpha(或直接 APNG),否則白底;SDXL 版 AnimateDiff+其他外掛疊在 16GB 會吃緊。
---
## 一、候選模型在 ComfyUI 的支援與 VRAM 需求(逐項查證)
| 模型 / 版本 | ComfyUI 支援 | 官方/權威 VRAM 需求 | 在 16GB 可行度(5080 |
|---|---|---|---|
| **AnimateDiffSD1.5 版)** | 原生透過 `ComfyUI-AnimateDiff-Evolved`Kosinkadink)等節點,「在 SD1.5/SDXL checkpoint 上疊 motion module」;支援 Motion LoRA | 文件未列硬性數字;因架構是在 ~5GB(SD1.5 fp16)底模上加工,慣用 <8GB 即可 | ✅ 非常安全(遠低於 16GB) |
| **AnimateDiffSDXL 版)** | 同上(mm_sdxl 系列 motion module、另有 beta adapter | SDXL fp16 底模 ~13GB,疊 motion 後 16GB 偏緊、需小心外掛 | ⚠️ 邊緣(單純底模可以,疊 ControlNet+IP-Adapter 會吃緊) |
| **Stable Video Diffusion 1.1 / XT** | ComfyUI 內建原生節點(`SVD_img2vid_Conditioning` 等),HuggingFace `stabilityai/stable-video-diffusion-img2vid[-xt]` | 官方未列統一 VRAM;慣用 1024×576・25 幀、約 812GB 可行(offload 後) | ✅ 可行(但為 2023 舊模型,品質落後,推薦度低) |
| **Wan2.1T2V-1.3B / 5B / 14B480p/720p** | ComfyUI 原生支援,Comfy-Org repackaged | 1.3B 極輕量各家皆可;14B 需 fp8/量化與層級 offload | ✅(1.3B)/⚠️14B 需量化);已被 Wan2.2 取代 |
| **Wan2.2-TI2V-5B**(混合 T2V+I2V720P@24fps | **ComfyUI 原生(2025-07-28 起)**Apache-2.0 | 官方 ComfyUI 文件明言「5B 版在 8GB 就能放得下(配 ComfyUI 原生 offload)」;官方「5 秒 720p < 9 分鐘(單消費卡)」 | ✅ 很安全,**本報告 I2V 首選** |
| **Wan2.2-I2V-A14B / T2V-A14B**14B MoE480p/720p | ComfyUI 原生;提供 fp8 high/low noise 兩子模型、另有 GGUF 量化(`bullerwins/Wan2.2-I2V-A14B-GGUF`) | 官方指令在 720p 需厚重 offload`--offload_model --t5_cpu`);16GB 要強量化+offload | ⚠️ 480p 可行;720p 需 GGUF/offload、較慢 |
| **LTX-Video 0.9.5 / 2.0 / 2.3**2B | ComfyUI 原生內建 LTX 節點(`EmptyLTXVLatentVideo` 等) | 2B 架構極省記憶體,慣用 <8GB;本調查未取得官方單一 VRAM 數字 | ✅ 很安全;快但細節/風格控制較弱 |
| **Mochi-1480p** | ComfyUI 原生(`EmptyMochiLatentVideo`、ModelMergeMochiPreview);2024-11-05 官方宣布消費卡原生支援 | genmo 官方:單 GPU 需 **~60GB**;「ComfyUI 可優化到 **<20GB**」 | ⚠️ 16GB 太勉強,**不建議用 5080 跑** |
| **HunyuanVideo13B720p129 幀)** | ComfyUI 原生 Hunyuan 節點 | 官方記憶體表:720px1280px129f ≈ **60GB**fp8 可省) | ❌ **不要用 5080 跑 720p**;見下方 1.5 |
| **HunyuanVideo-1.52025-11-21** | ComfyUI 原生(`HunyuanVideo15ImageToVideo` 等) | 官方 README:「**Minimum GPU Memory: 14 GB(含 model offloading**」;另 480p I2V step-distilled 模型;Wan2GP 號稱可低至 6GB | ⚠️ 16GB 邊緣可行(需 offload |
| **MiniMax H3open weights** | ComfyUI 原生(2026 起,T2V/I2V/R2V 範本) | 大型 omni-modal 模型;16GB 可行性**未查到**;官方建議面向雲/高階 GPU | ❓ 不建議本地 16GB;走 API(見系列 #01/#03/#07 |
> 註:表中 WAN 14B、Mochi、Hunyuan 等「GB」多為官方文件的單卡估算;實際落地依解析度/幀數/是否 offload/量化精度浮動,屬「數量級」而非精確值,請以官方連結為準。
### 1.1 逐項查證細節
- **AnimateDiff**`ComfyUI-AnimateDiff-Evolved` README 明言「motion module 與 SD LoRA 可共存、可在單趟由 512² upscale」,並支援 `mm_sd_v15_v2`SD1.5)與 Hotshot/SDXL 架構;運動可用 Motion LoRA 調控。官方 motion adapter 存於 HF `guoyww/animatediff`Apache-2.0)與 `guoyww/animatediff-motion-adapter-sdxl-beta`。→ **最適合把既有 2D 立繪做「眨眼/髮絲/呼吸」這種小動作**(因為它本質就是「讓靜態圖模型在時間上一致地動」)。
- **SVD**HF `stabilityai/stable-video-diffusion-img2vid-xt`(17.2 萬次下載)為官方出圖後接視訊;2023 年模型,量產「23–25 幀、約 1024×576」,慣用 812GB。偏好寫實,2D/toon 表現普通。→ 2026 年有更好選擇,**僅列為後備**。
- **Wan2.2**AliyunApache-2.0ComfyUI 官方 2025-07-28 Day-0 支援):官方提出三款開源:
- `Wan2.2-TI2V-5B`:單模型吃 T2VI2V720P@24fps,於 4090 級消費卡可跑(官方)。
- `Wan2.2-I2V-A14B`14B MoE 圖生視訊,480p/720p。
- `Wan2.2-T2V-A14B`14B MoE 文生視訊,480p/720p。
-`Wan2.2-Animate-14B`(角色動畫/替換,用 driving video 帶動角色動作+DWpose)與 `Wan2.2-S2V-14B`(語音帶動)。
- ComfyUI 文件的 I2V/A14B 工作流用 high/low noise 兩個 fp16 子模型;另有 GGUF 量化方便 16GB。
- **LTX-Video**:官方高效 2B 模型(Lightricks548K 下載),ComfyUI 有影像/文字/多幀控制工作流,強調長敘述 prompt;極省記憶體,適合快速迭代。
- **Mochi**genmo 1,「~60GB 單 GPU、ComfyUI 可優化 <20GB」;480p。
- **HunyuanVideo**:騰訊 13B,官方表列 720p@129f 需 60GB1.5 版大幅改進(最低 14GB offload、480p I2V distilled),是 16GB 上唯一可行的 Hunyuan 家族。
---
## 二、哪些特別適合「2D / cel / toon 卡通角色」風格
| 模型 | 2D/toon 適合度 | 理由/方法 |
|---|---|---|
| **AnimateDiff2D 底模+2D LoRA** | ★★★★★(最準) | 風格 100% 由你選的 2D/SD1.5 checkpoint 與 LoRA 決定,可直接對齊你既有立繪畫風(Q版/卡通/toon)。Motion LoRA 控制動作幅度。**建議的首選**。 |
| **Wan2.25B/14B I2V** | ★★★☆ | Wan 偏向電影寫實/美學,也能出卡通/吉普力風,但要靠 prompt(如 `anime, cel shading, 2D cartoon style`)與 LoRA 拉回來;動作品質高。做「較大動作」時用。 |
| **HunyuanVideo-1.5** | ★★★☆ | 社群多 2D/動畫 LoRA 與微調(如按動畫資料訓練),在 16GB 邊緣可用(offload)。 |
| **LTX-Video2.3** | ★★★ | 快、便宜,細節與風格控制較弱;適合快速草稿/非精緻動畫。 |
| **SVD** | ★★☆ | 偏寫實,toon 表現有限。 |
**風格建議(這是「建議」,非官方規定):** 若目標是「把既有 2D 立繪活起來」且動畫幅度小(眨眼、髮絲、呼吸、微笑),**用 AnimateDiff 最能保畫風一致**;若要「有明顯動作/鏡頭的短動畫」,再用 Wan2.2 5B I2V。兩者可分工:小動作→AnimateDiff,大動作/720p→Wan。
---
## 三、RTX 5080 / Blackwellsm_120)在 ComfyUI 的相容性與加速設定
- **硬體規格(NVIDIA 官方)**RTX 5080 = 16GB GDDR7、10752 CUDA cores、**1801 AI TOPSFP4**、約 171 TFLOPSFP32)、Blackwell 架構。消費級 Blackwell 的計算能力標記為 **sm_120**
- **PyTorch / CUDA 要求(關鍵)**RTX 50 系列(sm_120)需要 **CUDA 12.8+ 的 PyTorch**torch cu128 起才原生支援 Blackwell)。**ComfyUI 官方「系統需求」頁對 NVIDIA 現行建議是 `pip install torch ... --extra-index-url https://download.pytorch.org/whl/cu130`CUDA 13.0**。→ 用 ComfyUI 當期 Windows 桌面安裝包/`ComfyUI_Windows_portable` 隨附的 torch 即可;若自行建環境,**務必用 cu128/cu130**,不要沿用舊的 cu121/cu124 環境。
- **已知坑**:部分第三方節點的編譯 kernel 若只針對舊架構 build,在 sm_120 上可能回退或需重編(2026 年主流節點均已支援,但遇到「某節點在 50 系錯誤/慢」時,先更新 ComfyUI、torch 與該節點)。FlashAttention 亦需 sm_120 版 build。
- **FP4/FP8**Blackwell 有第五代 Tensor Core,原生加速 fp8/fp4ComfyUI 對 Wan、HunyuanVideo 等已支援 fp8 與層級 offload,這對 16GB 是關鍵省記憶體手段(但會微微劣化品質)。
- **既有管線相容**IP-Adapter、ControlNet DWpose 皆為成熟節點,在最新 ComfyUI+cu130 上正常運作;與 AnimateDiff/Wan 同圖堆疊時留意 VRAM(見陷阱章節)。
---
## 四、推薦工作流:8 個 2D 角色 → 待機迴圈 → WebM/APNG(每步註明 VRAM 是否安全落在 16GB)
> 前置:更新 ComfyUItorch(cu128/cu130)。建議把 8 張立繪批次迴圈,先跑 1 張驗證再上全量。
### 階段 A — AnimateDiff 做「2D 無縫待機迴圈」(最省、畫風最準)
| 步驟 | 節點 / 模型 | 建議值 | 16GB VRAM |
|---|---|---|---|
| A1 | `Load Checkpoint`:2D/SD1.5 底模(與你的立繪同畫風,fp16;建議 ~5GB 級 SD1.5 | 底模解析度如 768×768 或底模原生 | ✅ 極安全(<6–8GB |
| A2 | `AnimateDiff Loader`motion module `mm_sd_v15_v2`SD1.5 | context length 1632 | ✅ |
| A3 | `AnimateDiff` 迴圈設定:選「Latent Loop」/首幀=末幀使輸出可無縫迴圈 | — | ✅ |
| A4 | (可選)疊 2D style LoRA 強化 toon 一致性;疊 IP-Adapter 鎖定角色臉 | 強度 0.5–0.9(建議值) | ✅(單一 LoRA)/⚠️(再疊 ControlNet+IP-Adapter 才開始吃緊) |
| A5 | 空 Latent`EmptyLatentImage` | 512×512~768×768、frames=16 左右 | ✅ |
| A6 | `KSampler`Scheduler | steps 2025 | ✅ |
| A7 | `VAE Decode``SaveAnimatedPNG`APNG)或 `SaveVideo`mp4 | 見階段 C 轉 WebM | ✅ |
**VRAM 結論(階段 A):SD1.5AnimateDiff1 支 2D LoRA16GB 綽綽有餘。** 若要 SDXL 版 AnimateDiff,僅純底模(fp16 ~13GB)接近 16GB 上限,疊外掛會吃緊——**建議 2D 迴圈用 SD1.5 版**。
### 階段 B — Wan2.2 5B I2V 做「較高動態短動畫」(16GB 可行)
| 步驟 | 節點 / 模型 | 建議值 | 16GB VRAM |
|---|---|---|---|
| B1 | 更新 ComfyUI,載入範本 `Video > Wan2.2 5B video generation`(官方內建) | — | — |
| B2 | `Load Diffusion Model``wan2.2_ti2v_5B_fp16.safetensors` | — | ✅(官方:5B 配 offload 8GB 可放) |
| B3 | `Load VAE``wan2.2_vae.safetensors``Load CLIP/Text``umt5_xxl_fp8_e4m3fn_scaled` | — | ✅ |
| B4 | `EmptyWanVideoLatent`Wan 系列原生節點) | **480p(如 832×480)優先**、則可上 720p1280×704 | ✅ 480p 很安全;720p 仍需 offload,建議先 480p 驗證 |
| B5 | I2V conditioning:首幀 = 你的立繪;正 prompt 寫待機動作(`breathing, hair swaying, gentle smile`+風格詞) | frames 81121;可加 `Video Extend`(每段 +77 幀) | ✅ 480p+短幀安全;拉長/拉高解析度才逼近 16GB |
| B6 | `KSampler`Wan 專屬 Scheduler(模型自動) | steps ~20;可加 Lightx2v 4-steps LoRA 求快 | ✅ |
| B7 | `VAE Decode`→ 輸出 mp4/APNG | 見階段 C | ✅ |
**VRAM 結論(階段 B):Wan2.2 5B 在 480p 下 16GB 安全(官方明言 8GB 可放,配 offload)。** 要到 720p 建議先測 1 張,若不穩就降到 480p 或開 `低VRAM/offload`
### 階段 C — 輸出 WebM(VP9) / APNG(網頁用)
| 輸出 | 做法 |
|---|---|
| **APNG(要透明背景)** | ComfyUI 內建 `SaveAnimatedPNG` 直接出 APNG;二值(RGB+alpha)即可,瀏覽器原生支援。 |
| **WebM(VP9 + alpha)** | ComfyUI `SaveVideo` 出 mp4 → ffmpeg 二進制轉:輸入原圖(或用亮/綠幕 key)→ `c:v libvpx-vp9 -pix_fmt yuva420p`(VP9 alpha)。若要無接縫迴圈,確保階段 A/B 已做「Latent Loop/首幀=末幀」,並在 ffmpeg 用 `-stream_loop -1` 播。 |
| **無透明需求** | 直接 `SaveVideo`mp4/H.264)或轉 VP9 WebM 即可,體積小、播放順。 |
> WebM/APNG 的業界格式取捨細節另見系列 #04《網頁素材整合技術》。此處僅列 ComfyUI 端產出與轉檔最小動作。
---
## 五、與 4090 / 5070 比較:5080 的優勢劣勢(量化/Offload 需求)
| 面向 | RTX 508016GB・Blackwell | RTX 409024GB・Ada | RTX 507012GB・Blackwell |
|---|---|---|---|
| VRAM | 16GB(居中) | **24GB(最大餘裕)** | 12GB(最小) |
| 架構 | Blackwell sm_120 | Ada Lovelace sm_89 | Blackwell sm_120 |
| FP4 AI TOPS(官方) | **1801**(查證) | 未查到(慣傳 ~1321) | 未查到(慣傳 ~988 |
| 開源視訊模型契合度 | ★★★★★(16GB 涵蓋 Wan2.2 5B、HunyuanVideo-1.5、AnimateDiff、LTX | ★★★★★(VRAM 大,14B/720p 更從容) | ★★★(12GB 下 14B 模型更需低量化/offload |
| 14BWan/Hunyuan)情形 | 需 fp8/GGUFoffload480p 順暢 | 720p 較順(24GB | 需低量位(Q4~Q6)+offload |
| 量化/Offload 需求強度 | 中(5B 不需、14B 需要) | 低–中 | 高(12GB 較緊) |
**優勢:** 5080 為 2026 年開源視訊模型的「甜點位」——比 5070 多 4GB(14B 明顯較可行),且 Blackwell FP8/FP4 原生加速讓 Wan/Hunyuan 等現代模型較 Ada4090)更快、更省電。
**劣勢:** 比 4090 少 8GB 餘裕,14B 模型的 720p 高幀長段仍需靠量化+offload,品質與速度會略打折;若要「無腦 720p 14B」不如 4090。
> 4090/5070 的 AI TOPS 數字本文標「未查到」(僅 5080 的 1801 經 NVIDIA 官方頁查證),避免臆造。
---
## 六、陷阱與避坑清單(降低成本/防爆 VRAM)
1. **爆 VRAM 主因**:同時疊 ControlNetIP-AdapterSDXL 底模+長幀數+高解析。解法=一次只開需要的外掛、解析度降、開 ComfyUI `--lowvram`/offload、用 fp8/GGUF。WLan 14B 開 720p +長幀最易爆——先 480p。**Mochi 720p、HunyuanVideo 720p 在 16GB 直接不要碰**(官方 ~60GB)。
2. **無縫迴圈**:不做「Latent Loop/首幀=末幀」迴圈,WebM 播放會出現跳接縫。輸出前用 `LatentFromBatch/Concat` 或 Wan `Video Extend` 對齊。
3. **透明背景**:網站常用透明角色。VP9 alpha(`yuva420p`)或 APNG 都要在生成時保留 alpha(亮/綠幕 key 或輸出 PNG 序列);直接輸 mp4 沒有 alpha。
4. **Blackwell 環境**:用錯 torchcu121/cu124)會「裝好了卻跑不動」。務必 cu128/cu130;第三方節點報錯先更新。
5. **品質 vs VRAM 的量化取捨**:16GB 上 14B 模型預設降量位置化(fp8/GGUF)會微微劣化,非必要不必強上 14B——Wan2.2 5B 在 480p 已很好,成本/品質比最優。
6. **降成本**:8 角色只做「待機小迴圈」,用階段 A(AnimateDiff,秒級、零額外 GPU 成本)即可覆蓋大部分;只有少數「重點鏡頭」才花時間跑階段 B 的 Wan。時間即成本,480p+短幀+Lightx2v 4-steps 加速 LoRA 能大幅壓縮單段時間。
7. **別把開源當萬能**:若追求電影級,MinimmH H3 等走 API(見系列 #01/#03/#07),本地 5080 專攻「可控、廉價、批量」的遊戲素材生成。
---
## 七、可行動建議(對本遊戲專案)
- **主推「雙軌」****AnimateDiff(SD1.52D LoRA) 產 8 個角色的待機迴圈(眨眼/髮絲/呼吸/微笑)****Wan2.2-TI2V-5B I2V(480p) 做少量高動態短動畫**。先跑 1 角色 2 段驗證畫風與迴圈,再批次 8 角色。
- **輸出規格**:待機迴圈→APNG(或 WebM VP9alpha);網頁播放用 `SaveAnimatedPNG`/ffmpeg 轉 WebM。無縫迴圈務必「首幀=末幀」。
- **第一批下載(目錄)**Wan2.2 5B + wan2.2_vae + umt5 fp8 text encoder(見官方 ComfyUI Wan2.2 教學);AnimateDiff motion module + 一支 2D/anime LoRA;如要做 Hunyuan 則抓 1.5 的 480p I2V。
- **不要**:別在本機 16GB 用 Mochi / HunyuanVideo(720p)14B 模型先試 480p。
---
## 八、資料來源連結清單
**ComfyUI 官方/權威**
- ComfyUI 官方文件(首頁):https://docs.comfy.org/
- ComfyUI 系統需求(NVIDIA 建議 torch cu130):https://docs.comfy.org/installation/system_requirements
- Wan2.2 官方 ComfyUI 教學(含 5B/14B 模型、VRAM 註記、GGUF):https://docs.comfy.org/tutorials/video/wan/wan2_2
- Wan2.2 Animate ComfyUI 教學(角色動畫+DWpose):https://docs.comfy.org/tutorials/video/wan/wan2-2-animate
- LTX-Video 0.9.5 ComfyUI 教學(2B,需求:ltx-video-2b + t5xxl):https://docs.comfy.org/tutorials/video/ltxv
- Hunyuan Video ComfyUI 教學(13B 720p 模型、I2V):https://docs.comfy.org/tutorials/video/hunyuan/hunyuan-video
- Minivideo MiniMax H3 open weights 教學:https://docs.comfy.org/tutorials/video/minimax/minimax-h3
- ComfyUI-AnimateDiff-EvolvedKosinkadink):https://github.com/Kosinkadink/ComfyUI-AnimateDiff-Evolved
**模型官方/權威**
- Wan2.2 GitHub(模型表、消費卡 4090 可跑、Apache-2.0):https://github.com/Wan-Video/Wan2.2
- Wan2.2-TI2V-5BHF):https://huggingface.co/Wan-AI/Wan2.2-TI2V-5B
- Wan2.2-I2V-A14BHF):https://huggingface.co/Wan-AI/Wan2.2-I2V-A14B
- Wan2.1HF1.3B/14B):https://huggingface.co/Wan-AI/Wan2.1-T2V-1.3B https://huggingface.co/Wan-AI/Wan2.1-T2V-14B
- HunyuanVideo GitHub(記憶體表 720p@129f=60GB、fp8):https://github.com/Tencent-Hunyuan/HunyuanVideo
- HunyuanVideo-1.5 GitHub(最低 14GB(offload)、480p I2V distilled、Wan2GP 6GB):https://github.com/Tencent-Hunyuan/HunyuanVideo-1.5
- Mochi-1 GitHub~60GBComfyUI <20GB):https://github.com/genmoai/mochi
- LTX-VideoHF):https://huggingface.co/Lightricks/LTX-Video
- AnimateDiffHFSD1.5 adapter):https://huggingface.co/guoyww/animatediff
- AnimateDiff SDXL beta adapterHF):https://huggingface.co/guoyww/animatediff-motion-adapter-sdxl-beta
- SVD 1.1 / XTHF):https://huggingface.co/stabilityai/stable-video-diffusion-img2vid https://huggingface.co/stabilityai/stable-video-diffusion-img2vid-xt
**硬體**
- NVIDIA RTX 5080 官方(16GB GDDR7、10752 cores、1801 AI TOPS、171 TFLOPS):https://www.nvidia.com/en-us/geforce/graphics-cards/50-series/rtx-5080/
- NVIDIA RTX 5070 官方:https://www.nvidia.com/en-us/geforce/graphics-cards/50-series/rtx-5070/
- Wikipedia GeForce 50 seriesBlackwell、RTX 50 於 2025-01 上市):https://en.wikipedia.org/wiki/GeForce_50_series
**本系列相關報告**
- series #00 總覽、#03 靜態轉動態、#07 成本部署(同目錄 minimax-ai-game-visual/
> 說明:SVD、LTX、AnimateDiff 等「慣用 VRAM」之數量級措辭,因官方未給單一硬性數字,本文以「慣用/建議值」+連結呈現;確切值如需可於實測階段以 Windows 工作管理員/`nvidia-smi` 實測修正。
@@ -0,0 +1,91 @@
# 騙子酒館 — 美術素材需求清單(要生什麼圖 / 什麼影片)
> 產出:2026-08-06 目標硬體:RTX 508016GB)+ ComfyUI 對應 PR #17 的佔位素材替換計畫
> 系列報告:docs/research/minimax-ai-game-visual/02 角色繪製、03 靜態轉動態、05 一致性、06 風格指引、10 5080 動畫方案)
---
## 一、總覽:分「圖」與「影片」兩類
| 類別 | 用途 | 來源方案 | 優先度 |
|------|------|----------|--------|
| **A. 靜態立繪 PNG** | 8 角色正式立繪(替換 PlayerSeat 的 CSS 佔位) | ComfyUIFLUX/SD + LoRA 或 IP-Adapter | 🔴 最高 |
| **B. 卡面/牌背/目標牌** | 撲克風卡面、牌背花紋、目標牌 | ComfyUI 文生圖 | 🔴 高 |
| **C. 待機動作迴圈** | 8 角色眨眼/髮絲/呼吸(WebM/APNG | ComfyUI AnimateDiff 或 Wan2.2 | 🟠 中(先做 1 個試) |
| **D. 氛圍/過場影片** | 過場動畫、行銷片頭 | Wan2.2 I2V 或 MiniMax H3 API | 🟡 低 |
| **E. 酒館背景** | 大廳/牌桌背景襯底 | ComfyUI 文生圖 16:9 | 🟡 低(CSS 已可暫代) |
---
## 二、A. 8 角色正式立繪(最高優先)
### 規格
- **尺寸**1024×1024(再縮到 512 當座位圖),透明背景 PNG(另存白底版備用)
- **畫風**:統一「Q 版 2D 卡通(chibi/cel/toon)」,與現有角色視覺映射一致(docs/角色視覺映射筆記.md)
- **一致性策略**(詳見 05 號報告):「1 支畫風 LoRA × 8 支角色 LoRA」疊加,或 FLUX.2/Nano Banana 多參考圖定稿
### 8 角色清單(沿用 characters.js / 視覺映射筆記)
| # | 角色 | 髮色/髮型 | 服裝 | 配件 | 性格 | 標籤色 |
|---|------|-----------|------|------|------|--------|
| 1 | Grok-4 | 波浪橘髮 | 黃色無袖洋裝 | 紅色大蝴蝶結 | 演戲/情緒 | 粉紅 |
| 2 | Kimi | 白銀長直髮 | 深藍洋裝+白領 | 項圈 | 老派/踏實 | 深藍 |
| 3 | Qwen3 | 粉紅短髮 | 灰無袖洋裝 | 圓眼鏡 | 刺兒頭 | 藍紫 |
| 4 | GPT-5 | 黑長直髮 | 黑洋裝 | 無 | 沉穩/謀略 | 黑 |
| 5 | Gemini | 紫短髮 | 淺藍洋裝 | 圓眼鏡+星夾 | 好奇 | 淺紫 |
| 6 | Deepseek | 淺藍短髮 | 深藍灰洋裝 | 鯨魚尾 | 瘋癲 | 藍 |
| 7 | Doubao | 深棕短髮 | 黑洋裝 | 無 | 冷靜 | 淺藍 |
| 8 | Claude | 橘短髮 | 橘洋裝 | 無 | 淡定 | 橘 |
**每角色需 2 姿勢**:正面表情1(預設)+ 得意/偷笑(出牌時)。共約 16 張。
---
## 三、B. 卡面 / 牌背 (高)
- **卡面**A/K/Q/J/10…目標牌區):簡約撲克風、酒館燙金邊、角色元素點綴。每張 512×712(3:4)
- **牌背**:1 張共用,深綠/深紅酒館圖樣,防看穿(無固定印記)。512×712
- **目標牌賞識**1 張放大版
---
## 四、C. 待機動作迴圈(中 — 先做 1 個 PoC)
> 優先做 **1 個角色**(建議 Deepseek 或 Claude)試水溫,驗證品質後再批量。
| 項目 | 來源 | 動作 | 輸出 |
|------|------|------|------|
| 待機循環(眨眼+髮絲+呼吸) | AnimateDiffSD1.5 + 2D LoRA16GB 安全) | 首幀=末幀無縫 | **Animated WebP / APNG**<128KB |
| 高動態短動畫(微笑/甩髮) | Wan2.2-5B I2V480p16GB 安全) | 圖生視訊 | WebM(VP9) |
| 備援方案 | MiniMax H3 API0.08 美元/秒) | First/Last-frame | WebM/APNG |
**網頁引導**:座位用 `<img src="xx.webp" loop>` 或 sprite sheet + CSS steps()(見 04 號報告)。避免 8 支同時播全析影片解碼爆。
---
## 五、D. 過場 / 氛圍影片(低)
- **過場**H3/Wan2.2 產 5–8 秒「酒杯碰撞/翻牌/煙霧」段子,WebM + MP4 備援
- **行銷片頭**:H3 文生影片 815 秒(2K 0.13 美元/秒)
---
## 六、E. 酒館背景(低,CSS 已暫代)
- 大廳/牌桌背景 16:9 深色木紋酒館內景(文生圖,污點/模糊版當襯底)
- 桌布/毛氈紋理替換純 CSS 漸層
---
## 七、生成 SOP(沿用 #10 報告三階段)
1. **定畫風**FLUX/SD + 畫風 LoRA 出 8 角色定稿 → 過濾 → 一致性檢查
2. **動畫**AnimateDiff2D 迴圈)→ Wan2.2(高動態)→ 挑閉合度最佳版
3. **轉檔**ffmpeg → WebM(VP9+alpha) / Animated WebP / APNG / sprite sheet
4. **落地**:換掉 PR #17 的 CSS 佔位 → 效能測試(DevTools Performance
---
## 八、預算速覽(依 07 號報告)
- 靜態圖:FLUX-schnell ~$0.003/張(幾乎可忽略);8 角色約 16 張 < $1
- 動畫(RTX 5080 自跑):僅電費;品質差強用 H3 API 補:8 角色 × 5 秒 ≈ ¥240(768P
- MVP 總估 ¥200–600(不含人力)