灵符lingfu

lingdev

↓ 0 downloads

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
Keywords game-engine, editor, 3d, 2d, cutscene, timeline, ling, lingos, raspberry-pi
lingfu install lingdev
LicenseApache-2.0 OR MIT
Authorsling-lin
READMECode / FilesVersionsDependency GraphDependentsSecurityBuildsSettings

Code / Files (136)

ling/actions.ling
# actions.ling - every command the menus, shortcuts, buttons and context menus run goes through do_action.

fn do_action(a) {
    if a == "" || a == "none" { return 0.0 }
    # ── project ──
    if a == "project.new" {
        st_set("np_first", 0.0)
        st_set("np_dir", p_join(st_get("app_root", "."), "projects"))
        modal_open("newproject")
    }
    else if a == "project.new_dir" { st_set("np_dir", st_get("pick_path", ""))  modal_open("newproject") }
    else if a == "project.create" {
        bind nm = str_trim(st_get("np_name", "MyGame"))
        if nm == "" { bind nm = "MyGame" }
        proj_create(st_get("np_dir", p_join(st_get("app_root", "."), "projects")), nm, st_get("np_tpl", 1.0), st_get("np_w", 1280.0), st_get("np_h", 720.0))
    }
    else if a == "project.open" { picker_open("open", "Open a LingDev project (lingdev.toml)", "toml", "project.open_go", p_join(st_get("app_root", "."), "projects"), "") }
    else if a == "project.open_go" { project_open_path(st_get("pick_path", "")) }
    else if str_starts_with(a, "project.recent:") {
        bind i = to_number(str_slice(a, 15.0, 10.0))
        if i < st_llen("recent") { project_open_path(st_lget("recent", i)) }
    }
    else if a == "project.save" { proj_save()  status("Project saved") }
    else if a == "project.reveal" { if proj_root() != "" { plat_reveal(proj_root()) } }
    else if a == "project.import_game" { picker_open("folder", "Folder of the Ling game to import (e.g. soul-symphony-ling)", "", "project.import_go", p_dir(st_get("app_root", ".")), "") }
    else if a == "project.import_go" { import_ling_game(st_get("pick_path", "")) }
    # ── scenes ──
    else if str_starts_with(a, "scene.new:") { guard_unsaved("scene.new_go:" + str_slice(a, 10.0, 20.0)) }
    else if str_starts_with(a, "scene.new_go:") { new_scene(str_slice(a, 13.0, 20.0)) }
    else if a == "scene.open" { guard_unsaved("scene.open_go") }
    else if a == "scene.open_go" { picker_open("open", "Open scene", "lscene,lcut", "scene.open_file", p_join(proj_root(), "scenes"), "") }
    else if a == "scene.open_file" { open_scene(st_get("pick_path", "")) }
    else if a == "scene.open_path" { open_scene(st_get("ctx_arg", "")) }
    else if a == "scene.save" {
        if sc_get("path", "") == "" { do_action("scene.save_as") } else { save_scene(sc_get("path", "")) }
    }
    else if a == "scene.save_as" { picker_open("save", "Save scene as", "lscene", "scene.save_file", p_join(proj_root(), "scenes"), str_lower(str_replace(sc_get("name", "scene"), " ", "_")) + ".lscene") }
    else if a == "scene.save_file" { save_scene(st_get("pick_path", "")) }
    else if a == "scene.reload" { if sc_get("path", "") != "" { open_scene(sc_get("path", "")) } }
    else if a == "scene.settings" { modal_open("scene") }
    else if a == "scene.set_hud" { sc_set("hud", proj_rel(st_get("pick_path", "")))  mark_dirty()  modal_open("scene") }
    else if a == "scene.set_next" { sc_set("next", proj_rel(st_get("pick_path", "")))  mark_dirty()  modal_open("scene") }
    else if a == "scene.set_music" { sc_set("music", proj_rel(st_get("pick_path", "")))  mark_dirty()  modal_open("scene") }
    else if a == "scene.set_start" {
        if sc_get("path", "") == "" { status("Save the scene first") } else { st_set("proj_start", proj_rel(sc_get("path", "")))  proj_save()  status("Start scene: " + st_get("proj_start", "")) }
    }
    else if a == "scene.add_to_project" { if sc_get("path", "") != "" { proj_add_scene(proj_rel(sc_get("path", ""))) } }
    else if a == "scene.to_cutscene" { undo_push()  sc_set("kind", "cutscene")  st_set("dock_tab", 1.0)  st_set("show_dock", 1.0) }
    # ── edit ──
    else if a == "edit.undo" { undo_do() }
    else if a == "edit.redo" { redo_do() }
    else if a == "edit.delete" { delete_selected() }
    else if a == "edit.dup" { dup_selected() }
    else if a == "edit.copy" { copy_selected() }
    else if a == "edit.cut" { copy_selected()  delete_selected() }
    else if a == "edit.paste" { paste_nodes() }
    else if a == "edit.select_all" {
        sel_clear()
        bind i = 0.0
        while i < nd_count() { sel_add(nd_id(i))  bind i = i + 1.0 }
    }
    else if a == "edit.deselect" { sel_clear() }
    else if a == "edit.snap" { st_set("snap_on", 1.0 - st_get("snap_on", 1.0)) }
    # ── nodes ──
    else if str_starts_with(a, "node.add:") { add_node(str_slice(a, 9.0, 20.0), false) }
    else if str_starts_with(a, "node.add_here:") { add_node(str_slice(a, 14.0, 20.0), true) }
    else if a == "node.palette" { modal_open("addnode") }
    else if a == "node.rename" {
        bind i = sel_index()
        if i >= 0.0 { modal_text("Rename node", "node_rename", nd_get(i, "name")) }
    }
    else if a == "node.togglevis" {
        bind i = sel_index()
        if i >= 0.0 { undo_push()  nd_set(i, "vis", 1.0 - nd_get(i, "vis")) }
    }
    else if a == "node.togglelock" {
        bind i = sel_index()
        if i >= 0.0 { undo_push()  nd_set(i, "lock", 1.0 - nd_get(i, "lock")) }
    }
    else if str_starts_with(a, "node.color:") {
        bind k = to_number(str_slice(a, 11.0, 4.0))
        bind c = sel(k < 7.0, c_pal(k), sel(k < 7.5, C_WHITE, C_TEXT_DIM))
        bind ids = sel_ids()
        if len(ids) > 0.5 { undo_push() }
        bind j = 0.0
        while j < len(ids) { nd_sid(list_get(ids, j), "col", c)  bind j = j + 1.0 }
    }
    else if a == "node.group" { group_selected() }
    else if a == "node.unparent" {
        bind ids = sel_ids()
        if len(ids) > 0.5 { undo_push() }
        bind j = 0.0
        while j < len(ids) {
            bind i = nd_find(list_get(ids, j))
            if i >= 0.0 {
                bind wp = nd_world_pos(i)
                nd_set(i, "par", 0.0)
                nd_set(i, "px", list_get(wp, 0))
                nd_set(i, "py", list_get(wp, 1))
                nd_set(i, "pz", list_get(wp, 2))
            }
            bind j = j + 1.0
        }
    }
    else if a == "node.up" {
        bind i = sel_index()
        if i > 0.0 { undo_push()  nd_reorder(i, i - 1.0) }
    }
    else if a == "node.down" {
        bind i = sel_index()
        if i >= 0.0 && i < nd_count() - 1.0 { undo_push()  nd_reorder(i, i + 2.0) }
    }
    # ── view ──
    else if a == "view.hier" { st_set("show_hier", 1.0 - st_get("show_hier", 1.0)) }
    else if a == "view.assets" { st_set("show_assets", 1.0 - st_get("show_assets", 1.0)) }
    else if a == "view.inspector" { st_set("show_insp", 1.0 - st_get("show_insp", 1.0)) }
    else if a == "view.dock" { st_set("show_dock", 1.0 - st_get("show_dock", 1.0)) }
    else if a == "view.wire" { st_set("view_solid", 0.0) }
    else if a == "view.solid" { st_set("view_solid", 1.0 - st_get("view_solid", 1.0)) }
    else if a == "view.grid" { st_set("view_grid", 1.0 - st_get("view_grid", 1.0)) }
    else if a == "view.gizmos" { st_set("view_gizmos", 1.0 - st_get("view_gizmos", 1.0)) }
    else if a == "view.cam_preview" { st_set("view_campre", 1.0 - st_get("view_campre", 0.0)) }
    else if a == "view.reset_cam" { view_reset_camera() }
    else if a == "view.top" { st_set("ed_yaw", 0.0)  st_set("ed_pitch", 1.5) }
    else if a == "view.front" { st_set("ed_yaw", 0.0)  st_set("ed_pitch", 0.0) }
    else if a == "view.side" { st_set("ed_yaw", 1.5707963)  st_set("ed_pitch", 0.0) }
    else if a == "view.focus" { focus_selected() }
    else if a == "view.zoom_all" { zoom_all() }
    # ── timeline ──
    else if a == "tl.play" { st_set("tl_playing", 1.0 - st_get("tl_playing", 0.0)) }
    else if a == "tl.stop" { st_set("tl_playing", 0.0)  st_set("tl_t", 0.0) }
    else if a == "tl.home" { st_set("tl_t", 0.0) }
    else if a == "tl.end" { st_set("tl_t", sc_get("len", 10.0)) }
    else if a == "tl.loop" { st_set("tl_loop", 1.0 - st_get("tl_loop", 1.0)) }
    else if a == "tl.prev" { st_set("tl_t", max(0.0, st_get("tl_t", 0.0) - 1.0 / sc_get("fps", 30.0))) }
    else if a == "tl.next" { st_set("tl_t", min(sc_get("len", 10.0), st_get("tl_t", 0.0) + 1.0 / sc_get("fps", 30.0))) }
    else if a == "tl.autokey" { st_set("autokey", 1.0 - st_get("autokey", 0.0)) }
    else if a == "tl.key" { key_selected() }
    else if a == "tl.delkey" { delkey_selected() }
    else if a == "tl.clearkeys" {
        bind ids = sel_ids()
        if len(ids) > 0.5 { undo_push() }
        bind j = 0.0
        while j < len(ids) { key_remove_node(list_get(ids, j))  bind j = j + 1.0 }
    }
    # ── play ──
    else if a == "play.project" { play_start(true) }
    else if a == "play.scene" { play_start(false) }
    else if a == "play.stop" { play_stop() }
    # ── build ──
    else if a == "build.panel" { st_set("dock_tab", 3.0)  st_set("show_dock", 1.0) }
    else if a == "build.gen" { gen_write_all()  status("Generated main.ling, Ling.toml and build scripts in " + proj_root()) }
    else if a == "build.reveal" { if proj_root() != "" { plat_reveal(p_join(proj_root(), "dist")) } }
    else if str_starts_with(a, "build.target:") { build_target(str_slice(a, 13.0, 10.0)) }
    else if a == "build.run" { build_run() }
    # ── demos and FPS stages ──
    else if a == "demo.agent" { guard_unsaved("demo.agent_go") }
    else if a == "demo.agent_go" { demo_agent_open() }
    else if a == "demo.agent_rebuild" { demo_agent_build() }
    else if a == "demo.breaker" { guard_unsaved("demo.breaker_go") }
    else if a == "demo.breaker_go" { demo_breaker_open() }
    else if a == "demo.breaker_rebuild" { demo_breaker_build() }
    else if a == "demo.story" { guard_unsaved("demo.story_go") }
    else if a == "demo.story_go" { demo_story_open() }
    else if a == "demo.story_rebuild" { demo_story_build() }
    else if a == "demo.ssz" { guard_unsaved("demo.ssz_go") }
    else if a == "demo.ssz_go" { demo_ssz_open() }
    else if a == "demo.ssz_rebuild" { demo_ssz_build() }
    else if str_starts_with(a, "fps.add:") { fps_add_node(str_slice(a, 8.0, 20.0)) }
    else if a == "fps.make_stage" {
        undo_push()
        sc_ex_set("mode", "fps")
        if sc_ex("obj", "") == "" { sc_ex_set("obj", "exit") }
        if game_player() < 0.0 {
            bind p = add_node_quiet("actor", "Agent", false)
            nd_set(p, "tag", "player")  nd_set(p, "sx", 0.32)  nd_set(p, "sz", 0.32)  nd_set(p, "d", 6.2)
        }
        status("FPS stage: mode=fps, objectives in Scene Settings > ex (obj=...), F6 plays it")
    }
    # ── picker callbacks ──
    else if a == "node.set_src" {
        bind i = nd_find(st_get("pending_src", 0.0))
        if i >= 0.0 {
            undo_push()
            bind col = st_get("pending_col", "src")
            nd_set(i, col, proj_rel(st_get("pick_path", "")))
            if nd_get(i, "name") == kind_label(nd_get(i, "kind")) || str_starts_with(nd_get(i, "name"), kind_label(nd_get(i, "kind")) + " ") { nd_set(i, "name", p_stem(st_get("pick_path", ""))) }
            st_set("pending_col", "src")
        }
    }
    else if a == "clip.set_file" {
        bind ci = st_get("clip_sel", 0.0 - 1.0)
        if ci >= 0.0 && ci < clip_count() { undo_push()  clip_set(ci, st_get("clip_field", "b"), proj_rel(st_get("pick_path", ""))) }
        modal_open("clip")
    }
    else if a == "asset.delete_go" {
        plat_delete(st_get("confirm_target", ""))
        assets_mark_stale()
        status("Deleted " + p_base(st_get("confirm_target", "")))
    }
    # ── assets ──
    else if a == "asset.import" { picker_open("open", "Import an asset into the project (copied into assets/)", "", "asset.import_go", "", "") }
    else if a == "asset.import_go" { asset_import(st_get("pick_path", "")) }
    # ── app ──
    else if a == "app.settings" { modal_open("settings") }
    else if a == "app.quit" { guard_unsaved("app.quit_go") }
    else if a == "app.quit_go" { settings_save()  st_set("quit", 1.0) }
    else if a == "help.about" { modal_open("about") }
    else if a == "help.help" { modal_open("help") }
    else if a == "help.shortcuts" { modal_open("shortcuts") }
    else if a == "help.scripting" { modal_open("scripting") }
    else if a == "help.reveal" { plat_reveal(st_get("app_root", ".")) }
    else if a == "unsaved.discard" { bind nxt = st_get("unsaved_then", "")  st_set("dirty", 0.0)  do_action(nxt) }
    else if a == "unsaved.save" {
        if sc_get("path", "") != "" { save_scene(sc_get("path", ""))  do_action(st_get("unsaved_then", "")) } else { do_action("scene.save_as") }
    }
    else { status("Unknown action: " + a) }
    return 0.0
}

