use ab_glyph::{Font, FontArc, PxScale, ScaleFont, point};
use bevy::asset::RenderAssetUsages;
use bevy::image::{Image, ImageSampler};
use bevy::prelude::*;
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
use bevy::time::Real;
use crate::style::{BubbleKind, Emotion};
const CAP_PX: f32 = 40.0;
const PAD: f32 = 28.0;
const MARGIN: f32 = 6.0;
const MAX_TEXT_W: f32 = 520.0;
const MIN_TEXT_W: f32 = 70.0;
const TAIL_H: f32 = 28.0;
const BORDER: f32 = 3.5;
const SPEECH_RADIUS: f32 = 18.0;
const WORLD_PER_PX: f32 = 1.0 / 210.0;
pub const BUBBLE_ANCHOR_Y: f32 = 2.5;
const CAMERA_NUDGE: f32 = 0.4;
const READING_WPM: f32 = 200.0;
const DWELL_FLOOR: f32 = 1.8;
pub fn dwell_secs(text: &str) -> f32 {
let words = text.split_whitespace().count().max(1) as f32;
(words / (READING_WPM / 60.0)).max(DWELL_FLOOR)
}
#[derive(Component)]
pub struct Bubble {
pub owner: Entity,
pub offset: Vec2,
}
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct BubblePx(pub UVec2);
#[derive(Component, Debug, Clone, Copy, PartialEq)]
pub struct BubbleOffsetPx(pub Vec2);
#[derive(Resource, Debug, Clone, Copy, PartialEq)]
pub struct BalloonScale {
pub world_per_px: f32,
pub anchor_y: f32,
}
impl Default for BalloonScale {
fn default() -> Self {
Self {
world_per_px: WORLD_PER_PX,
anchor_y: BUBBLE_ANCHOR_Y,
}
}
}
#[derive(Component)]
pub struct BubbleTtl {
pub expires_at: f32,
}
#[derive(Resource)]
pub struct BubbleAssets {
pub quad: Handle<Mesh>,
pub font: FontArc,
}
impl BubbleAssets {
pub fn from_font_bytes(quad: Handle<Mesh>, bytes: Vec<u8>) -> Result<Self, String> {
let font = FontArc::try_from_vec(bytes).map_err(|e| format!("not a usable font: {e}"))?;
Ok(Self { quad, font })
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BalloonSkin {
pub fill: [u8; 4],
pub border: [u8; 4],
pub text: [u8; 4],
pub pixel: bool,
pub thought_fill: [u8; 4],
pub thought_border: [u8; 4],
pub text_px: f32,
}
impl BalloonSkin {
pub const fn crt() -> Self {
Self {
fill: [8, 20, 12, 240],
border: [140, 255, 158, 255],
text: [226, 240, 226, 255],
pixel: false,
thought_fill: [8, 16, 22, 232],
thought_border: [150, 210, 255, 240],
text_px: CAP_PX,
}
}
pub const fn ink_on_paper() -> Self {
Self {
fill: [0xe8, 0xd9, 0xb0, 0xff],
border: [0x3a, 0x2a, 0x1e, 0xff],
text: [0x2a, 0x1c, 0x12, 0xff],
pixel: true,
thought_fill: [0xe8, 0xd9, 0xb0, 0xff],
thought_border: [0x3a, 0x2a, 0x1e, 0xff],
text_px: 16.0,
}
}
}
impl Default for BalloonSkin {
fn default() -> Self {
Self::crt()
}
}
#[derive(Resource, Debug, Clone, Copy, PartialEq, Default)]
pub struct DefaultBalloonSkin(pub BalloonSkin);
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BubbleStyle {
pub kind: BubbleKind,
pub emotion: Emotion,
pub tail: bool,
pub skin: BalloonSkin,
}
pub struct RenderedBubble {
pub material: Handle<StandardMaterial>,
pub px: UVec2,
pub size: Vec2,
}
pub fn build_bubble(
assets: &BubbleAssets,
images: &mut Assets<Image>,
materials: &mut Assets<StandardMaterial>,
style: &BubbleStyle,
text: &str,
) -> RenderedBubble {
let (rgba, w, h) = rasterize(&assets.font, style, text);
let mut image = Image::new(
Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
TextureDimension::D2,
rgba,
TextureFormat::Rgba8UnormSrgb,
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
);
if style.skin.pixel {
image.sampler = ImageSampler::nearest();
}
let image = images.add(image);
let material = materials.add(StandardMaterial {
base_color_texture: Some(image),
unlit: true,
alpha_mode: AlphaMode::Blend,
cull_mode: None,
..default()
});
RenderedBubble {
material,
px: UVec2::new(w, h),
size: Vec2::new(w as f32 * WORLD_PER_PX, h as f32 * WORLD_PER_PX),
}
}
type TrackedBubble = (
Entity,
&'static Bubble,
Option<&'static BubblePx>,
Option<&'static BubbleOffsetPx>,
&'static mut Transform,
&'static mut Visibility,
);
type Anchoring = (
Option<&'static Transform>,
Option<&'static Visibility>,
Option<&'static ChildOf>,
);
fn owner_in_world(
owner: Entity,
chain: &Query<Anchoring, Without<Bubble>>,
) -> Option<(Vec3, bool)> {
let (Some(tf), Some(vis), mut parent) = chain.get(owner).ok()? else {
return None;
};
let mut point = tf.translation;
let mut composing = true;
let mut visible = decides(Some(vis));
while let Some(child_of) = parent {
if !composing && visible.is_some() {
break;
}
let Ok((tf, vis, next)) = chain.get(child_of.parent()) else {
break;
};
match tf {
Some(tf) if composing => point = tf.transform_point(point),
_ => composing = false,
}
if visible.is_none() {
visible = decides(vis);
}
parent = next;
}
Some((point, visible.unwrap_or(true)))
}
fn decides(vis: Option<&Visibility>) -> Option<bool> {
match vis {
Some(Visibility::Inherited) => None,
Some(Visibility::Visible) | None => Some(true),
Some(Visibility::Hidden) => Some(false),
}
}
pub fn track_bubbles<C: Component>(
mut commands: Commands,
camera: Single<&GlobalTransform, With<C>>,
scale: Option<Res<BalloonScale>>,
chain: Query<Anchoring, Without<Bubble>>,
mut bubbles: Query<TrackedBubble>,
) {
let scale = scale.map_or_else(BalloonScale::default, |s| *s);
let cam_rot = camera.rotation();
let cam_pos = camera.translation();
let up = cam_rot * Vec3::Y;
let right = cam_rot * Vec3::X;
for (entity, bubble, face, offset_px, mut tf, mut vis) in &mut bubbles {
let Some((owner_pos, owner_visible)) = owner_in_world(bubble.owner, &chain) else {
commands.entity(entity).despawn();
continue;
};
if let Some(face) = face {
let size = face.0.as_vec2() * scale.world_per_px;
tf.scale = Vec3::new(size.x, size.y, 1.0);
}
let offset = offset_px.map_or(bubble.offset, |o| o.0 * scale.world_per_px);
let anchor = owner_pos + Vec3::Y * scale.anchor_y;
let half_h = tf.scale.y * 0.5;
let toward = (cam_pos - anchor).normalize_or_zero();
tf.translation =
anchor + up * (half_h + offset.y) + right * offset.x + toward * CAMERA_NUDGE;
tf.rotation = cam_rot;
vis.set_if_neq(if owner_visible {
Visibility::Inherited
} else {
Visibility::Hidden
});
}
}
pub fn expire_bubbles(
mut commands: Commands,
time: Res<Time<Real>>,
ttls: Query<(Entity, &BubbleTtl)>,
) {
let now = time.elapsed_secs();
for (entity, ttl) in &ttls {
if now >= ttl.expires_at {
commands.entity(entity).despawn();
}
}
}
struct Palette {
fill: [u8; 4],
border: [u8; 4],
text: [u8; 4],
pixel: bool,
border_w: f32,
}
struct Metrics {
cap: f32,
k: f32,
pad: f32,
margin: f32,
max_text_w: f32,
min_text_w: f32,
tail_h: f32,
border: f32,
radius: f32,
}
impl Metrics {
fn new(skin: &BalloonSkin) -> Self {
let cap = skin.text_px.max(1.0);
let k = cap / CAP_PX;
let sc = |v: f32| {
let s = v * k;
if skin.pixel { s.round().max(1.0) } else { s }
};
Self {
cap,
k,
pad: sc(PAD),
margin: sc(MARGIN),
max_text_w: sc(MAX_TEXT_W),
min_text_w: sc(MIN_TEXT_W),
tail_h: sc(TAIL_H),
border: sc(BORDER),
radius: sc(SPEECH_RADIUS),
}
}
}
impl Palette {
fn edge(&self, d: f32) -> f32 {
if self.pixel {
if d <= 0.0 { 1.0 } else { 0.0 }
} else {
(0.5 - d).clamp(0.0, 1.0)
}
}
fn glyph(&self, cov: f32) -> f32 {
if self.pixel {
if cov >= 0.5 { 1.0 } else { 0.0 }
} else {
cov
}
}
}
fn palette(style: &BubbleStyle, m: &Metrics) -> Palette {
let skin = &style.skin;
let (fill, base_border) = match style.kind {
BubbleKind::Speech => (skin.fill, skin.border),
BubbleKind::Thought => (skin.thought_fill, skin.thought_border),
};
let border = match style.emotion {
Emotion::Neutral => base_border,
Emotion::Joy => [235, 205, 90, 255],
Emotion::Anger => [242, 74, 58, 255],
Emotion::Sadness => [96, 150, 240, 255],
Emotion::Surprise => [250, 240, 150, 255],
Emotion::Fear => [178, 118, 226, 255],
Emotion::Calm => [120, 226, 170, 255],
};
Palette {
fill,
border,
text: skin.text,
pixel: skin.pixel,
border_w: m.border,
}
}
fn blend(buf: &mut [u8], w: u32, h: u32, x: i32, y: i32, col: [u8; 4], cov: f32) {
if x < 0 || y < 0 || x >= w as i32 || y >= h as i32 || cov <= 0.0 {
return;
}
let idx = ((y as u32 * w + x as u32) * 4) as usize;
let a = (col[3] as f32 / 255.0) * cov.clamp(0.0, 1.0);
if a <= 0.0 {
return;
}
for k in 0..3 {
let src = col[k] as f32;
let dst = buf[idx + k] as f32;
buf[idx + k] = (src * a + dst * (1.0 - a)).round().clamp(0.0, 255.0) as u8;
}
let da = buf[idx + 3] as f32 / 255.0;
let out_a = a + da * (1.0 - a);
buf[idx + 3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
}
fn rounded_rect_sdf(p: Vec2, half: Vec2, r: f32) -> f32 {
let q = p.abs() - (half - Vec2::splat(r));
q.max(Vec2::ZERO).length() + q.x.max(q.y).min(0.0) - r
}
fn measure<F: Font>(sf: &impl ScaleFont<F>, s: &str) -> f32 {
let mut w = 0.0;
let mut prev = None;
for c in s.chars() {
let id = sf.glyph_id(c);
if let Some(p) = prev {
w += sf.kern(p, id);
}
w += sf.h_advance(id);
prev = Some(id);
}
w
}
fn wrap_lines<F: Font>(sf: &impl ScaleFont<F>, text: &str, max_w: f32) -> Vec<String> {
let space = measure(sf, " ");
let mut lines = Vec::new();
for para in text.split('\n') {
let mut cur = String::new();
let mut cur_w = 0.0;
for word in para.split_whitespace() {
let ww = measure(sf, word);
if !cur.is_empty() && cur_w + space + ww > max_w {
lines.push(std::mem::take(&mut cur));
cur_w = 0.0;
}
if !cur.is_empty() {
cur.push(' ');
cur_w += space;
}
cur.push_str(word);
cur_w += ww;
}
lines.push(cur);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn horizontal_pad(m: &Metrics, text_w: f32, text_h: f32, thought: bool) -> f32 {
let balloon_h = text_h + 2.0 * m.pad;
let corner = Vec2::new(text_w * 0.5, text_h * 0.5);
let clear = m.border + 4.0;
let fits = |pad: f32| {
let half = Vec2::new((text_w + 2.0 * pad) * 0.5, balloon_h * 0.5);
let r = if thought {
half.y.min(half.x)
} else {
m.radius.min(half.x).min(half.y)
};
rounded_rect_sdf(corner, half, r) <= -clear
};
if fits(m.pad) {
return m.pad;
}
let mut lo = m.pad;
let mut hi = m.pad + text_h + clear;
for _ in 0..8 {
if fits(hi) {
break;
}
hi += text_h.max(m.pad);
}
for _ in 0..24 {
let mid = 0.5 * (lo + hi);
if fits(mid) { hi = mid } else { lo = mid }
}
hi
}
fn rasterize(font: &FontArc, style: &BubbleStyle, text: &str) -> (Vec<u8>, u32, u32) {
let m = Metrics::new(&style.skin);
let pal = palette(style, &m);
let pixel = style.skin.pixel;
let sf = font.as_scaled(PxScale::from(m.cap));
let mut line_h = sf.height() + sf.line_gap();
let mut ascent = sf.ascent();
if pixel {
line_h = line_h.round().max(1.0);
ascent = ascent.round();
}
let lines = wrap_lines(&sf, text, m.max_text_w);
let mut text_w = lines
.iter()
.map(|l| measure(&sf, l))
.fold(0.0_f32, f32::max)
.max(m.min_text_w);
if pixel {
text_w = text_w.ceil();
}
let text_h = line_h * lines.len() as f32;
let balloon_h = text_h + 2.0 * m.pad;
let thought = matches!(style.kind, BubbleKind::Thought);
let mut pad_x = horizontal_pad(&m, text_w, text_h, thought);
if pixel {
pad_x = pad_x.ceil();
}
let balloon_w = text_w + 2.0 * pad_x;
let tail_h = if style.tail { m.tail_h } else { 0.0 };
let img_w = (balloon_w + 2.0 * m.margin).ceil() as u32;
let img_h = (balloon_h + 2.0 * m.margin + tail_h).ceil() as u32;
let mut buf = vec![0u8; (img_w * img_h * 4) as usize];
let cx = m.margin + balloon_w / 2.0;
let cy = m.margin + balloon_h / 2.0;
let half = Vec2::new(balloon_w / 2.0, balloon_h / 2.0);
let radius = if thought {
(balloon_h * 0.5).min(balloon_w * 0.5) } else {
m.radius.min(half.x).min(half.y)
};
for py in 0..img_h {
for px in 0..img_w {
let p = Vec2::new(px as f32 + 0.5 - cx, py as f32 + 0.5 - cy);
let d = rounded_rect_sdf(p, half, radius);
let outer = pal.edge(d); if outer <= 0.0 {
continue;
}
if d >= -m.border {
blend(
&mut buf, img_w, img_h, px as i32, py as i32, pal.border, outer,
);
} else {
blend(&mut buf, img_w, img_h, px as i32, py as i32, pal.fill, 1.0);
}
}
}
if style.tail {
let k = m.k;
let base_y = m.margin + balloon_h;
if thought {
draw_disc(&mut buf, img_w, img_h, cx, base_y + 7.0 * k, 8.0 * k, &pal);
draw_disc(
&mut buf,
img_w,
img_h,
cx - 4.0 * k,
base_y + 18.0 * k,
5.0 * k,
&pal,
);
draw_disc(
&mut buf,
img_w,
img_h,
cx - 8.0 * k,
base_y + 26.0 * k,
3.0 * k,
&pal,
);
} else {
let a = Vec2::new(cx - 14.0 * k, base_y - 2.0 * k);
let b = Vec2::new(cx + 14.0 * k, base_y - 2.0 * k);
let tip = Vec2::new(cx - 6.0 * k, base_y + m.tail_h);
draw_triangle(&mut buf, img_w, img_h, a, b, tip, &pal);
}
}
let snap = |v: f32| if pixel { v.round() } else { v };
let left = m.margin + pad_x;
let mut baseline = m.margin + m.pad + ascent;
for line in &lines {
let mut caret = left;
let mut prev = None;
for c in line.chars() {
let id = sf.glyph_id(c);
if let Some(p) = prev {
caret += sf.kern(p, id);
}
let mut glyph = sf.scaled_glyph(c);
glyph.position = point(snap(caret), snap(baseline));
if let Some(outline) = sf.outline_glyph(glyph) {
let bounds = outline.px_bounds();
outline.draw(|gx, gy, cov| {
let x = bounds.min.x as i32 + gx as i32;
let y = bounds.min.y as i32 + gy as i32;
blend(&mut buf, img_w, img_h, x, y, pal.text, pal.glyph(cov));
});
}
caret += sf.h_advance(id);
prev = Some(id);
}
baseline += line_h;
}
(buf, img_w, img_h)
}
fn draw_disc(buf: &mut [u8], w: u32, h: u32, cx: f32, cy: f32, r: f32, pal: &Palette) {
let x0 = (cx - r - 1.0).floor() as i32;
let x1 = (cx + r + 1.0).ceil() as i32;
let y0 = (cy - r - 1.0).floor() as i32;
let y1 = (cy + r + 1.0).ceil() as i32;
for y in y0..=y1 {
for x in x0..=x1 {
let d = Vec2::new(x as f32 + 0.5 - cx, y as f32 + 0.5 - cy).length() - r;
let outer = pal.edge(d);
if outer <= 0.0 {
continue;
}
if d >= -pal.border_w {
blend(buf, w, h, x, y, pal.border, outer);
} else {
blend(buf, w, h, x, y, pal.fill, 1.0);
}
}
}
}
fn draw_triangle(buf: &mut [u8], w: u32, h: u32, a: Vec2, b: Vec2, c: Vec2, pal: &Palette) {
let min_x = a.x.min(b.x).min(c.x).floor() as i32 - 1;
let max_x = a.x.max(b.x).max(c.x).ceil() as i32 + 1;
let min_y = a.y.min(b.y).min(c.y).floor() as i32 - 1;
let max_y = a.y.max(b.y).max(c.y).ceil() as i32 + 1;
let area = edge(a, b, c);
if area.abs() < 1.0e-3 {
return;
}
let sign = area.signum();
for y in min_y..=max_y {
for x in min_x..=max_x {
let p = Vec2::new(x as f32 + 0.5, y as f32 + 0.5);
let w0 = edge(b, c, p) * sign;
let w1 = edge(c, a, p) * sign;
let w2 = edge(a, b, p) * sign;
if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
continue;
}
let d0 = w0 / (b - c).length();
let d1 = w1 / (c - a).length();
let d2 = w2 / (a - b).length();
let edge_dist = d0.min(d1).min(d2);
let col = if edge_dist <= pal.border_w {
pal.border
} else {
pal.fill
};
blend(buf, w, h, x, y, col, 1.0);
}
}
}
fn edge(a: Vec2, b: Vec2, p: Vec2) -> f32 {
(b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::image::ImageSamplerDescriptor;
#[test]
fn text_corners_stay_inside_the_balloon() {
for skin in [BalloonSkin::crt(), BalloonSkin::ink_on_paper()] {
let m = Metrics::new(&skin);
for lines in 1..=4 {
for thought in [false, true] {
let text_w = 300.0 * m.k;
let text_h = 56.0 * m.k * lines as f32;
let pad = horizontal_pad(&m, text_w, text_h, thought);
let balloon_w = text_w + 2.0 * pad;
let balloon_h = text_h + 2.0 * m.pad;
let half = Vec2::new(balloon_w * 0.5, balloon_h * 0.5);
let radius = if thought {
(balloon_h * 0.5).min(balloon_w * 0.5)
} else {
m.radius.min(half.x).min(half.y)
};
let corner = Vec2::new(text_w * 0.5, text_h * 0.5);
let d = rounded_rect_sdf(corner, half, radius);
assert!(
d < -m.border,
"pixel={} lines={lines} thought={thought}: text corner sits at sdf {d:.2}, which \
is not clear of the {}px border — glyphs there draw over the outline or outside it",
skin.pixel,
m.border
);
}
}
}
}
#[test]
fn one_line_pays_nothing_for_the_fix() {
let m = Metrics::new(&BalloonSkin::crt());
assert_eq!(m.pad, PAD, "crt metrics are the reference constants");
assert_eq!(horizontal_pad(&m, 300.0, 56.0, true), PAD);
assert_eq!(horizontal_pad(&m, 300.0, 56.0, false), PAD);
assert!(
horizontal_pad(&m, 300.0, 168.0, true) > PAD,
"three lines must widen the pill"
);
}
#[test]
fn dwell_has_floor() {
assert!((dwell_secs("hi") - DWELL_FLOOR).abs() < 1.0e-6);
}
#[test]
fn dwell_scales_with_length() {
let long = "word ".repeat(40);
let secs = dwell_secs(&long);
assert!(secs > DWELL_FLOOR);
assert!((secs - 12.0).abs() < 0.5);
}
#[test]
fn empty_text_still_floors() {
assert!((dwell_secs("") - DWELL_FLOOR).abs() < 1.0e-6);
}
#[test]
fn from_font_bytes_accepts_a_font_and_refuses_garbage() {
let bytes = include_bytes!("../tests/data/FiraMono-subset.ttf").to_vec();
assert!(BubbleAssets::from_font_bytes(Handle::default(), bytes).is_ok());
let err =
BubbleAssets::from_font_bytes(Handle::default(), b"definitely not a font".to_vec())
.err()
.expect("garbage must be refused");
assert!(err.contains("font"), "{err}");
}
fn test_font() -> FontArc {
FontArc::try_from_slice(include_bytes!("../tests/data/FiraMono-subset.ttf"))
.expect("the test font parses")
}
fn styled(kind: BubbleKind, tail: bool, skin: BalloonSkin) -> BubbleStyle {
BubbleStyle {
kind,
emotion: Emotion::Neutral,
tail,
skin,
}
}
const LINE: &str = "Hold the gate until dawn, then ring the bell twice.";
#[test]
fn a_pixel_skin_has_no_partially_transparent_texel() {
let font = test_font();
let skin = BalloonSkin::ink_on_paper();
for kind in [BubbleKind::Speech, BubbleKind::Thought] {
for tail in [true, false] {
let (rgba, w, h) = rasterize(&font, &styled(kind, tail, skin), LINE);
assert_eq!(rgba.len(), (w * h * 4) as usize);
let mut solid = 0;
let mut text = 0;
for px in rgba.as_chunks::<4>().0 {
assert!(
px[3] == 0 || px[3] == 255,
"{kind:?} tail={tail}: texel {px:?} is partially transparent"
);
if px[3] == 255 {
solid += 1;
if px[..3] == skin.text[..3] {
text += 1;
}
}
}
assert!(solid > 0, "{kind:?}: nothing was drawn");
assert!(text > 0, "{kind:?}: no glyph texel carries the text colour");
}
}
}
#[test]
fn the_anti_aliased_skin_does_blend() {
let font = test_font();
let (rgba, ..) = rasterize(
&font,
&styled(BubbleKind::Speech, true, BalloonSkin::crt()),
LINE,
);
assert!(
rgba.as_chunks::<4>()
.0
.iter()
.any(|px| px[3] != 0 && px[3] != 255),
"crt skin produced no partially transparent texel; the pixel test proves nothing"
);
}
#[test]
fn a_pixel_skin_gets_a_nearest_sampler() {
let assets = BubbleAssets {
quad: Handle::default(),
font: test_font(),
};
let mut images = Assets::<Image>::default();
let mut materials = Assets::<StandardMaterial>::default();
for (skin, nearest) in [
(BalloonSkin::ink_on_paper(), true),
(BalloonSkin::crt(), false),
] {
let rendered = build_bubble(
&assets,
&mut images,
&mut materials,
&styled(BubbleKind::Speech, true, skin),
"Sampler.",
);
let material = materials
.get(&rendered.material)
.expect("the material exists");
let handle = material
.base_color_texture
.as_ref()
.expect("a face texture");
let image = images.get(handle).expect("the image exists");
assert_eq!(
matches!(&image.sampler, ImageSampler::Descriptor(d) if *d == ImageSamplerDescriptor::nearest()),
nearest,
"pixel={}: wrong sampler {:?}",
skin.pixel,
image.sampler
);
}
}
#[test]
fn emotion_tints_the_pixel_border() {
let font = test_font();
let mut style = styled(BubbleKind::Speech, false, BalloonSkin::ink_on_paper());
style.emotion = Emotion::Anger;
let (rgba, ..) = rasterize(&font, &style, "No.");
assert!(
rgba.as_chunks::<4>().0.contains(&[242, 74, 58, 255]),
"no texel carries the anger tint"
);
}
#[test]
fn a_pixel_balloon_is_native_size() {
let font = test_font();
let skin = BalloonSkin::ink_on_paper();
assert_eq!(skin.text_px, 16.0);
let (rgba, w, h) = rasterize(
&font,
&styled(BubbleKind::Speech, false, skin),
"Hold the gate at dwn",
);
assert!(
(150..=240).contains(&w),
"width {w} texels is not about 20 chars at 16 px"
);
assert!(
(26..=50).contains(&h),
"height {h} texels is not one pixel-font line"
);
assert!(
rgba.as_chunks::<4>()
.0
.iter()
.all(|px| px[3] == 0 || px[3] == 255)
);
let (_, hw, hh) = rasterize(
&font,
&styled(BubbleKind::Speech, false, BalloonSkin::crt()),
"Hold the gate at dwn",
);
assert!(hw > 2 * w && hh > 2 * h, "crt {hw}x{hh} vs pixel {w}x{h}");
let m = Metrics::new(&skin);
assert_eq!(m.border, 1.0);
let x = (w / 2) as usize;
let col: Vec<[u8; 4]> = (0..h as usize)
.map(|y| {
let i = (y * w as usize + x) * 4;
[rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]]
})
.collect();
let first = col.iter().position(|p| p[3] == 255).expect("a solid texel");
assert_eq!(
col[first][..3],
skin.border[..3],
"the outermost texel is the border"
);
assert_eq!(
col[first + 1][..3],
skin.fill[..3],
"and only one texel thick"
);
}
#[test]
fn a_pixel_tail_is_small() {
let font = test_font();
let skin = BalloonSkin::ink_on_paper();
for kind in [BubbleKind::Speech, BubbleKind::Thought] {
let (_, w0, h0) = rasterize(&font, &styled(kind, false, skin), "Hi there");
let (rgba, w1, h1) = rasterize(&font, &styled(kind, true, skin), "Hi there");
assert_eq!(w0, w1);
assert!(
(1..=14).contains(&(h1 - h0)),
"{kind:?}: the tail adds {} texels",
h1 - h0
);
assert!(
rgba.as_chunks::<4>()
.0
.iter()
.all(|px| px[3] == 0 || px[3] == 255)
);
}
}
#[derive(Component)]
struct Cam;
#[test]
fn track_bubbles_follows_a_change_of_balloon_scale() {
let mut app = App::new();
app.add_systems(Update, track_bubbles::<Cam>)
.insert_resource(BalloonScale {
world_per_px: 0.01,
anchor_y: 2.0,
});
app.world_mut().spawn((
Cam,
GlobalTransform::from(Transform::from_xyz(5.0, 1.0, 1000.0)),
));
let owner = app
.world_mut()
.spawn((Transform::from_xyz(5.0, 1.0, 0.0), Visibility::default()))
.id();
let balloon = app
.world_mut()
.spawn((
Bubble {
owner,
offset: Vec2::ZERO,
},
BubblePx(UVec2::new(100, 40)),
BubbleOffsetPx(Vec2::new(0.0, 10.0)),
Transform::default(),
Visibility::default(),
))
.id();
app.update();
let tf = *app.world().get::<Transform>(balloon).expect("balloon");
assert!(
(tf.scale.x - 1.0).abs() < 1e-5 && (tf.scale.y - 0.4).abs() < 1e-5,
"{:?}",
tf.scale
);
assert!(
(tf.translation.y - (3.0 + 0.2 + 0.1)).abs() < 0.01,
"{:?}",
tf.translation
);
*app.world_mut().resource_mut::<BalloonScale>() = BalloonScale {
world_per_px: 0.02,
anchor_y: 3.0,
};
app.update();
let tf = *app.world().get::<Transform>(balloon).expect("balloon");
assert!(
(tf.scale.x - 2.0).abs() < 1e-5 && (tf.scale.y - 0.8).abs() < 1e-5,
"{:?}",
tf.scale
);
assert!(
(tf.translation.y - (4.0 + 0.4 + 0.2)).abs() < 0.01,
"{:?}",
tf.translation
);
}
fn balloon_on(app: &mut App, owner: Entity) -> Entity {
app.world_mut()
.spawn((
Bubble {
owner,
offset: Vec2::ZERO,
},
BubblePx(UVec2::new(100, 40)),
Transform::default(),
Visibility::default(),
))
.id()
}
#[test]
fn a_child_owner_gets_its_balloon_over_its_world_position() {
let mut app = App::new();
app.add_plugins(TransformPlugin)
.add_systems(Update, track_bubbles::<Cam>);
app.world_mut().spawn((
Cam,
GlobalTransform::from(Transform::from_xyz(10.0, 1.0, 1000.0)),
));
let parent = app
.world_mut()
.spawn((Transform::from_xyz(10.0, 0.0, 0.0), Visibility::default()))
.id();
let owner = app
.world_mut()
.spawn((Transform::default(), Visibility::default(), ChildOf(parent)))
.id();
let balloon = balloon_on(&mut app, owner);
app.update();
let tf = app.world().get::<Transform>(balloon).expect("balloon");
assert!(
(tf.translation.x - 10.0).abs() < 0.05,
"{:?}",
tf.translation
);
}
#[test]
fn a_nested_owner_anchors_at_its_propagated_world_position_in_the_same_frame() {
let mut app = App::new();
app.add_plugins(TransformPlugin)
.add_systems(Update, track_bubbles::<Cam>);
let cam = Vec3::new(-3.0, 4.0, 900.0);
app.world_mut()
.spawn((Cam, GlobalTransform::from(Transform::from_translation(cam))));
let grandparent = app
.world_mut()
.spawn((
Transform::from_xyz(4.0, 1.0, -2.0)
.with_rotation(Quat::from_rotation_y(0.7))
.with_scale(Vec3::splat(2.0)),
Visibility::default(),
))
.id();
let parent = app
.world_mut()
.spawn((
Transform::from_xyz(1.5, 0.25, 0.0).with_rotation(Quat::from_rotation_z(0.4)),
Visibility::default(),
ChildOf(grandparent),
))
.id();
let owner = app
.world_mut()
.spawn((
Transform::from_xyz(0.0, 1.0, 0.5),
Visibility::default(),
ChildOf(parent),
))
.id();
let balloon = balloon_on(&mut app, owner);
let scale = BalloonScale::default();
let check = |app: &App, when: &str| {
let world_pos = app
.world()
.get::<GlobalTransform>(owner)
.expect("owner")
.translation();
let anchor = world_pos + Vec3::Y * scale.anchor_y;
let half_h = 40.0 * scale.world_per_px * 0.5;
let expected = anchor + Vec3::Y * half_h + (cam - anchor).normalize() * CAMERA_NUDGE;
let got = app
.world()
.get::<Transform>(balloon)
.expect("balloon")
.translation;
assert!(
got.distance(expected) < 1e-4,
"{when}: balloon at {got:?}, owner's world position {world_pos:?} wants {expected:?}"
);
};
app.update();
check(&app, "first frame");
app.world_mut()
.get_mut::<Transform>(grandparent)
.expect("grandparent")
.translation += Vec3::new(-6.0, 0.5, 3.0);
app.update();
check(&app, "the frame the grandparent moved");
}
#[test]
fn a_balloon_hides_with_its_owners_parent() {
use bevy::camera::visibility::VisibilityPlugin;
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
AssetPlugin::default(),
TransformPlugin,
VisibilityPlugin,
))
.init_asset::<Mesh>()
.init_asset::<bevy::mesh::skinning::SkinnedMeshInverseBindposes>()
.add_systems(Update, track_bubbles::<Cam>);
app.world_mut().spawn((Cam, GlobalTransform::default()));
let parent = app
.world_mut()
.spawn((Transform::from_xyz(10.0, 0.0, 0.0), Visibility::default()))
.id();
let owner = app
.world_mut()
.spawn((Transform::default(), Visibility::default(), ChildOf(parent)))
.id();
let balloon = balloon_on(&mut app, owner);
let shown = |app: &App| {
app.world()
.get::<InheritedVisibility>(balloon)
.expect("balloon")
.get()
};
app.update();
assert!(shown(&app), "a visible owner's balloon is visible");
*app.world_mut()
.get_mut::<Visibility>(parent)
.expect("parent") = Visibility::Hidden;
app.update();
assert!(
!app.world()
.get::<InheritedVisibility>(owner)
.expect("owner")
.get()
);
assert!(
!shown(&app),
"the owner is hidden through its parent, so is its balloon"
);
*app.world_mut().get_mut::<Visibility>(owner).expect("owner") = Visibility::Visible;
app.update();
assert!(
shown(&app),
"an owner set Visible shows under a hidden parent, and so does its balloon"
);
*app.world_mut().get_mut::<Visibility>(owner).expect("owner") = Visibility::Inherited;
*app.world_mut()
.get_mut::<Visibility>(parent)
.expect("parent") = Visibility::Inherited;
app.update();
assert!(shown(&app), "the parent shown again, the balloon is back");
}
#[test]
fn a_bubble_without_a_raster_size_keeps_its_scale() {
let mut app = App::new();
app.add_systems(Update, track_bubbles::<Cam>);
app.world_mut().spawn((Cam, GlobalTransform::default()));
let owner = app
.world_mut()
.spawn((Transform::default(), Visibility::default()))
.id();
let balloon = app
.world_mut()
.spawn((
Bubble {
owner,
offset: Vec2::ZERO,
},
Transform::from_scale(Vec3::new(3.0, 1.0, 1.0)),
Visibility::default(),
))
.id();
app.update();
let tf = app.world().get::<Transform>(balloon).expect("balloon");
assert_eq!(tf.scale, Vec3::new(3.0, 1.0, 1.0));
}
}