4 Commits

Author SHA1 Message Date
richard 56593c58cf Release 0.2.0: device-aware controls, spear, settings, shaders
Show touch controls only on touch platforms and keyboard hints only on
desktop, via a shared Controls.use_touch_ui() gate (is_touchscreen_available
is unreliable with emulate_touch_from_mouse). Bumps version to 0.2.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-09 19:39:37 +03:00
richard ce6f3d7075 Deployment script update 2026-08-09 15:26:01 +03:00
siim a74182255b light tim 2026-08-08 11:47:36 +03:00
siim ebf5046461 credits 2026-07-30 20:21:10 +03:00
38 changed files with 1958 additions and 287 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

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

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

BIN
View File
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://jepc74w8opbu"
path="res://.godot/imported/spear.glb-0139879d219c93f9dd35db2304db7d5d.scn"
[deps]
source_file="res://Assets/spear.glb"
dest_files=["res://.godot/imported/spear.glb-0139879d219c93f9dd35db2304db7d5d.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
Binary file not shown.
+83 -39
View File
@@ -1,7 +1,13 @@
[gd_scene load_steps=5 format=3 uid="uid://bmnmenu0intr1"]
[gd_scene format=3 uid="uid://bmnmenu0intr1"]
[ext_resource type="Script" path="res://main_menu.gd" id="1_menu"]
[ext_resource type="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,7 +216,7 @@ 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 = 5
@@ -180,24 +224,24 @@ 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
@@ -206,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
@@ -224,36 +268,36 @@ theme_override_font_sizes/font_size = 44
text = "Credits"
horizontal_alignment = 1
[node name="Names" type="RichTextLabel" parent="CreditsPanel/VBox"]
[node name="Names" type="RichTextLabel" parent="CreditsPanel/VBox" unique_id=1355198629]
layout_mode = 2
size_flags_vertical = 3
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
theme_override_colors/default_color = Color(0.93, 0.88, 0.72, 1)
theme_override_constants/outline_size = 5
theme_override_colors/font_outline_color = Color(0, 0, 0, 1)
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
fit_content = true
scroll_active = false
text = "[center][font_size=32][color=#d9a833]Bullosseum[/color][/font_size]
[b][color=#d9a833]Siim Raud[/color][/b] [url=https://instagram.com/siimpressionist]@siimpressionist[/url]
[font_size=20]Creative, art, idea guy[/font_size]
[font_size=20]Artist[/font_size]
[b][color=#d9a833]Richard Aasa[/color][/b] [url=https://instagram.com/aasarichard]@aasarichard[/url]
[font_size=20]Programmer[/font_size]
[b][color=#d9a833]Kadi Rebane[/color][/b] [url=https://instagram.com/kadirebane]@kadirebane[/url]
[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"]
[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
+2
View File
@@ -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
+16 -1
View File
@@ -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):
+16
View File
@@ -0,0 +1,16 @@
shader_type canvas_item;
// Faint red gradient hugging the screen edges, flashed on a hit. `flash` is tweened
// 0.9 → 0 by the HUD; the vignette is transparent in the centre and reddens toward
// the corners so the middle of the screen stays clear.
uniform float flash : hint_range(0.0, 1.0) = 0.0;
uniform vec3 tint : source_color = vec3(0.75, 0.0, 0.0);
uniform float inner : hint_range(0.0, 1.0) = 0.45;
uniform float outer : hint_range(0.0, 1.0) = 1.0;
void fragment() {
// Distance from centre normalised so the corners land at ~1.0.
float dist = length(UV - vec2(0.5)) / 0.70710678;
float v = smoothstep(inner, outer, dist);
COLOR = vec4(tint, v * flash);
}
+1
View File
@@ -0,0 +1 @@
uid://dys23pu1kw5fl
+68 -19
View File
@@ -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,19 +250,24 @@ 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
@@ -250,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,
+24 -1
View File
@@ -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"
+311 -28
View File
@@ -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
_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
_toggle_btn.text = "Hide controls" if _controls_visible else "Show controls"
# ── 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.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)
+47 -3
View File
@@ -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()
@@ -183,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()
@@ -221,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()
@@ -240,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()
+184 -123
View File
@@ -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
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 flight := flat.length() / speed
target += _bull.velocity * flight
var to_target := target - gx.origin
if to_target.length() > 0.1:
dir = to_target.normalized()
# A little aim scatter so throws are a threat to READ and dodge, not a hitscan —
# a bull that keeps moving can slip them, a stationary one gets pinned.
var 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
+86 -13
View File
@@ -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
+25
View File
@@ -0,0 +1,25 @@
shader_type canvas_item;
// Blurred, darkened backdrop for the intro video. A COVER-scaled copy of the
// clip sits behind the sharp fitted clip so portrait/ultrawide screens fill
// edge-to-edge instead of showing dead bars.
uniform float blur_radius : hint_range(0.0, 8.0) = 3.0;
uniform float darken : hint_range(0.0, 1.0) = 0.35;
void fragment() {
vec2 px = TEXTURE_PIXEL_SIZE * blur_radius;
vec4 sum = vec4(0.0);
// 3x3 gaussian-ish tap, weights sum to 16.
sum += texture(TEXTURE, UV + vec2(-px.x, -px.y)) * 1.0;
sum += texture(TEXTURE, UV + vec2(0.0, -px.y)) * 2.0;
sum += texture(TEXTURE, UV + vec2(px.x, -px.y)) * 1.0;
sum += texture(TEXTURE, UV + vec2(-px.x, 0.0)) * 2.0;
sum += texture(TEXTURE, UV) * 4.0;
sum += texture(TEXTURE, UV + vec2(px.x, 0.0)) * 2.0;
sum += texture(TEXTURE, UV + vec2(-px.x, px.y)) * 1.0;
sum += texture(TEXTURE, UV + vec2(0.0, px.y)) * 2.0;
sum += texture(TEXTURE, UV + vec2(px.x, px.y)) * 1.0;
sum /= 16.0;
COLOR = vec4(sum.rgb * (1.0 - darken), sum.a);
}
+1
View File
@@ -0,0 +1 @@
uid://bmdtg6qlh7wri
+236 -39
View File
@@ -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:
# 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(steer_flat.normalized(), t).normalized()
_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
_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)
+10 -1
View File
@@ -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"
+9 -8
View File
@@ -11,8 +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" path="res://crowd_billboards.gd" id="11_crowd"]
[ext_resource type="Script" path="res://ps1_filter.gd" id="12_ps1ctl"]
[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
@@ -80,12 +80,12 @@ layer = 10
[node name="PS1Filter" type="ColorRect" parent="CanvasLayer" unique_id=1449116873]
material = SubResource("ShaderMaterial_ps1")
script = ExtResource("12_ps1ctl")
anchors_preset = 15
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]
@@ -96,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
@@ -112,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
@@ -381,7 +381,8 @@ 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="."]
[node name="CrowdBillboards" type="MultiMeshInstance3D" parent="." unique_id=549548313]
visible = false
script = ExtResource("11_crowd")
[editable path="Player"]
+49
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dyspgr7m238la
+206
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dwjakh1is1jtj
+13 -1
View File
@@ -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(hud._game_over, "a single sword hit raises the game-over screen")
_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, "draining the last HP raises the game-over screen")
_assert_true(not hud._result_win, "a bull hit is a loss, not a win")
Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

+40
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

+40
View File
@@ -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
+65
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://dfcfb205scjvt
+315
View File
@@ -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)
+12
View File
@@ -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)
+4 -4
View File
@@ -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