3203 lines
138 KiB
GDScript
3203 lines
138 KiB
GDScript
extends Control
|
||
|
||
const UIAudioFeedbackScript := preload("res://scripts/ui/ui_audio_feedback.gd")
|
||
const UIAudioSettingsDialogScript := preload("res://scripts/ui/ui_audio_settings_dialog.gd")
|
||
const UIAudioOwnerShutdownScript := preload("res://scripts/ui/ui_audio_owner_shutdown.gd")
|
||
const PairedBGMScript := preload("res://scripts/ui/paired_bgm.gd")
|
||
var _paired_bgm: Variant
|
||
signal ui_audio_window_close_drained(report: Dictionary)
|
||
var audio_settings_path := "user://settings/audio.cfg"
|
||
var _audio_feedback: Variant
|
||
var _audio_settings_dialog: Variant
|
||
var _window_close_in_progress := false
|
||
|
||
const RankDialogScript := preload("res://scripts/ui/world_tree_rank_dialog.gd")
|
||
const EventLogDialogScript := preload("res://scripts/ui/event_log_dialog.gd")
|
||
const ManualSaveSlotsDialogScript := preload("res://scripts/ui/manual_save_slots_dialog.gd")
|
||
|
||
const OpeningGuideScript := preload("res://scripts/ui/inn_opening_guide.gd")
|
||
const OperatingLedgerPanelScript := preload("res://scripts/ui/operating_ledger_panel.gd")
|
||
const StaffManagementPanelScript := preload("res://scripts/ui/staff_management_panel.gd")
|
||
const SupplyManagementPanelScript := preload("res://scripts/ui/supply_management_panel.gd")
|
||
const BusinessManagementPanelScript := preload("res://scripts/ui/business_management_panel.gd")
|
||
const DefenseManagementPanelScript := preload("res://scripts/ui/defense_management_panel.gd")
|
||
const WorldPreviewScript := preload("res://scenes/main/world_preview.gd")
|
||
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
|
||
const DefaultVisualTheme := preload("res://assets/themes/japanese_fantasy/visual-theme-v1.tres")
|
||
var _last_presented_close_minute := 0
|
||
|
||
const MANUAL_SAVE_PATH := "user://saves/manual_ui"
|
||
const MVP_FACILITY_IDS: Array[StringName] = [
|
||
&"cleaning_room",
|
||
&"chapel",
|
||
&"reception",
|
||
&"standard_room",
|
||
&"premium_room",
|
||
&"cafeteria",
|
||
&"warehouse",
|
||
&"kitchen",
|
||
&"public_bath",
|
||
&"staff_waiting_room",
|
||
&"staff_dormitory",
|
||
&"infirmary",
|
||
&"security_station",
|
||
&"tavern",
|
||
&"massage_room",
|
||
&"guest_elevator",
|
||
&"service_elevator",
|
||
&"guest_stairs",
|
||
&"service_stairs",
|
||
&"staff_door",
|
||
&"guest_corridor",
|
||
&"service_corridor",
|
||
&"adult_private_room",
|
||
]
|
||
const FACILITY_STATE_NAMES := [
|
||
"故障",
|
||
"到達不能",
|
||
"魔力不足",
|
||
"人員不足",
|
||
"閉鎖中",
|
||
"低下運転",
|
||
"営業中",
|
||
]
|
||
const ELEVATOR_PHASE_NAMES := {&"IDLE": "待機", &"PICKUP": "迎車中", &"BOARDING": "乗車中", &"TRAVEL": "運行中", &"ALIGHTING": "降車中"}
|
||
const FACILITY_REASON_NAMES := {
|
||
&"BROKEN": "故障",
|
||
&"DESTROYED": "破壊による休止",
|
||
&"RAIDED": "摘発による休止",
|
||
&"RELATED_FACILITY_INACTIVE": "関連施設が未稼働",
|
||
&"NO_ACCESS": "経路なし",
|
||
&"NO_POWER": "魔力供給なし",
|
||
&"NO_STAFF": "必要人員不足",
|
||
&"CLOSED_BY_OPERATOR": "手動閉鎖",
|
||
&"OUTSIDE_OPENING_HOURS": "営業時間外",
|
||
&"LOW_DURABILITY": "耐久度低下",
|
||
}
|
||
const PREVIEW_REASON_NAMES := {
|
||
"INVALID_DEFINITION": "施設データが不正です",
|
||
"MISSING_DEPENDENCY": "建設システムを利用できません",
|
||
"OUT_OF_BOUNDS": "世界の範囲外です",
|
||
"ZONE_MISMATCH": "地上・境界・地下の条件が合いません",
|
||
"LAYER_MISMATCH": "表層/裏層が合いません",
|
||
"OVERLAP": "ほかの施設または工事と重なります",
|
||
"UNSUPPORTED": "構造を支えられません",
|
||
"INSUFFICIENT_RESOURCES": "建設資源が不足しています",
|
||
}
|
||
const EXCAVATION_REASON_NAMES := {
|
||
&"OUT_OF_BOUNDS": "世界の範囲外です",
|
||
&"NOT_UNDERGROUND": "地下のマスだけ掘削できます",
|
||
&"ALREADY_EXCAVATED": "すでに掘削済みです",
|
||
&"ALREADY_RESERVED": "このマスは掘削中です",
|
||
&"NO_EXCAVATED_NEIGHBOR": "掘削済みの隣接マスから延ばしてください",
|
||
&"NO_ENTRANCE_CONNECTION": "地上入口までつながる経路がありません",
|
||
&"SUPPORT_REQUIRED": "硬い地盤を支える補強施設が必要です",
|
||
&"INSUFFICIENT_RESOURCES": "掘削資源が不足しています",
|
||
&"MISSING_DEPENDENCY": "掘削システムを利用できません",
|
||
}
|
||
const EVENT_CHOICE_NAMES := {
|
||
&"event.relationship.threshold.acknowledge": "向き合う",
|
||
&"event.relationship.threshold.defer": "今は保留する",
|
||
}
|
||
const STAFF_ROLE_NAMES := {
|
||
&"attendant": "接客",
|
||
&"bartender": "酒場",
|
||
&"clerk": "会計",
|
||
&"cleaner": "清掃",
|
||
&"cook": "調理",
|
||
&"guard": "警備",
|
||
&"maintenance": "保守",
|
||
&"masseur": "施術",
|
||
&"medic": "医療",
|
||
&"porter": "物流",
|
||
&"receptionist": "受付", &"innkeeper": "女将", &"cleric": "聖職者",
|
||
}
|
||
const STAFF_SHIFT_NAMES := {
|
||
&"day": "日勤",
|
||
&"night": "夜勤",
|
||
&"any": "柔軟勤務",
|
||
}
|
||
const STAFF_SKILL_NAMES := {
|
||
&"service": "接客",
|
||
&"operation": "運営",
|
||
&"combat": "戦闘",
|
||
&"underworld": "裏社会",
|
||
}
|
||
|
||
@export var visual_theme: VisualThemeProfile = DefaultVisualTheme
|
||
var manual_save_path := MANUAL_SAVE_PATH
|
||
var manual_save_slots_dialog: ConfirmationDialog
|
||
|
||
var time_label: Label
|
||
var phase_label: Label
|
||
var rank_button: Button
|
||
var rank_dialog: ConfirmationDialog
|
||
var event_log_dialog: AcceptDialog
|
||
var weather_label: Label
|
||
var guest_count_label: Button
|
||
var dimension_migration_dialog: ConfirmationDialog
|
||
var _dimension_migration_digest := ""
|
||
var guest_observer: AcceptDialog
|
||
var lodging_count_label: Label
|
||
var alert_count_label: Label
|
||
var defense_manager: AcceptDialog
|
||
var alert_icon_node: TextureRect
|
||
var resource_labels: Dictionary = {}
|
||
var resource_icon_nodes: Dictionary = {}
|
||
var world_preview: WorldPreview
|
||
var front_button: Button
|
||
var back_button: Button
|
||
var excavation_button: Button
|
||
var demolition_button: Button
|
||
var demolition_dialog: ConfirmationDialog
|
||
var _demolition_mode := false
|
||
var _demolition_target: StringName = &""
|
||
var _pending_demolition_commands: Dictionary = {}
|
||
var pan_button: Button
|
||
var pause_button: Button
|
||
var speed_buttons: Dictionary = {}
|
||
var dashboard_summary_label: Label
|
||
var foreground_event_panel: VBoxContainer
|
||
var foreground_event_label: Label
|
||
var foreground_event_choices: VBoxContainer
|
||
var staff_management_panel: VBoxContainer
|
||
var staff_management_toggle_button: Button
|
||
var _staff_management_panel_open := false
|
||
var staff_candidate_panel: VBoxContainer
|
||
var staff_candidate_summary_label: Label
|
||
var staff_candidate_toggle_button: Button
|
||
var staff_candidate_buttons: Dictionary = {}
|
||
var selected_heading_label: Label
|
||
var selected_detail_label: Label
|
||
var related_facility_picker: OptionButton
|
||
var _pending_related_commands: Dictionary = {}
|
||
var selected_entity_icon: TextureRect
|
||
var selected_elevator_controls: VBoxContainer
|
||
var elevator_operation_button: Button
|
||
var elevator_operation_hint: Label
|
||
var build_status_label: Label
|
||
var build_buttons: Dictionary = {}
|
||
var navigation_buttons: Dictionary = {}
|
||
var _interface_layer := -1
|
||
var opening_guide: Control
|
||
var operating_ledger_panel: VBoxContainer
|
||
var supply_management_panel: VBoxContainer
|
||
var business_management_panel: VBoxContainer
|
||
var _business_panel_open := false
|
||
var _supply_panel_open := false
|
||
var operations_controls: VBoxContainer
|
||
var _ledger_panel_open := false
|
||
var management_panel: Control
|
||
var management_toggle_button: Button
|
||
var palette_scroll: ScrollContainer
|
||
var palette_toggle_button: Button
|
||
var _management_panel_open := false
|
||
var _build_palette_open := false
|
||
|
||
var _runtime: Variant = null
|
||
var _selected_entity_id: StringName = &""
|
||
var _selected_entity_kind: StringName = &""
|
||
var _selected_definition_id: StringName = &""
|
||
var _selected_definition: Variant = null
|
||
var _build_rotation := 0
|
||
var _build_sequence := 1
|
||
var _excavation_sequence := 1
|
||
var _hire_sequence := 1
|
||
var _elevator_sequence := 1
|
||
var _pending_elevator_commands: Dictionary = {}
|
||
var _pending_build_commands: Dictionary = {}
|
||
var _excavation_mode := false
|
||
var _pending_excavation_commands: Dictionary = {}
|
||
var _pending_hire_commands: Dictionary = {}
|
||
var _staff_candidate_panel_open := false
|
||
var _operations_start_report: Dictionary = {}
|
||
|
||
|
||
func _ready() -> void:
|
||
_runtime = get_node_or_null("/root/SimulationRuntime")
|
||
_pause_fresh_opening()
|
||
if _runtime != null and _runtime.has_method("enable_production_save_policy"):
|
||
_runtime.enable_production_save_policy()
|
||
if _runtime != null \
|
||
and _runtime.has_method("get_operations_report") \
|
||
and _runtime.has_method("enable_production_operations"):
|
||
var current_operations: Dictionary = _runtime.call(&"get_operations_report")
|
||
if not bool(current_operations.get(&"enabled", false)):
|
||
_operations_start_report = _runtime.call(&"enable_production_operations")
|
||
_synchronize_ui_command_state()
|
||
_setup_ui_audio()
|
||
UIAudioOwnerShutdownScript.register_owner(get_tree(), self)
|
||
get_tree().root.close_requested.connect(_on_window_close_requested)
|
||
_build_interface()
|
||
_connect_signals()
|
||
_populate_build_palette()
|
||
_populate_staff_candidates()
|
||
_on_time_changed(GameClock.day, GameClock.get_hour(), GameClock.get_minute())
|
||
_on_phase_changed(GameClock.get_phase())
|
||
for resource_name: StringName in GameState.resources:
|
||
_on_resource_changed(resource_name, GameState.get_resource(resource_name))
|
||
_set_layer(GameState.active_layer)
|
||
_update_pause_and_speed()
|
||
_refresh_dashboard()
|
||
if not _operations_start_report.is_empty() \
|
||
and not bool(_operations_start_report.get(&"ok", false)):
|
||
_set_status(
|
||
"本番運営を開始できませんでした: %s" % _result_message(_operations_start_report),
|
||
false
|
||
)
|
||
_focus_initial_view.call_deferred()
|
||
|
||
|
||
func _exit_tree() -> void:
|
||
var tree := get_tree()
|
||
if tree != null:
|
||
if tree.root.close_requested.is_connected(_on_window_close_requested):
|
||
tree.root.close_requested.disconnect(_on_window_close_requested)
|
||
UIAudioOwnerShutdownScript.unregister_owner(tree, self)
|
||
|
||
|
||
func _on_window_close_requested() -> void:
|
||
# The shared coordinator drains every registered Main before one quit.
|
||
# Main stays alive while its audio child is freed and the backend releases.
|
||
var tree := get_tree()
|
||
UIAudioOwnerShutdownScript.close_owners(tree)
|
||
|
||
|
||
func _report_audio_close_drained(report: Dictionary) -> void:
|
||
ui_audio_window_close_drained.emit(report)
|
||
|
||
|
||
func _prepare_owned_audio_close() -> Array[Dictionary]:
|
||
_window_close_in_progress = true
|
||
set_process(false)
|
||
set_process_input(false)
|
||
set_process_unhandled_input(false)
|
||
set_process_unhandled_key_input(false)
|
||
hide()
|
||
var audio: Node = _audio_feedback as Node
|
||
_audio_feedback = null
|
||
var targets := UIAudioOwnerShutdownScript.capture_and_release(audio)
|
||
if is_instance_valid(_paired_bgm):
|
||
targets.append_array(_paired_bgm.capture_owned_targets())
|
||
_paired_bgm.prepare_owner_shutdown()
|
||
_paired_bgm.queue_free()
|
||
_paired_bgm = null
|
||
return targets
|
||
|
||
|
||
func _focus_initial_view() -> void:
|
||
await get_tree().process_frame
|
||
world_preview.focus_operating_area()
|
||
|
||
|
||
func _pause_fresh_opening() -> void:
|
||
if _runtime == null or GameClock.absolute_minute != 360:
|
||
return
|
||
if not (_runtime.get(&"latest_restore_safety_report") as Dictionary).is_empty():
|
||
return
|
||
var operations: Dictionary = _runtime.call(&"get_operations_report")
|
||
if bool(operations.get(&"enabled", false)):
|
||
return
|
||
if not _call_array(_service(&"construction_service"), &"get_facilities").is_empty() \
|
||
or not _call_array(_service(&"construction_service"), &"get_jobs").is_empty() \
|
||
or not _call_array(_service(&"staff_service"), &"get_staff_members").is_empty():
|
||
return
|
||
var queue: Variant = _runtime.get(&"command_queue")
|
||
if queue != null and not queue.get_accepted_command_metadata().is_empty():
|
||
return
|
||
GameClock.set_paused(true)
|
||
|
||
|
||
func _unhandled_key_input(event: InputEvent) -> void:
|
||
if not event.pressed or event.echo:
|
||
return
|
||
var handled := true
|
||
match event.keycode:
|
||
KEY_ESCAPE:
|
||
_clear_active_tool()
|
||
KEY_E:
|
||
_toggle_excavation_mode()
|
||
KEY_H:
|
||
_toggle_staff_candidate_panel()
|
||
KEY_B:
|
||
_toggle_build_palette()
|
||
KEY_I:
|
||
_set_management_panel_open(not _management_panel_open)
|
||
KEY_SPACE:
|
||
_toggle_pause()
|
||
KEY_TAB:
|
||
_set_layer(1 - world_preview.layer_mode)
|
||
KEY_1:
|
||
_set_speed(1)
|
||
KEY_2:
|
||
_set_speed(2)
|
||
KEY_3:
|
||
_set_speed(3)
|
||
KEY_R:
|
||
if _selected_definition == null:
|
||
handled = false
|
||
else:
|
||
_rotate_build_preview()
|
||
_:
|
||
handled = false
|
||
if handled:
|
||
if _audio_feedback != null:
|
||
_audio_feedback.request_click()
|
||
get_viewport().set_input_as_handled()
|
||
|
||
|
||
func _build_interface() -> void:
|
||
var background := ColorRect.new()
|
||
background.name = "InterfaceBackground"
|
||
background.color = _visual_color(&"window_background", Color("24170f"))
|
||
background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
background.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
add_child(background)
|
||
|
||
var root_margin := MarginContainer.new()
|
||
root_margin.add_theme_constant_override("margin_left", 6)
|
||
root_margin.add_theme_constant_override("margin_top", 6)
|
||
root_margin.add_theme_constant_override("margin_right", 6)
|
||
root_margin.add_theme_constant_override("margin_bottom", 6)
|
||
root_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||
add_child(root_margin)
|
||
|
||
var layout := VBoxContainer.new()
|
||
layout.add_theme_constant_override("separation", 4)
|
||
root_margin.add_child(layout)
|
||
layout.add_child(_create_top_bar())
|
||
|
||
var content := HBoxContainer.new()
|
||
content.name = "MainContent"
|
||
content.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
content.add_theme_constant_override("separation", 4)
|
||
layout.add_child(content)
|
||
content.add_child(_create_navigation_rail())
|
||
|
||
var map_panel := PanelContainer.new()
|
||
map_panel.name = "MapPanel"
|
||
map_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
map_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
map_panel.add_theme_stylebox_override(
|
||
"panel",
|
||
_themed_panel_style(&"panel_outer", _visual_color(&"window_background", Color("1e1712")), _visual_color(&"border_gold", Color("9b6a32")), 8, 5)
|
||
)
|
||
content.add_child(map_panel)
|
||
world_preview = WorldPreviewScript.new()
|
||
world_preview.name = "WorldPreview"
|
||
world_preview.custom_minimum_size = Vector2(600, 380)
|
||
world_preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
world_preview.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
world_preview.set_visual_theme(visual_theme)
|
||
map_panel.add_child(world_preview)
|
||
opening_guide = OpeningGuideScript.new()
|
||
world_preview.add_child(opening_guide)
|
||
opening_guide.setup(_runtime, GameClock, GameState.active_layer)
|
||
opening_guide.position = Vector2(8, 8)
|
||
opening_guide.size = Vector2(302, 0)
|
||
opening_guide.action_requested.connect(_on_opening_action)
|
||
# The inspector overlays the map only when requested, leaving the full
|
||
# cross-section available while watching the simulation.
|
||
management_panel = _create_side_panel()
|
||
world_preview.add_child(management_panel)
|
||
management_panel.set_anchors_and_offsets_preset(Control.PRESET_RIGHT_WIDE)
|
||
management_panel.offset_left = -320
|
||
management_panel.offset_right = -8
|
||
management_panel.offset_top = 8
|
||
management_panel.offset_bottom = -8
|
||
management_panel.visible = false
|
||
|
||
layout.add_child(_create_bottom_bar())
|
||
|
||
|
||
func _create_top_bar() -> Control:
|
||
var panel := PanelContainer.new()
|
||
panel.name = "TopBar"
|
||
panel.custom_minimum_size.y = 46
|
||
panel.add_theme_stylebox_override("panel", _themed_panel_style(&"panel_wood", _visual_color(&"dark_panel", Color("6b3e1d")), _visual_color(&"border_gold", Color("d5a552")), 8, 8))
|
||
var row := HBoxContainer.new()
|
||
row.add_theme_constant_override("separation", 7)
|
||
panel.add_child(row)
|
||
|
||
var title_box := VBoxContainer.new()
|
||
title_box.add_theme_constant_override("separation", 0)
|
||
row.add_child(title_box)
|
||
var title := Label.new()
|
||
title.text = "THE TREE"
|
||
title.custom_minimum_size.x = 98
|
||
title.add_theme_font_size_override("font_size", 17)
|
||
title.add_theme_color_override("font_color", Color("fff0c8"))
|
||
title_box.add_child(title)
|
||
rank_button=Button.new()
|
||
rank_button.name="WorldTreeRankButton"
|
||
rank_button.text="世界樹ランク 1"
|
||
rank_button.flat=true
|
||
rank_button.alignment=HORIZONTAL_ALIGNMENT_LEFT
|
||
rank_button.add_theme_font_size_override("font_size",10)
|
||
rank_button.add_theme_stylebox_override("normal",StyleBoxEmpty.new())
|
||
rank_button.tooltip_text="世界樹のランクと昇格条件を確認"
|
||
title_box.add_child(rank_button)
|
||
rank_dialog=RankDialogScript.new()
|
||
rank_dialog.configure(_runtime)
|
||
rank_dialog.configure_audio(Callable(_audio_feedback, "submit_command"))
|
||
_audio_feedback.bind_button(rank_dialog.get_ok_button())
|
||
_audio_feedback.bind_button(rank_dialog.get_cancel_button())
|
||
rank_dialog.status_requested.connect(func(message: String, success: bool) -> void:
|
||
_set_status(message,success)
|
||
_refresh_dashboard()
|
||
)
|
||
add_child(rank_dialog)
|
||
rank_button.pressed.connect(rank_dialog.show_rank)
|
||
|
||
row.add_child(VSeparator.new())
|
||
time_label = Label.new()
|
||
time_label.custom_minimum_size.x = 104
|
||
time_label.add_theme_font_size_override("font_size", 12)
|
||
time_label.add_theme_color_override("font_color", Color("fff5d9"))
|
||
row.add_child(time_label)
|
||
phase_label = Label.new()
|
||
phase_label.custom_minimum_size.x = 48
|
||
phase_label.add_theme_font_size_override("font_size", 12)
|
||
phase_label.add_theme_color_override("font_color", Color("ffd37a"))
|
||
row.add_child(phase_label)
|
||
weather_label = _hud_value_label("晴れ", 48)
|
||
weather_label.name = "WeatherLabel"
|
||
weather_label.tooltip_text = "嵐の間は新規入館が停止します。現在のお客様は館内に残ります。"
|
||
weather_label.mouse_filter = Control.MOUSE_FILTER_STOP
|
||
row.add_child(weather_label)
|
||
|
||
guest_count_label = Button.new()
|
||
guest_count_label.text = "来客 0人"
|
||
guest_count_label.tooltip_text = "来客の欲求と現在の目的地を見る"
|
||
guest_count_label.custom_minimum_size.x = 72
|
||
guest_count_label.add_theme_font_size_override("font_size", 12)
|
||
guest_count_label.pressed.connect(_show_guest_observer)
|
||
lodging_count_label = _hud_value_label("宿泊 0人", 72)
|
||
row.add_child(guest_count_label)
|
||
row.add_child(lodging_count_label)
|
||
for resource_name: StringName in [&"gold", &"mana", &"materials", &"faith"]:
|
||
var resource_block := HBoxContainer.new()
|
||
resource_block.add_theme_constant_override("separation", 2)
|
||
var resource_icon := TextureRect.new()
|
||
resource_icon.name = "%sIcon" % String(resource_name).capitalize()
|
||
resource_icon.texture = visual_theme.resource_icon(resource_name) if visual_theme != null else null
|
||
resource_icon.custom_minimum_size = Vector2(16, 16)
|
||
resource_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
resource_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
resource_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
resource_icon_nodes[resource_name] = resource_icon
|
||
resource_block.add_child(resource_icon)
|
||
var label := _hud_value_label("", 54)
|
||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||
resource_labels[resource_name] = label
|
||
resource_block.add_child(label)
|
||
row.add_child(resource_block)
|
||
alert_count_label = _hud_value_label("警戒 0", 64)
|
||
alert_count_label.mouse_filter = Control.MOUSE_FILTER_STOP
|
||
alert_count_label.focus_mode = Control.FOCUS_ALL
|
||
alert_count_label.gui_input.connect(_on_defense_notice_input)
|
||
alert_count_label.add_theme_color_override("font_color", Color("ffb095"))
|
||
alert_icon_node = _icon_rect(visual_theme.control_icon(&"alert") if visual_theme != null else null, 20)
|
||
alert_icon_node.name = "AlertIcon"
|
||
row.add_child(alert_icon_node)
|
||
row.add_child(alert_count_label)
|
||
var spacer := Control.new()
|
||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
row.add_child(spacer)
|
||
pause_button = _button("停止", _toggle_pause, 52)
|
||
row.add_child(pause_button)
|
||
for value: int in [1, 2, 3]:
|
||
var speed_button := _button("x%d" % value, _set_speed.bind(value), 42)
|
||
speed_button.toggle_mode = true
|
||
speed_buttons[value] = speed_button
|
||
row.add_child(speed_button)
|
||
return panel
|
||
|
||
|
||
func _create_navigation_rail() -> Control:
|
||
var panel := PanelContainer.new()
|
||
panel.name = "NavigationRail"
|
||
panel.custom_minimum_size.x = 64
|
||
panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
panel.add_theme_stylebox_override("panel", _themed_panel_style(&"panel_leather", _visual_color(&"dark_panel_alt", Color("513018")), _visual_color(&"border_gold", Color("bd8540")), 8, 7))
|
||
var column := VBoxContainer.new()
|
||
column.add_theme_constant_override("separation", 4)
|
||
panel.add_child(column)
|
||
var entries := [
|
||
[&"staff", "従業員"],
|
||
[&"facility", "施設"],
|
||
[&"product", "商品"],
|
||
[&"business", "営業"],
|
||
[&"explore", "探索"],
|
||
[&"investment", "投資"],
|
||
[&"ledger", "帳簿"],
|
||
]
|
||
for entry: Array in entries:
|
||
var mode := StringName(entry[0])
|
||
var button := _button(String(entry[1]), _activate_management_mode.bind(mode), 50)
|
||
button.custom_minimum_size.y = 46
|
||
button.toggle_mode = true
|
||
button.add_theme_font_size_override("font_size", 12)
|
||
_apply_button_icon(button, visual_theme.navigation_icon(&"ledger" if mode == &"business" else mode) if visual_theme != null else null, 16)
|
||
navigation_buttons[mode] = button
|
||
column.add_child(button)
|
||
var nav_spacer := Control.new()
|
||
nav_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
column.add_child(nav_spacer)
|
||
var layer_hint := Label.new()
|
||
layer_hint.text = "TAB\n表 / 裏"
|
||
layer_hint.tooltip_text = "ホイール: 拡大縮小\n画面移動ボタン → 左ドラッグ: 移動\n中ボタンドラッグ: 移動\nHome: 全体表示"
|
||
layer_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
layer_hint.add_theme_font_size_override("font_size", 10)
|
||
layer_hint.add_theme_color_override("font_color", Color("e2c38c"))
|
||
column.add_child(layer_hint)
|
||
return panel
|
||
|
||
|
||
func _create_side_panel() -> Control:
|
||
var panel := PanelContainer.new()
|
||
panel.name = "SidePanel"
|
||
panel.custom_minimum_size.x = 312
|
||
panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
panel.add_theme_stylebox_override("panel", _themed_panel_style(&"panel_parchment", _visual_color(&"light_panel", Color("f4e3bd")), _visual_color(&"border_gold", Color("a96f2f")), 8, 10))
|
||
var scroll := ScrollContainer.new()
|
||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
panel.add_child(scroll)
|
||
var column := VBoxContainer.new()
|
||
column.custom_minimum_size.x = 278
|
||
column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
column.add_theme_constant_override("separation", 8)
|
||
scroll.add_child(column)
|
||
var root_column := column
|
||
|
||
var inspector_heading := HBoxContainer.new()
|
||
var inspector_title := _section_heading("運営状況")
|
||
inspector_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
inspector_heading.add_child(inspector_title)
|
||
inspector_heading.add_child(_button("閉じる I", _set_management_panel_open.bind(false), 64))
|
||
column.add_child(inspector_heading)
|
||
dashboard_summary_label = Label.new()
|
||
dashboard_summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
dashboard_summary_label.add_theme_color_override("font_color", Color("4d341f"))
|
||
dashboard_summary_label.add_theme_font_size_override("font_size", 13)
|
||
column.add_child(dashboard_summary_label)
|
||
foreground_event_panel = VBoxContainer.new()
|
||
foreground_event_panel.name = "ForegroundEventPanel"
|
||
foreground_event_panel.add_theme_constant_override("separation", 6)
|
||
foreground_event_panel.visible = false
|
||
column.add_child(foreground_event_panel)
|
||
foreground_event_panel.add_child(_section_heading("要対応イベント"))
|
||
foreground_event_label = Label.new()
|
||
foreground_event_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
foreground_event_label.add_theme_color_override("font_color", Color("8f3f29"))
|
||
foreground_event_label.add_theme_font_size_override("font_size", 13)
|
||
foreground_event_panel.add_child(foreground_event_label)
|
||
foreground_event_choices = VBoxContainer.new()
|
||
foreground_event_choices.name = "ForegroundEventChoices"
|
||
foreground_event_choices.add_theme_constant_override("separation", 5)
|
||
foreground_event_panel.add_child(foreground_event_choices)
|
||
column.add_child(HSeparator.new())
|
||
operations_controls = VBoxContainer.new()
|
||
operations_controls.name = "OperationsControls"
|
||
operations_controls.add_theme_constant_override("separation", 8)
|
||
column.add_child(operations_controls)
|
||
column = operations_controls
|
||
|
||
var candidate_heading_row := HBoxContainer.new()
|
||
candidate_heading_row.add_child(_section_heading("スタッフ"))
|
||
var candidate_heading_spacer := Control.new()
|
||
candidate_heading_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
candidate_heading_row.add_child(candidate_heading_spacer)
|
||
var defense_button := _button("防衛", _show_defense_manager, 52)
|
||
defense_button.name = "DefenseManagementButton"
|
||
candidate_heading_row.add_child(defense_button)
|
||
staff_management_toggle_button = _button("雇用済み", _toggle_staff_management_panel, 62)
|
||
staff_management_toggle_button.name = "StaffManagementToggleButton"
|
||
staff_management_toggle_button.toggle_mode = true
|
||
candidate_heading_row.add_child(staff_management_toggle_button)
|
||
staff_candidate_toggle_button = _button(
|
||
"候補を見る H",
|
||
_toggle_staff_candidate_panel,
|
||
108
|
||
)
|
||
staff_candidate_toggle_button.name = "StaffCandidateToggleButton"
|
||
candidate_heading_row.add_child(staff_candidate_toggle_button)
|
||
column.add_child(candidate_heading_row)
|
||
staff_management_panel = StaffManagementPanelScript.new()
|
||
staff_management_panel.configure(_runtime, _apply_button_style)
|
||
staff_management_panel.configure_audio(Callable(_audio_feedback, "submit_command"))
|
||
staff_management_panel.status_requested.connect(_on_staff_management_status)
|
||
staff_management_panel.visible = false
|
||
column.add_child(staff_management_panel)
|
||
staff_candidate_panel = VBoxContainer.new()
|
||
staff_candidate_panel.name = "StaffCandidatePanel"
|
||
staff_candidate_panel.visible = false
|
||
staff_candidate_panel.add_theme_constant_override("separation", 7)
|
||
column.add_child(staff_candidate_panel)
|
||
staff_candidate_summary_label = Label.new()
|
||
staff_candidate_summary_label.name = "StaffCandidateSummary"
|
||
staff_candidate_summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
staff_candidate_summary_label.add_theme_color_override("font_color", Color("765438"))
|
||
staff_candidate_summary_label.add_theme_font_size_override("font_size", 12)
|
||
staff_candidate_panel.add_child(staff_candidate_summary_label)
|
||
var candidate_list := VBoxContainer.new()
|
||
candidate_list.name = "StaffCandidateList"
|
||
candidate_list.add_theme_constant_override("separation", 7)
|
||
staff_candidate_panel.add_child(candidate_list)
|
||
column.add_child(HSeparator.new())
|
||
|
||
selected_heading_label = _section_heading("選択中の施設")
|
||
column.add_child(selected_heading_label)
|
||
var selected_row := HBoxContainer.new()
|
||
selected_row.add_theme_constant_override("separation", 8)
|
||
column.add_child(selected_row)
|
||
selected_entity_icon = TextureRect.new()
|
||
selected_entity_icon.name = "SelectedEntityIcon"
|
||
selected_entity_icon.custom_minimum_size = Vector2(24, 24)
|
||
selected_entity_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
selected_entity_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
selected_entity_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
selected_row.add_child(selected_entity_icon)
|
||
selected_detail_label = Label.new()
|
||
selected_detail_label.mouse_filter = Control.MOUSE_FILTER_PASS
|
||
selected_detail_label.text = "地図上の施設をクリックすると、営業状態を確認できます。"
|
||
selected_detail_label.custom_minimum_size.y = 112
|
||
selected_detail_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
selected_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
selected_detail_label.add_theme_color_override("font_color", Color("4d341f"))
|
||
selected_detail_label.add_theme_font_size_override("font_size", 13)
|
||
selected_row.add_child(selected_detail_label)
|
||
related_facility_picker = OptionButton.new()
|
||
related_facility_picker.name = "RelatedFacilityPicker"
|
||
related_facility_picker.visible = false
|
||
related_facility_picker.tooltip_text = "自施設の稼働に必要な関連施設を指定します。休止状態は解除しません。"
|
||
related_facility_picker.item_selected.connect(_on_related_facility_selected)
|
||
column.add_child(related_facility_picker)
|
||
selected_elevator_controls = VBoxContainer.new()
|
||
selected_elevator_controls.name = "SelectedElevatorControls"
|
||
selected_elevator_controls.visible = false
|
||
column.add_child(selected_elevator_controls)
|
||
elevator_operation_button = _button("昇降機を停止", _on_elevator_operation_pressed, 160)
|
||
elevator_operation_button.name = "ElevatorOperationButton"
|
||
selected_elevator_controls.add_child(elevator_operation_button)
|
||
elevator_operation_hint = Label.new()
|
||
elevator_operation_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
elevator_operation_hint.add_theme_font_size_override("font_size", 12)
|
||
elevator_operation_hint.add_theme_color_override("font_color", Color("654d37"))
|
||
selected_elevator_controls.add_child(elevator_operation_hint)
|
||
|
||
operating_ledger_panel = OperatingLedgerPanelScript.new()
|
||
root_column.add_child(operating_ledger_panel)
|
||
operating_ledger_panel.setup(_runtime, GameClock, _apply_button_style, GameState.active_layer)
|
||
operating_ledger_panel.configure_audio(Callable(_audio_feedback, "submit_command"))
|
||
operating_ledger_panel.status_requested.connect(_on_staff_management_status)
|
||
operating_ledger_panel.visible = false
|
||
supply_management_panel = SupplyManagementPanelScript.new()
|
||
supply_management_panel.configure(_runtime, _apply_button_style)
|
||
supply_management_panel.configure_audio(Callable(_audio_feedback, "submit_command"))
|
||
supply_management_panel.status_requested.connect(_on_staff_management_status)
|
||
supply_management_panel.action_requested.connect(_activate_management_mode)
|
||
supply_management_panel.visible = false
|
||
root_column.add_child(supply_management_panel)
|
||
business_management_panel = BusinessManagementPanelScript.new()
|
||
business_management_panel.configure(_runtime, _apply_button_style)
|
||
business_management_panel.configure_audio(Callable(_audio_feedback, "submit_command"))
|
||
business_management_panel.status_requested.connect(_on_staff_management_status)
|
||
business_management_panel.action_requested.connect(_activate_management_mode)
|
||
business_management_panel.visible = false
|
||
root_column.add_child(business_management_panel)
|
||
return panel
|
||
|
||
|
||
func _create_bottom_bar() -> Control:
|
||
var panel := PanelContainer.new()
|
||
panel.name = "BottomBar"
|
||
panel.add_theme_stylebox_override("panel", _themed_panel_style(&"panel_wood", _visual_color(&"dark_panel", Color("24382e")), _visual_color(&"border_gold", Color("7d8862")), 0, 5))
|
||
var column := VBoxContainer.new()
|
||
column.add_theme_constant_override("separation", 4)
|
||
panel.add_child(column)
|
||
var row := HBoxContainer.new()
|
||
row.add_theme_constant_override("separation", 5)
|
||
column.add_child(row)
|
||
|
||
palette_toggle_button = _button("建築 B", _toggle_build_palette, 76)
|
||
palette_toggle_button.name = "BuildPaletteToggleButton"
|
||
palette_toggle_button.toggle_mode = true
|
||
row.add_child(palette_toggle_button)
|
||
front_button = _button("客用・表層", _set_layer.bind(0), 96)
|
||
back_button = _button("裏方・裏層", _set_layer.bind(1), 96)
|
||
_apply_button_icon(front_button, visual_theme.control_icon(&"front_layer") if visual_theme != null else null, 16)
|
||
_apply_button_icon(back_button, visual_theme.control_icon(&"back_layer") if visual_theme != null else null, 16)
|
||
front_button.toggle_mode = true
|
||
back_button.toggle_mode = true
|
||
row.add_child(front_button)
|
||
row.add_child(back_button)
|
||
excavation_button = _button("掘削 E", _toggle_excavation_mode, 72)
|
||
excavation_button.name = "ExcavationButton"
|
||
excavation_button.toggle_mode = true
|
||
_apply_button_icon(excavation_button, visual_theme.control_icon(&"excavate") if visual_theme != null else null, 16)
|
||
row.add_child(excavation_button)
|
||
row.add_child(VSeparator.new())
|
||
var rotate_button := _button("回転 R", _rotate_build_preview, 72)
|
||
_apply_button_icon(rotate_button, visual_theme.control_icon(&"rotate") if visual_theme != null else null, 16)
|
||
row.add_child(rotate_button)
|
||
row.add_child(_button("解除 Esc", _clear_active_tool, 80))
|
||
pan_button = _button("画面移動", _toggle_pan_mode, 90)
|
||
pan_button.name = "PanModeButton"
|
||
pan_button.toggle_mode = true
|
||
pan_button.tooltip_text = "左ドラッグとホイールで画面を上下移動。Ctrl+ホイールで拡大縮小。もう一度押すか Esc で解除、Homeで全体表示。"
|
||
row.add_child(pan_button)
|
||
var focus_button := _button("営業階", func() -> void: world_preview.focus_operating_area(), 64)
|
||
focus_button.name = "FocusOperatingAreaButton"
|
||
focus_button.tooltip_text = "現在の層の建物を見やすい大きさで表示。Homeで全体表示。"
|
||
row.add_child(focus_button)
|
||
var spacer := Control.new()
|
||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
row.add_child(spacer)
|
||
management_toggle_button = _button("運営 I", _toggle_management_panel, 72)
|
||
management_toggle_button.name = "ManagementToggleButton"
|
||
management_toggle_button.toggle_mode = true
|
||
row.add_child(management_toggle_button)
|
||
event_log_dialog = EventLogDialogScript.new()
|
||
event_log_dialog.configure(_runtime)
|
||
add_child(event_log_dialog)
|
||
var event_log_button := _button("記録", event_log_dialog.show_events, 62)
|
||
event_log_button.name = "EventLogButton"
|
||
event_log_button.tooltip_text = "最近のイベントを確認"
|
||
row.add_child(event_log_button)
|
||
var save_button := _button("保存", _save_manual, 62)
|
||
save_button.name = "ManualSaveButton"
|
||
_apply_button_icon(save_button, visual_theme.control_icon(&"save") if visual_theme != null else null, 16)
|
||
row.add_child(save_button)
|
||
var audio_button := _button("音設定", _show_audio_settings, 62)
|
||
audio_button.name = "AudioSettingsButton"
|
||
row.add_child(audio_button)
|
||
var load_menu := MenuButton.new()
|
||
load_menu.name = "SaveLoadMenu"
|
||
load_menu.text = "読込 ▾"
|
||
load_menu.custom_minimum_size = Vector2(64,28)
|
||
load_menu.add_theme_font_size_override("font_size",12)
|
||
_apply_button_style(load_menu)
|
||
load_menu.get_popup().add_item("手動セーブを読み込む",0)
|
||
load_menu.get_popup().add_item("旧寸法を修正して読み込む",1)
|
||
load_menu.get_popup().add_item("保存スロットを選んで読込",2)
|
||
load_menu.get_popup().id_pressed.connect(func(id: int):
|
||
if id == 0: _load_manual()
|
||
elif id == 1: _preview_dimension_migration()
|
||
elif id == 2: _show_manual_load_slots())
|
||
row.add_child(load_menu)
|
||
|
||
palette_scroll = ScrollContainer.new()
|
||
palette_scroll.name = "BuildPaletteScroll"
|
||
palette_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
|
||
palette_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||
palette_scroll.custom_minimum_size.y = 62
|
||
palette_scroll.visible = false
|
||
column.add_child(palette_scroll)
|
||
var palette_row := HBoxContainer.new()
|
||
palette_row.name = "BuildPaletteRow"
|
||
palette_row.add_theme_constant_override("separation", 4)
|
||
palette_scroll.add_child(palette_row)
|
||
var grid := GridContainer.new()
|
||
grid.name = "BuildPalette"
|
||
grid.columns = MVP_FACILITY_IDS.size()
|
||
grid.add_theme_constant_override("h_separation", 4)
|
||
grid.add_theme_constant_override("v_separation", 0)
|
||
palette_row.add_child(grid)
|
||
demolition_button = _button("撤去", _toggle_demolition_mode, 72)
|
||
demolition_button.name = "DemolitionButton"
|
||
demolition_button.toggle_mode = true
|
||
palette_row.add_child(demolition_button)
|
||
|
||
build_status_label = Label.new()
|
||
build_status_label.name = "BuildStatusLabel"
|
||
build_status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
build_status_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||
build_status_label.add_theme_font_size_override("font_size", 11)
|
||
build_status_label.add_theme_color_override("font_color", Color("c7d3a8"))
|
||
column.add_child(build_status_label)
|
||
return panel
|
||
|
||
|
||
func _toggle_build_palette() -> void:
|
||
_set_build_palette_open(not _build_palette_open)
|
||
|
||
|
||
func _set_build_palette_open(open: bool) -> void:
|
||
_build_palette_open = open
|
||
if palette_scroll != null:
|
||
palette_scroll.visible = open
|
||
if palette_toggle_button != null:
|
||
palette_toggle_button.set_pressed_no_signal(open)
|
||
palette_toggle_button.text = "建築を閉じる B" if open else "建築 B"
|
||
if not open:
|
||
var bottom := find_child("BottomBar", true, false) as Control
|
||
if bottom != null: bottom.reset_size.call_deferred()
|
||
|
||
|
||
func _toggle_management_panel() -> void:
|
||
_set_management_panel_open(not _management_panel_open)
|
||
|
||
|
||
func _set_management_panel_open(open: bool) -> void:
|
||
_management_panel_open = open
|
||
if management_panel != null:
|
||
management_panel.visible = open
|
||
if management_toggle_button != null:
|
||
management_toggle_button.set_pressed_no_signal(open)
|
||
if not open:
|
||
_set_staff_candidate_panel_open(false)
|
||
_set_staff_management_panel_open(false)
|
||
_set_ledger_panel_open(false)
|
||
_set_supply_panel_open(false)
|
||
_set_business_panel_open(false)
|
||
|
||
|
||
func _activate_management_mode(mode: StringName) -> void:
|
||
_set_management_panel_open(mode != &"facility")
|
||
_set_supply_panel_open(mode == &"product")
|
||
_set_business_panel_open(mode == &"business")
|
||
_set_ledger_panel_open(mode == &"ledger")
|
||
for raw_mode: Variant in navigation_buttons:
|
||
(navigation_buttons[raw_mode] as Button).set_pressed_no_signal(StringName(raw_mode) == mode)
|
||
match mode:
|
||
&"staff":
|
||
_set_staff_management_panel_open(true)
|
||
&"facility":
|
||
_set_build_palette_open(true)
|
||
_set_staff_candidate_panel_open(false)
|
||
_set_status("建築する施設を下のパレットから選んでください。", true, true)
|
||
&"business":
|
||
_set_status("施設ごとの料金と新規受付の開閉を設定できます。", true, true)
|
||
&"product":
|
||
_set_status("倉庫への仕入れ、厨房の自動生産、施設への配送を確認できます。", true, true)
|
||
&"explore":
|
||
_set_status("探索管理は一覧画面の実装準備中です。", true, true)
|
||
&"investment":
|
||
_set_status("投資管理は一覧画面の実装準備中です。", true, true)
|
||
&"ledger":
|
||
_set_status("本日の資金増減と、00:00に確定した営業日の損益を表示します。", true, true)
|
||
|
||
|
||
func _set_ledger_panel_open(open: bool) -> void:
|
||
_ledger_panel_open = open
|
||
if open:
|
||
_set_supply_panel_open(false)
|
||
_set_business_panel_open(false)
|
||
if operating_ledger_panel != null:
|
||
operating_ledger_panel.visible = open
|
||
if open:
|
||
operating_ledger_panel.refresh()
|
||
if operations_controls != null:
|
||
operations_controls.visible = not open and not _supply_panel_open and not _business_panel_open
|
||
if dashboard_summary_label != null:
|
||
dashboard_summary_label.visible = not open and not _supply_panel_open and not _business_panel_open
|
||
if open:
|
||
_set_staff_candidate_panel_open(false)
|
||
_set_staff_management_panel_open(false)
|
||
|
||
|
||
func _set_supply_panel_open(open: bool) -> void:
|
||
_supply_panel_open = open
|
||
if open:
|
||
_set_business_panel_open(false)
|
||
_set_ledger_panel_open(false)
|
||
_set_staff_candidate_panel_open(false)
|
||
_set_staff_management_panel_open(false)
|
||
if supply_management_panel != null:
|
||
supply_management_panel.visible = open
|
||
if open:
|
||
supply_management_panel.refresh()
|
||
if operations_controls != null:
|
||
operations_controls.visible = not open and not _ledger_panel_open and not _business_panel_open
|
||
if dashboard_summary_label != null:
|
||
dashboard_summary_label.visible = not open and not _ledger_panel_open and not _business_panel_open
|
||
|
||
|
||
func _set_business_panel_open(open: bool) -> void:
|
||
_business_panel_open = open
|
||
if open:
|
||
_set_ledger_panel_open(false)
|
||
_set_supply_panel_open(false)
|
||
_set_staff_candidate_panel_open(false)
|
||
_set_staff_management_panel_open(false)
|
||
if business_management_panel != null:
|
||
business_management_panel.visible = open
|
||
if open:
|
||
business_management_panel.refresh()
|
||
if operations_controls != null:
|
||
operations_controls.visible = not open and not _ledger_panel_open and not _supply_panel_open
|
||
if dashboard_summary_label != null:
|
||
dashboard_summary_label.visible = not open and not _ledger_panel_open and not _supply_panel_open
|
||
|
||
|
||
func _on_opening_action(action: StringName) -> void:
|
||
match action:
|
||
&"build_reception", &"build_room", &"build_corridor", &"build_stairs":
|
||
_set_ledger_panel_open(false)
|
||
_set_management_panel_open(false)
|
||
_set_build_palette_open(true)
|
||
var definitions := {&"build_reception": &"reception", &"build_room": &"standard_room", &"build_corridor": &"guest_corridor", &"build_stairs": &"guest_stairs"}
|
||
_select_build_definition(definitions[action])
|
||
&"staff":
|
||
_activate_management_mode(&"staff")
|
||
if _call_array(_service(&"staff_service"), &"get_staff_members").is_empty():
|
||
_set_staff_candidate_panel_open(true)
|
||
&"resume":
|
||
if GameClock.paused:
|
||
_toggle_pause()
|
||
&"ledger":
|
||
_activate_management_mode(&"ledger")
|
||
|
||
|
||
func _connect_signals() -> void:
|
||
GameClock.time_changed.connect(_on_time_changed)
|
||
GameClock.phase_changed.connect(_on_phase_changed)
|
||
GameState.resource_changed.connect(_on_resource_changed)
|
||
world_preview.facility_selected.connect(_on_facility_selected)
|
||
world_preview.facility_selected.connect(_on_audio_world_selection)
|
||
world_preview.staff_assignment_dropped.connect(func(data: Dictionary, facility_id: StringName) -> void:
|
||
staff_management_panel.assign_from_drop(data, facility_id)
|
||
)
|
||
world_preview.legacy_stair_selected.connect(_on_legacy_stair_selected)
|
||
world_preview.legacy_stair_selected.connect(_on_audio_world_selection)
|
||
world_preview.construction_selected.connect(_on_construction_selected)
|
||
world_preview.construction_selected.connect(_on_audio_world_selection)
|
||
world_preview.cell_clicked.connect(_on_map_cell_clicked)
|
||
world_preview.build_preview_changed.connect(_on_build_preview_changed)
|
||
world_preview.excavation_preview_changed.connect(_on_excavation_preview_changed)
|
||
if _runtime != null and _runtime is Object:
|
||
if _runtime.has_signal(&"state_updated"):
|
||
_runtime.connect(&"state_updated", Callable(self, "_on_snapshot_published"))
|
||
elif _runtime.has_signal(&"snapshot_published"):
|
||
_runtime.connect(&"snapshot_published", Callable(self, "_on_snapshot_published"))
|
||
if _runtime.has_signal(&"checkpoint_presentation_requested"):
|
||
_runtime.connect(&"checkpoint_presentation_requested", Callable(self, "_on_checkpoint_presentation_requested"))
|
||
if _runtime.has_signal(&"save_operation_completed"):
|
||
_runtime.connect(&"save_operation_completed", Callable(self, "_on_save_operation_completed"))
|
||
if _runtime.has_signal(&"command_completed"):
|
||
_runtime.connect(&"command_completed", Callable(self, "_on_command_completed"))
|
||
|
||
|
||
func _populate_build_palette() -> void:
|
||
var palette := find_child("BuildPalette", true, false) as GridContainer
|
||
if palette == null:
|
||
return
|
||
var registry: Variant = _service(&"definition_registry")
|
||
for definition_id: StringName in MVP_FACILITY_IDS:
|
||
var definition: Variant = null
|
||
if registry != null and registry.has_method(&"get_facility"):
|
||
definition = registry.call(&"get_facility", definition_id)
|
||
if definition == null:
|
||
continue
|
||
var button := Button.new()
|
||
button.toggle_mode = true
|
||
button.custom_minimum_size = Vector2(116, 50)
|
||
button.text = _build_button_text(definition)
|
||
button.tooltip_text = String(_definition_value(definition, &"description", ""))
|
||
button.add_theme_font_size_override("font_size", 12)
|
||
_apply_button_icon(button, visual_theme.facility_icon(definition_id) if visual_theme != null else null, 22)
|
||
_apply_button_style(button)
|
||
button.toggled.connect(_on_build_button_toggled.bind(definition_id))
|
||
build_buttons[definition_id] = button
|
||
palette.add_child(button)
|
||
if build_buttons.is_empty():
|
||
_set_status("施設定義を読み込めませんでした。", false)
|
||
else:
|
||
_set_status("B 建築 / H 雇用 / I 運営 ホイール 拡大縮小・中ボタン 移動・Home 全体表示", true, true)
|
||
|
||
|
||
func _populate_staff_candidates() -> void:
|
||
var list := find_child("StaffCandidateList", true, false) as VBoxContainer
|
||
if list == null:
|
||
return
|
||
for child: Node in list.get_children():
|
||
list.remove_child(child)
|
||
child.queue_free()
|
||
staff_candidate_buttons.clear()
|
||
var summaries := _staff_candidate_summaries()
|
||
for summary: Dictionary in summaries:
|
||
var candidate_id := StringName(summary.get(&"candidate_id", &""))
|
||
if candidate_id.is_empty():
|
||
continue
|
||
var card := PanelContainer.new()
|
||
card.name = "Candidate_%s" % String(candidate_id)
|
||
card.add_theme_stylebox_override(
|
||
"panel",
|
||
_themed_panel_style(&"panel_leather", Color("132630"), Color("31505d"), 7, 8)
|
||
)
|
||
var content := VBoxContainer.new()
|
||
content.add_theme_constant_override("separation", 4)
|
||
card.add_child(content)
|
||
var detail := Label.new()
|
||
detail.name = "CandidateDetail_%s" % String(candidate_id)
|
||
detail.text = _staff_candidate_text(summary)
|
||
detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
detail.add_theme_color_override("font_color", Color("c8d7dc"))
|
||
detail.add_theme_font_size_override("font_size", 12)
|
||
content.add_child(detail)
|
||
var hire_button := Button.new()
|
||
hire_button.name = "Hire_%s" % String(candidate_id)
|
||
hire_button.text = "無料で加入" if bool(summary.get(&"unique", false)) else "雇用する"
|
||
hire_button.custom_minimum_size.y = 30
|
||
_apply_button_style(hire_button)
|
||
hire_button.pressed.connect(_hire_staff_candidate.bind(candidate_id))
|
||
content.add_child(hire_button)
|
||
staff_candidate_buttons[candidate_id] = hire_button
|
||
list.add_child(card)
|
||
_refresh_staff_candidates()
|
||
|
||
|
||
func _staff_candidate_summaries() -> Array[Dictionary]:
|
||
if _runtime == null or not _runtime.has_method(&"get_mvp_staff_candidate_summaries"):
|
||
return []
|
||
var value: Variant = _runtime.call(&"get_mvp_staff_candidate_summaries")
|
||
var result: Array[Dictionary] = []
|
||
if value is Array:
|
||
for item: Variant in value:
|
||
if item is Dictionary:
|
||
result.append((item as Dictionary).duplicate(true))
|
||
return result
|
||
|
||
|
||
func _staff_candidate_text(summary: Dictionary) -> String:
|
||
var candidate_id := StringName(summary.get(&"candidate_id", &""))
|
||
var display_name := String(summary.get(&"display_name", candidate_id))
|
||
var role_id := StringName(summary.get(&"role_id", &""))
|
||
var shift_id := StringName(summary.get(&"preferred_shift", &"any"))
|
||
var skills_value: Variant = summary.get(&"skills", {})
|
||
var skills: Dictionary = skills_value if skills_value is Dictionary else {}
|
||
var skill_parts: Array[String] = []
|
||
for skill_id: StringName in [&"service", &"operation", &"combat", &"underworld"]:
|
||
skill_parts.append("%s%d" % [
|
||
String(STAFF_SKILL_NAMES.get(skill_id, String(skill_id))),
|
||
int(skills.get(skill_id, 0)),
|
||
])
|
||
return "%s(%d歳) %s / %s\n契約 G%d 日給 G%d\n技能 %s" % [
|
||
display_name,
|
||
int(summary.get(&"age", 0)),
|
||
String(STAFF_ROLE_NAMES.get(role_id, String(role_id))),
|
||
String(STAFF_SHIFT_NAMES.get(shift_id, String(shift_id))),
|
||
int(summary.get(&"signing_cost", 0)),
|
||
int(summary.get(&"daily_wage", 0)),
|
||
"・".join(skill_parts),
|
||
]
|
||
|
||
|
||
func _toggle_staff_management_panel() -> void:
|
||
_set_staff_management_panel_open(not _staff_management_panel_open)
|
||
|
||
|
||
func _set_staff_management_panel_open(open: bool) -> void:
|
||
_staff_management_panel_open = open
|
||
if staff_management_panel != null:
|
||
staff_management_panel.visible = open
|
||
if staff_management_toggle_button != null:
|
||
staff_management_toggle_button.set_pressed_no_signal(open)
|
||
if open:
|
||
_set_ledger_panel_open(false)
|
||
_set_supply_panel_open(false)
|
||
_set_business_panel_open(false)
|
||
_set_staff_candidate_panel_open(false)
|
||
_set_management_panel_open(true)
|
||
staff_management_panel.refresh()
|
||
_set_status("従業員を選び、配属先と毎日の勤務帯を変更できます。", true, true)
|
||
|
||
|
||
func _on_staff_management_status(message: String, success: bool) -> void:
|
||
_set_status(message, success)
|
||
|
||
|
||
func _toggle_staff_candidate_panel() -> void:
|
||
_set_staff_candidate_panel_open(not _staff_candidate_panel_open)
|
||
|
||
|
||
func _set_staff_candidate_panel_open(open: bool) -> void:
|
||
_staff_candidate_panel_open = open
|
||
if staff_candidate_panel != null:
|
||
staff_candidate_panel.visible = open
|
||
_refresh_staff_candidates()
|
||
if open:
|
||
_set_ledger_panel_open(false)
|
||
_set_supply_panel_open(false)
|
||
_set_business_panel_open(false)
|
||
_set_staff_management_panel_open(false)
|
||
_set_management_panel_open(true)
|
||
_set_status("雇用候補を選んでください。契約金と日給は確定前に表示されます。", true, true)
|
||
|
||
|
||
func _refresh_staff_candidates() -> void:
|
||
if staff_candidate_panel == null or staff_candidate_summary_label == null:
|
||
return
|
||
var summaries := _staff_candidate_summaries()
|
||
var hired_count := 0
|
||
for summary: Dictionary in summaries:
|
||
var candidate_id := StringName(summary.get(&"candidate_id", &""))
|
||
var hired := bool(summary.get(&"hired", false))
|
||
if hired:
|
||
hired_count += 1
|
||
var button := staff_candidate_buttons.get(candidate_id) as Button
|
||
if button == null:
|
||
continue
|
||
var pending := _pending_hire_commands.values().has(candidate_id)
|
||
button.disabled = hired or pending
|
||
button.text = ("加入済み" if bool(summary.get(&"unique", false)) else "雇用済み") if hired else "手続き中…" if pending else ("無料で加入" if bool(summary.get(&"unique", false)) else "雇用する")
|
||
staff_candidate_summary_label.text = "未雇用 %d人 / 雇用済み %d人" % [
|
||
maxi(summaries.size() - hired_count, 0),
|
||
hired_count,
|
||
]
|
||
if staff_candidate_toggle_button != null:
|
||
staff_candidate_toggle_button.text = (
|
||
"候補を閉じる H" if _staff_candidate_panel_open
|
||
else "候補を見る H(%d)" % maxi(summaries.size() - hired_count, 0)
|
||
)
|
||
|
||
|
||
func _hire_staff_candidate(candidate_id: StringName) -> void:
|
||
if _runtime == null or not _runtime.has_method(&"submit_command"):
|
||
_set_status("雇用手続きを送信できません。", false)
|
||
return
|
||
if _pending_hire_commands.values().has(candidate_id):
|
||
_set_status("この候補の雇用手続きはすでに受理されています。", false)
|
||
return
|
||
var summaries := _staff_candidate_summaries()
|
||
var selected: Dictionary = {}
|
||
for summary: Dictionary in summaries:
|
||
if StringName(summary.get(&"candidate_id", &"")) == candidate_id:
|
||
selected = summary
|
||
break
|
||
if selected.is_empty():
|
||
_set_status("雇用候補を読み込めませんでした。", false)
|
||
return
|
||
if bool(selected.get(&"hired", false)):
|
||
_set_status("%sはすでに雇用済みです。" % String(selected.get(&"display_name", candidate_id)), false)
|
||
_refresh_staff_candidates()
|
||
return
|
||
var sequence := _hire_sequence
|
||
_hire_sequence += 1
|
||
var idempotency_key := StringName(
|
||
"ui.hire.%s.%08d" % [String(candidate_id), sequence]
|
||
)
|
||
var result: Variant = _submit_ui_command(&"hire_staff_candidate",
|
||
{&"candidate_id": candidate_id},
|
||
&"player",
|
||
candidate_id,
|
||
75,
|
||
-1,
|
||
idempotency_key
|
||
)
|
||
if result == null or not result.has_method(&"is_success"):
|
||
_set_status("雇用手続きへ応答がありません。", false)
|
||
return
|
||
if not bool(result.call(&"is_success")):
|
||
_set_status(_hire_failure_message(result), false)
|
||
_refresh_staff_candidates()
|
||
return
|
||
var display_name := String(selected.get(&"display_name", candidate_id))
|
||
if result.details is Dictionary and result.details.has(&"staff_id"):
|
||
_set_status("%sを雇用しました(契約 G%d / 日給 G%d)。" % [
|
||
display_name,
|
||
int(selected.get(&"signing_cost", 0)),
|
||
int(selected.get(&"daily_wage", 0)),
|
||
], true)
|
||
else:
|
||
_pending_hire_commands[int(result.command_id)] = candidate_id
|
||
_set_status("%sの雇用手続きを受理しました。" % display_name, true)
|
||
_refresh_dashboard()
|
||
|
||
|
||
func _hire_failure_message(result: Variant) -> String:
|
||
var details: Variant = result.details
|
||
if details is Dictionary:
|
||
var reason := StringName(details.get(&"reason", &""))
|
||
match reason:
|
||
&"ARREARS_REMAIN", &"UNPAID_EXPENSES":
|
||
return "雇用不可: 未払経費 G%dを清算してください。" % int(details.get(&"arrears_total", 0))
|
||
&"BANKRUPT":
|
||
return "雇用不可: 資金破綻後は新しい契約を結べません。"
|
||
&"EXTERNAL_ROLLBACK_FAILED":
|
||
return "雇用不可: 運営データの復旧が必要です。"
|
||
var message := String(result.message).to_lower()
|
||
if int(result.code) == int(CommandResult.Code.DUPLICATE_COMMAND_ID):
|
||
return "この候補はすでに雇用済みです。"
|
||
if "insufficient" in message or "balance" in message:
|
||
return "雇用不可: 契約金が不足しています。"
|
||
return _command_failure_message(result)
|
||
|
||
|
||
func _build_button_text(definition: Variant) -> String:
|
||
var name := String(_definition_value(definition, &"display_name", _definition_value(definition, &"id", "施設")))
|
||
var cost_value: Variant = _definition_value(definition, &"construction_cost", {})
|
||
var gold := 0
|
||
var materials := 0
|
||
if cost_value is Dictionary:
|
||
gold = int(cost_value.get(&"gold", cost_value.get("gold", 0)))
|
||
materials = int(cost_value.get(&"materials", cost_value.get("materials", 0)))
|
||
return "%s\nG %d / 資材 %d" % [name, gold, materials]
|
||
|
||
|
||
func _on_build_button_toggled(pressed: bool, definition_id: StringName) -> void:
|
||
if not pressed:
|
||
if _selected_definition_id == definition_id:
|
||
_clear_build_selection()
|
||
return
|
||
_select_build_definition(definition_id)
|
||
|
||
|
||
func _select_build_definition(definition_id: StringName) -> void:
|
||
var registry: Variant = _service(&"definition_registry")
|
||
if registry == null or not registry.has_method(&"get_facility"):
|
||
_set_status("建設パレットを利用できません。", false)
|
||
return
|
||
var definition: Variant = registry.call(&"get_facility", definition_id)
|
||
if definition == null:
|
||
_set_status("選択した施設定義が見つかりません。", false)
|
||
return
|
||
_set_demolition_mode(false, false)
|
||
_set_excavation_mode(false, false)
|
||
_set_pan_mode(false)
|
||
_selected_definition_id = definition_id
|
||
_selected_definition = definition
|
||
_build_rotation = 0
|
||
for raw_id: Variant in build_buttons:
|
||
(build_buttons[raw_id] as Button).set_pressed_no_signal(StringName(raw_id) == definition_id)
|
||
var target_layer := clampi(int(_definition_value(definition, &"layer", 0)), 0, 1)
|
||
_set_layer(target_layer)
|
||
world_preview.set_build_definition(definition, _build_rotation)
|
||
_set_status("%sを選択中。配置するマスをクリックしてください。" % String(_definition_value(definition, &"display_name", definition_id)), true, true)
|
||
|
||
|
||
func _clear_build_selection(announce := true) -> void:
|
||
_selected_definition_id = &""
|
||
_selected_definition = null
|
||
_build_rotation = 0
|
||
for button_value: Variant in build_buttons.values():
|
||
(button_value as Button).set_pressed_no_signal(false)
|
||
if world_preview != null:
|
||
world_preview.clear_build_definition()
|
||
if announce:
|
||
_set_status("建設選択を解除しました。", true, true)
|
||
|
||
|
||
func _clear_active_tool() -> void:
|
||
if _demolition_mode:
|
||
_set_demolition_mode(false)
|
||
return
|
||
if world_preview.pan_mode:
|
||
_set_pan_mode(false)
|
||
_set_status("画面移動を解除しました。施設をクリックすると情報を表示します。", true, true)
|
||
return
|
||
if _supply_panel_open or _business_panel_open:
|
||
_set_management_panel_open(false)
|
||
return
|
||
if _staff_management_panel_open:
|
||
_set_staff_management_panel_open(false)
|
||
return
|
||
if _staff_candidate_panel_open:
|
||
_set_staff_candidate_panel_open(false)
|
||
return
|
||
if _excavation_mode:
|
||
_set_excavation_mode(false)
|
||
return
|
||
_clear_build_selection()
|
||
_set_build_palette_open(false)
|
||
_set_management_panel_open(false)
|
||
|
||
|
||
func _toggle_excavation_mode() -> void:
|
||
_set_excavation_mode(not _excavation_mode)
|
||
|
||
|
||
func _toggle_pan_mode() -> void:
|
||
_set_pan_mode(not world_preview.pan_mode)
|
||
_set_status("画面移動中:左ドラッグで移動、ホイールで上下移動、Ctrl+ホイールで拡大縮小。Escで解除。" if world_preview.pan_mode else "画面移動を解除しました。施設をクリックすると情報を表示します。", true, true)
|
||
|
||
|
||
func _set_pan_mode(enabled: bool) -> void:
|
||
if enabled:
|
||
_set_demolition_mode(false, false)
|
||
_clear_build_selection(false)
|
||
_set_excavation_mode(false, false)
|
||
_set_build_palette_open(false)
|
||
world_preview.set_pan_mode(enabled)
|
||
if pan_button != null:
|
||
pan_button.set_pressed_no_signal(enabled)
|
||
|
||
|
||
func _set_excavation_mode(enabled: bool, announce := true) -> void:
|
||
if enabled:
|
||
_set_demolition_mode(false, false)
|
||
_set_pan_mode(false)
|
||
_excavation_mode = enabled
|
||
if excavation_button != null:
|
||
excavation_button.set_pressed_no_signal(_excavation_mode)
|
||
if world_preview != null:
|
||
world_preview.set_excavation_mode(_excavation_mode)
|
||
if not _excavation_mode:
|
||
if announce:
|
||
_set_status("掘削モードを解除しました。", true, true)
|
||
return
|
||
_clear_build_selection(false)
|
||
_selected_entity_id = &""
|
||
_selected_entity_kind = &""
|
||
world_preview.selected_entity_id = &""
|
||
selected_heading_label.text = "掘削モード"
|
||
selected_detail_label.text = "地下のマスにカーソルを合わせると、費用と所要時間を確認できます。"
|
||
var gate_message := _excavation_gate_message()
|
||
_set_status(
|
||
gate_message if not gate_message.is_empty() else "地下の掘削候補を選んでください。FRONT / BACKは同時に開通します。",
|
||
gate_message.is_empty(),
|
||
true
|
||
)
|
||
|
||
|
||
func _rotate_build_preview() -> void:
|
||
if _selected_definition == null:
|
||
_set_status("先に建設する施設を選択してください。", false, true)
|
||
return
|
||
var allowed_value: Variant = _definition_value(_selected_definition, &"allowed_rotations", [0])
|
||
var allowed: Array[int] = []
|
||
if allowed_value is Array:
|
||
for rotation_value: Variant in allowed_value:
|
||
allowed.append(int(rotation_value))
|
||
allowed.sort()
|
||
if allowed.is_empty():
|
||
allowed = [0]
|
||
var index := allowed.find(_build_rotation)
|
||
_build_rotation = allowed[(index + 1) % allowed.size()]
|
||
world_preview.set_build_rotation(_build_rotation)
|
||
_set_status("回転 %d°。地図のマスをクリックして建設します。" % _build_rotation, true, true)
|
||
|
||
|
||
func _on_map_cell_clicked(cell: Vector3i) -> void:
|
||
if _audio_feedback != null:
|
||
_audio_feedback.request_click()
|
||
if _demolition_mode:
|
||
_preview_demolition(cell)
|
||
return
|
||
if _excavation_mode:
|
||
_start_excavation(cell)
|
||
return
|
||
if _selected_definition == null:
|
||
_selected_entity_id = &""
|
||
_selected_entity_kind = &""
|
||
world_preview.selected_entity_id = &""
|
||
selected_elevator_controls.visible = false
|
||
selected_entity_icon.texture = null
|
||
selected_heading_label.text = "選択中のマス"
|
||
selected_detail_label.text = "座標 (%d, %d)\nレイヤー %s\n\n施設のないマスです。" % [cell.x, cell.y, "FRONT / 表層" if cell.z == 0 else "BACK / 裏層"]
|
||
return
|
||
var preview := world_preview.get_build_preview_report(cell)
|
||
if not bool(preview.get(&"can_build", false)):
|
||
if _audio_feedback != null:
|
||
_audio_feedback.reject_local(&"BUILD_PREVIEW_REJECTED")
|
||
_set_status(_preview_failure_message(preview), false)
|
||
return
|
||
if _runtime == null or not _runtime.has_method(&"submit_command"):
|
||
_set_status("Simulation Runtimeへ接続できません。", false)
|
||
return
|
||
var idempotency_key := StringName("ui.build.%08d" % _build_sequence)
|
||
_build_sequence += 1
|
||
var result: Variant = _submit_ui_command(&"build_facility",
|
||
{&"definition_id": _selected_definition_id, &"origin": cell, &"rotation": _build_rotation},
|
||
&"player",
|
||
&"",
|
||
50,
|
||
-1,
|
||
idempotency_key
|
||
)
|
||
if result == null or not result.has_method(&"is_success"):
|
||
_set_status("建設指示を送信できませんでした。", false)
|
||
return
|
||
if not bool(result.call(&"is_success")):
|
||
_set_status(_command_failure_message(result), false)
|
||
return
|
||
var name := String(_definition_value(_selected_definition, &"display_name", _selected_definition_id))
|
||
if result.details is Dictionary and result.details.has(&"job_id"):
|
||
_set_status("%sの建設を開始しました(%d, %d / %d°)。" % [name, cell.x, cell.y, _build_rotation], true)
|
||
else:
|
||
_pending_build_commands[int(result.command_id)] = _selected_definition_id
|
||
_set_status("%sの建設指示を受理しました(%d, %d / %d°)。" % [name, cell.x, cell.y, _build_rotation], true)
|
||
_refresh_dashboard()
|
||
|
||
|
||
func _on_build_preview_changed(report: Dictionary) -> void:
|
||
if _selected_definition == null or world_preview.get_hovered_cell() == null:
|
||
return
|
||
if bool(report.get(&"can_build", false)):
|
||
var cell: Vector3i = world_preview.get_hovered_cell()
|
||
var connection: Dictionary = report.get(&"connection", {})
|
||
var warning := " · 入口への経路未接続" if connection.get(&"status", &"") == &"DISCONNECTED" else " · 経路確認不可" if connection.get(&"status", &"") == &"UNAVAILABLE" else ""
|
||
_set_status("配置可能 (%d, %d)・%d°%s — クリックで建設" % [cell.x, cell.y, _build_rotation, warning], true, true)
|
||
else:
|
||
_set_status(_preview_failure_message(report), false, true)
|
||
|
||
|
||
func _on_excavation_preview_changed(report: Dictionary) -> void:
|
||
if not _excavation_mode or world_preview.get_hovered_cell() == null:
|
||
return
|
||
var gate_message := _excavation_gate_message()
|
||
if not gate_message.is_empty():
|
||
_set_status("掘削不可: %s%s" % [gate_message, _excavation_cost_and_time(report)], false, true)
|
||
return
|
||
var cell: Vector3i = world_preview.get_hovered_cell()
|
||
if bool(report.get(&"ok", false)):
|
||
_set_status(
|
||
"掘削可能 (%d, %d)・%s%s — クリックでFRONT / BACKを開通" % [
|
||
cell.x,
|
||
cell.y,
|
||
"FRONT / 表層" if cell.z == 0 else "BACK / 裏層",
|
||
_excavation_cost_and_time(report),
|
||
],
|
||
true,
|
||
true
|
||
)
|
||
else:
|
||
_set_status(_excavation_failure_message(report), false, true)
|
||
|
||
|
||
func _start_excavation(cell: Vector3i) -> void:
|
||
var report := world_preview.get_excavation_preview_report(cell)
|
||
var gate_message := _excavation_gate_message()
|
||
if not gate_message.is_empty():
|
||
_set_status("掘削不可: %s%s" % [gate_message, _excavation_cost_and_time(report)], false)
|
||
return
|
||
if not bool(report.get(&"ok", false)):
|
||
_set_status(_excavation_failure_message(report), false)
|
||
return
|
||
if _runtime == null or not _runtime.has_method(&"submit_command"):
|
||
_set_status("Simulation Runtimeへ接続できません。", false)
|
||
return
|
||
var spatial_position := Vector2i(cell.x, cell.y)
|
||
if _pending_excavation_commands.values().has(spatial_position):
|
||
_set_status("このマスの掘削指示はすでに受理されています。", false)
|
||
return
|
||
var idempotency_key := _excavation_idempotency_key(cell)
|
||
var result: Variant = _submit_ui_command(&"start_excavation",
|
||
{&"cell": Vector3i(cell.x, cell.y, world_preview.layer_mode)},
|
||
&"player",
|
||
&"",
|
||
50,
|
||
-1,
|
||
idempotency_key
|
||
)
|
||
if result == null or not result.has_method(&"is_success"):
|
||
_set_status("掘削指示を送信できませんでした。", false)
|
||
return
|
||
if not bool(result.call(&"is_success")):
|
||
_set_status(_excavation_command_failure_message(result), false)
|
||
return
|
||
if result.details is Dictionary and result.details.has(&"job_id"):
|
||
_select_excavation_job(StringName(result.details.get(&"job_id", &"")))
|
||
_set_status("掘削を開始しました(%d, %d / FRONT・BACK)。" % [cell.x, cell.y], true)
|
||
else:
|
||
_pending_excavation_commands[int(result.command_id)] = spatial_position
|
||
_set_status("掘削指示を受理しました(%d, %d / FRONT・BACK)。" % [cell.x, cell.y], true)
|
||
_refresh_dashboard()
|
||
|
||
|
||
func _excavation_idempotency_key(cell: Vector3i) -> StringName:
|
||
# One click gets one stable retry identity. A later player retry receives a
|
||
# new sequence so a previously cached validation failure cannot block it.
|
||
var sequence := _excavation_sequence
|
||
_excavation_sequence += 1
|
||
return StringName(
|
||
"ui.excavation.%d.%d.%08d" % [cell.x, cell.y, sequence]
|
||
)
|
||
|
||
|
||
func _on_facility_selected(instance_id: StringName) -> void:
|
||
_set_ledger_panel_open(false)
|
||
_set_supply_panel_open(false)
|
||
_set_management_panel_open(true)
|
||
_set_excavation_mode(false, false)
|
||
_selected_entity_id = instance_id
|
||
_selected_entity_kind = &"facility"
|
||
_refresh_selected_entity()
|
||
|
||
|
||
func _on_legacy_stair_selected(stair_id: StringName) -> void:
|
||
_set_ledger_panel_open(false)
|
||
_set_supply_panel_open(false)
|
||
_set_management_panel_open(true)
|
||
_set_excavation_mode(false, false)
|
||
_selected_entity_id = stair_id
|
||
_selected_entity_kind = &"legacy_stairs"
|
||
world_preview.selected_entity_id = stair_id
|
||
_refresh_selected_entity()
|
||
|
||
|
||
func _on_construction_selected(job_id: StringName) -> void:
|
||
_set_ledger_panel_open(false)
|
||
_set_supply_panel_open(false)
|
||
_set_management_panel_open(true)
|
||
_set_excavation_mode(false, false)
|
||
_selected_entity_id = job_id
|
||
_selected_entity_kind = &"construction"
|
||
_refresh_selected_entity()
|
||
|
||
|
||
func _refresh_dashboard() -> void:
|
||
if event_log_dialog != null and event_log_dialog.visible:
|
||
event_log_dialog.refresh()
|
||
if rank_button != null and _service(&"progression_service") != null:
|
||
rank_button.text="世界樹ランク %d" % _service(&"progression_service").current_rank
|
||
if rank_dialog != null and rank_dialog.visible: rank_dialog.refresh()
|
||
if weather_label != null and is_instance_valid(_runtime) and _runtime.has_method("get_weather_report"):
|
||
var weather_report: Dictionary = _runtime.get_weather_report()
|
||
var weather_names := {&"clear": "晴れ", &"rain": "雨", &"storm": "嵐", &"mana_fog": "マナ霧"}
|
||
weather_label.text = weather_names.get(weather_report.get(&"kind", &""), "天候 —")
|
||
var guests := _call_array(_service(&"guest_service"), &"get_guests")
|
||
var queued_guests := 0
|
||
var served_guests := 0
|
||
for guest: Variant in guests:
|
||
if guest == null:
|
||
continue
|
||
if int(guest.state) == 4:
|
||
queued_guests += 1
|
||
elif int(guest.state) == 5:
|
||
served_guests += 1
|
||
var staff_members := _call_array(_service(&"staff_service"), &"get_staff_members")
|
||
var available_staff := 0
|
||
var working_staff := 0
|
||
for staff: Variant in staff_members:
|
||
if staff == null:
|
||
continue
|
||
if int(staff.state) == 2:
|
||
available_staff += 1
|
||
elif int(staff.state) in [3, 4]:
|
||
working_staff += 1
|
||
var event_service: Variant = _service(&"event_service")
|
||
var events := _call_array(event_service, &"get_instances")
|
||
var active_events := 0
|
||
for event_instance: Variant in events:
|
||
if event_instance != null and int(event_instance.state) in [0, 1, 2, 7]:
|
||
active_events += 1
|
||
var active_jobs := 0
|
||
for job: Variant in _call_array(_service(&"construction_service"), &"get_jobs"):
|
||
if job != null and int(job.state) not in [2, 3]:
|
||
active_jobs += 1
|
||
var heat_text := "—"
|
||
var heat_value := 0
|
||
var authority: Variant = _service(&"authority_service")
|
||
if authority != null and authority.has_method(&"get_authority_state"):
|
||
var heat_state: Variant = authority.call(&"get_authority_state")
|
||
if heat_state != null:
|
||
heat_value = int(heat_state.heat)
|
||
heat_text = "%d / 100" % heat_value
|
||
var foreground_text := "なし"
|
||
if event_service != null and event_service.has_method(&"get_foreground_instance"):
|
||
var foreground: Variant = event_service.call(&"get_foreground_instance")
|
||
if foreground != null:
|
||
foreground_text = String(foreground.resolved_event_id)
|
||
_refresh_foreground_event(event_service)
|
||
var defense_summary: Dictionary = {}
|
||
var defense: Variant = _service(&"defense_service")
|
||
if defense != null and defense.has_method(&"get_threat_summary"):
|
||
defense_summary = defense.call(&"get_threat_summary")
|
||
var threat_count := int(defense_summary.get(&"total", 0))
|
||
var defense_text := "防衛 準備中 %d / 戦闘中 %d / 次波待ち %d" % [
|
||
int(defense_summary.get(&"preparing", 0)), int(defense_summary.get(&"active", 0)),
|
||
int(defense_summary.get(&"between_waves", 0))]
|
||
var operations_text := _operations_dashboard_text()
|
||
dashboard_summary_label.text = (
|
||
"客 %d人(待機 %d / 利用 %d)\n" % [guests.size(), queued_guests, served_guests]
|
||
+ "従業員 %d人(待機 %d / 作業 %d)\n" % [staff_members.size(), available_staff, working_staff]
|
||
+ "イベント %d件 前景: %s\n" % [active_events, foreground_text]
|
||
+ "警戒度 %s 建設中 %d件" % [heat_text, active_jobs]
|
||
# Keep a stable row so threat changes do not move staff drag targets.
|
||
+ "\n" + defense_text
|
||
+ operations_text
|
||
)
|
||
if guest_count_label != null:
|
||
guest_count_label.text = "来客 %d人" % guests.size()
|
||
if lodging_count_label != null:
|
||
var lodging_guests := 0
|
||
for guest: Variant in guests:
|
||
if guest != null and int(guest.planned_exit_at) >= GameClock.absolute_minute + 360:
|
||
lodging_guests += 1
|
||
lodging_count_label.text = "宿泊 %d人" % lodging_guests
|
||
if alert_count_label != null:
|
||
alert_count_label.text = "警戒 %d" % heat_value + (" / 襲撃 %d" % threat_count if threat_count > 0 else "")
|
||
alert_count_label.tooltip_text = (defense_text + "\n防衛参加中の従業員は配属変更できません。") if threat_count > 0 else "進行中の襲撃はありません。"
|
||
alert_count_label.add_theme_color_override(
|
||
"font_color",
|
||
Color("ffb498") if heat_value >= 60 or threat_count > 0 else Color("ebca84") if heat_value >= 30 else Color("c7d3a8")
|
||
)
|
||
_refresh_staff_candidates()
|
||
_refresh_selected_entity()
|
||
_update_pause_and_speed()
|
||
if opening_guide != null:
|
||
opening_guide.refresh()
|
||
if operating_ledger_panel != null and _ledger_panel_open:
|
||
operating_ledger_panel.refresh()
|
||
|
||
|
||
func _refresh_foreground_event(event_service: Variant) -> void:
|
||
if foreground_event_panel == null \
|
||
or foreground_event_label == null \
|
||
or foreground_event_choices == null:
|
||
return
|
||
for child: Node in foreground_event_choices.get_children():
|
||
foreground_event_choices.remove_child(child)
|
||
child.queue_free()
|
||
var foreground: Variant = null
|
||
if event_service != null and event_service.has_method(&"get_foreground_instance"):
|
||
foreground = event_service.call(&"get_foreground_instance")
|
||
if foreground == null or int(foreground.state) != 2:
|
||
foreground_event_panel.visible = false
|
||
return
|
||
foreground_event_panel.visible = true
|
||
if _audio_feedback != null:
|
||
_audio_feedback.observe_foreground(foreground)
|
||
_set_ledger_panel_open(false)
|
||
_set_supply_panel_open(false)
|
||
_set_management_panel_open(true)
|
||
var actors: Array[String] = []
|
||
for actor_value: Variant in foreground.actor_ids:
|
||
actors.append(String(actor_value))
|
||
foreground_event_label.text = (
|
||
"%s\n関係者: %s" % [
|
||
String(foreground.resolved_event_id),
|
||
"・".join(actors) if not actors.is_empty() else "なし",
|
||
]
|
||
)
|
||
var definition: Variant = null
|
||
if event_service.has_method(&"get_definition"):
|
||
definition = event_service.call(&"get_definition", foreground.resolved_event_id)
|
||
var choices_value: Variant = _definition_value(definition, &"choices", [])
|
||
if not choices_value is Array:
|
||
return
|
||
for choice: Variant in choices_value:
|
||
if choice == null:
|
||
continue
|
||
var choice_id := StringName(_definition_value(choice, &"choice_id", &""))
|
||
if choice_id.is_empty():
|
||
continue
|
||
var choice_button := Button.new()
|
||
choice_button.text = _event_choice_button_text(choice)
|
||
choice_button.custom_minimum_size.y = 34
|
||
_apply_button_style(choice_button)
|
||
choice_button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
choice_button.pressed.connect(
|
||
_submit_foreground_event_choice.bind(
|
||
foreground.event_instance_id,
|
||
foreground.choice_instance_id,
|
||
choice_id
|
||
)
|
||
)
|
||
foreground_event_choices.add_child(choice_button)
|
||
|
||
|
||
func _event_choice_button_text(choice: Variant) -> String:
|
||
var choice_id := StringName(_definition_value(choice, &"choice_id", &""))
|
||
var text_key := StringName(_definition_value(choice, &"text_key", choice_id))
|
||
var title := String(EVENT_CHOICE_NAMES.get(text_key, String(choice_id)))
|
||
var cost_value: Variant = _definition_value(choice, &"cost", {})
|
||
if not cost_value is Dictionary or cost_value.is_empty():
|
||
return title
|
||
var cost_parts: Array[String] = []
|
||
var cost_keys: Array = cost_value.keys()
|
||
cost_keys.sort_custom(func(left: Variant, right: Variant) -> bool:
|
||
return String(left) < String(right)
|
||
)
|
||
for raw_key: Variant in cost_keys:
|
||
cost_parts.append("%s %d" % [String(raw_key), int(cost_value[raw_key])])
|
||
return "%s(%s)" % [title, " / ".join(cost_parts)]
|
||
|
||
|
||
func _submit_foreground_event_choice(
|
||
event_instance_id: StringName,
|
||
choice_instance_id: StringName,
|
||
choice_id: StringName
|
||
) -> void:
|
||
if _runtime == null or not _runtime.has_method(&"submit_command"):
|
||
_set_status("イベント選択を送信できません。", false)
|
||
return
|
||
var idempotency_key := StringName(
|
||
"ui.event.choice.%s.%s" % [String(choice_instance_id), String(choice_id)]
|
||
)
|
||
var result: Variant = _submit_ui_command(&"submit_event_choice",
|
||
{
|
||
&"event_instance_id": event_instance_id,
|
||
&"choice_instance_id": choice_instance_id,
|
||
&"choice_id": choice_id,
|
||
},
|
||
&"player",
|
||
event_instance_id,
|
||
100,
|
||
-1,
|
||
idempotency_key
|
||
)
|
||
var succeeded: bool = result != null \
|
||
and result.has_method(&"is_success") \
|
||
and bool(result.call(&"is_success"))
|
||
_set_status(
|
||
"イベントの選択を確定しました。" if succeeded \
|
||
else _command_failure_message(result) if result != null else "イベント選択に失敗しました。",
|
||
succeeded
|
||
)
|
||
_refresh_dashboard()
|
||
_update_pause_and_speed()
|
||
|
||
|
||
func _operations_dashboard_text() -> String:
|
||
if _runtime == null or not _runtime.has_method(&"get_operations_report"):
|
||
return ""
|
||
var report_value: Variant = _runtime.call(&"get_operations_dashboard_report") if _runtime.has_method(&"get_operations_dashboard_report") else _runtime.call(&"get_operations_report")
|
||
if not report_value is Dictionary:
|
||
return ""
|
||
var report: Dictionary = report_value
|
||
if not bool(report.get(&"enabled", false)):
|
||
return "\n運営日次処理 未開始"
|
||
var coordinator_value: Variant = report.get(&"coordinator", {})
|
||
var coordinator: Dictionary = coordinator_value if coordinator_value is Dictionary else {}
|
||
var terminal := StringName(coordinator.get(&"terminal_state", &"RUNNING"))
|
||
var state_text := "営業中"
|
||
if terminal == &"BANKRUPT":
|
||
state_text = "ゲーム終了"
|
||
elif bool(report.get(&"runtime_failure_latched", false)):
|
||
state_text = "処理失敗・再試行待ち"
|
||
elif bool(report.get(&"tick_blocked", false)):
|
||
state_text = "確認待ち"
|
||
var day_profit_text := "未確定"
|
||
var summaries_value: Variant = coordinator.get(&"day_summaries", [])
|
||
if summaries_value is Array and not summaries_value.is_empty():
|
||
var latest_value: Variant = summaries_value.back()
|
||
if latest_value is Dictionary:
|
||
day_profit_text = "G %+d" % int(latest_value.get(&"net_profit", 0))
|
||
var old_financial_history: bool = summaries_value is Array and not summaries_value.is_empty() and int(summaries_value.back().get(&"day", 0)) <= int(coordinator.get(&"legacy_history_last_day", 0))
|
||
return (
|
||
"\n行動点 %d / 6 未払経費 G %d\n" % [
|
||
int(report.get(&"personal_action_points", 0)),
|
||
int(report.get(&"arrears_total", coordinator.get(&"arrears_total", 0))),
|
||
]
|
||
+ "運営状態 %s 前日損益 %s" % [state_text, day_profit_text]
|
||
+ "\n%s %d / 3回" % ["旧版の連続未払い" if old_financial_history else "連続赤字", int(coordinator.get(&"consecutive_insolvency_count", 0))]
|
||
)
|
||
|
||
|
||
func _refresh_selected_entity() -> void:
|
||
related_facility_picker.visible = false
|
||
selected_elevator_controls.visible = false
|
||
selected_detail_label.tooltip_text = ""
|
||
if _selected_entity_id.is_empty():
|
||
_move_selected_detail_first(false)
|
||
selected_entity_icon.texture = null
|
||
selected_heading_label.text = "掘削モード" if _excavation_mode else "選択中の施設"
|
||
selected_detail_label.text = (
|
||
"地下のマスにカーソルを合わせると、費用と所要時間を確認できます。"
|
||
if _excavation_mode else "地図上の施設をクリックすると、営業状態を確認できます。"
|
||
)
|
||
return
|
||
if _selected_entity_kind == &"construction":
|
||
_move_selected_detail_first(false)
|
||
_refresh_selected_construction()
|
||
return
|
||
if _selected_entity_kind == &"excavation":
|
||
_move_selected_detail_first(false)
|
||
_refresh_selected_excavation()
|
||
return
|
||
if _refresh_selected_stairs():
|
||
return
|
||
var shaft := _selected_elevator_report()
|
||
if not shaft.is_empty():
|
||
_refresh_selected_elevator(shaft)
|
||
return
|
||
_move_selected_detail_first(false)
|
||
var facility_service: Variant = _service(&"facility_service")
|
||
if facility_service == null or not facility_service.has_method(&"get_instance"):
|
||
selected_detail_label.text = "施設サービスを利用できません。"
|
||
return
|
||
var facility: Variant = facility_service.call(&"get_instance", _selected_entity_id)
|
||
if facility == null:
|
||
var construction_facility := _construction_facility(_selected_entity_id)
|
||
if construction_facility.is_empty():
|
||
_selected_entity_id = &""
|
||
_selected_entity_kind = &""
|
||
world_preview.selected_entity_id = &""
|
||
selected_heading_label.text = "選択中の施設"
|
||
selected_detail_label.text = "選択していた施設は現在の世界に存在しません。"
|
||
selected_entity_icon.texture = null
|
||
return
|
||
var construction_definition_id := StringName(construction_facility.get(&"definition_id", &""))
|
||
var infrastructure_definition: Variant = _facility_definition(construction_definition_id)
|
||
selected_heading_label.text = String(_definition_value(infrastructure_definition, &"display_name", construction_definition_id))
|
||
selected_entity_icon.texture = visual_theme.facility_icon(construction_definition_id) if visual_theme != null else null
|
||
selected_detail_label.text = "設備完成\n" + _facility_upkeep_text(_selected_entity_id)
|
||
return
|
||
var definition: Variant = _facility_definition(facility.definition_id)
|
||
selected_entity_icon.texture = visual_theme.facility_icon(facility.definition_id) if visual_theme != null else null
|
||
related_facility_picker.clear()
|
||
related_facility_picker.add_item("関連施設:指定なし")
|
||
related_facility_picker.set_item_metadata(0, &"")
|
||
for related: FacilityInstance in _runtime.facility_service.get_instances():
|
||
if related.instance_id == facility.instance_id:
|
||
continue
|
||
var related_definition: Variant = _facility_definition(related.definition_id)
|
||
related_facility_picker.add_item("関連施設:%s (%d, %d)" % [String(_definition_value(related_definition, &"display_name", related.definition_id)), related.origin.x, related.origin.y])
|
||
var index := related_facility_picker.item_count - 1
|
||
related_facility_picker.set_item_metadata(index, related.instance_id)
|
||
if related.instance_id == facility.related_facility_id:
|
||
related_facility_picker.select(index)
|
||
related_facility_picker.visible = true
|
||
var display_name := String(_definition_value(definition, &"display_name", facility.definition_id))
|
||
var state_index := clampi(int(facility.state), 0, FACILITY_STATE_NAMES.size() - 1)
|
||
var reasons := _facility_reasons(facility.state_reasons)
|
||
var queue_count: int = facility.queue_entries.size()
|
||
var service_count: int = facility.active_services.size()
|
||
var staff_count: int = facility.active_staff_ids.size()
|
||
var occupied_slots: int = service_count
|
||
var related_name := "なし"
|
||
if not facility.related_facility_id.is_empty():
|
||
var related: FacilityInstance = _runtime.facility_service.get_instance(facility.related_facility_id)
|
||
related_name = "撤去済み" if related == null else "%s (%d, %d)" % [String(_definition_value(_facility_definition(related.definition_id), &"display_name", related.definition_id)), related.origin.x, related.origin.y]
|
||
selected_heading_label.text = display_name
|
||
selected_detail_label.text = (
|
||
"状態 %s%s\n" % [FACILITY_STATE_NAMES[state_index], "(%s)" % reasons if not reasons.is_empty() else ""]
|
||
+ "待機列 %d / %d サービス %d / %d\n" % [queue_count, int(facility.queue_capacity), occupied_slots, int(facility.capacity)]
|
||
+ "稼働区分 %s\n顧客同時利用 %d / %s\n" % [["未稼働", "稼働", "休止"][facility.operation_state], facility.customer_count(), str(facility.customer_capacity) if facility.customer_capacity >= 0 else "未設定"]
|
||
+ "関連施設 %s(%s)\n従業員上限 %d\n" % [related_name, "稼働条件成立" if facility.related_facility_operating else "未成立", facility.staff_max]
|
||
+ "配置人員 %d / 必要 %d 耐久 %d%%\n" % [staff_count, int(facility.staff_min), int(facility.durability) / 100]
|
||
+ _facility_upkeep_text(_selected_entity_id)
|
||
+ "基本料金 G %d 所要 %d分\n" % [int(facility.base_price), int(facility.base_service_minutes)]
|
||
+ "提供サービス %s\n" % {&"": "未設定", &"appetite": "食欲", &"relaxation": "リラックス欲", &"sexual": "性欲", &"gambling": "ギャンブル欲"}.get(facility.service_attribute, "未設定")
|
||
+ "座標 (%d, %d) %s" % [facility.origin.x, facility.origin.y, "FRONT" if facility.origin.z == 0 else "BACK"]
|
||
)
|
||
|
||
|
||
func _facility_upkeep_text(facility_id: StringName) -> String:
|
||
if _runtime == null or not _runtime.has_method(&"get_facility_upkeep_report"):
|
||
return ""
|
||
var report: Dictionary = _runtime.call(&"get_facility_upkeep_report", facility_id)
|
||
var pending := float(report[&"unsettled_units"]) / float(report[&"gold_denominator"])
|
||
return "維持費 %dG / 日\n精算前 約%.2fG 未払い %dG\n閉店・休止中も計上\n完成から撤去までの時間で按分\n" % [int(report[&"daily_rate"]), pending, int(report[&"arrears"])]
|
||
|
||
|
||
func _on_related_facility_selected(index: int) -> void:
|
||
if _runtime == null or _selected_entity_id.is_empty():
|
||
return
|
||
var related_id := StringName(related_facility_picker.get_item_metadata(index))
|
||
var result: Variant = _submit_ui_command(&"set_facility_conditions", {&"facility_id": _selected_entity_id, &"conditions": {&"related_facility_id": related_id}})
|
||
if result.is_success() and not result.details.has(&"instance_id"):
|
||
_pending_related_commands[int(result.command_id)] = true
|
||
_set_status("関連施設の変更を受け付けました。" if result.is_success() else result.message, result.is_success(), true)
|
||
_refresh_selected_entity()
|
||
|
||
|
||
func _refresh_selected_stairs() -> bool:
|
||
var from_cell := Vector3i.ZERO
|
||
var to_cell := Vector3i.ZERO
|
||
var definition_id: StringName = &""
|
||
var legacy := _selected_entity_kind == &"legacy_stairs"
|
||
var legacy_access: StringName = &""
|
||
if legacy:
|
||
for stair: Dictionary in world_preview.get_legacy_stair_records():
|
||
if StringName(stair[&"id"]) == _selected_entity_id:
|
||
from_cell = stair[&"from"]
|
||
legacy_access = StringName(stair.get(&"access_id", &""))
|
||
to_cell = stair[&"to"]
|
||
definition_id = &"guest_stairs" if from_cell.z == 0 else &"service_stairs"
|
||
break
|
||
else:
|
||
var facility := _construction_facility(_selected_entity_id)
|
||
definition_id = StringName(facility.get(&"definition_id", &""))
|
||
if definition_id not in [&"guest_stairs", &"service_stairs"]:
|
||
return false
|
||
var origin: Variant = facility.get(&"origin", Vector3i.ZERO)
|
||
if origin is Array and origin.size() == 3:
|
||
origin = Vector3i(int(origin[0]), int(origin[1]), int(origin[2]))
|
||
to_cell = origin
|
||
from_cell = to_cell+Vector3i(0,-1,0)
|
||
if definition_id.is_empty():
|
||
return false
|
||
_move_selected_detail_first(true)
|
||
var staff_only := legacy_access == &"staff" if legacy else definition_id == &"service_stairs"
|
||
selected_heading_label.text = "既設の階段" if legacy else ("業務用階段" if staff_only else "客用階段")
|
||
selected_entity_icon.texture = visual_theme.facility_icon(definition_id) if visual_theme != null else null
|
||
selected_detail_label.text = (
|
||
"接続階 %s ↔ %s\n" % [_elevator_floor_name(mini(from_cell.y,to_cell.y)), _elevator_floor_name(maxi(from_cell.y,to_cell.y))]
|
||
+ "利用者 %s\n" % ("従業員専用・物資運搬可" if staff_only else "客・従業員用")
|
||
+ "階段で昇り降りします。魔力は不要です。\n"
|
||
+ "座標 (%d, %d) %s" % [to_cell.x,maxi(from_cell.y,to_cell.y),"BACK" if to_cell.z == 1 else "FRONT"]
|
||
)
|
||
return true
|
||
|
||
|
||
func _selected_elevator_report() -> Dictionary:
|
||
if _selected_entity_kind != &"facility" or _selected_entity_id.is_empty():
|
||
return {}
|
||
var transport: Variant = _service(&"elevators")
|
||
if transport == null or not transport.has_method(&"get_report"):
|
||
return {}
|
||
for shaft: Dictionary in transport.call(&"get_report").get(&"shafts", []):
|
||
if (shaft.get(&"module_ids", []) as Array).has(_selected_entity_id):
|
||
return shaft
|
||
return {}
|
||
|
||
|
||
func _refresh_selected_elevator(shaft: Dictionary) -> void:
|
||
_move_selected_detail_first(true)
|
||
var construction_facility := _construction_facility(_selected_entity_id)
|
||
var definition_id := StringName(construction_facility.get(&"definition_id", &"guest_elevator"))
|
||
var definition: Variant = _facility_definition(definition_id)
|
||
selected_heading_label.text = String(_definition_value(definition, &"display_name", "昇降機"))
|
||
selected_entity_icon.texture = visual_theme.facility_icon(definition_id) if visual_theme != null else null
|
||
var powered := bool(shaft.get(&"powered", true))
|
||
var operator_open := bool(shaft.get(&"operator_open", true))
|
||
var state_text := String(ELEVATOR_PHASE_NAMES.get(StringName(shaft.get(&"phase", &"IDLE")), "待機"))
|
||
if not operator_open:
|
||
state_text = "手動停止"
|
||
elif not powered:
|
||
state_text = "魔力供給なし"
|
||
var floor_index := int(shaft.get(&"current_floor", 0))
|
||
var target_index := int(shaft.get(&"boarding_floor", floor_index) if StringName(shaft.get(&"phase", &"IDLE")) == &"PICKUP" else shaft.get(&"target_floor", floor_index))
|
||
var stops: Array = shaft.get(&"stops", [])
|
||
var stop_text := "—"
|
||
if not stops.is_empty():
|
||
stop_text = "%s 〜 %s" % [_elevator_floor_name(stops.front().y), _elevator_floor_name(stops.back().y)]
|
||
selected_detail_label.text = (
|
||
"状態 %s\n" % state_text
|
||
+ "現在階 %s 目的階 %s\n" % [_elevator_floor_name(floor_index), _elevator_floor_name(target_index)]
|
||
+ "乗客 %d / %d人 待機 %d人\n" % [int(shaft.get(&"passenger_count", 0)), int(shaft.get(&"capacity", 0)), int(shaft.get(&"waiting_count", 0))]
|
||
+ "停止階 %s\n" % stop_text
|
||
+ "魔力 %s %s" % ["供給中" if powered else "不足", "客・従業員用" if definition_id == &"guest_elevator" else "従業員・物資用"]
|
||
)
|
||
selected_detail_label.text += _elevator_wait_text(StringName(shaft.get(&"shaft_id", &"")))
|
||
selected_elevator_controls.visible = true
|
||
elevator_operation_button.disabled = _pending_elevator_commands.values().has(shaft.get(&"shaft_id", &""))
|
||
elevator_operation_button.text = "指示を受理済み…" if elevator_operation_button.disabled else ("昇降機を停止" if operator_open else "運行を再開")
|
||
elevator_operation_hint.text = "停止すると最寄りの階で降車し、待機中の呼び出しを解除します。" if operator_open else ("再開すると新しい呼び出しを受け付けます。" if powered else "魔力が供給されると運行を再開できます。")
|
||
|
||
|
||
func _elevator_wait_text(shaft_id: StringName) -> String:
|
||
selected_detail_label.tooltip_text = "保持している直近256件の出来事のうち、過去60分の乗車を集計。現在の最長待ちは、いま並んでいる人の待ち時間です。"
|
||
if _runtime == null or not _runtime.has_method(&"get_elevator_wait_statistics"):
|
||
return "\n最近の乗車記録 計測待ち"
|
||
var statistics: Dictionary = _runtime.call(&"get_elevator_wait_statistics", shaft_id)
|
||
var samples := int(statistics.get(&"sample_count", 0))
|
||
var text := "\n最近の乗車記録 %d人" % samples
|
||
if samples == 0:
|
||
text += "(計測待ち)"
|
||
else:
|
||
text += "\n平均待ち %.1f分 / 95%% %.1f分" % [float(statistics.get(&"mean_wait_minutes",0)),float(statistics.get(&"p95_wait_minutes",0))]
|
||
return text+"\n現在の最長待ち %d分" % int(statistics.get(&"oldest_wait_minutes",0))
|
||
|
||
|
||
func _move_selected_detail_first(first: bool) -> void:
|
||
var column := selected_heading_label.get_parent()
|
||
var selected_row := selected_detail_label.get_parent()
|
||
# The transport action fits without scrolling; other management views keep
|
||
# their existing staff-first layout.
|
||
var desired_index := 0 if first else column.get_child_count()-4
|
||
if selected_heading_label.get_index() == desired_index:
|
||
return
|
||
column.move_child(selected_heading_label, 0 if first else -1)
|
||
column.move_child(selected_row, 1 if first else -1)
|
||
column.move_child(selected_elevator_controls, 2 if first else -1)
|
||
column.move_child(related_facility_picker, 3 if first else -1)
|
||
|
||
|
||
func _elevator_floor_name(floor_index: int) -> String:
|
||
return "%dF" % (floor_index+1) if floor_index >= 0 else "B%d" % -floor_index
|
||
|
||
|
||
func _on_elevator_operation_pressed() -> void:
|
||
var shaft := _selected_elevator_report()
|
||
if shaft.is_empty() or _runtime == null:
|
||
return
|
||
var shaft_id := StringName(shaft[&"shaft_id"])
|
||
if _pending_elevator_commands.values().has(shaft_id):
|
||
return
|
||
var key := StringName("ui.elevator.%08d" % _elevator_sequence)
|
||
_elevator_sequence += 1
|
||
var open_value := not bool(shaft.get(&"operator_open", true))
|
||
var result: Variant = _submit_ui_command(&"set_elevator_operation", {&"shaft_id": shaft_id, &"operator_open": open_value}, &"player", &"", 50, -1, key)
|
||
if result == null or not result.is_success():
|
||
_set_status("昇降機の指示を反映できませんでした。", false)
|
||
return
|
||
if not result.details.has(&"shaft_id"):
|
||
_pending_elevator_commands[int(result.command_id)] = shaft_id
|
||
_set_status("昇降機の運行指示を受理しました。", true)
|
||
else:
|
||
var message := "昇降機を停止しました。"
|
||
if open_value:
|
||
message = "昇降機の運行を再開しました。" if bool(shaft.get(&"powered", true)) else "運行を許可しました。魔力の供給待ちです。"
|
||
_set_status(message, true)
|
||
_refresh_selected_entity()
|
||
|
||
|
||
func _synchronize_elevator_pending() -> void:
|
||
var queue: Variant = _service(&"command_queue")
|
||
if queue == null:
|
||
return
|
||
if queue.pending_count() == 0:
|
||
_pending_elevator_commands.clear()
|
||
return
|
||
_pending_elevator_commands.clear()
|
||
for command: Dictionary in queue.create_snapshot().get("pending", []):
|
||
if StringName(command.get("command_type", &"")) == &"set_elevator_operation":
|
||
_pending_elevator_commands[int(command.get("command_id", 0))] = StringName((command.get("payload", {}) as Dictionary).get(&"shaft_id", &""))
|
||
|
||
|
||
func _refresh_selected_construction() -> void:
|
||
var construction: Variant = _service(&"construction_service")
|
||
if construction == null or not construction.has_method(&"get_job"):
|
||
selected_detail_label.text = "建設サービスを利用できません。"
|
||
return
|
||
var job: Variant = construction.call(&"get_job", _selected_entity_id)
|
||
if job == null:
|
||
_selected_entity_id = &""
|
||
_selected_entity_kind = &""
|
||
world_preview.selected_entity_id = &""
|
||
selected_heading_label.text = "選択中の施設"
|
||
selected_detail_label.text = "選択していた工事は現在の世界に存在しません。"
|
||
selected_entity_icon.texture = null
|
||
return
|
||
var definition: Variant = _facility_definition(job.definition_id)
|
||
selected_entity_icon.texture = visual_theme.facility_icon(job.definition_id) if visual_theme != null else null
|
||
var display_name := String(_definition_value(definition, &"display_name", job.definition_id))
|
||
var state_names := ["計画済み", "建設中", "完成", "中止", "停止中"]
|
||
var job_state := clampi(int(job.state), 0, state_names.size() - 1)
|
||
selected_heading_label.text = "%s(工事)" % display_name
|
||
selected_detail_label.text = (
|
||
"状態 %s\n" % state_names[job_state]
|
||
+ "進捗 %d%% 予定 %d分\n" % [int(job.progress) / 100, int(job.build_minutes)]
|
||
+ "座標 (%d, %d) %s\n" % [job.origin.x, job.origin.y, "FRONT" if int(job.layer) == 0 else "BACK"]
|
||
+ ("停止理由 %s" % String(job.state_reason) if not String(job.state_reason).is_empty() else "建設はシミュレーション時間とともに進みます。")
|
||
)
|
||
|
||
|
||
func _select_excavation_job(job_id: StringName) -> void:
|
||
if job_id.is_empty():
|
||
return
|
||
_selected_entity_id = job_id
|
||
_selected_entity_kind = &"excavation"
|
||
world_preview.selected_entity_id = &""
|
||
world_preview.clear_hovered_cell()
|
||
_refresh_selected_excavation()
|
||
|
||
|
||
func _refresh_selected_excavation() -> void:
|
||
selected_entity_icon.texture = visual_theme.control_icon(&"excavate") if visual_theme != null else null
|
||
var excavation: Variant = _service(&"excavation_service")
|
||
if excavation == null or not excavation.has_method(&"get_job"):
|
||
selected_detail_label.text = "掘削サービスを利用できません。"
|
||
return
|
||
var job_value: Variant = excavation.call(&"get_job", _selected_entity_id)
|
||
if not job_value is Dictionary or job_value.is_empty():
|
||
_selected_entity_id = &""
|
||
_selected_entity_kind = &""
|
||
selected_heading_label.text = "掘削モード" if _excavation_mode else "選択中の施設"
|
||
selected_detail_label.text = "選択していた掘削作業は現在の世界に存在しません。"
|
||
return
|
||
var job: Dictionary = job_value
|
||
var state := StringName(job.get(&"state", &""))
|
||
var state_text := "掘削中"
|
||
if state == &"PAUSED":
|
||
state_text = "一時停止"
|
||
elif state == &"COMPLETED":
|
||
state_text = "完了"
|
||
var position: Vector2i = job.get(&"position", Vector2i.ZERO)
|
||
var cost_value: Variant = job.get(&"cost", {})
|
||
var cost: Dictionary = cost_value if cost_value is Dictionary else {}
|
||
var guidance := "両レイヤーが開通しました。"
|
||
if state == &"ACTIVE":
|
||
guidance = "掘削はシミュレーション時間とともに進みます。"
|
||
elif state == &"PAUSED":
|
||
guidance = "停止理由 %s" % String(job.get(&"state_reason", ""))
|
||
selected_heading_label.text = "地下掘削(%d, %d)" % [position.x, position.y]
|
||
selected_detail_label.text = (
|
||
"状態 %s 進捗 %d%%\n" % [state_text, clampi(int(job.get(&"progress", 0)) / 100, 0, 100)]
|
||
+ "所要 %d分 費用 G %d / 資材 %d\n" % [
|
||
int(job.get(&"build_minutes", 0)),
|
||
int(cost.get(&"gold", cost.get("gold", 0))),
|
||
int(cost.get(&"materials", cost.get("materials", 0))),
|
||
]
|
||
+ "対象 FRONT / 表層・BACK / 裏層\n"
|
||
+ guidance
|
||
)
|
||
|
||
|
||
func _preview_failure_message(report: Dictionary) -> String:
|
||
var codes_value: Variant = report.get(&"error_codes", [])
|
||
if codes_value is Array and not codes_value.is_empty():
|
||
var code := String(codes_value[0])
|
||
if PREVIEW_REASON_NAMES.has(code):
|
||
return "配置不可: %s" % PREVIEW_REASON_NAMES[code]
|
||
var code_reasons: Variant = report.get(&"reasons", [])
|
||
if code_reasons is Array and not code_reasons.is_empty():
|
||
var reason: Variant = code_reasons[0]
|
||
for candidate: Variant in code_reasons:
|
||
if candidate is Dictionary and String(candidate.get(&"code", "")) == code:
|
||
reason = candidate
|
||
break
|
||
if reason is Dictionary:
|
||
var message := String(reason.get(&"message", ""))
|
||
if not message.is_empty():
|
||
return "配置不可: %s" % message
|
||
var reasons_value: Variant = report.get(&"reasons", [])
|
||
if reasons_value is Array and not reasons_value.is_empty() and reasons_value[0] is Dictionary:
|
||
return "配置不可: %s" % String(reasons_value[0].get(&"message", "条件を満たしていません"))
|
||
return "このマスには配置できません。"
|
||
|
||
|
||
func _excavation_failure_message(report: Dictionary) -> String:
|
||
var errors_value: Variant = report.get(&"errors", [])
|
||
var reason := "条件を満たしていません"
|
||
if errors_value is Array and not errors_value.is_empty():
|
||
var code := StringName(errors_value[0])
|
||
reason = String(EXCAVATION_REASON_NAMES.get(code, String(code)))
|
||
if code == &"INSUFFICIENT_RESOURCES":
|
||
var missing_value: Variant = report.get(&"missing_resources", {})
|
||
if missing_value is Dictionary and not missing_value.is_empty():
|
||
var missing: Dictionary = missing_value
|
||
reason += "(不足: G %d / 資材 %d)" % [
|
||
int(missing.get(&"gold", missing.get("gold", 0))),
|
||
int(missing.get(&"materials", missing.get("materials", 0))),
|
||
]
|
||
return "掘削不可: %s%s" % [reason, _excavation_cost_and_time(report)]
|
||
|
||
|
||
func _excavation_cost_and_time(report: Dictionary) -> String:
|
||
var cost_value: Variant = report.get(&"cost", {})
|
||
var cost: Dictionary = cost_value if cost_value is Dictionary else {}
|
||
var minutes := int(report.get(&"build_minutes", 0))
|
||
if cost.is_empty() and minutes <= 0:
|
||
return ""
|
||
return "(費用 G %d / 資材 %d・所要 %d分)" % [
|
||
int(cost.get(&"gold", cost.get("gold", 0))),
|
||
int(cost.get(&"materials", cost.get("materials", 0))),
|
||
minutes,
|
||
]
|
||
|
||
|
||
func _excavation_gate_message() -> String:
|
||
var progression: Variant = _service(&"progression_service")
|
||
if progression == null:
|
||
return "世界樹ランクを確認できません"
|
||
var current_rank := int(_definition_value(progression, &"current_rank", 1))
|
||
if current_rank < 2:
|
||
return "世界樹ランク2で地下掘削が解禁されます(現在ランク%d)" % current_rank
|
||
if _runtime == null or not _runtime.has_method(&"get_operations_report"):
|
||
return "運営状態を確認できません"
|
||
var report_value: Variant = _runtime.call(&"get_operations_report")
|
||
if not report_value is Dictionary:
|
||
return "運営状態を確認できません"
|
||
var report: Dictionary = report_value
|
||
if not bool(report.get(&"enabled", false)):
|
||
return "本番運営が始まるまで掘削できません"
|
||
var coordinator_value: Variant = report.get(&"coordinator", {})
|
||
var coordinator: Dictionary = coordinator_value if coordinator_value is Dictionary else {}
|
||
if StringName(coordinator.get(&"terminal_state", &"RUNNING")) == &"BANKRUPT":
|
||
return "資金破綻により新しい掘削を開始できません"
|
||
if bool(coordinator.get(&"external_quarantined", false)):
|
||
return "運営データの復旧が必要なため掘削を開始できません"
|
||
var arrears := int(report.get(&"arrears_total", coordinator.get(&"arrears_total", 0)))
|
||
if arrears > 0:
|
||
return "未払経費 G %dを清算するまで掘削できません" % arrears
|
||
return ""
|
||
|
||
|
||
func _excavation_command_failure_message(result: Variant) -> String:
|
||
var details: Variant = result.details
|
||
if details is Dictionary:
|
||
var report_value: Variant = details.get(&"report", null)
|
||
if report_value is Dictionary:
|
||
return _excavation_failure_message(report_value)
|
||
var reason := StringName(details.get(&"reason", &""))
|
||
match reason:
|
||
&"ARREARS_REMAIN", &"UNPAID_EXPENSES":
|
||
return "掘削不可: 未払経費 G %dを清算してください" % int(details.get(&"arrears_total", 0))
|
||
&"BANKRUPT":
|
||
return "掘削不可: 資金破綻により新しい工事は開始できません"
|
||
&"EXTERNAL_ROLLBACK_FAILED":
|
||
return "掘削不可: 運営データの復旧が必要です"
|
||
if details.has(&"required_rank"):
|
||
return "掘削不可: 世界樹ランク%dが必要です(現在ランク%d)" % [
|
||
int(details.get(&"required_rank", 2)),
|
||
int(details.get(&"current_rank", 1)),
|
||
]
|
||
if int(result.code) == int(CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY):
|
||
return "この掘削指示はすでに受理されています。"
|
||
return _command_failure_message(result)
|
||
|
||
|
||
func _command_failure_message(result: Variant) -> String:
|
||
var message := String(result.message)
|
||
var details: Variant = result.details
|
||
if details is Dictionary:
|
||
var report_value: Variant = details.get(&"placement_report", null)
|
||
if report_value is Dictionary:
|
||
return _preview_failure_message(report_value)
|
||
return "操作に失敗しました: %s" % (message if not message.is_empty() else "不明なエラー")
|
||
|
||
|
||
func _save_manual() -> void:
|
||
_save_manual_at_path(manual_save_path, "手動保存")
|
||
|
||
|
||
func _save_manual_at_path(save_path: String, display_name: String) -> bool:
|
||
if _runtime == null or not _runtime.has_method(&"save_to_path"):
|
||
_set_status("保存サービスへ接続できません。", false)
|
||
return false
|
||
var directory_error := DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(save_path).get_base_dir())
|
||
if directory_error != OK and directory_error != ERR_ALREADY_EXISTS:
|
||
_set_status("保存フォルダを作成できませんでした。", false)
|
||
return false
|
||
var metadata := {&"display_name": display_name, &"interface_selection": _capture_interface_selection()}
|
||
var thumbnail := _capture_manual_thumbnail_png()
|
||
if not thumbnail.is_empty(): metadata[&"thumbnail_png"] = thumbnail
|
||
var result: Variant = _runtime.call(&"save_to_path", save_path, metadata)
|
||
if result is Dictionary and bool(result.get(&"ok", false)):
|
||
manual_save_path = save_path
|
||
_set_status("手動セーブを保存しました。", true)
|
||
return true
|
||
if result is Dictionary and StringName(result.get(&"code", &"")) == &"FORMAT_MIGRATION_REQUIRED":
|
||
_set_status("元の保存を残すため、別のスロットへ保存してください。", false)
|
||
_ensure_manual_save_slots().show_new_save(save_path)
|
||
else:
|
||
_set_status("保存に失敗しました: %s" % _result_message(result), false)
|
||
return false
|
||
|
||
|
||
func _capture_manual_thumbnail_png() -> PackedByteArray:
|
||
# Capture the existing rendered game; no simulation update or new art source.
|
||
# Dummy/headless rendering retains the existing SaveService fallback.
|
||
if DisplayServer.get_name() == "headless": return PackedByteArray()
|
||
var viewport := get_viewport()
|
||
if viewport == null: return PackedByteArray()
|
||
var texture := viewport.get_texture()
|
||
if texture == null: return PackedByteArray()
|
||
var image := texture.get_image()
|
||
if image == null or image.is_empty(): return PackedByteArray()
|
||
var scale := minf(320.0 / float(image.get_width()), 180.0 / float(image.get_height()))
|
||
var width := maxi(1, roundi(float(image.get_width()) * scale))
|
||
var height := maxi(1, roundi(float(image.get_height()) * scale))
|
||
image.resize(width, height, Image.INTERPOLATE_LANCZOS)
|
||
return image.save_png_to_buffer()
|
||
|
||
|
||
func _load_manual(strict: bool = false) -> void:
|
||
_load_manual_at_path(manual_save_path, strict)
|
||
|
||
|
||
func _load_manual_at_path(save_path: String, strict: bool = false) -> void:
|
||
if _runtime == null or (
|
||
not _runtime.has_method(&"load_with_recovery")
|
||
and not _runtime.has_method(&"load_from_path")
|
||
):
|
||
_set_status("読込サービスへ接続できません。", false)
|
||
return
|
||
var result: Variant = (
|
||
_runtime.call(&"load_with_recovery", save_path)
|
||
if not strict and _runtime.has_method(&"load_with_recovery")
|
||
else _runtime.call(&"load_from_path", save_path)
|
||
)
|
||
if result is Dictionary and bool(result.get(&"ok", false)):
|
||
manual_save_path = save_path
|
||
# Real Runtime already emitted its common completion notification.
|
||
if not _runtime.has_signal(&"save_operation_completed"):
|
||
_apply_loaded_interface(result)
|
||
_set_status("手動セーブを読み込みました。", true)
|
||
else:
|
||
_set_status("読み込みに失敗しました: %s" % _result_message(result), false)
|
||
|
||
|
||
func _ensure_manual_save_slots() -> Variant:
|
||
if manual_save_slots_dialog == null:
|
||
manual_save_slots_dialog = ManualSaveSlotsDialogScript.new()
|
||
manual_save_slots_dialog.connect(&"new_slot_requested", _save_new_manual_slot)
|
||
manual_save_slots_dialog.connect(&"load_slot_requested", _load_selected_manual_slot)
|
||
add_child(manual_save_slots_dialog)
|
||
return manual_save_slots_dialog
|
||
|
||
|
||
func _save_new_manual_slot(path: String, display_name: String) -> void:
|
||
if not ManualSaveSlotsDialogScript.is_fresh_slot(path) or path.get_base_dir() != manual_save_path.get_base_dir():
|
||
_set_status("新しい保存先を用意できませんでした。元の保存は変更していません。", false)
|
||
return
|
||
_save_manual_at_path(path, display_name)
|
||
|
||
|
||
func _show_manual_load_slots() -> void:
|
||
_ensure_manual_save_slots().show_load_slots(manual_save_path)
|
||
|
||
|
||
func _load_selected_manual_slot(path: String) -> void:
|
||
if path.get_base_dir() != manual_save_path.get_base_dir() or not ManualSaveSlotsDialogScript.is_owned_slot_name(path.get_file()):
|
||
_set_status("この保存先を読み込めません。", false)
|
||
return
|
||
_load_manual_at_path(path)
|
||
|
||
func _capture_interface_selection() -> Dictionary:
|
||
var selection := {&"version": 1, &"kind": String(_selected_entity_kind), &"id": String(_selected_entity_id), &"staff_open": _staff_management_panel_open, &"guest_open": false}
|
||
selection[&"product_open"] = _supply_panel_open
|
||
if supply_management_panel != null:
|
||
selection[&"product_tab"] = String(supply_management_panel.selected_tab)
|
||
if staff_management_panel != null:
|
||
selection[&"staff_id"] = String(staff_management_panel.get("selected_staff_id"))
|
||
if guest_observer != null:
|
||
var panel := guest_observer.find_child("GuestObservationPanel", true, false) as GuestObservationPanel
|
||
if panel != null:
|
||
selection[&"guest_id"] = String(panel.selected_guest_id)
|
||
selection[&"guest_open"] = guest_observer.visible
|
||
if defense_manager != null:
|
||
var panel := defense_manager.find_child("DefenseManagementPanel", true, false) as DefenseManagementPanel
|
||
if panel != null:
|
||
selection[&"defense_id"] = String(panel.selected_raid_id)
|
||
selection[&"defense_open"] = defense_manager.visible
|
||
return selection
|
||
|
||
|
||
func _on_checkpoint_presentation_requested(presentation: Dictionary) -> void:
|
||
presentation[&"interface_selection"] = _capture_interface_selection()
|
||
|
||
|
||
func _on_save_operation_completed(result: Dictionary) -> void:
|
||
if not bool(result.get(&"ok", false)) or StringName(result.get(&"operation", &"")) not in [&"load", &"recovery", &"recovery_candidate"]:
|
||
return
|
||
_apply_loaded_interface(result)
|
||
|
||
|
||
func _apply_loaded_interface(result: Dictionary) -> void:
|
||
if _audio_feedback != null:
|
||
_audio_feedback.begin_loaded_epoch(_current_audio_foreground())
|
||
_pending_build_commands.clear()
|
||
_pending_demolition_commands.clear()
|
||
_set_demolition_mode(false, false)
|
||
_pending_excavation_commands.clear()
|
||
_pending_hire_commands.clear()
|
||
_pending_elevator_commands.clear()
|
||
_synchronize_ui_command_state()
|
||
if staff_management_panel != null:
|
||
staff_management_panel.refresh()
|
||
if supply_management_panel != null:
|
||
supply_management_panel.refresh()
|
||
if business_management_panel != null:
|
||
business_management_panel.refresh()
|
||
_selected_entity_id = &""
|
||
_selected_entity_kind = &""
|
||
world_preview.selected_entity_id = &""
|
||
world_preview.set_runtime(_runtime)
|
||
_set_layer(GameState.active_layer)
|
||
_restore_interface_selection(result.get(&"manifest", {}))
|
||
_restore_actor_interface_selection(result.get(&"manifest", {}))
|
||
_restore_defense_interface_selection(result.get(&"manifest", {}))
|
||
_restore_product_interface_selection(result.get(&"manifest", {}))
|
||
_refresh_dashboard()
|
||
_update_pause_and_speed()
|
||
|
||
|
||
func _valid_interface_selection_version(value: Variant) -> bool:
|
||
# JSON numbers may be decoded as floats; booleans are never versions.
|
||
return typeof(value) in [TYPE_INT, TYPE_FLOAT] and value == 1
|
||
|
||
|
||
func _restore_interface_selection(manifest: Variant) -> void:
|
||
# Presentation metadata is optional. It must never invalidate gameplay,
|
||
# and recovery must use the manifest of the actually loaded candidate.
|
||
if not manifest is Dictionary:
|
||
return
|
||
var selection: Variant = manifest.get("interface_selection", {})
|
||
if not selection is Dictionary or not _valid_interface_selection_version(selection.get("version", 0)):
|
||
return
|
||
var kind_value: Variant = selection.get("kind", "")
|
||
var id_value: Variant = selection.get("id", "")
|
||
if not kind_value is String or not id_value is String or id_value.is_empty() or id_value.length() > 256:
|
||
return
|
||
var kind := StringName(kind_value)
|
||
var entity_id := StringName(id_value)
|
||
var exists := false
|
||
match kind:
|
||
&"facility":
|
||
exists = _service(&"facility_service").get_instance(entity_id) != null or not _construction_facility(entity_id).is_empty()
|
||
&"construction":
|
||
exists = _service(&"construction_service").get_job(entity_id) != null
|
||
&"excavation":
|
||
exists = not _service(&"excavation_service").get_job(entity_id).is_empty()
|
||
&"legacy_stairs":
|
||
for stair: Dictionary in world_preview.get_legacy_stair_records():
|
||
if StringName(stair.get(&"id", &"")) == entity_id:
|
||
exists = true
|
||
break
|
||
if not exists:
|
||
return
|
||
_selected_entity_kind = kind
|
||
_selected_entity_id = entity_id
|
||
world_preview.selected_entity_id = entity_id if kind != &"excavation" else &""
|
||
_set_management_panel_open(true)
|
||
|
||
|
||
func _restore_actor_interface_selection(manifest: Variant) -> void:
|
||
# Only successful gameplay loads reach here. Reset stale live selections,
|
||
# then restore each optional inspector independently of the map selection.
|
||
_set_staff_management_panel_open(false)
|
||
if staff_management_panel != null:
|
||
staff_management_panel.call("clear_selection")
|
||
if guest_observer != null:
|
||
guest_observer.hide()
|
||
var old_panel := guest_observer.find_child("GuestObservationPanel", true, false) as GuestObservationPanel
|
||
if old_panel != null:
|
||
old_panel.clear_selection()
|
||
if not manifest is Dictionary:
|
||
return
|
||
var selection: Variant = manifest.get("interface_selection", {})
|
||
if not selection is Dictionary or not _valid_interface_selection_version(selection.get("version", 0)):
|
||
return
|
||
var staff_id: Variant = selection.get("staff_id", "")
|
||
if staff_id is String and not staff_id.is_empty() and staff_id.length() <= 256 and _service(&"staff_service").has_staff(StringName(staff_id)):
|
||
staff_management_panel.call("select_staff", StringName(staff_id))
|
||
if selection.get("staff_open", false) is bool and selection.get("staff_open", false):
|
||
_activate_management_mode(&"staff")
|
||
var guest_id: Variant = selection.get("guest_id", "")
|
||
if guest_id is String and not guest_id.is_empty() and guest_id.length() <= 256 and _service(&"guest_service").has_guest(StringName(guest_id)):
|
||
var panel := _ensure_guest_observer()
|
||
panel.selected_guest_id = StringName(guest_id)
|
||
panel.refresh()
|
||
if selection.get("guest_open", false) is bool and selection.get("guest_open", false):
|
||
_show_guest_observer()
|
||
|
||
|
||
func _restore_product_interface_selection(manifest: Variant) -> void:
|
||
# Additive optional presentation: old packages keep their existing behavior.
|
||
# No purchase draft, quantity, policy, assignment or command is restored here.
|
||
if supply_management_panel == null or not manifest is Dictionary:
|
||
return
|
||
var selection: Variant = manifest.get("interface_selection", {})
|
||
if not selection is Dictionary:
|
||
return
|
||
var version: Variant = selection.get("version", 0)
|
||
if not (version is int or version is float) or version != 1:
|
||
return
|
||
var open_value: Variant = selection.get("product_open", null)
|
||
var tab_value: Variant = selection.get("product_tab", null)
|
||
if not open_value is bool or not tab_value is String:
|
||
return
|
||
if tab_value not in ["purchase", "kitchen", "delivery"]:
|
||
return
|
||
# A captured main panel cannot be both staff and product. Ignore a
|
||
# contradictory optional product claim without overriding restored staff.
|
||
if open_value and selection.get("staff_open", false) is bool and selection.get("staff_open", false):
|
||
return
|
||
supply_management_panel.select_tab(StringName(tab_value))
|
||
if open_value:
|
||
_activate_management_mode(&"product")
|
||
else:
|
||
_set_supply_panel_open(false)
|
||
|
||
|
||
func _result_message(result: Variant) -> String:
|
||
if result is Dictionary:
|
||
var message := String(result.get(&"message", result.get(&"code", "保存データがありません")))
|
||
return message if not message.is_empty() else "保存データがありません"
|
||
return "サービスから応答がありません"
|
||
|
||
|
||
func _synchronize_ui_command_state() -> void:
|
||
if _runtime == null:
|
||
return
|
||
var queue: Variant = _runtime.get(&"command_queue")
|
||
if queue == null:
|
||
return
|
||
if queue.has_method(&"get_accepted_command_metadata"):
|
||
var metadata_value: Variant = queue.call(&"get_accepted_command_metadata")
|
||
if metadata_value is Array:
|
||
for raw_metadata: Variant in metadata_value:
|
||
if not raw_metadata is Dictionary:
|
||
continue
|
||
_advance_ui_sequence_from_key(String(raw_metadata.get("idempotency_key", "")))
|
||
if not queue.has_method(&"create_snapshot"):
|
||
return
|
||
var snapshot_value: Variant = queue.call(&"create_snapshot")
|
||
if not snapshot_value is Dictionary:
|
||
return
|
||
var pending_value: Variant = snapshot_value.get("pending", [])
|
||
if not pending_value is Array:
|
||
return
|
||
for raw_pending: Variant in pending_value:
|
||
if not raw_pending is Dictionary:
|
||
continue
|
||
var command_id := int(raw_pending.get("command_id", 0))
|
||
var command_type := StringName(raw_pending.get("command_type", &""))
|
||
var payload_value: Variant = raw_pending.get("payload", {})
|
||
var payload: Dictionary = payload_value if payload_value is Dictionary else {}
|
||
if command_id <= 0:
|
||
continue
|
||
match command_type:
|
||
&"set_elevator_operation":
|
||
_pending_elevator_commands[command_id] = StringName(payload.get(&"shaft_id", &""))
|
||
&"build_facility":
|
||
var definition_id := StringName(payload.get(&"definition_id", payload.get("definition_id", &"")))
|
||
if not definition_id.is_empty():
|
||
_pending_build_commands[command_id] = definition_id
|
||
&"start_excavation":
|
||
var cell_value: Variant = payload.get(&"cell", payload.get("cell", null))
|
||
if cell_value is Vector3i:
|
||
_pending_excavation_commands[command_id] = Vector2i(cell_value.x, cell_value.y)
|
||
&"hire_staff_candidate":
|
||
var candidate_id := StringName(payload.get(&"candidate_id", payload.get("candidate_id", &"")))
|
||
if not candidate_id.is_empty():
|
||
_pending_hire_commands[command_id] = candidate_id
|
||
|
||
|
||
func _advance_ui_sequence_from_key(key: String) -> void:
|
||
if key.is_empty():
|
||
return
|
||
var parts := key.split(".")
|
||
if parts.is_empty():
|
||
return
|
||
var suffix := String(parts[parts.size() - 1])
|
||
if not suffix.is_valid_int():
|
||
return
|
||
var next_sequence := int(suffix) + 1
|
||
if key.begins_with("ui.build."):
|
||
_build_sequence = maxi(_build_sequence, next_sequence)
|
||
elif key.begins_with("ui.excavation."):
|
||
_excavation_sequence = maxi(_excavation_sequence, next_sequence)
|
||
elif key.begins_with("ui.hire."):
|
||
_hire_sequence = maxi(_hire_sequence, next_sequence)
|
||
elif key.begins_with("ui.elevator."):
|
||
_elevator_sequence = maxi(_elevator_sequence, next_sequence)
|
||
|
||
|
||
var _dashboard_refresh_pending := false
|
||
|
||
|
||
func _queue_dashboard_refresh() -> void:
|
||
if _dashboard_refresh_pending:
|
||
return
|
||
_dashboard_refresh_pending = true
|
||
_flush_dashboard_refresh.call_deferred()
|
||
|
||
|
||
func _flush_dashboard_refresh() -> void:
|
||
if not _dashboard_refresh_pending or not is_inside_tree():
|
||
return
|
||
_dashboard_refresh_pending = false
|
||
_refresh_dashboard()
|
||
|
||
|
||
func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void:
|
||
_synchronize_elevator_pending()
|
||
_queue_dashboard_refresh()
|
||
_update_pause_and_speed()
|
||
var operation: Dictionary = _snapshot.get(&"systems", {}).get(&"runtime", {}).get(&"operations", {}).get(&"coordinator", {})
|
||
var summaries: Array = operation.get(&"day_summaries", [])
|
||
if summaries.is_empty():
|
||
_last_presented_close_minute = 0
|
||
return
|
||
var latest: Dictionary = summaries.back()
|
||
var closed_at := int(latest.get(&"closed_at", 0))
|
||
if closed_at > _last_presented_close_minute:
|
||
_last_presented_close_minute = closed_at
|
||
operating_ledger_panel.select_latest_close()
|
||
_activate_management_mode(&"ledger")
|
||
var losses := int(latest.get(&"consecutive_insolvency_count", 0))
|
||
if int(latest[&"day"]) <= int(operation.get(&"legacy_history_last_day", 0)):
|
||
_set_status("旧版で確定した決算を表示しています。", true, true)
|
||
return
|
||
_set_status("3回連続の赤字決算でゲーム終了です。帳簿を確認できます。" if String(latest.get(&"terminal_state", "")) == "BANKRUPT" else "第%d日 決算:利益 %+dG・連続赤字 %d回" % [int(latest[&"day"]), int(latest[&"net_profit"]), losses], true, true)
|
||
else:
|
||
_last_presented_close_minute = closed_at
|
||
|
||
|
||
func _on_command_completed(result: Variant) -> void:
|
||
if result == null:
|
||
return
|
||
if _pending_demolition_commands.has(int(result.command_id)):
|
||
_pending_demolition_commands.erase(int(result.command_id))
|
||
_show_demolition_result(result)
|
||
return
|
||
if _pending_related_commands.has(int(result.command_id)):
|
||
_pending_related_commands.erase(int(result.command_id))
|
||
_set_status("関連施設の設定を反映しました。" if result.is_success() else result.message, result.is_success())
|
||
_refresh_selected_entity()
|
||
return
|
||
if _pending_elevator_commands.has(int(result.command_id)):
|
||
_pending_elevator_commands.erase(int(result.command_id))
|
||
_set_status("昇降機の運行設定を反映しました。" if result.is_success() else "昇降機の運行設定を反映できませんでした。", result.is_success())
|
||
_refresh_selected_entity()
|
||
return
|
||
if _pending_hire_commands.has(int(result.command_id)):
|
||
var candidate_id := StringName(_pending_hire_commands[int(result.command_id)])
|
||
_pending_hire_commands.erase(int(result.command_id))
|
||
var display_name := String(candidate_id)
|
||
for summary: Dictionary in _staff_candidate_summaries():
|
||
if StringName(summary.get(&"candidate_id", &"")) == candidate_id:
|
||
display_name = String(summary.get(&"display_name", candidate_id))
|
||
break
|
||
if bool(result.call(&"is_success")):
|
||
_set_status("%sを雇用しました。" % display_name, true)
|
||
else:
|
||
_set_status(_hire_failure_message(result), false)
|
||
_refresh_dashboard()
|
||
return
|
||
if _pending_excavation_commands.has(int(result.command_id)):
|
||
var position: Vector2i = _pending_excavation_commands[int(result.command_id)]
|
||
_pending_excavation_commands.erase(int(result.command_id))
|
||
if bool(result.call(&"is_success")):
|
||
_select_excavation_job(StringName(result.details.get(&"job_id", &"")))
|
||
_set_status("掘削を開始しました(%d, %d / FRONT・BACK)。" % [position.x, position.y], true)
|
||
else:
|
||
_set_status(_excavation_command_failure_message(result), false)
|
||
return
|
||
if not _pending_build_commands.has(int(result.command_id)):
|
||
return
|
||
var definition_id := StringName(_pending_build_commands[int(result.command_id)])
|
||
_pending_build_commands.erase(int(result.command_id))
|
||
if bool(result.call(&"is_success")):
|
||
var definition: Variant = _facility_definition(definition_id)
|
||
_set_status("%sの建設を開始しました。" % String(_definition_value(definition, &"display_name", definition_id)), true)
|
||
else:
|
||
_set_status(_command_failure_message(result), false)
|
||
|
||
|
||
func _set_layer(value: int) -> void:
|
||
var normalized := clampi(value, 0, 1)
|
||
if is_instance_valid(_paired_bgm):
|
||
_paired_bgm.set_layer(normalized)
|
||
if world_preview != null:
|
||
world_preview.layer_mode = normalized
|
||
GameState.active_layer = normalized
|
||
if front_button != null:
|
||
front_button.set_pressed_no_signal(normalized == 0)
|
||
if back_button != null:
|
||
back_button.set_pressed_no_signal(normalized == 1)
|
||
for label: Label in [dashboard_summary_label, staff_candidate_summary_label, selected_detail_label, elevator_operation_hint]:
|
||
if label != null: LayerSkin.register_text(label, normalized)
|
||
# Styles are registered in the saved layer during construction.
|
||
# Reapplying them before the initial container layout causes extra sizing.
|
||
if _interface_layer >= 0 and _interface_layer != normalized:
|
||
LayerSkin.apply_tree(self, normalized)
|
||
if rank_dialog != null: rank_dialog.apply_layer_skin(normalized)
|
||
if event_log_dialog != null: event_log_dialog.apply_layer_skin(normalized)
|
||
if manual_save_slots_dialog != null: manual_save_slots_dialog.apply_layer_skin(normalized)
|
||
_interface_layer = normalized
|
||
if foreground_event_label != null:
|
||
foreground_event_label.add_theme_color_override("font_color", Color("ffb498") if normalized == 1 else Color("8f3f29"))
|
||
var background := get_node_or_null("InterfaceBackground") as ColorRect
|
||
if background != null:
|
||
background.color = Color("10191e") if normalized == 1 else _visual_color(&"window_background", Color("24170f"))
|
||
EventBus.layer_changed.emit(normalized)
|
||
|
||
|
||
func _toggle_pause() -> void:
|
||
var event_service: Variant = _service(&"event_service")
|
||
if event_service != null and event_service.has_method(&"get_foreground_instance"):
|
||
var foreground: Variant = event_service.call(&"get_foreground_instance")
|
||
if foreground != null and int(foreground.state) == 2:
|
||
GameClock.set_paused(true)
|
||
_set_status("先に要対応イベントの選択肢を決めてください。", false)
|
||
_update_pause_and_speed()
|
||
return
|
||
if _runtime != null \
|
||
and _runtime.has_method(&"get_operations_report") \
|
||
and _runtime.has_method(&"acknowledge_operations_pause"):
|
||
var report_value: Variant = _runtime.call(&"get_operations_report")
|
||
if report_value is Dictionary:
|
||
var report: Dictionary = report_value
|
||
var coordinator_value: Variant = report.get(&"coordinator", {})
|
||
var coordinator: Dictionary = (
|
||
coordinator_value if coordinator_value is Dictionary else {}
|
||
)
|
||
if StringName(coordinator.get(&"terminal_state", &"RUNNING")) == &"BANKRUPT":
|
||
_set_status("資金破綻により運営は終了しています。", false)
|
||
return
|
||
if bool(report.get(&"enabled", false)) \
|
||
and int(report.get(&"deferred_runtime_minute", -1)) >= 0:
|
||
var retry_value: Variant = _runtime.call(&"acknowledge_operations_pause")
|
||
var retry_ok := retry_value is Dictionary and bool(retry_value.get(&"ok", false))
|
||
_set_status(
|
||
"運営処理を再開しました。" if retry_ok \
|
||
else "運営処理を再開できません: %s" % _result_message(retry_value),
|
||
retry_ok
|
||
)
|
||
_refresh_dashboard()
|
||
_update_pause_and_speed()
|
||
return
|
||
GameClock.toggle_pause()
|
||
_update_pause_and_speed()
|
||
|
||
|
||
func _set_speed(value: int) -> void:
|
||
GameClock.set_speed(value)
|
||
_update_pause_and_speed()
|
||
|
||
|
||
func _update_pause_and_speed() -> void:
|
||
var operations_deferred := false
|
||
var operations_terminal := false
|
||
var event_waiting := false
|
||
if _runtime != null and _runtime.has_method(&"get_operations_report"):
|
||
var report_value: Variant = _runtime.call(&"get_operations_dashboard_report") if _runtime.has_method(&"get_operations_dashboard_report") else _runtime.call(&"get_operations_report")
|
||
if report_value is Dictionary:
|
||
operations_deferred = bool(report_value.get(&"enabled", false)) \
|
||
and int(report_value.get(&"deferred_runtime_minute", -1)) >= 0
|
||
var coordinator_value: Variant = report_value.get(&"coordinator", {})
|
||
if coordinator_value is Dictionary:
|
||
operations_terminal = StringName(
|
||
coordinator_value.get(&"terminal_state", &"RUNNING")
|
||
) == &"BANKRUPT"
|
||
var event_service: Variant = _service(&"event_service")
|
||
if event_service != null and event_service.has_method(&"get_foreground_instance"):
|
||
var foreground: Variant = event_service.call(&"get_foreground_instance")
|
||
event_waiting = foreground != null and int(foreground.state) == 2
|
||
if phase_label != null:
|
||
if operations_terminal:
|
||
phase_label.text = "運営終了"
|
||
elif operations_deferred:
|
||
phase_label.text = "要確認"
|
||
elif event_waiting:
|
||
phase_label.text = "選択待ち"
|
||
else:
|
||
phase_label.text = "停止中" if GameClock.paused else _phase_name(GameClock.get_phase())
|
||
phase_label.add_theme_color_override("font_color", Color("e88972") if GameClock.paused else Color("f0c574"))
|
||
if pause_button != null:
|
||
pause_button.text = (
|
||
"破綻"
|
||
if operations_terminal else (
|
||
"運営再試行"
|
||
if operations_deferred else (
|
||
"イベント待ち"
|
||
if event_waiting else ("再開" if GameClock.paused else "停止")
|
||
)
|
||
)
|
||
)
|
||
pause_button.disabled = operations_terminal or event_waiting
|
||
for raw_speed: Variant in speed_buttons:
|
||
var button := speed_buttons[raw_speed] as Button
|
||
button.set_pressed_no_signal(not GameClock.paused and int(raw_speed) == GameClock.speed)
|
||
|
||
|
||
func _on_time_changed(current_day: int, hour: int, minute: int) -> void:
|
||
time_label.text = "%d日目 %02d:%02d" % [current_day, hour, minute]
|
||
_update_pause_and_speed()
|
||
|
||
|
||
func _on_phase_changed(phase: GameClock.Phase) -> void:
|
||
if not GameClock.paused:
|
||
phase_label.text = _phase_name(phase)
|
||
|
||
|
||
func _phase_name(phase: GameClock.Phase) -> String:
|
||
match phase:
|
||
GameClock.Phase.MORNING:
|
||
return "朝"
|
||
GameClock.Phase.DAY:
|
||
return "昼"
|
||
GameClock.Phase.EVENING:
|
||
return "夕方"
|
||
_:
|
||
return "夜"
|
||
|
||
|
||
const RESOURCE_TITLES := {&"gold": "所持金", &"mana": "マナ", &"materials": "資材", &"faith": "信仰"}
|
||
|
||
func _resource_title(resource_name: StringName) -> String:
|
||
return String(RESOURCE_TITLES.get(resource_name, String(resource_name)))
|
||
|
||
|
||
func _on_resource_changed(resource_name: StringName, value: int) -> void:
|
||
if not resource_labels.has(resource_name):
|
||
return
|
||
var label := resource_labels[resource_name] as Label
|
||
label.text = "%s %d" % [_resource_title(resource_name), value]
|
||
if resource_name == &"mana":
|
||
label.mouse_filter = Control.MOUSE_FILTER_STOP
|
||
label.tooltip_text = "残量0で全館停止。接客・調理・搬入・エレベーターと新規入館が停止します。\n残量と配電が回復すると再開します。破壊・摘発による休止は解除されません。"
|
||
if value <= 0:
|
||
label.text += "・停止"
|
||
label.add_theme_color_override("font_color", Color("ffb095"))
|
||
else:
|
||
label.add_theme_color_override("font_color", _visual_color(&"text_light", Color("fff2d0")))
|
||
|
||
|
||
func _button(text_value: String, callback: Callable, minimum_width := 82) -> Button:
|
||
var button := Button.new()
|
||
button.text = text_value
|
||
button.custom_minimum_size = Vector2(minimum_width, 28)
|
||
button.add_theme_font_size_override("font_size", 12)
|
||
button.pressed.connect(callback)
|
||
_apply_button_style(button)
|
||
return button
|
||
|
||
|
||
func _hud_value_label(text_value: String, minimum_width: float) -> Label:
|
||
var label := Label.new()
|
||
label.text = text_value
|
||
label.custom_minimum_size.x = minimum_width
|
||
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||
label.add_theme_font_size_override("font_size", 12)
|
||
label.add_theme_color_override("font_color", _visual_color(&"text_light", Color("fff2d0")))
|
||
return label
|
||
|
||
|
||
func _icon_rect(icon_texture: Texture2D, side: float) -> TextureRect:
|
||
var icon := TextureRect.new()
|
||
icon.texture = icon_texture
|
||
icon.custom_minimum_size = Vector2(side, side)
|
||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||
return icon
|
||
|
||
|
||
func _apply_button_icon(button: Button, icon_texture: Texture2D, maximum_width: int) -> void:
|
||
if icon_texture == null:
|
||
return
|
||
button.icon = icon_texture
|
||
button.add_theme_constant_override("icon_max_width", maximum_width)
|
||
button.expand_icon = true
|
||
|
||
|
||
func _apply_button_style(button: Button) -> void:
|
||
if _audio_feedback != null:
|
||
_audio_feedback.bind_button(button)
|
||
button.add_theme_color_override("font_color", _visual_color(&"text_light", Color("fff0c8")))
|
||
button.add_theme_color_override("font_hover_color", Color("fff8e5"))
|
||
button.add_theme_color_override("font_pressed_color", Color("fff8e5"))
|
||
button.add_theme_color_override("font_disabled_color", Color("a98e68"))
|
||
button.add_theme_stylebox_override("normal", _themed_panel_style(&"button_normal", _visual_color(&"button_normal", Color("75451f")), _visual_color(&"border_gold", Color("b77b37")), 7, 7))
|
||
button.add_theme_stylebox_override("hover", _themed_panel_style(&"button_hover", _visual_color(&"button_hover", Color("8d5628")), _visual_color(&"border_gold", Color("e4b45c")), 7, 7))
|
||
button.add_theme_stylebox_override("pressed", _themed_panel_style(&"button_pressed", _visual_color(&"button_pressed", Color("496528")), _visual_color(&"border_gold", Color("d8bd61")), 7, 7))
|
||
button.add_theme_stylebox_override("focus", _themed_panel_style(&"button_pressed", _visual_color(&"button_pressed", Color("496528")), _visual_color(&"border_gold", Color("f2d97d")), 7, 7))
|
||
button.add_theme_stylebox_override("disabled", _themed_panel_style(&"button_disabled", _visual_color(&"button_disabled", Color("574334")), Color("7b6955"), 7, 7))
|
||
|
||
|
||
func _section_heading(text_value: String) -> Label:
|
||
var heading := Label.new()
|
||
heading.text = text_value
|
||
heading.add_theme_font_size_override("font_size", 14)
|
||
heading.add_theme_color_override("font_color", _visual_color(&"text_dark", Color("55371e")))
|
||
LayerSkin.register_text(heading, GameState.active_layer)
|
||
return heading
|
||
|
||
|
||
func _panel_style(color: Color, border_color: Color, radius := 8, margin := 12) -> StyleBoxFlat:
|
||
var style := StyleBoxFlat.new()
|
||
style.bg_color = color
|
||
style.border_color = border_color
|
||
style.set_border_width_all(1)
|
||
style.set_corner_radius_all(mini(radius, 1))
|
||
style.set_content_margin_all(margin)
|
||
style.shadow_size = 0
|
||
return style
|
||
|
||
|
||
func _themed_panel_style(skin_id: StringName, color: Color, border_color: Color, radius := 0, margin := 5) -> StyleBox:
|
||
# Small flat frames keep attention on the animated cross-section.
|
||
var style := _panel_style(color, border_color, mini(radius, 1), mini(margin, 6))
|
||
LayerSkin.register_style(style, skin_id, GameState.active_layer)
|
||
return style
|
||
|
||
|
||
func _set_status(message: String, success: bool, quiet := false) -> void:
|
||
if build_status_label == null:
|
||
return
|
||
build_status_label.text = message
|
||
build_status_label.tooltip_text = message
|
||
if quiet:
|
||
build_status_label.add_theme_color_override("font_color", Color("c7d3a8") if success else Color("ffb498"))
|
||
else:
|
||
build_status_label.add_theme_color_override("font_color", Color("d8e8ad") if success else Color("ffb498"))
|
||
|
||
|
||
func _visual_color(property_name: StringName, fallback: Color) -> Color:
|
||
if visual_theme == null:
|
||
return fallback
|
||
var value: Variant = visual_theme.get(property_name)
|
||
return value if value is Color else fallback
|
||
|
||
|
||
func _service(property_name: StringName) -> Variant:
|
||
if _runtime == null or not _runtime is Object:
|
||
return null
|
||
for property: Dictionary in _runtime.get_property_list():
|
||
if StringName(property.get(&"name", &"")) == property_name:
|
||
return _runtime.get(property_name)
|
||
return null
|
||
|
||
|
||
func _call_array(service: Variant, method_name: StringName) -> Array:
|
||
if service == null or not service.has_method(method_name):
|
||
return []
|
||
var result: Variant = service.call(method_name)
|
||
return result if result is Array else []
|
||
|
||
|
||
func _facility_definition(definition_id: StringName) -> Variant:
|
||
var registry: Variant = _service(&"definition_registry")
|
||
if registry == null or not registry.has_method(&"get_facility"):
|
||
return null
|
||
return registry.call(&"get_facility", definition_id)
|
||
|
||
|
||
func _construction_facility(instance_id: StringName) -> Dictionary:
|
||
var construction: Variant = _service(&"construction_service")
|
||
if construction == null or not construction.has_method(&"get_facility"):
|
||
return {}
|
||
var result: Variant = construction.call(&"get_facility", instance_id)
|
||
return result if result is Dictionary else {}
|
||
|
||
|
||
func _facility_reasons(reasons_value: Variant) -> String:
|
||
if not reasons_value is Array:
|
||
return ""
|
||
var names: Array[String] = []
|
||
for reason_value: Variant in reasons_value:
|
||
var reason := StringName(reason_value)
|
||
names.append(String(FACILITY_REASON_NAMES.get(reason, reason)))
|
||
return "・".join(names)
|
||
|
||
|
||
func _definition_value(source: Variant, property_name: StringName, fallback: Variant) -> Variant:
|
||
if source is Dictionary:
|
||
return source.get(property_name, source.get(String(property_name), fallback))
|
||
if source == null or not source is Object:
|
||
return fallback
|
||
for property: Dictionary in source.get_property_list():
|
||
if StringName(property.get(&"name", &"")) == property_name:
|
||
return source.get(property_name)
|
||
return fallback
|
||
|
||
|
||
func _ensure_guest_observer() -> GuestObservationPanel:
|
||
if guest_observer == null:
|
||
guest_observer = AcceptDialog.new()
|
||
guest_observer.title = "来客の様子"
|
||
guest_observer.ok_button_text = "閉じる"
|
||
var content := preload("res://scripts/ui/guest_observation_panel.gd").new()
|
||
content.configure(_runtime)
|
||
var scroll := ScrollContainer.new()
|
||
scroll.name = "GuestObservationScroll"
|
||
scroll.custom_minimum_size = Vector2(420, 340)
|
||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
guest_observer.add_child(scroll)
|
||
scroll.add_child(content)
|
||
add_child(guest_observer)
|
||
return guest_observer.find_child("GuestObservationPanel", true, false) as GuestObservationPanel
|
||
|
||
|
||
func _show_guest_observer() -> void:
|
||
var panel := _ensure_guest_observer()
|
||
guest_observer.popup_centered(Vector2i(460, 410))
|
||
panel.refresh()
|
||
|
||
|
||
func _preview_dimension_migration() -> void:
|
||
var report: Dictionary = _runtime.preview_facility_dimensions_at_path(manual_save_path)
|
||
if not report.get(&"ok",false):
|
||
_set_status("旧寸法の修正を検証できませんでした。保存データは変更していません。",false)
|
||
return
|
||
if report.get(&"changes",[]).is_empty():
|
||
_set_status("この手動セーブに旧寸法の施設はありません。通常の読込を利用してください。",true)
|
||
return
|
||
_dimension_migration_digest=report[&"source_digest"]
|
||
if dimension_migration_dialog==null:
|
||
dimension_migration_dialog=ConfirmationDialog.new()
|
||
dimension_migration_dialog.name="DimensionMigrationDialog"
|
||
dimension_migration_dialog.title="旧寸法を修正して読み込む"
|
||
dimension_migration_dialog.ok_button_text="修正して読込"
|
||
dimension_migration_dialog.cancel_button_text="戻る"
|
||
dimension_migration_dialog.confirmed.connect(_confirm_dimension_migration)
|
||
dimension_migration_dialog.canceled.connect(func(): _dimension_migration_digest="")
|
||
add_child(dimension_migration_dialog)
|
||
var lines: PackedStringArray=[]
|
||
for change: Dictionary in report[&"changes"]:
|
||
lines.append("%s%s → %d×%d" % [change[&"name"],"(建設中)" if change[&"building"] else "",change[&"size"].x,change[&"size"].y])
|
||
dimension_migration_dialog.dialog_text="保存済みの手動セーブを修正します。\n"+"\n".join(lines)+"\n\n修正前のバックアップを残します。\n現在の未保存の進行は読み込みで置き換わります。"
|
||
dimension_migration_dialog.popup_centered(Vector2i(520,280))
|
||
|
||
func _confirm_dimension_migration() -> void:
|
||
if _dimension_migration_digest.is_empty(): return
|
||
var expected := _dimension_migration_digest
|
||
_dimension_migration_digest=""
|
||
var report: Dictionary=_runtime.migrate_facility_dimensions_at_path(manual_save_path,&"",expected)
|
||
if not report.get(&"ok",false):
|
||
_set_status("旧寸法の修正に失敗しました。読込は行っていません。保存データを確認してください。",false)
|
||
return
|
||
_load_manual(true)
|
||
if build_status_label.text=="手動セーブを読み込みました。":
|
||
_set_status("旧寸法を修正して読み込みました。修正前のバックアップを保持しています。",true)
|
||
|
||
|
||
func _on_defense_notice_input(event: InputEvent) -> void:
|
||
if (event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT) or event.is_action_pressed(&"ui_accept"):
|
||
alert_count_label.accept_event()
|
||
_show_defense_manager()
|
||
|
||
|
||
func _ensure_defense_manager() -> DefenseManagementPanel:
|
||
if defense_manager == null:
|
||
defense_manager = AcceptDialog.new()
|
||
defense_manager.name = "DefenseManager"
|
||
defense_manager.title = "世界樹の防衛"
|
||
defense_manager.ok_button_text = "閉じる"
|
||
var dialog_theme := Theme.new()
|
||
dialog_theme.set_stylebox("panel", "AcceptDialog", _themed_panel_style(&"panel_parchment", Color("f4e3bd"), Color("a96f2f"), 8, 10))
|
||
var window_border := _themed_panel_style(&"panel_outer", Color("49311f"), Color("a96f2f"), 8, 10)
|
||
window_border.expand_margin_top = 32
|
||
dialog_theme.set_stylebox("embedded_border", "Window", window_border)
|
||
dialog_theme.set_color("title_color", "Window", Color("fff0cf"))
|
||
defense_manager.theme = dialog_theme
|
||
_apply_button_style(defense_manager.get_ok_button())
|
||
var panel := DefenseManagementPanelScript.new()
|
||
panel.configure(_runtime, _apply_button_style)
|
||
panel.configure_audio(Callable(_audio_feedback, "submit_command"))
|
||
panel.status_requested.connect(_on_staff_management_status)
|
||
var scroll := ScrollContainer.new()
|
||
scroll.name = "DefenseManagementScroll"
|
||
scroll.custom_minimum_size = Vector2(480, 460)
|
||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
defense_manager.add_child(scroll)
|
||
scroll.add_child(panel)
|
||
add_child(defense_manager)
|
||
return defense_manager.find_child("DefenseManagementPanel", true, false) as DefenseManagementPanel
|
||
|
||
|
||
func _show_defense_manager() -> void:
|
||
var panel := _ensure_defense_manager()
|
||
panel.refresh()
|
||
LayerSkin.apply_tree(defense_manager, GameState.active_layer)
|
||
defense_manager.popup_centered(Vector2i(520, 540))
|
||
|
||
|
||
func _restore_defense_interface_selection(manifest: Variant) -> void:
|
||
if defense_manager != null:
|
||
defense_manager.hide()
|
||
var existing := defense_manager.find_child("DefenseManagementPanel", true, false) as DefenseManagementPanel
|
||
if existing != null: existing.clear_selection()
|
||
if not manifest is Dictionary: return
|
||
var selection: Variant = manifest.get("interface_selection", {})
|
||
if not selection is Dictionary or selection.get("version", 0) != 1: return
|
||
var id_value: Variant = selection.get("defense_id", "")
|
||
if not id_value is String or id_value.is_empty() or id_value.length() > 256: return
|
||
var raid_id := StringName(id_value)
|
||
if _service(&"defense_service").get_management_detail(raid_id).is_empty(): return
|
||
_ensure_defense_manager().select_raid(raid_id)
|
||
if selection.get("defense_open", false) is bool and selection.get("defense_open", false):
|
||
_show_defense_manager()
|
||
|
||
|
||
func _setup_ui_audio() -> void:
|
||
_paired_bgm = PairedBGMScript.new()
|
||
_paired_bgm.name = "PairedBGM"
|
||
_paired_bgm.configure(GameState.active_layer)
|
||
add_child(_paired_bgm)
|
||
_audio_feedback = UIAudioFeedbackScript.new()
|
||
_audio_feedback.name = "UIAudioFeedback"
|
||
_audio_feedback.configure(_runtime, audio_settings_path)
|
||
add_child(_audio_feedback)
|
||
_audio_feedback.begin_loaded_epoch(_current_audio_foreground())
|
||
_audio_settings_dialog = UIAudioSettingsDialogScript.new()
|
||
_audio_settings_dialog.configure(_audio_feedback, _apply_button_style)
|
||
add_child(_audio_settings_dialog)
|
||
|
||
|
||
func _current_audio_foreground() -> Variant:
|
||
var events: Variant = _service(&"event_service")
|
||
if events != null and events.has_method(&"get_foreground_instance"):
|
||
return events.call(&"get_foreground_instance")
|
||
return null
|
||
|
||
|
||
func _show_audio_settings() -> void:
|
||
if _audio_settings_dialog != null:
|
||
_audio_settings_dialog.show_settings()
|
||
|
||
|
||
func _on_audio_world_selection(_id: StringName) -> void:
|
||
if _audio_feedback != null:
|
||
_audio_feedback.request_click()
|
||
|
||
|
||
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_feedback != null:
|
||
return _audio_feedback.submit_command(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)
|
||
|
||
func _toggle_demolition_mode() -> void:
|
||
_set_demolition_mode(not _demolition_mode)
|
||
|
||
|
||
func _set_demolition_mode(enabled: bool, announce := true) -> void:
|
||
if enabled:
|
||
_set_pan_mode(false)
|
||
_set_excavation_mode(false, false)
|
||
_clear_build_selection(false)
|
||
_demolition_mode = enabled
|
||
_demolition_target = &""
|
||
if demolition_dialog != null:
|
||
demolition_dialog.hide()
|
||
if demolition_button != null:
|
||
demolition_button.set_pressed_no_signal(enabled)
|
||
if world_preview != null:
|
||
world_preview.set_demolition_mode(enabled)
|
||
if announce:
|
||
_set_status("撤去する施設のマスを選択してください。確認画面で取り消せます。" if enabled else "撤去モードを解除しました。", true, true)
|
||
|
||
|
||
func _preview_demolition(cell: Vector3i) -> void:
|
||
# Selection only; Runtime owns eligibility, occupancy and refunds.
|
||
_demolition_target = world_preview.facility_at_canvas(world_preview.cell_to_canvas(cell))
|
||
world_preview.selected_entity_id = _demolition_target
|
||
if demolition_dialog == null:
|
||
demolition_dialog = ConfirmationDialog.new()
|
||
demolition_dialog.name = "DemolitionDialog"
|
||
demolition_dialog.title = "施設の撤去"
|
||
demolition_dialog.ok_button_text = "撤去する"
|
||
demolition_dialog.cancel_button_text = "戻る"
|
||
demolition_dialog.confirmed.connect(_confirm_demolition)
|
||
demolition_dialog.canceled.connect(_cancel_demolition)
|
||
add_child(demolition_dialog)
|
||
_apply_button_style(demolition_dialog.get_ok_button())
|
||
_apply_button_style(demolition_dialog.get_cancel_button())
|
||
demolition_dialog.dialog_text = "座標 (%d, %d / %d)\n施設: %s\n\n撤去を指示しますか?" % [cell.x, cell.y, cell.z, String(_demolition_target)]
|
||
demolition_dialog.popup_centered(Vector2i(420, 210))
|
||
|
||
|
||
func _cancel_demolition() -> void:
|
||
_demolition_target = &""
|
||
_set_status("撤去を取り消しました。", true, true)
|
||
|
||
|
||
func _confirm_demolition() -> void:
|
||
if not _demolition_mode:
|
||
return
|
||
if _runtime == null or not _runtime.has_method(&"submit_command"):
|
||
_set_status("Simulation Runtimeへ接続できません。", false)
|
||
return
|
||
var target := _demolition_target
|
||
_demolition_target = &""
|
||
var result: CommandResult = _runtime.submit_command(&"demolish_facility", {&"instance_id": target})
|
||
# Paused play returns execution synchronously; running play returns acceptance.
|
||
if result.is_success() and _runtime.command_queue.get_processed_result(result.command_id) == null:
|
||
_pending_demolition_commands[result.command_id] = target
|
||
_set_status("撤去を受け付けました", true)
|
||
return
|
||
_show_demolition_result(result)
|
||
|
||
|
||
func _show_demolition_result(result: CommandResult) -> void:
|
||
if result.is_success():
|
||
var refund: Dictionary = result.details.get(&"refund", {})
|
||
var keys: Array = refund.keys()
|
||
keys.sort()
|
||
var amounts: PackedStringArray = []
|
||
for key: Variant in keys:
|
||
if int(refund[key]) != 0:
|
||
amounts.append("%s %d" % [_resource_title(StringName(key)), int(refund[key])])
|
||
_set_status("撤去しました(返金: %s)" % "、".join(amounts) if not amounts.is_empty() else "撤去しました(返金なし)", true)
|
||
else:
|
||
_set_status("撤去できません: " + result.message, false)
|
||
_refresh_selected_entity()
|
||
world_preview.queue_redraw()
|