]> Untitled Git - wolf-seeking-sheep.git/blob - addons/dialogic/Modules/Save/subsystem_save.gd
Squashed commit of the following:
[wolf-seeking-sheep.git] / addons / dialogic / Modules / Save / subsystem_save.gd
1 extends DialogicSubsystem
2 ## Subsystem to save and load game states.
3 ##
4 ## This subsystem has many different helper methods to save Dialogic or custom
5 ## game data to named save slots.
6 ##
7 ## You can listen to saves via [signal saved]. \
8 ## If you want to save, you can call [method save]. \
9
10
11 ## Emitted when a save happened with the following info:
12 ## [br]
13 ## Key           |   Value Type  | Value [br]
14 ## -----------   | ------------- | ----- [br]
15 ## `slot_name`   | [type String] | The name of the slot that the game state was saved to. [br]
16 ## `is_autosave` | [type bool]   | `true`, if the save was an autosave. [br]
17 signal saved(info: Dictionary)
18
19
20 ## The directory that will be saved to.
21 const SAVE_SLOTS_DIR := "user://dialogic/saves/"
22
23 ## The project settings key for the auto-save enabled settings.
24 const AUTO_SAVE_SETTINGS := "dialogic/save/autosave"
25
26 ## The project settings key for the auto-save mode settings.
27 const AUTO_SAVE_MODE_SETTINGS := "dialogic/save/autosave_mode"
28
29 ## The project settings key for the auto-save delay settings.
30 const AUTO_SAVE_TIME_SETTINGS := "dialogic/save/autosave_delay"
31
32 ## Temporarily stores a taken screen capture when using [take_slot_image()].
33 enum ThumbnailMode {NONE, TAKE_AND_STORE, STORE_ONLY}
34 var latest_thumbnail: Image = null
35
36
37 ## The different types of auto-save triggers.
38 ## If one of these occurs in the game, an auto-save may happen
39 ## if [member autosave_enabled] is `true`.
40 enum AutoSaveMode {
41         ## Includes timeline start, end, and jump events.
42         ON_TIMELINE_JUMPS = 0,
43         ## Saves after a certain time interval.
44         ON_TIMER = 1,
45         ## Saves after every text event.
46         ON_TEXT_EVENT = 2
47 }
48
49 ## Whether the auto-save feature is enabled.
50 ## The initial value can be set in the project settings via th Dialogic editor.
51 ##
52 ## This can be toggled during the game.
53 var autosave_enabled := false:
54         set(enabled):
55                 autosave_enabled = enabled
56
57                 if enabled:
58                         autosave_timer.start()
59                 else:
60                         autosave_timer.stop()
61
62
63 ## Under what conditions the auto-save feature will trigger if
64 ## [member autosave_enabled] is `true`.
65 var autosave_mode := AutoSaveMode.ON_TIMELINE_JUMPS
66
67 ## After what time interval the auto-save feature will trigger if
68 ## [member autosave_enabled] is `true` and [member autosave_mode] is
69 ## `AutoSaveMode.ON_TIMER`.
70 var autosave_time := 60:
71         set(timer_time):
72                 autosave_time = timer_time
73                 autosave_timer.wait_time = timer_time
74
75
76 #region STATE
77 ####################################################################################################
78
79 ## Built-in, called by DialogicGameHandler.
80 func clear_game_state(_clear_flag := DialogicGameHandler.ClearFlags.FULL_CLEAR) -> void:
81         _make_sure_slot_dir_exists()
82
83
84 ## Built-in, called by DialogicGameHandler.
85 func pause() -> void:
86         autosave_timer.paused = true
87
88
89 ## Built-in, called by DialogicGameHandler.
90 func resume() -> void:
91         autosave_timer.paused = false
92
93 #endregion
94
95
96 #region MAIN METHODS
97 ####################################################################################################
98
99 ## Saves the current state to the given slot.
100 ## If no slot is given, the default slot is used. You can change this name in
101 ## the Dialogic editor.
102 ## If you want to save to the last used slot, you can get its slot name with the
103 ## [method get_latest_slot()] method.
104 func save(slot_name := "", is_autosave := false, thumbnail_mode := ThumbnailMode.TAKE_AND_STORE, slot_info := {}) -> Error:
105         # check if to save (if this is an autosave)
106         if is_autosave and !autosave_enabled:
107                 return OK
108
109         if slot_name.is_empty():
110                 slot_name = get_default_slot()
111
112         set_latest_slot(slot_name)
113
114         var save_error := save_file(slot_name, 'state.txt', dialogic.get_full_state())
115
116         if save_error:
117                 return save_error
118
119         if thumbnail_mode == ThumbnailMode.TAKE_AND_STORE:
120                 take_thumbnail()
121                 save_slot_thumbnail(slot_name)
122         elif thumbnail_mode == ThumbnailMode.STORE_ONLY:
123                 save_slot_thumbnail(slot_name)
124
125         if slot_info:
126                 set_slot_info(slot_name, slot_info)
127
128         saved.emit({"slot_name": slot_name, "is_autosave": is_autosave})
129         print('[Dialogic] Saved to slot "'+slot_name+'".')
130         return OK
131
132
133 ## Loads all info from the given slot in the DialogicGameHandler (Dialogic Autoload).
134 ## If no slot is given, the default slot is used.
135 ## To check if something is saved in that slot use has_slot().
136 ## If the slot does not exist, this method will fail.
137 func load(slot_name := "") -> Error:
138         if slot_name.is_empty(): slot_name = get_default_slot()
139
140         if !has_slot(slot_name):
141                 printerr("[Dialogic Error] Tried loading from invalid save slot '"+slot_name+"'.")
142                 return ERR_FILE_NOT_FOUND
143
144         var set_latest_error := set_latest_slot(slot_name)
145         if set_latest_error:
146                 push_error("[Dialogic Error]: Failed to store latest slot to global info. Error %d '%s'" % [set_latest_error, error_string(set_latest_error)])
147
148         var state: Dictionary = load_file(slot_name, 'state.txt', {})
149         dialogic.load_full_state(state)
150
151         if state.is_empty():
152                 return FAILED
153         else:
154                 return OK
155
156
157 ## Saves a variable to a file in the given slot.
158 ##
159 ## Be aware, the [param slot_name] will be used as a filesystem folder name.
160 ## Some operating systems do not support every character in folder names.
161 ## It is recommended to use only letters, numbers, and underscores.
162 ##
163 ## This method allows you to build your own save and load system.
164 ## You may be looking for the simple [method save] method to save the game state.
165 func save_file(slot_name: String, file_name: String, data: Variant) -> Error:
166         if slot_name.is_empty():
167                 slot_name = get_default_slot()
168
169         if slot_name.is_empty():
170                 push_error("[Dialogic Error]: No fallback slot name set.")
171                 return ERR_FILE_NOT_FOUND
172
173         if !has_slot(slot_name):
174                 add_empty_slot(slot_name)
175
176         var encryption_password := get_encryption_password()
177         var file: FileAccess
178
179         if encryption_password.is_empty():
180                 file = FileAccess.open(SAVE_SLOTS_DIR.path_join(slot_name).path_join(file_name), FileAccess.WRITE)
181         else:
182                 file = FileAccess.open_encrypted_with_pass(SAVE_SLOTS_DIR.path_join(slot_name).path_join(file_name), FileAccess.WRITE, encryption_password)
183
184         if file:
185                 file.store_var(data)
186                 return OK
187         else:
188                 var error := FileAccess.get_open_error()
189                 push_error("[Dialogic Error]: Could not save slot to file. Error: %d '%s'" % [error, error_string(error)])
190                 return error
191
192
193 ## Loads a file using [param slot_name] and returns the contained info.
194 ##
195 ## This method allows you to build your own save and load system.
196 ## You may be looking for the simple [method load] method to load the game state.
197 func load_file(slot_name: String, file_name: String, default: Variant) -> Variant:
198         if slot_name.is_empty(): slot_name = get_default_slot()
199
200         var path := get_slot_path(slot_name).path_join(file_name)
201         if FileAccess.file_exists(path):
202                 var encryption_password := get_encryption_password()
203                 var file: FileAccess
204
205                 if encryption_password.is_empty():
206                         file = FileAccess.open(path, FileAccess.READ)
207                 else:
208                         file = FileAccess.open_encrypted_with_pass(path, FileAccess.READ, encryption_password)
209
210                 if file:
211                         return file.get_var()
212                 else:
213                         push_error(FileAccess.get_open_error())
214         return default
215
216
217 ## Data set in global info can be accessed unrelated to the save slots.
218 ## For instance, you may want to store game settings in here, as they
219 ## affect the game globally unrelated to the slot used.
220 func set_global_info(key: String, value: Variant) -> Error:
221         var global_info := ConfigFile.new()
222         var encryption_password := get_encryption_password()
223
224         if encryption_password.is_empty():
225                 var load_error := global_info.load(SAVE_SLOTS_DIR.path_join('global_info.txt'))
226                 if load_error:
227                         printerr("[Dialogic Error]: Couldn't access global saved info file.")
228                         return load_error
229
230                 else:
231                         global_info.set_value('main', key, value)
232                         return global_info.save(SAVE_SLOTS_DIR.path_join('global_info.txt'))
233
234         else:
235                 var load_error := global_info.load_encrypted_pass(SAVE_SLOTS_DIR.path_join('global_info.txt'), encryption_password)
236                 if load_error:
237                         printerr("[Dialogic Error]: Couldn't access global saved info file.")
238                         return load_error
239
240                 else:
241                         global_info.set_value('main', key, value)
242                         return global_info.save_encrypted_pass(SAVE_SLOTS_DIR.path_join('global_info.txt'), encryption_password)
243
244
245 ## Access the data unrelated to a save slot.
246 ## First, the data must have been set with [method set_global_info].
247 func get_global_info(key: String, default: Variant) -> Variant:
248         var global_info := ConfigFile.new()
249         var encryption_password := get_encryption_password()
250
251         if encryption_password.is_empty():
252
253                 if global_info.load(SAVE_SLOTS_DIR.path_join('global_info.txt')) == OK:
254                         return global_info.get_value('main', key, default)
255
256                 printerr("[Dialogic Error]: Couldn't access global saved info file.")
257
258         elif global_info.load_encrypted_pass(SAVE_SLOTS_DIR.path_join('global_info.txt'), encryption_password) == OK:
259                 return global_info.get_value('main', key, default)
260
261         return default
262
263
264 ## Gets the encryption password from the project settings if it has been set.
265 ## If no password has been set, an empty string is returned.
266 func get_encryption_password() -> String:
267         if OS.is_debug_build() and ProjectSettings.get_setting('dialogic/save/encryption_on_exports_only', true):
268                 return ""
269         return ProjectSettings.get_setting("dialogic/save/encryption_password", "")
270
271 #endregion
272
273
274 #region SLOT HELPERS
275 ####################################################################################################
276 ## Returns a list of all available slots. Useful for iterating over all slots,
277 ## e.g., when building a UI with all save slots.
278 func get_slot_names() -> Array[String]:
279         var save_folders: Array[String] = []
280
281         if DirAccess.dir_exists_absolute(SAVE_SLOTS_DIR):
282                 var directory := DirAccess.open(SAVE_SLOTS_DIR)
283                 var _list_dir := directory.list_dir_begin()
284                 var file_name := directory.get_next()
285
286                 while not file_name.is_empty():
287
288                         if directory.current_is_dir() and not file_name.begins_with("."):
289                                 save_folders.append(file_name)
290
291                         file_name = directory.get_next()
292
293                 return save_folders
294
295         return []
296
297
298 ## Returns true if the given slot exists.
299 func has_slot(slot_name: String) -> bool:
300         if slot_name.is_empty():
301                 slot_name = get_default_slot()
302
303         return slot_name in get_slot_names()
304
305
306 ## Removes all the given slot along with all it's info/files.
307 func delete_slot(slot_name: String) -> Error:
308         var path := SAVE_SLOTS_DIR.path_join(slot_name)
309
310         if DirAccess.dir_exists_absolute(path):
311                 var directory := DirAccess.open(path)
312                 if not directory:
313                         return DirAccess.get_open_error()
314                 var _list_dir := directory.list_dir_begin()
315                 var file_name := directory.get_next()
316
317                 while not file_name.is_empty():
318                         var remove_error := directory.remove(file_name)
319                         if remove_error:
320                                 push_warning("[Dialogic Error]: Encountered error while removing '%s': %d\t%s" % [path.path_join(file_name), remove_error, error_string(remove_error)])
321                         file_name = directory.get_next()
322
323                 # Delete the folder.
324                 return directory.remove(SAVE_SLOTS_DIR.path_join(slot_name))
325
326         push_warning("[Dialogic Warning]: Save slot '%s' has already been deleted." % path)
327         return OK
328
329
330 ## This adds a new save folder with the given name
331 func add_empty_slot(slot_name: String) -> Error:
332         if DirAccess.dir_exists_absolute(SAVE_SLOTS_DIR):
333                 var directory := DirAccess.open(SAVE_SLOTS_DIR)
334                 if directory:
335                         return directory.make_dir(slot_name)
336                 return DirAccess.get_open_error()
337
338         push_error("[Dialogic Error]: Path to '%s' does not exist." % SAVE_SLOTS_DIR)
339         return ERR_FILE_BAD_PATH
340
341
342 ## Reset the state of the given save folder (or default)
343 func reset_slot(slot_name := "") -> Error:
344         if slot_name.is_empty():
345                 slot_name = get_default_slot()
346
347         return save_file(slot_name, 'state.txt', {})
348
349
350 ## Returns the full path to the given slot folder
351 func get_slot_path(slot_name: String) -> String:
352         return SAVE_SLOTS_DIR.path_join(slot_name)
353
354
355 ## Returns the default slot name defined in the dialogic settings
356 func get_default_slot() -> String:
357         return ProjectSettings.get_setting('dialogic/save/default_slot', 'Default')
358
359
360 ## Returns the latest slot or empty if nothing was saved yet
361 func get_latest_slot() -> String:
362         var latest_slot := ""
363
364         if Engine.get_main_loop().has_meta('dialogic_latest_saved_slot'):
365                 latest_slot = Engine.get_main_loop().get_meta('dialogic_latest_saved_slot', '')
366
367         else:
368                 latest_slot = get_global_info('latest_save_slot', '')
369                 Engine.get_main_loop().set_meta('dialogic_latest_saved_slot', latest_slot)
370
371
372         if !has_slot(latest_slot):
373                 return ''
374
375         return latest_slot
376
377
378 func set_latest_slot(slot_name:String) -> Error:
379         Engine.get_main_loop().set_meta('dialogic_latest_saved_slot', slot_name)
380         return set_global_info('latest_save_slot', slot_name)
381
382
383 func _make_sure_slot_dir_exists() -> Error:
384         if not DirAccess.dir_exists_absolute(SAVE_SLOTS_DIR):
385                 var make_dir_result := DirAccess.make_dir_recursive_absolute(SAVE_SLOTS_DIR)
386                 if make_dir_result:
387                         return make_dir_result
388
389         var global_info_path := SAVE_SLOTS_DIR.path_join('global_info.txt')
390
391         if not FileAccess.file_exists(global_info_path):
392                 var config := ConfigFile.new()
393                 var password := get_encryption_password()
394
395                 if password.is_empty():
396                         return config.save(global_info_path)
397
398                 else:
399                         return config.save_encrypted_pass(global_info_path, password)
400
401         return OK
402
403 #endregion
404
405
406 #region SLOT INFO
407 ####################################################################################################
408
409 func set_slot_info(slot_name:String, info: Dictionary) -> Error:
410         if slot_name.is_empty():
411                 slot_name = get_default_slot()
412
413         return save_file(slot_name, 'info.txt', info)
414
415
416 func get_slot_info(slot_name := "") -> Dictionary:
417         if slot_name.is_empty():
418                 slot_name = get_default_slot()
419
420         return load_file(slot_name, 'info.txt', {})
421
422 #endregion
423
424
425 #region SLOT IMAGE
426 ####################################################################################################
427
428 ## This method creates a thumbnail of the current game view, it allows to
429 ## save the game without having the UI on the save slot image.
430 ## The thumbnail will be stored in [member latest_thumbnail].
431 ##
432 ## Call this method before opening your save & load menu.
433 ## After that, call [method save] with [constant ThumbnailMode.STORE_ONLY].
434 ## The [method save] will automatically use the stored thumbnail.
435 func take_thumbnail() -> void:
436         latest_thumbnail = get_viewport().get_texture().get_image()
437
438
439 ## No need to call from outside.
440 ## Used to store the latest thumbnail to the given slot.
441 func save_slot_thumbnail(slot_name: String) -> Error:
442         if latest_thumbnail:
443                 var path := get_slot_path(slot_name).path_join('thumbnail.png')
444                 return latest_thumbnail.save_png(path)
445
446         push_warning("[Dialogic Warning]: No thumbnail has been set yet.")
447         return OK
448
449
450 ## Returns the thumbnail of the given slot.
451 func get_slot_thumbnail(slot_name: String) -> ImageTexture:
452         if slot_name.is_empty():
453                 slot_name = get_default_slot()
454
455         var path := get_slot_path(slot_name).path_join('thumbnail.png')
456
457         if FileAccess.file_exists(path):
458                 return ImageTexture.create_from_image(Image.load_from_file(path))
459
460         return null
461
462 #endregion
463
464
465 #region AUTOSAVE
466 ####################################################################################################
467 ## Reference to the autosave timer.
468 var autosave_timer := Timer.new()
469
470
471 func _ready() -> void:
472         autosave_timer.one_shot = true
473         DialogicUtil.update_timer_process_callback(autosave_timer)
474         autosave_timer.name = "AutosaveTimer"
475         var _result := autosave_timer.timeout.connect(_on_autosave_timer_timeout)
476         add_child(autosave_timer)
477
478         autosave_enabled = ProjectSettings.get_setting(AUTO_SAVE_SETTINGS, autosave_enabled)
479         autosave_mode = ProjectSettings.get_setting(AUTO_SAVE_MODE_SETTINGS, autosave_mode)
480         autosave_time = ProjectSettings.get_setting(AUTO_SAVE_TIME_SETTINGS, autosave_time)
481
482         _result = dialogic.event_handled.connect(_on_dialogic_event_handled)
483         _result = dialogic.timeline_started.connect(_on_start_or_end_autosave)
484         _result = dialogic.timeline_ended.connect(_on_start_or_end_autosave)
485
486         if autosave_enabled:
487                 autosave_timer.start(autosave_time)
488
489
490 func _on_autosave_timer_timeout() -> void:
491         if autosave_mode == AutoSaveMode.ON_TIMER:
492                 perform_autosave()
493
494         autosave_timer.start(autosave_time)
495
496
497 func _on_dialogic_event_handled(event: DialogicEvent) -> void:
498         if event is DialogicJumpEvent:
499
500                 if autosave_mode == AutoSaveMode.ON_TIMELINE_JUMPS:
501                         perform_autosave()
502
503         if event is DialogicTextEvent:
504
505                 if autosave_mode == AutoSaveMode.ON_TEXT_EVENT:
506                         perform_autosave()
507
508
509 func _on_start_or_end_autosave() -> void:
510         if autosave_mode == AutoSaveMode.ON_TIMELINE_JUMPS:
511                 perform_autosave()
512
513
514 ## Perform an autosave.
515 ## This method will be called automatically if the auto-save mode is enabled.
516 func perform_autosave() -> Error:
517         return save("", true)
518
519 #endregion