| 1 | //! Port of `acting.js`: presence → acting state → pose over time. |
| 2 | //! |
| 3 | //! ```text |
| 4 | //! pose(t) = spring(base pose of the state) state blends, follow-through |
| 5 | //! + Σ one-shot clips (enter, exit, beats) authored, exact frame timing |
| 6 | //! + loop clip of the state the ongoing performance |
| 7 | //! + ambient (breath, bob, blinks, puffs) alive, never dancing |
| 8 | //! + drag (flukes and fins trail the body) secondary motion |
| 9 | //! ``` |
| 10 | //! |
| 11 | //! Nothing here invents activity: every acting state comes from the owner's |
| 12 | //! presence, every work action from the owner's activity kind, every beat |
| 13 | //! from an observed owner onset. The Director owns no status timer and no |
| 14 | //! telemetry; it is a pure function of its inputs and the frames it steps. |
| 15 | |
| 16 | use super::data::{Act, Ambient, Clip, Emit, N, Pose, PosterParticle, p, tables}; |
| 17 | use super::math::{Rng, TAU, clamp, lerp}; |
| 18 | use super::props::{Particle, ParticleKind}; |
| 19 | use super::rig::{Anchors, MARK_DIRECTION, build}; |
| 20 | use std::collections::{HashSet, VecDeque}; |
| 21 | |
| 22 | pub const FPS: f64 = 30.; |
| 23 | |
| 24 | /// The authoritative owner presence. The first seven are today's GPUI |
| 25 | /// states; `Stuck` is the handoff's **proposed** eighth state, accepted here |
| 26 | /// for conformance only — the app never emits it. |
| 27 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 28 | pub enum Presence { |
| 29 | Offline, |
| 30 | Idle, |
| 31 | Listening, |
| 32 | Thinking, |
| 33 | Working, |
| 34 | NeedsYou, |
| 35 | Done, |
| 36 | Stuck, |
| 37 | } |
| 38 | |
| 39 | /// One observed foreground span onset reported by the owner. |
| 40 | #[derive(Clone, Debug, PartialEq)] |
| 41 | pub struct Span { |
| 42 | pub kind: String, |
| 43 | pub since_ms: f64, |
| 44 | } |
| 45 | |
| 46 | /// The owner's activity, exactly as reported. `observed` is true only when |
| 47 | /// the owner strictly said so; `parallel` keeps its raw number so a |
| 48 | /// non-integer count is refused rather than rounded. |
| 49 | #[derive(Clone, Debug, Default, PartialEq)] |
| 50 | pub struct Activity { |
| 51 | pub kind: Option<String>, |
| 52 | pub observed: bool, |
| 53 | pub parallel: Option<f64>, |
| 54 | pub active: Vec<Span>, |
| 55 | } |
| 56 | |
| 57 | #[derive(Clone, Debug, Default, PartialEq)] |
| 58 | pub struct Context { |
| 59 | /// `freshness === 'Live'`. Missing or stale freshness is plain work. |
| 60 | pub live: bool, |
| 61 | /// Stable turn identity; a completed one authorizes one Done flourish. |
| 62 | pub turn_id: Option<String>, |
| 63 | /// The explicit terminal status: completed, failed, error, canceled, |
| 64 | /// interrupted. Never inferred. |
| 65 | pub status: Option<String>, |
| 66 | pub now_ms: Option<f64>, |
| 67 | pub failed_at_ms: Option<f64>, |
| 68 | } |
| 69 | |
| 70 | #[derive(Clone, Debug, PartialEq)] |
| 71 | pub enum Event { |
| 72 | Tool { |
| 73 | kind: String, |
| 74 | }, |
| 75 | Failed { |
| 76 | kind: String, |
| 77 | status: Option<String>, |
| 78 | }, |
| 79 | } |
| 80 | |
| 81 | /// `actingFor()`. |
| 82 | pub fn acting_for(presence: Presence, activity: Option<&Activity>, context: &Context) -> Act { |
| 83 | match presence { |
| 84 | Presence::Offline => Act::Sleep, |
| 85 | Presence::Idle => Act::Rest, |
| 86 | Presence::Listening => Act::Listen, |
| 87 | Presence::Thinking => Act::Think, |
| 88 | Presence::NeedsYou => Act::Needs, |
| 89 | Presence::Done => Act::Done, |
| 90 | Presence::Stuck => { |
| 91 | let failed = matches!(context.status.as_deref(), Some("failed" | "error")); |
| 92 | let age = match (context.now_ms, context.failed_at_ms) { |
| 93 | (Some(now), Some(at)) => now - at, |
| 94 | _ => f64::NAN, |
| 95 | }; |
| 96 | if failed && (0. ..3000.).contains(&age) { |
| 97 | Act::Hmm |
| 98 | } else { |
| 99 | Act::Rest |
| 100 | } |
| 101 | } |
| 102 | Presence::Working => match activity { |
| 103 | Some(activity) if activity.observed && context.live => activity |
| 104 | .kind |
| 105 | .as_deref() |
| 106 | .and_then(|kind| tables().act_for_kind(kind)) |
| 107 | .unwrap_or(Act::Busy), |
| 108 | _ => Act::Busy, |
| 109 | }, |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 114 | pub enum ClipId { |
| 115 | Enter(Act), |
| 116 | Loop(Act), |
| 117 | Beat(Act), |
| 118 | Exit(Act), |
| 119 | Wake, |
| 120 | HmmBeat, |
| 121 | Puff, |
| 122 | Blink, |
| 123 | } |
| 124 | |
| 125 | impl ClipId { |
| 126 | pub fn clip(self) -> &'static Clip { |
| 127 | let t = tables(); |
| 128 | match self { |
| 129 | ClipId::Enter(act) => &t.act(act).enter, |
| 130 | ClipId::Loop(act) => &t.act(act).loop_, |
| 131 | ClipId::Beat(act) => t.act(act).beat.as_ref().expect("beat"), |
| 132 | ClipId::Exit(act) => t.act(act).exit.as_ref().expect("exit"), |
| 133 | ClipId::Wake => &t.wake, |
| 134 | ClipId::HmmBeat => &t.hmm_beat, |
| 135 | ClipId::Puff => &t.puff, |
| 136 | ClipId::Blink => &t.blink, |
| 137 | } |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | #[derive(Clone, Copy, Debug)] |
| 142 | pub struct Shot { |
| 143 | pub clip: ClipId, |
| 144 | pub at: f64, |
| 145 | pub owner: Act, |
| 146 | pub exit: bool, |
| 147 | pub wake: bool, |
| 148 | pub beat: bool, |
| 149 | } |
| 150 | |
| 151 | #[derive(Clone, Copy, Debug, Default)] |
| 152 | pub struct Calf { |
| 153 | pub value: f64, |
| 154 | pub velocity: f64, |
| 155 | pub target: f64, |
| 156 | } |
| 157 | |
| 158 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 159 | pub struct Emission { |
| 160 | pub kind: Emit, |
| 161 | pub owner: Act, |
| 162 | pub f: f64, |
| 163 | } |
| 164 | |
| 165 | #[derive(Clone, Copy, Debug)] |
| 166 | struct AmbientClock { |
| 167 | blink_at: f64, |
| 168 | dart_at: f64, |
| 169 | puff_at: f64, |
| 170 | sleep_at: f64, |
| 171 | look_at: f64, |
| 172 | look: (f64, f64), |
| 173 | cur: (f64, f64), |
| 174 | } |
| 175 | |
| 176 | #[derive(Clone, Copy, Debug)] |
| 177 | struct Departure { |
| 178 | at: f64, |
| 179 | offset: Pose, |
| 180 | } |
| 181 | |
| 182 | #[derive(Clone, Debug)] |
| 183 | pub struct Options { |
| 184 | pub seed: u32, |
| 185 | pub reduced: bool, |
| 186 | pub ambient: bool, |
| 187 | } |
| 188 | |
| 189 | impl Default for Options { |
| 190 | fn default() -> Self { |
| 191 | Self { |
| 192 | seed: 7, |
| 193 | reduced: false, |
| 194 | ambient: true, |
| 195 | } |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | /// One session's performance. Drop it when the foreground session changes: |
| 200 | /// no pending clip, calf count or completed-turn identity may carry over. |
| 201 | #[derive(Clone, Debug)] |
| 202 | pub struct Director { |
| 203 | rng: Rng, |
| 204 | /// Frame time at 30 fps, floating. |
| 205 | pub f: f64, |
| 206 | pub presence: Presence, |
| 207 | pub activity: Option<Activity>, |
| 208 | pub context: Context, |
| 209 | pub acting: Act, |
| 210 | pub reduced: bool, |
| 211 | ambient_on: bool, |
| 212 | x: Pose, |
| 213 | v: Pose, |
| 214 | pub shots: Vec<Shot>, |
| 215 | loops: Vec<Shot>, |
| 216 | pub particles: Vec<Particle>, |
| 217 | pub lines: f64, |
| 218 | pub events: VecDeque<(Event, f64)>, |
| 219 | pub emissions: VecDeque<Emission>, |
| 220 | amb: AmbientClock, |
| 221 | pose_out: Pose, |
| 222 | departure: Option<Departure>, |
| 223 | changed_at: f64, |
| 224 | from_target: Pose, |
| 225 | delays: Pose, |
| 226 | done_turns: HashSet<String>, |
| 227 | spans: VecDeque<String>, |
| 228 | span_set: HashSet<String>, |
| 229 | pub calves: [Calf; 3], |
| 230 | anchors: Option<Anchors>, |
| 231 | } |
| 232 | |
| 233 | const HELD_PROPS: [usize; 9] = [ |
| 234 | p::page, |
| 235 | p::pad, |
| 236 | p::pencil, |
| 237 | p::lens, |
| 238 | p::wrench, |
| 239 | p::glass, |
| 240 | p::pod, |
| 241 | p::cursor, |
| 242 | p::link, |
| 243 | ]; |
| 244 | |
| 245 | impl Director { |
| 246 | pub fn new(options: Options) -> Self { |
| 247 | let seed = if options.seed == 0 { 7 } else { options.seed }; |
| 248 | let mut director = Self { |
| 249 | rng: Rng::new(seed), |
| 250 | f: 0., |
| 251 | presence: Presence::Idle, |
| 252 | activity: None, |
| 253 | context: Context::default(), |
| 254 | acting: Act::Rest, |
| 255 | reduced: options.reduced, |
| 256 | ambient_on: options.ambient, |
| 257 | x: [0.; N], |
| 258 | v: [0.; N], |
| 259 | shots: Vec::new(), |
| 260 | loops: vec![Shot { |
| 261 | clip: ClipId::Loop(Act::Rest), |
| 262 | at: 0., |
| 263 | owner: Act::Rest, |
| 264 | exit: false, |
| 265 | wake: false, |
| 266 | beat: false, |
| 267 | }], |
| 268 | particles: Vec::new(), |
| 269 | lines: 1., |
| 270 | events: VecDeque::new(), |
| 271 | emissions: VecDeque::new(), |
| 272 | amb: AmbientClock { |
| 273 | blink_at: 40., |
| 274 | dart_at: 120., |
| 275 | puff_at: 360., |
| 276 | sleep_at: 0., |
| 277 | look_at: 0., |
| 278 | look: (0., 0.), |
| 279 | cur: (0., 0.), |
| 280 | }, |
| 281 | pose_out: [0.; N], |
| 282 | departure: None, |
| 283 | changed_at: 0., |
| 284 | from_target: [0.; N], |
| 285 | delays: [0.; N], |
| 286 | done_turns: HashSet::new(), |
| 287 | spans: VecDeque::new(), |
| 288 | span_set: HashSet::new(), |
| 289 | calves: [Calf::default(); 3], |
| 290 | anchors: None, |
| 291 | }; |
| 292 | director.x = director.target_for(Act::Rest); |
| 293 | director.pose_out = director.x; |
| 294 | director |
| 295 | } |
| 296 | |
| 297 | pub fn target_for(&self, act: Act) -> Pose { |
| 298 | let mut pose = tables().defaults; |
| 299 | for (param, value) in &tables().act(act).base { |
| 300 | pose[*param] = *value; |
| 301 | } |
| 302 | pose[p::padLines] = if self.lines == 0. { 1. } else { self.lines }; |
| 303 | pose |
| 304 | } |
| 305 | |
| 306 | /// `set(presence, activity, context)`: the only way owner truth enters. |
| 307 | pub fn set(&mut self, presence: Presence, activity: Option<Activity>, context: Context) { |
| 308 | self.presence = presence; |
| 309 | self.activity = activity; |
| 310 | self.context = context; |
| 311 | let next = acting_for(presence, self.activity.as_ref(), &self.context); |
| 312 | let done_key = (presence == Presence::Done |
| 313 | && self.context.status.as_deref() == Some("completed")) |
| 314 | .then(|| self.context.turn_id.clone()) |
| 315 | .flatten() |
| 316 | .filter(|id| !id.is_empty()); |
| 317 | let celebrate = done_key.is_some_and(|key| self.done_turns.insert(key)); |
| 318 | self.go(next, celebrate, celebrate && next == Act::Done); |
| 319 | let n = match (next, self.activity.as_ref().and_then(|a| a.parallel)) { |
| 320 | (Act::Pod, Some(parallel)) if parallel.fract() == 0. && parallel > 0. => { |
| 321 | parallel.min(3.) as usize |
| 322 | } |
| 323 | _ => 0, |
| 324 | }; |
| 325 | let reduced = self.reduced; |
| 326 | for (i, calf) in self.calves.iter_mut().enumerate() { |
| 327 | calf.target = if i < n { 1. } else { 0. }; |
| 328 | if reduced { |
| 329 | calf.value = calf.target; |
| 330 | calf.velocity = 0.; |
| 331 | } |
| 332 | } |
| 333 | // Observe every onset, including those hidden by owner priority: |
| 334 | // never queue, never replay. |
| 335 | let spans = match &self.activity { |
| 336 | Some(activity) if activity.observed && self.context.live => { |
| 337 | activity.active.iter().take(4).cloned().collect() |
| 338 | } |
| 339 | _ => Vec::new(), |
| 340 | }; |
| 341 | for span in spans { |
| 342 | if !span.since_ms.is_finite() { |
| 343 | continue; |
| 344 | } |
| 345 | let since = if span.since_ms == 0. { |
| 346 | 0. |
| 347 | } else { |
| 348 | span.since_ms |
| 349 | }; |
| 350 | let key = format!( |
| 351 | "{}|{}|{}", |
| 352 | self.context.turn_id.as_deref().unwrap_or(""), |
| 353 | span.kind, |
| 354 | since |
| 355 | ); |
| 356 | if self.span_set.insert(key.clone()) { |
| 357 | self.spans.push_back(key); |
| 358 | self.event(Event::Tool { kind: span.kind }); |
| 359 | } |
| 360 | } |
| 361 | if self.span_set.len() > 512 { |
| 362 | while self.spans.len() > 256 { |
| 363 | let old = self.spans.pop_front().unwrap(); |
| 364 | self.span_set.remove(&old); |
| 365 | } |
| 366 | } |
| 367 | if self.reduced { |
| 368 | self.pose_out = self.poster(); |
| 369 | } |
| 370 | } |
| 371 | |
| 372 | fn go(&mut self, next: Act, celebrate: bool, force: bool) { |
| 373 | if next == self.acting && !force { |
| 374 | return; |
| 375 | } |
| 376 | let prev = self.acting; |
| 377 | let current = self.pose(); |
| 378 | // Freeze the visible offset, not the old clip's future movement or events. |
| 379 | let mut offset = [0.; N]; |
| 380 | for i in 0..N { |
| 381 | offset[i] = current[i] - self.x[i]; |
| 382 | } |
| 383 | self.departure = Some(Departure { at: self.f, offset }); |
| 384 | let wake_dur = tables().wake.dur; |
| 385 | let f = self.f; |
| 386 | self.shots |
| 387 | .retain(|s| s.wake && f - s.at < wake_dur && next != Act::Sleep); |
| 388 | self.loops.clear(); |
| 389 | self.particles.clear(); |
| 390 | self.acting = next; |
| 391 | self.changed_at = self.f; |
| 392 | self.from_target = self.x; |
| 393 | self.delays = [0.; N]; |
| 394 | self.delays[p::fin] = 3.; |
| 395 | self.delays[p::finFar] = 4.; |
| 396 | self.delays[p::fluke] = 4.; |
| 397 | self.delays[p::curl] = if prev == Act::Sleep { 2. } else { 3. }; |
| 398 | let think = if next == Act::Think { 6. } else { 0. }; |
| 399 | self.delays[p::lookX] = think; |
| 400 | self.delays[p::lookY] = think; |
| 401 | self.delays[p::lid] = if next == Act::Sleep { 6. } else { 0. }; |
| 402 | self.delays[p::glassExt] = if next == Act::Browse { 8. } else { 0. }; |
| 403 | if next == Act::Write { |
| 404 | self.lines = 1.; |
| 405 | } |
| 406 | if self.reduced { |
| 407 | self.x = self.target_for(next); |
| 408 | self.v = [0.; N]; |
| 409 | self.shots.clear(); |
| 410 | self.departure = None; |
| 411 | self.pose_out = self.poster(); |
| 412 | return; |
| 413 | } |
| 414 | let shot = |clip, owner| Shot { |
| 415 | clip, |
| 416 | at: f, |
| 417 | owner, |
| 418 | exit: false, |
| 419 | wake: false, |
| 420 | beat: false, |
| 421 | }; |
| 422 | if tables().act(prev).exit.is_some() { |
| 423 | self.shots.push(Shot { |
| 424 | exit: true, |
| 425 | ..shot(ClipId::Exit(prev), prev) |
| 426 | }); |
| 427 | } |
| 428 | if prev == Act::Sleep && next != Act::Sleep { |
| 429 | self.shots.push(Shot { |
| 430 | wake: true, |
| 431 | ..shot(ClipId::Wake, next) |
| 432 | }); |
| 433 | } |
| 434 | if next != Act::Done || celebrate { |
| 435 | self.shots.push(shot(ClipId::Enter(next), next)); |
| 436 | } |
| 437 | let enter = tables().act(next).enter.dur; |
| 438 | self.loops = vec![Shot { |
| 439 | at: f + (enter * 0.6).min(10.), |
| 440 | ..shot(ClipId::Loop(next), next) |
| 441 | }]; |
| 442 | } |
| 443 | |
| 444 | pub fn set_reduced(&mut self, value: bool) { |
| 445 | if self.reduced == value { |
| 446 | return; |
| 447 | } |
| 448 | self.reduced = value; |
| 449 | self.shots.clear(); |
| 450 | self.loops.clear(); |
| 451 | self.particles.clear(); |
| 452 | self.departure = None; |
| 453 | self.acting = acting_for(self.presence, self.activity.as_ref(), &self.context); |
| 454 | self.x = self.target_for(self.acting); |
| 455 | self.v = [0.; N]; |
| 456 | for calf in &mut self.calves { |
| 457 | calf.value = calf.target; |
| 458 | calf.velocity = 0.; |
| 459 | } |
| 460 | self.pose_out = self.poster(); |
| 461 | if !self.reduced { |
| 462 | self.loops = vec![Shot { |
| 463 | clip: ClipId::Loop(self.acting), |
| 464 | at: self.f, |
| 465 | owner: self.acting, |
| 466 | exit: false, |
| 467 | wake: false, |
| 468 | beat: false, |
| 469 | }]; |
| 470 | } |
| 471 | } |
| 472 | |
| 473 | /// An owner event. A beat plays only for matching, fresh, observed work. |
| 474 | pub fn event(&mut self, event: Event) -> bool { |
| 475 | self.events.push_back((event.clone(), self.f)); |
| 476 | if self.events.len() > 40 { |
| 477 | self.events.pop_front(); |
| 478 | } |
| 479 | let observed = self.activity.as_ref().is_some_and(|a| a.observed); |
| 480 | if self.presence != Presence::Working || !self.context.live || !observed { |
| 481 | return false; |
| 482 | } |
| 483 | let kind = match &event { |
| 484 | Event::Tool { kind } | Event::Failed { kind, .. } => kind, |
| 485 | }; |
| 486 | if tables().act_for_kind(kind) != Some(self.acting) { |
| 487 | return false; |
| 488 | } |
| 489 | let clip = match &event { |
| 490 | Event::Tool { .. } => tables() |
| 491 | .act(self.acting) |
| 492 | .beat |
| 493 | .is_some() |
| 494 | .then_some(ClipId::Beat(self.acting)), |
| 495 | Event::Failed { status, .. } => (matches!(status.as_deref(), Some("failed" | "error")) |
| 496 | && !matches!( |
| 497 | self.acting, |
| 498 | Act::Rest |
| 499 | | Act::Sleep |
| 500 | | Act::Needs |
| 501 | | Act::Done |
| 502 | | Act::Hmm |
| 503 | | Act::Listen |
| 504 | | Act::Think |
| 505 | )) |
| 506 | .then_some(ClipId::HmmBeat), |
| 507 | }; |
| 508 | let Some(clip) = clip else { |
| 509 | return false; |
| 510 | }; |
| 511 | if matches!(event, Event::Tool { .. }) && self.acting == Act::Write { |
| 512 | self.lines = (self.lines + 1.).min(4.); |
| 513 | } |
| 514 | if self.reduced { |
| 515 | self.pose_out = self.poster(); |
| 516 | return true; |
| 517 | } |
| 518 | self.shots.retain(|s| s.clip != clip); |
| 519 | self.shots.push(Shot { |
| 520 | clip, |
| 521 | at: self.f, |
| 522 | owner: self.acting, |
| 523 | exit: false, |
| 524 | wake: false, |
| 525 | beat: true, |
| 526 | }); |
| 527 | true |
| 528 | } |
| 529 | |
| 530 | /// Advance one step of at most a tenth of a second. |
| 531 | pub fn step(&mut self, dt: f64) { |
| 532 | // Invalid host time must not poison every spring and contour. |
| 533 | if !dt.is_finite() { |
| 534 | return; |
| 535 | } |
| 536 | if self.reduced { |
| 537 | self.pose_out = self.poster(); |
| 538 | return; |
| 539 | } |
| 540 | let t = tables(); |
| 541 | let dt = clamp(dt, 0., 1. / 10.); |
| 542 | let df = dt * FPS; |
| 543 | let f0 = self.f; |
| 544 | self.f += df; |
| 545 | let target = self.target_for(self.acting); |
| 546 | let sub = (dt * 120.).ceil().max(1.) as usize; |
| 547 | let h = dt / sub as f64; |
| 548 | for _ in 0..sub { |
| 549 | for (i, &target) in target.iter().enumerate() { |
| 550 | let (w, z) = t.springs[i]; |
| 551 | let goal = if self.f - self.changed_at < self.delays[i] { |
| 552 | self.from_target[i] |
| 553 | } else { |
| 554 | target |
| 555 | }; |
| 556 | self.v[i] += (w * w * (goal - self.x[i]) - 2. * z * w * self.v[i]) * h; |
| 557 | self.x[i] += self.v[i] * h; |
| 558 | } |
| 559 | for calf in &mut self.calves { |
| 560 | calf.velocity += (144. * (calf.target - calf.value) - 24. * calf.velocity) * h; |
| 561 | calf.value = clamp(calf.value + calf.velocity * h, 0., 1.); |
| 562 | } |
| 563 | } |
| 564 | let mut out = [0.; N]; |
| 565 | if let Some(departure) = self.departure { |
| 566 | let w = 1. - clamp((self.f - departure.at) / 8., 0., 1.); |
| 567 | for (out, offset) in out.iter_mut().zip(departure.offset) { |
| 568 | *out = offset * w; |
| 569 | } |
| 570 | if w == 0. { |
| 571 | self.departure = None; |
| 572 | } |
| 573 | } |
| 574 | self.anchors = Some(build(MARK_DIRECTION, &self.pose_out, 0, 24., 1.).anchors); |
| 575 | let f = self.f; |
| 576 | self.shots.retain(|s| f - s.at <= s.clip.clip().dur); |
| 577 | let shots = self.shots.clone(); |
| 578 | for s in &shots { |
| 579 | if f < s.at { |
| 580 | continue; |
| 581 | } |
| 582 | let clip = s.clip.clip(); |
| 583 | clip.eval(f - s.at, &mut out, 1.); |
| 584 | if !s.exit && (s.wake || s.owner == self.acting) { |
| 585 | self.fire(clip, f0 - s.at, f - s.at, s.owner); |
| 586 | } |
| 587 | } |
| 588 | let loops = self.loops.clone(); |
| 589 | for l in &loops { |
| 590 | if f < l.at { |
| 591 | continue; |
| 592 | } |
| 593 | let clip = l.clip.clip(); |
| 594 | let lf = (f - l.at) % clip.dur; |
| 595 | let w = clamp((f - l.at) / 8., 0., 1.); |
| 596 | clip.eval(lf, &mut out, w); |
| 597 | let pf = (f0 - l.at) % clip.dur; |
| 598 | self.fire(clip, if pf <= lf { pf } else { -1. }, lf, l.owner); |
| 599 | } |
| 600 | self.ambient(&mut out); |
| 601 | out[p::fluke] += clamp(-self.v[p::y] * 2.2 - self.v[p::rot] * 0.6, -22., 22.); |
| 602 | out[p::fin] += clamp(-self.v[p::y] * 1.2, -12., 12.); |
| 603 | let mut pose = [0.; N]; |
| 604 | for i in 0..N { |
| 605 | let desired = self.x[i] + out[i]; |
| 606 | let prior = self.pose_out[i]; |
| 607 | let delta = if i == p::wrenchSpin { |
| 608 | angle_delta(desired, prior) |
| 609 | } else { |
| 610 | desired - prior |
| 611 | }; |
| 612 | let rate = t.rates[i]; |
| 613 | pose[i] = prior + clamp(delta, -rate * df, rate * df); |
| 614 | } |
| 615 | for i in [p::lid, p::lidLow, p::mouth, p::yaw, p::pageFlip] { |
| 616 | pose[i] = clamp(pose[i], 0., 1.); |
| 617 | } |
| 618 | for i in HELD_PROPS { |
| 619 | pose[i] = clamp(pose[i], 0., 1.); |
| 620 | } |
| 621 | self.pose_out = pose; |
| 622 | for particle in &mut self.particles { |
| 623 | particle.age += df; |
| 624 | particle.vy += particle.g * df; |
| 625 | particle.vx *= particle.drag.powf(df); |
| 626 | particle.vy *= particle.drag.powf(df); |
| 627 | particle.x += particle.vx * df; |
| 628 | particle.y += particle.vy * df; |
| 629 | } |
| 630 | let acting = self.acting; |
| 631 | self.particles |
| 632 | .retain(|particle| particle.age < particle.life && particle.owner == acting); |
| 633 | } |
| 634 | |
| 635 | fn fire(&mut self, clip: &Clip, a: f64, b: f64, owner: Act) { |
| 636 | if owner != self.acting { |
| 637 | return; |
| 638 | } |
| 639 | for (frame, kind) in &clip.events { |
| 640 | if *frame > a && *frame <= b { |
| 641 | self.emit(*kind); |
| 642 | } |
| 643 | } |
| 644 | } |
| 645 | |
| 646 | fn ambient(&mut self, out: &mut Pose) { |
| 647 | if !self.ambient_on { |
| 648 | return; |
| 649 | } |
| 650 | let a: Ambient = tables().act(self.acting).ambient; |
| 651 | let f = self.f; |
| 652 | // Breath: a slow inhale, a short hold, a longer exhale. |
| 653 | let ph = (f % a.period) / a.period; |
| 654 | let half_pi = std::f64::consts::PI / 2.; |
| 655 | let b = if ph < 0.4 { |
| 656 | (ph / 0.4 * half_pi).sin() |
| 657 | } else if ph < 0.5 { |
| 658 | 1. |
| 659 | } else { |
| 660 | ((ph - 0.5) / 0.5 * half_pi).cos() |
| 661 | }; |
| 662 | let breath = a.breath; |
| 663 | out[p::squash] -= 0.016 * b * breath; |
| 664 | out[p::y] = out[p::y] - 0.55 * b * breath + (f / 210. * TAU).sin() * 1.1 * a.bob; |
| 665 | out[p::fluke] += 3.5 * ((f - 10.) / a.period * TAU).sin() * breath; |
| 666 | out[p::fin] += 2.2 * ((f - 6.) / a.period * TAU).sin() * breath; |
| 667 | if self.acting == Act::Rest { |
| 668 | out[p::rot] += 2.3 * (f / 240. * TAU).sin(); |
| 669 | out[p::x] += 1.8 * (f / 330. * TAU).sin(); |
| 670 | out[p::y] += ((f + 33.) / 210. * TAU).sin(); |
| 671 | out[p::fluke] += 8. * ((f - 24.) / 180. * TAU).sin(); |
| 672 | out[p::head] += 1.5 * ((f - 12.) / 250. * TAU).sin(); |
| 673 | } |
| 674 | let blink = |at: f64| Shot { |
| 675 | clip: ClipId::Blink, |
| 676 | at, |
| 677 | owner: self.acting, |
| 678 | exit: false, |
| 679 | wake: false, |
| 680 | beat: false, |
| 681 | }; |
| 682 | if a.blink > 0. && f >= self.amb.blink_at { |
| 683 | self.shots.push(blink(f)); |
| 684 | if self.rng.draw() < 0.2 { |
| 685 | self.shots.push(blink(f + 9.)); |
| 686 | } |
| 687 | self.amb.blink_at = f + (90. + self.rng.draw() * 150.) / a.blink; |
| 688 | } |
| 689 | if a.dart > 0. { |
| 690 | if f >= self.amb.dart_at { |
| 691 | let x = (self.rng.draw() - 0.5) * 0.9; |
| 692 | let y = (self.rng.draw() - 0.5) * 0.5; |
| 693 | self.amb.look = (x, y); |
| 694 | self.amb.dart_at = f + 150. + self.rng.draw() * 240.; |
| 695 | self.amb.look_at = f; |
| 696 | } |
| 697 | let t = clamp((f - self.amb.look_at) / 4., 0., 1.); |
| 698 | self.amb.cur.0 = lerp(self.amb.cur.0, self.amb.look.0, t); |
| 699 | self.amb.cur.1 = lerp(self.amb.cur.1, self.amb.look.1, t); |
| 700 | out[p::lookX] += self.amb.cur.0; |
| 701 | out[p::lookY] += self.amb.cur.1; |
| 702 | } |
| 703 | if a.puff > 0. && f >= self.amb.puff_at { |
| 704 | self.shots.push(Shot { |
| 705 | clip: ClipId::Puff, |
| 706 | ..blink(f) |
| 707 | }); |
| 708 | self.amb.puff_at = f + 600. + self.rng.draw() * 540.; |
| 709 | } |
| 710 | if a.sleepy > 0. && f >= self.amb.sleep_at { |
| 711 | self.emit(Emit::Sleep); |
| 712 | self.amb.sleep_at = f + 170. + self.rng.draw() * 140.; |
| 713 | } |
| 714 | } |
| 715 | |
| 716 | /// Particles are born at rig anchors, in world design units. |
| 717 | fn emit(&mut self, kind: Emit) { |
| 718 | let Some(a) = self.anchors else { |
| 719 | return; |
| 720 | }; |
| 721 | let owner = self.acting; |
| 722 | self.emissions.push_back(Emission { |
| 723 | kind, |
| 724 | owner, |
| 725 | f: self.f, |
| 726 | }); |
| 727 | if self.emissions.len() > 100 { |
| 728 | self.emissions.pop_front(); |
| 729 | } |
| 730 | let r = &mut self.rng; |
| 731 | let _seed = r.draw(); |
| 732 | let particle = |kind, x, y, vx, vy, radius, life| Particle { |
| 733 | kind, |
| 734 | x, |
| 735 | y, |
| 736 | vx, |
| 737 | vy, |
| 738 | g: 0., |
| 739 | drag: 0.98, |
| 740 | r: radius, |
| 741 | age: 0., |
| 742 | life, |
| 743 | cloud: false, |
| 744 | owner, |
| 745 | }; |
| 746 | match kind { |
| 747 | Emit::Spout { n, big } => { |
| 748 | let source = if big { a.spout } else { a.blowhole }; |
| 749 | for _ in 0..n { |
| 750 | let ang = |
| 751 | -std::f64::consts::PI / 2. + (r.draw() - 0.5) * if big { 1.3 } else { 0.8 }; |
| 752 | let sp = if big { 1.55 } else { 0.9 } * (0.75 + r.draw() * 0.5); |
| 753 | let x = source.x + if big { (r.draw() - 0.5) * 26. } else { 0. }; |
| 754 | let y = source.y - if big { 24. + r.draw() * 3. } else { 1. }; |
| 755 | let vx = ang.cos() * if big { 0.3 } else { sp }; |
| 756 | let vy = if big { -0.35 } else { ang.sin() * sp }; |
| 757 | self.particles.push(Particle { |
| 758 | g: if big { 0.055 } else { 0.075 }, |
| 759 | drag: 0.985, |
| 760 | ..particle( |
| 761 | ParticleKind::Drop, |
| 762 | x, |
| 763 | y, |
| 764 | vx, |
| 765 | vy, |
| 766 | if big { 1.6 } else { 1.3 }, |
| 767 | if big { 35. } else { 26. }, |
| 768 | ) |
| 769 | }); |
| 770 | } |
| 771 | for _ in 0..if big { 3 } else { 2 } { |
| 772 | let x = a.blowhole.x + (r.draw() - 0.5) * 3.; |
| 773 | let y = a.blowhole.y - 5. - r.draw() * 4.; |
| 774 | let vx = (r.draw() - 0.5) * 0.1; |
| 775 | self.particles.push(Particle { |
| 776 | drag: 0.95, |
| 777 | ..particle( |
| 778 | ParticleKind::Mist, |
| 779 | x, |
| 780 | y, |
| 781 | vx, |
| 782 | -0.12, |
| 783 | if big { 4.5 } else { 3.2 }, |
| 784 | 24., |
| 785 | ) |
| 786 | }); |
| 787 | } |
| 788 | } |
| 789 | Emit::Thought => { |
| 790 | let bx = a.blowhole.x + 2.; |
| 791 | let by = a.blowhole.y - 3.; |
| 792 | for (i, (delay, radius, cloud)) in |
| 793 | [(0., 1.3, false), (5., 2.1, false), (10., 5.2, true)] |
| 794 | .into_iter() |
| 795 | .enumerate() |
| 796 | { |
| 797 | self.particles.push(Particle { |
| 798 | age: -delay, |
| 799 | drag: 0.99, |
| 800 | cloud, |
| 801 | ..particle( |
| 802 | ParticleKind::Thought, |
| 803 | bx + [0., 6., 14.][i], |
| 804 | by - i as f64 * 5.5, |
| 805 | 0.06, |
| 806 | -0.05, |
| 807 | radius, |
| 808 | 64. - delay, |
| 809 | ) |
| 810 | }); |
| 811 | } |
| 812 | } |
| 813 | Emit::Speech => { |
| 814 | let m = a.mouth; |
| 815 | for i in 0..3 { |
| 816 | let x = m.x + 4. + r.draw() * 2.; |
| 817 | let vx = 0.26 + r.draw() * 0.15; |
| 818 | let vy = -0.3 - r.draw() * 0.2; |
| 819 | let radius = 1.7 + r.draw() * 1.6; |
| 820 | let _seed = r.draw(); |
| 821 | self.particles.push(Particle { |
| 822 | age: -(i as f64) * 6., |
| 823 | drag: 0.985, |
| 824 | ..particle(ParticleKind::Bubble, x, m.y - 1., vx, vy, radius, 50.) |
| 825 | }); |
| 826 | } |
| 827 | } |
| 828 | Emit::Blub => { |
| 829 | self.particles.push(Particle { |
| 830 | drag: 0.995, |
| 831 | ..particle( |
| 832 | ParticleKind::Bubble, |
| 833 | a.blowhole.x, |
| 834 | a.blowhole.y - 2., |
| 835 | 0.02, |
| 836 | -0.16, |
| 837 | 2.2, |
| 838 | 64., |
| 839 | ) |
| 840 | }); |
| 841 | } |
| 842 | Emit::Sleep => { |
| 843 | self.particles.push(Particle { |
| 844 | drag: 0.997, |
| 845 | ..particle( |
| 846 | ParticleKind::Bubble, |
| 847 | a.blowhole.x, |
| 848 | a.blowhole.y - 1.5, |
| 849 | 0.03, |
| 850 | -0.1, |
| 851 | 1.6, |
| 852 | 90., |
| 853 | ) |
| 854 | }); |
| 855 | } |
| 856 | } |
| 857 | } |
| 858 | |
| 859 | /// Reduced motion: the state's poster pose — no clock, no ambient, no |
| 860 | /// transitions. |
| 861 | pub fn poster(&self) -> Pose { |
| 862 | let mut pose = self.target_for(self.acting); |
| 863 | for (param, value) in &tables().act(self.acting).poster { |
| 864 | pose[*param] = *value; |
| 865 | } |
| 866 | pose[p::padLines] = self.lines; |
| 867 | pose |
| 868 | } |
| 869 | |
| 870 | pub fn poster_particles(&self, anchors: &Anchors) -> Vec<Particle> { |
| 871 | let owner = self.acting; |
| 872 | let still = |kind, x, y, r, cloud| Particle { |
| 873 | kind, |
| 874 | x, |
| 875 | y, |
| 876 | vx: 0., |
| 877 | vy: 0., |
| 878 | g: 0., |
| 879 | drag: 0.98, |
| 880 | r, |
| 881 | age: 10., |
| 882 | life: 100., |
| 883 | cloud, |
| 884 | owner, |
| 885 | }; |
| 886 | let mut out = Vec::new(); |
| 887 | for kind in &tables().act(self.acting).poster_particles { |
| 888 | match kind { |
| 889 | PosterParticle::ThoughtCloud => { |
| 890 | let bx = anchors.blowhole.x + 2.; |
| 891 | let by = anchors.blowhole.y - 3.; |
| 892 | out.push(still(ParticleKind::Thought, bx, by, 1.3, false)); |
| 893 | out.push(still(ParticleKind::Thought, bx + 6., by - 5.5, 2.1, false)); |
| 894 | out.push(still(ParticleKind::Thought, bx + 14., by - 13., 5.2, true)); |
| 895 | } |
| 896 | PosterParticle::Speech => { |
| 897 | let m = anchors.mouth; |
| 898 | out.push(still(ParticleKind::Bubble, m.x + 6., m.y - 5., 1.9, false)); |
| 899 | out.push(still(ParticleKind::Bubble, m.x + 9., m.y - 10., 1.3, false)); |
| 900 | } |
| 901 | } |
| 902 | } |
| 903 | out |
| 904 | } |
| 905 | |
| 906 | pub fn pose(&self) -> Pose { |
| 907 | self.pose_out |
| 908 | } |
| 909 | |
| 910 | /// Whether anything is still moving beyond the settled loop: a clip, a |
| 911 | /// departure fade, a particle or a calf in transit. Surfaces use it to |
| 912 | /// choose their active or resting paint cap. |
| 913 | pub fn in_transition(&self) -> bool { |
| 914 | self.departure.is_some() |
| 915 | || self.shots.iter().any(|s| s.clip != ClipId::Blink) |
| 916 | || !self.particles.is_empty() |
| 917 | || self |
| 918 | .calves |
| 919 | .iter() |
| 920 | .any(|c| (c.value - c.target).abs() > 0.002 || c.velocity.abs() > 0.002) |
| 921 | } |
| 922 | } |
| 923 | |
| 924 | pub fn angle_delta(a: f64, b: f64) -> f64 { |
| 925 | ((a - b + 180.) % 360. + 360.) % 360. - 180. |
| 926 | } |
| 927 |