initial commit
Some checks failed
linting & formatting / build (push) Failing after 5s
itch.io publish action / build (linux64, x86_64) (push) Failing after 34s
itch.io publish action / build (osx, app) (push) Failing after 30s
itch.io publish action / build (win64, exe) (push) Failing after 31s

This commit is contained in:
duncgibbs 2026-04-13 11:34:00 -05:00
parent dea39d77f3
commit 99de9e8b40
216 changed files with 11363 additions and 27 deletions

7
addons/tube/LICENCE.md Normal file
View File

@ -0,0 +1,7 @@
Copyright (c) 2025 Koop Myers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

264
addons/tube/README.md Normal file
View File

@ -0,0 +1,264 @@
# Tube
A lightweight Godot addon that helps create simple multiplayer sessions.
One player creates a session and shares the session ID with others through an external channel (WhatsApp, Discord, etc.). The other players can then join and play together. Thats it, no server deployment needed.
## Use case & limitation
Tube works on any platform that supports WebRTC over the internet, see [Requirements](#requirements).
It also runs on non-web platforms (Windows, macOS, Linux, Android, iOS) over a local network, without needing an internet connection.
However, the benefit of not having to deploy a server comes with a trade-off: in some cases, two peers may fail to connect. To better understand why this happens, see [How it works](#how-it-works).
Because no server is deployed by default, Tube may not be suitable for projects that require high stability or support for a large user base. If stability is critical, you can deploy your own servers to ensure reliable connectivity [Using your own servers](#using-your-own-servers).
As it is, Tube is a great option for:
- Rapid prototyping of peer-to-peer multiplayer
- Testing mutliplayer games
- Learning Godot High-level multiplayer
- Local multiplayer game
- Game demo
- Simple indie game
- Private multiplayer game
Theres no strict technical limit on the number of players in a session, but each additional player increases the load on the server peer.
Tube was developed and tested with Godot 4.5, and it may also work with other Godot 4.x versions. It is not compatible with Godot 3.
## How to use
### Requirements
**Tube** uses WebRTC, as it, it works automatically on HTML5 export, but require an external GDExtension plugin on other platforms. You can find everything you need in the [webrtc-native plugin repository](https://github.com/godotengine/webrtc-native/releases).
> [!WARNING]
> No **specific** error message will appear if WebRTC implementation is missing. Make sure its set up correctly!
When exporting to Android, make sure to enable the `INTERNET` and `CHANGE_WIFI_MULTICAST_STATE` permission in the Android export preset before exporting the project or using one-click deploy. Otherwise, network communication of any kind will be blocked by Android.
To use this add-on effectively, it is essential to understand [Godot High-Level Multiplayer](https://docs.godotengine.org/en/stable/tutorials/networking/high_level_multiplayer.html)
### Installation
To install copy the *addons/tube* folder into *addons* Godot project's *addons* folder.
Or download it directly from the [Godot asset library](https://godotengine.org/asset-library/asset/4419)
Verify that the addon is activated in your godot project in `Project Settings -> Plugins`.
### Configuration & Utilisation
**Tube** is composed of two main elements:
- `TubeContext`: A `Resource` defining the configuration the session connexions.
- `TubeClient`: A `Node` managing network connection and multiplayer peers.
#### 1. Creating a `TubeContext`
First, create a new `TubeContext` for your project `in Godot FileSystem inspector -> Create New -> TubeContext`. And do the following :
1. Enter a `App ID` in your `TubeContext`. App ID must be exactly 15 ASCII characters. You can generate one automatically by clicking `Generate App ID`. App ID must be the same on all instance of your game.
> [!TIP]
> If your game is only intended for local play, you can skip the following steps 2 and 3.
For Web builds, however, steps 2 and 3 are mandatory, since local connections do not work on Web.
2. Add `Trackers URLs` , you can use the following:
- wss://tracker.openwebtorrent.com
- wss://tracker.files.fm:7073/announce
- wss://tracker.btorrent.xyz/
- wss://tracker.ghostchu-services.top:443/announce
3. Add `Stun Servers URLs`, you can use the following:
- stun:stun.l.google.com:19302
- stun:stun.cloudflare.com:3478
- stun:stun.bethesda.net:3478
#### 2. Adding a `TubeClient` to Your Scene
Next add a `TubeClient` to our game scene : `in Godot Scene inspector -> Add Child Node -> TubeClient`.
> [!IMPORTANT]
> `TubeClient` must be present in the scene tree to function, and it can be placed anywhere.
However, it should not be removed while a session is open (either, creating, joining, created or joined).
Assign the previously created TubeContext to the Context property of your TubeClient.
Optionally, you can also configure:
- `peer_signaling_timeout`
- `peer_signaling_max_attempts`
- `multiplayer_root_node`
For more details about the available properties and functions:
- In Godot Scene inspector -> Right click on your `TubeClient` -> `Open Documentation`.
- In the Script tab, search for `TubeClient` in the Help panel.
#### 3. Creating and Joining Sessions
On only one instance of the game call `create_session()`, for example:
```GDScript
@onready var label: Label = $Label # Label to display session id
@onready var tube_client: TubeClient = $TubeClient # reference to tube client in scene tree
func _on_button_pressed(): # User press create session button
tube_client.create_session()
label.text = tube_client.session_id
```
This player becomes the server (`is_server = true`) and have acces to the created session ID in the `session_id` property.
The server player should share this session ID with others through an external channel (e.g. Discord).
Other players can join by calling `join_session(session_id)`, for example:
```GDScript
@onready var line_edit: LineEdit = $LineEdit # text user input for session id
func _on_button_pressed(): # User press join session button
tube_client.join_session(line_edit.text)
```
When the session is successfully created or joined, the corresponding signals are emitted:
- `session_created`
- `session_joined`
If an error occurs during creation or joining, the client emits:
`error_raised(code: ErrorCode, message: String)`
(see the `TubeClient` documentation in Godot for details on signals and error codes).
Any player can leave the session by calling `leave_session()`.
If the server calls it, the session will close for everyone, and `session_left` will be emitted.
The server can:
- Kick a player using `kick_peer(p_peer_id: int)`
- Refuse new connections automatically by setting `refuse_new_connections = true`
#### 4. Implementing Multiplayer Logic
By default, `TubeClient` automatically configures Godots `MultiplayerAPI` and `MultiplayerPeer` on the SceneTree root node.
You can customize this behavior by setting the multiplayer_root_node property on TubeClient (see [SceneTree.set_multiplayer](https://docs.godotengine.org/en/stable/classes/class_scenetree.html#class-scenetree-method-set-multiplayer) for more information).
Once peers are connected, use [Godot High-level multiplayer](https://docs.godotengine.org/en/stable/tutorials/networking/high_level_multiplayer.html) to implement your game logic.
You can make use of tools such as:
- Godot RPC
- [MultiplayerSpawner](https://docs.godotengine.org/en/stable/classes/)
- [MultiplayerSynchronizer](https://docs.godotengine.org/en/stable/classes/class_multiplayersynchronizer.html)
For exemple:
```GDScript
func _on_some_input(): # Connected to some input.
transfer_some_input.rpc_id(1) # Send the input only to the server.
# Call local is required if the server is also a player.
@rpc("any_peer", "call_local", "reliable")
func transfer_some_input():
# The server knows who sent the input.
var sender_id = multiplayer.get_remote_sender_id()
# Process the input and affect game logic.
```
To know more about how to configure and use it, you can look into the [demo project](https://github.com/koopmyers/pixelary)
### Trouble shooting
**Tube** includes a helpful tool called `TubeInspector` for debugging and visualizing internal network activity.
To use it, add the scene located at `/addons/tube/tube_inspector.tscn` to your project and assign your `TubeClient` to it.
> [!NOTE]
> Some features, such as latency display and chat, are only available if `TubeInspector` is part of the `MultiplayerAPI` scene tree.
<img src="https://raw.githubusercontent.com/koopmyers/tube/refs/heads/main/screenshots/inspector2.png" alt="Tube inspector" width="200"/>
<img src="https://raw.githubusercontent.com/koopmyers/tube/b47f12c37505baa57a5c89281d6d2fd9263c3cd4/screenshots/inspector.png" alt="Tube inspector" width="200"/>
#### Major known issues
The most common reason a player cannot connect is a **symmetric NAT**.
A symmetric NAT is a router configuration that prevents NAT hole punching. This means that if both peers are behind a symmetric NAT, the connection will likely fail.
You can check whether you are behind a symmetric NAT using the **NAT hole punching** field in `TubeInspector`. Multiple STUN servers with different addresses are required. If the result is `unknown`, try different STUN domains. This tool is not available on Web platform.
You can also test here: [Symmetric NAT test](https://tomchen.github.io/symmetric-nat-test/), but note that false positives are common due to browser privacy behavior.
Tube will attempt to map public ports via **UPnP**. Port mapping can help bypass symmetric NAT.
However, UPnP is not supported on all networks, commonly disabled on corporate, public, or VPN networks.
You can verify UPnP support using the **UPnP port mapping** field in `TubeInspector`. Port mapping is not available on Web platform.
If UPnP is available but connections still fail, the timeout may occur before the port opens. Try increasing the client's `peer_signaling_timeout` or `peer_signaling_max_attempts`.
If both **NAT hole punching** and **UPnP port mapping** show `likely to fail` for two players, then a direct Internet connection is likely impossible without a relay server.
You can still use **Tube** with your own servers to ensure reliable connectivity. See: [Using your own servers](#using-your-own-servers).
#### Minor known issues
> [!CAUTION]
> Class 'UPNPDeviceMiniUPNP' already exists
This is a core Godot Engine issue caused by multithreading. There is currently no known way to fix or suppress it without modifying the engine itself.
</br>
> [!CAUTION]
> Invalid status code. Got 'XXX', expected 101.
This refers to a [HTTP status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status#server_error_responses), indicating that a tracker is unavailable or encountered an issue. `TubeInspector` will show which trackers failed to connect.
This problem is related to tracker availability or network conditions. There is no reliable way to handle this error in GDScript. Because public trackers can occasionally be unstable, we recommend using **multiple trackers** to improve connection reliability.
## How it works
**Tube** establishes a serverclient architecture between peers.
One peer acts as the server, while all other peers connect to it as clients.
The server is responsible for relaying Godots RPC (see [Godot High-level multiplayer](https://docs.godotengine.org/en/stable/tutorials/networking/high_level_multiplayer.html)) between peers.
To connect peers to the server, Tube uses WebRTC (Web Real-Time Communication), an open-source technology that enables secure, real-time peer-to-peer data transmission.
Establishing a WebRTC connection requires an initial signaling phase, which depends on three external components:
- Signaling servers: Used to exchange connection initialization messages between peers.
- STUN servers: Help peers determine their public address and how they can be reached.
- TURN servers (optional): Act as relays when a direct peer-to-peer connection cannot be established.
For more details on WebRTC, visit the [Official WebRTC web site](https://webrtc.org) and the [WebRTC Godot documentation](https://docs.godotengine.org/en/stable/classes/class_webrtcpeerconnection.html)
### Local signaling
On a local network, the server peer listens on determined port. When joining, other peers broadcast their signaling data across the network at destination of the server. Once signaling is complete, peers automatically switch to a WebRTC connection.
STUN and TURN servers are not needed in this mode.
Because the Web platform cannot open listening ports, local signaling is unavailable on Web builds.
### Online signaling
For Signaling servers, **Tube** use WebTorrent tracker servers as signaling servers. Several public trackers are available, such as those listed in [Configuration & Utilisation](#configuration--utilisation).
It is recommended to use multiple trackers to improve connection reliability, as public trackers can occasionally be unstable.
To learn more about BitTorrent trackers and WebTorrent, see the [WebTorrent github](https://github.com/webtorrent/webtorrent) and the [Wikipedia BitTorrent Tracker page](https://en.wikipedia.org/wiki/BitTorrent_tracker).
If you need more stable connections for your game, you can deploy your own tracker servers, see [Using your own servers](#using-your-own-servers).
Many public STUN servers are available, such as those provided by Google.
You can find an updated list here: [Public STUN list](https://gist.github.com/mondain/b0ec1cf5f60ae726202e)
Currently, there are no reliable public TURN servers.
Without a TURN server, there is no fallback mechanism when peers cannot establish a direct connection, for example, if both peers are behind a *symmetric NAT*. To mitigate this, Tube attempts to open ports automatically using *UPnP port mapping*. However, this feature is not supported on the Web platform.
For maximum reliability, you can deploy your own TURN server and add it to your `TubeContext` configuration see [Using your own servers](#using-your-own-servers).
## Using your own servers
### WebTorrent tracker
You can deploy your own WebTorrent tracker using the [Official Webtorrent Tracker](https://github.com/webtorrent/bittorrent-tracker) or the [OpenWebTorrent Tracker](https://github.com/OpenWebTorrent/openwebtorrent-tracker).
Make sure to configure it with WebSocket support, availbale on Internet and set its URL in your TubeContext.
It is strongly recommended to use secure WebSockets (WSS/TLS) for to ensure reliable and encrypted communication and some browser will block non secure communication.
### Turn server
To improve connection reliability, you can host your own TURN server using [coturn](https://github.com/coturn/coturn) or [eturnal](https://github.com/processone/eturnal). They can also be used as STUN servers.
Once deployed, add your TURN servers URL and credentials to your `TubeContext`.
For security reasons, its recommended to use ephemeral credentials to prevent unauthorized access to your TURN server.
This approach requires additional setup, such as generating credentials dynamically through a secure backend.
There are also third-party TURN hosting services available, but most are paid solutions.
## Credits
Inspector icons: https://www.kenney.nl/assets/game-icons

BIN
addons/tube/icons/tube_client.svg (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b4o7vx0n8db1s"
path="res://.godot/imported/tube_client.svg-3544df422a27b401f812416126d79a81.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/tube/icons/tube_client.svg"
dest_files=["res://.godot/imported/tube_client.svg-3544df422a27b401f812416126d79a81.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

BIN
addons/tube/icons/tube_context.svg (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cqibqku84s77x"
path="res://.godot/imported/tube_context.svg-febea6fb8bda9c0e97caaf283ec71d2f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/tube/icons/tube_context.svg"
dest_files=["res://.godot/imported/tube_context.svg-febea6fb8bda9c0e97caaf283ec71d2f.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

BIN
addons/tube/icons/tube_inspector.svg (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://x56krrgxw70m"
path="res://.godot/imported/tube_inspector.svg-327f0bac19dd37d51cef3c03e256b924.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/tube/icons/tube_inspector.svg"
dest_files=["res://.godot/imported/tube_inspector.svg-327f0bac19dd37d51cef3c03e256b924.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View File

@ -0,0 +1,36 @@
class_name EditorTubeChannelControl extends Control
## @experimental: This class is used as part of the TubeClientDebugPanel scene and is part of a scene. Should not be used as itself.
@export var channel_item: EditorTubeChannelItemControl:
set(x):
channel_item = x
show()
if is_instance_valid(id_label):
id_label.text = str(channel_item.channel.get_id())
if is_instance_valid(label_label):
label_label.text = channel_item.channel.get_label()
update_messages()
@export var messages_container: EditorTubeMessagesContainer
@onready var id_label: Label = %IdLabel
@onready var label_label: Label = %LabelLabel
func _ready() -> void:
hide()
func update_messages():
if null == channel_item:
return
if is_instance_valid(messages_container):
messages_container.display_messages(channel_item.message_item_controls)

View File

@ -0,0 +1 @@
uid://dxd3thybq7mc2

View File

@ -0,0 +1,30 @@
[gd_scene load_steps=2 format=3 uid="uid://budxt0ohhps46"]
[ext_resource type="Script" uid="uid://dxd3thybq7mc2" path="res://addons/tube/inspector/channel_control.gd" id="1_t40fg"]
[node name="ChannelControl" type="MarginContainer"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_type_variation = &"H1PanelContainer"
script = ExtResource("1_t40fg")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 2
[node name="HeaderContainer" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
[node name="IdLabel" type="Label" parent="VBoxContainer/HeaderContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"HeaderMedium"
text = "00000000000000000000"
[node name="LabelLabel" type="Label" parent="VBoxContainer/HeaderContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"HeaderMedium"
text = "00000000000000000000"

View File

@ -0,0 +1,82 @@
class_name EditorTubeChannelItemControl extends Control
## @experimental: This class is used as part of the TubeClientDebugPanel scene and is part of a scene. Should not be used as itself.
signal pressed
const MESSAGE_ITEM_CONTROL_SCENE := preload("uid://cfsei3airwx4s")
const STATE_COLOR_DEFAULT := Color.WHITE
const STATE_COLOR := {
WebRTCDataChannel.STATE_CONNECTING: Color.CYAN,
WebRTCDataChannel.STATE_OPEN: Color.PALE_GREEN,
WebRTCDataChannel.STATE_CLOSING: Color.GOLDENROD,
WebRTCDataChannel.STATE_CLOSED: Color.CRIMSON,
}
const STATE_TEXT_DEFAULT := "Unknown"
const STATE_TEXT := {
WebRTCDataChannel.STATE_CONNECTING: "Connecting",
WebRTCDataChannel.STATE_OPEN: "Open",
WebRTCDataChannel.STATE_CLOSING: "Closing",
WebRTCDataChannel.STATE_CLOSED: "Closed",
}
@export var channel_control: EditorTubeChannelControl
var peer: TubePeer:
set(x):
if null != peer:
peer.channel_state_changed.disconnect(
_on_peer_channel_state_changed
)
if null != x:
x.channel_state_changed.connect(
_on_peer_channel_state_changed
)
peer = x
update()
var channel: WebRTCDataChannel:
set(x):
channel = x
update()
#var message_item_controls: Array[EditorTubeMessagesItemControl] = []
@onready var name_label: Label = %NameLabel
@onready var state_indicator: Control = %StateIndicator
func _ready() -> void:
update()
func _on_button_pressed() -> void:
if is_instance_valid(channel_control):
channel_control.channel_item = self
pressed.emit()
func update():
if is_instance_valid(name_label):
name_label.text = channel.get_label()
if is_instance_valid(state_indicator):
state_indicator.modulate = STATE_COLOR_DEFAULT if not channel else STATE_COLOR[channel.get_ready_state()]
state_indicator.tooltip_text = STATE_TEXT_DEFAULT if not channel else STATE_TEXT[channel.get_ready_state()]
if is_instance_valid(channel_control):
if self == channel_control.channel_item:
channel_control.update_messages()
func _on_peer_channel_state_changed(_channel: WebRTCDataChannel):
update()

View File

@ -0,0 +1 @@
uid://b1h5h73j26j8v

View File

@ -0,0 +1,46 @@
[gd_scene load_steps=4 format=3 uid="uid://dc3ssinymllca"]
[ext_resource type="Script" uid="uid://b1h5h73j26j8v" path="res://addons/tube/inspector/channel_item_control.gd" id="1_ep4vf"]
[ext_resource type="Theme" uid="uid://bcibt73qths3g" path="res://addons/tube/inspector/theme.tres" id="1_gkg3v"]
[sub_resource type="ButtonGroup" id="ButtonGroup_ep4vf"]
resource_local_to_scene = false
allow_unpress = true
[node name="ChannelControl" type="MarginContainer"]
anchors_preset = 10
anchor_right = 1.0
offset_bottom = 28.0
grow_horizontal = 2
theme = ExtResource("1_gkg3v")
script = ExtResource("1_ep4vf")
[node name="HBoxContainer" type="HBoxContainer" parent="."]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 4
[node name="StateIndicator" type="Panel" parent="HBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(24, 12)
layout_mode = 2
size_flags_vertical = 4
theme_type_variation = &"PanelIndicator"
[node name="NameLabel" type="Label" parent="HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"LabelH2"
text = "CHANNEL"
text_overrun_behavior = 1
[node name="Button" type="Button" parent="."]
visible = false
layout_mode = 2
theme_type_variation = &"FlatButton"
toggle_mode = true
button_group = SubResource("ButtonGroup_ep4vf")
alignment = 0
[connection signal="pressed" from="Button" to="." method="_on_button_pressed"]

View File

@ -0,0 +1,76 @@
class_name EditorTubeChatControl extends Control
## @experimental: This class is used as part of the TubeClientDebugPanel scene and is part of a scene. Should not be used as itself.
const MESSAGE_ITEM_CONTROL_SCENE := preload("uid://cfsei3airwx4s")
@export var messages_container: EditorTubeMessagesContainer
@export var max_messages_amount: int = 100
var message_item_controls: Array[EditorTubeMessagesItemControl] = []
var message_item_button_group := ButtonGroup.new()
@onready var line_edit: LineEdit = %LineEdit
func _ready() -> void:
message_item_button_group.allow_unpress = true
func add_message_item_control(data) -> EditorTubeMessagesItemControl:
if max_messages_amount <= message_item_controls.size():
var item := message_item_controls.pop_front()
item.queue_free()
var message_item_control := MESSAGE_ITEM_CONTROL_SCENE.instantiate()
message_item_controls.append(message_item_control)
message_item_control.data = data
message_item_control.button_group = message_item_button_group
return message_item_control
func update():
if is_instance_valid(messages_container):
if messages_container.is_displaying_from(self):
messages_container.display_messages(
message_item_controls,
self
)
func send_chat_message(p_message: String):
add_message_item_control(p_message).sent()
update()
receive_chat_message.rpc(p_message)
line_edit.text = ""
@rpc("any_peer", "call_remote", "reliable")
func receive_chat_message(p_message: String):
var peer_id := multiplayer.get_remote_sender_id()
var item := add_message_item_control(p_message)
item.received(peer_id)
update()
func _on_send_button_pressed() -> void:
send_chat_message(line_edit.text)
func _on_line_edit_text_submitted(new_text: String) -> void:
send_chat_message(new_text)
func _on_visibility_changed() -> void:
if not is_visible_in_tree():
return
if is_instance_valid(messages_container):
if not messages_container.is_displaying_from(self):
messages_container.display_messages(
message_item_controls,
self
)

View File

@ -0,0 +1 @@
uid://ccqt34u2k2rbo

View File

@ -0,0 +1,41 @@
[gd_scene load_steps=4 format=3 uid="uid://dyfuyauko76jj"]
[ext_resource type="Theme" uid="uid://bcibt73qths3g" path="res://addons/tube/inspector/theme.tres" id="1_ecj0n"]
[ext_resource type="Script" uid="uid://ccqt34u2k2rbo" path="res://addons/tube/inspector/chat_control.gd" id="1_yvw80"]
[ext_resource type="Texture2D" uid="uid://b4jdl1ipes5d0" path="res://addons/tube/inspector/icons/send_icon.tres" id="2_ho0s2"]
[node name="ChatControl" type="MarginContainer"]
theme = ExtResource("1_ecj0n")
script = ExtResource("1_yvw80")
metadata/_tab_index = 0
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 2
[node name="TitleLabel" type="Label" parent="VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"LabelH2"
text = "Chat"
[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
[node name="LineEdit" type="LineEdit" parent="VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Enter message"
[node name="SendButton" type="Button" parent="VBoxContainer/HBoxContainer"]
custom_minimum_size = Vector2(40, 40)
layout_mode = 2
tooltip_text = "Send"
icon = ExtResource("2_ho0s2")
icon_alignment = 1
expand_icon = true
[connection signal="visibility_changed" from="." to="." method="_on_visibility_changed"]
[connection signal="text_submitted" from="VBoxContainer/HBoxContainer/LineEdit" to="." method="_on_line_edit_text_submitted"]
[connection signal="pressed" from="VBoxContainer/HBoxContainer/SendButton" to="." method="_on_send_button_pressed"]

View File

@ -0,0 +1,142 @@
extends Control
const HOLE_PUNCHING_COMPLIANCE_TEXT: Dictionary[TubeNetworkDiagnosisPeer.Compliance, String] = {
TubeNetworkDiagnosisPeer.Compliance.UNKNOWN: "Unknown",
TubeNetworkDiagnosisPeer.Compliance.YES: "likely to succeed",
TubeNetworkDiagnosisPeer.Compliance.NO: "likely to fail",
}
const HOLE_PUNCHING_COMPLIANCE_COLOR: Dictionary[TubeNetworkDiagnosisPeer.Compliance, Color] = {
TubeNetworkDiagnosisPeer.Compliance.UNKNOWN: Color.BEIGE,
TubeNetworkDiagnosisPeer.Compliance.YES: Color.PALE_GREEN,
TubeNetworkDiagnosisPeer.Compliance.NO: Color.CRIMSON,
}
@export var inspector: EditorTubeClientPanel
var client: TubeClient:
set(x):
if client != x:
if null != client:
client._upnp.port_mapped.disconnect(
sucess_port_mapping
)
client._upnp.warning_raised.disconnect(
fail_port_mapping
)
if null != x:
x._upnp.port_mapped.connect(
sucess_port_mapping
)
x._upnp.warning_raised.connect(
fail_port_mapping
)
client = x
if not is_instance_valid(client):
return
if is_instance_valid(client_label):
client_label.text = client.name
if is_instance_valid(context_label):
context_label.text = client.context.resource_name
if is_instance_valid(app_id_label):
app_id_label.text = client.context.app_id
if is_instance_valid(root_node_label):
root_node_label.text = client.multiplayer_root_node.get_path()
detect_nat()
detect_upnp_port_mapping()
var network_diagnosis_peer := TubeNetworkDiagnosisPeer.new(4443)
@onready var client_label: Label = %ClientLabel
@onready var context_label: Label = %ContextLabel
@onready var app_id_label: Label = %AppIdLabel
@onready var root_node_label: Label = %RootNodeLabel
@onready var nat_detection_label: Label = %NATDetectionLabel
@onready var upnp_port_mapping_label: Label = %UPNPPortMappingLabel
func _ready() -> void:
network_diagnosis_peer.warning_raised.connect(
_on_network_diagnosis_peer_warning_raised
)
network_diagnosis_peer.nat_hole_punching_compliance_updated.connect(
_on_network_diagnosis_peer_nat_hole_punching_compliance_updated
)
func _process(delta: float):
network_diagnosis_peer._process(delta)
func _on_network_diagnosis_peer_warning_raised(message: String):
if is_instance_valid(inspector):
inspector.add_message_item_control("Network diagnosis: " + message).warning()
func _on_network_diagnosis_peer_nat_hole_punching_compliance_updated(compliance: TubeNetworkDiagnosisPeer.Compliance):
if is_instance_valid(nat_detection_label):
nat_detection_label.text = HOLE_PUNCHING_COMPLIANCE_TEXT[compliance]
nat_detection_label.modulate = HOLE_PUNCHING_COMPLIANCE_COLOR[compliance]
func _on_nat_detection_button_pressed() -> void:
detect_nat()
func detect_nat():
if OS.get_name() == "Web":
if is_instance_valid(inspector):
inspector.add_message_item_control("NAT hole punching detection is not available on Web").warning()
return
if not is_instance_valid(client):
inspector.add_message_item_control("NAT hole punching detection needs a tube client set on inspector").warning()
return
if len(client.context.stun_servers_urls) < 2:
if is_instance_valid(inspector):
inspector.add_message_item_control("NAT hole punching detection can only be done with 2 or more STUN urls").warning()
return
network_diagnosis_peer.start_nat_hole_punching_detection(client.context.stun_servers_urls)
func _on_upnp_port_mapping_button_pressed() -> void:
detect_upnp_port_mapping()
func detect_upnp_port_mapping():
if OS.get_name() == "Web":
if is_instance_valid(inspector):
inspector.add_message_item_control("Port mapping detection is not available on Web").warning()
return
if not is_instance_valid(client):
inspector.add_message_item_control("Port mapping detection needs a tube client set on inspector").warning()
return
var port := 4443
client._upnp.add_port_mapping(port, port)
client._upnp.delete_port_mapping(port)
func sucess_port_mapping(public_port: int, local_port: int):
if is_instance_valid(upnp_port_mapping_label):
upnp_port_mapping_label.text = HOLE_PUNCHING_COMPLIANCE_TEXT[TubeNetworkDiagnosisPeer.Compliance.YES]
upnp_port_mapping_label.modulate = HOLE_PUNCHING_COMPLIANCE_COLOR[TubeNetworkDiagnosisPeer.Compliance.YES]
func fail_port_mapping(message: String):
if is_instance_valid(upnp_port_mapping_label):
upnp_port_mapping_label.text = HOLE_PUNCHING_COMPLIANCE_TEXT[TubeNetworkDiagnosisPeer.Compliance.NO]
upnp_port_mapping_label.modulate = HOLE_PUNCHING_COMPLIANCE_COLOR[TubeNetworkDiagnosisPeer.Compliance.NO]

View File

@ -0,0 +1 @@
uid://dadtx2xi0157

View File

@ -0,0 +1,120 @@
[gd_scene load_steps=3 format=3 uid="uid://c3p410vwblsb3"]
[ext_resource type="Script" uid="uid://dadtx2xi0157" path="res://addons/tube/inspector/client_control.gd" id="1_xtewr"]
[ext_resource type="Theme" uid="uid://bcibt73qths3g" path="res://addons/tube/inspector/theme.tres" id="1_y7bgu"]
[node name="ClientControl" type="MarginContainer"]
offset_right = 245.0
offset_bottom = 77.0
theme = ExtResource("1_y7bgu")
script = ExtResource("1_xtewr")
metadata/_tab_index = 0
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 2
[node name="ClientContainer" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
[node name="Label" type="Label" parent="VBoxContainer/ClientContainer"]
custom_minimum_size = Vector2(80, 0)
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "Client"
[node name="ClientLabel" type="Label" parent="VBoxContainer/ClientContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"LabelH2"
text_overrun_behavior = 3
[node name="ContextContainer" type="HBoxContainer" parent="VBoxContainer"]
visible = false
layout_mode = 2
[node name="Label" type="Label" parent="VBoxContainer/ContextContainer"]
custom_minimum_size = Vector2(80, 0)
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "Context"
[node name="ContextLabel" type="Label" parent="VBoxContainer/ContextContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"LabelH2"
text_overrun_behavior = 3
[node name="AppIdContainer" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
[node name="Label" type="Label" parent="VBoxContainer/AppIdContainer"]
custom_minimum_size = Vector2(80, 0)
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "App ID"
[node name="AppIdLabel" type="Label" parent="VBoxContainer/AppIdContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"LabelH2"
text_overrun_behavior = 3
[node name="RootNodeContainer" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
[node name="Label" type="Label" parent="VBoxContainer/RootNodeContainer"]
custom_minimum_size = Vector2(80, 0)
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "Root node"
[node name="RootNodeLabel" type="Label" parent="VBoxContainer/RootNodeContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"LabelH2"
text_overrun_behavior = 3
[node name="NatContainer" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
[node name="NATDetectionButton" type="Button" parent="VBoxContainer/NatContainer"]
custom_minimum_size = Vector2(180, 0)
layout_mode = 2
size_flags_horizontal = 0
tooltip_text = "Reload"
theme_type_variation = &"FlatButton"
text = "NAT hole punching"
[node name="NATDetectionLabel" type="Label" parent="VBoxContainer/NatContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"LabelH2"
text = "Unknow"
text_overrun_behavior = 3
[node name="UPNPContainer" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
[node name="UPNPPortMappingButton" type="Button" parent="VBoxContainer/UPNPContainer"]
custom_minimum_size = Vector2(180, 0)
layout_mode = 2
size_flags_horizontal = 0
tooltip_text = "Reload"
theme_type_variation = &"FlatButton"
text = "UPnP port mapping"
[node name="UPNPPortMappingLabel" type="Label" parent="VBoxContainer/UPNPContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"LabelH2"
text = "Unknow"
text_overrun_behavior = 3
[connection signal="pressed" from="VBoxContainer/NatContainer/NATDetectionButton" to="." method="_on_nat_detection_button_pressed"]
[connection signal="pressed" from="VBoxContainer/UPNPContainer/UPNPPortMappingButton" to="." method="_on_upnp_port_mapping_button_pressed"]

View File

@ -0,0 +1,7 @@
[gd_resource type="AtlasTexture" load_steps=2 format=3 uid="uid://pxj3b3e28fg3"]
[ext_resource type="Texture2D" uid="uid://bblpobvrbvblp" path="res://addons/tube/inspector/icons/icons_sheet_white.png" id="1_jdxrn"]
[resource]
atlas = ExtResource("1_jdxrn")
region = Rect2(100, 1900, 100, 100)

View File

@ -0,0 +1,7 @@
[gd_resource type="AtlasTexture" load_steps=2 format=3 uid="uid://wcdvmyl01v1p"]
[ext_resource type="Texture2D" uid="uid://bblpobvrbvblp" path="res://addons/tube/inspector/icons/icons_sheet_white.png" id="1_e61nn"]
[resource]
atlas = ExtResource("1_e61nn")
region = Rect2(400, 1200, 100, 100)

BIN
addons/tube/inspector/icons/icons_sheet_white.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bblpobvrbvblp"
path="res://.godot/imported/icons_sheet_white.png-2f4a5023d48df0787006ef991a38d766.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/tube/inspector/icons/icons_sheet_white.png"
dest_files=["res://.godot/imported/icons_sheet_white.png-2f4a5023d48df0787006ef991a38d766.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@ -0,0 +1,7 @@
[gd_resource type="AtlasTexture" load_steps=2 format=3 uid="uid://bw1nalj6ph1ul"]
[ext_resource type="Texture2D" uid="uid://bblpobvrbvblp" path="res://addons/tube/inspector/icons/icons_sheet_white.png" id="1_5n4tk"]
[resource]
atlas = ExtResource("1_5n4tk")
region = Rect2(300, 1400, 100, 100)

View File

@ -0,0 +1,7 @@
[gd_resource type="AtlasTexture" load_steps=2 format=3 uid="uid://d3r4nf1lyap1q"]
[ext_resource type="Texture2D" uid="uid://bblpobvrbvblp" path="res://addons/tube/inspector/icons/icons_sheet_white.png" id="1_b014o"]
[resource]
atlas = ExtResource("1_b014o")
region = Rect2(0, 1200, 100, 100)

View File

@ -0,0 +1,7 @@
[gd_resource type="AtlasTexture" load_steps=2 format=3 uid="uid://b4jdl1ipes5d0"]
[ext_resource type="Texture2D" uid="uid://bblpobvrbvblp" path="res://addons/tube/inspector/icons/icons_sheet_white.png" id="1_rmrpv"]
[resource]
atlas = ExtResource("1_rmrpv")
region = Rect2(100, 0, 100, 100)

View File

@ -0,0 +1,7 @@
[gd_resource type="AtlasTexture" load_steps=2 format=3 uid="uid://c141mpg6pwbt8"]
[ext_resource type="Texture2D" uid="uid://bblpobvrbvblp" path="res://addons/tube/inspector/icons/icons_sheet_white.png" id="1_yfsny"]
[resource]
atlas = ExtResource("1_yfsny")
region = Rect2(0, 900, 100, 100)

View File

@ -0,0 +1,7 @@
[gd_resource type="AtlasTexture" load_steps=2 format=3 uid="uid://vj4v31qi6u4d"]
[ext_resource type="Texture2D" uid="uid://bblpobvrbvblp" path="res://addons/tube/inspector/icons/icons_sheet_white.png" id="1_43xo5"]
[resource]
atlas = ExtResource("1_43xo5")
region = Rect2(100, 1000, 100, 100)

View File

@ -0,0 +1,7 @@
[gd_resource type="AtlasTexture" load_steps=2 format=3 uid="uid://cxdxdrs7t6fbp"]
[ext_resource type="Texture2D" uid="uid://bblpobvrbvblp" path="res://addons/tube/inspector/icons/icons_sheet_white.png" id="1_mgvxc"]
[resource]
atlas = ExtResource("1_mgvxc")
region = Rect2(0, 1800, 100, 100)

View File

@ -0,0 +1,145 @@
class_name EditorTubeLocalSignalingControl extends Control
## @experimental: This class is used as part of the TubeClientDebugPanel scene and is part of a scene. Should not be used as itself.
const MESSAGE_ITEM_CONTROL_SCENE := preload("uid://cfsei3airwx4s")
@export var messages_container: EditorTubeMessagesContainer
@export var max_messages_amount: int = 100
var local_signaling_peer: TubeLocalSignalingPeer:
set(x):
if null != local_signaling_peer:
local_signaling_peer.warning_raised.disconnect(
_on_local_signaling_peer_warning_raised
)
local_signaling_peer.data_sent.disconnect(
_on_local_signaling_peer_data_sent
)
local_signaling_peer.received_data.disconnect(
_on_local_signaling_peer_data_received
)
if null != x:
x.warning_raised.connect(
_on_local_signaling_peer_warning_raised
)
x.data_sent.connect(
_on_local_signaling_peer_data_sent
)
x.received_data.connect(
_on_local_signaling_peer_data_received
)
local_signaling_peer = x
update()
var message_item_controls: Array[EditorTubeMessagesItemControl] = []
var message_item_button_group := ButtonGroup.new()
@onready var name_label: Label = %NameLabel
#@onready var state_indicator: Control = %StateIndicator
func _ready() -> void:
message_item_button_group.allow_unpress = true
update()
func update():
if is_instance_valid(local_signaling_peer):
if is_instance_valid(name_label):
name_label.text = str(local_signaling_peer.udp_peer.get_local_port())
if is_instance_valid(messages_container):
if messages_container.is_displaying_from(self):
messages_container.display_messages(
message_item_controls,
self
)
else:
if is_instance_valid(name_label):
name_label.text = "Unset"
func add_message_item_control(data) -> EditorTubeMessagesItemControl:
if max_messages_amount <= message_item_controls.size():
var item := message_item_controls.pop_front()
item.queue_free()
var message_item_control:= MESSAGE_ITEM_CONTROL_SCENE.instantiate()
message_item_controls.append(message_item_control)
message_item_control.data = data
message_item_control.button_group = message_item_button_group
return message_item_control
func _on_local_signaling_peer_warning_raised(message: String):
add_message_item_control(message).warning()
update()
#func _on_local_signaling_peer_connected():
#add_message_item_control("Connected").success()
#update()
#func _on_local_signaling_peer_failed():
#add_message_item_control(
#"Connection failed: {error}".format({
#"error": local_signaling_peer.error_message
#})
#).error()
#update()
#
#
#func _on_local_signaling_peer_disconnected():
#add_message_item_control("Disconneted")
#update()
#
#
#func _on_local_signaling_peer_state_changed():
##if WebSocketPeer.STATE_OPEN == local_signaling_peer.state:
##add_message_item_control("Connection open")
#
#if WebSocketPeer.STATE_CLOSING == local_signaling_peer.state:
#add_message_item_control("Connection closing")
#
##elif WebSocketPeer.STATE_CLOSED == local_signaling_peer.state:
##add_message_item_control("Connection closed")
#
#update()
func _on_local_signaling_peer_data_received(data: Variant, address: String, port: int):
var control := add_message_item_control(data)
control.received()
control.from_address = address
control.from_id = port
update()
func _on_local_signaling_peer_data_sent(data: Dictionary, address: String, port: int):
var control := add_message_item_control(data)
control.sent()
control.from_address = address
control.from_id = port
update()
func _on_visibility_changed() -> void:
if not is_visible_in_tree():
return
if is_instance_valid(messages_container):
if not messages_container.is_displaying_from(self):
messages_container.display_messages(
message_item_controls,
self
)

View File

@ -0,0 +1 @@
uid://c77pnnu05pblc

View File

@ -0,0 +1,20 @@
[gd_scene load_steps=3 format=3 uid="uid://5f8u55hvqq4w"]
[ext_resource type="Theme" uid="uid://bcibt73qths3g" path="res://addons/tube/inspector/theme.tres" id="1_gfvfe"]
[ext_resource type="Script" uid="uid://c77pnnu05pblc" path="res://addons/tube/inspector/local_signaling_control.gd" id="2_gfvfe"]
[node name="LocalSignalingControl" type="MarginContainer"]
offset_right = 217.0
offset_bottom = 24.0
theme = ExtResource("1_gfvfe")
script = ExtResource("2_gfvfe")
[node name="NameLabel" type="Label" parent="."]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"LabelH3"
text = "PORT"
text_overrun_behavior = 1
[connection signal="visibility_changed" from="." to="." method="_on_visibility_changed"]

View File

@ -0,0 +1,49 @@
class_name EditorTubeMessageControl extends Control
## @experimental: This class is used as part of the TubeClientDebugPanel scene and is part of a scene. Should not be used as itself.
var message_item_control: EditorTubeMessagesItemControl:
set(x):
message_item_control = x
if null == message_item_control:
hide()
return
show()
if is_instance_valid(type_texture_rect):
type_texture_rect.texture = message_item_control.icons.get(message_item_control.type)
type_texture_rect.modulate = message_item_control.colors.get(message_item_control.type)
if is_instance_valid(time_label):
time_label.text = message_item_control.time
if is_instance_valid(from_label):
from_label.visible = bool(message_item_control.from_id)
from_label.text = EditorTubePeerItemControl.get_peer_string(
message_item_control.from_id
)
from_label.modulate = EditorTubePeerItemControl.get_peer_color(
message_item_control.from_id
)
if is_instance_valid(message_code_edit):
if message_item_control.data is String:
message_code_edit.text = message_item_control.data
if not message_item_control.data is String:
message_code_edit.text = JSON.stringify(message_item_control.data, " ")
@onready var type_texture_rect: TextureRect = %TypeTextureRect
@onready var time_label: Label = %TimeLabel
@onready var from_label: Label = %FromLabel
@onready var message_code_edit: CodeEdit = %MessageCodeEdit
func _ready() -> void:
hide()
func _on_clipboard_button_pressed() -> void:
DisplayServer.clipboard_set(str(message_item_control.data))

View File

@ -0,0 +1 @@
uid://chqe7mwhwv255

View File

@ -0,0 +1,75 @@
[gd_scene load_steps=5 format=3 uid="uid://bi8vgsoslhvrb"]
[ext_resource type="Theme" uid="uid://bcibt73qths3g" path="res://addons/tube/inspector/theme.tres" id="1_5ms28"]
[ext_resource type="Script" uid="uid://chqe7mwhwv255" path="res://addons/tube/inspector/message_control.gd" id="1_gw4sm"]
[ext_resource type="Texture2D" uid="uid://pxj3b3e28fg3" path="res://addons/tube/inspector/icons/clipboard_icon.tres" id="2_78cti"]
[ext_resource type="Texture2D" uid="uid://c141mpg6pwbt8" path="res://addons/tube/inspector/icons/sent_icon.tres" id="3_qi5c7"]
[node name="MessageControl" type="PanelContainer"]
anchors_preset = 9
anchor_bottom = 1.0
offset_right = 238.0
grow_vertical = 2
theme = ExtResource("1_5ms28")
theme_type_variation = &"PanelH1Container"
script = ExtResource("1_gw4sm")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 2
[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 4
[node name="TypeTextureRect" type="TextureRect" parent="VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(32, 32)
layout_mode = 2
size_flags_horizontal = 4
mouse_filter = 2
texture = ExtResource("3_qi5c7")
expand_mode = 1
stretch_mode = 5
[node name="TimeLabel" type="Label" parent="VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(70, 0)
layout_mode = 2
size_flags_vertical = 1
theme_type_variation = &"LabelH2"
text = "00:00:00"
vertical_alignment = 1
[node name="FromLabel" type="Label" parent="VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 1
theme_type_variation = &"LabelH2"
text = "00"
vertical_alignment = 1
[node name="ClipboardButton" type="Button" parent="VBoxContainer/HBoxContainer"]
custom_minimum_size = Vector2(40, 40)
layout_mode = 2
size_flags_horizontal = 10
tooltip_text = "Copy message to clipboard"
theme_type_variation = &"ButtonFlat"
icon = ExtResource("2_78cti")
icon_alignment = 1
expand_icon = true
[node name="MessageCodeEdit" type="CodeEdit" parent="VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
editable = false
context_menu_enabled = false
virtual_keyboard_enabled = false
virtual_keyboard_show_on_focus = false
wrap_mode = 1
highlight_all_occurrences = true
gutters_draw_line_numbers = true
auto_brace_completion_highlight_matching = true
[connection signal="pressed" from="VBoxContainer/HBoxContainer/ClipboardButton" to="." method="_on_clipboard_button_pressed"]

View File

@ -0,0 +1,158 @@
class_name EditorTubeMessagesItemControl extends Control
## @experimental: This class is used as part of the TubeClientDebugPanel scene and is part of a scene. Should not be used as itself.
enum Type {INFO, ERROR, WARNING, SENT, RECEIVED, SUCCESS}
@export var type := Type.INFO:
set(x):
type = x
if is_instance_valid(type_texture_rect):
type_texture_rect.texture = icons.get(type)
type_texture_rect.modulate = colors.get(type)
@export var message_control: EditorTubeMessageControl
@export var icons: Dictionary[Type, Texture] = {}
@export var colors: Dictionary[Type, Color] = {
Type.INFO: Color.BEIGE,
Type.ERROR: Color.CRIMSON,
Type.WARNING: Color.GOLDENROD,
Type.SUCCESS: Color.PALE_GREEN,
Type.SENT: Color.DODGER_BLUE,
Type.RECEIVED: Color.PLUM,
}
@export var strings: Dictionary[Type, String] = {
Type.INFO: "-",
Type.ERROR: "X",
Type.WARNING: "!",
Type.SUCCESS: "*",
Type.SENT: "<",
Type.RECEIVED: ">",
}
var data: Variant:
set(x):
data = x
if is_instance_valid(data_label):
data_label.text = str(data)
var from_address: String:
set(x):
from_address = x
if from_address.is_empty():
return
if is_instance_valid(from_label):
from_label.text = "{address}:{port}".format({
"address": from_address,
"port": str(from_id)
})
if is_instance_valid(from_label):
from_label.modulate = EditorTubePeerItemControl.get_peer_color(from_address.hash() + from_id)
var from_id: int:
set(x):
from_id = x
if is_instance_valid(from_label):
if from_address.is_empty():
from_label.text = EditorTubePeerItemControl.get_peer_string(from_id)
from_label.visible = bool(from_id)
else:
from_label.text = "{address}:{port}".format({
"address": from_address,
"port": str(from_id)
})
if is_instance_valid(from_label):
if from_address.is_empty():
from_label.modulate = EditorTubePeerItemControl.get_peer_color(from_id)
else:
from_label.modulate = EditorTubePeerItemControl.get_peer_color(from_address.hash() + from_id)
var time: String:
set(x):
time = x
if is_instance_valid(time_label):
time_label.text = time
var button_group: ButtonGroup
@onready var button: Button = %Button
@onready var type_texture_rect: TextureRect = %TypeTextureRect
@onready var time_label: Label = %TimeLabel
@onready var from_label: Label = %FromLabel
@onready var data_label: Label = %DataLabel
func _init() -> void:
time = Time.get_time_string_from_system()
func _ready() -> void:
type = type
data = data
time = time
from_id = from_id
button.button_group = button_group
func _to_string() -> String:
return "{type}\t{time}\t{address}\t{from}\t{data}".format({
"type": strings.get(type, "-"),
"time": time,
"address": from_address,
"from": from_id,
"data": str(data),
})
func _on_button_toggled(toggled_on: bool) -> void:
if not is_instance_valid(message_control):
return
if toggled_on:
message_control.message_item_control = self
else:
message_control.message_item_control = null
#pressed.emit()
func is_pressed() -> bool:
return button.button_pressed
func info():
type = Type.INFO
func error():
type = Type.ERROR
func warning():
type = Type.WARNING
func success():
type = Type.SUCCESS
func received(p_from_id: int = 0):
type = Type.RECEIVED
from_id = p_from_id
func sent():
type = Type.SENT

View File

@ -0,0 +1 @@
uid://pbxxonhemirk

View File

@ -0,0 +1,83 @@
[gd_scene load_steps=9 format=3 uid="uid://cfsei3airwx4s"]
[ext_resource type="Theme" uid="uid://bcibt73qths3g" path="res://addons/tube/inspector/theme.tres" id="1_5a58g"]
[ext_resource type="Script" uid="uid://pbxxonhemirk" path="res://addons/tube/inspector/message_item_control.gd" id="1_eynp4"]
[ext_resource type="Texture2D" uid="uid://bw1nalj6ph1ul" path="res://addons/tube/inspector/icons/info_icon.tres" id="2_83orp"]
[ext_resource type="Texture2D" uid="uid://wcdvmyl01v1p" path="res://addons/tube/inspector/icons/error_icon.tres" id="3_hv8fo"]
[ext_resource type="Texture2D" uid="uid://c141mpg6pwbt8" path="res://addons/tube/inspector/icons/sent_icon.tres" id="4_815v1"]
[ext_resource type="Texture2D" uid="uid://cxdxdrs7t6fbp" path="res://addons/tube/inspector/icons/warning_icon.tres" id="4_hv8fo"]
[ext_resource type="Texture2D" uid="uid://d3r4nf1lyap1q" path="res://addons/tube/inspector/icons/received_icon.tres" id="5_5a58g"]
[ext_resource type="Texture2D" uid="uid://vj4v31qi6u4d" path="res://addons/tube/inspector/icons/success_icon.tres" id="7_815v1"]
[node name="MessageItemControl" type="MarginContainer"]
custom_minimum_size = Vector2(0, 32)
anchors_preset = 10
anchor_right = 1.0
offset_bottom = 27.0
grow_horizontal = 2
size_flags_horizontal = 3
theme = ExtResource("1_5a58g")
script = ExtResource("1_eynp4")
icons = Dictionary[int, Texture]({
0: ExtResource("2_83orp"),
1: ExtResource("3_hv8fo"),
2: ExtResource("4_hv8fo"),
3: ExtResource("4_815v1"),
4: ExtResource("5_5a58g"),
5: ExtResource("7_815v1")
})
[node name="Button" type="Button" parent="."]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"ButtonFlat"
toggle_mode = true
[node name="MarginContainer" type="MarginContainer" parent="."]
layout_mode = 2
mouse_filter = 2
theme_override_constants/margin_left = 4
theme_override_constants/margin_top = 2
theme_override_constants/margin_right = 4
theme_override_constants/margin_bottom = 2
[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer"]
layout_mode = 2
mouse_filter = 2
[node name="TypeTextureRect" type="TextureRect" parent="MarginContainer/HBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(28, 28)
layout_mode = 2
size_flags_horizontal = 4
mouse_filter = 2
texture = ExtResource("4_815v1")
expand_mode = 1
stretch_mode = 5
[node name="TimeLabel" type="Label" parent="MarginContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 1
theme_type_variation = &"HeaderSmall"
text = "00:00:00"
horizontal_alignment = 1
vertical_alignment = 1
[node name="FromLabel" type="Label" parent="MarginContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"HeaderSmall"
text = "00"
[node name="DataLabel" type="Label" parent="MarginContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 1
theme_type_variation = &"HeaderSmall"
text = "DATA"
vertical_alignment = 1
text_overrun_behavior = 1
[connection signal="toggled" from="Button" to="." method="_on_button_toggled"]

View File

@ -0,0 +1,71 @@
class_name EditorTubeMessagesContainer extends Control
## @experimental: This class is used as part of the TubeClientDebugPanel scene and is part of a scene. Should not be used as itself.
@export var message_control: EditorTubeMessageControl
@export var max_messages_amount := 100:
set(x):
max_messages_amount = x
if is_instance_valid(messages_amount_label):
messages_amount_label.text = "(last {value})".format({
"value": max_messages_amount
})
var _display_from: Object
@onready var messages_amount_label: Label = %MessagesAmountLabel
@onready var scroll_container: ScrollContainer = %ScrollContainer
@onready var list_container: Container = %ListContainer
func is_displaying_from(p_from) -> bool:
return _display_from == p_from
func display_messages(p_controls: Array[EditorTubeMessagesItemControl], p_from: Object = null):
_display_from = p_from
for child in list_container.get_children():
list_container.remove_child(child)
var last_control
var pressed_control
for control in p_controls:
if not is_instance_valid(control):
continue
if control.is_queued_for_deletion():
continue
control.message_control = message_control
list_container.add_child(control)
last_control = control
if control.is_pressed():
pressed_control = control
if pressed_control:
last_control = pressed_control
message_control.message_item_control = last_control
else:
message_control.message_item_control = null
if not last_control:
return
await get_tree().process_frame
if not is_instance_valid(last_control) or last_control.is_queued_for_deletion():
return
scroll_container.ensure_control_visible(last_control)
func _on_clipboard_button_pressed() -> void:
var clipboard := ""
for child in list_container.get_children():
clipboard += str(child) + "\n"
DisplayServer.clipboard_set(clipboard)

View File

@ -0,0 +1 @@
uid://bh6v4r3cb8nfa

View File

@ -0,0 +1,61 @@
[gd_scene load_steps=4 format=3 uid="uid://btfc8o5xfs14w"]
[ext_resource type="Script" uid="uid://bh6v4r3cb8nfa" path="res://addons/tube/inspector/messages_container.gd" id="1_cbqc8"]
[ext_resource type="Theme" uid="uid://bcibt73qths3g" path="res://addons/tube/inspector/theme.tres" id="1_kof4f"]
[ext_resource type="Texture2D" uid="uid://pxj3b3e28fg3" path="res://addons/tube/inspector/icons/clipboard_icon.tres" id="2_kj7ih"]
[node name="MessagesContainer" type="MarginContainer"]
custom_minimum_size = Vector2(0, 96)
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_right = -366.0
offset_bottom = -194.0
grow_horizontal = 2
grow_vertical = 2
theme = ExtResource("1_kof4f")
script = ExtResource("1_cbqc8")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 2
[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
[node name="Label" type="Label" parent="VBoxContainer/HBoxContainer"]
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "Messages"
[node name="MessagesAmountLabel" type="Label" parent="VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "(last 100)"
[node name="ClipboardButton" type="Button" parent="VBoxContainer/HBoxContainer"]
custom_minimum_size = Vector2(40, 40)
layout_mode = 2
size_flags_horizontal = 10
size_flags_vertical = 0
tooltip_text = "Copy all messages to clipboard"
theme_type_variation = &"ButtonFlat"
icon = ExtResource("2_kj7ih")
icon_alignment = 1
expand_icon = true
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
follow_focus = true
horizontal_scroll_mode = 0
vertical_scroll_mode = 4
[node name="ListContainer" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[connection signal="pressed" from="VBoxContainer/HBoxContainer/ClipboardButton" to="." method="_on_clipboard_button_pressed"]

View File

@ -0,0 +1,185 @@
class_name EditorTubePeerControl extends Control
## @experimental: This class is used as part of the TubeClientDebugPanel scene and is part of a scene. Should not be used as itself.
const STATE_COLOR_DEFAULT := Color.WHITE
const STATE_TEXT_DEFAULT := "Unknown"
@export var client: TubeClient
@export var peer_item: EditorTubePeerItemControl:
set(x):
show()
if peer_item != x:
latency_label.text = "Unknown..."
if null != peer_item:
peer_item.updated.disconnect(update)
if null != x:
x.updated.connect(update)
if null != x:
if not waiting_for_pong:
send_ping(x.peer)
if is_instance_valid(messages_container):
if peer_item != x or not messages_container.is_displaying_from(self):
messages_container.display_messages(
x.message_item_controls,
self
)
if is_instance_valid(id_label):
id_label.text = EditorTubePeerItemControl.get_peer_string(x.peer.id)
id_label.modulate = EditorTubePeerItemControl.get_peer_color(x.peer.id)
if is_instance_valid(channels_containers):
for child in channels_containers.get_children():
channels_containers.remove_child(child)
for channel_item_control in x.channel_item_controls:
channels_containers.add_child(
channel_item_control
)
peer_item = x
update()
@export var messages_container: EditorTubeMessagesContainer
var last_ping_time: float
var waiting_for_pong := false
@onready var ping_timer: Timer = %PingTimer
@onready var id_label: Label = %IdLabel
@onready var connection_state_indicator: Control = %ConnectionStateIndicator
@onready var connection_state_label: Label = %ConnectionStateLabel
@onready var gathering_state_indicator: Control = %GatheringStateIndicator
@onready var gathering_state_label: Label = %GatheringStateLabel
@onready var signaling_state_indicator: Control = %SignalingStateIndicator
@onready var signaling_state_label: Label = %SignalingStateLabel
@onready var channels_containers: Container = %ChannelsContainer
@onready var connection_time_label: Label = %ConnectingTimeLabel
@onready var up_time_label: Label = %UpTimeLabel
@onready var latency_label: Label = %LatencyLabel
@onready var fake_disconnection_timer: Timer = %FakeDisconnectionTimer
@onready var fake_disconnection_button: Button = %FakeDisconnectionButton
@onready var fake_disconnection_spin_box: SpinBox = %FakeDisconnectionSpinBox
func _ready() -> void:
hide()
func update():
if is_instance_valid(connection_state_indicator):
connection_state_indicator.modulate = STATE_COLOR_DEFAULT if not peer_item else peer_item.get_connection_state_color()
if is_instance_valid(connection_state_label):
connection_state_label.text = STATE_TEXT_DEFAULT if not peer_item else peer_item.get_connection_state_text()
if is_instance_valid(gathering_state_indicator):
gathering_state_indicator.modulate = STATE_COLOR_DEFAULT if not peer_item else peer_item.get_gathering_state_color()
if is_instance_valid(gathering_state_label):
gathering_state_label.text = STATE_TEXT_DEFAULT if not peer_item else peer_item.get_gathering_state_text()
if is_instance_valid(signaling_state_indicator):
signaling_state_indicator.modulate = STATE_COLOR_DEFAULT if not peer_item else peer_item.get_signaling_state_color()
if is_instance_valid(signaling_state_label):
signaling_state_label.text = STATE_TEXT_DEFAULT if not peer_item else peer_item.get_signaling_state_text()
if null == peer_item:
return
if is_instance_valid(fake_disconnection_button):
fake_disconnection_button.disabled = not peer_item.peer.is_peer_connected()
if is_instance_valid(messages_container):
if messages_container.is_displaying_from(self):
messages_container.display_messages(
peer_item.message_item_controls,
self
)
func add_channel_item_control(channel_item_control: EditorTubeChannelItemControl):
channels_containers.add_child(channel_item_control)
func send_ping(peer: TubePeer):
if not peer.is_peer_connected():
return
last_ping_time = Time.get_ticks_msec()
ping_timer.start(5.0)
waiting_for_pong = true
receive_ping.rpc_id(peer.id)
@rpc("any_peer", "call_remote", "reliable")
func receive_ping():
var sender_id := multiplayer.get_remote_sender_id()
send_pong(sender_id)
func send_pong(to: int):
receive_pong.rpc_id(to)
@rpc("any_peer", "call_remote", "reliable")
func receive_pong():
waiting_for_pong = false
ping_timer.start(1.0)
var ping := Time.get_ticks_msec() - last_ping_time
latency_label.text = str(ping).pad_decimals(0)
func _on_ping_timer_timeout() -> void:
if not is_visible_in_tree():
return
if not peer_item:
return
send_ping(peer_item.peer)
func _process(_delta: float) -> void:
if null == peer_item:
return
connection_time_label.text = str(
peer_item.peer.connecting_time
).pad_decimals(3)
up_time_label.text = str(
peer_item.peer.up_time
).pad_decimals(3)
func _on_fake_disconnection_button_pressed() -> void:
if not fake_disconnection_timer.is_stopped():
fake_disconnection_timer.stop()
fake_disconnection_timer.timeout.emit()
var peer := peer_item.peer
peer._disconnected()
fake_disconnection_timer.start(
fake_disconnection_spin_box.value
)
await fake_disconnection_timer.timeout
peer._connected()

View File

@ -0,0 +1 @@
uid://qcomcx6e48wn

View File

@ -0,0 +1,229 @@
[gd_scene load_steps=3 format=3 uid="uid://ckrifxh4o768d"]
[ext_resource type="Theme" uid="uid://bcibt73qths3g" path="res://addons/tube/inspector/theme.tres" id="1_6pmfw"]
[ext_resource type="Script" uid="uid://qcomcx6e48wn" path="res://addons/tube/inspector/peer_control.gd" id="1_kiw0h"]
[node name="PeerControl" type="MarginContainer"]
offset_right = 523.0
offset_bottom = 161.0
theme = ExtResource("1_6pmfw")
script = ExtResource("1_kiw0h")
[node name="PingTimer" type="Timer" parent="."]
unique_name_in_owner = true
one_shot = true
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 2
[node name="HeaderContainer" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
[node name="IdLabel" type="Label" parent="VBoxContainer/HeaderContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "00000000000000000000"
[node name="AddressLabel" type="Label" parent="VBoxContainer/HeaderContainer"]
unique_name_in_owner = true
visible = false
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "00000000000000000000"
[node name="GridContainer" type="GridContainer" parent="VBoxContainer"]
layout_mode = 2
columns = 2
[node name="StatesContainer" type="VBoxContainer" parent="VBoxContainer/GridContainer"]
layout_mode = 2
[node name="ConnectionStateContainer" type="HBoxContainer" parent="VBoxContainer/GridContainer/StatesContainer"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 4
[node name="Label" type="Label" parent="VBoxContainer/GridContainer/StatesContainer/ConnectionStateContainer"]
custom_minimum_size = Vector2(114, 0)
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "Connection"
[node name="ConnectionStateIndicator" type="Panel" parent="VBoxContainer/GridContainer/StatesContainer/ConnectionStateContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(24, 12)
layout_mode = 2
size_flags_vertical = 4
theme_type_variation = &"PanelIndicator"
[node name="ConnectionStateLabel" type="Label" parent="VBoxContainer/GridContainer/StatesContainer/ConnectionStateContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"LabelH2"
text = "STATE"
[node name="GatheringStateContainer" type="HBoxContainer" parent="VBoxContainer/GridContainer/StatesContainer"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 4
[node name="Label" type="Label" parent="VBoxContainer/GridContainer/StatesContainer/GatheringStateContainer"]
custom_minimum_size = Vector2(114, 0)
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "Gathering"
[node name="GatheringStateIndicator" type="Panel" parent="VBoxContainer/GridContainer/StatesContainer/GatheringStateContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(24, 12)
layout_mode = 2
size_flags_vertical = 4
theme_type_variation = &"PanelIndicator"
[node name="GatheringStateLabel" type="Label" parent="VBoxContainer/GridContainer/StatesContainer/GatheringStateContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"LabelH2"
text = "STATE"
[node name="SignalingStateContainer" type="HBoxContainer" parent="VBoxContainer/GridContainer/StatesContainer"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 4
[node name="Label" type="Label" parent="VBoxContainer/GridContainer/StatesContainer/SignalingStateContainer"]
custom_minimum_size = Vector2(114, 0)
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "Signaling"
[node name="SignalingStateIndicator" type="Panel" parent="VBoxContainer/GridContainer/StatesContainer/SignalingStateContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(24, 12)
layout_mode = 2
size_flags_vertical = 4
theme_type_variation = &"PanelIndicator"
[node name="SignalingStateLabel" type="Label" parent="VBoxContainer/GridContainer/StatesContainer/SignalingStateContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"LabelH2"
text = "STATE"
[node name="ChannelsContainer" type="HBoxContainer" parent="VBoxContainer/GridContainer"]
layout_mode = 2
size_flags_horizontal = 3
[node name="ChannelsLabel" type="Label" parent="VBoxContainer/GridContainer/ChannelsContainer"]
layout_mode = 2
size_flags_vertical = 0
theme_type_variation = &"LabelH3"
text = "Channels"
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer/GridContainer/ChannelsContainer"]
custom_minimum_size = Vector2(0, 96)
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
follow_focus = true
horizontal_scroll_mode = 0
[node name="ChannelsContainer" type="VBoxContainer" parent="VBoxContainer/GridContainer/ChannelsContainer/ScrollContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
[node name="TimesContainer" type="VBoxContainer" parent="VBoxContainer/GridContainer"]
custom_minimum_size = Vector2(250, 0)
layout_mode = 2
[node name="ConnectingTimeContainer" type="HBoxContainer" parent="VBoxContainer/GridContainer/TimesContainer"]
layout_mode = 2
[node name="Label" type="Label" parent="VBoxContainer/GridContainer/TimesContainer/ConnectingTimeContainer"]
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "Connecting time"
[node name="ConnectingTimeLabel" type="Label" parent="VBoxContainer/GridContainer/TimesContainer/ConnectingTimeContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"LabelH2"
text = "00.000"
[node name="SecondLabel" type="Label" parent="VBoxContainer/GridContainer/TimesContainer/ConnectingTimeContainer"]
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "s"
[node name="UpTimeContainer" type="HBoxContainer" parent="VBoxContainer/GridContainer/TimesContainer"]
layout_mode = 2
[node name="Label" type="Label" parent="VBoxContainer/GridContainer/TimesContainer/UpTimeContainer"]
custom_minimum_size = Vector2(146, 0)
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "Up time"
[node name="UpTimeLabel" type="Label" parent="VBoxContainer/GridContainer/TimesContainer/UpTimeContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"LabelH2"
text = "0.0"
[node name="SecondLabel" type="Label" parent="VBoxContainer/GridContainer/TimesContainer/UpTimeContainer"]
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "s"
[node name="LatencyContainer" type="HBoxContainer" parent="VBoxContainer/GridContainer/TimesContainer"]
layout_mode = 2
[node name="Label" type="Label" parent="VBoxContainer/GridContainer/TimesContainer/LatencyContainer"]
custom_minimum_size = Vector2(146, 0)
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "Latency"
[node name="LatencyLabel" type="Label" parent="VBoxContainer/GridContainer/TimesContainer/LatencyContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"LabelH2"
text = "00000"
[node name="SecondLabel" type="Label" parent="VBoxContainer/GridContainer/TimesContainer/LatencyContainer"]
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "ms"
[node name="UtilsContainer" type="VBoxContainer" parent="VBoxContainer/GridContainer"]
layout_mode = 2
[node name="FakeDisconnectionContainer" type="HBoxContainer" parent="VBoxContainer/GridContainer/UtilsContainer"]
layout_mode = 2
[node name="FakeDisconnectionTimer" type="Timer" parent="VBoxContainer/GridContainer/UtilsContainer/FakeDisconnectionContainer"]
unique_name_in_owner = true
one_shot = true
[node name="FakeDisconnectionButton" type="Button" parent="VBoxContainer/GridContainer/UtilsContainer/FakeDisconnectionContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(192, 0)
layout_mode = 2
size_flags_horizontal = 0
tooltip_text = "Fake disconnectionon on code level, not on network level, for a few secondes.
Peer will emit disconneted (unstabilized) signal to client. Will emit connected (stabilized) to client after few seconde."
text = "FAKE DISCONNECTED"
[node name="FakeDisconnectionSpinBox" type="SpinBox" parent="VBoxContainer/GridContainer/UtilsContainer/FakeDisconnectionContainer"]
unique_name_in_owner = true
layout_mode = 2
max_value = 10.0
step = 0.001
value = 1.0
allow_greater = true
suffix = "s"
[connection signal="timeout" from="PingTimer" to="." method="_on_ping_timer_timeout"]
[connection signal="pressed" from="VBoxContainer/GridContainer/UtilsContainer/FakeDisconnectionContainer/FakeDisconnectionButton" to="." method="_on_fake_disconnection_button_pressed"]

View File

@ -0,0 +1,390 @@
class_name EditorTubePeerItemControl extends Control
## @experimental: This class is used as part of the TubeClientDebugPanel scene and is part of a scene. Should not be used as itself.
signal pressed
signal updated
const MESSAGE_ITEM_CONTROL_SCENE := preload("uid://cfsei3airwx4s")
const CHANNEL_ITEM_CONTROL_SCENE := preload("uid://dc3ssinymllca")
const STATE_COLOR_DEFAULT := Color.WHITE
const STATE_TEXT_DEFAULT := "Unknown"
const CONNECTION_STATE_COLOR := {
WebRTCPeerConnection.STATE_NEW: Color.BEIGE,
WebRTCPeerConnection.STATE_CONNECTING: Color.CYAN,
WebRTCPeerConnection.STATE_CONNECTED: Color.PALE_GREEN,
WebRTCPeerConnection.STATE_DISCONNECTED: Color.CYAN,
WebRTCPeerConnection.STATE_FAILED: Color.GOLDENROD,
WebRTCPeerConnection.STATE_CLOSED: Color.CRIMSON,
}
const CONNECTION_STATE_TEXT := {
WebRTCPeerConnection.STATE_NEW: "New",
WebRTCPeerConnection.STATE_CONNECTING: "Connecting",
WebRTCPeerConnection.STATE_CONNECTED: "Connected",
WebRTCPeerConnection.STATE_DISCONNECTED: 'Disconnected',
WebRTCPeerConnection.STATE_FAILED: "Failed",
WebRTCPeerConnection.STATE_CLOSED: "Closed",
}
const GATHERING_STATE_COLOR := {
WebRTCPeerConnection.GATHERING_STATE_NEW: Color.BEIGE,
WebRTCPeerConnection.GATHERING_STATE_GATHERING: Color.CYAN,
WebRTCPeerConnection.GATHERING_STATE_COMPLETE: Color.PALE_GREEN,
}
const GATHERING_STATE_TEXT := {
WebRTCPeerConnection.GATHERING_STATE_NEW: "New",
WebRTCPeerConnection.GATHERING_STATE_GATHERING: "Gathering",
WebRTCPeerConnection.GATHERING_STATE_COMPLETE: "Complete",
}
const SIGNALING_STATE_COLOR := {
WebRTCPeerConnection.SIGNALING_STATE_STABLE: Color.PALE_GREEN,
WebRTCPeerConnection.SIGNALING_STATE_HAVE_LOCAL_OFFER: Color.CYAN,
WebRTCPeerConnection.SIGNALING_STATE_HAVE_REMOTE_OFFER: Color.CYAN,
WebRTCPeerConnection.SIGNALING_STATE_HAVE_LOCAL_PRANSWER: Color.CYAN,
WebRTCPeerConnection.SIGNALING_STATE_HAVE_REMOTE_PRANSWER: Color.CYAN,
WebRTCPeerConnection.SIGNALING_STATE_CLOSED: Color.CRIMSON,
}
const SIGNALING_STATE_TEXT := {
WebRTCPeerConnection.SIGNALING_STATE_STABLE: "Stable",
WebRTCPeerConnection.SIGNALING_STATE_HAVE_LOCAL_OFFER: "Have local offer",
WebRTCPeerConnection.SIGNALING_STATE_HAVE_REMOTE_OFFER: "Have remote offer",
WebRTCPeerConnection.SIGNALING_STATE_HAVE_LOCAL_PRANSWER: "Have local answer",
WebRTCPeerConnection.SIGNALING_STATE_HAVE_REMOTE_PRANSWER: "Have remote answer",
WebRTCPeerConnection.SIGNALING_STATE_CLOSED: "Closed",
}
@export var peer_control: EditorTubePeerControl
@export var channel_control: EditorTubeChannelControl
@export var client: TubeClient # to call kick
@export var max_messages_amount: int = 100
var peer: TubePeer:
set(x):
if null != peer:
peer.warning_raised.disconnect(
_on_peer_warning_raised
)
peer.failed.disconnect(
_on_peer_failed
)
peer.connected.disconnect(
_on_peer_connected
)
peer.disconnected.disconnect(
_on_peer_disconnected
)
peer.signaling_readied.disconnect(
_on_peer_signaling_readied
)
peer.signaling_timeout.disconnect(
_on_peer_signaling_timeout
)
peer.connection_state_changed.disconnect(
_on_peer_connection_state_changed
)
peer.port_mapped.disconnect(
_on_peer_port_mapped
)
peer.channel_initiated.disconnect(
_on_peer_channel_initiated
)
peer.session_description_created.disconnect(
_on_peer_session_description_created
)
peer.ice_candidate_created.disconnect(
_on_peer_ice_candidate_created
)
peer.remote_description_setted.disconnect(
_on_peer_remote_description_setted
)
peer.ice_candidate_added.disconnect(
_on_peer_ice_candidate_added
)
if null != x:
x.warning_raised.connect(
_on_peer_warning_raised
)
x.failed.connect(
_on_peer_failed
)
x.connected.connect(
_on_peer_connected
)
x.disconnected.connect(
_on_peer_disconnected
)
x.signaling_readied.connect(
_on_peer_signaling_readied
)
x.signaling_timeout.connect(
_on_peer_signaling_timeout
)
x.connection_state_changed.connect(
_on_peer_connection_state_changed
)
x.port_mapped.connect(
_on_peer_port_mapped
)
x.channel_initiated.connect(
_on_peer_channel_initiated
)
x.session_description_created.connect(
_on_peer_session_description_created
)
x.ice_candidate_created.connect(
_on_peer_ice_candidate_created
)
x.remote_description_setted.connect(
_on_peer_remote_description_setted
)
x.ice_candidate_added.connect(
_on_peer_ice_candidate_added
)
peer = x
update()
var message_item_controls: Array[EditorTubeMessagesItemControl] = []
var message_item_button_group := ButtonGroup.new()
var channel_item_controls: Array[EditorTubeChannelItemControl] = []
@onready var name_label: Label = %NameLabel
@onready var state_indicator: Control = %StateIndicator
@onready var kick_button: Button = %KickButton
func _ready() -> void:
message_item_button_group.allow_unpress = true
update()
static var peers_color: Dictionary[int, Color] = {}
static func get_peer_color(p_peer_id: int) -> Color:
if 0 == p_peer_id:
return Color.BLACK
if 1 == p_peer_id:
return Color.WHITE
if peers_color.has(p_peer_id):
return peers_color[p_peer_id]
var rng := RandomNumberGenerator.new()
rng.seed = p_peer_id
var color := Color.from_hsv(
rng.randf_range(0.4, 0.9),
rng.randf_range(0.4, 0.8),
rng.randf_range(0.9, 1.0),
1.0
)
peers_color[p_peer_id] = color
return color
static func get_peer_string(p_peer_id: int) -> String:
if 0 == p_peer_id:
return ""
if 1 == p_peer_id:
return "1 (Server)"
return str(p_peer_id)
func get_connection_state_color() -> Color:
return STATE_COLOR_DEFAULT if not peer else CONNECTION_STATE_COLOR[peer.connection_state]
func get_connection_state_text() -> String:
return STATE_TEXT_DEFAULT if not peer else CONNECTION_STATE_TEXT[peer.connection_state]
func get_gathering_state_color() -> Color:
return STATE_COLOR_DEFAULT if not peer else GATHERING_STATE_COLOR[peer.gathering_state]
func get_gathering_state_text() -> String:
return STATE_TEXT_DEFAULT if not peer else GATHERING_STATE_TEXT[peer.gathering_state]
func get_signaling_state_color() -> Color:
return STATE_COLOR_DEFAULT if not peer else SIGNALING_STATE_COLOR[peer.signaling_state]
func get_signaling_state_text() -> String:
return STATE_TEXT_DEFAULT if not peer else SIGNALING_STATE_TEXT[peer.signaling_state]
func _on_button_pressed() -> void:
if is_instance_valid(peer_control):
peer_control.peer_item = self
pressed.emit()
func _on_kick_button_pressed() -> void:
client.kick_peer(peer.id)
func update():
if null == peer:
return
if is_instance_valid(name_label):
name_label.text = get_peer_string(peer.id)
name_label.modulate = get_peer_color(peer.id)
if is_instance_valid(state_indicator):
state_indicator.modulate = get_connection_state_color()
state_indicator.tooltip_text = get_connection_state_text()
if is_instance_valid(peer_control):
if self == peer_control.peer_item:
peer_control.update()
if is_instance_valid(kick_button):
if client:
kick_button.visible = client.is_server
updated.emit()
func add_message_item_control(data) -> EditorTubeMessagesItemControl:
if max_messages_amount <= message_item_controls.size():
var item := message_item_controls.pop_front()
item.queue_free()
var message_item_control := MESSAGE_ITEM_CONTROL_SCENE.instantiate()
message_item_controls.append(message_item_control)
message_item_control.data = data
message_item_control.button_group = message_item_button_group
return message_item_control
func add_channel_item_control(channel: WebRTCDataChannel):
var channel_item_control := CHANNEL_ITEM_CONTROL_SCENE.instantiate()
channel_item_controls.append(channel_item_control)
channel_item_control.peer = peer
channel_item_control.channel = channel
channel_item_control.channel_control = channel_control
if is_instance_valid(peer_control):
if peer_control.peer_item == self:
peer_control.add_channel_item_control(
channel_item_control
)
func _on_peer_warning_raised(message: String):
add_message_item_control(message).warning()
update()
func _on_peer_connected():
add_message_item_control("Connected").success()
update()
func _on_peer_failed():
add_message_item_control("Connection failed: {error}".format({
"error": peer.error_message
})).error()
update()
func _on_peer_disconnected():
add_message_item_control("Disconnected")
update()
func _on_peer_signaling_readied():
add_message_item_control("Signaling ready")
update()
func _on_peer_signaling_timeout():
add_message_item_control("Signaling timeout").warning()
update()
func _on_peer_connection_state_changed():
add_message_item_control("State changed to {connection}/{gathering}/{signaling}".format({
"connection": get_connection_state_text(),
"gathering": get_gathering_state_text(),
"signaling": get_signaling_state_text(),
}))
update()
func _on_peer_channel_initiated(p_channel: WebRTCDataChannel):
add_channel_item_control(p_channel)
add_message_item_control(
"Channel {label} initiated".format({
"label": p_channel.get_label(),
})
)
update()
func _on_peer_port_mapped(public_port: int, local_port: int):
add_message_item_control(
"Port {port} mapped to internal port {internal_port}".format({
"port": public_port,
"internal_port": local_port
})
)
update()
func _on_peer_session_description_created(): # local
add_message_item_control(
"Session description created: {description}".format({
"description": peer.local_session_description
})
)
update()
func _on_peer_ice_candidate_created(): # local
add_message_item_control(
"Ice candidate created: {candidate}".format({
"candidate": peer.ice_candidates[-1]
})
)
update()
func _on_peer_remote_description_setted():
add_message_item_control(
"Remote session description setted: {description}".format({
"description": peer.remote_session_description
})
)
update()
func _on_peer_ice_candidate_added(ice_candidate: Dictionary): # remote
add_message_item_control(
"Ice candidate added: {candidate}".format({
"candidate": ice_candidate
})
)
update()

View File

@ -0,0 +1 @@
uid://c2jyudi8823qj

View File

@ -0,0 +1,65 @@
[gd_scene load_steps=6 format=3 uid="uid://dq2d125kftur6"]
[ext_resource type="Script" uid="uid://c2jyudi8823qj" path="res://addons/tube/inspector/peer_item_control.gd" id="1_2007k"]
[ext_resource type="Theme" uid="uid://bcibt73qths3g" path="res://addons/tube/inspector/theme.tres" id="1_uctpu"]
[ext_resource type="ButtonGroup" uid="uid://fko7ise7cj31" path="res://addons/tube/inspector/tracker_peer_item_button_group.tres" id="2_nna7f"]
[ext_resource type="Texture2D" uid="uid://bblpobvrbvblp" path="res://addons/tube/inspector/icons/icons_sheet_white.png" id="3_hvdy4"]
[sub_resource type="AtlasTexture" id="AtlasTexture_uctpu"]
atlas = ExtResource("3_hvdy4")
region = Rect2(400, 1200, 100, 100)
[node name="PeerItemControl" type="MarginContainer"]
offset_right = 202.0
offset_bottom = 38.0
theme = ExtResource("1_uctpu")
script = ExtResource("1_2007k")
[node name="Button" type="Button" parent="."]
layout_mode = 2
theme_type_variation = &"ButtonFlat"
toggle_mode = true
button_group = ExtResource("2_nna7f")
alignment = 0
[node name="MarginContainer" type="MarginContainer" parent="."]
layout_mode = 2
mouse_filter = 2
theme_override_constants/margin_left = 4
theme_override_constants/margin_top = 2
theme_override_constants/margin_right = 4
theme_override_constants/margin_bottom = 2
[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 4
[node name="StateIndicator" type="Panel" parent="MarginContainer/HBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(24, 12)
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
theme_type_variation = &"PanelIndicator"
[node name="NameLabel" type="Label" parent="MarginContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"HeaderMedium"
text = "PEER_ID"
text_overrun_behavior = 1
[node name="KickButton" type="Button" parent="MarginContainer/HBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(34, 0)
layout_mode = 2
tooltip_text = "Kick"
theme_type_variation = &"ButtonFlat"
icon = SubResource("AtlasTexture_uctpu")
icon_alignment = 1
expand_icon = true
[connection signal="pressed" from="Button" to="." method="_on_button_pressed"]
[connection signal="pressed" from="MarginContainer/HBoxContainer/KickButton" to="." method="_on_kick_button_pressed"]

View File

@ -0,0 +1,537 @@
[gd_resource type="Theme" format=3 uid="uid://bcibt73qths3g"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_8xdx6"]
bg_color = Color(0, 0, 0, 0.3)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_qlom4"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_h3tqh"]
content_margin_left = 2.0
content_margin_top = 2.0
content_margin_right = 2.0
content_margin_bottom = 2.0
bg_color = Color(0, 0, 0, 0.5)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(1, 1, 1, 0.2)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
shadow_color = Color(0, 0, 0, 0.005)
shadow_size = 16
shadow_offset = Vector2(0, 8)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_rmtr8"]
content_margin_left = 2.0
content_margin_top = 2.0
content_margin_right = 2.0
content_margin_bottom = 2.0
bg_color = Color(0, 0, 0, 0.5)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(1, 1, 1, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_l5cq1"]
content_margin_left = 2.0
content_margin_top = 2.0
content_margin_right = 2.0
content_margin_bottom = 2.0
bg_color = Color(0.20906562, 0.20906562, 0.20906562, 0)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(1, 1, 1, 0.3)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_22oxg"]
bg_color = Color(1, 1, 1, 0.1)
draw_center = false
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lxenf"]
bg_color = Color(1, 1, 1, 0.15)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_5vlfp"]
bg_color = Color(1, 1, 1, 0.1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_udha7"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_84y7l"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ob1qp"]
content_margin_left = 12.0
content_margin_top = 4.0
content_margin_right = 12.0
content_margin_bottom = 4.0
bg_color = Color(0.145098, 0.145098, 0.145098, 1)
draw_center = false
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_hmtxw"]
content_margin_left = 8.0
content_margin_top = 8.0
content_margin_right = 8.0
content_margin_bottom = 8.0
bg_color = Color(0.145098, 0.145098, 0.145098, 1)
draw_center = false
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(1, 1, 1, 0.3)
corner_radius_top_left = 512
corner_radius_top_right = 512
corner_radius_bottom_right = 512
corner_radius_bottom_left = 512
corner_detail = 20
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_s60ke"]
content_margin_left = 8.0
content_margin_top = 8.0
content_margin_right = 8.0
content_margin_bottom = 8.0
bg_color = Color(0.145098, 0.145098, 0.145098, 1)
draw_center = false
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(1, 1, 1, 0.3)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_wopcr"]
content_margin_left = 8.0
content_margin_top = 8.0
content_margin_right = 8.0
content_margin_bottom = 8.0
bg_color = Color(0, 0, 0, 0.5)
border_width_left = 4
border_width_top = 4
border_width_right = 4
border_width_bottom = 4
border_color = Color(0.145098, 0.145098, 0.145098, 0)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_qy4ym"]
content_margin_left = 8.0
content_margin_top = 8.0
content_margin_right = 8.0
content_margin_bottom = 8.0
bg_color = Color(0, 0, 0, 0.3)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7lltk"]
content_margin_left = 0.0
content_margin_top = 12.0
content_margin_right = 0.0
content_margin_bottom = 12.0
bg_color = Color(0.145098, 0.145098, 0.145098, 1)
draw_center = false
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxLine" id="StyleBoxLine_urpwp"]
color = Color(0.101569, 0.101569, 0.101569, 1)
grow_begin = -8.0
grow_end = -8.0
thickness = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_xw7tr"]
content_margin_left = 4.0
content_margin_top = 4.0
content_margin_right = 4.0
content_margin_bottom = 4.0
bg_color = Color(1, 1, 1, 0.75)
draw_center = false
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
corner_radius_top_left = 3
corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
corner_detail = 5
expand_margin_left = 2.0
expand_margin_top = 2.0
expand_margin_right = 2.0
expand_margin_bottom = 2.0
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_hoenv"]
bg_color = Color(0.145098, 0.145098, 0.145098, 1)
draw_center = false
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jnksh"]
content_margin_left = 16.0
content_margin_top = 6.0
content_margin_right = 16.0
content_margin_bottom = 6.0
bg_color = Color(0.09985284, 0.09985284, 0.09985284, 0.5)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(1, 1, 1, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_e1cyt"]
content_margin_left = 16.0
content_margin_top = 6.0
content_margin_right = 16.0
content_margin_bottom = 6.0
bg_color = Color(0.11207495, 0.11207495, 0.11207495, 0)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(1, 1, 1, 0.3)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7uqqi"]
content_margin_left = 16.0
content_margin_top = 6.0
content_margin_right = 16.0
content_margin_bottom = 6.0
bg_color = Color(0, 0, 0, 0.3)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_k2ivb"]
content_margin_left = 2.0
content_margin_top = 2.0
content_margin_right = 2.0
content_margin_bottom = 2.0
bg_color = Color(0.187843, 0.187843, 0.187843, 1)
border_color = Color(0.235931, 0.235931, 0.235931, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
shadow_color = Color(0, 0, 0, 0.005)
shadow_size = 16
shadow_offset = Vector2(0, 8)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0p70m"]
content_margin_left = 0.0
content_margin_top = 0.0
content_margin_right = 0.0
content_margin_bottom = 0.0
bg_color = Color(0.145098, 0.145098, 0.145098, 1)
draw_center = false
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_owuhx"]
content_margin_left = 8.0
content_margin_top = 8.0
content_margin_right = 8.0
content_margin_bottom = 8.0
bg_color = Color(0.016954614, 0.052993968, 0.09495633, 0.9019608)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_urpwp"]
content_margin_left = 16.0
content_margin_top = 9.0
content_margin_right = 16.0
content_margin_bottom = 8.0
bg_color = Color(1, 1, 1, 0.1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_n7dtj"]
bg_color = Color(1, 1, 1, 0.85)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
shadow_color = Color(1, 1, 1, 0.2)
shadow_size = 2
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_281ff"]
content_margin_left = 8.0
content_margin_top = 8.0
content_margin_right = 8.0
content_margin_bottom = 8.0
bg_color = Color(0.0979412, 0.0979412, 0.0979412, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
expand_margin_top = 4.0
expand_margin_bottom = 4.0
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lt73j"]
content_margin_left = 8.0
content_margin_top = 8.0
content_margin_right = 8.0
content_margin_bottom = 8.0
bg_color = Color(0.230588, 0.230588, 0.230588, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
expand_margin_top = 4.0
expand_margin_bottom = 4.0
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6eiqv"]
content_margin_left = 16.0
content_margin_top = 16.0
content_margin_right = 16.0
content_margin_bottom = 16.0
bg_color = Color(0.126961, 0.126961, 0.126961, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_8xdx6"]
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_g0kxi"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_84y7l"]
content_margin_left = 16.0
content_margin_top = 6.0
content_margin_right = 16.0
content_margin_bottom = 6.0
bg_color = Color(0.11207495, 0.11207495, 0.11207495, 0)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_5arv8"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_im384"]
content_margin_left = 0.0
content_margin_top = 0.0
content_margin_right = 0.0
content_margin_bottom = 0.0
bg_color = Color(0.116078, 0.116078, 0.116078, 1)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_doa6c"]
content_margin_left = 12.0
content_margin_top = 0.0
content_margin_right = 12.0
content_margin_bottom = 0.0
bg_color = Color(0.145098, 0.145098, 0.145098, 1)
draw_center = false
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxLine" id="StyleBoxLine_5clyl"]
color = Color(0.101569, 0.101569, 0.101569, 1)
grow_begin = -8.0
grow_end = -8.0
thickness = 4
vertical = true
[resource]
Button/colors/font_color = Color(1, 1, 1, 1)
Button/colors/font_disabled_color = Color(1, 1, 1, 0.3)
Button/colors/font_focus_color = Color(1, 1, 1, 1)
Button/colors/font_hover_color = Color(1, 1, 1, 1)
Button/colors/font_hover_pressed_color = Color(1, 1, 1, 1)
Button/colors/font_pressed_color = Color(1, 1, 1, 1)
Button/colors/icon_disabled_color = Color(1, 1, 1, 0.3)
Button/colors/icon_normal_color = Color(1, 1, 1, 1)
Button/colors/icon_pressed_color = Color(1, 1, 1, 1)
Button/constants/outline_size = 0
Button/font_sizes/font_size = 18
Button/styles/disabled = SubResource("StyleBoxFlat_8xdx6")
Button/styles/disabled_mirrored = null
Button/styles/focus = SubResource("StyleBoxEmpty_qlom4")
Button/styles/hover = SubResource("StyleBoxFlat_h3tqh")
Button/styles/hover_pressed = SubResource("StyleBoxFlat_rmtr8")
Button/styles/normal = SubResource("StyleBoxFlat_l5cq1")
Button/styles/pressed = SubResource("StyleBoxFlat_rmtr8")
ButtonFlat/base_type = &"Button"
ButtonFlat/styles/disabled = SubResource("StyleBoxFlat_22oxg")
ButtonFlat/styles/disabled_mirrored = SubResource("StyleBoxFlat_22oxg")
ButtonFlat/styles/hover = SubResource("StyleBoxFlat_lxenf")
ButtonFlat/styles/hover_mirrored = SubResource("StyleBoxFlat_lxenf")
ButtonFlat/styles/hover_pressed = SubResource("StyleBoxFlat_5vlfp")
ButtonFlat/styles/hover_pressed_mirrored = SubResource("StyleBoxFlat_5vlfp")
ButtonFlat/styles/normal = SubResource("StyleBoxEmpty_udha7")
ButtonFlat/styles/normal_mirrored = SubResource("StyleBoxEmpty_84y7l")
ButtonFlat/styles/pressed = SubResource("StyleBoxFlat_5vlfp")
ButtonFlat/styles/pressed_mirrored = SubResource("StyleBoxFlat_5vlfp")
CheckBox/colors/font_hover_pressed_color = Color(1, 1, 1, 1)
CheckBox/colors/font_pressed_color = Color(1, 1, 1, 0.7)
CheckBox/styles/normal = SubResource("StyleBoxFlat_ob1qp")
CheckBox/styles/normal_mirrored = SubResource("StyleBoxFlat_ob1qp")
CheckButton/colors/font_focus_color = Color(1, 1, 1, 0.7)
CheckButton/colors/font_hover_pressed_color = Color(1, 1, 1, 1)
CheckButton/colors/font_pressed_color = Color(1, 1, 1, 0.7)
CodeEdit/colors/line_number_color = Color(1, 1, 1, 0.6)
ColorPicker/styles/picker_focus_circle = SubResource("StyleBoxFlat_hmtxw")
ColorPicker/styles/picker_focus_rectangle = SubResource("StyleBoxFlat_s60ke")
ColorPicker/styles/sample_focus = SubResource("StyleBoxFlat_s60ke")
HBoxContainer/constants/separation = 4
HScrollBar/styles/grabber = SubResource("StyleBoxFlat_wopcr")
HScrollBar/styles/grabber_highlight = SubResource("StyleBoxFlat_qy4ym")
HScrollBar/styles/grabber_pressed = SubResource("StyleBoxFlat_qy4ym")
HScrollBar/styles/scroll = SubResource("StyleBoxFlat_7lltk")
HScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_7lltk")
HSeparator/constants/separation = 16
HSeparator/styles/separator = SubResource("StyleBoxLine_urpwp")
HSplitContainer/constants/autohide = 0
HSplitContainer/constants/minimum_grab_thickness = 12
HSplitContainer/constants/separation = 4
Label/colors/font_color = Color(1, 1, 1, 1)
Label/colors/font_outline_color = Color(0, 0, 0, 1)
Label/colors/font_shadow_color = Color(0, 0, 0, 0)
Label/constants/line_spacing = 3
Label/constants/outline_size = 0
Label/constants/paragraph_spacing = 0
Label/constants/shadow_offset_x = 1
Label/constants/shadow_offset_y = 1
Label/constants/shadow_outline_size = 1
Label/font_sizes/font_size = 16
Label/styles/focus = SubResource("StyleBoxFlat_xw7tr")
Label/styles/normal = SubResource("StyleBoxFlat_hoenv")
LabelH1/base_type = &"Label"
LabelH1/font_sizes/font_size = 28
LabelH2/base_type = &"Label"
LabelH2/font_sizes/font_size = 24
LabelH3/base_type = &"Label"
LabelH3/font_sizes/font_size = 20
LineEdit/colors/font_placeholder_color = Color(1, 1, 1, 0.4)
LineEdit/font_sizes/font_size = 18
LineEdit/styles/focus = SubResource("StyleBoxFlat_jnksh")
LineEdit/styles/normal = SubResource("StyleBoxFlat_e1cyt")
LineEdit/styles/read_only = SubResource("StyleBoxFlat_7uqqi")
MenuButton/colors/font_color = Color(1, 1, 1, 0.7)
MenuButton/colors/font_disabled_color = Color(1, 1, 1, 0.3)
MenuButton/colors/font_focus_color = Color(1, 1, 1, 1)
MenuButton/colors/font_hover_color = Color(1, 1, 1, 1)
MenuButton/colors/font_hover_pressed_color = Color(1, 1, 1, 1)
MenuButton/colors/font_pressed_color = Color(1, 1, 1, 1)
MenuButton/colors/icon_disabled_color = Color(1, 1, 1, 0.3)
MenuButton/colors/icon_focus_color = Color(1, 1, 1, 1)
MenuButton/colors/icon_hover_color = Color(1, 1, 1, 1)
MenuButton/colors/icon_hover_pressed_color = Color(1, 1, 1, 1)
MenuButton/colors/icon_normal_color = Color(1, 1, 1, 0.7)
MenuButton/colors/icon_pressed_color = Color(1, 1, 1, 1)
MenuButton/styles/disabled = SubResource("StyleBoxFlat_22oxg")
MenuButton/styles/disabled_mirrored = SubResource("StyleBoxFlat_22oxg")
MenuButton/styles/focus = SubResource("StyleBoxFlat_22oxg")
MenuButton/styles/hover = SubResource("StyleBoxFlat_lxenf")
MenuButton/styles/hover_mirrored = SubResource("StyleBoxFlat_lxenf")
MenuButton/styles/hover_pressed = SubResource("StyleBoxFlat_lxenf")
MenuButton/styles/hover_pressed_mirrored = SubResource("StyleBoxFlat_lxenf")
MenuButton/styles/normal = SubResource("StyleBoxFlat_22oxg")
MenuButton/styles/normal_mirrored = SubResource("StyleBoxFlat_22oxg")
MenuButton/styles/pressed = SubResource("StyleBoxFlat_5vlfp")
MenuButton/styles/pressed_mirrored = SubResource("StyleBoxFlat_5vlfp")
OptionButton/colors/font_color = Color(1, 1, 1, 0.7)
OptionButton/colors/font_disabled_color = Color(1, 1, 1, 0.3)
OptionButton/colors/font_focus_color = Color(1, 1, 1, 1)
OptionButton/colors/font_hover_color = Color(1, 1, 1, 1)
OptionButton/colors/font_hover_pressed_color = Color(1, 1, 1, 1)
OptionButton/colors/font_pressed_color = Color(1, 1, 1, 1)
OptionButton/colors/icon_disabled_color = Color(1, 1, 1, 0.3)
OptionButton/colors/icon_normal_color = Color(1, 1, 1, 0.7)
OptionButton/constants/arrow_margin = 14
OptionButton/styles/disabled = SubResource("StyleBoxFlat_k2ivb")
OptionButton/styles/disabled_mirrored = SubResource("StyleBoxFlat_k2ivb")
OptionButton/styles/focus = SubResource("StyleBoxFlat_0p70m")
OptionButton/styles/hover = SubResource("StyleBoxFlat_h3tqh")
OptionButton/styles/hover_mirrored = SubResource("StyleBoxFlat_h3tqh")
OptionButton/styles/hover_pressed = SubResource("StyleBoxFlat_rmtr8")
OptionButton/styles/hover_pressed_mirrored = SubResource("StyleBoxFlat_rmtr8")
OptionButton/styles/normal = SubResource("StyleBoxFlat_l5cq1")
OptionButton/styles/normal_mirrored = SubResource("StyleBoxFlat_l5cq1")
OptionButton/styles/pressed = SubResource("StyleBoxFlat_rmtr8")
OptionButton/styles/pressed_mirrored = SubResource("StyleBoxFlat_rmtr8")
PanelContainer/styles/panel = SubResource("StyleBoxFlat_owuhx")
PanelH1Container/base_type = &"PanelContainer"
PanelH1Container/styles/panel = SubResource("StyleBoxFlat_urpwp")
PanelIndicator/base_type = &"Panel"
PanelIndicator/styles/panel = SubResource("StyleBoxFlat_n7dtj")
ProgressBar/styles/background = SubResource("StyleBoxFlat_281ff")
ProgressBar/styles/fill = SubResource("StyleBoxFlat_lt73j")
RichTextLabel/styles/normal = SubResource("StyleBoxFlat_6eiqv")
ScrollContainer/styles/focus = SubResource("StyleBoxFlat_0p70m")
ScrollContainer/styles/panel = SubResource("StyleBoxFlat_0p70m")
SplitContainer/constants/autohide = 0
SplitContainer/constants/minimum_grab_thickness = 16
SplitContainer/constants/separation = 6
TabContainer/styles/panel = SubResource("StyleBoxEmpty_8xdx6")
TextEdit/colors/font_color = Color(1, 1, 1, 1)
TextEdit/colors/font_placeholder_color = Color(1, 1, 1, 1)
TextEdit/colors/font_readonly_color = Color(1, 1, 1, 1)
TextEdit/font_sizes/font_size = 20
TextEdit/styles/focus = SubResource("StyleBoxEmpty_g0kxi")
TextEdit/styles/normal = SubResource("StyleBoxFlat_84y7l")
TextEdit/styles/read_only = SubResource("StyleBoxEmpty_5arv8")
TooltipPanel/styles/panel = SubResource("StyleBoxFlat_im384")
VBoxContainer/constants/separation = 4
VScrollBar/styles/grabber = SubResource("StyleBoxFlat_wopcr")
VScrollBar/styles/grabber_highlight = SubResource("StyleBoxFlat_qy4ym")
VScrollBar/styles/grabber_pressed = SubResource("StyleBoxFlat_qy4ym")
VScrollBar/styles/scroll = SubResource("StyleBoxFlat_doa6c")
VScrollBar/styles/scroll_focus = SubResource("StyleBoxFlat_doa6c")
VSeparator/constants/separation = 16
VSeparator/styles/separator = SubResource("StyleBoxLine_5clyl")
VSplitContainer/constants/autohide = 0
VSplitContainer/constants/minimum_grab_thickness = 12
VSplitContainer/constants/separation = 4

View File

@ -0,0 +1,64 @@
class_name EditorTubeTrackerControl extends Control
## @experimental: This class is used as part of the TubeClientDebugPanel scene and is part of a scene. Should not be used as itself.
@export var tracker_item: EditorTubeTrackerItemControl:
set(x):
show()
if is_instance_valid(messages_container):
if tracker_item != x or not messages_container.is_displaying_from(self):
messages_container.display_messages(
x.message_item_controls,
self
)
if is_instance_valid(url_label):
url_label.text = str(x.tracker)
tracker_item = x
@export var messages_container: EditorTubeMessagesContainer
@onready var url_label: Label = %UrlLabel
@onready var connection_time_label: Label = %ConnectingTimeLabel
@onready var up_time_label: Label = %UpTimeLabel
@onready var interval_time_left_label: Label = %IntervalTimeLeftLabel
@onready var interval_time_label: Label = %IntervalTimeLabel
func _ready() -> void:
hide()
func update_messages():
if null == tracker_item:
return
if is_instance_valid(messages_container):
if messages_container.is_displaying_from(self):
messages_container.display_messages(
tracker_item.message_item_controls,
self
)
func _process(_delta: float) -> void:
if null == tracker_item:
return
connection_time_label.text = str(
tracker_item.tracker.connecting_time
).pad_decimals(3)
up_time_label.text = str(
tracker_item.tracker.up_time
).pad_decimals(3)
interval_time_left_label.text = str(
tracker_item.tracker.interval_time_left
).pad_decimals(3)
interval_time_label.text = str(
tracker_item.tracker.interval_time
).pad_decimals(3)

View File

@ -0,0 +1 @@
uid://cygcobx75tkey

View File

@ -0,0 +1,97 @@
[gd_scene load_steps=3 format=3 uid="uid://ja0u2vuivo8b"]
[ext_resource type="Theme" uid="uid://bcibt73qths3g" path="res://addons/tube/inspector/theme.tres" id="1_noh0l"]
[ext_resource type="Script" uid="uid://cygcobx75tkey" path="res://addons/tube/inspector/tracker_control.gd" id="1_pu2xr"]
[node name="TrackerControl" type="MarginContainer"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme = ExtResource("1_noh0l")
script = ExtResource("1_pu2xr")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 2
[node name="HeaderContainer" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
[node name="UrlLabel" type="Label" parent="VBoxContainer/HeaderContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"LabelH3"
text = "00000000000000000000"
text_overrun_behavior = 1
[node name="ConnectingTimeContainer" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
[node name="Label" type="Label" parent="VBoxContainer/ConnectingTimeContainer"]
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "Connecting time"
[node name="ConnectingTimeLabel" type="Label" parent="VBoxContainer/ConnectingTimeContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"LabelH2"
text = "0.0"
[node name="SecondLabel" type="Label" parent="VBoxContainer/ConnectingTimeContainer"]
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "s"
[node name="UpTimeContainer" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
[node name="Label" type="Label" parent="VBoxContainer/UpTimeContainer"]
custom_minimum_size = Vector2(146, 0)
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "Up time"
[node name="UpTimeLabel" type="Label" parent="VBoxContainer/UpTimeContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"LabelH2"
text = "0.0"
[node name="SecondLabel" type="Label" parent="VBoxContainer/UpTimeContainer"]
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "s"
[node name="IntervalTimeContainer" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
[node name="Label" type="Label" parent="VBoxContainer/IntervalTimeContainer"]
custom_minimum_size = Vector2(146, 0)
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "Interval time"
[node name="IntervalTimeLeftLabel" type="Label" parent="VBoxContainer/IntervalTimeContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"LabelH2"
text = "0.0"
[node name="SlashLabel" type="Label" parent="VBoxContainer/IntervalTimeContainer"]
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "/"
[node name="IntervalTimeLabel" type="Label" parent="VBoxContainer/IntervalTimeContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"LabelH2"
text = "0.0"
[node name="SecondLabel" type="Label" parent="VBoxContainer/IntervalTimeContainer"]
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "s"

View File

@ -0,0 +1,186 @@
class_name EditorTubeTrackerItemControl extends Control
## @experimental: This class is used as part of the TubeClientDebugPanel scene and is part of a scene. Should not be used as itself.
signal pressed
const MESSAGE_ITEM_CONTROL_SCENE := preload("uid://cfsei3airwx4s")
const STATE_COLOR_DEFAULT := Color.WHITE
const STATE_COLOR := {
WebSocketPeer.STATE_CONNECTING: Color.CYAN,
WebSocketPeer.STATE_OPEN: Color.PALE_GREEN,
WebSocketPeer.STATE_CLOSING: Color.GOLDENROD,
WebSocketPeer.STATE_CLOSED: Color.CRIMSON,
}
const STATE_TEXT_DEFAULT := "Unknown"
const STATE_TEXT := {
WebSocketPeer.STATE_CONNECTING: "Connecting",
WebSocketPeer.STATE_OPEN: "Open",
WebSocketPeer.STATE_CLOSING: "Closing",
WebSocketPeer.STATE_CLOSED: "Closed",
}
@export var tracker_control: EditorTubeTrackerControl
@export var max_messages_amount: int = 100
var tracker: TubeTracker:
set(x):
if null != tracker:
tracker.warning_raised.disconnect(
_on_tracker_warning_raised
)
tracker.connected.disconnect(
_on_tracker_connected
)
tracker.failed.disconnect(
_on_tracker_failed
)
tracker.disconnected.disconnect(
_on_tracker_disconnected
)
tracker.state_changed.disconnect(
_on_tracker_state_changed
)
tracker.data_sent.disconnect(
_on_tracker_data_sent
)
tracker.received_data.disconnect(
_on_tracker_data_received
)
if null != x:
x.warning_raised.connect(
_on_tracker_warning_raised
)
x.connected.connect(
_on_tracker_connected
)
x.failed.connect(
_on_tracker_failed
)
x.disconnected.connect(
_on_tracker_disconnected
)
x.state_changed.connect(
_on_tracker_state_changed
)
x.data_sent.connect(
_on_tracker_data_sent
)
x.received_data.connect(
_on_tracker_data_received
)
tracker = x
update()
var message_item_controls: Array[EditorTubeMessagesItemControl] = []
var message_item_button_group := ButtonGroup.new()
@onready var name_label: Label = %NameLabel
@onready var state_indicator: Control = %StateIndicator
func _ready() -> void:
message_item_button_group.allow_unpress = true
update()
func _on_button_pressed() -> void:
if is_instance_valid(tracker_control):
tracker_control.tracker_item = self
pressed.emit()
func update():
if is_instance_valid(tracker):
if is_instance_valid(name_label):
name_label.text = tracker.socket.get_requested_url()
if is_instance_valid(state_indicator):
state_indicator.modulate = STATE_COLOR[tracker.state]
state_indicator.tooltip_text = STATE_TEXT[tracker.state]
if is_instance_valid(tracker_control):
if self == tracker_control.tracker_item:
tracker_control.update_messages()
else:
if is_instance_valid(name_label):
name_label.text = "Unset"
if is_instance_valid(state_indicator):
state_indicator.modulate = STATE_COLOR_DEFAULT
state_indicator.tooltip_text = STATE_TEXT_DEFAULT
func add_message_item_control(data) -> EditorTubeMessagesItemControl:
if max_messages_amount <= message_item_controls.size():
var item := message_item_controls.pop_front()
item.queue_free()
var message_item_control := MESSAGE_ITEM_CONTROL_SCENE.instantiate()
message_item_controls.append(message_item_control)
message_item_control.data = data
message_item_control.button_group = message_item_button_group
return message_item_control
func _on_tracker_warning_raised(message: String):
add_message_item_control(message).warning()
update()
func _on_tracker_connected():
add_message_item_control("Connected").success()
update()
func _on_tracker_failed():
add_message_item_control(
"Connection failed: {error}".format({
"error": tracker.error_message
})
).error()
update()
func _on_tracker_disconnected():
add_message_item_control("Disconneted")
update()
func _on_tracker_state_changed():
#if WebSocketPeer.STATE_OPEN == tracker.state:
#add_message_item_control("Connection open")
if WebSocketPeer.STATE_CLOSING == tracker.state:
add_message_item_control("Connection closing")
#elif WebSocketPeer.STATE_CLOSED == tracker.state:
#add_message_item_control("Connection closed")
update()
func _on_tracker_data_received(data: Dictionary):
add_message_item_control(data).received()
update()
func _on_tracker_data_sent(data: Dictionary):
add_message_item_control(data).sent()
update()

View File

@ -0,0 +1 @@
uid://c8vrv4ynnxskv

View File

@ -0,0 +1,49 @@
[gd_scene load_steps=4 format=3 uid="uid://bc0iqgoaed12"]
[ext_resource type="Script" uid="uid://c8vrv4ynnxskv" path="res://addons/tube/inspector/tracker_item_control.gd" id="1_2wqop"]
[ext_resource type="Theme" uid="uid://bcibt73qths3g" path="res://addons/tube/inspector/theme.tres" id="1_x2s1b"]
[ext_resource type="ButtonGroup" uid="uid://fko7ise7cj31" path="res://addons/tube/inspector/tracker_peer_item_button_group.tres" id="2_daba7"]
[node name="TrackerControl" type="MarginContainer"]
custom_minimum_size = Vector2(196, 32)
offset_right = 196.0
offset_bottom = 38.0
size_flags_horizontal = 3
theme = ExtResource("1_x2s1b")
script = ExtResource("1_2wqop")
[node name="Button" type="Button" parent="."]
layout_mode = 2
theme_type_variation = &"ButtonFlat"
toggle_mode = true
button_group = ExtResource("2_daba7")
alignment = 0
[node name="MarginContainer" type="MarginContainer" parent="."]
layout_mode = 2
mouse_filter = 2
theme_override_constants/margin_left = 4
theme_override_constants/margin_top = 2
theme_override_constants/margin_right = 4
theme_override_constants/margin_bottom = 2
[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer"]
layout_mode = 2
mouse_filter = 2
[node name="StateIndicator" type="Panel" parent="MarginContainer/HBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(24, 12)
layout_mode = 2
size_flags_vertical = 4
theme_type_variation = &"PanelIndicator"
[node name="NameLabel" type="Label" parent="MarginContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"HeaderMedium"
text = "wss://TRACKER_URL"
text_overrun_behavior = 1
[connection signal="pressed" from="Button" to="." method="_on_button_pressed"]

View File

@ -0,0 +1,4 @@
[gd_resource type="ButtonGroup" format=3 uid="uid://fko7ise7cj31"]
[resource]
resource_local_to_scene = false

View File

@ -0,0 +1,462 @@
@icon("../icons/tube_inspector.svg")
class_name EditorTubeClientPanel extends Control
## @experimental: This class is used as part of the TubeClientDebugPanel scene, and is part of a scene. Should not be used as itself
const TRACKER_ITEM_CONTROL_SCENE := preload("uid://bc0iqgoaed12")
const PEER_ITEM_CONTROL_SCENE := preload("uid://dq2d125kftur6")
const MESSAGE_ITEM_CONTROL_SCENE := preload("uid://cfsei3airwx4s")
const STATE_COLORS := {
TubeClient.State.IDLE: Color.BEIGE,
TubeClient.State.CREATING_SESSION: Color.CYAN,
TubeClient.State.JOINING_SESSION: Color.CYAN,
TubeClient.State.SESSION_CREATED: Color.PALE_GREEN,
TubeClient.State.SESSION_JOINED: Color.PALE_GREEN,
}
const SIGNALING_COLORS := {
false: Color.CRIMSON,
true: Color.PALE_GREEN,
}
@export var client: TubeClient:
set(x):
if client != x:
if null != client:
client.error_raised.disconnect(
_on_client_error_raised
)
client.session_created.disconnect(
_on_client_session_created
)
client.session_joined.disconnect(
_on_client_session_joined
)
client.session_left.disconnect(
_on_client_session_left
)
client.peer_refused.disconnect(
_on_client_peer_refused
)
client.peer_connected.disconnect(
_on_client_peer_connected
)
client.peer_disconnected.disconnect(
_on_client_peer_disconnected
)
client.peer_unstabilized.disconnect(
_on_client_peer_unstabilized
)
client.peer_stabilized.disconnect(
_on_client_peer_stabilized
)
client._session_initiated.disconnect(
_on_client_session_initiated
)
client._local_signaling_peer_initiated.disconnect(
_on_client_local_signaling_initiated
)
client._tracker_initiated.disconnect(
_on_client_tracker_initiated
)
client._peer_initiated.disconnect(
_on_client_peer_initiated
)
client._upnp.port_mapped.disconnect(
_on_client_port_mapped
)
client._upnp.warning_raised.disconnect(
_on_client_upnp_warning_raised
)
if null != x:
x.error_raised.connect(
_on_client_error_raised
)
x.session_created.connect(
_on_client_session_created
)
x.session_joined.connect(
_on_client_session_joined
)
x.session_left.connect(
_on_client_session_left
)
x.peer_refused.connect(
_on_client_peer_refused
)
x.peer_connected.connect(
_on_client_peer_connected
)
x.peer_disconnected.connect(
_on_client_peer_disconnected
)
x.peer_unstabilized.connect(
_on_client_peer_unstabilized
)
x.peer_stabilized.connect(
_on_client_peer_stabilized
)
x._session_initiated.connect(
_on_client_session_initiated
)
x._local_signaling_peer_initiated.connect(
_on_client_local_signaling_initiated
)
x._tracker_initiated.connect(
_on_client_tracker_initiated
)
x._peer_initiated.connect(
_on_client_peer_initiated
)
x._upnp.port_mapped.connect(
_on_client_port_mapped
)
x._upnp.warning_raised.connect(
_on_client_upnp_warning_raised
)
client = x
if is_instance_valid(client_control):
client_control.client = client
if is_instance_valid(peer_control):
peer_control.client = client
update()
## Maximum of messages available, the oldest message will be removed when a new message is pushed. It is to prevent memory leak.
## [br][br]
## The amount is by item, meaning is max_messages_amount is set to 100. Client can store 100 messages, each trackers 100 messages, each peers 100 messages...
@export var max_messages_amount: int = 100
var message_item_controls: Array[EditorTubeMessagesItemControl] = []
var message_item_button_group := ButtonGroup.new()
@onready var peer_label: Label = %PeerLabel
@onready var session_line_edit: LineEdit = %SessionLineEdit
@onready var session_state_indicator: Control = %SessionIndicator
@onready var join_button: Button = %JoinButton
@onready var create_button: Button = %CreateButton
@onready var refuse_new_button: Button = %RefuseNewButton
@onready var close_button: Button = %CloseButton
@onready var local_signaling_indicator: Control = %LocalSignalingIndicator
@onready var trackers_indicator: Control = %TrackersIndicator
@onready var trackers_container: Container = %TrackersContainer
@onready var peers_container: Container = %PeersContainer
@onready var client_control: Control = %ClientControl
@onready var local_signaling_control: EditorTubeLocalSignalingControl = %LocalSignalingControl
@onready var tracker_control: EditorTubeTrackerControl = %TrackerControl
@onready var peer_control: EditorTubePeerControl = %PeerControl
@onready var chat_control: Control = %ChatControl
@onready var messages_container: EditorTubeMessagesContainer = %MessagesContainer
func _ready() -> void:
messages_container.max_messages_amount = max_messages_amount
chat_control.max_messages_amount = max_messages_amount
local_signaling_control.max_messages_amount = max_messages_amount
client_control.show()
message_item_button_group.allow_unpress = true
switch_to_idle_config()
messages_container.display_messages([], self)
client = client
update()
func clear():
for child in trackers_container.get_children():
trackers_container.remove_child(child)
child.queue_free()
for child in peers_container.get_children():
peers_container.remove_child(child)
child.queue_free()
message_item_controls.clear()
messages_container.display_messages(
message_item_controls,
self
)
update()
func switch_to_idle_config():
session_state_indicator.modulate = STATE_COLORS[TubeClient.State.IDLE]
local_signaling_indicator.modulate = session_state_indicator.modulate
trackers_indicator.modulate = session_state_indicator.modulate
session_line_edit.editable = true
#session_line_edit.clear()
join_button.visible = true
create_button.visible = true
refuse_new_button.visible = false
close_button.visible = false
func switch_to_joined_config():
session_line_edit.editable = false
join_button.visible = false
create_button.visible = false
close_button.visible = true
func switch_to_created_config():
session_line_edit.editable = false
join_button.visible = false
create_button.visible = false
refuse_new_button.visible = true
close_button.visible = true
func update():
if not is_instance_valid(client):
return
if is_instance_valid(session_state_indicator):
session_state_indicator.modulate = STATE_COLORS[client.state]
if is_instance_valid(local_signaling_indicator):
local_signaling_indicator.modulate = SIGNALING_COLORS[client._is_local_signaling()]
if TubeClient.State.IDLE == client.state:
local_signaling_indicator.modulate = STATE_COLORS[client.state]
if is_instance_valid(trackers_indicator):
trackers_indicator.modulate = SIGNALING_COLORS[client._is_online_signaling()]
if TubeClient.State.IDLE == client.state:
trackers_indicator.modulate = STATE_COLORS[client.state]
if is_instance_valid(peer_label):
peer_label.text = EditorTubePeerItemControl.get_peer_string(client.peer_id)
peer_label.modulate = EditorTubePeerItemControl.get_peer_color(client.peer_id)
if is_instance_valid(session_line_edit):
session_line_edit.text = client.session_id
if is_instance_valid(messages_container):
if messages_container.is_displaying_from(self):
messages_container.display_messages(
message_item_controls,
self
)
func add_message_item_control(data) -> EditorTubeMessagesItemControl:
if max_messages_amount <= message_item_controls.size():
var item := message_item_controls.pop_front()
item.queue_free()
var message_item_control := MESSAGE_ITEM_CONTROL_SCENE.instantiate()
message_item_controls.append(message_item_control)
message_item_control.data = data
message_item_control.button_group = message_item_button_group
update()
return message_item_control
func add_tracker(p_tracker: TubeTracker):
var item_control := TRACKER_ITEM_CONTROL_SCENE.instantiate()
trackers_container.add_child(item_control)
#item_control.client = client
item_control.tracker = p_tracker
item_control.tracker_control = tracker_control
item_control.max_messages_amount = max_messages_amount
func add_peer(peer: TubePeer):
for i_control in peers_container.get_children():
if i_control.peer.id == peer.id:
i_control.peer = peer
return
var item_control := PEER_ITEM_CONTROL_SCENE.instantiate()
peers_container.add_child(item_control)
item_control.client = client
item_control.peer = peer
item_control.peer_control = peer_control
item_control.max_messages_amount = max_messages_amount
func _on_header_button_pressed() -> void:
client_control.show()
if not messages_container.is_displaying_from(self):
messages_container.display_messages(
message_item_controls,
self
)
func _on_join_button_pressed() -> void:
if not is_instance_valid(client):
return
var session_id := session_line_edit.text
client.join_session(session_id)
func _on_create_button_pressed() -> void:
if not is_instance_valid(client):
return
client.create_session()
func _on_refuse_new_button_toggled(toggled_on: bool) -> void:
client.refuse_new_connections = toggled_on
func _on_close_button_pressed() -> void:
if not is_instance_valid(client):
return
client.leave_session()
#
func _on_client_error_raised(code: int, message: String):
var item := add_message_item_control(message)
item.error()
match code:
TubeClient.SessionError.CREATE_SESSION_FAILED, TubeClient.SessionError.JOIN_SESSION_FAILED:
switch_to_idle_config()
update()
func _on_client_session_initiated():
clear()
match client.state:
TubeClient.State.JOINING_SESSION:
switch_to_joined_config()
TubeClient.State.CREATING_SESSION:
switch_to_created_config()
TubeClient.State.IDLE:
switch_to_idle_config()
update()
func _on_client_session_created():
switch_to_created_config()
local_signaling_indicator.modulate = SIGNALING_COLORS[true]
trackers_indicator.modulate = SIGNALING_COLORS[true]
add_message_item_control("Create session {id}".format({
"id": client.session_id
})).success()
func _on_client_session_joined():
switch_to_joined_config()
local_signaling_indicator.modulate = SIGNALING_COLORS[true]
trackers_indicator.modulate = SIGNALING_COLORS[true]
add_message_item_control("Join session {id}".format({
"id": client.session_id
})).success()
func _on_client_session_left():
switch_to_idle_config()
add_message_item_control("Leave session {id}".format({
"id": client.session_id
}))
func _on_client_peer_refused(peer_id: int):
add_message_item_control("Peer {peer_id} connection refused".format({
"peer_id": peer_id,
})).warning()
func _on_client_peer_connected(peer_id: int):
var will_add_peer := true
for i_control in peers_container.get_children():
if i_control.peer.id == peer_id:
will_add_peer = false
break
if will_add_peer:
var peer := TubePeer.new(peer_id)
peer.connection_state = WebRTCPeerConnection.STATE_CONNECTED
add_peer(peer)
add_message_item_control("Peer {peer_id} connected".format({
"peer_id": peer_id,
})).success()
func _on_client_peer_disconnected(peer_id: int):
add_message_item_control("Peer {peer_id} disconnected".format({
"peer_id": peer_id,
}))
func _on_client_peer_unstabilized(peer_id: int):
add_message_item_control("Peer {peer_id} unstabilized".format({
"peer_id": peer_id,
})).warning()
func _on_client_peer_stabilized(peer_id: int):
add_message_item_control("Peer {peer_id} stabilized".format({
"peer_id": peer_id,
}))
func _on_client_local_signaling_initiated(local_signaling_peer: TubeLocalSignalingPeer):
local_signaling_control.local_signaling_peer = local_signaling_peer
add_message_item_control("Local signaling on {port} initiated".format({
"port": local_signaling_peer.udp_peer.get_local_port()
}))
update.call_deferred()
func _on_client_tracker_initiated(tracker: TubeTracker):
tracker.failed.connect(update.call_deferred)
tracker.disconnected.connect(update.call_deferred)
add_tracker(tracker)
add_message_item_control("Tracker {tracker} initiated".format({
"tracker": str(tracker)
}))
update.call_deferred()
func _on_client_peer_initiated(peer: TubePeer):
add_peer(peer)
add_message_item_control("Peer {peer_id} initiated".format({
"peer_id": peer.id,
}))
update.call_deferred()
func _on_client_port_mapped(public_port: int, local_port: int):
add_message_item_control("Port {port} mapped to internal port {internal_port}".format({
"port": public_port,
"internal_port": local_port
}))
func _on_client_upnp_warning_raised(message: String):
add_message_item_control("Upnp: " + message).warning()

View File

@ -0,0 +1 @@
uid://c6txv1voyurrl

8
addons/tube/plugin.cfg Normal file
View File

@ -0,0 +1,8 @@
[plugin]
name="Tube"
description="A lightweight Godot addon that helps create simple multiplayer sessions.
One player creates a session and shares the session ID with others through a external channel (WhatsApp, Discord, etc.). The other players can then join and play together. Thats it, no server deployment needed."
author="Koop Myers"
version="1.1"
script="tube_addon.gd"

33
addons/tube/tube_addon.gd Normal file
View File

@ -0,0 +1,33 @@
@tool
extends EditorPlugin
func _enable_plugin() -> void:
# Add autoloads here.
pass
func _disable_plugin() -> void:
# Remove autoloads here.
pass
func _enter_tree() -> void:
add_custom_type(
"TubeContext",
"Resource",
preload("tube_context.gd"),
null
)
add_custom_type(
"TubeClient",
"Node",
preload("tube_client.gd"),
null
)
func _exit_tree() -> void:
remove_custom_type("TubeContext")
remove_custom_type("TubeClient")

View File

@ -0,0 +1 @@
uid://wlh8u3rl6hmh

797
addons/tube/tube_client.gd Normal file
View File

@ -0,0 +1,797 @@
@icon("./icons/tube_client.svg")
class_name TubeClient extends Node
## Node to create or join multiplayer session as simple as possible.
##
## One player creates a session and shares the session ID with others. The other players can then join and play together. Thats it, no server deployment needed.
## [br][br]
## This class will set up all the High-level multiplayer api for [member multiplayer_root_node] Node.
## [br][br]
## [b]Note[/b]: It uses WebRTC for peer connections, as it, it works automatically in HTML5, but require an external GDExtension plugin on other non-HTML5 platforms. Check out the [url=https://github.com/godotengine/webrtc-native/releases]webrtc-native plugin repository[/url] for instructions. No specific error message will appear if WebRTC implementation is missing.
## [br][br]
## When exporting to Android, make sure to enable the [code]INTERNET[/code] permission in the Android export preset before exporting the project or using one-click deploy. Otherwise, network communication of any kind will be blocked by Android.
##
## @tutorial(README): https://github.com/koopmyers/tube
## @tutorial(Demo project): https://github.com/koopmyers/pixelary
## Emitted when a session has been successfully created.
signal session_created
## Emitted when the client has successfully joined a session.
signal session_joined
## Emitted when the client has left the current session. Emitted after calling [method leave_session]. Also emitted on non-sever when the server leaves (closes) the session or the connection is unrecoverable.
signal session_left
## Emitted when a peer connection is refused. Only emitted on server if [member refuse_new_connections] is set to [code]true[/code] while player try to connect to the session.
signal peer_refused(peer_id: int)
## Emitted when a peer successfully joins the session.
## Emitted on all peers for every other peer.
## [br][br]
## When joining a session, it will be emitted for all peers, both server and non-server, already connected to the session.
## This is equivalent to [signal MultiplayerPeer.peer_connected].
signal peer_connected(peer_id: int)
## Emitted when a peer leaves the session or its connection becomes unrecoverable.
## Emitted on all peers for every other peer.
## [br][br]
## This is equivalent to [signal MultiplayerPeer.peer_disconnected].
signal peer_disconnected(peer_id: int)
## Emitted when a peer becomes temporarily unavailable, indicating a lost connection to the server.
## [br][br]
## This condition may represent a temporary network issue. If the connection recovers, [signal peer_stabilized] will be emitted. Otherwise, [signal peer_disconnected] will be emitted later. RPC on transport mode [code]"reliable"[/code] will be received when connection stabilizes again.
## [br][br]
## Emitted on both server and non-server peers. On non-server peers, this signal is only emitted for the server (where [param peer_id] equals 1), in this state, communication with other peers are also unstable.
signal peer_unstabilized(peer_id: int)
## Emitted when the connection to a peer stabilizes after being unstable ([signal peer_unstabilized]). Communication with other peers is now possible again.
## [br][br]
## Emitted on both server and non-server peers. On non-server peers, this signal is only emitted for the server (where [param peer_id] equals 1).
signal peer_stabilized(peer_id: int)
## Emitted when an error occurs during session. [code]message[/code] is a human-readable description of the error.
signal error_raised(code: SessionError, message: String)
signal _session_initiated
signal _local_signaling_peer_initiated(signaling_peer: TubeLocalSignalingPeer)
signal _tracker_initiated(tracker: TubeTracker)
signal _peer_initiated(peer: TubePeer)
enum State {
## No active session. Can only create or join new session in this state.
IDLE,
## Attempting to create a session.
CREATING_SESSION,
## The session has been successfully created. Waiting for other player to join.
SESSION_CREATED,
## Attempting to join a session.
JOINING_SESSION,
## A session has been successfully joined. Connected to server.
SESSION_JOINED,
}
enum SessionError {
## Failed to create a session.
CREATE_SESSION_FAILED,
## Failed to join a session.
JOIN_SESSION_FAILED,
## Failed to kick a peer from the session.
KICK_PEER_FAILED,
## Session signaling failed, only for server. Meaning new players will not be able to join the session. The session is still considerated open as communication will connected peer is still possible.
## [br][br]
## Signaling is composed of local and online signaling.
SIGNALING_FAILED,
## Session online signaling failed, only for server. Meaning new players will not be able to join the via Internet session. The session is still considerated open as communication will connected peer is still possible.
## [br][br]
## Local signaling is not available on Web platform, meaing if online signaling failed on Web platform there is no other way for player to join the session. [signal error_raised] will be emitted once with [enum SessionError.ONLINE_SIGNALING_FAILED] and a second time with [enum SessionError.SIGNALING_FAILED].
ONLINE_SIGNALING_FAILED,
}
const _SERVER_PEER_ID: int = 1
## Session context used to create or join a session.
@export var context: TubeContext
## Timeout (in seconds) before signaling with a peer is considered failed. Will try again util [member peer_signaling_max_attempts] is reached.
@export var peer_signaling_timeout:float = 2.0
## Maximum number of signaling attempts with a peer before failing.
@export var peer_signaling_max_attempts: int = 3
## Root node to which the multiplayer API should attach.
## If null, scene tree's root node will be used.
@export var multiplayer_root_node: Node
## Current state of the session client.
var state := State.IDLE
## The ID of the current session, if any.
var session_id := ""
## The unique ID of this peer in the session.
var peer_id: int
## Whether this peer is acting as the server, creator of a session.
var is_server: bool:
get:
return _SERVER_PEER_ID == peer_id
## Instance of [MultiplayerAPI] used for High-level multiplayer
var multiplayer_api := MultiplayerAPI.create_default_interface()
## Instance of [MultiplayerPeer] used for managing peer connections.
var multiplayer_peer := WebRTCMultiplayerPeer.new()
## Server will refuse new connections if set to [code]true[/code].
var refuse_new_connections: bool = false:
get:
if not is_server:
return false
return refuse_new_connections
set(x):
if not is_server:
push_error("Cannot refuse new connections, not server")
return
refuse_new_connections = x
multiplayer_peer.refuse_new_connections = x
var _local_signaling_peer: TubeLocalSignalingPeer
var _trackers: Array[TubeTracker] = []
var _peers: Dictionary[int, TubePeer] = {}
var _upnp := TubeUPNP.new()
func _raise_error(p_code: int, p_message: String):
printerr(p_message)
error_raised.emit(p_code, p_message)
func _ready() -> void:
var node_path := NodePath()
if is_instance_valid(multiplayer_root_node):
node_path = multiplayer_root_node.get_path()
get_tree().set_multiplayer(multiplayer_api, node_path)
if not multiplayer_api.peer_connected.is_connected(
peer_connected.emit
):
multiplayer_api.peer_connected.connect(
peer_connected.emit
)
multiplayer_api.peer_disconnected.connect(
peer_disconnected.emit
)
# API ###
## Creates a new multiplayer session.
## Emits [signal session_created] if successful, or [signal error_raised] with [code]SessionError.CREATE_SESSION_FAILED[/code] if failed.
func create_session() -> void:
if not is_inside_tree():
_session_initiated.emit()
_raise_error(SessionError.CREATE_SESSION_FAILED, "Session creation failed, client is not inside tree")
return
if State.IDLE != state:
_session_initiated.emit()
_raise_error(SessionError.CREATE_SESSION_FAILED, "Session creation failed, not in idle state")
return
if null == context:
_session_initiated.emit()
_raise_error(SessionError.CREATE_SESSION_FAILED, "Session creation failed, context is missing")
return
if not context.is_valid():
_session_initiated.emit()
_raise_error(SessionError.CREATE_SESSION_FAILED, "Session creation failed, context is invalid")
return
state = State.CREATING_SESSION
session_id = context.generate_session_id()
peer_id = _SERVER_PEER_ID
refuse_new_connections = false
_session_initiated.emit()
var error := multiplayer_peer.create_server()
if error:
_terminate_session()
_raise_error(SessionError.CREATE_SESSION_FAILED, "Session creation failed, cannot create mutiplayer peer server: {error}".format({
"error": error_string(error),
}))
return
multiplayer_api.multiplayer_peer = multiplayer_peer
_initiate_local_signaling()
for url in context.trackers_urls:
_initiate_tracker(url)
if _is_local_signaling() and not _is_online_signaling():
state = State.SESSION_CREATED
session_created.emit()
## Attempts to join a active session [param p_session_id] created by a server.
## Emits [signal session_joined] if successful, or [signal error_raised] with [code]SessionError.JOIN_SESSION_FAILED[/code] if failed.
func join_session(p_session_id: String) -> void:
if not is_inside_tree():
_session_initiated.emit()
_raise_error(SessionError.JOIN_SESSION_FAILED, "Joining session failed, client is not inside tree")
return
if State.IDLE != state:
_session_initiated.emit()
_raise_error(SessionError.JOIN_SESSION_FAILED, "Joining session failed, not in idle state")
return
if null == context:
_session_initiated.emit()
_raise_error(SessionError.JOIN_SESSION_FAILED, "Joining session failed, context is missing")
return
if not context.is_valid():
_session_initiated.emit()
_raise_error(SessionError.JOIN_SESSION_FAILED, "Joining session failed, context is invalid")
return
if not context.is_session_id_valid(p_session_id):
_session_initiated.emit()
_raise_error(SessionError.JOIN_SESSION_FAILED, "Joining session failed, session id invalid '{id}'".format({
"id": p_session_id,
}))
return
state = State.JOINING_SESSION
session_id = p_session_id
peer_id = multiplayer_peer.generate_unique_id()
_session_initiated.emit()
var error := multiplayer_peer.create_client(peer_id)
if error:
_terminate_session()
_raise_error(SessionError.JOIN_SESSION_FAILED, "Joining session failed, cannot create mutiplayer peer client: {error}".format({
"error": error_string(error),
}))
return
multiplayer_api.multiplayer_peer = multiplayer_peer
var peer := _initiate_peer(_SERVER_PEER_ID)
if not peer.valid:
return
_initiate_local_signaling()
for url in context.trackers_urls:
_initiate_tracker(url)
## Attempts to remove a peer [param p_peer_id from the session.
## Emits [signal peer_disconnected] if successful [signal error_raised] with [code]SessionError.KICK_PEER_FAILED[/code] if the operation fails.
func kick_peer(p_peer_id: int) -> void:
if not is_server:
_raise_error(SessionError.KICK_PEER_FAILED, "Kick peer failed, not server")
return
if not _peers.has(p_peer_id):
_raise_error(SessionError.KICK_PEER_FAILED, "Kick peer failed, peer {peer_id}".format({
"peer_id": p_peer_id
}))
return
multiplayer_peer.disconnect_peer(p_peer_id)
## Leaves the current session. Will close the session for all other client if called by server.
func leave_session() -> void:
session_left.emit()
_terminate_session()
# SIGNALING ###
func _terminate_signaling():
if null != _local_signaling_peer:
_local_signaling_peer.close()
_local_signaling_peer = null
for i_tracker in _trackers:
i_tracker.close(
context.get_info_hash(session_id),
context.get_peer_id_hash(peer_id)
)
func _terminate_session():
state = State.IDLE
_upnp.clear_port_mapping()
if null != _local_signaling_peer:
_local_signaling_peer.close()
_local_signaling_peer = null
for i_tracker in _trackers:
i_tracker.close(
context.get_info_hash(session_id),
context.get_peer_id_hash(peer_id)
)
for i_peer: TubePeer in _peers.values():
i_peer.close() # will be clean collected
session_id = ""
multiplayer_peer.close()
func _is_local_signaling() -> bool:
if null == _local_signaling_peer:
return false
return _local_signaling_peer.is_bound()
func _is_online_signaling() -> bool:
return not _trackers.is_empty()
func _initiate_local_signaling() -> void:
if not TubeLocalSignalingPeer.is_local_signaling_available():
return
_local_signaling_peer = TubeLocalSignalingPeer.new()
var error := _local_signaling_peer.bind(
context.app_id,
session_id,
peer_id
)
_local_signaling_peer_initiated.emit(
_local_signaling_peer
)
if error:
_local_signaling_peer = null
return
_local_signaling_peer.received_signaling_data.connect(
_handle_local_signaling_data
)
func _initiate_tracker(p_url: String) -> void:
var tracker := TubeTracker.new()
var error := tracker.connect_to_url(p_url)
_tracker_initiated.emit(tracker)
if error:
return
_trackers.append(tracker)
tracker.connected.connect(
_on_tracker_connected.bind(tracker)
)
tracker.received_answer.connect(
_handle_tracker_answer.bind(tracker)
)
tracker.interval_timeout.connect(
_on_tracker_interval_timeout.bind(tracker)
)
func _on_tracker_connected(p_tracker: TubeTracker):
p_tracker.send_announce(
context.get_info_hash(session_id),
context.get_peer_id_hash(peer_id),
)
if State.CREATING_SESSION == state:
state = State.SESSION_CREATED
session_created.emit()
if is_server:
return
if not _peers.has(_SERVER_PEER_ID):
return
var server_peer := _peers[_SERVER_PEER_ID]
if server_peer.is_signaling_ready():
_send_signaling_data(server_peer, p_tracker)
func _all_trackers_disconnected(): # is_online_signaling false
if State.CREATING_SESSION == state:
if _is_local_signaling():
_raise_error(
SessionError.ONLINE_SIGNALING_FAILED,
"Online signaling failed, cannot connect to any tracker"
)
return
_raise_error(
SessionError.CREATE_SESSION_FAILED,
"Session creation failed, cannot connect to any tracker"
)
_terminate_session()
elif State.SESSION_CREATED == state:
_raise_error(
SessionError.ONLINE_SIGNALING_FAILED,
"Signaling failed, lost all trackers connections"
)
if not _is_local_signaling():
_raise_error(
SessionError.ONLINE_SIGNALING_FAILED,
"Signaling failed, lost all trackers connections"
)
elif State.JOINING_SESSION == state:
if _peers.has(_SERVER_PEER_ID):
var peer = _peers[_SERVER_PEER_ID]
if peer.remote_session_description.is_empty():
_raise_error(
SessionError.JOIN_SESSION_FAILED,
"Joining session failed, cannot connect to any tracker"
)
_terminate_session()
func _handle_local_signaling_data(p_data: Dictionary, p_address: String):
var from_app_id := TubeLocalSignalingPeer.get_app_id_from_signaling_data(p_data)
if from_app_id != context.app_id:
_local_signaling_peer.raise_warning("Received signaling data from other app ID")
return
var from_session_id := TubeLocalSignalingPeer.get_session_id_from_signaling_data(p_data)
if from_session_id != session_id:
_local_signaling_peer.raise_warning("Received signaling data from other session ID")
return
var from_peer_id := TubeLocalSignalingPeer.get_peer_id_from_signaling_data(p_data)
if is_server and from_peer_id == _SERVER_PEER_ID:
_local_signaling_peer.raise_warning("Received signaling data from peer id 1 as server")
return
if not is_server and from_peer_id != _SERVER_PEER_ID:
_local_signaling_peer.raise_warning("Received signaling data from peer {peer_id}, but not as server".format({
"peer_id": from_peer_id
}))
return
if refuse_new_connections:
peer_refused.emit(from_peer_id)
return
var peer: TubePeer = _peers.get(from_peer_id)
if null == peer:
peer = _initiate_peer(from_peer_id)
if not peer.valid:
return
peer.local_address = p_address
if WebRTCPeerConnection.ConnectionState.STATE_CONNECTED == peer.get_connection_state():
peer.raise_warning(
"Receive signaling data but already connected"
)
return
if peer.remote_session_description.is_empty():
var type := TubeLocalSignalingPeer.get_type_from_signaling_data(p_data)
var sdp := TubeLocalSignalingPeer.get_sdp_from_signaling_data(p_data)
if peer.set_remote_description(type, sdp): # error
return
for candidate_data in TubeLocalSignalingPeer.get_ice_candidates_from_signaling_data(p_data):
if not TubeLocalSignalingPeer.is_ice_candidate_data_valid(candidate_data):
peer.raise_warning(
"Cannot add ice candidate, ice data invalid"
)
continue
peer.add_ice_candidate(
TubeLocalSignalingPeer.get_media_from_ice_candidate_data(
candidate_data
),
TubeLocalSignalingPeer.get_index_from_ice_candidate_data(
candidate_data
),
TubeLocalSignalingPeer.get_sdp_from_ice_candidate_data(
candidate_data
)
)
func _handle_tracker_answer(data: Dictionary, p_tracker: TubeTracker):
var from_peer_id_hash := TubeTracker.get_peer_id_hash_from_answer_data(data)
if not context.is_peer_id_hash_valid(from_peer_id_hash):
p_tracker.raise_warning("answer peer id invalid")
return
var from_peer_id := context.get_peer_id(from_peer_id_hash)
if refuse_new_connections:
peer_refused.emit(from_peer_id)
return
var peer: TubePeer = _peers.get(from_peer_id)
if null == peer:
peer = _initiate_peer(from_peer_id)
if not peer.valid:
return
if WebRTCPeerConnection.ConnectionState.STATE_CONNECTED == peer.get_connection_state():
peer.raise_warning(
"Receive signaling data but already connected"
)
return
if peer.remote_session_description.is_empty():
var type := TubeTracker.get_type_from_answer_data(data)
var sdp := TubeTracker.get_sdp_from_answer_data(data)
if peer.set_remote_description(type, sdp): # error
return
for candidate_data in TubeTracker.get_ice_candidates_from_answer_data(data):
if not TubeTracker.is_ice_candidate_data_valid(candidate_data):
peer.raise_warning(
"Cannot add ice candidate, ice data invalid"
)
continue
peer.add_ice_candidate(
TubeTracker.get_media_from_ice_candidate_data(
candidate_data
),
TubeTracker.get_index_from_ice_candidate_data(
candidate_data
),
TubeTracker.get_sdp_from_ice_candidate_data(
candidate_data
)
)
func _on_tracker_interval_timeout(p_tracker: TubeTracker):
p_tracker.send_announce(
context.get_info_hash(session_id),
context.get_peer_id_hash(peer_id),
)
func _send_signaling_data(p_peer: TubePeer, p_tracker: TubeTracker = null):
if _local_signaling_peer:
if is_server:
_local_signaling_peer.send_signaling_data(
p_peer.local_address,
context.app_id,
session_id,
peer_id,
p_peer.id,
p_peer.local_session_description,
p_peer.ice_candidates,
)
else:
_local_signaling_peer.broadcast_signaling_data(
context.app_id,
session_id,
peer_id,
p_peer.id,
p_peer.local_session_description,
p_peer.ice_candidates,
)
var info_hash := context.get_info_hash(session_id)
var peer_id_hash := context.get_peer_id_hash(peer_id)
var to_peer_id_hash := context.get_peer_id_hash(p_peer.id)
var to_trackers = _trackers
if p_tracker:
to_trackers = [p_tracker]
for i_tracker in to_trackers:
if not i_tracker.is_open():
continue
i_tracker.send_answer(
info_hash,
peer_id_hash,
to_peer_id_hash,
p_peer.local_session_description,
p_peer.ice_candidates,
)
# PEER CONNECTION ###
func _initiate_peer(p_peer_id: int) -> TubePeer:
var peer := TubePeer.new(p_peer_id)
peer.signaling_timeout_time = peer_signaling_timeout
peer.signaling_max_attempts = peer_signaling_max_attempts
var error := peer.initialize(
context.get_ice_servers()
)
if error: # error raised with peer.failed
return peer
peer.signaling_readied.connect(
_on_peer_signaling_readied.bind(peer)
)
peer.signaling_timeout.connect(
_on_peer_signaling_timeout.bind(peer)
)
peer.connected.connect(
_on_peer_connected.bind(peer)
)
peer.disconnected.connect(
_on_peer_disconnected.bind(peer)
)
peer.failed.connect(
_on_peer_failed.bind(peer)
)
peer.closed.connect(
_on_peer_closed.bind(peer)
)
peer.port_mapped.connect(
_upnp.add_port_mapping
)
_peers[p_peer_id] = peer
_peer_initiated.emit(peer)
error = multiplayer_peer.add_peer(peer, p_peer_id)
if error:
peer.valid = false
peer.error_message = "cannot add to multiplayer: ".format({
"error": error_string(error)
})
peer.failed.emit()
if not is_server:
error = peer.create_offer()
if error: # error raised with peer.failed
return peer
return peer
func _clean_peer(p_peer: TubePeer):
if multiplayer_peer.has_peer(p_peer.id):
multiplayer_peer.remove_peer(p_peer.id)
for port in p_peer.mapped_ports:
_upnp.delete_port_mapping(port)
#if _peers.has(p_peer.id): # garbage collected
#_peers.erase(p_peer.id)
p_peer.has_joined_session = false
p_peer.close()
func _on_peer_signaling_readied(p_peer: TubePeer):
_send_signaling_data(p_peer)
p_peer.start_connection_attempt()
func _on_peer_signaling_timeout(p_peer: TubePeer):
_send_signaling_data(p_peer)
p_peer.start_connection_attempt()
func _on_peer_connected(p_peer: TubePeer):
if State.IDLE == state:
_clean_peer(p_peer)
return
if State.JOINING_SESSION == state:
state = State.SESSION_JOINED
_terminate_signaling()
session_joined.emit()
if p_peer.has_joined_session:
peer_stabilized.emit(p_peer.id)
p_peer.has_joined_session = true
#peer connected will be emitted by multiplayer
func _on_peer_disconnected(p_peer: TubePeer): # temporary disconnection
if State.IDLE == state:
_clean_peer(p_peer)
return
peer_unstabilized.emit(p_peer.id)
func _on_peer_failed(p_peer: TubePeer):
_clean_peer(p_peer)
if State.IDLE == state:
return
if not is_server:
if State.JOINING_SESSION == state:
_raise_error(
SessionError.JOIN_SESSION_FAILED,
"Joining session failed, peer {peer_id} connection failed: {error}".format({
"peer_id": p_peer.id,
"error": p_peer.error_message
})
)
elif State.SESSION_JOINED == state:
session_left.emit()
_terminate_session()
func _on_peer_closed(p_peer: TubePeer):
_clean_peer(p_peer)
if State.IDLE == state:
return
if not is_server:
if State.SESSION_JOINED == state:
session_left.emit()
_terminate_session()
# PROCESS ###
func _process(delta):
if _upnp:
_upnp._process(delta)
if _local_signaling_peer:
_local_signaling_peer._process(delta)
var tracker_closed := false
var updated_trackers: Array[TubeTracker] = []
for i_tracker in _trackers:
i_tracker._process(delta)
if i_tracker.is_close():
tracker_closed = true
continue
updated_trackers.append(i_tracker)
_trackers = updated_trackers
if tracker_closed and not _is_online_signaling():
_all_trackers_disconnected()
var updated_peers: Dictionary[int, TubePeer] = {}
for i_peer_id in _peers:
var i_peer := _peers[i_peer_id]
i_peer._process(delta)
if WebRTCPeerConnection.STATE_CLOSED == i_peer.connection_state: # don't use is_close to use connection_state
_clean_peer(i_peer)
continue
updated_peers[i_peer_id] = i_peer
_peers = updated_peers

View File

@ -0,0 +1 @@
uid://cy006uvidc4y

152
addons/tube/tube_context.gd Normal file
View File

@ -0,0 +1,152 @@
@icon("./icons/tube_context.svg")
@tool
class_name TubeContext extends Resource
## A resource that holds configuration and helper methods for managing simple multiplayer session.
## Character set to generate app IDs. Contains most printable ASCII characters.
const _APP_ID_CHARACTER_SET := "!#$%&()*+,-./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890:;<=>?@[]^_{|}~"
@export_tool_button("Generate app id", "RandomNumberGenerator") var _generate_app_id_tool_button = (func():
app_id = _get_random_string(15, _APP_ID_CHARACTER_SET)
)
## Application identifier for this multiplayer context.
## Must be exactly 15 ASCII characters long.
@export var app_id: String
## Character set used to generate session IDs.
## Must not be empty and should only contain ASCII characters.
## A larger set reduces the probability of collision. With 62 characters
## (AZ, az, 09), the chance of two random 5-character IDs matching is approximately 1 in 916 million.
## For readability by players, consider removing ambiguous characters (e.g., oO0, ilj1I, z2).
@export_multiline var session_id_characters_set: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890"
## List of tracker server URLs used for session signaling.
@export var trackers_urls: Array[String] = []
## List of STUN server URLs used for WebRTC ICE candidate resolution.
@export var stun_servers_urls: Array[String] = []
## List of TURN servers (optional). Turn server are dictionnary in the form:
## [codeblock]
## {
## "urls": "turn:turn.example.com:3478",
## "username: "my-username",
## "credential": "my-credential",
## }
@export var turn_servers: Array[Dictionary] = []
func _to_string() -> String:
return "AppID: %s | Trackers: %s | STUN: %s" % [app_id, str(trackers_urls), str(stun_servers_urls)]
func _is_ascii(string: String) -> bool:
for char_index in range(string.length()):
if string.unicode_at(char_index) >= 128:
return false
return true
## Checks if the context configuration is valid.
func is_valid() -> bool:
if 0 == session_id_characters_set.length():
printerr("Session ID Character Set is empty")
return false
if not _is_ascii(session_id_characters_set):
printerr("Session ID Character Set can only contain ASCII characters")
return false
if null == app_id or 15 != app_id.length() or not _is_ascii(app_id):
printerr("App id is invalid")
return false
return true
## Returns ICE server configuration dictionary for WebRTC peer connection.
##
## Example:
## [codeblock]
## {
## "iceServers": [
## {
## "urls": [ "stun:stun.example.com:3478" ], # One or more STUN servers.
## },
## {
## "urls": [ "turn:turn.example.com:3478" ], # One or more TURN servers.
## "username": "a_username", # Optional username for the TURN server.
## "credential": "a_password", # Optional password for the TURN server.
## }
## ]
## }
## [/codeblock]
func get_ice_servers() -> Dictionary:
var ice_servers := []
if null != stun_servers_urls:
for url in stun_servers_urls:
ice_servers.append({
"urls": url
})
if null != turn_servers:
for turn_server in turn_servers:
ice_servers.append(turn_server)
if ice_servers.is_empty():
return {}
return {
"iceServers": ice_servers
}
func _get_random_string(p_size: int, character_set: String) -> String:
var rng := RandomNumberGenerator.new()
rng.randomize()
var character_set_length := character_set.length()
var out := ""
for i in range(p_size):
var index := rng.randi()%character_set_length
out += character_set[index]
return out
## Generates a random 5-character session ID.
func generate_session_id() -> String:
return _get_random_string(5, session_id_characters_set)
## Validates if a session ID is correct
func is_session_id_valid(p_session_id: String) -> bool:
return 5 == p_session_id.length()
## Validates if a peer ID hash is numeric and valid.
func is_peer_id_hash_valid(p_peer_id_hash: String) -> bool:
return p_peer_id_hash.is_valid_int()
## Returns the combined "info hash" (app ID and session ID) for tracker usage.
func get_info_hash(p_session_id: String) -> String:
if not is_session_id_valid(p_session_id):
printerr("Invalid session id")
return ""
return app_id + p_session_id
## Converts a integer peer ID hash into an peer ID hash for tracker usage.
func get_peer_id_hash(p_peer_id: int) -> String:
return str(p_peer_id).pad_zeros(20)
## Converts a peer ID hash into an integer peer ID.
func get_peer_id(p_peer_id_hash: String) -> int:
if not is_peer_id_hash_valid(p_peer_id_hash):
return 0
return int(p_peer_id_hash)

View File

@ -0,0 +1 @@
uid://t4pe7yqc3pnt

View File

@ -0,0 +1,302 @@
[gd_scene load_steps=11 format=3 uid="uid://dujkendqt6ls1"]
[ext_resource type="Theme" uid="uid://bcibt73qths3g" path="res://addons/tube/inspector/theme.tres" id="1_xsva5"]
[ext_resource type="Script" uid="uid://c6txv1voyurrl" path="res://addons/tube/inspector/tube_inspector.gd" id="2_6ede3"]
[ext_resource type="ButtonGroup" uid="uid://fko7ise7cj31" path="res://addons/tube/inspector/tracker_peer_item_button_group.tres" id="3_v58fy"]
[ext_resource type="PackedScene" uid="uid://c3p410vwblsb3" path="res://addons/tube/inspector/client_control.tscn" id="4_6ede3"]
[ext_resource type="PackedScene" uid="uid://dyfuyauko76jj" path="res://addons/tube/inspector/chat_control.tscn" id="4_geh7p"]
[ext_resource type="PackedScene" uid="uid://ja0u2vuivo8b" path="res://addons/tube/inspector/tracker_control.tscn" id="5_2rkog"]
[ext_resource type="PackedScene" uid="uid://5f8u55hvqq4w" path="res://addons/tube/inspector/local_signaling_control.tscn" id="5_v58fy"]
[ext_resource type="PackedScene" uid="uid://ckrifxh4o768d" path="res://addons/tube/inspector/peer_control.tscn" id="6_xyrfc"]
[ext_resource type="PackedScene" uid="uid://btfc8o5xfs14w" path="res://addons/tube/inspector/messages_container.tscn" id="7_6bfdn"]
[ext_resource type="PackedScene" uid="uid://bi8vgsoslhvrb" path="res://addons/tube/inspector/message_control.tscn" id="8_pnoa1"]
[node name="TubeInspector" type="PanelContainer"]
offset_right = 524.0
offset_bottom = 565.0
theme = ExtResource("1_xsva5")
script = ExtResource("2_6ede3")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 2
theme_override_constants/separation = 8
[node name="HeaderContainer" type="MarginContainer" parent="VBoxContainer"]
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
[node name="PanelContainer" type="PanelContainer" parent="VBoxContainer/HeaderContainer"]
layout_mode = 2
theme_type_variation = &"PanelH1Container"
[node name="HeaderButton" type="Button" parent="VBoxContainer/HeaderContainer"]
layout_mode = 2
theme_type_variation = &"ButtonFlat"
toggle_mode = true
button_pressed = true
button_group = ExtResource("3_v58fy")
[node name="MarginContainer" type="MarginContainer" parent="VBoxContainer/HeaderContainer"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/margin_left = 16
theme_override_constants/margin_top = 9
theme_override_constants/margin_right = 16
theme_override_constants/margin_bottom = 9
[node name="VContainer" type="VBoxContainer" parent="VBoxContainer/HeaderContainer/MarginContainer"]
layout_mode = 2
size_flags_vertical = 4
mouse_filter = 2
[node name="SessionContainer" type="HBoxContainer" parent="VBoxContainer/HeaderContainer/MarginContainer/VContainer"]
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 2
[node name="Label" type="Label" parent="VBoxContainer/HeaderContainer/MarginContainer/VContainer/SessionContainer"]
custom_minimum_size = Vector2(96, 0)
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "Session"
[node name="SessionLineEdit" type="LineEdit" parent="VBoxContainer/HeaderContainer/MarginContainer/VContainer/SessionContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(128, 40)
layout_mode = 2
size_flags_horizontal = 3
text = "ABCDE"
placeholder_text = "Enter session id"
alignment = 1
[node name="JoinButton" type="Button" parent="VBoxContainer/HeaderContainer/MarginContainer/VContainer/SessionContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(96, 40)
layout_mode = 2
theme_type_variation = &"FlatButton"
text = "JOIN"
[node name="CreateButton" type="Button" parent="VBoxContainer/HeaderContainer/MarginContainer/VContainer/SessionContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(96, 40)
layout_mode = 2
theme_type_variation = &"FlatButton"
text = "CREATE"
[node name="CloseButton" type="Button" parent="VBoxContainer/HeaderContainer/MarginContainer/VContainer/SessionContainer"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(96, 0)
layout_mode = 2
theme_type_variation = &"FlatButton"
text = "QUIT"
[node name="PeerContainer" type="HBoxContainer" parent="VBoxContainer/HeaderContainer/MarginContainer/VContainer"]
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 2
[node name="Label" type="Label" parent="VBoxContainer/HeaderContainer/MarginContainer/VContainer/PeerContainer"]
custom_minimum_size = Vector2(96, 0)
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "Peer"
[node name="PeerLabel" type="Label" parent="VBoxContainer/HeaderContainer/MarginContainer/VContainer/PeerContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(208, 0)
layout_mode = 2
size_flags_vertical = 1
theme_type_variation = &"HeaderLarge"
text = "0000000000000000000"
[node name="SessionIndicator" type="Panel" parent="VBoxContainer/HeaderContainer/MarginContainer/VContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(4, 4)
layout_mode = 2
mouse_filter = 2
theme_type_variation = &"PanelIndicator"
[node name="VSplitContainer" type="VSplitContainer" parent="VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
[node name="SocketsContainer" type="HSplitContainer" parent="VBoxContainer/VSplitContainer"]
custom_minimum_size = Vector2(0, 160)
layout_mode = 2
size_flags_vertical = 3
size_flags_stretch_ratio = 0.35
[node name="TrackersPanelContainer" type="PanelContainer" parent="VBoxContainer/VSplitContainer/SocketsContainer"]
custom_minimum_size = Vector2(0, 128)
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"PanelH1Container"
[node name="TrackersPanel" type="VBoxContainer" parent="VBoxContainer/VSplitContainer/SocketsContainer/TrackersPanelContainer"]
layout_mode = 2
[node name="LocalSignalingContainer" type="MarginContainer" parent="VBoxContainer/VSplitContainer/SocketsContainer/TrackersPanelContainer/TrackersPanel"]
layout_mode = 2
[node name="LocalSignalingButton" type="Button" parent="VBoxContainer/VSplitContainer/SocketsContainer/TrackersPanelContainer/TrackersPanel/LocalSignalingContainer"]
custom_minimum_size = Vector2(100, 30)
layout_mode = 2
theme_type_variation = &"ButtonFlat"
toggle_mode = true
button_group = ExtResource("3_v58fy")
[node name="Container" type="HBoxContainer" parent="VBoxContainer/VSplitContainer/SocketsContainer/TrackersPanelContainer/TrackersPanel/LocalSignalingContainer"]
layout_mode = 2
mouse_filter = 2
[node name="LocalSignalingIndicator" type="Panel" parent="VBoxContainer/VSplitContainer/SocketsContainer/TrackersPanelContainer/TrackersPanel/LocalSignalingContainer/Container"]
unique_name_in_owner = true
custom_minimum_size = Vector2(16, 8)
layout_mode = 2
size_flags_horizontal = 4
size_flags_vertical = 4
mouse_filter = 2
theme_type_variation = &"PanelIndicator"
[node name="Label" type="Label" parent="VBoxContainer/VSplitContainer/SocketsContainer/TrackersPanelContainer/TrackersPanel/LocalSignalingContainer/Container"]
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "Local signaling"
[node name="TrackerHeader" type="HBoxContainer" parent="VBoxContainer/VSplitContainer/SocketsContainer/TrackersPanelContainer/TrackersPanel"]
layout_mode = 2
[node name="TrackersIndicator" type="Panel" parent="VBoxContainer/VSplitContainer/SocketsContainer/TrackersPanelContainer/TrackersPanel/TrackerHeader"]
unique_name_in_owner = true
custom_minimum_size = Vector2(16, 8)
layout_mode = 2
size_flags_horizontal = 4
size_flags_vertical = 4
theme_type_variation = &"PanelIndicator"
[node name="Label" type="Label" parent="VBoxContainer/VSplitContainer/SocketsContainer/TrackersPanelContainer/TrackersPanel/TrackerHeader"]
layout_mode = 2
theme_type_variation = &"LabelH3"
text = "Online signaling - trackers"
[node name="TrackersContainer" type="VBoxContainer" parent="VBoxContainer/VSplitContainer/SocketsContainer/TrackersPanelContainer/TrackersPanel"]
unique_name_in_owner = true
layout_mode = 2
[node name="PeersPanelContainer" type="PanelContainer" parent="VBoxContainer/VSplitContainer/SocketsContainer"]
custom_minimum_size = Vector2(128, 0)
layout_mode = 2
size_flags_horizontal = 3
theme_type_variation = &"PanelH1Container"
[node name="VBoxContainer" type="VBoxContainer" parent="VBoxContainer/VSplitContainer/SocketsContainer/PeersPanelContainer"]
layout_mode = 2
[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/VSplitContainer/SocketsContainer/PeersPanelContainer/VBoxContainer"]
layout_mode = 2
[node name="Label" type="Label" parent="VBoxContainer/VSplitContainer/SocketsContainer/PeersPanelContainer/VBoxContainer/HBoxContainer"]
layout_mode = 2
size_flags_horizontal = 2
theme_type_variation = &"LabelH3"
text = "Peers"
[node name="RefuseNewButton" type="Button" parent="VBoxContainer/VSplitContainer/SocketsContainer/PeersPanelContainer/VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(144, 0)
layout_mode = 2
theme_type_variation = &"FlatButton"
toggle_mode = true
text = "REFUSE NEW"
[node name="ChatButton" type="Button" parent="VBoxContainer/VSplitContainer/SocketsContainer/PeersPanelContainer/VBoxContainer/HBoxContainer"]
custom_minimum_size = Vector2(100, 30)
layout_mode = 2
theme_type_variation = &"ButtonFlat"
toggle_mode = true
button_group = ExtResource("3_v58fy")
text = "Chat"
[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer/VSplitContainer/SocketsContainer/PeersPanelContainer/VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
follow_focus = true
horizontal_scroll_mode = 0
[node name="PeersContainer" type="VBoxContainer" parent="VBoxContainer/VSplitContainer/SocketsContainer/PeersPanelContainer/VBoxContainer/ScrollContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
[node name="VSplitContainer" type="VSplitContainer" parent="VBoxContainer/VSplitContainer"]
layout_mode = 2
size_flags_vertical = 3
[node name="PanelContainer" type="PanelContainer" parent="VBoxContainer/VSplitContainer/VSplitContainer"]
layout_mode = 2
size_flags_vertical = 3
theme_type_variation = &"PanelH1Container"
[node name="VBoxContainer" type="VBoxContainer" parent="VBoxContainer/VSplitContainer/VSplitContainer/PanelContainer"]
layout_mode = 2
size_flags_vertical = 3
[node name="TabContainer" type="TabContainer" parent="VBoxContainer/VSplitContainer/VSplitContainer/PanelContainer/VBoxContainer"]
layout_mode = 2
current_tab = 0
tabs_visible = false
[node name="ClientControl" parent="VBoxContainer/VSplitContainer/VSplitContainer/PanelContainer/VBoxContainer/TabContainer" node_paths=PackedStringArray("inspector") instance=ExtResource("4_6ede3")]
unique_name_in_owner = true
layout_mode = 2
inspector = NodePath("../../../../../../..")
[node name="LocalSignalingControl" parent="VBoxContainer/VSplitContainer/VSplitContainer/PanelContainer/VBoxContainer/TabContainer" node_paths=PackedStringArray("messages_container") instance=ExtResource("5_v58fy")]
unique_name_in_owner = true
visible = false
layout_mode = 2
messages_container = NodePath("../../MessagesContainer")
metadata/_tab_index = 1
[node name="TrackerControl" parent="VBoxContainer/VSplitContainer/VSplitContainer/PanelContainer/VBoxContainer/TabContainer" node_paths=PackedStringArray("messages_container") instance=ExtResource("5_2rkog")]
unique_name_in_owner = true
visible = false
layout_mode = 2
messages_container = NodePath("../../MessagesContainer")
metadata/_tab_index = 2
[node name="PeerControl" parent="VBoxContainer/VSplitContainer/VSplitContainer/PanelContainer/VBoxContainer/TabContainer" node_paths=PackedStringArray("messages_container") instance=ExtResource("6_xyrfc")]
unique_name_in_owner = true
visible = false
layout_mode = 2
messages_container = NodePath("../../MessagesContainer")
metadata/_tab_index = 3
[node name="ChatControl" parent="VBoxContainer/VSplitContainer/VSplitContainer/PanelContainer/VBoxContainer/TabContainer" node_paths=PackedStringArray("messages_container") instance=ExtResource("4_geh7p")]
unique_name_in_owner = true
visible = false
layout_mode = 2
messages_container = NodePath("../../MessagesContainer")
metadata/_tab_index = 4
[node name="MessagesContainer" parent="VBoxContainer/VSplitContainer/VSplitContainer/PanelContainer/VBoxContainer" node_paths=PackedStringArray("message_control") instance=ExtResource("7_6bfdn")]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
message_control = NodePath("../../../MessageControl")
[node name="MessageControl" parent="VBoxContainer/VSplitContainer/VSplitContainer" instance=ExtResource("8_pnoa1")]
custom_minimum_size = Vector2(0, 96)
layout_mode = 2
size_flags_vertical = 3
size_flags_stretch_ratio = 0.7
[connection signal="pressed" from="VBoxContainer/HeaderContainer/HeaderButton" to="." method="_on_header_button_pressed"]
[connection signal="pressed" from="VBoxContainer/HeaderContainer/MarginContainer/VContainer/SessionContainer/JoinButton" to="." method="_on_join_button_pressed"]
[connection signal="pressed" from="VBoxContainer/HeaderContainer/MarginContainer/VContainer/SessionContainer/CreateButton" to="." method="_on_create_button_pressed"]
[connection signal="pressed" from="VBoxContainer/HeaderContainer/MarginContainer/VContainer/SessionContainer/CloseButton" to="." method="_on_close_button_pressed"]
[connection signal="pressed" from="VBoxContainer/VSplitContainer/SocketsContainer/TrackersPanelContainer/TrackersPanel/LocalSignalingContainer/LocalSignalingButton" to="VBoxContainer/VSplitContainer/VSplitContainer/PanelContainer/VBoxContainer/TabContainer/LocalSignalingControl" method="show"]
[connection signal="toggled" from="VBoxContainer/VSplitContainer/SocketsContainer/PeersPanelContainer/VBoxContainer/HBoxContainer/RefuseNewButton" to="." method="_on_refuse_new_button_toggled"]
[connection signal="pressed" from="VBoxContainer/VSplitContainer/SocketsContainer/PeersPanelContainer/VBoxContainer/HBoxContainer/ChatButton" to="VBoxContainer/VSplitContainer/VSplitContainer/PanelContainer/VBoxContainer/TabContainer/ChatControl" method="show"]

View File

@ -0,0 +1,300 @@
class_name TubeLocalSignalingPeer extends RefCounted
const BROADCAST_ADDRESS := "255.255.255.255"
const MIN_PORT := 49152
const MAX_PORT := 65535
const PORT_RANGE := MAX_PORT - MIN_PORT
signal received_signaling_data(data: Dictionary, address: String)
signal warning_raised(message: String)
signal data_sent(data: Dictionary, address: String, port: int)
signal received_data(data: Variant, address: String, port: int)
var udp_peer := PacketPeerUDP.new()
var port: int
static func is_local_signaling_available() -> bool:
return OS.get_name() != "Web"
func raise_warning(message: String):
push_warning(message)
warning_raised.emit(message)
func bind(
p_app_id: String,
p_session_id: String,
p_peer_id: int
) -> Error:
port = get_port(
p_app_id,
p_session_id,
p_peer_id
)
var error := udp_peer.bind(port)
if error:
raise_warning(
"Cannot set bind to port {port}: {error}".format({
"port": port,
"error": error_string(error)
}))
udp_peer.close()
return error
func is_bound() -> bool:
return udp_peer.is_bound()
func close():
udp_peer.close()
func get_port(
p_app_id: String,
p_session_id: String,
p_peer_id: int
) -> int:
return (p_app_id.hash() + p_session_id.hash() + p_peer_id)%PORT_RANGE + MIN_PORT
## Encodes tracker packet data as JSON string.
func encode_data(data: Dictionary) -> PackedByteArray:
var json := JSON.stringify(data)
return json.to_utf8_buffer()
## Decodes tracker packet data from a [PackedByteArray].
func decode_packet(p_packet: PackedByteArray) -> Variant:
var string := p_packet.get_string_from_utf8()
var data = JSON.parse_string(string)
if null == data:
return string
return data
func broadcast_signaling_data(
p_app_id: String,
p_session_id: String,
p_peer_id: int,
p_to_peer_id: int,
description: Dictionary,
ice_candidates: Array
) -> Error:
var data := {
"app_id": p_app_id,
"session_id": p_session_id,
"peer_id": p_peer_id,
"type": description.type,
"sdp": description.sdp,
"ice_candidates": ice_candidates,
}
var destination_port := get_port(
p_app_id,
p_session_id,
1, # Server peer_id
)
udp_peer.set_broadcast_enabled(true)
var error := udp_peer.set_dest_address(
BROADCAST_ADDRESS,
destination_port
)
if error:
raise_warning(
"Cannot set destination address to {address}: {error}".format({
"address": BROADCAST_ADDRESS,
"error": error_string(error)
}))
return error
error = udp_peer.put_packet(encode_data(data))
if error:
raise_warning(
"Cannot send packet to {address}: {error}".format({
"address": BROADCAST_ADDRESS,
"error": error_string(error)
}))
return error
data_sent.emit(
data,
BROADCAST_ADDRESS,
destination_port
)
return error
func send_signaling_data(
p_address: String,
p_app_id: String,
p_session_id: String,
p_peer_id: int,
p_to_peer_id: int,
description: Dictionary,
ice_candidates: Array
) -> Error:
var data := {
"app_id": p_app_id,
"session_id": p_session_id,
"peer_id": p_peer_id,
"type": description.type,
"sdp": description.sdp,
"ice_candidates": ice_candidates,
}
var destination_port := get_port(
p_app_id,
p_session_id,
p_to_peer_id,
)
var error := udp_peer.set_dest_address(
p_address,
destination_port,
)
if error:
raise_warning(
"Cannot set destination address to {address}: {error}".format({
"address": p_address,
"error": error_string(error)
}))
return error
error = udp_peer.put_packet(encode_data(data))
if error:
raise_warning(
"Cannot send packet to {address}: {error}".format({
"address": p_address,
"error": error_string(error)
}))
return error
data_sent.emit(
data,
p_address,
destination_port
)
return error
func _handle_signaling_data(p_data: Variant):
if not p_data is Dictionary:
raise_warning("signaling data invalid data type")
return
if not p_data.has("app_id"):
raise_warning("signaling data has no app_id")
return
if not p_data.app_id is String:
raise_warning("app_id invalid data type")
return
if not p_data.has("session_id"):
raise_warning("signaling data has no session_id")
return
if not p_data.session_id is String:
raise_warning("session_id invalid data type")
return
if not p_data.has("peer_id"):
raise_warning("signaling data has no peer_id")
return
if not p_data.peer_id is float:
raise_warning("peer_id invalid data type")
return
if not p_data.has("sdp"):
raise_warning("signaling data has no sdp")
return
if not p_data.sdp is String:
raise_warning("sdp invalid data type")
return
if not p_data.has("type"):
raise_warning("signaling data has no type")
return
if not p_data.type is String:
raise_warning("type invalid data type")
return
if not p_data.has("ice_candidates"):
raise_warning("signaling data has no ice_candidates")
return
if not p_data.ice_candidates is Array:
raise_warning("ice_candidates invalid data type")
return
received_signaling_data.emit(
p_data, udp_peer.get_packet_ip()
)
static func get_app_id_from_signaling_data(p_data: Dictionary) -> String:
return p_data.app_id
static func get_session_id_from_signaling_data(p_data: Dictionary) -> String:
return p_data.session_id
static func get_peer_id_from_signaling_data(p_data: Dictionary) -> int:
return int(p_data.peer_id)
static func get_type_from_signaling_data(p_data: Dictionary) -> String:
return p_data.type
static func get_sdp_from_signaling_data(p_data: Dictionary) -> String:
return p_data.sdp
static func get_ice_candidates_from_signaling_data(p_data: Dictionary) -> Array:
return p_data.ice_candidates
static func is_ice_candidate_data_valid(p_data: Variant) -> bool:
return TubeTracker.is_ice_candidate_data_valid(p_data)
static func get_media_from_ice_candidate_data(p_data: Dictionary) -> String:
return TubeTracker.get_media_from_ice_candidate_data(p_data)
static func get_index_from_ice_candidate_data(p_data: Dictionary) -> int:
return TubeTracker.get_index_from_ice_candidate_data(p_data)
static func get_sdp_from_ice_candidate_data(p_data: Dictionary) -> String:
return TubeTracker.get_sdp_from_ice_candidate_data(p_data)
func _process(delta):
while 0 < udp_peer.get_available_packet_count():
var data = decode_packet(udp_peer.get_packet())
received_data.emit(
data,
udp_peer.get_packet_ip(),
udp_peer.get_packet_port()
)
_handle_signaling_data(data)

View File

@ -0,0 +1 @@
uid://dbkvketmx6gxb

View File

@ -0,0 +1,168 @@
class_name TubeNetworkDiagnosisPeer extends RefCounted
signal warning_raised(message: String)
signal nat_hole_punching_compliance_updated(compliance: Compliance)
const WAITING_TIME := 10.0 #sec
const STUN_BINDING_REQUEST := 0x0001
const STUN_MAGIC_COOKIE := 0x2112A442
# STUN attribute types
const ATTR_XOR_MAPPED_ADDRESS := 0x0020
const ATTR_MAPPED_ADDRESS := 0x0001
#const ATTR_USERNAME := 0x0006
#const ATTR_MESSAGE_INTEGRITY := 0x0008
#const ATTR_ERROR_CODE := 0x0009
#const ATTR_UNKNOWN_ATTRIBUTES := 0x000A
#const ATTR_PRIORITY := 0x0024
#const ATTR_USE_CANDIDATE := 0x0025
#const ATTR_ICE_CONTROLLED := 0x8029
#const ATTR_ICE_CONTROLLING := 0x802A
enum Compliance {UNKNOWN, YES, NO}
var peer := PacketPeerUDP.new()
var _binding_port: int
var _binding_time: float = 0.0
var public_ports: Dictionary[String, int] = {} # destionnation address:port, public port
func _u16_to_bytes(v) -> PackedByteArray:
return PackedByteArray([v>>8 & 0xFF, v & 0xFF])
func _u32_to_bytes(v) -> PackedByteArray:
return PackedByteArray([v>>24 & 0xFF, v>>16 & 0xFF, v>>8 & 0xFF, v & 0xFF])
func _bytes_to_u16(bytes: PackedByteArray) -> int:
return (bytes[0] << 8) | bytes[1]
func _init(p_port: int) -> void:
_binding_port = p_port
func raise_warning(message: String):
push_warning(message)
warning_raised.emit(message)
# HOLE PUNCHING
func start_nat_hole_punching_detection(p_urls: Array[String]):
public_ports.clear()
if not peer.is_bound():
var error := peer.bind(_binding_port)
if error:
raise_warning("cannot bind to {port}: {error}".format({
"port": _binding_port,
"error": error_string(error)
}))
return
_binding_time = 0.0
for url in p_urls:
var splited := url.split(":")
var address := splited[1]
var port := int(splited[2])
_send_stunbinding_request(address, port)
func is_nat_hole_punching_compliant() -> Compliance:
var ports := {}
for i_port in public_ports.values():
ports[i_port] = 0
if 1 < len(ports): # multiple ports --> mapping depends on destination
return Compliance.NO
if 1 == len(ports) and 1 < len(public_ports): # one port and multiple destinations --> NAT preserves mapping across destinations
return Compliance.YES
return Compliance.UNKNOWN
func _send_stunbinding_request(p_address: String, p_port: int) -> void:
var error := peer.set_dest_address(p_address, p_port)
if error:
raise_warning("cannot set destionation address {address}:{port}: {error}".format({
"address": p_address,
"port": p_port,
"error": error_string(error)
}))
return
# Build STUN Binding Request packet (20 bytes header)
var packet = PackedByteArray()
packet.resize(0)
packet.append_array(_u16_to_bytes(STUN_BINDING_REQUEST)) # Type
packet.append_array(_u16_to_bytes(0)) # Message length 0 (no attributes)
packet.append_array(_u32_to_bytes(STUN_MAGIC_COOKIE)) # Magic Cookie
for i in 12: # Transaction ID (random)
packet.append(randi() & 0xFF)
error = peer.put_packet(packet)
if error:
raise_warning("cannot put packet to {address}:{port}: {error}".format({
"address": p_address,
"port": p_port,
"error": error_string(error)
}))
return
func parse_stun_response(data: PackedByteArray):
if data.size() < 20:
raise_warning("STUN response too short")
return
var i := 20 #Skip header
while i + 4 <= data.size():
var attr_type := (data[i] << 8) | data[i+1]
var attr_len := (data[i+2] << 8) | data[i+3]
var value := data.slice(i+4, i+4+attr_len)
i += 4 + attr_len
# Attributes are 32-bit aligned: pad to 4 bytes
if attr_len % 4 != 0:
i += 4 - (attr_len % 4)
# Decode port in attributes
var port: int
if attr_type == ATTR_XOR_MAPPED_ADDRESS and attr_len >= 8:
port = ((value[2] << 8) | value[3]) ^ (STUN_MAGIC_COOKIE >> 16)
elif attr_type == ATTR_MAPPED_ADDRESS and attr_len >= 8:
port = (value[2] << 8) | value[3]
else:
continue
var key := "{address}:{port}".format({
"address": peer.get_packet_ip(),
"port": peer.get_packet_port(),
})
public_ports[key] = port
return
# PROCESS
func _process(delta: float) -> void:
_binding_time += delta
while 0 < peer.get_available_packet_count():
var response = peer.get_packet()
parse_stun_response(response)
nat_hole_punching_compliance_updated.emit(is_nat_hole_punching_compliant())
if WAITING_TIME < _binding_time:
peer.close()

View File

@ -0,0 +1 @@
uid://by2koobiifvrh

459
addons/tube/tube_peer.gd Normal file
View File

@ -0,0 +1,459 @@
class_name TubePeer extends WebRTCPeerConnectionExtension
signal signaling_readied
signal signaling_timeout
signal connected
signal disconnected
signal failed
signal closed
signal warning_raised(message: String)
signal connection_state_changed
#signal session_description_created # local
#signal ice_candidate_created # local
signal remote_description_setted
signal ice_candidate_added(ice_candidate: Dictionary) # remote
signal port_mapped(public_port: int, local_port: int)
signal channel_initiated(channel: WebRTCDataChannel)
signal channel_state_changed(channel: WebRTCDataChannel)
class WebRTCSdp extends RefCounted:
var foundation: String
var component: String
var protocol: String
var priority: int
var ip: String
var port: int
var type: String
var related_address: String
var related_port: int
var tcp_type: String
func _init(line: String) -> void:
var parts: Array
if line.begins_with("a=candidate:"):
parts = line.substr(12, line.length()).split(" ")
else:
parts = line.substr(10, line.length()).split(" ")
var related_address: String = ""
var related_port: int = -1
var tcp_type: String = ""
var i := 8
while i < parts.size():
match parts[i]:
"raddr":
related_address = parts[i + 1]
"rport":
related_port = int(parts[i + 1])
"tcptype":
tcp_type = parts[i + 1]
_:
# Unknown extensions are ignored
pass
i += 2
foundation = parts[0]
component = parts[1]
protocol = parts[2].to_lower()
priority = int(parts[3])
ip = parts[4]
port = int(parts[5])
type = parts[7]
related_address = related_address
related_port = related_port
tcp_type = tcp_type
var id: int
var valid := true
var error_message: String
var connection := WebRTCPeerConnection.new()
var connection_state := connection.get_connection_state()
var gathering_state := connection.get_gathering_state()
var signaling_state := connection.get_signaling_state()
var signaling_time: float = -1.0
var signaling_timeout_time: float = 1.0
var signaling_attempts: int = 0
var signaling_max_attempts: int = 3
var connecting_time: float = 0.0
var up_time: float = 0.0
var local_address: String
var local_session_description := {}
var remote_session_description := {}
var ice_candidates: Array[Dictionary] = []
var has_joined_session := false # set by client
var pending_public_ports: Array[int] = []
var local_ports: Array[int] = []
var mapped_ports: Dictionary[int, int] = {} # public to local
var channel_states: Dictionary[WebRTCDataChannel, WebRTCDataChannel.ChannelState] = {}
func _init(p_peer_id: int) -> void:
id = p_peer_id
#client = p_client
connection_state = connection.get_connection_state()
gathering_state = connection.get_gathering_state()
signaling_state = connection.get_signaling_state()
connection.session_description_created.connect(
set_local_description
)
connection.ice_candidate_created.connect(
_on_ice_candidate_created
)
func raise_warning(message: String):
push_warning(message)
warning_raised.emit(message)
func _initialize(p_config: Dictionary) -> Error:
var error := connection.initialize(p_config)
if error:
valid = false
error_message = "cannot initialize peer: {error}".format({
"error": error_string(error)
})
failed.emit()
return error
func _get_connection_state() -> WebRTCPeerConnection.ConnectionState:
var state := connection.get_connection_state()
if state == WebRTCPeerConnection.STATE_DISCONNECTED:
# WebRTC Connection can be temporary disconnected and will automaticaly reconnect quickly. But for godot, disconnected is putting a end to the connection. We don't want that so we tell godot that it is still connected. If does not reconnect, state will be FAILED and handle as real disconnection by Tube and Godot.
# It looks like when using reliable channel, while DISCONNECTED, message will be received on reconnection.
return WebRTCPeerConnection.STATE_CONNECTED
return state
func _get_gathering_state() -> WebRTCPeerConnection.GatheringState:
return connection.get_gathering_state()
func _get_signaling_state() -> WebRTCPeerConnection.SignalingState:
return connection.get_signaling_state()
func _create_data_channel(p_label: String, p_config: Dictionary) -> WebRTCDataChannel:
var channel := connection.create_data_channel(p_label, p_config)
channel_states[channel] = channel.get_ready_state()
channel_initiated.emit(channel)
return channel
func _create_offer() -> Error:
var error = connection.create_offer()
if error:
valid = false
error_message = "cannot create offer: {error}".format({
"error": error_string(error)
})
failed.emit()
return error
func _set_local_description(p_type: String, p_sdp: String):
var error := connection.set_local_description(p_type, p_sdp)
if error:
error_message = "cannot set local description: {error}".format({
"error": error_string(error)
})
failed.emit()
return error
local_session_description = {
"type": p_type,
"sdp": p_sdp,
}
session_description_created.emit()
if is_signaling_ready() and not is_attempting_connection():
match_remaining_ports()
signaling_readied.emit()
return error
func _on_ice_candidate_created(p_media: String, p_index: int, p_sdp: String):
ice_candidates.append({
"media": p_media,
"index": p_index,
"sdp": p_sdp,
})
var sdp_parsed := WebRTCSdp.new(p_sdp)
if "udp" == sdp_parsed.protocol:
if "host" == sdp_parsed.type:
if not local_ports.has(sdp_parsed.port):
local_ports.append(sdp_parsed.port)
elif "srflx" == sdp_parsed.type:
if not mapped_ports.has(sdp_parsed.port):
if not pending_public_ports.has(sdp_parsed.port):
pending_public_ports.append(sdp_parsed.port)
if sdp_parsed.related_port != 0:
mapped_ports[sdp_parsed.port] = sdp_parsed.related_port
port_mapped.emit(sdp_parsed.port, sdp_parsed.related_port)
pending_public_ports.erase(sdp_parsed.port)
match_ports()
ice_candidate_created.emit()
if is_signaling_ready() and not is_attempting_connection():
match_remaining_ports()
signaling_readied.emit()
func _set_remote_description(p_type: String, p_sdp: String) -> Error:
var error := connection.set_remote_description(p_type, p_sdp)
if error:
raise_warning(
"Cannot set remote description: {error}".format({
"error": error_string(error)
}))
return error
remote_session_description = {
"type": p_type,
"sdp": p_sdp,
}
remote_description_setted.emit()
return error
func _add_ice_candidate(media: String, index: int, name: String) -> Error:
var error = connection.add_ice_candidate(
media,
index,
name,
)
if error:
raise_warning(
"Cannot add ice candidate: {error}".format({
"error": error_string(error)
}))
return error
ice_candidate_added.emit({
"media": media,
"index": index,
"name": name,
})
return error
func _poll() -> Error:
return connection.poll()
func _close() -> void:
valid = false
if WebRTCPeerConnection.STATE_CLOSED != connection.get_connection_state():
connection.close()
func is_signaling_ready() -> bool:
if WebRTCPeerConnection.STATE_CONNECTING != connection.get_connection_state(): # already connected, do nothing
return false
#if is_attempting_connection(): # already signaling
#return false
if local_session_description.is_empty():
return false
if ice_candidates.is_empty():
return false
return WebRTCPeerConnection.GATHERING_STATE_COMPLETE == connection.get_gathering_state()
func start_connection_attempt():
if is_attempting_connection(): # already started
return
signaling_time = 0.0
signaling_attempts += 1
func is_attempting_connection():
return 0.0 <= signaling_time
func _signaling_timeout():
if signaling_max_attempts <= signaling_attempts:
stop_connection_attempts()
error_message = "max connection attempts reached"
failed.emit()
return
signaling_time = -1.0
signaling_timeout.emit()
func stop_connection_attempts():
signaling_time = -1.0
func is_peer_connected() -> bool: # is_connected is use for signals
return WebRTCPeerConnection.STATE_CONNECTED == connection_state
func update_connection_state() -> bool: #changed
var previous := connection_state
connection_state = connection.get_connection_state()
return previous != connection_state
func update_gathering_state() -> bool: #changed
var previous := gathering_state
gathering_state = connection.get_gathering_state()
return previous != gathering_state
func update_signaling_state() -> bool: #changed
var previous := signaling_state
signaling_state = connection.get_signaling_state()
return previous != signaling_state
func match_ports():
for port in pending_public_ports:
if local_ports.has(port):
mapped_ports[port] = port
port_mapped.emit(port, port)
for port in mapped_ports:
if pending_public_ports.has(port):
pending_public_ports.erase(port)
func match_remaining_ports():
match_ports()
if local_ports.is_empty():
return
var port := local_ports[0]
for i_port in pending_public_ports:
mapped_ports[i_port] = port
port_mapped.emit(i_port, port)
for i_port in mapped_ports:
if pending_public_ports.has(port):
pending_public_ports.erase(port)
func _connected():
stop_connection_attempts()
connected.emit()
func _disconnected():
stop_connection_attempts()
disconnected.emit()
func _connection_failed():
stop_connection_attempts()
error_message = "connection failed"
failed.emit()
func _connection_closed():
stop_connection_attempts()
closed.emit()
func _process(delta: float):
# State
var connection_changed := update_connection_state()
var gathering_changed := update_gathering_state()
var signaling_changed := update_signaling_state()
if connection_changed or gathering_changed or signaling_changed:
connection_state_changed.emit()
# Channel
for channel in channel_states:
_process_channel(channel)
# Connections
if connection_changed:
if WebRTCPeerConnection.STATE_NEW == connection_state:
pass
if WebRTCPeerConnection.STATE_CONNECTING == connection_state:
pass
if WebRTCPeerConnection.STATE_CONNECTED == connection_state:
_connected()
if WebRTCPeerConnection.STATE_DISCONNECTED == connection_state:
_disconnected()
if WebRTCPeerConnection.STATE_FAILED == connection_state:
_connection_failed()
if WebRTCPeerConnection.STATE_CLOSED == connection_state:
_connection_closed()
if gathering_changed or signaling_changed:
if is_signaling_ready() and not is_attempting_connection():
match_remaining_ports()
signaling_readied.emit()
# Times
if is_attempting_connection():
signaling_time += delta
if signaling_timeout_time < signaling_time:
_signaling_timeout()
if WebRTCPeerConnection.STATE_CONNECTING == connection_state:
connecting_time += delta
if WebRTCPeerConnection.STATE_CONNECTED == connection_state:
up_time += delta
func _process_channel(p_channel: WebRTCDataChannel) -> WebRTCDataChannel.ChannelState:
var current_state:= p_channel.get_ready_state()
var old_state := channel_states[p_channel]
channel_states[p_channel] = current_state
if old_state != current_state:
channel_state_changed.emit(
p_channel
)
return current_state

View File

@ -0,0 +1 @@
uid://pqifv3ichp2f

361
addons/tube/tube_tracker.gd Normal file
View File

@ -0,0 +1,361 @@
class_name TubeTracker extends RefCounted
const MAX_INTERVAL := 120.0 #sec
signal failed
signal connected
signal disconnected
signal received_answer(data: Dictionary)
signal interval_timeout
signal warning_raised(message: String)
signal state_changed
signal data_sent(data: Dictionary)
signal received_data(data: Dictionary)
const CLOSE_CODE_CLIENT: int = 3001
const CLOSE_CODE_FAILED: int = 3002
# https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close, custom code 3000-4999
# https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1
var error_message: String
var socket := WebSocketPeer.new()
var state := socket.get_ready_state()
var connecting_time: float = 0.0 #sec
var up_time: float = 0.0 #sec
var interval_time: float = 0.0 #sec
var interval_time_left: float = -1.0
func _to_string() -> String:
var string := socket.get_requested_url()
if "Web" != OS.get_name():
if WebSocketPeer.STATE_OPEN == socket.get_ready_state():
string += "({protocol}://{address}:{port})".format({
"protocol": socket.get_selected_protocol(),
"address": socket.get_connected_host(),
"port": socket.get_connected_port(),
})
return string
func raise_warning(message: String):
push_warning(message)
warning_raised.emit(message)
func connect_to_url(p_url: String) -> Error:
var error := socket.connect_to_url(p_url)
if error:
error_message = "connection failed: {error}".format({
"error": error_string(error)
})
failed.emit()
return error
func is_open() -> bool:
return WebSocketPeer.STATE_OPEN == socket.get_ready_state()
func is_close() -> bool:
return WebSocketPeer.STATE_CLOSED == socket.get_ready_state()
func close(p_info_hash: String, p_peer_id_hash: String):
if is_open():
send_stop(
p_info_hash,
p_peer_id_hash
)
if not is_close():
socket.close(
CLOSE_CODE_CLIENT,
"Close by client",
)
func _socket_connection_opened():
connected.emit()
func _socket_connection_closed(p_code: int, p_reason: String):
#if -1 == p_code: # error
if WebSocketPeer.State.STATE_CONNECTING == state:
error_message = "connection impossible"
p_reason = p_reason if p_reason else "Closed unexpectedly, code: {code}".format({
"code": p_code,
})
if WebSocketPeer.State.STATE_OPEN == state:
error_message = "connection failed: {reason}".format({
"reason": p_reason,
})
disconnected.emit()
## Encodes tracker packet data as JSON string.
func encode_data(data: Dictionary) -> String:
var json := JSON.stringify(data)
return json
## Decodes tracker packet data from a [PackedByteArray].
func decode_packet(p_packet: PackedByteArray) -> Variant:
var string := p_packet.get_string_from_utf8()
var data = JSON.parse_string(string)
return data
func send_data(p_data: Dictionary) -> Error:
var text := encode_data(p_data)
var error := socket.send_text(
text
)
if error:
raise_warning(
"Cannot send text: {error}".format({
"error": error_string(error)
}))
else:
data_sent.emit(p_data)
return error
func send_announce(p_info_hash: String, p_peer_id_hash: String) -> Error:
return send_data({
"action": "announce",
"info_hash": p_info_hash,
"peer_id": p_peer_id_hash,
"uploaded": 0,
"downloaded": 0,
})
func send_answer(
p_info_hash: String,
p_peer_id_hash: String,
p_to_peer_id_hash: String,
description: Dictionary,
ice_candidates: Array
) -> Error:
return send_data({
"action": "announce",
"info_hash": p_info_hash,
"peer_id": p_peer_id_hash,
"to_peer_id": p_to_peer_id_hash,
"answer": {
"type": description.type,
"sdp": description.sdp,
"ice_candidates": ice_candidates,
},
"offer_id": "0",
})
func send_stop(p_info_hash: String, p_peer_id_hash: String) -> Error:
return send_data({
"action": "announce",
"info_hash": p_info_hash,
"peer_id": p_peer_id_hash,
"event": "stopped"
})
func _received_packet(p_packet: PackedByteArray):
var data = decode_packet(p_packet)
if not data is Dictionary:
raise_warning("Received invalid packet: {packet}".format({
"packet": str(p_packet)
}))
return
received_data.emit(data)
if data.has("answer"):
_handle_answer(data)
return
_handle_announce(data)
func _handle_announce(p_data: Dictionary):
if not p_data.has("interval"):
raise_warning("announce data has no interval")
return
if not p_data.interval is float:
raise_warning("interval invalid data type")
return
interval_time = min(p_data.interval, MAX_INTERVAL)
interval_time_left = interval_time
func _handle_answer(p_data: Dictionary):
if not p_data is Dictionary:
raise_warning("answer data invalid data type")
return
if not p_data.has("peer_id"):
raise_warning("answer data has no peer_id")
return
if not p_data.peer_id is String:
raise_warning("peer_id invalid data type")
return
if not p_data.has("answer"):
raise_warning("answer data has no answer")
return
if not p_data.answer is Dictionary:
raise_warning("answer invalid data type")
return
var answer: Dictionary = p_data.answer
if not answer.has("sdp"):
raise_warning("answer data has no sdp")
if not answer.sdp is String:
raise_warning("sdp invalid data type")
return
if not answer.has("type"):
raise_warning("answer data has no type")
return
if not answer.type is String:
raise_warning("type invalid data type")
return
if not answer.has("ice_candidates"):
raise_warning("answer data has no ice_candidates")
return
if not answer.ice_candidates is Array:
raise_warning("ice_candidates invalid data type")
return
received_answer.emit(p_data)
static func get_peer_id_hash_from_answer_data(p_data: Dictionary) -> String:
return p_data.peer_id
static func get_type_from_answer_data(p_data: Dictionary) -> String:
return p_data.answer.type
static func get_sdp_from_answer_data(p_data: Dictionary) -> String:
return p_data.answer.sdp
static func get_ice_candidates_from_answer_data(p_data: Dictionary) -> Array:
return p_data.answer.ice_candidates
static func is_ice_candidate_data_valid(p_data: Variant) -> bool:
if not p_data is Dictionary:
push_error("Ice candidate data invalid data type")
return false
if not p_data.has("media"):
push_error("Ice candidate data has no media")
return false
if not p_data.media is String:
push_error("media invalid data type")
return false
if not p_data.has("index"):
push_error("Ice candidate has no index")
return false
if not (typeof(p_data.index) in [TYPE_INT, TYPE_FLOAT]):
push_error("index invalid data type")
return false
if not p_data.has("sdp"):
push_error("Ice candidate has no sdp")
return false
if not p_data.sdp is String:
push_error("Ice candidate sdp invalid data type")
return false
return true
static func get_media_from_ice_candidate_data(p_data: Dictionary) -> String:
return p_data.media
static func get_index_from_ice_candidate_data(p_data: Dictionary) -> int:
return int(p_data.index)
static func get_sdp_from_ice_candidate_data(p_data: Dictionary) -> String:
return p_data.sdp
func _process(delta: float):
socket.poll() # push error when 502 bad gateway, doesn't block anything
var old_state := state
state = socket.get_ready_state()
if state != old_state:
state_changed.emit()
if WebSocketPeer.STATE_CONNECTING == state:
connecting_time += delta
if WebSocketPeer.STATE_OPEN == state:
if WebSocketPeer.STATE_OPEN != old_state:
_socket_connection_opened()
while socket.get_available_packet_count():
var packet := socket.get_packet()
_received_packet(packet)
up_time += delta
if 0.0 < interval_time:
interval_time_left -= delta
if interval_time_left < 0.0:
interval_time_left = interval_time
interval_timeout.emit()
elif WebSocketPeer.STATE_CLOSING == state:
# Keep polling to achieve proper close.
pass
elif WebSocketPeer.STATE_CLOSED == state:
var code = socket.get_close_code()
var reason = socket.get_close_reason()
_socket_connection_closed(code, reason)
#
#if WebRTCPeerConnection.STATE_CONNECTED == connection_state:
#

View File

@ -0,0 +1 @@
uid://b81mnu14j3uxw

213
addons/tube/tube_upnp.gd Normal file
View File

@ -0,0 +1,213 @@
class_name TubeUPNP extends RefCounted
signal warning_raised(message: String)
signal port_mapping_ready
signal port_mapped(public_port: int, local_port: int)
const MAPPING_DURATION := 60*2 #sec, 2 minutes
const MAPPING_RENEW_TIME := 0.75*MAPPING_DURATION
var mapped_ports: Dictionary[int, int] = {}
var mapped_times: Dictionary[int, float] = {}
var task_ids: Array[int] = []
var upnp := UPNP.new()
var is_port_mapping_ready := false
var mutex := Mutex.new()
var mapping_queue: Array[Callable] = []
func raise_warning(message: String):
push_warning(message)
warning_raised.emit(message)
func _init() -> void:
if OS.get_name() == "Web":
return
port_mapping_ready.connect(_on_port_mapping_ready)
task_ids.append(WorkerThreadPool.add_task(_upnp_init_task))
func _upnp_init_task() -> void:
var error := upnp.discover()
if error:
raise_warning.call_deferred(
"cannot map port, upnp discover error: {error}".format({
"error": ClassDB.class_get_enum_constants("UPNP", "UPNPResult")[int(error)], # UPNPResult
}))
return
var gateway := upnp.get_gateway()
if null == gateway:
raise_warning.call_deferred(
"cannot map port, no gateway found".format({
}))
return
if not gateway.is_valid_gateway():
raise_warning.call_deferred(
"cannot map port, gateway not valid".format({
}))
return
mutex.lock()
is_port_mapping_ready = true
mutex.unlock()
port_mapping_ready.emit.call_deferred()
func _on_port_mapping_ready():
for callable in mapping_queue:
WorkerThreadPool.add_task(callable, true)
func _process(delta: float):
for port in mapped_times:
if not mapped_ports.has(port):
continue
mapped_times[port] += delta
if MAPPING_RENEW_TIME < mapped_times[port]:
var local_port := mapped_ports[port]
_add_port_mapping(port, local_port)
var tmp_ids := Array(task_ids)
task_ids.clear()
while not tmp_ids.is_empty():
var id := tmp_ids.pop_back()
if WorkerThreadPool.is_task_completed(id):
var error := WorkerThreadPool.wait_for_task_completion(id)
if error:
raise_warning("cannot wait for port mapping task completion: {error}".format({
"error": error_string(error)
}))
else:
task_ids.append(id)
func add_port_mapping(p_public_port: int, p_local_port: int) -> void:
if mapped_ports.has(p_public_port):
return
_add_port_mapping(p_public_port, p_local_port)
func _add_port_mapping(p_public_port: int, p_local_port: int) -> void:
mapped_ports[p_public_port] = p_local_port
mapped_times[p_public_port] = 0.0
var callable := _add_port_mapping_task.bind(
p_public_port,
p_local_port
)
mutex.lock()
if is_port_mapping_ready:
task_ids.append(WorkerThreadPool.add_task(callable, true))
else:
mapping_queue.append(callable)
mutex.unlock()
func _add_port_mapping_task(p_public_port: int, p_local_port: int) -> void:
mutex.lock()
if not is_port_mapping_ready:
raise_warning.call_deferred(
"cannot map port {port} to internal port {internal_port}, upnp not ready".format({
"port": p_public_port,
"internal_port": p_local_port,
}))
return
mutex.unlock()
var result := upnp.add_port_mapping(
p_public_port,
p_local_port,
"Tube", #ProjectSettings.get_setting("application/config/name"),
"UDP",
MAPPING_DURATION
)
if result:
raise_warning.call_deferred(
"cannot map port {port} to internal port {internal_port}, error: {error}".format({
"port": p_public_port,
"internal_port": p_local_port,
"error": ClassDB.class_get_enum_constants("UPNP", "UPNPResult")[int(result)]
}))
else:
port_mapped.emit.call_deferred(
p_public_port,
p_local_port
)
func delete_port_mapping(p_public_port: int):
if not mapped_ports.has(p_public_port):
return
mapped_ports.erase(p_public_port)
mapped_times.erase(p_public_port)
var callable := _delete_port_mapping_task.bind(
p_public_port,
)
mutex.lock()
if is_port_mapping_ready:
task_ids.append(WorkerThreadPool.add_task(callable, true))
else:
mapping_queue.append(callable)
mutex.unlock()
func _delete_port_mapping_task(p_public_port: int) -> void:
mutex.lock()
if not is_port_mapping_ready:
raise_warning.call_deferred(
"cannot delete port mapping {port}, upnp not ready".format({
"port": p_public_port,
}))
return
mutex.unlock()
var result := upnp.delete_port_mapping(p_public_port, "UDP")
if result:
raise_warning.call_deferred(
"cannot delete port mapping {port}, error: {error}".format({
"port": p_public_port,
"error": ClassDB.class_get_enum_constants("UPNP", "UPNPResult")[int(result)]
}))
func clear_port_mapping() -> void:
for port in mapped_ports:
delete_port_mapping(port)
mapped_ports.clear()
mapped_times.clear()
func _notification(what):
if what == NOTIFICATION_PREDELETE:
for port in mapped_ports:
delete_port_mapping(port)
mapped_ports.clear()
mapped_times.clear()
for id in task_ids:
var error := WorkerThreadPool.wait_for_task_completion(id)
if error:
raise_warning("cannot wait for port mapping task completion: {error}".format({
"error": error_string(error)
}))
return
task_ids.clear()

View File

@ -0,0 +1 @@
uid://b7atm4ie65ntq

BIN
assets/Assets/Extra/static_shadow.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b7cma8jcljou7"
path="res://.godot/imported/static_shadow.png-1b98f78cddf6e18349e81ec4c7928421.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/Assets/Extra/static_shadow.png"
dest_files=["res://.godot/imported/static_shadow.png-1b98f78cddf6e18349e81ec4c7928421.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

BIN
assets/Assets/Prototype_Character/prototype_character.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://mp8uhhjx1orc"
path="res://.godot/imported/prototype_character.png-1c49162889488572260123552355c566.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/Assets/Prototype_Character/prototype_character.png"
dest_files=["res://.godot/imported/prototype_character.png-1c49162889488572260123552355c566.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bap7oggpwdvqs"
path="res://.godot/imported/prototype_character_blue.png-8f4797f794496f4660a8a57b622e59e7.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/Assets/Prototype_Character/prototype_character_blue.png"
dest_files=["res://.godot/imported/prototype_character_blue.png-8f4797f794496f4660a8a57b622e59e7.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dy1ls3gbu74pw"
path="res://.godot/imported/prototype_character_green.png-256684b5480019ec9f154ea1a1c40b3d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/Assets/Prototype_Character/prototype_character_green.png"
dest_files=["res://.godot/imported/prototype_character_green.png-256684b5480019ec9f154ea1a1c40b3d.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bnxunneqi1d01"
path="res://.godot/imported/prototype_character_red.png-e2c7157cfaaa27a5548f802a98124527.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/Assets/Prototype_Character/prototype_character_red.png"
dest_files=["res://.godot/imported/prototype_character_red.png-e2c7157cfaaa27a5548f802a98124527.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://lub3bl8xi147"
path="res://.godot/imported/prototype_character_shadow.png-2d3c58479e73f8f95450d52ca424722c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/Assets/Prototype_Character/prototype_character_shadow.png"
dest_files=["res://.godot/imported/prototype_character_shadow.png-2d3c58479e73f8f95450d52ca424722c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

Binary file not shown.

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://rq1mddd0hgj3"
path="res://.godot/imported/prototype_character_yellow.png-f8e7f36765641a155a312967fd98b1ae.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/Assets/Prototype_Character/prototype_character_yellow.png"
dest_files=["res://.godot/imported/prototype_character_yellow.png-f8e7f36765641a155a312967fd98b1ae.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

BIN
assets/Pattern-Panic-10x10/Circles.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dmm6upjnwahu8"
path="res://.godot/imported/Circles.png-10f706c140f293279b5d2fd9f2674218.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/Pattern-Panic-10x10/Circles.png"
dest_files=["res://.godot/imported/Circles.png-10f706c140f293279b5d2fd9f2674218.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

BIN
assets/Pattern-Panic-10x10/Cross-and-Petals.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cmdsg0h4i4n1x"
path="res://.godot/imported/Cross-and-Petals.png-b5d1fee2d129685e87710835b27fb29a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/Pattern-Panic-10x10/Cross-and-Petals.png"
dest_files=["res://.godot/imported/Cross-and-Petals.png-b5d1fee2d129685e87710835b27fb29a.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

BIN
assets/Pattern-Panic-10x10/Misc.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://jjiaybhtopy"
path="res://.godot/imported/Misc.png-13825b07612f9d6c157e76a73f83d8a5.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/Pattern-Panic-10x10/Misc.png"
dest_files=["res://.godot/imported/Misc.png-13825b07612f9d6c157e76a73f83d8a5.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

BIN
assets/Pattern-Panic-10x10/Points-and-Pulses.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://lud2f8x4hx54"
path="res://.godot/imported/Points-and-Pulses.png-1f420e7fc3d1e69127dd375ab2641b2e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/Pattern-Panic-10x10/Points-and-Pulses.png"
dest_files=["res://.godot/imported/Points-and-Pulses.png-1f420e7fc3d1e69127dd375ab2641b2e.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

View File

@ -0,0 +1,16 @@
Pattern Panic
-------------
Enjoy my assets? Keep up to date by following me on itch or twitter.
https://v3x3d.itch.io/
https://twitter.com/_V3X3D
You can also support me on Patreon for just a $1 each month.
Your support helps me keep making assets and provides you rewards.
https://www.patreon.com/V3X3D
-- VEXED

Some files were not shown because too many files have changed in this diff Show More