diff --git a/crates/benilla-app/src/chat_bubble.rs b/crates/benilla-app/src/chat_bubble.rs index 1a91b740..71565963 100644 --- a/crates/benilla-app/src/chat_bubble.rs +++ b/crates/benilla-app/src/chat_bubble.rs @@ -1,13 +1,19 @@ -//! Chat bubbles, `CGChatBubbleFrame`: the speech bubble a chat line spawns over the speaker, a -//! 2-D overlay like the V-plate ([`crate::vplates`]). +//! Chat bubbles, `CGChatBubbleFrame`: the speech bubble a chat line spawns over the speaker. This +//! file owns the world side (gate, lifetime, fade, anchor, projection, stacking order, sizes); the +//! widgets, real `WorldFrame` children an addon can skin, live in +//! [`benilla_ui::script::chat_bubble`]. //! //! - 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. +//! - Lifetime and fade (`0x4b0c30`): a 250 ms fade in, the line's [`duration_ms`], a 250 ms fade +//! out, then the frame goes back to the pool. Out of range, or with the speaker not drawn in the +//! last render ([`speaker_drawn`]), it fades out and hides, and fades back in on return. +//! - Anchor (`0x4b0c30`): unit z + height × model scale + 0.7, the text's BOTTOM on the projected +//! point (`0x4b157a`), 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. //! @@ -17,25 +23,20 @@ //! 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. +//! Not built: the reference also ends a bubble whose speaker's model data is not resident +//! (`0x7103d0`); here a speaker with no model yet only counts as not drawn. 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 benilla_ui::script::{BubbleGeometry, BubbleState}; 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 crate::vplates::{gx_px, plate_basis, text_px, VPlateSet, VPlates}; use benilla_world::view::WorldCamera; /// The two bubble CVars (registrar `0x603280`), booting at the reference's defaults. @@ -56,40 +57,22 @@ impl Default for BubbleConfig { } } -/// 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). +/// The range gate, 20 yd squared (`[0x806798]`, the plates' too): a spawn passes at or under it +/// (`0x608ba0`), a live bubble stays in range only under it (`0x4b0cd2`). 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. +/// The linear fade, in and out, in ms (`0x4b0d3e`, `0x4b0d78`). +const FADE_MS: u64 = 250; +/// `NAMEPLATE_FONT` at 0.01 gx (`0x4b0b21`), the plate name's em. const TEXT_H: f32 = 0.01; -/// The body margin around the text layout, all four sides (`0x3c23d70a`). +/// The frame's margin around the text, 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; @@ -100,87 +83,73 @@ fn bubble_cvar(kind: ChatEventKind, cfg: &BubbleConfig) -> Option { } } -/// 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; +/// The lifetime in ms (`0x4b1810`) over the displayed text: the base, plus the per-word step for +/// each space or tab that ends a word, 2750/750 for others and 1500/500 for the local player. An +/// empty text lasts 0; separators alone, the base. +fn duration_ms(text: &str, is_self: bool) -> u64 { + if text.is_empty() { + return 0; } let (base, per) = if is_self { (1500, 500) } else { (2750, 750) }; - (base + per * (words - 1)) as f32 / 1000.0 + let mut ms = base; + // Starts as if after a separator, so a leading run adds nothing. + let mut after_sep = true; + for b in text.bytes() { + let sep = b == b' ' || b == b'\t'; + if sep && !after_sep { + ms += per; + } + after_sep = sep; + } + ms } -/// 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. +/// The bubble's copy of the line (`0x4b1600`, into a 0x800-byte buffer): `||` stays escaped for the +/// text's markup parser; any other `|` jumps to the next `[`, or ends the text if there is none; +/// from there the brackets drop, and a `|h` then a `|r` right after the `]` skip. So a link shows +/// its bare name and a colour code with no link after it cuts the line short. fn sanitize(text: &str) -> String { + const BUF: usize = 0x800 - 1; let b = text.as_bytes(); - let mut out = String::with_capacity(text.len()); + let mut out: Vec = Vec::with_capacity(b.len()); let mut i = 0; - while i < b.len() { + while i < b.len() && out.len() < BUF { 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]); + out.push(b[i]); + i += 1; 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')) { + if b.get(i + 1) == Some(&b'|') { + out.extend_from_slice(b"||"); + i += 2; + continue; + } + let Some(open) = b[i..].iter().position(|&c| c == b'[') else { + break; + }; + i += open; + while i < b.len() { + match b[i] { + b'[' => i += 1, + b']' => { + i += 1; + for skip in [b"|h", b"|r"] { + if b[i..].starts_with(skip) { + i += 2; + } + } + break; + } + c => { + out.push(c); 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), - ) + out.truncate(BUF); + // Cut only at ASCII bytes, so whole characters, but for the buffer cap. + String::from_utf8_lossy(&out).into_owned() } /// The border unit in logical px: 16/1024 of the screen width, in the damped size basis. @@ -221,69 +190,161 @@ impl BubbleQueue { } } +/// `[bubble+0x320]` bit 1: fading in. +const FADING_IN: u8 = 0x1; +/// Bit 2: fading out. +const FADING_OUT: u8 = 0x2; +/// Bit 4: the fade out is the last, at the end of the line's life. +const FINAL: u8 = 0x4; + +/// A bubble's fade (`0x4b0ea0`, `0x4b0ee0`, the middle of `0x4b0c30`): the flags, the fade's start +/// time, the frame's alpha byte and its shown bit. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Fade { + flags: u8, + /// `[bubble+0x33c]`, the current fade's start. + since: u64, + /// The frame's alpha byte (`+0xc8`); a new frame starts opaque. + alpha: u8, + /// The own-shown bit (`+0xd0`). + shown: bool, +} + +impl Fade { + /// A line's start (`0x4b17da`, then `0x4b17f7`): the flags reset, then the fade in. The alpha + /// is a new frame's (`0x7690d2`); a reused frame keeps what its last fade left, which this + /// does not track, and which matters only to a line whose first frame fades out. + fn spawn(now: u64) -> Self { + let mut f = Fade { + flags: 0, + since: now, + alpha: 255, + shown: true, + }; + f.fade_in(now); + f + } + + /// `0x4b0ea0`: unless fading in or on the last fade out, start fading in and show. + fn fade_in(&mut self, now: u64) { + if self.flags & (FADING_IN | FINAL) != 0 { + return; + } + self.flags = (self.flags & !FADING_OUT) | FADING_IN; + self.since = now; + self.shown = true; + } + + /// `0x4b0ee0`: unless on the last fade out, mark it final if asked, and start fading out + /// unless already. + fn fade_out(&mut self, now: u64, last: bool) { + if self.flags & FINAL != 0 { + return; + } + if last { + self.flags |= FINAL; + } + if self.flags & FADING_OUT == 0 { + self.flags = (self.flags & !FADING_IN) | FADING_OUT; + self.since = now; + } + } + + /// One frame of `0x4b0c30` past its residency gates; `false` when the last fade out ends and + /// the bubble goes back to the pool. + fn tick(&mut self, now: u64, in_range: bool, born: u64, duration: u64) -> bool { + if !in_range { + if self.alpha != 0 { + self.fade_out(now, false); + } + } else if self.alpha != 255 { + self.fade_in(now); + } + if self.flags & FADING_OUT == 0 && now.saturating_sub(born) > duration + FADE_MS { + self.fade_out(now, true); + } + // `elapsed · 255 / 250`, truncated (`0x4b0d5b`-`0x4b0d68`). + let ramp = |e: u64| (e * 255 / FADE_MS) as u8; + let e = now.saturating_sub(self.since); + if self.flags & FADING_IN != 0 { + if e > FADE_MS { + self.alpha = 255; + self.flags &= !FADING_IN; + } else { + self.alpha = ramp(e); + } + } else if self.flags & FADING_OUT != 0 { + if e > FADE_MS { + if self.flags & FINAL != 0 { + return false; + } + // An out-of-range fade ends hidden (`0x4b0d95`). + self.flags &= !(FADING_OUT | FINAL); + self.shown = false; + } else { + self.alpha = 255 - ramp(e); + } + } + true + } +} + /// One live bubble: the `CGChatBubbleFrame` and its `CGUnit+0xe64` handle. struct Bubble { + /// This line's number, which tells the widget layer a replacement from a repeat. + line: u64, /// 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, + /// The spawn time in ms (`[bubble+0x338]`). + born: u64, + /// The steady window after the fade-in ([`duration_ms`], `[bubble+0x340]`). + duration: u64, /// 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, + fade: Fade, } -/// The live bubbles by speaker guid, at most one per unit. +/// Whether the speaker was drawn in the last render: the reference's scene-node test `0x6704c0` +/// (`node && [node+0x94] == 0`, read at `0x4b0cab`). Every frame sets `[node+0x94]` on every node +/// in the scene list `[0xca7d70]` (`0x698a07`) and clears it only for a node that passes the +/// frustum test `0x682ef0` and the horizon test `0x686000` (`0x6833dd`-`0x6833ec`, and +/// `0x68359b`-`0x6835aa` in WMO groups). Here it is any of the unit's meshes passing last frame's +/// visibility check; benilla has no horizon cull yet, so that is the frustum verdict alone. +fn speaker_drawn( + unit: Entity, + children: &Query<&Children>, + meshes: &Query<&ViewVisibility, With>, +) -> bool { + children + .get(unit) + .is_ok_and(|kids| kids.iter().any(|c| meshes.get(c).is_ok_and(|v| v.get()))) +} + +/// The live bubbles by speaker guid, at most one per unit, and the next line's number. #[derive(Resource, Default)] -struct Bubbles(HashMap); +struct Bubbles { + live: HashMap, + next_line: u64, +} + +/// This frame's live bubbles for the widget layer, farthest from the camera first, and the sizes +/// the window gives them; `None` with no viewport to lay out against. [`crate::ui_chat::bubbles`] +/// hands them to the VM. +#[derive(Resource, Default)] +pub(crate) struct BubbleFeed { + pub(crate) geometry: Option, + pub(crate) states: Vec, +} /// 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. +/// Every frame: drain the queue through the spawn gate (`0x608ac0`), run each bubble's fade against +/// its lifetime and the 20 yd re-test (`0x4b0c30`), publish [`BubblesActive`], and lay out the +/// live set for the widget layer ([`BubbleFeed`]), farthest from the camera first. #[allow(clippy::type_complexity)] // one Bevy system's full input set fn drive_bubbles( mut queue: ResMut, @@ -294,17 +355,17 @@ fn drive_bubbles( 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