49 Commits

Author SHA1 Message Date
richard d57c3dc2a2 Merge remote-tracking branch 'origin/main' 2026-08-26 20:38:14 +03:00
richard 09ae6bfbf4 lobby level 2026-08-26 20:38:07 +03:00
richard 40c1b8e680 debug param save works, bull/bear hitbox 2026-08-26 20:37:50 +03:00
siim a1c77fed5e Update debug_params.gd 2026-08-26 20:02:23 +03:00
siim 1a8af92d88 matador attack fix 2026-08-26 19:53:43 +03:00
siim 8166a6c18e Merge branch 'main' of https://git.richard.work/siim/Bullosseum 2026-08-26 19:41:35 +03:00
siim 46d6dda325 capsule 2026-08-26 19:41:23 +03:00
richard fc9348551d fix sword holster 2026-08-26 19:18:52 +03:00
richard c4a19c7443 crowd markers, unsheathed sword correct 2026-08-26 19:13:39 +03:00
siim 95c4c0ddf9 BEAR ARENA UPDATE 2026-08-26 19:12:27 +03:00
siim 6a4f104655 bear arena model updeit 2026-08-26 18:30:09 +03:00
siim d4b67888d7 controller zoom with right stick 2026-08-26 18:12:12 +03:00
siim b177e0aee3 matadors dont come inside the player anymore with sword attack. also got rid of matador.gd dublicate.. that i made for testing animation 2026-08-26 08:39:40 +03:00
siim 2a6f4c0f7b bear_FX 2026-08-25 21:58:43 +03:00
siim 32d0c58f5c bear_ Call method track testing with spears 2026-08-25 08:49:44 +03:00
siim 251002ea7c bull and bear flashing red when take damage 2026-08-25 08:04:39 +03:00
siim 986496097f arena cleanup 2026-08-24 18:45:03 +03:00
siim d976deeb6a arena cleanup+tex modification 2026-08-24 18:41:34 +03:00
siim d632670092 +death animation bear 2026-08-24 08:36:48 +03:00
siim e1899e49f4 level lobby 2026-08-23 23:40:28 +03:00
siim 6f5f9e6bba ps1 filter tuun 2026-08-23 22:26:58 +03:00
siim 91cd027a98 adj 2026-08-23 22:05:57 +03:00
siim ef2fb24969 cooldowns increase 2026-08-23 21:39:19 +03:00
siim 4c75776c70 debug_param_cooldown adjusts 2026-08-23 21:28:19 +03:00
siim 7688084b7e spear update 2026-08-23 21:15:25 +03:00
richard ccc92f201f mobile support tweaks 2026-08-23 20:32:52 +03:00
richard 008650a87e Merge remote-tracking branch 'origin/main' 2026-08-23 17:34:05 +03:00
richard b9e3147519 Bear, level selector (to be removed), new matador spawn, new bear level, saving config updates source files 2026-08-23 17:33:47 +03:00
richard cdba320cbc bear 2026-08-23 17:33:32 +03:00
richard ee7b1617c1 Bear, level selector (to be removed), new matador spawn, new bear level, saving config updates source files 2026-08-23 17:33:15 +03:00
richard d749485126 Map screen version 2026-08-23 13:16:04 +03:00
richard 45427b7070 Merge remote-tracking branch 'origin/main' 2026-08-23 12:37:35 +03:00
richard d514435dd8 touch controls and hud 2026-08-23 12:37:30 +03:00
siim 3eb9fa50f3 +bear boss 2026-08-23 11:18:45 +03:00
siim aee900c009 bear boss, aga broken 2026-08-21 18:23:30 +03:00
siim 4cf714f516 Update spear.glb 2026-08-17 23:49:29 +03:00
siim b78736a1f4 icon update 2026-08-10 04:43:48 +03:00
siim 212263c858 credits tuun 2026-08-10 03:53:17 +03:00
siim 8c4eeb0861 cooldowns? 2026-08-10 03:30:53 +03:00
siim 95def2d527 loading text + fullscreen default 2026-08-10 01:04:57 +03:00
siim 9a43a9ff8b icon 2026-08-09 23:58:01 +03:00
siim 7238cbf49e death anim speed .8 2026-08-09 23:19:23 +03:00
siim 44c667f097 collision scale 2026-08-09 23:15:43 +03:00
siim 3cd347d420 walk anim, death anim ja loading screen 2026-08-09 23:05:55 +03:00
richard 6414b2e7cc uids 2026-08-09 19:40:39 +03:00
richard 56593c58cf Release 0.2.0: device-aware controls, spear, settings, shaders
Show touch controls only on touch platforms and keyboard hints only on
desktop, via a shared Controls.use_touch_ui() gate (is_touchscreen_available
is unreliable with emulate_touch_from_mouse). Bumps version to 0.2.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-09 19:39:37 +03:00
richard ce6f3d7075 Deployment script update 2026-08-09 15:26:01 +03:00
siim a74182255b light tim 2026-08-08 11:47:36 +03:00
siim ebf5046461 credits 2026-07-30 20:21:10 +03:00
131 changed files with 17907 additions and 1345 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+3 -2
View File
@@ -4,8 +4,9 @@ importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://dk6xuwwa0mfpe" uid="uid://dk6xuwwa0mfpe"
path.s3tc="res://.godot/imported/Arena_sand.png-416de2afe03241df088061e1a5f831af.s3tc.ctex" path.s3tc="res://.godot/imported/Arena_sand.png-416de2afe03241df088061e1a5f831af.s3tc.ctex"
path.etc2="res://.godot/imported/Arena_sand.png-416de2afe03241df088061e1a5f831af.etc2.ctex"
metadata={ metadata={
"imported_formats": ["s3tc_bptc"], "imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true "vram_texture": true
} }
generator_parameters={ generator_parameters={
@@ -15,7 +16,7 @@ generator_parameters={
[deps] [deps]
source_file="res://Assets/Arena_sand.png" source_file="res://Assets/Arena_sand.png"
dest_files=["res://.godot/imported/Arena_sand.png-416de2afe03241df088061e1a5f831af.s3tc.ctex"] dest_files=["res://.godot/imported/Arena_sand.png-416de2afe03241df088061e1a5f831af.s3tc.ctex", "res://.godot/imported/Arena_sand.png-416de2afe03241df088061e1a5f831af.etc2.ctex"]
[params] [params]
+3 -2
View File
@@ -4,8 +4,9 @@ importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://b4gkpa7jypcsm" uid="uid://b4gkpa7jypcsm"
path.s3tc="res://.godot/imported/Arena_wall.png-e67bd4bbd2f60583bfc14dd8b7687eef.s3tc.ctex" path.s3tc="res://.godot/imported/Arena_wall.png-e67bd4bbd2f60583bfc14dd8b7687eef.s3tc.ctex"
path.etc2="res://.godot/imported/Arena_wall.png-e67bd4bbd2f60583bfc14dd8b7687eef.etc2.ctex"
metadata={ metadata={
"imported_formats": ["s3tc_bptc"], "imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true "vram_texture": true
} }
generator_parameters={ generator_parameters={
@@ -15,7 +16,7 @@ generator_parameters={
[deps] [deps]
source_file="res://Assets/Arena_wall.png" source_file="res://Assets/Arena_wall.png"
dest_files=["res://.godot/imported/Arena_wall.png-e67bd4bbd2f60583bfc14dd8b7687eef.s3tc.ctex"] dest_files=["res://.godot/imported/Arena_wall.png-e67bd4bbd2f60583bfc14dd8b7687eef.s3tc.ctex", "res://.godot/imported/Arena_wall.png-e67bd4bbd2f60583bfc14dd8b7687eef.etc2.ctex"]
[params] [params]
+3 -2
View File
@@ -4,8 +4,9 @@ importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://yercpq3y2o0x" uid="uid://yercpq3y2o0x"
path.s3tc="res://.godot/imported/Arena_wood.png-b4ed20d66b4d375396b0be2f2f863d80.s3tc.ctex" path.s3tc="res://.godot/imported/Arena_wood.png-b4ed20d66b4d375396b0be2f2f863d80.s3tc.ctex"
path.etc2="res://.godot/imported/Arena_wood.png-b4ed20d66b4d375396b0be2f2f863d80.etc2.ctex"
metadata={ metadata={
"imported_formats": ["s3tc_bptc"], "imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true "vram_texture": true
} }
generator_parameters={ generator_parameters={
@@ -15,7 +16,7 @@ generator_parameters={
[deps] [deps]
source_file="res://Assets/Arena_wood.png" source_file="res://Assets/Arena_wood.png"
dest_files=["res://.godot/imported/Arena_wood.png-b4ed20d66b4d375396b0be2f2f863d80.s3tc.ctex"] dest_files=["res://.godot/imported/Arena_wood.png-b4ed20d66b4d375396b0be2f2f863d80.s3tc.ctex", "res://.godot/imported/Arena_wood.png-b4ed20d66b4d375396b0be2f2f863d80.etc2.ctex"]
[params] [params]
BIN
View File
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ch1wqha62vhtb"
path="res://.godot/imported/IG_square.png-c38dd1a0a4601b6ff4ffec2910ed2ce0.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://Assets/IG_square.png"
dest_files=["res://.godot/imported/IG_square.png-c38dd1a0a4601b6ff4ffec2910ed2ce0.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.
@@ -3,13 +3,13 @@
importer="scene" importer="scene"
importer_version=1 importer_version=1
type="PackedScene" type="PackedScene"
uid="uid://dxe72ig8gfbcg" uid="uid://cwmo5vh738x6w"
path="res://.godot/imported/arena model2026.glb-299db01cae891e5005f8ac1e6c83d0a6.scn" path="res://.godot/imported/Lobby_level.glb-a7e896ecf7377d9530a80d5e63140cc3.scn"
[deps] [deps]
source_file="res://Assets/arena model2026.glb" source_file="res://Assets/Lobby_level.glb"
dest_files=["res://.godot/imported/arena model2026.glb-299db01cae891e5005f8ac1e6c83d0a6.scn"] dest_files=["res://.godot/imported/Lobby_level.glb-a7e896ecf7377d9530a80d5e63140cc3.scn"]
[params] [params]
Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://xtv6u00w4nmy"
path.s3tc="res://.godot/imported/Lobby_level_sand.png-bb70b0169c6b5159e020ce143afe9786.s3tc.ctex"
path.etc2="res://.godot/imported/Lobby_level_sand.png-bb70b0169c6b5159e020ce143afe9786.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
generator_parameters={
"md5": "c8b9b7d6205ce80dfcfaa38152b996fe"
}
[deps]
source_file="res://Assets/Lobby_level_sand.png"
dest_files=["res://.godot/imported/Lobby_level_sand.png-bb70b0169c6b5159e020ce143afe9786.s3tc.ctex", "res://.godot/imported/Lobby_level_sand.png-bb70b0169c6b5159e020ce143afe9786.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://csy5f3l5lufp5"
path.s3tc="res://.godot/imported/Lobby_level_wall.png-39243638bfc6ea5742b6e2fdc927ae2c.s3tc.ctex"
path.etc2="res://.godot/imported/Lobby_level_wall.png-39243638bfc6ea5742b6e2fdc927ae2c.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
generator_parameters={
"md5": "0e7ed34c3c812d96732bd32f9dca0c93"
}
[deps]
source_file="res://Assets/Lobby_level_wall.png"
dest_files=["res://.godot/imported/Lobby_level_wall.png-39243638bfc6ea5742b6e2fdc927ae2c.s3tc.ctex", "res://.godot/imported/Lobby_level_wall.png-39243638bfc6ea5742b6e2fdc927ae2c.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ym5273astq46"
path.s3tc="res://.godot/imported/Lobby_level_wood.png-4817792c09af3fd5e04ad29e0f9bd0ce.s3tc.ctex"
path.etc2="res://.godot/imported/Lobby_level_wood.png-4817792c09af3fd5e04ad29e0f9bd0ce.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
generator_parameters={
"md5": "23a7a951553ffe271c47693fd71314ca"
}
[deps]
source_file="res://Assets/Lobby_level_wood.png"
dest_files=["res://.godot/imported/Lobby_level_wood.png-4817792c09af3fd5e04ad29e0f9bd0ce.s3tc.ctex", "res://.godot/imported/Lobby_level_wood.png-4817792c09af3fd5e04ad29e0f9bd0ce.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0
Binary file not shown.
+1 -1
View File
@@ -28,7 +28,7 @@ meshes/generate_lods=true
meshes/create_shadow_meshes=true meshes/create_shadow_meshes=true
meshes/light_baking=1 meshes/light_baking=1
meshes/lightmap_texel_size=0.2 meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false meshes/force_disable_compression=true
skins/use_named_skins=true skins/use_named_skins=true
animation/import=true animation/import=true
animation/fps=30 animation/fps=30
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
[gd_scene format=3 uid="uid://by4ifsgxlv0gj"]
[ext_resource type="PackedScene" uid="uid://bm6rmt3xprhjs" path="res://Assets/bear_level_arena.glb" id="1_beararena"]
[sub_resource type="BoxShape3D" id="BoxShape3D_floor"]
[node name="bear_level" type="Node3D" unique_id=2020009824]
[node name="Node3D" type="Node3D" parent="." unique_id=1366355859]
[node name="StaticBody3D" type="StaticBody3D" parent="Node3D" unique_id=721553462]
[node name="CollisionShape3D" type="CollisionShape3D" parent="Node3D/StaticBody3D" unique_id=474515358]
transform = Transform3D(30, 0, 0, 0, 1, 0, 0, 0, 30, 0, -0.5, 0)
shape = SubResource("BoxShape3D_floor")
[node name="bear_level_arena" parent="." unique_id=948414670 instance=ExtResource("1_beararena")]
transform = Transform3D(2.875, 0, 0, 0, 2.875, 0, 0, 0, 2.875, 0, 0, 0)
[node name="MatadorSpawner" type="Node3D" parent="." unique_id=1869467620]
[node name="Lights" type="Node3D" parent="." unique_id=1435665429]
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="Lights" unique_id=1814200565]
transform = Transform3D(0.01535992, 0.56825536, -0.82270896, 0.16280846, 0.8104039, 0.56279576, 0.9865382, -0.14258848, -0.08006904, -4.08134, 9.197985, 0.97087634)
light_energy = 0.4
light_indirect_energy = 0.767
shadow_blur = 0.47
[node name="SpotLight3D2" type="SpotLight3D" parent="Lights" unique_id=2071037822]
transform = Transform3D(0.904133, 0.30962402, -0.2944087, 0.3084627, 0.0037425472, 0.9512291, 0.2956252, -0.95085174, -0.0921237, -3.629381, 10.86347, -2.0124264)
light_energy = 16.0
light_indirect_energy = 26.095
distance_fade_enabled = true
spot_range = 32.90586
spot_angle = 73.93
[node name="SUN_DirectionalLight3D2" type="DirectionalLight3D" parent="Lights" unique_id=1003843933]
transform = Transform3D(0.45811793, 0.34843865, -0.81775206, 0.5316611, 0.62986195, 0.56622505, 0.7123655, -0.69416475, 0.10329977, 0, 8.980043, 0)
light_energy = 0.106
shadow_enabled = true
shadow_blur = 10.0
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bm6rmt3xprhjs"
path="res://.godot/imported/bear_level_arena.glb-b6a9df3b338c8b0345fe2642d8b715eb.scn"
[deps]
source_file="res://Assets/bear_level_arena.glb"
dest_files=["res://.godot/imported/bear_level_arena.glb-b6a9df3b338c8b0345fe2642d8b715eb.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -28,7 +28,7 @@ meshes/generate_lods=true
meshes/create_shadow_meshes=true meshes/create_shadow_meshes=true
meshes/light_baking=1 meshes/light_baking=1
meshes/lightmap_texel_size=0.2 meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false meshes/force_disable_compression=true
skins/use_named_skins=true skins/use_named_skins=true
animation/import=true animation/import=true
animation/fps=30 animation/fps=30
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ml0ntjv5injp"
path="res://.godot/imported/bull_hud_bg.png-4645abe7114935a3d4a8e0f88ccce15c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://Assets/bull_hud_bg.png"
dest_files=["res://.godot/imported/bull_hud_bg.png-4645abe7114935a3d4a8e0f88ccce15c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bdbx0wwa73rhp"
path="res://.godot/imported/Bear_FX.glb-5bac655e61b5e140f6cd145298d00ca8.scn"
[deps]
source_file="res://Assets/fx/Bear_FX.glb"
dest_files=["res://.godot/imported/Bear_FX.glb-5bac655e61b5e140f6cd145298d00ca8.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://2e6fbder27w1"
path="res://.godot/imported/cubes_fx_test.glb-29490ce7011d666c1c4917a1a92679e4.scn"
[deps]
source_file="res://Assets/fx/cubes_fx_test.glb"
dest_files=["res://.godot/imported/cubes_fx_test.glb-29490ce7011d666c1c4917a1a92679e4.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

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

After

Width:  |  Height:  |  Size: 3.5 MiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bh5nuyudh7x44"
path="res://.godot/imported/loading_screen.png-253743b71eb7ab3f4f9e2b29cbabf407.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://Assets/loading_screen.png"
dest_files=["res://.godot/imported/loading_screen.png-253743b71eb7ab3f4f9e2b29cbabf407.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
-5
View File
@@ -1,5 +0,0 @@
extends Node3D
func _ready() -> void:
$AnimationPlayer.play("Taunt")
-1
View File
@@ -1 +0,0 @@
uid://bn3xwnlktwdlf
BIN
View File
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://jepc74w8opbu"
path="res://.godot/imported/spear.glb-0139879d219c93f9dd35db2304db7d5d.scn"
[deps]
source_file="res://Assets/spear.glb"
dest_files=["res://.godot/imported/spear.glb-0139879d219c93f9dd35db2304db7d5d.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
+287
View File
@@ -0,0 +1,287 @@
[gd_scene format=3 uid="uid://o8ocjfprxa7u"]
[ext_resource type="Script" uid="uid://cn7fhjkro7637" path="res://bear.gd" id="1_beargd"]
[ext_resource type="PackedScene" uid="uid://cofv5dbavesis" path="res://Assets/Bear.glb" id="2_bearglb"]
[ext_resource type="Script" uid="uid://bsbed05ifr18s" path="res://bear_test_cube.gd" id="3_rog1v"]
[ext_resource type="PackedScene" uid="uid://bdbx0wwa73rhp" path="res://Assets/fx/Bear_FX.glb" id="4_rog1v"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"]
radius = 1.0
height = 2.4
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_hit"]
radius = 1.3
height = 2.7
[node name="Bear" type="CharacterBody3D" unique_id=1573237287]
script = ExtResource("1_beargd")
[node name="CollisionShape3D" type="CollisionShape3D" parent="." unique_id=830443382]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.2, 0)
shape = SubResource("CapsuleShape3D_body")
[node name="HitArea" type="Area3D" parent="." unique_id=365327397]
monitorable = false
[node name="CollisionShape3D" type="CollisionShape3D" parent="HitArea" unique_id=2111867348]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.35, 0)
shape = SubResource("CapsuleShape3D_hit")
[node name="bear_model" parent="." unique_id=359217823 instance=ExtResource("2_bearglb")]
[node name="Skeleton3D" parent="bear_model/bear-rig" parent_id_path=PackedInt32Array(359217823, 1619621659) index="0" unique_id=1324818366]
bones/1/position = Vector3(0.04601016, 1.9209582, 0.9051311)
bones/1/rotation = Quaternion(0.5270001, -0.011489415, -0.05297156, 0.848135)
bones/2/rotation = Quaternion(0.29015693, 0.002787927, 0.00639222, 0.9569537)
bones/2/scale = Vector3(0.9999999, 1.0000008, 0.99999934)
bones/3/rotation = Quaternion(0.06707396, 0.012082217, 0.011868715, 0.9976043)
bones/4/rotation = Quaternion(0.3487565, 0.010761848, 0.013076123, 0.93706036)
bones/5/rotation = Quaternion(0.15472108, -0.21109715, -0.09227382, 0.96072114)
bones/6/rotation = Quaternion(0.4204113, 0.23492317, -0.11518617, 0.86879086)
bones/7/rotation = Quaternion(0.76830006, 0.36476925, -0.4668917, -0.24222025)
bones/8/rotation = Quaternion(0.5460264, -0.71257937, 0.40870878, -0.16444752)
bones/9/rotation = Quaternion(-0.02719195, -0.14156067, -0.26938534, 0.9521832)
bones/10/rotation = Quaternion(-0.12630412, -0.39785445, -0.057911687, 0.90686566)
bones/11/rotation = Quaternion(0.70209646, -0.35357565, 0.59454584, -0.16899753)
bones/12/rotation = Quaternion(0.5294657, 0.7712517, -0.33475664, -0.1130258)
bones/13/rotation = Quaternion(-0.04075809, 0.17025389, 0.28913936, 0.94114333)
bones/14/rotation = Quaternion(-0.03496992, 0.2568669, -0.0008229511, 0.9658136)
bones/15/rotation = Quaternion(0.9957146, -0.032195006, 0.031220285, 0.08087847)
bones/16/rotation = Quaternion(0.9367782, -0.012506716, 0.033659726, 0.3480767)
bones/17/rotation = Quaternion(-0.860235, -0.08192984, 0.009118035, 0.50319016)
bones/17/scale = Vector3(0.9999998, 1.0000002, 1)
bones/19/rotation = Quaternion(-4.514978e-15, 1, 7.4505806e-08, -5.523351e-07)
bones/20/rotation = Quaternion(0.9729887, -0.014750167, 0.014303936, 0.22993676)
bones/21/rotation = Quaternion(0.9366144, -0.00601367, 0.016095275, 0.34994036)
bones/22/rotation = Quaternion(-0.7732739, -0.06955802, 0.0075612683, 0.6302)
bones/22/scale = Vector3(0.9999997, 0.9999998, 0.9999999)
bones/24/rotation = Quaternion(6.628114e-12, 0.99999994, 5.2154085e-08, -0.00035543842)
bones/25/position = Vector3(1.0117401, 1.3159828, 3.9725027)
bones/25/rotation = Quaternion(-0.27285534, 0.9274522, 0.08017836, 0.2428039)
bones/25/scale = Vector3(1.0000001, 12.140265, 1.0000001)
bones/26/position = Vector3(-0.9025072, 1.4953425, 4.006362)
bones/26/rotation = Quaternion(0.5100001, 0.6936306, 0.15438241, -0.48470873)
bones/26/scale = Vector3(0.99999994, 11.348344, 1)
bones/27/position = Vector3(0.70899034, 1.0356265, 0.611243)
bones/27/rotation = Quaternion(0.7965308, 0.01196337, -0.0119632045, 0.6043613)
bones/27/scale = Vector3(0.99999994, 29.328602, 1)
bones/28/position = Vector3(-0.9047001, 1.0118608, 0.8510791)
bones/28/rotation = Quaternion(0.776662, 0.010376095, -0.010376263, 0.6297467)
bones/28/scale = Vector3(1, 26.535925, 1)
bones/30/position = Vector3(0.07262612, 0.67294323, -1.1644177)
bones/30/rotation = Quaternion(-0.7183768, 0.04937226, -0.02237074, 0.69353926)
bones/31/position = Vector3(5.9604645e-08, -1.4631644, 0.23291233)
bones/31/rotation = Quaternion(1.1641533e-09, 1.8335415e-09, 2.328307e-10, 1)
bones/31/scale = Vector3(0.99999994, 0.9999999, 0.99999994)
bones/32/position = Vector3(-0.0331195, -2.362161, -0.036310136)
bones/32/rotation = Quaternion(0.20497073, -0.020519394, -0.033738203, 0.9779712)
bones/32/scale = Vector3(0.9999999, 0.9999999, 0.99999994)
bones/33/position = Vector3(8.195639e-08, -1.4631642, 0.23291235)
bones/33/rotation = Quaternion(-8.288772e-08, 3.4342527e-09, -4.1909525e-09, 1)
bones/33/scale = Vector3(0.9999998, 0.9999998, 0.99999994)
bones/35/position = Vector3(-7.893884e-09, -1.0594703, 5.2079617e-08)
bones/35/rotation = Quaternion(0.99971783, 1.2737803e-07, 5.365157e-06, -0.023754692)
bones/35/scale = Vector3(0.99999976, 0.9999998, 1.0000012)
bones/39/position = Vector3(0.8199172, 0.25251278, 0.47214246)
bones/39/rotation = Quaternion(0.9961935, -0.035078514, -0.0045541152, 0.07967066)
bones/39/scale = Vector3(1.0000004, 0.9999996, 0.9999991)
bones/40/rotation = Quaternion(0.93738276, 1.2575202e-07, 3.4991626e-08, 0.3483009)
bones/40/scale = Vector3(1.0000004, 1.0000001, 1.0000001)
bones/41/rotation = Quaternion(-0.860235, -0.081929855, 0.009117997, 0.5031899)
bones/41/scale = Vector3(0.99999946, 0.99999976, 0.9999996)
bones/42/position = Vector3(-1.9729964e-07, 0.56145483, 7.283501e-08)
bones/46/scale = Vector3(0.99999946, 0.99999964, 0.9999999)
bones/48/rotation = Quaternion(9.8842406e-08, -0.054423165, -1.786034e-08, 0.998518)
bones/48/scale = Vector3(0.9999993, 0.9999996, 0.9999999)
bones/50/scale = Vector3(0.99999994, 0.99999994, 0.9999998)
bones/51/rotation = Quaternion(-6.30798e-08, 0.026635498, 6.253992e-09, 0.99964523)
bones/51/scale = Vector3(0.9999999, 0.99999964, 0.99999964)
bones/53/position = Vector3(-0.8199191, 0.25251284, 0.47213903)
bones/53/rotation = Quaternion(0.9730908, -0.0186988, -0.0024160824, 0.22964936)
bones/53/scale = Vector3(1, 0.99999875, 0.99999946)
bones/54/rotation = Quaternion(0.9367526, -1.1281111e-07, -3.6940495e-08, 0.34999225)
bones/55/rotation = Quaternion(-0.77327377, -0.06955799, 0.007561261, 0.6302002)
bones/55/scale = Vector3(1.0000002, 1, 1.0000002)
bones/56/position = Vector3(-5.9126133e-08, 0.5614537, 7.70865e-07)
bones/57/rotation = Quaternion(0.70073944, -0.70073956, -0.094679825, -0.09467953)
bones/60/scale = Vector3(1.0000004, 1.0000004, 1.0000002)
bones/62/rotation = Quaternion(5.8764627e-08, -0.042629674, 2.4442278e-08, 0.99909097)
bones/62/scale = Vector3(1.0000002, 1.0000002, 1.0000001)
bones/64/scale = Vector3(1.0000004, 1.0000004, 1)
bones/65/rotation = Quaternion(8.029546e-08, 0.01272784, 4.3857337e-08, 0.999919)
bones/65/scale = Vector3(1.0000002, 1.0000004, 1.0000004)
bones/67/rotation = Quaternion(0.7179022, -7.671307e-07, 7.885642e-07, 0.696144)
bones/67/scale = Vector3(1.0000004, 0.9999986, 0.99999994)
bones/68/rotation = Quaternion(0.9776482, 2.0029378e-07, 1.0460874e-06, -0.2102479)
bones/68/scale = Vector3(1.0000004, 1.0000004, 0.9999982)
bones/69/position = Vector3(-2.4645832e-08, -6.861796e-09, 7.455772e-08)
bones/69/rotation = Quaternion(-0.8315212, 2.5276703e-08, -9.3330055e-09, 0.5554931)
bones/69/scale = Vector3(1, 1.0000001, 1.0000002)
bones/70/rotation = Quaternion(0.67750055, 8.820757e-08, 7.575834e-08, 0.7355223)
bones/70/scale = Vector3(1, 1, 1)
bones/71/scale = Vector3(0.9999999, 1, 0.9999999)
bones/72/rotation = Quaternion(-2.1641608e-07, -4.6565607e-10, -4.6576065e-10, 1)
bones/75/rotation = Quaternion(0.25299338, 0.020847347, 0.028576687, 0.9668212)
bones/76/position = Vector3(1.01587794e-07, -1.5306958e-07, -4.3554866e-07)
bones/76/rotation = Quaternion(-0.5206146, -3.634943e-08, 7.6474e-08, 0.8537919)
bones/77/rotation = Quaternion(0.9373828, 1.2785249e-07, 3.3241214e-08, 0.3483008)
bones/78/position = Vector3(0.8199172, 0.25251257, 0.47214243)
bones/78/rotation = Quaternion(0.99571455, -0.032195, 0.031220287, 0.08087897)
bones/78/scale = Vector3(1.0000004, 0.9999997, 0.99999905)
bones/80/rotation = Quaternion(0.7179022, -7.6713064e-07, 7.885641e-07, 0.696144)
bones/80/scale = Vector3(1.0000004, 0.9999986, 0.99999994)
bones/81/rotation = Quaternion(0.97764814, 1.9846473e-07, 1.0426893e-06, -0.21024783)
bones/81/scale = Vector3(1.0000004, 1.0000002, 0.9999982)
bones/82/position = Vector3(6.103571e-08, 8.8816435e-08, -3.6955274e-09)
bones/82/rotation = Quaternion(-0.8315211, 2.3407136e-08, -1.2821512e-08, 0.5554932)
bones/82/scale = Vector3(0.99999994, 0.99999994, 1)
bones/83/rotation = Quaternion(0.6775009, -8.600083e-08, -8.977273e-08, 0.735522)
bones/83/scale = Vector3(1, 1.0000001, 1)
bones/84/scale = Vector3(0.99999994, 1.0000001, 0.9999999)
bones/85/rotation = Quaternion(2.6833732e-07, -9.313788e-10, -2.5612494e-09, 1)
bones/85/scale = Vector3(1.0000001, 1, 1.0000001)
bones/87/rotation = Quaternion(-0.5230753, 0.20132942, 0.026226493, 0.8277505)
bones/88/rotation = Quaternion(0.10320552, 0.011111915, 0.015231778, 0.99448144)
bones/89/position = Vector3(2.953037e-07, 4.9207458e-08, -4.3015167e-08)
bones/89/rotation = Quaternion(-0.5201237, 5.177172e-08, -1.16139375e-07, 0.8540909)
bones/90/rotation = Quaternion(0.9367527, -1.0975643e-07, -3.3382268e-08, 0.34999216)
bones/91/position = Vector3(-0.8199192, 0.2525128, 0.47213912)
bones/91/rotation = Quaternion(0.9729887, -0.0147501845, 0.014303951, 0.22993667)
bones/91/scale = Vector3(1.0000002, 0.999999, 0.99999964)
bones/96/position = Vector3(-0.016559705, -1.9126627, 0.09830098)
bones/96/rotation = Quaternion(0.10305436, -0.010316663, -0.016962789, 0.99447757)
bones/96/scale = Vector3(0.9999998, 1, 1)
bones/97/rotation = Quaternion(0.997074, -1.2813135e-07, 1.666339e-06, -0.07644201)
bones/98/position = Vector3(0.0013585633, -0.23419222, 0.016214281)
bones/98/rotation = Quaternion(-0.15130657, -0.005602528, -0.008209973, 0.98843694)
bones/98/scale = Vector3(1.0000001, 1.0000006, 1.0000029)
bones/101/position = Vector3(-0.016559973, 1.4684486, 0.06451648)
bones/101/rotation = Quaternion(0.99944556, 0.016124567, -0.011581497, -0.02673253)
bones/101/scale = Vector3(1.0000001, 0.9999993, 0.99999183)
bones/102/rotation = Quaternion(0.95545256, -6.924111e-08, 2.2887033e-07, -0.29514462)
bones/103/position = Vector3(-0.00705543, 0.6694702, -0.06780842)
bones/103/rotation = Quaternion(0.15471841, -0.21109664, -0.09227309, 0.9607217)
bones/103/scale = Vector3(1.0000005, 1.0000286, 1.0000087)
bones/104/rotation = Quaternion(0.420411, 0.23492327, -0.115186565, 0.86879104)
bones/104/scale = Vector3(0.9999993, 0.9999884, 0.9999856)
bones/105/rotation = Quaternion(0.91976035, -0.013162326, 0.0148637965, 0.39197809)
bones/105/scale = Vector3(1, 1.0000005, 1.0000114)
bones/106/position = Vector3(-0.010389687, -0.091948025, 0.0016658108)
bones/106/rotation = Quaternion(0.82734275, 0.15726796, -0.14167279, -0.52028793)
bones/107/rotation = Quaternion(0.77328545, 0.16233337, -0.107321426, 0.6034564)
bones/107/scale = Vector3(0.9999966, 0.99997723, 0.9999995)
bones/108/position = Vector3(-1.1340098e-08, 7.923448e-07, 9.0939814e-08)
bones/108/rotation = Quaternion(0.42463258, 0.043219328, 0.08968367, -0.89987564)
bones/110/rotation = Quaternion(5.443812e-06, 3.6259098e-09, -1.2069942e-06, 1)
bones/110/scale = Vector3(0.99999994, 0.99999976, 1.0000001)
bones/113/position = Vector3(0.08179821, 0.64012206, 0.7106584)
bones/113/rotation = Quaternion(0.7683008, 0.36476624, -0.46689337, -0.24221936)
bones/114/position = Vector3(0.08595797, 0.6463289, -0.32087424)
bones/114/rotation = Quaternion(0.5615046, -0.7056476, 0.3871702, -0.19202453)
bones/114/scale = Vector3(1.0000049, 1.0000027, 1.0000052)
bones/115/rotation = Quaternion(-0.037640397, -0.10443457, -0.2681242, 0.9569672)
bones/115/scale = Vector3(0.9999974, 1.0000036, 0.99999976)
bones/116/rotation = Quaternion(-0.12630361, -0.39785278, -0.057911348, 0.90686655)
bones/116/scale = Vector3(1.0000005, 0.9999972, 1.0000013)
bones/119/scale = Vector3(1.0000043, 0.9999975, 0.99999744)
bones/121/rotation = Quaternion(-2.7027176e-07, -0.20424414, 2.9341666e-07, 0.97892)
bones/121/scale = Vector3(1.0000037, 0.99999774, 0.9999977)
bones/123/scale = Vector3(1.000003, 0.9999977, 0.99999917)
bones/124/rotation = Quaternion(1.2542905e-06, -0.033494003, 5.941339e-07, 0.999439)
bones/124/scale = Vector3(1.0000037, 0.9999978, 0.99999875)
bones/126/rotation = Quaternion(-0.51635414, -0.1681693, 0.6340872, 0.5504825)
bones/126/scale = Vector3(1.0000056, 1.0000031, 1.0000038)
bones/127/rotation = Quaternion(-0.24332374, 0.12663902, 0.6612198, 0.6982439)
bones/127/scale = Vector3(1.0000057, 1.0000007, 1.0000064)
bones/128/position = Vector3(3.0230987e-07, -5.5011895e-07, -6.94335e-08)
bones/128/rotation = Quaternion(0.17027588, 0.93388194, -0.2967173, 0.104064964)
bones/129/rotation = Quaternion(-0.037640374, -0.10443452, -0.26812422, 0.9569671)
bones/131/rotation = Quaternion(-0.54023695, -0.3639527, 0.10910668, 0.7508517)
bones/132/rotation = Quaternion(-0.13163939, 0.9186296, -0.34996635, -0.12772812)
bones/133/rotation = Quaternion(-0.012778445, 0.011854936, 0.00015161023, 0.99984807)
bones/133/scale = Vector3(1.0422997, 1.0422999, 1.0422992)
bones/134/rotation = Quaternion(0.02152282, -2.7759576e-08, -1.5735115e-07, 0.99976844)
bones/134/scale = Vector3(0.98826313, 0.9882641, 0.9882627)
bones/135/position = Vector3(0.08595812, 0.646329, -0.3208742)
bones/135/rotation = Quaternion(0.5460276, -0.7125794, 0.40870702, -0.16444758)
bones/135/scale = Vector3(1.000007, 1.0000004, 1.0000051)
bones/137/position = Vector3(-0.09762837, 0.6401221, 0.7106583)
bones/137/rotation = Quaternion(0.7020965, -0.35357338, 0.5945473, -0.16899663)
bones/138/position = Vector3(-0.08595776, 0.646329, -0.3208746)
bones/138/rotation = Quaternion(0.5492876, 0.7627154, -0.30113, -0.16083841)
bones/138/scale = Vector3(1.0000051, 1.0000018, 1.0000046)
bones/139/rotation = Quaternion(-0.058682628, 0.11130926, 0.28603986, 0.94991994)
bones/139/scale = Vector3(0.9999983, 1.0000024, 0.9999992)
bones/140/rotation = Quaternion(-0.034969915, 0.25686622, -0.0008231461, 0.96581376)
bones/140/scale = Vector3(1.0000031, 0.9999977, 0.9999988)
bones/143/scale = Vector3(1.000005, 0.99999815, 0.9999967)
bones/145/rotation = Quaternion(-4.0431777e-07, 0.1295653, -5.1404834e-07, 0.99157095)
bones/145/scale = Vector3(1.0000045, 0.99999803, 0.9999968)
bones/147/scale = Vector3(1.0000035, 0.99999845, 0.9999981)
bones/148/rotation = Quaternion(1.9343124e-06, 0.025161995, -3.222267e-07, 0.9996834)
bones/148/scale = Vector3(1.000004, 0.9999991, 0.999998)
bones/150/rotation = Quaternion(-0.5163542, 0.16816999, -0.6340877, 0.5504816)
bones/150/scale = Vector3(1.0000052, 1.0000039, 1.0000025)
bones/151/rotation = Quaternion(-0.24332343, -0.1266389, -0.6612192, 0.6982446)
bones/151/scale = Vector3(1.0000049, 1.0000005, 1.0000063)
bones/152/position = Vector3(-2.0525843e-07, -4.4378166e-07, 2.2242949e-07)
bones/152/rotation = Quaternion(-0.1980554, 0.9486651, -0.20058647, -0.14343536)
bones/153/rotation = Quaternion(-0.058682676, 0.11130926, 0.2860399, 0.94991994)
bones/155/rotation = Quaternion(-0.48170745, 0.21343125, -0.09362694, 0.84477156)
bones/155/scale = Vector3(0.99999994, 1, 0.9999998)
bones/156/rotation = Quaternion(0.2123156, 0.9450362, -0.15822437, 0.19181721)
bones/157/rotation = Quaternion(-0.01277751, -0.011854968, -0.0001513001, 0.9998481)
bones/157/scale = Vector3(1.0423003, 1.0422994, 1.0423005)
bones/158/rotation = Quaternion(0.021521209, -2.5687226e-07, -2.570953e-07, 0.9997684)
bones/158/scale = Vector3(0.9882631, 0.98826426, 0.9882618)
bones/159/position = Vector3(-0.085957706, 0.6463291, -0.32087478)
bones/159/rotation = Quaternion(0.5294669, 0.7712517, -0.33475488, -0.1130262)
bones/159/scale = Vector3(1.0000075, 1.0000001, 1.0000042)
bones/161/position = Vector3(0.08179821, 0.64012206, 0.7106584)
bones/161/rotation = Quaternion(0.7683008, 0.36476624, -0.46689337, -0.24221936)
bones/162/position = Vector3(-0.09762837, 0.6401221, 0.7106583)
bones/162/rotation = Quaternion(0.7020965, -0.35357338, 0.5945473, -0.16899663)
bones/165/position = Vector3(-1.5017577e-08, 0.26895812, -1.6243575e-08)
bones/165/rotation = Quaternion(-0.019247467, -0.20323175, 0.97894067, -0.0012537595)
bones/166/position = Vector3(0.016703974, -2.2814677, 2.0323193)
bones/167/position = Vector3(0.7698192, 2.760036, -1.7026894)
bones/167/rotation = Quaternion(-0.93479383, -0.013274327, 0.31412983, 0.16524744)
bones/167/scale = Vector3(0.9999947, 0.9999997, 1.0000055)
bones/168/position = Vector3(0.18381567, -0.9622623, 0.9894084)
bones/168/rotation = Quaternion(0.93622446, 1.3019375e-07, -0.34127164, -0.08377019)
bones/168/scale = Vector3(1.0000024, 1.0000011, 1.0000095)
bones/170/position = Vector3(0.748137, 0.69556385, 3.7252903e-09)
bones/170/rotation = Quaternion(0.019250846, -0.20323181, 0.9789406, 0.001253055)
bones/171/position = Vector3(0.381392, -2.057497, 2.0762825)
bones/172/position = Vector3(-0.9278099, 2.7545004, -1.5181485)
bones/172/rotation = Quaternion(-0.91823035, 0.06278634, -0.38804358, 0.04830265)
bones/172/scale = Vector3(0.99999493, 0.9999995, 1.0000055)
bones/173/position = Vector3(0.18381546, -0.96226203, 0.9894083)
bones/173/rotation = Quaternion(0.9362244, 3.755922e-07, 0.3412715, -0.0837717)
bones/173/scale = Vector3(1.0000001, 1.0000001, 0.9999949)
bones/183/position = Vector3(-1.982537e-07, 5.9604563e-08, -1.517833e-08)
bones/185/rotation = Quaternion(5.4077844e-07, -0.20349081, 0.97907686, -1.12395135e-07)
bones/186/position = Vector3(0.78680634, 2.8457582, -0.41063747)
bones/186/rotation = Quaternion(-0.9983755, 0.019092962, -0.050729975, -0.017562928)
bones/186/scale = Vector3(0.9999997, 0.99999994, 1)
bones/187/rotation = Quaternion(0.15451065, -2.0305349e-09, -2.0850066e-09, 0.9879911)
bones/187/scale = Vector3(1.0000002, 0.99999994, 0.9999999)
bones/197/position = Vector3(7.903883e-08, 1.4901151e-07, 8.8932055e-08)
bones/199/rotation = Quaternion(0.00034800146, -0.20349097, 0.9790768, -7.2328476e-05)
bones/200/position = Vector3(-0.84339374, 2.905353, -0.57784474)
bones/200/rotation = Quaternion(-0.9983755, 0.019092962, -0.050729975, -0.017562928)
bones/200/scale = Vector3(0.9999997, 0.99999994, 1)
bones/201/rotation = Quaternion(0.15451066, -1.9585848e-09, -2.5450761e-09, 0.9879911)
bones/201/scale = Vector3(1.0000002, 0.99999994, 0.99999994)
[node name="test_cube" type="Node3D" parent="." unique_id=646988893]
script = ExtResource("3_rog1v")
[node name="Bear_FX" parent="test_cube" unique_id=1183391245 instance=ExtResource("4_rog1v")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.1459732, 0, -1.528423)
[node name="Bear_fx_claw_attack_R" parent="test_cube/Bear_FX" index="1" unique_id=941189652]
transform = Transform3D(0.26165774, 0.06970292, -0.23443498, -0.046572562, 0.18028688, 0.0016228362, 0.22878301, 0.056650188, 0.2721929, -0.40204993, 0.29855162, 0.917461)
[editable path="bear_model"]
[editable path="test_cube/Bear_FX"]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+17 -3
View File
@@ -12,13 +12,25 @@ I am a **Godot 4.6+ expert**. I follow current best practices for GDScript, scen
| Path | Purpose | | Path | Purpose |
|---|---| |---|---|
| `scene.tscn` | Main scene (arena + player) | | `scene.tscn` | The reusable **shell** loaded for every level: player, camera, HUD, PS1 filter, and an empty `LevelRoot`. Its `game_shell.gd` instances the active level's module into `LevelRoot` at runtime |
| `levels/` | The level system: per-level module scenes plus the code that defines, loads and sequences them (see rows below) |
| `levels/game_shell.gd` | Shell controller — on load, frees any current level module and instances `Run.active_level().level_scene` into `LevelRoot`. Swapping the whole subtree (never hiding it) is what keeps one level's meshes/colliders/spawners from leaking into the next |
| `levels/arena_level.tscn` | The arena **level module**: floor + wall colliders, arena meshes, `MatadorSpawner` (with child `Node3D` spawn points placed by the designer), lighting, audience (`PublikNode`). Instanced into the shell's `LevelRoot` |
| `levels/bear_level.tscn` | The Bear's Den **level module**: bear-arena mesh, its own floor collider, lighting, and a `BearSpawner` (`bear_spawn.gd`) that places the bear at center. Instanced into the shell's `LevelRoot` |
| `Player.tscn` | Player scene root (`CharacterBody3D`) | | `Player.tscn` | Player scene root (`CharacterBody3D`) |
| `player.gd` | Movement, charge, turning logic | | `player.gd` | Movement, charge, turning logic |
| `camera_spring_arm.gd` | Mouse-look pivot (`Node3D` + `SpringArm3D`) | | `camera_spring_arm.gd` | Mouse-look pivot (`Node3D` + `SpringArm3D`) |
| `camera_follow.gd` | Smooth camera follow (`Camera3D`) | | `camera_follow.gd` | Smooth camera follow (`Camera3D`) |
| `matador.gd` | Matador AI (wander / ragdoll state machine) | | `matador.gd` | Matador AI (wander / ragdoll state machine) |
| `matador_spawn.gd` | Spawns N matadors at random arena positions | | `levels/matador_spawn.gd` | Arena spawner — one matador per designer-placed child `Node3D` spawn point (each enters with a jump-roll toward center); falls back to a programmatic spread if no spawn points are present. Also handles the split-on-death hydra logic |
| `levels/bear_spawn.gd` | Bear-level spawner — instantiates `enemy_scene` at the arena center; emits the same `all_defeated` / `matador_killed` signals as `matador_spawn.gd` |
| `Bear.tscn` / `bear.gd` | Dark-Souls-style bear boss (`CharacterBody3D`). Routine state machine (wind-up → active → recovery) picking telegraphed moves by range band: Double Swipe / Overhead Smash (close), Leap Slam (mid-far), plus Stalk (2-leg walk-in) / Prowl (4-leg lope) positioning. Melee steps in during the swing (`bear_lunge_speed`) + tracks the bull; the leap is frame-synced to JUMP_SMASH so the paws hit ground on the impact frame (`bear_leap_impact_frame`); slams kick up dust + shake. Combos chain, hyper-armour mid-swing + poise (`bear_stagger_cd`), phase 2 enrage under `bear_enrage_frac` HP. Never damages the bull by colliding — only swings/slams. Joins `&"matador"` so bull abilities already hit it. Tuning under the `Bear` DP section |
| `levels/run_state.gd` | Run progression autoload (`Run`) — Slay-the-Spire map + player position across fights |
| `levels/level_def.gd` | `LevelDef` resource — a level's name / map colour / `scene_path` (always the shell) / `level_scene` (the level module instanced into the shell) / optional `enemy_scene` (arena wave vs. a boss like the bear) |
| `levels/map_node.gd` | `MapNode` — one runtime node on the run map (position, level, links) |
| `levels/MapScreen.tscn` / `levels/map_screen.gd` | Between-fights map screen; win → pick a node → next level |
| `crowd_marker.gd` | `CrowdMarker` (`@tool Node3D`, `class_name`) — designer drops one per billboard spectator via the editor's Add-Node dialog; picks one of six 60°-spaced facings from the `orientation` dropdown. Draws an editor-only preview (slab + forward arrow, never serialised) and joins the `&"crowd_marker"` group |
| `crowd_billboards.gd` / `crowd_billboard.gdshader` | `MultiMeshInstance3D` that harvests every `CrowdMarker` (group `&"crowd_marker"`) into one draw call, using each marker's world position + chosen facing. Front/back sprite sheet baked by `tools/bake_crowd_sheet.gd`; falls back to a generated inward-facing ring when no markers are placed |
| `debug_params.gd` | Runtime-tunable parameter registry (autoload `DP`) | | `debug_params.gd` | Runtime-tunable parameter registry (autoload `DP`) |
| `Assets/` | Raw 3D assets (`.glb`, `.fbx`) | | `Assets/` | Raw 3D assets (`.glb`, `.fbx`) |
| `Blender/` | Blender source files, animation scripts, FBX exports | | `Blender/` | Blender source files, animation scripts, FBX exports |
@@ -88,10 +100,12 @@ After FBX import, animations appear as `Armature|walk` and `Armature|idle` in An
bash run_tests.sh bash run_tests.sh
# Individual tests: # Individual tests:
gdlint *.gd tests/*.gd # GDScript lint (gdtoolkit, installed via uv) gdlint *.gd levels/*.gd tests/*.gd # GDScript lint (gdtoolkit, installed via uv)
godot --headless --script tests/logic_test.gd # pure logic, ~2 s godot --headless --script tests/logic_test.gd # pure logic, ~2 s
godot --headless --script tests/performance_test.gd # frame-budget regression guard, ~4 s godot --headless --script tests/performance_test.gd # frame-budget regression guard, ~4 s
godot --headless --script tests/gameplay_test.gd # full scene, bone sanity, ~4 s godot --headless --script tests/gameplay_test.gd # full scene, bone sanity, ~4 s
godot --headless --script tests/level_switch_test.gd # no arena geometry leaks into the bear level, ~2 s
godot --headless --script tests/bear_boss_test.gd # bear boss moveset: routines, leap landing, hyper-armour, ~10 s
# Gameplay test with real rendering (inspect frame strip visually): # Gameplay test with real rendering (inspect frame strip visually):
godot --script tests/gameplay_test.gd # opens a window, saves real screenshots godot --script tests/gameplay_test.gd # opens a window, saves real screenshots
@@ -4,15 +4,16 @@ importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://bxm7c6ldrdiq5" uid="uid://bxm7c6ldrdiq5"
path.bptc="res://.godot/imported/citrus_orchard_road_puresky_4k.hdr-262868e8787abf05f7044da72beefce7.bptc.ctex" path.bptc="res://.godot/imported/citrus_orchard_road_puresky_4k.hdr-262868e8787abf05f7044da72beefce7.bptc.ctex"
path.astc="res://.godot/imported/citrus_orchard_road_puresky_4k.hdr-262868e8787abf05f7044da72beefce7.astc.ctex"
metadata={ metadata={
"imported_formats": ["s3tc_bptc"], "imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true "vram_texture": true
} }
[deps] [deps]
source_file="res://HDRI/citrus_orchard_road_puresky_4k.hdr" source_file="res://HDRI/citrus_orchard_road_puresky_4k.hdr"
dest_files=["res://.godot/imported/citrus_orchard_road_puresky_4k.hdr-262868e8787abf05f7044da72beefce7.bptc.ctex"] dest_files=["res://.godot/imported/citrus_orchard_road_puresky_4k.hdr-262868e8787abf05f7044da72beefce7.bptc.ctex", "res://.godot/imported/citrus_orchard_road_puresky_4k.hdr-262868e8787abf05f7044da72beefce7.astc.ctex"]
[params] [params]
+3 -2
View File
@@ -4,15 +4,16 @@ importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://3syhacswepyj" uid="uid://3syhacswepyj"
path.bptc="res://.godot/imported/kloppenheim_06_puresky_4k.hdr-026f0eabdfce50035e2f8417ce3e0060.bptc.ctex" path.bptc="res://.godot/imported/kloppenheim_06_puresky_4k.hdr-026f0eabdfce50035e2f8417ce3e0060.bptc.ctex"
path.astc="res://.godot/imported/kloppenheim_06_puresky_4k.hdr-026f0eabdfce50035e2f8417ce3e0060.astc.ctex"
metadata={ metadata={
"imported_formats": ["s3tc_bptc"], "imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true "vram_texture": true
} }
[deps] [deps]
source_file="res://HDRI/kloppenheim_06_puresky_4k.hdr" source_file="res://HDRI/kloppenheim_06_puresky_4k.hdr"
dest_files=["res://.godot/imported/kloppenheim_06_puresky_4k.hdr-026f0eabdfce50035e2f8417ce3e0060.bptc.ctex"] dest_files=["res://.godot/imported/kloppenheim_06_puresky_4k.hdr-026f0eabdfce50035e2f8417ce3e0060.bptc.ctex", "res://.godot/imported/kloppenheim_06_puresky_4k.hdr-026f0eabdfce50035e2f8417ce3e0060.astc.ctex"]
[params] [params]
+3 -3
View File
@@ -51,7 +51,7 @@ dof_blur_near_transition = 5.0
transform = Transform3D(-0.56715536, 0, -0.8236109, 0, 1, 0, 0.8236109, 0, -0.56715536, 5.2114463, -0.08998108, 1.8927763) transform = Transform3D(-0.56715536, 0, -0.8236109, 0, 1, 0, 0.8236109, 0, -0.56715536, 5.2114463, -0.08998108, 1.8927763)
script = ExtResource("2_5jr0i") script = ExtResource("2_5jr0i")
[node name="Skeleton3D" parent="Matador/Armature" parent_id_path=PackedInt32Array(166852612, 1269629432) index="0" unique_id=978366810] [node name="Skeleton3D" parent="Matador/Armature" parent_id_path=PackedInt32Array(166852612, 1447304122) index="0" unique_id=357848305]
bones/0/position = Vector3(0.056660548, 0.6424767, -0.0167365) bones/0/position = Vector3(0.056660548, 0.6424767, -0.0167365)
bones/0/rotation = Quaternion(0.013179442, -0.2660563, 0.073319815, 0.9610747) bones/0/rotation = Quaternion(0.013179442, -0.2660563, 0.073319815, 0.9610747)
bones/1/rotation = Quaternion(0.06234612, 0.049236443, 0.08183311, 0.99347484) bones/1/rotation = Quaternion(0.06234612, 0.049236443, 0.08183311, 0.99347484)
@@ -81,10 +81,10 @@ bones/23/rotation = Quaternion(0.9993525, -0.0021175682, 0.03502641, -0.00795782
bones/24/rotation = Quaternion(0.08874816, -0.018719967, -0.04671622, 0.99478185) bones/24/rotation = Quaternion(0.08874816, -0.018719967, -0.04671622, 0.99478185)
bones/25/rotation = Quaternion(0.09634846, -0.0268489, -0.04050248, 0.9941608) bones/25/rotation = Quaternion(0.09634846, -0.0268489, -0.04050248, 0.9941608)
[node name="matador" parent="Matador/Armature/Skeleton3D" index="0" unique_id=256196413] [node name="matador" parent="Matador/Armature/Skeleton3D" index="0" unique_id=593562242]
transform = Transform3D(0.8642424, 0, 0.50307554, 0, 1, 0, -0.50307554, 0, 0.8642424, 0, 0, 0) transform = Transform3D(0.8642424, 0, 0.50307554, 0, 1, 0, -0.50307554, 0, 0.8642424, 0, 0, 0)
[node name="Muleta" parent="Matador/Armature/Skeleton3D" index="1" unique_id=988244930] [node name="Muleta" parent="Matador/Armature/Skeleton3D" index="1" unique_id=978680467]
cast_shadow = 0 cast_shadow = 0
[node name="Camera3D" type="Camera3D" parent="." unique_id=1531255459] [node name="Camera3D" type="Camera3D" parent="." unique_id=1531255459]
+82 -39
View File
@@ -1,7 +1,13 @@
[gd_scene load_steps=5 format=3 uid="uid://bmnmenu0intr1"] [gd_scene format=3 uid="uid://bmnmenu0intr1"]
[ext_resource type="Script" path="res://main_menu.gd" id="1_menu"] [ext_resource type="Script" uid="uid://46y10alai56q" path="res://main_menu.gd" id="1_menu"]
[ext_resource type="VideoStream" path="res://Assets/intro_bg.ogv" id="2_video"] [ext_resource type="VideoStream" uid="uid://csxxlnq74fej4" path="res://Assets/intro_bg.ogv" id="2_video"]
[ext_resource type="Shader" uid="uid://bmdtg6qlh7wri" path="res://menu_blur.gdshader" id="3_blur"]
[sub_resource type="ShaderMaterial" id="mat_blur"]
shader = ExtResource("3_blur")
shader_parameter/blur_radius = 3.0
shader_parameter/darken = 0.35
[sub_resource type="StyleBoxFlat" id="sb_normal"] [sub_resource type="StyleBoxFlat" id="sb_normal"]
content_margin_left = 16.0 content_margin_left = 16.0
@@ -30,7 +36,7 @@ corner_radius_top_right = 8
corner_radius_bottom_right = 8 corner_radius_bottom_right = 8
corner_radius_bottom_left = 8 corner_radius_bottom_left = 8
[node name="MainMenu" type="Control"] [node name="MainMenu" type="Control" unique_id=371457725]
layout_mode = 3 layout_mode = 3
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
@@ -39,29 +45,54 @@ grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
script = ExtResource("1_menu") script = ExtResource("1_menu")
[node name="VideoStreamPlayer" type="VideoStreamPlayer" parent="."] [node name="VideoBlur" type="AspectRatioContainer" parent="." unique_id=374854263]
layout_mode = 1 layout_mode = 1
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
mouse_filter = 2
ratio = 1.7778
stretch_mode = 3
[node name="BlurVideo" type="VideoStreamPlayer" parent="VideoBlur" unique_id=374854262]
material = SubResource("mat_blur")
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
mouse_filter = 2
stream = ExtResource("2_video") stream = ExtResource("2_video")
expand = true expand = true
[node name="MainPanel" type="Control" parent="."] [node name="VideoAspect" type="AspectRatioContainer" parent="." unique_id=374854264]
layout_mode = 1 layout_mode = 1
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
anchor_bottom = 1.0 anchor_bottom = 1.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
mouse_filter = 2
ratio = 1.7778
[node name="PlayButton" type="Button" parent="MainPanel"] [node name="VideoStreamPlayer" type="VideoStreamPlayer" parent="VideoAspect" unique_id=374854265]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
mouse_filter = 2
stream = ExtResource("2_video")
expand = true
[node name="CapeLayer" type="Control" parent="VideoAspect" unique_id=374854266]
layout_mode = 2
mouse_filter = 2
[node name="PlayButton" type="Button" parent="VideoAspect/CapeLayer" unique_id=743089847]
layout_mode = 1 layout_mode = 1
anchor_left = 0.4530 anchors_preset = -1
anchor_left = 0.453
anchor_top = 0.6513 anchor_top = 0.6513
anchor_right = 0.4530 anchor_right = 0.453
anchor_bottom = 0.6513 anchor_bottom = 0.6513
offset_left = -120.0 offset_left = -120.0
offset_top = -55.0 offset_top = -55.0
@@ -74,13 +105,23 @@ theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 8 theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 56 theme_override_font_sizes/font_size = 56
theme_override_styles/normal = SubResource("sb_normal") theme_override_styles/normal = SubResource("sb_normal")
theme_override_styles/hover = SubResource("sb_hover")
theme_override_styles/pressed = SubResource("sb_hover") theme_override_styles/pressed = SubResource("sb_hover")
theme_override_styles/hover = SubResource("sb_hover")
theme_override_styles/focus = SubResource("sb_hover") theme_override_styles/focus = SubResource("sb_hover")
text = "PLAY" text = "PLAY"
[node name="OptionsButton" type="Button" parent="MainPanel"] [node name="MainPanel" type="Control" parent="." unique_id=1263320918]
layout_mode = 1 layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="OptionsButton" type="Button" parent="MainPanel" unique_id=673211179]
layout_mode = 1
anchors_preset = -1
anchor_left = 0.11 anchor_left = 0.11
anchor_top = 0.94 anchor_top = 0.94
anchor_right = 0.11 anchor_right = 0.11
@@ -96,13 +137,14 @@ theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 6 theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 30 theme_override_font_sizes/font_size = 30
theme_override_styles/normal = SubResource("sb_normal") theme_override_styles/normal = SubResource("sb_normal")
theme_override_styles/hover = SubResource("sb_hover")
theme_override_styles/pressed = SubResource("sb_hover") theme_override_styles/pressed = SubResource("sb_hover")
theme_override_styles/hover = SubResource("sb_hover")
theme_override_styles/focus = SubResource("sb_hover") theme_override_styles/focus = SubResource("sb_hover")
text = "OPTIONS" text = "OPTIONS"
[node name="CreditsButton" type="Button" parent="MainPanel"] [node name="CreditsButton" type="Button" parent="MainPanel" unique_id=882339461]
layout_mode = 1 layout_mode = 1
anchors_preset = -1
anchor_left = 0.489 anchor_left = 0.489
anchor_top = 0.951 anchor_top = 0.951
anchor_right = 0.489 anchor_right = 0.489
@@ -118,13 +160,14 @@ theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 6 theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 30 theme_override_font_sizes/font_size = 30
theme_override_styles/normal = SubResource("sb_normal") theme_override_styles/normal = SubResource("sb_normal")
theme_override_styles/hover = SubResource("sb_hover")
theme_override_styles/pressed = SubResource("sb_hover") theme_override_styles/pressed = SubResource("sb_hover")
theme_override_styles/hover = SubResource("sb_hover")
theme_override_styles/focus = SubResource("sb_hover") theme_override_styles/focus = SubResource("sb_hover")
text = "CREDITS" text = "CREDITS"
[node name="ExitButton" type="Button" parent="MainPanel"] [node name="ExitButton" type="Button" parent="MainPanel" unique_id=1064586744]
layout_mode = 1 layout_mode = 1
anchors_preset = -1
anchor_left = 0.92 anchor_left = 0.92
anchor_top = 0.951 anchor_top = 0.951
anchor_right = 0.92 anchor_right = 0.92
@@ -140,12 +183,12 @@ theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 6 theme_override_constants/outline_size = 6
theme_override_font_sizes/font_size = 30 theme_override_font_sizes/font_size = 30
theme_override_styles/normal = SubResource("sb_normal") theme_override_styles/normal = SubResource("sb_normal")
theme_override_styles/hover = SubResource("sb_hover")
theme_override_styles/pressed = SubResource("sb_hover") theme_override_styles/pressed = SubResource("sb_hover")
theme_override_styles/hover = SubResource("sb_hover")
theme_override_styles/focus = SubResource("sb_hover") theme_override_styles/focus = SubResource("sb_hover")
text = "EXIT" text = "EXIT"
[node name="OptionsPanel" type="CenterContainer" parent="."] [node name="OptionsPanel" type="CenterContainer" parent="." unique_id=593823446]
visible = false visible = false
layout_mode = 1 layout_mode = 1
anchors_preset = 15 anchors_preset = 15
@@ -154,17 +197,17 @@ anchor_bottom = 1.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
[node name="Backdrop" type="ColorRect" parent="OptionsPanel"] [node name="Backdrop" type="ColorRect" parent="OptionsPanel" unique_id=1811982549]
layout_mode = 2 layout_mode = 2
color = Color(0, 0, 0, 0.55)
mouse_filter = 2 mouse_filter = 2
color = Color(0, 0, 0, 0.55)
[node name="VBox" type="VBoxContainer" parent="OptionsPanel"] [node name="VBox" type="VBoxContainer" parent="OptionsPanel" unique_id=2125052135]
custom_minimum_size = Vector2(560, 480) custom_minimum_size = Vector2(560, 480)
layout_mode = 2 layout_mode = 2
theme_override_constants/separation = 16 theme_override_constants/separation = 16
[node name="TitleLabel" type="Label" parent="OptionsPanel/VBox"] [node name="TitleLabel" type="Label" parent="OptionsPanel/VBox" unique_id=853979265]
layout_mode = 2 layout_mode = 2
theme_override_colors/font_outline_color = Color(0, 0, 0, 1) theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 8 theme_override_constants/outline_size = 8
@@ -172,7 +215,7 @@ theme_override_font_sizes/font_size = 44
text = "Controls" text = "Controls"
horizontal_alignment = 1 horizontal_alignment = 1
[node name="Hint" type="Label" parent="OptionsPanel/VBox"] [node name="Hint" type="Label" parent="OptionsPanel/VBox" unique_id=1809504670]
layout_mode = 2 layout_mode = 2
theme_override_colors/font_outline_color = Color(0, 0, 0, 1) theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 5 theme_override_constants/outline_size = 5
@@ -180,24 +223,24 @@ theme_override_font_sizes/font_size = 20
text = "Click a key, then press a new key. Esc cancels." text = "Click a key, then press a new key. Esc cancels."
horizontal_alignment = 1 horizontal_alignment = 1
[node name="Scroll" type="ScrollContainer" parent="OptionsPanel/VBox"] [node name="Scroll" type="ScrollContainer" parent="OptionsPanel/VBox" unique_id=683738956]
custom_minimum_size = Vector2(0, 340) custom_minimum_size = Vector2(0, 340)
layout_mode = 2 layout_mode = 2
size_flags_vertical = 3 size_flags_vertical = 3
horizontal_scroll_mode = 0 horizontal_scroll_mode = 0
[node name="RebindList" type="VBoxContainer" parent="OptionsPanel/VBox/Scroll"] [node name="RebindList" type="VBoxContainer" parent="OptionsPanel/VBox/Scroll" unique_id=1261520279]
layout_mode = 2 layout_mode = 2
size_flags_horizontal = 3 size_flags_horizontal = 3
theme_override_constants/separation = 8 theme_override_constants/separation = 8
[node name="BackButton" type="Button" parent="OptionsPanel/VBox"] [node name="BackButton" type="Button" parent="OptionsPanel/VBox" unique_id=1587524951]
custom_minimum_size = Vector2(0, 48) custom_minimum_size = Vector2(0, 48)
layout_mode = 2 layout_mode = 2
theme_override_font_sizes/font_size = 24 theme_override_font_sizes/font_size = 24
text = "Back" text = "Back"
[node name="CreditsPanel" type="CenterContainer" parent="."] [node name="CreditsPanel" type="CenterContainer" parent="." unique_id=175743902]
visible = false visible = false
layout_mode = 1 layout_mode = 1
anchors_preset = 15 anchors_preset = 15
@@ -206,17 +249,17 @@ anchor_bottom = 1.0
grow_horizontal = 2 grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
[node name="Backdrop" type="ColorRect" parent="CreditsPanel"] [node name="Backdrop" type="ColorRect" parent="CreditsPanel" unique_id=1950253735]
layout_mode = 2 layout_mode = 2
color = Color(0, 0, 0, 0.55)
mouse_filter = 2 mouse_filter = 2
color = Color(0, 0, 0, 0.55)
[node name="VBox" type="VBoxContainer" parent="CreditsPanel"] [node name="VBox" type="VBoxContainer" parent="CreditsPanel" unique_id=901836808]
custom_minimum_size = Vector2(520, 360) custom_minimum_size = Vector2(520, 360)
layout_mode = 2 layout_mode = 2
theme_override_constants/separation = 20 theme_override_constants/separation = 20
[node name="TitleLabel" type="Label" parent="CreditsPanel/VBox"] [node name="TitleLabel" type="Label" parent="CreditsPanel/VBox" unique_id=825593160]
layout_mode = 2 layout_mode = 2
theme_override_colors/font_outline_color = Color(0, 0, 0, 1) theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/outline_size = 8 theme_override_constants/outline_size = 8
@@ -224,36 +267,36 @@ theme_override_font_sizes/font_size = 44
text = "Credits" text = "Credits"
horizontal_alignment = 1 horizontal_alignment = 1
[node name="Names" type="RichTextLabel" parent="CreditsPanel/VBox"] [node name="Names" type="RichTextLabel" parent="CreditsPanel/VBox" unique_id=1355198629]
layout_mode = 2 layout_mode = 2
size_flags_vertical = 3 size_flags_vertical = 3
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_colors/default_color = Color(0.93, 0.88, 0.72, 1) theme_override_colors/default_color = Color(0.93, 0.88, 0.72, 1)
theme_override_constants/outline_size = 5 theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_constants/line_separation = 6 theme_override_constants/line_separation = 6
theme_override_constants/outline_size = 5
theme_override_font_sizes/normal_font_size = 26 theme_override_font_sizes/normal_font_size = 26
theme_override_font_sizes/bold_font_size = 30 theme_override_font_sizes/bold_font_size = 30
bbcode_enabled = true bbcode_enabled = true
fit_content = true
scroll_active = false
text = "[center][font_size=32][color=#d9a833]Bullosseum[/color][/font_size] text = "[center][font_size=32][color=#d9a833]Bullosseum[/color][/font_size]
[b][color=#d9a833]Siim Raud[/color][/b] [url=https://instagram.com/siimpressionist]@siimpressionist[/url] [b][color=#d9a833]Siim Raud[/color][/b] [url=https://instagram.com/siimpressionist]@siimpressionist[/url]
[font_size=20]Creative, art, idea guy[/font_size] [font_size=20]Artist / Animator[/font_size]
[b][color=#d9a833]Richard Aasa[/color][/b] [url=https://instagram.com/aasarichard]@aasarichard[/url] [b][color=#d9a833]Richard Aasa[/color][/b] [url=https://instagram.com/aasarichard]@aasarichard[/url]
[font_size=20]Programmer[/font_size] [font_size=20]Programmer[/font_size]
[b][color=#d9a833]Kadi Rebane[/color][/b] [url=https://instagram.com/kadirebane]@kadirebane[/url] [b][color=#d9a833]Kadi Rebane[/color][/b] [url=https://x.com/_barkseven]@_barkseven[/url]
[font_size=20]Arena asset[/font_size][/center]" [font_size=20]Arena asset[/font_size][/center]"
fit_content = true
scroll_active = false
[node name="BackButton" type="Button" parent="CreditsPanel/VBox"] [node name="BackButton" type="Button" parent="CreditsPanel/VBox" unique_id=1194791074]
custom_minimum_size = Vector2(0, 48) custom_minimum_size = Vector2(0, 48)
layout_mode = 2 layout_mode = 2
theme_override_font_sizes/font_size = 24 theme_override_font_sizes/font_size = 24
text = "Back" text = "Back"
[node name="FadeRect" type="ColorRect" parent="."] [node name="FadeRect" type="ColorRect" parent="." unique_id=15381366]
layout_mode = 1 layout_mode = 1
anchors_preset = 15 anchors_preset = 15
anchor_right = 1.0 anchor_right = 1.0
+32 -19
View File
@@ -4,30 +4,17 @@
[ext_resource type="Script" uid="uid://cpyq2r5cq653g" path="res://camera_iso.gd" id="2_camera_iso"] [ext_resource type="Script" uid="uid://cpyq2r5cq653g" path="res://camera_iso.gd" id="2_camera_iso"]
[ext_resource type="PackedScene" uid="uid://bun14xmk20pcj" path="res://Assets/bull.fbx" id="4_e80uo"] [ext_resource type="PackedScene" uid="uid://bun14xmk20pcj" path="res://Assets/bull.fbx" id="4_e80uo"]
[sub_resource type="SphereShape3D" id="SphereShape3D_chest"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_kne1u"]
radius = 0.35 radius = 0.7495117
height = 1.845398
[sub_resource type="SphereShape3D" id="SphereShape3D_belly"]
radius = 0.4
[sub_resource type="SphereShape3D" id="SphereShape3D_hips"]
radius = 0.3
[node name="Player" type="CharacterBody3D" unique_id=165510044] [node name="Player" type="CharacterBody3D" unique_id=165510044]
collision_layer = 3 collision_layer = 3
script = ExtResource("1_xhfnw") script = ExtResource("1_xhfnw")
[node name="CollisionChest" type="CollisionShape3D" parent="." unique_id=447032296] [node name="CollisionBull" type="CollisionShape3D" parent="." unique_id=571780801]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.505, -0.2) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.29183745, 0.3197594)
shape = SubResource("SphereShape3D_chest") shape = SubResource("CapsuleShape3D_kne1u")
[node name="CollisionBelly" type="CollisionShape3D" parent="." unique_id=571780801]
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="." unique_id=717874116]
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] [node name="Camera3D" type="Camera3D" parent="." unique_id=1120161087]
script = ExtResource("2_camera_iso") script = ExtResource("2_camera_iso")
@@ -35,4 +22,30 @@ script = ExtResource("2_camera_iso")
[node name="bull" parent="." unique_id=1386495166 instance=ExtResource("4_e80uo")] [node name="bull" parent="." unique_id=1386495166 instance=ExtResource("4_e80uo")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.655, 0.23) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.655, 0.23)
[node name="Skeleton3D" parent="bull/Armature" parent_id_path=PackedInt32Array(1386495166, 1942410469) index="0" unique_id=550138996]
bones/0/position = Vector3(0, 0, 0.009963839)
bones/0/rotation = Quaternion(0.7071068, 0, 0, 0.7071067)
bones/0/scale = Vector3(1, 1, 1)
bones/1/position = Vector3(0.003526625, -0.0043296535, 0.0040827994)
bones/1/rotation = Quaternion(-0.00038647873, 0.06909673, 0.9868542, -0.14609687)
bones/2/rotation = Quaternion(-0.06929122, 3.2175163e-07, -0.112670615, 0.99121344)
bones/4/position = Vector3(0.0045358227, -0.002137695, -0.004238624)
bones/4/rotation = Quaternion(-0.24948503, 0.19590352, 0.9416978, -0.11217985)
bones/4/scale = Vector3(1.0000001, 0.9999999, 1.0000001)
bones/5/rotation = Quaternion(-0.58943665, -0.3660725, -0.22564478, 0.683842)
bones/5/scale = Vector3(1, 1.0000001, 0.99999994)
bones/6/rotation = Quaternion(0.52910197, 0.15997827, 0.120245, 0.8246207)
bones/6/scale = Vector3(1, 1, 1.0000001)
bones/17/position = Vector3(0, 0.0009373814, 0.007552617)
bones/17/rotation = Quaternion(-9.4156974e-08, 0.7071068, 0.7071067, -9.4156974e-08)
bones/19/position = Vector3(-0.003526625, -0.0043296535, 0.0040827994)
bones/19/rotation = Quaternion(0.00038647873, 0.06909673, 0.9868542, 0.14609687)
bones/20/rotation = Quaternion(-0.06929122, -3.2227587e-07, 0.11267061, 0.99121344)
bones/22/position = Vector3(-0.0045358227, -0.002137695, -0.004238624)
bones/22/rotation = Quaternion(0.24948503, 0.19590352, 0.9416978, 0.11217985)
bones/23/rotation = Quaternion(-0.58943665, 0.3660725, 0.22564478, 0.683842)
bones/23/scale = Vector3(1, 1.0000001, 1)
bones/24/rotation = Quaternion(0.529102, -0.15997836, -0.120245025, 0.82462066)
bones/24/scale = Vector3(1, 1.0000001, 1)
[editable path="bull"] [editable path="bull"]
+1 -1
View File
@@ -8,5 +8,5 @@ script="rider-plugin.gd"
[presets] [presets]
active="off" active="on"
presets="presets.json" presets="presets.json"
+935
View File
@@ -0,0 +1,935 @@
extends CharacterBody3D
## Dark-Souls-style boss bear with a learnable moveset. From NEUTRAL it weighs its
## options by the range band to the bull and commits to one telegraphed routine —
## wind-up (tell) → active (the hit) → recovery (the player's punish window). A routine
## always plays to the end (hyper-armour: it takes damage mid-swing but isn't knocked out
## of it), so every attack is readable and punishable. Variety comes from which routine
## NEUTRAL picks (weighted per range band); predictability from each routine being fixed
## once entered. Below bear_enrage_frac HP it enters phase 2: snappier tells/recovery and
## longer combo chains.
##
## The bear only wounds the bull with its swings and slams — walking or leaping into the
## bull does nothing. It plugs into the bull's combat interface: it joins the &"matador"
## group so every bull ability already targets it (apply_ability_hit), and mauls the bull
## via take_sword_hit.
# Positioning / neutral states, then the three attack routines, then reactions.
enum State { NEUTRAL, STALK, PROWL, SWIPE, SMASH, LEAP, STAGGER, DEAD }
# Every attack routine runs through these three beats.
enum Phase { WINDUP, ACTIVE, RECOVER }
signal killed
signal health_changed(current: int, max_hp: int)
var _state: State = State.NEUTRAL
var _phase: Phase = Phase.WINDUP
var _bull: CharacterBody3D = null
var _skeleton: Skeleton3D = null
var _anim_player: AnimationPlayer = null
var _blood_burst: CPUParticles3D = null
var _hp: int = 10
var _enraged: bool = false
var _phase_timer: float = 0.0
var _active_len: float = 0.8
var _think_timer: float = 0.0
var _state_timer: float = 0.0
var _stagger_timer: float = 0.0
var _stagger_cd: float = 0.0 # poise window after a stagger during which hits don't stun
var _hits_done: int = 0
var _leap_airborne: bool = false
# Solved per-leap so the arc lands on the clip's impact frame: the launch vertical speed
# and the custom gravity used only while airborne (so peak height and airtime are tuned
# independently of world gravity).
var _leap_up: float = 0.0
var _leap_grav: float = 0.0
# Attack routines queued to fire back-to-back as a combo, popped when the current one
# finishes its recovery. Element type is State.
var _combo_queue: Array = []
# Resolved at runtime from the rig's clip list — the Bear.glb ships Rigify-named clips
# (RUN_ON4LEGS, IDLE_standing, SMASH_HIT, …) that may import with any prefix, so we match
# by substring rather than hard-coding the full track names.
var _anim_idle: StringName = &"" # IDLE_ON4LEGS — neutral / recovery (the punish pose)
var _anim_run: StringName = &"" # RUN_ON4LEGS — prowl approach / neutral coast
var _anim_walk2: StringName = &"" # WALK_ON2LEGS — the 2-leg stalk-in
var _anim_stand: StringName = &"" # IDLE_standing — reared-up wind-up / roar
var _anim_swipe: StringName = &"" # ONE_TWO_HIT — double swipe
var _anim_smash: StringName = &"" # SMASH_HIT — overhead smash
var _anim_leap: StringName = &"" # JUMP_SMASH — leap slam
var _anim_death: StringName = &"" # DEATH — death
#take hit flashing
var _hit_flash_active := false
var _hit_flash_tween: Tween
const _ACCEL_FACTOR: float = 9.0
# Hard deceleration for planted states (wind-up / recovery / neutral settle) so the bear
# never coasts across the ground with a stationary pose playing (no foot-sliding).
const _PLANT_DECEL: float = 22.0
# The Bear.glb clips are authored at 30 fps (see Bear.glb.import); leap timing is keyed by
# frame, so convert frames→seconds with this.
const _ANIM_FPS: float = 30.0
# How close the bear closes on the bull before it stops advancing (roughly the bear+bull
# body radii). Its swings still reach well past this; it just never tries to occupy the
# bull's spot, so it doesn't shove up onto it.
const _BULL_STANDOFF: float = 1.9
@onready var _mesh: Node3D = $bear_model
@onready var _hit_area: Area3D = $HitArea
var _dust: CPUParticles3D = null
# Claw-slash bursts bound to the paw bones, fired one per hit of the 1-2 punch.
var _claw_l: CPUParticles3D = null
var _claw_r: CPUParticles3D = null
const RigidSkin = preload("res://rigid_skin.gd")
func _ready() -> void:
add_to_group(&"matador") # so every existing bull ability already targets the bear
add_to_group(&"bear")
_hp = maxi(1, int(DP.f("bear_max_hp")))
health_changed.emit(_hp, _hp)
_skeleton = _find_skeleton(_mesh)
_anim_player = _find_anim_player(_mesh)
# Web (Mali/ANGLE) can't run Compatibility vertex-skinning; rebuild the bear as
# non-skinned bone-attached pieces there. Desktop keeps smooth GPU skinning.
if OS.has_feature("web"):
RigidSkin.convert_tree(self)
if _anim_player:
_anim_idle = _resolve_anim(["IDLE_ON4LEGS", "IDLE"])
_anim_run = _resolve_anim(["RUN_ON4LEGS", "RUN"])
_anim_walk2 = _resolve_anim(["WALK_ON2LEGS", "WALK", "IDLE_standing"])
_anim_stand = _resolve_anim(["IDLE_standing", "WALK_ON2LEGS"])
_anim_swipe = _resolve_anim(["ONE_TWO_HIT", "SMASH_HIT"])
_anim_smash = _resolve_anim(["SMASH_HIT", "ONE_TWO_HIT"])
_anim_leap = _resolve_anim(["JUMP_SMASH", "SMASH_HIT"])
_anim_death = _resolve_anim(["DEATH"])
# Loop the locomotion / idle clips; the attack clips (swipe/smash/leap) are one-shot,
# so their on-ground settle frames play once and then hold instead of looping.
for a in [_anim_idle, _anim_run, _anim_walk2, _anim_stand]:
_ensure_loop(a)
for a in [_anim_swipe, _anim_smash, _anim_leap, _anim_death]:
_set_no_loop(a)
_anim_player.playback_default_blend_time = 0.12
_hit_area.body_entered.connect(_on_body_entered)
_blood_burst = preload("res://blood_burst.gd").new()
add_child(_blood_burst)
_dust = _make_slam_dust()
add_child(_dust)
_setup_claws()
_enter_neutral()
#take hit flashing
func _hit_flash() -> void:
if _mesh == null:
return
if _hit_flash_tween:
_hit_flash_tween.kill()
_hit_flash_active = true
# Make the bear red
for child in _mesh.find_children("*", "MeshInstance3D", true, false):
var mesh := child as MeshInstance3D
if mesh:
mesh.material_overlay = _make_hit_overlay()
# Flash for 0.1 sec, then remove
_hit_flash_tween = create_tween()
_hit_flash_tween.tween_interval(0.10)
_hit_flash_tween.tween_callback(_clear_hit_flash)
func _make_hit_overlay() -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.05, 0.05, 1.0)
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
return mat
func _clear_hit_flash() -> void:
if _mesh == null:
return
for child in _mesh.find_children("*", "MeshInstance3D", true, false):
var mesh := child as MeshInstance3D
if mesh:
mesh.material_overlay = null
_hit_flash_active = false
# Bind a claw-slash emitter to each paw bone (hand_l / hand_r) via a BoneAttachment3D, so
# a burst fired on a swipe hit erupts from that paw wherever the animation has it.
func _setup_claws() -> void:
if _skeleton == null:
return
_claw_r = _attach_claw("hand_r")
_claw_l = _attach_claw("hand_l")
func _attach_claw(bone: String) -> CPUParticles3D:
if _skeleton.find_bone(bone) == -1:
return null
var attach := BoneAttachment3D.new()
attach.bone_name = bone
_skeleton.add_child(attach)
var claw := _make_claw_slash()
attach.add_child(claw)
return claw
func get_hp() -> int:
return _hp
func _acquire_bull() -> void:
if _bull != null:
return
var players := get_tree().get_nodes_in_group(&"player")
if not players.is_empty():
_bull = players[0] as CharacterBody3D
# The bear only ever hits via distance / area checks, never body collision, so let
# it pass through the bull. Otherwise the big capsule rides up and gets stuck on top
# of the bull when it lunges or lands a leap on it.
if _bull != null:
add_collision_exception_with(_bull)
func _physics_process(delta: float) -> void:
if not is_on_floor():
# The leap uses its own gravity mid-flight so its arc height and airtime can be
# tuned to land on the clip's impact frame; everything else falls under world gravity.
if _state == State.LEAP and _phase == Phase.ACTIVE and _leap_grav > 0.0:
velocity.y -= _leap_grav * delta
else:
velocity += get_gravity() * delta
if _state == State.DEAD:
velocity.x = 0.0
velocity.z = 0.0
move_and_slide()
return
_acquire_bull()
_update_enrage()
_stagger_cd = maxf(0.0, _stagger_cd - delta)
match _state:
State.NEUTRAL: _tick_neutral(delta)
State.STALK: _tick_stalk(delta)
State.PROWL: _tick_prowl(delta)
State.SWIPE: _tick_swipe(delta)
State.SMASH: _tick_smash(delta)
State.LEAP: _tick_leap(delta)
State.STAGGER: _tick_stagger(delta)
move_and_slide()
# ── Phase 2 ──────────────────────────────────────────────────────────────────────
func _update_enrage() -> void:
if _enraged:
return
if _hp <= int(ceil(DP.f("bear_max_hp") * DP.f("bear_enrage_frac"))):
_enraged = true
# Roar: a beat of stagger-free wind-up with a screen shake, so the phase flip reads.
_blood_burst.burst(global_position + Vector3(0.0, 1.6, 0.0), Vector3.UP)
_shake(0.9)
# Timers that shape readability (tells + recovery) shrink in phase 2; active windows and
# the clip-locked leap timings are left alone.
func _scaled(t: float) -> float:
return t * (DP.f("bear_enrage_scale") if _enraged else 1.0)
# ── NEUTRAL: think, then commit ────────────────────────────────────────────────────
func _enter_neutral() -> void:
_state = State.NEUTRAL
# A short, lightly-varied beat so the bear flows from one action into the next; phase 2
# shrinks it further. Keep the jitter small so it reads as eager, not hesitant.
var think := DP.f("bear_think_time")
_think_timer = _scaled(think * randf_range(0.7, 1.3))
func _tick_neutral(delta: float) -> void:
_decelerate(_PLANT_DECEL, delta)
if _bull != null:
_face_point(_bull.global_position, delta)
# Coast on the run clip while residual speed bleeds off, then settle to idle — never
# hold the idle pose while still sliding.
_play(_anim_run if _horizontal_speed() > 1.0 else _anim_idle)
_think_timer -= delta
if _think_timer <= 0.0:
_decide()
# Pop the next queued combo step, or pick a fresh routine weighted by the current range
# band. Weights lean into pressure (and chaining) in phase 2.
func _decide() -> void:
if not _combo_queue.is_empty():
var next: State = _combo_queue.pop_front()
if _range_allows(next):
_start_routine(next)
return
_combo_queue.clear()
if _bull == null:
_start_routine(State.PROWL)
return
var dist := _flat_dist_to_bull()
var options: Array
if dist <= DP.f("bear_close_range"):
# In strike range: punch or overhead (both step in during the swing).
options = [[State.SWIPE, 3.0], [State.SMASH, 3.0 if _enraged else 2.5]]
elif dist <= DP.f("bear_mid_range"):
# Mid: mostly walk the bull down on two legs; occasionally leap the gap.
options = [[State.STALK, 3.0], [State.LEAP, 2.0 if _enraged else 1.5]]
else:
# Far: lope in on all fours, or spring across with a leap.
options = [[State.PROWL, 3.0], [State.LEAP, 1.5]]
_start_routine(_weighted_pick(options))
# A queued combo step only fires if the bull is still roughly in its band, so a chain
# doesn't whiff into empty air after the player rolls away.
func _range_allows(routine: State) -> bool:
if _bull == null:
return routine == State.PROWL
var dist := _flat_dist_to_bull()
match routine:
State.SWIPE, State.SMASH:
return dist <= DP.f("bear_close_range") * 1.4
State.LEAP:
return dist <= DP.f("bear_mid_range") * 1.5
return true
func _start_routine(routine: State) -> void:
match routine:
State.SWIPE: _start_swipe()
State.SMASH: _start_smash()
State.LEAP: _start_leap()
State.STALK: _start_stalk()
State.PROWL: _start_prowl()
_: _enter_neutral()
# When a routine's recovery ends: fire the next combo step if one is queued, else think.
func _finish_routine() -> void:
if not _combo_queue.is_empty():
_decide()
else:
_enter_neutral()
# ── STALK: a menacing 2-leg walk-in that closes the gap into strike range ────────────
func _start_stalk() -> void:
_state = State.STALK
_state_timer = DP.f("bear_stalk_time")
func _tick_stalk(delta: float) -> void:
_state_timer -= delta
if _bull == null or _state_timer <= 0.0 or _flat_dist_to_bull() <= DP.f("bear_close_range"):
_enter_neutral()
return
var to_bull := _flat_to_bull()
_accelerate(to_bull.normalized(), DP.f("bear_stalk_speed"), delta)
_face_point(_bull.global_position, delta)
_play(_anim_walk2)
# ── PROWL: lope in on all fours to close a big gap ─────────────────────────────────
func _start_prowl() -> void:
_state = State.PROWL
_state_timer = 1.6
func _tick_prowl(delta: float) -> void:
_state_timer -= delta
if _bull == null or _state_timer <= 0.0 or _flat_dist_to_bull() <= DP.f("bear_mid_range") * 0.9:
_enter_neutral()
return
var to_bull := _flat_to_bull()
_accelerate(to_bull.normalized(), DP.f("bear_run_speed"), delta)
_face_point(_bull.global_position, delta)
_play(_anim_run)
# ── SWIPE: Double Swipe (ONE_TWO_HIT), two quick hits ──────────────────────────────
func _start_swipe() -> void:
_state = State.SWIPE
_phase = Phase.WINDUP
_phase_timer = _scaled(DP.f("bear_swipe_windup"))
_hits_done = 0
_play(_anim_stand)
# Sometimes chain the big smash after the swipe (always in phase 2) — the signature
# close string the player learns to bait and punish.
if _combo_queue.is_empty() and (_enraged or randf() < DP.f("bear_combo_chance")):
_combo_queue.append(State.SMASH)
func _tick_swipe(delta: float) -> void:
match _phase:
Phase.WINDUP:
_decelerate(_PLANT_DECEL, delta) # rear up in place — no slide
_face_point_if_bull(delta) # track hard before committing
_play(_anim_stand)
_phase_timer -= delta
if _phase_timer <= 0.0:
_begin_attack_active(_anim_swipe, 0.8)
Phase.ACTIVE:
_lunge_and_track(DP.f("bear_lunge_speed"), delta)
_phase_timer -= delta
var frac := 1.0 - _phase_timer / maxf(_active_len, 0.001)
if _hits_done == 0 and frac >= 0.25:
_hits_done = 1
_try_hit(DP.f("bear_swipe_reach"))
_claw_slash(true) # first paw (right) rakes across
elif _hits_done == 1 and frac >= 0.65:
_hits_done = 2
_try_hit(DP.f("bear_swipe_reach"))
_claw_slash(false) # back-hand with the left paw
if _phase_timer <= 0.0:
_begin_recover(DP.f("bear_swipe_recover"))
Phase.RECOVER:
_tick_recover(delta)
# ── SMASH: Overhead Smash (SMASH_HIT), slower tell, big punish window ────────────────
func _start_smash() -> void:
_state = State.SMASH
_phase = Phase.WINDUP
_phase_timer = _scaled(DP.f("bear_smash_windup"))
_hits_done = 0
_play(_anim_stand)
func _tick_smash(delta: float) -> void:
match _phase:
Phase.WINDUP:
_decelerate(_PLANT_DECEL, delta)
_face_point_if_bull(delta)
_play(_anim_stand)
_phase_timer -= delta
if _phase_timer <= 0.0:
_begin_attack_active(_anim_smash, 0.9)
Phase.ACTIVE:
# The overhead steps in less than the swipe — it's a commitment, not a rush.
_lunge_and_track(DP.f("bear_lunge_speed") * 0.5, delta)
_phase_timer -= delta
var frac := 1.0 - _phase_timer / maxf(_active_len, 0.001)
if _hits_done == 0 and frac >= 0.4:
_hits_done = 1
_slam(DP.f("bear_smash_radius"), 0.8)
if _phase_timer <= 0.0:
_begin_recover(DP.f("bear_smash_recover"))
Phase.RECOVER:
_tick_recover(delta)
# Shared wind-up → active handoff for the melee swings: start the clip (sped up for snappy
# punches) and size the active window to the sped-up clip length.
func _begin_attack_active(clip: StringName, fallback_len: float) -> void:
_phase = Phase.ACTIVE
var spd := maxf(DP.f("bear_attack_anim_speed"), 0.1)
_active_len = _anim_length(clip, fallback_len) / spd
_phase_timer = _active_len
_play_attack(clip, spd)
# ── LEAP: Leap Slam (JUMP_SMASH) — clip-synced ballistic gap-closer + AoE ────────────
# The one JUMP_SMASH clip runs start-to-finish across the whole routine: it plays the
# crouch on the ground (wind-up), springs at the takeoff frame, the arc is solved so the
# paws hit ground exactly on the impact frame, and the last frames settle on the ground.
func _start_leap() -> void:
_state = State.LEAP
_phase = Phase.WINDUP
_hits_done = 0
_leap_airborne = false
_leap_grav = 0.0
_active_len = _anim_length(_anim_leap, 1.0)
_play_attack(_anim_leap, 1.0) # play from frame 0 at real speed, grounded crouch
_phase_timer = DP.f("bear_leap_takeoff_frame") / _ANIM_FPS # ground time before the spring
# In phase 2 a leap sometimes follows straight into a swipe once it lands close.
if _enraged and _combo_queue.is_empty() and randf() < 0.5:
_combo_queue.append(State.SWIPE)
func _tick_leap(delta: float) -> void:
match _phase:
Phase.WINDUP:
_decelerate(_PLANT_DECEL, delta) # crouch in place
_face_point_if_bull(delta) # aim the pounce, tracking the bull
_phase_timer -= delta
if _phase_timer <= 0.0:
_launch_leap()
Phase.ACTIVE:
_phase_timer -= delta
if not is_on_floor():
_leap_airborne = true
elif _leap_airborne or _phase_timer <= 0.0:
velocity.x = 0.0
velocity.z = 0.0
_slam(DP.f("bear_slam_radius"), 0.7)
_phase = Phase.RECOVER
_phase_timer = _scaled(DP.f("bear_leap_recover"))
Phase.RECOVER:
_decelerate(_PLANT_DECEL, delta)
# Let the clip's on-ground settle frames finish first, then idle out the recovery.
if _anim_player != null and _anim_player.is_playing() \
and _anim_player.current_animation == _anim_leap:
return
_play(_anim_idle)
_phase_timer -= delta
if _phase_timer <= 0.0:
_finish_routine()
func _launch_leap() -> void:
_phase = Phase.ACTIVE
_leap_airborne = false
var target := _bull.global_position if _bull != null else global_position
var flat := target - global_position
flat.y = 0.0
var dist := flat.length()
var dir := flat.normalized() if dist > 0.1 else _facing()
_face_dir(dir, 1.0)
# Airtime = the clip span between the takeoff and impact frames, so the paws hit ground
# on the impact frame (25). A custom leap gravity lets the arc reach bear_leap_height in
# exactly that airtime regardless of world gravity: for a symmetric hop of airtime T to
# peak height H, v_up = 4H/T and g = 8H/T².
var air_t := maxf(
(DP.f("bear_leap_impact_frame") - DP.f("bear_leap_takeoff_frame")) / _ANIM_FPS, 0.05)
var h := maxf(DP.f("bear_leap_height"), 0.1)
_leap_up = 4.0 * h / air_t
_leap_grav = 8.0 * h / (air_t * air_t)
# Land just in front of the bull, not on top of it — the slam radius still catches it.
var land_dist := maxf(dist - _BULL_STANDOFF, 0.0)
var h_speed := clampf(land_dist / air_t, 0.0, DP.f("bear_leap_speed"))
velocity = dir * h_speed + Vector3.UP * _leap_up
_phase_timer = air_t + 1.0 # safety fallback if a landing is missed
func _begin_recover(secs: float) -> void:
_phase = Phase.RECOVER
_phase_timer = _scaled(secs)
func _tick_recover(delta: float) -> void:
_decelerate(_PLANT_DECEL, delta)
_play(_anim_idle)
_phase_timer -= delta
if _phase_timer <= 0.0:
_finish_routine()
func _tick_stagger(delta: float) -> void:
_decelerate(12.0, delta)
_stagger_timer -= delta
_play(_anim_idle)
if _stagger_timer <= 0.0:
_stagger_cd = DP.f("bear_stagger_cd") # poise window so it can act before the next stun
_enter_neutral()
# Step toward the bull during a swing (so a strike lands even when the bull is a touch out
# of reach) while tracking its facing — but plant once close so the bear doesn't shove
# through it. Motion here reads as the strike's lunge, under the attack clip.
func _lunge_and_track(speed: float, delta: float) -> void:
if _bull == null:
_decelerate(_PLANT_DECEL, delta)
return
_face_point(_bull.global_position, delta, DP.f("bear_track_active"))
var to_bull := _flat_to_bull()
var dist := to_bull.length()
if speed > 0.0 and dist > _BULL_STANDOFF:
_accelerate(to_bull / dist, speed, delta)
else:
_decelerate(_PLANT_DECEL, delta)
# ── Hitting the bull ───────────────────────────────────────────────────────────────
# Directional strike: the bull is mauled inside `reach` of the bear that's facing it
# (a fast bull tunnels a thin collider between frames, so distance + facing is the
# reliable hit). Returns whether it connected.
func _try_hit(reach: float) -> bool:
if _bull == null:
return false
var to_bull := _flat_to_bull()
var dist := to_bull.length()
if dist > reach or dist < 0.01:
return false
if _facing().dot(to_bull / dist) < cos(deg_to_rad(DP.f("bear_hit_arc"))):
return false
_bull.call(&"take_sword_hit", "mauled")
_shake(0.55)
return true
# Radial slam (smash / leap landing): hits any way the bull is, within `radius`, with a
# ground-pound shake and a burst of dust kicked up at the paws. No facing arc — you're
# caught if you're near where it comes down.
func _slam(radius: float, shake: float) -> void:
_slam_dust()
if _bull == null:
_shake(shake)
return
if _flat_dist_to_bull() <= radius:
_bull.call(&"take_sword_hit", "mauled")
_shake(shake)
# Kick up a one-shot ring of dust at the bear's feet where the slam lands.
func _slam_dust() -> void:
if _dust == null:
return
_dust.global_position = global_position + Vector3(0.0, 0.15, 0.0)
_dust.restart()
_dust.emitting = true
# Tan ground-impact dust: a fast, wide, low burst that arcs up and settles — built in code
# so the Bear scene needs no extra particle node.
func _make_slam_dust() -> CPUParticles3D:
var p := CPUParticles3D.new()
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.16
sphere.height = 0.32
sphere.radial_segments = 4
sphere.rings = 2
sphere.material = mat
var ramp := Gradient.new()
ramp.set_color(0, Color(0.72, 0.60, 0.40, 0.9))
ramp.set_color(1, Color(0.55, 0.45, 0.30, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 0.4))
scale_curve.add_point(Vector2(0.35, 1.0))
scale_curve.add_point(Vector2(1.0, 0.0))
p.mesh = sphere
p.color_ramp = ramp
p.scale_amount_curve = scale_curve
p.emitting = false
p.one_shot = true
p.explosiveness = 1.0
p.amount = 36
p.lifetime = 0.9
p.randomness = 0.5
p.local_coords = false
# A flat-ish disc kicked outward and up from the ground.
p.emission_shape = CPUParticles3D.EMISSION_SHAPE_SPHERE
p.emission_sphere_radius = 0.5
p.direction = Vector3(0.0, 1.0, 0.0)
p.spread = 75.0
p.gravity = Vector3(0.0, -6.0, 0.0)
p.initial_velocity_min = 2.5
p.initial_velocity_max = 6.5
p.scale_amount_min = 0.6
p.scale_amount_max = 1.4
return p
# Fire the claw-slash burst on one paw for a swipe hit (right paw first, then left).
func _claw_slash(right: bool) -> void:
var claw := _claw_r if right else _claw_l
if claw == null:
return
claw.restart()
claw.emitting = true
# A short fan of pale streaks raked from a paw — thin boxes aligned to their velocity so
# they read as claw marks slashing outward. Built in code and parented to a hand bone.
func _make_claw_slash() -> CPUParticles3D:
var p := CPUParticles3D.new()
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
mat.vertex_color_use_as_albedo = true
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
# A thin, long box becomes a streak once aligned to its velocity.
var streak := BoxMesh.new()
streak.size = Vector3(0.03, 0.55, 0.03)
streak.material = mat
var ramp := Gradient.new()
ramp.set_color(0, Color(0.85, 0.95, 1.0, 0.95))
ramp.set_color(1, Color(0.5, 0.75, 1.0, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 1.0))
scale_curve.add_point(Vector2(0.7, 1.0))
scale_curve.add_point(Vector2(1.0, 0.0))
p.mesh = streak
p.color_ramp = ramp
p.scale_amount_curve = scale_curve
p.emitting = false
p.one_shot = true
p.explosiveness = 1.0 # all at once = a single rake, not a stream
p.amount = 5 # a handful of parallel claw marks
p.lifetime = 0.22
p.randomness = 0.2
p.local_coords = false # streaks stay in the air where they were raked, then fade
p.set_particle_flag(CPUParticles3D.PARTICLE_FLAG_ALIGN_Y_TO_VELOCITY, true)
# Rake across the paw's swing plane: a tight fan of fast streaks.
p.emission_shape = CPUParticles3D.EMISSION_SHAPE_SPHERE
p.emission_sphere_radius = 0.12
p.direction = Vector3(0.0, 0.0, 1.0)
p.spread = 22.0
p.gravity = Vector3.ZERO
p.initial_velocity_min = 6.0
p.initial_velocity_max = 9.0
return p
func _shake(amount: float) -> void:
if _bull == null:
return
var cam := _bull.get_node_or_null("Camera3D")
if cam:
cam.call(&"trigger_hit", clampf(amount, 0.2, 1.0))
# ── Taking damage ──────────────────────────────────────────────────────────────────
# Called by every bull ability (kick / slam / roll) that iterates the matador group.
func apply_ability_hit(hit_dir: Vector3, strength: float, _up_boost: float = 0.0) -> void:
_acquire_bull()
_take_hit(hit_dir, strength)
# Ramming: only a real charge from the bull wounds the bear; a gentle nudge is shrugged
# off. The bear never damages the bull by colliding — only its swings/slams do.
func _on_body_entered(body: Node3D) -> void:
if _state == State.DEAD or not body.is_in_group(&"player"):
return
var player := body as CharacterBody3D
if player.velocity.length() < DP.f("bear_hit_threshold"):
return
var flat := Vector3(player.velocity.x, 0.0, player.velocity.z)
var dir := flat.normalized() if flat.length() > 0.5 else \
(global_position - player.global_position).normalized()
_take_hit(dir, player.velocity.length())
func _take_hit(hit_dir: Vector3, strength: float) -> void:
if _state == State.DEAD:
return
_hp -= 1
_hit_flash()
health_changed.emit(_hp, int(DP.f("bear_max_hp")))
hit_dir.y = 0.0
hit_dir = hit_dir.normalized()
_blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir)
_shake(clampf(strength / 15.0, 0.4, 1.0))
if _hp <= 0:
_die(hit_dir)
return
# Hyper-armour: a committed swing takes the wound but isn't knocked out of it — only
# the vulnerable states (neutral / positioning / a routine's recovery) stagger. This
# is what makes the recovery the real punish window.
if not _can_stagger():
return
velocity.x = hit_dir.x * 4.0
velocity.z = hit_dir.z * 4.0
_combo_queue.clear()
_state = State.STAGGER
_stagger_timer = DP.f("bear_stagger_time")
func _can_stagger() -> bool:
if _stagger_cd > 0.0:
return false # poise: recently staggered, so it rides out hits instead of stun-locking
match _state:
State.NEUTRAL, State.STALK, State.PROWL, State.STAGGER:
return true
State.SWIPE, State.SMASH, State.LEAP:
return _phase == Phase.RECOVER # airborne / mid-swing can't be staggered
return false
func _die(hit_dir: Vector3) -> void:
_state = State.DEAD
killed.emit()
collision_layer = 0
if _hit_area:
_hit_area.monitoring = false
velocity = Vector3.ZERO
_blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir)
_play(_anim_death)
# No death clip on the rig — topple the beast onto its side (about the hit direction,
# so it falls the way it was struck) and sink it away, then free.
#var fall_axis := Vector3(-hit_dir.z, 0.0, hit_dir.x)
#if fall_axis.length() < 0.01:
# fall_axis = Vector3.RIGHT
#var tween := create_tween()
#tween.tween_property(_mesh, "rotation",
# _mesh.rotation + fall_axis.normalized() * deg_to_rad(88.0), 0.7)\
# .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
#tween.tween_interval(2.5)
#tween.tween_property(_mesh, "position:y", _mesh.position.y - 3.0, 1.0)
#tween.tween_callback(queue_free)
# ── Interface shared with the matador (guarded elsewhere via has_method) ─────────────
func is_active() -> bool:
return _state != State.DEAD
func ai_state_name() -> String:
var n := State.keys()[_state] as String
if _state in [State.SWIPE, State.SMASH, State.LEAP]:
n += ":" + (Phase.keys()[_phase] as String)
if _enraged:
n += "*"
return n
# ── Movement / geometry helpers ─────────────────────────────────────────────────────
func _flat_to_bull() -> Vector3:
var to := _bull.global_position - global_position
to.y = 0.0
return to
func _flat_dist_to_bull() -> float:
return _flat_to_bull().length()
func _horizontal_speed() -> float:
return Vector2(velocity.x, velocity.z).length()
func _facing() -> Vector3:
return Vector3(sin(_mesh.rotation.y), 0.0, cos(_mesh.rotation.y))
func _accelerate(dir: Vector3, spd: float, delta: float) -> void:
velocity.x = move_toward(velocity.x, dir.x * spd, spd * _ACCEL_FACTOR * delta)
velocity.z = move_toward(velocity.z, dir.z * spd, spd * _ACCEL_FACTOR * delta)
func _decelerate(rate: float, delta: float) -> void:
velocity.x = move_toward(velocity.x, 0.0, rate * delta)
velocity.z = move_toward(velocity.z, 0.0, rate * delta)
func _face_point_if_bull(delta: float, rate: float = -1.0) -> void:
if _bull != null:
_face_point(_bull.global_position, delta, rate)
func _face_point(point: Vector3, delta: float, rate: float = -1.0) -> void:
_face_dir(point - global_position, delta, rate)
func _face_dir(dir: Vector3, delta: float, rate: float = -1.0) -> void:
if rate < 0.0:
rate = DP.f("bear_turn_rate")
dir.y = 0.0
if dir.length_squared() > 0.01:
_mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(dir.x, dir.z), delta * rate)
func _weighted_pick(options: Array) -> State:
var total := 0.0
for o: Array in options:
total += o[1]
var r := randf() * total
for o: Array in options:
r -= o[1]
if r <= 0.0:
return o[0]
return options[0][0]
# ── Animation helpers ───────────────────────────────────────────────────────────────
func _resolve_anim(candidates: Array) -> StringName:
if _anim_player == null:
return &""
var list := _anim_player.get_animation_list()
for want: String in candidates:
for have: String in list:
if have.to_upper().contains(want.to_upper()):
return StringName(have)
return &""
func _ensure_loop(anim_name: StringName) -> void:
if anim_name != &"" and _anim_player.has_animation(anim_name):
_anim_player.get_animation(anim_name).loop_mode = Animation.LOOP_LINEAR
func _set_no_loop(anim_name: StringName) -> void:
if anim_name != &"" and _anim_player.has_animation(anim_name):
_anim_player.get_animation(anim_name).loop_mode = Animation.LOOP_NONE
func _anim_length(anim_name: StringName, fallback: float) -> float:
if _anim_player and anim_name != &"" and _anim_player.has_animation(anim_name):
return _anim_player.get_animation(anim_name).length
return fallback
func _play(anim_name: StringName) -> void:
if _anim_player == null or anim_name == &"":
return
if _anim_player.current_animation == anim_name:
return
_anim_player.play(anim_name)
# Restart a one-shot attack clip from its first frame at `speed` (even if already current).
func _play_attack(anim_name: StringName, speed: float) -> void:
if _anim_player == null or anim_name == &"":
return
_anim_player.play(anim_name, -1.0, speed)
_anim_player.seek(0.0, true)
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
+1
View File
@@ -0,0 +1 @@
uid://cn7fhjkro7637
+8
View File
@@ -0,0 +1,8 @@
extends Node3D
func mesh_on():
visible = true
func mesh_off():
visible = false
+1
View File
@@ -0,0 +1 @@
uid://bsbed05ifr18s
+11
View File
@@ -42,6 +42,15 @@ func _unhandled_input(event: InputEvent) -> void:
_user_fov = clamp(_user_fov * 1.1, fov_min, fov_max) _user_fov = clamp(_user_fov * 1.1, fov_min, fov_max)
# Pinch-to-zoom hook for the touch UI. `ratio` is the two-finger spread this frame over
# last frame: >1 (fingers parting) narrows the FOV to zoom in, <1 widens it to pull back,
# mirroring the scroll-wheel zoom_in/zoom_out feel.
func adjust_zoom(ratio: float) -> void:
if ratio <= 0.0:
return
_user_fov = clampf(_user_fov / ratio, fov_min, fov_max)
func _process(delta: float) -> void: func _process(delta: float) -> void:
fov = lerpf(fov, _user_fov, delta * 8.0) fov = lerpf(fov, _user_fov, delta * 8.0)
@@ -92,6 +101,8 @@ func _drag_focus(target: Node3D, delta: float) -> Vector3:
func trigger_hit(strength: float) -> void: func trigger_hit(strength: float) -> void:
if not Settings.screen_shake:
return
_shake_amount = strength * 0.5 _shake_amount = strength * 0.5
_shake_decay = _shake_amount / 0.4 _shake_decay = _shake_amount / 0.4
+29 -2
View File
@@ -18,6 +18,8 @@ const _COMMANDS := {
"diff": "list params changed from default", "diff": "list params changed from default",
"toggle": "flip a bool param", "toggle": "flip a bool param",
"list": "print params (optional section)", "list": "print params (optional section)",
"level": "select level and reload (level <id> — arena / arena_boss / bear)",
"restart": "reload the current level scene",
"clear": "clear the log", "clear": "clear the log",
"help": "show this help", "help": "show this help",
} }
@@ -472,6 +474,12 @@ func _run_command(parts: PackedStringArray) -> bool:
_cmd_diff() _cmd_diff()
"list": "list":
_cmd_list(parts) _cmd_list(parts)
"level":
_cmd_level(parts)
"restart":
_log_ok("Restarting…")
get_tree().paused = false
get_tree().reload_current_scene()
"clear": "clear":
_log.clear() _log.clear()
"help": "help":
@@ -581,9 +589,9 @@ func _set_param(key: String, meta: Dictionary, raw: String) -> void:
var lo := meta["min"] as float var lo := meta["min"] as float
var hi := meta["max"] as float var hi := meta["max"] as float
if not raw.is_valid_float() or not is_finite(raw.to_float()): if not raw.is_valid_float() or not is_finite(raw.to_float()):
_log_warn("%s wants a number %s%s" % [key, _fmt_num(lo), _fmt_num(hi)]) _log_warn("%s wants a number" % key)
return return
var v := snappedf(clampf(raw.to_float(), lo, hi), meta["step"] as float) var v := raw.to_float()
DP.set_value(key, v) DP.set_value(key, v)
_log_ok("[b]%s[/b] ← [color=#ffe080]%s[/color]" % [key, _fmt_num(v)]) _log_ok("[b]%s[/b] ← [color=#ffe080]%s[/color]" % [key, _fmt_num(v)])
elif meta["type"] == TYPE_BOOL: elif meta["type"] == TYPE_BOOL:
@@ -600,6 +608,25 @@ func _set_param(key: String, meta: Dictionary, raw: String) -> void:
_log_warn("Cannot set param of type %d" % meta["type"]) _log_warn("Cannot set param of type %d" % meta["type"])
func _cmd_level(parts: PackedStringArray) -> void:
if parts.size() == 1:
var cur := Run.active_level()
var cur_name := (cur.id as String) if cur != null else "none"
_log_ok("Current level: [b]%s[/b]" % cur_name)
var ids: Array[StringName] = Run.level_ids()
_log_raw("Available: " + " ".join(ids.map(func(id: StringName) -> String: return str(id))))
return
var id := StringName(parts[1].to_lower())
var level := Run.get_level(id)
if level == null:
_log_warn("Unknown level '%s'. Try: %s" % [parts[1], " ".join(Run.level_ids().map(func(i: StringName) -> String: return str(i)))])
return
Run.debug_level_override = level
_log_ok("Level set to [b]%s[/b] — reloading…" % str(id))
get_tree().paused = false
get_tree().change_scene_to_file(level.scene_path)
func _cmd_list(parts: PackedStringArray) -> void: func _cmd_list(parts: PackedStringArray) -> void:
var filter := parts[1].to_lower() if parts.size() > 1 else "" var filter := parts[1].to_lower() if parts.size() > 1 else ""
var all := DP.get_all() var all := DP.get_all()
+93 -1
View File
@@ -15,8 +15,8 @@ const REBINDABLE_ACTIONS: PackedStringArray = [
&"move_left", &"move_left",
&"move_right", &"move_right",
&"ability_kick", &"ability_kick",
&"ability_slam",
&"ability_dash", &"ability_dash",
&"ability_slam",
&"ability_roll", &"ability_roll",
] ]
@@ -25,6 +25,98 @@ func _ready() -> void:
load_saved() load_saved()
# ── Web fullscreen + landscape lock ─────────────────────────────────────────────
# Browsers only allow requestFullscreen / screen.orientation.lock from inside a user
# gesture, so we piggyback on the first tap/click of the web build to go fullscreen and
# lock to landscape. One-shot; input still flows to the menu/game normally (never consumed).
var _fs_triggered: bool = false
func _input(event: InputEvent) -> void:
if _fs_triggered or not OS.has_feature("web"):
return
var gesture := (event is InputEventScreenTouch and (event as InputEventScreenTouch).pressed) \
or (event is InputEventMouseButton and (event as InputEventMouseButton).pressed)
if gesture:
_fs_triggered = true
request_fullscreen_landscape()
## Enter browser fullscreen and lock to landscape. Must be called from a user-gesture
## context (first tap, or a button press). No-op off web. Android Chrome/Brave honour the
## orientation lock; iOS Safari ignores it (unsupported), so the .catch() swallows the reject.
func request_fullscreen_landscape() -> void:
if not OS.has_feature("web"):
return
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN)
JavaScriptBridge.eval(
"(function(){function l(){try{if(screen.orientation&&screen.orientation.lock)"
+ "screen.orientation.lock('landscape').catch(function(){});}catch(e){}}"
+ "l();document.addEventListener('fullscreenchange',l,{once:true});})()", true)
## Whether to drive the game with the on-screen touch UI (joystick + buttons)
## instead of keyboard hints. Platform detection is cached (it can't change mid-run)
## so the per-frame/per-input callers don't repay the cost — but the DP force flag is
## re-read every call so the console can toggle the layout live on a desktop build.
var _touch_ui_cached: int = -1 # -1 = not yet detected, 0 = false, 1 = true
func use_touch_ui() -> bool:
if DP.b("force_touch_controls"):
return true
if _touch_ui_cached == -1:
_touch_ui_cached = 1 if _detect_touch_ui() else 0
return _touch_ui_cached == 1
## True only on touch-primary devices (phones, tablets). Detection is deliberately
## conservative so a desktop never gets the on-screen thumb UI.
##
## DisplayServer.is_touchscreen_available() is unusable here on two fronts: on native
## desktop `emulate_touch_from_mouse` (on for testing) forces it true, and on the web build
## it maps to `'ontouchstart' in window`, which desktop Chromium reports true even with no
## touchscreen. So on web we ask the browser two questions instead: does it report touch
## points, AND is the *primary* pointer coarse? A desktop-with-mouse (even a touch laptop)
## answers no to the second and keeps the desktop HUD; a phone/tablet answers yes to both.
## maxTouchPoints also catches iPadOS, which masquerades as desktop Safari.
func _detect_touch_ui() -> bool:
if (
OS.has_feature("mobile")
or OS.has_feature("web_android")
or OS.has_feature("web_ios")
):
return true
if not OS.has_feature("web"):
return false
var js := "(navigator.maxTouchPoints > 0) && window.matchMedia('(pointer: coarse)').matches"
var touch_primary: Variant = JavaScriptBridge.eval(js, true)
if touch_primary is bool:
return touch_primary
if touch_primary is int or touch_primary is float:
return int(touch_primary) != 0
return false
## Whether on-screen debug overlays (touch state + render diagnostics) should show. True on
## the DP "touch_debug" flag, or — so it can be switched on from a phone where no console is
## reachable — when the page URL carries a `debug` query param (append `?debug=1`).
var _url_debug: int = -1
func debug_overlay() -> bool:
if DP.b("touch_debug"):
return true
if _url_debug == -1:
_url_debug = 0
if OS.has_feature("web"):
var has: Variant = JavaScriptBridge.eval(
"String(window.location.search).indexOf('debug') >= 0", true)
if (has is bool and has) or ((has is int or has is float) and int(has) != 0):
_url_debug = 1
return _url_debug == 1
## Returns the first keyboard event bound to an action, or null. ## Returns the first keyboard event bound to an action, or null.
func get_key_event(action: StringName) -> InputEventKey: func get_key_event(action: StringName) -> InputEventKey:
for event: InputEvent in InputMap.action_get_events(action): for event: InputEvent in InputMap.action_get_events(action):
+37 -55
View File
@@ -1,19 +1,16 @@
extends MultiMeshInstance3D extends MultiMeshInstance3D
## A whole stand of cheering spectators rendered as camera-facing billboards in a ## A whole stand of cheering spectators rendered as flat billboards in a single draw
## single draw call — the performant replacement for dozens of skinned crowd skeletons. ## call — the performant replacement for dozens of skinned crowd skeletons. The sprite
## The sprite sheet is baked from the existing crowd figure by tools/bake_crowd_sheet.gd. ## sheet is baked from the existing crowd figure by tools/bake_crowd_sheet.gd.
## ##
## Placement mirrors an existing crowd node (default: the sibling PublikNode) so the ## Placement comes from CrowdMarker nodes the designer drops into the level (collected
## billboards land exactly where the crowd was authored; if that node is missing it ## via the &"crowd_marker" group): each marker gives one spectator its world position and
## falls back to a generated tribune so the scene still populates. ## a chosen facing (one of six 60°-spaced headings). If no markers are present the scene
## falls back to a generated tribune so it still populates.
const SHEET_PATH := "res://Assets/crowd_clap_sheet.png" const SHEET_PATH := "res://Assets/crowd_clap_sheet.png"
const SHADER_PATH := "res://crowd_billboard.gdshader" const SHADER_PATH := "res://crowd_billboard.gdshader"
## Node whose descendant figures (names starting with `figure_prefix`) donate their
## world positions. Empty = use the generated fallback stand.
@export var source_path: NodePath = ^"../PublikNode"
@export var figure_prefix: String = "mees"
## World height of a spectator billboard, in metres. Sheet aspect sets the width. ## World height of a spectator billboard, in metres. Sheet aspect sets the width.
@export var figure_height: float = 2.0 @export var figure_height: float = 2.0
@export var frames: int = 8 @export var frames: int = 8
@@ -36,7 +33,8 @@ var _rng := RandomNumberGenerator.new()
func _ready() -> void: func _ready() -> void:
_rng.seed = hash("bullosseum-crowd") # deterministic layout/phases across runs _rng.seed = hash("bullosseum-crowd") # deterministic layout/phases across runs
_build() # Deferred so every CrowdMarker has run _ready and joined the group before we harvest.
_build.call_deferred()
func _build() -> void: func _build() -> void:
@@ -58,71 +56,53 @@ func _build() -> void:
mat.set_shader_parameter("fps", clap_fps) mat.set_shader_parameter("fps", clap_fps)
material_override = mat material_override = mat
var seats := _seat_positions() # Each seat is a (global feet-position, unit forward) pair.
var center := _arena_center(seats) var positions := PackedVector3Array()
var facings: Array[Vector3] = []
_collect_seats(positions, facings)
var mm := MultiMesh.new() var mm := MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_3D mm.transform_format = MultiMesh.TRANSFORM_3D
mm.use_custom_data = true mm.use_custom_data = true
mm.mesh = quad mm.mesh = quad
mm.instance_count = seats.size() mm.instance_count = positions.size()
for i in seats.size(): for i in positions.size():
var s := 1.0 + _rng.randf_range(-0.06, 0.06) # slight height variety var s := 1.0 + _rng.randf_range(-0.06, 0.06) # slight height variety
mm.set_instance_transform(i, Transform3D(_facing_basis(seats[i], center, s), seats[i])) mm.set_instance_transform(i, Transform3D(_upright_basis(facings[i], s), positions[i]))
var b := 1.0 + _rng.randf_range(-tint_variation, tint_variation) var b := 1.0 + _rng.randf_range(-tint_variation, tint_variation)
mm.set_instance_custom_data(i, Color(_rng.randf(), b, b * 0.99, b * 0.98)) mm.set_instance_custom_data(i, Color(_rng.randf(), b, b * 0.99, b * 0.98))
multimesh = mm multimesh = mm
# Orientation for a seat: the sprite's forward (+Z, the quad's face) points horizontally # Upright basis whose forward (+Z, the quad's face) points along `look`, uniformly
# inward at the arena centre, so each ring segment faces the action instead of the # scaled by `s`. World +Y stays up so spectators never tilt.
# camera. Upright (world +Y), uniformly scaled by `s`. func _upright_basis(look: Vector3, s: float) -> Basis:
func _facing_basis(seat: Vector3, center: Vector3, s: float) -> Basis:
var look := center - seat
look.y = 0.0 look.y = 0.0
look = look.normalized() if look.length() > 0.001 else Vector3(0.0, 0.0, -1.0) look = look.normalized() if look.length() > 0.001 else Vector3(0.0, 0.0, 1.0)
var up := Vector3.UP var up := Vector3.UP
var right := up.cross(look).normalized()
var basis := Basis() var basis := Basis()
basis.x = right basis.x = up.cross(look).normalized()
basis.y = up basis.y = up
basis.z = look basis.z = look
return basis.scaled(Vector3.ONE * s) return basis.scaled(Vector3.ONE * s)
# Horizontal centre the crowd faces — the mean of the seat positions (the ring/stand # Seats from designer-placed CrowdMarker nodes: each contributes its world position and
# centroid), which for an arena crowd is the middle of the pitch. # its chosen 60°-spaced facing. With no markers present, fall back to a generated ring
func _arena_center(seats: PackedVector3Array) -> Vector3: # that faces inward so the scene still shows a crowd.
if seats.is_empty(): func _collect_seats(positions: PackedVector3Array, facings: Array[Vector3]) -> void:
return global_position for node in get_tree().get_nodes_in_group(&"crowd_marker"):
var sum := Vector3.ZERO var marker := node as Node3D
for p in seats: if marker == null or not marker.has_method(&"facing_dir"):
sum += p continue
return sum / float(seats.size()) positions.append(marker.global_position)
facings.append(marker.facing_dir())
if positions.is_empty():
_fallback_stand(positions, facings)
# Global feet-positions for every spectator: harvested from the source crowd if present, func _fallback_stand(positions: PackedVector3Array, facings: Array[Vector3]) -> void:
# otherwise the generated fallback tribune.
func _seat_positions() -> PackedVector3Array:
var out := PackedVector3Array()
var src := get_node_or_null(source_path)
if src != null:
_collect_figures(src, out)
if out.is_empty():
_fallback_stand(out)
return out
func _collect_figures(node: Node, out: PackedVector3Array) -> void:
if node is Node3D and node != self and (node as Node3D).name.begins_with(figure_prefix):
out.append((node as Node3D).global_position)
return # don't descend into a figure's own rig
for c in node.get_children():
_collect_figures(c, out)
func _fallback_stand(out: PackedVector3Array) -> void:
var placed := 0 var placed := 0
for row in fallback_rows: for row in fallback_rows:
var radius := fallback_inner_radius + float(row) * fallback_row_step var radius := fallback_inner_radius + float(row) * fallback_row_step
@@ -133,5 +113,7 @@ func _fallback_stand(out: PackedVector3Array) -> void:
if placed >= fallback_count: if placed >= fallback_count:
return return
var ang := TAU * float(s) / float(seats_in_row) var ang := TAU * float(s) / float(seats_in_row)
out.append(Vector3(cos(ang) * radius, y, sin(ang) * radius)) var pos := Vector3(cos(ang) * radius, y, sin(ang) * radius)
positions.append(pos)
facings.append(-pos) # face the ring centre (origin)
placed += 1 placed += 1
+89
View File
@@ -0,0 +1,89 @@
@tool
extends Node3D
class_name CrowdMarker
## A designer-placed spot for one billboard spectator. Drop these into a level in the
## editor; crowd_billboards.gd harvests every marker (via the &"crowd_marker" group) into
## its MultiMesh at runtime, using each marker's global position and chosen facing.
##
## The marker draws a lightweight preview (a slab standing in for the sprite + a forward
## arrow) so its position and orientation are visible in the viewport. That preview is
## editor-only and never serialised into the scene, so it costs nothing at runtime.
## The six selectable facings, one every 60° about the Y axis. DEG_0 faces the quad's
## front (+Z) along world +Z; increasing values rotate clockwise looking down.
enum Orientation {
DEG_0,
DEG_60,
DEG_120,
DEG_180,
DEG_240,
DEG_300,
}
const ORIENTATION_STEPS := 6
## Metres — matches crowd_billboards.figure_height so the editor preview reads true to scale.
const PREVIEW_HEIGHT := 2.0
@export var orientation := Orientation.DEG_0:
set(value):
orientation = value
_apply_orientation()
var _preview: Node3D
func _ready() -> void:
add_to_group(&"crowd_marker")
if Engine.is_editor_hint():
_build_preview()
_apply_orientation()
# Facing about Y for the current orientation: 0, 60, 120 … degrees as radians.
func facing_yaw() -> float:
return float(orientation) * (TAU / float(ORIENTATION_STEPS))
# Unit forward the spectator's front (+Z) points along, on the horizontal plane.
func facing_dir() -> Vector3:
var a := facing_yaw()
return Vector3(sin(a), 0.0, cos(a))
func _apply_orientation() -> void:
if _preview != null:
_preview.rotation = Vector3(0.0, facing_yaw(), 0.0)
# Editor-only preview. Children are added without an owner so they are excluded from the
# saved .tscn (Godot only serialises nodes owned by the scene root).
func _build_preview() -> void:
_preview = Node3D.new()
add_child(_preview)
var aspect := 0.5
var slab := MeshInstance3D.new()
var quad := QuadMesh.new()
quad.size = Vector2(PREVIEW_HEIGHT * aspect, PREVIEW_HEIGHT)
quad.center_offset = Vector3(0.0, PREVIEW_HEIGHT * 0.5, 0.0) # stand on the marker
slab.mesh = quad
slab.material_override = _preview_material(Color(0.3, 0.7, 1.0, 0.35))
_preview.add_child(slab)
var arrow := MeshInstance3D.new()
var shaft := BoxMesh.new()
shaft.size = Vector3(0.08, 0.08, PREVIEW_HEIGHT * 0.5)
arrow.mesh = shaft
# Push the shaft out along +Z (the facing) and up to chest height.
arrow.position = Vector3(0.0, PREVIEW_HEIGHT * 0.5, PREVIEW_HEIGHT * 0.25)
arrow.material_override = _preview_material(Color(1.0, 0.85, 0.2, 0.9))
_preview.add_child(arrow)
func _preview_material(col: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = col
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
return mat
+1
View File
@@ -0,0 +1 @@
uid://70j3ks1emhkd
+16
View File
@@ -0,0 +1,16 @@
shader_type canvas_item;
// Faint red gradient hugging the screen edges, flashed on a hit. `flash` is tweened
// 0.9 → 0 by the HUD; the vignette is transparent in the centre and reddens toward
// the corners so the middle of the screen stays clear.
uniform float flash : hint_range(0.0, 1.0) = 0.0;
uniform vec3 tint : source_color = vec3(0.75, 0.0, 0.0);
uniform float inner : hint_range(0.0, 1.0) = 0.45;
uniform float outer : hint_range(0.0, 1.0) = 1.0;
void fragment() {
// Distance from centre normalised so the corners land at ~1.0.
float dist = length(UV - vec2(0.5)) / 0.70710678;
float v = smoothstep(inner, outer, dist);
COLOR = vec4(tint, v * flash);
}
+1
View File
@@ -0,0 +1 @@
uid://dys23pu1kw5fl
+5 -1
View File
@@ -10,6 +10,7 @@ extends Node
## ##
## Toggle from the console: ## Toggle from the console:
## show_collisions · show_hitboxes · show_bones · show_raycasts (true / false) ## show_collisions · show_hitboxes · show_bones · show_raycasts (true / false)
## show_hitboxes also surfaces the bull's and bear's body capsules, not just Area3Ds
## show_states — floating AI-state tags + velocity vectors over each matador ## show_states — floating AI-state tags + velocity vectors over each matador
## show_animation_name — floating current-clip tag over every AnimationPlayer host ## show_animation_name — floating current-clip tag over every AnimationPlayer host
## show_stats — 2D corner readout: fps / frame ms / draw calls / matador tally ## show_stats — 2D corner readout: fps / frame ms / draw calls / matador tally
@@ -421,7 +422,10 @@ func _collect(node: Node, col: bool, hit: bool, bone: bool, anim: bool, wanted:
# Ragdoll bone colliders (PhysicalBone3D) are excluded — use show_bones for # Ragdoll bone colliders (PhysicalBone3D) are excluded — use show_bones for
# the skeleton; otherwise every corpse floods the view with capsules. # the skeleton; otherwise every corpse floods the view with capsules.
var is_body := parent is PhysicsBody3D and not (parent is PhysicalBone3D) var is_body := parent is PhysicsBody3D and not (parent is PhysicalBone3D)
if (is_area and hit) or (is_body and col): # The bull's and bear's body capsules are combat volumes too, so they also
# surface under show_hitboxes (not just show_collisions).
var is_combatant := is_body and (parent.is_in_group(&"player") or parent.is_in_group(&"bear"))
if (is_area and hit) or (is_body and col) or (is_combatant and hit):
wanted[cs.get_instance_id()] = [cs, is_area] wanted[cs.get_instance_id()] = [cs, is_area]
for child in node.get_children(): for child in node.get_children():
_collect(child, col, hit, bone, anim, wanted) _collect(child, col, hit, bone, anim, wanted)
+203 -40
View File
@@ -17,14 +17,13 @@ var _values: Dictionary = {}
func _ready() -> void: func _ready() -> void:
_register_all() _register_all()
load_saved()
func _register_all() -> void: func _register_all() -> void:
# ── Movement ────────────────────────────────────────────────────────────── # ── Movement ──────────────────────────────────────────────────────────────
# WASD/arrows drive the bull: move_accel ramps flat velocity toward max_speed in # WASD/arrows drive the bull: move_accel ramps flat velocity toward max_speed in
# the input direction; move_friction bleeds it off when no direction is held. # the input direction; move_friction bleeds it off when no direction is held.
_reg_f("Movement", "max_speed", 50.0, 2.0, 1000.0) _reg_f("Movement", "max_speed", 10.0, 2.0, 1000.0)
_reg_f("Movement", "move_accel", 120.0, 5.0, 600.0) _reg_f("Movement", "move_accel", 120.0, 5.0, 600.0)
_reg_f("Movement", "move_friction", 90.0, 5.0, 600.0) _reg_f("Movement", "move_friction", 90.0, 5.0, 600.0)
_reg_f("Movement", "jump_velocity", 4.5, 1.0, 200.0) _reg_f("Movement", "jump_velocity", 4.5, 1.0, 200.0)
@@ -33,6 +32,11 @@ func _register_all() -> void:
_reg_f("Movement", "bounce_min_impact", 1.0, 0.0, 20.0) _reg_f("Movement", "bounce_min_impact", 1.0, 0.0, 20.0)
_reg_f("Movement", "wall_bounce_restitution", 0.6, 0.0, 1.5, 0.05) _reg_f("Movement", "wall_bounce_restitution", 0.6, 0.0, 1.5, 0.05)
_reg_f("Movement", "wall_min_bounce_speed", 6.0, 0.0, 30.0) _reg_f("Movement", "wall_min_bounce_speed", 6.0, 0.0, 30.0)
# ── Bull health ───────────────────────────────────────────────────────────
# The bull soaks bull_max_hp clean hits before it dies; each hit grants
# bull_hit_iframes seconds of invulnerability so a single pass can't drain it.
_reg_f("Bull", "bull_max_hp", 10.0, 1.0, 100.0, 1.0)
_reg_f("Bull", "bull_hit_iframes", 0.8, 0.0, 3.0, 0.1)
# ── Dust speed thresholds ───────────────────────────────────────────────── # ── Dust speed thresholds ─────────────────────────────────────────────────
_reg_f("Dust", "dust_walk_spd", 1.5, 0.1, 10.0) _reg_f("Dust", "dust_walk_spd", 1.5, 0.1, 10.0)
_reg_f("Dust", "dust_charge_spd", 7.0, 1.0, 20.0) _reg_f("Dust", "dust_charge_spd", 7.0, 1.0, 20.0)
@@ -41,12 +45,12 @@ func _register_all() -> void:
_reg_f("Dust", "dust_explosiveness", 0.35, 0.0, 1.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_spread", 45.0, 5.0, 90.0)
_reg_f("Dust", "dust_gravity_y", -1.5,-15.0, 0.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_min", 0.20, 0.05, 3.0)
_reg_f("Dust", "walk_scale_max", 0.65, 0.05, 3.0) _reg_f("Dust", "walk_scale_max", 0.40, 0.05, 3.0)
_reg_f("Dust", "walk_vel_min", 0.8, 0.1, 12.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", "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_min", 0.35, 0.05, 6.0)
_reg_f("Dust", "charge_scale_max", 2.2, 0.05, 6.0) _reg_f("Dust", "charge_scale_max", 0.70, 0.05, 6.0)
_reg_f("Dust", "charge_vel_min", 2.8, 0.1, 20.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) _reg_f("Dust", "charge_vel_max", 5.5, 0.1, 20.0)
# ── Tail ────────────────────────────────────────────────────────────────── # ── Tail ──────────────────────────────────────────────────────────────────
@@ -94,14 +98,18 @@ func _register_all() -> void:
_reg_f("Camera", "cam_drag_speed", 3.5, 0.5, 20.0) _reg_f("Camera", "cam_drag_speed", 3.5, 0.5, 20.0)
_reg_f("Camera", "cam_follow_lerp", 10.0, 1.0, 40.0) _reg_f("Camera", "cam_follow_lerp", 10.0, 1.0, 40.0)
# ── Matador ─────────────────────────────────────────────────────────────── # ── Matador ───────────────────────────────────────────────────────────────
_reg_f("Matador", "mat_spawn_count", 1.0, 1.0, 30.0, 1.0) # Wave system: matadors enter the arena in escalating waves (1 → 2 → … → N where N
# is the number of placed spawn points). mat_spawn_stagger is the delay between each
# matador leaping in within a wave; mat_wave_pause is the breather after a wave clears.
_reg_f("Matador", "mat_spawn_stagger", 0.4, 0.0, 3.0, 0.05)
_reg_f("Matador", "mat_wave_pause", 2.0, 0.0, 8.0, 0.25)
_reg_f("Matador", "mat_walk_speed", 5.5, 0.5, 10.0) _reg_f("Matador", "mat_walk_speed", 5.5, 0.5, 10.0)
_reg_f("Matador", "mat_wander_radius", 22.0, 5.0, 50.0) _reg_f("Matador", "mat_wander_radius", 22.0, 5.0, 50.0)
# Radius of the inner fighting floor: bull + matadors spawn on this ring (so # Radius of the inner fighting floor: bull + matadors spawn on this ring (so
# they stay off the benches). Tune in the console to hug the bench edge. # they stay off the benches). Tune in the console to hug the bench edge.
_reg_f("Matador", "arena_spawn_radius", 10.0, 2.0, 22.0, 0.5) _reg_f("Matador", "arena_spawn_radius", 10.0, 2.0, 22.0, 0.5)
_reg_f("Matador", "mat_idle_min", 0.5, 0.0, 5.0, 0.1) _reg_f("Matador", "mat_idle_min", 1.2, 0.0, 5.0, 0.1)
_reg_f("Matador", "mat_idle_max", 2.5, 0.5, 10.0, 0.1) _reg_f("Matador", "mat_idle_max", 4.0, 0.5, 10.0, 0.1)
_reg_f("Matador", "mat_hit_threshold", 4.0, 1.0, 30.0) _reg_f("Matador", "mat_hit_threshold", 4.0, 1.0, 30.0)
_reg_f("Matador", "mat_ragdoll_impulse", 6.0, 0.5, 20.0) _reg_f("Matador", "mat_ragdoll_impulse", 6.0, 0.5, 20.0)
# Ragdoll joint limits — lower swing/twist = stiffer corpse that keeps its # Ragdoll joint limits — lower swing/twist = stiffer corpse that keeps its
@@ -125,7 +133,7 @@ func _register_all() -> void:
_reg_f("Matador", "mat_attack_range", 8.0, 2.0, 20.0) _reg_f("Matador", "mat_attack_range", 8.0, 2.0, 20.0)
_reg_f("Matador", "mat_attack_speed", 7.5, 1.0, 12.0) _reg_f("Matador", "mat_attack_speed", 7.5, 1.0, 12.0)
_reg_f("Matador", "mat_attack_duration", 1.8, 0.5, 5.0, 0.1) _reg_f("Matador", "mat_attack_duration", 1.8, 0.5, 5.0, 0.1)
_reg_f("Matador", "mat_attack_min_dist", 3.2, 0.5, 6.0, 0.1) _reg_f("Matador", "mat_attack_min_dist", 4.2, 0.5, 6.0, 0.1)
# Forward drive during the opening of a melee swing — turns the stationary stab # Forward drive during the opening of a melee swing — turns the stationary stab
# into an estocada lunge so the blade covers ground toward the bull. # into an estocada lunge so the blade covers ground toward the bull.
_reg_f("Matador", "mat_lunge_speed", 13.0, 0.0, 20.0) _reg_f("Matador", "mat_lunge_speed", 13.0, 0.0, 20.0)
@@ -153,43 +161,144 @@ func _register_all() -> void:
# bullfighter, caution for a slippery one. mat_w_commit is a stickiness bonus for # bullfighter, caution for a slippery one. mat_w_commit is a stickiness bonus for
# the current choice so it doesn't dither between two near-tied options. # the current choice so it doesn't dither between two near-tied options.
_reg_f("Matador", "mat_decide_interval", 0.2, 0.05, 1.0, 0.05) _reg_f("Matador", "mat_decide_interval", 0.2, 0.05, 1.0, 0.05)
_reg_f("Matador", "mat_w_aggression", 1.4, 0.0, 3.0, 0.05) _reg_f("Matador", "mat_w_aggression", 1.1, 0.0, 3.0, 0.05)
_reg_f("Matador", "mat_w_caution", 1.0, 0.0, 3.0, 0.05) _reg_f("Matador", "mat_w_caution", 1.0, 0.0, 3.0, 0.05)
_reg_f("Matador", "mat_w_flee", 0.8, 0.0, 3.0, 0.05) _reg_f("Matador", "mat_w_flee", 0.8, 0.0, 3.0, 0.05)
_reg_f("Matador", "mat_w_showmanship", 1.0, 0.0, 3.0, 0.05) # Showmanship (strut, circle the bull, taunt) — the supporting matadors lean on
# this to spread out and give the lead room, so a whole crowd reads as a bullring,
# not a swarm. Crank it for more posturing, drop it for a relentless mob.
_reg_f("Matador", "mat_w_showmanship", 1.6, 0.0, 3.0, 0.05)
# Weight on hurling the blade. Kept low — a thrown sword is an occasional flourish,
# not a barrage; raising this brings back the pelting.
_reg_f("Matador", "mat_w_throw", 0.35, 0.0, 3.0, 0.05)
# How many matadors press the bull at once. The nearest this-many engage (close,
# throw); everyone else keeps their distance and taunts, taking turns as the bull
# moves. 1 = strict one-on-one, higher = a bolder pack.
_reg_f("Matador", "mat_engage_slots", 2.0, 1.0, 8.0, 1.0)
# Preferred distance (m) a taunting matador circles the bull at while waiting its
# turn — the showmanship ring the crowd spreads out onto.
_reg_f("Matador", "mat_taunt_ring", 7.0, 3.0, 15.0, 0.5)
# How long (s) a matador safely out of the action holds a taunt before drifting to
# a new spot — much longer than the between-passes idle, so bystanders mostly pose
# for the crowd instead of milling about. Actual hold is randomised ±30%.
_reg_f("Matador", "mat_taunt_hold", 8.0, 1.0, 20.0, 0.5)
# Small so it smooths dithering without locking the matador into idling. # Small so it smooths dithering without locking the matador into idling.
_reg_f("Matador", "mat_w_commit", 0.15, 0.0, 2.0, 0.05) _reg_f("Matador", "mat_w_commit", 0.15, 0.0, 2.0, 0.05)
# ── Bear (boss) ─────────────────────────────────────────────────────────────
# A Dark-Souls-style boss with a learnable moveset. It soaks bear_max_hp clean hits
# (ram or ability), staggering on each, and only topples when worn down. From NEUTRAL
# it weighs its options by the range band to the bull and commits to one telegraphed
# routine (wind-up → active → recovery); each routine plays to the end (the recovery
# is the player's punish window). Below bear_enrage_frac HP it enters phase 2: faster
# tells and recovery (× bear_enrage_scale) and longer combo chains.
_reg_f("Bear", "bear_max_hp", 20.0, 1.0, 100.0, 1.0)
# HP fraction at which phase 2 ("Fury") triggers, and how much it multiplies every
# wind-up / recovery timer (lower = snappier, more dangerous).
_reg_f("Bear", "bear_enrage_frac", 0.4, 0.0, 1.0, 0.05)
_reg_f("Bear", "bear_enrage_scale", 0.7, 0.3, 1.0, 0.05)
# Range bands (m) that pick the moveset: ≤close it strikes (swipe / smash), ≤mid it
# walks in to close the gap (stalk) or leaps, beyond it lopes in (prowl) or leaps.
# close is deliberately generous — the bear commits to melee "from a bit further" and
# steps in during the swing (see bear_lunge_speed) rather than needing to be on top.
_reg_f("Bear", "bear_close_range", 5.0, 1.0, 12.0, 0.1)
_reg_f("Bear", "bear_mid_range", 9.0, 3.0, 20.0, 0.5)
# Beat spent in NEUTRAL sizing up the bull between routines — kept short so the bear
# flows straight from one action into the next instead of pausing (raise for a more
# hesitant, readable boss; near-0 for relentless pressure). Shrinks again in phase 2.
_reg_f("Bear", "bear_think_time", 0.06, 0.0, 1.0, 0.01)
# Locomotion: the all-fours prowl (close a big gap), the menacing 2-leg walk-in
# (stalk), the facing rate while positioning/winding up, and the softer tracking rate
# a swing keeps on the bull mid-attack so it's harder to just walk out of.
_reg_f("Bear", "bear_run_speed", 11.0, 1.0, 20.0, 0.1)
_reg_f("Bear", "bear_stalk_speed", 5.5, 0.5, 12.0, 0.1)
_reg_f("Bear", "bear_turn_rate", 7.0, 1.0, 20.0, 0.5)
_reg_f("Bear", "bear_track_active", 3.5, 0.0, 12.0, 0.1)
# Forward step-in during a swing so a strike lands even when the bull is a touch out of
# reach (also carries the tracking); 0 = a purely stationary swing.
_reg_f("Bear", "bear_lunge_speed", 7.0, 0.0, 16.0, 0.5)
# Playback multiplier for the swipe/smash clips — >1 makes the punches snappier.
_reg_f("Bear", "bear_attack_anim_speed", 1.35, 0.5, 3.0, 0.05)
# Half-arc (deg) the bear must be facing the bull within for a directional swing to
# connect; slams use their own radius instead of an arc.
_reg_f("Bear", "bear_hit_arc", 70.0, 5.0, 180.0, 5.0)
# Double Swipe (ONE_TWO_HIT) — bread-and-butter close combo, two quick hits.
_reg_f("Bear", "bear_swipe_windup", 0.3, 0.05, 2.0, 0.05)
_reg_f("Bear", "bear_swipe_recover", 0.35, 0.05, 3.0, 0.05)
_reg_f("Bear", "bear_swipe_reach", 4.2, 1.0, 8.0, 0.1)
# Overhead Smash (SMASH_HIT) — slower tell, biggest punish window, AoE on impact.
_reg_f("Bear", "bear_smash_windup", 0.6, 0.1, 3.0, 0.05)
_reg_f("Bear", "bear_smash_recover", 0.75, 0.1, 4.0, 0.05)
_reg_f("Bear", "bear_smash_radius", 4.2, 1.0, 8.0, 0.1)
# Leap Slam (JUMP_SMASH, 47 frames @30fps) — timed to the clip by FRAME: it plays the
# crouch grounded, springs at the takeoff frame, and the arc is solved so the paws hit
# ground exactly on the impact frame (25), leaving the tail frames to settle on the
# ground — no finishing the anim in mid-air. The arc uses its own gravity so peak height
# and airtime are independent: bear_leap_height is the peak reached in that fixed airtime.
_reg_f("Bear", "bear_leap_takeoff_frame", 6.0, 0.0, 46.0, 1.0)
_reg_f("Bear", "bear_leap_impact_frame", 25.0, 1.0, 47.0, 1.0)
_reg_f("Bear", "bear_leap_height", 2.6, 0.5, 8.0, 0.1)
_reg_f("Bear", "bear_leap_recover", 0.35, 0.0, 4.0, 0.05)
_reg_f("Bear", "bear_leap_speed", 14.0, 4.0, 30.0, 0.5)
_reg_f("Bear", "bear_slam_radius", 4.0, 1.0, 8.0, 0.1)
# Stalk (WALK_ON2LEGS) — how long it will keep walking the bull down before re-deciding.
_reg_f("Bear", "bear_stalk_time", 2.5, 0.0, 6.0, 0.1)
# Odds a close combo chains a follow-up smash after the swipe (phase 2 always chains).
_reg_f("Bear", "bear_combo_chance", 0.7, 0.0, 1.0, 0.05)
# Minimum bull speed (ram) that counts as a hit on the bear — a gentle nudge is
# shrugged off, only a real charge or an ability wounds it. (The bear itself never
# damages the bull by colliding — only its swings and slams hurt.)
_reg_f("Bear", "bear_hit_threshold", 6.0, 1.0, 30.0, 0.5)
# How long (s) the bear is rocked back and can't act after taking a hit.
_reg_f("Bear", "bear_stagger_time", 0.5, 0.0, 2.0, 0.05)
# Poise: after a stagger the bear shrugs off staggers for this long (hits still wound
# it), so it can't be perma-stunlocked — it gets a window to commit an attack that then
# rides its hyper-armour. 0 = no poise (stun-locks under sustained fire).
_reg_f("Bear", "bear_stagger_cd", 1.0, 0.0, 4.0, 0.05)
# ── Sword ───────────────────────────────────────────────────────────────── # ── Sword ─────────────────────────────────────────────────────────────────
# Local seating of the sword in the right hand (drawn / fighting). # Local seating of the sword in the right hand (drawn / fighting).
# Defaults are identity — the bone is oriented in Blender to seat the sword # The blade model runs along its local +Z, but weapon_bone (like every rig bone)
# directly; these exist to fine-tune in-game without re-exporting. # points down its local +Y — so identity leaves the blade jutting out sideways.
# rot_x = -90 maps the blade's +Z onto the bone's +Y so it thrusts straight along
# the bone; rot_y is then the free roll (crossguard tilt) about the blade axis.
_reg_f("Sword", "sword_pos_x", 0.0, -1.0, 1.0, 0.01) _reg_f("Sword", "sword_pos_x", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "sword_pos_y", 0.0, -1.0, 1.0, 0.01) _reg_f("Sword", "sword_pos_y", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "sword_pos_z", 0.0, -1.0, 1.0, 0.01) _reg_f("Sword", "sword_pos_z", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "sword_rot_x", 0.0, -180.0, 180.0, 1.0) _reg_f("Sword", "sword_rot_x", -90.0, -180.0, 180.0, 1.0)
_reg_f("Sword", "sword_rot_y", 0.0, -180.0, 180.0, 1.0) _reg_f("Sword", "sword_rot_y", 0.0, -180.0, 180.0, 1.0)
_reg_f("Sword", "sword_rot_z", 0.0, -180.0, 180.0, 1.0) _reg_f("Sword", "sword_rot_z", 0.0, -180.0, 180.0, 1.0)
# Local seating of the sword in the hip holster (sheathed / wandering). # Local seating of the sword in the hip holster (sheathed / wandering).
# Independent of the hand grip so each pose can be tuned without affecting the # Independent of the hand grip so each pose can be tuned without affecting the
# other. Defaults angle the blade down along the matador's side. # other. weapon_rest_bone sits on the left hip; these angle the blade down and
# out along the OUTSIDE of the left thigh (rot_x/rot_y aim the blade, rot_z rolls
# it about its own axis) so it trails back beside the leg instead of clipping it.
_reg_f("Sword", "sword_rest_pos_x", 0.0, -1.0, 1.0, 0.01) _reg_f("Sword", "sword_rest_pos_x", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "sword_rest_pos_y", 0.0, -1.0, 1.0, 0.01) _reg_f("Sword", "sword_rest_pos_y", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "sword_rest_pos_z", 0.0, -1.0, 1.0, 0.01) _reg_f("Sword", "sword_rest_pos_z", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "sword_rest_rot_x", -120.0, -180.0, 180.0, 1.0) _reg_f("Sword", "sword_rest_rot_x", -110.0, -180.0, 180.0, 1.0)
_reg_f("Sword", "sword_rest_rot_y", 0.0, -180.0, 180.0, 1.0) _reg_f("Sword", "sword_rest_rot_y", -133.0, -180.0, 180.0, 1.0)
_reg_f("Sword", "sword_rest_rot_z", 0.0, -180.0, 180.0, 1.0) _reg_f("Sword", "sword_rest_rot_z", 0.0, -180.0, 180.0, 1.0)
# ── Sword throw (matador ranged attack) ────────────────────────────────── # ── Spear throw (matador ranged attack) ──────────────────────────────────
# Delay before a fresh sword appears in the holster after one is thrown. # Local seating of the spear in the throwing hand during the wind-up. Tune to
# seat the shaft in the grip without re-exporting; flight orientation is derived
# from the aim direction at release, not from this pose.
_reg_f("Sword", "spear_pos_x", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "spear_pos_y", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "spear_pos_z", 0.0, -1.0, 1.0, 0.01)
_reg_f("Sword", "spear_rot_x", 0.0, -180.0, 180.0, 1.0)
_reg_f("Sword", "spear_rot_y", 0.0, -180.0, 180.0, 1.0)
_reg_f("Sword", "spear_rot_z", 0.0, -180.0, 180.0, 1.0)
# Delay before a fresh sword reappears in the holster if one is ever lost.
_reg_f("Sword", "mat_resword_delay", 2.0, 0.0, 10.0, 0.5) _reg_f("Sword", "mat_resword_delay", 2.0, 0.0, 10.0, 0.5)
_reg_f("Sword", "mat_throw_range", 18.0, 5.0, 40.0) _reg_f("Sword", "mat_throw_range", 18.0, 5.0, 40.0)
_reg_f("Sword", "mat_throw_min_dist", 5.0, 2.0, 15.0, 0.1) _reg_f("Sword", "mat_throw_min_dist", 5.0, 2.0, 15.0, 0.1)
_reg_f("Sword", "mat_throw_windup", 0.5, 0.2, 2.0, 0.05) _reg_f("Sword", "mat_throw_windup", 0.5, 0.2, 2.0, 0.05)
_reg_f("Sword", "mat_throw_release", 0.55, 0.1, 0.95, 0.01) _reg_f("Sword", "mat_throw_release", 0.55, 0.1, 0.95, 0.01)
_reg_f("Sword", "mat_throw_speed", 24.0, 5.0, 60.0) # Horizontal speed of the thrown spear; the vertical launch is solved from this
_reg_f("Sword", "mat_throw_spin", 14.0, 0.0, 40.0) # and the gravity below so the arc lands on the bull. Lower = a higher, slower lob.
# Gravity scale on the thrown blade (low = flies straight to the aim point) and _reg_f("Sword", "mat_throw_speed", 11.0, 5.0, 60.0)
# the vertical aim offset from the bull origin down onto its low collision body. # Gravity scale on the spear (higher = a more pronounced arc) and the vertical aim
_reg_f("Sword", "mat_throw_gravity", 0.25, 0.0, 1.0, 0.05) # offset from the bull origin down onto its low collision body.
_reg_f("Sword", "mat_throw_gravity", 1.0, 0.1, 3.0, 0.05)
_reg_f("Sword", "mat_throw_aim_y", -0.45,-1.5, 0.5, 0.05) _reg_f("Sword", "mat_throw_aim_y", -0.45,-1.5, 0.5, 0.05)
# Aim scatter (± degrees) so thrown blades can be read and dodged on the move. # Aim scatter (± degrees) so thrown blades can be read and dodged on the move.
_reg_f("Sword", "mat_throw_spread", 11.0, 0.0, 30.0, 0.5) _reg_f("Sword", "mat_throw_spread", 11.0, 0.0, 30.0, 0.5)
@@ -197,10 +306,10 @@ func _register_all() -> void:
_reg_f("Sword", "mat_throw_arm_release", -90.0,-180.0, 180.0, 1.0) _reg_f("Sword", "mat_throw_arm_release", -90.0,-180.0, 180.0, 1.0)
_reg_f("Sword", "mat_throw_elbow", 80.0,-180.0, 180.0, 1.0) _reg_f("Sword", "mat_throw_elbow", 80.0,-180.0, 180.0, 1.0)
# ── Abilities ───────────────────────────────────────────────────────────── # ── Abilities ─────────────────────────────────────────────────────────────
_reg_f("Abilities", "kick_cooldown", 1.5, 0.2, 10.0, 0.1) _reg_f("Abilities", "kick_cooldown", 2, 0.2, 600, 0.1)
_reg_f("Abilities", "kick_range", 3.5, 0.5, 8.0, 0.1) _reg_f("Abilities", "kick_range", 6.0, 0.5, 8.0, 0.1)
_reg_f("Abilities", "kick_strength", 12.0, 1.0, 30.0) _reg_f("Abilities", "kick_strength", 12.0, 1.0, 30.0)
_reg_f("Abilities", "slam_cooldown", 4.0, 0.5, 15.0, 0.1) _reg_f("Abilities", "slam_cooldown", 12, 0.5, 600, 0.1)
_reg_f("Abilities", "slam_jump_velocity", 7.0, 2.0, 20.0) _reg_f("Abilities", "slam_jump_velocity", 7.0, 2.0, 20.0)
# Descent gravity multiplier — higher = faster crash, less airtime. # Descent gravity multiplier — higher = faster crash, less airtime.
_reg_f("Abilities", "slam_fall_mult", 3.0, 1.0, 8.0, 0.5) _reg_f("Abilities", "slam_fall_mult", 3.0, 1.0, 8.0, 0.5)
@@ -209,19 +318,24 @@ func _register_all() -> void:
_reg_f("Abilities", "slam_launch_up", 7.0, 0.0, 15.0, 0.5) _reg_f("Abilities", "slam_launch_up", 7.0, 0.0, 15.0, 0.5)
_reg_f("Abilities", "slam_range", 4.0, 1.0, 10.0, 0.1) _reg_f("Abilities", "slam_range", 4.0, 1.0, 10.0, 0.1)
_reg_f("Abilities", "slam_strength", 14.0, 1.0, 30.0) _reg_f("Abilities", "slam_strength", 14.0, 1.0, 30.0)
_reg_f("Abilities", "dash_cooldown", 2.0, 0.5, 10.0, 0.1) _reg_f("Abilities", "dash_cooldown", 3, 0.1, 600.0, 0.1)
_reg_f("Abilities", "dash_speed", 66.0, 5.0, 200.0) _reg_f("Abilities", "dash_speed", 66.0, 5.0, 200.0)
# ── Roll (Rammus Powerball) ─────────────────────────────────────────────── # Exponential rate the dash bleeds from dash_speed back to cruise (max_speed).
# Higher = a snappier bullet that dies off quicker; lower = a longer glide.
_reg_f("Abilities", "dash_decel", 7.0, 0.5, 30.0, 0.5)
# ── Roll (homing chain ball) ──────────────────────────────────────────────
# The bull curls into a ball and ACCELERATES the longer it rolls, from # The bull curls into a ball and ACCELERATES the longer it rolls, from
# roll_base_speed up to roll_max_speed over roll_rampup_time. Turning is wide # roll_base_speed up to roll_max_speed over roll_rampup_time. It HOMES on the
# (momentum steering via roll_turn). On slamming into a matador the ball POPS: # closest matador; on slamming one the ball POPS it (launched skyward via
# the target is launched skyward (roll_knockup) and the roll ends. # roll_knockup) then CHAINS to the next closest, up to roll_chain_count times.
_reg_f("Roll", "roll_cooldown", 3.0, 0.5, 15.0, 0.1) # roll_turn is the homing turn rate. When no chain targets remain, the roll ends.
_reg_f("Roll", "roll_cooldown", 20, 0.5, 600, 0.1)
_reg_f("Roll", "roll_duration", 2.5, 0.5, 6.0, 0.1) # max roll time _reg_f("Roll", "roll_duration", 2.5, 0.5, 6.0, 0.1) # max roll time
_reg_f("Roll", "roll_chain_count", 2.0, 0.0, 8.0, 1.0) # extra matadors chained after the first
_reg_f("Roll", "roll_base_speed", 28.0, 5.0, 120.0) # speed at the start of the roll _reg_f("Roll", "roll_base_speed", 28.0, 5.0, 120.0) # speed at the start of the roll
_reg_f("Roll", "roll_max_speed", 95.0, 10.0, 200.0) # top speed once fully ramped _reg_f("Roll", "roll_max_speed", 95.0, 10.0, 200.0) # top speed once fully ramped
_reg_f("Roll", "roll_rampup_time", 2.0, 0.2, 6.0, 0.1) # time to reach top speed _reg_f("Roll", "roll_rampup_time", 2.0, 0.2, 6.0, 0.1) # time to reach top speed
_reg_f("Roll", "roll_turn", 7.0, 0.2, 20.0) # steer rate (WASD/arrows; higher = tighter) _reg_f("Roll", "roll_turn", 7.0, 0.2, 20.0) # homing turn rate (higher = tighter tracking)
_reg_f("Roll", "roll_spin_radius", 1.4, 0.3, 4.0, 0.1) # ball radius for tumble spin (smaller = faster) _reg_f("Roll", "roll_spin_radius", 1.4, 0.3, 4.0, 0.1) # ball radius for tumble spin (smaller = faster)
_reg_f("Roll", "roll_hit_radius", 2.4, 0.5, 6.0, 0.1) # contact radius that pops the ball _reg_f("Roll", "roll_hit_radius", 2.4, 0.5, 6.0, 0.1) # contact radius that pops the ball
_reg_f("Roll", "roll_hit_strength", 22.0, 1.0, 40.0) # horizontal launch on the pop _reg_f("Roll", "roll_hit_strength", 22.0, 1.0, 40.0) # horizontal launch on the pop
@@ -235,8 +349,8 @@ func _register_all() -> void:
# a uniform out of bounds. # a uniform out of bounds.
_reg_b("PS1", "show_ps1", true) _reg_b("PS1", "show_ps1", true)
_reg_f("PS1", "ps1_pixel_scale", 4.0, 1.0, 8.0, 1.0) _reg_f("PS1", "ps1_pixel_scale", 4.0, 1.0, 8.0, 1.0)
_reg_f("PS1", "ps1_color_depth", 5.0, 2.0, 8.0, 1.0) _reg_f("PS1", "ps1_color_depth", 4.5, 2.0, 8.0, 1.0)
_reg_f("PS1", "ps1_dither_strength", 0.8, 0.0, 2.0, 0.05) _reg_f("PS1", "ps1_dither_strength", 1, 0.0, 2.0, 0.05)
_reg_f("PS1", "ps1_scanline_strength", 0.2, 0.0, 1.0, 0.05) _reg_f("PS1", "ps1_scanline_strength", 0.2, 0.0, 1.0, 0.05)
_reg_f("PS1", "ps1_roll_speed", 3.0, 0.0, 10.0, 0.1) _reg_f("PS1", "ps1_roll_speed", 3.0, 0.0, 10.0, 0.1)
_reg_f("PS1", "ps1_roll_strength", 1.0, 0.0, 1.0, 0.05) _reg_f("PS1", "ps1_roll_strength", 1.0, 0.0, 1.0, 0.05)
@@ -250,6 +364,12 @@ func _register_all() -> void:
_reg_b("Debug", "show_wireframe", false) _reg_b("Debug", "show_wireframe", false)
_reg_b("Debug", "show_grid", false) _reg_b("Debug", "show_grid", false)
_reg_b("Debug", "show_axes", false) _reg_b("Debug", "show_axes", false)
# Force the on-screen touch controls (joystick + ability buttons) on even on a
# desktop build — otherwise they appear only when a touchscreen is detected.
_reg_b("Touch", "force_touch_controls", false)
# On-screen readout of live touch state (event count, last position, active zone) so
# touch problems can be diagnosed on the actual device, where no console is reachable.
_reg_b("Touch", "touch_debug", false)
func _reg_f(section: String, key: String, default: float, func _reg_f(section: String, key: String, default: float,
@@ -300,11 +420,54 @@ func get_all() -> Dictionary:
func save() -> void: func save() -> void:
var cfg := ConfigFile.new() var path := "res://debug_params.gd"
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
push_error("DP.save: cannot open " + path)
return
var src := file.get_as_text()
file.close()
for key: String in _params: for key: String in _params:
var p: Dictionary = _params[key] var p: Dictionary = _params[key]
cfg.set_value(p["section"], key, p["value"]) if p["value"] == p["default"]:
cfg.save(SAVE_PATH) continue
var re := RegEx.new()
var literal := ""
if p["type"] == TYPE_FLOAT:
re.compile('(_reg_f\\("[^"]+",\\s*"%s",\\s*)([-\\d.e]+)' % key)
literal = _fmt_float(p["value"] as float)
else:
re.compile('(_reg_b\\("[^"]+",\\s*"%s",\\s*)(true|false)' % key)
literal = "true" if bool(p["value"]) else "false"
var m := re.search(src)
if m == null:
push_warning("DP.save: no _reg_ line found for '%s' — skipped" % key)
continue
# Splice the value in by index rather than RegEx.sub(): sub()'s replacement
# string parses "$n" as a backreference, so "$1"+"12.5" becomes "$112.5"
# (group 112 → PCRE2 error → mangled output). Indexed splice inserts the
# literal verbatim, exactly the value the user entered.
src = src.substr(0, m.get_start(2)) + literal + src.substr(m.get_end(2))
p["default"] = p["value"]
var wfile := FileAccess.open(path, FileAccess.WRITE)
if wfile == null:
push_error("DP.save: cannot write " + path)
return
wfile.store_string(src)
wfile.close()
# Godot's String % operator has no "g" conversion — "%.6g" % v slips past the
# compiler (v is not const) but emits the literal "%.6g" at runtime, which would be
# written straight into the source. "%s" gives the shortest round-tripping form,
# matching the value the user entered; the guard keeps it a float literal.
func _fmt_float(v: float) -> String:
var s := "%s" % v
if not ("." in s or "e" in s):
s += ".0"
return s
func load_saved() -> void: func load_saved() -> void:
+44 -3
View File
@@ -1,26 +1,67 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Run from the project root regardless of where the script is invoked.
cd "$(dirname "$0")"
SERVER_USER="richard" SERVER_USER="richard"
SERVER_HOST="192.168.1.168" SERVER_HOST="192.168.1.168"
SERVER_PATH="/opt/bulloseum/data" SERVER_PATH="/opt/bulloseum/data"
BUILD_DIR="build/web" BUILD_DIR="build/web"
# Derive the version the same way GameVersion does (single source of truth):
# numeric version from project.godot, pre-release channel from game_version.gd.
VERSION_NUMBER="$(grep -oP 'config/version="\K[^"]+' project.godot)"
CHANNEL="$(grep -oP 'const CHANNEL: String = "\K[^"]*' game_version.gd || true)"
VERSION="v${VERSION_NUMBER}"
[ -n "$CHANNEL" ] && VERSION="${VERSION}-${CHANNEL}"
GIT_COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "building Bullosseum ${VERSION} (${GIT_COMMIT})"
mkdir -p "$BUILD_DIR" mkdir -p "$BUILD_DIR"
godot --headless --export-release "Web" "$BUILD_DIR/index.html" godot --headless --export-release "Web" "$BUILD_DIR/index.html"
gzip -kf "$BUILD_DIR/index.wasm" gzip -kf "$BUILD_DIR/index.wasm"
# Version stamp shipped alongside the flat build so the server records what's live.
cat > "$BUILD_DIR/version.txt" <<EOF
version=${VERSION}
commit=${GIT_COMMIT}
built=${BUILD_DATE}
EOF
# Cache-bust: give wasm/pck unique names so browsers never serve a stale build.
# index.js gets a ?v= query string; wasm/pck get renamed because the engine
# fetches them internally using GODOT_CONFIG.executable as the base name.
STAMP="$GIT_COMMIT"
cp "$BUILD_DIR/index.wasm" "$BUILD_DIR/index.${STAMP}.wasm"
cp "$BUILD_DIR/index.wasm.gz" "$BUILD_DIR/index.${STAMP}.wasm.gz"
cp "$BUILD_DIR/index.pck" "$BUILD_DIR/index.${STAMP}.pck"
sed -i \
-e "s|src=\"index\.js\"|src=\"index.js?v=${STAMP}\"|" \
-e "s|\"executable\":\"index\"|\"executable\":\"index.${STAMP}\"|" \
-e "s|\"index\.pck\"|\"index.${STAMP}.pck\"|g" \
-e "s|\"index\.wasm\"|\"index.${STAMP}.wasm\"|g" \
"$BUILD_DIR/index.html"
# Remove previous build's versioned assets from the server before uploading.
ssh "$SERVER_USER@$SERVER_HOST" "find $SERVER_PATH -maxdepth 1 -name 'index.*.wasm' -o -name 'index.*.wasm.gz' -o -name 'index.*.pck' | xargs -r rm -f"
scp "$BUILD_DIR/index.html" \ scp "$BUILD_DIR/index.html" \
"$BUILD_DIR/index.js" \ "$BUILD_DIR/index.js" \
"$BUILD_DIR/index.wasm.gz" \ "$BUILD_DIR/index.${STAMP}.wasm.gz" \
"$BUILD_DIR/index.pck" \ "$BUILD_DIR/index.${STAMP}.pck" \
"$BUILD_DIR/index.audio.worklet.js" \ "$BUILD_DIR/index.audio.worklet.js" \
"$BUILD_DIR/index.audio.position.worklet.js" \ "$BUILD_DIR/index.audio.position.worklet.js" \
"$BUILD_DIR/index.icon.png" \ "$BUILD_DIR/index.icon.png" \
"$BUILD_DIR/index.apple-touch-icon.png" \ "$BUILD_DIR/index.apple-touch-icon.png" \
"$BUILD_DIR/index.png" \ "$BUILD_DIR/index.png" \
"$BUILD_DIR/version.txt" \
"$SERVER_USER@$SERVER_HOST:$SERVER_PATH/" "$SERVER_USER@$SERVER_HOST:$SERVER_PATH/"
echo "deployed to $SERVER_HOST:$SERVER_PATH" echo "deployed ${VERSION} (${STAMP}) to $SERVER_HOST:$SERVER_PATH"
+88 -2
View File
@@ -1,18 +1,30 @@
[runnable_presets]
Web="Web"
"Windows Desktop"="Windows Desktop"
[preset.0] [preset.0]
name="Web" name="Web"
platform="Web" platform="Web"
runnable=true
dedicated_server=false dedicated_server=false
custom_features="" custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="" include_filter=""
exclude_filter="" exclude_filter=""
export_path="build/web/index.html" export_path="build/web/index.html"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
patch_delta_min_reduction=0.1
patch_delta_include_filters="*"
patch_delta_exclude_filters=""
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
seed=0
encrypt_pck=false encrypt_pck=false
encrypt_directory=false encrypt_directory=false
script_export_mode=2
[preset.0.options] [preset.0.options]
@@ -21,7 +33,7 @@ custom_template/release=""
variant/extensions_support=false variant/extensions_support=false
variant/thread_support=true variant/thread_support=true
vram_texture_compression/for_desktop=true vram_texture_compression/for_desktop=true
vram_texture_compression/for_mobile=false vram_texture_compression/for_mobile=true
html/export_icon=true html/export_icon=true
html/custom_html_shell="" html/custom_html_shell=""
html/head_include="" html/head_include=""
@@ -29,6 +41,7 @@ html/canvas_resize_policy=2
html/focus_canvas_on_start=true html/focus_canvas_on_start=true
html/experimental_virtual_keyboard=false html/experimental_virtual_keyboard=false
progressive_web_app/enabled=false progressive_web_app/enabled=false
progressive_web_app/ensure_cross_origin_isolation_headers=true
progressive_web_app/offline_page="" progressive_web_app/offline_page=""
progressive_web_app/display=1 progressive_web_app/display=1
progressive_web_app/orientation=0 progressive_web_app/orientation=0
@@ -36,3 +49,76 @@ progressive_web_app/icon_144x144=""
progressive_web_app/icon_180x180="" progressive_web_app/icon_180x180=""
progressive_web_app/icon_512x512="" progressive_web_app/icon_512x512=""
progressive_web_app/background_color=Color(0, 0, 0, 1) progressive_web_app/background_color=Color(0, 0, 0, 1)
threads/emscripten_pool_size=8
threads/godot_pool_size=4
[preset.1]
name="Windows Desktop"
platform="Windows Desktop"
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="../Export/Bullosseum_020.exe"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
patch_delta_min_reduction=0.1
patch_delta_include_filters="*"
patch_delta_exclude_filters=""
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.1.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=false
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
shader_baker/enabled=false
binary_format/architecture="x86_64"
codesign/enable=false
codesign/timestamp=true
codesign/timestamp_server_url=""
codesign/digest_algorithm=1
codesign/description=""
codesign/custom_options=PackedStringArray()
application/modify_resources=true
application/icon=""
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version=""
application/product_version=""
application/company_name=""
application/product_name=""
application/file_description=""
application/copyright=""
application/trademarks=""
application/export_angle=0
application/export_d3d12=0
application/d3d12_agility_sdk_multiarch=true
ssh_remote_deploy/enabled=false
ssh_remote_deploy/host="user@host_ip"
ssh_remote_deploy/port="22"
ssh_remote_deploy/extra_args_ssh=""
ssh_remote_deploy/extra_args_scp=""
ssh_remote_deploy/run_script="Expand-Archive -LiteralPath '{temp_dir}\\{archive_name}' -DestinationPath '{temp_dir}'
$action = New-ScheduledTaskAction -Execute '{temp_dir}\\{exe_name}' -Argument '{cmd_args}'
$trigger = New-ScheduledTaskTrigger -Once -At 00:00
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
$task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings
Register-ScheduledTask godot_remote_debug -InputObject $task -Force:$true
Start-ScheduledTask -TaskName godot_remote_debug
while (Get-ScheduledTask -TaskName godot_remote_debug | ? State -eq running) { Start-Sleep -Milliseconds 100 }
Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue"
ssh_remote_deploy/cleanup_script="Stop-ScheduledTask -TaskName godot_remote_debug -ErrorAction:SilentlyContinue
Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue
Remove-Item -Recurse -Force '{temp_dir}'"
+353 -35
View File
@@ -5,25 +5,25 @@ const _CREAM := Color(0.93, 0.88, 0.72)
const _MUTED := Color(0.66, 0.60, 0.46) const _MUTED := Color(0.66, 0.60, 0.46)
# Per-ability trim colour, tying each tile (and its key cap) to that ability's FX. # Per-ability trim colour, tying each tile (and its key cap) to that ability's FX.
# Order matches _ABILITY_ICONS: kick, slam, dash, roll. # Order matches _ABILITY_ICONS: kick, dash, slam, roll.
const _ABILITY_ACCENT: Array[Color] = [ const _ABILITY_ACCENT: Array[Color] = [
Color(1.00, 0.62, 0.16), # Kick — ember orange Color(1.00, 0.62, 0.16), # Kick — ember orange
Color(0.98, 0.34, 0.20), # Slam — crimson
Color(0.45, 0.75, 1.00), # Dash — sky blue Color(0.45, 0.75, 1.00), # Dash — sky blue
Color(0.98, 0.34, 0.20), # Slam — crimson
Color(1.00, 0.82, 0.32), # Roll — gold Color(1.00, 0.82, 0.32), # Roll — gold
] ]
const _ABILITY_ICONS: Array[String] = [ const _ABILITY_ICONS: Array[String] = [
"res://HUD/KICK.png", "res://HUD/KICK.png",
"res://HUD/SLAM.png",
"res://HUD/DASH.png", "res://HUD/DASH.png",
"res://HUD/SLAM.png",
"res://HUD/ROLL.png", "res://HUD/ROLL.png",
] ]
const _ABILITY_ACTIONS: Array[StringName] = [ const _ABILITY_ACTIONS: Array[StringName] = [
&"ability_kick", &"ability_slam", &"ability_dash", &"ability_roll", &"ability_kick", &"ability_dash", &"ability_slam", &"ability_roll",
] ]
# Fallback label shown in a slot whose icon file fails to load. # Fallback label shown in a slot whose icon file fails to load.
const _ABILITY_NAMES: Array[String] = ["Kick", "Slam", "Dash", "Roll"] const _ABILITY_NAMES: Array[String] = ["Kick", "Dash", "Slam", "Roll"]
var _controls_visible: bool = false var _controls_visible: bool = false
var _controls_panel: PanelContainer var _controls_panel: PanelContainer
@@ -34,18 +34,45 @@ var _player: Node = null
var _died_connected: bool = false var _died_connected: bool = false
var _cd_overlays: Array[ColorRect] = [] var _cd_overlays: Array[ColorRect] = []
var _cd_labels: Array[Label] = [] var _cd_labels: Array[Label] = []
var _hp_label: Label
var _hp_segments: HBoxContainer
var _hp_seg_fills: Array[ColorRect] = []
var _bottom_col: VBoxContainer
var _ability_bar: HBoxContainer var _ability_bar: HBoxContainer
var _ability_panels: Array[PanelContainer] = [] var _ability_panels: Array[PanelContainer] = []
var _bull_bg: TextureRect
# Red damage vignette — a full-screen shader ColorRect flashed on every hit.
const _VIGNETTE_SHADER := "res://damage_vignette.gdshader"
var _vignette: ColorRect
var _vignette_mat: ShaderMaterial
# Bull-head backdrop for the gameplay HUD. The image is a wide horned silhouette; its
# solid "face" occupies the centred middle band, with horns flaring above. We size the
# image so that face band wraps the ability+HP cluster (horns crowning it above).
const _BULL_BG := "res://Assets/bull_hud_bg.png"
const _BULL_FACE_W: float = 0.529 # face width as a fraction of image width
const _BULL_ASPECT: float = 0.2741 # image height / width (267 / 974)
const _BULL_FACE_PAD: float = 1.15 # breathing room around the cluster within the face
# Win/lose screen. _game_over latches so the result is shown once; _result_win # Win/lose screen. _game_over latches so the result is shown once; _result_win
# records which way it went (read by the gameplay test). # records which way it went (read by the gameplay test).
const _WIN_VIDEO := "res://videos/bull_win.ogv" # the bull triumphs const _WIN_VIDEO := "res://videos/bull_win.ogv" # the bull triumphs
const _LOSS_VIDEO := "res://videos/matador_win.ogv" # the matador triumphs const _LOSS_VIDEO := "res://videos/matador_win.ogv" # the matador triumphs
# Beat between the run ending and the outcome screen. A loss is a short freeze on the
# dead bull; a win gives the player a longer stretch to strut a victory lap around the
# arena before the screen (and the pause) takes over.
const _LOSS_DELAY := 2.0
const _WIN_DELAY := 3.0
var _game_over: bool = false var _game_over: bool = false
var _result_win: bool = false var _result_win: bool = false
var _over_root: Control var _over_root: Control
var _over_video: VideoStreamPlayer var _over_video: VideoStreamPlayer
var _over_label: Label
var _over_continue: Button
var _over_again: Button
func _ready() -> void: func _ready() -> void:
@@ -53,12 +80,89 @@ func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS process_mode = Node.PROCESS_MODE_ALWAYS
_build_controls_panel() _build_controls_panel()
_build_tab_hint() _build_tab_hint()
_build_bottom_hud()
_build_bull_hud_bg()
_build_ability_bar() _build_ability_bar()
_build_health()
_build_version_label() _build_version_label()
_build_damage_vignette()
_build_game_over() _build_game_over()
_build_touch_controls()
_update_control_hints()
_find_spawner.call_deferred() _find_spawner.call_deferred()
# On-screen joystick + ability buttons for touch devices. Self-gating: the overlay
# only shows itself when a touchscreen is present (or DP force_touch_controls is on),
# so this is a no-op on desktop.
func _build_touch_controls() -> void:
add_child((load("res://touch_controls.gd") as Script).new())
# Bottom-centre column that groups the gameplay HUD (abilities on top, bull health
# directly beneath) so both read as one cluster instead of scattered corners.
func _build_bottom_hud() -> void:
_bottom_col = VBoxContainer.new()
_bottom_col.add_theme_constant_override("separation", 8)
_bottom_col.alignment = BoxContainer.ALIGNMENT_END
# Collapsed horizontal anchors so the column shrinks to its content and stays
# bottom-centred; the bull backdrop is sized against this content rect.
_bottom_col.anchor_left = 0.5
_bottom_col.anchor_right = 0.5
_bottom_col.anchor_top = 1.0
_bottom_col.anchor_bottom = 1.0
_bottom_col.grow_horizontal = Control.GROW_DIRECTION_BOTH
_bottom_col.grow_vertical = Control.GROW_DIRECTION_BEGIN
_bottom_col.offset_bottom = -16.0
_bottom_col.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_bottom_col)
# Bull-head plate that sits behind the ability + health cluster. It's a sibling of the
# cluster (not a parent) so it never constrains the cluster's layout; we size and place
# it against the cluster's rect in _position_bull_bg.
# Skipped on touch/phone: the on-screen thumb cluster already crowds the bottom edge, so
# the wide horned plate just adds clutter there.
func _build_bull_hud_bg() -> void:
if Controls.use_touch_ui():
return
_bull_bg = TextureRect.new()
if ResourceLoader.exists(_BULL_BG):
_bull_bg.texture = load(_BULL_BG)
# We size the rect ourselves to the image's aspect, so STRETCH_SCALE fills it
# exactly with no letterboxing.
_bull_bg.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_bull_bg.stretch_mode = TextureRect.STRETCH_SCALE
_bull_bg.modulate.a = 0.95
_bull_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_bull_bg)
# Draw the plate behind the cluster.
move_child(_bull_bg, _bottom_col.get_index())
_bottom_col.resized.connect(_position_bull_bg)
get_viewport().size_changed.connect(_position_bull_bg)
_position_bull_bg.call_deferred()
# Wrap the bull's solid "face" band around the current cluster rect: scale the image up
# so its face spans the cluster (plus padding), centre it horizontally on the cluster,
# and pin its bottom edge to the bottom of the screen so the plate is always seated flush
# against the screen edge on any resolution (horns crowning the cluster above).
func _position_bull_bg() -> void:
if _bull_bg == null or _bottom_col == null:
return
var r := _bottom_col.get_global_rect()
if r.size.x <= 1.0:
return
var vp_h := get_viewport().get_visible_rect().size.y
var bw := (r.size.x * _BULL_FACE_PAD) / _BULL_FACE_W
var bh := bw * _BULL_ASPECT
_bull_bg.size = Vector2(bw, bh)
_bull_bg.position = Vector2(
r.position.x + r.size.x * 0.5 - bw * 0.5,
vp_h - bh)
# Unobtrusive early-build tag in the bottom-right corner. # Unobtrusive early-build tag in the bottom-right corner.
func _build_version_label() -> void: func _build_version_label() -> void:
var lbl := Label.new() var lbl := Label.new()
@@ -75,6 +179,7 @@ func _build_version_label() -> void:
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
_update_control_hints()
if _player == null: if _player == null:
var players := get_tree().get_nodes_in_group(&"player") var players := get_tree().get_nodes_in_group(&"player")
if not players.is_empty(): if not players.is_empty():
@@ -83,6 +188,9 @@ func _process(_delta: float) -> void:
if not _died_connected: if not _died_connected:
_died_connected = true _died_connected = true
_player.died.connect(_on_player_died) _player.died.connect(_on_player_died)
_player.health_changed.connect(_on_health_changed)
_player.hit_taken.connect(_on_hit_taken)
_on_health_changed(_player.call(&"get_hp"), _player.call(&"get_max_hp"))
for i: int in _cd_overlays.size(): for i: int in _cd_overlays.size():
var fraction: float = _player.call(&"ability_cooldown_fraction", i) var fraction: float = _player.call(&"ability_cooldown_fraction", i)
var ov := _cd_overlays[i] var ov := _cd_overlays[i]
@@ -108,7 +216,10 @@ func _find_spawner() -> void:
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo: if event is InputEventKey and event.pressed and not event.echo:
if _game_over and event.physical_keycode in [KEY_ENTER, KEY_KP_ENTER]: if _game_over and event.physical_keycode in [KEY_ENTER, KEY_KP_ENTER]:
_restart() if _result_win:
_continue_to_lobby()
else:
_restart()
elif event.physical_keycode == KEY_TAB and not _game_over: elif event.physical_keycode == KEY_TAB and not _game_over:
_toggle_controls() _toggle_controls()
elif event.physical_keycode == KEY_ESCAPE: elif event.physical_keycode == KEY_ESCAPE:
@@ -127,11 +238,67 @@ func _restart() -> void:
get_tree().reload_current_scene() get_tree().reload_current_scene()
# A cleared level advances the run and drops the player into the lobby hub: commit the
# win, flag the lobby as the active level, then reload the shell so it builds the lobby.
# (Choosing the next map node happens from the lobby via trigger areas, added later.)
func _continue_to_lobby() -> void:
get_tree().paused = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
Run.complete_current_level()
Run.go_to_lobby()
get_tree().change_scene_to_file(Run.active_level().scene_path)
func _toggle_controls() -> void: func _toggle_controls() -> void:
_controls_visible = not _controls_visible _controls_visible = not _controls_visible
_controls_panel.visible = _controls_visible
_tab_hint.visible = not _controls_visible
_toggle_btn.text = "Hide controls" if _controls_visible else "Show controls" _toggle_btn.text = "Hide controls" if _controls_visible else "Show controls"
_update_control_hints()
# The Tab hint and the keyboard controls plaque only make sense with a keyboard, so
# they stand down on touch devices — where touch_controls.gd shows the on-screen
# joystick and ability buttons instead. Shares Controls.use_touch_ui() with that
# overlay so exactly one control scheme shows on any given device.
func _update_control_hints() -> void:
var touch := Controls.use_touch_ui()
# The desktop tiles carry keyboard keycaps that mean nothing on a phone, and their
# STOP-filtered panels would sit over the joystick zone eating touches — touch_controls
# shows its own ability buttons instead, so drop the desktop bar on touch devices.
if _ability_bar != null:
_ability_bar.visible = not touch
if _game_over or touch:
_controls_panel.visible = false
_tab_hint.visible = false
else:
_controls_panel.visible = _controls_visible
_tab_hint.visible = not _controls_visible
# ── Damage vignette ───────────────────────────────────────────────────────────
# A full-screen ColorRect driven by damage_vignette.gdshader: transparent centre, red
# toward the edges, alpha ("flash") tweened up on a hit and eased back to nothing. Built
# before the game-over layer so the outcome screen always draws over it.
func _build_damage_vignette() -> void:
_vignette = ColorRect.new()
_vignette.set_anchors_preset(Control.PRESET_FULL_RECT)
_vignette.color = Color.WHITE # the shader writes COLOR outright; this is just a base
_vignette.mouse_filter = Control.MOUSE_FILTER_IGNORE
if ResourceLoader.exists(_VIGNETTE_SHADER):
_vignette_mat = ShaderMaterial.new()
_vignette_mat.shader = load(_VIGNETTE_SHADER)
_vignette_mat.set_shader_parameter("flash", 0.0)
_vignette.material = _vignette_mat
add_child(_vignette)
func _on_hit_taken(_cause: String) -> void:
if _vignette_mat == null:
return
var set_flash := func(v: float) -> void:
_vignette_mat.set_shader_parameter("flash", v)
create_tween().tween_method(set_flash, 0.9, 0.0, 0.5)\
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
# ── Win / lose ────────────────────────────────────────────────────────────── # ── Win / lose ──────────────────────────────────────────────────────────────
@@ -157,8 +324,14 @@ func _show_game_over(win: bool, _cause: String = "") -> void:
if _tab_hint: if _tab_hint:
_tab_hint.visible = false _tab_hint.visible = false
# Swap in the matching outcome clip and loop it behind the buttons. await get_tree().create_timer(_WIN_DELAY if win else _LOSS_DELAY, false, false, true).timeout
var path := _WIN_VIDEO if win else _LOSS_VIDEO
_over_label.text = "Bull Won!" if _result_win else "Matadors Won!"
# A win advances the run (on to the map); a loss ends it (retry from the top).
_over_continue.visible = _result_win
_over_again.visible = not _result_win
var path := _WIN_VIDEO if _result_win else _LOSS_VIDEO
if ResourceLoader.exists(path): if ResourceLoader.exists(path):
_over_video.stream = load(path) _over_video.stream = load(path)
_over_video.play() _over_video.play()
@@ -179,13 +352,40 @@ func _build_game_over() -> void:
add_child(_over_root) add_child(_over_root)
# Full-screen outcome clip (bull win / matador win), looping. # Full-screen outcome clip (bull win / matador win), looping.
# AspectRatioContainer keeps the 16:9 video's ratio and crops overflow (COVER),
# so it fills any screen shape without stretching or leaving dead bars.
var arc := AspectRatioContainer.new()
arc.set_anchors_preset(Control.PRESET_FULL_RECT)
arc.ratio = 16.0 / 9.0
arc.stretch_mode = AspectRatioContainer.STRETCH_COVER
arc.alignment_horizontal = AspectRatioContainer.ALIGNMENT_CENTER
arc.alignment_vertical = AspectRatioContainer.ALIGNMENT_CENTER
arc.process_mode = Node.PROCESS_MODE_ALWAYS
arc.mouse_filter = Control.MOUSE_FILTER_IGNORE
_over_root.add_child(arc)
_over_video = VideoStreamPlayer.new() _over_video = VideoStreamPlayer.new()
_over_video.set_anchors_preset(Control.PRESET_FULL_RECT) _over_video.set_anchors_preset(Control.PRESET_FULL_RECT)
_over_video.expand = true _over_video.expand = true
_over_video.process_mode = Node.PROCESS_MODE_ALWAYS _over_video.process_mode = Node.PROCESS_MODE_ALWAYS
_over_video.mouse_filter = Control.MOUSE_FILTER_IGNORE _over_video.mouse_filter = Control.MOUSE_FILTER_IGNORE
_over_video.finished.connect(_over_video.play) # loop the outcome clip _over_video.finished.connect(_over_video.play) # loop the outcome clip
_over_root.add_child(_over_video) arc.add_child(_over_video)
# Outcome text centred over the video.
_over_label = Label.new()
_over_label.set_anchors_preset(Control.PRESET_CENTER_TOP)
_over_label.grow_horizontal = Control.GROW_DIRECTION_BOTH
_over_label.grow_vertical = Control.GROW_DIRECTION_END
_over_label.offset_top = 80.0
_over_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_over_label.add_theme_font_override("font", UiFonts.title())
_over_label.add_theme_font_size_override("font_size", 64)
_over_label.add_theme_color_override("font_color", _GOLD)
_over_label.add_theme_color_override("font_outline_color", Color(0.0, 0.0, 0.0, 0.9))
_over_label.add_theme_constant_override("outline_size", 6)
_over_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
_over_root.add_child(_over_label)
# Buttons overlaid at the bottom of the clip. # Buttons overlaid at the bottom of the clip.
var row := HBoxContainer.new() var row := HBoxContainer.new()
@@ -200,14 +400,111 @@ func _build_game_over() -> void:
row.offset_bottom = -48.0 row.offset_bottom = -48.0
_over_root.add_child(row) _over_root.add_child(row)
var again := _make_button("Go Again (Enter)") # Win → Continue to the run map; loss → Go Again from the top. Shown per-result in
again.pressed.connect(_restart) # _show_game_over; both share the Enter shortcut.
row.add_child(again) _over_continue = _make_button("Continue (Enter)")
_over_continue.pressed.connect(_continue_to_lobby)
row.add_child(_over_continue)
_over_again = _make_button("Go Again (Enter)")
_over_again.pressed.connect(_restart)
row.add_child(_over_again)
var menu := _make_button("Main Menu") var menu := _make_button("Main Menu")
menu.pressed.connect(_return_to_menu) menu.pressed.connect(_return_to_menu)
row.add_child(menu) row.add_child(menu)
# ── Bull health ───────────────────────────────────────────────────────────────
# A row of gold-socketed pips beneath the ability bar — one pip per HP, so raising or
# lowering max HP visibly grows or shrinks the row. Lit pips run red and shade toward
# crimson as the bull is worn down; spent pips read as empty dark sockets.
const _HP_FULL: Color = Color(0.82, 0.16, 0.12)
const _HP_EMPTY: Color = Color(0.35, 0.05, 0.05)
const _HP_SOCKET: Color = Color(0.05, 0.02, 0.02, 0.92)
const _HP_PIP_W: float = 22.0
const _HP_PIP_H: float = 14.0
func _build_health() -> void:
var panel := PanelContainer.new()
panel.add_theme_stylebox_override(
"panel", UiTheme.plaque(UiTheme.LEATHER, UiTheme.BORDER, 4, 12.0, 6.0))
panel.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_bottom_col.add_child(panel)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
panel.add_child(row)
_hp_label = Label.new()
_hp_label.text = "BULL"
_hp_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_hp_label.add_theme_font_override("font", UiFonts.body())
_hp_label.add_theme_color_override("font_color", _GOLD)
_hp_label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
_hp_label.add_theme_constant_override("outline_size", 3)
_hp_label.add_theme_font_size_override("font_size", 14)
row.add_child(_hp_label)
_hp_segments = HBoxContainer.new()
_hp_segments.add_theme_constant_override("separation", 4)
_hp_segments.size_flags_vertical = Control.SIZE_SHRINK_CENTER
_hp_segments.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(_hp_segments)
# The gold-underlined socket behind each pip — shared, non-mutating, so one instance
# is safe across every pip.
func _hp_socket_style() -> StyleBoxFlat:
var s := StyleBoxFlat.new()
s.bg_color = _HP_SOCKET
s.set_corner_radius_all(3)
s.corner_detail = 4
s.border_width_bottom = 2
s.border_color = Color(UiTheme.GOLD.r, UiTheme.GOLD.g, UiTheme.GOLD.b, 0.45)
s.content_margin_left = 2.0
s.content_margin_right = 2.0
s.content_margin_top = 2.0
s.content_margin_bottom = 2.0
return s
func _rebuild_hp_pips(max_hp: int) -> void:
for child in _hp_segments.get_children():
child.queue_free()
_hp_seg_fills.clear()
var socket_style := _hp_socket_style()
for _i: int in maxi(max_hp, 0):
var socket := PanelContainer.new()
socket.custom_minimum_size = Vector2(_HP_PIP_W, _HP_PIP_H)
socket.clip_contents = true
socket.add_theme_stylebox_override("panel", socket_style)
socket.mouse_filter = Control.MOUSE_FILTER_IGNORE
_hp_segments.add_child(socket)
var fill := ColorRect.new()
fill.color = _HP_FULL
fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
socket.add_child(fill)
_hp_seg_fills.append(fill)
func _on_health_changed(current: int, max_hp: int) -> void:
if _hp_segments == null:
return
if _hp_seg_fills.size() != max_hp:
_rebuild_hp_pips(max_hp)
var cur := maxi(current, 0)
var frac := 0.0 if max_hp <= 0 else clampf(float(cur) / float(max_hp), 0.0, 1.0)
var lit := _HP_FULL.lerp(_HP_EMPTY, 1.0 - frac)
for i: int in _hp_seg_fills.size():
var fill := _hp_seg_fills[i]
fill.visible = i < cur
fill.color = lit
const _SLOT_SEPARATION: int = 10 const _SLOT_SEPARATION: int = 10
@@ -215,14 +512,9 @@ const _SLOT_SEPARATION: int = 10
func _build_ability_bar() -> void: func _build_ability_bar() -> void:
_ability_bar = HBoxContainer.new() _ability_bar = HBoxContainer.new()
_ability_bar.add_theme_constant_override("separation", _SLOT_SEPARATION) _ability_bar.add_theme_constant_override("separation", _SLOT_SEPARATION)
_ability_bar.anchor_left = 0.5 _ability_bar.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
_ability_bar.anchor_right = 0.5 _ability_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
_ability_bar.anchor_top = 1.0 _bottom_col.add_child(_ability_bar)
_ability_bar.anchor_bottom = 1.0
_ability_bar.grow_horizontal = Control.GROW_DIRECTION_BOTH
_ability_bar.grow_vertical = Control.GROW_DIRECTION_BEGIN
_ability_bar.offset_bottom = -20.0
add_child(_ability_bar)
for i: int in 4: for i: int in 4:
_ability_bar.add_child(_build_ability_slot(i)) _ability_bar.add_child(_build_ability_slot(i))
_layout_ability_bar() _layout_ability_bar()
@@ -230,20 +522,17 @@ func _build_ability_bar() -> void:
# Size the ability slots as a fraction of the viewport (clamped) so the bar scales # Size the ability slots as a fraction of the viewport (clamped) so the bar scales
# with the window, and keep it horizontally centred by matching the HBox offsets. # with the window; the bottom column keeps the bar horizontally centred.
func _layout_ability_bar() -> void: func _layout_ability_bar() -> void:
var vp := get_viewport().get_visible_rect().size var vp := get_viewport().get_visible_rect().size
var slot := clampf(minf(vp.x * 0.055, vp.y * 0.11), 56.0, 104.0) var slot := clampf(minf(vp.x * 0.055, vp.y * 0.11), 56.0, 104.0)
for panel: PanelContainer in _ability_panels: for panel: PanelContainer in _ability_panels:
panel.custom_minimum_size = Vector2(slot, slot) panel.custom_minimum_size = Vector2(slot, slot)
var total := slot * 4.0 + float(_SLOT_SEPARATION) * 3.0
_ability_bar.offset_left = -total * 0.5
_ability_bar.offset_right = total * 0.5
func _build_ability_slot(idx: int) -> Control: func _build_ability_slot(idx: int) -> Control:
var vbox := VBoxContainer.new() var vbox := VBoxContainer.new()
vbox.add_theme_constant_override("separation", 4) vbox.add_theme_constant_override("separation", 3)
var panel := PanelContainer.new() var panel := PanelContainer.new()
panel.custom_minimum_size = Vector2(72.0, 72.0) panel.custom_minimum_size = Vector2(72.0, 72.0)
@@ -298,19 +587,48 @@ func _build_ability_slot(idx: int) -> Control:
stack.add_child(cd_lbl) stack.add_child(cd_lbl)
_cd_labels.append(cd_lbl) _cd_labels.append(cd_lbl)
# Keycap tucked directly beneath the tile — a solid dark plate wearing the
# ability's accent underline, so it reads as attached to its box without ever
# covering the artwork. Centred under the tile and tightly spaced.
var keycap := PanelContainer.new()
keycap.add_theme_stylebox_override("panel", _keycap_style(idx))
keycap.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
keycap.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_child(keycap)
var key_lbl := Label.new() var key_lbl := Label.new()
key_lbl.text = Controls.get_key_label(_ABILITY_ACTIONS[idx]) key_lbl.text = Controls.get_key_label(_ABILITY_ACTIONS[idx])
key_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER key_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
key_lbl.add_theme_font_override("font", UiFonts.body()) key_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
key_lbl.add_theme_color_override("font_color", _ABILITY_ACCENT[idx].lerp(_CREAM, 0.35)) key_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
key_lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85)) key_lbl.add_theme_font_override("font", UiFonts.body_bold())
key_lbl.add_theme_color_override("font_color", _CREAM)
key_lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
key_lbl.add_theme_constant_override("outline_size", 3) key_lbl.add_theme_constant_override("outline_size", 3)
key_lbl.add_theme_font_size_override("font_size", 13) key_lbl.add_theme_font_size_override("font_size", 14)
vbox.add_child(key_lbl) keycap.add_child(key_lbl)
return vbox return vbox
# Solid keycap plate for an ability bind: near-black fill with the ability's accent
# as a chunky top edge, so the accent bridges up to the tile it sits under (reads as
# attached) while the key stays on a high-contrast background against busy scenes.
func _keycap_style(idx: int) -> StyleBoxFlat:
var s := StyleBoxFlat.new()
s.bg_color = Color(0.04, 0.03, 0.02, 0.94)
s.set_corner_radius_all(4)
s.corner_detail = 4
s.set_border_width_all(1)
s.border_width_top = 3
s.border_color = _ABILITY_ACCENT[idx]
s.content_margin_left = 7.0
s.content_margin_right = 7.0
s.content_margin_top = 2.0
s.content_margin_bottom = 1.0
return s
func _build_tab_hint() -> void: func _build_tab_hint() -> void:
_tab_hint = Label.new() _tab_hint = Label.new()
_tab_hint.text = "Press 'Tab' to see controls" _tab_hint.text = "Press 'Tab' to see controls"
@@ -353,8 +671,8 @@ func _build_controls_panel() -> void:
] ]
_row(vbox, move_keys, "Move", _CREAM) _row(vbox, move_keys, "Move", _CREAM)
_row(vbox, Controls.get_key_label(&"ability_kick"), "Kick", _CREAM) _row(vbox, Controls.get_key_label(&"ability_kick"), "Kick", _CREAM)
_row(vbox, Controls.get_key_label(&"ability_slam"), "Slam", _CREAM)
_row(vbox, Controls.get_key_label(&"ability_dash"), "Dash", _CREAM) _row(vbox, Controls.get_key_label(&"ability_dash"), "Dash", _CREAM)
_row(vbox, Controls.get_key_label(&"ability_slam"), "Slam", _CREAM)
_row(vbox, Controls.get_key_label(&"ability_roll"), "Roll (Powerball: ram the matador!)", _CREAM) _row(vbox, Controls.get_key_label(&"ability_roll"), "Roll (Powerball: ram the matador!)", _CREAM)
_row(vbox, "Scroll", "Zoom", _CREAM) _row(vbox, "Scroll", "Zoom", _CREAM)
_row(vbox, "Tab", "Toggle controls", _MUTED) _row(vbox, "Tab", "Toggle controls", _MUTED)
+10
View File
@@ -0,0 +1,10 @@
[gd_scene format=3 uid="uid://map_screen_v1"]
[ext_resource type="Script" path="res://levels/map_screen.gd" id="1_map"]
[node name="MapScreen" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_map")
+112
View File
@@ -0,0 +1,112 @@
[gd_scene format=3 uid="uid://co4tgvdpdte4v"]
[ext_resource type="PackedScene" uid="uid://dio67r8ambe7h" path="res://Assets/arena_placeholder_v01.glb" id="2_nxogm"]
[ext_resource type="PackedScene" uid="uid://cl54dt38yj2x" path="res://Assets/Arena.glb" id="3_fy5k1"]
[ext_resource type="Script" uid="uid://dou5ar6tkmng1" path="res://levels/matador_spawn.gd" id="3_spawn"]
[ext_resource type="Script" uid="uid://70j3ks1emhkd" path="res://crowd_marker.gd" id="9_agtuu"]
[ext_resource type="Script" uid="uid://cj0ytlkuavmf4" path="res://crowd_billboards.gd" id="11_crowd"]
[sub_resource type="BoxShape3D" id="BoxShape3D_ulcgi"]
[sub_resource type="Environment" id="Environment_73fnb"]
[node name="arena_level" type="Node3D" unique_id=701327120]
[node name="Node3D" type="Node3D" parent="." unique_id=1772505910]
[node name="StaticBody3D" type="StaticBody3D" parent="Node3D" unique_id=2083665760]
[node name="CollisionShape3D" type="CollisionShape3D" parent="Node3D/StaticBody3D" unique_id=1028681009]
transform = Transform3D(30, 0, 0, 0, 1, 0, 0, 0, 30, 0, -0.5, 0)
shape = SubResource("BoxShape3D_ulcgi")
[node name="arena_placeholder_v01" parent="Node3D" unique_id=173047470 instance=ExtResource("2_nxogm")]
[node name="Arena_draft" parent="Node3D/arena_placeholder_v01" index="0" unique_id=727602018]
visible = false
[node name="Wall" parent="Node3D/arena_placeholder_v01" index="1" unique_id=1457821831]
visible = false
[node name="CollisionShape3D" parent="Node3D/arena_placeholder_v01/Wall/StaticBody3D" parent_id_path=PackedInt32Array(173047470, 1904923047) index="0" unique_id=1063849009]
transform = Transform3D(0.98309773, 0, 0, 0, 0.9558868, 0, 0, 0, 4.061706, 0.13596812, -0.091219574, -5.4371103e-09)
[node name="Arena2" parent="Node3D/arena_placeholder_v01" unique_id=1270130903 instance=ExtResource("3_fy5k1")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.017679214, 9.536743e-07, -0.013886452)
[node name="MatadorSpawner" type="Node3D" parent="." unique_id=1524912967]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 3, 14)
script = ExtResource("3_spawn")
[node name="MatadorSpawner2" type="Node3D" parent="." unique_id=2081003535]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -15, 3, 0)
script = ExtResource("3_spawn")
[node name="MatadorSpawner4" type="Node3D" parent="." unique_id=497566616]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 14, 3, 0)
script = ExtResource("3_spawn")
[node name="MatadorSpawner3" type="Node3D" parent="." unique_id=618446747]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 3, -15)
script = ExtResource("3_spawn")
[node name="Lights" type="Node3D" parent="." unique_id=614769858]
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="Lights" unique_id=960453715]
transform = Transform3D(0.01535992, 0.56825536, -0.82270896, 0.16280846, 0.8104039, 0.56279576, 0.9865382, -0.14258848, -0.08006904, -4.08134, 9.197985, 0.97087634)
light_energy = 0.4
light_indirect_energy = 0.767
shadow_blur = 0.47
[node name="SpotLight3D2" type="SpotLight3D" parent="Lights" unique_id=1654524086]
transform = Transform3D(0.904133, 0.30962402, -0.2944087, 0.3084627, 0.0037425472, 0.9512291, 0.2956252, -0.95085174, -0.0921237, -3.629381, 10.86347, -2.0124264)
light_energy = 16.0
light_indirect_energy = 26.095
distance_fade_enabled = true
spot_range = 32.90586
spot_angle = 73.93
[node name="SpotLight3D" type="SpotLight3D" parent="Lights" unique_id=1408554387]
transform = Transform3D(0.9763736, -9.445564e-09, 0.21608931, -0.20963383, 0.24260214, 0.9472053, -0.052423734, -0.9701259, 0.23687032, 4.0400305, 13.463301, 3.9410677)
visible = false
light_energy = 13.695
shadow_enabled = true
spot_range = 32.90586
[node name="SUN_DirectionalLight3D2" type="DirectionalLight3D" parent="Lights" unique_id=1840798568]
transform = Transform3D(0.45811793, 0.34843865, -0.81775206, 0.5316611, 0.62986195, 0.56622505, 0.7123655, -0.69416475, 0.10329977, 0, 8.980043, 0)
light_energy = 0.106
shadow_enabled = true
shadow_blur = 10.0
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=1750284504]
environment = SubResource("Environment_73fnb")
[node name="CrowdBillboards" type="MultiMeshInstance3D" parent="." unique_id=106730614]
script = ExtResource("11_crowd")
[node name="CrowdMarker1" type="Node3D" parent="." unique_id=2100000001]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5, 14)
script = ExtResource("9_agtuu")
orientation = 3
metadata/_custom_type_script = "uid://70j3ks1emhkd"
[node name="CrowdMarker2" type="Node3D" parent="." unique_id=2100000002]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 14, 5, 0)
script = ExtResource("9_agtuu")
orientation = 5
metadata/_custom_type_script = "uid://70j3ks1emhkd"
[node name="CrowdMarker3" type="Node3D" parent="." unique_id=2100000003]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5, -14)
script = ExtResource("9_agtuu")
orientation = 0
metadata/_custom_type_script = "uid://70j3ks1emhkd"
[node name="CrowdMarker4" type="Node3D" parent="." unique_id=2100000004]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -14, 5, 0)
script = ExtResource("9_agtuu")
orientation = 1
metadata/_custom_type_script = "uid://70j3ks1emhkd"
[editable path="Node3D/arena_placeholder_v01"]
+105
View File
@@ -0,0 +1,105 @@
extends CanvasLayer
const _GOLD := Color(1.00, 0.78, 0.22)
const _CREAM := Color(0.93, 0.88, 0.72)
const _BAR_W: float = 1260.0
const _BAR_H: float = 18.0
const _BAR_BG: Color = Color(0.12, 0.04, 0.04, 0.88)
const _BAR_FILL: Color = Color(0.65, 0.08, 0.06)
const _BAR_ENRAGE: Color = Color(0.90, 0.32, 0.06)
var _bear: Node = null
var _fill: ColorRect = null
var _label: Label = null
var _max_hp: int = 1
var _current_hp: int = 1
func _ready() -> void:
layer = 10
_build_ui()
_find_bear.call_deferred()
func _find_bear() -> void:
var bears := get_tree().get_nodes_in_group(&"bear")
if bears.is_empty():
return
_bear = bears[0]
_bear.health_changed.connect(_on_health_changed)
_bear.killed.connect(_on_bear_killed)
var max_hp := int(DP.f("bear_max_hp"))
_on_health_changed(_bear.get_hp(), max_hp)
func _on_health_changed(current: int, max_hp: int) -> void:
_current_hp = current
_max_hp = max_hp
_update_bar()
func _on_bear_killed() -> void:
_current_hp = 0
_update_bar()
func _update_bar() -> void:
if _fill == null:
return
var frac := 0.0 if _max_hp <= 0 else clampf(float(_current_hp) / float(_max_hp), 0.0, 1.0)
_fill.size.x = _BAR_W * frac
var enrage_frac: float = DP.f("bear_enrage_frac")
_fill.color = _BAR_FILL.lerp(_BAR_ENRAGE, 1.0 - frac / enrage_frac if frac < enrage_frac else 0.0)
if _label != null:
_label.text = "BEAR" % [maxi(_current_hp, 0), _max_hp]
## for hp display in numbers write %d / %d
func _build_ui() -> void:
var panel := PanelContainer.new()
panel.add_theme_stylebox_override(
"panel", UiTheme.plaque(UiTheme.LEATHER, UiTheme.BORDER, 4, 14.0, 8.0))
panel.anchor_left = 0.5
panel.anchor_right = 0.5
panel.anchor_top = 0.0
panel.anchor_bottom = 0.0
panel.grow_horizontal = Control.GROW_DIRECTION_BOTH
panel.offset_top = 16.0
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(panel)
var col := VBoxContainer.new()
col.add_theme_constant_override("separation", 5)
col.mouse_filter = Control.MOUSE_FILTER_IGNORE
panel.add_child(col)
_label = Label.new()
_label.text = "BEAR"
_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_label.add_theme_font_override("font", UiFonts.body())
_label.add_theme_color_override("font_color", _GOLD)
_label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
_label.add_theme_constant_override("outline_size", 3)
_label.add_theme_font_size_override("font_size", 30)
_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
col.add_child(_label)
var bar_bg := PanelContainer.new()
bar_bg.custom_minimum_size = Vector2(_BAR_W, _BAR_H)
bar_bg.clip_contents = true
var bg_style := StyleBoxFlat.new()
bg_style.bg_color = _BAR_BG
bg_style.set_corner_radius_all(4)
bg_style.corner_detail = 4
bg_style.border_width_bottom = 2
bg_style.border_color = Color(UiTheme.GOLD.r, UiTheme.GOLD.g, UiTheme.GOLD.b, 0.35)
bar_bg.add_theme_stylebox_override("panel", bg_style)
bar_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
col.add_child(bar_bg)
_fill = ColorRect.new()
_fill.color = _BAR_FILL
_fill.size = Vector2(_BAR_W, _BAR_H)
_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
bar_bg.add_child(_fill)
+1
View File
@@ -0,0 +1 @@
uid://dfk7dcvki7rqu
+49
View File
@@ -0,0 +1,49 @@
[gd_scene format=3 uid="uid://dfxigg3cr6nl5"]
[ext_resource type="PackedScene" uid="uid://bm6rmt3xprhjs" path="res://Assets/bear_level_arena.glb" id="1_beararena"]
[ext_resource type="Script" uid="uid://bbtlwihtlc1wp" path="res://levels/bear_spawn.gd" id="2_spawn"]
[ext_resource type="PackedScene" uid="uid://o8ocjfprxa7u" path="res://Bear.tscn" id="3_bear"]
[ext_resource type="Script" uid="uid://dfk7dcvki7rqu" path="res://levels/bear_hud.gd" id="4_bearhud"]
[sub_resource type="BoxShape3D" id="BoxShape3D_floor"]
[node name="bear_level" type="Node3D" unique_id=261730746]
[node name="Node3D" type="Node3D" parent="." unique_id=333447532]
[node name="StaticBody3D" type="StaticBody3D" parent="Node3D" unique_id=1466540248]
[node name="CollisionShape3D" type="CollisionShape3D" parent="Node3D/StaticBody3D" unique_id=575454169]
transform = Transform3D(30, 0, 0, 0, 1, 0, 0, 0, 30, 0, -0.5, 0)
shape = SubResource("BoxShape3D_floor")
[node name="bear_level_arena" parent="." unique_id=1529242945 instance=ExtResource("1_beararena")]
transform = Transform3D(1.94, 0, 0, 0, 1.94, 0, 0, 0, 1.94, 0, 0, 0)
[node name="floor" parent="bear_level_arena/floor" parent_id_path=PackedInt32Array(1529242945, 294762985) index="0" unique_id=1266729531]
visible = false
[node name="wall" parent="bear_level_arena/floor" parent_id_path=PackedInt32Array(1529242945, 294762985) index="1" unique_id=1445338369]
visible = false
[node name="BearSpawner" type="Node3D" parent="." unique_id=47931969]
script = ExtResource("2_spawn")
enemy_scene = ExtResource("3_bear")
[node name="BearHud" type="CanvasLayer" parent="." unique_id=102938471]
script = ExtResource("4_bearhud")
[node name="Lights" type="Node3D" parent="." unique_id=937122836]
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="Lights" unique_id=1786336124]
transform = Transform3D(1, 0, 0, 0, 0.7071067, 0.70710677, 0, -0.70710677, 0.7071067, 0, 36.945946, 70)
light_color = Color(1, 0.75782466, 0.7332154, 1)
light_energy = 1.796
shadow_enabled = true
[node name="DirectionalLight3D2" type="DirectionalLight3D" parent="Lights" unique_id=1308313006]
transform = Transform3D(1, 0, 0, 0, -0.8660254, 0.49999994, 0, -0.49999994, -0.8660254, 0, 112.56815, 93.37274)
light_color = Color(0, 0.33367383, 0.57971, 1)
light_energy = 0.616
[editable path="bear_level_arena"]
+33
View File
@@ -0,0 +1,33 @@
extends Node3D
signal matador_killed
signal all_defeated
@export var enemy_scene: PackedScene = null
var _alive: int = 0
var _resolved: bool = false
func _ready() -> void:
add_to_group(&"matador_spawn")
_spawn.call_deferred()
func _spawn() -> void:
if enemy_scene == null:
return
var bear: Node3D = enemy_scene.instantiate()
bear.position = Vector3(-15,0,0)
get_parent().add_child(bear)
if bear.has_signal(&"killed"):
bear.killed.connect(_on_bear_killed)
_alive += 1
func _on_bear_killed() -> void:
matador_killed.emit()
_alive -= 1
if not _resolved:
_resolved = true
all_defeated.emit()
+1
View File
@@ -0,0 +1 @@
uid://bbtlwihtlc1wp
+26
View File
@@ -0,0 +1,26 @@
extends Node3D
## The reusable game shell: player, camera, HUD and the PS1 filter — everything that
## outlives a single level. On load it instances the active level's module (geometry,
## spawners, lighting, colliders) into LevelRoot. Switching levels frees the whole
## previous module first, so no old mesh or collider can survive into the next fight.
@onready var _level_root: Node3D = $LevelRoot
func _ready() -> void:
Run.ensure_run()
load_active_level()
## Tear down the current level module and build the run's active one. Freeing the whole
## subtree (not hiding it) is the point: a level's colliders and spawners cease to exist
## before the next level's are created.
func load_active_level() -> void:
for child: Node in _level_root.get_children():
_level_root.remove_child(child)
child.queue_free()
var level := Run.active_level()
if level == null or level.level_scene == null:
push_warning("game_shell: active level has no level_scene")
return
_level_root.add_child(level.level_scene.instantiate())
+1
View File
@@ -0,0 +1 @@
uid://ptsfs3wq5bje
+26
View File
@@ -0,0 +1,26 @@
class_name LevelDef
extends Resource
## Definition of one playable level on the run map — the arena today, later a cave with
## a bear, a china shop to smash, and so on. Every map node references a LevelDef, and
## the map/flow code never hard-codes a scene: adding a level is a new LevelDef (plus its
## scene) handed out by the map generator in run_state.gd.
@export var id: StringName = &""
@export var display_name: String = "Level"
## The shell scene loaded when the player enters a node with this level — the reusable
## frame (player, camera, HUD, PS1 filter). Shared by every level; the level's own
## geometry rides in `level_scene`, which the shell instances at runtime.
@export_file("*.tscn") var scene_path: String = ""
## The level module instanced into the shell's LevelRoot: spawners, lighting, meshes,
## colliders and any level-specific subscripts. Swapped wholesale between levels so no
## previous level's geometry (or its colliders) can leak into the next.
@export var level_scene: PackedScene = null
## Placeholder pip tint on the map until per-level icon art exists.
@export var color: Color = Color(0.80, 0.62, 0.24)
## A boss level caps a run — its node is drawn larger and clearing it ends the map.
@export var is_boss: bool = false
## The enemy this level pits the bull against. Null → the usual matador wave (the
## spawner's default). Set → a single boss enemy of this scene is spawned instead (e.g.
## the bear), and the fight is won when it falls. The arena scene is shared either way;
## only the opponent changes.
@export var enemy_scene: PackedScene = null
+1
View File
@@ -0,0 +1 @@
uid://c6sef0tfl26sr
+25
View File
@@ -0,0 +1,25 @@
[gd_scene format=3 uid="uid://32nyipcwrab5"]
[ext_resource type="PackedScene" uid="uid://cwmo5vh738x6w" path="res://Assets/Lobby_level.glb" id="1_lobby"]
[sub_resource type="Environment" id="Environment_lobby"]
[node name="lobby_level" type="Node3D"]
[node name="Lobby_level" parent="." instance=ExtResource("1_lobby")]
[node name="Lights" type="Node3D" parent="."]
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="Lights"]
transform = Transform3D(1, 0, 0, 0, 0.7071067, 0.70710677, 0, -0.70710677, 0.7071067, 0, 36.945946, 70)
light_color = Color(1, 0.9137255, 0.827451, 1)
light_energy = 1.6
shadow_enabled = true
[node name="DirectionalLight3D2" type="DirectionalLight3D" parent="Lights"]
transform = Transform3D(1, 0, 0, 0, -0.8660254, 0.49999994, 0, -0.49999994, -0.8660254, 0, 112.56815, 93.37274)
light_color = Color(0.4784314, 0.6156863, 0.8, 1)
light_energy = 0.45
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Environment_lobby")
+13
View File
@@ -0,0 +1,13 @@
class_name MapNode
extends RefCounted
## One node on the run map. Regenerated every run and never persisted, so RefCounted
## rather than Resource. `pos` is normalized (0-1) so the map screen can lay the node out
## at any resolution; `links` are column indices in the *next* row this node connects to
## (the Slay-the-Spire branching path).
var row: int = 0
var col: int = 0
var pos: Vector2 = Vector2.ZERO
var level: LevelDef = null
var links: PackedInt32Array = PackedInt32Array()
var cleared: bool = false
+1
View File
@@ -0,0 +1 @@
uid://emq8i2bmkekn
+280
View File
@@ -0,0 +1,280 @@
extends Control
## Slay-the-Spire-style run map shown between fights. Placeholder art — coloured pips
## joined by paths — but fully interactive: the player picks one of the reachable nodes
## and is sent into that level. Every node is the arena for now (see run_state.gd); the
## screen is level-agnostic, so richer levels just need a LevelDef with its own colour.
const _GOLD := Color(1.00, 0.78, 0.22)
const _CREAM := Color(0.93, 0.88, 0.72)
const _EDGE_DIM := Color(0.45, 0.36, 0.18, 0.55)
const _EDGE_ACTIVE := Color(1.00, 0.82, 0.32, 0.95)
const _NODE_SIZE := 58.0
const _BOSS_SIZE := 88.0
const _MENU_SCENE := "res://MainMenu.tscn"
var _map_area: Control
var _completion: PanelContainer
func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
get_tree().paused = false
Run.ensure_run()
_build_ui()
_relayout.call_deferred() # defer so _map_area has its real size first
func _build_ui() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
var bg := ColorRect.new()
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.color = Color(0.09, 0.06, 0.03) # opaque leather, no scene showing through
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(bg)
var title := Label.new()
title.text = "Choose Your Path"
title.set_anchors_preset(Control.PRESET_TOP_WIDE)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.offset_top = 22.0
title.add_theme_font_override("font", UiFonts.title())
title.add_theme_font_size_override("font_size", 46)
title.add_theme_color_override("font_color", _GOLD)
title.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
title.add_theme_constant_override("outline_size", 6)
title.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(title)
_map_area = Control.new()
_map_area.set_anchors_preset(Control.PRESET_FULL_RECT)
_map_area.offset_top = 108.0
_map_area.offset_bottom = -92.0
_map_area.offset_left = 48.0
_map_area.offset_right = -48.0
_map_area.clip_contents = false
add_child(_map_area)
_map_area.resized.connect(_relayout)
var menu_btn := _make_button("Main Menu")
menu_btn.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
menu_btn.grow_vertical = Control.GROW_DIRECTION_BEGIN
menu_btn.offset_left = 32.0
menu_btn.offset_bottom = -26.0
menu_btn.pressed.connect(_to_menu)
add_child(menu_btn)
_build_completion()
# ── Layout ──────────────────────────────────────────────────────────────────
# Rebuilt from the run state on first show and whenever the map area resizes. Edges are
# added first so the node pips draw on top of the paths.
func _relayout() -> void:
if _map_area == null:
return
for child in _map_area.get_children():
child.queue_free()
var size := _map_area.size
if size.x <= 0.0 or size.y <= 0.0:
return
var reachable := Run.reachable()
var reach_set := {}
for n: MapNode in reachable:
reach_set[n] = true
var here := Run.current_node()
if Run.player_row < 0:
var start_pos := Vector2(size.x * 0.5, size.y - 8.0)
_add_start_pip(start_pos)
for n: MapNode in reachable:
_add_edge(start_pos, _local(n, size), true)
for row: Array in Run.map:
for from_node: MapNode in row:
for to_col: int in from_node.links:
var to_node := Run.node_at(from_node.row + 1, to_col)
if to_node == null:
continue
var active := from_node == here and reach_set.has(to_node)
_add_edge(_local(from_node, size), _local(to_node, size), active)
for row: Array in Run.map:
for node: MapNode in row:
_add_node(node, size, reach_set.has(node))
_completion.visible = Run.run_complete() or reachable.is_empty()
func _local(node: MapNode, size: Vector2) -> Vector2:
return Vector2(node.pos.x * size.x, node.pos.y * size.y)
func _add_edge(a: Vector2, b: Vector2, active: bool) -> void:
var line := Line2D.new()
line.add_point(a)
line.add_point(b)
line.width = 4.0 if active else 2.5
line.default_color = _EDGE_ACTIVE if active else _EDGE_DIM
line.antialiased = true
_map_area.add_child(line)
func _add_start_pip(center: Vector2) -> void:
var pip := PanelContainer.new()
var sz := 22.0
pip.custom_minimum_size = Vector2(sz, sz)
pip.size = Vector2(sz, sz)
pip.position = center - Vector2(sz, sz) * 0.5
pip.mouse_filter = Control.MOUSE_FILTER_IGNORE
pip.add_theme_stylebox_override("panel", UiTheme.disc(_GOLD, _CREAM, 2))
_map_area.add_child(pip)
func _add_node(node: MapNode, size: Vector2, is_reachable: bool) -> void:
var is_current := node.row == Run.player_row and node.col == Run.player_col
var sz := _BOSS_SIZE if node.level.is_boss else _NODE_SIZE
var center := _local(node, size)
var btn := Button.new()
btn.custom_minimum_size = Vector2(sz, sz)
btn.size = Vector2(sz, sz)
btn.position = center - Vector2(sz, sz) * 0.5
btn.focus_mode = Control.FOCUS_NONE
btn.disabled = not is_reachable
btn.mouse_filter = Control.MOUSE_FILTER_STOP if is_reachable else Control.MOUSE_FILTER_IGNORE
var fill := node.level.color
var border := UiTheme.BORDER
if is_current:
border = _CREAM
elif is_reachable:
border = _GOLD
elif node.cleared:
fill = fill.darkened(0.35)
border = Color(_GOLD.r, _GOLD.g, _GOLD.b, 0.5)
else: # locked — a future row not yet reachable
fill = fill.darkened(0.6)
border = Color(0.40, 0.34, 0.20, 0.6)
var border_w := 4 if (is_reachable or is_current) else 2
var normal := UiTheme.disc(fill, border, border_w)
var hover := UiTheme.disc(fill.lightened(0.18), _CREAM, border_w)
btn.add_theme_stylebox_override("normal", normal)
btn.add_theme_stylebox_override("hover", hover)
btn.add_theme_stylebox_override("pressed", hover)
btn.add_theme_stylebox_override("disabled", normal)
_map_area.add_child(btn)
if is_reachable:
btn.pressed.connect(_enter_node.bind(node))
_pulse(btn)
if is_reachable or is_current or node.level.is_boss:
_add_caption(node.level.display_name, center + Vector2(0.0, sz * 0.5 + 6.0), is_reachable)
if is_current:
_add_caption("You are here", center - Vector2(0.0, sz * 0.5 + 18.0), false)
func _add_caption(text: String, center_top: Vector2, bright: bool) -> void:
var lbl := Label.new()
lbl.text = text
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
lbl.size = Vector2(200.0, 0.0)
lbl.position = center_top - Vector2(100.0, 0.0)
lbl.add_theme_font_override("font", UiFonts.body())
lbl.add_theme_font_size_override("font_size", 15)
lbl.add_theme_color_override("font_color", _CREAM if bright else UiTheme.MUTED)
lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
lbl.add_theme_constant_override("outline_size", 4)
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
_map_area.add_child(lbl)
# Slow glow on the reachable pips so the eye lands on where the player can go next.
func _pulse(node: CanvasItem) -> void:
var tw := create_tween().set_loops()
tw.tween_property(node, "modulate:a", 0.6, 0.75).set_trans(Tween.TRANS_SINE)
tw.tween_property(node, "modulate:a", 1.0, 0.75).set_trans(Tween.TRANS_SINE)
# ── Actions ───────────────────────────────────────────────────────────────────
func _enter_node(node: MapNode) -> void:
Run.select_node(node)
get_tree().paused = false
get_tree().change_scene_to_file(node.level.scene_path)
func _to_menu() -> void:
get_tree().paused = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
get_tree().change_scene_to_file(_MENU_SCENE)
# ── Run-complete overlay ──────────────────────────────────────────────────────
func _build_completion() -> void:
_completion = PanelContainer.new()
_completion.set_anchors_preset(Control.PRESET_CENTER)
_completion.grow_horizontal = Control.GROW_DIRECTION_BOTH
_completion.grow_vertical = Control.GROW_DIRECTION_BOTH
_completion.add_theme_stylebox_override(
"panel", UiTheme.plaque(UiTheme.LEATHER, _GOLD, 4, 32.0, 24.0)
)
_completion.visible = false
add_child(_completion)
var col := VBoxContainer.new()
col.add_theme_constant_override("separation", 16)
col.alignment = BoxContainer.ALIGNMENT_CENTER
_completion.add_child(col)
var heading := Label.new()
heading.text = "Run Complete!"
heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
heading.add_theme_font_override("font", UiFonts.title())
heading.add_theme_font_size_override("font_size", 40)
heading.add_theme_color_override("font_color", _GOLD)
heading.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
heading.add_theme_constant_override("outline_size", 5)
col.add_child(heading)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 16)
col.add_child(row)
var again := _make_button("New Run")
again.pressed.connect(_new_run)
row.add_child(again)
var menu := _make_button("Main Menu")
menu.pressed.connect(_to_menu)
row.add_child(menu)
func _new_run() -> void:
Run.start_new_run()
_relayout()
# Shared thematic button, matching the HUD / menu plaques.
func _make_button(text: String) -> Button:
var btn := Button.new()
btn.text = text
btn.focus_mode = Control.FOCUS_NONE
btn.add_theme_stylebox_override(
"normal", UiTheme.plaque(Color(0.10, 0.07, 0.03, 0.90), _GOLD, 3, 18.0, 9.0)
)
var hover := UiTheme.plaque(Color(0.16, 0.10, 0.04, 0.96), _CREAM, 3, 18.0, 9.0)
btn.add_theme_stylebox_override("hover", hover)
btn.add_theme_stylebox_override("pressed", hover)
btn.add_theme_font_override("font", UiFonts.body())
btn.add_theme_color_override("font_color", _GOLD)
btn.add_theme_color_override("font_hover_color", _CREAM)
btn.add_theme_font_size_override("font_size", 18)
return btn
+1
View File
@@ -0,0 +1 @@
uid://dt3ffakbwlkmc
+76
View File
@@ -0,0 +1,76 @@
extends Node3D
signal matador_killed
signal all_defeated
const MATADOR := preload("res://Matador.tscn")
var _alive: int = 0
var _wave: int = 0
var _resolved: bool = false
var _spawn_points: Array[Node3D] = []
func _ready() -> void:
add_to_group(&"matador_spawn")
_spawn.call_deferred()
# Each spawner node is a spawn-point marker. The first in tree order (the one the HUD
# connects its all_defeated to) runs the wave logic and uses every marker — itself and
# its siblings — as an entry point; the rest stay passive so waves aren't run four times.
func _spawn() -> void:
var markers := get_tree().get_nodes_in_group(&"matador_spawn")
if markers.is_empty() or markers[0] != self:
return
for node: Node in markers:
if node is Node3D:
_spawn_points.append(node as Node3D)
_place_bull(randf() * TAU, DP.f("arena_spawn_radius"))
_start_next_wave()
# Wave 1 sends 1 matador, wave 2 sends 2, … up to the number of placed spawn points.
# Matadors are assigned to a random subset of spawn points each wave and enter staggered.
func _start_next_wave() -> void:
_wave += 1
var count := mini(_wave, _spawn_points.size())
var points: Array = _spawn_points.duplicate()
points.shuffle()
for i: int in count:
var sp := points[i] as Node3D
var m := _spawn_at(sp.global_position)
m.call_deferred(&"enter_spawn_roll", Vector3.ZERO)
if i < count - 1:
await get_tree().create_timer(DP.f("mat_spawn_stagger")).timeout
func _place_bull(angle: float, arena_r: float) -> void:
var bull := get_tree().get_first_node_in_group(&"player") as Node3D
if not bull:
return
bull.global_position = Vector3(
cos(angle) * arena_r, bull.global_position.y, sin(angle) * arena_r
)
func _spawn_at(pos: Vector3) -> Node3D:
var m: Node3D = MATADOR.instantiate()
m.position = pos
get_parent().add_child(m)
m.killed.connect(_on_matador_killed)
_alive += 1
return m
func _on_matador_killed() -> void:
matador_killed.emit()
_alive -= 1
if _alive > 0 or _resolved:
return
if _wave >= _spawn_points.size():
_resolved = true
all_defeated.emit()
else:
await get_tree().create_timer(DP.f("mat_wave_pause")).timeout
_start_next_wave()
+267
View File
@@ -0,0 +1,267 @@
extends Node
## Persistent run progression across scene changes — the Slay-the-Spire-style map and
## where the player sits on it. The arena scene is reloaded for every fight, so this
## state must outlive scene changes: genuinely global run state earns an autoload (`Run`).
##
## Flow: menu Play → start_new_run() → arena (intro fight) → win → MapScreen shows the
## first frontier → pick a node → its level scene → win → back to MapScreen advanced one
## row, and on until a boss node ends the map. Every node is the arena for now; adding a
## level later is a new LevelDef in the catalog and pointing the generator at it.
signal run_started
# Every level loads the same reusable shell (player, camera, HUD, PS1 filter); the shell
# then instances the level's own `level_scene` module into its LevelRoot.
const _SHELL_SCENE := "res://scene.tscn"
const _ARENA_LEVEL := preload("res://levels/arena_level.tscn")
const _BEAR_LEVEL := preload("res://levels/bear_level.tscn")
const _LOBBY_LEVEL := preload("res://levels/lobby_level.tscn")
# Map shape. Small + placeholder for now; the final row is a single boss node.
const _ROWS := 5
const _MIN_PER_ROW := 2
const _MAX_PER_ROW := 4
## map[row] is an Array[MapNode]; empty until a run starts.
var map: Array = []
## Node the player currently occupies. (-1, -1) = the start gate below the first row —
## i.e. straight after the intro fight, before any map node has been chosen.
var player_row: int = -1
var player_col: int = -1
## Node picked on the map whose level is being played now; committed to on the next win.
var pending_row: int = -1
var pending_col: int = -1
## Debug override — when set, active_level() returns this regardless of map state.
## Set via the console `level` command; cleared on start_new_run().
var debug_level_override: LevelDef = null
## The lobby is an interstitial hub between fights, not a map node: the player is sent
## here after clearing a level (go_to_lobby()) to move around freely before choosing the
## next node. While set, active_level() serves the lobby; cleared once a node is picked
## (select_node) or a fresh run begins.
var enter_lobby: bool = false
# Level catalog. Every level shares the shell scene; what changes is the level module
# (its geometry + spawners) and the opponent. The arena (matador wave, normal + boss
# flavours) and the bear (a lone boss enemy that can turn up as a normal node) live here.
# Future levels — china shop — slot in the same way: a new module + a LevelDef entry.
const _BEAR_SCENE := preload("res://Bear.tscn")
var _arena: LevelDef
var _arena_boss: LevelDef
var _bear: LevelDef
var _lobby: LevelDef
func _ready() -> void:
_arena = _make_level(&"arena", "The Arena", _ARENA_LEVEL, Color(0.80, 0.62, 0.24), false)
_arena_boss = _make_level(
&"arena_boss", "Grand Arena", _ARENA_LEVEL, Color(0.88, 0.30, 0.20), true
)
_bear = _make_level(&"bear", "Bear's Den", _BEAR_LEVEL, Color(0.55, 0.38, 0.22), false)
_bear.enemy_scene = _BEAR_SCENE
# The lobby is a fightless hub — no enemy_scene, so no wave spawns and no win/lose.
_lobby = _make_level(&"lobby", "Lobby", _LOBBY_LEVEL, Color(0.62, 0.66, 0.72), false)
func _make_level(
id: StringName, level_name: String, level_scene: PackedScene, color: Color, boss: bool
) -> LevelDef:
var l := LevelDef.new()
l.id = id
l.display_name = level_name
l.scene_path = _SHELL_SCENE
l.level_scene = level_scene
l.color = color
l.is_boss = boss
return l
## The level being fought right now: debug override wins, then the pending/current map
## node, falling back to the plain arena for the intro fight or a direct scene launch.
func active_level() -> LevelDef:
if debug_level_override != null:
return debug_level_override
if enter_lobby:
return _lobby
var n := node_at(pending_row, pending_col)
if n == null:
n = current_node()
return n.level if n != null and n.level != null else _arena
## Return a level by id, or null if unknown. Used by the debug console `level` command.
func get_level(id: StringName) -> LevelDef:
match id:
&"arena": return _arena
&"arena_boss": return _arena_boss
&"bear": return _bear
&"lobby": return _lobby
return null
## All known level ids — shown by `level` in the console.
func level_ids() -> Array[StringName]:
return [&"arena", &"arena_boss", &"bear", &"lobby"]
## Begin a fresh run: build a new map and place the player at the start gate. Called from
## the main-menu Play button, not from the arena scene (which reloads for every fight).
func start_new_run() -> void:
_generate_map()
player_row = -1
player_col = -1
pending_row = -1
pending_col = -1
debug_level_override = null
enter_lobby = false
run_started.emit()
## Build a run if none exists — a safety net for entering the map or arena directly (e.g.
## running scene.tscn straight from the editor) without coming through the menu.
func ensure_run() -> void:
if map.is_empty():
start_new_run()
func has_run() -> bool:
return not map.is_empty()
func node_at(row: int, col: int) -> MapNode:
if row < 0 or row >= map.size():
return null
var r: Array = map[row]
if col < 0 or col >= r.size():
return null
return r[col]
func current_node() -> MapNode:
return node_at(player_row, player_col)
## Nodes the player may pick next: the whole first row from the start gate, otherwise the
## nodes the current node links forward to.
func reachable() -> Array:
if map.is_empty():
return []
if player_row < 0:
return (map[0] as Array).duplicate()
var here := current_node()
if here == null or player_row + 1 >= map.size():
return []
var out: Array = []
for c: int in here.links:
var n := node_at(player_row + 1, c)
if n != null:
out.append(n)
return out
## True once the player has cleared a boss node — the map is finished.
func run_complete() -> bool:
var here := current_node()
return here != null and here.level != null and here.level.is_boss
## Mark a node as the one being played (its level scene is about to load).
func select_node(node: MapNode) -> void:
if node == null:
return
enter_lobby = false
pending_row = node.row
pending_col = node.col
## Send the player to the lobby hub instead of a fight — active_level() serves the lobby
## until a node is picked. Called after clearing a level so the player lands in the lobby
## rather than jumping straight to the next fight.
func go_to_lobby() -> void:
enter_lobby = true
## Commit the level just won: move the player onto the pending node (or leave them at the
## start gate after the intro fight, which has no pending node).
func complete_current_level() -> void:
if pending_row >= 0:
player_row = pending_row
player_col = pending_col
var n := current_node()
if n != null:
n.cleared = true
pending_row = -1
pending_col = -1
# ── Map generation ────────────────────────────────────────────────────────────
# A small branching lattice: each row gets a handful of nodes, each node links to the
# nearest node(s) in the next row, and every next-row node is guaranteed a parent so the
# path is always connected (no orphan the player can never reach). The final row is a
# single boss node the whole map funnels toward.
func _generate_map() -> void:
map.clear()
for r: int in _ROWS:
var count := 1 if r == _ROWS - 1 else randi_range(_MIN_PER_ROW, _MAX_PER_ROW)
var y := 1.0 - float(r + 1) / float(_ROWS + 1) # row 0 near the bottom, boss on top
var row_nodes: Array = []
for c: int in count:
var node := MapNode.new()
node.row = r
node.col = c
node.pos = Vector2(_column_x(c, count), y)
# Boss row is always the Grand Arena; normal nodes are a mix of matador
# arenas and the occasional Bear's Den for variety.
if r == _ROWS - 1:
node.level = _arena_boss
else:
node.level = _bear if randf() < 0.35 else _arena
row_nodes.append(node)
map.append(row_nodes)
_link_rows()
func _column_x(col: int, count: int) -> float:
if count <= 1:
return 0.5
return lerpf(0.18, 0.82, float(col) / float(count - 1))
func _link_rows() -> void:
for r: int in _ROWS - 1:
var cur: Array = map[r]
var nxt: Array = map[r + 1]
var incoming := PackedInt32Array()
incoming.resize(nxt.size()) # zero-filled
for from_node: MapNode in cur:
var nearest := _nearest_index(from_node, nxt)
_add_link(from_node, nearest, incoming)
# Occasionally fan out to an adjacent next-row node so paths branch.
if nxt.size() > 1 and randf() < 0.45:
var step := 1 if randf() < 0.5 else -1
_add_link(from_node, clampi(nearest + step, 0, nxt.size() - 1), incoming)
# Guarantee every next-row node has at least one parent (no orphans).
for c: int in nxt.size():
if incoming[c] == 0:
_add_link(cur[_nearest_index(nxt[c], cur)], c, incoming)
func _add_link(from_node: MapNode, to_col: int, incoming: PackedInt32Array) -> void:
if to_col in from_node.links:
return
from_node.links.append(to_col)
incoming[to_col] += 1
# Index of the node in `others` whose column position is closest to `node` on the x axis —
# used both to link a node forward and to adopt an orphan back to its nearest parent.
func _nearest_index(node: MapNode, others: Array) -> int:
var best := 0
var best_d := INF
for i: int in others.size():
var d: float = absf((others[i] as MapNode).pos.x - node.pos.x)
if d < best_d:
best_d = d
best = i
return best
+1
View File
@@ -0,0 +1 @@
uid://u4fartm52bk3
+109 -3
View File
@@ -57,23 +57,26 @@ const ACTION_LABELS: Dictionary = {
&"move_left": "Move Left", &"move_left": "Move Left",
&"move_right": "Move Right", &"move_right": "Move Right",
&"ability_kick": "Kick", &"ability_kick": "Kick",
&"ability_slam": "Slam",
&"ability_dash": "Dash", &"ability_dash": "Dash",
&"ability_slam": "Slam",
&"ability_roll": "Roll", &"ability_roll": "Roll",
} }
@onready var main_panel: Control = $MainPanel @onready var main_panel: Control = $MainPanel
@onready var options_panel: Control = $OptionsPanel @onready var options_panel: Control = $OptionsPanel
@onready var credits_panel: Control = $CreditsPanel @onready var credits_panel: Control = $CreditsPanel
@onready var options_vbox: VBoxContainer = $OptionsPanel/VBox
@onready var options_scroll: ScrollContainer = $OptionsPanel/VBox/Scroll
@onready var rebind_list: VBoxContainer = $OptionsPanel/VBox/Scroll/RebindList @onready var rebind_list: VBoxContainer = $OptionsPanel/VBox/Scroll/RebindList
@onready var fade_rect: ColorRect = $FadeRect @onready var fade_rect: ColorRect = $FadeRect
@onready var play_button: Button = $MainPanel/PlayButton @onready var play_button: Button = $VideoAspect/CapeLayer/PlayButton
@onready var options_button: Button = $MainPanel/OptionsButton @onready var options_button: Button = $MainPanel/OptionsButton
@onready var credits_button: Button = $MainPanel/CreditsButton @onready var credits_button: Button = $MainPanel/CreditsButton
@onready var exit_button: Button = $MainPanel/ExitButton @onready var exit_button: Button = $MainPanel/ExitButton
@onready var options_back: Button = $OptionsPanel/VBox/BackButton @onready var options_back: Button = $OptionsPanel/VBox/BackButton
@onready var credits_back: Button = $CreditsPanel/VBox/BackButton @onready var credits_back: Button = $CreditsPanel/VBox/BackButton
@onready var video: VideoStreamPlayer = $VideoStreamPlayer @onready var video: VideoStreamPlayer = $VideoAspect/VideoStreamPlayer
@onready var blur_video: VideoStreamPlayer = $VideoBlur/BlurVideo
var _listening_action: StringName = &"" var _listening_action: StringName = &""
var _listening_button: Button = null var _listening_button: Button = null
@@ -87,6 +90,9 @@ func _ready() -> void:
if video.stream != null: if video.stream != null:
video.play() video.play()
video.finished.connect(video.play) # loop the background clip video.finished.connect(video.play) # loop the background clip
if blur_video.stream != null:
blur_video.play()
blur_video.finished.connect(blur_video.play) # loop the blurred backdrop
play_button.pressed.connect(_on_play_pressed) play_button.pressed.connect(_on_play_pressed)
options_button.pressed.connect(_on_options_pressed) options_button.pressed.connect(_on_options_pressed)
@@ -96,6 +102,7 @@ func _ready() -> void:
credits_back.pressed.connect(_close_subpanels) credits_back.pressed.connect(_close_subpanels)
_apply_theme() _apply_theme()
_build_version_label() _build_version_label()
_build_options_toggles()
_build_rebind_rows() _build_rebind_rows()
play_button.grab_focus() play_button.grab_focus()
@@ -183,6 +190,101 @@ func _process(_delta: float) -> void:
play_button.anchor_bottom = cape.y play_button.anchor_bottom = cape.y
# Player-facing option toggles and audio sliders, seated above the rebind list.
func _build_options_toggles() -> void:
var idx := options_scroll.get_index()
_add_section_label("── Audio ──", idx); idx += 1
_add_slider_row("Master Volume", Settings.volume_master, Settings.set_volume_master, idx); idx += 1
_add_slider_row("Ambient Volume", Settings.volume_ambient, Settings.set_volume_ambient, idx); idx += 1
_add_slider_row("Effects Volume", Settings.volume_effects, Settings.set_volume_effects, idx); idx += 1
_add_toggle_row("Mute All", Settings.mute_all, Settings.set_mute_all, idx); idx += 1
_add_section_label("── Gameplay ──", idx); idx += 1
_add_toggle_row("Screen Shake", Settings.screen_shake, Settings.set_screen_shake, idx); idx += 1
_add_toggle_row("Blood & Gore", Settings.gore, Settings.set_gore, idx); idx += 1
_add_section_label("── Controls ──", idx)
func _add_section_label(text: String, at_index: int) -> void:
var lbl := Label.new()
lbl.text = text
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
lbl.add_theme_font_override("font", UiFonts.title())
lbl.add_theme_font_size_override("font_size", 18)
lbl.add_theme_color_override("font_color", MENU_GOLD)
lbl.add_theme_color_override("font_outline_color", Color.BLACK)
lbl.add_theme_constant_override("outline_size", 4)
options_vbox.add_child(lbl)
options_vbox.move_child(lbl, at_index)
func _add_slider_row(label_text: String, value: float, setter: Callable, at_index: int) -> void:
var row := HBoxContainer.new()
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var name_label := Label.new()
name_label.text = label_text
name_label.custom_minimum_size = Vector2(160, 0)
name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
name_label.add_theme_font_override("font", UiFonts.body())
name_label.add_theme_font_size_override("font_size", 20)
name_label.add_theme_color_override("font_color", MENU_CREAM)
name_label.add_theme_color_override("font_outline_color", Color.BLACK)
name_label.add_theme_constant_override("outline_size", 5)
row.add_child(name_label)
var slider := HSlider.new()
slider.min_value = 0.0
slider.max_value = 1.0
slider.step = 0.01
slider.value = value
slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
slider.custom_minimum_size = Vector2(0, 28)
row.add_child(slider)
var pct_label := Label.new()
pct_label.text = "%d%%" % roundi(value * 100.0)
pct_label.custom_minimum_size = Vector2(46, 0)
pct_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
pct_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
pct_label.add_theme_font_override("font", UiFonts.body())
pct_label.add_theme_font_size_override("font_size", 18)
pct_label.add_theme_color_override("font_color", MENU_GOLD)
row.add_child(pct_label)
slider.value_changed.connect(func(v: float) -> void:
setter.call(v)
pct_label.text = "%d%%" % roundi(v * 100.0))
options_vbox.add_child(row)
options_vbox.move_child(row, at_index)
func _add_toggle_row(label_text: String, value: bool, setter: Callable, at_index: int) -> void:
var row := HBoxContainer.new()
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var name_label := Label.new()
name_label.text = label_text
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
name_label.add_theme_font_override("font", UiFonts.body())
name_label.add_theme_font_size_override("font_size", 20)
name_label.add_theme_color_override("font_color", MENU_CREAM)
name_label.add_theme_color_override("font_outline_color", Color.BLACK)
name_label.add_theme_constant_override("outline_size", 5)
row.add_child(name_label)
var toggle := CheckButton.new()
toggle.button_pressed = value
toggle.add_theme_color_override("font_color", MENU_GOLD)
toggle.toggled.connect(func(on: bool) -> void: setter.call(on))
row.add_child(toggle)
options_vbox.add_child(row)
options_vbox.move_child(row, at_index)
func _build_rebind_rows() -> void: func _build_rebind_rows() -> void:
for child in rebind_list.get_children(): for child in rebind_list.get_children():
child.queue_free() child.queue_free()
@@ -213,6 +315,7 @@ func _build_rebind_rows() -> void:
func _on_play_pressed() -> void: func _on_play_pressed() -> void:
_set_buttons_disabled(true) _set_buttons_disabled(true)
Run.start_new_run() # fresh map for this run; the arena is the intro fight
var tween := create_tween() var tween := create_tween()
tween.tween_property(fade_rect, "color:a", 1.0, FADE_TIME) tween.tween_property(fade_rect, "color:a", 1.0, FADE_TIME)
tween.tween_callback(func() -> void: tween.tween_callback(func() -> void:
@@ -221,12 +324,14 @@ func _on_play_pressed() -> void:
func _on_options_pressed() -> void: func _on_options_pressed() -> void:
main_panel.visible = false main_panel.visible = false
play_button.visible = false
options_panel.visible = true options_panel.visible = true
options_back.grab_focus() options_back.grab_focus()
func _on_credits_pressed() -> void: func _on_credits_pressed() -> void:
main_panel.visible = false main_panel.visible = false
play_button.visible = false
credits_panel.visible = true credits_panel.visible = true
credits_back.grab_focus() credits_back.grab_focus()
@@ -240,6 +345,7 @@ func _close_subpanels() -> void:
options_panel.visible = false options_panel.visible = false
credits_panel.visible = false credits_panel.visible = false
main_panel.visible = true main_panel.visible = true
play_button.visible = true
play_button.grab_focus() play_button.grab_focus()
+218 -125
View File
@@ -22,6 +22,7 @@ var _sim: PhysicalBoneSimulator3D = null
var _anim_player: AnimationPlayer = null var _anim_player: AnimationPlayer = null
var _wander_target: Vector3 = Vector3.ZERO var _wander_target: Vector3 = Vector3.ZERO
var _idle_timer: float = 0.0 var _idle_timer: float = 0.0
var _taunt_swap: float = 0.0
var _idle_anim: StringName = _ANIM_IDLE var _idle_anim: StringName = _ANIM_IDLE
var _bull: CharacterBody3D = null var _bull: CharacterBody3D = null
var _step_dir: Vector3 = Vector3.ZERO var _step_dir: Vector3 = Vector3.ZERO
@@ -34,6 +35,7 @@ var _swing_timer: float = 0.0
var _lunge_timer: float = 0.0 var _lunge_timer: float = 0.0
var _roll_timer: float = 0.0 var _roll_timer: float = 0.0
var _roll_dir: Vector3 = Vector3.ZERO var _roll_dir: Vector3 = Vector3.ZERO
var _is_spawning: bool = false
var _throw_timer: float = 0.0 var _throw_timer: float = 0.0
var _throw_aim_cd: float = 0.0 var _throw_aim_cd: float = 0.0
var _throw_dir: Vector3 = Vector3.ZERO var _throw_dir: Vector3 = Vector3.ZERO
@@ -50,8 +52,8 @@ var _drawing: bool = false
var _sheathing: bool = false var _sheathing: bool = false
var _draw_timer: float = 0.0 var _draw_timer: float = 0.0
var _draw_len: float = 0.0 var _draw_len: float = 0.0
var _resword_timer: float = 0.0
var _sword_blade_area: Area3D = null var _sword_blade_area: Area3D = null
var _spear_node: Node3D = null
var _blade_hit_cd: float = 0.0 var _blade_hit_cd: float = 0.0
var _death_player: AudioStreamPlayer = null var _death_player: AudioStreamPlayer = null
var _blood_burst: CPUParticles3D = null var _blood_burst: CPUParticles3D = null
@@ -59,6 +61,7 @@ var _blood_burst: CPUParticles3D = null
@onready var _mesh: Node3D = $matador2 @onready var _mesh: Node3D = $matador2
@onready var _hit_area: Area3D = $HitArea @onready var _hit_area: Area3D = $HitArea
const _ANIM_RUN: StringName = &"Run" const _ANIM_RUN: StringName = &"Run"
const _ANIM_IDLE: StringName = &"Taunt" const _ANIM_IDLE: StringName = &"Taunt"
const _ANIM_ATTACK: StringName = &"Attack" const _ANIM_ATTACK: StringName = &"Attack"
@@ -74,6 +77,9 @@ const _ANIM_TAUNTS: Array[StringName] = [&"Taunt", &"Taunt_B", &"Taunt_C"]
const _DRAW_GRAB_AT: float = 0.55 const _DRAW_GRAB_AT: float = 0.55
const _SWORD_SCENE: PackedScene = preload("res://Assets/sword.glb") const _SWORD_SCENE: PackedScene = preload("res://Assets/sword.glb")
const _SPEAR_SCENE: PackedScene = preload("res://Assets/spear.glb")
const _GRAVITY: float = 9.8 # arena gravity, for the spear's ballistic arc
const _STEER_LOOKAHEAD: float = 3.5 # longer = turns before hitting wall const _STEER_LOOKAHEAD: float = 3.5 # longer = turns before hitting wall
const _STEER_INTERVAL: float = 0.12 # recompute direction at most ~8×/sec const _STEER_INTERVAL: float = 0.12 # recompute direction at most ~8×/sec
@@ -88,15 +94,23 @@ const _TURN_FACE: float = 16.0 # face the bull while engaging
const _TURN_SHARP: float = 24.0 # snap onto a sidestep / roll direction const _TURN_SHARP: float = 24.0 # snap onto a sidestep / roll direction
const RigidSkin = preload("res://rigid_skin.gd")
func _ready() -> void: func _ready() -> void:
add_to_group(&"matador") add_to_group(&"matador")
_skeleton = _find_skeleton(_mesh) _skeleton = _find_skeleton(_mesh)
_anim_player = _find_anim_player(_mesh) _anim_player = _find_anim_player(_mesh)
if _skeleton: if _skeleton:
_sim = MatadorRagdoll.build(_skeleton) _sim = MatadorRagdoll.build(_skeleton)
# Web (Mali/ANGLE) can't run Compatibility vertex-skinning; rebuild as non-skinned
# bone-attached pieces. Runs after the ragdoll sim is built (both drive the same bones).
if OS.has_feature("web"):
RigidSkin.convert_tree(self)
if _anim_player: if _anim_player:
for anim in [_ANIM_RUN, _ANIM_ATTACK, _ANIM_ROLL] + _ANIM_TAUNTS: for anim in [_ANIM_RUN, _ANIM_ATTACK] + _ANIM_TAUNTS:
_ensure_loop(anim) _ensure_loop(anim)
_flatten_roll_travel()
if not _anim_player.has_animation(_ANIM_RUN): if not _anim_player.has_animation(_ANIM_RUN):
push_warning("Matador: expected animations not found. Available: %s" % push_warning("Matador: expected animations not found. Available: %s" %
str(_anim_player.get_animation_list())) str(_anim_player.get_animation_list()))
@@ -104,7 +118,7 @@ func _ready() -> void:
_anim_player.play(_ANIM_IDLE) _anim_player.play(_ANIM_IDLE)
_setup_sword() _setup_sword()
DP.any_changed.connect(_on_dp_changed) DP.any_changed.connect(_on_dp_changed)
_throw_aim_cd = randf_range(0.3, 1.0) _throw_aim_cd = randf_range(2.0, 5.0)
_hit_area.body_entered.connect(_on_body_entered) _hit_area.body_entered.connect(_on_body_entered)
_pick_wander_target() _pick_wander_target()
_blood_burst = preload("res://blood_burst.gd").new() _blood_burst = preload("res://blood_burst.gd").new()
@@ -113,11 +127,13 @@ func _ready() -> void:
_ole_player = AudioStreamPlayer.new() _ole_player = AudioStreamPlayer.new()
_ole_player.stream = load("res://sounds/ole.ogg") _ole_player.stream = load("res://sounds/ole.ogg")
_ole_player.volume_db = 0.0 _ole_player.volume_db = 0.0
_ole_player.bus = &"Effects"
add_child(_ole_player) add_child(_ole_player)
if ResourceLoader.exists("res://sounds/matador_death.ogg"): if ResourceLoader.exists("res://sounds/matador_death.ogg"):
_death_player = AudioStreamPlayer.new() _death_player = AudioStreamPlayer.new()
_death_player.stream = load("res://sounds/matador_death.ogg") _death_player.stream = load("res://sounds/matador_death.ogg")
_death_player.volume_db = 2.0 _death_player.volume_db = 2.0
_death_player.bus = &"Effects"
add_child(_death_player) add_child(_death_player)
@@ -138,6 +154,10 @@ func _acquire_bull() -> void:
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
_acquire_bull() _acquire_bull()
if _is_spawning and is_on_floor():
_is_spawning = false
collision_layer = 1
if DP.b("show_raycasts") and _steer_dir != Vector3.ZERO: if DP.b("show_raycasts") and _steer_dir != Vector3.ZERO:
var o := global_position + Vector3.UP * 0.6 var o := global_position + Vector3.UP * 0.6
DebugDraw.ray(o, o + _steer_dir * _STEER_LOOKAHEAD, Color(0.3, 1.0, 0.9)) DebugDraw.ray(o, o + _steer_dir * _STEER_LOOKAHEAD, Color(0.3, 1.0, 0.9))
@@ -148,12 +168,6 @@ func _physics_process(delta: float) -> void:
_dodge_cd = maxf(0.0, _dodge_cd - delta) _dodge_cd = maxf(0.0, _dodge_cd - delta)
_blade_hit_cd = maxf(0.0, _blade_hit_cd - delta) _blade_hit_cd = maxf(0.0, _blade_hit_cd - delta)
_throw_aim_cd = maxf(0.0, _throw_aim_cd - delta) _throw_aim_cd = maxf(0.0, _throw_aim_cd - delta)
# Pull a replacement sword from the holster a beat after throwing the last one.
if _state != State.RAGDOLL and not is_instance_valid(_sword_node) \
and (_sword_rest_attach != null or _sword_hand_attach != null):
_resword_timer -= delta
if _resword_timer <= 0.0:
_spawn_sword()
if _state != State.RAGDOLL and _bull != null: if _state != State.RAGDOLL and _bull != null:
_update_ai_state(delta) _update_ai_state(delta)
@@ -216,20 +230,30 @@ func _choose_intent() -> Intent:
var throw_ok := _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node) \ var throw_ok := _throw_aim_cd <= 0.0 and is_instance_valid(_sword_node) \
and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range") and dist >= DP.f("mat_throw_min_dist") and dist <= DP.f("mat_throw_range")
# Only the nearest few matadors work the bull at once; the rest hang back and
# taunt (see _engagement_rank). This turns a crowd into a bullring — matadors
# spreading out and taking turns — instead of a swarm all piling on together.
var is_engager := _engagement_rank() < maxi(1, int(DP.f("mat_engage_slots")))
# Supporting matadors strut and circle constantly; the lead only struts between
# passes, once the bull is back out past horn reach.
var strut := 0.65 if not is_engager else 0.3 * beyond
var engage_mult := 1.0 if is_engager else 0.15
var score := { var score := {
# Only drift when the bull is out past throw range; up close this is ~0. # Showmanship: circle the bull at a taunting distance and pose for the crowd.
Intent.WANDER: DP.f("mat_w_showmanship") * 0.3 * beyond, Intent.WANDER: DP.f("mat_w_showmanship") * strut,
# Retreat only when the bull is close AND actually dangerous. # Retreat only when the bull is close AND actually dangerous.
Intent.FLEE: DP.f("mat_w_flee") * close * (0.25 + 0.75 * danger), Intent.FLEE: DP.f("mat_w_flee") * close * (0.25 + 0.75 * danger),
# Press the attack; ease off a touch when a charge is barrelling in. # Press the attack; ease off a touch when a charge is barrelling in. Supporting
# matadors barely press — they yield the pass to whoever's engaging.
Intent.ATTACK: DP.f("mat_w_aggression") * (0.45 + 0.55 * close) \ Intent.ATTACK: DP.f("mat_w_aggression") * (0.45 + 0.55 * close) \
* (1.0 - 0.55 * danger) * dodge_ready, * (1.0 - 0.55 * danger) * dodge_ready * engage_mult,
# Sidestep / plant against an incoming charge. # Sidestep / plant against an incoming charge.
Intent.DODGE: DP.f("mat_w_caution") * danger * (1.0 if _dodge_cd <= 0.0 else 0.0), Intent.DODGE: DP.f("mat_w_caution") * danger * (1.0 if _dodge_cd <= 0.0 else 0.0),
# Hurl the blade whenever there's a clean lane at range — the ranged threat # Hurl the blade — an occasional ranged flourish from an engaging matador when
# that punishes a bull for hanging back out of horn reach. # there's a clean lane, not a barrage (see mat_w_throw). Supporters never pelt.
Intent.THROW: ((DP.f("mat_w_showmanship") * 0.55 + DP.f("mat_w_aggression") * 0.6) \ Intent.THROW: (DP.f("mat_w_throw") * (0.35 + 0.65 * (1.0 - close))) \
* (0.35 + 0.65 * (1.0 - close))) if throw_ok else -1.0, if (throw_ok and is_engager) else -1.0,
} }
score[_intent] = float(score[_intent]) + DP.f("mat_w_commit") score[_intent] = float(score[_intent]) + DP.f("mat_w_commit")
@@ -277,6 +301,40 @@ func _enter_intent(intent: Intent) -> void:
_start_throw() _start_throw()
# How many other live matadors are closer to the bull than this one. 0 = the
# closest (the natural lead engager); the utility brain gives the nearest
# mat_engage_slots the go-ahead to press while the rest spread out and taunt.
func _engagement_rank() -> int:
if _bull == null:
return 0
var my_d := global_position.distance_squared_to(_bull.global_position)
var rank := 0
for node: Node in get_tree().get_nodes_in_group(&"matador"):
if node == self:
continue
var other := node as Node3D
if other == null or not is_instance_valid(other) or not other.call(&"is_active"):
continue
if other.global_position.distance_squared_to(_bull.global_position) < my_d:
rank += 1
return rank
# Live and vying for the bull's attention — a ragdolled corpse doesn't hold a slot.
func is_active() -> bool:
return _state != State.RAGDOLL
# True when this matador is safely out of the action — not one of the engaging few,
# and with the bull far enough off to strike a long, unhurried taunt for the crowd.
func _out_of_harm() -> bool:
if _bull == null:
return true
if _engagement_rank() < maxi(1, int(DP.f("mat_engage_slots"))):
return false
return global_position.distance_to(_bull.global_position) >= DP.f("mat_taunt_ring") * 0.6
# Bull is moving fast and aimed within ~30° of the matador # Bull is moving fast and aimed within ~30° of the matador
func _is_charge_incoming() -> bool: func _is_charge_incoming() -> bool:
var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z) var bull_flat := Vector3(_bull.velocity.x, 0.0, _bull.velocity.z)
@@ -351,6 +409,14 @@ func _tick_wander(delta: float) -> void:
if _drawing or _sheathing or _idle_timer > 0.0: if _drawing or _sheathing or _idle_timer > 0.0:
_idle_timer = maxf(0.0, _idle_timer - delta) _idle_timer = maxf(0.0, _idle_timer - delta)
_decelerate(10.0, delta) _decelerate(10.0, delta)
# Taunt toward the bull so the posturing plays to it, not off into space.
if _bull != null and _idle_timer > 0.0:
_face_point(_bull.global_position, delta, _TURN_MOVE)
# Cycle gestures through a long hold so a posing matador stays lively.
_taunt_swap -= delta
if _taunt_swap <= 0.0 and not _drawing and not _sheathing:
_idle_anim = _pick_taunt()
_taunt_swap = randf_range(1.5, 3.0)
_play_anim(_idle_anim) _play_anim(_idle_anim)
move_and_slide() move_and_slide()
return return
@@ -358,8 +424,12 @@ func _tick_wander(delta: float) -> void:
var to_target := Vector3( var to_target := Vector3(
_wander_target.x - global_position.x, 0.0, _wander_target.z - global_position.z) _wander_target.x - global_position.x, 0.0, _wander_target.z - global_position.z)
if to_target.length() < 0.8: if to_target.length() < 0.8:
_idle_timer = randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max")) # A matador out of harm's way settles into a long taunt for the crowd; one still
# in the mix only pauses briefly between passes.
_idle_timer = randf_range(DP.f("mat_taunt_hold") * 0.7, DP.f("mat_taunt_hold") * 1.3) \
if _out_of_harm() else randf_range(DP.f("mat_idle_min"), DP.f("mat_idle_max"))
_idle_anim = _pick_taunt() _idle_anim = _pick_taunt()
_taunt_swap = randf_range(1.5, 3.0)
_pick_wander_target() _pick_wander_target()
else: else:
var dir := _steer_clear(to_target.normalized(), delta) var dir := _steer_clear(to_target.normalized(), delta)
@@ -482,7 +552,7 @@ func _tick_attack(delta: float) -> void:
if _swing_timer > 0.0: if _swing_timer > 0.0:
_lunge_timer = maxf(0.0, _lunge_timer - delta) _lunge_timer = maxf(0.0, _lunge_timer - delta)
if _lunge_timer > 0.0 and dist > 0.1: if _lunge_timer > 0.0 and dist > DP.f("mat_attack_min_dist"):
_accelerate(to_bull.normalized(), DP.f("mat_lunge_speed"), delta) _accelerate(to_bull.normalized(), DP.f("mat_lunge_speed"), delta)
else: else:
_decelerate(22.0, delta) _decelerate(22.0, delta)
@@ -509,6 +579,23 @@ func _enter_roll(hit_dir: Vector3) -> void:
_anim_player.seek(0.0, true) _anim_player.seek(0.0, true)
# Called by the spawner (deferred) to make the matador leap-roll toward a target
# point on entry — the cinematic arena entrance. After the roll it transitions to
# normal AI via the standard ROLL → FLEE path.
func enter_spawn_roll(toward: Vector3) -> void:
var dir := toward - global_position
dir.y = 0.0
_roll_dir = dir.normalized() if dir.length() > 0.1 else -_mesh.global_transform.basis.z
_state = State.ROLL
_roll_timer = _anim_length(_ANIM_ROLL, DP.f("mat_roll_duration"))
_is_spawning = true
collision_layer = 0
velocity.y = 4.0
if _anim_player and _anim_player.has_animation(_ANIM_ROLL):
_anim_player.play(_ANIM_ROLL)
_anim_player.seek(0.0, true)
func _tick_roll(delta: float) -> void: func _tick_roll(delta: float) -> void:
_roll_timer -= delta _roll_timer -= delta
if _roll_timer <= 0.0: if _roll_timer <= 0.0:
@@ -528,7 +615,8 @@ func _start_throw() -> void:
_throw_timer = DP.f("mat_throw_windup") _throw_timer = DP.f("mat_throw_windup")
velocity = Vector3.ZERO velocity = Vector3.ZERO
_cancel_transitions() _cancel_transitions()
_carry_sword(true) # blade must be in hand for the wind-up and release _carry_sword(false) # the sword is melee-only — holster it for the throw
_spawn_spear_in_hand() # a fresh spear is drawn for the overhand throw
if _anim_player: if _anim_player:
_anim_player.pause() _anim_player.pause()
var to_bull := _bull.global_position - global_position var to_bull := _bull.global_position - global_position
@@ -552,8 +640,8 @@ func _tick_throw(delta: float) -> void:
_pose_throw_arm(phase) _pose_throw_arm(phase)
var t_rel := DP.f("mat_throw_release") var t_rel := DP.f("mat_throw_release")
if prev < t_rel and phase >= t_rel and is_instance_valid(_sword_node): if prev < t_rel and phase >= t_rel and is_instance_valid(_spear_node):
_release_sword() _release_spear()
if _throw_timer <= 0.0: if _throw_timer <= 0.0:
_end_throw() _end_throw()
@@ -562,20 +650,18 @@ func _tick_throw(delta: float) -> void:
func _end_throw() -> void: func _end_throw() -> void:
_state = State.FLEE _state = State.FLEE
_clear_spear() # drop any spear still in hand if the wind-up was cut short
_dodge_cd = DP.f("mat_dodge_cooldown") _dodge_cd = DP.f("mat_dodge_cooldown")
# Space throws out so the utility brain doesn't immediately vote another one, # Space throws well apart so a hurl stays a flourish, not a barrage.
# but keep them frequent enough to pressure a bull that hangs back at range. _throw_aim_cd = randf_range(4.0, 7.0)
_throw_aim_cd = randf_range(1.6, 3.2)
_intent = Intent.FLEE _intent = Intent.FLEE
# Blend straight into locomotion so the manually-posed throw arm eases out # Blend to locomotion so the manually-posed throw arm eases out (resume() snaps).
# (a bare resume() would snap and leave the player on whatever it paused on).
if _anim_player: if _anim_player:
_anim_player.play(_ANIM_RUN) _anim_player.play(_ANIM_RUN)
# Drive arm_R + forearm_R through a cock-back → release arc while the # Drive arm_R + forearm_R through a cock-back → release arc while the
# AnimationPlayer is paused. Swing is on bone-local X (see CLAUDE.md rig notes); # AnimationPlayer is paused. Swing is on bone-local X (see CLAUDE.md), DP-tunable.
# angles are DP-tunable so the arc can be dialled in per rig.
func _pose_throw_arm(phase: float) -> void: func _pose_throw_arm(phase: float) -> void:
if not _skeleton: if not _skeleton:
return return
@@ -605,82 +691,59 @@ func _set_bone_swing(bone: String, deg: float) -> void:
_skeleton.set_bone_pose_rotation(idx, rest * Quaternion(Vector3.RIGHT, deg_to_rad(deg))) _skeleton.set_bone_pose_rotation(idx, rest * Quaternion(Vector3.RIGHT, deg_to_rad(deg)))
# Detach the sword from the hand and launch it as a spinning physics projectile # Draw a fresh spear into the throwing hand for the wind-up (no melee collider).
# that damages the bull on contact, then settles and despawns. func _spawn_spear_in_hand() -> void:
func _release_sword() -> void: if is_instance_valid(_spear_node) or _sword_hand_attach == null:
if not is_instance_valid(_sword_node):
return return
var sword := _sword_node _spear_node = _SPEAR_SCENE.instantiate() as Node3D
var blade := _sword_blade_area _sword_hand_attach.add_child(_spear_node)
var gx := sword.global_transform _spear_node.position = Vector3(
_sword_node = null DP.f("spear_pos_x"), DP.f("spear_pos_y"), DP.f("spear_pos_z"))
_sword_blade_area = null _spear_node.rotation_degrees = Vector3(
_sword_in_hand = false DP.f("spear_rot_x"), DP.f("spear_rot_y"), DP.f("spear_rot_z"))
_resword_timer = DP.f("mat_resword_delay")
# The RigidBody owns collisions now — silence the melee blade Area it carries.
if is_instance_valid(blade):
blade.monitoring = false
var rb := RigidBody3D.new()
rb.collision_layer = 0
rb.collision_mask = 1
rb.contact_monitor = true
rb.max_contacts_reported = 6
# Fly nearly straight to the aim point instead of lobbing — the old horizontal
# throw sailed clean over the bull's low body. A small gravity_scale keeps a
# touch of drop for feel without dropping short.
rb.gravity_scale = DP.f("mat_throw_gravity")
get_tree().current_scene.add_child(rb)
rb.global_transform = gx
sword.reparent(rb, true)
# Physics collider along the blade (GLB +Z), which equals rb-local +Z because func _clear_spear() -> void:
# the mesh kept its global transform and rb adopted it. Fattened so a fast throw if is_instance_valid(_spear_node):
# reliably overlaps the bull's collision spheres instead of tunnelling past. _spear_node.queue_free()
var cap := CapsuleShape3D.new() _spear_node = null
cap.radius = 0.14
cap.height = 1.4
var cs := CollisionShape3D.new()
cs.shape = cap
cs.position = Vector3(0.0, 0.0, 0.7)
cs.rotation_degrees = Vector3(90.0, 0.0, 0.0)
rb.add_child(cs)
# Aim at the bull's BODY (its collision spheres ride low, ~0.5 m below the
# origin), leading a moving target so it arrives where the bull will be. The aim # Hurl the held spear on a ballistic arc that lands on the bull's low body. The
# keeps its vertical component so the blade drives into the body, not over it. # launch velocity is solved so gravity (mat_throw_gravity) carries it down onto the
var speed := maxf(DP.f("mat_throw_speed"), 0.1) # lead point; SpearProjectile aims the shaft along the arc and embeds it on a hit.
var dir := (_throw_dir + Vector3.UP * 0.0) func _release_spear() -> void:
if not is_instance_valid(_spear_node):
return
var spear := _spear_node
var origin := spear.global_position
_spear_node = null
var hs := maxf(DP.f("mat_throw_speed"), 0.1) # horizontal speed
var g := _GRAVITY * maxf(DP.f("mat_throw_gravity"), 0.05)
# Aim low (the bull's collision spheres ride ~0.5 m below its origin) and lead it
# across the arc's flight time.
var target := origin + _throw_dir * 5.0
if is_instance_valid(_bull): if is_instance_valid(_bull):
var target := _bull.global_position + Vector3(0.0, DP.f("mat_throw_aim_y"), 0.0) target = _bull.global_position + Vector3(0.0, DP.f("mat_throw_aim_y"), 0.0)
var flat := target - gx.origin var lead := target - origin
flat.y = 0.0 lead.y = 0.0
var flight := flat.length() / speed target += _bull.velocity * (lead.length() / hs)
target += _bull.velocity * flight var flat := target - origin
var to_target := target - gx.origin var rise := flat.y
if to_target.length() > 0.1: flat.y = 0.0
dir = to_target.normalized() var dist := flat.length()
# A little aim scatter so throws are a threat to READ and dodge, not a hitscan — var hdir := flat.normalized() if dist > 0.01 else _throw_dir
# a bull that keeps moving can slip them, a stationary one gets pinned. # Aim scatter so the arc can be read and sidestepped, not a hitscan.
var spread := deg_to_rad(DP.f("mat_throw_spread")) var spread := deg_to_rad(DP.f("mat_throw_spread"))
dir = dir.rotated(Vector3.UP, randf_range(-spread, spread)).normalized() hdir = hdir.rotated(Vector3.UP, randf_range(-spread, spread)).normalized()
rb.linear_velocity = dir * speed # Ballistic solve: with horizontal speed hs over distance dist, the flight lasts t;
rb.angular_velocity = dir.cross(Vector3.UP).normalized() * -DP.f("mat_throw_spin") # pick the vertical launch speed that lands the arc on the aim point's height.
var t := dist / hs
var hit_done := [false] var vy := (rise + 0.5 * g * t * t) / t
rb.body_entered.connect(func(body: Node) -> void: var vel := hdir * hs + Vector3.UP * vy
if hit_done[0]: SpearProjectile.launch(get_tree().current_scene, spear, origin, vel,
return maxf(DP.f("mat_throw_gravity"), 0.05))
if body.is_in_group(&"player"):
hit_done[0] = true
body.call(&"take_sword_hit", "thrown")
)
var cleanup := get_tree().create_timer(6.0)
cleanup.timeout.connect(func() -> void:
if is_instance_valid(rb):
rb.queue_free()
)
func _tick_ragdoll(_delta: float) -> void: func _tick_ragdoll(_delta: float) -> void:
@@ -756,6 +819,7 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float, up_boost: float = 0.0)
_state = State.RAGDOLL _state = State.RAGDOLL
if _sword_blade_area: if _sword_blade_area:
_sword_blade_area.monitoring = false _sword_blade_area.monitoring = false
_clear_spear() # a spear mid-wind-up dies with the matador, not in the air
killed.emit() killed.emit()
var cleanup_timer := get_tree().create_timer(4.0) var cleanup_timer := get_tree().create_timer(4.0)
cleanup_timer.timeout.connect(func() -> void: cleanup_timer.timeout.connect(func() -> void:
@@ -866,6 +930,32 @@ func _ensure_loop(anim_name: StringName) -> void:
anim.loop_mode = Animation.LOOP_LINEAR anim.loop_mode = Animation.LOOP_LINEAR
# Roll was authored *travelling* (COG drives ~4.5 m forward on Z), which snapped the
# mesh back when the clip ended. _tick_roll moves the dodge, so strip that COG travel.
func _flatten_roll_travel() -> void:
if _anim_player == null or not _anim_player.has_animation(_ANIM_ROLL):
return
var anim := _anim_player.get_animation(_ANIM_ROLL)
if anim.has_meta(&"_travel_flattened"):
return
anim.set_meta(&"_travel_flattened", true)
var cog := anim.find_track(NodePath("Armature/Skeleton3D:COG"), Animation.TYPE_POSITION_3D)
if cog < 0:
return
# Snapshot the COG travel before mutating (flattening COG first would zero it).
var travel: Dictionary = {}
for i in anim.get_track_count():
if anim.track_get_type(i) == Animation.TYPE_POSITION_3D:
for k in anim.track_get_key_count(i):
var t := anim.track_get_key_time(i, k)
travel[t] = anim.position_track_interpolate(cog, t).z
for i in anim.get_track_count():
if anim.track_get_type(i) == Animation.TYPE_POSITION_3D:
for k in anim.track_get_key_count(i):
var v: Vector3 = anim.track_get_key_value(i, k)
anim.track_set_key_value(i, k, v - Vector3(0.0, 0.0, travel[anim.track_get_key_time(i, k)]))
func _pick_taunt() -> StringName: func _pick_taunt() -> StringName:
var choices: Array[StringName] = [] var choices: Array[StringName] = []
for anim in _ANIM_TAUNTS: for anim in _ANIM_TAUNTS:
@@ -893,13 +983,25 @@ func _play_anim(anim_name: StringName) -> void:
func _pick_wander_target() -> void: func _pick_wander_target() -> void:
# 35% chance: pick a point orbiting close to the bull for a daring pass # Circle the bull for showmanship: mostly settle on the taunt ring (spreading the
if _bull != null and randf() < 0.35: # crowd out around it), with an occasional close pass for daring flair.
var angle := randf() * TAU if _bull != null:
var r := randf_range(1.5, 4.0)
var bull_flat := _bull.global_position var bull_flat := _bull.global_position
bull_flat.y = 0.0 bull_flat.y = 0.0
var r: float
if randf() < 0.2:
r = randf_range(1.5, 4.0) # close daring pass
else:
var ring := DP.f("mat_taunt_ring")
r = randf_range(ring * 0.7, ring * 1.3) # the showmanship ring
var angle := randf() * TAU
_wander_target = bull_flat + Vector3(cos(angle) * r, 0.0, sin(angle) * r) _wander_target = bull_flat + Vector3(cos(angle) * r, 0.0, sin(angle) * r)
# Keep the pose inside the fighting floor rather than backing onto the benches.
var arena_r := DP.f("arena_spawn_radius")
var flat := Vector2(_wander_target.x, _wander_target.z)
if flat.length() > arena_r:
flat = flat.normalized() * arena_r
_wander_target = Vector3(flat.x, 0.0, flat.y)
return return
var radius := DP.f("mat_wander_radius") var radius := DP.f("mat_wander_radius")
var angle := randf() * TAU var angle := randf() * TAU
@@ -909,13 +1011,10 @@ func _pick_wander_target() -> void:
# ── Sword attachment ────────────────────────────────────────────────────────── # ── Sword attachment ──────────────────────────────────────────────────────────
# The rig (matador_v03) carries two purpose-built bones for the sword: # The rig (matador_v03) carries two purpose-built bones: weapon_bone (right-hand
# weapon_bone — the grip pose in the right hand (drawn / fighting) # grip) and weapon_rest_bone (hip holster). The sword rides in the holster and is
# weapon_rest_bone — the holstered pose at the hip (sheathed / wandering) # carried to the hand in combat (see _carry_sword); each bone has its own DP-tunable
# The sword lives in the holster by default and is carried to the hand while the # local offset/rotation (sword_* / sword_rest_*) so the poses seat independently.
# matador is in a combat state (see _carry_sword). Each bone has its own
# DP-tunable local offset/rotation (sword_* for the hand, sword_rest_* for the
# holster) so the two poses can be seated independently without re-exporting.
func _setup_sword() -> void: func _setup_sword() -> void:
if not _skeleton: if not _skeleton:
return return
@@ -934,8 +1033,7 @@ func _setup_sword() -> void:
_spawn_sword() _spawn_sword()
# Instantiate a fresh sword into the holster, with its own blade collider. Called # Instantiate the matador's sword into the holster, with its own blade collider.
# at spawn and again after a throw, so a matador can keep drawing replacements.
func _spawn_sword() -> void: func _spawn_sword() -> void:
if is_instance_valid(_sword_node): if is_instance_valid(_sword_node):
return return
@@ -949,15 +1047,14 @@ func _spawn_sword() -> void:
_drawing = false _drawing = false
_apply_sword_grip() _apply_sword_grip()
# Blade collider spans the steel (hilt at local origin, blade running +Z out # Blade collider along +Z (hilt at origin), tipped from the capsule's default Y.
# to ~1.4 m). Only monitors while the sword is in hand (see _carry_sword). # Only monitors while the sword is in hand (see _carry_sword).
var blade_cap := CapsuleShape3D.new() var blade_cap := CapsuleShape3D.new()
blade_cap.radius = 0.18 blade_cap.radius = 0.18
blade_cap.height = 1.30 blade_cap.height = 1.30
var blade_cs := CollisionShape3D.new() var blade_cs := CollisionShape3D.new()
blade_cs.shape = blade_cap blade_cs.shape = blade_cap
blade_cs.position = Vector3(0.0, 0.0, 0.8) blade_cs.position = Vector3(0.0, 0.0, 0.8)
# CapsuleShape3D runs along Y by default; tip it onto +Z to follow the blade.
blade_cs.rotation_degrees = Vector3(90.0, 0.0, 0.0) blade_cs.rotation_degrees = Vector3(90.0, 0.0, 0.0)
_sword_blade_area = Area3D.new() _sword_blade_area = Area3D.new()
@@ -969,12 +1066,11 @@ func _spawn_sword() -> void:
_sword_blade_area.body_entered.connect(_on_blade_hit) _sword_blade_area.body_entered.connect(_on_blade_hit)
# The sword is carried by ACTION, not distance: drawn only while striking # The sword is MELEE-only: drawn while fighting up close (ATTACK) or guarding
# (ATTACK / THROW) and holstered while running, dodging, bracing or taunting. # (BRACE / SIDESTEP), holstered otherwise. Ranged throws use a spear instead
# THROW snaps the blade out (its arm cock-back covers the motion); everything # (see _spawn_spear_in_hand), so THROW keeps the sword holstered.
# else animates with the Draw_weapon clip — forward to draw, reversed to sheathe.
func _wants_sword_drawn() -> bool: func _wants_sword_drawn() -> bool:
return _state == State.ATTACK or _state == State.THROW \ return _state == State.ATTACK \
or _state == State.BRACE or _state == State.SIDESTEP or _state == State.BRACE or _state == State.SIDESTEP
@@ -1019,8 +1115,7 @@ func _begin_sheathe() -> void:
_anim_player.play(_ANIM_DRAW, -1.0, -_draw_speed(), true) _anim_player.play(_ANIM_DRAW, -1.0, -_draw_speed(), true)
# Progress a draw or sheathe; the blade reparents at the grab point (mirrored for # Progress a draw or sheathe; the blade reparents at the grab point (mirrored on the reverse).
# the reverse sheathe), and the transition ends when the clip's runtime elapses.
func _advance_draw(delta: float) -> void: func _advance_draw(delta: float) -> void:
if not _drawing and not _sheathing: if not _drawing and not _sheathing:
return return
@@ -1049,9 +1144,7 @@ func _draw_speed() -> float:
return maxf(DP.f("mat_draw_speed"), 0.1) return maxf(DP.f("mat_draw_speed"), 0.1)
# Move the sword between the hand grip and the hip holster. Reparenting follows # Move the sword between hand grip and hip holster, reapplying the local seating.
# whichever bone the BoneAttachment tracks; the local grip transform is reapplied
# so the seating is identical in both sockets.
func _carry_sword(in_hand: bool) -> void: func _carry_sword(in_hand: bool) -> void:
if not is_instance_valid(_sword_node): if not is_instance_valid(_sword_node):
return return
-69
View File
@@ -1,69 +0,0 @@
extends Node3D
signal matador_killed
signal all_defeated
const MATADOR := preload("res://Matador.tscn")
var _spawn_count: int = 1
var _alive: int = 0
var _resolved: bool = false
func _ready() -> void:
add_to_group(&"matador_spawn")
_spawn_count = maxi(1, int(DP.f("mat_spawn_count")))
# Deferred so it's safe from _ready and matches the previous spawn timing.
_spawn.call_deferred()
func _spawn() -> void:
# Bull and matadors start at opposite ends of a random diameter, so they always
# begin as far apart as the arena allows (edge-to-edge across the centre).
var arena_r := DP.f("arena_spawn_radius")
var bull_angle := randf() * TAU
_place_bull(bull_angle, arena_r)
for pos: Vector3 in _spread_positions(_spawn_count, bull_angle + PI, arena_r):
_spawn_at(pos)
func _place_bull(angle: float, arena_r: float) -> void:
var bull := get_tree().get_first_node_in_group(&"player") as Node3D
if not bull:
return
bull.global_position = Vector3(
cos(angle) * arena_r, bull.global_position.y, sin(angle) * arena_r
)
# Positions for the matadors, clustered on the far side (centred on `center_angle`)
# so every matador stays near the point diametrically opposite the bull.
func _spread_positions(count: int, center_angle: float, arena_r: float) -> Array[Vector3]:
var positions: Array[Vector3] = []
if count <= 1:
positions.append(Vector3(cos(center_angle) * arena_r, 1.0, sin(center_angle) * arena_r))
return positions
var arc := TAU * 0.33
for i: int in count:
var t := float(i) / float(count - 1) - 0.5 # -0.5 .. 0.5 across the arc
var angle := center_angle + t * arc
var radius := randf_range(arena_r * 0.7, arena_r)
positions.append(Vector3(cos(angle) * radius, 1.0, sin(angle) * radius))
return positions
func _spawn_at(pos: Vector3) -> void:
var m: Node3D = MATADOR.instantiate()
m.position = pos
get_parent().add_child(m)
m.killed.connect(_on_matador_killed)
_alive += 1
# Every matador down = the bull wins. No waves, no respawns — one clean arena.
func _on_matador_killed() -> void:
matador_killed.emit()
_alive -= 1
if _alive <= 0 and not _resolved:
_resolved = true
all_defeated.emit()
+25
View File
@@ -0,0 +1,25 @@
shader_type canvas_item;
// Blurred, darkened backdrop for the intro video. A COVER-scaled copy of the
// clip sits behind the sharp fitted clip so portrait/ultrawide screens fill
// edge-to-edge instead of showing dead bars.
uniform float blur_radius : hint_range(0.0, 8.0) = 3.0;
uniform float darken : hint_range(0.0, 1.0) = 0.35;
void fragment() {
vec2 px = TEXTURE_PIXEL_SIZE * blur_radius;
vec4 sum = vec4(0.0);
// 3x3 gaussian-ish tap, weights sum to 16.
sum += texture(TEXTURE, UV + vec2(-px.x, -px.y)) * 1.0;
sum += texture(TEXTURE, UV + vec2(0.0, -px.y)) * 2.0;
sum += texture(TEXTURE, UV + vec2(px.x, -px.y)) * 1.0;
sum += texture(TEXTURE, UV + vec2(-px.x, 0.0)) * 2.0;
sum += texture(TEXTURE, UV) * 4.0;
sum += texture(TEXTURE, UV + vec2(px.x, 0.0)) * 2.0;
sum += texture(TEXTURE, UV + vec2(-px.x, px.y)) * 1.0;
sum += texture(TEXTURE, UV + vec2(0.0, px.y)) * 2.0;
sum += texture(TEXTURE, UV + vec2(px.x, px.y)) * 1.0;
sum /= 16.0;
COLOR = vec4(sum.rgb * (1.0 - darken), sum.a);
}
+1
View File
@@ -0,0 +1 @@
uid://bmdtg6qlh7wri
+63
View File
@@ -0,0 +1,63 @@
extends CanvasLayer
## Universal "rotate to landscape" prompt (autoload `OrientationGuard`). The cross-device
## half of landscape enforcement: screen.orientation.lock (controls_manager.gd) handles
## Android, but iOS Safari has no lock API, and some browsers only lock in fullscreen — so
## wherever the device ends up portrait, we simply ask the player to turn it. No canvas
## rotation or input remapping (those break touch coordinates), so it works on every phone
## and browser cleanly. Only ever shows on touch-primary devices, only while portrait.
var _panel: ColorRect
func _ready() -> void:
layer = 128 # above HUD, touch UI, everything
process_mode = Node.PROCESS_MODE_ALWAYS
_panel = ColorRect.new()
_panel.color = Color(0.05, 0.03, 0.02, 0.97)
_panel.set_anchors_preset(Control.PRESET_FULL_RECT)
_panel.mouse_filter = Control.MOUSE_FILTER_STOP # swallow taps to the game behind it
_panel.visible = false
add_child(_panel)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
_panel.add_child(center)
var box := VBoxContainer.new()
box.alignment = BoxContainer.ALIGNMENT_CENTER
box.add_theme_constant_override("separation", 14)
center.add_child(box)
var title := Label.new()
title.text = "Rotate your device"
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 44)
title.add_theme_color_override("font_color", Color(1.0, 0.78, 0.22))
box.add_child(title)
var sub := Label.new()
sub.text = "Turn it sideways to play in landscape"
sub.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
sub.add_theme_font_size_override("font_size", 22)
sub.add_theme_color_override("font_color", Color(0.85, 0.85, 0.85))
box.add_child(sub)
get_viewport().size_changed.connect(_update)
_update.call_deferred()
func _update() -> void:
_panel.visible = _should_guard() and _is_portrait()
func _is_portrait() -> bool:
var s := get_viewport().get_visible_rect().size
return s.y > s.x
# Touch-primary devices only — never a desktop browser with a tall window (they can't rotate
# a monitor). Reuses the same conservative detection the touch UI uses.
func _should_guard() -> bool:
return Controls.use_touch_ui()
+1
View File
@@ -0,0 +1 @@
uid://cmh7kd85wi5gx
+304 -51
View File
@@ -14,7 +14,9 @@ const _ANIM_IDLE: StringName = &"Armature|IDLE"
const _ANIM_KICK: StringName = &"Armature|KICK" const _ANIM_KICK: StringName = &"Armature|KICK"
const _ANIM_SLAM: StringName = &"Armature|SLAM" const _ANIM_SLAM: StringName = &"Armature|SLAM"
const _ANIM_DASH: StringName = &"Armature|DASH" const _ANIM_DASH: StringName = &"Armature|DASH"
const _ANIM_WALK: StringName = &"Armature|WALK"
const _ANIM_ROLL: StringName = &"Armature|ROLL" const _ANIM_ROLL: StringName = &"Armature|ROLL"
const _ANIM_DEATH: StringName = &"Armature|DEATH"
@onready var camera_pivot: Node3D = $Camera3D @onready var camera_pivot: Node3D = $Camera3D
@onready var cube_guy: Node3D = $bull @onready var cube_guy: Node3D = $bull
@@ -27,16 +29,23 @@ var _pre_slide_vel_y: float = 0.0
var _slam_ring_mesh: ArrayMesh = null var _slam_ring_mesh: ArrayMesh = null
# Ability system — kick=0, slam=1, dash=2, roll=3 # Ability system — kick=0, dash=1, slam=2, roll=3
var ability_cd: Array[float] = [0.0, 0.0, 0.0, 0.0] var ability_cd: Array[float] = [0.0, 0.0, 0.0, 0.0]
var _ability_active: bool = false var _ability_active: bool = false
var _ability_timer: float = 0.0 var _ability_timer: float = 0.0
var _active_ability: int = -1 var _active_ability: int = -1
var _dash_dir: Vector3 = Vector3.ZERO var _dash_dir: Vector3 = Vector3.ZERO
var _dash_speed_cur: float = 0.0
var _roll_dir: Vector3 = Vector3.ZERO var _roll_dir: Vector3 = Vector3.ZERO
var _roll_ramp: float = 0.0 var _roll_ramp: float = 0.0
var _roll_spin: float = 0.0 var _roll_spin: float = 0.0
var _roll_target: Node3D = null
var _roll_chains_left: int = 0
var _roll_hit: Array[Node] = []
var _bull_anim: AnimationPlayer = null var _bull_anim: AnimationPlayer = null
# Locomotion animation state: true while the looping DASH clip is driving WASD
# movement, false while idling. Flipping it cross-fades between the two clips.
var _locomotion_moving: bool = false
var _slam_pending: bool = false var _slam_pending: bool = false
var _charge_trail_emitter: CPUParticles3D = null var _charge_trail_emitter: CPUParticles3D = null
@@ -49,6 +58,17 @@ var _cube_scale: Vector3 = Vector3.ONE
signal died(cause: String) signal died(cause: String)
var _dead: bool = false var _dead: bool = false
# HP system — the bull soaks several clean hits instead of dying to the first.
# Each landed blade drops one pip and starts an i-frame window (so one pass can't
# empty the bar); at 0 the run ends via `died`. The HUD listens on `health_changed`.
signal health_changed(current: int, max_hp: int)
# Fired on every landed hit (after i-frames pass) so the HUD can flash the damage
# vignette; `cause` mirrors take_sword_hit ("gored" / "thrown").
signal hit_taken(cause: String)
var _max_hp: int = 10
var _hp: int = 10
var _hit_iframes: float = 0.0
var _huff_player: AudioStreamPlayer = null var _huff_player: AudioStreamPlayer = null
var _crowd_player: AudioStreamPlayer = null var _crowd_player: AudioStreamPlayer = null
var _huff_timer: float = 0.0 var _huff_timer: float = 0.0
@@ -59,8 +79,20 @@ var _walk_dust_ramp: Gradient = null
var _charge_dust_ramp: Gradient = null var _charge_dust_ramp: Gradient = null
const RigidSkin = preload("res://rigid_skin.gd")
var _hit_flash_tween: Tween
func _ready() -> void: func _ready() -> void:
add_to_group(&"player") add_to_group(&"player")
# Mali/ANGLE mobile GPUs can't run Godot's Compatibility vertex-skinning (transform
# feedback) — skinned meshes render invisible on the web build. Rebuild the bull as
# non-skinned bone-attached pieces there; desktop keeps smooth GPU skinning.
if OS.has_feature("web"):
RigidSkin.convert_tree(self)
_max_hp = maxi(1, int(DP.f("bull_max_hp")))
_hp = _max_hp
_cube_scale = cube_guy.scale _cube_scale = cube_guy.scale
_setup_hoof_dust() _setup_hoof_dust()
_setup_legs() _setup_legs()
@@ -80,16 +112,16 @@ func _setup_legs() -> void:
func _setup_hoof_dust() -> void: func _setup_hoof_dust() -> void:
var sphere := _particle_sphere(0.10, 4, 2, _unshaded_material()) var sphere := _particle_sphere(0.07, 4, 2, _unshaded_material())
_walk_dust_ramp = Gradient.new() _walk_dust_ramp = Gradient.new()
_walk_dust_ramp.set_color(0, Color(0.80, 0.69, 0.46, 1.0)) _walk_dust_ramp.set_color(0, Color(0.94, 0.90, 0.83, 1.0))
_walk_dust_ramp.set_color(1, Color(0.80, 0.69, 0.46, 0.0)) _walk_dust_ramp.set_color(1, Color(0.94, 0.90, 0.83, 0.0))
# Light blue dust while charging # White-beige dust while running
_charge_dust_ramp = Gradient.new() _charge_dust_ramp = Gradient.new()
_charge_dust_ramp.set_color(0, Color(0.20, 0.40, 0.85, 1.0)) _charge_dust_ramp.set_color(0, Color(0.94, 0.90, 0.83, 1.0))
_charge_dust_ramp.set_color(1, Color(0.20, 0.40, 0.85, 0.0)) _charge_dust_ramp.set_color(1, Color(0.94, 0.90, 0.83, 0.0))
var scale_curve := Curve.new() var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 0.2)) scale_curve.add_point(Vector2(0.0, 0.2))
@@ -193,6 +225,7 @@ func _setup_audio() -> void:
_huff_player = AudioStreamPlayer.new() _huff_player = AudioStreamPlayer.new()
_huff_player.stream = load("res://sounds/bull_huff.ogg") _huff_player.stream = load("res://sounds/bull_huff.ogg")
_huff_player.volume_db = 0.0 _huff_player.volume_db = 0.0
_huff_player.bus = &"Effects"
add_child(_huff_player) add_child(_huff_player)
if ResourceLoader.exists("res://sounds/crowd_ambient.ogg"): if ResourceLoader.exists("res://sounds/crowd_ambient.ogg"):
_crowd_player = AudioStreamPlayer.new() _crowd_player = AudioStreamPlayer.new()
@@ -202,6 +235,7 @@ func _setup_audio() -> void:
_crowd_player.stream = s _crowd_player.stream = s
_crowd_player.volume_db = -14.0 _crowd_player.volume_db = -14.0
_crowd_player.autoplay = true _crowd_player.autoplay = true
_crowd_player.bus = &"Ambient"
add_child(_crowd_player) add_child(_crowd_player)
@@ -231,8 +265,8 @@ func _set_dust_state(new_state: DustState) -> void:
p.initial_velocity_min = DP.f("charge_vel_min") p.initial_velocity_min = DP.f("charge_vel_min")
p.initial_velocity_max = DP.f("charge_vel_max") p.initial_velocity_max = DP.f("charge_vel_max")
p.emitting = true p.emitting = true
if _charge_trail_emitter: # Blue energy trail belongs to the roll/dash ability only, not to fast WASD
_charge_trail_emitter.emitting = _dust_state == DustState.CHARGE # running — driven explicitly in the roll loop / _end_roll, not by dust state.
func _on_dp_changed(_key: String, _val: Variant) -> void: func _on_dp_changed(_key: String, _val: Variant) -> void:
@@ -258,8 +292,9 @@ func _find_anim_player(node: Node) -> AnimationPlayer:
func _setup_bull_idle() -> void: func _setup_bull_idle() -> void:
if _bull_anim == null: if _bull_anim == null:
return return
# IDLE and ROLL both loop as continuous states; the ability clips are one-shot. # IDLE, DASH and ROLL loop as continuous states (DASH doubles as the WASD
for clip: StringName in [_ANIM_IDLE, _ANIM_ROLL]: # locomotion cycle); the remaining ability clips are one-shot.
for clip: StringName in [_ANIM_IDLE, _ANIM_DASH, _ANIM_ROLL]:
if _bull_anim.has_animation(clip): if _bull_anim.has_animation(clip):
_bull_anim.get_animation(clip).loop_mode = Animation.LOOP_LINEAR _bull_anim.get_animation(clip).loop_mode = Animation.LOOP_LINEAR
_play_idle() _play_idle()
@@ -268,32 +303,66 @@ func _setup_bull_idle() -> void:
# Return to the looping idle (and undo any ability speed-scale). Cross-fades so a # Return to the looping idle (and undo any ability speed-scale). Cross-fades so a
# finishing ability clip eases back to idle instead of snapping. # finishing ability clip eases back to idle instead of snapping.
func _play_idle() -> void: func _play_idle() -> void:
_locomotion_moving = false
if _bull_anim == null: if _bull_anim == null:
return return
_bull_anim.speed_scale = 1.0 _bull_anim.speed_scale = 1.0
if _bull_anim.has_animation(_ANIM_IDLE): if _bull_anim.has_animation(_ANIM_IDLE):
_bull_anim.play(_ANIM_IDLE, 0.2) _bull_anim.play(_ANIM_IDLE, 0.05)
# Drive the WASD locomotion clip: the looping DASH doubles as the run cycle while a
# direction is held, easing back to idle when the bull coasts to a stop. Cross-fades
# on each transition so idle↔run (and steering into it after an ability) blend rather
# than snap. Abilities own the animation while active, so this yields to them.
func _update_locomotion_anim(moving: bool) -> void:
if _ability_active or _bull_anim == null or moving == _locomotion_moving:
return
_locomotion_moving = moving
if moving:
if _bull_anim.has_animation(_ANIM_WALK):
_bull_anim.speed_scale = 2.0
_bull_anim.play(_ANIM_WALK, 1)
var anim = _bull_anim.get_animation(_ANIM_WALK)
anim.loop_mode = Animation.LOOP_LINEAR
else:
_play_idle()
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if _dead:
return
# Abilities can be chained: a new one interrupts whatever is currently # Abilities can be chained: a new one interrupts whatever is currently
# active, gated only by each ability's own cooldown. # active, gated only by each ability's own cooldown.
if event.is_action_pressed(&"ability_kick") and ability_cd[0] <= 0.0: if event.is_action_pressed(&"ability_kick"):
_activate_kick() try_activate_ability(0)
elif event.is_action_pressed(&"ability_slam") and ability_cd[1] <= 0.0: elif event.is_action_pressed(&"ability_dash"):
_activate_slam() try_activate_ability(1)
elif event.is_action_pressed(&"ability_dash") and ability_cd[2] <= 0.0: elif event.is_action_pressed(&"ability_slam"):
_activate_dash() try_activate_ability(2)
elif event.is_action_pressed(&"ability_roll") and ability_cd[3] <= 0.0: elif event.is_action_pressed(&"ability_roll"):
_activate_roll() try_activate_ability(3)
# Public ability entry point shared by keyboard/gamepad input and the on-screen
# touch buttons. Slot order matches ability_cd — kick=0, dash=1, slam=2, roll=3.
# No-ops if the bull is dead or the slot is still cooling down.
func try_activate_ability(slot: int) -> void:
if _dead or slot < 0 or slot >= ability_cd.size() or ability_cd[slot] > 0.0:
return
match slot:
0: _activate_kick()
1: _activate_dash()
2: _activate_slam()
3: _activate_roll()
func ability_cooldown_fraction(slot: int) -> float: func ability_cooldown_fraction(slot: int) -> float:
var maxcd := 0.0 var maxcd := 0.0
match slot: match slot:
0: maxcd = DP.f("kick_cooldown") 0: maxcd = DP.f("kick_cooldown")
1: maxcd = DP.f("slam_cooldown") 1: maxcd = DP.f("dash_cooldown")
2: maxcd = DP.f("dash_cooldown") 2: maxcd = DP.f("slam_cooldown")
3: maxcd = DP.f("roll_cooldown") 3: maxcd = DP.f("roll_cooldown")
return ability_cd[slot] / maxcd if maxcd > 0.0 else 0.0 return ability_cd[slot] / maxcd if maxcd > 0.0 else 0.0
@@ -306,9 +375,9 @@ func _activate_kick() -> void:
ability_cd[0] = DP.f("kick_cooldown") ability_cd[0] = DP.f("kick_cooldown")
_ability_active = true _ability_active = true
_active_ability = 0 _active_ability = 0
_ability_timer = 0.6 _ability_timer = 0.3
if _bull_anim: if _bull_anim:
_bull_anim.speed_scale = 1.0 _bull_anim.speed_scale = 2.0
if _bull_anim and _bull_anim.has_animation(_ANIM_KICK): if _bull_anim and _bull_anim.has_animation(_ANIM_KICK):
_bull_anim.play(_ANIM_KICK) _bull_anim.play(_ANIM_KICK)
_ability_timer = _bull_anim.get_animation(_ANIM_KICK).length _ability_timer = _bull_anim.get_animation(_ANIM_KICK).length
@@ -321,11 +390,12 @@ func _activate_kick() -> void:
func _activate_slam() -> void: func _activate_slam() -> void:
ability_cd[1] = DP.f("slam_cooldown") ability_cd[2] = DP.f("slam_cooldown")
_ability_active = true _ability_active = true
_active_ability = 1 _active_ability = 2
_legs.set(&"tail_ragdoll", true) _legs.set(&"tail_ragdoll", true)
_slam_pending = true _slam_pending = true
SpearProjectile.shed_all.call_deferred(self) # the leap shakes off any stuck spears
var jump_vel := DP.f("slam_jump_velocity") var jump_vel := DP.f("slam_jump_velocity")
velocity.y = jump_vel velocity.y = jump_vel
# Airtime with an accelerated descent: rise (v/g) + fall (v / (g·√mult)). # Airtime with an accelerated descent: rise (v/g) + fall (v / (g·√mult)).
@@ -339,9 +409,9 @@ func _activate_slam() -> void:
func _activate_dash() -> void: func _activate_dash() -> void:
ability_cd[2] = DP.f("dash_cooldown") ability_cd[1] = DP.f("dash_cooldown")
_ability_active = true _ability_active = true
_active_ability = 2 _active_ability = 1
_ability_timer = 0.45 _ability_timer = 0.45
if _bull_anim: if _bull_anim:
_bull_anim.speed_scale = 1.0 _bull_anim.speed_scale = 1.0
@@ -350,8 +420,9 @@ func _activate_dash() -> void:
_ability_timer = _bull_anim.get_animation(_ANIM_DASH).length _ability_timer = _bull_anim.get_animation(_ANIM_DASH).length
var raw := cube_guy.global_transform.basis.z var raw := cube_guy.global_transform.basis.z
_dash_dir = Vector3(raw.x, 0.0, raw.z).normalized() _dash_dir = Vector3(raw.x, 0.0, raw.z).normalized()
velocity.x = _dash_dir.x * DP.f("dash_speed") _dash_speed_cur = DP.f("dash_speed")
velocity.z = _dash_dir.z * DP.f("dash_speed") velocity.x = _dash_dir.x * _dash_speed_cur
velocity.z = _dash_dir.z * _dash_speed_cur
_spawn_dash_burst() _spawn_dash_burst()
_legs.set(&"charge_pitch_target", DP.f("charge_head_pitch")) _legs.set(&"charge_pitch_target", DP.f("charge_head_pitch"))
if _huff_player: if _huff_player:
@@ -366,10 +437,16 @@ func _activate_roll() -> void:
_ability_timer = DP.f("roll_duration") _ability_timer = DP.f("roll_duration")
_roll_ramp = 0.0 _roll_ramp = 0.0
_roll_spin = 0.0 _roll_spin = 0.0
# Curl into the ball along the current heading (or facing when standing still) _roll_chains_left = int(DP.f("roll_chain_count"))
# and launch at the base speed — it ramps up from here the longer you roll. _roll_hit.clear()
# Lock onto the closest matador and curl into the ball aimed at it; if the arena
# is empty, launch along the current heading (or facing when standing still).
_roll_target = _roll_pick_target()
var flat := Vector3(velocity.x, 0.0, velocity.z) var flat := Vector3(velocity.x, 0.0, velocity.z)
if flat.length() > 1.0: if _roll_target != null:
var to_t := _roll_target.global_position - global_position
_roll_dir = Vector3(to_t.x, 0.0, to_t.z).normalized()
elif flat.length() > 1.0:
_roll_dir = flat.normalized() _roll_dir = flat.normalized()
else: else:
var f := cube_guy.global_transform.basis.z var f := cube_guy.global_transform.basis.z
@@ -392,14 +469,20 @@ func _activate_roll() -> void:
_huff_player.play() _huff_player.play()
# One rolling frame (Rammus Powerball): steer with momentum, accelerate the longer # One rolling frame: home on the current target matador, accelerate the longer the
# the roll lasts, move (walls just slide), then pop the ball on the first matador we # roll lasts, move (walls just slide), then pop the ball on contact and chain to the
# ram. Fully replaces the normal locomotion path while active. # next closest. Fully replaces the normal locomotion path while active. `steer` is
func _tick_roll(delta: float, steer: Vector3) -> void: # ignored — the roll auto-targets — but kept so the caller stays uniform.
var steer_flat := Vector3(steer.x, 0.0, steer.z) func _tick_roll(delta: float, _steer: Vector3) -> void:
if steer_flat.length() > 0.1: # Reacquire if the target ragdolled or was freed before we reached it.
var t := clampf(DP.f("roll_turn") * delta, 0.0, 1.0) if not _roll_target_valid():
_roll_dir = _roll_dir.slerp(steer_flat.normalized(), t).normalized() _roll_target = _roll_pick_target()
if _roll_target != null:
var to_t := _roll_target.global_position - global_position
var flat := Vector3(to_t.x, 0.0, to_t.z)
if flat.length() > 0.1:
var t := clampf(DP.f("roll_turn") * delta, 0.0, 1.0)
_roll_dir = _roll_dir.slerp(flat.normalized(), t).normalized()
# Speed builds over time: base → max across roll_rampup_time, so a long roll # Speed builds over time: base → max across roll_rampup_time, so a long roll
# winds up into a runaway boulder while a quick tap barely gets going. # winds up into a runaway boulder while a quick tap barely gets going.
@@ -423,6 +506,7 @@ func _tick_roll(delta: float, steer: Vector3) -> void:
_set_dust_state(DustState.CHARGE) _set_dust_state(DustState.CHARGE)
if _charge_trail_emitter: if _charge_trail_emitter:
_charge_trail_emitter.emitting = true
_charge_trail_emitter.direction = (-_roll_dir + Vector3(0.0, 0.25, 0.0)).normalized() _charge_trail_emitter.direction = (-_roll_dir + Vector3(0.0, 0.25, 0.0)).normalized()
move_and_slide() move_and_slide()
@@ -431,25 +515,64 @@ func _tick_roll(delta: float, steer: Vector3) -> void:
# Pop the ball on ramming a matador: launch every matador in the hit radius up and # Pop the ball on ramming a matador: launch every matador in the hit radius up and
# outward, then end the roll (Rammus stops when Powerball connects). Returns true if # outward, then either CHAIN to the next closest (if chains remain and a target is
# it popped so the caller can stop touching roll state this frame. # left) or end the roll. Returns true if it popped so the caller can stop touching
# roll state this frame.
func _roll_try_pop(speed: float) -> bool: func _roll_try_pop(speed: float) -> bool:
var radius := DP.f("roll_hit_radius") var radius := DP.f("roll_hit_radius")
for mat: Node in get_tree().get_nodes_in_group(&"matador"): for mat: Node in get_tree().get_nodes_in_group(&"matador"):
if _roll_hit.has(mat) or not _mat_active(mat):
continue
var to_mat: Vector3 = (mat as Node3D).global_position - global_position var to_mat: Vector3 = (mat as Node3D).global_position - global_position
to_mat.y = 0.0 to_mat.y = 0.0
if to_mat.length() > radius: if to_mat.length() > radius:
continue continue
# Record everyone this pop reaches so the chain never re-picks a downed body.
for m: Node in get_tree().get_nodes_in_group(&"matador"):
var d: Vector3 = (m as Node3D).global_position - global_position
d.y = 0.0
if d.length() <= radius and not _roll_hit.has(m):
_roll_hit.append(m)
_hit_matadors_radius(radius, DP.f("roll_hit_strength"), DP.f("roll_knockup")) _hit_matadors_radius(radius, DP.f("roll_hit_strength"), DP.f("roll_knockup"))
camera_pivot.call(&"trigger_hit", clampf(speed / 90.0, 0.35, 1.0)) camera_pivot.call(&"trigger_hit", clampf(speed / 90.0, 0.35, 1.0))
if _huff_player: if _huff_player:
_huff_player.pitch_scale = randf_range(1.05, 1.35) _huff_player.pitch_scale = randf_range(1.05, 1.35)
_huff_player.play() _huff_player.play()
if _roll_chains_left > 0:
var next := _roll_pick_target()
if next != null:
_roll_chains_left -= 1
_roll_target = next
return true # keep rolling — the ball carries on to the next target
_end_roll() _end_roll()
return true return true
return false return false
# Closest active matador we haven't already popped this roll, or null if none left.
func _roll_pick_target() -> Node3D:
var best: Node3D = null
var best_d := INF
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
if _roll_hit.has(mat) or not _mat_active(mat):
continue
var d: float = (mat as Node3D).global_position.distance_squared_to(global_position)
if d < best_d:
best_d = d
best = mat as Node3D
return best
func _roll_target_valid() -> bool:
return is_instance_valid(_roll_target) and _mat_active(_roll_target)
# A matador still in play — valid, not ragdolled/consumed. Ragdolled bodies report
# is_active() == false, so a popped target is skipped on the next chain search.
func _mat_active(mat: Node) -> bool:
return is_instance_valid(mat) and mat.has_method(&"is_active") and mat.call(&"is_active")
# End the Powerball early: brake hard (the ball "pops" and unrolls) and drop back to # End the Powerball early: brake hard (the ball "pops" and unrolls) and drop back to
# the normal locomotion path next frame. # the normal locomotion path next frame.
func _end_roll() -> void: func _end_roll() -> void:
@@ -457,11 +580,16 @@ func _end_roll() -> void:
_active_ability = -1 _active_ability = -1
_ability_timer = 0.0 _ability_timer = 0.0
_roll_ramp = 0.0 _roll_ramp = 0.0
_roll_target = null
_roll_chains_left = 0
_roll_hit.clear()
_legs.set(&"charge_pitch_target", 0.0) _legs.set(&"charge_pitch_target", 0.0)
_legs.set(&"tail_ragdoll", false) _legs.set(&"tail_ragdoll", false)
_legs.set(&"external_rotation", false) _legs.set(&"external_rotation", false)
if _roll_spark_emitter: if _roll_spark_emitter:
_roll_spark_emitter.emitting = false _roll_spark_emitter.emitting = false
if _charge_trail_emitter:
_charge_trail_emitter.emitting = false
velocity.x *= 0.15 velocity.x *= 0.15
velocity.z *= 0.15 velocity.z *= 0.15
_play_idle() _play_idle()
@@ -805,6 +933,19 @@ func _make_slam_ring_mesh() -> ArrayMesh:
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
# ── Dead bull ─────────────────────────────────────────────────────────────
# The run is over the instant HP hits zero, but the lose screen holds off a
# beat before it appears (see HUD._show_game_over). Freeze the carcass through
# that delay — no input, no abilities, just coast to a stop under gravity — so a
# dead bull can't keep charging around while the screen is pending.
if _dead:
velocity.x = move_toward(velocity.x, 0.0, 40.0 * delta)
velocity.z = move_toward(velocity.z, 0.0, 40.0 * delta)
if not is_on_floor():
velocity += get_gravity() * delta
move_and_slide()
return
# ── Gravity ─────────────────────────────────────────────────────────────── # ── Gravity ───────────────────────────────────────────────────────────────
if not is_on_floor(): if not is_on_floor():
var gravity := get_gravity() var gravity := get_gravity()
@@ -824,6 +965,7 @@ func _physics_process(delta: float) -> void:
var on_floor := is_on_floor() var on_floor := is_on_floor()
# ── Ability cooldowns ──────────────────────────────────────────────────────── # ── Ability cooldowns ────────────────────────────────────────────────────────
_hit_iframes = maxf(0.0, _hit_iframes - delta)
for i: int in 4: for i: int in 4:
ability_cd[i] = maxf(0.0, ability_cd[i] - delta) ability_cd[i] = maxf(0.0, ability_cd[i] - delta)
if _ability_active: if _ability_active:
@@ -856,7 +998,13 @@ func _physics_process(delta: float) -> void:
# input is released. # input is released.
var flat_vel := Vector3(velocity.x, 0.0, velocity.z) var flat_vel := Vector3(velocity.x, 0.0, velocity.z)
var max_speed := DP.f("max_speed") var max_speed := DP.f("max_speed")
if direction.length_squared() > 0.01: if _active_ability == 1 and _ability_active:
# Dash: leave the barrel fast like a bullet, then bleed exponentially back
# down to cruise speed (fastest drop right at the start), so it settles into
# normal running by the time the clip ends instead of holding top speed.
_dash_speed_cur = lerpf(_dash_speed_cur, max_speed, 1.0 - exp(-DP.f("dash_decel") * delta))
flat_vel = _dash_dir * _dash_speed_cur
elif direction.length_squared() > 0.01:
var cap := maxf(max_speed, flat_vel.length()) var cap := maxf(max_speed, flat_vel.length())
flat_vel = flat_vel.move_toward(direction * cap, DP.f("move_accel") * delta) flat_vel = flat_vel.move_toward(direction * cap, DP.f("move_accel") * delta)
else: else:
@@ -871,6 +1019,9 @@ func _physics_process(delta: float) -> void:
cube_guy.rotation.y = lerp_angle( cube_guy.rotation.y = lerp_angle(
cube_guy.rotation.y, target_angle, delta * DP.f("visual_turn_speed")) cube_guy.rotation.y, target_angle, delta * DP.f("visual_turn_speed"))
# Looping DASH clip while genuinely moving on the ground, idle otherwise.
_update_locomotion_anim(on_floor and flat_speed > 1.0)
_pre_slide_vel_y = velocity.y _pre_slide_vel_y = velocity.y
var pre_wall_vel := Vector3(velocity.x, 0.0, velocity.z) var pre_wall_vel := Vector3(velocity.x, 0.0, velocity.z)
move_and_slide() move_and_slide()
@@ -925,11 +1076,113 @@ func _physics_process(delta: float) -> void:
# ── Hoof sounds ─────────────────────────────────────────────────────────── # ── Hoof sounds ───────────────────────────────────────────────────────────
# A single clean hit — thrown or swung blade — ends the run. `cause` ("gored" / func get_hp() -> int:
# "thrown") tags how it happened for the death notice. Latches so the lose screen is return _hp
# raised exactly once even if several blades land on the same frame.
func take_sword_hit(cause: String = "") -> void:
if _dead: func get_max_hp() -> int:
return _max_hp
# A clean hit — thrown or swung blade — costs the bull one HP pip; the run ends only
# when the last pip is gone. `cause` ("gored" / "thrown") tags how it happened for the
# death notice. I-frames absorb a flurry so several blades in one pass drop one pip,
# not the whole bar; `_dead` latches so the lose screen is raised exactly once.
func take_sword_hit(cause: String = "", hit_pos: Vector3 = Vector3.ZERO) -> void:
if _dead or _hit_iframes > 0.0:
return return
_dead = true _hp -= 1
died.emit(cause) _hit_flash()
_hit_iframes = DP.f("bull_hit_iframes")
health_changed.emit(_hp, _max_hp)
hit_taken.emit(cause)
camera_pivot.call(&"trigger_hit", 0.7)
# A spear gores a spot — blood spurts from the entry wound (where it struck the
# bull). Melee gores pass no position and skip the spurt; gore can be turned off.
if cause == "thrown" and Settings.gore:
_spawn_blood(hit_pos if hit_pos != Vector3.ZERO else global_position)
if _huff_player:
_huff_player.pitch_scale = randf_range(0.7, 0.9)
_huff_player.play()
if _hp <= 0:
if _bull_anim.current_animation != _ANIM_DEATH:
_bull_anim.speed_scale = 0.8
_bull_anim.play(_ANIM_DEATH)
_dead = true
died.emit(cause)
# Blood spurt from a spear wound: a short-lived spray of dark-red gobs flung out of the
# entry point, biased outward from the bull's centre (so it reads as bursting from the
# hide) with a touch of lift before gravity drags it down.
func _spawn_blood(hit_pos: Vector3) -> void:
var out := hit_pos - global_position
out.y = 0.0
out = out.normalized() if out.length() > 0.05 else Vector3(0.0, 0.0, 1.0)
var dir := (out + Vector3.UP * 0.8).normalized()
var sphere := _particle_sphere(0.05, 4, 2, _unshaded_material())
var ramp := Gradient.new()
ramp.set_color(0, Color(0.55, 0.02, 0.02, 1.0))
ramp.set_color(1, Color(0.28, 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.7))
scale_curve.add_point(Vector2(1.0, 0.0))
var p := CPUParticles3D.new()
p.one_shot = true
p.explosiveness = 0.9
p.amount = 34
p.lifetime = 0.7
p.randomness = 0.5
p.local_coords = false
p.direction = dir
p.spread = 42.0
p.gravity = Vector3(0.0, -14.0, 0.0)
p.initial_velocity_min = 3.5
p.initial_velocity_max = 8.0
p.scale_amount_min = 0.5
p.scale_amount_max = 1.6
p.mesh = sphere
p.color_ramp = ramp
p.scale_amount_curve = scale_curve
get_parent().add_child(p)
p.global_position = hit_pos
p.restart()
_free_after(p, 1.5)
#hit flashing
func _hit_flash() -> void:
var meshes := find_children("*", "MeshInstance3D", true, false)
for node in meshes:
var mesh := node as MeshInstance3D
if mesh:
mesh.material_overlay = _make_hit_overlay()
if _hit_flash_tween:
_hit_flash_tween.kill()
_hit_flash_tween = create_tween()
_hit_flash_tween.tween_interval(0.08)
_hit_flash_tween.tween_callback(_clear_hit_flash)
func _make_hit_overlay() -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.albedo_color = Color(1.0, 0.03, 0.03, 1.0)
return mat
func _clear_hit_flash() -> void:
var meshes := find_children("*", "MeshInstance3D", true, false)
for node in meshes:
var mesh := node as MeshInstance3D
if mesh:
mesh.material_overlay = null

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