17 Commits

108 changed files with 5702 additions and 1055 deletions
+1
View File
@@ -2,3 +2,4 @@ disable:
- class-definitions-order - class-definitions-order
- no-else-return - no-else-return
max-line-length: 120 max-line-length: 120
max-file-lines: 1200
+1
View File
@@ -2,6 +2,7 @@ extends Node3D
func _ready() -> void: func _ready() -> void:
add_to_group(&"public") # excluded from debug overlays — see publikum_controller.gd
var anim := get_node_or_null(^"AnimationPlayer") as AnimationPlayer var anim := get_node_or_null(^"AnimationPlayer") as AnimationPlayer
if anim: if anim:
anim.play(&"Juubeldab") anim.play(&"Juubeldab")
BIN
View File
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://cl54dt38yj2x"
path="res://.godot/imported/Arena.glb-bf6b057edaab2fa44513d7f705517361.scn"
[deps]
source_file="res://Assets/Arena.glb"
dest_files=["res://.godot/imported/Arena.glb-bf6b057edaab2fa44513d7f705517361.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

+44
View File
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dk6xuwwa0mfpe"
path.s3tc="res://.godot/imported/Arena_sand.png-416de2afe03241df088061e1a5f831af.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "c8b9b7d6205ce80dfcfaa38152b996fe"
}
[deps]
source_file="res://Assets/Arena_sand.png"
dest_files=["res://.godot/imported/Arena_sand.png-416de2afe03241df088061e1a5f831af.s3tc.ctex"]
[params]
compress/mode=2
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=true
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=0
Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

+44
View File
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b4gkpa7jypcsm"
path.s3tc="res://.godot/imported/Arena_wall.png-e67bd4bbd2f60583bfc14dd8b7687eef.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "0e7ed34c3c812d96732bd32f9dca0c93"
}
[deps]
source_file="res://Assets/Arena_wall.png"
dest_files=["res://.godot/imported/Arena_wall.png-e67bd4bbd2f60583bfc14dd8b7687eef.s3tc.ctex"]
[params]
compress/mode=2
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=true
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=0
Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

