docs: add Phase 8-1 AI system status, spec, plan, and deferred items

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 14:20:50 +09:00
parent acaad86aee
commit e28690b24a
4 changed files with 841 additions and 3 deletions

View File

@@ -62,6 +62,14 @@
- **raycast_all (다중 hit)** — 가장 가까운 hit만 반환.
- **BVH 조기 종료 최적화** — 모든 리프 검사 후 최소 t 선택. front-to-back 순회 미구현.
## Phase 8-1
- **자동 내비메시 생성** — Recast 스타일 복셀화 미구현. 수동 정의만.
- **String Pulling (Funnel)** — 삼각형 중심점 경로만. 최적 경로 스무딩 미구현.
- **동적 장애물 회피** — 정적 내비메시만. 런타임 장애물 미처리.
- **ECS 통합** — AI 컴포넌트 미구현. 함수 직접 호출.
- **내비메시 직렬화** — 미구현.
## Phase 7-4
- **TAA** — Temporal Anti-Aliasing 미구현. Motion vector 필요.

View File

@@ -120,6 +120,11 @@
- voltex_renderer: Tonemap shader (ACES filmic + bloom merge + gamma)
- deferred_demo updated with full post-processing (7 passes)
### 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 구조
```
@@ -130,10 +135,11 @@ crates/
├── voltex_ecs — Entity, SparseSet, World, Transform, Hierarchy, Scene, WorldTransform
├── voltex_asset — Handle<T>, AssetStorage<T>, Assets
├── voltex_physics — Collider, ContactPoint, BvhTree, RigidBody, detect_collisions, physics_step, raycast
── voltex_audio — AudioClip, WAV parser, mixing, WASAPI backend, AudioSystem, MixGroup, spatial
── voltex_audio — AudioClip, WAV parser, mixing, WASAPI backend, AudioSystem, MixGroup, spatial
└── voltex_ai — NavMesh, A* pathfinding, steering behaviors
```
## 테스트: 213개 전부 통과
## 테스트: 228개 전부 통과
- voltex_asset: 14
- voltex_audio: 35 (audio_clip 2 + wav 5 + mixing 11 + audio_system 2 + spatial 8 + mix_group 7)
@@ -141,6 +147,7 @@ crates/
- voltex_math: 37 (29 + AABB 6 + Ray 2)
- voltex_physics: 52 (collider 2 + narrow 11 + bvh 5 + collision 7 + rigid_body 3 + integrator 3 + solver 5 + ray 10 + raycast 6)
- voltex_platform: 3
- voltex_ai: 15 (navmesh 4 + pathfinding 5 + steering 6)
- voltex_renderer: 33 (20 + SSGI 3 + RT 3 + bloom 3 + tonemap 4)
## Examples (11개)
@@ -157,7 +164,7 @@ crates/
- audio_demo — 사인파 오디오 재생
- deferred_demo — 디퍼드 렌더링 + 다중 포인트 라이트
## 다음: Phase 8 (AI, 네트워킹, 스크립팅, 에디터) — Stretch Goal
## 다음: Phase 8-2 (네트워킹) / 8-3 (스크립팅) / 8-4 (에디터) — Stretch Goal
스펙 참조: `docs/superpowers/specs/2026-03-24-voltex-engine-design.md`

View File

