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:
@@ -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 필요.
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
672
docs/superpowers/plans/2026-03-25-phase8-1-ai.md
Normal file
672
docs/superpowers/plans/2026-03-25-phase8-1-ai.md
Normal 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"
|
||||
```
|
||||
151
docs/superpowers/specs/2026-03-25-phase8-1-ai.md
Normal file
151
docs/superpowers/specs/2026-03-25-phase8-1-ai.md
Normal 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: 웨이포인트 전진
|
||||
Reference in New Issue
Block a user