Files
TheTree_mock/scripts/ui/operation_feedback.gd
T

220 lines
9.9 KiB
GDScript

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)