mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-16 10:52:24 +00:00
Compare commits
8 Commits
2533827cf8
...
bca08d266e
| Author | SHA1 | Date | |
|---|---|---|---|
| bca08d266e | |||
| c998d2271a | |||
| 01dbfc7ede | |||
| 4408ec3ecd | |||
| ac317cd2e1 | |||
| 09ea8d6fbc | |||
| a9b7b450d5 | |||
| 93f90ca6de |
@@ -5,3 +5,7 @@
|
|||||||
training/.venv/
|
training/.venv/
|
||||||
training/smoke_run.log
|
training/smoke_run.log
|
||||||
training/__pycache__/
|
training/__pycache__/
|
||||||
|
|
||||||
|
# Exported training binary: a regenerable build artifact (rebuilt by
|
||||||
|
# export_linux.sh / run_training.sh), not a training result.
|
||||||
|
training/build/
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
[preset.0]
|
||||||
|
|
||||||
|
name="Linux Training"
|
||||||
|
platform="Linux"
|
||||||
|
runnable=true
|
||||||
|
dedicated_server=false
|
||||||
|
custom_features="training"
|
||||||
|
export_filter="all_resources"
|
||||||
|
include_filter=""
|
||||||
|
exclude_filter=""
|
||||||
|
export_path="../training/build/CosmicClash.x86_64"
|
||||||
|
encryption_include_filters=""
|
||||||
|
encryption_exclude_filters=""
|
||||||
|
encrypt_pck=false
|
||||||
|
encrypt_directory=false
|
||||||
|
script_encryption_key=""
|
||||||
|
|
||||||
|
[preset.0.options]
|
||||||
|
|
||||||
|
custom_template/debug=""
|
||||||
|
custom_template/release=""
|
||||||
|
debug/export_console_script=1
|
||||||
|
binary_format/embed_pck=true
|
||||||
|
texture_format/bptc=true
|
||||||
|
texture_format/s3tc=true
|
||||||
|
texture_format/etc=false
|
||||||
|
texture_format/etc2=false
|
||||||
|
binary_format/architecture="x86_64"
|
||||||
+1
-12
@@ -18,6 +18,7 @@ config/name="Cosmic Clash"
|
|||||||
config/description="A fast-paced, physics-based sports game set in space. From Raymond Studios."
|
config/description="A fast-paced, physics-based sports game set in space. From Raymond Studios."
|
||||||
config/version="0.0.1"
|
config/version="0.0.1"
|
||||||
run/main_scene="uid://bcq14356s3e2i"
|
run/main_scene="uid://bcq14356s3e2i"
|
||||||
|
run/main_scene.training="res://scenes/training.tscn"
|
||||||
config/features=PackedStringArray("4.7", "Forward Plus")
|
config/features=PackedStringArray("4.7", "Forward Plus")
|
||||||
config/icon="res://icon.svg"
|
config/icon="res://icon.svg"
|
||||||
|
|
||||||
@@ -117,16 +118,4 @@ common/physics_interpolation=true
|
|||||||
|
|
||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
GameSettings="*res://scripts/game_settings.gd"
|
GameSettings="*res://scripts/game_settings.gd"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+102
-36
@@ -1,4 +1,4 @@
|
|||||||
[gd_scene load_steps=6 format=3 uid="uid://c8kak2l3m4n5"]
|
[gd_scene load_steps=9 format=3 uid="uid://c8kak2l3m4n5"]
|
||||||
|
|
||||||
[ext_resource type="Script" uid="uid://du7y176h5aaq4" path="res://scripts/HUDController.gd" id="1_hud_controller"]
|
[ext_resource type="Script" uid="uid://du7y176h5aaq4" path="res://scripts/HUDController.gd" id="1_hud_controller"]
|
||||||
[ext_resource type="Script" path="res://scripts/hud_attitude_indicator.gd" id="2_adi"]
|
[ext_resource type="Script" path="res://scripts/hud_attitude_indicator.gd" id="2_adi"]
|
||||||
@@ -8,6 +8,36 @@
|
|||||||
[sub_resource type="LabelSettings" id="LabelSettings_hud"]
|
[sub_resource type="LabelSettings" id="LabelSettings_hud"]
|
||||||
font_size = 32
|
font_size = 32
|
||||||
|
|
||||||
|
[sub_resource type="LabelSettings" id="LabelSettings_kickoff"]
|
||||||
|
font_size = 96
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_scoreboard"]
|
||||||
|
bg_color = Color(0.05, 0.06, 0.09, 0.6)
|
||||||
|
corner_radius_top_left = 10
|
||||||
|
corner_radius_top_right = 10
|
||||||
|
corner_radius_bottom_right = 10
|
||||||
|
corner_radius_bottom_left = 10
|
||||||
|
content_margin_left = 20.0
|
||||||
|
content_margin_top = 8.0
|
||||||
|
content_margin_right = 20.0
|
||||||
|
content_margin_bottom = 8.0
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_result"]
|
||||||
|
bg_color = Color(0.05, 0.06, 0.09, 0.85)
|
||||||
|
border_width_left = 3
|
||||||
|
border_width_top = 3
|
||||||
|
border_width_right = 3
|
||||||
|
border_width_bottom = 3
|
||||||
|
border_color = Color(1, 1, 1, 0.2)
|
||||||
|
corner_radius_top_left = 20
|
||||||
|
corner_radius_top_right = 20
|
||||||
|
corner_radius_bottom_right = 20
|
||||||
|
corner_radius_bottom_left = 20
|
||||||
|
content_margin_left = 48.0
|
||||||
|
content_margin_top = 32.0
|
||||||
|
content_margin_right = 48.0
|
||||||
|
content_margin_bottom = 32.0
|
||||||
|
|
||||||
[node name="HUD" type="CanvasLayer"]
|
[node name="HUD" type="CanvasLayer"]
|
||||||
process_mode = 3
|
process_mode = 3
|
||||||
script = ExtResource("1_hud_controller")
|
script = ExtResource("1_hud_controller")
|
||||||
@@ -21,47 +51,79 @@ grow_horizontal = 2
|
|||||||
grow_vertical = 2
|
grow_vertical = 2
|
||||||
mouse_filter = 2
|
mouse_filter = 2
|
||||||
|
|
||||||
[node name="TimerLabel" type="Label" parent="Control"]
|
[node name="ScoreboardPanel" type="PanelContainer" parent="Control"]
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
anchors_preset = 5
|
anchors_preset = 5
|
||||||
anchor_left = 0.5
|
anchor_left = 0.5
|
||||||
anchor_right = 0.5
|
anchor_right = 0.5
|
||||||
offset_left = -90.5
|
offset_left = -220.0
|
||||||
offset_right = 90.5
|
offset_right = 220.0
|
||||||
offset_bottom = 62.0
|
offset_top = 8.0
|
||||||
|
offset_bottom = 60.0
|
||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
|
theme_override_styles/panel = SubResource("StyleBoxFlat_scoreboard")
|
||||||
|
|
||||||
|
[node name="ScoreRow" type="HBoxContainer" parent="Control/ScoreboardPanel"]
|
||||||
|
layout_mode = 2
|
||||||
|
alignment = 1
|
||||||
|
theme_override_constants/separation = 16
|
||||||
|
|
||||||
|
[node name="Team0Name" type="Label" parent="Control/ScoreboardPanel/ScoreRow"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_override_colors/font_color = Color(0.25, 0.55, 1, 1)
|
||||||
|
theme_override_font_sizes/font_size = 14
|
||||||
|
horizontal_alignment = 1
|
||||||
|
text = "BLUE"
|
||||||
|
|
||||||
|
[node name="Team0Score" type="Label" parent="Control/ScoreboardPanel/ScoreRow"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_override_colors/font_color = Color(0.25, 0.55, 1, 1)
|
||||||
|
theme_override_font_sizes/font_size = 32
|
||||||
|
horizontal_alignment = 1
|
||||||
|
text = "0"
|
||||||
|
|
||||||
|
[node name="TimerLabel" type="Label" parent="Control/ScoreboardPanel/ScoreRow"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
label_settings = SubResource("LabelSettings_hud")
|
label_settings = SubResource("LabelSettings_hud")
|
||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
|
|
||||||
[node name="ScoreRow" type="HBoxContainer" parent="Control"]
|
[node name="Team1Score" type="Label" parent="Control/ScoreboardPanel/ScoreRow"]
|
||||||
layout_mode = 1
|
|
||||||
anchors_preset = 5
|
|
||||||
anchor_left = 0.5
|
|
||||||
anchor_right = 0.5
|
|
||||||
offset_left = -90.5
|
|
||||||
offset_right = 90.5
|
|
||||||
offset_top = 64.0
|
|
||||||
offset_bottom = 110.0
|
|
||||||
grow_horizontal = 2
|
|
||||||
alignment = 1
|
|
||||||
|
|
||||||
[node name="Team0Score" type="Label" parent="Control/ScoreRow"]
|
|
||||||
layout_mode = 2
|
|
||||||
theme_override_colors/font_color = Color(0.25, 0.55, 1, 1)
|
|
||||||
theme_override_font_sizes/font_size = 32
|
|
||||||
text = "0"
|
|
||||||
|
|
||||||
[node name="ScoreDash" type="Label" parent="Control/ScoreRow"]
|
|
||||||
layout_mode = 2
|
|
||||||
theme_override_font_sizes/font_size = 32
|
|
||||||
text = " - "
|
|
||||||
|
|
||||||
[node name="Team1Score" type="Label" parent="Control/ScoreRow"]
|
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
theme_override_colors/font_color = Color(1, 0.5, 0.15, 1)
|
theme_override_colors/font_color = Color(1, 0.5, 0.15, 1)
|
||||||
theme_override_font_sizes/font_size = 32
|
theme_override_font_sizes/font_size = 32
|
||||||
|
horizontal_alignment = 1
|
||||||
text = "0"
|
text = "0"
|
||||||
|
|
||||||
|
[node name="Team1Name" type="Label" parent="Control/ScoreboardPanel/ScoreRow"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_override_colors/font_color = Color(1, 0.5, 0.15, 1)
|
||||||
|
theme_override_font_sizes/font_size = 14
|
||||||
|
horizontal_alignment = 1
|
||||||
|
text = "ORANGE"
|
||||||
|
|
||||||
|
[node name="KickoffLabel" type="Label" parent="Control"]
|
||||||
|
visible = false
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 8
|
||||||
|
anchor_left = 0.5
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_right = 0.5
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
offset_left = -90.0
|
||||||
|
offset_top = -60.0
|
||||||
|
offset_right = 90.0
|
||||||
|
offset_bottom = 60.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
label_settings = SubResource("LabelSettings_kickoff")
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 1
|
||||||
|
|
||||||
[node name="ResultOverlay" type="Control" parent="Control"]
|
[node name="ResultOverlay" type="Control" parent="Control"]
|
||||||
visible = false
|
visible = false
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
@@ -88,19 +150,23 @@ anchor_bottom = 1.0
|
|||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
grow_vertical = 2
|
grow_vertical = 2
|
||||||
|
|
||||||
[node name="VBox" type="VBoxContainer" parent="Control/ResultOverlay/Center"]
|
[node name="ResultPanel" type="PanelContainer" parent="Control/ResultOverlay/Center"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_styles/panel = SubResource("StyleBoxFlat_result")
|
||||||
|
|
||||||
|
[node name="VBox" type="VBoxContainer" parent="Control/ResultOverlay/Center/ResultPanel"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_constants/separation = 16
|
theme_override_constants/separation = 16
|
||||||
|
|
||||||
[node name="ResultLabel" type="Label" parent="Control/ResultOverlay/Center/VBox"]
|
[node name="ResultLabel" type="Label" parent="Control/ResultOverlay/Center/ResultPanel/VBox"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_font_sizes/font_size = 56
|
theme_override_font_sizes/font_size = 64
|
||||||
text = "Draw"
|
text = "Draw"
|
||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
|
|
||||||
[node name="FinalScoreLabel" type="Label" parent="Control/ResultOverlay/Center/VBox"]
|
[node name="FinalScoreLabel" type="Label" parent="Control/ResultOverlay/Center/ResultPanel/VBox"]
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_font_sizes/font_size = 36
|
theme_override_font_sizes/font_size = 44
|
||||||
text = "0 - 0"
|
text = "0 - 0"
|
||||||
horizontal_alignment = 1
|
horizontal_alignment = 1
|
||||||
|
|
||||||
@@ -120,8 +186,8 @@ anchor_left = 0.5
|
|||||||
anchor_right = 0.5
|
anchor_right = 0.5
|
||||||
offset_left = -180.0
|
offset_left = -180.0
|
||||||
offset_right = 180.0
|
offset_right = 180.0
|
||||||
offset_top = 116.0
|
offset_top = 68.0
|
||||||
offset_bottom = 156.0
|
offset_bottom = 108.0
|
||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
mouse_filter = 2
|
mouse_filter = 2
|
||||||
script = ExtResource("3_tape")
|
script = ExtResource("3_tape")
|
||||||
|
|||||||
@@ -5,13 +5,17 @@
|
|||||||
extends CanvasLayer
|
extends CanvasLayer
|
||||||
class_name HUDController
|
class_name HUDController
|
||||||
|
|
||||||
@onready var timer_label = get_node("Control/TimerLabel")
|
@onready var score_row = get_node_or_null("Control/ScoreboardPanel")
|
||||||
@onready var score_row = get_node_or_null("Control/ScoreRow")
|
@onready var timer_label = get_node_or_null("Control/ScoreboardPanel/ScoreRow/TimerLabel")
|
||||||
@onready var team0_score_label = get_node_or_null("Control/ScoreRow/Team0Score")
|
@onready var team0_name_label = get_node_or_null("Control/ScoreboardPanel/ScoreRow/Team0Name")
|
||||||
@onready var team1_score_label = get_node_or_null("Control/ScoreRow/Team1Score")
|
@onready var team0_score_label = get_node_or_null("Control/ScoreboardPanel/ScoreRow/Team0Score")
|
||||||
|
@onready var team1_score_label = get_node_or_null("Control/ScoreboardPanel/ScoreRow/Team1Score")
|
||||||
|
@onready var team1_name_label = get_node_or_null("Control/ScoreboardPanel/ScoreRow/Team1Name")
|
||||||
|
@onready var kickoff_label = get_node_or_null("Control/KickoffLabel")
|
||||||
@onready var result_overlay = get_node_or_null("Control/ResultOverlay")
|
@onready var result_overlay = get_node_or_null("Control/ResultOverlay")
|
||||||
@onready var result_label = get_node_or_null("Control/ResultOverlay/Center/VBox/ResultLabel")
|
@onready var result_panel = get_node_or_null("Control/ResultOverlay/Center/ResultPanel")
|
||||||
@onready var final_score_label = get_node_or_null("Control/ResultOverlay/Center/VBox/FinalScoreLabel")
|
@onready var result_label = get_node_or_null("Control/ResultOverlay/Center/ResultPanel/VBox/ResultLabel")
|
||||||
|
@onready var final_score_label = get_node_or_null("Control/ResultOverlay/Center/ResultPanel/VBox/FinalScoreLabel")
|
||||||
|
|
||||||
@onready var heading_tape: HudHeadingTape = get_node_or_null("Control/Instruments/HeadingTape")
|
@onready var heading_tape: HudHeadingTape = get_node_or_null("Control/Instruments/HeadingTape")
|
||||||
@onready var attitude_indicator: HudAttitudeIndicator = get_node_or_null("Control/Instruments/Cluster/Dials/AttitudeIndicator")
|
@onready var attitude_indicator: HudAttitudeIndicator = get_node_or_null("Control/Instruments/Cluster/Dials/AttitudeIndicator")
|
||||||
@@ -25,6 +29,7 @@ const TEAM_NAMES := {0: "Blue", 1: "Orange"}
|
|||||||
const TEAM_COLORS := {0: Color(0.25, 0.55, 1.0), 1: Color(1.0, 0.5, 0.15)}
|
const TEAM_COLORS := {0: Color(0.25, 0.55, 1.0), 1: Color(1.0, 0.5, 0.15)}
|
||||||
|
|
||||||
var ship: Node
|
var ship: Node
|
||||||
|
var _last_score := {0: 0, 1: 0}
|
||||||
|
|
||||||
func _ready():
|
func _ready():
|
||||||
# Wait one frame to ensure ship is ready and added to group
|
# Wait one frame to ensure ship is ready and added to group
|
||||||
@@ -66,12 +71,24 @@ func _initialize_hud():
|
|||||||
var has_score = game_manager and game_manager.has_signal("score_changed")
|
var has_score = game_manager and game_manager.has_signal("score_changed")
|
||||||
if has_score:
|
if has_score:
|
||||||
game_manager.score_changed.connect(_on_score_changed)
|
game_manager.score_changed.connect(_on_score_changed)
|
||||||
|
for label in [team0_name_label, team0_score_label, team1_score_label, team1_name_label]:
|
||||||
|
if label and is_instance_valid(label):
|
||||||
|
label.visible = has_score
|
||||||
|
|
||||||
|
# The banner itself (timer + score share one panel) only fully hides
|
||||||
|
# when neither signal exists at all.
|
||||||
if score_row and is_instance_valid(score_row):
|
if score_row and is_instance_valid(score_row):
|
||||||
score_row.visible = has_score
|
score_row.visible = has_timer or has_score
|
||||||
|
|
||||||
if game_manager and game_manager.has_signal("match_ended"):
|
if game_manager and game_manager.has_signal("match_ended"):
|
||||||
game_manager.match_ended.connect(_on_match_ended)
|
game_manager.match_ended.connect(_on_match_ended)
|
||||||
|
|
||||||
|
if game_manager and game_manager.has_signal("kickoff_countdown"):
|
||||||
|
game_manager.kickoff_countdown.connect(_on_kickoff_countdown)
|
||||||
|
|
||||||
|
if game_manager and game_manager.has_signal("overtime_started"):
|
||||||
|
game_manager.overtime_started.connect(_on_overtime_started)
|
||||||
|
|
||||||
func _connect_ship_signals():
|
func _connect_ship_signals():
|
||||||
# Route ship telemetry to the flight instruments
|
# Route ship telemetry to the flight instruments
|
||||||
if ship.has_signal("speed_changed"):
|
if ship.has_signal("speed_changed"):
|
||||||
@@ -122,10 +139,35 @@ func update_timer(time_text: String):
|
|||||||
timer_label.text = time_text
|
timer_label.text = time_text
|
||||||
|
|
||||||
func _on_score_changed(score: Dictionary):
|
func _on_score_changed(score: Dictionary):
|
||||||
if team0_score_label and is_instance_valid(team0_score_label):
|
_update_score_label(team0_score_label, score, 0)
|
||||||
team0_score_label.text = str(score.get(0, 0))
|
_update_score_label(team1_score_label, score, 1)
|
||||||
if team1_score_label and is_instance_valid(team1_score_label):
|
_last_score = score.duplicate()
|
||||||
team1_score_label.text = str(score.get(1, 0))
|
|
||||||
|
func _update_score_label(label: Label, score: Dictionary, team: int):
|
||||||
|
if not label or not is_instance_valid(label):
|
||||||
|
return
|
||||||
|
label.text = str(score.get(team, 0))
|
||||||
|
if score.get(team, 0) > _last_score.get(team, 0):
|
||||||
|
_flash_score_label(label)
|
||||||
|
|
||||||
|
# Brief scale pop to draw the eye to whichever team just scored.
|
||||||
|
func _flash_score_label(label: Label) -> void:
|
||||||
|
label.pivot_offset = label.size / 2.0
|
||||||
|
var tween := create_tween()
|
||||||
|
tween.tween_property(label, "scale", Vector2(1.4, 1.4), 0.1)
|
||||||
|
tween.tween_property(label, "scale", Vector2(1.0, 1.0), 0.2)
|
||||||
|
|
||||||
|
func _on_overtime_started():
|
||||||
|
if timer_label and is_instance_valid(timer_label):
|
||||||
|
timer_label.text = "OT"
|
||||||
|
timer_label.add_theme_color_override("font_color", Color(1.0, 0.85, 0.2))
|
||||||
|
|
||||||
|
func _on_kickoff_countdown(count: int):
|
||||||
|
if not kickoff_label or not is_instance_valid(kickoff_label):
|
||||||
|
return
|
||||||
|
kickoff_label.visible = count > 0
|
||||||
|
if count > 0:
|
||||||
|
kickoff_label.text = str(count)
|
||||||
|
|
||||||
func _on_match_ended(winning_team: int, score: Dictionary):
|
func _on_match_ended(winning_team: int, score: Dictionary):
|
||||||
if not result_overlay or not is_instance_valid(result_overlay):
|
if not result_overlay or not is_instance_valid(result_overlay):
|
||||||
@@ -133,8 +175,35 @@ func _on_match_ended(winning_team: int, score: Dictionary):
|
|||||||
if winning_team < 0:
|
if winning_team < 0:
|
||||||
result_label.text = "Draw"
|
result_label.text = "Draw"
|
||||||
result_label.remove_theme_color_override("font_color")
|
result_label.remove_theme_color_override("font_color")
|
||||||
|
_set_result_panel_accent(Color(1, 1, 1, 0.2))
|
||||||
else:
|
else:
|
||||||
result_label.text = "%s team wins!" % TEAM_NAMES[winning_team]
|
result_label.text = "%s team wins!" % TEAM_NAMES[winning_team]
|
||||||
result_label.add_theme_color_override("font_color", TEAM_COLORS[winning_team])
|
result_label.add_theme_color_override("font_color", TEAM_COLORS[winning_team])
|
||||||
|
_set_result_panel_accent(TEAM_COLORS[winning_team])
|
||||||
final_score_label.text = "%d - %d" % [score.get(0, 0), score.get(1, 0)]
|
final_score_label.text = "%d - %d" % [score.get(0, 0), score.get(1, 0)]
|
||||||
result_overlay.visible = true
|
result_overlay.visible = true
|
||||||
|
_animate_result_panel_in()
|
||||||
|
|
||||||
|
# Tints the result panel's border to match the winning team (neutral for a draw).
|
||||||
|
func _set_result_panel_accent(color: Color) -> void:
|
||||||
|
if not result_panel or not is_instance_valid(result_panel):
|
||||||
|
return
|
||||||
|
var style: StyleBoxFlat = result_panel.get_theme_stylebox("panel").duplicate()
|
||||||
|
style.border_color = color
|
||||||
|
result_panel.add_theme_stylebox_override("panel", style)
|
||||||
|
|
||||||
|
# Quick scale/fade pop so the results screen doesn't just snap into view.
|
||||||
|
func _animate_result_panel_in() -> void:
|
||||||
|
if not result_panel or not is_instance_valid(result_panel):
|
||||||
|
return
|
||||||
|
result_panel.scale = Vector2(0.85, 0.85)
|
||||||
|
result_panel.modulate.a = 0.0
|
||||||
|
# ResultOverlay was hidden since scene start, so ResultPanel's
|
||||||
|
# container-computed size isn't valid until the next layout pass.
|
||||||
|
await get_tree().process_frame
|
||||||
|
if not is_instance_valid(result_panel):
|
||||||
|
return
|
||||||
|
result_panel.pivot_offset = result_panel.size / 2.0
|
||||||
|
var tween := create_tween().set_parallel(true)
|
||||||
|
tween.tween_property(result_panel, "scale", Vector2.ONE, 0.25).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
|
||||||
|
tween.tween_property(result_panel, "modulate:a", 1.0, 0.2)
|
||||||
|
|||||||
@@ -7,11 +7,16 @@ extends GameMode
|
|||||||
|
|
||||||
signal timer_updated(minutes: int, seconds: int)
|
signal timer_updated(minutes: int, seconds: int)
|
||||||
signal score_changed(score: Dictionary)
|
signal score_changed(score: Dictionary)
|
||||||
# winning_team is -1 for a draw.
|
# winning_team is -1 for a draw. Never -1 when reached via overtime.
|
||||||
signal match_ended(winning_team: int, score: Dictionary)
|
signal match_ended(winning_team: int, score: Dictionary)
|
||||||
|
# Counts 3, 2, 1, then 0 ("go", hide the label).
|
||||||
|
signal kickoff_countdown(count: int)
|
||||||
|
# Full time ended level: sudden-death golden goal, no clock.
|
||||||
|
signal overtime_started
|
||||||
|
|
||||||
# How long the result overlay stays up before returning to the menu.
|
# How long the result overlay stays up before returning to the menu.
|
||||||
const RESULT_SCREEN_SECONDS := 5.0
|
const RESULT_SCREEN_SECONDS := 5.0
|
||||||
|
const KICKOFF_COUNTDOWN_SECONDS := 3
|
||||||
|
|
||||||
@export var match_length_seconds := 150.0
|
@export var match_length_seconds := 150.0
|
||||||
|
|
||||||
@@ -24,6 +29,7 @@ const RESULT_SCREEN_SECONDS := 5.0
|
|||||||
|
|
||||||
var score := {0: 0, 1: 0}
|
var score := {0: 0, 1: 0}
|
||||||
var match_timer: Timer
|
var match_timer: Timer
|
||||||
|
var _in_overtime := false
|
||||||
|
|
||||||
|
|
||||||
func _start() -> void:
|
func _start() -> void:
|
||||||
@@ -32,6 +38,8 @@ func _start() -> void:
|
|||||||
spawn_camera_rig(player_ship)
|
spawn_camera_rig(player_ship)
|
||||||
spawn_ship(1, 0, _make_opponent_controller())
|
spawn_ship(1, 0, _make_opponent_controller())
|
||||||
|
|
||||||
|
await _run_kickoff_countdown()
|
||||||
|
|
||||||
match_timer = Timer.new()
|
match_timer = Timer.new()
|
||||||
match_timer.one_shot = true
|
match_timer.one_shot = true
|
||||||
match_timer.wait_time = match_length_seconds
|
match_timer.wait_time = match_length_seconds
|
||||||
@@ -66,15 +74,58 @@ func _on_goal_scored(conceding_team: int) -> void:
|
|||||||
score[scoring_team] += 1
|
score[scoring_team] += 1
|
||||||
score_changed.emit(score.duplicate())
|
score_changed.emit(score.duplicate())
|
||||||
print("Goal for team %d! Score: %d - %d" % [scoring_team, score[0], score[1]])
|
print("Goal for team %d! Score: %d - %d" % [scoring_team, score[0], score[1]])
|
||||||
|
if _in_overtime:
|
||||||
|
# Golden goal: the first score after a draw ends the match outright.
|
||||||
|
_end_match(scoring_team)
|
||||||
|
return
|
||||||
|
await _run_kickoff_countdown()
|
||||||
|
|
||||||
|
|
||||||
|
# Used both for the initial kickoff and after every goal. Freezes ball/ships
|
||||||
|
# via RigidBody3D.freeze — a built-in engine property — so this needs zero
|
||||||
|
# changes to ship.gd/ball.gd/ship_controller.gd, keeping training_mode.gd
|
||||||
|
# (which never calls into this file) completely untouched.
|
||||||
|
func _run_kickoff_countdown() -> void:
|
||||||
reset_ball()
|
reset_ball()
|
||||||
reset_ships()
|
reset_ships()
|
||||||
|
_set_frozen(true)
|
||||||
|
for count in range(KICKOFF_COUNTDOWN_SECONDS, 0, -1):
|
||||||
|
kickoff_countdown.emit(count)
|
||||||
|
# process_always=false: if full-time fires mid-countdown (see
|
||||||
|
# _on_match_timer_timeout's get_tree().paused = true), this stalls
|
||||||
|
# harmlessly in lockstep with the pause instead of ticking a
|
||||||
|
# countdown label over the results screen.
|
||||||
|
await get_tree().create_timer(1.0, false).timeout
|
||||||
|
kickoff_countdown.emit(0)
|
||||||
|
_set_frozen(false)
|
||||||
|
|
||||||
|
|
||||||
|
func _set_frozen(frozen: bool) -> void:
|
||||||
|
if is_instance_valid(ball):
|
||||||
|
ball.set_deferred("freeze", frozen)
|
||||||
|
for ship in ships:
|
||||||
|
if is_instance_valid(ship):
|
||||||
|
ship.set_deferred("freeze", frozen)
|
||||||
|
|
||||||
|
|
||||||
func _on_match_timer_timeout() -> void:
|
func _on_match_timer_timeout() -> void:
|
||||||
print("Full time! Final score: %d - %d" % [score[0], score[1]])
|
print("Full time! Score: %d - %d" % [score[0], score[1]])
|
||||||
var winning_team := -1
|
if score[0] == score[1]:
|
||||||
if score[0] != score[1]:
|
await _start_overtime()
|
||||||
winning_team = 0 if score[0] > score[1] else 1
|
return
|
||||||
|
_end_match(0 if score[0] > score[1] else 1)
|
||||||
|
|
||||||
|
|
||||||
|
# Full time ended level: sudden-death golden goal, no clock running.
|
||||||
|
# _on_goal_scored checks _in_overtime and ends the match on the next goal.
|
||||||
|
func _start_overtime() -> void:
|
||||||
|
_in_overtime = true
|
||||||
|
overtime_started.emit()
|
||||||
|
await _run_kickoff_countdown()
|
||||||
|
|
||||||
|
|
||||||
|
func _end_match(winning_team: int) -> void:
|
||||||
|
print("Match over! Final score: %d - %d" % [score[0], score[1]])
|
||||||
match_ended.emit(winning_team, score.duplicate())
|
match_ended.emit(winning_team, score.duplicate())
|
||||||
# Freeze gameplay while the HUD (process_mode ALWAYS) shows the result.
|
# Freeze gameplay while the HUD (process_mode ALWAYS) shows the result.
|
||||||
get_tree().paused = true
|
get_tree().paused = true
|
||||||
|
|||||||
@@ -107,6 +107,15 @@ var ball: RigidBody3D
|
|||||||
var opponent: Ship
|
var opponent: Ship
|
||||||
var attack_goal_position: Vector3
|
var attack_goal_position: Vector3
|
||||||
|
|
||||||
|
# Set directly by TrainingMode (_on_goal_scored / the timeout branch in
|
||||||
|
# _physics_process) at the same time as `done = true`. Deliberately NOT
|
||||||
|
# cleared in reset(): TrainingMode's _reset_episode() (which calls reset())
|
||||||
|
# runs synchronously, immediately after done is set, before the Sync node
|
||||||
|
# ever reads get_info()/get_done() for that terminal tick — clearing it here
|
||||||
|
# would wipe the value that read needs. Both call sites always overwrite
|
||||||
|
# (true on goal, false on timeout) rather than toggle, so no reset is needed.
|
||||||
|
var goal_scored_this_episode := false
|
||||||
|
|
||||||
var _ticks_since_ball_touch := 1 << 30 # large so the first touch always pays
|
var _ticks_since_ball_touch := 1 << 30 # large so the first touch always pays
|
||||||
|
|
||||||
|
|
||||||
@@ -135,6 +144,14 @@ func get_reward() -> float:
|
|||||||
return reward
|
return reward
|
||||||
|
|
||||||
|
|
||||||
|
# Symmetric across both self-play agents: reports whether this episode ended
|
||||||
|
# in a goal at all, not which team scored — a clean "goal rate" signal
|
||||||
|
# distinct from rollout/ep_rew_mean, which mixes this with dense shaping
|
||||||
|
# (ball chasing/touching). See train.py's GoalRateCallback.
|
||||||
|
func get_info() -> Dictionary:
|
||||||
|
return {"goal_scored": goal_scored_this_episode}
|
||||||
|
|
||||||
|
|
||||||
func get_action_space() -> Dictionary:
|
func get_action_space() -> Dictionary:
|
||||||
return {
|
return {
|
||||||
"thrust": {"size": 3, "action_type": "continuous"},
|
"thrust": {"size": 3, "action_type": "continuous"},
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ func _physics_process(_delta):
|
|||||||
for agent in _agents:
|
for agent in _agents:
|
||||||
agent.reward -= draw_penalty
|
agent.reward -= draw_penalty
|
||||||
agent.done = true
|
agent.done = true
|
||||||
|
agent.goal_scored_this_episode = false
|
||||||
_reset_episode()
|
_reset_episode()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -320,6 +321,7 @@ func _on_goal_scored(conceding_team: int) -> void:
|
|||||||
for agent in _agents:
|
for agent in _agents:
|
||||||
agent.reward += goal_reward if agent.ship.team != conceding_team else -goal_reward
|
agent.reward += goal_reward if agent.ship.team != conceding_team else -goal_reward
|
||||||
agent.done = true
|
agent.done = true
|
||||||
|
agent.goal_scored_this_episode = true
|
||||||
_reset_episode()
|
_reset_episode()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,6 @@ The training pipeline is built — see `TRAINING.md` (self-play PPO via the vend
|
|||||||
- [ ] Frozen-opponent league: train the live policy against a *pool* of past exported checkpoints, sampled per-episode (today's `--opponent-mode=frozen` only supports one fixed model per run) to prevent self-play strategy collapse on long runs.
|
- [ ] Frozen-opponent league: train the live policy against a *pool* of past exported checkpoints, sampled per-episode (today's `--opponent-mode=frozen` only supports one fixed model per run) to prevent self-play strategy collapse on long runs.
|
||||||
- [ ] Richer state setter / curriculum: aerial states, wall plays, rebound scenarios as skill grows (beyond the score/defend/draw staging already in place).
|
- [ ] Richer state setter / curriculum: aerial states, wall plays, rebound scenarios as skill grows (beyond the score/defend/draw staging already in place).
|
||||||
- [ ] Main-menu difficulty picker (Match already takes `bot_model_path`/`bot_reaction_ticks`/`bot_action_noise` exports).
|
- [ ] Main-menu difficulty picker (Match already takes `bot_model_path`/`bot_reaction_ticks`/`bot_action_noise` exports).
|
||||||
- [ ] Optional: exported headless Linux build for faster parallel training instances (train.py currently runs the project from source, which is fine but re-parses scripts per instance).
|
|
||||||
|
|
||||||
## Match mode polish
|
|
||||||
|
|
||||||
- [ ] Kickoff countdown (3-2-1) before play starts and after each goal, instead of instant reset.
|
|
||||||
- [ ] HUD scoreboard widget consuming the existing `score_changed` signal.
|
|
||||||
- [ ] Results screen on timer expiry (winner, final score) instead of dumping straight back to the main menu.
|
|
||||||
- [ ] Overtime / golden-goal rule on a draw.
|
|
||||||
|
|
||||||
## Multiplayer (long term)
|
## Multiplayer (long term)
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -17,7 +17,10 @@ Python, no .NET, no network.
|
|||||||
observation builder — training and in-game inference must stay identical,
|
observation builder — training and in-game inference must stay identical,
|
||||||
so never fork it.
|
so never fork it.
|
||||||
- `training/train.py` — PPO trainer; launches N parallel headless Godot
|
- `training/train.py` — PPO trainer; launches N parallel headless Godot
|
||||||
instances (2 agents each).
|
instances (2 agents each) — from source by default, or from a pre-built
|
||||||
|
binary via `--exported-binary` (see TRAINING_LINUX.md's "Exported-binary
|
||||||
|
training" section; `training/export_linux.sh` builds it from
|
||||||
|
`Game/export_presets.cfg`'s "Linux Training" preset).
|
||||||
- `training/export_policy.py` — SB3 checkpoint → JSON policy for the game.
|
- `training/export_policy.py` — SB3 checkpoint → JSON policy for the game.
|
||||||
- `scripts/ai_ship_controller.gd` + `scripts/policy_network.gd` — in-game
|
- `scripts/ai_ship_controller.gd` + `scripts/policy_network.gd` — in-game
|
||||||
inference (GDScript MLP forward pass).
|
inference (GDScript MLP forward pass).
|
||||||
@@ -86,7 +89,11 @@ real behaviour needs tens of millions of steps (hours on the 3090 box).
|
|||||||
|
|
||||||
Key curves: `rollout/ep_rew_mean` (should trend up), `rollout/ep_len_mean`
|
Key curves: `rollout/ep_rew_mean` (should trend up), `rollout/ep_len_mean`
|
||||||
(should trend *down* from 225 as goals end episodes early — 225 action steps
|
(should trend *down* from 225 as goals end episodes early — 225 action steps
|
||||||
= the 30s episode timeout).
|
= the 30s episode timeout), `rollout/goal_rate` (fraction of recent episodes
|
||||||
|
that ended in an actual goal rather than timing out as a draw — the live
|
||||||
|
signal for "is the policy actually finishing more episodes by scoring",
|
||||||
|
since `ep_rew_mean` mixes that with dense reward-shaping (ball chasing/
|
||||||
|
touching) and doesn't isolate it).
|
||||||
|
|
||||||
### Reward/observation tuning
|
### Reward/observation tuning
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,56 @@ On the Mac (or anywhere), collecting the results is just `git pull`. A
|
|||||||
20M-step run adds roughly 40 MB of checkpoints — acceptable growth for the
|
20M-step run adds roughly 40 MB of checkpoints — acceptable growth for the
|
||||||
guarantee that training is never lost with a machine.
|
guarantee that training is never lost with a machine.
|
||||||
|
|
||||||
|
## Exported-binary training (faster parallel startup)
|
||||||
|
|
||||||
|
By default (and in every example above) `train.py` runs the project from
|
||||||
|
source via `--godot_bin` — each of the `--n-parallel` instances re-parses
|
||||||
|
project settings and re-imports scripts/resources on launch. An **exported**
|
||||||
|
build skips that: resources are pre-imported and packed once at export time,
|
||||||
|
so each instance just loads a binary. Worth it once `--n-parallel` is large
|
||||||
|
enough that per-instance startup overhead adds up (i.e. this box, not the Mac
|
||||||
|
mini's `--n-parallel 6`).
|
||||||
|
|
||||||
|
Opt in once — `run_training.sh` (and so `start_training.sh`/`next_run.sh`/
|
||||||
|
`curriculum.sh`, which all funnel through it) takes it from there automatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./export_linux.sh # one-time opt-in: builds training/build/CosmicClash.x86_64
|
||||||
|
./next_run.sh # from here on, every standing/curriculum run uses it
|
||||||
|
```
|
||||||
|
|
||||||
|
You don't need to (and shouldn't) pass `--exported-binary` yourself through
|
||||||
|
those entry points — `run_training.sh` adds it whenever `training/build/`
|
||||||
|
exists, after re-exporting against whatever `git pull` just fetched. Calling
|
||||||
|
`train.py` directly still takes it explicitly, same as any other flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python train.py --experiment run04 --exported-binary build/CosmicClash.x86_64 \
|
||||||
|
--timesteps 20000000 --n-parallel 14 --speedup 24
|
||||||
|
```
|
||||||
|
|
||||||
|
To go back to a source run permanently, delete `training/build/` — with it
|
||||||
|
gone, `run_training.sh` stops re-exporting and stops adding the flag, so
|
||||||
|
`next_run.sh`/`curriculum.sh` revert to plain source runs with no code changes.
|
||||||
|
|
||||||
|
`export_linux.sh` builds from the "Linux Training" preset in
|
||||||
|
`Game/export_presets.cfg`, which is training-only — its
|
||||||
|
`custom_features="training"` activates project.godot's
|
||||||
|
`run/main_scene.training` override, so the resulting binary boots straight
|
||||||
|
into `training.tscn` on its own. This indirection is required, not
|
||||||
|
incidental: official Godot export templates have path/scene overrides
|
||||||
|
compiled out, so passing `--scene` at launch time (the way the source run
|
||||||
|
does) hard-aborts an exported binary with "compiled without support for path
|
||||||
|
overrides" — there's no way to redirect an exported build to a different
|
||||||
|
scene at runtime. Because the main scene is baked in at export time, this
|
||||||
|
preset can't later double as a normal "ship the game" Linux build (which
|
||||||
|
would need `main_menu.tscn` and no training feature tag) — a real game export
|
||||||
|
would need its own separate preset.
|
||||||
|
|
||||||
|
`setup_linux.sh` installs the export templates this needs alongside the
|
||||||
|
Godot binary. If you never opt in (no `training/build/` directory), this
|
||||||
|
costs nothing — training stays a plain source run.
|
||||||
|
|
||||||
## Dashboard over the network
|
## Dashboard over the network
|
||||||
|
|
||||||
`start_training.sh` already serves TensorBoard on all interfaces — browse to
|
`start_training.sh` already serves TensorBoard on all interfaces — browse to
|
||||||
|
|||||||
+24
-8
@@ -19,10 +19,27 @@ TRAINING_SCENE = "res://scenes/training.tscn"
|
|||||||
|
|
||||||
|
|
||||||
class CosmicClashEnv(GodotEnv):
|
class CosmicClashEnv(GodotEnv):
|
||||||
"""GodotEnv that launches `godot --path Game res://scenes/training.tscn`."""
|
"""GodotEnv that launches either the project from source or an exported binary.
|
||||||
|
|
||||||
# env_path is a Godot binary, not an exported game: skip the suffix and
|
Source mode (default): `godot --path Game res://scenes/training.tscn` — the
|
||||||
# platform checks stock GodotEnv applies to exported executables.
|
positional scene argument overrides the project's normal main scene.
|
||||||
|
Exported mode (`exported=True`): `env_path` is a pre-built game executable
|
||||||
|
(see training/export_linux.sh, "Linux Training" preset) — no `--path` and
|
||||||
|
no scene override needed or possible: official Godot export templates have
|
||||||
|
path/scene overrides compiled out (`--scene`/a positional scene argument
|
||||||
|
hard-aborts with "compiled without support for path overrides"), so the
|
||||||
|
binary instead boots straight into training.tscn on its own via
|
||||||
|
project.godot's `run/main_scene.training` feature-tag override, activated
|
||||||
|
by that preset's `custom_features="training"`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, exported: bool = False, **kwargs):
|
||||||
|
self.exported = exported
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# env_path is a Godot binary (or, in exported mode, a game executable we
|
||||||
|
# built ourselves), not a stock godot_rl exported-project path: skip the
|
||||||
|
# suffix and platform checks stock GodotEnv applies to those.
|
||||||
def _set_platform_suffix(self, env_path: str) -> str:
|
def _set_platform_suffix(self, env_path: str) -> str:
|
||||||
return env_path
|
return env_path
|
||||||
|
|
||||||
@@ -32,11 +49,10 @@ class CosmicClashEnv(GodotEnv):
|
|||||||
def _launch_env(self, env_path, port, show_window, framerate, seed, action_repeat, speedup, **kwargs):
|
def _launch_env(self, env_path, port, show_window, framerate, seed, action_repeat, speedup, **kwargs):
|
||||||
# sync.gd reads --key=value pairs from the raw command line; they must
|
# sync.gd reads --key=value pairs from the raw command line; they must
|
||||||
# NOT go after a `--` separator or OS.get_cmdline_args() drops them.
|
# NOT go after a `--` separator or OS.get_cmdline_args() drops them.
|
||||||
cmd = [
|
cmd = [env_path]
|
||||||
env_path,
|
if not self.exported:
|
||||||
"--path",
|
cmd += ["--path", str(GAME_DIR), TRAINING_SCENE]
|
||||||
str(GAME_DIR),
|
cmd += [
|
||||||
TRAINING_SCENE,
|
|
||||||
f"--port={port}",
|
f"--port={port}",
|
||||||
f"--env_seed={seed}",
|
f"--env_seed={seed}",
|
||||||
]
|
]
|
||||||
|
|||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the exported Linux training binary from the "Linux Training" preset in
|
||||||
|
# Game/export_presets.cfg. That preset's custom_features="training" activates
|
||||||
|
# project.godot's `run/main_scene.training` override, so the resulting binary
|
||||||
|
# boots straight into training.tscn on its own — official Godot export
|
||||||
|
# templates have path/scene overrides compiled out, so this can't be done at
|
||||||
|
# launch time via --scene. Re-run any time training-relevant scripts/scenes
|
||||||
|
# change — an exported binary is a snapshot, not a live view of the source.
|
||||||
|
# See TRAINING_LINUX.md.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
GODOT_VERSION="4.7.1"
|
||||||
|
GODOT_BIN="${GODOT_BIN:-$HOME/ai-training/godot/Godot_v${GODOT_VERSION}-stable_linux.x86_64}"
|
||||||
|
OUT="build/CosmicClash.x86_64"
|
||||||
|
|
||||||
|
mkdir -p build
|
||||||
|
"$GODOT_BIN" --headless --path ../Game --export-release "Linux Training" "$(pwd)/$OUT"
|
||||||
|
chmod +x "$OUT"
|
||||||
|
echo "Exported: training/$OUT"
|
||||||
@@ -16,10 +16,28 @@ shift
|
|||||||
# Train on the latest code and checkpoints from any machine
|
# Train on the latest code and checkpoints from any machine
|
||||||
git pull --rebase
|
git pull --rebase
|
||||||
|
|
||||||
|
# Rebuild the exported training binary against the code we just pulled, so a
|
||||||
|
# --exported-binary run never trains on a stale snapshot. Only when the
|
||||||
|
# caller has already opted into the exported-binary flow (training/build/
|
||||||
|
# exists from a prior export) — a source-run caller pays no extra latency.
|
||||||
|
# Fails the whole run rather than silently falling through to train.py
|
||||||
|
# against a stale or partially-rebuilt binary (export_linux.sh sets -e, but
|
||||||
|
# that only exits *it*, not this script).
|
||||||
|
EXTRA_ARGS=()
|
||||||
|
if [ -d build ]; then
|
||||||
|
./export_linux.sh || { echo "export_linux.sh failed — aborting" >&2; exit 1; }
|
||||||
|
# Standing entry points (next_run.sh, curriculum.py) don't know about the
|
||||||
|
# exported binary and never pass --exported-binary themselves; default it
|
||||||
|
# here so opting in (by ever running export_linux.sh once) actually gets
|
||||||
|
# used, not just kept up to date. An explicit --exported-binary in "$@"
|
||||||
|
# still wins (argparse: later occurrence overrides).
|
||||||
|
EXTRA_ARGS=(--exported-binary build/CosmicClash.x86_64)
|
||||||
|
fi
|
||||||
|
|
||||||
# Let Ctrl-C stop train.py without killing this script, so the export and
|
# Let Ctrl-C stop train.py without killing this script, so the export and
|
||||||
# commit below still run
|
# commit below still run
|
||||||
trap ':' INT
|
trap ':' INT
|
||||||
.venv/bin/python train.py --experiment "$EXP" "$@"
|
.venv/bin/python train.py --experiment "$EXP" "${EXTRA_ARGS[@]}" "$@"
|
||||||
trap - INT
|
trap - INT
|
||||||
|
|
||||||
if [ ! -f "checkpoints/$EXP/final.zip" ]; then
|
if [ ! -f "checkpoints/$EXP/final.zip" ]; then
|
||||||
|
|||||||
@@ -20,6 +20,24 @@ if [ ! -x "$GODOT_BIN" ]; then
|
|||||||
fi
|
fi
|
||||||
echo "Godot: $GODOT_BIN"
|
echo "Godot: $GODOT_BIN"
|
||||||
|
|
||||||
|
# Export templates — needed to build the exported training binary
|
||||||
|
# (export_linux.sh); the editor binary alone can't produce release exports.
|
||||||
|
TEMPLATES_DIR="$HOME/.local/share/godot/export_templates/${GODOT_VERSION}.stable"
|
||||||
|
if [ ! -d "$TEMPLATES_DIR" ]; then
|
||||||
|
echo "Downloading Godot $GODOT_VERSION export templates to $TEMPLATES_DIR"
|
||||||
|
TMP_DL="$(mktemp -d)"
|
||||||
|
wget -q "https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}-stable/Godot_v${GODOT_VERSION}-stable_export_templates.tpz" \
|
||||||
|
-O "$TMP_DL/templates.tpz"
|
||||||
|
unzip -o -q "$TMP_DL/templates.tpz" -d "$TMP_DL"
|
||||||
|
mkdir -p "$(dirname "$TEMPLATES_DIR")"
|
||||||
|
# Single mv creates $TEMPLATES_DIR only on full success — if this script is
|
||||||
|
# interrupted any earlier, the guard above correctly sees "not installed"
|
||||||
|
# and retries, instead of finding a half-populated dir and skipping.
|
||||||
|
mv "$TMP_DL/templates" "$TEMPLATES_DIR"
|
||||||
|
rm -rf "$TMP_DL"
|
||||||
|
fi
|
||||||
|
echo "Export templates: $TEMPLATES_DIR"
|
||||||
|
|
||||||
# Python env
|
# Python env
|
||||||
[ -d .venv ] || python3 -m venv .venv
|
[ -d .venv ] || python3 -m venv .venv
|
||||||
.venv/bin/pip install -q -r requirements.txt
|
.venv/bin/pip install -q -r requirements.txt
|
||||||
|
|||||||
+36
-4
@@ -16,7 +16,8 @@ import os
|
|||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
from stable_baselines3 import PPO
|
from stable_baselines3 import PPO
|
||||||
from stable_baselines3.common.callbacks import CheckpointCallback
|
from stable_baselines3.common.callbacks import BaseCallback, CheckpointCallback
|
||||||
|
from stable_baselines3.common.utils import safe_mean
|
||||||
from stable_baselines3.common.vec_env.vec_monitor import VecMonitor
|
from stable_baselines3.common.vec_env.vec_monitor import VecMonitor
|
||||||
|
|
||||||
from cosmic_env import CosmicClashVecEnv
|
from cosmic_env import CosmicClashVecEnv
|
||||||
@@ -25,6 +26,27 @@ TRAINING_DIR = pathlib.Path(__file__).resolve().parent
|
|||||||
DEFAULT_GODOT_MACOS = "/Applications/Godot.app/Contents/MacOS/Godot"
|
DEFAULT_GODOT_MACOS = "/Applications/Godot.app/Contents/MacOS/Godot"
|
||||||
|
|
||||||
|
|
||||||
|
class GoalRateCallback(BaseCallback):
|
||||||
|
"""Logs rollout/goal_rate: the fraction of completed episodes in the
|
||||||
|
current ep_info_buffer that ended in an actual goal, vs. timing out as a
|
||||||
|
draw. rollout/ep_rew_mean mixes dense reward-shaping (ball chasing/
|
||||||
|
touching) with the sparse terminal goal reward, so it can trend up from
|
||||||
|
better shaping alone without the policy finishing more episodes by
|
||||||
|
actually scoring — this isolates that. Requires VecMonitor(...,
|
||||||
|
info_keywords=("goal_scored",)), which copies ShipAIController.get_info()
|
||||||
|
into each completed episode's info["episode"] dict (see
|
||||||
|
training_mode.gd's _on_goal_scored / timeout branch)."""
|
||||||
|
|
||||||
|
def _on_step(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _on_rollout_end(self) -> None:
|
||||||
|
if len(self.model.ep_info_buffer) == 0:
|
||||||
|
return
|
||||||
|
goal_rate = safe_mean([ep_info["goal_scored"] for ep_info in self.model.ep_info_buffer])
|
||||||
|
self.logger.record("rollout/goal_rate", goal_rate)
|
||||||
|
|
||||||
|
|
||||||
def parse_args():
|
def parse_args():
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -32,6 +54,13 @@ def parse_args():
|
|||||||
default=os.environ.get("GODOT_BIN", DEFAULT_GODOT_MACOS),
|
default=os.environ.get("GODOT_BIN", DEFAULT_GODOT_MACOS),
|
||||||
help="Path to the Godot binary (or set GODOT_BIN)",
|
help="Path to the Godot binary (or set GODOT_BIN)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--exported-binary",
|
||||||
|
default=None,
|
||||||
|
help="Path to a pre-built game executable (see export_linux.sh) instead of running the "
|
||||||
|
"project from source — skips per-instance script/resource import for faster parallel "
|
||||||
|
"startup. Overrides --godot_bin when set.",
|
||||||
|
)
|
||||||
parser.add_argument("--experiment", default="default", help="Run name for logs/checkpoints")
|
parser.add_argument("--experiment", default="default", help="Run name for logs/checkpoints")
|
||||||
parser.add_argument("--timesteps", type=int, default=200_000)
|
parser.add_argument("--timesteps", type=int, default=200_000)
|
||||||
parser.add_argument("--n-parallel", type=int, default=2, help="Parallel Godot instances (2 agents each)")
|
parser.add_argument("--n-parallel", type=int, default=2, help="Parallel Godot instances (2 agents each)")
|
||||||
@@ -146,8 +175,10 @@ def main():
|
|||||||
|
|
||||||
wandb.init(project="cosmic-clash-rl", name=args.experiment, sync_tensorboard=True)
|
wandb.init(project="cosmic-clash-rl", name=args.experiment, sync_tensorboard=True)
|
||||||
|
|
||||||
|
exported_binary = args.exported_binary or None
|
||||||
env = CosmicClashVecEnv(
|
env = CosmicClashVecEnv(
|
||||||
godot_bin=args.godot_bin,
|
godot_bin=exported_binary or args.godot_bin,
|
||||||
|
exported=exported_binary is not None,
|
||||||
n_parallel=args.n_parallel,
|
n_parallel=args.n_parallel,
|
||||||
seed=args.seed,
|
seed=args.seed,
|
||||||
port=args.port,
|
port=args.port,
|
||||||
@@ -155,7 +186,7 @@ def main():
|
|||||||
speedup=args.speedup,
|
speedup=args.speedup,
|
||||||
**_curriculum_kwargs(args),
|
**_curriculum_kwargs(args),
|
||||||
)
|
)
|
||||||
env = VecMonitor(env)
|
env = VecMonitor(env, info_keywords=("goal_scored",))
|
||||||
|
|
||||||
if args.resume:
|
if args.resume:
|
||||||
model = PPO.load(
|
model = PPO.load(
|
||||||
@@ -195,11 +226,12 @@ def main():
|
|||||||
save_path=str(checkpoint_dir),
|
save_path=str(checkpoint_dir),
|
||||||
name_prefix="ppo",
|
name_prefix="ppo",
|
||||||
)
|
)
|
||||||
|
goal_rate_callback = GoalRateCallback()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
model.learn(
|
model.learn(
|
||||||
args.timesteps,
|
args.timesteps,
|
||||||
callback=checkpoint_callback,
|
callback=[checkpoint_callback, goal_rate_callback],
|
||||||
tb_log_name=args.experiment,
|
tb_log_name=args.experiment,
|
||||||
reset_num_timesteps=not args.resume,
|
reset_num_timesteps=not args.resume,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user