- the world engine splits from the game into a benilla-world crate — 112 files, and the wall is ratcheted to zero names of the game inside the engine - the engine's public doorway is counted and gated at 32 items, and the render materials, shaders and asset cache move down to benilla-assets - the game's files leave the engine's asset root — game:// is its own space — and a spike binary boots the engine with no game attached - weather rides along: snowfall emission is denominated in the reference's frame cap, and a long teleport retires the stranded precipitation field instead of leaving it behind Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
704 lines
30 KiB
Rust
704 lines
30 KiB
Rust
//! **Chat bubbles** — `CGChatBubbleFrame` (wow-re `object-layer/scratch/chat-bubble.md`,
|
||
//! §5-verified 2026-07-11; decision 0288's phase-9 tail, landed by decision 0598): the
|
||
//! over-the-head speech bubble a chat line spawns, a **2-D overlay** like the V-plate
|
||
//! ([`crate::vplates`]) and unlike the world-pass overhead *names* ([`crate::nameplates`]).
|
||
//!
|
||
//! The pinned law, transcribed:
|
||
//! - **Spawn** (`0x608ac0`, one caller — the SMSG_MESSAGECHAT display path): the sender GUID
|
||
//! typed-looks-up to a live unit or no bubble; CVar select `ChatBubbles` (party lines:
|
||
//! `ChatBubblesParty`); non-empty text; **no active V-plate on the unit** (`0x608adc` —
|
||
//! mutual exclusion both ways: a live bubble in turn suppresses the floating overhead name,
|
||
//! [`BubblesActive`]); the local player resolves; 3-D dist² ≤ 400 (20 yd). Self is NOT
|
||
//! excluded. **Replace, never queue**: a new line tears the old bubble down first.
|
||
//! - **Lifetime** (`0x4b1810`): word-count-scaled, self-vs-other asymmetric —
|
||
//! `2750 + 750·(words−1)` ms for others, `1500 + 500·(words−1)` for the local player
|
||
//! (your own bubbles are shorter-lived); words = space/tab-run count. 250 ms linear fade-in;
|
||
//! at `create + duration + 250` a permanent 250 ms fade-out, then the frame recycles. The
|
||
//! 20 yd gate re-tests **every frame**: out of range fades out *recoverable*, back in range
|
||
//! fades back in.
|
||
//! - **Geometry** (`0x4b0940`/`0x4b1600`): the classic `Backdrop` construct —
|
||
//! `ChatBubble-Background` flat fill, `ChatBubble-Backdrop` 8-piece edge
|
||
//! ([`benilla_ui::script::backdrop::pieces`], the same engine geometry FrameXML backdrops
|
||
//! use), `ChatBubble-Tail` a separate square of side = the border-unit, TOPRIGHT on the
|
||
//! frame's BOTTOM + (0, border/4). Border-unit = insets = edge-size = **16/1024 of the
|
||
//! screen width** (`G44·16/(S·1024)`); text = `NAMEPLATE_FONT` at 0.01 gx, wrap hard-capped
|
||
//! at **0.2 gx**, floor 2·border-unit; the frame hugs the text layout ± **0.01 gx** on all
|
||
//! four sides; text colored by chatType (the 94-entry table ≡ [`default_color`]), rendered
|
||
//! PLAIN (`||`→`|`, color/hyperlink escapes stripped — [`sanitize`]).
|
||
//! - **Anchor** (`0x4b0c30`): `worldZ = unit.z + attachmentHeight·modelScale + 0.7`, projected
|
||
//! by the plate/name projector, seated **BOTTOM at the point, growing upward** (the mirror
|
||
//! of the plate, which hangs down).
|
||
//!
|
||
//! Named divergences (all deliberate, decisions 0598/0599):
|
||
//! - **`ChatBubblesParty` defaults ON** (binary default "0") — the director asked for `/p`
|
||
//! bubbles; same posture as [`crate::vplates::VPlateMode`].
|
||
//! - **The v1 kind set is SAY/YELL/PARTY + monster say/yell.** The byte gate is
|
||
//! "sender resolves", not a type whitelist, which *implies* guild/officer/whisper/emote
|
||
//! bubbles too — but that category claim is INFERRED on an OPEN wire-type remap
|
||
//! (chat-bubble.md §1) and contradicts the remembered reference look, so the uncontested
|
||
//! set ships and a capture can widen it ([`bubble_cvar`]).
|
||
//! - **The anchor height is the posed overhead attachment** ([`overhead_anchor`], the
|
||
//! `0x608640` chain) rather than the client's `0x711a20` model-attachment query — the
|
||
//! attachment behind `0x711a20` is not identified in the note (INFERRED equivalence; both
|
||
//! are "the head-region attachment height, model-scaled").
|
||
//! - **Sizes ride the plates' damped diagonal basis** ([`plate_basis`], decisions 0185/0186)
|
||
//! so bubble text and plate text stay the same em at every window — the same director-pinned
|
||
//! deviation from the unbounded byte law.
|
||
//! - **No per-frame occlusion fade**: the client also fades a bubble whose speaker model isn't
|
||
//! render-visible (`0x7103d0`/`0x6704c0`); v1 re-tests distance only. Residual named in 0598.
|
||
//!
|
||
//! (0598 briefly shipped the plate-blocks-bubble gate UN-transcribed — bubbles stacked over our
|
||
//! then-always-on plates. 0599 restored the faithful gate and booted friendly plates OFF
|
||
//! instead, so friendly/party bubbles have room the reference way.)
|
||
|
||
use std::collections::HashMap;
|
||
|
||
use bevy::ecs::entity::EntityHashSet;
|
||
use bevy::prelude::*;
|
||
|
||
use benilla_ui::layout::Rect as GxRect;
|
||
use benilla_ui::script::{pieces, Backdrop, Insets};
|
||
use benilla_ui::script::{JustifyH, JustifyV, Outline};
|
||
|
||
use crate::entities::{overhead_anchor, BoneAttach, OverheadFallback};
|
||
use crate::net::{Guid, NetEntity, SelfGuid, SelfPlayer};
|
||
use crate::ui_chat::{default_color, ChatEventKind};
|
||
use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuads, UvRect};
|
||
use crate::ui_text::{layout_text_quads, measure_text, FontSpec, Justify, UiFontAtlas};
|
||
use crate::vplates::{gx_px, plate_basis, text_px, VPlateSet, VPlates};
|
||
use benilla_assets::{AssetSet, WorldAssets};
|
||
use benilla_world::view::WorldCamera;
|
||
|
||
/// The two CVars (registrar `0x603280`), host side. **Registered knobs since decision 1139** —
|
||
/// they were a pair of `const bool` from 0598 until the options window had a Social page to put
|
||
/// them on, which is exactly the shape 1134 calls a row over a frozen gate. The defaults are the
|
||
/// values that were frozen: `ChatBubbles` the reference's own "1"; `ChatBubblesParty` flipped ON
|
||
/// against the binary's "0" (the director's ask: `/p` bubbles), the same deliberate deviation
|
||
/// [`crate::vplates::VPlateMode`]'s boot default carries.
|
||
#[derive(Resource)]
|
||
pub(crate) struct BubbleConfig {
|
||
/// `ChatBubbles` — say/yell and their monster variants.
|
||
pub(crate) all: bool,
|
||
/// `ChatBubblesParty` — party lines, which the client gates separately.
|
||
pub(crate) party: bool,
|
||
}
|
||
|
||
impl Default for BubbleConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
all: true,
|
||
party: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The bubble art (`0x4b0940` ctor) — the shared tooltip-family textures.
|
||
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 attachment height (`[0x7ffd7c]` = 0.699999988).
|
||
const LIFT: f32 = 0.7;
|
||
/// The spawn + per-frame range gate: dist² ≤ 400 (20 yd, `[0x806798]` — same as the plates).
|
||
const MAX_DIST_SQ: f32 = 400.0;
|
||
/// The fade ramps: 250 ms linear, in and out (`0x4b0ea0`/`0x4b0ee0`).
|
||
const FADE_SECS: f32 = 0.25;
|
||
/// `NAMEPLATE_FONT` at 0.01 gx — the same em law as the plate name.
|
||
const TEXT_H: f32 = 0.01;
|
||
/// The auto-fit body margin: text layout ± 0.01 gx on all four sides (`0x3c23d70a`).
|
||
const MARGIN: f32 = 0.01;
|
||
/// The text wrap hard cap, gx (`[0x80679c]` = 0.2).
|
||
const WRAP_W: f32 = 0.2;
|
||
/// Border-unit = edge-size = insets = tail side: 16/1024 of the screen WIDTH
|
||
/// (`G44·16.0/(S·1024.0)` — G44/S nets the width in the diagonal gx basis).
|
||
const BORDER_FRAC: f32 = 16.0 / 1024.0;
|
||
|
||
/// Paint order inside the overlays lane: the whole bubble sits UNDER the V-plates' 4..8 band
|
||
/// (same-unit overlap can't happen — the mutual exclusion — so this only orders cross-unit
|
||
/// stacking). The tail draws over the frame's bottom edge piece: its art carries the border
|
||
/// lines that make the seam read continuous.
|
||
const Z_BG: u64 = 0;
|
||
const Z_EDGE: u64 = 1;
|
||
const Z_TAIL: u64 = 2;
|
||
const Z_TEXT: u64 = 3;
|
||
|
||
/// Which CVar gates this chat kind's bubble — `None` = the kind never bubbles in v1.
|
||
/// PARTY selects `ChatBubblesParty`, every other bubbling kind `ChatBubbles` (`0x608b0d`).
|
||
/// Guild/officer/whisper/emote are structurally implied by the byte gate but INFERRED on an
|
||
/// OPEN remap (module doc) — out until a capture confirms.
|
||
fn bubble_cvar(kind: ChatEventKind, cfg: &BubbleConfig) -> Option<bool> {
|
||
use ChatEventKind as K;
|
||
match kind {
|
||
K::Party => Some(cfg.party),
|
||
K::Say | K::Yell | K::MonsterSay | K::MonsterYell => Some(cfg.all),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The space/tab-run word counter (`0x4b1810`): maximal runs of non-space/tab characters.
|
||
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 duration law (`0x4b1810`): `base + perWord·(words−1)` ms — 2750/750 for someone else,
|
||
/// 1500/500 for the local player (your own bubbles are shorter-lived). Empty → 0.
|
||
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
|
||
}
|
||
|
||
/// The bubble-text sanitize: `||` stays an escaped literal pipe, `|cAARRGGBB`/`|r` color
|
||
/// escapes and the `|H…|h`/`|h` hyperlink wrappers strip (their display text stays) — bubble
|
||
/// text is always plain, colored solely by chatType. Output remains in ESCAPED form (literal
|
||
/// pipes as `||`) because the glyph layout's own markup parser consumes it downstream.
|
||
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 display text then flows until the
|
||
// closing `|h`, which the arm below drops.
|
||
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 + the rest.
|
||
_ => {
|
||
out.push_str("||");
|
||
i += 1;
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The border-unit in logical px for this viewport: 16/1024 of the screen width, carried
|
||
/// through the damped size basis (so it shrinks in step with plate/text sizes past the knee).
|
||
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)
|
||
}
|
||
|
||
/// The bubble-spawn requests this frame — fed by the chat feed's wire arm (the reference
|
||
/// spawns in the SMSG display path, the same moment the line routes) and drained by
|
||
/// [`drive_bubbles`]. Push-side filtered so guild/system spam never queues strings.
|
||
#[derive(Resource, Default)]
|
||
pub(crate) struct BubbleQueue(Vec<(u64, ChatEventKind, String)>);
|
||
|
||
impl BubbleQueue {
|
||
/// Queue a routed wire line for a bubble. Drops non-bubbling kinds, disabled CVars, and
|
||
/// senderless lines here; the live-unit/range/plate gates run in the driver. The switch is
|
||
/// read at PUSH time, so a Social-page click takes the very next line either way.
|
||
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) {
|
||
return;
|
||
}
|
||
self.0.push((sender_guid, kind, text.to_string()));
|
||
}
|
||
}
|
||
|
||
/// One live bubble (the `CGChatBubbleFrame` + its `CGUnit+0xe64` handle, folded together).
|
||
struct Bubble {
|
||
/// Sanitized display text ([`sanitize`] — plain, escapes stripped).
|
||
text: String,
|
||
/// The chatType color (the 94-entry table), client-space sRGB 0..1.
|
||
color: [f32; 3],
|
||
/// `Time::elapsed_secs` at spawn.
|
||
born: f32,
|
||
/// The steady window past the fade-in ([`duration_secs`]).
|
||
duration: f32,
|
||
/// Current fade alpha 0..1 — ramped 250 ms linear toward the eligibility verdict.
|
||
alpha: f32,
|
||
}
|
||
|
||
/// The live bubbles, keyed by speaker guid (a unit carries at most one — replace, never queue).
|
||
#[derive(Resource, Default)]
|
||
struct Bubbles(HashMap<u64, Bubble>);
|
||
|
||
/// The units carrying a live bubble this frame — the `ShouldShowName` exclusivity verdict the
|
||
/// overhead-name driver reads (`+0xe64` ≠ 0 suppresses the floating name), the mirror of
|
||
/// [`VPlates`].
|
||
#[derive(Resource, Default)]
|
||
pub(crate) struct BubblesActive(pub(crate) EntityHashSet);
|
||
|
||
/// The bubble art, warmed at boot like the plate art (a first bubble must not draw texture-less).
|
||
/// The edge strip needs REPEAT addressing (its runs tile UVs past 1); bg and tail clamp.
|
||
#[derive(Resource)]
|
||
struct BubbleArt {
|
||
bg: Handle<Image>,
|
||
edge: Handle<Image>,
|
||
tail: Handle<Image>,
|
||
}
|
||
|
||
fn load_bubble_art(
|
||
mut commands: Commands,
|
||
assets: Option<ResMut<WorldAssets>>,
|
||
mut images: ResMut<Assets<Image>>,
|
||
) {
|
||
let Some(mut assets) = assets else {
|
||
return; // no game data (bare test app) — drive_bubbles tolerates the missing resource
|
||
};
|
||
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;
|
||
};
|
||
commands.insert_resource(BubbleArt { bg, edge, tail });
|
||
}
|
||
|
||
/// Spawn, tick, and draw, every frame: drain the queue through the spawn gate (`0x608ac0`),
|
||
/// ramp each live bubble's fade against its lifetime + the per-frame 20 yd re-test
|
||
/// (`0x4b0c30`), publish the name-suppression verdict, and append the draw list — backdrop
|
||
/// pieces, tail, wrapped chatType-colored text — bottom-seated on the projected anchor.
|
||
/// Runs after [`VPlateSet`] (the spawn gate reads this frame's plate verdict), inside the
|
||
/// [`UiQuadAppend`] window.
|
||
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // one Bevy system's full input set
|
||
fn drive_bubbles(
|
||
mut queue: ResMut<BubbleQueue>,
|
||
mut bubbles: ResMut<Bubbles>,
|
||
mut active: ResMut<BubblesActive>,
|
||
vplates: Res<VPlates>,
|
||
self_guid: Res<SelfGuid>,
|
||
units: Query<(Entity, &Guid, &Transform), With<NetEntity>>,
|
||
self_q: Query<&Transform, With<SelfPlayer>>,
|
||
camera: Query<(&Camera, &Transform), With<WorldCamera>>,
|
||
mut atlas: Option<ResMut<UiFontAtlas>>,
|
||
mut quads: ResMut<UiQuads>,
|
||
art: Option<Res<BubbleArt>>,
|
||
// The overhead-anchor inputs ([`overhead_anchor`]).
|
||
anchor_q: (
|
||
Query<&BoneAttach>,
|
||
Query<&OverheadFallback>,
|
||
Query<&GlobalTransform>,
|
||
Query<(), With<crate::entities::mount::MountChild>>,
|
||
),
|
||
time: Res<Time>,
|
||
) {
|
||
active.0.clear();
|
||
let now = time.elapsed_secs();
|
||
let step = time.delta_secs() / FADE_SECS;
|
||
let self_tf = self_q.single().ok();
|
||
// The typed sender lookup (`0x468460`): guid → live unit. Linear over the streamed set,
|
||
// like the plate walk — both populations are 20 yd-bounded and small.
|
||
let find = |guid: u64| units.iter().find(|(_, g, _)| g.0 == guid);
|
||
|
||
// ── The spawn/replace gate (`0x608ac0`) ─────────────────────────────────────────────────
|
||
for (guid, kind, raw) in queue.0.drain(..) {
|
||
let text = sanitize(&raw);
|
||
let words = word_count(&text);
|
||
if words == 0 {
|
||
continue; // empty text → duration 0 → no bubble
|
||
}
|
||
let Some((entity, _, tf)) = find(guid) else {
|
||
continue; // sender not resolvable to a live unit → NO bubble
|
||
};
|
||
// An active V-plate blocks bubble creation (`0x608adc` — the mutual exclusion,
|
||
// faithful; restored by 0599 after 0598 briefly stacked them). Friendly plates boot
|
||
// OFF (0599's other half), so friendly/party bubbles have room; a plated hostile
|
||
// yelling shows no bubble, exactly like the reference with plates toggled on.
|
||
if vplates.0.contains(&entity) {
|
||
continue;
|
||
}
|
||
let Some(self_tf) = self_tf else {
|
||
continue; // the local player must resolve
|
||
};
|
||
if (tf.translation - self_tf.translation).length_squared() > MAX_DIST_SQ {
|
||
continue; // 20 yd at spawn
|
||
}
|
||
let is_self = self_guid.0 == Some(guid);
|
||
let c = default_color(kind);
|
||
// Replace, never queue: the insert tears the old bubble down (`0x608c00`) and the
|
||
// fresh one fades in from 0.
|
||
bubbles.0.insert(
|
||
guid,
|
||
Bubble {
|
||
text,
|
||
color: [
|
||
f32::from(c[0]) / 255.0,
|
||
f32::from(c[1]) / 255.0,
|
||
f32::from(c[2]) / 255.0,
|
||
],
|
||
born: now,
|
||
duration: duration_secs(words, is_self),
|
||
alpha: 0.0,
|
||
},
|
||
);
|
||
}
|
||
if bubbles.0.is_empty() {
|
||
return;
|
||
}
|
||
|
||
// ── Tick + draw ─────────────────────────────────────────────────────────────────────────
|
||
let cam = camera.single().ok();
|
||
let art = art.as_deref();
|
||
let trace = std::env::var("WOW_BUBBLE_TRACE").as_deref() == Ok("1");
|
||
let mut dead = Vec::new();
|
||
for (guid, b) in bubbles.0.iter_mut() {
|
||
let Some((entity, _, tf)) = find(*guid) else {
|
||
dead.push(*guid); // the speaker despawned — the unit teardown takes its bubble
|
||
continue;
|
||
};
|
||
if now >= b.born + b.duration + FADE_SECS {
|
||
// The permanent fade-out (`0x4b0ee0(1)`) — at 0 the frame recycles.
|
||
b.alpha -= step;
|
||
if b.alpha <= 0.0 {
|
||
dead.push(*guid);
|
||
continue;
|
||
}
|
||
} else {
|
||
// Per-frame re-eligibility: the same 20 yd gate, recoverable — out of range fades
|
||
// out, back in range fades back in. (The client also fades on the speaker model's
|
||
// render visibility — the v1 residual, module doc.)
|
||
let eligible = self_tf
|
||
.is_some_and(|s| (tf.translation - s.translation).length_squared() <= MAX_DIST_SQ);
|
||
b.alpha = if eligible {
|
||
(b.alpha + step).min(1.0)
|
||
} else {
|
||
(b.alpha - step).max(0.0)
|
||
};
|
||
}
|
||
// The live handle (`+0xe64` ≠ 0): the name suppression holds while the bubble EXISTS,
|
||
// faded or not.
|
||
active.0.insert(entity);
|
||
if b.alpha <= 0.0 {
|
||
continue;
|
||
}
|
||
let (Some((cam, cam_pose)), Some(atlas), Some(art)) = (cam, atlas.as_deref_mut(), art)
|
||
else {
|
||
continue; // no camera/atlas/art — lifetimes still tick, nothing draws
|
||
};
|
||
// World anchor: unit position, Z lifted by the posed attachment height + 0.7 yd —
|
||
// seated BOTTOM at the projected point, growing upward. A point behind the camera
|
||
// draws nothing (state kept — it fades back the moment it projects again).
|
||
let anchor = overhead_anchor(
|
||
entity,
|
||
tf,
|
||
&anchor_q.0,
|
||
&anchor_q.1,
|
||
&anchor_q.2,
|
||
&anchor_q.3,
|
||
);
|
||
let seat_world = Vec3::new(tf.translation.x, anchor.y + LIFT, tf.translation.z);
|
||
let cam_tf = GlobalTransform::from(*cam_pose);
|
||
let Ok(seat) = cam.world_to_viewport(&cam_tf, seat_world) else {
|
||
continue;
|
||
};
|
||
let Some(viewport) = cam.logical_viewport_size() else {
|
||
continue;
|
||
};
|
||
draw_bubble(atlas, &mut quads, art, b, seat, viewport, trace);
|
||
}
|
||
for g in dead {
|
||
bubbles.0.remove(&g);
|
||
}
|
||
}
|
||
|
||
/// Append one bubble's draw list: the Backdrop pieces (bg fill inset by the border-unit +
|
||
/// the 8-piece edge), the tail square, and the wrapped, centered, chatType-colored text.
|
||
fn draw_bubble(
|
||
atlas: &mut UiFontAtlas,
|
||
quads: &mut UiQuads,
|
||
art: &BubbleArt,
|
||
b: &Bubble,
|
||
seat: Vec2,
|
||
viewport: Vec2,
|
||
trace: bool,
|
||
) {
|
||
let basis = plate_basis(viewport);
|
||
let border = border_px(viewport, basis);
|
||
let margin = gx_px(MARGIN, basis);
|
||
|
||
// Shape at the nearest baked atlas size, then rescale to the exact window-derived em —
|
||
// the plate-text recipe (`crate::vplates::plate_text`).
|
||
let px = text_px(TEXT_H, basis);
|
||
let shaped = atlas.snap_size(px);
|
||
if shaped <= 0.0 {
|
||
return;
|
||
}
|
||
let s = px / shaped;
|
||
let spec = FontSpec {
|
||
path: None, // NAMEPLATE_FONT — Friz Quadrata, the atlas default
|
||
height: Some(shaped),
|
||
outline: Outline::None,
|
||
paint_halo: true,
|
||
alpha_gradient: None,
|
||
};
|
||
// The wrap law (`0x4b1600` tail): single-line width > 0.2 gx → hard-cap (forces wrap);
|
||
// else max(measured, 2·border-unit). All in shaped space, rescaled after.
|
||
let cap_s = gx_px(WRAP_W, basis) / s;
|
||
let floor_s = (2.0 * border) / s;
|
||
let (line_w, _) = measure_text(atlas, &b.text, None, spec);
|
||
let box_w_s = if line_w > cap_s {
|
||
cap_s
|
||
} else {
|
||
line_w.max(floor_s)
|
||
};
|
||
let (_, box_h_s) = measure_text(atlas, &b.text, Some(box_w_s), spec);
|
||
let (text_w, text_h) = ((box_w_s * s).ceil(), (box_h_s * s).ceil());
|
||
|
||
// The auto-fit body: the frame hugs the text layout ± the flat margin; BOTTOM-center on
|
||
// the seat, growing upward. Snapped to whole logical px (the plate divergence — a
|
||
// fractional corner bilinear-smears the border art).
|
||
let w = text_w + 2.0 * margin;
|
||
let h = text_h + 2.0 * margin;
|
||
let left = (seat.x - w * 0.5).round();
|
||
let bottom = seat.y.round();
|
||
let frame = Rect::new(left, bottom - h, left + w, bottom);
|
||
let alpha = b.alpha;
|
||
|
||
// The Backdrop construct (`0x4b0a35 → 0x76a5d0`): one value feeds the 4 insets and both
|
||
// edge-size fields. The engine's `pieces` speaks y-up — negate y across the seam.
|
||
let bd = Backdrop {
|
||
bg_file: Some(BG_TEXTURE.to_string()),
|
||
edge_file: Some(EDGE_TEXTURE.to_string()),
|
||
tile: false,
|
||
tile_size: 0.0,
|
||
edge_size: border,
|
||
insets: Insets {
|
||
left: border,
|
||
right: border,
|
||
top: border,
|
||
bottom: border,
|
||
},
|
||
bg_color: [1.0; 4],
|
||
border_color: [1.0; 4],
|
||
};
|
||
let up = GxRect::new(-frame.max.y, frame.min.x, -frame.min.y, frame.max.x);
|
||
for p in pieces(up, &bd) {
|
||
// Every bubble piece is axis-aligned (equal insets make the BR-inset quirk vacuous):
|
||
// corners [TL,TR,BR,BL] y-up → a y-down rect from TL/BR, UVs riding their corners.
|
||
let rect = Rect::new(
|
||
p.corners[0][0],
|
||
-p.corners[0][1],
|
||
p.corners[2][0],
|
||
-p.corners[2][1],
|
||
);
|
||
quads.overlays.push(UiQuad {
|
||
rect,
|
||
z_key: if p.is_bg { Z_BG } else { Z_EDGE },
|
||
texture: Some(if p.is_bg {
|
||
art.bg.clone()
|
||
} else {
|
||
art.edge.clone()
|
||
}),
|
||
uv: UvRect::from_corners(p.uvs),
|
||
color: [1.0, 1.0, 1.0, alpha],
|
||
..default()
|
||
});
|
||
}
|
||
// The tail (`0x4b0af1`): a border-unit square, TOPRIGHT on the frame's BOTTOM lifted
|
||
// border/4 INTO the body — the overlap that makes the seam read continuous.
|
||
let tail_top = frame.max.y - border * 0.25;
|
||
let cx = (frame.min.x + frame.max.x) * 0.5;
|
||
quads.overlays.push(UiQuad {
|
||
rect: Rect::new(cx - border, tail_top, cx, tail_top + border),
|
||
z_key: Z_TAIL,
|
||
texture: Some(art.tail.clone()),
|
||
uv: UvRect::FULL,
|
||
color: [1.0, 1.0, 1.0, alpha],
|
||
..default()
|
||
});
|
||
// The text: centered in the margin box, wrapped at the shaped-space cap, rescaled around
|
||
// the box center to the exact em (uniform, so wrap points survive the rescale).
|
||
let center = Vec2::new(cx, (frame.min.y + frame.max.y) * 0.5);
|
||
let mut text_quads = layout_text_quads(
|
||
atlas,
|
||
&b.text,
|
||
Rect::from_center_size(center, Vec2::new(box_w_s, box_h_s)),
|
||
[b.color[0], b.color[1], b.color[2], alpha],
|
||
Justify {
|
||
h: JustifyH::Center,
|
||
v: JustifyV::Middle,
|
||
},
|
||
Z_TEXT,
|
||
spec,
|
||
);
|
||
for q in text_quads.iter_mut() {
|
||
q.rect = Rect::new(
|
||
center.x + (q.rect.min.x - center.x) * s,
|
||
center.y + (q.rect.min.y - center.y) * s,
|
||
center.x + (q.rect.max.x - center.x) * s,
|
||
center.y + (q.rect.max.y - center.y) * s,
|
||
);
|
||
}
|
||
if trace {
|
||
eprintln!(
|
||
"bubble-trace: viewport={viewport:?} frame=({:.1},{:.1})..({:.1},{:.1}) border={border:.1} alpha={alpha:.2} text={:?}",
|
||
frame.min.x, frame.min.y, frame.max.x, frame.max.y, b.text
|
||
);
|
||
}
|
||
quads.overlays.append(&mut text_quads);
|
||
}
|
||
|
||
/// The bubble stage — [`crate::nameplates`] orders after it (the bubble/name exclusion reads
|
||
/// [`BubblesActive`]), exactly as it orders after [`VPlateSet`] for the plate verdict.
|
||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||
pub(crate) struct BubbleSet;
|
||
|
||
pub(crate) struct ChatBubblePlugin;
|
||
|
||
impl Plugin for ChatBubblePlugin {
|
||
fn build(&self, app: &mut App) {
|
||
app.init_resource::<BubbleQueue>()
|
||
.init_resource::<BubbleConfig>()
|
||
.init_resource::<Bubbles>()
|
||
.init_resource::<BubblesActive>()
|
||
.add_systems(Startup, load_bubble_art.after(AssetSet::Open))
|
||
// After the V-plate drive (the spawn gate reads this frame's plate verdict),
|
||
// inside the UI-quad append window; the name driver orders after [`BubbleSet`]
|
||
// (the bubble/name exclusion reads this frame's verdict).
|
||
.add_systems(
|
||
Update,
|
||
drive_bubbles
|
||
.after(VPlateSet)
|
||
.in_set(UiQuadAppend)
|
||
.in_set(BubbleSet),
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// The word counter is a space/tab-run law — not Unicode whitespace, not collapse-free.
|
||
#[test]
|
||
fn word_count_is_the_space_tab_run_law() {
|
||
assert_eq!(word_count(""), 0);
|
||
assert_eq!(word_count(" \t "), 0);
|
||
assert_eq!(word_count("hi"), 1);
|
||
assert_eq!(word_count(" hi there\tfriend "), 3);
|
||
assert_eq!(word_count("a\u{a0}b"), 1, "NBSP is not a separator");
|
||
}
|
||
|
||
/// The duration bytes: others 2750 + 750·(n−1), self 1500 + 500·(n−1), empty 0.
|
||
#[test]
|
||
fn duration_matches_the_byte_law() {
|
||
assert_eq!(duration_secs(0, false), 0.0);
|
||
assert_eq!(duration_secs(1, false), 2.75);
|
||
assert_eq!(duration_secs(4, false), 5.0);
|
||
assert_eq!(duration_secs(1, true), 1.5);
|
||
assert_eq!(duration_secs(3, true), 2.5);
|
||
}
|
||
|
||
/// Bubble text is plain: color/hyperlink escapes strip (display text stays), `||` stays
|
||
/// escaped for the downstream markup parser, a dangling pipe renders literally.
|
||
#[test]
|
||
fn sanitize_strips_escapes_keeps_display_text() {
|
||
assert_eq!(sanitize("hello"), "hello");
|
||
assert_eq!(sanitize("a||b"), "a||b");
|
||
assert_eq!(sanitize("|cffff0000red|r plain"), "red plain");
|
||
assert_eq!(
|
||
sanitize("look |Hitem:19019|h[Thunderfury]|h!"),
|
||
"look [Thunderfury]!"
|
||
);
|
||
assert_eq!(sanitize("dangling |"), "dangling ||");
|
||
assert_eq!(sanitize("|x odd"), "||x odd");
|
||
}
|
||
|
||
/// The v1 kind set: say/yell + monster say/yell on `ChatBubbles`, party on
|
||
/// `ChatBubblesParty` (benilla default ON — the 0598 deviation); everything else out
|
||
/// pending the OPEN remap capture. And the two switches are genuinely separate: the client
|
||
/// gates party lines on their own CVar, so turning one off leaves the other bubbling.
|
||
#[test]
|
||
fn the_kind_set_is_the_uncontested_v1() {
|
||
use ChatEventKind as K;
|
||
let on = BubbleConfig::default();
|
||
for k in [K::Say, K::Yell, K::MonsterSay, K::MonsterYell] {
|
||
assert_eq!(bubble_cvar(k, &on), Some(true));
|
||
}
|
||
assert_eq!(
|
||
bubble_cvar(K::Party, &on),
|
||
Some(true),
|
||
"the director's /p ask"
|
||
);
|
||
for k in [
|
||
K::Guild,
|
||
K::Officer,
|
||
K::Whisper,
|
||
K::Emote,
|
||
K::TextEmote,
|
||
K::System,
|
||
K::Channel,
|
||
K::MonsterEmote,
|
||
] {
|
||
assert_eq!(bubble_cvar(k, &on), None, "{k:?} must not bubble in v1");
|
||
}
|
||
|
||
let no_party = BubbleConfig {
|
||
all: true,
|
||
party: false,
|
||
};
|
||
assert_eq!(bubble_cvar(K::Party, &no_party), Some(false));
|
||
assert_eq!(
|
||
bubble_cvar(K::Say, &no_party),
|
||
Some(true),
|
||
"say is untouched"
|
||
);
|
||
let no_say = BubbleConfig {
|
||
all: false,
|
||
party: true,
|
||
};
|
||
assert_eq!(bubble_cvar(K::Say, &no_say), Some(false));
|
||
assert_eq!(
|
||
bubble_cvar(K::Party, &no_say),
|
||
Some(true),
|
||
"party has its own switch"
|
||
);
|
||
}
|
||
|
||
/// The border-unit lands the byte constants: 16 px at 1024-wide 4:3 (G44·16/(S·1024)
|
||
/// nets width/64), damped past the plate knee like every other size.
|
||
#[test]
|
||
fn border_unit_is_a_64th_of_the_width() {
|
||
let vp = Vec2::new(1024.0, 768.0);
|
||
assert_eq!(border_px(vp, plate_basis(vp)), 16.0);
|
||
let wide = Vec2::new(2560.0, 1440.0);
|
||
let b = border_px(wide, plate_basis(wide));
|
||
assert!(
|
||
b > 16.0 && b < 40.0,
|
||
"damped growth between the native pin and the faithful 40 px, got {b}"
|
||
);
|
||
}
|
||
}
|