Funpix Kit
Inicio / Componentes / fpx · quests

fpx · quests

Quests como datos: pasos secuenciales con disparadores (talk/kill/reach/collect/encounter), prerequisitos y recompensas, QuestLog con motivos y QuestOfferFlow puro.

capa 3 v0.1.0 items ↓ inventory ↓

El ZIP incluye: addons/fpx/quests, addons/fpx/items, addons/fpx/inventory, addons/fpx/stats + sus tests + data/balance/default.tres.

Qué es

Quests como datos: QuestDefinition (pasos secuenciales, oferta por páginas, prerequisitos required_level/required_quests, unlocks_quests, recompensas XP/oro/ítems/puntos), QuestStepDefinition (disparadores talk|kill|reach|collect|encounter con sus parámetros y goal()), QuestOfferPageDefinition (página simple o pregunta decisiva). QuestLog: estado puro del jugador — can_start con motivos tipados, start, report(event_id, params) que casa el evento con el paso actual (collect comprueba el inventario incluso por adelantado), seguidas ≤ MAX_FOLLOWED, to_rows()/restore_* para persistir. QuestOfferFlow: la máquina de estados de «¿aceptar la misión?» (páginas, pregunta decisiva con reintento, resumen de recompensas) sin UI.

Instalación

  1. Descarga fpx-quests-v0.1.0.zip (botón de arriba) y extráelo en la raíz de tu proyecto Godot — deja la estructura tal cual:
text
tu-juego/
├── project.godot
├── addons/
│   └── fpx/
│       ├── quests/
│       ├── items/
│       ├── inventory/
│       └── stats/
└── tests/          ← opcional: bórralo si no quieres los tests
  1. Abre el proyecto (o corre --import headless). Sin plugins ni autoloads que activar: las clases quedan disponibles en cualquier script.
  2. Opcional — verifica con la suite (necesita GdUnit4, ver la guía): -a tests/quests.

Ejemplo de uso

Usa las fixtures incluidas en el componente para probar en segundos; en tu juego, sustitúyelas por tus datos.

quests · ejemplo
# La quest dorada de la fixture: matar 2 drones + entregar 3 chatarras,
# con oferta de página simple + pregunta decisiva. Las tuyas son .tres.
var quest := QuestFixtures.golden_quest()
var log := QuestLog.new()
var inv := Inventory.new(20)

# La conversación de oferta, sin UI: páginas y pregunta decisiva con reintento.
var flow := QuestOfferFlow.make(quest)
print(flow.page().text)              # «Los drones no dejan pasar…»
flow.advance()                       # página simple → siguiente
if not flow.answer(1):               # decisiva: la respuesta correcta es «Dos»
	flow.retry()                     # fallar corta la oferta; se reintenta
print(QuestOfferFlow.rewards_summary(quest))  # «120 XP · 40 oro · 1 objeto»

# Aceptar: can_start valida nivel y prerequisitos con MOTIVOS tipados.
if log.can_start(quest, player_level) == &"":
	log.start(quest, inv)            # el collect ya mira este inventario

# Tu juego EMITE eventos; el log los casa contra el paso actual.
log.report(&"mob_killed", {"mob_id": &"dron_de_asalto"})
log.report(&"mob_killed", {"mob_id": &"dron_de_asalto"})  # 2/2 → paso siguiente

inv.add_item(ItemFixtures.material(&"chatarra"), 3)
var results := log.report(&"item_added", {"item_id": &"chatarra"}, inv)
for r: Dictionary in results:
	if bool(r["completed"]):
		apply_rewards(quest)         # en el ejemplar: RewardFlow (progression)

# Seguimiento para el HUD (tope 5) y persistencia por filas.
log.follow(quest.id)
var rows := log.to_rows()            # → guardar; restore_active/_completed al cargar

Demo en vivo

El componente REAL corriendo en tu navegador — Godot 4.7 exportado a Web sobre las fixtures del kit. La primera carga baja el motor (~10 MB comprimido) y se comparte entre todas las demos.

abrir a pantalla completa ↗

API pública

