灵符lingfu

lingdev

↓ 0 下载

LingDev: a Godot / Unity-style game editor written in Ling. 2-D and 3-D scenes, Flash-style timeline and cutscenes, HUDs over 3-D, Ling scripting, audio / music / MIDI, builds for Windows, Linux, macOS, web, Raspberry Pi and LingOS. Ships with Ling Agent (an N64-style spy FPS), Ling Breaker (a 3-D breakout) and Moko's Picture Book (a kawaii storybook platformer).

Windows · x86-64Linux · x86-64LingOS · x86-64LingOS · Pi 5 ARM64Web · WASM
关键词 game-engine, editor, 3d, 2d, cutscene, timeline, ling, lingos, raspberry-pi
lingfu install lingdev
许可证Apache-2.0 OR MIT
作者ling-lin
README代码 / 文件版本依赖关系图依赖此包的项目安全构建设置

代码 / 文件 (136)

ling/modals.ling
# modals.ling - floating windows: text prompt, confirm, file picker, settings, scene settings,
# new project, about / help / shortcuts. One modal at a time (st "modal").

fn modal_open(name) {
    st_set("modal", name)
    st_set("modal_t0", st_get("now", 0.0))
    st_set("focus", "")
}
fn modal_close() {
    st_set("modal", "")
    st_set("focus", "")
}
fn modal_text(title, kind, initial) {
    st_set("text_title", title)
    st_set("text_kind", kind)
    modal_open("text")
    st_set("text_val", initial)
    text_set(initial)
    st_set("focus", "text_modal")
}
fn confirm_open(title, message, act) {
    st_set("confirm_title", title)
    st_set("confirm_msg", message)
    st_set("confirm_act", act)
    modal_open("confirm")
}
fn info_open(title, message) {
    st_set("info_title", title)
    st_set("info_msg", message)
    modal_open("info")
}

# window chrome; leaves the content origin in wx, wy
fn win_begin(title, w, h) {
    bind W = get_width()
    bind H = get_height()
    bind w = min(w, W - 20.0)
    bind h = min(h, H - 20.0)
    bind x = floor((W - w) * 0.5)
    bind y = floor((H - h) * 0.5) - 20.0
    if y < 4.0 { bind y = 4.0 }
    g_rect_a(0.0, 0.0, W, H, 0.0, 0.45)
    px_rrect(x + 3.0, y + 5.0, w, h, 8.0, 0.0, 0.5)
    g_rrect(x, y, w, h, 7.0, C_BG_PANEL)
    g_rframe(x, y, w, h, 7.0, C_POPUP_LINE)
    g_rrect(x, y, w, 26.0, 7.0, 2434344.0)
    g_rect(x, y + 14.0, w, 12.0, 2434344.0)
    g_text(x + 12.0, y + 6.0, 13.0, C_TEXT_STRONG, title)
    bind hot = u_hot(x + w - 26.0, y + 3.0, 20.0, 20.0)
    if hot { g_rrect(x + w - 26.0, y + 3.0, 20.0, 20.0, 4.0, C_RED) }
    icon_draw("x", x + w - 16.0, y + 13.0, 10.0, C_TEXT_STRONG)
    if hot && u_pressed() { modal_close() }
    u_hsep(x, y + 26.0, w)
    st_set("wx", x)
    st_set("wy", y + 34.0)
    st_set("ww", w)
    st_set("wh", h - 34.0)
}

fn modal_overlay() {
    bind m = st_get("modal", "")
    if m == "" { return 0.0 }
    st_set("blk", sel(st_get("cb_open", "") != "" || st_get("ctx_open", 0.0) > 0.5, 1.0, 0.0))
    if m == "text" { modal_text_win() }
    else if m == "confirm" { modal_confirm() }
    else if m == "info" { modal_info() }
    else if m == "picker" { modal_picker() }
    else if m == "settings" { modal_settings() }
    else if m == "scene" { modal_scene_settings() }
    else if m == "newproject" { modal_new_project() }
    else if m == "about" { modal_about() }
    else if m == "help" { modal_help() }
    else if m == "shortcuts" { modal_shortcuts() }
    else if m == "scripting" { modal_scripting() }
    else if m == "clip" { modal_clip() }
    else if m == "addnode" { modal_addnode() }
    if key_pressed("escape") && st_get("cb_open", "") == "" { modal_close() }
    return 0.0
}

