灵符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
โฮมเพจhttps://ling-lang.org
ใบอนุญาตApache-2.0 OR MIT
ผู้เขียนling-lin
READMEโค้ด / ไฟล์รุ่นกราฟการพึ่งพาแพ็กเกจที่พึ่งพาความปลอดภัยบิลด์การตั้งค่า

โค้ด / ไฟล์ (136)

ling/shell.ling
# shell.ling - menu bar, popup lists, context menus, the view-tab row, toolbar and status bar.
# Menu item format: "Label|action|Shortcut|icon"  ("-" = separator, a label ending in " >" opens a submenu "sub:<name>")

fn menu_titles() { return ["File", "Edit", "View", "Scene", "Play", "Build", "Help"] }
fn menu_color(i) {
    if i < 0.5 { return C_MENU_FILE }
    if i < 1.5 { return C_MENU_EDIT }
    if i < 2.5 { return C_MENU_VIEW }
    if i < 3.5 { return C_MENU_SCENE }
    if i < 4.5 { return C_MENU_PLAY }
    if i < 5.5 { return C_MENU_BUILD }
    return C_MENU_HELP
}

fn menu_def(m) {
    if m == 0.0 {
        return ["New Project...|project.new|Ctrl+Shift+N|file", "Open Project...|project.open|Ctrl+Shift+O|folder", "Open Recent >|sub:recent||refresh", "Save Project|project.save||save", "-", "New Scene >|sub:newscene||scene", "Open Scene...|scene.open|Ctrl+O|folder", "Save Scene|scene.save|Ctrl+S|save", "Save Scene As...|scene.save_as|Ctrl+Shift+S|save", "Reload Scene|scene.reload||refresh", "-", "Import Asset...|asset.import|Ctrl+I|layers", "Import Ling Game (Soul Symphony)...|project.import_game||film", "Open Demo: Ling Agent (FPS)|demo.agent||actor", "Open Demo: Ling Breaker (3-D breakout)|demo.breaker||cube", "Open Demo: Moko's Picture Book (kawaii platformer)|demo.story||actor", "Open Soul Symphony (2019 original)|demo.ssz||film", "Open Project Folder|project.reveal||folder", "-", "Settings...|app.settings|Ctrl+P|gear", "Quit|app.quit|Ctrl+Q|x"]
    }
    if m == 1.0 {
        return ["Undo|edit.undo|Ctrl+Z|undo", "Redo|edit.redo|Ctrl+Y|redo", "-", "Cut|edit.cut|Ctrl+X|x", "Copy|edit.copy|Ctrl+C|copy", "Paste|edit.paste|Ctrl+V|layers", "Duplicate|edit.dup|Ctrl+D|copy", "Delete|edit.delete|Del|trash", "-", "Select All|edit.select_all|Ctrl+A|select", "Deselect|edit.deselect|Esc|x", "Focus Selected|view.focus|F|eye", "-", "Rename...|node.rename|F2|edit", "Group Selected|node.group|Ctrl+G|layers", "Unparent|node.unparent|Shift+P|arrow_up", "-", "Snap to Grid|edit.snap||grid", "Auto Key|tl.autokey|K|key"]
    }
    if m == 2.0 {
        return ["Hierarchy|view.hier||bars", "Assets|view.assets||folder", "Inspector|view.inspector||info", "Dock (Timeline / Cutscene / Script / Build)|view.dock||clock", "-", "Wireframe|view.wire||cube", "Solid|view.solid||cube", "Grid|view.grid||grid", "Gizmos|view.gizmos||axes", "Scene Camera Preview|view.cam_preview||camera", "-", "Reset Camera|view.reset_cam|Home|orbit", "Top|view.top|Numpad7|chev_u", "Front|view.front|Numpad1|chev_r", "Side|view.side|Numpad3|chev_d", "Zoom to Scene|view.zoom_all||search"]
    }
    if m == 3.0 {
        return ["Add 3D Object >|sub:add3d||cube", "Add 2D Control >|sub:add2d||panel", "Add FPS >|sub:addfps||actor", "Add Camera|node.add:camera|Shift+C|camera", "Add Light|node.add:light|Shift+L|light", "Add Actor (rig)|node.add:actor|Shift+A|actor", "Add Spawn Point|node.add:spawn||spawn", "Add Trigger Zone|node.add:trigger|Shift+T|trigger", "Add Audio Source|node.add:audio||audio", "Add Music|node.add:music||audio", "Add MIDI Track|node.add:midi||midi", "Add 3D Text|node.add:text3d||text", "Add Empty / Group|node.add:empty|Shift+E|dot", "-", "Scene Settings...|scene.settings||gear", "Set as Start Scene|scene.set_start||play", "Add Scene to Project|scene.add_to_project||plus", "Convert to Cutscene|scene.to_cutscene||film"]
    }
    if m == 4.0 {
        return ["Play Project|play.project|F5|play", "Play This Scene|play.scene|F6|play", "Stop|play.stop|Esc|stop", "-", "Playhead Play / Pause|tl.play|Space|play", "Go to Start|tl.home|Home|skip_start", "Go to End|tl.end|End|skip_end", "Loop Timeline|tl.loop|L|loop", "Step Frame Back|tl.prev|,|chev_l", "Step Frame Forward|tl.next|.|chev_r", "-", "Insert Key (selected)|tl.key|K|key", "Delete Keys at Playhead|tl.delkey|Shift+K|key_off", "Clear Keys of Selected|tl.clearkeys||trash"]
    }
    if m == 5.0 {
        return ["Build & Export...|build.panel|Ctrl+B|build", "Generate Game Sources|build.gen||script", "Open Build Folder|build.reveal||folder", "-", "Windows x86-64 (ling build --platform win)|build.target:win||window", "Linux x86-64 (--platform lin)|build.target:lin||terminal", "macOS (--platform mac)|build.target:mac||window", "Web (--platform web)|build.target:web||globe", "Raspberry Pi 5 / LingOS kernel (--platform rpi)|build.target:rpi||chip", "LingOS app (--platform lingos)|build.target:lingos||chip", "-", "Run Last Build|build.run||play"]
    }
    return ["About LingDev|help.about||info", "Shortcuts|help.shortcuts|F1|bars", "Help|help.help||help", "Rebuild the Ling Agent demo|demo.agent_rebuild||refresh", "Rebuild the Ling Breaker demo|demo.breaker_rebuild||refresh", "Rebuild Moko's Picture Book|demo.story_rebuild||refresh", "Rebuild Soul Symphony (2019) scenes|demo.ssz_rebuild||refresh", "Scripting Reference (hooks)|help.scripting||script", "-", "Open LingDev Folder|help.reveal||folder"]
}

