Compare commits

...

49 Commits

Author SHA1 Message Date
773867bd2c fixed the UI shaking at higher framerates 2025-07-23 05:56:01 +10:00
760ffb8b80 better quick settings layout 2025-07-22 10:34:22 +10:00
853e4423f7 NDI Working 2025-07-22 09:00:28 +10:00
fc6fa30c85 Added Mirror and Rotation 2025-07-22 06:21:17 +10:00
95476cfd57 removed temp files !build 2025-07-22 06:03:48 +10:00
722fed9b94 Made costumes work again !build 2025-07-22 05:42:26 +10:00
e8620611df Added renaming layers on right click, removed a lot of old debug logging 2025-07-21 08:29:11 +10:00
85a208a729 Update README.md 2025-07-21 06:44:11 +10:00
92fc5dda89 removed temp files and removed git plugin !build 2025-07-21 06:35:50 +10:00
af17a1dee0 Fixed up edit menu. now is more modular + uses extensive use of v+hbox's !build 2025-07-21 06:21:42 +10:00
43dfc410dc added blend modes 2025-07-20 22:14:42 +10:00
ef8ecb3732 !build 2025-07-20 10:26:51 +10:00
803e867e20 Merge branch 'master' of https://github.com/litruv/PNGTuber-Plus 2025-07-20 10:25:09 +10:00
cebef2f06b wobble sync groups !build 2025-07-20 10:24:53 +10:00
e0420a6583 Update README.md 2025-07-20 07:35:21 +10:00
56e76c61e6 Update README.md !build 2025-07-20 07:34:46 +10:00
5dfe68cb81 Merge branch 'master' of https://github.com/litruv/PNGTuber-Plus 2025-07-20 07:32:01 +10:00
6e02a67114 replaced framerate dependant physics with delta time 2025-07-20 07:31:59 +10:00
32a67a6617 Update README.md !build 2025-07-20 06:57:22 +10:00
fb0c7a9e3b Merge branch 'master' of https://github.com/litruv/PNGTuber-Plus 2025-07-20 06:53:37 +10:00
f0ce00b23b Viewmode Panning 2025-07-20 06:53:34 +10:00
d4a9f5a0b0 scaled zooming 2025-07-20 06:36:07 +10:00
085d6a21a0 Update README.md !build 2025-07-20 06:33:37 +10:00
99d2d3f508 Update UI positions during zoom changes 2025-07-20 06:26:23 +10:00
dad22ece22 !build fixed opacity shader on open 2025-07-20 06:19:21 +10:00
52496df10e added panning to edit mode 2025-07-20 06:13:09 +10:00
29b903fadd Improve GitHub Actions build automation workflow
- Update build automation wording and descriptions
- Refine workflow trigger conditions
- Enhance build process testing and validation
- Consolidate multiple build automation improvements
2025-07-20 05:48:34 +10:00
e8191e2de8 Merge branch 'master' of https://github.com/litruv/PNGTuber-Plus 2025-07-20 05:34:26 +10:00
b7b7d7cdec test some build description stuff out !build 2025-07-20 05:34:24 +10:00
fc6e4a1028 Update README.md 2025-07-20 05:28:11 +10:00
1dfe21363b update wording 2025-07-20 04:42:36 +10:00
fde3f23ed5 build trigger 2025-07-20 04:17:21 +10:00
79e54c28da Merge branch 'master' of https://github.com/litruv/PNGTuber-Plus 2025-07-20 04:15:13 +10:00
a8f327ae5a Add opacity slider with shader-based rendering that preserves clip mask behavior
- Added opacity slider (0-100%) in sprite options with 'affect children' checkbox
- Implemented hierarchical opacity that affects entire sprite hierarchy when enabled
- Created shader-based opacity system that doesn't interfere with clip mask functionality
- Clip masks now work with original image pixels regardless of opacity settings
- Removed shadow effects from selected sprites to prevent visual conflicts
- Updated save/load system to persist opacity properties with backward compatibility

Fixes clipping behavior issues where opacity changes previously affected clip mask detection
2025-07-20 04:14:58 +10:00
1295bcbcba Add opacity slider with shader-based rendering that preserves clip mask behavior
- Added opacity slider (0-100%) in sprite options with 'affect children' checkbox
- Implemented hierarchical opacity that affects entire sprite hierarchy when enabled
- Created shader-based opacity system that doesn't interfere with clip mask functionality
- Clip masks now work with original image pixels regardless of opacity settings
- Removed shadow effects from selected sprites to prevent visual conflicts
- Updated save/load system to persist opacity properties with backward compatibility

Fixes clipping behavior issues where opacity changes previously affected clip mask detection
2025-07-20 04:07:41 +10:00
758f2ca885 fix key listening again 2025-07-20 02:48:16 +10:00
524920d140 Merge branch 'master' of https://github.com/litruv/PNGTuber-Plus 2025-07-20 02:38:29 +10:00
f9c72448d9 Merge pull request #2 from litruv/copilot/fix-1
Fix costume key assignment regression - resolves "awaiting input" hang
2025-07-20 02:31:13 +10:00
copilot-swe-agent[bot]
135e63ef52 Fix costume key assignment regression by handling mouse click cancellation
Co-authored-by: litruv <3798007+litruv@users.noreply.github.com>
2025-07-19 16:25:12 +00:00
copilot-swe-agent[bot]
c86e68d383 Initial plan 2025-07-19 16:17:53 +00:00
f226c14c00 Revert "adding mask options"
This reverts commit c74dccf1cf.
2025-07-20 02:12:22 +10:00
d345e700fc Merge branch 'master' of https://github.com/litruv/PNGTuber-Plus 2025-07-20 00:03:50 +10:00
bc187cff09 Improve GitHub Actions workflow versioning and release formatting
- Add automated release info generation with date-based versioning
- Generate semantic version tags (v2025.MM.DD-buildNUMBER format)
- Implement dynamic build numbering based on commit count
- Streamline release name format to 'PNGTuber+ vDATE Build NUMBER'
- Update release body with proper credits and installation instructions
- Remove hardcoded release descriptions in favor of standardized format
2025-07-20 00:03:23 +10:00
fc4cacdc9f Update release name format in build workflow 2025-07-19 23:58:28 +10:00
848361d19b update versioning to be not awful 2025-07-19 23:52:46 +10:00
711865c6a4 update kaiakairos social to bluesky 2025-07-19 23:42:03 +10:00
d231b730f7 Add experimental mic loudness UI toggle to settings 2025-07-19 22:16:40 +10:00
8ba361f3c2 Integrate microphone improvements from k0ffinz repository
- Add experimental microphone loudness detection feature
- Remove microphone restart interval for better stability
- Improve microphone initialization and error handling
- Fix audio delay issues by setting channel_disable_time=0.0
- Update settings UI to include experimental mic loudness toggle
- Add credits to k0ffinz/Ralsi for microphone improvements

These changes improve audio responsiveness and fix various
microphone-related issues including WASAPI errors and growing
audio delay problems.
2025-07-19 22:00:53 +10:00
c74dccf1cf adding mask options 2025-07-19 21:52:49 +10:00
99 changed files with 20264 additions and 737 deletions

View File

@@ -16,6 +16,7 @@ jobs:
export-windows:
name: Windows Export
runs-on: self-hosted
if: contains(github.event.head_commit.message, '!build')
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -99,6 +100,74 @@ jobs:
runs-on: self-hosted
if: github.ref == 'refs/heads/master' && github.event_name == 'push' && success()
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate Release Info
id: release_info
run: |
# Get current date and time
$date = Get-Date -Format "yyyy.MM.dd"
$time = Get-Date -Format "HHmm"
# Get short commit hash (first 7 characters)
$shortSha = "${{ github.sha }}".Substring(0, 7)
# Get commit count since last tag (for build number)
$buildNumber = & git rev-list --count HEAD
# Create version-like tag and release name
$tag = "v$date-build$buildNumber"
$releaseName = "PNGTuber+ v$date Build $buildNumber"
Write-Host "Generated tag: $tag"
Write-Host "Generated release name: $releaseName"
# Get the latest release tag to compare against
$latestTag = ""
try {
$latestTag = & git describe --tags --abbrev=0 2>$null
Write-Host "Latest tag found: $latestTag"
} catch {
Write-Host "No previous tags found, showing all commits"
}
# Generate commit history since last tag (or all commits if no tag)
$commitRange = if ($latestTag) { "$latestTag..HEAD" } else { "HEAD" }
# Get commits with format: short hash, subject, author
$commits = & git log $commitRange --pretty=format:"%h|%s|%an" --no-merges
# Build commit history as a single string
$commitHistory = ""
if ($commits) {
$commitHistory += "`n`n## Changes Since Last Release`n`n"
$commits | ForEach-Object {
$parts = $_ -split '\|', 3
$hash = $parts[0]
$subject = $parts[1] -replace '"', '""'
$author = $parts[2]
$commitUrl = "https://github.com/${{ github.repository }}/commit/$hash"
$commitHistory += "- [$hash]($commitUrl) $subject`n"
}
} else {
$commitHistory += "`n`n## Changes Since Last Release`n`nNo new commits since last release.`n"
}
# Set outputs for use in next steps
echo "tag=$tag" >> $env:GITHUB_OUTPUT
echo "release_name=$releaseName" >> $env:GITHUB_OUTPUT
echo "short_sha=$shortSha" >> $env:GITHUB_OUTPUT
echo "build_number=$buildNumber" >> $env:GITHUB_OUTPUT
# Use heredoc syntax for multiline commit history
Add-Content -Path $env:GITHUB_OUTPUT -Value "commit_history<<EOF"
Add-Content -Path $env:GITHUB_OUTPUT -Value $commitHistory
Add-Content -Path $env:GITHUB_OUTPUT -Value "EOF"
- name: Download Windows Build
uses: actions/download-artifact@v4
with:
@@ -111,21 +180,18 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: build-${{ github.sha }}
release_name: PNGTuber Plus Build ${{ github.sha }}
tag_name: ${{ steps.release_info.outputs.tag }}
release_name: ${{ steps.release_info.outputs.release_name }}
body: |
Automated build of PNGTuber Plus
## Recent Updates by Litruv
- **StreamDeck Integration**: Add buttons to enable the streamdeck plugin that was built in
- **Rotation Wobble**: New sprite rotation effects with frequency and amplitude controls
- **Enhanced Modifier Keys**: Support for complex key combinations (Ctrl+Shift+Alt+Cmd)
- **Godot 4.4.1 Support**: Updated for latest Godot stable release
${{ steps.release_info.outputs.commit_history }}
## Installation
1. Download PNGTuber-Plus.exe from the assets below
2. Run the executable - no installation required!
3. For StreamDeck integration, download the plugin from [here](https://github.com/BoyneGames/streamdeck-godot-plugin/releases/tag/1.0.0)
draft: false
prerelease: false

3
.gitignore vendored
View File

@@ -3,4 +3,5 @@
.export/
.itchio/
.steam/
build/
build/
*.TMP

11
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Build PNGTuber-Plus",
"type": "shell",
"command": "cd c:\\DEV\\Sync\\PNGTuber-Plus; .\\build-windows.bat",
"group": "build"
}
]
}

154
NDI_SETUP_GUIDE.md Normal file
View File

