返回 CodeWhale
ambient_life.rs
根目录 / crates / tui / src / tui / ambient_life.rs
1 //! Ambient ocean life for the underwater transcript field.
2 //!
3 //! One clear owner for the fish school, jellyfish, bubbles, and the rare
4 //! whale cameo — nothing else lives in the water (2026-07-23 product
5 //! decision: seaweed and bio-dust are gone). Motion stays inside the
6 //! existing delta/interpolation path: this module never requests frames on
7 //! its own.
8 //!
9 //! Motion language (shared with the rest of the shell): every mark can lerp
10 //! between the water and its ink at a time-varying brightness. Fish carry a
11 //! travelling sin² wave, jellyfish a slow band-bounded pulse that opens and
12 //! closes the dome while the tentacles trail it by ~0.6 s, bubbles an
13 //! occasional raised-cosine glint. Phases are wall-clock keyed and entity
14 //! periods deliberately never match, so nothing strobes in sync.
15 //!
16 //! The jellyfish is a *visitor*, not scenery: one at most, present for roughly
17 //! a fifth of a ~5-minute cycle and dimmer than everything around it. See the
18 //! `JELLY_VISIT_*` constants for the rarity knobs and why they are set where
19 //! they are.
20 //!
21 //! Fish swim on a wrap-around path: they exit one edge and re-enter the
22 //! other still facing their travel direction, so facing always equals
23 //! velocity by construction. Direction may only change while the school is
24 //! fully off-screen.
25 //!
26 //! Two clocks feed this module and neither is a token counter. Positions ride
27 //! `App::sample_ambient_clock_ms`, which advances by real elapsed time clamped
28 //! to `App::AMBIENT_MAX_STEP_MS` per draw, so drift speed is identical at 16 ms
29 //! and 33 ms frames and a stalled-then-resumed frame cannot jump a creature.
30 //! Sideways *placement*, by contrast, is a function of the transcript text
31 //! under the silhouette — which does change with token throughput — so it is
32 //! bounded by [`JELLY_MAX_TEXT_DODGE_COLS`].
33 //!
34 //! Under reduced motion there is no ambient life at all: `ocean::life_presence`
35 //! returns 0 and [`paint_marks`] returns before writing a cell. Marks are still
36 //! *built* (the budget counters stay honest), just never painted.
37 //!
38 //! `render_ambient_life` returns per-frame budget counters
39 //! ([`AmbientFrameStats`]): marks built always splits exactly into painted +
40 //! text-skipped + clipped. Counting is a handful of `u32` increments — no
41 //! allocation, no frame requests.
42
43 use ratatui::{
44 buffer::Buffer,
45 layout::Rect,
46 style::{Color, Modifier, Style},
47 text::Line,
48 };
49 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
50
51 use crate::tui::ocean::{self, OceanColumn};
52
53 /// Depth layers for parallax. Nearer life is larger, faster, and more visible.
54 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
55 enum Depth {
56 Background,
57 Midground,
58 Foreground,
59 }
60
61 impl Depth {
62 #[must_use]
63 fn ink_index(self) -> usize {
64 match self {
65 Self::Background => 1,
66 Self::Midground | Self::Foreground => 0,
67 }
68 }
69 }
70
71 /// Creature density tier mirrored from shell width/height.
72 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
73 pub enum LifeDensity {
74 Sparse,
75 Normal,
76 Rich,
77 }
78
79 impl LifeDensity {
80 #[must_use]
81 pub fn from_area(area: Rect) -> Self {
82 if area.width < 56 || area.height < 12 {
83 Self::Sparse
84 } else if area.width < 88 || area.height < 20 {
85 Self::Normal
86 } else {
87 Self::Rich
88 }
89 }
90
91 #[must_use]
92 fn school_size(self) -> usize {
93 // One loose wedge of real fish; two schools compete with the whale.
94 match self {
95 Self::Sparse => 3,
96 Self::Normal => 5,
97 Self::Rich => 7,
98 }
99 }
100
101 #[must_use]
102 fn jellyfish_count(self) -> usize {
103 // At most one jellyfish in the water at a time, at every tier. Two
104 // put a pulsing silhouette in *both* side lanes, which is what made
105 // them read as resident scenery instead of a passing visitor. The
106 // rarity knob that matters is the visit duty cycle
107 // ([`JELLY_VISIT_CYCLE_SLOTS`]), not the population.
108 match self {
109 Self::Sparse | Self::Normal | Self::Rich => 1,
110 }
111 }
112
113 #[must_use]
114 fn bubble_streams(self) -> usize {
115 match self {
116 Self::Sparse => 1,
117 Self::Normal => 2,
118 Self::Rich => 2,
119 }
120 }
121 }
122
123 /// Lower floors so smaller windows still retain some life (was 68×15).
124 /// Keep in sync with [`crate::tui::ocean::AMBIENT_MIN_WIDTH`].
125 pub const AMBIENT_MIN_WIDTH: u16 = crate::tui::ocean::AMBIENT_MIN_WIDTH;
126 pub const AMBIENT_MIN_HEIGHT: u16 = crate::tui::ocean::AMBIENT_MIN_HEIGHT;
127
128 /// Whale cameo state: brief breach → spout → fluke → submerge.
129 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
130 pub enum WhaleCameoPhase {
131 Hidden,
132 Breach,
133 Spout,
134 Fluke,
135 Submerge,
136 }
137
138 /// Snapshot of ambient positions for one frame (memoized once per draw).
139 #[derive(Debug, Clone)]
140 struct FrameMarks {
141 marks: Vec<AmbientMark>,
142 }
143
144 #[derive(Debug, Clone, Copy)]
145 struct AmbientMark {
146 x: u16,
147 y: u16,
148 glyph: &'static str,
149 /// Multi-row creature identity. Every part relocates or is withheld as one
150 /// unit so a jellyfish never degrades into a detached dome or tentacles.
151 jellyfish: Option<usize>,
152 depth: Depth,
153 style_mod: Option<Modifier>,
154 /// Time-varying glow in `[0, 1]`: the mark's ink is lerped from the
155 /// painted water toward full ink at this amount. `None` renders the
156 /// plain ink (legacy behavior for the whale cameo).
157 brightness: Option<f32>,
158 }
159
160 /// Per-frame render budget counters. `marks_built` splits exactly into
161 /// `marks_painted + marks_skipped_text + marks_clipped`. `cells_written`
162 /// counts individual cell writes: a multi-cell glyph counts each of its
163 /// cells, and two overlapping marks count the shared cell once per write.
164 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
165 pub struct AmbientFrameStats {
166 pub marks_built: u32,
167 pub marks_painted: u32,
168 pub marks_skipped_text: u32,
169 pub marks_clipped: u32,
170 pub cells_written: u32,
171 }
172
173 /// Hard upper bound on marks built in one frame: 7 fish + 1 jellyfish × 5
174 /// parts (2 dome rows + 3 tentacles) + 2 bubbles + 2 whale-cameo cells = 16,
175 /// plus headroom. This is a test-gate ceiling asserted against
176 /// [`AmbientFrameStats::marks_built`], not a runtime clamp: the population is
177 /// bounded by construction, and this constant is what fails the build if a
178 /// future change makes it unbounded.
179 #[allow(dead_code)]
180 pub const MAX_FRAME_MARKS: u32 = 24;
181
182 /// Optional pointer reaction for fish dart / bubble rise.
183 #[derive(Debug, Clone, Copy, Default)]
184 pub struct AmbientCursor {
185 pub column: u16,
186 pub row: u16,
187 /// When set, fish flee from this point for ~800 ms of shared ocean clock.
188 pub flee_elapsed_ms: Option<u128>,
189 }
190
191 /// Optional whale cameo trigger (e.g. successful turn completion).
192 #[derive(Debug, Clone, Copy, Default)]
193 pub struct WhaleCameo {
194 pub elapsed_ms: Option<u128>,
195 /// Anchor column within the field (composer / center).
196 pub anchor_x: u16,
197 pub anchor_y: u16,
198 }
199
200 const WHALE_CAMEO_MS: u128 = 2_400;
201
202 /// Render ambient life into empty water cells of `area`.
203 ///
204 /// Returns per-frame budget counters for tests and debug tooling; the
205 /// counting itself is a few `u32` increments, never an allocation.
206 #[allow(clippy::too_many_arguments)]
207 pub fn render_ambient_life(
208 area: Rect,
209 buf: &mut Buffer,
210 inks: (Color, Color),
211 lines: &[Line<'static>],
212 elapsed_ms: u128,
213 presence: f32,
214 cursor: AmbientCursor,
215 whale: WhaleCameo,
216 ) -> AmbientFrameStats {
217 if area.width < AMBIENT_MIN_WIDTH || area.height < AMBIENT_MIN_HEIGHT {
218 return AmbientFrameStats::default();
219 }
220
221 let density = LifeDensity::from_area(area);
222 let mut stats = AmbientFrameStats::default();
223 // Positions always ride the live monotonic clock; `presence` fades the
224 // marks in and out, so the animated/static boundary eases instead of
225 // snapping fish between t=0 and their mid-path positions.
226 let frame = build_frame_marks(area, elapsed_ms, density, cursor, whale, &mut stats);
227 paint_marks(area, buf, inks, lines, &frame, presence, &mut stats);
228 stats
229 }
230
231 fn build_frame_marks(
232 area: Rect,
233 elapsed_ms: u128,
234 density: LifeDensity,
235 cursor: AmbientCursor,
236 whale: WhaleCameo,
237 stats: &mut AmbientFrameStats,
238 ) -> FrameMarks {
239 let mut marks = Vec::with_capacity(48);
240 let t = elapsed_ms;
241
242 // Leave the empty-state brand band (center third) mostly clear so life
243 // frames the room instead of littering the hero whale + status lines.
244 let quiet_top = (area.height / 5).max(2);
245 let quiet_mid_lo = area.height.saturating_mul(2) / 5;
246 let quiet_mid_hi = area.height.saturating_mul(3) / 5;
247
248 // --- One loose fish school on a wrap-around path ---
249 // The school enters one edge, crosses, and exits the other; direction
250 // may only change while it is fully off-screen, so facing always equals
251 // velocity. A travelling sin² brightness wave runs through the wedge.
252 let school_size = density.school_size().min(SCHOOL_WEDGE.len());
253 let school_span = SCHOOL_WEDGE
254 .iter()
255 .take(school_size)
256 .map(|(_, dx)| *dx)
257 .max()
258 .unwrap_or(0)
259 .saturating_add(LEAD_FISH_RIGHT.len() as u16);
260 let travel = u128::from(area.width.saturating_add(school_span).max(1));
261 let cycle_ms = travel.saturating_mul(SCHOOL_CELL_MS);
262 // Half-cycle head start: freshly opened water shows the school
263 // mid-crossing instead of an empty entry beat.
264 let school_clock = t.saturating_add(cycle_ms / 2);
265 let (cycle_index, cycle_step) = (
266 school_clock / cycle_ms,
267 ((school_clock % cycle_ms) / SCHOOL_CELL_MS) as i32,
268 );
269 let swims_right = school_swims_right(cycle_index);
270 // Alternate the travel band between crossings; both avoid the hero band.
271 let swims_low = school_swims_low(cycle_index);
272 let anchor_y = if swims_low {
273 area.height.saturating_mul(3) / 4
274 } else {
275 quiet_top.saturating_add(1)
276 };
277 let ptr = cursor.column.saturating_sub(area.x);
278 let ptr_y = cursor.row.saturating_sub(area.y);
279 for (m, (dy, dx)) in SCHOOL_WEDGE.iter().take(school_size).enumerate() {
280 let body = fish_body(swims_right, m == 0);
281 let body_w = body.len() as u16; // ASCII bodies: len == width
282 // Nose position in wrap space; trailers sit `dx` columns behind the
283 // lead relative to travel, so the wedge follows instead of leading.
284 // Right-swimmers enter from the left edge, left-swimmers from the
285 // right edge — both facing exactly the way they move.
286 let mut x_i32 = if swims_right {
287 cycle_step - 1 - i32::from(*dx) - (i32::from(body_w) - 1)
288 } else {
289 i32::from(area.width) - cycle_step + i32::from(*dx)
290 };
291 // Slight per-fish vertical stagger + slow bob.
292 let bob = sine_bob(t, 3_400 + (m as u128) * 640, 1);
293 let mut y_i32 = i32::from(anchor_y) + i32::from(*dy) + i32::from(bob);
294 // Fish flee the pointer in both dimensions (nearby motion only).
295 if let Some(flee_ms) = cursor.flee_elapsed_ms {
296 let flee = i32::from(fish_flee_offset(flee_ms));
297 if x_i32.abs_diff(i32::from(ptr)) < 16 && y_i32.abs_diff(i32::from(ptr_y)) < 6 {
298 if x_i32 >= i32::from(ptr) {
299 x_i32 += flee;
300 } else {
301 x_i32 -= flee;
302 }
303 if y_i32 >= i32::from(ptr_y) {
304 y_i32 += 1;
305 } else {
306 y_i32 -= 1;
307 }
308 }
309 }
310 let max_x = i32::from(area.width.saturating_sub(body_w));
311 let max_y = i32::from(area.height.saturating_sub(1));
312 if x_i32 < 0 || x_i32 > max_x || y_i32 < 0 || y_i32 > max_y {
313 continue; // off-screen while wrapping
314 }
315 let y = y_i32 as u16;
316 // Never swim through the hero band.
317 if y > quiet_mid_lo && y < quiet_mid_hi {
318 continue;
319 }
320 let brightness = FISH_BRIGHTNESS_FLOOR
321 + (1.0 - FISH_BRIGHTNESS_FLOOR)
322 * wave01(t, FISH_WAVE_MS, (m as u128).saturating_mul(320));
323 marks.push(AmbientMark {
324 x: x_i32 as u16,
325 y,
326 glyph: body,
327 jellyfish: None,
328 depth: if m == 0 {
329 Depth::Foreground
330 } else {
331 Depth::Midground
332 },
333 style_mod: None,
334 brightness: Some(brightness),
335 });
336 }
337
338 // --- Jellyfish: a pulsing dome with lagging tentacles ---
339 // Two dome rows (arc + bell rim) over a row of swaying
340 // tentacles. The dome opens and closes on a slow floor-bounded sin²;
341 // the tentacles repeat the pulse ~350 ms later and sway out of phase
342 // with each other — the lag is what sells "jellyfish". Rich/Normal get
343 // the full 5-cell dome with three tentacles; Sparse (narrow) swaps in a
344 // compact 3-cell dome with two — a real fallback silhouette, not just
345 // fewer jellies. They drift slowly upward through the side lanes, wrap,
346 // and park mid-rise just under the hero band under reduced motion.
347 let in_band = |row: u16| row > quiet_mid_lo && row < quiet_mid_hi;
348 for j in 0..density.jellyfish_count() {
349 let phase = 3_100u128.saturating_add((j as u128) * 4_700);
350 let lane_x = if j % 2 == 0 {
351 area.width.saturating_mul(5) / 6
352 } else {
353 area.width / 6
354 };
355 let wobble = sine_bob(t, 5_200 + phase, 1);
356 let compact = density == LifeDensity::Sparse;
357 let (dome_top, dome_skirt, tentacle_cols): (&[&str], &[&str], &[u16]) = if compact {
358 (JELLY_DOME_TOP_COMPACT, JELLY_DOME_SKIRT_COMPACT, &[0, 2])
359 } else {
360 (JELLY_DOME_TOP_FRAMES, JELLY_DOME_SKIRT_FRAMES, &[1, 2, 3])
361 };
362 let dome_w = dome_top[0].len() as u16; // ASCII frames: len == width
363 let x = lane_x
364 .saturating_add(wobble)
365 .min(area.width.saturating_sub(dome_w + 1));
366 // A visit is a short, slow rise near the floor followed by a long
367 // absence: the jelly climbs [`JELLY_VISIT_ROWS`] rows and then spends
368 // the rest of the cycle out of sight. Rows are discrete cells, so the
369 // per-row dwell stays long — a jellyfish should read as drifting, not
370 // as stepping.
371 let rise_period = JELLY_RISE_ROW_MS.saturating_add((j as u128) * JELLY_RISE_ROW_STAGGER_MS);
372 let slot = (t.saturating_add(phase) / rise_period) % JELLY_VISIT_CYCLE_SLOTS;
373 if slot >= u128::from(JELLY_VISIT_ROWS) {
374 continue; // still down in the dark between visits
375 }
376 let risen = slot as u16;
377 let y = area.height.saturating_sub(3).saturating_sub(risen);
378 if y == 0 || in_band(y) {
379 continue;
380 }
381 let dome_brightness = jelly_glow(wave01(t, JELLY_PULSE_MS, phase));
382 let tentacle_brightness = jelly_glow(wave01(
383 t.saturating_sub(JELLY_TENTACLE_LAG_MS),
384 JELLY_PULSE_MS,
385 phase,
386 ));
387 // The dome opens/closes on the same clock as its glow; the parked
388 // pose holds the half-pulsed (contracted) frame.
389 let pulse_frame = usize::from(wave01(t, JELLY_PULSE_MS, phase) > 0.5);
390 let skirt_row = y.saturating_add(1);
391 let tentacle_row = y.saturating_add(2);
392 // Treat the silhouette as one visual unit. The former per-row quiet
393 // band checks deliberately allowed the dome, skirt, or tentacles to
394 // disappear independently, which is exactly the broken punctuation
395 // visible in the v0.9.2 dogfood screenshot.
396 if tentacle_row >= area.height || [y, skirt_row, tentacle_row].into_iter().any(in_band) {
397 continue;
398 }
399 for (row, glyph) in [
400 (y, dome_top[pulse_frame]),
401 (skirt_row, dome_skirt[pulse_frame]),
402 ] {
403 marks.push(AmbientMark {
404 x,
405 y: row,
406 glyph,
407 jellyfish: Some(j),
408 // Background ink, same as the tentacles: the dome used to sit
409 // a layer nearer than everything else in the side lanes,
410 // which is most of why it drew the eye.
411 depth: Depth::Background,
412 style_mod: None,
413 brightness: Some(dome_brightness),
414 });
415 }
416 for (col, &dx) in tentacle_cols.iter().enumerate() {
417 // Each column runs the sway table with its own phase offset
418 // so the trio lags left-to-right; the parked pose holds a
419 // mid-sway frame.
420 let frame = t
421 .saturating_add(phase)
422 .saturating_add((col as u128) * JELLY_TENTACLE_PHASE_STEP_MS)
423 / JELLY_TENTACLE_SWAY_MS;
424 let sway = JELLY_TENTACLE_FRAMES[(frame as usize) % JELLY_TENTACLE_FRAMES.len()];
425 marks.push(AmbientMark {
426 x: x.saturating_add(dx),
427 y: tentacle_row,
428 glyph: sway,
429 jellyfish: Some(j),
430 depth: Depth::Background,
431 style_mod: None,
432 brightness: Some(tentacle_brightness),
433 });
434 }
435 }
436
437 // --- Rising bubble streams (quiet ·/˚ with occasional glints) ---
438 for b in 0..density.bubble_streams() {
439 let phase = (b as u128).saturating_mul(1_900);
440 // Edge columns — avoid center brand.
441 let column = if b % 2 == 0 {
442 area.width / 8
443 } else {
444 area.width.saturating_mul(7) / 8
445 };
446 let rise_period = 3_200u128.saturating_add(phase % 900);
447 let cycle = (t.saturating_add(phase) % rise_period) as f64 / rise_period as f64;
448 let max_rise = area.height.saturating_sub(3) as f64;
449 let rise = (cycle * max_rise) as u16;
450 let boost = if cursor.flee_elapsed_ms.is_some() && column.abs_diff(ptr) < 10 {
451 2
452 } else {
453 0
454 };
455 let y = area
456 .height
457 .saturating_sub(2)
458 .saturating_sub(rise.saturating_add(boost))
459 .max(quiet_top);
460 // Skip the empty-state text band.
461 if y > quiet_mid_lo && y < quiet_mid_hi {
462 continue;
463 }
464 let glyph = ["·", "˚", "·", "°"][((t.saturating_add(phase)) / 320) as usize % 4];
465 let brightness = glint01(t, 2_600 + phase % 700, 600, BUBBLE_BRIGHTNESS_FLOOR, phase);
466 marks.push(AmbientMark {
467 x: column.min(area.width.saturating_sub(1)),
468 y,
469 glyph,
470 jellyfish: None,
471 depth: Depth::Foreground,
472 style_mod: None,
473 brightness: Some(brightness),
474 });
475 }
476
477 // --- Rare whale cameo (completion only) ---
478 if let Some(cameo_ms) = whale.elapsed_ms.filter(|ms| *ms < WHALE_CAMEO_MS) {
479 let phase = whale_cameo_phase(cameo_ms);
480 if phase != WhaleCameoPhase::Hidden {
481 let ax = whale
482 .anchor_x
483 .saturating_sub(area.x)
484 .min(area.width.saturating_sub(4));
485 let ay = whale
486 .anchor_y
487 .saturating_sub(area.y)
488 .min(area.height.saturating_sub(2));
489 let (glyph, y_off) = match phase {
490 WhaleCameoPhase::Breach => ("≈≈>", 0u16),
491 WhaleCameoPhase::Spout => ("≈≈>", 0),
492 WhaleCameoPhase::Fluke => ("~", 1),
493 WhaleCameoPhase::Submerge => ("·", 1),
494 WhaleCameoPhase::Hidden => ("", 0),
495 };
496 if !glyph.is_empty() {
497 marks.push(AmbientMark {
498 x: ax,
499 y: ay.saturating_add(y_off).min(area.height.saturating_sub(1)),
500 glyph,
501 jellyfish: None,
502 depth: Depth::Foreground,
503 style_mod: None,
504 brightness: None,
505 });
506 if phase == WhaleCameoPhase::Spout && ay > 0 {
507 marks.push(AmbientMark {
508 x: ax.saturating_add(1).min(area.width.saturating_sub(1)),
509 y: ay.saturating_sub(1),
510 glyph: "˚",
511 jellyfish: None,
512 depth: Depth::Foreground,
513 style_mod: Some(Modifier::DIM),
514 brightness: None,
515 });
516 }
517 }
518 }
519 }
520
521 stats.marks_built = marks.len() as u32;
522 FrameMarks { marks }
523 }
524
525 /// Loose diagonal wedge for the school: `(row_offset, columns_behind_lead)`.
526 /// The slight row spread is what makes it read as a school, not a text row.
527 const SCHOOL_WEDGE: &[(i16, u16)] = &[(0, 0), (-1, 4), (1, 6), (-2, 9), (2, 11), (0, 14), (-1, 17)];
528
529 /// Wall-clock milliseconds per column of school travel (~2.6 cells/s).
530 const SCHOOL_CELL_MS: u128 = 380;
531 /// Travelling brightness-wave period through the wedge.
532 const FISH_WAVE_MS: u128 = 2_200;
533 /// Fish are small: never let one sink into the gradient.
534 const FISH_BRIGHTNESS_FLOOR: f32 = 0.45;
535
536 /// Lead fish silhouettes (ASCII only — width == len). Members drop the eye.
537 const LEAD_FISH_RIGHT: &str = "><o>";
538 const LEAD_FISH_LEFT: &str = "<o><";
539
540 /// Jellyfish silhouette frames — pure ASCII by construction so the
541 /// ascii_safe tier needs no fallback mapping for them (len == width).
542 ///
543 /// Full dome (Rich/Normal), two rows with an open/closed pulse pair: a
544 /// rounded arc over the bell's rim.
545 ///
546 /// The skirt is the bell's lower rim and nothing else: it carries the pulse by
547 /// flaring (`\` `/`) and contracting (`(` `)`), the way a real bell swims. It
548 /// holds no interior glyphs on purpose — an earlier pair put marks inside the
549 /// rim (`(v_v)` / `(v.v)`), which read as two eyes and a mouth. The motion the
550 /// silhouette is meant to sell lives in the tentacle row below, not in the
551 /// skirt.
552 const JELLY_DOME_TOP_FRAMES: &[&str] = &[".-~-.", ".'-.'"];
553 const JELLY_DOME_SKIRT_FRAMES: &[&str] = &["\\___/", "(___)"];
554 /// Compact dome for the Sparse (narrow) tier: same two-row read at 3 cells.
555 const JELLY_DOME_TOP_COMPACT: &[&str] = &[".-.", "'.'"];
556 const JELLY_DOME_SKIRT_COMPACT: &[&str] = &["\\_/", "(_)"];
557 /// Tentacle sway frames (all width-1). Each column runs the same table with
558 /// a phase offset so the trio lags instead of strobing in sync.
559 const JELLY_TENTACLE_FRAMES: &[&str] = &["|", "/", "|", "\\"];
560
561 /// How far sideways a jellyfish may dodge to clear transcript text before it
562 /// is withheld for the frame instead.
563 ///
564 /// Placement is a pure function of the text under the silhouette, so during a
565 /// fast stream it is effectively a function of token throughput: a growing
566 /// line pushes the anchor one column per character, and a wrap or a scroll
567 /// collapses that row's occupied bounds and snaps the anchor back tens of
568 /// columns in a single frame. On screen that reads as teleporting, and it only
569 /// shows up on models fast enough to change those bounds every frame — which
570 /// is why slow providers never surfaced it.
571 ///
572 /// Bounding the dodge keeps the behavior the silhouette was actually given
573 /// (ease around a word that happens to brush its lane) and turns everything
574 /// larger into the same quiet withhold the fish already use. Worst-case
575 /// frame-to-frame movement is therefore `2 * JELLY_MAX_TEXT_DODGE_COLS`, at
576 /// the single moment a left-hand candidate overtakes a right-hand one.
577 const JELLY_MAX_TEXT_DODGE_COLS: u16 = 3;
578
579 // --- Jellyfish rarity ------------------------------------------------------
580 // The jellyfish is the loudest thing in the water: a five-cell silhouette that
581 // changes glyph as it pulses, parked in a side lane. Before v0.9.4 it was also
582 // permanently resident, which is the combination that made it obnoxious rather
583 // than incidental. Everything below is one knob with one stated intent, so the
584 // balance can be retuned without re-deriving it from the motion code.
585
586 /// Wall-clock milliseconds a jellyfish spends on each row of its rise
587 /// (~9.4 s). A row step is a discrete one-cell jump, so the dwell has to stay
588 /// long or the rise reads as stepping rather than drifting.
589 const JELLY_RISE_ROW_MS: u128 = 9_400;
590 /// Per-jelly rise-rate stagger, so two jellyfish (should a tier ever want
591 /// them again) can never step in lockstep.
592 const JELLY_RISE_ROW_STAGGER_MS: u128 = 1_400;
593 /// Rows climbed in a single visit — about 56 s of presence.
594 const JELLY_VISIT_ROWS: u16 = 6;
595 /// Row-slots in one full visit cycle. Slots at or past [`JELLY_VISIT_ROWS`]
596 /// are spent out of sight, and that gap is *the* rarity knob: at 32 slots the
597 /// cycle is ~5 min and a jellyfish is present under a fifth of the time —
598 /// occasionally noticed, never resident. Raise it to make them rarer; lower
599 /// it to bring them back. It must stay `> JELLY_VISIT_ROWS` or the jelly
600 /// becomes permanent again.
601 const JELLY_VISIT_CYCLE_SLOTS: u128 = 32;
602
603 // --- Jellyfish motion and glow ---------------------------------------------
604
605 /// Dome pulse period. Slow on purpose: a pulse fast enough to notice in
606 /// peripheral vision is a pulse that interrupts reading.
607 const JELLY_PULSE_MS: u128 = 5_200;
608 /// The tentacles repeat the dome pulse this much later. Held at ~12% of
609 /// [`JELLY_PULSE_MS`] — the lag is what sells "jellyfish", so it scales with
610 /// the pulse rather than staying an absolute number.
611 const JELLY_TENTACLE_LAG_MS: u128 = 620;
612 /// Wall-clock milliseconds per tentacle sway frame.
613 const JELLY_TENTACLE_SWAY_MS: u128 = 2_600;
614 /// Per-column sway phase offset, so adjacent tentacles never move in sync.
615 /// Keep this a non-divisor of [`JELLY_TENTACLE_SWAY_MS`] or the trio strobes.
616 const JELLY_TENTACLE_PHASE_STEP_MS: u128 = 700;
617 /// Dimmest point of the pulse: still legible against the water, no lower.
618 const JELLY_BRIGHTNESS_FLOOR: f32 = 0.28;
619 /// Brightest point of the pulse. Deliberately well short of full ink — the
620 /// jellyfish used to swing floor-to-1.0, and that swing (not its presence)
621 /// is what pulled the eye off the transcript.
622 const JELLY_BRIGHTNESS_CEIL: f32 = 0.62;
623
624 /// Map a `[0, 1]` pulse onto the jellyfish's shallow glow band.
625 #[must_use]
626 fn jelly_glow(pulse: f32) -> f32 {
627 JELLY_BRIGHTNESS_FLOOR + (JELLY_BRIGHTNESS_CEIL - JELLY_BRIGHTNESS_FLOOR) * pulse
628 }
629
630 /// Bubbles stay mostly steady with occasional glints, not a constant wave.
631 const BUBBLE_BRIGHTNESS_FLOOR: f32 = 0.55;
632
633 /// One soft sin² hump per `period_ms`, wall-clock keyed, in `[0, 1]`.
634 #[must_use]
635 fn wave01(elapsed_ms: u128, period_ms: u128, phase_ms: u128) -> f32 {
636 if period_ms == 0 {
637 return 1.0;
638 }
639 let frac = (elapsed_ms.saturating_add(phase_ms) % period_ms) as f64 / period_ms as f64;
640 let s = (frac * std::f64::consts::PI).sin();
641 (s * s) as f32
642 }
643
644 /// Mostly `floor`, with a raised-cosine glint to full brightness for
645 /// `glint_ms` out of every `period_ms`.
646 #[must_use]
647 fn glint01(elapsed_ms: u128, period_ms: u128, glint_ms: u128, floor: f32, phase_ms: u128) -> f32 {
648 if period_ms == 0 || glint_ms == 0 {
649 return floor;
650 }
651 let pos = elapsed_ms.saturating_add(phase_ms) % period_ms;
652 if pos >= glint_ms {
653 return floor;
654 }
655 let frac = pos as f64 / glint_ms as f64;
656 let bump = 0.5 * (1.0 - (frac * std::f64::consts::TAU).cos());
657 floor + (1.0 - floor) * bump as f32
658 }
659
660 /// Stateless per-crossing travel direction. Direction only ever changes
661 /// between cycles — while the school is fully off-screen — so a turn is
662 /// never visible as an in-place flip.
663 #[must_use]
664 fn school_swims_right(cycle_index: u128) -> bool {
665 (cycle_index.wrapping_mul(0x9E37_79B9_7F4A_7C15) >> 7) & 1 == 0
666 }
667
668 /// Stateless per-crossing band choice (lower vs upper third).
669 #[must_use]
670 fn school_swims_low(cycle_index: u128) -> bool {
671 (cycle_index.wrapping_mul(0xC2B2_AE3D_27D4_EB4F) >> 9) & 1 == 0
672 }
673
674 fn paint_marks(
675 area: Rect,
676 buf: &mut Buffer,
677 inks: (Color, Color),
678 lines: &[Line<'static>],
679 frame: &FrameMarks,
680 presence: f32,
681 stats: &mut AmbientFrameStats,
682 ) {
683 if presence <= 0.0 {
684 // Fully static water: nothing to paint (all marks invisible).
685 return;
686 }
687 let presence = presence.clamp(0.0, 1.0);
688 #[derive(Clone, Copy)]
689 enum SkipReason {
690 Text,
691 Clipped,
692 }
693
694 #[derive(Clone, Copy)]
695 enum Placement {
696 Anchor { original: u16, placed: u16 },
697 Skip(SkipReason),
698 }
699 #[derive(Clone, Copy)]
700 struct RowBounds {
701 y: u16,
702 protected: Option<(usize, usize)>,
703 }
704
705 let mut placements: [Option<Placement>; 2] = [None, None];
706 let population_overflow = frame
707 .marks
708 .iter()
709 .filter_map(|mark| mark.jellyfish)
710 .any(|jellyfish| jellyfish >= placements.len());
711 debug_assert!(
712 !population_overflow,
713 "jellyfish population exceeded its bound"
714 );
715 for (jellyfish, placement) in placements.iter_mut().enumerate() {
716 let marks = || {
717 frame
718 .marks
719 .iter()
720 .filter(move |mark| mark.jellyfish == Some(jellyfish))
721 };
722 let Some(original) = marks().map(|mark| mark.x).min() else {
723 continue;
724 };
725 let mut rows: [Option<RowBounds>; MAX_FRAME_MARKS as usize] =
726 [None; MAX_FRAME_MARKS as usize];
727 let mut row_count = 0usize;
728 let mut row_overflow = false;
729 let mut group_end = 0u16;
730 for mark in marks() {
731 let offset = mark.x.saturating_sub(original);
732 let width = u16::try_from(UnicodeWidthStr::width(mark.glyph)).unwrap_or(u16::MAX);
733 group_end = group_end.max(offset.saturating_add(width));
734 if rows[..row_count]
735 .iter()
736 .flatten()
737 .all(|row| row.y != mark.y)
738 {
739 if row_count == rows.len() {
740 debug_assert!(
741 row_count < rows.len(),
742 "jellyfish rows exceeded the ambient mark budget"
743 );
744 row_overflow = true;
745 break;
746 }
747 rows[row_count] = Some(RowBounds {
748 y: mark.y,
749 protected: lines
750 .get(usize::from(mark.y))
751 .and_then(occupied_text_bounds),
752 });
753 row_count += 1;
754 }
755 }
756 if row_overflow {
757 *placement = Some(Placement::Skip(SkipReason::Clipped));
758 continue;
759 }
760 let Some(right_edge) = area.width.checked_sub(group_end) else {
761 *placement = Some(Placement::Skip(SkipReason::Clipped));
762 continue;
763 };
764
765 let mut best: Option<(u16, u16)> = None;
766 let mut consider = |candidate: i64| {
767 let Ok(candidate) = u16::try_from(candidate) else {
768 return;
769 };
770 // Bounded dodge. Anything further than the cap is a relocation
771 // rather than a drift, so it is refused here and the silhouette
772 // is withheld instead — see [`JELLY_MAX_TEXT_DODGE_COLS`].
773 let dodge = candidate.abs_diff(original);
774 if dodge > JELLY_MAX_TEXT_DODGE_COLS {
775 return;
776 }
777 let fits = candidate <= right_edge
778 && marks().all(|mark| {
779 let x = candidate.saturating_add(mark.x.saturating_sub(original));
780 let width = UnicodeWidthStr::width(mark.glyph);
781 !rows[..row_count]
782 .iter()
783 .flatten()
784 .find(|row| row.y == mark.y)
785 .and_then(|row| row.protected)
786 .is_some_and(|(start, end)| {
787 usize::from(x) < end.saturating_add(1)
788 && usize::from(x) + width > start.saturating_sub(1)
789 })
790 });
791 if fits {
792 let ranked = (dodge, candidate);
793 if best.is_none_or(|current| ranked < current) {
794 best = Some(ranked);
795 }
796 }
797 };
798 consider(i64::from(original));
799 consider(0);
800 consider(i64::from(right_edge));
801 for mark in marks() {
802 let Some((start, end)) = rows[..row_count]
803 .iter()
804 .flatten()
805 .find(|row| row.y == mark.y)
806 .and_then(|row| row.protected)
807 else {
808 continue;
809 };
810 let offset = mark.x.saturating_sub(original);
811 let mark_end = offset.saturating_add(
812 u16::try_from(UnicodeWidthStr::width(mark.glyph)).unwrap_or(u16::MAX),
813 );
814 if let Ok(start) = i64::try_from(start) {
815 consider(start - 1 - i64::from(mark_end));
816 }
817 if let Ok(end) = i64::try_from(end) {
818 consider(end + 1 - i64::from(offset));
819 }
820 }
821 *placement = Some(match best {
822 Some((_, placed)) => Placement::Anchor { original, placed },
823 None => Placement::Skip(SkipReason::Text),
824 });
825 }
826
827 for mark in &frame.marks {
828 let mark_placement = mark
829 .jellyfish
830 .map(|index| placements.get(index).copied().flatten());
831 let (mark_x, preflighted) = match mark_placement {
832 Some(None) => {
833 stats.marks_clipped += 1;
834 continue;
835 }
836 Some(Some(Placement::Anchor { original, placed })) => (
837 placed
838 .checked_add(mark.x.saturating_sub(original))
839 .expect("preflight accepted a clipped jellyfish"),
840 true,
841 ),
842 Some(Some(Placement::Skip(SkipReason::Text))) => {
843 stats.marks_skipped_text += 1;
844 continue;
845 }
846 Some(Some(Placement::Skip(SkipReason::Clipped))) => {
847 stats.marks_clipped += 1;
848 continue;
849 }
850 None => (mark.x, false),
851 };
852 if !preflighted {
853 let mark_width = UnicodeWidthStr::width(mark.glyph);
854 // Clipped is checked before text collision so a mark that fails
855 // both is charged to the bound it could never satisfy.
856 if mark_x.saturating_add(mark_width as u16) > area.width {
857 stats.marks_clipped += 1;
858 continue;
859 }
860 let protected = lines
861 .get(usize::from(mark.y))
862 .and_then(occupied_text_bounds);
863 let collides = protected.is_some_and(|(start, end)| {
864 usize::from(mark_x) < end.saturating_add(1)
865 && usize::from(mark_x) + mark_width > start.saturating_sub(1)
866 });
867 if collides {
868 stats.marks_skipped_text += 1;
869 continue;
870 }
871 }
872 stats.marks_painted += 1;
873 let ink = if mark.depth.ink_index() == 1 {
874 inks.1
875 } else {
876 inks.0
877 };
878 for (offset, ch) in mark.glyph.chars().enumerate() {
879 let cell = &mut buf[(area.x + mark_x + offset as u16, area.y + mark.y)];
880 // Glow language: lerp the mark's ink up from the water the cell
881 // already sits in, at the entity's time-varying brightness. The
882 // overall lerp is additionally scaled by life presence so marks
883 // fade in/out with the animated/static boundary.
884 let fg = match (mark.brightness, cell.style().bg) {
885 (Some(amount), Some(water)) => {
886 ocean::mix_colors(water, ink, (amount * presence).clamp(0.0, 1.0))
887 }
888 (Some(amount), None) => ocean::scale_color(ink, amount.clamp(0.0, 1.0).max(0.4)),
889 (None, Some(water)) => ocean::mix_colors(water, ink, presence),
890 (None, None) => ocean::scale_color(ink, presence),
891 };
892 let mut style = Style::default().fg(fg);
893 if let Some(m) = mark.style_mod {
894 style = style.add_modifier(m);
895 }
896 cell.set_symbol(&ch.to_string());
897 cell.set_style(style);
898 stats.cells_written += 1;
899 }
900 }
901 }
902
903 /// Width-only occupied-text measurement (no per-line String allocation).
904 #[must_use]
905 pub fn occupied_text_bounds(line: &Line<'_>) -> Option<(usize, usize)> {
906 if line.spans.is_empty() {
907 return None;
908 }
909 let mut total = 0usize;
910 let mut leading = 0usize;
911 let mut seen_non_ws = false;
912 let mut trailing_run = 0usize;
913
914 for span in &line.spans {
915 for ch in span.content.chars() {
916 let w = UnicodeWidthChar::width(ch).unwrap_or(0);
917 total = total.saturating_add(w);
918 if ch.is_whitespace() {
919 if !seen_non_ws {
920 leading = leading.saturating_add(w);
921 } else {
922 trailing_run = trailing_run.saturating_add(w);
923 }
924 } else {
925 seen_non_ws = true;
926 trailing_run = 0;
927 }
928 }
929 }
930 if !seen_non_ws {
931 return None;
932 }
933 Some((leading, total.saturating_sub(trailing_run)))
934 }
935
936 #[must_use]
937 fn sine_bob(elapsed_ms: u128, period_ms: u128, amplitude: u16) -> u16 {
938 if period_ms == 0 || amplitude == 0 {
939 return 0;
940 }
941 let phase = (elapsed_ms % period_ms) as f64 / period_ms as f64;
942 let s = (phase * std::f64::consts::TAU).sin();
943 // Map [-1,1] → [0, amplitude]
944 (((s + 1.0) * 0.5) * f64::from(amplitude)).round() as u16
945 }
946
947 /// One-shot flee arc keyed to Working transition / pointer motion.
948 #[must_use]
949 pub fn fish_flee_offset(elapsed_ms: u128) -> u16 {
950 let progress = elapsed_ms.min(800) as f32 / 800.0;
951 let excursion = (progress * std::f32::consts::PI).sin() * 9.0;
952 excursion.round().clamp(0.0, 9.0) as u16
953 }
954
955 /// One fish silhouette family for the whole school: the lead carries an eye
956 /// (`><o>`), members are plain `><>`. Never mix lone `>` arrows in — that
957 /// reads as broken punctuation. All bodies are ASCII so `len() == width`.
958 #[must_use]
959 fn fish_body(facing_right: bool, lead: bool) -> &'static str {
960 match (facing_right, lead) {
961 (true, true) => LEAD_FISH_RIGHT,
962 (true, false) => "><>",
963 (false, true) => LEAD_FISH_LEFT,
964 (false, false) => "<><",
965 }
966 }
967
968 #[must_use]
969 pub fn whale_cameo_phase(elapsed_ms: u128) -> WhaleCameoPhase {
970 match elapsed_ms {
971 0..400 => WhaleCameoPhase::Breach,
972 400..1_000 => WhaleCameoPhase::Spout,
973 1_000..1_700 => WhaleCameoPhase::Fluke,
974 1_700..WHALE_CAMEO_MS => WhaleCameoPhase::Submerge,
975 _ => WhaleCameoPhase::Hidden,
976 }
977 }
978
979 /// Subtle caustic shimmer applied to empty water cells when the field would
980 /// otherwise read as a static ramp. Cheap: one phase lookup per cell, only
981 /// when `animated` and density allows.
982 pub fn apply_caustic_shimmer(
983 area: Rect,
984 buf: &mut Buffer,
985 column: &OceanColumn,
986 elapsed_ms: u128,
987 animated: bool,
988 lines: &[Line<'static>],
989 ) {
990 if !animated || area.width < AMBIENT_MIN_WIDTH || area.height < AMBIENT_MIN_HEIGHT {
991 return;
992 }
993 // Sparse sampling: every 3rd column on every other row near the surface.
994 let band = (area.height / 3).max(2);
995 for local_y in 0..band {
996 let protected = lines
997 .get(usize::from(local_y))
998 .and_then(occupied_text_bounds);
999 let ramp = frame_ocean_ramp(
1000 column,
1001 area.height,
1002 area.y,
1003 elapsed_ms,
1004 column.phase_tag(),
1005 column.ramp_fingerprint(),
1006 );
1007 let row_bg = ramp
1008 .get(usize::from(local_y))
1009 .copied()
1010 .unwrap_or_else(|| column.color_at_y(area.y.saturating_add(local_y)));
1011 for local_x in (0..area.width).step_by(3) {
1012 if protected.is_some_and(|(start, end)| {
1013 usize::from(local_x) >= start && usize::from(local_x) < end
1014 }) {
1015 continue;
1016 }
1017 let phase = ((elapsed_ms / 80)
1018 .wrapping_add(u128::from(local_x))
1019 .wrapping_add(u128::from(local_y) * 3))
1020 % 12;
1021 if phase > 2 {
1022 continue;
1023 }
1024 let cell = &mut buf[(area.x + local_x, area.y + local_y)];
1025 // Soften toward ambient ink without replacing semantic glyphs.
1026 if cell.symbol() == " " || cell.symbol().is_empty() {
1027 let shimmer = ocean::scale_color(row_bg, 1.08);
1028 cell.set_bg(shimmer);
1029 }
1030 }
1031 }
1032 }
1033
1034 /// Cached ocean row colors invalidated only when phase/dimensions/palette/breath tick.
1035 /// Shared across widgets that paint the same [`OceanColumn`] within a frame.
1036 #[derive(Debug, Clone, Default)]
1037 pub struct OceanRampCache {
1038 colors: Vec<Color>,
1039 height: u16,
1040 top: u16,
1041 elapsed_bucket: u128,
1042 phase_tag: u8,
1043 ramp_fingerprint: u64,
1044 }
1045
1046 impl OceanRampCache {
1047 /// Return a per-row color ramp, recomputing only when inputs change.
1048 pub fn colors_for(
1049 &mut self,
1050 column: &OceanColumn,
1051 height: u16,
1052 top: u16,
1053 elapsed_ms: u128,
1054 phase_tag: u8,
1055 ramp_fingerprint: u64,
1056 ) -> &[Color] {
1057 // Breath cycle is 90s; bucket at ~80ms atmosphere cadence so we don't
1058 // recompute every draw when nothing visible changed.
1059 let bucket = elapsed_ms / 80;
1060 if self.colors.len() == usize::from(height)
1061 && self.height == height
1062 && self.top == top
1063 && self.elapsed_bucket == bucket
1064 && self.phase_tag == phase_tag
1065 && self.ramp_fingerprint == ramp_fingerprint
1066 {
1067 return &self.colors;
1068 }
1069 self.colors.clear();
1070 self.colors.reserve(usize::from(height));
1071 for local_y in 0..height {
1072 self.colors
1073 .push(column.color_at_y(top.saturating_add(local_y)));
1074 }
1075 self.height = height;
1076 self.top = top;
1077 self.elapsed_bucket = bucket;
1078 self.phase_tag = phase_tag;
1079 self.ramp_fingerprint = ramp_fingerprint;
1080 &self.colors
1081 }
1082 }
1083
1084 thread_local! {
1085 static FRAME_RAMP: std::cell::RefCell<OceanRampCache> =
1086 const { std::cell::RefCell::new(OceanRampCache {
1087 colors: Vec::new(),
1088 height: 0,
1089 top: 0,
1090 elapsed_bucket: 0,
1091 phase_tag: 0,
1092 ramp_fingerprint: 0,
1093 }) };
1094 }
1095
1096 /// Process-local per-frame ocean ramp shared by chat field, caustics, and
1097 /// other widgets that paint the same column.
1098 #[must_use]
1099 pub fn frame_ocean_ramp(
1100 column: &OceanColumn,
1101 height: u16,
1102 top: u16,
1103 elapsed_ms: u128,
1104 phase_tag: u8,
1105 ramp_fingerprint: u64,
1106 ) -> Vec<Color> {
1107 FRAME_RAMP.with(|cache| {
1108 cache
1109 .borrow_mut()
1110 .colors_for(column, height, top, elapsed_ms, phase_tag, ramp_fingerprint)
1111 .to_vec()
1112 })
1113 }
1114
1115 #[cfg(test)]
1116 #[path = "ambient_life/tests.rs"]
1117 mod tests;
1118
1118 lines RUST