fn project_open_path(path) {
    bind f = path
    if fs_is_dir(f) { bind f = p_join(f, "lingdev.toml") }
    if !proj_load(f) {
        status("Not a LingDev project: " + path)
        return false
    }
    bind s = st_get("proj_start", "")
    if s == "" && st_llen("proj_scenes") > 0.5 { bind s = st_lget("proj_scenes", 0.0) }
    if s != "" { open_scene(proj_abs(s)) } else { new_scene("3d") }
    assets_mark_stale()
    return true
}

# ask before losing unsaved scene edits
fn guard_unsaved(then) {
    if st_get("dirty", 0.0) < 0.5 || nd_count() < 0.5 {
        do_action(then)
        return 0.0
    }
    st_set("unsaved_then", then)
    st_set("confirm_title", "Unsaved changes")
    st_set("confirm_msg", "The scene has unsaved changes. Discard them?")
    st_set("confirm_act", "unsaved.discard")
    modal_open("confirm")
    return 0.0
}

fn text_submit(kind, v) {
    if kind == "node_rename" {
        bind i = sel_index()
        if i >= 0.0 { undo_push()  nd_set(i, "name", v) }
    }
    else if kind == "clip_text" { clip_text_submit(v) }
    else if kind == "new_script" { script_create(v) }
    else if kind == "proj_title" { st_set("proj_title", v)  proj_save() }
    else if kind == "node_field" { inspector_text_submit(v) }
}

