提交 11fbefbe authored 作者: lidongxu's avatar lidongxu

修改api适配线上环境和数据库环境

上级 696c9185
# 生产环境配置
VITE_API_BASE_URL=https://paopao.wxl66.cn/api/api
VITE_SOCKET_URL=wss://paopao.wxl66.cn
import axios from 'axios' import axios from 'axios'
const baseURL = import.meta.env.VITE_API_BASE_URL || '/api'
const instance = axios.create({ const instance = axios.create({
baseURL: '/api', baseURL,
timeout: 10000, timeout: 10000,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
......
...@@ -4,7 +4,7 @@ let socket = null ...@@ -4,7 +4,7 @@ let socket = null
export function connectSocket(baseURL = '') { export function connectSocket(baseURL = '') {
if (socket?.connected) return socket 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, { socket = io(url, {
path: '/socket.io', path: '/socket.io',
transports: ['websocket', 'polling'], transports: ['websocket', 'polling'],
......
# 生产环境配置
VITE_SOCKET_URL=wss://paopao.wxl66.cn/ws
VITE_SCREEN_NAME=big-screen-1
...@@ -2,13 +2,13 @@ ...@@ -2,13 +2,13 @@
* 大屏展示页入口:支持多玩家并排渲染 * 大屏展示页入口:支持多玩家并排渲染
*/ */
import { initScaler } from './scaler.js' 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 { initSocket, getConnectionStatus } from './socket.js'
import { drawBackground } from './renderer/background.js' import { drawBackground } from './renderer/background.js'
import { drawBubbleGrid } from './renderer/bubbleGrid.js' import { drawBubbleGrid } from './renderer/bubbleGrid.js'
import { drawBubble3D, BUBBLE_RADIUS } from './renderer/bubble.js' import { drawBubble3D, BUBBLE_RADIUS } from './renderer/bubble.js'
import { drawShooter } from './renderer/shooter.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 { updateAndDrawExplosions, appendExplosionsFromState, Explosion } from './renderer/explosion.js'
import { drawIdleScreen } from './renderer/idleScreen.js' import { drawIdleScreen } from './renderer/idleScreen.js'
import { SCREEN_WIDTH, SCREEN_HEIGHT } from './constants.js' import { SCREEN_WIDTH, SCREEN_HEIGHT } from './constants.js'
...@@ -75,7 +75,7 @@ function renderPlayer(state, offsetX, roomId) { ...@@ -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() ctx.restore()
} }
...@@ -100,24 +100,73 @@ function drawDivider(x) { ...@@ -100,24 +100,73 @@ function drawDivider(x) {
ctx.restore() ctx.restore()
} }
// ─── 玩家编号标签 ────────────────────────────────────────────────────────────── // ─── VS 分隔线(两队中间)──────────────────────────────────────────────────────
function drawPlayerLabel(offsetX, playerId) { function drawVSDivider(x) {
const label = `P${playerId}`
const bw = 36, bh = 20, bx = offsetX + SCREEN_WIDTH / 2 - bw / 2, by = SCREEN_HEIGHT - 28
ctx.save() 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.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.fill()
ctx.stroke()
// 文字
ctx.textAlign = 'center' ctx.textAlign = 'center'
ctx.textBaseline = 'middle' ctx.textBaseline = 'middle'
ctx.font = 'bold 12px Arial' ctx.font = 'bold 16px Arial'
ctx.fillStyle = 'rgba(221,214,254,0.9)' ctx.fillStyle = color
ctx.fillText(label, offsetX + SCREEN_WIDTH / 2, by + bh / 2) ctx.fillText(label, x, by + bh / 2)
ctx.restore() ctx.restore()
} }
// ─── 主循环 ─────────────────────────────────────────────────────────────────── // ─── 主循环 ───────────────────────────────────────────────────────────────────
let _lastPlayerCount = 1 let _lastPlayerCount = 1
...@@ -129,10 +178,15 @@ function loop() { ...@@ -129,10 +178,15 @@ function loop() {
const connStatus = getConnectionStatus() const connStatus = getConnectionStatus()
const playerCount = states.length || 1 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) { if (totalSlots !== _lastPlayerCount) {
_lastPlayerCount = playerCount _lastPlayerCount = totalSlots
applyScaler(playerCount) applyScaler(totalSlots)
// 清理消失玩家的爆炸列表 // 清理消失玩家的爆炸列表
for (const pid of playerExplosions.keys()) { for (const pid of playerExplosions.keys()) {
if (!states.find(s => (s.playerId ?? 1) === pid)) { if (!states.find(s => (s.playerId ?? 1) === pid)) {
...@@ -144,22 +198,66 @@ function loop() { ...@@ -144,22 +198,66 @@ function loop() {
// 绘制背景(横向铺满整个大屏) // 绘制背景(横向铺满整个大屏)
ctx.save() ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0) ctx.setTransform(1, 0, 0, 1, 0, 0)
drawBackground(ctx, SCREEN_WIDTH * playerCount, SCREEN_HEIGHT) drawBackground(ctx, SCREEN_WIDTH * totalSlots, SCREEN_HEIGHT)
ctx.restore() ctx.restore()
if (states.length > 0) { if (states.length > 0) {
// ── 多玩家并排渲染 ───────────────────────────────────────────────────── // ── 按队伍分组渲染:A队左,B队右 ────────────────────────────────────────
states.forEach((state, idx) => { let currentOffsetX = 0
const offsetX = idx * SCREEN_WIDTH
// 绘制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) if (idx > 0) drawDivider(offsetX)
renderPlayer(state, offsetX, roomId) renderPlayer(state, offsetX, roomId)
// 玩家编号(多于1人时显示) currentOffsetX += SCREEN_WIDTH
if (states.length > 1) drawPlayerLabel(offsetX, state.playerId ?? idx + 1)
}) })
// ── 检测是否所有玩家都结束,显示队伍比分 ─────────────────────────────
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 { } else {
// ── 空闲等待画面 ────────────────────────────────────────────────────── // ── 空闲等待画面 ──────────────────────────────────────────────────────
drawIdleScreen(ctx, SCREEN_WIDTH, SCREEN_HEIGHT, roomId, connStatus, SCREEN_NAME, frameCount) drawIdleScreen(ctx, SCREEN_WIDTH, SCREEN_HEIGHT, roomId, connStatus, SCREEN_NAME, frameCount)
......
...@@ -174,7 +174,7 @@ function drawOverlay(ctx) { ...@@ -174,7 +174,7 @@ function drawOverlay(ctx) {
ctx.restore() ctx.restore()
} }
function drawGameOverCard(ctx, score) { function drawGameOverCard(ctx, score, nickname = '') {
const cw = 300 const cw = 300
const ch = 360 const ch = 360
const cx = SCREEN_WIDTH / 2 const cx = SCREEN_WIDTH / 2
...@@ -227,6 +227,13 @@ function drawGameOverCard(ctx, score) { ...@@ -227,6 +227,13 @@ function drawGameOverCard(ctx, score) {
ctx.fillText('游戏结束', cx, cardY + 68) ctx.fillText('游戏结束', cx, cardY + 68)
ctx.shadowBlur = 0 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) const dg = ctx.createLinearGradient(cardX + 20, 0, cardX + cw - 20, 0)
dg.addColorStop(0, 'rgba(255,255,255,0)') dg.addColorStop(0, 'rgba(255,255,255,0)')
...@@ -244,6 +251,7 @@ function drawGameOverCard(ctx, score) { ...@@ -244,6 +251,7 @@ function drawGameOverCard(ctx, score) {
ctx.fillStyle = 'rgba(255,255,255,0.5)' ctx.fillStyle = 'rgba(255,255,255,0.5)'
ctx.fillText('本局得分', cx, cardY + 140) ctx.fillText('本局得分', cx, cardY + 140)
// 分数数字 // 分数数字
ctx.shadowColor = 'rgba(251,191,36,0.7)' ctx.shadowColor = 'rgba(251,191,36,0.7)'
ctx.shadowBlur = 30 ctx.shadowBlur = 30
...@@ -273,13 +281,261 @@ function drawGameOverCard(ctx, score) { ...@@ -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) drawScoreCard(ctx, score)
if (roomId !== '' && roomId !== null && roomId !== undefined) { if (roomId !== '' && roomId !== null && roomId !== undefined) {
drawRoomCard(ctx, roomId) drawRoomCard(ctx, roomId)
} }
// 游戏进行中底部显示昵称
if (nickname && !isGameOver) {
drawNicknameBar(ctx, nickname)
}
if (isGameOver) { if (isGameOver) {
drawOverlay(ctx) 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()
}
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
* 发送 → JSON.stringify({ event: string, data: any }) * 发送 → JSON.stringify({ event: string, data: any })
* 接收 ← 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' const defaultServerUrl = import.meta.env.VITE_SOCKET_URL || 'ws://localhost:3000/ws'
...@@ -100,10 +100,49 @@ function _dispatch(event, data) { ...@@ -100,10 +100,49 @@ function _dispatch(event, data) {
* 接收小游戏实时状态帧 * 接收小游戏实时状态帧
*/ */
case 'room:state': { case 'room:state': {
// 如果状态中有 team 信息,更新玩家队伍
if (data?.playerId && data?.team) {
setPlayerTeam(data.playerId, data.team)
}
setGameState(data) setGameState(data)
break 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': { case 'screen:joined': {
console.log('[Socket] 大屏注册成功:', data) console.log('[Socket] 大屏注册成功:', data)
break break
......
...@@ -8,6 +8,9 @@ let currentRoomId = null ...@@ -8,6 +8,9 @@ let currentRoomId = null
/** Map<playerId, state> */ /** Map<playerId, state> */
const playerStates = new Map() const playerStates = new Map()
/** 玩家队伍信息 Map<playerId, team> */
const playerTeams = new Map()
export function setCurrentRoom(roomId) { export function setCurrentRoom(roomId) {
currentRoomId = roomId currentRoomId = roomId
} }
...@@ -16,6 +19,20 @@ export function getCurrentRoom() { ...@@ -16,6 +19,20 @@ export function getCurrentRoom() {
return currentRoomId return currentRoomId
} }
/**
* 设置玩家队伍信息
*/
export function setPlayerTeam(playerId, team) {
playerTeams.set(playerId, team)
}
/**
* 获取玩家队伍信息
*/
export function getPlayerTeam(playerId) {
return playerTeams.get(playerId) || 'A'
}
/** /**
* 更新某个玩家的状态 * 更新某个玩家的状态
* state 中必须含 playerId 字段 * state 中必须含 playerId 字段
...@@ -23,9 +40,35 @@ export function getCurrentRoom() { ...@@ -23,9 +40,35 @@ export function getCurrentRoom() {
export function setGameState(state) { export function setGameState(state) {
if (!state) return if (!state) return
const pid = state.playerId ?? 1 const pid = state.playerId ?? 1
// 已结束的玩家保留 isGameOver 标记,不被新帧覆盖
const prev = playerStates.get(pid)
if (prev && prev.isGameOver && !state.isGameOver) return
playerStates.set(pid, state) 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 升序排列 * 获取所有玩家状态,按 playerId 升序排列
* @returns {Array<state>} * @returns {Array<state>}
...@@ -47,4 +90,5 @@ export function getGameState() { ...@@ -47,4 +90,5 @@ export function getGameState() {
export function clearGameState() { export function clearGameState() {
playerStates.clear() playerStates.clear()
playerTeams.clear()
} }
# 线上生产环境配置
# 服务端口
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
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
"main": "src/app.js", "main": "src/app.js",
"scripts": { "scripts": {
"start": "node src/app.js", "start": "node src/app.js",
"start:prod": "node -r dotenv/config src/app.js dotenv_config_path=.env.production",
"dev": "nodemon src/app.js", "dev": "nodemon src/app.js",
"db:generate": "prisma generate --schema=src/prisma/schema.prisma", "db:generate": "prisma generate --schema=src/prisma/schema.prisma",
"db:migrate": "prisma migrate dev --schema=src/prisma/schema.prisma", "db:migrate": "prisma migrate dev --schema=src/prisma/schema.prisma",
......
require('dotenv').config(); const fs = require('fs');
const path = require('path');
const http = require('http'); 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 express = require('express');
const cors = require('cors'); const cors = require('cors');
const { initSocket } = require('./socket'); const { initSocket } = require('./socket');
const app = express(); 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({ app.use(cors({
......
const prisma = require('../prisma/client'); const prisma = require('../prisma/client');
/** /**
* 内存等待表:roomId → { totalSeats, joined: Set<ws> } * 内存等待表:roomId → { totalSeats, joined: Set<ws>, players: Array }
* 房间满员或游戏开始后从表中移除 * 房间满员或游戏开始后从表中移除
*/ */
const waitingRooms = new Map(); const waitingRooms = new Map();
...@@ -9,6 +9,12 @@ const waitingRooms = new Map(); ...@@ -9,6 +9,12 @@ const waitingRooms = new Map();
/** 房间玩家计数:roomId → number,用于分配递增的 playerId */ /** 房间玩家计数:roomId → number,用于分配递增的 playerId */
const roomPlayerCounter = new Map(); const roomPlayerCounter = new Map();
/**
* 房间倒计时表:roomId → { timer, duration, startTime }
* allReady 后启动,时间到广播 room:timeUp
*/
const roomTimers = new Map();
/** /**
* 注册房间相关 WebSocket 事件处理 * 注册房间相关 WebSocket 事件处理
* *
...@@ -37,14 +43,15 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms }) ...@@ -37,14 +43,15 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms })
// ── room:create ──────────────────────────────────────────────────────────── // ── room:create ────────────────────────────────────────────────────────────
// 房主创建房间,写库并进入等待状态 // 房主创建房间,写库并进入等待状态
ws._handlers['room:create'] = async ({ roomId, totalSeats } = {}) => { ws._handlers['room:create'] = async ({ roomId, totalSeats, gameDuration, nickname, team } = {}) => {
if (!roomId || !totalSeats) { if (!roomId || !totalSeats) {
ws.sendEvent('error', { message: '缺少 roomId 或 totalSeats' }); ws.sendEvent('error', { message: '缺少 roomId 或 totalSeats' });
return; return;
} }
const rid = String(roomId); const rid = String(roomId);
const seats = Number(totalSeats); const seats = Number(totalSeats);
const duration = Number(gameDuration) || 0; // 0 表示不限时
try { try {
// 写库:waiting 状态 // 写库:waiting 状态
...@@ -54,22 +61,37 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms }) ...@@ -54,22 +61,37 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms })
create: { roomId: rid, status: 'waiting', totalSeats: seats }, 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); joinRoom(ws, rid);
ws.ctx.roomId = rid; ws.ctx.roomId = rid;
ws.ctx.role = 'minigame'; 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); roomPlayerCounter.set(rid, 1);
console.log(`[Room] 创建房间 ${rid},总座位: ${seats}`); console.log(`[Room] 创建房间 ${rid},总座位: ${seats},房主: ${playerInfo.nickname} (${playerInfo.team}队)`);
ws.sendEvent('room:created', { ws.sendEvent('room:created', {
roomId: rid, roomId: rid,
totalSeats: seats, totalSeats: seats,
joinedCount: 1, joinedCount: 1,
playerId: 1, playerId: 1,
players: [playerInfo]
}); });
} catch (err) { } catch (err) {
console.error('[room:create] 错误:', err); console.error('[room:create] 错误:', err);
...@@ -79,7 +101,7 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms }) ...@@ -79,7 +101,7 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms })
// ── room:join ────────────────────────────────────────────────────────────── // ── room:join ──────────────────────────────────────────────────────────────
// 其他玩家凭房间号加入等待中的房间 // 其他玩家凭房间号加入等待中的房间
ws._handlers['room:join'] = async ({ roomId } = {}) => { ws._handlers['room:join'] = async ({ roomId, nickname, team } = {}) => {
if (!roomId) { if (!roomId) {
ws.sendEvent('error', { message: '缺少 roomId' }); ws.sendEvent('error', { message: '缺少 roomId' });
return; return;
...@@ -120,28 +142,38 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms }) ...@@ -120,28 +142,38 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms })
return; return;
} }
// 加入等待表
waiting.joined.add(ws);
joinRoom(ws, rid);
ws.ctx.roomId = rid;
ws.ctx.role = 'minigame';
// 分配递增 playerId // 分配递增 playerId
const nextId = (roomPlayerCounter.get(rid) || 0) + 1; const nextId = (roomPlayerCounter.get(rid) || 0) + 1;
roomPlayerCounter.set(rid, nextId); 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.playerId = nextId;
ws.ctx.nickname = playerInfo.nickname;
ws.ctx.team = playerInfo.team;
const joinedCount = waiting.joined.size; 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 排除自身,自己单独 send)
broadcastToRoom(rid, 'room:playerJoined', { roomId: rid, joinedCount, totalSeats }); broadcastToRoom(rid, 'room:playerJoined', { roomId: rid, joinedCount, totalSeats, players });
ws.sendEvent('room:playerJoined', { roomId: rid, joinedCount, totalSeats }); ws.sendEvent('room:playerJoined', { roomId: rid, joinedCount, totalSeats, players });
// 人数到齐 → 通知所有人可以开始 // 人数到齐 → 通知所有人可以开始
if (joinedCount >= totalSeats) { if (joinedCount >= totalSeats) {
...@@ -160,8 +192,15 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms }) ...@@ -160,8 +192,15 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms })
playerWs.ctx.sessionId = session.id; playerWs.ctx.sessionId = session.id;
} }
broadcastToRoom(rid, 'room:allReady', { roomId: rid, sessionId: session.id }); const { gameDuration = 0 } = waiting;
ws.sendEvent('room:allReady', { roomId: rid, sessionId: session.id }); 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) { } catch (err) {
console.error('[room:join allReady] 错误:', err); console.error('[room:join allReady] 错误:', err);
} }
...@@ -172,26 +211,28 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms }) ...@@ -172,26 +211,28 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms })
// ── room:state ───────────────────────────────────────────────────────────── // ── room:state ─────────────────────────────────────────────────────────────
ws._handlers['room:state'] = (stateData) => { ws._handlers['room:state'] = (stateData) => {
const { roomId, playerId } = ws.ctx; const { roomId, playerId, nickname } = ws.ctx;
if (!roomId) return; if (!roomId) return;
// 转发给同房间其他客户端(大屏),携带 playerId 供大屏区分玩家 broadcastToRoom(roomId, 'room:state', { ...stateData, playerId, nickname }, ws);
broadcastToRoom(roomId, 'room:state', { ...stateData, playerId }, ws);
}; };
// ── room:gameOver ────────────────────────────────────────────────────────── // ── room:gameOver ──────────────────────────────────────────────────────────
ws._handlers['room:gameOver'] = async ({ score } = {}) => { ws._handlers['room:gameOver'] = async ({ score } = {}) => {
const { roomId, sessionId } = ws.ctx; const { roomId, sessionId, playerId, nickname } = ws.ctx;
if (!roomId || !sessionId) return; if (!roomId || !sessionId) return;
try { try {
// 正常游戏结束:session + room 都标记 finished // 停掉服务端倒计时(避免重复触发)
_clearRoomTimer(roomId);
await _finishSession(ws, roomId, sessionId, score); await _finishSession(ws, roomId, sessionId, score);
await prisma.room.update({ await prisma.room.update({
where: { roomId }, where: { roomId },
data: { status: 'finished' }, data: { status: 'finished' },
}); });
broadcastToRoom(roomId, 'room:gameOver', { roomId, score }, ws); // 广播给大屏(含 playerId 和分数)
console.log(`[Room] 游戏正常结束 roomId=${roomId} score=${score ?? 0}`); broadcastToRoom(roomId, 'room:gameOver', { roomId, score, playerId, nickname }, ws);
console.log(`[Room] 游戏正常结束 roomId=${roomId} playerId=${playerId} score=${score ?? 0}`);
} catch (err) { } catch (err) {
console.error('[room:gameOver] 错误:', err); console.error('[room:gameOver] 错误:', err);
} }
...@@ -300,7 +341,63 @@ async function onRoomEmpty(roomId) { ...@@ -300,7 +341,63 @@ async function onRoomEmpty(roomId) {
data: { status: 'finished' }, data: { status: 'finished' },
}); });
_clearRoomTimer(roomId);
console.log(`[Room] 房间 ${roomId} 已标记为 finished(连接归零触发)`); 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 }; module.exports = { registerRoomHandlers, onRoomEmpty };
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论