diff --git a/.gitignore b/.gitignore index 0af181c..0935bf7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ # Godot 4+ specific ignores .godot/ /android/ + +# JetBrains IDE — contains absolute paths (Godot binary, SDK dirs), varies per user +.idea/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1db47dc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,59 @@ +# Bullosseum — CLAUDE.md + +## About this project + +A 3D arena game built in Godot 4.7 (Forward Plus renderer, Jolt Physics) where the player controls a bull. Currently features third-person camera, bull movement with charge mechanics, and a placeholder arena. + +## AI role + +I am a **Godot 4.6+ expert**. I follow current best practices for GDScript, scene architecture, physics, and performance. If a question suggests a suboptimal approach — wrong node type, unnecessary complexity, a pattern that fights the engine — I will say so and explain the better alternative before implementing anything. + +## Project structure + +| Path | Purpose | +|---|---| +| `scene.tscn` | Main scene (arena + player) | +| `Player.tscn` | Player scene root (`CharacterBody3D`) | +| `player.gd` | Movement, charge, turning logic | +| `camera_spring_arm.gd` | Mouse-look pivot (`Node3D` + `SpringArm3D`) | +| `camera_follow.gd` | Smooth camera follow (`Camera3D`) | +| `Assets/` | Raw 3D assets (`.glb`, `.fbx`) | +| `addons/rider-plugin/` | JetBrains Rider IDE integration | + +## Tech choices + +- **Physics:** Jolt Physics (not the default Godot Physics) +- **Renderer:** Forward Plus +- **Input map:** WASD + arrow keys, Shift = charge, Space = jump, scroll = zoom, middle-click = toggle mouse capture +- **IDE:** JetBrains Rider via the rider-plugin addon + +## GDScript conventions + +- Typed GDScript everywhere (`var x: float`, return types on functions) +- `@onready` for node references; never `get_node()` strings when avoidable +- Constants in `SCREAMING_SNAKE_CASE`, variables in `snake_case` +- One script per scene root — keep scripts focused +- Signal names in `snake_case`; connect via `signal.connect()` not the legacy string form +- Prefer `_physics_process` for physics/movement, `_process` for visuals/camera, `_unhandled_input` for input that shouldn't bubble +- No comments that restate what the code already says; only comment non-obvious constraints or workarounds + +## Best practices to enforce + +- Use `CharacterBody3D` for player-controlled characters, not `RigidBody3D` (unless the design specifically needs physics simulation) +- Use `SpringArm3D` for third-person cameras to get free collision avoidance +- Prefer `move_and_slide()` with `velocity` over manual collision queries +- Export variables (`@export`) for any value a designer might tune; keep magic numbers out of logic +- Scene composition over inheritance — build behaviour from small focused scenes +- Use `autoload` (singletons) sparingly: only for truly global state (e.g. GameManager, AudioBus); not as a shortcut for passing data +- Keep `_physics_process` deterministic and frame-rate independent (always multiply by `delta`) +- Prefer signals over direct node references for decoupling + +## Godot 4.x specifics + +- `wrapf` / `wrap` instead of manual modulo for angles +- `lerp_angle` for smooth rotation interpolation (handles wrap-around correctly) +- `move_toward` for speed ramps without overshooting +- `PackedStringArray`, `PackedVector3Array`, etc. for performance-sensitive arrays +- `@tool` scripts for editor helpers only — don't use in gameplay scripts +- Resource (`extends Resource`) for shared data/config; no plain `Dictionary` for structured data +- `StringName` (`&"action_name"`) for input action lookups in hot paths diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..d8aad08 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,205 @@ +# Bullosseum — Game Design Document + +> Living document. `?` marks open questions. + +--- + +## Elevator Pitch + +You are the bull. Waves of cowboys, matadors, and gladiators pour into your arena — each with their own tricks to dodge or counter your charge. Survive, wreck things, pick upgrades. Every run is different. Goofy low-poly mayhem. + +--- + +## Art Direction + +- **Style:** Low-poly, chunky geometry, flat or minimal shading +- **Tone:** Goofy and physical — think *Gang Beasts* meets *Vampire Survivors* energy +- **Camera:** Third-person, spring-arm collision avoidance +- **Palette:** Warm sand/terracotta arena, vivid faction colours (red/gold for matadors, brown/blue for cowboys, iron/purple for gladiators) +- **Particles:** Stylised cartoon puffs — dust, impact sparks, ragdoll stars +- **Audio direction:** ? + +--- + +## Confirmed Mechanics + +- Third-person bull locomotion with momentum (walk, charge) +- Wide committed turning arc during charge — charges feel like commitments +- Visual turn lag (body leans) +- Spring-arm camera with zoom +- Sand dust particles at hooves (walk = small, charge = big) +- Jolt Physics backend + +--- + +## Run Structure (Vampire Survivors-inspired) + +``` +Start run → Wave spawns → Kill enemies → Wave cleared → Pick upgrade → Next wave → ... → Die or achieve target +``` + +- **No hard time limit per wave** — pressure comes from enemy density escalating +- **Upgrade picks between waves** — choose 1 of 3 options (stat, ability, passive synergy) +- **Fail state is soft** — getting overwhelmed is a death spiral, not a timeout. You stop being able to charge effectively and get cornered +- **Run length:** ? (15–25 minutes for a full run feels right for web) +- **Meta progression:** ? Unlock new starting abilities or bull cosmetics across runs + +### Wave Escalation — Faction Ramp + +Factions are introduced one at a time so each mechanic gets space to teach itself before mixing begins. + +| Waves | Faction makeup | Boss | +|---|---|---| +| 1–3 | Matadors only | — | +| 4 | Matadors only | El Presidente | +| 5–7 | Matadors + Cowboys introduced | — | +| 8 | Mixed (matadors + cowboys) | Sheriff | +| 9–11 | All three factions introduced | — | +| 12 | Mixed (all three) | Centurion | +| 13+ | Full chaos — all factions, escalating density | Repeat bosses + ? final boss | + +- Each new faction's first wave is a "pure" wave so the player can learn the pattern before it mixes +- Bosses appear solo — the arena clears before a boss spawns, making it feel like a duel +- ? After wave 12, does a loop begin (harder repeat) or does a final boss end the run? + +--- + +## Enemy Factions + +### Matadors (the classics) +The trickiest faction — they're designed around baiting your charge. + +| Enemy | Behaviour | +|---|---| +| Matador | Waves red cape to draw your charge, sidesteps at last moment. Must catch them off-guard or predict the dodge | +| Banderillero | Plants slow-flags in the ground — charges into one stuns the bull briefly | +| Picador | Mounted on horse, tanky, slow. Horse must be hit first before the rider is vulnerable | +| ? El Presidente (boss) | Summons matadors and teleports around the arena in bursts | + +### Cowboys +The herd faction — individually easy, dangerous in groups. + +| Enemy | Behaviour | +|---|---| +| Cowboy | Throws lasso to root you for 1–2s, then runs. Easy to hit, just annoying | +| Wrangler | Herds other enemies toward you — a force multiplier | +| Mounted Cowboy | Circles you on horseback, throwing dynamite. Must cut off the riding arc | +| ? Sheriff (boss) | Shotgun blast that staggers the bull; spawns deputies on death | + +### Gladiators +The armour faction — charge angles matter. + +| Enemy | Behaviour | +|---|---| +| Legionary | Front shield absorbs charges completely. Must hit from the side or behind | +| Net-thrower | Long-range root that holds you in place for 3s | +| Archer | Stationary on elevated platform (arena wall edge), fires slowing arrows | +| ? Centurion (boss) | Buffs nearby gladiators, has a charge-reflect shield that bounces you back | + +--- + +## Ability System + +Three slots total — always the same structure, filled differently every run. + +``` +[Charge ──────────] [Medium □] [Ultimate □] + always on 1 slot 1 slot +``` + +### Slot 1 — Charge (always on) +| | Description | +|---|---| +| **Charge** | Hold to build, release to sprint. Committed arc, hard to steer. The foundation everything else is built around | + +### Slot 2 — Medium Ability +Cooldown ~8–15s. Tactical, reactive, fills gaps that charge can't. One equipped at a time. + +| Ability | Description | Good against | +|---|---|---| +| **Stomp** | Slam front hooves — short-range AoE stun | Matador clusters, anything close | +| **Gore** | Upward headbutt — launches a single target | Shielded gladiators (bypasses front shield) | +| **Tail Whip** | 360° sweep behind and to the sides | Cowboys lassooing from behind | +| **Bull Roar** | Knockback shockwave — no damage, pure space creation | Net-throwers, tight groups | +| **Ground Pound** | From a jump, landing creates a shockwave crater | Mounted enemies (dismounts them) | +| **Counter** | Narrow window — if you charge as an attack lands, explosive ricochet | Picadors, centurion reflect | + +### Slot 3 — Ultimate Ability +Cooldown ~35–50s, or requires a full charge meter. High impact. One equipped at a time. + +| Ability | Description | Good against | +|---|---|---| +| **Stampede** | ~5s of zero deceleration — can't stop or steer well, flattens everything in the path | Boss phases, dense waves | +| **Enrage** | ~8s berserk: charge speed +60%, hitbox wider, ignores all slows and roots | Lasso/net-heavy waves | +| **Red Mist** | Every enemy on screen gets a red target marker and is pulled slightly toward you — brief guaranteed hits | Matador dodge phase | +| **Thunder Horn** | Next charge is electrified — chains damage to up to 4 nearby enemies on contact | Gladiator formations | +| **Call of the Herd** | Summon 3 ghost bulls that charge in parallel alongside you for 6s | Any — pure chaos | + +### Card Pick Screen (between every wave) +Game pauses. Three cards shown — pick one. + +| Card type | Examples | +|---|---| +| **New medium** | Swap current medium for a different one | +| **New ultimate** | Swap current ultimate for a different one | +| **Stat boost** | +charge speed, +turn rate, −medium cooldown, −ultimate cooldown | +| **Passive modifier** | Changes how your *current* ability behaves (see below) | +| **Synergy** | Unlocks a cross-ability combo effect | + +**Passive modifier examples:** +- Stomp → "Dust Blind" — stun cloud also blocks enemy vision for 2s +- Gore → "Pinball" — launched enemy bounces off walls and damages others on landing +- Stampede → "Aftershock" — leaves a trembling ground trail that slows enemies for 5s +- Enrage → "Bloodlust" — kills during enrage reduce remaining cooldown + +**Synergy examples:** +- Charge + Stomp = "Seismic Run" — charging for 2s+ turns your next stomp into a ground wave +- Gore + Thunder Horn = "Lightning Rod" — electrified gore sends a chain to the whole group +- ? Others to discover during design + +--- + +## Arena Design + +Each faction maps to an arena environment. Mixed-faction waves could transition the arena or blend themes. + +| Faction | Arena | Hazards | +|---|---|---| +| Matadors | Sand bullring, wooden barrier ring | Flags slow movement, open centre is the danger zone | +| Cowboys | Frontier rodeo grounds, hay bales, fences | Lasso posts (rope traps), horse stampede lanes | +| Gladiators | Roman colosseum, stone floor, iron gates | Archer towers on walls, spike traps, wet stone (slide) | + +- ? Does the arena change mid-run or is one arena selected at run start? +- ? Destructible fence/barrier sections throughout? + +--- + +## Core Loop Revisited + +``` +Move → Spot threat pattern → Position approach angle → Charge/Ability → Chain hits → Wave clear + ↑ ↓ + Dodge/react to debuffs (lasso, net, slow-flag) Pick upgrade (1 of 3) +``` + +The **positioning** step is where skill expression lives: which enemy to hit first, what angle to approach a shielded gladiator, whether to charge the matador or fake him out. + +--- + +## Open Questions + +1. **Meta progression** — cosmetic unlocks only, or do you unlock new starting medium/ultimate options across runs? +2. **Arena change** — single arena per run, or does the arena theme shift when a new faction is introduced mid-run? +3. **After wave 12** — infinite loop with scaling difficulty, or a final boss that ends the run cleanly? +4. **Ability replacement** — when you pick a new medium/ultimate card, does it always replace the slot, or can you upgrade an existing ability through repeated picks (level 1→2→3)? +5. **Web scope** — vertical slice (matadors only, 4 waves, one ability tree) or full 12-wave game? + +--- + +## References + +- *Vampire Survivors* — wave escalation, upgrade pick loop, run length +- *Gang Beasts* — ragdoll slapstick commitment +- *Katamari* / *Donut County* — simple core verb scaled through variety +- *Superhot* — charge-as-commitment, every move mattering +- *Cult of the Lamb* — faction variety with distinct visual languages diff --git a/addons/rider-plugin/CONTRIBUTING.md b/addons/rider-plugin/CONTRIBUTING.md new file mode 100644 index 0000000..16ddc5f --- /dev/null +++ b/addons/rider-plugin/CONTRIBUTING.md @@ -0,0 +1,33 @@ +# Contribution + +Hello, thanks for taking time and helping out with the addon! + +Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. Please sign the CLA before sending the PR: https://www.jetbrains.com/agreements/cla/. + +#### Local setup + +Open the `godot-editor-plugin/CMakeLists.txt` in Rider 2026.1+, select `rider-gdextension` target in the run configuration selector. +There are some more fixes coming in 2026.2, which will allow working on gdscript and cpp files within the same workspace. + +#### Signing the binaries + +Plugin binaries need to be signed to comply with modern operating system security requirements. Unsigned dynamic libraries may fail to load or trigger security warnings: + +- **macOS**: Requires code signing (and often notarization) for `.dylib` files. Unsigned libraries may simply fail to load. +- **Windows**: SmartScreen may block unsigned binaries, and corporate environments often enforce signed code only. +- **Linux**: Generally allows unsigned `.so` files, but some sandboxed environments (Flatpak, Snap) impose restrictions. + +**How signing is implemented:** + +1. **TeamCity Configuration**: [ijplatform_master_Net_Deploy_Plugins_Public_Godot](https://buildserver.labs.intellij.net/buildConfiguration/ijplatform_master_Net_Deploy_Plugins_Public_Godot) (internal JetBrains link) + - Note: The automatic trigger on new tags is currently not working, so manual triggering is required. + +2. **Release Process**: + - First, use your GitHub Action to prepare a **pre-release** with a tag + - Manually trigger the TeamCity configuration on the necessary branch/tag + - The configuration will download assets, sign them, and upload them back (removing the pre-release flag) + - The pre-release flag is required at the start; otherwise, the signing configuration will skip the release (protection against double signing) + +3. **Signing existing releases**: + - You can run signing on already existing old releases/tags + - Mark them as pre-release first, then run the configuration on the necessary tag diff --git a/addons/rider-plugin/LICENCE b/addons/rider-plugin/LICENCE new file mode 100644 index 0000000..f1f8c07 --- /dev/null +++ b/addons/rider-plugin/LICENCE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 JetBrains + +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. \ No newline at end of file diff --git a/addons/rider-plugin/README.md b/addons/rider-plugin/README.md new file mode 100644 index 0000000..0e7c4ad --- /dev/null +++ b/addons/rider-plugin/README.md @@ -0,0 +1,51 @@ +# JetBrains Rider Integration – Godot addon + +This addon currently provides two features: +1. Finds all Rider installations in the system and provides a selector on the `Text Editor` -> `External` tab in the settings to select one. +2. Provides the "Use Rider" toggle in the Godot toolbar and, when enabled, applies a set of editor settings recommended for working with JetBrains Rider. The goal is to make it trivial to switch between Rider‑optimized settings and stock Godot settings with a single click. + +## Quick start + +Requirements: +- Godot 4.2.2+ + +Install: +1. Inside the Godot editor, it can be installed from the AssetLib view or [downloaded](https://godotengine.org/asset-library/asset/4576) +2. [Optional] Change the initial value of `active` in the plugin.cfg +3. [Optional] Change the initial values in the presets.json file. +4. Enable "JetBrains Rider External Editor" plugin in the Project → Project Settings… → Plugins tab. + +Use: +- A toolbar toggle named "Use Rider" will appear. Click it to turn the preset On/Off. + +Screenshot: +![Toolbar toggle](screenshots/Toolbar.png) + +## What the toggle changes + +The preset values live in `presets.json`. + +When ON: +- Write the values from the "on" preset into the Editor Settings. + +When OFF: +- Write the values from the "off" preset into the Editor Settings. + +Note: The plugin does not currently auto‑set Rider’s executable path or flags. See Plans below. + +## Setting Rider to be the external editor + +The plugin automatically detects installed Rider versions on your system and provides a convenient dropdown menu to +select which installation to use as your external editor. + +- The plugin scans common installation locations for Rider on Windows, macOS, and Linux. +- Detected installations appear in the "Select Rider" dropdown in the toolbar. +- When you select a Rider installation, the plugin automatically updates the `dotnet/editor/external_editor_path` editor + setting. + +## License +See `addons/rider-plugin/LICENCE`. + +## Acknowledgements +Created by JetBrains to streamline using Rider with Godot. +Initial idea https://github.com/sszigeti/toggle_external_editor diff --git a/addons/rider-plugin/bin/linux/librider-gdextension.linux.editor.arm64.so b/addons/rider-plugin/bin/linux/librider-gdextension.linux.editor.arm64.so new file mode 100644 index 0000000..09cfa38 Binary files /dev/null and b/addons/rider-plugin/bin/linux/librider-gdextension.linux.editor.arm64.so differ diff --git a/addons/rider-plugin/bin/linux/librider-gdextension.linux.editor.x86_64.so b/addons/rider-plugin/bin/linux/librider-gdextension.linux.editor.x86_64.so new file mode 100644 index 0000000..948d1ce Binary files /dev/null and b/addons/rider-plugin/bin/linux/librider-gdextension.linux.editor.x86_64.so differ diff --git a/addons/rider-plugin/bin/macos/librider-gdextension.macos.editor.dylib b/addons/rider-plugin/bin/macos/librider-gdextension.macos.editor.dylib new file mode 100644 index 0000000..52d3777 Binary files /dev/null and b/addons/rider-plugin/bin/macos/librider-gdextension.macos.editor.dylib differ diff --git a/addons/rider-plugin/bin/rider-plugin.gdextension b/addons/rider-plugin/bin/rider-plugin.gdextension new file mode 100644 index 0000000..770d413 --- /dev/null +++ b/addons/rider-plugin/bin/rider-plugin.gdextension @@ -0,0 +1,16 @@ +[configuration] + +entry_symbol = "rider_library_init" +compatibility_minimum = "4.2.2" + +[libraries] + +macos.single.debug = "./macos/librider-gdextension.macos.editor.dylib" + +windows.arm64.single.debug = "./windows/rider-gdextension.windows.editor.arm64.dll" + +windows.x86_64.single.debug = "./windows/rider-gdextension.windows.editor.x86_64.dll" + +linux.x86_64.single.debug = "./linux/librider-gdextension.linux.editor.x86_64.so" + +linux.arm64.single.debug = "./linux/librider-gdextension.linux.editor.arm64.so" \ No newline at end of file diff --git a/addons/rider-plugin/bin/rider-plugin.gdextension.uid b/addons/rider-plugin/bin/rider-plugin.gdextension.uid new file mode 100644 index 0000000..e69de29 diff --git a/addons/rider-plugin/bin/windows/rider-gdextension.windows.editor.arm64.dll b/addons/rider-plugin/bin/windows/rider-gdextension.windows.editor.arm64.dll new file mode 100644 index 0000000..7e58e1a Binary files /dev/null and b/addons/rider-plugin/bin/windows/rider-gdextension.windows.editor.arm64.dll differ diff --git a/addons/rider-plugin/bin/windows/rider-gdextension.windows.editor.arm64.exp b/addons/rider-plugin/bin/windows/rider-gdextension.windows.editor.arm64.exp new file mode 100644 index 0000000..338b635 Binary files /dev/null and b/addons/rider-plugin/bin/windows/rider-gdextension.windows.editor.arm64.exp differ diff --git a/addons/rider-plugin/bin/windows/rider-gdextension.windows.editor.arm64.lib b/addons/rider-plugin/bin/windows/rider-gdextension.windows.editor.arm64.lib new file mode 100644 index 0000000..55a8f1e Binary files /dev/null and b/addons/rider-plugin/bin/windows/rider-gdextension.windows.editor.arm64.lib differ diff --git a/addons/rider-plugin/bin/windows/rider-gdextension.windows.editor.x86_64.dll b/addons/rider-plugin/bin/windows/rider-gdextension.windows.editor.x86_64.dll new file mode 100644 index 0000000..31e7384 Binary files /dev/null and b/addons/rider-plugin/bin/windows/rider-gdextension.windows.editor.x86_64.dll differ diff --git a/addons/rider-plugin/bin/windows/rider-gdextension.windows.editor.x86_64.exp b/addons/rider-plugin/bin/windows/rider-gdextension.windows.editor.x86_64.exp new file mode 100644 index 0000000..cdfff4f Binary files /dev/null and b/addons/rider-plugin/bin/windows/rider-gdextension.windows.editor.x86_64.exp differ diff --git a/addons/rider-plugin/bin/windows/rider-gdextension.windows.editor.x86_64.lib b/addons/rider-plugin/bin/windows/rider-gdextension.windows.editor.x86_64.lib new file mode 100644 index 0000000..5acc0a0 Binary files /dev/null and b/addons/rider-plugin/bin/windows/rider-gdextension.windows.editor.x86_64.lib differ diff --git a/addons/rider-plugin/icons/icon.svg b/addons/rider-plugin/icons/icon.svg new file mode 100644 index 0000000..04cb843 --- /dev/null +++ b/addons/rider-plugin/icons/icon.svg @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/rider-plugin/icons/icon.svg.import b/addons/rider-plugin/icons/icon.svg.import new file mode 100644 index 0000000..05cbbf5 --- /dev/null +++ b/addons/rider-plugin/icons/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://wrdrj6wednn8" +path="res://.godot/imported/icon.svg-45c914cff7482ba9564963fe65b548e4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/rider-plugin/icons/icon.svg" +dest_files=["res://.godot/imported/icon.svg-45c914cff7482ba9564963fe65b548e4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/addons/rider-plugin/plugin.cfg b/addons/rider-plugin/plugin.cfg new file mode 100644 index 0000000..0936868 --- /dev/null +++ b/addons/rider-plugin/plugin.cfg @@ -0,0 +1,12 @@ +[plugin] + +name="JetBrains Rider External Editor" +description="Provides a toolbar toggle, to switch a set of settings on and off. Default set of settings helps to enable/disable the following settings recommended by the Rider documentation https://www.jetbrains.com/help/rider/Godot.html#optimize-godot-editor-for-rider" +author="JetBrains" +version="1.0.0" +script="rider-plugin.gd" + +[presets] + +active="off" +presets="presets.json" diff --git a/addons/rider-plugin/presets.json b/addons/rider-plugin/presets.json new file mode 100644 index 0000000..5e38bf3 --- /dev/null +++ b/addons/rider-plugin/presets.json @@ -0,0 +1,15 @@ +{ + "on": { + "text_editor/external/use_external_editor": true, + "interface/editor/import_resources_when_unfocused": true, + "interface/editor/save_on_focus_loss": true, + "text_editor/behavior/files/auto_reload_scripts_on_external_change": true, + "run/window_placement/game_embed_mode": -1 + }, + "off": { + "text_editor/external/use_external_editor": false, + "interface/editor/import_resources_when_unfocused": false, + "interface/editor/save_on_focus_loss": false, + "text_editor/behavior/files/auto_reload_scripts_on_external_change": false + } +} diff --git a/addons/rider-plugin/rider-plugin.gd b/addons/rider-plugin/rider-plugin.gd new file mode 100644 index 0000000..9bbb7cd --- /dev/null +++ b/addons/rider-plugin/rider-plugin.gd @@ -0,0 +1,66 @@ +@tool +extends EditorPlugin + +var editor_settings: EditorSettings +var checkbutton: CheckButton +var _preset_applier: PresetApplier +var _settings_service: EditorSettingsService +var _locator_service: RiderLocatorService +var _plugin_cfg_path: String +var _presets_json_path: String + +func _enter_tree() -> void: + editor_settings = EditorInterface.get_editor_settings() + var script_path := (get_script() as Script).resource_path + var plugin_dir := script_path.get_base_dir() + _plugin_cfg_path = plugin_dir + "/plugin.cfg" + var cfg := ConfigFile.new() + var err := cfg.load(_plugin_cfg_path) + if err != OK: + push_warning("Failed to load plugin.cfg: %s" % [err]) + return + var active_str := str(cfg.get_value("presets", "active", "on")) + var is_active := active_str == "on" + var presets_rel_path := str(cfg.get_value("presets", "presets", "presets.json")) + _presets_json_path = plugin_dir + "/" + presets_rel_path + + # Build UI + checkbutton = CheckButton.new() + checkbutton.text = "Use Rider" + checkbutton.tooltip_text = "Shortcut for setting recommended settings" + checkbutton.button_pressed = is_active + checkbutton.pressed.connect(_on_checkbutton_pressed) + add_control_to_container(EditorPlugin.CONTAINER_TOOLBAR, checkbutton) + + # Initialize services and panel + _settings_service = EditorSettingsService.new() + _locator_service = RiderLocatorService.new() + _preset_applier = PresetApplier.new(_presets_json_path) + + _locator_service.add_selector_in_editor_interface(_settings_service) + + # Ensure settings reflect current state on startup + _preset_applier.apply_preset(editor_settings, is_active) + +func _on_checkbutton_pressed() -> void: + var cfg := ConfigFile.new() + if cfg.load(_plugin_cfg_path) != OK: + push_warning("Failed to load plugin.cfg to update state") + return + var is_active := checkbutton.button_pressed + var key := _preset_applier.get_preset_key(is_active) + cfg.set_value("presets", "active", key) + var save_err := cfg.save(_plugin_cfg_path) + if save_err != OK: + push_warning("Failed to save plugin.cfg: %s" % [save_err]) + # Apply selected preset to editor settings + _preset_applier.apply_preset(editor_settings, is_active) + +func _exit_tree() -> void: + if checkbutton != null: + remove_control_from_container(EditorPlugin.CONTAINER_TOOLBAR, checkbutton) + checkbutton.queue_free() + + var args = OS.get_cmdline_args() + if "--rider-addon-tests" in args: + print("==== rider-addon-tests finished ====") diff --git a/addons/rider-plugin/rider-plugin.gd.uid b/addons/rider-plugin/rider-plugin.gd.uid new file mode 100644 index 0000000..80edc2b --- /dev/null +++ b/addons/rider-plugin/rider-plugin.gd.uid @@ -0,0 +1 @@ +uid://c1x58xm2w1n20 diff --git a/addons/rider-plugin/screenshots/Toolbar.png b/addons/rider-plugin/screenshots/Toolbar.png new file mode 100644 index 0000000..51c2617 Binary files /dev/null and b/addons/rider-plugin/screenshots/Toolbar.png differ diff --git a/addons/rider-plugin/screenshots/Toolbar.png.import b/addons/rider-plugin/screenshots/Toolbar.png.import new file mode 100644 index 0000000..dcec205 --- /dev/null +++ b/addons/rider-plugin/screenshots/Toolbar.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bro2w3ghx4xt7" +path="res://.godot/imported/Toolbar.png-a521e2493bd3c08a829245b3129bb58f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://addons/rider-plugin/screenshots/Toolbar.png" +dest_files=["res://.godot/imported/Toolbar.png-a521e2493bd3c08a829245b3129bb58f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/addons/rider-plugin/scripts/json_utils.gd b/addons/rider-plugin/scripts/json_utils.gd new file mode 100644 index 0000000..660086b --- /dev/null +++ b/addons/rider-plugin/scripts/json_utils.gd @@ -0,0 +1,23 @@ +@tool +## Simple JSON helpers for editor/runtime use. +## Keeps file and JSON parsing concerns out of feature code. +class_name JsonUtils + +static func load_from_file(path: String) -> Variant: + # Returns parsed JSON value (Dictionary/Array/etc.) or null on error. + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + push_warning("JsonUtils: Failed to open file: %s" % path) + return null + var text := file.get_as_text() + file.close() + var data: Variant = JSON.parse_string(text) + if data == null: + push_warning("JsonUtils: Invalid JSON in file: %s" % path) + return null + return data + +static func load_dict_from_file(path: String) -> Dictionary: + # Returns Dictionary or empty {} on error. + var data := load_from_file(path) as Dictionary + return data diff --git a/addons/rider-plugin/scripts/json_utils.gd.uid b/addons/rider-plugin/scripts/json_utils.gd.uid new file mode 100644 index 0000000..e780a44 --- /dev/null +++ b/addons/rider-plugin/scripts/json_utils.gd.uid @@ -0,0 +1 @@ +uid://bci4kmk7h4j6a diff --git a/addons/rider-plugin/scripts/locator/rider_locator_service.gd b/addons/rider-plugin/scripts/locator/rider_locator_service.gd new file mode 100644 index 0000000..8f54727 --- /dev/null +++ b/addons/rider-plugin/scripts/locator/rider_locator_service.gd @@ -0,0 +1,112 @@ +@tool +extends RefCounted +class_name RiderLocatorService + +var _installations_found: Array = [] +var _thread: Thread = null + +func get_installations() -> Array: + var result: Array = RiderLocator.new().get_installations() # from the gdextension + return result + +# todo +func fix_external_editor_if_supplied_in_commandline(_settings_service: EditorSettingsService, editor_settings: EditorSettings) -> bool: + # When Godot is started from Rider (or vice versa), we may receive the Rider path + # via command-line so we can keep Godot's external editor setting in sync. + # Supported form (only this one): + # --my_rider_path="/absolute/path/to/rider with possible spaces" + var args : Array = OS.get_cmdline_args() + var provided_rider_path := "" + for a_raw in args: + var a: String = str(a_raw) + if a.begins_with("--my_rider_path="): + provided_rider_path = a.substr("--my_rider_path=".length()) + break + + if provided_rider_path.is_empty(): + return false + + provided_rider_path = trim_quotes(provided_rider_path) + + # Validate existence (file or dir) + var looks_existing := FileAccess.file_exists(provided_rider_path) || DirAccess.dir_exists_absolute(provided_rider_path) + if looks_existing: + _settings_service.set_external_editor_path(editor_settings, provided_rider_path) + print("Rider path provided via CLI (my_rider_path): ", provided_rider_path) + return true + else: + push_warning("my_rider_path was provided but does not exist: %s" % [provided_rider_path]) + return false + +func trim_quotes(s: String) -> String: + if s.begins_with('"') and s.ends_with('"') and s.length() >= 2: + return s.substr(1, s.length() - 2) + return s + + +func add_selector_in_editor_interface(_settings_service: EditorSettingsService): + _update_selector(_installations_found) + + if _installations_found.is_empty() and _thread == null: + _thread = Thread.new() + _thread.start(_load_installations) + +func _load_installations() -> void: + var array: Array = get_installations() + call_deferred("_on_installations_loaded", array) + +func _on_installations_loaded(array: Array): + _installations_found = array + if _thread: + _thread.wait_to_finish() + _thread = null + _update_selector(_installations_found) + +func _notification(what: int) -> void: + if what == NOTIFICATION_PREDELETE and _thread != null: + _thread.wait_to_finish() + +func _update_selector(array: Array): + var name := "text_editor/external/editor" + var settings := EditorInterface.get_editor_settings() + + if !(settings.has_setting(name)): + settings.set(name, 0) + + var installations: Array = ["Custom"] + for element in array: + var display_name: String = element.get("display", "") + # Replace special characters that break PROPERTY_HINT_ENUM format + # Comma is the enum delimiter, colon is used for explicit value assignment + display_name = display_name.replace(",", " •").replace(":", " -") + installations.append(display_name) + var options :String = ",".join(installations) + + settings.add_property_info({ + "name": name, + "type":TYPE_INT, + "hint":PROPERTY_HINT_ENUM, + "hint_string": options + }) + + # Connect to settings changes to update external editor path when selection changes + if not settings.settings_changed.is_connected(_on_selection_changed): + settings.settings_changed.connect(_on_selection_changed.bind()) + +func _on_selection_changed() -> void: + var name := "text_editor/external/editor" + var settings := EditorInterface.get_editor_settings() + var selected_index: int = settings.get_setting(name) + + # Index 0 is "Custom", so user manages the path manually + if selected_index == 0: + return + + # Map to actual installation (offset by 1 because of "Custom" at index 0) + var installation_index := selected_index - 1 + var installations_array = _installations_found + if installation_index >= 0 and installation_index < installations_array.size(): + var installation = installations_array[installation_index] + var new_path: String = installation.get("path", "") + if not new_path.is_empty(): + EditorSettingsService.new().set_external_editor_path(settings, new_path) diff --git a/addons/rider-plugin/scripts/locator/rider_locator_service.gd.uid b/addons/rider-plugin/scripts/locator/rider_locator_service.gd.uid new file mode 100644 index 0000000..f7fcdaf --- /dev/null +++ b/addons/rider-plugin/scripts/locator/rider_locator_service.gd.uid @@ -0,0 +1 @@ +uid://cfsu1rbg0ypem diff --git a/addons/rider-plugin/scripts/presets/preset_applier.gd b/addons/rider-plugin/scripts/presets/preset_applier.gd new file mode 100644 index 0000000..b09d025 --- /dev/null +++ b/addons/rider-plugin/scripts/presets/preset_applier.gd @@ -0,0 +1,35 @@ +@tool +extends RefCounted +class_name PresetApplier + +var presets_path: String + +func _init(p_path: String) -> void: + presets_path = p_path + +func get_preset_key(is_active: bool) -> String: + return "on" if is_active else "off" + +func apply_preset(editor_settings: EditorSettings, is_active: bool) -> void: + var data: Dictionary = JsonUtils.load_dict_from_file(presets_path) + if data.is_empty(): + push_warning("Failed to load presets: %s" % presets_path) + return + + var new_preset_key := get_preset_key(is_active) + var previous_preset_key := get_preset_key(not is_active) + + if not data.has(new_preset_key): + push_warning("Preset '%s' not found in presets.json" % new_preset_key) + return + + var new_preset := data[new_preset_key] as Dictionary + # Reset keys from previous preset that are missing in the new preset + if data.has(previous_preset_key): + var previous_preset := data[previous_preset_key] as Dictionary + for key in previous_preset: + if not new_preset.has(key): + editor_settings.set_setting(str(key), editor_settings.property_get_revert(str(key))) + # Apply the new preset + for key in new_preset: + editor_settings.set_setting(str(key), new_preset[key]) diff --git a/addons/rider-plugin/scripts/presets/preset_applier.gd.uid b/addons/rider-plugin/scripts/presets/preset_applier.gd.uid new file mode 100644 index 0000000..af4fdde --- /dev/null +++ b/addons/rider-plugin/scripts/presets/preset_applier.gd.uid @@ -0,0 +1 @@ +uid://bjiaycxso8h8u diff --git a/addons/rider-plugin/scripts/settings/editor_settings_service.gd b/addons/rider-plugin/scripts/settings/editor_settings_service.gd new file mode 100644 index 0000000..e2f2d97 --- /dev/null +++ b/addons/rider-plugin/scripts/settings/editor_settings_service.gd @@ -0,0 +1,17 @@ +@tool +extends RefCounted +class_name EditorSettingsService + +func set_external_editor_path(editor_settings: EditorSettings, path: String) -> void: + editor_settings.set_setting("text_editor/external/exec_path", path) + +func has_valid_external_editor_path(editor_settings: EditorSettings) -> bool: + var has_setting: bool = editor_settings.has_setting("text_editor/external/exec_path") + if not has_setting: + return false + var path : String = editor_settings.get_setting("text_editor/external/exec_path") + var exists := not path.is_empty() && (FileAccess.file_exists(path) || DirAccess.dir_exists_absolute(path)) + return exists + +func set_use_external_editor(editor_settings: EditorSettings, enabled: bool) -> void: + editor_settings.set_setting("text_editor/external/use_external_editor", enabled) diff --git a/addons/rider-plugin/scripts/settings/editor_settings_service.gd.uid b/addons/rider-plugin/scripts/settings/editor_settings_service.gd.uid new file mode 100644 index 0000000..3f50ddc --- /dev/null +++ b/addons/rider-plugin/scripts/settings/editor_settings_service.gd.uid @@ -0,0 +1 @@ +uid://bdiu78ot0rrkc diff --git a/debug_menu.gd b/debug_menu.gd new file mode 100644 index 0000000..8bd31fe --- /dev/null +++ b/debug_menu.gd @@ -0,0 +1,192 @@ +extends Node +## Runtime debug panel. Toggle with F1. +## Reads DP.get_all() to build controls — adding a param to DP is all that's needed to show it here. + +var _panel: Control +var _status_label: Label +var _prev_mouse_mode: Input.MouseMode = Input.MOUSE_MODE_CAPTURED + + +func _ready() -> void: + _build_ui() + DP.any_changed.connect(_on_dp_changed) + + +func _build_ui() -> void: + var canvas := CanvasLayer.new() + canvas.layer = 128 + add_child(canvas) + + _panel = PanelContainer.new() + _panel.anchor_left = 1.0 + _panel.anchor_right = 1.0 + _panel.anchor_top = 0.0 + _panel.anchor_bottom = 1.0 + _panel.offset_left = -390.0 + _panel.offset_right = 0.0 + _panel.offset_top = 0.0 + _panel.offset_bottom = 0.0 + _panel.mouse_filter = Control.MOUSE_FILTER_STOP + _panel.visible = false + + var bg := StyleBoxFlat.new() + bg.bg_color = Color(0.08, 0.08, 0.10, 0.95) + _panel.add_theme_stylebox_override("panel", bg) + canvas.add_child(_panel) + + var root_vbox := VBoxContainer.new() + root_vbox.size_flags_horizontal = Control.SIZE_FILL + _panel.add_child(root_vbox) + + # ── Toolbar ─────────────────────────────────────────────────────────────── + var toolbar_margin := MarginContainer.new() + toolbar_margin.add_theme_constant_override("margin_left", 8) + toolbar_margin.add_theme_constant_override("margin_right", 8) + toolbar_margin.add_theme_constant_override("margin_top", 6) + toolbar_margin.add_theme_constant_override("margin_bottom", 4) + root_vbox.add_child(toolbar_margin) + + var toolbar := HBoxContainer.new() + toolbar.add_theme_constant_override("separation", 6) + toolbar_margin.add_child(toolbar) + + var title := Label.new() + title.text = "DEBUG [F1]" + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + toolbar.add_child(title) + + _status_label = Label.new() + _status_label.modulate = Color(0.4, 1.0, 0.5) + toolbar.add_child(_status_label) + + var save_btn := Button.new() + save_btn.text = "Save" + save_btn.pressed.connect(_on_save) + toolbar.add_child(save_btn) + + var reset_btn := Button.new() + reset_btn.text = "Reset All" + reset_btn.pressed.connect(DP.reset_all) + toolbar.add_child(reset_btn) + + root_vbox.add_child(HSeparator.new()) + + # ── Scrollable content ──────────────────────────────────────────────────── + var scroll := ScrollContainer.new() + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + scroll.size_flags_horizontal = Control.SIZE_FILL + root_vbox.add_child(scroll) + + var content_margin := MarginContainer.new() + content_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL + content_margin.add_theme_constant_override("margin_left", 8) + content_margin.add_theme_constant_override("margin_right", 8) + content_margin.add_theme_constant_override("margin_top", 4) + content_margin.add_theme_constant_override("margin_bottom", 8) + scroll.add_child(content_margin) + + var content := VBoxContainer.new() + content.size_flags_horizontal = Control.SIZE_EXPAND_FILL + content_margin.add_child(content) + + _build_params(content) + + +func _build_params(content: VBoxContainer) -> void: + # Group keys by section preserving registration order + var sections: Dictionary = {} + for key: String in DP.get_all(): + var sec: String = DP.get_all()[key]["section"] + if not sections.has(sec): + sections[sec] = [] + sections[sec].append(key) + + for section: String in sections: + content.add_child(HSeparator.new()) + + var header := Label.new() + header.text = section.to_upper() + header.modulate = Color(0.75, 0.85, 1.0) + content.add_child(header) + + for key: String in sections[section]: + _add_float_row(content, key, DP.get_all()[key]) + + +func _add_float_row(parent: VBoxContainer, key: String, meta: Dictionary) -> void: + var row := HBoxContainer.new() + row.size_flags_horizontal = Control.SIZE_FILL + parent.add_child(row) + + var lbl := Label.new() + lbl.text = key.replace("_", " ") + lbl.custom_minimum_size.x = 140 + lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + row.add_child(lbl) + + var slider := HSlider.new() + slider.min_value = meta["min"] + slider.max_value = meta["max"] + slider.step = meta["step"] + slider.value = meta["value"] + slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL + slider.custom_minimum_size.x = 60 + row.add_child(slider) + + var spin := SpinBox.new() + spin.min_value = meta["min"] + spin.max_value = meta["max"] + spin.step = meta["step"] + spin.value = meta["value"] + spin.custom_minimum_size.x = 85 + row.add_child(spin) + + # Slider → SpinBox + DP (use no_signal to avoid echo) + slider.value_changed.connect(func(v: float) -> void: + spin.set_value_no_signal(v) + DP.set_value(key, v) + ) + # SpinBox → Slider + DP + spin.value_changed.connect(func(v: float) -> void: + slider.set_value_no_signal(v) + DP.set_value(key, v) + ) + # External changes (reset, load) → sync both controls + DP.any_changed.connect(func(changed_key: String, _val: Variant) -> void: + if changed_key != key and changed_key != "__all__": + return + var v := DP.f(key) + slider.set_value_no_signal(v) + spin.set_value_no_signal(v) + ) + + +func _on_save() -> void: + DP.save() + _status_label.text = "Saved!" + get_tree().create_timer(1.5).timeout.connect(func() -> void: + _status_label.text = "" + ) + + +func _on_dp_changed(_key: String, _value: Variant) -> void: + pass # individual rows handle their own updates via signal + + +func _input(event: InputEvent) -> void: + if _panel.visible and Input.is_key_pressed(KEY_CTRL) and event is InputEventMouseMotion: + get_viewport().set_input_as_handled() + + +func _unhandled_input(event: InputEvent) -> void: + if not (event is InputEventKey and event.pressed and not event.echo): + return + if (event as InputEventKey).keycode != KEY_F1: + return + _panel.visible = not _panel.visible + if _panel.visible: + _prev_mouse_mode = Input.mouse_mode + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + else: + Input.mouse_mode = _prev_mouse_mode + get_viewport().set_input_as_handled() diff --git a/debug_params.gd b/debug_params.gd new file mode 100644 index 0000000..0d9a61e --- /dev/null +++ b/debug_params.gd @@ -0,0 +1,98 @@ +extends Node +## Central registry for all runtime-tunable parameters. +## Add a new param with one _reg_f() call in _register_all() — the menu picks it up automatically. +## Access: DP.f("key") • Persist: DP.save() / DP.load_saved() • Reset: DP.reset_all() + +signal any_changed(key: String, value: Variant) + +const SAVE_PATH := "user://debug_params.cfg" + +var _params: Dictionary = {} + + +func _ready() -> void: + _register_all() + load_saved() + + +func _register_all() -> void: + # ── Movement ────────────────────────────────────────────────────────────── + _reg_f("Movement", "walk_speed", 6.0, 1.0, 40.0) + _reg_f("Movement", "charge_speed", 18.0, 5.0, 60.0) + _reg_f("Movement", "walk_accel", 14.0, 1.0, 80.0) + _reg_f("Movement", "charge_accel", 40.0, 5.0, 120.0) + _reg_f("Movement", "deceleration", 12.0, 1.0, 50.0) + _reg_f("Movement", "charge_decel", 3.5, 0.5, 20.0) + _reg_f("Movement", "jump_velocity", 4.5, 1.0, 20.0) + # ── Turning ─────────────────────────────────────────────────────────────── + _reg_f("Turning", "walk_turn_fast", 8.0, 0.5, 25.0) + _reg_f("Turning", "walk_turn_slow", 3.5, 0.5, 15.0) + _reg_f("Turning", "charge_turn", 0.55, 0.05, 3.0, 0.01) + _reg_f("Turning", "visual_turn_speed", 12.0, 1.0, 40.0) + # ── Dust ────────────────────────────────────────────────────────────────── + _reg_f("Dust", "dust_lifetime", 0.9, 0.1, 4.0) + _reg_f("Dust", "dust_explosiveness", 0.35, 0.0, 1.0) + _reg_f("Dust", "dust_spread", 45.0, 5.0, 90.0) + _reg_f("Dust", "dust_gravity_y", -1.5,-15.0, 0.0) + _reg_f("Dust", "walk_scale_min", 0.35, 0.05, 3.0) + _reg_f("Dust", "walk_scale_max", 0.65, 0.05, 3.0) + _reg_f("Dust", "walk_vel_min", 0.8, 0.1, 12.0) + _reg_f("Dust", "walk_vel_max", 1.8, 0.1, 12.0) + _reg_f("Dust", "charge_scale_min", 1.0, 0.05, 6.0) + _reg_f("Dust", "charge_scale_max", 2.2, 0.05, 6.0) + _reg_f("Dust", "charge_vel_min", 2.8, 0.1, 20.0) + _reg_f("Dust", "charge_vel_max", 5.5, 0.1, 20.0) + + +func _reg_f(section: String, key: String, default: float, + min_val: float, max_val: float, step: float = 0.05) -> void: + _params[key] = { + "value": default, + "default": default, + "section": section, + "type": TYPE_FLOAT, + "min": min_val, + "max": max_val, + "step": step, + } + + +## Read a float param. Panics on unknown key — intentional: typos should be loud. +func f(key: String) -> float: + return _params[key]["value"] as float + + +func set_value(key: String, value: Variant) -> void: + if not _params.has(key): + return + _params[key]["value"] = value + any_changed.emit(key, value) + + +## Returns the full metadata dict — used by DebugMenu to build UI. +func get_all() -> Dictionary: + return _params + + +func save() -> void: + var cfg := ConfigFile.new() + for key: String in _params: + var p: Dictionary = _params[key] + cfg.set_value(p["section"], key, p["value"]) + cfg.save(SAVE_PATH) + + +func load_saved() -> void: + var cfg := ConfigFile.new() + if cfg.load(SAVE_PATH) != OK: + return + for section: String in cfg.get_sections(): + for key: String in cfg.get_section_keys(section): + if _params.has(key): + _params[key]["value"] = cfg.get_value(section, key) + + +func reset_all() -> void: + for key: String in _params: + _params[key]["value"] = _params[key]["default"] + any_changed.emit("__all__", null) diff --git a/player.gd b/player.gd index ae9df19..74d1f28 100644 --- a/player.gd +++ b/player.gd @@ -1,19 +1,6 @@ extends CharacterBody3D -const WALK_SPEED = 6.0 -const CHARGE_SPEED = 180.0 -const WALK_ACCEL = 14.0 -const CHARGE_ACCEL = 40.0 -const DECELERATION = 12.0 -const CHARGE_DECEL = 3.5 - -const WALK_TURN_FAST = 8.0 -const WALK_TURN_SLOW = 3.5 -const CHARGE_TURN = 0.55 - -const JUMP_VELOCITY = 4.5 - -# Hoof positions in bull_v10 local space — tweak y/z if hooves don't line up with the model +# Hoof positions in bull_v10 local space — adjust y/z if puffs don't land on the hooves const HOOF_OFFSETS: Array = [ Vector3(-0.30, -0.25, -0.50), Vector3( 0.30, -0.25, -0.50), @@ -21,8 +8,6 @@ const HOOF_OFFSETS: Array = [ Vector3( 0.30, -0.25, 0.60), ] -@export var visual_turn_speed: float = 12.0 - @onready var camera_pivot: Node3D = $SpringArmPivot @onready var cube_guy: Node3D = $bull_v10 @@ -37,6 +22,7 @@ var _dust_state: _DustState = _DustState.NONE func _ready() -> void: _setup_hoof_dust() + DP.any_changed.connect(_on_dp_changed) func _setup_hoof_dust() -> void: @@ -46,7 +32,6 @@ func _setup_hoof_dust() -> void: mat.vertex_color_use_as_albedo = true mat.cull_mode = BaseMaterial3D.CULL_DISABLED - # 4×2 sphere = chunky low-poly diamond shape var sphere := SphereMesh.new() sphere.radius = 0.10 sphere.height = 0.20 @@ -54,12 +39,10 @@ func _setup_hoof_dust() -> void: sphere.rings = 2 sphere.material = mat - # sand colour, fades to transparent over lifetime var ramp := Gradient.new() ramp.set_color(0, Color(0.80, 0.69, 0.46, 1.0)) ramp.set_color(1, Color(0.80, 0.69, 0.46, 0.0)) - # puff blooms up then vanishes var scale_curve := Curve.new() scale_curve.add_point(Vector2(0.0, 0.2)) scale_curve.add_point(Vector2(0.4, 1.0)) @@ -70,17 +53,17 @@ func _setup_hoof_dust() -> void: p.position = offset p.emitting = false p.amount = 12 - p.lifetime = 0.9 - p.explosiveness = 0.35 + p.lifetime = DP.f("dust_lifetime") + p.explosiveness = DP.f("dust_explosiveness") p.randomness = 0.6 p.local_coords = false p.direction = Vector3(0.0, 1.0, 0.0) - p.spread = 45.0 - p.gravity = Vector3(0.0, -1.5, 0.0) - p.initial_velocity_min = 0.8 - p.initial_velocity_max = 1.8 - p.scale_amount_min = 0.4 - p.scale_amount_max = 0.7 + p.spread = DP.f("dust_spread") + p.gravity = Vector3(0.0, DP.f("dust_gravity_y"), 0.0) + p.initial_velocity_min = DP.f("walk_vel_min") + p.initial_velocity_max = DP.f("walk_vel_max") + p.scale_amount_min = DP.f("walk_scale_min") + p.scale_amount_max = DP.f("walk_scale_max") p.mesh = sphere p.color_ramp = ramp p.scale_amount_curve = scale_curve @@ -93,29 +76,42 @@ func _set_dust_state(new_state: _DustState) -> void: return _dust_state = new_state for p: CPUParticles3D in _hoof_emitters: + p.lifetime = DP.f("dust_lifetime") + p.explosiveness = DP.f("dust_explosiveness") + p.spread = DP.f("dust_spread") + p.gravity = Vector3(0.0, DP.f("dust_gravity_y"), 0.0) match new_state: _DustState.NONE: p.emitting = false _DustState.WALK: - p.scale_amount_min = 0.35 - p.scale_amount_max = 0.65 - p.initial_velocity_min = 0.8 - p.initial_velocity_max = 1.8 + p.scale_amount_min = DP.f("walk_scale_min") + p.scale_amount_max = DP.f("walk_scale_max") + p.initial_velocity_min = DP.f("walk_vel_min") + p.initial_velocity_max = DP.f("walk_vel_max") p.emitting = true _DustState.CHARGE: - p.scale_amount_min = 1.0 - p.scale_amount_max = 2.2 - p.initial_velocity_min = 2.8 - p.initial_velocity_max = 5.5 + p.scale_amount_min = DP.f("charge_scale_min") + p.scale_amount_max = DP.f("charge_scale_max") + p.initial_velocity_min = DP.f("charge_vel_min") + p.initial_velocity_max = DP.f("charge_vel_max") p.emitting = true +func _on_dp_changed(_key: String, _val: Variant) -> void: + # Force re-apply so slider changes take effect without needing a state transition + if _hoof_emitters.is_empty() or _dust_state == _DustState.NONE: + return + var prev := _dust_state + _dust_state = _DustState.NONE + _set_dust_state(prev) + + func _physics_process(delta: float) -> void: if not is_on_floor(): velocity += get_gravity() * delta if Input.is_action_just_pressed("jump") and is_on_floor(): - velocity.y = JUMP_VELOCITY + velocity.y = DP.f("jump_velocity") var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back") var cam_basis := camera_pivot.global_transform.basis @@ -123,35 +119,36 @@ func _physics_process(delta: float) -> void: var flat_right := Vector3(cam_basis.x.x, 0.0, cam_basis.x.z).normalized() var direction := (flat_forward * -input_dir.y + flat_right * input_dir.x).normalized() + var charging := Input.is_action_pressed(&"charge") + if direction: var target_angle := atan2(direction.x, direction.z) if current_speed < 0.01: facing_angle = target_angle - var charging := Input.is_key_pressed(KEY_SHIFT) - var speed_t: float = clampf(current_speed / WALK_SPEED, 0.0, 1.0) - var turn_rate: float = CHARGE_TURN if charging else lerpf(WALK_TURN_FAST, WALK_TURN_SLOW, speed_t) + var speed_t: float = clampf(current_speed / DP.f("walk_speed"), 0.0, 1.0) + var turn_rate: float = DP.f("charge_turn") if charging \ + else lerpf(DP.f("walk_turn_fast"), DP.f("walk_turn_slow"), speed_t) var diff := wrapf(target_angle - facing_angle, -PI, PI) facing_angle += clampf(diff, -turn_rate * delta, turn_rate * delta) - var top_speed: float = CHARGE_SPEED if charging else WALK_SPEED - var accel: float = CHARGE_ACCEL if charging else WALK_ACCEL + var top_speed: float = DP.f("charge_speed") if charging else DP.f("walk_speed") + var accel: float = DP.f("charge_accel") if charging else DP.f("walk_accel") current_speed = move_toward(current_speed, top_speed, accel * delta) else: - var charging := Input.is_key_pressed(KEY_SHIFT) - var decel: float = CHARGE_DECEL if charging else DECELERATION + var decel: float = DP.f("charge_decel") if charging else DP.f("deceleration") current_speed = move_toward(current_speed, 0.0, decel * delta) velocity.x = sin(facing_angle) * current_speed velocity.z = cos(facing_angle) * current_speed - cube_guy.rotation.y = lerp_angle(cube_guy.rotation.y, facing_angle, delta * visual_turn_speed) + cube_guy.rotation.y = lerp_angle(cube_guy.rotation.y, facing_angle, + delta * DP.f("visual_turn_speed")) move_and_slide() - var charging := Input.is_key_pressed(KEY_SHIFT) if current_speed > 0.5 and is_on_floor(): _set_dust_state(_DustState.CHARGE if charging else _DustState.WALK) else: diff --git a/project.godot b/project.godot index 336aaaf..fb054ea 100644 --- a/project.godot +++ b/project.godot @@ -67,6 +67,11 @@ charge={ ] } +[autoload] + +DP="*res://debug_params.gd" +DebugMenu="*res://debug_menu.gd" + [physics] 3d/physics_engine="Jolt Physics"