fn menu_sub_def(name) {
    if name == "recent" { return st_get("recent_menu", ["(empty)|none||"]) }
    if name == "newscene" { return ["3D Scene / Level|scene.new:3d||cube", "2D Menu / HUD|scene.new:2d||panel", "Cutscene|scene.new:cutscene||film"] }
    if name == "add3d" {
        return ["Box|node.add:box||cube", "Sphere|node.add:sphere||sphere", "Cylinder|node.add:cylinder||sphere", "Cone|node.add:cone||sphere", "Capsule|node.add:capsule||sphere", "Torus|node.add:torus||sphere", "Plane / Floor|node.add:plane||grid", "Model (glb / gltf / lmesh)|node.add:model||layers"]
    }
    if name == "add2d" {
        return ["Panel|node.add:panel||panel", "Label|node.add:label||text", "Button|node.add:button||button", "Image|node.add:image||image", "Bar (health / progress)|node.add:bar||bar"]
    }
    if name == "addfps" {
        return ["Guard (patrols, shoots)|fps.add:guard||actor", "Pickup: weapon / ammo / armour|fps.add:pickup||plus", "Sliding door|fps.add:door||panel", "Alarm console (destructible)|fps.add:console||chip", "Extraction point|fps.add:exit||spawn", "Make this scene an FPS stage|fps.make_stage||actor"]
    }
    if name == "nodecolor" {
        return ["Red|node.color:0||", "Orange|node.color:1||", "Yellow|node.color:2||", "Green|node.color:3||", "Blue|node.color:4||", "Indigo|node.color:5||", "Violet|node.color:6||", "White|node.color:7||", "Grey|node.color:8||"]
    }
    return []
}

# 1 checked / 0 unchecked / -1 not checkable
fn menu_check(action) {
    if action == "view.hier" { return st_get("show_hier", 1.0) }
    if action == "view.assets" { return st_get("show_assets", 1.0) }
    if action == "view.inspector" { return st_get("show_insp", 1.0) }
    if action == "view.dock" { return st_get("show_dock", 1.0) }
    if action == "view.wire" { return sel(st_get("view_solid", 1.0) < 0.5, 1.0, 0.0) }
    if action == "view.solid" { return st_get("view_solid", 1.0) }
    if action == "view.grid" { return st_get("view_grid", 1.0) }
    if action == "view.gizmos" { return st_get("view_gizmos", 1.0) }
    if action == "view.cam_preview" { return st_get("view_campre", 0.0) }
    if action == "edit.snap" { return st_get("snap_on", 1.0) }
    if action == "tl.autokey" { return st_get("autokey", 0.0) }
    if action == "tl.loop" { return st_get("tl_loop", 1.0) }
    return 0.0 - 1.0
}

