NativeScript 灵活且提供了无限的可能性。我们一直在开发 NativeScript 运行时的另一个迭代版本,它允许它在任何实现 Node-API 的 JavaScript 引擎上运行。
从历史上看,NativeScript 一直与 V8 这里 以及 JavaScriptCore 这里 耦合,用于 iOS 运行时。
这个令人兴奋的新 运行时 可以直接插入 Node 和 Deno,可以与 Hermes 以及 QuickJS 一起使用。实际上,它可以与任何支持 Node-API 的引擎一起使用。
NativeScript 已经在 Android、iOS 和 visionOS 上运行良好,那么这里有什么新东西?
💻 桌面支持 - 现在您可以利用 macOS 上本机平台 API 的强大功能 - 框架如 AppKit、Metal 用于基于 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
标志。
让我们从创建 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 图标点击。
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 渲染球体。让我们先创建它。
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;
为了启动和停靠球体,让我们在这里定义两个方法:launch
和 dock
。它们都将接受一个 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));
}
}
}
这使得阴影根据距离底部的距离或多或少地突出显示。我们还需要在视图控制器的 launch
和 dock
方法中调用 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 频道,我们期待与你合作!