# ── node operations ──────────────────────────────────────────────────────────
fn add_node(kind, at_cursor) {
    undo_push()
    bind parent = 0.0
    bind sidx = sel_index()
    if sidx >= 0.0 && (nd_get(sidx, "kind") == "empty" || nd_get(sidx, "kind") == "panel") { bind parent = nd_id(sidx) }
    bind n = 1.0
    bind i = 0.0
    while i < nd_count() {
        if nd_get(i, "kind") == kind { bind n = n + 1.0 }
        bind i = i + 1.0
    }
    bind name = kind_label(kind) + sel(n > 1.0, " " + num_str(n), "")
    bind ni = nd_add(kind, name, parent)
    if nd_is_2d(kind) {
        nd_set(ni, "px", 40.0 + (n % 6.0) * 30.0)
        nd_set(ni, "py", 40.0 + (n % 6.0) * 30.0)
        if parent > 0.0 { nd_set(ni, "px", 20.0)  nd_set(ni, "py", 20.0 + (n % 6.0) * 30.0) }
    } else {
        bind px = 0.0
        bind py = 0.0 - nd_get(ni, "sy")
        bind pz = 0.0
        if at_cursor && st_get("hit_ok", 0.0) > 0.5 {
            bind px = st_get("hit_x", 0.0)
            bind pz = st_get("hit_z", 0.0)
            bind py = st_get("hit_y", 0.0) - nd_get(ni, "sy")
        } else {
            bind px = st_get("ed_tx", 0.0) + (n % 5.0) * 1.5
            bind pz = st_get("ed_tz", 0.0) + floor(n / 5.0) * 1.5
        }
        if kind == "light" || kind == "camera" || kind == "audio" || kind == "music" || kind == "midi" { bind py = nd_get(ni, "py") }
        if parent > 0.0 { bind px = 0.0  bind pz = 0.0 }
        if st_get("snap_on", 1.0) > 0.5 { bind px = snap_v(px)  bind pz = snap_v(pz) }
        nd_set(ni, "px", px)
        nd_set(ni, "py", py)
        nd_set(ni, "pz", pz)
    }
    if kind == "model" || kind == "actor" { st_set("pending_src", nd_id(ni))  picker_open("open", "Model file for " + name, "glb,gltf,lmesh", "node.set_src", p_join(proj_root(), "assets"), "") }
    if kind == "audio" || kind == "music" { st_set("pending_src", nd_id(ni))  picker_open("open", "Sound file for " + name, "wav,ogg,mp3,flac", "node.set_src", p_join(proj_root(), "assets"), "") }
    if kind == "midi" { st_set("pending_src", nd_id(ni))  picker_open("open", "MIDI file for " + name, "mid,midi", "node.set_src", p_join(proj_root(), "assets"), "") }
    if kind == "image" { st_set("pending_src", nd_id(ni))  picker_open("open", "Image for " + name, "png,jpg,jpeg,bmp", "node.set_src", p_join(proj_root(), "assets"), "") }
    sel_set(nd_id(ni))
    status("Added " + name)
    return ni
}
fn kind_label(k) {
    if k == "text3d" { return "Text" }
    if k == "empty" { return "Group" }
    return str_upper(str_slice(k, 0.0, 1.0)) + str_slice(k, 1.0, str_len(k) - 1.0)
}
fn snap_v(v) {
    bind s = st_get("grid_step", 1.0)
    if s <= 0.0 { return v }
    return round(v / s) * s
}
fn delete_selected() {
    bind ids = sel_ids()
    if len(ids) < 0.5 { return 0.0 }
    undo_push()
    bind j = 0.0
    while j < len(ids) { nd_del_id(list_get(ids, j))  bind j = j + 1.0 }
    sel_clear()
    status("Deleted " + num_str(len(ids)) + " node(s)")
    return 0.0
}
fn dup_selected() {
    bind ids = sel_ids()
    if len(ids) < 0.5 { return 0.0 }
    undo_push()
    sel_clear()
    bind j = 0.0
    while j < len(ids) {
        bind id = list_get(ids, j)
        bind nid = nd_dup_id(id, nd_gid(id, "par"))
        if nid > 0.0 {
            bind ni = nd_find(nid)
            if !nd_is_2d(nd_get(ni, "kind")) { nd_set(ni, "px", nd_get(ni, "px") + st_get("grid_step", 1.0)) } else { nd_set(ni, "px", nd_get(ni, "px") + 20.0)  nd_set(ni, "py", nd_get(ni, "py") + 20.0) }
            sel_add(nid)
        }
        bind j = j + 1.0
    }
    return 0.0
}
fn copy_selected() {
    bind ids = sel_ids()
    if len(ids) < 0.5 { return 0.0 }
    # the clipboard is a mini scene text of the selected nodes (keys included)
    bind s = ""
    bind cols = nd_cols()
    bind j = 0.0
    while j < len(ids) {
        bind i = nd_find(list_get(ids, j))
        if i >= 0.0 {
            bind line = "node"
            bind c = 0.0
            while c < len(cols) {
                bind col = list_get(cols, c)
                if nd_is_text(col) { bind line = line + fld(col, nd_get(i, col)) } else { bind line = line + fldn(col, nd_get(i, col)) }
                bind c = c + 1.0
            }
            bind s = s + line + "\n"
            bind kn = key_count()
            bind k = 0.0
            while k < kn {
                if st_lget("k_node", k) == nd_id(i) { bind s = s + "key" + fldn("n", nd_id(i)) + fld("p", st_lget("k_prop", k)) + fldn("t", st_lget("k_t", k)) + fldn("v", st_lget("k_v", k)) + fld("e", st_lget("k_e", k)) + "\n" }
                bind k = k + 1.0
            }
        }
        bind j = j + 1.0
    }
    st_set("clipboard", s)
    status("Copied " + num_str(len(ids)) + " node(s)")
    return 0.0
}
fn paste_nodes() {
    bind s = st_get("clipboard", "")
    if s == "" { return 0.0 }
    undo_push()
    sel_clear()
    bind lines = str_split(s, "\n")
    bind cols = nd_cols()
    bind idmap_old = []
    bind idmap_new = []
    bind li = 0.0
    while li < len(lines) {
        bind ln = str_trim(list_get(lines, li))
        if ln != "" {
            bind f = str_split(ln, "|")
            if list_get(f, 0) == "node" {
                bind ni = nd_add(rec_get(f, "kind", "box"), rec_get(f, "name", "node"), 0.0)
                bind c = 0.0
                while c < len(cols) {
                    bind col = list_get(cols, c)
                    if col != "id" && col != "par" {
                        if nd_is_text(col) { nd_set(ni, col, rec_get(f, col, "")) } else { nd_set(ni, col, rec_num(f, col, 0.0)) }
                    }
                    bind c = c + 1.0
                }
                if !nd_is_2d(nd_get(ni, "kind")) { nd_set(ni, "px", nd_get(ni, "px") + st_get("grid_step", 1.0)) } else { nd_set(ni, "px", nd_get(ni, "px") + 24.0)  nd_set(ni, "py", nd_get(ni, "py") + 24.0) }
                bind idmap_old = list_push(idmap_old, rec_num(f, "id", 0.0))
                bind idmap_new = list_push(idmap_new, nd_id(ni))
                sel_add(nd_id(ni))
            } else if list_get(f, 0) == "key" {
                bind oid = rec_num(f, "n", 0.0)
                bind k = l_index(idmap_old, oid)
                if k >= 0.0 { key_add(list_get(idmap_new, k), rec_get(f, "p", "px"), rec_num(f, "t", 0.0), rec_num(f, "v", 0.0), rec_get(f, "e", "linear")) }
            }
        }
        bind li = li + 1.0
    }
    status("Pasted " + num_str(len(idmap_new)) + " node(s)")
    return 0.0
}
fn group_selected() {
    bind ids = sel_ids()
    if len(ids) < 0.5 { return 0.0 }
    undo_push()
    bind gi = nd_add("empty", "Group", 0.0)
    bind gid = nd_id(gi)
    bind cx = 0.0
    bind cy = 0.0
    bind cz = 0.0
    bind j = 0.0
    while j < len(ids) {
        bind i = nd_find(list_get(ids, j))
        if i >= 0.0 { bind cx = cx + nd_get(i, "px")  bind cy = cy + nd_get(i, "py")  bind cz = cz + nd_get(i, "pz") }
        bind j = j + 1.0
    }
    bind cx = cx / len(ids)
    bind cy = cy / len(ids)
    bind cz = cz / len(ids)
    nd_set(gi, "px", cx)
    nd_set(gi, "py", cy)
    nd_set(gi, "pz", cz)
    bind j = 0.0
    while j < len(ids) {
        bind i = nd_find(list_get(ids, j))
        if i >= 0.0 {
            nd_set(i, "par", gid)
            nd_set(i, "px", nd_get(i, "px") - cx)
            nd_set(i, "py", nd_get(i, "py") - cy)
            nd_set(i, "pz", nd_get(i, "pz") - cz)
        }
        bind j = j + 1.0
    }
    sel_set(gid)
    return 0.0
}
fn focus_selected() {
    bind i = sel_index()
    if i < 0.0 { return 0.0 }
    if nd_is_2d(nd_get(i, "kind")) { st_set("c2_ox", 0.0)  st_set("c2_oy", 0.0)  return 0.0 }
    bind wp = nd_world_pos(i)
    st_set("ed_tx", list_get(wp, 0))
    st_set("ed_ty", list_get(wp, 1))
    st_set("ed_tz", list_get(wp, 2))
    bind r = max(nd_get(i, "sx"), max(nd_get(i, "sy"), nd_get(i, "sz")))
    st_set("ed_dist", clampf(r * 5.0 + 3.0, 3.0, 200.0))
    return 0.0
}
fn zoom_all() {
    bind n = nd_count()
    if n < 0.5 { view_reset_camera()  return 0.0 }
    bind minx = 999999.0  bind maxx = 0.0 - 999999.0
    bind miny = 999999.0  bind maxy = 0.0 - 999999.0
    bind minz = 999999.0  bind maxz = 0.0 - 999999.0
    bind i = 0.0
    while i < n {
        if !nd_is_2d(nd_get(i, "kind")) {
            bind wp = nd_world_pos(i)
            bind r = max(nd_get(i, "sx"), max(nd_get(i, "sy"), nd_get(i, "sz")))
            if nd_get(i, "kind") == "plane" { bind r = r * 0.3 }
            bind minx = min(minx, list_get(wp, 0) - r)  bind maxx = max(maxx, list_get(wp, 0) + r)
            bind miny = min(miny, list_get(wp, 1) - r)  bind maxy = max(maxy, list_get(wp, 1) + r)
            bind minz = min(minz, list_get(wp, 2) - r)  bind maxz = max(maxz, list_get(wp, 2) + r)
        }
        bind i = i + 1.0
    }
    if minx > maxx { view_reset_camera()  return 0.0 }
    st_set("ed_tx", (minx + maxx) * 0.5)
    st_set("ed_ty", (miny + maxy) * 0.5)
    st_set("ed_tz", (minz + maxz) * 0.5)
    bind span = max(maxx - minx, max(maxy - miny, maxz - minz))
    st_set("ed_dist", clampf(span * 1.3 + 4.0, 4.0, 400.0))
    return 0.0
}
# keyframe every animatable transform property of the selection at the playhead
fn key_selected() {
    bind ids = sel_ids()
    if len(ids) < 0.5 { status("Select a node to key")  return 0.0 }
    undo_push()
    bind t = st_get("tl_t", 0.0)
    bind props = ["px", "py", "pz", "rx", "ry", "rz", "sx", "sy", "sz"]
    bind j = 0.0
    while j < len(ids) {
        bind i = nd_find(list_get(ids, j))
        if i >= 0.0 {
            bind p = 0.0
            while p < len(props) {
                bind prop = list_get(props, p)
                if nd_is_2d(nd_get(i, "kind")) {
                    if prop == "px" || prop == "py" || prop == "sx" || prop == "sy" { key_add(nd_id(i), prop, t, nd_get(i, prop), "cubic_in_out") }
                } else { key_add(nd_id(i), prop, t, nd_get(i, prop), "cubic_in_out") }
                bind p = p + 1.0
            }
        }
        bind j = j + 1.0
    }
    status("Keyed " + num_str(len(ids)) + " node(s) at " + fmt2(t) + " s")
    return 0.0
}
fn delkey_selected() {
    bind ids = sel_ids()
    bind t = st_get("tl_t", 0.0)
    bind removed = 0.0
    bind j = 0.0
    while j < len(ids) {
        bind id = list_get(ids, j)
        bind k = key_count() - 1.0
        while k >= 0.0 {
            if st_lget("k_node", k) == id && abs(st_lget("k_t", k) - t) < 0.02 { key_del(k)  bind removed = removed + 1.0 }
            bind k = k - 1.0
        }
        bind j = j + 1.0
    }
    if removed > 0.0 { mark_dirty() }
    status("Removed " + num_str(removed) + " key(s)")
    return 0.0
}
# a property edit from the inspector / viewport: writes a key when auto-key is on
fn set_prop(i, col, v) {
    nd_set(i, col, v)
    if st_get("autokey", 0.0) > 0.5 && l_has(nd_props_anim(), col) { key_add(nd_id(i), col, st_get("tl_t", 0.0), v, "cubic_in_out") }
    mark_dirty()
}

