Initial commit.

This commit is contained in:
Nekojimi 2025-05-31 22:21:22 +01:00
commit 37ce953fb0
180 changed files with 35377 additions and 0 deletions

27
Caddyfile Normal file
View File

@ -0,0 +1,27 @@
{
local_certs
auto_https disable_redirects
ocsp_stapling off
}
:2480 {
bind 0.0.0.0
encode
file_server {
root build/web
index JackIt.html
}
}
31.125.232.239:2443 {
bind 0.0.0.0
encode
file_server {
root build/web
index JackIt.html
}
tls internal {
on_demand
}
}

27
Caddyfile~ Normal file
View File

@ -0,0 +1,27 @@
{
local_certs
auto_https disable_redirects
ocsp_stapling off
}
31.125.232.239:2480 {
bind 0.0.0.0
encode
file_server {
root build/web
index JackIt.html
}
}
31.125.232.239:2443 {
bind 0.0.0.0
encode
file_server {
root build/web
index JackIt.html
}
tls internal {
on_demand
}
}

View File

@ -0,0 +1,34 @@
@tool
extends EditorExportPlugin
var export_path: String = ""
func _get_name() -> String:
return "tnhujftdbnhkjdftrgh"
func _export_begin(features: PackedStringArray, is_debug: bool, path: String, flags: int) -> void:
export_path = path.left(path.rfind("/"))
print("Project is being exported to %s" % export_path)
func _export_end() -> void:
var dir: DirAccess = DirAccess.open(export_path)
if dir:
for file in dir.get_files():
print("Exported file: %s" % file)
if file.ends_with(".js"):
hack_js_file(export_path + "/" + file)
func hack_js_file(file: String) -> void:
print("Checking exported JS file %s" % file)
var file_access: FileAccess = FileAccess.open(file,FileAccess.READ_WRITE)
if file_access == null:
printerr("Failed to open file %s, code: %s" % [file, FileAccess.get_open_error()])
return
var contents: String = file_access.get_as_text()
var regex: RegEx = RegEx.create_from_string("if \\(!Features.isSecureContext\\(\\)\\) \\{(\n[^\n]*){2}")
var match: RegExMatch = regex.search(contents)
if match and match.get_start() != -1:
print("Found target string! Modifying...")
contents = regex.sub(contents, "/* I fucked this file to enable HTTP support, look at me */")
file_access.resize(0)
file_access.store_string(contents)

View File

@ -0,0 +1,7 @@
[plugin]
name="GodotWebHTTPHack"
description=""
author=""
version=""
script="plugin.gd"

View File

@ -0,0 +1,14 @@
@tool
extends EditorPlugin
const ExportPlugin = preload("res://addons/GodotWebHTTPHack/export_plugin.gd")
var export_plugin = ExportPlugin.new()
func _enter_tree() -> void:
# Initialization of the plugin goes here.
add_export_plugin(export_plugin)
func _exit_tree() -> void:
# Clean-up of the plugin goes here.
remove_export_plugin(export_plugin)

8
addons/cables/cables.gd Normal file
View File

@ -0,0 +1,8 @@
@tool
class_name Cables extends EditorPlugin
func _enter_tree():
pass
func _exit_tree():
pass

View File

@ -0,0 +1 @@
uid://2mslx0hvbkff

