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:
|
||||
add_to_group(&"public") # excluded from debug overlays — see publikum_controller.gd
|
||||
var anim := get_node_or_null(^"AnimationPlayer") as AnimationPlayer
|
||||
if anim:
|
||||
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="VideoStream" path="res://Assets/intro_bg.ogv" id="2_video"]
|
||||
[ext_resource type="Script" uid="uid://46y10alai56q" path="res://main_menu.gd" id="1_menu"]
|
||||
[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"]
|
||||
content_margin_left = 16.0
|
||||
@@ -30,7 +36,7 @@ corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
|
||||
[node name="MainMenu" type="Control"]
|
||||
[node name="MainMenu" type="Control" unique_id=371457725]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -39,29 +45,55 @@ grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_menu")
|
||||
|
||||
[node name="VideoStreamPlayer" type="VideoStreamPlayer" parent="."]
|
||||
[node name="VideoBlur" type="AspectRatioContainer" parent="." unique_id=374854263]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 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")
|
||||
expand = true
|
||||
|
||||
[node name="MainPanel" type="Control" parent="."]
|
||||
[node name="VideoAspect" type="AspectRatioContainer" parent="." unique_id=374854264]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 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
|
||||
anchor_left = 0.4530
|
||||
anchors_preset = -1
|
||||
anchor_left = 0.453
|
||||
anchor_top = 0.6513
|
||||
anchor_right = 0.4530
|
||||
anchor_right = 0.453
|
||||
anchor_bottom = 0.6513
|
||||
offset_left = -120.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_font_sizes/font_size = 56
|
||||
theme_override_styles/normal = SubResource("sb_normal")
|
||||
theme_override_styles/hover = SubResource("sb_hover")
|
||||
theme_override_styles/pressed = SubResource("sb_hover")
|
||||
theme_override_styles/hover = SubResource("sb_hover")
|
||||
theme_override_styles/focus = SubResource("sb_hover")
|
||||
text = "PLAY"
|
||||
|
||||
[node name="OptionsButton" type="Button" parent="MainPanel"]
|
||||
[node name="MainPanel" type="Control" parent="." unique_id=1263320918]
|
||||
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_top = 0.94
|
||||
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_font_sizes/font_size = 30
|
||||
theme_override_styles/normal = SubResource("sb_normal")
|
||||
theme_override_styles/hover = SubResource("sb_hover")
|
||||
theme_override_styles/pressed = SubResource("sb_hover")
|
||||
theme_override_styles/hover = SubResource("sb_hover")
|
||||
theme_override_styles/focus = SubResource("sb_hover")
|
||||
text = "OPTIONS"
|
||||
|
||||
[node name="CreditsButton" type="Button" parent="MainPanel"]
|
||||
[node name="CreditsButton" type="Button" parent="MainPanel" unique_id=882339461]
|
||||
layout_mode = 1
|
||||
anchors_preset = -1
|
||||
anchor_left = 0.489
|
||||
anchor_top = 0.951
|
||||
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_font_sizes/font_size = 30
|
||||
theme_override_styles/normal = SubResource("sb_normal")
|
||||
theme_override_styles/hover = SubResource("sb_hover")
|
||||
theme_override_styles/pressed = SubResource("sb_hover")
|
||||
theme_override_styles/hover = SubResource("sb_hover")
|
||||
theme_override_styles/focus = SubResource("sb_hover")
|
||||
text = "CREDITS"
|
||||
|
||||
[node name="ExitButton" type="Button" parent="MainPanel"]
|
||||
[node name="ExitButton" type="Button" parent="MainPanel" unique_id=1064586744]
|
||||
layout_mode = 1
|
||||
anchors_preset = -1
|
||||
anchor_left = 0.92
|
||||
anchor_top = 0.951
|
||||
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_font_sizes/font_size = 30
|
||||
theme_override_styles/normal = SubResource("sb_normal")
|
||||
theme_override_styles/hover = SubResource("sb_hover")
|
||||
theme_override_styles/pressed = SubResource("sb_hover")
|
||||
theme_override_styles/hover = SubResource("sb_hover")
|
||||
theme_override_styles/focus = SubResource("sb_hover")
|
||||
text = "EXIT"
|
||||
|
||||
[node name="OptionsPanel" type="CenterContainer" parent="."]
|
||||
[node name="OptionsPanel" type="CenterContainer" parent="." unique_id=593823446]
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
@@ -154,17 +198,17 @@ anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="Backdrop" type="ColorRect" parent="OptionsPanel"]
|
||||
[node name="Backdrop" type="ColorRect" parent="OptionsPanel" unique_id=1811982549]
|
||||
layout_mode = 2
|
||||
color = Color(0, 0, 0, 0.55)
|
||||
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)
|
||||
layout_mode = 2
|
||||
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
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 8
|
||||
@@ -172,31 +216,32 @@ theme_override_font_sizes/font_size = 44
|
||||
text = "Controls"
|
||||
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
|
||||
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."
|
||||
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)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
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
|
||||
size_flags_horizontal = 3
|
||||
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)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 24
|
||||
text = "Back"
|
||||
|
||||
[node name="CreditsPanel" type="CenterContainer" parent="."]
|
||||
[node name="CreditsPanel" type="CenterContainer" parent="." unique_id=175743902]
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
@@ -205,17 +250,17 @@ anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="Backdrop" type="ColorRect" parent="CreditsPanel"]
|
||||
[node name="Backdrop" type="ColorRect" parent="CreditsPanel" unique_id=1950253735]
|
||||
layout_mode = 2
|
||||
color = Color(0, 0, 0, 0.55)
|
||||
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)
|
||||
layout_mode = 2
|
||||
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
|
||||
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
|
||||
theme_override_constants/outline_size = 8
|
||||
@@ -223,25 +268,36 @@ theme_override_font_sizes/font_size = 44
|
||||
text = "Credits"
|
||||
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
|
||||
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_constants/outline_size = 4
|
||||
theme_override_font_sizes/font_size = 24
|
||||
text = "Bullosseum
|
||||
theme_override_constants/line_separation = 6
|
||||
theme_override_constants/outline_size = 5
|
||||
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"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
[b][color=#d9a833]Siim Raud[/color][/b] [url=https://instagram.com/siimpressionist]@siimpressionist[/url]
|
||||
[font_size=20]Artist[/font_size]
|
||||
|
||||
[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)
|
||||
layout_mode = 2
|
||||
theme_override_font_sizes/font_size = 24
|
||||
text = "Back"
|
||||
|
||||
[node name="FadeRect" type="ColorRect" parent="."]
|
||||
[node name="FadeRect" type="ColorRect" parent="." unique_id=15381366]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
|
||||
@@ -92,6 +92,8 @@ func _drag_focus(target: Node3D, delta: float) -> Vector3:
|
||||
|
||||
|
||||
func trigger_hit(strength: float) -> void:
|
||||
if not Settings.screen_shake:
|
||||
return
|
||||
_shake_amount = strength * 0.5
|
||||
_shake_decay = _shake_amount / 0.4
|
||||
|
||||
|
||||
@@ -172,6 +172,18 @@ func _input(event: InputEvent) -> void:
|
||||
if not _is_open:
|
||||
return
|
||||
|
||||
# Ctrl+C clears the input line, Ctrl+L clears the log — familiar shell reflexes.
|
||||
if ke.ctrl_pressed:
|
||||
match ke.keycode:
|
||||
KEY_C:
|
||||
_clear_input()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
KEY_L:
|
||||
_log.clear()
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
|
||||
match ke.keycode:
|
||||
KEY_TAB:
|
||||
_accept_completion()
|
||||
@@ -187,6 +199,20 @@ func _input(event: InputEvent) -> void:
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
# Focus can be stolen a frame or more after we re-grab it (submit, arrows, the tree
|
||||
# unpausing), so a one-shot deferred grab loses the race. Enforce it every frame
|
||||
# while the console is open — cheap, and it never lets the command line go dark.
|
||||
func _process(_delta: float) -> void:
|
||||
if _is_open and not _input_field.has_focus():
|
||||
# Diagnostic: name whatever currently holds focus before we snatch it back, so
|
||||
# a persistent thief (a HUD button, a menu control) shows up in the log instead
|
||||
# of us guessing. Remove once the culprit is identified.
|
||||
var owner: Control = get_viewport().gui_get_focus_owner()
|
||||
var who: String = String(owner.name) if owner != null else "<nothing>"
|
||||
_log_warn("focus stolen by %s — reclaiming" % who)
|
||||
_input_field.grab_focus()
|
||||
|
||||
|
||||
func _toggle() -> void:
|
||||
_is_open = not _is_open
|
||||
get_tree().paused = _is_open
|
||||
@@ -381,6 +407,15 @@ func _history_step(dir: int) -> void:
|
||||
_set_input(_pending_line if _history_idx == -1 else _history[_history_idx])
|
||||
|
||||
|
||||
# Wipe the live line (Ctrl+C) without submitting — also drops any suggestion popup
|
||||
# and resets history browsing so the next ↑ starts fresh.
|
||||
func _clear_input() -> void:
|
||||
_input_field.clear()
|
||||
_history_idx = -1
|
||||
_pending_line = ""
|
||||
_clear_suggestions()
|
||||
|
||||
|
||||
func _set_input(text: String) -> void:
|
||||
_input_field.text = text
|
||||
_input_field.caret_column = text.length()
|
||||
@@ -400,9 +435,6 @@ func _on_submitted(raw: String) -> void:
|
||||
_input_field.clear()
|
||||
_clear_suggestions()
|
||||
_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:
|
||||
|
||||
@@ -15,8 +15,8 @@ const REBINDABLE_ACTIONS: PackedStringArray = [
|
||||
&"move_left",
|
||||
&"move_right",
|
||||
&"ability_kick",
|
||||
&"ability_slam",
|
||||
&"ability_dash",
|
||||
&"ability_slam",
|
||||
&"ability_roll",
|
||||
]
|
||||
|
||||
@@ -25,6 +25,21 @@ func _ready() -> void:
|
||||
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.
|
||||
func get_key_event(action: StringName) -> InputEventKey:
|
||||
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:
|
||||
# The audience ("public") is ambient decoration, not a debug target — skip the whole
|
||||
# crowd subtree so its clapping AnimationPlayers/skeletons don't flood the overlays.
|
||||
if node.is_in_group(&"public"):
|
||||
return
|
||||
if bone and node is Skeleton3D and (node as Skeleton3D).is_visible_in_tree():
|
||||
_skeletons.append(node as Skeleton3D)
|
||||
if anim and node is AnimationPlayer:
|
||||
|
||||
@@ -24,7 +24,7 @@ func _register_all() -> void:
|
||||
# ── Movement ──────────────────────────────────────────────────────────────
|
||||
# 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.
|
||||
_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_friction", 90.0, 5.0, 600.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", "wall_bounce_restitution", 0.6, 0.0, 1.5, 0.05)
|
||||
_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 ─────────────────────────────────────────────────
|
||||
_reg_f("Dust", "dust_walk_spd", 1.5, 0.1, 10.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)
|
||||
# ── Matador ───────────────────────────────────────────────────────────────
|
||||
_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_wander_radius", 22.0, 5.0, 50.0)
|
||||
# Radius of the inner fighting floor: bull + matadors spawn on this ring (so
|
||||
# they stay off the benches). Tune in the console to hug the bench edge.
|
||||
_reg_f("Matador", "arena_spawn_radius", 10.0, 2.0, 22.0, 0.5)
|
||||
_reg_f("Matador", "mat_idle_min", 0.5, 0.0, 5.0, 0.1)
|
||||
_reg_f("Matador", "mat_idle_max", 2.5, 0.5, 10.0, 0.1)
|
||||
_reg_f("Matador", "mat_idle_min", 1.2, 0.0, 5.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_ragdoll_impulse", 6.0, 0.5, 20.0)
|
||||
# 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
|
||||
# the current choice so it doesn't dither between two near-tied options.
|
||||
_reg_f("Matador", "mat_decide_interval", 0.2, 0.05, 1.0, 0.05)
|
||||
_reg_f("Matador", "mat_w_aggression", 1.4, 0.0, 3.0, 0.05)
|
||||
_reg_f("Matador", "mat_w_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_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.
|
||||
_reg_f("Matador", "mat_w_commit", 0.15, 0.0, 2.0, 0.05)
|
||||
# ── 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_y", 0.0, -180.0, 180.0, 1.0)
|
||||
_reg_f("Sword", "sword_rest_rot_z", 0.0, -180.0, 180.0, 1.0)
|
||||
# ── Sword throw (matador ranged attack) ──────────────────────────────────
|
||||
# Delay before a fresh sword appears in the holster after one is thrown.
|
||||
# ── Spear throw (matador ranged attack) ──────────────────────────────────
|
||||
# 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_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_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_speed", 24.0, 5.0, 60.0)
|
||||
_reg_f("Sword", "mat_throw_spin", 14.0, 0.0, 40.0)
|
||||
# Gravity scale on the thrown blade (low = flies straight to the aim point) and
|
||||
# the vertical aim offset from the bull origin down onto its low collision body.
|
||||
_reg_f("Sword", "mat_throw_gravity", 0.25, 0.0, 1.0, 0.05)
|
||||
# Horizontal speed of the thrown spear; the vertical launch is solved from this
|
||||
# and the gravity below so the arc lands on the bull. Lower = a higher, slower lob.
|
||||
_reg_f("Sword", "mat_throw_speed", 11.0, 5.0, 60.0)
|
||||
# Gravity scale on the spear (higher = a more pronounced arc) and the vertical aim
|
||||
# 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)
|
||||
# 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)
|
||||
@@ -198,7 +239,7 @@ func _register_all() -> void:
|
||||
_reg_f("Sword", "mat_throw_elbow", 80.0,-180.0, 180.0, 1.0)
|
||||
# ── Abilities ─────────────────────────────────────────────────────────────
|
||||
_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", "slam_cooldown", 4.0, 0.5, 15.0, 0.1)
|
||||
_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_range", 4.0, 1.0, 10.0, 0.1)
|
||||
_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)
|
||||
# ── 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
|
||||
# roll_base_speed up to roll_max_speed over roll_rampup_time. Turning is wide
|
||||
# (momentum steering via roll_turn). On slamming into a matador the ball POPS:
|
||||
# the target is launched skyward (roll_knockup) and the roll ends.
|
||||
# roll_base_speed up to roll_max_speed over roll_rampup_time. It HOMES on the
|
||||
# closest matador; on slamming one the ball POPS it (launched skyward via
|
||||
# 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_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_max_speed", 95.0, 10.0, 200.0) # top speed once fully ramped
|
||||
_reg_f("Roll", "roll_rampup_time", 2.0, 0.2, 6.0, 0.1) # time to reach top speed
|
||||
_reg_f("Roll", "roll_turn", 7.0, 0.2, 20.0) # steer rate (WASD/arrows; higher = tighter)
|
||||
_reg_f("Roll", "roll_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_hit_radius", 2.4, 0.5, 6.0, 0.1) # contact radius that pops the ball
|
||||
_reg_f("Roll", "roll_hit_strength", 22.0, 1.0, 40.0) # horizontal launch on the pop
|
||||
_reg_f("Roll", "roll_knockup", 11.0, 0.0, 25.0) # vertical knock-up on the pop
|
||||
# ── Debug overlays (see debug_draw.gd) ───────────────────────────────────
|
||||
_reg_b("Debug", "show_collisions", false)
|
||||
# PS1 post-process filter (see ps1_filter.gd on the fullscreen ColorRect):
|
||||
# screen-covering CRT/PS1 shader — pixelation, colour-banding + dither, and a
|
||||
# rolling scanline. Keys map 1:1 onto the shader uniforms; show_ps1 toggles the
|
||||
# whole overlay. Ranges mirror the shader's hint_range so the console can't push
|
||||
# a uniform out of bounds.
|
||||
_reg_b("PS1", "show_ps1", true)
|
||||
_reg_f("PS1", "ps1_pixel_scale", 4.0, 1.0, 8.0, 1.0)
|
||||
_reg_f("PS1", "ps1_color_depth", 5.0, 2.0, 8.0, 1.0)
|
||||
_reg_f("PS1", "ps1_dither_strength", 0.8, 0.0, 2.0, 0.05)
|
||||
_reg_f("PS1", "ps1_scanline_strength", 0.2, 0.0, 1.0, 0.05)
|
||||
_reg_f("PS1", "ps1_roll_speed", 3.0, 0.0, 10.0, 0.1)
|
||||
_reg_f("PS1", "ps1_roll_strength", 1.0, 0.0, 1.0, 0.05)
|
||||
_reg_b("Debug", "show_hitboxes", false)
|
||||
_reg_b("Debug", "show_bones", false)
|
||||
_reg_b("Debug", "show_raycasts", false)
|
||||
@@ -238,6 +296,9 @@ func _register_all() -> void:
|
||||
_reg_b("Debug", "show_wireframe", false)
|
||||
_reg_b("Debug", "show_grid", 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,
|
||||
|
||||
@@ -1,17 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Run from the project root regardless of where the script is invoked.
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
SERVER_USER="richard"
|
||||
SERVER_HOST="192.168.1.168"
|
||||
SERVER_PATH="/opt/bulloseum/data"
|
||||
|
||||
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"
|
||||
godot --headless --export-release "Web" "$BUILD_DIR/index.html"
|
||||
|
||||
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" \
|
||||
"$BUILD_DIR/index.js" \
|
||||
"$BUILD_DIR/index.wasm.gz" \
|
||||
@@ -21,6 +43,7 @@ scp "$BUILD_DIR/index.html" \
|
||||
"$BUILD_DIR/index.icon.png" \
|
||||
"$BUILD_DIR/index.apple-touch-icon.png" \
|
||||
"$BUILD_DIR/index.png" \
|
||||
"$BUILD_DIR/version.txt" \
|
||||
"$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)
|
||||
|
||||
# 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] = [
|
||||
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.98, 0.34, 0.20), # Slam — crimson
|
||||
Color(1.00, 0.82, 0.32), # Roll — gold
|
||||
]
|
||||
|
||||
const _ABILITY_ICONS: Array[String] = [
|
||||
"res://HUD/KICK.png",
|
||||
"res://HUD/SLAM.png",
|
||||
"res://HUD/DASH.png",
|
||||
"res://HUD/SLAM.png",
|
||||
"res://HUD/ROLL.png",
|
||||
]
|
||||
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.
|
||||
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_panel: PanelContainer
|
||||
@@ -34,18 +34,43 @@ var _player: Node = null
|
||||
var _died_connected: bool = false
|
||||
var _cd_overlays: Array[ColorRect] = []
|
||||
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_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
|
||||
# records which way it went (read by the gameplay test).
|
||||
const _WIN_VIDEO := "res://videos/bull_win.ogv" # the bull triumphs
|
||||
const _LOSS_VIDEO := "res://videos/matador_win.ogv" # the matador triumphs
|
||||
|
||||
# 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 _result_win: bool = false
|
||||
var _over_root: Control
|
||||
var _over_video: VideoStreamPlayer
|
||||
var _over_label: Label
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -53,12 +78,85 @@ func _ready() -> void:
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
_build_controls_panel()
|
||||
_build_tab_hint()
|
||||
_build_bottom_hud()
|
||||
_build_bull_hud_bg()
|
||||
_build_ability_bar()
|
||||
_build_health()
|
||||
_build_version_label()
|
||||
_build_damage_vignette()
|
||||
_build_game_over()
|
||||
_build_touch_controls()
|
||||
_update_control_hints()
|
||||
_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.
|
||||
func _build_version_label() -> void:
|
||||
var lbl := Label.new()
|
||||
@@ -75,6 +173,7 @@ func _build_version_label() -> void:
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
_update_control_hints()
|
||||
if _player == null:
|
||||
var players := get_tree().get_nodes_in_group(&"player")
|
||||
if not players.is_empty():
|
||||
@@ -83,6 +182,9 @@ func _process(_delta: float) -> void:
|
||||
if not _died_connected:
|
||||
_died_connected = true
|
||||
_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():
|
||||
var fraction: float = _player.call(&"ability_cooldown_fraction", i)
|
||||
var ov := _cd_overlays[i]
|
||||
@@ -129,9 +231,48 @@ func _restart() -> void:
|
||||
|
||||
func _toggle_controls() -> void:
|
||||
_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"
|
||||
_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 ──────────────────────────────────────────────────────────────
|
||||
@@ -157,8 +298,11 @@ func _show_game_over(win: bool, _cause: String = "") -> void:
|
||||
if _tab_hint:
|
||||
_tab_hint.visible = false
|
||||
|
||||
# Swap in the matching outcome clip and loop it behind the buttons.
|
||||
var path := _WIN_VIDEO if win else _LOSS_VIDEO
|
||||
await get_tree().create_timer(_WIN_DELAY if win else _LOSS_DELAY, false, false, true).timeout
|
||||
|
||||
_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):
|
||||
_over_video.stream = load(path)
|
||||
_over_video.play()
|
||||
@@ -179,13 +323,40 @@ func _build_game_over() -> void:
|
||||
add_child(_over_root)
|
||||
|
||||
# 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.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_over_video.expand = true
|
||||
_over_video.process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
_over_video.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_over_video.finished.connect(_over_video.play) # loop the outcome clip
|
||||
_over_root.add_child(_over_video)
|
||||
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.
|
||||
var row := HBoxContainer.new()
|
||||
@@ -208,6 +379,97 @@ func _build_game_over() -> void:
|
||||
menu.pressed.connect(_return_to_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
|
||||
|
||||
@@ -215,14 +477,9 @@ const _SLOT_SEPARATION: int = 10
|
||||
func _build_ability_bar() -> void:
|
||||
_ability_bar = HBoxContainer.new()
|
||||
_ability_bar.add_theme_constant_override("separation", _SLOT_SEPARATION)
|
||||
_ability_bar.anchor_left = 0.5
|
||||
_ability_bar.anchor_right = 0.5
|
||||
_ability_bar.anchor_top = 1.0
|
||||
_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)
|
||||
_ability_bar.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||
_ability_bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_bottom_col.add_child(_ability_bar)
|
||||
for i: int in 4:
|
||||
_ability_bar.add_child(_build_ability_slot(i))
|
||||
_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
|
||||
# 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:
|
||||
var vp := get_viewport().get_visible_rect().size
|
||||
var slot := clampf(minf(vp.x * 0.055, vp.y * 0.11), 56.0, 104.0)
|
||||
for panel: PanelContainer in _ability_panels:
|
||||
panel.custom_minimum_size = Vector2(slot, slot)
|
||||
var total := slot * 4.0 + float(_SLOT_SEPARATION) * 3.0
|
||||
_ability_bar.offset_left = -total * 0.5
|
||||
_ability_bar.offset_right = total * 0.5
|
||||
|
||||
|
||||
func _build_ability_slot(idx: int) -> Control:
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 4)
|
||||
vbox.add_theme_constant_override("separation", 3)
|
||||
|
||||
var panel := PanelContainer.new()
|
||||
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)
|
||||
_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()
|
||||
key_lbl.text = Controls.get_key_label(_ABILITY_ACTIONS[idx])
|
||||
key_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
key_lbl.add_theme_font_override("font", UiFonts.body())
|
||||
key_lbl.add_theme_color_override("font_color", _ABILITY_ACCENT[idx].lerp(_CREAM, 0.35))
|
||||
key_lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
|
||||
key_lbl.text = Controls.get_key_label(_ABILITY_ACTIONS[idx])
|
||||
key_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
key_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
key_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
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_font_size_override("font_size", 13)
|
||||
vbox.add_child(key_lbl)
|
||||
key_lbl.add_theme_font_size_override("font_size", 14)
|
||||
keycap.add_child(key_lbl)
|
||||
|
||||
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:
|
||||
_tab_hint = Label.new()
|
||||
_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, 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_slam"), "Slam", _CREAM)
|
||||
_row(vbox, Controls.get_key_label(&"ability_roll"), "Roll (Powerball: ram the matador!)", _CREAM)
|
||||
_row(vbox, "Scroll", "Zoom", _CREAM)
|
||||
_row(vbox, "Tab", "Toggle controls", _MUTED)
|
||||
|
||||
@@ -57,23 +57,26 @@ const ACTION_LABELS: Dictionary = {
|
||||
&"move_left": "Move Left",
|
||||
&"move_right": "Move Right",
|
||||
&"ability_kick": "Kick",
|
||||
&"ability_slam": "Slam",
|
||||
&"ability_dash": "Dash",
|
||||
&"ability_slam": "Slam",
|
||||
&"ability_roll": "Roll",
|
||||
}
|
||||
|
||||
@onready var main_panel: Control = $MainPanel
|
||||
@onready var options_panel: Control = $OptionsPanel
|
||||
@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 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 credits_button: Button = $MainPanel/CreditsButton
|
||||
@onready var exit_button: Button = $MainPanel/ExitButton
|
||||
@onready var options_back: Button = $OptionsPanel/VBox/BackButton
|
||||
@onready var credits_back: Button = $CreditsPanel/VBox/BackButton
|
||||
@onready var video: VideoStreamPlayer = $VideoStreamPlayer
|
||||
@onready var video: VideoStreamPlayer = $VideoAspect/VideoStreamPlayer
|
||||
@onready var blur_video: VideoStreamPlayer = $VideoBlur/BlurVideo
|
||||
|
||||
var _listening_action: StringName = &""
|
||||
var _listening_button: Button = null
|
||||
@@ -87,6 +90,9 @@ func _ready() -> void:
|
||||
if video.stream != null:
|
||||
video.play()
|
||||
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)
|
||||
options_button.pressed.connect(_on_options_pressed)
|
||||
@@ -96,6 +102,7 @@ func _ready() -> void:
|
||||
credits_back.pressed.connect(_close_subpanels)
|
||||
_apply_theme()
|
||||
_build_version_label()
|
||||
_build_options_toggles()
|
||||
_build_rebind_rows()
|
||||
play_button.grab_focus()
|
||||
|
||||
@@ -130,11 +137,24 @@ func _apply_theme() -> void:
|
||||
if title != null:
|
||||
title.add_theme_font_override("font", UiFonts.title())
|
||||
title.add_theme_color_override("font_color", MENU_GOLD)
|
||||
for path: String in ["OptionsPanel/VBox/Hint", "CreditsPanel/VBox/Names"]:
|
||||
var lbl := get_node_or_null(path) as Label
|
||||
if lbl != null:
|
||||
lbl.add_theme_font_override("font", UiFonts.body())
|
||||
lbl.add_theme_color_override("font_color", MENU_CREAM)
|
||||
var hint := get_node_or_null("OptionsPanel/VBox/Hint") as Label
|
||||
if hint != null:
|
||||
hint.add_theme_font_override("font", UiFonts.body())
|
||||
hint.add_theme_color_override("font_color", MENU_CREAM)
|
||||
_style_credits(get_node_or_null("CreditsPanel/VBox/Names") as RichTextLabel)
|
||||
|
||||
|
||||
# The credits use a RichTextLabel so the Instagram handles are clickable links
|
||||
# ([url] tags). meta_clicked opens the profile — in the web export OS.shell_open
|
||||
# pops a new browser tab.
|
||||
func _style_credits(names: RichTextLabel) -> void:
|
||||
if names == null:
|
||||
return
|
||||
names.add_theme_font_override("normal_font", UiFonts.body())
|
||||
names.add_theme_font_override("bold_font", UiFonts.title())
|
||||
names.add_theme_color_override("default_color", MENU_CREAM)
|
||||
names.meta_clicked.connect(func(meta: Variant) -> void:
|
||||
OS.shell_open(str(meta)))
|
||||
|
||||
|
||||
func _style_button(btn: Button, big: bool) -> void:
|
||||
@@ -170,6 +190,40 @@ func _process(_delta: float) -> void:
|
||||
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:
|
||||
for child in rebind_list.get_children():
|
||||
child.queue_free()
|
||||
@@ -182,9 +236,10 @@ func _build_rebind_rows() -> void:
|
||||
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", 4)
|
||||
name_label.add_theme_constant_override("outline_size", 5)
|
||||
row.add_child(name_label)
|
||||
|
||||
var key_button := Button.new()
|
||||
@@ -207,12 +262,14 @@ func _on_play_pressed() -> void:
|
||||
|
||||
func _on_options_pressed() -> void:
|
||||
main_panel.visible = false
|
||||
play_button.visible = false
|
||||
options_panel.visible = true
|
||||
options_back.grab_focus()
|
||||
|
||||
|
||||
func _on_credits_pressed() -> void:
|
||||
main_panel.visible = false
|
||||
play_button.visible = false
|
||||
credits_panel.visible = true
|
||||
credits_back.grab_focus()
|
||||
|
||||
@@ -226,6 +283,7 @@ func _close_subpanels() -> void:
|
||||
options_panel.visible = false
|
||||
credits_panel.visible = false
|
||||
main_panel.visible = true
|
||||
play_button.visible = true
|
||||
play_button.grab_focus()
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ var _sim: PhysicalBoneSimulator3D = null
|
||||
var _anim_player: AnimationPlayer = null
|
||||
var _wander_target: Vector3 = Vector3.ZERO
|
||||
var _idle_timer: float = 0.0
|
||||
var _taunt_swap: float = 0.0
|
||||
var _idle_anim: StringName = _ANIM_IDLE
|
||||
var _bull: CharacterBody3D = null
|
||||
var _step_dir: Vector3 = Vector3.ZERO
|
||||
@@ -50,8 +51,8 @@ var _drawing: bool = false
|
||||
var _sheathing: bool = false
|
||||
var _draw_timer: float = 0.0
|
||||
var _draw_len: float = 0.0
|
||||
var _resword_timer: float = 0.0
|
||||
var _sword_blade_area: Area3D = null
|
||||
var _spear_node: Node3D = null
|
||||
var _blade_hit_cd: float = 0.0
|
||||
var _death_player: AudioStreamPlayer = 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 _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_INTERVAL: float = 0.12 # recompute direction at most ~8×/sec
|
||||
@@ -95,8 +99,9 @@ func _ready() -> void:
|
||||
if _skeleton:
|
||||
_sim = MatadorRagdoll.build(_skeleton)
|
||||
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)
|
||||
_flatten_roll_travel()
|
||||
if not _anim_player.has_animation(_ANIM_RUN):
|
||||
push_warning("Matador: expected animations not found. Available: %s" %
|
||||
str(_anim_player.get_animation_list()))
|
||||
@@ -104,7 +109,7 @@ func _ready() -> void:
|
||||
_anim_player.play(_ANIM_IDLE)
|
||||
_setup_sword()
|
||||
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)
|
||||
_pick_wander_target()
|
||||
_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)
|
||||
_blade_hit_cd = maxf(0.0, _blade_hit_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:
|
||||
_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) \
|
||||
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 := {
|
||||
# Only drift when the bull is out past throw range; up close this is ~0.
|
||||
Intent.WANDER: DP.f("mat_w_showmanship") * 0.3 * beyond,
|
||||
# Showmanship: circle the bull at a taunting distance and pose for the crowd.
|
||||
Intent.WANDER: DP.f("mat_w_showmanship") * strut,
|
||||
# Retreat only when the bull is close AND actually dangerous.
|
||||
Intent.FLEE: DP.f("mat_w_flee") * close * (0.25 + 0.75 * danger),
|
||||
# Press the attack; ease off a touch when a charge is barrelling in.
|
||||
# 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) \
|
||||
* (1.0 - 0.55 * danger) * dodge_ready,
|
||||
* (1.0 - 0.55 * danger) * dodge_ready * engage_mult,
|
||||
# Sidestep / plant against an incoming charge.
|
||||
Intent.DODGE: DP.f("mat_w_caution") * danger * (1.0 if _dodge_cd <= 0.0 else 0.0),
|
||||
# Hurl the blade whenever there's a clean lane at range — the ranged threat
|
||||
# that punishes a bull for hanging back out of horn reach.
|
||||
Intent.THROW: ((DP.f("mat_w_showmanship") * 0.55 + DP.f("mat_w_aggression") * 0.6) \
|
||||
* (0.35 + 0.65 * (1.0 - close))) if throw_ok else -1.0,
|
||||
# Hurl the blade — an occasional ranged flourish from an engaging matador when
|
||||
# there's a clean lane, not a barrage (see mat_w_throw). Supporters never pelt.
|
||||
Intent.THROW: (DP.f("mat_w_throw") * (0.35 + 0.65 * (1.0 - close))) \
|
||||
if (throw_ok and is_engager) else -1.0,
|
||||
}
|
||||
score[_intent] = float(score[_intent]) + DP.f("mat_w_commit")
|
||||
|
||||
@@ -277,6 +286,40 @@ func _enter_intent(intent: Intent) -> void:
|
||||
_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
|
||||
func _is_charge_incoming() -> bool:
|
||||
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:
|
||||
_idle_timer = maxf(0.0, _idle_timer - 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)
|
||||
move_and_slide()
|
||||
return
|
||||
@@ -358,8 +409,12 @@ func _tick_wander(delta: float) -> void:
|
||||
var to_target := Vector3(
|
||||
_wander_target.x - global_position.x, 0.0, _wander_target.z - global_position.z)
|
||||
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()
|
||||
_taunt_swap = randf_range(1.5, 3.0)
|
||||
_pick_wander_target()
|
||||
else:
|
||||
var dir := _steer_clear(to_target.normalized(), delta)
|
||||
@@ -528,7 +583,8 @@ func _start_throw() -> void:
|
||||
_throw_timer = DP.f("mat_throw_windup")
|
||||
velocity = Vector3.ZERO
|
||||
_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:
|
||||
_anim_player.pause()
|
||||
var to_bull := _bull.global_position - global_position
|
||||
@@ -552,8 +608,8 @@ func _tick_throw(delta: float) -> void:
|
||||
_pose_throw_arm(phase)
|
||||
|
||||
var t_rel := DP.f("mat_throw_release")
|
||||
if prev < t_rel and phase >= t_rel and is_instance_valid(_sword_node):
|
||||
_release_sword()
|
||||
if prev < t_rel and phase >= t_rel and is_instance_valid(_spear_node):
|
||||
_release_spear()
|
||||
|
||||
if _throw_timer <= 0.0:
|
||||
_end_throw()
|
||||
@@ -562,20 +618,18 @@ func _tick_throw(delta: float) -> void:
|
||||
|
||||
func _end_throw() -> void:
|
||||
_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")
|
||||
# Space throws out so the utility brain doesn't immediately vote another one,
|
||||
# but keep them frequent enough to pressure a bull that hangs back at range.
|
||||
_throw_aim_cd = randf_range(1.6, 3.2)
|
||||
# Space throws well apart so a hurl stays a flourish, not a barrage.
|
||||
_throw_aim_cd = randf_range(4.0, 7.0)
|
||||
_intent = Intent.FLEE
|
||||
# Blend straight into locomotion so the manually-posed throw arm eases out
|
||||
# (a bare resume() would snap and leave the player on whatever it paused on).
|
||||
# Blend to locomotion so the manually-posed throw arm eases out (resume() snaps).
|
||||
if _anim_player:
|
||||
_anim_player.play(_ANIM_RUN)
|
||||
|
||||
|
||||
# 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);
|
||||
# angles are DP-tunable so the arc can be dialled in per rig.
|
||||
# AnimationPlayer is paused. Swing is on bone-local X (see CLAUDE.md), DP-tunable.
|
||||
func _pose_throw_arm(phase: float) -> void:
|
||||
if not _skeleton:
|
||||
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)))
|
||||
|
||||
|
||||
# Detach the sword from the hand and launch it as a spinning physics projectile
|
||||
# that damages the bull on contact, then settles and despawns.
|
||||
func _release_sword() -> void:
|
||||
if not is_instance_valid(_sword_node):
|
||||
# Draw a fresh spear into the throwing hand for the wind-up (no melee collider).
|
||||
func _spawn_spear_in_hand() -> void:
|
||||
if is_instance_valid(_spear_node) or _sword_hand_attach == null:
|
||||
return
|
||||
var sword := _sword_node
|
||||
var blade := _sword_blade_area
|
||||
var gx := sword.global_transform
|
||||
_sword_node = null
|
||||
_sword_blade_area = null
|
||||
_sword_in_hand = false
|
||||
_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
|
||||
_spear_node = _SPEAR_SCENE.instantiate() as Node3D
|
||||
_sword_hand_attach.add_child(_spear_node)
|
||||
_spear_node.position = Vector3(
|
||||
DP.f("spear_pos_x"), DP.f("spear_pos_y"), DP.f("spear_pos_z"))
|
||||
_spear_node.rotation_degrees = Vector3(
|
||||
DP.f("spear_rot_x"), DP.f("spear_rot_y"), DP.f("spear_rot_z"))
|
||||
|
||||
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
|
||||
# the mesh kept its global transform and rb adopted it. Fattened so a fast throw
|
||||
# reliably overlaps the bull's collision spheres instead of tunnelling past.
|
||||
var cap := CapsuleShape3D.new()
|
||||
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)
|
||||
func _clear_spear() -> void:
|
||||
if is_instance_valid(_spear_node):
|
||||
_spear_node.queue_free()
|
||||
_spear_node = null
|
||||
|
||||
# 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
|
||||
# keeps its vertical component so the blade drives into the body, not over it.
|
||||
var speed := maxf(DP.f("mat_throw_speed"), 0.1)
|
||||
var dir := (_throw_dir + Vector3.UP * 0.0)
|
||||
|
||||
# Hurl the held spear on a ballistic arc that lands on the bull's low body. The
|
||||
# launch velocity is solved so gravity (mat_throw_gravity) carries it down onto the
|
||||
# lead point; SpearProjectile aims the shaft along the arc and embeds it on a hit.
|
||||
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):
|
||||
var target := _bull.global_position + Vector3(0.0, DP.f("mat_throw_aim_y"), 0.0)
|
||||
var flat := target - gx.origin
|
||||
flat.y = 0.0
|
||||
var flight := flat.length() / speed
|
||||
target += _bull.velocity * flight
|
||||
var to_target := target - gx.origin
|
||||
if to_target.length() > 0.1:
|
||||
dir = to_target.normalized()
|
||||
# A little aim scatter so throws are a threat to READ and dodge, not a hitscan —
|
||||
# a bull that keeps moving can slip them, a stationary one gets pinned.
|
||||
target = _bull.global_position + Vector3(0.0, DP.f("mat_throw_aim_y"), 0.0)
|
||||
var lead := target - origin
|
||||
lead.y = 0.0
|
||||
target += _bull.velocity * (lead.length() / hs)
|
||||
var flat := target - origin
|
||||
var rise := flat.y
|
||||
flat.y = 0.0
|
||||
var dist := flat.length()
|
||||
var hdir := flat.normalized() if dist > 0.01 else _throw_dir
|
||||
# Aim scatter so the arc can be read and sidestepped, not a hitscan.
|
||||
var spread := deg_to_rad(DP.f("mat_throw_spread"))
|
||||
dir = dir.rotated(Vector3.UP, randf_range(-spread, spread)).normalized()
|
||||
rb.linear_velocity = dir * speed
|
||||
rb.angular_velocity = dir.cross(Vector3.UP).normalized() * -DP.f("mat_throw_spin")
|
||||
|
||||
var hit_done := [false]
|
||||
rb.body_entered.connect(func(body: Node) -> void:
|
||||
if hit_done[0]:
|
||||
return
|
||||
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()
|
||||
)
|
||||
hdir = hdir.rotated(Vector3.UP, randf_range(-spread, spread)).normalized()
|
||||
# Ballistic solve: with horizontal speed hs over distance dist, the flight lasts t;
|
||||
# pick the vertical launch speed that lands the arc on the aim point's height.
|
||||
var t := dist / hs
|
||||
var vy := (rise + 0.5 * g * t * t) / t
|
||||
var vel := hdir * hs + Vector3.UP * vy
|
||||
SpearProjectile.launch(get_tree().current_scene, spear, origin, vel,
|
||||
maxf(DP.f("mat_throw_gravity"), 0.05))
|
||||
|
||||
|
||||
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
|
||||
if _sword_blade_area:
|
||||
_sword_blade_area.monitoring = false
|
||||
_clear_spear() # a spear mid-wind-up dies with the matador, not in the air
|
||||
killed.emit()
|
||||
var cleanup_timer := get_tree().create_timer(4.0)
|
||||
cleanup_timer.timeout.connect(func() -> void:
|
||||
@@ -866,6 +898,32 @@ func _ensure_loop(anim_name: StringName) -> void:
|
||||
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:
|
||||
var choices: Array[StringName] = []
|
||||
for anim in _ANIM_TAUNTS:
|
||||
@@ -893,13 +951,25 @@ func _play_anim(anim_name: StringName) -> void:
|
||||
|
||||
|
||||
func _pick_wander_target() -> void:
|
||||
# 35% chance: pick a point orbiting close to the bull for a daring pass
|
||||
if _bull != null and randf() < 0.35:
|
||||
var angle := randf() * TAU
|
||||
var r := randf_range(1.5, 4.0)
|
||||
# Circle the bull for showmanship: mostly settle on the taunt ring (spreading the
|
||||
# crowd out around it), with an occasional close pass for daring flair.
|
||||
if _bull != null:
|
||||
var bull_flat := _bull.global_position
|
||||
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)
|
||||
# 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
|
||||
var radius := DP.f("mat_wander_radius")
|
||||
var angle := randf() * TAU
|
||||
@@ -909,13 +979,10 @@ func _pick_wander_target() -> void:
|
||||
|
||||
# ── Sword attachment ──────────────────────────────────────────────────────────
|
||||
|
||||
# The rig (matador_v03) carries two purpose-built bones for the sword:
|
||||
# weapon_bone — the grip pose in the right hand (drawn / fighting)
|
||||
# weapon_rest_bone — the holstered pose at the hip (sheathed / wandering)
|
||||
# The sword lives in the holster by default and is carried to the hand while the
|
||||
# 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.
|
||||
# The rig (matador_v03) carries two purpose-built bones: weapon_bone (right-hand
|
||||
# grip) and weapon_rest_bone (hip holster). The sword rides in the holster and is
|
||||
# carried to the hand in combat (see _carry_sword); each bone has its own DP-tunable
|
||||
# local offset/rotation (sword_* / sword_rest_*) so the poses seat independently.
|
||||
func _setup_sword() -> void:
|
||||
if not _skeleton:
|
||||
return
|
||||
@@ -934,8 +1001,7 @@ func _setup_sword() -> void:
|
||||
_spawn_sword()
|
||||
|
||||
|
||||
# Instantiate a fresh sword into the holster, with its own blade collider. Called
|
||||
# at spawn and again after a throw, so a matador can keep drawing replacements.
|
||||
# Instantiate the matador's sword into the holster, with its own blade collider.
|
||||
func _spawn_sword() -> void:
|
||||
if is_instance_valid(_sword_node):
|
||||
return
|
||||
@@ -949,15 +1015,14 @@ func _spawn_sword() -> void:
|
||||
_drawing = false
|
||||
_apply_sword_grip()
|
||||
|
||||
# Blade collider spans the steel (hilt at local origin, blade running +Z out
|
||||
# to ~1.4 m). Only monitors while the sword is in hand (see _carry_sword).
|
||||
# Blade collider along +Z (hilt at origin), tipped from the capsule's default Y.
|
||||
# Only monitors while the sword is in hand (see _carry_sword).
|
||||
var blade_cap := CapsuleShape3D.new()
|
||||
blade_cap.radius = 0.18
|
||||
blade_cap.height = 1.30
|
||||
var blade_cs := CollisionShape3D.new()
|
||||
blade_cs.shape = blade_cap
|
||||
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)
|
||||
|
||||
_sword_blade_area = Area3D.new()
|
||||
@@ -969,12 +1034,11 @@ func _spawn_sword() -> void:
|
||||
_sword_blade_area.body_entered.connect(_on_blade_hit)
|
||||
|
||||
|
||||
# The sword is carried by ACTION, not distance: drawn only while striking
|
||||
# (ATTACK / THROW) and holstered while running, dodging, bracing or taunting.
|
||||
# THROW snaps the blade out (its arm cock-back covers the motion); everything
|
||||
# else animates with the Draw_weapon clip — forward to draw, reversed to sheathe.
|
||||
# The sword is MELEE-only: drawn while fighting up close (ATTACK) or guarding
|
||||
# (BRACE / SIDESTEP), holstered otherwise. Ranged throws use a spear instead
|
||||
# (see _spawn_spear_in_hand), so THROW keeps the sword holstered.
|
||||
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
|
||||
|
||||
|
||||
@@ -1019,8 +1083,7 @@ func _begin_sheathe() -> void:
|
||||
_anim_player.play(_ANIM_DRAW, -1.0, -_draw_speed(), true)
|
||||
|
||||
|
||||
# Progress a draw or sheathe; the blade reparents at the grab point (mirrored for
|
||||
# the reverse sheathe), and the transition ends when the clip's runtime elapses.
|
||||
# Progress a draw or sheathe; the blade reparents at the grab point (mirrored on the reverse).
|
||||
func _advance_draw(delta: float) -> void:
|
||||
if not _drawing and not _sheathing:
|
||||
return
|
||||
@@ -1049,9 +1112,7 @@ func _draw_speed() -> float:
|
||||
return maxf(DP.f("mat_draw_speed"), 0.1)
|
||||
|
||||
|
||||
# Move the sword between the hand grip and the hip holster. Reparenting follows
|
||||
# whichever bone the BoneAttachment tracks; the local grip transform is reapplied
|
||||
# so the seating is identical in both sockets.
|
||||
# Move the sword between hand grip and hip holster, reapplying the local seating.
|
||||
func _carry_sword(in_hand: bool) -> void:
|
||||
if not is_instance_valid(_sword_node):
|
||||
return
|
||||
|
||||
@@ -7,8 +7,14 @@ const MATADOR := preload("res://Matador.tscn")
|
||||
|
||||
var _spawn_count: int = 1
|
||||
var _alive: int = 0
|
||||
var _kills: int = 0
|
||||
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:
|
||||
add_to_group(&"matador_spawn")
|
||||
@@ -23,8 +29,10 @@ func _spawn() -> void:
|
||||
var arena_r := DP.f("arena_spawn_radius")
|
||||
var bull_angle := randf() * TAU
|
||||
_place_bull(bull_angle, arena_r)
|
||||
for pos: Vector3 in _spread_positions(_spawn_count, bull_angle + PI, arena_r):
|
||||
var placed: Array[Vector3] = _spread_positions(_spawn_count, bull_angle + PI, arena_r)
|
||||
for pos: Vector3 in placed:
|
||||
_spawn_at(pos)
|
||||
_last_spawn_positions = placed
|
||||
|
||||
|
||||
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`)
|
||||
# so every matador stays near the point diametrically opposite the bull.
|
||||
# Positions for the matadors, spread across the far side (centred on `center_angle`)
|
||||
# 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]:
|
||||
var positions: Array[Vector3] = []
|
||||
if count <= 1:
|
||||
positions.append(Vector3(cos(center_angle) * arena_r, 1.0, sin(center_angle) * arena_r))
|
||||
return positions
|
||||
var arc := TAU * 0.33
|
||||
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:
|
||||
var t := float(i) / float(count - 1) - 0.5 # -0.5 .. 0.5 across the arc
|
||||
var angle := center_angle + t * arc
|
||||
var radius := randf_range(arena_r * 0.7, arena_r)
|
||||
positions.append(Vector3(cos(angle) * radius, 1.0, sin(angle) * radius))
|
||||
# Give each matador its own angular slot for an even fan, but sample the radius
|
||||
# freely across the whole far-side band so the rejection test can push crowded
|
||||
# neighbours onto a different ring instead of piling them at one distance.
|
||||
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
|
||||
|
||||
|
||||
func _spawn_at(pos: Vector3) -> void:
|
||||
func _spawn_at(pos: Vector3, generation: int = 0) -> void:
|
||||
var m: Node3D = MATADOR.instantiate()
|
||||
m.position = pos
|
||||
m.set_meta(&"generation", generation)
|
||||
get_parent().add_child(m)
|
||||
m.killed.connect(_on_matador_killed)
|
||||
m.killed.connect(_on_matador_killed.bind(m))
|
||||
_alive += 1
|
||||
|
||||
|
||||
# Every matador down = the bull wins. No waves, no respawns — one clean arena.
|
||||
func _on_matador_killed() -> void:
|
||||
# A slain matador is replaced by mat_split_factor fresh ones on the spot (a hydra),
|
||||
# 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()
|
||||
_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
|
||||
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
|
||||
|
||||
# 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_active: bool = false
|
||||
var _ability_timer: float = 0.0
|
||||
var _active_ability: int = -1
|
||||
var _dash_dir: Vector3 = Vector3.ZERO
|
||||
var _dash_speed_cur: float = 0.0
|
||||
var _roll_dir: Vector3 = Vector3.ZERO
|
||||
var _roll_ramp: 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
|
||||
# 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 _charge_trail_emitter: CPUParticles3D = null
|
||||
@@ -49,6 +56,17 @@ var _cube_scale: Vector3 = Vector3.ONE
|
||||
signal died(cause: String)
|
||||
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 _crowd_player: AudioStreamPlayer = null
|
||||
var _huff_timer: float = 0.0
|
||||
@@ -61,6 +79,8 @@ var _charge_dust_ramp: Gradient = null
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group(&"player")
|
||||
_max_hp = maxi(1, int(DP.f("bull_max_hp")))
|
||||
_hp = _max_hp
|
||||
_cube_scale = cube_guy.scale
|
||||
_setup_hoof_dust()
|
||||
_setup_legs()
|
||||
@@ -258,8 +278,9 @@ func _find_anim_player(node: Node) -> AnimationPlayer:
|
||||
func _setup_bull_idle() -> void:
|
||||
if _bull_anim == null:
|
||||
return
|
||||
# IDLE and ROLL both loop as continuous states; the ability clips are one-shot.
|
||||
for clip: StringName in [_ANIM_IDLE, _ANIM_ROLL]:
|
||||
# IDLE, DASH and ROLL loop as continuous states (DASH doubles as the WASD
|
||||
# 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):
|
||||
_bull_anim.get_animation(clip).loop_mode = Animation.LOOP_LINEAR
|
||||
_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
|
||||
# finishing ability clip eases back to idle instead of snapping.
|
||||
func _play_idle() -> void:
|
||||
_locomotion_moving = false
|
||||
if _bull_anim == null:
|
||||
return
|
||||
_bull_anim.speed_scale = 1.0
|
||||
@@ -275,25 +297,56 @@ func _play_idle() -> void:
|
||||
_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:
|
||||
if _dead:
|
||||
return
|
||||
# Abilities can be chained: a new one interrupts whatever is currently
|
||||
# active, gated only by each ability's own cooldown.
|
||||
if event.is_action_pressed(&"ability_kick") and ability_cd[0] <= 0.0:
|
||||
_activate_kick()
|
||||
elif event.is_action_pressed(&"ability_slam") and ability_cd[1] <= 0.0:
|
||||
_activate_slam()
|
||||
elif event.is_action_pressed(&"ability_dash") and ability_cd[2] <= 0.0:
|
||||
_activate_dash()
|
||||
elif event.is_action_pressed(&"ability_roll") and ability_cd[3] <= 0.0:
|
||||
_activate_roll()
|
||||
if event.is_action_pressed(&"ability_kick"):
|
||||
try_activate_ability(0)
|
||||
elif event.is_action_pressed(&"ability_dash"):
|
||||
try_activate_ability(1)
|
||||
elif event.is_action_pressed(&"ability_slam"):
|
||||
try_activate_ability(2)
|
||||
elif event.is_action_pressed(&"ability_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:
|
||||
var maxcd := 0.0
|
||||
match slot:
|
||||
0: maxcd = DP.f("kick_cooldown")
|
||||
1: maxcd = DP.f("slam_cooldown")
|
||||
2: maxcd = DP.f("dash_cooldown")
|
||||
1: maxcd = DP.f("dash_cooldown")
|
||||
2: maxcd = DP.f("slam_cooldown")
|
||||
3: maxcd = DP.f("roll_cooldown")
|
||||
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_active = true
|
||||
_active_ability = 0
|
||||
_ability_timer = 0.6
|
||||
_ability_timer = 0.3
|
||||
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):
|
||||
_bull_anim.play(_ANIM_KICK)
|
||||
_ability_timer = _bull_anim.get_animation(_ANIM_KICK).length
|
||||
@@ -321,11 +374,12 @@ func _activate_kick() -> void:
|
||||
|
||||
|
||||
func _activate_slam() -> void:
|
||||
ability_cd[1] = DP.f("slam_cooldown")
|
||||
ability_cd[2] = DP.f("slam_cooldown")
|
||||
_ability_active = true
|
||||
_active_ability = 1
|
||||
_active_ability = 2
|
||||
_legs.set(&"tail_ragdoll", 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")
|
||||
velocity.y = jump_vel
|
||||
# Airtime with an accelerated descent: rise (v/g) + fall (v / (g·√mult)).
|
||||
@@ -339,9 +393,9 @@ func _activate_slam() -> void:
|
||||
|
||||
|
||||
func _activate_dash() -> void:
|
||||
ability_cd[2] = DP.f("dash_cooldown")
|
||||
ability_cd[1] = DP.f("dash_cooldown")
|
||||
_ability_active = true
|
||||
_active_ability = 2
|
||||
_active_ability = 1
|
||||
_ability_timer = 0.45
|
||||
if _bull_anim:
|
||||
_bull_anim.speed_scale = 1.0
|
||||
@@ -350,8 +404,9 @@ func _activate_dash() -> void:
|
||||
_ability_timer = _bull_anim.get_animation(_ANIM_DASH).length
|
||||
var raw := cube_guy.global_transform.basis.z
|
||||
_dash_dir = Vector3(raw.x, 0.0, raw.z).normalized()
|
||||
velocity.x = _dash_dir.x * DP.f("dash_speed")
|
||||
velocity.z = _dash_dir.z * DP.f("dash_speed")
|
||||
_dash_speed_cur = DP.f("dash_speed")
|
||||
velocity.x = _dash_dir.x * _dash_speed_cur
|
||||
velocity.z = _dash_dir.z * _dash_speed_cur
|
||||
_spawn_dash_burst()
|
||||
_legs.set(&"charge_pitch_target", DP.f("charge_head_pitch"))
|
||||
if _huff_player:
|
||||
@@ -366,10 +421,16 @@ func _activate_roll() -> void:
|
||||
_ability_timer = DP.f("roll_duration")
|
||||
_roll_ramp = 0.0
|
||||
_roll_spin = 0.0
|
||||
# Curl into the ball along the current heading (or facing when standing still)
|
||||
# and launch at the base speed — it ramps up from here the longer you roll.
|
||||
_roll_chains_left = int(DP.f("roll_chain_count"))
|
||||
_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)
|
||||
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()
|
||||
else:
|
||||
var f := cube_guy.global_transform.basis.z
|
||||
@@ -392,14 +453,20 @@ func _activate_roll() -> void:
|
||||
_huff_player.play()
|
||||
|
||||
|
||||
# One rolling frame (Rammus Powerball): steer with momentum, accelerate the longer
|
||||
# the roll lasts, move (walls just slide), then pop the ball on the first matador we
|
||||
# ram. Fully replaces the normal locomotion path while active.
|
||||
func _tick_roll(delta: float, steer: Vector3) -> void:
|
||||
var steer_flat := Vector3(steer.x, 0.0, steer.z)
|
||||
if steer_flat.length() > 0.1:
|
||||
var t := clampf(DP.f("roll_turn") * delta, 0.0, 1.0)
|
||||
_roll_dir = _roll_dir.slerp(steer_flat.normalized(), t).normalized()
|
||||
# One rolling frame: home on the current target matador, accelerate the longer the
|
||||
# roll lasts, move (walls just slide), then pop the ball on contact and chain to the
|
||||
# next closest. Fully replaces the normal locomotion path while active. `steer` is
|
||||
# ignored — the roll auto-targets — but kept so the caller stays uniform.
|
||||
func _tick_roll(delta: float, _steer: Vector3) -> void:
|
||||
# Reacquire if the target ragdolled or was freed before we reached it.
|
||||
if not _roll_target_valid():
|
||||
_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
|
||||
# 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
|
||||
# outward, then end the roll (Rammus stops when Powerball connects). Returns true if
|
||||
# it popped so the caller can stop touching roll state this frame.
|
||||
# outward, then either CHAIN to the next closest (if chains remain and a target is
|
||||
# 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:
|
||||
var radius := DP.f("roll_hit_radius")
|
||||
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
|
||||
to_mat.y = 0.0
|
||||
if to_mat.length() > radius:
|
||||
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"))
|
||||
camera_pivot.call(&"trigger_hit", clampf(speed / 90.0, 0.35, 1.0))
|
||||
if _huff_player:
|
||||
_huff_player.pitch_scale = randf_range(1.05, 1.35)
|
||||
_huff_player.play()
|
||||
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()
|
||||
return true
|
||||
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
|
||||
# the normal locomotion path next frame.
|
||||
func _end_roll() -> void:
|
||||
@@ -457,6 +563,9 @@ func _end_roll() -> void:
|
||||
_active_ability = -1
|
||||
_ability_timer = 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(&"tail_ragdoll", false)
|
||||
_legs.set(&"external_rotation", false)
|
||||
@@ -805,6 +914,19 @@ func _make_slam_ring_mesh() -> ArrayMesh:
|
||||
|
||||
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
if not is_on_floor():
|
||||
var gravity := get_gravity()
|
||||
@@ -824,6 +946,7 @@ func _physics_process(delta: float) -> void:
|
||||
var on_floor := is_on_floor()
|
||||
|
||||
# ── Ability cooldowns ────────────────────────────────────────────────────────
|
||||
_hit_iframes = maxf(0.0, _hit_iframes - delta)
|
||||
for i: int in 4:
|
||||
ability_cd[i] = maxf(0.0, ability_cd[i] - delta)
|
||||
if _ability_active:
|
||||
@@ -856,7 +979,13 @@ func _physics_process(delta: float) -> void:
|
||||
# input is released.
|
||||
var flat_vel := Vector3(velocity.x, 0.0, velocity.z)
|
||||
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())
|
||||
flat_vel = flat_vel.move_toward(direction * cap, DP.f("move_accel") * delta)
|
||||
else:
|
||||
@@ -871,6 +1000,9 @@ func _physics_process(delta: float) -> void:
|
||||
cube_guy.rotation.y = lerp_angle(
|
||||
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
|
||||
var pre_wall_vel := Vector3(velocity.x, 0.0, velocity.z)
|
||||
move_and_slide()
|
||||
@@ -925,11 +1057,76 @@ func _physics_process(delta: float) -> void:
|
||||
# ── Hoof sounds ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# A single clean hit — thrown or swung blade — ends the run. `cause` ("gored" /
|
||||
# "thrown") tags how it happened for the death notice. Latches so the lose screen is
|
||||
# raised exactly once even if several blades land on the same frame.
|
||||
func take_sword_hit(cause: String = "") -> void:
|
||||
if _dead:
|
||||
func get_hp() -> int:
|
||||
return _hp
|
||||
|
||||
|
||||
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
|
||||
_dead = true
|
||||
died.emit(cause)
|
||||
_hp -= 1
|
||||
_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]
|
||||
|
||||
config/name="Bullosseum"
|
||||
config/version="0.1.0"
|
||||
config/version="0.2.0"
|
||||
run/main_scene="res://MainMenu.tscn"
|
||||
config/features=PackedStringArray("4.7", "Forward Plus")
|
||||
config/icon="res://icon.svg"
|
||||
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]
|
||||
|
||||
@@ -23,6 +27,7 @@ DP="*res://debug_params.gd"
|
||||
Console="*res://console.gd"
|
||||
Controls="*res://controls_manager.gd"
|
||||
DebugDraw="*res://debug_draw.gd"
|
||||
Settings="*res://settings.gd"
|
||||
|
||||
[editor_plugins]
|
||||
|
||||
@@ -102,6 +107,10 @@ ability_roll={
|
||||
]
|
||||
}
|
||||
|
||||
[input_devices]
|
||||
|
||||
pointing/emulate_touch_from_mouse=true
|
||||
|
||||
[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:
|
||||
# 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():
|
||||
var anim := child.get_node_or_null(^"AnimationPlayer") as AnimationPlayer
|
||||
if anim:
|
||||
|
||||
@@ -14,6 +14,10 @@ echo ""
|
||||
echo "=== Logic tests ==="
|
||||
"$GODOT" --headless --script tests/logic_test.gd
|
||||
|
||||
echo ""
|
||||
echo "=== Console focus tests ==="
|
||||
"$GODOT" --headless --script tests/console_focus_test.gd
|
||||
|
||||
echo ""
|
||||
echo "=== Performance tests (frame-budget regression guard) ==="
|
||||
"$GODOT" --headless --script tests/performance_test.gd
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
[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="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"]
|
||||
dof_blur_far_enabled = true
|
||||
@@ -83,6 +85,7 @@ anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("12_ps1ctl")
|
||||
|
||||
[node name="Lights" type="Node3D" parent="." unique_id=737767662]
|
||||
|
||||
@@ -93,9 +96,9 @@ light_indirect_energy = 0.767
|
||||
shadow_blur = 0.47
|
||||
|
||||
[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)
|
||||
visible = false
|
||||
light_energy = 14.585
|
||||
transform = Transform3D(0.904133, 0.30962402, -0.2944087, 0.3084627, 0.0037425472, 0.9512291, 0.2956252, -0.95085174, -0.0921237, -3.629381, 10.86347, -2.0124264)
|
||||
light_energy = 16.0
|
||||
light_indirect_energy = 26.095
|
||||
distance_fade_enabled = true
|
||||
spot_range = 32.90586
|
||||
spot_angle = 73.93
|
||||
@@ -109,7 +112,7 @@ spot_range = 32.90586
|
||||
|
||||
[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)
|
||||
light_energy = 2.988
|
||||
light_energy = 1.199
|
||||
shadow_enabled = true
|
||||
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]
|
||||
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/bull"]
|
||||
[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"),
|
||||
"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()
|
||||
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")
|
||||
|
||||
|
||||
|
||||
@@ -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 _body: FontFile = null
|
||||
static var _body_bold: FontVariation = null
|
||||
|
||||
|
||||
## Ornate display face — big banners, menu title, win/lose text.
|
||||
@@ -27,6 +28,17 @@ static func body() -> FontFile:
|
||||
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:
|
||||
if ResourceLoader.exists(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
|
||||
## plate (still shadowed).
|
||||
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:
|
||||
var s := StyleBoxFlat.new()
|
||||
s.bg_color = bg
|
||||
s.set_corner_radius_all(corner)
|
||||
s.corner_detail = 6
|
||||
s.corner_detail = 1
|
||||
if accent.a > 0.0:
|
||||
s.set_border_width_all(2)
|
||||
s.border_width_bottom = 5
|
||||
s.set_border_width_all(1)
|
||||
s.border_width_bottom = 2
|
||||
s.border_color = accent
|
||||
s.content_margin_left = h_margin
|
||||
s.content_margin_right = h_margin
|
||||
|
||||