From fd8f6d47fa86ca483822035ad46e072425171ed8 Mon Sep 17 00:00:00 2001 From: wsy182 <2392948297@qq.com> Date: Fri, 27 Mar 2026 15:34:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(tiles):=20=E5=AE=9E=E7=8E=B0=E9=BA=BB?= =?UTF-8?q?=E5=B0=86=E7=89=8C=E5=9B=BE=E5=83=8F=E7=B3=BB=E7=BB=9F=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=B8=B8=E6=88=8F=E7=95=8C=E9=9D=A2=E6=98=BE?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重命名 tileMap.ts 为 bottomTileMap.ts 并扩展支持字牌(东南西北、中发白) - 新增 leftTileMap.ts、rightTileMap.ts 和 topTileMap.ts 支持多位置牌面渲染 - 实现牌面图像类型区分(手牌、明牌、盖牌)和动态图像键构建 - 添加牌面验证函数支持不同花色的数值范围检查 - 更新 ChengduGamePage.vue 使用新的底部牌面配置文件 - 实现玩家手牌可见性控制仅在非等待阶段显示 - 重构服务器响应解析逻辑适配新的数据结构 - 添加玩家手牌响应处理器实时更新手牌状态 - 将玩家手牌显示从文本改为图像展示提升用户体验 - 重构CSS样式实现牌面图像的响应式布局和阴影效果 --- src/assets/styles/room.css | 27 +-- src/config/bottomTileMap.ts | 299 ++++++++++++++++++++++++++++++++++ src/config/leftTileMap.ts | 251 ++++++++++++++++++++++++++++ src/config/rightTileMap.ts | 251 ++++++++++++++++++++++++++++ src/config/tileMap.ts | 111 ------------- src/config/topTileMap.ts | 251 ++++++++++++++++++++++++++++ src/game/events.ts | 0 src/views/ChengduGamePage.vue | 135 +++++++++++---- 8 files changed, 1167 insertions(+), 158 deletions(-) create mode 100644 src/config/bottomTileMap.ts create mode 100644 src/config/leftTileMap.ts create mode 100644 src/config/rightTileMap.ts delete mode 100644 src/config/tileMap.ts create mode 100644 src/config/topTileMap.ts create mode 100644 src/game/events.ts diff --git a/src/assets/styles/room.css b/src/assets/styles/room.css index 15e19b5..fe06ac1 100644 --- a/src/assets/styles/room.css +++ b/src/assets/styles/room.css @@ -886,20 +886,25 @@ } .tile-chip { + display: flex; + align-items: flex-end; + justify-content: center; min-width: 90px; height: 126px; - border: 1px solid rgba(70, 80, 92, 0.18); - border-radius: 8px; - color: #14181d; - font-size: 32px; - 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); + padding: 0; + border: 0; + background: transparent; + box-shadow: none; 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 { diff --git a/src/config/bottomTileMap.ts b/src/config/bottomTileMap.ts new file mode 100644 index 0000000..d250df3 --- /dev/null +++ b/src/config/bottomTileMap.ts @@ -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 = { + W: 1, + T: 2, + B: 3, + F: 4, + D: 4, +} + +/** + * 明牌图索引: + * p4s1_x => 万 + * p4s2_x => 筒 + * p4s3_x => 条 + * p4s4_x => 东南西北中发白 + */ +const EXPOSED_SUIT_INDEX_MAP: Record = { + 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 + +/** + * 判断是否为合法花色 + */ +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 { + if (!isValidSuit(tile.suit)) { + return false + } + return isValidTileValueBySuit(tile.suit, tile.value) +} + +/** + * 获取手牌图片 + */ +export function getHandTileImage( + tile: Pick, + position: TilePosition = 'bottom', +): string { + if (!isValidTile(tile)) { + return '' + } + + const key = buildHandTileImageKey(tile.suit, tile.value, position) + return tileImageModules[key] || '' +} + +/** + * 获取碰/杠/胡漏出的明牌图片 + */ +export function getExposedTileImage( + tile: Pick, + 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, + 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> { + const result: Array> = [] + + // 万筒条 + 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): 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 = { + 1: '东', + 2: '南', + 3: '西', + 4: '北', + } + return map[tile.value] || '' + } + case 'D': { + const map: Record = { + 1: '中', + 2: '发', + 3: '白', + } + return map[tile.value] || '' + } + default: + return '' + } +} \ No newline at end of file diff --git a/src/config/leftTileMap.ts b/src/config/leftTileMap.ts new file mode 100644 index 0000000..9ababdb --- /dev/null +++ b/src/config/leftTileMap.ts @@ -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 = { + 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 + +/** + * 判断是否为合法花色 + */ +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 { + 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, + 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, + 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> { + const result: Array> = [] + + 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): 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 = { + 1: '东', + 2: '南', + 3: '西', + 4: '北', + } + return map[tile.value] || '' + } + case 'D': { + const map: Record = { + 1: '中', + 2: '发', + 3: '白', + } + return map[tile.value] || '' + } + default: + return '' + } +} \ No newline at end of file diff --git a/src/config/rightTileMap.ts b/src/config/rightTileMap.ts new file mode 100644 index 0000000..278c2b7 --- /dev/null +++ b/src/config/rightTileMap.ts @@ -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 = { + 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 + +/** + * 判断是否为合法花色 + */ +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 { + 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, + 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, + 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> { + const result: Array> = [] + + 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): 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 = { + 1: '东', + 2: '南', + 3: '西', + 4: '北', + } + return map[tile.value] || '' + } + case 'D': { + const map: Record = { + 1: '中', + 2: '发', + 3: '白', + } + return map[tile.value] || '' + } + default: + return '' + } +} \ No newline at end of file diff --git a/src/config/tileMap.ts b/src/config/tileMap.ts deleted file mode 100644 index add231f..0000000 --- a/src/config/tileMap.ts +++ /dev/null @@ -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 = { - 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 - -/** - * 判断是否为合法花色 - */ -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 { - 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, - position: TilePosition = 'bottom', -): string { - if (!isValidTile(tile)) { - return '' - } - - const key = buildTileImageKey(tile.suit, tile.value, position) - return tileImageModules[key] || '' -} - -/** - * 获取全部基础牌 - */ -export function getAllTiles(): Array> { - const suits: Suit[] = ['W', 'T', 'B'] - const result: Array> = [] - - for (const suit of suits) { - for (let value = 1; value <= 9; value++) { - result.push({suit, value}) - } - } - - return result -} \ No newline at end of file diff --git a/src/config/topTileMap.ts b/src/config/topTileMap.ts new file mode 100644 index 0000000..530f40e --- /dev/null +++ b/src/config/topTileMap.ts @@ -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 = { + 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 + +/** + * 判断是否为合法花色 + */ +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 { + 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, + 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, + 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> { + const result: Array> = [] + + 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): 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 = { + 1: '东', + 2: '南', + 3: '西', + 4: '北', + } + return map[tile.value] || '' + } + case 'D': { + const map: Record = { + 1: '中', + 2: '发', + 3: '白', + } + return map[tile.value] || '' + } + default: + return '' + } +} \ No newline at end of file diff --git a/src/game/events.ts b/src/game/events.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/views/ChengduGamePage.vue b/src/views/ChengduGamePage.vue index e514084..a00bbe7 100644 --- a/src/views/ChengduGamePage.vue +++ b/src/views/ChengduGamePage.vue @@ -37,7 +37,7 @@ import {useGameStore} from '../store/gameStore' import {setActiveRoom, useActiveRoomState} from '../store' import type {PlayerState} from '../types/state' import type {Tile} from '../types/tile' -import {getTileImage} from "../config/tileMap.ts"; +import {getTileImage} from "../config/bottomTileMap.ts"; const gameStore = useGameStore() const activeRoom = useActiveRoomState() @@ -132,6 +132,14 @@ const myHandTiles = computed(() => { return myPlayer.value?.handTiles ?? [] }) +const visibleHandTiles = computed(() => { + if (gameStore.phase === 'waiting') { + return [] + } + + return myHandTiles.value +}) + const remainingTiles = computed(() => { return gameStore.remainingTiles }) @@ -449,22 +457,21 @@ function handleRoomInfoResponse(message: unknown): void { } const payload = asRecord(source.payload) ?? source - const summary = asRecord(payload.summary) ?? asRecord(payload.room_summary) ?? null - const publicState = asRecord(payload.public) ?? asRecord(payload.public_state) ?? null - const privateState = asRecord(payload.private) ?? asRecord(payload.private_state) ?? null - console.log("server response payload: " + payload) + const room = asRecord(payload.room) + const gameState = asRecord(payload.game_state) + const playerView = asRecord(payload.player_view) const roomId = - readString(summary ?? {}, 'room_id', 'roomId') || - readString(publicState ?? {}, 'room_id', 'roomId') || - readString(privateState ?? {}, 'room_id', 'roomId') || + readString(room ?? {}, 'room_id', 'roomId') || + readString(gameState ?? {}, 'room_id', 'roomId') || + readString(playerView ?? {}, 'room_id', 'roomId') || readString(payload, 'room_id', 'roomId') || readString(source, 'roomId') if (!roomId) { return } - const summaryPlayers = Array.isArray(summary?.players) ? summary.players : [] - const publicPlayers = Array.isArray(publicState?.players) ? publicState.players : [] + const roomPlayers = Array.isArray(room?.players) ? room.players : [] + const gamePlayers = Array.isArray(gameState?.players) ? gameState.players : [] const playerMap = new Map() - summaryPlayers.forEach((item, fallbackIndex) => { + roomPlayers.forEach((item, fallbackIndex) => { const player = asRecord(item) if (!player) { return @@ -536,7 +543,7 @@ function handleRoomInfoResponse(message: unknown): void { }) }) - publicPlayers.forEach((item, fallbackIndex) => { + gamePlayers.forEach((item, fallbackIndex) => { const player = asRecord(item) if (!player) { 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)) { const current = playerMap.get(loggedInUserId.value) if (current) { @@ -599,8 +606,8 @@ function handleRoomInfoResponse(message: unknown): void { const nextPlayers: typeof gameStore.players = {} players.forEach(({gamePlayer}) => { const previous = previousPlayers[gamePlayer.playerId] - const score = (publicState?.scores && typeof publicState.scores === 'object' - ? (publicState.scores as Record)[gamePlayer.playerId] + const score = (gameState?.scores && typeof gameState.scores === 'object' + ? (gameState.scores as Record)[gamePlayer.playerId] : undefined) nextPlayers[gamePlayer.playerId] = { playerId: gamePlayer.playerId, @@ -617,18 +624,18 @@ function handleRoomInfoResponse(message: unknown): void { }) const status = - readString(publicState ?? {}, 'status') || - readString(summary ?? {}, 'status') || - readString(publicState ?? {}, 'phase') || + readString(gameState ?? {}, 'status') || + readString(room ?? {}, 'status') || + readString(gameState ?? {}, 'phase') || 'waiting' const phase = - readString(publicState ?? {}, 'phase') || - readString(summary ?? {}, 'status') || + readString(gameState ?? {}, 'phase') || + readString(room ?? {}, 'status') || 'waiting' - const wallCount = readNumber(publicState ?? {}, 'wall_count', 'wallCount') - const dealerIndex = readNumber(publicState ?? {}, 'dealer_index', 'dealerIndex') - const currentTurnSeat = readNumber(publicState ?? {}, 'current_turn', 'currentTurn') - const currentTurnPlayerId = readString(publicState ?? {}, 'current_turn_player', 'currentTurnPlayer') + const wallCount = readNumber(gameState ?? {}, 'wall_count', 'wallCount') + const dealerIndex = readNumber(gameState ?? {}, 'dealer_index', 'dealerIndex') + const currentTurnSeat = readNumber(gameState ?? {}, 'current_turn', 'currentTurn') + const currentTurnPlayerId = readString(gameState ?? {}, 'current_turn_player', 'currentTurnPlayer') const currentTurn = currentTurnSeat ?? (currentTurnPlayerId && nextPlayers[currentTurnPlayerId] @@ -658,24 +665,24 @@ function handleRoomInfoResponse(message: unknown): void { if (typeof currentTurn === 'number') { gameStore.currentTurn = currentTurn } - const scores = asRecord(publicState?.scores) + const scores = asRecord(gameState?.scores) if (scores) { gameStore.scores = Object.fromEntries( Object.entries(scores).filter(([, value]) => typeof value === 'number'), ) as Record } - gameStore.winners = readStringArray(publicState ?? {}, 'winners') + gameStore.winners = readStringArray(gameState ?? {}, 'winners') setActiveRoom({ roomId, - roomName: readString(summary ?? {}, 'name', 'room_name', 'roomName') || activeRoom.value?.roomName || roomName.value, - gameType: readString(summary ?? {}, 'game_type', 'gameType') || activeRoom.value?.gameType || 'chengdu', - ownerId: readString(summary ?? {}, 'owner_id', 'ownerId') || activeRoom.value?.ownerId || '', - maxPlayers: readNumber(summary ?? {}, 'max_players', 'maxPlayers') ?? activeRoom.value?.maxPlayers ?? 4, - playerCount: readNumber(summary ?? {}, 'player_count', 'playerCount') ?? players.length, + roomName: readString(room ?? {}, 'name', 'room_name') || activeRoom.value?.roomName || roomName.value, + gameType: readString(room ?? {}, 'game_type') || activeRoom.value?.gameType || 'chengdu', + ownerId: readString(room ?? {}, 'owner_id') || activeRoom.value?.ownerId || '', + maxPlayers: readNumber(room ?? {}, 'max_players') ?? activeRoom.value?.maxPlayers ?? 4, + playerCount: readNumber(room ?? {}, 'player_count') ?? players.length, status, - createdAt: readString(summary ?? {}, 'created_at', 'createdAt') || activeRoom.value?.createdAt || '', - updatedAt: readString(summary ?? {}, 'updated_at', 'updatedAt') || activeRoom.value?.updatedAt || '', + createdAt: readString(room ?? {}, 'created_at') || activeRoom.value?.createdAt || '', + updatedAt: readString(room ?? {}, 'updated_at') || activeRoom.value?.updatedAt || '', players: players.map((item) => item.roomPlayer), myHand: privateHand.map((tile) => tileToText(tile)), game: { @@ -712,6 +719,15 @@ const formattedClock = computed(() => { }) const wallBacks = computed>(() => { + if (gameStore.phase === 'waiting' || remainingTiles.value <= 0) { + return { + top: [], + right: [], + bottom: [], + left: [], + } + } + const wallSize = remainingTiles.value 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}` } +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 { if (!message || typeof message !== 'object') { return null @@ -1265,6 +1323,7 @@ onMounted(() => { const text = typeof msg === 'string' ? msg : JSON.stringify(msg) wsMessages.value.push(`[server] ${text}`) handleRoomInfoResponse(msg) + handlePlayerHandResponse(msg) handleReadyStateResponse(msg) const gameAction = toGameAction(msg) if (gameAction) { @@ -1457,17 +1516,21 @@ onBeforeUnmount(() => { 开始游戏 -
+