From 32f6cf689b9314dd6d0106a38045cb84d0781750 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 22 Jul 2026 00:14:51 -0700 Subject: [PATCH] Add native Menu Fighter desktop rounds --- macos/menu-fighter-native/.gitignore | 1 + macos/menu-fighter-native/Package.swift | 12 + macos/menu-fighter-native/README.md | 44 ++ .../MenuFighterNative/FightNetwork.swift | 143 +++++ .../Sources/MenuFighterNative/main.swift | 575 ++++++++++++++++++ .../Sources/TrackpadBridge/TrackpadBridge.c | 63 ++ .../TrackpadBridge/include/TrackpadBridge.h | 6 + macos/menu-fighter-native/install.sh | 19 + session-server/fight-manager.mjs | 23 +- spec/fight-manager-spec.mjs | 21 + 10 files changed, 906 insertions(+), 1 deletion(-) create mode 100644 macos/menu-fighter-native/.gitignore create mode 100644 macos/menu-fighter-native/Package.swift create mode 100644 macos/menu-fighter-native/README.md create mode 100644 macos/menu-fighter-native/Sources/MenuFighterNative/FightNetwork.swift create mode 100644 macos/menu-fighter-native/Sources/MenuFighterNative/main.swift create mode 100644 macos/menu-fighter-native/Sources/TrackpadBridge/TrackpadBridge.c create mode 100644 macos/menu-fighter-native/Sources/TrackpadBridge/include/TrackpadBridge.h create mode 100755 macos/menu-fighter-native/install.sh diff --git a/macos/menu-fighter-native/.gitignore b/macos/menu-fighter-native/.gitignore new file mode 100644 index 0000000000..30bcfa4ed5 --- /dev/null +++ b/macos/menu-fighter-native/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/macos/menu-fighter-native/Package.swift b/macos/menu-fighter-native/Package.swift new file mode 100644 index 0000000000..989eb76849 --- /dev/null +++ b/macos/menu-fighter-native/Package.swift @@ -0,0 +1,12 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "MenuFighterNative", + platforms: [.macOS(.v14)], + products: [.executable(name: "menu-fighter", targets: ["MenuFighterNative"])], + targets: [ + .target(name: "TrackpadBridge", publicHeadersPath: "include"), + .executableTarget(name: "MenuFighterNative", dependencies: ["TrackpadBridge"], linkerSettings: [.linkedFramework("Security")]) + ] +) diff --git a/macos/menu-fighter-native/README.md b/macos/menu-fighter-native/README.md new file mode 100644 index 0000000000..c50f93cbaf --- /dev/null +++ b/macos/menu-fighter-native/README.md @@ -0,0 +1,44 @@ +# Menu Fighter — native macOS + +A browserless Swift/AppKit build of Menu Fighter. SpriteKit supplies the native +Metal-backed render loop; GameController and AppKit provide input; AVFoundation +supplies immediate hit/round audio. + +```sh +cd macos/menu-fighter-native +swift run menu-fighter +``` + +Put the fighters on a small central stage over the macOS desktop in a +transparent, chromeless, click-through overlay. Shots win through knockback and +ring-out; normal clicks continue to reach the app underneath. The overlay closes +itself after the round ends. + +```sh +swift run menu-fighter --desktop +``` + +Install the fleet-only four-corner trackpad watcher: + +```sh +./install.sh +``` + +Hold one finger in each trackpad corner for five uninterrupted seconds. A +rising noise countdown cancels as soon as the pose breaks. Completion opens the +desktop stage in search/practice mode with Player 2 acting as the dummy. Practice +continues while it searches for another native player, then both clients enter +the scoped online round. + +Public matchmaking requires an Aesthetic Computer access token. Save it in the +macOS Keychain once with `menu-fighter auth `. `AC_TOKEN` and +`~/.config/aesthetic-computer/token` are also supported for development. + +Controls: + +- Trackpad (Player 1): move the pointer to run, click/tap to shoot, two-finger + click to fire a heavy shot, and two-finger swipe upward to jump. +- Player 1: `A/D` move, `W` jump, `F` light attack, `G` heavy attack. +- Player 2: arrows move/jump, `/` light attack, `.` heavy attack. +- Controllers: d-pad/stick, A jump, X light, Y heavy. +- `Escape` opens the Menu Fighter card; `Return` starts/resets TRAIN. diff --git a/macos/menu-fighter-native/Sources/MenuFighterNative/FightNetwork.swift b/macos/menu-fighter-native/Sources/MenuFighterNative/FightNetwork.swift new file mode 100644 index 0000000000..6d32ed573f --- /dev/null +++ b/macos/menu-fighter-native/Sources/MenuFighterNative/FightNetwork.swift @@ -0,0 +1,143 @@ +import Foundation +import AppKit +import Security + +struct FightWireButtons { + let mask: Int +} + +@MainActor +final class NativeMatchmaker: NSObject, URLSessionWebSocketDelegate { + var onStatus: ((String) -> Void)? + var onStart: ((Int) -> Void)? + var onInput: ((FightWireButtons) -> Void)? + + private var session: URLSession! + private var socket: URLSessionWebSocketTask? + private var matchID: String? + private var frame = 0 + + func start() { + guard let token = Self.loadToken() else { + onStatus?("SIGN IN: menu-fighter auth ") + return + } + session = URLSession(configuration: .default, delegate: self, delegateQueue: .main) + let task = session.webSocketTask(with: URL(string: "wss://session-server.aesthetic.computer/")!) + socket = task; onStatus?("CONNECTING") + task.resume(); receive() + send("fight:auth", ["token": token, "requestId": UUID().uuidString]) + } + + func beginLogin() { + onStatus?("OPENING LOGIN") + Task { + do { + var request = URLRequest(url: URL(string: "https://aesthetic.computer/api/device-pair")!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: ["action": "create", "kind": "browser"]) + let (data, _) = try await URLSession.shared.data(for: request) + guard let pair = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let code = pair["code"] as? String, let secret = pair["pollSecret"] as? String else { throw LoginError.invalidResponse } + NSWorkspace.shared.open(URL(string: "https://aesthetic.computer/api/device-pair-login?code=\(code)&kind=browser")!) + onStatus?("FINISH LOGIN IN BROWSER") + for _ in 0..<120 { + try await Task.sleep(for: .seconds(2)) + var parts = URLComponents(string: "https://aesthetic.computer/api/device-pair")! + parts.queryItems = [URLQueryItem(name: "code", value: code), URLQueryItem(name: "secret", value: secret)] + let (pollData, response) = try await URLSession.shared.data(from: parts.url!) + if (response as? HTTPURLResponse)?.statusCode == 410 { throw LoginError.expired } + guard let result = try? JSONSerialization.jsonObject(with: pollData) as? [String: Any], + result["status"] as? String == "claimed", + let session = result["session"] as? [String: Any], let token = session["accessToken"] as? String else { continue } + guard Self.saveToken(token) else { throw LoginError.keychain } + onStatus?("SIGNED IN — CONNECTING"); start(); return + } + throw LoginError.expired + } catch { onStatus?("LOGIN FAILED — CLICK TO RETRY") } + } + } + + private enum LoginError: Error { case invalidResponse, expired, keychain } + + func stop() { socket?.cancel(with: .goingAway, reason: nil); socket = nil } + + func sendInput(_ buttons: Int) { + guard let matchID else { return } + frame += 1 + send("fight:input", ["matchId": matchID, "frame": frame, "buttons": buttons]) + } + + nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, + didOpenWithProtocol protocol: String?) { + Task { @MainActor in self.onStatus?("AUTHENTICATING") } + } + + private func receive() { + socket?.receive { [weak self] result in + Task { @MainActor in + guard let self else { return } + if case .success(let message) = result { + let data: Data? + switch message { case .string(let value): data = value.data(using: .utf8); case .data(let value): data = value; @unknown default: data = nil } + if let data { self.handle(data) } + self.receive() + } else { self.onStatus?("OFFLINE — PRACTICE"); self.socket = nil } + } + } + } + + private func handle(_ data: Data) { + guard let outer = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let type = outer["type"] as? String else { return } + var content = outer["content"] as? [String: Any] ?? [:] + if let string = outer["content"] as? String, let bytes = string.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: bytes) as? [String: Any] { content = parsed } + switch type { + case "fight:auth:ok": + onStatus?("SEARCHING — PRACTICE") + send("fight:queue:join", ["manifest": Self.manifest, "region": "us-west", "platform": "macos-native", "mode": "casual", "transport": "ws-input-v1"]) + case "fight:match:proposal": + guard let id = content["matchId"] as? String else { return } + matchID = id; onStatus?("OPPONENT FOUND") + send("fight:match:accept", ["matchId": id, "manifest": Self.manifest]) + case "fight:match:start": + matchID = content["matchId"] as? String + onStatus?("FIGHT") + onStart?(content["seat"] as? Int ?? 0) + case "fight:input": + if let buttons = content["buttons"] as? Int { onInput?(FightWireButtons(mask: buttons)) } + case "fight:match:peer-left": onStatus?("OPPONENT LEFT") + case "fight:error": onStatus?(String(describing: content["message"] ?? "MATCH ERROR").uppercased()) + default: break + } + } + + private func send(_ type: String, _ content: [String: Any]) { + guard let data = try? JSONSerialization.data(withJSONObject: ["type": type, "content": content]), + let text = String(data: data, encoding: .utf8) else { return } + socket?.send(.string(text)) { _ in } + } + + static let manifest: [String: Any] = [ + "protocolVersion": 1, "buildId": "menu-fighter-dev-2026-07-20", + "simHash": "fight-int32-v1", "rulesHash": "freefight-v1", "contentHash": "base-roster-v1" + ] + + static func loadToken() -> String? { + if let value = ProcessInfo.processInfo.environment["AC_TOKEN"], !value.isEmpty { return value } + let path = NSString(string: "~/.config/aesthetic-computer/token").expandingTildeInPath + if let value = try? String(contentsOfFile: path, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty { return value } + var item: CFTypeRef? + let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "computer.aesthetic.menu-fighter", kSecAttrAccount as String: "access-token", kSecReturnData as String: true] + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func saveToken(_ token: String) -> Bool { + let key: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "computer.aesthetic.menu-fighter", kSecAttrAccount as String: "access-token"] + SecItemDelete(key as CFDictionary) + var value = key; value[kSecValueData as String] = Data(token.utf8) + return SecItemAdd(value as CFDictionary, nil) == errSecSuccess + } +} diff --git a/macos/menu-fighter-native/Sources/MenuFighterNative/main.swift b/macos/menu-fighter-native/Sources/MenuFighterNative/main.swift new file mode 100644 index 0000000000..536826eca0 --- /dev/null +++ b/macos/menu-fighter-native/Sources/MenuFighterNative/main.swift @@ -0,0 +1,575 @@ +import AppKit +import AVFoundation +import GameController +import SpriteKit +import TrackpadBridge + +private let fixedStep = 1.0 / 60.0 +private let arenaWidth: CGFloat = 1280 +private let arenaHeight: CGFloat = 720 +private let windowWidth: CGFloat = 420 +private let windowHeight: CGFloat = 236.25 +private let ink = NSColor(calibratedRed: 0.035, green: 0.025, blue: 0.055, alpha: 1) +private let pink = NSColor(calibratedRed: 1, green: 0.22, blue: 0.49, alpha: 1) + +private struct Buttons { + var left = false, right = false, jump = false, light = false, heavy = false +} + +private extension Buttons { + var mask: Int { (left ? 1 : 0) | (right ? 2 : 0) | (jump ? 4 : 0) | (light ? 8 : 0) | (heavy ? 16 : 0) } + init(mask: Int) { left = mask & 1 != 0; right = mask & 2 != 0; jump = mask & 4 != 0; light = mask & 8 != 0; heavy = mask & 16 != 0 } +} + +@MainActor private final class Shot { + let node = SKShapeNode() + let owner: Int + let damage: CGFloat + let direction: CGFloat + + init(owner: Int, heavy: Bool, direction: CGFloat, position: CGPoint) { + self.owner = owner; self.damage = heavy ? 15 : 8; self.direction = direction + let radius: CGFloat = heavy ? 18 : 10 + node.path = CGPath(ellipseIn: CGRect(x: -radius, y: -radius, width: radius * 2, height: radius * 2), transform: nil) + node.fillColor = owner == 1 ? pink : ink + node.strokeColor = pink; node.lineWidth = heavy ? 6 : 4 + node.position = position + } + + var hitbox: CGRect { node.frame.insetBy(dx: -3, dy: -3) } +} + +private final class Tone { + private let engine = AVAudioEngine() + private let player = AVAudioPlayerNode() + private let format = AVAudioFormat(standardFormatWithSampleRate: 48_000, channels: 1)! + private var voicePlayer: AVAudioPlayer? + + init() { + engine.attach(player) + engine.connect(player, to: engine.mainMixerNode, format: format) + try? engine.start() + } + + func play(frequency: Double, duration: Double, volume: Float = 0.18) { + let frames = AVAudioFrameCount(48_000 * duration) + guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames), + let samples = buffer.floatChannelData?[0] else { return } + buffer.frameLength = frames + for i in 0..= 2, !spokeCountdown { spokeCountdown = true; tone.playJeffreyCountdown() } + tone.noise(volume: Float(0.018 + min(1, elapsed / 5) * 0.12)) + guard elapsed >= 5 else { return } + self.began = nil; timer?.invalidate(); timer = nil; launched = true + panel?.orderOut(nil) + tone.play(frequency: 520, duration: 0.14, volume: 0.24) + let process = Process(); process.executableURL = URL(fileURLWithPath: CommandLine.arguments[0]) + process.arguments = ["--desktop", "--searching"] + try? process.run() + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { self.launched = false } + } + + private func showCountdown() { + if panel == nil { + let rect = NSRect(x: 0, y: 0, width: 300, height: 170) + let view = CornerCountdownView(frame: rect) + let created = NSPanel(contentRect: rect, styleMask: [.borderless], backing: .buffered, defer: false) + created.contentView = view; created.isOpaque = false; created.backgroundColor = .clear + created.hasShadow = false; created.level = .floating; created.ignoresMouseEvents = true + created.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel = created; countdownView = view + } + countdownView?.progress = 0; panel?.center(); panel?.orderFrontRegardless() + } +} + +@MainActor private final class Fighter { + let root = SKNode() + private let body = SKShapeNode(rectOf: CGSize(width: 76, height: 108), cornerRadius: 30) + private let face = SKShapeNode(circleOfRadius: 27) + private let fist = SKShapeNode(circleOfRadius: 16) + private let shadow = SKShapeNode(ellipseOf: CGSize(width: 92, height: 19)) + let number: Int + var velocity = CGVector.zero + var health: CGFloat = 100 + var facing: CGFloat + var grounded = true + var attackFrames = 0 + var cooldown = 0 + var hitThisAttack = false + var flashFrames = 0 + var floorY: CGFloat = 122 + var platformRange: ClosedRange = 70...(arenaWidth - 70) + var allowsRingOut = false + var visualScale: CGFloat = 1 + + init(number: Int, inverted: Bool, facing: CGFloat) { + self.number = number; self.facing = facing + let fill = inverted ? ink : pink + let outline = inverted ? pink : ink + shadow.fillColor = ink; shadow.strokeColor = ink + shadow.position.y = -57; root.addChild(shadow) + body.fillColor = fill; body.strokeColor = outline; body.lineWidth = 6 + root.addChild(body) + face.fillColor = outline; face.strokeColor = fill; face.lineWidth = 6; face.position.y = 48; root.addChild(face) + let eyeA = SKShapeNode(circleOfRadius: 4), eyeB = SKShapeNode(circleOfRadius: 4) + for (eye, x) in [(eyeA, CGFloat(-9)), (eyeB, CGFloat(9))] { + eye.fillColor = fill; eye.strokeColor = fill; eye.position = CGPoint(x: x, y: 52); root.addChild(eye) + } + fist.fillColor = fill; fist.strokeColor = outline; fist.lineWidth = 5 + fist.position = CGPoint(x: 44 * facing, y: 2); root.addChild(fist) + } + + var hitbox: CGRect { CGRect(x: root.position.x - 38, y: root.position.y - 54, width: 76, height: 108) } + var attackbox: CGRect { + let reach: CGFloat = attackFrames > 0 ? (attackFrames > 7 ? 82 : 60) : 0 + return CGRect(x: facing > 0 ? root.position.x + 22 : root.position.x - 22 - reach, + y: root.position.y - 28, width: reach, height: 62) + } + + func beginAttack(heavy: Bool) -> Bool { + guard cooldown == 0, attackFrames == 0 else { return false } + attackFrames = heavy ? 16 : 10; cooldown = heavy ? 29 : 18; hitThisAttack = false + return true + } + + func tick(buttons: Buttons, opponentX: CGFloat) -> Int? { + facing = opponentX >= root.position.x ? 1 : -1 + let desired: CGFloat = buttons.left ? -7 : buttons.right ? 7 : 0 + velocity.dx += (desired - velocity.dx) * (grounded ? 0.34 : 0.12) + if buttons.jump && grounded { velocity.dy = 17; grounded = false } + var fired: Int? + if buttons.light, beginAttack(heavy: false) { fired = 8 } + if buttons.heavy, beginAttack(heavy: true) { fired = 15 } + if allowsRingOut, grounded, !platformRange.contains(root.position.x) { grounded = false } + velocity.dy -= grounded ? 0 : 0.92 + let nextX = root.position.x + velocity.dx + root.position.x = allowsRingOut ? min(arenaWidth + 80, max(-80, nextX)) : min(arenaWidth - 70, max(70, nextX)) + root.position.y += velocity.dy + if root.position.y <= floorY, velocity.dy <= 0, platformRange.contains(root.position.x), root.position.y > floorY - 36 { + root.position.y = floorY; velocity.dy = 0; grounded = true + } + if attackFrames > 0 { attackFrames -= 1 } + if cooldown > 0 { cooldown -= 1 } + if flashFrames > 0 { flashFrames -= 1 } + let extensionAmount: CGFloat = attackFrames > 0 ? 12 : 0 + fist.position.x = facing * (44 + extensionAmount) + root.xScale = visualScale * (flashFrames % 2 == 1 ? 1.08 : 1) + root.yScale = visualScale + root.alpha = flashFrames % 2 == 1 ? 0.55 : 1 + shadow.xScale = max(0.55, 1 - (root.position.y - floorY) / 600) + return fired + } +} + +private final class FightScene: SKScene { + private let desktopMode: Bool + private let searching: Bool + private var matchmaker: NativeMatchmaker? + private var online = false + private var remoteButtons = Buttons() + private var loginNeeded = false + private let tone = Tone() + private let p1 = Fighter(number: 1, inverted: false, facing: 1) + private let p2 = Fighter(number: 2, inverted: true, facing: -1) + private let p1Bar = SKShapeNode(), p2Bar = SKShapeNode() + private let title = SKLabelNode(fontNamed: "AvenirNext-Heavy") + private let status = SKLabelNode(fontNamed: "AvenirNext-Bold") + private let loginButton = SKShapeNode(rectOf: CGSize(width: 250, height: 52), cornerRadius: 18) + private let menuCard = SKShapeNode(rectOf: CGSize(width: 450, height: 290), cornerRadius: 34) + private let train = SKLabelNode(fontNamed: "AvenirNext-Heavy") + private let find = SKLabelNode(fontNamed: "AvenirNext-Heavy") + private var keys = Set() + private var previous = [Buttons(), Buttons()] + private var accumulator = 0.0, lastTime = 0.0 + private var menuOpen = true, roundOver = false + private var shots: [Shot] = [] + private var pointerX: CGFloat? + private var pointerLight = false, pointerHeavy = false, pointerJump = false + private let desktopStage: ClosedRange = 500...780 + private let desktopFloor: CGFloat = 260 + + init(size: CGSize, desktopMode: Bool = false, searching: Bool = false) { + self.desktopMode = desktopMode; self.searching = searching + super.init(size: size) + } + + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func didMove(to view: SKView) { + backgroundColor = desktopMode ? .clear : ink + if !desktopMode { addBackdrop() } else { addDesktopStage() } + if desktopMode { + for fighter in [p1, p2] { + fighter.floorY = desktopFloor; fighter.platformRange = desktopStage; fighter.allowsRingOut = true + fighter.visualScale = 0.52; fighter.root.setScale(fighter.visualScale) + } + } + p1.root.position = CGPoint(x: 390, y: 122); p2.root.position = CGPoint(x: 890, y: 122) + addChild(p1.root); addChild(p2.root) + title.text = "MENU FIGHTER"; title.fontSize = 44; title.fontColor = pink + title.position = CGPoint(x: arenaWidth / 2, y: arenaHeight - 70); if !desktopMode { addChild(title) } + status.fontSize = desktopMode ? 16 : 22; status.fontColor = pink; status.position = CGPoint(x: arenaWidth / 2, y: desktopMode ? desktopFloor + 55 : 48); status.zPosition = 31; addChild(status) + loginButton.fillColor = ink; loginButton.strokeColor = pink; loginButton.lineWidth = 4 + loginButton.position = status.position; loginButton.zPosition = 30; loginButton.isHidden = true; addChild(loginButton) + menuCard.fillColor = ink; menuCard.strokeColor = pink; menuCard.lineWidth = 7 + menuCard.position = CGPoint(x: arenaWidth / 2, y: arenaHeight / 2); menuCard.zPosition = 20; if !desktopMode { addChild(menuCard) } + train.text = "TRAIN"; train.fontSize = 42; train.fontColor = pink; train.position = CGPoint(x: 0, y: 26); menuCard.addChild(train) + let trainSub = SKLabelNode(fontNamed: "AvenirNext-Medium"); trainSub.text = "RETURN • LOCAL FREEFIGHT"; trainSub.fontSize = 15; trainSub.fontColor = pink; trainSub.position.y = -4; menuCard.addChild(trainSub) + find.text = "FIND"; find.fontSize = 34; find.fontColor = pink; find.alpha = 0.28; find.position.y = -75; menuCard.addChild(find) + let planned = SKLabelNode(fontNamed: "AvenirNext-Medium"); planned.text = "ONLINE • COMING NEXT"; planned.fontSize = 13; planned.fontColor = pink; planned.alpha = 0.28; planned.position.y = -101; menuCard.addChild(planned) + resetRound(); showMenu(!desktopMode) + if searching { startMatchmaking() } + } + + private func startMatchmaking() { + let network = NativeMatchmaker(); matchmaker = network + network.onStatus = { [weak self] text in + guard let self else { return } + status.text = text.hasPrefix("SIGN IN") ? "LOG IN" : text + loginNeeded = text.hasPrefix("SIGN IN") || text.hasPrefix("LOGIN FAILED") + loginButton.isHidden = !loginNeeded + } + network.onStart = { [weak self] _ in self?.online = true; self?.remoteButtons = Buttons(); self?.resetRound() } + network.onInput = { [weak self] wire in self?.remoteButtons = Buttons(mask: wire.mask) } + network.start() + } + + private func addBackdrop() { + let floor = SKShapeNode(rect: CGRect(x: 0, y: 0, width: arenaWidth, height: 122)) + floor.fillColor = pink; floor.strokeColor = pink; addChild(floor) + for i in 0..<12 { + let stripe = SKShapeNode(rectOf: CGSize(width: 42, height: 500)) + stripe.fillColor = i % 2 == 0 ? pink : ink + stripe.strokeColor = stripe.fillColor; stripe.position = CGPoint(x: CGFloat(i) * 116, y: 370); stripe.zRotation = -0.14; addChild(stripe) + } + } + + private func addDesktopStage() { + let platform = SKShapeNode(rectOf: CGSize(width: desktopStage.upperBound - desktopStage.lowerBound, height: 14), cornerRadius: 7) + platform.fillColor = pink; platform.strokeColor = pink; platform.lineWidth = 5 + platform.position = CGPoint(x: arenaWidth / 2, y: desktopFloor - 35) + platform.zPosition = -1; addChild(platform) + } + + private func showMenu(_ show: Bool) { + menuOpen = show; menuCard.isHidden = !show + status.text = show ? "CLICK TRAIN • ESC closes menu" : "TRACKPAD: MOVE • TAP SHOOTS • 2-FINGER CLICK HEAVY • SWIPE UP JUMPS" + } + + private func resetRound() { + p1.health = 100; p2.health = 100 + let y = desktopMode ? desktopFloor : 122 + p1.root.position = CGPoint(x: desktopMode ? 565 : 390, y: y) + p2.root.position = CGPoint(x: desktopMode ? 715 : 890, y: y) + p1.velocity = .zero; p2.velocity = .zero; roundOver = false; updateBars() + for shot in shots { shot.node.removeFromParent() }; shots.removeAll() + } + + override func update(_ currentTime: TimeInterval) { + if lastTime == 0 { lastTime = currentTime; return } + accumulator += min(0.1, currentTime - lastTime); lastTime = currentTime + while accumulator >= fixedStep { tick(); accumulator -= fixedStep } + } + + private func tick() { + guard !menuOpen, !roundOver else { return } + let now = input() + if online { matchmaker?.sendInput(now[0].mask) } + if let damage = p1.tick(buttons: edges(now[0], previous[0]), opponentX: p2.root.position.x) { fire(from: p1, damage: damage) } + let second = online ? remoteButtons : now[1] + if let damage = p2.tick(buttons: edges(second, previous[1]), opponentX: p1.root.position.x) { fire(from: p2, damage: damage) } + previous = [now[0], second] + separate() + updateShots() + checkRingOut() + updateBars() + } + + private func edges(_ current: Buttons, _ old: Buttons) -> Buttons { + var out = current; out.jump = current.jump && !old.jump; out.light = current.light && !old.light; out.heavy = current.heavy && !old.heavy; return out + } + + private func input() -> [Buttons] { + var a = Buttons(left: keys.contains(0), right: keys.contains(2), jump: keys.contains(13), light: keys.contains(3), heavy: keys.contains(5)) + var b = Buttons(left: keys.contains(123), right: keys.contains(124), jump: keys.contains(126), light: keys.contains(44), heavy: keys.contains(47)) + if let target = pointerX { + a.left = a.left || target < p1.root.position.x - 18 + a.right = a.right || target > p1.root.position.x + 18 + } + a.light = a.light || pointerLight; a.heavy = a.heavy || pointerHeavy; a.jump = a.jump || pointerJump + pointerLight = false; pointerHeavy = false; pointerJump = false + for (i, controller) in GCController.controllers().prefix(2).enumerated() { + guard let g = controller.extendedGamepad else { continue } + let x = g.leftThumbstick.xAxis.value + if i == 0 { a.left = a.left || x < -0.3 || g.dpad.left.isPressed; a.right = a.right || x > 0.3 || g.dpad.right.isPressed; a.jump = a.jump || g.buttonA.isPressed; a.light = a.light || g.buttonX.isPressed; a.heavy = a.heavy || g.buttonY.isPressed } + else { b.left = b.left || x < -0.3 || g.dpad.left.isPressed; b.right = b.right || x > 0.3 || g.dpad.right.isPressed; b.jump = b.jump || g.buttonA.isPressed; b.light = b.light || g.buttonX.isPressed; b.heavy = b.heavy || g.buttonY.isPressed } + } + return [a, b] + } + + private func separate() { + let delta = p2.root.position.x - p1.root.position.x + if abs(delta) < 82 { let push = (82 - abs(delta)) / 2; p1.root.position.x -= push * (delta >= 0 ? 1 : -1); p2.root.position.x += push * (delta >= 0 ? 1 : -1) } + } + + private func fire(from fighter: Fighter, damage: Int) { + let heavy = damage > 8 + let shot = Shot(owner: fighter.number, heavy: heavy, direction: fighter.facing, + position: CGPoint(x: fighter.root.position.x + fighter.facing * 72, y: fighter.root.position.y + 5)) + if desktopMode { shot.node.setScale(0.52) } + shots.append(shot); addChild(shot.node) + tone.play(frequency: heavy ? 115 : 230, duration: heavy ? 0.11 : 0.045, volume: heavy ? 0.24 : 0.14) + } + + private func updateShots() { + for shot in shots { + shot.node.position.x += shot.direction * (shot.damage > 8 ? 13 : 18) + shot.node.zRotation += shot.direction * 0.22 + let defender = shot.owner == 1 ? p2 : p1 + guard shot.hitbox.intersects(defender.hitbox) else { continue } + defender.health = max(0, defender.health - shot.damage) + defender.velocity.dx = shot.direction * (shot.damage > 8 ? 14 : 8) + defender.velocity.dy = shot.damage > 8 ? 8 : 4; defender.grounded = false; defender.flashFrames = 8 + shot.node.removeFromParent() + tone.play(frequency: shot.damage > 8 ? 72 : 105, duration: 0.09, volume: 0.25) + if defender.health == 0 { + roundOver = true; status.text = "PLAYER \(shot.owner) WINS • RETURN TO REMATCH" + tone.play(frequency: 440, duration: 0.35, volume: 0.22) + } + } + shots.removeAll { $0.node.parent == nil || $0.node.position.x < -40 || $0.node.position.x > arenaWidth + 40 } + } + + private func checkRingOut() { + guard desktopMode, !roundOver else { return } + let loser: Fighter? = p1.root.position.y < -90 ? p1 : (p2.root.position.y < -90 ? p2 : nil) + guard let loser else { return } + let winner = loser.number == 1 ? 2 : 1 + roundOver = true + tone.play(frequency: 440, duration: 0.35, volume: 0.22) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { NSApp.terminate(nil) } + print("[menu-fighter] player \(winner) wins by ring-out") + } + + private func updateBars() { + guard !desktopMode else { return } + p1Bar.removeFromParent(); p2Bar.removeFromParent() + let w: CGFloat = 430 + p1Bar.path = CGPath(rect: CGRect(x: 0, y: 0, width: w * p1.health / 100, height: 24), transform: nil) + p2Bar.path = CGPath(rect: CGRect(x: 0, y: 0, width: w * p2.health / 100, height: 24), transform: nil) + p1Bar.fillColor = pink; p2Bar.fillColor = ink; p1Bar.strokeColor = pink; p2Bar.strokeColor = pink; p2Bar.lineWidth = 4 + p1Bar.position = CGPoint(x: 54, y: arenaHeight - 116); p2Bar.position = CGPoint(x: arenaWidth - 54 - w, y: arenaHeight - 116); addChild(p1Bar); addChild(p2Bar) + } + + override func keyDown(with event: NSEvent) { + if event.keyCode == 53 { showMenu(!menuOpen); return } + if event.keyCode == 36 && (menuOpen || roundOver) { resetRound(); showMenu(false); tone.play(frequency: 330, duration: 0.09); return } + keys.insert(event.keyCode) + } + override func keyUp(with event: NSEvent) { keys.remove(event.keyCode) } + + override func mouseMoved(with event: NSEvent) { updatePointer(event) } + override func mouseDragged(with event: NSEvent) { updatePointer(event) } + override func rightMouseDragged(with event: NSEvent) { updatePointer(event) } + + private func updatePointer(_ event: NSEvent) { + pointerX = convertPoint(fromView: event.locationInWindow).x + } + + override func mouseDown(with event: NSEvent) { + updatePointer(event) + if menuOpen { resetRound(); showMenu(false); tone.play(frequency: 330, duration: 0.09) } + else if roundOver { resetRound() } + else { pointerLight = true } + } + + override func rightMouseDown(with event: NSEvent) { + updatePointer(event) + if !menuOpen, !roundOver { pointerHeavy = true } + } + + override func scrollWheel(with event: NSEvent) { + updatePointer(event) + if event.scrollingDeltaY > 2 { pointerJump = true } + } + + func desktopPointer(normalizedX: CGFloat) { + let screenX = min(1, max(0, normalizedX)) * arenaWidth + pointerX = min(desktopStage.upperBound - 20, max(desktopStage.lowerBound + 20, screenX)) + } + func desktopShoot(heavy: Bool) { if heavy { pointerHeavy = true } else { pointerLight = true } } + func desktopJump() { pointerJump = true } + func desktopClick(at point: CGPoint, heavy: Bool) { + if loginNeeded, loginButton.contains(point) { + loginNeeded = false; loginButton.isHidden = true; matchmaker?.beginLogin() + } else { desktopShoot(heavy: heavy) } + } +} + +private final class AppDelegate: NSObject, NSApplicationDelegate { + private var window: NSWindow! + private var monitors: [Any] = [] + func applicationDidFinishLaunching(_ notification: Notification) { + let desktopMode = CommandLine.arguments.contains("--desktop") + let screenFrame = NSScreen.main?.frame ?? NSRect(x: 0, y: 0, width: 1280, height: 720) + let viewFrame = desktopMode ? screenFrame : NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight) + let view = SKView(frame: viewFrame) + view.preferredFramesPerSecond = 60; view.ignoresSiblingOrder = true + view.allowsTransparency = desktopMode + let scene = FightScene(size: CGSize(width: arenaWidth, height: arenaHeight), desktopMode: desktopMode, searching: CommandLine.arguments.contains("--searching")); scene.scaleMode = .aspectFit + view.presentScene(scene) + window = NSWindow(contentRect: view.frame, + styleMask: desktopMode ? [.borderless] : [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false) + window.title = "Menu Fighter — Native Swift"; window.contentView = view + if desktopMode { + window.isOpaque = false; window.backgroundColor = .clear; window.hasShadow = false + window.level = .floating; window.ignoresMouseEvents = true + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + installDesktopControls(scene: scene, screenFrame: screenFrame) + } else { + window.contentAspectRatio = NSSize(width: 16, height: 9) + window.minSize = NSSize(width: 320, height: 180) + window.acceptsMouseMovedEvents = true + } + window.center(); window.makeKeyAndOrderFront(nil) + if !desktopMode { NSApp.activate(ignoringOtherApps: true) } + } + + private func installDesktopControls(scene: FightScene, screenFrame: NSRect) { + let pointerMask: NSEvent.EventTypeMask = [.mouseMoved, .leftMouseDragged, .rightMouseDragged] + if let monitor = NSEvent.addGlobalMonitorForEvents(matching: pointerMask, handler: { event in + let x = (NSEvent.mouseLocation.x - screenFrame.minX) / screenFrame.width + Task { @MainActor in scene.desktopPointer(normalizedX: x) } + }) { monitors.append(monitor) } + if let monitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown], handler: { event in + let heavy = event.type == .rightMouseDown + let screenPoint = NSEvent.mouseLocation + let scale = min(screenFrame.width / arenaWidth, screenFrame.height / arenaHeight) + let offsetX = (screenFrame.width - arenaWidth * scale) / 2 + let offsetY = (screenFrame.height - arenaHeight * scale) / 2 + let scenePoint = CGPoint(x: (screenPoint.x - screenFrame.minX - offsetX) / scale, + y: (screenPoint.y - screenFrame.minY - offsetY) / scale) + Task { @MainActor in + scene.desktopClick(at: scenePoint, heavy: heavy) + } + }) { monitors.append(monitor) } + if let monitor = NSEvent.addGlobalMonitorForEvents(matching: .scrollWheel, handler: { event in + if event.scrollingDeltaY > 2 { Task { @MainActor in scene.desktopJump() } } + }) { monitors.append(monitor) } + } + + func applicationWillTerminate(_ notification: Notification) { for monitor in monitors { NSEvent.removeMonitor(monitor) } } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } +} + +if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "auth" { + let ok = NativeMatchmaker.saveToken(CommandLine.arguments[2]) + print(ok ? "Menu Fighter sign-in saved in Keychain." : "Could not save Menu Fighter sign-in.") + exit(ok ? 0 : 1) +} + +if CommandLine.arguments.contains("--watch") { + let watcherApp = NSApplication.shared + watcherApp.setActivationPolicy(.accessory) + CornerWatcher.shared.start() + watcherApp.run() + exit(0) +} + +private let app = NSApplication.shared +private let delegate = AppDelegate(); app.delegate = delegate; app.setActivationPolicy(.regular); app.run() diff --git a/macos/menu-fighter-native/Sources/TrackpadBridge/TrackpadBridge.c b/macos/menu-fighter-native/Sources/TrackpadBridge/TrackpadBridge.c new file mode 100644 index 0000000000..8a22e95528 --- /dev/null +++ b/macos/menu-fighter-native/Sources/TrackpadBridge/TrackpadBridge.c @@ -0,0 +1,63 @@ +#include "TrackpadBridge.h" +#include +#include + +typedef void *MTDeviceRef; +typedef struct { float x, y; } MTPoint; +typedef struct { MTPoint position, velocity; } MTVector; +typedef struct { + int frame; double timestamp; int identifier, state, fingerID, handID; + MTVector normalized; float size; int zero1; float angle, majorAxis, minorAxis; + MTVector absolute; int zero2, zero3; float density; +} MTTouch; +typedef int (*MTCallback)(MTDeviceRef, MTTouch *, int, double, int); + +static void *library; +static CFArrayRef devices; +static MFCornerPoseCallback client; +static void (*registerCallback)(MTDeviceRef, MTCallback); +static void (*unregisterCallback)(MTDeviceRef, MTCallback); +static void (*startDevice)(MTDeviceRef, int); +static void (*stopDevice)(MTDeviceRef); + +static int contacts(MTDeviceRef device, MTTouch *touches, int count, double time, int frame) { + (void)device; (void)time; (void)frame; + bool corner[4] = {false, false, false, false}; + int active = 0; + for (int i = 0; i < count; i++) { + float x = touches[i].normalized.position.x, y = touches[i].normalized.position.y; + if (touches[i].state == 7) continue; + active++; + if (x < .30f && y < .30f) corner[0] = true; + if (x > .70f && y < .30f) corner[1] = true; + if (x < .30f && y > .70f) corner[2] = true; + if (x > .70f && y > .70f) corner[3] = true; + } + if (client) client(active == 4 && corner[0] && corner[1] && corner[2] && corner[3]); + return 0; +} + +bool MFStartCornerPoseWatcher(MFCornerPoseCallback callback) { + library = dlopen("/System/Library/PrivateFrameworks/MultitouchSupport.framework/MultitouchSupport", RTLD_NOW); + if (!library) return false; + CFArrayRef (*createList)(void) = dlsym(library, "MTDeviceCreateList"); + registerCallback = dlsym(library, "MTRegisterContactFrameCallback"); + unregisterCallback = dlsym(library, "MTUnregisterContactFrameCallback"); + startDevice = dlsym(library, "MTDeviceStart"); stopDevice = dlsym(library, "MTDeviceStop"); + if (!createList || !registerCallback || !startDevice) return false; + client = callback; devices = createList(); + for (CFIndex i = 0; i < CFArrayGetCount(devices); i++) { + MTDeviceRef device = (MTDeviceRef)CFArrayGetValueAtIndex(devices, i); + registerCallback(device, contacts); startDevice(device, 0); + } + return CFArrayGetCount(devices) > 0; +} + +void MFStopCornerPoseWatcher(void) { + if (devices) for (CFIndex i = 0; i < CFArrayGetCount(devices); i++) { + MTDeviceRef device = (MTDeviceRef)CFArrayGetValueAtIndex(devices, i); + if (unregisterCallback) unregisterCallback(device, contacts); if (stopDevice) stopDevice(device); + } + if (devices) CFRelease(devices); devices = 0; client = 0; + if (library) dlclose(library); library = 0; +} diff --git a/macos/menu-fighter-native/Sources/TrackpadBridge/include/TrackpadBridge.h b/macos/menu-fighter-native/Sources/TrackpadBridge/include/TrackpadBridge.h new file mode 100644 index 0000000000..4679bf155d --- /dev/null +++ b/macos/menu-fighter-native/Sources/TrackpadBridge/include/TrackpadBridge.h @@ -0,0 +1,6 @@ +#pragma once +#include + +typedef void (*MFCornerPoseCallback)(bool active); +bool MFStartCornerPoseWatcher(MFCornerPoseCallback callback); +void MFStopCornerPoseWatcher(void); diff --git a/macos/menu-fighter-native/install.sh b/macos/menu-fighter-native/install.sh new file mode 100755 index 0000000000..475d0f567b --- /dev/null +++ b/macos/menu-fighter-native/install.sh @@ -0,0 +1,19 @@ +#!/bin/zsh +set -euo pipefail +cd "${0:A:h}" +swift build -c release +mkdir -p "$HOME/.local/bin" "$HOME/.local/share/menu-fighter" "$HOME/Library/LaunchAgents" +cp .build/release/menu-fighter "$HOME/.local/bin/menu-fighter" +cp "../../pop/samples/whats-inside-your-heart/sfx/Jeffrey count in.wav" "$HOME/.local/share/menu-fighter/jeffrey-count-in.wav" +chmod 755 "$HOME/.local/bin/menu-fighter" +plist="$HOME/Library/LaunchAgents/computer.aesthetic.menu-fighter.plist" +launchctl bootout "gui/$UID/computer.aesthetic.menu-fighter" 2>/dev/null || true +/usr/libexec/PlistBuddy -c Clear "$plist" 2>/dev/null || true +/usr/libexec/PlistBuddy -c 'Add :Label string computer.aesthetic.menu-fighter' "$plist" +/usr/libexec/PlistBuddy -c 'Add :ProgramArguments array' "$plist" +/usr/libexec/PlistBuddy -c "Add :ProgramArguments:0 string $HOME/.local/bin/menu-fighter" "$plist" +/usr/libexec/PlistBuddy -c 'Add :ProgramArguments:1 string --watch' "$plist" +/usr/libexec/PlistBuddy -c 'Add :RunAtLoad bool true' "$plist" +/usr/libexec/PlistBuddy -c 'Add :KeepAlive bool true' "$plist" +launchctl bootstrap "gui/$UID" "$plist" +echo "Installed Menu Fighter corner-pose watcher." diff --git a/session-server/fight-manager.mjs b/session-server/fight-manager.mjs index a480f40941..3e72db0041 100644 --- a/session-server/fight-manager.mjs +++ b/session-server/fight-manager.mjs @@ -105,6 +105,7 @@ export class FightManager { region, mode: data.mode === "ranked" ? "ranked" : "casual", platform: String(data.platform || "web").slice(0, 24), + transport: String(data.transport || "webrtc-v1").slice(0, 24), joinedAt: this.queue.get(wsId)?.joinedAt || now, expiresAt: now + QUEUE_TTL, }); @@ -121,7 +122,7 @@ export class FightManager { compatible(a, b, now = this.now()) { if (!compatibleManifest(a.manifest, b.manifest)) return false; - if (a.mode !== b.mode || a.accountId === b.accountId) return false; + if (a.mode !== b.mode || a.transport !== b.transport || a.accountId === b.accountId) return false; const aRegions = allowedOpponentRegions(a.region, now - a.joinedAt); const bRegions = allowedOpponentRegions(b.region, now - b.joinedAt); return aRegions.includes(b.region) && bRegions.includes(a.region); @@ -161,6 +162,7 @@ export class FightManager { manifest: { ...this.manifest }, seed: crypto.randomBytes(4).readUInt32LE(0), mode: a.mode || "casual", + transport: a.transport || "webrtc-v1", regions: [a.region || null, b.region || null], roomId, createdAt: now, @@ -183,6 +185,7 @@ export class FightManager { manifest: match.manifest, seed: match.seed, mode: match.mode, + transport: match.transport, region: match.regions[player.seat], candidateRelayRegions: Object.keys(FIGHT_REGIONS), probeNonce: match.probeNonce, @@ -226,6 +229,23 @@ export class FightManager { return true; } + input(wsId, raw) { + const data = cleanPayload(raw); + const match = this.memberMatch(wsId, data.matchId); + if (!match || match.transport !== "ws-input-v1" || !match.accepted.has(wsId)) { + return this.error(wsId, "bad-input", "Invalid native match input."); + } + const frame = Number(data.frame); + const buttons = Number(data.buttons); + if (!Number.isSafeInteger(frame) || frame < 0 || !Number.isInteger(buttons) || buttons < 0 || buttons > 31) { + return this.error(wsId, "bad-input", "Invalid native input payload."); + } + const sender = match.players.find((player) => player.wsId === wsId); + const target = match.players.find((player) => player.wsId !== wsId); + this.send(target.wsId, "fight:input", { matchId: match.id, fromSeat: sender.seat, frame, buttons }); + return true; + } + routeReport(wsId, raw) { const data = cleanPayload(raw); const match = this.memberMatch(wsId, data.matchId); @@ -380,6 +400,7 @@ export class FightManager { if (type === "fight:queue:leave") return this.queueLeave(wsId); if (type === "fight:match:accept") return this.proposalAccept(wsId, raw); if (type === "fight:signal") return this.signal(wsId, raw); + if (type === "fight:input") return this.input(wsId, raw); if (type === "fight:route:report") return this.routeReport(wsId, raw); if (type === "fight:room:create") return this.roomCreate(wsId, raw); if (type === "fight:room:join") return this.roomJoin(wsId, raw); diff --git a/spec/fight-manager-spec.mjs b/spec/fight-manager-spec.mjs index ab2f0db2c9..44db716b03 100644 --- a/spec/fight-manager-spec.mjs +++ b/spec/fight-manager-spec.mjs @@ -25,6 +25,27 @@ manager.signal("wa", { matchId, signal: { candidate: "private" } }); assert.deepEqual(sent.map((m) => m.wsId), ["wc"]); assert.equal(sent[0].type, "fight:signal"); +// Native clients pair only with their relay transport, and live input remains +// scoped to the accepted opponent. +const native = new FightManager({ now: () => now, id: () => `native-${++serial}` }); +const nativeSent = []; +native.setSendFunction((wsId, type, content) => nativeSent.push({ wsId, type, content })); +for (const [wsId, accountId] of [["n1", "n1"], ["web", "web"], ["n2", "n2"]]) + native.authenticate(wsId, { accountId, handle: `@${accountId}` }); +native.queueJoin("n1", { manifest: FIGHT_MANIFEST, region: "us-west", transport: "ws-input-v1" }); +native.queueJoin("web", { manifest: FIGHT_MANIFEST, region: "us-west" }); +assert.equal(nativeSent.filter((m) => m.type === "fight:match:proposal").length, 0); +native.queueJoin("n2", { manifest: FIGHT_MANIFEST, region: "us-west", transport: "ws-input-v1" }); +const nativeProposals = nativeSent.filter((m) => m.type === "fight:match:proposal"); +const nativeMatchId = nativeProposals[0].content.matchId; +native.proposalAccept("n1", { matchId: nativeMatchId, manifest: FIGHT_MANIFEST }); +native.proposalAccept("n2", { matchId: nativeMatchId, manifest: FIGHT_MANIFEST }); +nativeSent.length = 0; +assert.equal(native.input("n1", { matchId: nativeMatchId, frame: 7, buttons: 9 }), true); +assert.deepEqual(nativeSent, [{ wsId: "n2", type: "fight:input", content: { + matchId: nativeMatchId, fromSeat: 0, frame: 7, buttons: 9, +} }]); + // Payload handles cannot impersonate a handled user; the server-bound socket wins. manager.queueLeave("wb"); assert.equal(manager.queueJoin("guest", { handle: "@alice", manifest: FIGHT_MANIFEST, region: "us-west" }), false); -- 2.51.2