concinnity-cook 0.19.16

Authored world model, validation, and the asset cook pipeline that bakes a Concinnity world into a blob
Documentation
// Build-time expansion: MainMenu -> Screen + Sprite (backdrop) + TextLabel +
// HitRegion per item + an optional Escape KeyBinding + an optional in-engine
// cursor Sprite, plus a generated settings sub-screen when an item asks for one.
//
// Everything is prefixed with the menu's own name so the build pipeline's
// `<screen>_*` rule scopes each generated UI element to the menu's Screen. The menu
// shows/hides at runtime purely as a Screen visibility flip; this pass adds no
// runtime behaviour, only the assets the existing UI systems already drive.

mod rows;
mod screen;
mod settings_tab;

#[cfg(test)]
mod tests;

use std::collections::HashSet;

use super::expand::{asset_name, type_norm};
use super::ui_spec::font_sizes;
use crate::authoring::registry::build_only::MainMenu;
use crate::authoring::spec::{asset, spec_to_value};
use concinnity_core::gfx::overlay::UI_REFERENCE_SIZE;

use rows::settings_tabs;
use screen::{MenuMetrics, emit_menu_screen};
use settings_tab::emit_settings_tab;

// Top margin of a centered menu as a fraction of the reference height. The menu
// is top-aligned (not vertically centered) so the heading and tab bar hold a
// fixed position when switching between tabs with different row counts.
const TOP_MARGIN_FRAC: f32 = 0.07;

// An RGB accent lifted to an opaque RGBA fill: the active-tab underline marker
// and the scrollbar thumb draw the hover colour at full alpha.
fn opaque(rgb: [f32; 3]) -> [f32; 4] {
    [rgb[0], rgb[1], rgb[2], 1.0]
}

// The in-engine cursor Sprite: a `follow_cursor` square the runtime tracks to
// the pointer while the menu is open.
fn cursor_sprite(name: &str, style: &MainMenu) -> serde_json::Value {
    spec_to_value(
        &asset::sprite(
            name,
            [0.0, 0.0, style.cursor_size, style.cursor_size],
            style.cursor_color,
        )
        .set("follow_cursor", true),
    )
}

// Replace every MainMenu asset with the concrete UI assets it expands to.
// Generated names are prefixed with the menu's (unique) asset name, so they
// never collide with hand-authored assets; a collision is a hard error.
pub(crate) fn expand_main_menus(assets: &mut Vec<serde_json::Value>) -> Result<(), String> {
    if !assets.iter().any(|v| type_norm(v) == "mainmenu") {
        return Ok(());
    }

    // Menus lay out against a fixed reference canvas; the renderer uniformly
    // scales the overlay to the live window (see concinnity_core::gfx::overlay), so the
    // declared Window size does not affect the menu layout.
    let (win_w, win_h) = (UI_REFERENCE_SIZE[0], UI_REFERENCE_SIZE[1]);
    let font_px_by_name = font_sizes(assets);

    // Names already in use: authored assets plus entries generated by earlier
    // menus. A generated name landing on one of these is rejected.
    let mut taken: HashSet<String> = assets
        .iter()
        .filter(|v| type_norm(v) != "mainmenu")
        .map(asset_name)
        .filter(|n| !n.is_empty())
        .collect();

    let mut result: Vec<serde_json::Value> = Vec::new();
    for value in assets.drain(..) {
        if type_norm(&value) != "mainmenu" {
            result.push(value);
            continue;
        }

        let menu_name = asset_name(&value);
        if menu_name.is_empty() {
            return Err("MainMenu: missing `name`".to_string());
        }
        let args = value
            .get("args")
            .cloned()
            .unwrap_or_else(|| serde_json::json!({}));
        let menu: MainMenu = serde_json::from_value(args)
            .map_err(|e| format!("MainMenu '{}': invalid args: {}", menu_name, e))?;

        let font_px = if menu.font.is_empty() {
            menu.font_px
        } else {
            *font_px_by_name.get(&menu.font).unwrap_or(&menu.font_px)
        };

        for entry in expand_one(&menu_name, &menu, win_w, win_h, font_px) {
            let name = asset_name(&entry);
            if !name.is_empty() && !taken.insert(name.clone()) {
                return Err(format!(
                    "MainMenu '{}': generated asset name '{}' collides with an existing \
                     asset; rename the menu or the conflicting asset",
                    menu_name, name
                ));
            }
            result.push(entry);
        }
    }

    *assets = result;
    Ok(())
}

// Generate every asset for one menu: the main screen and its buttons, an optional
// Escape key binding, and (when an item resolves to the settings convenience) a
// settings sub-screen with a Back button.
fn expand_one(
    menu_name: &str,
    menu: &MainMenu,
    win_w: f32,
    win_h: f32,
    font_px: f32,
) -> Vec<serde_json::Value> {
    let mut out = Vec::new();
    let mut wants_settings = false;

    // Resolve the font. Use the user's font when set; otherwise emit a font for
    // this menu at `font_px` and reference it explicitly, because the row
    // geometry below is laid out in those pixels: leaving the labels font-less
    // would draw them at the built-in face's own size instead, inside buttons
    // measured for this one.
    let font_name = if menu.font.is_empty() {
        let name = format!("{}_font", menu_name);
        out.push(spec_to_value(&asset::font(&name, font_px as u32)));
        name
    } else {
        menu.font.clone()
    };

    // Resolve the per-menu convenience actions against this menu's name.
    let items: Vec<(String, String)> = menu
        .items
        .iter()
        .map(|item| {
            let action = match item.action.trim().to_lowercase().as_str() {
                "return" | "close" => "screen:hide".to_string(),
                "settings" => {
                    wants_settings = true;
                    format!("screen:show:{}_settings_video", menu_name)
                }
                _ => item.action.clone(),
            };
            (item.label.clone(), action)
        })
        .collect();

    out.extend(emit_menu_screen(
        menu_name,
        &menu.title,
        &items,
        menu,
        &font_name,
        MenuMetrics {
            win_w,
            win_h,
            font_px,
        },
        menu.initial,
    ));

    if !menu.toggle_key.is_empty() {
        out.push(serde_json::json!({
            "name": format!("{}_toggle", menu_name),
            "type": "KeyBinding",
            "args": { "key": menu.toggle_key, "action": format!("screen:toggle:{}", menu_name) }
        }));
    }

    // Generate the settings screen when an item opens it (the `"settings"`
    // convenience) or when a Back-action override is set (a caller that opens
    // settings by its own action, e.g. a story pause menu).
    if wants_settings || !menu.settings_back_action.is_empty() {
        for (suffix, _) in settings_tabs(menu.settings_profile) {
            out.extend(emit_settings_tab(
                menu_name, suffix, menu, &font_name, win_w, win_h, font_px,
            ));
        }
    }

    out
}