fn menu_item_h(s) {
    if s == "-" { return 7.0 }
    return 22.0
}
fn menu_field(item, k) {
    bind parts = str_split(item, "|")
    if k >= len(parts) { return "" }
    return list_get(parts, k)
}

fn menu_bar() {
    bind W = get_width()
    g_rect(0.0, 0.0, W, MENU_H, C_BG_PANEL)
    u_hsep(0.0, MENU_H - 1.0, W)
    icon_draw("cube", 13.0, 12.0, 13.0, C_BLUE)
    bind titles = menu_titles()
    bind x = 26.0
    bind open = st_get("menu_open", 0.0 - 1.0)
    bind i = 0.0
    while i < len(titles) {
        bind t = list_get(titles, i)
        bind w = g_text_w(12.0, t) + 18.0
        bind hot = u_hot(x, 0.0, w, MENU_H - 1.0)
        bind col = menu_color(i)
        if open == i || hot { g_rrect_a(x, 2.0, w, MENU_H - 5.0, 3.0, col, 0.28) }
        g_text(x + 9.0, 5.0, 12.0, sel(open == i, C_TEXT_STRONG, col), t)
        if hot && u_pressed() {
            if open == i {
                st_set("menu_open", 0.0 - 1.0)
                st_set("menu_sub", "")
            } else {
                st_set("menu_open", i)
                st_set("menu_x", x)
                st_set("menu_sub", "")
            }
            st_set("menu_click_eat", 1.0)
        } else if hot && open >= 0.0 && open != i {
            st_set("menu_open", i)
            st_set("menu_x", x)
            st_set("menu_sub", "")
        }
        bind x = x + w + 2.0
        bind i = i + 1.0
    }
    bind p = st_get("proj_name", "")
    bind sname = sc_get("name", "")
    bind title = "LingDev"
    if p != "" { bind title = title + "  -  " + p }
    if sname != "" { bind title = title + "  /  " + sname + sel(st_get("dirty", 0.0) > 0.5, " *", "") }
    g_text_r(W - 10.0, 5.0, 12.0, C_TEXT_DIM, title)
    if st_get("playing", 0.0) > 0.5 {
        g_rrect(W - 300.0, 3.0, 86.0, 18.0, 3.0, C_GREEN)
        g_text_c(W - 257.0, 6.0, 11.0, C_BG_DEEP, "PLAYING")
    }
}

