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"