//! Chat bubbles, `CGChatBubbleFrame`: the speech bubble a chat line spawns over the speaker, a //! 2-D overlay like the V-plate ([`crate::vplates`]). //! //! - Spawn (`0x608ac0`, from the `SMSG_MESSAGECHAT` display path): the sender resolves to a live //! unit, its CVar is on, the text is non-empty, the unit carries no V-plate (`0x608adc`; a live //! bubble in turn hides the overhead name, [`BubblesActive`]), and the local player is within //! 20 yd (3-D). Self is not excluded. A new line replaces the old bubble, never queues. //! - Anchor (`0x4b0c30`): unit z + height × model scale + 0.7, bottom-seated, growing upward. The //! height is the Stand sequence box's Z extent from the model header (`0x711a20`), not the posed //! attachment the overhead name reads (`0x608640`), latched once per chat line. //! - Stacking (`0x4b1060`): every frame the bubbles sort farthest-first by camera distance and //! take frame levels 2, 3, 4…, so whole cards stack with the nearest speaker's on top. //! //! Only say, yell, party and monster say/yell bubble: the reference's gate is "sender resolves", //! but the wire-type remap (`0x49a870`) that would admit other kinds is untraced. //! //! Deviation: sizes ride the plates' damped diagonal basis ([`plate_basis`]), not the unbounded //! reference law, so bubble text and plate text keep one em at every window size. //! //! Not built: the reference also fades a bubble whose speaker's model data is not resident or whose //! scene-entity flag is clear (`0x7103d0`/`0x6704c0`); only distance is re-tested here. use std::collections::HashMap; use bevy::ecs::entity::EntityHashSet; use bevy::prelude::*; use benilla_ui::layout::Rect as GxRect; use benilla_ui::script::{inset_atlas_bleed, pieces, Backdrop, Insets}; use benilla_ui::script::{JustifyH, JustifyV, Outline}; use crate::entities::StandBoxHeight; use crate::net::{Embodied, Guid, NetEntity, SelfGuid}; use crate::ui_chat::{default_color, ChatEventKind}; use crate::ui_pass::{overlay_z, UiQuad, UiQuadAppend, UiQuads, UvRect}; use crate::ui_text::{layout_text_quads, measure_text, FontSpec, Justify, TextSeat, UiFontAtlas}; use crate::vplates::{device_snap, gx_px, plate_basis, text_px, VPlateSet, VPlates}; use benilla_assets::{AssetSet, WorldAssets}; use benilla_world::view::WorldCamera; /// The two bubble CVars (registrar `0x603280`), booting at the reference's defaults. #[derive(Resource)] pub(crate) struct BubbleConfig { /// `ChatBubbles`: say, yell and their monster variants. pub(crate) all: bool, /// `ChatBubblesParty`: party lines, which the reference gates separately. pub(crate) party: bool, } impl Default for BubbleConfig { fn default() -> Self { Self { all: true, party: false, } } } /// The bubble art (`0x4b0940`). const BG_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Background"; const EDGE_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Backdrop"; const TAIL_TEXTURE: &str = "Interface\\Tooltips\\ChatBubble-Tail"; /// The 0.7 yd lift over the anchor height (`[0x7ffd7c]`). const LIFT: f32 = 0.7; /// The spawn and per-frame range gate, 20 yd squared (`[0x806798]`, the plates' too). const MAX_DIST_SQ: f32 = 400.0; /// The 250 ms linear fade, in and out (`0x4b0ea0`/`0x4b0ee0`). const FADE_SECS: f32 = 0.25; /// `NAMEPLATE_FONT` at 0.01 gx, the plate name's em. const TEXT_H: f32 = 0.01; /// The body margin around the text layout, all four sides (`0x3c23d70a`). const MARGIN: f32 = 0.01; /// The text wrap hard cap, gx (`[0x80679c]` = 0.2). const WRAP_W: f32 = 0.2; /// The border unit (edge size, insets, tail side): 16/1024 of the screen width. const BORDER_FRAC: f32 = 16.0 / 1024.0; /// Paint order inside one bubble's level: the tail covers the bottom edge piece and the text draws /// last, as the reference's batch drains textures before font strings (`0x76fb00`). const Z_BG: u64 = 0; const Z_EDGE: u64 = 1; const Z_TAIL: u64 = 2; const Z_TEXT: u64 = 3; /// The z base for the bubble at `rank`, farthest first: the reference's per-frame /// `SetFrameLevel(2 + i)` walk (`0x4b12d5`-`0x4b1312`), one level per bubble, under the V-plates. fn level_z(rank: usize) -> u64 { let level = (rank as u64).min(overlay_z::BUBBLE_MAX_LEVEL); overlay_z::BUBBLE + level * overlay_z::BUBBLE_STRIDE } /// The CVar gating this kind's bubble (`0x608b0d`); `None` for a kind that does not bubble. fn bubble_cvar(kind: ChatEventKind, cfg: &BubbleConfig) -> Option { use ChatEventKind as K; match kind { K::Party => Some(cfg.party), K::Say | K::Yell | K::MonsterSay | K::MonsterYell => Some(cfg.all), _ => None, } } /// The word count (`0x4b1810`): maximal runs of characters other than space and tab. fn word_count(text: &str) -> u32 { let mut words = 0u32; let mut in_word = false; for b in text.bytes() { let sep = b == b' ' || b == b'\t'; if !sep && !in_word { words += 1; } in_word = !sep; } words } /// The lifetime (`0x4b1810`): `base + perWord·(words−1)` ms, 2750/750 for others and 1500/500 /// for the local player. fn duration_secs(words: u32, is_self: bool) -> f32 { if words == 0 { return 0.0; } let (base, per) = if is_self { (1500, 500) } else { (2750, 750) }; (base + per * (words - 1)) as f32 / 1000.0 } /// Bubble text is plain, coloured only by chat type: colour escapes and hyperlink wrappers strip /// (their display text stays), and `||` stays escaped for the glyph layout's markup parser. fn sanitize(text: &str) -> String { let b = text.as_bytes(); let mut out = String::with_capacity(text.len()); let mut i = 0; while i < b.len() { if b[i] != b'|' { // Copy the whole non-escape run at once (UTF-8 safe: '|' is single-byte). let start = i; while i < b.len() && b[i] != b'|' { i += 1; } out.push_str(&text[start..i]); continue; } match b.get(i + 1) { Some(b'|') => { out.push_str("||"); i += 2; } Some(b'c') | Some(b'C') if i + 10 <= b.len() => i += 10, Some(b'r') | Some(b'R') => i += 2, Some(b'H') => { // Skip the opener through its `|h`; the closing `|h` drops in the arm below. i += 2; while i < b.len() && !(b[i] == b'|' && b.get(i + 1) == Some(&b'h')) { i += 1; } i += 2; } Some(b'h') => i += 2, // A dangling or unknown escape renders as a literal pipe. _ => { out.push_str("||"); i += 1; } } } out } /// The bleed inset for one border piece; zero texels (no patch chain) leaves the UVs alone. fn pieces_inset(uvs: [[f32; 2]; 4], texels: Vec2) -> [[f32; 2]; 4] { if texels.x <= 0.0 || texels.y <= 0.0 { return uvs; } inset_atlas_bleed(uvs, texels.x, texels.y) } /// The frame's bottom-left origin, bottom-center on the seat. Deviation: snapped onto the device /// pixel grid ([`device_snap`]) so the border art blits 1:1; the reference seats it fractionally. fn seat_origin(seat: Vec2, w: f32, scale: f32) -> Vec2 { Vec2::new( device_snap(seat.x - w * 0.5, scale), device_snap(seat.y, scale), ) } /// The border unit in logical px: 16/1024 of the screen width, in the damped size basis. fn border_px(viewport: Vec2, basis: f32) -> f32 { let width_gx = viewport.x / viewport.length(); gx_px(width_gx * BORDER_FRAC, basis).max(1.0) } /// This frame's bubble-spawn requests, pushed as the chat line routes. #[derive(Resource, Default)] pub(crate) struct BubbleQueue(Vec<(u64, ChatEventKind, String)>); impl BubbleQueue { /// Queue a routed line; kind, CVar and sender filter here, unit, plate and range in the driver. pub(crate) fn push( &mut self, cfg: &BubbleConfig, sender_guid: u64, kind: ChatEventKind, text: &str, ) { if sender_guid == 0 || !bubble_cvar(kind, cfg).unwrap_or(false) { if benilla_assets::trace::enabled_for("bub") { let why = match (sender_guid, bubble_cvar(kind, cfg)) { (0, _) => "senderless".to_string(), (_, None) => format!("{kind:?}-never-bubbles"), (_, Some(false)) => format!("{kind:?}-cvar-off"), _ => unreachable!("the guard above admits nothing else"), }; benilla_assets::trace::line( "bub", &format!("refuse guid={sender_guid:#x} push:{why}"), ); } return; } self.0.push((sender_guid, kind, text.to_string())); } } /// One live bubble: the `CGChatBubbleFrame` and its `CGUnit+0xe64` handle. struct Bubble { /// The [`sanitize`]d display text. text: String, /// The chat-type colour (the 94-entry table), sRGB 0..1. color: [f32; 3], /// `Time::elapsed_secs` at spawn. born: f32, /// The steady window after the fade-in ([`duration_secs`]). duration: f32, /// Stand-box height × model scale, latched at spawn as the reference does (`bubble+0x354`). lift: f32, /// Fade alpha 0..1, ramped toward the eligibility verdict. alpha: f32, } /// The live bubbles by speaker guid, at most one per unit. #[derive(Resource, Default)] struct Bubbles(HashMap); /// Units with a live bubble this frame, whose overhead name is suppressed (`+0xe64` ≠ 0). #[derive(Resource, Default)] pub(crate) struct BubblesActive(pub(crate) EntityHashSet); /// The bubble art, loaded at boot. The edge strip tiles UVs past 1 (repeat); bg and tail clamp. #[derive(Resource)] struct BubbleArt { bg: Handle, edge: Handle, tail: Handle, /// The edge atlas's size in texels, for the half-texel bleed inset. edge_texels: Vec2, } fn load_bubble_art( mut commands: Commands, assets: Option>, mut images: ResMut>, ) { let Some(mut assets) = assets else { return; // no game data (a bare test app); `drive_bubbles` tolerates the missing art }; let bg = assets.sprite_texture(BG_TEXTURE, &mut images); let edge = assets.sprite_texture_tiled(EDGE_TEXTURE, &mut images); let tail = assets.sprite_texture(TAIL_TEXTURE, &mut images); let (Some(bg), Some(edge), Some(tail)) = (bg, edge, tail) else { warn!("chat_bubble: bubble art missing from the patch chain — bubbles will not draw"); return; }; let edge_texels = images.get(&edge).map_or(Vec2::ZERO, |i| { let s = i.texture_descriptor.size; Vec2::new(s.width as f32, s.height as f32) }); commands.insert_resource(BubbleArt { bg, edge, tail, edge_texels, }); } /// Every frame: drain the queue through the spawn gate (`0x608ac0`), ramp each bubble's fade /// against its lifetime and the 20 yd re-test (`0x4b0c30`), publish [`BubblesActive`], and draw. #[allow(clippy::type_complexity)] // one Bevy system's full input set fn drive_bubbles( mut queue: ResMut, mut bubbles: ResMut, mut active: ResMut, vplates: Res, self_guid: Res, units: Query<(Entity, &Guid, &Transform), With>, self_q: Query<&Transform, With>, camera: Query<(&Camera, &Transform), With>, mut atlas: Option>, mut quads: ResMut, art: Option>, heights: Query<&StandBoxHeight>, time: Res