Compare commits
14 Commits
a147527e91
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 02aec4249f | |||
| 431ee295ae | |||
| a34989c49e | |||
| e810d2e75b | |||
| d2a59f695e | |||
| d5c6b749d0 | |||
| 56a4965ff7 | |||
| d8d6b11508 | |||
| a9440c6a43 | |||
| 1ea4171863 | |||
| cd54242de0 | |||
| 91d88cd5c7 | |||
| 6dff45c659 | |||
| 2fd41f4f69 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -835,9 +835,8 @@ func take_sword_hit(cause: String = "", hit_pos: Vector3 = Vector3.ZERO) -> void
|
||||
health_changed.emit(_hp, _max_hp)
|
||||
hit_taken.emit(cause)
|
||||
camera_pivot.call(&"trigger_hit", 0.7)
|
||||
# Vibrate controller 0 with half weak strength, full strong strength, for 0.5 seconds
|
||||
if DP.b("gamepad_vibration"):
|
||||
Input.start_joy_vibration(0, 0.5, 1, 0.2)
|
||||
# Vibrate controller 0 with half weak strength, full strong strength, for 0.2 seconds
|
||||
Controls.vibrate(0, 0.5, 1, 0.2)
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -23,7 +23,7 @@ script = ExtResource("2_camera_iso")
|
||||
[node name="bull" parent="." unique_id=1386495166 instance=ExtResource("4_e80uo")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.655, 0.23)
|
||||
|
||||
[node name="Skeleton3D" parent="bull/Armature" parent_id_path=PackedInt32Array(1386495166, 1942410469) index="0" unique_id=550138996]
|
||||
[node name="Skeleton3D" parent="bull/Armature" parent_id_path=PackedInt32Array(1386495166, 1057197468) index="0" unique_id=840295370]
|
||||
bones/0/position = Vector3(0, 0, 0.009963839)
|
||||
bones/0/rotation = Quaternion(0.7071068, 0, 0, 0.7071067)
|
||||
bones/0/scale = Vector3(1, 1, 1)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
extends Node3D
|
||||
|
||||
var player: Node3D = null
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# If we don't have the player yet, try to find them in the group
|
||||
if not is_instance_valid(player):
|
||||
var players = get_tree().get_nodes_in_group("player")
|
||||
if players.size() > 0:
|
||||
player = players[0] as Node3D
|
||||
|
||||
# If the player exists, follow their position
|
||||
if is_instance_valid(player):
|
||||
global_position = player.global_position
|
||||
@@ -0,0 +1 @@
|
||||
uid://cmapsaj010q40
|
||||
@@ -0,0 +1,14 @@
|
||||
[gd_scene format=3 uid="uid://4v25hb0qu5l0"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://cmapsaj010q40" path="res://actors/player/player_follow_light.gd" id="1_w7wrt"]
|
||||
|
||||
[node name="PlayerFollowLight" type="Node3D" unique_id=1889971494]
|
||||
script = ExtResource("1_w7wrt")
|
||||
|
||||
[node name="SpotLight3D" type="SpotLight3D" parent="." unique_id=1523840927]
|
||||
transform = Transform3D(1, 0, 0, 0, -4.371139e-08, 1, 0, -1, -4.371139e-08, 0, 2.6136062, 1.0431258e-07)
|
||||
light_color = Color(0.9999998, 0.8702623, 0.60548127, 1)
|
||||
light_energy = 8.0
|
||||
shadow_enabled = true
|
||||
distance_fade_length = 30.45
|
||||
spot_angle = 72.62192
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
[ext_resource type="PackedScene" uid="uid://bbfvwolhomilp" path="res://props/torch.tscn" id="1_w7uyf"]
|
||||
[ext_resource type="PackedScene" uid="uid://bd56iuttyiypv" path="res://props/barrel_simple.tscn" id="2_3o0md"]
|
||||
[ext_resource type="PackedScene" uid="uid://4v25hb0qu5l0" path="res://actors/player/player_follow_light.tscn" id="2_8bh2m"]
|
||||
[ext_resource type="PackedScene" uid="uid://dctmlh4hnuux1" path="res://art/levels/lobby.glb" id="2_w7uyf"]
|
||||
[ext_resource type="Script" uid="uid://cwbf7jfkr8j10" path="res://levels/lobby_underground.gd" id="4_73452"]
|
||||
[ext_resource type="Script" uid="uid://dn77mjyxllgl6" path="res://levels/to_the_bear_level.gd" id="5_6j2sa"]
|
||||
@@ -45,8 +46,8 @@ size = Vector3(4.1274414, 8.299622, 6.9958496)
|
||||
|
||||
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="Lights" unique_id=955995990]
|
||||
transform = Transform3D(-0.502238, -0.82287306, 0.26577574, 0.36594617, 0.07621761, 0.9275096, -0.78347975, 0.5630903, 0.26284787, 0, 0, 0)
|
||||
light_color = Color(0.78431374, 0.8117647, 1, 1)
|
||||
light_energy = 0.228
|
||||
light_color = Color(0.5266291, 0.5549962, 0.9999985, 1)
|
||||
light_energy = 0.466
|
||||
shadow_enabled = true
|
||||
|
||||
[node name="SpotLight3D" type="SpotLight3D" parent="Lights" unique_id=1783040386]
|
||||
@@ -99,6 +100,9 @@ transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, -0.3
|
||||
[node name="Torch8" parent="Lights/Torches" unique_id=987701581 instance=ExtResource("1_w7uyf")]
|
||||
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, -0.349617, 1.9073486e-06, -0.31505227)
|
||||
|
||||
[node name="PlayerFollowLight" parent="Lights" unique_id=1889971494 instance=ExtResource("2_8bh2m")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.1712027, 0)
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=939963056]
|
||||
environment = SubResource("Environment_lobby")
|
||||
|
||||
@@ -240,7 +244,7 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.1928787, 0.11255789, 2.8063
|
||||
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 0, 0, 0)
|
||||
script = ExtResource("4_73452")
|
||||
|
||||
[node name="Plane" parent="Lobby_underground" index="1" unique_id=1962555538]
|
||||
[node name="Plane" parent="Lobby_underground" index="1" unique_id=2038841618]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.2352347, 0)
|
||||
|
||||
[node name="AnimationPlayer" type="AnimationPlayer" parent="Lobby_underground" unique_id=2070502042]
|
||||
|
||||
@@ -11,5 +11,4 @@ func _process(_delta: float) -> void:
|
||||
if Run.bear_gate_open() or DP.b("gate_unlocked_bear"):
|
||||
bear_gate_open = true
|
||||
$AnimationPlayer.play("Gate_opening")
|
||||
if DP.b("gamepad_vibration"):
|
||||
Input.start_joy_vibration(0, 0.5, .5, 7)
|
||||
Controls.vibrate_for(0, 0.5, .5, 7.0)
|
||||
|
||||
@@ -83,7 +83,6 @@ func _on_matador_killed() -> void:
|
||||
if _alive > 0 or _resolved:
|
||||
return
|
||||
if _wave >= DP.f("mat_wave_size"):
|
||||
DP.set_value("gate_unlocked_bear", true)
|
||||
_resolved = true
|
||||
all_defeated.emit()
|
||||
else:
|
||||
|
||||
@@ -33,6 +33,14 @@ var cleared: Dictionary = {}
|
||||
## loads — the player reads it on spawn — and is capped so it can't stack past BONUS_HP_CAP.
|
||||
var bonus_hp: int = 0
|
||||
const BONUS_HP_CAP: int = 2
|
||||
|
||||
## Wall-clock run timer: starts the moment the player walks through the arena gate (the
|
||||
## run's first fight) and freezes the instant the bear falls, so the completion screen can
|
||||
## show a final time. Time.get_ticks_msec()-based — real time, not delta-summed — so it
|
||||
## keeps ticking correctly across the scene reload between the arena and the bear's den.
|
||||
var _timer_start_msec: int = -1
|
||||
var _timer_end_msec: int = -1
|
||||
var _timer_running: bool = false
|
||||
## True once start_new_run() has built a run. Lets ensure_run() avoid wiping an active run
|
||||
## when the shell reloads for each fight.
|
||||
var _run_active: bool = false
|
||||
@@ -105,6 +113,9 @@ func start_new_run() -> void:
|
||||
lobby_choice = null
|
||||
cleared = {}
|
||||
bonus_hp = 0
|
||||
_timer_start_msec = -1
|
||||
_timer_end_msec = -1
|
||||
_timer_running = false
|
||||
_run_active = true
|
||||
run_started.emit()
|
||||
|
||||
@@ -154,3 +165,39 @@ func add_bonus_hp(amount: int) -> int:
|
||||
var before := bonus_hp
|
||||
bonus_hp = clampi(bonus_hp + amount, 0, BONUS_HP_CAP)
|
||||
return bonus_hp - before
|
||||
|
||||
|
||||
## Begin the run clock. No-op while the clock is already running, so walking back through
|
||||
## the arena gate mid-run can't restart it — but once it's been stopped (the bear is dead
|
||||
## and the player has looped back to the arena to go again), the next call restarts it from
|
||||
## zero rather than staying stuck showing the previous run's final time.
|
||||
func start_run_timer() -> void:
|
||||
if _timer_running:
|
||||
return
|
||||
_timer_start_msec = Time.get_ticks_msec()
|
||||
_timer_end_msec = -1
|
||||
_timer_running = true
|
||||
|
||||
|
||||
## Freeze the run clock — called the instant the bear falls, so the recorded time is the
|
||||
## kill moment itself, not whatever the win-screen's celebratory delay adds on top.
|
||||
func stop_run_timer() -> void:
|
||||
if not _timer_running:
|
||||
return
|
||||
_timer_end_msec = Time.get_ticks_msec()
|
||||
_timer_running = false
|
||||
|
||||
|
||||
## Seconds elapsed since the timer started (live while running, held steady once
|
||||
## stop_run_timer() has been called). 0 before the timer has ever started.
|
||||
func run_elapsed() -> float:
|
||||
if _timer_start_msec < 0:
|
||||
return 0.0
|
||||
var end_msec := Time.get_ticks_msec() if _timer_running else _timer_end_msec
|
||||
return float(end_msec - _timer_start_msec) / 1000.0
|
||||
|
||||
|
||||
## Whether the run clock has been started (the player has entered the arena at least once
|
||||
## this run). Lets the HUD hide the timer readout until there's a time worth showing.
|
||||
func run_timer_started() -> bool:
|
||||
return _timer_start_msec >= 0
|
||||
|
||||
@@ -8,5 +8,6 @@ func _ready() -> void:
|
||||
func _on_body_entered(body: Node3D) -> void:
|
||||
if not body.is_in_group("player"):
|
||||
return
|
||||
Run.start_run_timer()
|
||||
Run.choose_level(&"arena")
|
||||
get_tree().change_scene_to_file(Run.active_level().scene_path)
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ config_version=5
|
||||
[application]
|
||||
|
||||
config/name="Bullosseum"
|
||||
config/version="0.4.0"
|
||||
config/version="0.5.0"
|
||||
run/main_scene="uid://bmnmenu0intr1"
|
||||
config/features=PackedStringArray("4.7", "GL Compatibility")
|
||||
boot_splash/bg_color=Color(0, 0, 0, 1)
|
||||
|
||||
@@ -167,6 +167,7 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -9.011785, -2.371254, 16.2322
|
||||
[node name="OmniLight3D" type="OmniLight3D" parent="MeshInstance3D/TorchLight" unique_id=1094582672]
|
||||
transform = Transform3D(0.9999986, -0.001596809, 0.00045388562, 0.0014380338, 0.96984345, 0.24372463, -0.00082937966, -0.24372365, 0.9698444, 9.096397, 6.2542057, -15.77264)
|
||||
light_color = Color(0.92773837, 0.6304423, 0, 1)
|
||||
light_energy = 2.0
|
||||
shadow_enabled = true
|
||||
omni_range = 13.657384
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ const SECTION := "options"
|
||||
var screen_shake: bool = true
|
||||
## Gore toggle — when off, no blood spawns (impact spurt + ongoing spear-wound bleed).
|
||||
var gore: bool = true
|
||||
## Controller rumble toggle — player-facing; Controls.vibrate() also respects the DP
|
||||
## "gamepad_vibration" debug flag, so either can silence it.
|
||||
var vibration: bool = true
|
||||
|
||||
## Audio volumes stored as linear 0–1. Applied to AudioServer buses on load/change.
|
||||
var volume_master: float = 1.0
|
||||
@@ -62,6 +65,14 @@ func set_gore(value: bool) -> void:
|
||||
save()
|
||||
|
||||
|
||||
func set_vibration(value: bool) -> void:
|
||||
if vibration == value:
|
||||
return
|
||||
vibration = value
|
||||
changed.emit("vibration", value)
|
||||
save()
|
||||
|
||||
|
||||
func set_volume_master(value: float) -> void:
|
||||
volume_master = clampf(value, 0.0, 1.0)
|
||||
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Master"), _vol_db(volume_master))
|
||||
@@ -96,6 +107,7 @@ func save() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
cfg.set_value(SECTION, "screen_shake", screen_shake)
|
||||
cfg.set_value(SECTION, "gore", gore)
|
||||
cfg.set_value(SECTION, "vibration", vibration)
|
||||
cfg.set_value(SECTION, "volume_master", volume_master)
|
||||
cfg.set_value(SECTION, "volume_ambient", volume_ambient)
|
||||
cfg.set_value(SECTION, "volume_effects", volume_effects)
|
||||
@@ -110,6 +122,7 @@ func load_saved() -> void:
|
||||
return
|
||||
screen_shake = cfg.get_value(SECTION, "screen_shake", true) as bool
|
||||
gore = cfg.get_value(SECTION, "gore", true) as bool
|
||||
vibration = cfg.get_value(SECTION, "vibration", true) as bool
|
||||
volume_master = cfg.get_value(SECTION, "volume_master", 1.0) as float
|
||||
volume_ambient = cfg.get_value(SECTION, "volume_ambient", 1.0) as float
|
||||
volume_effects = cfg.get_value(SECTION, "volume_effects", 1.0) as float
|
||||
|
||||
@@ -20,6 +20,7 @@ const _COMMANDS := {
|
||||
"list": "print params (optional section)",
|
||||
"level": "select level and reload (level <id> — arena / arena_boss / bear)",
|
||||
"restart": "reload the current level scene",
|
||||
"video": "preview an outcome screen (video win / loss / finish)",
|
||||
"clear": "clear the log",
|
||||
"help": "show this help",
|
||||
}
|
||||
@@ -206,12 +207,6 @@ func _input(event: InputEvent) -> void:
|
||||
# while the console is open — cheap, and it never lets the command line go dark.
|
||||
func _process(_delta: float) -> void:
|
||||
if _is_open and not _input_field.has_focus():
|
||||
# Diagnostic: name whatever currently holds focus before we snatch it back, so
|
||||
# a persistent thief (a HUD button, a menu control) shows up in the log instead
|
||||
# of us guessing. Remove once the culprit is identified.
|
||||
var owner: Control = get_viewport().gui_get_focus_owner()
|
||||
var who: String = String(owner.name) if owner != null else "<nothing>"
|
||||
_log_warn("focus stolen by %s — reclaiming" % who)
|
||||
_input_field.grab_focus()
|
||||
|
||||
|
||||
@@ -476,6 +471,8 @@ func _run_command(parts: PackedStringArray) -> bool:
|
||||
_cmd_list(parts)
|
||||
"level":
|
||||
_cmd_level(parts)
|
||||
"video":
|
||||
_cmd_video(parts)
|
||||
"restart":
|
||||
_log_ok("Restarting…")
|
||||
get_tree().paused = false
|
||||
@@ -608,6 +605,27 @@ func _set_param(key: String, meta: Dictionary, raw: String) -> void:
|
||||
_log_warn("Cannot set param of type %d" % meta["type"])
|
||||
|
||||
|
||||
# Jumps straight to an outcome screen (win / loss / the bear's thankyou.ogv finish clip)
|
||||
# via hud.gd's debug-only entry point — a quick way to preview a clip's framing/timing
|
||||
# without actually clearing (or dying to) a fight.
|
||||
const _VIDEO_KINDS := ["win", "loss", "finish"]
|
||||
|
||||
|
||||
func _cmd_video(parts: PackedStringArray) -> void:
|
||||
if parts.size() < 2 or not _VIDEO_KINDS.has(parts[1].to_lower()):
|
||||
_log_warn("Usage: video <%s>" % "|".join(_VIDEO_KINDS))
|
||||
return
|
||||
var hud: Node = get_tree().get_first_node_in_group(&"hud")
|
||||
if hud == null:
|
||||
_log_warn("No HUD in the current scene.")
|
||||
return
|
||||
var kind := parts[1].to_lower()
|
||||
get_tree().paused = false
|
||||
_toggle() # close the console so it doesn't sit on top of the outcome screen
|
||||
hud.debug_force_outcome(kind)
|
||||
_log_ok("Playing '%s' outcome…" % kind)
|
||||
|
||||
|
||||
func _cmd_level(parts: PackedStringArray) -> void:
|
||||
if parts.size() == 1:
|
||||
var cur := Run.active_level()
|
||||
|
||||
@@ -7,6 +7,7 @@ extends Node
|
||||
|
||||
const SAVE_PATH := "user://controls.cfg"
|
||||
const SECTION := "keybinds"
|
||||
const JOY_SECTION := "joybinds"
|
||||
|
||||
## Actions the options menu lets the player rebind (keyboard only).
|
||||
const REBINDABLE_ACTIONS: PackedStringArray = [
|
||||
@@ -20,12 +21,162 @@ const REBINDABLE_ACTIONS: PackedStringArray = [
|
||||
&"ability_roll",
|
||||
]
|
||||
|
||||
## Actions the options menu also lets the player rebind on a gamepad. Movement stays on the
|
||||
## D-pad/stick (standard, not individually remapped); only the face-button abilities are.
|
||||
const JOY_REBINDABLE_ACTIONS: PackedStringArray = [
|
||||
&"ability_kick",
|
||||
&"ability_dash",
|
||||
&"ability_slam",
|
||||
&"ability_roll",
|
||||
]
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_ensure_gamepad_confirm_binding()
|
||||
load_saved()
|
||||
_install_web_fullscreen_hook()
|
||||
|
||||
|
||||
# ── Active input device ──────────────────────────────────────────────────────────
|
||||
# Tracks whether the player's last deliberate input was a gamepad or keyboard/mouse, so the
|
||||
# HUD can swap its ability-bar keycaps to match whichever the player is actually holding.
|
||||
# Mouse motion is deliberately ignored — the mouse still drives the camera regardless of
|
||||
# which device is moving the bull, so panning the camera shouldn't flip the hint away from
|
||||
# gamepad glyphs. A small deadzone on joypad axes avoids stick drift/noise doing the same.
|
||||
const _JOY_AXIS_DEADZONE := 0.5
|
||||
var _last_input_is_gamepad: bool = false
|
||||
## Device id of whichever gamepad was pressed most recently — which controller's name we
|
||||
## check to pick Xbox vs PlayStation glyphs when more than one is connected.
|
||||
var _last_joy_device: int = 0
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if event is InputEventJoypadButton:
|
||||
if event.pressed:
|
||||
_last_input_is_gamepad = true
|
||||
_last_joy_device = event.device
|
||||
elif event is InputEventJoypadMotion:
|
||||
if absf(event.axis_value) > _JOY_AXIS_DEADZONE:
|
||||
_last_input_is_gamepad = true
|
||||
_last_joy_device = event.device
|
||||
elif event is InputEventKey:
|
||||
if event.pressed:
|
||||
_last_input_is_gamepad = false
|
||||
elif event is InputEventMouseButton:
|
||||
if event.pressed:
|
||||
_last_input_is_gamepad = false
|
||||
|
||||
|
||||
## Whether the player's last button/key/stick press was on a gamepad rather than
|
||||
## keyboard/mouse. Drives which glyph set the HUD's ability keycaps show.
|
||||
func using_gamepad() -> bool:
|
||||
return _last_input_is_gamepad
|
||||
|
||||
|
||||
# Godot's joypad button indices are already Xbox-position-normalized — by SDL on native, by
|
||||
# the browser's Standard Gamepad Mapping on web — so a position-based glyph table reads
|
||||
# correctly for any controller brand with no per-device special-casing (a Nintendo pad's
|
||||
# physically-labelled "A" is button 1 here, same as "B" on Xbox; PlayStation's ✕/○/□/△ sit
|
||||
# in the exact same four positions as Xbox's A/B/X/Y, so the same button-index keys apply).
|
||||
const _XBOX_BUTTON_GLYPHS := {
|
||||
JOY_BUTTON_A: "A",
|
||||
JOY_BUTTON_B: "B",
|
||||
JOY_BUTTON_X: "X",
|
||||
JOY_BUTTON_Y: "Y",
|
||||
}
|
||||
const _PLAYSTATION_BUTTON_GLYPHS := {
|
||||
JOY_BUTTON_A: "✕",
|
||||
JOY_BUTTON_B: "○",
|
||||
JOY_BUTTON_X: "□",
|
||||
JOY_BUTTON_Y: "△",
|
||||
}
|
||||
## Substrings (checked lower-case) that identify a controller as a PlayStation pad from
|
||||
## Input.get_joy_name() — which varies a lot by OS/browser/driver, so this is necessarily a
|
||||
## heuristic. "wireless controller" is what macOS/some browsers call a DualShock 4 with no
|
||||
## further detail — but a Bluetooth-connected Xbox pad reports as "Xbox Wireless Controller",
|
||||
## so _is_playstation_pad checks for "xbox" first and bails out before these hints, or that
|
||||
## exact string would misfire as PlayStation. Anything that matches neither reads as
|
||||
## Xbox-style — the safe default, since unrecognised/generic pads and the browser's Standard
|
||||
## Gamepad Mapping are Xbox-shaped.
|
||||
const _PLAYSTATION_NAME_HINTS := [
|
||||
"sony", "playstation", "dualshock", "dualsense", "ps3", "ps4", "ps5", "wireless controller",
|
||||
]
|
||||
|
||||
|
||||
## True if the given gamepad's reported name looks like a PlayStation pad (DualShock/
|
||||
## DualSense), false (Xbox-style) for everything else, including unknown controllers.
|
||||
func _is_playstation_pad(device: int) -> bool:
|
||||
var name := Input.get_joy_name(device).to_lower()
|
||||
if name.contains("xbox"):
|
||||
return false
|
||||
for hint: String in _PLAYSTATION_NAME_HINTS:
|
||||
if name.contains(hint):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## Returns the InputEventJoypadButton bound to `action`, or null.
|
||||
func _get_joy_button_event(action: StringName) -> InputEventJoypadButton:
|
||||
for event: InputEvent in InputMap.action_get_events(action):
|
||||
if event is InputEventJoypadButton:
|
||||
return event
|
||||
return null
|
||||
|
||||
|
||||
## Face-button glyph for the action's bound joypad button — Xbox letters (A/B/X/Y) or
|
||||
## PlayStation shapes (✕/○/□/△) depending on whichever pad was pressed most recently — or
|
||||
## "—" if the action has no joypad binding.
|
||||
func get_joy_button_label(action: StringName) -> String:
|
||||
var event := _get_joy_button_event(action)
|
||||
if event == null:
|
||||
return "—"
|
||||
var glyphs := _PLAYSTATION_BUTTON_GLYPHS if _is_playstation_pad(_last_joy_device) \
|
||||
else _XBOX_BUTTON_GLYPHS
|
||||
return glyphs.get(event.button_index, "Btn %d" % event.button_index)
|
||||
|
||||
|
||||
## Replaces the joypad button bound to `action`, keeping every other event (keyboard,
|
||||
## alternates) in place.
|
||||
func rebind_joy(action: StringName, event: InputEventJoypadButton) -> void:
|
||||
_rebind_joy_no_save(action, event)
|
||||
save()
|
||||
|
||||
|
||||
## Rebuilds the action's event list, swapping the first joypad-button event for `event` in
|
||||
## place so ordering is preserved, same as _rebind_no_save() does for keyboard.
|
||||
func _rebind_joy_no_save(action: StringName, event: InputEventJoypadButton) -> void:
|
||||
var events := InputMap.action_get_events(action)
|
||||
InputMap.action_erase_events(action)
|
||||
var replaced := false
|
||||
for existing: InputEvent in events:
|
||||
if not replaced and existing is InputEventJoypadButton:
|
||||
InputMap.action_add_event(action, event)
|
||||
replaced = true
|
||||
else:
|
||||
InputMap.action_add_event(action, existing)
|
||||
if not replaced:
|
||||
InputMap.action_add_event(action, event)
|
||||
|
||||
|
||||
## Ability-bar keycap label for `action`: the bound Xbox-style face button while a gamepad
|
||||
## is in use, else the bound keyboard key — so the on-screen prompt always matches whichever
|
||||
## the player is actually holding.
|
||||
func ability_key_label(action: StringName) -> String:
|
||||
return get_joy_button_label(action) if using_gamepad() else get_key_label(action)
|
||||
|
||||
|
||||
## Godot's built-in ui_accept (menu/button confirm) ships with zero joypad bindings —
|
||||
## checked directly: only Enter/Kp Enter/Space. ui_left/right/up/down, by contrast, do
|
||||
## default to the D-pad and stick, which is why gamepad focus navigation on a menu works out
|
||||
## of the box while "confirming" the focused button does nothing. Bind joypad button 0 (the
|
||||
## bottom face button in Xbox-style position mapping) as the confirm button.
|
||||
func _ensure_gamepad_confirm_binding() -> void:
|
||||
var alt := InputEventJoypadButton.new()
|
||||
alt.button_index = JOY_BUTTON_A
|
||||
if not InputMap.action_has_event(&"ui_accept", alt):
|
||||
InputMap.action_add_event(&"ui_accept", alt)
|
||||
|
||||
|
||||
# ── Web fullscreen + landscape lock ─────────────────────────────────────────────
|
||||
# The reliable way to force fullscreen on the web: install a NATIVE DOM listener that calls
|
||||
# requestFullscreen() synchronously inside the real user-gesture event — the same thing
|
||||
@@ -222,8 +373,59 @@ func rigid_skin_enabled() -> bool:
|
||||
return DP.b("use_rigid_skin")
|
||||
|
||||
|
||||
## Rumble a connected gamepad, gated on the player-facing Settings.vibration toggle and the
|
||||
## DP "gamepad_vibration" debug flag (either can silence it). Off web this is a thin wrapper
|
||||
## over Input.start_joy_vibration(); on web that call is a documented engine no-op
|
||||
## (godotengine/godot#96985 — never wired to the browser side), so there we drive the Gamepad
|
||||
## Haptic Actuator API ourselves. Safe no-op wherever it's unsupported (Safari, most Firefox
|
||||
## versions, or a controller with no rumble motors) — desktop Chromium/Edge is the target.
|
||||
func vibrate(device: int, weak_magnitude: float, strong_magnitude: float, duration: float) -> void:
|
||||
if not Settings.vibration or not DP.b("gamepad_vibration"):
|
||||
return
|
||||
if not OS.has_feature("web"):
|
||||
Input.start_joy_vibration(device, weak_magnitude, strong_magnitude, duration)
|
||||
return
|
||||
JavaScriptBridge.eval("""
|
||||
(function(){
|
||||
try {
|
||||
var pads = navigator.getGamepads ? navigator.getGamepads() : [];
|
||||
var pad = pads[%d];
|
||||
var act = pad && pad.vibrationActuator;
|
||||
if (act && act.playEffect) {
|
||||
act.playEffect('dual-rumble', {
|
||||
duration: %d,
|
||||
weakMagnitude: %f,
|
||||
strongMagnitude: %f
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
""" % [device, int(duration * 1000.0), weak_magnitude, strong_magnitude], true)
|
||||
|
||||
|
||||
## Godot's own start_joy_vibration() docs warn that a single long effect is unreliable —
|
||||
## "not every hardware is compatible with long effects; you may want to make a shorter
|
||||
## effect and make it repeat instead." That's exactly what broke the lobby gate's 7-second
|
||||
## rumble (the short 0.2 s hit-vibration works fine): this repeats vibrate() as short pulses
|
||||
## to cover the same total duration instead of one long call.
|
||||
## Explicitly stopping between pulses (rather than letting each one time out on its own) and
|
||||
## spacing them out avoids re-triggering the effect faster than some drivers/browsers can
|
||||
## retire the previous one — back-to-back-to-back start calls left the gamepad's rumble
|
||||
## motor stuck reporting "busy" for the rest of the session on at least one setup.
|
||||
func vibrate_for(device: int, weak_magnitude: float, strong_magnitude: float,
|
||||
total_duration: float, pulse: float = 0.25, gap: float = 0.6) -> void:
|
||||
var elapsed := 0.0
|
||||
while elapsed < total_duration:
|
||||
vibrate(device, weak_magnitude, strong_magnitude, pulse)
|
||||
await get_tree().create_timer(pulse).timeout
|
||||
if not OS.has_feature("web"):
|
||||
Input.stop_joy_vibration(device)
|
||||
await get_tree().create_timer(gap).timeout
|
||||
elapsed += pulse + gap
|
||||
|
||||
|
||||
## Returns the first keyboard event bound to an action, or null.
|
||||
func get_key_event(action: StringName) -> InputEventKey:
|
||||
func _get_key_event(action: StringName) -> InputEventKey:
|
||||
for event: InputEvent in InputMap.action_get_events(action):
|
||||
if event is InputEventKey:
|
||||
return event
|
||||
@@ -232,7 +434,7 @@ func get_key_event(action: StringName) -> InputEventKey:
|
||||
|
||||
## Human-readable label for the action's current key (e.g. "W", "Space").
|
||||
func get_key_label(action: StringName) -> String:
|
||||
var event := get_key_event(action)
|
||||
var event := _get_key_event(action)
|
||||
if event == null:
|
||||
return "—"
|
||||
return event.as_text_physical_keycode()
|
||||
@@ -241,16 +443,20 @@ func get_key_label(action: StringName) -> String:
|
||||
## Replaces the primary keyboard event of `action` with `event`, keeping
|
||||
## every other event (e.g. arrow-key alternates, joypad) in its place.
|
||||
func rebind(action: StringName, event: InputEventKey) -> void:
|
||||
rebind_no_save(action, event)
|
||||
_rebind_no_save(action, event)
|
||||
save()
|
||||
|
||||
|
||||
func save() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
for action: StringName in REBINDABLE_ACTIONS:
|
||||
var event := get_key_event(action)
|
||||
var event := _get_key_event(action)
|
||||
if event != null:
|
||||
cfg.set_value(SECTION, String(action), event.physical_keycode)
|
||||
for action: StringName in JOY_REBINDABLE_ACTIONS:
|
||||
var joy_event := _get_joy_button_event(action)
|
||||
if joy_event != null:
|
||||
cfg.set_value(JOY_SECTION, String(action), joy_event.button_index)
|
||||
cfg.save(SAVE_PATH)
|
||||
|
||||
|
||||
@@ -265,12 +471,20 @@ func load_saved() -> void:
|
||||
var keycode: int = cfg.get_value(SECTION, key)
|
||||
var event := InputEventKey.new()
|
||||
event.physical_keycode = keycode as Key
|
||||
rebind_no_save(action, event)
|
||||
_rebind_no_save(action, event)
|
||||
for action: StringName in JOY_REBINDABLE_ACTIONS:
|
||||
var joy_key := String(action)
|
||||
if not cfg.has_section_key(JOY_SECTION, joy_key):
|
||||
continue
|
||||
var button_index: int = cfg.get_value(JOY_SECTION, joy_key)
|
||||
var joy_event := InputEventJoypadButton.new()
|
||||
joy_event.button_index = button_index
|
||||
_rebind_joy_no_save(action, joy_event)
|
||||
|
||||
|
||||
## Rebuilds the action's event list, swapping the first keyboard event for
|
||||
## `event` in place so ordering (and thus the "primary" key) is preserved.
|
||||
func rebind_no_save(action: StringName, event: InputEventKey) -> void:
|
||||
func _rebind_no_save(action: StringName, event: InputEventKey) -> void:
|
||||
var events := InputMap.action_get_events(action)
|
||||
InputMap.action_erase_events(action)
|
||||
var replaced := false
|
||||
|
||||
@@ -3,6 +3,10 @@ extends CanvasLayer
|
||||
const _GOLD := Color(1.00, 0.78, 0.22)
|
||||
const _CREAM := Color(0.93, 0.88, 0.72)
|
||||
const _MUTED := Color(0.66, 0.60, 0.46)
|
||||
# Sampled from thankyou.ogv's baked "THANK YOU FOR PLAYING!" title — a flat dark ink on the
|
||||
# clip's sunburst poster art, no outline/glow. The run-time label mimics it so it reads as
|
||||
# part of that same poster instead of a HUD readout stamped on top.
|
||||
const _INK := Color(0.12, 0.08, 0.02)
|
||||
|
||||
# Per-ability trim colour, tying each tile (and its key cap) to that ability's FX.
|
||||
# Order matches _ABILITY_ICONS: kick, dash, slam, roll.
|
||||
@@ -40,7 +44,11 @@ var _hp_seg_fills: Array[ColorRect] = []
|
||||
var _bottom_col: VBoxContainer
|
||||
var _ability_bar: HBoxContainer
|
||||
var _ability_panels: Array[PanelContainer] = []
|
||||
var _ability_key_labels: Array[Label] = []
|
||||
var _bull_bg: TextureRect
|
||||
# Cached so the ability keycaps only get rewritten on an actual keyboard<->gamepad switch,
|
||||
# not every frame.
|
||||
var _showing_gamepad_hints: bool = false
|
||||
|
||||
# Lobby barrel-smash reward — tally the barrels present on load; when the last one bursts,
|
||||
# throw confetti + a "WOooOoW!" and grant the bull two yellow bonus pips. Latched so it
|
||||
@@ -66,6 +74,10 @@ const _BULL_FACE_PAD: float = 1.15 # breathing room around the cluster within
|
||||
# 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
|
||||
# Clearing the bear — the run's final boss — replaces the usual bull-win clip with the
|
||||
# closing "Thank You For Playing" screen. That clip already bakes its own title text in,
|
||||
# so _over_label is left blank for this outcome (see _show_game_over).
|
||||
const _THANKYOU_VIDEO := "res://videos/thankyou.ogv"
|
||||
|
||||
# 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
|
||||
@@ -75,16 +87,27 @@ const _WIN_DELAY := 3.0
|
||||
|
||||
var _game_over: bool = false
|
||||
var _result_win: bool = false
|
||||
# Set per-outcome in _show_game_over: the bear is the run's last fight, so its win screen
|
||||
# offers a fresh run instead of a lobby gate to continue through.
|
||||
var _is_final_boss: bool = false
|
||||
var _over_root: Control
|
||||
var _over_video: VideoStreamPlayer
|
||||
var _over_label: Label
|
||||
var _over_continue: Button
|
||||
var _over_again: Button
|
||||
var _over_label_tween: Tween # the finish screen's breathing "Your Time" pulse
|
||||
|
||||
# Run clock readout — top-left, desktop only (touch's top-left corner is already the
|
||||
# mobile HP readout from mobile_hud.gd). Tracks Run.run_elapsed(); hidden until the timer
|
||||
# has actually started (the player hasn't entered the arena yet) and while the game-over
|
||||
# screen is up.
|
||||
var _run_timer_label: Label
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 11
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
add_to_group(&"hud") # lets the debug console's `video` command find this instance
|
||||
_build_controls_panel()
|
||||
_build_tab_hint()
|
||||
_build_bottom_hud()
|
||||
@@ -92,6 +115,7 @@ func _ready() -> void:
|
||||
_build_ability_bar()
|
||||
_build_health()
|
||||
_build_version_label()
|
||||
_build_run_timer()
|
||||
_build_damage_vignette()
|
||||
_build_game_over()
|
||||
_build_touch_controls()
|
||||
@@ -206,8 +230,42 @@ func _build_version_label() -> void:
|
||||
add_child(lbl)
|
||||
|
||||
|
||||
# Run clock, top-left. Desktop only — touch already owns that corner for the mobile HP
|
||||
# readout, and a run timer is a nice-to-have there, not worth the crowding.
|
||||
func _build_run_timer() -> void:
|
||||
if Controls.use_touch_ui():
|
||||
return
|
||||
_run_timer_label = Label.new()
|
||||
_run_timer_label.text = _format_time(0.0)
|
||||
_run_timer_label.visible = false
|
||||
_run_timer_label.add_theme_font_override("font", UiFonts.body())
|
||||
_run_timer_label.add_theme_color_override("font_color", _CREAM)
|
||||
_run_timer_label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
|
||||
_run_timer_label.add_theme_constant_override("outline_size", 4)
|
||||
_run_timer_label.add_theme_font_size_override("font_size", 20)
|
||||
_run_timer_label.set_anchors_preset(Control.PRESET_TOP_LEFT)
|
||||
_run_timer_label.offset_left = 20.0
|
||||
_run_timer_label.offset_top = 16.0
|
||||
_run_timer_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(_run_timer_label)
|
||||
|
||||
|
||||
# mm:ss.hh — matches how a speedrun-style clock reads at a glance.
|
||||
static func _format_time(seconds: float) -> String:
|
||||
var total_cs := int(round(seconds * 100.0))
|
||||
var minutes := total_cs / 6000
|
||||
var secs := (total_cs / 100) % 60
|
||||
var centis := total_cs % 100
|
||||
return "%02d:%02d.%02d" % [minutes, secs, centis]
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
_update_control_hints()
|
||||
_update_ability_key_labels()
|
||||
if _run_timer_label != null:
|
||||
_run_timer_label.visible = Run.run_timer_started() and not _game_over
|
||||
if _run_timer_label.visible:
|
||||
_run_timer_label.text = _format_time(Run.run_elapsed())
|
||||
if _player == null:
|
||||
var players := get_tree().get_nodes_in_group(&"player")
|
||||
if not players.is_empty():
|
||||
@@ -343,7 +401,7 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
if _game_over and event.physical_keycode in [KEY_ENTER, KEY_KP_ENTER]:
|
||||
if _result_win:
|
||||
_continue_to_lobby()
|
||||
_on_win_button_pressed()
|
||||
else:
|
||||
_restart()
|
||||
elif event.physical_keycode == KEY_TAB and not _game_over:
|
||||
@@ -373,6 +431,24 @@ func _continue_to_lobby() -> void:
|
||||
get_tree().change_scene_to_file(Run.active_level().scene_path)
|
||||
|
||||
|
||||
# The bear is the run's last fight — there's no further lobby gate to continue through, so
|
||||
# the win screen offers a fresh run instead. Mirrors main_menu.gd's Play button.
|
||||
func _play_again() -> void:
|
||||
get_tree().paused = false
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
Run.start_new_run()
|
||||
get_tree().change_scene_to_file("res://levels/game_shell.tscn")
|
||||
|
||||
|
||||
# Shared by the win-screen button and its Enter-key shortcut, routing to whichever the
|
||||
# current outcome needs (set in _show_game_over).
|
||||
func _on_win_button_pressed() -> void:
|
||||
if _is_final_boss:
|
||||
_play_again()
|
||||
else:
|
||||
_continue_to_lobby()
|
||||
|
||||
|
||||
func _toggle_controls() -> void:
|
||||
_controls_visible = not _controls_visible
|
||||
_toggle_btn.text = "Hide controls" if _controls_visible else "Show controls"
|
||||
@@ -398,6 +474,18 @@ func _update_control_hints() -> void:
|
||||
_tab_hint.visible = not _controls_visible
|
||||
|
||||
|
||||
# Swaps the ability-bar keycaps between keyboard letters and Xbox-style face-button glyphs
|
||||
# (A/B/X/Y) as the player switches between keyboard and gamepad. Only rewrites the labels on
|
||||
# an actual switch, not every frame — Controls.using_gamepad() itself is cheap either way.
|
||||
func _update_ability_key_labels() -> void:
|
||||
var gamepad := Controls.using_gamepad()
|
||||
if gamepad == _showing_gamepad_hints:
|
||||
return
|
||||
_showing_gamepad_hints = gamepad
|
||||
for i in _ability_key_labels.size():
|
||||
_ability_key_labels[i].text = Controls.ability_key_label(_ABILITY_ACTIONS[i])
|
||||
|
||||
|
||||
# ── 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
|
||||
@@ -434,6 +522,10 @@ func _on_player_died(cause: String = "") -> void:
|
||||
|
||||
|
||||
func _on_all_defeated() -> void:
|
||||
# Freeze the run clock the instant the bear falls — before _show_game_over's
|
||||
# celebratory delay — so the recorded time is the kill moment, not the victory lap.
|
||||
if Run.active_level().id == &"bear":
|
||||
Run.stop_run_timer()
|
||||
_show_game_over(true)
|
||||
|
||||
|
||||
@@ -450,12 +542,78 @@ func _show_game_over(win: bool, _cause: String = "") -> void:
|
||||
|
||||
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!"
|
||||
# A win advances the run (on to the map); a loss ends it (retry from the top).
|
||||
_is_final_boss = _result_win and Run.active_level().id == &"bear"
|
||||
_present_outcome()
|
||||
|
||||
|
||||
## Debug-only entry point for the console's `video` command — skips the fight and the
|
||||
## outcome delay, jumping straight to an outcome screen so a clip can be previewed
|
||||
## (`video finish` for thankyou.ogv) without clearing the bear for real. kind: one of
|
||||
## "win" / "loss" / "finish".
|
||||
func debug_force_outcome(kind: String) -> void:
|
||||
if _controls_panel:
|
||||
_controls_panel.visible = false
|
||||
if _tab_hint:
|
||||
_tab_hint.visible = false
|
||||
_game_over = true
|
||||
match kind:
|
||||
"finish":
|
||||
_result_win = true
|
||||
_is_final_boss = true
|
||||
"win":
|
||||
_result_win = true
|
||||
_is_final_boss = false
|
||||
_:
|
||||
_result_win = false
|
||||
_is_final_boss = false
|
||||
_present_outcome()
|
||||
|
||||
|
||||
# Populates and reveals the outcome screen from _result_win / _is_final_boss, set by
|
||||
# either _show_game_over (a real fight ending) or debug_force_outcome (console preview).
|
||||
func _present_outcome() -> void:
|
||||
# The thank-you clip already bakes in its own title, so this label carries the winning
|
||||
# time instead — the one thing that clip can't show. Anchored as a fraction of the
|
||||
# screen (not fixed pixels) at ~83% down — just under the clip's own baked title (which
|
||||
# sits at ~72-81% in the source video) and clear of the bull it frames, instead of the
|
||||
# shared top-of-screen spot "Bull Won!"/"Matadors Won!" use. Styled to match the baked
|
||||
# title's flat dark-ink poster look rather than the gold HUD outline style.
|
||||
_over_label.text = ("Your Time: %s" % _format_time(Run.run_elapsed())) if _is_final_boss \
|
||||
else ("Bull Won!" if _result_win else "Matadors Won!")
|
||||
if _is_final_boss:
|
||||
_over_label.anchor_top = 0.83
|
||||
_over_label.anchor_bottom = 0.83
|
||||
_over_label.grow_vertical = Control.GROW_DIRECTION_END
|
||||
_over_label.offset_top = 0.0
|
||||
_over_label.offset_bottom = 0.0
|
||||
_over_label.add_theme_font_size_override("font_size", 40)
|
||||
_over_label.add_theme_color_override("font_color", _INK)
|
||||
_over_label.add_theme_color_override("font_outline_color", Color(0.0, 0.0, 0.0, 0.0))
|
||||
_over_label.add_theme_constant_override("outline_size", 0)
|
||||
_start_over_label_pulse()
|
||||
else:
|
||||
_over_label.anchor_top = 0.0
|
||||
_over_label.anchor_bottom = 0.0
|
||||
_over_label.grow_vertical = Control.GROW_DIRECTION_END
|
||||
_over_label.offset_top = 80.0
|
||||
_over_label.offset_bottom = 0.0
|
||||
_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)
|
||||
_stop_over_label_pulse()
|
||||
# A win advances the run (on to the map, or — for the bear, the run's last fight — into
|
||||
# a fresh run); a loss ends it (retry from the top).
|
||||
_over_continue.text = "Play Again (Enter)" if _is_final_boss else "Continue (Enter)"
|
||||
_over_continue.visible = _result_win
|
||||
_over_again.visible = not _result_win
|
||||
# Nothing has focus by default when this screen appears, so a gamepad's ui_accept/ui_left/
|
||||
# ui_right (bound to Godot's built-in ui_* actions) would have nothing to act on — grab it
|
||||
# on the default button, same as main_menu.gd does for its own panels. Geometry-based focus
|
||||
# neighbors (Godot's default) then let ui_left/ui_right reach Main Menu from here.
|
||||
(_over_continue if _result_win else _over_again).grab_focus()
|
||||
|
||||
var path := _WIN_VIDEO if _result_win else _LOSS_VIDEO
|
||||
var path := _THANKYOU_VIDEO if _is_final_boss else (_WIN_VIDEO if _result_win else _LOSS_VIDEO)
|
||||
if ResourceLoader.exists(path):
|
||||
_over_video.stream = load(path)
|
||||
_over_video.play()
|
||||
@@ -468,6 +626,29 @@ func _show_game_over(win: bool, _cause: String = "") -> void:
|
||||
create_tween().tween_property(_over_root, "modulate:a", 1.0, 0.5)
|
||||
|
||||
|
||||
# thankyou.ogv's baked title isn't static — it breathes, scaling gently in and out on a loop
|
||||
# (measured off the source clip: roughly a 1.4 s in/out cycle, matching the clip's own loop
|
||||
# length). The "Your Time" label mimics that same pulse so it reads as part of the clip
|
||||
# rather than a HUD readout stamped over it. Scales around the label's own centre (its size
|
||||
# only settles a frame after the anchor/text changes above, hence the deferred pivot read).
|
||||
func _start_over_label_pulse() -> void:
|
||||
_stop_over_label_pulse()
|
||||
_over_label.scale = Vector2.ONE
|
||||
await get_tree().process_frame
|
||||
_over_label.pivot_offset = _over_label.size * 0.5
|
||||
_over_label_tween = create_tween().set_loops()
|
||||
_over_label_tween.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
|
||||
_over_label_tween.tween_property(_over_label, "scale", Vector2(1.08, 1.08), 0.7)
|
||||
_over_label_tween.tween_property(_over_label, "scale", Vector2(0.92, 0.92), 0.7)
|
||||
|
||||
|
||||
func _stop_over_label_pulse() -> void:
|
||||
if _over_label_tween:
|
||||
_over_label_tween.kill()
|
||||
_over_label_tween = null
|
||||
_over_label.scale = Vector2.ONE
|
||||
|
||||
|
||||
func _build_game_over() -> void:
|
||||
_over_root = Control.new()
|
||||
_over_root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
@@ -524,10 +705,11 @@ func _build_game_over() -> void:
|
||||
row.offset_bottom = -48.0
|
||||
_over_root.add_child(row)
|
||||
|
||||
# Win → continue to the run map; loss → restart from the top. Shown per-result in
|
||||
# _show_game_over; both share the Enter shortcut and label.
|
||||
# Win → continue to the run map, or — beating the bear, the run's last fight — start a
|
||||
# fresh run; loss → restart from the top. Text is set per-outcome in _show_game_over;
|
||||
# both routes share the Enter shortcut.
|
||||
_over_continue = _make_button("Continue (Enter)")
|
||||
_over_continue.pressed.connect(_continue_to_lobby)
|
||||
_over_continue.pressed.connect(_on_win_button_pressed)
|
||||
row.add_child(_over_continue)
|
||||
|
||||
_over_again = _make_button("Continue (Enter)")
|
||||
@@ -737,7 +919,7 @@ func _build_ability_slot(idx: int) -> Control:
|
||||
vbox.add_child(keycap)
|
||||
|
||||
var key_lbl := Label.new()
|
||||
key_lbl.text = Controls.get_key_label(_ABILITY_ACTIONS[idx])
|
||||
key_lbl.text = Controls.ability_key_label(_ABILITY_ACTIONS[idx])
|
||||
key_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
key_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
key_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
@@ -747,6 +929,7 @@ func _build_ability_slot(idx: int) -> Control:
|
||||
key_lbl.add_theme_constant_override("outline_size", 3)
|
||||
key_lbl.add_theme_font_size_override("font_size", 14)
|
||||
keycap.add_child(key_lbl)
|
||||
_ability_key_labels.append(key_lbl)
|
||||
|
||||
return vbox
|
||||
|
||||
|
||||
+77
-3
@@ -80,6 +80,11 @@ const ACTION_LABELS: Dictionary = {
|
||||
|
||||
var _listening_action: StringName = &""
|
||||
var _listening_button: Button = null
|
||||
var _listening_joy_action: StringName = &""
|
||||
var _listening_joy_button: Button = null
|
||||
# Cached so the rebind list only rebuilds on an actual keyboard<->gamepad switch, not
|
||||
# every frame the Options panel is open.
|
||||
var _rebind_rows_show_gamepad: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -184,6 +189,7 @@ func _plaque(hot: bool) -> StyleBoxFlat:
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
_update_rebind_rows_for_device()
|
||||
if not main_panel.visible or video.stream == null:
|
||||
return
|
||||
var frame_count := CAPE_POS.size()
|
||||
@@ -207,6 +213,7 @@ func _build_options_toggles() -> void:
|
||||
_add_section_label("── Gameplay ──", idx); idx += 1
|
||||
_add_toggle_row("Screen Shake", Settings.screen_shake, Settings.set_screen_shake, idx); idx += 1
|
||||
_add_toggle_row("Blood & Gore", Settings.gore, Settings.set_gore, idx); idx += 1
|
||||
_add_toggle_row("Controller Vibration", Settings.vibration, Settings.set_vibration, idx); idx += 1
|
||||
_add_section_label("── Controls ──", idx)
|
||||
|
||||
|
||||
@@ -291,10 +298,24 @@ func _add_toggle_row(label_text: String, value: bool, setter: Callable, at_index
|
||||
options_vbox.move_child(row, at_index)
|
||||
|
||||
|
||||
## Rebuilds the Controls rebind list for whichever device the player is actually holding
|
||||
## (Controls.using_gamepad(), the same last-input heuristic the HUD's ability keycaps use —
|
||||
## not mere connection presence, so a controller left plugged in but unused doesn't hide
|
||||
## keyboard rebinds from a mouse-and-keyboard player). On a gamepad: movement rows drop
|
||||
## entirely (the D-pad/stick isn't individually remapped, so there's nothing to show), and
|
||||
## each ability shows only its gamepad button. On keyboard: the reverse — WASD-style
|
||||
## keyboard binds only, no gamepad buttons cluttering the list.
|
||||
func _build_rebind_rows() -> void:
|
||||
for child in rebind_list.get_children():
|
||||
child.queue_free()
|
||||
var gamepad := Controls.using_gamepad()
|
||||
_rebind_rows_show_gamepad = gamepad
|
||||
_update_rebind_hint(gamepad)
|
||||
for action: StringName in ACTION_LABELS:
|
||||
var is_joy_rebindable := Controls.JOY_REBINDABLE_ACTIONS.has(String(action))
|
||||
if gamepad and not is_joy_rebindable:
|
||||
continue # movement: nothing to show/rebind on a gamepad
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
|
||||
@@ -309,6 +330,7 @@ func _build_rebind_rows() -> void:
|
||||
name_label.add_theme_constant_override("outline_size", 5)
|
||||
row.add_child(name_label)
|
||||
|
||||
if not gamepad:
|
||||
var key_button := Button.new()
|
||||
key_button.custom_minimum_size = Vector2(160, 0)
|
||||
key_button.text = Controls.get_key_label(action)
|
||||
@@ -316,9 +338,37 @@ func _build_rebind_rows() -> void:
|
||||
key_button.pressed.connect(_on_rebind_pressed.bind(action, key_button))
|
||||
row.add_child(key_button)
|
||||
|
||||
if gamepad and is_joy_rebindable:
|
||||
var joy_button := Button.new()
|
||||
joy_button.custom_minimum_size = Vector2(72, 0)
|
||||
joy_button.text = Controls.get_joy_button_label(action)
|
||||
_style_button(joy_button, false)
|
||||
joy_button.pressed.connect(_on_rebind_joy_pressed.bind(action, joy_button))
|
||||
row.add_child(joy_button)
|
||||
|
||||
rebind_list.add_child(row)
|
||||
|
||||
|
||||
func _update_rebind_hint(gamepad: bool) -> void:
|
||||
var hint := get_node_or_null("OptionsPanel/VBox/Hint") as Label
|
||||
if hint != null:
|
||||
hint.text = "Click a button, then press a new one on your controller. Esc cancels." \
|
||||
if gamepad else "Click a key, then press a new key. Esc cancels."
|
||||
|
||||
|
||||
# Live-swaps the rebind list if the player switches device while the Options panel is open
|
||||
# (e.g. they were clicking through with a mouse, then pick up a controller). Skipped while
|
||||
# actively listening for a rebind so an in-progress capture never gets torn out from under
|
||||
# the player.
|
||||
func _update_rebind_rows_for_device() -> void:
|
||||
if not options_panel.visible:
|
||||
return
|
||||
if _listening_action != &"" or _listening_joy_action != &"":
|
||||
return
|
||||
if Controls.using_gamepad() != _rebind_rows_show_gamepad:
|
||||
_build_rebind_rows()
|
||||
|
||||
|
||||
func _on_play_pressed() -> void:
|
||||
_set_buttons_disabled(true)
|
||||
Run.start_new_run() # fresh map for this run; the arena is the intro fight
|
||||
@@ -369,17 +419,26 @@ func _on_rebind_pressed(action: StringName, button: Button) -> void:
|
||||
button.text = "Press a key…"
|
||||
|
||||
|
||||
func _on_rebind_joy_pressed(action: StringName, button: Button) -> void:
|
||||
_cancel_listening()
|
||||
_listening_joy_action = action
|
||||
_listening_joy_button = button
|
||||
button.text = "Press…"
|
||||
|
||||
|
||||
func _cancel_listening() -> void:
|
||||
if _listening_button != null:
|
||||
_listening_button.text = Controls.get_key_label(_listening_action)
|
||||
if _listening_joy_button != null:
|
||||
_listening_joy_button.text = Controls.get_joy_button_label(_listening_joy_action)
|
||||
_listening_action = &""
|
||||
_listening_button = null
|
||||
_listening_joy_action = &""
|
||||
_listening_joy_button = null
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if _listening_action == &"":
|
||||
return
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
if _listening_action != &"" and event is InputEventKey and event.pressed and not event.echo:
|
||||
if event.physical_keycode == KEY_ESCAPE:
|
||||
_cancel_listening()
|
||||
else:
|
||||
@@ -390,3 +449,18 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
_listening_action = &""
|
||||
_listening_button = null
|
||||
get_viewport().set_input_as_handled()
|
||||
return
|
||||
|
||||
if _listening_joy_action != &"":
|
||||
if event is InputEventKey and event.pressed and not event.echo \
|
||||
and event.physical_keycode == KEY_ESCAPE:
|
||||
_cancel_listening()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif event is InputEventJoypadButton and event.pressed:
|
||||
var new_joy_event := InputEventJoypadButton.new()
|
||||
new_joy_event.button_index = event.button_index
|
||||
Controls.rebind_joy(_listening_joy_action, new_joy_event)
|
||||
_listening_joy_button.text = Controls.get_joy_button_label(_listening_joy_action)
|
||||
_listening_joy_action = &""
|
||||
_listening_joy_button = null
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
uid://b5rupuoe1lk5j
|
||||
Reference in New Issue
Block a user