673 lines
20 KiB
Markdown
673 lines
20 KiB
Markdown
# 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**
|
|
|
|
```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 작성**
|
|
|
|
```rust
|
|
// 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 작성**
|
|
|
|
```rust
|
|
pub mod navmesh;
|
|
pub use navmesh::{NavMesh, NavTriangle};
|
|
```
|
|
|
|
- [ ] **Step 5: 테스트 실행**
|
|
|
|
Run: `cargo test -p voltex_ai`
|
|
Expected: 4 PASS
|
|
|
|
- [ ] **Step 6: 커밋**
|
|
|
|
```bash
|
|
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 작성**
|
|
|
|
```rust
|
|
// 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에 모듈 등록**
|
|
|
|
```rust
|
|
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: 커밋**
|
|
|
|
```bash
|
|
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 작성**
|
|
|
|
```rust
|
|
// 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에 모듈 등록**
|
|
|
|
```rust
|
|
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: 커밋**
|
|
|
|
```bash
|
|
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 추가**
|
|
|
|
```markdown
|
|
### 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 미뤄진 항목**
|
|
|
|
```markdown
|
|
## Phase 8-1
|
|
|
|
- **자동 내비메시 생성** — Recast 스타일 복셀화 미구현. 수동 정의만.
|
|
- **String Pulling (Funnel)** — 삼각형 중심점 경로만. 최적 경로 스무딩 미구현.
|
|
- **동적 장애물 회피** — 정적 내비메시만. 런타임 장애물 미처리.
|
|
- **ECS 통합** — AI 컴포넌트 미구현. 함수 직접 호출.
|
|
- **내비메시 직렬화** — 미구현.
|
|
```
|
|
|
|
- [ ] **Step 3: 커밋**
|
|
|
|
```bash
|
|
git add docs/STATUS.md docs/DEFERRED.md
|
|
git commit -m "docs: add Phase 8-1 AI system status and deferred items"
|
|
```
|