feat: 定时招募弹窗,开局5选3+定期3候选,配置驱动(GameConfig/RecruitPanel)

This commit is contained in:
2026-08-06 00:13:31 +08:00
parent a00c95af51
commit a3cbd738ad
10 changed files with 324 additions and 28 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ GameState="*res://src/Core/GameState.gd"
TimeSystem="*res://src/Core/TimeSystem.gd"
Settings="*res://src/Core/Settings.gd"
SectManager="*res://src/Core/SectManager.gd"
DiscipleManager="*res://src/Character/DiscipleManager.gd"
GameConfig="*res://src/Core/GameConfig.gd"
DiscipleManager="*res://src/Character/DiscipleManager.gd"
[display]
+9
View File
@@ -0,0 +1,9 @@
; 模拟宗门 全局配置
; 所有可调参数集中在此,修改后重启游戏生效
; 注释以 ; 开头,参数按系统分节
[recruitment]
opening_applicant_count=5
opening_pick_count=3
recruit_interval_years=0.1
recruit_applicant_count=3
+71 -25
View File
@@ -1,19 +1,21 @@
## 弟子管理器(Autoload 单例)。
## 负责弟子定时招募、入宗分流与宗门名录维护。
## 负责候选弟子生成、入宗选择与宗门名录维护。
## 招募流程:开局/定期生成候选 → 暂停时间 → 玩家在弹窗选择 → 结算入名录。
extends Node
## 名录变化时发出(增删/重置)。
signal disciples_changed
## 候选弟子生成时发出(开局/定期,弹窗 UI 监听此信号)。
## @param is_opening: 是否开局招募(开局必须选满,定期自由选择)
signal applicants_generated(is_opening: bool)
## 宗门弟子名录。
## 宗门弟子名录(已入宗)
var disciples: Array[Disciple] = []
## 招募间隔(游戏天数,每满间隔自动招募一名弟子)。
const RECRUIT_INTERVAL_DAYS := 30
## 候选弟子池(未入宗,等待玩家选择)。
var applicants: Array[Disciple] = []
## 入宗分流权重(按资质四类):下品 / 中品 / 上品 / 天骄。
const APTITUDE_WEIGHTS: Array[int] = [45, 35, 15, 5]
## 天灵根生成概率(3%)。
const HEAVENLY_CHANCE := 0.03
@@ -31,33 +33,48 @@ const GIVEN_CHARS: Array[String] = [
"", "", "", "屿", "", "", "", "",
]
## 距下次招募的剩余游戏天数。
var _days_until_recruit := RECRUIT_INTERVAL_DAYS
## 下一位弟子的 ID(自增,重置归零)。
## 距下次定期招募的剩余游戏天数。
var _days_until_recruit := 0
## 下一位入宗弟子的 ID(自增,重置归零)。
var _next_id := 1
func _ready() -> void:
# 挂到时间系统上:每日推进时倒计时,满间隔自动招募
# 挂到时间系统上:每日推进时倒计时,满间隔生成候选并暂停时间
TimeSystem.day_passed.connect(_on_day_passed)
_days_until_recruit = _get_interval_days()
## 每日推进:招募倒计时减 1,归零时自动招募一名弟子
## 每日推进:定期招募倒计时减 1,归零时生成候选并暂停时间
func _on_day_passed(_year: int, _month: int, _day: int) -> void:
_days_until_recruit -= 1
if _days_until_recruit <= 0:
_days_until_recruit = RECRUIT_INTERVAL_DAYS
recruit()
_days_until_recruit = _get_interval_days()
generate_applicants(GameConfig.recruit_applicant_count)
TimeSystem.set_paused(true)
## 招募一名弟子:随机生成属性、按资质分流后加入名录,返回该弟子
func recruit() -> Disciple:
var d := Disciple.new()
## 开局新游戏:清空所有数据,生成开局候选(数量来自配置),暂停时间等待选择
func start_new_game() -> void:
reset()
generate_applicants(GameConfig.opening_applicant_count, true)
TimeSystem.set_paused(true)
## 生成一批候选弟子:清空旧候选后按 count 生成,发信号供弹窗显示。
## @param count: 候选人数(调用方从 GameConfig 传入)
## @param is_opening: 是否开局招募
func generate_applicants(count: int, is_opening := false) -> void:
applicants.clear()
for i in count:
applicants.append(_create_applicant())
applicants_generated.emit(is_opening)
## 接受一名候选弟子:分配 ID 并移入名录(候选池 → 名录)。
## @param d: 要接受的候选
func accept_applicant(d: Disciple) -> void:
if not applicants.has(d):
return
applicants.erase(d)
d.id = _next_id
_next_id += 1
d.name = _generate_name()
d.age = randi_range(12, 18)
d.is_heavenly = randf() < HEAVENLY_CHANCE
d.roots = _generate_roots(d.is_heavenly)
d.aptitude = _roll_aptitude()
disciples.append(d)
disciples_changed.emit()
print("弟子入宗:%s %d岁 | %s资质%s | %s灵根 | %s" % [
@@ -65,21 +82,50 @@ func recruit() -> Disciple:
"·天灵根" if d.is_heavenly else "",
d.get_roots_text(), d.get_realm_text(),
])
return d
## 移除弟子(逐出/陨落)
## 拒绝一名候选弟子:从候选池移除,不进入名录
## @param d: 要拒绝的候选
func reject_applicant(d: Disciple) -> void:
applicants.erase(d)
## 结算本次招募:selected 内的候选入宗,其余全部拒绝,清空候选池。
## @param selected: 玩家勾选的候选列表(弹窗"确定"时传入)
func finish_recruitment(selected: Array[Disciple]) -> void:
for d in applicants.duplicate():
if selected.has(d):
accept_applicant(d)
else:
reject_applicant(d)
## 移除已入宗弟子(逐出/陨落)。
## @param d: 要移除的弟子
func remove_disciple(d: Disciple) -> void:
disciples.erase(d)
disciples_changed.emit()
## 清空名录(新游戏开局调用),招募倒计时复位。
## 清空名录与候选池(新游戏开局调用),倒计时与 ID 复位。
func reset() -> void:
disciples.clear()
applicants.clear()
_next_id = 1
_days_until_recruit = RECRUIT_INTERVAL_DAYS
_days_until_recruit = _get_interval_days()
disciples_changed.emit()
## 定期招募间隔(游戏天数),由 GameConfig 的"年"换算而来。
func _get_interval_days() -> int:
var days_per_year := TimeSystem.MONTHS_PER_YEAR * TimeSystem.DAYS_PER_MONTH
return maxi(1, roundi(GameConfig.recruit_interval_years * days_per_year))
## 创建一名候选弟子:随机生成属性(姓名/年龄/灵根/资质),
## 不分配 ID——ID 在入宗时由 accept_applicant 分配,保证连续且无浪费。
func _create_applicant() -> Disciple:
var d := Disciple.new()
d.name = _generate_name()
d.age = randi_range(12, 18)
d.is_heavenly = randf() < HEAVENLY_CHANCE
d.roots = _generate_roots(d.is_heavenly)
d.aptitude = _roll_aptitude()
return d
## 随机生成姓名:单姓 + 1~2 个名用字。
func _generate_name() -> String:
+32
View File
@@ -0,0 +1,32 @@
## 游戏全局配置(Autoload 单例)。
## 启动时从 resources/game_config.cfg 读取所有可调参数,
## 其他系统通过公开变量读取,如 GameConfig.recruit_interval_years。
extends Node
## 配置文件路径(res:// 开发期可改,导出后只读)。
const CONFIG_PATH := "res://resources/game_config.cfg"
# —— 招募系统(对应 cfg 的 [recruitment] 节)——
## 开局候选人数。
var opening_applicant_count := 5
## 开局必须选满数。
var opening_pick_count := 3
## 定期招募间隔(游戏年,1 年 = 360 天)。
var recruit_interval_years := 0.1
## 定期弹窗候选人数。
var recruit_applicant_count := 3
func _ready() -> void:
_load_config()
## 读取配置文件;缺失的项保持代码默认值,不会报错。
func _load_config() -> void:
var cfg := ConfigFile.new()
if cfg.load(CONFIG_PATH) != OK:
push_warning("GameConfig: 配置文件不存在,使用默认值")
return
opening_applicant_count = cfg.get_value("recruitment", "opening_applicant_count", opening_applicant_count)
opening_pick_count = cfg.get_value("recruitment", "opening_pick_count", opening_pick_count)
recruit_interval_years = cfg.get_value("recruitment", "recruit_interval_years", recruit_interval_years)
recruit_applicant_count = cfg.get_value("recruitment", "recruit_applicant_count", recruit_applicant_count)
+1
View File
@@ -0,0 +1 @@
uid://dlchibfd86oek
+2 -2
View File
@@ -7,8 +7,8 @@ func _ready() -> void:
add_to_group("game_scene")
# 应用用户设置中的默认流速
TimeSystem.apply_default_speed()
# 新游戏清空弟子名录与招募倒计时(读档流程接入后由存档还原)
DiscipleManager.reset()
# 开局招募:清空旧数据 → 生成 5 名候选 → 暂停时间等待选择(读档流程接入后由存档还原)
DiscipleManager.start_new_game()
## 顶部 HUD 的返回主菜单请求。
func _on_top_bar_hud_sign_return_mainmenu() -> void:
+3
View File
@@ -2,6 +2,7 @@
[ext_resource type="Script" uid="uid://centrk8l54j7c" path="res://src/Core/MainGame.gd" id="1_8bu23"]
[ext_resource type="PackedScene" uid="uid://dl0tb68vsw3c6" path="res://src/UI/HUD/TopBarHUD.tscn" id="1_g6xmk"]
[ext_resource type="PackedScene" path="res://src/UI/Panels/RecruitPanel/RecruitPanel.tscn" id="1_k5nrm"]
[node name="MainGame" type="Node" unique_id=2053561265]
groups = ["game_scene"]
@@ -9,4 +10,6 @@ script = ExtResource("1_8bu23")
[node name="TopBarHud" parent="." unique_id=1115394694 instance=ExtResource("1_g6xmk")]
[node name="RecruitPanel" parent="." unique_id=770000016 instance=ExtResource("1_k5nrm")]
[connection signal="sign_return_mainmenu" from="TopBarHud" to="." method="_on_top_bar_hud_sign_return_mainmenu"]
@@ -0,0 +1,96 @@
[gd_scene format=3 uid="uid://d4y7q3m6k2x5n7w2"]
[ext_resource type="Script" path="res://src/UI/Panels/RecruitPanel/recruit_panel.gd" id="1_script"]
[ext_resource type="FontFile" uid="uid://drt448qncx27p" path="res://assets/fonts/dingliezhuhaifont-20240831GengXinBan)-2.ttf" id="2_font"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dialog"]
bg_color = Color(0.11, 0.08, 0.13, 0.97)
corner_radius_top_left = 8
corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
[node name="RecruitPanel" type="Control" unique_id=770000001]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_script")
[node name="Backdrop" type="ColorRect" parent="." unique_id=770000002]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0, 0, 0, 0.6)
[node name="CenterContainer" type="CenterContainer" parent="." unique_id=770000003]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="Panel" type="PanelContainer" parent="CenterContainer" unique_id=770000004]
theme_override_styles/panel = SubResource("StyleBoxFlat_dialog")
[node name="MarginContainer" type="MarginContainer" parent="CenterContainer/Panel" unique_id=770000005]
theme_override_constants/margin_left = 28
theme_override_constants/margin_top = 20
theme_override_constants/margin_right = 28
theme_override_constants/margin_bottom = 20
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer/Panel/MarginContainer" unique_id=770000006]
theme_override_constants/separation = 12
[node name="TitleLabel" type="Label" parent="CenterContainer/Panel/MarginContainer/VBoxContainer" unique_id=770000007]
unique_name_in_owner = true
theme_override_fonts/font = ExtResource("2_font")
theme_override_font_sizes/font_size = 30
text = "弟子入宗"
[node name="HintLabel" type="Label" parent="CenterContainer/Panel/MarginContainer/VBoxContainer" unique_id=770000008]
unique_name_in_owner = true
theme_override_fonts/font = ExtResource("2_font")
theme_override_font_sizes/font_size = 20
theme_override_colors/font_color = Color(0.78, 0.75, 0.82, 1)
text = ""
[node name="ScrollContainer" type="ScrollContainer" parent="CenterContainer/Panel/MarginContainer/VBoxContainer" unique_id=770000009]
custom_minimum_size = Vector2(640, 300)
size_flags_vertical = 3
[node name="ListContainer" type="VBoxContainer" parent="CenterContainer/Panel/MarginContainer/VBoxContainer/ScrollContainer" unique_id=770000010]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
[node name="ButtonRow" type="HBoxContainer" parent="CenterContainer/Panel/MarginContainer/VBoxContainer" unique_id=770000011]
theme_override_constants/separation = 8
[node name="Button_AcceptAll" type="Button" parent="CenterContainer/Panel/MarginContainer/VBoxContainer/ButtonRow" unique_id=770000012]
unique_name_in_owner = true
theme_override_fonts/font = ExtResource("2_font")
theme_override_font_sizes/font_size = 22
text = "全部接受"
[node name="Button_RejectAll" type="Button" parent="CenterContainer/Panel/MarginContainer/VBoxContainer/ButtonRow" unique_id=770000013]
unique_name_in_owner = true
theme_override_fonts/font = ExtResource("2_font")
theme_override_font_sizes/font_size = 22
text = "全部拒绝"
[node name="Spacer" type="Control" parent="CenterContainer/Panel/MarginContainer/VBoxContainer/ButtonRow" unique_id=770000014]
layout_mode = 2
size_flags_horizontal = 3
[node name="Button_Confirm" type="Button" parent="CenterContainer/Panel/MarginContainer/VBoxContainer/ButtonRow" unique_id=770000015]
unique_name_in_owner = true
theme_override_fonts/font = ExtResource("2_font")
theme_override_font_sizes/font_size = 22
text = "确定"
[connection signal="button_up" from="CenterContainer/Panel/MarginContainer/VBoxContainer/ButtonRow/Button_AcceptAll" to="." method="_on_button_accept_all_button_up"]
[connection signal="button_up" from="CenterContainer/Panel/MarginContainer/VBoxContainer/ButtonRow/Button_RejectAll" to="." method="_on_button_reject_all_button_up"]
[connection signal="button_up" from="CenterContainer/Panel/MarginContainer/VBoxContainer/ButtonRow/Button_Confirm" to="." method="_on_button_confirm_button_up"]
+108
View File
@@ -0,0 +1,108 @@
## 招募弹窗(挂载于 RecruitPanel.tscn)。
## 监听候选生成信号自动弹出,玩家勾选后点"确定"结算入宗。
extends Control
## 招募模式:开局必须选满 / 定期自由选择。
enum Mode { OPENING, PERIODIC }
@onready var title_label: Label = %TitleLabel
@onready var hint_label: Label = %HintLabel
@onready var list_container: VBoxContainer = %ListContainer
@onready var confirm_button: Button = %Button_Confirm
## 当前招募模式。
var _mode: Mode = Mode.PERIODIC
## 勾选框列表(与 _entry_disciples 按下标一一对应)。
var _check_buttons: Array[CheckButton] = []
## 对应勾选框的候选弟子。
var _entry_disciples: Array[Disciple] = []
func _ready() -> void:
# 订阅候选生成信号:开局与定期招募都会触发
DiscipleManager.applicants_generated.connect(_on_applicants_generated)
visible = false
## 候选生成时弹出:按当前候选池重建列表。
## @param is_opening: 是否开局招募
func _on_applicants_generated(is_opening: bool) -> void:
_mode = Mode.OPENING if is_opening else Mode.PERIODIC
_clear_entries()
for d in DiscipleManager.applicants:
_add_entry(d)
title_label.text = "弟子入宗(%s" % ("开局招募" if is_opening else "定期招募")
_update_ui()
visible = true
## 勾选任一候选后刷新提示与确定按钮可用性。
func _on_check_toggled(_pressed: bool, _d: Disciple) -> void:
_update_ui()
## 全部接受:勾选所有候选。
func _on_button_accept_all_button_up() -> void:
for c in _check_buttons:
c.set_pressed_no_signal(true)
_update_ui()
## 全部拒绝:取消所有勾选。
func _on_button_reject_all_button_up() -> void:
for c in _check_buttons:
c.set_pressed_no_signal(false)
_update_ui()
## 确定:勾选的入宗、未勾选的拒绝,关闭弹窗并恢复时间。
func _on_button_confirm_button_up() -> void:
var selected: Array[Disciple] = []
for i in _entry_disciples.size():
if _check_buttons[i].button_pressed:
selected.append(_entry_disciples[i])
DiscipleManager.finish_recruitment(selected)
_close()
## 刷新提示文本与确定按钮状态。
func _update_ui() -> void:
if _mode == Mode.OPENING:
var picked := 0
for c in _check_buttons:
if c.button_pressed:
picked += 1
hint_label.text = "开局招募:请选择 %d 名弟子入宗(已选 %d/%d" % [
GameConfig.opening_pick_count, picked, _check_buttons.size(),
]
confirm_button.disabled = picked < GameConfig.opening_pick_count
else:
hint_label.text = "定期招募:勾选入宗弟子,未勾选的将被拒绝(可全部拒绝)"
confirm_button.disabled = false
## 关闭弹窗:清空条目并恢复时间推进。
func _close() -> void:
_clear_entries()
visible = false
TimeSystem.set_paused(false)
## 清空候选条目。
func _clear_entries() -> void:
for child in list_container.get_children():
child.queue_free()
_check_buttons.clear()
_entry_disciples.clear()
## 添加一名候选的行条目:信息文本 + 入宗勾选框。
## @param d: 候选弟子
func _add_entry(d: Disciple) -> void:
var row := HBoxContainer.new()
var info := Label.new()
info.text = "%s %d岁 | %s资质%s | %s灵根 | %s" % [
d.name, d.age, d.get_aptitude_name(),
"·天灵根" if d.is_heavenly else "",
d.get_roots_text(), d.get_realm_text(),
]
info.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var check := CheckButton.new()
check.text = "入宗"
check.toggled.connect(_on_check_toggled.bind(d))
row.add_child(info)
row.add_child(check)
list_container.add_child(row)
_check_buttons.append(check)
_entry_disciples.append(d)
@@ -0,0 +1 @@
uid://byjqc1ssqulc5