# draws a popup list at (x,y); returns the clicked action or ""
fn popup_list(items, x, y, mcol, level) {
    bind w = 250.0
    bind i = 0.0
    while i < len(items) {
        bind it = list_get(items, i)
        if it != "-" { bind w = max(w, g_text_w(12.0, menu_field(it, 0)) + g_text_w(11.0, menu_field(it, 2)) + 70.0) }
        bind i = i + 1.0
    }
    bind w = min(w, 420.0)
    bind h = 8.0
    bind i = 0.0
    while i < len(items) {
        bind h = h + menu_item_h(list_get(items, i))
        bind i = i + 1.0
    }
    bind W = get_width()
    bind H = get_height()
    if x + w > W { bind x = W - w - 2.0 }
    bind full = h
    bind vh = min(h, H - 8.0)
    if y + vh > H - 2.0 { bind y = H - vh - 2.0 }
    if y < 2.0 { bind y = 2.0 }
    bind okey = "popup_off" + num_str(level)
    bind off = 0.0
    if full > vh {
        bind off = clampf(st_get(okey, 0.0), 0.0, full - vh)
        if u_in(x, y, w, vh) && u_scroll() != 0.0 {
            bind off = clampf(off - u_scroll() * 36.0, 0.0, full - vh)
            st_set("wheel_used", 1.0)
        }
        st_set(okey, off)
    } else { st_set(okey, 0.0) }
    px_rrect(x + 2.0, y + 3.0, w, vh, 6.0, 0.0, 0.35)
    g_rrect(x, y, w, vh, 5.0, C_POPUP)
    g_rframe(x, y, w, vh, 5.0, C_POPUP_LINE)
    if u_in(x, y, w, vh) { st_set("popup_hit", 1.0) }
    bind out = ""
    bind cy = y + 4.0 - off
    u_clip(x, y, w, vh)
    bind i = 0.0
    while i < len(items) {
        bind it = list_get(items, i)
        bind ih = menu_item_h(it)
        bind vis = cy + ih >= y && cy <= y + vh
        if vis && it == "-" {
            g_rect(x + 8.0, cy + 3.0, w - 16.0, 1.0, C_LINE)
        } else if vis && it != "-" {
            bind label = menu_field(it, 0)
            bind act = menu_field(it, 1)
            bind shortcut = menu_field(it, 2)
            bind icon = menu_field(it, 3)
            bind hot = u_hot(x + 2.0, cy, w - 4.0, ih) && u_my() >= y && u_my() < y + vh
            if hot { g_rrect_a(x + 3.0, cy, w - 6.0, ih, 3.0, mcol, 0.30) }
            bind chk = menu_check(act)
            if chk > 0.5 { g_rrect(x + 9.0, cy + 6.0, 10.0, 10.0, 2.0, mcol) }
            else if chk > 0.0 - 0.5 { g_rframe(x + 9.0, cy + 6.0, 10.0, 10.0, 2.0, C_TEXT_DIM) }
            else if icon != "" { icon_draw(icon, x + 14.0, cy + ih * 0.5, 11.0, mcol) }
            bind is_sub = str_ends_with(label, " >")
            bind shown = label
            if is_sub { bind shown = str_slice(label, 0.0, str_len(label) - 2.0) }
            g_text_fit(x + 28.0, cy + 4.0, w - 36.0 - g_text_w(11.0, shortcut), 12.0, C_TEXT, shown)
            if is_sub { icon_draw("chev_r", x + w - 12.0, cy + ih * 0.5, 9.0, C_TEXT_DIM) }
            else if shortcut != "" { g_text_r(x + w - 10.0, cy + 5.0, 11.0, C_TEXT_DIM, shortcut) }
            if hot {
                if is_sub {
                    st_set("menu_sub", str_slice(act, 4.0, str_len(act) - 4.0))
                    st_set("menu_sub_x", x + w - 2.0)
                    st_set("menu_sub_y", cy - 4.0)
                } else if level < 0.5 {
                    st_set("menu_sub", "")
                }
                if u_pressed() && !is_sub && act != "none" { bind out = act }
            }
        }
        bind cy = cy + ih
        bind i = i + 1.0
    }
    u_unclip()
    if full > vh {
        bind th = max(24.0, vh * vh / full)
        bind ty = y + (vh - th) * off / (full - vh)
        g_rrect_a(x + w - 6.0, ty, 4.0, th, 2.0, C_TEXT_DIM, 0.6)
    }
    return out
}

# the open menu; call last each frame. returns the chosen action or ""
fn menu_overlay() {
    bind open = st_get("menu_open", 0.0 - 1.0)
    if open < 0.0 { return "" }
    st_set("blk", 0.0)
    st_set("popup_hit", 0.0)
    bind mcol = menu_color(open)
    bind x = st_get("menu_x", 0.0)
    bind act = popup_list(menu_def(open), x, MENU_H - 2.0, mcol, 0.0)
    bind sub = st_get("menu_sub", "")
    if sub != "" {
        bind sact = popup_list(menu_sub_def(sub), st_get("menu_sub_x", 0.0), st_get("menu_sub_y", 0.0), mcol, 1.0)
        if sact != "" { bind act = sact }
    }
    if u_pressed() && act == "" && st_get("menu_click_eat", 0.0) < 0.5 {
        if u_my() >= MENU_H && st_get("popup_hit", 0.0) < 0.5 {
            st_set("menu_open", 0.0 - 1.0)
            st_set("menu_sub", "")
        }
    }
    if key_pressed("escape") {
        st_set("menu_open", 0.0 - 1.0)
        st_set("menu_sub", "")
    }
    st_set("menu_click_eat", 0.0)
    if act != "" {
        st_set("menu_open", 0.0 - 1.0)
        st_set("menu_sub", "")
    }
    return act
}