fn modal_confirm() {
    win_begin(st_get("confirm_title", "Confirm"), 420.0, 140.0)
    bind x = st_get("wx", 0.0)
    bind y = st_get("wy", 0.0)
    g_text_wrap(x + 16.0, y + 10.0, 388.0, 13.0, C_TEXT, st_get("confirm_msg", ""))
    if u_button("cf_yes", x + 16.0, y + 66.0, 110.0, 26.0, "Yes", C_RED) {
        bind act = st_get("confirm_act", "")
        modal_close()
        do_action(act)
    }
    if u_button("cf_no", x + 134.0, y + 66.0, 110.0, 26.0, "Cancel", 0.0) { modal_close() }
    if key_pressed("enter") {
        bind act = st_get("confirm_act", "")
        modal_close()
        do_action(act)
    }
}
fn modal_info() {
    win_begin(st_get("info_title", "Info"), 520.0, 220.0)
    bind x = st_get("wx", 0.0)
    bind y = st_get("wy", 0.0)
    bind lines = str_split(st_get("info_msg", ""), "\n")
    bind ly = y + 10.0
    bind i = 0.0
    while i < len(lines) {
        bind ly = ly + g_text_wrap(x + 16.0, ly, 488.0, 12.0, C_TEXT, list_get(lines, i))
        bind i = i + 1.0
    }
    if u_button("inf_ok", x + 520.0 * 0.5 - 50.0, y + 220.0 - 76.0, 100.0, 26.0, "OK", 0.0) { modal_close() }
    if key_pressed("enter") { modal_close() }
}

fn modal_text_win() {
    win_begin(st_get("text_title", "Rename"), 420.0, 120.0)
    bind x = st_get("wx", 0.0)
    bind y = st_get("wy", 0.0)
    bind v = u_textfield("text_modal", x + 14.0, y + 10.0, 392.0, 26.0, st_get("text_val", ""))
    st_set("text_val", v)
    bind okb = u_button("txt_ok", x + 14.0, y + 50.0, 80.0, 26.0, "OK", C_BLUE)
    bind cancel = u_button("txt_cancel", x + 100.0, y + 50.0, 80.0, 26.0, "Cancel", 0.0)
    if key_pressed("enter") { bind okb = true }
    if okb {
        bind kind = st_get("text_kind", "")
        modal_close()
        text_submit(kind, v)
        return 0.0
    }
    if cancel { modal_close() }
    return 0.0
}

