返回博客首页
← 所有文章

NativeScript macOS Node-API 预览

2024 年 8 月 26 日 — 作者:Diljit Singh(又名 Dj)与技术指导委员会 (TSC)

NativeScript 灵活且提供了无限的可能性。我们一直在开发 NativeScript 运行时的另一个迭代版本,它允许它在任何实现 Node-API 的 JavaScript 引擎上运行。

从历史上看,NativeScript 一直与 V8 这里 以及 JavaScriptCore 这里 耦合,用于 iOS 运行时。

这个令人兴奋的新 运行时 可以直接插入 NodeDeno,可以与 Hermes 以及 QuickJS 一起使用。实际上,它可以与任何支持 Node-API 的引擎一起使用。

这意味着什么?

NativeScript 已经在 Android、iOS 和 visionOS 上运行良好,那么这里有什么新东西?

💻 桌面支持 - 现在您可以利用 macOS 上本机平台 API 的强大功能 - 框架如 AppKitMetal 用于基于 GPU 的图形或计算性能、BNNS 用于加速机器学习应用,直接在您的 Node 或 Deno 应用中。

让我们通过示例学习

说到这个,我们遇到了这个项目:Ball by Nate Parrot - 它只是一个位于您的 Dock 中的球体,您可以单击它将球体(或重新停靠它)启动到您的屏幕上并与之交互。它有一些有趣的小细节,比如使用 AppKit & SpriteKit 来渲染球体及其物理特性,并使用 Swift Motion 库(我们使用 popmotion 在 JS 中实现!)进行一些动画。这是一个有趣的玩弄的小项目,它很好地展示了您可以使用 NativeScript Node-API 做些什么。

NativeScript 的妙处在于本机 API 在 JavaScript 中可用,因此您只需要打开 Apple 文档就可以开始构建内容。即使该项目是用 Swift 编写的,也很容易理解逻辑并以完全相同的方式在 JavaScript 中实现它。

注意:我们将在未来几个月分享更多示例,展示更多使用不同框架的 NativeScript Node-API。

理解项目

您可以立即运行此示例

git clone https://github.com/DjDeveloperr/NSBall.git
cd NSBall
npm install

使用 Deno 尝试

deno task start

使用 Node 尝试

npm start

当我们查看源代码时,主入口点是 AppDelegate 类(AppDelegate.swift 的一部分)。球体从 Dock 启动,并返回到那里,因此 Dock 事件在应用程序委托中处理,然后是应用程序控制器,它抽象了应用程序的主要逻辑,例如在 dockIconClicked 中启动和停靠球体,以及处理这里使用的两个窗口。一个是使用 SpriteKit 渲染球体的窗口,它覆盖整个屏幕,另一个是位于顶部的透明小窗口,大小与球体完全相同,用于捕获鼠标事件以进行交互。这就是这个项目如何工作的基本逻辑。

让我们讨论一下我们是如何制作这个项目的。

初始化项目

让我们从创建一个简单的配置文件来导入 NativeScript Node-API 和 src/main.ts 开始。

{
  "tasks": {
    "run": "deno run -A src/main.ts"
  },

  "imports": {
    "@nativescript/macos-node-api": "npm:@nativescript/macos-node-api@^0.1.0",
  }
}

现在让我们创建 src/main.ts 并测试 NativeScript Node-API。

import "@nativescript/macos-node-api";

console.log(NSProcessInfo.processInfo.operatingSystemVersionString);

运行 deno task run 应该会打印出您的操作系统版本!

这使我们能够在 Deno 上运行。要在 Node.js 上运行,请使用 npm init 初始化项目,安装 npm install @nativescript/macos-node-api。确保也设置 tsconfig.json,运行 TypeScript 编译器,然后使用 node 运行项目。好了,您将获得相同的输出,但在 Node.js 中!Node-API 允许 NativeScript 在 Node.js 和 Deno 上无缝运行。

这是我使用的 tsconfig.json

