Files
TheTree_mock/scenes/main/main.gd
T
TakeshiandClaude Opus 5.5 8917c190cf P14-02: Split warps into guest FRONT and staff BACK service warp (A08)
The FRONT Mana warp is guest-only: its connectors carry the "!staff" access,
which path access checks treat as open to everyone except staff. A new 2x2
BACK service warp links only to other service warps for staff, with A07
values. Registry and palette now hold 25 definitions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:48:14 +09:00

3229 lines
139 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 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",
&"mana_warp",
&"service_warp",
&"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()
_refresh_mana_tooltip()
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 MANA_RESERVE := preload("res://scripts/mana/mana_reserve_cycle.gd")
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.text += "/%d" % MANA_RESERVE.CAPACITY
label.mouse_filter = Control.MOUSE_FILTER_STOP
_refresh_mana_tooltip(label)
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 _refresh_mana_tooltip(label: Label = null) -> void:
if label == null:
label = resource_labels.get(&"mana") as Label
if label == null:
return
var lines: Array[String] = []
if _runtime != null and _runtime.has_method(&"get_mana_reserve_report"):
var report: Dictionary = _runtime.call(&"get_mana_reserve_report")
lines.append("供給 +%d/%d分 消費 −%d/%d分 収支 %+d" % [int(report[&"generation"]), int(report[&"interval_minutes"]), int(report[&"demand"]), int(report[&"interval_minutes"]), int(report[&"net"])])
var flow: Dictionary = report[&"last_flow"]
var stored := "蓄積 %d/%d" % [int(report[&"amount"]), int(report[&"capacity"])]
if not flow.is_empty():
stored += "(直近:不足 %d・上限超過で破棄 %d)" % [int(flow[&"shortage"]), int(flow[&"overflow"])]
lines.append(stored)
else:
lines.append("世界樹が%d分ごとに%dマナを生成し、施設の維持マナを先に支払って残りを最大%dまで蓄えます。" % [MANA_RESERVE.INTERVAL_MINUTES, MANA_RESERVE.WORLD_TREE_GENERATION, MANA_RESERVE.CAPACITY])
lines.append("残量0で全館停止。接客・調理・搬入・エレベーターと新規入館が停止します。")
lines.append("残量と配電が回復すると再開します。破壊・摘発による休止は解除されません。")
label.tooltip_text = "\n".join(lines)
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()