返回 CodeWhale
habitat.rs
1 //! The native marine habitat: a small school, one jellyfish and rising bubbles.
2 //!
3 //! Travel and authored dot poses are adapted from Codewhale Engine's
4 //! `ambient_life.rs` at `a79ce5c4d`; the pose module carries its MIT notice.
5 //! These components own no clock, simulation, input handler or runtime state.
6 //! The host supplies elapsed time and motion policy. Reduced and still motion
7 //! remove ambient life, as the Engine does; a host may separately paint its
8 //! existing [`crate::Whale`] in the host's reported state.
9 //!
10 //! Paint content first, then habitat. Every creature requires clear water
11 //! including a row and column of clearance around its full silhouette. No
12 //! component clears its area or repaints existing text. Filled blank overlays
13 //! need explicit protection through [`Habitat::protected`]. Painting into a fresh
14 //! buffer each frame lets creatures move without leaving trails.
15
16 use std::time::Duration;
17
18 use ratatui::{buffer::Buffer, layout::Rect};
19
20 use crate::{MotionMode, Paint, Role, Theme};
21
22 #[path = "habitat_poses.rs"]
23 mod poses;
24
25 /// A bounded marine population. Auto follows the Engine's terminal tiers.
26 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
27 pub enum HabitatDensity {
28 #[default]
29 Auto,
30 /// Three fish, two bubble streams.
31 Sparse,
32 /// Five fish, four bubble streams.
33 Normal,
34 /// Seven fish, six bubble streams. There is still at most one jellyfish.
35 Rich,
36 }
37
38 impl HabitatDensity {
39 #[must_use]
40 pub const fn for_area(self, area: Rect) -> Self {
41 match self {
42 Self::Auto if area.width < 56 || area.height < 12 => Self::Sparse,
43 Self::Auto if area.width < 88 || area.height < 20 => Self::Normal,
44 Self::Auto => Self::Rich,
45 other => other,
46 }
47 }
48
49 fn fish(self) -> usize {
50 match self {
51 Self::Sparse => 3,
52 Self::Normal => 5,
53 Self::Rich | Self::Auto => 7,
54 }
55 }
56
57 fn bubbles(self) -> usize {
58 match self {
59 Self::Sparse => 2,
60 Self::Normal => 4,
61 Self::Rich | Self::Auto => 6,
62 }
63 }
64 }
65
66 const WEDGE: &[(i32, u16)] = &[(0, 0), (-1, 4), (1, 6), (-1, 9), (1, 11), (0, 14), (-1, 17)];
67 const SCHOOL_CELL_MS: u128 = 380;
68
69 /// One loose school near the floor, with four native tail poses and half-cell
70 /// travel. ASCII terminals retain the Engine's eyed lead and smaller members.
71 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
72 pub struct FishSchool {
73 pub elapsed: Duration,
74 pub motion: MotionMode,
75 pub density: HabitatDensity,
76 }
77
78 impl FishSchool {
79 #[must_use]
80 pub const fn new(elapsed: Duration, motion: MotionMode) -> Self {
81 Self {
82 elapsed,
83 motion,
84 density: HabitatDensity::Auto,
85 }
86 }
87
88 #[must_use]
89 pub const fn density(mut self, density: HabitatDensity) -> Self {
90 self.density = density;
91 self
92 }
93 }
94
95 impl FishSchool {
96 fn paint_except(&self, area: Rect, buf: &mut Buffer, theme: &Theme, protected: &[Rect]) {
97 let area = area.intersection(buf.area);
98 if !self.motion.animates() || area.width < 4 || area.height < 4 {
99 return;
100 }
101 let count = self.density.for_area(area).fish();
102 let span = WEDGE[count - 1].1 + 4;
103 let travel = u128::from(area.width) + u128::from(span);
104 let cycle_ms = travel * SCHOOL_CELL_MS;
105 let t = self.elapsed.as_millis();
106 // A fresh scene starts mid-crossing, rather than with an empty ocean.
107 let clock = t.saturating_add(cycle_ms / 2);
108 let right = ((clock / cycle_ms).wrapping_mul(0x9E37_79B9_7F4A_7C15) >> 7) & 1 == 0;
109 let step = ((clock % cycle_ms) * travel * 2 / cycle_ms) as i32;
110 let mut marks = Vec::with_capacity(count);
111 for (member, &(dy, behind)) in WEDGE.iter().take(count).enumerate() {
112 let bob = sine_bob(t, 3_000 + jitter(member as u128 + 41) % 2_600, 1);
113 let body = if theme.ascii() {
114 match (right, member == 0) {
115 (true, true) => "><o>",
116 (true, false) => "><>",
117 (false, true) => "<o><",
118 (false, false) => "<><",
119 }
120 } else {
121 let drift =
122 i32::from(sine_bob(t, 5_200 + jitter(member as u128 + 617) % 3_400, 2)) - 1;
123 let dot_x = if right {
124 step - i32::from(behind) * 2 - 8 + drift
125 } else {
126 i32::from(area.width) * 2 - step + i32::from(behind) * 2 - drift
127 };
128 poses::fish(
129 right,
130 ((t / 300 + member as u128) % 4) as usize,
131 dot_x.rem_euclid(2) as usize,
132 usize::from(bob),
133 )
134 };
135 let width = if theme.ascii() { body.len() as u16 } else { 4 };
136 let x = if theme.ascii() {
137 if right {
138 step / 2 - i32::from(behind) - i32::from(width)
139 } else {
140 i32::from(area.width) - step / 2 + i32::from(behind)
141 }
142 } else {
143 let drift =
144 i32::from(sine_bob(t, 5_200 + jitter(member as u128 + 617) % 3_400, 2)) - 1;
145 let dot_x = if right {
146 step - i32::from(behind) * 2 - 8 + drift
147 } else {
148 i32::from(area.width) * 2 - step + i32::from(behind) * 2 - drift
149 };
150 dot_x.div_euclid(2)
151 };
152 let y =
153 i32::from(area.height) - 3 + dy + if theme.ascii() { i32::from(bob) } else { 0 };
154 let Some(at) = local_rect(area, x, y, width, 1) else {
155 continue;
156 };
157 if open_water(at, buf, protected) {
158 let role = if member == 0 {
159 Role::Primary
160 } else {
161 Role::Hint
162 };
163 marks.push((at, body, role));
164 }
165 }
166 // All school members see the host's original text, rather than
167 // mistaking a neighbouring member of their wedge for a word.
168 for (at, body, role) in marks {
169 buf.set_stringn(at.x, at.y, body, usize::from(at.width), theme.fg(role));
170 }
171 }
172 }
173
174 impl Paint for FishSchool {
175 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
176 self.paint_except(area, buf, theme, &[]);
177 }
178 fn height(&self, _width: u16, _theme: &Theme) -> u16 {
179 6
180 }
181 }
182
183 /// One complete bell with two lagging arms. The standalone component is
184 /// always present under Full motion. `visitor(true)` gives it the Engine's
185 /// slow six-row rise and long absence between visits, suitable for a habitat.
186 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
187 pub struct Jellyfish {
188 pub elapsed: Duration,
189 pub motion: MotionMode,
190 pub visitor: bool,
191 /// Keep a visitor's rise cycling during the Engine's long absence.
192 /// Collision checks remain authoritative even for a held visit.
193 pub hold_visit: bool,
194 }
195
196 impl Jellyfish {
197 #[must_use]
198 pub const fn new(elapsed: Duration, motion: MotionMode) -> Self {
199 Self {
200 elapsed,
201 motion,
202 visitor: false,
203 hold_visit: false,
204 }
205 }
206
207 #[must_use]
208 pub const fn visitor(mut self, visitor: bool) -> Self {
209 self.visitor = visitor;
210 self
211 }
212
213 #[must_use]
214 pub const fn hold_visit(mut self, hold: bool) -> Self {
215 self.hold_visit = hold;
216 self
217 }
218 }
219
220 impl Jellyfish {
221 fn paint_except(&self, area: Rect, buf: &mut Buffer, theme: &Theme, protected: &[Rect]) {
222 let area = area.intersection(buf.area);
223 if !self.motion.animates() || area.width < 7 || area.height < 5 {
224 return;
225 }
226 let t = self.elapsed.as_millis();
227 let compact = theme.ascii() && area.width < 56;
228 let width = if compact { 3 } else { 5 };
229 let wobble = sine_bob(t, 8_300, 2);
230 let (x, y, dy) = if self.visitor {
231 if area.height < 9 {
232 return;
233 }
234 const ROW_MS: u128 = 9_400;
235 let cycle = t.saturating_add(3_100) % (ROW_MS * 32);
236 if !self.hold_visit && cycle >= ROW_MS * 6 {
237 return;
238 }
239 let cycle = cycle % (ROW_MS * 6);
240 let dots = i32::from(area.height) * 4 - 32 - (cycle * 4 / ROW_MS) as i32;
241 let x = (u32::from(area.width) * 5 / 6) as u16;
242 (
243 i32::from(x.min(area.width - width - 1)),
244 dots.div_euclid(4),
245 dots.rem_euclid(4) as usize,
246 )
247 } else {
248 (
249 i32::from((area.width - width) / 2),
250 i32::from((area.height - 3) / 2),
251 usize::from(sine_bob(t, 9_400, 3)),
252 )
253 };
254 let Some(target) = local_rect(area, x, y, width, 3) else {
255 return;
256 };
257 // Whole-silhouette clearance, including blank cells between arms.
258 // Withhold rather than splitting the bell or dodging around live text.
259 if target.x == area.x
260 || target.y == area.y
261 || target.right() == area.right()
262 || target.bottom() == area.bottom()
263 || !open_water(target, buf, protected)
264 {
265 return;
266 }
267 if theme.ascii() {
268 let pulse = ((t % 5_200) * 2 / 5_200) as usize;
269 let arms = ((t.saturating_add(3_100) / 1_100) % 4) as usize;
270 let tops = if compact {
271 [".-.", "'-'"]
272 } else {
273 [".-~-.", ".'-'."]
274 };
275 let skirts = if compact {
276 ["\\_/", "(_)"]
277 } else {
278 ["\\___/", "(___)"]
279 };
280 let trails = if compact {
281 ["| |", "/ \\", "| /", "\\ |"]
282 } else {
283 [" | | ", " / \\ ", " | / ", " \\ | "]
284 };
285 for (row, body) in [tops[pulse], skirts[pulse], trails[arms]]
286 .into_iter()
287 .enumerate()
288 {
289 buf.set_stringn(
290 target.x,
291 target.y + row as u16,
292 body,
293 usize::from(width),
294 theme.fg(Role::Hint),
295 );
296 }
297 } else {
298 let pose = ((t % 5_200) * 16 / 5_200) as usize;
299 for (row, body) in poses::jelly(pose, usize::from(wobble % 2), dy)
300 .iter()
301 .enumerate()
302 {
303 let role = if row == 0 { Role::Hint } else { Role::Dim };
304 buf.set_stringn(
305 target.x,
306 target.y + row as u16,
307 body,
308 usize::from(width),
309 theme.fg(role),
310 );
311 }
312 }
313 }
314 }
315
316 impl Paint for Jellyfish {
317 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
318 self.paint_except(area, buf, theme, &[]);
319 }
320 fn height(&self, _width: u16, _theme: &Theme) -> u16 {
321 7
322 }
323 }
324
325 /// Two, four or six irregular bubble streams. Their short five-row ascent
326 /// stays near the floor and gives way to prose and other marine silhouettes.
327 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
328 pub struct BubbleField {
329 pub elapsed: Duration,
330 pub motion: MotionMode,
331 pub density: HabitatDensity,
332 }
333
334 impl BubbleField {
335 #[must_use]
336 pub const fn new(elapsed: Duration, motion: MotionMode) -> Self {
337 Self {
338 elapsed,
339 motion,
340 density: HabitatDensity::Auto,
341 }
342 }
343
344 #[must_use]
345 pub const fn density(mut self, density: HabitatDensity) -> Self {
346 self.density = density;
347 self
348 }
349 }
350
351 impl BubbleField {
352 fn paint_except(&self, area: Rect, buf: &mut Buffer, theme: &Theme, protected: &[Rect]) {
353 let area = area.intersection(buf.area);
354 if !self.motion.animates() || area.width < 3 || area.height < 3 {
355 return;
356 }
357 let t = self.elapsed.as_millis();
358 for stream in 0..self.density.for_area(area).bubbles() {
359 let seed = stream as u128;
360 let phase = jitter(seed) % 9_000;
361 let period = 3_200 + jitter(seed + 313) % 2_600;
362 let rise = (t.saturating_add(phase) % period) * 5 / period;
363 let lane = (jitter(seed + 977) % u128::from(area.width)) as i32;
364 let drift = i32::from(sine_bob(t.saturating_add(phase), 2_100, 2)) - 1;
365 let x = (lane + drift).clamp(0, i32::from(area.width) - 1);
366 let y = (i32::from(area.height) - 2 - rise as i32).max(0);
367 let Some(at) = local_rect(area, x, y, 1, 1) else {
368 continue;
369 };
370 if !open_water(at, buf, protected) {
371 continue;
372 }
373 let glyph = match (theme.ascii(), rise) {
374 (true, 0..=1) => ".",
375 (true, 2..=3) => "o",
376 (true, _) => "O",
377 (false, 0..=1) => "·",
378 (false, 2..=3) => "˚",
379 (false, _) => "°",
380 };
381 buf.set_stringn(at.x, at.y, glyph, 1, theme.fg(Role::Dim));
382 }
383 }
384 }
385
386 impl Paint for BubbleField {
387 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
388 self.paint_except(area, buf, theme, &[]);
389 }
390 fn height(&self, _width: u16, _theme: &Theme) -> u16 {
391 8
392 }
393 }
394
395 /// The three marine layers, painted over the host's existing content. The
396 /// school owns the lower band, one occasional jellyfish visits above it,
397 /// and bubbles occupy whatever clear water remains. No new whale is created.
398 #[derive(Clone, Debug, PartialEq, Eq)]
399 pub struct Habitat {
400 pub elapsed: Duration,
401 pub motion: MotionMode,
402 pub density: HabitatDensity,
403 pub fish: bool,
404 pub jellyfish: bool,
405 pub bubbles: bool,
406 pub hold_jellyfish_visit: bool,
407 /// Host-owned overlays and other areas that must remain undisturbed,
408 /// including their blank cells. Coordinates are absolute buffer positions.
409 pub protected: Vec<Rect>,
410 }
411
412 impl Habitat {
413 #[must_use]
414 pub const fn new(elapsed: Duration, motion: MotionMode) -> Self {
415 Self {
416 elapsed,
417 motion,
418 density: HabitatDensity::Auto,
419 fish: true,
420 jellyfish: true,
421 bubbles: true,
422 hold_jellyfish_visit: false,
423 protected: Vec::new(),
424 }
425 }
426
427 #[must_use]
428 pub const fn density(mut self, density: HabitatDensity) -> Self {
429 self.density = density;
430 self
431 }
432 #[must_use]
433 pub const fn fish(mut self, show: bool) -> Self {
434 self.fish = show;
435 self
436 }
437 #[must_use]
438 pub const fn jellyfish(mut self, show: bool) -> Self {
439 self.jellyfish = show;
440 self
441 }
442 #[must_use]
443 pub const fn bubbles(mut self, show: bool) -> Self {
444 self.bubbles = show;
445 self
446 }
447 /// Protect filled blank overlays, focus surfaces or other host-owned
448 /// regions. A creature that touches a region's one-cell clearance is
449 /// withheld as a complete silhouette.
450 #[must_use]
451 pub fn protected(mut self, areas: impl Into<Vec<Rect>>) -> Self {
452 self.protected = areas.into();
453 self
454 }
455
456 #[must_use]
457 pub fn exclude_rect(mut self, area: Rect) -> Self {
458 self.protected.push(area);
459 self
460 }
461
462 /// Keep the corner visitor present for a preview or dedicated marine
463 /// surface. False (the default) retains its long absence between visits.
464 /// Full-silhouette collision checks still give all existing text priority.
465 #[must_use]
466 pub const fn hold_jellyfish_visit(mut self, hold: bool) -> Self {
467 self.hold_jellyfish_visit = hold;
468 self
469 }
470 }
471
472 impl Paint for Habitat {
473 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
474 if self.fish {
475 FishSchool::new(self.elapsed, self.motion)
476 .density(self.density)
477 .paint_except(area, buf, theme, &self.protected);
478 }
479 if self.jellyfish {
480 Jellyfish::new(self.elapsed, self.motion)
481 .visitor(true)
482 .hold_visit(self.hold_jellyfish_visit)
483 .paint_except(area, buf, theme, &self.protected);
484 }
485 if self.bubbles {
486 BubbleField::new(self.elapsed, self.motion)
487 .density(self.density)
488 .paint_except(area, buf, theme, &self.protected);
489 }
490 }
491
492 fn height(&self, _width: u16, _theme: &Theme) -> u16 {
493 16
494 }
495 }
496
497 // Signed local coordinates make off-screen school travel and saturated
498 // absolute buffer edges ordinary clipping, rather than u16 wraparound.
499 fn local_rect(area: Rect, x: i32, y: i32, width: u16, height: u16) -> Option<Rect> {
500 if x < 0
501 || y < 0
502 || x + i32::from(width) > i32::from(area.width)
503 || y + i32::from(height) > i32::from(area.height)
504 {
505 return None;
506 }
507 Some(Rect::new(
508 area.x + x as u16,
509 area.y + y as u16,
510 width,
511 height,
512 ))
513 }
514
515 fn open_water(at: Rect, buf: &Buffer, protected: &[Rect]) -> bool {
516 let x0 = at.x.saturating_sub(1).max(buf.area.x);
517 let y0 = at.y.saturating_sub(1).max(buf.area.y);
518 let x1 = at.right().saturating_add(1).min(buf.area.right());
519 let y1 = at.bottom().saturating_add(1).min(buf.area.bottom());
520 let clearance = Rect::new(x0, y0, x1 - x0, y1 - y0);
521 if protected
522 .iter()
523 .any(|r| !clearance.intersection(*r).is_empty())
524 {
525 return false;
526 }
527 (y0..y1).all(|y| (x0..x1).all(|x| buf[(x, y)].symbol().chars().all(char::is_whitespace)))
528 }
529
530 // Engine's deterministic phase spread; no RNG and no animation state.
531 fn jitter(seed: u128) -> u128 {
532 let mut hash = 0xcbf2_9ce4_8422_2325u64;
533 for byte in seed.to_le_bytes() {
534 hash ^= u64::from(byte);
535 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
536 }
537 u128::from(hash)
538 }
539
540 fn sine_bob(elapsed: u128, period: u128, amplitude: u16) -> u16 {
541 let fraction = (elapsed % period) as f64 / period as f64;
542 (((fraction * std::f64::consts::TAU).sin() + 1.0) * 0.5 * f64::from(amplitude)).round() as u16
543 }
544
544 lines RUST