@@ -0,0 +1,672 @@
# 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"
```

View File

@@ -0,0 +1,151 @@
# Phase 8-1: AI System — Design Spec
## Overview
`voltex_ai` crate를 신규 생성한다. 수동 정의 내비메시, A* 패스파인딩, 스티어링 행동을 구현한다.
## Scope
- NavMesh (수동 삼각형 폴리곤, 인접 정보)
- A* 패스파인딩 (삼각형 그래프)
- 스티어링 행동 (Seek, Flee, Arrive, Wander, FollowPath)
## Out of Scope
- 자동 내비메시 생성 (Recast 스타일 복셀화)
- String pulling (Funnel algorithm)
- 동적 장애물 회피
- ECS 통합 (AI 컴포넌트)
- 내비메시 직렬화
## Module Structure
```
crates/voltex_ai/
├── Cargo.toml
└── src/
├── lib.rs
├── navmesh.rs — NavMesh, NavTriangle
├── pathfinding.rs — A* find_path
└── steering.rs — SteeringAgent, seek/flee/arrive/wander/follow_path
```
## Dependencies
- `voltex_math` — Vec3
## Types
### NavTriangle
```rust
#[derive(Debug, Clone)]
pub struct NavTriangle {
pub indices: [usize; 3],
pub neighbors: [Option<usize>; 3],
}
```
- `indices` — NavMesh.vertices 인덱스 (CCW)
- `neighbors[i]` — edge i↔(i+1) 반대편 삼각형. None이면 경계 에지.
### NavMesh
```rust
pub struct NavMesh {
pub vertices: Vec<Vec3>,
pub triangles: Vec<NavTriangle>,
}
```
**Methods:**
- `new(vertices, triangles)` — 생성
- `find_triangle(point: Vec3) -> Option<usize>` — XZ 평면 투영으로 점이 속한 삼각형 인덱스 반환
- `triangle_center(tri_idx: usize) -> Vec3` — 삼각형 세 꼭짓점의 중심
- `edge_midpoint(tri_idx: usize, edge: usize) -> Vec3` — 에지 중점 (공유 에지 통과 지점)
### A* Pathfinding
```rust
pub fn find_path(navmesh: &NavMesh, start: Vec3, goal: Vec3) -> Option<Vec<Vec3>>
```
1. `find_triangle(start)`, `find_triangle(goal)` — 시작/목표 삼각형
2. 둘 다 찾지 못하면 None
3. 같은 삼각형이면 `vec![start, goal]`
4. A* on triangle graph:
- Node = triangle index
- Neighbors = triangle.neighbors (Some만)
- g cost = 현재까지 실제 거리 (중심점 간)
- h cost = 현재 삼각형 중심 → 목표 삼각형 중심 유클리드 거리
5. 결과: 삼각형 체인 → 각 삼각형 중심점으로 경로 생성
6. 경로 시작에 start, 끝에 goal 삽입
### SteeringAgent
```rust
#[derive(Debug, Clone, Copy)]
pub struct SteeringAgent {
pub position: Vec3,
pub velocity: Vec3,
pub max_speed: f32,
pub max_force: f32,
}
```
### Steering Functions
모두 순수 함수, `Vec3` 조향력 반환 (에이전트 velocity에 더하기 전).
```rust
pub fn seek(agent: &SteeringAgent, target: Vec3) -> Vec3
```
- desired = normalize(target - position) * max_speed
- steering = desired - velocity
- truncate to max_force
```rust
pub fn flee(agent: &SteeringAgent, threat: Vec3) -> Vec3
```
- seek 반대 방향
```rust
pub fn arrive(agent: &SteeringAgent, target: Vec3, slow_radius: f32) -> Vec3
```
- distance < slow_radius이면 desired speed = max_speed * (distance / slow_radius)
- 목표에 매우 가까우면 (< 0.01) 제로
```rust
pub fn wander(agent: &SteeringAgent, wander_radius: f32, wander_distance: f32, angle: f32) -> Vec3
```
- agent 전방 wander_distance에 원(wander_radius) 위의 점을 target으로 seek
- angle은 호출자가 매 프레임 랜덤 변경
```rust
pub fn follow_path(agent: &SteeringAgent, path: &[Vec3], current_waypoint: usize, waypoint_radius: f32) -> (Vec3, usize)
```
- current_waypoint에 도달하면 다음으로 전진
- 마지막 웨이포인트면 arrive
- 아니면 seek
- 반환: (steering_force, updated_waypoint_index)
## Test Plan
### navmesh.rs
- 정사각형(2 삼각형) 내비메시 생성
- find_triangle: 내부 점 → Some, 외부 점 → None
- triangle_center: 정확한 중심
- edge_midpoint: 공유 에지 중점
### pathfinding.rs
- 같은 삼각형: 직선 경로
- 인접 삼각형: 2-step 경로
- 3개 삼각형 체인: 올바른 순서
- 도달 불가: None
- start/goal이 navmesh 밖: None
### steering.rs
- seek: 목표 방향
- flee: 반대 방향
- arrive: 가까우면 감속, 멀면 max_speed
- follow_path: 웨이포인트 전진