feat(tiles): 实现麻将牌图像系统并优化游戏界面显示

- 重命名 tileMap.ts 为 bottomTileMap.ts 并扩展支持字牌(东南西北、中发白)
- 新增 leftTileMap.ts、rightTileMap.ts 和 topTileMap.ts 支持多位置牌面渲染
- 实现牌面图像类型区分(手牌、明牌、盖牌)和动态图像键构建
- 添加牌面验证函数支持不同花色的数值范围检查
- 更新 ChengduGamePage.vue 使用新的底部牌面配置文件
- 实现玩家手牌可见性控制仅在非等待阶段显示
- 重构服务器响应解析逻辑适配新的数据结构
- 添加玩家手牌响应处理器实时更新手牌状态
- 将玩家手牌显示从文本改为图像展示提升用户体验
- 重构CSS样式实现牌面图像的响应式布局和阴影效果
This commit is contained in:
2026-03-27 15:34:59 +08:00
parent b1e394d675
commit fd8f6d47fa
8 changed files with 1167 additions and 158 deletions

View File

@@ -886,20 +886,25 @@
} }
.tile-chip { .tile-chip {
display: flex;
align-items: flex-end;
justify-content: center;
min-width: 90px; min-width: 90px;
height: 126px; height: 126px;
border: 1px solid rgba(70, 80, 92, 0.18); padding: 0;
border-radius: 8px; border: 0;
color: #14181d; background: transparent;
font-size: 32px; box-shadow: none;
font-weight: 700;
background:
linear-gradient(180deg, #ffffff 0%, #f8fafc 68%, #dfe6ed 100%);
box-shadow:
inset 0 -4px 0 #1ea328,
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 6px 12px rgba(0, 0, 0, 0.18);
cursor: pointer; cursor: pointer;
transition: transform 120ms ease-out;
}
.tile-chip-image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.18));
} }
.tile-chip.selected { .tile-chip.selected {

299
src/config/bottomTileMap.ts Normal file
View File

@@ -0,0 +1,299 @@
// src/config/bottomTileMap.ts
export type Suit = 'W' | 'T' | 'B' | 'F' | 'D'
export interface Tile {
id: number
suit: Suit
value: number
}
/**
* 图片用途:
* - hand: 手牌
* - exposed: 碰/杠/胡等明牌
* - covered: 盖住的牌
*/
export type TileImageType = 'hand' | 'exposed' | 'covered'
export type TilePosition = 'bottom'
/**
* 手牌图索引:
* p4b1_x => 万
* p4b2_x => 筒
* p4b3_x => 条
* p4b4_x => 东南西北中发白
*/
const HAND_SUIT_INDEX_MAP: Record<Suit, 1 | 2 | 3 | 4> = {
W: 1,
T: 2,
B: 3,
F: 4,
D: 4,
}
/**
* 明牌图索引:
* p4s1_x => 万
* p4s2_x => 筒
* p4s3_x => 条
* p4s4_x => 东南西北中发白
*/
const EXPOSED_SUIT_INDEX_MAP: Record<Suit, 1 | 2 | 3 | 4> = {
W: 1,
T: 2,
B: 3,
F: 4,
D: 4,
}
/**
* 字牌 value 映射:
* 风牌 F:
* 1=东 2=南 3=西 4=北
*
* 箭牌 D:
* 1=中 2=发 3=白
*
* 在图片资源中:
* 1=东 2=南 3=西 4=北 5=中 6=发 7=白
*/
function getHonorImageValue(suit: Suit, value: number): number {
if (suit === 'F') {
return value
}
if (suit === 'D') {
return value + 4
}
return value
}
/**
* 构建手牌图片 key
* 例如:
* /src/assets/images/tiles/bottom/p4b1_1.png
* /src/assets/images/tiles/bottom/p4b4_5.png
*/
function buildHandTileImageKey(
suit: Suit,
value: number,
position: TilePosition = 'bottom',
): string {
const suitIndex = HAND_SUIT_INDEX_MAP[suit]
const imageValue = suit === 'F' || suit === 'D'
? getHonorImageValue(suit, value)
: value
return `/src/assets/images/tiles/${position}/p4b${suitIndex}_${imageValue}.png`
}
/**
* 构建明牌图片 key碰/杠/胡漏出的牌)
* 例如:
* /src/assets/images/tiles/bottom/p4s1_1.png
* /src/assets/images/tiles/bottom/p4s4_5.png
*/
function buildExposedTileImageKey(
suit: Suit,
value: number,
position: TilePosition = 'bottom',
): string {
const suitIndex = EXPOSED_SUIT_INDEX_MAP[suit]
const imageValue = suit === 'F' || suit === 'D'
? getHonorImageValue(suit, value)
: value
return `/src/assets/images/tiles/${position}/p4s${suitIndex}_${imageValue}.png`
}
/**
* 构建盖牌图片 key
*/
function buildCoveredTileImageKey(position: TilePosition = 'bottom'): string {
return `/src/assets/images/tiles/${position}/tdbgs_4.png`
}
/**
* 通过 Vite 收集所有麻将牌资源
*/
const tileImageModules = import.meta.glob(
'/src/assets/images/tiles/bottom/*.png',
{
eager: true,
import: 'default',
},
) as Record<string, string>
/**
* 判断是否为合法花色
*/
export function isValidSuit(suit: string): suit is Suit {
return suit === 'W' || suit === 'T' || suit === 'B' || suit === 'F' || suit === 'D'
}
/**
* 判断是否为合法点数
* W/T/B => 1~9
* F => 1~4
* D => 1~3
*/
export function isValidTileValueBySuit(suit: Suit, value: number): boolean {
if (!Number.isInteger(value)) {
return false
}
switch (suit) {
case 'W':
case 'T':
case 'B':
return value >= 1 && value <= 9
case 'F':
return value >= 1 && value <= 4
case 'D':
return value >= 1 && value <= 3
default:
return false
}
}
/**
* 判断是否为合法牌
*/
export function isValidTile(tile: { suit: string; value: number }): tile is Pick<Tile, 'suit' | 'value'> {
if (!isValidSuit(tile.suit)) {
return false
}
return isValidTileValueBySuit(tile.suit, tile.value)
}
/**
* 获取手牌图片
*/
export function getHandTileImage(
tile: Pick<Tile, 'suit' | 'value'>,
position: TilePosition = 'bottom',
): string {
if (!isValidTile(tile)) {
return ''
}
const key = buildHandTileImageKey(tile.suit, tile.value, position)
return tileImageModules[key] || ''
}
/**
* 获取碰/杠/胡漏出的明牌图片
*/
export function getExposedTileImage(
tile: Pick<Tile, 'suit' | 'value'>,
position: TilePosition = 'bottom',
): string {
if (!isValidTile(tile)) {
return ''
}
const key = buildExposedTileImageKey(tile.suit, tile.value, position)
return tileImageModules[key] || ''
}
/**
* 获取盖住的牌图片
*/
export function getCoveredTileImage(position: TilePosition = 'bottom'): string {
const key = buildCoveredTileImageKey(position)
return tileImageModules[key] || ''
}
/**
* 统一获取牌图片
*/
export function getTileImage(
tile: Pick<Tile, 'suit' | 'value'>,
imageType: TileImageType = 'hand',
position: TilePosition = 'bottom',
): string {
if (imageType === 'covered') {
return getCoveredTileImage(position)
}
if (!isValidTile(tile)) {
return ''
}
if (imageType === 'exposed') {
return getExposedTileImage(tile, position)
}
return getHandTileImage(tile, position)
}
/**
* 获取所有基础牌(不含重复)
* 包含:
* - 万 1~9
* - 筒 1~9
* - 条 1~9
* - 东南西北
* - 中发白
*/
export function getAllTiles(): Array<Pick<Tile, 'suit' | 'value'>> {
const result: Array<Pick<Tile, 'suit' | 'value'>> = []
// 万筒条
const numberSuits: Array<'W' | 'T' | 'B'> = ['W', 'T', 'B']
for (const suit of numberSuits) {
for (let value = 1; value <= 9; value++) {
result.push({ suit, value })
}
}
// 东南西北
for (let value = 1; value <= 4; value++) {
result.push({ suit: 'F', value })
}
// 中发白
for (let value = 1; value <= 3; value++) {
result.push({ suit: 'D', value })
}
return result
}
/**
* 获取牌的中文名称
*/
export function getTileLabel(tile: Pick<Tile, 'suit' | 'value'>): string {
if (!isValidTile(tile)) {
return ''
}
switch (tile.suit) {
case 'W':
return `${tile.value}`
case 'T':
return `${tile.value}`
case 'B':
return `${tile.value}`
case 'F': {
const map: Record<number, string> = {
1: '东',
2: '南',
3: '西',
4: '北',
}
return map[tile.value] || ''
}
case 'D': {
const map: Record<number, string> = {
1: '中',
2: '发',
3: '白',
}
return map[tile.value] || ''
}
default:
return ''
}
}

251
src/config/leftTileMap.ts Normal file
View File

@@ -0,0 +1,251 @@
// src/config/leftTileMap.ts
export type Suit = 'W' | 'T' | 'B' | 'F' | 'D'
export interface Tile {
id: number
suit: Suit
value: number
}
/**
* 图片用途:
* - hand: 左侧手牌背面
* - exposed: 左侧碰/杠/胡等明牌
* - covered: 左侧盖住的牌
*/
export type TileImageType = 'hand' | 'exposed' | 'covered'
export type TilePosition = 'left'
/**
* 明牌图索引:
* p3s1_x => 万
* p3s2_x => 筒
* p3s3_x => 条
* p3s4_x => 东南西北中发白
*/
const EXPOSED_SUIT_INDEX_MAP: Record<Suit, 1 | 2 | 3 | 4> = {
W: 1,
T: 2,
B: 3,
F: 4,
D: 4,
}
/**
* 字牌 value 映射:
* F:
* 1=东 2=南 3=西 4=北
*
* D:
* 1=中 2=发 3=白
*
* 图片资源中:
* 1=东 2=南 3=西 4=北 5=中 6=发 7=白
*/
function getHonorImageValue(suit: Suit, value: number): number {
if (suit === 'F') return value
if (suit === 'D') return value + 4
return value
}
/**
* 构建左侧明牌图片 key
* 例如:
* /src/assets/images/tiles/left/p3s1_1.png
* /src/assets/images/tiles/left/p3s4_5.png
*/
function buildExposedTileImageKey(
suit: Suit,
value: number,
position: TilePosition = 'left',
): string {
const suitIndex = EXPOSED_SUIT_INDEX_MAP[suit]
const imageValue =
suit === 'F' || suit === 'D' ? getHonorImageValue(suit, value) : value
return `/src/assets/images/tiles/${position}/p3s${suitIndex}_${imageValue}.png`
}
/**
* 构建左侧手牌背面图片 key
*/
function buildHandTileImageKey(position: TilePosition = 'left'): string {
return `/src/assets/images/tiles/${position}/tbgs_3.png`
}
/**
* 构建左侧盖牌图片 key
*/
function buildCoveredTileImageKey(position: TilePosition = 'left'): string {
return `/src/assets/images/tiles/${position}/tdbgs_3.png`
}
/**
* 通过 Vite 收集左侧麻将牌资源
*/
const tileImageModules = import.meta.glob('/src/assets/images/tiles/left/*.png', {
eager: true,
import: 'default',
}) as Record<string, string>
/**
* 判断是否为合法花色
*/
export function isValidSuit(suit: string): suit is Suit {
return suit === 'W' || suit === 'T' || suit === 'B' || suit === 'F' || suit === 'D'
}
/**
* 判断是否为合法点数
* W/T/B => 1~9
* F => 1~4
* D => 1~3
*/
export function isValidTileValueBySuit(suit: Suit, value: number): boolean {
if (!Number.isInteger(value)) {
return false
}
switch (suit) {
case 'W':
case 'T':
case 'B':
return value >= 1 && value <= 9
case 'F':
return value >= 1 && value <= 4
case 'D':
return value >= 1 && value <= 3
default:
return false
}
}
/**
* 判断是否为合法牌
*/
export function isValidTile(tile: {
suit: string
value: number
}): tile is Pick<Tile, 'suit' | 'value'> {
if (!isValidSuit(tile.suit)) {
return false
}
return isValidTileValueBySuit(tile.suit, tile.value)
}
/**
* 获取左侧手牌背面图
*/
export function getHandTileImage(position: TilePosition = 'left'): string {
const key = buildHandTileImageKey(position)
return tileImageModules[key] || ''
}
/**
* 获取左侧碰/杠/胡等明牌图片
*/
export function getExposedTileImage(
tile: Pick<Tile, 'suit' | 'value'>,
position: TilePosition = 'left',
): string {
if (!isValidTile(tile)) {
return ''
}
const key = buildExposedTileImageKey(tile.suit, tile.value, position)
return tileImageModules[key] || ''
}
/**
* 获取左侧盖牌图片
*/
export function getCoveredTileImage(position: TilePosition = 'left'): string {
const key = buildCoveredTileImageKey(position)
return tileImageModules[key] || ''
}
/**
* 统一获取左侧牌图片
*/
export function getTileImage(
tile?: Pick<Tile, 'suit' | 'value'>,
imageType: TileImageType = 'hand',
position: TilePosition = 'left',
): string {
if (imageType === 'hand') {
return getHandTileImage(position)
}
if (imageType === 'covered') {
return getCoveredTileImage(position)
}
if (!tile || !isValidTile(tile)) {
return ''
}
return getExposedTileImage(tile, position)
}
/**
* 获取所有基础牌(不含重复)
*/
export function getAllTiles(): Array<Pick<Tile, 'suit' | 'value'>> {
const result: Array<Pick<Tile, 'suit' | 'value'>> = []
const numberSuits: Array<'W' | 'T' | 'B'> = ['W', 'T', 'B']
for (const suit of numberSuits) {
for (let value = 1; value <= 9; value++) {
result.push({ suit, value })
}
}
for (let value = 1; value <= 4; value++) {
result.push({ suit: 'F', value })
}
for (let value = 1; value <= 3; value++) {
result.push({ suit: 'D', value })
}
return result
}
/**
* 获取牌的中文名称
*/
export function getTileLabel(tile: Pick<Tile, 'suit' | 'value'>): string {
if (!isValidTile(tile)) {
return ''
}
switch (tile.suit) {
case 'W':
return `${tile.value}`
case 'T':
return `${tile.value}`
case 'B':
return `${tile.value}`
case 'F': {
const map: Record<number, string> = {
1: '东',
2: '南',
3: '西',
4: '北',
}
return map[tile.value] || ''
}
case 'D': {
const map: Record<number, string> = {
1: '中',
2: '发',
3: '白',
}
return map[tile.value] || ''
}
default:
return ''
}
}

251
src/config/rightTileMap.ts Normal file
View File

@@ -0,0 +1,251 @@
// src/config/rightTileMap.ts
export type Suit = 'W' | 'T' | 'B' | 'F' | 'D'
export interface Tile {
id: number
suit: Suit
value: number
}
/**
* 图片用途:
* - hand: 右侧手牌背面
* - exposed: 右侧碰/杠/胡等明牌
* - covered: 右侧盖住的牌
*/
export type TileImageType = 'hand' | 'exposed' | 'covered'
export type TilePosition = 'right'
/**
* 明牌图索引:
* p1s1_x => 万
* p1s2_x => 筒
* p1s3_x => 条
* p1s4_x => 东南西北中发白
*/
const EXPOSED_SUIT_INDEX_MAP: Record<Suit, 1 | 2 | 3 | 4> = {
W: 1,
T: 2,
B: 3,
F: 4,
D: 4,
}
/**
* 字牌 value 映射:
* F:
* 1=东 2=南 3=西 4=北
*
* D:
* 1=中 2=发 3=白
*
* 图片资源中:
* 1=东 2=南 3=西 4=北 5=中 6=发 7=白
*/
function getHonorImageValue(suit: Suit, value: number): number {
if (suit === 'F') return value
if (suit === 'D') return value + 4
return value
}
/**
* 构建右侧明牌图片 key
* 例如:
* /src/assets/images/tiles/right/p1s1_1.png
* /src/assets/images/tiles/right/p1s4_5.png
*/
function buildExposedTileImageKey(
suit: Suit,
value: number,
position: TilePosition = 'right',
): string {
const suitIndex = EXPOSED_SUIT_INDEX_MAP[suit]
const imageValue =
suit === 'F' || suit === 'D' ? getHonorImageValue(suit, value) : value
return `/src/assets/images/tiles/${position}/p1s${suitIndex}_${imageValue}.png`
}
/**
* 构建右侧手牌背面图片 key
*/
function buildHandTileImageKey(position: TilePosition = 'right'): string {
return `/src/assets/images/tiles/${position}/tbgs_1.png`
}
/**
* 构建右侧盖牌图片 key
*/
function buildCoveredTileImageKey(position: TilePosition = 'right'): string {
return `/src/assets/images/tiles/${position}/tdbgs_1.png`
}
/**
* 通过 Vite 收集右侧麻将牌资源
*/
const tileImageModules = import.meta.glob('/src/assets/images/tiles/right/*.png', {
eager: true,
import: 'default',
}) as Record<string, string>
/**
* 判断是否为合法花色
*/
export function isValidSuit(suit: string): suit is Suit {
return suit === 'W' || suit === 'T' || suit === 'B' || suit === 'F' || suit === 'D'
}
/**
* 判断是否为合法点数
* W/T/B => 1~9
* F => 1~4
* D => 1~3
*/
export function isValidTileValueBySuit(suit: Suit, value: number): boolean {
if (!Number.isInteger(value)) {
return false
}
switch (suit) {
case 'W':
case 'T':
case 'B':
return value >= 1 && value <= 9
case 'F':
return value >= 1 && value <= 4
case 'D':
return value >= 1 && value <= 3
default:
return false
}
}
/**
* 判断是否为合法牌
*/
export function isValidTile(tile: {
suit: string
value: number
}): tile is Pick<Tile, 'suit' | 'value'> {
if (!isValidSuit(tile.suit)) {
return false
}
return isValidTileValueBySuit(tile.suit, tile.value)
}
/**
* 获取右侧手牌背面图
*/
export function getHandTileImage(position: TilePosition = 'right'): string {
const key = buildHandTileImageKey(position)
return tileImageModules[key] || ''
}
/**
* 获取右侧碰/杠/胡等明牌图片
*/
export function getExposedTileImage(
tile: Pick<Tile, 'suit' | 'value'>,
position: TilePosition = 'right',
): string {
if (!isValidTile(tile)) {
return ''
}
const key = buildExposedTileImageKey(tile.suit, tile.value, position)
return tileImageModules[key] || ''
}
/**
* 获取右侧盖牌图片
*/
export function getCoveredTileImage(position: TilePosition = 'right'): string {
const key = buildCoveredTileImageKey(position)
return tileImageModules[key] || ''
}
/**
* 统一获取右侧牌图片
*/
export function getTileImage(
tile?: Pick<Tile, 'suit' | 'value'>,
imageType: TileImageType = 'hand',
position: TilePosition = 'right',
): string {
if (imageType === 'hand') {
return getHandTileImage(position)
}
if (imageType === 'covered') {
return getCoveredTileImage(position)
}
if (!tile || !isValidTile(tile)) {
return ''
}
return getExposedTileImage(tile, position)
}
/**
* 获取所有基础牌(不含重复)
*/
export function getAllTiles(): Array<Pick<Tile, 'suit' | 'value'>> {
const result: Array<Pick<Tile, 'suit' | 'value'>> = []
const numberSuits: Array<'W' | 'T' | 'B'> = ['W', 'T', 'B']
for (const suit of numberSuits) {
for (let value = 1; value <= 9; value++) {
result.push({ suit, value })
}
}
for (let value = 1; value <= 4; value++) {
result.push({ suit: 'F', value })
}
for (let value = 1; value <= 3; value++) {
result.push({ suit: 'D', value })
}
return result
}
/**
* 获取牌的中文名称
*/
export function getTileLabel(tile: Pick<Tile, 'suit' | 'value'>): string {
if (!isValidTile(tile)) {
return ''
}
switch (tile.suit) {
case 'W':
return `${tile.value}`
case 'T':
return `${tile.value}`
case 'B':
return `${tile.value}`
case 'F': {
const map: Record<number, string> = {
1: '东',
2: '南',
3: '西',
4: '北',
}
return map[tile.value] || ''
}
case 'D': {
const map: Record<number, string> = {
1: '中',
2: '发',
3: '白',
}
return map[tile.value] || ''
}
default:
return ''
}
}

View File

@@ -1,111 +0,0 @@
// src/config/tileMap.ts
export type Suit = 'W' | 'T' | 'B'
export interface Tile {
id: number
suit: Suit
value: number
}
export type TilePosition = 'bottom'
const SUIT_INDEX_MAP: Record<Suit, 1 | 2 | 3> = {
W: 1, // 万
T: 2, // 筒
B: 3, // 条
}
/**
* 当前目录结构:
* /src/assets/images/tiles/bottom/p4b1_1.png
* /src/assets/images/tiles/bottom/p4b2_1.png
* /src/assets/images/tiles/bottom/p4b3_1.png
*/
function buildTileImageKey(
suit: Suit,
value: number,
position: TilePosition = 'bottom',
): string {
const suitIndex = SUIT_INDEX_MAP[suit]
return `/src/assets/images/tiles/${position}/p4b${suitIndex}_${value}.png`
}
/**
* 通过 Vite 收集所有麻将牌资源
*/
const tileImageModules = import.meta.glob(
'/src/assets/images/tiles/bottom/*.png',
{
eager: true,
import: 'default',
},
) as Record<string, string>
/**
* 判断是否为合法花色
*/
export function isValidSuit(suit: string): suit is Suit {
return suit === 'W' || suit === 'T' || suit === 'B'
}
/**
* 判断是否为合法点数
*/
export function isValidTileValue(value: number): boolean {
return Number.isInteger(value) && value >= 1 && value <= 9
}
/**
* 判断是否为合法牌
*/
export function isValidTile(tile: { suit: string; value: number }): tile is Pick<Tile, 'suit' | 'value'> {
return isValidSuit(tile.suit) && isValidTileValue(tile.value)
}
/**
* 根据花色 + 点数获取图片路径
*/
export function getTileImageBySuitAndValue(
suit: Suit,
value: number,
position: TilePosition = 'bottom',
): string {
if (!isValidTileValue(value)) {
return ''
}
const key = buildTileImageKey(suit, value, position)
return tileImageModules[key] || ''
}
/**
* 根据 Tile 获取图片路径
*/
export function getTileImage(
tile: Pick<Tile, 'suit' | 'value'>,
position: TilePosition = 'bottom',
): string {
if (!isValidTile(tile)) {
return ''
}
const key = buildTileImageKey(tile.suit, tile.value, position)
return tileImageModules[key] || ''
}
/**
* 获取全部基础牌
*/
export function getAllTiles(): Array<Pick<Tile, 'suit' | 'value'>> {
const suits: Suit[] = ['W', 'T', 'B']
const result: Array<Pick<Tile, 'suit' | 'value'>> = []
for (const suit of suits) {
for (let value = 1; value <= 9; value++) {
result.push({suit, value})
}
}
return result
}

251
src/config/topTileMap.ts Normal file
View File

@@ -0,0 +1,251 @@
// src/config/topTileMap.ts
export type Suit = 'W' | 'T' | 'B' | 'F' | 'D'
export interface Tile {
id: number
suit: Suit
value: number
}
/**
* 图片用途:
* - hand: 上方手牌背面
* - exposed: 上方碰/杠/胡等明牌
* - covered: 上方盖住的牌
*/
export type TileImageType = 'hand' | 'exposed' | 'covered'
export type TilePosition = 'top'
/**
* 明牌图索引:
* p2s1_x => 万
* p2s2_x => 筒
* p2s3_x => 条
* p2s4_x => 东南西北中发白
*/
const EXPOSED_SUIT_INDEX_MAP: Record<Suit, 1 | 2 | 3 | 4> = {
W: 1,
T: 2,
B: 3,
F: 4,
D: 4,
}
/**
* 字牌 value 映射:
* F:
* 1=东 2=南 3=西 4=北
*
* D:
* 1=中 2=发 3=白
*
* 图片资源中:
* 1=东 2=南 3=西 4=北 5=中 6=发 7=白
*/
function getHonorImageValue(suit: Suit, value: number): number {
if (suit === 'F') return value
if (suit === 'D') return value + 4
return value
}
/**
* 构建上方明牌图片 key
* 例如:
* /src/assets/images/tiles/top/p2s1_1.png
* /src/assets/images/tiles/top/p2s4_5.png
*/
function buildExposedTileImageKey(
suit: Suit,
value: number,
position: TilePosition = 'top',
): string {
const suitIndex = EXPOSED_SUIT_INDEX_MAP[suit]
const imageValue =
suit === 'F' || suit === 'D' ? getHonorImageValue(suit, value) : value
return `/src/assets/images/tiles/${position}/p2s${suitIndex}_${imageValue}.png`
}
/**
* 构建上方手牌背面图片 key
*/
function buildHandTileImageKey(position: TilePosition = 'top'): string {
return `/src/assets/images/tiles/${position}/tbgs_2.png`
}
/**
* 构建上方盖牌图片 key
*/
function buildCoveredTileImageKey(position: TilePosition = 'top'): string {
return `/src/assets/images/tiles/${position}/tdbgs_2.png`
}
/**
* 通过 Vite 收集上方麻将牌资源
*/
const tileImageModules = import.meta.glob('/src/assets/images/tiles/top/*.png', {
eager: true,
import: 'default',
}) as Record<string, string>
/**
* 判断是否为合法花色
*/
export function isValidSuit(suit: string): suit is Suit {
return suit === 'W' || suit === 'T' || suit === 'B' || suit === 'F' || suit === 'D'
}
/**
* 判断是否为合法点数
* W/T/B => 1~9
* F => 1~4
* D => 1~3
*/
export function isValidTileValueBySuit(suit: Suit, value: number): boolean {
if (!Number.isInteger(value)) {
return false
}
switch (suit) {
case 'W':
case 'T':
case 'B':
return value >= 1 && value <= 9
case 'F':
return value >= 1 && value <= 4
case 'D':
return value >= 1 && value <= 3
default:
return false
}
}
/**
* 判断是否为合法牌
*/
export function isValidTile(tile: {
suit: string
value: number
}): tile is Pick<Tile, 'suit' | 'value'> {
if (!isValidSuit(tile.suit)) {
return false
}
return isValidTileValueBySuit(tile.suit, tile.value)
}
/**
* 获取上方手牌背面图
*/
export function getHandTileImage(position: TilePosition = 'top'): string {
const key = buildHandTileImageKey(position)
return tileImageModules[key] || ''
}
/**
* 获取上方碰/杠/胡等明牌图片
*/
export function getExposedTileImage(
tile: Pick<Tile, 'suit' | 'value'>,
position: TilePosition = 'top',
): string {
if (!isValidTile(tile)) {
return ''
}
const key = buildExposedTileImageKey(tile.suit, tile.value, position)
return tileImageModules[key] || ''
}
/**
* 获取上方盖牌图片
*/
export function getCoveredTileImage(position: TilePosition = 'top'): string {
const key = buildCoveredTileImageKey(position)
return tileImageModules[key] || ''
}
/**
* 统一获取上方牌图片
*/
export function getTileImage(
tile?: Pick<Tile, 'suit' | 'value'>,
imageType: TileImageType = 'hand',
position: TilePosition = 'top',
): string {
if (imageType === 'hand') {
return getHandTileImage(position)
}
if (imageType === 'covered') {
return getCoveredTileImage(position)
}
if (!tile || !isValidTile(tile)) {
return ''
}
return getExposedTileImage(tile, position)
}
/**
* 获取所有基础牌(不含重复)
*/
export function getAllTiles(): Array<Pick<Tile, 'suit' | 'value'>> {
const result: Array<Pick<Tile, 'suit' | 'value'>> = []
const numberSuits: Array<'W' | 'T' | 'B'> = ['W', 'T', 'B']
for (const suit of numberSuits) {
for (let value = 1; value <= 9; value++) {
result.push({ suit, value })
}
}
for (let value = 1; value <= 4; value++) {
result.push({ suit: 'F', value })
}
for (let value = 1; value <= 3; value++) {
result.push({ suit: 'D', value })
}
return result
}
/**
* 获取牌的中文名称
*/
export function getTileLabel(tile: Pick<Tile, 'suit' | 'value'>): string {
if (!isValidTile(tile)) {
return ''
}
switch (tile.suit) {
case 'W':
return `${tile.value}`
case 'T':
return `${tile.value}`
case 'B':
return `${tile.value}`
case 'F': {
const map: Record<number, string> = {
1: '东',
2: '南',
3: '西',
4: '北',
}
return map[tile.value] || ''
}
case 'D': {
const map: Record<number, string> = {
1: '中',
2: '发',
3: '白',
}
return map[tile.value] || ''
}
default:
return ''
}
}

0
src/game/events.ts Normal file
View File

View File

@@ -37,7 +37,7 @@ import {useGameStore} from '../store/gameStore'
import {setActiveRoom, useActiveRoomState} from '../store' import {setActiveRoom, useActiveRoomState} from '../store'
import type {PlayerState} from '../types/state' import type {PlayerState} from '../types/state'
import type {Tile} from '../types/tile' import type {Tile} from '../types/tile'
import {getTileImage} from "../config/tileMap.ts"; import {getTileImage} from "../config/bottomTileMap.ts";
const gameStore = useGameStore() const gameStore = useGameStore()
const activeRoom = useActiveRoomState() const activeRoom = useActiveRoomState()
@@ -132,6 +132,14 @@ const myHandTiles = computed(() => {
return myPlayer.value?.handTiles ?? [] return myPlayer.value?.handTiles ?? []
}) })
const visibleHandTiles = computed(() => {
if (gameStore.phase === 'waiting') {
return []
}
return myHandTiles.value
})
const remainingTiles = computed(() => { const remainingTiles = computed(() => {
return gameStore.remainingTiles return gameStore.remainingTiles
}) })
@@ -449,22 +457,21 @@ function handleRoomInfoResponse(message: unknown): void {
} }
const payload = asRecord(source.payload) ?? source const payload = asRecord(source.payload) ?? source
const summary = asRecord(payload.summary) ?? asRecord(payload.room_summary) ?? null const room = asRecord(payload.room)
const publicState = asRecord(payload.public) ?? asRecord(payload.public_state) ?? null const gameState = asRecord(payload.game_state)
const privateState = asRecord(payload.private) ?? asRecord(payload.private_state) ?? null const playerView = asRecord(payload.player_view)
console.log("server response payload: " + payload)
const roomId = const roomId =
readString(summary ?? {}, 'room_id', 'roomId') || readString(room ?? {}, 'room_id', 'roomId') ||
readString(publicState ?? {}, 'room_id', 'roomId') || readString(gameState ?? {}, 'room_id', 'roomId') ||
readString(privateState ?? {}, 'room_id', 'roomId') || readString(playerView ?? {}, 'room_id', 'roomId') ||
readString(payload, 'room_id', 'roomId') || readString(payload, 'room_id', 'roomId') ||
readString(source, 'roomId') readString(source, 'roomId')
if (!roomId) { if (!roomId) {
return return
} }
const summaryPlayers = Array.isArray(summary?.players) ? summary.players : [] const roomPlayers = Array.isArray(room?.players) ? room.players : []
const publicPlayers = Array.isArray(publicState?.players) ? publicState.players : [] const gamePlayers = Array.isArray(gameState?.players) ? gameState.players : []
const playerMap = new Map<string, { const playerMap = new Map<string, {
roomPlayer: { roomPlayer: {
index: number index: number
@@ -491,7 +498,7 @@ function handleRoomInfoResponse(message: unknown): void {
} }
}>() }>()
summaryPlayers.forEach((item, fallbackIndex) => { roomPlayers.forEach((item, fallbackIndex) => {
const player = asRecord(item) const player = asRecord(item)
if (!player) { if (!player) {
return return
@@ -536,7 +543,7 @@ function handleRoomInfoResponse(message: unknown): void {
}) })
}) })
publicPlayers.forEach((item, fallbackIndex) => { gamePlayers.forEach((item, fallbackIndex) => {
const player = asRecord(item) const player = asRecord(item)
if (!player) { if (!player) {
return return
@@ -584,7 +591,7 @@ function handleRoomInfoResponse(message: unknown): void {
}) })
}) })
const privateHand = normalizeTiles(privateState?.hand) const privateHand = normalizeTiles(playerView?.hand)
if (loggedInUserId.value && playerMap.has(loggedInUserId.value)) { if (loggedInUserId.value && playerMap.has(loggedInUserId.value)) {
const current = playerMap.get(loggedInUserId.value) const current = playerMap.get(loggedInUserId.value)
if (current) { if (current) {
@@ -599,8 +606,8 @@ function handleRoomInfoResponse(message: unknown): void {
const nextPlayers: typeof gameStore.players = {} const nextPlayers: typeof gameStore.players = {}
players.forEach(({gamePlayer}) => { players.forEach(({gamePlayer}) => {
const previous = previousPlayers[gamePlayer.playerId] const previous = previousPlayers[gamePlayer.playerId]
const score = (publicState?.scores && typeof publicState.scores === 'object' const score = (gameState?.scores && typeof gameState.scores === 'object'
? (publicState.scores as Record<string, unknown>)[gamePlayer.playerId] ? (gameState.scores as Record<string, unknown>)[gamePlayer.playerId]
: undefined) : undefined)
nextPlayers[gamePlayer.playerId] = { nextPlayers[gamePlayer.playerId] = {
playerId: gamePlayer.playerId, playerId: gamePlayer.playerId,
@@ -617,18 +624,18 @@ function handleRoomInfoResponse(message: unknown): void {
}) })
const status = const status =
readString(publicState ?? {}, 'status') || readString(gameState ?? {}, 'status') ||
readString(summary ?? {}, 'status') || readString(room ?? {}, 'status') ||
readString(publicState ?? {}, 'phase') || readString(gameState ?? {}, 'phase') ||
'waiting' 'waiting'
const phase = const phase =
readString(publicState ?? {}, 'phase') || readString(gameState ?? {}, 'phase') ||
readString(summary ?? {}, 'status') || readString(room ?? {}, 'status') ||
'waiting' 'waiting'
const wallCount = readNumber(publicState ?? {}, 'wall_count', 'wallCount') const wallCount = readNumber(gameState ?? {}, 'wall_count', 'wallCount')
const dealerIndex = readNumber(publicState ?? {}, 'dealer_index', 'dealerIndex') const dealerIndex = readNumber(gameState ?? {}, 'dealer_index', 'dealerIndex')
const currentTurnSeat = readNumber(publicState ?? {}, 'current_turn', 'currentTurn') const currentTurnSeat = readNumber(gameState ?? {}, 'current_turn', 'currentTurn')
const currentTurnPlayerId = readString(publicState ?? {}, 'current_turn_player', 'currentTurnPlayer') const currentTurnPlayerId = readString(gameState ?? {}, 'current_turn_player', 'currentTurnPlayer')
const currentTurn = const currentTurn =
currentTurnSeat ?? currentTurnSeat ??
(currentTurnPlayerId && nextPlayers[currentTurnPlayerId] (currentTurnPlayerId && nextPlayers[currentTurnPlayerId]
@@ -658,24 +665,24 @@ function handleRoomInfoResponse(message: unknown): void {
if (typeof currentTurn === 'number') { if (typeof currentTurn === 'number') {
gameStore.currentTurn = currentTurn gameStore.currentTurn = currentTurn
} }
const scores = asRecord(publicState?.scores) const scores = asRecord(gameState?.scores)
if (scores) { if (scores) {
gameStore.scores = Object.fromEntries( gameStore.scores = Object.fromEntries(
Object.entries(scores).filter(([, value]) => typeof value === 'number'), Object.entries(scores).filter(([, value]) => typeof value === 'number'),
) as Record<string, number> ) as Record<string, number>
} }
gameStore.winners = readStringArray(publicState ?? {}, 'winners') gameStore.winners = readStringArray(gameState ?? {}, 'winners')
setActiveRoom({ setActiveRoom({
roomId, roomId,
roomName: readString(summary ?? {}, 'name', 'room_name', 'roomName') || activeRoom.value?.roomName || roomName.value, roomName: readString(room ?? {}, 'name', 'room_name') || activeRoom.value?.roomName || roomName.value,
gameType: readString(summary ?? {}, 'game_type', 'gameType') || activeRoom.value?.gameType || 'chengdu', gameType: readString(room ?? {}, 'game_type') || activeRoom.value?.gameType || 'chengdu',
ownerId: readString(summary ?? {}, 'owner_id', 'ownerId') || activeRoom.value?.ownerId || '', ownerId: readString(room ?? {}, 'owner_id') || activeRoom.value?.ownerId || '',
maxPlayers: readNumber(summary ?? {}, 'max_players', 'maxPlayers') ?? activeRoom.value?.maxPlayers ?? 4, maxPlayers: readNumber(room ?? {}, 'max_players') ?? activeRoom.value?.maxPlayers ?? 4,
playerCount: readNumber(summary ?? {}, 'player_count', 'playerCount') ?? players.length, playerCount: readNumber(room ?? {}, 'player_count') ?? players.length,
status, status,
createdAt: readString(summary ?? {}, 'created_at', 'createdAt') || activeRoom.value?.createdAt || '', createdAt: readString(room ?? {}, 'created_at') || activeRoom.value?.createdAt || '',
updatedAt: readString(summary ?? {}, 'updated_at', 'updatedAt') || activeRoom.value?.updatedAt || '', updatedAt: readString(room ?? {}, 'updated_at') || activeRoom.value?.updatedAt || '',
players: players.map((item) => item.roomPlayer), players: players.map((item) => item.roomPlayer),
myHand: privateHand.map((tile) => tileToText(tile)), myHand: privateHand.map((tile) => tileToText(tile)),
game: { game: {
@@ -712,6 +719,15 @@ const formattedClock = computed(() => {
}) })
const wallBacks = computed<Record<SeatKey, string[]>>(() => { const wallBacks = computed<Record<SeatKey, string[]>>(() => {
if (gameStore.phase === 'waiting' || remainingTiles.value <= 0) {
return {
top: [],
right: [],
bottom: [],
left: [],
}
}
const wallSize = remainingTiles.value const wallSize = remainingTiles.value
const perSide = Math.max(6, Math.ceil((wallSize || 48) / 4 / 2)) const perSide = Math.max(6, Math.ceil((wallSize || 48) / 4 / 2))
@@ -846,6 +862,48 @@ function formatTile(tile: Tile): string {
return `${tile.suit}${tile.value}` return `${tile.suit}${tile.value}`
} }
function handlePlayerHandResponse(message: unknown): void {
const source = asRecord(message)
if (!source || typeof source.type !== 'string') {
return
}
if (normalizeWsType(source.type) !== 'PLAYER_HAND') {
return
}
const payload = asRecord(source.payload)
if (!payload) {
return
}
const roomId =
readString(payload, 'room_id', 'roomId') ||
readString(source, 'roomId')
if (roomId && gameStore.roomId && roomId !== gameStore.roomId) {
return
}
const handTiles = normalizeTiles(payload.hand)
if (!loggedInUserId.value || handTiles.length === 0) {
return
}
const existingPlayer = gameStore.players[loggedInUserId.value]
if (existingPlayer) {
existingPlayer.handTiles = handTiles
}
const room = activeRoom.value
if (room && room.roomId === (roomId || gameStore.roomId)) {
room.myHand = handTiles.map((tile) => tileToText(tile))
const roomPlayer = room.players.find((item) => item.playerId === loggedInUserId.value)
if (roomPlayer) {
roomPlayer.hand = room.myHand
}
}
}
function toGameAction(message: unknown): GameAction | null { function toGameAction(message: unknown): GameAction | null {
if (!message || typeof message !== 'object') { if (!message || typeof message !== 'object') {
return null return null
@@ -1265,6 +1323,7 @@ onMounted(() => {
const text = typeof msg === 'string' ? msg : JSON.stringify(msg) const text = typeof msg === 'string' ? msg : JSON.stringify(msg)
wsMessages.value.push(`[server] ${text}`) wsMessages.value.push(`[server] ${text}`)
handleRoomInfoResponse(msg) handleRoomInfoResponse(msg)
handlePlayerHandResponse(msg)
handleReadyStateResponse(msg) handleReadyStateResponse(msg)
const gameAction = toGameAction(msg) const gameAction = toGameAction(msg)
if (gameAction) { if (gameAction) {
@@ -1457,17 +1516,21 @@ onBeforeUnmount(() => {
<span class="ready-toggle-label">开始游戏</span> <span class="ready-toggle-label">开始游戏</span>
</button> </button>
</div> </div>
<div class="player-hand" v-if="myHandTiles.length > 0"> <div class="player-hand" v-if="visibleHandTiles.length > 0">
<button <button
v-for="tile in myHandTiles" v-for="tile in visibleHandTiles"
:key="tile.id" :key="tile.id"
class="tile-chip" class="tile-chip"
:class="{ selected: selectedTile === formatTile(tile) }" :class="{ selected: selectedTile === formatTile(tile) }"
type="button" type="button"
@click="selectTile(formatTile(tile))" @click="selectTile(formatTile(tile))"
> >
{{ formatTile(tile) }} <img
class="tile-chip-image"
:src="getTileImage(tile)"
:alt="formatTile(tile)"
/>
</button> </button>
</div> </div>
</div> </div>