Skip to content

Commit 4bf4d63

Browse files
committed
Adds asciiquarium widget
Introduces an asciiquarium widget for egui. The widget displays an animated ASCII art aquarium with fish, bubbles, seaweed, and a castle. It includes logic for updating the aquarium state (fish movement, bubble emission, etc.) and rendering it to a string for display. Adds an `AquariumState` struct to manage the aquarium's data and an `AsciiquariumTheme` to style the widget.
1 parent 66ea131 commit 4bf4d63

File tree

2 files changed

+257
-30
lines changed

2 files changed

+257
-30
lines changed

examples/egui_demo.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ impl MyApp {
3434
let mut state = AquariumState {
3535
size,
3636
fishes: Vec::new(),
37+
..Default::default()
3738
};
3839

3940
// Seed with a few random fish

src/widgets/asciiquarium.rs

Lines changed: 256 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,12 @@
22
Asciiquarium widget scaffold for egui.
33
44
Agent Log:
5-
- Created core data structures: FishArt, FishInstance, AquariumState.
6-
- Implemented update_aquarium with wall-bounce using asset dimensions.
7-
- Implemented render_aquarium_to_string using a 2D char grid, floor() projection, clipping, and last-wins overlap.
8-
- Added theming via AsciiquariumTheme (no hardcoded colors/styles).
9-
- Added AsciiquariumWidget<'a> implementing egui::Widget; stateless, consumes state + assets + theme.
10-
- Decisions:
11-
- AquariumState.size is (usize, usize) to simplify indexing; parent code can cast from other units if desired.
12-
- Float-to-int via floor() for stable, predictable rendering.
13-
- No unwrap/expect, no panics; graceful handling of bad fish_art_index.
5+
- Extended AquariumState with environment (waterlines, seaweed, castle) and bubbles, plus a tick counter.
6+
- Implemented environment initialization and simple wave/seaweed animation phases.
7+
- Added bubble emission from fish and upward drift with culling at waterline.
8+
- Updated rendering to draw waterlines, castle, seaweed, fishes, then bubbles (top-most).
9+
- Preserved stateless widget and single-label rendering approach.
10+
- Kept bounce physics and clipping; float-to-int via floor() for stability.
1411
*/
1512

1613
use egui;
@@ -34,13 +31,56 @@ pub struct FishInstance {
3431
pub velocity: (f32, f32),
3532
}
3633

34+
/// A bubble that rises towards the waterline.
35+
#[derive(Debug, Clone)]
36+
pub struct Bubble {
37+
pub position: (f32, f32),
38+
pub velocity: (f32, f32),
39+
}
40+
41+
/// A single seaweed stalk.
42+
#[derive(Debug, Clone)]
43+
pub struct Seaweed {
44+
pub x: usize,
45+
pub height: usize,
46+
/// Per-stalk phase to desynchronize sway animation.
47+
pub sway_phase: u8,
48+
}
49+
50+
/// Environment effects and static props.
51+
#[derive(Debug, Clone)]
52+
pub struct AquariumEnvironment {
53+
/// Phase for waterline horizontal offset/sway animations.
54+
pub water_phase: u8,
55+
/// Detected/generated set of seaweed stalks.
56+
pub seaweed: Vec<Seaweed>,
57+
/// Whether to render the castle at bottom-right.
58+
pub castle: bool,
59+
}
60+
61+
impl Default for AquariumEnvironment {
62+
fn default() -> Self {
63+
Self {
64+
water_phase: 0,
65+
seaweed: Vec::new(),
66+
castle: true,
67+
}
68+
}
69+
}
70+
3771
/// The aquarium state that the parent application owns and updates.
3872
#[derive(Debug, Default)]
3973
pub struct AquariumState {
4074
/// Bounds of the aquarium in character cells (width, height).
4175
pub size: (usize, usize),
4276
/// All fish currently in the aquarium.
4377
pub fishes: Vec<FishInstance>,
78+
/// Rising bubbles.
79+
pub bubbles: Vec<Bubble>,
80+
/// Background/props animation state.
81+
pub env: AquariumEnvironment,
82+
/// Tick counter advanced once per update.
83+
pub tick: u64,
4484
}
4585

4686
/// Theme passed during render. No hardcoded styles in the component.
@@ -63,27 +103,81 @@ impl Default for AsciiquariumTheme {
63103
}
64104
}
65105

