Files
benilla-octo/crates/benilla-app/src/char_create/screen.rs
T
SamandClaude Fable 5 bdef9cd790 benilla-world: the engine becomes its own crate
- 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>
2026-08-11 18:13:44 +07:00

646 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The create screen's **layout** — the reference `CharacterCreate.xml` arrangement rebuilt in
//! Bevy UI (decision 0423's polish passes), full-bleed and scaled to the window: the glue engine
//! renders a 1024×768 virtual screen scaled to the display, so every authored offset/size below is
//! the ref's number times `height / 768`. The widget shapes it places live in [`super::widgets`],
//! the component vocabulary in [`super::parts`], the art in [`super::art`], and the systems that
//! drive it all in [`super::refresh`].
//!
//! Left: the configuration tower — `UI-CharacterCreate-Background` under three stacked
//! `OuterBorder` pieces, the faction banners behind the 2×4 race grid, the gender pair, the
//! valid-classes-only grid, the five `LabelFrame` dial spinners, Randomize. Right: the three
//! `TextPanel-Border` info panels (faction/race/class), bg-tinted per faction like the ref's
//! `SetBackdropColor`, quoting the GlueStrings paragraphs. Center: the transparent-booth model,
//! full-height, drag- or button-rotatable (the big `UI-RotationRight` pair, bottom-left). Bottom:
//! NAME over the `Glue-Tooltip-Border` edit box; Accept over Back in the corner.
use bevy::prelude::*;
use bevy::ui_render::ui_material::MaterialNode;
use bevy::window::PrimaryWindow;
use crate::char_select::{race_name, wow_font};
use crate::entities::CharCreate;
use crate::glue_strings::GlueStrings;
use crate::portrait::{GlueLook, GluePreview, PortraitImages, PortraitSource, GLUE_SLOT};
use benilla_assets::WorldAssets;
use super::parts::{CharCreateUi, DialRow, DynIcon, DynText, DynTint, StatusLine};
use super::{CreateAction, CreateSelection, ALLIANCE, HORDE, INITIAL_FACING};
use crate::glue::art::{
tc_rect, GlueArt, ALLIANCE_BORDER, ALLIANCE_FILL, BACKDROP, BTN_BG, DIM, GOLD, INFO_TEXT,
};
use crate::glue::widgets::{
abs, dial_arrow, glue_button, icon_button, outlined_text, ArtSwap, FallbackFace, GlueBtnKind,
GlueText, Hilight,
};
const SCREEN_Z: i32 = 1100;
// ── Spawn ────────────────────────────────────────────────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
pub(super) fn enter_create(
mut commands: Commands,
assets: Res<AssetServer>,
catalog: Option<Res<CharCreate>>,
portraits: Res<PortraitImages>,
mut sel: ResMut<CreateSelection>,
mut preview: ResMut<GluePreview>,
mut art: ResMut<GlueArt>,
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
mut add_mats: ResMut<Assets<crate::glue::add_material::AddUiMaterial>>,
strings: Option<Res<GlueStrings>>,
window: Query<&Window, With<PrimaryWindow>>,
) {
sel.reset(catalog.as_deref());
preview.scene = Some(crate::portrait::GlueScene::Race(sel.race));
preview.look = Some(GlueLook::Create(sel.look()));
preview.yaw = INITIAL_FACING;
if let Some(mut wa) = world_assets {
art.ensure_loaded(&mut wa, &mut images, &mut add_mats);
}
spawn_screen(
&mut commands,
&assets,
&portraits,
&art,
strings.as_deref(),
&window,
);
}
/// Rebuild the tree when a window resize (mac fullscreen, a drag) has changed the glue scale it
/// was baked at — the create screen has no per-frame materialize (it spawns on entry, after the
/// boot-order traps the other screens dodge), so the rescale watch lives here. Selection and the
/// typed name live in resources and repaint via [`super::refresh`], so the rebuild loses nothing.
pub(super) fn rescale_screen(
mut commands: Commands,
existing: Query<(Entity, &CharCreateUi)>,
assets: Res<AssetServer>,
portraits: Res<PortraitImages>,
art: Res<GlueArt>,
strings: Option<Res<GlueStrings>>,
window: Query<&Window, With<PrimaryWindow>>,
) {
let s = crate::glue::screen_scale(window.single().ok());
for (root, ui) in &existing {
if ui.s != s {
commands.entity(root).despawn();
spawn_screen(
&mut commands,
&assets,
&portraits,
&art,
strings.as_deref(),
&window,
);
}
}
}
fn spawn_screen(
commands: &mut Commands,
assets: &AssetServer,
portraits: &PortraitImages,
art: &GlueArt,
strings: Option<&GlueStrings>,
window: &Query<&Window, With<PrimaryWindow>>,
) {
let font = wow_font(assets);
// The edit box types in `GlueEditBoxFont` — ARIALN, not FRIZQT (GlueFonts.xml).
let edit_font: Handle<Font> = assets.load("mpq://Fonts/ARIALN.ttf");
let model_image = match portraits.0.get(GLUE_SLOT) {
Some(PortraitSource::Live(h)) => Some(h.clone()),
_ => None,
};
// The glue engine scales a 1024×768 virtual screen to the window; scale the authored sizes the
// same way so the ref proportions hold at any size.
let s = crate::glue::screen_scale(window.single().ok());
let px = |v: f32| Val::Px(v * s);
let empty = GlueStrings::default();
let strings = strings.unwrap_or(&empty);
let mut root = commands.spawn((
CharCreateUi { s },
DynTint::Backdrop,
GlobalZIndex(SCREEN_Z),
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
BackgroundColor(BACKDROP),
));
root.with_children(|ui| {
// The 3D scene, full-bleed and first (everything else draws over it) — the ref's screen IS
// a fullscreen ModelFFX: the per-race background with the character standing in it (the
// booth renders both into this window-sized target). The whole pane drags to rotate, the
// ref's full-frame mouse rotation; the page tint behind it is the no-art fallback.
let mut pane = ui.spawn((
CreateAction::Model,
Button,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
));
if let Some(image) = model_image {
pane.insert(ImageNode::new(image));
}
left_tower(ui, art, &font, s, strings);
// The WoW logo (`CharacterCreateWoWLogo`, 256×128 at (3,−7)) — after the tower, like the
// ref's frame order (child frames draw over the parent's border art).
if let Some(logo) = &art.logo {
ui.spawn((ImageNode::new(logo.clone()), abs(s, 3.0, 7.0, 256.0, 128.0)));
}
super::panels::right_stack(ui, art, &font, s);
name_cluster(ui, art, &font, &edit_font, s, strings);
rotate_cluster(ui, art, &font, s);
// Accept over Back, bottom-right (`CharCreateOkayButton` 160×35 over `BackButton` 120×30
// at BOTTOMRIGHT (−50, 20)).
ui.spawn((Node {
position_type: PositionType::Absolute,
right: px(50.0),
bottom: px(20.0),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
row_gap: px(5.0),
..default()
},))
.with_children(|actions| {
glue_button(
actions,
art,
&font,
CreateAction::Create,
strings.text("CHARACTER_CREATE_ACCEPT", "Accept"),
160.0,
35.0,
GlueBtnKind::Normal,
s,
);
glue_button(
actions,
art,
&font,
CreateAction::Back,
strings.text("BACK", "Back"),
120.0,
30.0,
GlueBtnKind::Small,
s,
);
});
});
}
/// The configuration tower (`CharacterCreateConfigurationFrame`, 206×600 at TOPLEFT (28,−74)):
/// frame art, banners, faction headers, the race/gender/class grids, the dial rows, Randomize —
/// every child at its authored offset.
fn left_tower(
ui: &mut ChildSpawnerCommands,
art: &GlueArt,
font: &Handle<Font>,
s: f32,
strings: &GlueStrings,
) {
let px = |v: f32| Val::Px(v * s);
ui.spawn((Node {
position_type: PositionType::Absolute,
left: px(28.0),
top: px(74.0),
width: px(206.0),
height: px(600.0),
..default()
},))
.with_children(|tower| {
// The frame: `UI-CharacterCreate-Background` stretched behind (TOPLEFT of border1 +6 →
// BOTTOMLEFT of border3 +6), three `OuterBorder` pieces stacked over it (224 wide,
// centered on the 206 frame → x −9; heights 236/240/210 with the authored texcoords).
if let Some(bg) = &art.tower_bg {
tower.spawn((ImageNode::new(bg.clone()), abs(s, -3.0, 0.0, 218.0, 680.0)));
}
if let Some((border, size)) = &art.tower_border {
for (top, height, tc) in [
(0.0, 236.0, [0.0, 0.875, 0.0, 0.9375]),
(236.0, 240.0, [0.0, 0.875, 0.0, 0.9375]),
(476.0, 210.0, [0.0, 0.875, 0.1796875, 1.0]),
] {
tower.spawn((
ImageNode {
image: border.clone(),
rect: Some(tc_rect(*size, tc)),
..default()
},
abs(s, -9.0, top, 224.0, height),
));
}
}
// The banners (`CharacterCreateBanners`, 256×259 at TOP (−2,−60)) behind the race grid.
if let Some(banners) = &art.banners {
tower.spawn((
ImageNode::new(banners.clone()),
abs(s, -27.0, 60.0, 256.0, 259.0),
));
}
// Alliance | Horde over the banner tops (bottom-anchored ±50 of the banner center).
// The XML's `text="ALLIANCE"` is a localization key — GlueStrings renders it
// mixed-case ("Alliance"), never the raw key.
for (key, fallback, center) in
[("ALLIANCE", "Alliance", 51.0), ("HORDE", "Horde", 151.0)]
{
outlined_text(
tower,
Node {
justify_content: JustifyContent::Center,
..abs(s, center - 60.0, 40.0, 120.0, 17.0)
},
(),
(),
GlueText {
text: strings.text(key, fallback),
size: 15.0, // GlueFontNormal
color: GOLD,
wrap: false,
},
font,
s,
);
}
// The race grid: two columns of 48² check-buttons (col A at (33,68), col B at (127,68),
// row pitch 48+5).
for (faction, left) in [(ALLIANCE, 33.0), (HORDE, 127.0)] {
tower
.spawn((Node {
position_type: PositionType::Absolute,
left: px(left),
top: px(68.0),
flex_direction: FlexDirection::Column,
row_gap: px(5.0),
..default()
},))
.with_children(|col| {
for race in faction {
icon_button(
col,
font,
CreateAction::Race(race),
Some(DynIcon::Race(race)),
None,
None::<DynText>,
race_name(race),
art,
s,
);
}
});
}
// The gender pair (below race col A: race4's BOTTOMLEFT + (20,−28)).
tower
.spawn((Node {
position_type: PositionType::Absolute,
left: px(53.0),
top: px(303.0),
flex_direction: FlexDirection::Row,
column_gap: px(5.0),
..default()
},))
.with_children(|g| {
for (sex, key, fallback) in [(0u8, "MALE", "Male"), (1u8, "FEMALE", "Female")] {
let icon = art.gender.as_ref().map(|(h, size)| {
let half = if sex == 0 {
[0.0, 0.5, 0.0, 1.0]
} else {
[0.5, 1.0, 0.0, 1.0]
};
(h.clone(), tc_rect(*size, half))
});
icon_button(
g,
font,
CreateAction::Gender(sex),
None::<DynIcon>,
icon,
None::<DynText>,
strings.text(key, fallback),
art,
s,
);
}
});
// The class grid (3-wide under the banners: cols at x 27/79/131, rows touching) — 8
// slots refreshed to the selected race's valid classes; unused slots collapse (the
// ref's enumerate-then-hide compacts the same way).
tower
.spawn((Node {
position_type: PositionType::Absolute,
left: px(27.0),
top: px(369.0),
width: px(3.0 * 48.0 + 2.0 * 4.0),
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
column_gap: px(4.0),
..default()
},))
.with_children(|grid| {
for slot in 0..8u8 {
icon_button(
grid,
font,
CreateAction::ClassSlot(slot),
Some(DynIcon::ClassSlot(slot)),
None,
Some(DynText::ClassSlotLabel(slot)),
"",
art,
s,
);
}
});
// The five dial spinners (`CharacterCustomizationFrameTemplate`, 198×32 rows stacked
// from (4,480) — centered under the class grid).
tower
.spawn((Node {
position_type: PositionType::Absolute,
left: px(4.0),
top: px(480.0),
width: px(198.0),
flex_direction: FlexDirection::Column,
..default()
},))
.with_children(|dials| {
for dial in 0..5u8 {
dial_row(dials, art, font, dial, s);
}
});
// RANDOMIZE (146×30, centered on the row column). The XML anchors it 25 below the
// dials, but `CharacterCreate_UpdateFacialHairCustomization` re-anchors it on every
// race set — `SetPoint("TOP", Frame5, "BOTTOM", 0, -5)` — so the shipped client always
// shows the 5px gap.
tower
.spawn((Node {
position_type: PositionType::Absolute,
left: px(30.0),
top: px(645.0),
..default()
},))
.with_children(|r| {
glue_button(
r,
art,
font,
CreateAction::Randomize,
strings.text("RANDOMIZE", "Randomize"),
146.0,
30.0,
GlueBtnKind::Small,
s,
);
});
});
}
/// One dial spinner row: the `CharacterCreate-LabelFrame` 3-slice (64-tall art overhanging the
/// 32-tall row), the centered per-race label, and the 32² arrow pair on the right.
fn dial_row(
dials: &mut ChildSpawnerCommands,
art: &GlueArt,
font: &Handle<Font>,
dial: u8,
s: f32,
) {
let px = |v: f32| Val::Px(v * s);
dials
.spawn((
DialRow(dial),
Node {
width: px(198.0),
height: px(32.0),
..default()
},
))
.with_children(|row| {
// LabelFrame: Left 25 at (−5), Middle stretched, Right 25 ending at x 154 (RIGHT −44,
// clearing the arrows) — the 128×64 art's 25|78|25 horizontal slices.
if let Some((frame, size)) = &art.label_frame {
for (left, width, tc) in [
(-5.0, 25.0, [0.0, 0.1953125, 0.0, 1.0]),
(20.0, 109.0, [0.1953125, 0.8046875, 0.0, 1.0]),
(129.0, 25.0, [0.8046875, 1.0, 0.0, 1.0]),
] {
row.spawn((
ImageNode {
image: frame.clone(),
rect: Some(tc_rect(*size, tc)),
..default()
},
abs(s, left, -16.0, width, 64.0),
));
}
}
// The per-race label, centered on the frame middle (`GlueFontHighlightSmall`).
outlined_text(
row,
Node {
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..abs(s, 20.0, 0.0, 109.0, 32.0)
},
(),
DynText::DialLabel(dial),
GlueText {
text: "",
size: 12.0,
color: INFO_TEXT,
wrap: false,
},
font,
s,
);
dial_arrow(
row,
&art.arrow_left,
font,
CreateAction::Dial(dial, -1),
137.0,
"<",
s,
);
dial_arrow(
row,
&art.arrow_right,
font,
CreateAction::Dial(dial, 1),
166.0,
">",
s,
);
});
}
/// NAME over the edit box (`CharacterCreateNameEdit`, 156×40 at BOTTOM (8,50), backdropped in
/// `Glue-Tooltip-Border` — always Alliance-tinted, the ref's `OnLoad`), our status line beneath.
fn name_cluster(
ui: &mut ChildSpawnerCommands,
art: &GlueArt,
font: &Handle<Font>,
edit_font: &Handle<Font>,
s: f32,
strings: &GlueStrings,
) {
let px = |v: f32| Val::Px(v * s);
ui.spawn((Node {
position_type: PositionType::Absolute,
bottom: px(50.0),
left: px(8.0),
width: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
row_gap: px(2.0),
..default()
},))
.with_children(|cluster| {
outlined_text(
cluster,
Node::default(),
(),
(),
GlueText {
text: strings.text("NAME", "Name"),
size: 18.0, // GlueFontNormalLarge
color: GOLD,
wrap: false,
},
font,
s,
);
// The shared glue edit-box chrome (decision 0539) — Alliance-tinted, always (the
// ref's `OnLoad`); the create refresh writes the typed name into the marker.
crate::glue::widgets::glue_edit_box(
cluster,
art,
edit_font,
(),
DynText::Name,
(156.0, 40.0),
(ALLIANCE_BORDER, ALLIANCE_FILL),
(15.0, 0.0, 0.0, 0.0), // CharacterCreate.xml: TextInsets left 15 only
s,
);
// Empty until a create fails — the ref surfaces errors in a dialog; this line is our
// minimal stand-in, never an idle hint.
outlined_text(
cluster,
Node::default(),
(),
StatusLine,
GlueText {
text: "",
size: 13.0,
color: DIM,
wrap: true,
},
font,
s,
);
});
}
/// The rotate pair (`CharacterCreateRotateLeft/Right`: 50² at BOTTOMLEFT (237,0), overlapping
/// −19) — `UI-RotationRight-Big` art, the left button mirrored, `UI-Common-MouseHilight` on hover.
fn rotate_cluster(ui: &mut ChildSpawnerCommands, art: &GlueArt, font: &Handle<Font>, s: f32) {
let px = |v: f32| Val::Px(v * s);
ui.spawn((Node {
position_type: PositionType::Absolute,
left: px(237.0),
bottom: px(0.0),
flex_direction: FlexDirection::Row,
..default()
},))
.with_children(|rot| {
for (action, flip, overlap) in [
(CreateAction::RotateLeft, true, 0.0),
(CreateAction::RotateRight, false, -19.0),
] {
let mut b = rot.spawn((
action,
Button,
Node {
width: px(50.0),
height: px(50.0),
margin: UiRect::left(px(overlap)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
));
match (&art.rotate_up, &art.rotate_down) {
(Some(up), down) => {
b.insert(ImageNode {
image: up.clone(),
flip_x: flip,
..default()
});
if let Some(down) = down {
b.insert(ArtSwap {
up: up.clone(),
down: down.clone(),
});
}
if let Some(hi) = &art.mouse_hilight {
b.with_children(|inner| {
inner.spawn((
Hilight,
Visibility::Hidden,
MaterialNode(hi.clone()),
abs(s, 10.0, 10.0, 30.0, 30.0),
));
});
}
}
(None, _) => {
b.insert((FallbackFace, BackgroundColor(BTN_BG)));
b.with_children(|inner| {
inner.spawn((
Text::new(if flip { "<" } else { ">" }),
TextFont {
font: font.clone(),
font_size: 16.0 * s,
..default()
},
TextColor(GOLD),
));
});
}
}
}
});
}
pub(super) fn exit_create(
mut commands: Commands,
roots: Query<Entity, With<CharCreateUi>>,
mut preview: ResMut<GluePreview>,
) {
for e in &roots {
commands.entity(e).despawn();
}
// Clear the booth + scene. Back to CharSelect re-establishes both the same frame
// (its `OnEnter` runs after this `OnExit`).
preview.look = None;
preview.scene = None;
}