# ── file picker (works on every runtime; native dialogs are a setting) ───────
# mode: open | save | folder.  exts: "lscene,lcut" or "".  act: action run with st pick_path set
fn picker_open(mode, title, exts, act, start_dir, name) {
    st_set("pick_mode", mode)
    st_set("pick_title", title)
    st_set("pick_exts", exts)
    st_set("pick_act", act)
    st_set("pick_name", name)
    st_set("pick_sel", "")
    st_set("pick_scroll", 0.0)
    bind d = start_dir
    if d == "" || !fs_is_dir(d) { bind d = st_get("proj_root", "") }
    if d == "" || !fs_is_dir(d) { bind d = fs_cwd() }
    st_set("pick_dir", p_norm(d))
    if plat_has_dialogs() && st_get("native_dialogs", 0.0) > 0.5 {
        bind r = ""
        if mode == "open" { bind r = plat_dlg_open(title, exts, d) }
        else if mode == "save" { bind r = plat_dlg_save(title, exts, name, d) }
        else { bind r = plat_dlg_folder(title, d) }
        if r != "" {
            st_set("pick_path", p_norm(r))
            do_action(act)
        }
        return 0.0
    }
    modal_open("picker")
    if mode == "save" {
        st_set("focus", "pick_namef")
        text_set(name)
    }
    return 0.0
}
fn pick_ext_ok(name) {
    bind exts = st_get("pick_exts", "")
    if exts == "" { return true }
    bind e = p_ext(name)
    bind l = str_split(exts, ",")
    bind i = 0.0
    while i < len(l) {
        if str_trim(list_get(l, i)) == e { return true }
        bind i = i + 1.0
    }
    return false
}
fn pick_go(dir) {
    st_set("pick_dir", p_norm(dir))
    st_set("pick_sel", "")
    st_set("pick_scroll", 0.0)
}
fn pick_finish(path) {
    bind act = st_get("pick_act", "")
    modal_close()
    st_set("pick_path", p_norm(path))
    do_action(act)
}
fn modal_picker() {
    bind ww = 760.0
    bind wh = 540.0
    win_begin(st_get("pick_title", "Open"), ww, wh)
    bind x = st_get("wx", 0.0)
    bind y = st_get("wy", 0.0)
    bind mode = st_get("pick_mode", "open")
    bind dir = st_get("pick_dir", ".")
    # places
    bind px = x + 12.0
    if u_button("pk_home", px, y + 6.0, 60.0, 24.0, "Home", 0.0) { pick_go(fs_home()) }
    bind px = px + 64.0
    if st_get("proj_root", "") != "" {
        if u_button("pk_proj", px, y + 6.0, 70.0, 24.0, "Project", 0.0) { pick_go(st_get("proj_root", "")) }
        bind px = px + 74.0
    }
    if u_button("pk_cwd", px, y + 6.0, 70.0, 24.0, "LingDev", 0.0) { pick_go(st_get("app_root", fs_cwd())) }
    bind px = px + 74.0
    if u_button("pk_up", px, y + 6.0, 40.0, 24.0, "Up", 0.0) {
        bind parent = fs_parent(dir)
        if parent != "" && parent != dir { pick_go(parent) }
    }
    bind px = px + 48.0
    g_rrect(px, y + 6.0, x + ww - px - 12.0, 24.0, 3.0, C_BG_DEEP)
    g_text_fit(px + 8.0, y + 12.0, x + ww - px - 28.0, 12.0, C_TEXT, dir)
    # listing
    bind ly = y + 40.0
    bind lh = wh - 40.0 - 100.0
    g_rect(x + 12.0, ly, ww - 24.0, lh, C_BG_DEEP)
    g_frame(x + 12.0, ly, ww - 24.0, lh, C_LINE)
    bind items = fs_list(dir)
    bind n = len(items) / 3.0
    bind rows = []
    bind i = 0.0
    while i < n {
        bind nm = list_get(items, i * 3.0)
        bind isd = is_true(list_get(items, i * 3.0 + 1.0))
        if isd || mode != "folder" {
            if isd || pick_ext_ok(nm) { bind rows = list_push(rows, i) }
        }
        bind i = i + 1.0
    }
    bind rh = 22.0
    bind total = len(rows) * rh + 8.0
    bind off = u_scroll_y("pk_sb", x + 12.0, ly, ww - 24.0, lh, total, st_get("pick_scroll", 0.0))
    st_set("pick_scroll", off)
    u_clip(x + 13.0, ly + 1.0, ww - 26.0, lh - 2.0)
    bind r = 0.0
    while r < len(rows) {
        bind i = list_get(rows, r)
        bind nm = list_get(items, i * 3.0)
        bind isd = is_true(list_get(items, i * 3.0 + 1.0))
        bind size = list_get(items, i * 3.0 + 2.0)
        bind ry = ly + 4.0 + r * rh - off
        if ry + rh >= ly && ry <= ly + lh {
            bind full = p_join(dir, nm)
            bind selected = st_get("pick_sel", "") == full
            bind hot = u_hot(x + 14.0, ry, ww - 40.0, rh)
            if selected { g_rect(x + 14.0, ry, ww - 40.0, rh, C_SEL_ROW) }
            else if hot { g_rect(x + 14.0, ry, ww - 40.0, rh, C_HOVER_ROW) }
            bind e = p_ext(nm)
            if isd { icon_draw("folder", x + 28.0, ry + rh * 0.5, 13.0, C_FOLDER) }
            else { icon_draw(ext_icon(e), x + 28.0, ry + rh * 0.5, 13.0, ext_col(e)) }
            g_text_fit(x + 44.0, ry + 5.0, ww - 170.0, 12.0, sel(selected, C_TEXT_STRONG, C_TEXT), nm)
            if !isd { g_text_r(x + ww - 30.0, ry + 6.0, 11.0, C_TEXT_DIM, sel(size > 1048576.0, fmt1(size / 1048576.0) + " MB", sel(size > 1024.0, num_str(floor(size / 1024.0)) + " KB", num_str(size) + " B"))) }
            if hot && u_pressed() {
                st_set("pick_sel", full)
                if !isd && mode == "save" {
                    st_set("pick_name", nm)
                    text_set(nm)
                }
                if u_double() {
                    if isd { pick_go(full) }
                    else if mode == "open" { pick_finish(full) }
                }
            }
        }
        bind r = r + 1.0
    }
    if len(rows) < 0.5 { g_text(x + 24.0, ly + 10.0, 12.0, C_TEXT_DIM, "(empty)") }
    u_unclip()
    # bottom row
    bind by = y + wh - 92.0
    if mode == "save" {
        g_text(x + 12.0, by + 6.0, 12.0, C_TEXT_DIM, "Name")
        bind nv = u_textfield("pick_namef", x + 56.0, by, ww - 68.0, 26.0, st_get("pick_name", ""))
        st_set("pick_name", nv)
    } else {
        g_text_fit(x + 12.0, by + 6.0, ww - 24.0, 12.0, C_TEXT_DIM, sel(st_get("pick_sel", "") == "", "Filter: " + sel(st_get("pick_exts", "") == "", "all files", st_get("pick_exts", "")), st_get("pick_sel", "")))
    }
    bind by = by + 36.0
    bind oklabel = sel(mode == "save", "Save", sel(mode == "folder", "Choose Folder", "Open"))
    bind ok = u_button("pk_ok", x + ww - 232.0, by, 110.0, 28.0, oklabel, C_BLUE)
    if key_pressed("enter") && st_get("focus", "") != "" { bind ok = true }
    if ok {
        if mode == "save" {
            bind nm = str_trim(st_get("pick_name", ""))
            if nm != "" {
                bind exts = st_get("pick_exts", "")
                if exts != "" && !pick_ext_ok(nm) { bind nm = nm + "." + str_trim(list_get(str_split(exts, ","), 0)) }
                pick_finish(p_join(dir, nm))
            }
        } else if mode == "folder" {
            bind s = st_get("pick_sel", "")
            pick_finish(sel(s != "" && fs_is_dir(s), s, dir))
        } else {
            bind s = st_get("pick_sel", "")
            if s != "" {
                if fs_is_dir(s) { pick_go(s) } else { pick_finish(s) }
            }
        }
    }
    if u_button("pk_cancel", x + ww - 116.0, by, 104.0, 28.0, "Cancel", 0.0) { modal_close() }
}