# ── context menus ────────────────────────────────────────────────────────────
fn ctx_open(kind, arg, x, y) {
    st_set("ctx_kind", kind)
    st_set("ctx_arg", arg)
    st_set("ctx_x", x)
    st_set("ctx_y", y)
    st_set("ctx_open", 1.0)
    st_set("ctx_eat", 1.0)
    st_set("menu_sub", "")
}
fn ctx_items(kind) {
    if kind == "node" {
        bind id = to_number(st_get("ctx_arg", "0"))
        bind i = nd_find(id)
        bind vis = sel(i >= 0.0 && nd_get(i, "vis") > 0.5, "Hide", "Show")
        bind lk = sel(i >= 0.0 && nd_get(i, "lock") > 0.5, "Unlock", "Lock")
        return ["Rename...|node.rename|F2|edit", "Duplicate|edit.dup|Ctrl+D|copy", "Focus|view.focus|F|eye", "Colour >|sub:nodecolor||layers", "-", vis + "|node.togglevis|H|eye", lk + "|node.togglelock||lock", "Insert Key|tl.key|K|key", "-", "Add Child >|sub:add3d||cube", "Group|node.group|Ctrl+G|layers", "Unparent|node.unparent||arrow_up", "Move Up|node.up||arrow_up", "Move Down|node.down||arrow_down", "-", "Delete|edit.delete|Del|trash"]
    }
    if kind == "viewport" {
        return ["Add 3D Object >|sub:add3d||cube", "Add Camera here|node.add_here:camera||camera", "Add Light here|node.add_here:light||light", "Add Spawn here|node.add_here:spawn||spawn", "Add Trigger here|node.add_here:trigger||trigger", "-", "Paste|edit.paste|Ctrl+V|layers", "Deselect|edit.deselect||x", "-", "Reset Camera|view.reset_cam||orbit"]
    }
    if kind == "canvas" {
        return ["Add 2D Control >|sub:add2d||panel", "-", "Paste|edit.paste|Ctrl+V|layers", "Deselect|edit.deselect||x"]
    }
    if kind == "asset" {
        bind p = st_get("ctx_arg", "")
        bind e = p_ext(p)
        bind base = ["Add to Scene|asset.add||plus", "Open / Edit|asset.open||edit", "Show in Explorer|asset.reveal||folder", "Copy Path|asset.copypath||copy"]
        if e == "lscene" || e == "lcut" { return ["Open Scene|asset.openscene||scene", "Set as Start Scene|asset.setstart||play", "Show in Explorer|asset.reveal||folder", "-", "Delete...|asset.delete||trash"] }
        if e == "ling" { return ["Open in Script Panel|asset.openscript||script", "Attach to Selected Node|asset.attach||script", "Show in Explorer|asset.reveal||folder"] }
        return list_push(base, "Convert with ling convert (info)|asset.convertinfo||info")
    }
    if kind == "key" {
        return ["Ease: Linear|key.ease:linear||", "Ease: Smooth (cubic in/out)|key.ease:cubic_in_out||", "Ease: Ease In|key.ease:quad_in||", "Ease: Ease Out|key.ease:quad_out||", "Ease: Bounce|key.ease:bounce_out||", "Ease: Elastic|key.ease:elastic_out||", "Ease: Back (overshoot)|key.ease:back_out||", "Ease: Step|key.ease:step||", "-", "Delete Key|key.delete|Del|trash"]
    }
    if kind == "clip" {
        return ["Edit...|clip.edit||edit", "Duplicate|clip.dup||copy", "Split at Playhead|clip.split||x", "-", "Delete|clip.delete|Del|trash"]
    }
    if kind == "track" {
        bind tr = st_get("ctx_arg", "")
        if tr == "camera" { return ["Add Camera Move (key)|clip.new:camera:move||camera", "Add Camera Cut|clip.new:camera:cut||camera", "Add Orbit|clip.new:camera:orbit||orbit", "Add Follow Node|clip.new:camera:follow||actor"] }
        if tr == "dialog" { return ["Add Line (subtitle + VO)|clip.new:dialog:say||text", "Add Title Card|clip.new:dialog:title||text"] }
        if tr == "anim" { return ["Play Model Clip|clip.new:anim:clip||layers", "Move Node To|clip.new:anim:moveto||move", "Show / Hide Node|clip.new:anim:vis||eye"] }
        if tr == "audio" { return ["Play Sound|clip.new:audio:play||audio", "Play Music|clip.new:audio:music||audio", "Stop Music|clip.new:audio:stop||stop"] }
        if tr == "fx" { return ["Fade In|clip.new:fx:fade_in||eye", "Fade Out|clip.new:fx:fade_out||eye_off", "Letterbox|clip.new:fx:letterbox||film", "Flash|clip.new:fx:flash||light", "Shake|clip.new:fx:shake||move"] }
        return ["Go To Scene|clip.new:event:scene||scene", "Call Script Hook|clip.new:event:call||script", "Wait for Input|clip.new:event:wait||select", "End Cutscene|clip.new:event:end||stop"]
    }
    return []
}
fn ctx_overlay() {
    if st_get("ctx_open", 0.0) < 0.5 { return "" }
    st_set("blk", 0.0)
    st_set("popup_hit", 0.0)
    bind act = popup_list(ctx_items(st_get("ctx_kind", "")), st_get("ctx_x", 0.0), st_get("ctx_y", 0.0), C_BLUE, 0.0)
    bind sub = st_get("menu_sub", "")
    if sub != "" {
        bind sact = popup_list(menu_sub_def(sub), st_get("menu_sub_x", 0.0), st_get("menu_sub_y", 0.0), C_BLUE, 1.0)
        if sact != "" { bind act = sact }
    }
    if act != "" {
        st_set("ctx_open", 0.0)
        st_set("menu_sub", "")
        do_ctx_action(act)
        return act
    }
    if (u_pressed() || u_rpressed()) && st_get("ctx_eat", 0.0) < 0.5 && st_get("popup_hit", 0.0) < 0.5 {
        st_set("ctx_open", 0.0)
        st_set("menu_sub", "")
    }
    if key_pressed("escape") { st_set("ctx_open", 0.0) }
    st_set("ctx_eat", 0.0)
    return ""
}