# ── context-menu actions ─────────────────────────────────────────────────────
fn do_ctx_action(a) {
    bind kind = st_get("ctx_kind", "")
    bind arg = st_get("ctx_arg", "")
    if kind == "asset" {
        if a == "asset.add" { asset_add_to_scene(arg, false) }
        else if a == "asset.open" || a == "asset.reveal" { plat_reveal(arg) }
        else if a == "asset.copypath" { st_set("clipboard_text", proj_rel(arg))  status("Path: " + proj_rel(arg)) }
        else if a == "asset.openscene" { guard_unsaved("scene.open_path") }
        else if a == "asset.setstart" { st_set("proj_start", proj_rel(arg))  proj_add_scene(proj_rel(arg))  proj_save()  status("Start scene: " + proj_rel(arg)) }
        else if a == "asset.openscript" { script_open(arg)  st_set("dock_tab", 2.0)  st_set("show_dock", 1.0) }
        else if a == "asset.attach" {
            bind i = sel_index()
            if i >= 0.0 { undo_push()  nd_set(i, "scr", p_stem(arg))  status("Attached " + p_stem(arg)) }
        }
        else if a == "asset.delete" { st_set("confirm_target", arg)  confirm_open("Delete file", "Permanently delete " + p_base(arg) + "?", "asset.delete_go") }
        else if a == "asset.convertinfo" { info_open("ling convert", "Convert an asset into an importable .ling module from a terminal:\n\n  ling convert " + p_base(arg) + "\n\n.glb/.gltf keep geometry and node names, .wav/.ogg/.mp3 become PCM, .mid becomes a note list, .svg becomes polylines. LingDev loads glb/gltf/lmesh models and wav/ogg/mp3/mid files directly; convert when you want the data inside a script.") }
        else { do_action(a) }
        return 0.0
    }
    if kind == "key" {
        if str_starts_with(a, "key.ease:") {
            bind k = to_number(arg)
            if k >= 0.0 && k < key_count() { undo_push()  st_lset("k_e", k, str_slice(a, 9.0, 30.0)) }
        } else if a == "key.delete" {
            bind k = to_number(arg)
            if k >= 0.0 && k < key_count() { undo_push()  key_del(k) }
        }
        return 0.0
    }
    if kind == "clip" || kind == "track" {
        clip_ctx_action(a, arg)
        return 0.0
    }
    do_action(a)
    return 0.0
}

