724 lines
19 KiB
Markdown
724 lines
19 KiB
Markdown
# Godot Remote
|
|
|
|
This is cross platform native module for [Godot Engine](https://github.com/godotengine/godot) v3 for control apps and games over WiFi or ADB.
|
|
|
|
If you are developing on a non-touch device, this module is the best way to quickly test touch input or test mobile sensors data.
|
|
|
|
[Video Demonstration](https://youtu.be/LbFcQnS3z3E)
|
|
|
|
[Custom Packets Demo](https://youtu.be/RmhppDWZZk8)
|
|
|
|
## Support
|
|
|
|
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I53VZ2D)
|
|
|
|
[![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://paypal.me/dmitriysalnikov)
|
|
|
|
## Compiling the Module
|
|
|
|
### As a module
|
|
|
|
1. [configure environment](https://docs.godotengine.org/en/3.2/development/compiling/index.html) to build editor for your platform (you need to clone [3.2 branch](https://github.com/godotengine/godot/tree/3.2) not master)
|
|
2. copy ```godot_remote``` folder to the ```modules/``` directory or make [symlink](https://en.wikipedia.org/wiki/Symbolic_link)
|
|
3. compile engine with instructions from documentation above (e.g. ```scons p=windows tools=yes -j[place here count of your CPU threads]```)
|
|
4. run ```bin/godot[based on config]```.
|
|
|
|
If everything compiles successfully, you'll find the new category in project settings ```Debug/Godot Remote``` where you can configure server.
|
|
|
|
![Settings](Images/Screenshots/settings.png)
|
|
|
|
### As a GDNative library
|
|
|
|
1. [Configure environment](https://docs.godotengine.org/en/3.2/development/compiling/index.html) to build editor for your platform
|
|
2. Generate api.json for GDNative api. ```bin/godot --gdnative-generate-json-api api.json```
|
|
3. Copy api.json to the root directory of this repository
|
|
4. Compile godot-cpp (e.g. in godot-cpp directory run ```scons generate_bindings=true platform=windows target=release bits=64 -j8 ../api.json```)
|
|
5. Compile module for your platform (Available platforms: windows, osx, linux, ios, android. Tested platforms: windows, linux, android)
|
|
1. For android: Run in root directory ```[path to your android ndk root dir]/ndk-build NDK_PROJECT_PATH=. APP_BUILD_SCRIPT=Android.mk APP_PLATFORM=android-21```
|
|
2. For all other platforms: ```scons platform=windows target=release -j8```
|
|
6. Use produced library in ```bin/```
|
|
|
|
GDNative has limitations so here ```GodotRemote``` is not a singleton and you need to create autoload scene with attached NativeScript for ```GodotRemote``` class. Also there is no any settings in ```Debug/Godot Remote```.
|
|
|
|
Enum constants in this version changed too (see [API Reference] )
|
|
|
|
**Currently, the GDNative version does not support the assignment of sensor data, so the editor will not support accelerometer, gyroscope, etc.
|
|
Also, this version may crash at a random moment.**
|
|
|
|
If GDNative becomes more stable, I will add the necessary code to easily integrate this module into any project, but now it just works.. sometimes.
|
|
|
|
### Additional parameters
|
|
|
|
Also module has additional compilation parameters for scons script
|
|
|
|
1. ```godot_remote_no_default_resources``` (yes/no) default no - compile with or without default resources
|
|
2. ```godot_remote_disable_server``` (yes/no) default no - do not include server code
|
|
3. ```godot_remote_disable_client``` (yes/no) default no - do not include client code
|
|
|
|
## Download
|
|
|
|
Precompiled binaries can be found on [GitHub Releases](https://github.com/DmitriySalnikov/GodotRemote/releases) page
|
|
|
|
### Mobile app
|
|
|
|
On releases page you can found precompiled mobile app but also it can be downloaded from [Google Play](https://play.google.com/store/apps/details?id=com.dmitriysalnikov.godotremote)
|
|
|
|
## Configure Mobile App
|
|
|
|
To open settings menu you need to touch the screen with 5 fingers at once.
|
|
|
|
Then you'll see this settings menu:
|
|
|
|
![Settings](Images/Screenshots/mobile_settings.png)
|
|
|
|
**Important:** after entering server address you should apply it by pressing `Set Type and Address` or `Set Type and Port`
|
|
|
|
## Custom client
|
|
|
|
If need to support other platforms or you need a specific version of module integrated to the client app, you can build client from source code placed [here](godot_remote_client).
|
|
|
|
If you don't want to use my client app you can check the [example client project](examples/simple_client) and build your own client.
|
|
|
|
Or you can donate me some money with request to buy iPhone and adapt a client for it 🙂
|
|
|
|
## API Reference
|
|
|
|
Methods will be declared follows this template:
|
|
|
|
```python
|
|
return_type function_name([arg_name1 : type [= defalut value]][, arg_name2 : type [= defalut value]])
|
|
```
|
|
|
|
**Important:** All enums in GDNative version is exposed in GodotRemote class because of limitations.
|
|
For example, if you want to use StreamState.STREAM_ACTIVE from GRClient you need to get property GRClient_STREAM_ACTIVE of GodotRemote __object__
|
|
|
|
```python
|
|
# Godot module:
|
|
GRClient.STREAM_ACTIVE:
|
|
|
|
# GDNative
|
|
# *GodotRemote is autoload scene with attached NativeScript
|
|
GodotRemote.GRClient_STREAM_ACTIVE
|
|
```
|
|
|
|
### GodotRemote
|
|
|
|
Main class of module.
|
|
|
|
```python
|
|
# --- Properties
|
|
|
|
# Canvas layer that shows notifications
|
|
# type int, default 128
|
|
notifications_layer
|
|
|
|
# Notifications position on screen
|
|
# type GRNotifications.NotificationsPosition, default TC
|
|
notifications_position
|
|
|
|
# Is notifications enabled
|
|
# type bool, default true
|
|
notifications_enabled
|
|
|
|
# Base duration for showing notifications
|
|
# type float, default 3.0
|
|
notifications_duration
|
|
|
|
# Notifcations style
|
|
# type GRNotificationStyle
|
|
notifications_style
|
|
|
|
# --- Methods
|
|
|
|
# Notifications
|
|
|
|
# Adds or fully update existing notification
|
|
# @title: Notification title
|
|
# @text: Text of notification
|
|
# @notification_icon: Notification icon from enum NotificationIcon
|
|
# @update_existing: Updates existing notification
|
|
# @duration_multiplier: Multiply base notifications duration
|
|
void add_notification(title: String, text: String, notification_icon: GRNotifications.NotificationIcon = 0, update_existing: bool = true, duration_multiplier: float = 1.0)
|
|
|
|
# Adds new notification or append text to existing notification
|
|
# @title: Notification title
|
|
# @text: Text of notification
|
|
# @icon: Notification icon from enum NotificationIcon
|
|
# @add_to_new_line: Adds text to new line or adds to current line
|
|
void add_notification_or_append_string(title: String, text: String, icon: GRNotifications.NotificationIcon, add_to_new_line: bool = true, duration_multiplier: float = 1.0)
|
|
|
|
# Adds notification or update one line of notification text
|
|
# @title: Notification title
|
|
# @id: Line ID
|
|
# @text: Text of notification
|
|
# @icon: Notification icon from enum NotificationIcon
|
|
# @duration_multiplier: Multiply base notifications duration
|
|
void add_notification_or_update_line(title: String, id: String, text: String, icon: GRNotifications.NotificationIcon, duration_multiplier: float = 1.0)
|
|
|
|
# Clear all notifications
|
|
void clear_notifications()
|
|
|
|
# Get notifications list
|
|
# @return list of all visible notifications
|
|
Array get_all_notifications()
|
|
|
|
# Get notification with specified title or null
|
|
# @title: Notification title
|
|
# @return matched notification
|
|
GRNotificationPanel get_notification(title: String)
|
|
|
|
# Get all notifications with specified title
|
|
# @title: Notification title
|
|
# @return list of visible notifications
|
|
Array get_notifications_with_title(title: String)
|
|
|
|
# Remove notifications with specified title
|
|
# @title: Notifications title
|
|
# @is_all_entries: Delete all notifications with @title if true
|
|
void remove_notification(title: String, is_all_entries: bool = true)
|
|
|
|
# Remove exact notification by reference
|
|
# @notification: Notification reference
|
|
void remove_notification_exact(notification: Node)
|
|
|
|
# Client/Server
|
|
|
|
# Create device: client or server
|
|
# @device_type: Type of device
|
|
# @return true if device created successful
|
|
bool create_remote_device(device_type: GodotRemote.DeviceType = 0)
|
|
|
|
# Start device
|
|
# @return true if device valid
|
|
bool start_remote_device()
|
|
|
|
# Create and start device
|
|
# @device_type: Type of device
|
|
void create_and_start_device(device_type: GodotRemote.DeviceType = 0)
|
|
|
|
# Remove and delete currently working device
|
|
# @return true if succeed
|
|
bool remove_remote_device()
|
|
|
|
# Get device
|
|
# @return client, server or null
|
|
GRDevice get_device()
|
|
|
|
# Utility functions
|
|
|
|
# Not exposed to GDScript fuctions from Input class
|
|
# And currently not available in GDNative
|
|
void set_accelerometer(value: Vector3)
|
|
void set_gravity(value: Vector3)
|
|
void set_gyroscope(value: Vector3)
|
|
void set_magnetometer(value: Vector3)
|
|
|
|
# Set GodotRemote log level
|
|
# @level: Level of logging
|
|
void set_log_level(level: LogLevel)
|
|
|
|
# Get GodotRemote module version
|
|
# @return module version in format "MAJOR.MINOR.BUILD"
|
|
String get_version()
|
|
|
|
# --- Signals
|
|
|
|
# Device added
|
|
device_added()
|
|
|
|
# Device removed
|
|
device_removed()
|
|
|
|
# --- Enumerations
|
|
|
|
DeviceType:
|
|
DEVICE_AUTO = 0
|
|
DEVICE_SERVER = 1
|
|
DEVICE_CLIENT = 2
|
|
|
|
LogLevel:
|
|
LL_NONE = 4
|
|
LL_DEBUG = 0
|
|
LL_NORMAL = 1
|
|
LL_WARNING = 2
|
|
LL_ERROR = 3
|
|
```
|
|
|
|
### GRNotifications
|
|
|
|
Container for all notifications
|
|
|
|
```python
|
|
|
|
# --- Signals
|
|
|
|
# Called when a single notification is added
|
|
notification_added(title: String, text: String)
|
|
|
|
# Called when a single notification is removed
|
|
notification_removed(title: String, is_cleared: bool)
|
|
|
|
# Called when all notifications are cleared
|
|
notifications_cleared()
|
|
|
|
# Called when notifications are enabled or disabled
|
|
notifications_toggled(is_enabled: bool)
|
|
|
|
# --- Enumerations
|
|
|
|
NotificationIcon:
|
|
ICON_NONE = 0
|
|
ICON_ERROR = 1
|
|
ICON_WARNING = 2
|
|
ICON_SUCCESS = 3
|
|
ICON_FAIL = 4
|
|
|
|
NotificationsPosition:
|
|
TOP_LEFT = 0
|
|
TOP_CENTER = 1
|
|
TOP_RIGHT = 2
|
|
BOTTOM_LEFT = 3
|
|
BOTTOM_CENTER = 4
|
|
BOTTOM_RIGHT = 5
|
|
```
|
|
|
|
### GRNotificationStyle
|
|
|
|
Helper class to store parameters of notifications style
|
|
|
|
```python
|
|
# --- Properties
|
|
|
|
# Style of background notifications panel
|
|
# type StyleBox
|
|
panel_style
|
|
|
|
# Theme for notification close button
|
|
# type Theme
|
|
close_button_theme
|
|
|
|
# Close button icon texture
|
|
# type Texture
|
|
close_button_icon
|
|
|
|
# Notification title font
|
|
# type Font
|
|
title_font
|
|
|
|
# Notification text font
|
|
# type Font
|
|
text_font
|
|
|
|
# --- Methods
|
|
|
|
# Get notification icon from this style
|
|
# @notification_icon: Notfication icon id
|
|
# @return icon texture of null
|
|
Texture get_notification_icon(notification_icon: GRNotifications.NotificationIcon)
|
|
|
|
# Set notification icon in this style
|
|
# @notification_icon: Notfication icon id
|
|
# @icon_texture: Icon texture
|
|
void set_notification_icon(notification_icon: GRNotifications.NotificationIcon, icon_texture: Texture)
|
|
|
|
```
|
|
|
|
### GRInputData
|
|
|
|
Container for all InputEvents
|
|
|
|
```python
|
|
# --- Enumerations
|
|
|
|
InputType:
|
|
_NoneIT = 0
|
|
_InputDeviceSensors = 1
|
|
_InputEvent = 64
|
|
_InputEventAction = 65
|
|
_InputEventGesture = 66
|
|
_InputEventJoypadButton = 67
|
|
_InputEventJoypadMotion = 68
|
|
_InputEventKey = 69
|
|
_InputEventMagnifyGesture = 70
|
|
_InputEventMIDI = 71
|
|
_InputEventMouse = 72
|
|
_InputEventMouseButton = 73
|
|
_InputEventMouseMotion = 74
|
|
_InputEventPanGesture = 75
|
|
_InputEventScreenDrag = 76
|
|
_InputEventScreenTouch = 77
|
|
_InputEventWithModifiers = 78
|
|
_InputEventMAX = 79
|
|
```
|
|
|
|
### GRPacket
|
|
|
|
The basic data type used to exchange information between the client and the server
|
|
|
|
```python
|
|
# --- Enumerations
|
|
|
|
PacketType:
|
|
NonePacket = 0
|
|
SyncTime = 1
|
|
ImageData = 2
|
|
InputData = 3
|
|
ServerSettings = 4
|
|
MouseModeSync = 5
|
|
CustomInputScene = 6
|
|
ClientStreamOrientation = 7
|
|
ClientStreamAspect = 8
|
|
CustomUserData = 9
|
|
Ping = 128
|
|
Pong = 192
|
|
```
|
|
|
|
### GRDevice
|
|
|
|
Base class for client and server
|
|
|
|
```python
|
|
# --- Properties
|
|
|
|
# Connection port
|
|
# type int, default 52341
|
|
port
|
|
|
|
# --- Methods
|
|
|
|
# Send user data to remote device
|
|
# @packet_id: any data to identify your packet
|
|
# @user_data: any data to send to remote device
|
|
# @full_objects: flag for full serialization of objects, possibly with their executable code. For more info check Godot's PacketPeer.put_var() and PacketPeer.get_var()
|
|
void send_user_data(packet_id: Variant, user_data: Variant, full_objects: bool = false)
|
|
|
|
# Get average FPS
|
|
# @return average FPS
|
|
float get_avg_fps()
|
|
|
|
# Get minimum FPS
|
|
# @return minimum FPS
|
|
float get_min_fps()
|
|
|
|
# Get maximum FPS
|
|
# @return maximum FPS
|
|
float get_max_fps()
|
|
|
|
# Get average ping
|
|
# @return average ping
|
|
float get_avg_ping()
|
|
|
|
# Get minimum ping
|
|
# @return minimum ping
|
|
float get_min_ping()
|
|
|
|
# Get maximum ping
|
|
# @return maximum ping
|
|
float get_max_ping()
|
|
|
|
# Get device status
|
|
WorkingStatus get_status()
|
|
|
|
# Start device
|
|
void start()
|
|
|
|
# Stop device
|
|
void stop()
|
|
|
|
# --- Signals
|
|
|
|
# Device status changed
|
|
status_changed(status: GRDevice.WorkingStatus)
|
|
|
|
# User data received from a remote device
|
|
user_data_received(packet_id: Variant, user_data: Variant)
|
|
|
|
# --- Enumerations
|
|
|
|
ImageCompressionType:
|
|
COMPRESSION_UNCOMPRESSED = 0
|
|
COMPRESSION_JPG = 1
|
|
COMPRESSION_PNG = 2
|
|
|
|
Subsampling:
|
|
SUBSAMPLING_Y_ONLY = 0
|
|
SUBSAMPLING_H1V1 = 1
|
|
SUBSAMPLING_H2V1 = 2
|
|
SUBSAMPLING_H2V2 = 3
|
|
|
|
TypesOfServerSettings:
|
|
SERVER_SETTINGS_USE_INTERNAL = 0
|
|
SERVER_SETTINGS_VIDEO_STREAM_ENABLED = 1
|
|
SERVER_SETTINGS_COMPRESSION_TYPE = 2
|
|
SERVER_SETTINGS_JPG_QUALITY = 3
|
|
SERVER_SETTINGS_SKIP_FRAMES = 4
|
|
SERVER_SETTINGS_RENDER_SCALE = 5
|
|
|
|
WorkingStatus:
|
|
STATUS_STARTING = 3
|
|
STATUS_STOPPING = 2
|
|
STATUS_WORKING = 1
|
|
STATUS_STOPPED = 0
|
|
```
|
|
|
|
### GRServer
|
|
|
|
```python
|
|
# --- Properties
|
|
|
|
# Server password
|
|
# type String, default ""
|
|
password
|
|
|
|
# Path to the custom input scene.
|
|
# type String, default ""
|
|
custom_input_scene
|
|
|
|
# Is custom input scene compressed
|
|
## Doesn't work in GDNative
|
|
# type bool, default true
|
|
custom_input_scene_compressed
|
|
|
|
# Compression type of custom input scene
|
|
## Doesn't work in GDNative
|
|
# type File.CompressionMode, default FastLZ
|
|
custom_input_scene_compression_type
|
|
|
|
# --- Methods
|
|
|
|
# Set whether the stream is enabled
|
|
bool set_video_stream_enabled(value : bool)
|
|
|
|
# Get whether the stream is enabled
|
|
bool is_video_stream_enabled()
|
|
|
|
# Set how many frames to skip
|
|
bool set_skip_frames(frames : int)
|
|
|
|
# Get the number of skipping frames
|
|
int get_skip_frames()
|
|
|
|
# Set JPG quality
|
|
bool set_jpg_quality(quality : int)
|
|
|
|
# Get JPG quality
|
|
int get_jpg_quality()
|
|
|
|
# Set the scale of the stream
|
|
bool set_render_scale(scale : float)
|
|
|
|
# Get stream scale
|
|
float get_render_scale()
|
|
|
|
# Force update custom input scene on client
|
|
void force_update_custom_input_scene()
|
|
|
|
# Get resize viewport node
|
|
# @return resize viewport or null
|
|
GRSViewport get_gr_viewport()
|
|
|
|
|
|
# --- Signals
|
|
|
|
# On client connected
|
|
client_connected(device_id: String)
|
|
|
|
# On client disconnected
|
|
client_disconnected(device_id: String)
|
|
|
|
# On orientation of client's screen or viewport changed
|
|
client_viewport_orientation_changed(is_vertical: bool)
|
|
|
|
# On client's screen or viewport aspect ratio changed
|
|
client_viewport_aspect_ratio_changed(stream_aspect: float)
|
|
|
|
```
|
|
|
|
### GRClient
|
|
|
|
```python
|
|
# --- Properties
|
|
|
|
# Capture input only when containing control has focus
|
|
# type bool, default false
|
|
capture_on_focus
|
|
|
|
# Capture input only when stream image hovered
|
|
# type bool, default true
|
|
capture_when_hover
|
|
|
|
# Capture mouse pointer and touch events
|
|
# type bool, default true
|
|
capture_pointer
|
|
|
|
# Capture input
|
|
# type bool, default true
|
|
capture_input
|
|
|
|
# Type of connection
|
|
# type GRClient.ConnectionType, default CONNECTION_WiFi
|
|
connection_type
|
|
|
|
# Frequency of sending data to the server
|
|
# type int, default 60
|
|
target_send_fps
|
|
|
|
# Stretch mode of stream image
|
|
# type GRClient.StretchMode, default STRETCH_KEEP_ASPECT
|
|
stretch_mode
|
|
|
|
# Use texture filtering of stream image
|
|
# type bool, default true
|
|
texture_filtering
|
|
|
|
# Password
|
|
# type String, default ""
|
|
password
|
|
|
|
# ID of device
|
|
# type String, default 6 random digits and characters
|
|
device_id
|
|
|
|
# Sync viewport orientation with server
|
|
# type bool, default true
|
|
viewport_orientation_syncing
|
|
|
|
# Sync viewport aspect ratio with server
|
|
# type bool, default true
|
|
viewport_aspect_ratio_syncing
|
|
|
|
# Receive updated server settings
|
|
# type bool, default false
|
|
server_settings_syncing
|
|
|
|
# --- Methods
|
|
|
|
# Restore settings on server
|
|
void disable_overriding_server_settings()
|
|
|
|
# Get the current visible custom input scene
|
|
# @return: Custom input scene
|
|
Node get_custom_input_scene()
|
|
|
|
# Get server address
|
|
# @return server address
|
|
String get_address()
|
|
|
|
# Is connected to server
|
|
# @return true if connected to server
|
|
bool is_connected_to_host()
|
|
|
|
# Is stream active
|
|
# @return true if stream active
|
|
bool is_stream_active()
|
|
|
|
# Set server address to connect
|
|
# @ip: IP of server
|
|
# @return true if address is valid
|
|
bool set_address(ip: String)
|
|
|
|
# Set both server address and port
|
|
# @ip: IP of server
|
|
# @port: Port of server
|
|
# @return true if address is valid
|
|
bool set_address_port(ip: String, port: int)
|
|
|
|
# Set the control to show stream in
|
|
# @control_node: Control where stream will be shown
|
|
# @position_in_node: Position of stream in parent
|
|
void set_control_to_show_in(control_node: Control, position_in_node: int = 0)
|
|
|
|
# Set custom material for no signal screen
|
|
# @material: Custom material
|
|
void set_custom_no_signal_material(material: Material)
|
|
|
|
# Set custom horizontal texture for no signal screen
|
|
# @texture: Custom texture
|
|
void set_custom_no_signal_texture(texture: Texture)
|
|
|
|
# Set custom vertical texture for no signal screen
|
|
# @texture: Custom texture
|
|
void set_custom_no_signal_vertical_texture(texture: Texture)
|
|
|
|
# Override setting on server
|
|
# @setting: Which setting need to change
|
|
# @value: Value of setting
|
|
void set_server_setting(setting: GRdevice.TypesOfServerSettings, value: Variant)
|
|
|
|
# --- Signals
|
|
|
|
# On custom input scene added and becomes visible
|
|
custom_input_scene_added()
|
|
|
|
# On custom input scene removed
|
|
custom_input_scene_removed()
|
|
|
|
# On connection state changed
|
|
connection_state_changed(is_connected: bool)
|
|
|
|
# On stream state changed
|
|
stream_state_changed(state: GRClient.StreamState)
|
|
|
|
# On mouse mode changed on server
|
|
mouse_mode_changed(mouse_mode: Input.MouseMode)
|
|
|
|
# On received server settings from server
|
|
server_settings_received(settings: Dictionary)
|
|
|
|
# --- Enumerations
|
|
|
|
ConnectionType:
|
|
CONNECTION_ADB = 1
|
|
CONNECTION_WiFi = 0
|
|
|
|
StreamState:
|
|
STREAM_NO_SIGNAL = 0
|
|
STREAM_ACTIVE = 1
|
|
STREAM_NO_IMAGE = 2
|
|
|
|
StretchMode:
|
|
STRETCH_KEEP_ASPECT = 0
|
|
STRETCH_FILL = 1
|
|
```
|
|
|
|
There is no need to describe other classes here
|
|
|
|
## Custom Input Scenes
|
|
|
|
In custom input scenes you can use everything you want but to send InputEvent's from client to server you must emulate input. Or use the send_user_data() method and user_data_received signal for send and receive custom packets.
|
|
Example:
|
|
|
|
```python
|
|
# -- With InputEvent's
|
|
|
|
func _on_pressed():
|
|
# Create event for pressed state
|
|
var iea_p = InputEventAction.new()
|
|
iea_p.pressed = true
|
|
iea_p.action = "jump"
|
|
# Create event for released state
|
|
var iea_r = InputEventAction.new()
|
|
iea_r.pressed = false
|
|
iea_p.action = "jump"
|
|
# Parse event to send it to the server
|
|
Input.parse_input_event(iea_p)
|
|
Input.parse_input_event(iea_r)
|
|
|
|
# -- With custom packets
|
|
|
|
# on first device
|
|
func _ready():
|
|
GodotRemote.get_device().connect("user_data_received", self, "_on_user_data_received")
|
|
|
|
func _on_user_data_received(id, data):
|
|
print("Received packet: %s, data: %s" % [id, data])
|
|
|
|
# on second device
|
|
func _on_button_pressed():
|
|
GodotRemote.get_device().send_user_data("bg_color", color, false)
|
|
```
|
|
|
|
## License
|
|
|
|
MIT license
|