# ── view tabs row: panel toggles + dock editor tabs ─────────────────────────
fn view_tabs() {
    bind W = get_width()
    bind y = MENU_H
    g_rect(0.0, y, W, TABS_H, C_BG_PANEL)
    u_hsep(0.0, y + TABS_H - 1.0, W)
    g_text(10.0, y + 8.0, 12.0, C_TEXT_DIM, "Panels")
    bind x = 56.0
    bind names = ["Hierarchy", "Assets", "Inspector", "Dock"]
    bind keys = ["show_hier", "show_assets", "show_insp", "show_dock"]
    bind i = 0.0
    while i < 4.0 {
        bind n = list_get(names, i)
        bind w = g_text_w(12.0, n) + 18.0
        if u_tab("v_" + n, x, y + 3.0, w, 22.0, n, st_get(list_get(keys, i), 1.0) > 0.5, 0.0) { st_set(list_get(keys, i), 1.0 - st_get(list_get(keys, i), 1.0)) }
        bind x = x + w + 4.0
        bind i = i + 1.0
    }
    u_vsep(x + 4.0, y + 6.0, 16.0)
    bind x = x + 16.0
    g_text(x, y + 8.0, 12.0, C_TEXT_DIM, "Dock")
    bind x = x + 40.0
    bind dn = ["Timeline", "Cutscene", "Script", "Build", "Console"]
    bind cols = [C_RED, C_YELLOW, C_GREEN, C_INDIGO, C_VIOLET]
    bind i = 0.0
    while i < 5.0 {
        bind n = list_get(dn, i)
        bind w = g_text_w(12.0, n) + 22.0
        if u_hot(x, y + 3.0, w, 22.0) { u_tip(n + " (" + list_get(["F7", "F8", "F9", "F10", "F11"], i) + ")") }
        if u_tab("dtab" + num_str(i), x, y + 3.0, w, 22.0, n, st_get("dock_tab", 0.0) == i, list_get(cols, i)) {
            st_set("dock_tab", i)
            st_set("show_dock", 1.0)
        }
        bind x = x + w + 3.0
        bind i = i + 1.0
    }
    # scene kind + quick scene switcher on the right
    bind k = sc_get("kind", "3d")
    bind klabel = sel(k == "2d", "2D MENU / HUD", sel(k == "cutscene", "CUTSCENE", "3D SCENE"))
    bind kw = ds_word_w(11.0, klabel)
    ds_word(W - kw - 12.0, y + 8.0, 11.0, klabel, sel(k == "2d", ds_col("asm"), sel(k == "cutscene", ds_col("bind"), ds_col("call"))), false)
    bind scenes = sl_to_list("proj_scenes")
    if len(scenes) > 0.5 {
        bind names2 = []
        bind cur = 0.0 - 1.0
        bind s = 0.0
        while s < len(scenes) {
            bind sp = list_get(scenes, s)
            bind names2 = list_push(names2, p_stem(sp))
            if proj_abs(sp) == sc_get("path", "") { bind cur = s }
            bind s = s + 1.0
        }
        bind pick = u_combo("scene_pick", W - kw - 200.0, y + 3.0, 176.0, 22.0, names2, max(0.0, cur))
        if pick >= 0.0 && pick != cur { open_scene(proj_abs(list_get(scenes, pick))) }
    }
}

