Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c4d696344 | ||
|
|
729d2a084d |
@@ -116,6 +116,17 @@
|
|||||||
<view class="menu-arrow">›</view>
|
<view class="menu-arrow">›</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="menu-item" @click="goto('/training/pages/camera-grip-ring')">
|
||||||
|
<view class="menu-icon menu-icon--grip">
|
||||||
|
<view class="grip-ring-shape" />
|
||||||
|
</view>
|
||||||
|
<view class="menu-info">
|
||||||
|
<text class="menu-title">握力环 2.0</text>
|
||||||
|
<text class="menu-sub">摄像头识别 · 试玩</text>
|
||||||
|
</view>
|
||||||
|
<view class="menu-arrow">›</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="menu-item" @click="goto('/training/pages/metronome')">
|
<view class="menu-item" @click="goto('/training/pages/metronome')">
|
||||||
<view class="menu-icon menu-icon--metro">
|
<view class="menu-icon menu-icon--metro">
|
||||||
<view class="metro-bar metro-bar-1" />
|
<view class="metro-bar metro-bar-1" />
|
||||||
@@ -163,6 +174,7 @@ const trainingItems: TrainingItem[] = [
|
|||||||
{ url: '/training/pages/foot-pedal', title: '脚蹬器', sub: '下肢有氧', icon: 'pedal', iconClass: 'menu-icon--pedal' },
|
{ url: '/training/pages/foot-pedal', title: '脚蹬器', sub: '下肢有氧', icon: 'pedal', iconClass: 'menu-icon--pedal' },
|
||||||
{ url: '/training/pages/pilates-ring', title: '瑜伽环', sub: '核心塑形', icon: 'pilates', iconClass: 'menu-icon--pilates' },
|
{ url: '/training/pages/pilates-ring', title: '瑜伽环', sub: '核心塑形', icon: 'pilates', iconClass: 'menu-icon--pilates' },
|
||||||
{ url: '/training/pages/grip-ring', title: '握力环', sub: '握力训练', icon: 'grip', iconClass: 'menu-icon--grip' },
|
{ url: '/training/pages/grip-ring', title: '握力环', sub: '握力训练', icon: 'grip', iconClass: 'menu-icon--grip' },
|
||||||
|
{ url: '/training/pages/camera-grip-ring', title: '握力环 2.0', sub: '摄像头识别', icon: 'grip', iconClass: 'menu-icon--grip' },
|
||||||
{ url: '/training/pages/metronome', title: '耗糖节拍器', sub: '健走配速', icon: 'metro', iconClass: 'menu-icon--metro', featured: true },
|
{ url: '/training/pages/metronome', title: '耗糖节拍器', sub: '健走配速', icon: 'metro', iconClass: 'menu-icon--metro', featured: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,17 @@
|
|||||||
"backgroundColor": "#f8fafc"
|
"backgroundColor": "#f8fafc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/camera-grip-ring",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "握力环训练 2.0",
|
||||||
|
"navigationBarBackgroundColor": "#F8FAFC",
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"backgroundColor": "#F8FAFC",
|
||||||
|
"disableScroll": true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/pilates-ring",
|
"path": "pages/pilates-ring",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { CameraFrame } from './types'
|
||||||
|
|
||||||
|
type FrameListener = {
|
||||||
|
start: () => void
|
||||||
|
stop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type CameraContextLike = {
|
||||||
|
onCameraFrame?: (cb: (frame: { width: number; height: number; data: ArrayBuffer }) => void) => FrameListener
|
||||||
|
}
|
||||||
|
|
||||||
|
function createContext(cameraId?: string): CameraContextLike | null {
|
||||||
|
try {
|
||||||
|
// uni-app 优先:可按 camera 组件 id 绑定
|
||||||
|
if (typeof uni !== 'undefined' && typeof uni.createCameraContext === 'function') {
|
||||||
|
return uni.createCameraContext(cameraId || 'gripCamera') as CameraContextLike
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
if (typeof wx !== 'undefined' && typeof wx.createCameraContext === 'function') {
|
||||||
|
// @ts-ignore
|
||||||
|
return wx.createCameraContext() as CameraContextLike
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
// #endif
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCameraFrameSupported(): boolean {
|
||||||
|
try {
|
||||||
|
const ctx = createContext('gripCamera')
|
||||||
|
return typeof ctx?.onCameraFrame === 'function'
|
||||||
|
} catch (_) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理 CameraContext.onCameraFrame:只保留最新一帧,不入 Vue 响应式。
|
||||||
|
*/
|
||||||
|
export function createCameraFramePump(options?: { cameraId?: string }) {
|
||||||
|
let listener: FrameListener | null = null
|
||||||
|
let latest: CameraFrame | null = null
|
||||||
|
let running = false
|
||||||
|
let callbackCount = 0
|
||||||
|
let fpsWindowStart = Date.now()
|
||||||
|
let fps = 0
|
||||||
|
let onFrame: ((frame: CameraFrame) => void) | null = null
|
||||||
|
|
||||||
|
function start(cb: (frame: CameraFrame) => void) {
|
||||||
|
stop()
|
||||||
|
onFrame = cb
|
||||||
|
const ctx = createContext(options?.cameraId || 'gripCamera')
|
||||||
|
if (!ctx?.onCameraFrame) {
|
||||||
|
throw new Error('当前基础库不支持 onCameraFrame')
|
||||||
|
}
|
||||||
|
|
||||||
|
listener = ctx.onCameraFrame((raw) => {
|
||||||
|
if (!running) return
|
||||||
|
const frame: CameraFrame = {
|
||||||
|
width: raw.width,
|
||||||
|
height: raw.height,
|
||||||
|
data: raw.data,
|
||||||
|
ts: Date.now(),
|
||||||
|
}
|
||||||
|
latest = frame
|
||||||
|
callbackCount += 1
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - fpsWindowStart >= 1000) {
|
||||||
|
fps = callbackCount
|
||||||
|
callbackCount = 0
|
||||||
|
fpsWindowStart = now
|
||||||
|
}
|
||||||
|
onFrame?.(frame)
|
||||||
|
})
|
||||||
|
running = true
|
||||||
|
listener.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
running = false
|
||||||
|
try {
|
||||||
|
listener?.stop()
|
||||||
|
} catch (_) {}
|
||||||
|
listener = null
|
||||||
|
onFrame = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatest() {
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFps() {
|
||||||
|
return fps
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRunning() {
|
||||||
|
return running
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, stop, getLatest, getFps, isRunning }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CameraFramePump = ReturnType<typeof createCameraFramePump>
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { createCameraFramePump } from './camera-frames'
|
||||||
|
import { createOpenCloseClassifier } from './open-close-classifier'
|
||||||
|
import { estimateOpennessFromRoi } from './roi-openness'
|
||||||
|
import type { GestureObservation, GestureSource, RepCompletedInfo } from './types'
|
||||||
|
import {
|
||||||
|
createVisionKitHandDetector,
|
||||||
|
getVisionKitSupportHint,
|
||||||
|
isLikelyVisionKitUnsupportedHost,
|
||||||
|
isVisionKitHandSupported,
|
||||||
|
} from './visionkit-hand'
|
||||||
|
|
||||||
|
export interface GesturePipelineHandlers {
|
||||||
|
onObservation: (obs: GestureObservation) => void
|
||||||
|
onRepCompleted?: (info: RepCompletedInfo) => void
|
||||||
|
/** 非致命提示(如已降级 ROI) */
|
||||||
|
onWarning?: (message: string) => void
|
||||||
|
/** 致命错误(相机帧都起不来) */
|
||||||
|
onError?: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编排:优先 VisionKit;开发者工具/不支持设备自动降级 ROI 联调。
|
||||||
|
*/
|
||||||
|
export function createGesturePipeline(handlers: GesturePipelineHandlers) {
|
||||||
|
const pump = createCameraFramePump()
|
||||||
|
// ~10Hz;置信度适中,减少噪声帧
|
||||||
|
const detector = createVisionKitHandDetector({ minIntervalMs: 100, scoreThreshold: 0.28 })
|
||||||
|
// 握力环校准:半开即可计张开,相对抬升即可完成松开
|
||||||
|
const classifier = createOpenCloseClassifier({
|
||||||
|
openEnter: 0.45,
|
||||||
|
openExit: 0.38,
|
||||||
|
closeEnter: 0.36,
|
||||||
|
closeExit: 0.42,
|
||||||
|
emaAlpha: 0.42,
|
||||||
|
minHoldMs: 120,
|
||||||
|
lostMs: 1400,
|
||||||
|
reopenRise: 0.08,
|
||||||
|
})
|
||||||
|
|
||||||
|
let active = false
|
||||||
|
let paused = false
|
||||||
|
let mode: GestureSource = 'none'
|
||||||
|
let lastRoiTs = 0
|
||||||
|
let lastUiEmitTs = 0
|
||||||
|
let lastEmittedPhase = ''
|
||||||
|
let lastEmittedCompression = -1
|
||||||
|
let lastEmittedHold = -1
|
||||||
|
let lastSyntheticTs = 0
|
||||||
|
const UI_EMIT_MIN_MS = 100
|
||||||
|
|
||||||
|
function emitFromSample(sample: Parameters<typeof classifier.update>[0]) {
|
||||||
|
if (!active || paused) return
|
||||||
|
const out = classifier.update(sample)
|
||||||
|
const now = Date.now()
|
||||||
|
const phaseChanged = out.phase !== lastEmittedPhase
|
||||||
|
const due = now - lastUiEmitTs >= UI_EMIT_MIN_MS
|
||||||
|
const compressionDelta = Math.abs(out.compression - lastEmittedCompression) >= 0.08
|
||||||
|
const holdDelta = Math.abs(out.holdProgress - lastEmittedHold) >= 0.12
|
||||||
|
|
||||||
|
// 计次始终即时;UI 观察节流,避免小程序每帧 setData 卡顿
|
||||||
|
if (out.rep) {
|
||||||
|
handlers.onRepCompleted?.(out.rep)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!phaseChanged && !due && !compressionDelta && !holdDelta && !out.rep) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUiEmitTs = now
|
||||||
|
lastEmittedPhase = out.phase
|
||||||
|
lastEmittedCompression = out.compression
|
||||||
|
lastEmittedHold = out.holdProgress
|
||||||
|
|
||||||
|
handlers.onObservation({
|
||||||
|
ts: now,
|
||||||
|
openness: out.openness,
|
||||||
|
compression: out.compression,
|
||||||
|
phase: out.phase,
|
||||||
|
trackingQuality: out.trackingQuality,
|
||||||
|
source: out.source,
|
||||||
|
holdProgress: out.holdProgress,
|
||||||
|
fps: pump.getFps(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startVisionKit(): Promise<boolean> {
|
||||||
|
if (!isVisionKitHandSupported()) return false
|
||||||
|
try {
|
||||||
|
await detector.start((sample) => emitFromSample(sample))
|
||||||
|
mode = 'visionkit'
|
||||||
|
return true
|
||||||
|
} catch (_) {
|
||||||
|
detector.stop()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRoiFallback(reason?: string) {
|
||||||
|
mode = 'roi'
|
||||||
|
const hint = reason || getVisionKitSupportHint() || '已降级为 ROI 联调模式(非真机手势识别)'
|
||||||
|
handlers.onWarning?.(hint)
|
||||||
|
pump.start((frame) => {
|
||||||
|
if (!active || paused) return
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastRoiTs < 160) return
|
||||||
|
lastRoiTs = now
|
||||||
|
emitFromSample(estimateOpennessFromRoi(frame))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
stop()
|
||||||
|
|
||||||
|
// 开发者工具直接走 ROI,避免 createVKSession 抛 “does not support version v1”
|
||||||
|
const forceRoi = isLikelyVisionKitUnsupportedHost()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!forceRoi) {
|
||||||
|
const vkOk = await startVisionKit()
|
||||||
|
if (vkOk) {
|
||||||
|
active = true
|
||||||
|
paused = false
|
||||||
|
pump.start((frame) => {
|
||||||
|
if (!active || paused) return
|
||||||
|
detector.pushFrame(frame)
|
||||||
|
// 手离开画面时 VK 可能完全静默:补发“无手”样本,
|
||||||
|
// 让状态机能正常走丢失→UNTRACKED,回来后能重新武装
|
||||||
|
const latest = detector.getLatest()
|
||||||
|
const now = Date.now()
|
||||||
|
if ((!latest || now - latest.ts > 500) && now - lastSyntheticTs > 250) {
|
||||||
|
lastSyntheticTs = now
|
||||||
|
emitFromSample(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VisionKit 不可用:ROI 回退,保证链路可联调
|
||||||
|
active = true
|
||||||
|
paused = false
|
||||||
|
startRoiFallback(forceRoi ? getVisionKitSupportHint() : 'VisionKit 启动失败,已降级 ROI 联调')
|
||||||
|
return true
|
||||||
|
} catch (e: any) {
|
||||||
|
handlers.onError?.(e?.message || '手势管线启动失败')
|
||||||
|
stop()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPaused(v: boolean) {
|
||||||
|
paused = v
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNearGoal(enabled: boolean) {
|
||||||
|
classifier.setNearGoal(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
active = false
|
||||||
|
paused = false
|
||||||
|
mode = 'none'
|
||||||
|
lastRoiTs = 0
|
||||||
|
lastUiEmitTs = 0
|
||||||
|
lastEmittedPhase = ''
|
||||||
|
lastEmittedCompression = -1
|
||||||
|
lastEmittedHold = -1
|
||||||
|
lastSyntheticTs = 0
|
||||||
|
pump.stop()
|
||||||
|
detector.stop()
|
||||||
|
classifier.setNearGoal(false)
|
||||||
|
classifier.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive() {
|
||||||
|
return active
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMode() {
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, stop, setPaused, setNearGoal, isActive, getMode }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GesturePipeline = ReturnType<typeof createGesturePipeline>
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import type { GesturePhase, GestureSource, HandSample, RepCompletedInfo, TrackingQuality } from './types'
|
||||||
|
|
||||||
|
export interface ClassifierOutput {
|
||||||
|
phase: GesturePhase
|
||||||
|
trackingQuality: TrackingQuality
|
||||||
|
openness: number
|
||||||
|
compression: number
|
||||||
|
holdProgress: number
|
||||||
|
source: GestureSource
|
||||||
|
rep?: RepCompletedInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开合状态机(握力环校准版)
|
||||||
|
* 计数:OPEN → CLOSING → CLOSED(保持) → REOPENING → 相对张开,才 +1
|
||||||
|
* 松开不必完全张开:相对握紧谷底抬升即可计次,避免做到后期张不开导致卡在 29
|
||||||
|
*/
|
||||||
|
export function createOpenCloseClassifier(options?: {
|
||||||
|
openEnter?: number
|
||||||
|
openExit?: number
|
||||||
|
closeEnter?: number
|
||||||
|
closeExit?: number
|
||||||
|
minHoldMs?: number
|
||||||
|
lostMs?: number
|
||||||
|
emaAlpha?: number
|
||||||
|
/** 相对松开:相对握紧谷底至少抬升这么多才计 1 次 */
|
||||||
|
reopenRise?: number
|
||||||
|
}) {
|
||||||
|
const openEnter = options?.openEnter ?? 0.48
|
||||||
|
const openExit = options?.openExit ?? 0.4
|
||||||
|
const closeEnter = options?.closeEnter ?? 0.38
|
||||||
|
const closeExit = options?.closeExit ?? 0.44
|
||||||
|
const minHoldMs = options?.minHoldMs ?? 140
|
||||||
|
const lostMs = options?.lostMs ?? 1200
|
||||||
|
const emaAlpha = options?.emaAlpha ?? 0.4
|
||||||
|
const reopenRise = options?.reopenRise ?? 0.1
|
||||||
|
|
||||||
|
let phase: GesturePhase = 'UNTRACKED'
|
||||||
|
let openness = 0.5
|
||||||
|
let holdStartedAt = 0
|
||||||
|
let holdProgress = 0
|
||||||
|
let lastSeenAt = 0
|
||||||
|
let opennessPeak = 0
|
||||||
|
let closedTrough = 1
|
||||||
|
let source: GestureSource = 'none'
|
||||||
|
let lastRepTs = 0
|
||||||
|
|
||||||
|
let reopenRiseActive = reopenRise
|
||||||
|
|
||||||
|
function resetPartial() {
|
||||||
|
holdStartedAt = 0
|
||||||
|
holdProgress = 0
|
||||||
|
opennessPeak = 0
|
||||||
|
closedTrough = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 接近目标次数时进一步放宽松开判定 */
|
||||||
|
function setNearGoal(enabled: boolean) {
|
||||||
|
reopenRiseActive = enabled ? 0.045 : reopenRise
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCountRep(now: number) {
|
||||||
|
// 防连发:两次有效计次至少间隔 280ms
|
||||||
|
return now - lastRepTs >= 280
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(sample: HandSample | null, now = Date.now()): ClassifierOutput {
|
||||||
|
let rep: RepCompletedInfo | undefined
|
||||||
|
|
||||||
|
if (!sample || sample.score <= 0.05) {
|
||||||
|
if (lastSeenAt && now - lastSeenAt > lostMs) {
|
||||||
|
phase = 'UNTRACKED'
|
||||||
|
resetPartial()
|
||||||
|
}
|
||||||
|
const withinGrace = lastSeenAt > 0 && now - lastSeenAt <= 600
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
trackingQuality: phase === 'UNTRACKED' ? 'lost' : withinGrace ? 'good' : 'weak',
|
||||||
|
openness,
|
||||||
|
compression: 1 - openness,
|
||||||
|
holdProgress,
|
||||||
|
source,
|
||||||
|
rep,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSeenAt = now
|
||||||
|
source = sample.source
|
||||||
|
openness = openness * (1 - emaAlpha) + sample.openness * emaAlpha
|
||||||
|
const quality: TrackingQuality = sample.score >= 0.22 ? 'good' : 'weak'
|
||||||
|
|
||||||
|
switch (phase) {
|
||||||
|
case 'UNTRACKED':
|
||||||
|
if (openness >= openEnter) {
|
||||||
|
phase = 'OPEN_READY'
|
||||||
|
opennessPeak = openness
|
||||||
|
resetPartial()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'OPEN_READY':
|
||||||
|
opennessPeak = Math.max(opennessPeak, openness)
|
||||||
|
if (openness <= closeEnter) {
|
||||||
|
phase = 'CLOSING'
|
||||||
|
closedTrough = openness
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'CLOSING':
|
||||||
|
closedTrough = Math.min(closedTrough, openness)
|
||||||
|
if (openness <= closeEnter) {
|
||||||
|
phase = 'CLOSED_CONFIRMED'
|
||||||
|
holdStartedAt = now
|
||||||
|
holdProgress = 0
|
||||||
|
closedTrough = openness
|
||||||
|
} else if (openness >= openEnter) {
|
||||||
|
phase = 'OPEN_READY'
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'CLOSED_CONFIRMED': {
|
||||||
|
closedTrough = Math.min(closedTrough, openness)
|
||||||
|
const held = now - holdStartedAt
|
||||||
|
holdProgress = Math.min(1, held / minHoldMs)
|
||||||
|
if (held >= minHoldMs && openness >= closeExit) {
|
||||||
|
phase = 'REOPENING'
|
||||||
|
} else if (openness >= openEnter && held < minHoldMs) {
|
||||||
|
// 保持不足就完全张开:作废本轮
|
||||||
|
phase = 'OPEN_READY'
|
||||||
|
resetPartial()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'REOPENING': {
|
||||||
|
closedTrough = Math.min(closedTrough, openness)
|
||||||
|
const rise = openness - closedTrough
|
||||||
|
// 绝对张开,或相对握紧谷底抬升足够 → 计 1 次
|
||||||
|
const reopened = openness >= openEnter || (rise >= reopenRiseActive && openness >= closeExit)
|
||||||
|
if (reopened && canCountRep(now)) {
|
||||||
|
lastRepTs = now
|
||||||
|
rep = {
|
||||||
|
ts: now,
|
||||||
|
holdMs: holdStartedAt ? now - holdStartedAt : 0,
|
||||||
|
opennessPeak,
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
phase = 'OPEN_READY'
|
||||||
|
resetPartial()
|
||||||
|
opennessPeak = openness
|
||||||
|
} else if (openness <= closeEnter) {
|
||||||
|
// 松开中又握回去:回到保持,不直接作废
|
||||||
|
phase = 'CLOSED_CONFIRMED'
|
||||||
|
holdStartedAt = now
|
||||||
|
holdProgress = 0
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
trackingQuality: quality,
|
||||||
|
openness,
|
||||||
|
compression: 1 - openness,
|
||||||
|
holdProgress,
|
||||||
|
source,
|
||||||
|
rep,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
phase = 'UNTRACKED'
|
||||||
|
openness = 0.5
|
||||||
|
source = 'none'
|
||||||
|
lastSeenAt = 0
|
||||||
|
lastRepTs = 0
|
||||||
|
reopenRiseActive = reopenRise
|
||||||
|
resetPartial()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPhase() {
|
||||||
|
return phase
|
||||||
|
}
|
||||||
|
|
||||||
|
return { update, reset, getPhase, setNearGoal }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpenCloseClassifier = ReturnType<typeof createOpenCloseClassifier>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { HandSample, Point2D } from './types'
|
||||||
|
|
||||||
|
/** VisionKit 文档:1=布/开掌,3=握拳 */
|
||||||
|
const GESTURE_OPEN = 1
|
||||||
|
const GESTURE_FIST = 3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenPose-21 指尖索引(相对手腕距离用于连续 openness)
|
||||||
|
* 0 wrist, 4 thumb tip, 8 index, 12 middle, 16 ring, 20 pinky
|
||||||
|
*/
|
||||||
|
const TIP_INDEXES = [4, 8, 12, 16, 20]
|
||||||
|
|
||||||
|
function dist(a: Point2D, b: Point2D) {
|
||||||
|
const dx = a.x - b.x
|
||||||
|
const dy = a.y - b.y
|
||||||
|
return Math.sqrt(dx * dx + dy * dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(v: number) {
|
||||||
|
return Math.max(0, Math.min(1, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 离散手势 → 开合度;未知手势返回 null(交给几何或保持) */
|
||||||
|
export function opennessFromGesture(gesture?: number): number | null {
|
||||||
|
if (gesture === GESTURE_OPEN) return 1
|
||||||
|
if (gesture === GESTURE_FIST) return 0
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用指尖到手腕的平均距离 / 掌宽 估计 openness。
|
||||||
|
* 握力环场景开合幅度偏小,映射区间比裸掌更紧一些。
|
||||||
|
*/
|
||||||
|
export function opennessFromLandmarks(points?: Point2D[]): number | null {
|
||||||
|
if (!points || points.length < 21) return null
|
||||||
|
const wrist = points[0]
|
||||||
|
const mcpIndex = points[5]
|
||||||
|
const mcpPinky = points[17]
|
||||||
|
const palmWidth = dist(mcpIndex, mcpPinky)
|
||||||
|
if (palmWidth < 1e-6) return null
|
||||||
|
|
||||||
|
let tipSum = 0
|
||||||
|
for (const i of TIP_INDEXES) {
|
||||||
|
tipSum += dist(points[i], wrist)
|
||||||
|
}
|
||||||
|
const tipMean = tipSum / TIP_INDEXES.length
|
||||||
|
// 握力环:握紧约 0.9~1.35 掌宽,半开约 1.5~2.2 掌宽
|
||||||
|
const ratio = tipMean / palmWidth
|
||||||
|
return clamp01((ratio - 0.95) / 1.15)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeOpenness(sample: Pick<HandSample, 'gesture' | 'points'>): number | null {
|
||||||
|
const fromGeom = opennessFromLandmarks(sample.points)
|
||||||
|
const fromGesture = opennessFromGesture(sample.gesture)
|
||||||
|
|
||||||
|
if (fromGeom != null && fromGesture != null) {
|
||||||
|
// 几何为主:离散手势(布/拳)在捏环时容易误判,只做轻牵引
|
||||||
|
return clamp01(fromGeom * 0.85 + fromGesture * 0.15)
|
||||||
|
}
|
||||||
|
if (fromGeom != null) return fromGeom
|
||||||
|
if (fromGesture != null) return fromGesture
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { CameraFrame, HandSample } from './types'
|
||||||
|
|
||||||
|
function clamp01(v: number) {
|
||||||
|
return Math.max(0, Math.min(1, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开发者工具 / 不支持 VisionKit 时的粗开合估计:
|
||||||
|
* 取画面中心 ROI 的亮度方差与边缘能量,映射到 openness。
|
||||||
|
* 仅用于打通链路与 UI 联调,不能作为产品识别。
|
||||||
|
*/
|
||||||
|
export function estimateOpennessFromRoi(frame: CameraFrame): HandSample {
|
||||||
|
const { width, height, data } = frame
|
||||||
|
const u8 = new Uint8Array(data)
|
||||||
|
const cx0 = Math.floor(width * 0.28)
|
||||||
|
const cy0 = Math.floor(height * 0.22)
|
||||||
|
const cx1 = Math.floor(width * 0.72)
|
||||||
|
const cy1 = Math.floor(height * 0.78)
|
||||||
|
const step = Math.max(4, Math.floor(Math.min(width, height) / 40))
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
let sum = 0
|
||||||
|
let sumSq = 0
|
||||||
|
let edge = 0
|
||||||
|
let prev = 0
|
||||||
|
|
||||||
|
for (let y = cy0; y < cy1; y += step) {
|
||||||
|
for (let x = cx0; x < cx1; x += step) {
|
||||||
|
const i = (y * width + x) * 4
|
||||||
|
const r = u8[i]
|
||||||
|
const g = u8[i + 1]
|
||||||
|
const b = u8[i + 2]
|
||||||
|
// 粗略肤色门控 + 亮度
|
||||||
|
const luma = 0.299 * r + 0.587 * g + 0.114 * b
|
||||||
|
const skinish = r > 60 && g > 30 && b > 20 && r >= g && Math.abs(r - g) > 8
|
||||||
|
if (!skinish && luma < 40) continue
|
||||||
|
sum += luma
|
||||||
|
sumSq += luma * luma
|
||||||
|
edge += Math.abs(luma - prev)
|
||||||
|
prev = luma
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count < 20) {
|
||||||
|
return {
|
||||||
|
ts: Date.now(),
|
||||||
|
openness: 0.5,
|
||||||
|
score: 0.05,
|
||||||
|
source: 'roi',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mean = sum / count
|
||||||
|
const variance = Math.max(0, sumSq / count - mean * mean)
|
||||||
|
const edgeNorm = edge / count
|
||||||
|
|
||||||
|
// 张开手掌通常带来更大轮廓/边缘;握拳更紧凑。经验映射,仅联调。
|
||||||
|
const openness = clamp01((Math.sqrt(variance) / 55 + edgeNorm / 40) * 0.85)
|
||||||
|
const score = clamp01(0.25 + count / 800)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ts: Date.now(),
|
||||||
|
openness,
|
||||||
|
score,
|
||||||
|
source: 'roi',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/** 握力环 2.0 视觉观测与状态机共享类型 */
|
||||||
|
|
||||||
|
export type TrackingQuality = 'good' | 'weak' | 'lost'
|
||||||
|
|
||||||
|
export type GesturePhase =
|
||||||
|
| 'UNTRACKED'
|
||||||
|
| 'OPEN_READY'
|
||||||
|
| 'CLOSING'
|
||||||
|
| 'CLOSED_CONFIRMED'
|
||||||
|
| 'REOPENING'
|
||||||
|
|
||||||
|
export type GestureSource = 'visionkit' | 'roi' | 'mock' | 'none'
|
||||||
|
|
||||||
|
export interface Point2D {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CameraFrame {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
data: ArrayBuffer
|
||||||
|
ts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HandSample {
|
||||||
|
ts: number
|
||||||
|
/** 0=握紧 … 1=张开 */
|
||||||
|
openness: number
|
||||||
|
score: number
|
||||||
|
gesture?: number
|
||||||
|
points?: Point2D[]
|
||||||
|
source: GestureSource
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GestureObservation {
|
||||||
|
ts: number
|
||||||
|
openness: number
|
||||||
|
/** 0=张开 … 1=握紧,供能量环使用 */
|
||||||
|
compression: number
|
||||||
|
phase: GesturePhase
|
||||||
|
trackingQuality: TrackingQuality
|
||||||
|
source: GestureSource
|
||||||
|
holdProgress: number
|
||||||
|
fps: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepCompletedInfo {
|
||||||
|
ts: number
|
||||||
|
holdMs: number
|
||||||
|
opennessPeak: number
|
||||||
|
source: GestureSource
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import type { CameraFrame, HandSample, Point2D } from './types'
|
||||||
|
import { mergeOpenness } from './openness'
|
||||||
|
|
||||||
|
type VkSession = {
|
||||||
|
start: (cb?: (errno: number | null) => void) => void
|
||||||
|
stop?: () => void
|
||||||
|
destroy?: () => void
|
||||||
|
detectHand?: (opts: Record<string, unknown>) => void
|
||||||
|
on?: (event: string, cb: (...args: any[]) => void) => void
|
||||||
|
off?: (event: string, cb?: (...args: any[]) => void) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type WxLike = {
|
||||||
|
createVKSession?: (opts: Record<string, unknown>) => VkSession
|
||||||
|
getSystemInfoSync?: () => { SDKVersion?: string; platform?: string; brand?: string; model?: string }
|
||||||
|
getAppBaseInfo?: () => { SDKVersion?: string }
|
||||||
|
getDeviceInfo?: () => { platform?: string; brand?: string; model?: string; system?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWx(): WxLike | null {
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// @ts-ignore
|
||||||
|
if (typeof wx !== 'undefined') return wx as WxLike
|
||||||
|
// #endif
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersion(v?: string) {
|
||||||
|
if (!v) return [0, 0, 0]
|
||||||
|
return v.split('.').map((n) => Number(n) || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function gteVersion(current: string | undefined, need: string) {
|
||||||
|
const a = parseVersion(current)
|
||||||
|
const b = parseVersion(need)
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (a[i] > b[i]) return true
|
||||||
|
if (a[i] < b[i]) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRuntimeInfo() {
|
||||||
|
const wxApi = getWx()
|
||||||
|
let SDKVersion = ''
|
||||||
|
let platform = ''
|
||||||
|
let brand = ''
|
||||||
|
let model = ''
|
||||||
|
try {
|
||||||
|
const base = wxApi?.getAppBaseInfo?.()
|
||||||
|
if (base?.SDKVersion) SDKVersion = base.SDKVersion
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
const device = wxApi?.getDeviceInfo?.()
|
||||||
|
if (device?.platform) platform = String(device.platform)
|
||||||
|
if (device?.brand) brand = String(device.brand)
|
||||||
|
if (device?.model) model = String(device.model)
|
||||||
|
} catch (_) {}
|
||||||
|
// 兼容旧环境
|
||||||
|
if (!SDKVersion || !platform) {
|
||||||
|
try {
|
||||||
|
const info = wxApi?.getSystemInfoSync?.() || {}
|
||||||
|
SDKVersion = SDKVersion || info.SDKVersion || ''
|
||||||
|
platform = platform || String(info.platform || '')
|
||||||
|
brand = brand || String(info.brand || '')
|
||||||
|
model = model || String(info.model || '')
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return { SDKVersion, platform, brand, model }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开发者工具 / 模拟器通常不支持 VisionKit Hand */
|
||||||
|
export function isLikelyVisionKitUnsupportedHost(): boolean {
|
||||||
|
const { platform, brand, model } = readRuntimeInfo()
|
||||||
|
const blob = `${platform} ${brand} ${model}`.toLowerCase()
|
||||||
|
return (
|
||||||
|
blob.includes('devtools') ||
|
||||||
|
blob.includes('mac') ||
|
||||||
|
blob.includes('windows') ||
|
||||||
|
blob.includes('devtools') ||
|
||||||
|
platform === 'devtools'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVisionKitHandSupported(): boolean {
|
||||||
|
const wxApi = getWx()
|
||||||
|
if (!wxApi?.createVKSession) return false
|
||||||
|
if (isLikelyVisionKitUnsupportedHost()) return false
|
||||||
|
const { SDKVersion } = readRuntimeInfo()
|
||||||
|
// VisionKit Hand: 基础库 2.28.0+
|
||||||
|
return gteVersion(SDKVersion, '2.28.0')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVisionKitSupportHint(): string {
|
||||||
|
if (isLikelyVisionKitUnsupportedHost()) {
|
||||||
|
return '当前是开发者工具/电脑环境,VisionKit 不可用,请用真机预览;已自动降级为 ROI 联调模式'
|
||||||
|
}
|
||||||
|
if (!getWx()?.createVKSession) {
|
||||||
|
return '当前微信不支持 VisionKit'
|
||||||
|
}
|
||||||
|
const { SDKVersion } = readRuntimeInfo()
|
||||||
|
if (!gteVersion(SDKVersion, '2.28.0')) {
|
||||||
|
return `基础库 ${SDKVersion || '未知'} 过低,VisionKit Hand 需 ≥ 2.28.0`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePoints(raw: any): Point2D[] | undefined {
|
||||||
|
if (!Array.isArray(raw) || raw.length === 0) return undefined
|
||||||
|
return raw.map((p) => {
|
||||||
|
if (Array.isArray(p)) return { x: Number(p[0]) || 0, y: Number(p[1]) || 0 }
|
||||||
|
return { x: Number(p?.x) || 0, y: Number(p?.y) || 0 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnsupportedDeviceError(err: unknown) {
|
||||||
|
const msg = String((err as any)?.message || err || '')
|
||||||
|
return (
|
||||||
|
msg.includes('does not support version') ||
|
||||||
|
msg.includes('not support') ||
|
||||||
|
msg.includes('不支持') ||
|
||||||
|
msg.includes('v1')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VisionKit Hand mode=2:由业务喂入 onCameraFrame 的 RGBA。
|
||||||
|
* 内部节流,保证同一时刻最多一次 detectHand。
|
||||||
|
*/
|
||||||
|
export function createVisionKitHandDetector(options?: {
|
||||||
|
scoreThreshold?: number
|
||||||
|
minIntervalMs?: number
|
||||||
|
}) {
|
||||||
|
const scoreThreshold = options?.scoreThreshold ?? 0.5
|
||||||
|
const minIntervalMs = options?.minIntervalMs ?? 100 // ~10Hz
|
||||||
|
// busy 卡死看门狗:detectHand 后超过该时长无回调则强制释放
|
||||||
|
// 注意:手不在画面时 VK 可能整段不回调,这属于正常情况,不能当作会话挂死
|
||||||
|
const BUSY_TIMEOUT_MS = 700
|
||||||
|
|
||||||
|
let session: VkSession | null = null
|
||||||
|
let started = false
|
||||||
|
let busy = false
|
||||||
|
let busySince = 0
|
||||||
|
let restarting = false
|
||||||
|
let sessionStartedAt = 0
|
||||||
|
let lastCallbackTs = 0
|
||||||
|
let lastDetectTs = 0
|
||||||
|
let latestSample: HandSample | null = null
|
||||||
|
let onSample: ((sample: HandSample) => void) | null = null
|
||||||
|
|
||||||
|
function handleAnchors(anchors: any[]) {
|
||||||
|
busy = false
|
||||||
|
lastCallbackTs = Date.now()
|
||||||
|
if (!anchors || !anchors.length) {
|
||||||
|
latestSample = {
|
||||||
|
ts: Date.now(),
|
||||||
|
openness: latestSample?.openness ?? 0.5,
|
||||||
|
score: 0,
|
||||||
|
source: 'visionkit',
|
||||||
|
}
|
||||||
|
onSample?.(latestSample)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...anchors].sort((a, b) => (Number(b?.score) || 0) - (Number(a?.score) || 0))
|
||||||
|
const best = sorted[0]
|
||||||
|
const points = normalizePoints(best?.points)
|
||||||
|
const gesture = Number(best?.gesture)
|
||||||
|
const score = Number(best?.score) || Number(best?.confidence?.[0]) || 0
|
||||||
|
const openness = mergeOpenness({ gesture, points })
|
||||||
|
|
||||||
|
latestSample = {
|
||||||
|
ts: Date.now(),
|
||||||
|
openness: openness == null ? (latestSample?.openness ?? 0.5) : openness,
|
||||||
|
score,
|
||||||
|
gesture: Number.isFinite(gesture) ? gesture : undefined,
|
||||||
|
points,
|
||||||
|
source: 'visionkit',
|
||||||
|
}
|
||||||
|
onSample?.(latestSample)
|
||||||
|
}
|
||||||
|
|
||||||
|
function teardownSession() {
|
||||||
|
busy = false
|
||||||
|
try {
|
||||||
|
session?.stop?.()
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
session?.destroy?.()
|
||||||
|
} catch (_) {}
|
||||||
|
session = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAndStartSession(): Promise<void> {
|
||||||
|
const wxApi = getWx()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
session = wxApi!.createVKSession!({
|
||||||
|
track: {
|
||||||
|
hand: { mode: 2 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
session.on?.('updateAnchors', (anchors: any[]) => {
|
||||||
|
handleAnchors(anchors || [])
|
||||||
|
})
|
||||||
|
session.on?.('removeAnchors', () => {
|
||||||
|
busy = false
|
||||||
|
lastCallbackTs = Date.now()
|
||||||
|
latestSample = {
|
||||||
|
ts: Date.now(),
|
||||||
|
openness: latestSample?.openness ?? 0.5,
|
||||||
|
score: 0,
|
||||||
|
source: 'visionkit',
|
||||||
|
}
|
||||||
|
onSample?.(latestSample)
|
||||||
|
})
|
||||||
|
session.start?.((errno) => {
|
||||||
|
if (errno) {
|
||||||
|
reject(new Error(`VisionKit start 失败: ${errno}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const now = Date.now()
|
||||||
|
started = true
|
||||||
|
busy = false
|
||||||
|
sessionStartedAt = now
|
||||||
|
lastCallbackTs = now
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
if (isUnsupportedDeviceError(e)) {
|
||||||
|
reject(new Error(getVisionKitSupportHint() || '当前设备不支持 VisionKit Hand'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reject(e instanceof Error ? e : new Error(String(e)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(cb: (sample: HandSample) => void): Promise<void> {
|
||||||
|
stop()
|
||||||
|
onSample = cb
|
||||||
|
const wxApi = getWx()
|
||||||
|
if (!wxApi?.createVKSession) {
|
||||||
|
return Promise.reject(new Error('不支持 VisionKit'))
|
||||||
|
}
|
||||||
|
if (isLikelyVisionKitUnsupportedHost()) {
|
||||||
|
return Promise.reject(new Error(getVisionKitSupportHint()))
|
||||||
|
}
|
||||||
|
return createAndStartSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushFrame(frame: CameraFrame) {
|
||||||
|
if (!started || restarting) return
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// busy 看门狗:detectHand 无回调时强制释放,避免识别永久停摆
|
||||||
|
if (busy && now - busySince > BUSY_TIMEOUT_MS) {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不再定期重建 VKSession:长跑中 destroy/create 容易把微信原生桥卡死(表现为 29 次后整页无响应)
|
||||||
|
if (!session?.detectHand || busy) return
|
||||||
|
if (now - lastDetectTs < minIntervalMs) return
|
||||||
|
lastDetectTs = now
|
||||||
|
busy = true
|
||||||
|
busySince = now
|
||||||
|
try {
|
||||||
|
session.detectHand({
|
||||||
|
frameBuffer: frame.data,
|
||||||
|
width: frame.width,
|
||||||
|
height: frame.height,
|
||||||
|
scoreThreshold,
|
||||||
|
algoMode: 2,
|
||||||
|
})
|
||||||
|
} catch (_) {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
started = false
|
||||||
|
restarting = false
|
||||||
|
onSample = null
|
||||||
|
sessionStartedAt = 0
|
||||||
|
lastCallbackTs = 0
|
||||||
|
teardownSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatest() {
|
||||||
|
return latestSample
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, stop, pushFrame, getLatest }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VisionKitHandDetector = ReturnType<typeof createVisionKitHandDetector>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -204,13 +204,13 @@ const instance = getCurrentInstance()
|
|||||||
|
|
||||||
// 由父级 prop 信号驱动触发特效(避免跨组件 ref 调用, 小程序端更稳)
|
// 由父级 prop 信号驱动触发特效(避免跨组件 ref 调用, 小程序端更稳)
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
crushSignal?: { type: CrushItemType; nonce: number } | null
|
crushSignal?: { type: CrushItemType; nonce: number; intensity?: number } | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.crushSignal,
|
() => props.crushSignal,
|
||||||
(sig) => {
|
(sig) => {
|
||||||
if (sig) triggerCrush(sig.type)
|
if (sig) triggerCrush(sig.type, sig.intensity ?? 1)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -270,12 +270,13 @@ function initCanvas() {
|
|||||||
// 触发捏碎特效
|
// 触发捏碎特效
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
function triggerCrush(itemType: CrushItemType) {
|
function triggerCrush(itemType: CrushItemType, intensity = 1) {
|
||||||
if (!ctx.value || !canvasNode.value) return
|
if (!ctx.value || !canvasNode.value) return
|
||||||
|
|
||||||
const item = CRUSH_ITEMS[itemType]
|
const item = CRUSH_ITEMS[itemType]
|
||||||
if (!item) return
|
if (!item) return
|
||||||
|
|
||||||
|
const scale = Math.max(0.45, Math.min(1.15, intensity))
|
||||||
const centerX = canvasWidth.value / 2
|
const centerX = canvasWidth.value / 2
|
||||||
const centerY = canvasHeight.value / 2
|
const centerY = canvasHeight.value / 2
|
||||||
const maxR = Math.min(canvasWidth.value, canvasHeight.value)
|
const maxR = Math.min(canvasWidth.value, canvasHeight.value)
|
||||||
@@ -284,39 +285,44 @@ function triggerCrush(itemType: CrushItemType) {
|
|||||||
flashList.value.push({
|
flashList.value.push({
|
||||||
x: centerX,
|
x: centerX,
|
||||||
y: centerY,
|
y: centerY,
|
||||||
radius: maxR * 0.12,
|
radius: maxR * 0.12 * scale,
|
||||||
maxRadius: maxR * 0.62,
|
maxRadius: maxR * 0.62 * scale,
|
||||||
color: item.flashColor,
|
color: item.flashColor,
|
||||||
life: 1.0,
|
life: 1.0,
|
||||||
decay: PHYSICS.FLASH_DECAY,
|
decay: PHYSICS.FLASH_DECAY,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2. 双层冲击波
|
// 2. 双层冲击波(低强度时只保留一层)
|
||||||
shockwaves.value.push({
|
shockwaves.value.push({
|
||||||
x: centerX, y: centerY,
|
x: centerX, y: centerY,
|
||||||
radius: 12, maxRadius: maxR * 0.62,
|
radius: 12, maxRadius: maxR * 0.62 * scale,
|
||||||
color: item.shockwaveColor, width: 5,
|
color: item.shockwaveColor, width: 5,
|
||||||
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY,
|
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY,
|
||||||
})
|
})
|
||||||
shockwaves.value.push({
|
if (scale >= 0.8) {
|
||||||
x: centerX, y: centerY,
|
shockwaves.value.push({
|
||||||
radius: 4, maxRadius: maxR * 0.42,
|
x: centerX, y: centerY,
|
||||||
color: item.shockwaveColor, width: 3,
|
radius: 4, maxRadius: maxR * 0.42 * scale,
|
||||||
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY * 1.3,
|
color: item.shockwaveColor, width: 3,
|
||||||
})
|
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY * 1.3,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const debrisCount = Math.max(8, Math.round(item.debrisCount * scale))
|
||||||
|
const sparkCount = Math.max(6, Math.round(item.sparkCount * scale))
|
||||||
|
const sparkleCount = Math.max(2, Math.round(item.sparkleCount * scale))
|
||||||
|
|
||||||
// 3. 碎片(主体飞溅)
|
// 3. 碎片(主体飞溅)
|
||||||
for (let i = 0; i < item.debrisCount; i++) {
|
for (let i = 0; i < debrisCount; i++) {
|
||||||
const angle = (Math.PI * 2 * i) / item.debrisCount + (Math.random() - 0.5) * 0.7
|
const angle = (Math.PI * 2 * i) / debrisCount + (Math.random() - 0.5) * 0.7
|
||||||
const speed = item.speedMin + Math.random() * (item.speedMax - item.speedMin)
|
const speed = (item.speedMin + Math.random() * (item.speedMax - item.speedMin)) * (0.85 + scale * 0.15)
|
||||||
const size = item.sizeMin + Math.random() * (item.sizeMax - item.sizeMin)
|
const size = (item.sizeMin + Math.random() * (item.sizeMax - item.sizeMin)) * scale
|
||||||
const color = item.colors[Math.floor(Math.random() * item.colors.length)]
|
const color = item.colors[Math.floor(Math.random() * item.colors.length)]
|
||||||
const shape = item.shapes[Math.floor(Math.random() * item.shapes.length)]
|
const shape = item.shapes[Math.floor(Math.random() * item.shapes.length)]
|
||||||
|
|
||||||
// confetti(彩纸): 初速带强烈向上偏移 + 水平摇摆下落
|
|
||||||
const confetti = !!item.confetti
|
const confetti = !!item.confetti
|
||||||
const vy0 = confetti
|
const vy0 = confetti
|
||||||
? Math.sin(angle) * speed - 2.5 // 先向上窜
|
? Math.sin(angle) * speed - 2.5
|
||||||
: Math.sin(angle) * speed
|
: Math.sin(angle) * speed
|
||||||
|
|
||||||
debrisList.value.push({
|
debrisList.value.push({
|
||||||
@@ -330,18 +336,18 @@ function triggerCrush(itemType: CrushItemType) {
|
|||||||
rotation: Math.random() * Math.PI * 2,
|
rotation: Math.random() * Math.PI * 2,
|
||||||
rotationSpeed: (Math.random() - 0.5) * (confetti ? 0.5 : 0.3),
|
rotationSpeed: (Math.random() - 0.5) * (confetti ? 0.5 : 0.3),
|
||||||
life: 1.0,
|
life: 1.0,
|
||||||
decay: PHYSICS.DEBRIS_DECAY * (confetti ? 0.7 : 1),
|
decay: PHYSICS.DEBRIS_DECAY * (confetti ? 0.7 : 1) * (scale < 0.7 ? 1.25 : 1),
|
||||||
gravity: item.gravity,
|
gravity: item.gravity,
|
||||||
glow: !confetti, // 彩纸不发光,实色碎片发光
|
glow: !confetti,
|
||||||
swing: confetti ? 0.6 + Math.random() * 1.2 : 0,
|
swing: confetti ? 0.6 + Math.random() * 1.2 : 0,
|
||||||
age: Math.random() * Math.PI * 2,
|
age: Math.random() * Math.PI * 2,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 火花(细小高速亮点,带拖尾)
|
// 4. 火花
|
||||||
for (let i = 0; i < item.sparkCount; i++) {
|
for (let i = 0; i < sparkCount; i++) {
|
||||||
const angle = Math.random() * Math.PI * 2
|
const angle = Math.random() * Math.PI * 2
|
||||||
const speed = item.speedMax * (0.9 + Math.random() * 0.8)
|
const speed = item.speedMax * (0.9 + Math.random() * 0.8) * scale
|
||||||
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
|
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
|
||||||
sparkList.value.push({
|
sparkList.value.push({
|
||||||
x: centerX,
|
x: centerX,
|
||||||
@@ -359,8 +365,8 @@ function triggerCrush(itemType: CrushItemType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. 闪烁星光
|
// 5. 闪烁星光
|
||||||
for (let i = 0; i < item.sparkleCount; i++) {
|
for (let i = 0; i < sparkleCount; i++) {
|
||||||
const r = maxR * (0.08 + Math.random() * 0.32)
|
const r = maxR * (0.08 + Math.random() * 0.32) * scale
|
||||||
const a = Math.random() * Math.PI * 2
|
const a = Math.random() * Math.PI * 2
|
||||||
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
|
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
|
||||||
sparkleList.value.push({
|
sparkleList.value.push({
|
||||||
@@ -370,7 +376,7 @@ function triggerCrush(itemType: CrushItemType) {
|
|||||||
rotation: Math.random() * Math.PI,
|
rotation: Math.random() * Math.PI,
|
||||||
spin: (Math.random() - 0.5) * 0.16,
|
spin: (Math.random() - 0.5) * 0.16,
|
||||||
color,
|
color,
|
||||||
life: 1.0 + Math.random() * 0.4, // 错峰出现
|
life: 1.0 + Math.random() * 0.4,
|
||||||
decay: PHYSICS.SPARKLE_DECAY * (0.8 + Math.random() * 0.5),
|
decay: PHYSICS.SPARKLE_DECAY * (0.8 + Math.random() * 0.5),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+6968
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user