View File

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
width="800px"
height="800px"
viewBox="0 0 24 24"
id="Layer_1"
data-name="Layer 1"
version="1.1"
sodipodi:docname="cable-icon.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
inkscape:export-filename="..\..\..\cables-addon-logo.png"
inkscape:export-xdpi="122.88"
inkscape:export-ydpi="122.88"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview3"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.10125"
inkscape:cx="400"
inkscape:cy="400"
inkscape:window-width="1920"
inkscape:window-height="1057"
inkscape:window-x="1912"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1" />
<defs
id="defs1">
<style
id="style1">.cls-1{fill:none;stroke:#020202;stroke-miterlimit:10;stroke-width:1.91px;}</style>
</defs>
<path
style="color:#000000;fill:#cccccc;stroke-miterlimit:10;-inkscape-stroke:none"
d="m 15.820313,0.54492187 c -2.625846,10e-9 -4.775391,2.14954523 -4.775391,4.77539063 V 18.679687 c 0,1.593606 -1.2716287,2.865235 -2.8652345,2.865235 -1.5936058,0 -2.8652344,-1.271629 -2.8652344,-2.865235 V 10.089844 H 3.4042969 v 8.589843 c 0,2.625846 2.1495452,4.775391 4.7753906,4.775391 2.6258455,0 4.7753905,-2.149545 4.7753905,-4.775391 V 5.3203125 c 0,-1.5936058 1.271629,-2.8652344 2.865235,-2.8652344 1.593605,0 2.865234,1.2716286 2.865234,2.8652344 v 8.5898435 h 1.910156 V 5.3203125 c 0,-2.6258454 -2.149545,-4.77539062 -4.77539,-4.77539063 z"
id="path3" />
<g
id="g2"
style="fill:#ffff00">
<path
style="color:#000000;fill:#ffff00;stroke-miterlimit:10;-inkscape-stroke:none"
d="M 1.4941406,0.54492187 V 6.2753906 H 7.2246094 V 0.54492187 Z M 3.4042969,2.4550781 H 5.3144531 V 4.3652344 H 3.4042969 Z"
id="rect1" />
<path
style="color:#000000;fill:#ffff00;stroke-miterlimit:10;-inkscape-stroke:none"
d="m 22.494609,23.455391 v -5.730469 h -5.730468 v 5.730469 z m -1.910156,-1.910157 h -1.910156 v -1.910156 h 1.910156 z"
id="rect2" />
</g>
<g
id="g1"
style="fill:#00ffff">
<path
style="color:#000000;fill:#00ffff;stroke-miterlimit:10;-inkscape-stroke:none"
d="m 0.54492187,4.3652344 v 3.8144531 c 10e-9,1.5709826 1.29425173,2.8652345 2.86523443,2.8652345 h 1.9101562 c 1.5709826,0 2.8652344,-1.2942519 2.8652344,-2.8652345 V 4.3652344 Z M 2.4550781,6.2753906 h 3.8203125 v 1.9042969 c 0,0.538743 -0.4163351,0.9550781 -0.9550781,0.9550781 H 3.4101563 c -0.5387431,0 -0.9550782,-0.4163351 -0.9550782,-0.9550781 z"
id="path1" />
<path
style="color:#000000;fill:#00ffff;stroke-miterlimit:10;-inkscape-stroke:none"
d="m 23.455547,19.634922 v -3.814453 c 0,-1.570983 -1.294252,-2.865235 -2.865234,-2.865235 h -1.910157 c -1.570982,0 -2.865234,1.294252 -2.865234,2.865235 v 3.814453 z m -1.910156,-1.910156 h -3.820313 v -1.904297 c 0,-0.538743 0.416335,-0.955078 0.955078,-0.955078 h 1.910157 c 0.538743,0 0.955078,0.416335 0.955078,0.955078 z"
id="path2" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cww3x2h528pv7"
path="res://.godot/imported/cable-icon.svg-f3b569967f2871c2fb436853cfcc9402.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/cables/icons/cable-icon.svg"
dest_files=["res://.godot/imported/cable-icon.svg-f3b569967f2871c2fb436853cfcc9402.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
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/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,52 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
fill="#000000"
width="512"
height="512"
viewBox="0 0 327.68 327.68"
id="_04_In_alt_"
data-name="04 In (alt)"
version="1.1"
sodipodi:docname="consumer-icon.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.10125"
inkscape:cx="307.37798"
inkscape:cy="288.30874"
inkscape:window-width="1920"
inkscape:window-height="1057"
inkscape:window-x="1912"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="_04_In_alt_" />
<path
id="Path_6"
data-name="Path 6"
d="M 279.56389,10.91285 H 145.75967 a 19.11489,19.11489 0 0 0 0,38.229777 H 260.449 V 278.52129 H 145.75967 a 19.114889,19.114889 0 0 0 0,38.22977 h 133.80422 a 19.109513,19.109513 0 0 0 19.11489,-19.11489 V 30.027739 A 19.109513,19.109513 0 0 0 279.56389,10.91285 Z"
fill-rule="evenodd"
style="fill:#f9f9f9;stroke-width:0.597341" />
<path
id="Path_7"
style="fill:#f9f9f9;stroke-width:0.815834"
d="M 106.54875 82.9175 L 106.54875 142.50125 L 54.62875 142.50125 L 54.62875 182.22375 L 106.54875 182.22375 L 106.54875 241.80625 L 243.815 162.3625 L 106.54875 82.9175 z " />
<path
id="rect1"
style="fill:#f9f9f9;stroke-width:0.515543"
d="m 32.417935,142.5359 c -17.182295,0 -31.085835,8.89302 -31.085835,19.86125 0,10.96823 13.90354,19.86125 31.085835,19.86125 h 53.76273 v -39.7225 z" />
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cdeod6hfxbyry"
path="res://.godot/imported/consumer-icon.svg-92a77bb7120fb045c57bf86e818fd775.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/cables/icons/consumer-icon.svg"
dest_files=["res://.godot/imported/consumer-icon.svg-92a77bb7120fb045c57bf86e818fd775.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
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/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,48 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
fill="#000000"
width="512"
height="512"
viewBox="0 0 327.68 327.68"
id="_04_In_alt_"
data-name="04 In (alt)"
version="1.1"
sodipodi:docname="producer-icon.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.10125"
inkscape:cx="163.90465"
inkscape:cy="366.40182"
inkscape:window-width="1920"
inkscape:window-height="1057"
inkscape:window-x="1912"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="_04_In_alt_" />
<path
id="Path_6"
data-name="Path 6"
d="m 290.16776,4.5201155 h -137.1755 a 19.596501,19.596501 0 0 0 0,39.1930015 h 117.579 V 278.87111 h -117.579 a 19.596499,19.596499 0 0 0 0,39.19299 h 137.1755 a 19.590988,19.590988 0 0 0 19.59649,-19.5965 V 24.116615 A 19.590988,19.590988 0 0 0 290.16776,4.5201155 Z"
fill-rule="evenodd"
style="fill:#f9f9f9;stroke-width:0.61239" />
<path
id="Path_7"
style="fill:#f9f9f9;stroke-width:0.818193"
d="M 156.68374,70.937376 40.10012,165.01569 156.68374,259.09401 v -70.55874 h 114.7164 v -47.03916 h -114.7164 z" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b1cwxnbxhl3v5"
path="res://.godot/imported/producer-icon.svg-be5a0b4c1a9db245c9812cb27a6a40d8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/cables/icons/producer-icon.svg"
dest_files=["res://.godot/imported/producer-icon.svg-be5a0b4c1a9db245c9812cb27a6a40d8.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
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/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

7
addons/cables/plugin.cfg Normal file
View File

@ -0,0 +1,7 @@
[plugin]
name="Cables"
description="Higher-order construct to bridge the signal gap between scenes"
author="Obsidize Games"
version="2.3.0"
script="cables.gd"

View File

@ -0,0 +1,131 @@
## A resource-based signal type.
##
## Bridges the gap between scene assets, and fixes the issue of
## signal connections getting broken when rearranging scenes.
@icon("res://addons/cables/icons/cable-icon.svg")
class_name Cable extends Resource
## Special value to indicate [method void_notify] was called.
## [br][br]
## Needed because [code]null[/code] can't be used for void events, otherwise
## the calls [code]notify(null)[/code] and [code]void_notify()[/code] would be ambiguous.
const VOID_EVENT := {}
## Utility to check if the value was produced from a [method void_notify] call.
static func is_void_event(value: Variant) -> bool:
return typeof(value) == typeof(VOID_EVENT) and value == VOID_EVENT
var _current_value: Variant = null
var _did_notify_once: bool = false
# Core signal event that this cable wraps.
# This should not be used directly - instead, use the
# `link()` / `unlink()` methods.
signal _value_updated(value: Variant)
## When [code]true[/code], will re-emit the last value update
## to all [code]Callable[/code]s registered via [method link].
## [br][br]
## This solves the issue of late registration where
## the cable value has already settled before some consumer
## is ready to start watching for changes.
@export var replay_on_link := false
## When [code]true[/code], will print all interactions with this cable to the console
@export var debug_trace := false
## Optional description indicating intended use of
## this particular cable resource.
## [br][br]
## This is for documentation purposes only, to
## keep your game organized as the number of cable resources grows.
@export_multiline var debug_description := ""
## [code]True[/code] if this Cable has emitted at least one value.
## [br]
## This will help determine when the value should be replayed.
var did_notify_once: bool:
get: return _did_notify_once
## The last emitted value.
## [br][br]
## This can be compared against the value received from [method link] where
## this would be the old value, and the value in the [method link] callable
## would be the new value.
var current_value: Variant:
get: return _current_value
set(value): notify(value)
## Print contextual information for this cable to the console.
## [br]
## Does nothing when [member debug_trace] is [code]false[/code]
func debug_log(message: String) -> void:
if debug_trace: print("[%s] %s" % [resource_path.get_file(), message])
## Convenience for obtaining the current value or a fallback,
## depending on the state of this cable.
func get_value_or_default(default_value: Variant) -> Variant:
if did_notify_once and not Cable.is_void_event(current_value):
return current_value
return default_value
## Produce a new value to be passed on to any [code]Callable[/code]s registered
## via [method link], as well as any registered [CableValueConsumer] instances.
func notify(value: Variant) -> void:
_value_updated.emit(value)
_current_value = value
_did_notify_once = true
## Produce a special [constant Cable.VOID_EVENT] value to to any registered listeners.
## [br][br]
## This should be used if the cable is intended to be strictly an event producer,
## and does not need to supply any specific value change
## (e.g. for a [b]player death[/b] event).
func void_notify() -> void:
debug_log("void_notify()")
notify(VOID_EVENT)
## Registers the given [code]Callable[/code] to this cable.
## [br][br]
## The given [code]Callable[/code] will be updated with the latest value
## whenever [method notify] or [method void_notify] is called on this Cable.
## [br][br]
## If this Cable has emitted at least one value, and is set up to
## replay values, the given [code]Callable[/code] will be called immediately
## with the latest value.
## [br][br]
## Does nothing if the given callable is already connected to this Cable.
## [br][br]
## Returns a [code]Callable[/code] that, when called, will deregister
## the originally passed in [code]Callable[/code] function.
func link(callable: Callable) -> Callable:
if not _value_updated.is_connected(callable):
debug_log("link")
_value_updated.connect(callable)
if replay_on_link and did_notify_once:
debug_log("replay_on_link")
callable.call(current_value)
var unlink_action := func(): unlink(callable)
return unlink_action
## Deregisters the given [code]Callable[/code] from this cable.
## [br]
## Does nothing if the [code]Callable[/code] is not connected.
func unlink(callable: Callable) -> void:
if _value_updated.is_connected(callable):
debug_log("unlink")
_value_updated.disconnect(callable)
## Links the given [code]Callable[/code], and will automatically unlink it
## when the given [NodeWithLifetime] instance is about to be
## deleted (i.e. when a [b]NOTIFICATION_PREDELETE[/b] event is received on it).
## [br][br]
## This is a more optimal alternative to [b]tree_entered[/b] / [b]tree_exiting[/b] events,
## which may fire multiple times if a node is reparented one or more times.
func link_until_destroyed(node: NodeWithLifetime, callable: Callable) -> void:
debug_log("link_until_destroyed(%s)" % node.name)
var unlink_action := link(callable)
node.node_destroyed.connect(unlink_action)

View File

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

View File

@ -0,0 +1,17 @@
## Thin wrapper around [CallableSink] that adds some
## functionality specific to [Cable]s.
class_name CableLinkGroup extends CallableSink
## Convenience to reduce boilerplate of [method with_lifetime]
## by inferring [method NodeWithLifetime.from] while creating the new link group.
static func with_lifetime_of(node: Node) -> CableLinkGroup:
return CableLinkGroup.with_lifetime(NodeWithLifetime.from(node))
## Creates a new [CableLinkGroup], and connects its
## [method call_each_and_clear] method as a callable to the given
## [param lifetime]'s [signal NodeWithLifetime.node_destroyed] signal.
static func with_lifetime(lifetime: NodeWithLifetime) -> CableLinkGroup:
var result := CableLinkGroup.new()
var unlink_all := func(): result.call_each_and_clear()
lifetime.node_destroyed.connect(unlink_all)
return result

View File

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

View File

@ -0,0 +1,50 @@
## Plug-and-play transformer for Cables that localizes
## cable signals into local ones that can be bound to in the editor.
##
## NOTE: Using this node and subscribing to its signals may reintroduce
## the problem that [member Cable.replay_on_link] solves. If this is
## the case, it is recommended to bind to the target [Cable](s) in code
## via [method CableLinkGroup.with_lifetime_of] like so:
## [br]
## [codeblock]
## func _ready() -> void:
## CableLinkGroup.with_lifetime_of(self).append([
## cable1.link(_on_cable1_value),
## cable2.link(_on_cable2_value),
## ])
##
## [/codeblock]
@tool
@icon("res://addons/cables/icons/consumer-icon.svg")
class_name CableValueConsumer extends NodeWithLifetime
## Emits when the [member input] cable emits a value that is not [constant Cable.VOID_EVENT]
signal value_updated(value: Variant)
## Emits when the [member input] cable emits a [constant Cable.VOID_EVENT]
signal void_update()
## Emits when either a value update or void update occurs
signal any_update()
## The Cable to forward events from
@export var input: Cable
func _ready() -> void:
input.debug_log("Consumer<%s> link_until_destroyed()" % str(self))
input.link_until_destroyed(self, _on_cable_value_update)
func _on_cable_value_update(value: Variant) -> void:
any_update.emit()
if Cable.is_void_event(value):
input.debug_log("Consumer<%s> void_update()" % str(self))
void_update.emit()
else:
input.debug_log("Consumer<%s> value_updated(%s)" % [str(self), str(value)])
value_updated.emit(value)
func _get_configuration_warnings() -> PackedStringArray:
var warnings := PackedStringArray()
if input == null:
warnings.append("Input cable not assigned")
return warnings

View File

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

View File

@ -0,0 +1,25 @@
## Plug-and-play transformer for Cables that forwards
## local send calls onto the target [member output] [Cable].
@tool
@icon("res://addons/cables/icons/producer-icon.svg")
class_name CableValueProducer extends NodeWithLifetime
## The [Cable] to forward local [method send_value_update] and
## [method send_void_update] calls to.
@export var output: Cable
## Emits on the assigned [member output] [Cable] with the given [param value].
func send_value_update(value: Variant) -> void:
output.debug_log("Producer<%s> send_value_update(%s)" % [str(self), str(value)])
output.notify(value)
## Emits a [constant Cable.VOID_EVENT] on the assigned [member output] [Cable].
func send_void_update() -> void:
output.debug_log("Producer<%s> send_void_update()" % str(self))
output.void_notify()
func _get_configuration_warnings() -> PackedStringArray:
var warnings := PackedStringArray()
if output == null:
warnings.append("Output cable not assigned")
return warnings

View File

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

View File

@ -0,0 +1,53 @@
## Generic utility to aggregate [code]Callable[/code]s.
class_name CallableSink extends RefCounted
var _actions: Array[Callable] = []
## Appends the given array of [code]Callable[/code]s to this group,
## while leaving any already-registered [code]Callable[/code]s in-tact.
## [br][br]
## This can be chained multiple times without causing any deregistration.
## [br][br]
## If you would like to instead [i]overwrite[/i] the currently stored
## [code]Callable[/code]s in this group, use [method collect] instead.
func append(unlink_actions: Array[Callable]) -> CallableSink:
if typeof(unlink_actions) == TYPE_ARRAY:
_actions.append_array(unlink_actions)
return self
## Similar to [method append], but returns a [code]Callable[/code]
## instead of a [code]self[/code] reference.
## [br][br]
## Useful in some edge cases where you may want to aggregate groups of groups
## like so:
## [br]
## [codeblock]
## var deregister_all := CallableSink.new().aggregate([
## CallableSink.new().aggregate(deregister_group_1),
## CallableSink.new().aggregate(deregister_group_2),
## CallableSink.new().aggregate(deregister_group_3),
## ])
## [/codeblock]
func aggregate(unlink_actions: Array[Callable]) -> Callable:
append(unlink_actions)
return call_each_and_clear
## Similar to [method aggregate], but deregisters any currently
## registered [code]Callable[/code]s via [method call_each_and_clear]
## before appending the given [param unlink_actions] to this group.
## [br][br]
## Call this when you intend to re-use an existing [CallableSink]
## to perform several "clean slate" hookups.
func collect(unlink_actions: Array[Callable]) -> Callable:
call_each_and_clear()
return aggregate(unlink_actions)
## Calls each currently registered [code]Callable[/code]
func call_each() -> void:
for c in _actions: if c is Callable: c.call()
## Similar to [method call_each], but clears the [code]Callable[/code] cache
## after all currently registered [code]Callable[/code]s have run.
func call_each_and_clear() -> void:
call_each()
_actions.clear()

View File

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

View File

@ -0,0 +1,42 @@
## Base class for Cable nodes that are interested in the [signal node_destroyed] event,
## i.e. when this node receives a [b]NOTIFICATION_PREDELETE[/b] message.
class_name NodeWithLifetime extends Node
## Emits when a [b]NOTIFICATION_PREDELETE[/b] notification is intercepted.
signal node_destroyed()
func _notification(what: int) -> void:
if what == NOTIFICATION_PREDELETE:
node_destroyed.emit()
## Translates the given [param node] into a [NodeWithLifetime] reference.
## [br][br]
## This returned instance will reflect the lifecycle events of the given [param node],
## i.e. when the returned instance is about to be deleted, it is inferred
## that the origin [param node] will also be deleted.
## [br][br]
## If the given [param node] is already a [NodeWithLifetime] instance
## it will be returned as-is.
## [br][br]
## If the given [param node] has a child node which is a [NodeWithLifetime] instance,
## that child node will be returned.
## [br][br]
## If neither of the above cases are met, this will create a new [NodeWithLifetime]
## instance and append it as a child of the given [param node].
static func from(node: Node) -> NodeWithLifetime:
if node is NodeWithLifetime:
return node as NodeWithLifetime
var result: NodeWithLifetime = null
for c in node.get_children():
if c is NodeWithLifetime:
result = c as NodeWithLifetime
break
if result == null:
result = NodeWithLifetime.new()
node.add_child(result)
result.name = "%s_LifetimeContext" % node.name
return result

View File

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

View File

@ -0,0 +1,38 @@
## Special type of producer that sends a void event in response to button interaction,
## based on this node's configuration.
## This node type should always be added as a direct child of a Button node type.
@tool
@icon("res://addons/cables/icons/producer-icon.svg")
class_name CableButtonEventProducer extends CableValueProducer
enum TriggerType {
PRESSED,
BUTTON_DOWN,
BUTTON_UP
}
@export var trigger_type: TriggerType = TriggerType.PRESSED
var _button: Button = null
func _ready() -> void:
var p := get_parent()
if p is Button:
_button = p as Button
if _button == null or Engine.is_editor_hint():
return
match trigger_type:
TriggerType.PRESSED: _button.pressed.connect(send_void_update)
TriggerType.BUTTON_DOWN: _button.button_down.connect(send_void_update)
TriggerType.BUTTON_UP: _button.button_up.connect(send_void_update)
_: push_warning("Unknown trigger type detected: %s" % str(trigger_type))
func _get_configuration_warnings() -> PackedStringArray:
var warnings := super._get_configuration_warnings()
var p := get_parent()
if not (p is Button):
warnings.append("Parent node must be a Button")
return warnings

View File

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

View File

@ -0,0 +1,37 @@
## Special type of producer that sends a void event in response to an input action,
## based on this node's configuration.
## This node type should always be added as a direct child of a Button node type.
@tool
@icon("res://addons/cables/icons/producer-icon.svg")
class_name CableInputEventProducer extends CableValueProducer
enum TriggerType {
JUST_PRESSED,
JUST_RELEASED,
HELD_DOWN
}
@export var action: StringName = ""
@export var trigger_type: TriggerType = TriggerType.JUST_PRESSED
var is_target_event_type: Callable = func(): return false
func _ready() -> void:
match trigger_type:
TriggerType.JUST_PRESSED: is_target_event_type = _is_just_pressed
TriggerType.JUST_RELEASED: is_target_event_type = _is_just_released
TriggerType.HELD_DOWN: is_target_event_type = _is_held_down
_: push_warning("Unknown trigger type: %s" % str(trigger_type))
func _is_just_pressed() -> bool:
return Input.is_action_just_pressed(action)
func _is_just_released() -> bool:
return Input.is_action_just_released(action)
func _is_held_down() -> bool:
return Input.is_action_pressed(action)
func _unhandled_input(_event: InputEvent) -> void:
var is_required_action: bool = is_target_event_type.call()
if is_required_action: send_void_update()

View File

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

View File

@ -0,0 +1,53 @@
## Special type of producer that sends a node reference.
##
## This class will track the lifetime of the referenced node,
## and emit a "cleared" event automatically to avoid reference-after-frees.
@tool
@icon("res://addons/cables/icons/producer-icon.svg")
class_name CableNodeValueProducer extends CableValueProducer
## Optional - the node to be broadcast on the assigned [member output] Cable.
## Can also be updated at runtime via [method send_node_value_update].
@export var node_value: Node = null
## Indicates that the [member node_value] should be broadcast on the given
## [member output] as soon as it becomes ready. This should be [code]true[/code]
## in most cases, especially when [member node_value] is set in the editor and not at runtime.
@export var notify_on_node_value_ready := true
## Indicates that the value on the given [member output] should be cleared when
## [i]this[/i] node is destroyed (NOT the [member node_value] node).
## [br][br]
## This node should generally be a child or sibling of the [member node_value] in
## most cases, so the destruction of this node will infer the destruction
## of the target.
## [br][br]
## If this inferred behavior is [i]not[/i] the case, set this value to [code]false[/code]
## and call [method send_node_value_clear] manually instead when you want
## the cable link to be removed.
@export var clear_on_destroy := true
func _ready() -> void:
if node_value and notify_on_node_value_ready:
if node_value.is_node_ready():
send_node_value()
else:
node_value.ready.connect(send_node_value)
if clear_on_destroy:
node_destroyed.connect(send_node_value_clear)
## Broadcasts [param node] on the currently assigned [member output].
func send_node_value_update(node: Node) -> void:
node_value = node
send_value_update(node)
## Broadcasts the current [member node_value] value on the given [member output].
## Triggered automatically when [member notify_on_node_value_ready] is [code]true[/code].
func send_node_value() -> void:
send_node_value_update(node_value)
## Broadcasts [code]null[/code] on the currently assigned [member output].
## Triggered automatically when [member clear_on_destroy] is [code]true[/code].
func send_node_value_clear() -> void:
send_node_value_update(null)

View File

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

22
addons/godottpd/LICENSE Normal file
View File

@ -0,0 +1,22 @@
MIT License
Copyright (c) 2022 deep Entertainment
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.

View File

@ -0,0 +1,173 @@
## Class inheriting HttpRouter for handling file serving requests
##
## NOTE: This class mainly handles behind the scenes stuff.
class_name HttpFileRouter
extends HttpRouter
## Full path to the folder which will be exposed to web
var path: String = ""
## Relative path to the index page, which will be served when a request is made to "/" (server root)
var index_page: String = "index.html"
## Relative path to the fallback page which will be served if the requested file was not found
var fallback_page: String = ""
## An ordered list of extensions that will be checked
## if no file extension is provided by the request
var extensions: PackedStringArray = ["html"]
## A list of extensions that will be excluded if requested
var exclude_extensions: PackedStringArray = []
## Creates an HttpFileRouter intance
## [br]
## [br][param path] - Full path to the folder which will be exposed to web.
## [br][param options] - Optional Dictionary of options which can be configured:
## [br] - [param fallback_page]: Full path to the fallback page which will be served if the requested file was not found
## [br] - [param extensions]: A list of extensions that will be checked if no file extension is provided by the request
## [br] - [param exclude_extensions]: A list of extensions that will be excluded if requested
func _init(
path: String,
options: Dictionary = {
index_page = index_page,
fallback_page = fallback_page,
extensions = extensions,
exclude_extensions = exclude_extensions,
}
) -> void:
self.path = path
self.index_page = options.get("index_page", "")
self.fallback_page = options.get("fallback_page", "")
self.extensions = options.get("extensions", [])
self.exclude_extensions = options.get("exclude_extensions", [])
## Handle a GET request
## [br]
## [br][param request] - The request from the client
## [br][param response] - The response to send to the clinet
func handle_get(request: HttpRequest, response: HttpResponse) -> void:
var serving_path: String = path + request.path
var file_exists: bool = _file_exists(serving_path)
if request.path == "/" and not file_exists:
if index_page.length() > 0:
serving_path = path + "/" + index_page
file_exists = _file_exists(serving_path)
if request.path.get_extension() == "" and not file_exists:
for extension in extensions:
serving_path = path + request.path + "." + extension
file_exists = _file_exists(serving_path)
if file_exists:
break
# GDScript must be excluded, unless it is used as a preprocessor (php-like)
if (file_exists and not serving_path.get_extension() in ["gd"] + Array(exclude_extensions)):
response.send_raw(
200,
_serve_file(serving_path),
_get_mime(serving_path.get_extension())
)
else:
if fallback_page.length() > 0:
serving_path = path + "/" + fallback_page
response.send_raw(200 if index_page == fallback_page else 404, _serve_file(serving_path), _get_mime(fallback_page.get_extension()))
else:
response.send_raw(404)
# Reads a file as text
#
# #### Parameters
# - file_path: Full path to the file
func _serve_file(file_path: String) -> PackedByteArray:
var content: PackedByteArray = []
var file: FileAccess = FileAccess.open(file_path, FileAccess.READ)
var error = file.get_open_error()
if error:
content = ("Couldn't serve file, ERROR = %s" % error).to_ascii_buffer()
else:
content = file.get_buffer(file.get_length())
file.close()
return content
# Check if a file exists
#
# #### Parameters
# - file_path: Full path to the file
func _file_exists(file_path: String) -> bool:
return FileAccess.file_exists(file_path)
# Get the full MIME type of a file from its extension
#
# #### Parameters
# - file_extension: Extension of the file to be served
func _get_mime(file_extension: String) -> String:
var type: String = "application"
var subtype : String = "octet-stream"
match file_extension:
# Web files
"css","html","csv","js","mjs":
type = "text"
subtype = "javascript" if file_extension in ["js","mjs"] else file_extension
"php":
subtype = "x-httpd-php"
"ttf","woff","woff2":
type = "font"
subtype = file_extension
# Image
"png","bmp","gif","png","webp":
type = "image"
subtype = file_extension
"jpeg","jpg":
type = "image"
subtype = "jpg"
"tiff", "tif":
type = "image"
subtype = "jpg"
"svg":
type = "image"
subtype = "svg+xml"
"ico":
type = "image"
subtype = "vnd.microsoft.icon"
# Documents
"doc":
subtype = "msword"
"docx":
subtype = "vnd.openxmlformats-officedocument.wordprocessingml.document"
"7z":
subtype = "x-7x-compressed"
"gz":
subtype = "gzip"
"tar":
subtype = "application/x-tar"
"json","pdf","zip":
subtype = file_extension
"txt":
type = "text"
subtype = "plain"
"ppt":
subtype = "vnd.ms-powerpoint"
# Audio
"midi","mp3","wav":
type = "audio"
subtype = file_extension
"mp4","mpeg","webm":
type = "audio"
subtype = file_extension
"oga","ogg":
type = "audio"
subtype = "ogg"
"mpkg":
subtype = "vnd.apple.installer+xml"
# Video
"ogv":
type = "video"
subtype = "ogg"
"avi":
type = "video"
subtype = "x-msvideo"
"ogx":
subtype = "ogg"
return type + "/" + subtype

View File

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

View File

@ -0,0 +1,53 @@
## An HTTP request received by the server
class_name HttpRequest
extends RefCounted
## A dictionary of the headers of the request
var headers: Dictionary
## The received raw body
var body: String
## A match object of the regular expression that matches the path
var query_match: RegExMatch
## The path that matches the router path
var path: String
## The method
var method: String
## A dictionary of request (aka. routing) parameters
var parameters: Dictionary
## A dictionary of request query parameters
var query: Dictionary
## Returns the body object based on the raw body and the content type of the request
func get_body_parsed() -> Variant:
var content_type: String = ""
if(headers.has("content-type")):
content_type = headers["content-type"]
elif(headers.has("Content-Type")):
content_type = headers["Content-Type"]
if(content_type == "application/json"):
return JSON.parse_string(body)
if(content_type == "application/x-www-form-urlencoded"):
var data = {}
for body_part in body.split("&"):
var key_and_value = body_part.split("=")
data[key_and_value[0]] = key_and_value[1]
return data
# Not supported contenty type parsing... for now
return null
## Override `str()` method, automatically called in `print()` function
func _to_string() -> String:
return JSON.stringify({headers=headers, method=method, path=path})

View File

@ -0,0 +1 @@
uid://3wm0jxm2ena7

View File

@ -0,0 +1,174 @@
## A response object useful to send out responses
class_name HttpResponse
extends RefCounted
## The client currently talking to the server
var client: StreamPeer
## The server identifier to use on responses [GodotTPD]
var server_identifier: String = "GodotTPD"
## A dictionary of headers
## [br] Headers can be set using the `set(name, value)` function
var headers: Dictionary = {}
## An array of cookies
## [br] Cookies can be set using the `cookie(name, value, options)` function
## [br] Cookies will be automatically sent via "Set-Cookie" headers to clients
var cookies: Array = []
## Origins allowed to call this resource
var access_control_origin = "*"
## Comma separed methods for the access control
var access_control_allowed_methods = "POST, GET, OPTIONS"
## Comma separed headers for the access control
var access_control_allowed_headers = "content-type"
## Send out a raw (Bytes) response to the client
## [br] Useful to send files faster or raw data which will be converted by the client
## [br][param status] - The HTTP Status code to send
## [br][param data] - The body data to send
## [br][param content_type] - The type of content to send.
func send_raw(status_code: int, data: PackedByteArray = PackedByteArray([]), content_type: String = "application/octet-stream") -> void:
client.put_data(("HTTP/1.1 %d %s\r\n" % [status_code, _match_status_code(status_code)]).to_ascii_buffer())
client.put_data(("Server: %s\r\n" % server_identifier).to_ascii_buffer())
for header in headers.keys():
client.put_data(("%s: %s\r\n" % [header, headers[header]]).to_ascii_buffer())
for cookie in cookies:
client.put_data(("Set-Cookie: %s\r\n" % cookie).to_ascii_buffer())
client.put_data(("Content-Length: %d\r\n" % data.size()).to_ascii_buffer())
client.put_data("Connection: close\r\n".to_ascii_buffer())
client.put_data(("Access-Control-Allow-Origin: %s\r\n" % access_control_origin).to_ascii_buffer())
client.put_data(("Access-Control-Allow-Methods: %s\r\n" % access_control_allowed_methods).to_ascii_buffer())
client.put_data(("Access-Control-Allow-Headers: %s\r\n" % access_control_allowed_headers).to_ascii_buffer())
client.put_data(("Content-Type: %s\r\n\r\n" % content_type).to_ascii_buffer())
client.put_data(data)
## Send out a response to the client
## [br]
## [br][param status_code] - The HTTP status code to send
## [br][param data] - The body to send
## [br][param content_type] - The type of the content to send
func send(status_code: int, data: String = "", content_type = "text/html") -> void:
send_raw(status_code, data.to_ascii_buffer(), content_type)
## Send out a JSON response to the client
## [br] This function will internally call the [method send]
## [br]
## [br][param status_code] - The HTTP status code to send
## [br][param data] - The body to send
func json(status_code: int, data) -> void:
send(status_code, JSON.stringify(data), "application/json")
## Sets the responses header "field" to "value"
## [br]
## [br][param field] - The name of the header. i.e. [code]Accept-Type[/code]
## [br][param value] - The value of this header. i.e. [code]application/json[/code]
func set(field: StringName, value: Variant) -> void:
headers[field] = value
## Sets cookie "name" to "value"
## [br]
## [br][param name] - The name of the cookie. i.e. [code]user-id[/code]
## [br][param value] - The value of this cookie. i.e. [code]abcdef[/code]
## [br][param options] - A Dictionary of [url=https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes]cookie attributes[/url]
## for this specific cokkie in the [code]{ "secure" : "true"}[/code] format.
func cookie(name: String, value: String, options: Dictionary = {}) -> void:
var cookie: String = name+"="+value
if options.has("domain"): cookie+="; Domain="+options["domain"]
if options.has("max-age"): cookie+="; Max-Age="+options["max-age"]
if options.has("expires"): cookie+="; Expires="+options["expires"]
if options.has("path"): cookie+="; Path="+options["path"]
if options.has("secure"): cookie+="; Secure="+options["secure"]
if options.has("httpOnly"): cookie+="; HttpOnly="+options["httpOnly"]
if options.has("sameSite"):
match (options["sameSite"]):
true: cookie += "; SameSite=Strict"
"lax": cookie += "; SameSite=Lax"
"strict": cookie += "; SameSite=Strict"
"none": cookie += "; SameSite=None"
_: pass
cookies.append(cookie)
## Automatically matches a "status_code" to an RFC 7231 compliant "status_text"
## [br]
## [br][param code] - The HTTP Status code to be matched
## [br]Returns: the matched [code]status_text[/code]
func _match_status_code(code: int) -> String:
var text: String = "OK"
match(code):
# 1xx - Informational Responses
100: text="Continue"
101: text="Switching protocols"
102: text="Processing"
103: text="Early Hints"
# 2xx - Successful Responses
200: text="OK"
201: text="Created"
202: text="Accepted"
203: text="Non-Authoritative Information"
204: text="No Content"
205: text="Reset Content"
206: text="Partial Content"
207: text="Multi-Status"
208: text="Already Reported"
226: text="IM Used"
# 3xx - Redirection Messages
300: text="Multiple Choices"
301: text="Moved Permanently"
302: text="Found (Previously 'Moved Temporarily')"
303: text="See Other"
304: text="Not Modified"
305: text="Use Proxy"
306: text="Switch Proxy"
307: text="Temporary Redirect"
308: text="Permanent Redirect"
# 4xx - Client Error Responses
400: text="Bad Request"
401: text="Unauthorized"
402: text="Payment Required"
403: text="Forbidden"
404: text="Not Found"
405: text="Method Not Allowed"
406: text="Not Acceptable"
407: text="Proxy Authentication Required"
408: text="Request Timeout"
409: text="Conflict"
410: text="Gone"
411: text="Length Required"
412: text="Precondition Failed"
413: text="Payload Too Large"
414: text="URI Too Long"
415: text="Unsupported Media Type"
416: text="Range Not Satisfiable"
417: text="Expectation Failed"
418: text="I'm a Teapot"
421: text="Misdirected Request"
422: text="Unprocessable Entity"
423: text="Locked"
424: text="Failed Dependency"
425: text="Too Early"
426: text="Upgrade Required"
428: text="Precondition Required"
429: text="Too Many Requests"
431: text="Request Header Fields Too Large"
451: text="Unavailable For Legal Reasons"
# 5xx - Server Error Responses
500: text="Internal Server Error"
501: text="Not Implemented"
502: text="Bad Gateway"
503: text="Service Unavailable"
504: text="Gateway Timeout"
505: text="HTTP Version Not Supported"
506: text="Variant Also Negotiates"
507: text="Insufficient Storage"
508: text="Loop Detected"
510: text="Not Extended"
511: text="Network Authentication Required"
return text

View File

@ -0,0 +1 @@
uid://01jj3dfsmp1f

View File

@ -0,0 +1,77 @@
## A base class for all HTTP routers
##
## This router handles all the requests that the client sends to the server.
## [br]NOTE: This class is meant to be expanded upon instead of used directly.
## [br]Usage:
## [codeblock]
## class_name MyCustomRouter
## extends HttpRouter
##
## func handle_get(request: HttpRequest, response: HttpResponse) -> void:
## response.send(200, "Hello World")
## [/codeblock]
class_name HttpRouter
extends RefCounted
## Handle a GET request
## [br]
## [br][param request] - The request from the client
## [br][param response] - The node to send the response back to the client
@warning_ignore("unused_parameter")
func handle_get(request: HttpRequest, response: HttpResponse) -> void:
response.send(405, "GET not allowed")
## Handle a POST request
## [br]
## [br][param request] - The request from the client
## [br][param response] - The node to send the response back to the client
@warning_ignore("unused_parameter")
func handle_post(request: HttpRequest, response: HttpResponse) -> void:
response.send(405, "POST not allowed")
## Handle a HEAD request
## [br]
## [br][param request] - The request from the client
## [br][param response] - The node to send the response back to the client
@warning_ignore("unused_parameter")
func handle_head(request: HttpRequest, response: HttpResponse) -> void:
response.send(405, "HEAD not allowed")
## Handle a PUT request
## [br]
## [br][param request] - The request from the client
## [br][param response] - The node to send the response back to the client
@warning_ignore("unused_parameter")
func handle_put(request: HttpRequest, response: HttpResponse) -> void:
response.send(405, "PUT not allowed")
## Handle a PATCH request
## [br]
## [br][param request] - The request from the client
## [br][param response] - The node to send the response back to the client
@warning_ignore("unused_parameter")
func handle_patch(request: HttpRequest, response: HttpResponse) -> void:
response.send(405, "PATCH not allowed")
## Handle a DELETE request
## [br]
## [br][param request] - The request from the client
## [br][param response] - The node to send the response back to the client
@warning_ignore("unused_parameter")
func handle_delete(request: HttpRequest, response: HttpResponse) -> void:
response.send(405, "DELETE not allowed")
## Handle an OPTIONS request
## [br]
## [br][param request] - The request from the client
## [br][param response] - The node to send the response back to the client
@warning_ignore("unused_parameter")
func handle_options(request: HttpRequest, response: HttpResponse) -> void:
response.send(405, "OPTIONS not allowed")

View File

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

View File

@ -0,0 +1,311 @@
## A routable HTTP server for Godot
##
## Provides a web server with routes for specific endpoints
## [br]Example usage:
## [codeblock]
## var server := HttpServer.new()
## server.register_router("/", MyExampleRouter.new())
## add_child(server)
## server.start()
## [/codeblock]
class_name HttpServer
extends Node
## The ip address to bind the server to. Use * for all IP addresses [*]
@export var bind_address: String = "*"
## The port to bind the server to. [8080]
@export var port: int = 8080
## The server identifier to use when responding to requests [GodotTPD]
@export var server_identifier: String = "GodotTPD"
# If `HttpRequest`s and `HttpResponse`s should be logged
@export var _logging: bool = false
# The TCP server instance used
var _server: TCPServer
# An array of StraemPeerTCP objects who are currently talking to the server
var _clients: Array
# A list of HttpRequest routers who could handle a request
var _routers: Array = []
# A regex identifiying the method line
var _method_regex: RegEx = RegEx.new()
# A regex for header lines
var _header_regex: RegEx = RegEx.new()
# The base path used in a project to serve files
var _local_base_path: String = "res://src"
# list of host allowed to call the server
var _allowed_origins: PackedStringArray = []
# Comma separed methods for the access control
var _access_control_allowed_methods = "POST, GET, OPTIONS"
# Comma separed headers for the access control
var _access_control_allowed_headers = "content-type"
# Compile the required regex
func _init(_logging: bool = false):
self._logging = _logging
set_process(false)
_method_regex.compile("^(?<method>GET|POST|HEAD|PUT|PATCH|DELETE|OPTIONS) (?<path>[^ ]+) HTTP/1.1$")
_header_regex.compile("^(?<key>[\\w-]+): (?<value>(.*))$")
# Print a debug message in console, if the debug mode is enabled
#
# #### Parameters
# - message: The message to be printed (only in debug mode)
func _print_debug(message: String) -> void:
var time = Time.get_datetime_dict_from_system()
var time_return = "%02d-%02d-%02d %02d:%02d:%02d" % [time.year, time.month, time.day, time.hour, time.minute, time.second]
print_debug("[SERVER] ",time_return," >> ", message)
## Register a new router to handle a specific path
## [br]
## [br][param path] - The path the router will handle.
## Supports a regular expression and the group matches will be available in HttpRequest.query_match.
## [br][param router] - The router which will handle the request
func register_router(path: String, router: HttpRouter):
var path_regex = RegEx.new()
var params: Array = []
if path.left(0) == "^":
path_regex.compile(path)
else:
var regexp: Array = _path_to_regexp(path, router is HttpFileRouter)
path_regex.compile(regexp[0])
params = regexp[1]
_routers.push_back({
"path": path_regex,
"params": params,
"router": router
})
## Handle possibly incoming requests
func _process(_delta: float) -> void:
if _server:
while _server.is_connection_available():
var new_client = _server.take_connection()
if new_client:
self._clients.append(new_client)
for client in self._clients:
client.poll()
if client.get_status() == StreamPeerTCP.STATUS_CONNECTED:
var bytes = client.get_available_bytes()
if bytes > 0:
var request_string = client.get_utf8_string(bytes)
self._handle_request(client, request_string)
_remove_disconnected_clients()
func _remove_disconnected_clients():
var valid_statuses = [StreamPeerTCP.STATUS_CONNECTED, StreamPeerTCP.STATUS_CONNECTING]
self._clients = self._clients.filter(
func(c: StreamPeerTCP): return valid_statuses.has(c.get_status())
)
## Start the server
func start():
set_process(true)
self._server = TCPServer.new()
var err: int = self._server.listen(self.port, self.bind_address)
match err:
22:
_print_debug("Could not bind to port %d, already in use" % [self.port])
stop()
_:
_print_debug("HTTP Server listening on http://%s:%s" % [self.bind_address, self.port])
## Stop the server and disconnect all clients
func stop():
for client in self._clients:
client.disconnect_from_host()
self._clients.clear()
self._server.stop()
set_process(false)
_print_debug("Server stopped.")
# Interpret a request string and perform the request
#
# #### Parameters
# - client: The client that send the request
# - request: The received request as a String
func _handle_request(client: StreamPeer, request_string: String):
var request = HttpRequest.new()
for line in request_string.split("\r\n"):
var method_matches = _method_regex.search(line)
var header_matches = _header_regex.search(line)
if method_matches:
request.method = method_matches.get_string("method")
var request_path: String = method_matches.get_string("path")
# Check if request_path contains "?" character, could be a query parameter
if not "?" in request_path:
request.path = request_path
else:
var path_query: PackedStringArray = request_path.split("?")
request.path = path_query[0]
request.query = _extract_query_params(path_query[1])
request.headers = {}
request.body = ""
elif header_matches:
request.headers[header_matches.get_string("key")] = \
header_matches.get_string("value")
else:
request.body += line
self._perform_current_request(client, request)
# Handle a specific request and send it to a router
# If no router matches, send a 404
#
# #### Parameters
# - client: The client that send the request
# - request_info: A dictionary with information about the request
# - method: The method of the request (e.g. GET, POST)
# - path: The requested path
# - headers: A dictionary of headers of the request
# - body: The raw body of the request
func _perform_current_request(client: StreamPeer, request: HttpRequest):
_print_debug("HTTP Request: " + str(request))
var found = false
var is_allowed_origin = false
var response = HttpResponse.new()
var fetch_mode = ""
var origin = ""
response.client = client
response.server_identifier = server_identifier
if request.headers.has("Sec-Fetch-Mode"):
fetch_mode = request.headers["Sec-Fetch-Mode"]
elif request.headers.has("sec-fetch-mode"):
fetch_mode = request.headers["sec-fetch-mode"]
if request.headers.has("Origin"):
origin = request.headers["Origin"]
elif request.headers.has("origin"):
origin = request.headers["origin"]
if _allowed_origins.has(origin):
is_allowed_origin = true
response.access_control_origin = origin
response.access_control_allowed_methods = _access_control_allowed_methods
response.access_control_allowed_headers = _access_control_allowed_headers
for router in self._routers:
var matches = router.path.search(request.path)
if matches:
request.query_match = matches
if request.query_match.get_string("subpath"):
request.path = request.query_match.get_string("subpath")
if router.params.size() > 0:
for parameter in router.params:
request.parameters[parameter] = request.query_match.get_string(parameter)
match request.method:
"GET":
found = true
router.router.handle_get(request, response)
"POST":
found = true
router.router.handle_post(request, response)
"HEAD":
found = true
router.router.handle_head(request, response)
"PUT":
found = true
router.router.handle_put(request, response)
"PATCH":
found = true
router.router.handle_patch(request, response)
"DELETE":
found = true
router.router.handle_delete(request, response)
"OPTIONS":
if _allowed_origins.size() > 0 && fetch_mode == "cors":
if is_allowed_origin:
response.send(204)
else:
response.send(400, "%s is not present in the allowed origins" % origin)
return
found = true
router.router.handle_options(request, response)
break
if not found:
response.send(404, "Not found")
# Converts a URL path to @regexp RegExp, providing a mechanism to fetch groups from the expression
# indexing each parameter by name in the @params array
#
# #### Parameters
# - path: The path of the HttpRequest
# - should_match_subfolder: (dafult [false]) if subfolders should be matched and grouped,
# used for HttpFileRouter
#
# Returns: A 2D array, containing a @regexp String and Dictionary of @params
# [0] = @regexp --> the output expression as a String, to be compiled in RegExp
# [1] = @params --> an Array of parameters, indexed by names
# ex. "/user/:id" --> "^/user/(?<id>([^/#?]+?))[/#?]?$"
func _path_to_regexp(path: String, should_match_subfolders: bool = false) -> Array:
var regexp: String = "^"
var params: Array = []
var fragments: Array = path.split("/")
fragments.pop_front()
for fragment in fragments:
if fragment.left(1) == ":":
fragment = fragment.lstrip(":")
regexp += "/(?<%s>([^/#?]+?))" % fragment
params.append(fragment)
else:
regexp += "/" + fragment
regexp += "[/#?]?$" if not should_match_subfolders else "(?<subpath>$|/.*)"
return [regexp, params]
## Enable CORS (Cross-origin resource sharing) which only allows requests from the specified servers
## [br]
## [br][param allowed_origins] - The origins that are allowed to be accessed from this server
## [br][param access_control_allowed_methods] - The methods that are allowed to be used
## [br][param access_control_allowed_headers] - The headers that are allowed to be sent
func enable_cors(allowed_origins: PackedStringArray, access_control_allowed_methods : String = "POST, GET, OPTIONS", access_control_allowed_headers : String = "content-type"):
_allowed_origins = allowed_origins
_access_control_allowed_methods = access_control_allowed_methods
_access_control_allowed_headers = access_control_allowed_headers
# Extracts query parameters from a String query,
# building a Query Dictionary of param:value pairs
#
# #### Parameters
# - query_string: the query string, extracted from the HttpRequest.path
#
# Returns: A Dictionary of param:value pairs
func _extract_query_params(query_string: String) -> Dictionary:
var query: Dictionary = {}
if query_string == "":
return query
var parameters: Array = query_string.split("&")
for param in parameters:
if not "=" in param:
continue
var kv : Array = param.split("=")
var value: String = kv[1]
if value.is_valid_int():
query[kv[0]] = value.to_int()
elif value.is_valid_float():
query[kv[0]] = value.to_float()
else:
query[kv[0]] = value
return query

View File

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

View File

@ -0,0 +1,7 @@
[plugin]
name="godottpd"
description="Web server for Godot"
author="deep Entertainment"
version="0.1.0"
script="plugin.gd"

View File

@ -0,0 +1,4 @@
# A routable HTTP server for Godot
# We don't really need to initialize anything here
@tool
extends EditorPlugin

View File

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

View File

@ -0,0 +1,81 @@
# Documentation
## Classes
All of the class are located in `res://addons/imjp94.yafsm/src` but you can just preload `res://addons/imjp94.yafsm/YAFSM.gd` to import all class available:
```gdscript
const YAFSM = preload("res://addons/imjp94.yafsm/YAFSM.gd")
const StackPlayer = YAFSM.StackPlayer
const StateMachinePlayer = YAFSM.StateMachinePlayer
const StateMachine = YAFSM.StateMachine
const State = YAFSM.State
```
### Node
- [StackPlayer](src/StackPlayer.gd) ![StackPlayer icon](assets/icons/stack_player_icon.png)
> Manage stack of item, use push/pop function to set current item on top of stack
- `current # Current item on top of stack`
- `stack`
- signals:
- `pushed(to) # When item pushed to stack`
- `popped(from) # When item popped from stack`
- [StateMachinePlayer](src/StateMachinePlayer.gd)(extends StackPlayer) ![StateMachinePlayer icon](assets/icons/state_machine_player_icon.png)
> Manage state based on `StateMachine` and parameters inputted
- `state_machine # StateMachine being played`
- `active # Activeness of player`
- `autostart # Automatically enter Entry state on ready if true`
- `process_mode # ProcessMode of player`
- signals:
- `transited(from, to) # Transition of state`
- `entered(to) # Entry of state machine(including nested), empty string equals to root`
- `exited(from) # Exit of state machine(including nested, empty string equals to root`
- `updated(state, delta) # Time to update(based on process_mode), up to user to handle any logic, for example, update movement of KinematicBody`
### Control
- [StackPlayerDebugger](src/debugger/StackPlayerDebugger.gd)
> Visualize stack of parent StackPlayer on screen
### Reference
- [StateDirectory](src/StateDirectory.gd)
> Convert state path to directory object for traversal, mainly used for nested state
### Resource
Relationship between all `Resource`s can be best represented as below:
```gdscript
var state_machine = state_machine_player.state_machine
var state = state_machine.states[state_name] # keyed by state name
var transition = state_machine.transitions[from][to] # keyed by state name transition from/to
var condition = transition.conditions[condition_name] # keyed by condition name
```
> For normal usage, you really don't have to access any `Resource` during runtime as they only store static data that describe the state machine, accessing `StackPlayer`/`StateMachinePlayer` alone should be sufficient.
- [State](src/states/State.gd)
> Resource that represent a state
- `name`
- [StateMachine](src/states/StateMachine.gd)(`extends State`) ![StateMachine icon](assets/icons/state_machine_icon.png)
> `StateMachine` is also a `State`, but mainly used as container of `State`s and `Transitions`s
- `states`
- `transitions`
- [Transition](src/transitions/Transition.gd)
> Describing connection from one state to another, all conditions must be fulfilled to transit to next state
- `from`
- `to`
- `conditions`
- [Condition](src/conditions/Condition.gd)
> Empty condition with just a name, treated as trigger
- `name`
- [ValueCondition](src/conditions/ValueCondition.gd)(`extends Condition`)
> Condition with value, fulfilled by comparing values based on comparation
- `comparation`
- `value`
- [BooleanCondition](src/conditions/BooleanCondition.gd)(`extends ValueCondition`)
- [IntegerCondition](src/conditions/IntegerCondition.gd)(`extends ValueCondition`)
- [FloatCondition](src/conditions/FloatCondition.gd)(`extends ValueCondition`)
- [StringCondition](src/conditions/StringCondition.gd)(`extends ValueCondition`)

View File

@ -0,0 +1,20 @@
# Node
const StackPlayer = preload("src/StackPlayer.gd")
const StateMachinePlayer = preload("src/StateMachinePlayer.gd")
# Reference
const StateDirectory = preload("src/StateDirectory.gd")
# Resources
# States
const State = preload("src/states/State.gd")
const StateMachine = preload("src/states/StateMachine.gd")
# Transitions
const Transition = preload("src/transitions/Transition.gd")
# Conditions
const Condition = preload("src/conditions/Condition.gd")
const ValueCondition = preload("src/conditions/ValueCondition.gd")
const BooleanCondition = preload("src/conditions/BooleanCondition.gd")
const IntegerCondition = preload("src/conditions/IntegerCondition.gd")
const FloatCondition = preload("src/conditions/FloatCondition.gd")
const StringCondition = preload("src/conditions/StringCondition.gd")

View File

@ -0,0 +1,5 @@
[gd_resource type="SystemFont" format=3 uid="uid://dmcxm8gxsonbq"]
[resource]
font_names = PackedStringArray("Sans-Serif")
multichannel_signed_distance_field = true

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="18px" height="18px"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>

After

Width:  |  Height:  |  Size: 190 B

View File

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dg8cmn5ubq6r5"
path="res://.godot/imported/add-white-18dp.svg-06b50d941748dbfd6e0203dec68494ea.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/imjp94.yafsm/assets/icons/add-white-18dp.svg"
dest_files=["res://.godot/imported/add-white-18dp.svg-06b50d941748dbfd6e0203dec68494ea.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
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/fix_alpha_border=false
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 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="18px" height="18px"><path d="M10 17l5-5-5-5v10z"/><path d="M0 24V0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 176 B

View File

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://yw43hcwiudst"
path="res://.godot/imported/arrow_right-white-18dp.svg-10d349447e9bd513637eade1f10225f0.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/imjp94.yafsm/assets/icons/arrow_right-white-18dp.svg"
dest_files=["res://.godot/imported/arrow_right-white-18dp.svg-10d349447e9bd513637eade1f10225f0.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
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/fix_alpha_border=false
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=4.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="18px" height="18px"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>

After

Width:  |  Height:  |  Size: 256 B

View File

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://l78bjwo7shm"
path="res://.godot/imported/close-white-18dp.svg-3d0e2341eb99a6dc45a6aecef969301b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/imjp94.yafsm/assets/icons/close-white-18dp.svg"
dest_files=["res://.godot/imported/close-white-18dp.svg-3d0e2341eb99a6dc45a6aecef969301b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
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/fix_alpha_border=false
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 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24" fill="white" width="18px" height="18px"><g><rect fill="none" height="24" width="24" x="0"/></g><g><g><g><path d="M9.01,14H2v2h7.01v3L13,15l-3.99-4V14z M14.99,13v-3H22V8h-7.01V5L11,9L14.99,13z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 306 B

View File

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cnkaa2ky1f4jq"
path="res://.godot/imported/compare_arrows-white-18dp.svg-7313ec3b54f05c948521b16e0efaaeed.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/imjp94.yafsm/assets/icons/compare_arrows-white-18dp.svg"
dest_files=["res://.godot/imported/compare_arrows-white-18dp.svg-7313ec3b54f05c948521b16e0efaaeed.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
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/fix_alpha_border=false
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 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="18px" height="18px"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 13H5v-2h14v2z"/></svg>

After

Width:  |  Height:  |  Size: 172 B

View File

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://p2md5n42lcqj"
path="res://.godot/imported/remove-white-18dp.svg-984af3406d3d64ea0f778da7f0f5a4c3.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/imjp94.yafsm/assets/icons/remove-white-18dp.svg"
dest_files=["res://.godot/imported/remove-white-18dp.svg-984af3406d3d64ea0f778da7f0f5a4c3.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
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/fix_alpha_border=false
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 B

View File

@ -0,0 +1,34 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bcc8ni3mjf55j"
path="res://.godot/imported/stack_player_icon.png-bf093c6193b73dc7a03c728b884edd0b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/imjp94.yafsm/assets/icons/stack_player_icon.png"
dest_files=["res://.godot/imported/stack_player_icon.png-bf093c6193b73dc7a03c728b884edd0b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
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/fix_alpha_border=false
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.

After

Width:  |  Height:  |  Size: 883 B

View File

@ -0,0 +1,34 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://quofx2t3tj1b"
path="res://.godot/imported/state_machine_icon.png-9917b22df6299aea6994b92cacbcef16.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/imjp94.yafsm/assets/icons/state_machine_icon.png"
dest_files=["res://.godot/imported/state_machine_icon.png-9917b22df6299aea6994b92cacbcef16.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
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/fix_alpha_border=false
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.

After

Width:  |  Height:  |  Size: 947 B

View File

@ -0,0 +1,34 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://crcg0exl13kdd"
path="res://.godot/imported/state_machine_player_icon.png-12d6c36cda302327e8c107292c578aa4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/imjp94.yafsm/assets/icons/state_machine_player_icon.png"
dest_files=["res://.godot/imported/state_machine_player_icon.png-12d6c36cda302327e8c107292c578aa4.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
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/fix_alpha_border=false
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=0

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" width="18px" height="18px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 15l-6 6-1.42-1.42L15.17 16H4V4h2v10h9.17l-3.59-3.58L13 9l6 6z"/></svg>

After

Width:  |  Height:  |  Size: 222 B

View File

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b2coah58shtq1"
path="res://.godot/imported/subdirectory_arrow_right-white-18dp.svg-09b2961410e6b2c0e48e0cf1138c3548.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/imjp94.yafsm/assets/icons/subdirectory_arrow_right-white-18dp.svg"
dest_files=["res://.godot/imported/subdirectory_arrow_right-white-18dp.svg-09b2961410e6b2c0e48e0cf1138c3548.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
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/fix_alpha_border=false
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,7 @@
[plugin]
name="gd-YAFSM"
description="Yet Another Finite State Machine"
author="imjp94"
version="0.6.2"
script="plugin.gd"

View File

@ -0,0 +1,150 @@
@tool
extends EditorPlugin
const YAFSM = preload("YAFSM.gd")
const StackPlayer = YAFSM.StackPlayer
const StateMachinePlayer = YAFSM.StateMachinePlayer
const StateMachineEditor = preload("scenes/StateMachineEditor.tscn")
const TransitionInspector = preload("scenes/transition_editors/TransitionInspector.gd")
const StateInspector = preload("scenes/state_nodes/StateInspector.gd")
const StackPlayerIcon = preload("assets/icons/stack_player_icon.png")
const StateMachinePlayerIcon = preload("assets/icons/state_machine_player_icon.png")
var state_machine_editor = StateMachineEditor.instantiate()
var transition_inspector = TransitionInspector.new()
var state_inspector = StateInspector.new()
var focused_object: # Can be StateMachine/StateMachinePlayer
set = set_focused_object
var editor_selection
var _handled_and_ready_to_edit = false # forces _handles => _edit flow
func _enter_tree():
editor_selection = get_editor_interface().get_selection()
editor_selection.selection_changed.connect(_on_EditorSelection_selection_changed)
var editor_base_control = get_editor_interface().get_base_control()
add_custom_type("StackPlayer", "Node", StackPlayer, StackPlayerIcon)
add_custom_type("StateMachinePlayer", "Node", StateMachinePlayer, StateMachinePlayerIcon)
state_machine_editor.selection_stylebox.bg_color = editor_base_control.get_theme_color("box_selection_fill_color", "Editor")
state_machine_editor.selection_stylebox.border_color = editor_base_control.get_theme_color("box_selection_stroke_color", "Editor")
state_machine_editor.zoom_minus.icon = editor_base_control.get_theme_icon("ZoomLess", "EditorIcons")
state_machine_editor.zoom_reset.icon = editor_base_control.get_theme_icon("ZoomReset", "EditorIcons")
state_machine_editor.zoom_plus.icon = editor_base_control.get_theme_icon("ZoomMore", "EditorIcons")
state_machine_editor.snap_button.icon = editor_base_control.get_theme_icon("SnapGrid", "EditorIcons")
state_machine_editor.condition_visibility.texture_pressed = editor_base_control.get_theme_icon("GuiVisibilityVisible", "EditorIcons")
state_machine_editor.condition_visibility.texture_normal = editor_base_control.get_theme_icon("GuiVisibilityHidden", "EditorIcons")
state_machine_editor.editor_accent_color = editor_base_control.get_theme_color("accent_color", "Editor")
state_machine_editor.current_layer.editor_accent_color = state_machine_editor.editor_accent_color
state_machine_editor.transition_arrow_icon = editor_base_control.get_theme_icon("TransitionImmediateBig", "EditorIcons")
state_machine_editor.inspector_changed.connect(_on_inspector_changed)
state_machine_editor.node_selected.connect(_on_StateMachineEditor_node_selected)
state_machine_editor.node_deselected.connect(_on_StateMachineEditor_node_deselected)
state_machine_editor.debug_mode_changed.connect(_on_StateMachineEditor_debug_mode_changed)
# Force anti-alias for default font, so rotated text will looks smoother
var font = editor_base_control.get_theme_font("main", "EditorFonts")
# font.use_filter = true
transition_inspector.undo_redo = get_undo_redo()
transition_inspector.transition_icon = editor_base_control.get_theme_icon("ToolConnect", "EditorIcons")
add_inspector_plugin(transition_inspector)
add_inspector_plugin(state_inspector)
func _exit_tree():
remove_custom_type("StackPlayer")
remove_custom_type("StateMachinePlayer")
remove_inspector_plugin(transition_inspector)
remove_inspector_plugin(state_inspector)
if state_machine_editor:
state_machine_editor.queue_free()
func _handles(object):
if object is StateMachine:
_handled_and_ready_to_edit = true # this should not be necessary, but it seemingly is (Godot 4.0-rc1)
return true # when return true from _handles, _edit can proceed.
if object is StateMachinePlayer:
if object.get_class() == "EditorDebuggerRemoteObject":
set_focused_object(object)
state_machine_editor.debug_mode = true
return false
return false
func _edit(object):
if _handled_and_ready_to_edit: # Forces _handles => _edit flow. This should not be necessary, but it seemingly is (Godot 4.0-rc1)
_handled_and_ready_to_edit = false
set_focused_object(object)
func show_state_machine_editor():
if focused_object and state_machine_editor:
if not state_machine_editor.is_inside_tree():
add_control_to_bottom_panel(state_machine_editor, "StateMachine")
make_bottom_panel_item_visible(state_machine_editor)
func hide_state_machine_editor():
if state_machine_editor.is_inside_tree():
state_machine_editor.state_machine = null
remove_control_from_bottom_panel(state_machine_editor)
func _on_EditorSelection_selection_changed():
if editor_selection == null:
return
var selected_nodes = editor_selection.get_selected_nodes()
if selected_nodes.size() == 1:
var selected_node = selected_nodes[0]
if selected_node is StateMachinePlayer:
set_focused_object(selected_node)
return
set_focused_object(null)
func _on_focused_object_changed(new_obj):
if new_obj:
# Must be shown first, otherwise StateMachineEditor can't execute ui action as it is not added to scene tree
show_state_machine_editor()
var state_machine
if focused_object is StateMachinePlayer:
if focused_object.get_class() == "EditorDebuggerRemoteObject":
state_machine = focused_object.get("Members/state_machine")
else:
state_machine = focused_object.state_machine
state_machine_editor.state_machine_player = focused_object
elif focused_object is StateMachine:
state_machine = focused_object
state_machine_editor.state_machine_player = null
state_machine_editor.state_machine = state_machine
else:
hide_state_machine_editor()
func _on_inspector_changed(property):
#get_editor_interface().get_inspector().refresh()
notify_property_list_changed()
func _on_StateMachineEditor_node_selected(node):
var to_inspect
if "state" in node:
if node.state is StateMachine: # Ignore, inspect state machine will trigger edit()
return
to_inspect = node.state
elif "transition" in node:
to_inspect = node.transition
get_editor_interface().inspect_object(to_inspect)
func _on_StateMachineEditor_node_deselected(node):
# editor_selection.remove_node(node)
get_editor_interface().inspect_object(state_machine_editor.state_machine)
func _on_StateMachineEditor_debug_mode_changed(new_debug_mode):
if not new_debug_mode:
state_machine_editor.debug_mode = false
state_machine_editor.state_machine_player = null
set_focused_object(null)
hide_state_machine_editor()
func set_focused_object(obj):
if focused_object != obj:
focused_object = obj
_on_focused_object_changed(obj)

View File

@ -0,0 +1,12 @@
[gd_scene format=3 uid="uid://cflltb00e10be"]
[node name="ContextMenu" type="PopupMenu"]
size = Vector2i(104, 100)
visible = true
item_count = 3
item_0/text = "Add State"
item_0/id = 0
item_1/text = "Add Entry"
item_1/id = 1
item_2/text = "Add Exit"
item_2/id = 2

View File

@ -0,0 +1,63 @@
@tool
extends MarginContainer
@onready var grid = $PanelContainer/MarginContainer/VBoxContainer/GridContainer
@onready var button = $PanelContainer/MarginContainer/VBoxContainer/MarginContainer/Button
func _ready():
button.pressed.connect(_on_button_pressed)
func update_params(params, local_params):
# Remove erased parameters from param panel
for param in grid.get_children():
if not (param.name in params):
remove_param(param.name)
for param in params:
var value = params[param]
if value == null: # Ignore trigger
continue
set_param(param, str(value))
# Remove erased local parameters from param panel
for param in grid.get_children():
if not (param.name in local_params) and not (param.name in params):
remove_param(param.name)
for param in local_params:
var nested_params = local_params[param]
for nested_param in nested_params:
var value = nested_params[nested_param]
if value == null: # Ignore trigger
continue
set_param(str(param, "/", nested_param), str(value))
func set_param(param, value):
var label = grid.get_node_or_null(NodePath(param))
if not label:
label = Label.new()
label.name = param
grid.add_child(label)
label.text = "%s = %s" % [param, value]
func remove_param(param):
var label = grid.get_node_or_null(NodePath(param))
if label:
grid.remove_child(label)
label.queue_free()
set_anchors_preset(PRESET_BOTTOM_RIGHT)
func clear_params():
for child in grid.get_children():
grid.remove_child(child)
child.queue_free()
func _on_button_pressed():
grid.visible = !grid.visible
if grid.visible:
button.text = "Hide params"
else:
button.text = "Show params"
set_anchors_preset(PRESET_BOTTOM_RIGHT)

View File

@ -0,0 +1,64 @@
@tool
extends HBoxContainer
signal dir_pressed(dir, index)
func _init():
add_dir("root")
# Select parent dir & return its path
func back():
return select_dir(get_child(max(get_child_count()-1 - 1, 0)).name)
# Select dir & return its path
func select_dir(dir):
for i in get_child_count():
var child = get_child(i)
if child.name == dir:
remove_dir_until(i)
return get_dir_until(i)
# Add directory button
func add_dir(dir):
var button = Button.new()
button.name = dir
button.flat = true
button.text = dir
add_child(button)
button.pressed.connect(_on_button_pressed.bind(button))
return button
# Remove directory until index(exclusive)
func remove_dir_until(index):
var to_remove = []
for i in get_child_count():
if index == get_child_count()-1 - i:
break
var child = get_child(get_child_count()-1 - i)
to_remove.append(child)
for n in to_remove:
remove_child(n)
n.queue_free()
# Return current working directory
func get_cwd():
return get_dir_until(get_child_count()-1)
# Return path until index(inclusive) of directory
func get_dir_until(index):
var path = ""
for i in get_child_count():
if i > index:
break
var child = get_child(i)
if i == 0:
path = "root"
else:
path = str(path, "/", child.text)
return path
func _on_button_pressed(button):
var index = button.get_index()
var dir = button.name
emit_signal("dir_pressed", dir, index)

View File

@ -0,0 +1,746 @@
@tool
extends "res://addons/imjp94.yafsm/scenes/flowchart/FlowChart.gd"
const StateMachinePlayer = preload("../src/StateMachinePlayer.gd")
const StateMachine = preload("../src/states/StateMachine.gd")
const Transition = preload("../src/transitions/Transition.gd")
const State = preload("../src/states/State.gd")
const StateDirectory = preload("../src/StateDirectory.gd")
const StateNode = preload("state_nodes/StateNode.tscn")
const TransitionLine = preload("transition_editors/TransitionLine.tscn")
const StateNodeScript = preload("state_nodes/StateNode.gd")
const StateMachineEditorLayer = preload("StateMachineEditorLayer.gd")
const PathViewer = preload("PathViewer.gd")
signal inspector_changed(property) # Inform plugin to refresh inspector
signal debug_mode_changed(new_debug_mode)
const ENTRY_STATE_MISSING_MSG = {
"key": "entry_state_missing",
"text": "Entry State missing, it will never get started. Right-click -> \"Add Entry\"."
}
const EXIT_STATE_MISSING_MSG = {
"key": "exit_state_missing",
"text": "Exit State missing, it will never exit from nested state. Right-click -> \"Add Exit\"."
}
const DEBUG_MODE_MSG = {
"key": "debug_mode",
"text": "Debug Mode"
}
@onready var context_menu = $ContextMenu
@onready var state_node_context_menu = $StateNodeContextMenu
@onready var convert_to_state_confirmation = $ConvertToStateConfirmation
@onready var save_dialog = $SaveDialog
@onready var create_new_state_machine_container = $MarginContainer
@onready var create_new_state_machine = $MarginContainer/CreateNewStateMachine
@onready var param_panel = $ParametersPanel
var path_viewer = HBoxContainer.new()
var condition_visibility = TextureButton.new()
var unsaved_indicator = Label.new()
var message_box = VBoxContainer.new()
var editor_accent_color = Color.WHITE
var transition_arrow_icon
var undo_redo
var debug_mode: = false:
set = set_debug_mode
var state_machine_player:
set = set_state_machine_player
var state_machine:
set = set_state_machine
var can_gui_name_edit = true
var can_gui_context_menu = true
var _reconnecting_connection
var _last_index = 0
var _last_path = ""
var _message_box_dict = {}
var _context_node
var _current_state = ""
var _last_stack = []
func _init():
super._init()
path_viewer.mouse_filter = MOUSE_FILTER_IGNORE
path_viewer.set_script(PathViewer)
path_viewer.dir_pressed.connect(_on_path_viewer_dir_pressed)
top_bar.add_child(path_viewer)
condition_visibility.tooltip_text = "Hide/Show Conditions on Transition Line"
condition_visibility.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT_CENTERED
condition_visibility.toggle_mode = true
condition_visibility.size_flags_vertical = SIZE_SHRINK_CENTER
condition_visibility.focus_mode = FOCUS_NONE
condition_visibility.pressed.connect(_on_condition_visibility_pressed)
condition_visibility.button_pressed = true
gadget.add_child(condition_visibility)
unsaved_indicator.size_flags_vertical = SIZE_SHRINK_CENTER
unsaved_indicator.focus_mode = FOCUS_NONE
gadget.add_child(unsaved_indicator)
message_box.set_anchors_and_offsets_preset(PRESET_BOTTOM_WIDE)
message_box.grow_vertical = GROW_DIRECTION_BEGIN
add_child(message_box)
content.get_child(0).name = "root"
set_process(false)
func _ready():
create_new_state_machine_container.visible = false
create_new_state_machine.pressed.connect(_on_create_new_state_machine_pressed)
context_menu.index_pressed.connect(_on_context_menu_index_pressed)
state_node_context_menu.index_pressed.connect(_on_state_node_context_menu_index_pressed)
convert_to_state_confirmation.confirmed.connect(_on_convert_to_state_confirmation_confirmed)
save_dialog.confirmed.connect(_on_save_dialog_confirmed)
func _process(delta):
if not debug_mode:
set_process(false)
return
if not is_instance_valid(state_machine_player):
set_process(false)
set_debug_mode(false)
return
var stack = state_machine_player.get("Members/StackPlayer.gd/stack")
if ((stack == []) or (stack==null)):
set_process(false)
set_debug_mode(false)
return
if stack.size() == 1:
set_current_state(state_machine_player.get("Members/StackPlayer.gd/current"))
else:
var stack_max_index = stack.size() - 1
var prev_index = stack.find(_current_state)
if prev_index == -1:
if _last_stack.size() < stack.size():
# Reproduce transition, for example:
# [Entry, Idle, Walk]
# [Entry, Idle, Jump, Fall]
# Walk -> Idle
# Idle -> Jump
# Jump -> Fall
var common_index = -1
for i in _last_stack.size():
if _last_stack[i] == stack[i]:
common_index = i
break
if common_index > -1:
var count_from_last_stack = _last_stack.size()-1 - common_index -1
_last_stack.reverse()
# Transit back to common state
for i in count_from_last_stack:
set_current_state(_last_stack[i + 1])
# Transit to all missing state in current stack
for i in range(common_index + 1, stack.size()):
set_current_state(stack[i])
else:
set_current_state(stack.back())
else:
set_current_state(stack.back())
else:
# Set every skipped state
var missing_count = stack_max_index - prev_index
for i in range(1, missing_count + 1):
set_current_state(stack[prev_index + i])
_last_stack = stack
var params = state_machine_player.get("Members/_parameters")
var local_params = state_machine_player.get("Members/_local_parameters")
param_panel.update_params(params, local_params)
get_focused_layer(_current_state).debug_update(_current_state, params, local_params)
func _on_path_viewer_dir_pressed(dir, index):
var path = path_viewer.select_dir(dir)
select_layer(get_layer(path))
if _last_index > index:
# Going backward
var end_state_parent_path = StateMachinePlayer.path_backward(_last_path)
var end_state_name = StateMachinePlayer.path_end_dir(_last_path)
var layer = content.get_node_or_null(NodePath(end_state_parent_path))
if layer:
var node = layer.content_nodes.get_node_or_null(NodePath(end_state_name))
if node:
var cond_1 = (not ("states" in node.state)) or (node.state.states=={}) # states property not defined or empty
# Now check if, for some reason, there are an Entry and/or an Exit node inside this node
# not registered in the states variable above.
var nested_layer = content.get_node_or_null(NodePath(_last_path))
var cond_2 = (nested_layer.content_nodes.get_node_or_null(NodePath(State.ENTRY_STATE)) == null) # there is no entry state in the node
var cond_3 = (nested_layer.content_nodes.get_node_or_null(NodePath(State.EXIT_STATE)) == null) # there is no exit state in the node
if (cond_1 and cond_2 and cond_3):
# Convert state machine node back to state node
convert_to_state(layer, node)
_last_index = index
_last_path = path
func _on_context_menu_index_pressed(index):
var new_node = StateNode.instantiate()
new_node.theme.get_stylebox("focus", "FlowChartNode").border_color = editor_accent_color
match index:
0: # Add State
## Handle state name duplication (4.x changed how duplicates are
## automatically handled and gave a random index instead of
## a progressive one)
var default_new_state_name = "State"
var state_dup_index = 0
var new_name = default_new_state_name
for state_name in current_layer.state_machine.states:
if (state_name == new_name):
state_dup_index += 1
new_name = "%s%s" % [default_new_state_name, state_dup_index]
new_node.name = new_name
1: # Add Entry
if State.ENTRY_STATE in current_layer.state_machine.states:
push_warning("Entry node already exist")
return
new_node.name = State.ENTRY_STATE
2: # Add Exit
if State.EXIT_STATE in current_layer.state_machine.states:
push_warning("Exit node already exist")
return
new_node.name = State.EXIT_STATE
new_node.position = content_position(get_local_mouse_position())
add_node(current_layer, new_node)
func _on_state_node_context_menu_index_pressed(index):
if not _context_node:
return
match index:
0: # Copy
_copying_nodes = [_context_node]
_context_node = null
1: # Duplicate
duplicate_nodes(current_layer, [_context_node])
_context_node = null
2: # Delete
remove_node(current_layer, _context_node.name)
for connection_pair in current_layer.get_connection_list():
if connection_pair.from == _context_node.name or connection_pair.to == _context_node.name:
disconnect_node(current_layer, connection_pair.from, connection_pair.to).queue_free()
_context_node = null
3: # Separator
_context_node = null
4: # Convert
convert_to_state_confirmation.popup_centered()
func _on_convert_to_state_confirmation_confirmed():
convert_to_state(current_layer, _context_node)
_context_node.queue_redraw() # Update outlook of node
# Remove layer
var path = str(path_viewer.get_cwd(), "/", _context_node.name)
var layer = get_layer(path)
if layer:
layer.queue_free()
_context_node = null
func _on_save_dialog_confirmed():
save()
func _on_create_new_state_machine_pressed():
var new_state_machine = StateMachine.new()
state_machine_player.state_machine = new_state_machine
set_state_machine(new_state_machine)
create_new_state_machine_container.visible = false
check_has_entry()
emit_signal("inspector_changed", "state_machine")
func _on_condition_visibility_pressed():
for line in current_layer.content_lines.get_children():
line.vbox.visible = condition_visibility.button_pressed
func _on_debug_mode_changed(new_debug_mode):
if new_debug_mode:
param_panel.show()
add_message(DEBUG_MODE_MSG.key, DEBUG_MODE_MSG.text)
set_process(true)
# mouse_filter = MOUSE_FILTER_IGNORE
can_gui_select_node = false
can_gui_delete_node = false
can_gui_connect_node = false
can_gui_name_edit = false
can_gui_context_menu = false
else:
param_panel.clear_params()
param_panel.hide()
remove_message(DEBUG_MODE_MSG.key)
set_process(false)
can_gui_select_node = true
can_gui_delete_node = true
can_gui_connect_node = true
can_gui_name_edit = true
can_gui_context_menu = true
func _on_state_machine_player_changed(new_state_machine_player):
if not state_machine_player:
return
if new_state_machine_player.get_class() == "EditorDebuggerRemoteObject":
return
if new_state_machine_player:
create_new_state_machine_container.visible = !new_state_machine_player.state_machine
else:
create_new_state_machine_container.visible = false
func _on_state_machine_changed(new_state_machine):
var root_layer = get_layer("root")
path_viewer.select_dir("root") # Before select_layer, so path_viewer will be updated in _on_layer_selected
select_layer(root_layer)
clear_graph(root_layer)
# Reset layers & path viewer
for child in root_layer.get_children():
if child is FlowChartLayer:
root_layer.remove_child(child)
child.queue_free()
if new_state_machine:
root_layer.state_machine = state_machine
var validated = StateMachine.validate(new_state_machine)
if validated:
print_debug("gd-YAFSM: Corrupted StateMachine Resource fixed, save to apply the fix.")
draw_graph(root_layer)
check_has_entry()
func _gui_input(event):
super._gui_input(event)
if event is InputEventMouseButton:
match event.button_index:
MOUSE_BUTTON_RIGHT:
if event.pressed and can_gui_context_menu:
context_menu.set_item_disabled(1, current_layer.state_machine.has_entry())
context_menu.set_item_disabled(2, current_layer.state_machine.has_exit())
context_menu.position = get_window().position + Vector2i(get_viewport().get_mouse_position())
context_menu.popup()
func _input(event):
# Intercept save action
if visible:
if event is InputEventKey:
match event.keycode:
KEY_S:
if event.ctrl_pressed and event.pressed:
save_request()
func create_layer(node):
# Create/Move to new layer
var new_state_machine = convert_to_state_machine(current_layer, node)
# Determine current layer path
var parent_path = path_viewer.get_cwd()
var path = str(parent_path, "/", node.name)
var layer = get_layer(path)
path_viewer.add_dir(node.state.name) # Before select_layer, so path_viewer will be updated in _on_layer_selected
if not layer:
# New layer to spawn
layer = add_layer_to(get_layer(parent_path))
layer.name = node.state.name
layer.state_machine = new_state_machine
draw_graph(layer)
_last_index = path_viewer.get_child_count()-1
_last_path = path
return layer
func open_layer(path):
var dir = StateDirectory.new(path)
dir.goto(dir.get_end_index())
dir.back()
var next_layer = get_next_layer(dir, get_layer("root"))
select_layer(next_layer)
return next_layer
# Recursively get next layer
func get_next_layer(dir, base_layer):
var next_layer = base_layer
var np = dir.next()
if np:
next_layer = base_layer.get_node_or_null(NodePath(np))
if next_layer:
next_layer = get_next_layer(dir, next_layer)
else:
var to_dir = StateDirectory.new(dir.get_current())
to_dir.goto(to_dir.get_end_index())
to_dir.back()
var node = base_layer.content_nodes.get_node_or_null(NodePath(to_dir.get_current_end()))
next_layer = get_next_layer(dir, create_layer(node))
return next_layer
func get_focused_layer(state):
var current_dir = StateDirectory.new(state)
current_dir.goto(current_dir.get_end_index())
current_dir.back()
return get_layer(str("root/", current_dir.get_current()))
func _on_state_node_gui_input(event, node):
if node.state.is_entry() or node.state.is_exit():
return
if event is InputEventMouseButton:
match event.button_index:
MOUSE_BUTTON_LEFT:
if event.pressed:
if event.double_click:
if node.name_edit.get_rect().has_point(event.position) and can_gui_name_edit:
# Edit State name if within LineEdit
node.enable_name_edit(true)
accept_event()
else:
var layer = create_layer(node)
select_layer(layer)
accept_event()
MOUSE_BUTTON_RIGHT:
if event.pressed:
# State node context menu
_context_node = node
state_node_context_menu.position = get_window().position + Vector2i(get_viewport().get_mouse_position())
state_node_context_menu.popup()
state_node_context_menu.set_item_disabled(4, not (node.state is StateMachine))
accept_event()
func convert_to_state_machine(layer, node):
# Convert State to StateMachine
var new_state_machine
if node.state is StateMachine:
new_state_machine = node.state
else:
new_state_machine = StateMachine.new()
new_state_machine.name = node.state.name
new_state_machine.graph_offset = node.state.graph_offset
layer.state_machine.remove_state(node.state.name)
layer.state_machine.add_state(new_state_machine)
node.state = new_state_machine
return new_state_machine
func convert_to_state(layer, node):
# Convert StateMachine to State
var new_state
if node.state is StateMachine:
new_state = State.new()
new_state.name = node.state.name
new_state.graph_offset = node.state.graph_offset
layer.state_machine.remove_state(node.state.name)
layer.state_machine.add_state(new_state)
node.state = new_state
else:
new_state = node.state
return new_state
func create_layer_instance():
var layer = Control.new()
layer.set_script(StateMachineEditorLayer)
layer.editor_accent_color = editor_accent_color
return layer
func create_line_instance():
var line = TransitionLine.instantiate()
line.theme.get_stylebox("focus", "FlowChartLine").shadow_color = editor_accent_color
line.theme.set_icon("arrow", "FlowChartLine", transition_arrow_icon)
return line
# Request to save current editing StateMachine
func save_request():
if not can_save():
return
save_dialog.dialog_text = "Saving StateMachine to %s" % state_machine.resource_path
save_dialog.popup_centered()
# Save current editing StateMachine
func save():
if not can_save():
return
unsaved_indicator.text = ""
ResourceSaver.save(state_machine, state_machine.resource_path)
# Clear editor
func clear_graph(layer):
clear_connections()
for child in layer.content_nodes.get_children():
if child is StateNodeScript:
layer.content_nodes.remove_child(child)
child.queue_free()
queue_redraw()
unsaved_indicator.text = "" # Clear graph is not action by user
# Intialize editor with current editing StateMachine
func draw_graph(layer):
for state_key in layer.state_machine.states.keys():
var state = layer.state_machine.states[state_key]
var new_node = StateNode.instantiate()
new_node.theme.get_stylebox("focus", "FlowChartNode").border_color = editor_accent_color
new_node.name = state_key # Set before add_node to let engine handle duplicate name
add_node(layer, new_node)
# Set after add_node to make sure UIs are initialized
new_node.state = state
new_node.state.name = state_key
new_node.position = state.graph_offset
for state_key in layer.state_machine.states.keys():
var from_transitions = layer.state_machine.transitions.get(state_key)
if from_transitions:
for transition in from_transitions.values():
connect_node(layer, transition.from, transition.to)
layer._connections[transition.from][transition.to].line.transition = transition
queue_redraw()
unsaved_indicator.text = "" # Draw graph is not action by user
# Add message to message_box(overlay text at bottom of editor)
func add_message(key, text):
var label = Label.new()
label.text = text
_message_box_dict[key] = label
message_box.add_child(label)
return label
# Remove message from message_box
func remove_message(key):
var control = _message_box_dict.get(key)
if control:
_message_box_dict.erase(key)
message_box.remove_child(control)
# Weird behavior of VBoxContainer, only sort children properly after changing grow_direction
message_box.grow_vertical = GROW_DIRECTION_END
message_box.grow_vertical = GROW_DIRECTION_BEGIN
return true
return false
# Check if current editing StateMachine has entry, warns user if entry state missing
func check_has_entry():
if not current_layer.state_machine:
return
if not current_layer.state_machine.has_entry():
if not (ENTRY_STATE_MISSING_MSG.key in _message_box_dict):
add_message(ENTRY_STATE_MISSING_MSG.key, ENTRY_STATE_MISSING_MSG.text)
else:
if ENTRY_STATE_MISSING_MSG.key in _message_box_dict:
remove_message(ENTRY_STATE_MISSING_MSG.key)
# Check if current editing StateMachine is nested and has exit, warns user if exit state missing
func check_has_exit():
if not current_layer.state_machine:
return
if not path_viewer.get_cwd() == "root": # Nested state
if not current_layer.state_machine.has_exit():
if not (EXIT_STATE_MISSING_MSG.key in _message_box_dict):
add_message(EXIT_STATE_MISSING_MSG.key, EXIT_STATE_MISSING_MSG.text)
return
if EXIT_STATE_MISSING_MSG.key in _message_box_dict:
remove_message(EXIT_STATE_MISSING_MSG.key)
func _on_layer_selected(layer):
if layer:
layer.show_content()
check_has_entry()
check_has_exit()
func _on_layer_deselected(layer):
if layer:
layer.hide_content()
func _on_node_dragged(layer, node, dragged):
node.state.graph_offset = node.position
_on_edited()
func _on_node_added(layer, new_node):
# Godot 4 duplicates node with an internal @ name, which breaks everything
while String(new_node.name).begins_with("@"):
new_node.name = String(new_node.name).lstrip("@")
new_node.undo_redo = undo_redo
new_node.state.name = new_node.name
new_node.state.graph_offset = new_node.position
new_node.name_edit_entered.connect(_on_node_name_edit_entered.bind(new_node))
new_node.gui_input.connect(_on_state_node_gui_input.bind(new_node))
layer.state_machine.add_state(new_node.state)
check_has_entry()
check_has_exit()
_on_edited()
func _on_node_removed(layer, node_name):
var path = str(path_viewer.get_cwd(), "/", node_name)
var layer_to_remove = get_layer(path)
if layer_to_remove:
layer_to_remove.get_parent().remove_child(layer_to_remove)
layer_to_remove.queue_free()
var result = layer.state_machine.remove_state(node_name)
check_has_entry()
check_has_exit()
_on_edited()
return result
func _on_node_connected(layer, from, to):
if _reconnecting_connection:
# Reconnection will trigger _on_node_connected after _on_node_reconnect_end/_on_node_reconnect_failed
if is_instance_valid(_reconnecting_connection.from_node) and \
_reconnecting_connection.from_node.name == from and \
is_instance_valid(_reconnecting_connection.to_node) and \
_reconnecting_connection.to_node.name == to:
_reconnecting_connection = null
return
if layer.state_machine.transitions.has(from):
if layer.state_machine.transitions[from].has(to):
return # Already existed as it is loaded from file
var line = layer._connections[from][to].line
var new_transition = Transition.new(from, to)
line.transition = new_transition
layer.state_machine.add_transition(new_transition)
clear_selection()
select(line)
_on_edited()
func _on_node_disconnected(layer, from, to):
layer.state_machine.remove_transition(from, to)
_on_edited()
func _on_node_reconnect_begin(layer, from, to):
_reconnecting_connection = layer._connections[from][to]
layer.state_machine.remove_transition(from, to)
func _on_node_reconnect_end(layer, from, to):
var transition = _reconnecting_connection.line.transition
transition.to = to
layer.state_machine.add_transition(transition)
clear_selection()
select(_reconnecting_connection.line)
func _on_node_reconnect_failed(layer, from, to):
var transition = _reconnecting_connection.line.transition
layer.state_machine.add_transition(transition)
clear_selection()
select(_reconnecting_connection.line)
func _request_connect_from(layer, from):
if from == State.EXIT_STATE:
return false
return true
func _request_connect_to(layer, to):
if to == State.ENTRY_STATE:
return false
return true
func _on_duplicated(layer, old_nodes, new_nodes):
# Duplicate condition as well
for i in old_nodes.size():
var from_node = old_nodes[i]
for connection_pair in get_connection_list():
if from_node.name == connection_pair.from:
for j in old_nodes.size():
var to_node = old_nodes[j]
if to_node.name == connection_pair.to:
var old_connection = layer._connections[connection_pair.from][connection_pair.to]
var new_connection = layer._connections[new_nodes[i].name][new_nodes[j].name]
for condition in old_connection.line.transition.conditions.values():
new_connection.line.transition.add_condition(condition.duplicate())
_on_edited()
func _on_node_name_edit_entered(new_name, node):
var old = node.state.name
var new = new_name
if old == new:
return
if "/" in new or "\\" in new: # No back/forward-slash
push_warning("Illegal State Name: / and \\ are not allowed in State name(%s)" % new)
node.name_edit.text = old
return
if current_layer.state_machine.change_state_name(old, new):
rename_node(current_layer, node.name, new)
node.name = new
# Rename layer as well
var path = str(path_viewer.get_cwd(), "/", node.name)
var layer = get_layer(path)
if layer:
layer.name = new
for child in path_viewer.get_children():
if child.text == old:
child.text = new
break
_on_edited()
else:
node.name_edit.text = old
func _on_edited():
unsaved_indicator.text = "*"
func _on_remote_transited(from, to):
var from_dir = StateDirectory.new(from)
var to_dir = StateDirectory.new(to)
var focused_layer = get_focused_layer(from)
if from:
if focused_layer:
focused_layer.debug_transit_out(from, to)
if to:
if from_dir.is_nested() and from_dir.is_exit():
if focused_layer:
var path = path_viewer.back()
select_layer(get_layer(path))
elif to_dir.is_nested():
if to_dir.is_entry() and focused_layer:
# Open into next layer
to_dir.goto(to_dir.get_end_index())
to_dir.back()
var node = focused_layer.content_nodes.get_node_or_null(NodePath(to_dir.get_current_end()))
if node:
var layer = create_layer(node)
select_layer(layer)
# In case where, "from" state is nested yet not an exit state,
# while "to" state is on different level, then jump to destination layer directly.
# This happens when StateMachinePlayer transit to state that existing in the stack,
# which trigger StackPlayer.reset() and cause multiple states removed from stack within one frame
elif from_dir.is_nested() and not from_dir.is_exit():
if to_dir._dirs.size() != from_dir._dirs.size():
to_dir.goto(to_dir.get_end_index())
var n = to_dir.back()
if not n:
n = "root"
var layer = get_layer(n)
path_viewer.select_dir(layer.name)
select_layer(layer)
focused_layer = get_focused_layer(to)
if not focused_layer:
focused_layer = open_layer(to)
focused_layer.debug_transit_in(from, to)
# Return if current editing StateMachine can be saved, ignore built-in resource
func can_save():
if not state_machine:
return false
var resource_path = state_machine.resource_path
if resource_path.is_empty():
return false
if ".scn" in resource_path or ".tscn" in resource_path: # Built-in resource will be saved by scene
return false
return true
func set_debug_mode(v):
if debug_mode != v:
debug_mode = v
_on_debug_mode_changed(v)
emit_signal("debug_mode_changed", debug_mode)
func set_state_machine_player(smp):
if state_machine_player != smp:
state_machine_player = smp
_on_state_machine_player_changed(smp)
func set_state_machine(sm):
if state_machine != sm:
state_machine = sm
_on_state_machine_changed(sm)
func set_current_state(v):
if _current_state != v:
var from = _current_state
var to = v
_current_state = v
_on_remote_transited(from, to)

View File

@ -0,0 +1,101 @@
[gd_scene load_steps=5 format=3 uid="uid://bp2f3rs2sgn8g"]
[ext_resource type="PackedScene" uid="uid://ccv81pntbud75" path="res://addons/imjp94.yafsm/scenes/StateNodeContextMenu.tscn" id="1"]
[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/StateMachineEditor.gd" id="2"]
[ext_resource type="PackedScene" uid="uid://cflltb00e10be" path="res://addons/imjp94.yafsm/scenes/ContextMenu.tscn" id="3"]
[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/ParametersPanel.gd" id="4"]
[node name="StateMachineEditor" type="Control"]
visible = false
clip_contents = true
custom_minimum_size = Vector2i(0, 200)
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
size_flags_horizontal = 3
size_flags_vertical = 3
focus_mode = 2
mouse_filter = 1
script = ExtResource("2")
[node name="MarginContainer" type="MarginContainer" parent="."]
visible = false
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="Panel" type="Panel" parent="MarginContainer"]
layout_mode = 2
offset_right = 1152.0
offset_bottom = 648.0
[node name="CreateNewStateMachine" type="Button" parent="MarginContainer"]
layout_mode = 2
offset_left = 473.0
offset_top = 308.0
offset_right = 679.0
offset_bottom = 339.0
size_flags_horizontal = 4
size_flags_vertical = 4
theme_override_colors/font_color = Color(0.87451, 0.87451, 0.87451, 1)
text = "Create new StateMachine"
[node name="ContextMenu" parent="." instance=ExtResource("3")]
visible = false
[node name="StateNodeContextMenu" parent="." instance=ExtResource("1")]
visible = false
[node name="SaveDialog" type="ConfirmationDialog" parent="."]
[node name="ConvertToStateConfirmation" type="ConfirmationDialog" parent="."]
dialog_text = "All nested states beneath it will be lost, are you sure about that?"
dialog_autowrap = true
[node name="ParametersPanel" type="MarginContainer" parent="."]
visible = false
layout_mode = 1
anchors_preset = 3
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 0
grow_vertical = 0
script = ExtResource("4")
[node name="PanelContainer" type="PanelContainer" parent="ParametersPanel"]
layout_mode = 2
offset_right = 113.0
offset_bottom = 31.0
[node name="MarginContainer" type="MarginContainer" parent="ParametersPanel/PanelContainer"]
layout_mode = 2
[node name="VBoxContainer" type="VBoxContainer" parent="ParametersPanel/PanelContainer/MarginContainer"]
layout_mode = 2
offset_right = 113.0
offset_bottom = 31.0
[node name="MarginContainer" type="MarginContainer" parent="ParametersPanel/PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
[node name="Button" type="Button" parent="ParametersPanel/PanelContainer/MarginContainer/VBoxContainer/MarginContainer"]
layout_mode = 2
offset_right = 113.0
offset_bottom = 31.0
size_flags_horizontal = 10
text = "Show Params"
[node name="GridContainer" type="GridContainer" parent="ParametersPanel/PanelContainer/MarginContainer/VBoxContainer"]
visible = false
layout_mode = 2
columns = 4

View File

@ -0,0 +1,149 @@
@tool
extends "res://addons/imjp94.yafsm/scenes/flowchart/FlowChartLayer.gd"
const Utils = preload("res://addons/imjp94.yafsm/scripts/Utils.gd")
const StateNode = preload("res://addons/imjp94.yafsm/scenes/state_nodes/StateNode.tscn")
const StateNodeScript = preload("res://addons/imjp94.yafsm/scenes/state_nodes/StateNode.gd")
const StateDirectory = preload("../src/StateDirectory.gd")
var editor_accent_color: = Color.WHITE:
set = set_editor_accent_color
var editor_complementary_color = Color.WHITE
var state_machine
var tween_lines
var tween_labels
var tween_nodes
func debug_update(current_state, parameters, local_parameters):
_init_tweens()
if not state_machine:
return
var current_dir = StateDirectory.new(current_state)
var transitions = state_machine.transitions.get(current_state, {})
if current_dir.is_nested():
transitions = state_machine.transitions.get(current_dir.get_end(), {})
for transition in transitions.values():
# Check all possible transitions from current state, update labels, color them accordingly
var line = content_lines.get_node_or_null(NodePath("%s>%s" % [transition.from, transition.to]))
if line:
# Blinking alpha of TransitionLine
var color1 = Color.WHITE
color1.a = 0.1
var color2 = Color.WHITE
color2.a = 0.5
if line.self_modulate == color1:
tween_lines.tween_property(line, "self_modulate", color2, 0.5)
elif line.self_modulate == color2:
tween_lines.tween_property(line, "self_modulate", color1, 0.5)
elif line.self_modulate == Color.WHITE:
tween_lines.tween_property(line, "self_modulate", color2, 0.5)
# Update TransitionLine condition labels
for condition in transition.conditions.values():
if not ("value" in condition): # Ignore trigger
continue
var value = parameters.get(str(condition.name))
value = str(value) if value != null else "?"
var label = line.vbox.get_node_or_null(NodePath(str(condition.name)))
var override_template_var = line._template_var.get(str(condition.name))
if override_template_var == null:
override_template_var = {}
line._template_var[str(condition.name)] = override_template_var
override_template_var["value"] = str(value)
line.update_label()
# Condition label color based on comparation
var cond_1: bool = condition.compare(parameters.get(str(condition.name)))
var cond_2: bool = condition.compare(local_parameters.get(str(condition.name)))
if cond_1 or cond_2:
tween_labels.tween_property(label, "self_modulate", Color.GREEN.lightened(0.5), 0.01)
else:
tween_labels.tween_property(label, "self_modulate", Color.RED.lightened(0.5), 0.01)
_start_tweens()
func debug_transit_out(from, to):
_init_tweens()
var from_dir = StateDirectory.new(from)
var to_dir = StateDirectory.new(to)
var from_node = content_nodes.get_node_or_null(NodePath(from_dir.get_end()))
if from_node != null:
tween_nodes.tween_property(from_node, "self_modulate", editor_complementary_color, 0.01)
tween_nodes.tween_property(from_node, "self_modulate", Color.WHITE, 1)
var transitions = state_machine.transitions.get(from, {})
if from_dir.is_nested():
transitions = state_machine.transitions.get(from_dir.get_end(), {})
# Fade out color of StateNode
for transition in transitions.values():
var line = content_lines.get_node_or_null(NodePath("%s>%s" % [transition.from, transition.to]))
if line:
line.template = "{condition_name} {condition_comparation} {condition_value}"
line.update_label()
if transition.to == to_dir.get_end():
tween_lines.tween_property(line, "self_modulate", editor_complementary_color, 0.01)
tween_lines.tween_property(line, "self_modulate", Color.WHITE, 1).set_trans(Tween.TRANS_EXPO).set_ease(Tween.EASE_IN)
# Highlight all the conditions of the transition that just happened
for condition in transition.conditions.values():
if not ("value" in condition): # Ignore trigger
continue
var label = line.vbox.get_node_or_null(NodePath(condition.name))
tween_labels.tween_property(label, "self_modulate", editor_complementary_color, 0.01)
tween_labels.tween_property(label, "self_modulate", Color.WHITE, 1)
else:
tween_lines.tween_property(line, "self_modulate", Color.WHITE, 0.1)
# Revert color of TransitionLine condition labels
for condition in transition.conditions.values():
if not ("value" in condition): # Ignore trigger
continue
var label = line.vbox.get_node_or_null(NodePath(condition.name))
if label.self_modulate != Color.WHITE:
tween_labels.tween_property(label, "self_modulate", Color.WHITE, 0.5)
if from_dir.is_nested() and from_dir.is_exit():
# Transition from nested state
transitions = state_machine.transitions.get(from_dir.get_base(), {})
tween_lines.set_parallel(true)
for transition in transitions.values():
var line = content_lines.get_node_or_null(NodePath("%s>%s" % [transition.from, transition.to]))
if line:
tween_lines.tween_property(line, "self_modulate", editor_complementary_color.lightened(0.5), 0.1)
for transition in transitions.values():
var line = content_lines.get_node_or_null(NodePath("%s>%s" % [transition.from, transition.to]))
if line:
tween_lines.tween_property(line, "self_modulate", Color.WHITE, 0.1)
_start_tweens()
func debug_transit_in(from, to):
_init_tweens()
var to_dir = StateDirectory.new(to)
var to_node = content_nodes.get_node_or_null(NodePath(to_dir.get_end()))
if to_node:
tween_nodes.tween_property(to_node, "self_modulate", editor_complementary_color, 0.5)
var transitions = state_machine.transitions.get(to, {})
if to_dir.is_nested():
transitions = state_machine.transitions.get(to_dir.get_end(), {})
# Change string template for current TransitionLines
for transition in transitions.values():
var line = content_lines.get_node_or_null(NodePath("%s>%s" % [transition.from, transition.to]))
line.template = "{condition_name} {condition_comparation} {condition_value}({value})"
_start_tweens()
func set_editor_accent_color(color):
editor_accent_color = color
editor_complementary_color = Utils.get_complementary_color(color)
func _init_tweens():
tween_lines = get_tree().create_tween()
tween_lines.stop()
tween_labels = get_tree().create_tween()
tween_labels.stop()
tween_nodes = get_tree().create_tween()
tween_nodes.stop()
func _start_tweens():
tween_lines.tween_interval(0.001)
tween_lines.play()
tween_labels.tween_interval(0.001)
tween_labels.play()
tween_nodes.tween_interval(0.001)
tween_nodes.play()

View File

@ -0,0 +1,17 @@
[gd_scene format=3 uid="uid://ccv81pntbud75"]
[node name="StateNodeContextMenu" type="PopupMenu"]
size = Vector2i(154, 120)
visible = true
item_count = 5
item_0/text = "Copy"
item_0/id = 0
item_1/text = "Duplicate"
item_1/id = 1
item_2/text = "Delete"
item_2/id = 4
item_3/text = ""
item_3/id = 2
item_3/separator = true
item_4/text = "Convert to State"
item_4/id = 3

View File

@ -0,0 +1,22 @@
@tool
extends "ValueConditionEditor.gd"
@onready var boolean_value = $MarginContainer/BooleanValue
func _ready():
super._ready()
boolean_value.pressed.connect(_on_boolean_value_pressed)
func _on_value_changed(new_value):
if boolean_value.button_pressed != new_value:
boolean_value.button_pressed = new_value
func _on_boolean_value_pressed():
change_value_action(condition.value, boolean_value.button_pressed)
func _on_condition_changed(new_condition):
super._on_condition_changed(new_condition)
if new_condition:
boolean_value.button_pressed = new_condition.value

View File

@ -0,0 +1,35 @@
[gd_scene load_steps=3 format=3 uid="uid://bmpwx6h3ckekr"]
[ext_resource type="PackedScene" uid="uid://blnscdhcxvpmk" path="res://addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.tscn" id="1"]
[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/condition_editors/BoolConditionEditor.gd" id="2"]
[node name="BoolConditionEditor" instance=ExtResource("1")]
script = ExtResource("2")
[node name="Name" parent="." index="0"]
layout_mode = 2
[node name="Comparation" parent="." index="1"]
layout_mode = 2
[node name="PopupMenu" parent="Comparation" index="0"]
item_count = 2
[node name="MarginContainer" parent="." index="2"]
layout_mode = 2
offset_top = 3.0
offset_right = 146.0
offset_bottom = 27.0
size_flags_horizontal = 3
size_flags_vertical = 4
[node name="BooleanValue" type="CheckButton" parent="MarginContainer" index="0"]
layout_mode = 2
offset_right = 44.0
offset_bottom = 24.0
size_flags_horizontal = 6
[node name="Remove" parent="." index="3"]
layout_mode = 2
offset_left = 150.0
offset_right = 176.0

View File

@ -0,0 +1,72 @@
@tool
extends HBoxContainer
@onready var name_edit = $Name
@onready var remove = $Remove
var undo_redo
var condition:
set = set_condition
func _ready():
name_edit.text_submitted.connect(_on_name_edit_text_submitted)
name_edit.focus_entered.connect(_on_name_edit_focus_entered)
name_edit.focus_exited.connect(_on_name_edit_focus_exited)
name_edit.text_changed.connect(_on_name_edit_text_changed)
set_process_input(false)
func _input(event):
if event is InputEventMouseButton:
if event.pressed:
if get_viewport().gui_get_focus_owner() == name_edit:
var local_event = name_edit.make_input_local(event)
if not name_edit.get_rect().has_point(local_event.position):
name_edit.release_focus()
func _on_name_edit_text_changed(new_text):
# name_edit.release_focus()
if condition.name == new_text: # Avoid infinite loop
return
rename_edit_action(new_text)
func _on_name_edit_focus_entered():
set_process_input(true)
func _on_name_edit_focus_exited():
set_process_input(false)
if condition.name == name_edit.text:
return
rename_edit_action(name_edit.text)
func _on_name_edit_text_submitted(new_text):
name_edit.tooltip_text = new_text
func change_name_edit(from, to):
var transition = get_parent().get_parent().get_parent().transition # TODO: Better way to get Transition object
if transition.change_condition_name(from, to):
if name_edit.text != to: # Manually update name_edit.text, in case called from undo_redo
name_edit.text = to
else:
name_edit.text = from
push_warning("Change Condition name_edit from (%s) to (%s) failed, name_edit existed" % [from, to])
func rename_edit_action(new_name_edit):
var old_name_edit = condition.name
undo_redo.create_action("Rename_edit Condition")
undo_redo.add_do_method(self, "change_name_edit", old_name_edit, new_name_edit)
undo_redo.add_undo_method(self, "change_name_edit", new_name_edit, old_name_edit)
undo_redo.commit_action()
func _on_condition_changed(new_condition):
if new_condition:
name_edit.text = new_condition.name
name_edit.tooltip_text = name_edit.text
func set_condition(c):
if condition != c:
condition = c
_on_condition_changed(c)

View File

@ -0,0 +1,24 @@
[gd_scene load_steps=3 format=3 uid="uid://cie8lb6ww58ck"]
[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/condition_editors/ConditionEditor.gd" id="1"]
[ext_resource type="Texture2D" uid="uid://l78bjwo7shm" path="res://addons/imjp94.yafsm/assets/icons/close-white-18dp.svg" id="2"]
[node name="ConditionEditor" type="HBoxContainer"]
script = ExtResource("1")
[node name="Name" type="LineEdit" parent="."]
layout_mode = 2
offset_right = 67.0
offset_bottom = 31.0
size_flags_horizontal = 3
size_flags_vertical = 4
text = "Param"
[node name="Remove" type="Button" parent="."]
layout_mode = 2
offset_left = 71.0
offset_right = 97.0
offset_bottom = 31.0
size_flags_horizontal = 9
icon = ExtResource("2")
flat = true

View File

@ -0,0 +1,44 @@
@tool
extends "ValueConditionEditor.gd"
@onready var float_value = $MarginContainer/FloatValue
var _old_value = 0.0
func _ready():
super._ready()
float_value.text_submitted.connect(_on_float_value_text_submitted)
float_value.focus_entered.connect(_on_float_value_focus_entered)
float_value.focus_exited.connect(_on_float_value_focus_exited)
set_process_input(false)
func _input(event):
super._input(event)
if event is InputEventMouseButton:
if event.pressed:
if get_viewport().gui_get_focus_owner() == float_value:
var local_event = float_value.make_input_local(event)
if not float_value.get_rect().has_point(local_event.position):
float_value.release_focus()
func _on_value_changed(new_value):
float_value.text = str(snapped(new_value, 0.01)).pad_decimals(2)
func _on_float_value_text_submitted(new_text):
change_value_action(_old_value, float(new_text))
float_value.release_focus()
func _on_float_value_focus_entered():
set_process_input(true)
_old_value = float(float_value.text)
func _on_float_value_focus_exited():
set_process_input(false)
change_value_action(_old_value, float(float_value.text))
func _on_condition_changed(new_condition):
super._on_condition_changed(new_condition)
if new_condition:
float_value.text = str(snapped(new_condition.value, 0.01)).pad_decimals(2)

View File

@ -0,0 +1,26 @@
[gd_scene load_steps=3 format=3 uid="uid://doq6lkdh20j15"]
[ext_resource type="PackedScene" uid="uid://blnscdhcxvpmk" path="res://addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.tscn" id="1"]
[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/condition_editors/FloatConditionEditor.gd" id="2"]
[node name="ValueConditionEditor" instance=ExtResource("1")]
script = ExtResource("2")
[node name="Comparation" parent="." index="1"]
layout_mode = 2
[node name="MarginContainer" parent="." index="2"]
layout_mode = 2
offset_right = 169.0
size_flags_horizontal = 3
size_flags_vertical = 4
[node name="FloatValue" type="LineEdit" parent="MarginContainer" index="0"]
layout_mode = 2
offset_right = 67.0
offset_bottom = 31.0
size_flags_horizontal = 3
[node name="Remove" parent="." index="3"]
offset_left = 173.0
offset_right = 199.0

View File

@ -0,0 +1,45 @@
@tool
extends "ValueConditionEditor.gd"
@onready var integer_value = $MarginContainer/IntegerValue
var _old_value = 0
func _ready():
super._ready()
integer_value.text_submitted.connect(_on_integer_value_text_submitted)
integer_value.focus_entered.connect(_on_integer_value_focus_entered)
integer_value.focus_exited.connect(_on_integer_value_focus_exited)
set_process_input(false)
func _input(event):
super._input(event)
if event is InputEventMouseButton:
if event.pressed:
if get_viewport().gui_get_focus_owner() == integer_value:
var local_event = integer_value.make_input_local(event)
if not integer_value.get_rect().has_point(local_event.position):
integer_value.release_focus()
func _on_value_changed(new_value):
integer_value.text = str(new_value)
func _on_integer_value_text_submitted(new_text):
change_value_action(_old_value, int(new_text))
integer_value.release_focus()
func _on_integer_value_focus_entered():
set_process_input(true)
_old_value = int(integer_value.text)
func _on_integer_value_focus_exited():
set_process_input(false)
change_value_action(_old_value, int(integer_value.text))
func _on_condition_changed(new_condition):
super._on_condition_changed(new_condition)
if new_condition:
integer_value.text = str(new_condition.value)

View File

@ -0,0 +1,26 @@
[gd_scene load_steps=3 format=3 uid="uid://d1ib30424prpf"]
[ext_resource type="PackedScene" uid="uid://blnscdhcxvpmk" path="res://addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.tscn" id="1"]
[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/condition_editors/IntegerConditionEditor.gd" id="2"]
[node name="IntegerConditionEditor" instance=ExtResource("1")]
script = ExtResource("2")
[node name="Comparation" parent="." index="1"]
layout_mode = 2
[node name="MarginContainer" parent="." index="2"]
layout_mode = 2
offset_right = 169.0
size_flags_horizontal = 3
size_flags_vertical = 4
[node name="IntegerValue" type="LineEdit" parent="MarginContainer" index="0"]
layout_mode = 2
offset_right = 67.0
offset_bottom = 31.0
size_flags_horizontal = 3
[node name="Remove" parent="." index="3"]
offset_left = 173.0
offset_right = 199.0

View File

@ -0,0 +1,46 @@
@tool
extends "res://addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.gd"
@onready var string_value = $MarginContainer/StringValue
var _old_value = 0
func _ready():
super._ready()
string_value.text_submitted.connect(_on_string_value_text_submitted)
string_value.focus_entered.connect(_on_string_value_focus_entered)
string_value.focus_exited.connect(_on_string_value_focus_exited)
set_process_input(false)
func _input(event):
super._input(event)
if event is InputEventMouseButton:
if event.pressed:
if get_viewport().gui_get_focus_owner() == string_value:
var local_event = string_value.make_input_local(event)
if not string_value.get_rect().has_point(local_event.position):
string_value.release_focus()
func _on_value_changed(new_value):
string_value.text = new_value
func _on_string_value_text_submitted(new_text):
change_value_action(_old_value, new_text)
string_value.release_focus()
func _on_string_value_focus_entered():
set_process_input(true)
_old_value = string_value.text
func _on_string_value_focus_exited():
set_process_input(false)
change_value_action(_old_value, string_value.text)
func _on_condition_changed(new_condition):
super._on_condition_changed(new_condition)
if new_condition:
string_value.text = new_condition.value

View File

@ -0,0 +1,29 @@
[gd_scene load_steps=3 format=3 uid="uid://qfw0snt5kss6"]
[ext_resource type="PackedScene" uid="uid://blnscdhcxvpmk" path="res://addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.tscn" id="1"]
[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/condition_editors/StringConditionEditor.gd" id="2"]
[node name="StringConditionEditor" instance=ExtResource("1")]
script = ExtResource("2")
[node name="Comparation" parent="." index="1"]
layout_mode = 2
[node name="PopupMenu" parent="Comparation" index="0"]
item_count = 2
[node name="MarginContainer" parent="." index="2"]
layout_mode = 2
offset_right = 169.0
size_flags_horizontal = 3
size_flags_vertical = 4
[node name="StringValue" type="LineEdit" parent="MarginContainer" index="0"]
layout_mode = 2
offset_right = 67.0
offset_bottom = 31.0
size_flags_horizontal = 3
[node name="Remove" parent="." index="3"]
offset_left = 173.0
offset_right = 199.0

View File

@ -0,0 +1,57 @@
@tool
extends "ConditionEditor.gd"
const Utils = preload("../../scripts/Utils.gd")
const Comparation = preload("../../src/conditions/ValueCondition.gd").Comparation
@onready var comparation_button = $Comparation
@onready var comparation_popup_menu = $Comparation/PopupMenu
func _ready():
super._ready()
comparation_button.pressed.connect(_on_comparation_button_pressed)
comparation_popup_menu.id_pressed.connect(_on_comparation_popup_menu_id_pressed)
func _on_comparation_button_pressed():
Utils.popup_on_target(comparation_popup_menu, comparation_button)
func _on_comparation_popup_menu_id_pressed(id):
change_comparation_action(id)
func _on_condition_changed(new_condition):
super._on_condition_changed(new_condition)
if new_condition:
comparation_button.text = comparation_popup_menu.get_item_text(new_condition.comparation)
func _on_value_changed(new_value):
pass
func change_comparation(id):
if id > Comparation.size() - 1:
push_error("Unexpected id(%d) from PopupMenu" % id)
return
condition.comparation = id
comparation_button.text = comparation_popup_menu.get_item_text(id)
func change_comparation_action(id):
var from = condition.comparation
var to = id
undo_redo.create_action("Change Condition Comparation")
undo_redo.add_do_method(self, "change_comparation", to)
undo_redo.add_undo_method(self, "change_comparation", from)
undo_redo.commit_action()
func set_value(v):
if condition.value != v:
condition.value = v
_on_value_changed(v)
func change_value_action(from, to):
if from == to:
return
undo_redo.create_action("Change Condition Value")
undo_redo.add_do_method(self, "set_value", to)
undo_redo.add_undo_method(self, "set_value", from)
undo_redo.commit_action()

View File

@ -0,0 +1,42 @@
[gd_scene load_steps=3 format=3 uid="uid://blnscdhcxvpmk"]
[ext_resource type="PackedScene" uid="uid://cie8lb6ww58ck" path="res://addons/imjp94.yafsm/scenes/condition_editors/ConditionEditor.tscn" id="1"]
[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/condition_editors/ValueConditionEditor.gd" id="2"]
[node name="ValueConditionEditor" instance=ExtResource("1")]
script = ExtResource("2")
[node name="Comparation" type="Button" parent="." index="1"]
layout_mode = 2
offset_left = 71.0
offset_right = 98.0
offset_bottom = 31.0
size_flags_horizontal = 5
size_flags_vertical = 4
text = "=="
[node name="PopupMenu" type="PopupMenu" parent="Comparation" index="0"]
item_count = 6
item_0/text = "=="
item_0/id = 0
item_1/text = "!="
item_1/id = 1
item_2/text = ">"
item_2/id = 2
item_3/text = "<"
item_3/id = 3
item_4/text = "≥"
item_4/id = 4
item_5/text = "≤"
item_5/id = 5
[node name="MarginContainer" type="MarginContainer" parent="." index="2"]
layout_mode = 2
offset_left = 102.0
offset_right = 102.0
offset_bottom = 31.0
[node name="Remove" parent="." index="3"]
offset_left = 106.0
offset_right = 132.0
tooltip_text = "Remove Condition"

View File

@ -0,0 +1,681 @@
@tool
extends Control
const Utils = preload("res://addons/imjp94.yafsm/scripts/Utils.gd")
const CohenSutherland = Utils.CohenSutherland
const FlowChartNode = preload("FlowChartNode.gd")
const FlowChartNodeScene = preload("FlowChartNode.tscn")
const FlowChartLine = preload("FlowChartLine.gd")
const FlowChartLineScene = preload("FlowChartLine.tscn")
const FlowChartLayer = preload("FlowChartLayer.gd")
const FlowChartGrid = preload("FlowChartGrid.gd")
const Connection = FlowChartLayer.Connection
signal connection(from, to, line) # When a connection established
signal disconnection(from, to, line) # When a connection broken
signal node_selected(node) # When a node selected
signal node_deselected(node) # When a node deselected
signal dragged(node, distance) # When a node dragged
# Margin of content from edge of FlowChart
@export var scroll_margin: = 50
# Offset between two line that interconnecting
@export var interconnection_offset: = 10
# Snap amount
@export var snap: = 20
# Zoom amount
@export var zoom: = 1.0:
set = set_zoom
@export var zoom_step: = 0.2
@export var max_zoom: = 2.0
@export var min_zoom: = 0.5
var grid = FlowChartGrid.new() # Grid
var content = Control.new() # Root node that hold anything drawn in the flowchart
var current_layer
var h_scroll = HScrollBar.new()
var v_scroll = VScrollBar.new()
var top_bar = VBoxContainer.new()
var gadget = HBoxContainer.new() # Root node of top overlay controls
var zoom_minus = Button.new()
var zoom_reset = Button.new()
var zoom_plus = Button.new()
var snap_button = Button.new()
var snap_amount = SpinBox.new()
var is_snapping = true
var can_gui_select_node = true
var can_gui_delete_node = true
var can_gui_connect_node = true
var _is_connecting = false
var _current_connection
var _is_dragging = false
var _is_dragging_node = false
var _drag_start_pos = Vector2.ZERO
var _drag_end_pos = Vector2.ZERO
var _drag_origins = []
var _selection = []
var _copying_nodes = []
var selection_stylebox = StyleBoxFlat.new()
var grid_major_color = Color(1, 1, 1, 0.2)
var grid_minor_color = Color(1, 1, 1, 0.05)
func _init():
focus_mode = FOCUS_ALL
selection_stylebox.bg_color = Color(0, 0, 0, 0.3)
selection_stylebox.set_border_width_all(1)
self.z_index = 0
content.mouse_filter = MOUSE_FILTER_IGNORE
add_child(content)
content.z_index = 1
grid.mouse_filter = MOUSE_FILTER_IGNORE
content.add_child.call_deferred(grid)
grid.z_index = -1
add_child(h_scroll)
h_scroll.set_anchors_and_offsets_preset(PRESET_BOTTOM_WIDE)
h_scroll.value_changed.connect(_on_h_scroll_changed)
h_scroll.gui_input.connect(_on_h_scroll_gui_input)
add_child(v_scroll)
v_scroll.set_anchors_and_offsets_preset(PRESET_RIGHT_WIDE)
v_scroll.value_changed.connect(_on_v_scroll_changed)
v_scroll.gui_input.connect(_on_v_scroll_gui_input)
h_scroll.offset_right = -v_scroll.size.x
v_scroll.offset_bottom = -h_scroll.size.y
h_scroll.min_value = 0
v_scroll.max_value = 0
add_layer_to(content)
select_layer_at(0)
top_bar.set_anchors_and_offsets_preset(PRESET_TOP_WIDE)
top_bar.mouse_filter = MOUSE_FILTER_IGNORE
add_child(top_bar)
gadget.mouse_filter = MOUSE_FILTER_IGNORE
top_bar.add_child(gadget)
zoom_minus.flat = true
zoom_minus.tooltip_text = "Zoom Out"
zoom_minus.pressed.connect(_on_zoom_minus_pressed)
zoom_minus.focus_mode = FOCUS_NONE
gadget.add_child(zoom_minus)
zoom_reset.flat = true
zoom_reset.tooltip_text = "Zoom Reset"
zoom_reset.pressed.connect(_on_zoom_reset_pressed)
zoom_reset.focus_mode = FOCUS_NONE
gadget.add_child(zoom_reset)
zoom_plus.flat = true
zoom_plus.tooltip_text = "Zoom In"
zoom_plus.pressed.connect(_on_zoom_plus_pressed)
zoom_plus.focus_mode = FOCUS_NONE
gadget.add_child(zoom_plus)
snap_button.flat = true
snap_button.toggle_mode = true
snap_button.tooltip_text = "Enable snap and show grid"
snap_button.pressed.connect(_on_snap_button_pressed)
snap_button.button_pressed = true
snap_button.focus_mode = FOCUS_NONE
gadget.add_child(snap_button)
snap_amount.value = snap
snap_amount.value_changed.connect(_on_snap_amount_value_changed)
gadget.add_child(snap_amount)
func _on_h_scroll_gui_input(event):
if event is InputEventMouseButton:
var v = (h_scroll.max_value - h_scroll.min_value) * 0.01 # Scroll at 0.1% step
match event.button_index:
MOUSE_BUTTON_WHEEL_UP:
h_scroll.value -= v
MOUSE_BUTTON_WHEEL_DOWN:
h_scroll.value += v
func _on_v_scroll_gui_input(event):
if event is InputEventMouseButton:
var v = (v_scroll.max_value - v_scroll.min_value) * 0.01 # Scroll at 0.1% step
match event.button_index:
MOUSE_BUTTON_WHEEL_UP:
v_scroll.value -= v # scroll left
MOUSE_BUTTON_WHEEL_DOWN:
v_scroll.value += v # scroll right
func _on_h_scroll_changed(value):
content.position.x = -value
func _on_v_scroll_changed(value):
content.position.y = -value
func set_zoom(v):
zoom = clampf(v, min_zoom, max_zoom)
content.scale = Vector2.ONE * zoom
queue_redraw()
grid.queue_redraw()
func _on_zoom_minus_pressed():
set_zoom(zoom - zoom_step)
queue_redraw()
func _on_zoom_reset_pressed():
set_zoom(1.0)
queue_redraw()
func _on_zoom_plus_pressed():
set_zoom(zoom + zoom_step)
queue_redraw()
func _on_snap_button_pressed():
is_snapping = snap_button.button_pressed
queue_redraw()
func _on_snap_amount_value_changed(value):
snap = value
queue_redraw()
func _draw():
# Update scrolls
var content_rect: Rect2 = get_scroll_rect(current_layer, 0)
content.pivot_offset = content_rect.size / 2.0 # Scale from center
var flowchart_rect: Rect2 = get_rect()
# ENCLOSE CONDITIONS
var is_content_enclosed = (flowchart_rect.size.x >= content_rect.size.x)
is_content_enclosed = is_content_enclosed and (flowchart_rect.size.y >= content_rect.size.y)
is_content_enclosed = is_content_enclosed and (flowchart_rect.position.x <= content_rect.position.x)
is_content_enclosed = is_content_enclosed and (flowchart_rect.position.y >= content_rect.position.y)
if not is_content_enclosed or (h_scroll.min_value==h_scroll.max_value) or (v_scroll.min_value==v_scroll.max_value):
var h_min = 0 # content_rect.position.x - scroll_margin/2 - content_rect.get_center().x/2
var h_max = content_rect.size.x - content_rect.position.x - size.x + scroll_margin + content_rect.get_center().x
var v_min = 0 # content_rect.position.y - scroll_margin/2 - content_rect.get_center().y/2
var v_max = content_rect.size.y - content_rect.position.y - size.y + scroll_margin + content_rect.get_center().y
if h_min == h_max: # Otherwise scroll bar will complain no ratio
h_min -= 0.1
h_max += 0.1
if v_min == v_max: # Otherwise scroll bar will complain no ratio
v_min -= 0.1
v_max += 0.1
h_scroll.min_value = h_min
h_scroll.max_value = h_max
h_scroll.page = content_rect.size.x / 100
v_scroll.min_value = v_min
v_scroll.max_value = v_max
v_scroll.page = content_rect.size.y / 100
# Draw selection box
if not _is_dragging_node and not _is_connecting:
var selection_box_rect = get_selection_box_rect()
draw_style_box(selection_stylebox, selection_box_rect)
if is_snapping:
grid.visible = true
grid.queue_redraw()
else:
grid.visible = false
# Debug draw
# for node in content_nodes.get_children():
# var rect = get_transform() * (content.get_transform() * (node.get_rect()))
# draw_style_box(selection_stylebox, rect)
# var connection_list = get_connection_list()
# for i in connection_list.size():
# var connection = _connections[connection_list[i].from][connection_list[i].to]
# # Line's offset along its down-vector
# var line_local_up_offset = connection.line.position - connection.line.get_transform() * (Vector2.UP * connection.offset)
# var from_pos = content.get_transform() * (connection.get_from_pos() + line_local_up_offset)
# var to_pos = content.get_transform() * (connection.get_to_pos() + line_local_up_offset)
# draw_line(from_pos, to_pos, Color.yellow)
func _gui_input(event):
var OS_KEY_DELETE = KEY_BACKSPACE if ( ["macOS", "OSX"].has(OS.get_name()) ) else KEY_DELETE
if event is InputEventKey:
match event.keycode:
OS_KEY_DELETE:
if event.pressed and can_gui_delete_node:
# Delete nodes
for node in _selection.duplicate():
if node is FlowChartLine:
# TODO: More efficient way to get connection from Line node
for connections_from in current_layer._connections.duplicate().values():
for connection in connections_from.duplicate().values():
if connection.line == node:
disconnect_node(current_layer, connection.from_node.name, connection.to_node.name).queue_free()
elif node is FlowChartNode:
remove_node(current_layer, node.name)
for connection_pair in current_layer.get_connection_list():
if connection_pair.from == node.name or connection_pair.to == node.name:
disconnect_node(current_layer, connection_pair.from, connection_pair.to).queue_free()
accept_event()
KEY_C:
if event.pressed and event.ctrl_pressed:
# Copy node
_copying_nodes = _selection.duplicate()
accept_event()
KEY_D:
if event.pressed and event.ctrl_pressed:
# Duplicate node directly from selection
duplicate_nodes(current_layer, _selection.duplicate())
accept_event()
KEY_V:
if event.pressed and event.ctrl_pressed:
# Paste node from _copying_nodes
duplicate_nodes(current_layer, _copying_nodes)
accept_event()
if event is InputEventMouseMotion:
match event.button_mask:
MOUSE_BUTTON_MASK_MIDDLE:
# Panning
h_scroll.value -= event.relative.x
v_scroll.value -= event.relative.y
queue_redraw()
MOUSE_BUTTON_LEFT:
# Dragging
if _is_dragging:
if _is_connecting:
# Connecting
if _current_connection:
var pos = content_position(get_local_mouse_position())
var clip_rects = [_current_connection.from_node.get_rect()]
# Snapping connecting line
for i in current_layer.content_nodes.get_child_count():
var child = current_layer.content_nodes.get_child(current_layer.content_nodes.get_child_count()-1 - i) # Inverse order to check from top to bottom of canvas
if child is FlowChartNode and child.name != _current_connection.from_node.name:
if _request_connect_to(current_layer, child.name):
if child.get_rect().has_point(pos):
pos = child.position + child.size / 2
clip_rects.append(child.get_rect())
break
_current_connection.line.join(_current_connection.get_from_pos(), pos, Vector2.ZERO, clip_rects)
elif _is_dragging_node:
# Dragging nodes
var dragged = content_position(_drag_end_pos) - content_position(_drag_start_pos)
for i in _selection.size():
var selected = _selection[i]
if not (selected is FlowChartNode):
continue
selected.position = (_drag_origins[i] + selected.size / 2.0 + dragged)
selected.modulate.a = 0.3
if is_snapping:
selected.position = selected.position.snapped(Vector2.ONE * snap)
selected.position -= selected.size / 2.0
_on_node_dragged(current_layer, selected, dragged)
emit_signal("dragged", selected, dragged)
# Update connection pos
for from in current_layer._connections:
var connections_from = current_layer._connections[from]
for to in connections_from:
if from == selected.name or to == selected.name:
var connection = current_layer._connections[from][to]
connection.join()
_drag_end_pos = get_local_mouse_position()
queue_redraw()
if event is InputEventMouseButton:
match event.button_index:
MOUSE_BUTTON_MIDDLE:
# Reset zoom
if event.double_click:
set_zoom(1.0)
queue_redraw()
MOUSE_BUTTON_WHEEL_UP:
# Zoom in
set_zoom(zoom + zoom_step/10)
queue_redraw()
MOUSE_BUTTON_WHEEL_DOWN:
# Zoom out
set_zoom(zoom - zoom_step/10)
queue_redraw()
MOUSE_BUTTON_LEFT:
# Hit detection
var hit_node
for i in current_layer.content_nodes.get_child_count():
var child = current_layer.content_nodes.get_child(current_layer.content_nodes.get_child_count()-1 - i) # Inverse order to check from top to bottom of canvas
if child is FlowChartNode:
if child.get_rect().has_point(content_position(get_local_mouse_position())):
hit_node = child
break
if not hit_node:
# Test Line
# Refer https://github.com/godotengine/godot/blob/master/editor/plugins/animation_state_machine_editor.cpp#L187
var closest = -1
var closest_d = 1e20
var connection_list = get_connection_list()
for i in connection_list.size():
var connection = current_layer._connections[connection_list[i].from][connection_list[i].to]
# Line's offset along its down-vector
var line_local_up_offset = connection.line.position - connection.line.get_transform()*(Vector2.DOWN * connection.offset)
var from_pos = connection.get_from_pos() + line_local_up_offset
var to_pos = connection.get_to_pos() + line_local_up_offset
var cp = Geometry2D.get_closest_point_to_segment(content_position(event.position), from_pos, to_pos)
var d = cp.distance_to(content_position(event.position))
if d > connection.line.size.y * 2:
continue
if d < closest_d:
closest = i
closest_d = d
if closest >= 0:
hit_node = current_layer._connections[connection_list[closest].from][connection_list[closest].to].line
if event.pressed:
if not (hit_node in _selection) and not event.shift_pressed:
# Click on empty space
clear_selection()
if hit_node:
# Click on node(can be a line)
_is_dragging_node = true
if hit_node is FlowChartLine:
current_layer.content_lines.move_child(hit_node, current_layer.content_lines.get_child_count()-1) # Raise selected line to top
if event.shift_pressed and can_gui_connect_node:
# Reconnection Start
for from in current_layer._connections.keys():
var from_connections = current_layer._connections[from]
for to in from_connections.keys():
var connection = from_connections[to]
if connection.line == hit_node:
_is_connecting = true
_is_dragging_node = false
_current_connection = connection
_on_node_reconnect_begin(current_layer, from, to)
break
if hit_node is FlowChartNode:
current_layer.content_nodes.move_child(hit_node, current_layer.content_nodes.get_child_count()-1) # Raise selected node to top
if event.shift_pressed and can_gui_connect_node:
# Connection start
if _request_connect_from(current_layer, hit_node.name):
_is_connecting = true
_is_dragging_node = false
var line = create_line_instance()
var connection = Connection.new(line, hit_node, null)
current_layer._connect_node(connection)
_current_connection = connection
_current_connection.line.join(_current_connection.get_from_pos(), content_position(event.position))
accept_event()
if _is_connecting:
clear_selection()
else:
if can_gui_select_node:
select(hit_node)
if not _is_dragging:
# Drag start
_is_dragging = true
for i in _selection.size():
var selected = _selection[i]
_drag_origins[i] = selected.position
selected.modulate.a = 1.0
_drag_start_pos = event.position
_drag_end_pos = event.position
else:
var was_connecting = _is_connecting
var was_dragging_node = _is_dragging_node
if _current_connection:
# Connection end
var from = _current_connection.from_node.name
var to = hit_node.name if hit_node else null
if hit_node is FlowChartNode and _request_connect_to(current_layer, to) and from != to:
# Connection success
var line
if _current_connection.to_node:
# Reconnection
line = disconnect_node(current_layer, from, _current_connection.to_node.name)
_current_connection.to_node = hit_node
_on_node_reconnect_end(current_layer, from, to)
connect_node(current_layer, from, to, line)
else:
# New Connection
current_layer.content_lines.remove_child(_current_connection.line)
line = _current_connection.line
_current_connection.to_node = hit_node
connect_node(current_layer, from, to, line)
else:
# Connection failed
if _current_connection.to_node:
# Reconnection
_current_connection.join()
_on_node_reconnect_failed(current_layer, from, name)
else:
# New Connection
_current_connection.line.queue_free()
_on_node_connect_failed(current_layer, from)
_is_connecting = false
_current_connection = null
accept_event()
if _is_dragging:
# Drag end
_is_dragging = false
_is_dragging_node = false
if not (was_connecting or was_dragging_node) and can_gui_select_node:
var selection_box_rect = get_selection_box_rect()
# Select node
for node in current_layer.content_nodes.get_children():
var rect = get_transform() * (content.get_transform() * (node.get_rect()))
if selection_box_rect.intersects(rect):
if node is FlowChartNode:
select(node)
# Select line
var connection_list = get_connection_list()
for i in connection_list.size():
var connection = current_layer._connections[connection_list[i].from][connection_list[i].to]
# Line's offset along its down-vector
var line_local_up_offset = connection.line.position - connection.line.get_transform() * (Vector2.UP * connection.offset)
var from_pos = content.get_transform() * (connection.get_from_pos() + line_local_up_offset)
var to_pos = content.get_transform() * (connection.get_to_pos() + line_local_up_offset)
if CohenSutherland.line_intersect_rectangle(from_pos, to_pos, selection_box_rect):
select(connection.line)
if was_dragging_node:
# Update _drag_origins with new position after dragged
for i in _selection.size():
var selected = _selection[i]
_drag_origins[i] = selected.position
selected.modulate.a = 1.0
_drag_start_pos = _drag_end_pos
queue_redraw()
# Get selection box rect
func get_selection_box_rect():
var pos = Vector2(min(_drag_start_pos.x, _drag_end_pos.x), min(_drag_start_pos.y, _drag_end_pos.y))
var size = (_drag_end_pos - _drag_start_pos).abs()
return Rect2(pos, size)
# Get required scroll rect base on content
func get_scroll_rect(layer=current_layer, force_scroll_margin=null):
var _scroll_margin = scroll_margin
if force_scroll_margin!=null:
_scroll_margin = force_scroll_margin
return layer.get_scroll_rect(_scroll_margin)
func add_layer_to(target):
var layer = create_layer_instance()
target.add_child(layer)
return layer
func get_layer(np):
return content.get_node_or_null(NodePath(np))
func select_layer_at(i):
select_layer(content.get_child(i))
func select_layer(layer):
var prev_layer = current_layer
_on_layer_deselected(prev_layer)
current_layer = layer
_on_layer_selected(layer)
# Add node
func add_node(layer, node):
layer.add_node(node)
_on_node_added(layer, node)
# Remove node
func remove_node(layer, node_name):
var node = layer.content_nodes.get_node_or_null(NodePath(node_name))
if node:
deselect(node) # Must deselct before remove to make sure _drag_origins synced with _selections
layer.remove_node(node)
_on_node_removed(layer, node_name)
# Called after connection established
func _connect_node(line, from_pos, to_pos):
pass
# Called after connection broken
func _disconnect_node(line):
if line in _selection:
deselect(line)
func create_layer_instance():
var layer = Control.new()
layer.set_script(FlowChartLayer)
return layer
# Return new line instance to use, called when connecting node
func create_line_instance():
return FlowChartLineScene.instantiate()
# Rename node
func rename_node(layer, old, new):
layer.rename_node(old, new)
# Connect two nodes with a line
func connect_node(layer, from, to, line=null):
if not line:
line = create_line_instance()
line.name = "%s>%s" % [from, to] # "From>To"
layer.connect_node(line, from, to, interconnection_offset)
_on_node_connected(layer, from, to)
emit_signal("connection", from, to, line)
# Break a connection between two node
func disconnect_node(layer, from, to):
var line = layer.disconnect_node(from, to)
deselect(line) # Since line is selectable as well
_on_node_disconnected(layer, from, to)
emit_signal("disconnection", from, to)
return line
# Clear all connections
func clear_connections(layer=current_layer):
layer.clear_connections()
# Select a node(can be a line)
func select(node):
if node in _selection:
return
_selection.append(node)
node.selected = true
_drag_origins.append(node.position)
emit_signal("node_selected", node)
# Deselect a node
func deselect(node):
_selection.erase(node)
if is_instance_valid(node):
node.selected = false
_drag_origins.pop_back()
emit_signal("node_deselected", node)
# Clear all selection
func clear_selection():
for node in _selection.duplicate(): # duplicate _selection array as deselect() edit array
if not node:
continue
deselect(node)
_selection.clear()
# Duplicate given nodes in editor
func duplicate_nodes(layer, nodes):
clear_selection()
var new_nodes = []
for i in nodes.size():
var node = nodes[i]
if not (node is FlowChartNode):
continue
var new_node = node.duplicate(DUPLICATE_SIGNALS + DUPLICATE_SCRIPTS)
var offset = content_position(get_local_mouse_position()) - content_position(_drag_end_pos)
new_node.position = new_node.position + offset
new_nodes.append(new_node)
add_node(layer, new_node)
select(new_node)
# Duplicate connection within selection
for i in nodes.size():
var from_node = nodes[i]
for connection_pair in get_connection_list():
if from_node.name == connection_pair.from:
for j in nodes.size():
var to_node = nodes[j]
if to_node.name == connection_pair.to:
connect_node(layer, new_nodes[i].name, new_nodes[j].name)
_on_duplicated(layer, nodes, new_nodes)
# Called after layer selected(current_layer changed)
func _on_layer_selected(layer):
pass
func _on_layer_deselected(layer):
pass
# Called after a node added
func _on_node_added(layer, node):
pass
# Called after a node removed
func _on_node_removed(layer, node):
pass
# Called when a node dragged
func _on_node_dragged(layer, node, dragged):
pass
# Called when connection established between two nodes
func _on_node_connected(layer, from, to):
pass
# Called when connection broken
func _on_node_disconnected(layer, from, to):
pass
func _on_node_connect_failed(layer, from):
pass
func _on_node_reconnect_begin(layer, from, to):
pass
func _on_node_reconnect_end(layer, from, to):
pass
func _on_node_reconnect_failed(layer, from, to):
pass
func _request_connect_from(layer, from):
return true
func _request_connect_to(layer, to):
return true
# Called when nodes duplicated
func _on_duplicated(layer, old_nodes, new_nodes):
pass
# Convert position in FlowChart space to content(takes translation/scale of content into account)
func content_position(pos):
return (pos - content.position - content.pivot_offset * (Vector2.ONE - content.scale)) * 1.0/content.scale
# Return array of dictionary of connection as such [{"from1": "to1"}, {"from2": "to2"}]
func get_connection_list(layer=current_layer):
return layer.get_connection_list()

View File

@ -0,0 +1,64 @@
extends Control
var flowchart
func _ready():
flowchart = get_parent().get_parent()
queue_redraw()
# Original Draw in FlowChart.gd inspired by:
# https://github.com/godotengine/godot/blob/6019dab0b45e1291e556e6d9e01b625b5076cc3c/scene/gui/graph_edit.cpp#L442
func _draw():
self.position = flowchart.position
# Extents of the grid.
self.size = flowchart.size*100 # good with min_zoom = 0.5 e max_zoom = 2.0
var zoom = flowchart.zoom
var snap = flowchart.snap
# Origin of the grid.
var offset = -Vector2(1, 1)*10000 # good with min_zoom = 0.5 e max_zoom = 2.0
var corrected_size = size/zoom
var from = (offset / snap).floor()
var l = (corrected_size / snap).floor() + Vector2(1, 1)
var grid_minor = flowchart.grid_minor_color
var grid_major = flowchart.grid_major_color
var multi_line_vector_array: PackedVector2Array = PackedVector2Array()
var multi_line_color_array: PackedColorArray = PackedColorArray ()
# for (int i = from.x; i < from.x + len.x; i++) {
for i in range(from.x, from.x + l.x):
var color
if (int(abs(i)) % 10 == 0):
color = grid_major
else:
color = grid_minor
var base_ofs = i * snap
multi_line_vector_array.append(Vector2(base_ofs, offset.y))
multi_line_vector_array.append(Vector2(base_ofs, corrected_size.y))
multi_line_color_array.append(color)
# for (int i = from.y; i < from.y + len.y; i++) {
for i in range(from.y, from.y + l.y):
var color
if (int(abs(i)) % 10 == 0):
color = grid_major
else:
color = grid_minor
var base_ofs = i * snap
multi_line_vector_array.append(Vector2(offset.x, base_ofs))
multi_line_vector_array.append(Vector2(corrected_size.x, base_ofs))
multi_line_color_array.append(color)
draw_multiline_colors(multi_line_vector_array, multi_line_color_array, -1)

View File

@ -0,0 +1,157 @@
@tool
extends Control
const FlowChartNode = preload("res://addons/imjp94.yafsm/scenes/flowchart/FlowChartNode.gd")
var content_lines = Control.new() # Node that hold all flowchart lines
var content_nodes = Control.new() # Node that hold all flowchart nodes
var _connections = {}
func _init():
name = "FlowChartLayer"
mouse_filter = MOUSE_FILTER_IGNORE
content_lines.name = "content_lines"
content_lines.mouse_filter = MOUSE_FILTER_IGNORE
add_child(content_lines)
move_child(content_lines, 0) # Make sure content_lines always behind nodes
content_nodes.name = "content_nodes"
content_nodes.mouse_filter = MOUSE_FILTER_IGNORE
add_child(content_nodes)
func hide_content():
content_nodes.hide()
content_lines.hide()
func show_content():
content_nodes.show()
content_lines.show()
# Get required scroll rect base on content
func get_scroll_rect(scroll_margin=0):
var rect = Rect2()
for child in content_nodes.get_children():
# Every child is a state/statemachine node
var child_rect = child.get_rect()
rect = rect.merge(child_rect)
return rect.grow(scroll_margin)
# Add node
func add_node(node):
content_nodes.add_child(node)
# Remove node
func remove_node(node):
if node:
content_nodes.remove_child(node)
# Called after connection established
func _connect_node(connection):
content_lines.add_child(connection.line)
connection.join()
# Called after connection broken
func _disconnect_node(connection):
content_lines.remove_child(connection.line)
return connection.line
# Rename node
func rename_node(old, new):
for from in _connections.keys():
if from == old: # Connection from
var from_connections = _connections[from]
_connections.erase(old)
_connections[new] = from_connections
else: # Connection to
for to in _connections[from].keys():
if to == old:
var from_connection = _connections[from]
var value = from_connection[old]
from_connection.erase(old)
from_connection[new] = value
# Connect two nodes with a line
func connect_node(line, from, to, interconnection_offset=0):
if from == to:
return # Connect to self
var connections_from = _connections.get(from)
if connections_from:
if to in connections_from:
return # Connection existed
var connection = Connection.new(line, content_nodes.get_node(NodePath(from)), content_nodes.get_node(NodePath(to)))
if connections_from == null:
connections_from = {}
_connections[from] = connections_from
connections_from[to] = connection
_connect_node(connection)
# Check if connection in both ways
connections_from = _connections.get(to)
if connections_from:
var inv_connection = connections_from.get(from)
if inv_connection:
connection.offset = interconnection_offset
inv_connection.offset = interconnection_offset
connection.join()
inv_connection.join()
# Break a connection between two node
func disconnect_node(from, to):
var connections_from = _connections.get(from)
var connection = connections_from.get(to)
if connection == null:
return
_disconnect_node(connection)
if connections_from.size() == 1:
_connections.erase(from)
else:
connections_from.erase(to)
connections_from = _connections.get(to)
if connections_from:
var inv_connection = connections_from.get(from)
if inv_connection:
inv_connection.offset = 0
inv_connection.join()
return connection.line
# Clear all selection
func clear_connections():
for connections_from in _connections.values():
for connection in connections_from.values():
connection.line.queue_free()
_connections.clear()
# Return array of dictionary of connection as such [{"from1": "to1"}, {"from2": "to2"}]
func get_connection_list():
var connection_list = []
for connections_from in _connections.values():
for connection in connections_from.values():
connection_list.append({"from": connection.from_node.name, "to": connection.to_node.name})
return connection_list
class Connection:
var line # Control node that draw line
var from_node
var to_node
var offset = 0 # line's y offset to make space for two interconnecting lines
func _init(p_line, p_from_node, p_to_node):
line = p_line
from_node = p_from_node
to_node = p_to_node
# Update line position
func join():
line.join(get_from_pos(), get_to_pos(), offset, [from_node.get_rect() if from_node else Rect2(), to_node.get_rect() if to_node else Rect2()])
# Return start position of line
func get_from_pos():
return from_node.position + from_node.size / 2
# Return destination position of line
func get_to_pos():
return to_node.position + to_node.size / 2 if to_node else line.position

View File

@ -0,0 +1,91 @@
@tool
extends Container
# Custom style normal, focus, arrow
var selected: = false:
set = set_selected
func _init():
focus_mode = FOCUS_CLICK
mouse_filter = MOUSE_FILTER_IGNORE
func _draw():
pivot_at_line_start()
var from = Vector2.ZERO
from.y += size.y / 2.0
var to = size
to.y -= size.y / 2.0
var arrow = get_theme_icon("arrow", "FlowChartLine")
var tint = Color.WHITE
if selected:
tint = get_theme_stylebox("focus", "FlowChartLine").shadow_color
draw_style_box(get_theme_stylebox("focus", "FlowChartLine"), Rect2(Vector2.ZERO, size))
else:
draw_style_box(get_theme_stylebox("normal", "FlowChartLine"), Rect2(Vector2.ZERO, size))
draw_texture(arrow, Vector2.ZERO - arrow.get_size() / 2 + size / 2, tint)
func _get_minimum_size():
return Vector2(0, 5)
func pivot_at_line_start():
pivot_offset.x = 0
pivot_offset.y = size.y / 2.0
func join(from, to, offset=Vector2.ZERO, clip_rects=[]):
# Offset along perpendicular direction
var perp_dir = from.direction_to(to).rotated(deg_to_rad(90.0)).normalized()
from -= perp_dir * offset
to -= perp_dir * offset
var dist = from.distance_to(to)
var dir = from.direction_to(to)
var center = from + dir * dist / 2
# Clip line with provided Rect2 array
var clipped = [[from, to]]
var line_from = from
var line_to = to
for clip_rect in clip_rects:
if clipped.size() == 0:
break
line_from = clipped[0][0]
line_to = clipped[0][1]
clipped = Geometry2D.clip_polyline_with_polygon(
[line_from, line_to],
[clip_rect.position, Vector2(clip_rect.position.x, clip_rect.end.y),
clip_rect.end, Vector2(clip_rect.end.x, clip_rect.position.y)]
)
if clipped.size() > 0:
from = clipped[0][0]
to = clipped[0][1]
else: # Line is totally overlapped
from = center
to = center + dir * 0.1
# Extends line by 2px to minimise ugly seam
from -= dir * 2.0
to += dir * 2.0
size.x = to.distance_to(from)
# size.y equals to the thickness of line
position = from
position.y -= size.y / 2.0
rotation = Vector2.RIGHT.angle_to(dir)
pivot_at_line_start()
func set_selected(v):
if selected != v:
selected = v
queue_redraw()
func get_from_pos():
return get_transform() * (position)
func get_to_pos():
return get_transform() * (position + size)

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,33 @@
@tool
extends Container
# Custom style normal, focus
var selected: = false:
set = set_selected
func _init():
focus_mode = FOCUS_NONE # Let FlowChart has the focus to handle gui_input
mouse_filter = MOUSE_FILTER_PASS
func _draw():
if selected:
draw_style_box(get_theme_stylebox("focus", "FlowChartNode"), Rect2(Vector2.ZERO, size))
else:
draw_style_box(get_theme_stylebox("normal", "FlowChartNode"), Rect2(Vector2.ZERO, size))
func _notification(what):
match what:
NOTIFICATION_SORT_CHILDREN:
for child in get_children():
if child is Control:
fit_child_in_rect(child, Rect2(Vector2.ZERO, size))
func _get_minimum_size():
return Vector2(50, 50)
func set_selected(v):
if selected != v:
selected = v
queue_redraw()

View File

@ -0,0 +1,34 @@
[gd_scene load_steps=5 format=3 uid="uid://bar1eob74t82f"]
[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/flowchart/FlowChartNode.gd" id="1"]
[sub_resource type="StyleBoxFlat" id="1"]
bg_color = Color(0.164706, 0.164706, 0.164706, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.901961, 0.756863, 0.243137, 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="2"]
bg_color = Color(0.164706, 0.164706, 0.164706, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="Theme" id="3"]
FlowChartNode/styles/focus = SubResource("1")
FlowChartNode/styles/normal = SubResource("2")
[node name="FlowChartNode" type="Container"]
theme = SubResource("3")
script = ExtResource("1")

View File

@ -0,0 +1,11 @@
extends EditorInspectorPlugin
const State = preload("res://addons/imjp94.yafsm/src/states/State.gd")
func _can_handle(object):
return object is State
func _parse_property(object, type, path, hint, hint_text, usage, wide) -> bool:
return false
# Hide all property
return true

View File

@ -0,0 +1,82 @@
@tool
extends "res://addons/imjp94.yafsm/scenes/flowchart/FlowChartNode.gd"
const State = preload("../../src/states/State.gd")
const StateMachine = preload("../../src/states/StateMachine.gd")
signal name_edit_entered(new_name) # Emits when focused exit or Enter pressed
@onready var name_edit = $MarginContainer/NameEdit
var undo_redo
var state:
set = set_state
func _init():
super._init()
set_state(State.new())
func _ready():
name_edit.focus_exited.connect(_on_NameEdit_focus_exited)
name_edit.text_submitted.connect(_on_NameEdit_text_submitted)
set_process_input(false) # _input only required when name_edit enabled to check mouse click outside
func _draw():
if state is StateMachine:
if selected:
draw_style_box(get_theme_stylebox("nested_focus", "StateNode"), Rect2(Vector2.ZERO, size))
else:
draw_style_box(get_theme_stylebox("nested_normal", "StateNode"), Rect2(Vector2.ZERO, size))
else:
super._draw()
func _input(event):
if event is InputEventMouseButton:
if event.pressed:
# Detect click outside rect
if get_viewport().gui_get_focus_owner() == name_edit:
var local_event = make_input_local(event)
if not name_edit.get_rect().has_point(local_event.position):
name_edit.release_focus()
func enable_name_edit(v):
if v:
set_process_input(true)
name_edit.editable = true
name_edit.selecting_enabled = true
name_edit.mouse_filter = MOUSE_FILTER_PASS
mouse_default_cursor_shape = CURSOR_IBEAM
name_edit.grab_focus()
else:
set_process_input(false)
name_edit.editable = false
name_edit.selecting_enabled = false
name_edit.mouse_filter = MOUSE_FILTER_IGNORE
mouse_default_cursor_shape = CURSOR_ARROW
name_edit.release_focus()
func _on_state_name_changed(new_name):
name_edit.text = new_name
size.x = 0 # Force reset horizontal size
func _on_state_changed(new_state):
if state:
state.name_changed.connect(_on_state_name_changed)
if name_edit:
name_edit.text = state.name
func _on_NameEdit_focus_exited():
enable_name_edit(false)
name_edit.deselect()
emit_signal("name_edit_entered", name_edit.text)
func _on_NameEdit_text_submitted(new_text):
enable_name_edit(false)
emit_signal("name_edit_entered", new_text)
func set_state(s):
if state != s:
state = s
_on_state_changed(s)

View File

@ -0,0 +1,79 @@
[gd_scene load_steps=8 format=3 uid="uid://l3mqbqjwjkc3"]
[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/state_nodes/StateNode.gd" id="2"]
[ext_resource type="SystemFont" uid="uid://dmcxm8gxsonbq" path="res://addons/imjp94.yafsm/assets/fonts/sans_serif.tres" id="2_352m3"]
[sub_resource type="StyleBoxFlat" id="1"]
bg_color = Color(0.164706, 0.164706, 0.164706, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
border_color = Color(0.44, 0.73, 0.98, 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="2"]
bg_color = Color(0.164706, 0.164706, 0.164706, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
corner_detail = 2
[sub_resource type="StyleBoxFlat" id="3"]
bg_color = Color(0.164706, 0.164706, 0.164706, 1)
border_width_left = 3
border_width_top = 3
border_width_right = 3
border_width_bottom = 3
border_color = Color(0.960784, 0.772549, 0.333333, 1)
shadow_size = 2
[sub_resource type="StyleBoxFlat" id="4"]
bg_color = Color(0.164706, 0.164706, 0.164706, 1)
border_width_left = 3
border_width_top = 3
border_width_right = 3
border_width_bottom = 3
shadow_size = 2
[sub_resource type="Theme" id="5"]
FlowChartNode/styles/focus = SubResource("1")
FlowChartNode/styles/normal = SubResource("2")
StateNode/styles/nested_focus = SubResource("3")
StateNode/styles/nested_normal = SubResource("4")
[node name="StateNode" type="HBoxContainer"]
grow_horizontal = 2
grow_vertical = 2
theme = SubResource("5")
script = ExtResource("2")
[node name="MarginContainer" type="MarginContainer" parent="."]
layout_mode = 2
mouse_filter = 2
theme_override_constants/margin_left = 5
theme_override_constants/margin_top = 5
theme_override_constants/margin_right = 5
theme_override_constants/margin_bottom = 5
[node name="NameEdit" type="LineEdit" parent="MarginContainer"]
layout_mode = 2
size_flags_horizontal = 4
size_flags_vertical = 4
mouse_filter = 2
mouse_default_cursor_shape = 0
theme_override_fonts/font = ExtResource("2_352m3")
text = "State"
alignment = 1
editable = false
expand_to_text_length = true
selecting_enabled = false
caret_blink = true

View File

@ -0,0 +1,185 @@
@tool
extends VBoxContainer
const Utils = preload("../../scripts/Utils.gd")
const ConditionEditor = preload("../condition_editors/ConditionEditor.tscn")
const BoolConditionEditor = preload("../condition_editors/BoolConditionEditor.tscn")
const IntegerConditionEditor = preload("../condition_editors/IntegerConditionEditor.tscn")
const FloatConditionEditor = preload("../condition_editors/FloatConditionEditor.tscn")
const StringConditionEditor = preload("../condition_editors/StringConditionEditor.tscn")
@onready var header = $HeaderContainer/Header
@onready var title = $HeaderContainer/Header/Title
@onready var title_icon = $HeaderContainer/Header/Title/Icon
@onready var from = $HeaderContainer/Header/Title/From
@onready var to = $HeaderContainer/Header/Title/To
@onready var condition_count_icon = $HeaderContainer/Header/ConditionCount/Icon
@onready var condition_count_label = $HeaderContainer/Header/ConditionCount/Label
@onready var priority_icon = $HeaderContainer/Header/Priority/Icon
@onready var priority_spinbox = $HeaderContainer/Header/Priority/SpinBox
@onready var add = $HeaderContainer/Header/HBoxContainer/Add
@onready var add_popup_menu = $HeaderContainer/Header/HBoxContainer/Add/PopupMenu
@onready var content_container = $MarginContainer
@onready var condition_list = $MarginContainer/Conditions
var undo_redo
var transition:
set = set_transition
var _to_free
func _init():
_to_free = []
func _ready():
header.gui_input.connect(_on_header_gui_input)
priority_spinbox.value_changed.connect(_on_priority_spinbox_value_changed)
add.pressed.connect(_on_add_pressed)
add_popup_menu.index_pressed.connect(_on_add_popup_menu_index_pressed)
condition_count_icon.texture = get_theme_icon("MirrorX", "EditorIcons")
priority_icon.texture = get_theme_icon("AnimationTrackGroup", "EditorIcons")
func _exit_tree():
free_node_from_undo_redo() # Managed by EditorInspector
func _on_header_gui_input(event):
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
toggle_conditions()
func _on_priority_spinbox_value_changed(val: int) -> void:
set_priority(val)
func _on_add_pressed():
Utils.popup_on_target(add_popup_menu, add)
func _on_add_popup_menu_index_pressed(index):
## Handle condition name duplication (4.x changed how duplicates are
## automatically handled and gave a random index instead of a progressive one)
var default_new_condition_name = "Param"
var condition_dup_index = 0
var new_name = default_new_condition_name
for condition_editor in condition_list.get_children():
var condition_name = condition_editor.condition.name
if (condition_name == new_name):
condition_dup_index += 1
new_name = "%s%s" % [default_new_condition_name, condition_dup_index]
var condition
match index:
0: # Trigger
condition = Condition.new(new_name)
1: # Boolean
condition = BooleanCondition.new(new_name)
2: # Integer
condition = IntegerCondition.new(new_name)
3: # Float
condition = FloatCondition.new(new_name)
4: # String
condition = StringCondition.new(new_name)
_:
push_error("Unexpected index(%d) from PopupMenu" % index)
var editor = create_condition_editor(condition)
add_condition_editor_action(editor, condition)
func _on_ConditionEditorRemove_pressed(editor):
remove_condition_editor_action(editor)
func _on_transition_changed(new_transition):
if not new_transition:
return
for condition in transition.conditions.values():
var editor = create_condition_editor(condition)
add_condition_editor(editor, condition)
update_title()
update_condition_count()
update_priority_spinbox_value()
func _on_condition_editor_added(editor):
editor.undo_redo = undo_redo
if not editor.remove.pressed.is_connected(_on_ConditionEditorRemove_pressed):
editor.remove.pressed.connect(_on_ConditionEditorRemove_pressed.bind(editor))
transition.add_condition(editor.condition)
update_condition_count()
func add_condition_editor(editor, condition):
condition_list.add_child(editor)
editor.condition = condition # Must be assigned after enter tree, as assignment would trigger ui code
_on_condition_editor_added(editor)
func remove_condition_editor(editor):
transition.remove_condition(editor.condition.name)
condition_list.remove_child(editor)
_to_free.append(editor) # Freeing immediately after removal will break undo/redo
update_condition_count()
func update_title():
from.text = transition.from
to.text = transition.to
func update_condition_count():
var count = transition.conditions.size()
condition_count_label.text = str(count)
if count == 0:
hide_conditions()
else:
show_conditions()
func update_priority_spinbox_value():
priority_spinbox.value = transition.priority
priority_spinbox.apply()
func set_priority(value):
transition.priority = value
func show_conditions():
content_container.visible = true
func hide_conditions():
content_container.visible = false
func toggle_conditions():
content_container.visible = !content_container.visible
func create_condition_editor(condition):
var editor
if condition is BooleanCondition:
editor = BoolConditionEditor.instantiate()
elif condition is IntegerCondition:
editor = IntegerConditionEditor.instantiate()
elif condition is FloatCondition:
editor = FloatConditionEditor.instantiate()
elif condition is StringCondition:
editor = StringConditionEditor.instantiate()
else:
editor = ConditionEditor.instantiate()
return editor
func add_condition_editor_action(editor, condition):
undo_redo.create_action("Add Transition Condition")
undo_redo.add_do_method(self, "add_condition_editor", editor, condition)
undo_redo.add_undo_method(self, "remove_condition_editor", editor)
undo_redo.commit_action()
func remove_condition_editor_action(editor):
undo_redo.create_action("Remove Transition Condition")
undo_redo.add_do_method(self, "remove_condition_editor", editor)
undo_redo.add_undo_method(self, "add_condition_editor", editor, editor.condition)
undo_redo.commit_action()
func set_transition(t):
if transition != t:
transition = t
_on_transition_changed(t)
# Free nodes cached in UndoRedo stack
func free_node_from_undo_redo():
for node in _to_free:
if is_instance_valid(node):
var history_id = undo_redo.get_object_history_id(node)
undo_redo.get_history_undo_redo(history_id).clear_history(false) # TODO: Should be handled by plugin.gd (Temporary solution as only TransitionEditor support undo/redo)
node.queue_free()
_to_free.clear()

View File

@ -0,0 +1,133 @@
[gd_scene load_steps=7 format=3 uid="uid://dw0ecw2wdeosi"]
[ext_resource type="Texture2D" uid="uid://dg8cmn5ubq6r5" path="res://addons/imjp94.yafsm/assets/icons/add-white-18dp.svg" id="1"]
[ext_resource type="Script" path="res://addons/imjp94.yafsm/scenes/transition_editors/TransitionEditor.gd" id="3"]
[sub_resource type="Gradient" id="Gradient_hw7k8"]
offsets = PackedFloat32Array(1)
colors = PackedColorArray(1, 1, 1, 1)
[sub_resource type="GradientTexture2D" id="GradientTexture2D_ipxab"]
gradient = SubResource("Gradient_hw7k8")
width = 18
height = 18
[sub_resource type="Image" id="Image_o35y7"]
data = {
"data": PackedByteArray(255, 255, 255, 0, 255, 255, 255, 0, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 131, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 131, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 131, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 94, 94, 127, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 128, 128, 4, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 231, 255, 93, 93, 55, 255, 97, 97, 58, 255, 93, 93, 233, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 231, 255, 94, 94, 54, 255, 94, 94, 57, 255, 93, 93, 233, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 97, 97, 42, 255, 255, 255, 0, 255, 255, 255, 0, 255, 97, 97, 42, 255, 93, 93, 233, 255, 93, 93, 232, 255, 93, 93, 41, 255, 255, 255, 0, 255, 255, 255, 0, 255, 97, 97, 42, 255, 93, 93, 233, 255, 93, 93, 232, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 96, 96, 45, 255, 97, 97, 42, 255, 255, 255, 0, 255, 97, 97, 42, 255, 97, 97, 42, 255, 255, 255, 0, 255, 98, 98, 47, 255, 97, 97, 42, 255, 255, 255, 0, 255, 97, 97, 42, 255, 97, 97, 42, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 96, 96, 45, 255, 93, 93, 235, 255, 93, 93, 233, 255, 97, 97, 42, 255, 255, 255, 0, 255, 255, 255, 0, 255, 94, 94, 46, 255, 93, 93, 236, 255, 93, 93, 233, 255, 97, 97, 42, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 235, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 233, 255, 95, 95, 59, 255, 96, 96, 61, 255, 93, 93, 235, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 233, 255, 95, 95, 59, 255, 96, 96, 61, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 93, 93, 255, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 93, 93, 252, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0, 255, 255, 255, 0),
"format": "RGBA8",
"height": 16,
"mipmaps": false,
"width": 16
}
[sub_resource type="ImageTexture" id="ImageTexture_v636r"]
image = SubResource("Image_o35y7")
[node name="TransitionEditor" type="VBoxContainer"]
script = ExtResource("3")
[node name="HeaderContainer" type="MarginContainer" parent="."]
layout_mode = 2
[node name="Panel" type="Panel" parent="HeaderContainer"]
layout_mode = 2
[node name="Header" type="HBoxContainer" parent="HeaderContainer"]
layout_mode = 2
[node name="Title" type="HBoxContainer" parent="HeaderContainer/Header"]
layout_mode = 2
tooltip_text = "Next State"
[node name="From" type="Label" parent="HeaderContainer/Header/Title"]
layout_mode = 2
size_flags_horizontal = 3
text = "From"
[node name="Icon" type="TextureRect" parent="HeaderContainer/Header/Title"]
texture_filter = 1
layout_mode = 2
texture = SubResource("GradientTexture2D_ipxab")
expand_mode = 3
stretch_mode = 3
[node name="To" type="Label" parent="HeaderContainer/Header/Title"]
layout_mode = 2
size_flags_horizontal = 3
text = "To"
[node name="VSeparator" type="VSeparator" parent="HeaderContainer/Header"]
layout_mode = 2
[node name="ConditionCount" type="HBoxContainer" parent="HeaderContainer/Header"]
layout_mode = 2
tooltip_text = "Number of Conditions"
[node name="Icon" type="TextureRect" parent="HeaderContainer/Header/ConditionCount"]
texture_filter = 1
layout_mode = 2
texture = SubResource("ImageTexture_v636r")
expand_mode = 3
stretch_mode = 3
[node name="Label" type="Label" parent="HeaderContainer/Header/ConditionCount"]
layout_mode = 2
text = "No."
[node name="VSeparator2" type="VSeparator" parent="HeaderContainer/Header"]
layout_mode = 2
[node name="Priority" type="HBoxContainer" parent="HeaderContainer/Header"]
layout_mode = 2
tooltip_text = "Priority"
[node name="Icon" type="TextureRect" parent="HeaderContainer/Header/Priority"]
texture_filter = 1
layout_mode = 2
texture = SubResource("ImageTexture_v636r")
expand_mode = 3
stretch_mode = 3
[node name="SpinBox" type="SpinBox" parent="HeaderContainer/Header/Priority"]
layout_mode = 2
max_value = 10.0
rounded = true
allow_greater = true
[node name="VSeparator3" type="VSeparator" parent="HeaderContainer/Header"]
layout_mode = 2
[node name="HBoxContainer" type="HBoxContainer" parent="HeaderContainer/Header"]
layout_mode = 2
size_flags_horizontal = 10
[node name="Add" type="Button" parent="HeaderContainer/Header/HBoxContainer"]
layout_mode = 2
tooltip_text = "Add Condition"
icon = ExtResource("1")
flat = true
[node name="PopupMenu" type="PopupMenu" parent="HeaderContainer/Header/HBoxContainer/Add"]
item_count = 5
item_0/text = "Trigger"
item_0/id = 0
item_1/text = "Boolean"
item_1/id = 1
item_2/text = "Integer"
item_2/id = 2
item_3/text = "Float"
item_3/id = 3
item_4/text = "String"
item_4/id = 4
[node name="MarginContainer" type="MarginContainer" parent="."]
layout_mode = 2
[node name="Panel" type="Panel" parent="MarginContainer"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="Conditions" type="VBoxContainer" parent="MarginContainer"]
layout_mode = 2

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