# ── settings ─────────────────────────────────────────────────────────────────
fn modal_settings() {
    win_begin("Settings", 520.0, 360.0)
    bind x = st_get("wx", 0.0)
    bind y = st_get("wy", 0.0)
    ds_word(x + 16.0, y + 4.0, 11.0, "EDITOR", ds_col("call"), true)
    bind yy = y + 32.0
    bind v = ds_switch("set_native", x + 16.0, yy, 16.0, "Native file dialogs", st_get("native_dialogs", 0.0) > 0.5, ds_col("ctl"), "Use the system Open / Save dialogs instead of the built-in picker")
    if v >= 0.0 { st_set("native_dialogs", v) }
    bind yy = yy + 28.0
    bind v = ds_switch("set_black", x + 16.0, yy, 16.0, "Black theme", st_get("theme_black", 0.0) > 0.5, ds_col("mod"), "Near-black chrome (LingStation's Black scheme)")
    if v >= 0.0 { st_set("theme_black", v) }
    bind yy = yy + 28.0
    bind v = ds_switch("set_loadlast", x + 16.0, yy, 16.0, "Open the last project at start", st_get("load_last", 1.0) > 0.5, ds_col("bind"), "")
    if v >= 0.0 { st_set("load_last", v) }
    bind yy = yy + 28.0
    bind v = ds_switch("set_sounds", x + 16.0, yy, 16.0, "Interface sounds", st_get("ui_sounds", 0.0) > 0.5, ds_col("asm"), "")
    if v >= 0.0 { st_set("ui_sounds", v) }
    bind yy = yy + 36.0
    ds_word(x + 16.0, yy, 11.0, "VIEWPORT", ds_col("func"), true)
    bind yy = yy + 28.0
    g_text(x + 16.0, yy + 5.0, 12.0, C_TEXT, "Grid step")
    bind nv = u_dragval("set_grid", x + 120.0, yy, 80.0, 24.0, st_get("grid_step", 1.0), 0.1, 10.0, 0.02, fmt2(st_get("grid_step", 1.0)))
    if u_has(nv) { st_set("grid_step", round(nv * 100.0) / 100.0) }
    g_text(x + 220.0, yy + 5.0, 12.0, C_TEXT, "Orbit speed")
    bind nv = u_dragval("set_orbit", x + 310.0, yy, 80.0, 24.0, st_get("orbit_speed", 1.0), 0.2, 3.0, 0.01, fmt2(st_get("orbit_speed", 1.0)))
    if u_has(nv) { st_set("orbit_speed", round(nv * 100.0) / 100.0) }
    bind yy = yy + 32.0
    g_text(x + 16.0, yy + 5.0, 12.0, C_TEXT, "Autosave (min)")
    bind nv = u_dragval("set_autosave", x + 120.0, yy, 80.0, 24.0, st_get("autosave", 3.0), 0.0, 60.0, 0.1, num_str(floor(st_get("autosave", 3.0))))
    if u_has(nv) { st_set("autosave", floor(nv)) }
    g_text(x + 220.0, yy + 5.0, 12.0, C_TEXT, "ling.exe")
    bind lp = u_textfield("set_ling", x + 290.0, yy, 210.0, 24.0, st_get("ling_exe", ""))
    st_set("ling_exe", lp)
    bind yy = yy + 44.0
    if u_button("set_ok", x + 16.0, yy, 100.0, 26.0, "Close", C_BLUE) {
        settings_save()
        modal_close()
    }
    g_text(x + 130.0, yy + 7.0, 11.0, C_TEXT_DIM, "Settings are kept in settings.txt next to LingDev.")
}

