46 Commits

Author SHA1 Message Date
richard 1f24a750fb sounds, deploy 2026-05-29 16:41:16 +03:00
richard 33ec51625c scanlines, sounds, bumper walls, score keep, control legend, timer 2026-05-29 16:37:26 +03:00
richard 2e2b70e030 web deployment, fix stuff so works on linux too (cached non commited stuff) 2026-05-28 22:08:17 +03:00
siim 04597cdc01 publik speed offset 2026-05-26 08:42:18 +03:00
siim c407513cbd more publik 2026-05-26 01:23:49 +03:00
siim ea065e4d81 added publikum 2026-05-26 01:20:19 +03:00
siim 0e654ec0b6 publik_anim 2026-05-25 22:32:01 +03:00
siim df27e9d807 controller inputs 2026-05-25 21:49:34 +03:00
richard 866cc12c2c When charging with both buttons, see red 2026-05-25 21:08:43 +03:00
richard d98a5ba0ac Hit effect 2026-05-25 20:56:08 +03:00
richard 6abf5355c2 tests & can charge up thrust 2026-05-25 20:51:48 +03:00
richard bfb026c7ae giitignore 2026-05-25 20:51:02 +03:00
richard 245ce31eab missing ps1 gdshader 2026-05-25 20:42:24 +03:00
richard b30fb6e0a6 hold right+left, it charges ahead 2026-05-25 20:38:38 +03:00
richard 950d93ed0e PS1 filter 2026-05-25 20:36:39 +03:00
richard 85b9c86164 isometric cam 2026-05-25 20:22:43 +03:00
richard 174b14fa61 R to reset matador 2026-05-25 20:10:16 +03:00
richard 30136a9b90 bull legs not clipped, arena not scaled 2026-05-25 19:39:02 +03:00
richard 80a35a4553 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	Blender/matador_v02.blend
2026-05-25 18:37:54 +03:00
richard 98d579f733 tail wag works 2026-05-25 18:37:42 +03:00
siim f19e6980cd Merge branch 'main' of https://git.richard.work/siim/Bullosseum 2026-05-20 21:16:42 +03:00
siim ab270ac04f mat blend 2026-05-20 21:16:35 +03:00
richard 22ed4f7722 missing files 2026-05-20 21:16:17 +03:00
richard af0790a1ce matadors moving, thrusty bull, slop 2026-05-20 21:07:09 +03:00
siim a8b9f166a6 matador_fbx 2026-05-20 20:43:50 +03:00
siim de5b5f3b7c blender matador 2026-05-20 20:33:31 +03:00
siim 13b027d91c matatod_mudeling 2026-05-20 00:17:49 +03:00
siim d6fe527906 + blenderfiles 2026-05-19 23:36:31 +03:00
richard 6549240337 bouncy bull 2026-05-15 22:10:58 +03:00
richard fe5d143047 Debug menu F1 (use ctrl to lock cam). Design.md doc 2026-05-10 18:40:14 +03:00
siim dc40057fe1 ragdoll skeleton 2026-05-10 17:32:56 +03:00
siim cb3a54c1cc Merge branch 'main' of https://git.richard.work/siim/Bullosseum 2026-05-10 17:15:17 +03:00
siim 34b3866a5e nimi ja dash 2026-05-10 17:15:11 +03:00
richard 4f1126563c dust particles for hooves 2026-05-10 17:01:18 +03:00
richard c587b9877f charge shiftiga, suure hooga ei peatu kohe, loomad ei keera snappily 2026-05-09 23:07:02 +03:00
richard a809ed6cc4 pull liigub õiges suunas 2026-05-09 21:52:11 +03:00
siim 9675ae884d lisasin pulli ja areeni 2026-05-09 18:09:23 +03:00
richard a318e65e70 local config xmls 2026-05-09 17:56:43 +03:00
richard 8b3a816203 Restructure 2026-05-09 17:55:47 +03:00
richard 4e7bc29507 vibe code and restructure 2026-05-09 17:55:07 +03:00
siim b7c50eaae2 uuesti 2026-05-09 17:23:14 +03:00
siim 4fa965725d + tutoriali link 2026-05-09 17:19:01 +03:00
siim f4d61b7f0a importisin 3rd per cam projecti 2026-05-09 17:16:49 +03:00
richard adbd3859de clean slate 2026-05-09 17:05:59 +03:00
siim fb6ac05890 lahednsasn merge konflikti 2026-05-09 17:04:14 +03:00
siim 22d8e5df8c woh 2026-05-09 16:57:13 +03:00
116 changed files with 11522 additions and 210 deletions
+4
View File
@@ -0,0 +1,4 @@
disable:
- class-definitions-order
- no-else-return
max-line-length: 120
+18
View File
@@ -0,0 +1,18 @@
# Godot 4+ specific ignores
.godot/
/android/
# Godot UID sidecar files (auto-generated, machine-local)
*.uid
# JetBrains IDE — contains absolute paths (Godot binary, SDK dirs), varies per user
.idea/
# Blender render previews (generated by render_anim_preview.py)
Blender/preview/
# Test output (generated screenshots, frame strips)
tests/output/
# Web export output
build/
+15
View File
@@ -0,0 +1,15 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/.idea.Bullosseum.iml
/projectSettingsUpdater.xml
/contentModel.xml
/modules.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
-3
View File
@@ -1,3 +0,0 @@
# Godot 4+ specific ignores
.godot/
/android/
-66
View File
@@ -1,66 +0,0 @@
[gd_scene format=3 uid="uid://crtm0xtiql3r8"]
[ext_resource type="Script" uid="uid://bn777gguclud5" path="res://player.gd" id="1_xhfnw"]
[ext_resource type="Script" uid="uid://b4td4k8yo5y78" path="res://camera_spring_arm.gd" id="2_kpjcp"]
[ext_resource type="Script" uid="uid://b720l6m0ehrj3" path="res://camera_follow.gd" id="3_kne1u"]
[sub_resource type="BoxShape3D" id="BoxShape3D_xhfnw"]
size = Vector3(1, 1.4072266, 1)
[sub_resource type="BoxMesh" id="BoxMesh_0rwas"]
[sub_resource type="SphereShape3D" id="SphereShape3D_kne1u"]
radius = 0.3
[node name="Player" type="CharacterBody3D" unique_id=165510044]
script = ExtResource("1_xhfnw")
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=1104396769]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.20361328, 0)
shape = SubResource("BoxShape3D_xhfnw")
[node name="CubeGuy" type="Node3D" parent="." unique_id=1651396216]
[node name="MeshInstance3D" type="MeshInstance3D" parent="CubeGuy" unique_id=367855086]
mesh = SubResource("BoxMesh_0rwas")
[node name="MeshInstance3D2" type="MeshInstance3D" parent="CubeGuy" unique_id=1565560527]
transform = Transform3D(0.22778136, 0, 0, 0, 0.4572928, 0, 0, 0, 0.2800231, 0.36544353, -0.65451574, 0)
mesh = SubResource("BoxMesh_0rwas")
[node name="MeshInstance3D3" type="MeshInstance3D" parent="CubeGuy" unique_id=2097296241]
transform = Transform3D(0.22778136, 0, 0, 0, 0.4572928, 0, 0, 0, 0.2800231, -0.38543648, -0.65451574, 0)
mesh = SubResource("BoxMesh_0rwas")
[node name="MeshInstance3D4" type="MeshInstance3D" parent="CubeGuy" unique_id=383816037]
transform = Transform3D(0.14033328, 0.3601992, 0, -0.17941824, 0.28173247, 0, 0, 0, 0.28002307, -0.60614854, -0.07838535, 0)
mesh = SubResource("BoxMesh_0rwas")
[node name="MeshInstance3D5" type="MeshInstance3D" parent="CubeGuy" unique_id=326472138]
transform = Transform3D(-0.14033328, -0.3601992, 2.4480395e-08, -0.17941824, 0.28173247, 0, -1.2268325e-08, -3.1489616e-08, -0.28002307, 0.6122027, -0.07838535, 0)
mesh = SubResource("BoxMesh_0rwas")
[node name="MeshInstance3D6" type="MeshInstance3D" parent="CubeGuy" unique_id=1642768377]
transform = Transform3D(-0.113692805, 0, -2.4480393e-08, 0, 0.11736439, 0, 9.939342e-09, 0, -0.28002304, -0.26236355, 0.3103568, 0.42819703)
mesh = SubResource("BoxMesh_0rwas")
[node name="MeshInstance3D7" type="MeshInstance3D" parent="CubeGuy" unique_id=695468518]
transform = Transform3D(-0.113692805, 0, -2.4480393e-08, 0, 0.11736439, 0, 9.939342e-09, 0, -0.28002304, 0.2908606, 0.28320956, 0.42819703)
mesh = SubResource("BoxMesh_0rwas")
[node name="SpringArmPivot" type="Node3D" parent="." unique_id=1671817680]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.22, 0)
script = ExtResource("2_kpjcp")
[node name="SpringArm3D" type="SpringArm3D" parent="SpringArmPivot" unique_id=141439848]
collision_mask = 2
shape = SubResource("SphereShape3D_kne1u")
spring_length = 7.0
[node name="SpringPosition" type="Node3D" parent="SpringArmPivot/SpringArm3D" unique_id=1307131677]
[node name="Camera3D" type="Camera3D" parent="SpringArmPivot" unique_id=1120161087 node_paths=PackedStringArray("spring_arm")]
keep_aspect = 0
script = ExtResource("3_kne1u")
spring_arm = NodePath("../SpringArm3D/SpringPosition")
lerp_power = 6.0
-9
View File
@@ -1,9 +0,0 @@
extends Camera3D
@export var spring_arm: Node3D
@export var lerp_power: float = 1.0
func _process(delta: float) -> void:
position = lerp(position, spring_arm.position, delta * lerp_power)
-31
View File
@@ -1,31 +0,0 @@
extends Node3D
@export var mouse_sensitivity: float = 0.005
@export_range(-90.0, 0.0, 0.1, "radians_as_degrees") var min_vertical_angle: float = -PI/2
@export_range(0.0, 90.0, 0.1, "radians_as_degrees") var max_vertical_angle: float = PI/4
@onready var spring_arm := $SpringArm3D
func _ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
func _unhandled_input(event : InputEvent) -> void:
if event is InputEventMouseMotion:
rotation.y -= event.relative.x * mouse_sensitivity
rotation.y = wrapf(rotation.y, 0.0, TAU)
rotation.x -= event.relative.y * mouse_sensitivity
rotation.x = clamp(rotation.x, min_vertical_angle, max_vertical_angle)
if event.is_action_pressed("wheel_up"):
spring_arm.spring_length -=1
if event.is_action_pressed("wheel_down"):
spring_arm.spring_length +=1
if event.is_action_pressed("toggle_mouse_capture"):
if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
else:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
-28
View File
@@ -1,28 +0,0 @@
extends CharacterBody3D
const SPEED = 5.0
const JUMP_VELOCITY = 4.5
func _physics_process(delta: float) -> void:
# Add the gravity.
if not is_on_floor():
velocity += get_gravity() * delta
# Handle jump.
if Input.is_action_just_pressed("ui_accept") and is_on_floor():
velocity.y = JUMP_VELOCITY
# Get the input direction and handle the movement/deceleration.
# As good practice, you should replace UI actions with custom gameplay actions.
var input_dir := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
var direction := (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
if direction:
velocity.x = direction.x * SPEED
velocity.z = direction.z * SPEED
else:
velocity.x = move_toward(velocity.x, 0, SPEED)
velocity.z = move_toward(velocity.z, 0, SPEED)
move_and_slide()
-41
View File
@@ -1,41 +0,0 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
[application]
config/name="3rd_person_camera"
config/features=PackedStringArray("4.6", "Forward Plus")
config/icon="res://icon.svg"
[input]
wheel_up={
"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)
]
}
wheel_down={
"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":16,"position":Vector2(400, 21),"global_position":Vector2(409, 69),"factor":1.0,"button_index":5,"canceled":false,"pressed":true,"double_click":false,"script":null)
]
}
toggle_mouse_capture={
"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":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)
]
}
[physics]
3d/physics_engine="Jolt Physics"
[rendering]
rendering_device/driver.windows="d3d12"
-30
View File
@@ -1,30 +0,0 @@
[gd_scene format=3 uid="uid://b7afbx36ltb3f"]
[ext_resource type="PackedScene" uid="uid://crtm0xtiql3r8" path="res://Player.tscn" id="1_ulcgi"]
[sub_resource type="BoxMesh" id="BoxMesh_nxogm"]
[sub_resource type="BoxShape3D" id="BoxShape3D_ulcgi"]
[node name="scene" type="Node3D" unique_id=22320312]
[node name="Player" parent="." unique_id=165510044 instance=ExtResource("1_ulcgi")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.3040223, 0)
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=818712731]
transform = Transform3D(0.030703627, 0.98235905, -0.18446636, -0.1612905, 0.18700416, 0.9690277, 0.9864293, -1.2735805e-08, 0.16418687, -4.08134, 0.3070748, 0.97087634)
shadow_enabled = true
[node name="Node3D" type="Node3D" parent="." unique_id=1081100876]
transform = Transform3D(3.5710833, 0, 0, 0, 1, 0, 0, 0, 2.2964425, 0, 0, 0)
[node name="MeshInstance3D" type="MeshInstance3D" parent="Node3D" unique_id=1193968330]
transform = Transform3D(7.6892195, 0, 0, 0, 0.34217757, 0, 0, 0, 8.638922, 0, 0, 0)
mesh = SubResource("BoxMesh_nxogm")
[node name="StaticBody3D" type="StaticBody3D" parent="Node3D" unique_id=2072095624]
transform = Transform3D(1.0000001, 0, 0, 0, 1.0000001, 0, 0, 0, 1.0000001, 0, 0, 0)
[node name="CollisionShape3D" type="CollisionShape3D" parent="Node3D/StaticBody3D" unique_id=1444198204]
transform = Transform3D(7.6892185, 0, 0, 0, 0.34217754, 0, 0, 0, 8.638921, 0, 0, 0)
shape = SubResource("BoxShape3D_ulcgi")
Binary file not shown.
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
extends Node3D
func _ready() -> void:
var anim := get_node_or_null(^"AnimationPlayer") as AnimationPlayer
if anim:
anim.play(&"Juubeldab")
+5
View File
@@ -0,0 +1,5 @@
extends Node3D
func _ready() -> void:
$AnimationPlayer.play("ArmatureAction")
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dio67r8ambe7h"
path="res://.godot/imported/arena_placeholder_v01.glb-fc11365d8ec7ae6778e90b13ba508b8a.scn"
[deps]
source_file="res://Assets/arena_placeholder_v01.glb"
dest_files=["res://.godot/imported/arena_placeholder_v01.glb-fc11365d8ec7ae6778e90b13ba508b8a.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
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=0
gltf/embedded_image_handling=1
Binary file not shown.
+44
View File
@@ -0,0 +1,44 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://qy6qrxdfgx2g"
path="res://.godot/imported/bull_v10.fbx-0bb89c99686d56acf88cf41a200f2277.scn"
[deps]
source_file="res://Assets/bull_v10.fbx"
dest_files=["res://.godot/imported/bull_v10.fbx-0bb89c99686d56acf88cf41a200f2277.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
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=true
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={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
+60
View File
@@ -0,0 +1,60 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://fv7q2kiamni5"
path="res://.godot/imported/ANIM_publik_mees.blend-d47f9b6736ce083865e1f16c01bc46f4.scn"
[deps]
source_file="res://Blender/ANIM_publik_mees.blend"
dest_files=["res://.godot/imported/ANIM_publik_mees.blend-d47f9b6736ce083865e1f16c01bc46f4.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
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={}
blender/nodes/visible=0
blender/nodes/active_collection_only=false
blender/nodes/punctual_lights=true
blender/nodes/cameras=true
blender/nodes/custom_properties=true
blender/nodes/modifiers=1
blender/meshes/colors=false
blender/meshes/uvs=true
blender/meshes/normals=true
blender/meshes/export_geometry_nodes_instances=false
blender/meshes/gpu_instances=false
blender/meshes/tangents=true
blender/meshes/skins=2
blender/meshes/export_bones_deforming_mesh_only=false
blender/materials/unpack_enabled=true
blender/materials/export_materials=1
blender/animation/limit_playback=true
blender/animation/always_sample=true
blender/animation/group_tracks=true
gltf/naming_version=2
Binary file not shown.
Binary file not shown.
+60
View File
@@ -0,0 +1,60 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://b203q5pgrk5s3"
path="res://.godot/imported/bull_v11.blend-0dd387f5e964c51fff069fa50a99986e.scn"
[deps]
source_file="res://Blender/bull_v11.blend"
dest_files=["res://.godot/imported/bull_v11.blend-0dd387f5e964c51fff069fa50a99986e.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
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={}
blender/nodes/visible=0
blender/nodes/active_collection_only=false
blender/nodes/punctual_lights=true
blender/nodes/cameras=true
blender/nodes/custom_properties=true
blender/nodes/modifiers=1
blender/meshes/colors=false
blender/meshes/uvs=true
blender/meshes/normals=true
blender/meshes/export_geometry_nodes_instances=false
blender/meshes/gpu_instances=false
blender/meshes/tangents=true
blender/meshes/skins=2
blender/meshes/export_bones_deforming_mesh_only=false
blender/materials/unpack_enabled=true
blender/materials/export_materials=1
blender/animation/limit_playback=true
blender/animation/always_sample=true
blender/animation/group_tracks=true
gltf/naming_version=0
Binary file not shown.
+151
View File
@@ -0,0 +1,151 @@
"""
Blender 5.1 script: create walk + idle animations for matador_v02.
Run: blender --background matador_v02.blend --python create_matador_anims.py
"""
import bpy, math, os
# ── setup ──────────────────────────────────────────────────────────────────────
arm_obj = next(o for o in bpy.data.objects if o.type == 'ARMATURE')
bpy.context.view_layer.objects.active = arm_obj
bpy.ops.object.mode_set(mode='POSE')
if arm_obj.animation_data is None:
arm_obj.animation_data_create()
pb = arm_obj.pose.bones
# Ensure all animated bones use XYZ Euler
for bname in ['leg_L', 'leg_R', 'shin_L', 'shin_R',
'arm_L', 'arm_R', 'forearm_L', 'forearm_R',
'collarbone_L', 'collarbone_R']:
pb[bname].rotation_mode = 'XYZ'
CYCLE = 30 # frames (1 s at 30 fps)
LEG_SWING = 25 # degrees
KNEE_BEND = 30
ARM_SWING = 15
ARM_DOWN = 55 # lower arms from T-pose around bone-local Y
ELBOW_BEND = 25
# ── WALK action ────────────────────────────────────────────────────────────────
# Remove old actions if they exist
for old_name in ['walk', 'idle']:
if old_name in bpy.data.actions:
bpy.data.actions.remove(bpy.data.actions[old_name])
walk = bpy.data.actions.new(name='walk')
walk.use_fake_user = True
walk.use_cyclic = True
arm_obj.animation_data.action = walk
# Clear all pose bone transforms first
bpy.ops.pose.select_all(action='SELECT')
bpy.ops.pose.rot_clear()
arm_down_l = math.radians(ARM_DOWN)
arm_down_r = math.radians(-ARM_DOWN)
for f in range(0, CYCLE + 1, 2):
t = (f / CYCLE) * math.tau
# Legs swing forward/back on bone-local X
pb['leg_L'].rotation_euler = (math.sin(t) * math.radians(LEG_SWING), 0, 0)
pb['leg_L'].keyframe_insert(data_path='rotation_euler', frame=f)
pb['leg_R'].rotation_euler = (math.sin(t + math.pi) * math.radians(LEG_SWING), 0, 0)
pb['leg_R'].keyframe_insert(data_path='rotation_euler', frame=f)
# Shins only bend backward when leg is back
val_l = max(0.0, -math.sin(t)) * math.radians(KNEE_BEND)
val_r = max(0.0, -math.sin(t + math.pi)) * math.radians(KNEE_BEND)
pb['shin_L'].rotation_euler = (val_l, 0, 0)
pb['shin_L'].keyframe_insert(data_path='rotation_euler', frame=f)
pb['shin_R'].rotation_euler = (val_r, 0, 0)
pb['shin_R'].keyframe_insert(data_path='rotation_euler', frame=f)
# Arms lower from T-pose (Y) + swing (X, opposite phase to legs)
swing_l = math.sin(t + math.pi) * math.radians(ARM_SWING)
swing_r = math.sin(t) * math.radians(ARM_SWING)
pb['arm_L'].rotation_euler = (swing_l, arm_down_l, 0)
pb['arm_L'].keyframe_insert(data_path='rotation_euler', frame=f)
pb['arm_R'].rotation_euler = (swing_r, arm_down_r, 0)
pb['arm_R'].keyframe_insert(data_path='rotation_euler', frame=f)
# Forearms constant slight bend
pb['forearm_L'].rotation_euler = (math.radians(ELBOW_BEND), 0, 0)
pb['forearm_L'].keyframe_insert(data_path='rotation_euler', frame=f)
pb['forearm_R'].rotation_euler = (math.radians(ELBOW_BEND), 0, 0)
pb['forearm_R'].keyframe_insert(data_path='rotation_euler', frame=f)
# Collarbones identity
pb['collarbone_L'].rotation_euler = (0, 0, 0)
pb['collarbone_L'].keyframe_insert(data_path='rotation_euler', frame=f)
pb['collarbone_R'].rotation_euler = (0, 0, 0)
pb['collarbone_R'].keyframe_insert(data_path='rotation_euler', frame=f)
print("Created 'walk' action")
# ── IDLE action ────────────────────────────────────────────────────────────────
idle = bpy.data.actions.new(name='idle')
idle.use_fake_user = True
arm_obj.animation_data.action = idle
bpy.ops.pose.select_all(action='SELECT')
bpy.ops.pose.rot_clear()
# Arms lowered, everything else at rest 2 frames for a valid action
for f in [0, 1]:
pb['arm_L'].rotation_euler = (0, arm_down_l, 0)
pb['arm_L'].keyframe_insert(data_path='rotation_euler', frame=f)
pb['arm_R'].rotation_euler = (0, arm_down_r, 0)
pb['arm_R'].keyframe_insert(data_path='rotation_euler', frame=f)
pb['forearm_L'].rotation_euler = (math.radians(ELBOW_BEND), 0, 0)
pb['forearm_L'].keyframe_insert(data_path='rotation_euler', frame=f)
pb['forearm_R'].rotation_euler = (math.radians(ELBOW_BEND), 0, 0)
pb['forearm_R'].keyframe_insert(data_path='rotation_euler', frame=f)
for bname in ['collarbone_L', 'collarbone_R', 'leg_L', 'leg_R', 'shin_L', 'shin_R']:
pb[bname].rotation_euler = (0, 0, 0)
pb[bname].keyframe_insert(data_path='rotation_euler', frame=f)
print("Created 'idle' action")
# ── NLA tracks for export ─────────────────────────────────────────────────────
bpy.ops.object.mode_set(mode='OBJECT')
# Clear existing NLA tracks
if arm_obj.animation_data.nla_tracks:
for t in list(arm_obj.animation_data.nla_tracks):
arm_obj.animation_data.nla_tracks.remove(t)
tw = arm_obj.animation_data.nla_tracks.new()
tw.name = 'walk'
sw = tw.strips.new('walk', 0, walk)
ti = arm_obj.animation_data.nla_tracks.new()
ti.name = 'idle'
si = ti.strips.new('idle', 0, idle)
# ── export FBX ─────────────────────────────────────────────────────────────────
fbx_path = os.path.join(os.path.dirname(bpy.data.filepath), 'matador_v02.fbx')
bpy.ops.export_scene.fbx(
filepath=fbx_path,
use_selection=False,
bake_anim=True,
bake_anim_use_all_actions=True,
bake_anim_use_nla_strips=False,
bake_anim_force_startend_keying=True,
bake_anim_step=1.0,
bake_anim_simplify_factor=0.0,
add_leaf_bones=False,
primary_bone_axis='Y',
secondary_bone_axis='X',
apply_scale_options='FBX_SCALE_ALL',
)
print('Exported FBX to:', fbx_path)
bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)
print('Saved .blend')
Binary file not shown.
+60
View File
@@ -0,0 +1,60 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://c6yhxdaba1nho"
path="res://.godot/imported/matador_v01.blend-ca770df72393bcfc88aeadbf3f984fc9.scn"
[deps]
source_file="res://Blender/matador_v01.blend"
dest_files=["res://.godot/imported/matador_v01.blend-ca770df72393bcfc88aeadbf3f984fc9.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
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={}
blender/nodes/visible=0
blender/nodes/active_collection_only=false
blender/nodes/punctual_lights=true
blender/nodes/cameras=true
blender/nodes/custom_properties=true
blender/nodes/modifiers=1
blender/meshes/colors=false
blender/meshes/uvs=true
blender/meshes/normals=true
blender/meshes/export_geometry_nodes_instances=false
blender/meshes/gpu_instances=false
blender/meshes/tangents=true
blender/meshes/skins=2
blender/meshes/export_bones_deforming_mesh_only=false
blender/materials/unpack_enabled=true
blender/materials/export_materials=1
blender/animation/limit_playback=true
blender/animation/always_sample=true
blender/animation/group_tracks=true
gltf/naming_version=0
Binary file not shown.
Binary file not shown.
+60
View File
@@ -0,0 +1,60 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://cbkylf6ba2qmw"
path="res://.godot/imported/matador_v02.blend-78c935c19487a89cf2e8e43e696513b9.scn"
[deps]
source_file="res://Blender/matador_v02.blend"
dest_files=["res://.godot/imported/matador_v02.blend-78c935c19487a89cf2e8e43e696513b9.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
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={}
blender/nodes/visible=0
blender/nodes/active_collection_only=false
blender/nodes/punctual_lights=true
blender/nodes/cameras=true
blender/nodes/custom_properties=true
blender/nodes/modifiers=1
blender/meshes/colors=false
blender/meshes/uvs=true
blender/meshes/normals=true
blender/meshes/export_geometry_nodes_instances=false
blender/meshes/gpu_instances=false
blender/meshes/tangents=true
blender/meshes/skins=2
blender/meshes/export_bones_deforming_mesh_only=false
blender/materials/unpack_enabled=true
blender/materials/export_materials=1
blender/animation/limit_playback=true
blender/animation/always_sample=true
blender/animation/group_tracks=true
gltf/naming_version=0
Binary file not shown.
Binary file not shown.
+44
View File
@@ -0,0 +1,44 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://qix7axtc0lf3"
path="res://.godot/imported/matador_v02.fbx-220033ca52e262349a727eddc79dcf22.scn"
[deps]
source_file="res://Blender/matador_v02.fbx"
dest_files=["res://.godot/imported/matador_v02.fbx-220033ca52e262349a727eddc79dcf22.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
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=true
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={}
fbx/importer=0
fbx/allow_geometry_helper_nodes=false
fbx/embedded_image_handling=1
fbx/naming_version=2
Binary file not shown.
+60
View File
@@ -0,0 +1,60 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://ce8p5ywum87oj"
path="res://.godot/imported/publik_mees_v01.blend-bd82e3b38f4d34ceacdfaddc95016c7b.scn"
[deps]
source_file="res://Blender/publik_mees_v01.blend"
dest_files=["res://.godot/imported/publik_mees_v01.blend-bd82e3b38f4d34ceacdfaddc95016c7b.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
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={}
blender/nodes/visible=0
blender/nodes/active_collection_only=false
blender/nodes/punctual_lights=true
blender/nodes/cameras=true
blender/nodes/custom_properties=true
blender/nodes/modifiers=1
blender/meshes/colors=false
blender/meshes/uvs=true
blender/meshes/normals=true
blender/meshes/export_geometry_nodes_instances=false
blender/meshes/gpu_instances=false
blender/meshes/tangents=true
blender/meshes/skins=2
blender/meshes/export_bones_deforming_mesh_only=false
blender/materials/unpack_enabled=true
blender/materials/export_materials=1
blender/animation/limit_playback=true
blender/animation/always_sample=true
blender/animation/group_tracks=true
gltf/naming_version=2
Binary file not shown.
+136
View File
@@ -0,0 +1,136 @@
"""
Blender 5.1 script: render preview frames of matador animations to PNG.
Run: blender --background matador_v02.blend --python render_anim_preview.py
Outputs to Blender/preview/ directory:
walk_frame_00.png .. walk_frame_09.png
idle_frame_00.png
Use this to visually verify bone orientations BEFORE exporting to Godot.
"""
import bpy
import os
import math
# ── Configuration ─────────────────────────────────────────────────────────────
PREVIEW_DIR = os.path.join(os.path.dirname(bpy.data.filepath), "preview")
RENDER_RES_X = 512
RENDER_RES_Y = 512
WALK_FRAMES = 10 # number of evenly-spaced frames to render from walk cycle
CAMERA_DISTANCE = 5.0
CAMERA_HEIGHT = 1.0
CAMERA_ANGLES = [0, 90] # degrees — front and side views
os.makedirs(PREVIEW_DIR, exist_ok=True)
# ── Find armature ─────────────────────────────────────────────────────────────
arm_obj = next((o for o in bpy.data.objects if o.type == 'ARMATURE'), None)
if arm_obj is None:
raise RuntimeError("No armature found in scene")
# ── Setup render settings ─────────────────────────────────────────────────────
scene = bpy.context.scene
scene.render.resolution_x = RENDER_RES_X
scene.render.resolution_y = RENDER_RES_Y
scene.render.resolution_percentage = 100
scene.render.film_transparent = True
scene.render.image_settings.file_format = 'PNG'
scene.render.image_settings.color_mode = 'RGBA'
scene.render.engine = 'BLENDER_EEVEE'
# ── Create or reuse camera ────────────────────────────────────────────────────
cam_data = bpy.data.cameras.get("PreviewCam") or bpy.data.cameras.new("PreviewCam")
cam_data.lens = 50
cam_obj = bpy.data.objects.get("PreviewCamObj")
if cam_obj is None:
cam_obj = bpy.data.objects.new("PreviewCamObj", cam_data)
scene.collection.objects.link(cam_obj)
scene.camera = cam_obj
# ── Create or reuse key light ─────────────────────────────────────────────────
light_data = bpy.data.lights.get("PreviewLight") or bpy.data.lights.new("PreviewLight", type='SUN')
light_data.energy = 3.0
light_obj = bpy.data.objects.get("PreviewLightObj")
if light_obj is None:
light_obj = bpy.data.objects.new("PreviewLightObj", light_data)
scene.collection.objects.link(light_obj)
light_obj.rotation_euler = (math.radians(45), math.radians(30), 0)
# ── Create or reuse fill light ────────────────────────────────────────────────
fill_data = bpy.data.lights.get("PreviewFill") or bpy.data.lights.new("PreviewFill", type='SUN')
fill_data.energy = 1.0
fill_obj = bpy.data.objects.get("PreviewFillObj")
if fill_obj is None:
fill_obj = bpy.data.objects.new("PreviewFillObj", fill_data)
scene.collection.objects.link(fill_obj)
fill_obj.rotation_euler = (math.radians(60), math.radians(-45), 0)
# ── Ensure armature is visible ────────────────────────────────────────────────
arm_obj.hide_render = False
arm_obj.hide_viewport = False
for child in arm_obj.children:
child.hide_render = False
child.hide_viewport = False
def position_camera(angle_deg: float) -> None:
"""Place camera at given horizontal angle around the armature center."""
rad = math.radians(angle_deg)
cam_obj.location = (
math.sin(rad) * CAMERA_DISTANCE,
CAMERA_HEIGHT,
math.cos(rad) * CAMERA_DISTANCE,
)
# Point camera at armature center (roughly hip height)
target = arm_obj.location.copy()
target.y = 0.9
direction = target - cam_obj.location
rot_quat = direction.to_track_quat('-Z', 'Y')
cam_obj.rotation_euler = rot_quat.to_euler()
def render_action(action_name: str, frame_count: int) -> None:
"""Render frame_count evenly-spaced frames of the named action."""
action = bpy.data.actions.get(action_name)
if action is None:
print(f"WARNING: Action '{action_name}' not found, skipping")
return
# Assign action
if arm_obj.animation_data is None:
arm_obj.animation_data_create()
arm_obj.animation_data.action = action
frame_start = int(action.frame_range[0])
frame_end = int(action.frame_range[1])
total_frames = max(frame_end - frame_start, 1)
for angle_deg in CAMERA_ANGLES:
position_camera(angle_deg)
angle_label = f"{angle_deg:03d}deg"
for i in range(frame_count):
t = i / max(frame_count - 1, 1) if frame_count > 1 else 0
frame = frame_start + int(t * total_frames)
scene.frame_set(frame)
filename = f"{action_name}_{angle_label}_frame_{i:02d}.png"
filepath = os.path.join(PREVIEW_DIR, filename)
scene.render.filepath = filepath
bpy.ops.render.render(write_still=True)
print(f" Rendered: {filename}")
# ── Render all animations ─────────────────────────────────────────────────────
print("=" * 60)
print("RENDERING ANIMATION PREVIEWS")
print("=" * 60)
render_action("walk", WALK_FRAMES)
render_action("idle", 1)
# ── Cleanup preview objects (optional — keep them for re-runs) ────────────────
print("=" * 60)
print(f"Preview images saved to: {PREVIEW_DIR}")
print(f"Total actions rendered: {len([a for a in ['walk', 'idle'] if bpy.data.actions.get(a)])}")
print("=" * 60)
+118
View File
@@ -0,0 +1,118 @@
# Bullosseum — CLAUDE.md
## About this project
A 3D arena game built in Godot 4.7 (Forward Plus renderer, Jolt Physics) where the player controls a bull. Currently features third-person camera, bull movement with charge mechanics, and a placeholder arena.
## AI role
I am a **Godot 4.6+ expert**. I follow current best practices for GDScript, scene architecture, physics, and performance. If a question suggests a suboptimal approach — wrong node type, unnecessary complexity, a pattern that fights the engine — I will say so and explain the better alternative before implementing anything.
## Project structure
| Path | Purpose |
|---|---|
| `scene.tscn` | Main scene (arena + player) |
| `Player.tscn` | Player scene root (`CharacterBody3D`) |
| `player.gd` | Movement, charge, turning logic |
| `camera_spring_arm.gd` | Mouse-look pivot (`Node3D` + `SpringArm3D`) |
| `camera_follow.gd` | Smooth camera follow (`Camera3D`) |
| `matador.gd` | Matador AI (wander / ragdoll state machine) |
| `matador_spawn.gd` | Spawns N matadors at random arena positions |
| `debug_params.gd` | Runtime-tunable parameter registry (autoload `DP`) |
| `Assets/` | Raw 3D assets (`.glb`, `.fbx`) |
| `Blender/` | Blender source files, animation scripts, FBX exports |
| `Blender/create_matador_anims.py` | Creates walk + idle animations and exports FBX |
| `Blender/render_anim_preview.py` | Renders animation preview PNGs for visual verification |
| `Blender/preview/` | Output directory for animation preview images |
| `tests/` | Headless test scripts (screenshot capture, logic validation) |
| `addons/rider-plugin/` | JetBrains Rider IDE integration |
## Tech choices
- **Physics:** Jolt Physics (not the default Godot Physics)
- **Renderer:** Forward Plus
- **Input map:** WASD + arrow keys, Shift = charge, Space = jump, scroll = zoom, middle-click = toggle mouse capture
- **IDE:** JetBrains Rider via the rider-plugin addon
## GDScript conventions
- Typed GDScript everywhere (`var x: float`, return types on functions)
- `@onready` for node references; never `get_node()` strings when avoidable
- Constants in `SCREAMING_SNAKE_CASE`, variables in `snake_case`
- One script per scene root — keep scripts focused
- Signal names in `snake_case`; connect via `signal.connect()` not the legacy string form
- Prefer `_physics_process` for physics/movement, `_process` for visuals/camera, `_unhandled_input` for input that shouldn't bubble
- No comments that restate what the code already says; only comment non-obvious constraints or workarounds
## Best practices to enforce
- Use `CharacterBody3D` for player-controlled characters, not `RigidBody3D` (unless the design specifically needs physics simulation)
- Use `SpringArm3D` for third-person cameras to get free collision avoidance
- Prefer `move_and_slide()` with `velocity` over manual collision queries
- Export variables (`@export`) for any value a designer might tune; keep magic numbers out of logic
- Scene composition over inheritance — build behaviour from small focused scenes
- Use `autoload` (singletons) sparingly: only for truly global state (e.g. GameManager, AudioBus); not as a shortcut for passing data
- Keep `_physics_process` deterministic and frame-rate independent (always multiply by `delta`)
- Prefer signals over direct node references for decoupling
## Animation & bone conventions
### Blender rig (matador_v02)
- **Armature bones:** `matador` (root) → `COG``chest``head`, `collarbone_L/R``arm_L/R``forearm_L/R``hand_L/R`, `leg_L/R``shin_L/R``foot_L/R`
- **Rotation mode:** All animated bones use **XYZ Euler** (set in `create_matador_anims.py`)
- **Bone-local axes:** Swing (forward/back) is on **local X**. Arm lowering from T-pose is on **local Y** (positive = left arm down, negative = right arm down). Knee bend is **positive X** (shins only bend backward).
- **Walk cycle:** 30 frames at 30 fps = 1 second loop. Legs swing ±25° X, knees bend 030° X (only when leg is back), arms swing ±15° X opposite phase to legs, arms lowered 55° Y from T-pose, elbows constant 25° X bend.
- **Idle pose:** Arms lowered 55° Y, elbows 25° X, everything else at rest.
### FBX export settings
- `primary_bone_axis = 'Y'`, `secondary_bone_axis = 'X'`
- `apply_scale_options = 'FBX_SCALE_ALL'`
- `add_leaf_bones = False`
- `bake_anim_use_all_actions = True`, `bake_anim_use_nla_strips = False`
### Godot animation names
After FBX import, animations appear as `Armature|walk` and `Armature|idle` in AnimationPlayer.
### Verification workflow
1. Edit bones/animations in Blender (`create_matador_anims.py`)
2. Run render preview: `blender --background Blender/matador_v02.blend --python Blender/render_anim_preview.py`
3. Inspect PNGs in `Blender/preview/` — check bone orientations **before** exporting to Godot
4. Export FBX (done automatically by `create_matador_anims.py`)
5. Re-import in Godot and test in-game
## Running tests
```bash
# All tests (lint + logic + gameplay assertions):
bash run_tests.sh
# Individual tests:
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/gameplay_test.gd # full scene, bone sanity, ~4 s
# Gameplay test with real rendering (inspect frame strip visually):
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
```
### What the gameplay test catches
| Check | What it detects |
|---|---|
| Tail Verlet chain spread | Tail bunching — consecutive nodes collapsed to same position |
| Ragdoll bone positions finite + within 15 m | Matador limbs exploding after ragdoll impulse |
| Ragdoll Y position > 10 | Bones falling through the floor |
| Frame strip (visual, manual) | Leg IK quality, animation glitches, anything that looks wrong in motion |
Headless screenshots will be blank (Forward Plus has no display). Run without `--headless` to get real frame strips.
## Godot 4.x specifics
- `wrapf` / `wrap` instead of manual modulo for angles
- `lerp_angle` for smooth rotation interpolation (handles wrap-around correctly)
- `move_toward` for speed ramps without overshooting
- `PackedStringArray`, `PackedVector3Array`, etc. for performance-sensitive arrays
- `@tool` scripts for editor helpers only — don't use in gameplay scripts
- Resource (`extends Resource`) for shared data/config; no plain `Dictionary` for structured data
- `StringName` (`&"action_name"`) for input action lookups in hot paths
+205
View File
@@ -0,0 +1,205 @@
# Bullosseum — Game Design Document
> Living document. `?` marks open questions.
---
## Elevator Pitch
You are the bull. Waves of cowboys, matadors, and gladiators pour into your arena — each with their own tricks to dodge or counter your charge. Survive, wreck things, pick upgrades. Every run is different. Goofy low-poly mayhem.
---
## Art Direction
- **Style:** Low-poly, chunky geometry, flat or minimal shading
- **Tone:** Goofy and physical — think *Gang Beasts* meets *Vampire Survivors* energy
- **Camera:** Third-person, spring-arm collision avoidance
- **Palette:** Warm sand/terracotta arena, vivid faction colours (red/gold for matadors, brown/blue for cowboys, iron/purple for gladiators)
- **Particles:** Stylised cartoon puffs — dust, impact sparks, ragdoll stars
- **Audio direction:** ?
---
## Confirmed Mechanics
- Third-person bull locomotion with momentum (walk, charge)
- Wide committed turning arc during charge — charges feel like commitments
- Visual turn lag (body leans)
- Spring-arm camera with zoom
- Sand dust particles at hooves (walk = small, charge = big)
- Jolt Physics backend
---
## Run Structure (Vampire Survivors-inspired)
```
Start run → Wave spawns → Kill enemies → Wave cleared → Pick upgrade → Next wave → ... → Die or achieve target
```
- **No hard time limit per wave** — pressure comes from enemy density escalating
- **Upgrade picks between waves** — choose 1 of 3 options (stat, ability, passive synergy)
- **Fail state is soft** — getting overwhelmed is a death spiral, not a timeout. You stop being able to charge effectively and get cornered
- **Run length:** ? (1525 minutes for a full run feels right for web)
- **Meta progression:** ? Unlock new starting abilities or bull cosmetics across runs
### Wave Escalation — Faction Ramp
Factions are introduced one at a time so each mechanic gets space to teach itself before mixing begins.
| Waves | Faction makeup | Boss |
|---|---|---|
| 13 | Matadors only | — |
| 4 | Matadors only | El Presidente |
| 57 | Matadors + Cowboys introduced | — |
| 8 | Mixed (matadors + cowboys) | Sheriff |
| 911 | All three factions introduced | — |
| 12 | Mixed (all three) | Centurion |
| 13+ | Full chaos — all factions, escalating density | Repeat bosses + ? final boss |
- Each new faction's first wave is a "pure" wave so the player can learn the pattern before it mixes
- Bosses appear solo — the arena clears before a boss spawns, making it feel like a duel
- ? After wave 12, does a loop begin (harder repeat) or does a final boss end the run?
---
## Enemy Factions
### Matadors (the classics)
The trickiest faction — they're designed around baiting your charge.
| Enemy | Behaviour |
|---|---|
| Matador | Waves red cape to draw your charge, sidesteps at last moment. Must catch them off-guard or predict the dodge |
| Banderillero | Plants slow-flags in the ground — charges into one stuns the bull briefly |
| Picador | Mounted on horse, tanky, slow. Horse must be hit first before the rider is vulnerable |
| ? El Presidente (boss) | Summons matadors and teleports around the arena in bursts |
### Cowboys
The herd faction — individually easy, dangerous in groups.
| Enemy | Behaviour |
|---|---|
| Cowboy | Throws lasso to root you for 12s, then runs. Easy to hit, just annoying |
| Wrangler | Herds other enemies toward you — a force multiplier |
| Mounted Cowboy | Circles you on horseback, throwing dynamite. Must cut off the riding arc |
| ? Sheriff (boss) | Shotgun blast that staggers the bull; spawns deputies on death |
### Gladiators
The armour faction — charge angles matter.
| Enemy | Behaviour |
|---|---|
| Legionary | Front shield absorbs charges completely. Must hit from the side or behind |
| Net-thrower | Long-range root that holds you in place for 3s |
| Archer | Stationary on elevated platform (arena wall edge), fires slowing arrows |
| ? Centurion (boss) | Buffs nearby gladiators, has a charge-reflect shield that bounces you back |
---
## Ability System
Three slots total — always the same structure, filled differently every run.
```
[Charge ──────────] [Medium □] [Ultimate □]
always on 1 slot 1 slot
```
### Slot 1 — Charge (always on)
| | Description |
|---|---|
| **Charge** | Hold to build, release to sprint. Committed arc, hard to steer. The foundation everything else is built around |
### Slot 2 — Medium Ability
Cooldown ~815s. Tactical, reactive, fills gaps that charge can't. One equipped at a time.
| Ability | Description | Good against |
|---|---|---|
| **Stomp** | Slam front hooves — short-range AoE stun | Matador clusters, anything close |
| **Gore** | Upward headbutt — launches a single target | Shielded gladiators (bypasses front shield) |
| **Tail Whip** | 360° sweep behind and to the sides | Cowboys lassooing from behind |
| **Bull Roar** | Knockback shockwave — no damage, pure space creation | Net-throwers, tight groups |
| **Ground Pound** | From a jump, landing creates a shockwave crater | Mounted enemies (dismounts them) |
| **Counter** | Narrow window — if you charge as an attack lands, explosive ricochet | Picadors, centurion reflect |
### Slot 3 — Ultimate Ability
Cooldown ~3550s, or requires a full charge meter. High impact. One equipped at a time.
| Ability | Description | Good against |
|---|---|---|
| **Stampede** | ~5s of zero deceleration — can't stop or steer well, flattens everything in the path | Boss phases, dense waves |
| **Enrage** | ~8s berserk: charge speed +60%, hitbox wider, ignores all slows and roots | Lasso/net-heavy waves |
| **Red Mist** | Every enemy on screen gets a red target marker and is pulled slightly toward you — brief guaranteed hits | Matador dodge phase |
| **Thunder Horn** | Next charge is electrified — chains damage to up to 4 nearby enemies on contact | Gladiator formations |
| **Call of the Herd** | Summon 3 ghost bulls that charge in parallel alongside you for 6s | Any — pure chaos |
### Card Pick Screen (between every wave)
Game pauses. Three cards shown — pick one.
| Card type | Examples |
|---|---|
| **New medium** | Swap current medium for a different one |
| **New ultimate** | Swap current ultimate for a different one |
| **Stat boost** | +charge speed, +turn rate, medium cooldown, ultimate cooldown |
| **Passive modifier** | Changes how your *current* ability behaves (see below) |
| **Synergy** | Unlocks a cross-ability combo effect |
**Passive modifier examples:**
- Stomp → "Dust Blind" — stun cloud also blocks enemy vision for 2s
- Gore → "Pinball" — launched enemy bounces off walls and damages others on landing
- Stampede → "Aftershock" — leaves a trembling ground trail that slows enemies for 5s
- Enrage → "Bloodlust" — kills during enrage reduce remaining cooldown
**Synergy examples:**
- Charge + Stomp = "Seismic Run" — charging for 2s+ turns your next stomp into a ground wave
- Gore + Thunder Horn = "Lightning Rod" — electrified gore sends a chain to the whole group
- ? Others to discover during design
---
## Arena Design
Each faction maps to an arena environment. Mixed-faction waves could transition the arena or blend themes.
| Faction | Arena | Hazards |
|---|---|---|
| Matadors | Sand bullring, wooden barrier ring | Flags slow movement, open centre is the danger zone |
| Cowboys | Frontier rodeo grounds, hay bales, fences | Lasso posts (rope traps), horse stampede lanes |
| Gladiators | Roman colosseum, stone floor, iron gates | Archer towers on walls, spike traps, wet stone (slide) |
- ? Does the arena change mid-run or is one arena selected at run start?
- ? Destructible fence/barrier sections throughout?
---
## Core Loop Revisited
```
Move → Spot threat pattern → Position approach angle → Charge/Ability → Chain hits → Wave clear
↑ ↓
Dodge/react to debuffs (lasso, net, slow-flag) Pick upgrade (1 of 3)
```
The **positioning** step is where skill expression lives: which enemy to hit first, what angle to approach a shielded gladiator, whether to charge the matador or fake him out.
---
## Open Questions
1. **Meta progression** — cosmetic unlocks only, or do you unlock new starting medium/ultimate options across runs?
2. **Arena change** — single arena per run, or does the arena theme shift when a new faction is introduced mid-run?
3. **After wave 12** — infinite loop with scaling difficulty, or a final boss that ends the run cleanly?
4. **Ability replacement** — when you pick a new medium/ultimate card, does it always replace the slot, or can you upgrade an existing ability through repeated picks (level 1→2→3)?
5. **Web scope** — vertical slice (matadors only, 4 waves, one ability tree) or full 12-wave game?
---
## References
- *Vampire Survivors* — wave escalation, upgrade pick loop, run length
- *Gang Beasts* — ragdoll slapstick commitment
- *Katamari* / *Donut County* — simple core verb scaled through variety
- *Superhot* — charge-as-commitment, every move mattering
- *Cult of the Lamb* — faction variety with distinct visual languages
+29
View File
@@ -0,0 +1,29 @@
[gd_scene format=3 uid="uid://matador_scene_v1"]
[ext_resource type="Script" path="res://matador.gd" id="1_matgd"]
[ext_resource type="PackedScene" uid="uid://qix7axtc0lf3" path="res://Blender/matador_v02.fbx" id="2_matfbx"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"]
radius = 0.25
height = 1.2
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_hit"]
radius = 0.45
height = 1.4
[node name="Matador" type="CharacterBody3D"]
script = ExtResource("1_matgd")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0)
shape = SubResource("CapsuleShape3D_body")
[node name="HitArea" type="Area3D" parent="."]
monitoring = true
monitorable = false
[node name="CollisionShape3D" type="CollisionShape3D" parent="HitArea"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0)
shape = SubResource("CapsuleShape3D_hit")
[node name="matador_v02" parent="." instance=ExtResource("2_matfbx")]
+39
View File
@@ -0,0 +1,39 @@
[gd_scene format=3 uid="uid://crtm0xtiql3r8"]
[ext_resource type="Script" uid="uid://bn777gguclud5" path="res://player.gd" id="1_xhfnw"]
[ext_resource type="Script" path="res://camera_iso.gd" id="2_camera_iso"]
[ext_resource type="PackedScene" uid="uid://qy6qrxdfgx2g" path="res://Assets/bull_v10.fbx" id="4_e80uo"]
[sub_resource type="SphereShape3D" id="SphereShape3D_chest"]
radius = 0.35
[sub_resource type="SphereShape3D" id="SphereShape3D_belly"]
radius = 0.40
[sub_resource type="SphereShape3D" id="SphereShape3D_hips"]
radius = 0.30
[node name="Player" type="CharacterBody3D" unique_id=165510044]
collision_layer = 3
script = ExtResource("1_xhfnw")
[node name="CollisionChest" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.505, -0.2)
shape = SubResource("SphereShape3D_chest")
[node name="CollisionBelly" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.505, 0.1)
shape = SubResource("SphereShape3D_belly")
[node name="CollisionHips" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.505, 0.4)
shape = SubResource("SphereShape3D_hips")
[node name="Camera3D" type="Camera3D" parent="." unique_id=1120161087]
script = ExtResource("2_camera_iso")
[node name="bull_v10" parent="." unique_id=1386495166 instance=ExtResource("4_e80uo")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.655, 0.23)
[editable path="bull_v10"]
+33
View File
@@ -0,0 +1,33 @@
# Contribution
Hello, thanks for taking time and helping out with the addon!
Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. Please sign the CLA before sending the PR: https://www.jetbrains.com/agreements/cla/.
#### Local setup
Open the `godot-editor-plugin/CMakeLists.txt` in Rider 2026.1+, select `rider-gdextension` target in the run configuration selector.
There are some more fixes coming in 2026.2, which will allow working on gdscript and cpp files within the same workspace.
#### Signing the binaries
Plugin binaries need to be signed to comply with modern operating system security requirements. Unsigned dynamic libraries may fail to load or trigger security warnings:
- **macOS**: Requires code signing (and often notarization) for `.dylib` files. Unsigned libraries may simply fail to load.
- **Windows**: SmartScreen may block unsigned binaries, and corporate environments often enforce signed code only.
- **Linux**: Generally allows unsigned `.so` files, but some sandboxed environments (Flatpak, Snap) impose restrictions.
**How signing is implemented:**
1. **TeamCity Configuration**: [ijplatform_master_Net_Deploy_Plugins_Public_Godot](https://buildserver.labs.intellij.net/buildConfiguration/ijplatform_master_Net_Deploy_Plugins_Public_Godot) (internal JetBrains link)
- Note: The automatic trigger on new tags is currently not working, so manual triggering is required.
2. **Release Process**:
- First, use your GitHub Action to prepare a **pre-release** with a tag
- Manually trigger the TeamCity configuration on the necessary branch/tag
- The configuration will download assets, sign them, and upload them back (removing the pre-release flag)
- The pre-release flag is required at the start; otherwise, the signing configuration will skip the release (protection against double signing)
3. **Signing existing releases**:
- You can run signing on already existing old releases/tags
- Mark them as pre-release first, then run the configuration on the necessary tag
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 JetBrains
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+51
View File
@@ -0,0 +1,51 @@
# JetBrains Rider Integration Godot addon
This addon currently provides two features:
1. Finds all Rider installations in the system and provides a selector on the `Text Editor` -> `External` tab in the settings to select one.
2. Provides the "Use Rider" toggle in the Godot toolbar and, when enabled, applies a set of editor settings recommended for working with JetBrains Rider. The goal is to make it trivial to switch between Rideroptimized settings and stock Godot settings with a single click.
## Quick start
Requirements:
- Godot 4.2.2+
Install:
1. Inside the Godot editor, it can be installed from the AssetLib view or [downloaded](https://godotengine.org/asset-library/asset/4576)
2. [Optional] Change the initial value of `active` in the plugin.cfg
3. [Optional] Change the initial values in the presets.json file.
4. Enable "JetBrains Rider External Editor" plugin in the Project → Project Settings… → Plugins tab.
Use:
- A toolbar toggle named "Use Rider" will appear. Click it to turn the preset On/Off.
Screenshot:
![Toolbar toggle](screenshots/Toolbar.png)
## What the toggle changes
The preset values live in `presets.json`.
When ON:
- Write the values from the "on" preset into the Editor Settings.
When OFF:
- Write the values from the "off" preset into the Editor Settings.
Note: The plugin does not currently autoset Riders executable path or flags. See Plans below.
## Setting Rider to be the external editor
The plugin automatically detects installed Rider versions on your system and provides a convenient dropdown menu to
select which installation to use as your external editor.
- The plugin scans common installation locations for Rider on Windows, macOS, and Linux.
- Detected installations appear in the "Select Rider" dropdown in the toolbar.
- When you select a Rider installation, the plugin automatically updates the `dotnet/editor/external_editor_path` editor
setting.
## License
See `addons/rider-plugin/LICENCE`.
## Acknowledgements
Created by JetBrains to streamline using Rider with Godot.
Initial idea https://github.com/sszigeti/toggle_external_editor
@@ -0,0 +1,16 @@
[configuration]
entry_symbol = "rider_library_init"
compatibility_minimum = "4.2.2"
[libraries]
macos.single.debug = "./macos/librider-gdextension.macos.editor.dylib"
windows.arm64.single.debug = "./windows/rider-gdextension.windows.editor.arm64.dll"
windows.x86_64.single.debug = "./windows/rider-gdextension.windows.editor.x86_64.dll"
linux.x86_64.single.debug = "./linux/librider-gdextension.linux.editor.x86_64.so"
linux.arm64.single.debug = "./linux/librider-gdextension.linux.editor.arm64.so"
+144
View File
@@ -0,0 +1,144 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 25.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
version="1.0"
id="katman_1"
x="0px"
y="0px"
viewBox="0 0 512 512"
xml:space="preserve"
sodipodi:docname="JetBrains Rider Icon.svg"
width="512"
height="512"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs43" /><sodipodi:namedview
id="namedview41"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
showgrid="false"
inkscape:zoom="1.0980553"
inkscape:cx="256.36233"
inkscape:cy="212.19333"
inkscape:window-width="1920"
inkscape:window-height="1027"
inkscape:window-x="-8"
inkscape:window-y="22"
inkscape:window-maximized="1"
inkscape:current-layer="katman_1" />
<style
type="text/css"
id="style2">
.st0{fill:url(#SVGID_1_);}
.st1{fill:url(#SVGID_00000130622934180993703410000017420799098261276343_);}
.st2{fill:url(#SVGID_00000060739771362873723200000017991140373209755022_);}
.st3{fill:#FFFFFF;}
</style>
<symbol
id="rider"
viewBox="-35 -35 70 70">
<linearGradient
id="SVGID_1_"
gradientUnits="userSpaceOnUse"
x1="30.4897"
y1="5.1188998"
x2="-23.4683"
y2="-25.8451">
<stop
offset="0"
style="stop-color:#DD1265"
id="stop4" />
<stop
offset="0.483"
style="stop-color:#DD1265"
id="stop6" />
<stop
offset="0.942"
style="stop-color:#FDB60D"
id="stop8" />
</linearGradient>
<path
class="st0"
d="M 35,-7.7 -14.1,-35 18.8,13.9 25.5,9.5 Z"
id="path11" />
<linearGradient
id="SVGID_00000169517474296634577950000016895268102491994795_"
gradientUnits="userSpaceOnUse"
x1="-1.5839"
y1="-28.888"
x2="19.805099"
y2="30.174999">
<stop
offset="0.139"
style="stop-color:#087CFA"
id="stop13" />
<stop
offset="0.476"
style="stop-color:#DD1265"
id="stop15" />
<stop
offset="0.958"
style="stop-color:#087CFA"
id="stop17" />
</linearGradient>
<path
style="fill:url(#SVGID_00000169517474296634577950000016895268102491994795_)"
d="M 15.5,-18.9 9.3,-33.9 -4.3,-20.5 1.2,28.1 14.4,35 35,23 Z"
id="path20" />
<linearGradient
id="SVGID_00000135668350575375336630000014638141720088491653_"
gradientUnits="userSpaceOnUse"
x1="-17.5865"
y1="-27.071199"
x2="-1.7875"
y2="29.073799">
<stop
offset="0.278"
style="stop-color:#DD1265"
id="stop22" />
<stop
offset="0.968"
style="stop-color:#FDB60D"
id="stop24" />
</linearGradient>
<path
style="fill:url(#SVGID_00000135668350575375336630000014638141720088491653_)"
d="m -14.1,-35 -20.9,14.1 7.8,48.1 20.1,7.7 26,-21 z"
id="path27" />
<path
d="M 21,-21 H -21 V 21 H 21 Z"
id="path29" />
<path
class="st3"
d="m -0.6,13.6 h -15.8 v 2.7 h 15.8 z"
id="path31" />
<path
class="st3"
d="m 0.5,-15.8 h 6.2 c 5,0 8.4,3.4 8.4,7.7 0,4.4 -3.4,7.9 -8.4,7.9 L 0.5,0 Z m 3.5,3.2 v 9.5 h 2.7 c 2.8,0 4.8,-1.9 4.8,-4.6 0,-2.8 -1.9,-4.7 -4.8,-4.7 z"
id="path33" />
<path
class="st3"
d="m -15.7,-15.8 h 7.2 c 2,0 3.5,0.6 4.6,1.6 0.9,0.9 1.3,2.1 1.3,3.6 v 0.1 c 0,1.3 -0.3,2.3 -0.9,3.1 -0.6,0.8 -1.4,1.4 -2.4,1.8 L -2,0 h -4.1 l -3.3,-4.8 h -2.9 V 0 h -3.5 z m 7,7.7 c 0.8,0 1.5,-0.2 2,-0.6 0.5,-0.4 0.7,-1 0.7,-1.6 v -0.1 c 0,-0.8 -0.2,-1.3 -0.7,-1.7 -0.5,-0.3 -1.1,-0.6 -2,-0.6 h -3.4 v 4.5 h 3.4 z"
id="path35" />
</symbol>
<use
xlink:href="#rider"
width="70"
height="70"
x="-35"
y="-35"
transform="matrix(7.0682519,0,0,7.2481031,256,256)"
style="overflow:visible"
id="use38" />
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://wrdrj6wednn8"
path="res://.godot/imported/icon.svg-45c914cff7482ba9564963fe65b548e4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/rider-plugin/icons/icon.svg"
dest_files=["res://.godot/imported/icon.svg-45c914cff7482ba9564963fe65b548e4.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+12
View File
@@ -0,0 +1,12 @@
[plugin]
name="JetBrains Rider External Editor"
description="Provides a toolbar toggle, to switch a set of settings on and off. Default set of settings helps to enable/disable the following settings recommended by the Rider documentation https://www.jetbrains.com/help/rider/Godot.html#optimize-godot-editor-for-rider"
author="JetBrains"
version="1.0.0"
script="rider-plugin.gd"
[presets]
active="off"
presets="presets.json"
+15
View File
@@ -0,0 +1,15 @@
{
"on": {
"text_editor/external/use_external_editor": true,
"interface/editor/import_resources_when_unfocused": true,
"interface/editor/save_on_focus_loss": true,
"text_editor/behavior/files/auto_reload_scripts_on_external_change": true,
"run/window_placement/game_embed_mode": -1
},
"off": {
"text_editor/external/use_external_editor": false,
"interface/editor/import_resources_when_unfocused": false,
"interface/editor/save_on_focus_loss": false,
"text_editor/behavior/files/auto_reload_scripts_on_external_change": false
}
}
+66
View File
@@ -0,0 +1,66 @@
@tool
extends EditorPlugin
var editor_settings: EditorSettings
var checkbutton: CheckButton
var _preset_applier: PresetApplier
var _settings_service: EditorSettingsService
var _locator_service: RiderLocatorService
var _plugin_cfg_path: String
var _presets_json_path: String
func _enter_tree() -> void:
editor_settings = EditorInterface.get_editor_settings()
var script_path := (get_script() as Script).resource_path
var plugin_dir := script_path.get_base_dir()
_plugin_cfg_path = plugin_dir + "/plugin.cfg"
var cfg := ConfigFile.new()
var err := cfg.load(_plugin_cfg_path)
if err != OK:
push_warning("Failed to load plugin.cfg: %s" % [err])
return
var active_str := str(cfg.get_value("presets", "active", "on"))
var is_active := active_str == "on"
var presets_rel_path := str(cfg.get_value("presets", "presets", "presets.json"))
_presets_json_path = plugin_dir + "/" + presets_rel_path
# Build UI
checkbutton = CheckButton.new()
checkbutton.text = "Use Rider"
checkbutton.tooltip_text = "Shortcut for setting recommended settings"
checkbutton.button_pressed = is_active
checkbutton.pressed.connect(_on_checkbutton_pressed)
add_control_to_container(EditorPlugin.CONTAINER_TOOLBAR, checkbutton)
# Initialize services and panel
_settings_service = EditorSettingsService.new()
_locator_service = RiderLocatorService.new()
_preset_applier = PresetApplier.new(_presets_json_path)
_locator_service.add_selector_in_editor_interface(_settings_service)
# Ensure settings reflect current state on startup
_preset_applier.apply_preset(editor_settings, is_active)
func _on_checkbutton_pressed() -> void:
var cfg := ConfigFile.new()
if cfg.load(_plugin_cfg_path) != OK:
push_warning("Failed to load plugin.cfg to update state")
return
var is_active := checkbutton.button_pressed
var key := _preset_applier.get_preset_key(is_active)
cfg.set_value("presets", "active", key)
var save_err := cfg.save(_plugin_cfg_path)
if save_err != OK:
push_warning("Failed to save plugin.cfg: %s" % [save_err])
# Apply selected preset to editor settings
_preset_applier.apply_preset(editor_settings, is_active)
func _exit_tree() -> void:
if checkbutton != null:
remove_control_from_container(EditorPlugin.CONTAINER_TOOLBAR, checkbutton)
checkbutton.queue_free()
var args = OS.get_cmdline_args()
if "--rider-addon-tests" in args:
print("==== rider-addon-tests finished ====")
+1
View File
@@ -0,0 +1 @@
uid://c1x58xm2w1n20
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bro2w3ghx4xt7"
path="res://.godot/imported/Toolbar.png-a521e2493bd3c08a829245b3129bb58f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/rider-plugin/screenshots/Toolbar.png"
dest_files=["res://.godot/imported/Toolbar.png-a521e2493bd3c08a829245b3129bb58f.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+23
View File
@@ -0,0 +1,23 @@
@tool
## Simple JSON helpers for editor/runtime use.
## Keeps file and JSON parsing concerns out of feature code.
class_name JsonUtils
static func load_from_file(path: String) -> Variant:
# Returns parsed JSON value (Dictionary/Array/etc.) or null on error.
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("JsonUtils: Failed to open file: %s" % path)
return null
var text := file.get_as_text()
file.close()
var data: Variant = JSON.parse_string(text)
if data == null:
push_warning("JsonUtils: Invalid JSON in file: %s" % path)
return null
return data
static func load_dict_from_file(path: String) -> Dictionary:
# Returns Dictionary or empty {} on error.
var data := load_from_file(path) as Dictionary
return data
@@ -0,0 +1 @@
uid://bci4kmk7h4j6a
@@ -0,0 +1,112 @@
@tool
extends RefCounted
class_name RiderLocatorService
var _installations_found: Array = []
var _thread: Thread = null
func get_installations() -> Array:
var result: Array = RiderLocator.new().get_installations() # from the gdextension
return result
# todo
func fix_external_editor_if_supplied_in_commandline(_settings_service: EditorSettingsService, editor_settings: EditorSettings) -> bool:
# When Godot is started from Rider (or vice versa), we may receive the Rider path
# via command-line so we can keep Godot's external editor setting in sync.
# Supported form (only this one):
# --my_rider_path="/absolute/path/to/rider with possible spaces"
var args : Array = OS.get_cmdline_args()
var provided_rider_path := ""
for a_raw in args:
var a: String = str(a_raw)
if a.begins_with("--my_rider_path="):
provided_rider_path = a.substr("--my_rider_path=".length())
break
if provided_rider_path.is_empty():
return false
provided_rider_path = trim_quotes(provided_rider_path)
# Validate existence (file or dir)
var looks_existing := FileAccess.file_exists(provided_rider_path) || DirAccess.dir_exists_absolute(provided_rider_path)
if looks_existing:
_settings_service.set_external_editor_path(editor_settings, provided_rider_path)
print("Rider path provided via CLI (my_rider_path): ", provided_rider_path)
return true
else:
push_warning("my_rider_path was provided but does not exist: %s" % [provided_rider_path])
return false
func trim_quotes(s: String) -> String:
if s.begins_with('"') and s.ends_with('"') and s.length() >= 2:
return s.substr(1, s.length() - 2)
return s
func add_selector_in_editor_interface(_settings_service: EditorSettingsService):
_update_selector(_installations_found)
if _installations_found.is_empty() and _thread == null:
_thread = Thread.new()
_thread.start(_load_installations)
func _load_installations() -> void:
var array: Array = get_installations()
call_deferred("_on_installations_loaded", array)
func _on_installations_loaded(array: Array):
_installations_found = array
if _thread:
_thread.wait_to_finish()
_thread = null
_update_selector(_installations_found)
func _notification(what: int) -> void:
if what == NOTIFICATION_PREDELETE and _thread != null:
_thread.wait_to_finish()
func _update_selector(array: Array):
var name := "text_editor/external/editor"
var settings := EditorInterface.get_editor_settings()
if !(settings.has_setting(name)):
settings.set(name, 0)
var installations: Array = ["Custom"]
for element in array:
var display_name: String = element.get("display", "")
# Replace special characters that break PROPERTY_HINT_ENUM format
# Comma is the enum delimiter, colon is used for explicit value assignment
display_name = display_name.replace(",", "").replace(":", " -")
installations.append(display_name)
var options :String = ",".join(installations)
settings.add_property_info({
"name": name,
"type":TYPE_INT,
"hint":PROPERTY_HINT_ENUM,
"hint_string": options
})
# Connect to settings changes to update external editor path when selection changes
if not settings.settings_changed.is_connected(_on_selection_changed):
settings.settings_changed.connect(_on_selection_changed.bind())
func _on_selection_changed() -> void:
var name := "text_editor/external/editor"
var settings := EditorInterface.get_editor_settings()
var selected_index: int = settings.get_setting(name)
# Index 0 is "Custom", so user manages the path manually
if selected_index == 0:
return
# Map to actual installation (offset by 1 because of "Custom" at index 0)
var installation_index := selected_index - 1
var installations_array = _installations_found
if installation_index >= 0 and installation_index < installations_array.size():
var installation = installations_array[installation_index]
var new_path: String = installation.get("path", "")
if not new_path.is_empty():
EditorSettingsService.new().set_external_editor_path(settings, new_path)
@@ -0,0 +1 @@
uid://cfsu1rbg0ypem
@@ -0,0 +1,35 @@
@tool
extends RefCounted
class_name PresetApplier
var presets_path: String
func _init(p_path: String) -> void:
presets_path = p_path
func get_preset_key(is_active: bool) -> String:
return "on" if is_active else "off"
func apply_preset(editor_settings: EditorSettings, is_active: bool) -> void:
var data: Dictionary = JsonUtils.load_dict_from_file(presets_path)
if data.is_empty():
push_warning("Failed to load presets: %s" % presets_path)
return
var new_preset_key := get_preset_key(is_active)
var previous_preset_key := get_preset_key(not is_active)
if not data.has(new_preset_key):
push_warning("Preset '%s' not found in presets.json" % new_preset_key)
return
var new_preset := data[new_preset_key] as Dictionary
# Reset keys from previous preset that are missing in the new preset
if data.has(previous_preset_key):
var previous_preset := data[previous_preset_key] as Dictionary
for key in previous_preset:
if not new_preset.has(key):
editor_settings.set_setting(str(key), editor_settings.property_get_revert(str(key)))
# Apply the new preset
for key in new_preset:
editor_settings.set_setting(str(key), new_preset[key])
@@ -0,0 +1 @@
uid://bjiaycxso8h8u
@@ -0,0 +1,17 @@
@tool
extends RefCounted
class_name EditorSettingsService
func set_external_editor_path(editor_settings: EditorSettings, path: String) -> void:
editor_settings.set_setting("text_editor/external/exec_path", path)
func has_valid_external_editor_path(editor_settings: EditorSettings) -> bool:
var has_setting: bool = editor_settings.has_setting("text_editor/external/exec_path")
if not has_setting:
return false
var path : String = editor_settings.get_setting("text_editor/external/exec_path")
var exists := not path.is_empty() && (FileAccess.file_exists(path) || DirAccess.dir_exists_absolute(path))
return exists
func set_use_external_editor(editor_settings: EditorSettings, enabled: bool) -> void:
editor_settings.set_setting("text_editor/external/use_external_editor", enabled)
@@ -0,0 +1 @@
uid://bdiu78ot0rrkc
+6
View File
@@ -0,0 +1,6 @@
extends AnimationPlayer
func _ready() -> void:
speed_scale = randf_range(0.5, 1.5)
play("ArmatureAction")
+214
View File
@@ -0,0 +1,214 @@
extends Node
## Body wobble and tail simulation. Legs stay in rest pose (no IK, no ragdoll).
## Created at runtime by player.gd; call setup() before the first _process tick.
var _player: CharacterBody3D
var _bull: Node3D
var _skeleton: Skeleton3D
# Body wobble
var _prev_vel: Vector3 = Vector3.ZERO
var _tilt_x: float = 0.0
var _tilt_z: float = 0.0
var _aerial_pitch: float = 0.0
var charge_pitch_target: float = 0.0
var _charge_pitch_curr: float = 0.0
# Tail — Verlet chain simulation
const TAIL_BONES: Array = [
&"tail_1", &"tail_2", &"tail_3", &"tail_4",
&"tail_5", &"tail_6",
&"tail_tip_1", &"tail_tip_2", &"tail_tip_2_end",
]
var _tail_idx: Array[int] = []
var _tail_world: Array[Vector3] = []
var _tail_prev: Array[Vector3] = []
var _tail_lengths: Array[float] = []
var _tail_rest_dir: Array[Vector3] = []
func setup(player: CharacterBody3D, bull: Node3D) -> void:
_player = player
_bull = bull
_skeleton = _find_skeleton(bull)
if not _skeleton:
push_error("BullLegs: Skeleton3D not found under bull_v10")
return
# Disable any physical bone simulator
var phys_sim := _find_physical_bones(_skeleton)
if phys_sim:
phys_sim.active = false
_setup_tail()
func _setup_tail() -> void:
for bone_name: StringName in TAIL_BONES:
var idx: int = _skeleton.find_bone(bone_name)
if idx == -1:
push_warning("BullLegs: tail bone '%s' not found" % bone_name)
continue
_tail_idx.append(idx)
if _tail_idx.size() < 2:
push_error("BullLegs: not enough tail bones found")
return
for idx: int in _tail_idx:
var world: Vector3 = _skeleton.to_global(_skeleton.get_bone_global_rest(idx).origin)
_tail_world.append(world)
_tail_prev.append(world)
var max_sep: float = 0.0
for i in range(_tail_world.size() - 1):
max_sep = maxf(max_sep, _tail_world[i].distance_to(_tail_world[i + 1]))
if max_sep < 0.01:
push_warning("BullLegs: tail bones have zero separation in rest pose, distributing along spine")
var root: Vector3 = _tail_world[0]
var back: Vector3 = (_skeleton.global_transform.basis.z + Vector3.DOWN * 0.3).normalized()
for i in range(_tail_world.size()):
_tail_world[i] = root + back * (i * 0.09)
_tail_prev[i] = _tail_world[i]
const MIN_SEG: float = 0.06
for i in range(_tail_world.size() - 1):
var seg_len: float = _tail_world[i].distance_to(_tail_world[i + 1])
_tail_lengths.append(maxf(seg_len, MIN_SEG))
for i in range(_tail_world.size() - 1):
var p1_skel: Vector3 = _skeleton.to_local(_tail_world[i])
var p2_skel: Vector3 = _skeleton.to_local(_tail_world[i + 1])
var seg_skel: Vector3 = p2_skel - p1_skel
_tail_rest_dir.append(seg_skel.normalized() if seg_skel.length() > 0.001 else Vector3(0, 0, 1))
_tail_rest_dir.append(_tail_rest_dir[-1])
func _process(delta: float) -> void:
if not _skeleton:
return
_wobble_body(delta)
_update_tail(delta)
_apply_tail()
# ── Body Wobble ───────────────────────────────────────────────────────────────
func _wobble_body(delta: float) -> void:
var vel: Vector3 = _player.velocity
var accel: Vector3 = (vel - _prev_vel) / maxf(delta, 0.001)
_prev_vel = vel
var la: Vector3 = _bull.global_transform.basis.inverse() * accel
var tx := clampf(-la.z * DP.f("body_tilt_fwd"), -0.35, 0.35)
var tz := clampf(-la.x * DP.f("body_tilt_side"), -0.25, 0.25)
_tilt_x = lerpf(_tilt_x, tx, delta * 10.0)
_tilt_z = lerpf(_tilt_z, tz, delta * 10.0)
var flat_spd: float = Vector2(vel.x, vel.z).length()
var target_pitch: float = 0.0
if not _player.is_on_floor() and flat_spd > 0.5:
target_pitch = clampf(-atan2(vel.y, flat_spd) * DP.f("aerial_pitch_scale"), -1.4, 1.4)
_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"))
_bull.rotation.x = _tilt_x + _aerial_pitch + _charge_pitch_curr
_bull.rotation.z = _tilt_z
# ── Tail ──────────────────────────────────────────────────────────────────────
func _update_tail(delta: float) -> void:
if _tail_world.is_empty():
return
var gravity := Vector3(0.0, -DP.f("tail_gravity"), 0.0)
var damping := 1.0 - clampf(DP.f("tail_damping"), 0.0, 0.99)
var anchor: Vector3 = _skeleton.to_global(_skeleton.get_bone_global_rest(_tail_idx[0]).origin)
_tail_world[0] = anchor
_tail_prev[0] = anchor
var flat_speed: float = Vector2(_player.velocity.x, _player.velocity.z).length()
var wag_strength: float = lerpf(DP.f("tail_wag_idle"), 0.0, clampf(flat_speed / 3.0, 0.0, 1.0))
var wag_freq: float = DP.f("tail_wag_freq")
var time: float = Time.get_ticks_msec() / 1000.0
var basis_x: Vector3 = _bull.global_transform.basis.x
for i in range(1, _tail_world.size()):
var vel: Vector3 = (_tail_world[i] - _tail_prev[i]) * damping
_tail_prev[i] = _tail_world[i]
var wag := basis_x * sin(time * wag_freq + i * 0.5) * wag_strength
_tail_world[i] = _tail_world[i] + vel + gravity * (delta * delta) + wag * delta
for i in range(1, _tail_world.size()):
var dir: Vector3 = _tail_world[i] - _tail_world[i - 1]
var len: float = dir.length()
var target_len: float = _tail_lengths[i - 1]
if len > 0.0001:
_tail_world[i] = _tail_world[i - 1] + dir * (target_len / len)
else:
_tail_world[i] = _tail_world[i - 1] + Vector3(0.0, -target_len, 0.0)
var body_radius: float = DP.f("tail_body_radius")
for i in range(1, _tail_world.size()):
var p := _bull.to_local(_tail_world[i])
var closest := Vector3(0.0, 0.0, clampf(p.z, -0.3, 0.5))
var diff := p - closest
var dist := diff.length()
if dist < body_radius and dist > 0.001:
p = closest + diff.normalized() * body_radius
_tail_world[i] = _bull.to_global(p)
func _apply_tail() -> void:
if _tail_world.is_empty():
return
var n := _tail_world.size()
var local_pos: Array[Vector3] = []
local_pos.resize(n)
for i in n:
local_pos[i] = _skeleton.to_local(_tail_world[i])
for i in n:
var idx: int = _tail_idx[i]
var pos_skel: Vector3 = local_pos[i]
var point_dir_skel: Vector3
if i < n - 1:
point_dir_skel = (local_pos[i + 1] - pos_skel).normalized()
else:
point_dir_skel = (pos_skel - local_pos[i - 1]).normalized()
var rest_dir: Vector3 = _tail_rest_dir[i]
var rest_t: Transform3D = _skeleton.get_bone_global_rest(idx)
var new_basis: Basis
if rest_dir.length_squared() > 0.0001 and point_dir_skel.length_squared() > 0.0001:
new_basis = Basis(Quaternion(rest_dir, point_dir_skel)) * rest_t.basis
else:
new_basis = rest_t.basis
_skeleton.set_bone_global_pose_override(idx, Transform3D(new_basis, pos_skel), 1.0, false)
# ── Utility ───────────────────────────────────────────────────────────────────
func _find_skeleton(node: Node) -> Skeleton3D:
if node is Skeleton3D:
return node as Skeleton3D
for child in node.get_children():
var r := _find_skeleton(child)
if r:
return r
return null
func _find_physical_bones(node: Node) -> PhysicalBoneSimulator3D:
for child in node.get_children():
if child is PhysicalBoneSimulator3D:
return child as PhysicalBoneSimulator3D
return null
+1
View File
@@ -0,0 +1 @@
uid://bvtw8s41wrrx
+9
View File
@@ -0,0 +1,9 @@
extends Camera3D
@export var spring_arm: Node3D
func _process(delta: float) -> void:
var lerp_power: float = DP.f("cam_lerp_power")
position = position.lerp(spring_arm.position, delta * lerp_power)
+74
View File
@@ -0,0 +1,74 @@
extends Camera3D
@export var height: float = 18.0
@export var horizontal_distance: float = 18.0
@export var horizontal_angle_deg: float = 45.0
@export var ortho_size: float = 15.0
@export var ortho_size_max: float = 40.0
const _FLASH_DURATION: float = 0.30
var _shake_amount: float = 0.0
var _shake_decay: float = 0.0
var _flash_timer: float = 0.0
var _flash_rect: ColorRect = null
var _target_size: float = 0.0
func _ready() -> void:
set_as_top_level(true)
projection = PROJECTION_ORTHOGONAL
_target_size = ortho_size
size = ortho_size
_setup_flash()
func _setup_flash() -> void:
var canvas := CanvasLayer.new()
canvas.layer = 20
add_child(canvas)
_flash_rect = ColorRect.new()
_flash_rect.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_flash_rect.color = Color(1.0, 0.25, 0.2, 0.0)
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
canvas.add_child(_flash_rect)
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("zoom_in"):
_target_size = clamp(_target_size / 1.2, ortho_size, ortho_size_max)
if event.is_action_pressed("zoom_out"):
_target_size = clamp(_target_size * 1.2, ortho_size, ortho_size_max)
func _process(delta: float) -> void:
size = lerpf(size, _target_size, delta * 10.0)
var angle_rad := deg_to_rad(horizontal_angle_deg)
var offset := Vector3(
sin(angle_rad) * horizontal_distance,
height,
cos(angle_rad) * horizontal_distance,
)
var target := get_parent() as Node3D
global_position = target.global_position + offset
look_at(target.global_position, Vector3.UP)
if _shake_amount > 0.005:
global_position += Vector3(
randf_range(-_shake_amount, _shake_amount),
randf_range(-_shake_amount * 0.25, _shake_amount * 0.25),
randf_range(-_shake_amount, _shake_amount),
)
_shake_amount = move_toward(_shake_amount, 0.0, _shake_decay * delta)
if _flash_timer > 0.0:
_flash_timer = maxf(0.0, _flash_timer - delta)
_flash_rect.color.a = (_flash_timer / _FLASH_DURATION) * 0.6
else:
_flash_rect.color.a = 0.0
func trigger_hit(strength: float) -> void:
_shake_amount = strength * 0.5
_shake_decay = _shake_amount / 0.4
_flash_timer = _FLASH_DURATION
+39
View File
@@ -0,0 +1,39 @@
extends Node3D
@onready var spring_arm: SpringArm3D = $SpringArm3D
var _desired_length: float = 7.0
func _ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
_desired_length = DP.f("cam_spring_length")
spring_arm.spring_length = _desired_length
func _unhandled_input(event: InputEvent) -> void:
var sensitivity: float = DP.f("cam_sensitivity")
var min_vert: float = deg_to_rad(DP.f("cam_min_vert_angle"))
var max_vert: float = deg_to_rad(DP.f("cam_max_vert_angle"))
var min_zoom: float = DP.f("cam_min_zoom")
var max_zoom: float = DP.f("cam_max_zoom")
if event is InputEventMouseMotion:
rotation.y -= event.relative.x * sensitivity
rotation.y = wrapf(rotation.y, 0.0, TAU)
rotation.x -= event.relative.y * sensitivity
rotation.x = clamp(rotation.x, min_vert, max_vert)
if event.is_action_pressed("zoom_in"):
_desired_length = clamp(_desired_length / 1.2, min_zoom, max_zoom)
if event.is_action_pressed("zoom_out"):
_desired_length = clamp(_desired_length * 1.2, min_zoom, max_zoom)
if event.is_action_pressed("toggle_mouse_capture"):
if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
else:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
func _process(_delta: float) -> void:
spring_arm.spring_length = _desired_length
+216
View File
@@ -0,0 +1,216 @@
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)
+1
View File
@@ -0,0 +1 @@
uid://via815ow6p5r
+185
View File
@@ -0,0 +1,185 @@
extends Node
## Central registry for all runtime-tunable parameters.
## Add a new param with one _reg_f() call in _register_all() — the menu picks it up automatically.
## Access: DP.f("key") • Persist: DP.save() / DP.load_saved() • Reset: DP.reset_all()
signal any_changed(key: String, value: Variant)
const SAVE_PATH := "user://debug_params.cfg"
var _params: Dictionary = {}
func _ready() -> void:
_register_all()
load_saved()
func _register_all() -> void:
# ── Movement ──────────────────────────────────────────────────────────────
_reg_f("Movement", "thrust_impulse", 18.0, 1.0, 200.0)
_reg_f("Movement", "thrust_vertical", 2.0, 0.0, 20.0)
_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", "roll_damping", 0.15, 0.01, 0.99, 0.01)
_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", "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", "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", "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 ─────────────────────────────────────────────────
_reg_f("Dust", "dust_walk_spd", 1.5, 0.1, 10.0)
_reg_f("Dust", "dust_charge_spd", 7.0, 1.0, 20.0)
# ── Dust ──────────────────────────────────────────────────────────────────
_reg_f("Dust", "dust_lifetime", 0.9, 0.1, 4.0)
_reg_f("Dust", "dust_explosiveness", 0.35, 0.0, 1.0)
_reg_f("Dust", "dust_spread", 45.0, 5.0, 90.0)
_reg_f("Dust", "dust_gravity_y", -1.5,-15.0, 0.0)
_reg_f("Dust", "walk_scale_min", 0.35, 0.05, 3.0)
_reg_f("Dust", "walk_scale_max", 0.65, 0.05, 3.0)
_reg_f("Dust", "walk_vel_min", 0.8, 0.1, 12.0)
_reg_f("Dust", "walk_vel_max", 1.8, 0.1, 12.0)
_reg_f("Dust", "charge_scale_min", 1.0, 0.05, 6.0)
_reg_f("Dust", "charge_scale_max", 2.2, 0.05, 6.0)
_reg_f("Dust", "charge_vel_min", 2.8, 0.1, 20.0)
_reg_f("Dust", "charge_vel_max", 5.5, 0.1, 20.0)
# ── Tail ──────────────────────────────────────────────────────────────────
_reg_f("Tail", "tail_gravity", 9.8, 0.0, 30.0)
_reg_f("Tail", "tail_damping", 0.08, 0.0, 0.5, 0.01)
_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_idle", 0.3, 0.0, 2.0, 0.05)
# ── Charge (both mouse buttons) ──────────────────────────────────────────
_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_speed", 5.0, 0.5, 20.0)
# ── Body ──────────────────────────────────────────────────────────────────
_reg_f("Body", "body_tilt_fwd", 0.015, 0.0, 0.1, 0.001)
_reg_f("Body", "body_tilt_side", 0.01, 0.0, 0.1, 0.001)
_reg_f("Body", "aerial_pitch_scale", 0.6, 0.0, 2.0)
_reg_f("Body", "aerial_pitch_speed", 5.0, 0.5, 20.0)
# ── Legs ──────────────────────────────────────────────────────────────────
_reg_f("Legs", "leg_step_threshold", 0.15, 0.01, 1.0, 0.01)
_reg_f("Legs", "leg_step_duration", 0.12, 0.02, 0.5, 0.01)
_reg_f("Legs", "leg_step_height", 0.15, 0.0, 0.5)
_reg_f("Legs", "leg_step_overshoot", 0.3, 0.0, 1.0)
_reg_f("Legs", "gait_freq", 1.5, 0.1, 5.0)
_reg_f("Legs", "launch_hold_time", 0.15, 0.0, 1.0, 0.01)
_reg_f("Legs", "ragdoll_wobble_amp", 0.15, 0.0, 1.0)
_reg_f("Legs", "tuck_apex_threshold", 0.5, -5.0, 5.0)
_reg_f("Legs", "jump_tuck_speed", 8.0, 1.0, 30.0)
_reg_f("Legs", "jump_tuck_up", 0.2, 0.0, 1.0)
_reg_f("Legs", "jump_tuck_fwd", -0.1, -0.5, 0.5)
_reg_f("Legs", "jump_land_threshold", 1.5, 0.5, 5.0)
# ── Camera ────────────────────────────────────────────────────────────────
_reg_f("Camera", "cam_lerp_power", 6.0, 0.5, 30.0)
_reg_f("Camera", "cam_sensitivity", 0.005, 0.001, 0.05, 0.001)
_reg_f("Camera", "cam_min_zoom", 3.5, 0.5, 20.0)
_reg_f("Camera", "cam_max_zoom", 40.0, 2.0, 80.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_max_vert_angle", 45.0, 0.0, 90.0, 1.0)
# ── Matador ───────────────────────────────────────────────────────────────
_reg_f("Matador", "mat_spawn_count", 1.0, 1.0, 30.0, 1.0)
_reg_f("Matador", "mat_walk_speed", 2.5, 0.5, 10.0)
_reg_f("Matador", "mat_wander_radius", 22.0, 5.0, 50.0)
_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_hit_threshold", 4.0, 1.0, 30.0)
_reg_f("Matador", "mat_ragdoll_impulse", 6.0, 0.5, 20.0)
_reg_f("Matador", "mat_flee_range", 3.5, 2.0, 40.0)
_reg_f("Matador", "mat_flee_speed", 2.5, 0.5, 12.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_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_step_speed", 3.5, 2.0, 20.0)
_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)
_reg_f("Matador", "mat_dodge_chance", 0.25, 0.0, 1.0, 0.05)
# ── Debug ─────────────────────────────────────────────────────────────────
_reg_b("Debug", "show_collisions", false)
func _reg_f(section: String, key: String, default: float,
min_val: float, max_val: float, step: float = 0.05) -> void:
_params[key] = {
"value": default,
"default": default,
"section": section,
"type": TYPE_FLOAT,
"min": min_val,
"max": max_val,
"step": step,
}
func _reg_b(section: String, key: String, default: bool) -> void:
_params[key] = {
"value": default,
"default": default,
"section": section,
"type": TYPE_BOOL,
}
## Read a float param. Panics on unknown key — intentional: typos should be loud.
func f(key: String) -> float:
return _params[key]["value"] as float
## Read a bool param.
func b(key: String) -> bool:
return _params[key]["value"] as bool
func set_value(key: String, value: Variant) -> void:
if not _params.has(key):
return
_params[key]["value"] = value
any_changed.emit(key, value)
## Returns the full metadata dict — used by DebugMenu to build UI.
func get_all() -> Dictionary:
return _params
func save() -> void:
var cfg := ConfigFile.new()
for key: String in _params:
var p: Dictionary = _params[key]
cfg.set_value(p["section"], key, p["value"])
cfg.save(SAVE_PATH)
func load_saved() -> void:
var cfg := ConfigFile.new()
if cfg.load(SAVE_PATH) != OK:
return
for section: String in cfg.get_sections():
for key: String in cfg.get_section_keys(section):
if not _params.has(key):
continue
var p: Dictionary = _params[key]
var val: Variant = cfg.get_value(section, key)
if p["type"] == TYPE_FLOAT:
val = clampf(val as float, p["min"] as float, p["max"] as float)
_params[key]["value"] = val
func reset_all() -> void:
for key: String in _params:
_params[key]["value"] = _params[key]["default"]
any_changed.emit("__all__", null)
+1
View File
@@ -0,0 +1 @@
uid://b3ix5yi7b3ynh
Executable
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
SERVER_USER="richard"
SERVER_HOST="192.168.1.168"
SERVER_PATH="/opt/bulloseum/data"
BUILD_DIR="build/web"
mkdir -p "$BUILD_DIR"
godot --headless --export-release "Web" "$BUILD_DIR/index.html"
gzip -kf "$BUILD_DIR/index.wasm"
scp "$BUILD_DIR/index.html" \
"$BUILD_DIR/index.js" \
"$BUILD_DIR/index.wasm.gz" \
"$BUILD_DIR/index.pck" \
"$BUILD_DIR/index.audio.worklet.js" \
"$BUILD_DIR/index.audio.position.worklet.js" \
"$BUILD_DIR/index.icon.png" \
"$BUILD_DIR/index.apple-touch-icon.png" \
"$BUILD_DIR/index.png" \
"$SERVER_USER@$SERVER_HOST:$SERVER_PATH/"
echo "deployed to $SERVER_HOST:$SERVER_PATH"
+38
View File
@@ -0,0 +1,38 @@
[preset.0]
name="Web"
platform="Web"
runnable=true
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="build/web/index.html"
encryption_include_filters=""
encryption_exclude_filters=""
encrypt_pck=false
encrypt_directory=false
[preset.0.options]
custom_template/debug=""
custom_template/release=""
variant/extensions_support=false
variant/thread_support=true
vram_texture_compression/for_desktop=true
vram_texture_compression/for_mobile=false
html/export_icon=true
html/custom_html_shell=""
html/head_include=""
html/canvas_resize_policy=2
html/focus_canvas_on_start=true
html/experimental_virtual_keyboard=false
progressive_web_app/enabled=false
progressive_web_app/offline_page=""
progressive_web_app/display=1
progressive_web_app/orientation=0
progressive_web_app/icon_144x144=""
progressive_web_app/icon_180x180=""
progressive_web_app/icon_512x512=""
progressive_web_app/background_color=Color(0, 0, 0, 1)
-2
View File
@@ -1,2 +0,0 @@
hello
xd
+360
View File
@@ -0,0 +1,360 @@
extends CanvasLayer
const _GOLD := Color(1.00, 0.78, 0.22)
const _CREAM := Color(0.88, 0.83, 0.72)
const _MUTED := Color(0.65, 0.60, 0.48)
const _BG := Color(0.06, 0.04, 0.02, 0.78)
const _BADGE := Color(0.16, 0.10, 0.03, 0.90)
const _BORDER := Color(0.65, 0.48, 0.15, 0.55)
const ROUND_DURATION := 60.0
var _controls_visible: bool = true
var _timer_remaining: float = ROUND_DURATION
var _score: int = 0
var _round_active: bool = true
var _controls_panel: PanelContainer
var _timer_label: Label
var _score_label: Label
var _toggle_btn: Button
var _game_over_panel: Control
var _final_score_label: Label
var _spawner: Node
func _ready() -> void:
layer = 11
process_mode = Node.PROCESS_MODE_ALWAYS
_build()
_find_spawner.call_deferred()
func _find_spawner() -> void:
var spawners := get_tree().get_nodes_in_group(&"matador_spawn")
if not spawners.is_empty():
_spawner = spawners[0]
_spawner.matador_killed.connect(_on_matador_killed)
func _process(delta: float) -> void:
if not _round_active:
return
_timer_remaining = maxf(0.0, _timer_remaining - delta)
_update_timer_display()
if _timer_remaining <= 0.0:
_end_round()
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo:
if event.physical_keycode == KEY_H:
_toggle_controls()
func _toggle_controls() -> void:
_controls_visible = not _controls_visible
_controls_panel.visible = _controls_visible
_toggle_btn.text = "Hide controls" if _controls_visible else "Show controls"
func _on_matador_killed() -> void:
if _round_active:
_score += 1
_update_score_display()
func _end_round() -> void:
_round_active = false
_final_score_label.text = "You killed %d matadors!" % _score
_game_over_panel.visible = true
get_tree().paused = true
func _on_continue_pressed() -> void:
_on_reset_pressed()
func _on_reset_pressed() -> void:
if get_tree().paused:
get_tree().paused = false
_game_over_panel.visible = false
_timer_remaining = ROUND_DURATION
_score = 0
_round_active = true
_update_timer_display()
_update_score_display()
if _spawner:
_spawner.reset_spawn()
func _update_timer_display() -> void:
var s := int(_timer_remaining)
_timer_label.text = "%d:%02d" % [s / 60, s % 60]
func _update_score_display() -> void:
_score_label.text = "Kills: %d" % _score
func _build() -> void:
_build_controls_panel()
_build_score_panel()
_build_game_over_overlay()
func _build_controls_panel() -> void:
_controls_panel = PanelContainer.new()
var bg := StyleBoxFlat.new()
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_top = 1.0
_controls_panel.anchor_right = 0.0
_controls_panel.anchor_bottom = 1.0
_controls_panel.grow_vertical = Control.GROW_DIRECTION_BEGIN
_controls_panel.offset_left = 20.0
_controls_panel.offset_bottom = -20.0
add_child(_controls_panel)
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 5)
_controls_panel.add_child(vbox)
_row(vbox, "WASD / Arrows", "Move (not intended)", _MUTED)
_row(vbox, "LMB (hold)", "Right horn", _CREAM)
_row(vbox, "RMB (hold)", "Left horn", _CREAM)
_row(vbox, "LMB + RMB", "Bull charge", _CREAM)
_row(vbox, "Scroll", "Zoom", _CREAM)
_row(vbox, "R", "Respawn matadors (cooldown)", _MUTED)
_row(vbox, "H", "Toggle controls", _MUTED)
func _build_score_panel() -> void:
var panel := PanelContainer.new()
var bg := StyleBoxFlat.new()
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 = 16.0
bg.content_margin_right = 16.0
bg.content_margin_top = 12.0
bg.content_margin_bottom = 12.0
panel.add_theme_stylebox_override("panel", bg)
panel.anchor_left = 1.0
panel.anchor_top = 0.0
panel.anchor_right = 1.0
panel.anchor_bottom = 0.0
panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
panel.grow_vertical = Control.GROW_DIRECTION_END
panel.offset_right = -20.0
panel.offset_top = 20.0
add_child(panel)
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 8)
panel.add_child(vbox)
_timer_label = Label.new()
_timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_timer_label.add_theme_color_override("font_color", _GOLD)
_timer_label.add_theme_font_size_override("font_size", 32)
vbox.add_child(_timer_label)
_score_label = Label.new()
_score_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_score_label.add_theme_color_override("font_color", _CREAM)
_score_label.add_theme_font_size_override("font_size", 16)
vbox.add_child(_score_label)
var sep := HSeparator.new()
var sep_style := StyleBoxFlat.new()
sep_style.bg_color = Color(0.65, 0.48, 0.15, 0.30)
sep_style.content_margin_top = 2.0
sep_style.content_margin_bottom = 2.0
sep.add_theme_stylebox_override("separator", sep_style)
vbox.add_child(sep)
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 8)
vbox.add_child(hbox)
_toggle_btn = _make_button("Hide controls")
_toggle_btn.pressed.connect(_toggle_controls)
hbox.add_child(_toggle_btn)
var reset_btn := _make_button("Reset")
reset_btn.pressed.connect(_on_reset_pressed)
hbox.add_child(reset_btn)
_update_timer_display()
_update_score_display()
func _build_game_over_overlay() -> void:
_game_over_panel = Control.new()
_game_over_panel.set_anchors_preset(Control.PRESET_FULL_RECT)
_game_over_panel.visible = false
_game_over_panel.process_mode = Node.PROCESS_MODE_ALWAYS
add_child(_game_over_panel)
var backdrop := ColorRect.new()
backdrop.set_anchors_preset(Control.PRESET_FULL_RECT)
backdrop.color = Color(0.0, 0.0, 0.0, 0.65)
_game_over_panel.add_child(backdrop)
var card := PanelContainer.new()
var card_bg := StyleBoxFlat.new()
card_bg.bg_color = Color(0.08, 0.05, 0.02, 0.95)
card_bg.corner_radius_top_left = 12
card_bg.corner_radius_top_right = 12
card_bg.corner_radius_bottom_left = 12
card_bg.corner_radius_bottom_right = 12
card_bg.border_width_left = 2
card_bg.border_width_right = 2
card_bg.border_width_top = 2
card_bg.border_width_bottom = 2
card_bg.border_color = _GOLD
card_bg.content_margin_left = 48.0
card_bg.content_margin_right = 48.0
card_bg.content_margin_top = 36.0
card_bg.content_margin_bottom = 36.0
card.add_theme_stylebox_override("panel", card_bg)
card.anchor_left = 0.5
card.anchor_top = 0.5
card.anchor_right = 0.5
card.anchor_bottom = 0.5
card.grow_horizontal = Control.GROW_DIRECTION_BOTH
card.grow_vertical = Control.GROW_DIRECTION_BOTH
_game_over_panel.add_child(card)
var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 18)
vbox.alignment = BoxContainer.ALIGNMENT_CENTER
card.add_child(vbox)
var title := Label.new()
title.text = "TIME'S UP!"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_color_override("font_color", _GOLD)
title.add_theme_font_size_override("font_size", 48)
vbox.add_child(title)
_final_score_label = Label.new()
_final_score_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_final_score_label.add_theme_color_override("font_color", _CREAM)
_final_score_label.add_theme_font_size_override("font_size", 24)
vbox.add_child(_final_score_label)
var continue_btn := _make_button("Continue")
continue_btn.custom_minimum_size = Vector2(160.0, 44.0)
continue_btn.process_mode = Node.PROCESS_MODE_ALWAYS
continue_btn.pressed.connect(_on_continue_pressed)
vbox.add_child(continue_btn)
func _make_button(label_text: String) -> Button:
var btn := Button.new()
btn.text = label_text
btn.process_mode = Node.PROCESS_MODE_ALWAYS
var normal := StyleBoxFlat.new()
normal.bg_color = Color(0.18, 0.12, 0.04, 0.90)
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("hover", hover)
btn.add_theme_stylebox_override("pressed", hover)
btn.add_theme_color_override("font_color", _GOLD)
btn.add_theme_color_override("font_hover_color", _GOLD)
btn.add_theme_font_size_override("font_size", 12)
return btn
func _row(parent: VBoxContainer, key: String, desc: String, desc_color: Color) -> void:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
parent.add_child(row)
var badge := PanelContainer.new()
var bs := StyleBoxFlat.new()
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)
row.add_child(badge)
var key_lbl := Label.new()
key_lbl.text = key
key_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
key_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
key_lbl.add_theme_color_override("font_color", _GOLD)
key_lbl.add_theme_font_size_override("font_size", 12)
badge.add_child(key_lbl)
var desc_lbl := Label.new()
desc_lbl.text = desc
desc_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
desc_lbl.add_theme_color_override("font_color", desc_color)
desc_lbl.add_theme_font_size_override("font_size", 12)
row.add_child(desc_lbl)

Before

Width:  |  Height:  |  Size: 995 B

After

Width:  |  Height:  |  Size: 995 B

+474
View File
@@ -0,0 +1,474 @@
extends CharacterBody3D
# WANDER → bull outside flee range, roaming freely
# FLEE → bull within range, matador backs away
# BRACE → charge detected: plant feet, face the bull (cite phase)
# SIDESTEP→ bull commits: sharp lateral step to let it pass (pase phase)
# RAGDOLL → hit by bull at speed
enum State { WANDER, FLEE, BRACE, SIDESTEP, RAGDOLL }
signal killed
var _state: State = State.WANDER
var _skeleton: Skeleton3D = null
var _sim: PhysicalBoneSimulator3D = null
var _anim_player: AnimationPlayer = null
var _wander_target: Vector3 = Vector3.ZERO
var _idle_timer: float = 0.0
var _bull: CharacterBody3D = null
var _step_dir: Vector3 = Vector3.ZERO
var _brace_timer: float = 0.0
var _step_timer: float = 0.0
var _dodge_cd: float = 0.0
var _will_dodge: bool = true
var _ole_player: AudioStreamPlayer = null
var _death_player: AudioStreamPlayer = null
var _blood_burst: CPUParticles3D = null
@onready var _mesh: Node3D = $matador_v02
@onready var _hit_area: Area3D = $HitArea
@onready var _body_col: CollisionShape3D = $CollisionShape3D
const _WALK_ANIM: StringName = &"Armature|walk"
const _IDLE_ANIM: StringName = &"Armature|idle"
func _ready() -> void:
add_to_group(&"matador")
_skeleton = _find_skeleton(_mesh)
_anim_player = _find_anim_player(_mesh)
if _skeleton:
_sim = _setup_physical_bones()
if _anim_player:
_ensure_loop(_WALK_ANIM)
if _anim_player.has_animation(_IDLE_ANIM):
_anim_player.play(_IDLE_ANIM)
else:
push_warning("Matador: idle animation not found. Available: %s" %
str(_anim_player.get_animation_list()))
_hit_area.body_entered.connect(_on_body_entered)
_pick_wander_target()
_setup_blood_burst()
if ResourceLoader.exists("res://sounds/ole.ogg"):
_ole_player = AudioStreamPlayer.new()
_ole_player.stream = load("res://sounds/ole.ogg")
_ole_player.volume_db = 0.0
add_child(_ole_player)
if ResourceLoader.exists("res://sounds/matador_death.ogg"):
_death_player = AudioStreamPlayer.new()
_death_player.stream = load("res://sounds/matador_death.ogg")
_death_player.volume_db = 2.0
add_child(_death_player)
func _physics_process(delta: float) -> void:
if _bull == null:
var players := get_tree().get_nodes_in_group(&"player")
if not players.is_empty():
_bull = players[0] as CharacterBody3D
if not is_on_floor():
velocity += get_gravity() * delta
_dodge_cd = maxf(0.0, _dodge_cd - delta)
if _state != State.RAGDOLL and _bull != null:
_update_ai_state()
match _state:
State.WANDER: _tick_wander(delta)
State.FLEE: _tick_flee(delta)
State.BRACE: _tick_brace(delta)
State.SIDESTEP: _tick_sidestep(delta)
State.RAGDOLL: _tick_ragdoll(delta)
func _update_ai_state() -> void:
var dist := global_position.distance_to(_bull.global_position)
match _state:
State.WANDER:
if dist < DP.f("mat_flee_range"):
_state = State.FLEE
State.FLEE:
if _dodge_cd <= 0.0 and (dist < DP.f("mat_commit_dist") or _is_charge_incoming()):
_start_brace()
elif dist > DP.f("mat_flee_range") * 1.3:
_state = State.WANDER
_pick_wander_target()
State.BRACE:
pass # brace timer drives transition
State.SIDESTEP:
pass # step timer drives transition
# Bull is moving fast and aimed within ~30° of the matador
func _is_charge_incoming() -> bool:
var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
if bull_flat.length() < DP.f("mat_charge_speed"):
return false
var to_me := (global_position - _bull.global_position)
to_me.y = 0.0
if to_me.length() > DP.f("mat_brace_range"):
return false
return bull_flat.normalized().dot(to_me.normalized()) > 0.85
func _tick_wander(delta: float) -> void:
if _idle_timer > 0.0:
_idle_timer -= delta
velocity.x = move_toward(velocity.x, 0.0, 10.0 * delta)
velocity.z = move_toward(velocity.z, 0.0, 10.0 * delta)
_play_anim(_IDLE_ANIM)
move_and_slide()
return
var dx: float = _wander_target.x - global_position.x
var dz: float = _wander_target.z - global_position.z
if Vector2(dx, dz).length() < 0.8:
_idle_timer = randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max"))
_pick_wander_target()
else:
var dir := Vector3(dx, 0.0, dz).normalized()
var spd := DP.f("mat_walk_speed")
velocity.x = dir.x * spd
velocity.z = dir.z * spd
_mesh.rotation.y = lerp_angle(
_mesh.rotation.y, atan2(velocity.x, velocity.z), delta * 8.0)
_play_anim(_WALK_ANIM)
move_and_slide()
func _tick_flee(delta: float) -> void:
var away := (global_position - _bull.global_position)
away.y = 0.0
if away.length() < 0.01:
away = Vector3(randf() - 0.5, 0.0, randf() - 0.5)
away = away.normalized()
var spd := DP.f("mat_flee_speed")
velocity.x = away.x * spd
velocity.z = away.z * spd
_mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(velocity.x, velocity.z), delta * 8.0)
_play_anim(_WALK_ANIM)
move_and_slide()
# Cite phase: plant feet, face the bull — wait for it to commit
func _start_brace() -> void:
_state = State.BRACE
var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
# Use bull velocity if it's moving, else use bull→matador direction as proxy
var charge_dir: Vector3
if bull_flat.length() > 1.0:
charge_dir = bull_flat.normalized()
else:
charge_dir = (_bull.global_position - global_position)
charge_dir.y = 0.0
charge_dir = charge_dir.normalized()
# Decide whether to dodge at all and which side — fixed before the bull arrives
_will_dodge = randf() < DP.f("mat_dodge_chance")
var side := 1.0 if randf() > 0.5 else -1.0
_step_dir = charge_dir.rotated(Vector3.UP, PI * 0.5 * side)
_brace_timer = DP.f("mat_brace_duration")
func _tick_brace(delta: float) -> void:
# Decelerate to a stop
velocity.x = move_toward(velocity.x, 0.0, 18.0 * delta)
velocity.z = move_toward(velocity.z, 0.0, 18.0 * delta)
# Face the bull
var to_bull := (_bull.global_position - global_position)
to_bull.y = 0.0
if to_bull.length() > 0.1:
_mesh.rotation.y = lerp_angle(
_mesh.rotation.y, atan2(to_bull.x, to_bull.z), delta * 14.0)
_play_anim(_IDLE_ANIM)
move_and_slide()
_brace_timer -= delta
var dist := global_position.distance_to(_bull.global_position)
# Execute the step when the bull is almost on top or the brace window expires
if dist < DP.f("mat_commit_dist") or _brace_timer <= 0.0:
if _will_dodge:
_state = State.SIDESTEP
_step_timer = DP.f("mat_step_duration")
if _ole_player:
_ole_player.pitch_scale = randf_range(0.88, 1.12)
_ole_player.play()
else:
# Stand and eat it — no juke
_state = State.FLEE
_dodge_cd = DP.f("mat_dodge_cooldown")
# Pase phase: sharp lateral step as the bull commits to its line
func _tick_sidestep(delta: float) -> void:
_step_timer -= delta
if _step_timer <= 0.0:
_state = State.FLEE
_dodge_cd = DP.f("mat_dodge_cooldown")
return
var spd := DP.f("mat_step_speed")
velocity.x = _step_dir.x * spd
velocity.z = _step_dir.z * spd
# Snap facing quickly to sell the movement
_mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(velocity.x, velocity.z), delta * 20.0)
_play_anim(_WALK_ANIM)
move_and_slide()
func _tick_ragdoll(_delta: float) -> void:
velocity.x = 0.0
velocity.z = 0.0
move_and_slide()
func _on_body_entered(body: Node3D) -> void:
if _state == State.RAGDOLL:
return
if not body.is_in_group(&"player"):
return
var player := body as CharacterBody3D
if player.velocity.length() < DP.f("mat_hit_threshold"):
return
var flat_vel := Vector3(player.velocity.x, 0.0, player.velocity.z)
var hit_dir := flat_vel.normalized() if flat_vel.length() > 0.5 else \
(global_position - player.global_position).normalized()
_enter_ragdoll(hit_dir, player.velocity.length())
func _enter_ragdoll(hit_dir: Vector3, bull_speed: float) -> void:
_state = State.RAGDOLL
killed.emit()
var _t := get_tree().create_timer(4.0)
_t.timeout.connect(func() -> void:
if is_instance_valid(self):
queue_free()
)
hit_dir.y = 0.0
hit_dir = hit_dir.normalized()
# Arc upward so the body lifts before falling — feels like a real goring throw
var throw_dir := (hit_dir + Vector3(0.0, 0.5, 0.0)).normalized()
_spawn_blood_burst(hit_dir)
if _death_player:
_death_player.pitch_scale = randf_range(0.9, 1.1)
_death_player.play()
var cam := _bull.get_node_or_null("Camera3D")
if cam:
cam.call(&"trigger_hit", clampf(bull_speed / 15.0, 0.4, 1.0))
# Keep the CharacterBody3D stationary: physics bones simulate relative to the
# skeleton root, so sliding the root makes every bone pose diverge.
velocity = Vector3.ZERO
# Remove from collision layer so the bull passes through, but keep the
# shape enabled — move_and_slide() still needs it to detect the floor.
collision_layer = 0
if _anim_player:
_anim_player.pause() # pause keeps current frame; stop() resets to T-pose
if not _sim:
return
# Teleport each physics body to its current bone world transform, then
# enable per-bone simulation. _sim.active alone does NOT set simulate_physics;
# without it _process_modification() skips every bone and writes nothing.
for child: Node in _sim.get_children():
if not (child is PhysicalBone3D):
continue
var pb := child as PhysicalBone3D
var bone_idx := _skeleton.find_bone(pb.bone_name)
if bone_idx >= 0:
pb.global_transform = _skeleton.global_transform * _skeleton.get_bone_global_pose(bone_idx)
_sim.active = true
_sim.physical_bones_start_simulation()
var strength := DP.f("mat_ragdoll_impulse") * clampf(bull_speed / 15.0, 0.4, 1.3)
var right := throw_dir.cross(Vector3.UP).normalized()
for child: Node in _sim.get_children():
if not (child is PhysicalBone3D):
continue
var pb := child as PhysicalBone3D
var impulse := Vector3.ZERO
match pb.bone_name:
"COG", "matador":
# Core of mass takes the full hit
impulse = throw_dir * strength
"chest":
impulse = (throw_dir + Vector3.UP * 0.3).normalized() * strength * 0.9
"head":
impulse = (throw_dir + Vector3.UP * 0.5).normalized() * strength * 0.7
"leg_L":
impulse = (throw_dir * 0.4 + right * 0.6 + Vector3.UP * 0.4).normalized() * strength * 0.6
"leg_R":
impulse = (throw_dir * 0.4 - right * 0.6 + Vector3.UP * 0.4).normalized() * strength * 0.6
"arm_L":
impulse = (throw_dir * 0.3 + right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5
"arm_R":
impulse = (throw_dir * 0.3 - right * 0.8 + Vector3.UP * 0.5).normalized() * strength * 0.5
_:
# Distal bones (forearms, shins, hands, feet) get no direct impulse;
# they trail naturally from the joints above them
continue
# Small random noise per bone so no two look identical
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)
# ── Blood burst ───────────────────────────────────────────────────────────────
func _setup_blood_burst() -> 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))
_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:
_blood_burst.global_position = global_position + Vector3(0.0, 0.9, 0.0)
_blood_burst.direction = (hit_dir * 0.6 + Vector3.UP * 0.4).normalized()
_blood_burst.restart()
# ── Animation helpers ─────────────────────────────────────────────────────────
func _ensure_loop(anim_name: StringName) -> void:
if _anim_player.has_animation(anim_name):
var anim := _anim_player.get_animation(anim_name)
anim.loop_mode = Animation.LOOP_LINEAR
func _play_anim(anim_name: StringName) -> void:
if not _anim_player:
return
if not _anim_player.has_animation(anim_name):
return
if _anim_player.current_animation != anim_name:
_anim_player.play(anim_name)
func _pick_wander_target() -> void:
var radius := DP.f("mat_wander_radius")
var angle := randf() * TAU
var dist := randf_range(2.0, radius)
_wander_target = Vector3(cos(angle) * dist, 0.0, sin(angle) * dist)
# ── 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:
var sph := SphereShape3D.new()
sph.radius = entry[1]
cs.shape = sph
pb.add_child(cs)
sim.add_child(pb)
return sim
# ── Utility ───────────────────────────────────────────────────────────────────
func _find_skeleton(node: Node) -> Skeleton3D:
if node is Skeleton3D:
return node as Skeleton3D
for child: Node in node.get_children():
var r := _find_skeleton(child)
if r:
return r
return null
func _find_anim_player(node: Node) -> AnimationPlayer:
if node is AnimationPlayer:
return node as AnimationPlayer
for child: Node in node.get_children():
var r := _find_anim_player(child)
if r:
return r
return null
+49
View File
@@ -0,0 +1,49 @@
extends Node3D
signal matador_killed
const MATADOR := preload("res://Matador.tscn")
const RESPAWN_COOLDOWN := 8.0
var _respawn_cooldown: float = 0.0
func _ready() -> void:
add_to_group(&"matador_spawn")
_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:
var count: int = int(DP.f("mat_spawn_count"))
for i: int in count:
_spawn_one()
func _spawn_one() -> void:
var m: Node3D = MATADOR.instantiate()
m.position = Vector3(0.0, 1.0, 0.0)
get_parent().add_child(m)
m.killed.connect(_on_matador_killed)
func _on_matador_killed() -> void:
matador_killed.emit()
_spawn_one.call_deferred()
func reset_spawn() -> void:
for m: Node in get_tree().get_nodes_in_group(&"matador"):
m.queue_free()
await get_tree().process_frame
_spawn()
+382
View File
@@ -0,0 +1,382 @@
extends CharacterBody3D
# Hoof positions in bull_v10 local space — dust emitter offsets
const HOOF_OFFSETS: Array = [
Vector3(-0.30, -0.25, -0.50),
Vector3( 0.30, -0.25, -0.50),
Vector3(-0.30, -0.25, 0.60),
Vector3( 0.30, -0.25, 0.60),
]
@onready var camera_pivot: Node3D = $Camera3D
@onready var cube_guy: Node3D = $bull_v10
var _hoof_emitters: Array[CPUParticles3D] = []
var _legs: Node
var _kick_timer: float = 0.0
var _was_on_floor: bool = false
var _pre_slide_vel_y: float = 0.0
var _charge_dir: Vector3 = Vector3.ZERO
var _charge_hold_time: float = 0.0
var _both_held: bool = false
var _thrust_left_charge: float = 0.0
var _thrust_right_charge: float = 0.0
var _charge_was_full: bool = false
var _charge_overlay: ColorRect = null
var _charge_ready_emitter: CPUParticles3D = null
var _huff_player: AudioStreamPlayer = null
var _crowd_player: AudioStreamPlayer = null
var _huff_timer: float = 0.0
var _roll_damping: float = 0.15
enum DustState { NONE, WALK, CHARGE }
var _dust_state: DustState = DustState.NONE
func _ready() -> void:
add_to_group(&"player")
_setup_hoof_dust()
_setup_legs()
_setup_charge_indicators()
_setup_audio()
DP.any_changed.connect(_on_dp_changed)
_roll_damping = DP.f("roll_damping")
func _setup_legs() -> void:
_legs = Node.new()
_legs.set_script(load("res://bull_legs.gd"))
add_child(_legs)
_legs.call(&"setup", self, cube_guy)
func _setup_hoof_dust() -> 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.10
sphere.height = 0.20
sphere.radial_segments = 4
sphere.rings = 2
sphere.material = mat
var ramp := Gradient.new()
ramp.set_color(0, Color(0.80, 0.69, 0.46, 1.0))
ramp.set_color(1, Color(0.80, 0.69, 0.46, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 0.2))
scale_curve.add_point(Vector2(0.4, 1.0))
scale_curve.add_point(Vector2(1.0, 0.0))
for offset: Vector3 in HOOF_OFFSETS:
var p := CPUParticles3D.new()
p.position = offset
p.emitting = false
p.amount = 12
p.lifetime = DP.f("dust_lifetime")
p.explosiveness = DP.f("dust_explosiveness")
p.randomness = 0.6
p.local_coords = false
p.direction = Vector3(0.0, 1.0, 0.0)
p.spread = DP.f("dust_spread")
p.gravity = Vector3(0.0, DP.f("dust_gravity_y"), 0.0)
p.initial_velocity_min = DP.f("walk_vel_min")
p.initial_velocity_max = DP.f("walk_vel_max")
p.scale_amount_min = DP.f("walk_scale_min")
p.scale_amount_max = DP.f("walk_scale_max")
p.mesh = sphere
p.color_ramp = ramp
p.scale_amount_curve = scale_curve
cube_guy.add_child(p)
_hoof_emitters.append(p)
func _setup_charge_indicators() -> 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
# Screen overlay — red tint while charge-ahead (both buttons) is active
var ui := CanvasLayer.new()
add_child(ui)
_charge_overlay = ColorRect.new()
_charge_overlay.color = Color(0.7, 0.05, 0.0, 0.0)
_charge_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
_charge_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
ui.add_child(_charge_overlay)
# Orange spark burst when single-button charge hits max
var burst_ramp := Gradient.new()
burst_ramp.set_color(0, Color(1.0, 0.55, 0.0, 1.0))
burst_ramp.set_color(1, Color(1.0, 0.15, 0.0, 0.0))
var burst_scale := Curve.new()
burst_scale.add_point(Vector2(0.0, 1.0))
burst_scale.add_point(Vector2(1.0, 0.0))
_charge_ready_emitter = CPUParticles3D.new()
_charge_ready_emitter.position = Vector3(0.0, 0.3, 0.0)
_charge_ready_emitter.emitting = false
_charge_ready_emitter.one_shot = true
_charge_ready_emitter.amount = 16
_charge_ready_emitter.lifetime = 0.5
_charge_ready_emitter.explosiveness = 1.0
_charge_ready_emitter.randomness = 0.4
_charge_ready_emitter.local_coords = false
_charge_ready_emitter.direction = Vector3.UP
_charge_ready_emitter.spread = 120.0
_charge_ready_emitter.initial_velocity_min = 2.5
_charge_ready_emitter.initial_velocity_max = 5.0
_charge_ready_emitter.scale_amount_min = 0.2
_charge_ready_emitter.scale_amount_max = 0.5
_charge_ready_emitter.mesh = sphere
_charge_ready_emitter.color_ramp = burst_ramp
_charge_ready_emitter.scale_amount_curve = burst_scale
cube_guy.add_child(_charge_ready_emitter)
func _setup_audio() -> void:
if ResourceLoader.exists("res://sounds/bull_huff.ogg"):
_huff_player = AudioStreamPlayer.new()
_huff_player.stream = load("res://sounds/bull_huff.ogg")
_huff_player.volume_db = 0.0
add_child(_huff_player)
if ResourceLoader.exists("res://sounds/crowd_ambient.ogg"):
_crowd_player = AudioStreamPlayer.new()
var s := load("res://sounds/crowd_ambient.ogg") as AudioStreamOggVorbis
if s:
s.loop = true
_crowd_player.stream = s
_crowd_player.volume_db = -14.0
_crowd_player.autoplay = true
add_child(_crowd_player)
func _set_dust_state(new_state: DustState) -> void:
if new_state == _dust_state:
return
_dust_state = new_state
for p: CPUParticles3D in _hoof_emitters:
p.lifetime = DP.f("dust_lifetime")
p.explosiveness = DP.f("dust_explosiveness")
p.spread = DP.f("dust_spread")
p.gravity = Vector3(0.0, DP.f("dust_gravity_y"), 0.0)
match new_state:
DustState.NONE:
p.emitting = false
DustState.WALK:
p.scale_amount_min = DP.f("walk_scale_min")
p.scale_amount_max = DP.f("walk_scale_max")
p.initial_velocity_min = DP.f("walk_vel_min")
p.initial_velocity_max = DP.f("walk_vel_max")
p.emitting = true
DustState.CHARGE:
p.scale_amount_min = DP.f("charge_scale_min")
p.scale_amount_max = DP.f("charge_scale_max")
p.initial_velocity_min = DP.f("charge_vel_min")
p.initial_velocity_max = DP.f("charge_vel_max")
p.emitting = true
func _on_dp_changed(_key: String, _val: Variant) -> void:
_roll_damping = DP.f("roll_damping")
if _hoof_emitters.is_empty() or _dust_state == DustState.NONE:
return
var prev := _dust_state
_dust_state = DustState.NONE
_set_dust_state(prev)
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:
# ── Gravity ───────────────────────────────────────────────────────────────
if not is_on_floor():
velocity += get_gravity() * delta
# ── Camera-relative input direction ───────────────────────────────────────
var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var cam_basis := camera_pivot.global_transform.basis
var flat_forward := -Vector3(cam_basis.z.x, 0.0, cam_basis.z.z).normalized()
var flat_right := Vector3(cam_basis.x.x, 0.0, cam_basis.x.z).normalized()
var direction := (flat_forward * -input_dir.y + flat_right * input_dir.x).normalized()
var on_floor := is_on_floor()
# ── Mouse button actions ──────────────────────────────────────────────────
# Both held → charge: lock direction, ramp speed, lower head
# Left only → thrust NE (forward-right diagonal)
# Right only → thrust NW (forward-left diagonal)
var left_held := Input.is_action_pressed(&"thrust_left")
var right_held := Input.is_action_pressed(&"thrust_right")
var both_held := left_held and right_held
if both_held and not _both_held:
var raw := cube_guy.global_transform.basis.z
_charge_dir = Vector3(raw.x, 0.0, raw.z).normalized()
if _huff_player:
_huff_player.pitch_scale = randf_range(0.9, 1.1)
_huff_player.play()
_huff_timer = randf_range(1.4, 2.2)
if both_held:
_thrust_left_charge = 0.0
_thrust_right_charge = 0.0
_charge_hold_time += delta
_huff_timer -= delta
if _huff_timer <= 0.0 and _huff_player and not _huff_player.playing:
_huff_player.pitch_scale = randf_range(0.9, 1.1)
_huff_player.play()
_huff_timer = randf_range(1.4, 2.2)
var ramp := minf(_charge_hold_time / DP.f("charge_ramp_time"), 1.0)
var accel := DP.f("bull_charge_accel") * lerpf(0.2, 1.0, ramp)
velocity.x += _charge_dir.x * accel * delta
velocity.z += _charge_dir.z * accel * delta
var flat_c := Vector2(velocity.x, velocity.z)
var spd_c := flat_c.length()
if spd_c > DP.f("bull_charge_max_speed"):
velocity.x *= DP.f("bull_charge_max_speed") / spd_c
velocity.z *= DP.f("bull_charge_max_speed") / spd_c
_legs.set(&"charge_pitch_target", DP.f("charge_head_pitch"))
else:
_charge_hold_time = 0.0
_legs.set(&"charge_pitch_target", 0.0)
if left_held and not right_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 and not left_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
# Fade red overlay in while charge-ahead, out when released
var target_alpha := 0.30 if both_held else 0.0
var oc := _charge_overlay.color
_charge_overlay.color = Color(oc.r, oc.g, oc.b, lerpf(oc.a, target_alpha, delta * 10.0))
_both_held = both_held
# 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
# ── Periodic kick ─────────────────────────────────────────────────────────
_kick_timer = maxf(0.0, _kick_timer - delta)
if direction and _kick_timer <= 0.0 and not both_held:
_apply_kick(direction)
_kick_timer = DP.f("kick_interval")
# ── Rolling friction (exponential, frame-rate independent) ────────────────
var retain := pow(_roll_damping, delta)
velocity.x *= retain
velocity.z *= retain
# ── Visual rotation follows velocity direction ─────────────────────────────
var flat_speed := Vector2(velocity.x, velocity.z).length()
if flat_speed > 0.4:
var target_angle := atan2(velocity.x, velocity.z)
cube_guy.rotation.y = lerp_angle(
cube_guy.rotation.y, target_angle, delta * DP.f("visual_turn_speed"))
_pre_slide_vel_y = velocity.y
var pre_wall_vel := Vector3(velocity.x, 0.0, velocity.z)
move_and_slide()
# ── Wall bounce — no sliding, guaranteed exit ────────────────────────────
# pre_wall_vel is captured before move_and_slide strips the into-wall component,
# so into_spd correctly reflects how hard the bull hit the wall even when stationary.
for i in get_slide_collision_count():
var col := get_slide_collision(i)
var normal := col.get_normal()
if abs(normal.y) < 0.5:
var flat_n := Vector3(normal.x, 0.0, normal.z).normalized()
var into_spd := -pre_wall_vel.dot(flat_n)
var exit_spd := maxf(into_spd * DP.f("wall_bounce_restitution"),
DP.f("wall_min_bounce_speed"))
velocity.x = flat_n.x * exit_spd
velocity.z = flat_n.z * exit_spd
pre_wall_vel = Vector3(velocity.x, 0.0, velocity.z)
if both_held and _charge_dir.dot(flat_n) < 0.0:
_charge_dir = _charge_dir.reflect(flat_n)
# ── Bounce on landing ─────────────────────────────────────────────────────
if is_on_floor() and not _was_on_floor:
var impact := -_pre_slide_vel_y
if impact > DP.f("bounce_min_impact"):
velocity.y = impact * DP.f("bounce_restitution")
# ── Dust ──────────────────────────────────────────────────────────────────
if flat_speed > DP.f("dust_charge_spd") and on_floor:
_set_dust_state(DustState.CHARGE)
elif flat_speed > DP.f("dust_walk_spd") and on_floor:
_set_dust_state(DustState.WALK)
else:
_set_dust_state(DustState.NONE)
# ── Hoof sounds ───────────────────────────────────────────────────────────

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