# ── toolbar: transport, time, tools, view toggles ───────────────────────────
fn toolbar() {
    bind W = get_width()
    bind y = MENU_H + TABS_H
    g_rect(0.0, y, W, TOOL_H, C_BG_PANEL)
    u_hsep(0.0, y + TOOL_H - 1.0, W)
    bind by = y + 7.0
    bind playing = st_get("tl_playing", 0.0) > 0.5
    if u_hot(10.0, by, 32.0, 26.0) { u_tip(sel(playing, "Pause the playhead (Space)", "Play the timeline from the playhead (Space)")) }
    if u_hot(46.0, by, 32.0, 26.0) { u_tip("Stop and rewind (Home)") }
    if u_hot(82.0, by, 32.0, 26.0) { u_tip("Loop the timeline (L)") }
    if u_icon_button("t_play", 10.0, by, 32.0, 26.0, sel(playing, "pause", "play"), 15.0, sel(playing, C_YELLOW, C_GREEN), playing) { do_action("tl.play") }
    if u_icon_button("t_stop", 46.0, by, 32.0, 26.0, "stop", 14.0, C_TEXT_STRONG, false) { do_action("tl.stop") }
    if u_icon_button("t_loop", 82.0, by, 32.0, 26.0, "loop", 15.0, sel(st_get("tl_loop", 1.0) > 0.5, C_BLUE, C_TEXT_DIM), st_get("tl_loop", 1.0) > 0.5) { do_action("tl.loop") }
    # time readout
    bind t = st_get("tl_t", 0.0)
    bind fps = sc_get("fps", 30.0)
    g_rrect(122.0, by, 118.0, 26.0, 3.0, C_BG_DEEP)
    g_text_c(181.0, by + 6.0, 15.0, C_GREEN, fmt_time(t))
    g_text(246.0, by + 7.0, 12.0, C_TEXT_DIM, "f " + num_str(floor(t * fps + 0.0001)))
    bind x = 300.0
    u_vsep(x, by + 3.0, 20.0)
    bind x = x + 10.0
    # game play
    bind gp = st_get("playing", 0.0) > 0.5
    if u_hot(x, by, 70.0, 26.0) { u_tip(sel(gp, "Stop playing (Esc)", "Play the project from its start scene (F5); F6 plays this scene")) }
    if u_button("t_game", x, by, 70.0, 26.0, sel(gp, "Stop", "Play"), sel(gp, C_RED, C_GREEN)) { do_action(sel(gp, "play.stop", "play.project")) }
    bind x = x + 78.0
    u_vsep(x, by + 3.0, 20.0)
    bind x = x + 10.0
    # tools
    bind tools = ["select", "move", "rotate", "scale"]
    bind tips = ["Select (Q)", "Move (W)", "Rotate (E)", "Scale (R)"]
    bind cur = st_get("tool", "move")
    bind i = 0.0
    while i < 4.0 {
        bind tn = list_get(tools, i)
        if u_hot(x, by, 30.0, 26.0) { u_tip(list_get(tips, i)) }
        if u_icon_button("tool_" + tn, x, by, 30.0, 26.0, tn, 14.0, sel(cur == tn, C_TEXT_STRONG, C_TEXT_DIM), cur == tn) { st_set("tool", tn) }
        bind x = x + 33.0
        bind i = i + 1.0
    }
    bind x = x + 6.0
    u_vsep(x, by + 3.0, 20.0)
    bind x = x + 10.0
    if u_hot(x, by, 30.0, 26.0) { u_tip("Wireframe / solid (Z)") }
    if u_icon_button("t_solid", x, by, 30.0, 26.0, "cube", 14.0, sel(st_get("view_solid", 1.0) > 0.5, C_TEXT_STRONG, C_TEXT_DIM), st_get("view_solid", 1.0) > 0.5) { do_action("view.solid") }
    bind x = x + 33.0
    if u_hot(x, by, 30.0, 26.0) { u_tip("Grid (G)") }
    if u_icon_button("t_grid", x, by, 30.0, 26.0, "grid", 14.0, sel(st_get("view_grid", 1.0) > 0.5, C_TEXT_STRONG, C_TEXT_DIM), st_get("view_grid", 1.0) > 0.5) { do_action("view.grid") }
    bind x = x + 33.0
    if u_hot(x, by, 30.0, 26.0) { u_tip("Snap moves to the grid (Ctrl drag = free)") }
    if u_icon_button("t_snap", x, by, 30.0, 26.0, "axes", 14.0, sel(st_get("snap_on", 1.0) > 0.5, C_TEXT_STRONG, C_TEXT_DIM), st_get("snap_on", 1.0) > 0.5) { do_action("edit.snap") }
    bind x = x + 33.0
    if u_hot(x, by, 30.0, 26.0) { u_tip("Auto key: edits at the playhead write keyframes (K inserts one by hand)") }
    if u_icon_button("t_autokey", x, by, 30.0, 26.0, "key", 14.0, sel(st_get("autokey", 0.0) > 0.5, C_RED, C_TEXT_DIM), st_get("autokey", 0.0) > 0.5) { do_action("tl.autokey") }
    bind x = x + 33.0
    if u_hot(x, by, 30.0, 26.0) { u_tip("Look through the scene camera") }
    if u_icon_button("t_campre", x, by, 30.0, 26.0, "camera", 14.0, sel(st_get("view_campre", 0.0) > 0.5, C_BLUE, C_TEXT_DIM), st_get("view_campre", 0.0) > 0.5) { do_action("view.cam_preview") }
    bind x = x + 40.0
    u_vsep(x, by + 3.0, 20.0)
    bind x = x + 10.0
    # add menu button
    if u_button("t_add", x, by, 60.0, 26.0, "+ Add", C_BLUE) { ctx_open("viewport", "", x, by + 28.0) }
    bind x = x + 70.0
    # status text
    bind cur_status = st_get("status", "Ready")
    if cur_status != st_get("status_seen", "") {
        st_set("status_seen", cur_status)
        st_set("status_t", st_get("now", 0.0))
    }
    bind age = st_get("now", 0.0) - st_get("status_t", 0.0)
    bind fade = clampf((age - 5.0) / 6.0, 0.0, 1.0)
    g_text_fit(x + 8.0, by + 7.0, max(40.0, W - x - 20.0), 12.0, c_mix(C_TEXT, 4408131.0, fade * 0.8), cur_status)
}

