Files
game_engine/docs/superpowers/plans/2026-03-25-phase8-1-ai.md
2026-03-25 14:20:50 +09:00

20 KiB

Phase 8-1: AI System Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 수동 내비메시 + A* 패스파인딩 + 스티어링 행동으로 AI 에이전트가 경로를 따라 이동

Architecture: voltex_ai crate 신규 생성. NavMesh(삼각형 그래프) + A*(삼각형 중심 경로) + 스티어링(순수 함수). 모두 voltex_math::Vec3 기반.

Tech Stack: Rust, voltex_math (Vec3)

Spec: docs/superpowers/specs/2026-03-25-phase8-1-ai.md


File Structure

voltex_ai (신규)

  • crates/voltex_ai/Cargo.toml (Create)
  • crates/voltex_ai/src/lib.rs (Create)
  • crates/voltex_ai/src/navmesh.rs — NavMesh, NavTriangle (Create)
  • crates/voltex_ai/src/pathfinding.rs — A* find_path (Create)
  • crates/voltex_ai/src/steering.rs — SteeringAgent, seek/flee/arrive/wander/follow_path (Create)

Workspace (수정)

  • Cargo.toml — members + dependencies (Modify)

Task 1: Crate 설정 + NavMesh

Files:

  • Create: crates/voltex_ai/Cargo.toml

  • Create: crates/voltex_ai/src/lib.rs

  • Create: crates/voltex_ai/src/navmesh.rs

  • Modify: Cargo.toml (workspace)

  • Step 1: Cargo.toml

[package]
name = "voltex_ai"
version = "0.1.0"
edition = "2021"

[dependencies]
voltex_math.workspace = true
  • Step 2: workspace에 추가

members에 "crates/voltex_ai", workspace.dependencies에 voltex_ai = { path = "crates/voltex_ai" }.

  • Step 3: navmesh.rs 작성
// crates/voltex_ai/src/navmesh.rs
use voltex_math::Vec3;

#[derive(Debug, Clone)]
pub struct NavTriangle {
    pub indices: [usize; 3],
    pub neighbors: [Option<usize>; 3],
}

pub struct NavMesh {
    pub vertices: Vec<Vec3>,
    pub triangles: Vec<NavTriangle>,
}

impl NavMesh {
    pub fn new(vertices: Vec<Vec3>, triangles: Vec<NavTriangle>) -> Self {
        Self { vertices, triangles }
    }

    /// Find which triangle contains the point (XZ plane projection, Y ignored).
    pub fn find_triangle(&self, point: Vec3) -> Option<usize> {
        for (i, tri) in self.triangles.iter().enumerate() {
            let a = self.vertices[tri.indices[0]];
            let b = self.vertices[tri.indices[1]];
            let c = self.vertices[tri.indices[2]];
            if point_in_triangle_xz(point, a, b, c) {
                return Some(i);
            }
        }
        None
    }

    /// Center of a triangle (average of 3 vertices).
    pub fn triangle_center(&self, tri_idx: usize) -> Vec3 {
        let tri = &self.triangles[tri_idx];
        let a = self.vertices[tri.indices[0]];
        let b = self.vertices[tri.indices[1]];
        let c = self.vertices[tri.indices[2]];
        (a + b + c) * (1.0 / 3.0)
    }

    /// Midpoint of shared edge between triangle tri_idx and its neighbor on edge_idx.
    pub fn edge_midpoint(&self, tri_idx: usize, edge_idx: usize) -> Vec3 {
        let tri = &self.triangles[tri_idx];
        let i0 = tri.indices[edge_idx];
        let i1 = tri.indices[(edge_idx + 1) % 3];
        (self.vertices[i0] + self.vertices[i1]) * 0.5
    }
}

