class_name OperationFeedback extends RefCounted ## Operation and notification UX helpers: rejection explanations, ## overhead unit bubbles, and important warning cut-in dialogs. ## Presentation-only: never modifies the simulation model or ledger directly. const BUBBLE_NONE := &"" const BUBBLE_ZZZ := &"zzz" const BUBBLE_ANGER := &"anger" const BUBBLE_HEART := &"heart" const CUTIN_INVASION := &"invasion" const CUTIN_DEFENSE := &"defense" const CUTIN_DUEL := &"duel" const ASSIGNMENT_REASONS := { &"STAFF_NOT_FOUND": "従業員が見つかりません。", &"FACILITY_NOT_FOUND": "配属先が見つかりません。施設が完成しているか確認してください。", &"ROLE_MISMATCH": "この施設には役割が合いません。必要な職種を確認してください。", &"NO_ACCESS": "従業員が配属先へ移動できません。通路と従業員ドアを確認してください。", &"NO_PATH": "従業員が配属先へ移動できません。通路と従業員ドアを確認してください。", &"STAFF_BUSY": "作業中のため変更できません。作業完了後に再試行してください。", &"CAPACITY_EXCEEDED": "配属先の人員上限を超えています。", &"FACILITY_FULL": "配属先の人員上限を超えています。", &"NO_ROUTE": "配属先へ移動できません。通路と従業員ドアを確認してください。", &"INVALID_TEMPLATE": "日勤または夜勤を選択してください。", &"STAFF_UNAVAILABLE": "この従業員は現在、勤務を変更できません。状態をご確認ください。", &"DEFENSE_LOCKED": "防衛参加中は勤務を変更できません。", &"EVENT_LOCKED": "イベント参加中は勤務を変更できません。", &"FACILITY_UNSTAFFED": "この施設は人員を配属する施設ではありません。", &"SHIFT_CONFLICT": "既存の勤務と時間が重なります。勤務予定を確認してください。", &"BANKRUPT": "運営終了後は配属を変更できません。", } const PLACEMENT_REASONS := { "INVALID_DEFINITION": "施設データが不正です", "MISSING_DEPENDENCY": "建設システムを利用できません", "OUT_OF_BOUNDS": "世界の範囲外です", "ZONE_MISMATCH": "地上・境界・地下の条件が合いません", "LAYER_MISMATCH": "表層/裏層が合いません", "OVERLAP": "ほかの施設または工事と重なります", "UNSUPPORTED": "構造を支えられません", "INSUFFICIENT_RESOURCES": "建設資源が不足しています", } static func resolve_staff_bubble(staff: Variant, relationship: Variant = null) -> StringName: if staff == null: return BUBBLE_NONE var state_val: int = int(staff.get(&"state")) if staff is Object and "state" in staff else (int(staff.get("state", -1)) if staff is Dictionary else -1) # Sleeping, incapacitated, or on break corresponds to Zzz if state_val in [StaffRuntime.State.SLEEPING, StaffRuntime.State.INCAPACITATED, StaffRuntime.State.BREAKING]: return BUBBLE_ZZZ # Strike or fleeing corresponds to anger (no fictional morale thresholds) if state_val in [StaffRuntime.State.STRIKE, StaffRuntime.State.FLEEING]: return BUBBLE_ANGER # Heart must derive from actual existing intimacy/love-shield state with source authority if relationship != null: var shield := 0 var route_lock_val := 0 var affection := 0 if relationship is RelationshipState: shield = relationship.shield_current route_lock_val = int(relationship.route_lock) affection = int(relationship.metrics.get(&"affection", 0)) elif relationship is Dictionary: shield = int(relationship.get(&"shield_current", relationship.get("shield_current", 0))) route_lock_val = int(relationship.get(&"route_lock", relationship.get("route_lock", 0))) var m: Variant = relationship.get(&"metrics", relationship.get("metrics", {})) if m is Dictionary: affection = int(m.get(&"affection", m.get("affection", 0))) elif relationship is Object: if "shield_current" in relationship: shield = int(relationship.get(&"shield_current")) if "route_lock" in relationship: route_lock_val = int(relationship.get(&"route_lock")) if "metrics" in relationship: var m: Variant = relationship.get(&"metrics") if m is Dictionary: affection = int(m.get(&"affection", m.get("affection", 0))) if shield > 0 or affection > 0 or route_lock_val in [RelationshipState.RouteLock.EXCLUSIVE_PENDING, RelationshipState.RouteLock.EXCLUSIVE_LOCKED]: return BUBBLE_HEART # Without authoritative relationship intimacy / love-shield state, mapping is unmapped/none return BUBBLE_NONE static func resolve_guest_bubble(guest: Variant, relationship: Variant = null) -> StringName: if guest == null: return BUBBLE_NONE var state_val: int = int(guest.get(&"state")) if guest is Object and "state" in guest else (int(guest.get("state", -1)) if guest is Dictionary else -1) # Incapacitated corresponds to Zzz if state_val == GuestRuntime.State.INCAPACITATED: return BUBBLE_ZZZ # Abandoning, fleeing, interrupted, or out of patience in queue corresponds to anger if state_val in [GuestRuntime.State.ABANDONING, GuestRuntime.State.FLEEING, GuestRuntime.State.INTERRUPTED]: return BUBBLE_ANGER var patience: int = int(guest.get(&"patience_remaining")) if guest is Object and "patience_remaining" in guest else (int(guest.get("patience_remaining", 1)) if guest is Dictionary else 1) if state_val == GuestRuntime.State.QUEUING and patience <= 0: return BUBBLE_ANGER # Heart must derive from actual existing intimacy/love-shield state with source authority if relationship != null: var shield := 0 var route_lock_val := 0 var affection := 0 if relationship is RelationshipState: shield = relationship.shield_current route_lock_val = int(relationship.route_lock) affection = int(relationship.metrics.get(&"affection", 0)) elif relationship is Dictionary: shield = int(relationship.get(&"shield_current", relationship.get("shield_current", 0))) route_lock_val = int(relationship.get(&"route_lock", relationship.get("route_lock", 0))) var m: Variant = relationship.get(&"metrics", relationship.get("metrics", {})) if m is Dictionary: affection = int(m.get(&"affection", m.get("affection", 0))) elif relationship is Object: if "shield_current" in relationship: shield = int(relationship.get(&"shield_current")) if "route_lock" in relationship: route_lock_val = int(relationship.get(&"route_lock")) if "metrics" in relationship: var m: Variant = relationship.get(&"metrics") if m is Dictionary: affection = int(m.get(&"affection", m.get("affection", 0))) if shield > 0 or affection > 0 or route_lock_val in [RelationshipState.RouteLock.EXCLUSIVE_PENDING, RelationshipState.RouteLock.EXCLUSIVE_LOCKED]: return BUBBLE_HEART return BUBBLE_NONE static func get_assignment_rejection_text(reason: StringName) -> String: return ASSIGNMENT_REASONS.get(reason, "配属を変更できませんでした。条件を確認してください。") static func get_placement_rejection_text(report: Dictionary) -> String: var reasons: Array = report.get(&"reasons", report.get("reasons", [])) var error_codes: Array = report.get(&"error_codes", report.get("error_codes", [])) if not error_codes.is_empty(): var code: String = String(error_codes[0]) if PLACEMENT_REASONS.has(code): return PLACEMENT_REASONS[code] if not reasons.is_empty(): return String(reasons[0]) return "指定したマスには建築できません。配置条件を確認してください。" ## Important warning cut-in dialog: invasion start, duel, defense start class ImportantWarningCutinDialog extends AcceptDialog: signal cutin_dismissed(kind: StringName) var _current_kind: StringName = &"" var _queue: Array[Dictionary] = [] var _cutin_active := false var _pre_cutin_user_paused := false var _title_label: Label var _body_label: Label func _init() -> void: name = "ImportantWarningCutinDialog" title = "重要警告" ok_button_text = "確認" unresizable = true min_size = Vector2i(480, 200) size = Vector2i(480, 200) var vbox := VBoxContainer.new() vbox.custom_minimum_size = Vector2(460, 140) vbox.add_theme_constant_override("separation", 10) add_child(vbox) _title_label = Label.new() _title_label.name = "CutinTitleLabel" _title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _title_label.add_theme_font_size_override("font_size", 16) _title_label.add_theme_color_override("font_color", Color("ff7b7b")) vbox.add_child(_title_label) _body_label = Label.new() _body_label.name = "CutinBodyLabel" _body_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _body_label.size_flags_vertical = Control.SIZE_EXPAND_FILL _body_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _body_label.add_theme_font_size_override("font_size", 13) vbox.add_child(_body_label) confirmed.connect(_on_dismissed) canceled.connect(_on_dismissed) close_requested.connect(_on_dismissed) func show_cutin(kind: StringName, heading: String, message: String) -> void: if _cutin_active: _queue.append({&"kind": kind, &"heading": heading, &"message": message}) return _cutin_active = true var clock := get_node_or_null("/root/GameClock") if clock != null: _pre_cutin_user_paused = bool(clock.get("paused")) if not _pre_cutin_user_paused: clock.set_paused(true) _display_cutin(kind, heading, message) func _display_cutin(kind: StringName, heading: String, message: String) -> void: _current_kind = kind _title_label.text = heading _body_label.text = message popup_centered(Vector2i(500, 220)) get_ok_button().grab_focus() func _on_dismissed() -> void: var kind := _current_kind _current_kind = &"" cutin_dismissed.emit(kind) if not _queue.is_empty(): var next: Dictionary = _queue.pop_front() _display_cutin(next[&"kind"], next[&"heading"], next[&"message"]) else: _cutin_active = false var clock := get_node_or_null("/root/GameClock") if clock != null and not _pre_cutin_user_paused: clock.set_paused(false)