220 lines
9.9 KiB
GDScript
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)
|