# ── scene settings ───────────────────────────────────────────────────────────
fn modal_scene_settings() {
    win_begin("Scene Settings", 540.0, 434.0)
    bind x = st_get("wx", 0.0)
    bind y = st_get("wy", 0.0)
    bind yy = y + 6.0
    g_text(x + 16.0, yy + 6.0, 12.0, C_TEXT, "Name")
    bind nv = u_textfield("scn_name", x + 110.0, yy, 300.0, 26.0, sc_get("name", ""))
    if nv != sc_get("name", "") { sc_set("name", nv)  st_set("dirty", 1.0) }
    bind yy = yy + 34.0
    g_text(x + 16.0, yy + 6.0, 12.0, C_TEXT, "Kind")
    bind kinds = ["3d", "2d", "cutscene"]
    bind kn = ["3D scene / level", "2D menu / HUD", "Cutscene"]
    bind ki = u_combo("scn_kind", x + 110.0, yy, 200.0, 26.0, kn, max(0.0, l_index(kinds, sc_get("kind", "3d"))))
    if ki >= 0.0 { sc_set("kind", list_get(kinds, ki))  st_set("dirty", 1.0) }
    bind yy = yy + 34.0
    g_text(x + 16.0, yy + 6.0, 12.0, C_TEXT, "Background")
    bind bg = sc_get("bg", 0.0)
    bind r = u_dragval("scn_bgr", x + 110.0, yy, 54.0, 26.0, c_r(bg), 0.0, 255.0, 1.0, num_str(c_r(bg)))
    bind g = u_dragval("scn_bgg", x + 168.0, yy, 54.0, 26.0, c_g(bg), 0.0, 255.0, 1.0, num_str(c_g(bg)))
    bind b = u_dragval("scn_bgb", x + 226.0, yy, 54.0, 26.0, c_b(bg), 0.0, 255.0, 1.0, num_str(c_b(bg)))
    if u_has(r) { sc_set("bg", c_rgb(r, c_g(bg), c_b(bg)))  st_set("dirty", 1.0) }
    if u_has(g) { sc_set("bg", c_rgb(c_r(bg), g, c_b(bg)))  st_set("dirty", 1.0) }
    if u_has(b) { sc_set("bg", c_rgb(c_r(bg), c_g(bg), b))  st_set("dirty", 1.0) }
    g_rect(x + 290.0, yy + 2.0, 40.0, 22.0, sc_get("bg", 0.0))
    g_frame(x + 290.0, yy + 2.0, 40.0, 22.0, C_LINE)
    bind yy = yy + 34.0
    g_text(x + 16.0, yy + 6.0, 12.0, C_TEXT, "Timeline")
    bind nv = u_dragval("scn_len", x + 110.0, yy, 80.0, 26.0, sc_get("len", 10.0), 0.5, 3600.0, 0.05, fmt1(sc_get("len", 10.0)) + " s")
    if u_has(nv) { sc_set("len", round(nv * 10.0) / 10.0)  st_set("dirty", 1.0) }
    bind nv = u_dragval("scn_fps", x + 196.0, yy, 80.0, 26.0, sc_get("fps", 30.0), 1.0, 120.0, 0.2, num_str(floor(sc_get("fps", 30.0))) + " fps")
    if u_has(nv) { sc_set("fps", floor(nv))  st_set("dirty", 1.0) }
    bind yy = yy + 34.0
    g_text(x + 16.0, yy + 6.0, 12.0, C_TEXT, "Ambient")
    bind nv = u_hslider("scn_amb", x + 110.0, yy + 2.0, 200.0, 22.0, sc_get("amb", 0.25), 0.0, 1.0, C_YELLOW)
    if u_has(nv) { sc_set("amb", nv)  st_set("dirty", 1.0) }
    g_text(x + 320.0, yy + 6.0, 12.0, C_TEXT_DIM, fmt2(sc_get("amb", 0.25)))
    bind yy = yy + 34.0
    g_text(x + 16.0, yy + 6.0, 12.0, C_TEXT, "HUD scene")
    bind hv = u_textfield("scn_hud", x + 110.0, yy, 300.0, 26.0, sc_get("hud", ""))
    if hv != sc_get("hud", "") { sc_set("hud", hv)  st_set("dirty", 1.0) }
    if u_button("scn_hud_pick", x + 416.0, yy, 60.0, 26.0, "Pick", 0.0) { picker_open("open", "HUD scene (2D) drawn over this scene", "lscene", "scene.set_hud", st_get("proj_root", ""), "") }
    bind yy = yy + 34.0
    g_text(x + 16.0, yy + 6.0, 12.0, C_TEXT, "Next scene")
    bind nxv = u_textfield("scn_next", x + 110.0, yy, 300.0, 26.0, sc_get("next", ""))
    if nxv != sc_get("next", "") { sc_set("next", nxv)  st_set("dirty", 1.0) }
    if u_button("scn_next_pick", x + 416.0, yy, 60.0, 26.0, "Pick", 0.0) { picker_open("open", "Scene to load when this one ends", "lscene", "scene.set_next", st_get("proj_root", ""), "") }
    bind yy = yy + 34.0
    g_text(x + 16.0, yy + 6.0, 12.0, C_TEXT, "Music")
    bind mv = u_textfield("scn_music", x + 110.0, yy, 300.0, 26.0, sc_get("music", ""))
    if mv != sc_get("music", "") { sc_set("music", mv)  st_set("dirty", 1.0) }
    if u_button("scn_music_pick", x + 416.0, yy, 60.0, 26.0, "Pick", 0.0) { picker_open("open", "Background music", "wav,ogg,mp3,flac", "scene.set_music", st_get("proj_root", ""), "") }
    bind yy = yy + 34.0
    g_text(x + 16.0, yy + 6.0, 12.0, C_TEXT, "Fog r,g,b,near,far")
    bind fogv = u_textfield("scn_fog", x + 150.0, yy, 260.0, 26.0, sc_ex("fog", ""))
    if fogv != sc_ex("fog", "") { sc_ex_set("fog", fogv)  st_set("dirty", 1.0) }
    g_text(x + 416.0, yy + 6.0, 11.0, C_TEXT_DIM, "e.g. 30,72,158,8,34")
    bind yy = yy + 34.0
    g_text(x + 16.0, yy + 6.0, 12.0, C_TEXT, "Gravity / floor")
    bind nv = u_dragval("scn_grav", x + 110.0, yy, 80.0, 26.0, sc_get("grav", 9.0), 0.0, 60.0, 0.05, fmt1(sc_get("grav", 9.0)))
    if u_has(nv) { sc_set("grav", round(nv * 10.0) / 10.0)  st_set("dirty", 1.0) }
    bind nv = u_dragval("scn_floor", x + 196.0, yy, 80.0, 26.0, sc_get("floor", 0.0), 0.0 - 1000.0, 1000.0, 0.05, fmt1(sc_get("floor", 0.0)))
    if u_has(nv) { sc_set("floor", round(nv * 10.0) / 10.0)  st_set("dirty", 1.0) }
    bind yy = yy + 40.0
    if u_button("scn_ok", x + 16.0, yy, 100.0, 26.0, "Close", C_BLUE) { modal_close() }
}

