Skip to content

Commit a54b2c1

Browse files
Fix BLE button click detection and add Triki integration recipes.
Resolve button edges once per pollInput so Dev Mode and menus see bleButtonClick; add TrikiRecipes helpers, Bowling button confirm, and updated SDK docs. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1b23018 commit a54b2c1

28 files changed

Lines changed: 606 additions & 182 deletions

.cursor/skills/veltokit/SKILL.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,16 @@ description: VeltoKit BLE motion SDK and gametriki sample app. Use when editing
1919

2020
## Frame pipeline
2121

22-
`BLE → MotionSDK.enqueueBLE / connect() → MotionEngine.updateFrame → GameInput → game.update(input:)`
22+
`BLE → MotionSDK.connect() → pollInput → GameInput → your game`
2323

2424
```swift
25-
let input = motion.pollInput(deltaTime: dt) // after connect()
25+
motion.configureForPong() // or Menu / PointerGame / GestureGame
26+
let input = motion.pollInput(deltaTime: dt)
2627
```
2728

29+
Helpers: `TrikiRecipes.swift``TrikiSimplePong`, `TrikiUIPicker`, `TrikiGameActions`, `TrikiButtonGate`.
30+
Docs: `website/docs/sdk/recipes.md`.
31+
2832
## MotionMode → games
2933

3034
| Mode | Games | Main fields |

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Unofficial / educational. Do not invent hardware brands or packet layouts not in
2727
```text
2828
VeltoKit/
2929
MotionSDK.swift # Public API — start here for SDK questions
30+
Triki/TrikiRecipes.swift # configureForPong/Menu + TrikiUIPicker, TrikiSimplePong
3031
MotionEngine.swift # Per-frame processing, modes, calibration
3132
GameInput.swift # Output contract — start here for game logic
3233
MotionConfig.swift # Presets per MotionMode
@@ -106,7 +107,7 @@ Mode setup in app: `app/Engine/GameManager.swift`. Per-game docs: `website/docs/
106107

107108
| Task | Read first |
108109
|------|------------|
109-
| Integrate SDK in a new app | `website/docs/quick-start.md`, `sdk/motion-sdk.md`, `sdk/game-input.md` |
110+
| Integrate SDK in a new app | `website/docs/sdk/recipes.md`, `quick-start.md`, `sdk/game-input.md` |
110111
| BLE packets / debugging | `sdk/ble-integration.md`, `VeltoKit/BLE/` |
111112
| Change gesture / throw | `sdk/gestures.md`, `VeltoKit/GestureDetector.swift` |
112113
| Triki menus / focus | `sdk/triki-ui.md`, `app/UI/TrikiUI/` |

