lingdev
↓ 0 downloadsLingDev: 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).
Keywords game-engine, editor, 3d, 2d, cutscene, timeline, ling, lingos, raspberry-pi
lingfu install lingdevCode / 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
}