Files
TheTree_mock/scenes/main/main.gd
T

2328 lines
95 KiB
GDScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends Control
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 WorldPreviewScript := preload("res://scenes/main/world_preview.gd")
const DefaultVisualTheme := preload("res://assets/themes/japanese_fantasy/visual-theme-v1.tres")
const MANUAL_SAVE_PATH := "user://saves/manual_ui"
const MVP_FACILITY_IDS: Array[StringName] = [
&"reception",
&"standard_room",
&"cafeteria",
&"warehouse",
&"kitchen",
&"public_bath",
&"staff_waiting_room",
&"security_station",
&"tavern",
&"massage_room",
&"guest_elevator",
&"service_elevator",
&"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": "故障",
&"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": "受付",
}
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 time_label: Label
var phase_label: Label
var guest_count_label: Label
var lodging_count_label: Label
var alert_count_label: Label
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 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 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 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()
_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
)
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_4:
_set_speed(4)
KEY_R:
if _selected_definition == null:
handled = false
else:
_rotate_build_preview()
_:
handled = false
if handled:
get_viewport().set_input_as_handled()
func _build_interface() -> void:
var background := ColorRect.new()
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)
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)
var subtitle := Label.new()
subtitle.text = "世界樹の宿"
subtitle.add_theme_font_size_override("font_size", 10)
subtitle.add_theme_color_override("font_color", Color("e5c992"))
title_box.add_child(subtitle)
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)
guest_count_label = _hud_value_label("来客 0人", 72)
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.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, 4]:
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中ボタンドラッグ: 移動\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)
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.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.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)
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)
operating_ledger_panel.visible = false
supply_management_panel = SupplyManagementPanelScript.new()
supply_management_panel.configure(_runtime, _apply_button_style)
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.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))
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)
var save_button := _button("保存", _save_manual, 62)
_apply_button_icon(save_button, visual_theme.control_icon(&"save") if visual_theme != null else null, 16)
row.add_child(save_button)
row.add_child(_button("読込", _load_manual, 56))
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 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_scroll.add_child(grid)
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"
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("本日の資金増減と、06: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":
_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"}
_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.construction_selected.connect(_on_construction_selected)
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(&"snapshot_published"):
_runtime.connect(&"snapshot_published", Callable(self, "_on_snapshot_published"))
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 = "雇用する"
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 hired else "手続き中…" if pending 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 = _runtime.call(
&"submit_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":
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_excavation_mode(false, 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 _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 _set_excavation_mode(enabled: bool, announce := true) -> void:
_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 _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)):
_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 = _runtime.call(
&"submit_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()
_set_status("配置可能 (%d, %d)・%d° — クリックで建設" % [cell.x, cell.y, _build_rotation], 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 = _runtime.call(
&"submit_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_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:
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 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]
+ 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
alert_count_label.add_theme_color_override(
"font_color",
Color("ffb498") if heat_value >= 60 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
_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 = _runtime.call(
&"submit_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_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))
return (
"\n行動点 %d / 6  未払給与 G %d\n" % [
int(report.get(&"personal_action_points", 0)),
int(coordinator.get(&"arrears_total", 0)),
]
+ "運営状態 %s  前日損益 %s" % [state_text, day_profit_text]
)
func _refresh_selected_entity() -> void:
selected_elevator_controls.visible = false
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
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", &""))
selected_heading_label.text = String(construction_definition_id)
selected_entity_icon.texture = visual_theme.facility_icon(construction_definition_id) if visual_theme != null else null
selected_detail_label.text = "施設データを運営サービスへ同期しています。"
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
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
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)]
+ "配置人員 %d / 必要 %d  耐久 %d%%\n" % [staff_count, int(facility.staff_min), int(facility.durability) / 100]
+ "基本料金 G %d  所要 %d分\n" % [int(facility.base_price), int(facility.base_service_minutes)]
+ "座標 (%d, %d) %s" % [facility.origin.x, facility.origin.y, "FRONT" if facility.origin.z == 0 else "BACK"]
)
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_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 _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()-3
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)
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 = _runtime.call(&"submit_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])
return "配置不可: %s" % PREVIEW_REASON_NAMES.get(code, code)
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(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":
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:
if _runtime == null or not _runtime.has_method(&"save_to_path"):
_set_status("保存サービスへ接続できません。", false)
return
var directory_error := DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(manual_save_path).get_base_dir())
if directory_error != OK and directory_error != ERR_ALREADY_EXISTS:
_set_status("保存フォルダを作成できませんでした。", false)
return
var result: Variant = _runtime.call(&"save_to_path", manual_save_path, {&"display_name": "手動保存"})
if result is Dictionary and bool(result.get(&"ok", false)):
_set_status("手動セーブを保存しました。", true)
else:
_set_status("保存に失敗しました: %s" % _result_message(result), false)
func _load_manual() -> 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", manual_save_path)
if _runtime.has_method(&"load_with_recovery")
else _runtime.call(&"load_from_path", manual_save_path)
)
if result is Dictionary and bool(result.get(&"ok", false)):
_pending_build_commands.clear()
_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.queue_redraw()
_refresh_dashboard()
_update_pause_and_speed()
_set_status("手動セーブを読み込みました。", true)
else:
_set_status("読み込みに失敗しました: %s" % _result_message(result), 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)
func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void:
_synchronize_elevator_pending()
_refresh_dashboard()
_update_pause_and_speed()
func _on_command_completed(result: Variant) -> void:
if result == null:
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 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)
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_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 "夜"
func _on_resource_changed(resource_name: StringName, value: int) -> void:
if not resource_labels.has(resource_name):
return
var titles := {
&"gold": "所持金",
&"mana": "マナ",
&"materials": "資材",
&"faith": "信仰",
}
(resource_labels[resource_name] as Label).text = "%s %d" % [titles[resource_name], value]
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:
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")))
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.
return _panel_style(color, border_color, mini(radius, 1), mini(margin, 6))
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:
var interface_colors := {
&"window_background": Color("17261f"),
&"dark_panel": Color("24392e"),
&"dark_panel_alt": Color("20342a"),
&"light_panel": Color("e9e6cd"),
&"border_gold": Color("778267"),
&"text_light": Color("eae7cc"),
&"text_dark": Color("344838"),
&"button_normal": Color("354b39"),
&"button_hover": Color("4c6246"),
&"button_pressed": Color("79623c"),
&"button_disabled": Color("354135"),
}
if interface_colors.has(property_name):
return interface_colors[property_name]
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