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 个修改的文件
包含
514 行增加
和
30 行删除
+514
-30
.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
+0
-0
没有找到文件。
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
.
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
,
1
0
)
ctx
.
roundRect
(
bx
,
by
,
bw
,
bh
,
1
6
)
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
差异被折叠。
点击展开。
编写
预览
Markdown
格式
0%
重试
或
添加新文件
添加附件
取消
您添加了
0
人
到此讨论。请谨慎行事。
请先完成此评论的编辑!
取消
请
注册
或者
登录
后发表评论