Funpix Kit
Inicio / Componentes / fpx · world_sim

fpx · world_sim

Patrullas por BFS, scheduler round-robin, respawn con reloj de mapa y avisos de aggro. Reloj inyectado: testeable sin escena.

capa 0 v0.1.0 grid ↓

El ZIP incluye: addons/fpx/world_sim, addons/fpx/grid + sus tests.

Qué es

Simulación de mundo determinista y sin escena, todo por inyección: Patrol (destino aleatorio alrededor de un ANCLA por BFS sobre predicados passable/is_free, RNG sembrado; vuelve al ancla si todo está ocupado) · PatrolScheduler (round-robin: un grupo se mueve por tick, acumulador acotado contra lag-spikes) · RespawnScheduler (cooldown de respawn con reloj de MAPA — solo avanza mientras exploras ese mapa; paso máximo 1 s) · AggroWarnings (máquina de estados del aviso de aggro: entrar al radio arranca cuenta atrás, salir cancela, llegar a 0 dispara).

Instalación

  1. Descarga fpx-world_sim-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/
│       ├── world_sim/
│       └── grid/
└── 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/world_sim.

Ejemplo de uso

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

world_sim · ejemplo
# Todo por inyección (predicados, RNG, reloj): determinista y sin escena.
var rng := WorldSimFixtures.seeded_rng()

# Patrulla: destino al azar alrededor de un ANCLA, por BFS sobre TUS predicados.
# Si todo está ocupado, devuelve el ancla (quedarse/volver).
var destino := Patrol.pick(
	Vector2i(6, 6), 2,                   # ancla + radio
	WorldSimFixtures.room_passable(),    # ¿se puede pisar esta celda?
	WorldSimFixtures.free_except([]),    # ¿está libre ahora mismo?
	rng)

# Round-robin: UN grupo se mueve por tick; el acumulador va acotado
# para que un lag-spike no dispare veinte movimientos de golpe.
var scheduler := PatrolScheduler.new(0.8)
var group := scheduler.advance(delta, 4, func(_i: int) -> bool: return true)
if group >= 0:
	move_group(group)  # -1 = a nadie le toca todavía

# Respawn con reloj DE MAPA: el cooldown solo avanza mientras el jugador
# explora ESE mapa (paso máximo 1 s contra saltos de frame).
var respawn := RespawnScheduler.new()
respawn.begin(&"bosque", &"lobos", 3000)
var vencidos := respawn.advance(delta, &"bosque")  # [&"lobos"] al cumplirse 3 s
if respawn.is_pending(&"bosque", &"lobos"):
	print(respawn.remaining_ms(&"bosque", &"lobos"))

# Aviso de aggro: entrar al radio arranca la cuenta atrás, salir la cancela,
# llegar a 0 dispara. `inside` = ids dentro del radio ESTE frame (tu detección).
var aggro := AggroWarnings.new(1.5)
var events := aggro.advance(delta, [&"mob_1"])
# events = {started: [...], cancelled: [...], triggered: [...]}

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
Patrolestáticas room_cells(anchor, radius, passable) · candidates(…, is_free) · pick(…, rng) -> Vector2i
PatrolSchedulernew(tick_interval) · advance(delta, group_count, is_eligible) -> índice del grupo que toca (-1 si ninguno) · reset_clock()
RespawnSchedulerbegin(map, spawn, delay_ms) · advance(delta, map) -> vencidos · is_pending/remaining_ms/cancel/clear_map/pending_count · MAX_STEP_MS
AggroWarningsnew(seconds) · advance(delta, inside) -> {started, cancelled, triggered} · remaining/is_warning/active_count/clear

Firmas completas (del código)

AggroWarningsextends RefCountedaggro_warnings.gd

Máquina de estados del AVISO de aggro (diseño del diseñador 2026-07-23): «cuando el personaje pisa una casilla del rango de agresión, aviso de que el mob lo va a agredir en 10 segundos, si no sale de la zona de agresión. Incluye el caso inverso: si el mob, por el patrol, llega hasta el jugador, también debe avisarle». El aggro deja de ser INMEDIATO: al entrar en el radio arranca una cuenta atrás; SALIR la cancela y resetea; seguir dentro al llegar a 0 = combate. (El ataque por CLICK directo no pasa por aquí: es inmediato.) Núcleo puro y testeable sin escena: el llamador aporta, cada frame, qué grupos están DENTRO del radio del jugador AHORA (sea porque el jugador entró o porque el patrol acercó al grupo — la máquina no distingue el porqué) y consume los eventos (empezó / canceló / disparó) para pintar FloatText, anuncios y lanzar el encuentro.

var warning_s: float
AggroWarnings.new(seconds: float)

Constructor.

func advance(delta: float, inside: Array) -> Dictionary

Avanza la máquina un frame. inside = ids de grupo cuyo radio contiene al jugador AHORA (ya filtrados por el llamador: agreden, sin gate, sin batalla). Devuelve {"started":[ids nuevos], "cancelled":[ids que salieron], "triggered": id|&""}: el PRIMER grupo cuya cuenta llegó a 0 estando dentro dispara el combate y la máquina se limpia entera (una transición a la vez).

