Skip to content
项目
群组
代码片段
帮助
当前项目
正在载入...
登录 / 注册
切换导航面板
P
paopao
项目
项目
详情
活动
周期分析
仓库
仓库
文件
提交
分支
标签
贡献者
图表
比较
统计图
议题
0
议题
0
列表
看板
标记
里程碑
合并请求
0
合并请求
0
CI / CD
CI / CD
流水线
作业
日程
统计图
Wiki
Wiki
代码片段
代码片段
成员
成员
折叠边栏
关闭边栏
活动
图像
聊天
创建新问题
作业
提交
问题看板
Open sidebar
cocktail-party
paopao
Commits
11fbefbe
提交
11fbefbe
authored
3月 19, 2026
作者:
lidongxu
浏览文件
操作
浏览文件
下载
电子邮件补丁
差异文件
修改api适配线上环境和数据库环境
上级
696c9185
隐藏空白字符变更
内嵌
并排
正在显示
12 个修改的文件
包含
640 行增加
和
59 行删除
+640
-59
.env.production
admin/.env.production
+3
-0
index.js
admin/src/api/index.js
+3
-1
index.js
admin/src/socket/index.js
+1
-1
.env.production
big-screen/.env.production
+3
-0
main.js
big-screen/src/main.js
+120
-22
gameinfo.js
big-screen/src/renderer/gameinfo.js
+259
-3
socket.js
big-screen/src/socket.js
+40
-1
stateManager.js
big-screen/src/stateManager.js
+44
-0
.env.production
server/.env.production
+12
-0
package.json
server/package.json
+1
-0
app.js
server/src/app.js
+28
-2
roomHandler.js
server/src/socket/roomHandler.js
+126
-29
没有找到文件。
admin/.env.production
0 → 100644
浏览文件 @
11fbefbe
# 生产环境配置
VITE_API_BASE_URL=https://paopao.wxl66.cn/api/api
VITE_SOCKET_URL=wss://paopao.wxl66.cn
admin/src/api/index.js
浏览文件 @
11fbefbe
import
axios
from
'axios'
const
baseURL
=
import
.
meta
.
env
.
VITE_API_BASE_URL
||
'/api'
const
instance
=
axios
.
create
({
baseURL
:
'/api'
,
baseURL
,
timeout
:
10000
,
headers
:
{
'Content-Type'
:
'application/json'
,
...
...
admin/src/socket/index.js
浏览文件 @
11fbefbe
...
...
@@ -4,7 +4,7 @@ let socket = null
export
function
connectSocket
(
baseURL
=
''
)
{
if
(
socket
?.
connected
)
return
socket
const
url
=
baseURL
||
(
import
.
meta
.
env
.
DEV
?
window
.
location
.
origin
:
''
)
const
url
=
baseURL
||
import
.
meta
.
env
.
VITE_SOCKET_URL
||
(
import
.
meta
.
env
.
DEV
?
window
.
location
.
origin
:
''
)
socket
=
io
(
url
,
{
path
:
'/socket.io'
,
transports
:
[
'websocket'
,
'polling'
],
...
...
big-screen/.env.production
0 → 100644
浏览文件 @
11fbefbe
# 生产环境配置
VITE_SOCKET_URL=wss://paopao.wxl66.cn/ws
VITE_SCREEN_NAME=big-screen-1
big-screen/src/main.js
浏览文件 @
11fbefbe
...
...
@@ -2,13 +2,13 @@
* 大屏展示页入口:支持多玩家并排渲染
*/
import
{
initScaler
}
from
'./scaler.js'
import
{
getAllPlayerStates
,
clearGameState
,
getCurrentRoom
}
from
'./stateManager.js'
import
{
getAllPlayerStates
,
clearGameState
,
getCurrentRoom
,
getPlayerTeam
}
from
'./stateManager.js'
import
{
initSocket
,
getConnectionStatus
}
from
'./socket.js'
import
{
drawBackground
}
from
'./renderer/background.js'
import
{
drawBubbleGrid
}
from
'./renderer/bubbleGrid.js'
import
{
drawBubble3D
,
BUBBLE_RADIUS
}
from
'./renderer/bubble.js'
import
{
drawShooter
}
from
'./renderer/shooter.js'
import
{
drawGameInfo
}
from
'./renderer/gameinfo.js'
import
{
drawGameInfo
,
drawTeamResultOverlay
}
from
'./renderer/gameinfo.js'
import
{
updateAndDrawExplosions
,
appendExplosionsFromState
,
Explosion
}
from
'./renderer/explosion.js'
import
{
drawIdleScreen
}
from
'./renderer/idleScreen.js'
import
{
SCREEN_WIDTH
,
SCREEN_HEIGHT
}
from
'./constants.js'
...
...
@@ -75,7 +75,7 @@ function renderPlayer(state, offsetX, roomId) {
}
// 得分 / 结束
drawGameInfo
(
ctx
,
state
.
score
??
0
,
state
.
isGameOver
??
false
,
roomId
??
state
.
roomId
??
''
)
drawGameInfo
(
ctx
,
state
.
score
??
0
,
state
.
isGameOver
??
false
,
roomId
??
state
.
roomId
??
''
,
state
.
nickname
??
''
)
ctx
.
restore
()
}
...
...
@@ -100,24 +100,73 @@ function drawDivider(x) {
ctx
.
restore
()
}
// ───
玩家编号标签 ────────
──────────────────────────────────────────────────────
// ───
VS 分隔线(两队中间)
──────────────────────────────────────────────────────
function
drawPlayerLabel
(
offsetX
,
playerId
)
{
const
label
=
`P
${
playerId
}
`
const
bw
=
36
,
bh
=
20
,
bx
=
offsetX
+
SCREEN_WIDTH
/
2
-
bw
/
2
,
by
=
SCREEN_HEIGHT
-
28
function
drawVSDivider
(
x
)
{
ctx
.
save
()
ctx
.
fillStyle
=
'rgba(139,92,246,0.6)'
// 发光效果
ctx
.
shadowColor
=
'rgba(251,191,36,0.5)'
ctx
.
shadowBlur
=
20
const
grad
=
ctx
.
createLinearGradient
(
x
,
0
,
x
,
SCREEN_HEIGHT
)
grad
.
addColorStop
(
0
,
'rgba(251,191,36,0)'
)
grad
.
addColorStop
(
0.15
,
'rgba(251,191,36,0.8)'
)
grad
.
addColorStop
(
0.5
,
'rgba(251,191,36,1)'
)
grad
.
addColorStop
(
0.85
,
'rgba(251,191,36,0.8)'
)
grad
.
addColorStop
(
1
,
'rgba(251,191,36,0)'
)
ctx
.
strokeStyle
=
grad
ctx
.
lineWidth
=
3
ctx
.
beginPath
()
ctx
.
roundRect
(
bx
,
by
,
bw
,
bh
,
10
)
ctx
.
moveTo
(
x
,
SCREEN_HEIGHT
*
0.15
)
ctx
.
lineTo
(
x
,
SCREEN_HEIGHT
*
0.85
)
ctx
.
stroke
()
ctx
.
shadowBlur
=
0
// VS 文字
ctx
.
textAlign
=
'center'
ctx
.
textBaseline
=
'middle'
ctx
.
font
=
'bold 48px Arial'
ctx
.
fillStyle
=
'rgba(251,191,36,0.9)'
ctx
.
shadowColor
=
'rgba(251,191,36,0.6)'
ctx
.
shadowBlur
=
15
ctx
.
fillText
(
'VS'
,
x
,
SCREEN_HEIGHT
/
2
)
ctx
.
shadowBlur
=
0
ctx
.
restore
()
}
// ─── 队伍标识(顶部)───────────────────────────────────────────────────────────
function
drawTeamLabel
(
x
,
team
)
{
const
isTeamA
=
team
===
'A'
const
label
=
isTeamA
?
'A队'
:
'B队'
const
color
=
isTeamA
?
'#8B5CF6'
:
'#EC4899'
const
bgColor
=
isTeamA
?
'rgba(139,92,246,0.3)'
:
'rgba(236,72,153,0.3)'
ctx
.
save
()
const
bw
=
80
,
bh
=
32
const
bx
=
x
-
bw
/
2
const
by
=
20
// 背景
ctx
.
fillStyle
=
bgColor
ctx
.
strokeStyle
=
color
ctx
.
lineWidth
=
2
ctx
.
beginPath
()
ctx
.
roundRect
(
bx
,
by
,
bw
,
bh
,
16
)
ctx
.
fill
()
ctx
.
stroke
()
// 文字
ctx
.
textAlign
=
'center'
ctx
.
textBaseline
=
'middle'
ctx
.
font
=
'bold 1
2
px Arial'
ctx
.
fillStyle
=
'rgba(221,214,254,0.9)'
ctx
.
fillText
(
label
,
offsetX
+
SCREEN_WIDTH
/
2
,
by
+
bh
/
2
)
ctx
.
font
=
'bold 1
6
px Arial'
ctx
.
fillStyle
=
color
ctx
.
fillText
(
label
,
x
,
by
+
bh
/
2
)
ctx
.
restore
()
}
// ─── 主循环 ───────────────────────────────────────────────────────────────────
let
_lastPlayerCount
=
1
...
...
@@ -129,10 +178,15 @@ function loop() {
const
connStatus
=
getConnectionStatus
()
const
playerCount
=
states
.
length
||
1
// 按队伍分组
const
teamAStates
=
states
.
filter
(
s
=>
getPlayerTeam
(
s
.
playerId
??
1
)
===
'A'
)
const
teamBStates
=
states
.
filter
(
s
=>
getPlayerTeam
(
s
.
playerId
??
1
)
===
'B'
)
const
totalSlots
=
Math
.
max
(
teamAStates
.
length
+
teamBStates
.
length
,
1
)
// 人数变化时重新计算缩放
if
(
playerCount
!==
_lastPlayerCount
)
{
_lastPlayerCount
=
playerCount
applyScaler
(
playerCount
)
if
(
totalSlots
!==
_lastPlayerCount
)
{
_lastPlayerCount
=
totalSlots
applyScaler
(
totalSlots
)
// 清理消失玩家的爆炸列表
for
(
const
pid
of
playerExplosions
.
keys
())
{
if
(
!
states
.
find
(
s
=>
(
s
.
playerId
??
1
)
===
pid
))
{
...
...
@@ -144,22 +198,66 @@ function loop() {
// 绘制背景(横向铺满整个大屏)
ctx
.
save
()
ctx
.
setTransform
(
1
,
0
,
0
,
1
,
0
,
0
)
drawBackground
(
ctx
,
SCREEN_WIDTH
*
playerCount
,
SCREEN_HEIGHT
)
drawBackground
(
ctx
,
SCREEN_WIDTH
*
totalSlots
,
SCREEN_HEIGHT
)
ctx
.
restore
()
if
(
states
.
length
>
0
)
{
// ── 多玩家并排渲染 ─────────────────────────────────────────────────────
states
.
forEach
((
state
,
idx
)
=>
{
const
offsetX
=
idx
*
SCREEN_WIDTH
// ── 按队伍分组渲染:A队左,B队右 ────────────────────────────────────────
let
currentOffsetX
=
0
// 绘制A队标识
if
(
teamAStates
.
length
>
0
)
{
const
teamACenterX
=
currentOffsetX
+
(
teamAStates
.
length
*
SCREEN_WIDTH
)
/
2
drawTeamLabel
(
teamACenterX
,
'A'
)
}
// 渲染A队玩家
teamAStates
.
forEach
((
state
,
idx
)
=>
{
const
offsetX
=
currentOffsetX
// 分隔线(A队内部)
if
(
idx
>
0
)
drawDivider
(
offsetX
)
renderPlayer
(
state
,
offsetX
,
roomId
)
// 分隔线(每个玩家右侧,最后一个不画)
currentOffsetX
+=
SCREEN_WIDTH
})
// 绘制 VS 分隔线(A队和B队之间)
if
(
teamAStates
.
length
>
0
&&
teamBStates
.
length
>
0
)
{
drawVSDivider
(
currentOffsetX
)
}
// 绘制B队标识
if
(
teamBStates
.
length
>
0
)
{
const
teamBCenterX
=
currentOffsetX
+
(
teamBStates
.
length
*
SCREEN_WIDTH
)
/
2
drawTeamLabel
(
teamBCenterX
,
'B'
)
}
// 渲染B队玩家
teamBStates
.
forEach
((
state
,
idx
)
=>
{
const
offsetX
=
currentOffsetX
// 分隔线(B队内部)
if
(
idx
>
0
)
drawDivider
(
offsetX
)
renderPlayer
(
state
,
offsetX
,
roomId
)
// 玩家编号(多于1人时显示)
if
(
states
.
length
>
1
)
drawPlayerLabel
(
offsetX
,
state
.
playerId
??
idx
+
1
)
currentOffsetX
+=
SCREEN_WIDTH
})
// ── 检测是否所有玩家都结束,显示队伍比分 ─────────────────────────────
const
allGameOver
=
states
.
every
(
s
=>
s
.
isGameOver
)
console
.
log
(
'[BigScreen] 游戏状态检查'
,
{
allGameOver
,
statesCount
:
states
.
length
,
totalSlots
,
canvasWidth
:
canvas
.
width
,
SCREEN_WIDTH
})
if
(
allGameOver
&&
states
.
length
>
1
)
{
// 全屏显示队伍比分(传入实际的大屏宽度)
const
totalWidth
=
SCREEN_WIDTH
*
totalSlots
console
.
log
(
'[BigScreen] 显示队伍比分'
,
{
totalSlots
,
totalWidth
,
canvasWidth
:
canvas
.
width
,
states
:
states
.
length
})
ctx
.
save
()
ctx
.
setTransform
(
1
,
0
,
0
,
1
,
0
,
0
)
drawTeamResultOverlay
(
ctx
,
states
,
getPlayerTeam
,
totalWidth
)
ctx
.
restore
()
}
}
else
{
// ── 空闲等待画面 ──────────────────────────────────────────────────────
drawIdleScreen
(
ctx
,
SCREEN_WIDTH
,
SCREEN_HEIGHT
,
roomId
,
connStatus
,
SCREEN_NAME
,
frameCount
)
...
...
big-screen/src/renderer/gameinfo.js
浏览文件 @
11fbefbe
...
...
@@ -174,7 +174,7 @@ function drawOverlay(ctx) {
ctx
.
restore
()
}
function
drawGameOverCard
(
ctx
,
score
)
{
function
drawGameOverCard
(
ctx
,
score
,
nickname
=
''
)
{
const
cw
=
300
const
ch
=
360
const
cx
=
SCREEN_WIDTH
/
2
...
...
@@ -227,6 +227,13 @@ function drawGameOverCard(ctx, score) {
ctx
.
fillText
(
'游戏结束'
,
cx
,
cardY
+
68
)
ctx
.
shadowBlur
=
0
// 昵称(有昵称才显示)
if
(
nickname
)
{
ctx
.
font
=
'bold 14px Arial'
ctx
.
fillStyle
=
'rgba(196,181,253,0.85)'
ctx
.
fillText
(
nickname
,
cx
,
cardY
+
100
)
}
// 分割线
const
dg
=
ctx
.
createLinearGradient
(
cardX
+
20
,
0
,
cardX
+
cw
-
20
,
0
)
dg
.
addColorStop
(
0
,
'rgba(255,255,255,0)'
)
...
...
@@ -244,6 +251,7 @@ function drawGameOverCard(ctx, score) {
ctx
.
fillStyle
=
'rgba(255,255,255,0.5)'
ctx
.
fillText
(
'本局得分'
,
cx
,
cardY
+
140
)
// 分数数字
ctx
.
shadowColor
=
'rgba(251,191,36,0.7)'
ctx
.
shadowBlur
=
30
...
...
@@ -273,13 +281,261 @@ function drawGameOverCard(ctx, score) {
// ─── 公开导出 ─────────────────────────────────────────────────────────────────
export
function
drawGameInfo
(
ctx
,
score
=
0
,
isGameOver
=
false
,
roomId
=
''
)
{
export
function
drawGameInfo
(
ctx
,
score
=
0
,
isGameOver
=
false
,
roomId
=
''
,
nickname
=
''
)
{
drawScoreCard
(
ctx
,
score
)
if
(
roomId
!==
''
&&
roomId
!==
null
&&
roomId
!==
undefined
)
{
drawRoomCard
(
ctx
,
roomId
)
}
// 游戏进行中底部显示昵称
if
(
nickname
&&
!
isGameOver
)
{
drawNicknameBar
(
ctx
,
nickname
)
}
if
(
isGameOver
)
{
drawOverlay
(
ctx
)
drawGameOverCard
(
ctx
,
score
)
drawGameOverCard
(
ctx
,
score
,
nickname
)
}
}
// ─── 队伍比分覆盖层(游戏结束时)──────────────────────────────────────────────
export
function
drawTeamResultOverlay
(
ctx
,
playerStates
,
getPlayerTeam
,
totalWidth
)
{
const
states
=
playerStates
||
[]
if
(
states
.
length
===
0
)
return
// 使用传入的总宽度或默认单屏宽度
const
canvasWidth
=
totalWidth
||
SCREEN_WIDTH
const
canvasHeight
=
SCREEN_HEIGHT
console
.
log
(
'[drawTeamResultOverlay]'
,
{
totalWidth
,
canvasWidth
,
canvasHeight
,
SCREEN_WIDTH
,
SCREEN_HEIGHT
})
// 按队伍分组
const
teamAPlayers
=
states
.
filter
(
s
=>
getPlayerTeam
(
s
.
playerId
??
1
)
===
'A'
)
const
teamBPlayers
=
states
.
filter
(
s
=>
getPlayerTeam
(
s
.
playerId
??
1
)
===
'B'
)
// 计算总分
const
teamAScore
=
teamAPlayers
.
reduce
((
sum
,
p
)
=>
sum
+
(
p
.
score
||
0
),
0
)
const
teamBScore
=
teamBPlayers
.
reduce
((
sum
,
p
)
=>
sum
+
(
p
.
score
||
0
),
0
)
// 判断胜负
const
isDraw
=
teamAScore
===
teamBScore
const
winner
=
teamAScore
>
teamBScore
?
'A'
:
'B'
// 半透明遮罩(全屏)
ctx
.
save
()
ctx
.
fillStyle
=
'rgba(0,0,0,0.92)'
ctx
.
fillRect
(
0
,
0
,
canvasWidth
,
canvasHeight
)
ctx
.
restore
()
const
cx
=
canvasWidth
/
2
const
startY
=
canvasHeight
*
0.1
// 标题
ctx
.
save
()
ctx
.
textAlign
=
'center'
ctx
.
textBaseline
=
'middle'
ctx
.
font
=
'bold 42px Arial'
const
titleText
=
isDraw
?
'平局!'
:
`
${
winner
}
队胜利!`
const
titleColor
=
isDraw
?
'#FCD34D'
:
winner
===
'A'
?
'#8B5CF6'
:
'#EC4899'
ctx
.
shadowColor
=
titleColor
ctx
.
shadowBlur
=
25
ctx
.
fillStyle
=
titleColor
ctx
.
fillText
(
titleText
,
cx
,
startY
)
ctx
.
shadowBlur
=
0
ctx
.
restore
()
// 队伍比分卡片 - 使用更宽的卡片确保覆盖所有内容
const
cardY
=
startY
+
50
const
cardW
=
Math
.
min
(
800
,
canvasWidth
*
0.85
)
const
cardH
=
140
const
cardX
=
cx
-
cardW
/
2
ctx
.
save
()
ctx
.
fillStyle
=
'rgba(30,15,60,0.95)'
ctx
.
strokeStyle
=
'rgba(139,92,246,0.5)'
ctx
.
lineWidth
=
2
ctx
.
beginPath
()
ctx
.
roundRect
(
cardX
,
cardY
,
cardW
,
cardH
,
16
)
ctx
.
fill
()
ctx
.
stroke
()
ctx
.
restore
()
// A队分数(左侧)
const
teamAX
=
cardX
+
cardW
*
0.25
drawTeamScoreBig
(
ctx
,
teamAX
,
cardY
+
cardH
/
2
,
'A'
,
teamAScore
,
winner
===
'A'
&&
!
isDraw
)
// VS(中间)
ctx
.
save
()
ctx
.
textAlign
=
'center'
ctx
.
textBaseline
=
'middle'
ctx
.
font
=
'bold 36px Arial'
ctx
.
fillStyle
=
'rgba(251,191,36,0.9)'
ctx
.
fillText
(
'VS'
,
cx
,
cardY
+
cardH
/
2
)
ctx
.
restore
()
// B队分数(右侧)
const
teamBX
=
cardX
+
cardW
*
0.75
drawTeamScoreBig
(
ctx
,
teamBX
,
cardY
+
cardH
/
2
,
'B'
,
teamBScore
,
winner
===
'B'
&&
!
isDraw
)
// 玩家详细列表 - 使用更宽的卡片
const
listY
=
cardY
+
cardH
+
40
const
listCardW
=
cardW
const
listCardH
=
Math
.
min
(
380
,
canvasHeight
-
listY
-
60
)
const
listCardX
=
cx
-
listCardW
/
2
ctx
.
save
()
ctx
.
fillStyle
=
'rgba(20,10,40,0.9)'
ctx
.
strokeStyle
=
'rgba(139,92,246,0.4)'
ctx
.
lineWidth
=
2
ctx
.
beginPath
()
ctx
.
roundRect
(
listCardX
,
listY
,
listCardW
,
listCardH
,
12
)
ctx
.
fill
()
ctx
.
stroke
()
ctx
.
restore
()
// 左右两列显示玩家 - 调整列宽以适应更宽的卡片
const
colW
=
listCardW
/
2
-
40
const
colX_A
=
listCardX
+
25
const
colX_B
=
listCardX
+
listCardW
/
2
+
15
// A队玩家列表
drawTeamPlayerList
(
ctx
,
colX_A
,
listY
+
15
,
colW
,
listCardH
-
30
,
'A'
,
teamAPlayers
,
winner
===
'A'
&&
!
isDraw
)
// B队玩家列表
drawTeamPlayerList
(
ctx
,
colX_B
,
listY
+
15
,
colW
,
listCardH
-
30
,
'B'
,
teamBPlayers
,
winner
===
'B'
&&
!
isDraw
)
// 提示文字
ctx
.
save
()
ctx
.
textAlign
=
'center'
ctx
.
textBaseline
=
'middle'
ctx
.
font
=
'16px Arial'
ctx
.
fillStyle
=
'rgba(196,181,253,0.6)'
ctx
.
fillText
(
'等待大屏切换...'
,
cx
,
canvasHeight
-
35
)
ctx
.
restore
()
}
function
drawTeamScoreBig
(
ctx
,
x
,
y
,
team
,
score
,
isWinner
)
{
const
color
=
team
===
'A'
?
'#8B5CF6'
:
'#EC4899'
const
glowColor
=
isWinner
?
color
:
'transparent'
ctx
.
save
()
// 队伍标签
ctx
.
textAlign
=
'center'
ctx
.
textBaseline
=
'middle'
ctx
.
font
=
'bold 18px Arial'
ctx
.
fillStyle
=
color
ctx
.
fillText
(
`
${
team
}
队`
,
x
,
y
-
28
)
// 分数(胜利方更大更亮)
ctx
.
font
=
isWinner
?
'bold 56px Arial'
:
'bold 42px Arial'
ctx
.
shadowColor
=
glowColor
ctx
.
shadowBlur
=
isWinner
?
30
:
0
const
sg
=
ctx
.
createLinearGradient
(
x
-
50
,
0
,
x
+
50
,
0
)
sg
.
addColorStop
(
0
,
'#FDE68A'
)
sg
.
addColorStop
(
0.5
,
'#FCD34D'
)
sg
.
addColorStop
(
1
,
'#F59E0B'
)
ctx
.
fillStyle
=
sg
ctx
.
fillText
(
String
(
score
),
x
,
y
+
15
)
ctx
.
shadowBlur
=
0
ctx
.
restore
()
}
function
drawTeamPlayerList
(
ctx
,
x
,
y
,
w
,
h
,
team
,
players
,
isWinner
)
{
const
color
=
team
===
'A'
?
'#8B5CF6'
:
'#EC4899'
const
rowHeight
=
36
ctx
.
save
()
// 队伍标题
ctx
.
textAlign
=
'center'
ctx
.
textBaseline
=
'middle'
ctx
.
font
=
'bold 16px Arial'
ctx
.
fillStyle
=
color
ctx
.
fillText
(
`
${
team
}
队成员`
,
x
+
w
/
2
,
y
+
12
)
// 分割线
ctx
.
strokeStyle
=
color
+
'40'
ctx
.
lineWidth
=
1
ctx
.
beginPath
()
ctx
.
moveTo
(
x
+
5
,
y
+
26
)
ctx
.
lineTo
(
x
+
w
-
5
,
y
+
26
)
ctx
.
stroke
()
// 玩家列表
ctx
.
font
=
'13px Arial'
players
.
forEach
((
player
,
index
)
=>
{
const
rowY
=
y
+
45
+
index
*
rowHeight
if
(
rowY
>
y
+
h
-
20
)
return
// 超出范围不显示
// 背景高亮(如果是胜利方)
if
(
isWinner
)
{
ctx
.
fillStyle
=
color
+
'15'
ctx
.
beginPath
()
ctx
.
roundRect
(
x
+
2
,
rowY
-
14
,
w
-
4
,
28
,
6
)
ctx
.
fill
()
}
// 排名
ctx
.
textAlign
=
'center'
ctx
.
fillStyle
=
index
===
0
?
'#FCD34D'
:
'rgba(196,181,253,0.6)'
ctx
.
fillText
(
String
(
index
+
1
),
x
+
15
,
rowY
)
// 昵称
ctx
.
textAlign
=
'left'
ctx
.
fillStyle
=
'rgba(255,255,255,0.9)'
const
nickname
=
player
.
nickname
||
`玩家
${
player
.
playerId
}
`
const
displayName
=
nickname
.
length
>
8
?
nickname
.
slice
(
0
,
8
)
+
'...'
:
nickname
ctx
.
fillText
(
displayName
,
x
+
30
,
rowY
)
// 分数
ctx
.
textAlign
=
'right'
ctx
.
fillStyle
=
'#FCD34D'
ctx
.
font
=
'bold 13px Arial'
ctx
.
fillText
(
String
(
player
.
score
||
0
),
x
+
w
-
10
,
rowY
)
ctx
.
font
=
'13px Arial'
})
ctx
.
restore
()
}
// ─── 底部昵称条 ───────────────────────────────────────────────────────────────
function
drawNicknameBar
(
ctx
,
nickname
)
{
const
cx
=
SCREEN_WIDTH
/
2
const
barY
=
SCREEN_HEIGHT
-
28
const
barH
=
22
ctx
.
save
()
// 背景胶囊
ctx
.
font
=
`bold 12px Arial`
const
tw
=
ctx
.
measureText
(
nickname
).
width
const
bw
=
tw
+
24
const
bx
=
cx
-
bw
/
2
ctx
.
shadowColor
=
'rgba(139,92,246,0.4)'
ctx
.
shadowBlur
=
8
ctx
.
shadowOffsetY
=
2
const
bg
=
ctx
.
createLinearGradient
(
bx
,
barY
,
bx
,
barY
+
barH
)
bg
.
addColorStop
(
0
,
'rgba(139,92,246,0.75)'
)
bg
.
addColorStop
(
1
,
'rgba(109,40,217,0.8)'
)
ctx
.
fillStyle
=
bg
roundRectPath
(
ctx
,
bx
,
barY
,
bw
,
barH
,
barH
/
2
)
ctx
.
fill
()
ctx
.
shadowBlur
=
0
// 边框
ctx
.
strokeStyle
=
'rgba(196,181,253,0.5)'
ctx
.
lineWidth
=
1
roundRectPath
(
ctx
,
bx
,
barY
,
bw
,
barH
,
barH
/
2
)
ctx
.
stroke
()
// 昵称文字
ctx
.
textAlign
=
'center'
ctx
.
textBaseline
=
'middle'
ctx
.
fillStyle
=
'#fff'
ctx
.
fillText
(
nickname
,
cx
,
barY
+
barH
/
2
)
ctx
.
restore
()
}
big-screen/src/socket.js
浏览文件 @
11fbefbe
...
...
@@ -5,7 +5,7 @@
* 发送 → JSON.stringify({ event: string, data: any })
* 接收 ← JSON.stringify({ event: string, data: any })
*/
import
{
setCurrentRoom
,
setGameState
,
clearGameState
}
from
'./stateManager.js'
import
{
setCurrentRoom
,
setGameState
,
clearGameState
,
setPlayerGameOver
,
setAllGameOver
,
setPlayerTeam
}
from
'./stateManager.js'
const
defaultServerUrl
=
import
.
meta
.
env
.
VITE_SOCKET_URL
||
'ws://localhost:3000/ws'
...
...
@@ -100,10 +100,49 @@ function _dispatch(event, data) {
* 接收小游戏实时状态帧
*/
case
'room:state'
:
{
// 如果状态中有 team 信息,更新玩家队伍
if
(
data
?.
playerId
&&
data
?.
team
)
{
setPlayerTeam
(
data
.
playerId
,
data
.
team
)
}
setGameState
(
data
)
break
}
/**
* 玩家加入房间,更新队伍信息
*/
case
'room:playerJoined'
:
{
// 如果有玩家列表,更新所有玩家的队伍信息
if
(
data
?.
players
)
{
data
.
players
.
forEach
(
p
=>
{
if
(
p
.
playerId
&&
p
.
team
)
{
setPlayerTeam
(
p
.
playerId
,
p
.
team
)
}
})
}
break
}
/**
* 某个玩家游戏结束(正常结束/泡泡压底)
* data: { roomId, score, playerId }
*/
case
'room:gameOver'
:
{
console
.
log
(
'[Socket] room:gameOver'
,
data
)
setPlayerGameOver
(
data
?.
playerId
,
data
?.
score
,
data
?.
nickname
)
break
}
/**
* 服务端倒计时到期,所有玩家强制结束
* data: { roomId, durationSec }
*/
case
'room:timeUp'
:
{
console
.
log
(
'[Socket] room:timeUp,所有玩家游戏结束'
)
setAllGameOver
()
break
}
case
'screen:joined'
:
{
console
.
log
(
'[Socket] 大屏注册成功:'
,
data
)
break
...
...
big-screen/src/stateManager.js
浏览文件 @
11fbefbe
...
...
@@ -8,6 +8,9 @@ let currentRoomId = null
/** Map<playerId, state> */
const
playerStates
=
new
Map
()
/** 玩家队伍信息 Map<playerId, team> */
const
playerTeams
=
new
Map
()
export
function
setCurrentRoom
(
roomId
)
{
currentRoomId
=
roomId
}
...
...
@@ -16,6 +19,20 @@ export function getCurrentRoom() {
return
currentRoomId
}
/**
* 设置玩家队伍信息
*/
export
function
setPlayerTeam
(
playerId
,
team
)
{
playerTeams
.
set
(
playerId
,
team
)
}
/**
* 获取玩家队伍信息
*/
export
function
getPlayerTeam
(
playerId
)
{
return
playerTeams
.
get
(
playerId
)
||
'A'
}
/**
* 更新某个玩家的状态
* state 中必须含 playerId 字段
...
...
@@ -23,9 +40,35 @@ export function getCurrentRoom() {
export
function
setGameState
(
state
)
{
if
(
!
state
)
return
const
pid
=
state
.
playerId
??
1
// 已结束的玩家保留 isGameOver 标记,不被新帧覆盖
const
prev
=
playerStates
.
get
(
pid
)
if
(
prev
&&
prev
.
isGameOver
&&
!
state
.
isGameOver
)
return
playerStates
.
set
(
pid
,
state
)
}
/**
* 标记某个玩家游戏结束(收到 room:gameOver 或 room:timeUp 时调用)
*/
export
function
setPlayerGameOver
(
playerId
,
score
,
nickname
)
{
const
pid
=
playerId
??
1
const
prev
=
playerStates
.
get
(
pid
)
||
{}
playerStates
.
set
(
pid
,
{
...
prev
,
isGameOver
:
true
,
score
:
score
??
prev
.
score
??
0
,
nickname
:
nickname
??
prev
.
nickname
,
})
}
/**
* 标记所有玩家游戏结束(room:timeUp 时调用)
*/
export
function
setAllGameOver
()
{
for
(
const
[
pid
,
state
]
of
playerStates
)
{
playerStates
.
set
(
pid
,
{
...
state
,
isGameOver
:
true
})
}
}
/**
* 获取所有玩家状态,按 playerId 升序排列
* @returns {Array<state>}
...
...
@@ -47,4 +90,5 @@ export function getGameState() {
export
function
clearGameState
()
{
playerStates
.
clear
()
playerTeams
.
clear
()
}
server/.env.production
0 → 100644
浏览文件 @
11fbefbe
# 线上生产环境配置
# 服务端口
PORT=3000
# 数据库连接(阿里云 RDS)
DATABASE_URL="mysql://wxl:kC2*mH1#@rm-2ze241z1hf323u76aqo.mysql.rds.aliyuncs.com:3306/paopao"
# CORS 允许的域(线上域名)
CLIENT_ORIGIN=https://paopao.wxl66.cn
# 环境标识
NODE_ENV=production
server/package.json
浏览文件 @
11fbefbe
...
...
@@ -5,6 +5,7 @@
"main"
:
"src/app.js"
,
"scripts"
:
{
"start"
:
"node src/app.js"
,
"start:prod"
:
"node -r dotenv/config src/app.js dotenv_config_path=.env.production"
,
"dev"
:
"nodemon src/app.js"
,
"db:generate"
:
"prisma generate --schema=src/prisma/schema.prisma"
,
"db:migrate"
:
"prisma migrate dev --schema=src/prisma/schema.prisma"
,
...
...
server/src/app.js
浏览文件 @
11fbefbe
require
(
'dotenv'
).
config
();
const
fs
=
require
(
'fs'
);
const
path
=
require
(
'path'
);
const
http
=
require
(
'http'
);
// 加载环境变量(支持通过 NODE_ENV 指定配置)
const
envFile
=
process
.
env
.
NODE_ENV
===
'production'
?
'.env.production'
:
'.env'
;
require
(
'dotenv'
).
config
({
path
:
envFile
});
console
.
log
(
`[Server] 加载配置:
${
envFile
}
`
);
console
.
log
(
`[Server] 数据库:
${
process
.
env
.
DATABASE_URL
?.
replace
(
/:.*@/
,
':***@'
)}
`);
const https = require('https');
const express = require('express');
const cors = require('cors');
const { initSocket } = require('./socket');
const app = express();
const
server
=
http
.
createServer
(
app
);
// 根据环境创建 HTTP 或 HTTPS 服务器
let server;
const SSL_KEY_PATH = process.env.SSL_KEY_PATH;
const SSL_CERT_PATH = process.env.SSL_CERT_PATH;
if (SSL_KEY_PATH && SSL_CERT_PATH && fs.existsSync(SSL_KEY_PATH) && fs.existsSync(SSL_CERT_PATH)) {
// 生产环境使用 HTTPS/WSS
const options = {
key: fs.readFileSync(SSL_KEY_PATH),
cert: fs.readFileSync(SSL_CERT_PATH),
};
server = https.createServer(options, app);
console.log('[Server] 使用 HTTPS 模式');
} else {
// 开发环境使用 HTTP/WS
server = http.createServer(app);
console.log('[Server] 使用 HTTP 模式');
}
// 中间件
app.use(cors({
...
...
server/src/socket/roomHandler.js
浏览文件 @
11fbefbe
const
prisma
=
require
(
'../prisma/client'
);
/**
* 内存等待表:roomId → { totalSeats, joined: Set<ws> }
* 内存等待表:roomId → { totalSeats, joined: Set<ws>
, players: Array
}
* 房间满员或游戏开始后从表中移除
*/
const
waitingRooms
=
new
Map
();
...
...
@@ -9,6 +9,12 @@ const waitingRooms = new Map();
/** 房间玩家计数:roomId → number,用于分配递增的 playerId */
const
roomPlayerCounter
=
new
Map
();
/**
* 房间倒计时表:roomId → { timer, duration, startTime }
* allReady 后启动,时间到广播 room:timeUp
*/
const
roomTimers
=
new
Map
();
/**
* 注册房间相关 WebSocket 事件处理
*
...
...
@@ -37,14 +43,15 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms })
// ── room:create ────────────────────────────────────────────────────────────
// 房主创建房间,写库并进入等待状态
ws
.
_handlers
[
'room:create'
]
=
async
({
roomId
,
totalSeats
}
=
{})
=>
{
ws
.
_handlers
[
'room:create'
]
=
async
({
roomId
,
totalSeats
,
gameDuration
,
nickname
,
team
}
=
{})
=>
{
if
(
!
roomId
||
!
totalSeats
)
{
ws
.
sendEvent
(
'error'
,
{
message
:
'缺少 roomId 或 totalSeats'
});
return
;
}
const
rid
=
String
(
roomId
);
const
seats
=
Number
(
totalSeats
);
const
rid
=
String
(
roomId
);
const
seats
=
Number
(
totalSeats
);
const
duration
=
Number
(
gameDuration
)
||
0
;
// 0 表示不限时
try
{
// 写库:waiting 状态
...
...
@@ -54,22 +61,37 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms })
create
:
{
roomId
:
rid
,
status
:
'waiting'
,
totalSeats
:
seats
},
});
// 加入内存等待表
waitingRooms
.
set
(
rid
,
{
totalSeats
:
seats
,
joined
:
new
Set
([
ws
])
});
// 房主信息
const
playerInfo
=
{
playerId
:
1
,
nickname
:
nickname
||
'玩家1'
,
team
:
team
||
'A'
};
// 加入内存等待表(含游戏时长和玩家列表)
waitingRooms
.
set
(
rid
,
{
totalSeats
:
seats
,
joined
:
new
Set
([
ws
]),
players
:
[
playerInfo
],
gameDuration
:
duration
});
// 订阅房间广播频道
joinRoom
(
ws
,
rid
);
ws
.
ctx
.
roomId
=
rid
;
ws
.
ctx
.
role
=
'minigame'
;
ws
.
ctx
.
playerId
=
1
;
// 房主固定为 1
ws
.
ctx
.
playerId
=
1
;
ws
.
ctx
.
nickname
=
playerInfo
.
nickname
;
ws
.
ctx
.
team
=
playerInfo
.
team
;
roomPlayerCounter
.
set
(
rid
,
1
);
console
.
log
(
`[Room] 创建房间
${
rid
}
,总座位:
${
seats
}
`
);
console
.
log
(
`[Room] 创建房间
${
rid
}
,总座位:
${
seats
}
,房主:
${
playerInfo
.
nickname
}
(
${
playerInfo
.
team
}
队)
`
);
ws
.
sendEvent
(
'room:created'
,
{
roomId
:
rid
,
totalSeats
:
seats
,
joinedCount
:
1
,
playerId
:
1
,
players
:
[
playerInfo
]
});
}
catch
(
err
)
{
console
.
error
(
'[room:create] 错误:'
,
err
);
...
...
@@ -79,7 +101,7 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms })
// ── room:join ──────────────────────────────────────────────────────────────
// 其他玩家凭房间号加入等待中的房间
ws
.
_handlers
[
'room:join'
]
=
async
({
roomId
}
=
{})
=>
{
ws
.
_handlers
[
'room:join'
]
=
async
({
roomId
,
nickname
,
team
}
=
{})
=>
{
if
(
!
roomId
)
{
ws
.
sendEvent
(
'error'
,
{
message
:
'缺少 roomId'
});
return
;
...
...
@@ -120,28 +142,38 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms })
return
;
}
// 加入等待表
waiting
.
joined
.
add
(
ws
);
joinRoom
(
ws
,
rid
);
ws
.
ctx
.
roomId
=
rid
;
ws
.
ctx
.
role
=
'minigame'
;
// 分配递增 playerId
const
nextId
=
(
roomPlayerCounter
.
get
(
rid
)
||
0
)
+
1
;
roomPlayerCounter
.
set
(
rid
,
nextId
);
// 玩家信息
const
playerInfo
=
{
playerId
:
nextId
,
nickname
:
nickname
||
`玩家
${
nextId
}
`
,
team
:
team
||
'A'
};
// 加入等待表
waiting
.
joined
.
add
(
ws
);
waiting
.
players
.
push
(
playerInfo
);
joinRoom
(
ws
,
rid
);
ws
.
ctx
.
roomId
=
rid
;
ws
.
ctx
.
role
=
'minigame'
;
ws
.
ctx
.
playerId
=
nextId
;
ws
.
ctx
.
nickname
=
playerInfo
.
nickname
;
ws
.
ctx
.
team
=
playerInfo
.
team
;
const
joinedCount
=
waiting
.
joined
.
size
;
const
{
totalSeats
}
=
waiting
;
const
{
totalSeats
,
players
}
=
waiting
;
console
.
log
(
`[Room] 玩家加入房间
${
rid
}
,playerId=
${
nextId
}
,当前
${
joinedCount
}
/
${
totalSeats
}
`
);
console
.
log
(
`[Room] 玩家加入房间
${
rid
}
,playerId=
${
nextId
}
,
team=
${
playerInfo
.
team
}
,
当前
${
joinedCount
}
/
${
totalSeats
}
`
);
// 通知自己加入成功
ws
.
sendEvent
(
'room:joined'
,
{
roomId
:
rid
,
joinedCount
,
totalSeats
,
playerId
:
nextId
});
ws
.
sendEvent
(
'room:joined'
,
{
roomId
:
rid
,
joinedCount
,
totalSeats
,
playerId
:
nextId
,
players
,
myPlayerId
:
nextId
});
// 广播给房间内所有人(含房主,broadcastToRoom 排除自身,自己单独 send)
broadcastToRoom
(
rid
,
'room:playerJoined'
,
{
roomId
:
rid
,
joinedCount
,
totalSeats
});
ws
.
sendEvent
(
'room:playerJoined'
,
{
roomId
:
rid
,
joinedCount
,
totalSeats
});
broadcastToRoom
(
rid
,
'room:playerJoined'
,
{
roomId
:
rid
,
joinedCount
,
totalSeats
,
players
});
ws
.
sendEvent
(
'room:playerJoined'
,
{
roomId
:
rid
,
joinedCount
,
totalSeats
,
players
});
// 人数到齐 → 通知所有人可以开始
if
(
joinedCount
>=
totalSeats
)
{
...
...
@@ -160,8 +192,15 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms })
playerWs
.
ctx
.
sessionId
=
session
.
id
;
}
broadcastToRoom
(
rid
,
'room:allReady'
,
{
roomId
:
rid
,
sessionId
:
session
.
id
});
ws
.
sendEvent
(
'room:allReady'
,
{
roomId
:
rid
,
sessionId
:
session
.
id
});
const
{
gameDuration
=
0
}
=
waiting
;
const
allReadyPayload
=
{
roomId
:
rid
,
sessionId
:
session
.
id
,
gameDuration
};
broadcastToRoom
(
rid
,
'room:allReady'
,
allReadyPayload
);
ws
.
sendEvent
(
'room:allReady'
,
allReadyPayload
);
// 启动服务端倒计时(gameDuration > 0 才限时)
if
(
gameDuration
>
0
)
{
_startRoomTimer
(
rid
,
gameDuration
,
session
.
id
,
broadcastToRoom
);
}
}
catch
(
err
)
{
console
.
error
(
'[room:join allReady] 错误:'
,
err
);
}
...
...
@@ -172,26 +211,28 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms })
// ── room:state ─────────────────────────────────────────────────────────────
ws
.
_handlers
[
'room:state'
]
=
(
stateData
)
=>
{
const
{
roomId
,
playerId
}
=
ws
.
ctx
;
const
{
roomId
,
playerId
,
nickname
}
=
ws
.
ctx
;
if
(
!
roomId
)
return
;
// 转发给同房间其他客户端(大屏),携带 playerId 供大屏区分玩家
broadcastToRoom
(
roomId
,
'room:state'
,
{
...
stateData
,
playerId
},
ws
);
broadcastToRoom
(
roomId
,
'room:state'
,
{
...
stateData
,
playerId
,
nickname
},
ws
);
};
// ── room:gameOver ──────────────────────────────────────────────────────────
ws
.
_handlers
[
'room:gameOver'
]
=
async
({
score
}
=
{})
=>
{
const
{
roomId
,
sessionId
}
=
ws
.
ctx
;
const
{
roomId
,
sessionId
,
playerId
,
nickname
}
=
ws
.
ctx
;
if
(
!
roomId
||
!
sessionId
)
return
;
try
{
// 正常游戏结束:session + room 都标记 finished
// 停掉服务端倒计时(避免重复触发)
_clearRoomTimer
(
roomId
);
await
_finishSession
(
ws
,
roomId
,
sessionId
,
score
);
await
prisma
.
room
.
update
({
where
:
{
roomId
},
data
:
{
status
:
'finished'
},
});
broadcastToRoom
(
roomId
,
'room:gameOver'
,
{
roomId
,
score
},
ws
);
console
.
log
(
`[Room] 游戏正常结束 roomId=
${
roomId
}
score=
${
score
??
0
}
`);
// 广播给大屏(含 playerId 和分数)
broadcastToRoom
(
roomId
,
'room:gameOver'
,
{
roomId
,
score
,
playerId
,
nickname
},
ws
);
console
.
log
(
`[Room] 游戏正常结束 roomId=
${
roomId
}
playerId=
${
playerId
}
score=
${
score
??
0
}
`);
} catch (err) {
console.error('[room:gameOver] 错误:', err);
}
...
...
@@ -300,7 +341,63 @@ async function onRoomEmpty(roomId) {
data: { status: 'finished' },
});
_clearRoomTimer(roomId);
console.log(`
[
Room
]
房间
$
{
roomId
}
已标记为
finished
(连接归零触发)
`);
}
/**
* 启动房间服务端倒计时
* 时间到后广播 room:timeUp 给房间内所有人(小游戏 + 大屏)
*/
function _startRoomTimer(roomId, durationSec, sessionId, broadcastToRoom) {
// 清理旧计时器
_clearRoomTimer(roomId);
const startTime = Date.now();
const timer = setTimeout(async () => {
console.log(`
[
Room
]
房间
$
{
roomId
}
时间到,广播
room
:
timeUp
`);
// 广播给所有人(不排除任何人,broadcastToRoom excludeWs=null)
broadcastToRoom(roomId, 'room:timeUp', { roomId, durationSec });
// 写库:批量结束未完成的 session
try {
const endedAt = new Date();
const activeSessions = await prisma.gameSession.findMany({
where: { roomId, status: 'playing' },
});
for (const s of activeSessions) {
const duration = Math.round((endedAt.getTime() - s.startedAt.getTime()) / 1000);
await prisma.gameSession.update({
where: { id: s.id },
data: { status: 'finished', endedAt, duration },
});
}
await prisma.room.update({
where: { roomId },
data: { status: 'finished' },
});
console.log(`
[
Room
]
房间
$
{
roomId
}
因超时已标记
finished
`);
} catch (err) {
console.error('[timeUp] 写库错误:', err);
}
roomTimers.delete(roomId);
}, durationSec * 1000);
roomTimers.set(roomId, { timer, durationSec, startTime });
console.log(`
[
Room
]
房间
$
{
roomId
}
倒计时启动,时长
$
{
durationSec
}
s
`);
}
/**
* 清理房间倒计时
*/
function _clearRoomTimer(roomId) {
const entry = roomTimers.get(roomId);
if (entry) {
clearTimeout(entry.timer);
roomTimers.delete(roomId);
}
}
module.exports = { registerRoomHandlers, onRoomEmpty };
编写
预览
Markdown
格式
0%
重试
或
添加新文件
添加附件
取消
您添加了
0
人
到此讨论。请谨慎行事。
请先完成此评论的编辑!
取消
请
注册
或者
登录
后发表评论