@haiyue/native 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/bridge/audio/pcm-bank.android.ts +81 -0
  4. package/bridge/audio/pcm-bank.ios.ts +80 -0
  5. package/bridge/audio/pcm-bank.ts +2 -0
  6. package/bridge/branding/assets/haiyue-moon.png +0 -0
  7. package/bridge/branding/engine-splash.ts +122 -0
  8. package/bridge/branding/launch-page.ts +28 -0
  9. package/bridge/branding/webpack.cjs +11 -0
  10. package/bridge/display/orientation-policy.ts +18 -0
  11. package/bridge/display/orientation.android.ts +31 -0
  12. package/bridge/display/orientation.ios.ts +57 -0
  13. package/bridge/display/orientation.ts +2 -0
  14. package/bridge/feedback/haptics.android.ts +31 -0
  15. package/bridge/feedback/haptics.ios.ts +35 -0
  16. package/bridge/feedback/haptics.ts +2 -0
  17. package/bridge/files/read-bytes.ts +18 -0
  18. package/bridge/input/native-touch.android.ts +55 -0
  19. package/bridge/input/native-touch.ios.ts +91 -0
  20. package/bridge/input/native-touch.ts +2 -0
  21. package/bridge/input/pointer-target.ts +106 -0
  22. package/bridge/input/touch-identity.ts +20 -0
  23. package/bridge/lifecycle/demand-frames.ts +51 -0
  24. package/bridge/lifecycle/frame-performance.ts +20 -0
  25. package/bridge/lifecycle/frame-scheduler.ts +33 -0
  26. package/bridge/lifecycle/host.ts +330 -0
  27. package/bridge/lifecycle/launch-flags.ts +6 -0
  28. package/bridge/lifecycle/presentation-pause.ts +18 -0
  29. package/bridge/lifecycle/runtime.ts +31 -0
  30. package/bridge/media/save-photo.android.ts +49 -0
  31. package/bridge/media/save-photo.ios.ts +19 -0
  32. package/bridge/media/save-photo.ts +2 -0
  33. package/bridge/motion/android-reading.ts +19 -0
  34. package/bridge/motion/device-motion.android.ts +109 -0
  35. package/bridge/motion/device-motion.ios.ts +121 -0
  36. package/bridge/motion/device-motion.ts +2 -0
  37. package/bridge/motion/motion-sample.ts +62 -0
  38. package/bridge/render/canvas-textures.ios.ts +45 -0
  39. package/bridge/render/canvas-textures.ts +2 -0
  40. package/bridge/render/device-descriptor.ts +8 -0
  41. package/bridge/render/frame-capture.android.ts +12 -0
  42. package/bridge/render/frame-capture.ios.ts +20 -0
  43. package/bridge/render/frame-capture.ts +2 -0
  44. package/bridge/render/queue-fence.android.ts +39 -0
  45. package/bridge/render/queue-fence.ts +1 -0
  46. package/bridge/render/surface.ts +153 -0
  47. package/bridge/render/view-capture.android.ts +11 -0
  48. package/bridge/render/view-capture.ios.ts +17 -0
  49. package/bridge/render/view-capture.ts +1 -0
  50. package/bridge/render/view-rect.android.ts +15 -0
  51. package/bridge/render/view-rect.ios.ts +12 -0
  52. package/bridge/render/view-rect.ts +2 -0
  53. package/bridge/render/webgpu-constants.ts +5 -0
  54. package/bridge/rewards/admob.ts +80 -0
  55. package/bridge/rewards/controller.ts +163 -0
  56. package/bridge/rewards/native/android/org/haiyue/rewards/HYRewardedAds.java +116 -0
  57. package/bridge/rewards/native/ios/HYRewardedAds.swift +169 -0
  58. package/bridge/storage/clone-runtime.ts +6 -0
  59. package/bridge/storage/settings-storage.ts +12 -0
  60. package/index.ts +17 -0
  61. package/package.json +87 -0
  62. package/provenance.json +66 -0
