2
2
Asciiquarium widget scaffold for egui.
3
3
4
4
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.
14
11
*/
15
12
16
13
use egui;
@@ -34,13 +31,56 @@ pub struct FishInstance {
34
31
pub velocity : ( f32 , f32 ) ,
35
32
}
36
33
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
+
37
71
/// The aquarium state that the parent application owns and updates.
38
72
#[ derive( Debug , Default ) ]
39
73
pub struct AquariumState {
40
74
/// Bounds of the aquarium in character cells (width, height).
41
75
pub size : ( usize , usize ) ,
42
76
/// All fish currently in the aquarium.
43
77
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 ,
44
84
}
45
85
46
86
/// Theme passed during render. No hardcoded styles in the component.
@@ -63,27 +103,81 @@ impl Default for AsciiquariumTheme {
63
103
}
64
104
}
65
105
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.
72
165
pub fn update_aquarium ( state : & mut AquariumState , assets : & [ FishArt ] ) {
73
166
let ( aw, ah) = ( state. size . 0 as f32 , state. size . 1 as f32 ) ;
74
167
168
+ // Ensure environment exists.
169
+ ensure_environment_initialized ( state) ;
170
+
171
+ // Integrate fish and handle bounce.
75
172
for fish in & mut state. fishes {
76
- // Integrate position.
77
173
fish. position . 0 += fish. velocity . 0 ;
78
174
fish. position . 1 += fish. velocity . 1 ;
79
175
80
- // Resolve asset size (fallback to 1x1 if out of range).
81
176
let ( fw, fh) = assets
82
177
. get ( fish. fish_art_index )
83
178
. map ( |a| ( a. width as f32 , a. height as f32 ) )
84
179
. unwrap_or ( ( 1.0 , 1.0 ) ) ;
85
180
86
- // Bounce on X.
87
181
if fish. position . 0 < 0.0 {
88
182
fish. position . 0 = 0.0 ;
89
183
fish. velocity . 0 = fish. velocity . 0 . abs ( ) ;
@@ -92,7 +186,6 @@ pub fn update_aquarium(state: &mut AquariumState, assets: &[FishArt]) {
92
186
fish. velocity . 0 = -fish. velocity . 0 . abs ( ) ;
93
187
}
94
188
95
- // Bounce on Y.
96
189
if fish. position . 1 < 0.0 {
97
190
fish. position . 1 = 0.0 ;
98
191
fish. velocity . 1 = fish. velocity . 1 . abs ( ) ;
@@ -101,13 +194,53 @@ pub fn update_aquarium(state: &mut AquariumState, assets: &[FishArt]) {
101
194
fish. velocity . 1 = -fish. velocity . 1 . abs ( ) ;
102
195
}
103
196
}
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 ) ;
104
234
}
105
235
106
236
/// Render the aquarium state into a single string (newline-separated).
107
237
///
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)
111
244
pub fn render_aquarium_to_string ( state : & AquariumState , assets : & [ FishArt ] ) -> String {
112
245
let ( w, h) = state. size ;
113
246
if w == 0 || h == 0 {
@@ -116,12 +249,95 @@ pub fn render_aquarium_to_string(state: &AquariumState, assets: &[FishArt]) -> S
116
249
117
250
let mut grid = vec ! [ ' ' ; w * h] ;
118
251
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).
119
336
for fish in & state. fishes {
120
337
let art = match assets. get ( fish. fish_art_index ) {
121
338
Some ( a) => a,
122
- None => continue , // Graceful skip if bad index
339
+ None => continue ,
123
340
} ;
124
-
125
341
let x0 = fish. position . 0 . floor ( ) as isize ;
126
342
let y0 = fish. position . 1 . floor ( ) as isize ;
127
343
@@ -139,12 +355,21 @@ pub fn render_aquarium_to_string(state: &AquariumState, assets: &[FishArt]) -> S
139
355
if x < 0 || x >= w as isize {
140
356
continue ;
141
357
}
142
- let idx = y as usize * w + x as usize ;
143
- grid[ idx] = ch;
358
+ grid[ y as usize * w + x as usize ] = ch;
144
359
}
145
360
}
146
361
}
147
362
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
+
148
373
// Join into a single string with newline separators.
149
374
let mut out = String :: with_capacity ( ( w + 1 ) * h) ;
150
375
for row in 0 ..h {
@@ -206,6 +431,7 @@ mod tests {
206
431
position: ( 8.5 , 1.0 ) ,
207
432
velocity: ( 1.0 , 0.0 ) ,
208
433
} ] ,
434
+ ..Default :: default ( )
209
435
} ;
210
436
update_aquarium ( & mut state, & assets) ;
211
437
let f = & state. fishes [ 0 ] ;
@@ -220,16 +446,16 @@ mod tests {
220
446
fn render_clips_left ( ) {
221
447
let assets = mk_assets ( ) ;
222
448
let state = AquariumState {
223
- size : ( 4 , 1 ) ,
449
+ size : ( 4 , 5 ) , // leave room for waterlines
224
450
fishes : vec ! [ FishInstance {
225
451
fish_art_index: 0 ,
226
452
position: ( -1.0 , 0.0 ) ,
227
453
velocity: ( 0.0 , 0.0 ) ,
228
454
} ] ,
455
+ ..Default :: default ( )
229
456
} ;
230
457
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( '>' ) ) ;
234
460
}
235
461
}
0 commit comments