Skip to content

Commit 9b4ab30

Browse files
feat(ui): add notice230 confirmation modals for quit and factory reset
Introduce a shared parchment (notice230) modal with dimmer, Fife-aligned copy and layout, and round OK/Cancel controls. Gate in-app quit on PlatformPolicy for iOS/Android store compliance (no quit UI, no quit_game handling). Options factory reset uses the same modal; main menu and Global quit_game use it on desktop only.
1 parent 8b744e0 commit 9b4ab30

8 files changed

Lines changed: 243 additions & 15 deletions

File tree

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
class_name Notice230ConfirmModal
2+
extends RefCounted
3+
4+
## Matches po/uh/unknown-horizons.pot (horizons/gui/gui.py quit flow).
5+
const QUIT_CONFIRM_TITLE: String = "Quit Game"
6+
const QUIT_CONFIRM_MESSAGE: String = "Are you sure you want to quit Unknown Horizons?"
7+
8+
const _MODAL_LAYER_DEFAULT: int = 80
9+
const _DIM_COLOR_DEFAULT: Color = Color(0, 0, 0, 0.55)
10+
const _NOTICE_BG: String = "res://Assets/UI/Images/Background/notice230.png"
11+
const _NOTICE_SIZE: Vector2 = Vector2(374, 230)
12+
const _FONT_TITLE: Font = preload("res://External/Fonts/LinLibertineB.ttf")
13+
const _FONT_BODY: Font = preload("res://External/Fonts/LinLibertineI.ttf")
14+
const _TEXT_COLOR: Color = Color(0.258824, 0.188235, 0.0509804, 1)
15+
const _OK_BUTTON: PackedScene = preload(
16+
"res://Assets/UI/BasicControls/RoundButtons/OKButton.tscn"
17+
)
18+
const _CANCEL_BUTTON: PackedScene = preload(
19+
"res://Assets/UI/BasicControls/RoundButtons/CancelButton.tscn"
20+
)
21+
22+
static func present(
23+
tree: SceneTree,
24+
title: String,
25+
message: String,
26+
on_confirmed: Callable,
27+
layer: int = _MODAL_LAYER_DEFAULT,
28+
dim_color: Color = _DIM_COLOR_DEFAULT,
29+
) -> void:
30+
if tree == null or tree.root == null:
31+
return
32+
33+
var modal_layer := CanvasLayer.new()
34+
modal_layer.layer = layer
35+
36+
var dimmer := ColorRect.new()
37+
dimmer.set_anchors_preset(Control.PRESET_FULL_RECT)
38+
dimmer.offset_left = 0.0
39+
dimmer.offset_top = 0.0
40+
dimmer.offset_right = 0.0
41+
dimmer.offset_bottom = 0.0
42+
dimmer.color = dim_color
43+
dimmer.mouse_filter = Control.MOUSE_FILTER_STOP
44+
modal_layer.add_child(dimmer)
45+
46+
var center := CenterContainer.new()
47+
center.set_anchors_preset(Control.PRESET_FULL_RECT)
48+
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
49+
modal_layer.add_child(center)
50+
51+
var notice: Control = _build_panel(modal_layer, title, message, on_confirmed)
52+
var ui_theme: Theme = ThemeDB.get_project_theme()
53+
if ui_theme != null:
54+
notice.theme = ui_theme
55+
center.add_child(notice)
56+
57+
tree.root.add_child(modal_layer)
58+
var ok_focus_target: Control = notice.get_node("Content/VBox/ButtonRow/OKButton") as Control
59+
if ok_focus_target != null:
60+
ok_focus_target.call_deferred(&"grab_focus")
61+
62+
63+
static func _build_panel(
64+
modal_layer: CanvasLayer,
65+
title: String,
66+
message: String,
67+
on_confirmed: Callable,
68+
) -> Control:
69+
var panel := PanelContainer.new()
70+
panel.name = "Notice230Confirm"
71+
panel.custom_minimum_size = _NOTICE_SIZE
72+
var panel_bg := StyleBoxTexture.new()
73+
var note_tex: Texture2D = load(_NOTICE_BG) as Texture2D
74+
panel_bg.texture = note_tex
75+
panel.add_theme_stylebox_override(&"panel", panel_bg)
76+
77+
# Margins roughly match content/gui/xml/mainmenu/templates/popup_230.xml (notice230 art).
78+
var margin := MarginContainer.new()
79+
margin.name = "Content"
80+
margin.add_theme_constant_override("margin_left", 18)
81+
margin.add_theme_constant_override("margin_top", 10)
82+
margin.add_theme_constant_override("margin_right", 18)
83+
margin.add_theme_constant_override("margin_bottom", 12)
84+
panel.add_child(margin)
85+
86+
var vbox := VBoxContainer.new()
87+
vbox.name = "VBox"
88+
vbox.add_theme_constant_override("separation", 8)
89+
margin.add_child(vbox)
90+
91+
var title_lbl := Label.new()
92+
# Fife headline is drawn all-caps in the parchment popup art.
93+
title_lbl.text = title.to_upper()
94+
title_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
95+
title_lbl.add_theme_font_override(&"font", _FONT_TITLE)
96+
title_lbl.add_theme_font_size_override(&"font_size", 18)
97+
title_lbl.add_theme_color_override(&"font_color", _TEXT_COLOR)
98+
vbox.add_child(title_lbl)
99+
100+
var sep := HSeparator.new()
101+
sep.theme_type_variation = &"HSeparatorBrownThin"
102+
vbox.add_child(sep)
103+
104+
# Body sits in the band between the rule and buttons, vertically centered like popup_230.
105+
var body_slot := CenterContainer.new()
106+
body_slot.name = "BodySlot"
107+
body_slot.size_flags_horizontal = Control.SIZE_EXPAND_FILL
108+
body_slot.size_flags_vertical = Control.SIZE_EXPAND_FILL
109+
vbox.add_child(body_slot)
110+
111+
var body_lbl := Label.new()
112+
body_lbl.text = message
113+
body_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
114+
body_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
115+
body_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
116+
body_lbl.custom_minimum_size = Vector2(304, 0)
117+
body_lbl.add_theme_font_override(&"font", _FONT_BODY)
118+
body_lbl.add_theme_font_size_override(&"font_size", 16)
119+
body_lbl.add_theme_color_override(&"font_color", _TEXT_COLOR)
120+
body_slot.add_child(body_lbl)
121+
122+
var btn_row := HBoxContainer.new()
123+
btn_row.name = "ButtonRow"
124+
btn_row.size_flags_vertical = Control.SIZE_SHRINK_END
125+
btn_row.add_theme_constant_override("separation", 0)
126+
btn_row.alignment = BoxContainer.ALIGNMENT_BEGIN
127+
vbox.add_child(btn_row)
128+
129+
var cancel_btn: TextureButton = _CANCEL_BUTTON.instantiate() as TextureButton
130+
cancel_btn.name = "CancelButton"
131+
cancel_btn.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
132+
var esc_shortcut := Shortcut.new()
133+
var esc_ev := InputEventKey.new()
134+
esc_ev.keycode = KEY_ESCAPE
135+
esc_shortcut.events.append(esc_ev)
136+
cancel_btn.shortcut = esc_shortcut
137+
cancel_btn.shortcut_in_tooltip = false
138+
cancel_btn.pressed.connect(
139+
func () -> void:
140+
if is_instance_valid(modal_layer) and not modal_layer.is_queued_for_deletion():
141+
modal_layer.queue_free()
142+
)
143+
btn_row.add_child(cancel_btn)
144+
145+
var btn_spacer := Control.new()
146+
btn_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
147+
btn_row.add_child(btn_spacer)
148+
149+
var ok_btn: TextureButton = _OK_BUTTON.instantiate() as TextureButton
150+
ok_btn.name = "OKButton"
151+
ok_btn.size_flags_horizontal = Control.SIZE_SHRINK_END
152+
var ok_shortcut := Shortcut.new()
153+
for keycode: Key in [KEY_ENTER, KEY_KP_ENTER]:
154+
var ok_ev := InputEventKey.new()
155+
ok_ev.keycode = keycode
156+
ok_shortcut.events.append(ok_ev)
157+
ok_btn.shortcut = ok_shortcut
158+
ok_btn.shortcut_in_tooltip = false
159+
ok_btn.pressed.connect(
160+
func () -> void:
161+
if is_instance_valid(modal_layer) and not modal_layer.is_queued_for_deletion():
162+
modal_layer.queue_free()
163+
on_confirmed.call()
164+
)
165+
btn_row.add_child(ok_btn)
166+
167+
return panel
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
uid://d272m5t7yf53i