@@ -0,0 +1,121 @@
1
+ import { Application } from '@nativescript/core';
2
+ import { createMotionSample, validateMotionInterval, validateScreenRotation,
3
+ type NativeMotionSample, type MotionScreenRotation, type MotionReading } from './motion-sample';
4
+ export type { NativeMotionSample, MotionScreenRotation } from './motion-sample';
5
+ // NativeScript's default iOS typings omit CoreMotion. Keep this binding limited to the public SDK methods used here.
6
+ interface CoreMotionManager {
7
+ readonly gyroAvailable: boolean;
8
+ readonly deviceMotionAvailable: boolean;
9
+ readonly deviceMotionActive: boolean;
10
+ readonly deviceMotion: MotionReading | null;
11
+ deviceMotionUpdateInterval: number;
12
+ startDeviceMotionUpdatesUsingReferenceFrame(frame: number): void;
13
+ stopDeviceMotionUpdates(): void;
14
+ }
15
+ declare const CMMotionManager: {
16
+ alloc(): { init(): CoreMotionManager };
17
+ availableAttitudeReferenceFrames(): number;
18
+ };
19
+ const X_ARBITRARY_Z_VERTICAL = 1; // CMAttitudeReferenceFrameXArbitraryZVertical in CoreMotion.framework.
20
+ export interface NativeDeviceMotionOptions {
21
+ /** Requested sensor interval, 5–1000 ms. Actual hardware rate may differ. */
22
+ updateIntervalMs?: number;
23
+ screenRotation?: MotionScreenRotation;
24
+ }
25
+ let manager: CoreMotionManager | null = null;
26
+ let owner: NativeDeviceMotion | null = null;
27
+ /** One app-owned controller; poll from the existing Engine frame, never add another frame loop. */
28
+ export class NativeDeviceMotion {
29
+ private readonly motion: CoreMotionManager;
30
+ private readonly listeners = new Set<(sample: NativeMotionSample) => void>();
31
+ private interval: number;
32
+ private rotation: MotionScreenRotation;
33
+ private requested = false;
34
+ private running = false;
35
+ private suspended = false;
36
+ private disposed = false;
37
+ private lastTimestamp: number | null = null;
38
+ private timestampFloor = -Infinity;
39
+ private sample: NativeMotionSample | null = null;
40
+ constructor(options: NativeDeviceMotionOptions = {}) {
41
+ this.interval = validateMotionInterval(options.updateIntervalMs ?? 1000 / 60);
42
+ this.rotation = validateScreenRotation(options.screenRotation ?? 0);
43
+ if (owner) throw new Error('Only one NativeDeviceMotion controller may exist per app.');
44
+ if (typeof CMMotionManager === 'undefined') throw new Error('Core Motion is unavailable on this platform.');
45
+ manager ??= CMMotionManager.alloc().init();
46
+ this.motion = manager; owner = this;
47
+ this.suspended = !!(Application.inBackground || Application.suspended);
48
+ Application.on(Application.suspendEvent, this.suspend);
49
+ Application.on(Application.resumeEvent, this.resume);
50
+ Application.on(Application.exitEvent, this.dispose);
51
+ }
52
+ get available(): boolean { return this.motion.gyroAvailable && this.motion.deviceMotionAvailable; }
53
+ get active(): boolean { return this.running && this.motion.deviceMotionActive; }
54
+ get latest(): NativeMotionSample | null { return this.sample; }
55
+ onUpdate(listener: (sample: NativeMotionSample) => void): () => void {
56
+ this.assertLive(); this.listeners.add(listener);
57
+ return () => { this.listeners.delete(listener); };
58
+ }
59
+ /** False means the physical gyro/device-motion service is unavailable (e.g. simulator). */
60
+ start(): boolean {
61
+ this.assertLive();
62
+ if (!this.available) return false;
63
+ this.requested = true;
64
+ if (!this.suspended) this.begin();
65
+ return true;
66
+ }
67
+ stop(): void { this.requested = false; this.end(); }
68
+ setUpdateInterval(ms: number): void {
69
+ this.assertLive(); this.interval = validateMotionInterval(ms);
70
+ if (this.running) this.motion.deviceMotionUpdateInterval = this.interval / 1000;
71
+ }
72
+ setScreenRotation(rotation: MotionScreenRotation): void {
73
+ this.assertLive(); this.rotation = validateScreenRotation(rotation);
74
+ this.sample = null;
75
+ }
76
+ /** Call once per Engine update. No event is emitted until a new sensor timestamp arrives. */
77
+ update(deltaMs: number): NativeMotionSample | null {
78
+ if (!Number.isFinite(deltaMs) || deltaMs < 0) throw new RangeError('Motion deltaMs must be finite and non-negative.');
79
+ if (this.disposed || !this.running || this.suspended) return null;
80
+ const reading = this.motion.deviceMotion;
81
+ if (!reading || reading.timestamp <= this.timestampFloor || (this.lastTimestamp !== null && reading.timestamp <= this.lastTimestamp)) return null;
82
+ const sample = createMotionSample(reading, deltaMs,
83
+ this.lastTimestamp === null ? 0 : (reading.timestamp - this.lastTimestamp) * 1000, this.rotation);
84
+ this.lastTimestamp = reading.timestamp; this.sample = sample;
85
+ for (const listener of [...this.listeners]) {
86
+ if (!this.running || this.disposed) break;
87
+ if (this.listeners.has(listener)) listener(sample);
88
+ }
89
+ return sample;
90
+ }
91
+ readonly suspend = (): void => { this.suspended = true; this.end(); };
92
+ readonly resume = (): void => {
93
+ if (this.disposed) return;
94
+ this.suspended = false;
95
+ if (this.requested) this.begin();
96
+ };
97
+ readonly dispose = (): void => {
98
+ if (this.disposed) return;
99
+ this.stop(); this.disposed = true; this.listeners.clear();
100
+ Application.off(Application.suspendEvent, this.suspend);
101
+ Application.off(Application.resumeEvent, this.resume);
102
+ Application.off(Application.exitEvent, this.dispose);
103
+ if (owner === this) owner = null;
104
+ };
105
+ private begin(): void {
106
+ if (this.running) return;
107
+ this.timestampFloor = this.motion.deviceMotion?.timestamp ?? -Infinity;
108
+ try {
109
+ if (!(CMMotionManager.availableAttitudeReferenceFrames() & X_ARBITRARY_Z_VERTICAL))
110
+ throw new Error('The gravity-aligned attitude reference frame is unavailable.');
111
+ this.motion.deviceMotionUpdateInterval = this.interval / 1000;
112
+ this.motion.startDeviceMotionUpdatesUsingReferenceFrame(X_ARBITRARY_Z_VERTICAL);
113
+ } catch (error) { this.requested = false; this.motion.stopDeviceMotionUpdates(); this.end(); throw error; }
114
+ this.running = true;
115
+ }
116
+ private end(): void {
117
+ if (this.running) this.motion.stopDeviceMotionUpdates();
118
+ this.running = false; this.lastTimestamp = null; this.sample = null;
119
+ }
120
+ private assertLive(): void { if (this.disposed) throw new Error('NativeDeviceMotion is disposed.'); }
121
+ }
@@ -0,0 +1,2 @@
1
+ // NativeScript resolves .android/.ios; this fallback supplies the common TypeScript API.
2
+ export * from './device-motion.ios';
@@ -0,0 +1,62 @@
1
+ export interface MotionVector { readonly x: number; readonly y: number; readonly z: number }
2
+ export interface MotionAngles { readonly pitch: number; readonly roll: number; readonly yaw: number }
3
+ export interface MotionQuaternion extends MotionVector { readonly w: number }
4
+ /** Screen rotation from the device's reference axes (iOS portrait; Android natural orientation). */
5
+ export type MotionScreenRotation = 0 | 90 | 180 | 270;
6
+ export interface MotionReading {
7
+ readonly timestamp: number;
8
+ readonly attitude: MotionAngles & { readonly quaternion: MotionQuaternion };
9
+ readonly rotationRate: MotionVector;
10
+ readonly gravity: MotionVector;
11
+ readonly userAcceleration: MotionVector;
12
+ }
13
+ export interface NativeMotionSample {
14
+ readonly timestampMs: number;
15
+ readonly deltaMs: number;
16
+ readonly sensorDeltaMs: number;
17
+ readonly angles: MotionAngles;
18
+ readonly radians: MotionAngles;
19
+ readonly quaternion: MotionQuaternion;
20
+ /** Device axes, radians/second. */
21
+ readonly rotationRate: MotionVector;
22
+ /** Device axes, units of g. */
23
+ readonly gravity: MotionVector;
24
+ readonly userAcceleration: MotionVector;
25
+ readonly screenRotation: MotionScreenRotation;
26
+ /** Degrees: positive right/forward means the screen's right/top edge is lower. */
27
+ readonly tilt: { readonly right: number; readonly forward: number; readonly total: number };
28
+ }
29
+ const DEGREES = 180 / Math.PI;
30
+ export function validateMotionInterval(ms: number): number {
31
+ if (!Number.isFinite(ms) || ms < 5 || ms > 1000) throw new RangeError('Motion updateIntervalMs must be between 5 and 1000.');
32
+ return ms;
33
+ }
34
+ export function validateScreenRotation(rotation: number): MotionScreenRotation {
35
+ if (![0, 90, 180, 270].includes(rotation)) throw new RangeError('Motion screenRotation must be 0, 90, 180 or 270.');
36
+ return rotation as MotionScreenRotation;
37
+ }
38
+ /** Copy native structs; snapshots never retain mutable native sensor objects. */
39
+ export function createMotionSample(raw: MotionReading, deltaMs: number, sensorDeltaMs: number, rotation: MotionScreenRotation): NativeMotionSample {
40
+ validateScreenRotation(rotation);
41
+ const vector = (v: MotionVector) => Object.freeze({ x: v.x, y: v.y, z: v.z });
42
+ const { pitch, roll, yaw, quaternion: q } = raw.attitude;
43
+ const gravity = vector(raw.gravity), rate = vector(raw.rotationRate), acceleration = vector(raw.userAcceleration);
44
+ const quaternion = Object.freeze({ x: q.x, y: q.y, z: q.z, w: q.w });
45
+ const numbers = [raw.timestamp, deltaMs, sensorDeltaMs, pitch, roll, yaw, ...Object.values(quaternion),
46
+ ...Object.values(gravity), ...Object.values(rate), ...Object.values(acceleration)];
47
+ if (numbers.some(n => !Number.isFinite(n)) || raw.timestamp < 0 || deltaMs < 0 || sensorDeltaMs < 0)
48
+ throw new RangeError('Motion samples must contain finite values and non-negative times.');
49
+ const length = Math.hypot(gravity.x, gravity.y, gravity.z);
50
+ if (length < 1e-8) throw new RangeError('Motion gravity vector is unavailable.');
51
+ const theta = rotation / DEGREES, c = Math.cos(theta), s = Math.sin(theta);
52
+ const x = c * gravity.x - s * gravity.y, y = s * gravity.x + c * gravity.y;
53
+ return Object.freeze({ timestampMs: raw.timestamp * 1000, deltaMs, sensorDeltaMs,
54
+ angles: Object.freeze({ pitch: pitch * DEGREES, roll: roll * DEGREES, yaw: yaw * DEGREES }),
55
+ radians: Object.freeze({ pitch, roll, yaw }), quaternion, rotationRate: rate, gravity, userAcceleration: acceleration,
56
+ screenRotation: rotation, tilt: Object.freeze({
57
+ right: Math.atan2(x, Math.hypot(y, gravity.z)) * DEGREES,
58
+ forward: Math.atan2(y, Math.hypot(x, gravity.z)) * DEGREES,
59
+ total: Math.acos(Math.max(-1, Math.min(1, -gravity.z / length))) * DEGREES,
60
+ }),
61
+ });
62
+ }
@@ -0,0 +1,45 @@
1
+ import { Canvas } from '@nativescript/canvas';
2
+ /** Canvas 2D rasterization with explicit RGBA upload; game rendering remains WebGPU. */
3
+ export class NativeCanvasTextures {
4
+ private readonly textures = new Map<string, { texture: GPUTexture; width: number; height: number }>();
5
+ private canvasCreations = 0;
6
+ private uploads = 0;
7
+ constructor(private readonly device: GPUDevice, private readonly format: 'rgba8unorm' | 'rgba8unorm-srgb' = 'rgba8unorm') {}
8
+ readonly createCanvas2D = (width: number, height: number): HTMLCanvasElement => {
9
+ const canvas = new Canvas();
10
+ this.canvasCreations++;
11
+ canvas.width = width;
12
+ canvas.height = height;
13
+ // These offscreen canvases are always read back immediately for a WebGPU
14
+ // upload. CPU rasterization avoids Android GL readback artifacts and prevents
15
+ // transient iOS text/hint canvases from accumulating heavyweight Metal contexts.
16
+ canvas.getContext('2d', { willReadFrequently: true })?.clearRect(0, 0, width, height);
17
+ return canvas as unknown as HTMLCanvasElement;
18
+ };
19
+ readonly readAtlasPixels = (canvas: HTMLCanvasElement): Uint8Array => {
20
+ const context = canvas.getContext('2d');
21
+ if (!context) throw new Error('Native Canvas 2D context is unavailable.');
22
+ const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
23
+ return new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength);
24
+ };
25
+ readonly textureFromCanvas = (canvas: HTMLCanvasElement, key: string): GPUTexture => {
26
+ const { width, height } = canvas;
27
+ const context = canvas.getContext('2d');
28
+ if (!context) throw new Error('Native Canvas 2D context is unavailable.');
29
+ const pixels = context.getImageData(0, 0, width, height).data;
30
+ let entry = this.textures.get(key);
31
+ if (!entry || entry.width !== width || entry.height !== height) {
32
+ if (entry) {
33
+ const old = entry.texture;
34
+ void this.device.queue.onSubmittedWorkDone().then(() => old.destroy());
35
+ }
36
+ entry = { width, height, texture: this.device.createTexture({ label: `native-game:${key}`, size: [width, height], format: this.format, usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST }) };
37
+ this.textures.set(key, entry);
38
+ }
39
+ this.device.queue.writeTexture({ texture: entry.texture }, new Uint8Array(pixels.buffer, pixels.byteOffset, pixels.byteLength), { bytesPerRow: width * 4 }, [width, height]);
40
+ this.uploads++;
41
+ return entry.texture;
42
+ };
43
+ snapshot() { return { canvasesCreated: this.canvasCreations, uploads: this.uploads, textures: this.textures.size, bytes: [...this.textures.values()].reduce((sum, item) => sum + item.width * item.height * 4, 0) }; }
44
+ dispose(): void { for (const item of this.textures.values()) item.texture.destroy(); this.textures.clear(); }
45
+ }
@@ -0,0 +1,2 @@
1
+ // The Canvas implementation handles both Metal and Vulkan.
2
+ export * from './canvas-textures.ios';
@@ -0,0 +1,8 @@
1
+ /** Canvas mutates its input descriptor; never pass Engine's frozen values through. */
2
+ export function copyDeviceDescriptor(descriptor?: GPUDeviceDescriptor): GPUDeviceDescriptor & { requiredFeatures: GPUFeatureName[] } {
3
+ return {
4
+ ...descriptor,
5
+ requiredFeatures: Array.from(descriptor?.requiredFeatures ?? []),
6
+ ...(descriptor?.requiredLimits ? { requiredLimits: { ...descriptor.requiredLimits } } : {}),
7
+ };
8
+ }
@@ -0,0 +1,12 @@
1
+ import { knownFolders, path } from '@nativescript/core';
2
+ import type { Canvas } from '@nativescript/canvas';
3
+ import { nativeLaunchFlag } from '../lifecycle/launch-flags';
4
+ export function isFrameCaptureRequested(): boolean { return nativeLaunchFlag('G02_CAPTURE_FRAME'); }
5
+ export function captureSurfaceFrame(view: Canvas, file = 'g02-clear-frame.png'): { file: string; bytes: number } {
6
+ const url: unknown = view.toDataURL('image/png'), prefix = 'data:image/png;base64,';
7
+ if (typeof url !== 'string' || !url.startsWith(prefix)) throw new Error('Native WebGPU PNG readback returned no PNG.');
8
+ const bytes = android.util.Base64.decode(url.slice(prefix.length), android.util.Base64.DEFAULT);
9
+ const stream = new java.io.FileOutputStream(path.join(knownFolders.documents().path, file));
10
+ try { stream.write(bytes); } finally { stream.close(); }
11
+ return { file, bytes: bytes.length };
12
+ }
@@ -0,0 +1,20 @@
1
+ import { File, knownFolders, path } from '@nativescript/core';
2
+ import type { Canvas } from '@nativescript/canvas';
3
+
4
+ /** Opt-in device validation; normal launches do not capture or write images. */
5
+ export function isFrameCaptureRequested(): boolean {
6
+ return NSProcessInfo.processInfo.environment.objectForKey('G02_CAPTURE_FRAME') === '1';
7
+ }
8
+
9
+ export function captureSurfaceFrame(view: Canvas, file = 'g02-clear-frame.png'): { file: string; bytes: number } {
10
+ // Canvas routes this to its native WebGPU readback, while the submitted
11
+ // current texture is still acquired and before present releases its handle.
12
+ const url: unknown = view.toDataURL('image/png');
13
+ const prefix = 'data:image/png;base64,';
14
+ if (typeof url !== 'string' || !url.startsWith(prefix)) throw new Error('Native WebGPU PNG readback returned no PNG.');
15
+ // Foundation's zero option means strict decoding; the generated enum omits it.
16
+ const data = NSData.alloc().initWithBase64EncodedStringOptions(url.slice(prefix.length), 0 as NSDataBase64DecodingOptions);
17
+ if (!data?.length) throw new Error('Native WebGPU PNG readback is empty.');
18
+ File.fromPath(path.join(knownFolders.documents().path, file)).writeSync(data, error => { throw error; });
19
+ return { file, bytes: data.length };
20
+ }
@@ -0,0 +1,2 @@
1
+ // TypeScript fallback; NativeScript resolves the platform suffix at bundle time.
2
+ export * from './frame-capture.ios';
@@ -0,0 +1,39 @@
1
+ /** Canvas 2.1.18's Android onSubmittedWorkDone callback crashes in JNI.
2
+ * A mapped readback after a queue submission supplies a real completion fence.
3
+ * Calls in the same JS turn share a fence; completed buffers are reused.
4
+ */
5
+ export function installQueueFence(device: GPUDevice): void {
6
+ const queue = device.queue;
7
+ const idle: GPUBuffer[] = [];
8
+ let scheduled: Promise<undefined> | null = null;
9
+ let destroyed = false;
10
+ const destroy = device.destroy.bind(device);
11
+ device.destroy = () => {
12
+ destroyed = true;
13
+ for (const buffer of idle) buffer.destroy();
14
+ idle.length = 0;
15
+ destroy();
16
+ };
17
+ queue.onSubmittedWorkDone = () => {
18
+ if (scheduled) return scheduled;
19
+ scheduled = Promise.resolve().then(async () => {
20
+ scheduled = null;
21
+ if (destroyed) throw new Error('GPU device is destroyed.');
22
+ const buffer = idle.pop() ?? device.createBuffer({ label: 'android-queue-fence', size: 4, usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST });
23
+ let reusable = false;
24
+ try {
25
+ const encoder = device.createCommandEncoder({ label: 'android-queue-fence' });
26
+ encoder.clearBuffer(buffer, 0, 4);
27
+ queue.submit([encoder.finish()]);
28
+ await buffer.mapAsync(GPUMapMode.READ, 0, 4);
29
+ buffer.unmap();
30
+ reusable = true;
31
+ } finally {
32
+ if (reusable && !destroyed && idle.length < 3) idle.push(buffer);
33
+ else buffer.destroy();
34
+ }
35
+ return undefined;
36
+ });
37
+ return scheduled;
38
+ };
39
+ }
@@ -0,0 +1 @@
1
+ export function installQueueFence(_device: GPUDevice): void {}
@@ -0,0 +1,153 @@
1
+ import { Screen, isAndroid } from '@nativescript/core';
2
+ import { Canvas, GPU, GPUAdapter, GPUCanvasContext } from '@nativescript/canvas';
3
+ import type { HaiyueEngine } from '@haiyue/engine';
4
+ import { nativeViewRect, nativeViewSize } from './view-rect';
5
+ import { copyDeviceDescriptor } from './device-descriptor';
6
+ import { installNativeWebGpuConstants } from './webgpu-constants';
7
+ import { installQueueFence } from './queue-fence';
8
+
9
+ type EngineOptions = ConstructorParameters<typeof HaiyueEngine>[0];
10
+ export type NativeCanvasInput = Pick<HTMLCanvasElement, 'addEventListener' | 'removeEventListener' | 'setPointerCapture' | 'releasePointerCapture'>;
11
+
12
+ /** Only a missing swapchain image is retryable; validation/device errors are not. */
13
+ export class NativeSurfaceUnavailableError extends Error {
14
+ constructor() { super('Native surface returned an empty current texture.'); this.name = 'NativeSurfaceUnavailableError'; }
15
+ }
16
+
17
+ export class NativeSurface {
18
+ readonly pixelRatio = Math.min(Screen.mainScreen.scale, 2);
19
+ readonly gpu = new GPU();
20
+ private adapter: GPUAdapter | null = null;
21
+ private context: GPUCanvasContext | null = null;
22
+ private acquired: GPUTexture | null = null;
23
+ private configuration: GPUCanvasConfiguration | null = null;
24
+ private measuredSize: ReturnType<typeof nativeViewSize> | null = null;
25
+ presentedFrames = 0;
26
+
27
+ constructor(readonly view: Canvas, private readonly report: (event: string, detail: unknown) => void, private readonly input?: NativeCanvasInput) {
28
+ // Canvas.getContext('webgpu') uses this entry point internally, even when
29
+ // Engine receives its own injected provider. Use the same native GPU.
30
+ installNativeWebGpuConstants();
31
+ const navigatorObject = globalThis.navigator ?? {};
32
+ if (!globalThis.navigator) Object.defineProperty(globalThis, 'navigator', { value: navigatorObject, configurable: true });
33
+ Object.defineProperty(navigatorObject, 'gpu', { value: this.gpu, configurable: true });
34
+ }
35
+
36
+ readonly provider = {
37
+ requestAdapter: async (options?: GPURequestAdapterOptions) => {
38
+ if (options?.featureLevel && options.featureLevel !== 'core' && options.featureLevel !== 'compatibility') {
39
+ throw new Error(`Unsupported WebGPU feature level: ${options.featureLevel}`);
40
+ }
41
+ this.adapter = await this.gpu.requestAdapter({ powerPreference: options?.powerPreference, isFallbackAdapter: options?.forceFallbackAdapter, featureLevel: options?.featureLevel as 'core' | 'compatibility' | undefined });
42
+ if (!this.adapter) throw new Error('Native WebGPU returned no adapter.');
43
+ const info = await this.adapter.requestAdapterInfo();
44
+ this.report('adapter', { vendor: info?.vendor, architecture: info?.architecture, device: info?.device, description: info?.description, features: [...this.adapter.features] });
45
+ const adapter = this.adapter;
46
+ return {
47
+ features: adapter.features,
48
+ limits: adapter.limits,
49
+ isFallbackAdapter: adapter.isFallbackAdapter,
50
+ requestAdapterInfo: () => adapter.requestAdapterInfo(),
51
+ requestDevice: async (descriptor?: GPUDeviceDescriptor) => {
52
+ const device = await adapter.requestDevice(copyDeviceDescriptor(descriptor) as unknown as Parameters<GPUAdapter['requestDevice']>[0]);
53
+ installQueueFence(device as unknown as GPUDevice);
54
+ return device;
55
+ },
56
+ };
57
+ },
58
+ getPreferredCanvasFormat: () => this.gpu.getPreferredCanvasFormat(),
59
+ };
60
+
61
+ get hasLayout(): boolean {
62
+ // Host calls this on layout and resume, even while rendering is stopped.
63
+ this.measuredSize = nativeViewSize(this.view);
64
+ return this.measuredSize.width > 0 && this.measuredSize.height > 0;
65
+ }
66
+ private get size() { return this.measuredSize ??= nativeViewSize(this.view); }
67
+
68
+ engineOptions(): Pick<EngineOptions, 'canvas' | 'gpu' | 'devicePixelRatio'> {
69
+ const self = this;
70
+ const context = {
71
+ configure(options: GPUCanvasConfiguration): void {
72
+ const native = self.getContext();
73
+ if (!self.adapter) throw new Error('Cannot configure surface before adapter acquisition.');
74
+ const caps = native.getCapabilities(self.adapter);
75
+ if (!(caps.format as readonly string[]).includes(options.format)) throw new Error(`Surface does not support ${options.format}.`);
76
+ self.report('surface-configure', { format: options.format, capabilities: caps });
77
+ native.configure(options as unknown as Parameters<GPUCanvasContext['configure']>[0]);
78
+ self.configuration = { ...options };
79
+ },
80
+ unconfigure(): void { self.release(); },
81
+ getCurrentTexture(): GPUTexture {
82
+ return self.acquire();
83
+ },
84
+ };
85
+ const canvas = {
86
+ focus: () => isAndroid ? (self.view.nativeViewProtected as android.view.View | undefined)?.requestFocus() : (self.view.nativeViewProtected as UIView | undefined)?.becomeFirstResponder(),
87
+ addEventListener: (...args: Parameters<NativeCanvasInput['addEventListener']>) => self.input?.addEventListener(...args),
88
+ removeEventListener: (...args: Parameters<NativeCanvasInput['removeEventListener']>) => self.input?.removeEventListener(...args),
89
+ setPointerCapture: (id: number) => self.input?.setPointerCapture(id),
90
+ releasePointerCapture: (id: number) => self.input?.releasePointerCapture(id),
91
+ get width() { return self.view.width; },
92
+ set width(value: number) { self.view.width = value; },
93
+ get height() { return self.view.height; },
94
+ set height(value: number) { self.view.height = value; },
95
+ get clientWidth() { return self.size.width; },
96
+ get clientHeight() { return self.size.height; },
97
+ getBoundingClientRect() { return nativeViewRect(self.view); },
98
+ getContext(type: string) { return type === 'webgpu' ? context : null; },
99
+ };
100
+ // These are the two audited structural boundaries. Engine has a browser
101
+ // canvas type, but its render path only uses the members supplied above.
102
+ return { canvas: canvas as unknown as EngineOptions['canvas'], gpu: this.provider as unknown as EngineOptions['gpu'], devicePixelRatio: this.pixelRatio };
103
+ }
104
+
105
+ private acquire(): GPUTexture {
106
+ if (this.acquired) return this.acquired;
107
+ const texture = this.getContext().getCurrentTexture();
108
+ if (!texture) { this.measuredSize = null; throw new NativeSurfaceUnavailableError(); }
109
+ return this.acquired = texture as unknown as GPUTexture;
110
+ }
111
+
112
+ /** Called before scene update so an unavailable surface cannot advance gameplay. */
113
+ beginFrame(): void { this.acquire(); }
114
+
115
+ /** Rebind the existing device after a native surface recreation or acquisition miss. */
116
+ reconfigure(): void {
117
+ this.measuredSize = null;
118
+ if (this.acquired) throw new Error('Cannot reconfigure an acquired native frame.');
119
+ if (this.configuration) this.getContext().configure(this.configuration as unknown as Parameters<GPUCanvasContext['configure']>[0]);
120
+ }
121
+
122
+ present(): boolean {
123
+ // Bound the cache to one frame; hit testing still reads current window coordinates.
124
+ this.measuredSize = null;
125
+ if (!this.acquired) return false;
126
+ this.getContext().presentSurface();
127
+ this.acquired = null;
128
+ this.presentedFrames++;
129
+ return true;
130
+ }
131
+
132
+ /** Finish an interrupted acquired frame before releasing the surface. */
133
+ release(): void {
134
+ this.measuredSize = null;
135
+ try {
136
+ if (this.acquired) {
137
+ this.acquired = null;
138
+ // Canvas releases its per-frame native texture/view handles at present.
139
+ // An interrupted frame is never counted as a successful Engine frame.
140
+ this.context?.presentSurface();
141
+ }
142
+ } finally {
143
+ this.configuration = null;
144
+ this.context?.unconfigure();
145
+ }
146
+ }
147
+
148
+ private getContext(): GPUCanvasContext {
149
+ if (!this.context) this.context = this.view.getContext('webgpu');
150
+ if (!this.context) throw new Error('Native Canvas WebGPU context is unavailable.');
151
+ return this.context;
152
+ }
153
+ }
@@ -0,0 +1,11 @@
1
+ import { ImageSource, knownFolders, path, type View } from '@nativescript/core';
2
+
3
+ export function captureNativeView(view: View, filename: string): void {
4
+ const native = view.nativeViewProtected as android.view.View;
5
+ if (!native || native.getWidth() <= 0 || native.getHeight() <= 0) throw new Error('View is not laid out.');
6
+ const bitmap = android.graphics.Bitmap.createBitmap(native.getWidth(), native.getHeight(), android.graphics.Bitmap.Config.ARGB_8888);
7
+ try {
8
+ native.draw(new android.graphics.Canvas(bitmap));
9
+ if (!new ImageSource(bitmap).saveToFile(path.join(knownFolders.documents().path, filename), 'png')) throw new Error('View capture failed.');
10
+ } finally { bitmap.recycle(); }
11
+ }
@@ -0,0 +1,17 @@
1
+ import { File, knownFolders, path, type View } from '@nativescript/core';
2
+
3
+ /** Diagnostic-only capture of native overlays, which WebGPU readback excludes. */
4
+ export function captureNativeView(view: View, filename: string): void {
5
+ const native = view.nativeViewProtected as UIView;
6
+ if (!native || native.bounds.size.width <= 0 || native.bounds.size.height <= 0) throw new Error('View is not laid out.');
7
+ UIGraphicsBeginImageContextWithOptions(native.bounds.size, false, 2);
8
+ try {
9
+ const context = UIGraphicsGetCurrentContext();
10
+ if (!context) throw new Error('View capture context unavailable.');
11
+ native.layer.renderInContext(context);
12
+ const image = UIGraphicsGetImageFromCurrentImageContext();
13
+ const data = image && UIImagePNGRepresentation(image);
14
+ if (!data) throw new Error('View capture returned no image.');
15
+ File.fromPath(path.join(knownFolders.documents().path, filename)).writeSync(data, error => { throw error; });
16
+ } finally { UIGraphicsEndImageContext(); }
17
+ }
@@ -0,0 +1 @@
1
+ export * from './view-capture.ios';
@@ -0,0 +1,15 @@
1
+ import { Screen } from '@nativescript/core';
2
+ import type { Canvas } from '@nativescript/canvas';
3
+ /** Drawing and touch bounds share Android density-independent pixels. */
4
+ export function nativeViewSize(view: Canvas) {
5
+ const native = view.nativeViewProtected as android.view.View | undefined;
6
+ const scale = Screen.mainScreen.scale;
7
+ return { width: native ? native.getWidth() / scale : view.clientWidth,
8
+ height: native ? native.getHeight() / scale : view.clientHeight };
9
+ }
10
+ export function nativeViewRect(view: Canvas) {
11
+ const origin = view.getLocationInWindow();
12
+ const x = origin?.x ?? 0, y = origin?.y ?? 0;
13
+ const { width, height } = nativeViewSize(view);
14
+ return { x, y, width, height, left: x, top: y, right: x + width, bottom: y + height };
15
+ }
@@ -0,0 +1,12 @@
1
+ import type { Canvas } from '@nativescript/canvas';
2
+ /** Actual UIKit drawing bounds, which may extend beyond NativeScript's safe-area measurement. */
3
+ export function nativeViewSize(view: Canvas) {
4
+ const bounds = (view.nativeViewProtected as UIView | undefined)?.bounds;
5
+ return { width: bounds?.size.width ?? view.clientWidth, height: bounds?.size.height ?? view.clientHeight };
6
+ }
7
+ export function nativeViewRect(view: Canvas) {
8
+ const origin = view.getLocationInWindow();
9
+ const x = origin?.x ?? 0, y = origin?.y ?? 0;
10
+ const { width, height } = nativeViewSize(view);
11
+ return { x, y, width, height, left: x, top: y, right: x + width, bottom: y + height };
12
+ }
@@ -0,0 +1,2 @@
1
+ // TypeScript fallback; NativeScript resolves the platform suffix at bundle time.
2
+ export * from './view-rect.ios';
@@ -0,0 +1,5 @@
1
+ /** Canvas 2.1.x omits GPUColorWrite; expose the standard WebGPU bitmask once. */
2
+ export function installNativeWebGpuConstants(target: object = globalThis): void {
3
+ if ('GPUColorWrite' in target) return;
4
+ Object.defineProperty(target, 'GPUColorWrite', { configurable: true, value: Object.freeze({ RED: 1, GREEN: 2, BLUE: 4, ALPHA: 8, ALL: 15 }) });
5
+ }
@@ -0,0 +1,80 @@
1
+ import { Application, Connectivity, isAndroid, isIOS } from '@nativescript/core';
2
+ import type { RewardGateway, RewardPresentation } from './controller';
3
+
4
+ declare const HYRewardedAds: { new(): { privacyRequired: boolean; consentRequired: boolean; continuePresentation(ready: boolean): void; performUnitEvents(action: string, unit: string, events: (event: string) => void): void; dispose(): void } };
5
+ declare const org: any;
6
+ /** Platform adapter is lazy: paid users and players who never opt in make no ad requests. */
7
+ export class AdMobRewardGateway implements RewardGateway {
8
+ private native: any;
9
+ private disposed = false;
10
+ private initialization?: Promise<void>;
11
+ constructor(private readonly config: { iosUnit: string; androidUnit: string; development: boolean }) {}
12
+ private getNative(): any {
13
+ return this.native ??= isAndroid ? new org.haiyue.rewards.HYRewardedAds() : new HYRewardedAds();
14
+ }
15
+ privacyRequired(): boolean {
16
+ if (this.disposed) return false;
17
+ try {
18
+ const native = this.getNative();
19
+ return isAndroid ? native.privacyRequired(Application.android.context) : native.privacyRequired;
20
+ } catch { return false; }
21
+ }
22
+ private async call(action: string, earned: () => void, presentation?: RewardPresentation): Promise<void> {
23
+ if (this.disposed) throw Error('unavailable');
24
+ if (Connectivity.getConnectionType() === Connectivity.connectionType.none) throw Error('offline');
25
+ const unit = this.config.development
26
+ ? (isIOS ? 'ca-app-pub-3940256099942544/1712485313' : 'ca-app-pub-3940256099942544/5224354917')
27
+ : (isIOS ? this.config.iosUnit : this.config.androidUnit);
28
+ if (action === 'show' && (!unit || (!this.config.development && unit.includes('3940256099942544')))) throw Error('unavailable');
29
+ this.getNative();
30
+ await new Promise<void>((resolve, reject) => {
31
+ let ended = false, preparing = false;
32
+ const onEvent = (event: string) => {
33
+ if (ended) return;
34
+ if (this.config.development) console.log('[haiyue-rewards]', event);
35
+ if (event === 'earned') { earned(); return; }
36
+ if (event === 'presenting') {
37
+ if (preparing) return;
38
+ preparing = true;
39
+ void (async () => {
40
+ let ready = false;
41
+ try { ready = presentation ? await presentation.prepare() : true; } catch { /* Tell native to cancel safely. */ }
42
+ if (!ended) this.native.continuePresentation(ready && !this.disposed);
43
+ })();
44
+ return;
45
+ }
46
+ if (event === 'presentation-closed') { preparing = false; presentation?.closed(); return; }
47
+ ended = true;
48
+ presentation?.closed();
49
+ if (event === 'closed') resolve(); else reject(Error(event.split(':')[1] || 'unavailable'));
50
+ };
51
+ if (isAndroid) {
52
+ const activity = Application.android.foregroundActivity;
53
+ if (!activity) { reject(Error('unavailable')); return; }
54
+ this.native.perform(activity, action, unit, new org.haiyue.rewards.HYRewardedAds.Events({ onEvent }));
55
+ } else this.native.performUnitEvents(action, unit, onEvent);
56
+ });
57
+ }
58
+ async show(earned: () => void, presentation?: RewardPresentation): Promise<void> {
59
+ // The native gateway accepts one operation at a time. An explicit request
60
+ // retries even if the background startup refresh failed.
61
+ if (this.initialization) await this.initialization.catch(() => {});
62
+ return this.call('show', earned, presentation);
63
+ }
64
+ /** Update consent once per host session, without initializing or preloading ads. */
65
+ initialize(presentForm: boolean, beforePresent: () => Promise<boolean>): Promise<void> {
66
+ if (!isIOS) return Promise.resolve();
67
+ return this.initialization ??= this.initializeConsent(presentForm, beforePresent);
68
+ }
69
+ private async initializeConsent(presentForm: boolean, beforePresent: () => Promise<boolean>): Promise<void> {
70
+ await this.call('refreshPrivacy', () => {});
71
+ if (!this.disposed && presentForm && this.getNative().consentRequired) {
72
+ await this.call('presentConsent', () => {}, { prepare: beforePresent, closed() {} });
73
+ }
74
+ }
75
+ async privacy(presentation?: RewardPresentation): Promise<void> {
76
+ if (this.initialization) await this.initialization.catch(() => {});
77
+ return this.call('privacy', () => {}, presentation);
78
+ }
79
+ dispose(): void { this.disposed = true; this.native?.dispose(); }
80
+ }