From e28690b24ac8e285b1391728b60787ef2c4c60ea Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 25 Mar 2026 14:20:50 +0900 Subject: [PATCH] docs: add Phase 8-1 AI system status, spec, plan, and deferred items Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/DEFERRED.md | 8 + docs/STATUS.md | 13 +- .../plans/2026-03-25-phase8-1-ai.md | 672 ++++++++++++++++++ .../specs/2026-03-25-phase8-1-ai.md | 151 ++++ 4 files changed, 841 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-25-phase8-1-ai.md create mode 100644 docs/superpowers/specs/2026-03-25-phase8-1-ai.md diff --git a/docs/DEFERRED.md b/docs/DEFERRED.md index df31b45..dd30ac4 100644 --- a/docs/DEFERRED.md +++ b/docs/DEFERRED.md @@ -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 필요. diff --git a/docs/STATUS.md b/docs/STATUS.md index 48b492a..ef17d8f 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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, AssetStorage, 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` diff --git a/docs/superpowers/plans/2026-03-25-phase8-1-ai.md b/docs/superpowers/plans/2026-03-25-phase8-1-ai.md new file mode 100644 index 0000000..7e2dc43 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-phase8-1-ai.md @@ -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; 3], +} + +pub struct NavMesh { + pub vertices: Vec, + pub triangles: Vec, +} + +impl NavMesh { + pub fn new(vertices: Vec, triangles: Vec) -> Self { + Self { vertices, triangles } + } + + /// Find which triangle contains the point (XZ plane projection, Y ignored). + pub fn find_triangle(&self, point: Vec3) -> Option { + 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 { 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> { + 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> = 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" +``` diff --git a/docs/superpowers/specs/2026-03-25-phase8-1-ai.md b/docs/superpowers/specs/2026-03-25-phase8-1-ai.md new file mode 100644 index 0000000..ef92ed6 --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-phase8-1-ai.md @@ -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; 3], +} +``` + +- `indices` — NavMesh.vertices 인덱스 (CCW) +- `neighbors[i]` — edge i↔(i+1) 반대편 삼각형. None이면 경계 에지. + +### NavMesh + +```rust +pub struct NavMesh { + pub vertices: Vec, + pub triangles: Vec, +} +``` + +**Methods:** +- `new(vertices, triangles)` — 생성 +- `find_triangle(point: Vec3) -> Option` — 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> +``` + +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: 웨이포인트 전진