# ── play mode ────────────────────────────────────────────────────────────────
fn play_start(whole_project) {
    if st_get("playing", 0.0) > 0.5 { return 0.0 }
    if nd_count() < 0.5 && !whole_project { status("Nothing to play")  return 0.0 }
    st_set("play_backup", sc_serialize())
    st_set("play_backup_path", sc_get("path", ""))
    st_set("play_sel_backup", sel_main())
    st_set("game_embedded", 1.0)
    bind start = proj_rel(sc_get("path", ""))
    if whole_project && st_get("proj_start", "") != "" { bind start = st_get("proj_start", "") }
    if start == "" {
        # unsaved scene: play it from memory
        game_boot_text(st_get("play_backup", ""), st_get("proj_title", "LingDev"), get_width(), get_height())
    } else {
        game_boot(start, st_get("proj_title", "LingDev"), get_width(), get_height())
    }
    st_set("playing", 1.0)
    st_set("tl_playing", 0.0)
    status("Playing - Esc stops")
    return 0.0
}
fn play_stop() {
    if st_get("playing", 0.0) < 0.5 { return 0.0 }
    fps_leave()
    game_shutdown()
    st_set("playing", 0.0)
    sc_parse(st_get("play_backup", ""))
    sc_set("path", st_get("play_backup_path", ""))
    sel_set(st_get("play_sel_backup", 0.0))
    status("Stopped")
    return 0.0
}

