feat(visual): 酒館場景風牌桌 + 8 Q 版角色座位 + 氛圍動畫 #17
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 5080(16GB・Blackwell sm_120)+ ComfyUI 開源 2D 卡通角色動畫方案
|
||||
|
||||
**調查日期:** 2026-08-07
|
||||
**調查者:** Hermes 研究子代理(minimax-ai-game-visual 工作流系列 #10)
|
||||
**目標硬體:** RTX 5080(16GB 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 待機迴圈」的主力是「AnimateDiff(SD1.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+I2V,720P@24fps,官方明言可在 4090 等消費卡上跑,ComfyUI 原生 offload 下「5B 版在 8GB 就能放得下」)、`Wan2.2-I2V-A14B`(14B MoE,480p/720p,需靠 fp8/GGUF+offload)。→ **給「高品質動作」的首選**。
|
||||
3. **RTX 5080/Blackwell(sm_120)的關鍵設定**:必須用支援 Blackwell 的 PyTorch(CUDA 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(單卡官方 ~60GB,ComfyUI 優化後 <20GB,16GB 太勉強)、HunyuanVideo(720p@129f 官方表列 60GB;改用 **HunyuanVideo-1.5**,官方最低 ~14GB(含 offload),才在 16GB 邊緣可行)、Wan2.2 14B 720p(需較重量化/offload,非首選;降落 480p 或用 5B)。
|
||||
6. **與 4090/5070 比較**:5080(16GB、Blackwell)在「開源視訊模型」是 2026 年消費卡甜點位——比 4090(24GB)VRAM 少但 Blackwell FP8/FP4 算力與耗電更好;比 5070(12GB)VRAM 多、14B 模型較可行。量化的需求:越高階模型越要靠 fp8/GGUF,這是省 VRAM 但劣化品質的雙面刃。
|
||||
7. **陷阱**:720p+長幀數+堆疊 ControlNet/IP-Adapter 最容易爆 VRAM;無縫迴圈要「首幀=末幀」或 Latent Loop,否則接縫明顯;網頁透明背景要用 chromatic key+VP9 alpha(或直接 APNG),否則白底;SDXL 版 AnimateDiff+其他外掛疊在 16GB 會吃緊。
|
||||
|
||||
---
|
||||
|
||||
## 一、候選模型在 ComfyUI 的支援與 VRAM 需求(逐項查證)
|
||||
|
||||
| 模型 / 版本 | ComfyUI 支援 | 官方/權威 VRAM 需求 | 在 16GB 可行度(5080) |
|
||||
|---|---|---|---|
|
||||
| **AnimateDiff(SD1.5 版)** | 原生透過 `ComfyUI-AnimateDiff-Evolved`(Kosinkadink)等節點,「在 SD1.5/SDXL checkpoint 上疊 motion module」;支援 Motion LoRA | 文件未列硬性數字;因架構是在 ~5GB(SD1.5 fp16)底模上加工,慣用 <8GB 即可 | ✅ 非常安全(遠低於 16GB) |
|
||||
| **AnimateDiff(SDXL 版)** | 同上(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 幀、約 8–12GB 可行(offload 後) | ✅ 可行(但為 2023 舊模型,品質落後,推薦度低) |
|
||||
| **Wan2.1(T2V-1.3B / 5B / 14B,480p/720p)** | ComfyUI 原生支援,Comfy-Org repackaged | 1.3B 極輕量各家皆可;14B 需 fp8/量化與層級 offload | ✅(1.3B)/⚠️(14B 需量化);已被 Wan2.2 取代 |
|
||||
| **Wan2.2-TI2V-5B**(混合 T2V+I2V,720P@24fps) | **ComfyUI 原生(2025-07-28 起)**,Apache-2.0 | 官方 ComfyUI 文件明言「5B 版在 8GB 就能放得下(配 ComfyUI 原生 offload)」;官方「5 秒 720p < 9 分鐘(單消費卡)」 | ✅ 很安全,**本報告 I2V 首選** |
|
||||
| **Wan2.2-I2V-A14B / T2V-A14B**(14B MoE,480p/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-1(480p)** | ComfyUI 原生(`EmptyMochiLatentVideo`、ModelMergeMochiPreview);2024-11-05 官方宣布消費卡原生支援 | genmo 官方:單 GPU 需 **~60GB**;「ComfyUI 可優化到 **<20GB**」 | ⚠️ 16GB 太勉強,**不建議用 5080 跑** |
|
||||
| **HunyuanVideo(13B,720p,129 幀)** | ComfyUI 原生 Hunyuan 節點 | 官方記憶體表:720px1280px129f ≈ **60GB**(fp8 可省) | ❌ **不要用 5080 跑 720p**;見下方 1.5 |
|
||||
| **HunyuanVideo-1.5(2025-11-21)** | ComfyUI 原生(`HunyuanVideo15ImageToVideo` 等) | 官方 README:「**Minimum GPU Memory: 14 GB(含 model offloading)**」;另 480p I2V step-distilled 模型;Wan2GP 號稱可低至 6GB | ⚠️ 16GB 邊緣可行(需 offload) |
|
||||
| **MiniMax H3(open 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」,慣用 8–12GB。偏好寫實,2D/toon 表現普通。→ 2026 年有更好選擇,**僅列為後備**。
|
||||
- **Wan2.2**(Aliyun,Apache-2.0;ComfyUI 官方 2025-07-28 Day-0 支援):官方提出三款開源:
|
||||
- `Wan2.2-TI2V-5B`:單模型吃 T2V+I2V,720P@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 模型(Lightricks,548K 下載),ComfyUI 有影像/文字/多幀控制工作流,強調長敘述 prompt;極省記憶體,適合快速迭代。
|
||||
- **Mochi**:genmo 1,「~60GB 單 GPU、ComfyUI 可優化 <20GB」;480p。
|
||||
- **HunyuanVideo**:騰訊 13B,官方表列 720p@129f 需 60GB;1.5 版大幅改進(最低 14GB offload、480p I2V distilled),是 16GB 上唯一可行的 Hunyuan 家族。
|
||||
|
||||
---
|
||||
|
||||
## 二、哪些特別適合「2D / cel / toon 卡通角色」風格
|
||||
|
||||
| 模型 | 2D/toon 適合度 | 理由/方法 |
|
||||
|---|---|---|
|
||||
| **AnimateDiff+2D 底模+2D LoRA** | ★★★★★(最準) | 風格 100% 由你選的 2D/SD1.5 checkpoint 與 LoRA 決定,可直接對齊你既有立繪畫風(Q版/卡通/toon)。Motion LoRA 控制動作幅度。**建議的首選**。 |
|
||||
| **Wan2.2(5B/14B I2V)** | ★★★☆ | Wan 偏向電影寫實/美學,也能出卡通/吉普力風,但要靠 prompt(如 `anime, cel shading, 2D cartoon style`)與 LoRA 拉回來;動作品質高。做「較大動作」時用。 |
|
||||
| **HunyuanVideo-1.5** | ★★★☆ | 社群多 2D/動畫 LoRA 與微調(如按動畫資料訓練),在 16GB 邊緣可用(offload)。 |
|
||||
| **LTX-Video(2.3)** | ★★★ | 快、便宜,細節與風格控制較弱;適合快速草稿/非精緻動畫。 |
|
||||
| **SVD** | ★★☆ | 偏寫實,toon 表現有限。 |
|
||||
|
||||
**風格建議(這是「建議」,非官方規定):** 若目標是「把既有 2D 立繪活起來」且動畫幅度小(眨眼、髮絲、呼吸、微笑),**用 AnimateDiff 最能保畫風一致**;若要「有明顯動作/鏡頭的短動畫」,再用 Wan2.2 5B I2V。兩者可分工:小動作→AnimateDiff,大動作/720p→Wan。
|
||||
|
||||
---
|
||||
|
||||
## 三、RTX 5080 / Blackwell(sm_120)在 ComfyUI 的相容性與加速設定
|
||||
|
||||
- **硬體規格(NVIDIA 官方)**:RTX 5080 = 16GB GDDR7、10752 CUDA cores、**1801 AI TOPS(FP4)**、約 171 TFLOPS(FP32)、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/fp4;ComfyUI 對 Wan、HunyuanVideo 等已支援 fp8 與層級 offload,這對 16GB 是關鍵省記憶體手段(但會微微劣化品質)。
|
||||
- **既有管線相容**:IP-Adapter、ControlNet DWpose 皆為成熟節點,在最新 ComfyUI+cu130 上正常運作;與 AnimateDiff/Wan 同圖堆疊時留意 VRAM(見陷阱章節)。
|
||||
|
||||
---
|
||||
|
||||
## 四、推薦工作流:8 個 2D 角色 → 待機迴圈 → WebM/APNG(每步註明 VRAM 是否安全落在 16GB)
|
||||
|
||||
> 前置:更新 ComfyUI+torch(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 16–32 | ✅ |
|
||||
| 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 20–25 | ✅ |
|
||||
| A7 | `VAE Decode` → `SaveAnimatedPNG`(APNG)或 `SaveVideo`(mp4) | 見階段 C 轉 WebM | ✅ |
|
||||
|
||||
**VRAM 結論(階段 A):SD1.5+AnimateDiff+1 支 2D LoRA,16GB 綽綽有餘。** 若要 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)優先**、則可上 720p(1280×704) | ✅ 480p 很安全;720p 仍需 offload,建議先 480p 驗證 |
|
||||
| B5 | I2V conditioning:首幀 = 你的立繪;正 prompt 寫待機動作(`breathing, hair swaying, gentle smile`+風格詞) | frames 81–121;可加 `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 5080(16GB・Blackwell) | RTX 4090(24GB・Ada) | RTX 5070(12GB・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) |
|
||||
| 14B(Wan/Hunyuan)情形 | 需 fp8/GGUF+offload;480p 順暢 | 720p 較順(24GB) | 需低量位(Q4~Q6)+offload |
|
||||
| 量化/Offload 需求強度 | 中(5B 不需、14B 需要) | 低–中 | 高(12GB 較緊) |
|
||||
|
||||
**優勢:** 5080 為 2026 年開源視訊模型的「甜點位」——比 5070 多 4GB(14B 明顯較可行),且 Blackwell FP8/FP4 原生加速讓 Wan/Hunyuan 等現代模型較 Ada(4090)更快、更省電。
|
||||
**劣勢:** 比 4090 少 8GB 餘裕,14B 模型的 720p 高幀長段仍需靠量化+offload,品質與速度會略打折;若要「無腦 720p 14B」不如 4090。
|
||||
> 4090/5070 的 AI TOPS 數字本文標「未查到」(僅 5080 的 1801 經 NVIDIA 官方頁查證),避免臆造。
|
||||
|
||||
---
|
||||
|
||||
## 六、陷阱與避坑清單(降低成本/防爆 VRAM)
|
||||
|
||||
1. **爆 VRAM 主因**:同時疊 ControlNet+IP-Adapter+SDXL 底模+長幀數+高解析。解法=一次只開需要的外掛、解析度降、開 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 環境**:用錯 torch(cu121/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.5+2D LoRA) 產 8 個角色的待機迴圈(眨眼/髮絲/呼吸/微笑)**;**Wan2.2-TI2V-5B I2V(480p) 做少量高動態短動畫**。先跑 1 角色 2 段驗證畫風與迴圈,再批次 8 角色。
|
||||
- **輸出規格**:待機迴圈→APNG(或 WebM VP9+alpha);網頁播放用 `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-Evolved(Kosinkadink):https://github.com/Kosinkadink/ComfyUI-AnimateDiff-Evolved
|
||||
|
||||
**模型官方/權威**
|
||||
- Wan2.2 GitHub(模型表、消費卡 4090 可跑、Apache-2.0):https://github.com/Wan-Video/Wan2.2
|
||||
- Wan2.2-TI2V-5B(HF):https://huggingface.co/Wan-AI/Wan2.2-TI2V-5B
|
||||
- Wan2.2-I2V-A14B(HF):https://huggingface.co/Wan-AI/Wan2.2-I2V-A14B
|
||||
- Wan2.1(HF,1.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(~60GB;ComfyUI <20GB):https://github.com/genmoai/mochi
|
||||
- LTX-Video(HF):https://huggingface.co/Lightricks/LTX-Video
|
||||
- AnimateDiff(HF,SD1.5 adapter):https://huggingface.co/guoyww/animatediff
|
||||
- AnimateDiff SDXL beta adapter(HF):https://huggingface.co/guoyww/animatediff-motion-adapter-sdxl-beta
|
||||
- SVD 1.1 / XT(HF):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 series(Blackwell、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 5080(16GB)+ ComfyUI | 對應 PR #17 的佔位素材替換計畫
|
||||
> 系列報告:docs/research/minimax-ai-game-visual/(02 角色繪製、03 靜態轉動態、05 一致性、06 風格指引、10 5080 動畫方案)
|
||||
|
||||
---
|
||||
|
||||
## 一、總覽:分「圖」與「影片」兩類
|
||||
|
||||
| 類別 | 用途 | 來源方案 | 優先度 |
|
||||
|------|------|----------|--------|
|
||||
| **A. 靜態立繪 PNG** | 8 角色正式立繪(替換 PlayerSeat 的 CSS 佔位) | ComfyUI:FLUX/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)試水溫,驗證品質後再批量。
|
||||
|
||||
| 項目 | 來源 | 動作 | 輸出 |
|
||||
|------|------|------|------|
|
||||
| 待機循環(眨眼+髮絲+呼吸) | AnimateDiff(SD1.5 + 2D LoRA,16GB 安全) | 首幀=末幀無縫 | **Animated WebP / APNG**(<128KB) |
|
||||
| 高動態短動畫(微笑/甩髮) | Wan2.2-5B I2V(480p,16GB 安全) | 圖生視訊 | WebM(VP9) |
|
||||
| 備援方案 | MiniMax H3 API(0.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 文生影片 8–15 秒(2K 0.13 美元/秒)
|
||||
|
||||
---
|
||||
|
||||
## 六、E. 酒館背景(低,CSS 已暫代)
|
||||
|
||||
- 大廳/牌桌背景 16:9 深色木紋酒館內景(文生圖,污點/模糊版當襯底)
|
||||
- 桌布/毛氈紋理替換純 CSS 漸層
|
||||
|
||||
---
|
||||
|
||||
## 七、生成 SOP(沿用 #10 報告三階段)
|
||||
|
||||
1. **定畫風**:FLUX/SD + 畫風 LoRA 出 8 角色定稿 → 過濾 → 一致性檢查
|
||||
2. **動畫**:AnimateDiff(2D 迴圈)→ 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(不含人力)
|
||||
Reference in New Issue
Block a user