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
+34 -9
View File
@@ -1,14 +1,16 @@
class_name BusinessManagementPanel
extends VBoxContainer
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
## Policy drafts are local; accepted commands and read models belong to Runtime.
signal status_requested(message: String, success: bool)
signal action_requested(action: StringName)
const KEY_PREFIX := "ui.business."
const COMMAND: StringName = &"set_facility_policy"
const GUEST_POLICY_EXPLANATION := "料金変更は新規受付から。受付済みの料金は変わりません。\n休業すると待機中のお客様は別の施設を探します。開始済みのサービスは完了まで続きます。休業中も給与は発生します。"
const SUPPLY_POLICY_EXPLANATION := "休業中は調理と新しい補給を止めます。在庫と運搬中の荷物は保持し、積込済みの荷物は納品先の営業状況に従って配送します。消費期限は経過し、給与も発生します。"
const GUEST_POLICY_EXPLANATION := "料金変更は新規受付から。受付済みの料金は変わりません。\n休業すると待機中のお客様は別の施設を探します。開始済みのサービスは完了まで続きます。休業中も給与と施設維持費は発生します。"
const SUPPLY_POLICY_EXPLANATION := "休業中は調理と新しい補給を止めます。在庫と運搬中の荷物は保持し、積込済みの荷物は納品先の営業状況に従って配送します。消費期限は経過し、給与と施設維持費も発生します。"
const STATE_NAMES := {&"BROKEN": "故障中", &"NO_ACCESS": "経路なし", &"NO_POWER": "魔力不足", &"NO_STAFF": "職員不足", &"CLOSED": "休業中", &"DEGRADED": "状態悪化", &"OPEN": "稼働中"}
const REASONS := {
&"FACILITY_NOT_FOUND": "施設が見つかりません。選び直してください。",
@@ -53,7 +55,9 @@ func _ready() -> void:
_build_interface()
_built = true
if _runtime != null:
if _runtime.has_signal(&"snapshot_published"):
if _runtime.has_signal(&"state_updated"):
_runtime.connect(&"state_updated", _on_snapshot_published)
elif _runtime.has_signal(&"snapshot_published"):
_runtime.connect(&"snapshot_published", _on_snapshot_published)
if _runtime.has_signal(&"command_completed"):
_runtime.connect(&"command_completed", _on_command_completed)
@@ -85,6 +89,7 @@ func _build_interface() -> void:
for color_name: StringName in [&"font_color", &"font_pressed_color", &"font_hover_color", &"font_hover_pressed_color", &"font_focus_color"]:
business_open_button.add_theme_color_override(color_name, Color("55371e"))
business_open_button.add_theme_color_override("font_disabled_color", Color("766650"))
LayerSkin.register_toggle_text(business_open_button, LayerSkin.active_layer(self))
business_open_button.add_theme_font_size_override("font_size", 13)
business_open_button.toggled.connect(func(_enabled: bool) -> void: _store_draft())
add_child(business_open_button)
@@ -129,9 +134,13 @@ func refresh() -> void:
business_open_button.set_pressed_no_signal(bool(draft.get(&"business_open", true)))
var pending := _has_pending(selected_facility_id)
var can_price := bool(row.get(&"can_price", false))
var is_cleaning := StringName(row.get(&"definition_id", &"")) == &"cleaning_room"
var is_supply := StringName(row.get(&"definition_id", &"")) in [&"kitchen", &"warehouse"]
business_open_button.text = "営業する" if is_supply else "新規の受付を開く"
explanation_label.text = SUPPLY_POLICY_EXPLANATION if is_supply else GUEST_POLICY_EXPLANATION
if is_cleaning:
business_open_button.text = "清掃業務を有効にする"
explanation_label.text = "配置した清掃員が汚れた施設へ向かいます。清掃業務を無効にすると、途中の清掃も中断します。維持費と給与は継続します。"
facility_option.disabled = _rows(&"facilities").is_empty()
price_option.disabled = row.is_empty() or not can_price or pending or price_option.item_count == 0
business_open_button.disabled = row.is_empty() or pending
@@ -141,21 +150,36 @@ func refresh() -> void:
facility_status_label.text = "施設が完成すると営業を設定できます。"
quote_label.text = "施設を選んでください。"
effect_label.text = ""
if int(row.get(&"faith_per_service", 0)) > 0:
effect_label.text += "\n礼拝完了ごとに信仰+%d" % int(row[&"faith_per_service"])
today_label.text = "本日の実績はまだありません。"
else:
facility_status_label.text = "%s · %s\n待ち %d人 / 利用中 %d人" % [("営業する設定" if is_supply else "受付を開く設定") if bool(row.get(&"business_open", true)) else ("休業する設定" if is_supply else "新規受付を停止中"), String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中")), int(row.get(&"queued_count", 0)), int(row.get(&"active_count", 0))]
facility_status_label.text = "%s · %s\n待ち %d人 / 利用中 %d人\n同時利用上限 %s" % [("営業する設定" if is_supply else "受付を開く設定") if bool(row.get(&"business_open", true)) else ("休業する設定" if is_supply else "新規受付を停止中"), String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中")), int(row.get(&"queued_customer_count", 0)), int(row.get(&"active_customer_count", 0)), ("%d人" % int(row[&"customer_capacity"])) if int(row.get(&"customer_capacity", -1)) >= 0 else "未設定"]
if is_supply:
facility_status_label.text = "%s · %s\n保管・補給の状況は「商品」で確認。" % ["営業する設定" if bool(row.get(&"business_open", true)) else "休業する設定", String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中"))]
if is_cleaning:
facility_status_label.text = "%s · %s\n顧客利用なし・清掃員の業務拠点" % ["清掃業務を有効にする設定" if bool(row.get(&"business_open", true)) else "清掃業務を停止中", String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中"))]
if int(row.get(&"cleanliness", -1)) >= 0:
facility_status_label.text += "\n清潔度 %d / 100" % int(row[&"cleanliness"])
if not String(row.get(&"cleaning_status", "")).is_empty():
facility_status_label.text += "\n" + String(row[&"cleaning_status"])
if can_price:
var mode := StringName(draft.get(&"price_mode", &"standard"))
var quoted := FacilityPricing.quote(int(row.get(&"base_price", 0)), {&"price_mode": mode})
var unit := String({&"standard_room": "泊", &"cafeteria": "食"}.get(StringName(row.get(&"definition_id", &"")), "回"))
var quoted := FacilityPricing.apply_multiplier(FacilityPricing.quote(int(row.get(&"base_price", 0)), {&"price_mode": mode}) * int(row.get(&"donation_multiplier", 1)), int(row.get(&"floor_price_multiplier_bp", 10000)))
var unit := String({&"standard_room": "泊", &"premium_room": "泊", &"cafeteria": "食"}.get(StringName(row.get(&"definition_id", &"")), "回"))
quote_label.text = "設定後 %dG / %s\n現在 %dG · 標準 %dG" % [quoted, unit, int(row.get(&"price", 0)), int(row.get(&"base_price", 0))]
effect_label.text = _price_effect(mode)
if int(row.get(&"donation_multiplier", 1)) == 3:
effect_label.text += "\nアメリア配置:寄付金3倍"
if int(row.get(&"floor_price_multiplier_bp", 10000)) == 12000:
effect_label.text += "\nアンリ配置フロア:利用料金+20%"
else:
quote_label.text = "この施設に利用料金はありません。"
effect_label.text = "営業・休業を設定できます。" if is_supply else "受付の開閉だけを設定できます。"
today_label.text = "本日の記帳 · 06:00〜\n売上 %dG · 直接支出 %dG\n給与の配賦と利益は翌朝の帳簿で確認。" % [int(row.get(&"today_revenue", 0)), int(row.get(&"today_expenses", 0))]
if is_cleaning: effect_label.text = "清掃業務の有効・無効を設定できます。"
if int(row.get(&"faith_per_service", 0)) > 0:
effect_label.text += "\n礼拝完了ごとに信仰+%d" % int(row[&"faith_per_service"])
today_label.text = "本日の記帳 · 00:00〜\n売上 %dG · 直接支出 %dG\n給与の配賦と利益は翌日の帳簿で確認。" % [int(row.get(&"today_revenue", 0)), int(row.get(&"today_expenses", 0))]
_refreshing = false
@@ -263,7 +287,7 @@ func _on_command_completed(result: Variant) -> void:
refresh()
func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void:
func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void:
if is_visible_in_tree():
refresh()
@@ -344,7 +368,7 @@ func _facility_row(id: StringName) -> Dictionary:
func _set_result(message: String, success: bool) -> void:
result_label.text = message
result_label.add_theme_color_override("font_color", Color("397346") if success else Color("a7402e"))
LayerSkin.register_status_text(result_label, success, LayerSkin.active_layer(self))
status_requested.emit(message, success)
@@ -357,6 +381,7 @@ func _label(value: String, node_name := "") -> Label:
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.add_theme_font_size_override("font_size", 13)
label.add_theme_color_override("font_color", Color("55371e"))
LayerSkin.register_text(label, LayerSkin.active_layer(self))
return label
+418
View File
@@ -0,0 +1,418 @@
class_name DefenseManagementPanel
extends VBoxContainer
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
## Player controls for an incident that already exists. Threat creation,
## combat balancing, skill authoring and automatic combat belong to Runtime.
signal status_requested(message: String, success: bool)
const KEY_PREFIX := "ui.defense."
const COMMANDS := [&"register_defense_combatant", &"register_defense_asset", &"start_defense_raid", &"set_defense_retreat_policy", &"begin_next_defense_wave", &"retreat_defenders"]
const STATE_NAMES := ["準備中", "戦闘中", "次の襲撃待ち", "撃退", "突破された", "撤退", "準備終了"]
const SOURCE_NAMES := {"THIEF": "盗賊の襲撃", "BANKRUPTCY_RIOT": "客の暴動", "BANK_DELINQUENCY": "銀行による取り立て", "AUTHORITY_ESCALATION": "監査による摘発"}
const POLICIES := [&"HOLD", &"MANUAL", &"THRESHOLD", &"PROTECT_AND_RETREAT"]
const POLICY_LABELS := ["持ち場を守る", "自分で撤退を指示", "体力が減ったら撤退", "体力・施設の損傷で撤退"]
var selected_raid_id: StringName = &""
var selected_staff_id: StringName = &""
var selected_objective_id: StringName = &""
var raid_option: OptionButton
var staff_option: OptionButton
var objective_option: OptionButton
var policy_option: OptionButton
var threshold_spin: SpinBox
var summary_label: Label
var defenders_label: Label
var assets_label: Label
var staff_label: Label
var objective_label: Label
var result_label: Label
var register_staff_button: Button
var register_objective_button: Button
var start_button: Button
var policy_button: Button
var next_wave_button: Button
var retreat_button: Button
var confirmation: ConfirmationDialog
var _runtime: Variant
var _style_button: Callable
var _report: Dictionary = {}
var _raid_labels: Dictionary = {}
var _pending: Dictionary = {}
var _queue_instance_id := 0
var _sequence := 1
var _built := false
var _confirmation_context: Dictionary = {}
var _policy_drafts: Dictionary = {}
var _refreshing := false
func configure(runtime: Variant, style_button := Callable()) -> void:
_runtime = runtime
_style_button = style_button
func _ready() -> void:
name = "DefenseManagementPanel"
size_flags_horizontal = Control.SIZE_EXPAND_FILL
add_theme_constant_override("separation", 6)
_build_interface()
_built = true
if _runtime != null:
if _runtime.has_signal(&"state_updated"):
_runtime.connect(&"state_updated", _on_snapshot_published)
elif _runtime.has_signal(&"snapshot_published"):
_runtime.connect(&"snapshot_published", _on_snapshot_published)
if _runtime.has_signal(&"command_completed"):
_runtime.connect(&"command_completed", _on_command_completed)
refresh()
func _build_interface() -> void:
raid_option = _option("DefenseRaidOption")
raid_option.fit_to_longest_item = false
raid_option.clip_text = true
raid_option.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
raid_option.item_selected.connect(func(index: int) -> void: select_raid(StringName(raid_option.get_item_metadata(index))))
add_child(raid_option)
summary_label = _label("", "DefenseSummary")
add_child(summary_label)
defenders_label = _label("", "DefenseParticipants")
add_child(defenders_label)
assets_label = _label("", "DefenseAssets")
add_child(assets_label)
add_child(HSeparator.new())
add_child(_label("参加する従業員"))
staff_option = _option("DefenseStaffOption")
staff_option.item_selected.connect(func(index: int) -> void:
selected_staff_id = StringName(staff_option.get_item_metadata(index))
_refresh_controls())
add_child(staff_option)
staff_label = _label("", "DefenseStaffStatus")
add_child(staff_label)
register_staff_button = _button("防衛に参加", "DefenseRegisterStaff", _register_staff)
add_child(register_staff_button)
add_child(_label("防衛する施設"))
objective_option = _option("DefenseObjectiveOption")
objective_option.item_selected.connect(func(index: int) -> void:
selected_objective_id = StringName(objective_option.get_item_metadata(index))
_refresh_controls())
add_child(objective_option)
objective_label = _label("", "DefenseObjectiveStatus")
add_child(objective_label)
register_objective_button = _button("防衛対象に登録", "DefenseRegisterObjective", _register_objective)
add_child(register_objective_button)
add_child(HSeparator.new())
add_child(_label("撤退の方針"))
policy_option = _option("DefenseRetreatPolicy")
for i: int in POLICIES.size():
policy_option.add_item(POLICY_LABELS[i])
policy_option.set_item_metadata(i, POLICIES[i])
policy_option.item_selected.connect(func(_index: int) -> void: _store_policy_draft())
add_child(policy_option)
var threshold_row := HBoxContainer.new()
threshold_row.add_child(_label("撤退する残量(%)"))
threshold_spin = SpinBox.new()
threshold_spin.name = "DefenseRetreatThreshold"
threshold_spin.min_value = 0
threshold_spin.max_value = 100
threshold_spin.step = 1
threshold_spin.value_changed.connect(func(_value: float) -> void: _store_policy_draft())
threshold_row.add_child(threshold_spin)
add_child(threshold_row)
policy_button = _button("撤退方針を適用", "DefenseApplyPolicy", _apply_policy)
add_child(policy_button)
var actions := HBoxContainer.new()
start_button = _button("防衛を開始", "DefenseStart", func() -> void: _submit(&"start_defense_raid"))
next_wave_button = _button("次の襲撃に対応", "DefenseNextWave", func() -> void: _submit(&"begin_next_defense_wave"))
retreat_button = _button("撤退する", "DefenseRetreat", _request_retreat_confirmation)
for button: Button in [start_button, next_wave_button, retreat_button]:
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
actions.add_child(button)
add_child(actions)
result_label = _label("", "DefenseResult")
add_child(result_label)
confirmation = ConfirmationDialog.new()
confirmation.name = "DefenseConfirmation"
confirmation.ok_button_text = "実行する"
confirmation.cancel_button_text = "戻る"
confirmation.confirmed.connect(_confirm_operation)
confirmation.canceled.connect(func() -> void: _confirmation_context.clear())
add_child(confirmation)
confirmation.get_label().add_theme_color_override("font_color", Color("55371e"))
LayerSkin.register_text(confirmation.get_label(), LayerSkin.active_layer(self))
if _style_button.is_valid():
for button: Button in [confirmation.get_ok_button(), confirmation.get_cancel_button()]:
_style_button.call(button)
func select_raid(raid_id: StringName) -> void:
selected_raid_id = raid_id
selected_staff_id = &""
selected_objective_id = &""
_confirmation_context.clear()
if confirmation != null: confirmation.hide()
refresh()
func refresh() -> void:
if not _built:
return
var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null
if queue != null and queue.get_instance_id() != _queue_instance_id:
_queue_instance_id = queue.get_instance_id()
_policy_drafts.clear()
_confirmation_context.clear()
confirmation.hide()
for option: OptionButton in [raid_option, staff_option, objective_option, policy_option]:
option.get_popup().hide()
_synchronize_commands(queue)
_report = _runtime.call(&"get_defense_management_report", selected_raid_id) if _runtime != null and _runtime.has_method(&"get_defense_management_report") else {}
_raid_labels = _build_raid_labels(_report.get(&"raids", []))
selected_raid_id = StringName((_report.get(&"selected", {}) as Dictionary).get(&"raid_id", &""))
selected_raid_id = _fill_option(raid_option, _report.get(&"raids", []), &"raid_id", selected_raid_id)
selected_staff_id = _fill_option(staff_option, _report.get(&"staff", []), &"staff_id", selected_staff_id)
selected_objective_id = _fill_option(objective_option, _report.get(&"objectives", []), &"facility_id", selected_objective_id)
var row: Dictionary = _report.get(&"selected", {})
var draft: Dictionary = _policy_drafts.get(selected_raid_id, row)
var policy := StringName(draft.get(&"retreat_policy", &"HOLD"))
_refreshing = true
policy_option.select(maxi(POLICIES.find(policy), 0))
threshold_spin.set_value_no_signal(int(draft.get(&"defender_retreat_threshold", 0)))
_refreshing = false
summary_label.text = "進行中・保存済みの防衛戦はありません。" if row.is_empty() else "%s · %s\n襲撃 %d / %d · 攻防 %d回\n%s" % [String(_raid_labels.get(selected_raid_id, _raid_base_label(row))), STATE_NAMES[int(row[&"state"])], int(row[&"current_wave"]), int(row[&"wave_count"]), int(row[&"current_round"]), "防衛対象が未登録です。" if not (row[&"missing_objectives"] as Array).is_empty() else "防衛対象は登録済みです。"]
var names: Array[String] = []
for defender: Dictionary in _report.get(&"defenders", []):
var employee := _row(&"staff", &"staff_id", defender[&"actor_id"])
names.append("%s 体力%d/%d・戦闘%d" % [employee.get(&"display_name", defender[&"actor_id"]), defender[&"health"], defender[&"max_health"], defender[&"combat"]])
defenders_label.text = "参加者:" + ("、".join(names) if not names.is_empty() else "未登録")
names.clear()
for asset: Dictionary in _report.get(&"assets", []):
var facility := _row(&"objectives", &"facility_id", asset[&"asset_id"])
names.append("%s 耐久%d/%d" % [facility.get(&"display_name", asset[&"asset_id"]), asset[&"integrity"], asset[&"max_integrity"]])
assets_label.text = "対象:" + ("、".join(names) if not names.is_empty() else "未登録")
_refresh_controls()
func _refresh_controls() -> void:
var row: Dictionary = _report.get(&"selected", {})
var state := int(row.get(&"state", -1))
var pending := _has_pending(selected_raid_id)
var blocked := row.is_empty() or pending or not bool(_report.get(&"can_control", false))
var staff := _row(&"staff", &"staff_id", selected_staff_id)
var objective := _row(&"objectives", &"facility_id", selected_objective_id)
staff_label.text = "従業員がいません。" if staff.is_empty() else "体力%d/100・戦闘%d\n%s" % [staff[&"health"], staff[&"combat"], "参加できます。参加中は配属を変更できません。" if staff[&"can_register"] else staff[&"reason"]]
objective_label.text = "防衛対象がありません。" if objective.is_empty() else "耐久%d/10000\n%s" % [objective[&"durability"], "登録できます。" if objective[&"can_register"] else objective[&"reason"]]
register_staff_button.disabled = blocked or staff.is_empty() or not bool(staff.get(&"can_register", false))
register_objective_button.disabled = blocked or objective.is_empty() or not bool(objective.get(&"can_register", false))
start_button.visible = state == DefenseService.RaidState.PREPARING
start_button.disabled = blocked or not (row.get(&"missing_objectives", []) as Array).is_empty()
next_wave_button.visible = state == DefenseService.RaidState.BETWEEN_WAVES
next_wave_button.disabled = blocked
retreat_button.visible = state in [DefenseService.RaidState.ACTIVE, DefenseService.RaidState.BETWEEN_WAVES]
retreat_button.disabled = blocked
var terminal := state in DefenseService.TERMINAL_STATES or state < 0
policy_option.disabled = blocked or terminal
var chosen_policy := StringName(policy_option.get_item_metadata(policy_option.selected))
threshold_spin.editable = not blocked and not terminal and chosen_policy in [&"THRESHOLD", &"PROTECT_AND_RETREAT"]
policy_button.disabled = blocked or terminal
staff_option.disabled = blocked or state != DefenseService.RaidState.PREPARING or staff.is_empty()
objective_option.disabled = blocked or state != DefenseService.RaidState.PREPARING or objective.is_empty()
if pending: result_label.text = "指示を受理しました。次の時間更新で反映します。"
func _register_staff() -> void:
var staff := _row(&"staff", &"staff_id", selected_staff_id)
if staff.is_empty() or not bool(staff.get(&"can_register", false)): return
_submit(&"register_defense_combatant", {&"actor_id": selected_staff_id, &"side": &"DEFENDER", &"combat": int(staff[&"combat"])})
func _register_objective() -> void:
var facility := _row(&"objectives", &"facility_id", selected_objective_id)
if facility.is_empty() or not bool(facility.get(&"can_register", false)): return
# Plain objectives have no defense-device bonus. Omit numeric overrides and
# use the existing Runtime command defaults and current live durability.
_submit(&"register_defense_asset", {&"asset_id": selected_objective_id, &"kind": &"OBJECTIVE"})
func _apply_policy() -> void:
_submit(&"set_defense_retreat_policy", {&"policy": policy_option.get_item_metadata(policy_option.selected), &"threshold": int(threshold_spin.value)})
func _store_policy_draft() -> void:
if _refreshing or selected_raid_id.is_empty() or _has_pending(selected_raid_id): return
_policy_drafts[selected_raid_id] = {&"retreat_policy": policy_option.get_item_metadata(policy_option.selected), &"defender_retreat_threshold": int(threshold_spin.value)}
_refresh_controls()
func _request_retreat_confirmation() -> void:
refresh()
var row: Dictionary = _report.get(&"selected", {})
if _has_pending(selected_raid_id) or selected_raid_id.is_empty() or not bool(_report.get(&"can_control", false)) or int(row.get(&"state", -1)) not in [DefenseService.RaidState.ACTIVE, DefenseService.RaidState.BETWEEN_WAVES]: return
_confirmation_context = {&"raid_id": selected_raid_id, &"queue_instance_id": _queue_instance_id}
confirmation.title = "撤退の確認"
confirmation.dialog_text = "撤退すると防衛戦は終了します。参加者の状態と施設の損傷が反映されます。"
confirmation.popup_centered(Vector2i(430, 170))
func _confirm_operation() -> void:
var context := _confirmation_context.duplicate()
_confirmation_context.clear()
var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null
if queue == null or queue.get_instance_id() != int(context.get(&"queue_instance_id", 0)) or context.get(&"raid_id", &"") != selected_raid_id:
_set_result("対象が変わりました。防衛戦を選び直してください。", false)
refresh()
return
refresh()
if context.get(&"raid_id", &"") != selected_raid_id:
_set_result("対象が変わりました。防衛戦を選び直してください。", false)
return
var row: Dictionary = _report.get(&"selected", {})
if _has_pending(selected_raid_id) or not bool(_report.get(&"can_control", false)) or int(row.get(&"state", -1)) not in [DefenseService.RaidState.ACTIVE, DefenseService.RaidState.BETWEEN_WAVES]:
_set_result("現在の防衛戦では撤退を指示できません。", false)
return
_submit(&"retreat_defenders")
func _submit(command_type: StringName, patch: Dictionary = {}) -> void:
if selected_raid_id.is_empty() or _has_pending(selected_raid_id) or command_type not in COMMANDS: return
var payload := patch.duplicate(true)
payload[&"raid_id"] = selected_raid_id
var key := StringName("%s%08d" % [KEY_PREFIX, _sequence])
_sequence += 1
var result: Variant = _runtime.call(&"submit_command", command_type, payload, &"player", selected_raid_id, 75, -1, key)
if result == null or not result.has_method(&"is_success"):
_set_result("防衛の指示に応答がありません。", false)
return
var queue: Variant = _runtime.get(&"command_queue")
var queued := false
for command: Dictionary in queue.create_snapshot().get("pending", []):
queued = queued or int(command.get("command_id", 0)) == int(result.command_id)
if result.is_success() and queued:
_pending[int(result.command_id)] = payload
else:
_show_result(result, payload)
refresh()
func _show_result(result: Variant, payload: Dictionary) -> void:
if result.is_success() and payload.has(&"policy"):
_policy_drafts.erase(StringName(payload.get(&"raid_id", &"")))
_set_result("防衛の指示を反映しました。" if result.is_success() else "指示を反映できませんでした。体調・業務中の状態・対象施設を確認してください。", result.is_success())
func _synchronize_commands(queue: Variant) -> void:
_pending.clear()
for metadata: Dictionary in queue.get_accepted_command_metadata():
var key := String(metadata.get("idempotency_key", ""))
if key.begins_with(KEY_PREFIX) and key.trim_prefix(KEY_PREFIX).is_valid_int():
_sequence = maxi(_sequence, int(key.trim_prefix(KEY_PREFIX)) + 1)
for command: Dictionary in queue.create_snapshot().get("pending", []):
if StringName(command.get("command_type", &"")) in COMMANDS:
_pending[int(command.get("command_id", 0))] = command.get("payload", {}).duplicate(true)
func _on_command_completed(result: Variant) -> void:
if not _pending.has(int(result.command_id)): return
var payload: Dictionary = _pending[int(result.command_id)]
_pending.erase(int(result.command_id))
_show_result(result, payload)
refresh()
func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void:
if is_visible_in_tree(): refresh()
func _has_pending(raid_id: StringName) -> bool:
for payload: Dictionary in _pending.values():
if payload.get(&"raid_id", &"") == raid_id: return true
return false
func _row(key: StringName, id_key: StringName, entity_id: StringName) -> Dictionary:
for row: Dictionary in _report.get(key, []):
if row.get(id_key, &"") == entity_id: return row
return {}
func _fill_option(option: OptionButton, rows: Array, id_key: StringName, selected: StringName) -> StringName:
if option.get_popup().visible: return selected
option.clear()
if rows.is_empty():
option.add_item("対象なし")
option.set_item_metadata(0, &"")
return &""
var index := 0
for row: Dictionary in rows:
var id := StringName(row[id_key])
var display_name := String(_raid_labels.get(id, _raid_base_label(row))) if id_key == &"raid_id" else String(row.get(&"display_name", id))
option.add_item(display_name)
if id_key == &"raid_id": option.set_item_tooltip(option.item_count - 1, display_name)
option.set_item_metadata(option.item_count - 1, id)
if id == selected: index = option.item_count - 1
option.select(index)
return StringName(option.get_item_metadata(index))
## Cause labels are display-only. Never infer cause from an internal ID or
## source_id, and never use the label as an option's metadata or command target.
func _build_raid_labels(rows: Array) -> Dictionary:
var totals: Dictionary = {}
for row: Dictionary in rows:
var base := _raid_base_label(row)
totals[base] = int(totals.get(base, 0)) + 1
var seen: Dictionary = {}
var labels: Dictionary = {}
for row: Dictionary in rows:
var base := _raid_base_label(row)
seen[base] = int(seen.get(base, 0)) + 1
labels[StringName(row[&"raid_id"])] = base + ("(%d)" % int(seen[base]) if int(totals[base]) > 1 else "")
return labels
func _raid_base_label(row: Dictionary) -> String:
var cause := String(SOURCE_NAMES.get(String(row.get(&"source_kind", "")), "防衛戦"))
var raw_minute: Variant = row.get(&"created_at", null)
if typeof(raw_minute) != TYPE_INT or int(raw_minute) < 0:
return cause + " · 時刻不明"
var minute := int(raw_minute)
return "%s · %d日目 %02d:%02d" % [cause, minute / 1440 + 1, minute % 1440 / 60, minute % 60]
func _set_result(message: String, success: bool) -> void:
result_label.text = message
LayerSkin.register_status_text(result_label, success, LayerSkin.active_layer(self))
status_requested.emit(message, success)
func _label(text_value: String, node_name := "") -> Label:
var label := Label.new()
if not node_name.is_empty(): label.name = node_name
label.text = text_value
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.add_theme_font_size_override("font_size", 13)
label.add_theme_color_override("font_color", Color("55371e"))
LayerSkin.register_text(label, LayerSkin.active_layer(self))
return label
func _option(node_name: String) -> OptionButton:
var option := OptionButton.new()
option.name = node_name
option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
if _style_button.is_valid(): _style_button.call(option)
return option
func _button(text_value: String, node_name: String, callback: Callable) -> Button:
var button := Button.new()
button.name = node_name
button.text = text_value
button.custom_minimum_size.y = 30
button.pressed.connect(callback)
if _style_button.is_valid(): _style_button.call(button)
return button
@@ -0,0 +1 @@
uid://s1oh611b0xrj
+149
View File
@@ -0,0 +1,149 @@
class_name GuestObservationPanel
extends VBoxContainer
const NEED_NAMES := {&"hunger": "食欲", &"sleep": "睡眠欲", &"leisure": "娯楽欲", &"intimacy": "性欲", &"hygiene": "衛生", &"medical": "救護"}
const STATES := ["到着", "入館", "行先を検討", "移動中", "待機中", "利用中", "退館中", "退館", "再検討", "待機終了", "中断", "避難中", "気絶"]
const REJECTION_NAMES := {
"MISSING_INPUTS": "食材・資材不足", "FACILITY_CLOSED": "閉店・稼働停止", "UNREACHABLE": "経路なし",
"CANNOT_PAY": "利用可能な資金が不足", "ETA_AFTER_EXIT": "出発予定に間に合わない",
"WAIT_HARD_LIMIT": "待ち時間が長すぎる", "QUALITY_TOO_LOW": "希望する品質に届かない",
"RISK_REFUSED": "危険度が高い", "ILLEGALITY_REFUSED": "違法性を敬遠",
"MISSING_PERMISSION": "利用資格なし", "FORBIDDEN_GUEST_TAG": "利用対象外",
"GUEST_TYPE_NOT_ALLOWED": "利用対象外", "ADULT_NOT_VERIFIED": "年齢条件を満たさない",
"CONTENT_FILTERED": "利用設定の対象外", "INVALID_NEED_MAP": "施設設定を確認できない",
}
var guests: OptionButton
var details: Label
var decision_label: Label
var priority_label: Label
var bars: Dictionary = {}
var selected_guest_id: StringName = &""
var _runtime: Variant
var _ids: Array[StringName] = []
var _elapsed := 0.0
func configure(runtime: Variant) -> void:
_runtime = runtime
func _ready() -> void:
name = "GuestObservationPanel"
custom_minimum_size = Vector2(420, 340)
add_theme_constant_override("separation", 8)
guests = OptionButton.new()
guests.item_selected.connect(func(index: int) -> void:
selected_guest_id = StringName(guests.get_item_metadata(index))
refresh())
add_child(guests)
details = _label()
for need: StringName in GuestService.PRIMARY_NEEDS:
var row := HBoxContainer.new()
var title := Label.new()
title.text = NEED_NAMES[need]
title.custom_minimum_size.x = 85
row.add_child(title)
var bar := ProgressBar.new()
bar.max_value = 100
bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(bar)
bars[need] = bar
add_child(row)
priority_label = _label()
decision_label = _label()
_label().text = "欲求が高いほど、そのサービスを優先します。\n利用できる施設の中で欲求を優先し、同じ優先度なら距離・行列・料金などで比較します。\n表示は現在の欲求です。移動・利用の途中で目的地を勝手に変更しません。"
refresh()
func _label() -> Label:
var label := Label.new()
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
add_child(label)
return label
func _process(delta: float) -> void:
if not is_visible_in_tree() or not get_window().visible:
return
_elapsed += delta
if _elapsed >= 0.5:
_elapsed = 0
refresh()
func refresh() -> void:
if guests == null or _runtime == null:
return
var service: Variant = _runtime.get(&"guest_service")
if service == null:
return
var next: Array[StringName] = []
for guest: GuestRuntime in service.get_guests(): next.append(guest.guest_id)
if next != _ids:
_ids = next
guests.clear()
for id: StringName in _ids:
guests.add_item(String(id))
guests.set_item_metadata(guests.item_count-1, id)
if not _ids.has(selected_guest_id):
selected_guest_id = _ids[0] if not _ids.is_empty() else &""
guests.disabled = _ids.is_empty()
if not _ids.is_empty(): guests.select(_ids.find(selected_guest_id))
var report: Dictionary = service.get_observation(selected_guest_id)
for need: StringName in bars:
bars[need].value = report.get(&"needs", {}).get(need, 0)
if report.is_empty():
details.text = "現在、来客はいません。"
priority_label.text = ""
decision_label.text = ""
return
var target := String(report[&"target_facility_id"])
var facility: Variant = _runtime.facility_service.get_instance(StringName(target))
if facility != null:
var definition: Variant = _runtime.definition_registry.get_facility(facility.definition_id)
if definition != null: target = definition.display_name
if target.is_empty(): target = "未選択"
details.text = "%s · 所持金 %dG · 忍耐 %d\n目的地: %s" % [STATES[int(report[&"state"])], report[&"wallet_gold"], report[&"patience"], target]
var names: PackedStringArray = []
for need: StringName in report[&"primary_needs"]: names.append(NEED_NAMES[need])
priority_label.text = "現在最も高い欲求: " + ("なし" if names.is_empty() else "・".join(names))
decision_label.text = _decision_text(_runtime.get_guest_decision_record(selected_guest_id))
func _decision_text(record: Dictionary) -> String:
if record.is_empty():
return "直近の行先判断:記録なし(判断前、旧セーブ、または履歴の保存範囲外)"
var minute := int(record.get(&"absolute_minute", 0))
var title := "直近の行先判断 · %d日目 %02d:%02d" % [minute / 1440 + 1, (minute % 1440) / 60, minute % 60]
if StringName(record.get(&"action", &"")) == &"LEAVE":
return title + "\n条件を満たして選べる施設がなく、退館を選びました。" + _rejection_text(record)
var id := StringName(record.get(&"facility_id", &""))
var destination := String(id)
var facility: Variant = _runtime.facility_service.get_instance(id)
if facility != null:
var definition: Variant = _runtime.definition_registry.get_facility(facility.definition_id)
if definition != null: destination = definition.display_name
var needs: PackedStringArray = []
for need: StringName in record.get(&"primary_needs", []):
needs.append("%s %d" % [NEED_NAMES.get(need, String(need)), int(record.get(&"needs", {}).get(need, 0))])
return title + "\n選択:%s / 判断時の最大欲求:%s\n移動 %d分・待ち %d分・見込料金 %dG\n選択候補の欲求優先度 %d・比較評価 %d(判断時点)" % [destination, "・".join(needs) if not needs.is_empty() else "なし", int(record.get(&"travel_minutes", 0)), int(record.get(&"wait_minutes", 0)), int(record.get(&"estimated_total_gold", 0)), int(record.get(&"need_priority", 0)), int(record.get(&"utility", 0))] + _rejection_text(record)
func _rejection_text(record: Dictionary) -> String:
if not record.has(&"rejection_counts"):
return "\n候補の除外理由:この記録にはありません。"
var counts: Dictionary = record[&"rejection_counts"]
if counts.is_empty():
return "\n今回検討した候補には、利用条件による除外はありません。"
var reasons: PackedStringArray = []
var keys := counts.keys()
keys.sort()
for reason: String in keys:
reasons.append("%s %d施設" % [REJECTION_NAMES.get(reason, "条件を満たさない"), int(counts[reason])])
var text := "\n判断時の除外理由:" + "・".join(reasons)
for row: Dictionary in record.get(&"rejection_examples", []):
var id := StringName(row.get(&"facility_id", &""))
var facility: Variant = _runtime.facility_service.get_instance(id)
var label := String(id)
if facility != null:
var definition: Variant = _runtime.definition_registry.get_facility(facility.definition_id)
if definition != null: label = "%s(%s)" % [definition.display_name, id]
text += "\n・%s:%s" % [label, REJECTION_NAMES.get(String(row.get(&"reason", "")), "条件を満たさない")]
var omitted: int = int(record.get(&"rejection_count", 0)) - record.get(&"rejection_examples", []).size()
if omitted > 0: text += "\nほか%d施設(件数は上記に含みます)" % omitted
return text
@@ -0,0 +1 @@
uid://diaqemp3k1wj
+18
View File
@@ -0,0 +1,18 @@
extends Node2D
# Reuse the same room art at low opacity; this layer has no input handlers.
const PixelArt := preload("res://scripts/ui/pixel_tower_art.gd")
var rooms: Array[Dictionary] = []
var pixel_scale := 1.0
var dungeon: Texture2D
var town: Texture2D
func _ready() -> void:
modulate = Color(1,1,1,0.22)
texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
func _draw() -> void:
for room: Dictionary in rooms:
if bool(room.get(&"building", false)): continue
if bool(room.get(&"legacy_stair", false)):
PixelArt.stairs(self,room[&"rect"],room[&"rect"].size.x/14.0,true,true)
continue
PixelArt.room(self,room[&"rect"],room[&"definition_id"],pixel_scale,dungeon,town,true,room[&"origin_x"],{})
+1
View File
@@ -0,0 +1 @@
uid://cn241cup6ht38
+61 -17
View File
@@ -1,6 +1,8 @@
class_name InnOpeningGuide
extends PanelContainer
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
## Read-only guidance. Every action goes back through the normal dashboard.
signal action_requested(action: StringName)
@@ -15,7 +17,7 @@ var _sale_economy_id := 0
var _first_sale_minute := -1
func setup(runtime: Variant, clock: Variant) -> void:
func setup(runtime: Variant, clock: Variant, layer := 0) -> void:
_runtime = runtime
_clock = clock
name = "InnOpeningGuide"
@@ -25,6 +27,7 @@ func setup(runtime: Variant, clock: Variant) -> void:
skin.border_color = Color("b58a45")
skin.set_border_width_all(1)
skin.set_content_margin_all(8)
LayerSkin.register_style(skin, &"panel_leather", layer)
add_theme_stylebox_override("panel", skin)
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 5)
@@ -32,7 +35,8 @@ func setup(runtime: Variant, clock: Variant) -> void:
_heading = Button.new()
_heading.alignment = HORIZONTAL_ALIGNMENT_LEFT
_heading.add_theme_font_size_override("font_size", 14)
_heading.pressed.connect(func() -> void: _body.visible = not _body.visible)
_heading.pressed.connect(_toggle_body)
_style_button(_heading, layer)
column.add_child(_heading)
_body = VBoxContainer.new()
column.add_child(_body)
@@ -41,14 +45,33 @@ func setup(runtime: Variant, clock: Variant) -> void:
_detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_detail.add_theme_font_size_override("font_size", 13)
_detail.add_theme_color_override("font_color", Color("fff0cf"))
LayerSkin.register_text(_detail, layer)
_body.add_child(_detail)
_action = Button.new()
_action.add_theme_font_size_override("font_size", 13)
_style_button(_action, layer)
_action.pressed.connect(func() -> void: action_requested.emit(StringName(_progress.get(&"action", &""))))
_body.add_child(_action)
refresh()
func _toggle_body() -> void:
_body.visible = not _body.visible
reset_size.call_deferred()
func _style_button(button: Button, layer: int) -> void:
button.add_theme_color_override("font_color", Color("fff0cf"))
for state_name: StringName in [&"normal", &"hover", &"pressed", &"focus", &"disabled"]:
var style := StyleBoxFlat.new()
style.bg_color = Color("30261b") if state_name == &"normal" else Color("51402a")
style.border_color = Color("b58a45")
style.set_border_width_all(1)
style.set_content_margin_all(4)
LayerSkin.register_style(style, StringName("button_"+String(state_name if state_name != &"focus" else &"pressed")), layer)
button.add_theme_stylebox_override(state_name, style)
func refresh() -> void:
if _runtime == null or _heading == null:
return
@@ -70,14 +93,14 @@ func _read_progress() -> Dictionary:
if first_sale >= 0 and _runtime.operations_coordinator != null:
for summary: Dictionary in _runtime.operations_coordinator.get_day_summaries():
if int(summary.get(&"closed_at", -1)) > first_sale:
return _step(&"complete", "初日の営業を達成", "宿泊売上と翌朝の決算を確認できました。帳簿を見ながら、客室と従業員を少しずつ増やしましょう。", &"ledger", "決算を見る")
return _step(&"complete", "初日の営業を達成", "宿泊売上と日次決算を確認できました。帳簿を見ながら、客室と従業員を少しずつ増やしましょう。", &"ledger", "決算を見る")
var reception: FacilityInstance
var rooms: Array[FacilityInstance] = []
for facility: FacilityInstance in _runtime.facility_service.get_instances():
if facility.definition_id == &"reception":
if reception == null or _reception_score(facility) > _reception_score(reception):
reception = facility
elif facility.definition_id == &"standard_room":
elif _is_guest_lodging(facility.definition_id):
rooms.append(facility)
if reception == null:
return _build_step(&"reception", &"reception", "1/6 受付をつくる", "表の地表階に受付を配置します。完成した受付の上に客室を増築できます。", &"build_reception")
@@ -95,7 +118,7 @@ func _read_progress() -> Dictionary:
return _step(&"staff", "4/6 受付係を配属", "従業員から受付係を雇い、雇用済み一覧でこの受付と勤務帯を選び「配属・勤務帯を変更」を押します。", &"staff", "従業員を開く")
if first_sale < 0:
return _step(&"sale", "5/6 最初の宿泊売上", "時間を進めて、お客さまの到着と宿泊を見守りましょう。売上は現在のサービスが完了すると記帳されます。", &"resume" if _clock.paused else &"ledger", "時間を進める" if _clock.paused else "帳簿を見る")
return _step(&"close", "6/6 翌朝の決算へ", "最初の宿泊売上が入りました。06:00に営業日が締まり、給与・利益・未払いを帳簿で確認できます。", &"resume" if _clock.paused else &"ledger", "時間を進める" if _clock.paused else "帳簿を見る")
return _step(&"close", "6/6 深夜の決算へ", "最初の宿泊売上が入りました。00:00に営業日が締まり、給与・利益・未払いを帳簿で確認できます。", &"resume" if _clock.paused else &"ledger", "時間を進める" if _clock.paused else "帳簿を見る")
func _can_reach_facility(facility: FacilityInstance) -> bool:
@@ -103,6 +126,8 @@ func _can_reach_facility(facility: FacilityInstance) -> bool:
return false
# Runtime uses the same public entrance and access cells as real guests.
# Component-only guide fixtures retain their explicit availability flags.
if _runtime.has_method(&"is_facility_reachable"):
return bool(_runtime.call(&"is_facility_reachable", facility.instance_id))
if _runtime.has_method(&"get_facility_access_report"):
var report: Dictionary = _runtime.call(&"get_facility_access_report", facility.instance_id)
return bool(report.get(&"reachable", false))
@@ -114,18 +139,37 @@ func _reception_score(facility: FacilityInstance) -> int:
func _has_reception_assignment(reception: FacilityInstance) -> bool:
var assigned := not reception.active_staff_ids.is_empty()
# A repeating plan remains valid off-duty. Historical or cancelled shifts
# cannot conceal a deliberate unassignment made before the first sale.
var definition: FacilityDefinition = _runtime.definition_registry.get_facility(reception.definition_id)
if definition == null:
return false
var unplanned_staff: Dictionary = {}
# Repeating plans remain valid off-duty; explicit plans take precedence
# over historical shifts. Management placement is not receptionist work.
for staff: StaffRuntime in _runtime.staff_service.get_staff_members():
if not definition.required_roles.is_empty() and staff.role_id not in definition.required_roles:
continue
if staff.staff_id in reception.active_staff_ids:
return true
var plan: Dictionary = _runtime.staff_service.get_work_assignment(staff.staff_id)
if not plan.is_empty():
assigned = assigned or StringName(plan.get(&"facility_id", &"")) == reception.instance_id
continue
for shift: ShiftInstance in _runtime.staff_service.get_shifts():
if shift.staff_id == staff.staff_id and not shift.cancelled and shift.end_at > _clock.absolute_minute and shift.assigned_facility_id == reception.instance_id:
assigned = true
return assigned
if StringName(plan.get(&"facility_id", &"")) == reception.instance_id:
return true
else:
unplanned_staff[staff.staff_id] = true
if unplanned_staff.is_empty():
return false
# Enumerate/sort the historical shifts once, not once per employee.
for shift: ShiftInstance in _runtime.staff_service.get_shifts():
if unplanned_staff.has(shift.staff_id) and not shift.cancelled and shift.end_at > _clock.absolute_minute and shift.assigned_facility_id == reception.instance_id:
return true
return false
func _is_guest_lodging(definition_id: StringName) -> bool:
var definition: FacilityDefinition = _runtime.definition_registry.get_facility(definition_id)
return definition != null and definition.layer == FacilityDefinition.ViewLayer.FRONT and (
definition.service_types.has(&"lodging") or definition.tags.has(&"lodging")
)
func _first_lodging_sale() -> int:
@@ -139,10 +183,10 @@ func _first_lodging_sale() -> int:
return _first_sale_minute
var room_ids: Dictionary = {}
for job: ConstructionJob in _runtime.construction_service.get_jobs():
if job.definition_id == &"standard_room" and not job.facility_instance_id.is_empty():
if _is_guest_lodging(job.definition_id) and not job.facility_instance_id.is_empty():
room_ids[job.facility_instance_id] = true
for facility: FacilityInstance in _runtime.facility_service.get_instances():
if facility.definition_id == &"standard_room":
if _is_guest_lodging(facility.definition_id):
room_ids[facility.instance_id] = true
if room_ids.is_empty():
return -1
@@ -154,7 +198,7 @@ func _first_lodging_sale() -> int:
func _build_step(stage: StringName, definition_id: StringName, title: String, detail: String, action: StringName) -> Dictionary:
for job: ConstructionJob in _runtime.construction_service.get_jobs():
if job.definition_id == definition_id and job.state not in [ConstructionJob.State.COMPLETED, ConstructionJob.State.CANCELLED]:
if (job.definition_id == definition_id or (stage == &"room" and _is_guest_lodging(job.definition_id))) and job.state not in [ConstructionJob.State.COMPLETED, ConstructionJob.State.CANCELLED]:
return _step(stage, title, "工事中 %d%%。時間を進めると完成します。" % (job.progress / 100), &"resume" if _clock.paused else &"", "時間を進める")
var definition: FacilityDefinition = _runtime.definition_registry.get_facility(definition_id)
if definition != null:
+59
View File
@@ -0,0 +1,59 @@
class_name LayerInterfaceSkin
extends RefCounted
## Visual-only palette for the persistent HUD. No simulation state is owned here.
const STYLE_META := &"layer_interface_front_style"
const TEXT_META := &"layer_interface_front_text"
const BACK_TEXT_META := &"layer_interface_back_text"
const BACK_PANELS := {
&"panel_outer": Color("10191e"), &"panel_wood": Color("1a282e"),
&"panel_leather": Color("172229"), &"panel_parchment": Color("202e34"),
&"button_normal": Color("293c43"), &"button_hover": Color("385860"),
&"button_pressed": Color("275954"), &"button_disabled": Color("293135"),
}
static func register_style(style: StyleBoxFlat, role: StringName, layer: int) -> void:
style.set_meta(STYLE_META, {&"role":role, &"background":style.bg_color, &"border":style.border_color})
apply_style(style, layer)
static func apply_style(style: StyleBoxFlat, layer: int) -> void:
if not style.has_meta(STYLE_META): return
var front: Dictionary = style.get_meta(STYLE_META)
var background: Color = BACK_PANELS.get(front[&"role"], Color("202e34")) if layer == 1 else front[&"background"]
var border: Color = Color("73989a") if layer == 1 else front[&"border"]
if style.bg_color != background: style.bg_color = background
if style.border_color != border: style.border_color = border
static func register_text(label: Label, layer: int) -> void:
if not label.has_meta(TEXT_META): label.set_meta(TEXT_META, label.get_theme_color("font_color"))
var color: Color = label.get_meta(BACK_TEXT_META, Color("d8eeea")) if layer == 1 else label.get_meta(TEXT_META)
if label.get_theme_color("font_color") != color: label.add_theme_color_override("font_color", color)
static func register_status_text(label: Label, success: bool, layer: int) -> void:
label.set_meta(TEXT_META, Color("397346") if success else Color("a7402e"))
label.set_meta(BACK_TEXT_META, Color("b5e7ba") if success else Color("ffb498"))
register_text(label, layer)
static func active_layer(node: Node) -> int:
if not node.is_inside_tree(): return 0
var state := node.get_node_or_null("/root/GameState")
return int(state.get("active_layer")) if state != null else 0
static func register_toggle_text(button: BaseButton, layer: int) -> void:
const META := &"layer_interface_toggle_text"
if not button.has_meta(META):
var colors := {}
for key: StringName in [&"font_color", &"font_pressed_color", &"font_hover_color", &"font_hover_pressed_color", &"font_focus_color", &"font_disabled_color"]:
colors[key] = button.get_theme_color(key)
button.set_meta(META, colors)
var front: Dictionary = button.get_meta(META)
for key: StringName in front:
var color: Color = front[key] if layer == 0 else (Color("8da4a4") if key == &"font_disabled_color" else Color("d8eeea"))
if button.get_theme_color(key) != color: button.add_theme_color_override(key, color)
static func apply_tree(node: Node, layer: int) -> void:
if node is Window and node.theme != null:
for entry: Array in [[&"panel", &"AcceptDialog"], [&"embedded_border", &"Window"]]:
if node.theme.has_stylebox(entry[0], entry[1]):
var window_style: StyleBox = node.theme.get_stylebox(entry[0], entry[1])
if window_style is StyleBoxFlat: apply_style(window_style, layer)
if node is Control:
for key: StringName in [&"panel", &"normal", &"hover", &"pressed", &"focus", &"disabled"]:
if node.has_theme_stylebox_override(key):
var style: StyleBox = node.get_theme_stylebox(key)
if style is StyleBoxFlat: apply_style(style, layer)
if node is Label and node.has_meta(TEXT_META): register_text(node, layer)
if node is BaseButton and node.has_meta(&"layer_interface_toggle_text"): register_toggle_text(node, layer)
for child: Node in node.get_children(): apply_tree(child, layer)
+1
View File
@@ -0,0 +1 @@
uid://bl3cvg8q4ykt7
+39 -6
View File
@@ -1,7 +1,10 @@
class_name OperatingLedgerPanel
extends VBoxContainer
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
## Projection only: authoritative close and ledger entries own all amounts.
var _initial_layer := 0
var _runtime: Variant
var _clock: Variant
var _cash_label: Label
@@ -12,18 +15,21 @@ var _selected_day := -1
var _picker_days: Array[int] = []
func setup(runtime: Variant, clock: Variant) -> void:
func setup(runtime: Variant, clock: Variant, style_button := Callable(), layer := 0) -> void:
_initial_layer = layer
_runtime = runtime
_clock = clock
name = "OperatingLedgerPanel"
add_theme_constant_override("separation", 8)
var heading := Label.new()
heading.text = "帳簿 · 06:00締め"
heading.text = "帳簿 · 00:00締め"
heading.add_theme_color_override("font_color", Color("55371e"))
LayerSkin.register_text(heading, _initial_layer)
add_child(heading)
_cash_label = _label()
_day_picker = OptionButton.new()
_day_picker.name = "LedgerDayPicker"
if style_button.is_valid(): style_button.call(_day_picker)
_day_picker.item_selected.connect(func(index: int) -> void:
_selected_day = _day_picker.get_item_id(index)
refresh())
@@ -33,16 +39,26 @@ func setup(runtime: Variant, clock: Variant) -> void:
refresh()
func select_latest_close() -> void:
_selected_day = -1
_day_picker.get_popup().hide()
refresh()
func get_report() -> Dictionary:
if _runtime == null:
return {}
var minute: int = _clock.absolute_minute
var business_day := maxi(1, int(floor(float(minute - 360) / 1440.0)) + 1)
var start := (business_day - 1) * 1440 + 360
var business_day := minute / 1440 + 1
var start := (business_day - 1) * 1440
if _runtime.operations_coordinator != null:
business_day = _runtime.operations_coordinator.get_open_financial_day()
start = _runtime.operations_coordinator.get_financial_period_start(business_day)
var inflow := 0
var outflow := 0
var construction_outflow := 0
var wage_outflow := 0
var upkeep_outflow := 0
for entry: LedgerEntry in _runtime.economy_service.get_entries():
if entry.absolute_minute < start or entry.absolute_minute > minute or entry.category == &"INITIALIZATION":
continue
@@ -52,6 +68,8 @@ func get_report() -> Dictionary:
outflow += entry.amount
if entry.category == &"CONSTRUCTION":
construction_outflow += entry.amount
elif entry.category == &"MAINTENANCE_PAYMENT":
upkeep_outflow += entry.amount
elif entry.category == &"WAGE":
wage_outflow += entry.amount
var summaries: Array[Dictionary] = []
@@ -68,6 +86,8 @@ func get_report() -> Dictionary:
&"cash": _runtime.economy_service.get_balance(&"PLAYER_CASH"),
&"cash_in": inflow, &"cash_out": outflow, &"cash_net": inflow - outflow,
&"construction_cash_out": construction_outflow, &"wage_cash_out": wage_outflow,
&"upkeep_cash_out": upkeep_outflow,
&"upkeep_arrears": _runtime.upkeep_settlement.get_arrears_total(),
&"closed_days": summaries, &"selected_close": selected.duplicate(true),
}
@@ -78,13 +98,13 @@ func refresh() -> void:
if _day_picker.get_popup().visible:
return
var report := get_report()
_cash_label.text = "第%d営業日 · 手持ち %dG\n本日の入金 +%dG / 出金 −%dG\nうち建築 %dG / 給与支払 %dG\n資金増減 %+dG(営業利益は下の決算)" % [report[&"business_day"], report[&"cash"], report[&"cash_in"], report[&"cash_out"], report[&"construction_cash_out"], report[&"wage_cash_out"], report[&"cash_net"]]
_cash_label.text = "第%d営業日 · 手持ち %dG\n本日の入金 +%dG / 出金 −%dG\nうち建築 %dG / 給与支払 %dG / 維持費支払 %dG\n未払い維持費 %dG\n資金増減 %+dG(営業利益は下の決算)" % [report[&"business_day"], report[&"cash"], report[&"cash_in"], report[&"cash_out"], report[&"construction_cash_out"], report[&"wage_cash_out"], report[&"upkeep_cash_out"], report[&"upkeep_arrears"], report[&"cash_net"]]
var summary: Dictionary = report[&"selected_close"]
if summary.is_empty():
if not _day_picker.disabled or _day_picker.item_count == 0:
_day_picker.clear()
_picker_days.clear()
_day_picker.add_item("決算は翌朝06:00に確定します")
_day_picker.add_item("決算は翌日00:00に確定します")
_day_picker.disabled = true
_closed_label.text = "建築後の手持ち資金だけで営業成績は決まりません。宿泊などの売上と日々の費用をここで確認できます。"
_facility_label.text = ""
@@ -105,6 +125,10 @@ func refresh() -> void:
for key: StringName in [&"maintenance", &"utilities", &"taxes_and_interest", &"fines"]:
other += int(totals.get(key, 0))
_closed_label.text = "売上 %dG / 返金 %dG\n運営費(仕入れ・契約金等) %dG\n給与 %dG / その他 %dG\n営業利益 %+dG\n前日比 %+dG / 未払給与 %dG\n帳簿照合:%s" % [totals.get(&"revenue", 0), totals.get(&"refunds", 0), totals.get(&"cost_of_goods", 0), totals.get(&"wages", 0), other, summary.get(&"net_profit", 0), summary.get(&"previous_day_delta", 0), summary.get(&"arrears_total", 0), "一致" if bool(summary.get(&"balanced", false)) else "差額あり"]
var legacy: bool = _runtime.operations_coordinator.is_legacy_financial_history(int(summary[&"day"]))
_closed_label.text += "\n%s %d回%s" % ["旧版の連続未払い" if legacy else "連続赤字", int(summary.get(&"consecutive_insolvency_count", 0)), "" if legacy else "(3回でゲーム終了)"]
var close_minute := int(summary[&"closed_at"]) % 1440
_closed_label.text += "\n確定時刻 %02d:%02d" % [close_minute / 60, close_minute % 60]
var lines: PackedStringArray = ["施設別 · 売上 / 費用 / 利益"]
var rows: Dictionary = summary.get(&"facility_profit_loss", {})
var ids := rows.keys()
@@ -115,6 +139,14 @@ func refresh() -> void:
if facility != null:
var definition: FacilityDefinition = _runtime.definition_registry.get_facility(facility.definition_id)
label = "%s (%d,%d)" % [definition.display_name if definition != null else facility.definition_id, facility.origin.x, facility.origin.y]
else:
# Corridors and stairs are completed construction entities without
# service instances, but their ownership upkeep still appears here.
var completed: Dictionary = _runtime.construction_service.get_facility(StringName(id))
if not completed.is_empty():
var definition: FacilityDefinition = _runtime.definition_registry.get_facility(completed[&"definition_id"])
var origin: Vector3i = completed[&"origin"]
label = "%s (%d,%d)" % [definition.display_name if definition != null else completed[&"definition_id"], origin.x, origin.y]
var row: Dictionary = rows[id]
lines.append("%s\n%dG / %dG / %+dG" % [label, row.get(&"revenue", 0), row.get(&"expenses", 0), row.get(&"net_profit", 0)])
_facility_label.text = "\n".join(lines) if not rows.is_empty() else "施設別の記帳はまだありません。"
@@ -124,6 +156,7 @@ func _label() -> Label:
var label := Label.new()
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.add_theme_color_override("font_color", Color("55371e"))
LayerSkin.register_text(label, _initial_layer)
label.add_theme_font_size_override("font_size", 13)
add_child(label)
return label
+75
View File
@@ -0,0 +1,75 @@
class_name PixelPeople
extends RefCounted
## Authored side-view poses on a 16x18 grid. No neighbouring atlas characters
## are used as animation frames. All motion is presentation-only.
const COATS := [Color("688da0"), Color("b87659"), Color("a69a63"), Color("89789e"), Color("638d72")]
const SKINS := [Color("edc49d"), Color("c99371"), Color("a56f50")]
const INK := Color("303c40")
static func pose(mode: StringName, frame: int, variant: int, staff: bool) -> Array[Dictionary]:
var marks: Array[Dictionary] = []
var skin: Color = SKINS[posmod(variant, SKINS.size())]
var coat: Color = Color("719d94") if staff else COATS[posmod(variant, COATS.size())]
var phase := posmod(frame, 8)
var walk := mode == &"walk" or mode == &"carry_walk"
var work := mode in [&"work", &"clean", &"eat"]
var stride := [0, 1, 2, 1, 0, -1, -2, -1][phase] as int
var bob := -1 if walk and phase in [2, 6] else 0
if mode in [&"sleep", &"collapsed"]:
mark(marks, Rect2(2, 14, 12, 2), INK)
mark(marks, Rect2(3, 12, 4, 3), skin)
mark(marks, Rect2(2, 12, 2, 3), INK)
mark(marks, Rect2(7, 12, 6, 3), coat)
mark(marks, Rect2(13, 13, 2, 2), INK)
if mode == &"sleep":
mark(marks, Rect2(3 + phase / 3, 8 - phase / 3, 2, 1), Color("eee6c7"))
return marks
# Far arm and leg, then silhouette, face, near arm and leg.
mark(marks, Rect2(6, 8 + bob, 2, 5), coat.darkened(0.25))
var far := -stride if walk else 0
var near := stride if walk else 0
mark(marks, Rect2(7 + far, 12 + bob, 2, 4 - bob), INK.lightened(0.12))
mark(marks, Rect2(7 + far, 16, 3, 1), INK)
mark(marks, Rect2(6, 6 + bob, 5, 7), INK)
mark(marks, Rect2(7, 7 + bob, 4, 5), coat)
mark(marks, Rect2(7, 1 + bob, 4, 5), skin)
mark(marks, Rect2(6, 1 + bob, 4, 2), INK)
mark(marks, Rect2(6, 3 + bob, 1, 2), INK)
mark(marks, Rect2(11, 3 + bob, 1, 2), skin)
mark(marks, Rect2(10, 3 + bob, 1, 1), INK)
mark(marks, Rect2(8, 6 + bob, 2, 1), skin)
mark(marks, Rect2(8 + near, 12 + bob, 2, 4 - bob), INK)
mark(marks, Rect2(8 + near, 16, 3, 1), INK)
var arm := -stride if walk else 0
if work:
arm = 2 if phase < 4 else 1
mark(marks, Rect2(9 + arm, 8 + bob, 2, 3), coat.lightened(0.12))
mark(marks, Rect2(9 + arm, 11 + bob, 2, 1), skin)
if staff:
mark(marks, Rect2(7, 9 + bob, 2, 3), Color("e3dfc2"))
if mode in [&"carry", &"carry_walk"]:
mark(marks, Rect2(10, 8 + bob, 5, 5), Color("956940"))
mark(marks, Rect2(10, 8 + bob, 5, 1), Color("d3ad6b"))
mark(marks, Rect2(12, 9 + bob, 1, 4), Color("b48850"))
elif mode == &"clean":
mark(marks, Rect2(11 + arm, 9, 1, 7), Color("af9059"))
mark(marks, Rect2(9 + arm, 16, 5, 1), Color("d8bc78"))
elif mode == &"eat":
mark(marks, Rect2(11, 6 if phase < 4 else 10, 3, 2), Color("f3ebcf"))
elif mode == &"work":
mark(marks, Rect2(11, 12, 4, 1), Color("efe7ce"))
mark(marks, Rect2(12 + phase % 2, 10, 1, 2), Color("805e44"))
elif mode == &"wait":
mark(marks, Rect2(9, 9, 3, 1), skin)
return marks
static func mark(marks: Array[Dictionary], rect: Rect2, color: Color) -> void:
marks.append({&"rect": rect, &"color": color})
static func draw(canvas: CanvasItem, origin: Vector2, pixel: float, mode: StringName, frame: int, variant: int, staff: bool, facing_left: bool) -> void:
for item: Dictionary in pose(mode, frame, variant, staff):
var rect: Rect2 = item[&"rect"]
if facing_left:
rect.position.x = 16 - rect.end.x
canvas.draw_rect(Rect2(origin + rect.position * pixel, rect.size * pixel), item[&"color"])
+1
View File
@@ -0,0 +1 @@
uid://bvhvqan76tawa
+74 -11
View File
@@ -3,6 +3,7 @@ extends RefCounted
## Small, code-native room furnishings. Coordinates use the same pixel size as
## Kenney's 16 px sprites. This renderer never changes simulation state.
const Layout := preload("res://scripts/ui/room_presentation.gd")
const INK := Color("34413b")
const WOOD := Color("8e6348")
const WOOD_LIGHT := Color("c39665")
@@ -15,7 +16,7 @@ static func tile(canvas: CanvasItem, texture: Texture2D, index: int, target: Rec
canvas.draw_texture_rect_region(texture, target, Rect2((index % 12) * 16, (index / 12) * 16, 16, 16), tint)
static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float, dungeon: Texture2D, town: Texture2D, back: bool, decoration_seed := 0) -> void:
static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float, dungeon: Texture2D, town: Texture2D, back: bool, decoration_seed := 0, presentation: Dictionary = {}) -> void:
var p := pixel
if id in [&"guest_stairs", &"service_stairs"]:
stairs(canvas, rect, p, id == &"service_stairs")
@@ -69,17 +70,14 @@ static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float,
counter(canvas, Vector2(mid-9*p, floor_y-6*p), 18*p, p)
canvas.draw_rect(Rect2(mid-6*p, floor_y-8*p, 4*p, 2*p), CREAM)
canvas.draw_rect(Rect2(mid+4*p, floor_y-8*p, 2*p, 2*p), Color("d6ad52"))
&"standard_room", &"adult_private_room":
bed(canvas, Vector2(rect.position.x+2*p, floor_y-9*p), p, Color("709b94") if not dark else Color("a37b91"),13)
if w >= 28*p:
bed(canvas, Vector2(rect.end.x-15*p, floor_y-9*p), p, Color("709b94") if not dark else Color("a37b91"),13)
&"standard_room", &"premium_room", &"staff_dormitory", &"adult_private_room", &"infirmary":
for at: Vector2 in Layout.slots(rect, p, id):
bed(canvas, at + Vector2(-7, -9)*p, p, Color("709b94") if not dark else Color("a37b91"), 13)
&"cafeteria", &"tavern":
for x in range(int(rect.position.x+5*p), int(rect.end.x-13*p), int(20*p)):
table(canvas, Vector2(x, floor_y-7*p), p)
if rect.size.y > 28*p:
counter(canvas, Vector2(rect.position.x+7*p, top+13*p), w-14*p, p)
for x in range(int(rect.position.x+10*p), int(rect.end.x-8*p), int(12*p)):
canvas.draw_rect(Rect2(x, top+11*p, 3*p, 2*p), CREAM)
for at: Vector2 in Layout.slots(rect, p, id):
table(canvas, at + Vector2(-2, -7)*p, p)
&"chapel":
chapel_furnishings(canvas, rect, p)
&"public_bath":
canvas.draw_rect(Rect2(rect.position.x+4*p, floor_y-7*p, w-8*p, 7*p), CREAM)
canvas.draw_rect(Rect2(rect.position.x+6*p, floor_y-6*p, w-12*p, 4*p), Color("72b6b8"))
@@ -92,6 +90,15 @@ static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float,
&"warehouse":
for x in range(int(rect.position.x+2*p), int(rect.end.x-12*p), int(13*p)):
tile(canvas, dungeon, 63, Rect2(x, floor_y-12*p, 12*p, 12*p))
&"cleaning_room":
# Original code-drawn supply rack, mop and bucket; existing palette.
counter(canvas, Vector2(rect.position.x+3*p, floor_y-10*p), 10*p, p)
for shelf in 2:
canvas.draw_rect(Rect2(rect.position.x+4*p,floor_y-(12+3*shelf)*p,8*p,2*p),CREAM)
canvas.draw_rect(Rect2(mid+3*p,floor_y-16*p,p,14*p),WOOD_LIGHT)
canvas.draw_rect(Rect2(mid+p,floor_y-3*p,6*p,2*p),CREAM)
canvas.draw_rect(Rect2(rect.end.x-9*p,floor_y-6*p,5*p,5*p),TEAL)
canvas.draw_rect(Rect2(rect.end.x-8*p,floor_y-7*p,3*p,p),INK)
&"staff_waiting_room":
table(canvas, Vector2(mid-6*p, floor_y-7*p), p)
tile(canvas, dungeon, 89, Rect2(rect.position.x+2*p, floor_y-12*p, 12*p, 12*p))
@@ -103,9 +110,65 @@ static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float,
_:
table(canvas, Vector2(mid-6*p, floor_y-7*p), p)
if not presentation.is_empty():
if rect.size.y >= 40*p and id != &"chapel":
var workers: Array = presentation.get(&"staff", [])
var columns := maxi(int(w / (16*p)), 1)
if not workers.is_empty():
canvas.draw_rect(Rect2(rect.position.x+p,top+24*p,w-2*p,p), WOOD_LIGHT)
for slot: int in mini(workers.size(), columns):
counter(canvas, Vector2(rect.position.x+(slot+0.5)*w/columns-6*p, top+20*p), 12*p, p)
var operation := int(presentation[&"operation"])
if operation != FacilityInstance.OperationState.OPERATING:
canvas.draw_rect(rect.grow(-p), Color(0.12, 0.16, 0.18, 0.42))
# A closed shutter differs in shape as well as colour from an open room.
for y: int in range(int(top+10*p), int(top+17*p), maxi(int(3*p), 1)):
canvas.draw_rect(Rect2(rect.position.x+2*p, y, w-4*p, p), WOOD)
if operation == FacilityInstance.OperationState.SUSPENDED:
canvas.draw_line(rect.position+Vector2(3,10)*p, rect.end-Vector2(3,5)*p, Color("ca9c62"), 2*p)
canvas.draw_line(Vector2(rect.end.x-3*p,top+10*p), Vector2(rect.position.x+3*p,rect.end.y-5*p), Color("ca9c62"), 2*p)
elif int(presentation[&"count"]) > 0:
canvas.draw_rect(Rect2(lamp_x-2*p, top+5*p, 4*p, 2*p), Color("ffe9a8"))
## The upper landing owns a short flight down to the floor below. Six square
## treads and a stepped handrail keep the shape legible at the 16 px room scale.
static func chapel_furnishings(canvas: CanvasItem, rect: Rect2, p: float) -> void:
# Two rows share the live visitor anchors. Keep the central aisle clear;
# the altar and world-tree glass occupy the otherwise unused upper wall.
var mid := rect.get_center().x
var top := rect.position.y
canvas.draw_rect(Rect2(mid-6*p,top+3*p,12*p,19*p), INK)
canvas.draw_rect(Rect2(mid-5*p,top+4*p,10*p,17*p), Color("6c8993"))
canvas.draw_rect(Rect2(mid-4*p,top+6*p,8*p,13*p), Color("adc5b4"))
canvas.draw_rect(Rect2(mid-p,top+9*p,2*p,10*p), WOOD)
for row in 3:
var width := float(row+1)*2*p
canvas.draw_rect(Rect2(mid-width/2,top+(6+row*3)*p,width,3*p), Color("52836c"))
canvas.draw_rect(Rect2(mid-p,top+5*p,2*p,p), Color("edc97c"))
var altar_y := top+28*p
counter(canvas,Vector2(mid-9*p,altar_y),18*p,p)
canvas.draw_rect(Rect2(mid-9*p,altar_y,18*p,2*p), CREAM)
canvas.draw_rect(Rect2(mid-2*p,altar_y+2*p,4*p,4*p), TEAL)
for direction in [-1,1]:
canvas.draw_rect(Rect2(mid+direction*6*p,altar_y-3*p,p,3*p), CREAM)
canvas.draw_rect(Rect2(mid+direction*6*p,altar_y-4*p,p,p), Color("edc97c"))
var rows: Dictionary = {}
for at: Vector2 in Layout.slots(rect,p,&"chapel"):
rows[at.y] = true
for y: float in rows:
for side in 2:
var x := rect.position.x+3*p if side==0 else mid+3*p
var width := rect.size.x/2-6*p
canvas.draw_rect(Rect2(x,y-8*p,width,5*p), WOOD)
canvas.draw_rect(Rect2(x,y-8*p,width,p), WOOD_LIGHT)
canvas.draw_rect(Rect2(x-p,y-3*p,width+2*p,2*p), WOOD_LIGHT)
canvas.draw_rect(Rect2(x,y-p,p,p), INK)
canvas.draw_rect(Rect2(x+width-p,y-p,p,p), INK)
static func stairs(canvas: CanvasItem, rect: Rect2, p: float, staff_only: bool, compact := false) -> void:
if not compact:
canvas.draw_rect(rect, INK)
+52
View File
@@ -0,0 +1,52 @@
extends RefCounted
## Read-only presentation. Counts refer to people, never storage capacity.
static func describe(instance: Variant) -> Dictionary:
if instance == null:
return {}
var ids: Array[StringName] = []
for service: Variant in instance.active_services.values():
for id: StringName in service.guest_ids:
if not ids.has(id):
ids.append(id)
ids.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
return {&"operation": int(instance.operation_state), &"people": ids,
&"count": ids.size(), &"limit": int(instance.customer_capacity),
&"staff": instance.active_staff_ids.duplicate(), &"suspension": StringName(instance.suspension_reason)}
static func slots(rect: Rect2, p: float, id: StringName) -> Array[Vector2]:
# Each anchor owns a 16x18 person footprint. Overflow is reported as a count,
# not drawn on top of another person. Furniture uses these same anchors.
var result: Array[Vector2] = []
var step := 20.0 if id in [&"cafeteria", &"tavern"] else 16.0
var columns := maxi(int(rect.size.x / p / step), 1)
var rows := maxi(int((rect.size.y / p - 10.0) / 20.0), 1)
if id == &"chapel":
columns = mini(columns, 4)
rows = maxi(int((rect.size.y / p - 30.0) / 20.0), 1)
for row: int in rows:
for col: int in columns:
if id == &"chapel" and result.size() == 8:
return result
result.append(Vector2(rect.position.x + (float(col) + 0.5) * rect.size.x / columns,
rect.end.y - (4.0 + row * 20.0) * p))
return result
static func label(data: Dictionary, visible_count: int) -> String:
if data.is_empty():
return ""
var operation := int(data[&"operation"])
if operation == FacilityInstance.OperationState.SUSPENDED:
return "休止・摘発" if data[&"suspension"] == &"RAIDED" else "休止・破壊"
if operation == FacilityInstance.OperationState.INACTIVE:
return "未稼働"
var count := int(data[&"count"])
var limit := int(data[&"limit"])
var value := "利用%d/%d人" % [count, limit] if limit >= 0 else "利用%d人" % count
if limit > 0 and count >= limit:
value = "満員 " + value
if count > visible_count:
value += "(他%d人)" % (count - visible_count)
return value
+1
View File
@@ -0,0 +1 @@
uid://dllkdogv348p
+94 -12
View File
@@ -1,6 +1,8 @@
class_name StaffManagementPanel
extends VBoxContainer
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
## Roster and assignment editing only. Runtime owns Shift, staffing, movement,
## and persistence; this panel never edits a Staff or Shift instance directly.
signal status_requested(message: String, success: bool)
@@ -11,7 +13,7 @@ const ROLE_NAMES := {
&"attendant": "接客", &"bartender": "酒場", &"clerk": "会計",
&"cleaner": "清掃", &"cook": "調理", &"guard": "警備",
&"maintenance": "保守", &"masseur": "施術", &"medic": "医療",
&"porter": "物流", &"receptionist": "受付",
&"porter": "物流", &"receptionist": "受付", &"innkeeper": "女将", &"cleric": "聖職者",
}
const STATE_NAMES := ["勤務外", "出勤中", "勤務中", "移動中", "作業中", "休憩中", "食事中", "睡眠中", "治療へ移動", "避難中", "行動不能", "ストライキ", "拘束中", "捕虜", "勤務不可"]
@@ -51,7 +53,9 @@ func _ready() -> void:
_build_interface()
_built = true
if _runtime != null:
if _runtime.has_signal(&"snapshot_published"):
if _runtime.has_signal(&"state_updated"):
_runtime.connect(&"state_updated", _on_snapshot_published)
elif _runtime.has_signal(&"snapshot_published"):
_runtime.connect(&"snapshot_published", _on_snapshot_published)
if _runtime.has_signal(&"command_completed"):
_runtime.connect(&"command_completed", _on_command_completed)
@@ -139,11 +143,12 @@ func refresh() -> void:
button.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
button.toggle_mode = true
button.pressed.connect(select_staff.bind(staff_id))
button.set_drag_forwarding(_staff_drag_data.bind(staff_id), Callable(), Callable())
_style(button)
roster_list.add_child(button)
roster_buttons[staff_id] = button
button.text = "%s %s%s" % [row.get(&"display_name", staff_id), _role_name(row), "・未配属" if StringName(row.get(&"assigned_facility_id", &"")).is_empty() else ""]
button.tooltip_text = "%s / %s" % [_facility_name(StringName(row.get(&"assigned_facility_id", &""))), _state_name(row)]
button.tooltip_text = "施設へドラッグして配属(選択中の勤務帯を適用)\n%s / %s" % [_facility_name(StringName(row.get(&"assigned_facility_id", &""))), _state_name(row)]
for raw_id: Variant in roster_buttons.keys():
if not live_ids.has(StringName(raw_id)):
var stale := roster_buttons[raw_id] as Button
@@ -182,11 +187,16 @@ func _refresh_editor() -> void:
if not _draft_dirty:
_draft_facility_id = current_facility
_draft_template_id = &"NIGHT" if template == &"NIGHT" else &"DAY"
staff_detail_label.text = "%s(%s) 日給 G%d\n配属:%s / %s\n%s 疲労 %d / 未払 G%d" % [
staff_detail_label.text = "%s(%s) 日給 G%d\n配属:%s / %s\n%s 体力 %d / 疲労 %d / 未払 G%d" % [
row.get(&"display_name", selected_staff_id), _role_name(row), int(row.get(&"daily_wage", 0)),
_facility_name(current_facility), "夜勤" if template == &"NIGHT" else "日勤" if template == &"DAY" else "自由勤務",
_state_name(row), int(row.get(&"fatigue", 0)), int(row.get(&"unpaid_wages", 0)),
_state_name(row), int(row.get(&"health", 100)), int(row.get(&"fatigue", 0)), int(row.get(&"unpaid_wages", 0)),
]
staff_detail_label.text += _unique_contract_text(row)
if int(row.get(&"state", 0)) == StaffRuntime.State.INCAPACITATED:
staff_detail_label.text += "\n" + String(row.get(&"rescue_status", "救護待ち"))
if not String(row.get(&"rest_status", "")).is_empty():
staff_detail_label.text += "\n" + String(row[&"rest_status"])
var effective_from := int(row.get(&"effective_from", -1))
var now := int(get_node("/root/GameClock").get("absolute_minute"))
if effective_from > now:
@@ -216,12 +226,35 @@ func _refresh_editor() -> void:
shift_option.disabled = pending
var can_edit := bool(row.get(&"can_edit", true))
apply_button.disabled = pending or not can_edit or facility_option.is_item_disabled(selected_index)
apply_button.tooltip_text = "現在の作業や拘束が終わってから変更できます。" if not can_edit else "配属先と毎日の勤務帯をまとめて変更します。"
apply_button.tooltip_text = _assignment_block_text(row) if not can_edit else "配属先と毎日の勤務帯をまとめて変更します。"
if not can_edit:
staff_detail_label.text += "\n現在の作業や拘束が終わってから変更できます。"
staff_detail_label.text += "\n" + _assignment_block_text(row)
apply_button.text = "変更を受理済み…" if pending else "配属・勤務帯を変更"
## Runtime supplies canonical, owned presentation data after every normal
## load. The panel neither infers a unique identity from wages nor edits a save.
func _unique_contract_text(row: Dictionary) -> String:
var contract: Dictionary = row.get(&"unique_contract", {})
if contract.is_empty():
return ""
var text := ""
if bool(contract.get(&"free_employment", false)) and bool(contract.get(&"free_upkeep", false)) and bool(contract.get(&"permanent_removal_forbidden", false)):
text = "\nユニーク:雇用・維持費無料 / 解雇・売却不可"
for effect: Dictionary in contract.get(&"passive_effects", []):
if effect.get(&"effect_id") != &"assigned_floor_revenue_multiplier_bp" or effect.get(&"scope") != &"ASSIGNED_FLOOR_ALL_LAYERS":
continue
var bonus_percent := int((int(effect[&"multiplier_bp"]) - 10000) / 100)
text += "\n[%s] 配置階の全施設売上+%d%%" % [effect[&"display_name"], bonus_percent]
if bool(effect.get(&"applied", false)) and effect.has(&"floor_y"):
var floor_y := int(effect[&"floor_y"])
var floor_name := "%dF" % (floor_y + 1) if floor_y >= 0 else "B%d" % -floor_y
text += "\n対象:%s(表層・裏層の全施設)" % floor_name
else:
text += "\n未適用(配属先なし・施設不在)"
return text
func _on_facility_changed(index: int) -> void:
_draft_facility_id = StringName(facility_option.get_item_metadata(index))
_draft_dirty = true
@@ -288,6 +321,18 @@ func _show_command_result(result: Variant, staff_id: StringName = &"") -> void:
func _failure_message(result: Variant) -> String:
var details: Dictionary = result.details if result.details is Dictionary else {}
var reason := StringName(details.get(&"reason", details.get("reason", &"")))
var translated := _assignment_reason_text(reason)
if not translated.is_empty():
return translated
return "変更できませんでした:%s" % String(result.message) if not String(result.message).is_empty() else "変更できませんでした。配属先と勤務帯を確認してください。"
func _assignment_block_text(row: Dictionary) -> String:
var translated := _assignment_reason_text(StringName(row.get(&"assignment_block_reason", &"")))
return translated if not translated.is_empty() else "現在の作業や拘束が終わってから変更できます。"
func _assignment_reason_text(reason: StringName) -> String:
var reasons := {
&"STAFF_NOT_FOUND": "従業員が見つかりません。",
&"FACILITY_NOT_FOUND": "配属先が見つかりません。施設が完成しているか確認してください。",
@@ -306,9 +351,7 @@ func _failure_message(result: Variant) -> String:
&"SHIFT_CONFLICT": "既存の勤務と時間が重なります。勤務予定を確認してください。",
&"BANKRUPT": "運営終了後は配属を変更できません。",
}
if reasons.has(reason):
return reasons[reason]
return "変更できませんでした:%s" % String(result.message) if not String(result.message).is_empty() else "変更できませんでした。配属先と勤務帯を確認してください。"
return reasons.get(reason, "")
func _synchronize_commands(queue: Variant) -> void:
@@ -324,7 +367,7 @@ func _synchronize_commands(queue: Variant) -> void:
_pending_commands[int(command.get("command_id", 0))] = StringName(payload.get(&"staff_id", payload.get("staff_id", &"")))
func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void:
func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void:
if is_visible_in_tree():
refresh()
@@ -340,7 +383,7 @@ func _on_command_completed(result: Variant) -> void:
func _set_result(message: String, success: bool) -> void:
result_label.text = message
result_label.add_theme_color_override("font_color", Color("397346") if success else Color("a7402e"))
LayerSkin.register_status_text(result_label, success, _active_layer())
status_requested.emit(message, success)
@@ -400,10 +443,49 @@ func _label(text_value: String) -> Label:
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.add_theme_font_size_override("font_size", 12)
label.add_theme_color_override("font_color", Color("394937"))
LayerSkin.register_text(label, _active_layer())
return label
func _active_layer() -> int:
var state := get_node_or_null("/root/GameState")
return int(state.get("active_layer")) if state != null else 0
func _style(button: Button) -> void:
button.add_theme_font_size_override("font_size", 12)
if _style_button.is_valid():
_style_button.call(button)
func _staff_drag_data(_position: Vector2, staff_id: StringName) -> Variant:
if selected_staff_id != staff_id:
select_staff(staff_id)
if _pending_commands.values().has(staff_id) or not bool(_selected_row().get(&"can_edit", false)):
_set_result("変更を処理中です。" if _pending_commands.values().has(staff_id) else _assignment_block_text(_selected_row()), false)
return null
var preview := Label.new()
preview.text = "%s:%s → 施設へ配置" % [_selected_row().get(&"display_name", staff_id), "夜勤" if _draft_template_id == &"NIGHT" else "日勤"]
(roster_buttons[staff_id] as Control).set_drag_preview(preview)
return {&"kind": &"staff_assignment", &"staff_id": staff_id, &"template_id": _draft_template_id, &"queue_instance_id": _queue_instance_id}
func assign_from_drop(data: Dictionary, facility_id: StringName) -> void:
# A successful load replaces the queue, even when staff/facility IDs match.
# Never carry an unfinished pointer gesture into that restored world.
refresh()
if data.get(&"queue_instance_id", 0) != _queue_instance_id:
_set_result("読み込み前のドラッグは取り消しました。従業員を選び直して配置してください。", false)
return
if facility_id.is_empty():
_set_result("完成した配属先施設へドロップしてください。表裏の層も確認してください。", false)
return
var staff_id := StringName(data.get(&"staff_id", &""))
if not roster_buttons.has(staff_id):
_set_result("従業員が見つかりません。", false)
return
select_staff(staff_id)
_draft_facility_id = facility_id
_draft_template_id = StringName(data.get(&"template_id", &"DAY"))
_draft_dirty = true
submit_assignment()
+11 -3
View File
@@ -1,6 +1,8 @@
class_name SupplyManagementPanel
extends VBoxContainer
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
## Read models and commands only. Prices, stock, recipes and delivery progress
## belong to Runtime; changing a control never mutates inventory directly.
signal status_requested(message: String, success: bool)
@@ -11,6 +13,8 @@ const PURCHASE: StringName = &"purchase_supplies"
const PRODUCTION: StringName = &"set_kitchen_production"
const ITEM_NAMES := {&"ingredient/raw_food": "食材", &"ingredient/basic_meal": "完成料理"}
const STATE_NAMES := {
&"SUSPENDED": "破壊・摘発による休止",
&"RELATED_FACILITY_INACTIVE": "関連施設が未稼働",
&"OPEN": "補給待ち", &"RESERVED": "運搬員待ち", &"ASSIGNED": "荷元へ移動",
&"PICKING_UP": "積込中", &"DELIVERING": "配送中", &"COMPLETED": "納品済み",
&"BLOCKED": "配送停止", &"CANCELLED": "取消済み", &"WORKING": "調理中",
@@ -99,7 +103,9 @@ func _ready() -> void:
_build_interface()
_built = true
if _runtime != null:
if _runtime.has_signal(&"snapshot_published"):
if _runtime.has_signal(&"state_updated"):
_runtime.connect(&"state_updated", _on_snapshot_published)
elif _runtime.has_signal(&"snapshot_published"):
_runtime.connect(&"snapshot_published", _on_snapshot_published)
if _runtime.has_signal(&"command_completed"):
_runtime.connect(&"command_completed", _on_command_completed)
@@ -159,6 +165,7 @@ func _build_interface() -> void:
for color_name: StringName in [&"font_color", &"font_pressed_color", &"font_hover_color", &"font_hover_pressed_color", &"font_focus_color"]:
production_enabled.add_theme_color_override(color_name, Color("55371e"))
production_enabled.add_theme_color_override("font_disabled_color", Color("766650"))
LayerSkin.register_toggle_text(production_enabled, LayerSkin.active_layer(self))
production_enabled.toggled.connect(_on_production_enabled_changed)
kitchen_page.add_child(production_enabled)
kitchen_page.add_child(_label("目標在庫 · 完成料理"))
@@ -395,7 +402,7 @@ func _on_command_completed(result: Variant) -> void:
refresh()
func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void:
func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void:
if is_visible_in_tree():
refresh()
@@ -519,7 +526,7 @@ func _reasons_text(value: Variant) -> String:
func _set_result(message: String, success: bool) -> void:
result_label.text = message
result_label.add_theme_color_override("font_color", Color("397346") if success else Color("a7402e"))
LayerSkin.register_status_text(result_label, success, LayerSkin.active_layer(self))
status_requested.emit(message, success)
@@ -532,6 +539,7 @@ func _label(value: String, node_name := "") -> Label:
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.add_theme_font_size_override("font_size", 13)
label.add_theme_color_override("font_color", Color("55371e"))
LayerSkin.register_text(label, LayerSkin.active_layer(self))
return label
+123
View File
@@ -0,0 +1,123 @@
class_name WorldTreeRankDialog
extends ConfirmationDialog
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
signal status_requested(message: String, success: bool)
const LABELS := {
&"reputation":"評判", &"asset_gold":"所持金", &"faith_lifetime":"累計信仰", &"guest_lifetime":"累計来客数",
&"underground_influence":"裏社会への影響", &"reputation_or_underground_influence":"評判または裏社会への影響",
&"active_above_ground_facilities":"地上の稼働施設", &"active_underground_facilities":"地下の稼働施設",
&"important_rank_5":"重要クエスト", &"main_chapter_complete":"本編の章目標",
}
var _runtime: Variant
var _target := -1
var _pending_id := 0
var _queue_id := 0
var _skin_styles: Array[StyleBoxFlat] = []
func configure(runtime: Variant) -> void: _runtime=runtime
func _ready() -> void:
name="WorldTreeRankDialog"
title="世界樹ランク"
get_ok_button().text="昇格する"
get_cancel_button().text="閉じる"
confirmed.connect(_promote)
_runtime.command_completed.connect(_on_command_completed)
_build_skin()
apply_layer_skin(LayerSkin.active_layer(self))
func show_rank() -> void:
refresh()
popup_centered(Vector2i(480,260))
func refresh() -> void:
var queue_id: int=_runtime.command_queue.get_instance_id()
if queue_id!=_queue_id:
_pending_id=0
_queue_id=queue_id
for command: Dictionary in _runtime.command_queue.create_snapshot().get("pending",[]):
if command.get("command_type","")=="set_world_tree_rank":
_pending_id=int(command["command_id"])
break
var report: Dictionary=_runtime.get_live_progression_report()
var rank: int=report[&"snapshot"][&"current_rank"]
var evaluation: Dictionary=report[&"evaluation"]
_target=rank+1
var lines: Array[String]=["現在のランク:%d" % rank]
if rank>=ProgressionService.MAX_RANK:
lines.append("最高ランクに到達しています。")
else:
lines.append("ランク%dへの条件" % _target)
for requirement: Dictionary in evaluation[&"requirements"]:
var label: String=LABELS.get(StringName(requirement[&"id"]),"進行条件")
var value: String="%d / %d" % [requirement[&"current"],requirement[&"required"]]
if requirement[&"comparison"]==&"QUEST_COMPLETED": value="達成済み" if requirement[&"met"] else "未達成"
lines.append("%s %s:%s" % ["✓" if requirement[&"met"] else "・",label,value])
if evaluation.get(&"eligible",false):lines.append("条件を満たしています。昇格すると建設範囲が広がります。")
elif evaluation.get(&"reason",&"")==&"QUARANTINED":lines.append("進行データの確認が必要なため、現在は昇格できません。")
if evaluation.get(&"reason",&"")==&"OPERATIONS_BLOCKED":
var reason: StringName=evaluation.get(&"block_reason",&"")
lines.append("未払い経費を精算するまで昇格できません。" if reason in [&"ARREARS_REMAIN",&"UNPAID_EXPENSES"] else "ゲーム終了後は昇格できません。" if reason==&"BANKRUPT" else "営業状態の復旧が必要なため、現在は昇格できません。")
if evaluation.get(&"reason",&"")==&"UNAVAILABLE":lines.append("条件を確認できません。もう一度開き直してください。")
if _pending_id!=0:lines.append("昇格申請を受理しました。時間更新を待っています。")
dialog_text="\n\n".join(lines)
get_ok_button().disabled=rank>=ProgressionService.MAX_RANK or not evaluation.get(&"eligible",false) or _pending_id!=0
func _promote() -> void:
if _pending_id!=0 or _target<2:return
var result: CommandResult=_runtime.submit_command(&"set_world_tree_rank",{&"rank":_target,&"request_id":StringName("ui_rank_%d" % _target)})
for command: Dictionary in _runtime.command_queue.create_snapshot().get("pending",[]):
if result.is_success() and int(command["command_id"])==result.command_id:
_pending_id=result.command_id
status_requested.emit("昇格申請を受理しました。次の時間更新で反映します。",true)
return
_finish(result)
func _on_command_completed(result: CommandResult) -> void:
if _pending_id!=0 and result.command_id==_pending_id:
_pending_id=0
_finish(result)
func _finish(result: CommandResult) -> void:
status_requested.emit("世界樹ランクが上がりました。" if result.is_success() else "昇格できませんでした。現在の条件を確認してください。",result.is_success())
refresh()
func _build_skin() -> void:
# Window uses a local Theme rather than Control theme overrides.
# Keep native dialog input, focus and confirmation behaviour intact.
theme = Theme.new()
var panel := _skin_box(Color("f4e3bd"), &"panel_parchment")
theme.set_stylebox("panel", "AcceptDialog", panel)
var border := _skin_box(Color("49311f"), &"panel_outer")
border.expand_margin_top = 32
theme.set_stylebox("embedded_border", "Window", border)
theme.set_color("title_color", "Window", Color("fff0cf"))
get_label().add_theme_color_override("font_color", Color("55371e"))
LayerSkin.register_text(get_label(), 0)
for button: Button in [get_ok_button(), get_cancel_button()]:
for key: StringName in [&"normal", &"hover", &"pressed", &"disabled", &"focus"]:
var role: StringName = &"button_normal"
var color := Color("65452d")
if key in [&"hover", &"focus"]: role = &"button_hover"; color = Color("896139")
elif key == &"pressed": role = &"button_pressed"; color = Color("49311f")
elif key == &"disabled": role = &"button_disabled"; color = Color("786852")
button.add_theme_stylebox_override(key, _skin_box(color, role))
for key: StringName in [&"font_color", &"font_hover_color", &"font_pressed_color", &"font_hover_pressed_color", &"font_focus_color"]:
button.add_theme_color_override(key, Color("fff0cf"))
button.add_theme_color_override("font_disabled_color", Color("c7bda9"))
LayerSkin.register_toggle_text(button, 0)
func _skin_box(background: Color, role: StringName) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = background
style.border_color = Color("a96f2f")
style.set_border_width_all(2)
style.set_content_margin_all(10)
LayerSkin.register_style(style, role, 0)
_skin_styles.append(style)
return style
func apply_layer_skin(layer: int) -> void:
for style: StyleBoxFlat in _skin_styles:
LayerSkin.apply_style(style, layer)
LayerSkin.register_text(get_label(), layer)
for button: Button in [get_ok_button(), get_cancel_button()]:
LayerSkin.register_toggle_text(button, layer)
+1
View File
@@ -0,0 +1 @@
uid://lr3xhtjonmwu