提交 9a27f2a4 authored 作者: lidongxu's avatar lidongxu

大屏幕效果ok版本

上级 c8fd4e62
......@@ -2,13 +2,13 @@
* 大屏展示页入口:支持多玩家并排渲染
*/
import { initScaler } from './scaler.js'
import { getAllPlayerStates, clearGameState, getCurrentRoom, getPlayerTeam, getCountdown } from './stateManager.js'
import { getAllPlayerStates, clearGameState, getCurrentRoom, getPlayerTeam, getCountdown, getRoomTimer } from './stateManager.js'
import { initSocket, getConnectionStatus } from './socket.js'
import { drawBackground } from './renderer/background.js'
import { drawBubbleGrid } from './renderer/bubbleGrid.js'
import { drawBubble3D, getBubbleRadius, configureBubbleLayout } from './renderer/bubble.js'
import { drawShooter } from './renderer/shooter.js'
import { drawGameInfo, drawTeamResultOverlay } from './renderer/gameinfo.js'
import { drawGameInfo, drawTeamResultOverlay, drawRoomTimerCard } from './renderer/gameinfo.js'
import { detectAndCreateBursts, updateAndDrawBursts, clearPrevGrid } from './renderer/explosion.js'
import { drawIdleScreen } from './renderer/idleScreen.js'
import { SCREEN_WIDTH, SCREEN_HEIGHT, configureScreenRatio, configureSafeArea } from './constants.js'
......@@ -25,6 +25,10 @@ gameBgImg.src = GAME_BG_URL
/** 每个玩家独立的碎裂效果列表:Map<playerId, BubbleBurst[]> */
const playerBursts = new Map()
const PLAYER_FRAME_MARGIN_X = 18
const PLAYER_FRAME_MARGIN_Y = 18
const PLAYER_FRAME_RADIUS = 18
let frameCount = 0
// ─── 缩放 ─────────────────────────────────────────────────────────────────────
......@@ -50,10 +54,76 @@ function drawPlayerGameBackground() {
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
}
function roundRectPath(x, y, w, h, r) {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.arcTo(x + w, y, x + w, y + r, r)
ctx.lineTo(x + w, y + h - r)
ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
ctx.lineTo(x + r, y + h)
ctx.arcTo(x, y + h, x, y + h - r, r)
ctx.lineTo(x, y + r)
ctx.arcTo(x, y, x + r, y, r)
ctx.closePath()
}
function getPlayerFrameRect() {
return {
x: PLAYER_FRAME_MARGIN_X,
y: PLAYER_FRAME_MARGIN_Y,
w: SCREEN_WIDTH - PLAYER_FRAME_MARGIN_X * 2,
h: SCREEN_HEIGHT - PLAYER_FRAME_MARGIN_Y * 2,
r: PLAYER_FRAME_RADIUS,
}
}
function drawPlayerFrameShell(frame) {
ctx.save()
ctx.shadowColor = 'rgba(0,0,0,0.38)'
ctx.shadowBlur = 24
ctx.shadowOffsetY = 10
const bg = ctx.createLinearGradient(frame.x, frame.y, frame.x, frame.y + frame.h)
bg.addColorStop(0, 'rgba(20,10,42,0.30)')
bg.addColorStop(1, 'rgba(8,4,22,0.46)')
ctx.fillStyle = bg
roundRectPath(frame.x, frame.y, frame.w, frame.h, frame.r)
ctx.fill()
ctx.restore()
}
function drawPlayerFrameBorder(frame) {
ctx.save()
ctx.lineWidth = 3
ctx.strokeStyle = 'rgba(251,191,36,0.82)'
ctx.shadowColor = 'rgba(251,191,36,0.28)'
ctx.shadowBlur = 18
roundRectPath(frame.x, frame.y, frame.w, frame.h, frame.r)
ctx.stroke()
ctx.shadowBlur = 0
ctx.save()
roundRectPath(frame.x, frame.y, frame.w, frame.h, frame.r)
ctx.clip()
const hl = ctx.createLinearGradient(frame.x, frame.y, frame.x, frame.y + frame.h * 0.22)
hl.addColorStop(0, 'rgba(255,255,255,0.22)')
hl.addColorStop(1, 'rgba(255,255,255,0)')
ctx.fillStyle = hl
ctx.fillRect(frame.x, frame.y, frame.w, frame.h * 0.22)
ctx.restore()
ctx.restore()
}
// ─── 单个玩家画面渲染 ──────────────────────────────────────────────────────────
function renderPlayer(state, offsetX, roomId) {
const pid = state.playerId ?? 1
const frame = getPlayerFrameRect()
const contentScale = Math.min(frame.w / SCREEN_WIDTH, frame.h / SCREEN_HEIGHT)
const contentW = SCREEN_WIDTH * contentScale
const contentH = SCREEN_HEIGHT * contentScale
const contentX = frame.x + (frame.w - contentW) / 2
const contentY = frame.y + (frame.h - contentH) / 2
// 从 grid 数据推算偶数行列数,动态调整泡泡大小
// 偶数行(row 0)的列数决定了整个网格的列配置
......@@ -68,6 +138,14 @@ function renderPlayer(state, offsetX, roomId) {
ctx.save()
ctx.translate(offsetX, 0)
drawPlayerFrameShell(frame)
ctx.save()
roundRectPath(frame.x, frame.y, frame.w, frame.h, frame.r)
ctx.clip()
ctx.translate(contentX, contentY)
ctx.scale(contentScale, contentScale)
drawPlayerGameBackground()
// 泡泡网格
......@@ -100,13 +178,17 @@ function renderPlayer(state, offsetX, roomId) {
drawGameInfo(ctx, state.score ?? 0, state.isGameOver ?? false, roomId ?? state.roomId ?? '', state.nickname ?? '')
ctx.restore()
drawPlayerFrameBorder(frame)
ctx.restore()
}
// ─── 多玩家分隔线 ──────────────────────────────────────────────────────────────
function drawDivider(x) {
ctx.save()
const grad = ctx.createLinearGradient(x, 0, x, SCREEN_HEIGHT)
const top = PLAYER_FRAME_MARGIN_Y + 26
const bottom = SCREEN_HEIGHT - PLAYER_FRAME_MARGIN_Y - 26
const grad = ctx.createLinearGradient(x, top, x, bottom)
grad.addColorStop(0, 'rgba(139,92,246,0)')
grad.addColorStop(0.2, 'rgba(139,92,246,0.5)')
grad.addColorStop(0.8, 'rgba(139,92,246,0.5)')
......@@ -115,8 +197,8 @@ function drawDivider(x) {
ctx.lineWidth = 2
ctx.setLineDash([6, 6])
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x, SCREEN_HEIGHT)
ctx.moveTo(x, top)
ctx.lineTo(x, bottom)
ctx.stroke()
ctx.setLineDash([])
ctx.restore()
......@@ -126,32 +208,36 @@ function drawDivider(x) {
function drawVSDivider(x) {
ctx.save()
// 发光效果
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)
const centerY = SCREEN_HEIGHT / 2
const pillW = 58
const pillH = 70
const pillX = x - pillW / 2
const pillY = centerY - pillH / 2
ctx.fillStyle = 'rgba(12,18,36,0.82)'
ctx.strokeStyle = 'rgba(103,232,249,0.9)'
ctx.lineWidth = 2
ctx.shadowColor = 'rgba(34,211,238,0.35)'
ctx.shadowBlur = 18
roundRectPath(pillX, pillY, pillW, pillH, 16)
ctx.fill()
ctx.stroke()
ctx.shadowBlur = 0
ctx.fillStyle = 'rgba(125,211,252,0.95)'
ctx.fillRect(pillX + 8, pillY + 8, pillW - 16, 3)
// VS 文字
// V|S 文字
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.font = 'bold 44px Arial'
ctx.fillStyle = '#F8FAFC'
ctx.strokeStyle = 'rgba(8,15,34,0.9)'
ctx.lineWidth = 4
ctx.shadowColor = 'rgba(103,232,249,0.65)'
ctx.shadowBlur = 16
ctx.strokeText('V|S', x, centerY + 1)
ctx.fillText('V|S', x, centerY)
ctx.shadowBlur = 0
ctx.restore()
}
......@@ -212,6 +298,9 @@ function loop() {
const roomId = getCurrentRoom()
const connStatus = getConnectionStatus()
const playerCount = states.length || 1
const totalWidth = SCREEN_WIDTH * Math.max(playerCount, 1)
const roomTimer = getRoomTimer()
let allGameOver = false
// 从任意一个玩家的 state 中提取屏幕比例和安全区域,在渲染前统一配置
if (states.length > 0 && states[0].screenRatio) {
......@@ -245,7 +334,7 @@ function loop() {
ctx.restore()
if (states.length > 0) {
const allGameOver = states.every(s => s.isGameOver)
allGameOver = states.every(s => s.isGameOver)
if (allGameOver && states.length > 1) {
// 最终结果页使用干净背景,不再保留各玩家游戏盘面
......@@ -257,43 +346,47 @@ function loop() {
drawTeamResultOverlay(ctx, states, getPlayerTeam, totalWidth)
ctx.restore()
} else {
// ── 按队伍分组渲染:A队左,B队右 ────────────────────────────────────────
// ── 按队伍分组渲染:A队左,B队右 ────────────────────────────────────────
let currentOffsetX = 0
const dividerXs = []
const hasBothTeams = teamAStates.length > 0 && teamBStates.length > 0
// 渲染A队玩家
teamAStates.forEach((state, idx) => {
const offsetX = currentOffsetX
// 分隔线(A队内部)
if (idx > 0) drawDivider(offsetX)
// 记录分隔线(A队内部)
if (idx > 0) dividerXs.push(offsetX)
renderPlayer(state, offsetX, roomId)
currentOffsetX += SCREEN_WIDTH
})
// 绘制 VS 分隔线(A队和B队之间)
if (teamAStates.length > 0 && teamBStates.length > 0) {
drawVSDivider(currentOffsetX)
}
// 渲染B队玩家
teamBStates.forEach((state, idx) => {
const offsetX = currentOffsetX
// 分隔线(B队内部)
if (idx > 0) drawDivider(offsetX)
// 记录分隔线(B队内部)
if (idx > 0) dividerXs.push(offsetX)
renderPlayer(state, offsetX, roomId)
currentOffsetX += SCREEN_WIDTH
})
dividerXs.forEach(drawDivider)
if (hasBothTeams) drawVSDivider(totalWidth / 2)
}
} else {
// ── 空闲等待画面 ──────────────────────────────────────────────────────
drawIdleScreen(ctx, SCREEN_WIDTH, SCREEN_HEIGHT, roomId, connStatus, SCREEN_NAME, frameCount)
}
if (states.length > 0 && !allGameOver && roomTimer.active) {
drawRoomTimerCard(ctx, roomTimer.remainingSec, totalWidth)
}
// ── 倒计时覆盖层(服务端驱动,所有端同步)──────────────────────────────
drawCountdownOverlay(SCREEN_WIDTH * totalSlots)
......
......@@ -6,6 +6,15 @@
*/
import { SCREEN_WIDTH, SCREEN_HEIGHT, SAFE_AREA_TOP } from '../constants.js'
const TEAM_NAME_MAP = {
A: '深耕队',
B: '致远队',
}
function getTeamDisplayName(team) {
return TEAM_NAME_MAP[team] || team
}
// ─── 工具 ─────────────────────────────────────────────────────────────────────
function roundRectPath(ctx, x, y, w, h, r) {
......@@ -154,6 +163,69 @@ function drawRoomCard(ctx, roomId) {
ctx.fillText(label, cx, boxY + 38)
}
function formatRemainTime(remainingSec) {
const safeSec = Math.max(0, Math.floor(remainingSec || 0))
const minutes = String(Math.floor(safeSec / 60)).padStart(2, '0')
const seconds = String(safeSec % 60).padStart(2, '0')
return `${minutes}:${seconds}`
}
export function drawRoomTimerCard(ctx, remainingSec, totalWidth = SCREEN_WIDTH) {
const label = formatRemainTime(remainingSec)
const boxY = SAFE_AREA_TOP + 8
const boxH = 44
const r = 10
ctx.save()
ctx.font = 'bold 22px Arial'
const timeW = ctx.measureText(label).width
ctx.font = 'bold 11px Arial'
const titleW = ctx.measureText('时间').width
const boxW = Math.max(timeW, titleW) + 34
const boxX = totalWidth / 2 - boxW / 2
ctx.shadowColor = 'rgba(0,0,0,0.45)'
ctx.shadowBlur = 10
ctx.shadowOffsetY = 4
const bg = ctx.createLinearGradient(boxX, boxY, boxX, boxY + boxH)
bg.addColorStop(0, 'rgba(139,92,246,0.92)')
bg.addColorStop(1, 'rgba(124,58,237,0.97)')
ctx.fillStyle = bg
roundRectPath(ctx, boxX, boxY, boxW, boxH, r)
ctx.fill()
ctx.shadowBlur = 0
const hl = ctx.createLinearGradient(boxX, boxY, boxX + boxW, boxY)
hl.addColorStop(0, 'rgba(167,139,250,0.55)')
hl.addColorStop(0.5, 'rgba(196,181,253,0.88)')
hl.addColorStop(1, 'rgba(167,139,250,0.55)')
ctx.fillStyle = hl
ctx.fillRect(boxX + 4, boxY + 4, boxW - 8, 3)
ctx.strokeStyle = 'rgba(167,139,250,0.55)'
ctx.lineWidth = 1.5
roundRectPath(ctx, boxX, boxY, boxW, boxH, r)
ctx.stroke()
const cx = boxX + boxW / 2
ctx.textAlign = 'center'
ctx.textBaseline = 'alphabetic'
ctx.font = 'bold 11px Arial'
ctx.fillStyle = 'rgba(221,214,254,0.95)'
ctx.fillText('时间', cx, boxY + 18)
ctx.font = 'bold 22px Arial'
ctx.fillStyle = 'rgba(0,0,0,0.32)'
ctx.fillText(label, cx + 1, boxY + 39)
const tg = ctx.createLinearGradient(cx - 35, 0, cx + 35, 0)
tg.addColorStop(0, '#FDE68A')
tg.addColorStop(0.5, '#FCD34D')
tg.addColorStop(1, '#F59E0B')
ctx.fillStyle = tg
ctx.fillText(label, cx, boxY + 38)
ctx.restore()
}
// ─── 游戏结束大卡片 ───────────────────────────────────────────────────────────
function getStarCount(score) {
......@@ -362,7 +434,7 @@ export function drawTeamResultOverlay(ctx, playerStates, getPlayerTeam, totalWid
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = 'bold 42px Arial'
const titleText = isDraw ? '平局!' : `${winner}胜利!`
const titleText = isDraw ? '平局!' : `${getTeamDisplayName(winner)}胜利!`
const titleColor = isDraw ? '#FCD34D' : winner === 'A' ? '#8B5CF6' : '#EC4899'
ctx.shadowColor = titleColor
ctx.shadowBlur = 25
......@@ -485,7 +557,7 @@ function drawTeamScoreBig(ctx, x, y, team, score, isWinner) {
ctx.textBaseline = 'middle'
ctx.font = 'bold 18px Arial'
ctx.fillStyle = isWinner ? color : 'rgba(150,150,150,0.6)'
ctx.fillText(`${team}队`, x, y - 28)
ctx.fillText(getTeamDisplayName(team), x, y - 28)
// 分数(胜利方更大更亮,失败方灰色暗淡)
ctx.font = isWinner ? 'bold 56px Arial' : 'bold 42px Arial'
......@@ -520,7 +592,7 @@ function drawTeamPlayerList(ctx, x, y, w, h, team, players, isWinner) {
ctx.textBaseline = 'middle'
ctx.font = 'bold 16px Arial'
ctx.fillStyle = displayColor
ctx.fillText(`${team}成员`, x + w / 2, y + 12)
ctx.fillText(`${getTeamDisplayName(team)}成员`, x + w / 2, y + 12)
// 分割线
ctx.strokeStyle = isWinner ? color + '40' : 'rgba(150,150,150,0.2)'
......
......@@ -5,7 +5,7 @@
* 发送 → JSON.stringify({ event: string, data: any })
* 接收 ← JSON.stringify({ event: string, data: any })
*/
import { setCurrentRoom, setGameState, getPlayerState, clearGameState, setPlayerGameOver, setAllGameOver, setPlayerTeam, setCountdown, clearCountdown } from './stateManager.js'
import { setCurrentRoom, setGameState, getPlayerState, clearGameState, setPlayerGameOver, setAllGameOver, setPlayerTeam, setCountdown, clearCountdown, setRoomTimer, clearRoomTimer } from './stateManager.js'
/**
* WebSocket 服务器地址配置
......@@ -109,6 +109,8 @@ function _dispatch(event, data) {
case 'screen:roomChanged': {
const roomId = data?.roomId ?? null
console.log('[Socket] screen:roomChanged, roomId:', roomId)
clearCountdown()
clearRoomTimer()
setCurrentRoom(roomId)
if (!roomId) clearGameState()
break
......@@ -186,6 +188,7 @@ function _dispatch(event, data) {
case 'room:gameStart': {
console.log('[Socket] room:gameStart')
clearCountdown()
setRoomTimer(data?.gameDuration ?? 0)
break
}
......@@ -195,6 +198,7 @@ function _dispatch(event, data) {
*/
case 'room:timeUp': {
console.log('[Socket] room:timeUp,所有玩家游戏结束')
clearRoomTimer()
setAllGameOver()
break
}
......
......@@ -14,6 +14,9 @@ const playerTeams = new Map()
/** 倒计时状态 */
let countdownState = { active: false, value: 0 }
/** 游戏进行中的房间计时状态 */
let roomTimerState = { active: false, durationSec: 0, startAtMs: 0 }
export function setCurrentRoom(roomId) {
currentRoomId = roomId
}
......@@ -129,8 +132,50 @@ export function getCountdown() {
return countdownState
}
/**
* 设置房间游戏计时
*/
export function setRoomTimer(durationSec, startAtMs = Date.now()) {
const safeDuration = Number(durationSec) || 0
if (safeDuration <= 0) {
roomTimerState = { active: false, durationSec: 0, startAtMs: 0 }
return
}
roomTimerState = {
active: true,
durationSec: safeDuration,
startAtMs,
}
}
/**
* 清除房间游戏计时
*/
export function clearRoomTimer() {
roomTimerState = { active: false, durationSec: 0, startAtMs: 0 }
}
/**
* 获取当前房间剩余时间
*/
export function getRoomTimer(now = Date.now()) {
if (!roomTimerState.active || roomTimerState.durationSec <= 0) {
return { active: false, remainingSec: 0, durationSec: 0 }
}
const elapsedSec = Math.max(0, Math.floor((now - roomTimerState.startAtMs) / 1000))
const remainingSec = Math.max(0, roomTimerState.durationSec - elapsedSec)
return {
active: true,
durationSec: roomTimerState.durationSec,
remainingSec,
}
}
export function clearGameState() {
playerStates.clear()
playerTeams.clear()
countdownState = { active: false, value: 0 }
roomTimerState = { active: false, durationSec: 0, startAtMs: 0 }
}
const prisma = require('../prisma/client');
const TEAM_NAME_MAP = {
A: '深耕队',
B: '致远队',
};
function getTeamDisplayName(team) {
return TEAM_NAME_MAP[team] || team;
}
/**
* 内存等待表:roomId → { totalSeats, joined: Set<ws>, players: Array }
* 房间满员或游戏开始后从表中移除
......@@ -102,7 +111,7 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms, ro
ws.ctx.team = playerInfo.team;
roomPlayerCounter.set(rid, 1);
console.log(`[Room] 创建房间 ${rid},总座位: ${seats},房主: ${playerInfo.nickname} (${playerInfo.team})`);
console.log(`[Room] 创建房间 ${rid},总座位: ${seats},房主: ${playerInfo.nickname} (${getTeamDisplayName(playerInfo.team)})`);
ws.sendEvent('room:created', {
roomId: rid,
totalSeats: seats,
......@@ -172,10 +181,10 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms, ro
const teamBCount = waiting.players.filter(p => p.team === 'B').length;
if (finalTeam === 'A' && teamACount >= perTeamSeats) {
ws.sendEvent('error', { message: 'A队已满,请选择其他队伍', code: 'TEAM_FULL' });
ws.sendEvent('error', { message: `${getTeamDisplayName('A')}已满,请选择其他队伍`, code: 'TEAM_FULL' });
return;
} else if (finalTeam === 'B' && teamBCount >= perTeamSeats) {
ws.sendEvent('error', { message: 'B队已满,请选择其他队伍', code: 'TEAM_FULL' });
ws.sendEvent('error', { message: `${getTeamDisplayName('B')}已满,请选择其他队伍`, code: 'TEAM_FULL' });
return;
}
......@@ -203,7 +212,7 @@ function registerRoomHandlers(ws, { broadcastToRoom, joinRoom, leaveAllRooms, ro
const joinedCount = waiting.joined.size;
const { totalSeats, players } = waiting;
console.log(`[Room] 玩家加入房间 ${rid},playerId=${nextId},team=${playerInfo.team},当前 ${joinedCount}/${totalSeats}`);
console.log(`[Room] 玩家加入房间 ${rid},playerId=${nextId},team=${getTeamDisplayName(playerInfo.team)},当前 ${joinedCount}/${totalSeats}`);
// 通知自己加入成功(含最终分配的队伍,可能因满员被调整)
ws.sendEvent('room:joined', { roomId: rid, joinedCount, totalSeats, playerId: nextId, players, myPlayerId: nextId, team: finalTeam });
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论