ClaseQué da
QuestDefinition (Resource)campos + matches_line(line_id) · by_id(id) desde QUESTS_DIR/QUESTS_MMO_DIR
QuestStepDefinition (Resource)trigger, npc_id/mob_id/item_id/map_id/cell/radius/count, goal(), TRIGGERS
QuestOfferPageDefinition (Resource)text, answers, correct_answer, wrong_reply, is_decisive()
QuestLogcan_start(quest, level) · start(quest, inventory) · report(event_id, params, …) · is_active/is_completed · follow/unfollow/is_followed/followed_ids · current_step/step_index/progress_count · to_rows() · restore_active/restore_completed · MAX_FOLLOWED = 5
QuestOfferFlowmake(quest) · state() · page() · advance() · answer(i) -> bool · retry() · rewards_summary(quest) estática

Firmas completas (del código)

QuestDefinitionextends Resourcequest_definition.gd

Quest como datos (E1-QST-01, A10): pasos SECUENCIALES y recompensas al completar el último (E1-QST-02: XP, oro, ítems garantizados y puntos de skill de quest, §8). Se resuelve por id contra data/quests/ como los mapas. El XP de quest es COMPLEMENTARIO al de combate: la promoción no depende de la questline — su gate es la quest declarada en ClassDefinition (P-22).

const QUESTS_DIR: String = "res://data/quests"
@export var id: StringName
@export var display_name: String = ""
@export var zone_id: StringName = &""

Zona a la que pertenece (agrupa el listado de la ventana «Misiones», 2026-07-10); &"" cae al grupo genérico «Misiones de mundo».

@export var line_id: StringName = &""

Línea de clase MMO a la que pertenece (promociones/Trial, SK-08); &"" = quest de mundo, sin restricción de línea. El oferente filtra por la línea ELEGIDA del personaje: JAMÁS ofrece la promoción de otra línea (un personaje = una línea, SK-12). Los ids de línea son el contrato §9.2 (anclaje, disrupcion…).

@export var steps: Array[QuestStepDefinition] = []
@export var offer_pages: Array[QuestOfferPageDefinition] = []

Páginas de conversación ANTES de la pantalla de aceptar: simples («Continuar.») o DECISIVAS (responder bien o reintentar). Vacío = el NPC va directo a la pantalla de aceptar con las posibles recompensas.

@export var required_level: int = 1

Nivel mínimo (del job activo) para ACEPTARLA; 1 = sin requisito.

@export var required_quests: Array[StringName] = []

Quests que deben estar COMPLETADAS para poder aceptarla (por id contra data/quests). El gate lo aplica QuestLog.can_start en TODAS las vías de arranque (NPC, consola, ítem, cadena) — cliente y server (capa 3).

@export var unlocks_quests: Array[StringName] = []

Quests que se DESBLOQUEAN al completar ésta (decisión del diseñador 2026-07-16: la cadena NO auto-arranca — desbloquea la DISPONIBILIDAD y se ACEPTA en su NPC con «¿Aceptar la misión?»). Metadato declarativo: el journal lo muestra («Al completar desbloquea…») y el guardia de contenido exige que toda quest desbloqueada tenga oferente propio (NPC o ítem).

@export var reward_xp: int = 0
@export var reward_gold: int = 0
@export var reward_items: Array[ItemDefinition] = []

Ítems GARANTIZADOS al completar (una unidad por entrada).

@export var reward_skill_points: int = 0

Puntos de skill extra (§8, cap +10/saga): van a quest_skill_points.

@export var reward_despertar_pct: int = 0

HITO de % de Despertar al completar (SK-18, §3.2): SOLO por hitos, jamás por grind. Las quests de promoción (q_promo_j1/j2) y la Trial de Sostener son la fuente natural — al completarlas el % SUBE al valor del gate (10/40/70). Se aplica UNA sola vez (la quest se completa una vez) y el track jamás baja (RewardFlow usa max con el actual). 0 = la quest no mueve el Despertar.

func matches_line(char_line_id: StringName) -> bool

¿Ofrecible a un personaje de esta línea? Una quest sin línea (&"") va a CUALQUIERA (quests de mundo); una de línea SOLO a quien comparte esa línea — y nunca a quien aún no ha elegido (&"" del personaje no casa una línea).

const QUESTS_MMO_DIR: String = "res://data/quests/mmo"

Subdirectorio de las quests del MMO (SK-18): promociones y Trial de Sostener de la constelación. Viven APARTE de las quests de mundo del SP para no arrastrar su regla de "toda quest la ofrece un NPC colocado" (su oferente es la propia constelación / futuros mentores de línea).