# ── status bar (bottom) ──────────────────────────────────────────────────────
fn status_bar() {
    bind W = get_width()
    bind H = get_height()
    bind y = H - STATUS_H
    g_rect(0.0, y, W, STATUS_H, C_BG_PANEL)
    u_hsep(0.0, y, W)
    bind sid = sel_main()
    bind left = "nodes " + num_str(nd_count()) + "   keys " + num_str(key_count()) + "   clips " + num_str(clip_count())
    if sid > 0.0 {
        bind i = nd_find(sid)
        if i >= 0.0 { bind left = left + "   selected: " + nd_get(i, "name") + " (" + nd_get(i, "kind") + ")" }
    }
    g_text(10.0, y + 5.0, 11.0, C_TEXT_DIM, left)
    bind ms = st_get("frame_ms", 0.0)
    g_text_r(W - 10.0, y + 5.0, 11.0, C_TEXT_DIM, plat_name() + "   " + num_str(round(ms)) + " ms/frame   " + num_str(get_width()) + "x" + num_str(get_height()))
}

# dragged asset ghost + tooltips
fn draw_floating() {
    if st_get("drag_path", "") != "" {
        bind dx = st_get("drag_x", 0.0)
        bind dy = st_get("drag_y", 0.0)
        if u_down() && (abs(u_mx() - dx) > 5.0 || abs(u_my() - dy) > 5.0) { st_set("drag_live", 1.0) }
        if st_get("drag_live", 0.0) > 0.5 && u_down() {
            bind nm = p_base(st_get("drag_path", ""))
            bind w = g_text_w(12.0, nm) + 24.0
            g_rrect_a(u_mx() + 12.0, u_my() + 6.0, w, 22.0, 4.0, C_SEL, 0.9)
            g_text(u_mx() + 22.0, u_my() + 11.0, 12.0, C_TEXT_STRONG, nm)
        }
        if u_released() || (!u_down() && st_get("drag_live", 0.0) < 0.5) {
            if st_get("drag_live", 0.0) > 0.5 { st_set("drop_path", st_get("drag_path", "")) }
            st_set("drag_path", "")
            st_set("drag_live", 0.0)
        }
    }
    bind tip = st_get("tip", "")
    if tip != "" {
        bind w = min(g_text_w(11.0, tip) + 14.0, get_width() - 20.0)
        bind tx = min(u_mx() + 14.0, get_width() - w - 4.0)
        bind ty = u_my() + 18.0
        if ty + 22.0 > get_height() { bind ty = u_my() - 26.0 }
        g_rrect(tx, ty, w, 20.0, 4.0, C_POPUP)
        g_rframe(tx, ty, w, 20.0, 4.0, C_POPUP_LINE)
        g_text_fit(tx + 7.0, ty + 4.0, w - 14.0, 11.0, C_TEXT, tip)
    }
}

← กลับไปดัชนี