/// Point-in-triangle test on XZ plane using barycentric coordinates.
fn point_in_triangle_xz(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> bool {
    let v0x = c.x - a.x;
    let v0z = c.z - a.z;
    let v1x = b.x - a.x;
    let v1z = b.z - a.z;
    let v2x = p.x - a.x;
    let v2z = p.z - a.z;

    let dot00 = v0x * v0x + v0z * v0z;
    let dot01 = v0x * v1x + v0z * v1z;
    let dot02 = v0x * v2x + v0z * v2z;
    let dot11 = v1x * v1x + v1z * v1z;
    let dot12 = v1x * v2x + v1z * v2z;

    let inv_denom = 1.0 / (dot00 * dot11 - dot01 * dot01);
    let u = (dot11 * dot02 - dot01 * dot12) * inv_denom;
    let v = (dot00 * dot12 - dot01 * dot02) * inv_denom;

    u >= 0.0 && v >= 0.0 && (u + v) <= 1.0
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_square_navmesh() -> NavMesh {
        // Square from (0,0,0) to (10,0,10), split into 2 triangles
        // Triangle 0: (0,0,0), (10,0,0), (10,0,10) — neighbors: [None, Some(1), None]
        // Triangle 1: (0,0,0), (10,0,10), (0,0,10) — neighbors: [Some(0), None, None]
        let vertices = vec![
            Vec3::new(0.0, 0.0, 0.0),
            Vec3::new(10.0, 0.0, 0.0),
            Vec3::new(10.0, 0.0, 10.0),
            Vec3::new(0.0, 0.0, 10.0),
        ];
        let triangles = vec![
            NavTriangle { indices: [0, 1, 2], neighbors: [None, Some(1), None] },
            NavTriangle { indices: [0, 2, 3], neighbors: [Some(0), None, None] },
        ];
        NavMesh::new(vertices, triangles)
    }

    #[test]
    fn test_find_triangle_inside() {
        let nm = make_square_navmesh();
        // Point in bottom-right triangle (0)
        assert_eq!(nm.find_triangle(Vec3::new(8.0, 0.0, 2.0)), Some(0));
        // Point in top-left triangle (1)
        assert_eq!(nm.find_triangle(Vec3::new(2.0, 0.0, 8.0)), Some(1));
    }

    #[test]
    fn test_find_triangle_outside() {
        let nm = make_square_navmesh();
        assert_eq!(nm.find_triangle(Vec3::new(-5.0, 0.0, 5.0)), None);
        assert_eq!(nm.find_triangle(Vec3::new(15.0, 0.0, 5.0)), None);
    }

    #[test]
    fn test_triangle_center() {
        let nm = make_square_navmesh();
        let c = nm.triangle_center(0);
        // Center of (0,0,0), (10,0,0), (10,0,10) ≈ (6.67, 0, 3.33)
        assert!((c.x - 10.0 / 3.0 * 2.0).abs() < 0.01);
        assert!((c.z - 10.0 / 3.0).abs() < 0.01);
    }

    #[test]
    fn test_edge_midpoint() {
        let nm = make_square_navmesh();
        // Edge 1 of triangle 0 connects vertices 1(10,0,0) and 2(10,0,10)
        let mid = nm.edge_midpoint(0, 1);
        assert!((mid.x - 10.0).abs() < 0.01);
        assert!((mid.z - 5.0).abs() < 0.01);
    }
}
  • Step 4: lib.rs 작성
pub mod navmesh;
pub use navmesh::{NavMesh, NavTriangle};
  • Step 5: 테스트 실행

Run: cargo test -p voltex_ai Expected: 4 PASS

  • Step 6: 커밋
git add crates/voltex_ai/ Cargo.toml
git commit -m "feat(ai): add voltex_ai crate with NavMesh (manual triangle mesh)"

Task 2: A* 패스파인딩

Files:

  • Create: crates/voltex_ai/src/pathfinding.rs

  • Modify: crates/voltex_ai/src/lib.rs

  • Step 1: pathfinding.rs 작성

// crates/voltex_ai/src/pathfinding.rs
use voltex_math::Vec3;
use crate::navmesh::NavMesh;
use std::collections::BinaryHeap;
use std::cmp::Ordering;

#[derive(Debug)]
struct AStarNode {
    tri_idx: usize,
    g_cost: f32,
    f_cost: f32,
}

impl PartialEq for AStarNode {
    fn eq(&self, other: &Self) -> bool { self.tri_idx == other.tri_idx }
}
impl Eq for AStarNode {}

impl PartialOrd for AStarNode {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> { Some(self.cmp(other)) }
}

impl Ord for AStarNode {
    fn cmp(&self, other: &Self) -> Ordering {
        // Min-heap: reverse comparison
        other.f_cost.partial_cmp(&self.f_cost).unwrap_or(Ordering::Equal)
    }
}

/// Find a path on the NavMesh from start to goal.
/// Returns waypoints (triangle centers) or None if no path exists.
pub fn find_path(navmesh: &NavMesh, start: Vec3, goal: Vec3) -> Option<Vec<Vec3>> {
    let start_tri = navmesh.find_triangle(start)?;
    let goal_tri = navmesh.find_triangle(goal)?;

    if start_tri == goal_tri {
        return Some(vec![start, goal]);
    }

    let tri_count = navmesh.triangles.len();
    let mut g_costs = vec![f32::INFINITY; tri_count];
    let mut came_from: Vec<Option<usize>> = vec![None; tri_count];
    let mut closed = vec![false; tri_count];

    g_costs[start_tri] = 0.0;

    let mut open = BinaryHeap::new();
    let h = distance_xz(navmesh.triangle_center(start_tri), navmesh.triangle_center(goal_tri));
    open.push(AStarNode { tri_idx: start_tri, g_cost: 0.0, f_cost: h });

    while let Some(current) = open.pop() {
        if current.tri_idx == goal_tri {
            // Reconstruct path
            let mut path = vec![goal];
            let mut idx = goal_tri;
            while let Some(prev) = came_from[idx] {
                path.push(navmesh.triangle_center(idx));
                idx = prev;
            }
            path.push(start);
            path.reverse();
            return Some(path);
        }

        if closed[current.tri_idx] {
            continue;
        }
        closed[current.tri_idx] = true;

        let tri = &navmesh.triangles[current.tri_idx];
        for neighbor_opt in &tri.neighbors {
            if let Some(neighbor) = *neighbor_opt {
                if closed[neighbor] {
                    continue;
                }

                let edge_cost = distance_xz(
                    navmesh.triangle_center(current.tri_idx),
                    navmesh.triangle_center(neighbor),
                );
                let tentative_g = g_costs[current.tri_idx] + edge_cost;

                if tentative_g < g_costs[neighbor] {
                    g_costs[neighbor] = tentative_g;
                    came_from[neighbor] = Some(current.tri_idx);
                    let h = distance_xz(navmesh.triangle_center(neighbor), navmesh.triangle_center(goal_tri));
                    open.push(AStarNode {
                        tri_idx: neighbor,
                        g_cost: tentative_g,
                        f_cost: tentative_g + h,
                    });
                }
            }
        }
    }

    None // No path found
}

fn distance_xz(a: Vec3, b: Vec3) -> f32 {
    let dx = a.x - b.x;
    let dz = a.z - b.z;
    (dx * dx + dz * dz).sqrt()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::navmesh::{NavMesh, NavTriangle};

    fn make_square_navmesh() -> NavMesh {
        let vertices = vec![
            Vec3::new(0.0, 0.0, 0.0),
            Vec3::new(10.0, 0.0, 0.0),
            Vec3::new(10.0, 0.0, 10.0),
            Vec3::new(0.0, 0.0, 10.0),
        ];
        let triangles = vec![
            NavTriangle { indices: [0, 1, 2], neighbors: [None, Some(1), None] },
            NavTriangle { indices: [0, 2, 3], neighbors: [Some(0), None, None] },
        ];
        NavMesh::new(vertices, triangles)
    }

    fn make_three_triangle_strip() -> NavMesh {
        // Three triangles in a strip: 0-1-2 connected
        let vertices = vec![
            Vec3::new(0.0, 0.0, 0.0),   // 0
            Vec3::new(5.0, 0.0, 0.0),   // 1
            Vec3::new(5.0, 0.0, 5.0),   // 2
            Vec3::new(10.0, 0.0, 0.0),  // 3
            Vec3::new(10.0, 0.0, 5.0),  // 4
            Vec3::new(15.0, 0.0, 0.0),  // 5
            Vec3::new(15.0, 0.0, 5.0),  // 6
        ];
        let triangles = vec![
            NavTriangle { indices: [0, 1, 2], neighbors: [Some(1), None, None] },
            NavTriangle { indices: [1, 3, 4], neighbors: [Some(2), None, Some(0)] },
            NavTriangle { indices: [3, 5, 6], neighbors: [None, None, Some(1)] },
        ];
        NavMesh::new(vertices, triangles)
    }

    #[test]
    fn test_same_triangle() {
        let nm = make_square_navmesh();
        let path = find_path(&nm, Vec3::new(8.0, 0.0, 2.0), Vec3::new(9.0, 0.0, 1.0));
        let p = path.unwrap();
        assert_eq!(p.len(), 2);
    }

    #[test]
    fn test_adjacent_triangles() {
        let nm = make_square_navmesh();
        let path = find_path(&nm, Vec3::new(8.0, 0.0, 2.0), Vec3::new(2.0, 0.0, 8.0));
        let p = path.unwrap();
        assert!(p.len() >= 2);
        // First point is start, last is goal
        assert!((p[0].x - 8.0).abs() < 0.01);
        assert!((p.last().unwrap().x - 2.0).abs() < 0.01);
    }

    #[test]
    fn test_three_triangle_path() {
        let nm = make_three_triangle_strip();
        let path = find_path(&nm, Vec3::new(2.0, 0.0, 1.0), Vec3::new(14.0, 0.0, 1.0));
        let p = path.unwrap();
        assert!(p.len() >= 3); // start + midpoints + goal
    }

    #[test]
    fn test_no_path_outside() {
        let nm = make_square_navmesh();
        assert!(find_path(&nm, Vec3::new(-5.0, 0.0, 0.0), Vec3::new(5.0, 0.0, 5.0)).is_none());
    }

    #[test]
    fn test_disconnected() {
        // Two triangles not connected
        let vertices = vec![
            Vec3::new(0.0, 0.0, 0.0),
            Vec3::new(5.0, 0.0, 0.0),
            Vec3::new(2.5, 0.0, 5.0),
            Vec3::new(20.0, 0.0, 0.0),
            Vec3::new(25.0, 0.0, 0.0),
            Vec3::new(22.5, 0.0, 5.0),
        ];
        let triangles = vec![
            NavTriangle { indices: [0, 1, 2], neighbors: [None, None, None] },
            NavTriangle { indices: [3, 4, 5], neighbors: [None, None, None] },
        ];
        let nm = NavMesh::new(vertices, triangles);
        assert!(find_path(&nm, Vec3::new(2.0, 0.0, 1.0), Vec3::new(22.0, 0.0, 1.0)).is_none());
    }
}
  • Step 2: lib.rs에 모듈 등록
pub mod pathfinding;
pub use pathfinding::find_path;
  • Step 3: 테스트 실행

Run: cargo test -p voltex_ai Expected: 9 PASS (4 navmesh + 5 pathfinding)

  • Step 4: 커밋
git add crates/voltex_ai/src/pathfinding.rs crates/voltex_ai/src/lib.rs
git commit -m "feat(ai): add A* pathfinding on NavMesh triangle graph"

Task 3: 스티어링 행동

Files:

  • Create: crates/voltex_ai/src/steering.rs

  • Modify: crates/voltex_ai/src/lib.rs

  • Step 1: steering.rs 작성

// crates/voltex_ai/src/steering.rs
use voltex_math::Vec3;

#[derive(Debug, Clone, Copy)]
pub struct SteeringAgent {
    pub position: Vec3,
    pub velocity: Vec3,
    pub max_speed: f32,
    pub max_force: f32,
}

impl SteeringAgent {
    pub fn new(position: Vec3, max_speed: f32, max_force: f32) -> Self {
        Self { position, velocity: Vec3::ZERO, max_speed, max_force }
    }
}

fn truncate(v: Vec3, max_len: f32) -> Vec3 {
    let len = v.length();
    if len > max_len && len > 1e-6 {
        v * (max_len / len)
    } else {
        v
    }
}

/// Steer toward a target at max speed.
pub fn seek(agent: &SteeringAgent, target: Vec3) -> Vec3 {
    let desired = target - agent.position;
    let len = desired.length();
    if len < 1e-6 { return Vec3::ZERO; }
    let desired = desired * (agent.max_speed / len);
    truncate(desired - agent.velocity, agent.max_force)
}

/// Steer away from a threat.
pub fn flee(agent: &SteeringAgent, threat: Vec3) -> Vec3 {
    let desired = agent.position - threat;
    let len = desired.length();
    if len < 1e-6 { return Vec3::ZERO; }
    let desired = desired * (agent.max_speed / len);
    truncate(desired - agent.velocity, agent.max_force)
}

/// Steer toward target, decelerating within slow_radius.
pub fn arrive(agent: &SteeringAgent, target: Vec3, slow_radius: f32) -> Vec3 {
    let to_target = target - agent.position;
    let dist = to_target.length();
    if dist < 0.01 { return Vec3::ZERO; }

    let speed = if dist < slow_radius {
        agent.max_speed * (dist / slow_radius)
    } else {
        agent.max_speed
    };

    let desired = to_target * (speed / dist);
    truncate(desired - agent.velocity, agent.max_force)
}

/// Steer in a wandering pattern.
/// `angle` should be varied by the caller each frame (add small random delta).
pub fn wander(agent: &SteeringAgent, wander_radius: f32, wander_distance: f32, angle: f32) -> Vec3 {
    let forward = if agent.velocity.length() > 1e-6 {
        agent.velocity.normalize()
    } else {
        Vec3::Z
    };

    let circle_center = agent.position + forward * wander_distance;
    let offset = Vec3::new(angle.cos() * wander_radius, 0.0, angle.sin() * wander_radius);
    let wander_target = circle_center + offset;

    seek(agent, wander_target)
}

/// Follow a path of waypoints. Returns (steering_force, current_waypoint_index).
pub fn follow_path(
    agent: &SteeringAgent,
    path: &[Vec3],
    current_waypoint: usize,
    waypoint_radius: f32,
) -> (Vec3, usize) {
    if path.is_empty() {
        return (Vec3::ZERO, 0);
    }

    let mut wp = current_waypoint.min(path.len() - 1);

    // Advance waypoint if close enough
    let dist = (path[wp] - agent.position).length();
    if dist < waypoint_radius && wp < path.len() - 1 {
        wp += 1;
    }

    // Last waypoint: arrive
    let force = if wp == path.len() - 1 {
        arrive(agent, path[wp], waypoint_radius * 2.0)
    } else {
        seek(agent, path[wp])
    };

    (force, wp)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn approx_vec(a: Vec3, b: Vec3) -> bool {
        (a.x - b.x).abs() < 0.1 && (a.y - b.y).abs() < 0.1 && (a.z - b.z).abs() < 0.1
    }

    #[test]
    fn test_seek_direction() {
        let agent = SteeringAgent::new(Vec3::ZERO, 5.0, 10.0);
        let force = seek(&agent, Vec3::new(10.0, 0.0, 0.0));
        assert!(force.x > 0.0, "seek should steer toward target, got x={}", force.x);
    }

    #[test]
    fn test_flee_direction() {
        let agent = SteeringAgent::new(Vec3::ZERO, 5.0, 10.0);
        let force = flee(&agent, Vec3::new(10.0, 0.0, 0.0));
        assert!(force.x < 0.0, "flee should steer away from threat, got x={}", force.x);
    }

    #[test]
    fn test_arrive_deceleration() {
        let agent = SteeringAgent::new(Vec3::ZERO, 5.0, 10.0);
        let far_force = arrive(&agent, Vec3::new(100.0, 0.0, 0.0), 10.0);
        let near_force = arrive(&agent, Vec3::new(5.0, 0.0, 0.0), 10.0);
        // Near force should be weaker (decelerating)
        assert!(near_force.length() < far_force.length(),
            "near={} should be < far={}", near_force.length(), far_force.length());
    }

    #[test]
    fn test_arrive_at_target() {
        let agent = SteeringAgent::new(Vec3::new(5.0, 0.0, 0.0), 5.0, 10.0);
        let force = arrive(&agent, Vec3::new(5.0, 0.0, 0.0), 1.0);
        assert!(force.length() < 0.1, "at target, force should be ~zero");
    }

    #[test]
    fn test_follow_path_advance() {
        let agent = SteeringAgent::new(Vec3::new(0.9, 0.0, 0.0), 5.0, 10.0);
        let path = vec![
            Vec3::new(1.0, 0.0, 0.0),
            Vec3::new(5.0, 0.0, 0.0),
            Vec3::new(10.0, 0.0, 0.0),
        ];
        let (_, wp) = follow_path(&agent, &path, 0, 0.5);
        assert_eq!(wp, 1, "should advance to next waypoint");
    }

    #[test]
    fn test_follow_path_last_arrives() {
        let agent = SteeringAgent::new(Vec3::new(9.5, 0.0, 0.0), 5.0, 10.0);
        let path = vec![
            Vec3::new(5.0, 0.0, 0.0),
            Vec3::new(10.0, 0.0, 0.0),
        ];
        let (force, wp) = follow_path(&agent, &path, 1, 1.0);
        // Should be arriving (low force)
        assert!(force.length() < 5.0);
    }
}
  • Step 2: lib.rs에 모듈 등록
pub mod steering;
pub use steering::{SteeringAgent, seek, flee, arrive, wander, follow_path};
  • Step 3: 테스트 실행

Run: cargo test -p voltex_ai Expected: 15 PASS (4 navmesh + 5 pathfinding + 6 steering)

  • Step 4: 전체 workspace 테스트

Run: cargo test --workspace Expected: all pass

  • Step 5: 커밋
git add crates/voltex_ai/src/steering.rs crates/voltex_ai/src/lib.rs
git commit -m "feat(ai): add steering behaviors (seek, flee, arrive, wander, follow_path)"

Task 4: 문서 업데이트

Files:

  • Modify: docs/STATUS.md

  • Modify: docs/DEFERRED.md

  • Step 1: STATUS.md에 Phase 8-1 추가

### Phase 8-1: AI System
- voltex_ai: NavMesh (manual triangle mesh, find_triangle, edge/center queries)
- voltex_ai: A* pathfinding on triangle graph (center-point path)
- voltex_ai: Steering behaviors (seek, flee, arrive, wander, follow_path)

crate 구조에 voltex_ai 추가. 테스트 수 업데이트.

  • Step 2: DEFERRED.md에 Phase 8-1 미뤄진 항목
## Phase 8-1

- **자동 내비메시 생성** — Recast 스타일 복셀화 미구현. 수동 정의만.
- **String Pulling (Funnel)** — 삼각형 중심점 경로만. 최적 경로 스무딩 미구현.
- **동적 장애물 회피** — 정적 내비메시만. 런타임 장애물 미처리.
- **ECS 통합** — AI 컴포넌트 미구현. 함수 직접 호출.
- **내비메시 직렬화** — 미구현.
  • Step 3: 커밋
git add docs/STATUS.md docs/DEFERRED.md
git commit -m "docs: add Phase 8-1 AI system status and deferred items"