From 6122d7e83b0036040d7b00cbb380b82f52a09cb0 Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Tue, 04 Aug 2026 19:10:06 +0000 Subject: [PATCH] Add native menu and trackpad fighters --- macos/menu-fighter-native/.gitignore | 1 + macos/menu-fighter-native/Package.swift | 12 ++++++++++++ macos/menu-fighter-native/README.md | 42 ++++++++++++++++++++++++++++++++++++++++++ macos/menu-fighter-native/Sources/MenuFighterNative/FightNetwork.swift | 143 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ macos/menu-fighter-native/Sources/MenuFighterNative/main.swift | 552 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ macos/menu-fighter-native/Sources/TrackpadBridge/TrackpadBridge.c | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ macos/menu-fighter-native/Sources/TrackpadBridge/include/TrackpadBridge.h | 6 ++++++ macos/menu-fighter-native/install.sh | 11 +++++++++++ macos/trackpad-fighter-native/.gitignore | 1 + macos/trackpad-fighter-native/Package.swift | 12 ++++++++++++ macos/trackpad-fighter-native/README.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ macos/trackpad-fighter-native/Sources/MenuFighterNative/FightNetwork.swift | 143 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ macos/trackpad-fighter-native/Sources/MenuFighterNative/main.swift | 1346 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ macos/trackpad-fighter-native/Sources/TrackpadBridge/TrackpadBridge.c | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ macos/trackpad-fighter-native/Sources/TrackpadBridge/include/TrackpadBridge.h | 8 ++++++++ macos/trackpad-fighter-native/install.sh | 23 +++++++++++++++++++++++ pop/out/pop-library.json | 46 +++++++++++++++++++++++----------------------- 17 file(s) changed, 2570 insertion(s)(+), 23 deletion(s)(-) diff --git a/macos/menu-fighter-native/.gitignore b/macos/menu-fighter-native/.gitignore new file mode 100644 --- /dev/null +++ b/macos/menu-fighter-native/.gitignore @@ -0,0 +1,1 @@ +.build/ diff --git a/macos/menu-fighter-native/Package.swift b/macos/menu-fighter-native/Package.swift new file mode 100644 --- /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 --- /dev/null +++ b/macos/menu-fighter-native/README.md @@ -0,0 +1,42 @@ +# 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 two tiny stick figures across a screen-wide stage over the macOS desktop in +a transparent, chromeless, click-through overlay. Close-range punches 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 Menu Fighter alongside Trackpad Fighter: + +```sh +./install.sh +``` + +Trackpad Fighter owns the fleet-only four-corner gesture. Launch this distinct +hand-to-hand game with `menu-fighter --desktop`; Player 2 begins as the local +dummy while the native matchmaker searches for another player. + +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 punch, two-finger + click for a heavy punch, 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 --- /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 --- /dev/null +++ b/macos/menu-fighter-native/Sources/MenuFighterNative/main.swift @@ -0,0 +1,552 @@ +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 } +} + +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 skeleton = SKShapeNode() + private let skeletonInk = SKShapeNode() + private let head = SKShapeNode(circleOfRadius: 11) + private let headInk = SKShapeNode(circleOfRadius: 11) + private let fist = SKShapeNode(circleOfRadius: 4) + private let shadow = SKShapeNode(ellipseOf: CGSize(width: 42, height: 7)) + 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 color = inverted ? NSColor.white : pink + let path = CGMutablePath() + path.move(to: CGPoint(x: 0, y: 23)); path.addLine(to: CGPoint(x: 0, y: -14)) + path.move(to: CGPoint(x: 0, y: 12)); path.addLine(to: CGPoint(x: -18, y: -1)) + path.move(to: CGPoint(x: 0, y: 12)); path.addLine(to: CGPoint(x: 18, y: -1)) + path.move(to: CGPoint(x: 0, y: -14)); path.addLine(to: CGPoint(x: -14, y: -39)) + path.move(to: CGPoint(x: 0, y: -14)); path.addLine(to: CGPoint(x: 14, y: -39)) + shadow.fillColor = ink; shadow.strokeColor = ink; shadow.alpha = 0.32 + shadow.position.y = -42; root.addChild(shadow) + skeletonInk.path = path; skeletonInk.strokeColor = ink; skeletonInk.lineWidth = 9 + skeletonInk.lineCap = .round; skeletonInk.lineJoin = .round; root.addChild(skeletonInk) + skeleton.path = path; skeleton.strokeColor = color; skeleton.lineWidth = 4 + skeleton.lineCap = .round; skeleton.lineJoin = .round; root.addChild(skeleton) + headInk.fillColor = .clear; headInk.strokeColor = ink; headInk.lineWidth = 8 + headInk.position.y = 35; root.addChild(headInk) + head.fillColor = .clear; head.strokeColor = color; head.lineWidth = 3 + head.position.y = 35; root.addChild(head) + fist.fillColor = color; fist.strokeColor = ink; fist.lineWidth = 3 + fist.position = CGPoint(x: 20 * facing, y: 0); root.addChild(fist) + } + + var hitbox: CGRect { CGRect(x: root.position.x - 18, y: root.position.y - 42, width: 36, height: 84) } + var attackbox: CGRect { + let reach: CGFloat = attackFrames > 0 ? (attackFrames > 7 ? 62 : 43) : 0 + return CGRect(x: facing > 0 ? root.position.x + 8 : root.position.x - 8 - reach, + y: root.position.y - 22, width: reach, height: 48) + } + + 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 ? (attackFrames > 7 ? 39 : 27) : 0 + fist.position.x = facing * (20 + 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 pointerX: CGFloat? + private var pointerLight = false, pointerHeavy = false, pointerJump = false + private let desktopStage: ClosedRange = 44...1236 + private let desktopFloor: CGFloat = 78 + + 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.72; 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: 5), cornerRadius: 2.5) + platform.fillColor = pink; platform.strokeColor = ink; platform.lineWidth = 7 + platform.position = CGPoint(x: arenaWidth / 2, y: desktopFloor - 43) + 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 PUNCHES • 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 ? 410 : 390, y: y) + p2.root.position = CGPoint(x: desktopMode ? 870 : 890, y: y) + p1.velocity = .zero; p2.velocity = .zero; roundOver = false; updateBars() + } + + 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) { strike(from: p1, defender: p2, damage: damage) } + let second = online ? remoteButtons : now[1] + if let damage = p2.tick(buttons: edges(second, previous[1]), opponentX: p1.root.position.x) { strike(from: p2, defender: p1, damage: damage) } + previous = [now[0], second] + separate() + 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 strike(from fighter: Fighter, defender: Fighter, damage: Int) { + let heavy = damage > 8 + tone.play(frequency: heavy ? 115 : 230, duration: heavy ? 0.08 : 0.035, + volume: heavy ? 0.22 : 0.12) + guard fighter.attackbox.intersects(defender.hitbox), !fighter.hitThisAttack else { return } + fighter.hitThisAttack = true + defender.health = max(0, defender.health - CGFloat(damage)) + defender.velocity.dx = fighter.facing * (heavy ? 17 : 9) + defender.velocity.dy = heavy ? 10 : 5 + defender.grounded = false; defender.flashFrames = 8 + tone.play(frequency: heavy ? 72 : 105, duration: 0.09, volume: 0.25) + if defender.health == 0 { + roundOver = true + status.text = "PLAYER \(fighter.number) WINS • RETURN TO REMATCH" + tone.play(frequency: 440, duration: 0.35, volume: 0.22) + } + } + + 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 --- /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 --- /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 100644 --- /dev/null +++ b/macos/menu-fighter-native/install.sh @@ -0,0 +1,11 @@ +#!/bin/zsh +set -euo pipefail +cd "${0:A:h}" +swift build -c release +mkdir -p "$HOME/.local/bin" "$HOME/.local/share/menu-fighter" +rm -f "$HOME/.local/bin/menu-fighter" +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" +echo "Installed Menu Fighter. Trackpad Fighter keeps ownership of the four-corner pose." +echo "Launch with: menu-fighter --desktop" diff --git a/macos/trackpad-fighter-native/.gitignore b/macos/trackpad-fighter-native/.gitignore new file mode 100644 --- /dev/null +++ b/macos/trackpad-fighter-native/.gitignore @@ -0,0 +1,1 @@ +.build/ diff --git a/macos/trackpad-fighter-native/Package.swift b/macos/trackpad-fighter-native/Package.swift new file mode 100644 --- /dev/null +++ b/macos/trackpad-fighter-native/Package.swift @@ -0,0 +1,12 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "TrackpadFighter", + platforms: [.macOS(.v14)], + products: [.executable(name: "trackpad-fighter", targets: ["MenuFighterNative"])], + targets: [ + .target(name: "TrackpadBridge", publicHeadersPath: "include"), + .executableTarget(name: "MenuFighterNative", dependencies: ["TrackpadBridge"], linkerSettings: [.linkedFramework("Security")]) + ] +) diff --git a/macos/trackpad-fighter-native/README.md b/macos/trackpad-fighter-native/README.md new file mode 100644 --- /dev/null +++ b/macos/trackpad-fighter-native/README.md @@ -0,0 +1,78 @@ +# Trackpad Fighter — native macOS + +A browserless Swift/AppKit starfighter game built around the Mac trackpad. 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 trackpad-fighter +``` + +Put two tiny, high-contrast triangle starfighters over the macOS desktop in a +transparent, chromeless overlay. Ships rotate, thrust, fire, and wrap around the +screen while sparse drifting asteroids threaten both players and break under fire. +The overlay closes itself after the round ends. + +```sh +swift run trackpad-fighter --desktop +``` + +Install the fleet-only four-corner trackpad watcher: + +```sh +./install.sh +``` + +Touch all four trackpad corners at once. A strong accent-color flash immediately +covers every display and launches the encounter; lifting the fingers cannot cancel +startup. Sparse star streaks then warp from the desktop center toward all four +corners and brake to a stop as a short ascending beep phrase plays. There is no +separate intro screen. Once the stars stop, the live scene unlocks directly into +play with no login step. + +Controls: + +- Trackpad (Player 1): touch with one finger to highlight the ship. Every finger + movement immediately points and thrusts the ship in that direction; lifting + stops adding force while momentum continues. A short stationary tap fires. + Two-finger acceleration is intentionally disabled for now. Player 1 begins at + screen center, and the ship palette follows the current macOS system accent color. +- All game marks use the same cursor-like line stack: black outer edge, light + keyline, and system-accent center line. There is no separate reticle, tether, + stored charge, or release animation. +- Active thrust sheds short line particles from the rear of the triangle. They + inherit ship momentum and enter SpriteKit's shared gravity field with damping + and a short lifetime. +- Player 1: `A/D` rotate, `W` thrust, `F` light shot, `G` heavy shot. +- Player 2: left/right arrows rotate, up arrow thrust, `/` light shot, `.` heavy shot. +- Controllers: d-pad/stick, A jump, X light, Y heavy. +- The desktop round captures mouse, trackpad, and keyboard input across every + display so clicks, drags, scrolling, and hover state never reach apps or Prox + rocks underneath it. The standard cursor stays hidden until the round ends; + `Escape` quits immediately. In windowed mode, `Escape` opens the local pause + card and `Return` starts/resets the fight. + +## Round lifetime logs + +Every watcher launch and fight round appends structured JSON lines to: + +```text +~/.local/share/trackpad-fighter/rounds.jsonl +``` + +Events share process, launch, scene, and round IDs and include monotonic elapsed +times. The trace starts before the scene is built, records duplicate scene-attach +attempts, intro/reset/combat events, attacks and hits, and finishes with the round +outcome and process teardown. A crash or forced kill deliberately leaves a +`round_started` entry without `round_finished`, so incomplete lifetimes remain +discoverable. + +```sh +# Follow new events. +tail -f ~/.local/share/trackpad-fighter/rounds.jsonl | jq . + +# Reconstruct one round in order. +jq --arg id '' 'select(.roundId == $id)' \ + ~/.local/share/trackpad-fighter/rounds.jsonl +``` diff --git a/macos/trackpad-fighter-native/Sources/MenuFighterNative/FightNetwork.swift b/macos/trackpad-fighter-native/Sources/MenuFighterNative/FightNetwork.swift new file mode 100644 --- /dev/null +++ b/macos/trackpad-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: trackpad-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/trackpad-fighter-native/Sources/MenuFighterNative/main.swift b/macos/trackpad-fighter-native/Sources/MenuFighterNative/main.swift new file mode 100644 --- /dev/null +++ b/macos/trackpad-fighter-native/Sources/MenuFighterNative/main.swift @@ -0,0 +1,1346 @@ +import AppKit +import AVFoundation +import GameController +import SpriteKit +import TrackpadBridge +import Darwin + +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 cornerHoldDuration: TimeInterval = 1 +private let ink = NSColor(calibratedRed: 0.035, green: 0.025, blue: 0.055, alpha: 1) +// Keep the encounter personal to the signed-in macOS account. AppKit resolves +// this dynamic color from System Settings > Appearance > Accent color. +private let accent = NSColor.controlAccentColor +private let contrastDark = NSColor.black +private let contrastLight = NSColor.white +private let lineDark: CGFloat = 4 +private let lineLight: CGFloat = 2.5 +private let lineSpot: CGFloat = 1 +private let playWindowLevel = NSWindow.Level(rawValue: NSWindow.Level.screenSaver.rawValue + 1) + +// One append-only trace for every Trackpad Fighter process and round. A round +// that crashes or is killed simply has no terminal event, which is intentional: +// the unfinished lifetime remains visible during backtracing. +private final class RoundLifetimeLog: @unchecked Sendable { + static let shared = RoundLifetimeLog() + + private let lock = NSLock() + private let processID = ProcessInfo.processInfo.processIdentifier + private let processSessionID = UUID().uuidString.lowercased() + private let processStarted = ProcessInfo.processInfo.systemUptime + private let fileURL: URL + private var activeRoundID: String? + private var roundStarted: TimeInterval? + private var roundSequence = 0 + private var processFinished = false + + private init() { + let root = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local/share/trackpad-fighter", isDirectory: true) + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + fileURL = root.appendingPathComponent("rounds.jsonl") + emit("process_started", roundID: nil, fields: [ + "executable": URL(fileURLWithPath: CommandLine.arguments.first ?? "trackpad-fighter").lastPathComponent, + ]) + } + + var path: String { fileURL.path } + + func event(_ name: String, roundID: String? = nil, fields: [String: Any] = [:]) { + lock.lock(); defer { lock.unlock() } + emitLocked(name, roundID: roundID ?? activeRoundID, fields: fields) + } + + @discardableResult + func beginRound(trigger: String, mode: String, launchID: String?) -> String { + lock.lock(); defer { lock.unlock() } + if let current = activeRoundID { + emitLocked("round_abandoned", roundID: current, fields: [ + "reason": "superseded", "nextTrigger": trigger, + "roundElapsedMs": elapsedMs(since: roundStarted), + ]) + } + roundSequence += 1 + let id = "\(processSessionID)-r\(roundSequence)" + activeRoundID = id + roundStarted = ProcessInfo.processInfo.systemUptime + var fields: [String: Any] = ["trigger": trigger, "mode": mode, "sequence": roundSequence] + if let launchID { fields["launchId"] = launchID } + emitLocked("round_started", roundID: id, fields: fields) + return id + } + + func finishRound(_ outcome: String, roundID: String, fields: [String: Any] = [:]) { + lock.lock(); defer { lock.unlock() } + var details = fields + details["outcome"] = outcome + details["roundElapsedMs"] = elapsedMs(since: self.roundStarted) + guard activeRoundID == roundID else { + details["activeRoundId"] = activeRoundID ?? NSNull() + emitLocked("round_finish_mismatch", roundID: roundID, fields: details) + return + } + emitLocked("round_finished", roundID: roundID, fields: details) + activeRoundID = nil + roundStarted = nil + } + + func finishProcess(reason: String) { + lock.lock(); defer { lock.unlock() } + guard !processFinished else { return } + if let roundID = activeRoundID { + emitLocked("round_abandoned", roundID: roundID, fields: [ + "reason": reason, "roundElapsedMs": elapsedMs(since: roundStarted), + ]) + activeRoundID = nil + roundStarted = nil + } + emitLocked("process_finished", roundID: nil, fields: ["reason": reason]) + processFinished = true + } + + private func emit(_ name: String, roundID: String?, fields: [String: Any]) { + lock.lock(); defer { lock.unlock() } + emitLocked(name, roundID: roundID, fields: fields) + } + + private func emitLocked(_ name: String, roundID: String?, fields: [String: Any]) { + let uptime = ProcessInfo.processInfo.systemUptime + var record: [String: Any] = [ + "at": ISO8601DateFormatter().string(from: Date()), + "elapsedMs": Int((uptime - processStarted) * 1000), + "event": name, + "pid": processID, + "processSessionId": processSessionID, + "fields": fields, + ] + if let roundID { record["roundId"] = roundID } + guard var data = try? JSONSerialization.data(withJSONObject: record, options: [.sortedKeys]) else { return } + data.append(0x0a) + let fd = Darwin.open(fileURL.path, O_WRONLY | O_CREAT | O_APPEND, S_IRUSR | S_IWUSR) + guard fd >= 0 else { return } + data.withUnsafeBytes { bytes in + if let base = bytes.baseAddress { _ = Darwin.write(fd, base, bytes.count) } + } + _ = Darwin.close(fd) + fputs("[trackpad-fighter][lifetime] \(name) process=\(processSessionID) round=\(roundID ?? "-")\n", stderr) + } + + private func elapsedMs(since start: TimeInterval?) -> Int { + guard let start else { return 0 } + return Int((ProcessInfo.processInfo.systemUptime - start) * 1000) + } +} + +private func pointerScreen() -> NSScreen? { + let pointer = NSEvent.mouseLocation + return NSScreen.screens.first(where: { $0.frame.contains(pointer) }) ?? NSScreen.main +} + +private func trackpadInputChanged(_ x: Float, _ y: Float, _ contacts: Int32) { + Task { @MainActor in TrackpadFlightInput.shared.update(x: CGFloat(x), y: CGFloat(y), contacts: Int(contacts)) } +} + +@MainActor private final class TrackpadFlightInput { + static let shared = TrackpadFlightInput() + private var contacts = 0 + private var lastX: CGFloat? + private var lastY: CGFloat? + private var touchStarted: TimeInterval? + private var touchTravel: CGFloat = 0 + private var direction: CGFloat? + private var boost: CGFloat = 0 + private var firePending = false + + func update(x: CGFloat, y: CGFloat, contacts nextContacts: Int) { + let now = ProcessInfo.processInfo.systemUptime + if nextContacts == 1 { + if contacts != 1 { + touchStarted = now; touchTravel = 0 + lastX = x; lastY = y + } else if let lastX, let lastY { + let dx = x - lastX, dy = y - lastY + let distance = hypot(dx, dy) + touchTravel += distance + if distance > 0.001 { + direction = atan2(dy, dx) + boost += distance * 3.5 + } + self.lastX = x; self.lastY = y + } + } else { + if contacts == 1, nextContacts == 0, let touchStarted, + now - touchStarted <= 0.24, touchTravel < 0.045 { + firePending = true + } + lastX = nil; lastY = nil; touchStarted = nil; touchTravel = 0 + } + contacts = nextContacts + } + + func consume() -> (direction: CGFloat?, boost: CGFloat, grabbed: Bool, fire: Bool) { + let snapshot = (direction, min(1, boost), contacts == 1, firePending) + direction = nil; boost = 0; firePending = false + return snapshot + } +} + +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 = SKNode() + let owner: Int + let damage: CGFloat + let direction: CGVector + + init(owner: Int, heavy: Bool, direction: CGVector, position: CGPoint) { + self.owner = owner; self.damage = heavy ? 15 : 8; self.direction = direction + let radius: CGFloat = heavy ? 9 : 6 + let orbitDark = SKShapeNode(circleOfRadius: radius) + orbitDark.fillColor = .clear; orbitDark.strokeColor = contrastDark; orbitDark.lineWidth = lineDark + let orbitLight = SKShapeNode(circleOfRadius: radius) + orbitLight.fillColor = .clear; orbitLight.strokeColor = contrastLight; orbitLight.lineWidth = lineLight + let orbit = SKShapeNode(circleOfRadius: radius) + orbit.fillColor = .clear; orbit.strokeColor = accent; orbit.lineWidth = lineSpot + node.addChild(orbitDark); node.addChild(orbitLight); node.addChild(orbit) + node.position = position + } + + var hitbox: CGRect { node.frame.insetBy(dx: -3, dy: -3) } +} + +@MainActor private final class Asteroid { + let node = SKShapeNode() + var velocity: CGVector + var health: Int + let radius: CGFloat + + init(index: Int, position: CGPoint) { + radius = CGFloat(9 + (index * 7) % 11) + health = radius > 14 ? 2 : 1 + velocity = CGVector(dx: CGFloat((index * 17) % 7 - 3) * 0.34, + dy: CGFloat((index * 23) % 9 - 4) * 0.26) + let path = CGMutablePath() + let points = 8 + for point in 0.. 0 ? 0 : .pi + let outline = accent + let hullPath = CGMutablePath() + hullPath.move(to: CGPoint(x: 36, y: 0)) + hullPath.addLine(to: CGPoint(x: -25, y: 20)) + hullPath.addLine(to: CGPoint(x: -25, y: -20)) + hullPath.closeSubpath() + hullDark.path = hullPath; hullDark.fillColor = .clear; hullDark.strokeColor = contrastDark; hullDark.lineWidth = lineDark + hullLight.path = hullPath; hullLight.fillColor = .clear; hullLight.strokeColor = contrastLight; hullLight.lineWidth = lineLight + hull.path = hullPath; hull.fillColor = outline; hull.strokeColor = outline; hull.lineWidth = lineSpot + for node in [hullDark, hullLight, hull] { + node.lineJoin = .round + node.lineCap = .butt + node.miterLimit = 1 + } + ship.addChild(hullDark); ship.addChild(hullLight); ship.addChild(hull) + root.addChild(ship) + updatePose() + } + + var hitbox: CGRect { + let width = 82 * visualScale, height = 58 * visualScale + return CGRect(x: root.position.x - width / 2, y: root.position.y - height / 2, + width: width, height: height) + } + 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 - 24, width: reach, height: 48) + } + + func beginAttack(heavy: Bool) -> Bool { + guard cooldown == 0, attackFrames == 0 else { return false } + attackDuration = heavy ? 16 : 10 + attackFrames = attackDuration; cooldown = heavy ? 29 : 18; hitThisAttack = false + return true + } + + func setVisualScale(_ scale: CGFloat) { + visualScale = scale + root.setScale(scale) + let compensation = 1 / max(0.001, scale) + hullDark.lineWidth = lineDark * compensation + hullLight.lineWidth = lineLight * compensation + hull.lineWidth = lineSpot * compensation + } + + var emitsThrust: Bool { flightMode && !grounded } + var exhaustPosition: CGPoint { + CGPoint(x: root.position.x - cos(heading) * 12, + y: root.position.y - sin(heading) * 12) + } + + func applyGesture(direction: CGFloat?, boost: CGFloat, grabbed: Bool) { + guard flightMode else { return } + gestureGrabbed = grabbed + if let direction { heading = direction } + if boost > 0 { + velocity.dx += cos(heading) * boost * 2.8 + velocity.dy += sin(heading) * boost * 2.8 + gestureBoostFrames = 9 + } + } + + func tick(buttons: Buttons, opponentX: CGFloat) -> Int? { + var fired: Int? + if buttons.light, beginAttack(heavy: false) { fired = 8 } + if buttons.heavy, beginAttack(heavy: true) { fired = 15 } + if flightMode { + if buttons.left { heading += 0.055 } + if buttons.right { heading -= 0.055 } + let engineOn = buttons.jump + if engineOn { + velocity.dx += cos(heading) * 0.34 + velocity.dy += sin(heading) * 0.34 + } + let speed = hypot(velocity.dx, velocity.dy) + if speed > 4.2 { + velocity.dx *= 4.2 / speed + velocity.dy *= 4.2 / speed + } + velocity.dx *= 0.985; velocity.dy *= 0.985 + root.position.x += velocity.dx; root.position.y += velocity.dy + if root.position.x < -45 { root.position.x = arenaWidth + 45 } + if root.position.x > arenaWidth + 45 { root.position.x = -45 } + if root.position.y < -45 { root.position.y = arenaHeight + 45 } + if root.position.y > arenaHeight + 45 { root.position.y = -45 } + facing = cos(heading) >= 0 ? 1 : -1 + grounded = !(engineOn || gestureBoostFrames > 0) + } else { + facing = opponentX >= root.position.x ? 1 : -1 + let desired: CGFloat = buttons.left ? -6 : buttons.right ? 6 : 0 + velocity.dx += (desired - velocity.dx) * (grounded ? 0.20 : 0.10) + if buttons.jump && grounded { velocity.dy = 17; grounded = false } + velocity.dy -= grounded ? 0 : 0.92 + root.position.x = min(arenaWidth - 70, max(70, root.position.x + velocity.dx)) + root.position.y += velocity.dy + if root.position.y <= floorY, velocity.dy <= 0 { + root.position.y = floorY; velocity.dy = 0; grounded = true + } + } + if attackFrames > 0 { attackFrames -= 1 } + if cooldown > 0 { cooldown -= 1 } + if flashFrames > 0 { flashFrames -= 1 } + updatePose() + if gestureBoostFrames > 0 { gestureBoostFrames -= 1 } + let rootScale = visualScale * (flashFrames % 2 == 1 ? 1.025 : 1) + if rootScale != lastRootScale { root.setScale(rootScale); lastRootScale = rootScale } + let rootAlpha: CGFloat = flashFrames % 2 == 1 ? 0.62 : 1 + if rootAlpha != lastRootAlpha { root.alpha = rootAlpha; lastRootAlpha = rootAlpha } + return fired + } + + private func updatePose() { + let lean = max(-0.18, min(0.18, velocity.dy * 0.012)) + if flightMode { ship.xScale = 1; ship.zRotation = heading } + else { ship.xScale = facing; ship.zRotation = lean * facing } + ship.position = .zero + ship.setScale(gestureGrabbed ? 1.08 : 1) + } +} + +private final class FightScene: SKScene { + private let lifetime = RoundLifetimeLog.shared + private let desktopMode: Bool + private let introMode: Bool + private let previewSling: Bool + private let introCancelPath: String? + private let sceneID: String + private let launchID: String? + private var roundID: String + private var sceneBuilt = 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-Regular") + private let status = SKLabelNode(fontNamed: "AvenirNext-Medium") + private let menuCard = SKShapeNode(rectOf: CGSize(width: 450, height: 290), cornerRadius: 34) + private let train = 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 var cachedControllers: [GCController] = [] + private var controllerRefreshFrames = 0 + private var particleFrame = 0 + private var particleSequence = 0 + private let desktopFloor: CGFloat = arenaHeight / 2 - 10 + private var asteroids: [Asteroid] = [] + private var asteroidImpactCooldown = [1: 0, 2: 0] + private var introProgress: CGFloat = 1 + private var introLastTime: TimeInterval = 0 + private var introFinished = true + private var introCancelling = false + private var introNextBeep = 0 + private let introFlash = SKShapeNode(rectOf: CGSize(width: arenaWidth, height: arenaHeight)) + private var introWarpStars: [(node: SKShapeNode, vector: CGVector, phase: CGFloat)] = [] + + init(size: CGSize, desktopMode: Bool = false, introMode: Bool = false, previewSling: Bool = false, + introCancelPath: String? = nil, + roundID: String, sceneID: String, launchID: String? = nil) { + self.desktopMode = desktopMode; self.introMode = introMode; self.previewSling = previewSling + self.introCancelPath = introCancelPath + self.roundID = roundID; self.sceneID = sceneID; self.launchID = launchID + super.init(size: size) + lifetime.event("scene_initialized", roundID: roundID, fields: [ + "sceneId": sceneID, "desktop": desktopMode, "intro": introMode, + "width": Int(size.width), "height": Int(size.height), + ]) + } + + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func didMove(to view: SKView) { + lifetime.event("scene_attach_requested", roundID: roundID, fields: [ + "sceneId": sceneID, "alreadyBuilt": sceneBuilt, "childCount": children.count, + "viewWidth": Int(view.bounds.width), "viewHeight": Int(view.bounds.height), + ]) + guard !sceneBuilt else { + lifetime.event("scene_duplicate_attach_ignored", roundID: roundID, fields: [ + "sceneId": sceneID, "childCount": children.count, + ]) + return + } + sceneBuilt = true + backgroundColor = desktopMode ? .clear : ink + if !desktopMode { addBackdrop() } + if desktopMode { + physicsWorld.gravity = CGVector(dx: 0, dy: -55) + for fighter in [p1, p2] { + fighter.floorY = desktopFloor; fighter.flightMode = true + fighter.setVisualScale(0.24) + } + addCornerVignette() + addAsteroids() + } + p1.root.position = CGPoint(x: 390, y: 122); p2.root.position = CGPoint(x: 890, y: 122) + addChild(p1.root); addChild(p2.root) + title.text = "TRACKPAD / STARFIGHTER"; title.fontSize = 34; title.fontColor = accent + title.position = CGPoint(x: arenaWidth / 2, y: arenaHeight - 70); if !desktopMode { addChild(title) } + status.fontSize = desktopMode ? 11 : 18; status.fontColor = accent.withAlphaComponent(desktopMode ? 0.72 : 1) + status.position = CGPoint(x: arenaWidth / 2, y: desktopMode ? desktopFloor + 82 : 48); status.zPosition = 31; addChild(status) + menuCard.fillColor = ink.withAlphaComponent(0.94); menuCard.strokeColor = accent; menuCard.lineWidth = 2 + menuCard.position = CGPoint(x: arenaWidth / 2, y: arenaHeight / 2); menuCard.zPosition = 20; if !desktopMode { addChild(menuCard) } + train.text = "TRAIN"; train.fontSize = 42; train.fontColor = accent; 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 = accent; trainSub.position.y = -4; menuCard.addChild(trainSub) + resetRound(trigger: "initial_scene", beginNewLifetime: false); showMenu(false) + if introMode { beginIntro() } + else { + lifetime.event("combat_started", roundID: roundID, fields: ["sceneId": sceneID]) + tone.play(frequency: 784, endFrequency: 1046, duration: 0.14, volume: 0.14) + } + lifetime.event("scene_attached", roundID: roundID, fields: [ + "sceneId": sceneID, "childCount": children.count, + "fighter1Children": p1.root.children.count, "fighter2Children": p2.root.children.count, + ]) + } + + override func willMove(from view: SKView) { + lifetime.event("scene_detaching", roundID: roundID, fields: [ + "sceneId": sceneID, "childCount": children.count, + "viewWidth": Int(view.bounds.width), "viewHeight": Int(view.bounds.height), + ]) + } + + private func addBackdrop() { + let gridPath = CGMutablePath() + gridPath.move(to: CGPoint(x: 0, y: 122)); gridPath.addLine(to: CGPoint(x: arenaWidth, y: 122)) + for x in stride(from: CGFloat(0), through: arenaWidth, by: 96) { + gridPath.move(to: CGPoint(x: arenaWidth / 2, y: 122)); gridPath.addLine(to: CGPoint(x: x, y: 0)) + } + for y in stride(from: CGFloat(22), through: 100, by: 22) { + gridPath.move(to: CGPoint(x: 0, y: y)); gridPath.addLine(to: CGPoint(x: arenaWidth, y: y)) + } + let grid = SKShapeNode(path: gridPath) + grid.fillColor = .clear; grid.strokeColor = accent.withAlphaComponent(0.24); grid.lineWidth = 1 + addChild(grid) + for radius in stride(from: CGFloat(90), through: 360, by: 90) { + let orbit = SKShapeNode(ellipseOf: CGSize(width: radius * 2, height: radius * 0.72)) + orbit.fillColor = .clear; orbit.strokeColor = accent.withAlphaComponent(0.10); orbit.lineWidth = 1 + orbit.position = CGPoint(x: arenaWidth / 2, y: 410); addChild(orbit) + } + } + + private func addAsteroids() { + let positions = [CGPoint(x: 160, y: 160), CGPoint(x: 310, y: 570), CGPoint(x: 610, y: 125), + CGPoint(x: 690, y: 610), CGPoint(x: 980, y: 520), CGPoint(x: 1135, y: 190)] + for (index, position) in positions.enumerated() { + let asteroid = Asteroid(index: index, position: position) + asteroid.node.zPosition = -2; asteroids.append(asteroid); addChild(asteroid.node) + } + } + + private func addCornerVignette() { + let width = 512, height = 288 + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, + bytesPerRow: width * 4, space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue), + let gradient = CGGradient(colorsSpace: colorSpace, + colors: [contrastDark.withAlphaComponent(0.42).cgColor, + contrastDark.withAlphaComponent(0).cgColor] as CFArray, + locations: [0, 1]) else { return } + for corner in [CGPoint(x: 0, y: 0), CGPoint(x: width, y: 0), + CGPoint(x: 0, y: height), CGPoint(x: width, y: height)] { + context.drawRadialGradient(gradient, startCenter: corner, startRadius: 0, + endCenter: corner, endRadius: 280, options: []) + } + guard let image = context.makeImage() else { return } + let texture = SKTexture(cgImage: image); texture.filteringMode = .linear + let shade = SKSpriteNode(texture: texture, size: CGSize(width: arenaWidth, height: arenaHeight)) + shade.position = CGPoint(x: arenaWidth / 2, y: arenaHeight / 2); shade.zPosition = -7 + addChild(shade) + } + + private func showMenu(_ show: Bool) { + menuOpen = show; menuCard.isHidden = !show + status.text = show && !desktopMode ? "PAUSED" : "" + lifetime.event("menu_changed", roundID: roundID, fields: ["sceneId": sceneID, "open": show]) + } + + private func resetRound(trigger: String, beginNewLifetime: Bool) { + if beginNewLifetime { + roundID = lifetime.beginRound(trigger: trigger, mode: desktopMode ? "desktop" : "windowed", launchID: launchID) + } + let removedShots = shots.count + p1.health = 100; p2.health = 100 + let y = desktopMode ? desktopFloor : 122 + p1.root.position = CGPoint(x: desktopMode ? arenaWidth / 2 : 390, y: y) + p2.root.position = CGPoint(x: desktopMode ? 1040 : 890, y: y) + p1.velocity = .zero; p2.velocity = .zero; roundOver = false; updateBars() + for shot in shots { shot.node.removeFromParent() }; shots.removeAll() + lifetime.event("round_reset", roundID: roundID, fields: [ + "sceneId": sceneID, "trigger": trigger, "removedShots": removedShots, + "sceneChildren": children.count, + ]) + } + + private func startRematch(trigger: String) { + resetRound(trigger: trigger, beginNewLifetime: true) + showMenu(false) + lifetime.event("combat_started", roundID: roundID, fields: ["sceneId": sceneID, "trigger": trigger]) + } + + override func update(_ currentTime: TimeInterval) { + if introMode, !introFinished { + updateIntro(currentTime) + lastTime = currentTime + return + } + if lastTime == 0 { lastTime = currentTime; return } + accumulator += min(0.1, currentTime - lastTime); lastTime = currentTime + while accumulator >= fixedStep { tick(); accumulator -= fixedStep } + } + + private func beginIntro() { + introProgress = 0; introLastTime = 0; introFinished = false + introCancelling = false; introNextBeep = 0; menuOpen = false + buildIntroWarp() + lifetime.event("intro_started", roundID: roundID, fields: [ + "sceneId": sceneID, "durationMs": Int(cornerHoldDuration * 1000), + ]) + renderIntro() + } + + private func updateIntro(_ currentTime: TimeInterval) { + if let path = introCancelPath, + FileManager.default.fileExists(atPath: path), !introCancelling { + introCancelling = true + lifetime.event("intro_cancel_requested", roundID: roundID, fields: [ + "sceneId": sceneID, "progress": Double(introProgress), + ]) + tone.cancel() + tone.play(frequency: 494, endFrequency: 294, duration: 0.12, volume: 0.12) + } + if introLastTime == 0 { introLastTime = currentTime } + let delta = min(0.05, currentTime - introLastTime); introLastTime = currentTime + introProgress = min(1, max(0, introProgress + CGFloat(delta / cornerHoldDuration) * (introCancelling ? -1 : 1))) + + if !introCancelling { + let beepAt: [CGFloat] = [0, 0.30, 0.60] + let notes = [392.0, 494.0, 659.0] + while introNextBeep < beepAt.count, introProgress >= beepAt[introNextBeep] { + let note = notes[introNextBeep] + tone.play(frequency: note, endFrequency: note * 1.035, duration: 0.075, volume: 0.13) + introNextBeep += 1 + } + } + renderIntro() + + if introCancelling, introProgress <= 0 { + if let path = introCancelPath { try? FileManager.default.removeItem(atPath: path) } + lifetime.finishRound("intro_cancelled", roundID: roundID, fields: ["sceneId": sceneID]) + NSApp.terminate(nil) + } else if !introCancelling, introProgress >= 1 { + introFinished = true + introFlash.removeFromParent() + settleWarpStars() + resetRound(trigger: "intro_completed", beginNewLifetime: false); showMenu(false) + lifetime.event("intro_completed", roundID: roundID, fields: ["sceneId": sceneID]) + lifetime.event("combat_started", roundID: roundID, fields: ["sceneId": sceneID, "trigger": "intro_completed"]) + tone.play(frequency: 784, endFrequency: 1046, duration: 0.16, volume: 0.18) + } + } + + private func renderIntro() { + let stopT = smooth(min(1, introProgress / 0.82)) + + introFlash.alpha = max(0, 0.34 * (1 - introProgress / 0.16)) + let center = CGPoint(x: arenaWidth / 2, y: arenaHeight / 2) + for star in introWarpStars { + let localT = min(1, max(0, stopT * 1.20 - star.phase * 0.20)) + let travel = pow(localT, 1.55) + star.node.position = CGPoint(x: center.x + star.vector.dx * travel, + y: center.y + star.vector.dy * travel) + star.node.zRotation = atan2(star.vector.dy, star.vector.dx) + star.node.xScale = 0.45 + localT * 1.45 + let arrivalFade = introProgress > 0.82 ? max(0, (1 - introProgress) / 0.18) : 1 + star.node.alpha = min(1, introProgress * 10) * arrivalFade + } + + let destinations: [(Fighter, CGFloat, CGFloat)] = [(p1, arenaWidth / 2, -1), (p2, 1040, 1)] + for (fighter, x, side) in destinations { + fighter.root.position = CGPoint(x: x + side * (1 - stopT) * 18, + y: desktopFloor + (1 - stopT) * 8) + fighter.root.setScale(fighter.visualScale * (0.90 + stopT * 0.10)) + fighter.root.alpha = 0.82 + stopT * 0.18 + } + status.alpha = 0 + status.text = "" + } + + private func buildIntroWarp() { + for star in introWarpStars { star.node.removeFromParent() } + introWarpStars.removeAll(keepingCapacity: true) + introFlash.removeFromParent() + introFlash.position = CGPoint(x: arenaWidth / 2, y: arenaHeight / 2) + introFlash.fillColor = accent; introFlash.strokeColor = .clear; introFlash.zPosition = -8 + addChild(introFlash) + + for index in 0..<48 { + let corner = index % 4 + let sx: CGFloat = corner == 0 || corner == 2 ? -1 : 1 + let sy: CGFloat = corner < 2 ? -1 : 1 + let lane = CGFloat((index * 29) % 17 - 8) / 8 + let vector = CGVector(dx: sx * (arenaWidth * (0.42 + abs(lane) * 0.07)), + dy: sy * (arenaHeight * (0.40 + abs(lane) * 0.08)) + lane * 62) + let path = CGMutablePath() + path.move(to: CGPoint(x: -7, y: 0)); path.addLine(to: .zero) + let node = SKShapeNode(path: path) + node.strokeColor = contrastDark; node.lineWidth = lineDark + let lightTrace = SKShapeNode(path: path) + lightTrace.name = "warp-light"; lightTrace.strokeColor = contrastLight + lightTrace.lineWidth = lineLight + let accentTrace = SKShapeNode(path: path) + accentTrace.name = "warp-accent"; accentTrace.strokeColor = accent + accentTrace.lineWidth = lineSpot + node.addChild(lightTrace); node.addChild(accentTrace) + node.zPosition = -5 + addChild(node) + introWarpStars.append((node, vector, CGFloat((index * 37) % 100) / 100)) + } + } + + private func settleWarpStars() { + for (index, star) in introWarpStars.enumerated() { + let radius: CGFloat = index % 7 == 0 ? 2.2 : 1.35 + let path = CGMutablePath() + path.move(to: CGPoint(x: -radius, y: 0)); path.addLine(to: CGPoint(x: radius, y: 0)) + path.move(to: CGPoint(x: 0, y: -radius)); path.addLine(to: CGPoint(x: 0, y: radius)) + star.node.path = path + (star.node.childNode(withName: "warp-light") as? SKShapeNode)?.path = path + (star.node.childNode(withName: "warp-accent") as? SKShapeNode)?.path = path + star.node.zRotation = 0; star.node.xScale = 1 + star.node.alpha = index % 5 == 0 ? 0.90 : 0.68 + } + } + + private func smooth(_ value: CGFloat) -> CGFloat { + value * value * (3 - 2 * value) + } + + private func tick() { + guard !menuOpen, !roundOver else { return } + var now = input() + if desktopMode { + if previewSling { + p1.velocity = .zero + p1.applyGesture(direction: -0.32, boost: 0.08, grabbed: true) + } else { + let trackpad = TrackpadFlightInput.shared.consume() + p1.applyGesture(direction: trackpad.direction, boost: trackpad.boost, + grabbed: trackpad.grabbed) + now[0].light = now[0].light || trackpad.fire + } + } + if let damage = p1.tick(buttons: edges(now[0], previous[0]), opponentX: p2.root.position.x) { fire(from: p1, damage: damage) } + let second = 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] + particleFrame += 1 + if particleFrame % 2 == 0 { + emitThrust(from: p1); emitThrust(from: p2) + } + separate() + updateAsteroids() + updateShots() + updateBars() + } + + private func edges(_ current: Buttons, _ old: Buttons) -> Buttons { + var out = current; 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, !desktopMode { + 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 + if controllerRefreshFrames <= 0 { + cachedControllers = GCController.controllers() + controllerRefreshFrames = 60 + } + controllerRefreshFrames -= 1 + for (i, controller) in cachedControllers.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 dx = p2.root.position.x - p1.root.position.x + let dy = p2.root.position.y - p1.root.position.y + let distance = max(0.001, hypot(dx, dy)) + let minimum = 82 * max(p1.visualScale, p2.visualScale) + if distance < minimum { + let push = (minimum - distance) / 2 + p1.root.position.x -= dx / distance * push; p1.root.position.y -= dy / distance * push + p2.root.position.x += dx / distance * push; p2.root.position.y += dy / distance * push + } + } + + private func fire(from fighter: Fighter, damage: Int) { + let heavy = damage > 8 + let direction = desktopMode + ? CGVector(dx: cos(fighter.heading), dy: sin(fighter.heading)) + : CGVector(dx: fighter.facing, dy: 0) + let shot = Shot(owner: fighter.number, heavy: heavy, direction: direction, + position: CGPoint(x: fighter.root.position.x + direction.dx * 58, + y: fighter.root.position.y + direction.dy * 58)) + if desktopMode { shot.node.setScale(fighter.visualScale) } + shots.append(shot); addChild(shot.node) + lifetime.event("attack_fired", roundID: roundID, fields: [ + "sceneId": sceneID, "owner": fighter.number, "damage": damage, + "shotCount": shots.count, "sceneChildren": children.count, + ]) + tone.play(frequency: heavy ? 330 : 620, endFrequency: heavy ? 420 : 780, + duration: heavy ? 0.11 : 0.055, volume: heavy ? 0.16 : 0.11) + } + + private func emitThrust(from fighter: Fighter) { + guard desktopMode, fighter.emitsThrust else { return } + particleSequence += 1 + let dx = cos(fighter.heading), dy = sin(fighter.heading) + let jitter = CGFloat((particleSequence * 37) % 17 - 8) * 1.7 + let speed = CGFloat(88 + (particleSequence * 19) % 25) + let velocity = CGVector(dx: fighter.velocity.dx * 28 - dx * speed - dy * jitter, + dy: fighter.velocity.dy * 28 - dy * speed + dx * jitter) + let particle = ThrustParticle(position: fighter.exhaustPosition, velocity: velocity) + addChild(particle.node) + } + + private func updateShots() { + for shot in shots { + let speed: CGFloat = shot.damage > 8 ? 13 : 18 + shot.node.position.x += shot.direction.dx * speed + shot.node.position.y += shot.direction.dy * speed + shot.node.zRotation += 0.08 + if hitAsteroid(with: shot) { continue } + let defender = shot.owner == 1 ? p2 : p1 + guard shot.hitbox.intersects(defender.hitbox) else { continue } + let healthBefore = defender.health + defender.health = max(0, defender.health - shot.damage) + defender.velocity.dx += shot.direction.dx * (shot.damage > 8 ? 14 : 8) + defender.velocity.dy += shot.direction.dy * (shot.damage > 8 ? 14 : 8) + defender.grounded = false; defender.flashFrames = 8 + shot.node.removeFromParent() + lifetime.event("attack_hit", roundID: roundID, fields: [ + "sceneId": sceneID, "attacker": shot.owner, "defender": defender.number, + "damage": Int(shot.damage), "healthBefore": Int(healthBefore), + "healthAfter": Int(defender.health), + ]) + tone.play(frequency: shot.damage > 8 ? 246 : 392, endFrequency: shot.damage > 8 ? 196 : 330, + duration: 0.085, volume: 0.15) + if defender.health == 0 { + roundOver = true; status.text = "PLAYER \(shot.owner) WINS • RETURN TO REMATCH" + lifetime.finishRound("knockout", roundID: roundID, fields: [ + "sceneId": sceneID, "winner": shot.owner, "loser": defender.number, + "sceneChildren": children.count, "remainingShots": shots.count, + ]) + tone.play(frequency: 659, endFrequency: 988, duration: 0.30, volume: 0.16) + if desktopMode { DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { NSApp.terminate(nil) } } + break + } + } + shots.removeAll { + $0.node.parent == nil || $0.node.position.x < -40 || $0.node.position.x > arenaWidth + 40 || + $0.node.position.y < -40 || $0.node.position.y > arenaHeight + 40 + } + } + + private func hitAsteroid(with shot: Shot) -> Bool { + guard let index = asteroids.firstIndex(where: { shot.hitbox.intersects($0.hitbox) }) else { return false } + let asteroid = asteroids[index] + asteroid.health -= shot.damage > 8 ? 2 : 1 + shot.node.removeFromParent() + if asteroid.health <= 0 { + asteroid.node.removeFromParent(); asteroids.remove(at: index) + lifetime.event("asteroid_destroyed", roundID: roundID, fields: [ + "sceneId": sceneID, "owner": shot.owner, "asteroidsRemaining": asteroids.count, + ]) + tone.play(frequency: 220, endFrequency: 110, duration: 0.10, volume: 0.13) + } else { + asteroid.node.run(.sequence([.fadeAlpha(to: 0.25, duration: 0.04), .fadeAlpha(to: 1, duration: 0.08)])) + } + return true + } + + private func updateAsteroids() { + guard desktopMode else { return } + for player in [1, 2] where (asteroidImpactCooldown[player] ?? 0) > 0 { + asteroidImpactCooldown[player, default: 0] -= 1 + } + for asteroid in asteroids { + asteroid.node.position.x += asteroid.velocity.dx + asteroid.node.position.y += asteroid.velocity.dy + asteroid.node.zRotation += 0.0025 + if asteroid.node.position.x < -55 { asteroid.node.position.x = arenaWidth + 55 } + if asteroid.node.position.x > arenaWidth + 55 { asteroid.node.position.x = -55 } + if asteroid.node.position.y < -55 { asteroid.node.position.y = arenaHeight + 55 } + if asteroid.node.position.y > arenaHeight + 55 { asteroid.node.position.y = -55 } + for fighter in [p1, p2] where (asteroidImpactCooldown[fighter.number] ?? 0) == 0 { + guard asteroid.hitbox.intersects(fighter.hitbox) else { continue } + let dx = fighter.root.position.x - asteroid.node.position.x + let dy = fighter.root.position.y - asteroid.node.position.y + let distance = max(1, hypot(dx, dy)) + fighter.velocity.dx += dx / distance * 8 + fighter.velocity.dy += dy / distance * 8 + fighter.flashFrames = 10 + asteroidImpactCooldown[fighter.number] = 40 + lifetime.event("asteroid_collision", roundID: roundID, fields: [ + "sceneId": sceneID, "player": fighter.number, + ]) + tone.play(frequency: 170, endFrequency: 105, duration: 0.08, volume: 0.12) + } + } + } + + 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 = accent.withAlphaComponent(0.18); p2Bar.fillColor = .clear + p1Bar.strokeColor = accent; p2Bar.strokeColor = accent; p1Bar.lineWidth = 1.5; p2Bar.lineWidth = 1.5 + 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) { + guard introFinished else { return } + if event.keyCode == 53 { + if desktopMode { + if roundOver { + lifetime.event("exit_after_round", roundID: roundID, fields: ["sceneId": sceneID, "input": "escape"]) + } else { + lifetime.finishRound("quit", roundID: roundID, fields: ["sceneId": sceneID, "input": "escape"]) + } + NSApp.terminate(nil) + } + else { showMenu(!menuOpen) } + return + } + if event.keyCode == 36 && (menuOpen || roundOver) { + startRematch(trigger: roundOver ? "return_rematch" : "return_restart") + tone.play(frequency: 660, endFrequency: 820, duration: 0.08); 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) { + guard introFinished else { return } + pointerX = convertPoint(fromView: event.locationInWindow).x + } + + override func mouseDown(with event: NSEvent) { + guard introFinished, !previewSling else { return } + updatePointer(event) + if menuOpen { startRematch(trigger: "pointer_restart"); tone.play(frequency: 660, endFrequency: 820, duration: 0.08) } + else if roundOver { startRematch(trigger: "pointer_rematch") } + else { pointerLight = true } + } + + override func rightMouseDown(with event: NSEvent) { + guard introFinished, !previewSling else { return } + updatePointer(event) + if !menuOpen, !roundOver { pointerHeavy = true } + } + + override func scrollWheel(with event: NSEvent) { + guard introFinished, !previewSling else { return } + guard !desktopMode else { return } + updatePointer(event) + if event.scrollingDeltaY > 2 { pointerJump = true } + } + +} + +private final class FighterWindow: NSWindow { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } +} + +private func makeFighterCursor() -> NSCursor { + let side: CGFloat = 28 + let image = NSImage(size: NSSize(width: side, height: side), flipped: false) { rect in + NSColor.clear.setFill(); rect.fill() + let center = NSPoint(x: rect.midX, y: rect.midY) + let path = NSBezierPath(ovalIn: NSRect(x: center.x - 6, y: center.y - 6, width: 12, height: 12)) + for (a, b) in [(0.0, 4.0), (10.0, 14.0)] { + path.move(to: NSPoint(x: center.x + CGFloat(a), y: center.y)) + path.line(to: NSPoint(x: center.x + CGFloat(b), y: center.y)) + path.move(to: NSPoint(x: center.x - CGFloat(a), y: center.y)) + path.line(to: NSPoint(x: center.x - CGFloat(b), y: center.y)) + path.move(to: NSPoint(x: center.x, y: center.y + CGFloat(a))) + path.line(to: NSPoint(x: center.x, y: center.y + CGFloat(b))) + path.move(to: NSPoint(x: center.x, y: center.y - CGFloat(a))) + path.line(to: NSPoint(x: center.x, y: center.y - CGFloat(b))) + } + path.lineCapStyle = .round + ink.withAlphaComponent(0.92).setStroke(); path.lineWidth = 3.5; path.stroke() + accent.setStroke(); path.lineWidth = 1.5; path.stroke() + accent.setFill(); NSBezierPath(ovalIn: NSRect(x: center.x - 1.5, y: center.y - 1.5, width: 3, height: 3)).fill() + return true + } + return NSCursor(image: image, hotSpot: NSPoint(x: side / 2, y: side / 2)) +} + +private final class FighterView: SKView { + var capturesCursor = false + private lazy var fighterCursor = makeFighterCursor() + + override func resetCursorRects() { + super.resetCursorRects() + if capturesCursor { addCursorRect(bounds, cursor: fighterCursor) } + } +} + +private final class EventShieldView: NSView { + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + override func mouseDown(with event: NSEvent) {} + override func mouseUp(with event: NSEvent) {} + override func rightMouseDown(with event: NSEvent) {} + override func rightMouseUp(with event: NSEvent) {} + override func otherMouseDown(with event: NSEvent) {} + override func otherMouseUp(with event: NSEvent) {} + override func mouseMoved(with event: NSEvent) {} + override func mouseDragged(with event: NSEvent) {} + override func rightMouseDragged(with event: NSEvent) {} + override func otherMouseDragged(with event: NSEvent) {} + override func scrollWheel(with event: NSEvent) {} +} + +@MainActor private final class AppDelegate: NSObject, NSApplicationDelegate { + private let lifetime = RoundLifetimeLog.shared + private var window: NSWindow! + private var shieldWindows: [NSPanel] = [] + private var trackpadInputStarted = false + private var desktopSessionActive = false + private var cursorHidden = false + func applicationDidFinishLaunching(_ notification: Notification) { + let args = CommandLine.arguments + let slingPreview = args.contains("--preview-sling") + let previewMode = args.contains("--preview-gesture") || slingPreview + let desktopMode = args.contains("--desktop") || previewMode + let introMode = args.contains("--intro") || previewMode + let cancelIndex = args.firstIndex(of: "--intro-cancel") + let introCancelPath = cancelIndex.flatMap { $0 + 1 < args.count ? args[$0 + 1] : nil } + let launchIndex = args.firstIndex(of: "--launch-id") + let launchID = launchIndex.flatMap { $0 + 1 < args.count ? args[$0 + 1] : nil } + let mode = previewMode ? "preview" : (desktopMode ? "desktop" : "windowed") + desktopSessionActive = desktopMode + if desktopMode && !previewMode { + trackpadInputStarted = MFStartTrackpadInputWatcher(trackpadInputChanged) + } + lifetime.event("application_initialized", fields: [ + "mode": mode, "intro": introMode, "launchId": launchID ?? NSNull(), + "logPath": lifetime.path, "trackpadInput": trackpadInputStarted, + ]) + let roundID = lifetime.beginRound(trigger: introMode ? "intro_launch" : "application_launch", + mode: mode, launchID: launchID) + let sceneID = UUID().uuidString.lowercased() + let screenFrame = pointerScreen()?.frame ?? NSRect(x: 0, y: 0, width: 1280, height: 720) + let viewFrame = desktopMode + ? NSRect(origin: .zero, size: screenFrame.size) + : NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight) + let view = FighterView(frame: viewFrame) + view.capturesCursor = desktopMode + view.autoresizingMask = [.width, .height] + view.preferredFramesPerSecond = 60; view.ignoresSiblingOrder = true + view.allowsTransparency = desktopMode + let scene = FightScene(size: CGSize(width: arenaWidth, height: arenaHeight), desktopMode: desktopMode, + introMode: introMode, previewSling: slingPreview, introCancelPath: introCancelPath, + roundID: roundID, sceneID: sceneID, launchID: launchID) + // Fill the whole desktop without distorting the fighters. On displays that + // are not 16:9 SpriteKit crops the scene's quiet outer edges instead of + // adding letterbox bars. + scene.scaleMode = desktopMode ? .aspectFill : .aspectFit + view.presentScene(scene) + window = FighterWindow(contentRect: desktopMode ? screenFrame : view.frame, + styleMask: desktopMode ? [.borderless] : [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false) + window.title = "Trackpad Fighter"; window.contentView = view + if desktopMode { + window.isOpaque = false; window.backgroundColor = .clear; window.hasShadow = false + // The transparent window is also the input shield: clicks and gestures + // stop here instead of activating whatever happens to be underneath. + window.level = playWindowLevel; window.ignoresMouseEvents = false + window.acceptsMouseMovedEvents = true + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + } else { + window.contentAspectRatio = NSSize(width: 16, height: 9) + window.minSize = NSSize(width: 320, height: 180) + window.acceptsMouseMovedEvents = true + } + if desktopMode { window.setFrame(screenFrame, display: true) } + else { window.center() } + window.makeKeyAndOrderFront(nil) + window.makeFirstResponder(view) + NSApp.activate(ignoringOtherApps: true) + if desktopMode { + installInputShields(excluding: screenFrame) + NSCursor.hide(); cursorHidden = true + } + window.invalidateCursorRects(for: view) + lifetime.event("window_presented", roundID: roundID, fields: [ + "sceneId": sceneID, "mode": mode, "windowNumber": window.windowNumber, + "screenX": Int(screenFrame.origin.x), "screenY": Int(screenFrame.origin.y), + "screenWidth": Int(screenFrame.width), "screenHeight": Int(screenFrame.height), + "inputShields": shieldWindows.count, + ]) + } + + private func installInputShields(excluding gameFrame: NSRect) { + for screen in NSScreen.screens where screen.frame != gameFrame { + let panel = NSPanel(contentRect: screen.frame, styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, defer: false) + panel.contentView = EventShieldView(frame: NSRect(origin: .zero, size: screen.frame.size)) + panel.isOpaque = false; panel.backgroundColor = .clear; panel.hasShadow = false + panel.level = playWindowLevel; panel.ignoresMouseEvents = false + panel.acceptsMouseMovedEvents = true + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + panel.orderFrontRegardless(); shieldWindows.append(panel) + } + } + + func applicationDidResignActive(_ notification: Notification) { + guard desktopSessionActive else { return } + window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + func applicationWillTerminate(_ notification: Notification) { + desktopSessionActive = false + if trackpadInputStarted { MFStopCornerPoseWatcher() } + for shield in shieldWindows { shield.orderOut(nil) } + shieldWindows.removeAll() + lifetime.event("application_terminating") + lifetime.finishProcess(reason: "application_terminated") + if cursorHidden { NSCursor.unhide(); cursorHidden = false } + NSCursor.arrow.set() + } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } +} + +if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "auth" { + let ok = NativeMatchmaker.saveToken(CommandLine.arguments[2]) + print(ok ? "Trackpad Fighter sign-in saved in Keychain." : "Could not save Trackpad Fighter sign-in.") + exit(ok ? 0 : 1) +} + +if CommandLine.arguments.contains("--watch") { + setbuf(stdout, nil) + setbuf(stderr, nil) + 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/trackpad-fighter-native/Sources/TrackpadBridge/TrackpadBridge.c b/macos/trackpad-fighter-native/Sources/TrackpadBridge/TrackpadBridge.c new file mode 100644 --- /dev/null +++ b/macos/trackpad-fighter-native/Sources/TrackpadBridge/TrackpadBridge.c @@ -0,0 +1,106 @@ +#include "TrackpadBridge.h" +#include +#include +#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 cornerClient; +static MFTrackpadInputCallback inputClient; +static void (*registerCallback)(MTDeviceRef, MTCallback); +static void (*unregisterCallback)(MTDeviceRef, MTCallback); +static void (*startDevice)(MTDeviceRef, int); +static void (*stopDevice)(MTDeviceRef); +static bool *devicePoses; +static CFIndex deviceCount; +static bool reportedPose; +static os_unfair_lock poseLock = OS_UNFAIR_LOCK_INIT; + +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; + float centroidX = 0, centroidY = 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++; + centroidX += x; centroidY += y; + 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; + } + const bool pose = active >= 4 && corner[0] && corner[1] && corner[2] && corner[3]; + bool combined = pose; + bool notify = false; + + os_unfair_lock_lock(&poseLock); + if (devicePoses && devices) { + combined = false; + for (CFIndex i = 0; i < deviceCount; i++) { + if ((MTDeviceRef)CFArrayGetValueAtIndex(devices, i) == device) devicePoses[i] = pose; + combined = combined || devicePoses[i]; + } + } + if (combined != reportedPose) { + reportedPose = combined; + notify = true; + } + os_unfair_lock_unlock(&poseLock); + + if (active > 0) { centroidX /= active; centroidY /= active; } + if (notify && cornerClient) cornerClient(combined); + if (inputClient) inputClient(centroidX, centroidY, active); + return 0; +} + +static bool startWatcher(void) { + 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; + devices = createList(); + deviceCount = CFArrayGetCount(devices); + devicePoses = calloc((size_t)deviceCount, sizeof(bool)); + reportedPose = false; + for (CFIndex i = 0; i < deviceCount; i++) { + MTDeviceRef device = (MTDeviceRef)CFArrayGetValueAtIndex(devices, i); + registerCallback(device, contacts); startDevice(device, 0); + } + return deviceCount > 0; +} + +bool MFStartCornerPoseWatcher(MFCornerPoseCallback callback) { + cornerClient = callback; + return startWatcher(); +} + +bool MFStartTrackpadInputWatcher(MFTrackpadInputCallback callback) { + inputClient = callback; + return startWatcher(); +} + +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); + } + free(devicePoses); devicePoses = 0; deviceCount = 0; reportedPose = false; + if (devices) CFRelease(devices); devices = 0; cornerClient = 0; inputClient = 0; + if (library) dlclose(library); library = 0; +} diff --git a/macos/trackpad-fighter-native/Sources/TrackpadBridge/include/TrackpadBridge.h b/macos/trackpad-fighter-native/Sources/TrackpadBridge/include/TrackpadBridge.h new file mode 100644 --- /dev/null +++ b/macos/trackpad-fighter-native/Sources/TrackpadBridge/include/TrackpadBridge.h @@ -0,0 +1,8 @@ +#pragma once +#include + +typedef void (*MFCornerPoseCallback)(bool active); +typedef void (*MFTrackpadInputCallback)(float x, float y, int contacts); +bool MFStartCornerPoseWatcher(MFCornerPoseCallback callback); +bool MFStartTrackpadInputWatcher(MFTrackpadInputCallback callback); +void MFStopCornerPoseWatcher(void); diff --git a/macos/trackpad-fighter-native/install.sh b/macos/trackpad-fighter-native/install.sh new file mode 100644 --- /dev/null +++ b/macos/trackpad-fighter-native/install.sh @@ -0,0 +1,23 @@ +#!/bin/zsh +set -euo pipefail +cd "${0:A:h}" +swift build -c release +mkdir -p "$HOME/.local/bin" "$HOME/.local/share/trackpad-fighter" "$HOME/Library/LaunchAgents" +cp .build/release/trackpad-fighter "$HOME/.local/bin/trackpad-fighter" +chmod 755 "$HOME/.local/bin/trackpad-fighter" +old_plist="$HOME/Library/LaunchAgents/computer.aesthetic.menu-fighter.plist" +launchctl bootout "gui/$UID/computer.aesthetic.menu-fighter" 2>/dev/null || true +plist="$HOME/Library/LaunchAgents/computer.aesthetic.trackpad-fighter.plist" +launchctl bootout "gui/$UID/computer.aesthetic.trackpad-fighter" 2>/dev/null || true +rm -f "$old_plist" +plutil -create xml1 "$plist" +/usr/libexec/PlistBuddy -c 'Add :Label string computer.aesthetic.trackpad-fighter' "$plist" +/usr/libexec/PlistBuddy -c 'Add :ProgramArguments array' "$plist" +/usr/libexec/PlistBuddy -c "Add :ProgramArguments:0 string $HOME/.local/bin/trackpad-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" +/usr/libexec/PlistBuddy -c "Add :StandardOutPath string $HOME/.local/share/trackpad-fighter/watcher.log" "$plist" +/usr/libexec/PlistBuddy -c "Add :StandardErrorPath string $HOME/.local/share/trackpad-fighter/watcher.log" "$plist" +launchctl bootstrap "gui/$UID" "$plist" +echo "Installed Trackpad Fighter corner-pose watcher." diff --git a/pop/out/pop-library.json b/pop/out/pop-library.json --- a/pop/out/pop-library.json +++ b/pop/out/pop-library.json @@ -1,5 +1,5 @@ { - "generated": "2026-07-30T05:53:10.807Z", + "generated": "2026-08-04T15:59:35.231Z", "count": 68, "tracks": [ { @@ -30,10 +30,10 @@ "path": "/Users/jas/aesthetic-computer/pop/teknull/out/taksmukkeklokken.mp3", "title": "taksmukkeklokken", "artist": "Aesthetic Dot Computer", "lane": "teknull", - "backend": "C engine", + "backend": "JS DSP", "status": "WIP", "updated": "2026-07-30T02:43:24.307Z", - "revisions": 3, + "revisions": 2, "bytes": 4801965, "durationSec": 120, "bpm": 140, @@ -53,10 +53,10 @@ "path": "/Users/jas/aesthetic-computer/pop/teknull/out/teknull.mp3", "title": "teknull", "artist": "Aesthetic Dot Computer", "lane": "teknull", - "backend": "C engine", + "backend": "JS DSP", "status": "WIP", "updated": "2026-07-30T02:42:23.935Z", - "revisions": 3, + "revisions": 2, "bytes": 2776461, "durationSec": 120, "bpm": 140, @@ -79,7 +79,7 @@ "lane": "maytrax", "backend": "C engine", "status": "UNRELEASED", "updated": "2026-07-30T05:11:47.814Z", - "revisions": 7, + "revisions": 8, "bytes": 4802021, "durationSec": 120, "bpm": null, @@ -97,7 +97,7 @@ "lane": "maytrax", "backend": "C engine", "status": "UNRELEASED", "updated": "2026-07-30T05:11:44.418Z", - "revisions": 7, + "revisions": 8, "bytes": 4802024, "durationSec": 120, "bpm": null, @@ -115,7 +115,7 @@ "lane": "maytrax", "backend": "C engine", "status": "UNRELEASED", "updated": "2026-07-30T05:11:42.098Z", - "revisions": 7, + "revisions": 8, "bytes": 4578344, "durationSec": 114.4, "bpm": null, @@ -133,7 +133,7 @@ "lane": "maytrax", "backend": "C engine", "status": "UNRELEASED", "updated": "2026-07-30T05:11:36.380Z", - "revisions": 7, + "revisions": 8, "bytes": 4876903, "durationSec": 121.9, "bpm": null, @@ -151,7 +151,7 @@ "lane": "maytrax", "backend": "C engine", "status": "UNRELEASED", "updated": "2026-07-30T05:11:29.707Z", - "revisions": 7, + "revisions": 8, "bytes": 5122663, "durationSec": 128, "bpm": null, @@ -187,7 +187,7 @@ "lane": "maytrax", "backend": "C engine", "status": "UNRELEASED", "updated": "2026-07-30T05:11:22.176Z", - "revisions": 7, + "revisions": 8, "bytes": 4935463, "durationSec": 123.3, "bpm": null, @@ -223,7 +223,7 @@ "lane": "maytrax", "backend": "C engine", "status": "UNRELEASED", "updated": "2026-07-30T05:11:14.399Z", - "revisions": 7, + "revisions": 8, "bytes": 4791464, "durationSec": 119.7, "bpm": null, @@ -241,7 +241,7 @@ "lane": "maytrax", "backend": "C engine", "status": "UNRELEASED", "updated": "2026-07-30T05:11:08.122Z", - "revisions": 7, + "revisions": 8, "bytes": 4578344, "durationSec": 114.4, "bpm": null, @@ -277,7 +277,7 @@ "lane": "maytrax", "backend": "C engine", "status": "UNRELEASED", "updated": "2026-07-30T05:11:00.576Z", - "revisions": 7, + "revisions": 8, "bytes": 4894183, "durationSec": 122.3, "bpm": null, @@ -295,7 +295,7 @@ "lane": "maytrax", "backend": "C engine", "status": "UNRELEASED", "updated": "2026-07-30T05:10:51.845Z", - "revisions": 7, + "revisions": 8, "bytes": 4904744, "durationSec": 122.6, "bpm": null, @@ -313,7 +313,7 @@ "lane": "maytrax", "backend": "C engine", "status": "UNRELEASED", "updated": "2026-07-30T05:10:42.961Z", - "revisions": 7, + "revisions": 8, "bytes": 4890343, "durationSec": 122.2, "bpm": null, @@ -331,7 +331,7 @@ "lane": "maytrax", "backend": "C engine", "status": "UNRELEASED", "updated": "2026-07-30T05:10:34.696Z", - "revisions": 7, + "revisions": 8, "bytes": 5042022, "durationSec": 126, "bpm": null, @@ -367,7 +367,7 @@ "lane": "maytrax", "backend": "C engine", "status": "UNRELEASED", "updated": "2026-07-30T05:10:27.065Z", - "revisions": 7, + "revisions": 8, "bytes": 5522023, "durationSec": 138, "bpm": null, @@ -385,7 +385,7 @@ "lane": "maytrax", "backend": "C engine", "status": "UNRELEASED", "updated": "2026-07-30T05:10:19.928Z", - "revisions": 7, + "revisions": 8, "bytes": 5718822, "durationSec": 142.9, "bpm": null, @@ -691,7 +691,7 @@ "lane": "wattajetta", "backend": "C engine", "status": "UNRELEASED", "updated": "2026-07-30T03:08:32.260Z", - "revisions": 3, + "revisions": 2, "bytes": 6900691, "durationSec": 172.5, "bpm": null, @@ -709,7 +709,7 @@ "lane": "wattajetta", "backend": "C engine", "status": "UNRELEASED", "updated": "2026-07-30T03:02:58.185Z", - "revisions": 3, + "revisions": 2, "bytes": 3341798, "durationSec": 168.1, "bpm": null, @@ -727,7 +727,7 @@ "lane": "marimba", "backend": "C engine", "status": "UNRELEASED", "updated": "2026-07-28T18:32:42.000Z", - "revisions": 26, + "revisions": 25, "bytes": 3970666, "durationSec": 99.2, "bpm": null, @@ -745,7 +745,7 @@ "lane": "marimba", "backend": "C engine", "status": "UNRELEASED", "updated": "2026-07-27T19:37:03.000Z", - "revisions": 26, + "revisions": 25, "bytes": 1496349, "durationSec": 91.9, "bpm": null, -- tangled.sh