Assets/UI/Pages/MainMenuUI/MainMenuUI.gd

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
extends Control
22
class_name MainMenuUI
33

4-
var _scenes = {
4+
var _scenes: Dictionary = {
55
sp_game = preload("res://Assets/UI/Pages/NewGameUI/NewGameUI.tscn"),
66
# load_game = preload("res://Assets/World/WorldDev.tscn"),
77
load_game = preload("res://Assets/World/WorldDev2D.tscn"),
88
help = preload("res://Assets/UI/Pages/HelpUI/HelpUI.tscn"),
99
options = preload("res://Assets/UI/Pages/OptionsUI/OptionsUI.tscn"),
10-
exit = preload("res://Assets/UI/Pages/QuitGameUI/ExitScene.tscn")
10+
exit = preload("res://Assets/UI/Pages/QuitGameUI/ExitScene.tscn"),
1111
}
1212

13+
func _ready() -> void:
14+
if PlatformPolicy.should_hide_application_quit_controls():
15+
var quit_btn: Node = find_child("QuitButton", true, false)
16+
if quit_btn != null:
17+
quit_btn.queue_free()
18+
1319
func _input(event: InputEvent) -> void:
1420
if not event is InputEventKey and not event is InputEventMouseButton:
1521
return
@@ -21,6 +27,21 @@ func _input(event: InputEvent) -> void:
2127
accept_event() # Avoid triggering buttons on intro skip.
2228
set_process_input(false)
2329

30+
func _on_quit_requested() -> void:
31+
Audio.play_snd_click()
32+
if PlatformPolicy.should_hide_application_quit_controls():
33+
return
34+
Notice230ConfirmModal.present(
35+
get_tree(),
36+
Notice230ConfirmModal.QUIT_CONFIRM_TITLE,
37+
Notice230ConfirmModal.QUIT_CONFIRM_MESSAGE,
38+
Callable(self, "_quit_after_confirm"),
39+
)
40+
41+
func _quit_after_confirm() -> void:
42+
#warning-ignore:return_value_discarded
43+
get_tree().change_scene_to_packed(_scenes.exit)
44+
2445
func _go_to_scene(scene: String) -> void:
2546
Audio.play_snd_click()
2647

Assets/UI/Pages/MainMenuUI/MainMenuUI.tscn

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,6 @@ autoplay = "intro"
375375

376376
[connection signal="pressed" from="Decoration/MenuItems/SinglePlayerButton" to="." method="_go_to_scene" binds= ["sp_game"]]
377377
[connection signal="pressed" from="Decoration/MenuItems/LoadGameButton" to="." method="_go_to_scene" binds= ["load_game"]]
378-
[connection signal="pressed" from="Decoration/MenuItems/QuitButton" to="." method="_go_to_scene" binds= ["exit"]]
378+
[connection signal="pressed" from="Decoration/MenuItems/QuitButton" to="." method="_on_quit_requested"]
379379
[connection signal="pressed" from="Decoration/MenuItems/HelpButton" to="." method="_go_to_scene" binds= ["help"]]
380380
[connection signal="pressed" from="Decoration/MenuItems/OptionsButton" to="." method="_go_to_scene" binds= ["options"]]

Assets/UI/Pages/OptionsUI/OptionsUI.gd

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ extends BookMenu
33
class_name OptionsUI
44

55
# Screen resolution choices
6-
const SCREEN_RESOLUTIONS = [
6+
const SCREEN_RESOLUTIONS: Array[String] = [
77
"800x600",
88
"1024x768",
99
"1280x1024",
@@ -22,7 +22,7 @@ const SCREEN_RESOLUTIONS = [
2222
"3840x2160",
2323
]
2424

25-
@onready var settings = {
25+
@onready var settings: Dictionary = {
2626
"AutosaveInterval": find_child("AutosaveInterval") as HSliderEx,
2727
"NumberOfAutosaves": find_child("NumberOfAutosaves") as HSliderEx,
2828
"NumberOfQuicksaves": find_child("NumberOfQuicksaves") as HSliderEx,
@@ -44,6 +44,15 @@ const SCREEN_RESOLUTIONS = [
4444
"VoiceVolume": find_child("VoiceVolume") as HSliderEx,
4545
}
4646

47+
const FACTORY_RESET_MODAL_LAYER: int = 80
48+
const FACTORY_RESET_DIM_COLOR: Color = Color(0, 0, 0, 0.55)
49+
# Match horizons/gui/modules/settings.py (uh-fife) + po/uh/unknown-horizons.pot msgids.
50+
const FACTORY_RESET_TITLE: String = "Restore default settings"
51+
const FACTORY_RESET_MESSAGE: String = (
52+
"Restoring the default settings will delete all changes to the settings you made so far. "
53+
+ "Do you want to continue?"
54+
)
55+
4756
func _ready() -> void:
4857
super()
4958

@@ -60,7 +69,7 @@ func _ready() -> void:
6069

6170
# Populate with languages
6271
settings["GameLanguage"].options = Global.LANGUAGES_READABLE.values()
63-
for language_index in Global.LANGUAGES.size():
72+
for language_index: int in Global.LANGUAGES.size():
6473
if Config.language == Global.LANGUAGES[language_index]:
6574
settings["GameLanguage"].selected = language_index
6675

@@ -78,7 +87,7 @@ func _ready() -> void:
7887

7988
# Populate with available resolutions
8089
settings["ScreenResolution"].options = SCREEN_RESOLUTIONS
81-
for screen_resolution_index in SCREEN_RESOLUTIONS.size():
90+
for screen_resolution_index: int in SCREEN_RESOLUTIONS.size():
8291
if Config.screen_resolution == SCREEN_RESOLUTIONS[screen_resolution_index]:
8392
settings["ScreenResolution"].selected = screen_resolution_index
8493
Utils.ensure_connected(settings["ScreenResolution"].item_selected, _on_ScreenResolution_item_selected)
@@ -97,15 +106,15 @@ func _ready() -> void:
97106
Utils.ensure_connected(settings["VoiceVolume"].value_changed, _on_VoiceVolume_value_changed)
98107

99108
func populate_dropdown(dropdown: OptionButton, items: Dictionary) -> void:
100-
for item in items.values():
109+
for item: Variant in items.values():
101110
dropdown.add_item(item)
102111

103-
func _on_WindowMode_item_selected(index) -> void:
112+
func _on_WindowMode_item_selected(index: int) -> void:
104113
get_window().mode = Window.MODE_EXCLUSIVE_FULLSCREEN if (index) else Window.MODE_WINDOWED
105114
settings["ScreenResolution"].get_node("OptionButton").disabled = not index == Global.WindowMode.WINDOWED
106115
Global.set_screen_resolution(Config.screen_resolution)
107116

108-
func _on_ScreenResolution_item_selected(index) -> void:
117+
func _on_ScreenResolution_item_selected(index: int) -> void:
109118
if index != -1:
110119
Global.set_screen_resolution(settings["ScreenResolution"].options[index])
111120

@@ -122,12 +131,20 @@ func _on_VoiceVolume_value_changed(slider_value: float) -> void:
122131
Audio.set_voice_volume(slider_value)
123132

124133
func _on_DeleteButton_pressed() -> void:
125-
print("TODO: Confirm action (modal dialog) before resetting everything.")
134+
super._on_DeleteButton_pressed()
135+
Notice230ConfirmModal.present(
136+
get_tree(),
137+
FACTORY_RESET_TITLE,
138+
FACTORY_RESET_MESSAGE,
139+
Callable(self, "_apply_factory_reset_from_notice"),
140+
FACTORY_RESET_MODAL_LAYER,
141+
FACTORY_RESET_DIM_COLOR,
142+
)
143+
144+
func _apply_factory_reset_from_notice() -> void:
126145
Config.reset_to_factory_settings()
127146
_ready()
128147

129-
super()
130-
131148
func _on_CancelButton_pressed() -> void:
132149
Config.load_config()
133150

@@ -161,7 +178,7 @@ func _on_OKButton_pressed() -> void:
161178
Config.effects_volume = settings["EffectsVolume"].value
162179
Config.voice_volume = settings["VoiceVolume"].value
163180

164-
var saved = Config.save_config()
181+
var saved: int = Config.save_config()
165182
if saved != OK:
166183
print("Could not save config!")
167184

Assets/World/Global.gd

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,4 +336,15 @@ func _input(event: InputEvent) -> void:
336336

337337
#Input.set_mouse_mode(Input.MOUSE_MODE_CONFINED)
338338
elif event.is_action_pressed("quit_game"):
339-
get_tree().quit()
339+
if PlatformPolicy.should_hide_application_quit_controls():
340+
return
341+
get_viewport().set_input_as_handled()
342+
Notice230ConfirmModal.present(
343+
get_tree(),
344+
Notice230ConfirmModal.QUIT_CONFIRM_TITLE,
345+
Notice230ConfirmModal.QUIT_CONFIRM_MESSAGE,
346+
Callable(self, "_quit_game_after_confirm"),
347+
)
348+
349+
func _quit_game_after_confirm() -> void:
350+
get_tree().quit()

Assets/World/PlatformPolicy.gd

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class_name PlatformPolicy
2+
extends RefCounted
3+
4+
## App Store and Google Play commonly reject builds that expose an in-game control to
5+
## terminate the process (even without a confirmation). Do not ship Quit UI on phone OS.
6+
static func should_hide_application_quit_controls() -> bool:
7+
var os_name: String = OS.get_name()
8+
if os_name == "iOS" or os_name == "Android":
9+
return true
10+
return OS.has_feature("mobile")

Assets/World/PlatformPolicy.gd.uid

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
uid://f5esnf3t60xt

0 commit comments

Comments
 (0)