From e548fab41149e2299726829cc39f7973560c6eb6 Mon Sep 17 00:00:00 2001 From: Brian Phillips Date: Thu, 23 Jul 2026 11:40:42 -0500 Subject: [PATCH] feat: add Busy Bar arcade --- README.md | 27 +- Sources/BarKeep/AppState.swift | 28 +- Sources/BarKeep/ArcadeController.swift | 323 +++++++++++++ Sources/BarKeep/ArcadeEngine.swift | 535 +++++++++++++++++++++ Sources/BarKeep/MenuView.swift | 112 ++++- Tests/BarKeepTests/ArcadeEngineTests.swift | 74 +++ packaging/Info.plist | 2 +- 7 files changed, 1096 insertions(+), 5 deletions(-) create mode 100644 Sources/BarKeep/ArcadeController.swift create mode 100644 Sources/BarKeep/ArcadeEngine.swift create mode 100644 Tests/BarKeepTests/ArcadeEngineTests.swift diff --git a/README.md b/README.md index b8fe63e..fc9b327 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ No cloud, no account, no telemetry: BarKeep talks directly to the bar's local HT ## Features -**Menu bar app** (tabbed popover: Device / Message / Timers / Settings) +**Menu bar app** (tabbed popover: Device / Message / Timers / Arcade / Settings) - πŸŽ™ **Auto On-Call** β€” flips the bar to *On Air* the moment any app opens your microphone (Teams, Zoom, FaceTime…), clears when the mic goes idle. Uses CoreAudio device state β€” no microphone permission needed, no audio ever captured. - πŸ“Ί **Live preview** β€” see what's on the bar's display, right in the popover. @@ -16,6 +16,9 @@ No cloud, no account, no telemetry: BarKeep talks directly to the bar's local HT - πŸ“… **Calendar** β€” auto-busy during calendar events; one-click countdown-to-next-meeting on the bar. - πŸ”” **Notification forwarding** β€” scroll macOS notifications (Teams by default, any app by filter) across the bar with per-app LED colors, optional chime, and queue-during-calls replay. - 🌐 **Ambient widgets** β€” live ping latency badge and local weather (icon + temperature) in the corners of the display. +- πŸ•Ή **Busy Bar Arcade** β€” play Snake, Tetris, Pong, and Breakout on the + physical 72Γ—16 display using your Mac keyboard. The Mac preview is optional + and off by default. - πŸ’€ **Slack sync** β€” bar goes busy β†’ your Slack status becomes "🎧 On a call" + DND; clears after. - βš™οΈ Brightness/volume control, device rename, firmware update check, launch at login. @@ -132,6 +135,28 @@ ad-hoc-signed copies may require the permission to be granted again. 3. *Install to Workspace*, copy the **User OAuth Token** (`xoxp-…`) 4. Paste into BarKeep β†’ Settings β†’ Slack +## Busy Bar Arcade + +Open BarKeep β†’ **Arcade**, then choose a game. BarKeep captures keyboard input +in a transparent input-only window, so the physical Busy Bar is the game +display and no game window needs to remain visible on the Mac. + +| Key | Action | +|---|---| +| `1` / `2` / `3` / `4` | Switch to Snake / Tetris / Pong / Breakout | +| Arrow keys | Move (all games) | +| `W` / `S` | Alternate Pong controls | +| `↑` | Rotate a Tetris piece | +| `↓` | Soft-drop a Tetris piece | +| Space | Hard-drop a Tetris piece | +| `R` | Restart the current game | +| Escape | Stop the arcade and return keyboard focus to the previous Mac app | + +Enable **Show preview in BarKeep** if you want a troubleshooting preview in +the Arcade tab. Games cannot run while a native busy/timer session is active, +because Busy Bar firmware rejects custom drawing during those sessions. +Starting an on-call session stops the arcade automatically. + ## Configuration Everything is configured in the app's Settings tab β€” device host, API token (needed for Wi-Fi), busy theme, notification filter, Slack token, ping target, weather unit and location (type a city, it's geocoded for you; leave empty for automatic IP-based location). No config files, no terminal required. diff --git a/Sources/BarKeep/AppState.swift b/Sources/BarKeep/AppState.swift index 11edaf6..7fe2950 100644 --- a/Sources/BarKeep/AppState.swift +++ b/Sources/BarKeep/AppState.swift @@ -151,6 +151,7 @@ final class AppState { var calendarAccessGranted: Bool { calendarMonitor.accessGranted } let client: BusyBarClient + let arcade: ArcadeController private let localNetworkPermissionTrigger = LocalNetworkPermissionTrigger() private let micMonitor = MicMonitor() private let notificationWatcher = NotificationWatcher() @@ -206,6 +207,7 @@ final class AppState { self.pingHost = defaults.string(forKey: "pingHost") ?? "1.1.1.1" self.weatherCelsius = defaults.bool(forKey: "weatherCelsius") self.client = BusyBarClient(host: host, token: token) + self.arcade = ArcadeController(client: self.client) localNetworkPermissionTrigger.onAccessAvailable = { [weak self] in Task { @MainActor [weak self] in await self?.refreshDeviceStatus() @@ -245,6 +247,9 @@ final class AppState { firmwareVersion = status.firmware.version let busyType = try await client.currentBusyType() onCall = busyType != "NOT_STARTED" + if onCall, arcade.isActive { + arcade.stop() + } if let themes = try? await client.listThemes(), !themes.isEmpty { availableThemes = themes } @@ -256,6 +261,9 @@ final class AppState { } catch { deviceReachable = false batteryCharge = nil + if arcade.isActive { + arcade.stop() + } } } @@ -395,7 +403,7 @@ final class AppState { self.latestPingMs = ms // The badge self-expires (10 s timeout), so a stopped loop // or an active busy session just lets it fade out. - if !self.onCall { + if !self.onCall && !self.arcade.isActive { let text: String let color: String if let ms { @@ -470,7 +478,7 @@ final class AppState { lastFetch = Date() } } - if let reading = self.latestWeather, !self.onCall { + if let reading = self.latestWeather, !self.onCall, !self.arcade.isActive { var iconOK = self.lastWeatherIconEmoji == reading.emoji if !iconOK, let png = MessageRenderer.renderEmojiIcon(reading.emoji) { iconOK = (try? await self.client.uploadAsset(filename: "wx.png", data: png)) != nil @@ -494,6 +502,10 @@ final class AppState { func sendMeetingCountdown() { guard let date = nextMeetingDate else { return } + guard !arcade.isActive else { + lastError = "Stop the arcade before drawing another item." + return + } Task { do { try await client.drawCountdown(to: date, colorHex: "#FFAA00FF", timeout: 0, priority: 95) @@ -534,6 +546,9 @@ final class AppState { func setOnCall(_ on: Bool, automatic: Bool = false) { Task { do { + if on, arcade.isActive { + arcade.stop() + } if on && automatic { // Don't stomp a busy session the user started elsewhere. let current = try await client.currentBusyType() @@ -563,6 +578,7 @@ final class AppState { // MARK: - Notification forwarding private func handleNotification(_ note: ForwardedNotification) { + guard !arcade.isActive else { return } let filters = notificationAppFilter .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespaces).lowercased() } @@ -646,6 +662,10 @@ final class AppState { // MARK: - Messages func sendMessage(_ text: String, font: TextFont, color: NSColor, timeoutSeconds: Int) { + guard !arcade.isActive else { + lastError = "Stop the arcade before sending a message." + return + } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } let hex = MessageRenderer.rgbaHex(from: color) @@ -674,6 +694,10 @@ final class AppState { } func sendDrawing(_ grid: [[String?]], timeoutSeconds: Int) { + guard !arcade.isActive else { + lastError = "Stop the arcade before sending a drawing." + return + } Task { do { guard let png = MessageRenderer.renderGridToPNG(grid) else { diff --git a/Sources/BarKeep/ArcadeController.swift b/Sources/BarKeep/ArcadeController.swift new file mode 100644 index 0000000..3c8011d --- /dev/null +++ b/Sources/BarKeep/ArcadeController.swift @@ -0,0 +1,323 @@ +import AppKit +import Observation +import os + +private let arcadeLog = Logger(subsystem: "dev.barkeep.mac", category: "arcade") + +private final class ArcadeInputPanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} + +@MainActor +private final class ArcadeKeyboardCapture: NSObject, NSWindowDelegate { + private var panel: ArcadeInputPanel? + private var eventMonitor: Any? + private var previousApplication: NSRunningApplication? + private var onFocusLost: (() -> Void)? + private var isStopping = false + + func start( + onEvent: @escaping (NSEvent) -> Void, + onFocusLost: @escaping () -> Void + ) { + stop(restoreFocus: false) + self.onFocusLost = onFocusLost + let frontmost = NSWorkspace.shared.frontmostApplication + previousApplication = frontmost?.bundleIdentifier == Bundle.main.bundleIdentifier + ? nil + : frontmost + + let panel = ArcadeInputPanel( + contentRect: NSRect(x: -10, y: -10, width: 1, height: 1), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.alphaValue = 0.01 + panel.hasShadow = false + panel.ignoresMouseEvents = true + panel.level = .floating + panel.collectionBehavior = [.canJoinAllSpaces, .transient, .ignoresCycle] + panel.delegate = self + panel.orderFrontRegardless() + NSApp.activate(ignoringOtherApps: true) + panel.makeKey() + self.panel = panel + + eventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.keyDown, .keyUp] + ) { event in + onEvent(event) + return nil + } + } + + func stop(restoreFocus: Bool = true) { + isStopping = true + if let eventMonitor { + NSEvent.removeMonitor(eventMonitor) + self.eventMonitor = nil + } + panel?.orderOut(nil) + panel?.delegate = nil + panel?.close() + panel = nil + onFocusLost = nil + if restoreFocus { + if let previousApplication { + previousApplication.activate(options: [.activateAllWindows]) + } else { + NSApp.hide(nil) + } + } + previousApplication = nil + isStopping = false + } + + func windowDidResignKey(_ notification: Notification) { + guard !isStopping else { return } + onFocusLost?() + } +} + +@Observable +@MainActor +final class ArcadeController { + private(set) var isActive = false + private(set) var selectedGame: ArcadeGame = .snake + private(set) var previewImage: CGImage? + private(set) var framesSent = 0 + private(set) var framesDropped = 0 + var showPreview: Bool { + didSet { + UserDefaults.standard.set(showPreview, forKey: "arcadeShowPreview") + updatePreview() + } + } + var errorMessage: String? + + private let client: BusyBarClient + private let keyboard = ArcadeKeyboardCapture() + private var engine = ArcadeEngine(game: .snake) + private var heldKeys = Set() + private var pressedKeys = Set() + private var gameTask: Task? + private var uploadTask: Task? + private var uploadSlot = 0 + private var lastUpload = ContinuousClock.now + private var nextUploadAllowed = ContinuousClock.now + private var generation = 0 + private var consecutiveFailures = 0 + + init(client: BusyBarClient) { + self.client = client + self.showPreview = UserDefaults.standard.bool(forKey: "arcadeShowPreview") + updatePreview() + } + + func start(_ game: ArcadeGame) { + if isActive { + select(game) + return + } + selectedGame = game + engine.select(game) + generation += 1 + isActive = true + framesSent = 0 + framesDropped = 0 + errorMessage = nil + consecutiveFailures = 0 + nextUploadAllowed = .now + heldKeys.removeAll() + pressedKeys.removeAll() + updatePreview() + keyboard.start( + onEvent: { [weak self] event in self?.handle(event) }, + onFocusLost: { [weak self] in + self?.stop(withError: "Arcade stopped because keyboard focus changed.") + } + ) + startLoop() + } + + func select(_ game: ArcadeGame) { + selectedGame = game + engine.select(game) + heldKeys.removeAll() + pressedKeys.removeAll() + updatePreview() + } + + func restart() { + engine.reset() + updatePreview() + } + + func stop() { + stop(withError: nil) + } + + private func stop(withError error: String?) { + guard isActive else { return } + generation += 1 + isActive = false + gameTask?.cancel() + gameTask = nil + uploadTask?.cancel() + uploadTask = nil + heldKeys.removeAll() + pressedKeys.removeAll() + keyboard.stop() + if let error { + errorMessage = error + } + } + + private func startLoop() { + gameTask?.cancel() + lastUpload = .now - .seconds(1) + gameTask = Task { [weak self] in + let clock = ContinuousClock() + while !Task.isCancelled { + guard let self, self.isActive else { break } + self.engine.update( + now: ProcessInfo.processInfo.systemUptime, + held: self.heldKeys, + pressed: self.pressedKeys + ) + self.pressedKeys.removeAll() + self.updatePreview() + self.sendLatestFrameIfReady() + try? await clock.sleep(for: .milliseconds(16)) + } + } + } + + private func sendLatestFrameIfReady() { + let now = ContinuousClock.now + guard now - lastUpload >= .milliseconds(50) else { return } + guard now >= nextUploadAllowed else { return } + guard uploadTask == nil else { + framesDropped += 1 + return + } + guard let png = ArcadeRenderer.pngData(from: engine.frame) else { + errorMessage = "Could not encode the arcade frame." + return + } + lastUpload = now + let frameGeneration = generation + let filename = "arcade\(uploadSlot).png" + uploadSlot = (uploadSlot + 1) % 2 + uploadTask = Task { [weak self] in + guard let self else { return } + do { + try await self.client.uploadAsset(filename: filename, data: png) + guard self.isActive, self.generation == frameGeneration else { return } + try await self.client.drawImage( + named: filename, timeout: 1, priority: 99 + ) + self.framesSent += 1 + self.consecutiveFailures = 0 + self.nextUploadAllowed = .now + self.errorMessage = nil + } catch is CancellationError { + // Stopping the arcade intentionally cancels an in-flight frame. + } catch { + arcadeLog.error("Arcade frame failed: \(error.localizedDescription, privacy: .public)") + self.errorMessage = error.localizedDescription + self.consecutiveFailures += 1 + let delay = min( + 5.0, + pow(2.0, Double(self.consecutiveFailures - 1)) * 0.25 + ) + self.nextUploadAllowed = .now + .seconds(delay) + if self.consecutiveFailures >= 5 { + self.stop(withError: "Arcade stopped after repeated connection failures.") + } + } + if self.generation == frameGeneration { + self.uploadTask = nil + } + } + } + + private func updatePreview() { + previewImage = showPreview ? ArcadeRenderer.cgImage(from: engine.frame) : nil + } + + private func handle(_ event: NSEvent) { + let isDown = event.type == .keyDown + if isDown, event.isARepeat { return } + + switch event.keyCode { + case 53: // Escape: release keyboard capture and stop. + if isDown { stop() } + return + case 18 where isDown: select(.snake) + case 19 where isDown: select(.tetris) + case 20 where isDown: select(.pong) + case 21 where isDown: select(.breakout) + case 15 where isDown: restart() + default: + guard let key = Self.arcadeKey(for: event.keyCode) else { return } + if isDown { + heldKeys.insert(key) + pressedKeys.insert(key) + } else { + heldKeys.remove(key) + } + } + } + + private static func arcadeKey(for keyCode: UInt16) -> ArcadeKey? { + switch keyCode { + case 126: .up + case 125: .down + case 123: .left + case 124: .right + case 49: .space + case 13: .w + case 1: .s + default: nil + } + } +} + +enum ArcadeRenderer { + static func cgImage(from frame: ArcadeFrame) -> CGImage? { + var rgba = Data(capacity: ArcadeFrame.width * ArcadeFrame.height * 4) + for color in frame.pixels { + rgba.append(color.red) + rgba.append(color.green) + rgba.append(color.blue) + rgba.append(0xFF) + } + guard let provider = CGDataProvider(data: rgba as CFData) else { return nil } + return CGImage( + width: ArcadeFrame.width, + height: ArcadeFrame.height, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: ArcadeFrame.width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo( + rawValue: CGImageAlphaInfo.noneSkipLast.rawValue + ), + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent + ) + } + + static func pngData(from frame: ArcadeFrame) -> Data? { + guard let image = cgImage(from: frame) else { return nil } + return NSBitmapImageRep(cgImage: image) + .representation(using: .png, properties: [.compressionFactor: 0.2]) + } +} diff --git a/Sources/BarKeep/ArcadeEngine.swift b/Sources/BarKeep/ArcadeEngine.swift new file mode 100644 index 0000000..438c253 --- /dev/null +++ b/Sources/BarKeep/ArcadeEngine.swift @@ -0,0 +1,535 @@ +import Foundation + +struct ArcadeColor: Equatable, Sendable { + let red: UInt8 + let green: UInt8 + let blue: UInt8 + + static let black = ArcadeColor(red: 0, green: 0, blue: 0) + static let white = ArcadeColor(red: 255, green: 255, blue: 255) + static let dim = ArcadeColor(red: 28, green: 32, blue: 38) + static let red = ArcadeColor(red: 255, green: 45, blue: 35) + static let green = ArcadeColor(red: 45, green: 255, blue: 80) + static let yellow = ArcadeColor(red: 255, green: 220, blue: 35) + static let cyan = ArcadeColor(red: 30, green: 240, blue: 255) + static let magenta = ArcadeColor(red: 240, green: 60, blue: 255) + static let orange = ArcadeColor(red: 255, green: 120, blue: 25) +} + +struct ArcadeFrame: Equatable, Sendable { + static let width = BusyBarClient.displayWidth + static let height = BusyBarClient.displayHeight + + private(set) var pixels = Array( + repeating: ArcadeColor.black, + count: width * height + ) + + mutating func clear(_ color: ArcadeColor = .black) { + pixels = Array(repeating: color, count: Self.width * Self.height) + } + + mutating func set(x: Int, y: Int, color: ArcadeColor) { + guard x >= 0, x < Self.width, y >= 0, y < Self.height else { return } + pixels[y * Self.width + x] = color + } + + mutating func rectangle(x: Int, y: Int, width: Int, height: Int, color: ArcadeColor) { + for row in y..<(y + height) { + for column in x..<(x + width) { + set(x: column, y: row, color: color) + } + } + } +} + +enum ArcadeGame: String, CaseIterable, Identifiable, Sendable { + case snake + case tetris + case pong + case breakout + + var id: String { rawValue } + + var title: String { rawValue.capitalized } + + var number: Int { + switch self { + case .snake: 1 + case .tetris: 2 + case .pong: 3 + case .breakout: 4 + } + } + + var controls: String { + switch self { + case .snake: "Arrow keys" + case .tetris: "←/β†’ move Β· ↑ rotate Β· ↓ drop Β· Space hard drop" + case .pong: "↑/↓ or W/S" + case .breakout: "←/β†’ move paddle" + } + } + + var color: ArcadeColor { + switch self { + case .snake: .green + case .tetris: .cyan + case .pong: .magenta + case .breakout: .orange + } + } +} + +enum ArcadeKey: Hashable, Sendable { + case up, down, left, right, space, w, s, restart +} + +struct ArcadePoint: Equatable, Sendable { + var x: Int + var y: Int +} + +struct ArcadeEngine: Sendable { + private(set) var game: ArcadeGame + private(set) var frame = ArcadeFrame() + + private var snake = SnakeState() + private var tetris = TetrisState() + private var pong = PongState() + private var breakout = BreakoutState() + + init(game: ArcadeGame) { + self.game = game + reset() + } + + mutating func select(_ game: ArcadeGame) { + self.game = game + reset() + } + + mutating func reset() { + switch game { + case .snake: snake.reset() + case .tetris: tetris.reset() + case .pong: pong.reset() + case .breakout: breakout.reset() + } + render() + } + + mutating func update( + now: TimeInterval, + held: Set, + pressed: Set + ) { + if pressed.contains(.restart) { + reset() + return + } + switch game { + case .snake: snake.update(now: now, pressed: pressed) + case .tetris: tetris.update(now: now, held: held, pressed: pressed) + case .pong: pong.update(held: held) + case .breakout: breakout.update(held: held) + } + render() + } + + private mutating func render() { + frame.clear() + switch game { + case .snake: snake.render(into: &frame) + case .tetris: tetris.render(into: &frame) + case .pong: pong.render(into: &frame) + case .breakout: breakout.render(into: &frame) + } + } +} + +private struct SnakeState: Sendable { + private var body: [ArcadePoint] = [] + private var food = ArcadePoint(x: 26, y: 8) + private var direction = ArcadePoint(x: 1, y: 0) + private var lastTick: TimeInterval = 0 + + mutating func reset() { + body = (0..<5).map { ArcadePoint(x: 10 - $0, y: 8) } + food = ArcadePoint(x: 26, y: 8) + direction = ArcadePoint(x: 1, y: 0) + lastTick = 0 + } + + mutating func update(now: TimeInterval, pressed: Set) { + if pressed.contains(.up), direction.y != 1 { + direction = ArcadePoint(x: 0, y: -1) + } else if pressed.contains(.down), direction.y != -1 { + direction = ArcadePoint(x: 0, y: 1) + } else if pressed.contains(.left), direction.x != 1 { + direction = ArcadePoint(x: -1, y: 0) + } else if pressed.contains(.right), direction.x != -1 { + direction = ArcadePoint(x: 1, y: 0) + } + + guard now - lastTick >= 0.105 else { return } + lastTick = now + let head = ArcadePoint( + x: body[0].x + direction.x, + y: body[0].y + direction.y + ) + let dead = head.x < 0 || head.x >= ArcadeFrame.width / 2 + || head.y < 0 || head.y >= ArcadeFrame.height + || body.contains(head) + if dead { + reset() + return + } + let ate = head == food + body.insert(head, at: 0) + if ate { + placeFood() + } else { + body.removeLast() + } + } + + private mutating func placeFood() { + repeat { + food = ArcadePoint( + x: Int.random(in: 1..<(ArcadeFrame.width / 2 - 1)), + y: Int.random(in: 1..<(ArcadeFrame.height - 1)) + ) + } while body.contains(food) + } + + func render(into frame: inout ArcadeFrame) { + for (index, point) in body.enumerated().reversed() { + frame.rectangle( + x: point.x * 2, y: point.y, width: 2, height: 1, + color: index == 0 ? .yellow : .green + ) + } + frame.rectangle(x: food.x * 2, y: food.y, width: 2, height: 1, color: .red) + } +} + +private struct TetrisState: Sendable { + private static let shapes: [[UInt16]] = [ + [0x0F00, 0x2222, 0x00F0, 0x4444], + [0x8E00, 0x6440, 0x0E20, 0x44C0], + [0x2E00, 0x4460, 0x0E80, 0xC440], + [0x6600, 0x6600, 0x6600, 0x6600], + [0x6C00, 0x4620, 0x06C0, 0x8C40], + [0x4E00, 0x4640, 0x0E40, 0x4C40], + [0xC600, 0x2640, 0x0C60, 0x4C80], + ] + private static let colors: [ArcadeColor] = [ + .black, .cyan, + ArcadeColor(red: 40, green: 80, blue: 255), + .orange, .yellow, .green, .magenta, .red, + ] + + private var board = Array(repeating: Array(repeating: 0, count: 10), count: 16) + private var piece = 0 + private var rotation = 0 + private var position = ArcadePoint(x: 3, y: -1) + private var lastTick: TimeInterval = 0 + private var score = 0 + + mutating func reset() { + board = Array(repeating: Array(repeating: 0, count: 10), count: 16) + score = 0 + lastTick = 0 + spawn() + } + + mutating func update( + now: TimeInterval, + held: Set, + pressed: Set + ) { + if pressed.contains(.left), isValid(x: position.x - 1, y: position.y, rotation: rotation) { + position.x -= 1 + } + if pressed.contains(.right), isValid(x: position.x + 1, y: position.y, rotation: rotation) { + position.x += 1 + } + let nextRotation = (rotation + 1) % 4 + if pressed.contains(.up), isValid(x: position.x, y: position.y, rotation: nextRotation) { + rotation = nextRotation + } + if pressed.contains(.space) { + while isValid(x: position.x, y: position.y + 1, rotation: rotation) { + position.y += 1 + } + lockPiece() + return + } + let delay = held.contains(.down) + ? 0.055 + : max(0.22, 0.42 - Double(score) * 0.01) + if now - lastTick >= delay { + lastTick = now + if isValid(x: position.x, y: position.y + 1, rotation: rotation) { + position.y += 1 + } else { + lockPiece() + } + } + } + + private func hasCell(piece: Int, rotation: Int, x: Int, y: Int) -> Bool { + let bit = 15 - (y * 4 + x) + return (Self.shapes[piece][rotation] >> bit) & 1 == 1 + } + + private func isValid(x: Int, y: Int, rotation: Int) -> Bool { + for row in 0..<4 { + for column in 0..<4 where hasCell( + piece: piece, rotation: rotation, x: column, y: row + ) { + let boardX = x + column + let boardY = y + row + if boardX < 0 || boardX >= 10 || boardY >= 16 { + return false + } + if boardY >= 0, board[boardY][boardX] != 0 { + return false + } + } + } + return true + } + + private mutating func spawn() { + piece = Int.random(in: 0..= 0 { + board[y][position.x + column] = piece + 1 + } + } + } + var row = 15 + while row >= 0 { + if board[row].allSatisfy({ $0 != 0 }) { + board.remove(at: row) + board.insert(Array(repeating: 0, count: 10), at: 0) + score += 1 + } else { + row -= 1 + } + } + spawn() + } + + func render(into frame: inout ArcadeFrame) { + let originX = 26 + frame.clear(.dim) + frame.rectangle(x: originX - 1, y: 0, width: 22, height: 16, color: .white) + frame.rectangle(x: originX, y: 0, width: 20, height: 16, color: .black) + for row in 0..<16 { + for column in 0..<10 where board[row][column] != 0 { + frame.rectangle( + x: originX + column * 2, y: row, width: 2, height: 1, + color: Self.colors[board[row][column]] + ) + } + } + for row in 0..<4 { + for column in 0..<4 where hasCell( + piece: piece, rotation: rotation, x: column, y: row + ) { + let y = position.y + row + if y >= 0 { + frame.rectangle( + x: originX + (position.x + column) * 2, + y: y, width: 2, height: 1, + color: Self.colors[piece + 1] + ) + } + } + } + for pixel in 0..<(score % 12) { + frame.set(x: 5 + pixel, y: 14, color: .yellow) + } + } +} + +private struct PongState: Sendable { + private var ballX = Double(ArcadeFrame.width / 2) + private var ballY = Double(ArcadeFrame.height / 2) + private var velocityX = 0.55 + private var velocityY = 0.28 + private var paddle = 6 + private var computer = 6 + private var playerScore = 0 + private var computerScore = 0 + + mutating func reset() { + paddle = 6 + computer = 6 + playerScore = 0 + computerScore = 0 + serve(direction: Bool.random() ? 1 : -1) + } + + mutating func update(held: Set) { + if held.contains(.up) || held.contains(.w) { paddle -= 1 } + if held.contains(.down) || held.contains(.s) { paddle += 1 } + paddle = min(max(paddle, 1), ArcadeFrame.height - 5) + if ballY > Double(computer + 2) { computer += 1 } + if ballY < Double(computer + 1) { computer -= 1 } + computer = min(max(computer, 1), ArcadeFrame.height - 5) + + ballX += velocityX + ballY += velocityY + if ballY <= 1 || ballY >= Double(ArcadeFrame.height - 2) { + velocityY *= -1 + } + if ballX >= 2, ballX <= 3, + ballY >= Double(paddle), ballY <= Double(paddle + 4) { + velocityX = min(abs(velocityX) + 0.025, 1.25) + velocityY += (ballY - Double(paddle + 2)) * 0.08 + velocityY = min(max(velocityY, -0.9), 0.9) + } + if ballX >= Double(ArcadeFrame.width - 4), + ballX <= Double(ArcadeFrame.width - 3), + ballY >= Double(computer), ballY <= Double(computer + 4) { + velocityX = -min(abs(velocityX) + 0.025, 1.25) + velocityY += (ballY - Double(computer + 2)) * 0.08 + velocityY = min(max(velocityY, -0.9), 0.9) + } + if ballX < 0 { + computerScore += 1 + serve(direction: 1) + } else if ballX >= Double(ArcadeFrame.width) { + playerScore += 1 + serve(direction: -1) + } + } + + private mutating func serve(direction: Double) { + ballX = Double(ArcadeFrame.width / 2) + ballY = Double(ArcadeFrame.height / 2) + velocityX = direction * 0.55 + velocityY = Bool.random() ? 0.28 : -0.28 + } + + func render(into frame: inout ArcadeFrame) { + for y in stride(from: 0, to: ArcadeFrame.height, by: 2) { + frame.set(x: ArcadeFrame.width / 2, y: y, color: .dim) + } + frame.rectangle(x: 2, y: paddle, width: 2, height: 5, color: .cyan) + frame.rectangle( + x: ArcadeFrame.width - 4, y: computer, + width: 2, height: 5, color: .magenta + ) + frame.rectangle(x: Int(ballX), y: Int(ballY), width: 2, height: 1, color: .white) + for pixel in 0..<(playerScore % 10) { + frame.set(x: 7 + pixel, y: 0, color: .cyan) + } + for pixel in 0..<(computerScore % 10) { + frame.set(x: ArcadeFrame.width - 8 - pixel, y: 0, color: .magenta) + } + } +} + +private struct BreakoutState: Sendable { + private var ballX = Double(ArcadeFrame.width / 2) + private var ballY = Double(ArcadeFrame.height - 4) + private var velocityX = 0.45 + private var velocityY = -0.32 + private var paddle = ArcadeFrame.width / 2 - 6 + private var bricks = Array(repeating: Array(repeating: true, count: 12), count: 4) + private var remaining = 48 + + mutating func reset() { + paddle = ArcadeFrame.width / 2 - 6 + bricks = Array(repeating: Array(repeating: true, count: 12), count: 4) + remaining = 48 + resetBall() + } + + mutating func update(held: Set) { + if held.contains(.left) { paddle -= 2 } + if held.contains(.right) { paddle += 2 } + paddle = min(max(paddle, 1), ArcadeFrame.width - 13) + + ballX += velocityX + ballY += velocityY + if ballX <= 1 || ballX >= Double(ArcadeFrame.width - 2) { + velocityX *= -1 + } + if ballY <= 1 { + velocityY = abs(velocityY) + } + if ballY >= Double(ArcadeFrame.height - 3), + ballY <= Double(ArcadeFrame.height - 2), + ballX >= Double(paddle), ballX <= Double(paddle + 12) { + velocityY = -abs(velocityY) + velocityX += (ballX - Double(paddle + 6)) * 0.025 + velocityX = min(max(velocityX, -1.25), 1.25) + } + let row = Int(ballY) - 1 + let column = Int(ballX) / 6 + if row >= 0, row < 4, column >= 0, column < 12, bricks[row][column] { + bricks[row][column] = false + remaining -= 1 + velocityY *= -1 + } + if ballY >= Double(ArcadeFrame.height) { + resetBall() + } + if remaining == 0 { + reset() + } + } + + private mutating func resetBall() { + ballX = Double(ArcadeFrame.width / 2) + ballY = Double(ArcadeFrame.height - 4) + velocityX = Bool.random() ? 0.45 : -0.45 + velocityY = -0.32 + } + + func render(into frame: inout ArcadeFrame) { + let colors: [ArcadeColor] = [.red, .orange, .yellow, .green] + for row in 0..<4 { + for column in 0..<12 where bricks[row][column] { + frame.rectangle( + x: column * 6 + 1, y: row + 1, + width: 5, height: 1, color: colors[row] + ) + } + } + frame.rectangle( + x: paddle, y: ArcadeFrame.height - 2, + width: 12, height: 1, color: .cyan + ) + frame.rectangle(x: Int(ballX), y: Int(ballY), width: 2, height: 1, color: .white) + frame.rectangle(x: 0, y: 0, width: ArcadeFrame.width, height: 1, color: .dim) + frame.rectangle( + x: 0, y: ArcadeFrame.height - 1, + width: ArcadeFrame.width, height: 1, color: .dim + ) + frame.rectangle(x: 0, y: 0, width: 1, height: ArcadeFrame.height, color: .dim) + frame.rectangle( + x: ArcadeFrame.width - 1, y: 0, + width: 1, height: ArcadeFrame.height, color: .dim + ) + } +} diff --git a/Sources/BarKeep/MenuView.swift b/Sources/BarKeep/MenuView.swift index 6325a00..ccdd210 100644 --- a/Sources/BarKeep/MenuView.swift +++ b/Sources/BarKeep/MenuView.swift @@ -13,7 +13,8 @@ struct MenuView: View { Text("Device").tag(0) Text("Message").tag(1) Text("Timers").tag(2) - Text("Settings").tag(3) + Text("Arcade").tag(3) + Text("Settings").tag(4) } .pickerStyle(.segmented) .labelsHidden() @@ -22,6 +23,7 @@ struct MenuView: View { case 0: DeviceTab() case 1: MessageTab() case 2: TimersTab() + case 3: ArcadeTab() default: SettingsTab() } @@ -92,6 +94,114 @@ struct MenuView: View { } } +// MARK: - Arcade tab + +@MainActor +struct ArcadeTab: View { + @Environment(AppState.self) private var state + + var body: some View { + @Bindable var arcade = state.arcade + VStack(alignment: .leading, spacing: 10) { + if state.onCall { + Text("End the current busy session before starting a game.") + .font(.caption2) + .foregroundStyle(.orange) + } else if !state.deviceReachable { + Text("Connect the Busy Bar before starting a game.") + .font(.caption2) + .foregroundStyle(.orange) + } + + if arcade.showPreview, let image = arcade.previewImage { + Image(decorative: image, scale: 1) + .resizable() + .interpolation(.none) + .aspectRatio(72.0 / 16.0, contentMode: .fit) + .padding(4) + .background(.black, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary)) + } + + ForEach(ArcadeGame.allCases) { game in + HStack { + Text("\(game.number)") + .font(.caption.monospaced().bold()) + .frame(width: 18, height: 18) + .foregroundStyle(Color(nsColor: nsColor(game.color))) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 4)) + VStack(alignment: .leading, spacing: 1) { + Text(game.title) + .font(.subheadline.bold()) + Text(game.controls) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + Button( + arcade.isActive && arcade.selectedGame == game + ? "Restart" : "Play" + ) { + if arcade.isActive && arcade.selectedGame == game { + arcade.restart() + } else { + arcade.start(game) + } + } + .controlSize(.small) + .disabled(state.onCall || !state.deviceReachable) + } + } + + Divider() + + if arcade.isActive { + HStack { + Circle() + .fill(.green) + .frame(width: 8, height: 8) + Text("Playing \(arcade.selectedGame.title)") + .font(.caption.bold()) + Spacer() + Button("Stop") { arcade.stop() } + .controlSize(.small) + } + Text("Keyboard captured Β· 1–4 switch games Β· R restarts Β· Esc stops") + .font(.caption2) + .foregroundStyle(.secondary) + Text("\(arcade.framesSent) frames sent Β· \(arcade.framesDropped) skipped") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.tertiary) + } else { + Text("After Play, use the Mac keyboard while watching the Busy Bar. Press Esc to stop and return keyboard focus to your previous app.") + .font(.caption2) + .foregroundStyle(.secondary) + } + + Toggle("Show preview in BarKeep", isOn: $arcade.showPreview) + .toggleStyle(.checkbox) + .font(.caption) + + if let error = arcade.errorMessage { + Text(error) + .font(.caption2) + .foregroundStyle(.red) + .lineLimit(2) + } + } + } + + private func nsColor(_ color: ArcadeColor) -> NSColor { + NSColor( + deviceRed: CGFloat(color.red) / 255, + green: CGFloat(color.green) / 255, + blue: CGFloat(color.blue) / 255, + alpha: 1 + ) + } +} + // MARK: - Device tab @MainActor diff --git a/Tests/BarKeepTests/ArcadeEngineTests.swift b/Tests/BarKeepTests/ArcadeEngineTests.swift new file mode 100644 index 0000000..9ce31ef --- /dev/null +++ b/Tests/BarKeepTests/ArcadeEngineTests.swift @@ -0,0 +1,74 @@ +import XCTest +@testable import BarKeep + +final class ArcadeEngineTests: XCTestCase { + func testEveryGameRendersAVisibleNativeResolutionFrame() { + for game in ArcadeGame.allCases { + let engine = ArcadeEngine(game: game) + XCTAssertEqual(engine.frame.pixels.count, 72 * 16, game.title) + XCTAssertTrue( + engine.frame.pixels.contains(where: { $0 != .black }), + "\(game.title) should render visible pixels" + ) + } + } + + func testSnakeMovesOnItsTick() { + var engine = ArcadeEngine(game: .snake) + let initial = engine.frame + engine.update(now: 1, held: [], pressed: [.down]) + XCTAssertNotEqual(engine.frame, initial) + } + + func testTetrisHardDropChangesTheBoard() { + var engine = ArcadeEngine(game: .tetris) + let initial = engine.frame + engine.update(now: 1, held: [], pressed: [.space]) + XCTAssertNotEqual(engine.frame, initial) + } + + func testPongAndBreakoutAnimate() { + for game in [ArcadeGame.pong, .breakout] { + var engine = ArcadeEngine(game: game) + let initial = engine.frame + for tick in 1...5 { + engine.update( + now: Double(tick) / 60, + held: game == .breakout ? [.left] : [], + pressed: [] + ) + } + XCTAssertNotEqual(engine.frame, initial, game.title) + } + } + + func testBallGamesRemainRenderableDuringLongSessions() { + for game in [ArcadeGame.pong, .breakout] { + var engine = ArcadeEngine(game: game) + for tick in 1...20_000 { + engine.update( + now: Double(tick) / 60, + held: tick.isMultiple(of: 120) ? [.left] : [], + pressed: [] + ) + } + + XCTAssertEqual(engine.frame.pixels.count, 72 * 16) + XCTAssertTrue( + engine.frame.pixels.contains { $0 != .black }, + "\(game.title) produced a blank frame after a long session" + ) + } + } + + @MainActor + func testArcadeFrameEncodesAsPNG() { + let engine = ArcadeEngine(game: .snake) + let data = ArcadeRenderer.pngData(from: engine.frame) + XCTAssertNotNil(data) + XCTAssertEqual( + Array(data?.prefix(8) ?? Data()), + [137, 80, 78, 71, 13, 10, 26, 10] + ) + } +} diff --git a/packaging/Info.plist b/packaging/Info.plist index 5f75af8..11f94be 100644 --- a/packaging/Info.plist +++ b/packaging/Info.plist @@ -13,7 +13,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.9 + 1.0.10 LSMinimumSystemVersion 14.0 LSUIElement