@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.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/bridge/audio/pcm-bank.android.ts +81 -0
- package/bridge/audio/pcm-bank.ios.ts +80 -0
- package/bridge/audio/pcm-bank.ts +2 -0
- package/bridge/branding/assets/haiyue-moon.png +0 -0
- package/bridge/branding/engine-splash.ts +122 -0
- package/bridge/branding/launch-page.ts +28 -0
- package/bridge/branding/webpack.cjs +11 -0
- package/bridge/display/orientation-policy.ts +18 -0
- package/bridge/display/orientation.android.ts +31 -0
- package/bridge/display/orientation.ios.ts +57 -0
- package/bridge/display/orientation.ts +2 -0
- package/bridge/feedback/haptics.android.ts +31 -0
- package/bridge/feedback/haptics.ios.ts +35 -0
- package/bridge/feedback/haptics.ts +2 -0
- package/bridge/files/read-bytes.ts +18 -0
- package/bridge/input/native-touch.android.ts +55 -0
- package/bridge/input/native-touch.ios.ts +91 -0
- package/bridge/input/native-touch.ts +2 -0
- package/bridge/input/pointer-target.ts +106 -0
- package/bridge/input/touch-identity.ts +20 -0
- package/bridge/lifecycle/demand-frames.ts +51 -0
- package/bridge/lifecycle/frame-performance.ts +20 -0
- package/bridge/lifecycle/frame-scheduler.ts +33 -0
- package/bridge/lifecycle/host.ts +330 -0
- package/bridge/lifecycle/launch-flags.ts +6 -0
- package/bridge/lifecycle/presentation-pause.ts +18 -0
- package/bridge/lifecycle/runtime.ts +31 -0
- package/bridge/media/save-photo.android.ts +49 -0
- package/bridge/media/save-photo.ios.ts +19 -0
- package/bridge/media/save-photo.ts +2 -0
- package/bridge/motion/android-reading.ts +19 -0
- package/bridge/motion/device-motion.android.ts +109 -0
- package/bridge/motion/device-motion.ios.ts +121 -0
- package/bridge/motion/device-motion.ts +2 -0
- package/bridge/motion/motion-sample.ts +62 -0
- package/bridge/render/canvas-textures.ios.ts +45 -0
- package/bridge/render/canvas-textures.ts +2 -0
- package/bridge/render/device-descriptor.ts +8 -0
- package/bridge/render/frame-capture.android.ts +12 -0
- package/bridge/render/frame-capture.ios.ts +20 -0
- package/bridge/render/frame-capture.ts +2 -0
- package/bridge/render/queue-fence.android.ts +39 -0
- package/bridge/render/queue-fence.ts +1 -0
- package/bridge/render/surface.ts +153 -0
- package/bridge/render/view-capture.android.ts +11 -0
- package/bridge/render/view-capture.ios.ts +17 -0
- package/bridge/render/view-capture.ts +1 -0
- package/bridge/render/view-rect.android.ts +15 -0
- package/bridge/render/view-rect.ios.ts +12 -0
- package/bridge/render/view-rect.ts +2 -0
- package/bridge/render/webgpu-constants.ts +5 -0
- package/bridge/rewards/admob.ts +80 -0
- package/bridge/rewards/controller.ts +163 -0
- package/bridge/rewards/native/android/org/haiyue/rewards/HYRewardedAds.java +116 -0
- package/bridge/rewards/native/ios/HYRewardedAds.swift +169 -0
- package/bridge/storage/clone-runtime.ts +6 -0
- package/bridge/storage/settings-storage.ts +12 -0
- package/index.ts +17 -0
- package/package.json +87 -0
- package/provenance.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Haiyue Engine contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# @haiyue/native
|
|
2
|
+
|
|
3
|
+
Haiyue 的 NativeScript 原生适配层,支持 iOS / Android:WebGPU 宿主与帧调度、触摸输入、陀螺仪、震动、音频、屏幕方向、存储、图片保存和通用开屏组件。
|
|
4
|
+
|
|
5
|
+
**npm 只包含可复用的原生代码与小尺寸通用标志。游戏、示例、美术、模型、引擎 vendor 包、构建产物和验收资料均不包含。** 完整游戏示例从 [GitHub](https://github.com/HaiyueStudio/Native) 下载。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
在 NativeScript 应用项目中执行:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install @haiyue/native@0.1.0
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
配套版本:`@haiyue/engine@0.1.0`、`@nativescript/core@9.1.1`、`@nativescript/canvas@2.1.18`,通过 peer dependencies 使用应用自己的依赖,不复制引擎或平台二进制到本包。
|
|
16
|
+
|
|
17
|
+
这是 NativeScript 专用 TypeScript 包,交给 NativeScript Webpack 编译;保留 `.ios.ts` / `.android.ts`,由平台解析器选择实现,并处理 `@NativeClass`。已验证 `@nativescript/webpack@5.0.38` / TypeScript 5.7.3。宿主 tsconfig 使用 `experimentalDecorators: true`、`emitDecoratorMetadata: true`,并包含 `@nativescript/types` 和 `@webgpu/types`;这些类型工具由宿主开发依赖提供。本包不能直接在浏览器或普通 Node.js 中执行。
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { NativeDeviceMotion, NativeHaptics } from '@haiyue/native';
|
|
21
|
+
// 也可按能力导入:import { NativeDeviceMotion } from '@haiyue/native/motion';
|
|
22
|
+
|
|
23
|
+
const motion = new NativeDeviceMotion({ updateIntervalMs: 1000 / 60 });
|
|
24
|
+
motion.onUpdate(sample => console.log(sample.angles.pitch, sample.angles.roll));
|
|
25
|
+
motion.start();
|
|
26
|
+
// 在应用已有的逐帧回调内调用 motion.update(deltaMs)。
|
|
27
|
+
|
|
28
|
+
const haptics = new NativeHaptics();
|
|
29
|
+
haptics.resume();
|
|
30
|
+
haptics.impact('light');
|
|
31
|
+
// 页面真正销毁时调用 motion.dispose()、haptics.dispose()。
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
iOS 使用陀螺仪时配置 `NSMotionUsageDescription`;Android 震动声明 `android.permission.VIBRATE`。其他权限和生命周期要求见 [原生能力说明](https://github.com/HaiyueStudio/Native/tree/main/bridge)。
|
|
35
|
+
|
|
36
|
+
## 能力入口
|
|
37
|
+
|
|
38
|
+
| 入口 | 内容 |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `@haiyue/native` | 常用宿主、渲染、输入、运动、反馈、音频、存储与开屏 API |
|
|
41
|
+
| `@haiyue/native/motion` | NativeDeviceMotion |
|
|
42
|
+
| `@haiyue/native/feedback` | NativeHaptics |
|
|
43
|
+
| `@haiyue/native/audio` | NativePcmAudioBank |
|
|
44
|
+
| `@haiyue/native/orientation` | NativeOrientationController |
|
|
45
|
+
| `@haiyue/native/media` | savePhoto |
|
|
46
|
+
| `@haiyue/native/branding` | NativeEngineLaunchPage |
|
|
47
|
+
| `@haiyue/native/rewards` | 可选的 RewardController |
|
|
48
|
+
| `@haiyue/native/rewards/admob` | 可选的 AdMobRewardGateway |
|
|
49
|
+
|
|
50
|
+
使用开屏组件时,在应用 webpack 配置的 `webpack.init(env)` 之后添加:
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
const { addEngineBrandingCopyRule } = require('@haiyue/native/branding/webpack');
|
|
54
|
+
addEngineBrandingCopyRule(webpack);
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
只有小尺寸通用月牙图会复制到应用资源。广告接口需要应用单独集成 Google SDK、Swift/Java 桥接源文件、广告配置与许可流程;本包不会自动接入广告服务。参考 [奖励接口说明](https://github.com/HaiyueStudio/Native/tree/main/bridge/rewards)。
|
|
58
|
+
|
|
59
|
+
## 许可与验证
|
|
60
|
+
|
|
61
|
+
MIT。原生实现取自 `native-v0.1.0` 的冻结源码,文件哈希保存在包内 `provenance.json`。npm 包验证独立类型检查、平台模块解析、安装和包内文件范围;已有原生构建与真机验证的范围见 [GitHub 发布说明](https://github.com/HaiyueStudio/Native/releases/tag/native-v0.1.0)。游戏模型不随 npm 分发。
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Utils } from '@nativescript/core';
|
|
2
|
+
export interface NativePcmSound { id: string; path: string; seconds: number }
|
|
3
|
+
export interface NativePcmPlay { channel: string; loop: boolean; gain: number; pan: number; priority: number }
|
|
4
|
+
/** Decode short PCM effects once; SoundPool owns playback on native audio threads. */
|
|
5
|
+
export class NativePcmAudioBank {
|
|
6
|
+
private readonly pool: android.media.SoundPool;
|
|
7
|
+
private readonly sounds = new Map<string, { nativeId: number; seconds: number }>();
|
|
8
|
+
private readonly loaded = new Set<number>();
|
|
9
|
+
private readonly streams = new Map<string, { id: number; until: number; gain: number; pan: number }>();
|
|
10
|
+
private readonly longSounds = new Map<string, NativePcmSound>();
|
|
11
|
+
private readonly players = new Map<string, { player: android.media.MediaPlayer; gain: number; pan: number; loop: boolean }>();
|
|
12
|
+
private readonly ready: Promise<void>;
|
|
13
|
+
private error: string | null = null;
|
|
14
|
+
private running = false;
|
|
15
|
+
private disposed = false;
|
|
16
|
+
private played = 0;
|
|
17
|
+
private volume = .39;
|
|
18
|
+
private pcmBytes = 0;
|
|
19
|
+
constructor(sounds: readonly NativePcmSound[], _onInterruption: () => void = () => {}) {
|
|
20
|
+
const attributes = new android.media.AudioAttributes.Builder().setUsage(android.media.AudioAttributes.USAGE_GAME).setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION).build();
|
|
21
|
+
this.pool = new android.media.SoundPool.Builder().setMaxStreams(8).setAudioAttributes(attributes).build();
|
|
22
|
+
// SoundPool truncates large decoded samples. Stream long music with MediaPlayer instead.
|
|
23
|
+
for (const sound of sounds) if (sound.seconds > 8) this.longSounds.set(sound.id, sound);
|
|
24
|
+
const shortSounds = sounds.filter(sound => !this.longSounds.has(sound.id));
|
|
25
|
+
this.ready = new Promise(resolve => {
|
|
26
|
+
const timer = setTimeout(() => { this.error='SoundPool decode timed out'; resolve(); }, 8000);
|
|
27
|
+
this.pool.setOnLoadCompleteListener(new android.media.SoundPool.OnLoadCompleteListener({onLoadComplete: (_pool, id, status) => {
|
|
28
|
+
if(this.disposed)return;
|
|
29
|
+
if(status!==0){this.error=`SoundPool decode failed: ${id}/${status}`;clearTimeout(timer);resolve();return;}
|
|
30
|
+
this.loaded.add(id); if(this.loaded.size===shortSounds.length){clearTimeout(timer);resolve();}
|
|
31
|
+
}}));
|
|
32
|
+
for(const sound of shortSounds){
|
|
33
|
+
const id=this.pool.load(sound.path,1);
|
|
34
|
+
if(!id){this.error=`Unable to load ${sound.id}`;clearTimeout(timer);resolve();break;}
|
|
35
|
+
this.sounds.set(sound.id,{nativeId:id,seconds:sound.seconds}); this.pcmBytes+=Math.round(sound.seconds*44100)*2;
|
|
36
|
+
}
|
|
37
|
+
if(!shortSounds.length){clearTimeout(timer);resolve();}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
async load(): Promise<void> { await this.ready; }
|
|
41
|
+
unlock(): void { if(!this.disposed&&!this.error)this.running=true; }
|
|
42
|
+
play(id: string, options: NativePcmPlay): boolean {
|
|
43
|
+
const sound=this.sounds.get(id), longSound=this.longSounds.get(id);
|
|
44
|
+
if(!this.running||this.disposed||this.error||(!longSound&&(!sound||!this.loaded.has(sound.nativeId))))return false;
|
|
45
|
+
const manager=Utils.android.getApplicationContext().getSystemService(android.content.Context.AUDIO_SERVICE) as android.media.AudioManager;
|
|
46
|
+
if(manager.getRingerMode()!==android.media.AudioManager.RINGER_MODE_NORMAL)return true;
|
|
47
|
+
this.stop(options.channel);
|
|
48
|
+
const gain=Math.max(0,Math.min(1,options.gain*this.volume)),pan=Math.max(-1,Math.min(1,options.pan));
|
|
49
|
+
if(longSound) {
|
|
50
|
+
if(this.players.size>=8)return false;
|
|
51
|
+
const player=new android.media.MediaPlayer();
|
|
52
|
+
try {
|
|
53
|
+
player.setAudioAttributes(new android.media.AudioAttributes.Builder().setUsage(android.media.AudioAttributes.USAGE_GAME).setContentType(android.media.AudioAttributes.CONTENT_TYPE_MUSIC).build());
|
|
54
|
+
player.setDataSource(longSound.path);player.setLooping(options.loop);
|
|
55
|
+
player.setVolume(gain*(pan>0?1-pan:1),gain*(pan<0?1+pan:1));
|
|
56
|
+
player.setOnCompletionListener(new android.media.MediaPlayer.OnCompletionListener({onCompletion:()=>{if(this.players.get(options.channel)?.player===player)this.stop(options.channel);}}));
|
|
57
|
+
player.setOnErrorListener(new android.media.MediaPlayer.OnErrorListener({onError:()=>{if(this.players.get(options.channel)?.player===player)this.stop(options.channel);return true;}}));
|
|
58
|
+
player.prepare();this.players.set(options.channel,{player,gain:options.gain,pan,loop:options.loop});player.start();this.played++;return true;
|
|
59
|
+
} catch(error) {player.release();this.players.delete(options.channel);this.error=String(error);return false;}
|
|
60
|
+
}
|
|
61
|
+
const stream=this.pool.play(sound!.nativeId,gain*(pan>0?1-pan:1),gain*(pan<0?1+pan:1),options.priority,options.loop?-1:0,1);
|
|
62
|
+
if(!stream)return false;
|
|
63
|
+
this.streams.set(options.channel,{id:stream,gain:options.gain,pan,until:options.loop?Infinity:performance.now()+sound!.seconds*1000});this.played++;return true;
|
|
64
|
+
}
|
|
65
|
+
setChannelGain(channel:string,gain:number):void {
|
|
66
|
+
const voice=this.streams.get(channel)??this.players.get(channel);if(!voice)return;
|
|
67
|
+
const next=Math.max(0,Math.min(1,gain));if(voice.gain===next)return;
|
|
68
|
+
voice.gain=next;this.applyVolume(channel);
|
|
69
|
+
}
|
|
70
|
+
private applyVolume(channel:string):void {
|
|
71
|
+
const stream=this.streams.get(channel), entry=this.players.get(channel), voice=stream??entry;if(!voice)return;
|
|
72
|
+
const gain=Math.max(0,Math.min(1,voice.gain*this.volume));
|
|
73
|
+
const left=gain*(voice.pan>0?1-voice.pan:1),right=gain*(voice.pan<0?1+voice.pan:1);
|
|
74
|
+
if(stream)this.pool.setVolume(stream.id,left,right);else entry!.player.setVolume(left,right);
|
|
75
|
+
}
|
|
76
|
+
stop(channel?: string): void {for(const [key,entry] of this.players)if(channel===undefined||channel===key){entry.player.release();this.players.delete(key);}for(const [key,stream] of this.streams)if(channel===undefined||channel===key){this.pool.stop(stream.id);this.streams.delete(key);}}
|
|
77
|
+
setVolume(volume:number):void {const next=Math.max(0,Math.min(1,volume))*.6;if(this.volume===next)return;this.volume=next;for(const key of [...this.streams.keys(),...this.players.keys()])this.applyVolume(key);}
|
|
78
|
+
suspend():void {if(this.disposed)return;this.stop();this.pool.autoPause();this.running=false;}
|
|
79
|
+
dispose():void {if(this.disposed)return;this.suspend();this.disposed=true;this.pool.release();this.sounds.clear();this.loaded.clear();}
|
|
80
|
+
snapshot(){return {kind:'android-sound-pool',error:this.error,running:this.running,buffers:this.loaded.size+this.longSounds.size,pcmBytes:this.pcmBytes,voices:[...this.streams.values()].filter(v=>v.until>performance.now()).length+this.players.size,nodeCount:8+this.players.size,loopChannels:[...[...this.streams].filter(([,v])=>v.until===Infinity).map(([k])=>k),...[...this.players].filter(([,v])=>v.loop).map(([k])=>k)],played:this.played,volume:this.volume,disposed:this.disposed};}
|
|
81
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export interface NativePcmSound { id:string; path:string; seconds:number }
|
|
2
|
+
export interface NativePcmPlay {channel:string;loop:boolean;gain:number;pan:number;priority:number}
|
|
3
|
+
interface Voice {node:AVAudioPlayerNode;channel:string|null;until:number;priority:number;sequence:number}
|
|
4
|
+
/** Cached PCM buffers + a fixed AVAudioEngine node pool. No JavaScript render/completion callbacks. */
|
|
5
|
+
export class NativePcmAudioBank {
|
|
6
|
+
private readonly engine=AVAudioEngine.new();
|
|
7
|
+
private readonly buffers=new Map<string,{buffer:AVAudioPCMBuffer;seconds:number}>();
|
|
8
|
+
private readonly voices:Voice[]=[];
|
|
9
|
+
private readonly observers:NSObjectProtocol[]=[];
|
|
10
|
+
private error:string|null=null;
|
|
11
|
+
private disposed=false;
|
|
12
|
+
private running=false;
|
|
13
|
+
private sequence=0;
|
|
14
|
+
private played=0;
|
|
15
|
+
private bytes=0;
|
|
16
|
+
private volume=0.65;
|
|
17
|
+
constructor(sounds:readonly NativePcmSound[],private readonly onInterruption:()=>void=()=>{}) {
|
|
18
|
+
try {
|
|
19
|
+
if(!AVAudioSession.sharedInstance().setCategoryError(AVAudioSessionCategoryAmbient))throw new Error('Unable to configure ambient audio session');
|
|
20
|
+
for(const sound of sounds){
|
|
21
|
+
const file=AVAudioFile.alloc().initForReadingError(NSURL.fileURLWithPath(sound.path));
|
|
22
|
+
if(!file||!Number.isFinite(sound.seconds)||sound.seconds<=0||sound.seconds>60||file.processingFormat.sampleRate!==44100||file.length<1||file.length>44100*Math.min(60,sound.seconds+.1)||file.processingFormat.channelCount!==1)throw new Error(`Invalid PCM audio ${sound.id}`);
|
|
23
|
+
const buffer=AVAudioPCMBuffer.alloc().initWithPCMFormatFrameCapacity(file.processingFormat,Number(file.length));
|
|
24
|
+
if(!file.readIntoBufferError(buffer))throw new Error(`Unable to decode ${sound.id}`);
|
|
25
|
+
this.buffers.set(sound.id,{buffer,seconds:buffer.frameLength/buffer.format.sampleRate});this.bytes+=buffer.frameLength*4;
|
|
26
|
+
}
|
|
27
|
+
const format=this.buffers.values().next().value?.buffer.format;
|
|
28
|
+
if(!format)throw new Error('Empty PCM sound bank');
|
|
29
|
+
for(let i=0;i<12;i++){const node=AVAudioPlayerNode.new();this.engine.attachNode(node);this.engine.connectToFormat(node,this.engine.mainMixerNode,format);this.voices.push({node,channel:null,until:0,priority:0,sequence:0});}
|
|
30
|
+
this.engine.mainMixerNode.outputVolume=this.volume*0.6;this.engine.prepare();
|
|
31
|
+
const center=NSNotificationCenter.defaultCenter;
|
|
32
|
+
this.observers.push(center.addObserverForNameObjectQueueUsingBlock(AVAudioSessionInterruptionNotification,null,NSOperationQueue.mainQueue,n=>{
|
|
33
|
+
if(Number(n.userInfo?.objectForKey(AVAudioSessionInterruptionTypeKey))===AVAudioSessionInterruptionType.Began)this.interrupt();
|
|
34
|
+
}));
|
|
35
|
+
this.observers.push(center.addObserverForNameObjectQueueUsingBlock(AVAudioSessionRouteChangeNotification,null,NSOperationQueue.mainQueue,n=>{
|
|
36
|
+
if(Number(n.userInfo?.objectForKey(AVAudioSessionRouteChangeReasonKey))===AVAudioSessionRouteChangeReason.OldDeviceUnavailable)this.interrupt();
|
|
37
|
+
}));
|
|
38
|
+
this.observers.push(center.addObserverForNameObjectQueueUsingBlock(AVAudioEngineConfigurationChangeNotification,this.engine,NSOperationQueue.mainQueue,()=>{
|
|
39
|
+
if(this.running&&!this.engine.running)this.interrupt();
|
|
40
|
+
}));
|
|
41
|
+
}catch(error){this.error=String(error);}
|
|
42
|
+
}
|
|
43
|
+
private now():number{return NSProcessInfo.processInfo.systemUptime;}
|
|
44
|
+
private interrupt():void {if(this.disposed)return;this.suspend();this.onInterruption();}
|
|
45
|
+
unlock():void {
|
|
46
|
+
if(this.disposed||this.error||this.running)return;
|
|
47
|
+
try{if(!AVAudioSession.sharedInstance().setActiveError(true)||!this.engine.startAndReturnError())throw new Error('Unable to start audio engine');this.running=true;}
|
|
48
|
+
catch(error){this.error=String(error);}
|
|
49
|
+
}
|
|
50
|
+
play(id:string,options:NativePcmPlay):boolean {
|
|
51
|
+
const sound=this.buffers.get(id);if(!this.running||this.disposed||this.error||!sound)return false;
|
|
52
|
+
const now=this.now();let voice=this.voices.find(v=>v.channel===options.channel)||this.voices.find(v=>v.until<=now);
|
|
53
|
+
if(!voice){voice=[...this.voices].sort((a,b)=>a.priority-b.priority||a.sequence-b.sequence)[0];if(!voice||voice.priority>options.priority)return false;}
|
|
54
|
+
try {
|
|
55
|
+
voice.node.stop();voice.channel=options.channel;voice.until=options.loop?Infinity:now+sound.seconds;voice.priority=options.priority;voice.sequence=this.sequence++;
|
|
56
|
+
voice.node.volume=options.gain;voice.node.pan=options.pan;
|
|
57
|
+
// NativeScript typings omit nullable annotations; null prevents JS callbacks on the audio thread.
|
|
58
|
+
voice.node.scheduleBufferAtTimeOptionsCompletionHandler(sound.buffer,null as unknown as AVAudioTime,
|
|
59
|
+
options.loop?AVAudioPlayerNodeBufferOptions.Loops:0 as AVAudioPlayerNodeBufferOptions,null as unknown as ()=>void);
|
|
60
|
+
voice.node.play();this.played++;return true;
|
|
61
|
+
}catch(error){this.error=String(error);this.suspend();return false;}
|
|
62
|
+
}
|
|
63
|
+
stop(channel?:string):void {
|
|
64
|
+
for(const voice of this.voices)if(channel===undefined||voice.channel===channel){voice.node.stop();voice.channel=null;voice.until=0;}
|
|
65
|
+
}
|
|
66
|
+
setChannelGain(channel:string,gain:number):void {
|
|
67
|
+
if(this.disposed)return;
|
|
68
|
+
const volume=Number.isFinite(gain)?Math.max(0,Math.min(1,gain)):0;
|
|
69
|
+
for(const voice of this.voices)if(voice.channel===channel)voice.node.volume=volume;
|
|
70
|
+
}
|
|
71
|
+
setVolume(volume:number):void {this.volume=Math.max(0,Math.min(1,volume));this.engine.mainMixerNode.outputVolume=this.volume*0.6;}
|
|
72
|
+
suspend():void {if(this.disposed)return;this.stop();this.engine.pause();this.running=false;}
|
|
73
|
+
dispose():void {
|
|
74
|
+
if(this.disposed)return;this.suspend();this.disposed=true;
|
|
75
|
+
for(const observer of this.observers)NSNotificationCenter.defaultCenter.removeObserver(observer);this.observers.length=0;
|
|
76
|
+
this.engine.stop();for(const voice of this.voices)this.engine.detachNode(voice.node);this.voices.length=0;this.buffers.clear();this.bytes=0;
|
|
77
|
+
}
|
|
78
|
+
snapshot(){return {kind:'av-audio-engine',error:this.error,running:this.running,buffers:this.buffers.size,pcmBytes:this.bytes,
|
|
79
|
+
voices:this.voices.filter(v=>v.until>this.now()).length,nodeCount:this.voices.length,loopChannels:this.voices.filter(v=>v.until===Infinity).map(v=>v.channel),played:this.played,volume:this.volume,disposed:this.disposed};}
|
|
80
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Color, Device, GridLayout, Image, Label, StackLayout } from '@nativescript/core';
|
|
2
|
+
|
|
3
|
+
export interface NativeEngineSplashOptions {
|
|
4
|
+
orientation?: 'auto' | 'portrait' | 'landscape';
|
|
5
|
+
/** Optional app-specific initialization status, localized by the caller. */
|
|
6
|
+
message?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** DIP sizing shared by the overlay and launch page. No platform orientation lock. */
|
|
10
|
+
export function engineSplashLayout(width: number, height: number, orientation: NativeEngineSplashOptions['orientation'] = 'auto') {
|
|
11
|
+
const portrait = orientation === 'portrait' || (orientation === 'auto' && height >= width);
|
|
12
|
+
const contentWidth = Math.max(160, Math.min(portrait ? 320 : 280, width - 40));
|
|
13
|
+
const logo = Math.max(64, Math.min(portrait ? 152 : 112, contentWidth * .48, height * .28));
|
|
14
|
+
return { contentWidth, logo, title: contentWidth < 240 ? 18 : portrait ? 23 : 21,
|
|
15
|
+
gap: Math.min(portrait ? 28 : 21, height * .055), captionGap: Math.min(portrait ? 34 : 27, height * .06),
|
|
16
|
+
offset: portrait ? -Math.min(28, height * .03) : 0 };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Shared native loading overlay. Copy assets/haiyue-moon.png to branding/ in
|
|
20
|
+
* the app bundle, then dismiss only after the first successful GPU presentation. */
|
|
21
|
+
export class NativeEngineSplash {
|
|
22
|
+
readonly view = new GridLayout();
|
|
23
|
+
private readonly content = new StackLayout();
|
|
24
|
+
private readonly logo = new Image();
|
|
25
|
+
private readonly name = new Label();
|
|
26
|
+
private readonly caption = new Label();
|
|
27
|
+
private state: 'loading' | 'leaving' | 'hidden' | 'failed' | 'disposed' = 'loading';
|
|
28
|
+
private fade?: ReturnType<GridLayout['animate']>;
|
|
29
|
+
private readonly language = Device.language?.startsWith('ja') ? 'ja' : Device.language?.startsWith('zh') ? 'zh' : 'en';
|
|
30
|
+
|
|
31
|
+
constructor(private readonly parent: GridLayout, private readonly options: NativeEngineSplashOptions = {}) {
|
|
32
|
+
this.view.backgroundColor = new Color('#07111f');
|
|
33
|
+
this.view.iosOverflowSafeArea = true;
|
|
34
|
+
this.view.isUserInteractionEnabled = true;
|
|
35
|
+
const content = this.content;
|
|
36
|
+
content.horizontalAlignment = 'center';
|
|
37
|
+
content.verticalAlignment = 'middle';
|
|
38
|
+
content.width = 280;
|
|
39
|
+
const logo = this.logo;
|
|
40
|
+
logo.loadMode = 'sync';
|
|
41
|
+
logo.src = '~/branding/haiyue-moon.png';
|
|
42
|
+
logo.width = 112;
|
|
43
|
+
logo.height = 112;
|
|
44
|
+
logo.stretch = 'aspectFit';
|
|
45
|
+
logo.accessibilityHidden = true;
|
|
46
|
+
content.addChild(logo);
|
|
47
|
+
const name = this.name;
|
|
48
|
+
name.text = 'HAIYUE ENGINE';
|
|
49
|
+
name.color = new Color('#d9f1ff');
|
|
50
|
+
name.fontSize = 21;
|
|
51
|
+
name.letterSpacing = 0.16;
|
|
52
|
+
name.marginTop = 21;
|
|
53
|
+
name.textAlignment = 'center';
|
|
54
|
+
content.addChild(name);
|
|
55
|
+
const signature = new Label();
|
|
56
|
+
signature.text = 'CRAFTED WITH HAIYUE';
|
|
57
|
+
signature.color = new Color('#7192aa');
|
|
58
|
+
signature.fontSize = 9;
|
|
59
|
+
signature.letterSpacing = 0.22;
|
|
60
|
+
signature.marginTop = 7;
|
|
61
|
+
signature.textAlignment = 'center';
|
|
62
|
+
content.addChild(signature);
|
|
63
|
+
this.caption.text = options.message ?? { zh: '正在载入…', en: 'Loading…', ja: '読み込み中…' }[this.language];
|
|
64
|
+
this.caption.color = new Color('#8daabd');
|
|
65
|
+
this.caption.fontSize = 11;
|
|
66
|
+
this.caption.marginTop = 27;
|
|
67
|
+
this.caption.textAlignment = 'center';
|
|
68
|
+
this.caption.textWrap = true;
|
|
69
|
+
content.addChild(this.caption);
|
|
70
|
+
this.view.addChild(content);
|
|
71
|
+
parent.addChild(this.view);
|
|
72
|
+
parent.on('layoutChanged', this.layout);
|
|
73
|
+
this.layout();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private readonly layout = (): void => {
|
|
77
|
+
const { width, height } = this.parent.getActualSize();
|
|
78
|
+
if (!width || !height || this.state === 'disposed') return;
|
|
79
|
+
const l = engineSplashLayout(width, height, this.options.orientation ?? 'auto');
|
|
80
|
+
this.content.width = l.contentWidth;
|
|
81
|
+
this.content.translateY = l.offset;
|
|
82
|
+
this.logo.width = this.logo.height = l.logo;
|
|
83
|
+
this.name.fontSize = l.title;
|
|
84
|
+
this.name.marginTop = l.gap;
|
|
85
|
+
this.caption.marginTop = l.captionGap;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
get status(): 'loading' | 'leaving' | 'hidden' | 'failed' | 'disposed' { return this.state; }
|
|
89
|
+
|
|
90
|
+
setMessage(message: string): void {
|
|
91
|
+
if (this.state === 'loading') this.caption.text = message;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
presented(): void {
|
|
95
|
+
if (this.state !== 'loading') return;
|
|
96
|
+
this.state = 'leaving';
|
|
97
|
+
// No minimum display timer: fast devices can enter the game immediately.
|
|
98
|
+
this.fade = this.view.animate({ opacity: 0, duration: 180 });
|
|
99
|
+
void this.fade.catch(() => {}).then(() => {
|
|
100
|
+
if (this.state !== 'leaving') return;
|
|
101
|
+
this.state = 'hidden';
|
|
102
|
+
this.view.visibility = 'collapse';
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
fail(message?: string): void {
|
|
107
|
+
if (this.state === 'disposed') return;
|
|
108
|
+
this.state = 'failed';
|
|
109
|
+
this.fade?.cancel();
|
|
110
|
+
this.view.opacity = 1;
|
|
111
|
+
this.view.visibility = 'visible';
|
|
112
|
+
this.caption.text = message ?? { zh: '载入失败,请重新打开游戏', en: 'Unable to load. Please reopen the game.', ja: '読み込めませんでした。再起動してください。' }[this.language];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
dispose(): void {
|
|
116
|
+
if (this.state === 'disposed') return;
|
|
117
|
+
this.state = 'disposed';
|
|
118
|
+
this.fade?.cancel();
|
|
119
|
+
this.parent.off('layoutChanged', this.layout);
|
|
120
|
+
this.parent.removeChild(this.view);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Color, GridLayout, Page } from '@nativescript/core';
|
|
2
|
+
import { NativeEngineSplash, type NativeEngineSplashOptions } from './engine-splash';
|
|
3
|
+
|
|
4
|
+
/** Reusable native launch page. Add app UI to gameRoot while the brand overlay
|
|
5
|
+
* covers initialization; call splash.presented() only when the app is visible.
|
|
6
|
+
* The app owns lifetime/disposal so backgrounding and modals do not replay it. */
|
|
7
|
+
export class NativeEngineLaunchPage extends Page {
|
|
8
|
+
readonly gameRoot = new GridLayout();
|
|
9
|
+
readonly splash: NativeEngineSplash;
|
|
10
|
+
|
|
11
|
+
constructor(options: NativeEngineSplashOptions = {}) {
|
|
12
|
+
super();
|
|
13
|
+
this.actionBarHidden = true;
|
|
14
|
+
// Android Page defaults to inset padding. Forward insets to gameRoot instead,
|
|
15
|
+
// so the sibling branding overlay can cover the entire window.
|
|
16
|
+
this.androidOverflowEdge = 'ignore';
|
|
17
|
+
this.backgroundColor = new Color('#07111f');
|
|
18
|
+
const layers = new GridLayout();
|
|
19
|
+
layers.iosOverflowSafeArea = true;
|
|
20
|
+
layers.androidOverflowEdge = 'ignore';
|
|
21
|
+
// App controls respect notches/home indicators; the branding fills the screen.
|
|
22
|
+
this.gameRoot.iosOverflowSafeArea = false;
|
|
23
|
+
this.gameRoot.androidOverflowEdge = 'none';
|
|
24
|
+
layers.addChild(this.gameRoot);
|
|
25
|
+
this.splash = new NativeEngineSplash(layers, options);
|
|
26
|
+
this.content = layers;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
|
|
3
|
+
/** Ship only the small transparent logo, never the production master. */
|
|
4
|
+
function addEngineBrandingCopyRule(webpack) {
|
|
5
|
+
webpack.Utils.addCopyRule({
|
|
6
|
+
from: path.join(__dirname, 'assets/haiyue-moon.png'),
|
|
7
|
+
to: 'branding/haiyue-moon.png',
|
|
8
|
+
noErrorOnMissing: false,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
module.exports = { addEngineBrandingCopyRule };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** 'any' permits portrait and both landscape directions; upside-down is excluded. */
|
|
2
|
+
export type OrientationPolicy = 'portrait' | 'landscape' | 'any';
|
|
3
|
+
export const orientationMasks = Object.freeze({ portrait: 2, landscape: 24, any: 26 });
|
|
4
|
+
export function orientationMask(policy: OrientationPolicy): number {
|
|
5
|
+
const mask = orientationMasks[policy];
|
|
6
|
+
if (!mask) throw new Error(`Unknown screen orientation policy: ${policy}`);
|
|
7
|
+
return mask;
|
|
8
|
+
}
|
|
9
|
+
export function orientationPlistValues(policy: OrientationPolicy): string[] {
|
|
10
|
+
orientationMask(policy);
|
|
11
|
+
return policy === 'portrait' ? ['UIInterfaceOrientationPortrait'] : policy === 'landscape'
|
|
12
|
+
? ['UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight']
|
|
13
|
+
: ['UIInterfaceOrientationPortrait', 'UIInterfaceOrientationLandscapeLeft', 'UIInterfaceOrientationLandscapeRight'];
|
|
14
|
+
}
|
|
15
|
+
export function assertOrientationSupported(policy: OrientationPolicy, supported: OrientationPolicy): void {
|
|
16
|
+
const requested = orientationMask(policy), available = orientationMask(supported);
|
|
17
|
+
if ((requested & available) !== requested) throw new Error(`Orientation ${policy} exceeds this App's supported ${supported} policy. Update the build configuration first.`);
|
|
18
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Application, AndroidApplication, type EventData } from '@nativescript/core';
|
|
2
|
+
import { assertOrientationSupported, type OrientationPolicy } from './orientation-policy';
|
|
3
|
+
export class NativeOrientationController {
|
|
4
|
+
private policy: OrientationPolicy;
|
|
5
|
+
private disposed = false;
|
|
6
|
+
constructor(readonly supported: OrientationPolicy, initial: OrientationPolicy = supported) {
|
|
7
|
+
assertOrientationSupported(initial, supported); this.policy = initial;
|
|
8
|
+
Application.android.on(AndroidApplication.activityCreatedEvent, this.apply);
|
|
9
|
+
Application.on(Application.resumeEvent, this.apply); Application.on(Application.displayedEvent, this.apply);
|
|
10
|
+
}
|
|
11
|
+
setPolicy(policy: OrientationPolicy): void { assertOrientationSupported(policy,this.supported); this.policy=policy; this.apply(); }
|
|
12
|
+
snapshot() { return {supported:this.supported,policy:this.policy}; }
|
|
13
|
+
readonly apply = (args?: EventData): void => {
|
|
14
|
+
if (this.disposed) return;
|
|
15
|
+
const activity = (args as EventData & {activity?:android.app.Activity})?.activity ?? Application.android.foregroundActivity ?? Application.android.startActivity;
|
|
16
|
+
if (!activity) return;
|
|
17
|
+
const orientation = this.policy==='landscape' ? android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE : this.policy==='portrait' ? android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT : android.content.pm.ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR;
|
|
18
|
+
activity.setRequestedOrientation(orientation);
|
|
19
|
+
const window=activity.getWindow();
|
|
20
|
+
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
|
21
|
+
window.getDecorView().setSystemUiVisibility(5894); // immersive sticky, fullscreen, hidden navigation, stable layout
|
|
22
|
+
if (android.os.Build.VERSION.SDK_INT >= 30) {
|
|
23
|
+
window.setDecorFitsSystemWindows(false);
|
|
24
|
+
const insets = window.getInsetsController();
|
|
25
|
+
insets?.hide(android.view.WindowInsets.Type.systemBars());
|
|
26
|
+
insets?.setSystemBarsBehavior(android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
|
27
|
+
}
|
|
28
|
+
if (android.os.Build.VERSION.SDK_INT>=28) { const attributes=window.getAttributes(); attributes.layoutInDisplayCutoutMode=android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; window.setAttributes(attributes); }
|
|
29
|
+
};
|
|
30
|
+
dispose(): void {if(this.disposed)return;this.disposed=true;Application.android.off(AndroidApplication.activityCreatedEvent,this.apply);Application.off(Application.resumeEvent,this.apply);Application.off(Application.displayedEvent,this.apply);}
|
|
31
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Application } from '@nativescript/core';
|
|
2
|
+
import { assertOrientationSupported, orientationMask, type OrientationPolicy } from './orientation-policy';
|
|
3
|
+
|
|
4
|
+
let active: NativeOrientationController | null = null;
|
|
5
|
+
/** Install before Application.run; plist support must include every runtime policy. */
|
|
6
|
+
export class NativeOrientationController {
|
|
7
|
+
private policy: OrientationPolicy;
|
|
8
|
+
private disposed = false;
|
|
9
|
+
constructor(readonly supported: OrientationPolicy, initial: OrientationPolicy = supported, private readonly report: (event: string, detail: unknown) => void = () => {}) {
|
|
10
|
+
if (active) throw new Error('Only one App orientation controller may be active.');
|
|
11
|
+
assertOrientationSupported(initial, supported);
|
|
12
|
+
this.policy = initial;
|
|
13
|
+
active = this;
|
|
14
|
+
const Parent = (Application.ios.delegate ?? NSObject) as unknown as typeof NSObject;
|
|
15
|
+
@NativeClass()
|
|
16
|
+
class OrientationDelegate extends Parent implements UIApplicationDelegate {
|
|
17
|
+
static ObjCProtocols = [UIApplicationDelegate];
|
|
18
|
+
applicationSupportedInterfaceOrientationsForWindow(_application: UIApplication, _window: UIWindow): UIInterfaceOrientationMask {
|
|
19
|
+
return orientationMask(active?.policy ?? supported);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
Application.ios.delegate = OrientationDelegate;
|
|
23
|
+
Application.on(Application.resumeEvent, this.apply);
|
|
24
|
+
Application.on(Application.displayedEvent, this.apply);
|
|
25
|
+
}
|
|
26
|
+
setPolicy(policy: OrientationPolicy): void {
|
|
27
|
+
if (this.disposed) throw new Error('Orientation controller is disposed.');
|
|
28
|
+
assertOrientationSupported(policy, this.supported);
|
|
29
|
+
this.policy = policy;
|
|
30
|
+
this.apply();
|
|
31
|
+
}
|
|
32
|
+
snapshot() { return { supported: this.supported, policy: this.policy, mask: orientationMask(this.policy) }; }
|
|
33
|
+
readonly apply = (): void => {
|
|
34
|
+
if (this.disposed) return;
|
|
35
|
+
const window = Application.ios.window;
|
|
36
|
+
const scene = window?.windowScene;
|
|
37
|
+
const controller = window?.rootViewController;
|
|
38
|
+
this.report('orientation-policy', this.snapshot());
|
|
39
|
+
if (!controller) return;
|
|
40
|
+
if (typeof controller.setNeedsUpdateOfSupportedInterfaceOrientations === 'function') {
|
|
41
|
+
controller.setNeedsUpdateOfSupportedInterfaceOrientations();
|
|
42
|
+
if (scene && typeof scene.requestGeometryUpdateWithPreferencesErrorHandler === 'function') {
|
|
43
|
+
const preferences = UIWindowSceneGeometryPreferencesIOS.alloc().initWithInterfaceOrientations(orientationMask(this.policy));
|
|
44
|
+
scene.requestGeometryUpdateWithPreferencesErrorHandler(preferences, error => this.report('orientation-error', { message: error.localizedDescription }));
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
UIViewController.attemptRotationToDeviceOrientation();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
dispose(): void {
|
|
51
|
+
if (this.disposed) return;
|
|
52
|
+
this.disposed = true;
|
|
53
|
+
Application.off(Application.resumeEvent, this.apply);
|
|
54
|
+
Application.off(Application.displayedEvent, this.apply);
|
|
55
|
+
if (active === this) active = null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Utils } from '@nativescript/core';
|
|
2
|
+
export type NativeImpact = 'light' | 'medium' | 'heavy';
|
|
3
|
+
/** Requires the normal (non-prompting) android.permission.VIBRATE manifest permission. */
|
|
4
|
+
export class NativeHaptics {
|
|
5
|
+
private vibrator: android.os.Vibrator | null = null;
|
|
6
|
+
private active = false;
|
|
7
|
+
private disposed = false;
|
|
8
|
+
private lastImpactMs = -Infinity;
|
|
9
|
+
private lastKind: NativeImpact = 'light';
|
|
10
|
+
private count = 0;
|
|
11
|
+
resume(): void {
|
|
12
|
+
if (this.disposed) return;
|
|
13
|
+
this.vibrator ??= Utils.android.getApplicationContext().getSystemService(android.content.Context.VIBRATOR_SERVICE) as android.os.Vibrator;
|
|
14
|
+
this.active = true;
|
|
15
|
+
}
|
|
16
|
+
impact(kind: NativeImpact): void {
|
|
17
|
+
if (!this.active || this.disposed || !this.vibrator?.hasVibrator()) return;
|
|
18
|
+
const now = android.os.SystemClock.uptimeMillis();
|
|
19
|
+
const priority = { light: 0, medium: 1, heavy: 2 };
|
|
20
|
+
if (now - this.lastImpactMs < 250 && priority[kind] <= priority[this.lastKind]) return;
|
|
21
|
+
const [duration, amplitude] = { light: [12, 55], medium: [24, 120], heavy: [40, 210] }[kind];
|
|
22
|
+
if (android.os.Build.VERSION.SDK_INT >= 26) {
|
|
23
|
+
const strength = this.vibrator.hasAmplitudeControl() ? amplitude : android.os.VibrationEffect.DEFAULT_AMPLITUDE;
|
|
24
|
+
this.vibrator.vibrate(android.os.VibrationEffect.createOneShot(duration, strength));
|
|
25
|
+
} else this.vibrator.vibrate(duration);
|
|
26
|
+
this.lastImpactMs = now; this.lastKind = kind; this.count++;
|
|
27
|
+
}
|
|
28
|
+
suspend(): void { this.active = false; this.vibrator?.cancel(); }
|
|
29
|
+
dispose(): void { if (this.disposed) return; this.suspend(); this.disposed = true; this.vibrator = null; }
|
|
30
|
+
snapshot() { return { active: this.active, impactsRequested: this.count, lastKind: this.lastKind }; }
|
|
31
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type NativeImpact = 'light' | 'medium' | 'heavy';
|
|
2
|
+
|
|
3
|
+
/** Reusable iOS impact feedback. Call from the main-thread game/lifecycle callbacks. */
|
|
4
|
+
export class NativeHaptics {
|
|
5
|
+
private light: UIImpactFeedbackGenerator | null = null;
|
|
6
|
+
private medium: UIImpactFeedbackGenerator | null = null;
|
|
7
|
+
private heavy: UIImpactFeedbackGenerator | null = null;
|
|
8
|
+
private active = false;
|
|
9
|
+
private disposed = false;
|
|
10
|
+
private lastImpactMs = -Infinity;
|
|
11
|
+
private lastKind: NativeImpact = 'light';
|
|
12
|
+
private count = 0;
|
|
13
|
+
|
|
14
|
+
resume(): void {
|
|
15
|
+
if (this.disposed) return;
|
|
16
|
+
this.active = true;
|
|
17
|
+
this.light ??= UIImpactFeedbackGenerator.alloc().initWithStyle(UIImpactFeedbackStyle.Light);
|
|
18
|
+
this.medium ??= UIImpactFeedbackGenerator.alloc().initWithStyle(UIImpactFeedbackStyle.Medium);
|
|
19
|
+
this.heavy ??= UIImpactFeedbackGenerator.alloc().initWithStyle(UIImpactFeedbackStyle.Heavy);
|
|
20
|
+
this.light.prepare(); this.medium.prepare(); this.heavy.prepare();
|
|
21
|
+
}
|
|
22
|
+
impact(kind: NativeImpact): void {
|
|
23
|
+
if (!this.active || this.disposed) return;
|
|
24
|
+
const now = NSProcessInfo.processInfo.systemUptime * 1000;
|
|
25
|
+
// Suppress clusters while allowing a stronger impact to supersede a softer cue.
|
|
26
|
+
const priority = { light: 0, medium: 1, heavy: 2 };
|
|
27
|
+
if (now - this.lastImpactMs < 250 && priority[kind] <= priority[this.lastKind]) return;
|
|
28
|
+
this.lastImpactMs = now; this.lastKind = kind;
|
|
29
|
+
const generator = kind === 'heavy' ? this.heavy : kind === 'medium' ? this.medium : this.light;
|
|
30
|
+
generator?.impactOccurred(); generator?.prepare(); this.count++;
|
|
31
|
+
}
|
|
32
|
+
suspend(): void { this.active = false; }
|
|
33
|
+
dispose(): void { this.suspend(); this.disposed = true; this.light = null; this.medium = null; this.heavy = null; }
|
|
34
|
+
snapshot() { return { active: this.active, impactsRequested: this.count, lastKind: this.lastKind }; }
|
|
35
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { isAndroid } from '@nativescript/core';
|
|
2
|
+
/** Copy packaged binary data into a JS-owned ArrayBuffer on either native platform. */
|
|
3
|
+
export function readNativeBytes(file: string): ArrayBuffer {
|
|
4
|
+
if (isAndroid) {
|
|
5
|
+
const stream = new java.io.FileInputStream(file);
|
|
6
|
+
try {
|
|
7
|
+
const channel = stream.getChannel();
|
|
8
|
+
const buffer = java.nio.ByteBuffer.allocateDirect(Number(channel.size()));
|
|
9
|
+
while (buffer.hasRemaining()) if (channel.read(buffer) < 0) throw new Error(`Truncated resource: ${file}`);
|
|
10
|
+
buffer.rewind();
|
|
11
|
+
// NativeScript's direct-buffer bridge avoids millions of boxed per-byte JNI calls.
|
|
12
|
+
return (ArrayBuffer as unknown as { from(value: java.nio.ByteBuffer): ArrayBuffer }).from(buffer).slice(0);
|
|
13
|
+
} finally { stream.close(); }
|
|
14
|
+
}
|
|
15
|
+
const data = NSData.dataWithContentsOfFile(file);
|
|
16
|
+
if (!data) throw new Error(`Missing bundled resource: ${file}`);
|
|
17
|
+
return interop.bufferFromData(data).slice(0);
|
|
18
|
+
}
|