feat: 游戏大厅
This commit is contained in:
4
components.d.ts
vendored
4
components.d.ts
vendored
@@ -24,8 +24,10 @@ declare module 'vue' {
|
||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
GameList: typeof import('./src/components/baseLayouts/GameList.vue')['default']
|
||||
Lobby: typeof import('./src/components/baseLayouts/Lobby.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
TableCard: typeof import('./src/components/baseLayouts/TableCard.vue')['default']
|
||||
UserBar: typeof import('./src/components/baseLayouts/UserBar.vue')['default']
|
||||
}
|
||||
}
|
||||
@@ -44,7 +46,9 @@ declare global {
|
||||
const ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||
const ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
const GameList: typeof import('./src/components/baseLayouts/GameList.vue')['default']
|
||||
const Lobby: typeof import('./src/components/baseLayouts/Lobby.vue')['default']
|
||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||
const RouterView: typeof import('vue-router')['RouterView']
|
||||
const TableCard: typeof import('./src/components/baseLayouts/TableCard.vue')['default']
|
||||
const UserBar: typeof import('./src/components/baseLayouts/UserBar.vue')['default']
|
||||
}
|
||||
@@ -54,46 +54,32 @@ interface GameInfo {
|
||||
/** 游戏列表数据 */
|
||||
const games: GameInfo[] = [
|
||||
{
|
||||
id: 'basketball',
|
||||
icon: '🏀',
|
||||
name: '篮球对决',
|
||||
description: '实时篮球对战游戏,与全球玩家同场竞技',
|
||||
gameplay: '使用方向键移动球员,空格键投篮。每场比赛5分钟,得分多者获胜。支持1v1、3v3模式。'
|
||||
id: 'snake',
|
||||
icon: '🐍',
|
||||
name: '贪吃蛇',
|
||||
description: '经典贪吃蛇竞技,在狭小空间内争夺生存权',
|
||||
gameplay: '方向键控制蛇的移动,吃到食物变长。撞墙或其他蛇的身体即死亡,最后存活的玩家获胜。'
|
||||
},
|
||||
{
|
||||
id: 'football',
|
||||
icon: '⚽',
|
||||
name: '足球竞技',
|
||||
description: '热血足球比赛,组建你的梦之队',
|
||||
gameplay: 'WASD控制球员移动,J键传球,K键射门。11人制比赛,配合战术赢得胜利。'
|
||||
id: 'tetris',
|
||||
icon: '🧊',
|
||||
name: '俄罗斯方块',
|
||||
description: '经典下落消除,与对手一较高下',
|
||||
gameplay: '方向键移动和旋转方块,填满横排即可消除。同时向对手发送干扰行,坚持到最后的玩家获胜。'
|
||||
},
|
||||
{
|
||||
id: 'racing',
|
||||
icon: '🏎️',
|
||||
name: '极速赛车',
|
||||
description: '极限竞速,漂移过弯超越对手',
|
||||
gameplay: '方向键控制方向,Shift键加速漂移。多种赛道可选,挑战最快圈速记录。'
|
||||
},
|
||||
{
|
||||
id: 'chess',
|
||||
icon: '♟️',
|
||||
name: '策略棋牌',
|
||||
description: '经典棋牌合集,运筹帷幄决胜千里',
|
||||
gameplay: '包含围棋、象棋、五子棋等多种棋类。支持人机对战和在线匹配两种模式。'
|
||||
},
|
||||
{
|
||||
id: 'shooting',
|
||||
id: 'gomoku',
|
||||
icon: '🎯',
|
||||
name: '射击战场',
|
||||
description: '团队战术射击,配合队友夺取胜利',
|
||||
gameplay: '鼠标瞄准,左键射击,R键换弹。多种武器和地图,5v5团队竞技模式。'
|
||||
name: '五子棋',
|
||||
description: '黑白对弈,先连成五子者胜',
|
||||
gameplay: '鼠标点击落子,黑白双方轮流在棋盘上放置棋子。先在横、竖、斜任意方向连成五子的一方获胜。'
|
||||
},
|
||||
{
|
||||
id: 'arena',
|
||||
icon: '🏆',
|
||||
name: '竞技场',
|
||||
description: '综合竞技平台,参与锦标赛赢取奖励',
|
||||
gameplay: '每日举办多场锦标赛,涵盖各类型游戏。积累积分提升段位,赢取丰厚奖励。'
|
||||
id: 'guess-number',
|
||||
icon: '🔢',
|
||||
name: '猜数字',
|
||||
description: '逻辑推理对战,猜中对方的秘密数字',
|
||||
gameplay: '每轮输入猜测的数字组合,系统会提示位置和数字正确的数量。率先猜中对手数字组合的玩家获胜。'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
449
src/components/baseLayouts/Lobby.vue
Normal file
449
src/components/baseLayouts/Lobby.vue
Normal file
@@ -0,0 +1,449 @@
|
||||
<template>
|
||||
<div class="lobby">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="lobby__toolbar">
|
||||
<div class="lobby__filters">
|
||||
<button
|
||||
v-for="f in statusFilters"
|
||||
:key="f.key"
|
||||
class="lobby__filter-btn"
|
||||
:class="{ 'lobby__filter-btn--active': activeFilter === f.key }"
|
||||
@click="activeFilter = f.key"
|
||||
>
|
||||
{{ f.label }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="lobby__create-btn" @click="handleCreateRoom">
|
||||
<span class="lobby__create-icon">+</span>
|
||||
创建房间
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 桌子网格 -->
|
||||
<div class="lobby__grid">
|
||||
<TableCard
|
||||
v-for="room in filteredRooms"
|
||||
:key="room.id"
|
||||
:room="room"
|
||||
@join="handleJoin"
|
||||
@join-seat="handleJoinSeat"
|
||||
@spectate="handleSpectate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 空状态提示 -->
|
||||
<div v-if="filteredRooms.length === 0" class="lobby__empty">
|
||||
<span class="lobby__empty-icon">🪑</span>
|
||||
<p class="lobby__empty-text">暂无符合条件的房间</p>
|
||||
<p class="lobby__empty-hint">换个筛选条件,或者自己创建一个房间吧</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import TableCard from './TableCard.vue'
|
||||
import type { Room } from './TableCard.vue'
|
||||
import { useBaseLayoutStore } from '@/stores'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
/** 状态筛选选项 */
|
||||
const statusFilters = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'waiting', label: '等待中' },
|
||||
{ key: 'playing', label: '进行中' },
|
||||
]
|
||||
const activeFilter = ref('all')
|
||||
|
||||
const baseLayoutStore = useBaseLayoutStore()
|
||||
const { activeGameId } = storeToRefs(baseLayoutStore)
|
||||
|
||||
/** 切换游戏时重置筛选 */
|
||||
watch(activeGameId, () => {
|
||||
activeFilter.value = 'all'
|
||||
})
|
||||
|
||||
/** 根据名字生成头像 URL */
|
||||
function avatarUrl(name: string): string {
|
||||
return `https://api.dicebear.com/7.x/bottts-neutral/svg?seed=${encodeURIComponent(name)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 各游戏的模拟房间数据
|
||||
* key 对应 GameList 中的 gameId
|
||||
*/
|
||||
const ALL_ROOMS: Record<string, Room[]> = {
|
||||
/* ---- 贪吃蛇 ---- */
|
||||
snake: [
|
||||
{
|
||||
id: 'snake-001', name: '桌 01', gameIcon: '🐍', gameId: 'snake',
|
||||
maxPlayers: 4,
|
||||
players: [
|
||||
{ avatar: avatarUrl('小龙'), nickname: '小龙' },
|
||||
{ avatar: avatarUrl('阿蟒'), nickname: '阿蟒' },
|
||||
],
|
||||
status: 'waiting', statusText: '等待中', stakes: 300, mode: '经典模式',
|
||||
creatorName: '小龙',
|
||||
},
|
||||
{
|
||||
id: 'snake-002', name: '桌 02', gameIcon: '🐍', gameId: 'snake',
|
||||
maxPlayers: 4,
|
||||
players: [
|
||||
{ avatar: avatarUrl('闪电'), nickname: '闪电' },
|
||||
{ avatar: avatarUrl('疾风'), nickname: '疾风' },
|
||||
{ avatar: avatarUrl('游侠'), nickname: '游侠' },
|
||||
{ avatar: avatarUrl('幻影'), nickname: '幻影' },
|
||||
],
|
||||
status: 'playing', statusText: '进行中', stakes: 500, mode: '道具模式',
|
||||
creatorName: '闪电',
|
||||
},
|
||||
{
|
||||
id: 'snake-003', name: '桌 03', gameIcon: '🐍', gameId: 'snake',
|
||||
maxPlayers: 4,
|
||||
players: [
|
||||
{ avatar: avatarUrl('青蛇'), nickname: '青蛇' },
|
||||
],
|
||||
status: 'waiting', statusText: '等待中', stakes: 200, mode: '加速模式',
|
||||
creatorName: '青蛇',
|
||||
},
|
||||
{
|
||||
id: 'snake-004', name: '桌 04', gameIcon: '🐍', gameId: 'snake',
|
||||
maxPlayers: 4,
|
||||
players: [],
|
||||
status: 'waiting', statusText: '等待中', stakes: 100, mode: '经典模式',
|
||||
creatorName: '毒牙',
|
||||
},
|
||||
{
|
||||
id: 'snake-005', name: '桌 05', gameIcon: '🐍', gameId: 'snake',
|
||||
maxPlayers: 4,
|
||||
players: [
|
||||
{ avatar: avatarUrl('巨蟒'), nickname: '巨蟒' },
|
||||
{ avatar: avatarUrl('灵蛇'), nickname: '灵蛇' },
|
||||
{ avatar: avatarUrl('毒牙'), nickname: '毒牙' },
|
||||
],
|
||||
status: 'waiting', statusText: '等待中', stakes: 400, mode: '道具模式',
|
||||
creatorName: '巨蟒',
|
||||
},
|
||||
],
|
||||
|
||||
/* ---- 俄罗斯方块 ---- */
|
||||
tetris: [
|
||||
{
|
||||
id: 'tetris-001', name: '桌 01', gameIcon: '🧊', gameId: 'tetris',
|
||||
maxPlayers: 2,
|
||||
players: [
|
||||
{ avatar: avatarUrl('方块大师'), nickname: '方块大师' },
|
||||
],
|
||||
status: 'waiting', statusText: '等待中', stakes: 300, mode: '1v1 对战',
|
||||
creatorName: '方块大师',
|
||||
},
|
||||
{
|
||||
id: 'tetris-002', name: '桌 02', gameIcon: '🧊', gameId: 'tetris',
|
||||
maxPlayers: 2,
|
||||
players: [
|
||||
{ avatar: avatarUrl('极速下落'), nickname: '极速下落' },
|
||||
{ avatar: avatarUrl('消行高手'), nickname: '消行高手' },
|
||||
],
|
||||
status: 'playing', statusText: '进行中', stakes: 500, mode: '竞速模式',
|
||||
creatorName: '极速下落',
|
||||
},
|
||||
{
|
||||
id: 'tetris-003', name: '桌 03', gameIcon: '🧊', gameId: 'tetris',
|
||||
maxPlayers: 2,
|
||||
players: [],
|
||||
status: 'waiting', statusText: '等待中', stakes: 150, mode: '1v1 对战',
|
||||
creatorName: '堆叠王者',
|
||||
},
|
||||
{
|
||||
id: 'tetris-004', name: '桌 04', gameIcon: '🧊', gameId: 'tetris',
|
||||
maxPlayers: 2,
|
||||
players: [
|
||||
{ avatar: avatarUrl('T旋大师'), nickname: 'T旋大师' },
|
||||
],
|
||||
status: 'waiting', statusText: '等待中', stakes: 200, mode: '1v1 对战',
|
||||
creatorName: 'T旋大师',
|
||||
},
|
||||
{
|
||||
id: 'tetris-005', name: '桌 05', gameIcon: '🧊', gameId: 'tetris',
|
||||
maxPlayers: 2,
|
||||
players: [
|
||||
{ avatar: avatarUrl('连击王'), nickname: '连击王' },
|
||||
{ avatar: avatarUrl('消除者'), nickname: '消除者' },
|
||||
],
|
||||
status: 'playing', statusText: '进行中', stakes: 400, mode: '竞速模式',
|
||||
creatorName: '连击王',
|
||||
},
|
||||
],
|
||||
|
||||
/* ---- 五子棋 ---- */
|
||||
gomoku: [
|
||||
{
|
||||
id: 'gomoku-001', name: '桌 01', gameIcon: '🎯', gameId: 'gomoku',
|
||||
maxPlayers: 2,
|
||||
players: [
|
||||
{ avatar: avatarUrl('棋圣'), nickname: '棋圣' },
|
||||
],
|
||||
status: 'waiting', statusText: '等待中', stakes: 200, mode: '无禁手',
|
||||
creatorName: '棋圣',
|
||||
},
|
||||
{
|
||||
id: 'gomoku-002', name: '桌 02', gameIcon: '🎯', gameId: 'gomoku',
|
||||
maxPlayers: 2,
|
||||
players: [
|
||||
{ avatar: avatarUrl('黑白子'), nickname: '黑白子' },
|
||||
{ avatar: avatarUrl('五连珠'), nickname: '五连珠' },
|
||||
],
|
||||
status: 'playing', statusText: '进行中', stakes: 400, mode: '有禁手',
|
||||
creatorName: '黑白子',
|
||||
},
|
||||
{
|
||||
id: 'gomoku-003', name: '桌 03', gameIcon: '🎯', gameId: 'gomoku',
|
||||
maxPlayers: 2,
|
||||
players: [],
|
||||
status: 'waiting', statusText: '等待中', stakes: 100, mode: '无禁手',
|
||||
creatorName: '落子无悔',
|
||||
},
|
||||
{
|
||||
id: 'gomoku-004', name: '桌 04', gameIcon: '🎯', gameId: 'gomoku',
|
||||
maxPlayers: 2,
|
||||
players: [
|
||||
{ avatar: avatarUrl('天元'), nickname: '天元' },
|
||||
],
|
||||
status: 'waiting', statusText: '等待中', stakes: 300, mode: '有禁手',
|
||||
creatorName: '天元',
|
||||
},
|
||||
{
|
||||
id: 'gomoku-005', name: '桌 05', gameIcon: '🎯', gameId: 'gomoku',
|
||||
maxPlayers: 2,
|
||||
players: [
|
||||
{ avatar: avatarUrl('先手必胜'), nickname: '先手必胜' },
|
||||
{ avatar: avatarUrl('后手无敌'), nickname: '后手无敌' },
|
||||
],
|
||||
status: 'playing', statusText: '进行中', stakes: 500, mode: '有禁手',
|
||||
creatorName: '先手必胜',
|
||||
},
|
||||
],
|
||||
|
||||
/* ---- 猜数字 ---- */
|
||||
'guess-number': [
|
||||
{
|
||||
id: 'guess-001', name: '桌 01', gameIcon: '🔢', gameId: 'guess-number',
|
||||
maxPlayers: 4,
|
||||
players: [
|
||||
{ avatar: avatarUrl('逻辑怪'), nickname: '逻辑怪' },
|
||||
{ avatar: avatarUrl('推理王'), nickname: '推理王' },
|
||||
],
|
||||
status: 'waiting', statusText: '等待中', stakes: 200, mode: '经典模式',
|
||||
creatorName: '逻辑怪',
|
||||
},
|
||||
{
|
||||
id: 'guess-002', name: '桌 02', gameIcon: '🔢', gameId: 'guess-number',
|
||||
maxPlayers: 4,
|
||||
players: [
|
||||
{ avatar: avatarUrl('秒猜'), nickname: '秒猜' },
|
||||
{ avatar: avatarUrl('心算'), nickname: '心算' },
|
||||
{ avatar: avatarUrl('直觉'), nickname: '直觉' },
|
||||
],
|
||||
status: 'waiting', statusText: '等待中', stakes: 300, mode: '限时模式',
|
||||
creatorName: '秒猜',
|
||||
},
|
||||
{
|
||||
id: 'guess-003', name: '桌 03', gameIcon: '🔢', gameId: 'guess-number',
|
||||
maxPlayers: 4,
|
||||
players: [
|
||||
{ avatar: avatarUrl('数字猎人'), nickname: '数字猎人' },
|
||||
],
|
||||
status: 'waiting', statusText: '等待中', stakes: 150, mode: '经典模式',
|
||||
creatorName: '数字猎人',
|
||||
},
|
||||
{
|
||||
id: 'guess-004', name: '桌 04', gameIcon: '🔢', gameId: 'guess-number',
|
||||
maxPlayers: 4,
|
||||
players: [
|
||||
{ avatar: avatarUrl('密码破解'), nickname: '密码破解' },
|
||||
{ avatar: avatarUrl('枚举大师'), nickname: '枚举大师' },
|
||||
{ avatar: avatarUrl('二分法'), nickname: '二分法' },
|
||||
{ avatar: avatarUrl('穷举王'), nickname: '穷举王' },
|
||||
],
|
||||
status: 'playing', statusText: '进行中', stakes: 500, mode: '限时模式',
|
||||
creatorName: '密码破解',
|
||||
},
|
||||
{
|
||||
id: 'guess-005', name: '桌 05', gameIcon: '🔢', gameId: 'guess-number',
|
||||
maxPlayers: 4,
|
||||
players: [],
|
||||
status: 'waiting', statusText: '等待中', stakes: 100, mode: '经典模式',
|
||||
creatorName: '神秘数字',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/** 当前游戏的房间列表 */
|
||||
const rooms = computed(() => ALL_ROOMS[activeGameId.value] ?? [])
|
||||
|
||||
/** 筛选后的房间列表 */
|
||||
const filteredRooms = computed(() => {
|
||||
const list = rooms.value
|
||||
if (activeFilter.value === 'all') return list
|
||||
if (activeFilter.value === 'waiting') return list.filter((r) => r.status === 'waiting')
|
||||
if (activeFilter.value === 'playing') return list.filter((r) => r.status === 'playing')
|
||||
return list
|
||||
})
|
||||
|
||||
/** 加入房间 */
|
||||
function handleJoin(roomId: string) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('加入房间:', roomId)
|
||||
}
|
||||
|
||||
/** 加入指定座位 */
|
||||
function handleJoinSeat(roomId: string, seatIndex: number) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('加入房间:', roomId, '座位:', seatIndex)
|
||||
}
|
||||
|
||||
/** 观战 */
|
||||
function handleSpectate(roomId: string) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('观战房间:', roomId)
|
||||
}
|
||||
|
||||
/** 创建房间 */
|
||||
function handleCreateRoom() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('创建房间')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.lobby {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--space-md);
|
||||
gap: var(--space-md);
|
||||
/* 深色底板模拟地板,让白色卡片"浮"在上面 */
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 0%, rgba(79, 70, 229, 0.03) 0%, transparent 60%),
|
||||
linear-gradient(180deg, #e8ecf1 0%, #e2e6ed 100%);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ===== 顶部操作栏 ===== */
|
||||
.lobby__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lobby__filters {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background-color: var(--bg-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.lobby__filter-btn {
|
||||
padding: 6px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-surface-hover);
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: var(--text-inverse);
|
||||
background-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
&:hover {
|
||||
color: var(--text-inverse);
|
||||
background-color: var(--color-primary-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lobby__create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 18px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background-color: var(--color-primary);
|
||||
color: var(--text-inverse);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-hover);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
.lobby__create-icon {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ===== 桌子网格 ===== */
|
||||
.lobby__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(252px, 1fr));
|
||||
gap: var(--space-md);
|
||||
justify-items: center;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
/* ===== 空状态 ===== */
|
||||
.lobby__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.lobby__empty-icon {
|
||||
font-size: 56px;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.lobby__empty-text {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lobby__empty-hint {
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
489
src/components/baseLayouts/TableCard.vue
Normal file
489
src/components/baseLayouts/TableCard.vue
Normal file
@@ -0,0 +1,489 @@
|
||||
<template>
|
||||
<div
|
||||
class="table-card"
|
||||
:class="{
|
||||
'table-card--playing': room.status === 'playing',
|
||||
'table-card--full': room.players.length >= room.maxPlayers
|
||||
}"
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- 桌子+椅子可视区域 -->
|
||||
<div class="table-card__stage">
|
||||
<!-- 椅子:以圆形环绕桌子排布 -->
|
||||
<div
|
||||
v-for="(pos, idx) in chairPositions"
|
||||
:key="idx"
|
||||
class="table-card__chair"
|
||||
:class="{
|
||||
'table-card__chair--occupied': idx < room.players.length,
|
||||
'table-card__chair--empty': idx >= room.players.length
|
||||
}"
|
||||
:style="{ top: pos.top, left: pos.left }"
|
||||
@click.stop="handleChairClick(idx)"
|
||||
>
|
||||
<!-- 有人:头像 + tooltip -->
|
||||
<el-tooltip
|
||||
v-if="room.players[idx]"
|
||||
:content="chairTooltip(idx)"
|
||||
placement="top"
|
||||
:show-after="400"
|
||||
>
|
||||
<div class="table-card__chair-seat">
|
||||
<img
|
||||
:src="room.players[idx].avatar"
|
||||
:alt="room.players[idx].nickname"
|
||||
class="table-card__chair-avatar"
|
||||
/>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
|
||||
<!-- 空椅子 -->
|
||||
<div v-else class="table-card__chair-seat">
|
||||
<span class="table-card__chair-plus"></span>
|
||||
</div>
|
||||
|
||||
<!-- 房主皇冠标识 -->
|
||||
<span v-if="isHost(idx)" class="table-card__chair-crown" title="房主">👑</span>
|
||||
</div>
|
||||
|
||||
<!-- 圆桌 -->
|
||||
<div class="table-card__table">
|
||||
<span class="table-card__table-icon">{{ room.gameIcon }}</span>
|
||||
<span class="table-card__table-name">{{ room.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部信息区 -->
|
||||
<div class="table-card__info">
|
||||
<div class="table-card__info-row">
|
||||
<span class="table-card__player-count">
|
||||
<span class="table-card__dot" :class="statusDotClass"></span>
|
||||
{{ room.players.length }}/{{ room.maxPlayers }}人
|
||||
</span>
|
||||
<span class="table-card__status" :class="statusLabelClass">{{ room.statusText }}</span>
|
||||
</div>
|
||||
<div class="table-card__info-row table-card__info-row--secondary">
|
||||
<span>底注 {{ room.stakes }}</span>
|
||||
<span>{{ room.mode }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
/** 房间内玩家 */
|
||||
interface RoomPlayer {
|
||||
avatar: string
|
||||
nickname: string
|
||||
}
|
||||
|
||||
/** 房间数据 */
|
||||
export interface Room {
|
||||
id: string
|
||||
/** 桌号显示名,如 "桌 01" */
|
||||
name: string
|
||||
/** 游戏图标 emoji */
|
||||
gameIcon: string
|
||||
/** 游戏 ID */
|
||||
gameId: string
|
||||
/** 最大玩家数 */
|
||||
maxPlayers: number
|
||||
/** 已入座玩家列表 */
|
||||
players: RoomPlayer[]
|
||||
/** 房间状态 */
|
||||
status: 'waiting' | 'playing' | 'finished'
|
||||
/** 状态展示文字 */
|
||||
statusText: string
|
||||
/** 底注金额 */
|
||||
stakes: number
|
||||
/** 玩法模式标签 */
|
||||
mode: string
|
||||
/** 房主昵称 */
|
||||
creatorName: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
room: Room
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'join', roomId: string): void
|
||||
(e: 'join-seat', roomId: string, seatIndex: number): void
|
||||
(e: 'spectate', roomId: string): void
|
||||
}>()
|
||||
|
||||
/** 舞台中心坐标(px) */
|
||||
const STAGE_CENTER_X = 124 // 卡片宽度 248px 的一半
|
||||
const STAGE_CENTER_Y = 105 // 舞台高度 210px 的一半
|
||||
/** 椅子环绕半径(px) */
|
||||
const ORBIT_RADIUS = 76
|
||||
|
||||
/**
|
||||
* 按角度环绕计算椅子位置
|
||||
* 角度从顶部(12 点钟方向)开始,顺时针递增
|
||||
* @returns 各椅子 {top, left} 像素值数组
|
||||
*/
|
||||
const chairPositions = computed(() => {
|
||||
const n = props.room.maxPlayers
|
||||
const positions: Array<{ top: string; left: string }> = []
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
// 从顶部开始,顺时针均分
|
||||
const angleDeg = (360 / n) * i
|
||||
const angleRad = (angleDeg * Math.PI) / 180
|
||||
|
||||
const left = STAGE_CENTER_X + ORBIT_RADIUS * Math.sin(angleRad)
|
||||
const top = STAGE_CENTER_Y - ORBIT_RADIUS * Math.cos(angleRad)
|
||||
|
||||
positions.push({
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
})
|
||||
}
|
||||
|
||||
return positions
|
||||
})
|
||||
|
||||
/** 状态指示点样式 */
|
||||
const statusDotClass = computed(() => {
|
||||
switch (props.room.status) {
|
||||
case 'waiting':
|
||||
return 'table-card__dot--waiting'
|
||||
case 'playing':
|
||||
return 'table-card__dot--playing'
|
||||
case 'finished':
|
||||
return 'table-card__dot--finished'
|
||||
default:
|
||||
return 'table-card__dot--waiting'
|
||||
}
|
||||
})
|
||||
|
||||
/** 状态标签样式 */
|
||||
const statusLabelClass = computed(() => {
|
||||
switch (props.room.status) {
|
||||
case 'waiting':
|
||||
return 'table-card__status--waiting'
|
||||
case 'playing':
|
||||
return 'table-card__status--playing'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
/** 点击卡片 → 加入或观战 */
|
||||
function handleClick() {
|
||||
if (props.room.status === 'playing') {
|
||||
emit('spectate', props.room.id)
|
||||
} else {
|
||||
emit('join', props.room.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击空椅子 → 指定座位加入 */
|
||||
function handleChairClick(idx: number) {
|
||||
if (idx >= props.room.players.length && props.room.status === 'waiting') {
|
||||
emit('join-seat', props.room.id, idx)
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断指定座位是否为房主 */
|
||||
function isHost(idx: number): boolean {
|
||||
const player = props.room.players[idx]
|
||||
return !!player && player.nickname === props.room.creatorName
|
||||
}
|
||||
|
||||
/** 椅子 hover 提示文字 */
|
||||
function chairTooltip(idx: number): string {
|
||||
const player = props.room.players[idx]
|
||||
if (!player) return ''
|
||||
return player.nickname === props.room.creatorName
|
||||
? `${player.nickname}(房主)`
|
||||
: player.nickname
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* ===== 卡片容器 ===== */
|
||||
.table-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 248px;
|
||||
background-color: var(--bg-surface);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-default);
|
||||
box-shadow: var(--shadow-sm);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* 进行中的房间 — 暖色左边条 */
|
||||
&--playing {
|
||||
border-left: 3px solid var(--color-warning);
|
||||
}
|
||||
|
||||
/* 满员 — 降低饱和度 */
|
||||
&--full {
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.82;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 舞台区域 ===== */
|
||||
.table-card__stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 210px;
|
||||
flex-shrink: 0;
|
||||
/* 中心微光,突出桌子 */
|
||||
background: radial-gradient(
|
||||
ellipse 65% 55% at 50% 50%,
|
||||
rgba(79, 70, 229, 0.028) 0%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* ===== 椅子(圆形环绕) ===== */
|
||||
.table-card__chair {
|
||||
position: absolute;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.table-card__chair-seat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
/* ---- 空椅子 ---- */
|
||||
.table-card__chair--empty .table-card__chair-seat {
|
||||
border: 2px dashed var(--border-default);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.table-card__chair-plus {
|
||||
/* 使用 CSS 伪元素画十字,保证几何居中 */
|
||||
position: relative;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background-color: var(--text-tertiary);
|
||||
border-radius: 1px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* 横线 */
|
||||
&::before {
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* 竖线 */
|
||||
&::after {
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.table-card__chair--empty:hover .table-card__chair-seat {
|
||||
transform: scale(1.25);
|
||||
border-style: solid;
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--color-primary-light);
|
||||
box-shadow: 0 0 0 5px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.table-card__chair--empty:hover .table-card__chair-plus {
|
||||
&::before,
|
||||
&::after {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 有人椅子 ---- */
|
||||
.table-card__chair--occupied .table-card__chair-seat {
|
||||
border: 2px solid var(--color-primary-light);
|
||||
background-color: var(--bg-surface);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.table-card__chair-avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ---- 房主皇冠 ---- */
|
||||
.table-card__chair-crown {
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
background-color: var(--bg-surface);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===== 圆桌(居中) ===== */
|
||||
.table-card__table {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: 50%;
|
||||
/* 木质桌面 */
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
#e8d5b0 0%,
|
||||
#d4a574 40%,
|
||||
#c49060 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 3px 12px rgba(0, 0, 0, 0.15),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.table-card__table-icon {
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
|
||||
.table-card__table-name {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* ===== 底部信息区 ===== */
|
||||
.table-card__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 14px 12px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.table-card__info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
&--secondary {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.table-card__player-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ---- 状态指示点 ---- */
|
||||
.table-card__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--waiting {
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
|
||||
&--playing {
|
||||
background-color: var(--color-warning);
|
||||
animation: dot-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&--finished {
|
||||
background-color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 状态标签 ---- */
|
||||
.table-card__status {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
|
||||
&--waiting {
|
||||
color: var(--color-success);
|
||||
background-color: var(--color-success-light);
|
||||
}
|
||||
|
||||
&--playing {
|
||||
color: var(--color-warning);
|
||||
background-color: var(--color-warning-light);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dot-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -27,7 +27,9 @@
|
||||
</div>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="base-layout__content"></div>
|
||||
<div class="base-layout__content">
|
||||
<Lobby />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -35,6 +37,7 @@
|
||||
<script setup lang="ts">
|
||||
import UserBar from '@/components/baseLayouts/UserBar.vue'
|
||||
import GameList from '@/components/baseLayouts/GameList.vue'
|
||||
import Lobby from '@/components/baseLayouts/Lobby.vue'
|
||||
import { useBaseLayoutStore } from '@/stores'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
@@ -95,7 +98,8 @@ const { activeGameIcon, activeGameName } = storeToRefs(baseLayoutStore)
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0; /* flex 子元素必须设置,允许收缩到内容以下 */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.base-layout__sidebar {
|
||||
@@ -157,21 +161,11 @@ const { activeGameIcon, activeGameName } = storeToRefs(baseLayoutStore)
|
||||
right: 0;
|
||||
cursor: ew-resize;
|
||||
z-index: 10;
|
||||
/* 默认微弱可见,提示可拖拽 */
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
transparent 40%,
|
||||
var(--border-default) 40%,
|
||||
var(--border-default) 60%,
|
||||
transparent 60%,
|
||||
transparent 100%
|
||||
);
|
||||
background: transparent;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-light);
|
||||
background: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
@@ -183,7 +177,8 @@ const { activeGameIcon, activeGameName } = storeToRefs(baseLayoutStore)
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0; /* flex 子元素必须设置,允许收缩 */
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-body);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,11 +7,11 @@ export const useBaseLayoutStore = defineStore(
|
||||
/** 顶部导航栏标题 */
|
||||
const topbarTitle = ref<string>('')
|
||||
/** 当前选中的游戏 ID */
|
||||
const activeGameId = ref<string>('basketball')
|
||||
const activeGameId = ref<string>('snake')
|
||||
/** 当前选中的游戏图标 */
|
||||
const activeGameIcon = ref<string>('🏀')
|
||||
const activeGameIcon = ref<string>('🐍')
|
||||
/** 当前选中的游戏名称 */
|
||||
const activeGameName = ref<string>('篮球对决')
|
||||
const activeGameName = ref<string>('贪吃蛇')
|
||||
|
||||
/**
|
||||
* 设置顶部导航栏标题
|
||||
|
||||
Reference in New Issue
Block a user