func remaining(id: StringName) -> float

Segundos restantes del aviso de un grupo (0 si no está avisando).

func is_warning(id: StringName) -> bool
func active_count() -> int
func clear() -> void
Patrolextends RefCountedpatrol.gd

Selección de DESTINO del patrol (diseño del diseñador 2026-07-23): «que se muevan algunas celdas random … alrededor de su celda ANCLA (radio 2-3), solo celdas caminables, sin pisar NPCs/portales/salidas ni el jugador; el grupo VUELVE cerca del ancla, no deriva». En DUNGEON el patrol queda confinado a la SALA (las puertas cerradas y los muros acotan el flood). Núcleo puro y testeable sin escena: el llamador aporta la geometría como dos predicados —passable(cell) (terreno de la SALA: caminable y sin puerta cerrada; define el recinto por sus muros) y is_free(cell) (destino LIBRE: sin NPC/portal/salida/jugador/otro grupo)— y el RNG (sembrado por tick para que la elección sea determinista y reproducible).

static func room_cells(anchor: Vector2i, radius: int, passable: Callable) -> Array[Vector2i]

Celdas alcanzables desde el ANCLA por BFS hasta radius pasos sobre celdas passable — el RECINTO del grupo (muros y puertas cerradas lo cierran).

static func candidates(anchor: Vector2i, radius: int, passable: Callable, is_free: Callable) -> Array[Vector2i]

Candidatas de DESTINO: celdas del recinto (menos el ancla) que además están LIBRES ahora. El ancla se excluye para que el grupo se MUEVA; si ninguna queda libre, pick cae al ancla (volver a casa).

static func pick(anchor: Vector2i, radius: int, passable: Callable, is_free: Callable, rng: RandomNumberGenerator) -> Vector2i

Destino DETERMINISTA (RNG sembrado por el llamador): una candidata al azar, o el ANCLA (quedarse/volver) si no hay ninguna celda libre alrededor.

PatrolSchedulerextends RefCountedpatrol_scheduler.gd

Scheduler ROUND-ROBIN del patrol de mobs (diseño del diseñador 2026-07-23): «el patrol se ROTA entre mobs del mapa: cada mob hace su patrol EN SECUENCIA: mob 1 se mueve → esperan 15 s → mob 2 se mueve → esperan 15 s…». Cada PATROL_TICK_S segundos se mueve UN grupo, en el ORDEN de spawns del mapa; el resto espera. Reloj INYECTADO (advance(delta)) — determinista y testeable sin sleeps reales. Núcleo puro: no conoce la escena, ni el grid, ni autoloads; el llamador aporta la ELEGIBILIDAD (un grupo en combate/ocupado o avisando no patrulla) y ejecuta el movimiento real del índice devuelto.

var interval: float
PatrolScheduler.new(tick_interval: float)

Constructor.

func advance(delta: float, group_count: int, is_eligible: Callable) -> int

Acumula el tiempo y, al cruzar el intervalo, elige el SIGUIENTE grupo ELEGIBLE en round-robin (is_eligible(index) -> bool). Devuelve el índice a mover, o -1 si aún no toca tick o ningún grupo es elegible. Un solo tick por llamada (el patrol no se dispara en ráfaga tras un parón): el acumulador se acota a 1.5× el intervalo para no encadenar saltos si hubo un lag-spike.

func reset_clock() -> void

Reinicia el reloj (p. ej. al reconstruirse la escena) sin perder el orden.

RespawnSchedulerextends RefCountedrespawn_scheduler.gd

Cooldown de RESPAWN de los grupos vencidos (SP-RESPAWN, decisión del diseñador 2026-08-04): «en los mapas de exploraciones no se hace respawn al pasar un rato en el mapa que acabo de vencer al mob. por ejemplo si venciera un mob group de t1, debería volver a hacer respawn al pasar un rato, así como en el MMO». ESPEJO del zone server: _respawns allí es [{map, index, at_ms}] y _tick_respawns vence las entradas comparando contra el reloj del proceso. Aquí las entradas son [{map, id, remaining_ms}] (en single-player el grupo se identifica por spawn.id, no por índice de sesión) y el reloj se INYECTA con advance(delta, map_id) — mismo patrón que PatrolScheduler/AggroWarnings: núcleo puro, sin escena ni autoloads, determinista y testeable sin esperas. DIVERGENCIA DELIBERADA CON EL MMO (decidida al traer el ciclo al SP): el zone server cuenta en TIEMPO DE PARED — su _tick_respawns corre en el bucle del proceso pase lo que pase, porque allí hay OTROS jugadores para los que el mundo no puede detenerse. Aquí el reloj es TIEMPO DE MAPA: sólo avanza mientras la exploración está viva y sin transición, así que pelear tres minutos CONGELA el cooldown de los demás grupos. Es a propósito: en single-player no hay nadie más y «el mundo te espera mientras peleas» juega mejor que volver del combate y encontrártelo todo repoblado. Unificar los dos relojes es decisión del diseñador (FLAG-RESPAWN-TIEMPO). Qué SÍ consume reloj: caminar, estar quieto en el mapa, y también tener un diálogo/menú/inventario abierto (el juego no pausa — mismo criterio que el patrol y el aviso de aggro, que siguen corriendo con la UI encima). Qué NO: el combate, la transición entre escenas y estar en otro mapa.

