diff --git a/scripts/test-godot.sh b/scripts/test-godot.sh index b003341..79e8445 100755 --- a/scripts/test-godot.sh +++ b/scripts/test-godot.sh @@ -9,6 +9,7 @@ GODOT_BIN="${GODOT_BIN:-godot}" TEST_SCRIPTS=( "res://tests/unit/runtime_options_test.gd" "res://tests/unit/bootstrap_test.gd" + "res://tests/replay_input_display_test.gd" "res://tests/world_record_announcement_test.gd" "res://tests/offline_playtest_smoke.gd" "res://tests/aim_menu_smoke.gd" diff --git a/src/maps/map_ui.gd b/src/maps/map_ui.gd index 19fa5c0..04eb282 100644 --- a/src/maps/map_ui.gd +++ b/src/maps/map_ui.gd @@ -15,6 +15,7 @@ signal return_control_to_player @export var replay_slider: HSlider @export var replay_container: Control @export var replay_v: VBoxContainer +@export var replay_input_display: ReplayInputDisplay @export var game_info: Container @export var ammo_label: Label @export var leaderboard: Container @@ -33,10 +34,31 @@ func set_frame(frame: int, total: int) -> void: replay_slider.value = frame replay_slider.max_value = total - 1 +func set_replay_inputs( + forward_input: bool, + back_input: bool, + left_input: bool, + right_input: bool, + shoot_input: bool, + ads_input: bool, + reload_input: bool, +) -> void: + replay_input_display.set_inputs( + forward_input, + back_input, + left_input, + right_input, + shoot_input, + ads_input, + reload_input, + ) + func set_replay_visible(value: bool) -> void: replay_container.visible = value replay_v.visible = value map.recorder.controller.visible = value + if not value: + replay_input_display.reset() func is_replay_visible() -> bool: return replay_container.visible diff --git a/src/maps/map_ui.tscn b/src/maps/map_ui.tscn index 7a331f6..7a73444 100644 --- a/src/maps/map_ui.tscn +++ b/src/maps/map_ui.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=13 format=3 uid="uid://bnati2e7fo1g"] +[gd_scene load_steps=14 format=3 uid="uid://bnati2e7fo1g"] [ext_resource type="Script" uid="uid://vuacki0u3vnw" path="res://src/maps/map_ui.gd" id="1_jnrmt"] [ext_resource type="Theme" uid="uid://3as6f5nxctip" path="res://src/main_theme.tres" id="2_uralg"] @@ -10,6 +10,7 @@ [ext_resource type="Texture2D" uid="uid://dqvbiit5bj0rm" path="res://src/textures/plat_medal.png" id="9_k6ak5"] [ext_resource type="Texture2D" uid="uid://doueeo1np6if1" path="res://src/textures/author_medal.png" id="10_dxk01"] [ext_resource type="FontFile" uid="uid://reim7hyo6sqy" path="res://src/fonts/OpenSans-Regular.ttf" id="11_5vm7s"] +[ext_resource type="PackedScene" path="res://src/maps/replay_input_display.tscn" id="12_pfxlv"] [sub_resource type="Theme" id="Theme_of3v1"] default_font_size = 15 @@ -17,7 +18,7 @@ default_font_size = 15 [sub_resource type="Theme" id="Theme_nvgh5"] default_font = ExtResource("11_5vm7s") -[node name="MapUi" type="CanvasLayer" node_paths=PackedStringArray("keybind_info_label", "done_replay_btn", "tick_label", "replay_slider", "replay_container", "replay_v", "game_info", "ammo_label", "leaderboard")] +[node name="MapUi" type="CanvasLayer" node_paths=PackedStringArray("keybind_info_label", "done_replay_btn", "tick_label", "replay_slider", "replay_container", "replay_v", "replay_input_display", "game_info", "ammo_label", "leaderboard")] script = ExtResource("1_jnrmt") keybind_info_label = NodePath("ReplayContainer/V/KeybindInfo") done_replay_btn = NodePath("ReplayContainer/V/Done") @@ -25,6 +26,7 @@ tick_label = NodePath("ReplayContainer/V/Label") replay_slider = NodePath("ReplayContainer/V/Slider") replay_container = NodePath("ReplayContainer") replay_v = NodePath("ReplayContainer/V") +replay_input_display = NodePath("ReplayContainer/V/ReplayInputDisplay") game_info = NodePath("UiContainer/GameInfo") ammo_label = NodePath("UiContainer/BottomLeft/V/Ammo") leaderboard = NodePath("UiContainer/GameInfo/Leaderboard") @@ -327,7 +329,7 @@ anchor_top = 1.0 anchor_right = 0.5 anchor_bottom = 1.0 offset_left = -150.0 -offset_top = -147.0 +offset_top = -215.0 offset_right = 150.0 grow_horizontal = 2 grow_vertical = 0 @@ -336,12 +338,15 @@ grow_vertical = 0 custom_minimum_size = Vector2(300, 0) layout_mode = 2 offset_right = 300.0 -offset_bottom = 147.0 +offset_bottom = 215.0 grow_horizontal = 2 grow_vertical = 0 theme = ExtResource("2_uralg") alignment = 1 +[node name="ReplayInputDisplay" parent="ReplayContainer/V" instance=ExtResource("12_pfxlv")] +layout_mode = 2 + [node name="Label" type="Label" parent="ReplayContainer/V"] layout_mode = 2 text = "Frame: 0 / 999" diff --git a/src/maps/replay_input_display.gd b/src/maps/replay_input_display.gd new file mode 100644 index 0000000..f1c95b6 --- /dev/null +++ b/src/maps/replay_input_display.gd @@ -0,0 +1,98 @@ +class_name ReplayInputDisplay +extends HBoxContainer + +const ACTIVE_BG := Color(0.94, 0.93, 0.89, 1.0) +const ACTIVE_BORDER := Color(0.18, 0.18, 0.18, 1.0) +const ACTIVE_FONT := Color(0.10, 0.10, 0.10, 1.0) +const INACTIVE_BG := Color(0.12, 0.14, 0.17, 1.0) +const INACTIVE_BORDER := Color(0.30, 0.33, 0.37, 1.0) +const INACTIVE_FONT := Color(0.78, 0.81, 0.84, 1.0) + +var _input_states := { + &"forward": false, + &"back": false, + &"left": false, + &"right": false, + &"shoot": false, + &"ads": false, + &"reload": false, +} + +var _key_panels: Dictionary = { } +var _key_labels: Dictionary = { } +var _active_panel_style: StyleBoxFlat +var _inactive_panel_style: StyleBoxFlat + +func _ready() -> void: + _active_panel_style = _create_panel_style(ACTIVE_BG, ACTIVE_BORDER) + _inactive_panel_style = _create_panel_style(INACTIVE_BG, INACTIVE_BORDER) + + _register_decorative_panel($"Movement/TopLeftSpacer") + _register_key(&"forward", $"Movement/W") + _register_decorative_panel($"Movement/TopRightSpacer") + _register_key(&"back", $"Movement/S") + _register_key(&"left", $"Movement/A") + _register_key(&"right", $"Movement/D") + _register_key(&"shoot", $"Combat/Shoot") + _register_key(&"ads", $"Combat/Ads") + _register_key(&"reload", $"Combat/Reload") + + reset() + +func set_inputs( + forward_input: bool, + back_input: bool, + left_input: bool, + right_input: bool, + shoot_input: bool, + ads_input: bool, + reload_input: bool, +) -> void: + _input_states[&"forward"] = forward_input + _input_states[&"back"] = back_input + _input_states[&"left"] = left_input + _input_states[&"right"] = right_input + _input_states[&"shoot"] = shoot_input + _input_states[&"ads"] = ads_input + _input_states[&"reload"] = reload_input + _apply_visual_state() + +func reset() -> void: + set_inputs(false, false, false, false, false, false, false) + +func is_input_active(input_name: StringName) -> bool: + return _input_states.get(input_name, false) + +func _register_key(action_name: StringName, panel: PanelContainer) -> void: + var label := panel.get_node_or_null("Label") as Label + _key_panels[action_name] = panel + _key_labels[action_name] = label + +func _register_decorative_panel(panel: PanelContainer) -> void: + if panel != null: + panel.add_theme_stylebox_override("panel", _inactive_panel_style) + +func _apply_visual_state() -> void: + for action_name in _key_panels.keys(): + var panel := _key_panels[action_name] as PanelContainer + var label := _key_labels.get(action_name) as Label + var active := _input_states.get(action_name, false) + + if panel != null: + panel.add_theme_stylebox_override("panel", _active_panel_style if active else _inactive_panel_style) + if label != null: + label.add_theme_color_override("font_color", ACTIVE_FONT if active else INACTIVE_FONT) + +func _create_panel_style(bg_color: Color, border_color: Color) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = bg_color + style.border_color = border_color + style.border_width_left = 1 + style.border_width_top = 1 + style.border_width_right = 1 + style.border_width_bottom = 1 + style.content_margin_left = 5 + style.content_margin_top = 3 + style.content_margin_right = 5 + style.content_margin_bottom = 3 + return style diff --git a/src/maps/replay_input_display.tscn b/src/maps/replay_input_display.tscn new file mode 100644 index 0000000..abdf39d --- /dev/null +++ b/src/maps/replay_input_display.tscn @@ -0,0 +1,106 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://src/maps/replay_input_display.gd" id="1_ry1dp"] + +[node name="ReplayInputDisplay" type="HBoxContainer"] +custom_minimum_size = Vector2(0, 64) +layout_mode = 2 +theme_override_constants/separation = 12 +alignment = 1 +script = ExtResource("1_ry1dp") + +[node name="Movement" type="GridContainer" parent="."] +layout_mode = 2 +theme_override_constants/h_separation = 4 +theme_override_constants/v_separation = 4 +columns = 3 + +[node name="TopLeftSpacer" type="PanelContainer" parent="Movement"] +custom_minimum_size = Vector2(30, 30) +layout_mode = 2 + +[node name="W" type="PanelContainer" parent="Movement"] +custom_minimum_size = Vector2(30, 30) +layout_mode = 2 + +[node name="Label" type="Label" parent="Movement/W"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "W" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="TopRightSpacer" type="PanelContainer" parent="Movement"] +custom_minimum_size = Vector2(30, 30) +layout_mode = 2 + +[node name="A" type="PanelContainer" parent="Movement"] +custom_minimum_size = Vector2(30, 30) +layout_mode = 2 + +[node name="Label" type="Label" parent="Movement/A"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "A" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="S" type="PanelContainer" parent="Movement"] +custom_minimum_size = Vector2(30, 30) +layout_mode = 2 + +[node name="Label" type="Label" parent="Movement/S"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "S" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="D" type="PanelContainer" parent="Movement"] +custom_minimum_size = Vector2(30, 30) +layout_mode = 2 + +[node name="Label" type="Label" parent="Movement/D"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "D" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Combat" type="HBoxContainer" parent="."] +layout_mode = 2 +theme_override_constants/separation = 4 +alignment = 1 + +[node name="Shoot" type="PanelContainer" parent="Combat"] +custom_minimum_size = Vector2(46, 30) +layout_mode = 2 + +[node name="Label" type="Label" parent="Combat/Shoot"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "LMB" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Ads" type="PanelContainer" parent="Combat"] +custom_minimum_size = Vector2(46, 30) +layout_mode = 2 + +[node name="Label" type="Label" parent="Combat/Ads"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "RMB" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="Reload" type="PanelContainer" parent="Combat"] +custom_minimum_size = Vector2(30, 30) +layout_mode = 2 + +[node name="Label" type="Label" parent="Combat/Reload"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "R" +horizontal_alignment = 1 +vertical_alignment = 1 diff --git a/src/recorder.gd b/src/recorder.gd index 24a5645..92a7971 100644 --- a/src/recorder.gd +++ b/src/recorder.gd @@ -103,6 +103,17 @@ func set_frame(value: int) -> void: else: map.map_ui.set_speed(speed) map.map_ui.set_timer(current_frame * dt) + map \ + .map_ui \ + .set_replay_inputs( + frame.forward_input, + frame.back_input, + frame.left_input, + frame.right_input, + frame.shoot_input, + frame.ads_input, + frame.reload_input, + ) if prev_frame.weapon_index != frame.weapon_index: controller.weapon_handler.set_weapon(Global.game_manager.get_weapon_from_index(frame.weapon_index), is_ghost) diff --git a/tests/replay_input_display_test.gd b/tests/replay_input_display_test.gd new file mode 100644 index 0000000..8b601aa --- /dev/null +++ b/tests/replay_input_display_test.gd @@ -0,0 +1,119 @@ +extends SceneTree + +const TestCase = preload("res://tests/support/test_case.gd") +const SCENE_PATH := "res://src/maps/replay_input_display.tscn" +const MAP_UI_SCENE_PATH := "res://src/maps/map_ui.tscn" + +func _init() -> void: + call_deferred("_run") + +func _run() -> void: + var t := TestCase.new() + + var replay_input_display_scene := load(SCENE_PATH) as PackedScene + t.check(replay_input_display_scene != null, "Expected %s to load" % SCENE_PATH) + if replay_input_display_scene != null: + var replay_input_display = replay_input_display_scene.instantiate() + t.check(replay_input_display != null, "Expected %s to instantiate" % SCENE_PATH) + if replay_input_display != null: + t.check( + replay_input_display.has_method("set_inputs"), + "Replay input display should expose set_inputs(bool, bool, bool, bool, bool, bool, bool)", + ) + t.check(replay_input_display.has_method("reset"), "Replay input display should expose reset()") + t.check( + replay_input_display.has_method("is_input_active"), + "Replay input display should expose is_input_active(StringName)", + ) + + root.add_child(replay_input_display) + await process_frame + + _check_label(t, replay_input_display, "Movement/W/Label", "W") + _check_label(t, replay_input_display, "Movement/A/Label", "A") + _check_label(t, replay_input_display, "Movement/S/Label", "S") + _check_label(t, replay_input_display, "Movement/D/Label", "D") + _check_label(t, replay_input_display, "Combat/Shoot/Label", "LMB") + _check_label(t, replay_input_display, "Combat/Ads/Label", "RMB") + _check_label(t, replay_input_display, "Combat/Reload/Label", "R") + + replay_input_display.set_inputs(true, false, true, false, true, false, true) + _check_input_state(t, replay_input_display, &"forward", true) + _check_input_state(t, replay_input_display, &"left", true) + _check_input_state(t, replay_input_display, &"back", false) + _check_input_state(t, replay_input_display, &"right", false) + _check_input_state(t, replay_input_display, &"shoot", true) + _check_input_state(t, replay_input_display, &"ads", false) + _check_input_state(t, replay_input_display, &"reload", true) + + var active_panel := _panel_stylebox(replay_input_display, "Movement/W/Label") + var inactive_panel := _panel_stylebox(replay_input_display, "Movement/S/Label") + t.check(active_panel != null, "Active keycap should expose a panel stylebox") + t.check(inactive_panel != null, "Inactive keycap should expose a panel stylebox") + if active_panel is StyleBoxFlat and inactive_panel is StyleBoxFlat: + t.check( + active_panel.bg_color != inactive_panel.bg_color, + "Active and inactive keycaps should use distinct panel background colors", + ) + + replay_input_display.reset() + _check_input_state(t, replay_input_display, &"forward", false) + _check_input_state(t, replay_input_display, &"left", false) + _check_input_state(t, replay_input_display, &"back", false) + _check_input_state(t, replay_input_display, &"right", false) + _check_input_state(t, replay_input_display, &"shoot", false) + _check_input_state(t, replay_input_display, &"ads", false) + _check_input_state(t, replay_input_display, &"reload", false) + + replay_input_display.queue_free() + + var map_ui_scene := load(MAP_UI_SCENE_PATH) as PackedScene + t.check(map_ui_scene != null, "Expected %s to load" % MAP_UI_SCENE_PATH) + if map_ui_scene != null: + var map_ui = map_ui_scene.instantiate() + t.check(map_ui != null, "Expected %s to instantiate" % MAP_UI_SCENE_PATH) + if map_ui != null: + var replay_input_display = map_ui.get_node_or_null("ReplayContainer/V/ReplayInputDisplay") + t.check( + replay_input_display != null, + "MapUi should own ReplayContainer/V/ReplayInputDisplay", + ) + t.check_equal( + map_ui.get("replay_input_display"), + replay_input_display, + "MapUi replay_input_display export should reference ReplayContainer/V/ReplayInputDisplay", + ) + + if t.check(map_ui.has_method("set_replay_inputs"), "MapUi should expose set_replay_inputs(bool, bool, bool, bool, bool, bool, bool)"): + map_ui.set_replay_inputs(true, false, false, true, false, true, false) + if replay_input_display != null: + _check_input_state(t, replay_input_display, &"forward", true) + _check_input_state(t, replay_input_display, &"right", true) + _check_input_state(t, replay_input_display, &"ads", true) + _check_input_state(t, replay_input_display, &"shoot", false) + + map_ui.queue_free() + + await process_frame + quit(t.finish()) + +func _check_label(t: TestCase, replay_input_display: Node, path: String, expected_text: String) -> void: + var label := replay_input_display.get_node_or_null(path) as Label + t.check(label != null, "Expected %s to exist" % path) + if label != null: + t.check_equal(label.text, expected_text, "%s should display %s" % [path, expected_text]) + +func _check_input_state(t: TestCase, replay_input_display: Node, input_name: StringName, expected_active: bool) -> void: + t.check_equal( + replay_input_display.is_input_active(input_name), + expected_active, + "Replay input %s should be %s" % [input_name, "active" if expected_active else "inactive"], + ) + +func _panel_stylebox(replay_input_display: Node, label_path: String) -> StyleBox: + var label := replay_input_display.get_node_or_null(label_path) as Label + if label == null: + return null + + var parent_control := label.get_parent() as Control + return parent_control.get_theme_stylebox("panel") if parent_control != null else null