# ── new project ──────────────────────────────────────────────────────────────
fn modal_new_project() {
    win_begin("New Project", 560.0, 330.0)
    bind x = st_get("wx", 0.0)
    bind y = st_get("wy", 0.0)
    bind yy = y + 8.0
    g_text(x + 16.0, yy + 6.0, 12.0, C_TEXT, "Name")
    bind nv = u_textfield("np_name", x + 110.0, yy, 300.0, 26.0, st_get("np_name", "MyGame"))
    st_set("np_name", nv)
    if st_get("focus", "") == "" && st_get("np_first", 0.0) < 0.5 { st_set("focus", "np_name")  text_set(nv)  st_set("np_first", 1.0) }
    bind yy = yy + 34.0
    g_text(x + 16.0, yy + 6.0, 12.0, C_TEXT, "Folder")
    g_rrect(x + 110.0, yy, 300.0, 26.0, 3.0, C_BG_DEEP)
    g_text_fit(x + 118.0, yy + 6.0, 284.0, 12.0, C_TEXT, st_get("np_dir", p_join(st_get("app_root", "."), "projects")))
    if u_button("np_pick", x + 416.0, yy, 60.0, 26.0, "Pick", 0.0) { picker_open("folder", "Folder that will hold the project folder", "", "project.new_dir", st_get("np_dir", ""), "") }
    bind yy = yy + 34.0
    g_text(x + 16.0, yy + 6.0, 12.0, C_TEXT, "Template")
    bind tn = ["Empty 3D scene", "3D level + 2D HUD", "2D menu + 3D background", "Cutscene demo", "Rhythm / MIDI demo"]
    bind ti = u_combo("np_tpl", x + 110.0, yy, 300.0, 26.0, tn, st_get("np_tpl", 1.0))
    if ti >= 0.0 { st_set("np_tpl", ti) }
    bind yy = yy + 34.0
    g_text(x + 16.0, yy + 6.0, 12.0, C_TEXT, "Window")
    bind nv = u_dragval("np_w", x + 110.0, yy, 80.0, 26.0, st_get("np_w", 1280.0), 320.0, 3840.0, 2.0, num_str(st_get("np_w", 1280.0)))
    if u_has(nv) { st_set("np_w", floor(nv)) }
    bind nv = u_dragval("np_h", x + 196.0, yy, 80.0, 26.0, st_get("np_h", 720.0), 200.0, 2160.0, 2.0, num_str(st_get("np_h", 720.0)))
    if u_has(nv) { st_set("np_h", floor(nv)) }
    bind yy = yy + 40.0
    g_text_wrap(x + 16.0, yy, 520.0, 11.0, C_TEXT_DIM, "Creates <folder>/<name>/ with lingdev.toml, Ling.toml, scenes/, scripts/, assets/ and the runtime (rt/) so `ling run main.ling` and `ling build .` work from day one.")
    bind yy = yy + 40.0
    if u_button("np_ok", x + 16.0, yy, 120.0, 28.0, "Create", C_BLUE) {
        modal_close()
        do_action("project.create")
    }
    if u_button("np_cancel", x + 144.0, yy, 100.0, 28.0, "Cancel", 0.0) { modal_close() }
}