66-
/// Update the aquarium by one tick with simple wall-bounce physics.
67-
///
68-
/// Notes:
69-
/// - Uses each fish's asset width/height to bounce at the visible edge.
70-
/// - Keeps fish entirely within bounds after a bounce.
71-
/// - Handles invalid asset indices gracefully by treating size as 1x1.
106+
// Static environment art and helpers.
107+
108+
const WATER_LINES: [&str; 4] = [
109+
"~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~",
110+
"^^^^ ^^^ ^^^ ^^^ ^^^^ ",
111+
"^^^^ ^^^^ ^^^ ^^ ",
112+
"^^ ^^^^ ^^^ ^^^^^^ ",
113+
];
114+
115+
const CASTLE: &str = r#"
116+
T~~
117+
|
118+
/^\
119+
/ \
120+
_ _ _ / \ _ _ _
121+
[ ]_[ ]_[ ]/ _ _ \[ ]_[ ]_[ ]
122+
|_=__-_ =_|_[ ]_[ ]_|_=-___-__|
123+
| _- = | =_ = _ |= _= |
124+
|= -[] |- = _ = |_-=_[] |
125+
| =_ |= - ___ | =_ = |
126+
|= []- |- /| |\ |=_ =[] |
127+
|- =_ | =| | | | |- = - |
128+
|_______|__|_|_|_|__|_______|
129+
"#;
130+
131+
fn measure_block(art: &str) -> (usize, usize) {
132+
let mut w = 0usize;
133+
let mut h = 0usize;
134+
for line in art.lines() {
135+
w = w.max(line.chars().count());
136+
h += 1;
137+
}
138+
(w.max(1), h.max(1))
139+
}
140+
141+
fn ensure_environment_initialized(state: &mut AquariumState) {
142+
// Generate seaweed based on width if none present or if size changed significantly.
143+
let (w, h) = state.size;
144+
if w == 0 || h == 0 {
145+
state.env.seaweed.clear();
146+
return;
147+
}
148+
let target_count = (w / 15).max(1);
149+
if state.env.seaweed.len() != target_count {
150+
state.env.seaweed.clear();
151+
// Evenly distribute stalks across width; deterministic heights.
152+
for i in 0..target_count {
153+
let x = ((i + 1) * w / (target_count + 1)).saturating_sub(1);
154+
let height = 3 + (i % 4); // 3..6
155+
state.env.seaweed.push(Seaweed {
156+
x,
157+
height,
158+
sway_phase: (i as u8) * 7,
159+
});
160+
}
161+
}
162+
}
163+
164+
/// Update the aquarium by one tick with simple wall-bounce physics and environment.
72165
pub fn update_aquarium(state: &mut AquariumState, assets: &[FishArt]) {
73166
let (aw, ah) = (state.size.0 as f32, state.size.1 as f32);
74167

168+
// Ensure environment exists.
169+
ensure_environment_initialized(state);
170+
171+
// Integrate fish and handle bounce.
75172
for fish in &mut state.fishes {
76-
// Integrate position.
77173
fish.position.0 += fish.velocity.0;
78174
fish.position.1 += fish.velocity.1;
79175

80-
// Resolve asset size (fallback to 1x1 if out of range).
81176
let (fw, fh) = assets
82177
.get(fish.fish_art_index)
83178
.map(|a| (a.width as f32, a.height as f32))
84179
.unwrap_or((1.0, 1.0));
85180

86-
// Bounce on X.
87181
if fish.position.0 < 0.0 {
88182
fish.position.0 = 0.0;
89183
fish.velocity.0 = fish.velocity.0.abs();
@@ -92,7 +186,6 @@ pub fn update_aquarium(state: &mut AquariumState, assets: &[FishArt]) {
92186
fish.velocity.0 = -fish.velocity.0.abs();
93187
}
94188

95-
// Bounce on Y.
96189
if fish.position.1 < 0.0 {
97190
fish.position.1 = 0.0;
98191
fish.velocity.1 = fish.velocity.1.abs();
@@ -101,13 +194,53 @@ pub fn update_aquarium(state: &mut AquariumState, assets: &[FishArt]) {
101194
fish.velocity.1 = -fish.velocity.1.abs();
102195
}
103196
}
197+
198+
// Occasionally emit bubbles from fish mouths, deterministically based on tick.
199+
// Emit every 24 ticks per fish to avoid randomness in the core crate.
200+
for fish in &state.fishes {
201+
if state.tick % 24 == 0 {
202+
let (fw, fh) = assets
203+
.get(fish.fish_art_index)
204+
.map(|a| (a.width as f32, a.height as f32))
205+
.unwrap_or((1.0, 1.0));
206+
207+
let mid_y = fish.position.1 + fh * 0.5;
208+
let bx = if fish.velocity.0 >= 0.0 {
209+
fish.position.0 + fw
210+
} else {
211+
fish.position.0 - 1.0
212+
};
213+
state.bubbles.push(Bubble {
214+
position: (bx, mid_y),
215+
velocity: (0.0, -0.3),
216+
});
217+
}
218+
}
219+
220+
// Update bubbles (rise) and cull above waterline (y < 0).
221+
let mut kept = Vec::with_capacity(state.bubbles.len());
222+
for mut b in state.bubbles.drain(..) {
223+
b.position.0 += b.velocity.0;
224+
b.position.1 += b.velocity.1;
225+
if b.position.1 >= 0.0 {
226+
kept.push(b);
227+
}
228+
}
229+
state.bubbles = kept;
230+
231+
// Advance environment phases.
232+
state.env.water_phase = state.env.water_phase.wrapping_add(1);
233+
state.tick = state.tick.wrapping_add(1);
104234
}
105235

106236
/// Render the aquarium state into a single string (newline-separated).
107237
///
108-
/// - Uses floor() for stable float->int projection.
109-
/// - Clips art at boundaries.
110-
/// - Later fish in the list overdraw earlier ones (simple z-order).
238+
/// Order:
239+
/// - Waterlines (background)
240+
/// - Castle (bottom-right)
241+
/// - Seaweed (foreground under fish)
242+
/// - Fish
243+
/// - Bubbles (top-most)
111244
pub fn render_aquarium_to_string(state: &AquariumState, assets: &[FishArt]) -> String {
112245
let (w, h) = state.size;
113246
if w == 0 || h == 0 {
@@ -116,12 +249,95 @@ pub fn render_aquarium_to_string(state: &AquariumState, assets: &[FishArt]) -> S
116249

117250
let mut grid = vec![' '; w * h];
118251

252+
// 1) Waterlines (top 4 rows), animated horizontal offset by water_phase.
253+
for (i, pattern) in WATER_LINES.iter().enumerate() {
254+
if i >= h {
255+
break;
256+
}
257+
let chars: Vec<char> = pattern.chars().collect();
258+
let plen = chars.len().max(1);
259+
let offset = (state.env.water_phase as usize) % plen;
260+
for x in 0..w {
261+
let ch = chars[(x + offset) % plen];
262+
let idx = i * w + x;
263+
grid[idx] = ch;
264+
}
265+
}
266+
267+
// 2) Castle at bottom-right if enabled.
268+
if state.env.castle {
269+
let (cw, ch) = measure_block(CASTLE);
270+
let base_x = w.saturating_sub(cw + 1);
271+
let base_y = h.saturating_sub(ch);
272+
for (dy, line) in CASTLE.lines().enumerate() {
273+
let y = base_y + dy;
274+
if y >= h {
275+
continue;
276+
}
277+
for (dx, ch) in line.chars().enumerate() {
278+
if ch == ' ' {
279+
continue;
280+
}
281+
let x = base_x + dx;
282+
if x >= w {
283+
continue;
284+
}
285+
grid[y * w + x] = ch;
286+
}
287+
}
288+
}
289+
290+
// 3) Seaweed stalks, swaying slightly with water_phase + per-stalk phase.
291+
for (idx, stalk) in state.env.seaweed.iter().enumerate() {
292+
let base_y = h.saturating_sub(stalk.height);
293+
// sway: -1, 0, +1 cycling at a slow rate
294+
let phase = (state.env.water_phase.wrapping_add(stalk.sway_phase)) / 8;
295+
let sway = match phase % 3 {
296+
0 => -1isize,
297+
1 => 0isize,
298+
_ => 1isize,
299+
};
300+
// Draw alternating '(' and ')' vertically.
301+
for dy in 0..stalk.height {
302+
let y = base_y + dy;
303+
if y >= h {
304+
continue;
305+
}
306+
let left = dy % 2 == 0;
307+
let x_base = stalk.x as isize + if left { 0 } else { 1 };
308+
let x = x_base + sway;
309+
if x < 0 || (x as usize) >= w {
310+
continue;
311+
}
312+
grid[y * w + (x as usize)] = if left { '(' } else { ')' };
313+
}
314+
// Slight horizontal spread for some stalks to avoid uniformity.
315+
if idx % 3 == 0 {
316+
let x2 = (stalk.x + 1).min(w.saturating_sub(1));
317+
for dy in 1..stalk.height {
318+
let y = base_y + dy;
319+
if y >= h {
320+
continue;
321+
}
322+
let x = x2 as isize + sway;
323+
if x < 0 || (x as usize) >= w {
324+
continue;
325+
}
326+
if dy % 2 == 0 {
327+
grid[y * w + (x as usize)] = '(';
328+
} else {
329+
grid[y * w + (x as usize)] = ')';
330+
}
331+
}
332+
}
333+
}
334+
335+
// 4) Fish (overdraw seaweed/castle/water where they overlap).
119336
for fish in &state.fishes {
120337
let art = match assets.get(fish.fish_art_index) {
121338
Some(a) => a,
122-
None => continue, // Graceful skip if bad index
339+
None => continue,
123340
};
124-
125341
let x0 = fish.position.0.floor() as isize;
126342
let y0 = fish.position.1.floor() as isize;
127343

@@ -139,12 +355,21 @@ pub fn render_aquarium_to_string(state: &AquariumState, assets: &[FishArt]) -> S
139355
if x < 0 || x >= w as isize {
140356
continue;
141357
}
142-
let idx = y as usize * w + x as usize;
143-
grid[idx] = ch;
358+
grid[y as usize * w + x as usize] = ch;
144359
}
145360
}
146361
}
147362

363+
// 5) Bubbles (top-most), simple '.' markers with clipping.
364+
for b in &state.bubbles {
365+
let x = b.position.0.floor() as isize;
366+
let y = b.position.1.floor() as isize;
367+
if x < 0 || x >= w as isize || y < 0 || y >= h as isize {
368+
continue;
369+
}
370+
grid[y as usize * w + x as usize] = '.';
371+
}
372+
148373
// Join into a single string with newline separators.
149374
let mut out = String::with_capacity((w + 1) * h);
150375
for row in 0..h {
@@ -206,6 +431,7 @@ mod tests {
206431
position: (8.5, 1.0),
207432
velocity: (1.0, 0.0),
208433
}],
434+
..Default::default()
209435
};
210436
update_aquarium(&mut state, &assets);
211437
let f = &state.fishes[0];
@@ -220,16 +446,16 @@ mod tests {
220446
fn render_clips_left() {
221447
let assets = mk_assets();
222448
let state = AquariumState {
223-
size: (4, 1),
449+
size: (4, 5), // leave room for waterlines
224450
fishes: vec![FishInstance {
225451
fish_art_index: 0,
226452
position: (-1.0, 0.0),
227453
velocity: (0.0, 0.0),
228454
}],
455+
..Default::default()
229456
};
230457
let s = render_aquarium_to_string(&state, &assets);
231-
assert_eq!(s.len(), 4);
232-
// Expect only the '>' to be visible when the fish is partially off-screen to the left.
233-
assert!(s.starts_with('>'));
458+
// Expect multiple rows; ensure first visible char is still fish '>' due to overdraw.
459+
assert!(s.lines().next().unwrap_or("").starts_with('>'));
234460
}
235461
}

0 commit comments

Comments
 (0)