{
  "compilerOptions": {
    "lib": ["ESNext", "DOM"],
    "target": "ESNext",
    "module": "ES2022",
    "moduleResolution": "Node",
    "rootDir": "./src",
    "outDir": "./dist",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

Node.js 和 Deno 中的模块系统略有不同。为了使其在两者中都能正常工作,我们将模块系统设置为 ES2022。输出代码将使用 ESM,这将适用于 Node.js 和 Deno。现在唯一的区别是 Deno 要求您使用带 .ts 后缀的完全限定说明符,但这会按原样发出说明符。这就是为什么我们将使用 .js 说明符的原因,这样发出的代码将在 Node.js 中起作用,为了在 Deno 中运行 TypeScript 本身,我们将不得不使用 --unstable-sloppy-imports 标志。

实现

基本的 AppKit 应用程序

让我们从创建 AppDelegate 类开始。

src/app_delegate.ts:

import "@nativescript/macos-node-api";

export class AppDelegate extends NSObject implements NSApplicationDelegate {
  static ObjCProtocols = [NSApplicationDelegate];

  static {
    NativeClass(this);
  }

  applicationDidFinishLaunching(_notification: NSNotification): void {
    console.log("NSBall started!");
  }
}

注意我们如何使用 NativeClass 使该类可用于 Objective-C 运行时。我们以及 ObjCProtocols 静态,我们需要在运行时获取该信息才能找到协议的定义,以便在本机环境中公开方法。除了这些之外,您只需像 NSObject 一样自然地扩展本机类,甚至可以使用 TypeScript implements 关键字来实现协议,并在编辑器中获得类型检查和自动完成。

但我们究竟如何使用它?在 Xcode 项目中,它将被隐式使用(类名在 Info.plist 中提到),但我们必须添加一些样板代码,使其工作起来就像我们手动编写 Objective-C 项目中的 AppKit 应用程序一样。因此,我们必须像这样更改 main.ts

import "@nativescript/macos-node-api";

objc.import("AppKit");

import { AppDelegate } from "./app_delegate.js";

const NSApp = NSApplication.sharedApplication;

NSApp.delegate = AppDelegate.new();
NSApp.setActivationPolicy(NSApplicationActivationPolicy.Regular);

NSApplicationMain(0, null);

运行它时,我们将在控制台中看到预期的消息,并且 Dock 中会出现一个终端图标。这是 NativeScript macOS 应用程序的基本设置。其余部分与在 Objective-C 或 Swift 中编写 macOS 应用程序相同,但在 JavaScript 中。

处理 Dock 图标点击

让我们添加更多内容,以便处理 Dock 图标点击。

export class AppDelegate extends NSObject implements NSApplicationDelegate {
  ...

  applicationWillFinishLaunching(_notification: NSNotification): void {
    NSApp.applicationIconImage = NSImage.alloc().initWithContentsOfFile(
      new URL("../assets/Ball.png", import.meta.url).pathname,
    );
  }

  applicationShouldHandleReopenHasVisibleWindows(_sender: NSApplication, _flag: boolean): boolean {
    console.log("Dock icon clicked");
    return true;
  }
}
  • applicationWillFinishLaunching:设置 Dock 图标图像。理想情况下,我们将在应用程序包中设置它,但现在,让我们在应用程序完成启动之前设置它。
  • applicationShouldHandleReopenHasVisibleWindows:当点击 Dock 图标时,将调用此方法。现在,我们向控制台记录一条消息。

为 SpriteKit 场景创建窗口

现在,当应用程序完成启动时,我们将要做的是创建一个位于屏幕保护程序级别的窗口,它覆盖整个屏幕并使用 SpriteKit 渲染球体。让我们先创建它。

export class AppDelegate extends NSObject implements NSApplicationDelegate {
  ...

  ballWindow!: NSWindow;

  makeBallWindow() {
    const window = NSWindow.alloc().initWithContentRectStyleMaskBackingDefer(
      { origin: { x: 196, y: 240 }, size: { width: 480, height: 270 } },
      NSWindowStyleMask.FullSizeContentView,
      NSBackingStoreType.Buffered,
      false,
    );

    window.title = "NSBall";
    window.isRestorable = false;
    window.isReleasedWhenClosed = false;

    window.collectionBehavior = NSWindowCollectionBehavior.Transient |
      NSWindowCollectionBehavior.IgnoresCycle |
      NSWindowCollectionBehavior.FullScreenNone | NSWindowCollectionBehavior.CanJoinAllApplications;
    window.hasShadow = false;
    window.animationBehavior = NSWindowAnimationBehavior.None;
    window.tabbingMode = NSWindowTabbingMode.Disallowed;
    window.backgroundColor = NSColor.clearColor;
    window.isOpaque = false;
    window.acceptsMouseMovedEvents = false;
    window.ignoresMouseEvents = true;
    window.level = NSScreenSaverWindowLevel;

    this.ballWindow = window;
    this.updateWindowSize();
  }

  updateWindowSize() {
    const screen = this.ballWindow.screen;
    if (!screen) {
      return;
    }

    this.ballWindow.setFrameDisplay({
      origin: { x: screen.frame.origin.x, y: screen.frame.origin.y },
      size: {
        width: screen.frame.size.width,
        height: screen.frame.size.height,
      },
    }, true);
  }

  applicationDidFinishLaunching(_notification: NSNotification): void {
    console.log("NSBall started!");

    this.makeBallWindow();
  }

  ...
}

在某些情况下,我们需要更新窗口大小,例如当屏幕更改或屏幕配置文件更改时。我们可以在此类本身实现窗口委托方法。

export class AppDelegate extends NSObject implements NSApplicationDelegate, NSWindowDelegate {
  static ObjCProtocols = [NSApplicationDelegate, NSWindowDelegate];

  ...

  makeBallWindow() {
    ...

    window.delegate = this;
    
    ...
  }

  windowDidChangeScreen(_notification: NSNotification): void {
    this.updateWindowSize();
  }

  windowDidChangeScreenProfile(_notification: NSNotification): void {
    this.updateWindowSize();
  }

  ...
}

将球体对象添加到场景

运行此代码时,您不会注意到太多变化,但让我们通过添加一个真正的球体到这个窗口来改变这一点。

从创建一个 Ball SpriteKit 节点开始。

ball.ts:

import "@nativescript/macos-node-api";

objc.import("AppKit");
objc.import("SpriteKit");

export class Ball extends SKNode {
  static {
    NativeClass(this);
  }

  imgContainer = SKNode.new();
  
  imgNode = SKSpriteNode.spriteNodeWithTexture(
    SKTexture.textureWithImage(
      NSImage.alloc().initWithContentsOfFile(
        new URL("../assets/Ball.png", import.meta.url).pathname,
      ),
    ),
  );

  radius = 0;

  static create(radius: number, pos: CGPoint) {
    const ball = Ball.new();
    
    ball.radius = radius;
    ball.position = pos;
    ball.imgNode.size = { width: radius * 2, height: radius * 2 };

    const body = SKPhysicsBody.bodyWithCircleOfRadius(radius);
    
    body.isDynamic = true;
    body.restitution = 0.6;
    body.allowsRotation = false;
    body.usesPreciseCollisionDetection = true;
    body.contactTestBitMask = 1;

    ball.physicsBody = body;

    ball.addChild(ball.imgContainer);
    ball.imgContainer.addChild(ball.imgNode);

    return ball;
  }

  update() {
    const yDelta = -(1 - this.imgContainer.xScale) * this.radius / 2;
    this.imgContainer.position = { x: 0, y: yDelta };
  }
}

在这里,我们创建一个带有圆形物理体的球体,以便它可以处理屏幕边界的碰撞,并以一种我们首先在球体节点内有一个图像容器,以及该容器内的图像节点的方式设置其层次结构。这样我们就可以更改偏移量和 x 缩放比例,以便在球体稍后撞击屏幕边界时产生挤压效果。

现在让我们创建一个视图控制器,它实际上设置了场景并允许启动和停靠球体。我们将此 VC 设为窗口的根视图控制器。

view_controller.ts:

import "@nativescript/macos-node-api";

export class ViewController extends NSViewController {
  static {
    NativeClass(this);
  }

  scene = SKScene.sceneWithSize({ width: 200, height: 200 });
  sceneView = SKView.new();

  viewDidLoad() {
    super.viewDidLoad();

    this.view.addSubview(this.sceneView);
    this.sceneView.presentScene(this.scene);
    this.scene.backgroundColor = NSColor.clearColor;
    this.sceneView.allowsTransparency = true;

    this.sceneView.preferredFramesPerSecond = 120;
  }

  viewDidLayout() {
    super.viewDidLayout();
    this.scene.size = this.view.bounds.size;
    this.sceneView.frame = this.view.bounds;
    this.scene.physicsBody = SKPhysicsBody.bodyWithEdgeLoopFromRect(
      this.view.bounds
    );
    this.scene.physicsBody.contactTestBitMask = 1;
  }
}

此视图控制器设置了 SpriteKit 场景环境、相应的场景视图,以及物理体,以允许球体在边缘碰撞。在 viewDidLayout 中,当视图大小更改时,会自动更新场景大小。

让我们还在应用程序委托中使用此视图控制器,将其设为窗口的根视图控制器。

    ballWindow?: NSWindow;
+   ballViewController = ViewController.new();

    ...

      window.level = NSScreenSaverWindowLevel;
+     window.contentViewController = this.ballViewController;

为了启动和停靠球体,让我们在这里定义两个方法:launchdock。它们都将接受一个 CGRect,它定义了 Dock 磁贴在屏幕坐标中的位置,同时考虑了点击 Dock 图标的鼠标位置。

export class ViewController extends NSViewController {
  ...
  physicsQueue: CallableFunction[] = [];

  _ball?: Ball;

  get ball() {
    return this._ball;
  }

  set ball(value) {
    this.ball?.destroy();

    this._ball = value;

    if (value) this.scene.addChild(value);
  }

  ...

  launch(rect: CGRect) {
    const screen = this.view.window?.screen;
    if (!screen) return;

    const ball = Ball.create(RADIUS, {
      x: CGRectGetMidX(rect),
      y: CGRectGetMidY(rect),
    });

    this.ball = ball;

    const strength = 2000;
    const impulse: CGVector = { dx: 0, dy: 0 };

    const distFromLeft = CGRectGetMidX(rect) - CGRectGetMinX(screen.frame);
    const distFromRight = CGRectGetMaxX(screen.frame) - CGRectGetMidX(rect);
    const distFromBottom = CGRectGetMidY(rect) - CGRectGetMinY(screen.frame);

    if (distFromBottom < 200) {
      impulse.dy = strength;
    }

    if (distFromLeft < 200) {
      impulse.dx = strength;
    } else if (distFromRight < 200) {
      impulse.dx = -strength;
    }

    ball.setScale(rect.size.width / (ball.radius * 2));
    const scaleUp = SKAction.scaleToDuration(1, 0.5);
    ball.runAction(scaleUp);

    this.physicsQueue.push(() => {
      ball.physicsBody.applyImpulse(impulse);
    });
  }

  dock(rect: CGRect, onComplete: () => void) {
    const ball = this.ball;

    if (!ball) {
      return onComplete();
    }

    if (ball.physicsBody) {
      ball.physicsBody.isDynamic = false;
      ball.physicsBody.affectedByGravity = false;
      ball.physicsBody.velocity = { dx: 0, dy: 0 };
    }

    ball.runAction(
      SKAction.scaleToDuration(rect.size.width / (ball.radius * 2), 0.25)
    );

    ball.runActionCompletion(
      SKAction.moveToDuration(
        {
          x: CGRectGetMidX(rect),
          y: CGRectGetMidY(rect),
        },
        0.25
      ),
      () => {
        this.ball = undefined;
        onComplete();
      }
    );
  }
}

注意我们如何在其中添加了一个物理队列,它包含必须仅在下次物理模拟时运行的回调。我们可以使用 SKSceneDelegate 协议来做到这一点。此外,当场景更新完成后,我们也必须调用 ball.update

export class ViewController extends NSViewController implements SKSceneDelegate {
  static ObjCProtocols = [SKSceneDelegate];

  ...

  didSimulatePhysicsForScene(_scene: SKScene): void {
    const queue = this.physicsQueue;
    this.physicsQueue = [];
    for (const cb of queue) cb();
  }

  didFinishUpdateForScene(_scene: SKScene): void {
    this.ball?.update();
  }
}

现在我们需要确定点击 Dock 图标时的位置。

util.ts:

import "@nativescript/macos-node-api";

export const RADIUS = 100;

// From https://gist.github.com/wonderbit/c8896ff429a858021a7623f312dcdbf9

export const WBDockPosition = {
  BOTTOM: 0,
  LEFT: 1,
  RIGHT: 2,
} as const;

export function getDockPosition(screen: NSScreen) {
  if (screen.visibleFrame.origin.y == 0) {
    if (screen.visibleFrame.origin.x == 0) {
      return WBDockPosition.RIGHT;
    } else {
      return WBDockPosition.LEFT;
    }
  } else {
    return WBDockPosition.BOTTOM;
  }
}

export function getDockSize(screen: NSScreen, position: number) {
  let size;
  switch (position) {
    case WBDockPosition.RIGHT:
      size =
        screen.frame.size.width -
        screen.visibleFrame.size.width;
      return size;
    case WBDockPosition.LEFT:
      size = screen.visibleFrame.origin.x;
      return size;
    case WBDockPosition.BOTTOM:
      size = screen.visibleFrame.origin.y;
      return size;
    default:
      throw new Error("unreachable");
  }
}

export function getInferredRectOfHoveredDockIcon(screen: NSScreen): CGRect {
  // Keep in mind coords are inverted (y=0 at BOTTOM)
  const dockPos = getDockPosition(screen);
  const dockSize = getDockSize(screen, dockPos);
  const tileSize = dockSize * (64.0 / 79.0);
  // First, set center to the mouse pos
  const center = NSEvent.mouseLocation;
  if (dockPos == WBDockPosition.BOTTOM) {
    center.y = CGRectGetMinY(screen.frame) + tileSize / 2;
    // Dock icons are a little above the center of the dock rect
    center.y += (2.5 / 79) * dockSize;
  }
  return {
    origin: { x: center.x - tileSize / 2, y: center.y - tileSize / 2 },
    size: { width: tileSize, height: tileSize },
  };
}

export function constrainRect(r: CGRect, bounds: CGRect): CGRect {
  const boundsMinX = CGRectGetMinX(bounds);
  const boundsMaxX = CGRectGetMaxX(bounds);
  const boundsMinY = CGRectGetMinY(bounds);
  const boundsMaxY = CGRectGetMaxY(bounds);

  if (CGRectGetMinX(r) < boundsMinX) r.origin.x = boundsMinX;
  if (CGRectGetMaxX(r) > boundsMaxX) r.origin.x = boundsMaxX - r.size.width;
  if (CGRectGetMinY(r) < boundsMinY) r.origin.y = boundsMinY;
  if (CGRectGetMaxY(r) > boundsMaxY) r.origin.y = boundsMaxY - r.size.height;

  return r;
}

export function remap(
  x: number,
  domainStart: number,
  domainEnd: number,
  rangeStart: number,
  rangeEnd: number,
  clamp = true
) {
  const domain = domainEnd - domainStart;
  const range = rangeEnd - rangeStart;
  const value = (x - domainStart) / domain;
  const result = rangeStart + value * range;
  if (clamp) {
    if (rangeStart < rangeEnd) {
      return Math.min(Math.max(result, rangeStart), rangeEnd);
    } else {
      return Math.min(Math.max(result, rangeEnd), rangeStart);
    }
  } else {
    return result;
  }
}

还在其中添加了一个实用函数,用于将一个 CGRect 约束在另一个 CGRect(边界)内。

接下来,让我们更改 Dock 点击事件的实现,以启动/停靠球体。

export class AppDelegate
  extends NSObject
  implements NSApplicationDelegate, NSWindowDelegate
{
  ...

  putBackImageView = NSImageView.imageViewWithImage(
    NSImage.alloc().initWithContentsOfFile(
      new URL("../assets/PutBack.png", import.meta.url).pathname
    )
  );

  ballImageView = NSImageView.imageViewWithImage(
    NSImage.alloc().initWithContentsOfFile(
      new URL("../assets/Ball.png", import.meta.url).pathname
    )
  );

  _ballVisible = false;

  get ballVisible() {
    return this._ballVisible;
  }

  set ballVisible(value) {
    if (value === this.ballVisible) {
      return;
    }

    this._ballVisible = value;

    this.ballWindow.setIsVisible(value);
    this.ballViewController.sceneView.isPaused = !value;

    NSApp.dockTile.contentView = value
      ? this.putBackImageView
      : this.ballImageView;
    NSApp.dockTile.display();
  }

  ...

  applicationShouldHandleReopenHasVisibleWindows(
    _sender: NSApplication,
    _flag: boolean
  ): boolean {
    let currentScreen: NSScreen | undefined;

    const mouseLocation = NSEvent.mouseLocation;

    for (const screen of NSScreen.screens) {
      if (NSPointInRect(mouseLocation, screen.frame)) {
        currentScreen = screen;
        break;
      }
    }

    if (!currentScreen) return true;

    const dockIconRect = constrainRect(
      getInferredRectOfHoveredDockIcon(currentScreen),
      currentScreen.frame
    );

    if (this.ballVisible) {
      this.ballViewController.dock(dockIconRect, () => {
        this.ballVisible = false;
      });
    } else {
      this.ballViewController.launch(dockIconRect);
      this.ballVisible = true;
    }

    return true;
  }
}

现在我们可以运行项目,并看到点击 Dock 图标时球体启动和停靠!

阴影

让我们也给球体添加一个阴影,当球体靠近屏幕底部时,阴影会淡入。

ball.ts:

export class Ball extends SKNode {
  ...

  shadowSprite = SKSpriteNode.spriteNodeWithTexture(
    SKTexture.textureWithImage(
      NSImage.alloc().initWithContentsOfFile(
        new URL("../assets/ContactShadow.png", import.meta.url).pathname
      )
    )
  );
  shadowContainer = SKNode.new();

  ...

  static create(radius: number, pos: CGPoint) {
    ...

    ball.addChild(ball.shadowContainer);
    ball.shadowContainer.addChild(ball.shadowSprite);
    const shadowWidth = radius * 4;
    ball.shadowSprite.size = {
      width: shadowWidth,
      height: 0.564 * shadowWidth,
    };
    ball.shadowSprite.alpha = 0;
    ball.shadowContainer.alpha = 0;

    ball.addChild(ball.imgContainer);
    ball.imgContainer.addChild(ball.imgNode);

    return ball;
  }

  update() {
    this.shadowSprite.position = {
      x: 0,
      y: this.radius * 0.3 - this.position.y,
    };

    const distFromBottom = this.position.y - this.radius;
    this.shadowSprite.alpha = remap(distFromBottom, 0, 200, 1, 0);

    const yDelta = (-(1 - this.imgContainer.xScale) * this.radius) / 2;
    this.imgContainer.position = { x: 0, y: yDelta };
  }

  ...

  animateShadow(visible: boolean, duration: number) {
    if (visible) {
      this.shadowContainer.runAction(SKAction.fadeInWithDuration(duration));
    } else {
      this.shadowContainer.runAction(SKAction.fadeOutWithDuration(duration));
    }
  }

}

这使得阴影根据距离底部的距离或多或少地突出显示。我们还需要在视图控制器的 launchdock 方法中调用 animateShadow

// in launch
ball.animateShadow(true, 0.5);

// in dock
ball.animateShadow(false, 0.25);

交互

接下来,让我们添加点击处理。为此,我们需要一个相同级别的窗口,但这次它接受鼠标事件,并且仅覆盖可见的场景部分,即球体本身。

我们首先定义一个视图,该视图通过覆盖 NSView 方法来捕获点击事件。它将把点击事件重定向到球体视图控制器。

mouse_catcher.ts:

import "@nativescript/macos-node-api";

export interface MouseCatcherDelegate {
  onMouseDown(): void;
  onMouseDrag(): void;
  onMouseUp(): void;
  onScroll(event: NSEvent): void;
}

export class MouseCatcherView extends NSView {
  static {
    NativeClass(this);
  }

  delegate!: MouseCatcherDelegate;

  mouseDown(_event: NSEvent) {
    this.delegate.onMouseDown();
  }

  mouseDragged(_event: NSEvent) {
    this.delegate.onMouseDrag();
  }

  mouseUp(_event: NSEvent) {
    this.delegate.onMouseUp();
  }

  scrollWheel(event: NSEvent) {
    if (!(event.hasPreciseScrollingDeltas && event.momentumPhase == 0)) {
      return;
    }

    this.delegate.onScroll(event);
  }
}

app_delegate.ts 中,我们需要设置点击窗口以及球体/SpriteKit 场景窗口。并且它的位置需要与球体一起更新,因此我们在球体视图控制器中添加了一个回调,以便在球体位置更改时更新点击窗口位置。为了方便起见,我们还添加了一个 getter,用于从球体视图控制器获取鼠标捕获矩形。

export class AppDelegate
  extends NSObject
  implements NSApplicationDelegate, NSWindowDelegate
{
  ...

  clickWindow!: NSWindow;

  ...

  set ballVisible(value) {
    ...

    this.clickWindow.setIsVisible(value);
    if (value) {
      this.updateClickWindow();
    }
  }

  makeClickWindow() {
    const clickWindow =
      NSWindow.alloc().initWithContentRectStyleMaskBackingDefer(
        {
          origin: { x: 0, y: 0 },
          size: { width: RADIUS * 2, height: RADIUS * 2 },
        },
        0,
        NSBackingStoreType.Buffered,
        false
      );
    clickWindow.isReleasedWhenClosed = false;
    clickWindow.level = NSScreenSaverWindowLevel;
    clickWindow.backgroundColor = NSColor.clearColor;

    const catcher = MouseCatcherView.new();
    clickWindow.contentView = catcher;
    catcher.frame = {
      origin: { x: 0, y: 0 },
      size: { width: RADIUS * 2, height: RADIUS * 2 },
    };
    catcher.wantsLayer = true;
    catcher.layer.backgroundColor =
      NSColor.blackColor.colorWithAlphaComponent(0.01).CGColor;
    catcher.layer.cornerRadius = RADIUS;
    catcher.delegate = this.ballViewController;

    this.clickWindow = clickWindow;

    this.ballViewController.ballPositionChanged = () => {
      this.updateClickWindow();
    };
  }

  updateClickWindow() {
    const rect = this.ballViewController.mouseCatcherRect;
    if (!this.ballVisible || !rect) return;

    const rounding = 10;
    rect.origin.x = Math.round(CGRectGetMinX(rect) / rounding) * rounding;
    rect.origin.y = Math.round(CGRectGetMinY(rect) / rounding) * rounding;

    // HACK: Assume scene coords are same as window coords
    const screen = this.ballWindow?.screen;
    if (!screen) return;

    if (rect) {
      this.clickWindow.setFrameDisplay(
        constrainRect(rect, screen.frame),
        false
      );
    }
  }

  ...

  applicationDidFinishLaunching(_notification: NSNotification): void {
    ...
    this.makeClickWindow();
  }
}

以下是 mouseCatcherRect getter 在 view_controller.ts 中的实现方式

export class ViewController
  extends NSViewController
  implements SKSceneDelegate
{
  ...

  tempMouseCatcherRect?: CGRect;

  get mouseCatcherRect(): CGRect | undefined {
    const rect = this.tempMouseCatcherRect ?? this.ball?.rect;
    const window = this.view.window;

    if (rect && window) {
      return window.convertRectToScreen(rect);
    }
  }
  
  ...
}

并向球体类添加 rect getter

export class Ball extends SKNode {
  ...

  get rect() {
    return {
      origin: {
        x: this.position.x - this.radius,
        y: this.position.y - this.radius,
      },
      size: { width: this.radius * 2, height: this.radius * 2 },
    };
  }

  ...
}

注意,临时鼠标捕获矩形用于在使用滚动事件拖动球体时覆盖球体矩形,在这种情况下,我们希望鼠标捕获矩形保持在鼠标的初始位置,而不是球体的初始位置,因为球体的初始位置将随着滚动事件的拖动而改变。

接下来我们需要处理事件。但在处理之前,让我们实现球体在拖动时的移动方式。它将按照预期跟随鼠标,如果滚动,它将在滚动的方向移动,但当从拖动状态释放时,必须对其施加一定的冲力。为了实现这一点,我们必须跟踪拖动过程中的速度,然后在拖动状态结束时计算冲力。

view_controller.ts:

export interface Sample {
  time: number;
  pos: CGPoint;
}

export class VelocityTracker {
  samples: Sample[] = [];

  add(pos: CGPoint) {
    const time = CACurrentMediaTime();
    const sample = { time, pos };
    this.samples.push(sample);
    this.samples = this.filteredSamples;
  }

  get filteredSamples() {
    const time = CACurrentMediaTime();
    const filtered = this.samples.filter((sample) => time - sample.time < 0.1);
    return filtered;
  }

  get velocity(): CGPoint {
    const samples = this.filteredSamples;
    if (samples.length < 2) {
      return CGPointZero;
    }
    const first = samples[0];
    const last = samples[samples.length - 1];
    const delta = {
      x: last.pos.x - first.pos.x,
      y: last.pos.y - first.pos.y,
    };
    const time = last.time - first.time;
    return {
      x: delta.x / time,
      y: delta.y / time,
    };
  }
}

export class DragState {
  velocityTracker = new VelocityTracker();

  constructor(
    public ballStart: CGPoint,
    public mouseStart: CGPoint,
    public currentMousePos: CGPoint
  ) {}

  get currentBallPos() {
    const delta = {
      x: this.currentMousePos.x - this.mouseStart.x,
      y: this.currentMousePos.y - this.mouseStart.y,
    };
    return {
      x: this.ballStart.x + delta.x,
      y: this.ballStart.y + delta.y,
    };
  }
}

export class ViewController
  extends NSViewController
  implements SKSceneDelegate, MouseCatcherDelegate
{
  ...

  _dragState?: DragState;

  get dragState() {
    return this._dragState;
  }

  set dragState(value) {
    this._dragState = value;

    if (value && this.ball) {
      this.ball.physicsBody.isDynamic = false;

      const pos = value.currentBallPos;

      const constrainedRect = constrainRect(
        {
          origin: { x: pos.x - this.ball.radius, y: pos.y - this.ball.radius },
          size: { width: this.ball.radius * 2, height: this.ball.radius * 2 },
        },
        this.view.bounds
      );

      this.ball.position = {
        x: CGRectGetMidX(constrainedRect),
        y: CGRectGetMidY(constrainedRect),
      };
    } else if (this.ball) {
      this.ball.physicsBody.isDynamic = true;
    }
  }

  ballPositionChanged?: () => void;

  ...

  get mouseScenePos() {
    const viewPos = this.sceneView.convertPointFromView(
      this.view.window.mouseLocationOutsideOfEventStream,
      null
    );
    const scenePos = this.scene.convertPointFromView(viewPos);
    return scenePos;
  }

  onMouseDown() {
    const scenePos = this.mouseScenePos;
    if (this.ball && this.ball.containsPoint(scenePos)) {
      this.dragState = new DragState(this.ball.position, scenePos, scenePos);
    } else {
      this.dragState = undefined;
    }
  }

  onMouseDrag() {
    if (this.dragState) {
      this.dragState.currentMousePos = this.mouseScenePos;
      this.dragState.velocityTracker.add(this.dragState.currentMousePos);
      this.dragState = this.dragState;
    }
  }

  onMouseUp() {
    const velocity = this.dragState?.velocityTracker.velocity ?? CGPointZero;
    this.dragState = undefined;

    if (CGPointGetLength(velocity) > 0) {
      this.ball?.physicsBody?.applyImpulse({ dx: velocity.x, dy: velocity.y });
    }
  }

  onScroll(event: NSEvent) {
    switch (event.phase) {
      case NSEventPhase.Began:
        if (this.ball) {
          this.dragState = new DragState(
            this.ball.position,
            CGPointZero,
            CGPointZero
          );
          this.tempMouseCatcherRect = this.mouseCatcherRect;
        }
        break;
      case NSEventPhase.Changed:
        if (this.dragState) {
          this.dragState.currentMousePos.x += event.scrollingDeltaX;
          this.dragState.currentMousePos.y -= event.scrollingDeltaY;
          this.dragState.velocityTracker.add({
            x: this.dragState.currentMousePos.x,
            y: this.dragState.currentMousePos.y,
          });
          this.dragState = this.dragState;
        }
        break;
      case NSEventPhase.Ended:
      case NSEventPhase.Cancelled: {
        const velocity =
          this.dragState?.velocityTracker.velocity ?? CGPointZero;
        this.dragState = undefined;

        if (CGPointGetLength(velocity) > 0) {
          this.ball?.physicsBody?.applyImpulse({
            dx: velocity.x,
            dy: velocity.y,
          });
        }

        this.tempMouseCatcherRect = undefined;
        break;
      }
      default:
        break;
    }
  }

  updateForScene(_currentTime: number, _scene: SKScene): void {
    this.ballPositionChanged?.();
  }
}

还将此实用函数添加到 util.ts

export function CGPointGetLength(p: CGPoint) {
  return Math.sqrt(p.x * p.x + p.y * p.y);
}

好了!现在您可以运行项目,并使用鼠标事件拖动球体!

还有两件事要做:声音和动画。添加声音很简单,添加动画会很有趣!

声音

以下是我们的操作步骤:在视图控制器中加载声音,初始化以确保它们可以播放,然后在球体击中屏幕边界时播放它们 - 我们使用 SKPhysicsContactDelegate 协议来检测碰撞。

export class ViewController
  extends NSViewController
  implements SKSceneDelegate, MouseCatcherDelegate, SKPhysicsContactDelegate
{
  static ObjCProtocols = [SKSceneDelegate, SKPhysicsContactDelegate];

  ...

  sounds = ["pop_01", "pop_02", "pop_03"].map((id) =>
    NSSound.alloc().initWithContentsOfFileByReference(
      new URL(`../assets/${id}.caf`, import.meta.url).pathname,
      true
    )
  );

  viewDidLoad() {
    super.viewDidLoad();

    this.view.addSubview(this.sceneView);
    this.sceneView.presentScene(this.scene);
    this.scene.backgroundColor = NSColor.clearColor;
    this.scene.delegate = this;
    this.scene.physicsWorld.contactDelegate = this;
    this.sceneView.allowsTransparency = true;

    this.sceneView.preferredFramesPerSecond = 120;

    for (const sound of this.sounds) {
      sound.volume = 0;
      sound.play();
    }
  }

  ...

  didBeginContact(contact: SKPhysicsContact) {
    const minImpulse = 1000;
    const maxImpulse = 2000;

    const collisionStrength = remap(
      contact.collisionImpulse,
      minImpulse,
      maxImpulse,
      0,
      0.5
    );

    if (collisionStrength <= 0) return;

    NSOperationQueue.mainQueue.addOperationWithBlock(() => {
      const sounds = this.sounds;
      const soundsUsable = sounds.filter((sound) => !sound.isPlaying);
      if (soundsUsable.length === 0) return;
      const randomSound =
        soundsUsable[Math.floor(Math.random() * soundsUsable.length)];
      randomSound.volume = collisionStrength;
      randomSound.play();
    });
  }
}

就是这样!现在运行程序,你就能听到球体与屏幕边界碰撞时的有趣音效。

动画

这一部分的实现并不像看起来那么简单,因为我们会采用不同的方式。我们会使用 npm 中的 popmotion 库来实现弹簧动画,我们将在这两个地方使用它:一是当球体被拖动时,它会稍微放大;二是当球体击中屏幕边界时,它会发生压缩。

我们只需要弹簧动画。在网页上,popmotion 会使用 requestAnimationFrame 来动画化这些值,但在这种情况下,我们没有这个功能。因此,我们会使用 CADisplayLink 来实现自己的动画循环。你可以在 popmotion 文档 中了解更多关于驱动程序的信息。

本质上,驱动程序是一个函数,它接受一个回调函数,该回调函数在每帧/滴答时调用,它返回一个停止动画的函数。首先,让我们实现驱动程序,然后实现动画函数。

motion.ts:

import "@nativescript/macos-node-api";
import { Driver } from "popmotion";

export class CALayerDriver extends NSObject {
  static ObjCExposedMethods = {
    tick: { returns: interop.types.void, params: [] },
  };

  static {
    NativeClass(this);
  }

  displayLink?: CADisplayLink;
  tickers = new Set<(timestamp: number) => void>();
  prevTick?: number;

  tick() {
    if (!this.displayLink) {
      throw new Error("Display link is not initialized and tick was called");
    }

    const timestamp = performance.now();
    const delta = this.prevTick ? timestamp - this.prevTick : 0;
    this.prevTick = timestamp;

    for (const ticker of this.tickers) {
      ticker(delta);
    }
  }

  static instance = CALayerDriver.new();

  static driver: Driver = (update) => {
    return {
      start: () => {
        this.instance.tickers.add(update);

        if (this.instance.tickers.size === 1) {
          this.start();
        }
      },

      stop: () => {
        if (!this.instance.tickers.delete(update)) {
          return;
        }

        if (this.instance.tickers.size === 0) {
          this.stop();
        }
      },
    };
  };

  static start() {
    if (this.instance.displayLink) {
      return;
    }

    this.instance.displayLink =
      NSScreen.mainScreen.displayLinkWithTargetSelector(this.instance, "tick");

    this.instance.displayLink.addToRunLoopForMode(
      NSRunLoop.currentRunLoop,
      NSDefaultRunLoopMode
    );

    this.instance.displayLink.preferredFrameRateRange = {
      minimum: 90,
      maximum: 120,
      preferred: 120,
    };

    this.instance.prevTick = performance.now();
  }

  static stop() {
    if (!this.instance.displayLink) {
      return;
    }

    this.instance.displayLink.invalidate();
    this.instance.displayLink = undefined;
  }
}

现在让我们实现弹簧动画。

motion.ts:

export class SpringParams {
  static passiveEase = new SpringParams(0.35, 0.85);

  constructor(
    public response: number,
    public dampingRatio: number,
    public epsilon = 0.01
  ) {}
}

export interface Sample {
  time: number;
  value: number;
}

export class VelocityTracker {
  samples: Sample[] = [];

  addSample(val: number) {
    this.samples.push({ time: CACurrentMediaTime(), value: val });
    this.trim();
  }

  get velocity() {
    this.trim();
    if (this.samples[0] && this.samples[this.samples.length - 1]) {
      const timeDelta = CACurrentMediaTime() - this.samples[0].time;
      const distDelta =
        this.samples[this.samples.length - 1].value - this.samples[0].value;
      if (timeDelta > 0) {
        return distDelta / timeDelta;
      }
    }
    return 0;
  }

  lookBack = 1.0 / 15;

  trim() {
    const now = CACurrentMediaTime();
    while (
      this.samples.length > 0 &&
      now - this.samples[0].time > this.lookBack
    ) {
      this.samples.shift();
    }
  }
}

export class SpringAnimation {
  animating = false;

  externallySetVelocityTracker = new VelocityTracker();

  onChange?: (value: number) => void;

  _value = 0;

  get value() {
    return this._value;
  }

  set value(value) {
    this._value = value;
    this.stop();
    this.externallySetVelocityTracker.addSample(value);
  }

  targetValue?: number;
  _velocity?: number;

  stopFunction?: () => void;

  get velocity() {
    return this.animating
      ? this._velocity ?? 0
      : this.externallySetVelocityTracker.velocity;
  }

  constructor(
    initialValue: number,
    public scale: number,
    public params: SpringParams = SpringParams.passiveEase
  ) {
    this.value = initialValue;
  }

  start(targetValue: number, velocity: number) {
    this.stop();
    this.animating = true;
    this.targetValue = targetValue;
    this._velocity = velocity;

    this.stopFunction = animate({
      type: "spring",

      from: this.value * this.scale,
      to: this.targetValue * this.scale,

      velocity: this.velocity * this.scale,
      stiffness: Math.pow((2 * Math.PI) / this.params.response, 2),
      damping: (4 * Math.PI * this.params.dampingRatio) / this.params.response,
      restDelta: this.params.epsilon,

      driver: CALayerDriver.driver,

      onUpdate: (value) => {
        this._value = value / this.scale;
        this.onChange?.(this.value);
      },

      onComplete: () => {
        this.stopFunction = undefined;
        this.stop();
      },
    }).stop;
  }

  stop() {
    this.targetValue = undefined;
    this.animating = false;
    this.stopFunction?.();
  }
}

为了使用这个动画类,让我们将其添加到球体类中。

ball.ts:

export class Ball extends SKNode {
  ...

  dragScale = new SpringAnimation(1, 1000, new SpringParams(0.2, 0.8));
  squish = new SpringAnimation(1, 1000, new SpringParams(0.3, 0.5));

  ...

  _beingDragged = false;

  animateDrag(beingDragged: boolean) {
    const old = this._beingDragged;
    this._beingDragged = beingDragged;

    if (old === beingDragged) {
      return;
    }

    this.dragScale.start(beingDragged ? 1.05 : 1, this.dragScale.velocity);
  }

  ...

  update() {
    this.shadowSprite.position = {
      x: 0,
      y: this.radius * 0.3 - this.position.y,
    };

    const distFromBottom = this.position.y - this.radius;
    this.shadowSprite.alpha = remap(distFromBottom, 0, 200, 1, 0);

    const yDelta = (-(1 - this.imgContainer.xScale) * this.radius) / 2;
    this.imgContainer.position = { x: 0, y: yDelta };

    this.imgContainer.xScale = this.squish.value;
    this.imgNode.setScale(this.dragScale.value);
  }

  ...

  didCollide(strength: number, normal: CGVector) {
    const angle = Math.atan2(normal.dy, normal.dx);
    this.imgContainer.zRotation = angle;
    this.imgNode.zRotation = -angle;

    const targetScale = remap(strength, 0, 1, 1, 0.8);
    const velocity = remap(strength, 0, 1, -5, -10);
    this.squish.start(targetScale, velocity);

    NSTimer.scheduledTimerWithTimeIntervalRepeatsBlock(0.01, false, () => {
      this.squish.start(1, velocity);
    });
  }
}

用于动画化压缩和拖动的函数已经完成,但我们需要从视图控制器中调用它们。

view_controller.ts:


export class ViewController
  extends NSViewController
  implements SKSceneDelegate, MouseCatcherDelegate, SKPhysicsContactDelegate
{
  ...

  set dragState(value) {
    ...

    this.ball?.animateDrag(!!value);
  }

  ...

  didBeginContact(contact: SKPhysicsContact) {
    ...

    this.ball?.didCollide(collisionStrength, contact.contactNormal);

    ...
  }
}

关于动画部分就到这里了:现在运行项目,你会看到球体在被拖动时如何放大/缩小,以及在发生碰撞时如何压缩!

到目前为止,这个项目已经完成了与本文开头提到的 Nate Parrott 的原始项目相同的功能。

向无限的未来致敬

目前,新运行时支持 C/Objective-C 绑定,因此 iOS、macOS 和其他 Apple 相关的平台都得到支持。Android 支持正在开发中。

如果你想更深入地了解并加入这个激动人心的项目,请加入我们 Discord 的 #discussions 频道,我们期待与你合作!