Compare commits
2 Commits
ce6f3d7075
...
6414b2e7cc
| Author | SHA1 | Date | |
|---|---|---|---|
| 6414b2e7cc | |||
| 56593c58cf |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://ml0ntjv5injp"
|
||||
path="res://.godot/imported/bull_hud_bg.png-4645abe7114935a3d4a8e0f88ccce15c.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://Assets/bull_hud_bg.png"
|
||||
dest_files=["res://.godot/imported/bull_hud_bg.png-4645abe7114935a3d4a8e0f88ccce15c.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 MiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b12qek2dvwok"
|
||||
path="res://.godot/imported/loadingScreen.png-4a07e122007517dac631d2ce58b2f3c9.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://Assets/loadingScreen.png"
|
||||
dest_files=["res://.godot/imported/loadingScreen.png-4a07e122007517dac631d2ce58b2f3c9.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 |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bh5nuyudh7x44"
|
||||
path="res://.godot/imported/loading_screen.png-253743b71eb7ab3f4f9e2b29cbabf407.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://Assets/loading_screen.png"
|
||||
dest_files=["res://.godot/imported/loading_screen.png-253743b71eb7ab3f4f9e2b29cbabf407.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
Binary file not shown.
@@ -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.
+43
-3
@@ -2,6 +2,12 @@
|
||||
|
||||
[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
|
||||
@@ -39,25 +45,50 @@ grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_menu")
|
||||
|
||||
[node name="VideoStreamPlayer" type="VideoStreamPlayer" parent="." unique_id=374854265]
|
||||
[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="." unique_id=1263320918]
|
||||
[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" unique_id=743089847]
|
||||
[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
|
||||
anchors_preset = -1
|
||||
anchor_left = 0.453
|
||||
@@ -80,6 +111,15 @@ theme_override_styles/hover = SubResource("sb_hover")
|
||||
theme_override_styles/focus = SubResource("sb_hover")
|
||||
text = "PLAY"
|
||||
|
||||
[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
|
||||
|
||||
@@ -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
@@ -15,8 +15,8 @@ const REBINDABLE_ACTIONS: PackedStringArray = [
|
||||
&"move_left",
|
||||
&"move_right",
|
||||
&"ability_kick",
|
||||
&"ability_slam",
|
||||
&"ability_dash",
|
||||
&"ability_slam",
|
||||
&"ability_roll",
|
||||
]
|
||||
|
||||
@@ -25,6 +25,21 @@ func _ready() -> void:
|
||||
load_saved()
|
||||
|
||||
|
||||
## Whether to drive the game with the on-screen touch UI (joystick + buttons)
|
||||
## instead of keyboard hints. DisplayServer.is_touchscreen_available() can't be
|
||||
## used here: `emulate_touch_from_mouse` (on for desktop testing) makes it report
|
||||
## true on every desktop. Gate on the actual runtime platform instead — real
|
||||
## mobile/native and mobile web browsers — plus the DP force flag for testing.
|
||||
func use_touch_ui() -> bool:
|
||||
if DP.b("force_touch_controls"):
|
||||
return true
|
||||
return (
|
||||
OS.has_feature("mobile")
|
||||
or OS.has_feature("web_android")
|
||||
or OS.has_feature("web_ios")
|
||||
)
|
||||
|
||||
|
||||
## Returns the first keyboard event bound to an action, or null.
|
||||
func get_key_event(action: StringName) -> InputEventKey:
|
||||
for event: InputEvent in InputMap.action_get_events(action):
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
shader_type canvas_item;
|
||||
|
||||
// Faint red gradient hugging the screen edges, flashed on a hit. `flash` is tweened
|
||||
// 0.9 → 0 by the HUD; the vignette is transparent in the centre and reddens toward
|
||||
// the corners so the middle of the screen stays clear.
|
||||
uniform float flash : hint_range(0.0, 1.0) = 0.0;
|
||||
uniform vec3 tint : source_color = vec3(0.75, 0.0, 0.0);
|
||||
uniform float inner : hint_range(0.0, 1.0) = 0.45;
|
||||
uniform float outer : hint_range(0.0, 1.0) = 1.0;
|
||||
|
||||
void fragment() {
|
||||
// Distance from centre normalised so the corners land at ~1.0.
|
||||
float dist = length(UV - vec2(0.5)) / 0.70710678;
|
||||
float v = smoothstep(inner, outer, dist);
|
||||
COLOR = vec4(tint, v * flash);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dys23pu1kw5fl
|
||||
+68
-19
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -7,8 +7,14 @@ const MATADOR := preload("res://Matador.tscn")
|
||||
|
||||
var _spawn_count: int = 1
|
||||
var _alive: int = 0
|
||||
var _kills: int = 0
|
||||
var _resolved: bool = false
|
||||
|
||||
# Flat (x,z) positions of the most recent batch spawned. A fresh wave keeps at least
|
||||
# _SPAWN_MIN_SEP away from these so it never lands on top of where the last one did.
|
||||
const _SPAWN_MIN_SEP: float = 3.5
|
||||
var _last_spawn_positions: Array[Vector3] = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group(&"matador_spawn")
|
||||
@@ -23,8 +29,10 @@ func _spawn() -> void:
|
||||
var arena_r := DP.f("arena_spawn_radius")
|
||||
var bull_angle := randf() * TAU
|
||||
_place_bull(bull_angle, arena_r)
|
||||
for pos: Vector3 in _spread_positions(_spawn_count, bull_angle + PI, arena_r):
|
||||
var placed: Array[Vector3] = _spread_positions(_spawn_count, bull_angle + PI, arena_r)
|
||||
for pos: Vector3 in placed:
|
||||
_spawn_at(pos)
|
||||
_last_spawn_positions = placed
|
||||
|
||||
|
||||
func _place_bull(angle: float, arena_r: float) -> void:
|
||||
@@ -36,34 +44,99 @@ func _place_bull(angle: float, arena_r: float) -> void:
|
||||
)
|
||||
|
||||
|
||||
# Positions for the matadors, clustered on the far side (centred on `center_angle`)
|
||||
# so every matador stays near the point diametrically opposite the bull.
|
||||
# Positions for the matadors, spread across the far side (centred on `center_angle`)
|
||||
# so every matador stays near the point diametrically opposite the bull. Each slot gets
|
||||
# its own angular band and a staggered radius, then rejection-samples inside that band so
|
||||
# no two matadors land closer than _SPAWN_MIN_SEP — otherwise they pile up on top of
|
||||
# each other on spawn. The arc widens with the crowd so a big wave doesn't get cramped.
|
||||
func _spread_positions(count: int, center_angle: float, arena_r: float) -> Array[Vector3]:
|
||||
var positions: Array[Vector3] = []
|
||||
if count <= 1:
|
||||
positions.append(Vector3(cos(center_angle) * arena_r, 1.0, sin(center_angle) * arena_r))
|
||||
return positions
|
||||
var arc := TAU * 0.33
|
||||
var arc := clampf(TAU * 0.33 + float(count) * 0.25, TAU * 0.33, TAU * 0.92)
|
||||
var slot := arc / float(count)
|
||||
var inner := maxf(2.5, arena_r * 0.5)
|
||||
for i: int in count:
|
||||
var t := float(i) / float(count - 1) - 0.5 # -0.5 .. 0.5 across the arc
|
||||
var angle := center_angle + t * arc
|
||||
var radius := randf_range(arena_r * 0.7, arena_r)
|
||||
positions.append(Vector3(cos(angle) * radius, 1.0, sin(angle) * radius))
|
||||
# Give each matador its own angular slot for an even fan, but sample the radius
|
||||
# freely across the whole far-side band so the rejection test can push crowded
|
||||
# neighbours onto a different ring instead of piling them at one distance.
|
||||
var base_angle := center_angle - arc * 0.5 + slot * (float(i) + 0.5)
|
||||
var pos := Vector3(cos(base_angle) * arena_r, 1.0, sin(base_angle) * arena_r)
|
||||
for attempt: int in 16:
|
||||
var angle := base_angle + randf_range(-0.5, 0.5) * slot
|
||||
var radius := randf_range(inner, arena_r)
|
||||
pos = Vector3(cos(angle) * radius, 1.0, sin(angle) * radius)
|
||||
if _clear_of(pos, positions, _SPAWN_MIN_SEP):
|
||||
break
|
||||
positions.append(pos)
|
||||
return positions
|
||||
|
||||
|
||||
func _spawn_at(pos: Vector3) -> void:
|
||||
func _spawn_at(pos: Vector3, generation: int = 0) -> void:
|
||||
var m: Node3D = MATADOR.instantiate()
|
||||
m.position = pos
|
||||
m.set_meta(&"generation", generation)
|
||||
get_parent().add_child(m)
|
||||
m.killed.connect(_on_matador_killed)
|
||||
m.killed.connect(_on_matador_killed.bind(m))
|
||||
_alive += 1
|
||||
|
||||
|
||||
# Every matador down = the bull wins. No waves, no respawns — one clean arena.
|
||||
func _on_matador_killed() -> void:
|
||||
# A slain matador is replaced by mat_split_factor fresh ones on the spot (a hydra),
|
||||
# so the horde doubles each generation, up to mat_split_generations deep. The bull
|
||||
# wins only when a whole generation dies out without splitting — i.e. once the spawns
|
||||
# stop and the last body drops.
|
||||
func _on_matador_killed(dead: Node3D) -> void:
|
||||
matador_killed.emit()
|
||||
_alive -= 1
|
||||
if _alive <= 0 and not _resolved:
|
||||
_kills += 1
|
||||
_split(dead)
|
||||
var win_kills := int(DP.f("mat_win_kills"))
|
||||
var reached_quota := win_kills > 0 and _kills >= win_kills
|
||||
if (reached_quota or _alive <= 0) and not _resolved:
|
||||
_resolved = true
|
||||
all_defeated.emit()
|
||||
|
||||
|
||||
func _split(dead: Node3D) -> void:
|
||||
var count := int(DP.f("mat_split_factor"))
|
||||
var generation := int(dead.get_meta(&"generation", 0))
|
||||
if count < 2 or generation >= int(DP.f("mat_split_generations")):
|
||||
return
|
||||
var arena_r := DP.f("arena_spawn_radius")
|
||||
var origin := dead.global_position
|
||||
origin.y = 0.0
|
||||
var wave: Array[Vector3] = []
|
||||
for i: int in count:
|
||||
var pos := _pick_spawn(origin, arena_r, wave)
|
||||
wave.append(pos)
|
||||
_spawn_at(pos, generation + 1)
|
||||
_last_spawn_positions = wave
|
||||
|
||||
|
||||
# Rejection-sample a spawn point ringed around `origin`, pushing the ring outward on
|
||||
# each failed attempt until it clears both the previous wave (_last_spawn_positions)
|
||||
# and the spots already taken this wave — so a fresh wave never lands where the last
|
||||
# one did. Falls back to the last candidate if the arena is too cramped to satisfy it.
|
||||
func _pick_spawn(origin: Vector3, arena_r: float, taken: Array[Vector3]) -> Vector3:
|
||||
var pos := origin
|
||||
for attempt: int in 12:
|
||||
var angle := randf() * TAU
|
||||
var r := randf_range(2.0, 3.5) + float(attempt) * 0.4
|
||||
pos = origin + Vector3(cos(angle) * r, 0.0, sin(angle) * r)
|
||||
var flat := Vector2(pos.x, pos.z)
|
||||
if flat.length() > arena_r:
|
||||
flat = flat.normalized() * arena_r
|
||||
pos = Vector3(flat.x, 0.0, flat.y)
|
||||
if _clear_of(pos, _last_spawn_positions, _SPAWN_MIN_SEP) \
|
||||
and _clear_of(pos, taken, _SPAWN_MIN_SEP):
|
||||
break
|
||||
pos.y = 1.0
|
||||
return pos
|
||||
|
||||
|
||||
func _clear_of(pos: Vector3, others: Array[Vector3], min_sep: float) -> bool:
|
||||
for o: Vector3 in others:
|
||||
if Vector2(pos.x - o.x, pos.z - o.z).length() < min_sep:
|
||||
return false
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
shader_type canvas_item;
|
||||
|
||||
// Blurred, darkened backdrop for the intro video. A COVER-scaled copy of the
|
||||
// clip sits behind the sharp fitted clip so portrait/ultrawide screens fill
|
||||
// edge-to-edge instead of showing dead bars.
|
||||
|
||||
uniform float blur_radius : hint_range(0.0, 8.0) = 3.0;
|
||||
uniform float darken : hint_range(0.0, 1.0) = 0.35;
|
||||
|
||||
void fragment() {
|
||||
vec2 px = TEXTURE_PIXEL_SIZE * blur_radius;
|
||||
vec4 sum = vec4(0.0);
|
||||
// 3x3 gaussian-ish tap, weights sum to 16.
|
||||
sum += texture(TEXTURE, UV + vec2(-px.x, -px.y)) * 1.0;
|
||||
sum += texture(TEXTURE, UV + vec2(0.0, -px.y)) * 2.0;
|
||||
sum += texture(TEXTURE, UV + vec2(px.x, -px.y)) * 1.0;
|
||||
sum += texture(TEXTURE, UV + vec2(-px.x, 0.0)) * 2.0;
|
||||
sum += texture(TEXTURE, UV) * 4.0;
|
||||
sum += texture(TEXTURE, UV + vec2(px.x, 0.0)) * 2.0;
|
||||
sum += texture(TEXTURE, UV + vec2(-px.x, px.y)) * 1.0;
|
||||
sum += texture(TEXTURE, UV + vec2(0.0, px.y)) * 2.0;
|
||||
sum += texture(TEXTURE, UV + vec2(px.x, px.y)) * 1.0;
|
||||
sum /= 16.0;
|
||||
COLOR = vec4(sum.rgb * (1.0 - darken), sum.a);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bmdtg6qlh7wri
|
||||
@@ -27,16 +27,23 @@ var _pre_slide_vel_y: float = 0.0
|
||||
|
||||
var _slam_ring_mesh: ArrayMesh = null
|
||||
|
||||
# Ability system — kick=0, slam=1, dash=2, roll=3
|
||||
# Ability system — kick=0, dash=1, slam=2, roll=3
|
||||
var ability_cd: Array[float] = [0.0, 0.0, 0.0, 0.0]
|
||||
var _ability_active: bool = false
|
||||
var _ability_timer: float = 0.0
|
||||
var _active_ability: int = -1
|
||||
var _dash_dir: Vector3 = Vector3.ZERO
|
||||
var _dash_speed_cur: float = 0.0
|
||||
var _roll_dir: Vector3 = Vector3.ZERO
|
||||
var _roll_ramp: float = 0.0
|
||||
var _roll_spin: float = 0.0
|
||||
var _roll_target: Node3D = null
|
||||
var _roll_chains_left: int = 0
|
||||
var _roll_hit: Array[Node] = []
|
||||
var _bull_anim: AnimationPlayer = null
|
||||
# Locomotion animation state: true while the looping DASH clip is driving WASD
|
||||
# movement, false while idling. Flipping it cross-fades between the two clips.
|
||||
var _locomotion_moving: bool = false
|
||||
|
||||
var _slam_pending: bool = false
|
||||
var _charge_trail_emitter: CPUParticles3D = null
|
||||
@@ -49,6 +56,17 @@ var _cube_scale: Vector3 = Vector3.ONE
|
||||
signal died(cause: String)
|
||||
var _dead: bool = false
|
||||
|
||||
# HP system — the bull soaks several clean hits instead of dying to the first.
|
||||
# Each landed blade drops one pip and starts an i-frame window (so one pass can't
|
||||
# empty the bar); at 0 the run ends via `died`. The HUD listens on `health_changed`.
|
||||
signal health_changed(current: int, max_hp: int)
|
||||
# Fired on every landed hit (after i-frames pass) so the HUD can flash the damage
|
||||
# vignette; `cause` mirrors take_sword_hit ("gored" / "thrown").
|
||||
signal hit_taken(cause: String)
|
||||
var _max_hp: int = 10
|
||||
var _hp: int = 10
|
||||
var _hit_iframes: float = 0.0
|
||||
|
||||
var _huff_player: AudioStreamPlayer = null
|
||||
var _crowd_player: AudioStreamPlayer = null
|
||||
var _huff_timer: float = 0.0
|
||||
@@ -61,6 +79,8 @@ var _charge_dust_ramp: Gradient = null
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group(&"player")
|
||||
_max_hp = maxi(1, int(DP.f("bull_max_hp")))
|
||||
_hp = _max_hp
|
||||
_cube_scale = cube_guy.scale
|
||||
_setup_hoof_dust()
|
||||
_setup_legs()
|
||||
@@ -258,8 +278,9 @@ func _find_anim_player(node: Node) -> AnimationPlayer:
|
||||
func _setup_bull_idle() -> void:
|
||||
if _bull_anim == null:
|
||||
return
|
||||
# IDLE and ROLL both loop as continuous states; the ability clips are one-shot.
|
||||
for clip: StringName in [_ANIM_IDLE, _ANIM_ROLL]:
|
||||
# IDLE, DASH and ROLL loop as continuous states (DASH doubles as the WASD
|
||||
# locomotion cycle); the remaining ability clips are one-shot.
|
||||
for clip: StringName in [_ANIM_IDLE, _ANIM_DASH, _ANIM_ROLL]:
|
||||
if _bull_anim.has_animation(clip):
|
||||
_bull_anim.get_animation(clip).loop_mode = Animation.LOOP_LINEAR
|
||||
_play_idle()
|
||||
@@ -268,6 +289,7 @@ func _setup_bull_idle() -> void:
|
||||
# Return to the looping idle (and undo any ability speed-scale). Cross-fades so a
|
||||
# finishing ability clip eases back to idle instead of snapping.
|
||||
func _play_idle() -> void:
|
||||
_locomotion_moving = false
|
||||
if _bull_anim == null:
|
||||
return
|
||||
_bull_anim.speed_scale = 1.0
|
||||
@@ -275,25 +297,56 @@ func _play_idle() -> void:
|
||||
_bull_anim.play(_ANIM_IDLE, 0.2)
|
||||
|
||||
|
||||
# Drive the WASD locomotion clip: the looping DASH doubles as the run cycle while a
|
||||
# direction is held, easing back to idle when the bull coasts to a stop. Cross-fades
|
||||
# on each transition so idle↔run (and steering into it after an ability) blend rather
|
||||
# than snap. Abilities own the animation while active, so this yields to them.
|
||||
func _update_locomotion_anim(moving: bool) -> void:
|
||||
if _ability_active or _bull_anim == null or moving == _locomotion_moving:
|
||||
return
|
||||
_locomotion_moving = moving
|
||||
if moving:
|
||||
if _bull_anim.has_animation(_ANIM_DASH):
|
||||
_bull_anim.speed_scale = 1.0
|
||||
_bull_anim.play(_ANIM_DASH, 0.25)
|
||||
else:
|
||||
_play_idle()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if _dead:
|
||||
return
|
||||
# Abilities can be chained: a new one interrupts whatever is currently
|
||||
# active, gated only by each ability's own cooldown.
|
||||
if event.is_action_pressed(&"ability_kick") and ability_cd[0] <= 0.0:
|
||||
_activate_kick()
|
||||
elif event.is_action_pressed(&"ability_slam") and ability_cd[1] <= 0.0:
|
||||
_activate_slam()
|
||||
elif event.is_action_pressed(&"ability_dash") and ability_cd[2] <= 0.0:
|
||||
_activate_dash()
|
||||
elif event.is_action_pressed(&"ability_roll") and ability_cd[3] <= 0.0:
|
||||
_activate_roll()
|
||||
if event.is_action_pressed(&"ability_kick"):
|
||||
try_activate_ability(0)
|
||||
elif event.is_action_pressed(&"ability_dash"):
|
||||
try_activate_ability(1)
|
||||
elif event.is_action_pressed(&"ability_slam"):
|
||||
try_activate_ability(2)
|
||||
elif event.is_action_pressed(&"ability_roll"):
|
||||
try_activate_ability(3)
|
||||
|
||||
|
||||
# Public ability entry point shared by keyboard/gamepad input and the on-screen
|
||||
# touch buttons. Slot order matches ability_cd — kick=0, dash=1, slam=2, roll=3.
|
||||
# No-ops if the bull is dead or the slot is still cooling down.
|
||||
func try_activate_ability(slot: int) -> void:
|
||||
if _dead or slot < 0 or slot >= ability_cd.size() or ability_cd[slot] > 0.0:
|
||||
return
|
||||
match slot:
|
||||
0: _activate_kick()
|
||||
1: _activate_dash()
|
||||
2: _activate_slam()
|
||||
3: _activate_roll()
|
||||
|
||||
|
||||
func ability_cooldown_fraction(slot: int) -> float:
|
||||
var maxcd := 0.0
|
||||
match slot:
|
||||
0: maxcd = DP.f("kick_cooldown")
|
||||
1: maxcd = DP.f("slam_cooldown")
|
||||
2: maxcd = DP.f("dash_cooldown")
|
||||
1: maxcd = DP.f("dash_cooldown")
|
||||
2: maxcd = DP.f("slam_cooldown")
|
||||
3: maxcd = DP.f("roll_cooldown")
|
||||
return ability_cd[slot] / maxcd if maxcd > 0.0 else 0.0
|
||||
|
||||
@@ -306,9 +359,9 @@ func _activate_kick() -> void:
|
||||
ability_cd[0] = DP.f("kick_cooldown")
|
||||
_ability_active = true
|
||||
_active_ability = 0
|
||||
_ability_timer = 0.6
|
||||
_ability_timer = 0.3
|
||||
if _bull_anim:
|
||||
_bull_anim.speed_scale = 1.0
|
||||
_bull_anim.speed_scale = 2.0
|
||||
if _bull_anim and _bull_anim.has_animation(_ANIM_KICK):
|
||||
_bull_anim.play(_ANIM_KICK)
|
||||
_ability_timer = _bull_anim.get_animation(_ANIM_KICK).length
|
||||
@@ -321,11 +374,12 @@ func _activate_kick() -> void:
|
||||
|
||||
|
||||
func _activate_slam() -> void:
|
||||
ability_cd[1] = DP.f("slam_cooldown")
|
||||
ability_cd[2] = DP.f("slam_cooldown")
|
||||
_ability_active = true
|
||||
_active_ability = 1
|
||||
_active_ability = 2
|
||||
_legs.set(&"tail_ragdoll", true)
|
||||
_slam_pending = true
|
||||
SpearProjectile.shed_all.call_deferred(self) # the leap shakes off any stuck spears
|
||||
var jump_vel := DP.f("slam_jump_velocity")
|
||||
velocity.y = jump_vel
|
||||
# Airtime with an accelerated descent: rise (v/g) + fall (v / (g·√mult)).
|
||||
@@ -339,9 +393,9 @@ func _activate_slam() -> void:
|
||||
|
||||
|
||||
func _activate_dash() -> void:
|
||||
ability_cd[2] = DP.f("dash_cooldown")
|
||||
ability_cd[1] = DP.f("dash_cooldown")
|
||||
_ability_active = true
|
||||
_active_ability = 2
|
||||
_active_ability = 1
|
||||
_ability_timer = 0.45
|
||||
if _bull_anim:
|
||||
_bull_anim.speed_scale = 1.0
|
||||
@@ -350,8 +404,9 @@ func _activate_dash() -> void:
|
||||
_ability_timer = _bull_anim.get_animation(_ANIM_DASH).length
|
||||
var raw := cube_guy.global_transform.basis.z
|
||||
_dash_dir = Vector3(raw.x, 0.0, raw.z).normalized()
|
||||
velocity.x = _dash_dir.x * DP.f("dash_speed")
|
||||
velocity.z = _dash_dir.z * DP.f("dash_speed")
|
||||
_dash_speed_cur = DP.f("dash_speed")
|
||||
velocity.x = _dash_dir.x * _dash_speed_cur
|
||||
velocity.z = _dash_dir.z * _dash_speed_cur
|
||||
_spawn_dash_burst()
|
||||
_legs.set(&"charge_pitch_target", DP.f("charge_head_pitch"))
|
||||
if _huff_player:
|
||||
@@ -366,10 +421,16 @@ func _activate_roll() -> void:
|
||||
_ability_timer = DP.f("roll_duration")
|
||||
_roll_ramp = 0.0
|
||||
_roll_spin = 0.0
|
||||
# Curl into the ball along the current heading (or facing when standing still)
|
||||
# and launch at the base speed — it ramps up from here the longer you roll.
|
||||
_roll_chains_left = int(DP.f("roll_chain_count"))
|
||||
_roll_hit.clear()
|
||||
# Lock onto the closest matador and curl into the ball aimed at it; if the arena
|
||||
# is empty, launch along the current heading (or facing when standing still).
|
||||
_roll_target = _roll_pick_target()
|
||||
var flat := Vector3(velocity.x, 0.0, velocity.z)
|
||||
if flat.length() > 1.0:
|
||||
if _roll_target != null:
|
||||
var to_t := _roll_target.global_position - global_position
|
||||
_roll_dir = Vector3(to_t.x, 0.0, to_t.z).normalized()
|
||||
elif flat.length() > 1.0:
|
||||
_roll_dir = flat.normalized()
|
||||
else:
|
||||
var f := cube_guy.global_transform.basis.z
|
||||
@@ -392,14 +453,20 @@ func _activate_roll() -> void:
|
||||
_huff_player.play()
|
||||
|
||||
|
||||
# One rolling frame (Rammus Powerball): steer with momentum, accelerate the longer
|
||||
# the roll lasts, move (walls just slide), then pop the ball on the first matador we
|
||||
# ram. Fully replaces the normal locomotion path while active.
|
||||
func _tick_roll(delta: float, steer: Vector3) -> void:
|
||||
var steer_flat := Vector3(steer.x, 0.0, steer.z)
|
||||
if steer_flat.length() > 0.1:
|
||||
# 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
@@ -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"
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
extends Node
|
||||
## Player-facing game options (autoload `Settings`), persisted to user://settings.cfg.
|
||||
## Distinct from DP (debug tunables) and Controls (keybinds): this is the small set of
|
||||
## comfort/accessibility toggles the options menu exposes to the player.
|
||||
|
||||
signal changed(key: String, value: Variant)
|
||||
|
||||
const SAVE_PATH := "user://settings.cfg"
|
||||
const SECTION := "options"
|
||||
|
||||
## Master screen-shake toggle — gates every camera_iso.trigger_hit (hits and ability FX).
|
||||
var screen_shake: bool = true
|
||||
## Gore toggle — when off, no blood spawns (impact spurt + ongoing spear-wound bleed).
|
||||
var gore: bool = true
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
load_saved()
|
||||
|
||||
|
||||
func set_screen_shake(value: bool) -> void:
|
||||
if screen_shake == value:
|
||||
return
|
||||
screen_shake = value
|
||||
changed.emit("screen_shake", value)
|
||||
save()
|
||||
|
||||
|
||||
func set_gore(value: bool) -> void:
|
||||
if gore == value:
|
||||
return
|
||||
gore = value
|
||||
changed.emit("gore", value)
|
||||
save()
|
||||
|
||||
|
||||
func save() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
cfg.set_value(SECTION, "screen_shake", screen_shake)
|
||||
cfg.set_value(SECTION, "gore", gore)
|
||||
cfg.save(SAVE_PATH)
|
||||
|
||||
|
||||
func load_saved() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(SAVE_PATH) != OK:
|
||||
return
|
||||
screen_shake = cfg.get_value(SECTION, "screen_shake", true) as bool
|
||||
gore = cfg.get_value(SECTION, "gore", true) as bool
|
||||
@@ -0,0 +1 @@
|
||||
uid://dyspgr7m238la
|
||||
@@ -0,0 +1,206 @@
|
||||
class_name SpearProjectile
|
||||
extends RigidBody3D
|
||||
|
||||
# A thrown spear. It flies a ballistic arc (gravity does the work — the launch
|
||||
# velocity is solved by the matador to land on the bull) and keeps its shaft aimed
|
||||
# along its own velocity, so it noses over at the apex and drives in point-first.
|
||||
# On impact it embeds where it struck: the tip buried just past the contact point
|
||||
# with the shaft trailing back along the flight line, so it reads as stuck-in rather
|
||||
# than floating alongside. A hit on the bull leaves the spear riding it like a spine
|
||||
# (see shed_all, which the bull's slam calls to fling them all off); other hits pin
|
||||
# it in place until the despawn timer clears it.
|
||||
|
||||
const _DESPAWN_SEC: float = 8.0
|
||||
const _STUCK_GROUP: StringName = &"stuck_spear"
|
||||
# Shaft geometry in spear.glb local space: tip at +Z, butt behind the origin.
|
||||
const _TIP_Z: float = 1.915
|
||||
const _EMBED_DEPTH: float = 0.45 # how far past the surface the tip buries
|
||||
|
||||
var _mesh: Node3D = null
|
||||
var _stopped: bool = false
|
||||
var _flight_dir: Vector3 = Vector3.ZERO # last pre-impact travel direction
|
||||
|
||||
|
||||
# Build, seat and launch a spear from an already-instantiated mesh (the one that was
|
||||
# in the matador's hand). `origin` is the release point; `velocity` is the full
|
||||
# ballistic launch vector; `gravity` is the RigidBody gravity_scale for the arc.
|
||||
static func launch(parent: Node, mesh: Node3D, origin: Vector3, velocity: Vector3,
|
||||
gravity: float) -> SpearProjectile:
|
||||
var s := SpearProjectile.new()
|
||||
s.gravity_scale = gravity
|
||||
s.collision_layer = 0
|
||||
s.collision_mask = 1
|
||||
s.contact_monitor = true
|
||||
s.max_contacts_reported = 6
|
||||
parent.add_child(s)
|
||||
# Seat the body so its local +Z (the shaft, see spear.glb) points along the throw.
|
||||
s.global_transform = Transform3D(_aim_basis(velocity), origin)
|
||||
mesh.reparent(s, false)
|
||||
mesh.transform = Transform3D.IDENTITY
|
||||
s._mesh = mesh
|
||||
s.add_child(_shaft_collider())
|
||||
s.linear_velocity = velocity
|
||||
_despawn(s)
|
||||
return s
|
||||
|
||||
|
||||
# Shed every spear stuck in `bull` — pop each off as a tumbling body that flies clear
|
||||
# and clears itself on the despawn timer. Called by the bull's slam.
|
||||
static func shed_all(bull: Node) -> void:
|
||||
if not (bull is Node3D):
|
||||
return
|
||||
var scene: Node = bull.get_tree().current_scene
|
||||
if scene == null:
|
||||
scene = bull
|
||||
var center: Vector3 = (bull as Node3D).global_position
|
||||
for child in bull.get_children():
|
||||
if not child.is_in_group(_STUCK_GROUP):
|
||||
continue
|
||||
var mesh := child as Node3D
|
||||
var gx := mesh.global_transform
|
||||
var rb := RigidBody3D.new()
|
||||
rb.collision_layer = 0
|
||||
rb.collision_mask = 1
|
||||
scene.add_child(rb)
|
||||
rb.global_transform = gx
|
||||
mesh.reparent(rb, false)
|
||||
mesh.transform = Transform3D.IDENTITY
|
||||
mesh.remove_from_group(_STUCK_GROUP)
|
||||
rb.add_child(_shaft_collider())
|
||||
# Fling outward from the bull with a bit of lift and spin.
|
||||
var out := gx.origin - center
|
||||
out.y = 0.0
|
||||
out = out.normalized() if out.length() > 0.01 else Vector3(randf() - 0.5, 0.0, randf() - 0.5)
|
||||
rb.linear_velocity = out * randf_range(3.0, 6.0) + Vector3.UP * randf_range(3.0, 5.0)
|
||||
rb.angular_velocity = Vector3(randf_range(-10.0, 10.0),
|
||||
randf_range(-10.0, 10.0), randf_range(-10.0, 10.0))
|
||||
_despawn(rb)
|
||||
|
||||
|
||||
static func _shaft_collider() -> CollisionShape3D:
|
||||
# Collider along local +Z (mesh runs z≈-0.34 … 1.92), fattened so a fast throw
|
||||
# can't tunnel past the bull's collision spheres.
|
||||
var cap := CapsuleShape3D.new()
|
||||
cap.radius = 0.13
|
||||
cap.height = 2.2
|
||||
var cs := CollisionShape3D.new()
|
||||
cs.shape = cap
|
||||
cs.position = Vector3(0.0, 0.0, 0.79)
|
||||
cs.rotation_degrees = Vector3(90.0, 0.0, 0.0)
|
||||
return cs
|
||||
|
||||
|
||||
# Continuous bleed from a spear lodged in the bull: a steady spurt of dark-red gobs out
|
||||
# of the entry wound. Parented to the stuck mesh so it tracks the wound as the bull
|
||||
# moves; world-space particles (local_coords off) so they arc off and fall behind as a
|
||||
# trail. Dies with the mesh when the spear is shed or despawned.
|
||||
static func _wound_emitter() -> CPUParticles3D:
|
||||
var ramp := Gradient.new()
|
||||
ramp.set_color(0, Color(0.5, 0.02, 0.02, 1.0))
|
||||
ramp.set_color(1, Color(0.22, 0.0, 0.0, 0.0))
|
||||
|
||||
var sc := Curve.new()
|
||||
sc.add_point(Vector2(0.0, 1.0))
|
||||
sc.add_point(Vector2(0.6, 0.7))
|
||||
sc.add_point(Vector2(1.0, 0.0))
|
||||
|
||||
var p := CPUParticles3D.new()
|
||||
# Seat at the wound (where the shaft meets the hide) and spray back out along the
|
||||
# shaft: mesh local -Z is the exit direction, rotated into world by the node basis.
|
||||
p.position = Vector3(0.0, 0.0, _TIP_Z - _EMBED_DEPTH)
|
||||
p.emitting = true
|
||||
p.amount = 22
|
||||
p.lifetime = 0.6
|
||||
p.explosiveness = 0.0
|
||||
p.randomness = 0.6
|
||||
p.local_coords = false
|
||||
p.direction = Vector3(0.0, 0.55, -1.0).normalized()
|
||||
p.spread = 32.0
|
||||
p.gravity = Vector3(0.0, -12.0, 0.0)
|
||||
p.initial_velocity_min = 1.6
|
||||
p.initial_velocity_max = 4.0
|
||||
p.scale_amount_min = 0.5
|
||||
p.scale_amount_max = 1.4
|
||||
p.mesh = _blood_mesh()
|
||||
p.color_ramp = ramp
|
||||
p.scale_amount_curve = sc
|
||||
return p
|
||||
|
||||
|
||||
# Small unshaded, vertex-coloured sphere for blood gobs.
|
||||
static func _blood_mesh() -> SphereMesh:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.vertex_color_use_as_albedo = true
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
var s := SphereMesh.new()
|
||||
s.radius = 0.045
|
||||
s.height = 0.09
|
||||
s.radial_segments = 4
|
||||
s.rings = 2
|
||||
s.material = mat
|
||||
return s
|
||||
|
||||
|
||||
static func _despawn(node: Node) -> void:
|
||||
node.get_tree().create_timer(_DESPAWN_SEC).timeout.connect(func() -> void:
|
||||
if is_instance_valid(node):
|
||||
node.queue_free()
|
||||
)
|
||||
|
||||
|
||||
# Basis whose local +Z points along `v` (looking_at points -Z at its target).
|
||||
static func _aim_basis(v: Vector3) -> Basis:
|
||||
var dir := v.normalized()
|
||||
if dir.length_squared() < 0.0001:
|
||||
return Basis.IDENTITY
|
||||
var up := Vector3.UP if absf(dir.dot(Vector3.UP)) < 0.99 else Vector3.FORWARD
|
||||
return Basis.looking_at(-dir, up)
|
||||
|
||||
|
||||
# While flying, keep the shaft aligned with the velocity; on the first contact freeze
|
||||
# and hand off to _embed so the spear sticks where it struck instead of bouncing off.
|
||||
func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
|
||||
if _stopped:
|
||||
return
|
||||
if state.get_contact_count() > 0:
|
||||
_stopped = true
|
||||
# Use the remembered pre-impact heading — state.linear_velocity is already the
|
||||
# bounced (post-collision) velocity by now, which would aim the shaft wrong.
|
||||
var contact := state.get_contact_collider_position(0)
|
||||
var other := state.get_contact_collider_object(0)
|
||||
state.linear_velocity = Vector3.ZERO
|
||||
state.angular_velocity = Vector3.ZERO
|
||||
_embed.call_deferred(other, contact, _flight_dir)
|
||||
return
|
||||
var v := state.linear_velocity
|
||||
if v.length_squared() > 0.04:
|
||||
_flight_dir = v.normalized()
|
||||
var t := state.transform
|
||||
t.basis = _aim_basis(v)
|
||||
state.transform = t
|
||||
state.angular_velocity = Vector3.ZERO
|
||||
|
||||
|
||||
# Compose the stuck pose (tip buried _EMBED_DEPTH past the contact, shaft along the
|
||||
# flight line) and plant the spear. A bull hit hands the mesh to the bull so it rides
|
||||
# along as a spine; anything else pins the body in place for the despawn timer.
|
||||
func _embed(other: Object, contact: Vector3, flight: Vector3) -> void:
|
||||
var f := flight.normalized()
|
||||
if f.length_squared() < 0.0001:
|
||||
f = global_transform.basis.z.normalized()
|
||||
var pose := Transform3D(_aim_basis(f), contact + f * (_EMBED_DEPTH - _TIP_Z))
|
||||
var bull := other as Node
|
||||
if bull != null and bull.is_in_group(&"player"):
|
||||
bull.call(&"take_sword_hit", "thrown", contact)
|
||||
if is_instance_valid(_mesh) and is_instance_valid(bull):
|
||||
_mesh.reparent(bull, false)
|
||||
_mesh.global_transform = pose
|
||||
_mesh.add_to_group(_STUCK_GROUP)
|
||||
if Settings.gore:
|
||||
_mesh.add_child(_wound_emitter()) # keep bleeding from the wound while lodged
|
||||
queue_free()
|
||||
return
|
||||
freeze = true
|
||||
global_transform = pose
|
||||
@@ -0,0 +1 @@
|
||||
uid://dwjakh1is1jtj
|
||||
+13
-1
@@ -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 |
@@ -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 |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://qx85guumsl7p"
|
||||
path="res://.godot/imported/top.png-1fab202f4dd35d955a9e4ea2d0fefe58.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://tools/preview/top.png"
|
||||
dest_files=["res://.godot/imported/top.png-1fab202f4dd35d955a9e4ea2d0fefe58.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -0,0 +1,65 @@
|
||||
extends SceneTree
|
||||
## Top-down + angled aerial of the arena with the crowd, plus axis markers, to locate
|
||||
## the ruined section and any billboards left floating over it.
|
||||
## godot --script tools/preview_topdown.gd
|
||||
## Output: tools/preview/top.png (ortho top-down), tools/preview/aerial.png
|
||||
|
||||
const OUT := "res://tools/preview"
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
DirAccess.make_dir_recursive_absolute(OUT)
|
||||
var scene := (load("res://scene.tscn") as PackedScene).instantiate()
|
||||
root.add_child(scene)
|
||||
current_scene = scene
|
||||
|
||||
# Axis markers: red pillar at +X (east), green at +Z (south), so directions read.
|
||||
_marker(scene, Vector3(18, 5, 0), Color(1, 0.2, 0.2))
|
||||
_marker(scene, Vector3(0, 5, 18), Color(0.2, 1, 0.2))
|
||||
|
||||
await create_timer(0.4).timeout
|
||||
var crowd := scene.get_node_or_null(^"CrowdBillboards") as MultiMeshInstance3D
|
||||
if crowd != null and crowd.multimesh != null:
|
||||
print("crowd instances = ", crowd.multimesh.instance_count)
|
||||
|
||||
var top := Camera3D.new()
|
||||
top.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
top.size = 46.0
|
||||
scene.add_child(top)
|
||||
top.look_at_from_position(Vector3(0, 60, 0.01), Vector3.ZERO, Vector3(0, 0, -1))
|
||||
top.current = true
|
||||
await create_timer(0.2).timeout
|
||||
_shot("top.png")
|
||||
|
||||
var air := Camera3D.new()
|
||||
air.fov = 60.0
|
||||
scene.add_child(air)
|
||||
air.look_at_from_position(Vector3(0, 26, 34), Vector3(0, 3, 0), Vector3.UP)
|
||||
air.current = true
|
||||
await create_timer(0.2).timeout
|
||||
_shot("aerial.png")
|
||||
quit(0)
|
||||
|
||||
|
||||
func _marker(scene: Node, pos: Vector3, col: Color) -> void:
|
||||
var m := MeshInstance3D.new()
|
||||
var box := BoxMesh.new()
|
||||
box.size = Vector3(1.5, 10.0, 1.5)
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.albedo_color = col
|
||||
box.material = mat
|
||||
m.mesh = box
|
||||
scene.add_child(m)
|
||||
m.global_position = pos
|
||||
|
||||
|
||||
func _shot(name: String) -> void:
|
||||
var img := root.get_viewport().get_texture().get_image()
|
||||
if img != null:
|
||||
img.save_png(OUT + "/" + name)
|
||||
print("captured ", name)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfcfb205scjvt
|
||||
@@ -0,0 +1,315 @@
|
||||
extends CanvasLayer
|
||||
## On-screen controls for touch devices: a floating movement joystick on the left
|
||||
## and a cluster of ability buttons on the right. Instantiated by hud.gd.
|
||||
##
|
||||
## The joystick feeds the same `move_*` input actions the keyboard/gamepad drive
|
||||
## (via Input.action_press with an analog strength), so player.gd needs no touch
|
||||
## awareness — it just reads Input.get_vector() as always. Ability buttons call the
|
||||
## player's public try_activate_ability(), which shares the keyboard's cooldown gate.
|
||||
##
|
||||
## Shown only on touch platforms (see Controls.use_touch_ui), or when DP
|
||||
## "force_touch_controls" is on so the layout can be exercised on a desktop build.
|
||||
## emulate_touch_from_mouse (project.godot) lets a mouse stand in for a finger while
|
||||
## testing.
|
||||
|
||||
# move_* actions, indexed by the four joystick push directions.
|
||||
const _MOVE_RIGHT: StringName = &"move_right"
|
||||
const _MOVE_LEFT: StringName = &"move_left"
|
||||
const _MOVE_BACK: StringName = &"move_back"
|
||||
const _MOVE_FORWARD: StringName = &"move_forward"
|
||||
|
||||
# Ability slots in player.gd order: kick=0, dash=1, slam=2, roll=3.
|
||||
const _ABILITY_ICONS: Array[String] = [
|
||||
"res://HUD/KICK.png",
|
||||
"res://HUD/DASH.png",
|
||||
"res://HUD/SLAM.png",
|
||||
"res://HUD/ROLL.png",
|
||||
]
|
||||
const _ABILITY_LETTERS: Array[String] = ["K", "D", "S", "R"]
|
||||
const _ABILITY_ACCENT: Array[Color] = [
|
||||
Color(1.00, 0.62, 0.16), # Kick — ember orange
|
||||
Color(0.45, 0.75, 1.00), # Dash — sky blue
|
||||
Color(0.98, 0.34, 0.20), # Slam — crimson
|
||||
Color(1.00, 0.82, 0.32), # Roll — gold
|
||||
]
|
||||
|
||||
var _root: Control
|
||||
var _stick: Control
|
||||
var _cd_overlays: Array[ColorRect] = []
|
||||
|
||||
var _player: Node = null
|
||||
|
||||
# Floating-joystick state. _origin is where the thumb first touched (base centre),
|
||||
# _knob is the current thumb position; both in _stick-local coordinates. _touch_index
|
||||
# is the finger owning the stick (-1 = none, -2 = mouse), so a second finger tapping
|
||||
# an ability button never steals the stick.
|
||||
var _stick_active: bool = false
|
||||
var _origin: Vector2 = Vector2.ZERO
|
||||
var _knob: Vector2 = Vector2.ZERO
|
||||
var _touch_index: int = -3
|
||||
var _stick_radius: float = 110.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 10 # below the HUD (11) so the game-over screen always draws on top
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
|
||||
_root = Control.new()
|
||||
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_root.visible = false
|
||||
add_child(_root)
|
||||
|
||||
_build_joystick()
|
||||
_build_ability_buttons()
|
||||
_build_menu_button()
|
||||
_relayout()
|
||||
get_viewport().size_changed.connect(_relayout)
|
||||
|
||||
|
||||
# ── Joystick ────────────────────────────────────────────────────────────────
|
||||
# The stick zone is the whole left half of the screen: touching anywhere in it
|
||||
# plants the base under the thumb, so the player never has to find a fixed pad.
|
||||
|
||||
func _build_joystick() -> void:
|
||||
_stick = Control.new()
|
||||
_stick.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_stick.gui_input.connect(_on_stick_input)
|
||||
_stick.draw.connect(_draw_stick)
|
||||
_root.add_child(_stick)
|
||||
|
||||
|
||||
func _on_stick_input(event: InputEvent) -> void:
|
||||
if event is InputEventScreenTouch:
|
||||
var t := event as InputEventScreenTouch
|
||||
if t.pressed and not _stick_active:
|
||||
_begin_stick(t.position, t.index)
|
||||
elif not t.pressed and _touch_index == t.index:
|
||||
_end_stick()
|
||||
elif event is InputEventScreenDrag:
|
||||
var d := event as InputEventScreenDrag
|
||||
if _touch_index == d.index:
|
||||
_update_stick(d.position)
|
||||
elif event is InputEventMouseButton:
|
||||
var mb := event as InputEventMouseButton
|
||||
if mb.button_index == MOUSE_BUTTON_LEFT:
|
||||
if mb.pressed and not _stick_active:
|
||||
_begin_stick(mb.position, -2)
|
||||
elif not mb.pressed and _touch_index == -2:
|
||||
_end_stick()
|
||||
elif event is InputEventMouseMotion and _touch_index == -2:
|
||||
_update_stick((event as InputEventMouseMotion).position)
|
||||
|
||||
|
||||
func _begin_stick(pos: Vector2, index: int) -> void:
|
||||
_stick_active = true
|
||||
_touch_index = index
|
||||
_origin = pos
|
||||
_knob = pos
|
||||
_stick.queue_redraw()
|
||||
|
||||
|
||||
func _update_stick(pos: Vector2) -> void:
|
||||
if not _stick_active:
|
||||
return
|
||||
var offset := pos - _origin
|
||||
if offset.length() > _stick_radius:
|
||||
offset = offset.normalized() * _stick_radius
|
||||
_knob = _origin + offset
|
||||
_apply_move(offset / _stick_radius)
|
||||
_stick.queue_redraw()
|
||||
|
||||
|
||||
func _end_stick() -> void:
|
||||
_stick_active = false
|
||||
_touch_index = -3
|
||||
_release_move()
|
||||
_stick.queue_redraw()
|
||||
|
||||
|
||||
# Translate a normalised stick offset (screen space: +x right, +y down) into analog
|
||||
# strengths on the four movement actions. Opposite pairs are mutually exclusive, so
|
||||
# only one of each axis is ever pressed at a time.
|
||||
func _apply_move(v: Vector2) -> void:
|
||||
Input.action_press(_MOVE_RIGHT, maxf(0.0, v.x))
|
||||
Input.action_press(_MOVE_LEFT, maxf(0.0, -v.x))
|
||||
Input.action_press(_MOVE_BACK, maxf(0.0, v.y))
|
||||
Input.action_press(_MOVE_FORWARD, maxf(0.0, -v.y))
|
||||
|
||||
|
||||
func _release_move() -> void:
|
||||
for action: StringName in [_MOVE_RIGHT, _MOVE_LEFT, _MOVE_BACK, _MOVE_FORWARD]:
|
||||
Input.action_release(action)
|
||||
|
||||
|
||||
func _draw_stick() -> void:
|
||||
if not _stick_active:
|
||||
return
|
||||
var ring := Color(0.93, 0.88, 0.72, 0.30)
|
||||
var knob := Color(1.00, 0.82, 0.32, 0.55)
|
||||
_stick.draw_circle(_origin, _stick_radius, Color(0.0, 0.0, 0.0, 0.20))
|
||||
_stick.draw_arc(_origin, _stick_radius, 0.0, TAU, 48, ring, 4.0, true)
|
||||
_stick.draw_circle(_knob, _stick_radius * 0.42, knob)
|
||||
|
||||
|
||||
# ── Ability buttons ─────────────────────────────────────────────────────────
|
||||
# A 2×2 cluster in the bottom-right thumb reach. Each button taps the matching
|
||||
# ability; a dark overlay wipes down over it while that ability is cooling down.
|
||||
|
||||
func _build_ability_buttons() -> void:
|
||||
var grid := GridContainer.new()
|
||||
grid.name = "AbilityGrid"
|
||||
grid.columns = 2
|
||||
grid.add_theme_constant_override("h_separation", 14)
|
||||
grid.add_theme_constant_override("v_separation", 14)
|
||||
grid.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
grid.anchor_left = 1.0
|
||||
grid.anchor_right = 1.0
|
||||
grid.anchor_top = 1.0
|
||||
grid.anchor_bottom = 1.0
|
||||
grid.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||
grid.grow_vertical = Control.GROW_DIRECTION_BEGIN
|
||||
grid.offset_left = -220.0
|
||||
grid.offset_top = -220.0
|
||||
grid.offset_right = -24.0
|
||||
grid.offset_bottom = -24.0
|
||||
_root.add_child(grid)
|
||||
|
||||
for slot: int in 4:
|
||||
grid.add_child(_build_ability_button(slot))
|
||||
|
||||
|
||||
func _build_ability_button(slot: int) -> Control:
|
||||
var panel := PanelContainer.new()
|
||||
panel.custom_minimum_size = Vector2(96.0, 96.0)
|
||||
panel.add_theme_stylebox_override("panel", _button_style(slot))
|
||||
panel.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
|
||||
var stack := Control.new()
|
||||
stack.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
panel.add_child(stack)
|
||||
|
||||
if ResourceLoader.exists(_ABILITY_ICONS[slot]):
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = load(_ABILITY_ICONS[slot])
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
stack.add_child(icon)
|
||||
else:
|
||||
var lbl := Label.new()
|
||||
lbl.text = _ABILITY_LETTERS[slot]
|
||||
lbl.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
lbl.add_theme_color_override("font_color", _ABILITY_ACCENT[slot])
|
||||
lbl.add_theme_font_size_override("font_size", 40)
|
||||
stack.add_child(lbl)
|
||||
|
||||
# Cooldown wipe — anchored to the bottom, its top edge driven in _process so it
|
||||
# shrinks away as the ability comes off cooldown (mirrors the desktop HUD tiles).
|
||||
var cd := ColorRect.new()
|
||||
cd.color = Color(0.0, 0.0, 0.0, 0.62)
|
||||
cd.anchor_left = 0.0
|
||||
cd.anchor_right = 1.0
|
||||
cd.anchor_top = 1.0
|
||||
cd.anchor_bottom = 1.0
|
||||
cd.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
stack.add_child(cd)
|
||||
_cd_overlays.append(cd)
|
||||
|
||||
# Fire on press (not release) so abilities feel instant under the thumb.
|
||||
panel.gui_input.connect(_on_ability_input.bind(slot))
|
||||
return panel
|
||||
|
||||
|
||||
func _on_ability_input(event: InputEvent, slot: int) -> void:
|
||||
var pressed := false
|
||||
if event is InputEventScreenTouch:
|
||||
pressed = (event as InputEventScreenTouch).pressed
|
||||
elif event is InputEventMouseButton:
|
||||
var mb := event as InputEventMouseButton
|
||||
pressed = mb.pressed and mb.button_index == MOUSE_BUTTON_LEFT
|
||||
if pressed and _player != null:
|
||||
_player.call(&"try_activate_ability", slot)
|
||||
|
||||
|
||||
func _button_style(slot: int) -> StyleBoxFlat:
|
||||
var s := StyleBoxFlat.new()
|
||||
s.bg_color = Color(0.10, 0.07, 0.03, 0.80)
|
||||
s.set_corner_radius_all(18)
|
||||
s.corner_detail = 6
|
||||
s.set_border_width_all(3)
|
||||
s.border_color = _ABILITY_ACCENT[slot]
|
||||
return s
|
||||
|
||||
|
||||
# ── Menu button ──────────────────────────────────────────────────────────────
|
||||
# Top-right "☰" — the touch stand-in for the keyboard's Esc-to-menu, so a phone
|
||||
# player has a way out of a run.
|
||||
|
||||
func _build_menu_button() -> void:
|
||||
var btn := Button.new()
|
||||
btn.text = "☰"
|
||||
btn.custom_minimum_size = Vector2(56.0, 56.0)
|
||||
btn.focus_mode = Control.FOCUS_NONE
|
||||
btn.anchor_left = 1.0
|
||||
btn.anchor_right = 1.0
|
||||
btn.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||
btn.offset_left = -72.0
|
||||
btn.offset_top = 16.0
|
||||
btn.offset_right = -16.0
|
||||
btn.offset_bottom = 72.0
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.10, 0.07, 0.03, 0.80)
|
||||
style.set_corner_radius_all(12)
|
||||
style.set_border_width_all(2)
|
||||
style.border_color = Color(1.00, 0.78, 0.22, 0.8)
|
||||
btn.add_theme_stylebox_override("normal", style)
|
||||
btn.add_theme_stylebox_override("hover", style)
|
||||
btn.add_theme_stylebox_override("pressed", style)
|
||||
btn.add_theme_color_override("font_color", Color(1.00, 0.78, 0.22))
|
||||
btn.add_theme_font_size_override("font_size", 28)
|
||||
btn.pressed.connect(_return_to_menu)
|
||||
_root.add_child(btn)
|
||||
|
||||
|
||||
func _return_to_menu() -> void:
|
||||
get_tree().paused = false
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
get_tree().change_scene_to_file("res://MainMenu.tscn")
|
||||
|
||||
|
||||
# ── Layout & lifecycle ──────────────────────────────────────────────────────
|
||||
|
||||
func _relayout() -> void:
|
||||
var vp := get_viewport().get_visible_rect().size
|
||||
# Left half is the joystick zone; base radius scales with the shorter screen edge.
|
||||
_stick.position = Vector2.ZERO
|
||||
_stick.size = Vector2(vp.x * 0.5, vp.y)
|
||||
_stick_radius = clampf(minf(vp.x, vp.y) * 0.16, 80.0, 150.0)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
var active := Controls.use_touch_ui()
|
||||
# The game-over / pause screen owns the display when paused — stand down and drop
|
||||
# any held movement so the bull doesn't coast on a stuck joystick.
|
||||
if not active or get_tree().paused:
|
||||
if _root.visible:
|
||||
_root.visible = false
|
||||
if _stick_active:
|
||||
_end_stick()
|
||||
return
|
||||
_root.visible = true
|
||||
|
||||
if _player == null:
|
||||
var players := get_tree().get_nodes_in_group(&"player")
|
||||
if not players.is_empty():
|
||||
_player = players[0]
|
||||
return
|
||||
|
||||
for slot: int in _cd_overlays.size():
|
||||
var fraction: float = _player.call(&"ability_cooldown_fraction", slot)
|
||||
_cd_overlays[slot].anchor_top = 1.0 - clampf(fraction, 0.0, 1.0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d4ky4pbu70bo3
|
||||
+12
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user