Funpix Kit
Inicio / Componentes / fpx · grid

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.

capa 0 v0.1.0 sin dependencias

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

  1. 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:
text
tu-juego/
├── project.godot
├── addons/
│   └── fpx/
│       └── 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/grid.

Ejemplo de uso

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

grid · ejemplo
# 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.

abrir a pantalla completa ↗

API pública

ClaseQué da
GridMap2Dsetup(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()
CellDatamake(walkable, move_cost, blocks_los)
LineOfSightestáticas: supercover + regla de vértice
Iso3Dcelda↔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 = true
var move_cost: int = 1 # PM por ENTRAR a la celda (GDD §2: 1 por celda; terreno difícil > 1)
var blocks_los: bool = false
static func make(is_walkable: bool = true, cost: int = 1, los_blocker: bool = false) -> CellData
GridMap2Dextends 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.ZERO
var cells: Dictionary[Vector2i, CellData] = {}
func setup(map_size: Vector2i, cell_data: Dictionary[Vector2i, CellData]) -> void
func is_inside(coord: Vector2i) -> bool
func get_cell(coord: Vector2i) -> CellData
static func manhattan(a: Vector2i, b: Vector2i) -> int
static func quarter_turns_towards(from: Vector2i, to: Vector2i) -> int

Cuartos 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) -> int
func set_occupant(coord: Vector2i, unit_id: int) -> void
func clear_occupant(coord: Vector2i) -> void
func move_occupant(from: Vector2i, to: Vector2i, unit_id: int) -> void
func set_diagonal_movement(enabled: bool) -> void

Diagonales 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]) -> int

Coste en PM de un camino: suma del move_cost de cada celda en la que se ENTRA.

func is_path_free(path: Array[Vector2i]) -> bool

Un 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) -> bool

LoS 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.0

Unidades de mundo por celda (contrato con Blender: 1 celda = 2.0 m, §20).

const CAMERA_PITCH_DEG: float = -30.0

Cá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.0
const EXPLORATION_VIEW_SIZE: float = 34.0

Encuadre 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.4

Margen 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) -> Vector3

Centro de una celda del grid lógico, en coordenadas de mundo (piso Y=0).

static func world_to_cell(world: Vector3) -> Vector2i

Celda del grid lógico bajo una posición de mundo.

static func screen_to_cell(camera: Camera3D, screen_pos: Vector2) -> Vector2i

Picking: 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() -> Basis

Base 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()) -> bool

Evalú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
grid_fixtures.gd
# 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 cells

Có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 en reachable_cells.
  • FLAG-ISO3D-PROFILE: si un juego usa otro CELL_SIZE/cámara, Iso3D pasa a leer un GridPresentationProfile.
Generado el 2026-08-31 · Funpix Kit — componentes extraídos de Stremus: El Tercer Despertar (ejemplar oro) · Funpixart