Merge branch 'master' into master-5-28
This commit is contained in:
@@ -10,6 +10,7 @@ lerna-debug.log*
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
unpackage/dist
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
## 简介
|
||||
本 demo 演示了如何在 uni-app 项目中集成 [TUICallKit](https://www.npmjs.com/package/@trtc/calls-uikit-wx-uniapp) 音视频通话组件。
|
||||
|
||||
## 两种开发方式
|
||||
|
||||
本工程同时支持 **HBuilderX** 和 **命令行 (vite)** 两套编译链路,二选一即可。
|
||||
|
||||
### 方式 A:命令行 (vite)
|
||||
|
||||
```bash
|
||||
npm install # 安装依赖
|
||||
npm run dev:mp-weixin # watch 模式,产物 dist/dev/mp-weixin
|
||||
npm run build:mp-weixin # 生产构建,产物 dist/build/mp-weixin
|
||||
```
|
||||
|
||||
构建完成后用微信开发者工具打开对应 `dist/.../mp-weixin` 目录即可。
|
||||
|
||||
支持平台:`mp-weixin` / `h5` / `app`(脚本里有 dev/build 两组)。
|
||||
|
||||
### 方式 B:HBuilderX
|
||||
|
||||
HBuilderX 3.4+ 会自动识别 `vite.config.ts` 并切换到 vite 编译模式。
|
||||
|
||||
1. 用 HBuilderX 打开工程根目录
|
||||
2. 菜单「运行」→「运行到小程序模拟器」→「微信开发者工具」
|
||||
3. 产物会输出到 `unpackage/dist/dev/mp-weixin`
|
||||
|
||||
|
||||
## 环境准备
|
||||
|
||||
|
||||
@@ -14,9 +14,49 @@
|
||||
<view class="menu-arrow">›</view>
|
||||
</view> -->
|
||||
|
||||
<view class="menu-item" @click="goto('/training/pages/dumbbell')">
|
||||
<view class="menu-icon menu-icon--dumbbell">
|
||||
<view class="dumbbell-shape">
|
||||
<view class="dumbbell-plate dumbbell-plate-left" />
|
||||
<view class="dumbbell-bar" />
|
||||
<view class="dumbbell-plate dumbbell-plate-right" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="menu-info">
|
||||
<text class="menu-title">哑铃</text>
|
||||
<text class="menu-sub">力量塑形</text>
|
||||
</view>
|
||||
<view class="menu-arrow">›</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @click="goto('/training/pages/foot-pedal')">
|
||||
<view class="menu-icon menu-icon--pedal">
|
||||
<view class="foot-pedal-shape">
|
||||
<view class="pedal-bar pedal-bar-left" />
|
||||
<view class="pedal-bar pedal-bar-right" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="menu-info">
|
||||
<text class="menu-title">脚蹬器</text>
|
||||
<text class="menu-sub">下肢有氧</text>
|
||||
</view>
|
||||
<view class="menu-arrow">›</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @click="goto('/training/pages/pilates-ring')">
|
||||
<view class="menu-icon menu-icon--pilates">
|
||||
<view class="pilates-ring-shape" />
|
||||
</view>
|
||||
<view class="menu-info">
|
||||
<text class="menu-title">瑜伽环</text>
|
||||
<text class="menu-sub">核心塑形</text>
|
||||
</view>
|
||||
<view class="menu-arrow">›</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @click="goto('/training/pages/grip-ring')">
|
||||
<view class="menu-icon menu-icon--grip">
|
||||
<text class="menu-icon-text">🟢</text>
|
||||
<view class="grip-ring-shape" />
|
||||
</view>
|
||||
<view class="menu-info">
|
||||
<text class="menu-title">握力环</text>
|
||||
@@ -262,9 +302,21 @@ $brand-light: #34d399;
|
||||
&--train {
|
||||
background: linear-gradient(140deg, $brand-light, $brand-deep);
|
||||
}
|
||||
&--dumbbell {
|
||||
background: linear-gradient(140deg, #93c5fd, #3b82f6);
|
||||
box-shadow: 0 2rpx 8rpx rgba(59, 130, 246, 0.36);
|
||||
}
|
||||
&--pedal {
|
||||
background: linear-gradient(140deg, #fdba74, #f97316);
|
||||
box-shadow: 0 2rpx 8rpx rgba(249, 115, 22, 0.36);
|
||||
}
|
||||
&--pilates {
|
||||
background: linear-gradient(140deg, #c4b5fd, #8b5cf6);
|
||||
box-shadow: 0 2rpx 8rpx rgba(139, 92, 246, 0.36);
|
||||
}
|
||||
&--grip {
|
||||
background: linear-gradient(140deg, #86efac, #22c55e);
|
||||
box-shadow: 0 2rpx 8rpx rgba(34, 197, 94, 0.36);
|
||||
background: linear-gradient(140deg, #5eead4, #14b8a6);
|
||||
box-shadow: 0 2rpx 8rpx rgba(20, 184, 166, 0.36);
|
||||
}
|
||||
&--metro {
|
||||
background: linear-gradient(140deg, #fbbf24, #d97706);
|
||||
@@ -278,6 +330,94 @@ $brand-light: #34d399;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 哑铃小 icon */
|
||||
.dumbbell-shape {
|
||||
width: 40rpx;
|
||||
height: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dumbbell-plate {
|
||||
width: 10rpx;
|
||||
height: 18rpx;
|
||||
background: #fff;
|
||||
border-radius: 3rpx;
|
||||
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.dumbbell-bar {
|
||||
width: 16rpx;
|
||||
height: 5rpx;
|
||||
background: #fff;
|
||||
border-radius: 2rpx;
|
||||
margin: 0 2rpx;
|
||||
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
/* 脚蹬器小 icon: 左右踏板 */
|
||||
.foot-pedal-shape {
|
||||
width: 40rpx;
|
||||
height: 28rpx;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.pedal-bar {
|
||||
width: 14rpx;
|
||||
background: #fff;
|
||||
border-radius: 4rpx 4rpx 2rpx 2rpx;
|
||||
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.pedal-bar-left {
|
||||
height: 18rpx;
|
||||
transform: rotate(-12deg);
|
||||
}
|
||||
|
||||
.pedal-bar-right {
|
||||
height: 22rpx;
|
||||
transform: rotate(12deg);
|
||||
}
|
||||
|
||||
/* 瑜伽环小 icon: 双弧阻力环 */
|
||||
.pilates-ring-shape {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-radius: 50%;
|
||||
border: 5rpx solid #fff;
|
||||
position: relative;
|
||||
box-shadow:
|
||||
0 1rpx 2rpx rgba(0, 0, 0, 0.18),
|
||||
inset 0 1rpx 2rpx rgba(255, 255, 255, 0.6);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 3rpx solid rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
/* 握力环小 icon: 白色描边的环(甜甜圈) */
|
||||
.grip-ring-shape {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
border-radius: 50%;
|
||||
border: 7rpx solid #fff;
|
||||
box-shadow:
|
||||
0 1rpx 2rpx rgba(0, 0, 0, 0.18),
|
||||
inset 0 1rpx 2rpx rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* 节拍器小 icon: 3 根高低柱模拟均衡器/节拍 */
|
||||
.metro-bar {
|
||||
width: 4rpx;
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"subPackages" : true
|
||||
},
|
||||
"usingComponents" : true,
|
||||
"requiredBackgroundModes" : ["audio"],
|
||||
|
||||
"plugins" : {
|
||||
"WechatSI" : {
|
||||
|
||||
Generated
+10540
-1784
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,55 @@
|
||||
{
|
||||
"name": "tuicallkit-vue3",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"description": "TUICallKit Vue3 uni-app 工程(兼容 HBuilderX & vite CLI)",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"dev:mp-weixin": "UNI_INPUT_DIR=\"$PWD\" uni -p mp-weixin",
|
||||
"dev:h5": "UNI_INPUT_DIR=\"$PWD\" uni",
|
||||
"dev:app": "UNI_INPUT_DIR=\"$PWD\" uni -p app",
|
||||
"build:mp-weixin": "UNI_INPUT_DIR=\"$PWD\" uni build -p mp-weixin",
|
||||
"build:h5": "UNI_INPUT_DIR=\"$PWD\" uni build",
|
||||
"build:app": "UNI_INPUT_DIR=\"$PWD\" uni build -p app"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-app-harmony": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-app-plus": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-components": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-alipay": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-baidu": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-jd": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-kuaishou": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-lark": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-qq": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-toutiao": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-xhs": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-quickapp-webview": "3.0.0-4060620250520001",
|
||||
"@trtc/calls-uikit-wx-uniapp": "^4.1.0",
|
||||
"@uni-ku/bundle-optimizer": "^1.3.16",
|
||||
"tuikit-atomicx-uniapp-wx-standard": "^1.1.9"
|
||||
"tuikit-atomicx-uniapp-wx-standard": "^1.1.9",
|
||||
"vue": "^3.4.21",
|
||||
"vue-i18n": "^9.1.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/types": "^3.4.8",
|
||||
"@dcloudio/uni-automator": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-stacktracey": "3.0.0-4060620250520001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
|
||||
"@types/node": "^20.11.0",
|
||||
"@vue/runtime-core": "^3.4.21",
|
||||
"less": "^4.6.4",
|
||||
"sass": "^1.77.8",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.2.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,18 +121,43 @@
|
||||
"navigationBarTitleText": "握力环训练",
|
||||
"navigationBarBackgroundColor": "#f8fafc",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f8fafc",
|
||||
"requiredBackgroundModes": ["audio"]
|
||||
"backgroundColor": "#f8fafc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/metronome",
|
||||
{
|
||||
"path": "pages/pilates-ring",
|
||||
"style": {
|
||||
"navigationBarTitleText": "瑜伽环训练",
|
||||
"navigationBarBackgroundColor": "#f8fafc",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f8fafc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/foot-pedal",
|
||||
"style": {
|
||||
"navigationBarTitleText": "脚蹬器训练",
|
||||
"navigationBarBackgroundColor": "#f8fafc",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f8fafc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/dumbbell",
|
||||
"style": {
|
||||
"navigationBarTitleText": "哑铃训练",
|
||||
"navigationBarBackgroundColor": "#f8fafc",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f8fafc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/metronome",
|
||||
"style": {
|
||||
"navigationBarTitleText": "耗糖节拍器",
|
||||
"navigationBarBackgroundColor": "#f8fafc",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f8fafc",
|
||||
"requiredBackgroundModes": ["audio"]
|
||||
"backgroundColor": "#f8fafc"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
|
||||
import { ref, watch, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
|
||||
|
||||
// ============================================================
|
||||
// 类型定义
|
||||
@@ -18,7 +18,8 @@ import { ref, onMounted, onBeforeUnmount, getCurrentInstance } from 'vue'
|
||||
type CrushItemType = 'egg' | 'walnut' | 'can' | 'balloon'
|
||||
type ParticleShape = 'circle' | 'square' | 'triangle' | 'rect'
|
||||
|
||||
interface Particle {
|
||||
// 碎片(主体飞溅物)
|
||||
interface Debris {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
@@ -29,7 +30,49 @@ interface Particle {
|
||||
rotation: number
|
||||
rotationSpeed: number
|
||||
life: number
|
||||
decay: number
|
||||
gravity: number
|
||||
glow: boolean
|
||||
swing: number // 水平摇摆幅度(confetti 彩纸用),0 表示不摇摆
|
||||
age: number
|
||||
}
|
||||
|
||||
// 火花(细小高速亮点,带拖尾)
|
||||
interface Spark {
|
||||
x: number
|
||||
y: number
|
||||
px: number // 上一帧位置(拖尾起点)
|
||||
py: number
|
||||
vx: number
|
||||
vy: number
|
||||
size: number
|
||||
color: string
|
||||
life: number
|
||||
decay: number
|
||||
gravity: number
|
||||
}
|
||||
|
||||
// 闪烁星光(四角星高光)
|
||||
interface Sparkle {
|
||||
x: number
|
||||
y: number
|
||||
size: number
|
||||
rotation: number
|
||||
spin: number
|
||||
color: string
|
||||
life: number
|
||||
decay: number
|
||||
}
|
||||
|
||||
// 中心爆闪
|
||||
interface Flash {
|
||||
x: number
|
||||
y: number
|
||||
radius: number
|
||||
maxRadius: number
|
||||
color: string
|
||||
life: number
|
||||
decay: number
|
||||
}
|
||||
|
||||
interface Shockwave {
|
||||
@@ -38,19 +81,26 @@ interface Shockwave {
|
||||
radius: number
|
||||
maxRadius: number
|
||||
color: string
|
||||
width: number
|
||||
life: number
|
||||
decay: number
|
||||
}
|
||||
|
||||
interface CrushItemConfig {
|
||||
colors: string[]
|
||||
colors: string[] // 碎片主体色
|
||||
sparkColors: string[] // 火花/高光色(偏亮)
|
||||
shapes: ParticleShape[]
|
||||
particles: number
|
||||
debrisCount: number
|
||||
sparkCount: number
|
||||
sparkleCount: number
|
||||
speedMin: number
|
||||
speedMax: number
|
||||
sizeMin: number
|
||||
sizeMax: number
|
||||
gravity: number
|
||||
flashColor: string // 中心爆闪核心色
|
||||
shockwaveColor: string
|
||||
confetti?: boolean // balloon: 碎片改为彩纸飘落
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -59,55 +109,76 @@ interface CrushItemConfig {
|
||||
|
||||
const CRUSH_ITEMS: Record<CrushItemType, CrushItemConfig> = {
|
||||
egg: {
|
||||
colors: ['#FFEB3B', '#FFF59D', '#FFFFFF', '#FFC107'],
|
||||
colors: ['#FFEB3B', '#FFF59D', '#FFFFFF', '#FFD54F', '#FFC107'],
|
||||
sparkColors: ['#FFFFFF', '#FFF9C4', '#FFEE58'],
|
||||
shapes: ['circle', 'circle', 'circle'],
|
||||
particles: 18,
|
||||
debrisCount: 20,
|
||||
sparkCount: 16,
|
||||
sparkleCount: 5,
|
||||
speedMin: 3,
|
||||
speedMax: 5.5,
|
||||
speedMax: 6,
|
||||
sizeMin: 4,
|
||||
sizeMax: 9,
|
||||
gravity: 0.32,
|
||||
shockwaveColor: 'rgba(255, 235, 59, 0.4)',
|
||||
sizeMax: 10,
|
||||
gravity: 0.34,
|
||||
flashColor: '#FFFDE7',
|
||||
shockwaveColor: 'rgba(255, 224, 130, 0.85)',
|
||||
},
|
||||
walnut: {
|
||||
colors: ['#5D4037', '#795548', '#8D6E63', '#3E2723'],
|
||||
colors: ['#5D4037', '#795548', '#8D6E63', '#A1887F', '#3E2723'],
|
||||
sparkColors: ['#FFCC80', '#FFB74D', '#FFE0B2'],
|
||||
shapes: ['square', 'triangle', 'rect'],
|
||||
particles: 20,
|
||||
speedMin: 2.5,
|
||||
speedMax: 4.5,
|
||||
debrisCount: 22,
|
||||
sparkCount: 18,
|
||||
sparkleCount: 5,
|
||||
speedMin: 2.8,
|
||||
speedMax: 5,
|
||||
sizeMin: 4,
|
||||
sizeMax: 8,
|
||||
gravity: 0.4,
|
||||
shockwaveColor: 'rgba(141, 110, 99, 0.5)',
|
||||
sizeMax: 9,
|
||||
gravity: 0.42,
|
||||
flashColor: '#FFE0B2',
|
||||
shockwaveColor: 'rgba(255, 167, 38, 0.8)',
|
||||
},
|
||||
can: {
|
||||
colors: ['#90A4AE', '#CFD8DC', '#B0BEC5', '#78909C', '#ECEFF1'],
|
||||
shapes: ['rect', 'rect', 'triangle'],
|
||||
particles: 22,
|
||||
colors: ['#90A4AE', '#CFD8DC', '#B0BEC5', '#78909C', '#ECEFF1', '#607D8B'],
|
||||
sparkColors: ['#FFFFFF', '#E1F5FE', '#B3E5FC'],
|
||||
shapes: ['rect', 'rect', 'triangle', 'square'],
|
||||
debrisCount: 24,
|
||||
sparkCount: 20,
|
||||
sparkleCount: 6,
|
||||
speedMin: 4,
|
||||
speedMax: 6.5,
|
||||
sizeMin: 3,
|
||||
sizeMax: 10,
|
||||
gravity: 0.35,
|
||||
shockwaveColor: 'rgba(176, 190, 197, 0.6)',
|
||||
},
|
||||
balloon: {
|
||||
colors: ['#FF5252', '#FF4081', '#E040FB', '#7C4DFF', '#536DFE', '#448AFF', '#FFEB3B', '#69F0AE'],
|
||||
shapes: ['circle', 'triangle'],
|
||||
particles: 24,
|
||||
speedMin: 4.5,
|
||||
speedMax: 7,
|
||||
sizeMin: 3,
|
||||
sizeMax: 6,
|
||||
gravity: 0.12,
|
||||
shockwaveColor: 'rgba(255, 64, 129, 0.5)',
|
||||
sizeMax: 11,
|
||||
gravity: 0.36,
|
||||
flashColor: '#FFFFFF',
|
||||
shockwaveColor: 'rgba(207, 216, 220, 0.9)',
|
||||
},
|
||||
balloon: {
|
||||
colors: ['#FF5252', '#FF4081', '#E040FB', '#7C4DFF', '#536DFE', '#448AFF', '#FFEB3B', '#69F0AE', '#FF6E40'],
|
||||
sparkColors: ['#FFFFFF', '#FF80AB', '#82B1FF', '#FFFF8D'],
|
||||
shapes: ['rect', 'rect', 'square'],
|
||||
debrisCount: 28,
|
||||
sparkCount: 18,
|
||||
sparkleCount: 8,
|
||||
speedMin: 4,
|
||||
speedMax: 7.5,
|
||||
sizeMin: 4,
|
||||
sizeMax: 8,
|
||||
gravity: 0.14,
|
||||
flashColor: '#FCE4EC',
|
||||
shockwaveColor: 'rgba(255, 64, 129, 0.85)',
|
||||
confetti: true,
|
||||
},
|
||||
}
|
||||
|
||||
const PHYSICS = {
|
||||
AIR_RESISTANCE: 0.985,
|
||||
LIFE_DECAY: 0.02,
|
||||
SHOCKWAVE_DECAY: 0.04,
|
||||
SPARK_RESISTANCE: 0.92,
|
||||
DEBRIS_DECAY: 0.018,
|
||||
SPARK_DECAY: 0.035,
|
||||
SPARKLE_DECAY: 0.045,
|
||||
FLASH_DECAY: 0.11,
|
||||
SHOCKWAVE_DECAY: 0.045,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -122,11 +193,27 @@ const dpr = ref<number>(1)
|
||||
|
||||
const rafId = ref<number | null>(null)
|
||||
const renderRunning = ref<boolean>(false)
|
||||
const particles = ref<Particle[]>([])
|
||||
|
||||
const debrisList = ref<Debris[]>([])
|
||||
const sparkList = ref<Spark[]>([])
|
||||
const sparkleList = ref<Sparkle[]>([])
|
||||
const flashList = ref<Flash[]>([])
|
||||
const shockwaves = ref<Shockwave[]>([])
|
||||
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
// 由父级 prop 信号驱动触发特效(避免跨组件 ref 调用, 小程序端更稳)
|
||||
const props = defineProps<{
|
||||
crushSignal?: { type: CrushItemType; nonce: number } | null
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.crushSignal,
|
||||
(sig) => {
|
||||
if (sig) triggerCrush(sig.type)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
initCanvas()
|
||||
})
|
||||
@@ -191,46 +278,100 @@ function triggerCrush(itemType: CrushItemType) {
|
||||
|
||||
const centerX = canvasWidth.value / 2
|
||||
const centerY = canvasHeight.value / 2
|
||||
const maxR = Math.min(canvasWidth.value, canvasHeight.value)
|
||||
|
||||
// 1. 生成双层冲击波
|
||||
shockwaves.value.push({
|
||||
// 1. 中心爆闪
|
||||
flashList.value.push({
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
radius: 10,
|
||||
maxRadius: Math.min(canvasWidth.value, canvasHeight.value) * 0.6,
|
||||
color: item.shockwaveColor,
|
||||
life: 1.0,
|
||||
})
|
||||
shockwaves.value.push({
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
radius: 4,
|
||||
maxRadius: Math.min(canvasWidth.value, canvasHeight.value) * 0.4,
|
||||
color: item.shockwaveColor,
|
||||
radius: maxR * 0.12,
|
||||
maxRadius: maxR * 0.62,
|
||||
color: item.flashColor,
|
||||
life: 1.0,
|
||||
decay: PHYSICS.FLASH_DECAY,
|
||||
})
|
||||
|
||||
// 2. 生成粒子(径向扩散)
|
||||
const particleCount = item.particles
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const angle = (Math.PI * 2 * i) / particleCount + (Math.random() - 0.5) * 0.6
|
||||
// 2. 双层冲击波
|
||||
shockwaves.value.push({
|
||||
x: centerX, y: centerY,
|
||||
radius: 12, maxRadius: maxR * 0.62,
|
||||
color: item.shockwaveColor, width: 5,
|
||||
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY,
|
||||
})
|
||||
shockwaves.value.push({
|
||||
x: centerX, y: centerY,
|
||||
radius: 4, maxRadius: maxR * 0.42,
|
||||
color: item.shockwaveColor, width: 3,
|
||||
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY * 1.3,
|
||||
})
|
||||
|
||||
// 3. 碎片(主体飞溅)
|
||||
for (let i = 0; i < item.debrisCount; i++) {
|
||||
const angle = (Math.PI * 2 * i) / item.debrisCount + (Math.random() - 0.5) * 0.7
|
||||
const speed = item.speedMin + Math.random() * (item.speedMax - item.speedMin)
|
||||
const size = item.sizeMin + Math.random() * (item.sizeMax - item.sizeMin)
|
||||
const color = item.colors[Math.floor(Math.random() * item.colors.length)]
|
||||
const shape = item.shapes[Math.floor(Math.random() * item.shapes.length)]
|
||||
|
||||
particles.value.push({
|
||||
// confetti(彩纸): 初速带强烈向上偏移 + 水平摇摆下落
|
||||
const confetti = !!item.confetti
|
||||
const vy0 = confetti
|
||||
? Math.sin(angle) * speed - 2.5 // 先向上窜
|
||||
: Math.sin(angle) * speed
|
||||
|
||||
debrisList.value.push({
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
vy: vy0,
|
||||
size,
|
||||
color,
|
||||
shape,
|
||||
rotation: Math.random() * Math.PI * 2,
|
||||
rotationSpeed: (Math.random() - 0.5) * 0.3,
|
||||
rotationSpeed: (Math.random() - 0.5) * (confetti ? 0.5 : 0.3),
|
||||
life: 1.0,
|
||||
decay: PHYSICS.DEBRIS_DECAY * (confetti ? 0.7 : 1),
|
||||
gravity: item.gravity,
|
||||
glow: !confetti, // 彩纸不发光,实色碎片发光
|
||||
swing: confetti ? 0.6 + Math.random() * 1.2 : 0,
|
||||
age: Math.random() * Math.PI * 2,
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 火花(细小高速亮点,带拖尾)
|
||||
for (let i = 0; i < item.sparkCount; i++) {
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const speed = item.speedMax * (0.9 + Math.random() * 0.8)
|
||||
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
|
||||
sparkList.value.push({
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
px: centerX,
|
||||
py: centerY,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
size: 1.5 + Math.random() * 2,
|
||||
color,
|
||||
life: 1.0,
|
||||
decay: PHYSICS.SPARK_DECAY * (0.8 + Math.random() * 0.6),
|
||||
gravity: item.gravity * 0.3,
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 闪烁星光
|
||||
for (let i = 0; i < item.sparkleCount; i++) {
|
||||
const r = maxR * (0.08 + Math.random() * 0.32)
|
||||
const a = Math.random() * Math.PI * 2
|
||||
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
|
||||
sparkleList.value.push({
|
||||
x: centerX + Math.cos(a) * r,
|
||||
y: centerY + Math.sin(a) * r,
|
||||
size: 10 + Math.random() * 16,
|
||||
rotation: Math.random() * Math.PI,
|
||||
spin: (Math.random() - 0.5) * 0.16,
|
||||
color,
|
||||
life: 1.0 + Math.random() * 0.4, // 错峰出现
|
||||
decay: PHYSICS.SPARKLE_DECAY * (0.8 + Math.random() * 0.5),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -238,7 +379,7 @@ function triggerCrush(itemType: CrushItemType) {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 渲染循环(使用 requestAnimationFrame,更准确高效)
|
||||
// 渲染循环
|
||||
// ============================================================
|
||||
|
||||
function ensureRenderLoop() {
|
||||
@@ -253,40 +394,74 @@ function renderLoop() {
|
||||
return
|
||||
}
|
||||
|
||||
// 更新粒子物理
|
||||
for (let i = particles.value.length - 1; i >= 0; i--) {
|
||||
const p = particles.value[i]
|
||||
const w = canvasWidth.value
|
||||
const h = canvasHeight.value
|
||||
|
||||
// 碎片
|
||||
for (let i = debrisList.value.length - 1; i >= 0; i--) {
|
||||
const p = debrisList.value[i]
|
||||
p.vy += p.gravity
|
||||
p.vx *= PHYSICS.AIR_RESISTANCE
|
||||
p.vy *= PHYSICS.AIR_RESISTANCE
|
||||
p.x += p.vx
|
||||
p.y += p.vy
|
||||
p.age += 0.2
|
||||
if (p.swing > 0) p.x += Math.sin(p.age) * p.swing // 彩纸摇摆
|
||||
p.rotation += p.rotationSpeed
|
||||
p.life -= PHYSICS.LIFE_DECAY
|
||||
|
||||
const outOfBounds =
|
||||
p.x < -50 || p.x > canvasWidth.value + 50 ||
|
||||
p.y < -50 || p.y > canvasHeight.value + 50
|
||||
if (p.life <= 0 || outOfBounds) {
|
||||
particles.value.splice(i, 1)
|
||||
p.life -= p.decay
|
||||
if (p.life <= 0 || p.x < -60 || p.x > w + 60 || p.y > h + 60) {
|
||||
debrisList.value.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新冲击波
|
||||
// 火花
|
||||
for (let i = sparkList.value.length - 1; i >= 0; i--) {
|
||||
const s = sparkList.value[i]
|
||||
s.px = s.x
|
||||
s.py = s.y
|
||||
s.vy += s.gravity
|
||||
s.vx *= PHYSICS.SPARK_RESISTANCE
|
||||
s.vy *= PHYSICS.SPARK_RESISTANCE
|
||||
s.x += s.vx
|
||||
s.y += s.vy
|
||||
s.life -= s.decay
|
||||
if (s.life <= 0) sparkList.value.splice(i, 1)
|
||||
}
|
||||
|
||||
// 闪烁星光
|
||||
for (let i = sparkleList.value.length - 1; i >= 0; i--) {
|
||||
const sp = sparkleList.value[i]
|
||||
sp.rotation += sp.spin
|
||||
sp.life -= sp.decay
|
||||
if (sp.life <= 0) sparkleList.value.splice(i, 1)
|
||||
}
|
||||
|
||||
// 中心爆闪
|
||||
for (let i = flashList.value.length - 1; i >= 0; i--) {
|
||||
const f = flashList.value[i]
|
||||
f.radius += (f.maxRadius - f.radius) * 0.35
|
||||
f.life -= f.decay
|
||||
if (f.life <= 0) flashList.value.splice(i, 1)
|
||||
}
|
||||
|
||||
// 冲击波
|
||||
for (let i = shockwaves.value.length - 1; i >= 0; i--) {
|
||||
const sw = shockwaves.value[i]
|
||||
sw.radius += (sw.maxRadius - sw.radius) * 0.15
|
||||
sw.life -= PHYSICS.SHOCKWAVE_DECAY
|
||||
if (sw.life <= 0) {
|
||||
shockwaves.value.splice(i, 1)
|
||||
}
|
||||
sw.radius += (sw.maxRadius - sw.radius) * 0.16
|
||||
sw.life -= sw.decay
|
||||
if (sw.life <= 0) shockwaves.value.splice(i, 1)
|
||||
}
|
||||
|
||||
draw()
|
||||
|
||||
// 决定是否继续
|
||||
if (particles.value.length > 0 || shockwaves.value.length > 0) {
|
||||
// canvas 2d 模式下使用 canvas.requestAnimationFrame
|
||||
const alive =
|
||||
debrisList.value.length > 0 ||
|
||||
sparkList.value.length > 0 ||
|
||||
sparkleList.value.length > 0 ||
|
||||
flashList.value.length > 0 ||
|
||||
shockwaves.value.length > 0
|
||||
|
||||
if (alive) {
|
||||
rafId.value = canvasNode.value.requestAnimationFrame(renderLoop) as unknown as number
|
||||
} else {
|
||||
rafId.value = null
|
||||
@@ -303,35 +478,89 @@ function draw() {
|
||||
const w = canvasWidth.value
|
||||
const h = canvasHeight.value
|
||||
|
||||
// 清空画布(透明)
|
||||
c.clearRect(0, 0, w, h)
|
||||
|
||||
// 1. 绘制冲击波(底层)
|
||||
// —— 1. 冲击波(底层) ——
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
shockwaves.value.forEach(sw => {
|
||||
c.beginPath()
|
||||
c.arc(sw.x, sw.y, sw.radius, 0, Math.PI * 2)
|
||||
c.strokeStyle = applyAlphaToRgba(sw.color, sw.life)
|
||||
c.lineWidth = 3 * sw.life
|
||||
c.strokeStyle = applyAlphaToRgba(sw.color, Math.min(1, sw.life))
|
||||
c.lineWidth = sw.width * sw.life
|
||||
c.stroke()
|
||||
})
|
||||
|
||||
// 2. 绘制粒子(上层)
|
||||
particles.value.forEach(p => {
|
||||
const alpha = p.life
|
||||
const currentSize = p.size * (0.6 + p.life * 0.4)
|
||||
c.fillStyle = hexToRgba(p.color, alpha)
|
||||
drawParticleShape(c, p, currentSize)
|
||||
// —— 2. 中心爆闪(叠加发光) ——
|
||||
c.globalCompositeOperation = 'lighter'
|
||||
flashList.value.forEach(f => {
|
||||
const grd = c.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.radius)
|
||||
const a = Math.min(1, f.life)
|
||||
grd.addColorStop(0, hexToRgba(f.color, 0.95 * a))
|
||||
grd.addColorStop(0.4, hexToRgba(f.color, 0.5 * a))
|
||||
grd.addColorStop(1, hexToRgba(f.color, 0))
|
||||
c.fillStyle = grd
|
||||
c.beginPath()
|
||||
c.arc(f.x, f.y, f.radius, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
})
|
||||
|
||||
// —— 3. 碎片(实体,带辉光) ——
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
debrisList.value.forEach(p => {
|
||||
const alpha = Math.min(1, p.life)
|
||||
const currentSize = p.size * (0.65 + p.life * 0.35)
|
||||
|
||||
// 辉光: 同色低透明大圆衬底
|
||||
if (p.glow) {
|
||||
c.fillStyle = hexToRgba(p.color, alpha * 0.22)
|
||||
c.beginPath()
|
||||
c.arc(p.x, p.y, currentSize * 1.9, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
}
|
||||
|
||||
c.fillStyle = hexToRgba(p.color, alpha)
|
||||
drawDebrisShape(c, p, currentSize)
|
||||
})
|
||||
|
||||
// —— 4. 火花(拖尾 + 亮点,叠加发光) ——
|
||||
c.globalCompositeOperation = 'lighter'
|
||||
sparkList.value.forEach(s => {
|
||||
const a = Math.min(1, s.life)
|
||||
// 拖尾线
|
||||
c.strokeStyle = hexToRgba(s.color, a * 0.8)
|
||||
c.lineWidth = s.size * a
|
||||
c.lineCap = 'round'
|
||||
c.beginPath()
|
||||
c.moveTo(s.px, s.py)
|
||||
c.lineTo(s.x, s.y)
|
||||
c.stroke()
|
||||
// 头部亮点
|
||||
c.fillStyle = hexToRgba(s.color, a)
|
||||
c.beginPath()
|
||||
c.arc(s.x, s.y, s.size * a, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
})
|
||||
|
||||
// —— 5. 闪烁星光(四角星,叠加发光) ——
|
||||
sparkleList.value.forEach(sp => {
|
||||
// life 在 1→0, 用 sin 做"先亮后灭"的缩放
|
||||
const t = Math.max(0, Math.min(1, sp.life))
|
||||
const scale = Math.sin(Math.min(1, sp.life) * Math.PI) // 0→1→0
|
||||
if (scale <= 0.02) return
|
||||
const a = t
|
||||
drawStar(c, sp.x, sp.y, sp.size * scale, sp.size * scale * 0.32, sp.rotation, hexToRgba(sp.color, a))
|
||||
})
|
||||
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
}
|
||||
|
||||
function drawParticleShape(c: any, p: Particle, size: number) {
|
||||
function drawDebrisShape(c: any, p: Debris, size: number) {
|
||||
switch (p.shape) {
|
||||
case 'circle':
|
||||
c.beginPath()
|
||||
c.arc(p.x, p.y, size, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
break
|
||||
|
||||
case 'square':
|
||||
c.save()
|
||||
c.translate(p.x, p.y)
|
||||
@@ -339,15 +568,13 @@ function drawParticleShape(c: any, p: Particle, size: number) {
|
||||
c.fillRect(-size, -size, size * 2, size * 2)
|
||||
c.restore()
|
||||
break
|
||||
|
||||
case 'rect':
|
||||
c.save()
|
||||
c.translate(p.x, p.y)
|
||||
c.rotate(p.rotation)
|
||||
c.fillRect(-size * 1.5, -size * 0.5, size * 3, size)
|
||||
c.fillRect(-size * 1.6, -size * 0.5, size * 3.2, size)
|
||||
c.restore()
|
||||
break
|
||||
|
||||
case 'triangle':
|
||||
c.save()
|
||||
c.translate(p.x, p.y)
|
||||
@@ -363,6 +590,27 @@ function drawParticleShape(c: any, p: Particle, size: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// 四角星(尖锐十字星),用于高光闪烁
|
||||
function drawStar(c: any, cx: number, cy: number, outerR: number, innerR: number, rotation: number, fill: string) {
|
||||
const points = 4
|
||||
c.save()
|
||||
c.translate(cx, cy)
|
||||
c.rotate(rotation)
|
||||
c.beginPath()
|
||||
for (let i = 0; i < points * 2; i++) {
|
||||
const r = i % 2 === 0 ? outerR : innerR
|
||||
const a = (Math.PI * i) / points
|
||||
const x = Math.cos(a) * r
|
||||
const y = Math.sin(a) * r
|
||||
if (i === 0) c.moveTo(x, y)
|
||||
else c.lineTo(x, y)
|
||||
}
|
||||
c.closePath()
|
||||
c.fillStyle = fill
|
||||
c.fill()
|
||||
c.restore()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具函数
|
||||
// ============================================================
|
||||
@@ -398,13 +646,13 @@ function cleanup() {
|
||||
rafId.value = null
|
||||
}
|
||||
renderRunning.value = false
|
||||
particles.value = []
|
||||
debrisList.value = []
|
||||
sparkList.value = []
|
||||
sparkleList.value = []
|
||||
flashList.value = []
|
||||
shockwaves.value = []
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
triggerCrush,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
<template>
|
||||
<view class="grip-ring-canvas">
|
||||
<!-- 环境氛围:中心放射光晕(不形成边界,真正融入) -->
|
||||
<view class="ambient-glow" />
|
||||
|
||||
<!-- 捏碎特效画布 -->
|
||||
<CrushCanvas ref="crushCanvasRef" />
|
||||
|
||||
<!-- 握力环容器 -->
|
||||
<view class="ring-container" :class="{ playing: isPlaying }" @click="onButtonClick">
|
||||
<!-- 外发光(只在播放时启用) -->
|
||||
<view class="ring-aura" />
|
||||
|
||||
<!-- 握力环主体 -->
|
||||
<view class="grip-ring">
|
||||
<view class="ring-outer-circle" />
|
||||
<view class="ring-highlight" />
|
||||
<view class="ring-inner-circle" />
|
||||
</view>
|
||||
|
||||
<!-- 中心提示 (仅未开始时显示) -->
|
||||
<view v-if="!isPlaying" class="center-hint">
|
||||
<view class="hint-icon">
|
||||
<view class="play-triangle" />
|
||||
</view>
|
||||
<text class="hint-text">点击开始</text>
|
||||
</view>
|
||||
|
||||
<!-- BPM 显示 (播放时显示) -->
|
||||
<view v-else class="bpm-display">
|
||||
<text class="bpm-value">{{ bpm }}</text>
|
||||
<text class="bpm-label">BPM</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import CrushCanvas from './crush-canvas.vue'
|
||||
|
||||
interface Props {
|
||||
bpm: number
|
||||
isPlaying: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
togglePlay: []
|
||||
}>()
|
||||
|
||||
const crushCanvasRef = ref()
|
||||
|
||||
const onButtonClick = () => {
|
||||
emit('togglePlay')
|
||||
}
|
||||
|
||||
// 暴露捏碎特效触发方法
|
||||
const triggerCrushEffect = (itemType: 'egg' | 'walnut' | 'can' | 'balloon') => {
|
||||
crushCanvasRef.value?.triggerCrush(itemType)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
triggerCrushEffect
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ============================================================
|
||||
* 画布容器 - 完全融入页面,无边界
|
||||
* ============================================================ */
|
||||
.grip-ring-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* 与页面背景一致,无可见边界 */
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
/* 环境氛围 - 中心大放射光晕,营造空间感 */
|
||||
.ambient-glow {
|
||||
position: absolute;
|
||||
width: 1400rpx;
|
||||
height: 1400rpx;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle,
|
||||
rgba(255, 255, 255, 1) 0%,
|
||||
rgba(241, 245, 249, 0.6) 25%,
|
||||
rgba(226, 232, 240, 0.3) 50%,
|
||||
transparent 75%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 握力环容器 - 主视觉
|
||||
* ============================================================ */
|
||||
.ring-container {
|
||||
position: relative;
|
||||
width: 640rpx;
|
||||
height: 640rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 外发光 - 只在播放时显示(节省 GPU) */
|
||||
.ring-aura {
|
||||
position: absolute;
|
||||
width: 700rpx;
|
||||
height: 700rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle,
|
||||
rgba(244, 114, 182, 0.45) 0%,
|
||||
rgba(244, 114, 182, 0.15) 35%,
|
||||
rgba(244, 114, 182, 0) 70%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.ring-container.playing .ring-aura {
|
||||
opacity: 1;
|
||||
animation: aura-breath 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes aura-breath {
|
||||
0%, 100% {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 握力环主体 - 大尺寸沉浸感
|
||||
* ============================================================ */
|
||||
.grip-ring {
|
||||
position: relative;
|
||||
width: 520rpx;
|
||||
height: 520rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.ring-container:active .grip-ring {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
/* 外圈 - 粉色渐变(精致质感) */
|
||||
.ring-outer-circle {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg,
|
||||
#fda4af 0%,
|
||||
#f472b6 50%,
|
||||
#ec4899 100%);
|
||||
box-shadow:
|
||||
0 24rpx 64rpx rgba(236, 72, 153, 0.4),
|
||||
0 12rpx 32rpx rgba(244, 114, 182, 0.3),
|
||||
inset 0 -20rpx 40rpx rgba(190, 24, 93, 0.35),
|
||||
inset 0 20rpx 40rpx rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* 顶部高光 - 模拟材质感 */
|
||||
.ring-highlight {
|
||||
position: absolute;
|
||||
width: 420rpx;
|
||||
height: 120rpx;
|
||||
top: 40rpx;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(ellipse,
|
||||
rgba(255, 255, 255, 0.6) 0%,
|
||||
rgba(255, 255, 255, 0) 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 内圈中空 */
|
||||
.ring-inner-circle {
|
||||
position: absolute;
|
||||
width: 280rpx;
|
||||
height: 280rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||
box-shadow:
|
||||
inset 0 8rpx 20rpx rgba(190, 24, 93, 0.3),
|
||||
inset 0 -2rpx 8rpx rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* 播放时的挤压动画(只影响 transform,GPU 加速) */
|
||||
.ring-container.playing .grip-ring {
|
||||
animation: squeeze-ring 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes squeeze-ring {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.93);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 中心提示 (未开始)
|
||||
* ============================================================ */
|
||||
.center-hint {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
pointer-events: none;
|
||||
animation: hint-fade-in 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes hint-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.hint-icon {
|
||||
width: 108rpx;
|
||||
height: 108rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 12rpx 32rpx rgba(236, 72, 153, 0.35),
|
||||
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.9);
|
||||
animation: hint-bounce 2s ease-in-out infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.play-triangle {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 36rpx solid #ec4899;
|
||||
border-top: 24rpx solid transparent;
|
||||
border-bottom: 24rpx solid transparent;
|
||||
margin-left: 10rpx;
|
||||
}
|
||||
|
||||
@keyframes hint-bounce {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 28rpx;
|
||||
color: #be185d;
|
||||
font-weight: 700;
|
||||
letter-spacing: 4rpx;
|
||||
text-shadow: 0 2rpx 8rpx rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* BPM 显示 (播放中)
|
||||
* ============================================================ */
|
||||
.bpm-display {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4rpx;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bpm-value {
|
||||
font-size: 88rpx;
|
||||
color: #be185d;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
letter-spacing: -2rpx;
|
||||
text-shadow: 0 2rpx 8rpx rgba(255, 255, 255, 0.9);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bpm-label {
|
||||
font-size: 22rpx;
|
||||
color: #ec4899;
|
||||
font-weight: 700;
|
||||
letter-spacing: 6rpx;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -86,7 +86,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onShow, onHide } from '@dcloudio/uni-app'
|
||||
import { onShow, onHide, onUnload } from '@dcloudio/uni-app'
|
||||
import { useMetronome } from '../hooks/useMetronome'
|
||||
import WalkerCanvas from './components/walker-canvas.vue'
|
||||
|
||||
@@ -206,15 +206,15 @@ onShow(() => {
|
||||
fg.preload()
|
||||
})
|
||||
|
||||
onHide(() => {
|
||||
function cleanupMetronome() {
|
||||
fg.stop()
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
})
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
fg.stop()
|
||||
uni.setKeepScreenOn({ keepScreenOn: false })
|
||||
})
|
||||
onHide(cleanupMetronome)
|
||||
onUnload(cleanupMetronome)
|
||||
|
||||
onUnmounted(cleanupMetronome)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
|
||||
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
|
||||
// 编译产物默认输出到 ./dist/<mode>/<platform>
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
// 屏蔽 TUICallKit SDK 内部 @import / legacy-js-api 弃用警告
|
||||
// 待 TUICallKit 升级 @use 语法后可移除
|
||||
silenceDeprecations: ['legacy-js-api', 'import'],
|
||||
},
|
||||
},
|
||||
},
|
||||
// dev 热重载卡顿修复:
|
||||
// UNI_INPUT_DIR=$PWD 让工程根=源码目录, chokidar 会监听整个仓库,
|
||||
// 包含 node_modules / dist(编译产物) / unpackage 等巨大目录, 导致每次热重载卡住,
|
||||
// 写 dist 又触发监听抖动。这里收窄监听范围。
|
||||
// 注意: 自定义 ignored 会覆盖 vite 默认值, 故 node_modules/.git 必须一并列出。
|
||||
server: {
|
||||
watch: {
|
||||
ignored: [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
'**/dist/**',
|
||||
'**/unpackage/**',
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user