@@ -0,0 +1,154 @@
# NDI Output for PNGTuber-Plus
This guide explains how to use the NDI (Network Device Interface) output feature in PNGTuber-Plus to stream your avatar to OBS Studio or other NDI-compatible applications.
## Requirements
1. **NDI Runtime**: Download and install the NDI Runtime from [NDI website](http://ndi.link/NDIRedistV6)
- Windows: [NDI Runtime for Windows](http://ndi.link/NDIRedistV6)
- macOS: [NDI Runtime for macOS](http://ndi.link/NDIRedistV6Apple)
- Linux: [NDI Runtime for Linux](https://github.com/DistroAV/DistroAV/wiki/1.-Installation#linux) (Note: Flatpak incompatible)
2. **Godot 4.4-stable or later**: This plugin requires Godot 4.4+ to function properly
3. **NDI-compatible software**: Such as OBS Studio with NDI plugin, vMix, Wirecast, etc.
## Features
- **Real-time NDI streaming**: Stream your PNGTuber avatar over the network
- **Audio support**: Include system audio in the NDI stream (optional)
- **Customizable source name**: Set a custom name for your NDI source
- **Hotkey controls**: Quick toggle streaming with keyboard shortcuts
- **Settings UI**: Easy configuration through the settings menu
## Getting Started
### 1. Basic Setup
The NDI output is automatically enabled when you start PNGTuber-Plus. The default settings are:
- **Source Name**: "PNGTuber-Plus"
- **Audio**: Enabled (Master bus)
- **Auto-start**: Enabled
### 2. Settings Menu Configuration
Access the NDI settings through the Settings Menu:
1. Click the settings wheel in PNGTuber-Plus
2. Scroll down to find the "NDI streaming" section
3. Configure the following options:
- **NDI streaming**: Enable/disable NDI output
- **Source name**: Set a custom name for your NDI source (default: "PNGTuber-Plus")
- **Include audio**: Toggle audio streaming on/off
- **Avatar-only stream**: Enable a clean avatar-only stream without UI elements
- **Status**: View current streaming status
- **Start/Stop NDI**: Manual control button
#### Avatar-Only Streaming
The "Avatar-only stream" option creates a second NDI source called "PNGTuber Avatar" that:
- Streams only the avatar sprites with a transparent background
- Excludes all UI elements, menus, and controls
- Perfect for professional streaming setups
- Syncs automatically with the main avatar state
### 3. Hotkey Controls
Quick keyboard shortcuts for NDI control:
- **F9**: Toggle NDI streaming on/off
- **F10**: Restart NDI streaming (useful for applying new settings)
### 4. OBS Studio Setup
To capture the NDI stream in OBS Studio:
1. **Install NDI Plugin for OBS**:
- Download from [OBS NDI Plugin](https://github.com/Palakis/obs-ndi/releases)
- Install following the provided instructions
2. **Add NDI Source in OBS**:
- In OBS, click the "+" in Sources
- Select "NDI Source"
- Name your source (e.g., "PNGTuber Avatar")
- In the properties, select your NDI source from the dropdown
- Look for "PNGTuber-Plus" (or your custom name)
- Configure audio settings as needed
3. **Position and Scale**:
- Resize and position the NDI source in your scene
- The stream will include the full PNGTuber viewport
## Troubleshooting
### NDI Source Not Appearing in OBS
1. **Check NDI Runtime**: Ensure NDI Runtime is installed on both machines
2. **Network connectivity**: NDI works over network, ensure devices can communicate
3. **Firewall settings**: NDI uses specific ports, check firewall rules
4. **Restart applications**: Try restarting both PNGTuber-Plus and OBS
5. **Check source name**: Verify the NDI source name matches in settings
### Audio Issues
1. **Audio bus**: Ensure the correct audio bus is selected (default: "Master")
2. **System audio**: Check that PNGTuber-Plus is receiving audio input
3. **OBS audio settings**: Verify NDI source audio is not muted in OBS
### Performance Issues
1. **Network bandwidth**: NDI requires good network performance for quality streaming
2. **Local streaming**: For best performance, use NDI on the same machine
3. **Resolution**: Lower viewport resolution if experiencing lag
4. **Frame rate**: Adjust FPS settings in PNGTuber-Plus settings
### NDI Not Working
1. **Godot version**: Ensure you're using Godot 4.4-stable or later
2. **Plugin enabled**: Verify the godot-ndi plugin is properly installed
3. **System compatibility**: Check NDI Runtime compatibility with your OS
4. **Console errors**: Check Godot console for any NDI-related errors
## Advanced Configuration
### Custom Audio Bus
You can route specific audio to NDI by:
1. Creating a custom audio bus in PNGTuber-Plus
2. Setting the NDI audio bus to your custom bus name
3. Routing desired audio sources to that bus
### Network Performance
For optimal network streaming:
- Use wired connections when possible
- Ensure sufficient bandwidth (>100 Mbps recommended for high quality)
- Keep NDI devices on the same network segment
- Consider NDI quality settings if available
### Multiple Instances
You can run multiple PNGTuber-Plus instances with different NDI source names:
1. Change the "Source name" in each instance
2. Each will appear as a separate NDI source
3. Useful for multiple avatars or backup streams
## Technical Details
- **Video Format**: NDI handles video format automatically based on viewport
- **Audio Format**: Uses the selected audio bus output
- **Compression**: NDI uses efficient compression for network streaming
- **Latency**: Typically very low latency (<1 frame) on local network
## Legal Notice
NDI® is a registered trademark of Vizrt NDI AB. Your application must comply with the [NDI SDK license](http://ndi.link/ndisdk_license) when using this feature.
## Support
For issues specific to NDI functionality:
1. Check the console output for error messages
2. Verify NDI Runtime installation
3. Test with other NDI applications to isolate issues
4. Check network connectivity for remote streaming
For PNGTuber-Plus specific issues, refer to the main application documentation and support channels.

View File

@@ -2,15 +2,93 @@
![Build Status](https://github.com/litruv/PNGTuber-Plus/workflows/Build%20Windows/badge.svg)
### Recent Updates by Litruv
- **GitHub Actions**: Automated Windows builds on every commit
- **StreamDeck Integration**: Add buttons to enable the streamdeck plugin that was built in
- **Rotation Wobble**: New sprite rotation effects with frequency and amplitude controls
- **Enhanced Modifier Keys**: Support for complex key combinations (Ctrl+Shift+Alt+Cmd)
- **Godot 4.4.1 Support**: Updated for latest Godot stable release
A fork of [kaiakairos/PNGTuber-Plus](https://github.com/kaiakairos/PNGTuber-Plus) with some quality-of-life upgrades, editor polish, and a few power-user toys.
## 🙏 Credits
---
- **Original Project**: PNGTuber Plus base application
- **StreamDeck Plugin**: Based on [BoyneGames StreamDeck Godot Plugin](https://github.com/BoyneGames/streamdeck-godot-plugin)
- **Enhanced Features**: Max Litruv Boonzaayer
## 🔧 Fork Changes (Litruv Edition)
### 🎛️ Opacity Controls
Added a proper opacity slider for sprites, with an "affect children" toggle. Clip masks still behave like they should.
![Opacity Demo](https://github.com/user-attachments/assets/d5a05508-1cb1-46e0-8c90-d0aac73398db)
### 🎨 Blend Modes
Photoshop-style blend modes for sprites! Choose from Normal, Multiply, Screen, Overlay, Soft Light, Hard Light, Color Dodge, Color Burn, Darken, Lighten, Add, and Subtract. Perfect for lighting effects, shadows, or artistic styling.
* Dropdown selector in the sprite viewer menu
* Works alongside opacity controls
### 🌀 Rotation Wobble
Sprites can now do a subtle (or chaotic) rotational wobble. Frequency + amplitude sliders included.
![Rotation Wobble](https://github.com/user-attachments/assets/f45c3cc8-98d8-48cf-8821-b015de0a2f78)
### 🔗 Wobble Sync Groups
Keep multiple sprites in sync with wobble sync groups. Create groups, assign sprites, and all wobble parameters (X/Y/Rotation frequency, amplitude) automatically sync across group members. Perfect for ears, hair, accessories, or any elements that should move together.
* Create custom sync groups with descriptive names
* Assign multiple sprites to the same group
* Edit any group member's wobble settings to update the entire group
* Visual indicators show which sprites are synced
* Groups persist in save files
### ⌨️ Enhanced Modifier Keys
Supports combos like Ctrl+Shift+Alt+Cmd. For people with more keybinds than fingers.
![Modifier Keys](https://github.com/user-attachments/assets/6d492134-f929-4e3e-a6c6-a89cf16cd62f)
### ↗️ Editor Middle Mouse Button Panning
Hold MMB in the editor to pan around the scene, it'll reset when you go back to live.
![Panning](https://github.com/user-attachments/assets/b8687b69-fc84-43c9-950b-70395ae54820)
### ↗️ View Mode MMB Shake
Hold MMB in view mode to shake your character, Show off them physics!
![viewmodeshake](https://github.com/user-attachments/assets/536dc4ae-0562-494f-8cd0-2d2ef115f085)
### 🎞️ Delta Time Physics
Physics are now framerate-independent. Low FPS wont make your character move in slow motion anymore.
Just smooth, consistent jank if thats your thing. Great for stop-motion vibes.
### 🎤 Microphone Fixes
Merged in [k0ffinzs](https://github.com/k0ffinz/PNGTuber-Plus) audio tweaks:
* Reduced mic startup delay
* Better loudness detection
* General stability improvements
### 🧱 Engine Update
Updated to **Godot 4.4.1** — no more editor nags or compatibility weirdness.
### 🎚️ StreamDeck Integration
StreamDeck plugin was already in, this just wires up the buttons.
---
## 🧾 Credits
* Original project by [kaiakairos](https://github.com/kaiakairos/PNGTuber-Plus)
* StreamDeck plugin from [BoyneGames](https://github.com/BoyneGames/streamdeck-godot-plugin)
* Mic improvements by [k0ffinz](https://github.com/k0ffinz/PNGTuber-Plus)

View File

@@ -4,4 +4,4 @@ name="Godot Git Plugin"
description="This plugin lets you interact with Git without leaving the Godot editor. More information can be found at https://github.com/godotengine/godot-git-plugin/wiki"
author="twaritwaikar"
version="v3.1.0"
script="godot-git-plugin.gd"
script=""

379
addons/godot-ndi/LICENSE Normal file
View File

@@ -0,0 +1,379 @@
https://github.com/unvermuthet/godot-ndi
(C) 2025 Henry Muth - unvermuthet
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at https://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.

View File

@@ -0,0 +1,52 @@
# Third-party software
## NDI SDK
- `ndi/*.h`
### MIT License
> The following MIT license applies to these files ONLY and not to the SDK as a whole. Please review the SDK documentation for the description of the full license terms, which are also provided in the file "NDI License Agreement.pdf" within the SDK or online at http://ndi.link/ndisdk_license. Your use of any part of this SDK is acknowledgment that you agree to the SDK license terms. The full NDI SDK may be downloaded at http://ndi.video/
Copyright (C) 2023-2024 Vizrt NDI AB. All rights reserved.
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.
## godot-cpp
- `godot-cpp/**`
### MIT License
Copyright (c) 2017-present Godot Engine contributors.
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,62 @@
# godot-ndi [![Made with Godot](https://img.shields.io/badge/Made%20with-Godot-478CBF?style=flat&logo=godot%20engine&logoColor=white)](https://godotengine.org) [![Build GDExtension](https://github.com/unvermuthet/godot-ndi/actions/workflows/builds.yml/badge.svg)](https://github.com/unvermuthet/godot-ndi/actions/workflows/builds.yml)
> Integrates the NDI® SDK with Godot
>
> [ NDI® is a registered trademark of Vizrt NDI AB ]
Adds a new [VideoStream](https://godot-ndi.readthedocs.io/stable/classes/class_videostreamndi.html) class to implement **NDI playback**.
For **outputting the Viewport** as an NDI source, add the [NDIOutput](https://godot-ndi.readthedocs.io/stable/classes/class_ndioutput.html) Node to the scene.
Check out the **demo scenes** included with all releases.
This was born out of work on my DMX Lighting Control Software. I'm fascinated by Live Event Productions, Projection Mapping, TechArt, and Broadcasting. [So reach out](https://discord.com/users/203583245223198722) or [show off](https://github.com/unvermuthet/godot-ndi/discussions/categories/show-and-tell) what you made with Godot NDI!
<!-- [![Static Badge](https://img.shields.io/badge/unvermuthet-gray?style=flat&logo=discord&logoColor=white&labelColor=%235865F2)](https://discord.com/users/203583245223198722) -->
![Demo GIF](https://github.com/user-attachments/assets/159eb455-b0de-41bb-972f-be0817771455)
> This GIF only shows local transmission, but imagine this over the network!
## Install
> [!IMPORTANT]
> - Only compatible with `Godot 4.4-stable` and later
> - Your application needs to comply with the [NDI SDK license](http://ndi.link/ndisdk_license).
> - The NDI Runtime is required ([Windows](http://ndi.link/NDIRedistV6), [MacOS](http://ndi.link/NDIRedistV6Apple), [Linux (Flatpak incompatible)](https://github.com/DistroAV/DistroAV/wiki/1.-Installation#linux))
Download the latest version under `Releases` and install by extracting the ZIP file in your Godot project.
Alternatively, you can install the extension from Godot's Asset Library.
## Support
For ***commercial*** support, exclusive alternative licensing, or [NDI Advanced SDK](https://ndi.video/for-developers/ndi-advanced/) integration, contact me at [godot-ndi@muth.pro](mailto:godot-ndi@muth.pro).
In the production industries, software usually comes with exorbitant licensing fees. If this project has been useful to you and you want to show appreciation for my time and effort, consider a donation.
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/I2I51A7ZC3)
## Building from source
To help you get going, I've configured a [Development Container](https://containers.dev/) with everything set up to target Windows or Linux. Just run `scons` or check `scons --help`!
If you want to configure the environment yourself, follow [Godot's "Building from source" Guide](https://docs.godotengine.org/en/latest/contributing/development/compiling/). This project uses an identical build system.
## License
**(C) 2025-present Henry Muth - unvermuthet and Godot NDI contributors**
### Software
Subject to the terms of the [Mozilla Public License, v. 2.0](https://www.mozilla.org/en-US/MPL/2.0/)
### Documentation
Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
### Attribution suggestion
```
godot-ndi
Licensed under MPL-2.0
(C) 2025-present Henry Muth - unvermuthet and Godot NDI contributors
https://github.com/unvermuthet/godot-ndi
```

View File

@@ -0,0 +1,73 @@
[gd_scene load_steps=4 format=3 uid="uid://deioml0m7ep1t"]
[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_4r73q"]
emission_shape = 1
emission_sphere_radius = 1.0
direction = Vector3(0, 1, 0)
initial_velocity_min = 2.0
initial_velocity_max = 6.0
[sub_resource type="SphereMesh" id="SphereMesh_jpqj6"]
[sub_resource type="LabelSettings" id="LabelSettings_ln6fu"]
font_size = 24
shadow_size = 5
shadow_color = Color(0, 0, 0, 0.235294)
[node name="3DOutput" type="SubViewportContainer"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
stretch = true
[node name="Node3D" type="Node3D" parent="."]
[node name="GPUParticles3D" type="GPUParticles3D" parent="Node3D"]
amount = 32
lifetime = 2.0
process_material = SubResource("ParticleProcessMaterial_4r73q")
draw_pass_1 = SubResource("SphereMesh_jpqj6")
[node name="SubViewportB" type="SubViewport" parent="."]
handle_input_locally = false
size = Vector2i(1152, 648)
render_target_update_mode = 4
[node name="NDIOutput" type="NDIOutput" parent="SubViewportB"]
name = "Godot Cam B"
enable_editor_output = true
[node name="Camera3D" type="Camera3D" parent="SubViewportB"]
transform = Transform3D(1, 0, 0, 0, -0.0010647, 0.999999, 0, -0.999999, -0.0010647, 0, 6.41845, 0)
current = true
[node name="SubViewportA" type="SubViewport" parent="."]
handle_input_locally = false
size = Vector2i(1152, 648)
render_target_update_mode = 4
[node name="NDIOutput" type="NDIOutput" parent="SubViewportA"]
name = "Godot Cam A"
enable_editor_output = true
[node name="Label" type="Label" parent="SubViewportA"]
anchors_preset = 2
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 31.0
offset_top = -357.0
offset_right = 534.0
offset_bottom = -27.0
grow_vertical = 0
text = "NDIOutput always transmits the Viewport closest up the Tree. When running in the Editor that's the 2D View.
If you want to display a camera view, add NDIOutput to a SubViewport instead. You can skip the SubViewportContainer incase you don't need the Scene to be visible when running the Game. "
label_settings = SubResource("LabelSettings_ln6fu")
horizontal_alignment = 3
autowrap_mode = 2
[node name="Camera3D" type="Camera3D" parent="SubViewportA"]
transform = Transform3D(0.99299, -0.0520139, 0.106136, 0, 0.897966, 0.440065, -0.118196, -0.43698, 0.891672, -2.28829, 1.75016, 5.03714)
current = true

View File

@@ -0,0 +1,58 @@
[gd_scene load_steps=5 format=3 uid="uid://p6ywmqtt7nke"]
[ext_resource type="Texture2D" uid="uid://cfvxj5cdj3n45" path="res://addons/godot-ndi/demo/assets/icon.svg" id="1_ekn6m"]
[sub_resource type="Animation" id="Animation_62e2m"]
length = 0.001
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Icon:position")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0),
"transitions": PackedFloat32Array(1),
"update": 0,
"values": [Vector2(1034, 527)]
}
[sub_resource type="Animation" id="Animation_0fbet"]
resource_name = "new_animation"
length = 8.0
loop_mode = 1
tracks/0/type = "value"
tracks/0/imported = false
tracks/0/enabled = true
tracks/0/path = NodePath("Icon:position")
tracks/0/interp = 1
tracks/0/loop_wrap = true
tracks/0/keys = {
"times": PackedFloat32Array(0, 3.9627, 7.9587),
"transitions": PackedFloat32Array(1, 1, 1),
"update": 0,
"values": [Vector2(116, 119), Vector2(1034, 527), Vector2(116, 119)]
}
[sub_resource type="AnimationLibrary" id="AnimationLibrary_ee4bf"]
_data = {
&"RESET": SubResource("Animation_62e2m"),
&"new_animation": SubResource("Animation_0fbet")
}
[node name="SubViewport" type="Node2D"]
[node name="NDIOutput" type="NDIOutput" parent="."]
name = "Godot (Animation)"
[node name="Icon" type="Sprite2D" parent="."]
position = Vector2(1034, 527)
scale = Vector2(1.463, 1.463)
texture = ExtResource("1_ekn6m")
[node name="AnimationPlayer" type="AnimationPlayer" parent="."]
libraries = {
&"": SubResource("AnimationLibrary_ee4bf")
}
autoplay = "new_animation"

View File

@@ -0,0 +1,28 @@
[gd_scene load_steps=2 format=3 uid="uid://dydvtidqppffs"]
[ext_resource type="Script" uid="uid://kbgdevvugj7k" path="res://addons/godot-ndi/demo/ndi_finder.gd" id="1_0rvup"]
[node name="Canvas" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="VideoStreamPlayer" type="VideoStreamPlayer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
autoplay = true
expand = true
[node name="NDIFinder" type="NDIFinder" parent="."]
script = ExtResource("1_0rvup")
[connection signal="sources_changed" from="NDIFinder" to="NDIFinder" method="_on_sources_changed"]
[connection signal="sources_found" from="NDIFinder" to="NDIFinder" method="_on_sources_found"]
[connection signal="sources_gone" from="NDIFinder" to="NDIFinder" method="_on_sources_gone"]

View File

@@ -0,0 +1,22 @@
[gd_scene load_steps=2 format=3 uid="uid://byblss3lhwvqm"]
[sub_resource type="VideoStreamNDI" id="VideoStreamNDI_lv7vm"]
[node name="Canvas" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="VideoStreamPlayer" type="VideoStreamPlayer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
stream = SubResource("VideoStreamNDI_lv7vm")
autoplay = true
expand = true

View File

@@ -0,0 +1,37 @@
[gd_scene load_steps=5 format=3 uid="uid://ddr3hiaft61tl"]
[ext_resource type="VideoStream" uid="uid://0whnog13yrlf" path="res://addons/godot-ndi/demo/assets/liftoff_space_shuttle_atlantis.ogv" id="1_utmvk"]
[ext_resource type="AudioStream" uid="uid://dyvxmok1n3vu1" path="res://addons/godot-ndi/demo/assets/sub-d-more-jazz-guitar.wav" id="2_51pmd"]
[ext_resource type="Script" uid="uid://d2sex4jisnkck" path="res://addons/godot-ndi/demo/video_stream_player.gd" id="2_w6vuq"]
[ext_resource type="VideoStream" path="res://addons/godot-ndi/demo/assets/twitch_sync_footage.ogv" id="3_ug8g3"]
[node name="Canvas" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="VideoStreamPlayer" type="VideoStreamPlayer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
volume_db = -15.0
autoplay = true
script = ExtResource("2_w6vuq")
playlist = Array[VideoStream]([ExtResource("1_utmvk"), ExtResource("3_ug8g3")])
[node name="AudioStreamPlayer" type="AudioStreamPlayer2D" parent="."]
stream = ExtResource("2_51pmd")
autoplay = true
panning_strength = 0.0
[node name="NDIOutput" type="NDIOutput" parent="."]
name = "Godot (Video)"
audio_bus = &"Master"
[connection signal="finished" from="VideoStreamPlayer" to="VideoStreamPlayer" method="_on_finished"]

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="124" height="124" x="2" y="2" fill="#363d52" stroke="#212532" stroke-width="4" rx="14"/><g fill="#fff" transform="translate(12.322 12.322)scale(.101)"><path d="M105 673v33q407 354 814 0v-33z"/><path fill="#478cbf" d="m105 673 152 14q12 1 15 14l4 67 132 10 8-61q2-11 15-15h162q13 4 15 15l8 61 132-10 4-67q3-13 15-14l152-14V427q30-39 56-81-35-59-83-108-43 20-82 47-40-37-88-64 7-51 8-102-59-28-123-42-26 43-46 89-49-7-98 0-20-46-46-89-64 14-123 42 1 51 8 102-48 27-88 64-39-27-82-47-48 49-83 108 26 42 56 81zm0 33v39c0 276 813 276 814 0v-39l-134 12-5 69q-2 10-14 13l-162 11q-12 0-16-11l-10-65H446l-10 65q-4 11-16 11l-162-11q-12-3-14-13l-5-69z"/><path d="M483 600c0 34 58 34 58 0v-86c0-34-58-34-58 0z"/><circle cx="725" cy="526" r="90"/><circle cx="299" cy="526" r="90"/></g><g fill="#414042" transform="translate(12.322 12.322)scale(.101)"><circle cx="307" cy="532" r="60"/><circle cx="717" cy="532" r="60"/></g></svg>

After

Width:  |  Height:  |  Size: 994 B

View File

@@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cfvxj5cdj3n45"
path="res://.godot/imported/icon.svg-5dbb75b05306facc4cf78b15baaae64d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/godot-ndi/demo/assets/icon.svg"
dest_files=["res://.godot/imported/icon.svg-5dbb75b05306facc4cf78b15baaae64d.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 @@
uid://0whnog13yrlf

View File

@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://dyvxmok1n3vu1"
path="res://.godot/imported/sub-d-more-jazz-guitar.wav-6bba7f91e7070b729764277f88bb96b0.sample"
[deps]
source_file="res://addons/godot-ndi/demo/assets/sub-d-more-jazz-guitar.wav"
dest_files=["res://.godot/imported/sub-d-more-jazz-guitar.wav-6bba7f91e7070b729764277f88bb96b0.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=true
edit/loop_mode=2
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

Binary file not shown.

View File

@@ -0,0 +1 @@
uid://4mp8kyfc212k

View File

@@ -0,0 +1,13 @@
extends NDIFinder
@onready var player: VideoStreamPlayer = $"../VideoStreamPlayer"
func _on_sources_changed() -> void:
player.stream = get_sources().pop_front()
player.play()
func _on_sources_found(found_sources: Array[VideoStreamNDI]) -> void:
print("NDI sources found: ", found_sources.map(func (source: VideoStreamNDI): return source.get_name()))
func _on_sources_gone(gone_sources: Array[VideoStreamNDI]) -> void:
print("NDI sources gone: ", gone_sources.map(func (source: VideoStreamNDI): return source.get_name()))

View File

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

View File

@@ -0,0 +1,17 @@
extends VideoStreamPlayer
@export var playlist: Array[VideoStream] = []
func _ready() -> void:
_on_finished()
func _on_finished() -> void:
if playlist.is_empty():
return
var next_idx = playlist.find(stream) + 1
if next_idx >= playlist.size():
next_idx = 0
stream = playlist[next_idx]
play()

View File

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

View File

@@ -0,0 +1,18 @@
[configuration]
entry_symbol = "godot_ndi_init"
compatibility_minimum = "4.4"
reloadable = false
[libraries]
linux.x86_64.single = "bin/linux/libgodot-ndi.linux.template_debug.x86_64.so"
linux.arm64.single = "bin/linux/libgodot-ndi.linux.template_debug.arm64.so"
windows.x86_64.single = "bin/windows/libgodot-ndi.windows.template_debug.x86_64.dll"
macos.single = "bin/macos/libgodot-ndi.macos.template_debug.universal.dylib"
[icons]
VideoStreamNDI = "icons/VideoStreamNDI.svg"
NDIOutput = "icons/NDIOutput.svg"
NDIFinder = "icons/NDIFinder.svg"

View File

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

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<g fill="none">
<path d="m6 10-3.5 3.5" stroke="#e4eded" stroke-linecap="round" stroke-width="3"/>
<circle cx="9.3341" cy="6.5" r="4.0547" stroke="#fa3d49" stroke-width="1.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 390 B

View File

@@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bjqtfd5qckc6l"
path="res://.godot/imported/NDIFinder.svg-6145b2a9070199a0c93ffe21a332ddf0.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/godot-ndi/icons/NDIFinder.svg"
dest_files=["res://.godot/imported/NDIFinder.svg-6145b2a9070199a0c93ffe21a332ddf0.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,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="7.2363" fill="#e4eded" stroke-width="11.339"/>
<circle cx="8" cy="8" r="3.247" fill="#fa3d49" stroke-width="1.284"/>
</svg>

After

Width:  |  Height:  |  Size: 346 B

View File

@@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cvv0gnogt1vac"
path="res://.godot/imported/NDIOutput.svg-b19965324d53dea421ada474b6902c23.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/godot-ndi/icons/NDIOutput.svg"
dest_files=["res://.godot/imported/NDIOutput.svg-b19965324d53dea421ada474b6902c23.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,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="m11 15 4.3235-7h-4.3235l-3.4993 5.5046-0.08129 1.4954z" fill="#484d3d"/>
<path d="m11 15s-6-1-6-7 6-7 6-7h-10v14z" fill="#e4eded"/>
<circle cx="11" cy="8" r="2.5288" fill="#fa3d49"/>
</svg>

After

Width:  |  Height:  |  Size: 397 B

View File

@@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://pfqe4htodj7k"
path="res://.godot/imported/VideoStreamNDI.svg-79e19fa3a194ede543418d01dcbbb6a7.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/godot-ndi/icons/VideoStreamNDI.svg"
dest_files=["res://.godot/imported/VideoStreamNDI.svg-79e19fa3a194ede543418d01dcbbb6a7.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,58 @@
extends Node
## Blend Mode Manager
##
## Manages blend mode constants and provides utility functions for
## working with blend modes in the PNGTuber application.
## Blend mode constants
enum BlendMode {
NORMAL = 0,
MULTIPLY = 1,
SCREEN = 2,
OVERLAY = 3,
SOFT_LIGHT = 4,
HARD_LIGHT = 5,
COLOR_DODGE = 6,
COLOR_BURN = 7,
DARKEN = 8,
LIGHTEN = 9,
ADD = 10, # Linear Dodge
SUBTRACT = 11
}
## Returns an array of blend mode names for use in dropdowns
static func get_blend_mode_names() -> Array:
return [
"Normal",
"Multiply",
"Screen",
"Overlay",
"Soft Light",
"Hard Light",
"Color Dodge",
"Color Burn",
"Darken",
"Lighten",
"Add",
"Subtract"
]
## Returns the string name for a given blend mode enum value
static func get_blend_mode_name(mode: BlendMode) -> String:
var names = get_blend_mode_names()
if mode >= 0 and mode < names.size():
return names[mode]
return "Normal"
## Returns the blend mode enum value for a given name
static func get_blend_mode_from_name(name: String) -> BlendMode:
var names = get_blend_mode_names()
var index = names.find(name)
if index != -1:
return index as BlendMode
return BlendMode.NORMAL
## Returns whether a blend mode is valid
static func is_valid_blend_mode(mode: int) -> bool:
return mode >= BlendMode.NORMAL and mode <= BlendMode.SUBTRACT

View File

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

View File

@@ -29,13 +29,13 @@ var blink = false
var blinkTick = 0
#Audio Listener
var currentMicrophone = null
var playa:AudioStreamPlayer
var speaking = false
var spectrum
var volume = 0
var volumeSensitivity = 0.0
var experimentalMicLoudness = false
var volumeLimit = 0.0
var senseLimit = 0.0
@@ -44,8 +44,6 @@ var senseLimit = 0.0
signal startSpeaking
signal stopSpeaking
var micResetTime = 180
var updatePusherNode = null
var rand = RandomNumberGenerator.new()
@@ -56,28 +54,23 @@ func _ready():
if !Saving.settings.has("useStreamDeck"):
Saving.settings["useStreamDeck"] = false
if Saving.settings.has("secondsToMicReset"):
Global.micResetTime = Saving.settings["secondsToMicReset"]
if Saving.settings.has("experimentalMicLoudness"):
Global.experimentalMicLoudness = Saving.settings["experimentalMicLoudness"]
else:
Saving.settings["secondsToMicReset"] = 180
Saving.settings["experimentalMicLoudness"] = false
createMicrophone()
func createMicrophone():
var playa = AudioStreamPlayer.new()
var mic = AudioStreamMicrophone.new()
playa.stream = mic
playa.autoplay = true
if playa != null:
remove_child(playa)
playa.free()
playa = AudioStreamPlayer.new()
playa.bus = "MIC"
playa.stream = AudioStreamMicrophone.new()
add_child(playa)
currentMicrophone = playa
await get_tree().create_timer(micResetTime).timeout
if currentMicrophone != playa:
return
deleteAllMics()
currentMicrophone = null
await get_tree().create_timer(0.25).timeout
createMicrophone()
await get_tree().create_timer(0.25).timeout # apparently it works to fix WASAPI "Initialize failed with error 0xffffffff88890002" and "init_input_device" errors
playa.play()
func deleteAllMics():
for child in get_children():
@@ -87,8 +80,9 @@ func deleteAllMics():
func _process(delta):
animationTick += 1
volume = spectrum.get_magnitude_for_frequency_range(20, 20000).length()
if currentMicrophone != null:
volume = (AudioServer.get_bus_peak_volume_left_db(1, 0) + AudioServer.get_bus_peak_volume_right_db(1, 0)) / 1600.0 + 0.25 if experimentalMicLoudness else spectrum.get_magnitude_for_frequency_range(20, 20000).length()
if playa != null:
volumeSensitivity = lerp(volumeSensitivity,0.0,delta*2)
if volume>volumeLimit:
@@ -112,7 +106,7 @@ func _process(delta):
heldSprite.z += 1
heldSprite.setZIndex()
pushUpdate("Moved sprite layer.")
if main.editMode:
if main and main.editMode:
if Input.is_action_just_pressed("reparent"):
reparentMode = !reparentMode
Global.chain.enable(reparentMode)
@@ -121,7 +115,7 @@ func _process(delta):
reparentMode = false
Global.chain.enable(reparentMode)
if main.editMode:
if main and main.editMode:
if reparentMode:
RenderingServer.set_default_clear_color(Color.POWDER_BLUE)
else:
@@ -132,7 +126,7 @@ func _process(delta):
scrollSprites()
if !main.fileSystemOpen:
if !main or main.fileSystemOpen:
if Input.is_action_just_pressed("refresh"):
refresh()
@@ -228,7 +222,7 @@ func scrollSprites():
if Input.is_action_pressed("control"):
return
if !main.editMode:
if !main or !main.editMode:
return
if main.fileSystemOpen:
@@ -286,7 +280,6 @@ func blinking():
blinkTick = -12
func epicFail(err):
print(fail)
if fail == null:
return

View File

@@ -54,13 +54,13 @@ var settings = {
"bounce":250,
"gravity":1000,
"maxFPS":60,
"secondsToMicReset":180,
"backgroundColor":var_to_str(Color(0.0,0.0,0.0,0.0)),
"filtering":false,
"costumeKeys":["1","2","3","4","5","6","7","8","9","0"],
"blinkSpeed":1.0,
"blinkChance":200,
"bounceOnCostumeChange":false,
"experimentalMicLoudness":false,
}
var settingsPath = "user://settings.pngtp"

View File

@@ -0,0 +1,336 @@
extends Node
## WobbleSyncGroupManager - Manages synchronized wobble groups for sprites
## Follows class-based OOP pattern as requested
var syncGroups: Dictionary = {} # groupName: WobbleSyncGroup
var spriteGroupMembership: Dictionary = {} # spriteId: groupName
signal group_created(group_name: String)
signal group_deleted(group_name: String)
signal sprite_joined_group(sprite_id: int, group_name: String)
signal sprite_left_group(sprite_id: int, group_name: String)
func _ready():
# Manager initialization complete
pass
class WobbleSyncGroup:
var name: String
var xFrq: float = 0.0
var xAmp: float = 0.0
var yFrq: float = 0.0
var yAmp: float = 0.0
var rFrq: float = 0.0
var rAmp: float = 0.0
var memberSprites: Array = []
func _init(group_name: String):
name = group_name
## Set wobble parameters for the entire group
func setWobbleParameters(x_frq: float, x_amp: float, y_frq: float, y_amp: float, r_frq: float, r_amp: float, manager_ref):
xFrq = x_frq
xAmp = x_amp
yFrq = y_frq
yAmp = y_amp
rFrq = r_frq
rAmp = r_amp
# Update all member sprites using the manager reference
var sprites = manager_ref.get_tree().get_nodes_in_group("saved")
var updated_count = 0
for sprite in sprites:
if sprite.id in memberSprites:
# Preserve the sprite's group membership
var current_group = sprite.wobbleSyncGroup
sprite.xFrq = xFrq
sprite.xAmp = xAmp
sprite.yFrq = yFrq
sprite.yAmp = yAmp
sprite.rFrq = rFrq
sprite.rAmp = rAmp
# Restore group membership in case it got cleared
sprite.wobbleSyncGroup = current_group
updated_count += 1
## Add sprite to this group
func addSprite(sprite_id): # Remove type constraint to handle float IDs
if sprite_id not in memberSprites:
memberSprites.append(sprite_id)
else:
pass # Sprite already in group
## Remove sprite from this group
func removeSprite(sprite_id): # Remove type constraint
memberSprites.erase(sprite_id)
## Check if group is empty
func isEmpty() -> bool:
return memberSprites.size() == 0
## Get group data for saving
func toSaveData() -> Dictionary:
return {
"name": name,
"xFrq": xFrq,
"xAmp": xAmp,
"yFrq": yFrq,
"yAmp": yAmp,
"rFrq": rFrq,
"rAmp": rAmp,
"memberSprites": memberSprites
}
## Load group data from save
func fromSaveData(data: Dictionary):
name = data.get("name", "")
xFrq = data.get("xFrq", 0.0)
xAmp = data.get("xAmp", 0.0)
yFrq = data.get("yFrq", 0.0)
yAmp = data.get("yAmp", 0.0)
rFrq = data.get("rFrq", 0.0)
rAmp = data.get("rAmp", 0.0)
memberSprites = data.get("memberSprites", [])
## Create a new wobble sync group
func createGroup(group_name: String, sprite_id = -1) -> bool: # Remove type constraint
if group_name.strip_edges() == "" or syncGroups.has(group_name):
return false
var group = WobbleSyncGroup.new(group_name)
syncGroups[group_name] = group
# If a sprite ID is provided, add it and copy its wobble values
if sprite_id != -1:
var sprite = getSpriteById(sprite_id)
if sprite:
# Store the sprite's raw wobble values directly
group.setWobbleParameters(sprite.xFrq, sprite.xAmp, sprite.yFrq, sprite.yAmp, sprite.rFrq, sprite.rAmp, self)
addSpriteToGroup(sprite_id, group_name)
group_created.emit(group_name)
Global.pushUpdate("Created wobble sync group: " + group_name)
return true
## Delete a wobble sync group
func deleteGroup(group_name: String):
if not syncGroups.has(group_name):
return
var group = syncGroups[group_name]
# Remove all sprites from the group
for sprite_id in group.memberSprites.duplicate():
removeSpriteFromGroup(sprite_id, group_name)
syncGroups.erase(group_name)
group_deleted.emit(group_name)
Global.pushUpdate("Deleted wobble sync group: " + group_name)
## Add sprite to a group
func addSpriteToGroup(sprite_id, group_name: String) -> bool: # Remove type constraint
if not syncGroups.has(group_name):
return false
# Remove sprite from any existing group first
removeSpriteFromAllGroups(sprite_id)
var group = syncGroups[group_name]
group.addSprite(sprite_id)
spriteGroupMembership[sprite_id] = group_name
# Sync the sprite's wobble parameters to the group
var sprite = getSpriteById(sprite_id)
if sprite:
sprite.wobbleSyncGroup = group_name
sprite.xFrq = group.xFrq
sprite.xAmp = group.xAmp
sprite.yFrq = group.yFrq
sprite.yAmp = group.yAmp
sprite.rFrq = group.rFrq
sprite.rAmp = group.rAmp
sprite_joined_group.emit(sprite_id, group_name)
Global.pushUpdate("Added sprite to wobble sync group: " + group_name)
return true
## Remove sprite from a specific group
func removeSpriteFromGroup(sprite_id, group_name: String): # Remove type constraint
if not syncGroups.has(group_name):
return
var group = syncGroups[group_name]
group.removeSprite(sprite_id)
spriteGroupMembership.erase(sprite_id)
# Clear the sprite's sync group reference
var sprite = getSpriteById(sprite_id)
if sprite:
sprite.wobbleSyncGroup = ""
else:
pass # Sprite not found
sprite_left_group.emit(sprite_id, group_name)
# Auto-delete empty groups
if group.isEmpty():
deleteGroup(group_name)
else:
Global.pushUpdate("Removed sprite from wobble sync group: " + group_name)
## Remove sprite from all groups
func removeSpriteFromAllGroups(sprite_id): # Remove type constraint
if spriteGroupMembership.has(sprite_id):
var group_name = spriteGroupMembership[sprite_id]
removeSpriteFromGroup(sprite_id, group_name)
else:
pass # Sprite not in any group
## Update wobble parameters for a group
func updateGroupWobble(group_name: String, x_frq: float, x_amp: float, y_frq: float, y_amp: float, r_frq: float, r_amp: float):
if not syncGroups.has(group_name):
return
var group = syncGroups[group_name]
group.setWobbleParameters(x_frq, x_amp, y_frq, y_amp, r_frq, r_amp, self)
## Get sprite's current group name
func getSpriteGroupName(sprite_id: int) -> String:
return spriteGroupMembership.get(sprite_id, "")
## Get all group names
func getGroupNames() -> Array:
return syncGroups.keys()
## Test function to manually load wobble sync groups
func testManualLoad():
var test_data = {
"ears": {
"name": "ears",
"xFrq": 0.0,
"xAmp": 0.0,
"yFrq": 0.019,
"yAmp": 3.0,
"rFrq": 0.399,
"rAmp": 47.0,
"memberSprites": [3291286625.0]
}
}
await loadSaveData(test_data)
## Check if a group exists
func hasGroup(group_name: String) -> bool:
return syncGroups.has(group_name)
## Get sprite by ID
func getSpriteById(sprite_id): # Remove type constraint to handle float IDs
var sprites = get_tree().get_nodes_in_group("saved")
for sprite in sprites:
if sprite.id == sprite_id:
return sprite
return null
## Clean up orphaned sprites (sprites that no longer exist)
func cleanupOrphanedSprites():
var existing_sprite_ids = []
var sprites = get_tree().get_nodes_in_group("saved")
for sprite in sprites:
existing_sprite_ids.append(sprite.id)
# Check each group for orphaned sprite IDs
for group_name in syncGroups.keys():
var group = syncGroups[group_name]
var orphaned_ids = []
for sprite_id in group.memberSprites:
if sprite_id not in existing_sprite_ids:
orphaned_ids.append(sprite_id)
# Remove orphaned IDs
for orphaned_id in orphaned_ids:
group.removeSprite(orphaned_id)
spriteGroupMembership.erase(orphaned_id)
# Delete empty groups
if group.isEmpty():
deleteGroup(group_name)
## Get save data for all groups
func getSaveData() -> Dictionary:
var save_data = {}
for group_name in syncGroups.keys():
var group = syncGroups[group_name]
save_data[group_name] = group.toSaveData()
return save_data
## Load save data for all groups
func loadSaveData(save_data: Dictionary):
# Check if save_data is valid
if save_data == null or typeof(save_data) != TYPE_DICTIONARY:
return
if save_data.size() == 0:
return
# Clear existing groups
syncGroups.clear()
spriteGroupMembership.clear()
# Load groups from save data
var loaded_groups = 0
for group_name in save_data.keys():
var group_data = save_data[group_name]
if typeof(group_data) == TYPE_DICTIONARY:
var group = WobbleSyncGroup.new(group_name)
group.fromSaveData(group_data)
syncGroups[group_name] = group
loaded_groups += 1
# Update sprite group membership
for sprite_id in group.memberSprites:
spriteGroupMembership[sprite_id] = group_name
else:
pass # Invalid group data
# If no groups were loaded, clear any orphaned group references on sprites
if syncGroups.size() == 0:
var sprites = get_tree().get_nodes_in_group("saved")
for sprite in sprites:
if sprite.wobbleSyncGroup != "":
sprite.wobbleSyncGroup = ""
else:
# Apply wobble parameters to sprites with better timing
await get_tree().process_frame # Wait for sprites to be loaded
await get_tree().process_frame # Extra frame to ensure everything is ready
for group_name in syncGroups.keys():
var group = syncGroups[group_name]
for sprite_id in group.memberSprites:
var sprite = getSpriteById(sprite_id)
if sprite:
sprite.wobbleSyncGroup = group_name
sprite.xFrq = group.xFrq
sprite.xAmp = group.xAmp
sprite.yFrq = group.yFrq
sprite.yAmp = group.yAmp
sprite.rFrq = group.rFrq
sprite.rAmp = group.rAmp
# Force a complete opacity update after modifying sprite properties
if sprite.has_method("updateOpacity"):
sprite.updateOpacity()
if sprite.has_method("updateShaderOpacity"):
sprite.updateShaderOpacity()
else:
pass # Sprite not found
## Called when a sprite is deleted to clean up references
func onSpriteDeleted(sprite_id): # Remove type constraint
removeSpriteFromAllGroups(sprite_id)

View File

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

View File

@@ -4,7 +4,8 @@ extends Node2D
@onready var editButton = $Edit/Button
func _process(delta):
var uiAnimSpeed = 8.0 # Fixed UI animation speed
if Rect2(editButton.get_parent().position-Vector2(24,24),editButton.size).has_point(get_local_mouse_position()):
editSprite.scale = lerp(editSprite.scale,Vector2(1.2,1.2),0.2)
editSprite.scale = lerp(editSprite.scale, Vector2(1.2,1.2), delta * uiAnimSpeed)
else:
editSprite.scale = lerp(editSprite.scale,Vector2(1.0,1.0),0.2)
editSprite.scale = lerp(editSprite.scale, Vector2(1.0,1.0), delta * uiAnimSpeed)

View File

@@ -30,7 +30,8 @@ func _process(delta):
if buttons[b] == null:
continue
if Rect2(buttons[b].get_parent().position-Vector2(24,24),buttons[b].size).has_point(get_local_mouse_position()):
sprites[s].scale = lerp(sprites[s].scale,Vector2(1.2,1.2),0.2)
var uiAnimSpeed = 8.0 # Fixed UI animation speed
sprites[s].scale = lerp(sprites[s].scale, Vector2(1.2,1.2), delta * uiAnimSpeed)
if Input.is_action_pressed("mouse_left"):
sprites[s].scale = Vector2(0.6,0.6)
match b:
@@ -50,7 +51,8 @@ func _process(delta):
Global.mouse.text = "Duplicate sprite"
else:
sprites[s].scale = lerp(sprites[s].scale,Vector2(1.0,1.0),0.2)
var uiAnimSpeed = 8.0 # Fixed UI animation speed
sprites[s].scale = lerp(sprites[s].scale, Vector2(1.0,1.0), delta * uiAnimSpeed)
s += 1
var newColor = Color.DARK_SLATE_GRAY if Global.heldSprite == null else Color.WHITE

View File

@@ -0,0 +1,144 @@
extends SubViewport
## NDI Avatar Viewport Controller
## Handles avatar-only NDI streaming with synchronized states
#Node Reference
@onready var origin = $OriginMotion/Origin
@onready var ndi_output = $NDIOutput
## Initialize viewport settings for transparent background and avatar mirroring
func _ready():
# Set transparent background
transparent_bg = true
# Connect to the main scene for state sync
if Global.main:
Global.main.connect("spriteVisToggles", Callable(self, "_on_sprite_visibility_toggle"))
Global.main.connect("bounceChange", Callable(self, "_sync_bounce"))
## Sync avatar sprites from main scene to this viewport
func sync_avatar():
# Clear existing sprites
for child in origin.get_children():
child.queue_free()
# Wait for cleanup
await get_tree().process_frame
# Get sprites from main scene
var main_sprites = get_tree().get_nodes_in_group("saved")
for sprite in main_sprites:
clone_sprite(sprite)
## Clone a sprite from main scene to this viewport
func clone_sprite(original_sprite):
# Create new instance
var sprite_scene = preload("res://ui_scenes/selectedSprite/spriteObject.tscn")
var cloned_sprite = sprite_scene.instantiate()
# Copy all properties
cloned_sprite.path = original_sprite.path
cloned_sprite.id = original_sprite.id
cloned_sprite.parentId = original_sprite.parentId
cloned_sprite.displayName = original_sprite.displayName
# Visual properties
cloned_sprite.offset = original_sprite.offset
cloned_sprite.z = original_sprite.z
cloned_sprite.position = original_sprite.position
# Physics properties
cloned_sprite.dragSpeed = original_sprite.dragSpeed
cloned_sprite.xFrq = original_sprite.xFrq
cloned_sprite.xAmp = original_sprite.xAmp
cloned_sprite.yFrq = original_sprite.yFrq
cloned_sprite.yAmp = original_sprite.yAmp
cloned_sprite.rFrq = original_sprite.rFrq
cloned_sprite.rAmp = original_sprite.rAmp
cloned_sprite.rdragStr = original_sprite.rdragStr
cloned_sprite.rLimitMin = original_sprite.rLimitMin
cloned_sprite.rLimitMax = original_sprite.rLimitMax
cloned_sprite.stretchAmount = original_sprite.stretchAmount
cloned_sprite.ignoreBounce = original_sprite.ignoreBounce
# Animation properties
cloned_sprite.frames = original_sprite.frames
cloned_sprite.animSpeed = original_sprite.animSpeed
# Visibility properties
cloned_sprite.showOnTalk = original_sprite.showOnTalk
cloned_sprite.showOnBlink = original_sprite.showOnBlink
cloned_sprite.costumeLayers = original_sprite.costumeLayers.duplicate()
cloned_sprite.spriteOpacity = original_sprite.spriteOpacity
cloned_sprite.affectChildrenOpacity = original_sprite.affectChildrenOpacity
cloned_sprite.blendMode = original_sprite.blendMode
cloned_sprite.toggle = original_sprite.toggle
cloned_sprite.clipped = original_sprite.clipped
# Transform properties
cloned_sprite.staticRotation = original_sprite.staticRotation
cloned_sprite.mirrorHorizontal = original_sprite.mirrorHorizontal
cloned_sprite.mirrorVertical = original_sprite.mirrorVertical
# Wobble sync
cloned_sprite.wobbleSyncGroup = original_sprite.wobbleSyncGroup
# Add to origin
origin.add_child(cloned_sprite)
# Apply current costume visibility
if Global.main:
var current_costume = Global.main.costume
if cloned_sprite.costumeLayers[current_costume - 1] == 1:
cloned_sprite.visible = true
else:
cloned_sprite.visible = false
## Process function to keep sprites in sync with main scene
func _process(delta):
if !Global.main:
return
# Sync origin position with bounce from main scene
$OriginMotion.position.y = 360 + Global.main.bounceChange
# Sync sprites with main scene
var main_sprites = get_tree().get_nodes_in_group("saved")
var avatar_sprites = origin.get_children()
for i in range(min(main_sprites.size(), avatar_sprites.size())):
sync_sprite_state(main_sprites[i], avatar_sprites[i])
## Sync individual sprite state
func sync_sprite_state(main_sprite, avatar_sprite):
if !is_instance_valid(main_sprite) or !is_instance_valid(avatar_sprite):
return
# Sync position and transform
avatar_sprite.position = main_sprite.position
avatar_sprite.rotation = main_sprite.rotation
avatar_sprite.scale = main_sprite.scale
# Sync visibility based on costume
if Global.main:
var current_costume = Global.main.costume
if avatar_sprite.costumeLayers[current_costume - 1] == 1:
avatar_sprite.visible = main_sprite.visible
else:
avatar_sprite.visible = false
# Sync animation frame
if avatar_sprite.has_method("get_sprite") and main_sprite.has_method("get_sprite"):
var avatar_spr = avatar_sprite.get_sprite()
var main_spr = main_sprite.get_sprite()
if avatar_spr and main_spr:
avatar_spr.frame = main_spr.frame
## Handle sprite visibility toggles
func _on_sprite_visibility_toggle():
sync_avatar()
## Sync bounce changes
func _sync_bounce(bounce_change):
$OriginMotion.position.y = 360 + bounce_change

View File

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

View File

@@ -0,0 +1,18 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://main_scenes/NDIAvatarViewport.gd" id="1"]
[node name="SubViewport" type="SubViewport"]
handle_input_locally = false
size = Vector2i(1280, 720)
render_target_update_mode = 4
script = ExtResource("1")
[node name="NDIOutput" type="NDIOutput"]
name = "PNGTuber Avatar"
enable_editor_output = true
[node name="OriginMotion" type="Node2D"]
position = Vector2(640, 360)
[node name="Origin" type="Node2D" parent="OriginMotion"]

View File

@@ -0,0 +1,110 @@
class_name NDIOutputManager
extends Node
## NDI Output Manager for PNGTuber-Plus
## Handles NDI streaming output for OBS integration
##
## This class manages the NDI output functionality, allowing the PNGTuber-Plus
## avatar to be streamed as an NDI source that can be captured by OBS Studio
## or other NDI-compatible applications.
@export var ndi_source_name: String = "PNGTuber-Plus"
@export var enable_audio: bool = true
@export var audio_bus_name: String = "Master"
@export var auto_start: bool = true
var ndi_output: NDIOutput
var is_streaming: bool = false
## Called when the node enters the scene tree
func _ready() -> void:
if auto_start:
initialize_ndi_output()
## Initialize the NDI output node and configure it
func initialize_ndi_output() -> void:
# Create NDI output node if it doesn't exist
if not ndi_output:
ndi_output = NDIOutput.new()
add_child(ndi_output)
# Configure NDI output settings
configure_ndi_output()
print("NDI Output initialized with source name: ", ndi_source_name)
## Configure the NDI output with current settings
func configure_ndi_output() -> void:
if not ndi_output:
push_error("NDI Output node not found")
return
# Set the NDI source name
ndi_output.name = ndi_source_name
# Configure audio if enabled
if enable_audio:
ndi_output.audio_bus = StringName(audio_bus_name)
print("NDI Output configured - Name: ", ndi_source_name, " Audio: ", enable_audio)
## Start NDI streaming
func start_streaming() -> void:
if not ndi_output:
initialize_ndi_output()
if ndi_output and not is_streaming:
is_streaming = true
print("NDI streaming started - Source: ", ndi_source_name)
## Stop NDI streaming
func stop_streaming() -> void:
if ndi_output and is_streaming:
is_streaming = false
print("NDI streaming stopped")
## Toggle NDI streaming on/off
func toggle_streaming() -> bool:
if is_streaming:
stop_streaming()
return false
else:
start_streaming()
return true
## Update the NDI source name
func set_source_name(new_name: String) -> void:
ndi_source_name = new_name
if ndi_output:
ndi_output.name = ndi_source_name
print("NDI source name changed to: ", ndi_source_name)
## Enable or disable audio streaming
func set_audio_enabled(enabled: bool) -> void:
enable_audio = enabled
if ndi_output:
if enable_audio:
ndi_output.audio_bus = StringName(audio_bus_name)
else:
ndi_output.audio_bus = StringName("")
print("NDI audio ", "enabled" if enabled else "disabled")
## Change the audio bus for NDI output
func set_audio_bus(bus_name: String) -> void:
audio_bus_name = bus_name
if ndi_output and enable_audio:
ndi_output.audio_bus = StringName(audio_bus_name)
print("NDI audio bus changed to: ", audio_bus_name)
## Get current streaming status
func get_streaming_status() -> bool:
return is_streaming
## Get current NDI source name
func get_source_name() -> String:
return ndi_source_name
## Clean up when node is removed
func _exit_tree() -> void:
if ndi_output and is_streaming:
stop_streaming()

View File

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

View File

@@ -8,7 +8,7 @@ var editMode = true
@onready var controlPanel = $ControlPanel
@onready var editControls = $EditControls
@onready var tutorial = $Tutorial
@onready var spriteViewer = $EditControls/SpriteViewer
@onready var spriteViewer = $EditControls/Container/SpriteViewer
@onready var viewerArrows = $ViewerArrows
@onready var spriteList = $EditControls/SpriteList
@@ -25,6 +25,9 @@ var editMode = true
@onready var shadow = $shadowSprite
@onready var ndi_output = $NDIOutput
@onready var ndi_avatar_viewport = $NDIAvatarViewport
#Scene Reference
@onready var spriteObject = preload("res://ui_scenes/selectedSprite/spriteObject.tscn")
@@ -36,6 +39,10 @@ var yVel = 0
var bounceSlider = 250
var bounceGravity = 1000
# Physics timing multiplier - adjust this to fine-tune physics speed
# 1.0 = very slow, 10.0 = reasonable, 60.0 = old frame-based speed
var physicsTimeMultiplier = 6.0
#Costumes
var costume = 1
var bounceOnCostumeChange = false
@@ -43,6 +50,18 @@ var bounceOnCostumeChange = false
#Zooming
var scaleOverall = 100
#Camera Panning
var isPanning = false
var initialPanPosition = Vector2.ZERO
var initialCameraOffset = Vector2.ZERO
var cameraOffset = Vector2.ZERO
#View Mode Panning
var viewModePanning = false
var initialViewPanPosition = Vector2.ZERO
var viewPanOffset = Vector2.ZERO
var viewPanLerpSpeed = 3.0
var bounceChange = 0.0
#IMPORTANT
@@ -59,17 +78,23 @@ func _ready():
Global.main = self
Global.fail = $Failed
var newUser = Saving.settings["newUser"]
Global.connect("startSpeaking",onSpeak)
ElgatoStreamDeck.on_key_down.connect(changeCostumeStreamDeck)
# Connect to StreamDeck if available
var streamdeck_node = get_node_or_null("/root/ElgatoStreamDeck")
if streamdeck_node != null:
streamdeck_node.on_key_down.connect(changeCostumeStreamDeck)
if Saving.settings["newUser"]:
_on_load_dialog_file_selected("default")
Saving.settings["newUser"] = false
saveLoaded = true
if newUser:
loadDefaultAvatar()
else:
_on_load_dialog_file_selected(Saving.settings["lastAvatar"])
var lastAvatar = Saving.settings.get("lastAvatar", "")
if lastAvatar != null and lastAvatar != "" and typeof(lastAvatar) == TYPE_STRING and FileAccess.file_exists(lastAvatar):
loadAvatar(lastAvatar)
else:
loadDefaultAvatar()
$ControlPanel/volumeSlider.value = Saving.settings["volume"]
$ControlPanel/sensitiveSlider.value = Saving.settings["sense"]
@@ -130,40 +155,130 @@ func _ready():
var s = get_viewport().get_visible_rect().size
origin.position = s*0.5
camera.position = origin.position
updateCameraPosition()
# Initialize avatar viewport
if ndi_avatar_viewport:
ndi_avatar_viewport.visible = false # Start disabled
# Test NDI output on startup
call_deferred("toggle_ndi")
func _process(delta):
var hold = origin.get_parent().position.y
origin.get_parent().position.y += yVel * 0.0166
origin.get_parent().position.y += yVel * delta * physicsTimeMultiplier
if origin.get_parent().position.y > 0:
origin.get_parent().position.y = 0
bounceChange = hold - origin.get_parent().position.y
yVel += bounceGravity*0.0166
yVel += bounceGravity * delta * physicsTimeMultiplier
# Handle view mode panning lerp back to center
if !editMode:
if !viewModePanning:
# Lerp back to center when not actively panning
viewPanOffset = viewPanOffset.lerp(Vector2.ZERO, viewPanLerpSpeed * delta)
# Apply view pan offset to the root character (affects physics)
origin.position = (get_viewport().get_visible_rect().size * 0.5) + viewPanOffset
updateCameraPosition()
if Input.is_action_just_pressed("openFolder"):
OS.shell_open(ProjectSettings.globalize_path("user://"))
moveSpriteMenu(delta)
zoomScene()
zoomScene(delta)
fileSystemOpen = isFileSystemOpen()
followShadow()
func followShadow():
shadow.visible = is_instance_valid(Global.heldSprite)
if !shadow.visible:
return
## Handle input events, especially for camera panning
func _input(event):
# Handle middle mouse button for camera panning/reset
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_MIDDLE:
if editMode:
if event.pressed:
# Start panning in edit mode
isPanning = true
initialPanPosition = event.global_position
initialCameraOffset = cameraOffset
else:
# Stop panning
isPanning = false
else:
if event.pressed:
# Start view mode panning
viewModePanning = true
initialViewPanPosition = event.global_position
else:
# Stop view mode panning
viewModePanning = false
shadow.global_position = Global.heldSprite.sprite.global_position + Vector2(6,6)
shadow.global_rotation = Global.heldSprite.sprite.global_rotation
shadow.offset = Global.heldSprite.sprite.offset
# Handle mouse movement during edit mode panning
elif editMode and event is InputEventMouseMotion and isPanning:
var totalDelta = event.global_position - initialPanPosition
shadow.texture = Global.heldSprite.sprite.texture
shadow.hframes = Global.heldSprite.sprite.hframes
shadow.frame = Global.heldSprite.sprite.frame
# Scale pan distance based on zoom level for more intuitive panning
# Higher zoom (closer) = less movement, lower zoom (farther) = more movement
var zoomScale = 1.0 / camera.zoom.x
var scaledDelta = totalDelta * zoomScale
# Calculate new camera offset from initial position plus scaled movement
cameraOffset = initialCameraOffset - scaledDelta
# Update camera position immediately
var s = get_viewport().get_visible_rect().size
camera.position = origin.position + cameraOffset
# Update UI positions
controlPanel.position = camera.position + (s/(camera.zoom*2.0))
tutorial.position = controlPanel.position
editControls.position = camera.position - (s/(camera.zoom*2.0))
viewerArrows.position = editControls.position
pushUpdates.position.y = controlPanel.position.y
pushUpdates.position.x = editControls.position.x
# Handle mouse movement during view mode panning
elif !editMode and event is InputEventMouseMotion and viewModePanning:
var totalDelta = event.global_position - initialViewPanPosition
# Scale pan distance for view mode (less sensitive than edit mode)
# Invert the movement so dragging right moves character right (not left)
var panSensitivity = 0.5
viewPanOffset = -totalDelta * panSensitivity
# Handle NDI output hotkeys
elif event is InputEventKey and event.pressed:
# F9 key to toggle NDI streaming
if event.keycode == KEY_F9:
toggle_ndi_streaming()
# F10 key to restart NDI streaming with new settings
elif event.keycode == KEY_F10:
restart_ndi_streaming()
## Toggle NDI streaming on/off
func toggle_ndi_streaming():
if ndi_output:
print("NDI Output is available - Source: ", ndi_output.name)
Global.pushUpdate("NDI Output available - Source: " + ndi_output.name)
else:
print("NDI Output not found")
Global.pushUpdate("NDI Output not found")
## Restart NDI streaming to apply any new settings
func restart_ndi_streaming():
if ndi_output:
print("NDI Output available - Source: ", ndi_output.name)
Global.pushUpdate("NDI Output available - Source: " + ndi_output.name)
else:
print("NDI Output not found")
Global.pushUpdate("NDI Output not found")
func followShadow():
# Shadow disabled for selected sprites
shadow.visible = false
func isFileSystemOpen():
@@ -173,6 +288,11 @@ func isFileSystemOpen():
return true
Global.heldSprite = null
return true
# Check wobble sync group dialog
if Global.spriteEdit and Global.spriteEdit.wobbleSyncControl and Global.spriteEdit.wobbleSyncControl.createGroupDialog and Global.spriteEdit.wobbleSyncControl.createGroupDialog.visible:
return true
return false
#Displays control panel whether or not application is focused
@@ -198,16 +318,10 @@ func onWindowSizeChange():
lines.position = s*0.5
lines.drawLine()
camera.position = origin.position
controlPanel.position = camera.position + (s/(camera.zoom*2.0))
tutorial.position = controlPanel.position
editControls.position = camera.position - (s/(camera.zoom*2.0))
viewerArrows.position = editControls.position
updateCameraPosition()
spriteList.position.x = s.x - 233
pushUpdates.position.y = controlPanel.position.y
pushUpdates.position.x = editControls.position.x
func zoomScene():
func zoomScene(delta):
#Handles Zooming
if Input.is_action_pressed("control"):
if Input.is_action_just_pressed("scrollUp"):
@@ -221,7 +335,7 @@ func zoomScene():
scaleOverall -= 10
changeZoom()
$ControlPanel/ZoomLabel.modulate.a = lerp($ControlPanel/ZoomLabel.modulate.a,0.0,0.02)
$ControlPanel/ZoomLabel.modulate.a = lerp($ControlPanel/ZoomLabel.modulate.a, 0.0, delta * (physicsTimeMultiplier * 1.2))
func changeZoom():
var newZoom = Vector2(1.0,1.0) / camera.zoom
@@ -233,11 +347,45 @@ func changeZoom():
pushUpdates.scale = newZoom
Global.mouse.scale = newZoom
# Update UI positions to account for new zoom level
# Force update even during panning since zoom affects UI positioning calculations
var s = get_viewport().get_visible_rect().size
controlPanel.position = camera.position + (s/(camera.zoom*2.0))
tutorial.position = controlPanel.position
editControls.position = camera.position - (s/(camera.zoom*2.0))
viewerArrows.position = editControls.position
pushUpdates.position.y = controlPanel.position.y
pushUpdates.position.x = editControls.position.x
$ControlPanel/ZoomLabel.modulate.a = 6.0
$ControlPanel/ZoomLabel.text = "Zoom : " + str(scaleOverall) + "%"
Global.pushUpdate("Set zoom to " + str(scaleOverall) + "%")
onWindowSizeChange()
## Update camera position with current offset
func updateCameraPosition():
var s = get_viewport().get_visible_rect().size
# Only update if not currently panning to avoid conflicts
if !isPanning:
camera.position = origin.position + cameraOffset
# Update UI element positions relative to new camera position
controlPanel.position = camera.position + (s/(camera.zoom*2.0))
tutorial.position = controlPanel.position
editControls.position = camera.position - (s/(camera.zoom*2.0))
viewerArrows.position = editControls.position
pushUpdates.position.y = controlPanel.position.y
pushUpdates.position.x = editControls.position.x
## Reset camera position to center
func resetCameraPosition():
cameraOffset = Vector2.ZERO
isPanning = false
viewPanOffset = Vector2.ZERO
viewModePanning = false
updateCameraPosition()
Global.pushUpdate("Camera reset to center.")
#When the user speaks!
func onSpeak():
@@ -252,6 +400,14 @@ func swapMode():
editMode = !editMode
Global.pushUpdate("Toggled editing mode.")
# Reset camera when entering view mode
if !editMode:
resetCameraPosition()
else:
# Reset view mode panning when entering edit mode
viewPanOffset = Vector2.ZERO
viewModePanning = false
get_viewport().transparent_bg = !editMode
if Global.backgroundColor.a != 0.0:
get_viewport().transparent_bg = false
@@ -280,8 +436,17 @@ func add_image(path):
Global.spriteList.updateData()
# Sync avatar to NDI viewport
if ndi_avatar_viewport:
call_deferred("sync_avatar_viewport")
Global.pushUpdate("Added new sprite.")
## Sync avatar viewport with current sprite state
func sync_avatar_viewport():
if ndi_avatar_viewport:
ndi_avatar_viewport.sync_avatar()
#Opens File Dialog
func _on_add_button_pressed():
fileDialog.visible = true
@@ -299,6 +464,11 @@ func _on_load_button_pressed():
#LOAD AVATAR
func _on_load_dialog_file_selected(path):
# Handle case where path might be a Dictionary instead of string
if typeof(path) == TYPE_DICTIONARY:
push_error("Expected string path but got Dictionary: " + str(path))
return
var data = Saving.read_save(path)
if data == null:
@@ -310,8 +480,19 @@ func _on_load_dialog_file_selected(path):
origin = new
for item in data:
if typeof(data[item]) != TYPE_DICTIONARY:
continue
if not data[item].has("path"):
continue
var sprite = spriteObject.instantiate()
sprite.path = data[item]["path"]
# Load display name (with backward compatibility)
if data[item].has("displayName"):
sprite.displayName = data[item]["displayName"]
sprite.id = data[item]["identification"]
sprite.parentId = data[item]["parentId"]
@@ -342,9 +523,9 @@ func _on_load_dialog_file_selected(path):
if data[item].has("costumeLayers"):
sprite.costumeLayers = str_to_var(data[item]["costumeLayers"]).duplicate()
if sprite.costumeLayers.size() < 8:
for i in range(5):
sprite.costumeLayers.append(1)
# Ensure we have exactly 10 costume layers
while sprite.costumeLayers.size() < 10:
sprite.costumeLayers.append(1)
if data[item].has("stretchAmount"):
sprite.stretchAmount = data[item]["stretchAmount"]
@@ -362,6 +543,24 @@ func _on_load_dialog_file_selected(path):
sprite.clipped = data[item]["clipped"]
if data[item].has("toggle"):
sprite.toggle = data[item]["toggle"]
if data[item].has("spriteOpacity"):
sprite.spriteOpacity = data[item]["spriteOpacity"]
if data[item].has("affectChildrenOpacity"):
sprite.affectChildrenOpacity = data[item]["affectChildrenOpacity"]
if data[item].has("blendMode"):
sprite.blendMode = data[item]["blendMode"]
# Load wobble sync group (with backward compatibility)
if data[item].has("wobbleSyncGroup"):
sprite.wobbleSyncGroup = data[item]["wobbleSyncGroup"]
# Load transform properties (with backward compatibility)
if data[item].has("staticRotation"):
sprite.staticRotation = data[item]["staticRotation"]
if data[item].has("mirrorHorizontal"):
sprite.mirrorHorizontal = data[item]["mirrorHorizontal"]
if data[item].has("mirrorVertical"):
sprite.mirrorVertical = data[item]["mirrorVertical"]
origin.add_child(sprite)
sprite.position = str_to_var(data[item]["pos"])
@@ -370,10 +569,42 @@ func _on_load_dialog_file_selected(path):
Saving.settings["lastAvatar"] = path
Global.spriteList.updateData()
# Load wobble sync groups
if data.has("wobbleSyncGroups") and WobbleSyncManager:
await WobbleSyncManager.loadSaveData(data["wobbleSyncGroups"])
# Refresh wobble sync UI after groups are loaded
if Global.spriteEdit and Global.spriteEdit.wobbleSyncControl:
Global.spriteEdit.wobbleSyncControl.updateUI()
# Force refresh in case timing issues
await get_tree().process_frame
Global.spriteEdit.wobbleSyncControl.forceRefresh()
# Force refresh in case timing issues
await get_tree().process_frame
Global.spriteEdit.wobbleSyncControl.forceRefresh()
# Update opacity shaders for all loaded sprites after wobble sync data is applied
await get_tree().process_frame # Wait for all sprites to be fully initialized
var allSprites = get_tree().get_nodes_in_group("saved")
for sprite in allSprites:
if sprite.has_method("updateOpacity"):
sprite.updateOpacity()
# Sync avatar to NDI viewport
if ndi_avatar_viewport:
ndi_avatar_viewport.sync_avatar()
Global.pushUpdate("Loaded avatar at: " + path)
onWindowSizeChange()
## Load the default avatar data
func loadDefaultAvatar():
_on_load_dialog_file_selected("default")
## Load avatar from a specific file path
func loadAvatar(path: String):
_on_load_dialog_file_selected(path)
#SAVE AVATAR
func _on_save_dialog_file_selected(path):
var data = {}
@@ -385,6 +616,7 @@ func _on_save_dialog_file_selected(path):
data[id] = {}
data[id]["type"] = "sprite"
data[id]["path"] = child.path
data[id]["displayName"] = child.displayName
data[id]["imageData"] = Marshalls.raw_to_base64(child.imageData.save_png_to_buffer())
data[id]["identification"] = child.id
data[id]["parentId"] = child.parentId
@@ -424,8 +656,24 @@ func _on_save_dialog_file_selected(path):
data[id]["toggle"] = child.toggle
data[id]["spriteOpacity"] = child.spriteOpacity
data[id]["affectChildrenOpacity"] = child.affectChildrenOpacity
data[id]["blendMode"] = child.blendMode
# Save wobble sync group
data[id]["wobbleSyncGroup"] = child.wobbleSyncGroup
# Save transform properties
data[id]["staticRotation"] = child.staticRotation
data[id]["mirrorHorizontal"] = child.mirrorHorizontal
data[id]["mirrorVertical"] = child.mirrorVertical
id += 1
# Save wobble sync groups
if WobbleSyncManager:
data["wobbleSyncGroups"] = WobbleSyncManager.getSaveData()
Saving.settings["lastAvatar"] = path
Saving.data = data.duplicate()
@@ -445,9 +693,9 @@ func _on_kofi_pressed():
Global.pushUpdate("Support me on ko-fi!")
func _on_twitter_pressed():
OS.shell_open("https://twitter.com/kaiakairos")
Global.pushUpdate("Follow me on twitter!")
func _on_bluesky_pressed():
OS.shell_open("https://bsky.app/profile/kaiakairos.net")
Global.pushUpdate("Follow me on bluesky!")
func _on_replace_button_pressed():
@@ -500,9 +748,25 @@ func _on_duplicate_button_pressed():
sprite.costumeLayers = Global.heldSprite.costumeLayers
sprite.spriteOpacity = Global.heldSprite.spriteOpacity
sprite.affectChildrenOpacity = Global.heldSprite.affectChildrenOpacity
sprite.blendMode = Global.heldSprite.blendMode
# Copy wobble sync group
sprite.wobbleSyncGroup = Global.heldSprite.wobbleSyncGroup
# Copy transform properties
sprite.staticRotation = Global.heldSprite.staticRotation
sprite.mirrorHorizontal = Global.heldSprite.mirrorHorizontal
sprite.mirrorVertical = Global.heldSprite.mirrorVertical
origin.add_child(sprite)
sprite.position = Global.heldSprite.position + Vector2(16,16)
# Update opacity shader for the duplicated sprite
await get_tree().process_frame # Wait for sprite to be fully initialized
sprite.updateOpacity()
Global.heldSprite = sprite
Global.spriteList.updateData()
@@ -522,9 +786,11 @@ func changeCostumeStreamDeck(id: String):
"9":changeCostume(9)
"10":changeCostume(10)
func changeCostume(newCostume):
func changeCostume(newCostume, preserve_selection: bool = false):
costume = newCostume
Global.heldSprite = null
# Only clear heldSprite for actual costume changes, not layer updates
if not preserve_selection:
Global.heldSprite = null
var nodes = get_tree().get_nodes_in_group("saved")
for sprite in nodes:
if sprite.costumeLayers[newCostume-1] == 1:
@@ -547,7 +813,7 @@ func moveSpriteMenu(delta):
var size = get_viewport().get_visible_rect().size
var windowLength = 1400
var windowLength = 2000
$ViewerArrows/Arrows.position.y = size.y - 25
@@ -575,9 +841,9 @@ func moveSpriteMenu(delta):
if $EditControls/MoveMenuUp.overlaps_area(Global.mouse.area):
Global.spriteEdit.position.y += (delta*432.0)
Global.spriteEdit.position.y += (delta * 400.0)
elif $EditControls/MoveMenuDown.overlaps_area(Global.mouse.area):
Global.spriteEdit.position.y -= (delta*432.0)
Global.spriteEdit.position.y -= (delta * 400.0)
if Global.spriteEdit.position.y > 66:
Global.spriteEdit.position.y = 66
@@ -627,47 +893,63 @@ func _on_background_input_capture_bg_key_pressed(node, keys_pressed):
if i == KEY_CTRL or i == KEY_SHIFT or i == KEY_ALT or i == KEY_META:
continue
var key_name = OS.get_keycode_string(i) if !OS.get_keycode_string(i).strip_edges().is_empty() else "Keycode" + str(i)
keyStrings.append(modifier_prefix + key_name)
# Skip mouse button keycodes (common source of phantom inputs)
if i == MOUSE_BUTTON_LEFT or i == MOUSE_BUTTON_RIGHT or i == MOUSE_BUTTON_MIDDLE:
continue
# Skip phantom keypad inputs if no actual keypad connected
var key_name = OS.get_keycode_string(i)
if key_name.begins_with("kp ") or key_name.begins_with("Kp "):
# Skip keypad inputs unless they're from actual key presses
continue
if !key_name.strip_edges().is_empty():
keyStrings.append(modifier_prefix + key_name)
else:
keyStrings.append(modifier_prefix + "Keycode" + str(i))
if fileSystemOpen:
return
if keyStrings.size() <= 0:
emit_signal("emptiedCapture")
emit_signal("fatfuckingballs")
return
if settingsMenu.awaitingCostumeInput >= 0:
if keyStrings[0] == "Keycode1":
if !settingsMenu.hasMouse:
emit_signal("pressedKey")
return
# Cancel key assignment if user clicks outside the settings menu
if keyStrings[0] == "Keycode1" and !settingsMenu.hasMouse:
# Don't assign the key, just complete the flow
emit_signal("pressedKey")
return
var currentButton = costumeKeys[settingsMenu.awaitingCostumeInput]
costumeKeys[settingsMenu.awaitingCostumeInput] = keyStrings[0]
Saving.settings["costumeKeys"] = costumeKeys
Global.pushUpdate("Changed costume " + str(settingsMenu.awaitingCostumeInput+1) + " hotkey from \"" + currentButton + "\" to \"" + keyStrings[0] + "\"")
emit_signal("pressedKey")
return
# Handle sprite visibility toggles if not waiting for costume input
spriteVisToggles.emit(keyStrings)
for key in keyStrings:
var i = costumeKeys.find(key)
if i >= 0:
changeCostume(i+1)
## Simple NDI control function
func toggle_ndi():
if ndi_output:
print("NDI Output is available - Source: ", ndi_output.name)
print("NDI should be streaming the main viewport")
else:
print("NDI Output not found")
func bgInputSprite(node, keys_pressed):
if fileSystemOpen:
return
var keyStrings = []
## Set NDI source name
func set_ndi_source_name(source_name: String):
if ndi_output:
ndi_output.name = source_name
print("NDI source name set to: ", source_name)
for i in keys_pressed:
if keys_pressed[i]:
keyStrings.append(OS.get_keycode_string(i) if !OS.get_keycode_string(i).strip_edges().is_empty() else "Keycode" + str(i))
if keyStrings.size() <= 0:
emit_signal("fatfuckingballs")
return
spriteVisToggles.emit(keyStrings)

View File

@@ -1,6 +1,7 @@
[gd_scene load_steps=80 format=4 uid="uid://bakgnwotiysu1"]
[gd_scene load_steps=81 format=4 uid="uid://bakgnwotiysu1"]
[ext_resource type="Script" uid="uid://c4g7pgn40uv4q" path="res://main_scenes/main.gd" id="1_06alx"]
[ext_resource type="Script" uid="uid://dkomsdn2pvjwp" path="res://main_scenes/NDIAvatarViewport.gd" id="1_avatar_viewport"]
[ext_resource type="Script" uid="uid://18kfbk72spvo" path="res://main_scenes/ControlPanel.gd" id="4_5ygs2"]
[ext_resource type="Texture2D" uid="uid://goff8ioh380e" path="res://ui_scenes/button sprites/buttonBack.png" id="4_xenui"]
[ext_resource type="Texture2D" uid="uid://da4jthat2oas4" path="res://ui_scenes/button sprites/fancy.png" id="5_75a7k"]
@@ -14341,7 +14342,7 @@ offset_bottom = 27.0
theme = SubResource("Theme_rv56a")
flat = true
[node name="twitter" type="Button" parent="ControlPanel/Links"]
[node name="bluesky" type="Button" parent="ControlPanel/Links"]
offset_left = -5.0
offset_top = -29.0
offset_right = 61.0
@@ -14357,37 +14358,70 @@ offset_bottom = -51.0
text = "Zoom : 100%"
horizontal_alignment = 2
[node name="VersionLabels" type="Node2D" parent="ControlPanel"]
[node name="VersionLabels" type="Control" parent="ControlPanel"]
layout_mode = 3
anchors_preset = 2
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -870.0
offset_top = -107.0
offset_right = -610.0
offset_bottom = -7.0
grow_vertical = 0
[node name="Label" type="Label" parent="ControlPanel/VersionLabels"]
offset_left = -713.0
offset_top = -94.0
offset_right = -575.0
offset_bottom = -60.0
[node name="VBoxContainer" type="VBoxContainer" parent="ControlPanel/VersionLabels"]
layout_mode = 1
anchors_preset = 2
anchor_top = 1.0
anchor_bottom = 1.0
offset_top = -100.0
offset_right = 138.0
offset_bottom = 1.0
grow_vertical = 0
[node name="Label" type="Label" parent="ControlPanel/VersionLabels/VBoxContainer"]
layout_mode = 2
text = "PNGTuber Plus"
label_settings = SubResource("LabelSettings_w0f1f")
[node name="Label2" type="Label" parent="ControlPanel/VersionLabels"]
offset_left = -713.0
offset_top = -123.0
offset_right = -575.0
offset_bottom = -89.0
[node name="Label2" type="Label" parent="ControlPanel/VersionLabels/VBoxContainer"]
layout_mode = 2
text = "kaiakairos'"
label_settings = SubResource("LabelSettings_qg0do")
[node name="versionNo" type="Label" parent="ControlPanel/VersionLabels/Label2"]
[node name="versionNo" type="Label" parent="ControlPanel/VersionLabels/VBoxContainer/Label2"]
layout_mode = 0
offset_left = 144.0
offset_top = 40.0
offset_right = 282.0
offset_bottom = 74.0
offset_left = 104.0
offset_top = 11.0
offset_right = 242.0
offset_bottom = 54.0
text = "v1.4.5"
label_settings = SubResource("LabelSettings_xvf50")
[node name="Label3" type="Label" parent="ControlPanel/VersionLabels/VBoxContainer"]
layout_mode = 2
text = "Litruv Edition"
label_settings = SubResource("LabelSettings_qg0do")
[node name="EditControls" type="Node2D" parent="."]
visible = false
z_index = 4090
script = ExtResource("17_bf1ic")
[node name="Container" type="Container" parent="EditControls"]
clip_contents = true
anchors_preset = 9
anchor_bottom = 1.0
offset_top = 70.0
offset_right = 273.485
offset_bottom = 20070.0
grow_vertical = 2
size_flags_vertical = 3
[node name="SpriteViewer" parent="EditControls/Container" instance=ExtResource("9_uqv8p")]
z_index = 4094
position = Vector2(9, 5)
[node name="Add" type="Sprite2D" parent="EditControls"]
position = Vector2(104, 32)
texture = ExtResource("4_xenui")
@@ -14549,11 +14583,6 @@ position = Vector2(2132, 928.5)
shape = SubResource("RectangleShape2D_ebmai")
disabled = true
[node name="SpriteViewer" parent="EditControls" instance=ExtResource("9_uqv8p")]
visible = false
z_index = 4094
position = Vector2(9, 66)
[node name="MoveMenuUp" type="Area2D" parent="EditControls"]
collision_layer = 0
collision_mask = 2048
@@ -14610,7 +14639,6 @@ patch_margin_right = 8
patch_margin_bottom = 8
[node name="Label" type="Label" parent="Tutorial/NinePatchRect"]
visible = false
layout_mode = 0
offset_left = 15.0
offset_top = 14.0
@@ -14726,11 +14754,29 @@ position = Vector2(0, 720)
modulate = Color(0, 0, 0, 0.478431)
z_index = -4096
[node name="NDIOutput" type="NDIOutput" parent="."]
name = "PNGTuber-Plus"
[node name="NDIAvatarViewport" type="SubViewport" parent="."]
handle_input_locally = false
size = Vector2i(1280, 720)
render_target_update_mode = 4
script = ExtResource("1_avatar_viewport")
[node name="NDIOutput" type="NDIOutput" parent="NDIAvatarViewport"]
name = "PNGTuber Avatar"
enable_editor_output = true
[node name="OriginMotion" type="Node2D" parent="NDIAvatarViewport"]
position = Vector2(640, 360)
[node name="Origin" type="Node2D" parent="NDIAvatarViewport/OriginMotion"]
[connection signal="pressed" from="ControlPanel/MicButtong/Button" to="." method="_on_button_pressed"]
[connection signal="pressed" from="ControlPanel/SettingsButton/settingsButtons" to="." method="_on_settings_buttons_pressed"]
[connection signal="pressed" from="ControlPanel/Edit/Button" to="." method="swapMode"]
[connection signal="pressed" from="ControlPanel/Links/kofi" to="." method="_on_kofi_pressed"]
[connection signal="pressed" from="ControlPanel/Links/twitter" to="." method="_on_twitter_pressed"]
[connection signal="pressed" from="ControlPanel/Links/bluesky" to="." method="_on_bluesky_pressed"]
[connection signal="pressed" from="EditControls/Add/addButton" to="." method="_on_add_button_pressed"]
[connection signal="pressed" from="EditControls/Link/linkButton" to="." method="_on_link_button_pressed"]
[connection signal="pressed" from="EditControls/Exit/Button2" to="." method="swapMode"]
@@ -14744,5 +14790,4 @@ z_index = -4096
[connection signal="visibility_changed" from="ReplaceDialog" to="." method="_on_replace_dialog_visibility_changed"]
[connection signal="file_selected" from="SaveDialog" to="." method="_on_save_dialog_file_selected"]
[connection signal="file_selected" from="LoadDialog" to="." method="_on_load_dialog_file_selected"]
[connection signal="bg_key_pressed" from="BackgroundInputCapture" to="." method="bgInputSprite"]
[connection signal="bg_key_pressed" from="BackgroundInputCapture" to="." method="_on_background_input_capture_bg_key_pressed"]

View File

@@ -23,11 +23,14 @@ config/icon="res://icon.png"
[audio]
driver/enable_input=true
buses/channel_disable_time=0.0
[autoload]
Saving="*res://autoload/saving.gd"
Global="*res://autoload/global.gd"
WobbleSyncManager="*res://autoload/wobble_sync_manager.gd"
BlendModeManager="*res://autoload/blend_mode_manager.gd"
ElgatoStreamDeck="*res://addons/godot-streamdeck-addon/singleton.gd"
DefaultAvatarData="*res://autoload/defaultAvatarData.gd"
@@ -47,11 +50,6 @@ window/vsync/vsync_mode=0
project/assembly_name="PNGTuberPlus"
[editor]
version_control/plugin_name="GitPlugin"
version_control/autoload_on_startup=true
[editor_plugins]
enabled=PackedStringArray("res://addons/godot-streamdeck-addon/plugin.cfg")
@@ -135,6 +133,11 @@ control={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194326,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
mouse_middle={
"deadzone": 0.5,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":3,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
saveImages={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":76,"key_label":0,"unicode":108,"location":0,"echo":false,"script":null)
@@ -145,6 +148,6 @@ saveImages={
textures/canvas_textures/default_texture_filter=0
textures/canvas_textures/default_texture_repeat=1
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
renderer/rendering_method="forward_plus"
renderer/rendering_method.mobile="forward_plus"
environment/defaults/default_clear_color=Color(0.376471, 0.376471, 0.376471, 1)

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,34 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://41u8egcau00i"
path="res://.godot/imported/checkmark.png-7bf604eba3017c173b17bad2b166951a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://ui_scenes/button sprites/checkmark.png"
dest_files=["res://.godot/imported/checkmark.png-7bf604eba3017c173b17bad2b166951a.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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,105 @@
# Implementation Complete: Sprite Viewer Slider Components
## ✅ **Successfully Implemented**
### **🎯 Components Created**
- **LabeledSlider** - Base reusable slider component
- **WobbleSlider** - Specialized for wobble parameters with sync support
- **ParameterSlider** - Generic parameter binding for sprite properties
- **SliderManager** - Centralized management system
### **🔄 Replaced in sprite_viewer.tscn**
| **Old Slider** | **New Component** | **Benefits** |
|----------------|-------------------|--------------|
| Drag Slider | ParameterSlider | Auto label updates, consistent formatting |
| X Frequency | WobbleSlider | Sync group support, auto parameter binding |
| X Amplitude | WobbleSlider | Consistent wobble handling |
| Y Frequency | WobbleSlider | Integrated with wobble sync system |
| Y Amplitude | WobbleSlider | Visual feedback for sync status |
| Rotation Frequency | WobbleSlider | Proper Hz display formatting |
| Rotation Amplitude | WobbleSlider | Percentage display with % suffix |
| Rotation Drag | ParameterSlider | Simplified parameter binding |
| Squash | ParameterSlider | Decimal precision formatting |
| Rotation Limit Min | ParameterSlider | Degree (°) suffix display |
| Rotation Limit Max | ParameterSlider | Consistent limit handling |
| Animation Frames | ParameterSlider | Integer-only values |
| Animation Speed | ParameterSlider | Decimal speed values |
| Opacity | ParameterSlider | Custom percentage display |
### **🎨 Key Features Implemented**
**Automatic Label Updates** - No more manual label text management
**Consistent Formatting** - All sliders use the same styling
**Parameter Binding** - Direct sprite property connections
**Wobble Sync Integration** - Visual feedback and group handling
**Type Safety** - Proper class inheritance
**Value Formatting** - Decimal places, suffixes (Hz, %, °)
**Signal Management** - Components handle their own connections
**Backwards Compatibility** - Old functions cleaned up
### **🔧 Technical Implementation**
#### **Sprite Viewer Updates:**
- Added SliderManager for centralized control
- Implemented `_setup_slider_components()`
- Updated `setImage()` to use component system
- Modified `updateWobbleControlStates()` for visual sync feedback
- Removed redundant old signal handlers
- Added new component signal connections
#### **Scene File Changes:**
- Added external resource references for new components
- Replaced 14 individual slider+label combinations
- Configured each component with appropriate parameters
- Removed outdated signal connections
- Optimized layout spacing
#### **Component Features:**
- **LabeledSlider**: Base functionality with export properties
- **WobbleSlider**: Inherits from LabeledSlider, adds wobble-specific logic
- **ParameterSlider**: Generic sprite property binding with special cases
- **SliderManager**: Bulk operations and consistent styling
### **🎯 Benefits Achieved**
1. **Consistency** - All sliders look and behave identically
2. **Maintainability** - One place to modify slider behavior
3. **Reusability** - Components work across different scenes
4. **Reduced Code** - Less repetitive slider setup and management
5. **Better UX** - Automatic updates, proper formatting, visual feedback
6. **Type Safety** - Class-based components with proper inheritance
### **📝 Usage Example**
**Before (Old System):**
```gdscript
$WobbleControl/xFrqLabel.text = "x frequency: " + str(value)
$WobbleControl/xFrq.value = value
# Repeat for every slider...
```
**After (New Components):**
```gdscript
slider_manager.update_all_sliders(Global.heldSprite)
# All sliders update automatically with proper formatting
```
### **🔧 Migration Notes**
- All old individual sliders have been replaced
- Legacy signal handlers removed
- Component system handles parameter updates automatically
- Visual sync feedback integrated into wobble sliders
- Opacity slider has custom percentage display logic
### **🚀 Ready to Use**
The sprite viewer now uses a modern, component-based slider system that is:
- More maintainable
- More consistent
- Easier to extend
- Better organized
- Type-safe
All sliders in the sprite viewer are now implemented as reusable components! 🎉

View File

@@ -0,0 +1,73 @@
# 🔧 Fixed: Slider Labels Now Show Correct Text
## ✅ **Problem Solved**
The issue was that slider labels were showing "Value" instead of the actual `label_text` property (like "X Frequency", "Drag", etc.) in the Godot editor.
## 🎯 **Root Cause**
1. **Editor Execution**: Components needed `@tool` to work properly in the editor
2. **Initialization Timing**: Label updates happened before exported properties were set
3. **Property Setter Timing**: Label wasn't updated when properties were set by the editor
## 🔧 **Solution Applied**
### **1. Added `@tool` Directive**
Added `@tool` to all component scripts to enable editor execution:
- `LabeledSlider.gd`
- `WobbleSlider.gd`
- `ParameterSlider.gd`
### **2. Improved Property Setters**
Updated all export property setters to check if the node is ready:
```gdscript
func set_label_text(new_text: String):
label_text = new_text
if is_inside_tree():
_update_label()
else:
call_deferred("_update_label")
```
### **3. Enhanced Initialization**
- Added `NOTIFICATION_SCENE_INSTANTIATED` handler
- Improved `_ready()` function timing
- Added deferred label updates for proper timing
### **4. Fixed Base Template**
Updated `LabeledSlider.tscn` to have cleaner default text:
```
text = "Value" # Instead of "Value: 0"
```
### **5. Robust Label Updates**
Enhanced `_update_label()` to handle missing nodes gracefully:
```gdscript
func _update_label():
if not label:
label = get_node_or_null("Label")
if not label:
call_deferred("_update_label")
return
# ... update logic
```
## 🎉 **Result**
Now in the Godot editor, all slider labels should properly display:
- ✅ "X Frequency: 0.000 Hz"
- ✅ "Y Amplitude: 0"
- ✅ "Drag: 0.0"
- ✅ "Opacity: 100%"
- ✅ "Rotational Limit Min: -180°"
Instead of just "Value" everywhere!
## 🔄 **How to Test**
1. Open `sprite_viewer.tscn` in the Godot editor
2. Check the slider components in the scene tree
3. Labels should now show the correct text for each parameter
4. Properties can be modified in the inspector and labels update immediately
The components now work properly both in the editor preview and at runtime! 🚀

View File

@@ -0,0 +1,140 @@
@tool
## A reusable component that combines a label and slider
## Provides consistent formatting and behavior for all sliders in the UI
extends VBoxContainer
class_name LabeledSlider
## Emitted when the slider value changes
signal value_changed(value: float)
## The base text that appears in the label (without the value)
@export var label_text: String = "Value" : set = set_label_text
## The minimum value of the slider
@export var min_value: float = 0.0 : set = set_min_value
## The maximum value of the slider
@export var max_value: float = 100.0 : set = set_max_value
## The step value for the slider
@export var step: float = 1.0 : set = set_step
## The current value of the slider
@export var value: float = 0.0 : set = set_value
## Whether to show the value in the label
@export var show_value_in_label: bool = true : set = set_show_value_in_label
## Format string for the value display (e.g., "%.1f" for one decimal place)
@export var value_format: String = "%.0f" : set = set_value_format
## Optional suffix to add after the value (e.g., "%", "Hz")
@export var value_suffix: String = "" : set = set_value_suffix
## Whether the slider is editable
@export var editable: bool = true : set = set_editable
@onready var label: Label = $Label
@onready var slider: HSlider = $HSlider
func _ready():
if slider:
slider.value_changed.connect(_on_slider_value_changed)
_update_slider_properties()
# Ensure label is updated after all properties are set
call_deferred("_update_label")
func _notification(what):
if what == NOTIFICATION_SCENE_INSTANTIATED:
# Update label when scene is instantiated (important for editor)
call_deferred("_update_label")
## Set the label text
func set_label_text(new_text: String):
label_text = new_text
if is_inside_tree():
_update_label()
else:
call_deferred("_update_label")
## Set the minimum value
func set_min_value(new_min: float):
min_value = new_min
if is_inside_tree():
_update_slider_properties()
## Set the maximum value
func set_max_value(new_max: float):
max_value = new_max
if is_inside_tree():
_update_slider_properties()
## Set the step value
func set_step(new_step: float):
step = new_step
if is_inside_tree():
_update_slider_properties()
## Set the current value
func set_value(new_value: float):
value = new_value
if slider:
slider.value = value
if is_inside_tree():
_update_label()
## Set whether to show value in label
func set_show_value_in_label(show: bool):
show_value_in_label = show
if is_inside_tree():
_update_label()
## Set the value format string
func set_value_format(format: String):
value_format = format
if is_inside_tree():
_update_label()
## Set the value suffix
func set_value_suffix(suffix: String):
value_suffix = suffix
if is_inside_tree():
_update_label()
## Set whether the slider is editable
func set_editable(is_editable: bool):
editable = is_editable
if slider:
slider.editable = editable
## Update the slider properties
func _update_slider_properties():
if not slider:
return
slider.min_value = min_value
slider.max_value = max_value
slider.step = step
slider.value = value
slider.editable = editable
## Update the label text
func _update_label():
if not label:
# If label isn't ready yet, try to find it
label = get_node_or_null("Label")
if not label:
# Call again after a frame when nodes are ready
call_deferred("_update_label")
return
var text = label_text
if show_value_in_label:
var formatted_value = value_format % value
text += ": " + formatted_value + value_suffix
label.text = text
## Handle slider value changes
func _on_slider_value_changed(new_value: float):
value = new_value
_update_label()
value_changed.emit(value)
## Set the modulate color for visual feedback
func set_modulate_color(color: Color):
if slider:
slider.modulate = color

View File

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

View File

@@ -0,0 +1,22 @@
[gd_scene load_steps=2 format=3 uid="uid://bxm5q6n8x7p2q"]
[ext_resource type="Script" uid="uid://combq5rrok31i" path="res://ui_scenes/components/LabeledSlider.gd" id="1_labeled_slider"]
[node name="LabeledSlider" type="VBoxContainer"]
offset_right = 200.0
offset_bottom = 40.0
size_flags_horizontal = 3
size_flags_vertical = 0
theme_override_constants/separation = 2
script = ExtResource("1_labeled_slider")
[node name="Label" type="Label" parent="."]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 0
text = "Value: 0"
vertical_alignment = 1
[node name="HSlider" type="HSlider" parent="."]
layout_mode = 2
size_flags_horizontal = 3

View File

@@ -0,0 +1,81 @@
@tool
## A parameter slider component for general sprite parameters
## Handles updating sprite properties with proper formatting
extends LabeledSlider
class_name ParameterSlider
## The sprite parameter name this slider controls
@export var parameter_name: String = ""
## The property path in the sprite object (e.g., "dragSpeed", "rdragStr")
@export var sprite_property: String = ""
## Reference to the sprite being edited
var target_sprite = null
func _ready():
super._ready()
# Connect to value changes to update parameters
value_changed.connect(_on_parameter_value_changed)
func _notification(what):
super._notification(what)
if what == NOTIFICATION_SCENE_INSTANTIATED:
# Update label for opacity specially
if sprite_property == "spriteOpacity":
call_deferred("_update_opacity_label")
## Set the sprite this slider controls
func set_target_sprite(sprite):
target_sprite = sprite
if sprite:
_update_from_sprite()
## Update the slider from the sprite's current values
func _update_from_sprite():
if not target_sprite or sprite_property == "":
return
var current_value = target_sprite.get(sprite_property)
if current_value != null:
set_value(current_value)
# Special handling for opacity display
if sprite_property == "spriteOpacity":
_update_opacity_label()
## Update label text with special opacity handling
func _update_opacity_label():
if sprite_property == "spriteOpacity" and label:
var percentage = int(value * 100)
label.text = label_text + ": " + str(percentage) + "%"
## Override base update_label for special cases
func _update_label():
if sprite_property == "spriteOpacity":
_update_opacity_label()
else:
super._update_label()
## Handle parameter changes
func _on_parameter_value_changed(new_value: float):
if not target_sprite or sprite_property == "":
return
target_sprite.set(sprite_property, new_value)
# Call specific update methods if they exist
_call_update_method()
## Call specific update methods for certain parameters
func _call_update_method():
if not target_sprite:
return
match sprite_property:
"spriteOpacity":
target_sprite.updateOpacity()
"frames":
target_sprite.changeFrames()
"blendMode":
target_sprite.updateBlendMode(value)

View File

@@ -0,0 +1 @@
uid://6maee7dhl7vl

View File

@@ -0,0 +1,9 @@
[gd_scene load_steps=3 format=3 uid="uid://fgee1pq0yqav"]
[ext_resource type="PackedScene" uid="uid://bxm5q6n8x7p2q" path="res://ui_scenes/components/LabeledSlider.tscn" id="1_base_slider"]
[ext_resource type="Script" uid="uid://6maee7dhl7vl" path="res://ui_scenes/components/ParameterSlider.gd" id="2_parameter_script"]
[node name="ParameterSlider" instance=ExtResource("1_base_slider")]
script = ExtResource("2_parameter_script")
parameter_name = ""
sprite_property = ""

View File

@@ -0,0 +1,220 @@
# Slider Components Documentation
This document explains the new slider component system that replaces the individual sliders in the sprite viewer with reusable, consistent components.
## Components Overview
### 1. LabeledSlider (Base Component)
**File:** `ui_scenes/components/LabeledSlider.tscn`
**Script:** `ui_scenes/components/LabeledSlider.gd`
The base slider component that combines a label and slider into one reusable unit.
**Key Features:**
- Automatic label updates with current value
- Configurable value formatting
- Optional value suffix (%, Hz, etc.)
- Consistent styling
- Signal emission on value changes
**Properties:**
- `label_text`: Base text for the label
- `min_value`, `max_value`, `step`: Slider configuration
- `value`: Current value
- `show_value_in_label`: Whether to display value in label
- `value_format`: Format string for value display (e.g., "%.1f")
- `value_suffix`: Suffix to add after value (e.g., "%", "Hz")
- `editable`: Whether the slider can be modified
### 2. WobbleSlider (Specialized Component)
**File:** `ui_scenes/components/WobbleSlider.tscn`
**Script:** `ui_scenes/components/WobbleSlider.gd`
Extends LabeledSlider specifically for wobble parameters.
**Key Features:**
- Automatic synchronization with sprite wobble parameters
- Visual feedback for sync group status
- Integrated with wobble sync system
- Parameter-specific configuration
**Properties:**
- `parameter_name`: The wobble parameter this controls ("xFrq", "xAmp", etc.)
- All LabeledSlider properties
### 3. ParameterSlider (Generic Component)
**File:** `ui_scenes/components/ParameterSlider.tscn`
**Script:** `ui_scenes/components/ParameterSlider.gd`
Extends LabeledSlider for general sprite parameters.
**Key Features:**
- Generic parameter binding to sprite properties
- Automatic update method calling for specific parameters
- Property path configuration
**Properties:**
- `sprite_property`: The sprite property to bind to
- `parameter_name`: Display name for the parameter
- All LabeledSlider properties
### 4. WobbleControlPanel (Composite Component)
**File:** `ui_scenes/components/WobbleControlPanel.tscn`
**Script:** `ui_scenes/components/WobbleControlPanel.gd`
A complete panel containing all wobble sliders.
**Key Features:**
- Pre-configured wobble sliders
- Centralized sprite management
- Integrated sync feedback
- Easy integration with existing wobble sync controls
### 5. SliderManager (Management System)
**Script:** `ui_scenes/components/SliderManager.gd`
Manages collections of slider components.
**Key Features:**
- Centralized slider updates
- Consistent styling application
- Bulk operations on sliders
- Integration with sprite viewer
## Usage Examples
### Basic LabeledSlider
```gdscript
# In your scene
@onready var my_slider: LabeledSlider = $LabeledSlider
func _ready():
my_slider.label_text = "Volume"
my_slider.min_value = 0.0
my_slider.max_value = 100.0
my_slider.value_suffix = "%"
my_slider.value_changed.connect(_on_volume_changed)
func _on_volume_changed(value: float):
print("Volume changed to: ", value)
```
### WobbleSlider Configuration
```gdscript
# In scene setup
@onready var x_freq_slider: WobbleSlider = $XFrequencySlider
func _ready():
x_freq_slider.label_text = "X Frequency"
x_freq_slider.parameter_name = "xFrq"
x_freq_slider.min_value = 0.0
x_freq_slider.max_value = 10.0
x_freq_slider.step = 0.1
x_freq_slider.value_format = "%.1f"
x_freq_slider.value_suffix = " Hz"
func set_sprite(sprite):
x_freq_slider.set_target_sprite(sprite)
```
### Using WobbleControlPanel
```gdscript
# Replace individual wobble sliders with the panel
@onready var wobble_panel: WobbleControlPanel = $WobbleControlPanel
func setImage():
if Global.heldSprite:
wobble_panel.set_target_sprite(Global.heldSprite)
wobble_panel.update_from_sprite()
```
### SliderManager Integration
```gdscript
# In sprite viewer
var slider_manager: SliderManager
func _ready():
slider_manager = SliderManager.new(self)
_setup_sliders()
func _setup_sliders():
# Add sliders to manager
slider_manager.add_slider($DragSlider)
slider_manager.add_slider($OpacitySlider)
# etc.
func setImage():
if slider_manager:
slider_manager.update_all_sliders(Global.heldSprite)
```
## Migration from Legacy Sliders
### Step 1: Replace Scene Nodes
Replace individual HSlider + Label combinations with LabeledSlider instances:
**Before:**
```
- Node2D
- Label (for "x frequency: 5.0")
- HSlider (for value input)
```
**After:**
```
- Node2D
- LabeledSlider (handles both label and slider)
```
### Step 2: Update Script References
**Before:**
```gdscript
$WobbleControl/xFrqLabel.text = "x frequency: " + str(value)
$WobbleControl/xFrq.value = value
```
**After:**
```gdscript
$WobbleControl/XFrequencySlider.set_value(value)
# Label updates automatically
```
### Step 3: Connect Signals
**Before:**
```gdscript
func _on_x_frq_value_changed(value):
Global.heldSprite.xFrq = value
```
**After:**
```gdscript
# Handled automatically by WobbleSlider component
# Or connect to the component's value_changed signal
```
## Benefits
1. **Consistency**: All sliders look and behave the same way
2. **Maintainability**: Changes to slider behavior only need to be made in one place
3. **Reusability**: Components can be used across different parts of the application
4. **Type Safety**: Proper class inheritance with specific functionality
5. **Reduced Code**: Less repetitive slider setup code
6. **Better Organization**: Clear separation of concerns
## Integration Notes
- The new system is designed to be backwards compatible
- Legacy slider code is maintained as fallback
- Components can be gradually adopted
- SliderManager provides centralized control
- Easy to extend for new parameter types
## Custom Styling
To apply consistent styling across all sliders, modify the base LabeledSlider component or use the SliderManager's styling methods:
```gdscript
func apply_custom_theme():
for slider in slider_manager.sliders:
slider.slider.add_theme_stylebox_override("slider", custom_style)
```

View File

@@ -0,0 +1,47 @@
[gd_scene load_steps=4 format=4 uid="uid://bh7n2k1q5m8ya"]
[ext_resource type="PackedScene" uid="uid://c8q7x2n9p4r5s" path="res://ui_scenes/components/WobbleSlider.tscn" id="1_wobble_x_frq"]
[ext_resource type="PackedScene" uid="uid://c8q7x2n9p4r5s" path="res://ui_scenes/components/WobbleSlider.tscn" id="2_wobble_x_amp"]
[ext_resource type="PackedScene" uid="uid://d9t8y3m0k6l1n" path="res://ui_scenes/components/ParameterSlider.tscn" id="3_drag_slider"]
[node name="SampleWobbleControl" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_right = -1680.0
offset_bottom = -1080.0
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
[node name="XFrequencySlider" parent="VBoxContainer" instance=ExtResource("1_wobble_x_frq")]
layout_mode = 2
label_text = "X Frequency"
min_value = 0.0
max_value = 10.0
step = 0.1
value_format = "%.1f"
value_suffix = " Hz"
parameter_name = "xFrq"
[node name="XAmplitudeSlider" parent="VBoxContainer" instance=ExtResource("2_wobble_x_amp")]
layout_mode = 2
label_text = "X Amplitude"
min_value = 0.0
max_value = 100.0
step = 1.0
value_format = "%.0f"
parameter_name = "xAmp"
[node name="DragSlider" parent="VBoxContainer" instance=ExtResource("3_drag_slider")]
layout_mode = 2
label_text = "Drag"
min_value = 0.0
max_value = 10.0
step = 0.1
value_format = "%.1f"
sprite_property = "dragSpeed"

View File

View File

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

View File

@@ -0,0 +1,60 @@
## Manages all slider components in the sprite viewer
## Provides a centralized way to update and configure sliders
extends RefCounted
class_name SliderManager
## Array of all slider components
var sliders: Array[LabeledSlider] = []
## Reference to the sprite viewer
var sprite_viewer = null
func _init(viewer):
sprite_viewer = viewer
## Add a slider to be managed
func add_slider(slider: LabeledSlider):
if slider not in sliders:
sliders.append(slider)
## Remove a slider from management
func remove_slider(slider: LabeledSlider):
sliders.erase(slider)
## Update all sliders with the current sprite data
func update_all_sliders(sprite):
for slider in sliders:
if slider is WobbleSlider:
slider.set_target_sprite(sprite)
elif slider is ParameterSlider:
slider.set_target_sprite(sprite)
## Update wobble control states based on sync status
func update_wobble_control_states(sprite):
if not sprite:
return
var is_synced = sprite.isSynced()
for slider in sliders:
if slider is WobbleSlider:
# Keep wobble controls enabled even when synced (they edit group settings)
slider.set_editable(true)
# Visual feedback for sync state - slight tint to indicate group editing
var tint = Color(1.0, 1.0, 0.9) if is_synced else Color.WHITE
slider.set_modulate_color(tint)
## Get a slider by its parameter name
func get_slider_by_parameter(parameter_name: String) -> LabeledSlider:
for slider in sliders:
if slider is WobbleSlider and slider.parameter_name == parameter_name:
return slider
elif slider is ParameterSlider and slider.sprite_property == parameter_name:
return slider
return null
## Apply consistent styling to all sliders
func apply_consistent_styling():
for slider in sliders:
# Apply any consistent styling here
pass

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
## A complete wobble control panel using the new slider components
## Provides a consistent interface for all wobble parameters
extends Control
class_name WobbleControlPanel
## Reference to all wobble sliders
@onready var x_frequency_slider: WobbleSlider = $VBoxContainer/XFrequencySlider
@onready var x_amplitude_slider: WobbleSlider = $VBoxContainer/XAmplitudeSlider
@onready var y_frequency_slider: WobbleSlider = $VBoxContainer/YFrequencySlider
@onready var y_amplitude_slider: WobbleSlider = $VBoxContainer/YAmplitudeSlider
@onready var r_frequency_slider: WobbleSlider = $VBoxContainer/RFrequencySlider
@onready var r_amplitude_slider: WobbleSlider = $VBoxContainer/RAmplitudeSlider
## Array of all wobble sliders for easy iteration
var wobble_sliders: Array[WobbleSlider] = []
## The sprite currently being edited
var target_sprite = null
func _ready():
# Collect all wobble sliders
wobble_sliders = [
x_frequency_slider,
x_amplitude_slider,
y_frequency_slider,
y_amplitude_slider,
r_frequency_slider,
r_amplitude_slider
]
# Connect to wobble sync control if it exists
var wobble_sync_control = find_child("WobbleSyncControl")
if wobble_sync_control:
_connect_wobble_sync_signals(wobble_sync_control)
## Set the target sprite for all sliders
func set_target_sprite(sprite):
target_sprite = sprite
for slider in wobble_sliders:
slider.set_target_sprite(sprite)
## Update all sliders from the current sprite
func update_from_sprite():
if target_sprite:
for slider in wobble_sliders:
slider._update_from_sprite()
## Update visual feedback for sync status
func update_sync_feedback():
for slider in wobble_sliders:
slider._update_sync_feedback()
## Connect signals from wobble sync control
func _connect_wobble_sync_signals(wobble_sync_control):
# Connect value change signals to update wobble sync control
for slider in wobble_sliders:
slider.value_changed.connect(_on_wobble_parameter_changed)
## Handle wobble parameter changes to update sync control
func _on_wobble_parameter_changed(value: float):
var wobble_sync_control = find_child("WobbleSyncControl")
if wobble_sync_control and wobble_sync_control.has_method("updateUI"):
wobble_sync_control.updateUI()

View File

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

View File

@@ -0,0 +1,75 @@
[gd_scene load_steps=8 format=4 uid="uid://bp2q8r4l6lam3"]
[ext_resource type="Script" path="res://ui_scenes/components/WobbleControlPanel.gd" id="1_wobble_panel_script"]
[ext_resource type="PackedScene" uid="uid://c8q7x2n9p4r5s" path="res://ui_scenes/components/WobbleSlider.tscn" id="2_wobble_slider"]
[node name="WobbleControlPanel" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_wobble_panel_script")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
[node name="XFrequencySlider" parent="VBoxContainer" instance=ExtResource("2_wobble_slider")]
layout_mode = 2
label_text = "X Frequency"
min_value = 0.0
max_value = 10.0
step = 0.1
value_format = "%.1f"
value_suffix = " Hz"
parameter_name = "xFrq"
[node name="XAmplitudeSlider" parent="VBoxContainer" instance=ExtResource("2_wobble_slider")]
layout_mode = 2
label_text = "X Amplitude"
min_value = 0.0
max_value = 100.0
step = 1.0
value_format = "%.0f"
parameter_name = "xAmp"
[node name="YFrequencySlider" parent="VBoxContainer" instance=ExtResource("2_wobble_slider")]
layout_mode = 2
label_text = "Y Frequency"
min_value = 0.0
max_value = 10.0
step = 0.1
value_format = "%.1f"
value_suffix = " Hz"
parameter_name = "yFrq"
[node name="YAmplitudeSlider" parent="VBoxContainer" instance=ExtResource("2_wobble_slider")]
layout_mode = 2
label_text = "Y Amplitude"
min_value = 0.0
max_value = 100.0
step = 1.0
value_format = "%.0f"
parameter_name = "yAmp"
[node name="RFrequencySlider" parent="VBoxContainer" instance=ExtResource("2_wobble_slider")]
layout_mode = 2
label_text = "Rotation Frequency"
min_value = 0.0
max_value = 10.0
step = 0.1
value_format = "%.1f"
value_suffix = " Hz"
parameter_name = "rFrq"
[node name="RAmplitudeSlider" parent="VBoxContainer" instance=ExtResource("2_wobble_slider")]
layout_mode = 2
label_text = "Rotation Amplitude"
min_value = 0.0
max_value = 100.0
step = 1.0
value_format = "%.0f"
value_suffix = "%"
parameter_name = "rAmp"

View File

@@ -0,0 +1,60 @@
@tool
## A specialized slider component for wobble parameters
## Automatically handles syncing with wobble groups and provides appropriate feedback
extends LabeledSlider
class_name WobbleSlider
## The wobble parameter name this slider controls
@export var parameter_name: String = ""
## Reference to the sprite being edited
var target_sprite = null
func _ready():
super._ready()
# Connect to value changes to update wobble parameters
value_changed.connect(_on_wobble_value_changed)
## Set the sprite this slider controls
func set_target_sprite(sprite):
target_sprite = sprite
if sprite:
_update_from_sprite()
## Update the slider from the sprite's current values
func _update_from_sprite():
if not target_sprite or parameter_name == "":
return
match parameter_name:
"xFrq":
set_value(target_sprite.xFrq)
"xAmp":
set_value(target_sprite.xAmp)
"yFrq":
set_value(target_sprite.yFrq)
"yAmp":
set_value(target_sprite.yAmp)
"rFrq":
set_value(target_sprite.rFrq)
"rAmp":
set_value(target_sprite.rAmp)
## Handle wobble parameter changes
func _on_wobble_value_changed(new_value: float):
if not target_sprite or parameter_name == "":
return
target_sprite.updateWobbleParameter(parameter_name, new_value)
# Update visual feedback for sync status
_update_sync_feedback()
## Update visual feedback based on sync status
func _update_sync_feedback():
if not target_sprite:
return
var is_synced = target_sprite.isSynced()
var tint = Color(1.0, 1.0, 0.9) if is_synced else Color.WHITE
set_modulate_color(tint)

View File

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

View File

@@ -0,0 +1,19 @@
[gd_scene load_steps=3 format=3 uid="uid://c8q7x2oap4r5s"]
[ext_resource type="PackedScene" uid="uid://bxm5q6n8x7p2q" path="res://ui_scenes/components/LabeledSlider.tscn" id="1_base_slider"]
[ext_resource type="Script" uid="uid://fsc2h7ajjcbs" path="res://ui_scenes/components/WobbleSlider.gd" id="2_wobble_script"]
[node name="WobbleSlider" instance=ExtResource("1_base_slider")]
script = ExtResource("2_wobble_script")
parameter_name = ""
[node name="Label" parent="." index="0"]
anchors_preset = 10
grow_horizontal = 2
text = "Value: 0"
horizontal_alignment = 0
[node name="HSlider" parent="." index="1"]
anchors_preset = 12
grow_horizontal = 0
grow_vertical = 0

View File

@@ -7,17 +7,11 @@ func _ready():
func _on_button_pressed():
if !get_parent().get_parent().get_parent().visible:
return
AudioServer.input_device = micName
Global.deleteAllMics()
Global.currentMicrophone = null
Global.createMicrophone()
get_parent().get_parent().get_parent().visible = false
await get_tree().create_timer(1.0).timeout
Global.createMicrophone()

View File

@@ -9,7 +9,7 @@ func _ready():
Global.mouse = self
func _process(delta):
if Global.main.editMode:
if Global.main and Global.main.editMode:
if text != "":
label.text = text
visible = true

View File

@@ -0,0 +1,8 @@
shader_type canvas_item;
uniform float opacity : hint_range(0.0, 1.0) = 1.0;
void fragment() {
COLOR = texture(TEXTURE, UV);
COLOR.a *= opacity;
}

View File

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

View File

@@ -0,0 +1,122 @@
shader_type canvas_item;
uniform float opacity : hint_range(0.0, 1.0) = 1.0;
uniform int blend_mode : hint_range(0, 11) = 0;
// Blend mode constants:
// 0: Normal
// 1: Multiply
// 2: Screen
// 3: Overlay
// 4: Soft Light
// 5: Hard Light
// 6: Color Dodge
// 7: Color Burn
// 8: Darken
// 9: Lighten
// 10: Add (Linear Dodge)
// 11: Subtract
vec3 multiply_blend(vec3 base, vec3 overlay) {
return base * overlay;
}
vec3 screen_blend(vec3 base, vec3 overlay) {
return 1.0 - (1.0 - base) * (1.0 - overlay);
}
vec3 overlay_blend(vec3 base, vec3 overlay) {
vec3 result;
result.r = base.r < 0.5 ? 2.0 * base.r * overlay.r : 1.0 - 2.0 * (1.0 - base.r) * (1.0 - overlay.r);
result.g = base.g < 0.5 ? 2.0 * base.g * overlay.g : 1.0 - 2.0 * (1.0 - base.g) * (1.0 - overlay.g);
result.b = base.b < 0.5 ? 2.0 * base.b * overlay.b : 1.0 - 2.0 * (1.0 - base.b) * (1.0 - overlay.b);
return result;
}
vec3 soft_light_blend(vec3 base, vec3 overlay) {
vec3 result;
result.r = overlay.r < 0.5 ? 2.0 * base.r * overlay.r + base.r * base.r * (1.0 - 2.0 * overlay.r) :
sqrt(base.r) * (2.0 * overlay.r - 1.0) + 2.0 * base.r * (1.0 - overlay.r);
result.g = overlay.g < 0.5 ? 2.0 * base.g * overlay.g + base.g * base.g * (1.0 - 2.0 * overlay.g) :
sqrt(base.g) * (2.0 * overlay.g - 1.0) + 2.0 * base.g * (1.0 - overlay.g);
result.b = overlay.b < 0.5 ? 2.0 * base.b * overlay.b + base.b * base.b * (1.0 - 2.0 * overlay.b) :
sqrt(base.b) * (2.0 * overlay.b - 1.0) + 2.0 * base.b * (1.0 - overlay.b);
return result;
}
vec3 hard_light_blend(vec3 base, vec3 overlay) {
vec3 result;
result.r = overlay.r < 0.5 ? 2.0 * base.r * overlay.r : 1.0 - 2.0 * (1.0 - base.r) * (1.0 - overlay.r);
result.g = overlay.g < 0.5 ? 2.0 * base.g * overlay.g : 1.0 - 2.0 * (1.0 - base.g) * (1.0 - overlay.g);
result.b = overlay.b < 0.5 ? 2.0 * base.b * overlay.b : 1.0 - 2.0 * (1.0 - base.b) * (1.0 - overlay.b);
return result;
}
vec3 color_dodge_blend(vec3 base, vec3 overlay) {
vec3 result;
result.r = overlay.r >= 1.0 ? 1.0 : min(1.0, base.r / (1.0 - overlay.r));
result.g = overlay.g >= 1.0 ? 1.0 : min(1.0, base.g / (1.0 - overlay.g));
result.b = overlay.b >= 1.0 ? 1.0 : min(1.0, base.b / (1.0 - overlay.b));
return result;
}
vec3 color_burn_blend(vec3 base, vec3 overlay) {
vec3 result;
result.r = overlay.r <= 0.0 ? 0.0 : max(0.0, 1.0 - (1.0 - base.r) / overlay.r);
result.g = overlay.g <= 0.0 ? 0.0 : max(0.0, 1.0 - (1.0 - base.g) / overlay.g);
result.b = overlay.b <= 0.0 ? 0.0 : max(0.0, 1.0 - (1.0 - base.b) / overlay.b);
return result;
}
vec3 darken_blend(vec3 base, vec3 overlay) {
return min(base, overlay);
}
vec3 lighten_blend(vec3 base, vec3 overlay) {
return max(base, overlay);
}
vec3 add_blend(vec3 base, vec3 overlay) {
return min(vec3(1.0), base + overlay);
}
vec3 subtract_blend(vec3 base, vec3 overlay) {
return max(vec3(0.0), base - overlay);
}
void fragment() {
vec4 tex_color = texture(TEXTURE, UV);
// Apply blend mode (only to RGB, preserve alpha channel behavior)
vec3 final_color;
if (blend_mode == 0) { // Normal
final_color = tex_color.rgb;
} else if (blend_mode == 1) { // Multiply
final_color = multiply_blend(vec3(1.0), tex_color.rgb);
} else if (blend_mode == 2) { // Screen
final_color = screen_blend(vec3(0.0), tex_color.rgb);
} else if (blend_mode == 3) { // Overlay
final_color = overlay_blend(vec3(0.5), tex_color.rgb);
} else if (blend_mode == 4) { // Soft Light
final_color = soft_light_blend(vec3(0.5), tex_color.rgb);
} else if (blend_mode == 5) { // Hard Light
final_color = hard_light_blend(vec3(0.5), tex_color.rgb);
} else if (blend_mode == 6) { // Color Dodge
final_color = color_dodge_blend(vec3(0.0), tex_color.rgb);
} else if (blend_mode == 7) { // Color Burn
final_color = color_burn_blend(vec3(1.0), tex_color.rgb);
} else if (blend_mode == 8) { // Darken
final_color = darken_blend(vec3(1.0), tex_color.rgb);
} else if (blend_mode == 9) { // Lighten
final_color = lighten_blend(vec3(0.0), tex_color.rgb);
} else if (blend_mode == 10) { // Add (Linear Dodge)
final_color = add_blend(vec3(0.0), tex_color.rgb);
} else if (blend_mode == 11) { // Subtract
final_color = subtract_blend(vec3(1.0), tex_color.rgb);
} else {
final_color = tex_color.rgb;
}
COLOR = vec4(final_color, tex_color.a * opacity);
}

View File

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

View File

@@ -6,6 +6,8 @@ var type = "sprite"
var imageData = null
var tex = null
@export var path = ""
## Custom display name for the sprite (separate from file path)
var displayName = ""
var loadedImageData = null
@@ -28,6 +30,9 @@ var imageSize = Vector2.ZERO
@onready var wob = $WobbleOrigin
@onready var outlineScene = preload("res://ui_scenes/selectedSprite/outline.tscn")
@onready var opacityBlendShader = preload("res://ui_scenes/selectedSprite/opacity_blend.gdshader")
var opacityMaterial = null
#Visuals
var mouseOffset = Vector2.ZERO
@@ -76,16 +81,30 @@ var ignoreBounce = false
#Animation
var frames = 1
var animSpeed = 0
var animTime = 0.0 # Time accumulator for animations
var remadePolygon = false
var clipped = false
var tick = 0
var time = 0.0 # Time accumulator for physics calculations
#Opacity
var spriteOpacity = 1.0
var affectChildrenOpacity = false
var blendMode = 0 # Default to Normal blend mode
#Vis toggle
var toggle = "null"
#Wobble Sync Group
var wobbleSyncGroup = ""
#Transform
var staticRotation = 0.0
var mirrorHorizontal = false
var mirrorVertical = false
func _ready():
Global.main.spriteVisToggles.connect(visToggle)
@@ -163,6 +182,8 @@ func _ready():
setClip(clipped)
# Always initialize opacity shader
updateShaderOpacity()
if Global.filtering:
sprite.texture_filter = 2
@@ -217,7 +238,7 @@ func replaceSprite(pathNew):
remakePolygon()
func _process(delta):
tick += 1
time += delta
if Global.heldSprite == self:
grabArea.visible = true
@@ -227,6 +248,16 @@ func _process(delta):
grabArea.visible = false
originSprite.visible = false
# Disable grabArea input and detection when dialogs are open
if Global.main and Global.main.fileSystemOpen:
grabArea.input_pickable = false
grabArea.monitoring = false
grabArea.monitorable = false
else:
grabArea.input_pickable = true
grabArea.monitoring = true
grabArea.monitorable = true
var glob = dragger.global_position
if ignoreBounce:
glob.y -= Global.main.bounceChange
@@ -238,19 +269,24 @@ func _process(delta):
rotationalDrag(length,delta)
stretch(length,delta)
applyStaticTransform()
if grabDelay > 0:
grabDelay -= 1
talkBlink()
animation()
animation(delta)
func animation():
var speed = max(float(animSpeed),Engine.max_fps*6.0)
func animation(delta):
if animSpeed > 0 and frames > 1:
if Global.animationTick % int((speed)/float(animSpeed)) == 0:
animTime += delta
# Convert animSpeed to frames per second - no additional scaling needed since this controls frame rate
var frameRate = animSpeed / 60.0 # Normalize from old frame-based system
var frameInterval = 1.0 / frameRate
if animTime >= frameInterval:
animTime -= frameInterval
if sprite.frame == frames - 1:
sprite.frame = 0
else:
@@ -262,12 +298,63 @@ func setZIndex():
sprite.z_index = z
func talkBlink():
var faded = 0.2 * int(Global.main.editMode)
var faded = 0.2 * int(Global.main and Global.main.editMode)
var value = (showOnTalk + (showOnBlink*3)) + (int(Global.speaking)*10) + (int(Global.blink)*20)
var yes = [0,10,20,30,1,21,12,32,3,13,4,15,26,36,27,38].has(int(value))
sprite.self_modulate.a = max(int(yes),faded)
var baseAlpha = max(int(yes), faded)
# Only handle talk/blink/edit visibility with modulate
# Opacity slider effects are handled by shader
sprite.self_modulate.a = 1.0 # Always keep original texture alpha for clipping
var previousAlpha = sprite.modulate.a
sprite.modulate.a = baseAlpha # Only apply talk/blink/edit visibility
# Update shader if modulate alpha changed (for editor dimming)
if previousAlpha != baseAlpha:
updateShaderOpacity()
func calculateParentOpacityMultiplier():
var multiplier = 1.0
var currentParent = parentSprite
# Traverse up the entire parent hierarchy
while currentParent != null:
if currentParent.affectChildrenOpacity:
multiplier *= currentParent.spriteOpacity
currentParent = currentParent.parentSprite
return multiplier
## Update wobble parameters and sync to group if needed
func updateWobbleParameter(parameter: String, value: float):
# Update this sprite's parameter first
match parameter:
"xFrq": xFrq = value
"xAmp": xAmp = value
"yFrq": yFrq = value
"yAmp": yAmp = value
"rFrq": rFrq = value
"rAmp": rAmp = value
# If this sprite is in a sync group, update the entire group
if wobbleSyncGroup != "" and WobbleSyncManager:
WobbleSyncManager.updateGroupWobble(wobbleSyncGroup, xFrq, xAmp, yFrq, yAmp, rFrq, rAmp)
# Don't refresh UI immediately - let the sync process complete first
# The wobble sync control will handle UI updates when needed
else:
pass # Individual sprite update - no sync needed
## Check if this sprite is synced to a group
func isSynced() -> bool:
return wobbleSyncGroup != ""
func delete():
# Clean up from wobble sync groups
if wobbleSyncGroup != "" and WobbleSyncManager:
WobbleSyncManager.onSpriteDeleted(id)
queue_free()
func _physics_process(delta):
@@ -324,31 +411,60 @@ func drag(delta):
if dragSpeed == 0:
dragger.global_position = wob.global_position
else:
dragger.global_position = lerp(dragger.global_position,wob.global_position,1/dragSpeed)
# Convert dragSpeed to a proper delta-time lerp factor
# Higher dragSpeed = slower movement, so we invert it
# Use Global.main.physicsTimeMultiplier for consistent physics timing
var physicsMultiplier = Global.main.physicsTimeMultiplier if Global.main else 10.0
var lerpFactor = clamp(delta * (physicsMultiplier * 10.0 / max(dragSpeed, 0.1)), 0.0, 1.0)
dragger.global_position = lerp(dragger.global_position, wob.global_position, lerpFactor)
dragOrigin.global_position = dragger.global_position
func wobble():
wob.position.x = sin(tick*xFrq)*xAmp
wob.position.y = sin(tick*yFrq)*yAmp
# Use Global.main.physicsTimeMultiplier for consistent physics timing
var physicsMultiplier = Global.main.physicsTimeMultiplier if Global.main else 10.0
var scaledTime = time * physicsMultiplier
wob.position.x = sin(scaledTime * xFrq) * xAmp
wob.position.y = sin(scaledTime * yFrq) * yAmp
func rotationalDrag(length,delta):
func rotationalDrag(length, delta):
var yvel = (length * rdragStr)
#Calculate Max angle
yvel = clamp(yvel,rLimitMin,rLimitMax)
yvel = clamp(yvel, rLimitMin, rLimitMax)
# Add rotation wobble as percentage of rotational limits range
var rotationRange = rLimitMax - rLimitMin
var rotationWobble = sin(tick*rFrq) * (rotationRange * rAmp * 0.01)
var totalRotation = yvel + rotationWobble
var physicsMultiplier = Global.main.physicsTimeMultiplier if Global.main else 10.0
var scaledTime = time * physicsMultiplier
var rotationWobble = sin(scaledTime * rFrq) * (rotationRange * rAmp * 0.01)
var totalRotation = yvel + rotationWobble + staticRotation
sprite.rotation = lerp_angle(sprite.rotation,deg_to_rad(totalRotation),0.25)
# Use delta-based lerp for smooth rotation with slower speed
var lerpFactor = clamp(delta * (physicsMultiplier * 1.0), 0.0, 1.0) # Reduced from 15.0 to 4.0
sprite.rotation = lerp_angle(sprite.rotation, deg_to_rad(totalRotation), lerpFactor)
func stretch(length,delta):
func stretch(length, delta):
var yvel = (length * stretchAmount * 0.01)
var target = Vector2(1.0-yvel,1.0+yvel)
var target = Vector2(1.0 - yvel, 1.0 + yvel)
sprite.scale = lerp(sprite.scale,target,0.5)
# Use delta-based lerp for smooth stretching
var physicsMultiplier = Global.main.physicsTimeMultiplier if Global.main else 10.0
var lerpFactor = clamp(delta * (physicsMultiplier * 30.0), 0.0, 1.0)
sprite.scale = lerp(sprite.scale, target, lerpFactor)
## Apply static transformation (mirroring)
func applyStaticTransform():
var scale_x = 1.0
var scale_y = 1.0
if mirrorHorizontal:
scale_x = -1.0
if mirrorVertical:
scale_y = -1.0
# Apply mirror scaling while preserving the stretch scale
var current_scale = sprite.scale
sprite.scale = Vector2(abs(current_scale.x) * scale_x, abs(current_scale.y) * scale_y)
func changeCollision(enable):
grabArea.monitorable = enable
@@ -403,6 +519,46 @@ func getAllLinkedSprites():
linkedSprites.append(node)
return linkedSprites
func updateOpacity():
# Force a refresh of opacity for this sprite and its children
talkBlink()
updateShaderOpacity()
# Update all child sprites in the entire hierarchy if this sprite affects children
if affectChildrenOpacity:
updateChildrenOpacityRecursively()
func updateShaderOpacity():
# Calculate total opacity from user settings and parent hierarchy
var totalOpacity = spriteOpacity * calculateParentOpacityMultiplier()
# Also factor in the modulate alpha for editor dimming/onion skinning
var editorAlpha = sprite.modulate.a
var finalOpacity = totalOpacity * editorAlpha
# Always apply the opacity blend shader to ensure it's loaded
if opacityMaterial == null:
opacityMaterial = ShaderMaterial.new()
opacityMaterial.shader = opacityBlendShader
opacityMaterial.set_shader_parameter("opacity", finalOpacity)
opacityMaterial.set_shader_parameter("blend_mode", blendMode)
sprite.material = opacityMaterial
## Update the blend mode and refresh the shader
func updateBlendMode(newBlendMode: int):
if BlendModeManager.is_valid_blend_mode(newBlendMode):
blendMode = newBlendMode
updateShaderOpacity() # Refresh shader with new blend mode
func updateChildrenOpacityRecursively():
var linkedSprites = getAllLinkedSprites()
for child in linkedSprites:
child.talkBlink()
child.updateShaderOpacity()
# Always update the entire hierarchy below this child
child.updateChildrenOpacityRecursively()
func visToggle(keys):
if keys.has(toggle):
$WobbleOrigin/DragOrigin.visible = !$WobbleOrigin/DragOrigin.visible

View File

@@ -46,6 +46,11 @@ func setvalues():
label.text = "costume " + str(tag) + " key: \"" + Global.main.costumeKeys[tag-1] + "\""
tag += 1
$experimentalMicLoudness/checkmark.button_pressed = Global.experimentalMicLoudness
# Initialize NDI settings
setup_ndi_settings()
func _on_color_picker_button_color_changed(color):
get_viewport().transparent_bg = false
RenderingServer.set_default_clear_color(color)
@@ -136,13 +141,10 @@ func _on_bounce_gravity_value_changed(value):
func costumeButtonsPressed(label,id):
label.text = "AWAITING INPUT"
await Global.main.emptiedCapture
awaitingCostumeInput = id - 1
await Global.main.pressedKey
label.text = "costume " + str(id) + " key: \"" + Global.main.costumeKeys[id - 1] + "\""
await Global.main.emptiedCapture
awaitingCostumeInput = -1
func _on_costume_button_1_pressed():
@@ -202,10 +204,17 @@ func _on_costume_check_toggled(button_pressed):
## @param button_pressed: bool - Whether StreamDeck should be enabled
func _on_stream_deck_check_toggled(button_pressed):
Saving.settings["useStreamDeck"] = button_pressed
if ElgatoStreamDeck:
ElgatoStreamDeck.refresh_connection()
var streamdeck_node = get_node_or_null("/root/ElgatoStreamDeck")
if streamdeck_node != null:
streamdeck_node.refresh_connection()
Global.pushUpdate("StreamDeck integration " + ("enabled" if button_pressed else "disabled") + ".")
## Handle experimental microphone loudness toggle
## @param checked: bool - Whether experimental mic loudness should be enabled
func _on_experimental_mic_loudness_toggle(checked):
Global.experimentalMicLoudness = checked
Saving.settings["experimentalMicLoudness"] = checked
func _process(delta):
var g = to_local(get_global_mouse_position())
@@ -258,3 +267,86 @@ func _on_delete_9_pressed():
func _on_delete_10_pressed():
var label = $CostumeInputs/ScrollContainer/VBoxContainer/costumeButton10/Label
deleteKey(label,10)
## NDI Output Settings Methods
func _on_ndi_enabled_check_toggled(toggled_on: bool):
# Enable/disable NDI streaming by adding/removing the NDI node
if toggled_on:
# Enable NDI - create the node if it doesn't exist
if not Global.main.ndi_output:
var ndi_node = NDIOutput.new()
ndi_node.set_name("NDIOutput") # Scene tree node name
var source_name = $NDIOutput/ndiSourceName.text
if source_name.is_empty():
source_name = "PNGTuber-Plus"
ndi_node.name = source_name # NDI source name
ndi_node.audio_bus = StringName("") # No audio by default
Global.main.add_child(ndi_node)
Global.main.ndi_output = ndi_node
print("NDI streaming enabled")
Global.pushUpdate("NDI streaming enabled")
update_ndi_status()
else:
# Disable NDI - remove the node
if Global.main.ndi_output:
Global.main.ndi_output.queue_free()
Global.main.ndi_output = null
print("NDI streaming disabled")
Global.pushUpdate("NDI streaming disabled")
update_ndi_status()
func _on_ndi_source_name_text_changed(new_text: String):
# Update NDI source name
if Global.main.ndi_output:
Global.main.ndi_output.name = new_text
print("NDI source name changed to: ", new_text)
func _on_ndi_audio_check_toggled(toggled_on: bool):
# Enable/disable audio in NDI stream
if Global.main.ndi_output:
if toggled_on:
Global.main.ndi_output.audio_bus = StringName("Master")
else:
Global.main.ndi_output.audio_bus = StringName("")
print("NDI audio ", "enabled" if toggled_on else "disabled")
func _on_ndi_toggle_button_pressed():
# Toggle NDI streaming on/off by toggling the checkbox
var current_state = $NDIOutput/ndiEnabledCheck.button_pressed
$NDIOutput/ndiEnabledCheck.button_pressed = !current_state
_on_ndi_enabled_check_toggled(!current_state)
func update_ndi_status():
# Update the status label and button text
if Global.main.ndi_output:
$NDIOutput/StatusLabel.text = "Status: Active (NDI Source: " + Global.main.ndi_output.name + ")"
$NDIOutput/ndiEnabledCheck.button_pressed = true
$NDIOutput/ndiSourceName.text = Global.main.ndi_output.name
else:
$NDIOutput/StatusLabel.text = "Status: Disabled"
$NDIOutput/ndiEnabledCheck.button_pressed = false
func setup_ndi_settings():
# Initialize NDI settings UI with current values
if Global.main.ndi_output:
$NDIOutput/ndiSourceName.text = Global.main.ndi_output.name
update_ndi_status()
# Initialize avatar-only checkbox state
$NDIOutput/avatarOnlyCheck.button_pressed = false
if Global.main.ndi_avatar_viewport:
$NDIOutput/avatarOnlyCheck.button_pressed = Global.main.ndi_avatar_viewport.visible
func _on_avatar_only_check_toggled(toggled_on: bool):
# Enable/disable avatar-only NDI streaming
if Global.main.ndi_avatar_viewport:
Global.main.ndi_avatar_viewport.visible = toggled_on
if toggled_on:
# Sync avatar when enabling
Global.main.ndi_avatar_viewport.sync_avatar()
print("Avatar-only NDI stream enabled")
Global.pushUpdate("Avatar-only NDI stream enabled")
else:
print("Avatar-only NDI stream disabled")
Global.pushUpdate("Avatar-only NDI stream disabled")

View File

@@ -14182,8 +14182,9 @@ HSlider/styles/slider = SubResource("StyleBoxFlat_t3qts")
script = ExtResource("1_ckdhy")
[node name="NinePatchRect" type="NinePatchRect" parent="."]
offset_left = -190.0
offset_right = 419.0
offset_bottom = 444.0
offset_bottom = 460.0
texture = ExtResource("1_ylauk")
region_rect = Rect2(0, 0, 48, 48)
patch_margin_left = 8
@@ -14289,46 +14290,107 @@ offset_bottom = 28.0
text = "confirm"
[node name="StreamDeck" type="Node2D" parent="."]
position = Vector2(16, 373)
position = Vector2(-173, 27)
[node name="Label" type="Label" parent="StreamDeck"]
offset_right = 138.0
offset_bottom = 34.0
text = "use streamdeck"
metadata/_edit_use_anchors_ = true
[node name="streamDeckCheck" type="CheckBox" parent="StreamDeck"]
offset_left = 130.0
offset_top = 1.0
offset_right = 154.0
offset_bottom = 25.0
metadata/_edit_use_anchors_ = true
[node name="experimentalMicLoudness" type="Node2D" parent="."]
position = Vector2(-173, 59)
[node name="Label" type="Label" parent="experimentalMicLoudness"]
offset_right = 200.0
offset_bottom = 34.0
text = "mic loudness"
[node name="checkmark" type="CheckBox" parent="experimentalMicLoudness"]
offset_left = 130.0
offset_top = 1.0
offset_right = 154.0
offset_bottom = 25.0
metadata/_edit_use_anchors_ = true
[node name="AntiAliasing" type="Node2D" parent="."]
position = Vector2(16, 405)
position = Vector2(-173, 91)
[node name="Label" type="Label" parent="AntiAliasing"]
offset_right = 138.0
offset_bottom = 34.0
text = "texture filtering"
metadata/_edit_use_anchors_ = true
[node name="CheckBox" type="CheckBox" parent="AntiAliasing"]
offset_left = 130.0
offset_top = 1.0
offset_right = 154.0
offset_bottom = 25.0
metadata/_edit_use_anchors_ = true
[node name="NDIOutput" type="Node2D" parent="."]
position = Vector2(-175, 149)
[node name="Label" type="Label" parent="NDIOutput"]
offset_top = 38.0
offset_right = 138.0
offset_bottom = 72.0
text = "NDI streaming"
metadata/_edit_use_anchors_ = true
[node name="ndiEnabledCheck" type="CheckBox" parent="NDIOutput"]
offset_left = 130.0
offset_top = 38.0
offset_right = 154.0
offset_bottom = 62.0
metadata/_edit_use_anchors_ = true
[node name="ndiSourceName" type="LineEdit" parent="NDIOutput"]
offset_top = 64.0
offset_right = 145.0
offset_bottom = 95.0
text = "PNGTuber-Plus"
metadata/_edit_use_anchors_ = true
[node name="StatusLabel" type="Label" parent="NDIOutput"]
offset_left = 4.0
offset_top = 100.0
offset_right = 204.0
offset_bottom = 123.0
text = "Status: Not streaming"
metadata/_edit_use_anchors_ = true
[node name="avatarOnlyCheck" type="CheckBox" parent="NDIOutput"]
offset_left = 4.0
offset_top = 125.0
offset_right = 150.0
offset_bottom = 149.0
text = "Avatar-only stream"
metadata/_edit_use_anchors_ = true
[node name="bounceOnCostume" type="Node2D" parent="."]
position = Vector2(17, 341)
position = Vector2(-172, 122)
[node name="Label" type="Label" parent="bounceOnCostume"]
offset_right = 138.0
offset_bottom = 34.0
text = "costume bounce"
metadata/_edit_use_anchors_ = true
[node name="costumeCheck" type="CheckBox" parent="bounceOnCostume"]
offset_left = 130.0
offset_top = 1.0
offset_right = 154.0
offset_bottom = 25.0
metadata/_edit_use_anchors_ = true
[node name="BounceForce" type="Node2D" parent="."]
position = Vector2(22, 115)
@@ -14381,13 +14443,13 @@ value = 1000.0
scrollable = false
[node name="CostumeInputs" type="Node2D" parent="."]
position = Vector2(404, 305)
position = Vector2(49, 408)
[node name="ScrollContainer" type="ScrollContainer" parent="CostumeInputs"]
offset_left = -226.0
offset_top = -78.0
offset_right = 7.0
offset_bottom = 123.0
offset_top = -125.0
offset_right = 354.0
offset_bottom = 39.0
horizontal_scroll_mode = 0
[node name="VBoxContainer" type="VBoxContainer" parent="CostumeInputs/ScrollContainer"]
@@ -14824,15 +14886,8 @@ position = Vector2(-2, -2)
texture = ExtResource("9_lywmf")
centered = false
[node name="Label" type="Label" parent="CostumeInputs"]
offset_left = -179.0
offset_top = -109.0
offset_right = -41.0
offset_bottom = -75.0
text = "costume hotkeys"
[node name="BlinkSpeed" type="Node2D" parent="."]
position = Vector2(17, 283)
position = Vector2(227, 205)
[node name="Label" type="Label" parent="BlinkSpeed"]
offset_left = -10.0
@@ -14841,6 +14896,7 @@ offset_right = 158.0
offset_bottom = 27.0
text = "blink speed: 1"
horizontal_alignment = 1
metadata/_edit_use_anchors_ = true
[node name="blinkSpeed" type="HSlider" parent="BlinkSpeed"]
offset_left = -9.0
@@ -14851,9 +14907,10 @@ theme = SubResource("Theme_e6tc4")
max_value = 20.0
value = 1.0
scrollable = false
metadata/_edit_use_anchors_ = true
[node name="BlinkChance" type="Node2D" parent="."]
position = Vector2(17, 235)
position = Vector2(17, 205)
[node name="Label" type="Label" parent="BlinkChance"]
offset_left = -10.0
@@ -14862,6 +14919,7 @@ offset_right = 160.0
offset_bottom = 27.0
text = "blink chance: 1 in 200"
horizontal_alignment = 1
metadata/_edit_use_anchors_ = true
[node name="blinkChance" type="HSlider" parent="BlinkChance"]
offset_left = -8.0
@@ -14873,6 +14931,7 @@ min_value = 1.0
max_value = 300.0
value = 200.0
scrollable = false
metadata/_edit_use_anchors_ = true
[connection signal="pressed" from="Background/Transparent/Button" to="." method="_on_button_pressed"]
[connection signal="pressed" from="Background/Green/greenButton" to="." method="_on_green_button_pressed"]
@@ -14883,7 +14942,11 @@ scrollable = false
[connection signal="value_changed" from="MaxFPS/fpsDrag" to="." method="_on_fps_drag_value_changed"]
[connection signal="pressed" from="MaxFPS/confirm" to="." method="_on_confirm_pressed"]
[connection signal="toggled" from="StreamDeck/streamDeckCheck" to="." method="_on_stream_deck_check_toggled"]
[connection signal="toggled" from="experimentalMicLoudness/checkmark" to="." method="_on_experimental_mic_loudness_toggle"]
[connection signal="toggled" from="AntiAliasing/CheckBox" to="." method="_on_check_box_toggled"]
[connection signal="toggled" from="NDIOutput/ndiEnabledCheck" to="." method="_on_ndi_enabled_check_toggled"]
[connection signal="text_changed" from="NDIOutput/ndiSourceName" to="." method="_on_ndi_source_name_text_changed"]
[connection signal="toggled" from="NDIOutput/avatarOnlyCheck" to="." method="_on_avatar_only_check_toggled"]
[connection signal="toggled" from="bounceOnCostume/costumeCheck" to="." method="_on_costume_check_toggled"]
[connection signal="value_changed" from="BounceForce/bounceForce" to="." method="_on_bounce_force_value_changed"]
[connection signal="value_changed" from="BounceGravity/bounceGravity" to="." method="_on_bounce_gravity_value_changed"]

View File

@@ -5,15 +5,150 @@ extends Node2D
@onready var parentSpin = $SubViewportContainer2/SubViewport/Node3D/Sprite3D
@onready var spriteRotDisplay = $RotationalLimits/RotBack/SpriteDisplay
@onready var spriteRotDisplay = $VBoxContainer/RotationalLimitsSection/RotBack/SpriteDisplay
@onready var coverCollider = $Area2D/CollisionShape2D
@onready var wobbleSyncControl = null # Will be assigned in _ready()
@onready var blendModeDropdown = $VBoxContainer/RenderingSection/blendModeContainer/blendModeDropdown
## Slider management system
var slider_manager: SliderManager = null
func _ready():
Global.spriteEdit = self
# Initialize slider management system
slider_manager = SliderManager.new(self)
_setup_slider_components()
# Find the wobble sync control node
wobbleSyncControl = find_child("WobbleSyncControl")
# Set up blend mode dropdown
setupBlendModeDropdown()
## Set up all slider components
func _setup_slider_components():
# Initialize all the new slider components and add them to the manager
_setup_drag_slider()
_setup_wobble_sliders()
_setup_rotation_sliders()
_setup_limit_sliders()
_setup_animation_sliders()
_setup_opacity_slider()
_setup_transform_sliders()
## Set up drag slider
func _setup_drag_slider():
var drag_slider = $VBoxContainer/PhysicsSection/DragSlider
if drag_slider:
slider_manager.add_slider(drag_slider)
drag_slider.value_changed.connect(_on_drag_slider_value_changed)
## Set up wobble sliders
func _setup_wobble_sliders():
var wobble_sliders = [
$VBoxContainer/WobbleSection/xFrqSlider,
$VBoxContainer/WobbleSection/xAmpSlider,
$VBoxContainer/WobbleSection/yFrqSlider,
$VBoxContainer/WobbleSection/yAmpSlider,
$VBoxContainer/WobbleSection/rFrqSlider,
$VBoxContainer/WobbleSection/rAmpSlider
]
for slider in wobble_sliders:
if slider:
slider_manager.add_slider(slider)
slider.value_changed.connect(_on_wobble_slider_changed)
## Set up rotation sliders
func _setup_rotation_sliders():
var rotation_sliders = [
$VBoxContainer/PhysicsSection/rDragSlider,
$VBoxContainer/PhysicsSection/squashSlider
]
for slider in rotation_sliders:
if slider:
slider_manager.add_slider(slider)
slider.value_changed.connect(_on_rotation_slider_changed)
## Set up rotational limit sliders
func _setup_limit_sliders():
var limit_sliders = [
$VBoxContainer/RotationalLimitsSection/rotLimitMinSlider,
$VBoxContainer/RotationalLimitsSection/rotLimitMaxSlider
]
for slider in limit_sliders:
if slider:
slider_manager.add_slider(slider)
slider.value_changed.connect(_on_limit_slider_changed)
## Set up animation sliders
func _setup_animation_sliders():
var animation_sliders = [
$VBoxContainer/AnimationSection/animFramesSlider,
$VBoxContainer/AnimationSection/animSpeedSlider
]
for slider in animation_sliders:
if slider:
slider_manager.add_slider(slider)
slider.value_changed.connect(_on_animation_slider_changed)
## Set up opacity slider
func _setup_opacity_slider():
var opacity_slider = $VBoxContainer/RenderingSection/opacitySlider
if opacity_slider:
slider_manager.add_slider(opacity_slider)
opacity_slider.value_changed.connect(_on_opacity_slider_changed)
## Set up transform sliders
func _setup_transform_sliders():
var rotation_slider = $VBoxContainer/TransformSection/rotationSlider
if rotation_slider:
slider_manager.add_slider(rotation_slider)
rotation_slider.value_changed.connect(_on_transform_slider_changed)
## Generic slider change handlers
func _on_drag_slider_value_changed(value: float):
if Global.heldSprite:
Global.heldSprite.dragSpeed = value
func _on_wobble_slider_changed(value: float):
# Wobble sliders handle their own parameter updates
# Just update the wobble sync control if needed
if wobbleSyncControl:
wobbleSyncControl.updateUI()
func _on_rotation_slider_changed(value: float):
# Rotation sliders handle their own parameter updates
if Global.heldSprite:
changeRotLimit() # Update the rotation limit display
func _on_limit_slider_changed(value: float):
# Limit sliders handle their own parameter updates
if Global.heldSprite:
changeRotLimit() # Update the rotation limit display
func _on_animation_slider_changed(value: float):
# Animation sliders handle their own parameter updates
if Global.heldSprite:
# Special handling for frames slider
var frames_slider = $VBoxContainer/AnimationSection/animFramesSlider
if frames_slider and frames_slider.value != Global.heldSprite.frames:
spriteSpin.hframes = Global.heldSprite.frames
func _on_opacity_slider_changed(value: float):
# Opacity slider handles its own parameter updates via sprite_property
pass
func _on_transform_slider_changed(value: float):
# Transform sliders handle their own parameter updates via sprite_property
pass
func setImage():
if Global.heldSprite == null:
return
@@ -22,68 +157,63 @@ func setImage():
spriteSpin.pixel_size = 1.5 / Global.heldSprite.imageData.get_size().y
spriteSpin.hframes = Global.heldSprite.frames
# Apply static rotation and mirroring to 3D preview
spriteSpin.rotation = Vector3(0, 0, deg_to_rad(Global.heldSprite.staticRotation))
var scale_x = -1.0 if Global.heldSprite.mirrorHorizontal else 1.0
var scale_y = -1.0 if Global.heldSprite.mirrorVertical else 1.0
spriteSpin.scale = Vector3(scale_x, scale_y, 1.0)
spriteRotDisplay.texture = Global.heldSprite.tex
spriteRotDisplay.offset = Global.heldSprite.offset
var displaySize = Global.heldSprite.imageData.get_size().y
spriteRotDisplay.scale = Vector2(1,1) * (150.0/displaySize)
$Slider/Label.text = "drag: " + str(Global.heldSprite.dragSpeed)
$Slider/DragSlider.value = Global.heldSprite.dragSpeed
# Update all slider components with the current sprite
if slider_manager:
slider_manager.update_all_sliders(Global.heldSprite)
# Update individual sliders with current values
_update_all_slider_values()
$WobbleControl/xFrqLabel.text = "x frequency: " + str(Global.heldSprite.xFrq)
$WobbleControl/xAmpLabel.text = "x amplitude: " + str(Global.heldSprite.xAmp)
$Position/fileTitle.text = Global.heldSprite.path.replace("user://", "")
$WobbleControl/xFrq.value = Global.heldSprite.xFrq
$WobbleControl/xAmp.value = Global.heldSprite.xAmp
$WobbleControl/yFrqLabel.text = "y frequency: " + str(Global.heldSprite.yFrq)
$WobbleControl/yAmpLabel.text = "y amplitude: " + str(Global.heldSprite.yAmp)
$WobbleControl/yFrq.value = Global.heldSprite.yFrq
$WobbleControl/yAmp.value = Global.heldSprite.yAmp
$VBoxContainer/PhysicsSection/BounceVelocity.button_pressed = Global.heldSprite.ignoreBounce
$VBoxContainer/RenderingSection/ClipLinked.button_pressed = Global.heldSprite.clipped
$WobbleControl/rFrqLabel.text = "rotation frequency: " + str(Global.heldSprite.rFrq)
$WobbleControl/rAmpLabel.text = "rotation percentage: " + str(Global.heldSprite.rAmp) + "%"
$WobbleControl/rFrq.value = Global.heldSprite.rFrq
$WobbleControl/rAmp.value = Global.heldSprite.rAmp
$VBoxContainer/Buttons/SpeakingWrapper/Speaking.frame = Global.heldSprite.showOnTalk
$VBoxContainer/Buttons/BlinkingWrapper/Blinking.frame = Global.heldSprite.showOnBlink
$Rotation/rDragLabel.text = "rotational drag: " + str(Global.heldSprite.rdragStr)
$Rotation/rDrag.value = Global.heldSprite.rdragStr
$VBoxContainer/RenderingSection/affectChildrenCheck.button_pressed = Global.heldSprite.affectChildrenOpacity
$VBoxContainer/RenderingSection/blendModeContainer/blendModeDropdown.selected = Global.heldSprite.blendMode
$Buttons/Speaking.frame = Global.heldSprite.showOnTalk
$Buttons/Blinking.frame = Global.heldSprite.showOnBlink
# Update transform controls
$VBoxContainer/TransformSection/MirrorControls/mirrorHorizontalCheck.button_pressed = Global.heldSprite.mirrorHorizontal
$VBoxContainer/TransformSection/MirrorControls/mirrorVerticalCheck.button_pressed = Global.heldSprite.mirrorVertical
$RotationalLimits/rotLimitMin.value = Global.heldSprite.rLimitMin
$RotationalLimits/RotLimitMin.text = "rotational limit min: " + str(Global.heldSprite.rLimitMin)
$RotationalLimits/rotLimitMax.value = Global.heldSprite.rLimitMax
$RotationalLimits/RotLimitMax.text = "rotational limit max: " + str(Global.heldSprite.rLimitMax)
$Rotation/squashlabel.text = "squash: " + str(Global.heldSprite.stretchAmount)
$Rotation/squash.value = Global.heldSprite.stretchAmount
$Position/fileTitle.text = Global.heldSprite.path
$Buttons/CheckBox.button_pressed = Global.heldSprite.ignoreBounce
$Buttons/ClipLinked.button_pressed = Global.heldSprite.clipped
$Animation/animSpeedLabel.text = "animation speed: " + str(Global.heldSprite.animSpeed)
$Animation/animSpeed.value = Global.heldSprite.animSpeed
$Animation/animFramesLabel.text = "sprite frames: " + str(Global.heldSprite.frames)
$Animation/animFrames.value = Global.heldSprite.frames
$VisToggle/setToggle/Label.text = "toggle: \"" + Global.heldSprite.toggle + "\""
$VBoxContainer/BoxContainer/setToggle/Label.text = "toggle: \"" + Global.heldSprite.toggle + "\""
changeRotLimit()
setLayerButtons()
# Update wobble sync control
if wobbleSyncControl:
wobbleSyncControl.setSprite(Global.heldSprite)
# Force a refresh to ensure dropdown is populated
await get_tree().process_frame
wobbleSyncControl.updateUI()
# Update wobble control states based on sync status
updateWobbleControlStates()
if Global.heldSprite.parentId == null:
$Buttons/Unlink.visible = false
$VBoxContainer/Buttons/UnlinkWrapper/Unlink.visible = false
parentSpin.visible = false
else:
$Buttons/Unlink.visible = true
$VBoxContainer/Buttons/UnlinkWrapper/Unlink.visible = true
var nodes = get_tree().get_nodes_in_group(str(Global.heldSprite.parentId))
@@ -94,6 +224,97 @@ func setImage():
parentSpin.pixel_size = 1.5 / nodes[0].imageData.get_size().y
parentSpin.hframes = nodes[0].frames
parentSpin.visible = true
## Update all slider components with current sprite values
func _update_all_slider_values():
if not Global.heldSprite:
return
# Update drag slider
var drag_slider = $VBoxContainer/PhysicsSection/DragSlider
if drag_slider:
drag_slider.set_value(Global.heldSprite.dragSpeed)
# Update wobble sliders
var wobble_sliders = {
$VBoxContainer/WobbleSection/xFrqSlider: Global.heldSprite.xFrq,
$VBoxContainer/WobbleSection/xAmpSlider: Global.heldSprite.xAmp,
$VBoxContainer/WobbleSection/yFrqSlider: Global.heldSprite.yFrq,
$VBoxContainer/WobbleSection/yAmpSlider: Global.heldSprite.yAmp,
$VBoxContainer/WobbleSection/rFrqSlider: Global.heldSprite.rFrq,
$VBoxContainer/WobbleSection/rAmpSlider: Global.heldSprite.rAmp
}
for slider in wobble_sliders:
if slider:
slider.set_value(wobble_sliders[slider])
# Update rotation sliders
var rotation_sliders = {
$VBoxContainer/PhysicsSection/rDragSlider: Global.heldSprite.rdragStr,
$VBoxContainer/PhysicsSection/squashSlider: Global.heldSprite.stretchAmount
}
for slider in rotation_sliders:
if slider:
slider.set_value(rotation_sliders[slider])
# Update limit sliders
var limit_sliders = {
$VBoxContainer/RotationalLimitsSection/rotLimitMinSlider: Global.heldSprite.rLimitMin,
$VBoxContainer/RotationalLimitsSection/rotLimitMaxSlider: Global.heldSprite.rLimitMax
}
for slider in limit_sliders:
if slider:
slider.set_value(limit_sliders[slider])
# Update animation sliders
var animation_sliders = {
$VBoxContainer/AnimationSection/animFramesSlider: Global.heldSprite.frames,
$VBoxContainer/AnimationSection/animSpeedSlider: Global.heldSprite.animSpeed
}
for slider in animation_sliders:
if slider:
slider.set_value(animation_sliders[slider])
# Update opacity slider
var opacity_slider = $VBoxContainer/RenderingSection/opacitySlider
if opacity_slider:
opacity_slider.set_value(Global.heldSprite.spriteOpacity)
# Update transform slider
var rotation_slider = $VBoxContainer/TransformSection/rotationSlider
if rotation_slider:
rotation_slider.set_value(Global.heldSprite.staticRotation)
## Update wobble control states based on sync status
func updateWobbleControlStates():
if Global.heldSprite == null:
return
# Use slider manager if available
if slider_manager:
slider_manager.update_wobble_control_states(Global.heldSprite)
# Also update individual wobble sliders for visual feedback
var is_synced = Global.heldSprite.isSynced()
var tint = Color(1.0, 1.0, 0.9) if is_synced else Color.WHITE
var wobble_sliders = [
$VBoxContainer/WobbleSection/xFrqSlider,
$VBoxContainer/WobbleSection/xAmpSlider,
$VBoxContainer/WobbleSection/yFrqSlider,
$VBoxContainer/WobbleSection/yAmpSlider,
$VBoxContainer/WobbleSection/rFrqSlider,
$VBoxContainer/WobbleSection/rAmpSlider
]
for slider in wobble_sliders:
if slider and slider.has_method("set_modulate_color"):
slider.set_modulate_color(tint)
slider.set_editable(true) # Keep enabled even when synced
func _process(delta):
@@ -107,9 +328,9 @@ func _process(delta):
spriteSpin.rotate_y(delta*4.0)
parentSpin.rotate_y(delta*4.0)
$Position/Label.text = "position X : "+str(obj.position.x)+" Y: " + str(obj.position.y)
$Position/Label2.text = "offset X : "+str(obj.offset.x)+" Y: " + str(obj.offset.y)
$Position/Label3.text = "layer : "+str(obj.z)
$VBoxContainer/InfoSection/PositionLabel.text = "position X : "+str(obj.position.x)+" Y: " + str(obj.position.y)
$VBoxContainer/InfoSection/OffsetLabel.text = "offset X : "+str(obj.offset.x)+" Y: " + str(obj.offset.y)
$VBoxContainer/InfoSection/LayerLabel.text = "layer : "+str(obj.z)
#Sprite Rotational Limit Display
@@ -117,65 +338,30 @@ func _process(delta):
var minimum = Global.heldSprite.rLimitMin
spriteRotDisplay.rotation_degrees = sin(Global.animationTick*0.05)*(size/2.0)+(minimum+(size/2.0))
$RotationalLimits/RotBack/RotLineDisplay3.rotation_degrees = spriteRotDisplay.rotation_degrees
func _on_drag_slider_value_changed(value):
if Global.heldSprite != null:
$Slider/Label.text = "drag: " + str(value)
Global.heldSprite.dragSpeed = value
func _on_x_frq_value_changed(value):
$WobbleControl/xFrqLabel.text = "x frequency: " + str(value)
Global.heldSprite.xFrq = value
func _on_x_amp_value_changed(value):
$WobbleControl/xAmpLabel.text = "x amplitude: " + str(value)
Global.heldSprite.xAmp = value
func _on_y_frq_value_changed(value):
$WobbleControl/yFrqLabel.text = "y frequency: " + str(value)
Global.heldSprite.yFrq = value
func _on_y_amp_value_changed(value):
$WobbleControl/yAmpLabel.text = "y amplitude: " + str(value)
Global.heldSprite.yAmp = value
func _on_r_frq_value_changed(value):
$WobbleControl/rFrqLabel.text = "rotation frequency: " + str(value)
Global.heldSprite.rFrq = value
func _on_r_amp_value_changed(value):
$WobbleControl/rAmpLabel.text = "rotation amplitude: " + str(value) + "%"
Global.heldSprite.rAmp = value
func _on_r_drag_value_changed(value):
$Rotation/rDragLabel.text = "rotational drag: " + str(value)
Global.heldSprite.rdragStr = value
$VBoxContainer/RotationalLimitsSection/RotBack/RotLineDisplay3.rotation_degrees = spriteRotDisplay.rotation_degrees
func _on_speaking_pressed():
var f = $Buttons/Speaking.frame
var f = $VBoxContainer/Buttons/SpeakingWrapper/Speaking.frame
f = (f+1) % 3
$Buttons/Speaking.frame = f
$VBoxContainer/Buttons/SpeakingWrapper/Speakingaking.frame = f
Global.heldSprite.showOnTalk = f
func _on_blinking_pressed():
var f = $Buttons/Blinking.frame
var f = $VBoxContainer/Buttons/BlinkingWrapper/Blinking.frame
f = (f+1) % 3
$Buttons/Blinking.frame = f
$VBoxContainer/Buttons/BlinkingWrapper/Blinking.frame = f
Global.heldSprite.showOnBlink = f
func _on_trash_pressed():
# Clean up wobble sync group membership before deleting
if Global.heldSprite.wobbleSyncGroup != "" and WobbleSyncManager:
WobbleSyncManager.onSpriteDeleted(Global.heldSprite.id)
Global.heldSprite.queue_free()
Global.heldSprite = null
@@ -188,39 +374,34 @@ func _on_unlink_pressed():
setImage()
func _on_rot_limit_min_value_changed(value):
$RotationalLimits/RotLimitMin.text = "rotational limit min: " + str(value)
Global.heldSprite.rLimitMin = value
changeRotLimit()
func _on_rot_limit_max_value_changed(value):
$RotationalLimits/RotLimitMax.text = "rotational limit max: " + str(value)
Global.heldSprite.rLimitMax = value
changeRotLimit()
func changeRotLimit():
$RotationalLimits/RotBack/rotLimitBar.value = Global.heldSprite.rLimitMax - Global.heldSprite.rLimitMin
$RotationalLimits/RotBack/rotLimitBar.rotation_degrees = Global.heldSprite.rLimitMin + 90
$VBoxContainer/RotationalLimitsSection/RotBack/rotLimitBar.value = Global.heldSprite.rLimitMax - Global.heldSprite.rLimitMin
$VBoxContainer/RotationalLimitsSection/RotBack/rotLimitBar.rotation_degrees = Global.heldSprite.rLimitMin + 90
$VBoxContainer/RotationalLimitsSection/RotBack/RotLineDisplay.rotation_degrees = Global.heldSprite.rLimitMin
$VBoxContainer/RotationalLimitsSection/RotBack/RotLineDisplay2.rotation_degrees = Global.heldSprite.rLimitMax
$RotationalLimits/RotBack/RotLineDisplay.rotation_degrees = Global.heldSprite.rLimitMin
$RotationalLimits/RotBack/RotLineDisplay2.rotation_degrees = Global.heldSprite.rLimitMax
func setLayerButtons():
if Global.heldSprite == null:
return
var a = Global.heldSprite.costumeLayers.duplicate()
$Layers/Layer1.frame = 1-a[0]
$Layers/Layer2.frame = 1-a[1]
$Layers/Layer3.frame = 1-a[2]
$Layers/Layer4.frame = 1-a[3]
$Layers/Layer5.frame = 1-a[4]
$Layers/Layer6.frame = 1-a[5]
$Layers/Layer7.frame = 1-a[6]
$Layers/Layer8.frame = 1-a[7]
$Layers/Layer9.frame = 1-a[8]
$Layers/Layer10.frame = 1-a[9]
$VBoxContainer/LayersSection/HBoxContainer/Layer1Container/Layer1.frame = 1-a[0]
$VBoxContainer/LayersSection/HBoxContainer/Layer2Container/Layer2.frame = 1-a[1]
$VBoxContainer/LayersSection/HBoxContainer/Layer3Container/Layer3.frame = 1-a[2]
$VBoxContainer/LayersSection/HBoxContainer/Layer4Container/Layer4.frame = 1-a[3]
$VBoxContainer/LayersSection/HBoxContainer/Layer5Container/Layer5.frame = 1-a[4]
$VBoxContainer/LayersSection/HBoxContainer2/Layer6Container/Layer6.frame = 1-a[5]
$VBoxContainer/LayersSection/HBoxContainer2/Layer7Container/Layer7.frame = 1-a[6]
$VBoxContainer/LayersSection/HBoxContainer2/Layer8Container/Layer8.frame = 1-a[7]
$VBoxContainer/LayersSection/HBoxContainer2/Layer9Container/Layer9.frame = 1-a[8]
$VBoxContainer/LayersSection/HBoxContainer2/Layer10Container/Layer10.frame = 1-a[9]
# Update checkmarks to show current costume layer
updateLayerCheckmarks()
# Update sprite visibility for all sprites based on current costume
var nodes = get_tree().get_nodes_in_group("saved")
for sprite in nodes:
if sprite.costumeLayers[Global.main.costume - 1] == 1:
@@ -229,7 +410,45 @@ func setLayerButtons():
else:
sprite.visible = false
sprite.changeCollision(false)
# Update the sprite list to reflect visibility changes
Global.spriteList.updateAllVisible()
func updateLayerCheckmarks():
# Hide all checkmarks first
$VBoxContainer/LayersSection/HBoxContainer/Layer1Container/Layer1/Checkmark1.visible = false
$VBoxContainer/LayersSection/HBoxContainer/Layer2Container/Layer2/Checkmark2.visible = false
$VBoxContainer/LayersSection/HBoxContainer/Layer3Container/Layer3/Checkmark3.visible = false
$VBoxContainer/LayersSection/HBoxContainer/Layer4Container/Layer4/Checkmark4.visible = false
$VBoxContainer/LayersSection/HBoxContainer/Layer5Container/Layer5/Checkmark5.visible = false
$VBoxContainer/LayersSection/HBoxContainer2/Layer6Container/Layer6/Checkmark6.visible = false
$VBoxContainer/LayersSection/HBoxContainer2/Layer7Container/Layer7/Checkmark7.visible = false
$VBoxContainer/LayersSection/HBoxContainer2/Layer8Container/Layer8/Checkmark8.visible = false
$VBoxContainer/LayersSection/HBoxContainer2/Layer9Container/Layer9/Checkmark9.visible = false
$VBoxContainer/LayersSection/HBoxContainer2/Layer10Container/Layer10/Checkmark10.visible = false
# Show checkmark for current costume layer
match Global.main.costume:
1:
$VBoxContainer/LayersSection/HBoxContainer/Layer1Container/Layer1/Checkmark1.visible = true
2:
$VBoxContainer/LayersSection/HBoxContainer/Layer2Container/Layer2/Checkmark2.visible = true
3:
$VBoxContainer/LayersSection/HBoxContainer/Layer3Container/Layer3/Checkmark3.visible = true
4:
$VBoxContainer/LayersSection/HBoxContainer/Layer4Container/Layer4/Checkmark4.visible = true
5:
$VBoxContainer/LayersSection/HBoxContainer/Layer5Container/Layer5/Checkmark5.visible = true
6:
$VBoxContainer/LayersSection/HBoxContainer2/Layer6Container/Layer6/Checkmark6.visible = true
7:
$VBoxContainer/LayersSection/HBoxContainer2/Layer7Container/Layer7/Checkmark7.visible = true
8:
$VBoxContainer/LayersSection/HBoxContainer2/Layer8Container/Layer8/Checkmark8.visible = true
9:
$VBoxContainer/LayersSection/HBoxContainer2/Layer9Container/Layer9/Checkmark9.visible = true
10:
$VBoxContainer/LayersSection/HBoxContainer2/Layer10Container/Layer10/Checkmark10.visible = true
func _on_layer_button_1_pressed():
@@ -310,62 +529,73 @@ func layerSelected():
var newPos = Vector2.ZERO
match Global.main.costume:
1:
newPos = $Layers/Layer1.position
newPos = $VBoxContainer/LayersSection/HBoxContainer/Layer1Container/Layer1.position
2:
newPos = $Layers/Layer2.position
newPos = $VBoxContainer/LayersSection/HBoxContainer/Layer2Container/Layer2.position
3:
newPos = $Layers/Layer3.position
newPos = $VBoxContainer/LayersSection/HBoxContainer/Layer3Container/Layer3.position
4:
newPos = $Layers/Layer4.position
newPos = $VBoxContainer/LayersSection/HBoxContainer/Layer4Container/Layer4.position
5:
newPos = $Layers/Layer5.position
newPos = $VBoxContainer/LayersSection/HBoxContainer/Layer5Container/Layer5.position
6:
newPos = $Layers/Layer6.position
newPos = $VBoxContainer/LayersSection/HBoxContainer2/Layer6Container/Layer6.position
7:
newPos = $Layers/Layer7.position
newPos = $VBoxContainer/LayersSection/HBoxContainer2/Layer7Container/Layer7.position
8:
newPos = $Layers/Layer8.position
newPos = $VBoxContainer/LayersSection/HBoxContainer2/Layer8Container/Layer8.position
9:
newPos = $Layers/Layer9.position
newPos = $VBoxContainer/LayersSection/HBoxContainer2/Layer9Container/Layer9.position
10:
newPos = $Layers/Layer10.position
$Layers/Select.position = newPos
func _on_squash_value_changed(value):
$Rotation/squashlabel.text = "squash: " + str(value)
Global.heldSprite.stretchAmount = value
func _on_check_box_toggled(button_pressed):
Global.heldSprite.ignoreBounce = button_pressed
func _on_anim_speed_value_changed(value):
$Animation/animSpeedLabel.text = "animation speed: " + str(value)
Global.heldSprite.animSpeed = value
func _on_anim_frames_value_changed(value):
$Animation/animFramesLabel.text = "sprite frames: " + str(value)
Global.heldSprite.frames = value
spriteSpin.hframes = Global.heldSprite.frames
Global.heldSprite.changeFrames()
newPos = $VBoxContainer/LayersSection/HBoxContainer2/Layer10Container/Layer10.position
# Update selection indicator position if it exists
var select_node = get_node_or_null("VBoxContainer/LayersSection/Select")
if select_node:
select_node.position = newPos
func _on_clip_linked_toggled(button_pressed):
Global.heldSprite.setClip(button_pressed)
func _on_check_box_toggled(button_pressed):
Global.heldSprite.ignoreBounce = button_pressed
func _on_delete_pressed():
Global.heldSprite.toggle = "null"
$VisToggle/setToggle/Label.text = "toggle: \"" + Global.heldSprite.toggle + "\""
$VBoxContainer/BoxContainer/setToggle/Label.text = "toggle: \"" + Global.heldSprite.toggle + "\""
Global.heldSprite.makeVis()
func _on_set_toggle_pressed():
$VisToggle/setToggle/Label.text = "toggle: AWAITING INPUT"
$VBoxContainer/BoxContainer/setToggle/Label.text = "toggle: AWAITING INPUT"
await Global.main.fatfuckingballs
var keys = await Global.main.spriteVisToggles
var key = keys[0]
Global.heldSprite.toggle = key
$VisToggle/setToggle/Label.text = "toggle: \"" + Global.heldSprite.toggle + "\""
$VBoxContainer/BoxContainer/setToggle/Label.text = "toggle: \"" + Global.heldSprite.toggle + "\""
func _on_affect_children_check_toggled(button_pressed):
Global.heldSprite.affectChildrenOpacity = button_pressed
Global.heldSprite.updateOpacity()
func _on_mirror_horizontal_check_toggled(button_pressed):
Global.heldSprite.mirrorHorizontal = button_pressed
func _on_mirror_vertical_check_toggled(button_pressed):
Global.heldSprite.mirrorVertical = button_pressed
## Set up the blend mode dropdown with all available blend modes
func setupBlendModeDropdown():
blendModeDropdown.clear()
var blendModeNames = BlendModeManager.get_blend_mode_names()
for mode_name in blendModeNames:
blendModeDropdown.add_item(mode_name)
## Handle blend mode dropdown selection
func _on_blend_mode_dropdown_item_selected(index: int):
if Global.heldSprite != null:
Global.heldSprite.updateBlendMode(index)
Global.pushUpdate("Blend mode set to: " + BlendModeManager.get_blend_mode_name(index))

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,190 @@
extends Control
## WobbleSyncControl - UI component for managing wobble sync groups
## Designed to integrate into the sprite editor panel
@onready var groupDropdown = $VBoxContainer/GroupRow/GroupDropdown
@onready var syncIndicator = $VBoxContainer/SyncIndicator
@onready var createGroupDialog = $CreateGroupDialog
@onready var groupNameInput = $CreateGroupDialog/VBoxContainer/LineEdit
var currentSprite = null
var updating_dropdown = false # Flag to prevent recursive calls
signal group_changed(sprite_id: int, group_name: String)
func _ready():
# Wait for autoload to be ready
await get_tree().process_frame
# Connect UI signals - no add button to connect
groupDropdown.item_selected.connect(_on_group_dropdown_selected)
createGroupDialog.confirmed.connect(_on_create_group_confirmed)
createGroupDialog.canceled.connect(_on_create_group_cancelled)
# Connect to wobble sync manager signals
if WobbleSyncManager:
WobbleSyncManager.group_created.connect(_on_group_created)
WobbleSyncManager.group_deleted.connect(_on_group_deleted)
# Setup create group dialog
createGroupDialog.title = "Create Wobble Sync Group"
createGroupDialog.size = Vector2(300, 150)
createGroupDialog.unresizable = false
createGroupDialog.transient = true # Prevent click-through
createGroupDialog.exclusive = true # Make modal
createGroupDialog.popup_window = false # Keep as embedded dialog
createGroupDialog.always_on_top = true
updateUI()
## Update the UI based on current sprite selection
func updateUI():
updateGroupDropdown()
updateSyncIndicator()
## Update the group dropdown with available options
func updateGroupDropdown():
updating_dropdown = true # Prevent recursive calls
groupDropdown.clear()
# Add "None" option
groupDropdown.add_item("None")
# Add existing groups
if WobbleSyncManager:
var groups = WobbleSyncManager.getGroupNames()
for group_name in groups:
groupDropdown.add_item(group_name)
else:
pass # WobbleSyncManager not available
# Add "Create New..." option
groupDropdown.add_separator()
groupDropdown.add_item("Create New...")
# Select current group if sprite is synced
if currentSprite != null and currentSprite.wobbleSyncGroup != "":
var group_index = -1
for i in range(groupDropdown.get_item_count()):
if groupDropdown.get_item_text(i) == currentSprite.wobbleSyncGroup:
group_index = i
break
if group_index != -1:
groupDropdown.selected = group_index
else:
groupDropdown.selected = 0 # Select "None"
else:
groupDropdown.selected = 0 # Select "None"
updating_dropdown = false
## Update the sync indicator
func updateSyncIndicator():
if currentSprite == null:
syncIndicator.visible = false
return
if currentSprite.isSynced():
syncIndicator.visible = true
syncIndicator.text = "⚡ Synced to \"" + currentSprite.wobbleSyncGroup + "\""
else:
syncIndicator.visible = false
## Set the current sprite to manage
func setSprite(sprite):
currentSprite = sprite
if currentSprite:
pass # Sprite is valid
updateUI()
## Get available groups for the dropdown (excluding "None" and "Create New...")
func getAvailableGroups() -> Array:
var groups = []
for i in range(1, groupDropdown.get_item_count() - 2): # Skip "None" and "Create New..."
groups.append(groupDropdown.get_item_text(i))
return groups
## Handle group dropdown selection
func _on_group_dropdown_selected(index: int):
if currentSprite == null or updating_dropdown:
return
var selected_text = groupDropdown.get_item_text(index)
match selected_text:
"None":
# Remove sprite from any group
if currentSprite.wobbleSyncGroup != "" and WobbleSyncManager:
WobbleSyncManager.removeSpriteFromAllGroups(currentSprite.id)
Global.pushUpdate("Removed sprite from wobble sync group")
"Create New...":
# Show create group dialog
groupNameInput.text = ""
groupNameInput.placeholder_text = "Enter group name..."
createGroupDialog.popup_centered_clamped(Vector2i(300, 150))
groupNameInput.grab_focus()
# Reset dropdown to current selection
updateGroupDropdown()
_:
# Join selected group
if WobbleSyncManager and WobbleSyncManager.hasGroup(selected_text):
WobbleSyncManager.addSpriteToGroup(currentSprite.id, selected_text)
# Refresh sprite editor to show updated wobble values
if Global.spriteEdit:
Global.spriteEdit.setImage()
else:
pass # Group doesn't exist
updateSyncIndicator()
## Handle create group dialog confirmation
func _on_create_group_confirmed():
var group_name = groupNameInput.text.strip_edges()
if group_name == "":
Global.pushUpdate("Group name cannot be empty")
return
if WobbleSyncManager and WobbleSyncManager.hasGroup(group_name):
Global.pushUpdate("Group \"" + group_name + "\" already exists")
return
# Create group with current sprite
if currentSprite != null and WobbleSyncManager:
if WobbleSyncManager.createGroup(group_name, currentSprite.id):
createGroupDialog.hide()
updateUI()
# Refresh sprite editor to show sync status
if Global.spriteEdit:
Global.spriteEdit.setImage()
else:
pass # Failed to create group
else:
pass # No sprite or WobbleSyncManager not available
## Handle create group dialog cancellation
func _on_create_group_cancelled():
createGroupDialog.hide()
updateGroupDropdown() # Reset dropdown to current selection
## Handle group creation signal from manager
func _on_group_created(group_name: String):
updateGroupDropdown()
## Handle group deletion signal from manager
func _on_group_deleted(group_name: String):
updateGroupDropdown()
updateSyncIndicator()
## Check if current sprite can have wobble syncing applied
func canSync() -> bool:
return currentSprite != null
## Force refresh the UI - useful after loading saves
func forceRefresh():
updateUI()

View File

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

View File

@@ -0,0 +1,59 @@
[gd_scene load_steps=2 format=3 uid="uid://bkwf3j2pdspxw"]
[ext_resource type="Script" uid="uid://ykov43vxml0k" path="res://ui_scenes/spriteEditMenu/wobble_sync_control.gd" id="1_k8h4v"]
[node name="WobbleSyncControl" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_k8h4v")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="GroupRow" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
[node name="GroupLabel" type="Label" parent="VBoxContainer/GroupRow"]
layout_mode = 2
size_flags_horizontal = 0
text = "Group:"
[node name="GroupDropdown" type="OptionButton" parent="VBoxContainer/GroupRow"]
layout_mode = 2
size_flags_horizontal = 3
[node name="SyncIndicator" type="Label" parent="VBoxContainer"]
layout_mode = 2
text = "⚡ Synced to \"Group Name\""
autowrap_mode = 2
[node name="CreateGroupDialog" type="ConfirmationDialog" parent="."]
process_mode = 3
title = "Create Wobble Sync Group"
size = Vector2i(300, 150)
[node name="VBoxContainer" type="VBoxContainer" parent="CreateGroupDialog"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 8.0
offset_top = 8.0
offset_right = -8.0
offset_bottom = -49.0
[node name="Label" type="Label" parent="CreateGroupDialog/VBoxContainer"]
layout_mode = 2
text = "Enter name for new wobble sync group:"
[node name="LineEdit" type="LineEdit" parent="CreateGroupDialog/VBoxContainer"]
layout_mode = 2
placeholder_text = "Group name..."

View File

@@ -2,9 +2,9 @@ extends NinePatchRect
@onready var spritePreview = $SpritePreview/Sprite2D
@onready var outline = $Selected
@onready var fade = $Fade
@onready var button = $Button
@onready var label = $Label
var sprite = null
var parent = null
@@ -14,9 +14,23 @@ var indent = 0
var childrenTags = []
var parentTag = null
## Context menu for right-click actions
var context_menu: PopupMenu = null
## Line edit for renaming
var rename_edit: LineEdit = null
## Track if we're currently in rename mode
var is_renaming: bool = false
func _ready():
var count = spritePath.get_slice_count("/") - 1
$Label.text = spritePath.get_slice("/",count)
# Use displayName if available, otherwise fallback to path
var display_text = ""
if sprite.displayName != "":
display_text = sprite.displayName
else:
var count = spritePath.get_slice_count("/") - 1
display_text = spritePath.get_slice("/",count)
label.text = display_text
$Line2D.visible = false
spritePreview.texture = sprite.sprite.texture
@@ -25,6 +39,115 @@ func _ready():
spritePreview.scale = Vector2(1,1) * (60.0/displaySize)
spritePreview.offset = sprite.sprite.offset
# Set up right-click detection
button.gui_input.connect(_on_button_gui_input)
# Create context menu
_setup_context_menu()
## Set up the right-click context menu
func _setup_context_menu():
context_menu = PopupMenu.new()
add_child(context_menu)
# Add rename option
context_menu.add_item("Rename", 0)
context_menu.id_pressed.connect(_on_context_menu_selected)
# Wait for menu to be ready, then adjust size
context_menu.ready.connect(_adjust_menu_size)
## Adjust the context menu size to be more compact
func _adjust_menu_size():
if context_menu:
# Let the menu calculate its natural size first
await get_tree().process_frame
context_menu.reset_size() # Reset to content-based size
## Handle GUI input events on the button
func _on_button_gui_input(event: InputEvent):
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
# Show context menu at cursor position
var global_pos = get_global_mouse_position()
context_menu.position = Vector2i(global_pos)
context_menu.popup()
## Handle context menu selections
func _on_context_menu_selected(id: int):
match id:
0: # Rename
_start_rename()
## Start the rename process
func _start_rename():
if is_renaming:
return
is_renaming = true
# Create line edit for renaming
rename_edit = LineEdit.new()
add_child(rename_edit)
# Position and size the line edit over the label
rename_edit.position = label.position
rename_edit.size = label.size
rename_edit.text = label.text
# Hide the original label
label.visible = false
# Focus the line edit and select all text
rename_edit.grab_focus()
rename_edit.select_all()
# Connect signals
rename_edit.text_submitted.connect(_on_rename_submitted)
rename_edit.focus_exited.connect(_on_rename_focus_lost)
## Handle rename submission (Enter key)
func _on_rename_submitted(new_name: String):
if rename_edit != null:
_finish_rename(new_name)
## Handle losing focus (clicking elsewhere)
func _on_rename_focus_lost():
if rename_edit != null:
_finish_rename(rename_edit.text)
## Finish the rename process
func _finish_rename(new_name: String):
if not is_renaming:
return
is_renaming = false
# Clean up the line edit
if rename_edit:
rename_edit.queue_free()
rename_edit = null
# Show the label again
label.visible = true
# Validate and apply the new name
new_name = new_name.strip_edges()
if new_name != "" and new_name != label.text:
_apply_rename(new_name)
## Apply the rename to the sprite
func _apply_rename(new_name: String):
# Update the display name (without file extension)
label.text = new_name
sprite.displayName = new_name # Store display name separately
# Update the global sprite list
Global.spriteList.updateData()
# Show feedback to user
Global.pushUpdate("Renamed sprite to: " + new_name)
func updateChildren():
for child in childrenTags: