提交 f2fef882 authored 作者: lidongxu's avatar lidongxu

大屏碎裂效果完美版本

上级 16dd650a
...@@ -9,7 +9,7 @@ import { drawBubbleGrid } from './renderer/bubbleGrid.js' ...@@ -9,7 +9,7 @@ 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, drawTeamResultOverlay } from './renderer/gameinfo.js' import { drawGameInfo, drawTeamResultOverlay } from './renderer/gameinfo.js'
import { updateAndDrawExplosions, appendExplosionsFromState, Explosion, setExplosionQuality } from './renderer/explosion.js' import { detectAndCreateBursts, updateAndDrawBursts, clearPrevGrid } 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'
...@@ -19,8 +19,8 @@ const ctx = canvas.getContext('2d') ...@@ -19,8 +19,8 @@ const ctx = canvas.getContext('2d')
const SCREEN_NAME = import.meta.env.VITE_SCREEN_NAME || 'big-screen-1' const SCREEN_NAME = import.meta.env.VITE_SCREEN_NAME || 'big-screen-1'
/** 每个玩家独立的爆炸列表:Map<playerId, Explosion[]> */ /** 每个玩家独立的碎裂效果列表:Map<playerId, BubbleBurst[]> */
const playerExplosions = new Map() const playerBursts = new Map()
let frameCount = 0 let frameCount = 0
...@@ -42,9 +42,9 @@ function applyScaler(playerCount = 1) { ...@@ -42,9 +42,9 @@ function applyScaler(playerCount = 1) {
function renderPlayer(state, offsetX, roomId) { function renderPlayer(state, offsetX, roomId) {
const pid = state.playerId ?? 1 const pid = state.playerId ?? 1
// 初始化该玩家的爆炸列表 // 初始化该玩家的碎裂效果列表
if (!playerExplosions.has(pid)) playerExplosions.set(pid, []) if (!playerBursts.has(pid)) playerBursts.set(pid, [])
const explosions = playerExplosions.get(pid) const bursts = playerBursts.get(pid)
ctx.save() ctx.save()
ctx.translate(offsetX, 0) ctx.translate(offsetX, 0)
...@@ -63,12 +63,11 @@ function renderPlayer(state, offsetX, roomId) { ...@@ -63,12 +63,11 @@ function renderPlayer(state, offsetX, roomId) {
} }
} }
// 爆炸特效:消费后立即清空,避免下一帧重复添加 // 碎裂特效:通过对比前后帧grid自动检测消失的球
if (state.explosions && state.explosions.length) { if (state.grid) {
appendExplosionsFromState(explosions, state.explosions) detectAndCreateBursts(bursts, pid, state.grid, state.pushAnimOffsetY ?? 0)
state.explosions = []
} }
updateAndDrawExplosions(ctx, explosions) updateAndDrawBursts(ctx, bursts)
// 射击器 // 射击器
if (state.shooter) { if (state.shooter) {
...@@ -196,15 +195,15 @@ function loop() { ...@@ -196,15 +195,15 @@ function loop() {
const teamBStates = states.filter(s => getPlayerTeam(s.playerId ?? 1) === 'B') const teamBStates = states.filter(s => getPlayerTeam(s.playerId ?? 1) === 'B')
const totalSlots = Math.max(teamAStates.length + teamBStates.length, 1) const totalSlots = Math.max(teamAStates.length + teamBStates.length, 1)
// 人数变化时重新计算缩放和爆炸质量 // 人数变化时重新计算缩放
if (totalSlots !== _lastPlayerCount) { if (totalSlots !== _lastPlayerCount) {
_lastPlayerCount = totalSlots _lastPlayerCount = totalSlots
applyScaler(totalSlots) applyScaler(totalSlots)
setExplosionQuality(totalSlots) // 根据人数调整爆炸效果质量 // 清理消失玩家的碎裂列表和grid快照
// 清理消失玩家的爆炸列表 for (const pid of playerBursts.keys()) {
for (const pid of playerExplosions.keys()) {
if (!states.find(s => (s.playerId ?? 1) === pid)) { if (!states.find(s => (s.playerId ?? 1) === pid)) {
playerExplosions.delete(pid) playerBursts.delete(pid)
clearPrevGrid(pid)
} }
} }
} }
...@@ -250,11 +249,9 @@ function loop() { ...@@ -250,11 +249,9 @@ function loop() {
// ── 检测是否所有玩家都结束,显示队伍比分 ───────────────────────────── // ── 检测是否所有玩家都结束,显示队伍比分 ─────────────────────────────
const allGameOver = states.every(s => s.isGameOver) 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) { if (allGameOver && states.length > 1) {
// 全屏显示队伍比分(传入实际的大屏宽度) // 全屏显示队伍比分(传入实际的大屏宽度)
const totalWidth = SCREEN_WIDTH * totalSlots const totalWidth = SCREEN_WIDTH * totalSlots
console.log('[BigScreen] 显示队伍比分', { totalSlots, totalWidth, canvasWidth: canvas.width, states: states.length })
ctx.save() ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0) ctx.setTransform(1, 0, 0, 1, 0, 0)
drawTeamResultOverlay(ctx, states, getPlayerTeam, totalWidth) drawTeamResultOverlay(ctx, states, getPlayerTeam, totalWidth)
......
/** /**
* 爆炸粒子特效 —— 与 minigame-1/js/effects/explosion.js 1:1 复刻 * 泡泡碎裂特效(大屏独立实现)
* 去除 wx 依赖,使用浏览器 Canvas 2D API *
* 优化:批量渲染 + 动态质量调整,支持4人同屏 * 原理:大屏通过对比前后两帧的 grid 数据,检测哪些泡泡消失了,
* 在消失位置播放一次碎裂动画。完全不依赖小游戏传来的 explosions 数据。
*
* 动画过程:闪光 → 碎片四散 → 渐隐消失,只播放一次。
*/ */
import { BUBBLE_RADIUS, BUBBLE_COLORS } from './bubble.js' import { BUBBLE_RADIUS, BUBBLE_COLORS, gridToScreen } from './bubble.js'
// ─── 全局配置:根据同屏玩家数动态调整质量 ─────────────────────────────────────
const QUALITY_CONFIG = {
// 单人/双人 - 高质量
low: {
circleCount: 12,
glintCount: 5,
sparkCount: 5,
ringCount: 2,
maxLifeBase: 25
},
// 三人 - 中等质量
medium: {
circleCount: 8,
glintCount: 3,
sparkCount: 3,
ringCount: 2,
maxLifeBase: 20
},
// 四人 - 性能优先
high: {
circleCount: 6,
glintCount: 2,
sparkCount: 2,
ringCount: 1,
maxLifeBase: 18
}
}
let currentQuality = 'medium'
/**
* 设置爆炸质量等级(根据同屏玩家数调用)
* @param {number} playerCount 当前游戏人数 1-4
*/
export function setExplosionQuality(playerCount) {
if (playerCount <= 2) currentQuality = 'low'
else if (playerCount === 3) currentQuality = 'medium'
else currentQuality = 'high'
}
// ─── 圆形粒子 ───────────────────────────────────────────────────────────────── // ─── 碎片粒子 ─────────────────────────────────────────────────────────────────
class CircleParticle { class Shard {
constructor(x, y, colorHex, vx, vy, radius, maxLife) { constructor(x, y, color, angle, speed, size) {
this.x = x this.x = x
this.y = y this.y = y
this.color = colorHex this.color = color
this.vx = vx this.size = size
this.vy = vy
this.radius = radius
this.alpha = 1 this.alpha = 1
this.life = 0 this.life = 0
this.maxLife = maxLife this.maxLife = 80 + Math.floor(Math.random() * 30) // 80-110帧 ≈ 1.3-1.8秒
this.alive = true this.rotation = Math.random() * Math.PI * 2
this.rotSpeed = (Math.random() - 0.5) * 0.1
this.gravity = 0.04
this.vx = Math.cos(angle) * speed
this.vy = Math.sin(angle) * speed
} }
update() { update() {
this.vx *= 0.92 this.life++
this.vy = this.vy * 0.92 + 0.2 this.vx *= 0.99
this.vy *= 0.99
this.vy += this.gravity
this.x += this.vx this.x += this.vx
this.y += this.vy this.y += this.vy
this.life++ this.rotation += this.rotSpeed
this.alpha = Math.max(0, 1 - this.life / this.maxLife) this.size *= 0.997
this.radius = Math.max(0.5, this.radius * 0.97) const t = this.life / this.maxLife
if (this.life >= this.maxLife) this.alive = false this.alpha = t < 0.85 ? 1 - t * 0.2 : Math.max(0, (1 - t) / 0.15)
} }
}
// ─── 火花线条粒子 ─────────────────────────────────────────────────────────────
class SparkParticle { get alive() {
constructor(x, y, colorHex, vx, vy) { return this.life < this.maxLife
this.x = x
this.y = y
this.color = colorHex
this.vx = vx
this.vy = vy
this.prevX = x
this.prevY = y
this.alpha = 1
this.life = 0
this.maxLife = 18 + Math.floor(Math.random() * 14)
this.alive = true
this.isSpark = true
} }
update() { render(ctx) {
this.prevX = this.x if (!this.alive || this.alpha <= 0) return
this.prevY = this.y ctx.globalAlpha = this.alpha
this.vx *= 0.88 ctx.fillStyle = this.color
this.vy = this.vy * 0.88 + 0.15 ctx.save()
this.x += this.vx ctx.translate(this.x, this.y)
this.y += this.vy ctx.rotate(this.rotation)
this.life++ ctx.beginPath()
this.alpha = Math.max(0, 1 - this.life / this.maxLife) ctx.moveTo(-this.size, -this.size * 0.6)
if (this.life >= this.maxLife) this.alive = false ctx.lineTo(this.size * 0.8, -this.size * 0.4)
ctx.lineTo(this.size * 0.5, this.size * 0.7)
ctx.lineTo(-this.size * 0.7, this.size * 0.5)
ctx.closePath()
ctx.fill()
ctx.restore()
} }
} }
// ─── 冲击波圆环 ─────────────────────────────────────────────────────────────── // ─── 冲击波圆环 ───────────────────────────────────────────────────────────────
class ShockRing { class Ring {
constructor(x, y, colorHex, delay = 0) { constructor(x, y, color) {
this.x = x this.x = x
this.y = y this.y = y
this.color = colorHex this.color = color
this.r = BUBBLE_RADIUS * 0.2 this.r = BUBBLE_RADIUS * 0.3
this.maxR = BUBBLE_RADIUS * 2.4 this.maxR = BUBBLE_RADIUS * 2.2
this.life = -delay this.life = 0
this.maxLife = 24 this.maxLife = 40
this.alpha = 0 }
this.alive = true
get alive() {
return this.life < this.maxLife
} }
update() { update() {
this.life++ this.life++
if (this.life <= 0) return
const t = this.life / this.maxLife const t = this.life / this.maxLife
this.r = BUBBLE_RADIUS * 0.2 + (this.maxR - BUBBLE_RADIUS * 0.2) * t this.r = BUBBLE_RADIUS * 0.3 + (this.maxR - BUBBLE_RADIUS * 0.3) * t
this.alpha = 0.9 * (1 - t) this.alpha = 0.6 * (1 - t)
if (this.life >= this.maxLife) this.alive = false }
render(ctx) {
if (!this.alive || this.alpha <= 0) return
ctx.globalAlpha = this.alpha
ctx.strokeStyle = this.color
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2)
ctx.stroke()
} }
} }
// ─── 单颗泡泡爆炸 ───────────────────────────────────────────────────────────── // ─── 单颗泡泡碎裂效果 ────────────────────────────────────────────────────────
export class Explosion { class BubbleBurst {
/** constructor(x, y, colorIdx) {
* @param {number} x 爆炸中心 x
* @param {number} y 爆炸中心 y
* @param {number|string} color 颜色索引 1-9 或直接传 '#rrggbb' 十六进制字符串
* @param {boolean} isFloating 是否为悬空掉落(较小效果)
*/
constructor(x, y, color, isFloating = false) {
this.alive = true this.alive = true
this.particles = [] this.shards = []
this.rings = [] this.ring = null
this._x = x
this._y = y
const colorHex = typeof color === 'string' && color.startsWith('#') const colorHex = BUBBLE_COLORS[colorIdx] || '#ffffff'
? color
: (BUBBLE_COLORS[color] || '#ffffff')
const R = BUBBLE_RADIUS const R = BUBBLE_RADIUS
const cfg = QUALITY_CONFIG[currentQuality]
this.flashLife = isFloating ? 1 : 1 // 只闪一下 this.flashAlpha = 1.0
this._x = x this.ring = new Ring(x, y, colorHex)
this._y = y
this._colorHex = colorHex
// ── 圆形粒子
const circleCount = isFloating ? Math.floor(cfg.circleCount * 0.6) : cfg.circleCount
for (let i = 0; i < circleCount; i++) {
const angle = (i / circleCount) * Math.PI * 2 + (Math.random() - 0.5) * 0.8
const speed = isFloating
? (1.5 + Math.random() * 3)
: (2.5 + Math.random() * 5)
const vx = Math.cos(angle) * speed
const vy = isFloating
? (Math.sin(angle) * speed * 0.4 + 1.5)
: Math.sin(angle) * speed
const radius = R * (isFloating ? 0.2 : 0.18 + Math.random() * 0.28)
const maxLife = cfg.maxLifeBase + Math.floor(Math.random() * 20)
this.particles.push(new CircleParticle(x, y, colorHex, vx, vy, radius, maxLife))
}
// ── 白色 & 黄色小圆粒子(高光碎片) // 碎片:10个不规则碎块
const glintCount = isFloating ? Math.floor(cfg.glintCount * 0.4) : cfg.glintCount const shardCount = 10
for (let i = 0; i < glintCount; i++) { for (let i = 0; i < shardCount; i++) {
const angle = Math.random() * Math.PI * 2 const angle = (i / shardCount) * Math.PI * 2 + (Math.random() - 0.5) * 0.5
const speed = 3 + Math.random() * 4 const speed = 0.6 + Math.random() * 1.2
const glintColor = Math.random() < 0.5 ? '#ffffff' : '#ffe566' const size = R * (0.18 + Math.random() * 0.22)
this.particles.push( this.shards.push(new Shard(x, y, colorHex, angle, speed, size))
new CircleParticle(x, y, glintColor,
Math.cos(angle) * speed, Math.sin(angle) * speed,
R * 0.12, 18 + Math.floor(Math.random() * 12))
)
} }
// ── 火花线条粒子 // 额外3个白色高光碎片
const sparkCount = isFloating ? Math.floor(cfg.sparkCount * 0.4) : cfg.sparkCount for (let i = 0; i < 3; i++) {
for (let i = 0; i < sparkCount; i++) {
const angle = Math.random() * Math.PI * 2 const angle = Math.random() * Math.PI * 2
const speed = 5 + Math.random() * 7 const speed = 0.8 + Math.random() * 1.5
const sparkColor = i % 2 === 0 ? '#ffffff' : colorHex const size = R * 0.1
this.particles.push( this.shards.push(new Shard(x, y, '#ffffff', angle, speed, size))
new SparkParticle(x, y, sparkColor,
Math.cos(angle) * speed, Math.sin(angle) * speed)
)
}
// ── 冲击波圆环
if (!isFloating) {
this.rings.push(new ShockRing(x, y, colorHex, 0))
if (cfg.ringCount >= 2) {
this.rings.push(new ShockRing(x, y, '#ffffff', 5))
}
} }
} }
update() { update() {
if (this.flashLife > 0) this.flashLife-- if (this.flashAlpha > 0) {
this.flashAlpha -= 0.05 // 约20帧(0.33秒)渐隐
}
for (const p of this.particles) { for (const s of this.shards) {
if (p.alive) p.update() if (s.alive) s.update()
} }
for (const r of this.rings) {
if (r.alive) r.update() if (this.ring && this.ring.alive) {
this.ring.update()
} }
if ( const shardsAlive = this.shards.some(s => s.alive)
this.flashLife <= 0 && const ringAlive = this.ring && this.ring.alive
this.particles.every(p => !p.alive) && if (!shardsAlive && !ringAlive && this.flashAlpha <= 0) {
this.rings.every(r => !r.alive)
) {
this.alive = false this.alive = false
} }
} }
render(ctx) { render(ctx) {
// ── 闪光光晕(简单实心圆,性能更好) if (this.flashAlpha > 0) {
if (this.flashLife > 0) {
const t = this.flashLife / 10
ctx.save() ctx.save()
ctx.globalAlpha = t * 0.6 ctx.globalAlpha = this.flashAlpha * 0.7
ctx.fillStyle = '#ffffff' ctx.fillStyle = '#ffffff'
ctx.beginPath() ctx.beginPath()
ctx.arc(this._x, this._y, BUBBLE_RADIUS * 0.8, 0, Math.PI * 2) ctx.arc(this._x, this._y, BUBBLE_RADIUS * 1.3, 0, Math.PI * 2)
ctx.fill()
ctx.globalAlpha = t * 0.4
ctx.fillStyle = this._colorHex
ctx.beginPath()
ctx.arc(this._x, this._y, BUBBLE_RADIUS * 1.4, 0, Math.PI * 2)
ctx.fill() ctx.fill()
ctx.restore() ctx.restore()
} }
// ── 冲击波圆环(批量渲染)
ctx.save() ctx.save()
ctx.lineWidth = 2.5 if (this.ring && this.ring.alive) {
for (const r of this.rings) { this.ring.render(ctx)
if (!r.alive || r.alpha <= 0 || r.life <= 0) continue
ctx.globalAlpha = r.alpha
ctx.strokeStyle = r.color
ctx.beginPath()
ctx.arc(r.x, r.y, r.r, 0, Math.PI * 2)
ctx.stroke()
} }
ctx.restore() ctx.restore()
// ── 粒子批量渲染(圆形粒子 + 火花线条)
ctx.save() ctx.save()
for (const p of this.particles) { for (const s of this.shards) {
if (!p.alive || p.alpha <= 0) continue if (s.alive) s.render(ctx)
ctx.globalAlpha = p.alpha
if (p.isSpark) {
ctx.strokeStyle = p.color
ctx.lineWidth = 1.8
ctx.lineCap = 'round'
ctx.beginPath()
ctx.moveTo(p.x, p.y)
ctx.lineTo(p.prevX, p.prevY)
ctx.stroke()
} else {
ctx.fillStyle = p.color
ctx.beginPath()
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2)
ctx.fill()
}
} }
ctx.restore() ctx.restore()
ctx.globalAlpha = 1
} }
} }
// ─── 工具函数(供 main.js 调用)────────────────────────────────────────────── // ─── 对外接口 ─────────────────────────────────────────────────────────────────
/** 每个玩家的上一帧 grid 快照 + 总球数:Map<playerId, { grid, totalCount }> */
const prevSnapshots = new Map()
/** /**
* 更新并绘制爆炸列表,移除已结束的实例 * 统计 grid 中泡泡总数
*/ */
export function updateAndDrawExplosions(ctx, explosionList) { function countBubbles(grid) {
if (!explosionList || !explosionList.length) return let count = 0
for (let i = explosionList.length - 1; i >= 0; i--) { for (const row of grid) {
const e = explosionList[i] if (!row) continue
e.update() for (const c of row) {
e.render(ctx) if (c > 0) count++
if (!e.alive) explosionList.splice(i, 1) }
} }
return count
} }
/** 单玩家最大爆炸实例数 */
const MAX_EXPLOSIONS_PER_PLAYER = 50
/** /**
* 根据状态中的新爆炸事件追加 Explosion 实例 * 通过对比前后两帧 grid,检测消失的泡泡并创建碎裂效果
* 小游戏端已保证每个爆炸只发送一次(待发送队列机制),这里直接添加即可 *
* 处理 pushDown 场景:消除后可能触发补行,grid 顶部插入新行导致行号整体偏移。
* 通过计算行偏移量(新增行数)来对齐对比,确保消除+补行同帧发生时碎裂效果不丢失。
*/ */
export function appendExplosionsFromState(explosionList, newExplosions) { export function detectAndCreateBursts(burstList, playerId, grid, pushAnimOffsetY = 0) {
if (!newExplosions || !newExplosions.length) return if (!grid || !grid.length) return
const prev = prevSnapshots.get(playerId)
const currCount = countBubbles(grid)
if (prev) {
const prevGrid = prev.grid
for (const { x, y, colorHex, color } of newExplosions) { // 计算行偏移:新grid比旧grid多出的行数(pushDown插入的新行数)
if (explosionList.length >= MAX_EXPLOSIONS_PER_PLAYER) break // pushDown 总是在顶部插入偶数行(通常是2行),所以偏移量 = grid.length - prevGrid.length
const c = colorHex || color || 1 const rowOffset = Math.max(0, grid.length - prevGrid.length)
explosionList.push(new Explosion(x, y, c, false))
// 逐格对比(旧grid的 row 对应新grid的 row+rowOffset)
for (let prevRow = 0; prevRow < prevGrid.length; prevRow++) {
const prevRowArr = prevGrid[prevRow]
const currRow = prevRow + rowOffset
const currRowArr = grid[currRow]
if (!prevRowArr) continue
for (let col = 0; col < prevRowArr.length; col++) {
const prevColor = prevRowArr[col]
const currColor = (currRowArr && currRowArr[col]) || 0
if (prevColor > 0 && currColor === 0) {
// 球消失了,用新的行号计算屏幕位置(因为球已经被推到新位置了)
const { x, y } = gridToScreen(currRow, col)
burstList.push(new BubbleBurst(x, y + (pushAnimOffsetY || 0), prevColor))
}
}
} }
}
// 保存当前帧快照
prevSnapshots.set(playerId, {
grid: grid.map(row => row ? [...row] : []),
totalCount: currCount
})
}
/**
* 更新并绘制碎裂效果列表
*/
export function updateAndDrawBursts(ctx, burstList) {
if (!burstList || !burstList.length) return
for (let i = burstList.length - 1; i >= 0; i--) {
const b = burstList[i]
b.update()
b.render(ctx)
if (!b.alive) burstList.splice(i, 1)
}
}
/**
* 清除某个玩家的 grid 快照(玩家离开时调用)
*/
export function clearPrevGrid(playerId) {
prevSnapshots.delete(playerId)
} }
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论