# ── add node palette ─────────────────────────────────────────────────────────
fn modal_addnode() {
    win_begin("Add to Scene", 600.0, 420.0)
    bind x = st_get("wx", 0.0)
    bind y = st_get("wy", 0.0)
    bind k2d = sc_get("kind", "3d") == "2d"
    bind kinds = sel(k2d, nd_kinds_2d(), nd_kinds_3d())
    bind cols = 4.0
    bind cw = 138.0
    bind ch = 64.0
    bind i = 0.0
    while i < len(kinds) {
        bind k = list_get(kinds, i)
        bind cx = x + 16.0 + (i % cols) * (cw + 8.0)
        bind cy = y + 8.0 + floor(i / cols) * (ch + 8.0)
        bind col = kind_col(k)
        bind hot = u_hot(cx, cy, cw, ch)
        px_rect(cx, cy, cw, ch, c_mix(C_BG_PANEL, col, sel(hot, 0.12, 0.03)))
        if hot { ds_cross(cx + 1.0, cy + 1.0, cw - 2.0, ch - 2.0, col, 4.0, 0.16) }
        px_outline(cx, cy, cw, ch, sel(hot, col, c_mix(C_BG_PANEL, col, 0.55)), 0.8)
        ds_brackets(cx, cy, cw, ch, sel(hot, 12.0, 6.0), col)
        icon_draw(kind_icon(k), cx + 26.0, cy + ch * 0.5, 26.0, col)
        g_text(cx + 50.0, cy + 18.0, 13.0, C_TEXT_STRONG, k)
        g_text_fit(cx + 50.0, cy + 36.0, cw - 56.0, 10.0, C_TEXT_DIM, kind_blurb(k))
        if hot && u_pressed() {
            modal_close()
            do_action("node.add:" + k)
        }
        bind i = i + 1.0
    }
    if u_button("an_cancel", x + 600.0 * 0.5 - 50.0, y + 420.0 - 74.0, 100.0, 26.0, "Cancel", 0.0) { modal_close() }
}
fn kind_blurb(k) {
    if k == "box" { return "Solid block, wall, floor" }
    if k == "sphere" { return "Ball, planet, orb" }
    if k == "cylinder" { return "Pillar, tube" }
    if k == "cone" { return "Spike, roof" }
    if k == "capsule" { return "Character proxy" }
    if k == "torus" { return "Ring, donut" }
    if k == "plane" { return "Ground plane" }
    if k == "model" { return "glb / gltf / lmesh file" }
    if k == "actor" { return "Rigged model + controller" }
    if k == "light" { return "Point light" }
    if k == "camera" { return "Fixed, orbit, follow, dolly" }
    if k == "spawn" { return "Where the player starts" }
    if k == "trigger" { return "Zone that fires an event" }
    if k == "audio" { return "Positional sound" }
    if k == "music" { return "Background track" }
    if k == "midi" { return "MIDI notes track" }
    if k == "text3d" { return "Text in the world" }
    if k == "empty" { return "Group / pivot" }
    if k == "swarm" { return "Stars, wire cubes, orbs" }
    if k == "knot" { return "Rosette chain of lit cubes" }
    if k == "panel" { return "Backdrop rectangle" }
    if k == "label" { return "Text" }
    if k == "button" { return "Clickable, runs an action" }
    if k == "image" { return "Picture (png / jpg)" }
    if k == "bar" { return "Health / progress" }
    return ""
}

