diff --git a/game/core/flag_manager.gd b/game/core/flag_manager.gd new file mode 100644 index 0000000..f7dd464 --- /dev/null +++ b/game/core/flag_manager.gd @@ -0,0 +1,201 @@ +extends Node +## Gestionnaire global de flags de progression. +## Un flag = un fait du monde identifié par un StringName. +## +## Types supportés : bool, int, String, float, Variant (fallback). +## +## Écriture : set_flag(), set_flag_once(), increment_flag() +## Lecture : get_flag(), has_flag(), is_flag_true() +## Conditions : evaluate_condition() pour tester des conditions composées. + +# Stockage : flag_name (StringName) -> valeur (Variant) +var _flags: Dictionary = {} + +# Flags marqués "one-shot" (ne peuvent être modifiés qu'une fois) +var _one_shot_flags: Dictionary = {} # flag_name -> true + +# --- Écriture --- + +## Définit un flag à une valeur. Écrase toujours. +func set_flag(flag_name: StringName, value: Variant) -> void: + var old_value: Variant = _flags.get(flag_name, null) + if old_value == value: + return + _flags[flag_name] = value + GameEvents.flag_changed.emit(flag_name, old_value, value) + _emit_legacy_signal(flag_name, value) + +## Définit un flag uniquement s'il n'a jamais été défini. +## Retourne true si le flag a effectivement été posé. +func set_flag_once(flag_name: StringName, value: Variant = true) -> bool: + if flag_name in _flags: + return false + set_flag(flag_name, value) + _one_shot_flags[flag_name] = true + return true + +## Incrémente un flag numérique. Crée le flag à 0 si inexistant. +func increment_flag(flag_name: StringName, amount: int = 1) -> void: + var current: int = int(_flags.get(flag_name, 0)) + set_flag(flag_name, current + amount) + +## Décrémente un flag numérique (utile pour consommation de ressources). +func decrement_flag(flag_name: StringName, amount: int = 1) -> void: + increment_flag(flag_name, -amount) + +## Supprime un flag (à utiliser avec prudence en dev). +func clear_flag(flag_name: StringName) -> void: + if flag_name in _flags: + var old_value: Variant = _flags[flag_name] + _flags.erase(flag_name) + GameEvents.flag_changed.emit(flag_name, old_value, null) + +# --- Lecture --- + +func get_flag(flag_name: StringName, default: Variant = null) -> Variant: + return _flags.get(flag_name, default) + +func has_flag(flag_name: StringName) -> bool: + return flag_name in _flags + +## Retourne true si le flag existe ET vaut `true` (booléen). +## Pour un int, retourne true si non-zéro. +func is_flag_true(flag_name: StringName) -> bool: + if not flag_name in _flags: + return false + var v: Variant = _flags[flag_name] + if v is bool: + return v + if v is int or v is float: + return v != 0 + return v != null + +func get_int(flag_name: StringName, default: int = 0) -> int: + return int(_flags.get(flag_name, default)) + +func get_bool(flag_name: StringName, default: bool = false) -> bool: + return bool(_flags.get(flag_name, default)) + +func get_string(flag_name: StringName, default: String = "") -> String: + return String(_flags.get(flag_name, default)) + +# --- Conditions composées --- + +## Évalue un dictionnaire de conditions : +## { +## &"prof_rencontre": true, # flag == true +## &"badges_gagnes": 3, # flag >= 3 (int) +## &"starter_choisi": "pikachu", # flag == "pikachu" +## } +## Retourne true si TOUTES les conditions sont satisfaites. +func evaluate_condition(conditions: Dictionary) -> bool: + for flag_name in conditions: + var expected: Variant = conditions[flag_name] + var actual: Variant = _flags.get(flag_name, null) + + # Booléen attendu : on teste la vérité (marche pour bool et int) + if expected is bool: + if is_flag_true(flag_name) != expected: + return false + # Int attendu : on teste >= (utile pour les compteurs) + elif expected is int: + if int(actual if actual != null else 0) < expected: + return false + # String / autre : égalité stricte + else: + if actual != expected: + return false + return true + +## Version "au moins une condition vraie" (OR). +func evaluate_any_condition(conditions: Dictionary) -> bool: + for flag_name in conditions: + var expected: Variant = conditions[flag_name] + var actual: Variant = _flags.get(flag_name, null) + if expected is bool: + if is_flag_true(flag_name) == expected: + return true + elif expected is int: + if int(actual if actual != null else 0) >= expected: + return true + else: + if actual == expected: + return true + return false + +# --- Signaux spécifiques (optionnel mais pratique) --- + +## Émet des signaux dédiés pour certains flags pour simplifier les connexions. +## Exemple : GameEvents.badge_gagne.emit() si le flag "badges_gagnes" change. +func _emit_legacy_signal(flag_name: StringName, value: Variant) -> void: + # Ajoute ici tes mappings custom si tu veux des signaux dédiés. + # Ex : + # if flag_name == &"badges_gagnes": + # GameEvents.badge_count_changed.emit(int(value)) + pass + +# --- Connexion automatique aux événements du jeu --- + +func _ready() -> void: + _connect_gameplay_events() + +func _connect_gameplay_events() -> void: + GameEvents.pokemon_captured.connect(_on_pokemon_captured) + GameEvents.pokemon_defeated.connect(_on_pokemon_defeated) + GameEvents.item_collected.connect(_on_item_collected) + GameEvents.npc_talked.connect(_on_npc_talked) + GameEvents.location_reached.connect(_on_location_reached) + +func _on_pokemon_captured(species_id: StringName) -> void: + increment_flag(&"nb_pokemon_captures", 1) + set_flag_once(&"premier_pokemon_capture", true) + # Flag spécifique par espèce : ex. "capture_chenipan" = true + set_flag(StringName("capture_%s" % species_id), true) + increment_flag(StringName("nb_captures_%s" % species_id), 1) + +func _on_pokemon_defeated(species_id: StringName) -> void: + increment_flag(&"nb_pokemon_vaincus", 1) + increment_flag(StringName("nb_vaincus_%s" % species_id), 1) + +func _on_item_collected(item_id: StringName, amount: int) -> void: + increment_flag(StringName("item_%s" % item_id), amount) + +func _on_npc_talked(npc_id: StringName) -> void: + set_flag(StringName("talked_%s" % npc_id), true) + increment_flag(StringName("nb_dialogues_%s" % npc_id), 1) + +func _on_location_reached(location_id: StringName) -> void: + set_flag(StringName("visited_%s" % location_id), true) + +# --- Sauvegarde / Chargement --- + +func get_save_data() -> Dictionary: + return { + "flags": _flags.duplicate(true), + "one_shot": _one_shot_flags.duplicate(), + } + +func load_save_data(data: Dictionary) -> void: + _flags = data.get("flags", {}).duplicate(true) + _one_shot_flags = data.get("one_shot", {}).duplicate() + +# --- Debug --- + +func debug_print_all() -> void: + print("\n=== FLAGS ===") + if _flags.is_empty(): + print("(aucun flag)") + else: + var keys: Array = _flags.keys() + keys.sort() + for flag_name in keys: + var v: Variant = _flags[flag_name] + var type_str := type_string(typeof(v)) + var one_shot := " [one-shot]" if flag_name in _one_shot_flags else "" + print(" %s = %s (%s)%s" % [flag_name, str(v), type_str, one_shot]) + print("=============\n") + +func debug_clear_all() -> void: + _flags.clear() + _one_shot_flags.clear() + print("[FlagManager] Tous les flags ont été effacés.") diff --git a/game/core/flag_manager.gd.uid b/game/core/flag_manager.gd.uid new file mode 100644 index 0000000..5da1186 --- /dev/null +++ b/game/core/flag_manager.gd.uid @@ -0,0 +1 @@ +uid://ceb0ky3nlq1do diff --git a/game/core/game_data.gd b/game/core/game_data.gd index 6448036..f6cd8c9 100644 --- a/game/core/game_data.gd +++ b/game/core/game_data.gd @@ -49,6 +49,11 @@ func _set_selected(index : int) -> void: func save() -> void: var config := ConfigFile.new() + + config.set_value("game", "version", 1) + config.set_value("game", "timestamp", Time.get_datetime_string_from_system()) + config.set_value("flags", "flags", FlagManager.get_save_data()) + config.set_value("quests", "quests", QuestManager.get_save_data()) config.set_value("progress", "story_progress", story_progress) config.set_value("team", "data", player_team.to_dict()) config.set_value("storage", "data", player_storage.to_dict()) @@ -62,3 +67,7 @@ func load_data() -> void: player_team = PokemonTeam.from_dict(team_data) if team_data is Dictionary else PokemonTeam.new() var storage_data : Variant = config.get_value("storage", "data", {}) player_storage = PokemonStorage.from_dict(storage_data) if storage_data is Dictionary else PokemonStorage.new() + var flags_data : Variant = config.get_value("flags", "flags", {}) + FlagManager.load_save_data(flags_data) + var quests_data : Variant = config.get_value("quests", "quests", {}) + QuestManager.load_save_data(quests_data) diff --git a/game/core/game_events.gd b/game/core/game_events.gd new file mode 100644 index 0000000..aa5eb2a --- /dev/null +++ b/game/core/game_events.gd @@ -0,0 +1,17 @@ +extends Node + +# --- Quêtes (existants) --- +signal quest_started(quest_id: StringName) +signal quest_completed(quest_id: StringName) +signal quest_available(quest_id: StringName) +signal quest_objective_updated(quest_id: StringName, objective_index: int, current: int, required: int) + +# --- Gameplay (existants) --- +signal pokemon_captured(species_id: StringName) +signal pokemon_defeated(species_id: StringName) +signal item_collected(item_id: StringName, amount: int) +signal npc_talked(npc_id: StringName) +signal location_reached(location_id: StringName) + +# --- Flags (nouveau) --- +signal flag_changed(flag_name: StringName, old_value: Variant, new_value: Variant) diff --git a/game/core/game_events.gd.uid b/game/core/game_events.gd.uid new file mode 100644 index 0000000..bcfda69 --- /dev/null +++ b/game/core/game_events.gd.uid @@ -0,0 +1 @@ +uid://ct3v0qsc3bvcn diff --git a/game/core/quest_manager.gd b/game/core/quest_manager.gd new file mode 100644 index 0000000..be8c119 --- /dev/null +++ b/game/core/quest_manager.gd @@ -0,0 +1,263 @@ +extends Node + +var all_quests: Dictionary = {} # quest_id -> QuestData +var active_quests: Dictionary = {} # quest_id -> QuestData +var completed_quests: Dictionary = {} # quest_id -> QuestData +var failed_quests: Dictionary = {} + +const QUESTS_PATH := "res://game/quests/data/" + +func _ready() -> void: + _load_all_quests() + _connect_events() + _refresh_quest_states() + + GameEvents.flag_changed.connect(_on_flag_changed) + +func _on_flag_changed(_flag_name: StringName, _old_value: Variant, _new_value: Variant) -> void: + _refresh_quest_states() + +# --- Chargement --- + +func _load_all_quests() -> void: + var dir := DirAccess.open(QUESTS_PATH) + if dir == null: + push_error("Dossier de quêtes introuvable : " + QUESTS_PATH) + return + for file in dir.get_files(): + if file.ends_with(".tres"): + var quest: QuestData = load(QUESTS_PATH + file) + if quest and quest.quest_id != &"": + all_quests[quest.quest_id] = quest + +# --- Calcul d'état (cœur du système de chaînes) --- + +## Recalcule l'état de toutes les quêtes non-commencées : +## une quête passe de LOCKED à AVAILABLE si ses prérequis sont satisfaits. +func _refresh_quest_states() -> void: + for quest_id in all_quests: + var quest: QuestData = all_quests[quest_id] + if quest.state in [QuestData.QuestState.ACTIVE, QuestData.QuestState.COMPLETED, QuestData.QuestState.FAILED]: + continue + if _are_prerequisites_met(quest): + if quest.state == QuestData.QuestState.LOCKED: + quest.state = QuestData.QuestState.AVAILABLE + _emit_quest_available(quest_id) + else: + quest.state = QuestData.QuestState.LOCKED + +func _are_prerequisites_met(quest: QuestData) -> bool: + # 1. Prérequis de quêtes + for prereq in quest.prerequisites: + if not prereq in completed_quests: + return false + + # 2. Flags bloquants (si l'un est vrai → la quête ne peut PAS se débloquer) + if not quest.blocking_flags.is_empty(): + if FlagManager.evaluate_condition(quest.blocking_flags): + return false + + # 3. Flags requis (tous doivent être vrais) + if not quest.required_flags.is_empty(): + if not FlagManager.evaluate_condition(quest.required_flags): + return false + + return true + +func _emit_quest_available(quest_id: StringName) -> void: + # Optionnel : signal pour notifier l'UI ou un PNJ + GameEvents.quest_available.emit(quest_id) + +# --- Connexion aux événements --- + +func _connect_events() -> void: + GameEvents.pokemon_captured.connect(_on_pokemon_captured) + GameEvents.pokemon_defeated.connect(_on_pokemon_defeated) + GameEvents.item_collected.connect(_on_item_collected) + GameEvents.npc_talked.connect(_on_npc_talked) + GameEvents.location_reached.connect(_on_location_reached) + +# --- Démarrage de quête --- + +func start_quest(quest_id: StringName, force: bool = false) -> bool: + if quest_id in active_quests or quest_id in completed_quests: + return false + if not quest_id in all_quests: + push_warning("Quête inconnue : " + str(quest_id)) + return false + + var quest: QuestData = all_quests[quest_id] + + # Vérifie les prérequis (sauf si forcé, pour debug) + if not force and not _are_prerequisites_met(quest): + return false + + # Reset les compteurs + for obj in quest.objectives: + obj.current_count = 0 + + quest.state = QuestData.QuestState.ACTIVE + active_quests[quest_id] = quest + GameEvents.quest_started.emit(quest_id) + return true + +## Démarre automatiquement toutes les quêtes disponibles qui n'attendent +## pas de déblocage manuel. À appeler après un chargement de zone, par ex. +func auto_start_available_quests() -> void: + for quest_id in all_quests: + var quest: QuestData = all_quests[quest_id] + if quest.state == QuestData.QuestState.AVAILABLE and not quest.requires_manual_unlock: + start_quest(quest_id) + +# --- Progression --- + +func _progress_objective(event_type: QuestObjective.Type, target: StringName) -> void: + for quest_id in active_quests.keys(): + var quest: QuestData = active_quests[quest_id] + var changed := false + + for i in quest.objectives.size(): + var obj: QuestObjective = quest.objectives[i] + if obj.is_complete(): + continue + if obj.matches(event_type, target): + obj.current_count = min(obj.current_count + 1, obj.required_count) + changed = true + GameEvents.quest_objective_updated.emit( + quest_id, i, obj.current_count, obj.required_count + ) + + if changed and quest.are_all_objectives_complete(): + _complete_quest(quest_id) + +# --- Handlers d'événements --- + +func _on_pokemon_captured(species_id: StringName) -> void: + _progress_objective(QuestObjective.Type.CAPTURE, species_id) + +func _on_pokemon_defeated(species_id: StringName) -> void: + _progress_objective(QuestObjective.Type.DEFEAT, species_id) + +func _on_item_collected(item_id: StringName, amount: int) -> void: + # On avance de `amount` unités + for quest_id in active_quests.keys(): + var quest: QuestData = active_quests[quest_id] + for i in quest.objectives.size(): + var obj: QuestObjective = quest.objectives[i] + if obj.type == QuestObjective.Type.COLLECT \ + and obj.target_id == item_id \ + and not obj.is_complete(): + obj.current_count = min(obj.current_count + amount, obj.required_count) + GameEvents.quest_objective_updated.emit( + quest_id, i, obj.current_count, obj.required_count + ) + if quest.are_all_objectives_complete(): + _complete_quest(quest_id) + +func _on_npc_talked(npc_id: StringName) -> void: + _progress_objective(QuestObjective.Type.TALK_TO, npc_id) + +func _on_location_reached(location_id: StringName) -> void: + _progress_objective(QuestObjective.Type.REACH_LOCATION, location_id) + +# --- Fin de quête --- + +func _complete_quest(quest_id: StringName) -> void: + var quest: QuestData = active_quests[quest_id] + quest.state = QuestData.QuestState.COMPLETED + active_quests.erase(quest_id) + completed_quests[quest_id] = quest + + _give_rewards(quest) + GameEvents.quest_completed.emit(quest_id) + + # 🔑 C'est ici que la magie opère : les quêtes suivantes se débloquent ! + _refresh_quest_states() + + # Optionnel : auto-démarrer les quêtes suivantes qui ne demandent + # pas de déblocage manuel (ex: chaîne principale purement scriptée) + auto_start_available_quests() + +func _give_rewards(quest: QuestData) -> void: + for reward in quest.rewards: + match reward.type: + QuestReward.Type.ITEM: + # À adapter à ton système d'inventaire + GameEvents.item_collected.emit(reward.target_id, reward.amount) + QuestReward.Type.MONEY: + # Inventory.add_money(reward.amount) + pass + QuestReward.Type.POKEMON: + # Team.add_pokemon(reward.target_id) + pass + QuestReward.Type.UNLOCK_QUEST: + start_quest(reward.target_id) + +# --- Sauvegarde / Chargement --- + +func get_save_data() -> Dictionary: + var data := { + "active": {}, # quest_id -> Array[int] (progressions) + "completed": [], # Array[StringName] + "failed": [] + } + for id in active_quests: + var quest: QuestData = active_quests[id] + var counts: Array[int] = [] + for obj in quest.objectives: + counts.append(obj.current_count) + data["active"][id] = counts + data["completed"] = completed_quests.keys() + data["failed"] = failed_quests.keys() + return data + +func load_save_data(data: Dictionary) -> void: + active_quests.clear() + completed_quests.clear() + failed_quests.clear() + + # Reset tous les états à LOCKED + for q in all_quests.values(): + q.state = QuestData.QuestState.LOCKED + for obj in q.objectives: + obj.current_count = 0 + + # Restaure les complétées + for id in data.get("completed", []): + if id in all_quests: + all_quests[id].state = QuestData.QuestState.COMPLETED + completed_quests[id] = all_quests[id] + + # Restaure les actives + for id in data.get("active", {}): + if id in all_quests: + var quest: QuestData = all_quests[id] + var counts: Array = data["active"][id] + for i in quest.objectives.size(): + quest.objectives[i].current_count = counts[i] if i < counts.size() else 0 + quest.state = QuestData.QuestState.ACTIVE + active_quests[id] = quest + + # Recalcule les LOCKED / AVAILABLE + _refresh_quest_states() + +func debug_print_all() -> void: + print("\n=== ÉTAT DES QUÊTES ===") + for quest_id in all_quests: + var q: QuestData = all_quests[quest_id] + var state_str := q.get_state_name() + var cat_str := q.get_category_name() + + print("\n[%s][%s] %s — %s" % [cat_str, state_str, q.quest_id, q.title]) + for i in q.objectives.size(): + var obj := q.objectives[i] + var done := "✓" if obj.is_complete() else " " + var optional := " (optionnel)" if obj.is_optional else "" + print(" [%s] %s : %d/%d%s" % [ + done, obj.description, obj.current_count, obj.required_count, optional + ]) + print("\n=======================\n") + +func _input(event): + if event.is_action_pressed("debug_quests"): + QuestManager.debug_print_all() diff --git a/game/core/quest_manager.gd.uid b/game/core/quest_manager.gd.uid new file mode 100644 index 0000000..04a15e7 --- /dev/null +++ b/game/core/quest_manager.gd.uid @@ -0,0 +1 @@ +uid://owgkeygdlsmv diff --git a/game/quests/quest_data.gd b/game/quests/quest_data.gd new file mode 100644 index 0000000..f48ab8f --- /dev/null +++ b/game/quests/quest_data.gd @@ -0,0 +1,41 @@ +extends Resource +class_name QuestData + +enum QuestState { LOCKED, AVAILABLE, ACTIVE, COMPLETED, FAILED } +enum Category { MAIN, SIDE, HIDDEN } + +@export var quest_id: StringName = &"" +@export var title: String = "" +@export_multiline var description: String = "" +@export var category: Category = Category.SIDE +@export var objectives: Array[QuestObjective] = [] +@export var rewards: Array[QuestReward] = [] +@export var prerequisites: Array[StringName] = [] +@export var requires_manual_unlock: bool = false +@export var chapter_index: int = 0 + +## NOUVEAU : conditions sur les flags pour que la quête soit disponible. +## Exemple : +## { &"prof_rencontre": true, &"badges_gagnes": 3 } +@export var required_flags: Dictionary = {} + +## NOUVEAU : si l'un de ces flags est vrai, la quête ne se débloque PAS. +## Utile pour les choix exclusifs (ex: avoir choisi la Team Rocket). +@export var blocking_flags: Dictionary = {} + +var state: QuestState = QuestState.LOCKED + +func get_required_objectives() -> Array[QuestObjective]: + return objectives.filter(func(o): return not o.is_optional) + +func are_all_objectives_complete() -> bool: + for obj in get_required_objectives(): + if not obj.is_complete(): + return false + return true + +func get_state_name() -> String: + return QuestState.keys()[state] + +func get_category_name() -> String: + return Category.keys()[category] diff --git a/game/quests/quest_data.gd.uid b/game/quests/quest_data.gd.uid new file mode 100644 index 0000000..cc95163 --- /dev/null +++ b/game/quests/quest_data.gd.uid @@ -0,0 +1 @@ +uid://bldyeahkrms8n diff --git a/game/quests/quest_objective.gd b/game/quests/quest_objective.gd new file mode 100644 index 0000000..beacb9e --- /dev/null +++ b/game/quests/quest_objective.gd @@ -0,0 +1,27 @@ +extends Resource +class_name QuestObjective + +enum Type { + TALK_TO, # Parler à un PNJ + COLLECT, # Récolter/Ramasser un objet + CAPTURE, # Capturer un Pokémon + DEFEAT, # Battre un Pokémon + REACH_LOCATION, # Atteindre un lieu + CUSTOM # Pour extensions futures +} + +@export var type: Type = Type.TALK_TO +@export var target_id: StringName = &"" # ex: &"chenipan", &"prof_chen" +@export var required_count: int = 1 +@export var is_optional: bool = false +@export var description: String = "" # Texte affiché dans le journal + +# Progression (non exportée : gérée en runtime par le QuestManager) +var current_count: int = 0 + +func is_complete() -> bool: + return current_count >= required_count + +## Retourne true si l'événement correspond à cet objectif +func matches(event_type: Type, event_target: StringName) -> bool: + return type == event_type and target_id == event_target diff --git a/game/quests/quest_objective.gd.uid b/game/quests/quest_objective.gd.uid new file mode 100644 index 0000000..6aa2e26 --- /dev/null +++ b/game/quests/quest_objective.gd.uid @@ -0,0 +1 @@ +uid://dj84i3mki0cws diff --git a/game/quests/quest_reward.gd b/game/quests/quest_reward.gd new file mode 100644 index 0000000..bd603d1 --- /dev/null +++ b/game/quests/quest_reward.gd @@ -0,0 +1,13 @@ +extends Resource +class_name QuestReward + +enum Type { + ITEM, # Donner un objet + MONEY, # Donner de l'argent + POKEMON, # Donner un Pokémon + UNLOCK_QUEST # Débloquer une nouvelle quête +} + +@export var type: Type = Type.ITEM +@export var target_id: StringName = &"" # ex: &"pokeball", &"pokedex" +@export var amount: int = 1 diff --git a/game/quests/quest_reward.gd.uid b/game/quests/quest_reward.gd.uid new file mode 100644 index 0000000..f6e5969 --- /dev/null +++ b/game/quests/quest_reward.gd.uid @@ -0,0 +1 @@ +uid://bpjxqwo7c5f64