-
Notifications
You must be signed in to change notification settings - Fork 313
Expand file tree
/
Copy pathgame_state.gd
More file actions
440 lines (335 loc) · 14.1 KB
/
game_state.gd
File metadata and controls
440 lines (335 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
# gdlint: disable=max-public-methods
# SPDX-FileCopyrightText: The Threadbare Authors
# SPDX-License-Identifier: MPL-2.0
extends Node
## Emitted when a new item is collected, even if it wasn't added to the
## inventory due to it being already there.
signal item_collected(item: InventoryItem)
## Emitted when a item is consumed, causing it to be removed from the
## [member inventory].
signal item_consumed(item: InventoryItem)
## Emitted whenever the items in the inventory change, either by collecting
## or consuming an item.
signal collected_items_changed(updated_items: Array[InventoryItem])
## Emitted when the player's lives change.
signal lives_changed(new_lives: int)
## Emitted when a [Trinket] is added to the collection.
signal trinket_collected(trinket: Trinket)
## Emitted when a [Trinket] is removed from the collection.
signal trinket_removed(trinket: Trinket)
## Emitted when it becomes too dark that artificial lights can turn on, or
## when darkness goes away so artificial lights should turn off.
signal lights_changed(lights_on: bool, immediate: bool)
## Emitted when a quest is added or removed from [member completed_quests].
signal completed_quests_changed
const GAME_STATE_PATH := "user://game_state.cfg"
const INVENTORY_SECTION := "inventory"
const INVENTORY_ITEMS_KEY := "items_collected"
const QUEST_SECTION := "quest"
const QUEST_PATH_KEY := "resource_path"
const QUEST_CURRENTSCENE_KEY := "current_scene"
const QUEST_SPAWNPOINT_KEY := "current_spawn_point"
const QUEST_CHALLENGE_START_KEY := "challenge_start_scene"
const GLOBAL_SECTION := "global"
const GLOBAL_INCORPORATING_THREADS_KEY := "incorporating_threads"
const COMPLETED_QUESTS_KEY := "completed_quests"
const LIVES_KEY := "current_lives"
const TRINKETS_SECTION := "trinkets"
const TRINKETS_IDS_KEY := "collected_ids"
const MAX_LIVES := 0x7fffffffffffffff
const DEBUG_LIVES := false
## Scenes to skip from saving.
const TRANSIENT_SCENES := [
"res://scenes/menus/title/title_screen.tscn",
"res://scenes/menus/intro/intro.tscn",
]
## Global inventory, used to track the items the player obtains and that
## can be added to the loom.
@export var inventory: Array[InventoryItem] = []
@export var current_spawn_point: NodePath
## Trinkets the player has collected. Persisted independently from quest state.
var trinkets: Array[Trinket] = []
## Current number of lives the player has.
var current_lives: int = MAX_LIVES
## Current state of artificial lights.
var lights_on: bool
## Set when the loom transports the player to a trio of Sokoban puzzles, so that
## when the player returns to Fray's End the loom can trigger a brief cutscene.
var incorporating_threads: bool = false
## Set when any introductory dialogue has been played for the current scene.
## Cleared when the scene changes.
var intro_dialogue_shown: bool = false
## The paths to the [Quest]s that the player has completed, in the order that they were completed.
var completed_quests: Array[String] = []
## The quest that the player is currently playing, or [code]null[/code] if they
## are not playing a quest. Update this with [method start_quest], [method
## mark_quest_completed] and [method abandon_quest].
var current_quest: Quest
var persist_progress: bool
var _state := ConfigFile.new()
func _ready() -> void:
var current_scene := get_tree().current_scene
var initial_scene_uid := (
ResourceLoader.get_resource_uid(current_scene.scene_file_path) if current_scene else -1
)
var main_scene_uid := ResourceLoader.get_resource_uid(
ProjectSettings.get_setting("application/run/main_scene")
)
persist_progress = initial_scene_uid == main_scene_uid
if not persist_progress:
if current_scene:
guess_quest(current_scene.scene_file_path)
return
var err := _state.load(GAME_STATE_PATH)
if err != OK and err != ERR_FILE_NOT_FOUND:
push_error("Failed to load %s: %s" % [GAME_STATE_PATH, err])
if DEBUG_LIVES:
prints("[LIVES DEBUG] GameState initialized with", current_lives, "lives")
## Set the [member incorporating_threads] flag.
func set_incorporating_threads(new_incorporating_threads: bool) -> void:
incorporating_threads = new_incorporating_threads
_state.set_value(GLOBAL_SECTION, GLOBAL_INCORPORATING_THREADS_KEY, incorporating_threads)
_save()
## Set [member current_quest] and clear the [member inventory].
## Also resets lives to maximum when starting a quest.
func start_quest(quest: Quest) -> void:
current_quest = quest
_state.set_value(QUEST_SECTION, QUEST_PATH_KEY, quest.resource_path)
_do_set_scene(quest.first_scene, ^"")
# Set the challenge start scene to the first scene of the quest
_state.set_value(QUEST_SECTION, QUEST_CHALLENGE_START_KEY, quest.first_scene)
# Reset lives when starting a new quest
reset_lives()
_save()
## Guess which quest the given scene is part of, and set [member current_quest]
## accordingly. If the quest cannot be determined, unset [member current_quest].
## [br][br]
## This is for use when jumping to a particular scene during development (e.g.
## with F6 in the editor, the URL hash in the browser, or in future if we add a
## level selector). During normal gameplay it should not be used.
func guess_quest(scene_path_or_uid: String) -> void:
var scene_path := ResourceUID.ensure_path(scene_path_or_uid)
var dir_path := scene_path.get_base_dir()
while dir_path != "res://":
var quest_path := dir_path.path_join("quest.tres")
if ResourceLoader.exists(quest_path, "Resource"):
current_quest = ResourceLoader.load(quest_path) as Quest
prints("Guessed quest", current_quest.resource_path, "from scene", scene_path)
return
dir_path = dir_path.get_base_dir()
current_quest = null
## Set the scene path and [member current_spawn_point].
func set_scene(scene_path: String, spawn_point: NodePath = ^"") -> void:
if scene_path in TRANSIENT_SCENES:
return
_do_set_scene(scene_path, spawn_point)
_save()
## Set the current spawn point and save it.
func set_current_spawn_point(spawn_point: NodePath = ^"") -> void:
current_spawn_point = spawn_point
_state.set_value(QUEST_SECTION, QUEST_SPAWNPOINT_KEY, current_spawn_point)
_save()
## Set the challenge start scene. This is the scene the player returns to
## when they run out of lives.
func set_challenge_start_scene(scene_path: String) -> void:
_state.set_value(QUEST_SECTION, QUEST_CHALLENGE_START_KEY, scene_path)
if DEBUG_LIVES:
prints("[LIVES DEBUG] Challenge start set to:", scene_path)
_save()
## Get the challenge start scene, or the first scene of the current quest
## if no challenge start has been set.
func get_challenge_start_scene() -> String:
var challenge_start: String = _state.get_value(QUEST_SECTION, QUEST_CHALLENGE_START_KEY, "")
if challenge_start.is_empty() and current_quest:
challenge_start = current_quest.first_scene
if DEBUG_LIVES:
prints(
"[LIVES DEBUG] No challenge start set, using quest first scene:", challenge_start
)
return challenge_start
## Returns [code]true[/code] if the player is currently on a quest; i.e. if
## [member current_quest] is not [code]null[/code].
func is_on_quest() -> bool:
return current_quest != null
## Clear all quest-related state from the config file.
func _clear_quest_state() -> void:
if _state.has_section(QUEST_SECTION):
_state.erase_section(QUEST_SECTION)
## If [member current_quest] is set, record this quest as having been completed,
## and unset it. Also resets lives to maximum.
func mark_quest_completed() -> void:
if current_quest:
_do_set_quest_completed_state(current_quest, true)
current_quest = null
_clear_quest_state()
_save()
## Set the scene path and [member current_spawn_point] without triggering a save.
func _do_set_scene(scene_path: String, spawn_point: NodePath = ^"") -> void:
if get_scene_to_restore() != scene_path:
intro_dialogue_shown = false
current_spawn_point = spawn_point
_state.set_value(QUEST_SECTION, QUEST_CURRENTSCENE_KEY, scene_path)
_state.set_value(QUEST_SECTION, QUEST_SPAWNPOINT_KEY, current_spawn_point)
## Add the [InventoryItem] to the [member inventory].
func add_collected_item(item: InventoryItem) -> void:
inventory.append(item)
item_collected.emit(item)
collected_items_changed.emit(items_collected())
_update_inventory_state()
_save()
## If [member current_quest] is set, unset it, without recording the quest as
## having been completed. Also resets lives to maximum.
func abandon_quest() -> void:
set_incorporating_threads(false)
_clear_quest_state()
current_quest = null
## Updates [member completed_quests] to include [param quest] if [param
## is_completed] is true, or remove [param quest] if [param is_completed] is
## false.
func set_quest_completed_state(quest: Quest, is_completed: bool) -> void:
_do_set_quest_completed_state(quest, is_completed)
_save()
func _do_set_quest_completed_state(quest: Quest, is_completed: bool) -> void:
var quest_name := quest.resource_path
if is_completed:
if quest_name not in completed_quests:
completed_quests.append(quest_name)
completed_quests_changed.emit()
else:
while quest_name in completed_quests:
completed_quests.erase(quest_name)
completed_quests_changed.emit()
## Remove all [InventoryItem] from the [member inventory].
func clear_inventory() -> void:
_do_clear_inventory()
_update_inventory_state()
_save()
## Remove all [InventoryItem] from the [member inventory] without triggering a save.
func _do_clear_inventory() -> void:
for item: InventoryItem in inventory.duplicate():
inventory.erase(item)
item_consumed.emit(item)
collected_items_changed.emit(items_collected())
## Return all the items collected so far in the [member inventory].
func items_collected() -> Array[InventoryItem]:
return inventory.duplicate()
## Add a [Trinket] to the collection if not already present.
func add_trinket(trinket: Trinket) -> void:
if has_trinket(trinket.id):
return
trinkets.append(trinket)
trinket_collected.emit(trinket)
_update_trinkets_state()
_save()
## Remove a [Trinket] from the collection.
func remove_trinket(trinket: Trinket) -> void:
trinkets.erase(trinket)
trinket_removed.emit(trinket)
_update_trinkets_state()
_save()
## Returns [code]true[/code] if a trinket with the given [param id] has been collected.
func has_trinket(id: StringName) -> bool:
return trinkets.any(func(t: Trinket) -> bool: return t.id == id)
func _trinket_to_dict(t: Trinket) -> Dictionary:
return {
"id": t.id,
"name": t.name,
"description": t.description,
"icon": t.icon.resource_path if t.icon else "",
"full_text": t.full_text,
}
func _update_trinkets_state() -> void:
_state.set_value(TRINKETS_SECTION, TRINKETS_IDS_KEY, trinkets.map(_trinket_to_dict))
func _update_inventory_state() -> void:
_state.set_value(
INVENTORY_SECTION,
INVENTORY_ITEMS_KEY,
inventory.map(func(i: InventoryItem) -> InventoryItem.ItemType: return i.type)
)
## Decrement the player's lives by 1. Does not go below 0.
## Saves the new lives count.
func decrement_lives() -> void:
current_lives = max(0, current_lives - 1)
_state.set_value(GLOBAL_SECTION, LIVES_KEY, current_lives)
_save()
lives_changed.emit(current_lives)
if DEBUG_LIVES:
prints("[LIVES DEBUG] Lives decremented to:", current_lives)
## Reset the player's lives to maximum (3).
## Saves the new lives count.
func reset_lives() -> void:
current_lives = MAX_LIVES
_state.set_value(GLOBAL_SECTION, LIVES_KEY, current_lives)
_save()
lives_changed.emit(current_lives)
if DEBUG_LIVES:
prints("[LIVES DEBUG] Lives reset to:", current_lives)
## Add one life to the player, up to the maximum.
## This is for future "extra life" pickups.
func add_life() -> void:
if current_lives < MAX_LIVES:
current_lives += 1
_state.set_value(GLOBAL_SECTION, LIVES_KEY, current_lives)
_save()
lives_changed.emit(current_lives)
if DEBUG_LIVES:
prints("[LIVES DEBUG] Life added. Lives now:", current_lives)
func change_lights(new_lights_on: bool, immediate: bool = false) -> void:
lights_on = new_lights_on
lights_changed.emit(lights_on, immediate)
## Clear the persisted state.
func clear() -> void:
_state.clear()
completed_quests = []
clear_inventory()
trinkets.clear()
current_lives = MAX_LIVES
if DEBUG_LIVES:
prints("[LIVES DEBUG] State cleared. Lives reset to:", current_lives)
_save()
## Check if there is persisted state.
func can_restore() -> bool:
return _state.get_sections().size()
## If there is a scene to restore, return it.
func get_scene_to_restore() -> String:
return _state.get_value(QUEST_SECTION, QUEST_CURRENTSCENE_KEY, "")
## Restore the persisted state.
func restore() -> Dictionary:
inventory.clear()
for item_type: InventoryItem.ItemType in _state.get_value(
INVENTORY_SECTION, INVENTORY_ITEMS_KEY, []
):
var item := InventoryItem.with_type(item_type)
inventory.append(item)
if _state.has_section_key(QUEST_SECTION, QUEST_PATH_KEY):
current_quest = load(_state.get_value(QUEST_SECTION, QUEST_PATH_KEY)) as Quest
var scene_path: String = _state.get_value(QUEST_SECTION, QUEST_CURRENTSCENE_KEY, "")
current_spawn_point = _state.get_value(QUEST_SECTION, QUEST_SPAWNPOINT_KEY, ^"")
incorporating_threads = _state.get_value(
GLOBAL_SECTION, GLOBAL_INCORPORATING_THREADS_KEY, false
)
completed_quests = _state.get_value(GLOBAL_SECTION, COMPLETED_QUESTS_KEY, [] as Array[String])
# Restore trinkets from saved data
trinkets.clear()
for trinket_data: Dictionary in _state.get_value(TRINKETS_SECTION, TRINKETS_IDS_KEY, []):
var trinket := Trinket.new()
trinket.id = trinket_data.get("id", &"")
trinket.name = trinket_data.get("name", "")
trinket.description = trinket_data.get("description", "")
var icon_path: String = trinket_data.get("icon", "")
if not icon_path.is_empty():
trinket.icon = load(icon_path)
trinket.full_text = trinket_data.get("full_text", "")
trinkets.append(trinket)
# Restore lives from saved state, default to MAX_LIVES if not found
current_lives = _state.get_value(GLOBAL_SECTION, LIVES_KEY, MAX_LIVES)
if DEBUG_LIVES:
prints("[LIVES DEBUG] State restored. Lives:", current_lives)
return {"scene_path": scene_path, "spawn_point": current_spawn_point}
func _save() -> void:
if not persist_progress:
return
_state.set_value(GLOBAL_SECTION, COMPLETED_QUESTS_KEY, completed_quests)
var err := _state.save(GAME_STATE_PATH)
if err != OK:
push_error("Failed to save settings to %s: %s" % [GAME_STATE_PATH, err])