P24-02: 操作と通知の全受入
This commit is contained in:
@@ -158,8 +158,18 @@ func get_event_rows() -> Array[Dictionary]:
|
||||
var stream: Variant = _runtime.get("domain_events")
|
||||
if stream == null or not stream.has_method("recent_events"):
|
||||
return rows
|
||||
var seen_ids: Dictionary = {}
|
||||
for event: SimulationDomainEvent in stream.recent_events():
|
||||
rows.append(event.to_dictionary())
|
||||
var dict: Dictionary = event.to_dictionary()
|
||||
var eid: String = String(dict.get("event_id", ""))
|
||||
if not eid.is_empty():
|
||||
if seen_ids.has(eid):
|
||||
continue
|
||||
seen_ids[eid] = true
|
||||
rows.append(dict)
|
||||
# Bound to recent 256 entries in strict sequence
|
||||
if rows.size() > 256:
|
||||
rows = rows.slice(rows.size() - 256)
|
||||
return rows
|
||||
|
||||
|
||||
@@ -201,9 +211,12 @@ func _select_row(index: int) -> void:
|
||||
var row: Dictionary = _rows[index]
|
||||
_selected_event_id = String(row["event_id"])
|
||||
var presentation := get_event_presentation(row)
|
||||
_details.text = "%s\n%s\n対象(現在の名称):%s" % [
|
||||
var detail_text := "%s\n%s\n対象(現在の名称):%s" % [
|
||||
presentation[&"time"], presentation[&"title"], presentation[&"targets"],
|
||||
]
|
||||
if not String(presentation.get(&"extra", "")).is_empty():
|
||||
detail_text += "\n" + String(presentation[&"extra"])
|
||||
_details.text = detail_text
|
||||
_details.tooltip_text = presentation[&"supplement"]
|
||||
|
||||
|
||||
@@ -225,13 +238,30 @@ func get_event_presentation(row: Dictionary) -> Dictionary:
|
||||
names.append(resolved)
|
||||
if unknown_count > 0:
|
||||
names.append("名称を確認できない対象(%d件)" % unknown_count)
|
||||
|
||||
var title_text := String(EVENT_NAMES.get(StringName(row.get("event_type", "")), "未識別の記録"))
|
||||
var extras: Array[String] = []
|
||||
|
||||
# Check for amount / gold / cost
|
||||
var payload: Dictionary = row.get("payload", {}) if row.get("payload") is Dictionary else {}
|
||||
var amount_val: Variant = row.get("amount", payload.get("amount", row.get("gold", payload.get("gold", null))))
|
||||
if amount_val != null and (amount_val is int or amount_val is float) and int(amount_val) != 0:
|
||||
extras.append("金額:G%+d" % int(amount_val) if int(amount_val) < 0 else "金額:G%d" % int(amount_val))
|
||||
|
||||
# Check for reason
|
||||
var reason_val: Variant = row.get("reason", payload.get("reason", row.get("error", payload.get("error", ""))))
|
||||
if not String(reason_val).is_empty():
|
||||
extras.append("理由:%s" % String(reason_val))
|
||||
|
||||
return {
|
||||
&"title": String(EVENT_NAMES.get(StringName(row.get("event_type", "")), "未識別の記録")),
|
||||
&"title": title_text,
|
||||
&"time": _format_minute(int(row.get("absolute_minute", 0))),
|
||||
&"targets": "、".join(names) if not names.is_empty() else "対象の指定なし",
|
||||
&"supplement": "補足情報\n記録:%s\n種別:%s\n対象:%s\n発行元:%s" % [
|
||||
&"extra": " ".join(extras),
|
||||
&"supplement": "補足情報\n記録:%s\n種別:%s\n対象:%s\n発行元:%s%s" % [
|
||||
row.get("event_id", ""), row.get("event_type", ""),
|
||||
"、".join(raw_ids), row.get("source_service", ""),
|
||||
"\n" + "\n".join(extras) if not extras.is_empty() else "",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -12,19 +12,38 @@ var _new_path := ""
|
||||
var _new_name := ""
|
||||
var _message: Label
|
||||
var _slots: ItemList
|
||||
var _label_edit: LineEdit
|
||||
var _validation_label: Label
|
||||
var _rows: Array[Dictionary] = []
|
||||
|
||||
func _ready() -> void:
|
||||
name = "ManualSaveSlotsDialog"
|
||||
cancel_button_text = "戻る"
|
||||
var column := VBoxContainer.new()
|
||||
column.custom_minimum_size = Vector2(460, 220)
|
||||
column.custom_minimum_size = Vector2(460, 240)
|
||||
add_child(column)
|
||||
_message = Label.new()
|
||||
_message.name = "ManualSaveSlotsMessage"
|
||||
_message.custom_minimum_size.x = 460
|
||||
_message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
column.add_child(_message)
|
||||
|
||||
_label_edit = LineEdit.new()
|
||||
_label_edit.name = "ManualSaveLabelEdit"
|
||||
_label_edit.custom_minimum_size = Vector2(460, 32)
|
||||
_label_edit.placeholder_text = "セーブデータの名前(日本語入力可)"
|
||||
_label_edit.max_length = 60
|
||||
_label_edit.text_changed.connect(_on_label_text_changed)
|
||||
column.add_child(_label_edit)
|
||||
_label_edit.hide()
|
||||
|
||||
_validation_label = Label.new()
|
||||
_validation_label.name = "ManualSaveValidationLabel"
|
||||
_validation_label.add_theme_font_size_override("font_size", 11)
|
||||
_validation_label.add_theme_color_override("font_color", Color("e06c75"))
|
||||
column.add_child(_validation_label)
|
||||
_validation_label.hide()
|
||||
|
||||
_slots = ItemList.new()
|
||||
_slots.name = "ManualSaveSlotsList"
|
||||
_slots.custom_minimum_size = Vector2(460, 150)
|
||||
@@ -50,9 +69,20 @@ func show_new_save(current_path: String) -> void:
|
||||
_new_name = "手動保存 " + Time.get_datetime_string_from_system(false, true)
|
||||
var fresh: bool = not _new_path.is_empty() and is_fresh_slot(_new_path)
|
||||
get_ok_button().disabled = not fresh
|
||||
_message.text = "今の進行は、元の保存とは別に保存します。\n元の保存は残り、あとから読込一覧で選べます。\n\n" + _new_name if fresh else "新しい保存先を用意できませんでした。\n元の保存は変更していません。"
|
||||
popup_centered(Vector2i(520, 300))
|
||||
get_cancel_button().grab_focus()
|
||||
_message.text = "今の進行は、元の保存とは別に保存します。\n元の保存は残り、あとから読込一覧で選べます。\n保存の名前(日本語入力対応):" if fresh else "新しい保存先を用意できませんでした。\n元の保存は変更していません。"
|
||||
if fresh:
|
||||
_label_edit.show()
|
||||
_label_edit.text = _new_name
|
||||
_validation_label.hide()
|
||||
else:
|
||||
_label_edit.hide()
|
||||
_validation_label.hide()
|
||||
popup_centered(Vector2i(520, 320))
|
||||
if fresh:
|
||||
_label_edit.grab_focus()
|
||||
_label_edit.select_all()
|
||||
else:
|
||||
get_cancel_button().grab_focus()
|
||||
|
||||
func show_load_slots(current_path: String) -> void:
|
||||
_clear_choice()
|
||||
@@ -61,6 +91,8 @@ func show_load_slots(current_path: String) -> void:
|
||||
title = "保存スロットを選んで読込"
|
||||
ok_button_text = "選んで読込"
|
||||
_message.text = "読み込む保存を選んでください。\n現在の未保存の進行は、選んだ保存で置き換わります。"
|
||||
_label_edit.hide()
|
||||
_validation_label.hide()
|
||||
_slots.show()
|
||||
_slots.clear()
|
||||
_rows = read_slot_rows(_directory)
|
||||
@@ -121,12 +153,48 @@ static func read_slot_rows(directory: String) -> Array[Dictionary]:
|
||||
rows.append({&"path": path, &"label": label, &"loadable": loadable})
|
||||
return rows
|
||||
|
||||
static func is_valid_label_text(text: String) -> Dictionary:
|
||||
if text.length() > 60:
|
||||
return {&"ok": false, &"error": "セーブ名は60文字以内で入力してください。"}
|
||||
for i in range(text.length()):
|
||||
var c := text.unicode_at(i)
|
||||
if c < 32 and c != 9:
|
||||
return {&"ok": false, &"error": "制御文字は入力できません。"}
|
||||
return {&"ok": true, &"error": ""}
|
||||
|
||||
func _on_label_text_changed(new_text: String) -> void:
|
||||
if _mode != "new":
|
||||
return
|
||||
var fresh: bool = not _new_path.is_empty() and is_fresh_slot(_new_path)
|
||||
if not fresh:
|
||||
get_ok_button().disabled = true
|
||||
return
|
||||
var check := is_valid_label_text(new_text)
|
||||
if not bool(check[&"ok"]):
|
||||
_validation_label.text = String(check[&"error"])
|
||||
_validation_label.show()
|
||||
get_ok_button().disabled = true
|
||||
else:
|
||||
_validation_label.hide()
|
||||
get_ok_button().disabled = false
|
||||
|
||||
func _select_slot(index: int) -> void:
|
||||
get_ok_button().disabled = index < 0 or index >= _rows.size() or not _rows[index][&"loadable"]
|
||||
|
||||
func _confirm_choice() -> void:
|
||||
if _mode == "new" and is_fresh_slot(_new_path):
|
||||
new_slot_requested.emit(_new_path, _new_name)
|
||||
if _mode == "new":
|
||||
if not is_fresh_slot(_new_path):
|
||||
_clear_choice()
|
||||
return
|
||||
var raw_name := _label_edit.text.strip_edges() if _label_edit != null else ""
|
||||
var check := is_valid_label_text(raw_name)
|
||||
if not bool(check[&"ok"]):
|
||||
_validation_label.text = String(check[&"error"])
|
||||
_validation_label.show()
|
||||
get_ok_button().disabled = true
|
||||
return
|
||||
var final_name := raw_name if not raw_name.is_empty() else _new_name
|
||||
new_slot_requested.emit(_new_path, final_name)
|
||||
elif _mode == "load":
|
||||
var selected: PackedInt32Array = _slots.get_selected_items()
|
||||
if selected.size() == 1 and selected[0] >= 0 and selected[0] < _rows.size() and _rows[selected[0]][&"loadable"]:
|
||||
@@ -137,8 +205,15 @@ func _clear_choice() -> void:
|
||||
_mode = ""
|
||||
_new_path = ""
|
||||
_new_name = ""
|
||||
if _label_edit != null:
|
||||
_label_edit.text = ""
|
||||
_label_edit.hide()
|
||||
if _validation_label != null:
|
||||
_validation_label.hide()
|
||||
_rows.clear()
|
||||
|
||||
func apply_layer_skin(layer: int) -> void:
|
||||
if _message != null: LayerSkin.register_text(_message, layer)
|
||||
if _validation_label != null: LayerSkin.register_status_text(_validation_label, false, layer)
|
||||
LayerSkin.apply_tree(self, layer)
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
class_name OperationFeedback
|
||||
extends RefCounted
|
||||
|
||||
## Operation and notification UX helpers: rejection explanations,
|
||||
## overhead unit bubbles, and important warning cut-in dialogs.
|
||||
## Presentation-only: never modifies the simulation model or ledger directly.
|
||||
|
||||
const BUBBLE_NONE := &""
|
||||
const BUBBLE_ZZZ := &"zzz"
|
||||
const BUBBLE_ANGER := &"anger"
|
||||
const BUBBLE_HEART := &"heart"
|
||||
|
||||
const CUTIN_INVASION := &"invasion"
|
||||
const CUTIN_DEFENSE := &"defense"
|
||||
const CUTIN_DUEL := &"duel"
|
||||
|
||||
const ASSIGNMENT_REASONS := {
|
||||
&"STAFF_NOT_FOUND": "従業員が見つかりません。",
|
||||
&"FACILITY_NOT_FOUND": "配属先が見つかりません。施設が完成しているか確認してください。",
|
||||
&"ROLE_MISMATCH": "この施設には役割が合いません。必要な職種を確認してください。",
|
||||
&"NO_ACCESS": "従業員が配属先へ移動できません。通路と従業員ドアを確認してください。",
|
||||
&"NO_PATH": "従業員が配属先へ移動できません。通路と従業員ドアを確認してください。",
|
||||
&"STAFF_BUSY": "作業中のため変更できません。作業完了後に再試行してください。",
|
||||
&"CAPACITY_EXCEEDED": "配属先の人員上限を超えています。",
|
||||
&"FACILITY_FULL": "配属先の人員上限を超えています。",
|
||||
&"NO_ROUTE": "配属先へ移動できません。通路と従業員ドアを確認してください。",
|
||||
&"INVALID_TEMPLATE": "日勤または夜勤を選択してください。",
|
||||
&"STAFF_UNAVAILABLE": "この従業員は現在、勤務を変更できません。状態をご確認ください。",
|
||||
&"DEFENSE_LOCKED": "防衛参加中は勤務を変更できません。",
|
||||
&"EVENT_LOCKED": "イベント参加中は勤務を変更できません。",
|
||||
&"FACILITY_UNSTAFFED": "この施設は人員を配属する施設ではありません。",
|
||||
&"SHIFT_CONFLICT": "既存の勤務と時間が重なります。勤務予定を確認してください。",
|
||||
&"BANKRUPT": "運営終了後は配属を変更できません。",
|
||||
}
|
||||
|
||||
const PLACEMENT_REASONS := {
|
||||
"INVALID_DEFINITION": "施設データが不正です",
|
||||
"MISSING_DEPENDENCY": "建設システムを利用できません",
|
||||
"OUT_OF_BOUNDS": "世界の範囲外です",
|
||||
"ZONE_MISMATCH": "地上・境界・地下の条件が合いません",
|
||||
"LAYER_MISMATCH": "表層/裏層が合いません",
|
||||
"OVERLAP": "ほかの施設または工事と重なります",
|
||||
"UNSUPPORTED": "構造を支えられません",
|
||||
"INSUFFICIENT_RESOURCES": "建設資源が不足しています",
|
||||
}
|
||||
|
||||
|
||||
static func resolve_staff_bubble(staff: Variant, relationship: Variant = null) -> StringName:
|
||||
if staff == null:
|
||||
return BUBBLE_NONE
|
||||
var state_val: int = int(staff.get(&"state")) if staff is Object and "state" in staff else (int(staff.get("state", -1)) if staff is Dictionary else -1)
|
||||
# Sleeping, incapacitated, or on break corresponds to Zzz
|
||||
if state_val in [StaffRuntime.State.SLEEPING, StaffRuntime.State.INCAPACITATED, StaffRuntime.State.BREAKING]:
|
||||
return BUBBLE_ZZZ
|
||||
# Strike or fleeing corresponds to anger (no fictional morale thresholds)
|
||||
if state_val in [StaffRuntime.State.STRIKE, StaffRuntime.State.FLEEING]:
|
||||
return BUBBLE_ANGER
|
||||
# Heart must derive from actual existing intimacy/love-shield state with source authority
|
||||
if relationship != null:
|
||||
var shield := 0
|
||||
var route_lock_val := 0
|
||||
var affection := 0
|
||||
if relationship is RelationshipState:
|
||||
shield = relationship.shield_current
|
||||
route_lock_val = int(relationship.route_lock)
|
||||
affection = int(relationship.metrics.get(&"affection", 0))
|
||||
elif relationship is Dictionary:
|
||||
shield = int(relationship.get(&"shield_current", relationship.get("shield_current", 0)))
|
||||
route_lock_val = int(relationship.get(&"route_lock", relationship.get("route_lock", 0)))
|
||||
var m: Variant = relationship.get(&"metrics", relationship.get("metrics", {}))
|
||||
if m is Dictionary:
|
||||
affection = int(m.get(&"affection", m.get("affection", 0)))
|
||||
elif relationship is Object:
|
||||
if "shield_current" in relationship:
|
||||
shield = int(relationship.get(&"shield_current"))
|
||||
if "route_lock" in relationship:
|
||||
route_lock_val = int(relationship.get(&"route_lock"))
|
||||
if "metrics" in relationship:
|
||||
var m: Variant = relationship.get(&"metrics")
|
||||
if m is Dictionary:
|
||||
affection = int(m.get(&"affection", m.get("affection", 0)))
|
||||
if shield > 0 or affection > 0 or route_lock_val in [RelationshipState.RouteLock.EXCLUSIVE_PENDING, RelationshipState.RouteLock.EXCLUSIVE_LOCKED]:
|
||||
return BUBBLE_HEART
|
||||
# Without authoritative relationship intimacy / love-shield state, mapping is unmapped/none
|
||||
return BUBBLE_NONE
|
||||
|
||||
|
||||
static func resolve_guest_bubble(guest: Variant, relationship: Variant = null) -> StringName:
|
||||
if guest == null:
|
||||
return BUBBLE_NONE
|
||||
var state_val: int = int(guest.get(&"state")) if guest is Object and "state" in guest else (int(guest.get("state", -1)) if guest is Dictionary else -1)
|
||||
# Incapacitated corresponds to Zzz
|
||||
if state_val == GuestRuntime.State.INCAPACITATED:
|
||||
return BUBBLE_ZZZ
|
||||
# Abandoning, fleeing, interrupted, or out of patience in queue corresponds to anger
|
||||
if state_val in [GuestRuntime.State.ABANDONING, GuestRuntime.State.FLEEING, GuestRuntime.State.INTERRUPTED]:
|
||||
return BUBBLE_ANGER
|
||||
var patience: int = int(guest.get(&"patience_remaining")) if guest is Object and "patience_remaining" in guest else (int(guest.get("patience_remaining", 1)) if guest is Dictionary else 1)
|
||||
if state_val == GuestRuntime.State.QUEUING and patience <= 0:
|
||||
return BUBBLE_ANGER
|
||||
# Heart must derive from actual existing intimacy/love-shield state with source authority
|
||||
if relationship != null:
|
||||
var shield := 0
|
||||
var route_lock_val := 0
|
||||
var affection := 0
|
||||
if relationship is RelationshipState:
|
||||
shield = relationship.shield_current
|
||||
route_lock_val = int(relationship.route_lock)
|
||||
affection = int(relationship.metrics.get(&"affection", 0))
|
||||
elif relationship is Dictionary:
|
||||
shield = int(relationship.get(&"shield_current", relationship.get("shield_current", 0)))
|
||||
route_lock_val = int(relationship.get(&"route_lock", relationship.get("route_lock", 0)))
|
||||
var m: Variant = relationship.get(&"metrics", relationship.get("metrics", {}))
|
||||
if m is Dictionary:
|
||||
affection = int(m.get(&"affection", m.get("affection", 0)))
|
||||
elif relationship is Object:
|
||||
if "shield_current" in relationship:
|
||||
shield = int(relationship.get(&"shield_current"))
|
||||
if "route_lock" in relationship:
|
||||
route_lock_val = int(relationship.get(&"route_lock"))
|
||||
if "metrics" in relationship:
|
||||
var m: Variant = relationship.get(&"metrics")
|
||||
if m is Dictionary:
|
||||
affection = int(m.get(&"affection", m.get("affection", 0)))
|
||||
if shield > 0 or affection > 0 or route_lock_val in [RelationshipState.RouteLock.EXCLUSIVE_PENDING, RelationshipState.RouteLock.EXCLUSIVE_LOCKED]:
|
||||
return BUBBLE_HEART
|
||||
return BUBBLE_NONE
|
||||
|
||||
|
||||
static func get_assignment_rejection_text(reason: StringName) -> String:
|
||||
return ASSIGNMENT_REASONS.get(reason, "配属を変更できませんでした。条件を確認してください。")
|
||||
|
||||
|
||||
static func get_placement_rejection_text(report: Dictionary) -> String:
|
||||
var reasons: Array = report.get(&"reasons", report.get("reasons", []))
|
||||
var error_codes: Array = report.get(&"error_codes", report.get("error_codes", []))
|
||||
if not error_codes.is_empty():
|
||||
var code: String = String(error_codes[0])
|
||||
if PLACEMENT_REASONS.has(code):
|
||||
return PLACEMENT_REASONS[code]
|
||||
if not reasons.is_empty():
|
||||
return String(reasons[0])
|
||||
return "指定したマスには建築できません。配置条件を確認してください。"
|
||||
|
||||
|
||||
## Important warning cut-in dialog: invasion start, duel, defense start
|
||||
class ImportantWarningCutinDialog extends AcceptDialog:
|
||||
signal cutin_dismissed(kind: StringName)
|
||||
|
||||
var _current_kind: StringName = &""
|
||||
var _queue: Array[Dictionary] = []
|
||||
var _cutin_active := false
|
||||
var _pre_cutin_user_paused := false
|
||||
var _title_label: Label
|
||||
var _body_label: Label
|
||||
|
||||
func _init() -> void:
|
||||
name = "ImportantWarningCutinDialog"
|
||||
title = "重要警告"
|
||||
ok_button_text = "確認"
|
||||
unresizable = true
|
||||
min_size = Vector2i(480, 200)
|
||||
size = Vector2i(480, 200)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.custom_minimum_size = Vector2(460, 140)
|
||||
vbox.add_theme_constant_override("separation", 10)
|
||||
add_child(vbox)
|
||||
|
||||
_title_label = Label.new()
|
||||
_title_label.name = "CutinTitleLabel"
|
||||
_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_title_label.add_theme_font_size_override("font_size", 16)
|
||||
_title_label.add_theme_color_override("font_color", Color("ff7b7b"))
|
||||
vbox.add_child(_title_label)
|
||||
|
||||
_body_label = Label.new()
|
||||
_body_label.name = "CutinBodyLabel"
|
||||
_body_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_body_label.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
_body_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_body_label.add_theme_font_size_override("font_size", 13)
|
||||
vbox.add_child(_body_label)
|
||||
|
||||
confirmed.connect(_on_dismissed)
|
||||
canceled.connect(_on_dismissed)
|
||||
close_requested.connect(_on_dismissed)
|
||||
|
||||
func show_cutin(kind: StringName, heading: String, message: String) -> void:
|
||||
if _cutin_active:
|
||||
_queue.append({&"kind": kind, &"heading": heading, &"message": message})
|
||||
return
|
||||
_cutin_active = true
|
||||
var clock := get_node_or_null("/root/GameClock")
|
||||
if clock != null:
|
||||
_pre_cutin_user_paused = bool(clock.get("paused"))
|
||||
if not _pre_cutin_user_paused:
|
||||
clock.set_paused(true)
|
||||
_display_cutin(kind, heading, message)
|
||||
|
||||
func _display_cutin(kind: StringName, heading: String, message: String) -> void:
|
||||
_current_kind = kind
|
||||
_title_label.text = heading
|
||||
_body_label.text = message
|
||||
popup_centered(Vector2i(500, 220))
|
||||
get_ok_button().grab_focus()
|
||||
|
||||
func _on_dismissed() -> void:
|
||||
var kind := _current_kind
|
||||
_current_kind = &""
|
||||
cutin_dismissed.emit(kind)
|
||||
if not _queue.is_empty():
|
||||
var next: Dictionary = _queue.pop_front()
|
||||
_display_cutin(next[&"kind"], next[&"heading"], next[&"message"])
|
||||
else:
|
||||
_cutin_active = false
|
||||
var clock := get_node_or_null("/root/GameClock")
|
||||
if clock != null and not _pre_cutin_user_paused:
|
||||
clock.set_paused(false)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8p24opfeedback
|
||||
@@ -0,0 +1,145 @@
|
||||
class_name OperationsCameraControls
|
||||
extends HBoxContainer
|
||||
|
||||
## Presentation-only camera and navigation controls providing click alternatives
|
||||
## for pointer drag, wheel scroll, and modifier-based navigation.
|
||||
## Does not alter the underlying simulation model or game state.
|
||||
|
||||
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
|
||||
|
||||
signal zoom_in_requested
|
||||
signal zoom_out_requested
|
||||
signal reset_view_requested
|
||||
signal pan_requested(direction: Vector2)
|
||||
signal grid_toggle_requested
|
||||
signal layer_toggle_requested
|
||||
|
||||
var zoom_in_button: Button
|
||||
var zoom_out_button: Button
|
||||
var reset_button: Button
|
||||
var pan_up_button: Button
|
||||
var pan_down_button: Button
|
||||
var pan_left_button: Button
|
||||
var pan_right_button: Button
|
||||
var grid_button: Button
|
||||
var layer_button: Button
|
||||
|
||||
var _world_preview: Variant
|
||||
var _main_scene: Variant
|
||||
var _buttons: Array[Button] = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
name = "OperationsCameraControls"
|
||||
add_theme_constant_override("separation", 4)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_build_ui()
|
||||
|
||||
|
||||
func configure(preview: Variant, main_scene: Variant = null) -> void:
|
||||
_world_preview = preview
|
||||
_main_scene = main_scene
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
# Navigation Pan Buttons
|
||||
pan_left_button = _create_button("◀", "左へ移動", func() -> void: _on_pan_step(Vector2(1, 0)))
|
||||
pan_up_button = _create_button("▲", "上へ移動", func() -> void: _on_pan_step(Vector2(0, 1)))
|
||||
pan_down_button = _create_button("▼", "下へ移動", func() -> void: _on_pan_step(Vector2(0, -1)))
|
||||
pan_right_button = _create_button("▶", "右へ移動", func() -> void: _on_pan_step(Vector2(-1, 0)))
|
||||
|
||||
# Zoom Buttons
|
||||
zoom_in_button = _create_button("+", "拡大表示", _on_zoom_in)
|
||||
zoom_out_button = _create_button("-", "縮小表示", _on_zoom_out)
|
||||
|
||||
# Reset / Focus
|
||||
reset_button = _create_button("全体", "表示範囲全体を収める", _on_reset_view)
|
||||
|
||||
# Grid & Layer Toggle
|
||||
grid_button = _create_button("マス目", "グリッド線の表示/非表示を切り替え", _on_grid_toggle)
|
||||
layer_button = _create_button("表/裏", "フロアの表層/裏層を切り替え", _on_layer_toggle)
|
||||
|
||||
|
||||
func _create_button(text_label: String, tooltip: String, callback: Callable) -> Button:
|
||||
var btn := Button.new()
|
||||
btn.text = text_label
|
||||
btn.tooltip_text = tooltip
|
||||
btn.custom_minimum_size = Vector2(30, 26)
|
||||
btn.focus_mode = Control.FOCUS_NONE
|
||||
btn.add_theme_font_size_override("font_size", 11)
|
||||
btn.pressed.connect(callback)
|
||||
add_child(btn)
|
||||
_buttons.append(btn)
|
||||
return btn
|
||||
|
||||
|
||||
func _on_zoom_in() -> void:
|
||||
if _world_preview != null and _world_preview.has_method(&"set_zoom"):
|
||||
var current: float = float(_world_preview.get("zoom_level"))
|
||||
_world_preview.set_zoom(current + 0.5)
|
||||
zoom_in_requested.emit()
|
||||
|
||||
|
||||
func _on_zoom_out() -> void:
|
||||
if _world_preview != null and _world_preview.has_method(&"set_zoom"):
|
||||
var current: float = float(_world_preview.get("zoom_level"))
|
||||
_world_preview.set_zoom(current - 0.5)
|
||||
zoom_out_requested.emit()
|
||||
|
||||
|
||||
func _on_pan_step(direction: Vector2) -> void:
|
||||
var delta := Vector2(32, 48) * direction
|
||||
if _world_preview != null:
|
||||
if _world_preview.has_method(&"_cell_dimensions"):
|
||||
var dims: Vector2 = _world_preview.call(&"_cell_dimensions")
|
||||
delta = direction * dims
|
||||
var offset: Vector2 = _world_preview.get("pan_offset")
|
||||
_world_preview.set("pan_offset", offset + delta)
|
||||
if _world_preview.has_method(&"_clamp_pan"):
|
||||
_world_preview.call(&"_clamp_pan")
|
||||
_world_preview.queue_redraw()
|
||||
pan_requested.emit(delta)
|
||||
|
||||
|
||||
func _on_pan(delta: Vector2) -> void:
|
||||
if _world_preview != null:
|
||||
var offset: Vector2 = _world_preview.get("pan_offset")
|
||||
_world_preview.set("pan_offset", offset + delta)
|
||||
if _world_preview.has_method(&"_clamp_pan"):
|
||||
_world_preview.call(&"_clamp_pan")
|
||||
_world_preview.queue_redraw()
|
||||
pan_requested.emit(delta)
|
||||
|
||||
|
||||
func _on_reset_view() -> void:
|
||||
if _world_preview != null:
|
||||
if _world_preview.has_method(&"focus_operating_area"):
|
||||
_world_preview.call(&"focus_operating_area")
|
||||
elif _world_preview.has_method(&"reset_view"):
|
||||
_world_preview.call(&"reset_view")
|
||||
reset_view_requested.emit()
|
||||
|
||||
|
||||
func _on_grid_toggle() -> void:
|
||||
if _world_preview != null:
|
||||
var current_grid: bool = bool(_world_preview.get("show_grid"))
|
||||
_world_preview.set("show_grid", not current_grid)
|
||||
_world_preview.queue_redraw()
|
||||
grid_toggle_requested.emit()
|
||||
|
||||
|
||||
func _on_layer_toggle() -> void:
|
||||
if _main_scene != null and _main_scene.has_method(&"_toggle_layer"):
|
||||
_main_scene.call(&"_toggle_layer")
|
||||
elif _world_preview != null:
|
||||
var current_mode: int = int(_world_preview.get("layer_mode"))
|
||||
_world_preview.set("layer_mode", 1 if current_mode == 0 else 0)
|
||||
layer_toggle_requested.emit()
|
||||
|
||||
|
||||
func apply_layer_skin(layer: int) -> void:
|
||||
for btn: Button in _buttons:
|
||||
LayerSkin.register_toggle_text(btn, layer)
|
||||
LayerSkin.apply_tree(self, layer)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c9p24cameractrl
|
||||
Reference in New Issue
Block a user