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:
Takeshi
2026-10-11 15:58:00 +09:00
parent 39f6068461
commit b84bade045
235 changed files with 495831 additions and 312 deletions
+160 -5
View File
@@ -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)
+41 -11
View File
@@ -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)
+315
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://ddbo74xrnvpxd
+10 -3
View File
@@ -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
+1 -1
View File
@@ -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)))
+54
View File
@@ -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"))
+1
View File
@@ -0,0 +1 @@
uid://dc4xv2kx0ah3u
+144
View File
@@ -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
+174 -1
View File
@@ -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)
+108
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://cve5judsgtgcy
+16
View File
@@ -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)
+13 -2
View File
@@ -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":
+6
View File
@@ -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"])
+32 -1
View File
@@ -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)
+142
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bophxjq1fck3q
+27 -4
View File
@@ -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)
+329
View File
@@ -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}
+1
View File
@@ -0,0 +1 @@
uid://u38heyutw0xp
+171
View File
@@ -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
+106
View File
@@ -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
+16 -1
View File
@@ -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)