fpx · grid
Grid táctico: adyacencia, pathfinding A*, alcance por presupuesto (Dijkstra), ocupación, línea de visión supercover, AoE rotable y adaptador isométrico 3D.
El ZIP incluye: addons/fpx/grid + sus tests.
Qué es
Grid lógico cartesiano puro (Vector2i), sin nodos ni render: adyacencia ortogonal (diagonal opcional para exploración), pathfinding A* (AStarGrid2D, heurística Manhattan, JPS off), celdas alcanzables por presupuesto de movimiento (Dijkstra por buckets), coste de camino, ocupación dinámica, línea de visión supercover con regla de vértice, bandas de alcance y AoE por offsets rotables. Iso3D es el adaptador de presentación celda↔mundo isométrico 3D.
Instalación
- Descarga
fpx-grid-v0.1.0.zip(botón de arriba) y extráelo en la raíz de tu proyecto Godot — deja la estructura tal cual:
tu-juego/
├── project.godot
├── addons/
│ └── fpx/
│ └── grid/
└── tests/ ← opcional: bórralo si no quieres los tests- Abre el proyecto (o corre
--importheadless). Sin plugins ni autoloads que activar: las clases quedan disponibles en cualquier script. - Opcional — verifica con la suite (necesita GdUnit4, ver la guía):
-a tests/grid.
Ejemplo de uso
Usa las fixtures incluidas en el componente para probar en segundos; en tu juego, sustitúyelas por tus datos.
# Un mapa 15×15 con un muro vertical en x=7 (hueco en y=7). GridFixtures viene
# incluida en el componente; tu juego construye su Dictionary[Vector2i, CellData]
# desde su propio formato de mapa y llama setup().
var grid := GridMap2D.new()
grid.setup(Vector2i(15, 15), GridFixtures.corridor_map())
# A* alrededor del muro. Contrato: camino contiguo, SIN el origen,
# coste = suma del move_cost de las celdas en las que se ENTRA.
var path := grid.find_path(Vector2i(2, 7), Vector2i(12, 7))
var cost := grid.path_cost(path)
# ¿Hasta dónde llego con 4 PM? Dijkstra por presupuesto: {celda: coste}.
var reachable := grid.reachable_cells(Vector2i(2, 7), 4)
# Línea de visión supercover con regla de vértice.
var sees := grid.has_line_of_sight(Vector2i(2, 7), Vector2i(12, 7))
# Ocupación dinámica por id de unidad (el grid no sabe qué es una "unidad").
grid.set_occupant(Vector2i(3, 7), 42)
grid.move_occupant(Vector2i(3, 7), Vector2i(4, 7), 42)
# AoE por offsets rotables: una cruz apuntada hacia el objetivo.
var cross: Array[Vector2i] = [Vector2i(1, 0), Vector2i(2, 0), Vector2i(0, 1), Vector2i(0, -1)]
var turns := GridMap2D.quarter_turns_towards(Vector2i(12, 7), Vector2i(2, 7))
var area := grid.cells_in_aoe(Vector2i(12, 7), cross, turns)
# Banda de alcance (mín..máx) exigiendo línea de visión.
var targets := grid.cells_in_range(Vector2i(2, 7), 1, 3, true)
# Presentación isométrica 3D (adaptador con las constantes del ejemplar).
var world_pos := Iso3D.cell_to_world(Vector2i(7, 7))
var cell := Iso3D.world_to_cell(world_pos)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.
API pública
| Clase | Qué da |
|---|---|
GridMap2D | setup(size, cells) · get_cell/is_inside · find_path · path_cost · is_path_free · reachable_cells(origin, mp_budget) · has_line_of_sight · cells_in_range(origin, min, max, …) · cells_in_aoe(origin, pattern, quarter_turns) · ocupación (occupant_at/set_occupant/clear_occupant/move_occupant) · set_diagonal_movement · estáticas manhattan(), quarter_turns_towards() |
CellData | make(walkable, move_cost, blocks_los) |
LineOfSight | estáticas: supercover + regla de vértice |
Iso3D | celda↔mundo con las constantes de cámara/celda del ejemplar |
Contrato de movimiento: «camino contiguo, sin origen, coste = suma de move_cost de las celdas en las que se ENTRA».
Firmas completas (del código)
CellDataextends RefCountedcell_data.gd
Terreno estático de una celda (E0-GRD-02, sistemas_core §11 y §15.0). Exclusivamente terreno: la ocupación dinámica por unidades NO vive aquí, vive en el estado de combate (GridMap2D._occupants, propiedad de CombatState). walkable y blocks_los son independientes: agua = no transitable y no bloquea visión; muro = ambos. Sin campo de elevación: la Z está eliminada del proyecto (D-03).
var walkable: bool = truevar move_cost: int = 1 # PM por ENTRAR a la celda (GDD §2: 1 por celda; terreno difícil > 1)var blocks_los: bool = falsestatic func make(is_walkable: bool = true, cost: int = 1, los_blocker: bool = false) -> CellDataGridMap2Dextends RefCountedgrid_map_2d.gd
Grid lógico cartesiano puro en Vector2i, ortogonal sin diagonales (A1, A6). Núcleo puro (A5): cero referencias a nodos ni al render; la conversión celda↔mundo es exclusiva de la presentación (Iso3D). El pathfinding es AStarGrid2D nativo (plan §6.5); el rango de movimiento es un Dijkstra propio porque AStarGrid2D no expone flood fill por presupuesto; la LoS es supercover propia porque AStarGrid2D no ofrece consultas de visibilidad. Ocupación dinámica: vive aquí (propiedad de CombatState, E0-PATH-01), nunca en CellData. Una celda con unidad viva es sólida para el pathfinding y bloquea LoS salvo para atacante y objetivo (sistemas_core §11).
const ORTHO_DIRS: Array[Vector2i] = …var size: Vector2i = Vector2i.ZEROvar cells: Dictionary[Vector2i, CellData] = {}func setup(map_size: Vector2i, cell_data: Dictionary[Vector2i, CellData]) -> voidfunc is_inside(coord: Vector2i) -> boolfunc get_cell(coord: Vector2i) -> CellDatastatic func manhattan(a: Vector2i, b: Vector2i) -> intstatic func quarter_turns_towards(from: Vector2i, to: Vector2i) -> intCuartos de vuelta para orientar un patrón AoE hacia el objetivo (A6). Los patrones se autoran asumiendo ataque hacia +x (0 vueltas); el eje dominante de la dirección actor→objetivo decide la rotación.
func occupant_at(coord: Vector2i) -> intfunc set_occupant(coord: Vector2i, unit_id: int) -> voidfunc clear_occupant(coord: Vector2i) -> voidfunc move_occupant(from: Vector2i, to: Vector2i, unit_id: int) -> voidfunc set_diagonal_movement(enabled: bool) -> voidDiagonales SOLO para exploración (S11, feedback 2026-07-08): el combate es ortogonal por ancla (A1) y JAMÁS llama esto — su setup queda en NEVER. ONLY_IF_NO_OBSTACLES: una diagonal solo es válida si sus DOS vecinas ortogonales son transitables — nunca se cortan esquinas de muros ni se roza a un ocupante.
func find_path(from: Vector2i, to: Vector2i) -> Array[Vector2i]Camino entre celdas, EXCLUYENDO la de origen (contrato de MoveAction §6.3). get_id_path() incluye el origen como primer elemento; se recorta aquí, una sola vez. Sin ruta (o destino sólido/ocupado) devuelve array vacío.
func path_cost(path: Array[Vector2i]) -> intCoste en PM de un camino: suma del move_cost de cada celda en la que se ENTRA.
func is_path_free(path: Array[Vector2i]) -> boolUn camino es libre si toda celda es interior, transitable y sin ocupante.
func reachable_cells(from: Vector2i, mp_budget: int) -> Dictionary[Vector2i, int]Celdas alcanzables con presupuesto de PM (E0-PATH-03): Dijkstra por buckets de coste entero. Devuelve celda → coste acumulado, SIN incluir el origen. Las unidades ocupantes bloquean el paso (E0-PATH-01), como en Dofus.
func has_line_of_sight(from: Vector2i, to: Vector2i) -> boolLoS 2D definitiva (D-03). Bloquean: celdas con blocks_los y unidades vivas, salvo atacante y objetivo (los extremos nunca bloquean su propia línea). Cruce exacto de vértice: bloquea solo si AMBAS celdas adyacentes son TERRENO (blocks_los); dos unidades vivas en el vértice ya NO lo cierran (revisión del diseñador 2026-07-19, sistemas_core §11 regla 3).
func cells_in_range(origin: Vector2i, range_min: int, range_max: int, needs_los: bool) -> Array[Vector2i]Celdas del mapa dentro de una banda de alcance Manhattan desde un origen, opcionalmente filtradas por LoS. Alimenta el preview de targeting del HUD (feedback de playtest E0; E1-SKL-03 lo reutiliza para skills data-driven).
func cells_in_aoe(origin: Vector2i, pattern: Array[Vector2i], quarter_turns: int) -> Array[Vector2i]Aplica un patrón de offsets rotado en cuartos de vuelta (0..3) sobre un origen; descarta celdas fuera de región. La misma función alimenta el preview del overlay y la resolución en SkillAction (E1-SKL).
Iso3Dextends RefCountediso_3d.gd
Matemática de grid 3D centralizada (sucesora de IsoMath, migración 3D F1): ningún script convierte celda↔mundo a mano; todo pasa por aquí. El grid lógico sigue siendo Vector2i cartesiano plano (D-03): x → +X, y → +Z, Y=0. ESCALA CONGELADA — P-39 RESUELTO (sesión con el diseñador, 2026-07-22): estos números son EL CONTRATO de producción de assets. Cambiar CELL_SIZE o la cámara obliga a reexportar TODOS los .glb — PROHIBIDO tocarlos sin una decisión formal del diseñador (guardia: tests/world/p39_escala_congelada_test.gd).
const CELL_SIZE: float = 2.0Unidades de mundo por celda (contrato con Blender: 1 celda = 2.0 m, §20).
const CAMERA_PITCH_DEG: float = -30.0Cámara isométrica del proyecto (ortográfica): pitch/yaw compartidos por combate y exploración para que el mundo se lea IGUAL en ambas escenas.
const CAMERA_YAW_DEG: float = 45.0const EXPLORATION_VIEW_SIZE: float = 34.0Encuadre ortográfico de EXPLORACIÓN (P-39; historial de ratificaciones del diseñador: 26.0 greybox → 30.0 «zoom liviano» → 34.0 «más zoom out», 2026-07-22). Independiente de la resolución — a 4K se ve el MISMO encuadre.
const COMBAT_VIEW_MARGIN: float = 1.4Margen del encuadre de COMBATE sobre el fit exacto de la arena (feedback del diseñador 2026-07-22: el mismo zoom out de exploración — 1.08 → 1.25 → 1.40 «más zoom out»). La cámara de combate sigue encuadrando la arena COMPLETA dinámicamente; este margen es el aire alrededor.
const NO_CELL: Vector2i = Vector2i(-9999, -9999)Centinela de "sin celda" (picking fuera del plano); cae fuera de todo mapa, así que is_inside() lo descarta sin caso especial.
static func cell_to_world(cell: Vector2i) -> Vector3Centro de una celda del grid lógico, en coordenadas de mundo (piso Y=0).
static func world_to_cell(world: Vector3) -> Vector2iCelda del grid lógico bajo una posición de mundo.
static func screen_to_cell(camera: Camera3D, screen_pos: Vector2) -> Vector2iPicking: rayo de la cámara → plano del piso (Y=0) → celda. Reemplaza al local_to_map del ratón del 2D. NO_CELL si el rayo no corta el plano.
static func camera_basis() -> BasisBase de la cámara isométrica del proyecto (mira hacia -Z local).
LineOfSightextends RefCountedline_of_sight.gd
Trazado supercover entre celdas (E0-LOS-01, A6, sistemas_core §11). Funciones estáticas puras, sin estado. El supercover (variante de Bresenham, algoritmo de Dedu) incluye TODA celda que el segmento entre centros toca. Cuando la línea cruza exactamente una esquina, las dos celdas adyacentes al vértice forman un PAR: bloquean la visión solo si ambas bloquean, y la regla de esquina cuenta SOLO terreno con blocks_los (sistemas_core §11, regla 3 — casos L4/L5 del contrato de tests). Revisión del diseñador 2026-07-19: dos UNIDADES VIVAS en el vértice ya NO cierran la diagonal (el cruce exacto entre bloqueadores blandos deja pasar); siguen cerrando muros/terreno. La simetría has_los(a,b) == has_los(b,a) se garantiza normalizando extremos.
static func get_traversal(a: Vector2i, b: Vector2i) -> Array[Array]Devuelve la traversal como Array de entradas; cada entrada es un Array[Vector2i] con 1 celda (cruce interior) o 2 celdas (par de esquina). Incluye ambos extremos (como entradas de 1 celda).
static func get_line_cells(a: Vector2i, b: Vector2i) -> Array[Vector2i]Todas las celdas que el segmento toca, extremos incluidos (E0-LOS-01).
static func is_line_blocked(a: Vector2i, b: Vector2i, blocks_hard: Callable, blocks_soft: Callable = Callable()) -> boolEvalúa la línea con predicados de bloqueo por celda. blocks_hard marca los bloqueadores de TERRENO (muros/blocks_los) y blocks_soft (opcional) los bloqueadores BLANDOS (unidades vivas). Los extremos nunca bloquean su propia línea (E0-LOS-02). Reglas (sistemas_core §11): - Celda interior simple: bloquea si es hard O soft. - Cruce exacto de vértice (par de celdas): bloquea SOLO si AMBAS son hard — dos unidades vivas en el vértice ya NO lo cierran (revisión del diseñador 2026-07-19; la regla de esquina queda para muros/terreno con blocks_los). Sin blocks_soft, blocks_hard decide todo (compatibilidad: la esquina vuelve a "ambas bloquean" con un único predicado).
Qué configura el juego
Nada obligatorio: construye cells (desde su propio formato de mapa) y llama setup. Diagonales solo si su exploración las quiere.
Fixture mínima
testing/grid_fixtures.gd — GridFixtures.open_map(), corridor_map(), weighted_map(), pillars_map(), water_map(): los 5 mapas de mecánica (la mitad «motor» de CombatFixtures de Stremus).
grid_fixtures.gdcódigo completo
# res://addons/fpx/grid/testing/grid_fixtures.gd
class_name GridFixtures
extends RefCounted
## Fixture mínima del componente `grid` (patrón oro: todo componente trae su
## «hola mundo» en memoria). Son los 5 mapas de mecánica portados de
## `CombatFixtures` de Stremus (la sutura que el plan parte en FC-7/FK):
## aquí viven SOLO los constructores de terreno — cero contenido de juego.
## Mapa 1: llano 15×15, todo transitable (coste 1, sin bloqueos de visión).
static func open_map(size: Vector2i = Vector2i(15, 15)) -> Dictionary[Vector2i, CellData]:
var cells: Dictionary[Vector2i, CellData] = {}
for y in size.y:
for x in size.x:
cells[Vector2i(x, y)] = CellData.make()
return cells
## Mapa 2: muro vertical en x=7 con un hueco en y=7 (rodeos y LoS).
## El muro es "muro" de verdad: no transitable Y bloquea visión.
static func corridor_map(size: Vector2i = Vector2i(15, 15)) -> Dictionary[Vector2i, CellData]:
var cells := open_map(size)
for y in size.y:
if y != 7:
cells[Vector2i(7, y)] = CellData.make(false, 1, true)
return cells
## Mapa 3: franja de terreno difícil (coste 3) en y=6..8, x=4..10, con un
## pasillo barato en y=5 (el pathfinding debe preferirlo).
static func weighted_map(size: Vector2i = Vector2i(15, 15)) -> Dictionary[Vector2i, CellData]:
var cells := open_map(size)
for y in range(6, 9):
for x in range(4, 11):
cells[Vector2i(x, y)] = CellData.make(true, 3, false)
return cells
## Mapa 4: pilares que bloquean visión, transitables alrededor.
static func pillars_map(size: Vector2i = Vector2i(15, 15)) -> Dictionary[Vector2i, CellData]:
var cells := open_map(size)
for pos: Vector2i in [Vector2i(5, 5), Vector2i(9, 5), Vector2i(5, 9), Vector2i(9, 9), Vector2i(7, 7)]:
cells[pos] = CellData.make(false, 1, true)
return cells
## Mapa 5: agua (no transitable, NO bloquea visión) y muro (ambos) — el par
## de casos independientes walkable/blocks_los.
static func water_map(size: Vector2i = Vector2i(15, 15)) -> Dictionary[Vector2i, CellData]:
var cells := open_map(size)
for x in range(3, 12):
cells[Vector2i(x, 4)] = CellData.make(false, 1, false) # agua
cells[Vector2i(7, 10)] = CellData.make(false, 1, true) # muro
return cellsCómo se prueba
tests/grid/ — grid_map_2d_test (A*, Dijkstra, ocupación, AoE), line_of_sight_test, iso_3d_test.
Ejemplar oro (Stremus)
scripts/core/grid/ (main@0be95b4) y sus MapDefinition por filas de caracteres; Iso3D lleva las constantes congeladas P-39 (tests/world/p39_escala_congelada_test.gd se queda allí).
Deuda declarada
- FLAG-GRID-HEX: sin adyacencia hexagonal (ancla A1 del ejemplar).
- FLAG-GRID-FLOOD-DUP:
Patrol(world_sim) hace su propio BFS por radio; podría delegar enreachable_cells. - FLAG-ISO3D-PROFILE: si un juego usa otro
CELL_SIZE/cámara,Iso3Dpasa a leer unGridPresentationProfile.