@airisu-dev/react-native-live2d-skia 0.0.0-stage → 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/Live2dSkia.podspec +159 -0
- package/README.md +64 -2
- package/android/CMakeLists.txt +113 -0
- package/android/build.gradle +223 -0
- package/android/fix-prefab.gradle +51 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/cpp-adapter.cpp +9 -0
- package/android/src/main/java/com/live2dskia/Live2dSkiaPackage.kt +20 -0
- package/app.plugin.js +2 -0
- package/cli/build/index.js +157 -0
- package/cpp/cubism/CubismCompat.hpp +26 -0
- package/cpp/cubism/CubismHelpers.hpp +27 -0
- package/cpp/cubism/CubismRendererStub.cpp +14 -0
- package/cpp/cubism/CubismSetup.cpp +54 -0
- package/cpp/cubism/CubismSetup.hpp +9 -0
- package/cpp/cubism/HeldParameterUpdater.hpp +25 -0
- package/cpp/cubism/Live2DUserModel.cpp +125 -0
- package/cpp/cubism/Live2DUserModel.hpp +57 -0
- package/cpp/cubism/Live2DUserModelAnimation.cpp +107 -0
- package/cpp/drawing/JsiUnwrap.hpp +25 -0
- package/cpp/drawing/MeshCache.cpp +75 -0
- package/cpp/drawing/MeshCache.hpp +45 -0
- package/cpp/drawing/SkiaDrawer.cpp +138 -0
- package/cpp/drawing/SkiaDrawer.hpp +42 -0
- package/cpp/hybrid/HybridLive2DModel.cpp +174 -0
- package/cpp/hybrid/HybridLive2DModel.hpp +64 -0
- package/cpp/hybrid/HybridLive2dSkia.cpp +10 -0
- package/cpp/hybrid/HybridLive2dSkia.hpp +11 -0
- package/ios/Bridge.h +8 -0
- package/nitro.json +20 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/Live2dSkia+autolinking.cmake +82 -0
- package/nitrogen/generated/android/Live2dSkia+autolinking.gradle +27 -0
- package/nitrogen/generated/android/Live2dSkiaOnLoad.cpp +49 -0
- package/nitrogen/generated/android/Live2dSkiaOnLoad.hpp +34 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/live2dskia/Live2dSkiaOnLoad.kt +35 -0
- package/nitrogen/generated/ios/Live2dSkia+autolinking.rb +62 -0
- package/nitrogen/generated/ios/Live2dSkia-Swift-Cxx-Bridge.cpp +17 -0
- package/nitrogen/generated/ios/Live2dSkia-Swift-Cxx-Bridge.hpp +27 -0
- package/nitrogen/generated/ios/Live2dSkia-Swift-Cxx-Umbrella.hpp +38 -0
- package/nitrogen/generated/ios/Live2dSkiaAutolinking.mm +35 -0
- package/nitrogen/generated/ios/Live2dSkiaAutolinking.swift +16 -0
- package/nitrogen/generated/shared/c++/DrawableData.hpp +116 -0
- package/nitrogen/generated/shared/c++/HybridLive2DModelSpec.cpp +39 -0
- package/nitrogen/generated/shared/c++/HybridLive2DModelSpec.hpp +86 -0
- package/nitrogen/generated/shared/c++/HybridLive2dSkiaSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridLive2dSkiaSpec.hpp +66 -0
- package/nitrogen/generated/shared/c++/MotionPriority.hpp +80 -0
- package/package.json +146 -4
- package/plugin/build/index.d.ts +14 -0
- package/plugin/build/index.js +81 -0
- package/src/components/Live2D.tsx +131 -0
- package/src/components/index.ts +2 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/useLive2DGesture.ts +140 -0
- package/src/hooks/useLive2DModel.ts +47 -0
- package/src/index.ts +5 -0
- package/src/loader/cubismJson.ts +23 -0
- package/src/loader/index.ts +9 -0
- package/src/loader/loadModel.ts +194 -0
- package/src/loader/modelContext.ts +80 -0
- package/src/loader/modelUrl.ts +112 -0
- package/src/native.ts +4 -0
- package/src/rendering/drawer.ts +256 -0
- package/src/rendering/skia.ts +114 -0
- package/src/rendering/ticker/addToTicker.ts +52 -0
- package/src/rendering/ticker/constants.ts +11 -0
- package/src/rendering/ticker/frames.ts +35 -0
- package/src/rendering/ticker/index.ts +5 -0
- package/src/rendering/ticker/loop.ts +115 -0
- package/src/rendering/ticker/state.ts +65 -0
- package/src/rendering/ticker/stats.ts +36 -0
- package/src/rendering/ticker/types.ts +57 -0
- package/src/specs/react-native-live2d-skia.nitro.ts +88 -0
- package/src/utils/clock.ts +10 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
///
|
|
2
|
+
/// HybridLive2dSkiaSpec.hpp
|
|
3
|
+
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
|
|
4
|
+
/// https://github.com/mrousavy/nitro
|
|
5
|
+
/// Copyright © Marc Rousavy @ Margelo
|
|
6
|
+
///
|
|
7
|
+
|
|
8
|
+
#pragma once
|
|
9
|
+
|
|
10
|
+
#if __has_include(<NitroModules/HybridObject.hpp>)
|
|
11
|
+
#include <NitroModules/HybridObject.hpp>
|
|
12
|
+
#else
|
|
13
|
+
#error NitroModules cannot be found! Are you sure you installed NitroModules properly?
|
|
14
|
+
#endif
|
|
15
|
+
|
|
16
|
+
// Forward declaration of `HybridLive2DModelSpec` to properly resolve imports.
|
|
17
|
+
namespace margelo::nitro::live2dskia { class HybridLive2DModelSpec; }
|
|
18
|
+
|
|
19
|
+
#include <memory>
|
|
20
|
+
#include "HybridLive2DModelSpec.hpp"
|
|
21
|
+
#include <NitroModules/ArrayBuffer.hpp>
|
|
22
|
+
#include <string>
|
|
23
|
+
|
|
24
|
+
namespace margelo::nitro::live2dskia {
|
|
25
|
+
|
|
26
|
+
using namespace margelo::nitro;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* An abstract base class for `Live2dSkia`
|
|
30
|
+
* Inherit this class to create instances of `HybridLive2dSkiaSpec` in C++.
|
|
31
|
+
* You must explicitly call `HybridObject`'s constructor yourself, because it is virtual.
|
|
32
|
+
* @example
|
|
33
|
+
* ```cpp
|
|
34
|
+
* class HybridLive2dSkia: public HybridLive2dSkiaSpec {
|
|
35
|
+
* public:
|
|
36
|
+
* HybridLive2dSkia(...): HybridObject(TAG) { ... }
|
|
37
|
+
* // ...
|
|
38
|
+
* };
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
class HybridLive2dSkiaSpec: public virtual HybridObject {
|
|
42
|
+
public:
|
|
43
|
+
// Constructor
|
|
44
|
+
explicit HybridLive2dSkiaSpec(): HybridObject(TAG) { }
|
|
45
|
+
|
|
46
|
+
// Destructor
|
|
47
|
+
~HybridLive2dSkiaSpec() override = default;
|
|
48
|
+
|
|
49
|
+
public:
|
|
50
|
+
// Properties
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
public:
|
|
54
|
+
// Methods
|
|
55
|
+
virtual std::shared_ptr<HybridLive2DModelSpec> loadModel(const std::shared_ptr<ArrayBuffer>& moc, const std::string& settingsJson) = 0;
|
|
56
|
+
|
|
57
|
+
protected:
|
|
58
|
+
// Hybrid Setup
|
|
59
|
+
void loadHybridMethods() override;
|
|
60
|
+
|
|
61
|
+
protected:
|
|
62
|
+
// Tag for logging
|
|
63
|
+
static constexpr auto TAG = "Live2dSkia";
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
} // namespace margelo::nitro::live2dskia
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
///
|
|
2
|
+
/// MotionPriority.hpp
|
|
3
|
+
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
|
|
4
|
+
/// https://github.com/mrousavy/nitro
|
|
5
|
+
/// Copyright © Marc Rousavy @ Margelo
|
|
6
|
+
///
|
|
7
|
+
|
|
8
|
+
#pragma once
|
|
9
|
+
|
|
10
|
+
#if __has_include(<NitroModules/NitroHash.hpp>)
|
|
11
|
+
#include <NitroModules/NitroHash.hpp>
|
|
12
|
+
#else
|
|
13
|
+
#error NitroModules cannot be found! Are you sure you installed NitroModules properly?
|
|
14
|
+
#endif
|
|
15
|
+
#if __has_include(<NitroModules/JSIConverter.hpp>)
|
|
16
|
+
#include <NitroModules/JSIConverter.hpp>
|
|
17
|
+
#else
|
|
18
|
+
#error NitroModules cannot be found! Are you sure you installed NitroModules properly?
|
|
19
|
+
#endif
|
|
20
|
+
#if __has_include(<NitroModules/NitroDefines.hpp>)
|
|
21
|
+
#include <NitroModules/NitroDefines.hpp>
|
|
22
|
+
#else
|
|
23
|
+
#error NitroModules cannot be found! Are you sure you installed NitroModules properly?
|
|
24
|
+
#endif
|
|
25
|
+
|
|
26
|
+
namespace margelo::nitro::live2dskia {
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* An enum which can be represented as a JavaScript union (MotionPriority).
|
|
30
|
+
*/
|
|
31
|
+
enum class MotionPriority {
|
|
32
|
+
IDLE SWIFT_NAME(idle) = 0,
|
|
33
|
+
NORMAL SWIFT_NAME(normal) = 1,
|
|
34
|
+
FORCE SWIFT_NAME(force) = 2,
|
|
35
|
+
} CLOSED_ENUM;
|
|
36
|
+
|
|
37
|
+
} // namespace margelo::nitro::live2dskia
|
|
38
|
+
|
|
39
|
+
namespace margelo::nitro {
|
|
40
|
+
|
|
41
|
+
// C++ MotionPriority <> JS MotionPriority (union)
|
|
42
|
+
template <>
|
|
43
|
+
struct JSIConverter<margelo::nitro::live2dskia::MotionPriority> final {
|
|
44
|
+
static inline margelo::nitro::live2dskia::MotionPriority fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) {
|
|
45
|
+
std::string unionValue = JSIConverter<std::string>::fromJSI(runtime, arg);
|
|
46
|
+
switch (hashString(unionValue.c_str(), unionValue.size())) {
|
|
47
|
+
case hashString("idle"): return margelo::nitro::live2dskia::MotionPriority::IDLE;
|
|
48
|
+
case hashString("normal"): return margelo::nitro::live2dskia::MotionPriority::NORMAL;
|
|
49
|
+
case hashString("force"): return margelo::nitro::live2dskia::MotionPriority::FORCE;
|
|
50
|
+
default: [[unlikely]]
|
|
51
|
+
throw std::invalid_argument("Cannot convert \"" + unionValue + "\" to enum MotionPriority - invalid value!");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
static inline jsi::Value toJSI(jsi::Runtime& runtime, margelo::nitro::live2dskia::MotionPriority arg) {
|
|
55
|
+
switch (arg) {
|
|
56
|
+
case margelo::nitro::live2dskia::MotionPriority::IDLE: return JSIConverter<std::string>::toJSI(runtime, "idle");
|
|
57
|
+
case margelo::nitro::live2dskia::MotionPriority::NORMAL: return JSIConverter<std::string>::toJSI(runtime, "normal");
|
|
58
|
+
case margelo::nitro::live2dskia::MotionPriority::FORCE: return JSIConverter<std::string>::toJSI(runtime, "force");
|
|
59
|
+
default: [[unlikely]]
|
|
60
|
+
throw std::invalid_argument("Cannot convert MotionPriority to JS - invalid value: "
|
|
61
|
+
+ std::to_string(static_cast<int>(arg)) + "!");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) {
|
|
65
|
+
if (!value.isString()) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
std::string unionValue = JSIConverter<std::string>::fromJSI(runtime, value);
|
|
69
|
+
switch (hashString(unionValue.c_str(), unionValue.size())) {
|
|
70
|
+
case hashString("idle"):
|
|
71
|
+
case hashString("normal"):
|
|
72
|
+
case hashString("force"):
|
|
73
|
+
return true;
|
|
74
|
+
default:
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
} // namespace margelo::nitro
|
package/package.json
CHANGED
|
@@ -1,6 +1,148 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@airisu-dev/react-native-live2d-skia",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "react-native-live2d-skia is a react native package built with Nitro",
|
|
5
|
+
"main": "src/index",
|
|
6
|
+
"types": "src/index",
|
|
7
|
+
"react-native": "src/index",
|
|
8
|
+
"source": "src/index",
|
|
9
|
+
"bin": {
|
|
10
|
+
"react-native-live2d-skia": "cli/build/index.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p plugin && tsc --noEmit -p cli",
|
|
14
|
+
"test": "bun test",
|
|
15
|
+
"verify:package": "bun scripts/verifyPackage.ts",
|
|
16
|
+
"clean": "git clean -dfX",
|
|
17
|
+
"build": "bun run typecheck && bob build && tsc -b plugin/tsconfig.build.json cli/tsconfig.build.json",
|
|
18
|
+
"codegen": "nitrogen --logLevel=debug",
|
|
19
|
+
"build:node": "tsc -b plugin/tsconfig.build.json cli/tsconfig.build.json",
|
|
20
|
+
"prepack": "tsc -b plugin/tsconfig.build.json cli/tsconfig.build.json"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"react-native",
|
|
24
|
+
"react-native-live2d-skia"
|
|
25
|
+
],
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"!**/*.test.ts",
|
|
29
|
+
"react-native.config.js",
|
|
30
|
+
"lib",
|
|
31
|
+
"nitrogen",
|
|
32
|
+
"cpp",
|
|
33
|
+
"nitro.json",
|
|
34
|
+
"android/build.gradle",
|
|
35
|
+
"android/fix-prefab.gradle",
|
|
36
|
+
"android/gradle.properties",
|
|
37
|
+
"android/CMakeLists.txt",
|
|
38
|
+
"android/src",
|
|
39
|
+
"ios/**/*.h",
|
|
40
|
+
"ios/**/*.m",
|
|
41
|
+
"ios/**/*.mm",
|
|
42
|
+
"ios/**/*.cpp",
|
|
43
|
+
"ios/**/*.swift",
|
|
44
|
+
"app.plugin.js",
|
|
45
|
+
"plugin/build",
|
|
46
|
+
"cli/build",
|
|
47
|
+
"*.podspec",
|
|
48
|
+
"README.md"
|
|
49
|
+
],
|
|
50
|
+
"repository": "https://github.com/airisu-dev/react-native-live2d-skia.git",
|
|
51
|
+
"author": "airisu-dev",
|
|
52
|
+
"license": "MIT",
|
|
53
|
+
"bugs": "https://github.com/airisu-dev/react-native-live2d-skia/issues",
|
|
54
|
+
"homepage": "https://github.com/airisu-dev/react-native-live2d-skia#readme",
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public",
|
|
57
|
+
"registry": "https://registry.npmjs.org/"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/bun": "^1.4.4",
|
|
61
|
+
"@types/node": "26.6.5",
|
|
62
|
+
"@types/react": "19.2.0",
|
|
63
|
+
"nitrogen": "^0.37.1",
|
|
64
|
+
"react": "19.2.3",
|
|
65
|
+
"react-native": "0.86.3",
|
|
66
|
+
"react-native-builder-bob": "^0.40.18",
|
|
67
|
+
"react-native-nitro-modules": "^0.37.1",
|
|
68
|
+
"typescript": "^6.0.3"
|
|
69
|
+
},
|
|
70
|
+
"peerDependencies": {
|
|
71
|
+
"expo": "*",
|
|
72
|
+
"expo-asset": "*",
|
|
73
|
+
"expo-file-system": "*",
|
|
74
|
+
"react": "*",
|
|
75
|
+
"react-native": "*",
|
|
76
|
+
"react-native-gesture-handler": "*",
|
|
77
|
+
"react-native-nitro-modules": "*",
|
|
78
|
+
"react-native-reanimated": "*",
|
|
79
|
+
"react-native-skia": ">=3.3.0",
|
|
80
|
+
"@shopify/react-native-skia": ">=2.10.0",
|
|
81
|
+
"react-native-worklets": "*"
|
|
82
|
+
},
|
|
83
|
+
"peerDependenciesMeta": {
|
|
84
|
+
"@shopify/react-native-skia": {
|
|
85
|
+
"optional": true
|
|
86
|
+
},
|
|
87
|
+
"react-native-skia": {
|
|
88
|
+
"optional": true
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"eslintConfig": {
|
|
92
|
+
"root": true,
|
|
93
|
+
"extends": [
|
|
94
|
+
"@react-native",
|
|
95
|
+
"prettier"
|
|
96
|
+
],
|
|
97
|
+
"plugins": [
|
|
98
|
+
"prettier"
|
|
99
|
+
],
|
|
100
|
+
"rules": {
|
|
101
|
+
"prettier/prettier": [
|
|
102
|
+
"warn",
|
|
103
|
+
{
|
|
104
|
+
"quoteProps": "consistent",
|
|
105
|
+
"singleQuote": true,
|
|
106
|
+
"tabWidth": 2,
|
|
107
|
+
"trailingComma": "es5",
|
|
108
|
+
"useTabs": false
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
"eslintIgnore": [
|
|
114
|
+
"node_modules/",
|
|
115
|
+
"lib/"
|
|
116
|
+
],
|
|
117
|
+
"prettier": {
|
|
118
|
+
"quoteProps": "consistent",
|
|
119
|
+
"singleQuote": true,
|
|
120
|
+
"tabWidth": 2,
|
|
121
|
+
"trailingComma": "es5",
|
|
122
|
+
"useTabs": false,
|
|
123
|
+
"semi": false
|
|
124
|
+
},
|
|
125
|
+
"react-native-builder-bob": {
|
|
126
|
+
"source": "src",
|
|
127
|
+
"output": "lib",
|
|
128
|
+
"exclude": "**/{__tests__,__fixtures__,__mocks__}/**,**/*.test.ts",
|
|
129
|
+
"targets": [
|
|
130
|
+
"commonjs",
|
|
131
|
+
"module",
|
|
132
|
+
[
|
|
133
|
+
"typescript",
|
|
134
|
+
{
|
|
135
|
+
"project": "tsconfig.build.json"
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
]
|
|
139
|
+
},
|
|
140
|
+
"cubismSdk": {
|
|
141
|
+
"latest": "5-r.5",
|
|
142
|
+
"versions": {
|
|
143
|
+
"5-r.5": {
|
|
144
|
+
"sha256": "7ff3a4bbc19c0a8728965aa522ab77eb11b252916453e68a8a78d3b71188bb12"
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ConfigPlugin } from 'expo/config-plugins';
|
|
2
|
+
export interface Live2dSkiaPluginProps {
|
|
3
|
+
/** Cubism SDK for Native folder (the one with `cubism-info.yml`), relative to the app's root. */
|
|
4
|
+
cubismSdkPath: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function nativeProperties(projectRoot: string, platformProjectRoot: string, sdkPath: string): {
|
|
7
|
+
"live2dSkia.cubismSdkPath": string;
|
|
8
|
+
"live2dSkia.skiaPackage": string;
|
|
9
|
+
};
|
|
10
|
+
export declare const withLive2dSkia: ConfigPlugin<Live2dSkiaPluginProps | undefined>;
|
|
11
|
+
export declare function skiaPackageOf(projectRoot: string): string;
|
|
12
|
+
export declare function warnIfMissing(sdkDir: string): void;
|
|
13
|
+
declare const _default: ConfigPlugin<Live2dSkiaPluginProps | undefined>;
|
|
14
|
+
export default _default;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Expo config plugin: tells the native builds where the Cubism SDK for Native is.
|
|
3
|
+
//
|
|
4
|
+
// "plugins": [["@airisu-dev/react-native-live2d-skia", { "cubismSdkPath": "./vendor/CubismSdkForNative" }]]
|
|
5
|
+
//
|
|
6
|
+
// Cubism Core is proprietary and not shipped: download the SDK with
|
|
7
|
+
// `@airisu-dev/react-native-live2d-skia fetch-sdk`, accepting Live2D's license. The path is relative to the app's root.
|
|
8
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
9
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.withLive2dSkia = void 0;
|
|
13
|
+
exports.nativeProperties = nativeProperties;
|
|
14
|
+
exports.skiaPackageOf = skiaPackageOf;
|
|
15
|
+
exports.warnIfMissing = warnIfMissing;
|
|
16
|
+
const config_plugins_1 = require("expo/config-plugins");
|
|
17
|
+
const fs_1 = __importDefault(require("fs"));
|
|
18
|
+
const path_1 = __importDefault(require("path"));
|
|
19
|
+
// Read by android/build.gradle and Live2dSkia.podspec.
|
|
20
|
+
const SDK_PROPERTY = 'live2dSkia.cubismSdkPath';
|
|
21
|
+
const SKIA_PROPERTY = 'live2dSkia.skiaPackage';
|
|
22
|
+
// The Skia packages react-native-live2d-skia can draw with in C++, by preference.
|
|
23
|
+
const SKIA_PACKAGES = ['react-native-skia', '@shopify/react-native-skia'];
|
|
24
|
+
const FETCH_HINT = 'Download it with `@airisu-dev/react-native-live2d-skia fetch-sdk --accept-license` (through npx, bunx, yarn or pnpm).';
|
|
25
|
+
const pkg = JSON.parse(fs_1.default.readFileSync(path_1.default.join(__dirname, '..', '..', 'package.json'), 'utf8'));
|
|
26
|
+
// Paths are written relative to the native folder, so a prebuilt project
|
|
27
|
+
// still builds after it's moved or checked out somewhere else.
|
|
28
|
+
const relativeTo = (dir, target) => path_1.default.relative(dir, target).split(path_1.default.sep).join('/');
|
|
29
|
+
function nativeProperties(projectRoot, platformProjectRoot, sdkPath) {
|
|
30
|
+
const sdkDir = path_1.default.resolve(projectRoot, sdkPath);
|
|
31
|
+
return {
|
|
32
|
+
[SDK_PROPERTY]: relativeTo(platformProjectRoot, sdkDir),
|
|
33
|
+
[SKIA_PROPERTY]: skiaPackageOf(projectRoot),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const withLive2dSkia = (config, props) => {
|
|
37
|
+
const sdkPath = props?.cubismSdkPath;
|
|
38
|
+
if (!sdkPath) {
|
|
39
|
+
throw new Error('[react-native-live2d-skia] Set the Cubism SDK for Native folder: ' +
|
|
40
|
+
'["@airisu-dev/react-native-live2d-skia", { "cubismSdkPath": "./vendor/CubismSdkForNative" }]. ' +
|
|
41
|
+
FETCH_HINT);
|
|
42
|
+
}
|
|
43
|
+
config = (0, config_plugins_1.withGradleProperties)(config, (c) => {
|
|
44
|
+
const { projectRoot, platformProjectRoot } = c.modRequest;
|
|
45
|
+
warnIfMissing(path_1.default.resolve(projectRoot, sdkPath));
|
|
46
|
+
const values = nativeProperties(projectRoot, platformProjectRoot, sdkPath);
|
|
47
|
+
c.modResults = c.modResults.filter((item) => !(item.type === 'property' && item.key in values));
|
|
48
|
+
for (const [key, value] of Object.entries(values)) {
|
|
49
|
+
c.modResults.push({ type: 'property', key, value });
|
|
50
|
+
}
|
|
51
|
+
return c;
|
|
52
|
+
});
|
|
53
|
+
config = (0, config_plugins_1.withPodfileProperties)(config, (c) => {
|
|
54
|
+
const { projectRoot, platformProjectRoot } = c.modRequest;
|
|
55
|
+
Object.assign(c.modResults, nativeProperties(projectRoot, platformProjectRoot, sdkPath));
|
|
56
|
+
return c;
|
|
57
|
+
});
|
|
58
|
+
return config;
|
|
59
|
+
};
|
|
60
|
+
exports.withLive2dSkia = withLive2dSkia;
|
|
61
|
+
// The Skia package the app depends on, by its own package.json: in a monorepo
|
|
62
|
+
// both can be installed, but an app uses one. Empty when it has neither: the
|
|
63
|
+
// native build then leaves out the C++ drawer.
|
|
64
|
+
function skiaPackageOf(projectRoot) {
|
|
65
|
+
const appPackage = JSON.parse(fs_1.default.readFileSync(path_1.default.join(projectRoot, 'package.json'), 'utf8'));
|
|
66
|
+
const found = SKIA_PACKAGES.filter((name) => appPackage.dependencies?.[name]);
|
|
67
|
+
if (found.length > 1) {
|
|
68
|
+
console.warn(`[react-native-live2d-skia] The app depends on both ${found.join(' and ')}: drawing with ${found[0]}. Skia says not to install both.`);
|
|
69
|
+
}
|
|
70
|
+
if (found.length === 0) {
|
|
71
|
+
console.warn(`[react-native-live2d-skia] The app depends on neither ${SKIA_PACKAGES.join(' nor ')}. Install one: react-native-live2d-skia draws with Skia.`);
|
|
72
|
+
}
|
|
73
|
+
return found[0] ?? '';
|
|
74
|
+
}
|
|
75
|
+
// Not an error: CI may fetch the SDK after prebuild. The native build checks again.
|
|
76
|
+
function warnIfMissing(sdkDir) {
|
|
77
|
+
if (!fs_1.default.existsSync(path_1.default.join(sdkDir, 'cubism-info.yml'))) {
|
|
78
|
+
console.warn(`[react-native-live2d-skia] Cubism SDK for Native not found at ${sdkDir}. ${FETCH_HINT}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
exports.default = (0, config_plugins_1.createRunOncePlugin)(exports.withLive2dSkia, pkg.name, pkg.version);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import { useFrameCallback, useSharedValue } from 'react-native-reanimated'
|
|
3
|
+
import {
|
|
4
|
+
createSynchronizable,
|
|
5
|
+
createWorkletRuntime,
|
|
6
|
+
scheduleOnRuntime,
|
|
7
|
+
type WorkletRuntime,
|
|
8
|
+
} from 'react-native-worklets'
|
|
9
|
+
import type { Live2DModel } from '../loader/loadModel'
|
|
10
|
+
import { Group, Picture, Skia, type SkPicture } from '../rendering/skia'
|
|
11
|
+
import { addToTicker, type Live2DFrameStats, type PublishedFrame } from '../rendering/ticker'
|
|
12
|
+
|
|
13
|
+
export type { Live2DFrameStats } from '../rendering/ticker'
|
|
14
|
+
|
|
15
|
+
export interface Live2DProps {
|
|
16
|
+
model: Live2DModel | null
|
|
17
|
+
/** Top-left position on the canvas. */
|
|
18
|
+
x?: number
|
|
19
|
+
y?: number
|
|
20
|
+
/** Uniform scale of the model's native canvas size. */
|
|
21
|
+
scale?: number
|
|
22
|
+
/** Stops animating and keeps the current frame. */
|
|
23
|
+
paused?: boolean
|
|
24
|
+
/** Caps the frame rate, e.g. 30 to save battery. Default: the display's refresh rate. */
|
|
25
|
+
maxFps?: number
|
|
26
|
+
/** Called about once a second with timings of the animation loop. */
|
|
27
|
+
onFrameStats?: (stats: Live2DFrameStats) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DISPLAY_SAMPLE_FRAMES = 60
|
|
31
|
+
|
|
32
|
+
// Shown until the model loads. A recorder must begin recording before it can finish.
|
|
33
|
+
const emptyRecorder = Skia.PictureRecorder()
|
|
34
|
+
emptyRecorder.beginRecording(Skia.XYWHRect(0, 0, 0, 0))
|
|
35
|
+
const EMPTY_PICTURE = emptyRecorder.finishRecordingAsPicture()
|
|
36
|
+
|
|
37
|
+
// Models animate on a background thread so a busy JS thread doesn't drop frames:
|
|
38
|
+
// one runtime and one loop (rendering/ticker) for every <Live2D>.
|
|
39
|
+
let runtime: WorkletRuntime | null = null
|
|
40
|
+
const live2dRuntime = () => (runtime ??= createWorkletRuntime({ name: 'live2d' }))
|
|
41
|
+
|
|
42
|
+
export function Live2D({
|
|
43
|
+
model,
|
|
44
|
+
x = 0,
|
|
45
|
+
y = 0,
|
|
46
|
+
scale = 1,
|
|
47
|
+
paused = false,
|
|
48
|
+
maxFps,
|
|
49
|
+
onFrameStats,
|
|
50
|
+
}: Live2DProps) {
|
|
51
|
+
// A shared value lets Skia redraw each frame without a React re-render.
|
|
52
|
+
const picture = useSharedValue<SkPicture>(model?.picture ?? EMPTY_PICTURE)
|
|
53
|
+
// Reset to the load-time frame only when the model changes, so pausing keeps the current frame.
|
|
54
|
+
const shownModel = useRef<Live2DModel | null>(null)
|
|
55
|
+
// The loop publishes frames here. scheduleOnUI can't be called from a custom
|
|
56
|
+
// worklet runtime (outside Bundle Mode), so the UI thread polls instead.
|
|
57
|
+
const [latest] = useState(() => createSynchronizable<PublishedFrame>(null))
|
|
58
|
+
// The frame shown, told back to the loop so it frees only frames no longer shown.
|
|
59
|
+
const [shown] = useState(() => createSynchronizable(-1))
|
|
60
|
+
// The display's frame interval: the shortest over a window of UI frames, so
|
|
61
|
+
// dropped frames don't count. 0 until measured.
|
|
62
|
+
const [displayFrameMs] = useState(() => createSynchronizable(0))
|
|
63
|
+
const sampleMin = useSharedValue(Infinity)
|
|
64
|
+
const sampleCount = useSharedValue(0)
|
|
65
|
+
useFrameCallback(({ timeSincePreviousFrame }) => {
|
|
66
|
+
const next = latest.getDirty()
|
|
67
|
+
if (next && next.frame !== shown.getDirty()) {
|
|
68
|
+
picture.value = next.picture
|
|
69
|
+
shown.setBlocking(next.frame)
|
|
70
|
+
}
|
|
71
|
+
if (timeSincePreviousFrame !== null && timeSincePreviousFrame > 0) {
|
|
72
|
+
sampleMin.value = Math.min(sampleMin.value, timeSincePreviousFrame)
|
|
73
|
+
if (++sampleCount.value >= DISPLAY_SAMPLE_FRAMES) {
|
|
74
|
+
displayFrameMs.setBlocking(sampleMin.value)
|
|
75
|
+
sampleMin.value = Infinity
|
|
76
|
+
sampleCount.value = 0
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// Stable for the loop to call; updated after render, as refs mustn't be written while rendering.
|
|
82
|
+
const onFrameStatsRef = useRef(onFrameStats)
|
|
83
|
+
useLayoutEffect(() => {
|
|
84
|
+
onFrameStatsRef.current = onFrameStats
|
|
85
|
+
})
|
|
86
|
+
const reportStats = useCallback((stats: Live2DFrameStats) => onFrameStatsRef.current?.(stats), [])
|
|
87
|
+
const wantStats = onFrameStats !== undefined
|
|
88
|
+
const capFrameMs = maxFps ? 1000 / maxFps : 0
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!model) return
|
|
92
|
+
if (shownModel.current !== model) {
|
|
93
|
+
shownModel.current = model
|
|
94
|
+
// set(), not `.value =`, outside worklets: the React Compiler form.
|
|
95
|
+
picture.set(model.picture)
|
|
96
|
+
}
|
|
97
|
+
if (paused) return
|
|
98
|
+
|
|
99
|
+
// Frame numbers restart when the model starts animating again.
|
|
100
|
+
latest.setBlocking(null)
|
|
101
|
+
shown.setBlocking(-1)
|
|
102
|
+
const running = createSynchronizable(true)
|
|
103
|
+
scheduleOnRuntime(
|
|
104
|
+
live2dRuntime(),
|
|
105
|
+
addToTicker,
|
|
106
|
+
{
|
|
107
|
+
id: model.id,
|
|
108
|
+
native: model.native,
|
|
109
|
+
textures: model.textures,
|
|
110
|
+
shaders: model.shaders,
|
|
111
|
+
canvas: model.canvas,
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
target: latest,
|
|
115
|
+
shown,
|
|
116
|
+
running,
|
|
117
|
+
displayMs: displayFrameMs,
|
|
118
|
+
capMs: capFrameMs,
|
|
119
|
+
report: wantStats ? reportStats : null,
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
return () => running.setBlocking(false)
|
|
123
|
+
}, [model, paused, picture, latest, shown, displayFrameMs, capFrameMs, wantStats, reportStats])
|
|
124
|
+
|
|
125
|
+
if (!model) return null
|
|
126
|
+
return (
|
|
127
|
+
<Group transform={[{ translateX: x }, { translateY: y }, { scale }]}>
|
|
128
|
+
<Picture picture={picture} />
|
|
129
|
+
</Group>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
Live2DGestureOptions,
|
|
3
|
+
Live2DGesturesOptions,
|
|
4
|
+
Live2DGestureTarget,
|
|
5
|
+
} from './useLive2DGesture'
|
|
6
|
+
export { useLive2DGesture, useLive2DGestures } from './useLive2DGesture'
|
|
7
|
+
export type { Live2DModelState } from './useLive2DModel'
|
|
8
|
+
export { useLive2DModel } from './useLive2DModel'
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useLayoutEffect, useMemo, useRef } from 'react'
|
|
2
|
+
import { Gesture } from 'react-native-gesture-handler'
|
|
3
|
+
import type { Live2DModel } from '../loader/loadModel'
|
|
4
|
+
|
|
5
|
+
export interface Live2DGestureTarget {
|
|
6
|
+
model: Live2DModel | null
|
|
7
|
+
x?: number
|
|
8
|
+
y?: number
|
|
9
|
+
scale?: number
|
|
10
|
+
/**
|
|
11
|
+
* Called with the hit areas under a tap. Return true to skip the default
|
|
12
|
+
* reaction: a `Tap<area>` motion, else a random expression for `Head`, else a `Tap` motion.
|
|
13
|
+
*/
|
|
14
|
+
onHit?: (hitAreas: string[]) => boolean | void
|
|
15
|
+
/** Makes the model draggable by long-press. Called with its new top-left; the app moves it. */
|
|
16
|
+
onMove?: (x: number, y: number) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Live2DGesturesOptions {
|
|
20
|
+
/** Models follow the finger while it's down. Default true. */
|
|
21
|
+
focus?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Live2DGestureOptions
|
|
25
|
+
extends Omit<Live2DGestureTarget, 'model'>,
|
|
26
|
+
Live2DGesturesOptions {}
|
|
27
|
+
|
|
28
|
+
function react(model: Live2DModel, hitAreas: string[]) {
|
|
29
|
+
for (const area of hitAreas) {
|
|
30
|
+
const group = `Tap${area}`
|
|
31
|
+
if (model.motionGroups.includes(group)) {
|
|
32
|
+
model.motion(group)
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
if (area === 'Head' && model.expressionNames.length > 0) {
|
|
36
|
+
model.expression()
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Some models have one `Tap` group for every area.
|
|
41
|
+
if (model.motionGroups.includes('Tap')) model.motion('Tap')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const LONG_PRESS_MS = 350
|
|
45
|
+
|
|
46
|
+
function toModel({ x = 0, y = 0, scale = 1 }: Live2DGestureTarget, px: number, py: number) {
|
|
47
|
+
return [(px - x) / scale, (py - y) / scale] as const
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function topmostHit(targets: Live2DGestureTarget[], px: number, py: number) {
|
|
51
|
+
// Later targets are drawn over earlier ones.
|
|
52
|
+
for (let i = targets.length - 1; i >= 0; i--) {
|
|
53
|
+
const t = targets[i]!
|
|
54
|
+
if (!t.model) continue
|
|
55
|
+
const hitAreas = t.model.hitTest(...toModel(t, px, py))
|
|
56
|
+
if (hitAreas.length > 0) return { target: t, hitAreas }
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Touch interaction for several `<Live2D>` models in one canvas, for a
|
|
63
|
+
* `<GestureDetector>` around the `<Canvas>`. List targets in drawing order.
|
|
64
|
+
*/
|
|
65
|
+
export function useLive2DGestures(
|
|
66
|
+
targets: Live2DGestureTarget[],
|
|
67
|
+
{ focus = true }: Live2DGesturesOptions = {},
|
|
68
|
+
) {
|
|
69
|
+
// The React Compiler miscompiled these callbacks (a `let` shared between them).
|
|
70
|
+
'use no memo'
|
|
71
|
+
|
|
72
|
+
// Read at event time, so the gesture is built once. Updated after render.
|
|
73
|
+
const latest = useRef(targets)
|
|
74
|
+
useLayoutEffect(() => {
|
|
75
|
+
latest.current = targets
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// The callbacks only run on touch events; the refs rule can't tell through RNGH's builder API.
|
|
79
|
+
/* eslint-disable react-hooks/refs */
|
|
80
|
+
return useMemo(() => {
|
|
81
|
+
const focusAll = (px: number, py: number) => {
|
|
82
|
+
for (const t of latest.current) t.model?.focus(...toModel(t, px, py))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const pan = Gesture.Pan()
|
|
86
|
+
.runOnJS(true)
|
|
87
|
+
.enabled(focus)
|
|
88
|
+
.onBegin((e) => focusAll(e.x, e.y))
|
|
89
|
+
.onUpdate((e) => focusAll(e.x, e.y))
|
|
90
|
+
.onFinalize(() => {
|
|
91
|
+
for (const t of latest.current) t.model?.resetFocus()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// Shorter than a long press, so picking a model up isn't also a tap.
|
|
95
|
+
const tap = Gesture.Tap()
|
|
96
|
+
.runOnJS(true)
|
|
97
|
+
.maxDuration(LONG_PRESS_MS - 50)
|
|
98
|
+
.onEnd((e, success) => {
|
|
99
|
+
if (!success) return
|
|
100
|
+
const hit = topmostHit(latest.current, e.x, e.y)
|
|
101
|
+
if (hit && hit.target.onHit?.(hit.hitAreas) !== true) {
|
|
102
|
+
react(hit.target.model!, hit.hitAreas)
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// The dragged model (found again by model, as targets can change) and the finger's
|
|
107
|
+
// offset from its top-left. On an object so it survives recompilation.
|
|
108
|
+
const drag: {
|
|
109
|
+
current: { model: Live2DModel; dx: number; dy: number } | null
|
|
110
|
+
} = { current: null }
|
|
111
|
+
const dragPan = Gesture.Pan()
|
|
112
|
+
.runOnJS(true)
|
|
113
|
+
.activateAfterLongPress(LONG_PRESS_MS)
|
|
114
|
+
.onStart((e) => {
|
|
115
|
+
const hit = topmostHit(latest.current, e.x, e.y)
|
|
116
|
+
if (!hit?.target.onMove) return
|
|
117
|
+
const { model, x = 0, y = 0 } = hit.target
|
|
118
|
+
drag.current = { model: model!, dx: e.x - x, dy: e.y - y }
|
|
119
|
+
})
|
|
120
|
+
.onUpdate((e) => {
|
|
121
|
+
if (!drag.current) return
|
|
122
|
+
const { model, dx, dy } = drag.current
|
|
123
|
+
latest.current.find((t) => t.model === model)?.onMove?.(e.x - dx, e.y - dy)
|
|
124
|
+
})
|
|
125
|
+
.onFinalize(() => {
|
|
126
|
+
drag.current = null
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
return Gesture.Simultaneous(pan, tap, dragPan)
|
|
130
|
+
}, [focus])
|
|
131
|
+
/* eslint-enable react-hooks/refs */
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Touch interaction for one `<Live2D>`. For several models in one canvas, use `useLive2DGestures`. */
|
|
135
|
+
export function useLive2DGesture(
|
|
136
|
+
model: Live2DModel | null,
|
|
137
|
+
{ focus, ...target }: Live2DGestureOptions = {},
|
|
138
|
+
) {
|
|
139
|
+
return useLive2DGestures([{ model, ...target }], { focus })
|
|
140
|
+
}
|