# ── build ────────────────────────────────────────────────────────────────────
fn build_target(t) {
    if proj_root() == "" { status("Open or create a project first")  return 0.0 }
    gen_write_all()
    bind cmd = build_command(t)
    st_set("build_last", t)
    bind script = gen_build_script(t)
    status("Build script written: " + script + "   (" + cmd + ")")
    if plat_name() == "desktop" { plat_launch(script) }
    return 0.0
}
fn build_run() {
    if proj_root() == "" { return 0.0 }
    bind rs = p_join(proj_root(), sel(plat_name() == "desktop", "run.bat", "run.sh"))
    if !file_exists(rs) { gen_write_all() }
    plat_launch(rs)
    status("Launched " + rs)
    return 0.0
}

# ── autosave ─────────────────────────────────────────────────────────────────
fn autosave_tick() {
    bind mins = st_get("autosave", 3.0)
    if mins < 0.5 || !plat_can_write() { return 0.0 }
    bind now = st_get("now", 0.0)
    if st_has("autosave_t") < 0.5 { st_set("autosave_t", now) }
    if now - st_get("autosave_t", now) > mins * 60.0 {
        st_set("autosave_t", now)
        if st_get("dirty", 0.0) > 0.5 && proj_root() != "" && st_get("playing", 0.0) < 0.5 {
            plat_mkdir(p_join(proj_root(), ".autosave"))
            bind nm = sel(sc_get("path", "") == "", "untitled", p_stem(sc_get("path", "")))
            plat_write(p_join(proj_root(), ".autosave/" + nm + ".lscene"), sc_serialize())
            status("Autosaved to .autosave/" + nm + ".lscene")
        }
    }
    return 0.0
}

# FPS building blocks at the point under the viewport centre
fn fps_add_node(kind) {
    undo_push()
    bind x = snap_v(st_get("ed_tx", 0.0))
    bind z = snap_v(st_get("ed_tz", 0.0))
    bind i = 0.0 - 1.0
    if kind == "guard" { bind i = ag_guard("Guard", x, z, 0.0, 2.0, "soldier", "") }
    else if kind == "pickup" { bind i = ag_pick("smg", x, z, 30.0) }
    else if kind == "door" { bind i = ag_wall_door(x - 4.0, z, x + 4.0, z, 0.5, 3.2, c_rgb(150.0, 160.0, 170.0), "") }
    else if kind == "console" { bind i = ag_console("Alarm console", x, z, 0.0) }
    else if kind == "exit" { bind i = ag_exit(x, z) }
    if i >= 0.0 { sel_set(nd_id(i)) }
    status("Added " + kind + " - its options are in the Inspector's Extra field (weapon=, look=, patrol=, item=, lock=)")
    return 0.0
}

← Back to index