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.
This commit is contained in:
@@ -9,6 +9,8 @@ signal action_requested(action: StringName)
|
||||
|
||||
const KEY_PREFIX := "ui.business."
|
||||
const COMMAND: StringName = &"set_facility_policy"
|
||||
const KITCHEN_KEY_PREFIX := "ui.business.kitchen."
|
||||
const KITCHEN_COMMAND: StringName = &"set_facility_conditions"
|
||||
const GUEST_POLICY_EXPLANATION := "料金変更は新規受付から。受付済みの料金は変わりません。\n休業すると待機中のお客様は別の施設を探します。開始済みのサービスは完了まで続きます。休業中も給与と施設維持費は発生します。"
|
||||
const SUPPLY_POLICY_EXPLANATION := "休業中は調理と新しい補給を止めます。在庫と運搬中の荷物は保持し、積込済みの荷物は納品先の営業状況に従って配送します。消費期限は経過し、給与と施設維持費も発生します。"
|
||||
const SERVICE_ATTRIBUTE_NAMES := {
|
||||
@@ -26,6 +28,9 @@ const REASONS := {
|
||||
}
|
||||
var facility_option: OptionButton
|
||||
var price_option: OptionButton
|
||||
var kitchen_option: OptionButton
|
||||
var kitchen_apply_button: Button
|
||||
var kitchen_status_label: Label
|
||||
var business_open_button: CheckButton
|
||||
var quote_label: Label
|
||||
var effect_label: Label
|
||||
@@ -37,10 +42,13 @@ var result_label: Label
|
||||
var selected_facility_id: StringName = &""
|
||||
|
||||
var _runtime: Variant
|
||||
var _audio_submitter := Callable()
|
||||
var _style_button: Callable
|
||||
var _report: Dictionary = {}
|
||||
var _drafts: Dictionary = {}
|
||||
var _pending_commands: Dictionary = {}
|
||||
var _kitchen_drafts: Dictionary = {}
|
||||
var _pending_kitchen_commands: Dictionary = {}
|
||||
var _queue_instance_id := 0
|
||||
var _sequence := 1
|
||||
var _refreshing := false
|
||||
@@ -78,6 +86,16 @@ func _build_interface() -> void:
|
||||
add_child(facility_option)
|
||||
facility_status_label = _label("", "BusinessFacilityStatus")
|
||||
add_child(facility_status_label)
|
||||
kitchen_status_label = _label("", "BusinessKitchenStatus")
|
||||
add_child(kitchen_status_label)
|
||||
kitchen_option = _option("BusinessKitchenOption")
|
||||
kitchen_option.item_selected.connect(func(index: int) -> void:
|
||||
if not _refreshing and not _has_pending(selected_facility_id):
|
||||
_kitchen_drafts[selected_facility_id] = StringName(kitchen_option.get_item_metadata(index))
|
||||
refresh())
|
||||
add_child(kitchen_option)
|
||||
kitchen_apply_button = _button("関連厨房を設定", "ApplyBusinessKitchenButton", submit_kitchen_link)
|
||||
add_child(kitchen_apply_button)
|
||||
add_child(_label("新規の料金"))
|
||||
price_option = _option("BusinessPriceOption")
|
||||
price_option.item_selected.connect(func(_index: int) -> void: _store_draft())
|
||||
@@ -122,6 +140,8 @@ func refresh() -> void:
|
||||
if queue != null and queue.get_instance_id() != _queue_instance_id:
|
||||
_queue_instance_id = queue.get_instance_id()
|
||||
_drafts.clear()
|
||||
_kitchen_drafts.clear()
|
||||
kitchen_option.get_popup().hide()
|
||||
facility_option.get_popup().hide()
|
||||
price_option.get_popup().hide()
|
||||
_synchronize_commands(queue)
|
||||
@@ -134,6 +154,7 @@ func refresh() -> void:
|
||||
_fill_facilities()
|
||||
var row := _facility_row(selected_facility_id)
|
||||
var draft := _selected_draft(row)
|
||||
_fill_kitchens(row)
|
||||
_fill_modes(StringName(draft.get(&"price_mode", &"standard")))
|
||||
business_open_button.set_pressed_no_signal(bool(draft.get(&"business_open", true)))
|
||||
var pending := _has_pending(selected_facility_id)
|
||||
@@ -159,11 +180,11 @@ func refresh() -> void:
|
||||
effect_label.text += "\n礼拝完了ごとに信仰+%d" % int(row[&"faith_per_service"])
|
||||
today_label.text = "本日の実績はまだありません。"
|
||||
else:
|
||||
facility_status_label.text = "%s · %s\n待ち %d人 / 利用中 %d人\n同時利用上限 %s" % [("営業する設定" if is_supply else "受付を開く設定") if bool(row.get(&"business_open", true)) else ("休業する設定" if is_supply else "新規受付を停止中"), String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中")), int(row.get(&"queued_customer_count", 0)), int(row.get(&"active_customer_count", 0)), ("%d人" % int(row[&"customer_capacity"])) if int(row.get(&"customer_capacity", -1)) >= 0 else "未設定"]
|
||||
facility_status_label.text = "%s · %s\n待ち %d人 / 利用中 %d人\n同時利用上限 %s" % [("営業する設定" if is_supply else "受付を開く設定") if bool(row.get(&"business_open", true)) else ("休業する設定" if is_supply else "新規受付を停止中"), _display_state(row), int(row.get(&"queued_customer_count", 0)), int(row.get(&"active_customer_count", 0)), ("%d人" % int(row[&"customer_capacity"])) if int(row.get(&"customer_capacity", -1)) >= 0 else "未設定"]
|
||||
if is_supply:
|
||||
facility_status_label.text = "%s · %s\n保管・補給の状況は「商品」で確認。" % ["営業する設定" if bool(row.get(&"business_open", true)) else "休業する設定", String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中"))]
|
||||
facility_status_label.text = "%s · %s\n保管・補給の状況は「商品」で確認。" % ["営業する設定" if bool(row.get(&"business_open", true)) else "休業する設定", _display_state(row)]
|
||||
if is_cleaning:
|
||||
facility_status_label.text = "%s · %s\n顧客利用なし・清掃員の業務拠点" % ["清掃業務を有効にする設定" if bool(row.get(&"business_open", true)) else "清掃業務を停止中", String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中"))]
|
||||
facility_status_label.text = "%s · %s\n顧客利用なし・清掃員の業務拠点" % ["清掃業務を有効にする設定" if bool(row.get(&"business_open", true)) else "清掃業務を停止中", _display_state(row)]
|
||||
if int(row.get(&"cleanliness", -1)) >= 0:
|
||||
facility_status_label.text += "\n清潔度 %d / 100" % int(row[&"cleanliness"])
|
||||
if not String(row.get(&"cleaning_status", "")).is_empty():
|
||||
@@ -192,6 +213,115 @@ func refresh() -> void:
|
||||
_refreshing = false
|
||||
|
||||
|
||||
func _display_state(row: Dictionary) -> String:
|
||||
var state_name := StringName(row.get(&"state", &""))
|
||||
var state_text := String(STATE_NAMES.get(state_name, "状態確認中"))
|
||||
var dependency: Dictionary = row.get(&"kitchen_dependency", {})
|
||||
# Preserve the facility's own fault/staff/access/power/closure reason.
|
||||
# The separate kitchen status label shows the dependency at the same time.
|
||||
if bool(row.get(&"business_open", true)) and state_name in [&"OPEN", &"DEGRADED"] and bool(dependency.get(&"required", false)) and not bool(dependency.get(&"operating", false)):
|
||||
return "関連厨房の稼働待ち" if state_name == &"OPEN" else state_text + " · 関連厨房の稼働待ち"
|
||||
return state_text
|
||||
|
||||
|
||||
func _selected_kitchen(row: Dictionary) -> StringName:
|
||||
for payload: Dictionary in _pending_kitchen_commands.values():
|
||||
if StringName(payload.get(&"facility_id", &"")) == selected_facility_id:
|
||||
return StringName(payload.get(&"conditions", {}).get(&"related_facility_id", &""))
|
||||
var dependency: Dictionary = row.get(&"kitchen_dependency", {})
|
||||
return StringName(_kitchen_drafts.get(selected_facility_id, dependency.get(&"related_facility_id", &"")))
|
||||
|
||||
|
||||
func _kitchen_placement(id: StringName) -> Dictionary:
|
||||
if _runtime == null: return {}
|
||||
var facilities: Variant = _runtime.get(&"facility_service")
|
||||
if facilities == null or not facilities.has_method(&"get_kitchen_placement_report"): return {}
|
||||
return facilities.call(&"get_kitchen_placement_report", selected_facility_id, id)
|
||||
|
||||
|
||||
func _fill_kitchens(row: Dictionary) -> void:
|
||||
var dependency: Dictionary = row.get(&"kitchen_dependency", {})
|
||||
var required := bool(dependency.get(&"required", false))
|
||||
kitchen_option.visible = required
|
||||
kitchen_apply_button.visible = required
|
||||
kitchen_status_label.visible = required
|
||||
if not required: return
|
||||
var selected := _selected_kitchen(row)
|
||||
if not kitchen_option.get_popup().visible:
|
||||
kitchen_option.clear()
|
||||
kitchen_option.add_item("未接続(飲食サービスを休止)")
|
||||
kitchen_option.set_item_metadata(0, &"")
|
||||
var selected_index := 0
|
||||
var found := selected.is_empty()
|
||||
for kitchen: Dictionary in _rows(&"kitchens"):
|
||||
var id := StringName(kitchen.get(&"facility_id", &""))
|
||||
var placement := _kitchen_placement(id)
|
||||
var label := _facility_name(kitchen)
|
||||
if StringName(placement.get(&"reason", &"")) == &"INVALID_RELATED_KITCHEN_PLACEMENT":
|
||||
label += "(この飲食店の裏に収まりません)"
|
||||
kitchen_option.add_item(label)
|
||||
var index := kitchen_option.item_count - 1
|
||||
kitchen_option.set_item_metadata(index, id)
|
||||
if id == selected:
|
||||
selected_index = index
|
||||
found = true
|
||||
if not found:
|
||||
kitchen_option.add_item("既存設定 %s(利用できません)" % selected)
|
||||
selected_index = kitchen_option.item_count - 1
|
||||
kitchen_option.set_item_metadata(selected_index, selected)
|
||||
kitchen_option.set_item_disabled(selected_index, true)
|
||||
kitchen_option.select(selected_index)
|
||||
var pending := _has_pending(selected_facility_id)
|
||||
kitchen_option.disabled = pending
|
||||
kitchen_apply_button.disabled = pending or selected == StringName(dependency.get(&"related_facility_id", &""))
|
||||
kitchen_apply_button.text = "設定を受理済み…" if pending else "関連厨房を設定"
|
||||
kitchen_status_label.text = "関連厨房:稼働中" if bool(dependency.get(&"operating", false)) else "表層の飲食店と同じ位置範囲・階に収まる裏層の厨房を1つ選んでください。厨房が稼働するまで飲食サービスは休止します。"
|
||||
if StringName(dependency.get(&"reason", &"")) == &"INVALID_RELATED_KITCHEN_PLACEMENT":
|
||||
kitchen_status_label.text = "関連厨房がこの飲食店の裏に収まっていません。同じ位置範囲・階に収まる裏層の厨房を明示的に選んでください。"
|
||||
if StringName(dependency.get(&"reason", &"")) == &"KITCHEN_SUSPENDED":
|
||||
kitchen_status_label.text = "関連厨房が持続休止中です。別の稼働する厨房を明示的に設定できます。"
|
||||
|
||||
|
||||
func submit_kitchen_link() -> void:
|
||||
var row := _facility_row(selected_facility_id)
|
||||
var dependency: Dictionary = row.get(&"kitchen_dependency", {})
|
||||
if row.is_empty() or not bool(dependency.get(&"required", false)) or _has_pending(selected_facility_id): return
|
||||
var selected := _selected_kitchen(row)
|
||||
if selected == StringName(dependency.get(&"related_facility_id", &"")): return
|
||||
if _runtime == null or not _runtime.has_method(&"submit_command"):
|
||||
_set_result("営業サービスへ接続できません。", false)
|
||||
return
|
||||
var payload := {&"facility_id": selected_facility_id, &"conditions": {&"related_facility_id": selected}}
|
||||
var key := StringName("%s%08d" % [KITCHEN_KEY_PREFIX, _sequence])
|
||||
_sequence += 1
|
||||
var result: Variant = _submit_ui_command(KITCHEN_COMMAND, payload, &"player", selected_facility_id, 75, -1, key)
|
||||
if result == null or not result.has_method(&"is_success"):
|
||||
_set_result("操作に応答がありません。", false)
|
||||
return
|
||||
var queued := false
|
||||
var queue: Variant = _runtime.get(&"command_queue")
|
||||
if queue != null:
|
||||
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_kitchen_commands[int(result.command_id)] = payload.duplicate(true)
|
||||
_set_result("関連厨房の設定を受理しました。次の時間更新で反映します。", true)
|
||||
else:
|
||||
_show_kitchen_result(result, payload)
|
||||
refresh()
|
||||
|
||||
|
||||
func _show_kitchen_result(result: Variant, payload: Dictionary) -> void:
|
||||
if not result.is_success():
|
||||
if StringName(result.details.get(&"reason", &"")) == &"INVALID_RELATED_KITCHEN_PLACEMENT":
|
||||
_set_result("この厨房は飲食店の裏に収まりません。同じ位置範囲・階に収まる裏層の厨房を選び直してください。", false)
|
||||
else:
|
||||
_set_result("関連厨房を設定できません。裏層の実在する厨房を選び直してください。", false)
|
||||
return
|
||||
_kitchen_drafts.erase(StringName(payload.get(&"facility_id", &"")))
|
||||
_set_result("関連厨房の設定を反映しました。", true)
|
||||
|
||||
|
||||
func select_facility(id: StringName) -> void:
|
||||
selected_facility_id = id
|
||||
refresh()
|
||||
@@ -217,7 +347,7 @@ func submit_policy() -> void:
|
||||
var payload := {&"facility_id": selected_facility_id, &"patch": patch}
|
||||
var key := StringName("%s%08d" % [KEY_PREFIX, _sequence])
|
||||
_sequence += 1
|
||||
var result: Variant = _runtime.call(&"submit_command", COMMAND, payload, &"player", selected_facility_id, 75, -1, key)
|
||||
var result: Variant = _submit_ui_command(COMMAND, payload, &"player", selected_facility_id, 75, -1, key)
|
||||
if result == null or not result.has_method(&"is_success"):
|
||||
_set_result("操作に応答がありません。", false)
|
||||
return
|
||||
@@ -271,23 +401,34 @@ func _store_draft() -> void:
|
||||
|
||||
func _synchronize_commands(queue: Variant) -> void:
|
||||
_pending_commands.clear()
|
||||
_pending_kitchen_commands.clear()
|
||||
for metadata: Dictionary in queue.get_accepted_command_metadata():
|
||||
var key := String(metadata.get("idempotency_key", ""))
|
||||
if key.begins_with(KITCHEN_KEY_PREFIX) and key.trim_prefix(KITCHEN_KEY_PREFIX).is_valid_int():
|
||||
_sequence = maxi(_sequence, int(key.trim_prefix(KITCHEN_KEY_PREFIX)) + 1)
|
||||
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", &"")) == COMMAND:
|
||||
_pending_commands[int(command.get("command_id", 0))] = command.get("payload", {}).duplicate(true)
|
||||
elif StringName(command.get("command_type", &"")) == KITCHEN_COMMAND and String(command.get("idempotency_key", "")).begins_with(KITCHEN_KEY_PREFIX):
|
||||
_pending_kitchen_commands[int(command.get("command_id", 0))] = command.get("payload", {}).duplicate(true)
|
||||
|
||||
|
||||
func _has_pending(id: StringName) -> bool:
|
||||
for payload: Dictionary in _pending_commands.values():
|
||||
for payload: Dictionary in _pending_commands.values() + _pending_kitchen_commands.values():
|
||||
if StringName(payload.get(&"facility_id", &"")) == id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _on_command_completed(result: Variant) -> void:
|
||||
if _pending_kitchen_commands.has(int(result.command_id)):
|
||||
var payload: Dictionary = _pending_kitchen_commands[int(result.command_id)]
|
||||
_pending_kitchen_commands.erase(int(result.command_id))
|
||||
_show_kitchen_result(result, payload)
|
||||
refresh()
|
||||
return
|
||||
if not _pending_commands.has(int(result.command_id)):
|
||||
return
|
||||
var payload: Dictionary = _pending_commands[int(result.command_id)]
|
||||
@@ -415,3 +556,17 @@ func _option(node_name: String) -> OptionButton:
|
||||
if _style_button.is_valid():
|
||||
_style_button.call(picker)
|
||||
return picker
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -37,6 +37,7 @@ 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 = {}
|
||||
@@ -148,16 +149,23 @@ func _build_interface() -> void:
|
||||
_style_button.call(button)
|
||||
|
||||
|
||||
func select_raid(raid_id: StringName) -> void:
|
||||
## 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()
|
||||
refresh(select_first_if_empty)
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
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
|
||||
@@ -170,11 +178,19 @@ func refresh() -> void:
|
||||
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)
|
||||
selected_staff_id = _fill_option(staff_option, _report.get(&"staff", []), &"staff_id", selected_staff_id)
|
||||
selected_objective_id = _fill_option(objective_option, _report.get(&"objectives", []), &"facility_id", selected_objective_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"))
|
||||
@@ -182,7 +198,7 @@ func refresh() -> void:
|
||||
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 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 "防衛対象は登録済みです。"]
|
||||
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"])
|
||||
@@ -282,7 +298,7 @@ func _submit(command_type: StringName, patch: Dictionary = {}) -> void:
|
||||
payload[&"raid_id"] = selected_raid_id
|
||||
var key := StringName("%s%08d" % [KEY_PREFIX, _sequence])
|
||||
_sequence += 1
|
||||
var result: Variant = _runtime.call(&"submit_command", command_type, payload, &"player", selected_raid_id, 75, -1, key)
|
||||
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
|
||||
@@ -338,14 +354,14 @@ func _row(key: StringName, id_key: StringName, entity_id: StringName) -> Diction
|
||||
return {}
|
||||
|
||||
|
||||
func _fill_option(option: OptionButton, rows: Array, id_key: StringName, selected: StringName) -> StringName:
|
||||
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 := 0
|
||||
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))
|
||||
@@ -354,7 +370,7 @@ func _fill_option(option: OptionButton, rows: Array, id_key: StringName, selecte
|
||||
option.set_item_metadata(option.item_count - 1, id)
|
||||
if id == selected: index = option.item_count - 1
|
||||
option.select(index)
|
||||
return StringName(option.get_item_metadata(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
|
||||
@@ -416,3 +432,17 @@ func _button(text_value: String, node_name: String, callback: Callable) -> Butto
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
class_name EventLogDialog
|
||||
extends AcceptDialog
|
||||
|
||||
## View the existing retained event stream. No catalog membership, unlock,
|
||||
## replay, acquisition, or serialized state is created by this dialog.
|
||||
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
|
||||
|
||||
const EVENT_NAMES := {
|
||||
&"AUTHORITY_ADVANCE_FAILED": "取締りの進行に失敗",
|
||||
&"AUTHORITY_EVIDENCE_DISCOVERED": "証拠が発見された",
|
||||
&"AUTHORITY_EVIDENCE_REGISTERED": "証拠を登録",
|
||||
&"AUTHORITY_HEAT_CHANGED": "取締りの警戒度が変化",
|
||||
&"AUTHORITY_INSPECTION_EVALUATED": "検査の状況を評価",
|
||||
&"AUTHORITY_INSPECTION_EVALUATION_FAILED": "検査の評価に失敗",
|
||||
&"AUTOSAVE_CHECKPOINT_FAILED": "自動保存に失敗",
|
||||
&"AUTOSAVE_CHECKPOINT_SUCCEEDED": "自動保存が完了",
|
||||
&"COMMAND_COMPLETED": "操作が完了",
|
||||
&"COMMAND_FAILED": "操作に失敗",
|
||||
&"DEFENSE_ADVANCE_FAILED": "防衛の進行に失敗",
|
||||
&"DEFENSE_AUTOMATIC_ROUND_FAILED": "防衛の自動ラウンドに失敗",
|
||||
&"DEFENSE_RAID_CREATED": "襲撃が発生",
|
||||
&"DEFENSE_RAID_RESOLVED": "襲撃の決着",
|
||||
&"DEFENSE_RAID_STATE_CHANGED": "襲撃の状況が変化",
|
||||
&"DEFENSE_ROUND_RESOLVED": "防衛ラウンドの結果",
|
||||
&"DEFENSE_SKILL_APPLIED": "防衛スキルを使用",
|
||||
&"ELEVATOR_ARRIVED": "エレベーターが到着",
|
||||
&"ELEVATOR_BOARDED": "エレベーターに乗車",
|
||||
&"ELEVATOR_COMMUTE_CANCELLED": "エレベーター通勤を中止",
|
||||
&"ELEVATOR_OPERATION_CHANGED": "エレベーターの営業設定を変更",
|
||||
&"ELEVATOR_RESCUE_REQUESTED": "救護のエレベーター搬送を依頼",
|
||||
&"ELEVATOR_TRIP_REQUESTED": "エレベーターの移動を依頼",
|
||||
&"ENTITY_RECOVERY_TELEPORTED": "移動できない対象を復旧",
|
||||
&"EVENT_CANCELLED": "イベントを中止",
|
||||
&"EVENT_CHOICE_RESOLVED": "イベントの選択を処理",
|
||||
&"EVENT_ENQUEUED": "イベントが待機",
|
||||
&"EVENT_FINISHED": "イベントが終了",
|
||||
&"EVENT_STARTED": "イベントが開始",
|
||||
&"EXCAVATION_COMPLETED": "掘削が完了",
|
||||
&"FACILITY_COMPLETED": "施設の建築が完了",
|
||||
&"FACILITY_DEMOLISHED": "施設を撤去",
|
||||
&"FACILITY_INFRASTRUCTURE_PROJECTED": "施設を交通設備に反映",
|
||||
&"FACILITY_STATE_CHANGED": "施設の稼働状態が変化",
|
||||
&"GUEST_AUTO_ARRIVED": "来客が到着",
|
||||
&"GUEST_DECISION_RECORDED": "来客が行き先を検討",
|
||||
&"GUEST_DESPAWNED": "来客が退館",
|
||||
&"GUEST_MOVED": "来客が移動",
|
||||
&"GUEST_QUEUE_ABANDONED": "来客が行列を離脱",
|
||||
&"GUEST_RECOVERY_STARTED": "来客の移動復旧を開始",
|
||||
&"GUEST_SERVICE_SYNC_FAILED": "来客のサービス状態更新に失敗",
|
||||
&"GUEST_SPAWNED": "来客を登録",
|
||||
&"GUEST_STATE_CHANGED": "来客の状態が変化",
|
||||
&"INCIDENT_CREATED": "事件が発生",
|
||||
&"INCIDENT_DETECTOR_ADDED": "事件の発見者を追加",
|
||||
&"INSPECTION_PATH_BLOCKED": "検査員の移動経路が遮断",
|
||||
&"INSPECTION_RESOLVED": "検査の決着",
|
||||
&"INSPECTION_SCHEDULED": "検査が予定された",
|
||||
&"INSPECTION_STARTED": "検査が開始",
|
||||
&"INSPECTION_STATE_CHANGED": "検査の状況が変化",
|
||||
&"INSPECTION_WAYPOINT_INSPECTED": "検査地点の調査が完了",
|
||||
&"INSPECTION_WAYPOINT_REACHED": "検査員が検査地点に到着",
|
||||
&"MANA_STATE_CHANGED": "魔力の供給状態が変化",
|
||||
&"PATH_ACTOR_CROSSLINK_FAILED": "移動状態の整合確認に失敗",
|
||||
&"PATH_ACTOR_STUCK": "移動が滞留",
|
||||
&"RELATIONSHIP_REGISTERED": "人物の関係を登録",
|
||||
&"RELATIONSHIP_ROUTE_LOCK_CHANGED": "人物のルート状態が変化",
|
||||
&"RELATIONSHIP_SHIELD_CHANGED": "人物のシールド値が変化",
|
||||
&"RELATIONSHIP_VALUE_CHANGED": "人物の関係値が変化",
|
||||
&"SAVE_CHECKPOINT_FAILED": "保存に失敗",
|
||||
&"SAVE_CHECKPOINT_SUCCEEDED": "保存が完了",
|
||||
&"SAVE_LOADED": "保存データを読み込み",
|
||||
&"SAVE_LOAD_FAILED": "保存データの読み込みに失敗",
|
||||
&"SAVE_MIGRATION_FAILED": "保存データの移行に失敗",
|
||||
&"SAVE_MIGRATION_SUCCEEDED": "保存データの移行が完了",
|
||||
&"SAVE_RECOVERY_CANDIDATE_LOADED": "選択した復旧データを読み込み",
|
||||
&"SAVE_RECOVERY_FAILED": "保存データの復旧に失敗",
|
||||
&"SAVE_RECOVERY_LOADED": "保存データを復旧",
|
||||
&"SERVICE_COMPLETED": "サービスが完了",
|
||||
&"SERVICE_FAILED": "サービスに失敗",
|
||||
&"SERVICE_INTERRUPTED": "サービスが中断",
|
||||
&"SERVICE_STARTED": "サービスが開始",
|
||||
&"STAFF_CANDIDATE_HIRED": "従業員を雇用",
|
||||
&"STAFF_COLLAPSED": "従業員が倒れた",
|
||||
&"STAFF_COLLAPSE_WITNESSED": "従業員の気絶が目撃された",
|
||||
&"STAFF_MOVED": "従業員が移動",
|
||||
&"STAFF_REGISTERED": "従業員を登録",
|
||||
&"STAFF_REMOVED": "従業員が離脱",
|
||||
&"STAFF_RESCUED": "従業員の救護が完了",
|
||||
&"STAFF_RESCUE_PICKED_UP": "従業員の救護搬送を開始",
|
||||
&"STAFF_SHIFT_REGISTERED": "勤務シフトを登録",
|
||||
&"STAFF_SHIFT_STATE_CHANGED": "勤務シフトの状態が変化",
|
||||
&"STAFF_TASK_ASSIGNED": "仕事を割り当て",
|
||||
&"STAFF_TASK_COMPLETED": "仕事が完了",
|
||||
&"STAFF_TASK_REGISTERED": "仕事を登録",
|
||||
&"STAFF_TASK_REOPENED": "仕事を再募集",
|
||||
&"STAFF_WORK_ASSIGNMENT_CHANGED": "従業員の配属を変更",
|
||||
&"WORLD_TREE_RANK_ADVANCED": "世界樹のランクが上昇",
|
||||
}
|
||||
|
||||
var _runtime: Variant
|
||||
var _event_list: ItemList
|
||||
var _details: Label
|
||||
var _details_scroll: ScrollContainer
|
||||
var _empty_label: Label
|
||||
var _rows: Array[Dictionary] = []
|
||||
var _selected_event_id := ""
|
||||
var _skin_styles: Array[StyleBoxFlat] = []
|
||||
|
||||
|
||||
func configure(runtime: Variant) -> void:
|
||||
_runtime = runtime
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
name = "EventLogDialog"
|
||||
title = "最近のイベント"
|
||||
get_ok_button().text = "閉じる"
|
||||
var column := VBoxContainer.new()
|
||||
column.custom_minimum_size = Vector2(440, 300)
|
||||
add_child(column)
|
||||
_empty_label = Label.new()
|
||||
_empty_label.name = "EventLogEmptyLabel"
|
||||
_empty_label.text = "イベントの記録はまだありません。"
|
||||
column.add_child(_empty_label)
|
||||
_event_list = ItemList.new()
|
||||
_event_list.name = "EventLogList"
|
||||
_event_list.custom_minimum_size.y = 150
|
||||
_event_list.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
_event_list.item_selected.connect(_select_row)
|
||||
column.add_child(_event_list)
|
||||
# The scroll viewport owns the fixed detail height; a wrapped Label which
|
||||
# has not yet been laid out cannot inflate the first popup minimum height.
|
||||
_details_scroll = ScrollContainer.new()
|
||||
_details_scroll.name = "EventLogDetailsScroll"
|
||||
_details_scroll.custom_minimum_size.y = 90
|
||||
_details_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
column.add_child(_details_scroll)
|
||||
_details = Label.new()
|
||||
_details.name = "EventLogDetails"
|
||||
_details.custom_minimum_size.x = 420
|
||||
_details.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_details.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_details_scroll.add_child(_details)
|
||||
_build_skin()
|
||||
apply_layer_skin(LayerSkin.active_layer(self))
|
||||
|
||||
|
||||
func show_events() -> void:
|
||||
refresh()
|
||||
popup_centered(Vector2i(540, 400))
|
||||
|
||||
|
||||
## Full defensive dictionaries are returned for ownership/restore checks.
|
||||
## Publication order and every existing field/type are preserved.
|
||||
func get_event_rows() -> Array[Dictionary]:
|
||||
var rows: Array[Dictionary] = []
|
||||
if not is_instance_valid(_runtime):
|
||||
return rows
|
||||
var stream: Variant = _runtime.get("domain_events")
|
||||
if stream == null or not stream.has_method("recent_events"):
|
||||
return rows
|
||||
for event: SimulationDomainEvent in stream.recent_events():
|
||||
rows.append(event.to_dictionary())
|
||||
return rows
|
||||
|
||||
|
||||
func get_selected_event() -> Dictionary:
|
||||
for row: Dictionary in _rows:
|
||||
if String(row["event_id"]) == _selected_event_id:
|
||||
return row.duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if _event_list == null:
|
||||
return
|
||||
_rows = get_event_rows()
|
||||
_event_list.clear()
|
||||
var selected_index := -1
|
||||
for index: int in range(_rows.size()):
|
||||
var row: Dictionary = _rows[index]
|
||||
var presentation := get_event_presentation(row)
|
||||
_event_list.add_item("%s %s — %s" % [presentation[&"time"], presentation[&"title"], presentation[&"targets"]])
|
||||
_event_list.set_item_tooltip(index, presentation[&"supplement"])
|
||||
_event_list.set_item_metadata(index, String(row["event_id"]))
|
||||
if String(row["event_id"]) == _selected_event_id:
|
||||
selected_index = index
|
||||
_empty_label.visible = _rows.is_empty()
|
||||
if selected_index < 0 and not _rows.is_empty():
|
||||
selected_index = 0
|
||||
if selected_index >= 0:
|
||||
_event_list.select(selected_index)
|
||||
_select_row(selected_index)
|
||||
else:
|
||||
_selected_event_id = ""
|
||||
_details.text = ""
|
||||
|
||||
|
||||
func _select_row(index: int) -> void:
|
||||
if index < 0 or index >= _rows.size():
|
||||
return
|
||||
var row: Dictionary = _rows[index]
|
||||
_selected_event_id = String(row["event_id"])
|
||||
var presentation := get_event_presentation(row)
|
||||
_details.text = "%s\n%s\n対象(現在の名称):%s" % [
|
||||
presentation[&"time"], presentation[&"title"], presentation[&"targets"],
|
||||
]
|
||||
_details.tooltip_text = presentation[&"supplement"]
|
||||
|
||||
|
||||
|
||||
## This is presentation of existing notification facts, not a story catalog.
|
||||
## Unknown kinds/targets remain readable placeholders; raw metadata is only a
|
||||
## tooltip supplement. Names are resolved from current validated definitions.
|
||||
func get_event_presentation(row: Dictionary) -> Dictionary:
|
||||
var names: Array[String] = []
|
||||
var raw_ids: Array[String] = []
|
||||
var unknown_count := 0
|
||||
for raw_id: Variant in row.get("entity_ids", []):
|
||||
var entity_id := String(raw_id)
|
||||
raw_ids.append(entity_id)
|
||||
var resolved := _entity_name(StringName(entity_id))
|
||||
if resolved.is_empty():
|
||||
unknown_count += 1
|
||||
else:
|
||||
names.append(resolved)
|
||||
if unknown_count > 0:
|
||||
names.append("名称を確認できない対象(%d件)" % unknown_count)
|
||||
return {
|
||||
&"title": String(EVENT_NAMES.get(StringName(row.get("event_type", "")), "未識別の記録")),
|
||||
&"time": _format_minute(int(row.get("absolute_minute", 0))),
|
||||
&"targets": "、".join(names) if not names.is_empty() else "対象の指定なし",
|
||||
&"supplement": "補足情報\n記録:%s\n種別:%s\n対象:%s\n発行元:%s" % [
|
||||
row.get("event_id", ""), row.get("event_type", ""),
|
||||
"、".join(raw_ids), row.get("source_service", ""),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
func _entity_name(entity_id: StringName) -> String:
|
||||
if not is_instance_valid(_runtime):
|
||||
return ""
|
||||
var catalog: Variant = _runtime.get("mvp_content_catalog")
|
||||
if catalog != null:
|
||||
for content_type: StringName in [&"staff_candidates", &"unique_characters", &"guest_classes", &"species"]:
|
||||
var entry: Dictionary = catalog.get_entry(content_type, entity_id)
|
||||
if not entry.is_empty():
|
||||
return String(entry.get(&"display_name", ""))
|
||||
var facilities: Variant = _runtime.get("facility_service")
|
||||
var definitions: Variant = _runtime.get("definition_registry")
|
||||
if facilities != null and definitions != null:
|
||||
var facility: Variant = facilities.get_instance(entity_id)
|
||||
if facility != null:
|
||||
var instance_definition: Variant = definitions.get_facility(facility.definition_id)
|
||||
if instance_definition != null:
|
||||
var floor_name := "%dF" % (facility.origin.y + 1) if facility.origin.y >= 0 else "B%d" % -facility.origin.y
|
||||
return "%s %s・%s" % [instance_definition.display_name, floor_name, "客用" if facility.origin.z == 0 else "裏方"]
|
||||
var registered_definition: Variant = definitions.get_facility(entity_id)
|
||||
if registered_definition != null:
|
||||
return String(registered_definition.display_name)
|
||||
var guests: Variant = _runtime.get("guest_service")
|
||||
if guests != null and guests.get_guest(entity_id) != null:
|
||||
return "来客"
|
||||
return ""
|
||||
|
||||
func _format_minute(minute: int) -> String:
|
||||
return "%d日目 %02d:%02d" % [minute / 1440 + 1, (minute % 1440) / 60, minute % 60]
|
||||
|
||||
|
||||
func _build_skin() -> void:
|
||||
theme = Theme.new()
|
||||
theme.set_stylebox("panel", "AcceptDialog", _skin_box(Color("f4e3bd"), &"panel_parchment"))
|
||||
var border := _skin_box(Color("49311f"), &"panel_outer")
|
||||
border.expand_margin_top = 32
|
||||
theme.set_stylebox("embedded_border", "Window", border)
|
||||
theme.set_color("title_color", "Window", Color("fff0cf"))
|
||||
for label: Label in [_empty_label, _details]:
|
||||
label.add_theme_color_override("font_color", Color("55371e"))
|
||||
LayerSkin.register_text(label, 0)
|
||||
_event_list.add_theme_stylebox_override("panel", _skin_box(Color("f4e3bd"), &"panel_parchment"))
|
||||
# Reuse the existing dark button palette with light selected text.
|
||||
for key: StringName in [&"selected", &"selected_focus"]:
|
||||
_event_list.add_theme_stylebox_override(key, _skin_box(Color("65452d"), &"button_pressed"))
|
||||
for key: StringName in [&"normal", &"hover", &"pressed", &"disabled", &"focus"]:
|
||||
var role: StringName = &"button_normal"
|
||||
if key in [&"hover", &"focus"]:
|
||||
role = &"button_hover"
|
||||
elif key == &"pressed":
|
||||
role = &"button_pressed"
|
||||
elif key == &"disabled":
|
||||
role = &"button_disabled"
|
||||
get_ok_button().add_theme_stylebox_override(key, _skin_box(Color("65452d"), role))
|
||||
LayerSkin.register_toggle_text(get_ok_button(), 0)
|
||||
|
||||
|
||||
func _skin_box(color: Color, role: StringName) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = color
|
||||
style.border_color = Color("a96f2f")
|
||||
style.set_border_width_all(2)
|
||||
style.set_content_margin_all(10)
|
||||
LayerSkin.register_style(style, role, 0)
|
||||
_skin_styles.append(style)
|
||||
return style
|
||||
|
||||
|
||||
func apply_layer_skin(layer: int) -> void:
|
||||
for style: StyleBoxFlat in _skin_styles:
|
||||
LayerSkin.apply_style(style, layer)
|
||||
for label: Label in [_empty_label, _details]:
|
||||
if label != null:
|
||||
LayerSkin.register_text(label, layer)
|
||||
if _event_list != null:
|
||||
_event_list.add_theme_color_override("font_color", Color("d8eeea") if layer == 1 else Color("55371e"))
|
||||
_event_list.add_theme_color_override("font_selected_color", Color("fff0cf"))
|
||||
LayerSkin.register_toggle_text(get_ok_button(), layer)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ddbo74xrnvpxd
|
||||
@@ -66,7 +66,14 @@ func _process(delta: float) -> void:
|
||||
_elapsed = 0
|
||||
refresh()
|
||||
|
||||
func refresh() -> void:
|
||||
## Clear optional saved presentation while rebuilding the actual loaded roster.
|
||||
## Opening or polling the visible observer retains the normal first-row choice.
|
||||
func clear_selection() -> void:
|
||||
selected_guest_id = &""
|
||||
_elapsed = 0.0
|
||||
refresh(false)
|
||||
|
||||
func refresh(select_first_if_empty: bool = true) -> void:
|
||||
if guests == null or _runtime == null:
|
||||
return
|
||||
var service: Variant = _runtime.get(&"guest_service")
|
||||
@@ -80,7 +87,7 @@ func refresh() -> void:
|
||||
for id: StringName in _ids:
|
||||
guests.add_item(String(id))
|
||||
guests.set_item_metadata(guests.item_count-1, id)
|
||||
if not _ids.has(selected_guest_id):
|
||||
if not _ids.has(selected_guest_id) and (not selected_guest_id.is_empty() or select_first_if_empty):
|
||||
selected_guest_id = _ids[0] if not _ids.is_empty() else &""
|
||||
guests.disabled = _ids.is_empty()
|
||||
if not _ids.is_empty(): guests.select(_ids.find(selected_guest_id))
|
||||
@@ -88,7 +95,7 @@ func refresh() -> void:
|
||||
for need: StringName in bars:
|
||||
bars[need].value = report.get(&"needs", {}).get(need, 0)
|
||||
if report.is_empty():
|
||||
details.text = "現在、来客はいません。"
|
||||
details.text = "現在、来客はいません。" if _ids.is_empty() else "来客を選択してください。"
|
||||
priority_label.text = ""
|
||||
decision_label.text = ""
|
||||
return
|
||||
|
||||
@@ -15,4 +15,4 @@ func _draw() -> void:
|
||||
if bool(room.get(&"legacy_stair", false)):
|
||||
PixelArt.stairs(self,room[&"rect"],room[&"rect"].size.x/14.0,true,true)
|
||||
continue
|
||||
PixelArt.room(self,room[&"rect"],room[&"definition_id"],pixel_scale,dungeon,town,true,room[&"origin_x"],{})
|
||||
PixelArt.room(self,room[&"rect"],room[&"definition_id"],pixel_scale,dungeon,town,true,room[&"origin_x"],{},int(room.get(&"level",1)))
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
extends RefCounted
|
||||
|
||||
## Medical ward furnishings at the established 16px scale. Cosmetic levels
|
||||
## describe the existing FacilityInstance.level; no treatment or upgrade rule.
|
||||
const Layout := preload("res://scripts/ui/room_presentation.gd")
|
||||
const INK := Color("34413b")
|
||||
const WOOD := Color("8e6348")
|
||||
const WOOD_LIGHT := Color("c39665")
|
||||
const LINEN := Color("eee5c9")
|
||||
|
||||
static func draw_room(canvas: CanvasItem, rect: Rect2, p: float, back: bool, level: int, dungeon: Texture2D) -> void:
|
||||
var tier := clampi(level, 1, 3)
|
||||
var frame := Color("8fa5a2") if back else WOOD_LIGHT
|
||||
var cover := Color("729e96") if back else Color("86b3a8")
|
||||
var wall := Color("acb9a5") if back else Color("d4c7a5")
|
||||
# Medicine occupies only the upper 13px. All established patient anchors
|
||||
# remain available, including the rotated 2x4 footprint and narrow zooms.
|
||||
canvas.draw_rect(Rect2(rect.position+Vector2(1,2)*p, Vector2(rect.size.x-2*p,12*p)),wall)
|
||||
var cabinet_width := maxf(6, minf(20, floorf((rect.size.x/p-6)/3)))
|
||||
for side: int in 2:
|
||||
var x := rect.position.x+2*p if side==0 else rect.end.x-(cabinet_width+2)*p
|
||||
canvas.draw_rect(Rect2(x,rect.position.y+2*p,cabinet_width*p,11*p),INK)
|
||||
canvas.draw_rect(Rect2(x+p,rect.position.y+3*p,(cabinet_width-2)*p,9*p),frame)
|
||||
canvas.draw_rect(Rect2(x+2*p,rect.position.y+4*p,(cabinet_width-4)*p,5*p),Color("577e77"))
|
||||
canvas.draw_rect(Rect2(x+p,rect.position.y+9*p,(cabinet_width-2)*p,p),LINEN)
|
||||
if dungeon != null:
|
||||
# Existing Tiny Dungeon CC0 blue/green bottles, exact packed indices.
|
||||
var index := 113+side
|
||||
canvas.draw_texture_rect_region(dungeon,Rect2(x+(cabinet_width-8)*p/2,rect.position.y+3*p,8*p,8*p),Rect2((index%12)*16,(index/12)*16,16,16))
|
||||
if tier >= 2:
|
||||
canvas.draw_rect(Rect2(x+2*p,rect.position.y+10*p,(cabinet_width-4)*p,p),WOOD)
|
||||
canvas.draw_rect(Rect2(x+cabinet_width*p/2,rect.position.y+10*p,p,p),LINEN)
|
||||
if tier >= 3:
|
||||
canvas.draw_rect(Rect2(x,rect.position.y+2*p,cabinet_width*p,p),Color("d6ad52"))
|
||||
canvas.draw_rect(Rect2(x+2*p,rect.position.y+5*p,p,3*p),Color("c3d1c5"))
|
||||
var mid := rect.get_center().x
|
||||
canvas.draw_rect(Rect2(mid-3*p,rect.position.y+3*p,6*p,9*p),INK)
|
||||
canvas.draw_rect(Rect2(mid-2*p,rect.position.y+4*p,4*p,7*p),LINEN)
|
||||
canvas.draw_rect(Rect2(mid,rect.position.y+6*p,p,4*p),Color("52836c"))
|
||||
canvas.draw_rect(Rect2(mid-p,rect.position.y+5*p,3*p,2*p),cover)
|
||||
if tier >= 3:
|
||||
canvas.draw_rect(Rect2(mid-3*p,rect.position.y+3*p,6*p,p),Color("d6ad52"))
|
||||
for anchor: Vector2 in Layout.slots(rect,p,&"infirmary"):
|
||||
var at := anchor+Vector2(-7,-9)*p
|
||||
canvas.draw_rect(Rect2(at+Vector2(0,2)*p,Vector2(13,7)*p),INK)
|
||||
canvas.draw_rect(Rect2(at+Vector2(1,2)*p,Vector2(11,4)*p),LINEN)
|
||||
canvas.draw_rect(Rect2(at+Vector2(5,2)*p,Vector2(7,5)*p),cover)
|
||||
canvas.draw_rect(Rect2(at,Vector2(1,10)*p),frame)
|
||||
canvas.draw_rect(Rect2(at+Vector2(12,5)*p,Vector2(1,5)*p),frame)
|
||||
canvas.draw_rect(Rect2(at+Vector2(2,2)*p,Vector2(3,2)*p),Color("fff0d6"))
|
||||
if tier >= 2:
|
||||
canvas.draw_rect(Rect2(at+Vector2(5,4)*p,Vector2(7,1)*p),LINEN)
|
||||
if tier >= 3:
|
||||
canvas.draw_rect(Rect2(at+Vector2(6,6)*p,Vector2(6,1)*p),Color("c3d1c5"))
|
||||
@@ -0,0 +1 @@
|
||||
uid://dc4xv2kx0ah3u
|
||||
@@ -0,0 +1,144 @@
|
||||
extends ConfirmationDialog
|
||||
|
||||
## Presentation only: listing never reconciles, migrates or deletes packages.
|
||||
## New package names use OS crypto, independently of every simulation RNG.
|
||||
signal new_slot_requested(path: String, display_name: String)
|
||||
signal load_slot_requested(path: String)
|
||||
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
|
||||
const OWNED_NAME_PATTERN := "^manual_ui_[0-9a-f]{32}$"
|
||||
var _directory := ""
|
||||
var _mode := ""
|
||||
var _new_path := ""
|
||||
var _new_name := ""
|
||||
var _message: Label
|
||||
var _slots: ItemList
|
||||
var _rows: Array[Dictionary] = []
|
||||
|
||||
func _ready() -> void:
|
||||
name = "ManualSaveSlotsDialog"
|
||||
cancel_button_text = "戻る"
|
||||
var column := VBoxContainer.new()
|
||||
column.custom_minimum_size = Vector2(460, 220)
|
||||
add_child(column)
|
||||
_message = Label.new()
|
||||
_message.name = "ManualSaveSlotsMessage"
|
||||
_message.custom_minimum_size.x = 460
|
||||
_message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
column.add_child(_message)
|
||||
_slots = ItemList.new()
|
||||
_slots.name = "ManualSaveSlotsList"
|
||||
_slots.custom_minimum_size = Vector2(460, 150)
|
||||
_slots.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
_slots.item_selected.connect(_select_slot)
|
||||
column.add_child(_slots)
|
||||
confirmed.connect(_confirm_choice)
|
||||
canceled.connect(_clear_choice)
|
||||
close_requested.connect(_clear_choice)
|
||||
apply_layer_skin(LayerSkin.active_layer(self))
|
||||
|
||||
func show_new_save(current_path: String) -> void:
|
||||
_clear_choice()
|
||||
_mode = "new"
|
||||
_directory = current_path.get_base_dir()
|
||||
title = "旧保存を残して新しく保存"
|
||||
ok_button_text = "新しく保存"
|
||||
_slots.hide()
|
||||
# No directory is created until the player explicitly confirms.
|
||||
var token: PackedByteArray = Crypto.new().generate_random_bytes(16)
|
||||
if token.size() == 16:
|
||||
_new_path = _directory.path_join("manual_ui_" + token.hex_encode())
|
||||
_new_name = "手動保存 " + Time.get_datetime_string_from_system(false, true)
|
||||
var fresh: bool = not _new_path.is_empty() and is_fresh_slot(_new_path)
|
||||
get_ok_button().disabled = not fresh
|
||||
_message.text = "今の進行は、元の保存とは別に保存します。\n元の保存は残り、あとから読込一覧で選べます。\n\n" + _new_name if fresh else "新しい保存先を用意できませんでした。\n元の保存は変更していません。"
|
||||
popup_centered(Vector2i(520, 300))
|
||||
get_cancel_button().grab_focus()
|
||||
|
||||
func show_load_slots(current_path: String) -> void:
|
||||
_clear_choice()
|
||||
_mode = "load"
|
||||
_directory = current_path.get_base_dir()
|
||||
title = "保存スロットを選んで読込"
|
||||
ok_button_text = "選んで読込"
|
||||
_message.text = "読み込む保存を選んでください。\n現在の未保存の進行は、選んだ保存で置き換わります。"
|
||||
_slots.show()
|
||||
_slots.clear()
|
||||
_rows = read_slot_rows(_directory)
|
||||
var selection := -1
|
||||
for index: int in range(_rows.size()):
|
||||
var row: Dictionary = _rows[index]
|
||||
_slots.add_item(row[&"label"])
|
||||
_slots.set_item_disabled(index, not row[&"loadable"])
|
||||
if row[&"path"] == current_path and row[&"loadable"]:
|
||||
selection = index
|
||||
if selection < 0:
|
||||
for index: int in range(_rows.size()):
|
||||
if _rows[index][&"loadable"]:
|
||||
selection = index
|
||||
break
|
||||
get_ok_button().disabled = selection < 0
|
||||
if selection >= 0:
|
||||
_slots.select(selection)
|
||||
else:
|
||||
_message.text = "読み込める保存が見つかりません。\n保存データは変更していません。"
|
||||
popup_centered(Vector2i(520, 360))
|
||||
if selection >= 0: _slots.grab_focus()
|
||||
else: get_cancel_button().grab_focus()
|
||||
|
||||
static func is_owned_slot_name(value: String) -> bool:
|
||||
if value == "manual_ui": return true
|
||||
var pattern := RegEx.new()
|
||||
return pattern.compile(OWNED_NAME_PATTERN) == OK and pattern.search(value) != null
|
||||
|
||||
static func is_fresh_slot(path: String) -> bool:
|
||||
if not path.begins_with("user://") or not is_owned_slot_name(path.get_file()) or path.get_file() == "manual_ui": return false
|
||||
# Reject any existing package, transaction, recovery family, file or link.
|
||||
for suffix: String in ["", ".tmp", ".bak", SaveService.MIGRATION_BACKUP_SUFFIX, SaveService.SAVE_TRANSACTION_SUFFIX]:
|
||||
var absolute: String = ProjectSettings.globalize_path(path + suffix)
|
||||
var parent: DirAccess = DirAccess.open(absolute.get_base_dir())
|
||||
if FileAccess.file_exists(absolute) or DirAccess.dir_exists_absolute(absolute) or (parent != null and parent.is_link(absolute.get_file())): return false
|
||||
return true
|
||||
|
||||
static func read_slot_rows(directory: String) -> Array[Dictionary]:
|
||||
var rows: Array[Dictionary] = []
|
||||
if not directory.begins_with("user://"): return rows
|
||||
var source: DirAccess = DirAccess.open(directory)
|
||||
if source == null: return rows
|
||||
var names: PackedStringArray = source.get_directories()
|
||||
names.sort()
|
||||
for slot: String in names:
|
||||
if not is_owned_slot_name(slot) or source.is_link(slot): continue
|
||||
var path: String = directory.path_join(slot)
|
||||
# Manifest text alone is never authority for a loadable package.
|
||||
var inspected: Dictionary = SaveService.inspect_snapshot(path)
|
||||
var loadable: bool = bool(inspected.get(&"ok", false))
|
||||
var label: String = "手動保存(最初のスロット)" if slot == "manual_ui" else "手動保存"
|
||||
if loadable:
|
||||
label = String(inspected.get(&"manifest", {}).get("display_name", label))
|
||||
else:
|
||||
label += " — 読込不可(保存は残っています)"
|
||||
label = "%d. %s" % [rows.size() + 1, label]
|
||||
rows.append({&"path": path, &"label": label, &"loadable": loadable})
|
||||
return rows
|
||||
|
||||
func _select_slot(index: int) -> void:
|
||||
get_ok_button().disabled = index < 0 or index >= _rows.size() or not _rows[index][&"loadable"]
|
||||
|
||||
func _confirm_choice() -> void:
|
||||
if _mode == "new" and is_fresh_slot(_new_path):
|
||||
new_slot_requested.emit(_new_path, _new_name)
|
||||
elif _mode == "load":
|
||||
var selected: PackedInt32Array = _slots.get_selected_items()
|
||||
if selected.size() == 1 and selected[0] >= 0 and selected[0] < _rows.size() and _rows[selected[0]][&"loadable"]:
|
||||
load_slot_requested.emit(_rows[selected[0]][&"path"])
|
||||
_clear_choice()
|
||||
|
||||
func _clear_choice() -> void:
|
||||
_mode = ""
|
||||
_new_path = ""
|
||||
_new_name = ""
|
||||
_rows.clear()
|
||||
|
||||
func apply_layer_skin(layer: int) -> void:
|
||||
if _message != null: LayerSkin.register_text(_message, layer)
|
||||
LayerSkin.apply_tree(self, layer)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bkhs10xbqj03b
|
||||
@@ -3,6 +3,8 @@ extends VBoxContainer
|
||||
|
||||
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
|
||||
|
||||
signal status_requested(message: String, success: bool)
|
||||
|
||||
## Projection only: authoritative close and ledger entries own all amounts.
|
||||
var _initial_layer := 0
|
||||
var _runtime: Variant
|
||||
@@ -13,6 +15,18 @@ var _closed_label: Label
|
||||
var _facility_label: Label
|
||||
var _selected_day := -1
|
||||
var _picker_days: Array[int] = []
|
||||
var loan_option: OptionButton
|
||||
var loan_amount: LineEdit
|
||||
var repay_button: Button
|
||||
var repay_all_button: Button
|
||||
var _loan_box: VBoxContainer
|
||||
var _loan_hint: Label
|
||||
var _loan_result: Label
|
||||
var _selected_loan: StringName = &""
|
||||
var _loan_pending_command := 0
|
||||
var _loan_queue_instance := 0
|
||||
var _audio_submitter := Callable()
|
||||
|
||||
|
||||
|
||||
func setup(runtime: Variant, clock: Variant, style_button := Callable(), layer := 0) -> void:
|
||||
@@ -27,6 +41,7 @@ func setup(runtime: Variant, clock: Variant, style_button := Callable(), layer :
|
||||
LayerSkin.register_text(heading, _initial_layer)
|
||||
add_child(heading)
|
||||
_cash_label = _label()
|
||||
_build_repayment_controls(style_button)
|
||||
_day_picker = OptionButton.new()
|
||||
_day_picker.name = "LedgerDayPicker"
|
||||
if style_button.is_valid(): style_button.call(_day_picker)
|
||||
@@ -36,6 +51,8 @@ func setup(runtime: Variant, clock: Variant, style_button := Callable(), layer :
|
||||
add_child(_day_picker)
|
||||
_closed_label = _label()
|
||||
_facility_label = _label()
|
||||
if _runtime != null and _runtime.has_signal(&"command_completed"):
|
||||
_runtime.connect(&"command_completed", _on_repayment_completed)
|
||||
refresh()
|
||||
|
||||
|
||||
@@ -97,6 +114,7 @@ func refresh() -> void:
|
||||
return
|
||||
if _day_picker.get_popup().visible:
|
||||
return
|
||||
_refresh_repayment_controls()
|
||||
var report := get_report()
|
||||
_cash_label.text = "第%d営業日 · 手持ち %dG\n本日の入金 +%dG / 出金 −%dG\nうち建築 %dG / 給与支払 %dG / 維持費支払 %dG\n未払い維持費 %dG\n資金増減 %+dG(営業利益は下の決算)" % [report[&"business_day"], report[&"cash"], report[&"cash_in"], report[&"cash_out"], report[&"construction_cash_out"], report[&"wage_cash_out"], report[&"upkeep_cash_out"], report[&"upkeep_arrears"], report[&"cash_net"]]
|
||||
var summary: Dictionary = report[&"selected_close"]
|
||||
@@ -124,7 +142,11 @@ func refresh() -> void:
|
||||
var other := 0
|
||||
for key: StringName in [&"maintenance", &"utilities", &"taxes_and_interest", &"fines"]:
|
||||
other += int(totals.get(key, 0))
|
||||
_closed_label.text = "売上 %dG / 返金 %dG\n運営費(仕入れ・契約金等) %dG\n給与 %dG / その他 %dG\n営業利益 %+dG\n前日比 %+dG / 未払給与 %dG\n帳簿照合:%s" % [totals.get(&"revenue", 0), totals.get(&"refunds", 0), totals.get(&"cost_of_goods", 0), totals.get(&"wages", 0), other, summary.get(&"net_profit", 0), summary.get(&"previous_day_delta", 0), summary.get(&"arrears_total", 0), "一致" if bool(summary.get(&"balanced", false)) else "差額あり"]
|
||||
_closed_label.text = "売上 %dG / 返金 %dG\n運営費(仕入れ・契約金等) %dG\n給与 %dG / その他 %dG\n日次利益 %+dG\n前日比 %+dG / 未払給与 %dG\n帳簿照合:%s" % [totals.get(&"revenue", 0), totals.get(&"refunds", 0), totals.get(&"cost_of_goods", 0), totals.get(&"wages", 0), other, summary.get(&"net_profit", 0), summary.get(&"previous_day_delta", 0), summary.get(&"arrears_total", 0), "一致" if bool(summary.get(&"balanced", false)) else "差額あり"]
|
||||
if totals.has(&"financing_principal_inflows") or totals.has(&"financing_principal_outflows"):
|
||||
_closed_label.text += "\n借入元本の入金 %dG(売上外) / 元本返済 %dG(決算経費)" % [int(totals.get(&"financing_principal_inflows", 0)), int(totals.get(&"financing_principal_outflows", 0))]
|
||||
if _runtime.operations_coordinator.is_legacy_principal_profit_history(int(summary[&"day"])):
|
||||
_closed_label.text += "\n旧版の確定履歴:元本返済を利益から除外した計算を保持"
|
||||
var legacy: bool = _runtime.operations_coordinator.is_legacy_financial_history(int(summary[&"day"]))
|
||||
_closed_label.text += "\n%s %d回%s" % ["旧版の連続未払い" if legacy else "連続赤字", int(summary.get(&"consecutive_insolvency_count", 0)), "" if legacy else "(3回でゲーム終了)"]
|
||||
var close_minute := int(summary[&"closed_at"]) % 1440
|
||||
@@ -160,3 +182,154 @@ func _label() -> Label:
|
||||
label.add_theme_font_size_override("font_size", 13)
|
||||
add_child(label)
|
||||
return label
|
||||
|
||||
|
||||
func configure_audio(submitter: Callable) -> void:
|
||||
_audio_submitter = submitter
|
||||
|
||||
|
||||
func _build_repayment_controls(style_button: Callable) -> void:
|
||||
_loan_hint = _label()
|
||||
_loan_hint.name = "LoanRepaymentHint"
|
||||
_loan_box = VBoxContainer.new()
|
||||
_loan_box.name = "LoanRepaymentControls"
|
||||
_loan_box.add_theme_constant_override("separation", 6)
|
||||
add_child(_loan_box)
|
||||
loan_option = OptionButton.new()
|
||||
loan_option.name = "LoanRepaymentOption"
|
||||
if style_button.is_valid(): style_button.call(loan_option)
|
||||
_loan_box.add_child(loan_option)
|
||||
loan_option.item_selected.connect(func(index: int) -> void:
|
||||
_selected_loan = StringName(loan_option.get_item_metadata(index))
|
||||
loan_amount.text = ""
|
||||
_refresh_repayment_controls())
|
||||
loan_amount = LineEdit.new()
|
||||
loan_amount.name = "LoanRepaymentAmount"
|
||||
loan_amount.placeholder_text = "返済する元本(整数G)"
|
||||
loan_amount.tooltip_text = "残元本と手持ちを超えない整数額を入力"
|
||||
_loan_box.add_child(loan_amount)
|
||||
loan_amount.text_changed.connect(func(_text: String) -> void: _refresh_repayment_controls())
|
||||
loan_amount.text_submitted.connect(func(_text: String) -> void: submit_repayment(false))
|
||||
repay_button = Button.new()
|
||||
repay_button.name = "LoanRepaymentButton"
|
||||
repay_button.text = "指定額を返済"
|
||||
if style_button.is_valid(): style_button.call(repay_button)
|
||||
_loan_box.add_child(repay_button)
|
||||
repay_button.pressed.connect(submit_repayment.bind(false))
|
||||
repay_all_button = Button.new()
|
||||
repay_all_button.name = "LoanRepayAllButton"
|
||||
repay_all_button.text = "元本を完済"
|
||||
if style_button.is_valid(): style_button.call(repay_all_button)
|
||||
_loan_box.add_child(repay_all_button)
|
||||
repay_all_button.pressed.connect(submit_repayment.bind(true))
|
||||
_loan_result = _label()
|
||||
_loan_result.name = "LoanRepaymentResult"
|
||||
|
||||
|
||||
func _loan_report() -> Dictionary:
|
||||
if _runtime == null or not _runtime.has_method(&"get_loan_principal_report"): return {}
|
||||
return _runtime.call(&"get_loan_principal_report")
|
||||
|
||||
|
||||
func _loan_row(report: Dictionary) -> Dictionary:
|
||||
for row: Dictionary in report.get(&"loans", []):
|
||||
if StringName(row[&"loan_id"]) == _selected_loan: return row
|
||||
return {}
|
||||
|
||||
|
||||
func _refresh_repayment_controls() -> void:
|
||||
if loan_option == null: return
|
||||
var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null
|
||||
var instance: int = queue.get_instance_id() if queue != null else 0
|
||||
if instance != _loan_queue_instance:
|
||||
_loan_queue_instance = instance
|
||||
_loan_pending_command = 0
|
||||
_loan_result.text = ""
|
||||
loan_amount.text = ""
|
||||
var report := _loan_report()
|
||||
var loans: Array = report.get(&"loans", [])
|
||||
_loan_box.visible = not loans.is_empty()
|
||||
if not loan_option.get_popup().visible:
|
||||
loan_option.clear()
|
||||
var selection := -1
|
||||
for index: int in loans.size():
|
||||
var row: Dictionary = loans[index]
|
||||
var remaining := int(row[&"outstanding_principal"])
|
||||
loan_option.add_item("借入 %d · %s" % [index + 1, "完済" if remaining == 0 else "残元本 %dG" % remaining])
|
||||
loan_option.set_item_metadata(index, row[&"loan_id"])
|
||||
if StringName(row[&"loan_id"]) == _selected_loan: selection = index
|
||||
if selection < 0 and not loans.is_empty():
|
||||
selection = 0
|
||||
_selected_loan = StringName(loans[0][&"loan_id"])
|
||||
if selection >= 0: loan_option.select(selection)
|
||||
var row := _loan_row(report)
|
||||
var remaining := int(row.get(&"outstanding_principal", 0))
|
||||
var cash := int(report.get(&"cash", 0))
|
||||
var waiting := not (row.get(&"pending_command_ids", []) as Array).is_empty()
|
||||
var allowed := bool(report.get(&"available", false)) and bool(report.get(&"ledger_valid", false)) and not bool(report.get(&"terminal", false)) and remaining > 0 and not waiting
|
||||
var text := loan_amount.text.strip_edges()
|
||||
var amount := int(text) if text.is_valid_int() else 0
|
||||
repay_button.disabled = not allowed or amount <= 0 or amount > remaining or amount > cash
|
||||
repay_all_button.disabled = not allowed or remaining > cash
|
||||
loan_amount.editable = allowed
|
||||
if not bool(report.get(&"available", false)) or not bool(report.get(&"ledger_valid", false)):
|
||||
_loan_hint.text = "返済の帳簿を確認できません。"
|
||||
elif bool(report.get(&"terminal", false)):
|
||||
_loan_hint.text = "運営は終了しました。借入の記録を確認できます。"
|
||||
elif loans.is_empty():
|
||||
_loan_hint.text = "借入はありません。"
|
||||
elif waiting:
|
||||
_loan_hint.text = "返済を受理済みです。次の時間更新で実行し、手持ちと残元本を再確認します。"
|
||||
elif remaining == 0:
|
||||
_loan_hint.text = "この借入は完済しています。"
|
||||
else:
|
||||
_loan_hint.text = "手持ち %dGから元本を返済します。残元本 %dG。返済額は日次経費に含まれます。" % [cash, remaining]
|
||||
if remaining > cash: _loan_hint.text += "\n完済には手持ちが不足しています。"
|
||||
if not text.is_empty() and repay_button.disabled: _loan_hint.text += "\n残元本と手持ちを超えない正の整数額を入力してください。"
|
||||
|
||||
|
||||
func submit_repayment(full: bool = false) -> void:
|
||||
var report := _loan_report()
|
||||
var row := _loan_row(report)
|
||||
var remaining := int(row.get(&"outstanding_principal", 0))
|
||||
var text := loan_amount.text.strip_edges()
|
||||
var amount := remaining if full else (int(text) if text.is_valid_int() else 0)
|
||||
if not bool(report.get(&"ledger_valid", false)) or bool(report.get(&"terminal", false)) or row.is_empty() or not (row.get(&"pending_command_ids", []) as Array).is_empty() or amount <= 0 or amount > remaining or amount > int(report.get(&"cash", 0)):
|
||||
_set_repayment_result("返済できません。手持ち・残元本・返済待ちを確認してください。", false)
|
||||
_refresh_repayment_controls()
|
||||
return
|
||||
var key := StringName("ui.loan.repay.%d" % int(report[&"next_command_id"]))
|
||||
var payload := {&"loan_id": _selected_loan, &"amount": amount}
|
||||
var result: Variant
|
||||
if _audio_submitter.is_valid():
|
||||
result = _audio_submitter.call(&"repay_loan_principal", payload, &"player", _selected_loan, 0, -1, key)
|
||||
else:
|
||||
result = _runtime.call(&"submit_command", &"repay_loan_principal", payload, &"player", _selected_loan, 0, -1, key)
|
||||
var after := _loan_row(_loan_report())
|
||||
if result.is_success() and (after.get(&"pending_command_ids", []) as Array).has(int(result.command_id)):
|
||||
_loan_pending_command = int(result.command_id)
|
||||
_set_repayment_result("返済を受理しました。実行時に手持ちと残元本を確認します。", true)
|
||||
else:
|
||||
_show_repayment_result(result)
|
||||
_refresh_repayment_controls()
|
||||
|
||||
|
||||
func _show_repayment_result(result: Variant) -> void:
|
||||
if result.is_success():
|
||||
_set_repayment_result("元本 %dGを返済しました。残元本 %dG。" % [int(result.details.get(&"paid_principal", 0)), int(result.details.get(&"outstanding_principal", 0))], true)
|
||||
loan_amount.text = ""
|
||||
else:
|
||||
_set_repayment_result("返済を実行できませんでした。手持ちと残元本を確認してください。", false)
|
||||
|
||||
|
||||
func _on_repayment_completed(result: Variant) -> void:
|
||||
if int(result.command_id) != _loan_pending_command: return
|
||||
_loan_pending_command = 0
|
||||
_show_repayment_result(result)
|
||||
_refresh_repayment_controls()
|
||||
|
||||
|
||||
func _set_repayment_result(message: String, success: bool) -> void:
|
||||
_loan_result.text = message
|
||||
LayerSkin.register_status_text(_loan_result, success, LayerSkin.active_layer(self))
|
||||
status_requested.emit(message, success)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
extends Node
|
||||
|
||||
## Two arrangements share one sample clock; changing layer never restarts music.
|
||||
const FRONT_PATH := "res://assets/audio/bgm/tree_theme_front_candidate.wav"
|
||||
const BACK_PATH := "res://assets/audio/bgm/tree_theme_back_candidate.wav"
|
||||
const CROSSFADE_SECONDS := 1.25
|
||||
|
||||
var mix := 0.0
|
||||
var target_mix := 0.0
|
||||
var playback_starts := 0
|
||||
var startup_error := ""
|
||||
var _start_mix := 0.0
|
||||
var _elapsed := CROSSFADE_SECONDS
|
||||
var _player: AudioStreamPlayer
|
||||
var _synchronized: AudioStreamSynchronized
|
||||
var _closed := false
|
||||
|
||||
|
||||
func configure(initial_layer: int) -> void:
|
||||
mix = float(clampi(initial_layer, 0, 1))
|
||||
target_mix = mix
|
||||
_start_mix = mix
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
var front := ResourceLoader.load(FRONT_PATH, "AudioStreamWAV", ResourceLoader.CACHE_MODE_IGNORE) as AudioStreamWAV
|
||||
var back := ResourceLoader.load(BACK_PATH, "AudioStreamWAV", ResourceLoader.CACHE_MODE_IGNORE) as AudioStreamWAV
|
||||
if front == null or back == null:
|
||||
startup_error = "MISSING_PAIRED_BGM"
|
||||
return
|
||||
if front.format != AudioStreamWAV.FORMAT_16_BITS or back.format != front.format or not front.stereo or not back.stereo or front.mix_rate != back.mix_rate or front.data.size() != back.data.size() or front.data.is_empty():
|
||||
startup_error = "INCOMPATIBLE_PAIRED_BGM"
|
||||
return
|
||||
for stream: AudioStreamWAV in [front, back]:
|
||||
stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
|
||||
stream.loop_begin = 0
|
||||
stream.loop_end = int(stream.data.size() / 4.0)
|
||||
_synchronized = AudioStreamSynchronized.new()
|
||||
_synchronized.stream_count = 2
|
||||
_synchronized.set_sync_stream(0, front)
|
||||
_synchronized.set_sync_stream(1, back)
|
||||
_apply_mix()
|
||||
_player = AudioStreamPlayer.new()
|
||||
_player.name = "PairedBGMPlayer"
|
||||
_player.process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
_player.bus = &"Master"
|
||||
_player.stream = _synchronized
|
||||
add_child(_player)
|
||||
_player.play()
|
||||
playback_starts += 1
|
||||
|
||||
|
||||
func set_layer(layer: int) -> void:
|
||||
if _closed:
|
||||
return
|
||||
var destination := float(clampi(layer, 0, 1))
|
||||
if destination == target_mix:
|
||||
return
|
||||
_start_mix = mix
|
||||
target_mix = destination
|
||||
_elapsed = 0.0
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _closed or _elapsed >= CROSSFADE_SECONDS or not is_finite(delta) or delta <= 0.0:
|
||||
return
|
||||
_elapsed = minf(CROSSFADE_SECONDS, _elapsed + delta)
|
||||
mix = lerpf(_start_mix, target_mix, _elapsed / CROSSFADE_SECONDS)
|
||||
_apply_mix()
|
||||
|
||||
|
||||
func _apply_mix() -> void:
|
||||
if _synchronized == null:
|
||||
return
|
||||
var front_gain := cos(mix * PI / 2.0)
|
||||
var back_gain := sin(mix * PI / 2.0)
|
||||
_synchronized.set_sync_stream_volume(0, linear_to_db(front_gain) if front_gain > 0.0001 else -80.0)
|
||||
_synchronized.set_sync_stream_volume(1, linear_to_db(back_gain) if back_gain > 0.0001 else -80.0)
|
||||
|
||||
|
||||
func capture_owned_targets() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for entry: Array in [[&"bgm_owner", self], [&"bgm_player", _player], [&"bgm_synchronized", _synchronized]]:
|
||||
if is_instance_valid(entry[1]):
|
||||
result.append({&"role": &"bgm", &"kind": entry[0], &"weak": weakref(entry[1]), &"instance_id": entry[1].get_instance_id()})
|
||||
if _synchronized != null:
|
||||
for index: int in range(2):
|
||||
var stream := _synchronized.get_sync_stream(index)
|
||||
if stream != null:
|
||||
result.append({&"role": &"front" if index == 0 else &"back", &"kind": &"bgm_stream", &"weak": weakref(stream), &"instance_id": stream.get_instance_id()})
|
||||
if is_instance_valid(_player) and _player.has_stream_playback():
|
||||
var playback := _player.get_stream_playback()
|
||||
result.append({&"role": &"bgm", &"kind": &"bgm_playback", &"weak": weakref(playback), &"instance_id": playback.get_instance_id()})
|
||||
return result
|
||||
|
||||
|
||||
func prepare_owner_shutdown() -> void:
|
||||
_closed = true
|
||||
set_process(false)
|
||||
if is_instance_valid(_player):
|
||||
_player.stop()
|
||||
_player.stream = null
|
||||
_synchronized = null
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
prepare_owner_shutdown()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cve5judsgtgcy
|
||||
@@ -16,6 +16,22 @@ static func pose(mode: StringName, frame: int, variant: int, staff: bool) -> Arr
|
||||
var work := mode in [&"work", &"clean", &"eat"]
|
||||
var stride := [0, 1, 2, 1, 0, -1, -2, -1][phase] as int
|
||||
var bob := -1 if walk and phase in [2, 6] else 0
|
||||
if mode == &"standard_room_sleep":
|
||||
# General-room presentation only: same16x18 actor grid and original skin.
|
||||
# Breathing follows existing visual frame; no Clock, guest or service change.
|
||||
var breathing := -1 if phase in [2, 3, 4] else 0
|
||||
mark(marks, Rect2(2, 14, 13, 2), INK)
|
||||
mark(marks, Rect2(2, 11, 5, 4), Color("eee5c9"))
|
||||
mark(marks, Rect2(3, 12, 4, 3), skin)
|
||||
mark(marks, Rect2(2, 12, 2, 3), INK)
|
||||
mark(marks, Rect2(5, 13, 2, 1), INK)
|
||||
mark(marks, Rect2(7, 11 + breathing, 8, 5 - breathing), INK)
|
||||
mark(marks, Rect2(8, 12 + breathing, 6, 3 - breathing), Color("608d84"))
|
||||
mark(marks, Rect2(8, 12 + breathing, 6, 1), Color("eee5c9"))
|
||||
mark(marks, Rect2(9, 14 + breathing, 4, 1), Color("719d94"))
|
||||
mark(marks, Rect2(6, 13 + breathing, 2, 1), skin)
|
||||
mark(marks, Rect2(14, 14, 1, 2), INK)
|
||||
return marks
|
||||
if mode in [&"sleep", &"collapsed"]:
|
||||
mark(marks, Rect2(2, 14, 12, 2), INK)
|
||||
mark(marks, Rect2(3, 12, 4, 3), skin)
|
||||
|
||||
@@ -4,6 +4,8 @@ extends RefCounted
|
||||
## Small, code-native room furnishings. Coordinates use the same pixel size as
|
||||
## Kenney's 16 px sprites. This renderer never changes simulation state.
|
||||
const Layout := preload("res://scripts/ui/room_presentation.gd")
|
||||
const StandardRooms := preload("res://scripts/ui/standard_room_art.gd")
|
||||
const InfirmaryRooms := preload("res://scripts/ui/infirmary_room_art.gd")
|
||||
const INK := Color("34413b")
|
||||
const WOOD := Color("8e6348")
|
||||
const WOOD_LIGHT := Color("c39665")
|
||||
@@ -16,7 +18,7 @@ static func tile(canvas: CanvasItem, texture: Texture2D, index: int, target: Rec
|
||||
canvas.draw_texture_rect_region(texture, target, Rect2((index % 12) * 16, (index / 12) * 16, 16, 16), tint)
|
||||
|
||||
|
||||
static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float, dungeon: Texture2D, town: Texture2D, back: bool, decoration_seed := 0, presentation: Dictionary = {}) -> void:
|
||||
static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float, dungeon: Texture2D, town: Texture2D, back: bool, decoration_seed := 0, presentation: Dictionary = {}, visual_level: int = 1) -> void:
|
||||
var p := pixel
|
||||
if id in [&"guest_stairs", &"service_stairs"]:
|
||||
stairs(canvas, rect, p, id == &"service_stairs")
|
||||
@@ -65,12 +67,21 @@ static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float,
|
||||
var lamp_x := mid if w > 32*p else rect.end.x-4*p
|
||||
canvas.draw_rect(Rect2(lamp_x-p, top+3*p, 2*p, 3*p), WOOD)
|
||||
canvas.draw_rect(Rect2(lamp_x-2*p, top+5*p, 4*p, 2*p), Color("edc97c"))
|
||||
if id == &"standard_room":
|
||||
StandardRooms.wall_trim(canvas, rect, p, back, visual_level)
|
||||
match id:
|
||||
&"reception":
|
||||
counter(canvas, Vector2(mid-9*p, floor_y-6*p), 18*p, p)
|
||||
canvas.draw_rect(Rect2(mid-6*p, floor_y-8*p, 4*p, 2*p), CREAM)
|
||||
canvas.draw_rect(Rect2(mid+4*p, floor_y-8*p, 2*p, 2*p), Color("d6ad52"))
|
||||
&"standard_room", &"premium_room", &"staff_dormitory", &"adult_private_room", &"infirmary":
|
||||
&"standard_room":
|
||||
for at: Vector2 in Layout.slots(rect, p, id):
|
||||
var bed_at: Vector2 = at + Vector2(-7, -9)*p
|
||||
bed(canvas, bed_at, p, Color("709b94") if not dark else Color("a37b91"), 13)
|
||||
StandardRooms.bed_trim(canvas, bed_at, p, back, visual_level)
|
||||
&"infirmary":
|
||||
InfirmaryRooms.draw_room(canvas,rect,p,back,visual_level,dungeon)
|
||||
&"premium_room", &"staff_dormitory", &"adult_private_room":
|
||||
for at: Vector2 in Layout.slots(rect, p, id):
|
||||
bed(canvas, at + Vector2(-7, -9)*p, p, Color("709b94") if not dark else Color("a37b91"), 13)
|
||||
&"cafeteria", &"tavern":
|
||||
|
||||
@@ -64,6 +64,12 @@ func prepare(runtime: Variant, clock: Variant, tree: SceneTree) -> bool:
|
||||
&"candidate_id": row[0], &"facility_id": row[1], &"current_cell": row[2],
|
||||
}):
|
||||
return false
|
||||
if not _command(&"hire_staff_candidate", {&"candidate_id": &"staff_doran", &"facility_id": facility_ids[&"kitchen"], &"current_cell": Vector3i(2, 0, 1)}):
|
||||
return false
|
||||
if not _command(&"set_staff_work_assignment", {&"staff_id": &"staff_doran", &"facility_id": facility_ids[&"kitchen"], &"template_id": &"DAY"}):
|
||||
return false
|
||||
if not _command(&"set_facility_conditions", {&"facility_id": facility_ids[&"cafeteria"], &"conditions": {&"related_facility_id": facility_ids[&"kitchen"]}}):
|
||||
return false
|
||||
# A small explicitly seeded pantry is part of the disposable scenario; its
|
||||
# stacks are real and get consumed by the same service pipeline as gameplay.
|
||||
var cafeteria: Variant = _runtime.facility_service.get_instance(facility_ids[&"cafeteria"])
|
||||
|
||||
@@ -29,6 +29,7 @@ var result_label: Label
|
||||
var selected_staff_id: StringName = &""
|
||||
|
||||
var _runtime: Variant
|
||||
var _audio_submitter := Callable()
|
||||
var _style_button: Callable
|
||||
var _staff_rows: Array[Dictionary] = []
|
||||
var _facility_rows: Array[Dictionary] = []
|
||||
@@ -147,6 +148,9 @@ func refresh() -> void:
|
||||
_style(button)
|
||||
roster_list.add_child(button)
|
||||
roster_buttons[staff_id] = button
|
||||
# Existing buttons may have been created in hire order. Match the
|
||||
# authoritative report order on every refresh, including after Load.
|
||||
roster_list.move_child(button, live_ids.size() - 1)
|
||||
button.text = "%s %s%s" % [row.get(&"display_name", staff_id), _role_name(row), "・未配属" if StringName(row.get(&"assigned_facility_id", &"")).is_empty() else ""]
|
||||
button.tooltip_text = "施設へドラッグして配属(選択中の勤務帯を適用)\n%s / %s" % [_facility_name(StringName(row.get(&"assigned_facility_id", &""))), _state_name(row)]
|
||||
for raw_id: Variant in roster_buttons.keys():
|
||||
@@ -169,6 +173,19 @@ func refresh() -> void:
|
||||
_refresh_editor()
|
||||
|
||||
|
||||
## Reset optional presentation without selecting the first roster row.
|
||||
## Opening the roster still uses refresh() to choose its normal default.
|
||||
func clear_selection() -> void:
|
||||
selected_staff_id = &""
|
||||
_draft_dirty = false
|
||||
if not _built:
|
||||
return
|
||||
result_label.text = ""
|
||||
for raw_id: Variant in roster_buttons:
|
||||
(roster_buttons[raw_id] as Button).set_pressed_no_signal(false)
|
||||
editor_panel.hide()
|
||||
|
||||
|
||||
func select_staff(staff_id: StringName) -> void:
|
||||
selected_staff_id = staff_id
|
||||
_draft_dirty = false
|
||||
@@ -273,7 +290,7 @@ func submit_assignment() -> void:
|
||||
return
|
||||
var key := StringName("%s%08d" % [KEY_PREFIX, _sequence])
|
||||
_sequence += 1
|
||||
var result: Variant = _runtime.call(&"submit_command", ASSIGNMENT_COMMAND, {
|
||||
var result: Variant = _submit_ui_command(ASSIGNMENT_COMMAND, {
|
||||
&"staff_id": selected_staff_id,
|
||||
&"facility_id": _draft_facility_id,
|
||||
&"template_id": _draft_template_id,
|
||||
@@ -489,3 +506,17 @@ func assign_from_drop(data: Dictionary, facility_id: StringName) -> void:
|
||||
_draft_template_id = StringName(data.get(&"template_id", &"DAY"))
|
||||
_draft_dirty = true
|
||||
submit_assignment()
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
class_name StandardRoomArt
|
||||
extends RefCounted
|
||||
|
||||
## Provisional visual-only Lv1..3 study. Preserve the existing two bed anchors,
|
||||
## footprint and operation overlays. These are cosmetic pixels, not new game
|
||||
## costs, capacity, quality or upgrade rules. Level1 draws no added rectangles.
|
||||
## Existing 16px furniture palette is retained. Inspected Kenney references and
|
||||
## exact CC0 records are pinned; no narrow two-bed replacement was selected.
|
||||
const BED_TRIMS := {
|
||||
"2": [
|
||||
{
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"size": [
|
||||
13,
|
||||
1
|
||||
],
|
||||
"front": "c39665",
|
||||
"back": "8fa5a2"
|
||||
},
|
||||
{
|
||||
"at": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"size": [
|
||||
2,
|
||||
8
|
||||
],
|
||||
"front": "a87852",
|
||||
"back": "698783"
|
||||
},
|
||||
{
|
||||
"at": [
|
||||
5,
|
||||
4
|
||||
],
|
||||
"size": [
|
||||
7,
|
||||
1
|
||||
],
|
||||
"front": "e5dac0",
|
||||
"back": "b8ccc2"
|
||||
}
|
||||
],
|
||||
"3": [
|
||||
{
|
||||
"at": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"size": [
|
||||
13,
|
||||
1
|
||||
],
|
||||
"front": "c39665",
|
||||
"back": "8fa5a2"
|
||||
},
|
||||
{
|
||||
"at": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"size": [
|
||||
11,
|
||||
1
|
||||
],
|
||||
"front": "eee5c9",
|
||||
"back": "c3d1c5"
|
||||
},
|
||||
{
|
||||
"at": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"size": [
|
||||
2,
|
||||
8
|
||||
],
|
||||
"front": "a87852",
|
||||
"back": "698783"
|
||||
},
|
||||
{
|
||||
"at": [
|
||||
5,
|
||||
6
|
||||
],
|
||||
"size": [
|
||||
7,
|
||||
1
|
||||
],
|
||||
"front": "eee5c9",
|
||||
"back": "c3d1c5"
|
||||
},
|
||||
{
|
||||
"at": [
|
||||
7,
|
||||
3
|
||||
],
|
||||
"size": [
|
||||
3,
|
||||
3
|
||||
],
|
||||
"front": "d6ad52",
|
||||
"back": "93b6ae"
|
||||
},
|
||||
{
|
||||
"at": [
|
||||
8,
|
||||
4
|
||||
],
|
||||
"size": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"front": "fff0d6",
|
||||
"back": "e6edda"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
static func bed_trim(canvas: CanvasItem, at: Vector2, p: float, back: bool, level: int) -> void:
|
||||
var commands: Array = BED_TRIMS.get(str(clampi(level, 1, 3)), [])
|
||||
for command: Dictionary in commands:
|
||||
var offset: Array = command["at"]
|
||||
var dimensions: Array = command["size"]
|
||||
var tone: Color = Color(String(command["back"] if back else command["front"]))
|
||||
canvas.draw_rect(Rect2(at + Vector2(float(offset[0]), float(offset[1]))*p,
|
||||
Vector2(float(dimensions[0]), float(dimensions[1]))*p), tone)
|
||||
|
||||
|
||||
static func wall_trim(canvas: CanvasItem, rect: Rect2, p: float, back: bool, level: int) -> void:
|
||||
if level < 2:
|
||||
return
|
||||
var tone: Color = Color("8fa5a2") if back else Color("c39665")
|
||||
canvas.draw_rect(Rect2(rect.position + Vector2(1, 2)*p, Vector2(rect.size.x - 2*p, p)), tone)
|
||||
if level >= 3:
|
||||
for offset: int in [-2, 0, 2]:
|
||||
canvas.draw_rect(Rect2(Vector2(rect.get_center().x + offset*p, rect.position.y + 3*p), Vector2(p, p)), tone)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bophxjq1fck3q
|
||||
@@ -86,6 +86,7 @@ var selected_kitchen_id: StringName = &""
|
||||
var selected_tab: StringName = &"purchase"
|
||||
|
||||
var _runtime: Variant
|
||||
var _audio_submitter := Callable()
|
||||
var _style_button: Callable
|
||||
var _report: Dictionary = {}
|
||||
var _quote: Dictionary = {}
|
||||
@@ -282,7 +283,11 @@ func _refresh_purchase() -> void:
|
||||
if catalog.is_empty() or warehouse.is_empty():
|
||||
purchase_quote_label.text = "受入倉庫と品目を選んでください。"
|
||||
return
|
||||
purchase_quote_label.text = "単価 %dG / %s\n合計 %dG · 購入後 %dG" % [catalog.get(&"unit_price", 0), catalog.get(&"unit_name", "個"), _quote.get(&"total_cost", 0), _quote.get(&"cash_after", _report.get(&"cash", 0))]
|
||||
if _quote.has(&"purchase_quote"):
|
||||
var frozen: Dictionary = _quote[&"purchase_quote"]
|
||||
purchase_quote_label.text = "基準単価 %dG / %s\n基準合計 %dG · ミミの仕入れ20%%割引\n合計 %dG · 購入後 %dG" % [frozen[&"base_unit_price"], catalog.get(&"unit_name", "個"), frozen[&"base_total"], frozen[&"effective_total"], _quote.get(&"cash_after", _report.get(&"cash", 0))]
|
||||
else:
|
||||
purchase_quote_label.text = "単価 %dG / %s\n合計 %dG · 購入後 %dG" % [catalog.get(&"unit_price", 0), catalog.get(&"unit_name", "個"), _quote.get(&"total_cost", 0), _quote.get(&"cash_after", _report.get(&"cash", 0))]
|
||||
if not bool(_quote.get(&"ok", false)):
|
||||
purchase_quote_label.text += "\n" + _reason_text(_quote.get(&"reason", &"UNAVAILABLE"))
|
||||
|
||||
@@ -323,7 +328,11 @@ func _refresh_auto_purchase(warehouse: Dictionary) -> void:
|
||||
if int(row.get(&"deficit", 0)) > 0 and not quote.is_empty():
|
||||
auto_purchase_status.text += "\n不足 %d · 今の購入見積 %dG" % [row[&"deficit"], quote.get(&"total_cost", 0)]
|
||||
if row.get(&"last_result", &"") == &"PURCHASED":
|
||||
auto_purchase_status.text += "\n前回入庫 %d個 · 代金 %dG(単価 %dG)" % [row[&"last_quantity"], row[&"last_total_cost"], row[&"last_unit_price"]]
|
||||
if row.has(&"last_quote"):
|
||||
var frozen: Dictionary = row[&"last_quote"]
|
||||
auto_purchase_status.text += "\n前回入庫 %d個 · 基準合計 %dG · ミミの仕入れ20%%割引 · 代金 %dG" % [row[&"last_quantity"], frozen[&"base_total"], frozen[&"effective_total"]]
|
||||
else:
|
||||
auto_purchase_status.text += "\n前回入庫 %d個 · 代金 %dG(単価 %dG)" % [row[&"last_quantity"], row[&"last_total_cost"], row[&"last_unit_price"]]
|
||||
if _auto_purchase_drafts.has(_auto_purchase_key()):
|
||||
auto_purchase_status.text += "\n未保存の変更です。設定を保存すると反映します。"
|
||||
auto_purchase_status.text += "\n在庫が減ると目標まで不足分を購入します。料金は手動購入と同じです。"
|
||||
@@ -430,7 +439,7 @@ func _submit(type: StringName, payload: Dictionary) -> void:
|
||||
var context := {&"type": type, &"payload": payload.duplicate(true)}
|
||||
var key := StringName("%s%08d" % [KEY_PREFIX, _sequence])
|
||||
_sequence += 1
|
||||
var result: Variant = _runtime.call(&"submit_command", type, payload, &"player", StringName(payload.get(&"facility_id", payload.get(&"warehouse_id", &""))), 75, -1, key)
|
||||
var result: Variant = _submit_ui_command(type, payload, &"player", StringName(payload.get(&"facility_id", payload.get(&"warehouse_id", &""))), 75, -1, key)
|
||||
if result == null or not result.has_method(&"is_success"):
|
||||
_set_result("操作に応答がありません。", false)
|
||||
return
|
||||
@@ -643,9 +652,9 @@ func _button(value: String, node_name: String, callback: Callable) -> Button:
|
||||
button.text = value
|
||||
button.custom_minimum_size.y = 30
|
||||
button.add_theme_font_size_override("font_size", 13)
|
||||
button.pressed.connect(callback)
|
||||
if _style_button.is_valid():
|
||||
_style_button.call(button)
|
||||
button.pressed.connect(callback)
|
||||
return button
|
||||
|
||||
|
||||
@@ -670,3 +679,17 @@ func _spin(node_name: String, minimum: int, value: int) -> SpinBox:
|
||||
spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
spin.custom_minimum_size.y = 30
|
||||
return spin
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
class_name UIAudioFeedback
|
||||
extends Node
|
||||
|
||||
## Presentation-only feedback. No Runtime snapshot/RNG/command mutation.
|
||||
signal cue_requested(role: StringName, correlation: Dictionary)
|
||||
|
||||
const UI_BUS := &"TheTreeUI"
|
||||
const SETTINGS_VERSION := 1
|
||||
const DEFAULT_SETTINGS := {&"master_volume": 1.0, &"ui_volume": 0.5, &"muted": false}
|
||||
const CUES := {
|
||||
&"click": "res://assets/audio/ui/click_001.ogg",
|
||||
&"success": "res://assets/audio/ui/confirmation_001.ogg",
|
||||
&"refusal": "res://assets/audio/ui/error_008.ogg",
|
||||
&"notification": "res://assets/audio/ui/bong_001.ogg",
|
||||
}
|
||||
const MAX_POLYPHONY_PER_CUE := 2
|
||||
const UI_LIMITER_CEILING_DB := -1.0
|
||||
|
||||
var playback_enabled := true
|
||||
var settings: Dictionary = DEFAULT_SETTINGS.duplicate(true)
|
||||
var settings_writable := true
|
||||
var settings_reason: StringName = &""
|
||||
var _settings_path := ""
|
||||
var _settings_fingerprint := ""
|
||||
var _settings_config := ConfigFile.new()
|
||||
var _runtime: Variant
|
||||
var _epoch := 0
|
||||
var _nonce := 0
|
||||
var _active_submissions: Array[Dictionary] = []
|
||||
var _armed: Dictionary = {}
|
||||
var _seen_foregrounds: Dictionary = {}
|
||||
var _players: Dictionary = {}
|
||||
var _owner_shutting_down := false
|
||||
|
||||
|
||||
func configure(runtime: Variant, settings_path := "", enable_playback := true) -> void:
|
||||
bind_runtime(runtime)
|
||||
playback_enabled = enable_playback
|
||||
_settings_path = settings_path
|
||||
read_settings()
|
||||
if is_inside_tree():
|
||||
_apply_bus_settings()
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
_ensure_ui_bus()
|
||||
for role: StringName in CUES:
|
||||
if not ResourceLoader.exists(CUES[role], "AudioStream"):
|
||||
continue
|
||||
var stream := load(CUES[role]) as AudioStreamOggVorbis
|
||||
if stream == null:
|
||||
continue
|
||||
stream = stream.duplicate() as AudioStreamOggVorbis
|
||||
stream.loop = false
|
||||
var player := AudioStreamPlayer.new()
|
||||
player.name = "UICue_%s" % role
|
||||
player.stream = stream
|
||||
player.bus = UI_BUS
|
||||
player.max_polyphony = MAX_POLYPHONY_PER_CUE
|
||||
player.process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
add_child(player)
|
||||
_players[role] = player
|
||||
_apply_bus_settings()
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
_disconnect_runtime()
|
||||
_armed.clear()
|
||||
_active_submissions.clear()
|
||||
|
||||
|
||||
func prepare_owner_shutdown() -> void:
|
||||
_owner_shutting_down = true
|
||||
_disconnect_runtime()
|
||||
_armed.clear()
|
||||
_active_submissions.clear()
|
||||
_seen_foregrounds.clear()
|
||||
for player: AudioStreamPlayer in _players.values():
|
||||
player.stop()
|
||||
player.stream = null
|
||||
_players.clear()
|
||||
|
||||
|
||||
func bind_runtime(runtime: Variant) -> void:
|
||||
_disconnect_runtime()
|
||||
_runtime = runtime
|
||||
begin_loaded_epoch()
|
||||
if is_instance_valid(_runtime) and _runtime.has_signal(&"command_completed"):
|
||||
if not _runtime.is_connected(&"command_completed", _on_command_completed):
|
||||
_runtime.connect(&"command_completed", _on_command_completed)
|
||||
|
||||
|
||||
func _disconnect_runtime() -> void:
|
||||
if is_instance_valid(_runtime) and _runtime.has_signal(&"command_completed"):
|
||||
if _runtime.is_connected(&"command_completed", _on_command_completed):
|
||||
_runtime.disconnect(&"command_completed", _on_command_completed)
|
||||
|
||||
|
||||
func begin_loaded_epoch(current_foreground: Variant = null) -> void:
|
||||
_epoch += 1
|
||||
_armed.clear()
|
||||
_active_submissions.clear()
|
||||
_seen_foregrounds.clear()
|
||||
for player: AudioStreamPlayer in _players.values():
|
||||
player.stop()
|
||||
observe_foreground(current_foreground, true)
|
||||
|
||||
|
||||
func submit_command(
|
||||
command_type: StringName, payload: Dictionary = {}, actor_id: StringName = &"player",
|
||||
target_id: StringName = &"", priority := 0, execute_at := -1,
|
||||
idempotency_key: StringName = &""
|
||||
) -> Variant:
|
||||
if not is_instance_valid(_runtime) or not _runtime.has_method(&"submit_command"):
|
||||
return null
|
||||
_nonce += 1
|
||||
var intent := {&"epoch": _epoch, &"nonce": _nonce, &"consumed": false, &"buffer": {}}
|
||||
# Paused Runtime emits completion before returning. Buffer before the call.
|
||||
_active_submissions.append(intent)
|
||||
var result: Variant = _runtime.call(&"submit_command", command_type, payload, actor_id, target_id, priority, execute_at, idempotency_key)
|
||||
_active_submissions.erase(intent)
|
||||
if not result is CommandResult or result.is_replay or int(intent[&"epoch"]) != _epoch:
|
||||
return result
|
||||
# Enqueue/validation failure is authoritative, even if an ID was rejected.
|
||||
if not result.is_success():
|
||||
_deliver_outcome(result, intent)
|
||||
return result
|
||||
var command_id: int = result.command_id
|
||||
var completed: Variant = intent[&"buffer"].get(command_id)
|
||||
var queue: Variant = _runtime.get(&"command_queue")
|
||||
if completed == null and queue != null and queue.has_method(&"get_processed_result"):
|
||||
completed = queue.call(&"get_processed_result", command_id)
|
||||
if completed is CommandResult:
|
||||
_deliver_outcome(completed, intent)
|
||||
elif _is_pending(queue, command_id):
|
||||
_armed[command_id] = intent
|
||||
# Unclassified acceptance is not evidence of successful completion.
|
||||
return result
|
||||
|
||||
|
||||
func _is_pending(queue: Variant, command_id: int) -> bool:
|
||||
if queue == null or command_id <= 0 or not queue.has_method(&"create_snapshot"):
|
||||
return false
|
||||
var snapshot: Dictionary = queue.call(&"create_snapshot")
|
||||
for command: Dictionary in snapshot.get("pending", []):
|
||||
if int(command.get("command_id", 0)) == command_id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _on_command_completed(result: Variant) -> void:
|
||||
if not result is CommandResult:
|
||||
return
|
||||
var command_id: int = result.command_id
|
||||
if _armed.has(command_id):
|
||||
var intent: Dictionary = _armed[command_id]
|
||||
_armed.erase(command_id)
|
||||
_deliver_outcome(result, intent)
|
||||
for intent: Dictionary in _active_submissions:
|
||||
if command_id > 0:
|
||||
intent[&"buffer"][command_id] = result
|
||||
|
||||
|
||||
func _deliver_outcome(result: CommandResult, intent: Dictionary) -> void:
|
||||
if bool(intent[&"consumed"]) or int(intent[&"epoch"]) != _epoch:
|
||||
return
|
||||
intent[&"consumed"] = true
|
||||
if result.is_replay:
|
||||
return
|
||||
if result.is_success() and (
|
||||
bool(result.details.get(&"unchanged", false))
|
||||
or bool(result.details.get(&"noop", false))
|
||||
or bool(result.details.get(&"no_op", false))
|
||||
):
|
||||
return
|
||||
_request_cue(&"success" if result.is_success() else &"refusal", {
|
||||
&"epoch": _epoch, &"intent": intent[&"nonce"], &"command_id": result.command_id,
|
||||
})
|
||||
|
||||
|
||||
func reject_local(reason: StringName) -> void:
|
||||
_nonce += 1
|
||||
_request_cue(&"refusal", {&"epoch": _epoch, &"intent": _nonce, &"reason": reason})
|
||||
|
||||
|
||||
func observe_foreground(instance: Variant, silent := false) -> void:
|
||||
if instance == null or int(instance.state) != 2:
|
||||
return
|
||||
var instance_id := StringName(instance.event_instance_id)
|
||||
if instance_id.is_empty() or _seen_foregrounds.has(instance_id):
|
||||
return
|
||||
_seen_foregrounds[instance_id] = true
|
||||
if not silent:
|
||||
_request_cue(&"notification", {&"epoch": _epoch, &"event_instance_id": instance_id})
|
||||
|
||||
|
||||
func bind_button(button: BaseButton) -> void:
|
||||
var callback := _on_button_pressed.bind(button)
|
||||
if not button.pressed.is_connected(callback):
|
||||
button.pressed.connect(callback)
|
||||
if button is OptionButton or button is MenuButton:
|
||||
var popup: PopupMenu = button.get_popup()
|
||||
if not popup.id_pressed.is_connected(_on_popup_item_pressed):
|
||||
popup.id_pressed.connect(_on_popup_item_pressed)
|
||||
|
||||
|
||||
func _on_button_pressed(button: BaseButton) -> void:
|
||||
if is_instance_valid(button) and not button.disabled:
|
||||
request_click()
|
||||
|
||||
|
||||
func _on_popup_item_pressed(_id: int) -> void:
|
||||
request_click()
|
||||
|
||||
|
||||
func request_click() -> void:
|
||||
_request_cue(&"click", {&"epoch": _epoch})
|
||||
|
||||
|
||||
func _request_cue(role: StringName, correlation: Dictionary) -> void:
|
||||
if _owner_shutting_down:
|
||||
return
|
||||
if bool(settings[&"muted"]) or float(settings[&"master_volume"]) <= 0.0 or float(settings[&"ui_volume"]) <= 0.0:
|
||||
return
|
||||
cue_requested.emit(role, correlation.duplicate(true))
|
||||
if playback_enabled and _players.has(role):
|
||||
(_players[role] as AudioStreamPlayer).play()
|
||||
|
||||
|
||||
func _ensure_ui_bus() -> void:
|
||||
if AudioServer.get_bus_index(UI_BUS) < 0:
|
||||
AudioServer.add_bus()
|
||||
AudioServer.set_bus_name(AudioServer.bus_count - 1, String(UI_BUS))
|
||||
AudioServer.set_bus_send(AudioServer.get_bus_index(UI_BUS), &"Master")
|
||||
var limiter := AudioEffectHardLimiter.new()
|
||||
limiter.ceiling_db = UI_LIMITER_CEILING_DB
|
||||
AudioServer.add_bus_effect(AudioServer.get_bus_index(UI_BUS), limiter)
|
||||
|
||||
|
||||
func _apply_bus_settings() -> void:
|
||||
_ensure_ui_bus()
|
||||
if bool(settings[&"muted"]) or float(settings[&"master_volume"]) == 0.0 or float(settings[&"ui_volume"]) == 0.0:
|
||||
for player: AudioStreamPlayer in _players.values():
|
||||
player.stop()
|
||||
for entry: Array in [[&"Master", &"master_volume"], [UI_BUS, &"ui_volume"]]:
|
||||
var index := AudioServer.get_bus_index(entry[0])
|
||||
if index < 0:
|
||||
continue
|
||||
var volume := float(settings[entry[1]])
|
||||
AudioServer.set_bus_volume_db(index, linear_to_db(volume) if volume > 0.0 else -80.0)
|
||||
AudioServer.set_bus_mute(index, bool(settings[&"muted"]) or volume == 0.0)
|
||||
|
||||
|
||||
func update_settings(value: Dictionary) -> bool:
|
||||
if not _valid_settings(value):
|
||||
return false
|
||||
settings = value.duplicate(true)
|
||||
_apply_bus_settings()
|
||||
return true
|
||||
|
||||
|
||||
func _valid_settings(value: Dictionary) -> bool:
|
||||
for key: StringName in [&"master_volume", &"ui_volume"]:
|
||||
var raw: Variant = value.get(key)
|
||||
if typeof(raw) not in [TYPE_INT, TYPE_FLOAT] or not is_finite(float(raw)) or float(raw) < 0.0 or float(raw) > 1.0:
|
||||
return false
|
||||
return typeof(value.get(&"muted")) == TYPE_BOOL
|
||||
|
||||
|
||||
func read_settings() -> void:
|
||||
settings = DEFAULT_SETTINGS.duplicate(true)
|
||||
settings_writable = true
|
||||
settings_reason = &""
|
||||
_settings_config = ConfigFile.new()
|
||||
_settings_fingerprint = ""
|
||||
if _settings_path.is_empty() or not FileAccess.file_exists(_settings_path):
|
||||
return
|
||||
_settings_fingerprint = FileAccess.get_sha256(_settings_path)
|
||||
if _settings_config.load(_settings_path) != OK:
|
||||
settings_writable = false
|
||||
settings_reason = &"UNREADABLE_SETTINGS"
|
||||
return
|
||||
var version: Variant = _settings_config.get_value("schema", "version", 0)
|
||||
if typeof(version) != TYPE_INT or version != SETTINGS_VERSION:
|
||||
settings_writable = false
|
||||
settings_reason = &"UNSUPPORTED_SETTINGS_VERSION"
|
||||
return
|
||||
var loaded := {}
|
||||
for key: StringName in DEFAULT_SETTINGS:
|
||||
loaded[key] = _settings_config.get_value("audio", String(key), DEFAULT_SETTINGS[key])
|
||||
if not _valid_settings(loaded):
|
||||
settings_writable = false
|
||||
settings_reason = &"INVALID_SETTINGS_VALUES"
|
||||
return
|
||||
settings = loaded
|
||||
|
||||
|
||||
func save_settings() -> Dictionary:
|
||||
if not settings_writable or _settings_path.is_empty():
|
||||
return {&"ok": false, &"reason": settings_reason if not settings_reason.is_empty() else &"NO_SETTINGS_PATH"}
|
||||
if not _valid_settings(settings):
|
||||
return {&"ok": false, &"reason": &"INVALID_SETTINGS_VALUES"}
|
||||
var current := FileAccess.get_sha256(_settings_path) if FileAccess.file_exists(_settings_path) else ""
|
||||
if current != _settings_fingerprint:
|
||||
return {&"ok": false, &"reason": &"SETTINGS_CHANGED_EXTERNALLY"}
|
||||
var target := ProjectSettings.globalize_path(_settings_path)
|
||||
var directory_error := DirAccess.make_dir_recursive_absolute(target.get_base_dir())
|
||||
if directory_error != OK and directory_error != ERR_ALREADY_EXISTS:
|
||||
return {&"ok": false, &"reason": &"SETTINGS_DIRECTORY_FAILED"}
|
||||
var temporary := "%s.tmp-%d-%d" % [target, OS.get_process_id(), Time.get_ticks_usec()]
|
||||
if FileAccess.file_exists(temporary):
|
||||
return {&"ok": false, &"reason": &"SETTINGS_TEMP_EXISTS"}
|
||||
_settings_config.set_value("schema", "version", SETTINGS_VERSION)
|
||||
for key: StringName in DEFAULT_SETTINGS:
|
||||
_settings_config.set_value("audio", String(key), settings[key])
|
||||
var error := _settings_config.save(temporary)
|
||||
if error != OK:
|
||||
if FileAccess.file_exists(temporary): DirAccess.remove_absolute(temporary)
|
||||
return {&"ok": false, &"reason": &"SETTINGS_WRITE_FAILED"}
|
||||
current = FileAccess.get_sha256(_settings_path) if FileAccess.file_exists(_settings_path) else ""
|
||||
if current != _settings_fingerprint:
|
||||
DirAccess.remove_absolute(temporary)
|
||||
return {&"ok": false, &"reason": &"SETTINGS_CHANGED_EXTERNALLY"}
|
||||
if DirAccess.rename_absolute(temporary, target) != OK:
|
||||
DirAccess.remove_absolute(temporary)
|
||||
return {&"ok": false, &"reason": &"SETTINGS_REPLACE_FAILED"}
|
||||
_settings_fingerprint = FileAccess.get_sha256(_settings_path)
|
||||
return {&"ok": true}
|
||||
@@ -0,0 +1 @@
|
||||
uid://u38heyutw0xp
|
||||
@@ -0,0 +1,171 @@
|
||||
extends RefCounted
|
||||
|
||||
## One close coordinator per SceneTree; owners remain alive while audio drains.
|
||||
## Runtime values, settings and shared AudioServer buses are never rewritten.
|
||||
const OWNER_META := &"_the_tree_ui_close_owners"
|
||||
const MAXIMUM_WAIT_US := 500000
|
||||
|
||||
|
||||
static func register_owner(tree: SceneTree, owner: Node) -> void:
|
||||
var state: Dictionary
|
||||
if tree.root.has_meta(OWNER_META):
|
||||
state = tree.root.get_meta(OWNER_META)
|
||||
else:
|
||||
state = {&"original_auto_accept_quit": tree.auto_accept_quit, &"owners": [], &"closing": false}
|
||||
tree.root.set_meta(OWNER_META, state)
|
||||
var owners: Array = state[&"owners"]
|
||||
for reference: WeakRef in owners:
|
||||
if reference.get_ref() == owner:
|
||||
return
|
||||
owners.append(weakref(owner))
|
||||
tree.auto_accept_quit = false
|
||||
|
||||
|
||||
static func unregister_owner(tree: SceneTree, owner: Node) -> void:
|
||||
if not tree.root.has_meta(OWNER_META):
|
||||
return
|
||||
var state: Dictionary = tree.root.get_meta(OWNER_META)
|
||||
var kept: Array = []
|
||||
for reference: WeakRef in state[&"owners"]:
|
||||
var node: Variant = reference.get_ref()
|
||||
if is_instance_valid(node) and node != owner:
|
||||
kept.append(reference)
|
||||
state[&"owners"] = kept
|
||||
if kept.is_empty() and not bool(state[&"closing"]):
|
||||
tree.auto_accept_quit = bool(state[&"original_auto_accept_quit"])
|
||||
tree.root.remove_meta(OWNER_META)
|
||||
|
||||
|
||||
static func capture_and_release(audio: Node) -> Array[Dictionary]:
|
||||
var targets := _capture_targets(audio)
|
||||
# This synchronous frame owns all strong player/stream/playback locals.
|
||||
# It returns only WeakRefs before the coordinator's first await.
|
||||
if is_instance_valid(audio):
|
||||
targets.append({&"role": &"owner", &"kind": &"audio_owner", &"weak": weakref(audio), &"instance_id": audio.get_instance_id()})
|
||||
audio.call(&"prepare_owner_shutdown")
|
||||
audio.queue_free()
|
||||
return targets
|
||||
|
||||
|
||||
static func close_owners(tree: SceneTree) -> Dictionary:
|
||||
if not tree.root.has_meta(OWNER_META):
|
||||
return {&"coordinator": false}
|
||||
var state: Dictionary = tree.root.get_meta(OWNER_META)
|
||||
if bool(state[&"closing"]):
|
||||
return {&"coordinator": false}
|
||||
state[&"closing"] = true
|
||||
var clock: Node = tree.root.get_node_or_null("GameClock")
|
||||
var clock_was_processing: bool = clock.is_processing() if is_instance_valid(clock) else false
|
||||
var windows: Array[Dictionary] = []
|
||||
_disable_window_input(tree.root, windows)
|
||||
if is_instance_valid(clock):
|
||||
clock.set_process(false)
|
||||
var targets: Array[Dictionary] = []
|
||||
var prepared: Array[int] = []
|
||||
_prepare_pending_owners(state, targets, prepared, windows)
|
||||
var initial := _states(targets)
|
||||
var started := Time.get_ticks_usec()
|
||||
await tree.process_frame
|
||||
while Time.get_ticks_usec() - started < MAXIMUM_WAIT_US:
|
||||
_prepare_pending_owners(state, targets, prepared, windows)
|
||||
if not _any_alive(targets):
|
||||
break
|
||||
await tree.process_frame
|
||||
var waited_us := Time.get_ticks_usec() - started
|
||||
var deadline_reached := waited_us >= MAXIMUM_WAIT_US
|
||||
# Closing lasts through the engine's final frame. Restoring here permits a
|
||||
# real Clock tick after this report and before SceneTree quit takes effect.
|
||||
# Keep only ephemeral process/GUI flags latched; saved values stay exact.
|
||||
var report := {&"coordinator": true, &"owned_engine_pid": OS.get_process_id(), &"owner_instance_ids": prepared,
|
||||
&"waited_actual_us": waited_us, &"maximum_wait_us": MAXIMUM_WAIT_US, &"deadline_reached": deadline_reached,
|
||||
&"initial_targets": initial, &"final_targets": _states(targets), &"all_owned_targets_released": not _any_alive(targets),
|
||||
&"clock_process_before": clock_was_processing, &"clock_process_held_stopped": is_instance_valid(clock) and not clock.is_processing(),
|
||||
&"window_input_held_disabled": _window_inputs_held_disabled(windows), &"original_window_input_flags": _window_input_records(windows),
|
||||
&"restored_before_quit": false,
|
||||
&"shared_bus_deleted": false, &"settings_mutated": false,
|
||||
&"passed": not deadline_reached and not _any_alive(targets) and _window_inputs_held_disabled(windows) and is_instance_valid(clock) and not clock.is_processing()}
|
||||
state[&"last_report"] = report
|
||||
# The static coordinator owns final quit, independently of a Main lifetime.
|
||||
# A normal close keeps Main alive; a QA removal cannot orphan this await.
|
||||
for reference: WeakRef in state[&"owners"]:
|
||||
var owner: Variant = reference.get_ref()
|
||||
if is_instance_valid(owner):
|
||||
owner.call(&"_report_audio_close_drained", report)
|
||||
if not bool(report[&"passed"]):
|
||||
push_error("UI audio close did not release its owners and hold input/clock stopped through final quit")
|
||||
tree.quit(0 if bool(report[&"passed"]) else 1)
|
||||
return report
|
||||
|
||||
|
||||
static func _disable_window_input(node: Node, windows: Array[Dictionary]) -> void:
|
||||
if node is Window:
|
||||
var window := node as Window
|
||||
var known := false
|
||||
for row: Dictionary in windows:
|
||||
known = known or (row[&"weak"] as WeakRef).get_ref() == window
|
||||
if not known:
|
||||
windows.append({&"weak": weakref(window), &"instance_id": window.get_instance_id(), &"gui_disable_input": window.gui_disable_input})
|
||||
window.gui_disable_input = true
|
||||
if window != node.get_tree().root:
|
||||
window.hide()
|
||||
for child: Node in node.get_children():
|
||||
_disable_window_input(child, windows)
|
||||
|
||||
|
||||
static func _window_input_records(windows: Array[Dictionary]) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for row: Dictionary in windows:
|
||||
var window: Variant = (row[&"weak"] as WeakRef).get_ref()
|
||||
result.append({&"instance_id": row[&"instance_id"], &"original_gui_disable_input": bool(row[&"gui_disable_input"]),
|
||||
&"alive_at_final_drain": is_instance_valid(window), &"gui_disable_input_at_final_drain": window.gui_disable_input if is_instance_valid(window) else null})
|
||||
return result
|
||||
|
||||
|
||||
static func _window_inputs_held_disabled(windows: Array[Dictionary]) -> bool:
|
||||
for row: Dictionary in windows:
|
||||
var window: Variant = (row[&"weak"] as WeakRef).get_ref()
|
||||
if is_instance_valid(window) and not window.gui_disable_input:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _prepare_pending_owners(state: Dictionary, targets: Array[Dictionary], prepared: Array[int], windows: Array[Dictionary]) -> void:
|
||||
for reference: WeakRef in state[&"owners"]:
|
||||
var owner: Variant = reference.get_ref()
|
||||
if not is_instance_valid(owner) or prepared.has(owner.get_instance_id()):
|
||||
continue
|
||||
prepared.append(owner.get_instance_id())
|
||||
_disable_window_input(owner, windows)
|
||||
targets.append_array(owner.call(&"_prepare_owned_audio_close"))
|
||||
|
||||
|
||||
static func _capture_targets(audio: Node) -> Array[Dictionary]:
|
||||
var rows: Array[Dictionary] = []
|
||||
if not is_instance_valid(audio):
|
||||
return rows
|
||||
for role: StringName in audio.get(&"_players"):
|
||||
var player: AudioStreamPlayer = audio.get(&"_players")[role]
|
||||
if player.stream != null:
|
||||
rows.append({&"role": role, &"kind": &"stream", &"weak": weakref(player.stream), &"instance_id": player.stream.get_instance_id()})
|
||||
var packets: Variant = player.stream.get(&"packet_sequence")
|
||||
if packets is Resource:
|
||||
rows.append({&"role": role, &"kind": &"packet_sequence", &"weak": weakref(packets), &"instance_id": packets.get_instance_id()})
|
||||
if player.has_stream_playback():
|
||||
var playback := player.get_stream_playback()
|
||||
rows.append({&"role": role, &"kind": &"playback", &"weak": weakref(playback), &"instance_id": playback.get_instance_id()})
|
||||
return rows
|
||||
|
||||
|
||||
static func _states(targets: Array[Dictionary]) -> Array[Dictionary]:
|
||||
var rows: Array[Dictionary] = []
|
||||
for target: Dictionary in targets:
|
||||
var resource: Variant = (target[&"weak"] as WeakRef).get_ref()
|
||||
rows.append({&"role": target[&"role"], &"kind": target[&"kind"], &"instance_id": target[&"instance_id"], &"alive": resource != null, &"class": resource.get_class() if resource != null else &""})
|
||||
return rows
|
||||
|
||||
|
||||
static func _any_alive(targets: Array[Dictionary]) -> bool:
|
||||
for row: Dictionary in _states(targets):
|
||||
if bool(row[&"alive"]):
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://bbuicrfga4f2t
|
||||
@@ -0,0 +1,106 @@
|
||||
class_name UIAudioSettingsDialog
|
||||
extends AcceptDialog
|
||||
|
||||
var _feedback: Variant
|
||||
var _style_button: Callable
|
||||
var _refreshing := false
|
||||
var master_slider: HSlider
|
||||
var ui_slider: HSlider
|
||||
var mute_button: CheckButton
|
||||
var status_label: Label
|
||||
var _master_label: Label
|
||||
var _ui_label: Label
|
||||
|
||||
|
||||
func configure(feedback: Variant, style_button := Callable()) -> void:
|
||||
_feedback = feedback
|
||||
_style_button = style_button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
name = "UIAudioSettingsDialog"
|
||||
title = "音の設定"
|
||||
ok_button_text = "設定を保存して閉じる"
|
||||
var column := VBoxContainer.new()
|
||||
column.add_theme_constant_override("separation", 12)
|
||||
add_child(column)
|
||||
_master_label = Label.new()
|
||||
column.add_child(_master_label)
|
||||
master_slider = _slider("MasterVolumeSlider")
|
||||
column.add_child(master_slider)
|
||||
_ui_label = Label.new()
|
||||
column.add_child(_ui_label)
|
||||
ui_slider = _slider("UIVolumeSlider")
|
||||
column.add_child(ui_slider)
|
||||
mute_button = CheckButton.new()
|
||||
mute_button.name = "MuteAudioButton"
|
||||
mute_button.text = "すべての音をミュート"
|
||||
column.add_child(mute_button)
|
||||
if _style_button.is_valid():
|
||||
_style_button.call(mute_button)
|
||||
_style_button.call(get_ok_button())
|
||||
status_label = Label.new()
|
||||
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
column.add_child(status_label)
|
||||
master_slider.value_changed.connect(_on_volume_changed)
|
||||
ui_slider.value_changed.connect(_on_volume_changed)
|
||||
mute_button.toggled.connect(_on_mute_changed)
|
||||
confirmed.connect(_save_settings)
|
||||
refresh()
|
||||
|
||||
|
||||
func _slider(node_name: String) -> HSlider:
|
||||
var slider := HSlider.new()
|
||||
slider.name = node_name
|
||||
slider.min_value = 0.0
|
||||
slider.max_value = 100.0
|
||||
slider.step = 1.0
|
||||
slider.custom_minimum_size.x = 340
|
||||
return slider
|
||||
|
||||
|
||||
func show_settings() -> void:
|
||||
refresh()
|
||||
popup_centered(Vector2i(440, 280))
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if _feedback == null or master_slider == null:
|
||||
return
|
||||
_refreshing = true
|
||||
master_slider.set_value_no_signal(float(_feedback.settings[&"master_volume"]) * 100.0)
|
||||
ui_slider.set_value_no_signal(float(_feedback.settings[&"ui_volume"]) * 100.0)
|
||||
mute_button.set_pressed_no_signal(bool(_feedback.settings[&"muted"]))
|
||||
_refreshing = false
|
||||
_refresh_labels()
|
||||
status_label.text = "設定はすぐに音へ反映されます。保存すると次の起動でも使用します。"
|
||||
if not _feedback.settings_writable:
|
||||
status_label.text = "既存の設定を保護するため保存できません。今回の音量変更はこの起動中だけ有効です。"
|
||||
get_ok_button().disabled = not _feedback.settings_writable
|
||||
|
||||
|
||||
func _on_volume_changed(_value: float) -> void:
|
||||
_update_settings()
|
||||
|
||||
|
||||
func _on_mute_changed(_enabled: bool) -> void:
|
||||
_update_settings()
|
||||
|
||||
|
||||
func _update_settings() -> void:
|
||||
if _refreshing or _feedback == null:
|
||||
return
|
||||
_feedback.update_settings({&"master_volume": master_slider.value / 100.0, &"ui_volume": ui_slider.value / 100.0, &"muted": mute_button.button_pressed})
|
||||
_refresh_labels()
|
||||
|
||||
|
||||
func _refresh_labels() -> void:
|
||||
_master_label.text = "マスター音量 %d%%" % int(master_slider.value)
|
||||
_ui_label.text = "UI操作音 %d%%" % int(ui_slider.value)
|
||||
|
||||
|
||||
func _save_settings() -> void:
|
||||
var result: Dictionary = _feedback.save_settings()
|
||||
if not bool(result.get(&"ok", false)):
|
||||
status_label.text = "音の設定を保存できませんでした。現在の音量はこの起動中だけ使用します。"
|
||||
call_deferred(&"popup_centered", Vector2i(440, 280))
|
||||
@@ -0,0 +1 @@
|
||||
uid://yrogab617bce
|
||||
@@ -11,6 +11,7 @@ const LABELS := {
|
||||
&"important_rank_5":"重要クエスト", &"main_chapter_complete":"本編の章目標",
|
||||
}
|
||||
var _runtime: Variant
|
||||
var _audio_submitter := Callable()
|
||||
var _target := -1
|
||||
var _pending_id := 0
|
||||
var _queue_id := 0
|
||||
@@ -62,7 +63,7 @@ func refresh() -> void:
|
||||
get_ok_button().disabled=rank>=ProgressionService.MAX_RANK or not evaluation.get(&"eligible",false) or _pending_id!=0
|
||||
func _promote() -> void:
|
||||
if _pending_id!=0 or _target<2:return
|
||||
var result: CommandResult=_runtime.submit_command(&"set_world_tree_rank",{&"rank":_target,&"request_id":StringName("ui_rank_%d" % _target)})
|
||||
var result: CommandResult=_submit_ui_command(&"set_world_tree_rank",{&"rank":_target,&"request_id":StringName("ui_rank_%d" % _target)})
|
||||
for command: Dictionary in _runtime.command_queue.create_snapshot().get("pending",[]):
|
||||
if result.is_success() and int(command["command_id"])==result.command_id:
|
||||
_pending_id=result.command_id
|
||||
@@ -121,3 +122,17 @@ func apply_layer_skin(layer: int) -> void:
|
||||
LayerSkin.register_text(get_label(), layer)
|
||||
for button: Button in [get_ok_button(), get_cancel_button()]:
|
||||
LayerSkin.register_toggle_text(button, layer)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user