+44
View File
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://yercpq3y2o0x"
path.s3tc="res://.godot/imported/Arena_wood.png-b4ed20d66b4d375396b0be2f2f863d80.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "23a7a951553ffe271c47693fd71314ca"
}
[deps]
source_file="res://Assets/Arena_wood.png"
dest_files=["res://.godot/imported/Arena_wood.png-b4ed20d66b4d375396b0be2f2f863d80.s3tc.ctex"]
[params]
compress/mode=2
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=true
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=0
Binary file not shown.
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dxe72ig8gfbcg"
path="res://.godot/imported/arena model2026.glb-299db01cae891e5005f8ac1e6c83d0a6.scn"
[deps]
source_file="res://Assets/arena model2026.glb"
dest_files=["res://.godot/imported/arena model2026.glb-299db01cae891e5005f8ac1e6c83d0a6.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

@@ -2,16 +2,16 @@
importer="texture" importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://ypxkr3vla3fr" uid="uid://d166u18p3wqiy"
path="res://.godot/imported/HUD_0000_ability_JALG.png-c430df7817ae1f22045a2a27b06f60a8.ctex" path="res://.godot/imported/crowd_clap_sheet.png-a110e7d663af8fcbe9ebf5de44188c85.ctex"
metadata={ metadata={
"vram_texture": false "vram_texture": false
} }
[deps] [deps]
source_file="res://HUD/HUD_0000_ability_JALG.png" source_file="res://Assets/crowd_clap_sheet.png"
dest_files=["res://.godot/imported/HUD_0000_ability_JALG.png-c430df7817ae1f22045a2a27b06f60a8.ctex"] dest_files=["res://.godot/imported/crowd_clap_sheet.png-a110e7d663af8fcbe9ebf5de44188c85.ctex"]
[params] [params]
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
uid://csxxlnq74fej4
+12
View File
@@ -0,0 +1,12 @@
extends Node3D
@onready var animation_player: AnimationPlayer = $AnimationPlayer
func _ready():
animation_player.play("Taunt")
animation_player.animation_finished.connect(_on_animation_finished)
func _on_animation_finished(anim_name):
if anim_name == "Taunt":
animation_player.play("Taunt")
+1
View File
@@ -0,0 +1 @@
uid://dycnoiadljbb1
Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

@@ -2,16 +2,16 @@
importer="texture" importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://w4yovxyg0104" uid="uid://bxv1uuymonrp6"
path="res://.godot/imported/HUD_0001_ability_SLAM.png-7c5f88306e1af474db2c2073888feab7.ctex" path="res://.godot/imported/menu_bg.png-3906063022fa3c81090e3c663f8866b0.ctex"
metadata={ metadata={
"vram_texture": false "vram_texture": false
} }
[deps] [deps]
source_file="res://HUD/HUD_0001_ability_SLAM.png" source_file="res://Assets/menu_bg.png"
dest_files=["res://.godot/imported/HUD_0001_ability_SLAM.png-7c5f88306e1af474db2c2073888feab7.ctex"] dest_files=["res://.godot/imported/menu_bg.png-3906063022fa3c81090e3c663f8866b0.ctex"]
[params] [params]
Binary file not shown.
+22
View File
@@ -90,13 +90,28 @@ bash run_tests.sh
# Individual tests: # Individual tests:
gdlint *.gd tests/*.gd # GDScript lint (gdtoolkit, installed via uv) gdlint *.gd tests/*.gd # GDScript lint (gdtoolkit, installed via uv)
godot --headless --script tests/logic_test.gd # pure logic, ~2 s godot --headless --script tests/logic_test.gd # pure logic, ~2 s
godot --headless --script tests/performance_test.gd # frame-budget regression guard, ~4 s
godot --headless --script tests/gameplay_test.gd # full scene, bone sanity, ~4 s godot --headless --script tests/gameplay_test.gd # full scene, bone sanity, ~4 s
# Gameplay test with real rendering (inspect frame strip visually): # Gameplay test with real rendering (inspect frame strip visually):
godot --script tests/gameplay_test.gd # opens a window, saves real screenshots godot --script tests/gameplay_test.gd # opens a window, saves real screenshots
# Output: tests/output/gameplay/motion_00..04.png, ragdoll_trigger.png, ragdoll_result.png # Output: tests/output/gameplay/motion_00..04.png, ragdoll_trigger.png, ragdoll_result.png
# Difficulty simulation (balance tool, ~2 min):
godot --headless --script tests/difficulty_sim.gd # drives a bot bull vs one matador
``` ```
### Difficulty simulation (`tests/difficulty_sim.gd`)
A balance tool, not a pass/fail test. It drives a kinematic "bot bull" against one
real matador across behaviour profiles (CHARGER / KITER / PASSIVE) and reports the
matador's win rate (how often it gores the bull). Use it to tune matador difficulty
from data. The bot bull has NO abilities (it only kills by ramming), so it can't use
roll/slam/kick — real skilled play beats the matador more than these numbers imply;
read the rates as "how well the matador threatens each movement pattern." A rough
target is CHARGER high (blind charging is punished), KITER moderate (throws are a
dodgeable threat), PASSIVE high (standing still dies). Note: as a `--script` entry it
reads the `DP` autoload via `/root/DP`, not the global identifier.
### What the gameplay test catches ### What the gameplay test catches
| Check | What it detects | | Check | What it detects |
|---|---| |---|---|
@@ -107,6 +122,13 @@ godot --script tests/gameplay_test.gd # opens a window, saves re
Headless screenshots will be blank (Forward Plus has no display). Run without `--headless` to get real frame strips. Headless screenshots will be blank (Forward Plus has no display). Run without `--headless` to get real frame strips.
### What the performance test catches
Loads the full scene with `STRESS_MATADORS` (20) matadors, samples per-frame time
during normal play and during a mass-ragdoll spike, and fails if the average
exceeds 16 ms or any single frame exceeds 100 ms. Budgets are generous regression
guards (not a target frame rate); the measured avg/peak/fps print every run so a
gradual creep shows up before it trips the ceiling.
## Godot 4.x specifics ## Godot 4.x specifics
- `wrapf` / `wrap` instead of manual modulo for angles - `wrapf` / `wrap` instead of manual modulo for angles
Binary file not shown.
@@ -0,0 +1,41 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bxm7c6ldrdiq5"
path.bptc="res://.godot/imported/citrus_orchard_road_puresky_4k.hdr-262868e8787abf05f7044da72beefce7.bptc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
[deps]
source_file="res://HDRI/citrus_orchard_road_puresky_4k.hdr"
dest_files=["res://.godot/imported/citrus_orchard_road_puresky_4k.hdr-262868e8787abf05f7044da72beefce7.bptc.ctex"]
[params]
compress/mode=2
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=true
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=0
Binary file not shown.
+41
View File
@@ -0,0 +1,41 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://3syhacswepyj"
path.bptc="res://.godot/imported/kloppenheim_06_puresky_4k.hdr-026f0eabdfce50035e2f8417ce3e0060.bptc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
[deps]
source_file="res://HDRI/kloppenheim_06_puresky_4k.hdr"
dest_files=["res://.godot/imported/kloppenheim_06_puresky_4k.hdr-026f0eabdfce50035e2f8417ce3e0060.bptc.ctex"]
[params]
compress/mode=2
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=true
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=0
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

@@ -2,16 +2,16 @@
importer="texture" importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://dnv8w5h32ck7m" uid="uid://cyscxku0nw055"
path="res://.godot/imported/HUD_0002_ability_DASH.png-011f272dda65c06c407720ec201ad92e.ctex" path="res://.godot/imported/DASH.png-02cbc39ec03a0c79526d7dc333659c48.ctex"
metadata={ metadata={
"vram_texture": false "vram_texture": false
} }
[deps] [deps]
source_file="res://HUD/HUD_0002_ability_DASH.png" source_file="res://HUD/DASH.png"
dest_files=["res://.godot/imported/HUD_0002_ability_DASH.png-011f272dda65c06c407720ec201ad92e.ctex"] dest_files=["res://.godot/imported/DASH.png-02cbc39ec03a0c79526d7dc333659c48.ctex"]
[params] [params]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bdnc3jw1ykydi"
path="res://.godot/imported/KICK.png-5482be80e8176ed7a0ae69a48ca1f5ed.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://HUD/KICK.png"
dest_files=["res://.godot/imported/KICK.png-5482be80e8176ed7a0ae69a48ca1f5ed.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d25i88cixy4pn"
path="res://.godot/imported/ROLL.png-306dafcb4b6c13eded9a925aa6d2f62a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://HUD/ROLL.png"
dest_files=["res://.godot/imported/ROLL.png-306dafcb4b6c13eded9a925aa6d2f62a.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bxplqrp4mc5yh"
path="res://.godot/imported/SLAM.png-b5036a739251be1240a24080e28d7a8e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://HUD/SLAM.png"
dest_files=["res://.godot/imported/SLAM.png-b5036a739251be1240a24080e28d7a8e.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
+137
View File
@@ -0,0 +1,137 @@
[gd_scene format=3 uid="uid://c4df4qgpc3d5g"]
[ext_resource type="PackedScene" uid="uid://eu2lqjhowj4t" path="res://Assets/Matador.glb" id="1_eicjn"]
[ext_resource type="Script" uid="uid://dycnoiadljbb1" path="res://Assets/matador_intro_menu.gd" id="2_5jr0i"]
[ext_resource type="PackedScene" uid="uid://dio67r8ambe7h" path="res://Assets/arena_placeholder_v01.glb" id="3_1v6n4"]
[ext_resource type="PackedScene" uid="uid://dxe72ig8gfbcg" path="res://Assets/arena model2026.glb" id="3_5jr0i"]
[ext_resource type="PackedScene" uid="uid://crtm0xtiql3r8" path="res://Player.tscn" id="4_eicjn"]
[ext_resource type="Texture2D" uid="uid://3syhacswepyj" path="res://HDRI/kloppenheim_06_puresky_4k.hdr" id="6_1v6n4"]
[sub_resource type="CylinderShape3D" id="CylinderShape3D_eicjn"]
[sub_resource type="PanoramaSkyMaterial" id="PanoramaSkyMaterial_w7j4y"]
panorama = ExtResource("6_1v6n4")
energy_multiplier = 5.6
[sub_resource type="Sky" id="Sky_3el7n"]
sky_material = SubResource("PanoramaSkyMaterial_w7j4y")
[sub_resource type="Environment" id="Environment_5jr0i"]
background_mode = 2
background_energy_multiplier = 0.18
sky = SubResource("Sky_3el7n")
ambient_light_color = Color(0.31061712, 0.31061712, 0.3106171, 1)
ambient_light_energy = 16.0
ssao_enabled = true
ssao_radius = 1.17
ssao_intensity = 0.0
glow_intensity = 0.34
glow_strength = 0.29
glow_bloom = 0.1
fog_light_color = Color(2.211, 2.211, 2.211, 1)
fog_light_energy = 0.01
fog_density = 0.0399
volumetric_fog_enabled = true
volumetric_fog_density = 0.0005
volumetric_fog_emission = Color(1, 1, 1, 1)
volumetric_fog_emission_energy = 7.85
volumetric_fog_anisotropy = 0.9
volumetric_fog_length = 69.07
[sub_resource type="CameraAttributesPractical" id="CameraAttributesPractical_5jr0i"]
dof_blur_far_enabled = true
dof_blur_far_distance = 26.01
dof_blur_near_enabled = true
dof_blur_near_distance = 2.6
dof_blur_near_transition = 5.0
[node name="IntroMenu" type="Node3D" unique_id=2130618263]
[node name="Matador" parent="." unique_id=166852612 instance=ExtResource("1_eicjn")]
transform = Transform3D(-0.56715536, 0, -0.8236109, 0, 1, 0, 0.8236109, 0, -0.56715536, 5.2114463, -0.08998108, 1.8927763)
script = ExtResource("2_5jr0i")
[node name="Skeleton3D" parent="Matador/Armature" parent_id_path=PackedInt32Array(166852612, 1269629432) index="0" unique_id=978366810]
bones/0/position = Vector3(0.056660548, 0.6424767, -0.0167365)
bones/0/rotation = Quaternion(0.013179442, -0.2660563, 0.073319815, 0.9610747)
bones/1/rotation = Quaternion(0.06234612, 0.049236443, 0.08183311, 0.99347484)
bones/2/rotation = Quaternion(-0.073883414, -0.049860783, -0.17668208, 0.9802238)
bones/3/rotation = Quaternion(0.1986702, -0.17794952, -0.71790075, 0.6430262)
bones/4/rotation = Quaternion(0.34491858, -0.4473084, -0.25076818, 0.7861691)
bones/5/rotation = Quaternion(0.2577085, 0.103201896, -0.6748433, 0.68375605)
bones/6/rotation = Quaternion(1.7417646e-08, -1.0305205e-07, -0.05870078, 0.99827564)
bones/7/rotation = Quaternion(0.24033272, 0.3103031, 0.5222128, 0.7571301)
bones/8/rotation = Quaternion(5.5530215e-08, 1.2911333e-07, 0.055464897, 0.99846065)
bones/9/rotation = Quaternion(-6.3051346e-09, -3.6333955e-07, 0.005667881, 0.999984)
bones/10/rotation = Quaternion(2.5839595e-08, 9.468444e-08, 0.058700778, 0.99827564)
bones/11/rotation = Quaternion(0.39202607, 0.47060946, 0.57993996, 0.53713304)
bones/12/rotation = Quaternion(0.8712108, 0.008231261, 0.48523128, 0.073990606)
bones/13/rotation = Quaternion(-0.056263044, 0.0027001738, 0.3080972, 0.9496859)
bones/14/rotation = Quaternion(0.016082464, 0.024660494, -0.86586916, 0.4994036)
bones/15/rotation = Quaternion(0.6067664, -0.3062479, -0.7163194, 0.15790276)
bones/16/rotation = Quaternion(-0.0731876, -0.0023153822, -0.47496143, 0.87695485)
bones/17/rotation = Quaternion(-0.11824294, -0.17776872, 0.80557114, 0.55269533)
bones/18/rotation = Quaternion(0.5305965, -0.28927496, 0.76720744, -0.21489605)
bones/20/position = Vector3(0.22012731, 0.38421977, 0.36399037)
bones/21/position = Vector3(-0.23671798, 0.052281797, -0.027087089)
bones/21/rotation = Quaternion(0.5078295, -0.4920459, -0.4920459, 0.50782955)
bones/22/position = Vector3(-0.39721552, 0.31719273, 0.3595722)
bones/23/position = Vector3(-0.9167187, 1.3206767, 0.42621902)
bones/23/rotation = Quaternion(0.9993525, -0.0021175682, 0.03502641, -0.00795782)
bones/24/rotation = Quaternion(0.08874816, -0.018719967, -0.04671622, 0.99478185)
bones/25/rotation = Quaternion(0.09634846, -0.0268489, -0.04050248, 0.9941608)
[node name="matador" parent="Matador/Armature/Skeleton3D" index="0" unique_id=256196413]
transform = Transform3D(0.8642424, 0, 0.50307554, 0, 1, 0, -0.50307554, 0, 0.8642424, 0, 0, 0)
[node name="Muleta" parent="Matador/Armature/Skeleton3D" index="1" unique_id=988244930]
cast_shadow = 0
[node name="Camera3D" type="Camera3D" parent="." unique_id=1531255459]
transform = Transform3D(0.42176014, 0.2362673, 0.8753835, -0.10687317, 0.97167695, -0.21076548, -0.900387, -0.0046625203, 0.4350652, 8.41945, 0.17132203, 2.0652685)
fov = 41.6
near = 0.001
[node name="arena model2026" parent="." unique_id=179544183 instance=ExtResource("3_5jr0i")]
transform = Transform3D(-0.83746713, 0, 0.54648757, 0, 0.99999994, 0, -0.54648757, 0, -0.83746713, 3.8733478, -0.6, -1.1080065)
[node name="arena_placeholder_v01" parent="." unique_id=1343590504 instance=ExtResource("3_1v6n4")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -1.3344436, 0, -1.5401263)
visible = false
[node name="StaticBody3D" type="StaticBody3D" parent="arena_placeholder_v01" unique_id=340133176]
[node name="CollisionShape3D" type="CollisionShape3D" parent="arena_placeholder_v01/StaticBody3D" unique_id=201484167]
transform = Transform3D(-33.265, 4.9526625e-07, -2.9081186e-06, 5.0228755e-06, 3.28, 0, -2.9081186e-06, 4.329755e-14, 33.265, 0, -3.3065948, 0)
shape = SubResource("CylinderShape3D_eicjn")
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=660229295]
transform = Transform3D(0.9491592, 0.31479403, -0.0014368258, -0.28033537, 0.84731936, 0.45106864, 0.14321098, -0.4277331, 0.89248836, 1.1162033, 7.3956842, -4.480528)
light_energy = 1.32
shadow_enabled = true
shadow_blur = 8.689
[node name="DirectionalLight3D2" type="DirectionalLight3D" parent="." unique_id=498871251]
transform = Transform3D(0.835524, -0.5376153, 0.11344351, -0.48260456, -0.6193575, 0.6192651, -0.26266432, -0.572159, -0.7769435, 1.1162033, 7.3956842, -4.480528)
visible = false
light_energy = 0.415
light_indirect_energy = 16.0
light_volumetric_fog_energy = 1.11
shadow_blur = 10.0
[node name="Player" parent="." unique_id=165510044 instance=ExtResource("4_eicjn")]
transform = Transform3D(0.9563367, 0, 0.29226705, 0, 1, 0, -0.29226705, 0, 0.9563367, 3.0920272, 0.39692497, -4.302477)
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=114793752]
environment = SubResource("Environment_5jr0i")
camera_attributes = SubResource("CameraAttributesPractical_5jr0i")
[node name="SpotLight3D" type="SpotLight3D" parent="." unique_id=1428020323]
transform = Transform3D(0.94930077, -0.05846006, -0.30888584, 0.21308771, 0.8420559, 0.4955155, 0.23113126, -0.53621286, 0.81182146, -20.807917, 10.690277, 20.135067)
visible = false
light_energy = 16.0
light_volumetric_fog_energy = 16.0
shadow_enabled = true
spot_range = 84.316696
[editable path="Matador"]
+264
View File
@@ -0,0 +1,264 @@
[gd_scene load_steps=5 format=3 uid="uid://bmnmenu0intr1"]
[ext_resource type="Script" path="res://main_menu.gd" id="1_menu"]
[ext_resource type="VideoStream" path="res://Assets/intro_bg.ogv" id="2_video"]
[sub_resource type="StyleBoxFlat" id="sb_normal"]
content_margin_left = 16.0
content_margin_top = 8.0
content_margin_right = 16.0
content_margin_bottom = 8.0
bg_color = Color(0, 0, 0, 0.4)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[sub_resource type="StyleBoxFlat" id="sb_hover"]
content_margin_left = 16.0
content_margin_top = 8.0
content_margin_right = 16.0
content_margin_bottom = 8.0
bg_color = Color(0, 0, 0, 0.65)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(1, 0.85, 0.2, 1)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[node name="MainMenu" 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_menu")
[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 = ExtResource("2_video")
expand = true
[node name="MainPanel" type="Control" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="PlayButton" type="Button" parent="MainPanel"]
layout_mode = 1
anchor_left = 0.4530
anchor_top = 0.6513
anchor_right = 0.4530
anchor_bottom = 0.6513
offset_left = -120.0
offset_top = -55.0
offset_right = 120.0
offset_bottom = 55.0
grow_horizontal = 2
grow_vertical = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 56
theme_override_styles/normal = SubResource("sb_normal")
theme_override_styles/hover = SubResource("sb_hover")
theme_override_styles/pressed = SubResource("sb_hover")
theme_override_styles/focus = SubResource("sb_hover")
text = "PLAY"
[node name="OptionsButton" type="Button" parent="MainPanel"]
layout_mode = 1
anchor_left = 0.11
anchor_top = 0.94
anchor_right = 0.11
anchor_bottom = 0.94
offset_left = -100.0
offset_top = -28.0
offset_right = 100.0
offset_bottom = 28.0
grow_horizontal = 2
grow_vertical = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 30
theme_override_styles/normal = SubResource("sb_normal")
theme_override_styles/hover = SubResource("sb_hover")
theme_override_styles/pressed = SubResource("sb_hover")
theme_override_styles/focus = SubResource("sb_hover")
text = "OPTIONS"
[node name="CreditsButton" type="Button" parent="MainPanel"]
layout_mode = 1
anchor_left = 0.489
anchor_top = 0.951
anchor_right = 0.489
anchor_bottom = 0.951
offset_left = -100.0
offset_top = -28.0
offset_right = 100.0
offset_bottom = 28.0
grow_horizontal = 2
grow_vertical = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 30
theme_override_styles/normal = SubResource("sb_normal")
theme_override_styles/hover = SubResource("sb_hover")
theme_override_styles/pressed = SubResource("sb_hover")
theme_override_styles/focus = SubResource("sb_hover")
text = "CREDITS"
[node name="ExitButton" type="Button" parent="MainPanel"]
layout_mode = 1
anchor_left = 0.92
anchor_top = 0.951
anchor_right = 0.92
anchor_bottom = 0.951
offset_left = -100.0
offset_top = -28.0
offset_right = 100.0
offset_bottom = 28.0
grow_horizontal = 2
grow_vertical = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 30
theme_override_styles/normal = SubResource("sb_normal")
theme_override_styles/hover = SubResource("sb_hover")
theme_override_styles/pressed = SubResource("sb_hover")
theme_override_styles/focus = SubResource("sb_hover")
text = "EXIT"
[node name="OptionsPanel" type="CenterContainer" parent="."]
visible = false
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="Backdrop" type="ColorRect" parent="OptionsPanel"]
layout_mode = 2
color = Color(0, 0, 0, 0.55)
mouse_filter = 2
[node name="VBox" type="VBoxContainer" parent="OptionsPanel"]
custom_minimum_size = Vector2(560, 480)
layout_mode = 2
theme_override_constants/separation = 16
[node name="TitleLabel" type="Label" parent="OptionsPanel/VBox"]
layout_mode = 2
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 44
text = "Controls"
horizontal_alignment = 1
[node name="Hint" type="Label" parent="OptionsPanel/VBox"]
layout_mode = 2
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 5
theme_override_font_sizes/font_size = 20
text = "Click a key, then press a new key. Esc cancels."
horizontal_alignment = 1
[node name="Scroll" type="ScrollContainer" parent="OptionsPanel/VBox"]
custom_minimum_size = Vector2(0, 340)
layout_mode = 2
size_flags_vertical = 3
horizontal_scroll_mode = 0
[node name="RebindList" type="VBoxContainer" parent="OptionsPanel/VBox/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="BackButton" type="Button" parent="OptionsPanel/VBox"]
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
theme_override_font_sizes/font_size = 24
text = "Back"
[node name="CreditsPanel" type="CenterContainer" parent="."]
visible = false
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="Backdrop" type="ColorRect" parent="CreditsPanel"]
layout_mode = 2
color = Color(0, 0, 0, 0.55)
mouse_filter = 2
[node name="VBox" type="VBoxContainer" parent="CreditsPanel"]
custom_minimum_size = Vector2(520, 360)
layout_mode = 2
theme_override_constants/separation = 20
[node name="TitleLabel" type="Label" parent="CreditsPanel/VBox"]
layout_mode = 2
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 44
text = "Credits"
horizontal_alignment = 1
[node name="Names" type="RichTextLabel" parent="CreditsPanel/VBox"]
layout_mode = 2
size_flags_vertical = 3
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_colors/default_color = Color(0.93, 0.88, 0.72, 1)
theme_override_constants/outline_size = 5
theme_override_constants/line_separation = 6
theme_override_font_sizes/normal_font_size = 26
theme_override_font_sizes/bold_font_size = 30
bbcode_enabled = true
fit_content = true
scroll_active = false
text = "[center][font_size=32][color=#d9a833]Bullosseum[/color][/font_size]
[b][color=#d9a833]Siim Raud[/color][/b] [url=https://instagram.com/siimpressionist]@siimpressionist[/url]
[font_size=20]Creative, art, idea guy[/font_size]
[b][color=#d9a833]Richard Aasa[/color][/b] [url=https://instagram.com/aasarichard]@aasarichard[/url]
[font_size=20]Programmer[/font_size]
[b][color=#d9a833]Kadi Rebane[/color][/b] [url=https://instagram.com/kadirebane]@kadirebane[/url]
[font_size=20]Arena asset[/font_size][/center]"
[node name="BackButton" type="Button" parent="CreditsPanel/VBox"]
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
theme_override_font_sizes/font_size = 24
text = "Back"
[node name="FadeRect" type="ColorRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
color = Color(0, 0, 0, 0)
+49
View File
@@ -0,0 +1,49 @@
extends CPUParticles3D
## One-shot blood spray. Add as a child of whatever bleeds, then call burst().
func _ready() -> void:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.vertex_color_use_as_albedo = true
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
var sphere := SphereMesh.new()
sphere.radius = 0.07
sphere.height = 0.14
sphere.radial_segments = 4
sphere.rings = 2
sphere.material = mat
var ramp := Gradient.new()
ramp.set_color(0, Color(1.0, 0.02, 0.02, 1.0))
ramp.set_color(1, Color(0.25, 0.0, 0.0, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 1.0))
scale_curve.add_point(Vector2(0.6, 0.5))
scale_curve.add_point(Vector2(1.0, 0.0))
mesh = sphere
color_ramp = ramp
scale_amount_curve = scale_curve
one_shot = true
explosiveness = 0.9
amount = 25
lifetime = 1.1
randomness = 0.4
local_coords = false
spread = 80.0
gravity = Vector3(0.0, -7.0, 0.0)
initial_velocity_min = 4.0
initial_velocity_max = 11.0
scale_amount_min = 0.5
scale_amount_max = 1.6
emitting = false
# Fire a burst at a world position, sprayed along dir (biased upward).
func burst(at: Vector3, dir: Vector3) -> void:
global_position = at
direction = (dir * 0.6 + Vector3.UP * 0.4).normalized()
restart()
+1
View File
@@ -0,0 +1 @@
uid://cnhmhom4w5o3i
+8 -17
View File
@@ -15,6 +15,10 @@ var _aerial_pitch: float = 0.0
var charge_pitch_target: float = 0.0 var charge_pitch_target: float = 0.0
var _charge_pitch_curr: float = 0.0 var _charge_pitch_curr: float = 0.0
# When true, another system (the roll's ball-tumble) owns the bull's full
# orientation — skip writing body wobble so we don't fight it.
var external_rotation: bool = false
# Tail — Verlet chain simulation # Tail — Verlet chain simulation
const TAIL_BONES: Array = [ const TAIL_BONES: Array = [
&"tail_1", &"tail_2", &"tail_3", &"tail_4", &"tail_1", &"tail_2", &"tail_3", &"tail_4",
@@ -94,18 +98,14 @@ func _setup_tail() -> void:
_tail_parent_idx = _skeleton.get_bone_parent(_tail_idx[0]) _tail_parent_idx = _skeleton.get_bone_parent(_tail_idx[0])
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo:
if event.physical_keycode == KEY_QUOTELEFT:
_dbg_toggle()
func _process(delta: float) -> void: func _process(delta: float) -> void:
if not _skeleton: if not _skeleton:
return return
_wobble_body(delta) _wobble_body(delta)
_update_tail(delta) _update_tail(delta)
_apply_tail() _apply_tail()
if DP.b("show_tail") != _dbg_on:
_dbg_toggle()
if _dbg_on: if _dbg_on:
_dbg_update() _dbg_update()
@@ -132,6 +132,8 @@ func _wobble_body(delta: float) -> void:
_aerial_pitch = lerpf(_aerial_pitch, target_pitch, delta * DP.f("aerial_pitch_speed")) _aerial_pitch = lerpf(_aerial_pitch, target_pitch, delta * DP.f("aerial_pitch_speed"))
_charge_pitch_curr = lerpf(_charge_pitch_curr, charge_pitch_target, delta * DP.f("charge_head_speed")) _charge_pitch_curr = lerpf(_charge_pitch_curr, charge_pitch_target, delta * DP.f("charge_head_speed"))
if external_rotation:
return
_bull.rotation.x = _tilt_x + _aerial_pitch + _charge_pitch_curr _bull.rotation.x = _tilt_x + _aerial_pitch + _charge_pitch_curr
_bull.rotation.z = _tilt_z _bull.rotation.z = _tilt_z
@@ -243,12 +245,9 @@ func _dbg_toggle() -> void:
n.queue_free() n.queue_free()
_dbg_nodes.clear() _dbg_nodes.clear()
_dbg_on = false _dbg_on = false
print("TailDebug OFF")
else: else:
_dbg_build() _dbg_build()
_dbg_on = true _dbg_on = true
var pname := _skeleton.get_bone_name(_tail_parent_idx) if _tail_parent_idx >= 0 else "none"
print("TailDebug ON parent_bone=", pname, " bull_pos=", _bull.global_position)
func _dbg_build() -> void: func _dbg_build() -> void:
@@ -275,14 +274,6 @@ func _dbg_build() -> void:
func _dbg_update() -> void: func _dbg_update() -> void:
for i: int in _dbg_nodes.size(): for i: int in _dbg_nodes.size():
_dbg_nodes[i].global_position = _tail_world[i] _dbg_nodes[i].global_position = _tail_world[i]
# Print positions every ~60 frames
if Engine.get_process_frames() % 60 == 0:
var parent_y := 0.0
if _tail_parent_idx >= 0:
parent_y = _skeleton.to_global(_skeleton.get_bone_global_pose(_tail_parent_idx).origin).y
print("anchor_Y=%.2f parent_bone_Y=%.2f player_Y=%.2f on_floor=%s" % [
_tail_world[0].y, parent_y, _player.global_position.y,
str(_player.is_on_floor())])
# ── Utility ─────────────────────────────────────────────────────────────────── # ── Utility ───────────────────────────────────────────────────────────────────
+65 -42
View File
@@ -1,94 +1,117 @@
extends Camera3D extends Camera3D
@export var height: float = 28.0 @export var height: float = 25.0
@export var horizontal_distance: float = 28.0 @export var horizontal_distance: float = 25.0
@export var horizontal_angle_deg: float = 45.0 @export var horizontal_angle_deg: float = 45.0
@export var size_min: float = 5.0 # Perspective zoom (FOV)
@export var size_max: float = 30.0 @export var fov_min: float = 20.0
@export var default_size: float = 15.0 @export var fov_max: float = 75.0
@export var default_fov: float = 30.0
@export var focus_slice_thickness: float = 6.0 @export var focus_slice_thickness: float = 6.0
@export var blur_transition: float = 15.0 @export var blur_transition: float = 15.0
var _shake_amount: float = 0.0 var _shake_amount: float = 0.0
var _shake_decay: float = 0.0 var _shake_decay: float = 0.0
var _user_size: float = 0.0 var _user_fov: float = 0.0
var _charge_active: bool = false
# Trailing-camera state: _focus is the smoothed point the camera frames (it lags
# the bull), _drag_offset is the velocity-driven trail that slides it behind.
var _focus: Vector3 = Vector3.ZERO
var _drag_offset: Vector3 = Vector3.ZERO
var _focus_init: bool = false
func _ready() -> void: func _ready() -> void:
set_as_top_level(true) set_as_top_level(true)
projection = Camera3D.PROJECTION_ORTHOGONAL
_user_size = default_size projection = Camera3D.PROJECTION_PERSPECTIVE
size = default_size fov = default_fov
_user_fov = default_fov
near = 0.1 near = 0.1
far = 150.0 far = 150.0
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("zoom_in"): if event.is_action_pressed("zoom_in"):
_user_size = clamp(_user_size / 1.2, size_min, size_max) _user_fov = clamp(_user_fov / 1.1, fov_min, fov_max)
if event.is_action_pressed("zoom_out"): if event.is_action_pressed("zoom_out"):
_user_size = clamp(_user_size * 1.2, size_min, size_max) _user_fov = clamp(_user_fov * 1.1, fov_min, fov_max)
func _process(delta: float) -> void: func _process(delta: float) -> void:
var zoom_target := _user_size * DP.f("charge_zoom_factor") if _charge_active else _user_size fov = lerpf(fov, _user_fov, delta * 8.0)
size = lerpf(size, zoom_target, delta * 8.0)
var angle_rad := deg_to_rad(horizontal_angle_deg) var angle_rad := deg_to_rad(horizontal_angle_deg)
var offset := Vector3( var offset := Vector3(
sin(angle_rad) * horizontal_distance, sin(angle_rad) * horizontal_distance,
height, height,
cos(angle_rad) * horizontal_distance, cos(angle_rad) * horizontal_distance
) )
var target := get_parent() as Node3D
global_position = target.global_position + offset
look_at(target.global_position, Vector3.UP)
_update_ortho_tilt_shift_dof(target.global_position) var target := get_parent() as Node3D
var focus := _drag_focus(target, delta)
global_position = focus + offset
look_at(focus, Vector3.UP)
_update_dof(focus)
if _shake_amount > 0.005: if _shake_amount > 0.005:
global_position += Vector3( global_position += Vector3(
randf_range(-_shake_amount, _shake_amount), randf_range(-_shake_amount, _shake_amount),
randf_range(-_shake_amount * 0.25, _shake_amount * 0.25), randf_range(-_shake_amount * 0.25, _shake_amount * 0.25),
randf_range(-_shake_amount, _shake_amount), randf_range(-_shake_amount, _shake_amount)
) )
_shake_amount = move_toward(_shake_amount, 0.0, _shake_decay * delta) _shake_amount = move_toward(_shake_amount, 0.0, _shake_decay * delta)
# Smoothed follow point that drags behind the bull. The target trail sits opposite
# the bull's flat velocity (cam_drag metres per m/s); the offset and the focus both
# ease toward their targets so the camera lags and settles rather than snapping.
func _drag_focus(target: Node3D, delta: float) -> Vector3:
var pos := target.global_position
if not _focus_init:
_focus = pos
_focus_init = true
var vel := Vector3.ZERO
if target is CharacterBody3D:
vel = (target as CharacterBody3D).velocity
vel.y = 0.0
var drag_target := -vel * DP.f("cam_drag")
_drag_offset = _drag_offset.lerp(drag_target, clampf(DP.f("cam_drag_speed") * delta, 0.0, 1.0))
_focus = _focus.lerp(pos + _drag_offset, clampf(DP.f("cam_follow_lerp") * delta, 0.0, 1.0))
return _focus
func trigger_hit(strength: float) -> void: func trigger_hit(strength: float) -> void:
_shake_amount = strength * 0.5 _shake_amount = strength * 0.5
_shake_decay = _shake_amount / 0.4 _shake_decay = _shake_amount / 0.4
func set_charge_active(active: bool) -> void: func _update_dof(target_pos: Vector3) -> void:
_charge_active = active
func _update_ortho_tilt_shift_dof(target_pos: Vector3) -> void:
var cam_attr: CameraAttributesPractical = null var cam_attr: CameraAttributesPractical = null
if "attributes" in self and self.attributes is CameraAttributesPractical: if attributes is CameraAttributesPractical:
cam_attr = self.attributes cam_attr = attributes
elif "camera_attributes" in self and self.camera_attributes is CameraAttributesPractical:
cam_attr = self.camera_attributes
if cam_attr != null: if cam_attr == null:
var to_target := target_pos - global_position return
var forward_vector := -global_transform.basis.z
var ortho_depth := to_target.dot(forward_vector)
var zoom_factor := size / default_size var distance := global_position.distance_to(target_pos)
var dynamic_slice := focus_slice_thickness * zoom_factor
var half_slice := dynamic_slice * 0.5
var base_transition := blur_transition * zoom_factor cam_attr.dof_blur_near_distance = max(distance - focus_slice_thickness * 0.5, near + 0.1)
cam_attr.dof_blur_far_distance = distance + focus_slice_thickness * 0.5
cam_attr.dof_blur_near_transition = blur_transition
cam_attr.dof_blur_far_transition = blur_transition
cam_attr.dof_blur_far_distance = ortho_depth + half_slice
cam_attr.dof_blur_near_distance = maxf(near + 0.1, ortho_depth - half_slice)
cam_attr.dof_blur_far_transition = maxf(0.1, base_transition * 0.5)
cam_attr.dof_blur_near_transition = maxf(1.0, base_transition * 1.5)
cam_attr.dof_blur_far_enabled = false
cam_attr.dof_blur_near_enabled = false cam_attr.dof_blur_near_enabled = false
cam_attr.dof_blur_far_enabled = false
+651
View File
@@ -0,0 +1,651 @@
extends Node
## Quake-style drop-down developer console (autoload "Console"). Toggle with the
## backtick/tilde key (`/~) — matched by physical position so it works on any
## keyboard layout and with or without Shift.
## Query a param: type its name. Set it: "name value".
## Tab = complete top match · ↑↓ = browse suggestions / history · Esc = close.
const CONSOLE_HEIGHT_RATIO := 0.25
const SLIDE_DURATION := 0.18
const MAX_SUGGESTIONS := 10
const LOG_MAX_LINES := 300
# Built-in commands (name → one-line help). Surfaced in autocomplete alongside
# param keys and printed by `help`. None collide with param key names.
const _COMMANDS := {
"save": "persist values to disk",
"reset": "reset all, or reset <name>",
"diff": "list params changed from default",
"toggle": "flip a bool param",
"list": "print params (optional section)",
"clear": "clear the log",
"help": "show this help",
}
var _canvas: CanvasLayer
var _panel: PanelContainer
var _log: RichTextLabel
var _suggest_panel: PanelContainer
var _suggest_box: VBoxContainer
var _suggest_labels: Array[Label] = []
var _input_field: LineEdit
var _tween: Tween
var _is_open: bool = false
var _prev_mouse: Input.MouseMode = Input.MOUSE_MODE_CAPTURED
var _suggestions: Array[String] = []
var _selected_idx: int = -1
var _history: Array[String] = []
var _history_idx: int = -1 # -1 = editing the live line
var _pending_line: String = "" # live line stashed while browsing history
func _ready() -> void:
# Keep the console (and its slide tween/input) alive while the tree is paused.
process_mode = Node.PROCESS_MODE_ALWAYS
_build_ui()
func _build_ui() -> void:
var mono := SystemFont.new()
mono.font_names = PackedStringArray([
"JetBrains Mono", "DejaVu Sans Mono", "Consolas", "Menlo", "monospace",
])
_canvas = CanvasLayer.new()
_canvas.layer = 128
add_child(_canvas)
_panel = PanelContainer.new()
_panel.anchor_left = 0.0
_panel.anchor_right = 1.0
_panel.anchor_top = 0.0
_panel.anchor_bottom = 0.0
_panel.offset_bottom = 500.0
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_panel.visible = false
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.04, 0.06, 0.04, 0.94)
bg.border_width_bottom = 2
bg.border_color = Color(0.2, 0.8, 0.2, 1.0)
_panel.add_theme_stylebox_override("panel", bg)
_canvas.add_child(_panel)
var root_vbox := VBoxContainer.new()
root_vbox.size_flags_horizontal = Control.SIZE_FILL
root_vbox.size_flags_vertical = Control.SIZE_FILL
_panel.add_child(root_vbox)
# ── Log ───────────────────────────────────────────────────────────────────
_log = RichTextLabel.new()
_log.bbcode_enabled = true
_log.scroll_following = true
_log.size_flags_vertical = Control.SIZE_EXPAND_FILL
_log.size_flags_horizontal = Control.SIZE_FILL
_log.add_theme_font_size_override("normal_font_size", 13)
_log.add_theme_constant_override("line_separation", 1)
# Monospace so the rpad-aligned suggestion/list columns actually line up.
_log.add_theme_font_override("normal_font", mono)
_log.add_theme_font_override("bold_font", mono)
_log.add_theme_font_override("mono_font", mono)
root_vbox.add_child(_log)
# ── Suggestion panel ──────────────────────────────────────────────────────
_suggest_panel = PanelContainer.new()
_suggest_panel.visible = false
_suggest_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
var sg_bg := StyleBoxFlat.new()
sg_bg.bg_color = Color(0.06, 0.10, 0.06, 0.97)
sg_bg.border_width_top = 1
sg_bg.border_color = Color(0.15, 0.55, 0.15, 0.8)
_suggest_panel.add_theme_stylebox_override("panel", sg_bg)
root_vbox.add_child(_suggest_panel)
_suggest_box = VBoxContainer.new()
_suggest_box.add_theme_constant_override("separation", 0)
_suggest_panel.add_child(_suggest_box)
for _i in MAX_SUGGESTIONS:
var lbl := Label.new()
lbl.visible = false
lbl.add_theme_font_size_override("font_size", 13)
lbl.add_theme_font_override("font", mono)
_suggest_box.add_child(lbl)
_suggest_labels.append(lbl)
root_vbox.add_child(HSeparator.new())
# ── Input row ─────────────────────────────────────────────────────────────
var in_margin := MarginContainer.new()
in_margin.add_theme_constant_override("margin_left", 10)
in_margin.add_theme_constant_override("margin_right", 10)
in_margin.add_theme_constant_override("margin_top", 4)
in_margin.add_theme_constant_override("margin_bottom", 6)
root_vbox.add_child(in_margin)
var in_row := HBoxContainer.new()
in_row.add_theme_constant_override("separation", 6)
in_margin.add_child(in_row)
var prompt_lbl := Label.new()
prompt_lbl.text = ">"
prompt_lbl.modulate = Color(0.35, 1.0, 0.35)
prompt_lbl.add_theme_font_override("font", mono)
in_row.add_child(prompt_lbl)
_input_field = LineEdit.new()
_input_field.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_input_field.placeholder_text = "param · param value · toggle <bool> · save reset diff list clear help"
_input_field.clear_button_enabled = true
_input_field.add_theme_font_size_override("font_size", 14)
_input_field.add_theme_font_override("font", mono)
# Drop the default LineEdit chrome (the white box border) — the panel is the frame.
var flat := StyleBoxFlat.new()
flat.bg_color = Color(0, 0, 0, 0)
flat.set_border_width_all(0)
flat.set_content_margin_all(2)
_input_field.add_theme_stylebox_override("normal", flat)
_input_field.add_theme_stylebox_override("focus", flat)
in_row.add_child(_input_field)
_input_field.text_changed.connect(_on_text_changed)
_input_field.text_submitted.connect(_on_submitted)
# ── Input handling ────────────────────────────────────────────────────────────
func _input(event: InputEvent) -> void:
if not (event is InputEventKey and (event as InputEventKey).pressed
and not (event as InputEventKey).echo):
return
var ke := event as InputEventKey
# Backtick/tilde — toggle regardless of focus. Match by physical position so a
# non-US layout or a Shift-produced ~ both open the console.
if ke.physical_keycode == KEY_QUOTELEFT:
_toggle()
get_viewport().set_input_as_handled()
return
if not _is_open:
return
# Ctrl+C clears the input line, Ctrl+L clears the log — familiar shell reflexes.
if ke.ctrl_pressed:
match ke.keycode:
KEY_C:
_clear_input()
get_viewport().set_input_as_handled()
return
KEY_L:
_log.clear()
get_viewport().set_input_as_handled()
return
match ke.keycode:
KEY_TAB:
_accept_completion()
get_viewport().set_input_as_handled()
KEY_UP:
_arrow(-1)
get_viewport().set_input_as_handled()
KEY_DOWN:
_arrow(1)
get_viewport().set_input_as_handled()
KEY_ESCAPE:
_toggle()
get_viewport().set_input_as_handled()
# Focus can be stolen a frame or more after we re-grab it (submit, arrows, the tree
# unpausing), so a one-shot deferred grab loses the race. Enforce it every frame
# while the console is open — cheap, and it never lets the command line go dark.
func _process(_delta: float) -> void:
if _is_open and not _input_field.has_focus():
# Diagnostic: name whatever currently holds focus before we snatch it back, so
# a persistent thief (a HUD button, a menu control) shows up in the log instead
# of us guessing. Remove once the culprit is identified.
var owner: Control = get_viewport().gui_get_focus_owner()
var who: String = String(owner.name) if owner != null else "<nothing>"
_log_warn("focus stolen by %s — reclaiming" % who)
_input_field.grab_focus()
func _toggle() -> void:
_is_open = not _is_open
get_tree().paused = _is_open
var h := get_viewport().get_visible_rect().size.y * CONSOLE_HEIGHT_RATIO
_panel.offset_bottom = h
if _tween:
_tween.kill()
_tween = create_tween().set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_QUART)
if _is_open:
_prev_mouse = Input.mouse_mode
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_panel.position.y = -h
_panel.visible = true
_tween.tween_property(_panel, "position:y", 0.0, SLIDE_DURATION)
_tween.tween_callback(_input_field.grab_focus)
else:
_tween.tween_property(_panel, "position:y", -h, SLIDE_DURATION)
_tween.tween_callback(func() -> void: _panel.visible = false)
Input.mouse_mode = _prev_mouse
_clear_suggestions()
# ── Fuzzy autocomplete ────────────────────────────────────────────────────────
func _on_text_changed(text: String) -> void:
_selected_idx = -1
_history_idx = -1
var query := text.get_slice(" ", 0).strip_edges()
if query.is_empty():
_clear_suggestions()
return
_refresh_suggestions(query)
func _refresh_suggestions(query: String) -> void:
var ql := query.to_lower()
var scored : Array = []
# Commands rank above params on an equal score so a leading "re" surfaces the
# `reset` verb, not just params that happen to contain those letters.
for name: String in _COMMANDS:
var cs := _fuzzy_score(ql, name)
if cs > 0:
scored.append([cs + 1, name])
for key: String in DP.get_all():
var s := _fuzzy_score(ql, key.to_lower())
if s > 0:
scored.append([s, key])
scored.sort_custom(func(a: Array, b: Array) -> bool: return a[0] > b[0])
_suggestions.clear()
for i in mini(scored.size(), MAX_SUGGESTIONS):
_suggestions.append(scored[i][1])
for i in MAX_SUGGESTIONS:
if i < _suggestions.size():
_suggest_labels[i].text = _suggestion_text(_suggestions[i])
_suggest_labels[i].modulate = _suggestion_color(i)
_suggest_labels[i].visible = true
else:
_suggest_labels[i].visible = false
_suggest_panel.visible = not _suggestions.is_empty()
func _is_command(name: String) -> bool:
return _COMMANDS.has(name)
func _suggestion_text(name: String) -> String:
if _is_command(name):
return " %s [cmd] %s" % [name.rpad(10), _COMMANDS[name]]
var meta: Dictionary = DP.get_all()[name]
return " %s %s [%s]" % [name.rpad(28), _fmt_val(name, meta).rpad(8), meta["section"]]
func _suggestion_color(idx: int) -> Color:
if idx == _selected_idx:
return Color(1.0, 1.0, 0.4) # selected — yellow
if idx == 0:
return Color(0.85, 1.0, 0.70) # top match — bright green
return Color(0.60, 0.78, 0.50) # rest — muted green
func _fuzzy_score(query: String, target: String) -> int:
var qi := 0
var score := 0
var last := -1
for ti in target.length():
if qi >= query.length():
break
if target[ti] == query[qi]:
score += 10 + (5 if ti == last + 1 else 0)
last = ti
qi += 1
if qi < query.length():
return 0 # not all query chars matched
if target.begins_with(query):
score += 50
if target == query:
score += 100
return score
func _fmt_val(_key: String, meta: Dictionary) -> String:
if meta["type"] == TYPE_FLOAT:
return _fmt_num(meta["value"] as float)
if meta["type"] == TYPE_BOOL:
var bval: bool = meta["value"]
return "true" if bval else "false"
return str(meta["value"])
# Render a float as a plain decimal — trailing zeros trimmed but always at least one
# place, so it always reads as a float (4.0, 0.4, 0.005) never an int (4).
func _fmt_num(v: float) -> String:
var s := ("%.4f" % v).rstrip("0")
if s.ends_with("."):
s += "0"
return s
func _clear_suggestions() -> void:
_suggestions.clear()
_selected_idx = -1
for lbl in _suggest_labels:
lbl.visible = false
_suggest_panel.visible = false
# ↑/↓ move through the suggestion list while it's open; once it's gone they recall
# command history instead. Same keys, context decides — no separate binding to learn.
func _arrow(dir: int) -> void:
if _suggest_panel.visible and not _suggestions.is_empty():
_move_selection(dir)
else:
_history_step(-dir)
# Highlight the next/prev suggestion and drop "<name> <value>" into the field with the
# value pre-selected, so you just type your number over it.
func _move_selection(dir: int) -> void:
if _selected_idx < 0:
_selected_idx = 0 if dir > 0 else _suggestions.size() - 1
else:
_selected_idx = wrapi(_selected_idx + dir, 0, _suggestions.size())
_recolor_suggestions()
_fill_selected()
# Tab commits the highlighted suggestion (top match if none yet) and closes the list.
func _accept_completion() -> void:
if _suggestions.is_empty():
return
_selected_idx = maxi(_selected_idx, 0)
_fill_selected()
_clear_suggestions()
func _fill_selected() -> void:
var key := _suggestions[_selected_idx]
# A command drops in as "verb " ready for its argument (or a bare Enter); a
# param drops in as "key value" with the value pre-selected to type over.
# Suggestions stay open either way so ↑↓ keeps cycling.
if _is_command(key):
_input_field.text = key + " "
_input_field.caret_column = _input_field.text.length()
return
var meta: Dictionary = DP.get_all()[key]
_input_field.text = "%s %s" % [key, _fmt_val(key, meta)]
_input_field.caret_column = _input_field.text.length()
_input_field.select(key.length() + 1)
func _recolor_suggestions() -> void:
for i in MAX_SUGGESTIONS:
if _suggest_labels[i].visible:
_suggest_labels[i].modulate = _suggestion_color(i)
# ↑ recalls older commands, ↓ newer ones; index -1 is the live (in-progress) line,
# which is stashed on the first ↑ and restored when you step back down to it.
func _history_step(dir: int) -> void:
if _history.is_empty():
return
var target := _history_idx + dir
if target < -1 or target >= _history.size():
return
if _history_idx == -1 and dir > 0:
_pending_line = _input_field.text
_history_idx = target
_set_input(_pending_line if _history_idx == -1 else _history[_history_idx])
# Wipe the live line (Ctrl+C) without submitting — also drops any suggestion popup
# and resets history browsing so the next ↑ starts fresh.
func _clear_input() -> void:
_input_field.clear()
_history_idx = -1
_pending_line = ""
_clear_suggestions()
func _set_input(text: String) -> void:
_input_field.text = text
_input_field.caret_column = text.length()
_clear_suggestions()
# ── Command execution ─────────────────────────────────────────────────────────
func _on_submitted(raw: String) -> void:
raw = raw.strip_edges()
if raw.is_empty():
return
if _history.is_empty() or _history[0] != raw:
_history.push_front(raw)
_history_idx = -1
_pending_line = ""
_input_field.clear()
_clear_suggestions()
_execute(raw)
func _execute(cmd: String) -> void:
_log_raw("[color=#3a5a3a]> %s[/color]" % _esc(cmd))
var parts := cmd.split(" ", false)
if parts.is_empty():
return
if _run_command(parts):
return
# Not a command — treat as a param key, with fuzzy fall-back for partial names.
var key := _resolve_key(parts[0])
if key.is_empty():
return
var meta: Dictionary = DP.get_all()[key]
if parts.size() == 1:
_read_param(key, meta)
else:
_set_param(key, meta, parts[1])
# Run a built-in command; returns false if parts[0] isn't one (caller falls through
# to param handling). Kept separate from _execute to keep each dispatch shallow.
func _run_command(parts: PackedStringArray) -> bool:
match parts[0].to_lower():
"save":
DP.save()
_log_ok("Saved.")
"reset":
_cmd_reset(parts)
"toggle":
_cmd_toggle(parts)
"diff":
_cmd_diff()
"list":
_cmd_list(parts)
"clear":
_log.clear()
"help":
_cmd_help()
_:
return false
return true
# Resolve a user-typed token to a param key: exact match, else unique fuzzy match.
# Logs an ambiguity / unknown warning (and an arrow on a fuzzy hit) and returns
# "" when it can't narrow to a single key.
func _resolve_key(raw: String) -> String:
if DP.get_all().has(raw):
return raw
var matches := _fuzzy_find_all(raw)
if matches.size() == 1:
_log_raw("[color=#8888ff]→ %s[/color]" % matches[0])
return matches[0]
if matches.size() > 1:
_log_warn("Ambiguous '%s': %s" % [_esc(raw), ", ".join(matches.slice(0, 6))])
else:
_log_warn("Unknown: %s" % _esc(raw))
return ""
func _cmd_reset(parts: PackedStringArray) -> void:
if parts.size() == 1:
DP.reset_all()
_log_ok("All params reset to defaults.")
return
var key := _resolve_key(parts[1])
if key.is_empty():
return
DP.reset(key)
_log_ok("[b]%s[/b] reset to [color=#ffe080]%s[/color]" % [key, _fmt_val(key, DP.get_all()[key])])
func _cmd_toggle(parts: PackedStringArray) -> void:
if parts.size() < 2:
_log_warn("Usage: toggle <bool param>")
return
var key := _resolve_key(parts[1])
if key.is_empty():
return
var meta: Dictionary = DP.get_all()[key]
if meta["type"] != TYPE_BOOL:
_log_warn("%s is not a bool — use '%s <value>'" % [key, key])
return
var v := not (meta["value"] as bool)
DP.set_value(key, v)
_log_ok("[b]%s[/b] ← [color=#ffe080]%s[/color]" % [key, "true" if v else "false"])
# List only params that currently differ from their registered default, grouped by
# section — the "what have I actually changed?" view before a save.
func _cmd_diff() -> void:
var all := DP.get_all()
var sections: Dictionary = {}
for key: String in all:
if not DP.is_modified(key):
continue
var sec: String = all[key]["section"]
if not sections.has(sec):
sections[sec] = []
sections[sec].append(key)
if sections.is_empty():
_log_ok("All params at defaults.")
return
for sec: String in sections:
_log_raw("[color=#6699ff][b]%s[/b][/color]" % sec)
for key: String in sections[sec]:
var meta: Dictionary = all[key]
_log_raw(" [color=#777777]%s[/color] = [color=#ffe080]%s[/color] [color=#484848](def %s)[/color]" % [
key, _fmt_val(key, meta), _fmt_default(meta)
])
func _cmd_help() -> void:
_log_raw("[color=#888888] <name>[/color] — read current value + range")
_log_raw("[color=#888888] <name> <value>[/color] — set it (number, or true/false)")
for name: String in _COMMANDS:
_log_raw("[color=#888888] %s[/color] — %s" % [name.rpad(20), _COMMANDS[name]])
_log_raw(" [color=#555555]↑↓ pick suggestion · Tab complete · ↑↓ history when empty · ` close[/color]")
func _fmt_default(meta: Dictionary) -> String:
if meta["type"] == TYPE_FLOAT:
return _fmt_num(meta["default"] as float)
return "true" if (meta["default"] as bool) else "false"
func _read_param(key: String, meta: Dictionary) -> void:
if meta["type"] == TYPE_FLOAT:
_log_raw("[b]%s[/b] = [color=#ffe080]%s[/color] [color=#484848](%s%s)[/color]" % [
key, _fmt_val(key, meta),
_fmt_num(meta["min"] as float), _fmt_num(meta["max"] as float),
])
else:
_log_raw("[b]%s[/b] = [color=#ffe080]%s[/color] [color=#484848](true / false)[/color]" % [
key, _fmt_val(key, meta),
])
func _set_param(key: String, meta: Dictionary, raw: String) -> void:
if meta["type"] == TYPE_FLOAT:
var lo := meta["min"] as float
var hi := meta["max"] as float
if not raw.is_valid_float() or not is_finite(raw.to_float()):
_log_warn("%s wants a number %s%s" % [key, _fmt_num(lo), _fmt_num(hi)])
return
var v := snappedf(clampf(raw.to_float(), lo, hi), meta["step"] as float)
DP.set_value(key, v)
_log_ok("[b]%s[/b] ← [color=#ffe080]%s[/color]" % [key, _fmt_num(v)])
elif meta["type"] == TYPE_BOOL:
match raw.to_lower():
"1", "true", "yes", "on":
DP.set_value(key, true)
_log_ok("[b]%s[/b] ← [color=#ffe080]true[/color]" % key)
"0", "false", "no", "off":
DP.set_value(key, false)
_log_ok("[b]%s[/b] ← [color=#ffe080]false[/color]" % key)
_:
_log_warn("%s wants true or false" % key)
else:
_log_warn("Cannot set param of type %d" % meta["type"])
func _cmd_list(parts: PackedStringArray) -> void:
var filter := parts[1].to_lower() if parts.size() > 1 else ""
var all := DP.get_all()
var sections: Dictionary = {}
for key: String in all:
var sec: String = all[key]["section"]
if filter.length() > 0:
if not sec.to_lower().begins_with(filter) and not key.to_lower().contains(filter):
continue
if not sections.has(sec):
sections[sec] = []
sections[sec].append(key)
for sec: String in sections:
_log_raw("[color=#6699ff][b]%s[/b][/color]" % sec)
for key: String in sections[sec]:
_log_raw(" [color=#777777]%s[/color] = [color=#ffe080]%s[/color]" % [
key, _fmt_val(key, all[key])
])
func _fuzzy_find_all(query: String) -> Array[String]:
var result: Array[String] = []
var ql := query.to_lower()
for key: String in DP.get_all():
if _fuzzy_score(ql, key.to_lower()) > 0:
result.append(key)
return result
# ── Logging ───────────────────────────────────────────────────────────────────
## Neutralise BBCode in user-supplied text before it reaches the RichTextLabel,
## so malformed or hostile tags (e.g. [img]) render literally instead of running.
func _esc(text: String) -> String:
return text.replace("[", "[lb]")
func _log_raw(text: String) -> void:
if _log.get_line_count() > LOG_MAX_LINES:
_log.clear()
_log.append_text(text + "\n")
func _log_ok(text: String) -> void:
_log_raw("[color=#66ee88]%s[/color]" % text)
func _log_warn(text: String) -> void:
_log_raw("[color=#ff7744]%s[/color]" % text)
+93
View File
@@ -0,0 +1,93 @@
extends Node
## Global keybinding manager (autoload `Controls`).
## Loads saved keyboard bindings on startup and applies them to InputMap so
## rebinds made in the intro menu persist into the game scene. Only the
## keyboard event of each action is overridden; joypad/mouse events are kept.
const SAVE_PATH := "user://controls.cfg"
const SECTION := "keybinds"
## Actions the options menu lets the player rebind (keyboard only).
const REBINDABLE_ACTIONS: PackedStringArray = [
&"move_forward",
&"move_back",
&"move_left",
&"move_right",
&"ability_kick",
&"ability_slam",
&"ability_dash",
&"ability_roll",
]
func _ready() -> void:
load_saved()
## Returns the first keyboard event bound to an action, or null.
func get_key_event(action: StringName) -> InputEventKey:
for event: InputEvent in InputMap.action_get_events(action):
if event is InputEventKey:
return event
return null
## Human-readable label for the action's current key (e.g. "W", "Space").
func get_key_label(action: StringName) -> String:
var event := get_key_event(action)
if event == null:
return ""
return event.as_text_physical_keycode()
## Replaces the primary keyboard event of `action` with `event`, keeping
## every other event (e.g. arrow-key alternates, joypad) in its place.
func rebind(action: StringName, event: InputEventKey) -> void:
rebind_no_save(action, event)
save()
func save() -> void:
var cfg := ConfigFile.new()
for action: StringName in REBINDABLE_ACTIONS:
var event := get_key_event(action)
if event != null:
cfg.set_value(SECTION, String(action), event.physical_keycode)
cfg.save(SAVE_PATH)
func load_saved() -> void:
var cfg := ConfigFile.new()
if cfg.load(SAVE_PATH) != OK:
return
for action: StringName in REBINDABLE_ACTIONS:
var key := String(action)
if not cfg.has_section_key(SECTION, key):
continue
var keycode: int = cfg.get_value(SECTION, key)
var event := InputEventKey.new()
event.physical_keycode = keycode as Key
rebind_no_save(action, event)
## Rebuilds the action's event list, swapping the first keyboard event for
## `event` in place so ordering (and thus the "primary" key) is preserved.
func rebind_no_save(action: StringName, event: InputEventKey) -> void:
var events := InputMap.action_get_events(action)
InputMap.action_erase_events(action)
var replaced := false
for existing: InputEvent in events:
if not replaced and existing is InputEventKey:
InputMap.action_add_event(action, event)
replaced = true
else:
InputMap.action_add_event(action, existing)
if not replaced:
InputMap.action_add_event(action, event)
func reset_defaults() -> void:
InputMap.load_from_project_settings()
if FileAccess.file_exists(SAVE_PATH):
DirAccess.remove_absolute(SAVE_PATH)
+1
View File
@@ -0,0 +1 @@
uid://dvxx75l5re2mf
+35
View File
@@ -0,0 +1,35 @@
shader_type spatial;
// Flat crowd sprite. Each instance is oriented at build time to face the arena centre
// (see crowd_billboards.gd) — NOT the camera — so spectators on different segments of
// the ring face inward toward the action, like a real stand. Unshaded (the sheet was
// baked with lighting), alpha-tested (no transparency sorting across hundreds of
// quads), nearest-filtered for a crisp PS1 look that matches the ps1_filter effect.
render_mode unshaded, cull_disabled, shadows_disabled, depth_draw_opaque;
uniform sampler2D sheet : source_color, filter_nearest;
uniform int hframes = 8;
uniform float fps = 10.0;
uniform float alpha_clip = 0.4;
varying flat float v_frame;
varying flat vec3 v_tint;
void vertex() {
// Per-instance clap phase (0..1) and tint are packed into the MultiMesh custom data.
// The instance transform already carries the inward facing, so no billboard maths.
float phase = INSTANCE_CUSTOM.x;
v_tint = INSTANCE_CUSTOM.yzw;
v_frame = floor(mod(TIME * fps + phase * float(hframes), float(hframes)));
}
void fragment() {
// Sheet is two rows: top = front, bottom = back. Show the back when the camera is
// behind the spectator (they're facing into the arena, away from us).
float u = (UV.x + v_frame) / float(hframes);
float v = UV.y * 0.5 + (FRONT_FACING ? 0.0 : 0.5);
vec4 c = texture(sheet, vec2(u, v));
if (c.a < alpha_clip) {
discard;
}
ALBEDO = c.rgb * v_tint;
}
+1
View File
@@ -0,0 +1 @@
uid://hkdivctavovb
+137
View File
@@ -0,0 +1,137 @@
extends MultiMeshInstance3D
## A whole stand of cheering spectators rendered as camera-facing billboards in a
## single draw call — the performant replacement for dozens of skinned crowd skeletons.
## The sprite sheet is baked from the existing crowd figure by tools/bake_crowd_sheet.gd.
##
## Placement mirrors an existing crowd node (default: the sibling PublikNode) so the
## billboards land exactly where the crowd was authored; if that node is missing it
## falls back to a generated tribune so the scene still populates.
const SHEET_PATH := "res://Assets/crowd_clap_sheet.png"
const SHADER_PATH := "res://crowd_billboard.gdshader"
## Node whose descendant figures (names starting with `figure_prefix`) donate their
## world positions. Empty = use the generated fallback stand.
@export var source_path: NodePath = ^"../PublikNode"
@export var figure_prefix: String = "mees"
## World height of a spectator billboard, in metres. Sheet aspect sets the width.
@export var figure_height: float = 2.0
@export var frames: int = 8
@export var clap_fps: float = 10.0
## Subtle per-spectator brightness spread so the crowd doesn't read as clones.
@export_range(0.0, 0.5) var tint_variation: float = 0.18
# Fallback tribune (used only when source_path resolves to nothing) — concentric arcs
# of seats ringing the arena.
@export_group("Fallback stand")
@export var fallback_count: int = 120
@export var fallback_inner_radius: float = 26.0
@export var fallback_rows: int = 6
@export var fallback_row_rise: float = 1.6
@export var fallback_row_step: float = 2.2
@export var fallback_seat_step: float = 2.4
var _rng := RandomNumberGenerator.new()
func _ready() -> void:
_rng.seed = hash("bullosseum-crowd") # deterministic layout/phases across runs
_build()
func _build() -> void:
var sheet := load(SHEET_PATH) as Texture2D
if sheet == null:
push_warning("crowd_billboards: missing sheet %s — run tools/bake_crowd_sheet.gd" % SHEET_PATH)
return
# Sheet holds `frames` columns and 2 rows (front / back), so a cell is half-height.
var aspect := (float(sheet.get_width()) / float(frames)) / (float(sheet.get_height()) / 2.0)
var quad := QuadMesh.new()
quad.size = Vector2(figure_height * aspect, figure_height)
quad.center_offset = Vector3(0.0, figure_height * 0.5, 0.0) # pivot at the feet
var mat := ShaderMaterial.new()
mat.shader = load(SHADER_PATH) as Shader
mat.set_shader_parameter("sheet", sheet)
mat.set_shader_parameter("hframes", frames)
mat.set_shader_parameter("fps", clap_fps)
material_override = mat
var seats := _seat_positions()
var center := _arena_center(seats)
var mm := MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_3D
mm.use_custom_data = true
mm.mesh = quad
mm.instance_count = seats.size()
for i in seats.size():
var s := 1.0 + _rng.randf_range(-0.06, 0.06) # slight height variety
mm.set_instance_transform(i, Transform3D(_facing_basis(seats[i], center, s), seats[i]))
var b := 1.0 + _rng.randf_range(-tint_variation, tint_variation)
mm.set_instance_custom_data(i, Color(_rng.randf(), b, b * 0.99, b * 0.98))
multimesh = mm
# Orientation for a seat: the sprite's forward (+Z, the quad's face) points horizontally
# inward at the arena centre, so each ring segment faces the action instead of the
# camera. Upright (world +Y), uniformly scaled by `s`.
func _facing_basis(seat: Vector3, center: Vector3, s: float) -> Basis:
var look := center - seat
look.y = 0.0
look = look.normalized() if look.length() > 0.001 else Vector3(0.0, 0.0, -1.0)
var up := Vector3.UP
var right := up.cross(look).normalized()
var basis := Basis()
basis.x = right
basis.y = up
basis.z = look
return basis.scaled(Vector3.ONE * s)
# Horizontal centre the crowd faces — the mean of the seat positions (the ring/stand
# centroid), which for an arena crowd is the middle of the pitch.
func _arena_center(seats: PackedVector3Array) -> Vector3:
if seats.is_empty():
return global_position
var sum := Vector3.ZERO
for p in seats:
sum += p
return sum / float(seats.size())
# Global feet-positions for every spectator: harvested from the source crowd if present,
# otherwise the generated fallback tribune.
func _seat_positions() -> PackedVector3Array:
var out := PackedVector3Array()
var src := get_node_or_null(source_path)
if src != null:
_collect_figures(src, out)
if out.is_empty():
_fallback_stand(out)
return out
func _collect_figures(node: Node, out: PackedVector3Array) -> void:
if node is Node3D and node != self and (node as Node3D).name.begins_with(figure_prefix):
out.append((node as Node3D).global_position)
return # don't descend into a figure's own rig
for c in node.get_children():
_collect_figures(c, out)
func _fallback_stand(out: PackedVector3Array) -> void:
var placed := 0
for row in fallback_rows:
var radius := fallback_inner_radius + float(row) * fallback_row_step
var y := float(row) * fallback_row_rise
var circumference := TAU * radius
var seats_in_row := int(circumference / fallback_seat_step)
for s in seats_in_row:
if placed >= fallback_count:
return
var ang := TAU * float(s) / float(seats_in_row)
out.append(Vector3(cos(ang) * radius, y, sin(ang) * radius))
placed += 1
+1
View File
@@ -0,0 +1 @@
uid://cj0ytlkuavmf4
+509
View File
@@ -0,0 +1,509 @@
extends Node
## Runtime debug overlays (autoload "DebugDraw").
##
## Two mechanisms, both gated by DP "Debug" bool params so they cost nothing when off:
## • Immediate-mode 3D lines — call DebugDraw.line()/ray() each frame; flushed once
## per frame into a single ImmediateMesh. Used for raycasts and the bone overlay.
## • Shape overlays — a MeshInstance3D attached to each CollisionShape3D, reconciled
## incrementally so static geometry is never re-instanced and freshly spawned
## bodies get picked up within one interval.
##
## Toggle from the console:
## show_collisions · show_hitboxes · show_bones · show_raycasts (true / false)
## show_states — floating AI-state tags + velocity vectors over each matador
## show_animation_name — floating current-clip tag over every AnimationPlayer host
## show_stats — 2D corner readout: fps / frame ms / draw calls / matador tally
## show_grid — 1 m reference grid on the ground plane around the origin
## show_axes — RGB world-axis gizmo at the origin
## show_wireframe — render the whole viewport in wireframe
const REBUILD_INTERVAL := 0.5
const STATS_INTERVAL := 0.2 # stats text is re-composed 5×/sec, not every frame
const COLOR_BODY := Color(0.30, 1.0, 0.45) # collision wireframes (green)
const COLOR_AREA := Color(0.0, 0.9, 1.0) # hit-area volumes (cyan, translucent)
const COLOR_BONE := Color(1.0, 0.35, 0.9) # skeleton bones (magenta)
const COLOR_VEL := Color(1.0, 0.85, 0.2) # matador velocity vectors (amber)
const COLOR_ANIM := Color(0.55, 0.85, 1.0) # animation-name tags (sky blue)
const COLOR_GRID := Color(0.45, 0.45, 0.45, 0.5)
const AREA_ALPHA := 0.25
const GRID_HALF := 20 # grid extends ±GRID_HALF cells from the origin
const GRID_STEP := 1.0 # metres between grid lines
const AXIS_LEN := 3.0 # length of each world-axis gizmo arm
var _line_mesh: ImmediateMesh
var _lines: PackedVector3Array = PackedVector3Array()
var _colors: PackedColorArray = PackedColorArray()
var _overlays: Dictionary = {} # CollisionShape3D instance id -> MeshInstance3D
var _skeletons: Array[Skeleton3D] = []
var _anim_players: Array[AnimationPlayer] = []
var _reconcile_t: float = 0.0
var _prev_flags: String = ""
var _state_labels: Dictionary = {} # matador instance id -> Label3D
var _anim_labels: Dictionary = {} # AnimationPlayer instance id -> Label3D
var _stats_layer: CanvasLayer = null
var _stats_label: Label = null
var _stats_t: float = 0.0
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS # keep drawing while the console pauses the game
process_priority = 1000 # flush after everyone has queued their lines
# One-time: wireframe debug-draw needs the wireframe index buffers generated.
RenderingServer.set_debug_generate_wireframes(true)
_line_mesh = ImmediateMesh.new()
var inst := MeshInstance3D.new()
inst.mesh = _line_mesh
inst.material_override = _line_material()
inst.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(inst)
# ── Public immediate-mode API ──────────────────────────────────────────────────
func line(from: Vector3, to: Vector3, color: Color = Color.YELLOW) -> void:
_lines.append(from)
_lines.append(to)
_colors.append(color)
_colors.append(color)
func ray(from: Vector3, to: Vector3, color: Color = Color.YELLOW) -> void:
line(from, to, color)
# ── Frame flush ────────────────────────────────────────────────────────────────
func _process(delta: float) -> void:
_reconcile(delta)
if DP.b("show_bones"):
_draw_bones()
if DP.b("show_states"):
_update_state_labels()
elif not _state_labels.is_empty():
_clear_state_labels()
if DP.b("show_animation_name"):
_update_anim_labels()
elif not _anim_labels.is_empty():
_clear_anim_labels()
if DP.b("show_grid"):
_draw_grid()
if DP.b("show_axes"):
_draw_axes()
_update_wireframe()
_update_stats(delta)
_flush_lines()
func _flush_lines() -> void:
_line_mesh.clear_surfaces()
if not _lines.is_empty():
_line_mesh.surface_begin(Mesh.PRIMITIVE_LINES)
for i in _lines.size():
_line_mesh.surface_set_color(_colors[i])
_line_mesh.surface_add_vertex(_lines[i])
_line_mesh.surface_end()
_lines.clear()
_colors.clear()
# ── Bone overlay ───────────────────────────────────────────────────────────────
func _draw_bones() -> void:
for sk in _skeletons:
if not is_instance_valid(sk):
continue
var gx := sk.global_transform
for b in sk.get_bone_count():
var parent := sk.get_bone_parent(b)
if parent < 0:
continue
var a := gx * sk.get_bone_global_pose(parent).origin
var c := gx * sk.get_bone_global_pose(b).origin
line(a, c, COLOR_BONE)
# ── Matador AI-state overlay ───────────────────────────────────────────────────
# A billboarded state tag hovers over each matador, coloured by state, with an
# amber velocity vector — reads the whole arena's AI at a glance while tuning combat.
func _update_state_labels() -> void:
var seen: Dictionary = {}
for node: Node in get_tree().get_nodes_in_group(&"matador"):
var m := node as Node3D
if m == null:
continue
var id := m.get_instance_id()
seen[id] = true
var lbl: Label3D = _state_labels.get(id)
if lbl == null:
lbl = _new_state_label()
_state_labels[id] = lbl
var state_name := "?"
if m.has_method(&"ai_state_name"):
state_name = m.call(&"ai_state_name")
lbl.text = state_name
lbl.modulate = _state_color(state_name)
lbl.global_position = m.global_position + Vector3.UP * 2.2
if m is CharacterBody3D:
var v: Vector3 = (m as CharacterBody3D).velocity
v.y = 0.0
if v.length() > 0.5:
var base := m.global_position + Vector3.UP * 0.15
line(base, base + v * 0.18, COLOR_VEL)
for id: int in _state_labels.keys():
if not seen.has(id):
var lbl: Label3D = _state_labels[id]
if is_instance_valid(lbl):
lbl.queue_free()
_state_labels.erase(id)
func _new_state_label() -> Label3D:
var lbl := Label3D.new()
lbl.billboard = BaseMaterial3D.BILLBOARD_ENABLED
lbl.no_depth_test = true
lbl.fixed_size = true
lbl.pixel_size = 0.0006
lbl.font_size = 64
lbl.outline_size = 12
lbl.outline_modulate = Color(0.0, 0.0, 0.0, 0.9)
add_child(lbl)
return lbl
func _clear_state_labels() -> void:
for id: int in _state_labels:
var lbl: Label3D = _state_labels[id]
if is_instance_valid(lbl):
lbl.queue_free()
_state_labels.clear()
const _STATE_COLORS := {
"WANDER": Color(0.70, 0.70, 0.70),
"FLEE": Color(0.40, 0.80, 1.00),
"BRACE": Color(1.00, 0.90, 0.30),
"SIDESTEP": Color(1.00, 0.60, 0.10),
"ATTACK": Color(1.00, 0.30, 0.30),
"ROLL": Color(0.60, 1.00, 0.60),
"THROW": Color(1.00, 0.40, 0.90),
"RAGDOLL": Color(0.40, 0.40, 0.40),
}
func _state_color(state_name: String) -> Color:
return _STATE_COLORS.get(state_name, Color.WHITE)
# ── Animation-name overlay ─────────────────────────────────────────────────────
# A billboarded tag showing the clip each AnimationPlayer is currently playing,
# hovering above its nearest Node3D host — catches wrong / stuck animation states
# at a glance. Players are re-collected on the reconcile tick (see _collect).
func _update_anim_labels() -> void:
var seen: Dictionary = {}
for ap in _anim_players:
if not is_instance_valid(ap):
continue
var clip := ap.current_animation
# Skip finished / dormant sub-players so a host with several AnimationPlayers
# (e.g. a matador's main + olé + death players) doesn't stack empty tags.
if clip.is_empty() and not ap.is_playing():
continue
var host := _node3d_host(ap)
if host == null:
continue
var id := ap.get_instance_id()
seen[id] = true
var lbl: Label3D = _anim_labels.get(id)
if lbl == null:
lbl = _new_state_label()
lbl.modulate = COLOR_ANIM
_anim_labels[id] = lbl
lbl.text = clip if not clip.is_empty() else ""
lbl.global_position = host.global_position + Vector3.UP * 2.6
for id: int in _anim_labels.keys():
if not seen.has(id):
var lbl: Label3D = _anim_labels[id]
if is_instance_valid(lbl):
lbl.queue_free()
_anim_labels.erase(id)
func _clear_anim_labels() -> void:
for id: int in _anim_labels:
var lbl: Label3D = _anim_labels[id]
if is_instance_valid(lbl):
lbl.queue_free()
_anim_labels.clear()
# Nearest Node3D at or above `node` — the anchor an AnimationPlayer's tag hovers over.
func _node3d_host(node: Node) -> Node3D:
var n := node
while n != null:
if n is Node3D:
return n as Node3D
n = n.get_parent()
return null
# ── World reference overlays ───────────────────────────────────────────────────
func _draw_grid() -> void:
var extent := GRID_HALF * GRID_STEP
for i in range(-GRID_HALF, GRID_HALF + 1):
var o := i * GRID_STEP
line(Vector3(o, 0.01, -extent), Vector3(o, 0.01, extent), COLOR_GRID)
line(Vector3(-extent, 0.01, o), Vector3(extent, 0.01, o), COLOR_GRID)
func _draw_axes() -> void:
line(Vector3.ZERO, Vector3.RIGHT * AXIS_LEN, Color.RED) # +X
line(Vector3.ZERO, Vector3.UP * AXIS_LEN, Color.GREEN) # +Y
line(Vector3.ZERO, Vector3(0.0, 0.0, 1.0) * AXIS_LEN, Color.DODGER_BLUE) # +Z
func _update_wireframe() -> void:
var vp := get_viewport()
var want := Viewport.DEBUG_DRAW_WIREFRAME if DP.b("show_wireframe") else Viewport.DEBUG_DRAW_DISABLED
if vp.debug_draw != want:
vp.debug_draw = want
# ── Stats overlay ──────────────────────────────────────────────────────────────
# A 2D corner readout of frame cost + scene load, plus a per-state matador tally so
# a combat slowdown or a stuck AI swarm shows up immediately.
func _update_stats(delta: float) -> void:
if not DP.b("show_stats"):
if _stats_layer != null:
_stats_layer.visible = false
return
_ensure_stats_ui()
_stats_layer.visible = true
_stats_t -= delta
if _stats_t > 0.0:
return
_stats_t = STATS_INTERVAL
_stats_label.text = _compose_stats()
func _ensure_stats_ui() -> void:
if _stats_layer != null:
return
var mono := SystemFont.new()
mono.font_names = PackedStringArray(["JetBrains Mono", "DejaVu Sans Mono", "monospace"])
_stats_layer = CanvasLayer.new()
_stats_layer.layer = 64
add_child(_stats_layer)
var panel := PanelContainer.new()
panel.position = Vector2(8.0, 8.0)
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.0, 0.0, 0.0, 0.55)
bg.set_content_margin_all(6.0)
panel.add_theme_stylebox_override("panel", bg)
_stats_layer.add_child(panel)
_stats_label = Label.new()
_stats_label.add_theme_font_override("font", mono)
_stats_label.add_theme_font_size_override("font_size", 13)
_stats_label.add_theme_color_override("font_color", Color(0.60, 1.0, 0.60))
panel.add_child(_stats_label)
func _compose_stats() -> String:
var fps := int(Performance.get_monitor(Performance.TIME_FPS))
var proc := Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
var phys := Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
var draws := int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME))
var prims := int(Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME))
var vmem := int(Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) / 1048576.0)
var nodes := int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
var phys3d := int(Performance.get_monitor(Performance.PHYSICS_3D_ACTIVE_OBJECTS))
return "fps %d proc %.1fms phys %.1fms\ndraws %d prims %s vmem %dMB\nnodes %d phys3d %d\n%s" % [
fps, proc, phys, draws, _si(prims), vmem, nodes, phys3d, _matador_tally()]
func _matador_tally() -> String:
var mats := get_tree().get_nodes_in_group(&"matador")
if mats.is_empty():
return "matadors 0"
var counts: Dictionary = {}
for node: Node in mats:
var s := "?"
if node.has_method(&"ai_state_name"):
s = node.call(&"ai_state_name")
counts[s] = int(counts.get(s, 0)) + 1
var parts: PackedStringArray = PackedStringArray()
for s: String in counts:
parts.append("%s:%d" % [s.substr(0, 2), counts[s]])
return "matadors %d %s" % [mats.size(), " ".join(parts)]
func _si(n: int) -> String:
if n >= 1000000:
return "%.1fM" % (n / 1000000.0)
if n >= 1000:
return "%.0fk" % (n / 1000.0)
return str(n)
# ── Collision / hit-area overlays (incremental) ────────────────────────────────
func _reconcile(delta: float) -> void:
var col := DP.b("show_collisions")
var hit := DP.b("show_hitboxes")
var bone := DP.b("show_bones")
var anim := DP.b("show_animation_name")
if not (col or hit or bone or anim):
if not _overlays.is_empty():
_clear_overlays()
_skeletons.clear()
_anim_players.clear()
_prev_flags = ""
return
var flags := "%d%d%d%d" % [int(col), int(hit), int(bone), int(anim)]
_reconcile_t -= delta
if flags != _prev_flags: # a toggle just changed — refresh immediately
_prev_flags = flags
_reconcile_t = 0.0
if _reconcile_t > 0.0:
return
_reconcile_t = REBUILD_INTERVAL
_skeletons.clear()
_anim_players.clear()
var wanted: Dictionary = {}
var scene := get_tree().current_scene
if scene != null:
_collect(scene, col, hit, bone, anim, wanted)
# Drop overlays whose shape is gone or no longer wanted. The overlay is a child of
# the collision shape, so it's freed with it — hold it as Variant, since assigning a
# freed instance to any *typed* var (even Object) throws before is_instance_valid()
# can filter it out.
for id: int in _overlays.keys():
var mi: Variant = _overlays[id]
if not wanted.has(id) or not is_instance_valid(mi):
if is_instance_valid(mi):
(mi as Node).queue_free()
_overlays.erase(id)
# Add overlays for newly seen shapes.
for id: int in wanted:
if not _overlays.has(id):
var entry: Array = wanted[id]
_overlays[id] = _attach(entry[0] as CollisionShape3D, entry[1] as bool)
func _collect(node: Node, col: bool, hit: bool, bone: bool, anim: bool, wanted: Dictionary) -> void:
# The audience ("public") is ambient decoration, not a debug target — skip the whole
# crowd subtree so its clapping AnimationPlayers/skeletons don't flood the overlays.
if node.is_in_group(&"public"):
return
if bone and node is Skeleton3D and (node as Skeleton3D).is_visible_in_tree():
_skeletons.append(node as Skeleton3D)
if anim and node is AnimationPlayer:
_anim_players.append(node as AnimationPlayer)
if node is CollisionShape3D:
var cs := node as CollisionShape3D
if cs.shape != null and not cs.disabled and _drawable(cs.shape):
var parent := cs.get_parent()
var is_area := parent is Area3D
# Ragdoll bone colliders (PhysicalBone3D) are excluded — use show_bones for
# the skeleton; otherwise every corpse floods the view with capsules.
var is_body := parent is PhysicsBody3D and not (parent is PhysicalBone3D)
if (is_area and hit) or (is_body and col):
wanted[cs.get_instance_id()] = [cs, is_area]
for child in node.get_children():
_collect(child, col, hit, bone, anim, wanted)
# Skip only the unbounded shapes whose debug mesh is meaningless/huge; concave arena
# geometry is fine — Godot caches each shape's debug mesh, so it's built at most once.
func _drawable(shape: Shape3D) -> bool:
return not (shape is WorldBoundaryShape3D or shape is HeightMapShape3D)
func _attach(cs: CollisionShape3D, is_area: bool) -> MeshInstance3D:
var mi := MeshInstance3D.new()
if is_area:
var solid := _solid_mesh(cs.shape)
mi.mesh = solid if solid != null else cs.shape.get_debug_mesh()
mi.material_override = _translucent_material(COLOR_AREA)
else:
mi.mesh = cs.shape.get_debug_mesh()
mi.material_override = _wire_material(COLOR_BODY)
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
cs.add_child(mi)
return mi
func _clear_overlays() -> void:
for id: int in _overlays:
var mi: Variant = _overlays[id]
if is_instance_valid(mi):
(mi as Node).queue_free()
_overlays.clear()
# ── Meshes & materials ─────────────────────────────────────────────────────────
# A filled mesh matching a primitive shape, for translucent volume overlays. Returns
# null for shapes without a clean solid equivalent (caller falls back to wireframe).
func _solid_mesh(shape: Shape3D) -> Mesh:
if shape is BoxShape3D:
var m := BoxMesh.new()
m.size = (shape as BoxShape3D).size
return m
if shape is SphereShape3D:
var m := SphereMesh.new()
m.radius = (shape as SphereShape3D).radius
m.height = m.radius * 2.0
return m
if shape is CapsuleShape3D:
var m := CapsuleMesh.new()
m.radius = (shape as CapsuleShape3D).radius
m.height = (shape as CapsuleShape3D).height
return m
if shape is CylinderShape3D:
var m := CylinderMesh.new()
m.top_radius = (shape as CylinderShape3D).radius
m.bottom_radius = (shape as CylinderShape3D).radius
m.height = (shape as CylinderShape3D).height
return m
return null
func _line_material() -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.vertex_color_use_as_albedo = true
mat.no_depth_test = true
return mat
func _wire_material(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.albedo_color = color
mat.no_depth_test = true
return mat
func _translucent_material(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.albedo_color = Color(color.r, color.g, color.b, AREA_ALPHA)
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
return mat
+1
View File
@@ -0,0 +1 @@
uid://cgty4al6aqey3
-216
View File
@@ -1,216 +0,0 @@
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()
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 rand_all_btn := Button.new()
rand_all_btn.text = "Randomize All"
rand_all_btn.pressed.connect(_on_randomize_all)
toolbar.add_child(rand_all_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]:
var meta: Dictionary = DP.get_all()[key]
if meta["type"] == TYPE_FLOAT:
_add_float_row(content, key, meta)
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)
var rand_btn := Button.new()
rand_btn.text = "🎲"
rand_btn.tooltip_text = "Randomize"
rand_btn.pressed.connect(func() -> void:
var v := randf_range(meta["min"], meta["max"])
v = snappedf(v, meta["step"])
slider.value = v
)
row.add_child(rand_btn)
# 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 _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()
func _on_randomize_all() -> void:
for key: String in DP.get_all():
var meta: Dictionary = DP.get_all()[key]
if meta["type"] == TYPE_FLOAT:
var v := snappedf(randf_range(meta["min"], meta["max"]), meta["step"])
DP.set_value(key, v)
+152 -38
View File
@@ -7,7 +7,12 @@ signal any_changed(key: String, value: Variant)
const SAVE_PATH := "user://debug_params.cfg" const SAVE_PATH := "user://debug_params.cfg"
# _params holds the full metadata (min/max/step/default/section/type) and is the
# authority the debug menu reads. _values is a flat key→value mirror so the hot
# f()/b() reads — called dozens of times per matador per physics frame — cost a
# single hash lookup instead of a nested dict access. Every write updates both.
var _params: Dictionary = {} var _params: Dictionary = {}
var _values: Dictionary = {}
func _ready() -> void: func _ready() -> void:
@@ -17,25 +22,17 @@ func _ready() -> void:
func _register_all() -> void: func _register_all() -> void:
# ── Movement ────────────────────────────────────────────────────────────── # ── Movement ──────────────────────────────────────────────────────────────
_reg_f("Movement", "thrust_impulse", 18.0, 1.0, 200.0) # WASD/arrows drive the bull: move_accel ramps flat velocity toward max_speed in
_reg_f("Movement", "thrust_vertical", 2.0, 0.0, 20.0) # the input direction; move_friction bleeds it off when no direction is held.
_reg_f("Movement", "thrust_min_angle", 15.0, 0.0, 90.0, 1.0)
_reg_f("Movement", "thrust_max_angle", 90.0, 0.0, 180.0, 1.0)
_reg_f("Movement", "thrust_charge_time", 0.5, 0.05, 2.0, 0.05)
_reg_f("Movement", "max_speed", 50.0, 2.0, 1000.0) _reg_f("Movement", "max_speed", 50.0, 2.0, 1000.0)
_reg_f("Movement", "roll_damping", 0.15, 0.01, 0.99, 0.01) _reg_f("Movement", "move_accel", 120.0, 5.0, 600.0)
_reg_f("Movement", "move_friction", 90.0, 5.0, 600.0)
_reg_f("Movement", "jump_velocity", 4.5, 1.0, 200.0) _reg_f("Movement", "jump_velocity", 4.5, 1.0, 200.0)
_reg_f("Movement", "visual_turn_speed", 12.0, 1.0, 200.0) _reg_f("Movement", "visual_turn_speed", 12.0, 1.0, 200.0)
_reg_f("Movement", "bounce_restitution", 0.55, 0.0, 2.0, 0.05) _reg_f("Movement", "bounce_restitution", 0.55, 0.0, 2.0, 0.05)
_reg_f("Movement", "bounce_min_impact", 1.0, 0.0, 20.0) _reg_f("Movement", "bounce_min_impact", 1.0, 0.0, 20.0)
_reg_f("Movement", "wall_bounce_restitution", 0.6, 0.0, 1.5, 0.05) _reg_f("Movement", "wall_bounce_restitution", 0.6, 0.0, 1.5, 0.05)
_reg_f("Movement", "wall_min_bounce_speed", 6.0, 0.0, 30.0) _reg_f("Movement", "wall_min_bounce_speed", 6.0, 0.0, 30.0)
_reg_f("Movement", "kick_impulse", 8.0, 1.0, 50.0)
_reg_f("Movement", "kick_interval", 0.25, 0.05, 1.0, 0.01)
_reg_f("Movement", "kick_vertical", 0.0, 0.0, 15.0)
_reg_f("Movement", "kick_force_var", 0.2, 0.0, 1.0, 0.01)
_reg_f("Movement", "kick_spread", 10.0, 0.0, 45.0, 1.0)
_reg_f("Movement", "charge_kick_mult", 2.0, 1.0, 5.0)
# ── Dust speed thresholds ───────────────────────────────────────────────── # ── Dust speed thresholds ─────────────────────────────────────────────────
_reg_f("Dust", "dust_walk_spd", 1.5, 0.1, 10.0) _reg_f("Dust", "dust_walk_spd", 1.5, 0.1, 10.0)
_reg_f("Dust", "dust_charge_spd", 7.0, 1.0, 20.0) _reg_f("Dust", "dust_charge_spd", 7.0, 1.0, 20.0)
@@ -58,10 +55,7 @@ func _register_all() -> void:
_reg_f("Tail", "tail_body_radius", 0.35, 0.05, 2.0, 0.05) _reg_f("Tail", "tail_body_radius", 0.35, 0.05, 2.0, 0.05)
_reg_f("Tail", "tail_wag_freq", 4.0, 0.5, 15.0) _reg_f("Tail", "tail_wag_freq", 4.0, 0.5, 15.0)
_reg_f("Tail", "tail_wag_idle", 0.3, 0.0, 2.0, 0.05) _reg_f("Tail", "tail_wag_idle", 0.3, 0.0, 2.0, 0.05)
# ── Charge (both mouse buttons) ────────────────────────────────────────── # ── Charge pose (horns-down lean during dash / roll) ─────────────────────
_reg_f("Charge", "bull_charge_accel", 80.0, 1.0, 400.0)
_reg_f("Charge", "bull_charge_max_speed", 76.0, 5.0, 200.0)
_reg_f("Charge", "charge_ramp_time", 0.75, 0.1, 5.0, 0.05)
_reg_f("Charge", "charge_head_pitch", 0.3, 0.0, 1.2, 0.01) _reg_f("Charge", "charge_head_pitch", 0.3, 0.0, 1.2, 0.01)
_reg_f("Charge", "charge_head_speed", 5.0, 0.5, 20.0) _reg_f("Charge", "charge_head_speed", 5.0, 0.5, 20.0)
# ── Body ────────────────────────────────────────────────────────────────── # ── Body ──────────────────────────────────────────────────────────────────
@@ -91,38 +85,83 @@ func _register_all() -> void:
_reg_f("Camera", "cam_spring_length", 7.0, 1.0, 30.0) _reg_f("Camera", "cam_spring_length", 7.0, 1.0, 30.0)
_reg_f("Camera", "cam_min_vert_angle", -60.0, -90.0, 0.0, 1.0) _reg_f("Camera", "cam_min_vert_angle", -60.0, -90.0, 0.0, 1.0)
_reg_f("Camera", "cam_max_vert_angle", 45.0, 0.0, 90.0, 1.0) _reg_f("Camera", "cam_max_vert_angle", 45.0, 0.0, 90.0, 1.0)
# Trailing "drag" camera: the focus point lags the bull and slides opposite to
# its velocity, so the camera visibly drags behind during a fast charge.
# cam_drag = metres of trail per m/s of bull speed (0 = rigid follow)
# cam_drag_speed = how fast the trail offset eases toward its target
# cam_follow_lerp = how tightly the focus point chases the bull (higher = snappier)
_reg_f("Camera", "cam_drag", 0.12, 0.0, 1.0, 0.01)
_reg_f("Camera", "cam_drag_speed", 3.5, 0.5, 20.0)
_reg_f("Camera", "cam_follow_lerp", 10.0, 1.0, 40.0)
# ── Matador ─────────────────────────────────────────────────────────────── # ── Matador ───────────────────────────────────────────────────────────────
_reg_f("Matador", "mat_spawn_count", 1.0, 1.0, 30.0, 1.0) _reg_f("Matador", "mat_spawn_count", 1.0, 1.0, 30.0, 1.0)
_reg_f("Matador", "mat_walk_speed", 4.0, 0.5, 10.0) _reg_f("Matador", "mat_walk_speed", 5.5, 0.5, 10.0)
_reg_f("Matador", "mat_wander_radius", 22.0, 5.0, 50.0) _reg_f("Matador", "mat_wander_radius", 22.0, 5.0, 50.0)
# Radius of the inner fighting floor: bull + matadors spawn on this ring (so
# they stay off the benches). Tune in the console to hug the bench edge.
_reg_f("Matador", "arena_spawn_radius", 10.0, 2.0, 22.0, 0.5)
_reg_f("Matador", "mat_idle_min", 0.5, 0.0, 5.0, 0.1) _reg_f("Matador", "mat_idle_min", 0.5, 0.0, 5.0, 0.1)
_reg_f("Matador", "mat_idle_max", 2.5, 0.5, 10.0, 0.1) _reg_f("Matador", "mat_idle_max", 2.5, 0.5, 10.0, 0.1)
_reg_f("Matador", "mat_hit_threshold", 4.0, 1.0, 30.0) _reg_f("Matador", "mat_hit_threshold", 4.0, 1.0, 30.0)
_reg_f("Matador", "mat_ragdoll_impulse", 6.0, 0.5, 20.0) _reg_f("Matador", "mat_ragdoll_impulse", 6.0, 0.5, 20.0)
# Ragdoll joint limits — lower swing/twist = stiffer corpse that keeps its
# shape; higher angular damp settles the flail instead of flopping flat.
_reg_f("Matador", "mat_joint_swing", 35.0, 0.0, 180.0, 1.0)
_reg_f("Matador", "mat_joint_twist", 40.0, 0.0, 180.0, 1.0)
_reg_f("Matador", "mat_ragdoll_damp", 1.5, 0.0, 10.0, 0.1)
_reg_f("Matador", "mat_flee_range", 3.5, 2.0, 40.0) _reg_f("Matador", "mat_flee_range", 3.5, 2.0, 40.0)
_reg_f("Matador", "mat_flee_speed", 5.5, 0.5, 12.0) _reg_f("Matador", "mat_flee_speed", 7.0, 0.5, 12.0)
_reg_f("Matador", "mat_charge_speed", 10.0, 1.0, 30.0) _reg_f("Matador", "mat_charge_speed", 10.0, 1.0, 30.0)
_reg_f("Matador", "mat_brace_range", 5.0, 2.0, 20.0) _reg_f("Matador", "mat_brace_range", 5.0, 2.0, 20.0)
_reg_f("Matador", "mat_brace_duration", 0.7, 0.05, 1.5, 0.05) _reg_f("Matador", "mat_brace_duration", 0.7, 0.05, 1.5, 0.05)
_reg_f("Matador", "mat_commit_dist", 2.5, 0.5, 6.0, 0.1) _reg_f("Matador", "mat_commit_dist", 2.5, 0.5, 6.0, 0.1)
_reg_f("Matador", "mat_step_speed", 3.5, 2.0, 20.0) _reg_f("Matador", "mat_step_speed", 6.5, 2.0, 20.0)
_reg_f("Matador", "mat_step_duration", 0.35, 0.1, 1.0, 0.05) _reg_f("Matador", "mat_step_duration", 0.35, 0.1, 1.0, 0.05)
_reg_f("Matador", "mat_dodge_cooldown", 5.0, 0.5, 12.0, 0.25) # How strongly the sidestep biases toward the bull so the blade sweeps through
_reg_f("Matador", "mat_dodge_chance", 0.25, 0.0, 1.0, 0.05) # its path during the pass (0 = pure lateral dodge, 1 = straight at the bull).
_reg_f("Matador", "mat_pass_lunge", 0.35, 0.0, 1.0, 0.05)
_reg_f("Matador", "mat_dodge_cooldown", 2.5, 0.5, 12.0, 0.25)
_reg_f("Matador", "mat_dodge_chance", 0.72, 0.0, 1.0, 0.05)
_reg_f("Matador", "mat_attack_range", 8.0, 2.0, 20.0) _reg_f("Matador", "mat_attack_range", 8.0, 2.0, 20.0)
_reg_f("Matador", "mat_attack_speed", 5.5, 1.0, 12.0) _reg_f("Matador", "mat_attack_speed", 7.5, 1.0, 12.0)
_reg_f("Matador", "mat_attack_duration", 1.8, 0.5, 5.0, 0.1) _reg_f("Matador", "mat_attack_duration", 1.8, 0.5, 5.0, 0.1)
_reg_f("Matador", "mat_attack_min_dist", 2.5, 0.5, 6.0, 0.1) _reg_f("Matador", "mat_attack_min_dist", 3.2, 0.5, 6.0, 0.1)
# Forward drive during the opening of a melee swing — turns the stationary stab
# into an estocada lunge so the blade covers ground toward the bull.
_reg_f("Matador", "mat_lunge_speed", 13.0, 0.0, 20.0)
_reg_f("Matador", "mat_lunge_time", 0.22, 0.0, 0.6, 0.01)
# Reach of the reach-based stab (the reliable melee hit) and its re-strike gap.
# The bull dies if it comes inside mat_stab_reach of a matador that's attacking,
# bracing, or passing with a drawn blade — closing in is genuinely deadly.
_reg_f("Matador", "mat_stab_reach", 1.7, 0.5, 4.0, 0.1)
_reg_f("Matador", "mat_stab_cooldown", 0.6, 0.1, 3.0, 0.1)
# Half-angle (deg) the matador must be facing the bull within for a stab to land.
# Stops "sword's off to the side but I still died" — you're only gored when the
# blade is actually presented at you.
_reg_f("Matador", "mat_stab_arc", 55.0, 5.0, 180.0, 5.0)
_reg_f("Matador", "mat_roll_chance", 0.35, 0.0, 1.0, 0.05) _reg_f("Matador", "mat_roll_chance", 0.35, 0.0, 1.0, 0.05)
_reg_f("Matador", "mat_roll_duration", 0.45, 0.1, 1.5, 0.05) _reg_f("Matador", "mat_roll_duration", 0.45, 0.1, 1.5, 0.05)
# Draw the sword from the holster once the bull closes to within this range; # Playback rate of the Draw_weapon clip used for both drawing and sheathing —
# re-sheathe when it retreats well beyond it (hysteresis in _update_sword_carry). # higher = snappier unholster/holster.
_reg_f("Matador", "mat_draw_range", 12.0, 2.0, 40.0) _reg_f("Matador", "mat_draw_speed", 1.9, 0.25, 3.0, 0.05)
# Cross-fade between matador animation clips — smooths Run/Attack/Taunt cuts. # Cross-fade between matador animation clips — smooths Run/Attack/Taunt cuts.
_reg_f("Matador", "mat_anim_blend", 0.12, 0.0, 0.5, 0.01) _reg_f("Matador", "mat_anim_blend", 0.12, 0.0, 0.5, 0.01)
# ── Matador heuristics (utility AI) ───────────────────────────────────────
# Every mat_decide_interval seconds the matador scores its options — dodge the
# pass, close for a strike, retreat, throw the blade, or strut/taunt — and picks
# the highest. These weights bias the personality: crank aggression for a reckless
# bullfighter, caution for a slippery one. mat_w_commit is a stickiness bonus for
# the current choice so it doesn't dither between two near-tied options.
_reg_f("Matador", "mat_decide_interval", 0.2, 0.05, 1.0, 0.05)
_reg_f("Matador", "mat_w_aggression", 1.4, 0.0, 3.0, 0.05)
_reg_f("Matador", "mat_w_caution", 1.0, 0.0, 3.0, 0.05)
_reg_f("Matador", "mat_w_flee", 0.8, 0.0, 3.0, 0.05)
_reg_f("Matador", "mat_w_showmanship", 1.0, 0.0, 3.0, 0.05)
# Small so it smooths dithering without locking the matador into idling.
_reg_f("Matador", "mat_w_commit", 0.15, 0.0, 2.0, 0.05)
# ── Sword ───────────────────────────────────────────────────────────────── # ── Sword ─────────────────────────────────────────────────────────────────
# Local seating of the sword on its weapon bones (hand grip + hip holster). # Local seating of the sword in the right hand (drawn / fighting).
# Defaults are identity — the bones are oriented in Blender to seat the sword # Defaults are identity — the bone is oriented in Blender to seat the sword
# directly; these exist to fine-tune in-game without re-exporting. # directly; these exist to fine-tune in-game without re-exporting.
_reg_f("Sword", "sword_pos_x", 0.0, -1.0, 1.0, 0.01) _reg_f("Sword", "sword_pos_x", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "sword_pos_y", 0.0, -1.0, 1.0, 0.01) _reg_f("Sword", "sword_pos_y", 0.0, -1.0, 1.0, 0.01)
@@ -130,18 +169,30 @@ func _register_all() -> void:
_reg_f("Sword", "sword_rot_x", 0.0, -180.0, 180.0, 1.0) _reg_f("Sword", "sword_rot_x", 0.0, -180.0, 180.0, 1.0)
_reg_f("Sword", "sword_rot_y", 0.0, -180.0, 180.0, 1.0) _reg_f("Sword", "sword_rot_y", 0.0, -180.0, 180.0, 1.0)
_reg_f("Sword", "sword_rot_z", 0.0, -180.0, 180.0, 1.0) _reg_f("Sword", "sword_rot_z", 0.0, -180.0, 180.0, 1.0)
# Local seating of the sword in the hip holster (sheathed / wandering).
# Independent of the hand grip so each pose can be tuned without affecting the
# other. Defaults angle the blade down along the matador's side.
_reg_f("Sword", "sword_rest_pos_x", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "sword_rest_pos_y", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "sword_rest_pos_z", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "sword_rest_rot_x", -120.0, -180.0, 180.0, 1.0)
_reg_f("Sword", "sword_rest_rot_y", 0.0, -180.0, 180.0, 1.0)
_reg_f("Sword", "sword_rest_rot_z", 0.0, -180.0, 180.0, 1.0)
# ── Sword throw (matador ranged attack) ────────────────────────────────── # ── Sword throw (matador ranged attack) ──────────────────────────────────
# Probability of committing to a throw each time the matador becomes eligible;
# kept low so they favour closing in for a melee Attack over throwing.
_reg_f("Sword", "mat_throw_chance", 0.15, 0.0, 1.0, 0.05)
# Delay before a fresh sword appears in the holster after one is thrown. # Delay before a fresh sword appears in the holster after one is thrown.
_reg_f("Sword", "mat_resword_delay", 2.0, 0.0, 10.0, 0.5) _reg_f("Sword", "mat_resword_delay", 2.0, 0.0, 10.0, 0.5)
_reg_f("Sword", "mat_throw_range", 18.0, 5.0, 40.0) _reg_f("Sword", "mat_throw_range", 18.0, 5.0, 40.0)
_reg_f("Sword", "mat_throw_min_dist", 5.0, 2.0, 15.0, 0.1) _reg_f("Sword", "mat_throw_min_dist", 5.0, 2.0, 15.0, 0.1)
_reg_f("Sword", "mat_throw_windup", 0.7, 0.2, 2.0, 0.05) _reg_f("Sword", "mat_throw_windup", 0.5, 0.2, 2.0, 0.05)
_reg_f("Sword", "mat_throw_release", 0.55, 0.1, 0.95, 0.01) _reg_f("Sword", "mat_throw_release", 0.55, 0.1, 0.95, 0.01)
_reg_f("Sword", "mat_throw_speed", 18.0, 5.0, 60.0) _reg_f("Sword", "mat_throw_speed", 24.0, 5.0, 60.0)
_reg_f("Sword", "mat_throw_spin", 14.0, 0.0, 40.0) _reg_f("Sword", "mat_throw_spin", 14.0, 0.0, 40.0)
# Gravity scale on the thrown blade (low = flies straight to the aim point) and
# the vertical aim offset from the bull origin down onto its low collision body.
_reg_f("Sword", "mat_throw_gravity", 0.25, 0.0, 1.0, 0.05)
_reg_f("Sword", "mat_throw_aim_y", -0.45,-1.5, 0.5, 0.05)
# Aim scatter (± degrees) so thrown blades can be read and dodged on the move.
_reg_f("Sword", "mat_throw_spread", 11.0, 0.0, 30.0, 0.5)
_reg_f("Sword", "mat_throw_arm_cock", 70.0,-180.0, 180.0, 1.0) _reg_f("Sword", "mat_throw_arm_cock", 70.0,-180.0, 180.0, 1.0)
_reg_f("Sword", "mat_throw_arm_release", -90.0,-180.0, 180.0, 1.0) _reg_f("Sword", "mat_throw_arm_release", -90.0,-180.0, 180.0, 1.0)
_reg_f("Sword", "mat_throw_elbow", 80.0,-180.0, 180.0, 1.0) _reg_f("Sword", "mat_throw_elbow", 80.0,-180.0, 180.0, 1.0)
@@ -151,12 +202,54 @@ func _register_all() -> void:
_reg_f("Abilities", "kick_strength", 12.0, 1.0, 30.0) _reg_f("Abilities", "kick_strength", 12.0, 1.0, 30.0)
_reg_f("Abilities", "slam_cooldown", 4.0, 0.5, 15.0, 0.1) _reg_f("Abilities", "slam_cooldown", 4.0, 0.5, 15.0, 0.1)
_reg_f("Abilities", "slam_jump_velocity", 7.0, 2.0, 20.0) _reg_f("Abilities", "slam_jump_velocity", 7.0, 2.0, 20.0)
# Descent gravity multiplier — higher = faster crash, less airtime.
_reg_f("Abilities", "slam_fall_mult", 3.0, 1.0, 8.0, 0.5)
# Upward launch velocity (m/s) given to a slammed matador's ragdoll — pops them
# skyward. ~7 ≈ a 2.5 m hop; 0 disables the launch.
_reg_f("Abilities", "slam_launch_up", 7.0, 0.0, 15.0, 0.5)
_reg_f("Abilities", "slam_range", 4.0, 1.0, 10.0, 0.1) _reg_f("Abilities", "slam_range", 4.0, 1.0, 10.0, 0.1)
_reg_f("Abilities", "slam_strength", 14.0, 1.0, 30.0) _reg_f("Abilities", "slam_strength", 14.0, 1.0, 30.0)
_reg_f("Abilities", "dash_cooldown", 2.0, 0.5, 10.0, 0.1) _reg_f("Abilities", "dash_cooldown", 2.0, 0.5, 10.0, 0.1)
_reg_f("Abilities", "dash_speed", 66.0, 5.0, 200.0) _reg_f("Abilities", "dash_speed", 66.0, 5.0, 200.0)
# ── Debug ───────────────────────────────────────────────────────────────── # ── Roll (Rammus Powerball) ───────────────────────────────────────────────
# The bull curls into a ball and ACCELERATES the longer it rolls, from
# roll_base_speed up to roll_max_speed over roll_rampup_time. Turning is wide
# (momentum steering via roll_turn). On slamming into a matador the ball POPS:
# the target is launched skyward (roll_knockup) and the roll ends.
_reg_f("Roll", "roll_cooldown", 3.0, 0.5, 15.0, 0.1)
_reg_f("Roll", "roll_duration", 2.5, 0.5, 6.0, 0.1) # max roll time
_reg_f("Roll", "roll_base_speed", 28.0, 5.0, 120.0) # speed at the start of the roll
_reg_f("Roll", "roll_max_speed", 95.0, 10.0, 200.0) # top speed once fully ramped
_reg_f("Roll", "roll_rampup_time", 2.0, 0.2, 6.0, 0.1) # time to reach top speed
_reg_f("Roll", "roll_turn", 7.0, 0.2, 20.0) # steer rate (WASD/arrows; higher = tighter)
_reg_f("Roll", "roll_spin_radius", 1.4, 0.3, 4.0, 0.1) # ball radius for tumble spin (smaller = faster)
_reg_f("Roll", "roll_hit_radius", 2.4, 0.5, 6.0, 0.1) # contact radius that pops the ball
_reg_f("Roll", "roll_hit_strength", 22.0, 1.0, 40.0) # horizontal launch on the pop
_reg_f("Roll", "roll_knockup", 11.0, 0.0, 25.0) # vertical knock-up on the pop
# ── Debug overlays (see debug_draw.gd) ───────────────────────────────────
_reg_b("Debug", "show_collisions", false) _reg_b("Debug", "show_collisions", false)
# PS1 post-process filter (see ps1_filter.gd on the fullscreen ColorRect):
# screen-covering CRT/PS1 shader — pixelation, colour-banding + dither, and a
# rolling scanline. Keys map 1:1 onto the shader uniforms; show_ps1 toggles the
# whole overlay. Ranges mirror the shader's hint_range so the console can't push
# a uniform out of bounds.
_reg_b("PS1", "show_ps1", true)
_reg_f("PS1", "ps1_pixel_scale", 4.0, 1.0, 8.0, 1.0)
_reg_f("PS1", "ps1_color_depth", 5.0, 2.0, 8.0, 1.0)
_reg_f("PS1", "ps1_dither_strength", 0.8, 0.0, 2.0, 0.05)
_reg_f("PS1", "ps1_scanline_strength", 0.2, 0.0, 1.0, 0.05)
_reg_f("PS1", "ps1_roll_speed", 3.0, 0.0, 10.0, 0.1)
_reg_f("PS1", "ps1_roll_strength", 1.0, 0.0, 1.0, 0.05)
_reg_b("Debug", "show_hitboxes", false)
_reg_b("Debug", "show_bones", false)
_reg_b("Debug", "show_raycasts", false)
_reg_b("Debug", "show_tail", false)
_reg_b("Debug", "show_stats", false)
_reg_b("Debug", "show_states", false)
_reg_b("Debug", "show_animation_name", false)
_reg_b("Debug", "show_wireframe", false)
_reg_b("Debug", "show_grid", false)
_reg_b("Debug", "show_axes", false)
func _reg_f(section: String, key: String, default: float, func _reg_f(section: String, key: String, default: float,
@@ -170,6 +263,7 @@ func _reg_f(section: String, key: String, default: float,
"max": max_val, "max": max_val,
"step": step, "step": step,
} }
_values[key] = default
func _reg_b(section: String, key: String, default: bool) -> void: func _reg_b(section: String, key: String, default: bool) -> void:
@@ -179,26 +273,28 @@ func _reg_b(section: String, key: String, default: bool) -> void:
"section": section, "section": section,
"type": TYPE_BOOL, "type": TYPE_BOOL,
} }
_values[key] = default
## Read a float param. Panics on unknown key — intentional: typos should be loud. ## Read a float param. Panics on unknown key — intentional: typos should be loud.
func f(key: String) -> float: func f(key: String) -> float:
return _params[key]["value"] as float return _values[key] as float
## Read a bool param. ## Read a bool param.
func b(key: String) -> bool: func b(key: String) -> bool:
return _params[key]["value"] as bool return _values[key] as bool
func set_value(key: String, value: Variant) -> void: func set_value(key: String, value: Variant) -> void:
if not _params.has(key): if not _params.has(key):
return return
_params[key]["value"] = value _params[key]["value"] = value
_values[key] = value
any_changed.emit(key, value) any_changed.emit(key, value)
## Returns the full metadata dict — used by DebugMenu to build UI. ## Returns the full metadata dict — used by Console to build UI.
func get_all() -> Dictionary: func get_all() -> Dictionary:
return _params return _params
@@ -224,9 +320,27 @@ func load_saved() -> void:
if p["type"] == TYPE_FLOAT: if p["type"] == TYPE_FLOAT:
val = clampf(val as float, p["min"] as float, p["max"] as float) val = clampf(val as float, p["min"] as float, p["max"] as float)
_params[key]["value"] = val _params[key]["value"] = val
_values[key] = val
func reset_all() -> void: func reset_all() -> void:
for key: String in _params: for key: String in _params:
_params[key]["value"] = _params[key]["default"] var default: Variant = _params[key]["default"]
_params[key]["value"] = default
_values[key] = default
any_changed.emit("__all__", null) any_changed.emit("__all__", null)
## Reset a single param to its default. No-op on unknown key.
func reset(key: String) -> void:
if not _params.has(key):
return
var default: Variant = _params[key]["default"]
_params[key]["value"] = default
_values[key] = default
any_changed.emit(key, default)
## True when the param currently differs from its registered default.
func is_modified(key: String) -> bool:
return _params.has(key) and _params[key]["value"] != _params[key]["default"]
BIN
View File
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://fu84fchl1im6"
path="res://.godot/imported/Cinzel.ttf-b9b78caa7497d2ec78b922cb77f34333.fontdata"
[deps]
source_file="res://fonts/Cinzel.ttf"
dest_files=["res://.godot/imported/Cinzel.ttf-b9b78caa7497d2ec78b922cb77f34333.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://rcfn6xhs7fur"
path="res://.godot/imported/CinzelDecorative-Bold.ttf-fd8dd0c7ef2ceb6acaeb4b78a3c0a47b.fontdata"
[deps]
source_file="res://fonts/CinzelDecorative-Bold.ttf"
dest_files=["res://.godot/imported/CinzelDecorative-Bold.ttf-fd8dd0c7ef2ceb6acaeb4b78a3c0a47b.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
[remap]
importer="font_data_dynamic"
type="FontFile"
uid="uid://cgppsmy8krkhw"
path="res://.godot/imported/CinzelDecorative-Regular.ttf-818749a2a58435ecbb00dae02eb6079a.fontdata"
[deps]
source_file="res://fonts/CinzelDecorative-Regular.ttf"
dest_files=["res://.godot/imported/CinzelDecorative-Regular.ttf-818749a2a58435ecbb00dae02eb6079a.fontdata"]
[params]
Rendering=null
antialiasing=1
generate_mipmaps=false
disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
allow_system_fallback=true
force_autohinter=false
modulate_color_glyphs=false
hinting=3
subpixel_positioning=4
keep_rounding_remainders=true
oversampling=0.0
Fallbacks=null
fallbacks=[]
Compress=null
compress=true
preload=[]
language_support={}
script_support={}
opentype_features={}
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Cinzel Project Authors (https://github.com/NDISCOVER/Cinzel)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+33
View File
@@ -0,0 +1,33 @@
class_name GameVersion
extends RefCounted
## Single source of truth for the game's version string.
##
## The numeric version lives in project.godot (application/config/version) so it
## also feeds export metadata; here we read it back and layer on the pre-release
## CHANNEL and a human-readable STAGE label. We're pre-1.0 — every 0.x build is
## understood to be unstable, in-development software (see the recommendations in
## the commit / PR notes for why 0.x is the convention for early game dev).
## Pre-release channel appended to the number, SemVer-style (e.g. 0.1.0-alpha).
const CHANNEL: String = "alpha"
## Friendly stage banner shown to players so it's unmistakably an early build.
const STAGE: String = "Early Development Build"
## Bare numeric version, e.g. "0.1.0". Read from project settings.
static func number() -> String:
return str(ProjectSettings.get_setting("application/config/version", "0.0.0"))
## Full version tag, e.g. "v0.1.0-alpha".
static func string() -> String:
var s := "v" + number()
if not CHANNEL.is_empty():
s += "-" + CHANNEL
return s
## Version tag + stage banner, e.g. "v0.1.0-alpha · Early Development Build".
static func full() -> String:
return "%s · %s" % [string(), STAGE]
+1
View File
@@ -0,0 +1 @@
uid://f4bvu1mnvpiu
+257 -187
View File
@@ -1,171 +1,256 @@
extends CanvasLayer extends CanvasLayer
const _GOLD := Color(1.00, 0.78, 0.22) const _GOLD := Color(1.00, 0.78, 0.22)
const _CREAM := Color(0.88, 0.83, 0.72) const _CREAM := Color(0.93, 0.88, 0.72)
const _MUTED := Color(0.65, 0.60, 0.48) const _MUTED := Color(0.66, 0.60, 0.46)
const _BG := Color(0.06, 0.04, 0.02, 0.78)
const _BADGE := Color(0.16, 0.10, 0.03, 0.90) # Per-ability trim colour, tying each tile (and its key cap) to that ability's FX.
const _BORDER := Color(0.65, 0.48, 0.15, 0.55) # Order matches _ABILITY_ICONS: kick, slam, dash, roll.
const _ABILITY_ACCENT: Array[Color] = [
Color(1.00, 0.62, 0.16), # Kick — ember orange
Color(0.98, 0.34, 0.20), # Slam — crimson
Color(0.45, 0.75, 1.00), # Dash — sky blue
Color(1.00, 0.82, 0.32), # Roll — gold
]
const _ABILITY_ICONS: Array[String] = [ const _ABILITY_ICONS: Array[String] = [
"res://HUD/HUD_0000_ability_JALG.png", "res://HUD/KICK.png",
"res://HUD/HUD_0001_ability_SLAM.png", "res://HUD/SLAM.png",
"res://HUD/HUD_0002_ability_DASH.png", "res://HUD/DASH.png",
"res://HUD/ROLL.png",
] ]
const _ABILITY_KEYS: Array[String] = ["F", "G", "H"] const _ABILITY_ACTIONS: Array[StringName] = [
&"ability_kick", &"ability_slam", &"ability_dash", &"ability_roll",
]
# Fallback label shown in a slot whose icon file fails to load.
const _ABILITY_NAMES: Array[String] = ["Kick", "Slam", "Dash", "Roll"]
var _controls_visible: bool = true var _controls_visible: bool = false
var _controls_panel: PanelContainer var _controls_panel: PanelContainer
var _toggle_btn: Button var _toggle_btn: Button
var _tab_hint: Label
var _spawner: Node var _spawner: Node
var _player: Node = null var _player: Node = null
var _died_connected: bool = false
var _cd_overlays: Array[ColorRect] = [] var _cd_overlays: Array[ColorRect] = []
var _health_segments: Array[ColorRect] = [] var _cd_labels: Array[Label] = []
var _health_connected: bool = false var _ability_bar: HBoxContainer
var _ability_panels: Array[PanelContainer] = []
# Win/lose screen. _game_over latches so the result is shown once; _result_win
# records which way it went (read by the gameplay test).
const _WIN_VIDEO := "res://videos/bull_win.ogv" # the bull triumphs
const _LOSS_VIDEO := "res://videos/matador_win.ogv" # the matador triumphs
var _game_over: bool = false
var _result_win: bool = false
var _over_root: Control
var _over_video: VideoStreamPlayer
func _ready() -> void: func _ready() -> void:
layer = 11 layer = 11
process_mode = Node.PROCESS_MODE_ALWAYS process_mode = Node.PROCESS_MODE_ALWAYS
_build_controls_panel() _build_controls_panel()
_build_tab_hint()
_build_ability_bar() _build_ability_bar()
_build_health_bar() _build_version_label()
_build_game_over()
_find_spawner.call_deferred() _find_spawner.call_deferred()
# Unobtrusive early-build tag in the bottom-right corner.
func _build_version_label() -> void:
var lbl := Label.new()
lbl.text = GameVersion.full()
lbl.add_theme_color_override("font_color", _MUTED)
lbl.add_theme_font_size_override("font_size", 12)
lbl.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
lbl.grow_horizontal = Control.GROW_DIRECTION_BEGIN
lbl.grow_vertical = Control.GROW_DIRECTION_BEGIN
lbl.offset_right = -14.0
lbl.offset_bottom = -10.0
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(lbl)
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
if _player == null: if _player == null:
var players := get_tree().get_nodes_in_group(&"player") var players := get_tree().get_nodes_in_group(&"player")
if not players.is_empty(): if not players.is_empty():
_player = players[0] _player = players[0]
return return
if not _health_connected: if not _died_connected:
_health_connected = true _died_connected = true
_player.health_changed.connect(_on_health_changed) _player.died.connect(_on_player_died)
_on_health_changed(_player.health)
for i: int in _cd_overlays.size(): for i: int in _cd_overlays.size():
var fraction: float = _player.call(&"ability_cooldown_fraction", i) var fraction: float = _player.call(&"ability_cooldown_fraction", i)
var ov := _cd_overlays[i] var ov := _cd_overlays[i]
ov.anchor_top = 1.0 - fraction ov.anchor_top = 1.0 - fraction
ov.anchor_bottom = 1.0 ov.anchor_bottom = 1.0
var remaining: float = _player.call(&"ability_cooldown_remaining", i)
var lbl := _cd_labels[i]
if remaining > 0.0:
lbl.text = "%.1fs" % remaining
lbl.visible = true
else:
lbl.visible = false
func _find_spawner() -> void: func _find_spawner() -> void:
var spawners := get_tree().get_nodes_in_group(&"matador_spawn") var spawners := get_tree().get_nodes_in_group(&"matador_spawn")
if not spawners.is_empty(): if spawners.is_empty():
return
_spawner = spawners[0] _spawner = spawners[0]
_spawner.all_defeated.connect(_on_all_defeated)
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo: if event is InputEventKey and event.pressed and not event.echo:
if event.physical_keycode == KEY_TAB: if _game_over and event.physical_keycode in [KEY_ENTER, KEY_KP_ENTER]:
_restart()
elif event.physical_keycode == KEY_TAB and not _game_over:
_toggle_controls() _toggle_controls()
elif event.physical_keycode == KEY_ESCAPE:
_return_to_menu()
func _return_to_menu() -> void:
get_tree().paused = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
get_tree().change_scene_to_file("res://MainMenu.tscn")
func _restart() -> void:
get_tree().paused = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
get_tree().reload_current_scene()
func _toggle_controls() -> void: func _toggle_controls() -> void:
_controls_visible = not _controls_visible _controls_visible = not _controls_visible
_controls_panel.visible = _controls_visible _controls_panel.visible = _controls_visible
_tab_hint.visible = not _controls_visible
_toggle_btn.text = "Hide controls" if _controls_visible else "Show controls" _toggle_btn.text = "Hide controls" if _controls_visible else "Show controls"
func _on_reset_pressed() -> void: # ── Win / lose ──────────────────────────────────────────────────────────────
if get_tree().paused: # The bull takes one clean blade and it's over; clear the last matador and it's a
get_tree().paused = false # victory. Both routes end here — the arena freezes and the result is proclaimed.
if _spawner:
_spawner.reset_spawn() func _on_player_died(cause: String = "") -> void:
_show_game_over(false, cause)
func _build_health_bar() -> void: func _on_all_defeated() -> void:
var bar := HBoxContainer.new() _show_game_over(true)
bar.add_theme_constant_override("separation", 4)
bar.anchor_left = 0.5
bar.anchor_right = 0.5
bar.anchor_top = 0.0
bar.anchor_bottom = 0.0
bar.grow_horizontal = Control.GROW_DIRECTION_BOTH
bar.offset_left = -90.0
bar.offset_top = 16.0
bar.offset_bottom = 16.0
add_child(bar)
var bg_style := StyleBoxFlat.new()
bg_style.bg_color = _BG
bg_style.corner_radius_top_left = 4
bg_style.corner_radius_top_right = 4
bg_style.corner_radius_bottom_left = 4
bg_style.corner_radius_bottom_right = 4
bg_style.border_width_left = 1
bg_style.border_width_right = 1
bg_style.border_width_top = 1
bg_style.border_width_bottom = 1
bg_style.border_color = _BORDER
bg_style.content_margin_left = 6.0
bg_style.content_margin_right = 6.0
bg_style.content_margin_top = 6.0
bg_style.content_margin_bottom = 6.0
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", bg_style)
bar.add_child(panel)
var inner := HBoxContainer.new()
inner.add_theme_constant_override("separation", 3)
panel.add_child(inner)
for i: int in 10:
var seg := ColorRect.new()
seg.custom_minimum_size = Vector2(14.0, 14.0)
seg.color = Color(0.72, 0.08, 0.08)
inner.add_child(seg)
_health_segments.append(seg)
func _on_health_changed(new_health: int) -> void: func _show_game_over(win: bool, _cause: String = "") -> void:
for i: int in _health_segments.size(): if _game_over:
_health_segments[i].color = Color(0.72, 0.08, 0.08) if i < new_health \ return
else Color(0.20, 0.10, 0.10) _game_over = true
_result_win = win
if _controls_panel:
_controls_panel.visible = false
if _tab_hint:
_tab_hint.visible = false
# Swap in the matching outcome clip and loop it behind the buttons.
var path := _WIN_VIDEO if win else _LOSS_VIDEO
if ResourceLoader.exists(path):
_over_video.stream = load(path)
_over_video.play()
_over_root.visible = true
_over_root.modulate.a = 0.0
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
get_tree().paused = true
create_tween().tween_property(_over_root, "modulate:a", 1.0, 0.5)
func _build_game_over() -> void:
_over_root = Control.new()
_over_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_over_root.process_mode = Node.PROCESS_MODE_ALWAYS
_over_root.visible = false
add_child(_over_root)
# Full-screen outcome clip (bull win / matador win), looping.
_over_video = VideoStreamPlayer.new()
_over_video.set_anchors_preset(Control.PRESET_FULL_RECT)
_over_video.expand = true
_over_video.process_mode = Node.PROCESS_MODE_ALWAYS
_over_video.mouse_filter = Control.MOUSE_FILTER_IGNORE
_over_video.finished.connect(_over_video.play) # loop the outcome clip
_over_root.add_child(_over_video)
# Buttons overlaid at the bottom of the clip.
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 16)
row.anchor_left = 0.5
row.anchor_right = 0.5
row.anchor_top = 1.0
row.anchor_bottom = 1.0
row.grow_horizontal = Control.GROW_DIRECTION_BOTH
row.grow_vertical = Control.GROW_DIRECTION_BEGIN
row.offset_bottom = -48.0
_over_root.add_child(row)
var again := _make_button("Go Again (Enter)")
again.pressed.connect(_restart)
row.add_child(again)
var menu := _make_button("Main Menu")
menu.pressed.connect(_return_to_menu)
row.add_child(menu)
const _SLOT_SEPARATION: int = 10
func _build_ability_bar() -> void: func _build_ability_bar() -> void:
var bar := HBoxContainer.new() _ability_bar = HBoxContainer.new()
bar.add_theme_constant_override("separation", 10) _ability_bar.add_theme_constant_override("separation", _SLOT_SEPARATION)
bar.anchor_left = 0.5 _ability_bar.anchor_left = 0.5
bar.anchor_right = 0.5 _ability_bar.anchor_right = 0.5
bar.anchor_top = 1.0 _ability_bar.anchor_top = 1.0
bar.anchor_bottom = 1.0 _ability_bar.anchor_bottom = 1.0
bar.grow_horizontal = Control.GROW_DIRECTION_BOTH _ability_bar.grow_horizontal = Control.GROW_DIRECTION_BOTH
bar.grow_vertical = Control.GROW_DIRECTION_BEGIN _ability_bar.grow_vertical = Control.GROW_DIRECTION_BEGIN
bar.offset_left = -120.0 _ability_bar.offset_bottom = -20.0
bar.offset_bottom = -20.0 add_child(_ability_bar)
add_child(bar) for i: int in 4:
for i: int in 3: _ability_bar.add_child(_build_ability_slot(i))
bar.add_child(_build_ability_slot(i)) _layout_ability_bar()
get_viewport().size_changed.connect(_layout_ability_bar)
# Size the ability slots as a fraction of the viewport (clamped) so the bar scales
# with the window, and keep it horizontally centred by matching the HBox offsets.
func _layout_ability_bar() -> void:
var vp := get_viewport().get_visible_rect().size
var slot := clampf(minf(vp.x * 0.055, vp.y * 0.11), 56.0, 104.0)
for panel: PanelContainer in _ability_panels:
panel.custom_minimum_size = Vector2(slot, slot)
var total := slot * 4.0 + float(_SLOT_SEPARATION) * 3.0
_ability_bar.offset_left = -total * 0.5
_ability_bar.offset_right = total * 0.5
func _build_ability_slot(idx: int) -> Control: func _build_ability_slot(idx: int) -> Control:
var vbox := VBoxContainer.new() var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 4) vbox.add_theme_constant_override("separation", 4)
var bg_style := StyleBoxFlat.new()
bg_style.bg_color = _BG
bg_style.corner_radius_top_left = 6
bg_style.corner_radius_top_right = 6
bg_style.corner_radius_bottom_left = 6
bg_style.corner_radius_bottom_right = 6
bg_style.border_width_left = 1
bg_style.border_width_right = 1
bg_style.border_width_top = 1
bg_style.border_width_bottom = 1
bg_style.border_color = _BORDER
bg_style.content_margin_left = 4.0
bg_style.content_margin_right = 4.0
bg_style.content_margin_top = 4.0
bg_style.content_margin_bottom = 4.0
var panel := PanelContainer.new() var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(72.0, 72.0) panel.custom_minimum_size = Vector2(72.0, 72.0)
panel.add_theme_stylebox_override("panel", bg_style) panel.add_theme_stylebox_override(
"panel", UiTheme.plaque(UiTheme.LEATHER, _ABILITY_ACCENT[idx], 4, 5.0, 5.0))
vbox.add_child(panel) vbox.add_child(panel)
_ability_panels.append(panel)
var stack := Control.new() var stack := Control.new()
stack.set_anchors_preset(Control.PRESET_FULL_RECT) stack.set_anchors_preset(Control.PRESET_FULL_RECT)
@@ -174,9 +259,23 @@ func _build_ability_slot(idx: int) -> Control:
if ResourceLoader.exists(_ABILITY_ICONS[idx]): if ResourceLoader.exists(_ABILITY_ICONS[idx]):
var icon := TextureRect.new() var icon := TextureRect.new()
icon.texture = load(_ABILITY_ICONS[idx]) icon.texture = load(_ABILITY_ICONS[idx])
# Ignore the texture's native size so a large source PNG doesn't blow the
# slot's minimum size out; scale-to-fit the box while keeping aspect.
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon.set_anchors_preset(Control.PRESET_FULL_RECT) icon.set_anchors_preset(Control.PRESET_FULL_RECT)
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
stack.add_child(icon) stack.add_child(icon)
else:
var name_lbl := Label.new()
name_lbl.text = _ABILITY_NAMES[idx]
name_lbl.set_anchors_preset(Control.PRESET_FULL_RECT)
name_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
name_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
name_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
name_lbl.add_theme_color_override("font_color", _GOLD)
name_lbl.add_theme_font_size_override("font_size", 16)
stack.add_child(name_lbl)
var cd_overlay := ColorRect.new() var cd_overlay := ColorRect.new()
cd_overlay.color = Color(0.0, 0.0, 0.0, 0.70) cd_overlay.color = Color(0.0, 0.0, 0.0, 0.70)
@@ -188,35 +287,49 @@ func _build_ability_slot(idx: int) -> Control:
stack.add_child(cd_overlay) stack.add_child(cd_overlay)
_cd_overlays.append(cd_overlay) _cd_overlays.append(cd_overlay)
var cd_lbl := Label.new()
cd_lbl.set_anchors_preset(Control.PRESET_FULL_RECT)
cd_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
cd_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
cd_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
cd_lbl.visible = false
cd_lbl.add_theme_color_override("font_color", Color.WHITE)
cd_lbl.add_theme_font_size_override("font_size", 18)
stack.add_child(cd_lbl)
_cd_labels.append(cd_lbl)
var key_lbl := Label.new() var key_lbl := Label.new()
key_lbl.text = _ABILITY_KEYS[idx] key_lbl.text = Controls.get_key_label(_ABILITY_ACTIONS[idx])
key_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER key_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
key_lbl.add_theme_color_override("font_color", _GOLD) key_lbl.add_theme_font_override("font", UiFonts.body())
key_lbl.add_theme_font_size_override("font_size", 12) key_lbl.add_theme_color_override("font_color", _ABILITY_ACCENT[idx].lerp(_CREAM, 0.35))
key_lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
key_lbl.add_theme_constant_override("outline_size", 3)
key_lbl.add_theme_font_size_override("font_size", 13)
vbox.add_child(key_lbl) vbox.add_child(key_lbl)
return vbox return vbox
func _build_tab_hint() -> void:
_tab_hint = Label.new()
_tab_hint.text = "Press 'Tab' to see controls"
_tab_hint.anchor_left = 0.0
_tab_hint.anchor_top = 1.0
_tab_hint.anchor_right = 0.0
_tab_hint.anchor_bottom = 1.0
_tab_hint.grow_vertical = Control.GROW_DIRECTION_BEGIN
_tab_hint.offset_left = 20.0
_tab_hint.offset_bottom = -20.0
_tab_hint.add_theme_color_override("font_color", _MUTED)
_tab_hint.add_theme_font_size_override("font_size", 12)
add_child(_tab_hint)
func _build_controls_panel() -> void: func _build_controls_panel() -> void:
_controls_panel = PanelContainer.new() _controls_panel = PanelContainer.new()
_controls_panel.add_theme_stylebox_override(
var bg := StyleBoxFlat.new() "panel", UiTheme.plaque(UiTheme.LEATHER, UiTheme.BORDER, 4, 16.0, 12.0))
bg.bg_color = _BG
bg.corner_radius_top_left = 7
bg.corner_radius_top_right = 7
bg.corner_radius_bottom_left = 7
bg.corner_radius_bottom_right = 7
bg.border_width_left = 1
bg.border_width_right = 1
bg.border_width_top = 1
bg.border_width_bottom = 1
bg.border_color = _BORDER
bg.content_margin_left = 14.0
bg.content_margin_right = 14.0
bg.content_margin_top = 10.0
bg.content_margin_bottom = 10.0
_controls_panel.add_theme_stylebox_override("panel", bg)
_controls_panel.anchor_left = 0.0 _controls_panel.anchor_left = 0.0
_controls_panel.anchor_top = 1.0 _controls_panel.anchor_top = 1.0
@@ -225,41 +338,35 @@ func _build_controls_panel() -> void:
_controls_panel.grow_vertical = Control.GROW_DIRECTION_BEGIN _controls_panel.grow_vertical = Control.GROW_DIRECTION_BEGIN
_controls_panel.offset_left = 20.0 _controls_panel.offset_left = 20.0
_controls_panel.offset_bottom = -20.0 _controls_panel.offset_bottom = -20.0
_controls_panel.visible = false
add_child(_controls_panel) add_child(_controls_panel)
var vbox := VBoxContainer.new() var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 5) vbox.add_theme_constant_override("separation", 5)
_controls_panel.add_child(vbox) _controls_panel.add_child(vbox)
_row(vbox, "WASD / Arrows", "Move (not intended)", _MUTED) var move_keys := "%s %s %s %s" % [
_row(vbox, "LMB (hold)", "Right horn", _CREAM) Controls.get_key_label(&"move_forward"),
_row(vbox, "RMB (hold)", "Left horn", _CREAM) Controls.get_key_label(&"move_left"),
_row(vbox, "F", "Kick", _CREAM) Controls.get_key_label(&"move_back"),
_row(vbox, "G", "Slam", _CREAM) Controls.get_key_label(&"move_right"),
_row(vbox, "H", "Dash", _CREAM) ]
_row(vbox, move_keys, "Move", _CREAM)
_row(vbox, Controls.get_key_label(&"ability_kick"), "Kick", _CREAM)
_row(vbox, Controls.get_key_label(&"ability_slam"), "Slam", _CREAM)
_row(vbox, Controls.get_key_label(&"ability_dash"), "Dash", _CREAM)
_row(vbox, Controls.get_key_label(&"ability_roll"), "Roll (Powerball: ram the matador!)", _CREAM)
_row(vbox, "Scroll", "Zoom", _CREAM) _row(vbox, "Scroll", "Zoom", _CREAM)
_row(vbox, "R", "Respawn matadors (cooldown)", _MUTED)
_row(vbox, "Tab", "Toggle controls", _MUTED) _row(vbox, "Tab", "Toggle controls", _MUTED)
_row(vbox, "Esc", "Back to menu", _MUTED)
var sep := HSeparator.new() var sep := HSeparator.new()
var sep_style := StyleBoxFlat.new() sep.add_theme_stylebox_override("separator", UiTheme.rule())
sep_style.bg_color = Color(0.65, 0.48, 0.15, 0.30)
sep_style.content_margin_top = 4.0
sep_style.content_margin_bottom = 4.0
sep.add_theme_stylebox_override("separator", sep_style)
vbox.add_child(sep) vbox.add_child(sep)
var hbox := HBoxContainer.new() _toggle_btn = _make_button("Show controls")
hbox.add_theme_constant_override("separation", 8)
vbox.add_child(hbox)
_toggle_btn = _make_button("Hide controls")
_toggle_btn.pressed.connect(_toggle_controls) _toggle_btn.pressed.connect(_toggle_controls)
hbox.add_child(_toggle_btn) vbox.add_child(_toggle_btn)
var reset_btn := _make_button("Reset")
reset_btn.pressed.connect(_on_reset_pressed)
hbox.add_child(reset_btn)
func _make_button(label_text: String) -> Button: func _make_button(label_text: String) -> Button:
@@ -267,44 +374,17 @@ func _make_button(label_text: String) -> Button:
btn.text = label_text btn.text = label_text
btn.process_mode = Node.PROCESS_MODE_ALWAYS btn.process_mode = Node.PROCESS_MODE_ALWAYS
var normal := StyleBoxFlat.new() var normal := UiTheme.plaque(Color(0.10, 0.07, 0.03, 0.90), _GOLD, 3, 18.0, 9.0)
normal.bg_color = Color(0.18, 0.12, 0.04, 0.90) var hover := UiTheme.plaque(Color(0.16, 0.10, 0.04, 0.96), _CREAM, 3, 18.0, 9.0)
normal.corner_radius_top_left = 5
normal.corner_radius_top_right = 5
normal.corner_radius_bottom_left = 5
normal.corner_radius_bottom_right = 5
normal.border_width_left = 1
normal.border_width_right = 1
normal.border_width_top = 1
normal.border_width_bottom = 1
normal.border_color = _BORDER
normal.content_margin_left = 12.0
normal.content_margin_right = 12.0
normal.content_margin_top = 6.0
normal.content_margin_bottom = 6.0
var hover := StyleBoxFlat.new()
hover.bg_color = Color(0.30, 0.20, 0.06, 0.95)
hover.corner_radius_top_left = 5
hover.corner_radius_top_right = 5
hover.corner_radius_bottom_left = 5
hover.corner_radius_bottom_right = 5
hover.border_width_left = 1
hover.border_width_right = 1
hover.border_width_top = 1
hover.border_width_bottom = 1
hover.border_color = _GOLD
hover.content_margin_left = 12.0
hover.content_margin_right = 12.0
hover.content_margin_top = 6.0
hover.content_margin_bottom = 6.0
btn.add_theme_stylebox_override("normal", normal) btn.add_theme_stylebox_override("normal", normal)
btn.add_theme_stylebox_override("hover", hover) btn.add_theme_stylebox_override("hover", hover)
btn.add_theme_stylebox_override("pressed", hover) btn.add_theme_stylebox_override("pressed", hover)
btn.add_theme_stylebox_override("focus", hover)
btn.add_theme_font_override("font", UiFonts.body())
btn.add_theme_color_override("font_color", _GOLD) btn.add_theme_color_override("font_color", _GOLD)
btn.add_theme_color_override("font_hover_color", _GOLD) btn.add_theme_color_override("font_hover_color", _CREAM)
btn.add_theme_font_size_override("font_size", 12) btn.add_theme_font_size_override("font_size", 18)
return btn return btn
@@ -315,17 +395,7 @@ func _row(parent: VBoxContainer, key: String, desc: String, desc_color: Color) -
parent.add_child(row) parent.add_child(row)
var badge := PanelContainer.new() var badge := PanelContainer.new()
var bs := StyleBoxFlat.new() badge.add_theme_stylebox_override("panel", UiTheme.badge())
bs.bg_color = _BADGE
bs.corner_radius_top_left = 3
bs.corner_radius_top_right = 3
bs.corner_radius_bottom_left = 3
bs.corner_radius_bottom_right = 3
bs.content_margin_left = 6.0
bs.content_margin_right = 6.0
bs.content_margin_top = 2.0
bs.content_margin_bottom = 2.0
badge.add_theme_stylebox_override("panel", bs)
badge.custom_minimum_size = Vector2(100.0, 0.0) badge.custom_minimum_size = Vector2(100.0, 0.0)
row.add_child(badge) row.add_child(badge)
+280
View File
@@ -0,0 +1,280 @@
extends Control
## Intro menu: Siim's arena animation as a looping video background.
## Play / Options / Credits / Exit UI are overlaid; Play follows the cape.
const GAME_SCENE := "res://scene.tscn"
const FADE_TIME := 0.6
# Arena palette: aged gold on dark leather, warm cream on hover.
const MENU_GOLD := Color(0.85, 0.66, 0.20)
const MENU_CREAM := Color(0.93, 0.88, 0.72)
## Cape centroid per frame (normalized 0-1), detected from red-pixel centroid.
## 34 frames at 24 fps = 1.4167 s loop.
const VIDEO_FPS := 24.0
const CAPE_POS: Array = [
Vector2(0.4530, 0.6513),
Vector2(0.4521, 0.6516),
Vector2(0.4495, 0.6524),
Vector2(0.4456, 0.6537),
Vector2(0.4415, 0.6550),
Vector2(0.4373, 0.6578),
Vector2(0.4338, 0.6616),
Vector2(0.4306, 0.6669),
Vector2(0.4273, 0.6673),
Vector2(0.4256, 0.6586),
Vector2(0.4258, 0.6428),
Vector2(0.4281, 0.6243),
Vector2(0.4324, 0.6086),
Vector2(0.4382, 0.5997),
Vector2(0.4439, 0.5972),
Vector2(0.4483, 0.5970),
Vector2(0.4512, 0.5986),
Vector2(0.4529, 0.6025),
Vector2(0.4535, 0.6078),
Vector2(0.4533, 0.6135),
Vector2(0.4539, 0.6159),
Vector2(0.4545, 0.6120),
Vector2(0.4546, 0.6050),
Vector2(0.4541, 0.5988),
Vector2(0.4528, 0.5961),
Vector2(0.4508, 0.6012),
Vector2(0.4485, 0.6123),
Vector2(0.4466, 0.6256),
Vector2(0.4456, 0.6388),
Vector2(0.4457, 0.6491),
Vector2(0.4473, 0.6552),
Vector2(0.4492, 0.6567),
Vector2(0.4513, 0.6556),
Vector2(0.4524, 0.6526),
]
## Friendly labels for the rebindable actions (order defines list order).
const ACTION_LABELS: Dictionary = {
&"move_forward": "Move Forward",
&"move_back": "Move Back",
&"move_left": "Move Left",
&"move_right": "Move Right",
&"ability_kick": "Kick",
&"ability_slam": "Slam",
&"ability_dash": "Dash",
&"ability_roll": "Roll",
}
@onready var main_panel: Control = $MainPanel
@onready var options_panel: Control = $OptionsPanel
@onready var credits_panel: Control = $CreditsPanel
@onready var rebind_list: VBoxContainer = $OptionsPanel/VBox/Scroll/RebindList
@onready var fade_rect: ColorRect = $FadeRect
@onready var play_button: Button = $MainPanel/PlayButton
@onready var options_button: Button = $MainPanel/OptionsButton
@onready var credits_button: Button = $MainPanel/CreditsButton
@onready var exit_button: Button = $MainPanel/ExitButton
@onready var options_back: Button = $OptionsPanel/VBox/BackButton
@onready var credits_back: Button = $CreditsPanel/VBox/BackButton
@onready var video: VideoStreamPlayer = $VideoStreamPlayer
var _listening_action: StringName = &""
var _listening_button: Button = null
func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
options_panel.visible = false
credits_panel.visible = false
fade_rect.color = Color(0, 0, 0, 0)
if video.stream != null:
video.play()
video.finished.connect(video.play) # loop the background clip
play_button.pressed.connect(_on_play_pressed)
options_button.pressed.connect(_on_options_pressed)
credits_button.pressed.connect(_on_credits_pressed)
exit_button.pressed.connect(_on_exit_pressed)
options_back.pressed.connect(_close_subpanels)
credits_back.pressed.connect(_close_subpanels)
_apply_theme()
_build_version_label()
_build_rebind_rows()
play_button.grab_focus()
# Version banner in the bottom-right corner so it's clear this is an early build.
func _build_version_label() -> void:
var lbl := Label.new()
lbl.text = GameVersion.full()
lbl.add_theme_font_override("font", UiFonts.body())
lbl.add_theme_font_size_override("font_size", 16)
lbl.add_theme_color_override("font_color", MENU_CREAM)
lbl.add_theme_color_override("font_outline_color", Color.BLACK)
lbl.add_theme_constant_override("outline_size", 4)
lbl.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
lbl.grow_horizontal = Control.GROW_DIRECTION_BEGIN
lbl.grow_vertical = Control.GROW_DIRECTION_BEGIN
lbl.offset_right = -16.0
lbl.offset_bottom = -12.0
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(lbl)
# Give the whole menu the Cinzel look and swap the generic translucent boxes for
# engraved bronze plaques (dark leather fill, gold border with a heavier bottom
# edge for a chiselled-tablet feel).
func _apply_theme() -> void:
_style_button(play_button, true)
for btn: Button in [options_button, credits_button, exit_button, options_back, credits_back]:
_style_button(btn, false)
for path: String in ["OptionsPanel/VBox/TitleLabel", "CreditsPanel/VBox/TitleLabel"]:
var title := get_node_or_null(path) as Label
if title != null:
title.add_theme_font_override("font", UiFonts.title())
title.add_theme_color_override("font_color", MENU_GOLD)
var hint := get_node_or_null("OptionsPanel/VBox/Hint") as Label
if hint != null:
hint.add_theme_font_override("font", UiFonts.body())
hint.add_theme_color_override("font_color", MENU_CREAM)
_style_credits(get_node_or_null("CreditsPanel/VBox/Names") as RichTextLabel)
# The credits use a RichTextLabel so the Instagram handles are clickable links
# ([url] tags). meta_clicked opens the profile — in the web export OS.shell_open
# pops a new browser tab.
func _style_credits(names: RichTextLabel) -> void:
if names == null:
return
names.add_theme_font_override("normal_font", UiFonts.body())
names.add_theme_font_override("bold_font", UiFonts.title())
names.add_theme_color_override("default_color", MENU_CREAM)
names.meta_clicked.connect(func(meta: Variant) -> void:
OS.shell_open(str(meta)))
func _style_button(btn: Button, big: bool) -> void:
btn.add_theme_font_override("font", UiFonts.title() if big else UiFonts.body())
btn.add_theme_color_override("font_color", MENU_GOLD)
btn.add_theme_color_override("font_hover_color", MENU_CREAM)
btn.add_theme_color_override("font_focus_color", MENU_CREAM)
btn.add_theme_color_override("font_pressed_color", MENU_CREAM)
btn.add_theme_color_override("font_outline_color", Color(0, 0, 0, 1))
btn.add_theme_constant_override("outline_size", 8 if big else 5)
btn.add_theme_stylebox_override("normal", _plaque(false))
btn.add_theme_stylebox_override("hover", _plaque(true))
btn.add_theme_stylebox_override("pressed", _plaque(true))
btn.add_theme_stylebox_override("focus", _plaque(true))
func _plaque(hot: bool) -> StyleBoxFlat:
var bg := Color(0.14, 0.09, 0.04, 0.94) if hot else Color(0.07, 0.05, 0.03, 0.82)
var trim := MENU_CREAM if hot else MENU_GOLD
return UiTheme.plaque(bg, trim, 2, 24.0, 9.0)
func _process(_delta: float) -> void:
if not main_panel.visible or video.stream == null:
return
var frame_count := CAPE_POS.size()
var t := video.stream_position
var frame_idx := int(t * VIDEO_FPS) % frame_count
var cape: Vector2 = CAPE_POS[frame_idx]
play_button.anchor_left = cape.x
play_button.anchor_right = cape.x
play_button.anchor_top = cape.y
play_button.anchor_bottom = cape.y
func _build_rebind_rows() -> void:
for child in rebind_list.get_children():
child.queue_free()
for action: StringName in ACTION_LABELS:
var row := HBoxContainer.new()
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var name_label := Label.new()
name_label.text = ACTION_LABELS[action]
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
name_label.add_theme_font_override("font", UiFonts.body())
name_label.add_theme_font_size_override("font_size", 20)
name_label.add_theme_color_override("font_color", MENU_CREAM)
name_label.add_theme_color_override("font_outline_color", Color.BLACK)
name_label.add_theme_constant_override("outline_size", 5)
row.add_child(name_label)
var key_button := Button.new()
key_button.custom_minimum_size = Vector2(160, 0)
key_button.text = Controls.get_key_label(action)
_style_button(key_button, false)
key_button.pressed.connect(_on_rebind_pressed.bind(action, key_button))
row.add_child(key_button)
rebind_list.add_child(row)
func _on_play_pressed() -> void:
_set_buttons_disabled(true)
var tween := create_tween()
tween.tween_property(fade_rect, "color:a", 1.0, FADE_TIME)
tween.tween_callback(func() -> void:
get_tree().change_scene_to_file(GAME_SCENE))
func _on_options_pressed() -> void:
main_panel.visible = false
options_panel.visible = true
options_back.grab_focus()
func _on_credits_pressed() -> void:
main_panel.visible = false
credits_panel.visible = true
credits_back.grab_focus()
func _on_exit_pressed() -> void:
get_tree().quit()
func _close_subpanels() -> void:
_cancel_listening()
options_panel.visible = false
credits_panel.visible = false
main_panel.visible = true
play_button.grab_focus()
func _set_buttons_disabled(value: bool) -> void:
play_button.disabled = value
options_button.disabled = value
credits_button.disabled = value
exit_button.disabled = value
func _on_rebind_pressed(action: StringName, button: Button) -> void:
_cancel_listening()
_listening_action = action
_listening_button = button
button.text = "Press a key…"
func _cancel_listening() -> void:
if _listening_button != null:
_listening_button.text = Controls.get_key_label(_listening_action)
_listening_action = &""
_listening_button = null
func _unhandled_input(event: InputEvent) -> void:
if _listening_action == &"":
return
if event is InputEventKey and event.pressed and not event.echo:
if event.physical_keycode == KEY_ESCAPE:
_cancel_listening()
else:
var new_event := InputEventKey.new()
new_event.physical_keycode = event.physical_keycode
Controls.rebind(_listening_action, new_event)
_listening_button.text = Controls.get_key_label(_listening_action)
_listening_action = &""
_listening_button = null
get_viewport().set_input_as_handled()
+1
View File
@@ -0,0 +1 @@
uid://46y10alai56q
+354 -219
View File
@@ -10,6 +10,10 @@ extends CharacterBody3D
# RAGDOLL → hit by bull, no roll chance # RAGDOLL → hit by bull, no roll chance
enum State { WANDER, FLEE, BRACE, SIDESTEP, ATTACK, ROLL, THROW, RAGDOLL } enum State { WANDER, FLEE, BRACE, SIDESTEP, ATTACK, ROLL, THROW, RAGDOLL }
# The matador's high-level *want*, chosen by a weighted utility vote (see
# _choose_intent). Each intent maps onto one or more concrete States below.
enum Intent { WANDER, FLEE, ATTACK, DODGE, THROW }
signal killed signal killed
var _state: State = State.WANDER var _state: State = State.WANDER
@@ -18,6 +22,7 @@ var _sim: PhysicalBoneSimulator3D = null
var _anim_player: AnimationPlayer = null var _anim_player: AnimationPlayer = null
var _wander_target: Vector3 = Vector3.ZERO var _wander_target: Vector3 = Vector3.ZERO
var _idle_timer: float = 0.0 var _idle_timer: float = 0.0
var _idle_anim: StringName = _ANIM_IDLE
var _bull: CharacterBody3D = null var _bull: CharacterBody3D = null
var _step_dir: Vector3 = Vector3.ZERO var _step_dir: Vector3 = Vector3.ZERO
var _brace_timer: float = 0.0 var _brace_timer: float = 0.0
@@ -26,6 +31,7 @@ var _dodge_cd: float = 0.0
var _will_dodge: bool = true var _will_dodge: bool = true
var _attack_timer: float = 0.0 var _attack_timer: float = 0.0
var _swing_timer: float = 0.0 var _swing_timer: float = 0.0
var _lunge_timer: float = 0.0
var _roll_timer: float = 0.0 var _roll_timer: float = 0.0
var _roll_dir: Vector3 = Vector3.ZERO var _roll_dir: Vector3 = Vector3.ZERO
var _throw_timer: float = 0.0 var _throw_timer: float = 0.0
@@ -33,12 +39,15 @@ var _throw_aim_cd: float = 0.0
var _throw_dir: Vector3 = Vector3.ZERO var _throw_dir: Vector3 = Vector3.ZERO
var _steer_dir: Vector3 = Vector3.ZERO var _steer_dir: Vector3 = Vector3.ZERO
var _steer_cd: float = 0.0 var _steer_cd: float = 0.0
var _intent: Intent = Intent.WANDER
var _decide_cd: float = 0.0
var _ole_player: AudioStreamPlayer = null var _ole_player: AudioStreamPlayer = null
var _sword_hand_attach: BoneAttachment3D = null var _sword_hand_attach: BoneAttachment3D = null
var _sword_rest_attach: BoneAttachment3D = null var _sword_rest_attach: BoneAttachment3D = null
var _sword_node: Node3D = null var _sword_node: Node3D = null
var _sword_in_hand: bool = false var _sword_in_hand: bool = false
var _drawing: bool = false var _drawing: bool = false
var _sheathing: bool = false
var _draw_timer: float = 0.0 var _draw_timer: float = 0.0
var _draw_len: float = 0.0 var _draw_len: float = 0.0
var _resword_timer: float = 0.0 var _resword_timer: float = 0.0
@@ -56,6 +65,10 @@ const _ANIM_ATTACK: StringName = &"Attack"
const _ANIM_ROLL: StringName = &"Roll" const _ANIM_ROLL: StringName = &"Roll"
const _ANIM_DRAW: StringName = &"Draw_weapon" const _ANIM_DRAW: StringName = &"Draw_weapon"
# Idle taunt clips — one is picked at random each time the matador pauses to
# taunt while wandering, so the crowd doesn't see the same gesture every time.
const _ANIM_TAUNTS: Array[StringName] = [&"Taunt", &"Taunt_B", &"Taunt_C"]
# Fraction of the draw clip at which the hand grabs the sword — the sword # Fraction of the draw clip at which the hand grabs the sword — the sword
# reparents from holster to hand at this point in the animation. # reparents from holster to hand at this point in the animation.
const _DRAW_GRAB_AT: float = 0.55 const _DRAW_GRAB_AT: float = 0.55
@@ -67,11 +80,12 @@ const _STEER_INTERVAL: float = 0.12 # recompute direction at most ~8×/sec
# Side angles (radians) tried when the straight-ahead path is blocked. # Side angles (radians) tried when the straight-ahead path is blocked.
const _STEER_CANDIDATES: Array = [-0.35, 0.35, -0.7, 0.7, -1.1, 1.1, PI] const _STEER_CANDIDATES: Array = [-0.35, 0.35, -0.7, 0.7, -1.1, 1.1, PI]
# Movement tuning # Movement tuning. Ramp/turn rates are deliberately snappy so the matador reads as
const _ACCEL_FACTOR: float = 8.0 # velocity ramp = speed * factor * delta # a quick, dangerous opponent rather than a sluggish one.
const _TURN_MOVE: float = 8.0 # face the travel direction while roaming const _ACCEL_FACTOR: float = 13.0 # velocity ramp = speed * factor * delta
const _TURN_FACE: float = 12.0 # face the bull while engaging const _TURN_MOVE: float = 11.0 # face the travel direction while roaming
const _TURN_SHARP: float = 20.0 # snap onto a sidestep / roll direction const _TURN_FACE: float = 16.0 # face the bull while engaging
const _TURN_SHARP: float = 24.0 # snap onto a sidestep / roll direction
func _ready() -> void: func _ready() -> void:
@@ -79,9 +93,9 @@ func _ready() -> void:
_skeleton = _find_skeleton(_mesh) _skeleton = _find_skeleton(_mesh)
_anim_player = _find_anim_player(_mesh) _anim_player = _find_anim_player(_mesh)
if _skeleton: if _skeleton:
_sim = _setup_physical_bones() _sim = MatadorRagdoll.build(_skeleton)
if _anim_player: if _anim_player:
for anim in [_ANIM_RUN, _ANIM_IDLE, _ANIM_ATTACK, _ANIM_ROLL]: for anim in [_ANIM_RUN, _ANIM_ATTACK, _ANIM_ROLL] + _ANIM_TAUNTS:
_ensure_loop(anim) _ensure_loop(anim)
if not _anim_player.has_animation(_ANIM_RUN): if not _anim_player.has_animation(_ANIM_RUN):
push_warning("Matador: expected animations not found. Available: %s" % push_warning("Matador: expected animations not found. Available: %s" %
@@ -90,10 +104,11 @@ func _ready() -> void:
_anim_player.play(_ANIM_IDLE) _anim_player.play(_ANIM_IDLE)
_setup_sword() _setup_sword()
DP.any_changed.connect(_on_dp_changed) DP.any_changed.connect(_on_dp_changed)
_throw_aim_cd = randf_range(0.8, 2.5) _throw_aim_cd = randf_range(0.3, 1.0)
_hit_area.body_entered.connect(_on_body_entered) _hit_area.body_entered.connect(_on_body_entered)
_pick_wander_target() _pick_wander_target()
_setup_blood_burst() _blood_burst = preload("res://blood_burst.gd").new()
add_child(_blood_burst)
if ResourceLoader.exists("res://sounds/ole.ogg"): if ResourceLoader.exists("res://sounds/ole.ogg"):
_ole_player = AudioStreamPlayer.new() _ole_player = AudioStreamPlayer.new()
_ole_player.stream = load("res://sounds/ole.ogg") _ole_player.stream = load("res://sounds/ole.ogg")
@@ -123,6 +138,10 @@ func _acquire_bull() -> void:
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
_acquire_bull() _acquire_bull()
if DP.b("show_raycasts") and _steer_dir != Vector3.ZERO:
var o := global_position + Vector3.UP * 0.6
DebugDraw.ray(o, o + _steer_dir * _STEER_LOOKAHEAD, Color(0.3, 1.0, 0.9))
if not is_on_floor(): if not is_on_floor():
velocity += get_gravity() * delta velocity += get_gravity() * delta
@@ -136,7 +155,7 @@ func _physics_process(delta: float) -> void:
if _resword_timer <= 0.0: if _resword_timer <= 0.0:
_spawn_sword() _spawn_sword()
if _state != State.RAGDOLL and _bull != null: if _state != State.RAGDOLL and _bull != null:
_update_ai_state() _update_ai_state(delta)
match _state: match _state:
State.WANDER: _tick_wander(delta) State.WANDER: _tick_wander(delta)
@@ -152,42 +171,110 @@ func _physics_process(delta: float) -> void:
_advance_draw(delta) _advance_draw(delta)
func _update_ai_state() -> void: # Utility-driven brain. The physically committed states (a brace, a pass, a roll, a
if _state == State.THROW: # throw wind-up) run to completion on their own timers; from the free states the
# matador re-scores its options every mat_decide_interval and acts on the winner.
func _update_ai_state(delta: float) -> void:
if _state == State.BRACE or _state == State.SIDESTEP \
or _state == State.ROLL or _state == State.THROW:
return return
_decide_cd -= delta
if _decide_cd > 0.0:
return
_decide_cd = DP.f("mat_decide_interval")
var intent := _choose_intent()
_intent = intent
if not _realizes_intent(intent):
_enter_intent(intent)
# Score each intent from weighted heuristics and return the winner. Weights (the
# mat_w_* params) shape the personality; a small commit bonus on the current intent
# stops it flip-flopping between two near-tied options.
func _choose_intent() -> Intent:
var dist := global_position.distance_to(_bull.global_position) var dist := global_position.distance_to(_bull.global_position)
if (_state == State.WANDER or _state == State.FLEE or _state == State.ATTACK) \ var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
and _throw_aim_cd <= 0.0 and _sword_in_hand and is_instance_valid(_sword_node) \ var bull_speed := bull_flat.length()
and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range"):
# Roll for a throw; on a miss, wait before becoming eligible again so the # close = 1 right on top of us, 0 at attack range or beyond. `beyond` measures
# matador commits to chasing for a melee Attack instead of throwing early. # how far past throw range the bull is (0 point-blank, 1 at/outside throw range):
if randf() < DP.f("mat_throw_chance"): # the matador only idles when the bull is out there, and engages otherwise.
_start_throw() var reach := maxf(DP.f("mat_attack_range"), 0.5)
return var throw_range := maxf(DP.f("mat_throw_range"), 1.0)
_throw_aim_cd = randf_range(2.5, 5.0) var close := clampf(1.0 - dist / reach, 0.0, 1.0)
match _state: var beyond := clampf(dist / throw_range, 0.0, 1.0)
State.WANDER: # danger blends "the bull is fast and near" with "a charge is aimed at me".
if dist < DP.f("mat_flee_range"): var danger := clampf(bull_speed / maxf(DP.f("mat_charge_speed"), 0.1), 0.0, 1.0) * close
_state = State.FLEE if _is_charge_incoming():
elif _dodge_cd <= 0.0 and dist < DP.f("mat_attack_range") and not _is_charge_incoming(): danger = clampf(danger + 0.7, 0.0, 1.0)
_steer_cd = 0.0
_start_attack() # A matador won't commit to a strike it can't bail out of, so a spent dodge
State.FLEE: # cooldown makes attacking (and dodging outright) less attractive.
if _dodge_cd <= 0.0 and (dist < DP.f("mat_commit_dist") or _is_charge_incoming()): var dodge_ready := 1.0 if _dodge_cd <= 0.0 else 0.35
_start_brace() var throw_ok := _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node) \
elif dist > DP.f("mat_flee_range") * 1.3: and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range")
var score := {
# Only drift when the bull is out past throw range; up close this is ~0.
Intent.WANDER: DP.f("mat_w_showmanship") * 0.3 * beyond,
# Retreat only when the bull is close AND actually dangerous.
Intent.FLEE: DP.f("mat_w_flee") * close * (0.25 + 0.75 * danger),
# Press the attack; ease off a touch when a charge is barrelling in.
Intent.ATTACK: DP.f("mat_w_aggression") * (0.45 + 0.55 * close) \
* (1.0 - 0.55 * danger) * dodge_ready,
# Sidestep / plant against an incoming charge.
Intent.DODGE: DP.f("mat_w_caution") * danger * (1.0 if _dodge_cd <= 0.0 else 0.0),
# Hurl the blade whenever there's a clean lane at range — the ranged threat
# that punishes a bull for hanging back out of horn reach.
Intent.THROW: ((DP.f("mat_w_showmanship") * 0.55 + DP.f("mat_w_aggression") * 0.6) \
* (0.35 + 0.65 * (1.0 - close))) if throw_ok else -1.0,
}
score[_intent] = float(score[_intent]) + DP.f("mat_w_commit")
var best: Intent = Intent.WANDER
var best_score := -INF
for intent: int in score:
if float(score[intent]) > best_score:
best_score = float(score[intent])
best = intent
return best
# True when the current State already carries out the intent, so we needn't restart
# it (restarting ATTACK every tick would reset its swing and stutter the animation).
func _realizes_intent(intent: Intent) -> bool:
match intent:
Intent.WANDER:
return _state == State.WANDER
Intent.FLEE:
return _state == State.FLEE
Intent.ATTACK:
return _state == State.ATTACK
Intent.DODGE:
return _state == State.BRACE or _state == State.SIDESTEP
Intent.THROW:
return _state == State.THROW
return false
func _enter_intent(intent: Intent) -> void:
match intent:
Intent.WANDER:
_state = State.WANDER _state = State.WANDER
_steer_cd = 0.0 _steer_cd = 0.0
_pick_wander_target() _pick_wander_target()
State.BRACE: Intent.FLEE:
pass # brace timer drives transition
State.SIDESTEP:
pass # step timer drives transition
State.ATTACK:
if _attack_timer <= 0.0 or dist > DP.f("mat_attack_range") * 1.5:
_state = State.FLEE _state = State.FLEE
State.ROLL: _steer_cd = 0.0
pass # roll timer drives transition Intent.ATTACK:
_steer_cd = 0.0
_start_attack()
Intent.DODGE:
_start_brace()
Intent.THROW:
_start_throw()
# Bull is moving fast and aimed within ~30° of the matador # Bull is moving fast and aimed within ~30° of the matador
@@ -260,11 +347,11 @@ func _face_point(point: Vector3, delta: float, rate: float) -> void:
func _tick_wander(delta: float) -> void: func _tick_wander(delta: float) -> void:
# Plant the feet while unsheathing instead of sliding through the draw clip. # Plant the feet while drawing / sheathing instead of sliding through the clip.
if _drawing or _idle_timer > 0.0: if _drawing or _sheathing or _idle_timer > 0.0:
_idle_timer = maxf(0.0, _idle_timer - delta) _idle_timer = maxf(0.0, _idle_timer - delta)
_decelerate(10.0, delta) _decelerate(10.0, delta)
_play_anim(_ANIM_IDLE) _play_anim(_idle_anim)
move_and_slide() move_and_slide()
return return
@@ -272,6 +359,7 @@ func _tick_wander(delta: float) -> void:
_wander_target.x - global_position.x, 0.0, _wander_target.z - global_position.z) _wander_target.x - global_position.x, 0.0, _wander_target.z - global_position.z)
if to_target.length() < 0.8: if to_target.length() < 0.8:
_idle_timer = randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max")) _idle_timer = randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max"))
_idle_anim = _pick_taunt()
_pick_wander_target() _pick_wander_target()
else: else:
var dir := _steer_clear(to_target.normalized(), delta) var dir := _steer_clear(to_target.normalized(), delta)
@@ -282,6 +370,13 @@ func _tick_wander(delta: float) -> void:
func _tick_flee(delta: float) -> void: func _tick_flee(delta: float) -> void:
# Plant the feet while sheathing instead of sliding away through the clip.
if _drawing or _sheathing:
_decelerate(18.0, delta)
_face_point(_bull.global_position, delta, _TURN_FACE)
move_and_slide()
return
var away := global_position - _bull.global_position var away := global_position - _bull.global_position
away.y = 0.0 away.y = 0.0
if away.length() < 0.01: if away.length() < 0.01:
@@ -319,29 +414,41 @@ func _tick_brace(delta: float) -> void:
_brace_timer -= delta _brace_timer -= delta
var dist := global_position.distance_to(_bull.global_position) var dist := global_position.distance_to(_bull.global_position)
if dist < DP.f("mat_commit_dist") or _brace_timer <= 0.0:
if _will_dodge: if _will_dodge:
# Dodger: wait for the bull to commit, then whip into the lateral pass.
if dist < DP.f("mat_commit_dist") or _brace_timer <= 0.0:
_state = State.SIDESTEP _state = State.SIDESTEP
_step_timer = DP.f("mat_step_duration") _step_timer = DP.f("mat_step_duration")
if _ole_player: if _ole_player:
_ole_player.pitch_scale = randf_range(0.88, 1.12) _ole_player.pitch_scale = randf_range(0.88, 1.12)
_ole_player.play() _ole_player.play()
else: else:
_state = State.FLEE # Planter (estocada): the bull only dies to an active, presented thrust, not to
# brushing a braced matador — so when it commits in, break into a real attack
# SWING (which lunges the blade forward and stabs), otherwise hold then press.
if dist < DP.f("mat_commit_dist") or (_brace_timer <= 0.0 and not _is_charge_incoming()):
_dodge_cd = DP.f("mat_dodge_cooldown") _dodge_cd = DP.f("mat_dodge_cooldown")
_start_attack()
# Pase phase: sharp lateral step, then immediately exploit with an attack # Pase phase: sharp lateral step biased toward the bull so the drawn blade sweeps
# across its path as it charges by; face the bull so the sword points at it.
func _tick_sidestep(delta: float) -> void: func _tick_sidestep(delta: float) -> void:
_step_timer -= delta _step_timer -= delta
if _step_timer <= 0.0: if _step_timer <= 0.0:
_start_attack() _start_attack()
return return
var dir := _step_dir
var to_bull := _bull.global_position - global_position
to_bull.y = 0.0
if to_bull.length() > 0.1:
dir = (_step_dir + to_bull.normalized() * DP.f("mat_pass_lunge")).normalized()
var spd := DP.f("mat_step_speed") var spd := DP.f("mat_step_speed")
velocity.x = _step_dir.x * spd velocity.x = dir.x * spd
velocity.z = _step_dir.z * spd velocity.z = dir.z * spd
_face_velocity(delta, _TURN_SHARP) _face_point(_bull.global_position, delta, _TURN_SHARP)
_play_anim(_ANIM_ROLL) _play_anim(_ANIM_ATTACK)
_try_melee_hit(DP.f("mat_stab_reach"))
move_and_slide() move_and_slide()
@@ -355,6 +462,12 @@ func _start_attack() -> void:
func _tick_attack(delta: float) -> void: func _tick_attack(delta: float) -> void:
_attack_timer -= delta _attack_timer -= delta
_swing_timer = maxf(0.0, _swing_timer - delta) _swing_timer = maxf(0.0, _swing_timer - delta)
# Plant the feet while drawing instead of sliding toward the bull mid-clip.
if _drawing or _sheathing:
_decelerate(22.0, delta)
_face_point(_bull.global_position, delta, _TURN_FACE)
move_and_slide()
return
_face_point(_bull.global_position, delta, _TURN_FACE) _face_point(_bull.global_position, delta, _TURN_FACE)
var to_bull := _bull.global_position - global_position var to_bull := _bull.global_position - global_position
to_bull.y = 0.0 to_bull.y = 0.0
@@ -363,12 +476,18 @@ func _tick_attack(delta: float) -> void:
# Commit to a full swing once in range instead of flipping Run/Attack at the # Commit to a full swing once in range instead of flipping Run/Attack at the
# distance boundary every frame (the old foot-slide jitter). When the swing # distance boundary every frame (the old foot-slide jitter). When the swing
# ends the matador re-closes if the bull slipped away, or stabs again if not. # ends the matador re-closes if the bull slipped away, or stabs again if not.
if _swing_timer <= 0.0 and dist <= DP.f("mat_attack_min_dist"): if _swing_timer <= 0.0 and dist <= DP.f("mat_attack_min_dist") and _sword_in_hand:
_swing_timer = _anim_length(_ANIM_ATTACK, 0.9) _swing_timer = _anim_length(_ANIM_ATTACK, 0.9)
_lunge_timer = DP.f("mat_lunge_time")
if _swing_timer > 0.0: if _swing_timer > 0.0:
_lunge_timer = maxf(0.0, _lunge_timer - delta)
if _lunge_timer > 0.0 and dist > 0.1:
_accelerate(to_bull.normalized(), DP.f("mat_lunge_speed"), delta)
else:
_decelerate(22.0, delta) _decelerate(22.0, delta)
_play_anim(_ANIM_ATTACK) _play_anim(_ANIM_ATTACK)
_try_melee_hit(DP.f("mat_stab_reach"))
else: else:
var dir := _steer_clear(to_bull.normalized(), delta) var dir := _steer_clear(to_bull.normalized(), delta)
_accelerate(dir, DP.f("mat_attack_speed"), delta) _accelerate(dir, DP.f("mat_attack_speed"), delta)
@@ -408,6 +527,8 @@ func _start_throw() -> void:
_state = State.THROW _state = State.THROW
_throw_timer = DP.f("mat_throw_windup") _throw_timer = DP.f("mat_throw_windup")
velocity = Vector3.ZERO velocity = Vector3.ZERO
_cancel_transitions()
_carry_sword(true) # blade must be in hand for the wind-up and release
if _anim_player: if _anim_player:
_anim_player.pause() _anim_player.pause()
var to_bull := _bull.global_position - global_position var to_bull := _bull.global_position - global_position
@@ -442,6 +563,10 @@ func _tick_throw(delta: float) -> void:
func _end_throw() -> void: func _end_throw() -> void:
_state = State.FLEE _state = State.FLEE
_dodge_cd = DP.f("mat_dodge_cooldown") _dodge_cd = DP.f("mat_dodge_cooldown")
# Space throws out so the utility brain doesn't immediately vote another one,
# but keep them frequent enough to pressure a bull that hangs back at range.
_throw_aim_cd = randf_range(1.6, 3.2)
_intent = Intent.FLEE
# Blend straight into locomotion so the manually-posed throw arm eases out # Blend straight into locomotion so the manually-posed throw arm eases out
# (a bare resume() would snap and leave the player on whatever it paused on). # (a bare resume() would snap and leave the player on whatever it paused on).
if _anim_player: if _anim_player:
@@ -500,15 +625,20 @@ func _release_sword() -> void:
rb.collision_layer = 0 rb.collision_layer = 0
rb.collision_mask = 1 rb.collision_mask = 1
rb.contact_monitor = true rb.contact_monitor = true
rb.max_contacts_reported = 4 rb.max_contacts_reported = 6
# Fly nearly straight to the aim point instead of lobbing — the old horizontal
# throw sailed clean over the bull's low body. A small gravity_scale keeps a
# touch of drop for feel without dropping short.
rb.gravity_scale = DP.f("mat_throw_gravity")
get_tree().current_scene.add_child(rb) get_tree().current_scene.add_child(rb)
rb.global_transform = gx rb.global_transform = gx
sword.reparent(rb, true) sword.reparent(rb, true)
# Physics collider along the blade (GLB +Z), which equals rb-local +Z because # Physics collider along the blade (GLB +Z), which equals rb-local +Z because
# the mesh kept its global transform and rb adopted it. # the mesh kept its global transform and rb adopted it. Fattened so a fast throw
# reliably overlaps the bull's collision spheres instead of tunnelling past.
var cap := CapsuleShape3D.new() var cap := CapsuleShape3D.new()
cap.radius = 0.06 cap.radius = 0.14
cap.height = 1.4 cap.height = 1.4
var cs := CollisionShape3D.new() var cs := CollisionShape3D.new()
cs.shape = cap cs.shape = cap
@@ -516,8 +646,25 @@ func _release_sword() -> void:
cs.rotation_degrees = Vector3(90.0, 0.0, 0.0) cs.rotation_degrees = Vector3(90.0, 0.0, 0.0)
rb.add_child(cs) rb.add_child(cs)
var dir := (_throw_dir + Vector3.UP * 0.12).normalized() # Aim at the bull's BODY (its collision spheres ride low, ~0.5 m below the
rb.linear_velocity = dir * DP.f("mat_throw_speed") # origin), leading a moving target so it arrives where the bull will be. The aim
# keeps its vertical component so the blade drives into the body, not over it.
var speed := maxf(DP.f("mat_throw_speed"), 0.1)
var dir := (_throw_dir + Vector3.UP * 0.0)
if is_instance_valid(_bull):
var target := _bull.global_position + Vector3(0.0, DP.f("mat_throw_aim_y"), 0.0)
var flat := target - gx.origin
flat.y = 0.0
var flight := flat.length() / speed
target += _bull.velocity * flight
var to_target := target - gx.origin
if to_target.length() > 0.1:
dir = to_target.normalized()
# A little aim scatter so throws are a threat to READ and dodge, not a hitscan —
# a bull that keeps moving can slip them, a stationary one gets pinned.
var spread := deg_to_rad(DP.f("mat_throw_spread"))
dir = dir.rotated(Vector3.UP, randf_range(-spread, spread)).normalized()
rb.linear_velocity = dir * speed
rb.angular_velocity = dir.cross(Vector3.UP).normalized() * -DP.f("mat_throw_spin") rb.angular_velocity = dir.cross(Vector3.UP).normalized() * -DP.f("mat_throw_spin")
var hit_done := [false] var hit_done := [false]
@@ -526,7 +673,7 @@ func _release_sword() -> void:
return return
if body.is_in_group(&"player"): if body.is_in_group(&"player"):
hit_done[0] = true hit_done[0] = true
body.call(&"take_sword_hit") body.call(&"take_sword_hit", "thrown")
) )
var cleanup := get_tree().create_timer(6.0) var cleanup := get_tree().create_timer(6.0)
@@ -542,11 +689,16 @@ func _tick_ragdoll(_delta: float) -> void:
move_and_slide() move_and_slide()
func apply_ability_hit(hit_dir: Vector3, strength: float) -> void: # Current state as its enum name (WANDER / FLEE / …), for the debug state overlay.
func ai_state_name() -> String:
return State.keys()[_state]
func apply_ability_hit(hit_dir: Vector3, strength: float, up_boost: float = 0.0) -> void:
if _state == State.RAGDOLL or _state == State.ROLL: if _state == State.RAGDOLL or _state == State.ROLL:
return return
_acquire_bull() _acquire_bull()
_enter_ragdoll(hit_dir, strength) _enter_ragdoll(hit_dir, strength, up_boost)
func _on_body_entered(body: Node3D) -> void: func _on_body_entered(body: Node3D) -> void:
@@ -568,17 +720,39 @@ func _on_body_entered(body: Node3D) -> void:
func _on_blade_hit(body: Node3D) -> void: func _on_blade_hit(body: Node3D) -> void:
if _state == State.RAGDOLL or _state != State.ATTACK: if _state != State.ATTACK and _state != State.SIDESTEP and _state != State.BRACE:
return return
if not body.is_in_group(&"player"): if not body.is_in_group(&"player"):
return return
if _blade_hit_cd > 0.0: if _blade_hit_cd > 0.0:
return return
_blade_hit_cd = 0.5 _blade_hit_cd = DP.f("mat_stab_cooldown")
body.call(&"take_sword_hit") body.call(&"take_sword_hit", "gored")
func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void: # Reach-based stab. A charging bull crosses metres per physics frame, so it easily
# tunnels through the thin blade Area between frames — this distance check is the
# reliable hit. Called from the offensive ticks (active swing / pass) whenever the
# blade is drawn: if the bull is inside reach AND the matador is facing it — the
# blade actually presented, not held off to the side — it's gored.
func _try_melee_hit(reach: float) -> void:
if _blade_hit_cd > 0.0 or not _sword_in_hand or _bull == null:
return
var to_bull := _bull.global_position - global_position
to_bull.y = 0.0
var dist := to_bull.length()
if dist > reach or dist < 0.01:
return
# Only lands within the frontal arc — you're gored on a presented blade, not by
# brushing a matador whose sword is pointing elsewhere.
var facing := Vector3(sin(_mesh.rotation.y), 0.0, cos(_mesh.rotation.y))
if facing.dot(to_bull / dist) < cos(deg_to_rad(DP.f("mat_stab_arc"))):
return
_blade_hit_cd = DP.f("mat_stab_cooldown")
_bull.call(&"take_sword_hit", "gored")
func _enter_ragdoll(hit_dir: Vector3, bull_speed: float, up_boost: float = 0.0) -> void:
_state = State.RAGDOLL _state = State.RAGDOLL
if _sword_blade_area: if _sword_blade_area:
_sword_blade_area.monitoring = false _sword_blade_area.monitoring = false
@@ -592,7 +766,7 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void:
hit_dir = hit_dir.normalized() hit_dir = hit_dir.normalized()
var throw_dir := (hit_dir + Vector3(0.0, 0.5, 0.0)).normalized() var throw_dir := (hit_dir + Vector3(0.0, 0.5, 0.0)).normalized()
_spawn_blood_burst(hit_dir) _blood_burst.burst(global_position + Vector3(0.0, 0.9, 0.0), hit_dir)
if _death_player: if _death_player:
_death_player.pitch_scale = randf_range(0.9, 1.1) _death_player.pitch_scale = randf_range(0.9, 1.1)
_death_player.play() _death_player.play()
@@ -619,6 +793,7 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void:
pb.global_transform = _skeleton.global_transform * _skeleton.get_bone_global_pose(bone_idx) pb.global_transform = _skeleton.global_transform * _skeleton.get_bone_global_pose(bone_idx)
_sim.active = true _sim.active = true
_sim.physical_bones_start_simulation() _sim.physical_bones_start_simulation()
_pin_cape_to_body()
var strength := DP.f("mat_ragdoll_impulse") * clampf(bull_speed / 15.0, 0.4, 1.3) var strength := DP.f("mat_ragdoll_impulse") * clampf(bull_speed / 15.0, 0.4, 1.3)
var right := throw_dir.cross(Vector3.UP).normalized() var right := throw_dir.cross(Vector3.UP).normalized()
for child: Node in _sim.get_children(): for child: Node in _sim.get_children():
@@ -641,61 +816,46 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void:
impulse = (throw_dir * 0.3 + right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5 impulse = (throw_dir * 0.3 + right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5
"arm_R": "arm_R":
impulse = (throw_dir * 0.3 - right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5 impulse = (throw_dir * 0.3 - right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5
"Bone", "Bone.001", "Bone.002":
# Scale by mass so the light cape gets the same velocity as the body
# (Δv = impulse/mass) and flies along with it instead of rocketing off.
impulse = (throw_dir + Vector3.UP * 0.35).normalized() * strength * 2.0 * pb.mass
_: _:
continue continue
var noise := Vector3(randf_range(-0.15, 0.15), randf_range(-0.08, 0.15), randf_range(-0.15, 0.15)) var noise := Vector3(randf_range(-0.15, 0.15), randf_range(-0.08, 0.15), randf_range(-0.15, 0.15))
pb.apply_central_impulse(impulse + noise * strength * 0.2) pb.apply_central_impulse(impulse + noise * strength * 0.2)
# A floor slam launches the whole ragdoll straight up: a uniform per-bone Δv
# ── Blood burst ─────────────────────────────────────────────────────────────── # (impulse = mass · speed) so the joints carry every bone up together.
if up_boost > 0.0:
func _setup_blood_burst() -> void: for child: Node in _sim.get_children():
var mat := StandardMaterial3D.new() if child is PhysicalBone3D:
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED var pbb := child as PhysicalBone3D
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA pbb.apply_central_impulse(Vector3.UP * up_boost * pbb.mass)
mat.vertex_color_use_as_albedo = true
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
var sphere := SphereMesh.new()
sphere.radius = 0.07
sphere.height = 0.14
sphere.radial_segments = 4
sphere.rings = 2
sphere.material = mat
var ramp := Gradient.new()
ramp.set_color(0, Color(1.0, 0.02, 0.02, 1.0))
ramp.set_color(1, Color(0.25, 0.0, 0.0, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 1.0))
scale_curve.add_point(Vector2(0.6, 0.5))
scale_curve.add_point(Vector2(1.0, 0.0))
_blood_burst = CPUParticles3D.new()
_blood_burst.mesh = sphere
_blood_burst.color_ramp = ramp
_blood_burst.scale_amount_curve = scale_curve
_blood_burst.one_shot = true
_blood_burst.explosiveness = 0.9
_blood_burst.amount = 25
_blood_burst.lifetime = 1.1
_blood_burst.randomness = 0.4
_blood_burst.local_coords = false
_blood_burst.spread = 80.0
_blood_burst.gravity = Vector3(0.0, -7.0, 0.0)
_blood_burst.initial_velocity_min = 4.0
_blood_burst.initial_velocity_max = 11.0
_blood_burst.scale_amount_min = 0.5
_blood_burst.scale_amount_max = 1.6
_blood_burst.emitting = false
add_child(_blood_burst)
func _spawn_blood_burst(hit_dir: Vector3) -> void: # Pin the cape's root link to the chest so it stays attached at the shoulders
_blood_burst.global_position = global_position + Vector3(0.0, 0.9, 0.0) # while ragdolling (its bone chain is a skeleton root, so it has no joint to the
_blood_burst.direction = (hit_dir * 0.6 + Vector3.UP * 0.4).normalized() # body otherwise). The chain hangs and swings from this point.
_blood_burst.restart() func _pin_cape_to_body() -> void:
var cape := _physical_bone(&"Bone")
var chest := _physical_bone(&"chest")
if cape == null or chest == null:
return
var joint := PinJoint3D.new()
_sim.add_child(joint)
joint.global_position = cape.global_position
joint.node_a = chest.get_path()
joint.node_b = cape.get_path()
func _physical_bone(bone: StringName) -> PhysicalBone3D:
if not _sim:
return null
for child: Node in _sim.get_children():
if child is PhysicalBone3D and (child as PhysicalBone3D).bone_name == bone:
return child as PhysicalBone3D
return null
# ── Animation helpers ───────────────────────────────────────────────────────── # ── Animation helpers ─────────────────────────────────────────────────────────
@@ -706,6 +866,16 @@ func _ensure_loop(anim_name: StringName) -> void:
anim.loop_mode = Animation.LOOP_LINEAR anim.loop_mode = Animation.LOOP_LINEAR
func _pick_taunt() -> StringName:
var choices: Array[StringName] = []
for anim in _ANIM_TAUNTS:
if _anim_player != null and _anim_player.has_animation(anim):
choices.append(anim)
if choices.is_empty():
return _ANIM_IDLE
return choices[randi() % choices.size()]
func _anim_length(anim_name: StringName, fallback: float) -> float: func _anim_length(anim_name: StringName, fallback: float) -> float:
if _anim_player and _anim_player.has_animation(anim_name): if _anim_player and _anim_player.has_animation(anim_name):
return _anim_player.get_animation(anim_name).length return _anim_player.get_animation(anim_name).length
@@ -715,8 +885,8 @@ func _anim_length(anim_name: StringName, fallback: float) -> float:
func _play_anim(anim_name: StringName) -> void: func _play_anim(anim_name: StringName) -> void:
if _anim_player == null: if _anim_player == null:
return return
if _drawing: if _drawing or _sheathing:
return # let the unholster clip play through uninterrupted return # let the draw / sheathe clip play through uninterrupted
if _anim_player.current_animation == anim_name: if _anim_player.current_animation == anim_name:
return return
_anim_player.play(anim_name) _anim_player.play(anim_name)
@@ -743,9 +913,9 @@ func _pick_wander_target() -> void:
# weapon_bone — the grip pose in the right hand (drawn / fighting) # weapon_bone — the grip pose in the right hand (drawn / fighting)
# weapon_rest_bone — the holstered pose at the hip (sheathed / wandering) # weapon_rest_bone — the holstered pose at the hip (sheathed / wandering)
# The sword lives in the holster by default and is carried to the hand while the # The sword lives in the holster by default and is carried to the hand while the
# matador is in a combat state (see _carry_sword). Both bones are oriented in # matador is in a combat state (see _carry_sword). Each bone has its own
# Blender to seat the sword directly, so the local offset/rotation default to # DP-tunable local offset/rotation (sword_* for the hand, sword_rest_* for the
# identity and stay DP-tunable for fine-tuning without re-exporting. # holster) so the two poses can be seated independently without re-exporting.
func _setup_sword() -> void: func _setup_sword() -> void:
if not _skeleton: if not _skeleton:
return return
@@ -782,8 +952,8 @@ func _spawn_sword() -> void:
# Blade collider spans the steel (hilt at local origin, blade running +Z out # Blade collider spans the steel (hilt at local origin, blade running +Z out
# to ~1.4 m). Only monitors while the sword is in hand (see _carry_sword). # to ~1.4 m). Only monitors while the sword is in hand (see _carry_sword).
var blade_cap := CapsuleShape3D.new() var blade_cap := CapsuleShape3D.new()
blade_cap.radius = 0.10 blade_cap.radius = 0.18
blade_cap.height = 1.10 blade_cap.height = 1.30
var blade_cs := CollisionShape3D.new() var blade_cs := CollisionShape3D.new()
blade_cs.shape = blade_cap blade_cs.shape = blade_cap
blade_cs.position = Vector3(0.0, 0.0, 0.8) blade_cs.position = Vector3(0.0, 0.0, 0.8)
@@ -799,63 +969,84 @@ func _spawn_sword() -> void:
_sword_blade_area.body_entered.connect(_on_blade_hit) _sword_blade_area.body_entered.connect(_on_blade_hit)
# Draw the sword as the bull approaches and re-sheathe it once the bull is far # The sword is carried by ACTION, not distance: drawn only while striking
# again — so a matador wanders with the sword holstered and pulls it out to # (ATTACK / THROW) and holstered while running, dodging, bracing or taunting.
# engage. Drawing plays the Draw_weapon clip; re-sheathing snaps back. Hysteresis # THROW snaps the blade out (its arm cock-back covers the motion); everything
# (1.25× on the re-sheathe) avoids flicker at the threshold. # else animates with the Draw_weapon clip — forward to draw, reversed to sheathe.
func _wants_sword_drawn() -> bool:
return _state == State.ATTACK or _state == State.THROW \
or _state == State.BRACE or _state == State.SIDESTEP
func _update_sword_carry() -> void: func _update_sword_carry() -> void:
if not is_instance_valid(_sword_node): if not is_instance_valid(_sword_node) or _state == State.RAGDOLL:
return return
if _state == State.RAGDOLL or _bull == null: if _wants_sword_drawn():
_cancel_draw()
_carry_sword(false)
return
var dist := global_position.distance_to(_bull.global_position)
var draw_r := DP.f("mat_draw_range")
if dist <= draw_r:
if not _sword_in_hand and not _drawing: if not _sword_in_hand and not _drawing:
_begin_draw() _begin_draw()
elif dist > draw_r * 1.25: elif _sword_in_hand or _drawing:
_cancel_draw() # Animate the sheathe only into the unhurried locomotion states; snap for
# a dodge / roll / brace where the body clip can't spare the time.
if _state == State.WANDER or _state == State.FLEE:
if not _sheathing:
_begin_sheathe()
else:
_cancel_transitions()
_carry_sword(false) _carry_sword(false)
func _begin_draw() -> void: func _begin_draw() -> void:
if _anim_player and _anim_player.has_animation(_ANIM_DRAW): if not (_anim_player and _anim_player.has_animation(_ANIM_DRAW)):
_drawing = true
_draw_len = _anim_length(_ANIM_DRAW, 1.0)
_draw_timer = 0.0
_anim_player.play(_ANIM_DRAW)
_anim_player.seek(0.0, true)
else:
_carry_sword(true) # no draw clip on this rig — snap to hand _carry_sword(true) # no draw clip on this rig — snap to hand
func _advance_draw(delta: float) -> void:
if not _drawing:
return return
# A combat state needs the body animations back at once — finish instantly. _sheathing = false
if _state != State.WANDER and _state != State.FLEE: _drawing = true
_finalize_draw() _draw_timer = 0.0
_draw_len = _anim_length(_ANIM_DRAW, 1.0) / _draw_speed()
_anim_player.play(_ANIM_DRAW, -1.0, _draw_speed())
_anim_player.seek(0.0, true)
func _begin_sheathe() -> void:
if not (_anim_player and _anim_player.has_animation(_ANIM_DRAW)):
_carry_sword(false) # no clip — snap to holster
return
_drawing = false
_sheathing = true
_draw_timer = 0.0
_draw_len = _anim_length(_ANIM_DRAW, 1.0) / _draw_speed()
# Draw_weapon reversed from its end frame = a put-the-sword-away motion.
_anim_player.play(_ANIM_DRAW, -1.0, -_draw_speed(), true)
# Progress a draw or sheathe; the blade reparents at the grab point (mirrored for
# the reverse sheathe), and the transition ends when the clip's runtime elapses.
func _advance_draw(delta: float) -> void:
if not _drawing and not _sheathing:
return return
_draw_timer += delta _draw_timer += delta
if not _sword_in_hand and _draw_timer >= _draw_len * _DRAW_GRAB_AT: var t := _draw_timer / maxf(_draw_len, 0.001)
if _drawing:
if not _sword_in_hand and t >= _DRAW_GRAB_AT:
_carry_sword(true) _carry_sword(true)
if _draw_timer >= _draw_len: if t >= 1.0:
_finalize_draw()
func _finalize_draw() -> void:
if not _drawing:
return
_drawing = false _drawing = false
_carry_sword(true) _carry_sword(true)
else:
if _sword_in_hand and t >= 1.0 - _DRAW_GRAB_AT:
_carry_sword(false)
if t >= 1.0:
_sheathing = false
_carry_sword(false)
# Abort an in-progress draw without forcing the sword into the hand, so the func _cancel_transitions() -> void:
# caller can re-holster (the suppressed locomotion anim resumes next frame).
func _cancel_draw() -> void:
_drawing = false _drawing = false
_sheathing = false
func _draw_speed() -> float:
return maxf(DP.f("mat_draw_speed"), 0.1)
# Move the sword between the hand grip and the hip holster. Reparenting follows # Move the sword between the hand grip and the hip holster. Reparenting follows
@@ -879,72 +1070,16 @@ func _carry_sword(in_hand: bool) -> void:
func _apply_sword_grip() -> void: func _apply_sword_grip() -> void:
if not is_instance_valid(_sword_node): if not is_instance_valid(_sword_node):
return return
if _sword_in_hand:
_sword_node.position = Vector3( _sword_node.position = Vector3(
DP.f("sword_pos_x"), DP.f("sword_pos_y"), DP.f("sword_pos_z")) DP.f("sword_pos_x"), DP.f("sword_pos_y"), DP.f("sword_pos_z"))
_sword_node.rotation_degrees = Vector3( _sword_node.rotation_degrees = Vector3(
DP.f("sword_rot_x"), DP.f("sword_rot_y"), DP.f("sword_rot_z")) DP.f("sword_rot_x"), DP.f("sword_rot_y"), DP.f("sword_rot_z"))
# ── Ragdoll setup ─────────────────────────────────────────────────────────────
func _setup_physical_bones() -> PhysicalBoneSimulator3D:
for child: Node in _skeleton.get_children():
if child is PhysicalBoneSimulator3D:
(child as PhysicalBoneSimulator3D).active = false
return child as PhysicalBoneSimulator3D
var sim := PhysicalBoneSimulator3D.new()
sim.active = false
_skeleton.add_child(sim)
const BONES: Array = [
["matador", 0.18],
["COG", 0.12],
["chest", 0.13, 0.30],
["head", 0.14],
["collarbone_L", 0.06, 0.18],
["collarbone_R", 0.06, 0.18],
["arm_L", 0.07, 0.22],
["arm_R", 0.07, 0.22],
["forearm_L", 0.06, 0.22],
["forearm_R", 0.06, 0.22],
["hand_L", 0.05, 0.12],
["hand_R", 0.05, 0.12],
["leg_L", 0.10, 0.32],
["leg_R", 0.10, 0.32],
["shin_L", 0.08, 0.28],
["shin_R", 0.08, 0.28],
["foot_L", 0.07, 0.18],
["foot_R", 0.07, 0.18],
]
for entry: Array in BONES:
var bname: String = entry[0]
if _skeleton.find_bone(bname) == -1:
continue
var pb := PhysicalBone3D.new()
pb.bone_name = bname
pb.joint_type = PhysicalBone3D.JOINT_TYPE_PIN
pb.mass = 0.4
pb.linear_damp = 0.1
pb.angular_damp = 0.2
pb.collision_layer = 1
pb.collision_mask = 1
var cs := CollisionShape3D.new()
var use_capsule: bool = entry.size() >= 3
if use_capsule:
var cap := CapsuleShape3D.new()
cap.radius = entry[1]
cap.height = entry[2]
cs.shape = cap
else: else:
var sph := SphereShape3D.new() _sword_node.position = Vector3(
sph.radius = entry[1] DP.f("sword_rest_pos_x"), DP.f("sword_rest_pos_y"), DP.f("sword_rest_pos_z"))
cs.shape = sph _sword_node.rotation_degrees = Vector3(
pb.add_child(cs) DP.f("sword_rest_rot_x"), DP.f("sword_rest_rot_y"), DP.f("sword_rest_rot_z"))
sim.add_child(pb)
return sim
# ── Utility ─────────────────────────────────────────────────────────────────── # ── Utility ───────────────────────────────────────────────────────────────────
+85
View File
@@ -0,0 +1,85 @@
class_name MatadorRagdoll
extends RefCounted
## Builds the matador's physical-bone ragdoll. Body joints are cone-twist with
## limited swing/twist (DP-tunable) so the corpse keeps its silhouette instead of
## folding into a flat "deflated" pile; the cape stays free (PIN) so it drapes.
# [bone, radius] → sphere collider; [bone, radius, height] → capsule.
const _BONES: Array = [
["matador", 0.18],
["COG", 0.12],
["chest", 0.13, 0.30],
["head", 0.14],
["collarbone_L", 0.06, 0.18],
["collarbone_R", 0.06, 0.18],
["arm_L", 0.07, 0.22],
["arm_R", 0.07, 0.22],
["forearm_L", 0.06, 0.22],
["forearm_R", 0.06, 0.22],
["hand_L", 0.05, 0.12],
["hand_R", 0.05, 0.12],
["leg_L", 0.10, 0.32],
["leg_R", 0.10, 0.32],
["shin_L", 0.08, 0.28],
["shin_R", 0.08, 0.28],
["foot_L", 0.07, 0.18],
["foot_R", 0.07, 0.18],
["Bone", 0.04, 0.30],
["Bone.001", 0.04, 0.34],
["Bone.002", 0.04, 0.30],
]
# Reuse an existing simulator if the rig already has one, else build the bodies.
static func build(skeleton: Skeleton3D) -> PhysicalBoneSimulator3D:
for child: Node in skeleton.get_children():
if child is PhysicalBoneSimulator3D:
(child as PhysicalBoneSimulator3D).active = false
return child as PhysicalBoneSimulator3D
var sim := PhysicalBoneSimulator3D.new()
sim.active = false
skeleton.add_child(sim)
var swing := DP.f("mat_joint_swing")
var twist := DP.f("mat_joint_twist")
var damp := DP.f("mat_ragdoll_damp")
for entry: Array in _BONES:
var bname: String = entry[0]
if skeleton.find_bone(bname) == -1:
continue
var pb := PhysicalBone3D.new()
pb.bone_name = bname
pb.mass = 0.4
pb.linear_damp = 0.1
pb.collision_layer = 1
pb.collision_mask = 1
if bname.begins_with("Bone"):
# Cape: floppy cloth — free swing, light, draggy, one-way collision.
pb.joint_type = PhysicalBone3D.JOINT_TYPE_PIN
pb.mass = 0.15
pb.linear_damp = 0.5
pb.angular_damp = 0.7
pb.collision_layer = 0
else:
# Body: cone-twist joints cap how far each bone bends from its rest
# pose, so the silhouette holds; extra angular damp settles the flail.
pb.joint_type = PhysicalBone3D.JOINT_TYPE_CONE
pb.angular_damp = damp
pb.set(&"joint_constraints/swing_span", swing)
pb.set(&"joint_constraints/twist_span", twist)
var cs := CollisionShape3D.new()
if entry.size() >= 3:
var cap := CapsuleShape3D.new()
cap.radius = entry[1]
cap.height = entry[2]
cs.shape = cap
else:
var sph := SphereShape3D.new()
sph.radius = entry[1]
cs.shape = sph
pb.add_child(cs)
sim.add_child(pb)
return sim
+1
View File
@@ -0,0 +1 @@
uid://dmf04b5exnejn
+34 -35
View File
@@ -1,46 +1,53 @@
extends Node3D extends Node3D
signal matador_killed signal matador_killed
signal all_defeated
const MATADOR := preload("res://Matador.tscn") const MATADOR := preload("res://Matador.tscn")
const RESPAWN_COOLDOWN := 8.0
var _respawn_cooldown: float = 0.0 var _spawn_count: int = 1
var _wave_count: int = 1
var _alive: int = 0 var _alive: int = 0
var _resolved: bool = false
func _ready() -> void: func _ready() -> void:
add_to_group(&"matador_spawn") add_to_group(&"matador_spawn")
_wave_count = maxi(1, int(DP.f("mat_spawn_count"))) _spawn_count = maxi(1, int(DP.f("mat_spawn_count")))
# Deferred so it's safe from _ready and matches the previous spawn timing.
_spawn.call_deferred() _spawn.call_deferred()
func _process(delta: float) -> void:
_respawn_cooldown = maxf(0.0, _respawn_cooldown - delta)
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo:
if event.physical_keycode == KEY_R and _respawn_cooldown <= 0.0:
_respawn_cooldown = RESPAWN_COOLDOWN
reset_spawn()
func _spawn() -> void: func _spawn() -> void:
var positions := _spread_positions(_wave_count) # Bull and matadors start at opposite ends of a random diameter, so they always
for pos: Vector3 in positions: # begin as far apart as the arena allows (edge-to-edge across the centre).
var arena_r := DP.f("arena_spawn_radius")
var bull_angle := randf() * TAU
_place_bull(bull_angle, arena_r)
for pos: Vector3 in _spread_positions(_spawn_count, bull_angle + PI, arena_r):
_spawn_at(pos) _spawn_at(pos)
func _spread_positions(count: int) -> Array[Vector3]: func _place_bull(angle: float, arena_r: float) -> void:
var bull := get_tree().get_first_node_in_group(&"player") as Node3D
if not bull:
return
bull.global_position = Vector3(
cos(angle) * arena_r, bull.global_position.y, sin(angle) * arena_r
)
# Positions for the matadors, clustered on the far side (centred on `center_angle`)
# so every matador stays near the point diametrically opposite the bull.
func _spread_positions(count: int, center_angle: float, arena_r: float) -> Array[Vector3]:
var positions: Array[Vector3] = [] var positions: Array[Vector3] = []
var arena_r := DP.f("mat_wander_radius") * 0.72 if count <= 1:
var min_r := 5.0 positions.append(Vector3(cos(center_angle) * arena_r, 1.0, sin(center_angle) * arena_r))
return positions
var arc := TAU * 0.33
for i: int in count: for i: int in count:
var sector := TAU / count var t := float(i) / float(count - 1) - 0.5 # -0.5 .. 0.5 across the arc
var angle := sector * i + randf_range(-sector * 0.35, sector * 0.35) var angle := center_angle + t * arc
var radius := randf_range(min_r, arena_r) var radius := randf_range(arena_r * 0.7, arena_r)
positions.append(Vector3(cos(angle) * radius, 1.0, sin(angle) * radius)) positions.append(Vector3(cos(angle) * radius, 1.0, sin(angle) * radius))
return positions return positions
@@ -53,18 +60,10 @@ func _spawn_at(pos: Vector3) -> void:
_alive += 1 _alive += 1
# Every matador down = the bull wins. No waves, no respawns — one clean arena.
func _on_matador_killed() -> void: func _on_matador_killed() -> void:
matador_killed.emit() matador_killed.emit()
_alive -= 1 _alive -= 1
if _alive <= 0: if _alive <= 0 and not _resolved:
_wave_count *= 2 _resolved = true
_spawn.call_deferred() all_defeated.emit()
func reset_spawn() -> void:
for m: Node in get_tree().get_nodes_in_group(&"matador"):
m.queue_free()
await get_tree().process_frame
_wave_count = maxi(1, int(DP.f("mat_spawn_count")))
_alive = 0
_spawn()
+452 -221
View File
@@ -8,54 +8,68 @@ const HOOF_OFFSETS: Array = [
Vector3( 0.30, -0.25, 0.60), Vector3( 0.30, -0.25, 0.60),
] ]
# Bull animation clips (Armature|* action names in Assets/bull.fbx). IDLE is the
# looping base pose; the others are one-shot ability clips that return to it.
const _ANIM_IDLE: StringName = &"Armature|IDLE"
const _ANIM_KICK: StringName = &"Armature|KICK"
const _ANIM_SLAM: StringName = &"Armature|SLAM"
const _ANIM_DASH: StringName = &"Armature|DASH"
const _ANIM_ROLL: StringName = &"Armature|ROLL"
@onready var camera_pivot: Node3D = $Camera3D @onready var camera_pivot: Node3D = $Camera3D
@onready var cube_guy: Node3D = $bull @onready var cube_guy: Node3D = $bull
var _hoof_emitters: Array[CPUParticles3D] = [] var _hoof_emitters: Array[CPUParticles3D] = []
var _legs: Node var _legs: Node
var _kick_timer: float = 0.0
var _was_on_floor: bool = false var _was_on_floor: bool = false
var _pre_slide_vel_y: float = 0.0 var _pre_slide_vel_y: float = 0.0
var _thrust_left_charge: float = 0.0
var _thrust_right_charge: float = 0.0
var _charge_was_full: bool = false
var _slam_ring_mesh: ArrayMesh = null var _slam_ring_mesh: ArrayMesh = null
# Ability system — kick=0, slam=1, dash=2 # Ability system — kick=0, slam=1, dash=2, roll=3
var ability_cd: Array[float] = [0.0, 0.0, 0.0] var ability_cd: Array[float] = [0.0, 0.0, 0.0, 0.0]
var _ability_active: bool = false var _ability_active: bool = false
var _ability_timer: float = 0.0 var _ability_timer: float = 0.0
var _active_ability: int = -1 var _active_ability: int = -1
var _dash_dir: Vector3 = Vector3.ZERO var _dash_dir: Vector3 = Vector3.ZERO
var _roll_dir: Vector3 = Vector3.ZERO
var _roll_ramp: float = 0.0
var _roll_spin: float = 0.0
var _bull_anim: AnimationPlayer = null var _bull_anim: AnimationPlayer = null
var _charge_ready_emitter: CPUParticles3D = null
var _slam_pending: bool = false var _slam_pending: bool = false
var _charge_trail_emitter: CPUParticles3D = null
var _roll_spark_emitter: CPUParticles3D = null
var _cube_scale: Vector3 = Vector3.ONE
const MAX_HEALTH: int = 10 # One clean hit ends the run — see take_sword_hit(). The HUD listens for `died`
var health: int = MAX_HEALTH # to raise the lose screen (the `cause` tags how it happened, for the death notice);
signal health_changed(new_health: int) # _dead latches so a flurry of hits fires it only once.
signal died(cause: String)
var _dead: bool = false
var _huff_player: AudioStreamPlayer = null var _huff_player: AudioStreamPlayer = null
var _crowd_player: AudioStreamPlayer = null var _crowd_player: AudioStreamPlayer = null
var _huff_timer: float = 0.0 var _huff_timer: float = 0.0
var _roll_damping: float = 0.15
enum DustState { NONE, WALK, CHARGE } enum DustState { NONE, WALK, CHARGE }
var _dust_state: DustState = DustState.NONE var _dust_state: DustState = DustState.NONE
var _walk_dust_ramp: Gradient = null
var _charge_dust_ramp: Gradient = null
func _ready() -> void: func _ready() -> void:
add_to_group(&"player") add_to_group(&"player")
_cube_scale = cube_guy.scale
_setup_hoof_dust() _setup_hoof_dust()
_setup_legs() _setup_legs()
_setup_charge_indicators() _setup_charge_trail()
_setup_roll_sparks()
_setup_audio() _setup_audio()
_bull_anim = _find_anim_player(cube_guy) _bull_anim = _find_anim_player(cube_guy)
_setup_bull_idle()
DP.any_changed.connect(_on_dp_changed) DP.any_changed.connect(_on_dp_changed)
_roll_damping = DP.f("roll_damping")
func _setup_legs() -> void: func _setup_legs() -> void:
@@ -66,22 +80,16 @@ func _setup_legs() -> void:
func _setup_hoof_dust() -> void: func _setup_hoof_dust() -> void:
var mat := StandardMaterial3D.new() var sphere := _particle_sphere(0.10, 4, 2, _unshaded_material())
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.vertex_color_use_as_albedo = true
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
var sphere := SphereMesh.new() _walk_dust_ramp = Gradient.new()
sphere.radius = 0.10 _walk_dust_ramp.set_color(0, Color(0.80, 0.69, 0.46, 1.0))
sphere.height = 0.20 _walk_dust_ramp.set_color(1, Color(0.80, 0.69, 0.46, 0.0))
sphere.radial_segments = 4
sphere.rings = 2
sphere.material = mat
var ramp := Gradient.new() # Light blue dust while charging
ramp.set_color(0, Color(0.80, 0.69, 0.46, 1.0)) _charge_dust_ramp = Gradient.new()
ramp.set_color(1, Color(0.80, 0.69, 0.46, 0.0)) _charge_dust_ramp.set_color(0, Color(0.20, 0.40, 0.85, 1.0))
_charge_dust_ramp.set_color(1, Color(0.20, 0.40, 0.85, 0.0))
var scale_curve := Curve.new() var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 0.2)) scale_curve.add_point(Vector2(0.0, 0.2))
@@ -105,54 +113,79 @@ func _setup_hoof_dust() -> void:
p.scale_amount_min = DP.f("walk_scale_min") p.scale_amount_min = DP.f("walk_scale_min")
p.scale_amount_max = DP.f("walk_scale_max") p.scale_amount_max = DP.f("walk_scale_max")
p.mesh = sphere p.mesh = sphere
p.color_ramp = ramp p.color_ramp = _walk_dust_ramp
p.scale_amount_curve = scale_curve p.scale_amount_curve = scale_curve
cube_guy.add_child(p) cube_guy.add_child(p)
_hoof_emitters.append(p) _hoof_emitters.append(p)
func _setup_charge_indicators() -> void: func _setup_charge_trail() -> void:
var mat := StandardMaterial3D.new() # Blue energy trail — streams backward during a fast run / roll
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED var trail_sphere := _particle_sphere(0.065, 4, 2, _unshaded_material())
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.vertex_color_use_as_albedo = true
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
var sphere := SphereMesh.new() var trail_ramp := Gradient.new()
sphere.radius = 0.07 trail_ramp.set_color(0, Color(0.30, 0.65, 1.0, 1.0))
sphere.height = 0.14 trail_ramp.set_color(1, Color(0.30, 0.65, 1.0, 0.0))
sphere.radial_segments = 4
sphere.rings = 2
sphere.material = mat
# Orange spark burst when single-button charge hits max var trail_scale := Curve.new()
var burst_ramp := Gradient.new() trail_scale.add_point(Vector2(0.0, 1.0))
burst_ramp.set_color(0, Color(1.0, 0.55, 0.0, 1.0)) trail_scale.add_point(Vector2(0.6, 0.8))
burst_ramp.set_color(1, Color(1.0, 0.15, 0.0, 0.0)) trail_scale.add_point(Vector2(1.0, 0.0))
var burst_scale := Curve.new() _charge_trail_emitter = CPUParticles3D.new()
burst_scale.add_point(Vector2(0.0, 1.0)) _charge_trail_emitter.position = Vector3(0.0, 0.25, 0.3)
burst_scale.add_point(Vector2(1.0, 0.0)) _charge_trail_emitter.emitting = false
_charge_trail_emitter.one_shot = false
_charge_trail_emitter.amount = 18
_charge_trail_emitter.lifetime = 0.30
_charge_trail_emitter.randomness = 0.4
_charge_trail_emitter.local_coords = false
_charge_trail_emitter.direction = Vector3(0.0, 0.2, 1.0).normalized()
_charge_trail_emitter.spread = 25.0
_charge_trail_emitter.gravity = Vector3(0.0, 2.0, 0.0)
_charge_trail_emitter.initial_velocity_min = 4.0
_charge_trail_emitter.initial_velocity_max = 9.0
_charge_trail_emitter.scale_amount_min = 0.4
_charge_trail_emitter.scale_amount_max = 1.2
_charge_trail_emitter.mesh = trail_sphere
_charge_trail_emitter.color_ramp = trail_ramp
_charge_trail_emitter.scale_amount_curve = trail_scale
cube_guy.add_child(_charge_trail_emitter)
_charge_ready_emitter = CPUParticles3D.new()
_charge_ready_emitter.position = Vector3(0.0, 0.3, 0.0) func _setup_roll_sparks() -> void:
_charge_ready_emitter.emitting = false # Rammus Powerball ground spray — dirt/sparks flung outward from the spinning
_charge_ready_emitter.one_shot = true # ball. World-space (local_coords off) so they peel off into a trail behind it.
_charge_ready_emitter.amount = 16 var spark := _particle_sphere(0.05, 4, 2, _unshaded_material())
_charge_ready_emitter.lifetime = 0.5
_charge_ready_emitter.explosiveness = 1.0 var ramp := Gradient.new()
_charge_ready_emitter.randomness = 0.4 ramp.set_color(0, Color(1.00, 0.78, 0.32, 1.0))
_charge_ready_emitter.local_coords = false ramp.set_color(1, Color(0.70, 0.42, 0.14, 0.0))
_charge_ready_emitter.direction = Vector3.UP
_charge_ready_emitter.spread = 120.0 var sc := Curve.new()
_charge_ready_emitter.initial_velocity_min = 2.5 sc.add_point(Vector2(0.0, 0.30))
_charge_ready_emitter.initial_velocity_max = 5.0 sc.add_point(Vector2(0.3, 1.00))
_charge_ready_emitter.scale_amount_min = 0.2 sc.add_point(Vector2(1.0, 0.00))
_charge_ready_emitter.scale_amount_max = 0.5
_charge_ready_emitter.mesh = sphere _roll_spark_emitter = CPUParticles3D.new()
_charge_ready_emitter.color_ramp = burst_ramp _roll_spark_emitter.position = Vector3(0.0, 0.10, 0.0)
_charge_ready_emitter.scale_amount_curve = burst_scale _roll_spark_emitter.emitting = false
cube_guy.add_child(_charge_ready_emitter) _roll_spark_emitter.one_shot = false
_roll_spark_emitter.amount = 46
_roll_spark_emitter.lifetime = 0.45
_roll_spark_emitter.randomness = 0.7
_roll_spark_emitter.local_coords = false
_roll_spark_emitter.direction = Vector3(0.0, 1.0, 0.0)
_roll_spark_emitter.spread = 180.0
_roll_spark_emitter.gravity = Vector3(0.0, -6.0, 0.0)
_roll_spark_emitter.initial_velocity_min = 3.0
_roll_spark_emitter.initial_velocity_max = 8.0
_roll_spark_emitter.scale_amount_min = 0.5
_roll_spark_emitter.scale_amount_max = 1.3
_roll_spark_emitter.mesh = spark
_roll_spark_emitter.color_ramp = ramp
_roll_spark_emitter.scale_amount_curve = sc
cube_guy.add_child(_roll_spark_emitter)
func _setup_audio() -> void: func _setup_audio() -> void:
@@ -185,21 +218,24 @@ func _set_dust_state(new_state: DustState) -> void:
DustState.NONE: DustState.NONE:
p.emitting = false p.emitting = false
DustState.WALK: DustState.WALK:
p.color_ramp = _walk_dust_ramp
p.scale_amount_min = DP.f("walk_scale_min") p.scale_amount_min = DP.f("walk_scale_min")
p.scale_amount_max = DP.f("walk_scale_max") p.scale_amount_max = DP.f("walk_scale_max")
p.initial_velocity_min = DP.f("walk_vel_min") p.initial_velocity_min = DP.f("walk_vel_min")
p.initial_velocity_max = DP.f("walk_vel_max") p.initial_velocity_max = DP.f("walk_vel_max")
p.emitting = true p.emitting = true
DustState.CHARGE: DustState.CHARGE:
p.color_ramp = _charge_dust_ramp
p.scale_amount_min = DP.f("charge_scale_min") p.scale_amount_min = DP.f("charge_scale_min")
p.scale_amount_max = DP.f("charge_scale_max") p.scale_amount_max = DP.f("charge_scale_max")
p.initial_velocity_min = DP.f("charge_vel_min") p.initial_velocity_min = DP.f("charge_vel_min")
p.initial_velocity_max = DP.f("charge_vel_max") p.initial_velocity_max = DP.f("charge_vel_max")
p.emitting = true p.emitting = true
if _charge_trail_emitter:
_charge_trail_emitter.emitting = _dust_state == DustState.CHARGE
func _on_dp_changed(_key: String, _val: Variant) -> void: func _on_dp_changed(_key: String, _val: Variant) -> void:
_roll_damping = DP.f("roll_damping")
if _hoof_emitters.is_empty() or _dust_state == DustState.NONE: if _hoof_emitters.is_empty() or _dust_state == DustState.NONE:
return return
var prev := _dust_state var prev := _dust_state
@@ -217,15 +253,39 @@ func _find_anim_player(node: Node) -> AnimationPlayer:
return null return null
func _unhandled_input(event: InputEvent) -> void: # IDLE ships one-shot; make it loop and start it so the bull breathes at rest
if _ability_active: # instead of freezing on a bind pose. It's the base every ability blends back to.
func _setup_bull_idle() -> void:
if _bull_anim == null:
return return
# IDLE and ROLL both loop as continuous states; the ability clips are one-shot.
for clip: StringName in [_ANIM_IDLE, _ANIM_ROLL]:
if _bull_anim.has_animation(clip):
_bull_anim.get_animation(clip).loop_mode = Animation.LOOP_LINEAR
_play_idle()
# Return to the looping idle (and undo any ability speed-scale). Cross-fades so a
# finishing ability clip eases back to idle instead of snapping.
func _play_idle() -> void:
if _bull_anim == null:
return
_bull_anim.speed_scale = 1.0
if _bull_anim.has_animation(_ANIM_IDLE):
_bull_anim.play(_ANIM_IDLE, 0.2)
func _unhandled_input(event: InputEvent) -> void:
# Abilities can be chained: a new one interrupts whatever is currently
# active, gated only by each ability's own cooldown.
if event.is_action_pressed(&"ability_kick") and ability_cd[0] <= 0.0: if event.is_action_pressed(&"ability_kick") and ability_cd[0] <= 0.0:
_activate_kick() _activate_kick()
elif event.is_action_pressed(&"ability_slam") and ability_cd[1] <= 0.0: elif event.is_action_pressed(&"ability_slam") and ability_cd[1] <= 0.0:
_activate_slam() _activate_slam()
elif event.is_action_pressed(&"ability_dash") and ability_cd[2] <= 0.0: elif event.is_action_pressed(&"ability_dash") and ability_cd[2] <= 0.0:
_activate_dash() _activate_dash()
elif event.is_action_pressed(&"ability_roll") and ability_cd[3] <= 0.0:
_activate_roll()
func ability_cooldown_fraction(slot: int) -> float: func ability_cooldown_fraction(slot: int) -> float:
@@ -234,17 +294,24 @@ func ability_cooldown_fraction(slot: int) -> float:
0: maxcd = DP.f("kick_cooldown") 0: maxcd = DP.f("kick_cooldown")
1: maxcd = DP.f("slam_cooldown") 1: maxcd = DP.f("slam_cooldown")
2: maxcd = DP.f("dash_cooldown") 2: maxcd = DP.f("dash_cooldown")
3: maxcd = DP.f("roll_cooldown")
return ability_cd[slot] / maxcd if maxcd > 0.0 else 0.0 return ability_cd[slot] / maxcd if maxcd > 0.0 else 0.0
func ability_cooldown_remaining(slot: int) -> float:
return ability_cd[slot]
func _activate_kick() -> void: func _activate_kick() -> void:
ability_cd[0] = DP.f("kick_cooldown") ability_cd[0] = DP.f("kick_cooldown")
_ability_active = true _ability_active = true
_active_ability = 0 _active_ability = 0
_ability_timer = 0.6 _ability_timer = 0.6
if _bull_anim and _bull_anim.has_animation(&"Armature|FOOTKICK"): if _bull_anim:
_bull_anim.play(&"Armature|FOOTKICK") _bull_anim.speed_scale = 1.0
_ability_timer = _bull_anim.get_animation(&"Armature|FOOTKICK").length if _bull_anim and _bull_anim.has_animation(_ANIM_KICK):
_bull_anim.play(_ANIM_KICK)
_ability_timer = _bull_anim.get_animation(_ANIM_KICK).length
var raw := cube_guy.global_transform.basis.z var raw := cube_guy.global_transform.basis.z
var back := -Vector3(raw.x, 0.0, raw.z).normalized() var back := -Vector3(raw.x, 0.0, raw.z).normalized()
_spawn_kick_fx(back) _spawn_kick_fx(back)
@@ -261,12 +328,14 @@ func _activate_slam() -> void:
_slam_pending = true _slam_pending = true
var jump_vel := DP.f("slam_jump_velocity") var jump_vel := DP.f("slam_jump_velocity")
velocity.y = jump_vel velocity.y = jump_vel
var air_time := jump_vel / 9.8 * 2.0 # Airtime with an accelerated descent: rise (v/g) + fall (v / (g·√mult)).
_ability_timer = air_time + 0.5 var fall_mult := maxf(DP.f("slam_fall_mult"), 0.01)
if _bull_anim and _bull_anim.has_animation(&"Armature|SLAM"): var air_time := (jump_vel / 9.8) * (1.0 + 1.0 / sqrt(fall_mult))
var anim_len := _bull_anim.get_animation(&"Armature|SLAM").length _ability_timer = air_time + 0.3
if _bull_anim and _bull_anim.has_animation(_ANIM_SLAM):
var anim_len := _bull_anim.get_animation(_ANIM_SLAM).length
_bull_anim.speed_scale = anim_len / maxf(air_time, 0.01) _bull_anim.speed_scale = anim_len / maxf(air_time, 0.01)
_bull_anim.play(&"Armature|SLAM") _bull_anim.play(_ANIM_SLAM)
func _activate_dash() -> void: func _activate_dash() -> void:
@@ -274,19 +343,130 @@ func _activate_dash() -> void:
_ability_active = true _ability_active = true
_active_ability = 2 _active_ability = 2
_ability_timer = 0.45 _ability_timer = 0.45
if _bull_anim and _bull_anim.has_animation(&"Armature|DASH"): if _bull_anim:
_bull_anim.play(&"Armature|DASH") _bull_anim.speed_scale = 1.0
_ability_timer = _bull_anim.get_animation(&"Armature|DASH").length if _bull_anim and _bull_anim.has_animation(_ANIM_DASH):
_bull_anim.play(_ANIM_DASH)
_ability_timer = _bull_anim.get_animation(_ANIM_DASH).length
var raw := cube_guy.global_transform.basis.z var raw := cube_guy.global_transform.basis.z
_dash_dir = Vector3(raw.x, 0.0, raw.z).normalized() _dash_dir = Vector3(raw.x, 0.0, raw.z).normalized()
velocity.x = _dash_dir.x * DP.f("dash_speed") velocity.x = _dash_dir.x * DP.f("dash_speed")
velocity.z = _dash_dir.z * DP.f("dash_speed") velocity.z = _dash_dir.z * DP.f("dash_speed")
_spawn_dash_burst()
_legs.set(&"charge_pitch_target", DP.f("charge_head_pitch")) _legs.set(&"charge_pitch_target", DP.f("charge_head_pitch"))
if _huff_player: if _huff_player:
_huff_player.pitch_scale = randf_range(0.9, 1.1) _huff_player.pitch_scale = randf_range(0.9, 1.1)
_huff_player.play() _huff_player.play()
func _activate_roll() -> void:
ability_cd[3] = DP.f("roll_cooldown")
_ability_active = true
_active_ability = 3
_ability_timer = DP.f("roll_duration")
_roll_ramp = 0.0
_roll_spin = 0.0
# Curl into the ball along the current heading (or facing when standing still)
# and launch at the base speed — it ramps up from here the longer you roll.
var flat := Vector3(velocity.x, 0.0, velocity.z)
if flat.length() > 1.0:
_roll_dir = flat.normalized()
else:
var f := cube_guy.global_transform.basis.z
_roll_dir = Vector3(f.x, 0.0, f.z).normalized()
var launch := DP.f("roll_base_speed")
velocity.x = _roll_dir.x * launch
velocity.z = _roll_dir.z * launch
if _bull_anim and _bull_anim.has_animation(_ANIM_ROLL):
_bull_anim.speed_scale = 1.0
_bull_anim.play(_ANIM_ROLL)
_legs.set(&"tail_ragdoll", true) # tail streams out behind
_legs.set(&"charge_pitch_target", DP.f("charge_head_pitch")) # horns down, plowing
_legs.set(&"external_rotation", true) # roll owns the model's full orientation (tumble)
if _roll_spark_emitter:
_roll_spark_emitter.emitting = true
_spawn_dash_burst()
camera_pivot.call(&"trigger_hit", 0.35)
if _huff_player:
_huff_player.pitch_scale = randf_range(0.8, 1.0)
_huff_player.play()
# One rolling frame (Rammus Powerball): steer with momentum, accelerate the longer
# the roll lasts, move (walls just slide), then pop the ball on the first matador we
# ram. Fully replaces the normal locomotion path while active.
func _tick_roll(delta: float, steer: Vector3) -> void:
var steer_flat := Vector3(steer.x, 0.0, steer.z)
if steer_flat.length() > 0.1:
var t := clampf(DP.f("roll_turn") * delta, 0.0, 1.0)
_roll_dir = _roll_dir.slerp(steer_flat.normalized(), t).normalized()
# Speed builds over time: base → max across roll_rampup_time, so a long roll
# winds up into a runaway boulder while a quick tap barely gets going.
_roll_ramp += delta
var ramp := clampf(_roll_ramp / maxf(DP.f("roll_rampup_time"), 0.01), 0.0, 1.0)
var top := DP.f("roll_max_speed")
var speed := lerpf(DP.f("roll_base_speed"), top, ramp)
velocity.x = _roll_dir.x * speed
velocity.z = _roll_dir.z * speed
# Tumble the whole model like a Powerball: face the travel direction (yaw) then
# spin forward about the horizontal axis perpendicular to it, angular speed = v/r
# so it "rolls" without slipping. Legs yield rotation control while active.
var radius := maxf(DP.f("roll_spin_radius"), 0.1)
_roll_spin = wrapf(_roll_spin + (speed / radius) * delta, 0.0, TAU)
var facing := Basis(Vector3.UP, atan2(_roll_dir.x, _roll_dir.z))
var tumble_axis := Vector3.UP.cross(_roll_dir).normalized()
cube_guy.basis = (Basis(tumble_axis, _roll_spin) * facing).scaled(_cube_scale)
if _bull_anim and _bull_anim.has_animation(_ANIM_ROLL):
_bull_anim.speed_scale = clampf(speed / maxf(top, 1.0), 0.6, 2.4)
_set_dust_state(DustState.CHARGE)
if _charge_trail_emitter:
_charge_trail_emitter.direction = (-_roll_dir + Vector3(0.0, 0.25, 0.0)).normalized()
move_and_slide()
_was_on_floor = is_on_floor()
_roll_try_pop(speed)
# Pop the ball on ramming a matador: launch every matador in the hit radius up and
# outward, then end the roll (Rammus stops when Powerball connects). Returns true if
# it popped so the caller can stop touching roll state this frame.
func _roll_try_pop(speed: float) -> bool:
var radius := DP.f("roll_hit_radius")
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
var to_mat: Vector3 = (mat as Node3D).global_position - global_position
to_mat.y = 0.0
if to_mat.length() > radius:
continue
_hit_matadors_radius(radius, DP.f("roll_hit_strength"), DP.f("roll_knockup"))
camera_pivot.call(&"trigger_hit", clampf(speed / 90.0, 0.35, 1.0))
if _huff_player:
_huff_player.pitch_scale = randf_range(1.05, 1.35)
_huff_player.play()
_end_roll()
return true
return false
# End the Powerball early: brake hard (the ball "pops" and unrolls) and drop back to
# the normal locomotion path next frame.
func _end_roll() -> void:
_ability_active = false
_active_ability = -1
_ability_timer = 0.0
_roll_ramp = 0.0
_legs.set(&"charge_pitch_target", 0.0)
_legs.set(&"tail_ragdoll", false)
_legs.set(&"external_rotation", false)
if _roll_spark_emitter:
_roll_spark_emitter.emitting = false
velocity.x *= 0.15
velocity.z *= 0.15
_play_idle()
func _hit_matadors_cone(range_m: float, half_angle_rad: float, strength: float, func _hit_matadors_cone(range_m: float, half_angle_rad: float, strength: float,
dir: Vector3 = Vector3.ZERO) -> void: dir: Vector3 = Vector3.ZERO) -> void:
var raw := cube_guy.global_transform.basis.z var raw := cube_guy.global_transform.basis.z
@@ -294,22 +474,56 @@ func _hit_matadors_cone(range_m: float, half_angle_rad: float, strength: float,
for mat: Node in get_tree().get_nodes_in_group(&"matador"): for mat: Node in get_tree().get_nodes_in_group(&"matador"):
var to_mat: Vector3 = (mat as Node3D).global_position - global_position var to_mat: Vector3 = (mat as Node3D).global_position - global_position
to_mat.y = 0.0 to_mat.y = 0.0
if to_mat.length() > range_m: var dist := to_mat.length()
if dist > range_m:
continue continue
if to_mat.length() > 0.01 and forward.dot(to_mat.normalized()) < cos(half_angle_rad): if dist > 0.01 and forward.dot(to_mat / dist) < cos(half_angle_rad):
continue continue
mat.call(&"apply_ability_hit", (forward + Vector3.UP * 0.4).normalized(), strength) mat.call(&"apply_ability_hit", (forward + Vector3.UP * 0.4).normalized(), strength)
func _hit_matadors_radius(range_m: float, strength: float) -> void: func _hit_matadors_radius(range_m: float, strength: float, up_boost: float = 0.0) -> void:
for mat: Node in get_tree().get_nodes_in_group(&"matador"): for mat: Node in get_tree().get_nodes_in_group(&"matador"):
var to_mat: Vector3 = (mat as Node3D).global_position - global_position var to_mat: Vector3 = (mat as Node3D).global_position - global_position
to_mat.y = 0.0 to_mat.y = 0.0
if to_mat.length() > range_m: var dist := to_mat.length()
if dist > range_m:
continue continue
var away := to_mat.normalized() if to_mat.length() > 0.01 \ var away := to_mat / dist if dist > 0.01 \
else Vector3(randf() - 0.5, 0.0, randf() - 0.5).normalized() else Vector3(randf() - 0.5, 0.0, randf() - 0.5).normalized()
mat.call(&"apply_ability_hit", (away + Vector3.UP * 0.6).normalized(), strength) mat.call(&"apply_ability_hit", (away + Vector3.UP * 0.6).normalized(), strength, up_boost)
# ── Particle helpers ──────────────────────────────────────────────────────────
# All FX in this script use unshaded, vertex-coloured, alpha-blended particles;
# these three helpers remove the boilerplate each spawn function used to repeat.
func _unshaded_material() -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.vertex_color_use_as_albedo = true
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
return mat
# Low-poly sphere sized for a particle mesh (height is always the diameter).
func _particle_sphere(radius: float, segments: int, rings: int, mat: Material) -> SphereMesh:
var sphere := SphereMesh.new()
sphere.radius = radius
sphere.height = radius * 2.0
sphere.radial_segments = segments
sphere.rings = rings
sphere.material = mat
return sphere
# Queue-free a transient FX node once its particles have finished.
func _free_after(node: Node, delay: float) -> void:
get_tree().create_timer(delay).timeout.connect(func() -> void:
if is_instance_valid(node):
node.queue_free()
)
func _spawn_slam_fx() -> void: func _spawn_slam_fx() -> void:
@@ -355,18 +569,7 @@ func _launch_slam_ring(origin: Vector3, max_r: float,
func _spawn_slam_dust(origin: Vector3) -> void: func _spawn_slam_dust(origin: Vector3) -> void:
var mat := StandardMaterial3D.new() var sphere := _particle_sphere(0.09, 4, 2, _unshaded_material())
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.vertex_color_use_as_albedo = true
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
var sphere := SphereMesh.new()
sphere.radius = 0.09
sphere.height = 0.18
sphere.radial_segments = 4
sphere.rings = 2
sphere.material = mat
var ramp := Gradient.new() var ramp := Gradient.new()
ramp.set_color(0, Color(0.88, 0.74, 0.46, 1.0)) ramp.set_color(0, Color(0.88, 0.74, 0.46, 1.0))
@@ -397,35 +600,24 @@ func _spawn_slam_dust(origin: Vector3) -> void:
get_parent().add_child(p) get_parent().add_child(p)
p.global_position = origin p.global_position = origin
p.restart() p.restart()
get_tree().create_timer(2.5).timeout.connect(func() -> void: _free_after(p, 2.5)
if is_instance_valid(p): p.queue_free()
)
func _spawn_kick_fx(back_dir: Vector3) -> void: func _spawn_kick_fx(back_dir: Vector3) -> void:
var origin := global_position + back_dir * 0.55 + Vector3(0.0, -0.25, 0.0) var origin := global_position + back_dir * 0.55 + Vector3(0.0, -0.25, 0.0)
_spawn_kick_dust(origin, back_dir) _spawn_kick_dust(origin, back_dir)
_spawn_kick_stones(origin, back_dir) _spawn_kick_stones(origin, back_dir)
_spawn_kick_sparks(origin, back_dir)
_spawn_kick_flash(origin)
camera_pivot.call(&"trigger_hit", 0.35) camera_pivot.call(&"trigger_hit", 0.35)
func _spawn_kick_dust(origin: Vector3, back_dir: Vector3) -> void: func _spawn_kick_dust(origin: Vector3, back_dir: Vector3) -> void:
var mat := StandardMaterial3D.new() var sphere := _particle_sphere(0.12, 4, 2, _unshaded_material())
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.vertex_color_use_as_albedo = true
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
var sphere := SphereMesh.new()
sphere.radius = 0.12
sphere.height = 0.24
sphere.radial_segments = 4
sphere.rings = 2
sphere.material = mat
var ramp := Gradient.new() var ramp := Gradient.new()
ramp.set_color(0, Color(0.80, 0.68, 0.42, 0.9)) ramp.set_color(0, Color(1.0, 0.78, 0.18, 0.95))
ramp.set_color(1, Color(0.80, 0.68, 0.42, 0.0)) ramp.set_color(1, Color(1.0, 0.45, 0.05, 0.0))
var scale_curve := Curve.new() var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 0.3)) scale_curve.add_point(Vector2(0.0, 0.3))
@@ -452,24 +644,11 @@ func _spawn_kick_dust(origin: Vector3, back_dir: Vector3) -> void:
get_parent().add_child(p) get_parent().add_child(p)
p.global_position = origin p.global_position = origin
p.restart() p.restart()
get_tree().create_timer(2.5).timeout.connect(func() -> void: _free_after(p, 2.5)
if is_instance_valid(p): p.queue_free()
)
func _spawn_kick_stones(origin: Vector3, back_dir: Vector3) -> void: func _spawn_kick_stones(origin: Vector3, back_dir: Vector3) -> void:
var mat := StandardMaterial3D.new() var sphere := _particle_sphere(0.055, 3, 1, _unshaded_material())
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.vertex_color_use_as_albedo = true
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
var sphere := SphereMesh.new()
sphere.radius = 0.055
sphere.height = 0.11
sphere.radial_segments = 3
sphere.rings = 1
sphere.material = mat
var ramp := Gradient.new() var ramp := Gradient.new()
ramp.set_color(0, Color(0.62, 0.58, 0.52, 1.0)) ramp.set_color(0, Color(0.62, 0.58, 0.52, 1.0))
@@ -500,9 +679,101 @@ func _spawn_kick_stones(origin: Vector3, back_dir: Vector3) -> void:
get_parent().add_child(p) get_parent().add_child(p)
p.global_position = origin p.global_position = origin
p.restart() p.restart()
get_tree().create_timer(2.0).timeout.connect(func() -> void: _free_after(p, 2.0)
if is_instance_valid(p): p.queue_free()
)
func _spawn_kick_flash(origin: Vector3) -> void:
var mat := _unshaded_material()
mat.albedo_color = Color(1.0, 0.97, 0.40, 0.0)
var sphere := _particle_sphere(1.0, 8, 4, mat)
var mi := MeshInstance3D.new()
mi.mesh = sphere
mi.material_override = mat
mi.scale = Vector3(0.05, 0.05, 0.05)
get_parent().add_child(mi)
mi.global_position = origin
var tween := create_tween()
tween.set_parallel(true)
tween.tween_property(mi, "scale", Vector3(0.7, 0.7, 0.7), 0.13)\
.set_trans(Tween.TRANS_EXPO).set_ease(Tween.EASE_OUT)
var fade := func(a: float) -> void: mat.albedo_color.a = a
tween.tween_method(fade, 0.92, 0.0, 0.14)\
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
tween.tween_callback(mi.queue_free).set_delay(0.16)
func _spawn_dash_burst() -> void:
var box := BoxMesh.new()
box.size = Vector3(0.045, 0.045, 0.30)
box.material = _unshaded_material()
var ramp := Gradient.new()
ramp.set_color(0, Color(0.50, 0.82, 1.0, 1.0))
ramp.set_color(1, Color(0.50, 0.82, 1.0, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 1.0))
scale_curve.add_point(Vector2(1.0, 0.0))
var p := CPUParticles3D.new()
p.one_shot = true
p.explosiveness = 0.92
p.amount = 18
p.lifetime = 0.22
p.randomness = 0.15
p.local_coords = false
p.direction = _dash_dir
p.spread = 15.0
p.gravity = Vector3.ZERO
p.initial_velocity_min = 18.0
p.initial_velocity_max = 35.0
p.scale_amount_min = 0.8
p.scale_amount_max = 2.8
p.mesh = box
p.color_ramp = ramp
p.scale_amount_curve = scale_curve
get_parent().add_child(p)
p.global_position = global_position + Vector3(0.0, 0.2, 0.0)
p.restart()
_free_after(p, 0.6)
func _spawn_kick_sparks(origin: Vector3, back_dir: Vector3) -> void:
var sphere := _particle_sphere(0.04, 3, 1, _unshaded_material())
var ramp := Gradient.new()
ramp.set_color(0, Color(1.0, 0.98, 0.60, 1.0))
ramp.set_color(1, Color(1.0, 1.0, 1.0, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 1.0))
scale_curve.add_point(Vector2(0.5, 0.6))
scale_curve.add_point(Vector2(1.0, 0.0))
var p := CPUParticles3D.new()
p.one_shot = true
p.explosiveness = 0.95
p.amount = 22
p.lifetime = 0.28
p.randomness = 0.4
p.local_coords = false
p.direction = (back_dir + Vector3(0.0, 0.5, 0.0)).normalized()
p.spread = 30.0
p.gravity = Vector3(0.0, -14.0, 0.0)
p.initial_velocity_min = 8.0
p.initial_velocity_max = 18.0
p.scale_amount_min = 0.6
p.scale_amount_max = 2.0
p.mesh = sphere
p.color_ramp = ramp
p.scale_amount_curve = scale_curve
get_parent().add_child(p)
p.global_position = origin
p.restart()
_free_after(p, 1.0)
func _make_slam_ring_mesh() -> ArrayMesh: func _make_slam_ring_mesh() -> ArrayMesh:
@@ -533,40 +804,15 @@ func _make_slam_ring_mesh() -> ArrayMesh:
return mesh return mesh
func _apply_thrust(dir: Vector3) -> void:
var impulse := DP.f("thrust_impulse")
velocity.x += dir.x * impulse
velocity.z += dir.z * impulse
if is_on_floor():
velocity.y += DP.f("thrust_vertical")
var flat := Vector2(velocity.x, velocity.z)
var spd := flat.length()
if spd > DP.f("max_speed"):
var s := DP.f("max_speed") / spd
velocity.x *= s
velocity.z *= s
func _apply_kick(direction: Vector3) -> void:
var force := DP.f("kick_impulse") * randf_range(1.0 - DP.f("kick_force_var"), 1.0 + DP.f("kick_force_var"))
var spread := deg_to_rad(randf_range(-DP.f("kick_spread"), DP.f("kick_spread")))
var kick_dir := direction.rotated(Vector3.UP, spread)
velocity.x += kick_dir.x * force
velocity.z += kick_dir.z * force
if is_on_floor():
velocity.y += DP.f("kick_vertical") * randf_range(0.75, 1.25)
var flat := Vector2(velocity.x, velocity.z)
var spd := flat.length()
if spd > DP.f("max_speed"):
var s := DP.f("max_speed") / spd
velocity.x *= s
velocity.z *= s
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
# ── Gravity ─────────────────────────────────────────────────────────────── # ── Gravity ───────────────────────────────────────────────────────────────
if not is_on_floor(): if not is_on_floor():
velocity += get_gravity() * delta var gravity := get_gravity()
# Crash a slam down hard: full leap on the way up, accelerated descent so
# the bull spends less time floating before the impact.
if _slam_pending and velocity.y < 0.0:
gravity *= DP.f("slam_fall_mult")
velocity += gravity * delta
# ── Camera-relative input direction ─────────────────────────────────────── # ── Camera-relative input direction ───────────────────────────────────────
var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back") var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
@@ -578,70 +824,45 @@ func _physics_process(delta: float) -> void:
var on_floor := is_on_floor() var on_floor := is_on_floor()
# ── Ability cooldowns ──────────────────────────────────────────────────────── # ── Ability cooldowns ────────────────────────────────────────────────────────
for i: int in 3: for i: int in 4:
ability_cd[i] = maxf(0.0, ability_cd[i] - delta) ability_cd[i] = maxf(0.0, ability_cd[i] - delta)
if _ability_active: if _ability_active:
_ability_timer -= delta _ability_timer -= delta
if _ability_timer <= 0.0: if _ability_timer <= 0.0:
if _active_ability == 3:
_end_roll() # tears down roll-specific state (sparks, tumble control)
else:
_ability_active = false _ability_active = false
_active_ability = -1 _active_ability = -1
_legs.set(&"charge_pitch_target", 0.0) _legs.set(&"charge_pitch_target", 0.0)
_legs.set(&"tail_ragdoll", false) _legs.set(&"tail_ragdoll", false)
_slam_pending = false _slam_pending = false
if _bull_anim: _play_idle()
_bull_anim.speed_scale = 1.0
# ── Mouse button single-thrust actions ─────────────────────────────────────── # ── Boulder roll ──────────────────────────────────────────────────────────
# Left only → thrust NE (forward-right diagonal) # While rolling, the ability owns movement entirely (its own accel, low friction
# Right only → thrust NW (forward-left diagonal) # and wall ricochet) — bypass the normal input/kick/damping path below.
var left_held := Input.is_action_pressed(&"thrust_left") if _active_ability == 3 and _ability_active:
var right_held := Input.is_action_pressed(&"thrust_right") _tick_roll(delta, direction)
camera_pivot.call(&"set_charge_active", left_held and right_held) return
if left_held and right_held:
_thrust_left_charge = 0.0
_thrust_right_charge = 0.0
else:
if left_held:
_thrust_left_charge = minf(_thrust_left_charge + delta, DP.f("thrust_charge_time"))
elif Input.is_action_just_released(&"thrust_left") and _thrust_left_charge > 0.0:
var t := _thrust_left_charge / DP.f("thrust_charge_time")
var angle := lerpf(deg_to_rad(DP.f("thrust_min_angle")), deg_to_rad(DP.f("thrust_max_angle")), t)
var raw := cube_guy.global_transform.basis.z
_apply_thrust(Vector3(raw.x, 0.0, raw.z).normalized().rotated(Vector3.UP, -angle))
_thrust_left_charge = 0.0
if right_held:
_thrust_right_charge = minf(_thrust_right_charge + delta, DP.f("thrust_charge_time"))
elif Input.is_action_just_released(&"thrust_right") and _thrust_right_charge > 0.0:
var t := _thrust_right_charge / DP.f("thrust_charge_time")
var angle := lerpf(deg_to_rad(DP.f("thrust_min_angle")), deg_to_rad(DP.f("thrust_max_angle")), t)
var raw := cube_guy.global_transform.basis.z
_apply_thrust(Vector3(raw.x, 0.0, raw.z).normalized().rotated(Vector3.UP, angle))
_thrust_right_charge = 0.0
# Fire spark burst the frame either single-button charge first hits max
var max_charge := DP.f("thrust_charge_time")
var either_full := _thrust_left_charge >= max_charge or _thrust_right_charge >= max_charge
if either_full and not _charge_was_full:
_charge_ready_emitter.restart()
_charge_was_full = either_full
# Reset kick timer on landing so the first kick after a bounce is immediate
if on_floor and not _was_on_floor and direction:
_kick_timer = 0.0
_was_on_floor = on_floor _was_on_floor = on_floor
# ── Periodic kick ───────────────────────────────────────────────────────── # ── Ground locomotion — smooth accelerate / friction ──────────────────────
_kick_timer = maxf(0.0, _kick_timer - delta) # WASD/arrows drive the bull directly: hold a direction to ramp up to max_speed
if direction and _kick_timer <= 0.0: # and steer, release to coast to a stop. Ability bursts (dash) and wall bounces
_apply_kick(direction) # can exceed max_speed — while a direction is held we steer that momentum without
_kick_timer = DP.f("kick_interval") # braking it (cap tracks the current speed); friction only bleeds the excess once
# input is released.
# ── Rolling friction (exponential, frame-rate independent) ──────────────── var flat_vel := Vector3(velocity.x, 0.0, velocity.z)
var retain := pow(_roll_damping, delta) var max_speed := DP.f("max_speed")
velocity.x *= retain if direction.length_squared() > 0.01:
velocity.z *= retain var cap := maxf(max_speed, flat_vel.length())
flat_vel = flat_vel.move_toward(direction * cap, DP.f("move_accel") * delta)
else:
flat_vel = flat_vel.move_toward(Vector3.ZERO, DP.f("move_friction") * delta)
velocity.x = flat_vel.x
velocity.z = flat_vel.z
# ── Visual rotation follows velocity direction ───────────────────────────── # ── Visual rotation follows velocity direction ─────────────────────────────
var flat_speed := Vector2(velocity.x, velocity.z).length() var flat_speed := Vector2(velocity.x, velocity.z).length()
@@ -683,10 +904,9 @@ func _physics_process(delta: float) -> void:
_ability_timer = 0.0 _ability_timer = 0.0
_legs.set(&"charge_pitch_target", 0.0) _legs.set(&"charge_pitch_target", 0.0)
_legs.set(&"tail_ragdoll", false) _legs.set(&"tail_ragdoll", false)
if _bull_anim: _play_idle()
_bull_anim.speed_scale = 1.0
_spawn_slam_fx() _spawn_slam_fx()
_hit_matadors_radius(DP.f("slam_range"), DP.f("slam_strength")) _hit_matadors_radius(DP.f("slam_range"), DP.f("slam_strength"), DP.f("slam_launch_up"))
# ── Dust ────────────────────────────────────────────────────────────────── # ── Dust ──────────────────────────────────────────────────────────────────
if flat_speed > DP.f("dust_charge_spd") and on_floor: if flat_speed > DP.f("dust_charge_spd") and on_floor:
@@ -696,9 +916,20 @@ func _physics_process(delta: float) -> void:
else: else:
_set_dust_state(DustState.NONE) _set_dust_state(DustState.NONE)
# ── Charge trail direction ────────────────────────────────────────────────
if _dust_state == DustState.CHARGE and _charge_trail_emitter:
var vel_flat := Vector3(velocity.x, 0.0, velocity.z)
if vel_flat.length_squared() > 0.1:
_charge_trail_emitter.direction = (-vel_flat.normalized() + Vector3(0.0, 0.25, 0.0)).normalized()
# ── Hoof sounds ─────────────────────────────────────────────────────────── # ── Hoof sounds ───────────────────────────────────────────────────────────
func take_sword_hit() -> void: # A single clean hit — thrown or swung blade — ends the run. `cause` ("gored" /
health = maxi(0, health - 1) # "thrown") tags how it happened for the death notice. Latches so the lose screen is
health_changed.emit(health) # raised exactly once even if several blades land on the same frame.
func take_sword_hit(cause: String = "") -> void:
if _dead:
return
_dead = true
died.emit(cause)
+12 -27
View File
@@ -11,15 +11,18 @@ config_version=5
[application] [application]
config/name="Bullosseum" config/name="Bullosseum"
run/main_scene="res://scene.tscn" config/version="0.1.0"
run/main_scene="res://MainMenu.tscn"
config/features=PackedStringArray("4.7", "Forward Plus") config/features=PackedStringArray("4.7", "Forward Plus")
config/icon="res://icon.svg" config/icon="res://icon.svg"
config/run/main_scene="res://scene.tscn" config/run/main_scene="res://MainMenu.tscn"
[autoload] [autoload]
DP="*res://debug_params.gd" DP="*res://debug_params.gd"
DebugMenu="*res://debug_menu.gd" Console="*res://console.gd"
Controls="*res://controls_manager.gd"
DebugDraw="*res://debug_draw.gd"
[editor_plugins] [editor_plugins]
@@ -59,12 +62,6 @@ move_right={
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":14,"pressure":0.0,"pressed":false,"script":null) , Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":14,"pressure":0.0,"pressed":false,"script":null)
] ]
} }
jump={
"deadzone": 0.2,
"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":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
]
}
zoom_in={ zoom_in={
"deadzone": 0.2, "deadzone": 0.2,
"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":8,"position":Vector2(726, 8),"global_position":Vector2(735, 56),"factor":1.0,"button_index":4,"canceled":false,"pressed":true,"double_click":false,"script":null) "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":8,"position":Vector2(726, 8),"global_position":Vector2(735, 56),"factor":1.0,"button_index":4,"canceled":false,"pressed":true,"double_click":false,"script":null)
@@ -80,24 +77,6 @@ toggle_mouse_capture={
"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":4,"position":Vector2(461, 22),"global_position":Vector2(470, 70),"factor":1.0,"button_index":3,"canceled":false,"pressed":true,"double_click":false,"script":null) "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":4,"position":Vector2(461, 22),"global_position":Vector2(470, 70),"factor":1.0,"button_index":3,"canceled":false,"pressed":true,"double_click":false,"script":null)
] ]
} }
charge={
"deadzone": 0.2,
"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":4194325,"key_label":0,"unicode":0,"location":1,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":10,"pressure":0.0,"pressed":false,"script":null)
]
}
thrust_left={
"deadzone": 0.2,
"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":1,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":true,"double_click":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":4,"axis_value":1.0,"script":null)
]
}
thrust_right={
"deadzone": 0.2,
"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":2,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":true,"double_click":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":5,"axis_value":1.0,"script":null)
]
}
ability_kick={ ability_kick={
"deadzone": 0.2, "deadzone": 0.2,
"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":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null) "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":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null)
@@ -116,6 +95,12 @@ ability_dash={
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":true,"script":null) , Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":true,"script":null)
] ]
} }
ability_roll={
"deadzone": 0.2,
"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":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":3,"pressure":0.0,"pressed":true,"script":null)
]
}
[physics] [physics]
+36
View File
@@ -0,0 +1,36 @@
extends ColorRect
## Drives the fullscreen PS1/CRT post-process (ps1_filter.gdshader) from the DP
## registry so every uniform is live-tunable in the console. `show_ps1` toggles the
## whole overlay; the ps1_* floats feed the matching shader uniforms 1:1.
# DP param key → shader uniform name. Keys are registered in debug_params.gd with
# ranges that mirror the shader's hint_range, so nothing here can go out of bounds.
const _UNIFORMS := {
"ps1_pixel_scale": "pixel_scale",
"ps1_color_depth": "color_depth",
"ps1_dither_strength": "dither_strength",
"ps1_scanline_strength": "scanline_strength",
"ps1_roll_speed": "roll_speed",
"ps1_roll_strength": "roll_strength",
}
@onready var _mat: ShaderMaterial = material as ShaderMaterial
func _ready() -> void:
DP.any_changed.connect(_on_param_changed)
_apply_all()
# reset_all() fires with key "__all__"; a single edit fires with its own key. Either
# way just re-push everything — it's a handful of cheap uniform writes.
func _on_param_changed(_key: String, _value: Variant) -> void:
_apply_all()
func _apply_all() -> void:
visible = DP.b("show_ps1")
if _mat == null:
return
for key: String in _UNIFORMS:
_mat.set_shader_parameter(_UNIFORMS[key], DP.f(key))
+1
View File
@@ -0,0 +1 @@
uid://ce5n4qu88mhyx
+3
View File
@@ -2,6 +2,9 @@ extends Node3D
func _ready() -> void: func _ready() -> void:
# Mark the crowd so debug overlays (show_animation_name / show_bones) skip it —
# 72 ambient clapping spectators would otherwise flood the view with tags.
add_to_group(&"public")
for child in get_children(): for child in get_children():
var anim := child.get_node_or_null(^"AnimationPlayer") as AnimationPlayer var anim := child.get_node_or_null(^"AnimationPlayer") as AnimationPlayer
if anim: if anim:
+8
View File
@@ -14,6 +14,14 @@ echo ""
echo "=== Logic tests ===" echo "=== Logic tests ==="
"$GODOT" --headless --script tests/logic_test.gd "$GODOT" --headless --script tests/logic_test.gd
echo ""
echo "=== Console focus tests ==="
"$GODOT" --headless --script tests/console_focus_test.gd
echo ""
echo "=== Performance tests (frame-budget regression guard) ==="
"$GODOT" --headless --script tests/performance_test.gd
if [[ "$SKIP_GAMEPLAY" -eq 0 ]]; then if [[ "$SKIP_GAMEPLAY" -eq 0 ]]; then
echo "" echo ""
echo "=== Gameplay tests (headless — assertions only, screenshots may be blank) ===" echo "=== Gameplay tests (headless — assertions only, screenshots may be blank) ==="
+36 -11
View File
File diff suppressed because one or more lines are too long
+110
View File
@@ -0,0 +1,110 @@
extends SceneTree
## Headless focus tests for the dev console — verifies the command line keeps (or
## reclaims) keyboard focus after the actions that were observed to drop it.
## Run: godot --headless --script tests/console_focus_test.gd
##
## Exit code 0 = all passed, 1 = any failure.
var _passed: int = 0
var _failed: int = 0
func _init() -> void:
_run.call_deferred()
func _run() -> void:
print("=".repeat(60))
print("CONSOLE FOCUS TESTS")
print("=".repeat(60))
await test_focus_after_submit()
await test_focus_after_external_steal()
print("=".repeat(60))
print("Results: %d passed, %d failed" % [_passed, _failed])
print("=".repeat(60))
quit(1 if _failed > 0 else 0)
func _assert_true(condition: bool, desc: String) -> void:
if condition:
_passed += 1
print(" PASS: %s" % desc)
else:
_failed += 1
print(" FAIL: %s" % desc)
func _get_console() -> Node:
var c: Node = root.get_node_or_null("/root/Console")
if c == null:
_assert_true(false, "Console autoload should exist")
return c
# Drive the real path: open via _toggle (pauses the tree, slide tween grabs focus),
# type a command as real key events, submit with a real Enter, and confirm the guard
# keeps the command line focused across the paused submit.
func test_focus_after_submit() -> void:
print("\n-- test_focus_after_submit --")
var menu := _get_console()
if menu == null:
return
menu._toggle()
# Let the open slide tween finish and its grab_focus callback fire.
for _i in 20:
await process_frame
_assert_true(paused, "tree paused while console open")
_assert_true(menu._input_field.has_focus(), "input has focus after open")
menu._input_field.text = "clear"
menu._input_field.caret_column = 5
_press(KEY_ENTER)
# The submit and any focus theft settle over the next few idle frames.
for _i in 5:
await process_frame
_assert_true(menu._input_field.has_focus(), "input still has focus after submit")
menu._toggle()
for _i in 20:
await process_frame
# Push a key down+up through the viewport so LineEdit / _input handle it for real.
func _press(keycode: Key) -> void:
var down := InputEventKey.new()
down.keycode = keycode
down.physical_keycode = keycode
down.pressed = true
root.push_input(down)
var up := InputEventKey.new()
up.keycode = keycode
up.physical_keycode = keycode
up.pressed = false
root.push_input(up)
# Simulate anything yanking focus away mid-session (a stray click, a UI button); the
# guard in _process must pull it straight back while the console is open.
func test_focus_after_external_steal() -> void:
print("\n-- test_focus_after_external_steal --")
var menu := _get_console()
if menu == null:
return
menu._is_open = true
menu._panel.visible = true
menu._input_field.grab_focus()
await process_frame
menu._input_field.release_focus()
_assert_true(not menu._input_field.has_focus(), "focus released (precondition)")
await process_frame
_assert_true(menu._input_field.has_focus(), "guard reclaims focus next frame")
menu._is_open = false
menu._panel.visible = false
+1
View File
@@ -0,0 +1 @@
uid://n6ymmhlqu6wp
+258
View File
@@ -0,0 +1,258 @@
extends SceneTree
## Headless difficulty simulator.
##
## Drives a kinematic "bot bull" against ONE real matador (the actual Matador.tscn
## + matador.gd AI) across a few behaviour profiles, many trials each, and reports
## how often the matador wins — i.e. gores the bull — versus how often the bull
## wins by ramming the matador. It exists so matador difficulty can be tuned from
## measurements instead of guesswork.
##
## godot --headless --script tests/difficulty_sim.gd
##
## Read the CHARGER row: that models a player who just charges in. If the matador
## almost never wins there, blind charging is unpunished and the game is too easy.
const CHARGER_TRIALS := 16
const KITER_TRIALS := 16
const PASSIVE_TRIALS := 8
const TRIAL_TIMEOUT := 5.0
const SPAWN_SETTLE := 0.4
const CHARGE_SPEED := 42.0 # a fast, committed horn-charge
const KITE_DIST := 12.0
const KITE_SPEED := 17.0 # a real bull easily out-paces the matador; only throws threaten
const START_DIST := 12.0
var _scene: Node
var _dp: Node
var _mat_scene: PackedScene
var _player: CharacterBody3D
var _matador: Node3D
var _bull_dead := false
var _mat_dead := false
var _dt := 1.0 / 60.0
# Charger bull state
var _charge_dir: Vector3 = Vector3.ZERO
var _charge_timer: float = 0.0
var _recover_timer: float = 0.0
# Kiter juke state
var _juke_dir: float = 1.0
var _juke_timer: float = 0.0
var _juke_range: float = KITE_DIST
func _init() -> void:
_run.call_deferred()
func _run() -> void:
_dt = 1.0 / float(Engine.physics_ticks_per_second)
_dp = root.get_node_or_null("/root/DP")
_mat_scene = load("res://Matador.tscn") as PackedScene
_scene = (load("res://scene.tscn") as PackedScene).instantiate()
root.add_child(_scene)
current_scene = _scene
await create_timer(0.5).timeout
var players := get_nodes_in_group(&"player")
if players.is_empty():
push_error("no player in scene"); quit(1); return
_player = players[0]
_player.set_physics_process(false) # we drive the bull kinematically
_player.died.connect(func(_cause: String) -> void: _bull_dead = true)
_strip_match_machinery() # no HUD pause / spawner interference
print("=".repeat(64))
print("DIFFICULTY SIMULATION (physics %d Hz)" % Engine.physics_ticks_per_second)
print("=".repeat(64))
var charger := await _run_profile("CHARGER (fast straight charges)", CHARGER_TRIALS, &"charger")
var kiter := await _run_profile("KITER (circles at ~11 m)", KITER_TRIALS, &"kiter")
var passive := await _run_profile("PASSIVE (holds at attack range)", PASSIVE_TRIALS, &"passive")
print("\n" + "=".repeat(64))
print("SUMMARY — matador win %% (bull gored)")
print("=".repeat(64))
_print_row("CHARGER", charger)
_print_row("KITER ", kiter)
_print_row("PASSIVE", passive)
print("=".repeat(64))
quit(0)
var _state_hist: Dictionary = {}
var _throws: int = 0
var _diag_printed: bool = false
func _run_profile(label: String, trials: int, mode: StringName) -> Dictionary:
var wins := 0
var losses := 0
var timeouts := 0
var total_t := 0.0
_state_hist = {}
_throws = 0
for _i in trials:
var res := await _run_trial(mode)
total_t += res[1] as float
match res[0] as StringName:
&"mat": wins += 1
&"bull": losses += 1
_: timeouts += 1
print("\n-- %s --" % label)
print(" matador wins: %d/%d bull wins: %d timeouts: %d avg %.2fs" % [
wins, trials, losses, timeouts, total_t / maxf(float(trials), 1.0)])
print(" swords thrown: %d states: %s" % [_throws, _fmt_hist()])
return {"win": wins, "loss": losses, "timeout": timeouts, "n": trials}
func _fmt_hist() -> String:
var parts: Array[String] = []
var total := 0
for k: String in _state_hist:
total += _state_hist[k] as int
for k: String in _state_hist:
parts.append("%s %.0f%%" % [k, 100.0 * float(_state_hist[k]) / maxf(float(total), 1.0)])
return ", ".join(parts)
func _count_thrown_swords() -> int:
var n := 0
for c: Node in _scene.get_children():
if c is RigidBody3D:
n += 1
return n
func _run_trial(mode: StringName) -> Array:
if is_instance_valid(_matador):
_matador.queue_free()
await physics_frame
_matador = _mat_scene.instantiate()
_scene.add_child(_matador)
_matador.global_position = Vector3(0.0, 1.0, 0.0)
_matador.killed.connect(func() -> void: _mat_dead = true)
var ang := randf() * TAU
_player.global_position = Vector3(cos(ang), 0.0, sin(ang)) * START_DIST + Vector3(0.0, 1.0, 0.0)
_player.velocity = Vector3.ZERO
_player._dead = false
_bull_dead = false
_mat_dead = false
_charge_dir = Vector3.ZERO
_charge_timer = 0.0
_recover_timer = 0.0
_juke_dir = 1.0 if randf() < 0.5 else -1.0
_juke_timer = 0.0
_juke_range = KITE_DIST
# Let the matador's _ready (ragdoll rig, sword) settle before the duel counts.
var settle := SPAWN_SETTLE
while settle > 0.0:
await physics_frame
settle -= _dt
_bull_dead = false
_mat_dead = false
_player._dead = false
if not _diag_printed:
_diag_printed = true
print(" [diag] matador has_sword=%s in_hand=%s throw_range=[%.0f,%.0f]" % [
is_instance_valid(_matador._sword_node), _matador._sword_in_hand,
_dp.f("mat_throw_min_dist"), _dp.f("mat_throw_range")])
var t := 0.0
var max_swords := 0
while t < TRIAL_TIMEOUT:
_drive(mode)
await physics_frame
t += _dt
var s: String = _matador.ai_state_name()
_state_hist[s] = int(_state_hist.get(s, 0)) + 1
max_swords = maxi(max_swords, _count_thrown_swords())
if _bull_dead: # the bull was gored → matador win
_throws += max_swords
return [&"mat", t]
if _mat_dead: # the matador was rammed → bull win
_throws += max_swords
return [&"bull", t]
_throws += max_swords
return [&"timeout", t]
func _drive(mode: StringName) -> void:
var to_mat := _matador.global_position - _player.global_position
to_mat.y = 0.0
var dist := to_mat.length()
var aim := to_mat.normalized() if dist > 0.05 else Vector3.FORWARD
var vel := Vector3.ZERO
match mode:
&"charger":
if _recover_timer > 0.0:
_recover_timer -= _dt
vel = -aim * 14.0
else:
if _charge_timer <= 0.0:
_charge_dir = aim # commit a straight line (dodgeable)
_charge_timer = 0.6
_charge_timer -= _dt
vel = _charge_dir * CHARGE_SPEED
if dist < 0.8 or _charge_timer <= 0.0:
_recover_timer = 0.35
_charge_timer = 0.0
&"kiter":
# Juke: flip strafe direction and shift the hold distance at random so the
# matador can't perfectly lead the throw — models an evasive player.
_juke_timer -= _dt
if _juke_timer <= 0.0:
_juke_timer = randf_range(0.4, 0.9)
_juke_dir = -_juke_dir if randf() < 0.6 else _juke_dir
_juke_range = randf_range(8.0, 13.0)
var inward := aim * clampf(dist - _juke_range, -1.0, 1.0)
var tangent := Vector3(-aim.z, 0.0, aim.x) * _juke_dir
vel = (tangent + inward).normalized() * KITE_SPEED
&"passive":
# Sit just inside the matador's attack range so it commits to a strike.
if dist > 6.5:
vel = aim * 6.0
elif dist < 5.0:
vel = -aim * 4.0
_player.velocity = Vector3(vel.x, 0.0, vel.z)
_player.move_and_slide()
var p := _player.global_position
p.y = 1.0
_player.global_position = p
func _strip_match_machinery() -> void:
for m: Node in get_nodes_in_group(&"matador"):
m.queue_free()
for s: Node in get_nodes_in_group(&"matador_spawn"):
s.queue_free()
var hud := _find_hud(_scene)
if hud != null:
hud.queue_free()
func _find_hud(n: Node) -> Node:
if n is CanvasLayer and n.has_method(&"_show_game_over"):
return n
for c: Node in n.get_children():
var r := _find_hud(c)
if r != null:
return r
return null
func _print_row(label: String, r: Dictionary) -> void:
var n: int = r["n"]
var win: int = r["win"]
var pct := 100.0 * float(win) / maxf(float(n), 1.0)
print(" %s %5.1f%% (%d win / %d loss / %d timeout)" % [
label, pct, win, r["loss"], r["timeout"]])
+1
View File
@@ -0,0 +1 @@
uid://dmrwponlq3eg6
+228 -5
View File
@@ -22,6 +22,15 @@ const TAIL_BONES: Array[StringName] = [
var _passed: int = 0 var _passed: int = 0
var _failed: int = 0 var _failed: int = 0
var _report: PackedStringArray = [] var _report: PackedStringArray = []
var _matador_scene: PackedScene = null
# Loaded lazily (not preloaded): as a --script SceneTree entry, a top-level
# preload of a matador scene compiles matador.gd before the DP autoload binds.
func _mat_scene() -> PackedScene:
if _matador_scene == null:
_matador_scene = load("res://Matador.tscn")
return _matador_scene
func _init() -> void: func _init() -> void:
@@ -39,10 +48,26 @@ func _run() -> void:
var scene: Node = scene_res.instantiate() var scene: Node = scene_res.instantiate()
root.add_child(scene) root.add_child(scene)
current_scene = scene # match runtime: game code (sword throw, overlays) uses current_scene
# Let physics, AI, and IK warm up. # Let physics, AI, and IK warm up.
await create_timer(0.5).timeout await create_timer(0.5).timeout
# The matador is lethal on contact now, so a wandering one could end the match
# during the non-combat checks below. Park the bull on a temp pad far out in the
# void, well out of any matador's reach (the roll tests use this spot too).
_isolate_bull(scene)
_check_bull_animation()
await _check_overlays()
# The overlay check needed live matadors; now remove them. The aggressive matador
# pursues the bull, and the ragdoll check spawns its own fresh one, so none should
# be roaming during the capture / tail / roll phases.
for m: Node in get_nodes_in_group(&"matador"):
m.queue_free()
await physics_frame
# Capture 5 frames ~0.4 s apart (covers roughly one wander step and walk cycle). # Capture 5 frames ~0.4 s apart (covers roughly one wander step and walk cycle).
for i in range(5): for i in range(5):
_capture_frame("motion_%02d.png" % i) _capture_frame("motion_%02d.png" % i)
@@ -51,10 +76,19 @@ func _run() -> void:
# After ~2.5 s the tail Verlet chain and IK should be stable. # After ~2.5 s the tail Verlet chain and IK should be stable.
_check_tail_integrity() _check_tail_integrity()
# Trigger ragdoll on the first matador and check that bones don't explode. # Roll first, in open space, before any matador dies (a kill ends the match).
var matadors := get_nodes_in_group(&"matador") await _check_roll_ability(scene)
if matadors.size() > 0: await create_timer(0.1).timeout # let the roll pop-test's throwaway matador free
var mat: Node3D = matadors[0] as Node3D
# Ragdoll check on a FRESH matador spawned at the origin (the isolated bull is far
# away, so this one can't reach it). Not wired to the spawner, so its death won't
# trip the win screen. Ragdoll it immediately and check the bones don't explode.
var mat_scene := _mat_scene()
if mat_scene != null:
var mat: Node3D = mat_scene.instantiate()
scene.add_child(mat)
mat.global_position = Vector3(0.0, 1.0, 0.0)
await create_timer(0.3).timeout
var start_pos: Vector3 = mat.global_position var start_pos: Vector3 = mat.global_position
mat._enter_ragdoll(Vector3(0.0, 0.0, 1.0), 10.0) mat._enter_ragdoll(Vector3(0.0, 0.0, 1.0), 10.0)
_capture_frame("ragdoll_trigger.png") _capture_frame("ragdoll_trigger.png")
@@ -62,11 +96,200 @@ func _run() -> void:
_capture_frame("ragdoll_result.png") _capture_frame("ragdoll_result.png")
_check_ragdoll_sanity(mat, start_pos) _check_ragdoll_sanity(mat, start_pos)
else: else:
_note("ragdoll check skipped — no matadors found in scene") _note("ragdoll check skipped — Matador.tscn missing")
# Last — a bull hit raises the lose screen and pauses the tree.
await _check_game_over_wiring(scene)
_finish() _finish()
# Drop a small static floor at (-45, -45) and stand the bull on it — 60+ m from the
# arena, so no matador can close the gap within the test window.
func _isolate_bull(scene: Node) -> void:
var pad := StaticBody3D.new()
var cs := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(20, 1, 20)
cs.shape = box
pad.add_child(cs)
scene.add_child(pad)
pad.global_position = Vector3(-45, -0.5, -45) # top surface at y = 0
var players := get_nodes_in_group(&"player")
if not players.is_empty():
(players[0] as Node3D).global_position = Vector3(-45, 1, -45)
# ── Roll ability (Rammus Powerball) ────────────────────────────────────────────
# Two behaviours: the speed ramps up the longer the ball rolls (base → max), capped
# at roll_max_speed with no wall-boost overshoot; and ramming a matador pops the ball
# (the roll ends). Rolled in empty space so it doesn't touch the real match matadors.
func _check_roll_ability(scene: Node) -> void:
print("\n-- check_roll_ability --")
var dp: Node = root.get_node_or_null("/root/DP")
var players := get_nodes_in_group(&"player")
if dp == null or players.is_empty():
_note("roll check: DP / player missing")
return
var player: Node = players[0]
var ap: AnimationPlayer = _find_anim_player(player)
_assert_true(player.ability_cd.size() == 4, "bull has 4 ability slots (roll added)")
var restore_dur: float = dp.f("roll_duration")
var restore_ramp: float = dp.f("roll_rampup_time")
dp.set_value("roll_duration", 1.2)
dp.set_value("roll_rampup_time", 0.7)
player.global_position = Vector3(-45, 1, -45) # empty void, away from the arena
player.velocity = Vector3.ZERO
player.cube_guy.rotation.y = 0.0
player.ability_cd[3] = 0.0
player._activate_roll()
_assert_true(player._active_ability == 3, "roll activates in slot 3")
if ap != null:
_assert_true(ap.current_animation == &"Armature|ROLL", "roll plays the ROLL clip")
var early := -1.0
var peak := 0.0
for n in 30:
await create_timer(0.03).timeout
var spd := Vector2(player.velocity.x, player.velocity.z).length()
if n == 1:
early = spd
peak = maxf(peak, spd)
_assert_true(peak > early + 5.0, "roll speed ramps up over time (Powerball)")
_assert_true(peak <= dp.f("roll_max_speed") + 2.0, "roll speed caps at roll_max_speed (no wall boost)")
dp.set_value("roll_duration", restore_dur)
dp.set_value("roll_rampup_time", restore_ramp)
await _check_roll_pop(scene, player)
# Roll into a throwaway matador (not one of the match spawns, so its death doesn't
# end the game) and confirm the ball pops: the roll ability ends on contact.
func _check_roll_pop(scene: Node, player: Node) -> void:
var mat_scene := _mat_scene()
if mat_scene == null:
_note("roll pop: Matador.tscn missing")
return
var mat: Node3D = mat_scene.instantiate()
scene.add_child(mat)
mat.global_position = Vector3(-45, 1, -38) # ~7 m ahead of the bull along +Z
await create_timer(0.2).timeout
player.global_position = Vector3(-45, 1, -45)
player.velocity = Vector3.ZERO
player.cube_guy.rotation.y = 0.0 # face +Z, toward the matador
player.ability_cd[3] = 0.0
player._activate_roll()
var popped := false
for _n in 45:
await create_timer(0.03).timeout
if player._active_ability != 3:
popped = true
break
_assert_true(popped, "roll pops (ends) on ramming a matador")
if is_instance_valid(mat):
mat.queue_free()
# ── Bull animation ────────────────────────────────────────────────────────────
# The bull sits idle in this test (no input), so it must be playing its looping
# IDLE clip — guards the idle wiring and the KICK clip rename in player.gd.
func _check_bull_animation() -> void:
print("\n-- check_bull_animation --")
var players := get_nodes_in_group(&"player")
if players.is_empty():
_note("bull anim check: no player in scene")
return
var ap: AnimationPlayer = _find_anim_player(players[0])
if ap == null:
_note("bull anim check: no AnimationPlayer under player")
return
_assert_true(ap.has_animation(&"Armature|IDLE"), "bull has IDLE clip")
_assert_true(ap.has_animation(&"Armature|KICK"), "bull has KICK clip (not stale FOOTKICK)")
_assert_true(ap.current_animation == &"Armature|IDLE", "bull plays IDLE at rest")
if ap.has_animation(&"Armature|IDLE"):
_assert_true(ap.get_animation(&"Armature|IDLE").loop_mode == Animation.LOOP_LINEAR,
"bull IDLE clip loops")
func _find_anim_player(node: Node) -> AnimationPlayer:
if node is AnimationPlayer:
return node
for child in node.get_children():
var r := _find_anim_player(child)
if r != null:
return r
return null
# ── Win / lose wiring ─────────────────────────────────────────────────────────
# One clean sword hit must end the run in a loss, and the signal chain must be in
# place for a win (spawner.all_defeated → HUD). Runs last: it pauses the tree.
func _check_game_over_wiring(scene: Node) -> void:
print("\n-- check_game_over_wiring --")
var hud: Node = _find_hud(scene)
var players := get_nodes_in_group(&"player")
if hud == null or players.is_empty():
_note("game over check: HUD / player missing")
return
var player: Node = players[0]
_assert_true(player.has_signal(&"died"), "player exposes a died signal")
var spawners := get_nodes_in_group(&"matador_spawn")
_assert_true(spawners.is_empty() or spawners[0].has_signal(&"all_defeated"),
"spawner exposes an all_defeated signal (win route)")
player.take_sword_hit()
await create_timer(0.05).timeout
_assert_true(hud._game_over, "a single sword hit raises the game-over screen")
_assert_true(not hud._result_win, "a bull hit is a loss, not a win")
func _find_hud(node: Node) -> Node:
if node is CanvasLayer and node.has_method(&"_show_game_over"):
return node
for child in node.get_children():
var r := _find_hud(child)
if r != null:
return r
return null
# ── Debug overlays ────────────────────────────────────────────────────────────
# Turn on every DebugDraw overlay against the live scene, confirm it builds, then
# turn them off and confirm it tears down — guards the reconcile bookkeeping.
func _check_overlays() -> void:
print("\n-- check_overlays --")
var dp: Node = root.get_node_or_null("/root/DP")
var draw: Node = root.get_node_or_null("/root/DebugDraw")
if dp == null or draw == null:
_note("overlay check: DP / DebugDraw autoload missing")
return
var flags := [
"show_states", "show_stats", "show_collisions", "show_hitboxes", "show_bones",
"show_animation_name", "show_wireframe", "show_grid", "show_axes",
]
for flag: String in flags:
dp.set_value(flag, true)
await create_timer(0.3).timeout
_assert_true(draw._state_labels.size() > 0, "overlay builds matador state labels")
_assert_true(draw._overlays.size() > 0, "overlay builds collision meshes")
_assert_true(draw._anim_labels.size() > 0, "overlay builds animation-name labels")
_assert_true(draw._stats_layer != null and draw._stats_layer.visible, "stats overlay visible")
_assert_true(not draw._stats_label.text.is_empty(), "stats overlay composes text")
for flag: String in flags:
dp.set_value(flag, false)
await create_timer(0.3).timeout
_assert_true(draw._state_labels.is_empty(), "overlay clears state labels when off")
_assert_true(draw._overlays.is_empty(), "overlay clears collision meshes when off")
_assert_true(draw._anim_labels.is_empty(), "overlay clears animation-name labels when off")
# ── Tail integrity ──────────────────────────────────────────────────────────── # ── Tail integrity ────────────────────────────────────────────────────────────
func _check_tail_integrity() -> void: func _check_tail_integrity() -> void:
+53
View File
@@ -20,6 +20,8 @@ func _run() -> void:
test_debug_params_defaults() test_debug_params_defaults()
test_debug_params_clamp_values() test_debug_params_clamp_values()
test_debug_params_save_load_cycle() test_debug_params_save_load_cycle()
test_debug_params_flat_cache()
test_debug_console_commands()
test_matador_spawn_positions() test_matador_spawn_positions()
test_matador_wander_target_in_bounds() test_matador_wander_target_in_bounds()
test_matador_state_transitions() test_matador_state_transitions()
@@ -230,6 +232,57 @@ func test_debug_params_save_load_cycle() -> void:
_assert_eq(dp.f("mat_walk_speed"), original, "restore original value") _assert_eq(dp.f("mat_walk_speed"), original, "restore original value")
# The hot-read cache (f/b) and the metadata mirror (get_all()[k].value) must never
# diverge across set / reset / reset_all.
func test_debug_params_flat_cache() -> void:
print("\n-- test_debug_params_flat_cache --")
var dp: Node = root.get_node_or_null("/root/DP")
if dp == null:
_assert_true(false, "DP autoload should exist")
return
dp.set_value("mat_walk_speed", 6.25)
_assert_eq(dp.f("mat_walk_speed"), 6.25, "flat cache reflects set_value")
_assert_eq(dp.get_all()["mat_walk_speed"]["value"], 6.25, "metadata mirror agrees with cache")
_assert_true(dp.is_modified("mat_walk_speed"), "is_modified true after set")
dp.reset("mat_walk_speed")
_assert_eq(dp.f("mat_walk_speed"), dp.get_all()["mat_walk_speed"]["default"],
"reset(key) restores default in cache")
_assert_true(not dp.is_modified("mat_walk_speed"), "is_modified false after reset")
# The debug console verbs (toggle / reset <name> / set / diff / clear / help) must
# mutate DP correctly and never crash on the read-only reporting commands.
func test_debug_console_commands() -> void:
print("\n-- test_debug_console_commands --")
var dp: Node = root.get_node_or_null("/root/DP")
var menu: Node = root.get_node_or_null("/root/Console")
if dp == null or menu == null:
_assert_true(false, "DP + Console autoloads should exist")
return
dp.set_value("show_bones", false)
menu._execute("toggle show_bones")
_assert_true(dp.b("show_bones"), "toggle flips bool on")
menu._execute("toggle show_bones")
_assert_true(not dp.b("show_bones"), "toggle flips bool off")
dp.set_value("mat_walk_speed", 4.0)
menu._execute("toggle mat_walk_speed")
_assert_eq(dp.f("mat_walk_speed"), 4.0, "toggle refuses a float param")
menu._execute("mat_walk_speed 6")
_assert_eq(dp.f("mat_walk_speed"), 6.0, "console 'key value' sets the param")
menu._execute("reset mat_walk_speed")
_assert_eq(dp.f("mat_walk_speed"), dp.get_all()["mat_walk_speed"]["default"],
"console 'reset <name>' restores default")
# Read-only reporting verbs must not raise.
menu._execute("diff")
menu._execute("clear")
menu._execute("help")
menu._execute("nonsense_key_xyz")
_assert_true(true, "diff / clear / help / unknown key survive")
func test_matador_spawn_positions() -> void: func test_matador_spawn_positions() -> void:
print("\n-- test_matador_spawn_positions --") print("\n-- test_matador_spawn_positions --")
var dp: Node = root.get_node_or_null("/root/DP") var dp: Node = root.get_node_or_null("/root/DP")
+138
View File
@@ -0,0 +1,138 @@
extends SceneTree
## Performance regression test — loads the full scene under a heavy matador load,
## samples per-frame times during normal play and during a mass-ragdoll stress
## spike, and asserts the frame budget holds.
##
## Run (headless):
## godot --headless --script tests/performance_test.gd
##
## Exit code 0 = within budget, 1 = a budget was exceeded (or a frame was non-finite).
##
## Budgets are deliberately generous regression guards, not a target frame rate:
## they only trip on a pathological slowdown, so the test stays stable across the
## range of machines it runs on. Every run also prints the measured numbers so a
## gradual creep is visible even when it doesn't fail.
const STRESS_MATADORS := 20
const WARMUP_SEC := 1.0
const SAMPLE_FRAMES := 120
# Generous ceilings — a healthy build sits far below these.
const AVG_BUDGET_MS := 16.0 # sustained cost must leave headroom for 60 fps
const MAX_BUDGET_MS := 100.0 # a single hitch this large is a real regression
var _passed: int = 0
var _failed: int = 0
func _init() -> void:
_run.call_deferred()
func _run() -> void:
print("=".repeat(60))
print("PERFORMANCE TEST")
print("=".repeat(60))
var dp: Node = root.get_node_or_null("/root/DP")
if dp:
dp.set_value("mat_spawn_count", STRESS_MATADORS)
var scene_res := load("res://scene.tscn")
if not scene_res:
push_error("performance_test: failed to load scene.tscn")
quit(1)
return
var scene_inst: Node = scene_res.instantiate()
root.add_child(scene_inst)
current_scene = scene_inst # match runtime: game code (sword throw, overlays) uses current_scene
# The matadors now stab the (idle) player; drop the HUD so a resulting player
# death doesn't raise the game-over screen and pause the tree mid-measurement.
var hud := _find_hud(scene_inst)
if hud != null:
hud.queue_free()
await create_timer(WARMUP_SEC).timeout
var matadors := get_nodes_in_group(&"matador")
print(" matadors alive: %d" % matadors.size())
# The mass-ragdoll below kills every matador; hold the spawner's alive count high
# so that doesn't trip the win screen (which pauses the tree) mid-measurement.
var spawners := get_nodes_in_group(&"matador_spawn")
if not spawners.is_empty():
spawners[0]._alive = 100000
var normal := await _sample_frames(SAMPLE_FRAMES)
_report_phase("normal play", normal)
# Stress spike: ragdoll every matador on the same frame.
for mat: Node in get_nodes_in_group(&"matador"):
if mat.has_method("_enter_ragdoll"):
mat._enter_ragdoll(Vector3(randf() - 0.5, 0.0, randf() - 0.5).normalized(), 12.0)
var stress := await _sample_frames(SAMPLE_FRAMES)
_report_phase("mass ragdoll", stress)
_finish()
# Awaits SAMPLE_FRAMES idle frames, returning [avg_ms, max_ms, all_finite].
func _sample_frames(count: int) -> Array:
var total_us := 0
var max_us := 0
var all_finite := true
var last := Time.get_ticks_usec()
for _i in count:
await process_frame
var now := Time.get_ticks_usec()
var frame_us := now - last
last = now
total_us += frame_us
max_us = maxi(max_us, frame_us)
if not is_finite(float(frame_us)):
all_finite = false
var avg_ms := (total_us / float(count)) / 1000.0
return [avg_ms, max_us / 1000.0, all_finite]
func _report_phase(label: String, sample: Array) -> void:
var avg_ms: float = sample[0]
var max_ms: float = sample[1]
var all_finite: bool = sample[2]
print("\n-- %s --" % label)
print(" avg %.2f ms/frame (~%.0f fps) peak %.2f ms" % [
avg_ms, 1000.0 / maxf(avg_ms, 0.001), max_ms])
_assert_true(all_finite, "%s: all frame times finite" % label)
_assert_true(avg_ms < AVG_BUDGET_MS,
"%s: avg %.2f ms under %.0f ms budget" % [label, avg_ms, AVG_BUDGET_MS])
_assert_true(max_ms < MAX_BUDGET_MS,
"%s: peak %.2f ms under %.0f ms budget" % [label, max_ms, MAX_BUDGET_MS])
func _find_hud(n: Node) -> Node:
if n is CanvasLayer and n.has_method(&"_show_game_over"):
return n
for c: Node in n.get_children():
var r := _find_hud(c)
if r != null:
return r
return null
func _assert_true(condition: bool, desc: String) -> void:
if condition:
_passed += 1
print(" PASS: %s" % desc)
else:
_failed += 1
print(" FAIL: %s" % desc)
func _finish() -> void:
print("")
print("=".repeat(60))
print("PERFORMANCE TEST: %d passed, %d failed" % [_passed, _failed])
print("=".repeat(60))
quit(1 if _failed > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://demi3ow7jemsd
+186
View File
@@ -0,0 +1,186 @@
extends SceneTree
## Bakes an animated-billboard sprite sheet from the existing clapping crowd figure
## (Animations/mees_plaksutab.glb, clip "ArmatureAction") so a whole stand of
## spectators can render as camera-facing quads in one draw call instead of 72 skinned
## skeletons. Renders FRAMES evenly-spaced poses from a front orthographic camera on a
## transparent background and packs them into a single-row atlas.
##
## Needs a display — script mode renders to a real window (headless is blank):
## godot --script tools/bake_crowd_sheet.gd
##
## Output: res://Assets/crowd_clap_sheet.png — FRAMES cells wide, TWO rows: the top row
## is the front view, the bottom row is the back view. The crowd shader picks the row
## with FRONT_FACING so spectators show their backs when the camera is behind them.
const SRC := "res://Animations/mees_plaksutab.glb"
const CLIP := &"ArmatureAction"
const OUT := "res://Assets/crowd_clap_sheet.png"
const FRAMES := 8
const CELL_H := 192 # sprite cell height in px; width derived from the figure aspect
const PAD := 1.12 # framing margin around the figure's bounds
const FACE_Z := 6.0 # camera distance in front (+Z looks toward -Z)
func _init() -> void:
_run.call_deferred()
func _run() -> void:
var ps := load(SRC) as PackedScene
if ps == null:
push_error("bake_crowd_sheet: cannot load %s" % SRC)
quit(1)
return
var fig: Node3D = ps.instantiate()
var vp := SubViewport.new()
vp.own_world_3d = true
vp.transparent_bg = true
vp.msaa_3d = Viewport.MSAA_4X
vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS
root.add_child(vp)
var world := World3D.new()
var env := Environment.new()
env.background_mode = Environment.BG_CLEAR_COLOR
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(1, 1, 1)
env.ambient_light_energy = 0.7
world.environment = env
vp.world_3d = world
var key := DirectionalLight3D.new()
key.rotation_degrees = Vector3(-30, 18, 0)
key.light_energy = 1.1
vp.add_child(key)
vp.add_child(fig)
var ap := _find_anim(fig)
if ap == null:
push_error("bake_crowd_sheet: no AnimationPlayer in %s" % SRC)
quit(1)
return
ap.play(CLIP)
ap.pause()
var length := ap.get_animation(CLIP).length
await process_frame
await process_frame
# Frame the figure generously from its bind bounds (get_aabb() is the rest pose, so
# arms read wide); the tight crop comes from the rendered alpha afterwards.
var bounds := _visual_aabb(fig)
var center := bounds.get_center()
var cap_h := bounds.size.y * 1.25
var cap_w := maxf(bounds.size.x, bounds.size.y) * 1.15
var vw := int(round(CELL_H * (cap_w / cap_h)))
vp.size = Vector2i(vw, CELL_H)
var cam := Camera3D.new()
cam.projection = Camera3D.PROJECTION_ORTHOGONAL
cam.size = cap_h # KEEP_HEIGHT: vertical world extent
cam.current = true
vp.add_child(cam)
# Render the loop from the front (camera at +Z, a Camera3D looks down its own -Z) and
# from the back (camera at -Z, yawed 180° to look back at the figure).
cam.position = center + Vector3(0, 0, FACE_Z)
cam.rotation = Vector3.ZERO
var fronts: Array = await _grab_frames(vp, ap, length)
cam.position = center + Vector3(0, 0, -FACE_Z)
cam.rotation = Vector3(0, PI, 0)
var backs: Array = await _grab_frames(vp, ap, length)
# One shared tight crop across every front and back pose so the cells line up.
var all := fronts.duplicate()
all.append_array(backs)
var box := _alpha_bounds(all, 0.12)
if box.size == Vector2i.ZERO:
push_error("bake_crowd_sheet: rendered frames are fully transparent — check framing/facing")
quit(1)
return
# One pixel of transparent margin so filtering doesn't bleed a hard edge.
box = box.grow(1).intersection(Rect2i(0, 0, vw, CELL_H))
var cw := box.size.x
var ch := box.size.y
var sheet := Image.create(cw * FRAMES, ch * 2, false, Image.FORMAT_RGBA8)
sheet.fill(Color(0, 0, 0, 0))
for i in FRAMES:
sheet.blit_rect(fronts[i], box, Vector2i(i * cw, 0)) # top row = front
sheet.blit_rect(backs[i], box, Vector2i(i * cw, ch)) # bottom row = back
var err := sheet.save_png(OUT)
if err != OK:
push_error("bake_crowd_sheet: save_png failed (%d)" % err)
quit(1)
return
print("baked %s %dx%d (%d frames x 2 rows, cell %dx%d, aspect %.3f, figure %.2fm tall)" % [
OUT, sheet.get_width(), sheet.get_height(), FRAMES, cw, ch,
float(cw) / float(ch), bounds.size.y])
quit(0)
# Render every clap pose from the currently-positioned camera into an RGBA8 image array.
func _grab_frames(vp: SubViewport, ap: AnimationPlayer, length: float) -> Array:
var out: Array[Image] = []
for i in FRAMES:
ap.seek((float(i) / FRAMES) * length, true)
await process_frame
await RenderingServer.frame_post_draw
var img := vp.get_texture().get_image()
img.convert(Image.FORMAT_RGBA8)
out.append(img)
return out
# Union bounding box of pixels with alpha above `thresh` across every frame — the
# tight crop that holds the whole clap loop.
func _alpha_bounds(frames: Array[Image], thresh: float) -> Rect2i:
var w := frames[0].get_width()
var h := frames[0].get_height()
var min_x := w
var min_y := h
var max_x := -1
var max_y := -1
for img in frames:
for y in h:
for x in w:
if img.get_pixel(x, y).a > thresh:
min_x = mini(min_x, x)
min_y = mini(min_y, y)
max_x = maxi(max_x, x)
max_y = maxi(max_y, y)
if max_x < 0:
return Rect2i()
return Rect2i(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1)
func _find_anim(n: Node) -> AnimationPlayer:
if n is AnimationPlayer:
return n as AnimationPlayer
for c in n.get_children():
var r := _find_anim(c)
if r != null:
return r
return null
func _visual_aabb(n: Node) -> AABB:
var acc := AABB()
var have := false
for mi in _all_mesh_instances(n):
var waabb: AABB = (mi as MeshInstance3D).global_transform * (mi as MeshInstance3D).get_aabb()
acc = waabb if not have else acc.merge(waabb)
have = true
return acc
func _all_mesh_instances(n: Node) -> Array:
var out: Array = []
if n is MeshInstance3D:
out.append(n)
for c in n.get_children():
out.append_array(_all_mesh_instances(c))
return out
+1
View File
@@ -0,0 +1 @@
uid://baa6bdq01fq20
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bx5urw8lnyisv"
path="res://.godot/imported/crowd_a.png-03adfaf2f7c0710faf98c43dece7bece.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tools/preview/crowd_a.png"
dest_files=["res://.godot/imported/crowd_a.png-03adfaf2f7c0710faf98c43dece7bece.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://4dauthuuuiyn"
path="res://.godot/imported/crowd_b.png-6a94563a7a2b29b054811408ccbfe711.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tools/preview/crowd_b.png"
dest_files=["res://.godot/imported/crowd_b.png-6a94563a7a2b29b054811408ccbfe711.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d16gmpff1kvwk"
path="res://.godot/imported/scene_crowd.png-79df05acf2fd99f031d42e7312b69019.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tools/preview/scene_crowd.png"
dest_files=["res://.godot/imported/scene_crowd.png-79df05acf2fd99f031d42e7312b69019.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
+54
View File
@@ -0,0 +1,54 @@
extends SceneTree
## Quick visual check for the billboard crowd: builds the generated fallback stand and
## captures two frames a moment apart (so the flipbook visibly advances) from an angle
## (so we can confirm the sprites turn to face the camera).
##
## godot --script tools/preview_crowd.gd
## Output: tools/preview/crowd_a.png, crowd_b.png
const OUT := "res://tools/preview"
func _init() -> void:
_run.call_deferred()
func _run() -> void:
DirAccess.make_dir_recursive_absolute(OUT)
var we := WorldEnvironment.new()
var env := Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.10, 0.12, 0.16)
we.environment = env
root.add_child(we)
var crowd := MultiMeshInstance3D.new()
crowd.set_script(load("res://crowd_billboards.gd"))
crowd.set("source_path", NodePath()) # force the generated fallback stand
crowd.set("fallback_count", 90)
root.add_child(crowd)
var cam := Camera3D.new()
cam.fov = 62.0
cam.current = true
root.add_child(cam)
# Outside the ring edge, elevated, looking across the centre: the NEAR arc has its
# back to us (facing inward), the FAR arc faces us — so this shot proves both the
# per-segment inward facing and the front/back sheet selection.
cam.look_at_from_position(Vector3(0.0, 16.0, 44.0), Vector3(0.0, 3.0, 0.0), Vector3.UP)
await create_timer(0.15).timeout
_shot("crowd_a.png")
await create_timer(0.45).timeout # ~45 clap frames later
_shot("crowd_b.png")
quit(0)
func _shot(name: String) -> void:
var img := root.get_viewport().get_texture().get_image()
if img == null:
print("capture failed: ", name)
return
img.save_png(OUT + "/" + name)
print("captured ", name, " ", img.get_width(), "x", img.get_height())
+1
View File
@@ -0,0 +1 @@
uid://bsekj2pxutdqr
+42
View File
@@ -0,0 +1,42 @@
extends SceneTree
## Renders the real arena (scene.tscn) with the billboard crowd wired in, from a camera
## near the arena centre, to confirm the stands populate and face inward in-game.
## godot --script tools/preview_crowd_scene.gd
## Output: tools/preview/scene_crowd.png
const OUT := "res://tools/preview"
func _init() -> void:
_run.call_deferred()
func _run() -> void:
DirAccess.make_dir_recursive_absolute(OUT)
var ps := load("res://scene.tscn") as PackedScene
var scene := ps.instantiate()
root.add_child(scene)
current_scene = scene
await create_timer(0.4).timeout
var cam := Camera3D.new()
cam.fov = 74.0
scene.add_child(cam)
# Out past one stand looking across the arena: the near crowd has its back to us,
# the far crowd faces us — the case the front/back sheet must get right.
cam.look_at_from_position(Vector3(0.0, 9.0, -22.0), Vector3(0.0, 4.0, 6.0), Vector3.UP)
cam.current = true
await create_timer(0.3).timeout
var crowd := scene.get_node_or_null(^"CrowdBillboards") as MultiMeshInstance3D
if crowd != null and crowd.multimesh != null:
print("crowd instances = ", crowd.multimesh.instance_count)
else:
print("crowd node/multimesh missing")
var img := root.get_viewport().get_texture().get_image()
if img != null:
img.save_png(OUT + "/scene_crowd.png")
print("captured scene_crowd.png ", img.get_width(), "x", img.get_height())
quit(0)
+1
View File
@@ -0,0 +1 @@
uid://bapd1uxqlkrmq

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