189 lines
6.3 KiB
JavaScript
189 lines
6.3 KiB
JavaScript
/**
|
||
* 糖分突袭 · 视觉特效状态(飘字、粒子、连消横幅、震屏、闪屏)
|
||
* 特效风格参考「开心消消乐」:糖果色星屑、弹性扩散光环、大消除闪屏。
|
||
*/
|
||
import { ref } from 'vue'
|
||
|
||
let fxSeq = 0
|
||
function nextFxId(prefix) {
|
||
fxSeq += 1
|
||
return `${prefix}-${fxSeq}-${Date.now()}`
|
||
}
|
||
|
||
// 开心消消乐式糖果色板(彩虹爆裂用)
|
||
const CANDY_COLORS = ['#FF5E9C', '#FFC93C', '#5BD16A', '#4D9DFF', '#FF8A3D', '#B57BFF', '#FF6F91', '#2FE6C8']
|
||
// 星屑字形
|
||
const STAR_GLYPHS = ['✦', '✧', '★', '✨']
|
||
|
||
export function useGameFx() {
|
||
const floatingTexts = ref([])
|
||
const burstParticles = ref([])
|
||
const ripples = ref([])
|
||
const comboBanner = ref({ show: false, text: '', tier: 1 })
|
||
const screenShaking = ref(false)
|
||
const flash = ref({ active: false, color: '#ffffff', opacity: 0 })
|
||
|
||
let comboBannerTimer = null
|
||
let shakeTimer = null
|
||
let flashTimer = null
|
||
let flashFadeTimer = null
|
||
|
||
function spawnFloatingText(payload) {
|
||
const id = nextFxId('ft')
|
||
floatingTexts.value.push({ id, ...payload })
|
||
const duration = payload.danger ? 1500 : payload.isCombo ? 1400 : 1200
|
||
setTimeout(() => {
|
||
floatingTexts.value = floatingTexts.value.filter((f) => f.id !== id)
|
||
}, duration)
|
||
return id
|
||
}
|
||
|
||
/**
|
||
* 糖果爆裂粒子(星屑 + 圆点混合,上抛后受重力下落,带旋转与缓动)。
|
||
* @param {number} cx,cy 爆裂中心(屏幕坐标)
|
||
* @param {string} color 基础颜色
|
||
* @param {number} count 粒子数量
|
||
* @param {object} opts { star, rainbow, gravity, sizeMin, sizeMax, distMin, distMax, life }
|
||
*/
|
||
function spawnBurst(cx, cy, color, count = 8, opts = {}) {
|
||
const {
|
||
star = true,
|
||
rainbow = false,
|
||
gravity = 64,
|
||
sizeMin = 8,
|
||
sizeMax = 16,
|
||
distMin = 36,
|
||
distMax = 110,
|
||
life = 850
|
||
} = opts
|
||
const dur = (life / 1000).toFixed(2)
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
const id = nextFxId('p')
|
||
const isStar = star && Math.random() < 0.65
|
||
const pSize = Math.random() * (sizeMax - sizeMin) + sizeMin
|
||
const angle = Math.random() * Math.PI * 2
|
||
const distance = Math.random() * (distMax - distMin) + distMin
|
||
const tx = Math.cos(angle) * distance
|
||
// 先上抛、再叠加重力下落,形成喷泉抛物线
|
||
const ty = Math.sin(angle) * distance - distance * 0.35 + gravity
|
||
const half = pSize / 2
|
||
const pColor = rainbow
|
||
? CANDY_COLORS[Math.floor(Math.random() * CANDY_COLORS.length)]
|
||
: color
|
||
const rot = Math.random() * 720 - 360
|
||
const glyph = isStar ? STAR_GLYPHS[Math.floor(Math.random() * STAR_GLYPHS.length)] : ''
|
||
|
||
const particle = {
|
||
id,
|
||
left: cx - half,
|
||
top: cy - half,
|
||
size: pSize,
|
||
color: pColor,
|
||
char: glyph,
|
||
opacity: 1,
|
||
transform: 'scale(0.4) rotate(0deg)',
|
||
transition: 'none'
|
||
}
|
||
burstParticles.value.push(particle)
|
||
|
||
setTimeout(() => {
|
||
particle.transition =
|
||
`left ${dur}s cubic-bezier(0.16,0.7,0.3,1), top ${dur}s cubic-bezier(0.3,0.1,0.4,1), opacity ${dur}s ease-in, transform ${dur}s ease-out`
|
||
particle.left = cx - half + tx
|
||
particle.top = cy - half + ty
|
||
particle.opacity = 0
|
||
particle.transform = `scale(${isStar ? 1.15 : 0.5}) rotate(${rot}deg)`
|
||
}, 16)
|
||
|
||
setTimeout(() => {
|
||
burstParticles.value = burstParticles.value.filter((p) => p.id !== id)
|
||
}, life + 80)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 弹性扩散发光光环(开心消消乐式的扩散光圈)。
|
||
* @param {object} opts { thickness, life }
|
||
*/
|
||
function spawnRipple(cx, cy, color, tier = 1, opts = {}) {
|
||
const id = nextFxId('rp')
|
||
const size = 36 + tier * 18
|
||
const { thickness = Math.max(3, tier + 2), life = 600 } = opts
|
||
ripples.value.push({
|
||
id,
|
||
left: cx,
|
||
top: cy,
|
||
size,
|
||
color,
|
||
thickness,
|
||
opacity: 0.95
|
||
})
|
||
setTimeout(() => {
|
||
ripples.value = ripples.value.filter((r) => r.id !== id)
|
||
}, life)
|
||
}
|
||
|
||
/**
|
||
* 全屏闪光(大消除瞬间的爆闪),先点亮再淡出。
|
||
*/
|
||
function triggerFlash(color = '#ffffff', intensity = 0.5, life = 280) {
|
||
if (flashFadeTimer) clearTimeout(flashFadeTimer)
|
||
if (flashTimer) clearTimeout(flashTimer)
|
||
flash.value = { active: true, color, opacity: intensity }
|
||
flashFadeTimer = setTimeout(() => {
|
||
flash.value = { ...flash.value, opacity: 0 }
|
||
}, 24)
|
||
flashTimer = setTimeout(() => {
|
||
flash.value = { active: false, color, opacity: 0 }
|
||
}, life)
|
||
}
|
||
|
||
function showComboBanner(chain, points) {
|
||
if (chain <= 1) return
|
||
const tier = chain >= 4 ? 3 : chain >= 3 ? 2 : 1
|
||
comboBanner.value = {
|
||
show: true,
|
||
text: `连消 ×${chain} +${points}`,
|
||
tier
|
||
}
|
||
if (comboBannerTimer) clearTimeout(comboBannerTimer)
|
||
comboBannerTimer = setTimeout(() => {
|
||
comboBanner.value = { ...comboBanner.value, show: false }
|
||
}, 1100)
|
||
}
|
||
|
||
function triggerShake(intensity = 1) {
|
||
screenShaking.value = true
|
||
if (shakeTimer) clearTimeout(shakeTimer)
|
||
shakeTimer = setTimeout(() => {
|
||
screenShaking.value = false
|
||
}, intensity >= 2 ? 420 : 280)
|
||
}
|
||
|
||
function clearAll() {
|
||
floatingTexts.value = []
|
||
burstParticles.value = []
|
||
ripples.value = []
|
||
comboBanner.value = { show: false, text: '', tier: 1 }
|
||
screenShaking.value = false
|
||
flash.value = { active: false, color: '#ffffff', opacity: 0 }
|
||
}
|
||
|
||
return {
|
||
floatingTexts,
|
||
burstParticles,
|
||
ripples,
|
||
comboBanner,
|
||
screenShaking,
|
||
flash,
|
||
spawnFloatingText,
|
||
spawnBurst,
|
||
spawnRipple,
|
||
showComboBanner,
|
||
triggerShake,
|
||
triggerFlash,
|
||
clearAll
|
||
}
|
||
}
|