diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..7cee6e4
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,465 @@
+# --------------------------------------------------------------------------- #
+# Chickensoft C# Style — .editorconfig #
+# --------------------------------------------------------------------------- #
+# Godot-friendly, K&R coding style with a bit of Dart-style flair thrown in. #
+# --------------------------------------------------------------------------- #
+# #
+# #
+# ╓╗_▄╗_╓▄_ #
+# ▄▄╟▓▓▓▓▓▓▓▓ #
+# ╙▓▓▓▀▀╠╠╦╦╓,_ #
+# ,φ╠╠╠╠╠╠╠╠╠╠▒╥ #
+# φ╠╠╠╠╠╠╠╠╠╠╠╠╠╠╦ #
+# @╠╠╫▌╠╟▌╠╠╠╠╠╠╠╠╠ #
+# ╠╠╠▄▄▄▒╠╠╠╠╠╠╠╠╠╠b #
+# ╠╠╨███▌╠╠╠╠╠╠╠▒╠╠▒_ ç╓ #
+# ╠╠╠╠▒▒╠╠╠╠╠╠╠╠▒Å▄╠╬▒φ╩ε #
+# ╚╠╠╠╠╠╠╠╠╠╠╠▒█▒╫█Å╠╠╩ #
+# ╠╠╠╠╠╠╠╠╠╠╠╠╠╟╫▒╠╠╩ #
+# ╙╠╠╠╠╠╠╠╠╠╠╠╠╠╠╠╜ #
+# ╙╚╠╠╠╠╠╠╠╠╩╙ #
+# ╒ µ #
+# ▌ ▓ #
+# ^▀▀ "▀ª #
+# #
+# #
+# --------------------------------------------------------------------------- #
+#
+# Based on:
+# - https://github.com/RehanSaeed/EditorConfig/blob/main/.editorconfig
+# - https://gist.github.com/FaronBracy/155d8d7ad98b4ceeb526b9f47543db1b
+# - various other gists floating around :)
+#
+# Have a problem? Encounter an issue?
+# Come visit our Discord and let us know! https://discord.gg/MjA6HUzzAE
+#
+# Based on https://github.com/RehanSaeed/EditorConfig/blob/main/.editorconfig
+# and https://gist.github.com/FaronBracy/155d8d7ad98b4ceeb526b9f47543db1b
+
+# This file is the top-most EditorConfig file
+root = true
+
+# All Files
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+##########################################
+# File Extension Settings
+##########################################
+
+# Visual Studio Solution Files
+[*.sln]
+indent_style = tab
+
+# Visual Studio XML Project Files
+[*.{csproj,vbproj,vcxproj.filters,proj,projitems,shproj}]
+indent_size = 2
+
+# XML Configuration Files
+[*.{xml,config,props,targets,nuspec,resx,ruleset,vsixmanifest,vsct}]
+indent_size = 2
+
+# JSON Files
+[*.{json,json5,webmanifest}]
+indent_size = 2
+
+# YAML Files
+[*.{yml,yaml}]
+indent_size = 2
+
+# Markdown Files
+[*.{md,mdx}]
+trim_trailing_whitespace = false
+max_line_length = off # use soft wrapping in editor
+
+# Web Files
+[*.{htm,html,js,jsm,ts,tsx,cjs,cts,ctsx,mjs,mts,mtsx,css,sass,scss,less,pcss,svg,vue}]
+indent_size = 2
+
+# Batch Files
+[*.{cmd,bat}]
+end_of_line = crlf
+
+# Bash Files
+[*.sh]
+end_of_line = lf
+
+# Makefiles
+[Makefile]
+indent_style = tab
+
+[*{._Generated.cs,.g.cs,.generated.cs}]
+# Ignore a lack of documentation for generated code. Doesn't apply to builds,
+# just to viewing generation output.
+dotnet_diagnostic.CS1591.severity = none
+
+##########################################
+# Default .NET Code Style Severities
+##########################################
+
+[*.{cs,csx,cake,vb,vbx}]
+# Default Severity for all .NET Code Style rules below
+dotnet_analyzer_diagnostic.severity = warning
+
+##########################################
+# Language Rules
+##########################################
+
+# .NET Style Rules
+
+# "this." and "Me." qualifiers
+dotnet_style_qualification_for_field = false
+dotnet_style_qualification_for_property = false
+dotnet_style_qualification_for_method = false
+dotnet_style_qualification_for_event = false
+
+# Language keywords instead of framework type names for type references
+dotnet_style_predefined_type_for_locals_parameters_members = true:warning
+dotnet_style_predefined_type_for_member_access = true:warning
+
+# Modifier preferences
+dotnet_style_require_accessibility_modifiers = always:warning
+csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async:warning
+visual_basic_preferred_modifier_order = Partial, Default, Private, Protected, Public, Friend, NotOverridable, Overridable, MustOverride, Overloads, Overrides, MustInherit, NotInheritable, Static, Shared, Shadows, ReadOnly, WriteOnly, Dim, Const, WithEvents, Widening, Narrowing, Custom, Async:warning
+dotnet_style_readonly_field = true:warning
+
+# Parentheses preferences
+dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:warning
+dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:warning
+dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:warning
+dotnet_style_parentheses_in_other_operators = never_if_unnecessary:warning
+
+# Expression-level preferences
+dotnet_style_object_initializer = true:warning
+dotnet_style_collection_initializer = true:warning
+dotnet_style_explicit_tuple_names = true:warning
+dotnet_style_prefer_inferred_tuple_names = true:warning
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:warning
+dotnet_style_prefer_auto_properties = true:warning
+dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
+dotnet_diagnostic.IDE0045.severity = suggestion
+dotnet_style_prefer_conditional_expression_over_return = true:suggestion
+dotnet_diagnostic.IDE0046.severity = suggestion
+dotnet_style_prefer_compound_assignment = true:warning
+dotnet_style_prefer_simplified_interpolation = true:warning
+dotnet_style_prefer_simplified_boolean_expressions = true:warning
+
+# Null-checking preferences
+dotnet_style_coalesce_expression = true:warning
+dotnet_style_null_propagation = true:warning
+dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning
+
+# File header preferences
+# Keep operators at end of line when wrapping.
+dotnet_style_operator_placement_when_wrapping = end_of_line
+csharp_style_prefer_null_check_over_type_check = true:warning
+
+# Code block preferences
+csharp_prefer_braces = true:warning
+csharp_prefer_simple_using_statement = true:suggestion
+dotnet_diagnostic.IDE0063.severity = suggestion
+
+# C# Style Rules
+[*.{cs,csx,cake}]
+# 'var' preferences
+csharp_style_var_for_built_in_types = true:warning
+csharp_style_var_when_type_is_apparent = true:warning
+csharp_style_var_elsewhere = true:warning
+# Expression-bodied members
+csharp_style_expression_bodied_methods = true:warning
+csharp_style_expression_bodied_constructors = false:warning
+csharp_style_expression_bodied_operators = true:warning
+csharp_style_expression_bodied_properties = true:warning
+csharp_style_expression_bodied_indexers = true:warning
+csharp_style_expression_bodied_accessors = true:warning
+csharp_style_expression_bodied_lambdas = true:warning
+csharp_style_expression_bodied_local_functions = true:warning
+# Pattern matching preferences
+csharp_style_pattern_matching_over_is_with_cast_check = true:warning
+csharp_style_pattern_matching_over_as_with_null_check = true:warning
+csharp_style_prefer_switch_expression = true:warning
+csharp_style_prefer_pattern_matching = true:warning
+csharp_style_prefer_not_pattern = true:warning
+# Expression-level preferences
+csharp_style_inlined_variable_declaration = true:warning
+csharp_prefer_simple_default_expression = true:warning
+csharp_style_pattern_local_over_anonymous_function = true:warning
+csharp_style_deconstructed_variable_declaration = true:warning
+csharp_style_prefer_index_operator = true:warning
+csharp_style_prefer_range_operator = true:warning
+csharp_style_implicit_object_creation_when_type_is_apparent = true:warning
+# "Null" checking preferences
+csharp_style_throw_expression = true:warning
+csharp_style_conditional_delegate_call = true:warning
+# Code block preferences
+csharp_prefer_braces = true:warning
+csharp_prefer_simple_using_statement = true:suggestion
+dotnet_diagnostic.IDE0063.severity = suggestion
+# 'using' directive preferences
+csharp_using_directive_placement = inside_namespace:warning
+# Modifier preferences
+# Don't suggest making public methods static. Very annoying.
+csharp_prefer_static_local_function = false
+# Only suggest making private methods static (if they don't use instance data).
+dotnet_code_quality.CA1822.api_surface = private
+
+##########################################
+# Unnecessary Code Rules
+##########################################
+
+# .NET Unnecessary code rules
+
+dotnet_code_quality_unused_parameters = non_public:suggestion
+dotnet_remove_unnecessary_suppression_exclusions = none
+dotnet_diagnostic.IDE0079.severity = warning
+
+# C# Unnecessary code rules
+
+# Chickensoft Unused Code Additions
+#
+# Unfortunately for VSCode users, disabling these rules prevents you from
+# detecting unused code. Enabling them will trigger the roslyn analyzers'
+# automatic code fixes to remove unused code, which is very annoying when
+# you're actively coding or using reflection.
+#
+# I have not found a way to disable automatic fixes while keeping
+# warnings/suggestions/etc in the editor. If you find a way, please file an
+# issue or open a PR.
+
+# Don't remove method parameters that are unused.
+dotnet_diagnostic.IDE0060.severity = none
+dotnet_diagnostic.RCS1163.severity = none
+
+# Don't remove methods that are unused.
+dotnet_diagnostic.IDE0051.severity = none
+dotnet_diagnostic.RCS1213.severity = none
+
+# Use discard variable for unused expression values.
+csharp_style_unused_value_expression_statement_preference = discard_variable
+
+# .NET formatting rules
+
+# Organize using directives
+dotnet_sort_system_directives_first = true
+dotnet_separate_import_directive_groups = false
+
+# Dotnet namespace options
+#
+# We don't care about namespaces matching folder structure. Games and apps
+# are complicated and you are free to organize them however you like. Change
+# this if you want to enforce it.
+dotnet_style_namespace_match_folder = false
+dotnet_diagnostic.IDE0130.severity = none
+
+# C# formatting rules
+
+# Newline options
+csharp_new_line_before_open_brace = none
+csharp_new_line_before_else = true
+csharp_new_line_before_catch = true
+csharp_new_line_before_finally = true
+csharp_new_line_before_members_in_object_initializers = true
+csharp_new_line_before_members_in_anonymous_types = true
+csharp_new_line_between_query_expression_clauses = true
+
+# Indentation options
+csharp_indent_switch_labels = true
+csharp_indent_case_contents = true
+csharp_indent_case_contents_when_block = true
+csharp_indent_labels = no_change
+csharp_indent_block_contents = true
+csharp_indent_braces = false
+
+# Spacing options
+csharp_space_after_cast = false
+csharp_space_after_keywords_in_control_flow_statements = true
+csharp_space_between_parentheses = false
+csharp_space_before_colon_in_inheritance_clause = true
+csharp_space_after_colon_in_inheritance_clause = true
+csharp_space_around_binary_operators = before_and_after
+csharp_space_between_method_declaration_parameter_list_parentheses = false
+csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
+csharp_space_between_method_declaration_name_and_open_parenthesis = false
+csharp_space_between_method_call_parameter_list_parentheses = false
+csharp_space_between_method_call_empty_parameter_list_parentheses = false
+csharp_space_between_method_call_name_and_opening_parenthesis = false
+csharp_space_after_comma = true
+csharp_space_before_comma = false
+csharp_space_after_dot = false
+csharp_space_before_dot = false
+csharp_space_after_semicolon_in_for_statement = true
+csharp_space_before_semicolon_in_for_statement = false
+csharp_space_around_declaration_statements = false
+csharp_space_before_open_square_brackets = false
+csharp_space_between_empty_square_brackets = false
+csharp_space_between_square_brackets = false
+
+# Wrap options
+csharp_preserve_single_line_statements = false
+csharp_preserve_single_line_blocks = true
+
+# Namespace options
+csharp_style_namespace_declarations = file_scoped:warning
+
+##########################################
+# .NET Naming Rules
+##########################################
+
+##########################################
+# Chickensoft Naming Conventions & Styles
+# These deviate heavily from Microsoft's Official Naming Conventions.
+##########################################
+
+# Allow underscores in names.
+dotnet_diagnostic.CA1707.severity = none
+
+# Styles
+dotnet_naming_style.pascal_case_style.capitalization = pascal_case
+
+dotnet_naming_style.upper_case_style.capitalization = all_upper
+dotnet_naming_style.upper_case_style.word_separator = _
+
+dotnet_naming_style.camel_case_style.capitalization = camel_case
+
+dotnet_naming_style.camel_case_underscore_style.required_prefix = _
+dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case
+
+# Use uppercase for all constant fields.
+dotnet_naming_rule.constants_uppercase.severity = suggestion
+dotnet_naming_rule.constants_uppercase.symbols = constant_fields
+dotnet_naming_rule.constants_uppercase.style = upper_case_style
+dotnet_naming_symbols.constant_fields.applicable_kinds = field
+dotnet_naming_symbols.constant_fields.applicable_accessibilities = *
+dotnet_naming_symbols.constant_fields.required_modifiers = const
+
+# Non-public fields should be _camelCase
+dotnet_naming_rule.non_public_fields_under_camel.severity = suggestion
+dotnet_naming_rule.non_public_fields_under_camel.symbols = non_public_fields
+dotnet_naming_rule.non_public_fields_under_camel.style = camel_case_underscore_style
+dotnet_naming_symbols.non_public_fields.applicable_kinds = field
+dotnet_naming_symbols.non_public_fields.required_modifiers =
+dotnet_naming_symbols.non_public_fields.applicable_accessibilities = private, private_protected, protected, internal, protected, protected_internal
+
+# Public fields should be PascalCase
+dotnet_naming_rule.public_fields_pascal.severity = suggestion
+dotnet_naming_rule.public_fields_pascal.symbols = public_fields
+dotnet_naming_rule.public_fields_pascal.style = pascal_case_style
+dotnet_naming_symbols.public_fields.applicable_kinds = field
+dotnet_naming_symbols.public_fields.required_modifiers =
+dotnet_naming_symbols.public_fields.applicable_accessibilities = public
+
+# Async methods should have "Async" suffix.
+# Disabled because it makes tests too verbose.
+# dotnet_naming_style.end_in_async.required_suffix = Async
+# dotnet_naming_style.end_in_async.capitalization = pascal_case
+# dotnet_naming_rule.methods_end_in_async.symbols = methods_async
+# dotnet_naming_rule.methods_end_in_async.style = end_in_async
+# dotnet_naming_rule.methods_end_in_async.severity = warning
+# dotnet_naming_symbols.methods_async.applicable_kinds = method
+# dotnet_naming_symbols.methods_async.required_modifiers = async
+# dotnet_naming_symbols.methods_async.applicable_accessibilities = *
+
+##########################################
+# Other Naming Rules
+##########################################
+
+# All of the following must be PascalCase:
+dotnet_naming_symbols.element_group.applicable_kinds = namespace, class, enum, struct, delegate, event, method, property
+dotnet_naming_rule.element_rule.symbols = element_group
+dotnet_naming_rule.element_rule.style = pascal_case_style
+dotnet_naming_rule.element_rule.severity = warning
+
+# Interfaces use PascalCase and are prefixed with uppercase 'I'
+# https://docs.microsoft.com/dotnet/standard/design-guidelines/names-of-classes-structs-and-interfaces
+dotnet_naming_style.prefix_interface_with_i_style.capitalization = pascal_case
+dotnet_naming_style.prefix_interface_with_i_style.required_prefix = I
+dotnet_naming_symbols.interface_group.applicable_kinds = interface
+dotnet_naming_rule.interface_rule.symbols = interface_group
+dotnet_naming_rule.interface_rule.style = prefix_interface_with_i_style
+dotnet_naming_rule.interface_rule.severity = warning
+
+# Generics Type Parameters use PascalCase and are prefixed with uppercase 'T'
+# https://docs.microsoft.com/dotnet/standard/design-guidelines/names-of-classes-structs-and-interfaces
+dotnet_naming_style.prefix_type_parameters_with_t_style.capitalization = pascal_case
+dotnet_naming_style.prefix_type_parameters_with_t_style.required_prefix = T
+dotnet_naming_symbols.type_parameter_group.applicable_kinds = type_parameter
+dotnet_naming_rule.type_parameter_rule.symbols = type_parameter_group
+dotnet_naming_rule.type_parameter_rule.style = prefix_type_parameters_with_t_style
+dotnet_naming_rule.type_parameter_rule.severity = warning
+
+# Function parameters use camelCase
+# https://docs.microsoft.com/dotnet/standard/design-guidelines/naming-parameters
+dotnet_naming_symbols.parameters_group.applicable_kinds = parameter
+dotnet_naming_rule.parameters_rule.symbols = parameters_group
+dotnet_naming_rule.parameters_rule.style = camel_case_style
+dotnet_naming_rule.parameters_rule.severity = warning
+
+# Anything not specified uses camel case.
+dotnet_naming_rule.unspecified_naming.severity = warning
+dotnet_naming_rule.unspecified_naming.symbols = unspecified
+dotnet_naming_rule.unspecified_naming.style = camel_case_style
+dotnet_naming_symbols.unspecified.applicable_kinds = *
+dotnet_naming_symbols.unspecified.applicable_accessibilities = *
+
+##########################################
+# Chickensoft Rule Overrides
+##########################################
+
+# Allow using keywords as names
+# dotnet_diagnostic.CA1716.severity = none
+# Don't require culture info for ToString()
+dotnet_diagnostic.CA1304.severity = none
+# Don't require a string comparison for comparing strings.
+dotnet_diagnostic.CA1310.severity = none
+# Don't require a string format specifier.
+dotnet_diagnostic.CA1305.severity = none
+# Allow protected fields.
+dotnet_diagnostic.CA1051.severity = none
+# Don't warn about checking values that are supposedly never null. Sometimes
+# they are actually null.
+dotnet_diagnostic.CS8073.severity = none
+# Don't remove seemingly "unnecessary" assignments, as they often have
+# intended side-effects.
+dotnet_diagnostic.IDE0059.severity = none
+# Switch/case should always have a default clause. Tell that to Roslynator.
+dotnet_diagnostic.RCS1070.severity = none
+# Tell roslynator not to eat unused parameters.
+dotnet_diagnostic.RCS1163.severity = none
+# Tell dotnet not to remove unused parameters.
+dotnet_diagnostic.IDE0060.severity = none
+# Tell roslynator not to remove `partial` modifiers.
+dotnet_diagnostic.RCS1043.severity = none
+# Tell roslynator not to make classes static so aggressively.
+dotnet_diagnostic.RCS1102.severity = none
+# Roslynator wants to make properties readonly all the time, so stop it.
+# The developer knows best when it comes to contract definitions with Godot.
+dotnet_diagnostic.RCS1170.severity = none
+# Allow expression values to go unused, even without discard variable.
+# Otherwise, using Moq would be way too verbose.
+dotnet_diagnostic.IDE0058.severity = none
+# Don't let roslynator turn every local variable into a const.
+# If we did, we'd have to specify the types of local variables far more often,
+# and this style prefers type inference.
+dotnet_diagnostic.RCS1118.severity = none
+# Enums don't need to declare explicit values. Everyone knows they start at 0.
+dotnet_diagnostic.RCS1161.severity = none
+# Allow unconstrained type parameter to be checked for null.
+dotnet_diagnostic.RCS1165.severity = none
+# Allow keyword-based names so that parameter names like `@event` can be used.
+dotnet_diagnostic.CA1716.severity = none
+# Let me put comments where I like
+dotnet_diagnostic.RCS1181.severity = none
+# Allow me to use the word Collection if I want.
+dotnet_diagnostic.CA1711.severity = none
+# No primary constructors — not supported well by tooling.
+dotnet_diagnostic.IDE0290.severity = none
+# Let me write dumb if statements for readability.
+dotnet_diagnostic.IDE0046.severity = none
+# Don't make me use expression bodies for methods
+dotnet_diagnostic.IDE0022.severity = none
+# Don't make me populate a switch expression redundantly
+dotnet_diagnostic.IDE0072.severity = none
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..3b7ca90
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,46 @@
+# Normalize EOL for all files that Git considers text files.
+* text=auto eol=lf
+
+# Image formats
+*.bmp filter=lfs diff=lfs merge=lfs -text
+*.dds filter=lfs diff=lfs merge=lfs -text
+*.exr filter=lfs diff=lfs merge=lfs -text
+*.hdr filter=lfs diff=lfs merge=lfs -text
+*.jpg filter=lfs diff=lfs merge=lfs -text
+*.jpeg filter=lfs diff=lfs merge=lfs -text
+*.png filter=lfs diff=lfs merge=lfs -text
+*.tga filter=lfs diff=lfs merge=lfs -text
+*.svg filter=lfs diff=lfs merge=lfs -text
+*.webp filter=lfs diff=lfs merge=lfs -text
+
+# Audio and Video formats
+*.mp3 filter=lfs diff=lfs merge=lfs -text
+*.wav filter=lfs diff=lfs merge=lfs -text
+*.ogg filter=lfs diff=lfs merge=lfs -text
+*.ogx filter=lfs diff=lfs merge=lfs -text
+*.ogv filter=lfs diff=lfs merge=lfs -text
+
+# 3D formats
+*.gltf filter=lfs diff=lfs merge=lfs -text
+*.blend filter=lfs diff=lfs merge=lfs -text
+*.blend1 filter=lfs diff=lfs merge=lfs -text
+*.glb filter=lfs diff=lfs merge=lfs -text
+*.dae filter=lfs diff=lfs merge=lfs -text
+*.obj filter=lfs diff=lfs merge=lfs -text
+*.fbx filter=lfs diff=lfs merge=lfs -text
+
+# Build
+*.dll filter=lfs diff=lfs merge=lfs -text
+*.exe filter=lfs diff=lfs merge=lfs -text
+*.pdb filter=lfs diff=lfs merge=lfs -text
+*.so filter=lfs diff=lfs merge=lfs -text
+*.dylib filter=lfs diff=lfs merge=lfs -text
+
+# Packaging
+*.zip filter=lfs diff=lfs merge=lfs -text
+*.7z filter=lfs diff=lfs merge=lfs -text
+*.gz filter=lfs diff=lfs merge=lfs -text
+*.rar filter=lfs diff=lfs merge=lfs -text
+*.tar filter=lfs diff=lfs merge=lfs -text
+*.file filter=lfs diff=lfs merge=lfs -text
+*.dylib filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/workflows/auto_release.yaml b/.github/workflows/auto_release.yaml
new file mode 100644
index 0000000..5209f30
--- /dev/null
+++ b/.github/workflows/auto_release.yaml
@@ -0,0 +1,81 @@
+# This workflow will run whenever tests finish running. If tests pass, it will
+# look at the last commit message to see if it contains the phrase
+# "chore(deps): update all dependencies".
+#
+# If it finds a commit with that phrase, and the testing workflow has passed,
+# it will automatically release a new version of the project by running the
+# publish workflow.
+#
+# The commit message phrase above is always used by renovatebot when opening
+# PR's to update dependencies. If you have renovatebot enabled and set to
+# automatically merge in dependency updates, this can automatically release and
+# publish the updated version of the project.
+#
+# You can disable this action by setting the DISABLE_AUTO_RELEASE repository
+# variable to true.
+
+name: '🦾 Auto-Release'
+on:
+ workflow_run:
+ workflows: ["🚥 Tests"]
+ branches:
+ - main
+ types:
+ - completed
+
+jobs:
+ auto_release:
+ name: 🦾 Auto-Release
+ runs-on: ubuntu-latest
+ outputs:
+ should_release: ${{ steps.release.outputs.should_release }}
+ steps:
+ - name: 🧾 Checkout
+ uses: actions/checkout@v4
+ with:
+ # Use your GitHub Personal Access Token variable here.
+ token: ${{ secrets.GH_BASIC }}
+ lfs: true
+ submodules: 'recursive'
+
+ - name: 🧑🔬 Check Test Results
+ id: tests
+ run: |
+ echo "passed=${{ github.event.workflow_run.conclusion == 'success' }}" >> "$GITHUB_OUTPUT"
+
+ - name: 📄 Check If Dependencies Changed
+ id: deps
+ run: |
+ message=$(git log -1 --pretty=%B)
+
+ if [[ $message == *"chore(deps)"* ]]; then
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "changed=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: 📝 Check Release Status
+ id: release
+ run: |
+ echo "Tests passed: ${{ steps.tests.outputs.passed }}"
+ echo "Dependencies changed: ${{ steps.deps.outputs.changed }}"
+ disable_auto_release='${{ vars.DISABLE_AUTO_RELEASE }}'
+ echo "DISABLE_AUTO_RELEASE=$disable_auto_release"
+
+ if [[ ${{ steps.tests.outputs.passed }} == "true" && ${{ steps.deps.outputs.changed }} == "true" && $disable_auto_release != "true" ]]; then
+ echo "should_release=true" >> "$GITHUB_OUTPUT"
+ echo "🦾 Creating a release!"
+ else
+ echo "should_release=false" >> "$GITHUB_OUTPUT"
+ echo "✋ Not creating a release."
+ fi
+
+ trigger_release:
+ uses: './.github/workflows/release.yaml'
+ needs: auto_release
+ if: needs.auto_release.outputs.should_release == 'true'
+ secrets:
+ NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
+ GH_BASIC: ${{ secrets.GH_BASIC }}
+ with:
+ bump: patch
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
new file mode 100644
index 0000000..f9735f6
--- /dev/null
+++ b/.github/workflows/release.yaml
@@ -0,0 +1,99 @@
+name: '📦 Release'
+on:
+ # Make a release whenever the developer wants.
+ workflow_dispatch:
+ inputs:
+ bump:
+ type: string
+ description: "major, minor, or patch"
+ required: true
+ default: "patch"
+ # Make a release whenever we're told to by another workflow.
+ workflow_call:
+ secrets:
+ NUGET_API_KEY:
+ description: "API key for Nuget"
+ required: true
+ GH_BASIC:
+ description: "Personal access token (PAT) for GitHub"
+ required: true
+ # Input unifies with the workflow dispatch since it's identical.
+ inputs:
+ bump:
+ type: string
+ description: "major, minor, or patch"
+ required: true
+ default: "patch"
+jobs:
+ release:
+ name: '📦 Release'
+ runs-on: ubuntu-latest
+ env:
+ DOTNET_CLI_TELEMETRY_OPTOUT: true
+ DOTNET_NOLOGO: true
+ steps:
+ - name: 🧾 Checkout
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.GH_BASIC }}
+ lfs: true
+ submodules: 'recursive'
+ fetch-depth: 0 # So we can get all tags.
+
+ - name: 🔎 Read Current Project Version
+ id: current-version
+ uses: WyriHaximus/github-action-get-previous-tag@v1
+ with:
+ fallback: "0.0.0-devbuild"
+
+ - name: 🖨 Print Current Version
+ run: |
+ echo "Current Version: ${{ steps.current-version.outputs.tag }}"
+
+ - name: 🧮 Compute Next Version
+ uses: chickensoft-games/next-godot-csproj-version@v1
+ id: next-version
+ with:
+ project-version: ${{ steps.current-version.outputs.tag }}
+ godot-version: global.json
+ bump: ${{ inputs.bump }}
+
+ - uses: actions/setup-dotnet@v4
+ name: 💽 Setup .NET SDK
+ with:
+ # Use the .NET SDK from global.json in the root of the repository.
+ global-json-file: global.json
+
+ # Write version to file so .NET will build correct version.
+ - name: 📝 Write Version to File
+ uses: jacobtomlinson/gha-find-replace@v3
+ with:
+ find: "0.0.0-devbuild"
+ replace: ${{ steps.next-version.outputs.version }}
+ regex: false
+ include: Chickensoft.PalettePainter/Chickensoft.PalettePainter.csproj
+
+ - name: 📦 Build
+ run: dotnet build \
+ Chickensoft.PalettePainter/Chickensoft.PalettePainter.csproj -c Release
+
+ - name: 🔎 Get Package Path
+ id: package-path
+ run: |
+ package=$(find ./Chickensoft.PalettePainter/nupkg -name "*.nupkg")
+ echo "package=$package" >> "$GITHUB_OUTPUT"
+ echo "📦 Found package: $package"
+
+ - name: ✨ Create Release
+ env:
+ GITHUB_TOKEN: ${{ secrets.GH_BASIC }}
+ run: |
+ version="${{ steps.next-version.outputs.version }}"
+ gh release create --title "v$version" --generate-notes "$version" \
+ "${{ steps.package-path.outputs.package }}"
+
+ - name: 🛜 Publish to Nuget
+ run: |
+ dotnet nuget push "${{ steps.package-path.outputs.package }}" \
+ --api-key "${{ secrets.NUGET_API_KEY }}" \
+ --source "https://api.nuget.org/v3/index.json" --skip-duplicate
diff --git a/.github/workflows/spellcheck.yaml b/.github/workflows/spellcheck.yaml
new file mode 100644
index 0000000..c7ca58f
--- /dev/null
+++ b/.github/workflows/spellcheck.yaml
@@ -0,0 +1,26 @@
+name: '🧑🏫 Spellcheck'
+on:
+ push:
+ pull_request:
+
+jobs:
+ spellcheck:
+ name: '🧑🏫 Spellcheck'
+ # Only run the workflow if it's not a PR or if it's a PR from a fork.
+ # This prevents duplicate workflows from running on PR's that originate
+ # from the repository itself.
+ if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: '.'
+ steps:
+ - uses: actions/checkout@v4
+ name: 🧾 Checkout
+
+ - uses: streetsidesoftware/cspell-action@v6
+ name: 📝 Check Spelling
+ with:
+ config: './cspell.json'
+ incremental_files_only: false
+ root: '.'
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
new file mode 100644
index 0000000..5a25f2d
--- /dev/null
+++ b/.github/workflows/tests.yaml
@@ -0,0 +1,68 @@
+name: 🚥 Tests
+on:
+ push:
+ pull_request:
+
+jobs:
+ tests:
+ name: 🧪 Evaluate Tests on ${{ matrix.os }}
+ # Only run the workflow if it's not a PR or if it's a PR from a fork.
+ # This prevents duplicate workflows from running on PR's that originate
+ # from the repository itself.
+ if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
+ runs-on: ${{ matrix.os }}
+ strategy:
+ # Don't cancel other OS runners if one fails.
+ fail-fast: false
+ matrix:
+ # Put the operating systems you want to run on here.
+ #
+ # You can change windows-2019 to windows-latest, but windows-2019
+ # was running in half the time. Try it out and see what works best.
+ os: [ubuntu-latest, macos-latest, windows-2019]
+ env:
+ DOTNET_CLI_TELEMETRY_OPTOUT: true
+ DOTNET_NOLOGO: true
+ defaults:
+ run:
+ # Use bash shells on all platforms.
+ shell: bash
+ steps:
+ - name: 🧾 Checkout
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.GH_BASIC }}
+ lfs: true
+ submodules: 'recursive'
+
+ - name: 💽 Setup .NET SDK
+ uses: actions/setup-dotnet@v4
+ with:
+ # Use the .NET SDK from global.json in the root of the repository.
+ global-json-file: global.json
+
+ - name: 📦 Restore Dependencies
+ run: dotnet restore
+
+ - name: 🤖 Setup Godot
+ uses: chickensoft-games/setup-godot@v2
+ with:
+ # Version must include major, minor, and patch, and be >= 4.0.0
+ # Pre-release label is optional.
+ #
+ # In this case, we are using the version from global.json.
+ #
+ # This allows checks on renovatebot PR's to succeed whenever
+ # renovatebot updates the Godot SDK version.
+ version: global.json
+
+ - name: 🧑🔬 Generate .NET Bindings
+ working-directory: Chickensoft.PalettePainter.Tests
+ run: godot --headless --build-solutions --quit || exit 0
+
+ - name: 🦺 Build Projects
+ run: dotnet build
+
+ - name: 🧪 Run Tests
+ working-directory: Chickensoft.PalettePainter.Tests
+ run: godot --headless --run-tests --quit-on-finish
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..377ed34
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+**/coverage/*
+!**/coverage/.gdignore
+nupkg/
+.godot/
+bin/
+obj/
+.generated/
+.vs/
+.DS_Store
+*.DotSettings.user
+*.binlog
diff --git a/.idea/.idea.Chickensoft.PalettePainter/.idea/.gitignore b/.idea/.idea.Chickensoft.PalettePainter/.idea/.gitignore
new file mode 100644
index 0000000..6c78d2d
--- /dev/null
+++ b/.idea/.idea.Chickensoft.PalettePainter/.idea/.gitignore
@@ -0,0 +1,13 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Rider ignored files
+/contentModel.xml
+/.idea.Chickensoft.PalettePainter.iml
+/modules.xml
+/projectSettingsUpdater.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/.idea.Chickensoft.PalettePainter/.idea/.name b/.idea/.idea.Chickensoft.PalettePainter/.idea/.name
new file mode 100644
index 0000000..4030062
--- /dev/null
+++ b/.idea/.idea.Chickensoft.PalettePainter/.idea/.name
@@ -0,0 +1 @@
+Chickensoft.PalettePainter
\ No newline at end of file
diff --git a/.idea/.idea.Chickensoft.PalettePainter/.idea/codeStyles/codeStyleConfig.xml b/.idea/.idea.Chickensoft.PalettePainter/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 0000000..a55e7a1
--- /dev/null
+++ b/.idea/.idea.Chickensoft.PalettePainter/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/.idea.Chickensoft.PalettePainter/.idea/encodings.xml b/.idea/.idea.Chickensoft.PalettePainter/.idea/encodings.xml
new file mode 100644
index 0000000..df87cf9
--- /dev/null
+++ b/.idea/.idea.Chickensoft.PalettePainter/.idea/encodings.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/.idea/.idea.Chickensoft.PalettePainter/.idea/indexLayout.xml b/.idea/.idea.Chickensoft.PalettePainter/.idea/indexLayout.xml
new file mode 100644
index 0000000..7b08163
--- /dev/null
+++ b/.idea/.idea.Chickensoft.PalettePainter/.idea/indexLayout.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/.idea.Chickensoft.PalettePainter/.idea/vcs.xml b/.idea/.idea.Chickensoft.PalettePainter/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/.idea.Chickensoft.PalettePainter/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/Chickensoft.PalettePainter.run.xml b/.run/Chickensoft.PalettePainter.run.xml
new file mode 100644
index 0000000..4e6a8bc
--- /dev/null
+++ b/.run/Chickensoft.PalettePainter.run.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..ac45447
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,10 @@
+{
+ "recommendations": [
+ "ms-dotnettools.csharp",
+ "selcukermaya.se-csproj-extensions",
+ "josefpihrt-vscode.roslynator",
+ "streetsidesoftware.code-spell-checker",
+ "VisualStudioExptTeam.vscodeintellicode",
+ "DavidAnson.vscode-markdownlint"
+ ]
+}
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..008163e
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,37 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ // For these launch configurations to work, you need to setup a GODOT
+ // environment variable. On mac or linux, this can be done by adding
+ // the following to your .zshrc, .bashrc, or .bash_profile file:
+ // export GODOT="/Applications/Godot.app/Contents/MacOS/Godot"
+ {
+ "name": "🧪 Debug Tests",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build",
+ "program": "${env:GODOT}",
+ "args": [
+ // These command line flags are used by GoDotTest to run tests.
+ "--run-tests",
+ "--quit-on-finish"
+ ],
+ "cwd": "${workspaceFolder}/Chickensoft.PalettePainter.Tests",
+ "stopAtEntry": false,
+ },
+ {
+ "name": "🔬 Debug Current Test",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build",
+ "program": "${env:GODOT}",
+ "args": [
+ // These command line flags are used by GoDotTest to run tests.
+ "--run-tests=${fileBasenameNoExtension}",
+ "--quit-on-finish"
+ ],
+ "cwd": "${workspaceFolder}/Chickensoft.PalettePainter.Tests",
+ "stopAtEntry": false,
+ },
+ ]
+}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..863b15d
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,169 @@
+{
+ "[csharp]": {
+ "editor.codeActionsOnSave": {
+ "source.addMissingImports": "explicit",
+ "source.fixAll": "explicit",
+ "source.organizeImports": "explicit"
+ },
+ "editor.formatOnPaste": true,
+ "editor.formatOnSave": true,
+ "editor.formatOnType": false
+ },
+ "csharp.semanticHighlighting.enabled": true,
+ "dotnet.completion.showCompletionItemsFromUnimportedNamespaces": true,
+ // Required to keep the C# language server from getting confused about which
+ // solution to open.
+ "dotnet.defaultSolution": "Chickensoft.Godotpackage.sln",
+ "dotnet.server.useOmnisharp": false,
+ "editor.semanticHighlighting.enabled": true,
+ // C# doc comment colorization gets lost with semantic highlighting, but we
+ // need semantic highlighting for proper syntax highlighting with record
+ // shorthand.
+ //
+ // Here's a workaround for doc comment highlighting from
+ // https://github.com/OmniSharp/omnisharp-vscode/issues/3816
+ "editor.tokenColorCustomizations": {
+ "[*]": {
+ // Themes that don't include the word "Dark" or "Light" in them.
+ // These are some bold colors that show up well against most dark and
+ // light themes.
+ //
+ // Change them to something that goes well with your preferred theme :)
+ "textMateRules": [
+ {
+ "scope": "comment.documentation",
+ "settings": {
+ "foreground": "#0091ff"
+ }
+ },
+ {
+ "scope": "comment.documentation.attribute",
+ "settings": {
+ "foreground": "#8480ff"
+ }
+ },
+ {
+ "scope": "comment.documentation.cdata",
+ "settings": {
+ "foreground": "#0091ff"
+ }
+ },
+ {
+ "scope": "comment.documentation.delimiter",
+ "settings": {
+ "foreground": "#aa00ff"
+ }
+ },
+ {
+ "scope": "comment.documentation.name",
+ "settings": {
+ "foreground": "#ef0074"
+ }
+ }
+ ]
+ },
+ "[*Dark*]": {
+ // Themes that include the word "Dark" in them.
+ "textMateRules": [
+ {
+ "scope": "comment.documentation",
+ "settings": {
+ "foreground": "#608B4E"
+ }
+ },
+ {
+ "scope": "comment.documentation.attribute",
+ "settings": {
+ "foreground": "#C8C8C8"
+ }
+ },
+ {
+ "scope": "comment.documentation.cdata",
+ "settings": {
+ "foreground": "#E9D585"
+ }
+ },
+ {
+ "scope": "comment.documentation.delimiter",
+ "settings": {
+ "foreground": "#808080"
+ }
+ },
+ {
+ "scope": "comment.documentation.name",
+ "settings": {
+ "foreground": "#569CD6"
+ }
+ }
+ ]
+ },
+ "[*Light*]": {
+ // Themes that include the word "Light" in them.
+ "textMateRules": [
+ {
+ "scope": "comment.documentation",
+ "settings": {
+ "foreground": "#008000"
+ }
+ },
+ {
+ "scope": "comment.documentation.attribute",
+ "settings": {
+ "foreground": "#282828"
+ }
+ },
+ {
+ "scope": "comment.documentation.cdata",
+ "settings": {
+ "foreground": "#808080"
+ }
+ },
+ {
+ "scope": "comment.documentation.delimiter",
+ "settings": {
+ "foreground": "#808080"
+ }
+ },
+ {
+ "scope": "comment.documentation.name",
+ "settings": {
+ "foreground": "#808080"
+ }
+ }
+ ]
+ }
+ },
+ "markdownlint.config": {
+ // Allow non-unique heading names so we don't break the changelog.
+ "MD024": false,
+ // Allow html in markdown.
+ "MD033": false
+ },
+ "markdownlint.lintWorkspaceGlobs": [
+ "!**/LICENSE"
+ ],
+ "omnisharp.enableEditorConfigSupport": true,
+ "omnisharp.enableMsBuildLoadProjectsOnDemand": false,
+ "omnisharp.maxFindSymbolsItems": 3000,
+ "omnisharp.organizeImportsOnFormat": true,
+ "omnisharp.useModernNet": true,
+ // Remove these if you're happy with your terminal profiles.
+ "terminal.integrated.defaultProfile.windows": "Git Bash",
+ "terminal.integrated.profiles.windows": {
+ "Command Prompt": {
+ "icon": "terminal-cmd",
+ "path": [
+ "${env:windir}\\Sysnative\\cmd.exe",
+ "${env:windir}\\System32\\cmd.exe"
+ ]
+ },
+ "Git Bash": {
+ "icon": "terminal",
+ "source": "Git Bash"
+ },
+ "PowerShell": {
+ "icon": "terminal-powershell",
+ "source": "PowerShell"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..4230088
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,40 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "build",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "build",
+ "--no-restore"
+ ],
+ "problemMatcher": "$msCompile",
+ "presentation": {
+ "echo": true,
+ "reveal": "silent",
+ "focus": false,
+ "panel": "shared",
+ "showReuseMessage": false,
+ "clear": false
+ }
+ },
+ {
+ "label": "coverage",
+ "group": "test",
+ "command": "${workspaceFolder}/Chickensoft.PalettePainter.Tests/coverage.sh",
+ "type": "shell",
+ "options": {
+ "cwd": "${workspaceFolder}/Chickensoft.PalettePainter.Tests"
+ },
+ "presentation": {
+ "echo": true,
+ "reveal": "always",
+ "focus": false,
+ "panel": "shared",
+ "showReuseMessage": false,
+ "clear": true
+ },
+ },
+ ]
+}
\ No newline at end of file
diff --git a/.zed/settings.json b/.zed/settings.json
new file mode 100644
index 0000000..fe827dd
--- /dev/null
+++ b/.zed/settings.json
@@ -0,0 +1,7 @@
+{
+ "languages": {
+ "Markdown": {
+ "soft_wrap": "bounded"
+ }
+ }
+}
diff --git a/.zed/tasks.json b/.zed/tasks.json
new file mode 100644
index 0000000..fc4c2c2
--- /dev/null
+++ b/.zed/tasks.json
@@ -0,0 +1,16 @@
+[
+ {
+ "label": "Run PalettePainter",
+ "command": "dotnet",
+ "reveal": "always",
+ "args": [
+ "run",
+ "--project",
+ "Chickensoft.PalettePainter/Chickensoft.PalettePainter.csproj",
+ "generate",
+ "output.png",
+ "-x",
+ "12"
+ ]
+ }
+]
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..0f84802
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,38 @@
+# Contributing
+
+Thank you for taking the time to read this contributing guide and for showing interest in helping this project!
+
+## Getting Started
+
+Need a helping hand to get started? Check out these resources!
+
+- [Discord Server][discord]
+- [Chickensoft Website][chickensoft]
+
+Please read our [code of conduct](#code-of-conduct). We do our best to treat others fairly and foster a welcoming environment.
+
+## Project Setup
+
+This is a C# nuget package, for use with the .NET SDK 6 or 7. As such, the `dotnet` tool will allow you to restore packages and build projects.
+
+The `Chickensoft.PalettePainter.Tests` project must be built with the Godot editor at least once before `dotnet build` will succeed. Godot has to generate the .NET bindings for the project, since tests run in an actual game environment.
+
+## Coding Guidelines
+
+Your IDE should automatically adhere to the style guidelines in the provided `.editorconfig` file. Please try to keep lines under 80 characters long whenever possible.
+
+We try to write tests for our projects to ensure a certain level of quality. We are willing to give you support and guidance if you need help!
+
+## Code of Conduct
+
+We follow the [Contributor Covenant][covenant].
+
+In short:
+
+> We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
+
+
+
+[discord]: https://discord.gg/gSjaPgMmYW
+[chickensoft]: https://chickensoft.games
+[covenant]: https://www.contributor-covenant.org/version/2/1/code_of_conduct/
diff --git a/Chickensoft.PalettePainter.Tests/Chickensoft.PalettePainter.Tests.csproj b/Chickensoft.PalettePainter.Tests/Chickensoft.PalettePainter.Tests.csproj
new file mode 100644
index 0000000..9c5b30a
--- /dev/null
+++ b/Chickensoft.PalettePainter.Tests/Chickensoft.PalettePainter.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Chickensoft.PalettePainter.Tests/UnitTest1.cs b/Chickensoft.PalettePainter.Tests/UnitTest1.cs
new file mode 100644
index 0000000..1857d34
--- /dev/null
+++ b/Chickensoft.PalettePainter.Tests/UnitTest1.cs
@@ -0,0 +1,6 @@
+namespace Chickensoft.PalettePainter.Tests;
+
+public class UnitTest1 {
+ [Fact]
+ public void Test1() { }
+}
diff --git a/Chickensoft.PalettePainter.sln b/Chickensoft.PalettePainter.sln
new file mode 100644
index 0000000..6f7b6de
--- /dev/null
+++ b/Chickensoft.PalettePainter.sln
@@ -0,0 +1,28 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Chickensoft.PalettePainter", "Chickensoft.PalettePainter\Chickensoft.PalettePainter.csproj", "{968B4768-1F08-4E47-AD54-23DC06A2BB80}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Chickensoft.PalettePainter.Tests", "Chickensoft.PalettePainter.Tests\Chickensoft.PalettePainter.Tests.csproj", "{D4C71ACA-462E-4240-A8A3-06144B678A83}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {968B4768-1F08-4E47-AD54-23DC06A2BB80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {968B4768-1F08-4E47-AD54-23DC06A2BB80}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {968B4768-1F08-4E47-AD54-23DC06A2BB80}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {968B4768-1F08-4E47-AD54-23DC06A2BB80}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D4C71ACA-462E-4240-A8A3-06144B678A83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D4C71ACA-462E-4240-A8A3-06144B678A83}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D4C71ACA-462E-4240-A8A3-06144B678A83}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D4C71ACA-462E-4240-A8A3-06144B678A83}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/Chickensoft.PalettePainter/Chickensoft.PalettePainter.csproj b/Chickensoft.PalettePainter/Chickensoft.PalettePainter.csproj
new file mode 100644
index 0000000..8d27e6d
--- /dev/null
+++ b/Chickensoft.PalettePainter/Chickensoft.PalettePainter.csproj
@@ -0,0 +1,57 @@
+
+
+ Exe
+ true
+ palettepainter
+ net8.0
+ Major
+ true
+ preview
+ true
+ enable
+ true
+ Chickensoft.PalettePainter
+ true
+ ./nupkg
+ portable
+
+ PalettePainter
+ 1.0.0
+ Command-line, general-purpose palette generator for use with pixel art, textures, or art software.
+ © 2024 Your Name
+ Your Name
+ Your Name
+
+ Chickensoft.PalettePainter
+ Chickensoft.PalettePainter release.
+ icon.png
+ palette;pixel art;art;generator;command line;cli;chickensoft;utils
+ README.md
+ LICENSE
+ https://github.com/chickensoft-games/PalettePainter
+
+ git
+ https://github.com/chickensoft-games/PalettePainter.git
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
diff --git a/Chickensoft.PalettePainter/icon.png b/Chickensoft.PalettePainter/icon.png
new file mode 100644
index 0000000..a901557
--- /dev/null
+++ b/Chickensoft.PalettePainter/icon.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:324496ebf5f67bf30ef6090d609fb16277e736bc883b29d734a9bfa79c2779d4
+size 86076
diff --git a/Chickensoft.PalettePainter/src/Colors.cs b/Chickensoft.PalettePainter/src/Colors.cs
new file mode 100644
index 0000000..8391bb8
--- /dev/null
+++ b/Chickensoft.PalettePainter/src/Colors.cs
@@ -0,0 +1,83 @@
+namespace Chickensoft.PalettePainter;
+
+using System;
+using SkiaSharp;
+
+public static class Colors {
+ ///
+ /// Given an interval between 0 and 1, return the saturation level for
+ /// the color at that interval along the ramp (0-100).
+ /// This was determined from using polynomial regression based on the values
+ /// provided by slynyrd.
+ /// https://www.slynyrd.com/blog/2018/1/10/pixelblog-1-color-palettes
+ ///
+ ///
+ /// Color position along the ramp as a value between 0
+ /// and 1.
+ ///
+ /// Saturation value between 0 and 100.
+ public static double SlynyrdSaturationFunction(double interval) =>
+ Math.Clamp(20 + (169 * interval) + (257 * Math.Pow(interval, 2)) +
+ (-1706 * Math.Pow(interval, 3)) +
+ (5444 * Math.Pow(interval, 4)) +
+ (-13860 * Math.Pow(interval, 5)) +
+ (16731 * Math.Pow(interval, 6)) +
+ (-7118 * Math.Pow(interval, 7)), 0, 100);
+
+ ///
+ /// Given an interval between 0 and 1, return the brightness level for
+ /// the color at that interval along the ramp (0-100).
+ /// This was determined from using polynomial regression based on the values
+ /// provided by slynyrd.
+ /// https://www.slynyrd.com/blog/2018/1/10/pixelblog-1-color-palettes
+ ///
+ ///
+ /// Color position along the ramp as a value between 0
+ /// and 1.
+ ///
+ /// Saturation value between 0 and 100.
+ public static double SlynyrdBrightnessFunction(double interval) =>
+ Math.Clamp(15 + (110 * interval) + (347 * Math.Pow(interval, 2)) +
+ (-1453 * Math.Pow(interval, 3)) +
+ (2416 * Math.Pow(interval, 4)) +
+ (-1888 * Math.Pow(interval, 5)) +
+ (554 * Math.Pow(interval, 6)), 0, 100);
+
+ ///
+ /// Converts HSV color values to RGB
+ ///
+ /// 0 - 360
+ /// 0 - 100
+ /// 0 - 100
+ public static SKColor HsvToRgb(int h, int s, int v) {
+ Span rgb = stackalloc int[3];
+
+ while (h < 0) {
+ h += 360;
+ }
+
+ while (h >= 360) {
+ h -= 360;
+ }
+
+ var baseColor = (h + 60) % 360 / 120;
+ var shift = ((h + 60) % 360) - ((120 * baseColor) + 60);
+ var secondaryColor = (baseColor + (shift >= 0 ? 1 : -1) + 3) % 3;
+
+ // Setting Hue
+ rgb[baseColor] = 255;
+ rgb[secondaryColor] = (int)(Math.Abs(shift) / 60.0f * 255.0f);
+
+ // Setting Saturation
+ for (var i = 0; i < 3; i++) {
+ rgb[i] += (int)((255 - rgb[i]) * ((100 - s) / 100.0f));
+ }
+
+ // Setting Value
+ for (var i = 0; i < 3; i++) {
+ rgb[i] -= (int)(rgb[i] * (100 - v) / 100.0f);
+ }
+
+ return new SKColor((byte)rgb[0], (byte)rgb[1], (byte)rgb[2]);
+ }
+}
diff --git a/Chickensoft.PalettePainter/src/Generate.cs b/Chickensoft.PalettePainter/src/Generate.cs
new file mode 100644
index 0000000..717c54a
--- /dev/null
+++ b/Chickensoft.PalettePainter/src/Generate.cs
@@ -0,0 +1,237 @@
+namespace Chickensoft.PalettePainter;
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using CliFx;
+using CliFx.Attributes;
+using CliFx.Infrastructure;
+using SkiaSharp;
+
+[Command("generate", Description = "Generate a palette of colors.")]
+public class Generate : ICommand
+{
+ private const int NUM_RAMPS_DEFAULT = 16;
+ private const int NUM_COLORS_PER_RAMP_DEFAULT = 9;
+ private const int NUM_COLORS_TO_TRIM_FOR_DESATURATED_RAMP_DEFAULT = 2;
+ private const double DESATURATE_DEFAULT = 0.3f;
+ private const int SCALE_DEFAULT = 1;
+ private const int HUE_DEFAULT = 0;
+ private const double HUE_SHIFT_DEFAULT = 0.5f;
+ private const double SATURATION_DEFAULT = 1f;
+ private const double BRIGHTNESS_DEFAULT = 1f;
+ private const int HUE_SPECTRUM_DEFAULT = 360;
+
+ [CommandParameter(0, Name = "output",
+ Description = "Palette output image file path", IsRequired = true)]
+ public required string Output { get; init; }
+
+ [CommandOption("num-ramps", 'n',
+ Description = "Number of color ramps to generate", IsRequired = false)]
+ public int NumRamps { get; init; } = NUM_RAMPS_DEFAULT;
+
+ [CommandOption("num-colors-per-ramp", 'c',
+ Description = "Number of colors per ramp", IsRequired = false)]
+ public int NumColorsPerRamp { get; init; } = NUM_COLORS_PER_RAMP_DEFAULT;
+
+ [CommandOption("num-colors-to-trim-for-desaturated-ramp", 'z',
+ Description = "Number of colors to remove from the ends of the ramp when " +
+ "constructing the desaturated color ramp variant.",
+ IsRequired = false)]
+ public int NumColorsToTrimForDesaturatedRamp { get; init; } =
+ NUM_COLORS_TO_TRIM_FOR_DESATURATED_RAMP_DEFAULT;
+
+ [CommandOption("desaturate", 'd',
+ Description = "Percentage of saturation to keep when creating a " +
+ "desaturated variant of a color.")]
+ public double Desaturate { get; init; } = DESATURATE_DEFAULT;
+
+ [CommandOption("scale", 'x',
+ Description = "Scale factor for the output image (how big a single pixel " +
+ "should be).", IsRequired = false)]
+ public int Scale { get; init; } = SCALE_DEFAULT;
+
+ [CommandOption("hue", 'h',
+ Description = "Starting hue of the middle color in the first ramp.")]
+ public int Hue { get; init; } = HUE_DEFAULT;
+
+ [CommandOption("saturation", 's',
+ Description = "Percentage of saturation to keep when creating a color. " +
+ "Colors follow the built-in artistic saturation function. " +
+ "This value determines how much of the saturation function " +
+ "to use. Values greater than 1.0 will over-apply the " +
+ "result and can potentially result in color data loss.")]
+ public double Saturation { get; init; } = SATURATION_DEFAULT;
+
+ [CommandOption("brightness", 'b',
+ Description = "Percentage of brightness to keep when creating a color. " +
+ "Colors follow the built-in artistic brightness function. " +
+ "This value determines how much of the brightness function " +
+ "to use. Values greater than 1.0 will over-apply the " +
+ "result and can potentially result in color data loss.")]
+ public double Brightness { get; init; } = BRIGHTNESS_DEFAULT;
+
+ [CommandOption("hue-shift", 'm',
+ Description =
+ "How much of the hue spectrum (as a fraction between 0 and 1) should " +
+ " be covered by a single color ramp.")]
+ public double HueShift { get; init; } = HUE_SHIFT_DEFAULT;
+
+ [CommandOption("hue-spectrum", 'u',
+ Description = "The amount of the hue spectrum to cover in the palette " +
+ "(0-360).")]
+ public int HueSpectrum { get; init; } = HUE_SPECTRUM_DEFAULT;
+
+ // Computed
+ public double TotalChangeInHuePerRamp => 360 * HueShift;
+
+ public double ChangeInHuePerColor =>
+ TotalChangeInHuePerRamp / NumColorsPerRamp;
+
+ public int NumDesaturatedColors =>
+ NumColorsPerRamp - NumColorsToTrimForDesaturatedRamp;
+
+ public ValueTask ExecuteAsync(IConsole console)
+ {
+ // a palette is a list of ramps
+ List> palette = [];
+
+ var cwd = Environment.CurrentDirectory;
+ var outputFile = Path.Combine(cwd, Output);
+
+ console.Output.WriteLine("Creating a palette...");
+
+ for (var rampIndex = 0; rampIndex < NumRamps; rampIndex++)
+ {
+ var hue = (int)(Hue + ((double)rampIndex / NumRamps * HueSpectrum));
+
+ var colors = GetRamp(
+ NumColorsPerRamp,
+ ChangeInHuePerColor,
+ hue,
+ Saturation,
+ Brightness,
+ 0d,
+ 1d
+ );
+
+ // compute the start progress and end progress such that the desaturated
+ // color ramp trims the lightest and darkest colors from the ramp since
+ // those colors would be nearly equivalent to their counterparts in
+ // the saturated ramp.
+ var desaturatedStartP =
+ NumColorsToTrimForDesaturatedRamp / 2d / NumColorsPerRamp;
+ var desaturatedEndP = 1 - desaturatedStartP;
+
+ var desaturatedColors = GetRamp(
+ NumDesaturatedColors,
+ ChangeInHuePerColor,
+ hue,
+ Desaturate,
+ Brightness,
+ desaturatedStartP,
+ desaturatedEndP
+ ).Reverse();
+
+ var ramp = colors.Concat(desaturatedColors).ToList();
+
+ palette.Add(ramp);
+ }
+
+ var image = Render(palette);
+ SaveImage(outputFile, image);
+
+ console.Output.WriteLine($"Palette saved: {outputFile}");
+
+ return new();
+ }
+
+ public static List GetRamp(
+ int numColors,
+ double changeInHuePerColor,
+ int hue,
+ double saturation,
+ double brightness,
+ double startP, /* start percentage */
+ double endP /* end percentage */
+ )
+ {
+ var colors = new SKColor[numColors];
+
+ var middleIndex = numColors / 2;
+
+ var changeInHuePerColorRemapped = changeInHuePerColor * (endP - startP);
+
+ // create colors (from darkest to lightest)
+ for (var colorIndex = 0; colorIndex < numColors; colorIndex++)
+ {
+ var p = (double)colorIndex / numColors;
+ var remappedP = startP + (p * (endP - startP));
+ var distanceFromMiddle = middleIndex - colorIndex;
+
+ var h = hue - (distanceFromMiddle * changeInHuePerColorRemapped);
+ var s = Colors.SlynyrdSaturationFunction(remappedP) * saturation;
+ var b = Colors.SlynyrdBrightnessFunction(remappedP) * brightness;
+
+ colors[colorIndex] = Colors.HsvToRgb((int)h, (int)s, (int)b);
+ }
+
+ return [.. colors];
+ }
+
+ public SKImage Render(List> palette)
+ {
+ var surface = SKSurface.Create(
+ new SKImageInfo(
+ (Scale * NumColorsPerRamp) +
+ (Scale * (NumColorsPerRamp - NumColorsToTrimForDesaturatedRamp)),
+ Scale * NumRamps
+ )
+ );
+
+ var canvas = surface.Canvas;
+
+ canvas.Clear(SKColors.Black);
+
+ using var paint = new SKPaint();
+
+ paint.IsAntialias = false;
+ paint.Style = SKPaintStyle.Fill;
+
+ // draw swatches from the palette
+ for (var rampIndex = 0; rampIndex < NumRamps; rampIndex++)
+ {
+ var ramp = palette[rampIndex];
+
+ for (var colorIndex = 0; colorIndex < ramp.Count; colorIndex++)
+ {
+ var color = ramp[colorIndex];
+
+ paint.Color = new SKColor(color.Red, color.Green, color.Blue);
+
+ var x = colorIndex;
+ var y = rampIndex;
+
+ canvas.DrawRect(
+ x * Scale, y * Scale, Scale, Scale, paint
+ );
+ }
+ }
+
+ return surface.Snapshot();
+ }
+
+ public void SaveImage(string fullyQualifiedPath, SKImage image)
+ {
+ using var data = image.Encode(SKEncodedImageFormat.Png, 100);
+
+ using var stream = File.OpenWrite(fullyQualifiedPath);
+
+ data.SaveTo(stream);
+
+ stream.Flush();
+ stream.Close();
+ }
+}
diff --git a/Chickensoft.PalettePainter/src/Main.cs b/Chickensoft.PalettePainter/src/Main.cs
new file mode 100644
index 0000000..ba99c10
--- /dev/null
+++ b/Chickensoft.PalettePainter/src/Main.cs
@@ -0,0 +1,26 @@
+namespace Chickensoft.PalettePainter;
+
+using System.Reflection;
+using System.Threading.Tasks;
+using CliFx;
+public static class PalettePainter {
+ public static async Task Main(string[] args) {
+ var version = Assembly
+ .GetEntryAssembly()!
+ .GetCustomAttribute()!
+ .InformationalVersion;
+
+ return await new CliApplicationBuilder()
+ .SetExecutableName("palettepainter")
+ .SetTitle("Palette Painter")
+ .SetVersion(version)
+ .SetDescription(
+ """
+ Create a palette of colors with the specified number of ramps. You may
+ customize hue shifts and saturation levels to create a unique palette.
+ """
+ )
+ .AddCommandsFromThisAssembly()
+ .Build().RunAsync(args);
+ }
+}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ad0e620
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+# MIT License
+
+Copyright (c) 2023 Your Name
+
+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.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..14fea85
--- /dev/null
+++ b/README.md
@@ -0,0 +1,111 @@
+# 🎨 PalettePainter
+
+[![Chickensoft Badge][chickensoft-badge]][chickensoft-website] [![Discord][discord-badge]][discord]
+
+Command-line, general-purpose palette generator for use with pixel art, textures, or art software. Palettes are constructed based on the principles described in [Slynyrd][slynyrd]'s seminal blog on [pixel art color palettes][palettes].
+
+---
+
+
+
+
+
+## 📦 Installation
+
+This is a .NET Framework 8 tool written in C#. Once you have .NET installed, you can install this tool globally:
+
+```sh
+dotnet tool install -g Chickensoft.PalettePainter
+
+# Run the tool
+palettepainter generate --help
+```
+
+## 🖼️ Quick Start
+
+```sh
+palettepainter generate palette.png --scale 12
+```
+
+![default palette](doc_assets/default.png)
+
+> [!NOTE]
+The `--scale` or `-x` parameter controls how large a pixel is. These images were generated with `--scale 12` so that each palette swatch is 12x12 pixels.
+
+## Winter Colors
+
+```sh
+palettepainter generate palette.png --hue 180 --saturation 1 --brightness 1 --num-ramps 8 --hue-shift 0.5 --hue-spectrum 100 --desaturate 0.3 --scale 12
+```
+
+![default palette](doc_assets/winter.png)
+
+## Wooden Colors
+
+```sh
+palettepainter generate palette.png --hue 20 --saturation 1 --brightness 1 --num-ramps 4 --hue-shift 0.1 --hue-spectrum 15 --desaturate 0.6 --scale 12
+```
+
+![default palette](doc_assets/wooden.png)
+
+## Leafy Greens
+
+```sh
+palettepainter generate palette.png --hue 110 --saturation 1 --brightness 1 --num-ramps 6 --hue-shift 0.5 --hue-spectrum 60 --desaturate 0.6 --scale 12
+```
+
+![default palette](doc_assets/leafy_greens.png)
+
+## Tiny Full Spectrum
+
+```sh
+palettepainter generate palette.png --hue 110 --saturation 1 --brightness 1 --num-ramps 4 --num-colors-per-ramp 12 -z 8 --hue-shift 0.5 --hue-spectrum 360 --desaturate 0.3 --scale 12
+```
+
+![default palette](doc_assets/tiny_full_spectrum.png)
+
+...etc!
+
+## 🖥️ Usage Details
+
+PalettePainter is a command-line tool that exposes a number of variables to help you generate the type of general-purpose palette you're looking for. Each of these parameters is described in the help text `palettepainter generate --help`:
+
+```plaintext
+Palette Painter 1.0.0
+ Create a palette of colors with the specified number of ramps. You may
+customize hue shifts and saturation levels to create a unique palette.
+
+USAGE
+ palettepainter generate