static func by_id(quest_id: StringName) -> QuestDefinition

Resolución por id contra data/quests y data/quests/mmo (mismo "Db" mínimo que MapDefinition): null + warning si no existe — el llamador decide el fallback.

QuestLogextends RefCountedquest_log.gd

Estado de quests del personaje (E1-QST-01): activas con su paso y progreso, y completadas. Núcleo puro (A5): no emite señales ni toca autoloads — GameState lo posee, le enruta los EventBus.quest_event y traduce los retornos a señales tipadas; SaveManager lo persiste (tabla quests, §13). El matching es contra el paso ACTUAL de cada quest activa (pasos secuenciales por diseño): un evento que no coincide no hace nada.

const MAX_FOLLOWED: int = 5

Tope de misiones SEGUIDAS a la vez (ventana «Misiones», pase 2026-07-10; ratificado por el diseñador a 5 — Biblia §7.5, GEN-3 MP-9).

func can_start(quest: QuestDefinition, level: int = -1) -> StringName

Gate de arranque (Questlines, plan §E2) SIN efectos secundarios: &"" si se puede aceptar; si no, el motivo tipado — &"invalid_quest" (null/sin pasos), &"already_active", &"already_completed", &"quest_pending" (prerequisito sin completar) o &"level_too_low". level < 0 = el llamador no aporta nivel (ese gate no se valida: el núcleo es puro y el nivel vive en el perfil).

func start(quest: QuestDefinition, inventory: Inventory = null, level: int = -1) -> Array[Dictionary]

Arranca una quest si can_start la deja pasar (prerequisitos incluidos). inventory permite que un primer paso &"collect" ya satisfecho avance solo (mismo criterio que al avanzar de paso). Devuelve los resultados de ese asentamiento (puede incluso completarla) o [] — el false conceptual es no aparecer en _active.

func is_active(quest_id: StringName) -> bool
func is_completed(quest_id: StringName) -> bool
func active_ids() -> Array[StringName]
func completed_ids() -> Array[StringName]
func follow(quest_id: StringName) -> bool

Sigue una quest ACTIVA. false si no está activa, ya se sigue o el tope MAX_FOLLOWED está lleno (la UI avisa).

func unfollow(quest_id: StringName) -> void
func is_followed(quest_id: StringName) -> bool
func followed_ids() -> Array[StringName]

Seguidas en orden de seguimiento (para el tracker del HUD).

func definition(quest_id: StringName) -> QuestDefinition
func step_index(quest_id: StringName) -> int
func progress_count(quest_id: StringName) -> int

Progreso del paso actual para la UI (n de n/m): kills llevadas; en &"collect" lo mide el llamador contra el inventario.

func current_step(quest_id: StringName) -> QuestStepDefinition

Paso actual de una quest activa (null si no está activa).

func report(event_id: StringName, params: Dictionary, inventory: Inventory = null) -> Array[Dictionary]

Enruta un evento de juego contra TODAS las quests activas (E1-QST-01). Devuelve un resultado por quest que reaccionó: {"quest": QuestDefinition, "step_index": int, "completed": bool} — step_index es el paso NUEVO (o el último si completed).

func to_rows() -> Array[Dictionary]

Filas para SaveManager: state &"active" lleva paso, progreso del paso y si la quest se está SIGUIENDO (pin del journal, 2026-07-10).

func restore_active(quest: QuestDefinition, step: int, count: int, followed: bool = false) -> void

Restaura una fila; la definición la resuelve el llamador (GameState) contra data/quests. Una activa cuyo contenido ya no existe se descarta con warning allí; los pasos fuera de rango se clampean (contenido re-autorado).

func restore_completed(quest_id: StringName) -> void
QuestOfferFlowextends RefCountedquest_offer_flow.gd

Estado de la CONVERSACIÓN de oferta de una quest (P-66b, decisión del diseñador 2026-07-16): páginas de diálogo — las DECISIVAS exigen la respuesta correcta; fallar corta la oferta y se reintenta la MISMA pregunta — y al final la pantalla de «¿Aceptar la misión?» con las posibles recompensas a la vista. Núcleo puro de UI (A5): sin GameState — el DialogPanel lo pinta y ACEPTAR es cosa del llamador (que re-valida con el gate can_start de todas formas; el server hace lo propio, capa 3).