# ── about / help / shortcuts / scripting ─────────────────────────────────────
fn modal_about() {
    win_begin("About LingDev", 520.0, 300.0)
    bind x = st_get("wx", 0.0)
    bind y = st_get("wy", 0.0)
    icon_draw("cube", x + 48.0, y + 44.0, 48.0, C_BLUE)
    ds_word(x + 96.0, y + 14.0, 16.0, "LINGDEV", ds_col("call"), true)
    g_text(x + 96.0, y + 40.0, 12.0, C_TEXT, "A scene, timeline and cutscene editor for games written in Ling.")
    g_text(x + 96.0, y + 58.0, 12.0, C_TEXT_DIM, "Runs on the desktop ling runtime and as a LingOS app (Raspberry Pi).")
    g_text(x + 16.0, y + 100.0, 12.0, C_TEXT, "Engine: ling " + st_get("ling_version", "2030.1") + "   runtime: " + plat_name())
    g_text(x + 16.0, y + 120.0, 12.0, C_TEXT, "Project: " + sel(st_get("proj_root", "") == "", "(none)", st_get("proj_root", "")))
    g_text_wrap(x + 16.0, y + 150.0, 488.0, 11.0, C_TEXT_DIM, "Scenes are .lscene text files; games export with `ling build` for Windows, Linux, macOS, the web, a Raspberry Pi kernel image or a LingOS app. Written in Ling, in the look of LingStationX.")
    if u_button("ab_ok", x + 16.0, y + 220.0, 100.0, 26.0, "Close", C_BLUE) { modal_close() }
}
fn modal_help() {
    win_begin("Help", 640.0, 480.0)
    bind x = st_get("wx", 0.0)
    bind y = st_get("wy", 0.0)
    bind lines = help_lines()
    bind off = u_scroll_y("help_sb", x + 8.0, y + 4.0, 624.0, 400.0, len(lines) * 18.0 + 20.0, st_get("help_scroll", 0.0))
    st_set("help_scroll", off)
    u_clip(x + 8.0, y + 4.0, 616.0, 400.0)
    bind i = 0.0
    while i < len(lines) {
        bind ln = list_get(lines, i)
        bind ly = y + 8.0 + i * 18.0 - off
        if str_starts_with(ln, "#") { ds_word(x + 16.0, ly, 10.0, str_slice(ln, 1.0, str_len(ln) - 1.0), ds_col("call"), true) }
        else { g_text_fit(x + 16.0, ly, 596.0, 12.0, C_TEXT, ln) }
        bind i = i + 1.0
    }
    u_unclip()
    if u_button("hp_ok", x + 16.0, y + 416.0, 100.0, 26.0, "Close", C_BLUE) { modal_close() }
}
fn modal_shortcuts() {
    win_begin("Shortcuts", 640.0, 480.0)
    bind x = st_get("wx", 0.0)
    bind y = st_get("wy", 0.0)
    bind lines = shortcut_lines()
    bind off = u_scroll_y("sc_sb", x + 8.0, y + 4.0, 624.0, 400.0, len(lines) * 18.0 + 20.0, st_get("sc_scroll", 0.0))
    st_set("sc_scroll", off)
    u_clip(x + 8.0, y + 4.0, 616.0, 400.0)
    bind i = 0.0
    while i < len(lines) {
        bind ln = list_get(lines, i)
        bind ly = y + 8.0 + i * 18.0 - off
        if str_starts_with(ln, "#") { ds_word(x + 16.0, ly, 10.0, str_slice(ln, 1.0, str_len(ln) - 1.0), ds_col("bind"), true) }
        else {
            bind p = str_split(ln, "|")
            g_text(x + 16.0, ly, 12.0, C_YELLOW, list_get(p, 0))
            if len(p) > 1.0 { g_text(x + 190.0, ly, 12.0, C_TEXT, list_get(p, 1)) }
        }
        bind i = i + 1.0
    }
    u_unclip()
    if u_button("sh_ok", x + 16.0, y + 416.0, 100.0, 26.0, "Close", C_BLUE) { modal_close() }
}
fn modal_scripting() {
    win_begin("Scripting Reference", 700.0, 520.0)
    bind x = st_get("wx", 0.0)
    bind y = st_get("wy", 0.0)
    bind lines = scripting_lines()
    bind off = u_scroll_y("scr_sb", x + 8.0, y + 4.0, 684.0, 440.0, len(lines) * 17.0 + 20.0, st_get("scr_scroll", 0.0))
    st_set("scr_scroll", off)
    u_clip(x + 8.0, y + 4.0, 676.0, 440.0)
    bind i = 0.0
    while i < len(lines) {
        bind ln = list_get(lines, i)
        bind ly = y + 8.0 + i * 17.0 - off
        if str_starts_with(ln, "#") { ds_word(x + 16.0, ly, 10.0, str_slice(ln, 1.0, str_len(ln) - 1.0), ds_col("ctl"), true) }
        else if str_starts_with(ln, "  ") { g_text_fit(x + 16.0, ly, 656.0, 11.0, C_GREEN, ln) }
        else { g_text_fit(x + 16.0, ly, 656.0, 12.0, C_TEXT, ln) }
        bind i = i + 1.0
    }
    u_unclip()
    if u_button("scr_ok", x + 16.0, y + 456.0, 100.0, 26.0, "Close", C_BLUE) { modal_close() }
}

← 返回索引