Record original-alignment implementation and traceability checkpoint

Capture current source, content data, tests, asset licenses, and original-specification traceability. Original implementation remains incomplete. The accepted matching UI generation passed 154 suites and 12,253 checks, with eight rendered staff-contract screens reviewed. Latest native Windows and full-original acceptance remain outstanding.
This commit is contained in:
Takeshi
2026-09-30 23:52:24 +09:00
parent 6077e02ebe
commit 5dde0c5ec2
490 changed files with 54051 additions and 809 deletions
+530 -75
View File
@@ -1,21 +1,32 @@
extends Control
const RankDialogScript := preload("res://scripts/ui/world_tree_rank_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",
@@ -40,6 +51,9 @@ 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": "必要人員不足",
@@ -83,7 +97,7 @@ const STAFF_ROLE_NAMES := {
&"masseur": "施術",
&"medic": "医療",
&"porter": "物流",
&"receptionist": "受付",
&"receptionist": "受付", &"innkeeper": "女将", &"cleric": "聖職者",
}
const STAFF_SHIFT_NAMES := {
&"day": "日勤",
@@ -102,9 +116,16 @@ var manual_save_path := MANUAL_SAVE_PATH
var time_label: Label
var phase_label: Label
var guest_count_label: Label
var rank_button: Button
var rank_dialog: ConfirmationDialog
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 = {}
@@ -112,6 +133,7 @@ var world_preview: WorldPreview
var front_button: Button
var back_button: Button
var excavation_button: Button
var pan_button: Button
var pause_button: Button
var speed_buttons: Dictionary = {}
var dashboard_summary_label: Label
@@ -127,6 +149,8 @@ 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
@@ -134,6 +158,7 @@ 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
@@ -197,6 +222,12 @@ func _ready() -> void:
"本番運営を開始できませんでした: %s" % _result_message(_operations_start_report),
false
)
_focus_initial_view.call_deferred()
func _focus_initial_view() -> void:
await get_tree().process_frame
world_preview.focus_operating_area()
func _pause_fresh_opening() -> void:
@@ -240,8 +271,8 @@ func _unhandled_key_input(event: InputEvent) -> void:
_set_speed(1)
KEY_2:
_set_speed(2)
KEY_4:
_set_speed(4)
KEY_3:
_set_speed(3)
KEY_R:
if _selected_definition == null:
handled = false
@@ -255,6 +286,7 @@ func _unhandled_key_input(event: InputEvent) -> void:
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
@@ -298,7 +330,7 @@ func _build_interface() -> void:
map_panel.add_child(world_preview)
opening_guide = OpeningGuideScript.new()
world_preview.add_child(opening_guide)
opening_guide.setup(_runtime, GameClock)
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)
@@ -334,11 +366,23 @@ func _create_top_bar() -> Control:
title.add_theme_font_size_override("font_size", 17)
title.add_theme_color_override("font_color", Color("fff0c8"))
title_box.add_child(title)
var subtitle := Label.new()
subtitle.text = "世界樹の宿"
subtitle.add_theme_font_size_override("font_size", 10)
subtitle.add_theme_color_override("font_color", Color("e5c992"))
title_box.add_child(subtitle)
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.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()
@@ -351,8 +395,18 @@ func _create_top_bar() -> Control:
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 = _hud_value_label("来客 0人", 72)
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)
@@ -374,6 +428,9 @@ func _create_top_bar() -> Control:
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"
@@ -384,7 +441,7 @@ func _create_top_bar() -> Control:
row.add_child(spacer)
pause_button = _button("停止", _toggle_pause, 52)
row.add_child(pause_button)
for value: int in [1, 2, 4]:
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
@@ -424,7 +481,7 @@ func _create_navigation_rail() -> Control:
column.add_child(nav_spacer)
var layer_hint := Label.new()
layer_hint.text = "TAB\n表 / 裏"
layer_hint.tooltip_text = "ホイール: 拡大縮小\n中ボタンドラッグ: 移動\nHome: 全体表示"
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"))
@@ -487,6 +544,9 @@ func _create_side_panel() -> Control:
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
@@ -542,6 +602,12 @@ func _create_side_panel() -> Control:
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
@@ -557,7 +623,7 @@ func _create_side_panel() -> Control:
operating_ledger_panel = OperatingLedgerPanelScript.new()
root_column.add_child(operating_ledger_panel)
operating_ledger_panel.setup(_runtime, GameClock)
operating_ledger_panel.setup(_runtime, GameClock, _apply_button_style, GameState.active_layer)
operating_ledger_panel.visible = false
supply_management_panel = SupplyManagementPanelScript.new()
supply_management_panel.configure(_runtime, _apply_button_style)
@@ -607,6 +673,15 @@ func _create_bottom_bar() -> Control:
_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 = "左ドラッグで画面を移動。もう一度押すか 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)
@@ -617,7 +692,18 @@ func _create_bottom_bar() -> Control:
var save_button := _button("保存", _save_manual, 62)
_apply_button_icon(save_button, visual_theme.control_icon(&"save") if visual_theme != null else null, 16)
row.add_child(save_button)
row.add_child(_button("読込", _load_manual, 56))
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().id_pressed.connect(func(id: int):
if id == 0: _load_manual()
elif id == 1: _preview_dimension_migration())
row.add_child(load_menu)
palette_scroll = ScrollContainer.new()
palette_scroll.name = "BuildPaletteScroll"
@@ -654,6 +740,9 @@ func _set_build_palette_open(open: bool) -> void:
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:
@@ -697,7 +786,7 @@ func _activate_management_mode(mode: StringName) -> void:
&"investment":
_set_status("投資管理は一覧画面の実装準備中です。", true, true)
&"ledger":
_set_status("本日の資金増減と、06:00に確定した営業日の損益を表示します。", true, true)
_set_status("本日の資金増減と、00:00に確定した営業日の損益を表示します。", true, true)
func _set_ledger_panel_open(open: bool) -> void:
@@ -776,14 +865,23 @@ func _connect_signals() -> void:
GameClock.phase_changed.connect(_on_phase_changed)
GameState.resource_changed.connect(_on_resource_changed)
world_preview.facility_selected.connect(_on_facility_selected)
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.construction_selected.connect(_on_construction_selected)
world_preview.cell_clicked.connect(_on_map_cell_clicked)
world_preview.build_preview_changed.connect(_on_build_preview_changed)
world_preview.excavation_preview_changed.connect(_on_excavation_preview_changed)
if _runtime != null and _runtime is Object:
if _runtime.has_signal(&"snapshot_published"):
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"))
@@ -847,7 +945,7 @@ func _populate_staff_candidates() -> void:
content.add_child(detail)
var hire_button := Button.new()
hire_button.name = "Hire_%s" % String(candidate_id)
hire_button.text = "雇用する"
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))
@@ -950,7 +1048,7 @@ func _refresh_staff_candidates() -> void:
continue
var pending := _pending_hire_commands.values().has(candidate_id)
button.disabled = hired or pending
button.text = "雇用済み" if hired else "手続き中…" if pending else "雇用する"
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,
@@ -1022,8 +1120,8 @@ func _hire_failure_message(result: Variant) -> String:
if details is Dictionary:
var reason := StringName(details.get(&"reason", &""))
match reason:
&"ARREARS_REMAIN":
return "雇用不可: 未払給与 G%dを清算してください。" % int(details.get(&"arrears_total", 0))
&"ARREARS_REMAIN", &"UNPAID_EXPENSES":
return "雇用不可: 未払経費 G%dを清算してください。" % int(details.get(&"arrears_total", 0))
&"BANKRUPT":
return "雇用不可: 資金破綻後は新しい契約を結べません。"
&"EXTERNAL_ROLLBACK_FAILED":
@@ -1065,6 +1163,7 @@ func _select_build_definition(definition_id: StringName) -> void:
_set_status("選択した施設定義が見つかりません。", false)
return
_set_excavation_mode(false, false)
_set_pan_mode(false)
_selected_definition_id = definition_id
_selected_definition = definition
_build_rotation = 0
@@ -1089,6 +1188,10 @@ func _clear_build_selection(announce := true) -> void:
func _clear_active_tool() -> void:
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
@@ -1110,7 +1213,24 @@ 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("画面移動中:左ドラッグで移動、ホイールで拡大縮小。Escで解除。" if world_preview.pan_mode else "画面移動を解除しました。施設をクリックすると情報を表示します。", true, true)
func _set_pan_mode(enabled: bool) -> void:
if enabled:
_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_pan_mode(false)
_excavation_mode = enabled
if excavation_button != null:
excavation_button.set_pressed_no_signal(_excavation_mode)
@@ -1316,6 +1436,13 @@ func _on_construction_selected(job_id: StringName) -> void:
func _refresh_dashboard() -> void:
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
@@ -1360,12 +1487,22 @@ func _refresh_dashboard() -> void:
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:
@@ -1377,10 +1514,11 @@ func _refresh_dashboard() -> void:
lodging_guests += 1
lodging_count_label.text = "宿泊 %d人" % lodging_guests
if alert_count_label != null:
alert_count_label.text = "警戒 %d" % heat_value
alert_count_label.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 else Color("ebca84") if heat_value >= 30 else Color("c7d3a8")
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()
@@ -1502,7 +1640,7 @@ func _submit_foreground_event_choice(
func _operations_dashboard_text() -> String:
if _runtime == null or not _runtime.has_method(&"get_operations_report"):
return ""
var report_value: Variant = _runtime.call(&"get_operations_report")
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
@@ -1513,7 +1651,7 @@ func _operations_dashboard_text() -> String:
var terminal := StringName(coordinator.get(&"terminal_state", &"RUNNING"))
var state_text := "営業中"
if terminal == &"BANKRUPT":
state_text = "破綻(終了)"
state_text = "ゲーム終了"
elif bool(report.get(&"runtime_failure_latched", false)):
state_text = "処理失敗・再試行待ち"
elif bool(report.get(&"tick_blocked", false)):
@@ -1524,16 +1662,19 @@ func _operations_dashboard_text() -> String:
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" % [
"\n行動点 %d / 6  未払経費 G %d\n" % [
int(report.get(&"personal_action_points", 0)),
int(coordinator.get(&"arrears_total", 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():
@@ -1576,12 +1717,26 @@ func _refresh_selected_entity() -> void:
selected_entity_icon.texture = null
return
var construction_definition_id := StringName(construction_facility.get(&"definition_id", &""))
selected_heading_label.text = String(construction_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 = "施設データを運営サービスへ同期しています。"
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)
@@ -1589,16 +1744,43 @@ func _refresh_selected_entity() -> void:
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 = _runtime.call(&"submit_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
@@ -1703,12 +1885,13 @@ func _move_selected_detail_first(first: bool) -> void:
var selected_row := selected_detail_label.get_parent()
# The transport action fits without scrolling; other management views keep
# their existing staff-first layout.
var desired_index := 0 if first else column.get_child_count()-3
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:
@@ -1894,9 +2077,9 @@ func _excavation_gate_message() -> String:
return "資金破綻により新しい掘削を開始できません"
if bool(coordinator.get(&"external_quarantined", false)):
return "運営データの復旧が必要なため掘削を開始できません"
var arrears := int(coordinator.get(&"arrears_total", 0))
var arrears := int(report.get(&"arrears_total", coordinator.get(&"arrears_total", 0)))
if arrears > 0:
return "未払給与 G %dを清算するまで掘削できません" % arrears
return "未払経費 G %dを清算するまで掘削できません" % arrears
return ""
@@ -1908,8 +2091,8 @@ func _excavation_command_failure_message(result: Variant) -> String:
return _excavation_failure_message(report_value)
var reason := StringName(details.get(&"reason", &""))
match reason:
&"ARREARS_REMAIN":
return "掘削不可: 未払給与 G %dを清算してください" % int(details.get(&"arrears_total", 0))
&"ARREARS_REMAIN", &"UNPAID_EXPENSES":
return "掘削不可: 未払経費 G %dを清算してください" % int(details.get(&"arrears_total", 0))
&"BANKRUPT":
return "掘削不可: 資金破綻により新しい工事は開始できません"
&"EXTERNAL_ROLLBACK_FAILED":
@@ -1942,14 +2125,14 @@ func _save_manual() -> void:
if directory_error != OK and directory_error != ERR_ALREADY_EXISTS:
_set_status("保存フォルダを作成できませんでした。", false)
return
var result: Variant = _runtime.call(&"save_to_path", manual_save_path, {&"display_name": "手動保存"})
var result: Variant = _runtime.call(&"save_to_path", manual_save_path, {&"display_name": "手動保存", &"interface_selection": _capture_interface_selection()})
if result is Dictionary and bool(result.get(&"ok", false)):
_set_status("手動セーブを保存しました。", true)
else:
_set_status("保存に失敗しました: %s" % _result_message(result), false)
func _load_manual() -> void:
func _load_manual(strict: bool = false) -> void:
if _runtime == null or (
not _runtime.has_method(&"load_with_recovery")
and not _runtime.has_method(&"load_from_path")
@@ -1958,32 +2141,135 @@ func _load_manual() -> void:
return
var result: Variant = (
_runtime.call(&"load_with_recovery", manual_save_path)
if _runtime.has_method(&"load_with_recovery")
if not strict and _runtime.has_method(&"load_with_recovery")
else _runtime.call(&"load_from_path", manual_save_path)
)
if result is Dictionary and bool(result.get(&"ok", false)):
_pending_build_commands.clear()
_pending_excavation_commands.clear()
_pending_hire_commands.clear()
_pending_elevator_commands.clear()
_synchronize_ui_command_state()
if staff_management_panel != null:
staff_management_panel.refresh()
if supply_management_panel != null:
supply_management_panel.refresh()
if business_management_panel != null:
business_management_panel.refresh()
_selected_entity_id = &""
_selected_entity_kind = &""
world_preview.selected_entity_id = &""
world_preview.queue_redraw()
_refresh_dashboard()
_update_pause_and_speed()
# 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 _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}
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:
_pending_build_commands.clear()
_pending_excavation_commands.clear()
_pending_hire_commands.clear()
_pending_elevator_commands.clear()
_synchronize_ui_command_state()
if staff_management_panel != null:
staff_management_panel.refresh()
if supply_management_panel != null:
supply_management_panel.refresh()
if business_management_panel != null:
business_management_panel.refresh()
_selected_entity_id = &""
_selected_entity_kind = &""
world_preview.selected_entity_id = &""
world_preview.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", {}))
_refresh_dashboard()
_update_pause_and_speed()
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 selection.get("version", 0) != 1:
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("select_staff", &"")
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.selected_guest_id = &""
old_panel.refresh()
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 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 _result_message(result: Variant) -> String:
if result is Dictionary:
var message := String(result.get(&"message", result.get(&"code", "保存データがありません")))
@@ -2058,15 +2344,55 @@ func _advance_ui_sequence_from_key(key: String) -> void:
_elevator_sequence = maxi(_elevator_sequence, next_sequence)
func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void:
_synchronize_elevator_pending()
var _dashboard_refresh_pending := false
func _queue_dashboard_refresh() -> void:
if _dashboard_refresh_pending:
return
_dashboard_refresh_pending = true
_flush_dashboard_refresh.call_deferred()
func _flush_dashboard_refresh() -> void:
if not _dashboard_refresh_pending or not is_inside_tree():
return
_dashboard_refresh_pending = false
_refresh_dashboard()
func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void:
_synchronize_elevator_pending()
_queue_dashboard_refresh()
_update_pause_and_speed()
var operation: Dictionary = _snapshot.get(&"systems", {}).get(&"runtime", {}).get(&"operations", {}).get(&"coordinator", {})
var summaries: Array = operation.get(&"day_summaries", [])
if summaries.is_empty():
_last_presented_close_minute = 0
return
var latest: Dictionary = summaries.back()
var closed_at := int(latest.get(&"closed_at", 0))
if closed_at > _last_presented_close_minute:
_last_presented_close_minute = closed_at
operating_ledger_panel.select_latest_close()
_activate_management_mode(&"ledger")
var losses := int(latest.get(&"consecutive_insolvency_count", 0))
if int(latest[&"day"]) <= int(operation.get(&"legacy_history_last_day", 0)):
_set_status("旧版で確定した決算を表示しています。", true, true)
return
_set_status("3回連続の赤字決算でゲーム終了です。帳簿を確認できます。" if String(latest.get(&"terminal_state", "")) == "BANKRUPT" else "第%d日 決算:利益 %+dG・連続赤字 %d回" % [int(latest[&"day"]), int(latest[&"net_profit"]), losses], true, true)
else:
_last_presented_close_minute = closed_at
func _on_command_completed(result: Variant) -> void:
if result == null:
return
if _pending_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())
@@ -2115,6 +2441,19 @@ func _set_layer(value: int) -> void:
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)
_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)
@@ -2166,7 +2505,7 @@ func _update_pause_and_speed() -> void:
var operations_terminal := false
var event_waiting := false
if _runtime != null and _runtime.has_method(&"get_operations_report"):
var report_value: Variant = _runtime.call(&"get_operations_report")
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
@@ -2237,7 +2576,16 @@ func _on_resource_changed(resource_name: StringName, value: int) -> void:
&"materials": "資材",
&"faith": "信仰",
}
(resource_labels[resource_name] as Label).text = "%s %d" % [titles[resource_name], value]
var label := resource_labels[resource_name] as Label
label.text = "%s %d" % [titles[resource_name], value]
if resource_name == &"mana":
label.mouse_filter = Control.MOUSE_FILTER_STOP
label.tooltip_text = "残量0で全館停止。接客・調理・搬入・エレベーターと新規入館が停止します。\n残量と配電が回復すると再開します。破壊・摘発による休止は解除されません。"
if value <= 0:
label.text += "・停止"
label.add_theme_color_override("font_color", Color("ffb095"))
else:
label.add_theme_color_override("font_color", _visual_color(&"text_light", Color("fff2d0")))
func _button(text_value: String, callback: Callable, minimum_width := 82) -> Button:
@@ -2295,6 +2643,7 @@ func _section_heading(text_value: String) -> Label:
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
@@ -2309,9 +2658,11 @@ func _panel_style(color: Color, border_color: Color, radius := 8, margin := 12)
return style
func _themed_panel_style(_skin_id: StringName, color: Color, border_color: Color, radius := 0, margin := 5) -> StyleBox:
func _themed_panel_style(skin_id: StringName, color: Color, border_color: Color, radius := 0, margin := 5) -> StyleBox:
# Small flat frames keep attention on the animated cross-section.
return _panel_style(color, border_color, mini(radius, 1), mini(margin, 6))
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:
@@ -2326,21 +2677,6 @@ func _set_status(message: String, success: bool, quiet := false) -> void:
func _visual_color(property_name: StringName, fallback: Color) -> Color:
var interface_colors := {
&"window_background": Color("17261f"),
&"dark_panel": Color("24392e"),
&"dark_panel_alt": Color("20342a"),
&"light_panel": Color("e9e6cd"),
&"border_gold": Color("778267"),
&"text_light": Color("eae7cc"),
&"text_dark": Color("344838"),
&"button_normal": Color("354b39"),
&"button_hover": Color("4c6246"),
&"button_pressed": Color("79623c"),
&"button_disabled": Color("354135"),
}
if interface_colors.has(property_name):
return interface_colors[property_name]
if visual_theme == null:
return fallback
var value: Variant = visual_theme.get(property_name)
@@ -2397,3 +2733,122 @@ func _definition_value(source: Variant, property_name: StringName, fallback: Var
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.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.select_raid(&"")
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()
+526 -114
View File
@@ -2,6 +2,7 @@ class_name WorldPreview
extends Control
signal facility_selected(instance_id: StringName)
signal staff_assignment_dropped(data: Dictionary, facility_id: StringName)
signal construction_selected(job_id: StringName)
signal legacy_stair_selected(stair_id: StringName)
signal cell_clicked(cell: Vector3i)
@@ -14,7 +15,12 @@ enum LayerMode {
BACK,
}
const RoomPresentation := preload("res://scripts/ui/room_presentation.gd")
const People := preload("res://scripts/ui/pixel_people.gd")
const PixelArt := preload("res://scripts/ui/pixel_tower_art.gd")
const InactiveLayerRooms := preload("res://scripts/ui/inactive_layer_rooms.gd")
const LAYER_LENS_SHADER := preload("res://assets/shaders/layer_lens.gdshader")
const LAYER_TRANSITION_SECONDS := 0.55
const MAP_PADDING := Vector2(76.0, 70.0)
const INVALID_PREVIEW := {&"can_build": false, &"error_codes": [], &"reasons": [], &"cells": []}
const INVALID_EXCAVATION_PREVIEW := {
@@ -27,7 +33,10 @@ const INVALID_EXCAVATION_PREVIEW := {
var layer_mode := LayerMode.FRONT:
set(value):
var previous := layer_mode
layer_mode = clampi(value, LayerMode.FRONT, LayerMode.BACK) as LayerMode
if previous != layer_mode and is_node_ready():
_start_layer_transition()
if _hovered_cell != null:
_hovered_cell = Vector3i(_hovered_cell.x, _hovered_cell.y, layer_mode)
queue_redraw()
@@ -47,12 +56,29 @@ var visual_theme: VisualThemeProfile = null
var zoom_level := 1.0
var pan_offset := Vector2.ZERO
var show_grid := false
var pan_mode := false
var _drawing := false
var _draw_cache: Dictionary = {}
var _inactive_layer_art: Node2D
var _layer_lens: ColorRect
var _layer_transition_elapsed := LAYER_TRANSITION_SECONDS
var _animation_time := 0.0
var _animation_frame := -1
var _actor_tracks: Dictionary = {}
var _elevator_tracks: Dictionary = {}
var _boarded_actor_ids: Dictionary = {}
var _carried_patients: Dictionary = {}
var _treated_patients: Dictionary = {}
var _resting_staff: Dictionary = {}
var _room_presentations: Dictionary = {}
var _queue_positions: Dictionary = {}
var _queue_hidden: Dictionary = {}
var _landing_badges: Array[Dictionary] = []
var _transport_report: Dictionary = {}
var _dragging := false
var _drag_button := MOUSE_BUTTON_NONE
var _runtime: Variant = null
var _hovered_cell: Variant = null
@@ -70,6 +96,18 @@ func _ready() -> void:
if _runtime == null:
_runtime = get_node_or_null("/root/SimulationRuntime")
_connect_runtime()
_inactive_layer_art = InactiveLayerRooms.new()
_inactive_layer_art.name = "InactiveLayerRooms"
add_child(_inactive_layer_art)
_layer_lens = ColorRect.new()
_layer_lens.name = "LayerLens"
_layer_lens.mouse_filter = Control.MOUSE_FILTER_IGNORE
_layer_lens.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var lens_material := ShaderMaterial.new()
lens_material.shader = LAYER_LENS_SHADER
_layer_lens.material = lens_material
_layer_lens.visible = false
add_child(_layer_lens)
queue_redraw()
@@ -173,6 +211,8 @@ func get_build_preview_report(cell: Variant = null) -> Dictionary:
func get_map_rect() -> Rect2:
if _drawing and _draw_cache.has(&"rect"):
return _draw_cache[&"rect"]
var bounds := _world_bounds()
if bounds.is_empty():
return Rect2()
@@ -181,10 +221,16 @@ func get_map_rect() -> Rect2:
int(bounds[&"max_x"]) - int(bounds[&"min_x"]) + 1,
int(bounds[&"max_y"]) - int(bounds[&"min_y"]) + 1
) * dimensions
return Rect2(((size - map_size) * 0.5 + pan_offset).floor(), map_size)
var result_value = Rect2(((size - map_size) * 0.5 + pan_offset).floor(), map_size)
if _drawing:
_draw_cache[&"rect"] = result_value
return result_value
func _cell_dimensions() -> Vector2:
if _drawing and _draw_cache.has(&"dimensions"):
return _draw_cache[&"dimensions"]
var bounds := _world_bounds()
if bounds.is_empty():
return Vector2(32, 48)
@@ -195,7 +241,11 @@ func _cell_dimensions() -> Vector2:
# Native 16px units use integer magnification. Large worlds can be viewed
# at overview scale, then enlarged with the same invertible camera mapping.
var pixel := maxf(floorf(fit), 1.0) if fit >= 1.0 else maxf(fit, 0.25)
return (Vector2(16, 24) * pixel * zoom_level).round().max(Vector2(4, 6))
var result_value = (Vector2(16, 24) * pixel * zoom_level).round().max(Vector2(4, 6))
if _drawing:
_draw_cache[&"dimensions"] = result_value
return result_value
func set_zoom(value: float, anchor := Vector2(-1, -1)) -> void:
@@ -217,12 +267,77 @@ func reset_view() -> void:
queue_redraw()
func focus_operating_area() -> void:
# Explicit framing, never continuous tracking: the player's pan stays put.
zoom_level = 1.0
pan_offset = Vector2.ZERO
var area := Rect2()
var construction: Variant = _service(&"construction_service")
if construction != null:
for item: Dictionary in construction.get_facilities():
if int(item.get(&"layer", 0)) != layer_mode:
continue
var cells := _cells_from_serialized(item.get(&"occupied_cells", []))
if cells.is_empty():
continue
var rect := _cell_bounds_rect(cells)
area = area.merge(rect) if area.has_area() else rect
if area.has_area():
var relative := (area.get_center()-get_map_rect().position) / get_map_rect().size
zoom_level = clampf(minf(size.x*0.68/area.size.x, size.y*0.65/area.size.y), 0.5, 2.0)
var rect := get_map_rect()
pan_offset += size*Vector2(0.5, 0.58) - (rect.position+rect.size*relative)
else:
zoom_level = 2.0
pan_offset += size*Vector2(0.5, 0.65) - cell_to_canvas(Vector3i(0, 1, layer_mode))
_clamp_pan()
clear_hovered_cell()
queue_redraw()
func set_pan_mode(enabled: bool) -> void:
pan_mode = enabled
_end_pan_drag()
clear_hovered_cell()
func _end_pan_drag() -> void:
_dragging = false
_drag_button = MOUSE_BUTTON_NONE
mouse_default_cursor_shape = Control.CURSOR_DRAG if pan_mode else Control.CURSOR_ARROW
func _notification(what: int) -> void:
if what == NOTIFICATION_APPLICATION_FOCUS_OUT:
_end_pan_drag()
func _clamp_pan() -> void:
var extent := get_map_rect().size * 0.5 + size * 0.3
pan_offset = pan_offset.clamp(-extent, extent)
func _start_layer_transition() -> void:
if _layer_lens == null: return
_layer_transition_elapsed = 0.0
var center := size * 0.5
if _hovered_cell != null:
center = cell_to_canvas(_hovered_cell)
var lens_material := _layer_lens.material as ShaderMaterial
lens_material.set_shader_parameter("center", center / size.max(Vector2.ONE))
lens_material.set_shader_parameter("extent", size)
lens_material.set_shader_parameter("progress", 0.0)
_layer_lens.visible = true
func _process(delta: float) -> void:
# UI transitions complete even in active pause; actor time remains frozen.
if _layer_lens != null and _layer_lens.visible:
_layer_transition_elapsed = minf(_layer_transition_elapsed + maxf(delta, 0.0), LAYER_TRANSITION_SECONDS)
var lens_material := _layer_lens.material as ShaderMaterial
lens_material.set_shader_parameter("extent", size)
lens_material.set_shader_parameter("progress", _layer_transition_elapsed / LAYER_TRANSITION_SECONDS)
_layer_lens.visible = _layer_transition_elapsed < LAYER_TRANSITION_SECONDS
var clock := get_node_or_null("/root/GameClock")
if clock != null and bool(clock.get("paused")):
return
@@ -294,9 +409,15 @@ func facility_at_canvas(canvas_position: Vector2) -> StringName:
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_MIDDLE:
_dragging = event.pressed
mouse_default_cursor_shape = Control.CURSOR_DRAG if _dragging else Control.CURSOR_ARROW
if not event.pressed and event.button_index == _drag_button:
_end_pan_drag()
accept_event()
return
if event.button_index == MOUSE_BUTTON_MIDDLE or (pan_mode and event.button_index == MOUSE_BUTTON_LEFT):
if event.pressed:
_dragging = true
_drag_button = event.button_index
mouse_default_cursor_shape = Control.CURSOR_DRAG
accept_event()
return
if event.pressed and event.button_index in [MOUSE_BUTTON_WHEEL_UP, MOUSE_BUTTON_WHEEL_DOWN]:
@@ -304,13 +425,18 @@ func _gui_input(event: InputEvent) -> void:
accept_event()
return
if event is InputEventMouseMotion and _dragging:
var mask := MOUSE_BUTTON_MASK_LEFT if _drag_button == MOUSE_BUTTON_LEFT else MOUSE_BUTTON_MASK_MIDDLE
if not event.button_mask & mask:
_end_pan_drag()
return
pan_offset += event.relative
_clamp_pan()
queue_redraw()
accept_event()
return
if event is InputEventMouseMotion:
_set_hovered_cell(canvas_to_cell(event.position))
if not pan_mode:
_set_hovered_cell(canvas_to_cell(event.position))
return
if not event is InputEventMouseButton or not event.pressed or event.button_index != MOUSE_BUTTON_LEFT:
return
@@ -348,6 +474,16 @@ func _gui_input(event: InputEvent) -> void:
func _draw() -> void:
_drawing = true
_draw_cache.clear()
_draw_contents()
_drawing = false
_draw_cache.clear()
func _draw_contents() -> void:
if _inactive_layer_art != null:
_inactive_layer_art.visible = false
_draw_background()
var bounds := _world_bounds()
if bounds.is_empty():
@@ -359,12 +495,14 @@ func _draw() -> void:
_draw_world_cells(bounds, map_rect)
if show_grid:
_draw_congestion_overlay(bounds)
_prepare_room_presentations()
_prepare_queues()
_draw_world_entity_icons()
_draw_legacy_stairs()
_draw_elevators()
_draw_other_layer()
_draw_construction_labels()
_draw_facility_labels()
_draw_queue_badges()
_draw_actors()
_draw_excavation_jobs()
_draw_build_preview()
@@ -519,6 +657,79 @@ func _draw_congestion_overlay(bounds: Dictionary) -> void:
draw_line(Vector2(rect.end.x, rect.position.y), Vector2(rect.position.x, rect.end.y), color, 2.0)
func _completed_facilities_for_draw() -> Array:
if _drawing and _draw_cache.has(&"completed_facilities"):
return _draw_cache[&"completed_facilities"]
var construction: Variant = _service(&"construction_service")
var facilities: Array = construction.get_facilities() if construction != null else []
if _drawing:
_draw_cache[&"completed_facilities"] = facilities
return facilities
func _construction_jobs_for_draw() -> Array:
if _drawing and _draw_cache.has(&"construction_jobs"):
return _draw_cache[&"construction_jobs"]
var construction: Variant = _service(&"construction_service")
var jobs: Array = construction.get_jobs() if construction != null else []
if _drawing:
_draw_cache[&"construction_jobs"] = jobs
return jobs
func _other_layer_rooms() -> Array[Dictionary]:
var rooms: Array[Dictionary] = []
for facility: Dictionary in _completed_facilities_for_draw():
if int(facility.get(&"layer", 0)) == layer_mode: continue
var cells := _cells_from_serialized(facility.get(&"occupied_cells", []))
if cells.is_empty(): continue
var rect := _cell_bounds_rect(cells)
if rect.intersects(Rect2(Vector2.ZERO, size)):
rooms.append({&"id": facility[&"instance_id"], &"rect": rect, &"definition_id": facility[&"definition_id"], &"origin_x": cells[0].x})
for job: ConstructionJob in _construction_jobs_for_draw():
if job.layer == layer_mode or job.state in [ConstructionJob.State.COMPLETED, ConstructionJob.State.CANCELLED]: continue
if job.reserved_cells.is_empty(): continue
var rect := _cell_bounds_rect(job.reserved_cells)
if rect.intersects(Rect2(Vector2.ZERO, size)):
rooms.append({&"id":job.job_id, &"rect":rect, &"definition_id":job.definition_id,
&"origin_x":job.origin.x, &"building":true, &"progress":job.progress})
for stair: Dictionary in get_legacy_stair_records():
var upper: Vector3i = stair[&"to"] if stair[&"to"].y > stair[&"from"].y else stair[&"from"]
if upper.z == layer_mode: continue
var rect := _legacy_stair_rect(upper)
if rect.intersects(Rect2(Vector2.ZERO,size)):
rooms.append({&"id":stair[&"id"], &"rect":rect, &"legacy_stair":true,
&"definition_id":&"service_stairs" if upper.z==LayerMode.BACK else &"guest_stairs", &"origin_x":upper.x})
return rooms
func _draw_other_layer() -> void:
# Opposite-layer footprints are visual context only. Hit testing still uses
# the selected layer's WorldService occupancy, including overlapping rooms.
var rooms := _other_layer_rooms()
if _inactive_layer_art != null:
_inactive_layer_art.visible = layer_mode == LayerMode.FRONT
_inactive_layer_art.set("rooms", rooms)
_inactive_layer_art.set("pixel_scale", _cell_dimensions().x / 16.0)
_inactive_layer_art.set("dungeon", visual_theme.pixel_dungeon_tiles if visual_theme != null else null)
_inactive_layer_art.set("town", visual_theme.pixel_town_tiles if visual_theme != null else null)
_inactive_layer_art.queue_redraw()
for room: Dictionary in rooms:
var rect: Rect2 = room[&"rect"]
if bool(room.get(&"building", false)):
var color := Color(0.95,0.76,0.35,0.45)
draw_rect(rect.grow(-2),color,false,1.0)
_draw_hatch(rect.grow(-3),Color(color,0.24),_cell_dimensions().x)
_draw_readable_badge(rect.position+Vector2(3,3),"%s 工事%d%%" % ["裏" if layer_mode==LayerMode.FRONT else "表",clampi(int(room[&"progress"])/100,0,100)])
continue
if layer_mode == LayerMode.FRONT:
draw_rect(rect.grow(-2), Color(0.6, 0.87, 0.9, 0.45), false, 1.0)
else:
var color := Color(0.94, 0.83, 0.54, 0.55)
draw_rect(rect.grow(-2), color, false, 1.0)
draw_line(rect.position+Vector2(3,3), rect.end-Vector2(3,3), Color(color,0.2), 1.0)
func _draw_world_entity_icons() -> void:
var construction: Variant = _service(&"construction_service")
if construction == null or not construction.has_method(&"get_facilities"):
@@ -526,7 +737,7 @@ func _draw_world_entity_icons() -> void:
var p := _cell_dimensions().x / 16.0
var dungeon := visual_theme.pixel_dungeon_tiles if visual_theme != null else null
var town := visual_theme.pixel_town_tiles if visual_theme != null else null
for value: Variant in construction.call(&"get_facilities"):
for value: Variant in _completed_facilities_for_draw():
if not value is Dictionary or int(value.get(&"layer",0)) != layer_mode:
continue
var facility: Dictionary = value
@@ -537,15 +748,144 @@ func _draw_world_entity_icons() -> void:
if not rect.intersects(Rect2(Vector2.ZERO,size)):
continue
var id := StringName(facility.get(&"definition_id",&""))
PixelArt.room(self,rect,id,p,dungeon,town,layer_mode==LayerMode.BACK,cells[0].x)
PixelArt.room(self,rect,id,p,dungeon,town,layer_mode==LayerMode.BACK,cells[0].x, _room_presentations.get(StringName(facility.get(&"instance_id", &"")), {}))
var instance_id := StringName(facility.get(&"instance_id",&""))
# Thin status lamp conveys operational state without repainting a room.
draw_rect(Rect2(rect.end.x-4*p,rect.position.y+2*p,2*p,2*p),_facility_color(instance_id))
if instance_id==selected_entity_id:
draw_rect(rect.grow(-p),Color("f5d582"),false,2*p)
var data: Dictionary = _room_presentations.get(instance_id, {})
if not data.is_empty() and (rect.size.x >= 80 or instance_id == selected_entity_id) and id not in [&"guest_corridor", &"service_corridor", &"guest_stairs", &"service_stairs", &"guest_elevator", &"service_elevator"]:
var status := RoomPresentation.label(data, RoomPresentation.slots(rect, p, id).size())
_draw_readable_badge(rect.position+Vector2(2, 2), status.replace("利用", "治療") if id == &"infirmary" else status.replace("利用", "休憩") if id in [&"staff_waiting_room", &"staff_dormitory"] else status)
func _draw_readable_badge(at: Vector2, text: String) -> void:
var width := ThemeDB.fallback_font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x+6
var rect := Rect2(at, Vector2(width, 14))
if not rect.intersects(Rect2(Vector2.ZERO, size)):
return
draw_rect(rect, Color("34413b", 0.94))
draw_string(ThemeDB.fallback_font, at+Vector2(3, 11), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 10, Color("fff2ce"))
func _prepare_room_presentations() -> void:
_room_presentations.clear()
_carried_patients.clear()
_treated_patients.clear()
_resting_staff.clear()
var service: Variant = _service(&"facility_service")
if service == null:
return
for instance: Variant in service.get_instances():
if instance.origin.z == layer_mode:
_room_presentations[instance.instance_id] = RoomPresentation.describe(instance)
var staff: Variant = _service(&"staff_service")
if staff != null:
if _runtime is Object and _runtime.has_method("get_staff_rest_report"):
for member: StaffRuntime in staff.get_staff_members():
if member.state != StaffRuntime.State.OFF_DUTY: continue
var rest: Dictionary = _runtime.get_staff_rest_report(member.staff_id)
if not rest.get(&"resting", false): continue
_resting_staff[member.staff_id] = rest[&"facility_id"]
var room: Dictionary = _room_presentations.get(rest[&"facility_id"], {})
if not room.is_empty():
room[&"people"].append(member.staff_id)
room[&"count"] += 1
for task: StaffTask in staff.get_tasks():
if task.task_type != &"rescue_transport" or task.is_terminal() or task.assigned_staff_id.is_empty(): continue
if task.state == StaffTask.State.WORKING:
_treated_patients[task.target_entity_id] = task.rescue_facility_id
var data: Dictionary = _room_presentations.get(task.rescue_facility_id, {})
if not data.is_empty():
data[&"people"].append(task.target_entity_id)
data[&"count"] += 1
else:
_carried_patients[task.target_entity_id] = task.assigned_staff_id
func _prepare_queues() -> void:
_queue_positions.clear()
_queue_hidden.clear()
_landing_badges.clear()
_transport_report.clear()
var groups: Dictionary = {}
var facilities: Variant = _service(&"facility_service")
var guests: Variant = _service(&"guest_service")
if facilities != null and guests != null:
for instance: Variant in facilities.get_instances():
for request_id: StringName in instance.queue_entries:
var request: Variant = facilities.get_request(request_id)
if request == null:
continue
for guest_id: StringName in request.guest_ids:
var guest: Variant = guests.get_guest(guest_id)
if guest != null and guest.state == GuestRuntime.State.QUEUING:
_add_queue_member(groups, "guest/"+String(guest_id), guest.current_cell)
var transport: Variant = _service(&"elevators")
if transport != null:
_transport_report = transport.get_report()
var trips: Array = _transport_report.get(&"trips", [])
trips.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a[&"enqueued_at"]) < int(b[&"enqueued_at"]) or (a[&"enqueued_at"] == b[&"enqueued_at"] and String(a[&"actor_id"]) < String(b[&"actor_id"])))
for trip: Dictionary in trips:
if trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED:
var cell: Variant = trip[&"from"]
if cell is Array:
cell = Vector3i(cell[0], cell[1], cell[2])
_add_queue_member(groups, _elevator_actor_key(String(trip[&"actor_id"])), cell)
var lift_cells: Dictionary = {}
for shaft: Dictionary in _transport_report.get(&"shafts", []):
for stop: Vector3i in _cells_from_serialized(shaft.get(&"stops", [])):
lift_cells[stop] = true
var reserved: Dictionary = {}
var cells: Array = groups.keys()
cells.sort_custom(func(a: Vector3i, b: Vector3i) -> bool: return a.y < b.y or (a.y == b.y and a.x < b.x))
for cell: Vector3i in cells:
var members: Array = groups[cell]
var lane: Array[Vector3i] = []
if not lift_cells.has(cell):
lane.append(cell)
var world: Variant = _service(&"world_service")
for direction: int in [-1, 1]:
for distance: int in range(1, 9):
var next := cell + Vector3i(direction*distance, 0, 0)
if world == null or not world.is_walkable(next):
break
var occupant: StringName = world.get_occupant(next)
var facility: Variant = facilities.get_instance(occupant) if facilities != null else null
if facility != null and facility.definition_id not in [&"guest_corridor", &"service_corridor"]:
break
lane.append(next)
var index := 0
for target: Vector3i in lane:
if index >= members.size():
break
if reserved.has(target):
continue
reserved[target] = true
_queue_positions[members[index]] = cell_to_canvas(target)
index += 1
for hidden: int in range(index, members.size()):
_queue_hidden[members[hidden]] = true
var text := "待ち%d人" % members.size()
if index < members.size():
text += "(他%d人)" % (members.size()-index)
_landing_badges.append({&"at": get_cell_rect(cell).position + Vector2(-80 if lift_cells.has(cell) else 0, -16), &"text": text})
func _add_queue_member(groups: Dictionary, key: String, cell: Vector3i) -> void:
if cell.z != layer_mode:
return
if not groups.has(cell):
groups[cell] = []
if not groups[cell].has(key):
groups[cell].append(key)
func get_legacy_stair_records() -> Array[Dictionary]:
if _drawing and _draw_cache.has(&"legacy_stair_records"):
return _draw_cache[&"legacy_stair_records"]
var result: Array[Dictionary] = []
var service: Variant = _service(&"legacy_stairs")
if service == null or not service.has_method(&"create_snapshot"):
@@ -560,6 +900,8 @@ func get_legacy_stair_records() -> Array[Dictionary]:
var connector: Dictionary = path.call(&"get_connector", StringName(row[&"id"])) if path != null else {}
record[&"access_id"] = connector.get(&"access_id", &"")
result.append(record)
if _drawing:
_draw_cache[&"legacy_stair_records"] = result
return result
@@ -601,21 +943,6 @@ func _cell_bounds_rect(cells: Array[Vector3i]) -> Rect2:
return result
func _draw_queue_badges() -> void:
var service: Variant = _service(&"facility_service")
if service==null or not service.has_method(&"get_instances"):
return
for instance: Variant in service.call(&"get_instances"):
if instance==null or instance.origin.z!=layer_mode or instance.queue_entries.is_empty():
continue
var count: int = instance.queue_entries.size()
var rect := get_cell_rect(instance.origin)
var at := Vector2(rect.position.x+3,rect.position.y+3)
draw_rect(Rect2(at,Vector2(30,15)),Color("76593f"))
draw_rect(Rect2(at+Vector2.ONE,Vector2(28,13)),Color("bb974e"))
draw_string(ThemeDB.fallback_font,at+Vector2(3,11),"待%d" % count,HORIZONTAL_ALIGNMENT_LEFT,-1,10,Color("fff2ce"))
func _draw_hatch(rect: Rect2, color: Color, cell_size: float) -> void:
if cell_size < 8.0:
return
@@ -632,7 +959,7 @@ func _draw_construction_labels() -> void:
if construction == null or not construction.has_method(&"get_jobs"):
return
var font := ThemeDB.fallback_font
for job: Variant in construction.call(&"get_jobs"):
for job: Variant in _construction_jobs_for_draw():
if job == null or int(job.layer) != layer_mode or int(job.state) in [2, 3]:
continue
var rect := get_cell_rect(job.origin)
@@ -647,7 +974,7 @@ func _draw_facility_labels() -> void:
var registry: Variant = _service(&"definition_registry")
if construction == null or not construction.has_method(&"get_facilities"):
return
for value: Variant in construction.call(&"get_facilities"):
for value: Variant in _completed_facilities_for_draw():
if not value is Dictionary or int(value.get(&"layer",0))!=layer_mode:
continue
var facility: Dictionary = value
@@ -677,7 +1004,7 @@ func _draw_elevators() -> void:
if transport == null or not transport.has_method(&"get_report"):
_elevator_tracks.clear()
return
var report_value: Variant = transport.call(&"get_report")
var report_value: Variant = _transport_report
if not report_value is Dictionary:
return
var alive: Dictionary = {}
@@ -708,7 +1035,8 @@ func _draw_elevators() -> void:
var phase := StringName(shaft.get(&"phase", &"IDLE"))
var doors_open := operational and phase in [&"IDLE", &"BOARDING", &"ALIGHTING"]
PixelArt.elevator_cabin(self,cabin,p,layer_mode==LayerMode.BACK,operational)
var visible_count := mini(passengers.size(), maxi(width*2-1,1))
var seat_columns := maxi(int((cabin.size.x / p - 4.0) / 8.0), 1)
var visible_count := mini(passengers.size(), seat_columns*2)
for index: int in range(visible_count):
var actor_key := _elevator_actor_key(String(passengers[index]))
var is_staff := actor_key.begins_with("staff/")
@@ -719,17 +1047,21 @@ func _draw_elevators() -> void:
person = people.call(getter,StringName(actor_key.get_slice("/",1)))
var carrying := is_staff and person != null and not String(person.carried_inventory_id).is_empty()
var state := int(person.state) if person != null else 0
var x := cabin.position.x+5*p+float(index)*(cabin.size.x-10*p)/maxf(float(visible_count-1),1.0)
if visible_count == 1:
x = cabin.get_center().x
var center := Vector2(x,cabin.end.y-_cell_dimensions().y*0.5)
var x := cabin.position.x + (2.0 + (index % seat_columns + 0.5)*8.0)*p
var feet := cabin.end.y - (2.0 + int(index / seat_columns)*9.0)*p
var center := Vector2(x, feet+3*p-_cell_dimensions().y*0.5)
_draw_pixel_actor(center,actor_key,is_staff,state,false,carrying,&"",true)
PixelArt.elevator_gate(self,cabin,p,doors_open)
var waiting := int(shaft.get(&"waiting_count",0))
if waiting > 0:
var badge := Vector2(cabin.end.x+2*p,cabin.position.y+2*p)
draw_rect(Rect2(badge,Vector2(20,12)*p),Color("76593f"))
draw_string(ThemeDB.fallback_font,badge+Vector2(2,9)*p,"待%d" % waiting,HORIZONTAL_ALIGNMENT_LEFT,-1,clampi(int(7*p),8,13),Color("f6e8bc"))
var capacity := int(shaft.get(&"capacity", 0))
var load_text := "%d/%d人" % [passengers.size(), capacity]
if capacity > 0 and passengers.size() >= capacity:
load_text = "満員 " + load_text
if not operational:
load_text = "停止 " + load_text
_draw_readable_badge(Vector2(cabin.position.x, cabin.position.y-14), load_text)
for badge: Dictionary in _landing_badges:
_draw_readable_badge(badge[&"at"], badge[&"text"])
for shaft_id: Variant in _elevator_tracks.keys():
if not alive.has(shaft_id):
_elevator_tracks.erase(shaft_id)
@@ -769,14 +1101,18 @@ func _draw_actors() -> void:
continue
var key := "guest/"+String(guest.guest_id)
alive[key] = true
if _boarded_actor_ids.has(key):
if _boarded_actor_ids.has(key) or _queue_hidden.has(key):
_actor_tracks.erase(key)
continue
var position := _tracked_actor_position(key,guest.current_cell)
if guest.current_cell.z==layer_mode:
var receiving := not String(guest.current_service_id).is_empty()
if receiving:
if _queue_positions.has(key):
position = _queue_positions[key]
elif receiving:
position = _service_use_position(guest,position,service_slots)
if not position.is_finite():
continue
else:
position += _actor_offset(guest.current_cell,counts)
_draw_pixel_actor(position,key,false,int(guest.state),receiving,false,_guest_activity(guest))
@@ -787,17 +1123,70 @@ func _draw_actors() -> void:
continue
var key := "staff/"+String(staff.staff_id)
alive[key] = true
if _boarded_actor_ids.has(key):
if _boarded_actor_ids.has(key) or _queue_hidden.has(key):
_actor_tracks.erase(key)
continue
var position := _tracked_actor_position(key,staff.current_cell)
if staff.current_cell.z==layer_mode:
_draw_pixel_actor(position+_actor_offset(staff.current_cell,counts),key,true,int(staff.state),false,not String(staff.carried_inventory_id).is_empty(),staff.role_id)
if _carried_patients.has(staff.staff_id):
var carrier: Variant = staff_service.get_staff(_carried_patients[staff.staff_id])
if carrier != null:
position = _tracked_actor_position("staff/"+String(carrier.staff_id), carrier.current_cell) - Vector2(0, 12*_cell_dimensions().x/16.0)
_draw_pixel_actor(position,key,true,int(staff.state),false,false)
continue
if _resting_staff.has(staff.staff_id):
position = _resting_staff_position(staff.staff_id, position)
if position.is_finite(): _draw_pixel_actor(position,key,true,int(staff.state),false,false)
continue
if _treated_patients.has(staff.staff_id):
position = _rescue_treatment_position(staff.staff_id, position)
if position.is_finite(): _draw_pixel_actor(position,key,true,int(staff.state),false,false)
continue
_draw_pixel_actor(_queue_positions.get(key, _staff_work_position(staff, position+_actor_offset(staff.current_cell,counts))),key,true,int(staff.state),false,not String(staff.carried_inventory_id).is_empty(),_staff_work_animation(staff))
for key: Variant in _actor_tracks.keys():
if not alive.has(key):
_actor_tracks.erase(key)
func _staff_work_animation(staff: StaffRuntime) -> StringName:
if staff.role_id != &"cleaner":
return &"work"
# A staffed base is not a cleaning site. Animate only a live job at its
# authoritative destination, including after load and interruption.
var service: Variant = _service(&"staff_service")
var bridge: Variant = _service(&"cleaning_bridge")
var task: StaffTask = service.get_task(staff.current_task_id) if service != null else null
if staff.state == StaffRuntime.State.WORKING and task != null and task.task_type == FacilityCleaningBridge.TASK_TYPE and task.state == StaffTask.State.WORKING and bridge != null and bridge.task_can_work(task, staff):
return &"clean"
return &"idle"
func _staff_work_position(staff: Variant, fallback: Vector2) -> Vector2:
if staff.state not in [StaffRuntime.State.WORKING, StaffRuntime.State.AVAILABLE]:
return fallback
var service: Variant = _service(&"facility_service")
var facility: Variant = service.get_instance(staff.assigned_facility_id) if service != null else null
if facility == null:
return fallback
var world: Variant = _service(&"world_service")
var cells: Array[Vector3i] = world.get_entity_cells(staff.assigned_facility_id) if world != null else []
if not cells.has(staff.current_cell):
return fallback
var data: Dictionary = _room_presentations.get(staff.assigned_facility_id, {})
var workers: Array = data.get(&"staff", [])
var slot := workers.find(staff.staff_id)
if slot < 0:
return fallback
var rect := _cell_bounds_rect(cells)
var p := _cell_dimensions().x / 16.0
if rect.size.y < 40*p:
return fallback
var columns := maxi(int(rect.size.x / (16*p)), 1)
if slot >= columns:
return fallback
return Vector2(rect.position.x+(slot+0.5)*rect.size.x/columns, rect.position.y+26*p-_cell_dimensions().y*0.5)
func _tracked_actor_position(key: String, cell: Vector3i) -> Vector2:
if not _actor_tracks.has(key):
_actor_tracks[key] = {&"cell":cell,&"from":cell,&"at":_animation_time,&"direction":1.0}
@@ -817,7 +1206,7 @@ func _tracked_actor_position(key: String, cell: Vector3i) -> Vector2:
return cell_to_canvas(track[&"from"]).lerp(cell_to_canvas(cell),t)
func _service_use_position(guest: Variant, fallback: Vector2, slots: Dictionary) -> Vector2:
func _service_use_position(guest: Variant, fallback: Vector2, _slots: Dictionary) -> Vector2:
# Guest paths stop at an access cell. While a service is active, project the
# occupant into that facility's use position; authoritative paths stay intact.
var id := StringName(guest.target_facility_id)
@@ -828,11 +1217,19 @@ func _service_use_position(guest: Variant, fallback: Vector2, slots: Dictionary)
if cells.is_empty() or cells[0].z!=layer_mode:
return fallback
var rect := _cell_bounds_rect(cells)
var slot := int(slots.get(id,0))
slots[id] = slot+1
var data: Dictionary = _room_presentations.get(id, {})
var slot := (data.get(&"people", []) as Array).find(guest.guest_id)
if slot < 0:
return fallback
var p := _cell_dimensions().x/16.0
var x := rect.position.x+minf(9.0+float(slot%4)*14.0,rect.size.x/p-5.0)*p
return Vector2(x,rect.end.y-_cell_dimensions().y*0.5)
var anchors := RoomPresentation.slots(rect, p, _guest_activity(guest))
if slot >= anchors.size():
return Vector2(INF, INF)
var at := anchors[slot]
if _guest_activity(guest) in [&"standard_room", &"premium_room"]:
at.y -= 5*p
return at + Vector2(0, 3*p-_cell_dimensions().y*0.5)
func _guest_activity(guest: Variant) -> StringName:
@@ -844,67 +1241,34 @@ func _guest_activity(guest: Variant) -> StringName:
func _draw_pixel_actor(center: Vector2, key: String, staff: bool, state: int, receiving: bool, carrying: bool, activity: StringName = &"", riding := false) -> void:
var texture := visual_theme.pixel_dungeon_tiles if visual_theme != null else null
if texture==null:
return
var world_pixel := _cell_dimensions().x/16.0
var p := maxf(floorf(world_pixel*0.5),1.0) if world_pixel>=1.0 else world_pixel
var track: Dictionary = _actor_tracks.get(key,{})
var moving: bool = track.get(&"cell",Vector3i.ZERO)!=track.get(&"from",Vector3i.ZERO) and _animation_time-float(track.get(&"at",0.0))<0.42
var world_pixel := _cell_dimensions().x / 16.0
var scale := 0.5 if riding else 0.75
var p := maxf(floorf(world_pixel * scale), 1.0) if world_pixel >= 2.0 else world_pixel * scale
var track: Dictionary = _actor_tracks.get(key, {})
var moving: bool = not receiving and not _queue_positions.has(key) and track.get(&"cell", Vector3i.ZERO) != track.get(&"from", Vector3i.ZERO) and _animation_time - float(track.get(&"at", 0.0)) < 0.42
var clock := get_node_or_null("/root/GameClock")
var paused := clock != null and bool(clock.get("paused"))
moving = moving and not paused and not riding
var variant := int(absi(key.hash()))%5
var tile_id := (96 if staff else 84)+variant
var frame := int(_animation_time*9.0+variant)%4
var step := (1.0 if frame==0 else (-1.0 if frame==2 else 0.0)) if moving else 0.0
var at := (center+Vector2(-8*p, _cell_dimensions().y*0.5-3*world_pixel-16*p)).round()
var source := Vector2((tile_id%12)*16,(tile_id/12)*16)
# Retain the same character; split only the feet into a four-phase walk.
# Neighbouring atlas tiles are other people, never animation frames.
var bob := -p if moving and frame%2==0 else 0.0
draw_rect(Rect2(at+Vector2(4,14)*p,Vector2(8,1)*p),Color("3a453c",0.35))
var flip := float(track.get(&"direction",1.0))<0.0
var torso := Rect2(at+Vector2(0,bob),Vector2(16,15)*p)
if flip:
torso.position.x += torso.size.x
torso.size.x = -torso.size.x
draw_texture_rect_region(texture,torso,Rect2(source,Vector2(16,15)))
for leg in range(2):
var leg_x := float(leg)*8*p
var stride := step*p*(1.0 if leg==0 else -1.0)
var foot := Rect2(at+Vector2(leg_x,14*p+stride+bob),Vector2(8,2)*p)
if flip:
foot.position.x = at.x+float(2-leg)*8*p
foot.size.x = -foot.size.x
draw_texture_rect_region(texture,foot,Rect2(source+Vector2(leg*8,14),Vector2(8,2)))
if staff:
draw_rect(Rect2(at+Vector2(7,8)*p,Vector2(2,3)*p),Color("81c6ba"))
if carrying:
PixelArt.tile(self,texture,89,Rect2(at+Vector2(9,6)*p,Vector2(9,9)*p))
var working := staff and state==StaffRuntime.State.WORKING and not riding
var resting := receiving and activity==&"standard_room"
if resting:
var lift := float(int(_animation_time*2)%3) if not paused else 0.0
draw_string(ThemeDB.fallback_font,at+Vector2(11,-1-lift)*p,"z",HORIZONTAL_ALIGNMENT_LEFT,-1,maxi(int(6*p),7),Color("f7efd6"))
if staff and activity==&"cleaner" and (moving or working):
var sweep := step*p if working or moving else 0.0
draw_rect(Rect2(at+Vector2(12*p+sweep,7*p),Vector2(p,8*p)),Color("b7975c"))
draw_rect(Rect2(at+Vector2(10*p+sweep,14*p),Vector2(5*p,2*p)),Color("cdb375"))
if receiving and activity in [&"cafeteria",&"tavern"]:
var lift := p if not paused and int(_animation_time*3)%2==0 else 0.0
draw_rect(Rect2(at+Vector2(11*p,8*p-lift),Vector2(3*p,2*p)),Color("e6d7b5"))
var waiting := not staff and state==GuestRuntime.State.QUEUING and not riding
if (receiving and not resting) or working or waiting:
var badge := Rect2(at+Vector2(10,-4)*p,Vector2(6,5)*p)
draw_rect(badge,Color("f2e9cb"))
var mark := Color("c39150") if waiting else Color("61958a")
if waiting:
draw_rect(Rect2(badge.position+Vector2(2,1)*p,Vector2(1,2)*p),mark)
draw_rect(Rect2(badge.position+Vector2(2,3)*p,Vector2(2,1)*p),mark)
else:
var offset := 1.0 if not paused and int(_animation_time*3)%2==0 else 0.0
draw_rect(Rect2(badge.position+Vector2(1+offset,2)*p,Vector2(3,1)*p),mark)
var variant := int(absi(key.hash())) % 15
var frame := int(_animation_time * 12.0) + variant
var mode: StringName = &"idle"
if staff and state == StaffRuntime.State.INCAPACITATED:
mode = &"collapsed"
elif receiving and activity in [&"standard_room", &"premium_room"]:
mode = &"sleep"
elif moving:
mode = &"carry_walk" if carrying else &"walk"
elif carrying:
mode = &"carry"
elif staff and state == StaffRuntime.State.WORKING and not riding:
mode = activity if activity in [&"clean", &"idle", &"work"] else &"work"
elif receiving and activity in [&"cafeteria", &"tavern"]:
mode = &"eat"
elif _queue_positions.has(key) or (not staff and state == GuestRuntime.State.QUEUING):
mode = &"wait"
var at := (center + Vector2(-8 * p, _cell_dimensions().y * 0.5 - 3 * world_pixel - 17 * p)).round()
draw_rect(Rect2(at + Vector2(5, 17) * p, Vector2(8, 1) * p), Color("303c40", 0.2))
People.draw(self, at, p, mode, frame, variant, staff, float(track.get(&"direction", 1.0)) < 0.0)
func _draw_excavation_jobs() -> void:
@@ -990,9 +1354,10 @@ func _draw_map_caption(_map_rect: Rect2) -> void:
var caption := "世界樹の宿 / お客様のフロア" if layer_mode==LayerMode.FRONT else "世界樹の宿 / 従業員と物流"
draw_rect(Rect2(14,12,244,26),Color("29483d",0.93))
draw_string(font,Vector2(24,30),caption,HORIZONTAL_ALIGNMENT_LEFT,-1,13,Color("f0e2bd"))
var status := "表示 %d%% · G マス目%s" % [int(zoom_level*100), " ON" if show_grid else ""]
draw_rect(Rect2(size.x-214,12,200,26),Color("29483d",0.93))
draw_string(font,Vector2(size.x-204,30),status,HORIZONTAL_ALIGNMENT_LEFT,-1,12,Color("f0e2bd"))
var status := "表示 %d%% · %s · G マス目%s" % [int(zoom_level*100), "裏層:半透明" if layer_mode == LayerMode.FRONT else "表層:輪郭", " ON" if show_grid else ""]
var status_width := font.get_string_size(status,HORIZONTAL_ALIGNMENT_LEFT,-1,12).x + 20.0
draw_rect(Rect2(size.x-status_width-14,12,status_width,26),Color("29483d",0.93))
draw_string(font,Vector2(size.x-status_width-4,30),status,HORIZONTAL_ALIGNMENT_LEFT,-1,12,Color("f0e2bd"))
func _draw_center_message(message: String) -> void:
@@ -1085,7 +1450,7 @@ func _on_mouse_exited() -> void:
_set_hovered_cell(null)
func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void:
func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void:
queue_redraw()
_update_build_preview()
_update_excavation_preview()
@@ -1094,29 +1459,38 @@ func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void:
func _connect_runtime() -> void:
if _runtime == null or not _runtime is Object or not _runtime.has_signal(&"snapshot_published"):
return
var notification: StringName = &"state_updated" if _runtime.has_signal(&"state_updated") else &"snapshot_published"
var callback := Callable(self, "_on_snapshot_published")
if not _runtime.is_connected(&"snapshot_published", callback):
_runtime.connect(&"snapshot_published", callback)
if not _runtime.is_connected(notification, callback):
_runtime.connect(notification, callback)
func _disconnect_runtime() -> void:
if _runtime == null or not _runtime is Object or not _runtime.has_signal(&"snapshot_published"):
return
var notification: StringName = &"state_updated" if _runtime.has_signal(&"state_updated") else &"snapshot_published"
var callback := Callable(self, "_on_snapshot_published")
if _runtime.is_connected(&"snapshot_published", callback):
_runtime.disconnect(&"snapshot_published", callback)
if _runtime.is_connected(notification, callback):
_runtime.disconnect(notification, callback)
func _service(property_name: StringName) -> Variant:
if _runtime == null or not _runtime is Object:
return null
if _drawing and _draw_cache.has(property_name):
return _draw_cache[property_name]
for property: Dictionary in _runtime.get_property_list():
if StringName(property.get(&"name", &"")) == property_name:
return _runtime.get(property_name)
var value = _runtime.get(property_name)
if _drawing:
_draw_cache[property_name] = value
return value
return null
func _world_bounds() -> Dictionary:
if _drawing and _draw_cache.has(&"bounds"):
return _draw_cache[&"bounds"]
var world: Variant = _service(&"world_service")
if world == null or not world.has_method(&"get_bounds"):
return {}
@@ -1126,13 +1500,17 @@ func _world_bounds() -> Dictionary:
for key: StringName in [&"min_x", &"max_x", &"min_y", &"max_y"]:
if not result.has(key) and not result.has(String(key)):
return {}
return {
var result_value = {
&"min_x": int(result.get(&"min_x", result.get("min_x", 0))),
&"max_x": int(result.get(&"max_x", result.get("max_x", -1))),
&"min_y": int(result.get(&"min_y", result.get("min_y", 0))),
&"max_y": int(result.get(&"max_y", result.get("max_y", -1))),
}
if _drawing:
_draw_cache[&"bounds"] = result_value
return result_value
func _cells_from_serialized(value: Variant) -> Array[Vector3i]:
var result: Array[Vector3i] = []
@@ -1155,3 +1533,37 @@ func _serialize_cells(cells: Array[Vector3i]) -> Array[Array]:
func _normalized_rotation(value: int) -> int:
return posmod(value, 360) / 90 * 90
func _rescue_treatment_position(patient_id: StringName, fallback: Vector2) -> Vector2:
var facility_id: StringName = _treated_patients[patient_id]
var world: Variant = _service(&"world_service")
var cells: Array[Vector3i] = world.get_entity_cells(facility_id) if world != null else []
if cells.is_empty(): return fallback
var data: Dictionary = _room_presentations.get(facility_id, {})
var slot := (data.get(&"people", []) as Array).find(patient_id)
var p := _cell_dimensions().x/16.0
var anchors := RoomPresentation.slots(_cell_bounds_rect(cells), p, &"infirmary")
if slot < 0 or slot >= anchors.size(): return Vector2(INF,INF)
return anchors[slot]+Vector2(0,-2*p-_cell_dimensions().y*0.5)
func _resting_staff_position(staff_id: StringName, fallback: Vector2) -> Vector2:
var facility_id: StringName = _resting_staff[staff_id]
var world: Variant = _service(&"world_service")
var cells: Array[Vector3i] = world.get_entity_cells(facility_id) if world != null else []
if cells.is_empty(): return fallback
var data: Dictionary = _room_presentations.get(facility_id, {})
var slot := (data.get(&"people", []) as Array).find(staff_id)
var anchors := RoomPresentation.slots(_cell_bounds_rect(cells), _cell_dimensions().x/16.0, &"staff_waiting_room")
if slot < 0 or slot >= anchors.size(): return Vector2(INF,INF)
return anchors[slot]+Vector2(0,-_cell_dimensions().y*0.5)
func _can_drop_data(_position: Vector2, data: Variant) -> bool:
return data is Dictionary and data.get(&"kind", &"") == &"staff_assignment" and data.get(&"staff_id", &"") is StringName
func _drop_data(position: Vector2, data: Variant) -> void:
if _can_drop_data(position, data):
staff_assignment_dropped.emit(data, facility_at_canvas(position))