var quest: QuestDefinition
static func make(offered: QuestDefinition) -> QuestOfferFlow
func state() -> StringName

Estado actual: &"page" (texto o pregunta) | &"failed" (réplica del NPC tras fallar la decisiva) | &"accept" (pantalla de aceptar).

func page() -> QuestOfferPageDefinition

Página actual (null si el flujo ya está en la aceptación).

func advance() -> void

Página SIMPLE: avanza («Continuar.»); la última desemboca en aceptar.

func answer(index: int) -> bool

Página DECISIVA: true si la respuesta es la correcta (y avanza); false deja el flujo en fallo — retry() reintenta la MISMA pregunta.

func retry() -> void
static func rewards_summary(offered: QuestDefinition) -> String

«Posibles recompensas» para la pantalla de aceptar (previsualización pedida por el diseñador): XP · oro · puntos de skill · objetos.

QuestOfferPageDefinitionextends Resourcequest_offer_page_definition.gd

Página del diálogo de OFERTA de una quest (P-66b, decisión del diseñador 2026-07-16): el NPC recorre las páginas antes de la pantalla de «¿Aceptar la misión?». Una página SIMPLE (sin respuestas) avanza con «Continuar.»; una DECISIVA lista respuestas y solo la correcta avanza — fallar corta con wrong_reply y se reintenta la MISMA pregunta hasta acertar.

@export var answers: Array[String] = []

Respuestas que se ofrecen al jugador (2+; el orden es el autorado).

@export var correct_answer: int = 0

Índice de la respuesta CORRECTA dentro de answers.

func is_decisive() -> bool
QuestStepDefinitionextends Resourcequest_step_definition.gd

Paso SECUENCIAL de una quest (E1-QST-01, A10): un trigger tipado con sus parámetros. El paso avanza solo si el evento de juego coincide (QuestLog hace el matching; los eventos llegan por EventBus.quest_event). Triggers y evento que los dispara: &"talk" → hablar con el NPC npc_id (evento &"npc_talked") &"kill" → matar count mobs mob_id (evento &"mob_killed") &"reach" → entrar al mapa map_id (evento &"map_entered"); si cell no es (-1,-1), pisar a ≤ radius de ella (&"cell_reached") &"collect" → tener count de item_id en el inventario (&"item_added") &"encounter" → GANAR el encuentro NO-letal encounter_id (Trial de Sostener §6; evento &"encounter" que emite el fin de combate en victoria). La arena y la condición de sostener viajan POR DATOS (abajo): el oferente monta el EncounterSetup con ellos — cero encuentro en código.

const TRIGGERS: Array[StringName] = [&"talk", &"kill", &"reach", &"collect", &"encounter"]
@export var id: StringName
@export var trigger: StringName = &"talk"
@export var npc_id: StringName
@export var mob_id: StringName
@export var item_id: StringName
@export var map_id: StringName
@export var cell: Vector2i = Vector2i(-1, -1)

Celda objetivo de &"reach"; (-1,-1) = basta con entrar al mapa.

@export var radius: int = 0
@export var count: int = 1

Objetivo de &"kill" (mobs) y &"collect" (unidades en inventario).

@export var encounter_id: StringName = &""

Identidad del encuentro NO-letal que lanza este paso. El evento &"encounter" del fin de combate (victoria) casa por este id. &"" = el paso no lanza combate.

@export var arena_id: StringName = &"arena_afueras"

Arena (MapDefinition) sobre la que se monta el encuentro — un bolsillo de la zona (D-12). El oferente resuelve el EncounterSetup contra ella.

@export var sostener_rounds: int = 0

Condición de sostener (EncounterSetup.sostener_*): rondas COMPLETAS a aguantar con el/los protegido(s) vivo(s). > 0 activa el modo NO-letal (§6 acto 2: no se gana matando). La victoria completa el paso; la derrota lo deja REPETIBLE.

@export var sostener_protected: int = 0

Número de protegidos a mantener vivos (aliados-civiles a sostener).

func goal() -> int

Total del paso para la UI de progreso (n/m): 1 en talk/reach/encounter.

Qué configura el juego