VeltoKit/BLEButtonDecoder.swift

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,29 @@ public enum BLEButtonDecoder {
77
/// Indeks bajtu przycisku w pakiecie.
88
public static let buttonIndex = 1
99

10-
/// Sprawdza, czy bajt reprezentuje stan wciśnięty.
10+
/// Sprawdza, czy bajt reprezentuje stan wciśnięty (dowolna wartość ≠ 0).
1111
public static func isPressed(_ byte: UInt8) -> Bool {
12-
byte == 1
12+
byte != 0
13+
}
14+
15+
/// Skanuje cały notify — każdy blok zaczynający się od `0x22`, przycisk na następnym bajcie.
16+
public static func risingEdgeAnywhere(in data: [UInt8], lastButton: inout UInt8) -> Bool {
17+
guard data.count > buttonIndex else { return false }
18+
var edge = false
19+
var i = 0
20+
while i < data.count {
21+
if data[i] == packetHeader, i + buttonIndex < data.count {
22+
let button = data[i + buttonIndex]
23+
if isPressed(button), !isPressed(lastButton) {
24+
edge = true
25+
}
26+
lastButton = button
27+
i += 2
28+
} else {
29+
i += 1
30+
}
31+
}
32+
return edge
1333
}
1434

1535
/// Zbocze narastające na `bytes[1]` (tylko gdy pakiet zaczyna się od `0x22`).

VeltoKit/ButtonDetector.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ final class ButtonDetector {
1818
if data.count > BLEButtonDecoder.buttonIndex, data[0] == BLEButtonDecoder.packetHeader {
1919
lastSeenButtonByte = data[BLEButtonDecoder.buttonIndex]
2020
}
21-
guard BLEButtonDecoder.risingEdge(in: data, lastButton: &lastButton) else { return }
21+
if BLEButtonDecoder.risingEdgeAnywhere(in: data, lastButton: &lastButton) {
22+
pendingClick = true
23+
}
24+
}
25+
26+
/// Ustawia oczekujący impuls (np. z TrikiParser preset v2).
27+
func latchClick() {
2228
pendingClick = true
2329
}
2430

VeltoKit/GameInput.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ public struct GameInput {
2828
public var tiltRight: Bool = false
2929
/// Główna akcja wejścia (np. klik/strzał).
3030
public var primaryAction: Bool = false
31+
/// Jednoklatkowy impuls fizycznego przycisku BLE (`bytes[1]`, zbocze 0→1).
32+
public var bleButtonClick: Bool = false
3133
/// Opcjonalna akcja dodatkowa.
3234
public var secondaryAction: Bool? = nil
3335
/// Surowe sensory Triki przypisane do tej ramki.
@@ -94,6 +96,7 @@ public struct GameInput {
9496
/// - tiltLeft: Sygnał przechyłu w lewo.
9597
/// - tiltRight: Sygnał przechyłu w prawo.
9698
/// - primaryAction: Główna akcja wejścia.
99+
/// - bleButtonClick: Impuls fizycznego przycisku BLE (jedna klatka).
97100
/// - secondaryAction: Opcjonalna akcja dodatkowa.
98101
/// - sensors: Zrzut surowych sensorów Triki.
99102
/// - lateral: Oś boczna.
@@ -128,6 +131,7 @@ public struct GameInput {
128131
tiltLeft: Bool = false,
129132
tiltRight: Bool = false,
130133
primaryAction: Bool = false,
134+
bleButtonClick: Bool = false,
131135
secondaryAction: Bool? = nil,
132136
sensors: TrikiSensors = TrikiSensors(),
133137
lateral: Double = 0,
@@ -162,6 +166,7 @@ public struct GameInput {
162166
self.tiltLeft = tiltLeft
163167
self.tiltRight = tiltRight
164168
self.primaryAction = primaryAction
169+
self.bleButtonClick = bleButtonClick
165170
self.secondaryAction = secondaryAction
166171
self.sensors = sensors
167172
self.lateral = lateral

VeltoKit/Motion/MotionParser.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ final class MotionParser: ObservableObject {
3434
private var rxBuffer: [UInt8] = []
3535
/// Ostatnio widziany `bytes[1]` (podgląd DEV).
3636
public private(set) var lastSeenButtonByte: UInt8 = 0
37+
private var lastIngressButtonByte: UInt8 = 0
3738

3839
private var smoothTiltX = 0.0
3940
private var smoothTiltY = 0.0
@@ -131,6 +132,7 @@ final class MotionParser: ObservableObject {
131132
sensors = TrikiSensors()
132133
recentFrames.removeAll()
133134
lastSeenButtonByte = 0
135+
lastIngressButtonByte = 0
134136
}
135137

136138
/// Czy klik był niedawno (DEV / HUD — impuls trwa ~1 klatkę).
@@ -139,8 +141,11 @@ final class MotionParser: ObservableObject {
139141
return sensors.click || (now - lastClickAt) < 0.25
140142
}
141143

142-
/// Podgląd `bytes[1]` — klik obsługuje `ButtonDetector` w MotionSDK.
144+
/// Podgląd `bytes[1]` + impuls kliknięcia do `consumeImpulses()`.
143145
private func ingestBLEButtonEdges(from data: [UInt8]) {
146+
if BLEButtonDecoder.risingEdgeAnywhere(in: data, lastButton: &lastIngressButtonByte) {
147+
registerClickImpulse()
148+
}
144149
if data.count > BLEButtonDecoder.buttonIndex, data[0] == BLEButtonDecoder.packetHeader {
145150
lastSeenButtonByte = data[BLEButtonDecoder.buttonIndex]
146151
}

VeltoKit/MotionSDK+Connection.swift

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Combine
22
import Foundation
33

44
private let motionHudPublishInterval: TimeInterval = 0.12
5+
private let buttonClickHighlightDuration: TimeInterval = 0.12
56

67
/// Adds focused motion sdk helpers.
78
extension MotionSDK {
@@ -36,12 +37,21 @@ extension MotionSDK {
3637
trikiController?.tick(deltaTime: deltaTime)
3738
applyTrikiGamepadToEngine()
3839

40+
let now = Date().timeIntervalSince1970
41+
3942
guard let parser = streamParser else {
4043
updateFrame(deltaTime: deltaTime)
41-
return input
44+
let buttonClick = resolveButtonClick(impulses: (false, false), now: now)
45+
var copy = input
46+
copy.bleButtonClick = buttonClick
47+
if config.mode == .paddle {
48+
copy.primaryAction = buttonClick
49+
} else if buttonClick {
50+
copy.primaryAction = copy.primaryAction || buttonClick
51+
}
52+
latestEnrichedInput = copy
53+
return copy
4254
}
43-
44-
let now = Date().timeIntervalSince1970
4555
let stale = trikiBLEMode.packetStaleSeconds
4656
if now - lastPacketAt > stale, isReceiving { isReceiving = false }
4757
syncTrikiPublishedState()
@@ -51,11 +61,13 @@ extension MotionSDK {
5161
parser.flushImpulsesOnly()
5262
let impulses = parser.consumeImpulses()
5363
let out = updateFrame(deltaTime: deltaTime)
64+
let buttonClick = resolveButtonClick(impulses: impulses, now: now)
5465
var enriched = makeEnrichedGameInput(
5566
output: out,
5667
sdkInput: input,
5768
parser: parser,
58-
impulses: impulses
69+
impulses: impulses,
70+
buttonClick: buttonClick
5971
)
6072
applyTrikiGamepadSignals(&enriched)
6173
finalizeAdaptiveInput(&enriched)
@@ -72,11 +84,13 @@ extension MotionSDK {
7284
)
7385
let out = updateFrame(deltaTime: deltaTime)
7486
let impulses = parser.consumeImpulses()
87+
let buttonClick = resolveButtonClick(impulses: impulses, now: now)
7588
var enriched = makeEnrichedGameInput(
7689
output: out,
7790
sdkInput: input,
7891
parser: parser,
79-
impulses: impulses
92+
impulses: impulses,
93+
buttonClick: buttonClick
8094
)
8195
applyTrikiGamepadSignals(&enriched)
8296
finalizeAdaptiveInput(&enriched)
@@ -287,19 +301,39 @@ extension MotionSDK {
287301
lastFramePosY = input.posY
288302
}
289303

304+
/// Jednorazowe zebranie impulsu przycisku BLE na końcu `pollInput` (nie w `publishInput`).
305+
func resolveButtonClick(impulses: (click: Bool, shake: Bool), now: TimeInterval) -> Bool {
306+
if trikiController?.consumeButtonEdge() == true {
307+
button.latchClick()
308+
}
309+
let packetEdge = button.consumeClick()
310+
let currentByte = button.lastSeenButtonByte
311+
let levelEdge = BLEButtonDecoder.isPressed(currentByte)
312+
&& !BLEButtonDecoder.isPressed(lastSampledButtonByte)
313+
lastSampledButtonByte = currentByte
314+
315+
let freshEdge = packetEdge || impulses.click || levelEdge
316+
if freshEdge {
317+
buttonClickHighlightUntil = now + buttonClickHighlightDuration
318+
}
319+
return freshEdge || now < buttonClickHighlightUntil
320+
}
321+
290322
/// Builds a UI/game-friendly input snapshot from raw engine output and parser state.
291323
///
292324
/// - Parameters:
293325
/// - output: Current processed motion output frame.
294326
/// - sdkInput: Base input snapshot that will be enriched.
295327
/// - parser: Optional parser containing latest sensor payload.
296328
/// - impulses: Edge-triggered click/shake impulses for this frame.
329+
/// - buttonClick: Resolved BLE button edge for this poll (includes short HUD latch).
297330
/// - Returns: Enriched `GameInput` used by HUD and gameplay layers.
298331
func makeEnrichedGameInput(
299332
output: MotionOutput,
300333
sdkInput: GameInput,
301334
parser: MotionParser?,
302-
impulses: (click: Bool, shake: Bool)
335+
impulses: (click: Bool, shake: Bool),
336+
buttonClick: Bool = false
303337
) -> GameInput {
304338
let dbg = debug
305339
var enriched = sdkInput
@@ -321,13 +355,13 @@ extension MotionSDK {
321355
enriched.sensors = parser.sensors
322356
enriched.pointerDirection = pointerDirection(posX: output.x, posY: output.y)
323357
}
324-
let buttonEdge = impulses.click || sdkInput.primaryAction
358+
enriched.bleButtonClick = buttonClick
325359
if config.mode == .paddle {
326-
enriched.primaryAction = buttonEdge
327-
} else if impulses.click {
328-
enriched.primaryAction = true
360+
enriched.primaryAction = buttonClick
361+
} else if buttonClick {
362+
enriched.primaryAction = enriched.primaryAction || buttonClick
329363
}
330-
applyClickToSensors(&enriched, clickEdge: buttonEdge)
364+
applyClickToSensors(&enriched, clickEdge: buttonClick)
331365
return enriched
332366
}
333367

VeltoKit/MotionSDK.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ public final class MotionSDK: ObservableObject {
1717
/// Ostatni bajt przycisku BLE (DEV).
1818
public var lastButtonByte: UInt8 { button.lastSeenButtonByte }
1919

20+
/// Czy oczekuje nieodebrany impuls przycisku (DEV).
21+
public var hasPendingButtonClick: Bool { button.didClick }
22+
2023
/// Ostatnia opublikowana ramka wejścia.
2124
public private(set) var input = GameInput()
2225

@@ -73,6 +76,10 @@ public final class MotionSDK: ObservableObject {
7376
private var latestPaddleRaw: Double?
7477
var lastFramePosX: Double?
7578
var lastFramePosY: Double?
79+
/// Ostatni `bytes[1]` przy ostatnim `pollInput` (zbocze między pollami).
80+
var lastSampledButtonByte: UInt8 = 0
81+
/// Dev Mode / HUD: trzyma `bleButtonClick` przez krótki czas po zboczu.
82+
var buttonClickHighlightUntil: TimeInterval = 0
7683

7784
/// Tworzy instancję SDK ruchu.
7885
public init() {
@@ -221,25 +228,26 @@ public final class MotionSDK: ObservableObject {
221228
latestPaddleRaw = nil
222229
lastFramePosX = nil
223230
lastFramePosY = nil
231+
lastSampledButtonByte = 0
232+
buttonClickHighlightUntil = 0
224233
button.reset()
225234
engine.resetState()
226235
input = GameInput()
227236
}
228237

229238
private func publishInput() {
230239
let out = engine.output
231-
let click = button.consumeClick()
232240
let trikiAction = trikiController?.gameInput.isAction ?? false
233241
let throwShot = out.didShoot
234242
input.posX = out.x
235243
input.posY = out.y
236244
input.shotTriggered = throwShot
245+
input.bleButtonClick = false
237246
switch engine.config.mode {
238247
case .paddle:
239-
// Quiz / Pong / menu: only the physical BLE button — not velocity or throw.
240-
input.primaryAction = click
248+
input.primaryAction = false
241249
case .pointer, .gesture:
242-
input.primaryAction = click || throwShot || trikiAction
250+
input.primaryAction = throwShot || trikiAction
243251
}
244252
input.throwPower = throwShot ? engine.lastGestureThrowPower : 0
245253
input.gesturePrimed = engine.gesturePrimed

VeltoKit/Triki/TrikiGameController.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public final class TrikiGameController: ObservableObject {
4343
private var lastPollTime: TimeInterval?
4444
private var lastHudPublishAt: TimeInterval = 0
4545
private var lastWakeUpAttemptAt: TimeInterval = 0
46+
private var pendingButtonEdge = false
4647

4748
private var moveHandlers: [(Float) -> Void] = []
4849
private var shakeHandlers: [() -> Void] = []
@@ -103,6 +104,9 @@ public final class TrikiGameController: ObservableObject {
103104

104105
let frames = parser.drainParsedFrames()
105106
if !frames.isEmpty {
107+
if frames.contains(where: \.buttonEdge) {
108+
pendingButtonEdge = true
109+
}
106110
motion.ingest(parsed: frames, deltaTime: dt)
107111
publishGameInputIfNeeded(now: now)
108112
dispatchCallbacks()
@@ -114,6 +118,13 @@ public final class TrikiGameController: ObservableObject {
114118
return gameInput
115119
}
116120

121+
/// Jednorazowo zwraca impuls przycisku z parsera (preset v2 / legacy).
122+
func consumeButtonEdge() -> Bool {
123+
let edge = pendingButtonEdge
124+
pendingButtonEdge = false
125+
return edge
126+
}
127+
117128
public func resetSession() {
118129
parser.reset()
119130
motion.reset()
@@ -125,6 +136,7 @@ public final class TrikiGameController: ObservableObject {
125136
lastPollTime = nil
126137
lastHudPublishAt = 0
127138
lastWakeUpAttemptAt = 0
139+
pendingButtonEdge = false
128140
}
129141

130142
// MARK: - Public API

VeltoKit/Triki/TrikiParser.swift

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public final class TrikiParser: @unchecked Sendable {
6363

6464
let result: ParsedMotionData
6565
if Self.isPresetV2(data) {
66-
result = Self.parsePresetV2(data)
66+
result = Self.parsePresetV2(data, lastButton: &lastButtonByte)
6767
} else if Self.isLegacyBlock8(data) {
6868
result = Self.parseLegacyBlock8(data, lastButton: &lastButtonByte)
6969
} else if Self.isLegacyFrame14(data) {
@@ -135,21 +135,24 @@ public final class TrikiParser: @unchecked Sendable {
135135
return true
136136
}
137137

138-
public static func parsePresetV2(_ data: Data) -> ParsedMotionData {
138+
public static func parsePresetV2(_ data: Data, lastButton: inout UInt8) -> ParsedMotionData {
139139
guard data.count >= 8 else {
140140
return ParsedMotionData(isValid: false, presetID: "v2-short")
141141
}
142142

143143
let rawX = int16(data, 2)
144144
let rawY = int16(data, 4)
145145
let rawZ = int16(data, 6)
146+
let header = [UInt8](data.prefix(min(8, data.count)))
147+
let edge = BLEButtonDecoder.risingEdge(in: header, lastButton: &lastButton)
146148

147149
return ParsedMotionData(
148150
x: Float(rawX) / 100.0,
149151
y: Float(rawY) / 100.0,
150152
z: Float(rawZ) / 100.0,
151153
isValid: true,
152-
presetID: "v2"
154+
presetID: "v2",
155+
buttonEdge: edge
153156
)
154157
}
155158

0 commit comments

Comments
 (0)