Files
TheTree_mock/scripts/ui/defense_management_panel.gd
T
Takeshi b84bade045 Save The Tree implementation and acceptance work in progress
Preserve production code, regression fixtures, audio assets and provenance, test infrastructure, and the original-completion plan and progress ledger. Final game and Windows acceptance remain pending; excluded local artifacts and native experiments are not part of this snapshot.
2026-10-11 15:58:00 +09:00

449 lines
21 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class_name DefenseManagementPanel
extends VBoxContainer
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
## Player controls for an incident that already exists. Threat creation,
## combat balancing, skill authoring and automatic combat belong to Runtime.
signal status_requested(message: String, success: bool)
const KEY_PREFIX := "ui.defense."
const COMMANDS := [&"register_defense_combatant", &"register_defense_asset", &"start_defense_raid", &"set_defense_retreat_policy", &"begin_next_defense_wave", &"retreat_defenders"]
const STATE_NAMES := ["準備中", "戦闘中", "次の襲撃待ち", "撃退", "突破された", "撤退", "準備終了"]
const SOURCE_NAMES := {"THIEF": "盗賊の襲撃", "BANKRUPTCY_RIOT": "客の暴動", "BANK_DELINQUENCY": "銀行による取り立て", "AUTHORITY_ESCALATION": "監査による摘発"}
const POLICIES := [&"HOLD", &"MANUAL", &"THRESHOLD", &"PROTECT_AND_RETREAT"]
const POLICY_LABELS := ["持ち場を守る", "自分で撤退を指示", "体力が減ったら撤退", "体力・施設の損傷で撤退"]
var selected_raid_id: StringName = &""
var selected_staff_id: StringName = &""
var selected_objective_id: StringName = &""
var raid_option: OptionButton
var staff_option: OptionButton
var objective_option: OptionButton
var policy_option: OptionButton
var threshold_spin: SpinBox
var summary_label: Label
var defenders_label: Label
var assets_label: Label
var staff_label: Label
var objective_label: Label
var result_label: Label
var register_staff_button: Button
var register_objective_button: Button
var start_button: Button
var policy_button: Button
var next_wave_button: Button
var retreat_button: Button
var confirmation: ConfirmationDialog
var _runtime: Variant
var _audio_submitter := Callable()
var _style_button: Callable
var _report: Dictionary = {}
var _raid_labels: Dictionary = {}
var _pending: Dictionary = {}
var _queue_instance_id := 0
var _sequence := 1
var _built := false
var _confirmation_context: Dictionary = {}
var _policy_drafts: Dictionary = {}
var _refreshing := false
func configure(runtime: Variant, style_button := Callable()) -> void:
_runtime = runtime
_style_button = style_button
func _ready() -> void:
name = "DefenseManagementPanel"
size_flags_horizontal = Control.SIZE_EXPAND_FILL
add_theme_constant_override("separation", 6)
_build_interface()
_built = true
if _runtime != null:
if _runtime.has_signal(&"state_updated"):
_runtime.connect(&"state_updated", _on_snapshot_published)
elif _runtime.has_signal(&"snapshot_published"):
_runtime.connect(&"snapshot_published", _on_snapshot_published)
if _runtime.has_signal(&"command_completed"):
_runtime.connect(&"command_completed", _on_command_completed)
refresh()
func _build_interface() -> void:
raid_option = _option("DefenseRaidOption")
raid_option.fit_to_longest_item = false
raid_option.clip_text = true
raid_option.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
raid_option.item_selected.connect(func(index: int) -> void: select_raid(StringName(raid_option.get_item_metadata(index))))
add_child(raid_option)
summary_label = _label("", "DefenseSummary")
add_child(summary_label)
defenders_label = _label("", "DefenseParticipants")
add_child(defenders_label)
assets_label = _label("", "DefenseAssets")
add_child(assets_label)
add_child(HSeparator.new())
add_child(_label("参加する従業員"))
staff_option = _option("DefenseStaffOption")
staff_option.item_selected.connect(func(index: int) -> void:
selected_staff_id = StringName(staff_option.get_item_metadata(index))
_refresh_controls())
add_child(staff_option)
staff_label = _label("", "DefenseStaffStatus")
add_child(staff_label)
register_staff_button = _button("防衛に参加", "DefenseRegisterStaff", _register_staff)
add_child(register_staff_button)
add_child(_label("防衛する施設"))
objective_option = _option("DefenseObjectiveOption")
objective_option.item_selected.connect(func(index: int) -> void:
selected_objective_id = StringName(objective_option.get_item_metadata(index))
_refresh_controls())
add_child(objective_option)
objective_label = _label("", "DefenseObjectiveStatus")
add_child(objective_label)
register_objective_button = _button("防衛対象に登録", "DefenseRegisterObjective", _register_objective)
add_child(register_objective_button)
add_child(HSeparator.new())
add_child(_label("撤退の方針"))
policy_option = _option("DefenseRetreatPolicy")
for i: int in POLICIES.size():
policy_option.add_item(POLICY_LABELS[i])
policy_option.set_item_metadata(i, POLICIES[i])
policy_option.item_selected.connect(func(_index: int) -> void: _store_policy_draft())
add_child(policy_option)
var threshold_row := HBoxContainer.new()
threshold_row.add_child(_label("撤退する残量(%)"))
threshold_spin = SpinBox.new()
threshold_spin.name = "DefenseRetreatThreshold"
threshold_spin.min_value = 0
threshold_spin.max_value = 100
threshold_spin.step = 1
threshold_spin.value_changed.connect(func(_value: float) -> void: _store_policy_draft())
threshold_row.add_child(threshold_spin)
add_child(threshold_row)
policy_button = _button("撤退方針を適用", "DefenseApplyPolicy", _apply_policy)
add_child(policy_button)
var actions := HBoxContainer.new()
start_button = _button("防衛を開始", "DefenseStart", func() -> void: _submit(&"start_defense_raid"))
next_wave_button = _button("次の襲撃に対応", "DefenseNextWave", func() -> void: _submit(&"begin_next_defense_wave"))
retreat_button = _button("撤退する", "DefenseRetreat", _request_retreat_confirmation)
for button: Button in [start_button, next_wave_button, retreat_button]:
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
actions.add_child(button)
add_child(actions)
result_label = _label("", "DefenseResult")
add_child(result_label)
confirmation = ConfirmationDialog.new()
confirmation.name = "DefenseConfirmation"
confirmation.ok_button_text = "実行する"
confirmation.cancel_button_text = "戻る"
confirmation.confirmed.connect(_confirm_operation)
confirmation.canceled.connect(func() -> void: _confirmation_context.clear())
add_child(confirmation)
confirmation.get_label().add_theme_color_override("font_color", Color("55371e"))
LayerSkin.register_text(confirmation.get_label(), LayerSkin.active_layer(self))
if _style_button.is_valid():
for button: Button in [confirmation.get_ok_button(), confirmation.get_cancel_button()]:
_style_button.call(button)
## Reset optional saved presentation without choosing a live raid implicitly.
func clear_selection() -> void:
_policy_drafts.clear()
if result_label != null: result_label.text = ""
select_raid(&"", false)
func select_raid(raid_id: StringName, select_first_if_empty: bool = true) -> void:
selected_raid_id = raid_id
selected_staff_id = &""
selected_objective_id = &""
_confirmation_context.clear()
if confirmation != null: confirmation.hide()
refresh(select_first_if_empty)
func refresh(select_first_if_empty: bool = true) -> void:
if not _built:
return
var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null
if queue != null and queue.get_instance_id() != _queue_instance_id:
_queue_instance_id = queue.get_instance_id()
_policy_drafts.clear()
_confirmation_context.clear()
confirmation.hide()
for option: OptionButton in [raid_option, staff_option, objective_option, policy_option]:
option.get_popup().hide()
_synchronize_commands(queue)
_report = _runtime.call(&"get_defense_management_report", selected_raid_id) if _runtime != null and _runtime.has_method(&"get_defense_management_report") else {}
if selected_raid_id.is_empty() and not select_first_if_empty:
# Runtime supplies its normal first-raid report. This inspector alone
# presents no selection; it never changes the raid or queued commands.
_report[&"selected"] = {}
_report[&"defenders"] = []
_report[&"assets"] = []
_report[&"objectives"] = []
_report[&"can_control"] = false
_raid_labels = _build_raid_labels(_report.get(&"raids", []))
selected_raid_id = StringName((_report.get(&"selected", {}) as Dictionary).get(&"raid_id", &""))
selected_raid_id = _fill_option(raid_option, _report.get(&"raids", []), &"raid_id", selected_raid_id, select_first_if_empty)
selected_staff_id = _fill_option(staff_option, _report.get(&"staff", []), &"staff_id", selected_staff_id, select_first_if_empty)
selected_objective_id = _fill_option(objective_option, _report.get(&"objectives", []), &"facility_id", selected_objective_id, select_first_if_empty)
var row: Dictionary = _report.get(&"selected", {})
var draft: Dictionary = _policy_drafts.get(selected_raid_id, row)
var policy := StringName(draft.get(&"retreat_policy", &"HOLD"))
_refreshing = true
policy_option.select(maxi(POLICIES.find(policy), 0))
threshold_spin.set_value_no_signal(int(draft.get(&"defender_retreat_threshold", 0)))
_refreshing = false
summary_label.text = ("進行中・保存済みの防衛戦はありません。" if (_report.get(&"raids", []) as Array).is_empty() else "防衛戦を選択してください。") if row.is_empty() else "%s · %s\n襲撃 %d / %d · 攻防 %d回\n%s" % [String(_raid_labels.get(selected_raid_id, _raid_base_label(row))), STATE_NAMES[int(row[&"state"])], int(row[&"current_wave"]), int(row[&"wave_count"]), int(row[&"current_round"]), "防衛対象が未登録です。" if not (row[&"missing_objectives"] as Array).is_empty() else "防衛対象は登録済みです。"]
var names: Array[String] = []
for defender: Dictionary in _report.get(&"defenders", []):
var employee := _row(&"staff", &"staff_id", defender[&"actor_id"])
names.append("%s 体力%d/%d・戦闘%d" % [employee.get(&"display_name", defender[&"actor_id"]), defender[&"health"], defender[&"max_health"], defender[&"combat"]])
defenders_label.text = "参加者:" + ("、".join(names) if not names.is_empty() else "未登録")
names.clear()
for asset: Dictionary in _report.get(&"assets", []):
var facility := _row(&"objectives", &"facility_id", asset[&"asset_id"])
names.append("%s 耐久%d/%d" % [facility.get(&"display_name", asset[&"asset_id"]), asset[&"integrity"], asset[&"max_integrity"]])
assets_label.text = "対象:" + ("、".join(names) if not names.is_empty() else "未登録")
_refresh_controls()
func _refresh_controls() -> void:
var row: Dictionary = _report.get(&"selected", {})
var state := int(row.get(&"state", -1))
var pending := _has_pending(selected_raid_id)
var blocked := row.is_empty() or pending or not bool(_report.get(&"can_control", false))
var staff := _row(&"staff", &"staff_id", selected_staff_id)
var objective := _row(&"objectives", &"facility_id", selected_objective_id)
staff_label.text = "従業員がいません。" if staff.is_empty() else "体力%d/100・戦闘%d\n%s" % [staff[&"health"], staff[&"combat"], "参加できます。参加中は配属を変更できません。" if staff[&"can_register"] else staff[&"reason"]]
objective_label.text = "防衛対象がありません。" if objective.is_empty() else "耐久%d/10000\n%s" % [objective[&"durability"], "登録できます。" if objective[&"can_register"] else objective[&"reason"]]
register_staff_button.disabled = blocked or staff.is_empty() or not bool(staff.get(&"can_register", false))
register_objective_button.disabled = blocked or objective.is_empty() or not bool(objective.get(&"can_register", false))
start_button.visible = state == DefenseService.RaidState.PREPARING
start_button.disabled = blocked or not (row.get(&"missing_objectives", []) as Array).is_empty()
next_wave_button.visible = state == DefenseService.RaidState.BETWEEN_WAVES
next_wave_button.disabled = blocked
retreat_button.visible = state in [DefenseService.RaidState.ACTIVE, DefenseService.RaidState.BETWEEN_WAVES]
retreat_button.disabled = blocked
var terminal := state in DefenseService.TERMINAL_STATES or state < 0
policy_option.disabled = blocked or terminal
var chosen_policy := StringName(policy_option.get_item_metadata(policy_option.selected))
threshold_spin.editable = not blocked and not terminal and chosen_policy in [&"THRESHOLD", &"PROTECT_AND_RETREAT"]
policy_button.disabled = blocked or terminal
staff_option.disabled = blocked or state != DefenseService.RaidState.PREPARING or staff.is_empty()
objective_option.disabled = blocked or state != DefenseService.RaidState.PREPARING or objective.is_empty()
if pending: result_label.text = "指示を受理しました。次の時間更新で反映します。"
func _register_staff() -> void:
var staff := _row(&"staff", &"staff_id", selected_staff_id)
if staff.is_empty() or not bool(staff.get(&"can_register", false)): return
_submit(&"register_defense_combatant", {&"actor_id": selected_staff_id, &"side": &"DEFENDER", &"combat": int(staff[&"combat"])})
func _register_objective() -> void:
var facility := _row(&"objectives", &"facility_id", selected_objective_id)
if facility.is_empty() or not bool(facility.get(&"can_register", false)): return
# Plain objectives have no defense-device bonus. Omit numeric overrides and
# use the existing Runtime command defaults and current live durability.
_submit(&"register_defense_asset", {&"asset_id": selected_objective_id, &"kind": &"OBJECTIVE"})
func _apply_policy() -> void:
_submit(&"set_defense_retreat_policy", {&"policy": policy_option.get_item_metadata(policy_option.selected), &"threshold": int(threshold_spin.value)})
func _store_policy_draft() -> void:
if _refreshing or selected_raid_id.is_empty() or _has_pending(selected_raid_id): return
_policy_drafts[selected_raid_id] = {&"retreat_policy": policy_option.get_item_metadata(policy_option.selected), &"defender_retreat_threshold": int(threshold_spin.value)}
_refresh_controls()
func _request_retreat_confirmation() -> void:
refresh()
var row: Dictionary = _report.get(&"selected", {})
if _has_pending(selected_raid_id) or selected_raid_id.is_empty() or not bool(_report.get(&"can_control", false)) or int(row.get(&"state", -1)) not in [DefenseService.RaidState.ACTIVE, DefenseService.RaidState.BETWEEN_WAVES]: return
_confirmation_context = {&"raid_id": selected_raid_id, &"queue_instance_id": _queue_instance_id}
confirmation.title = "撤退の確認"
confirmation.dialog_text = "撤退すると防衛戦は終了します。参加者の状態と施設の損傷が反映されます。"
confirmation.popup_centered(Vector2i(430, 170))
func _confirm_operation() -> void:
var context := _confirmation_context.duplicate()
_confirmation_context.clear()
var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null
if queue == null or queue.get_instance_id() != int(context.get(&"queue_instance_id", 0)) or context.get(&"raid_id", &"") != selected_raid_id:
_set_result("対象が変わりました。防衛戦を選び直してください。", false)
refresh()
return
refresh()
if context.get(&"raid_id", &"") != selected_raid_id:
_set_result("対象が変わりました。防衛戦を選び直してください。", false)
return
var row: Dictionary = _report.get(&"selected", {})
if _has_pending(selected_raid_id) or not bool(_report.get(&"can_control", false)) or int(row.get(&"state", -1)) not in [DefenseService.RaidState.ACTIVE, DefenseService.RaidState.BETWEEN_WAVES]:
_set_result("現在の防衛戦では撤退を指示できません。", false)
return
_submit(&"retreat_defenders")
func _submit(command_type: StringName, patch: Dictionary = {}) -> void:
if selected_raid_id.is_empty() or _has_pending(selected_raid_id) or command_type not in COMMANDS: return
var payload := patch.duplicate(true)
payload[&"raid_id"] = selected_raid_id
var key := StringName("%s%08d" % [KEY_PREFIX, _sequence])
_sequence += 1
var result: Variant = _submit_ui_command(command_type, payload, &"player", selected_raid_id, 75, -1, key)
if result == null or not result.has_method(&"is_success"):
_set_result("防衛の指示に応答がありません。", false)
return
var queue: Variant = _runtime.get(&"command_queue")
var queued := false
for command: Dictionary in queue.create_snapshot().get("pending", []):
queued = queued or int(command.get("command_id", 0)) == int(result.command_id)
if result.is_success() and queued:
_pending[int(result.command_id)] = payload
else:
_show_result(result, payload)
refresh()
func _show_result(result: Variant, payload: Dictionary) -> void:
if result.is_success() and payload.has(&"policy"):
_policy_drafts.erase(StringName(payload.get(&"raid_id", &"")))
_set_result("防衛の指示を反映しました。" if result.is_success() else "指示を反映できませんでした。体調・業務中の状態・対象施設を確認してください。", result.is_success())
func _synchronize_commands(queue: Variant) -> void:
_pending.clear()
for metadata: Dictionary in queue.get_accepted_command_metadata():
var key := String(metadata.get("idempotency_key", ""))
if key.begins_with(KEY_PREFIX) and key.trim_prefix(KEY_PREFIX).is_valid_int():
_sequence = maxi(_sequence, int(key.trim_prefix(KEY_PREFIX)) + 1)
for command: Dictionary in queue.create_snapshot().get("pending", []):
if StringName(command.get("command_type", &"")) in COMMANDS:
_pending[int(command.get("command_id", 0))] = command.get("payload", {}).duplicate(true)
func _on_command_completed(result: Variant) -> void:
if not _pending.has(int(result.command_id)): return
var payload: Dictionary = _pending[int(result.command_id)]
_pending.erase(int(result.command_id))
_show_result(result, payload)
refresh()
func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void:
if is_visible_in_tree(): refresh()
func _has_pending(raid_id: StringName) -> bool:
for payload: Dictionary in _pending.values():
if payload.get(&"raid_id", &"") == raid_id: return true
return false
func _row(key: StringName, id_key: StringName, entity_id: StringName) -> Dictionary:
for row: Dictionary in _report.get(key, []):
if row.get(id_key, &"") == entity_id: return row
return {}
func _fill_option(option: OptionButton, rows: Array, id_key: StringName, selected: StringName, select_first_if_empty: bool = true) -> StringName:
if option.get_popup().visible: return selected
option.clear()
if rows.is_empty():
option.add_item("対象なし")
option.set_item_metadata(0, &"")
return &""
var index := -1 if selected.is_empty() and not select_first_if_empty else 0
for row: Dictionary in rows:
var id := StringName(row[id_key])
var display_name := String(_raid_labels.get(id, _raid_base_label(row))) if id_key == &"raid_id" else String(row.get(&"display_name", id))
option.add_item(display_name)
if id_key == &"raid_id": option.set_item_tooltip(option.item_count - 1, display_name)
option.set_item_metadata(option.item_count - 1, id)
if id == selected: index = option.item_count - 1
option.select(index)
return &"" if index < 0 else StringName(option.get_item_metadata(index))
## Cause labels are display-only. Never infer cause from an internal ID or
## source_id, and never use the label as an option's metadata or command target.
func _build_raid_labels(rows: Array) -> Dictionary:
var totals: Dictionary = {}
for row: Dictionary in rows:
var base := _raid_base_label(row)
totals[base] = int(totals.get(base, 0)) + 1
var seen: Dictionary = {}
var labels: Dictionary = {}
for row: Dictionary in rows:
var base := _raid_base_label(row)
seen[base] = int(seen.get(base, 0)) + 1
labels[StringName(row[&"raid_id"])] = base + ("(%d)" % int(seen[base]) if int(totals[base]) > 1 else "")
return labels
func _raid_base_label(row: Dictionary) -> String:
var cause := String(SOURCE_NAMES.get(String(row.get(&"source_kind", "")), "防衛戦"))
var raw_minute: Variant = row.get(&"created_at", null)
if typeof(raw_minute) != TYPE_INT or int(raw_minute) < 0:
return cause + " · 時刻不明"
var minute := int(raw_minute)
return "%s · %d日目 %02d:%02d" % [cause, minute / 1440 + 1, minute % 1440 / 60, minute % 60]
func _set_result(message: String, success: bool) -> void:
result_label.text = message
LayerSkin.register_status_text(result_label, success, LayerSkin.active_layer(self))
status_requested.emit(message, success)
func _label(text_value: String, node_name := "") -> Label:
var label := Label.new()
if not node_name.is_empty(): label.name = node_name
label.text = text_value
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.add_theme_font_size_override("font_size", 13)
label.add_theme_color_override("font_color", Color("55371e"))
LayerSkin.register_text(label, LayerSkin.active_layer(self))
return label
func _option(node_name: String) -> OptionButton:
var option := OptionButton.new()
option.name = node_name
option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
if _style_button.is_valid(): _style_button.call(option)
return option
func _button(text_value: String, node_name: String, callback: Callable) -> Button:
var button := Button.new()
button.name = node_name
button.text = text_value
button.custom_minimum_size.y = 30
button.pressed.connect(callback)
if _style_button.is_valid(): _style_button.call(button)
return button
func configure_audio(submitter: Callable) -> void:
_audio_submitter = submitter
func _submit_ui_command(
command_type: StringName, payload: Dictionary = {}, actor_id: StringName = &"player",
target_id: StringName = &"", priority := 0, execute_at := -1,
idempotency_key: StringName = &""
) -> Variant:
if _audio_submitter.is_valid():
return _audio_submitter.call(command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key)
return _runtime.call(&"submit_command", command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key)