Sus .tres de quests (QUESTS_DIR), quién emite los eventos (report(&"mob_killed", {...}) desde combate, &"item_added" desde inventario, &"map_entered"…), qué hace con las recompensas (en el ejemplar RewardFlow, que llega con progression) y dónde están los objetivos (QuestTargets, que llega con world).

Fixture mínima

testing/quest_fixtures.gd — QuestFixtures.step()/quest()/page() y golden_quest() (kill ×2 + collect ×3, 120 XP + 40 oro + poción, oferta con página decisiva).

quest_fixtures.gdcódigo completo
quest_fixtures.gd
# res://addons/fpx/quests/testing/quest_fixtures.gd
class_name QuestFixtures
extends RefCounted
## Fixture mínima del componente `quests` (plan §6.9): constructores EN MEMORIA
## de pasos y quests, y la quest dorada de 2 pasos (kill + collect) con oferta
## de una página decisiva — misma forma que `la_senda_del_espadachin` del
## ejemplar, cero `res://data`.


static func step(trigger: StringName, params: Dictionary = {}) -> QuestStepDefinition:
	var s := QuestStepDefinition.new()
	s.id = StringName("%s_%d" % [trigger, params.size()])
	s.trigger = trigger
	for key: Variant in params:
		s.set(StringName(key), params[key])
	return s


static func quest(quest_id: StringName, steps: Array[QuestStepDefinition],
		reward_xp: int = 0, reward_gold: int = 0) -> QuestDefinition:
	var q := QuestDefinition.new()
	q.id = quest_id
	q.display_name = String(quest_id).capitalize()
	q.steps = steps
	q.reward_xp = reward_xp
	q.reward_gold = reward_gold
	return q


static func page(text: String, answers: Array[String] = [], correct: int = 0,
		wrong_reply: String = "") -> QuestOfferPageDefinition:
	var p := QuestOfferPageDefinition.new()
	p.text = text
	p.answers = answers
	p.correct_answer = correct
	p.wrong_reply = wrong_reply
	return p


## Quest dorada: matar 2 drones y entregar 3 chatarras; 120 XP + 40 oro +
## 1 poción; oferta de una página simple y una decisiva.
static func golden_quest() -> QuestDefinition:
	var q := quest(&"limpieza_del_perimetro", [
		step(&"kill", {"mob_id": &"dron_de_asalto", "count": 2}),
		step(&"collect", {"item_id": &"chatarra", "count": 3}),
	], 120, 40)
	q.display_name = "Limpieza del perímetro"
	q.required_level = 2
	q.reward_items = [ItemFixtures.consumable(&"pocion_pequena", 10, &"heal", 30)]
	q.offer_pages = [
		page("Los drones no dejan pasar a nadie. ¿Nos ayudas?"),
		page("¿Cuántos drones hay que abatir?", ["Uno", "Dos", "Tres"], 1,
				"No, son dos. Presta atención."),
	]
	return q

Cómo se prueba

tests/quests/ — quest_log_test (arranque/duplicados, matching por disparador, reach con radio, collect contra inventario, completado, seguidas y su tope, roundtrip de filas, prerequisitos y nivel), quest_offer_flow_test (páginas, decisiva con reintento, resumen), quest_fixtures_test.

Ejemplar oro (Stremus)

scripts/resources/quest_{,step_,offer_page_}definition.gd, scripts/core/progression/quest_log.gd, scripts/ui/quest_offer_flow.gd (main@5a5942a); la cadena la_senda_del_espadachin y las questlines P-66 son el contenido de referencia.

Deuda declarada

  • FLAG-QUESTS-LINE: line_id y reward_despertar_pct (SK-08/SK-18) viajan con default neutro.
  • QuestTargets (dónde está el objetivo: depende de MapDefinition/NpcDefinition/WorldAtlas) → componente world; RewardFlow (aplica recompensas sobre CharacterProgress) → progression (FK-6b). Los 2 casos de quest_log_test que prueban NpcDefinition.has_quest_for/awaits_turn_in se quedan en Stremus (llegarán con actors).
  • MAX_FOLLOWED es const (el plan lo quiere parámetro): delta futuro, se hará en Stremus.
Generado el 2026-08-31 · Funpix Kit — componentes extraídos de Stremus: El Tercer Despertar (ejemplar oro) · Funpixart