Compare commits
5 Commits
3defc9da95
..
0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 56593c58cf | |||
| ce6f3d7075 | |||
| a74182255b | |||
| ebf5046461 | |||
| 271de1b045 |
@@ -2,6 +2,7 @@ extends Node3D
|
|||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
add_to_group(&"public") # excluded from debug overlays — see publikum_controller.gd
|
||||||
var anim := get_node_or_null(^"AnimationPlayer") as AnimationPlayer
|
var anim := get_node_or_null(^"AnimationPlayer") as AnimationPlayer
|
||||||
if anim:
|
if anim:
|
||||||
anim.play(&"Juubeldab")
|
anim.play(&"Juubeldab")
|
||||||
|
|||||||
|
After Width: | Height: | Size: 16 KiB |
@@ -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
|
||||||
|
After Width: | Height: | Size: 115 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://d166u18p3wqiy"
|
||||||
|
path="res://.godot/imported/crowd_clap_sheet.png-a110e7d663af8fcbe9ebf5de44188c85.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://Assets/crowd_clap_sheet.png"
|
||||||
|
dest_files=["res://.godot/imported/crowd_clap_sheet.png-a110e7d663af8fcbe9ebf5de44188c85.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
|
||||||
|
After Width: | Height: | Size: 12 MiB |
|
After Width: | Height: | Size: 12 MiB |
@@ -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
|
||||||
@@ -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" 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,55 @@ 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
|
||||||
|
stretch_mode = 2
|
||||||
|
|
||||||
[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 +106,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 +138,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 +161,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 +184,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 +198,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,31 +216,32 @@ 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 = 4
|
theme_override_constants/outline_size = 5
|
||||||
|
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
|
||||||
@@ -205,17 +250,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
|
||||||
@@ -223,25 +268,36 @@ theme_override_font_sizes/font_size = 44
|
|||||||
text = "Credits"
|
text = "Credits"
|
||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
|
|
||||||
[node name="Names" type="Label" 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/default_color = Color(0.93, 0.88, 0.72, 1)
|
||||||
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 = 4
|
theme_override_constants/line_separation = 6
|
||||||
theme_override_font_sizes/font_size = 24
|
theme_override_constants/outline_size = 5
|
||||||
text = "Bullosseum
|
theme_override_font_sizes/normal_font_size = 26
|
||||||
|
theme_override_font_sizes/bold_font_size = 30
|
||||||
|
bbcode_enabled = true
|
||||||
|
text = "[center][font_size=32][color=#d9a833]Bullosseum[/color][/font_size]
|
||||||
|
|
||||||
A game by Siim & Richard"
|
[b][color=#d9a833]Siim Raud[/color][/b] [url=https://instagram.com/siimpressionist]@siimpressionist[/url]
|
||||||
horizontal_alignment = 1
|
[font_size=20]Artist[/font_size]
|
||||||
vertical_alignment = 1
|
|
||||||
|
|
||||||
[node name="BackButton" type="Button" parent="CreditsPanel/VBox"]
|
[b][color=#d9a833]Richard Aasa[/color][/b] [url=https://instagram.com/aasarichard]@aasarichard[/url]
|
||||||
|
[font_size=20]Programmer[/font_size]
|
||||||
|
|
||||||
|
[b][color=#d9a833]Kadi Rebane[/color][/b] [url=https://x.com/_barkseven]@_barkseven[/url]
|
||||||
|
[font_size=20]Arena asset[/font_size][/center]"
|
||||||
|
fit_content = true
|
||||||
|
scroll_active = false
|
||||||
|
|
||||||
|
[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
|
||||||
|
|||||||
@@ -92,6 +92,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
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,18 @@ func _input(event: InputEvent) -> void:
|
|||||||
if not _is_open:
|
if not _is_open:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Ctrl+C clears the input line, Ctrl+L clears the log — familiar shell reflexes.
|
||||||
|
if ke.ctrl_pressed:
|
||||||
|
match ke.keycode:
|
||||||
|
KEY_C:
|
||||||
|
_clear_input()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
return
|
||||||
|
KEY_L:
|
||||||
|
_log.clear()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
|
return
|
||||||
|
|
||||||
match ke.keycode:
|
match ke.keycode:
|
||||||
KEY_TAB:
|
KEY_TAB:
|
||||||
_accept_completion()
|
_accept_completion()
|
||||||
@@ -187,6 +199,20 @@ func _input(event: InputEvent) -> void:
|
|||||||
get_viewport().set_input_as_handled()
|
get_viewport().set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
|
# Focus can be stolen a frame or more after we re-grab it (submit, arrows, the tree
|
||||||
|
# unpausing), so a one-shot deferred grab loses the race. Enforce it every frame
|
||||||
|
# while the console is open — cheap, and it never lets the command line go dark.
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
if _is_open and not _input_field.has_focus():
|
||||||
|
# Diagnostic: name whatever currently holds focus before we snatch it back, so
|
||||||
|
# a persistent thief (a HUD button, a menu control) shows up in the log instead
|
||||||
|
# of us guessing. Remove once the culprit is identified.
|
||||||
|
var owner: Control = get_viewport().gui_get_focus_owner()
|
||||||
|
var who: String = String(owner.name) if owner != null else "<nothing>"
|
||||||
|
_log_warn("focus stolen by %s — reclaiming" % who)
|
||||||
|
_input_field.grab_focus()
|
||||||
|
|
||||||
|
|
||||||
func _toggle() -> void:
|
func _toggle() -> void:
|
||||||
_is_open = not _is_open
|
_is_open = not _is_open
|
||||||
get_tree().paused = _is_open
|
get_tree().paused = _is_open
|
||||||
@@ -381,6 +407,15 @@ func _history_step(dir: int) -> void:
|
|||||||
_set_input(_pending_line if _history_idx == -1 else _history[_history_idx])
|
_set_input(_pending_line if _history_idx == -1 else _history[_history_idx])
|
||||||
|
|
||||||
|
|
||||||
|
# Wipe the live line (Ctrl+C) without submitting — also drops any suggestion popup
|
||||||
|
# and resets history browsing so the next ↑ starts fresh.
|
||||||
|
func _clear_input() -> void:
|
||||||
|
_input_field.clear()
|
||||||
|
_history_idx = -1
|
||||||
|
_pending_line = ""
|
||||||
|
_clear_suggestions()
|
||||||
|
|
||||||
|
|
||||||
func _set_input(text: String) -> void:
|
func _set_input(text: String) -> void:
|
||||||
_input_field.text = text
|
_input_field.text = text
|
||||||
_input_field.caret_column = text.length()
|
_input_field.caret_column = text.length()
|
||||||
@@ -400,9 +435,6 @@ func _on_submitted(raw: String) -> void:
|
|||||||
_input_field.clear()
|
_input_field.clear()
|
||||||
_clear_suggestions()
|
_clear_suggestions()
|
||||||
_execute(raw)
|
_execute(raw)
|
||||||
# Defer: the LineEdit finishes handling the Enter keypress after this signal
|
|
||||||
# returns, so a synchronous grab_focus() gets clobbered. Re-grab next idle.
|
|
||||||
_input_field.grab_focus.call_deferred()
|
|
||||||
|
|
||||||
|
|
||||||
func _execute(cmd: String) -> void:
|
func _execute(cmd: String) -> void:
|
||||||
|
|||||||
@@ -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,21 @@ func _ready() -> void:
|
|||||||
load_saved()
|
load_saved()
|
||||||
|
|
||||||
|
|
||||||
|
## Whether to drive the game with the on-screen touch UI (joystick + buttons)
|
||||||
|
## instead of keyboard hints. DisplayServer.is_touchscreen_available() can't be
|
||||||
|
## used here: `emulate_touch_from_mouse` (on for desktop testing) makes it report
|
||||||
|
## true on every desktop. Gate on the actual runtime platform instead — real
|
||||||
|
## mobile/native and mobile web browsers — plus the DP force flag for testing.
|
||||||
|
func use_touch_ui() -> bool:
|
||||||
|
if DP.b("force_touch_controls"):
|
||||||
|
return true
|
||||||
|
return (
|
||||||
|
OS.has_feature("mobile")
|
||||||
|
or OS.has_feature("web_android")
|
||||||
|
or OS.has_feature("web_ios")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
## 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):
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
shader_type spatial;
|
||||||
|
// Flat crowd sprite. Each instance is oriented at build time to face the arena centre
|
||||||
|
// (see crowd_billboards.gd) — NOT the camera — so spectators on different segments of
|
||||||
|
// the ring face inward toward the action, like a real stand. Unshaded (the sheet was
|
||||||
|
// baked with lighting), alpha-tested (no transparency sorting across hundreds of
|
||||||
|
// quads), nearest-filtered for a crisp PS1 look that matches the ps1_filter effect.
|
||||||
|
render_mode unshaded, cull_disabled, shadows_disabled, depth_draw_opaque;
|
||||||
|
|
||||||
|
uniform sampler2D sheet : source_color, filter_nearest;
|
||||||
|
uniform int hframes = 8;
|
||||||
|
uniform float fps = 10.0;
|
||||||
|
uniform float alpha_clip = 0.4;
|
||||||
|
|
||||||
|
varying flat float v_frame;
|
||||||
|
varying flat vec3 v_tint;
|
||||||
|
|
||||||
|
void vertex() {
|
||||||
|
// Per-instance clap phase (0..1) and tint are packed into the MultiMesh custom data.
|
||||||
|
// The instance transform already carries the inward facing, so no billboard maths.
|
||||||
|
float phase = INSTANCE_CUSTOM.x;
|
||||||
|
v_tint = INSTANCE_CUSTOM.yzw;
|
||||||
|
v_frame = floor(mod(TIME * fps + phase * float(hframes), float(hframes)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
// Sheet is two rows: top = front, bottom = back. Show the back when the camera is
|
||||||
|
// behind the spectator (they're facing into the arena, away from us).
|
||||||
|
float u = (UV.x + v_frame) / float(hframes);
|
||||||
|
float v = UV.y * 0.5 + (FRONT_FACING ? 0.0 : 0.5);
|
||||||
|
vec4 c = texture(sheet, vec2(u, v));
|
||||||
|
if (c.a < alpha_clip) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
ALBEDO = c.rgb * v_tint;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://hkdivctavovb
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
extends MultiMeshInstance3D
|
||||||
|
## A whole stand of cheering spectators rendered as camera-facing billboards in a
|
||||||
|
## single draw call — the performant replacement for dozens of skinned crowd skeletons.
|
||||||
|
## The sprite sheet is baked from the existing crowd figure by tools/bake_crowd_sheet.gd.
|
||||||
|
##
|
||||||
|
## Placement mirrors an existing crowd node (default: the sibling PublikNode) so the
|
||||||
|
## billboards land exactly where the crowd was authored; if that node is missing it
|
||||||
|
## falls back to a generated tribune so the scene still populates.
|
||||||
|
|
||||||
|
const SHEET_PATH := "res://Assets/crowd_clap_sheet.png"
|
||||||
|
const SHADER_PATH := "res://crowd_billboard.gdshader"
|
||||||
|
|
||||||
|
## Node whose descendant figures (names starting with `figure_prefix`) donate their
|
||||||
|
## world positions. Empty = use the generated fallback stand.
|
||||||
|
@export var source_path: NodePath = ^"../PublikNode"
|
||||||
|
@export var figure_prefix: String = "mees"
|
||||||
|
## World height of a spectator billboard, in metres. Sheet aspect sets the width.
|
||||||
|
@export var figure_height: float = 2.0
|
||||||
|
@export var frames: int = 8
|
||||||
|
@export var clap_fps: float = 10.0
|
||||||
|
## Subtle per-spectator brightness spread so the crowd doesn't read as clones.
|
||||||
|
@export_range(0.0, 0.5) var tint_variation: float = 0.18
|
||||||
|
|
||||||
|
# Fallback tribune (used only when source_path resolves to nothing) — concentric arcs
|
||||||
|
# of seats ringing the arena.
|
||||||
|
@export_group("Fallback stand")
|
||||||
|
@export var fallback_count: int = 120
|
||||||
|
@export var fallback_inner_radius: float = 26.0
|
||||||
|
@export var fallback_rows: int = 6
|
||||||
|
@export var fallback_row_rise: float = 1.6
|
||||||
|
@export var fallback_row_step: float = 2.2
|
||||||
|
@export var fallback_seat_step: float = 2.4
|
||||||
|
|
||||||
|
var _rng := RandomNumberGenerator.new()
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_rng.seed = hash("bullosseum-crowd") # deterministic layout/phases across runs
|
||||||
|
_build()
|
||||||
|
|
||||||
|
|
||||||
|
func _build() -> void:
|
||||||
|
var sheet := load(SHEET_PATH) as Texture2D
|
||||||
|
if sheet == null:
|
||||||
|
push_warning("crowd_billboards: missing sheet %s — run tools/bake_crowd_sheet.gd" % SHEET_PATH)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Sheet holds `frames` columns and 2 rows (front / back), so a cell is half-height.
|
||||||
|
var aspect := (float(sheet.get_width()) / float(frames)) / (float(sheet.get_height()) / 2.0)
|
||||||
|
var quad := QuadMesh.new()
|
||||||
|
quad.size = Vector2(figure_height * aspect, figure_height)
|
||||||
|
quad.center_offset = Vector3(0.0, figure_height * 0.5, 0.0) # pivot at the feet
|
||||||
|
|
||||||
|
var mat := ShaderMaterial.new()
|
||||||
|
mat.shader = load(SHADER_PATH) as Shader
|
||||||
|
mat.set_shader_parameter("sheet", sheet)
|
||||||
|
mat.set_shader_parameter("hframes", frames)
|
||||||
|
mat.set_shader_parameter("fps", clap_fps)
|
||||||
|
material_override = mat
|
||||||
|
|
||||||
|
var seats := _seat_positions()
|
||||||
|
var center := _arena_center(seats)
|
||||||
|
|
||||||
|
var mm := MultiMesh.new()
|
||||||
|
mm.transform_format = MultiMesh.TRANSFORM_3D
|
||||||
|
mm.use_custom_data = true
|
||||||
|
mm.mesh = quad
|
||||||
|
mm.instance_count = seats.size()
|
||||||
|
|
||||||
|
for i in seats.size():
|
||||||
|
var s := 1.0 + _rng.randf_range(-0.06, 0.06) # slight height variety
|
||||||
|
mm.set_instance_transform(i, Transform3D(_facing_basis(seats[i], center, s), seats[i]))
|
||||||
|
var b := 1.0 + _rng.randf_range(-tint_variation, tint_variation)
|
||||||
|
mm.set_instance_custom_data(i, Color(_rng.randf(), b, b * 0.99, b * 0.98))
|
||||||
|
multimesh = mm
|
||||||
|
|
||||||
|
|
||||||
|
# Orientation for a seat: the sprite's forward (+Z, the quad's face) points horizontally
|
||||||
|
# inward at the arena centre, so each ring segment faces the action instead of the
|
||||||
|
# camera. Upright (world +Y), uniformly scaled by `s`.
|
||||||
|
func _facing_basis(seat: Vector3, center: Vector3, s: float) -> Basis:
|
||||||
|
var look := center - seat
|
||||||
|
look.y = 0.0
|
||||||
|
look = look.normalized() if look.length() > 0.001 else Vector3(0.0, 0.0, -1.0)
|
||||||
|
var up := Vector3.UP
|
||||||
|
var right := up.cross(look).normalized()
|
||||||
|
var basis := Basis()
|
||||||
|
basis.x = right
|
||||||
|
basis.y = up
|
||||||
|
basis.z = look
|
||||||
|
return basis.scaled(Vector3.ONE * s)
|
||||||
|
|
||||||
|
|
||||||
|
# Horizontal centre the crowd faces — the mean of the seat positions (the ring/stand
|
||||||
|
# centroid), which for an arena crowd is the middle of the pitch.
|
||||||
|
func _arena_center(seats: PackedVector3Array) -> Vector3:
|
||||||
|
if seats.is_empty():
|
||||||
|
return global_position
|
||||||
|
var sum := Vector3.ZERO
|
||||||
|
for p in seats:
|
||||||
|
sum += p
|
||||||
|
return sum / float(seats.size())
|
||||||
|
|
||||||
|
|
||||||
|
# Global feet-positions for every spectator: harvested from the source crowd if present,
|
||||||
|
# otherwise the generated fallback tribune.
|
||||||
|
func _seat_positions() -> PackedVector3Array:
|
||||||
|
var out := PackedVector3Array()
|
||||||
|
var src := get_node_or_null(source_path)
|
||||||
|
if src != null:
|
||||||
|
_collect_figures(src, out)
|
||||||
|
if out.is_empty():
|
||||||
|
_fallback_stand(out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func _collect_figures(node: Node, out: PackedVector3Array) -> void:
|
||||||
|
if node is Node3D and node != self and (node as Node3D).name.begins_with(figure_prefix):
|
||||||
|
out.append((node as Node3D).global_position)
|
||||||
|
return # don't descend into a figure's own rig
|
||||||
|
for c in node.get_children():
|
||||||
|
_collect_figures(c, out)
|
||||||
|
|
||||||
|
|
||||||
|
func _fallback_stand(out: PackedVector3Array) -> void:
|
||||||
|
var placed := 0
|
||||||
|
for row in fallback_rows:
|
||||||
|
var radius := fallback_inner_radius + float(row) * fallback_row_step
|
||||||
|
var y := float(row) * fallback_row_rise
|
||||||
|
var circumference := TAU * radius
|
||||||
|
var seats_in_row := int(circumference / fallback_seat_step)
|
||||||
|
for s in seats_in_row:
|
||||||
|
if placed >= fallback_count:
|
||||||
|
return
|
||||||
|
var ang := TAU * float(s) / float(seats_in_row)
|
||||||
|
out.append(Vector3(cos(ang) * radius, y, sin(ang) * radius))
|
||||||
|
placed += 1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cj0ytlkuavmf4
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dys23pu1kw5fl
|
||||||
@@ -405,6 +405,10 @@ func _reconcile(delta: float) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _collect(node: Node, col: bool, hit: bool, bone: bool, anim: bool, wanted: Dictionary) -> void:
|
func _collect(node: Node, col: bool, hit: bool, bone: bool, anim: bool, wanted: Dictionary) -> void:
|
||||||
|
# The audience ("public") is ambient decoration, not a debug target — skip the whole
|
||||||
|
# crowd subtree so its clapping AnimationPlayers/skeletons don't flood the overlays.
|
||||||
|
if node.is_in_group(&"public"):
|
||||||
|
return
|
||||||
if bone and node is Skeleton3D and (node as Skeleton3D).is_visible_in_tree():
|
if bone and node is Skeleton3D and (node as Skeleton3D).is_visible_in_tree():
|
||||||
_skeletons.append(node as Skeleton3D)
|
_skeletons.append(node as Skeleton3D)
|
||||||
if anim and node is AnimationPlayer:
|
if anim and node is AnimationPlayer:
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ 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 +33,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)
|
||||||
@@ -95,13 +100,22 @@ func _register_all() -> void:
|
|||||||
_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)
|
_reg_f("Matador", "mat_spawn_count", 1.0, 1.0, 30.0, 1.0)
|
||||||
|
# Hydra split: a slain matador is replaced on the spot by mat_split_factor fresh
|
||||||
|
# matadors, so the horde doubles each generation (1 → 2 → 4 → 8 …) at the default
|
||||||
|
# 2. Capped at mat_split_generations deep so the arena stays winnable (factor <2 or
|
||||||
|
# generations 0 = no splitting, the classic one-and-done arena).
|
||||||
|
_reg_f("Matador", "mat_split_factor", 2.0, 1.0, 6.0, 1.0)
|
||||||
|
_reg_f("Matador", "mat_split_generations", 3.0, 0.0, 6.0, 1.0)
|
||||||
|
# The bull wins outright once it has slain this many matadors, even if the hydra
|
||||||
|
# still has bodies on the sand. 0 = disabled (win only when the horde dies out)
|
||||||
|
_reg_f("Matador", "mat_win_kills", 21.0, 0.0, 200.0, 1.0)
|
||||||
_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
|
||||||
@@ -153,10 +167,27 @@ 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)
|
||||||
# ── Sword ─────────────────────────────────────────────────────────────────
|
# ── Sword ─────────────────────────────────────────────────────────────────
|
||||||
@@ -178,18 +209,28 @@ func _register_all() -> void:
|
|||||||
_reg_f("Sword", "sword_rest_rot_x", -120.0, -180.0, 180.0, 1.0)
|
_reg_f("Sword", "sword_rest_rot_x", -120.0, -180.0, 180.0, 1.0)
|
||||||
_reg_f("Sword", "sword_rest_rot_y", 0.0, -180.0, 180.0, 1.0)
|
_reg_f("Sword", "sword_rest_rot_y", 0.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)
|
||||||
@@ -198,7 +239,7 @@ func _register_all() -> void:
|
|||||||
_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", 1.5, 0.2, 10.0, 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", 4.0, 0.5, 15.0, 0.1)
|
||||||
_reg_f("Abilities", "slam_jump_velocity", 7.0, 2.0, 20.0)
|
_reg_f("Abilities", "slam_jump_velocity", 7.0, 2.0, 20.0)
|
||||||
@@ -209,25 +250,42 @@ 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", 0.5, 0.1, 10.0, 0.1)
|
||||||
_reg_f("Abilities", "dash_speed", 66.0, 5.0, 200.0)
|
_reg_f("Abilities", "dash_speed", 66.0, 5.0, 200.0)
|
||||||
# ── 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.
|
||||||
|
# roll_turn is the homing turn rate. When no chain targets remain, the roll ends.
|
||||||
_reg_f("Roll", "roll_cooldown", 3.0, 0.5, 15.0, 0.1)
|
_reg_f("Roll", "roll_cooldown", 3.0, 0.5, 15.0, 0.1)
|
||||||
_reg_f("Roll", "roll_duration", 2.5, 0.5, 6.0, 0.1) # max roll time
|
_reg_f("Roll", "roll_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
|
||||||
_reg_f("Roll", "roll_knockup", 11.0, 0.0, 25.0) # vertical knock-up on the pop
|
_reg_f("Roll", "roll_knockup", 11.0, 0.0, 25.0) # vertical knock-up on the pop
|
||||||
# ── Debug overlays (see debug_draw.gd) ───────────────────────────────────
|
# ── Debug overlays (see debug_draw.gd) ───────────────────────────────────
|
||||||
_reg_b("Debug", "show_collisions", false)
|
_reg_b("Debug", "show_collisions", false)
|
||||||
|
# PS1 post-process filter (see ps1_filter.gd on the fullscreen ColorRect):
|
||||||
|
# screen-covering CRT/PS1 shader — pixelation, colour-banding + dither, and a
|
||||||
|
# rolling scanline. Keys map 1:1 onto the shader uniforms; show_ps1 toggles the
|
||||||
|
# whole overlay. Ranges mirror the shader's hint_range so the console can't push
|
||||||
|
# a uniform out of bounds.
|
||||||
|
_reg_b("PS1", "show_ps1", true)
|
||||||
|
_reg_f("PS1", "ps1_pixel_scale", 4.0, 1.0, 8.0, 1.0)
|
||||||
|
_reg_f("PS1", "ps1_color_depth", 5.0, 2.0, 8.0, 1.0)
|
||||||
|
_reg_f("PS1", "ps1_dither_strength", 0.8, 0.0, 2.0, 0.05)
|
||||||
|
_reg_f("PS1", "ps1_scanline_strength", 0.2, 0.0, 1.0, 0.05)
|
||||||
|
_reg_f("PS1", "ps1_roll_speed", 3.0, 0.0, 10.0, 0.1)
|
||||||
|
_reg_f("PS1", "ps1_roll_strength", 1.0, 0.0, 1.0, 0.05)
|
||||||
_reg_b("Debug", "show_hitboxes", false)
|
_reg_b("Debug", "show_hitboxes", false)
|
||||||
_reg_b("Debug", "show_bones", false)
|
_reg_b("Debug", "show_bones", false)
|
||||||
_reg_b("Debug", "show_raycasts", false)
|
_reg_b("Debug", "show_raycasts", false)
|
||||||
@@ -238,6 +296,9 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
func _reg_f(section: String, key: String, default: float,
|
func _reg_f(section: String, key: String, default: float,
|
||||||
|
|||||||
@@ -1,17 +1,39 @@
|
|||||||
#!/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
|
||||||
|
|
||||||
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.wasm.gz" \
|
||||||
@@ -21,6 +43,7 @@ scp "$BUILD_DIR/index.html" \
|
|||||||
"$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} to $SERVER_HOST:$SERVER_PATH"
|
||||||
|
|||||||
@@ -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,43 @@ 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
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -53,12 +78,85 @@ 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.
|
||||||
|
func _build_bull_hud_bg() -> void:
|
||||||
|
_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 +173,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 +182,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]
|
||||||
@@ -129,9 +231,48 @@ func _restart() -> void:
|
|||||||
|
|
||||||
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:
|
||||||
|
if _game_over or Controls.use_touch_ui():
|
||||||
|
_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 +298,11 @@ 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!"
|
||||||
|
|
||||||
|
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 +323,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()
|
||||||
@@ -208,6 +379,97 @@ func _build_game_over() -> void:
|
|||||||
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 +477,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 +487,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 +552,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 +636,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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
@@ -130,11 +137,24 @@ func _apply_theme() -> void:
|
|||||||
if title != null:
|
if title != null:
|
||||||
title.add_theme_font_override("font", UiFonts.title())
|
title.add_theme_font_override("font", UiFonts.title())
|
||||||
title.add_theme_color_override("font_color", MENU_GOLD)
|
title.add_theme_color_override("font_color", MENU_GOLD)
|
||||||
for path: String in ["OptionsPanel/VBox/Hint", "CreditsPanel/VBox/Names"]:
|
var hint := get_node_or_null("OptionsPanel/VBox/Hint") as Label
|
||||||
var lbl := get_node_or_null(path) as Label
|
if hint != null:
|
||||||
if lbl != null:
|
hint.add_theme_font_override("font", UiFonts.body())
|
||||||
lbl.add_theme_font_override("font", UiFonts.body())
|
hint.add_theme_color_override("font_color", MENU_CREAM)
|
||||||
lbl.add_theme_color_override("font_color", MENU_CREAM)
|
_style_credits(get_node_or_null("CreditsPanel/VBox/Names") as RichTextLabel)
|
||||||
|
|
||||||
|
|
||||||
|
# The credits use a RichTextLabel so the Instagram handles are clickable links
|
||||||
|
# ([url] tags). meta_clicked opens the profile — in the web export OS.shell_open
|
||||||
|
# pops a new browser tab.
|
||||||
|
func _style_credits(names: RichTextLabel) -> void:
|
||||||
|
if names == null:
|
||||||
|
return
|
||||||
|
names.add_theme_font_override("normal_font", UiFonts.body())
|
||||||
|
names.add_theme_font_override("bold_font", UiFonts.title())
|
||||||
|
names.add_theme_color_override("default_color", MENU_CREAM)
|
||||||
|
names.meta_clicked.connect(func(meta: Variant) -> void:
|
||||||
|
OS.shell_open(str(meta)))
|
||||||
|
|
||||||
|
|
||||||
func _style_button(btn: Button, big: bool) -> void:
|
func _style_button(btn: Button, big: bool) -> void:
|
||||||
@@ -170,6 +190,40 @@ func _process(_delta: float) -> void:
|
|||||||
play_button.anchor_bottom = cape.y
|
play_button.anchor_bottom = cape.y
|
||||||
|
|
||||||
|
|
||||||
|
# Player-facing option toggles, seated above the rebind list in the Options panel:
|
||||||
|
# the master screen-shake switch and the gore toggle (both persisted via Settings).
|
||||||
|
func _build_options_toggles() -> void:
|
||||||
|
var scroll_idx := options_scroll.get_index()
|
||||||
|
# Insert in reverse so both land directly above the rebind scroll, in listed order.
|
||||||
|
_add_toggle_row("Blood & Gore", Settings.gore, Settings.set_gore, scroll_idx)
|
||||||
|
_add_toggle_row("Screen Shake", Settings.screen_shake, Settings.set_screen_shake, scroll_idx)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -182,9 +236,10 @@ func _build_rebind_rows() -> void:
|
|||||||
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
name_label.add_theme_font_override("font", UiFonts.body())
|
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_color", MENU_CREAM)
|
||||||
name_label.add_theme_color_override("font_outline_color", Color.BLACK)
|
name_label.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||||
name_label.add_theme_constant_override("outline_size", 4)
|
name_label.add_theme_constant_override("outline_size", 5)
|
||||||
row.add_child(name_label)
|
row.add_child(name_label)
|
||||||
|
|
||||||
var key_button := Button.new()
|
var key_button := Button.new()
|
||||||
@@ -207,12 +262,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()
|
||||||
|
|
||||||
@@ -226,6 +283,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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -50,8 +51,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
|
||||||
@@ -74,6 +75,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
|
||||||
@@ -95,8 +99,9 @@ func _ready() -> void:
|
|||||||
if _skeleton:
|
if _skeleton:
|
||||||
_sim = MatadorRagdoll.build(_skeleton)
|
_sim = MatadorRagdoll.build(_skeleton)
|
||||||
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 +109,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()
|
||||||
@@ -148,12 +153,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 +215,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 +286,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 +394,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 +409,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)
|
||||||
@@ -528,7 +583,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 +608,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 +618,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 +659,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 +787,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 +898,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 +951,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 +979,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 +1001,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 +1015,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 +1034,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 +1083,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 +1112,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
|
||||||
|
|||||||
@@ -7,8 +7,14 @@ const MATADOR := preload("res://Matador.tscn")
|
|||||||
|
|
||||||
var _spawn_count: int = 1
|
var _spawn_count: int = 1
|
||||||
var _alive: int = 0
|
var _alive: int = 0
|
||||||
|
var _kills: int = 0
|
||||||
var _resolved: bool = false
|
var _resolved: bool = false
|
||||||
|
|
||||||
|
# Flat (x,z) positions of the most recent batch spawned. A fresh wave keeps at least
|
||||||
|
# _SPAWN_MIN_SEP away from these so it never lands on top of where the last one did.
|
||||||
|
const _SPAWN_MIN_SEP: float = 3.5
|
||||||
|
var _last_spawn_positions: Array[Vector3] = []
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group(&"matador_spawn")
|
add_to_group(&"matador_spawn")
|
||||||
@@ -23,8 +29,10 @@ func _spawn() -> void:
|
|||||||
var arena_r := DP.f("arena_spawn_radius")
|
var arena_r := DP.f("arena_spawn_radius")
|
||||||
var bull_angle := randf() * TAU
|
var bull_angle := randf() * TAU
|
||||||
_place_bull(bull_angle, arena_r)
|
_place_bull(bull_angle, arena_r)
|
||||||
for pos: Vector3 in _spread_positions(_spawn_count, bull_angle + PI, arena_r):
|
var placed: Array[Vector3] = _spread_positions(_spawn_count, bull_angle + PI, arena_r)
|
||||||
|
for pos: Vector3 in placed:
|
||||||
_spawn_at(pos)
|
_spawn_at(pos)
|
||||||
|
_last_spawn_positions = placed
|
||||||
|
|
||||||
|
|
||||||
func _place_bull(angle: float, arena_r: float) -> void:
|
func _place_bull(angle: float, arena_r: float) -> void:
|
||||||
@@ -36,34 +44,99 @@ func _place_bull(angle: float, arena_r: float) -> void:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Positions for the matadors, clustered on the far side (centred on `center_angle`)
|
# Positions for the matadors, spread across the far side (centred on `center_angle`)
|
||||||
# so every matador stays near the point diametrically opposite the bull.
|
# so every matador stays near the point diametrically opposite the bull. Each slot gets
|
||||||
|
# its own angular band and a staggered radius, then rejection-samples inside that band so
|
||||||
|
# no two matadors land closer than _SPAWN_MIN_SEP — otherwise they pile up on top of
|
||||||
|
# each other on spawn. The arc widens with the crowd so a big wave doesn't get cramped.
|
||||||
func _spread_positions(count: int, center_angle: float, arena_r: float) -> Array[Vector3]:
|
func _spread_positions(count: int, center_angle: float, arena_r: float) -> Array[Vector3]:
|
||||||
var positions: Array[Vector3] = []
|
var positions: Array[Vector3] = []
|
||||||
if count <= 1:
|
if count <= 1:
|
||||||
positions.append(Vector3(cos(center_angle) * arena_r, 1.0, sin(center_angle) * arena_r))
|
positions.append(Vector3(cos(center_angle) * arena_r, 1.0, sin(center_angle) * arena_r))
|
||||||
return positions
|
return positions
|
||||||
var arc := TAU * 0.33
|
var arc := clampf(TAU * 0.33 + float(count) * 0.25, TAU * 0.33, TAU * 0.92)
|
||||||
|
var slot := arc / float(count)
|
||||||
|
var inner := maxf(2.5, arena_r * 0.5)
|
||||||
for i: int in count:
|
for i: int in count:
|
||||||
var t := float(i) / float(count - 1) - 0.5 # -0.5 .. 0.5 across the arc
|
# Give each matador its own angular slot for an even fan, but sample the radius
|
||||||
var angle := center_angle + t * arc
|
# freely across the whole far-side band so the rejection test can push crowded
|
||||||
var radius := randf_range(arena_r * 0.7, arena_r)
|
# neighbours onto a different ring instead of piling them at one distance.
|
||||||
positions.append(Vector3(cos(angle) * radius, 1.0, sin(angle) * radius))
|
var base_angle := center_angle - arc * 0.5 + slot * (float(i) + 0.5)
|
||||||
|
var pos := Vector3(cos(base_angle) * arena_r, 1.0, sin(base_angle) * arena_r)
|
||||||
|
for attempt: int in 16:
|
||||||
|
var angle := base_angle + randf_range(-0.5, 0.5) * slot
|
||||||
|
var radius := randf_range(inner, arena_r)
|
||||||
|
pos = Vector3(cos(angle) * radius, 1.0, sin(angle) * radius)
|
||||||
|
if _clear_of(pos, positions, _SPAWN_MIN_SEP):
|
||||||
|
break
|
||||||
|
positions.append(pos)
|
||||||
return positions
|
return positions
|
||||||
|
|
||||||
|
|
||||||
func _spawn_at(pos: Vector3) -> void:
|
func _spawn_at(pos: Vector3, generation: int = 0) -> void:
|
||||||
var m: Node3D = MATADOR.instantiate()
|
var m: Node3D = MATADOR.instantiate()
|
||||||
m.position = pos
|
m.position = pos
|
||||||
|
m.set_meta(&"generation", generation)
|
||||||
get_parent().add_child(m)
|
get_parent().add_child(m)
|
||||||
m.killed.connect(_on_matador_killed)
|
m.killed.connect(_on_matador_killed.bind(m))
|
||||||
_alive += 1
|
_alive += 1
|
||||||
|
|
||||||
|
|
||||||
# Every matador down = the bull wins. No waves, no respawns — one clean arena.
|
# A slain matador is replaced by mat_split_factor fresh ones on the spot (a hydra),
|
||||||
func _on_matador_killed() -> void:
|
# so the horde doubles each generation, up to mat_split_generations deep. The bull
|
||||||
|
# wins only when a whole generation dies out without splitting — i.e. once the spawns
|
||||||
|
# stop and the last body drops.
|
||||||
|
func _on_matador_killed(dead: Node3D) -> void:
|
||||||
matador_killed.emit()
|
matador_killed.emit()
|
||||||
_alive -= 1
|
_alive -= 1
|
||||||
if _alive <= 0 and not _resolved:
|
_kills += 1
|
||||||
|
_split(dead)
|
||||||
|
var win_kills := int(DP.f("mat_win_kills"))
|
||||||
|
var reached_quota := win_kills > 0 and _kills >= win_kills
|
||||||
|
if (reached_quota or _alive <= 0) and not _resolved:
|
||||||
_resolved = true
|
_resolved = true
|
||||||
all_defeated.emit()
|
all_defeated.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _split(dead: Node3D) -> void:
|
||||||
|
var count := int(DP.f("mat_split_factor"))
|
||||||
|
var generation := int(dead.get_meta(&"generation", 0))
|
||||||
|
if count < 2 or generation >= int(DP.f("mat_split_generations")):
|
||||||
|
return
|
||||||
|
var arena_r := DP.f("arena_spawn_radius")
|
||||||
|
var origin := dead.global_position
|
||||||
|
origin.y = 0.0
|
||||||
|
var wave: Array[Vector3] = []
|
||||||
|
for i: int in count:
|
||||||
|
var pos := _pick_spawn(origin, arena_r, wave)
|
||||||
|
wave.append(pos)
|
||||||
|
_spawn_at(pos, generation + 1)
|
||||||
|
_last_spawn_positions = wave
|
||||||
|
|
||||||
|
|
||||||
|
# Rejection-sample a spawn point ringed around `origin`, pushing the ring outward on
|
||||||
|
# each failed attempt until it clears both the previous wave (_last_spawn_positions)
|
||||||
|
# and the spots already taken this wave — so a fresh wave never lands where the last
|
||||||
|
# one did. Falls back to the last candidate if the arena is too cramped to satisfy it.
|
||||||
|
func _pick_spawn(origin: Vector3, arena_r: float, taken: Array[Vector3]) -> Vector3:
|
||||||
|
var pos := origin
|
||||||
|
for attempt: int in 12:
|
||||||
|
var angle := randf() * TAU
|
||||||
|
var r := randf_range(2.0, 3.5) + float(attempt) * 0.4
|
||||||
|
pos = origin + Vector3(cos(angle) * r, 0.0, sin(angle) * r)
|
||||||
|
var flat := Vector2(pos.x, pos.z)
|
||||||
|
if flat.length() > arena_r:
|
||||||
|
flat = flat.normalized() * arena_r
|
||||||
|
pos = Vector3(flat.x, 0.0, flat.y)
|
||||||
|
if _clear_of(pos, _last_spawn_positions, _SPAWN_MIN_SEP) \
|
||||||
|
and _clear_of(pos, taken, _SPAWN_MIN_SEP):
|
||||||
|
break
|
||||||
|
pos.y = 1.0
|
||||||
|
return pos
|
||||||
|
|
||||||
|
|
||||||
|
func _clear_of(pos: Vector3, others: Array[Vector3], min_sep: float) -> bool:
|
||||||
|
for o: Vector3 in others:
|
||||||
|
if Vector2(pos.x - o.x, pos.z - o.z).length() < min_sep:
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bmdtg6qlh7wri
|
||||||
@@ -27,16 +27,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 +56,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
|
||||||
@@ -61,6 +79,8 @@ var _charge_dust_ramp: Gradient = null
|
|||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group(&"player")
|
add_to_group(&"player")
|
||||||
|
_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()
|
||||||
@@ -258,8 +278,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,6 +289,7 @@ 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
|
||||||
@@ -275,25 +297,56 @@ func _play_idle() -> void:
|
|||||||
_bull_anim.play(_ANIM_IDLE, 0.2)
|
_bull_anim.play(_ANIM_IDLE, 0.2)
|
||||||
|
|
||||||
|
|
||||||
|
# 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_DASH):
|
||||||
|
_bull_anim.speed_scale = 1.0
|
||||||
|
_bull_anim.play(_ANIM_DASH, 0.25)
|
||||||
|
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 +359,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 +374,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 +393,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 +404,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 +421,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 +453,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.
|
||||||
@@ -431,25 +498,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,6 +563,9 @@ 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)
|
||||||
@@ -805,6 +914,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 +946,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 +979,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 +1000,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 +1057,76 @@ 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_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:
|
||||||
|
_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)
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ config_version=5
|
|||||||
[application]
|
[application]
|
||||||
|
|
||||||
config/name="Bullosseum"
|
config/name="Bullosseum"
|
||||||
config/version="0.1.0"
|
config/version="0.2.0"
|
||||||
run/main_scene="res://MainMenu.tscn"
|
run/main_scene="res://MainMenu.tscn"
|
||||||
config/features=PackedStringArray("4.7", "Forward Plus")
|
config/features=PackedStringArray("4.7", "Forward Plus")
|
||||||
config/icon="res://icon.svg"
|
config/icon="res://icon.svg"
|
||||||
config/run/main_scene="res://MainMenu.tscn"
|
config/run/main_scene="res://MainMenu.tscn"
|
||||||
|
boot_splash/bg_color=Color(0, 0, 0, 1)
|
||||||
|
boot_splash/image="res://Assets/loading_screen.png"
|
||||||
|
boot_splash/fullsize=true
|
||||||
|
boot_splash/use_filter=true
|
||||||
|
|
||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
@@ -23,6 +27,7 @@ DP="*res://debug_params.gd"
|
|||||||
Console="*res://console.gd"
|
Console="*res://console.gd"
|
||||||
Controls="*res://controls_manager.gd"
|
Controls="*res://controls_manager.gd"
|
||||||
DebugDraw="*res://debug_draw.gd"
|
DebugDraw="*res://debug_draw.gd"
|
||||||
|
Settings="*res://settings.gd"
|
||||||
|
|
||||||
[editor_plugins]
|
[editor_plugins]
|
||||||
|
|
||||||
@@ -102,6 +107,10 @@ ability_roll={
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[input_devices]
|
||||||
|
|
||||||
|
pointing/emulate_touch_from_mouse=true
|
||||||
|
|
||||||
[physics]
|
[physics]
|
||||||
|
|
||||||
3d/physics_engine="Jolt Physics"
|
3d/physics_engine="Jolt Physics"
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
extends ColorRect
|
||||||
|
## Drives the fullscreen PS1/CRT post-process (ps1_filter.gdshader) from the DP
|
||||||
|
## registry so every uniform is live-tunable in the console. `show_ps1` toggles the
|
||||||
|
## whole overlay; the ps1_* floats feed the matching shader uniforms 1:1.
|
||||||
|
|
||||||
|
# DP param key → shader uniform name. Keys are registered in debug_params.gd with
|
||||||
|
# ranges that mirror the shader's hint_range, so nothing here can go out of bounds.
|
||||||
|
const _UNIFORMS := {
|
||||||
|
"ps1_pixel_scale": "pixel_scale",
|
||||||
|
"ps1_color_depth": "color_depth",
|
||||||
|
"ps1_dither_strength": "dither_strength",
|
||||||
|
"ps1_scanline_strength": "scanline_strength",
|
||||||
|
"ps1_roll_speed": "roll_speed",
|
||||||
|
"ps1_roll_strength": "roll_strength",
|
||||||
|
}
|
||||||
|
|
||||||
|
@onready var _mat: ShaderMaterial = material as ShaderMaterial
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
DP.any_changed.connect(_on_param_changed)
|
||||||
|
_apply_all()
|
||||||
|
|
||||||
|
|
||||||
|
# reset_all() fires with key "__all__"; a single edit fires with its own key. Either
|
||||||
|
# way just re-push everything — it's a handful of cheap uniform writes.
|
||||||
|
func _on_param_changed(_key: String, _value: Variant) -> void:
|
||||||
|
_apply_all()
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_all() -> void:
|
||||||
|
visible = DP.b("show_ps1")
|
||||||
|
if _mat == null:
|
||||||
|
return
|
||||||
|
for key: String in _UNIFORMS:
|
||||||
|
_mat.set_shader_parameter(_UNIFORMS[key], DP.f(key))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ce5n4qu88mhyx
|
||||||
@@ -2,6 +2,9 @@ extends Node3D
|
|||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
# Mark the crowd so debug overlays (show_animation_name / show_bones) skip it —
|
||||||
|
# 72 ambient clapping spectators would otherwise flood the view with tags.
|
||||||
|
add_to_group(&"public")
|
||||||
for child in get_children():
|
for child in get_children():
|
||||||
var anim := child.get_node_or_null(^"AnimationPlayer") as AnimationPlayer
|
var anim := child.get_node_or_null(^"AnimationPlayer") as AnimationPlayer
|
||||||
if anim:
|
if anim:
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ echo ""
|
|||||||
echo "=== Logic tests ==="
|
echo "=== Logic tests ==="
|
||||||
"$GODOT" --headless --script tests/logic_test.gd
|
"$GODOT" --headless --script tests/logic_test.gd
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Console focus tests ==="
|
||||||
|
"$GODOT" --headless --script tests/console_focus_test.gd
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Performance tests (frame-budget regression guard) ==="
|
echo "=== Performance tests (frame-budget regression guard) ==="
|
||||||
"$GODOT" --headless --script tests/performance_test.gd
|
"$GODOT" --headless --script tests/performance_test.gd
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
[ext_resource type="Script" uid="uid://di0jafakftp3n" path="res://publikum_controller.gd" id="9_pubctrl"]
|
[ext_resource type="Script" uid="uid://di0jafakftp3n" path="res://publikum_controller.gd" id="9_pubctrl"]
|
||||||
[ext_resource type="PackedScene" uid="uid://dxe72ig8gfbcg" path="res://Assets/arena model2026.glb" id="10_5juve"]
|
[ext_resource type="PackedScene" uid="uid://dxe72ig8gfbcg" path="res://Assets/arena model2026.glb" id="10_5juve"]
|
||||||
[ext_resource type="Script" uid="uid://butd6b3tdwxrv" path="res://hud.gd" id="10_hud"]
|
[ext_resource type="Script" uid="uid://butd6b3tdwxrv" path="res://hud.gd" id="10_hud"]
|
||||||
|
[ext_resource type="Script" uid="uid://cj0ytlkuavmf4" path="res://crowd_billboards.gd" id="11_crowd"]
|
||||||
|
[ext_resource type="Script" uid="uid://ce5n4qu88mhyx" path="res://ps1_filter.gd" id="12_ps1ctl"]
|
||||||
|
|
||||||
[sub_resource type="CameraAttributesPractical" id="CameraAttributesPractical_73fnb"]
|
[sub_resource type="CameraAttributesPractical" id="CameraAttributesPractical_73fnb"]
|
||||||
dof_blur_far_enabled = true
|
dof_blur_far_enabled = true
|
||||||
@@ -83,6 +85,7 @@ anchor_right = 1.0
|
|||||||
anchor_bottom = 1.0
|
anchor_bottom = 1.0
|
||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
grow_vertical = 2
|
grow_vertical = 2
|
||||||
|
script = ExtResource("12_ps1ctl")
|
||||||
|
|
||||||
[node name="Lights" type="Node3D" parent="." unique_id=737767662]
|
[node name="Lights" type="Node3D" parent="." unique_id=737767662]
|
||||||
|
|
||||||
@@ -93,9 +96,9 @@ light_indirect_energy = 0.767
|
|||||||
shadow_blur = 0.47
|
shadow_blur = 0.47
|
||||||
|
|
||||||
[node name="SpotLight3D2" type="SpotLight3D" parent="Lights" unique_id=918477294]
|
[node name="SpotLight3D2" type="SpotLight3D" parent="Lights" unique_id=918477294]
|
||||||
transform = Transform3D(0.904133, 0.30962405, -0.2944087, 0.3084627, 0.0037425563, 0.9512291, 0.2956252, -0.95085174, -0.0921237, -3.629381, 8.186143, -2.0124264)
|
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)
|
||||||
visible = false
|
light_energy = 16.0
|
||||||
light_energy = 14.585
|
light_indirect_energy = 26.095
|
||||||
distance_fade_enabled = true
|
distance_fade_enabled = true
|
||||||
spot_range = 32.90586
|
spot_range = 32.90586
|
||||||
spot_angle = 73.93
|
spot_angle = 73.93
|
||||||
@@ -109,7 +112,7 @@ spot_range = 32.90586
|
|||||||
|
|
||||||
[node name="SUN_DirectionalLight3D2" type="DirectionalLight3D" parent="Lights" unique_id=1706919950]
|
[node name="SUN_DirectionalLight3D2" type="DirectionalLight3D" parent="Lights" unique_id=1706919950]
|
||||||
transform = Transform3D(0.45811793, 0.34843865, -0.81775206, 0.5316611, 0.62986195, 0.56622505, 0.7123655, -0.69416475, 0.10329977, 0, 8.980043, 0)
|
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 = 2.988
|
light_energy = 1.199
|
||||||
shadow_enabled = true
|
shadow_enabled = true
|
||||||
shadow_blur = 10.0
|
shadow_blur = 10.0
|
||||||
|
|
||||||
@@ -378,6 +381,10 @@ transform = Transform3D(0.97025216, 0, 0.24209678, 0, 1, 0, -0.24209678, 0, 0.97
|
|||||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=755853425]
|
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=755853425]
|
||||||
environment = SubResource("Environment_73fnb")
|
environment = SubResource("Environment_73fnb")
|
||||||
|
|
||||||
|
[node name="CrowdBillboards" type="MultiMeshInstance3D" parent="." unique_id=549548313]
|
||||||
|
visible = false
|
||||||
|
script = ExtResource("11_crowd")
|
||||||
|
|
||||||
[editable path="Player"]
|
[editable path="Player"]
|
||||||
[editable path="Player/bull"]
|
[editable path="Player/bull"]
|
||||||
[editable path="Node3D/arena_placeholder_v01"]
|
[editable path="Node3D/arena_placeholder_v01"]
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
extends Node
|
||||||
|
## Player-facing game options (autoload `Settings`), persisted to user://settings.cfg.
|
||||||
|
## Distinct from DP (debug tunables) and Controls (keybinds): this is the small set of
|
||||||
|
## comfort/accessibility toggles the options menu exposes to the player.
|
||||||
|
|
||||||
|
signal changed(key: String, value: Variant)
|
||||||
|
|
||||||
|
const SAVE_PATH := "user://settings.cfg"
|
||||||
|
const SECTION := "options"
|
||||||
|
|
||||||
|
## Master screen-shake toggle — gates every camera_iso.trigger_hit (hits and ability FX).
|
||||||
|
var screen_shake: bool = true
|
||||||
|
## Gore toggle — when off, no blood spawns (impact spurt + ongoing spear-wound bleed).
|
||||||
|
var gore: bool = true
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
load_saved()
|
||||||
|
|
||||||
|
|
||||||
|
func set_screen_shake(value: bool) -> void:
|
||||||
|
if screen_shake == value:
|
||||||
|
return
|
||||||
|
screen_shake = value
|
||||||
|
changed.emit("screen_shake", value)
|
||||||
|
save()
|
||||||
|
|
||||||
|
|
||||||
|
func set_gore(value: bool) -> void:
|
||||||
|
if gore == value:
|
||||||
|
return
|
||||||
|
gore = value
|
||||||
|
changed.emit("gore", value)
|
||||||
|
save()
|
||||||
|
|
||||||
|
|
||||||
|
func save() -> void:
|
||||||
|
var cfg := ConfigFile.new()
|
||||||
|
cfg.set_value(SECTION, "screen_shake", screen_shake)
|
||||||
|
cfg.set_value(SECTION, "gore", gore)
|
||||||
|
cfg.save(SAVE_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
func load_saved() -> void:
|
||||||
|
var cfg := ConfigFile.new()
|
||||||
|
if cfg.load(SAVE_PATH) != OK:
|
||||||
|
return
|
||||||
|
screen_shake = cfg.get_value(SECTION, "screen_shake", true) as bool
|
||||||
|
gore = cfg.get_value(SECTION, "gore", true) as bool
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dyspgr7m238la
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
class_name SpearProjectile
|
||||||
|
extends RigidBody3D
|
||||||
|
|
||||||
|
# A thrown spear. It flies a ballistic arc (gravity does the work — the launch
|
||||||
|
# velocity is solved by the matador to land on the bull) and keeps its shaft aimed
|
||||||
|
# along its own velocity, so it noses over at the apex and drives in point-first.
|
||||||
|
# On impact it embeds where it struck: the tip buried just past the contact point
|
||||||
|
# with the shaft trailing back along the flight line, so it reads as stuck-in rather
|
||||||
|
# than floating alongside. A hit on the bull leaves the spear riding it like a spine
|
||||||
|
# (see shed_all, which the bull's slam calls to fling them all off); other hits pin
|
||||||
|
# it in place until the despawn timer clears it.
|
||||||
|
|
||||||
|
const _DESPAWN_SEC: float = 8.0
|
||||||
|
const _STUCK_GROUP: StringName = &"stuck_spear"
|
||||||
|
# Shaft geometry in spear.glb local space: tip at +Z, butt behind the origin.
|
||||||
|
const _TIP_Z: float = 1.915
|
||||||
|
const _EMBED_DEPTH: float = 0.45 # how far past the surface the tip buries
|
||||||
|
|
||||||
|
var _mesh: Node3D = null
|
||||||
|
var _stopped: bool = false
|
||||||
|
var _flight_dir: Vector3 = Vector3.ZERO # last pre-impact travel direction
|
||||||
|
|
||||||
|
|
||||||
|
# Build, seat and launch a spear from an already-instantiated mesh (the one that was
|
||||||
|
# in the matador's hand). `origin` is the release point; `velocity` is the full
|
||||||
|
# ballistic launch vector; `gravity` is the RigidBody gravity_scale for the arc.
|
||||||
|
static func launch(parent: Node, mesh: Node3D, origin: Vector3, velocity: Vector3,
|
||||||
|
gravity: float) -> SpearProjectile:
|
||||||
|
var s := SpearProjectile.new()
|
||||||
|
s.gravity_scale = gravity
|
||||||
|
s.collision_layer = 0
|
||||||
|
s.collision_mask = 1
|
||||||
|
s.contact_monitor = true
|
||||||
|
s.max_contacts_reported = 6
|
||||||
|
parent.add_child(s)
|
||||||
|
# Seat the body so its local +Z (the shaft, see spear.glb) points along the throw.
|
||||||
|
s.global_transform = Transform3D(_aim_basis(velocity), origin)
|
||||||
|
mesh.reparent(s, false)
|
||||||
|
mesh.transform = Transform3D.IDENTITY
|
||||||
|
s._mesh = mesh
|
||||||
|
s.add_child(_shaft_collider())
|
||||||
|
s.linear_velocity = velocity
|
||||||
|
_despawn(s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
# Shed every spear stuck in `bull` — pop each off as a tumbling body that flies clear
|
||||||
|
# and clears itself on the despawn timer. Called by the bull's slam.
|
||||||
|
static func shed_all(bull: Node) -> void:
|
||||||
|
if not (bull is Node3D):
|
||||||
|
return
|
||||||
|
var scene: Node = bull.get_tree().current_scene
|
||||||
|
if scene == null:
|
||||||
|
scene = bull
|
||||||
|
var center: Vector3 = (bull as Node3D).global_position
|
||||||
|
for child in bull.get_children():
|
||||||
|
if not child.is_in_group(_STUCK_GROUP):
|
||||||
|
continue
|
||||||
|
var mesh := child as Node3D
|
||||||
|
var gx := mesh.global_transform
|
||||||
|
var rb := RigidBody3D.new()
|
||||||
|
rb.collision_layer = 0
|
||||||
|
rb.collision_mask = 1
|
||||||
|
scene.add_child(rb)
|
||||||
|
rb.global_transform = gx
|
||||||
|
mesh.reparent(rb, false)
|
||||||
|
mesh.transform = Transform3D.IDENTITY
|
||||||
|
mesh.remove_from_group(_STUCK_GROUP)
|
||||||
|
rb.add_child(_shaft_collider())
|
||||||
|
# Fling outward from the bull with a bit of lift and spin.
|
||||||
|
var out := gx.origin - center
|
||||||
|
out.y = 0.0
|
||||||
|
out = out.normalized() if out.length() > 0.01 else Vector3(randf() - 0.5, 0.0, randf() - 0.5)
|
||||||
|
rb.linear_velocity = out * randf_range(3.0, 6.0) + Vector3.UP * randf_range(3.0, 5.0)
|
||||||
|
rb.angular_velocity = Vector3(randf_range(-10.0, 10.0),
|
||||||
|
randf_range(-10.0, 10.0), randf_range(-10.0, 10.0))
|
||||||
|
_despawn(rb)
|
||||||
|
|
||||||
|
|
||||||
|
static func _shaft_collider() -> CollisionShape3D:
|
||||||
|
# Collider along local +Z (mesh runs z≈-0.34 … 1.92), fattened so a fast throw
|
||||||
|
# can't tunnel past the bull's collision spheres.
|
||||||
|
var cap := CapsuleShape3D.new()
|
||||||
|
cap.radius = 0.13
|
||||||
|
cap.height = 2.2
|
||||||
|
var cs := CollisionShape3D.new()
|
||||||
|
cs.shape = cap
|
||||||
|
cs.position = Vector3(0.0, 0.0, 0.79)
|
||||||
|
cs.rotation_degrees = Vector3(90.0, 0.0, 0.0)
|
||||||
|
return cs
|
||||||
|
|
||||||
|
|
||||||
|
# Continuous bleed from a spear lodged in the bull: a steady spurt of dark-red gobs out
|
||||||
|
# of the entry wound. Parented to the stuck mesh so it tracks the wound as the bull
|
||||||
|
# moves; world-space particles (local_coords off) so they arc off and fall behind as a
|
||||||
|
# trail. Dies with the mesh when the spear is shed or despawned.
|
||||||
|
static func _wound_emitter() -> CPUParticles3D:
|
||||||
|
var ramp := Gradient.new()
|
||||||
|
ramp.set_color(0, Color(0.5, 0.02, 0.02, 1.0))
|
||||||
|
ramp.set_color(1, Color(0.22, 0.0, 0.0, 0.0))
|
||||||
|
|
||||||
|
var sc := Curve.new()
|
||||||
|
sc.add_point(Vector2(0.0, 1.0))
|
||||||
|
sc.add_point(Vector2(0.6, 0.7))
|
||||||
|
sc.add_point(Vector2(1.0, 0.0))
|
||||||
|
|
||||||
|
var p := CPUParticles3D.new()
|
||||||
|
# Seat at the wound (where the shaft meets the hide) and spray back out along the
|
||||||
|
# shaft: mesh local -Z is the exit direction, rotated into world by the node basis.
|
||||||
|
p.position = Vector3(0.0, 0.0, _TIP_Z - _EMBED_DEPTH)
|
||||||
|
p.emitting = true
|
||||||
|
p.amount = 22
|
||||||
|
p.lifetime = 0.6
|
||||||
|
p.explosiveness = 0.0
|
||||||
|
p.randomness = 0.6
|
||||||
|
p.local_coords = false
|
||||||
|
p.direction = Vector3(0.0, 0.55, -1.0).normalized()
|
||||||
|
p.spread = 32.0
|
||||||
|
p.gravity = Vector3(0.0, -12.0, 0.0)
|
||||||
|
p.initial_velocity_min = 1.6
|
||||||
|
p.initial_velocity_max = 4.0
|
||||||
|
p.scale_amount_min = 0.5
|
||||||
|
p.scale_amount_max = 1.4
|
||||||
|
p.mesh = _blood_mesh()
|
||||||
|
p.color_ramp = ramp
|
||||||
|
p.scale_amount_curve = sc
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# Small unshaded, vertex-coloured sphere for blood gobs.
|
||||||
|
static func _blood_mesh() -> SphereMesh:
|
||||||
|
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 s := SphereMesh.new()
|
||||||
|
s.radius = 0.045
|
||||||
|
s.height = 0.09
|
||||||
|
s.radial_segments = 4
|
||||||
|
s.rings = 2
|
||||||
|
s.material = mat
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
static func _despawn(node: Node) -> void:
|
||||||
|
node.get_tree().create_timer(_DESPAWN_SEC).timeout.connect(func() -> void:
|
||||||
|
if is_instance_valid(node):
|
||||||
|
node.queue_free()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Basis whose local +Z points along `v` (looking_at points -Z at its target).
|
||||||
|
static func _aim_basis(v: Vector3) -> Basis:
|
||||||
|
var dir := v.normalized()
|
||||||
|
if dir.length_squared() < 0.0001:
|
||||||
|
return Basis.IDENTITY
|
||||||
|
var up := Vector3.UP if absf(dir.dot(Vector3.UP)) < 0.99 else Vector3.FORWARD
|
||||||
|
return Basis.looking_at(-dir, up)
|
||||||
|
|
||||||
|
|
||||||
|
# While flying, keep the shaft aligned with the velocity; on the first contact freeze
|
||||||
|
# and hand off to _embed so the spear sticks where it struck instead of bouncing off.
|
||||||
|
func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
|
||||||
|
if _stopped:
|
||||||
|
return
|
||||||
|
if state.get_contact_count() > 0:
|
||||||
|
_stopped = true
|
||||||
|
# Use the remembered pre-impact heading — state.linear_velocity is already the
|
||||||
|
# bounced (post-collision) velocity by now, which would aim the shaft wrong.
|
||||||
|
var contact := state.get_contact_collider_position(0)
|
||||||
|
var other := state.get_contact_collider_object(0)
|
||||||
|
state.linear_velocity = Vector3.ZERO
|
||||||
|
state.angular_velocity = Vector3.ZERO
|
||||||
|
_embed.call_deferred(other, contact, _flight_dir)
|
||||||
|
return
|
||||||
|
var v := state.linear_velocity
|
||||||
|
if v.length_squared() > 0.04:
|
||||||
|
_flight_dir = v.normalized()
|
||||||
|
var t := state.transform
|
||||||
|
t.basis = _aim_basis(v)
|
||||||
|
state.transform = t
|
||||||
|
state.angular_velocity = Vector3.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
# Compose the stuck pose (tip buried _EMBED_DEPTH past the contact, shaft along the
|
||||||
|
# flight line) and plant the spear. A bull hit hands the mesh to the bull so it rides
|
||||||
|
# along as a spine; anything else pins the body in place for the despawn timer.
|
||||||
|
func _embed(other: Object, contact: Vector3, flight: Vector3) -> void:
|
||||||
|
var f := flight.normalized()
|
||||||
|
if f.length_squared() < 0.0001:
|
||||||
|
f = global_transform.basis.z.normalized()
|
||||||
|
var pose := Transform3D(_aim_basis(f), contact + f * (_EMBED_DEPTH - _TIP_Z))
|
||||||
|
var bull := other as Node
|
||||||
|
if bull != null and bull.is_in_group(&"player"):
|
||||||
|
bull.call(&"take_sword_hit", "thrown", contact)
|
||||||
|
if is_instance_valid(_mesh) and is_instance_valid(bull):
|
||||||
|
_mesh.reparent(bull, false)
|
||||||
|
_mesh.global_transform = pose
|
||||||
|
_mesh.add_to_group(_STUCK_GROUP)
|
||||||
|
if Settings.gore:
|
||||||
|
_mesh.add_child(_wound_emitter()) # keep bleeding from the wound while lodged
|
||||||
|
queue_free()
|
||||||
|
return
|
||||||
|
freeze = true
|
||||||
|
global_transform = pose
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dwjakh1is1jtj
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Headless focus tests for the dev console — verifies the command line keeps (or
|
||||||
|
## reclaims) keyboard focus after the actions that were observed to drop it.
|
||||||
|
## Run: godot --headless --script tests/console_focus_test.gd
|
||||||
|
##
|
||||||
|
## Exit code 0 = all passed, 1 = any failure.
|
||||||
|
|
||||||
|
var _passed: int = 0
|
||||||
|
var _failed: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
print("=".repeat(60))
|
||||||
|
print("CONSOLE FOCUS TESTS")
|
||||||
|
print("=".repeat(60))
|
||||||
|
|
||||||
|
await test_focus_after_submit()
|
||||||
|
await test_focus_after_external_steal()
|
||||||
|
|
||||||
|
print("=".repeat(60))
|
||||||
|
print("Results: %d passed, %d failed" % [_passed, _failed])
|
||||||
|
print("=".repeat(60))
|
||||||
|
|
||||||
|
quit(1 if _failed > 0 else 0)
|
||||||
|
|
||||||
|
|
||||||
|
func _assert_true(condition: bool, desc: String) -> void:
|
||||||
|
if condition:
|
||||||
|
_passed += 1
|
||||||
|
print(" PASS: %s" % desc)
|
||||||
|
else:
|
||||||
|
_failed += 1
|
||||||
|
print(" FAIL: %s" % desc)
|
||||||
|
|
||||||
|
|
||||||
|
func _get_console() -> Node:
|
||||||
|
var c: Node = root.get_node_or_null("/root/Console")
|
||||||
|
if c == null:
|
||||||
|
_assert_true(false, "Console autoload should exist")
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
# Drive the real path: open via _toggle (pauses the tree, slide tween grabs focus),
|
||||||
|
# type a command as real key events, submit with a real Enter, and confirm the guard
|
||||||
|
# keeps the command line focused across the paused submit.
|
||||||
|
func test_focus_after_submit() -> void:
|
||||||
|
print("\n-- test_focus_after_submit --")
|
||||||
|
var menu := _get_console()
|
||||||
|
if menu == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
menu._toggle()
|
||||||
|
# Let the open slide tween finish and its grab_focus callback fire.
|
||||||
|
for _i in 20:
|
||||||
|
await process_frame
|
||||||
|
_assert_true(paused, "tree paused while console open")
|
||||||
|
_assert_true(menu._input_field.has_focus(), "input has focus after open")
|
||||||
|
|
||||||
|
menu._input_field.text = "clear"
|
||||||
|
menu._input_field.caret_column = 5
|
||||||
|
_press(KEY_ENTER)
|
||||||
|
# The submit and any focus theft settle over the next few idle frames.
|
||||||
|
for _i in 5:
|
||||||
|
await process_frame
|
||||||
|
_assert_true(menu._input_field.has_focus(), "input still has focus after submit")
|
||||||
|
|
||||||
|
menu._toggle()
|
||||||
|
for _i in 20:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
|
||||||
|
# Push a key down+up through the viewport so LineEdit / _input handle it for real.
|
||||||
|
func _press(keycode: Key) -> void:
|
||||||
|
var down := InputEventKey.new()
|
||||||
|
down.keycode = keycode
|
||||||
|
down.physical_keycode = keycode
|
||||||
|
down.pressed = true
|
||||||
|
root.push_input(down)
|
||||||
|
var up := InputEventKey.new()
|
||||||
|
up.keycode = keycode
|
||||||
|
up.physical_keycode = keycode
|
||||||
|
up.pressed = false
|
||||||
|
root.push_input(up)
|
||||||
|
|
||||||
|
|
||||||
|
# Simulate anything yanking focus away mid-session (a stray click, a UI button); the
|
||||||
|
# guard in _process must pull it straight back while the console is open.
|
||||||
|
func test_focus_after_external_steal() -> void:
|
||||||
|
print("\n-- test_focus_after_external_steal --")
|
||||||
|
var menu := _get_console()
|
||||||
|
if menu == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
menu._is_open = true
|
||||||
|
menu._panel.visible = true
|
||||||
|
menu._input_field.grab_focus()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
menu._input_field.release_focus()
|
||||||
|
_assert_true(not menu._input_field.has_focus(), "focus released (precondition)")
|
||||||
|
|
||||||
|
await process_frame
|
||||||
|
_assert_true(menu._input_field.has_focus(), "guard reclaims focus next frame")
|
||||||
|
|
||||||
|
menu._is_open = false
|
||||||
|
menu._panel.visible = false
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://n6ymmhlqu6wp
|
||||||
@@ -243,9 +243,21 @@ func _check_game_over_wiring(scene: Node) -> void:
|
|||||||
_assert_true(spawners.is_empty() or spawners[0].has_signal(&"all_defeated"),
|
_assert_true(spawners.is_empty() or spawners[0].has_signal(&"all_defeated"),
|
||||||
"spawner exposes an all_defeated signal (win route)")
|
"spawner exposes an all_defeated signal (win route)")
|
||||||
|
|
||||||
|
# The bull now soaks bull_max_hp hits. Zero the i-frame window so the test can
|
||||||
|
# land consecutive blades, then confirm it survives every hit but the last.
|
||||||
|
var dp: Node = root.get_node_or_null("/root/DP")
|
||||||
|
if dp != null:
|
||||||
|
dp.set_value("bull_hit_iframes", 0.0)
|
||||||
|
var max_hp: int = player.call(&"get_max_hp")
|
||||||
|
for i: int in max_hp - 1:
|
||||||
|
player.take_sword_hit()
|
||||||
|
await create_timer(0.05).timeout
|
||||||
|
_assert_true(not hud._game_over, "hit %d of %d does not end the run" % [i + 1, max_hp])
|
||||||
|
_assert_true(player.call(&"get_hp") == 1, "bull sits at 1 HP before the killing blow")
|
||||||
|
|
||||||
player.take_sword_hit()
|
player.take_sword_hit()
|
||||||
await create_timer(0.05).timeout
|
await create_timer(0.05).timeout
|
||||||
_assert_true(hud._game_over, "a single sword hit raises the game-over screen")
|
_assert_true(hud._game_over, "draining the last HP raises the game-over screen")
|
||||||
_assert_true(not hud._result_win, "a bull hit is a loss, not a win")
|
_assert_true(not hud._result_win, "a bull hit is a loss, not a win")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Bakes an animated-billboard sprite sheet from the existing clapping crowd figure
|
||||||
|
## (Animations/mees_plaksutab.glb, clip "ArmatureAction") so a whole stand of
|
||||||
|
## spectators can render as camera-facing quads in one draw call instead of 72 skinned
|
||||||
|
## skeletons. Renders FRAMES evenly-spaced poses from a front orthographic camera on a
|
||||||
|
## transparent background and packs them into a single-row atlas.
|
||||||
|
##
|
||||||
|
## Needs a display — script mode renders to a real window (headless is blank):
|
||||||
|
## godot --script tools/bake_crowd_sheet.gd
|
||||||
|
##
|
||||||
|
## Output: res://Assets/crowd_clap_sheet.png — FRAMES cells wide, TWO rows: the top row
|
||||||
|
## is the front view, the bottom row is the back view. The crowd shader picks the row
|
||||||
|
## with FRONT_FACING so spectators show their backs when the camera is behind them.
|
||||||
|
|
||||||
|
const SRC := "res://Animations/mees_plaksutab.glb"
|
||||||
|
const CLIP := &"ArmatureAction"
|
||||||
|
const OUT := "res://Assets/crowd_clap_sheet.png"
|
||||||
|
const FRAMES := 8
|
||||||
|
const CELL_H := 192 # sprite cell height in px; width derived from the figure aspect
|
||||||
|
const PAD := 1.12 # framing margin around the figure's bounds
|
||||||
|
const FACE_Z := 6.0 # camera distance in front (+Z looks toward -Z)
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
var ps := load(SRC) as PackedScene
|
||||||
|
if ps == null:
|
||||||
|
push_error("bake_crowd_sheet: cannot load %s" % SRC)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
var fig: Node3D = ps.instantiate()
|
||||||
|
|
||||||
|
var vp := SubViewport.new()
|
||||||
|
vp.own_world_3d = true
|
||||||
|
vp.transparent_bg = true
|
||||||
|
vp.msaa_3d = Viewport.MSAA_4X
|
||||||
|
vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||||
|
root.add_child(vp)
|
||||||
|
|
||||||
|
var world := World3D.new()
|
||||||
|
var env := Environment.new()
|
||||||
|
env.background_mode = Environment.BG_CLEAR_COLOR
|
||||||
|
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||||
|
env.ambient_light_color = Color(1, 1, 1)
|
||||||
|
env.ambient_light_energy = 0.7
|
||||||
|
world.environment = env
|
||||||
|
vp.world_3d = world
|
||||||
|
|
||||||
|
var key := DirectionalLight3D.new()
|
||||||
|
key.rotation_degrees = Vector3(-30, 18, 0)
|
||||||
|
key.light_energy = 1.1
|
||||||
|
vp.add_child(key)
|
||||||
|
|
||||||
|
vp.add_child(fig)
|
||||||
|
var ap := _find_anim(fig)
|
||||||
|
if ap == null:
|
||||||
|
push_error("bake_crowd_sheet: no AnimationPlayer in %s" % SRC)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
ap.play(CLIP)
|
||||||
|
ap.pause()
|
||||||
|
var length := ap.get_animation(CLIP).length
|
||||||
|
|
||||||
|
await process_frame
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
# Frame the figure generously from its bind bounds (get_aabb() is the rest pose, so
|
||||||
|
# arms read wide); the tight crop comes from the rendered alpha afterwards.
|
||||||
|
var bounds := _visual_aabb(fig)
|
||||||
|
var center := bounds.get_center()
|
||||||
|
var cap_h := bounds.size.y * 1.25
|
||||||
|
var cap_w := maxf(bounds.size.x, bounds.size.y) * 1.15
|
||||||
|
var vw := int(round(CELL_H * (cap_w / cap_h)))
|
||||||
|
|
||||||
|
vp.size = Vector2i(vw, CELL_H)
|
||||||
|
|
||||||
|
var cam := Camera3D.new()
|
||||||
|
cam.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||||
|
cam.size = cap_h # KEEP_HEIGHT: vertical world extent
|
||||||
|
cam.current = true
|
||||||
|
vp.add_child(cam)
|
||||||
|
|
||||||
|
# Render the loop from the front (camera at +Z, a Camera3D looks down its own -Z) and
|
||||||
|
# from the back (camera at -Z, yawed 180° to look back at the figure).
|
||||||
|
cam.position = center + Vector3(0, 0, FACE_Z)
|
||||||
|
cam.rotation = Vector3.ZERO
|
||||||
|
var fronts: Array = await _grab_frames(vp, ap, length)
|
||||||
|
cam.position = center + Vector3(0, 0, -FACE_Z)
|
||||||
|
cam.rotation = Vector3(0, PI, 0)
|
||||||
|
var backs: Array = await _grab_frames(vp, ap, length)
|
||||||
|
|
||||||
|
# One shared tight crop across every front and back pose so the cells line up.
|
||||||
|
var all := fronts.duplicate()
|
||||||
|
all.append_array(backs)
|
||||||
|
var box := _alpha_bounds(all, 0.12)
|
||||||
|
if box.size == Vector2i.ZERO:
|
||||||
|
push_error("bake_crowd_sheet: rendered frames are fully transparent — check framing/facing")
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
# One pixel of transparent margin so filtering doesn't bleed a hard edge.
|
||||||
|
box = box.grow(1).intersection(Rect2i(0, 0, vw, CELL_H))
|
||||||
|
|
||||||
|
var cw := box.size.x
|
||||||
|
var ch := box.size.y
|
||||||
|
var sheet := Image.create(cw * FRAMES, ch * 2, false, Image.FORMAT_RGBA8)
|
||||||
|
sheet.fill(Color(0, 0, 0, 0))
|
||||||
|
for i in FRAMES:
|
||||||
|
sheet.blit_rect(fronts[i], box, Vector2i(i * cw, 0)) # top row = front
|
||||||
|
sheet.blit_rect(backs[i], box, Vector2i(i * cw, ch)) # bottom row = back
|
||||||
|
|
||||||
|
var err := sheet.save_png(OUT)
|
||||||
|
if err != OK:
|
||||||
|
push_error("bake_crowd_sheet: save_png failed (%d)" % err)
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
print("baked %s %dx%d (%d frames x 2 rows, cell %dx%d, aspect %.3f, figure %.2fm tall)" % [
|
||||||
|
OUT, sheet.get_width(), sheet.get_height(), FRAMES, cw, ch,
|
||||||
|
float(cw) / float(ch), bounds.size.y])
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
# Render every clap pose from the currently-positioned camera into an RGBA8 image array.
|
||||||
|
func _grab_frames(vp: SubViewport, ap: AnimationPlayer, length: float) -> Array:
|
||||||
|
var out: Array[Image] = []
|
||||||
|
for i in FRAMES:
|
||||||
|
ap.seek((float(i) / FRAMES) * length, true)
|
||||||
|
await process_frame
|
||||||
|
await RenderingServer.frame_post_draw
|
||||||
|
var img := vp.get_texture().get_image()
|
||||||
|
img.convert(Image.FORMAT_RGBA8)
|
||||||
|
out.append(img)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# Union bounding box of pixels with alpha above `thresh` across every frame — the
|
||||||
|
# tight crop that holds the whole clap loop.
|
||||||
|
func _alpha_bounds(frames: Array[Image], thresh: float) -> Rect2i:
|
||||||
|
var w := frames[0].get_width()
|
||||||
|
var h := frames[0].get_height()
|
||||||
|
var min_x := w
|
||||||
|
var min_y := h
|
||||||
|
var max_x := -1
|
||||||
|
var max_y := -1
|
||||||
|
for img in frames:
|
||||||
|
for y in h:
|
||||||
|
for x in w:
|
||||||
|
if img.get_pixel(x, y).a > thresh:
|
||||||
|
min_x = mini(min_x, x)
|
||||||
|
min_y = mini(min_y, y)
|
||||||
|
max_x = maxi(max_x, x)
|
||||||
|
max_y = maxi(max_y, y)
|
||||||
|
if max_x < 0:
|
||||||
|
return Rect2i()
|
||||||
|
return Rect2i(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1)
|
||||||
|
|
||||||
|
|
||||||
|
func _find_anim(n: Node) -> AnimationPlayer:
|
||||||
|
if n is AnimationPlayer:
|
||||||
|
return n as AnimationPlayer
|
||||||
|
for c in n.get_children():
|
||||||
|
var r := _find_anim(c)
|
||||||
|
if r != null:
|
||||||
|
return r
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func _visual_aabb(n: Node) -> AABB:
|
||||||
|
var acc := AABB()
|
||||||
|
var have := false
|
||||||
|
for mi in _all_mesh_instances(n):
|
||||||
|
var waabb: AABB = (mi as MeshInstance3D).global_transform * (mi as MeshInstance3D).get_aabb()
|
||||||
|
acc = waabb if not have else acc.merge(waabb)
|
||||||
|
have = true
|
||||||
|
return acc
|
||||||
|
|
||||||
|
|
||||||
|
func _all_mesh_instances(n: Node) -> Array:
|
||||||
|
var out: Array = []
|
||||||
|
if n is MeshInstance3D:
|
||||||
|
out.append(n)
|
||||||
|
for c in n.get_children():
|
||||||
|
out.append_array(_all_mesh_instances(c))
|
||||||
|
return out
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://baa6bdq01fq20
|
||||||
|
After Width: | Height: | Size: 393 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://cf0mk32r40fbb"
|
||||||
|
path="res://.godot/imported/aerial.png-6f0c31ec14797100c056557b8d5f36d8.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://tools/preview/aerial.png"
|
||||||
|
dest_files=["res://.godot/imported/aerial.png-6f0c31ec14797100c056557b8d5f36d8.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
|
||||||
|
After Width: | Height: | Size: 77 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://bx5urw8lnyisv"
|
||||||
|
path="res://.godot/imported/crowd_a.png-03adfaf2f7c0710faf98c43dece7bece.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://tools/preview/crowd_a.png"
|
||||||
|
dest_files=["res://.godot/imported/crowd_a.png-03adfaf2f7c0710faf98c43dece7bece.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
After Width: | Height: | Size: 98 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://4dauthuuuiyn"
|
||||||
|
path="res://.godot/imported/crowd_b.png-6a94563a7a2b29b054811408ccbfe711.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://tools/preview/crowd_b.png"
|
||||||
|
dest_files=["res://.godot/imported/crowd_b.png-6a94563a7a2b29b054811408ccbfe711.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
After Width: | Height: | Size: 346 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://d16gmpff1kvwk"
|
||||||
|
path="res://.godot/imported/scene_crowd.png-79df05acf2fd99f031d42e7312b69019.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://tools/preview/scene_crowd.png"
|
||||||
|
dest_files=["res://.godot/imported/scene_crowd.png-79df05acf2fd99f031d42e7312b69019.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
After Width: | Height: | Size: 465 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://qx85guumsl7p"
|
||||||
|
path="res://.godot/imported/top.png-1fab202f4dd35d955a9e4ea2d0fefe58.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://tools/preview/top.png"
|
||||||
|
dest_files=["res://.godot/imported/top.png-1fab202f4dd35d955a9e4ea2d0fefe58.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
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Quick visual check for the billboard crowd: builds the generated fallback stand and
|
||||||
|
## captures two frames a moment apart (so the flipbook visibly advances) from an angle
|
||||||
|
## (so we can confirm the sprites turn to face the camera).
|
||||||
|
##
|
||||||
|
## godot --script tools/preview_crowd.gd
|
||||||
|
## Output: tools/preview/crowd_a.png, crowd_b.png
|
||||||
|
|
||||||
|
const OUT := "res://tools/preview"
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
DirAccess.make_dir_recursive_absolute(OUT)
|
||||||
|
|
||||||
|
var we := WorldEnvironment.new()
|
||||||
|
var env := Environment.new()
|
||||||
|
env.background_mode = Environment.BG_COLOR
|
||||||
|
env.background_color = Color(0.10, 0.12, 0.16)
|
||||||
|
we.environment = env
|
||||||
|
root.add_child(we)
|
||||||
|
|
||||||
|
var crowd := MultiMeshInstance3D.new()
|
||||||
|
crowd.set_script(load("res://crowd_billboards.gd"))
|
||||||
|
crowd.set("source_path", NodePath()) # force the generated fallback stand
|
||||||
|
crowd.set("fallback_count", 90)
|
||||||
|
root.add_child(crowd)
|
||||||
|
|
||||||
|
var cam := Camera3D.new()
|
||||||
|
cam.fov = 62.0
|
||||||
|
cam.current = true
|
||||||
|
root.add_child(cam)
|
||||||
|
# Outside the ring edge, elevated, looking across the centre: the NEAR arc has its
|
||||||
|
# back to us (facing inward), the FAR arc faces us — so this shot proves both the
|
||||||
|
# per-segment inward facing and the front/back sheet selection.
|
||||||
|
cam.look_at_from_position(Vector3(0.0, 16.0, 44.0), Vector3(0.0, 3.0, 0.0), Vector3.UP)
|
||||||
|
|
||||||
|
await create_timer(0.15).timeout
|
||||||
|
_shot("crowd_a.png")
|
||||||
|
await create_timer(0.45).timeout # ~4–5 clap frames later
|
||||||
|
_shot("crowd_b.png")
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
func _shot(name: String) -> void:
|
||||||
|
var img := root.get_viewport().get_texture().get_image()
|
||||||
|
if img == null:
|
||||||
|
print("capture failed: ", name)
|
||||||
|
return
|
||||||
|
img.save_png(OUT + "/" + name)
|
||||||
|
print("captured ", name, " ", img.get_width(), "x", img.get_height())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bsekj2pxutdqr
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Renders the real arena (scene.tscn) with the billboard crowd wired in, from a camera
|
||||||
|
## near the arena centre, to confirm the stands populate and face inward in-game.
|
||||||
|
## godot --script tools/preview_crowd_scene.gd
|
||||||
|
## Output: tools/preview/scene_crowd.png
|
||||||
|
|
||||||
|
const OUT := "res://tools/preview"
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
DirAccess.make_dir_recursive_absolute(OUT)
|
||||||
|
var ps := load("res://scene.tscn") as PackedScene
|
||||||
|
var scene := ps.instantiate()
|
||||||
|
root.add_child(scene)
|
||||||
|
current_scene = scene
|
||||||
|
|
||||||
|
await create_timer(0.4).timeout
|
||||||
|
|
||||||
|
var cam := Camera3D.new()
|
||||||
|
cam.fov = 74.0
|
||||||
|
scene.add_child(cam)
|
||||||
|
# Out past one stand looking across the arena: the near crowd has its back to us,
|
||||||
|
# the far crowd faces us — the case the front/back sheet must get right.
|
||||||
|
cam.look_at_from_position(Vector3(0.0, 9.0, -22.0), Vector3(0.0, 4.0, 6.0), Vector3.UP)
|
||||||
|
cam.current = true
|
||||||
|
|
||||||
|
await create_timer(0.3).timeout
|
||||||
|
var crowd := scene.get_node_or_null(^"CrowdBillboards") as MultiMeshInstance3D
|
||||||
|
if crowd != null and crowd.multimesh != null:
|
||||||
|
print("crowd instances = ", crowd.multimesh.instance_count)
|
||||||
|
else:
|
||||||
|
print("crowd node/multimesh missing")
|
||||||
|
|
||||||
|
var img := root.get_viewport().get_texture().get_image()
|
||||||
|
if img != null:
|
||||||
|
img.save_png(OUT + "/scene_crowd.png")
|
||||||
|
print("captured scene_crowd.png ", img.get_width(), "x", img.get_height())
|
||||||
|
quit(0)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bapd1uxqlkrmq
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Top-down + angled aerial of the arena with the crowd, plus axis markers, to locate
|
||||||
|
## the ruined section and any billboards left floating over it.
|
||||||
|
## godot --script tools/preview_topdown.gd
|
||||||
|
## Output: tools/preview/top.png (ortho top-down), tools/preview/aerial.png
|
||||||
|
|
||||||
|
const OUT := "res://tools/preview"
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
DirAccess.make_dir_recursive_absolute(OUT)
|
||||||
|
var scene := (load("res://scene.tscn") as PackedScene).instantiate()
|
||||||
|
root.add_child(scene)
|
||||||
|
current_scene = scene
|
||||||
|
|
||||||
|
# Axis markers: red pillar at +X (east), green at +Z (south), so directions read.
|
||||||
|
_marker(scene, Vector3(18, 5, 0), Color(1, 0.2, 0.2))
|
||||||
|
_marker(scene, Vector3(0, 5, 18), Color(0.2, 1, 0.2))
|
||||||
|
|
||||||
|
await create_timer(0.4).timeout
|
||||||
|
var crowd := scene.get_node_or_null(^"CrowdBillboards") as MultiMeshInstance3D
|
||||||
|
if crowd != null and crowd.multimesh != null:
|
||||||
|
print("crowd instances = ", crowd.multimesh.instance_count)
|
||||||
|
|
||||||
|
var top := Camera3D.new()
|
||||||
|
top.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||||
|
top.size = 46.0
|
||||||
|
scene.add_child(top)
|
||||||
|
top.look_at_from_position(Vector3(0, 60, 0.01), Vector3.ZERO, Vector3(0, 0, -1))
|
||||||
|
top.current = true
|
||||||
|
await create_timer(0.2).timeout
|
||||||
|
_shot("top.png")
|
||||||
|
|
||||||
|
var air := Camera3D.new()
|
||||||
|
air.fov = 60.0
|
||||||
|
scene.add_child(air)
|
||||||
|
air.look_at_from_position(Vector3(0, 26, 34), Vector3(0, 3, 0), Vector3.UP)
|
||||||
|
air.current = true
|
||||||
|
await create_timer(0.2).timeout
|
||||||
|
_shot("aerial.png")
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
func _marker(scene: Node, pos: Vector3, col: Color) -> void:
|
||||||
|
var m := MeshInstance3D.new()
|
||||||
|
var box := BoxMesh.new()
|
||||||
|
box.size = Vector3(1.5, 10.0, 1.5)
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
|
mat.albedo_color = col
|
||||||
|
box.material = mat
|
||||||
|
m.mesh = box
|
||||||
|
scene.add_child(m)
|
||||||
|
m.global_position = pos
|
||||||
|
|
||||||
|
|
||||||
|
func _shot(name: String) -> void:
|
||||||
|
var img := root.get_viewport().get_texture().get_image()
|
||||||
|
if img != null:
|
||||||
|
img.save_png(OUT + "/" + name)
|
||||||
|
print("captured ", name)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dfcfb205scjvt
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
extends CanvasLayer
|
||||||
|
## On-screen controls for touch devices: a floating movement joystick on the left
|
||||||
|
## and a cluster of ability buttons on the right. Instantiated by hud.gd.
|
||||||
|
##
|
||||||
|
## The joystick feeds the same `move_*` input actions the keyboard/gamepad drive
|
||||||
|
## (via Input.action_press with an analog strength), so player.gd needs no touch
|
||||||
|
## awareness — it just reads Input.get_vector() as always. Ability buttons call the
|
||||||
|
## player's public try_activate_ability(), which shares the keyboard's cooldown gate.
|
||||||
|
##
|
||||||
|
## Shown only on touch platforms (see Controls.use_touch_ui), or when DP
|
||||||
|
## "force_touch_controls" is on so the layout can be exercised on a desktop build.
|
||||||
|
## emulate_touch_from_mouse (project.godot) lets a mouse stand in for a finger while
|
||||||
|
## testing.
|
||||||
|
|
||||||
|
# move_* actions, indexed by the four joystick push directions.
|
||||||
|
const _MOVE_RIGHT: StringName = &"move_right"
|
||||||
|
const _MOVE_LEFT: StringName = &"move_left"
|
||||||
|
const _MOVE_BACK: StringName = &"move_back"
|
||||||
|
const _MOVE_FORWARD: StringName = &"move_forward"
|
||||||
|
|
||||||
|
# Ability slots in player.gd order: kick=0, dash=1, slam=2, roll=3.
|
||||||
|
const _ABILITY_ICONS: Array[String] = [
|
||||||
|
"res://HUD/KICK.png",
|
||||||
|
"res://HUD/DASH.png",
|
||||||
|
"res://HUD/SLAM.png",
|
||||||
|
"res://HUD/ROLL.png",
|
||||||
|
]
|
||||||
|
const _ABILITY_LETTERS: Array[String] = ["K", "D", "S", "R"]
|
||||||
|
const _ABILITY_ACCENT: Array[Color] = [
|
||||||
|
Color(1.00, 0.62, 0.16), # Kick — ember orange
|
||||||
|
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
|
||||||
|
]
|
||||||
|
|
||||||
|
var _root: Control
|
||||||
|
var _stick: Control
|
||||||
|
var _cd_overlays: Array[ColorRect] = []
|
||||||
|
|
||||||
|
var _player: Node = null
|
||||||
|
|
||||||
|
# Floating-joystick state. _origin is where the thumb first touched (base centre),
|
||||||
|
# _knob is the current thumb position; both in _stick-local coordinates. _touch_index
|
||||||
|
# is the finger owning the stick (-1 = none, -2 = mouse), so a second finger tapping
|
||||||
|
# an ability button never steals the stick.
|
||||||
|
var _stick_active: bool = false
|
||||||
|
var _origin: Vector2 = Vector2.ZERO
|
||||||
|
var _knob: Vector2 = Vector2.ZERO
|
||||||
|
var _touch_index: int = -3
|
||||||
|
var _stick_radius: float = 110.0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
layer = 10 # below the HUD (11) so the game-over screen always draws on top
|
||||||
|
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||||
|
|
||||||
|
_root = Control.new()
|
||||||
|
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_root.visible = false
|
||||||
|
add_child(_root)
|
||||||
|
|
||||||
|
_build_joystick()
|
||||||
|
_build_ability_buttons()
|
||||||
|
_build_menu_button()
|
||||||
|
_relayout()
|
||||||
|
get_viewport().size_changed.connect(_relayout)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Joystick ────────────────────────────────────────────────────────────────
|
||||||
|
# The stick zone is the whole left half of the screen: touching anywhere in it
|
||||||
|
# plants the base under the thumb, so the player never has to find a fixed pad.
|
||||||
|
|
||||||
|
func _build_joystick() -> void:
|
||||||
|
_stick = Control.new()
|
||||||
|
_stick.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
_stick.gui_input.connect(_on_stick_input)
|
||||||
|
_stick.draw.connect(_draw_stick)
|
||||||
|
_root.add_child(_stick)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_stick_input(event: InputEvent) -> void:
|
||||||
|
if event is InputEventScreenTouch:
|
||||||
|
var t := event as InputEventScreenTouch
|
||||||
|
if t.pressed and not _stick_active:
|
||||||
|
_begin_stick(t.position, t.index)
|
||||||
|
elif not t.pressed and _touch_index == t.index:
|
||||||
|
_end_stick()
|
||||||
|
elif event is InputEventScreenDrag:
|
||||||
|
var d := event as InputEventScreenDrag
|
||||||
|
if _touch_index == d.index:
|
||||||
|
_update_stick(d.position)
|
||||||
|
elif event is InputEventMouseButton:
|
||||||
|
var mb := event as InputEventMouseButton
|
||||||
|
if mb.button_index == MOUSE_BUTTON_LEFT:
|
||||||
|
if mb.pressed and not _stick_active:
|
||||||
|
_begin_stick(mb.position, -2)
|
||||||
|
elif not mb.pressed and _touch_index == -2:
|
||||||
|
_end_stick()
|
||||||
|
elif event is InputEventMouseMotion and _touch_index == -2:
|
||||||
|
_update_stick((event as InputEventMouseMotion).position)
|
||||||
|
|
||||||
|
|
||||||
|
func _begin_stick(pos: Vector2, index: int) -> void:
|
||||||
|
_stick_active = true
|
||||||
|
_touch_index = index
|
||||||
|
_origin = pos
|
||||||
|
_knob = pos
|
||||||
|
_stick.queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _update_stick(pos: Vector2) -> void:
|
||||||
|
if not _stick_active:
|
||||||
|
return
|
||||||
|
var offset := pos - _origin
|
||||||
|
if offset.length() > _stick_radius:
|
||||||
|
offset = offset.normalized() * _stick_radius
|
||||||
|
_knob = _origin + offset
|
||||||
|
_apply_move(offset / _stick_radius)
|
||||||
|
_stick.queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _end_stick() -> void:
|
||||||
|
_stick_active = false
|
||||||
|
_touch_index = -3
|
||||||
|
_release_move()
|
||||||
|
_stick.queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
# Translate a normalised stick offset (screen space: +x right, +y down) into analog
|
||||||
|
# strengths on the four movement actions. Opposite pairs are mutually exclusive, so
|
||||||
|
# only one of each axis is ever pressed at a time.
|
||||||
|
func _apply_move(v: Vector2) -> void:
|
||||||
|
Input.action_press(_MOVE_RIGHT, maxf(0.0, v.x))
|
||||||
|
Input.action_press(_MOVE_LEFT, maxf(0.0, -v.x))
|
||||||
|
Input.action_press(_MOVE_BACK, maxf(0.0, v.y))
|
||||||
|
Input.action_press(_MOVE_FORWARD, maxf(0.0, -v.y))
|
||||||
|
|
||||||
|
|
||||||
|
func _release_move() -> void:
|
||||||
|
for action: StringName in [_MOVE_RIGHT, _MOVE_LEFT, _MOVE_BACK, _MOVE_FORWARD]:
|
||||||
|
Input.action_release(action)
|
||||||
|
|
||||||
|
|
||||||
|
func _draw_stick() -> void:
|
||||||
|
if not _stick_active:
|
||||||
|
return
|
||||||
|
var ring := Color(0.93, 0.88, 0.72, 0.30)
|
||||||
|
var knob := Color(1.00, 0.82, 0.32, 0.55)
|
||||||
|
_stick.draw_circle(_origin, _stick_radius, Color(0.0, 0.0, 0.0, 0.20))
|
||||||
|
_stick.draw_arc(_origin, _stick_radius, 0.0, TAU, 48, ring, 4.0, true)
|
||||||
|
_stick.draw_circle(_knob, _stick_radius * 0.42, knob)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ability buttons ─────────────────────────────────────────────────────────
|
||||||
|
# A 2×2 cluster in the bottom-right thumb reach. Each button taps the matching
|
||||||
|
# ability; a dark overlay wipes down over it while that ability is cooling down.
|
||||||
|
|
||||||
|
func _build_ability_buttons() -> void:
|
||||||
|
var grid := GridContainer.new()
|
||||||
|
grid.name = "AbilityGrid"
|
||||||
|
grid.columns = 2
|
||||||
|
grid.add_theme_constant_override("h_separation", 14)
|
||||||
|
grid.add_theme_constant_override("v_separation", 14)
|
||||||
|
grid.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
grid.anchor_left = 1.0
|
||||||
|
grid.anchor_right = 1.0
|
||||||
|
grid.anchor_top = 1.0
|
||||||
|
grid.anchor_bottom = 1.0
|
||||||
|
grid.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||||
|
grid.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||||
|
grid.offset_left = -220.0
|
||||||
|
grid.offset_top = -220.0
|
||||||
|
grid.offset_right = -24.0
|
||||||
|
grid.offset_bottom = -24.0
|
||||||
|
_root.add_child(grid)
|
||||||
|
|
||||||
|
for slot: int in 4:
|
||||||
|
grid.add_child(_build_ability_button(slot))
|
||||||
|
|
||||||
|
|
||||||
|
func _build_ability_button(slot: int) -> Control:
|
||||||
|
var panel := PanelContainer.new()
|
||||||
|
panel.custom_minimum_size = Vector2(96.0, 96.0)
|
||||||
|
panel.add_theme_stylebox_override("panel", _button_style(slot))
|
||||||
|
panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
|
||||||
|
var stack := Control.new()
|
||||||
|
stack.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
panel.add_child(stack)
|
||||||
|
|
||||||
|
if ResourceLoader.exists(_ABILITY_ICONS[slot]):
|
||||||
|
var icon := TextureRect.new()
|
||||||
|
icon.texture = load(_ABILITY_ICONS[slot])
|
||||||
|
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||||
|
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||||
|
icon.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
stack.add_child(icon)
|
||||||
|
else:
|
||||||
|
var lbl := Label.new()
|
||||||
|
lbl.text = _ABILITY_LETTERS[slot]
|
||||||
|
lbl.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
lbl.add_theme_color_override("font_color", _ABILITY_ACCENT[slot])
|
||||||
|
lbl.add_theme_font_size_override("font_size", 40)
|
||||||
|
stack.add_child(lbl)
|
||||||
|
|
||||||
|
# Cooldown wipe — anchored to the bottom, its top edge driven in _process so it
|
||||||
|
# shrinks away as the ability comes off cooldown (mirrors the desktop HUD tiles).
|
||||||
|
var cd := ColorRect.new()
|
||||||
|
cd.color = Color(0.0, 0.0, 0.0, 0.62)
|
||||||
|
cd.anchor_left = 0.0
|
||||||
|
cd.anchor_right = 1.0
|
||||||
|
cd.anchor_top = 1.0
|
||||||
|
cd.anchor_bottom = 1.0
|
||||||
|
cd.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
stack.add_child(cd)
|
||||||
|
_cd_overlays.append(cd)
|
||||||
|
|
||||||
|
# Fire on press (not release) so abilities feel instant under the thumb.
|
||||||
|
panel.gui_input.connect(_on_ability_input.bind(slot))
|
||||||
|
return panel
|
||||||
|
|
||||||
|
|
||||||
|
func _on_ability_input(event: InputEvent, slot: int) -> void:
|
||||||
|
var pressed := false
|
||||||
|
if event is InputEventScreenTouch:
|
||||||
|
pressed = (event as InputEventScreenTouch).pressed
|
||||||
|
elif event is InputEventMouseButton:
|
||||||
|
var mb := event as InputEventMouseButton
|
||||||
|
pressed = mb.pressed and mb.button_index == MOUSE_BUTTON_LEFT
|
||||||
|
if pressed and _player != null:
|
||||||
|
_player.call(&"try_activate_ability", slot)
|
||||||
|
|
||||||
|
|
||||||
|
func _button_style(slot: int) -> StyleBoxFlat:
|
||||||
|
var s := StyleBoxFlat.new()
|
||||||
|
s.bg_color = Color(0.10, 0.07, 0.03, 0.80)
|
||||||
|
s.set_corner_radius_all(18)
|
||||||
|
s.corner_detail = 6
|
||||||
|
s.set_border_width_all(3)
|
||||||
|
s.border_color = _ABILITY_ACCENT[slot]
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
# ── Menu button ──────────────────────────────────────────────────────────────
|
||||||
|
# Top-right "☰" — the touch stand-in for the keyboard's Esc-to-menu, so a phone
|
||||||
|
# player has a way out of a run.
|
||||||
|
|
||||||
|
func _build_menu_button() -> void:
|
||||||
|
var btn := Button.new()
|
||||||
|
btn.text = "☰"
|
||||||
|
btn.custom_minimum_size = Vector2(56.0, 56.0)
|
||||||
|
btn.focus_mode = Control.FOCUS_NONE
|
||||||
|
btn.anchor_left = 1.0
|
||||||
|
btn.anchor_right = 1.0
|
||||||
|
btn.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||||
|
btn.offset_left = -72.0
|
||||||
|
btn.offset_top = 16.0
|
||||||
|
btn.offset_right = -16.0
|
||||||
|
btn.offset_bottom = 72.0
|
||||||
|
var style := StyleBoxFlat.new()
|
||||||
|
style.bg_color = Color(0.10, 0.07, 0.03, 0.80)
|
||||||
|
style.set_corner_radius_all(12)
|
||||||
|
style.set_border_width_all(2)
|
||||||
|
style.border_color = Color(1.00, 0.78, 0.22, 0.8)
|
||||||
|
btn.add_theme_stylebox_override("normal", style)
|
||||||
|
btn.add_theme_stylebox_override("hover", style)
|
||||||
|
btn.add_theme_stylebox_override("pressed", style)
|
||||||
|
btn.add_theme_color_override("font_color", Color(1.00, 0.78, 0.22))
|
||||||
|
btn.add_theme_font_size_override("font_size", 28)
|
||||||
|
btn.pressed.connect(_return_to_menu)
|
||||||
|
_root.add_child(btn)
|
||||||
|
|
||||||
|
|
||||||
|
func _return_to_menu() -> void:
|
||||||
|
get_tree().paused = false
|
||||||
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||||
|
get_tree().change_scene_to_file("res://MainMenu.tscn")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Layout & lifecycle ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func _relayout() -> void:
|
||||||
|
var vp := get_viewport().get_visible_rect().size
|
||||||
|
# Left half is the joystick zone; base radius scales with the shorter screen edge.
|
||||||
|
_stick.position = Vector2.ZERO
|
||||||
|
_stick.size = Vector2(vp.x * 0.5, vp.y)
|
||||||
|
_stick_radius = clampf(minf(vp.x, vp.y) * 0.16, 80.0, 150.0)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
var active := Controls.use_touch_ui()
|
||||||
|
# The game-over / pause screen owns the display when paused — stand down and drop
|
||||||
|
# any held movement so the bull doesn't coast on a stuck joystick.
|
||||||
|
if not active or get_tree().paused:
|
||||||
|
if _root.visible:
|
||||||
|
_root.visible = false
|
||||||
|
if _stick_active:
|
||||||
|
_end_stick()
|
||||||
|
return
|
||||||
|
_root.visible = true
|
||||||
|
|
||||||
|
if _player == null:
|
||||||
|
var players := get_tree().get_nodes_in_group(&"player")
|
||||||
|
if not players.is_empty():
|
||||||
|
_player = players[0]
|
||||||
|
return
|
||||||
|
|
||||||
|
for slot: int in _cd_overlays.size():
|
||||||
|
var fraction: float = _player.call(&"ability_cooldown_fraction", slot)
|
||||||
|
_cd_overlays[slot].anchor_top = 1.0 - clampf(fraction, 0.0, 1.0)
|
||||||
@@ -11,6 +11,7 @@ const _ROMAN := "res://fonts/Cinzel.ttf"
|
|||||||
|
|
||||||
static var _title: FontFile = null
|
static var _title: FontFile = null
|
||||||
static var _body: FontFile = null
|
static var _body: FontFile = null
|
||||||
|
static var _body_bold: FontVariation = null
|
||||||
|
|
||||||
|
|
||||||
## Ornate display face — big banners, menu title, win/lose text.
|
## Ornate display face — big banners, menu title, win/lose text.
|
||||||
@@ -27,6 +28,17 @@ static func body() -> FontFile:
|
|||||||
return _body
|
return _body
|
||||||
|
|
||||||
|
|
||||||
|
## Emboldened Roman face for small, high-legibility bits (keycaps, badges) where
|
||||||
|
## Cinzel's thin regular strokes wash out. Faked via FontVariation since only the
|
||||||
|
## regular weight ships, so no extra .ttf is needed.
|
||||||
|
static func body_bold() -> FontVariation:
|
||||||
|
if _body_bold == null:
|
||||||
|
_body_bold = FontVariation.new()
|
||||||
|
_body_bold.base_font = body()
|
||||||
|
_body_bold.variation_embolden = 0.5
|
||||||
|
return _body_bold
|
||||||
|
|
||||||
|
|
||||||
static func _load(path: String) -> FontFile:
|
static func _load(path: String) -> FontFile:
|
||||||
if ResourceLoader.exists(path):
|
if ResourceLoader.exists(path):
|
||||||
var res := load(path)
|
var res := load(path)
|
||||||
|
|||||||
@@ -21,15 +21,15 @@ const SHADOW := Color(0.00, 0.00, 0.00, 0.55)
|
|||||||
## HUD tiles or CREAM for a hot/hover state; a transparent accent draws a frameless
|
## HUD tiles or CREAM for a hot/hover state; a transparent accent draws a frameless
|
||||||
## plate (still shadowed).
|
## plate (still shadowed).
|
||||||
static func plaque(bg: Color, accent: Color = BORDER,
|
static func plaque(bg: Color, accent: Color = BORDER,
|
||||||
corner: int = 3, h_margin: float = 16.0, v_margin: float = 8.0,
|
corner: int = 1, h_margin: float = 16.0, v_margin: float = 8.0,
|
||||||
shadow: bool = true) -> StyleBoxFlat:
|
shadow: bool = true) -> StyleBoxFlat:
|
||||||
var s := StyleBoxFlat.new()
|
var s := StyleBoxFlat.new()
|
||||||
s.bg_color = bg
|
s.bg_color = bg
|
||||||
s.set_corner_radius_all(corner)
|
s.set_corner_radius_all(corner)
|
||||||
s.corner_detail = 6
|
s.corner_detail = 1
|
||||||
if accent.a > 0.0:
|
if accent.a > 0.0:
|
||||||
s.set_border_width_all(2)
|
s.set_border_width_all(1)
|
||||||
s.border_width_bottom = 5
|
s.border_width_bottom = 2
|
||||||
s.border_color = accent
|
s.border_color = accent
|
||||||
s.content_margin_left = h_margin
|
s.content_margin_left = h_margin
|
||||||
s.content_margin_right = h_margin
|
s.content_margin_right = h_margin
|
||||||
|
|||||||