const MAX_STEP_MS: float = 1000.0

Tope de UN solo avance (espejo del acotado de PatrolScheduler, que limita su acumulador para que un lag-spike no encadene saltos). Un frame de más de un segundo no es exploración: es un freeze, un alt-tab o el proceso en pausa — y como aquí el reloj mide TIEMPO DE MAPA (ver arriba), ese tiempo no debe contar. Sin el tope, un parón largo repondría el mapa ENTERO de una tacada.

func begin(map_id: StringName, spawn_id: StringName, delay_ms: int) -> void

Arranca (o REARRANCA) el cooldown de un grupo. delay_ms viene de SpawnDefinition.respawn_delay_ms() — el MISMO plazo que usa el server.

func advance(delta: float, map_id: StringName) -> Array[StringName]

Avanza el reloj de los pendientes DE ESE MAPA y devuelve los que vencieron, en orden de ingreso (el resto de mapas queda congelado). Espejo de _tick_respawns: una entrada vencida se retira de la lista al devolverse.

func is_pending(map_id: StringName, spawn_id: StringName) -> bool
func remaining_ms(map_id: StringName, spawn_id: StringName) -> int

Milisegundos que le faltan al grupo para volver (0 si no hay cooldown vivo).

func cancel(map_id: StringName, spawn_id: StringName) -> void
func clear_map(map_id: StringName) -> void

Tira los pendientes de un mapa (visita nueva: la exploración se rehace entera).

func clear() -> void
func pending_count() -> int

Qué configura el juego

Los predicados, el radio, los ticks, los delay_ms (en el ejemplar vienen de SpawnDefinition) y qué hace al disparar.

Fixture mínima

testing/world_sim_fixtures.gd — WorldSimFixtures.room_passable() (sala 4..8), free_except(blocked), seeded_rng(), loaded_respawns() (A a 1.5 s, B a 3 s).

world_sim_fixtures.gdcódigo completo
world_sim_fixtures.gd
# res://addons/fpx/world_sim/testing/world_sim_fixtures.gd
class_name WorldSimFixtures
extends RefCounted
## Fixture mínima del componente `world_sim`: una «sala» rectangular como
## predicados puros (sin grid vivo ni escena), un RNG sembrado y un scheduler
## de respawn ya cargado — todo EN MEMORIA y determinista.

const MAP_ID := &"mapa_dorado"
const ROOM_LO := Vector2i(4, 4)
const ROOM_HI := Vector2i(8, 8)
const ANCHOR := Vector2i(6, 6)
const RADIUS := 2
const SPAWN_A := &"grupo_a"
const SPAWN_B := &"grupo_b"


static func seeded_rng(rng_seed: int = 7) -> RandomNumberGenerator:
	var rng := RandomNumberGenerator.new()
	rng.seed = rng_seed
	return rng


## Recinto transitable acotado al rectángulo [ROOM_LO..ROOM_HI].
static func room_passable() -> Callable:
	return func(cell: Vector2i) -> bool:
		return cell.x >= ROOM_LO.x and cell.y >= ROOM_LO.y \
				and cell.x <= ROOM_HI.x and cell.y <= ROOM_HI.y


## Nada ocupa la sala salvo las celdas de `blocked`.
static func free_except(blocked: Array[Vector2i] = []) -> Callable:
	return func(cell: Vector2i) -> bool:
		return not blocked.has(cell)


## Scheduler con dos grupos pendientes en el mapa dorado: A vence a 1.5 s, B a 3 s.
static func loaded_respawns() -> RespawnScheduler:
	var sched := RespawnScheduler.new()
	sched.begin(MAP_ID, SPAWN_A, 1500)
	sched.begin(MAP_ID, SPAWN_B, 3000)
	return sched

Cómo se prueba

tests/world_sim/ — patrol_test, patrol_scheduler_test, aggro_warnings_test, world_sim_fixtures_test (patrol reproducible dentro de la sala, respawn en orden con reloj acotado y por mapa).

Ejemplar oro (Stremus)

scripts/world/{patrol,patrol_scheduler,respawn_scheduler,aggro_warnings}.gd (main@0be95b4); decisiones del diseñador 2026-07-23 (patrol) y FLAG-RESPAWN-TIEMPO (reloj de mapa).

Deuda declarada

  • FLAG-GRID-FLOOD-DUP: Patrol hace su propio BFS; podría delegar en GridMap2D.reachable_cells.
  • Test que se queda en Stremus: respawn_scheduler_test (usa SpawnDefinition del componente world, FK-8+).
Generado el 2026-08-31 · Funpix Kit — componentes extraídos de Stremus: El Tercer Despertar (ejemplar oro) · Funpixart