灵符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/importers.ling
# importers.ling - turn an existing Ling game folder into a LingDev project.
#
# Soul Symphony (../soul-symphony-ling) gets a faithful conversion: the press-start title over the
# hue-cycling palace pieces and ink horizon, the main menu rows (load game, new game, level select,
# multiplayer, options, language, quit) over the knotwork mandala, level select / multiplayer /
# options screens, one cutscene per chapter with every dialogue line, speaker and voice file on
# tracks and a backdrop in the chapter's mood, and the hub cathedral as a walkable level with the
# orb player. Models are the game's own .lmesh files; nothing is copied.

fn import_ling_game(root) {
    if root == "" || !fs_is_dir(root) { status("Pick the game's folder")  return 0.0 }
    if file_exists(p_join(root, "cutscenes_dialog.txt")) && fs_is_dir(p_join(root, "game/voice")) { return import_soul_symphony(root) }
    return import_generic(root)
}

# ── generic: index assets, title menu, model gallery ─────────────────────────
fn import_generic(root) {
    bind name = p_base(root)
    bind dir = p_join(st_get("app_root", "."), "projects")
    plat_mkdir(dir)
    if !proj_create(dir, name, 0.0, 1280.0, 720.0) { return 0.0 }
    st_set("proj_source", p_norm(root))
    st_set("proj_title", name)
    assets_scan()
    new_scene("3d")
    sc_set("name", "Gallery")
    bind k = 0.0
    bind i = 0.0
    while i < st_llen("as_path") && k < 12.0 {
        if assets_kind(st_lget("as_ext", i)) == "model" {
            bind ni = nd_add("model", p_stem(st_lget("as_path", i)), 0.0)
            nd_set(ni, "src", st_lget("as_path", i))
            nd_set(ni, "px", (k % 4.0) * 5.0 - 7.5)
            nd_set(ni, "pz", floor(k / 4.0) * 5.0)
            nd_set(ni, "py", 0.0)
            bind k = k + 1.0
        }
        bind i = i + 1.0
    }
    bind a = nd_add("actor", "Player", 0.0)
    nd_set(a, "py", 0.0 - 1.0)
    nd_set(a, "pz", 0.0 - 6.0)
    save_scene(p_join(proj_root(), "scenes/gallery.lscene"))
    import_title_menu(name, "", ["Gallery|goto:scenes/gallery.lscene", "Quit|quit"], "scenes/gallery.lscene")
    st_set("proj_start", "scenes/title.lscene")
    import_write_run_original(root)
    proj_save()
    gen_write_all()
    open_scene(p_join(proj_root(), "scenes/title.lscene"))
    status("Imported " + name + ": " + num_str(k) + " models in the gallery, assets indexed, run_original.bat written")
    return 0.0
}

# a framed-button menu (generic imports)
fn import_title_menu(title, logo, buttons, bg3d) {
    sc_new("Title", "2d")
    st_lclear("undo")
    st_lclear("redo")
    sel_clear()
    st_set("tl_t", 0.0)
    view_reset_camera()
    sc_set("bg", 526603.0)
    if bg3d != "" { sc_set("ex", "bg3d=" + bg3d) }
    bind p = nd_add("panel", "Menu", 0.0)
    nd_set(p, "px", 60.0)
    nd_set(p, "py", 120.0)
    nd_set(p, "w", 420.0)
    nd_set(p, "h", 480.0)
    nd_set(p, "a", 0.82)
    nd_set(p, "col", 1447706.0)
    if logo != "" {
        bind im = nd_add("image", "Logo", nd_id(p))
        nd_set(im, "src", logo)
        nd_set(im, "px", 30.0)
        nd_set(im, "py", 24.0)
        nd_set(im, "w", 360.0)
        nd_set(im, "h", 150.0)
    } else {
        bind l = nd_add("label", "Title", nd_id(p))
        nd_set(l, "txt", title)
        nd_set(l, "px", 30.0)
        nd_set(l, "py", 30.0)
        nd_set(l, "w", 360.0)
        nd_set(l, "a", 32.0)
    }
    bind by = 200.0
    bind i = 0.0
    while i < len(buttons) {
        bind parts = str_split(list_get(buttons, i), "|")
        bind b = nd_add("button", list_get(parts, 0), nd_id(p))
        nd_set(b, "txt", list_get(parts, 0))
        nd_set(b, "scr", list_get(parts, 1))
        nd_set(b, "px", 60.0)
        nd_set(b, "py", by)
        nd_set(b, "w", 300.0)
        nd_set(b, "h", 34.0)
        nd_set(b, "col", c_pal(i))
        bind by = by + 42.0
        bind i = i + 1.0
    }
    nd_set(p, "h", max(200.0, by + 20.0))
    bind hint = nd_add("label", "Hint", 0.0)
    nd_set(hint, "txt", "Made with LingDev  -  WASD move, Space jump, Esc back")
    nd_set(hint, "px", 0.0)
    nd_set(hint, "py", 0.0 - 36.0)
    nd_set(hint, "w", 1280.0)
    nd_set(hint, "a", 14.0)
    nd_set(hint, "anc", "bc")
    nd_set(hint, "col", C_TEXT_DIM)
    save_scene(p_join(proj_root(), "scenes/title.lscene"))
}
fn import_write_run_original(root) {
    bind entry = sel(file_exists(p_join(root, "main.ling")), "main.ling", sel(file_exists(p_join(root, "game/main.ling")), "game/main.ling", ""))
    if entry == "" { return 0.0 }
    bind exe = str_replace(ling_exe_guess(), "/", "\\")
    plat_write(p_join(proj_root(), "run_original.bat"), "@echo off\r\nrem Plays the original game from its own folder.\r\ncd /d \"" + str_replace(root, "/", "\\") + "\"\r\nset LING=" + exe + "\r\nif not exist \"%LING%\" set LING=ling\r\n\"%LING%\" run --interp " + str_replace(entry, "/", "\\") + " %*\r\nif errorlevel 1 pause\r\n")
    plat_write(p_join(proj_root(), "run_original.sh"), "#!/usr/bin/env sh\ncd \"" + root + "\"\n${LING:-ling} run --interp " + entry + " \"$@\"\n")
    return 0.0
}

# ── Soul Symphony ────────────────────────────────────────────────────────────
fn ss_scene_id(header) {
    bind l = str_lower(header)
    bind out = ""
    bind i = 0.0
    while i < str_len(l) {
        bind ch = str_slice(l, i, 1.0)
        if str_find("abcdefghijklmnopqrstuvwxyz0123456789", ch) >= 0.0 { bind out = out + ch }
        bind i = i + 1.0
    }
    if str_starts_with(out, "postlevel") { return "post_level" + str_slice(out, 9.0, 4.0) }
    return out
}
fn ss_role(speaker) { return str_replace(str_lower(str_trim(speaker)), " ", "_") }
fn ss_two(n) {
    if n < 10.0 { return "0" + num_str(n) }
    return num_str(n)
}
fn ss_title(id) {
    if id == "intro" { return "Intro - the poem in the snow globe" }
    if id == "bonus" { return "Bonus - the leftover note" }
    if id == "post_level1" { return "Prelude - the festival of first notes" }
    if str_starts_with(id, "chapter") { return "Chapter " + str_slice(id, 7.0, 3.0) }
    if str_starts_with(id, "finale") { return "Finale " + str_slice(id, 6.0, 3.0) }
    return id
}
# the music a scene module plays: the first music/*.wav stem its source mentions
fn ss_scene_music(root, id, stems) {
    bind src = plat_read(p_join(root, "game/scenes/" + id + ".ling"))
    if src == "" { return "" }
    bind best = ""
    bind bestpos = 999999999.0
    bind i = 0.0
    while i < len(stems) {
        bind s = list_get(stems, i)
        bind pos = str_find(src, "\"" + s)
        if pos < 0.0 { bind pos = str_find(src, s + "_data") }
        if pos >= 0.0 && pos < bestpos { bind best = s  bind bestpos = pos }
        bind i = i + 1.0
    }
    if best == "" { return "" }
    return p_join(root, "music/" + best + ".wav")
}
fn ss_music_stems(root) {
    bind out = []
    bind items = plat_list(p_join(root, "music"))
    bind n = len(items) / 3.0
    bind i = 0.0
    while i < n {
        bind nm = list_get(items, i * 3.0)
        if !is_true(list_get(items, i * 3.0 + 1.0)) && p_ext(nm) == "wav" { bind out = list_push(out, p_stem(nm)) }
        bind i = i + 1.0
    }
    return out
}
# the scene camera pose; save_scene stores the editor view, so set both
fn ss_view(x, y, z, yaw, pitch, dist) {
    sc_set("camx", x)
    sc_set("camy", y)
    sc_set("camz", z)
    sc_set("camyaw", yaw)
    sc_set("campitch", pitch)
    sc_set("camzd", dist)
    st_set("ed_tx", x)
    st_set("ed_ty", y)
    st_set("ed_tz", z)
    st_set("ed_yaw", yaw)
    st_set("ed_pitch", pitch)
    st_set("ed_dist", dist)
}
fn ss_mesh(root, name) { return p_join(root, "game/" + name + ".lmesh") }
fn ss_music_file(root, stem) {
    bind p = p_join(root, "music/" + stem + ".wav")
    if file_exists(p) { return p }
    return ""
}

fn import_soul_symphony(root) {
    bind dir = p_join(st_get("app_root", "."), "projects")
    plat_mkdir(dir)
    if !proj_create(dir, "SoulSymphony", 0.0, 1280.0, 720.0) { return 0.0 }
    # the template level is not part of this game
    plat_delete(p_join(proj_root(), "scenes/level.lscene"))
    proj_remove_scene("scenes/level.lscene")
    st_set("proj_source", p_norm(root))
    st_set("proj_title", "Soul Symphony - LingDev Edition")
    st_set("proj_author", "Ling Lin")
    plat_mkdir(p_join(proj_root(), "assets/fonts"))
    plat_copy(p_join(st_get("app_root", "."), "assets/fonts/Daemon.otf"), p_join(proj_root(), "assets/fonts/Daemon.otf"))
    bind stems = ss_music_stems(root)
    # parse the dialogue export into scenes
    bind text = plat_read(p_join(root, "cutscenes_dialog.txt"))
    bind lines = str_split(str_replace(text, "\r", ""), "\n")
    bind order = []
    bind cur = ""
    st_lclear("ss_lines")
    st_lclear("ss_scene")
    bind i = 0.0
    while i < len(lines) {
        bind ln = str_trim(list_get(lines, i))
        if ln != "" {
            bind ci = str_find(ln, ":")
            if ci < 0.0 {
                bind id = ss_scene_id(ln)
                if fs_is_dir(p_join(root, "game/voice/" + id)) { bind cur = id  bind order = list_push(order, id) } else { bind cur = "" }
            } else if cur != "" {
                st_lpush("ss_scene", cur)
                st_lpush("ss_lines", ln)
            }
        }
        bind i = i + 1.0
    }
    # scenes with voice files but no lines in the export (intro, prelude, bonus, finales)
    bind vdirs = plat_list(p_join(root, "game/voice"))
    bind vi = 0.0
    while vi < len(vdirs) / 3.0 {
        bind vid = list_get(vdirs, vi * 3.0)
        if is_true(list_get(vdirs, vi * 3.0 + 1.0)) && !l_has(order, vid) {
            bind added = ss_lines_from_module(root, vid)
            if added > 0.0 { bind order = ss_insert_scene(order, vid) }
        }
        bind vi = vi + 1.0
    }
    # story order for Next: intro -> prelude -> chapters -> finales; bonus returns to the menu
    bind made = 0.0
    bind total_lines = 0.0
    bind s = 0.0
    while s < len(order) {
        bind id = list_get(order, s)
        bind nxt = "scenes/menu.lscene"
        if id != "bonus" {
            bind k = s + 1.0
            while k < len(order) && list_get(order, k) == "bonus" { bind k = k + 1.0 }
            if k < len(order) { bind nxt = "scenes/" + list_get(order, k) + ".lscene" }
        }
        bind n = ss_build_cutscene(root, id, nxt, stems)
        bind total_lines = total_lines + n
        bind made = made + 1.0
        bind s = s + 1.0
    }
    ss_build_hub(root)
    ss_build_title_bg(root)
    ss_build_menu_bg(root)
    ss_build_menus(root, order)
    st_set("proj_start", "scenes/title.lscene")
    import_write_run_original(root)
    proj_save()
    gen_write_all()
    assets_mark_stale()
    open_scene(p_join(proj_root(), "scenes/title.lscene"))
    status("Soul Symphony imported: " + num_str(made) + " cutscenes, " + num_str(total_lines) + " voiced lines, title + menus, hub cathedral. run_original.bat plays the original.")
    return 0.0
}

# keep the story order: intro first, prelude after chapter1's gate, bonus and finales last
fn ss_insert_scene(order, id) {
    if id == "intro" { return l_insert_at(order, 0.0, id) }
    if id == "post_level1" {
        bind k = l_index(order, "chapter1")
        if k >= 0.0 { return l_insert_at(order, k, id) }
    }
    return list_push(order, id)
}
# the English strings of a scene module's dialogue function (its "== 1 {" block up to the closing "]");
# the speakers come from the voice file names NN_<role>_en.wav
fn ss_lines_from_module(root, id) {
    bind src = plat_read(p_join(root, "game/scenes/" + id + ".ling"))
    bind files = plat_list(p_join(root, "game/voice/" + id))
    bind roles = []
    bind fi = 0.0
    while fi < len(files) / 3.0 {
        bind nm = list_get(files, fi * 3.0)
        if str_ends_with(nm, "_en.wav") {
            bind parts = str_split(nm, "_")
            if len(parts) >= 3.0 {
                bind role = list_get(parts, 1)
                bind pi = 2.0
                while pi < len(parts) - 1.0 { bind role = role + "_" + list_get(parts, pi)  bind pi = pi + 1.0 }
                bind roles = list_push(roles, role)
            }
        }
        bind fi = fi + 1.0
    }
    if len(roles) < 1.0 { return 0.0 }
    bind texts = []
    if src != "" {
        bind at = str_find(src, "== 1 {")
        bind guard = 0.0
        while at >= 0.0 && guard < 12.0 {
            bind rest = str_slice(src, at, str_len(src) - at)
            bind close = str_find(rest, "]")
            bind block = sel(close > 0.0, str_slice(rest, 0.0, close), "")
            bind qs = str_split(block, "\"")
            bind cand = []
            bind qi = 1.0
            while qi < len(qs) {
                bind piece = list_get(qs, qi)
                if str_len(str_trim(piece)) > 2.0 { bind cand = list_push(cand, piece) }
                bind qi = qi + 2.0
            }
            if len(cand) > len(texts) && len(cand) <= len(roles) + 2.0 { bind texts = cand }
            bind tail = str_slice(src, at + 6.0, str_len(src) - at - 6.0)
            bind nxt = str_find(tail, "== 1 {")
            if nxt < 0.0 { bind at = 0.0 - 1.0 } else { bind at = at + 6.0 + nxt }
            bind guard = guard + 1.0
        }
    }
    bind i = 0.0
    while i < len(roles) {
        bind role = list_get(roles, i)
        bind speaker = str_upper(str_slice(role, 0.0, 1.0)) + str_replace(str_slice(role, 1.0, str_len(role) - 1.0), "_", " ")
        bind line = sel(i < len(texts), list_get(texts, i), "")
        st_lpush("ss_scene", id)
        st_lpush("ss_lines", speaker + ": " + line)
        bind i = i + 1.0
    }
    return len(roles)
}

# ── shared pieces ────────────────────────────────────────────────────────────
fn ss_node(kind, name, x, y, z, sx, sy, sz, col) {
    bind i = nd_add(kind, name, 0.0)
    nd_set(i, "px", x)
    nd_set(i, "py", y)
    nd_set(i, "pz", z)
    nd_set(i, "sx", sx)
    nd_set(i, "sy", sy)
    nd_set(i, "sz", sz)
    nd_set(i, "col", col)
    return i
}
fn ss_model(root, name, label, x, y, z, scale, yaw, col) {
    bind i = ss_node("model", label, x, y, z, scale, scale, scale, col)
    nd_set(i, "src", ss_mesh(root, name))
    nd_set(i, "ry", yaw)
    return i
}
fn ss_swarm(name, x, y, z, count, radius, shape, size, drift, col, extra) {
    bind i = ss_node("swarm", name, x, y, z, size, size, size, col)
    nd_set(i, "a", count)
    nd_set(i, "b", radius)
    nd_set(i, "c", shape)
    nd_set(i, "d", drift)
    nd_set(i, "ex", extra)
    return i
}
fn ss_knot(name, x, y, z, count, outer, inner, speed, size) {
    bind i = ss_node("knot", name, x, y, z, size, size, size, C_WHITE)
    nd_set(i, "a", count)
    nd_set(i, "b", outer)
    nd_set(i, "c", inner)
    nd_set(i, "d", speed)
    nd_set(i, "ex", "rainbow=1")
    return i
}
fn ss_light(name, x, y, z, col, intensity, radius) {
    bind i = ss_node("light", name, x, y, z, 1.0, 1.0, 1.0, col)
    nd_set(i, "a", intensity)
    nd_set(i, "b", radius)
    return i
}
# a circular path: keys on px / pz (and py when bob > 0), `turns` revolutions over [t0, t1]
fn ss_orbit_keys(i, cx, cy, cz, r, bob, t0, t1, turns) {
    bind steps = 16.0
    bind k = 0.0
    while k <= steps {
        bind u = k / steps
        bind a = u * 6.2831853 * turns
        bind t = t0 + (t1 - t0) * u
        key_add(nd_id(i), "px", t, cx + cos(a) * r, "linear")
        key_add(nd_id(i), "pz", t, cz + sin(a) * r, "linear")
        if bob > 0.0 { key_add(nd_id(i), "py", t, cy + sin(a * 2.0) * bob, "linear") }
        bind k = k + 1.0
    }
}
fn ss_spin_keys(i, t1, turns) {
    key_add(nd_id(i), "ry", 0.0, 0.0, "linear")
    key_add(nd_id(i), "ry", t1, 6.2831853 * turns, "linear")
}
# the five palace pieces as the original title draws them: (0, 1.02, 6), scale 12, turned to face us
fn ss_palace_pieces(root, x, y, z, scale, hue) {
    bind k = 0.0
    while k < 5.0 {
        bind m = ss_model(root, "palace_p" + num_str(k), "Palace " + num_str(k), x, y, z, scale, 3.1416, c_pal(k + 3.0))
        if hue > 0.0 { nd_set(m, "ex", "hue=" + fmt2(hue) + ";gain=1.1") }
        bind k = k + 1.0
    }
}

# ── title: press start over the palace / ink horizon ─────────────────────────
fn ss_build_title_bg(root) {
    new_scene("3d")
    sc_set("name", "Title backdrop")
    sc_set("bg", c_rgb(4.0, 0.0, 8.0))
    sc_set("amb", 0.4)
    sc_set("ex", "fog=30,72,158,14,60;bgspin=0;bgsway=0.05;bgzd=2.8;shade=2;rim=0.25")
    ss_view(0.0, 0.0, 0.0, 0.0, 0.0, 22.0)
    bind g = nd_find_name("Ground")
    if g >= 0.0 { nd_set(g, "py", 3.0)  nd_set(g, "sx", 400.0)  nd_set(g, "sz", 400.0)  nd_set(g, "col", c_rgb(255.0, 60.0, 140.0))  nd_set(g, "name", "Ink horizon") }
    bind sun = nd_find_name("Sun")
    if sun >= 0.0 { nd_set(sun, "px", 0.0)  nd_set(sun, "py", 0.0 - 4.0)  nd_set(sun, "pz", 0.0 - 6.0)  nd_set(sun, "col", c_rgb(255.0, 225.0, 165.0))  nd_set(sun, "a", 3.0)  nd_set(sun, "b", 14.0) }
    ss_light("Blue rim", 0.0 - 4.0, 0.0 - 2.5, 2.0, c_rgb(76.0, 204.0, 255.0), 1.5, 10.0)
    bind cam = nd_find_name("Camera")
    if cam >= 0.0 { nd_set(cam, "px", 0.0)  nd_set(cam, "py", 0.0)  nd_set(cam, "pz", 0.0 - 22.0)  nd_set(cam, "rx", 0.0)  nd_set(cam, "ry", 0.0)  nd_set(cam, "a", 2.8) }
    bind spawn = nd_find_name("Spawn")
    if spawn >= 0.0 { nd_del_id(nd_id(spawn)) }
    ss_palace_pieces(root, 0.0, 1.02, 6.0, 12.0, 0.85)
    bind pool = ss_node("cylinder", "Moon pool", 0.0, 2.95, 6.0, 9.0, 0.02, 4.0, c_rgb(120.0, 70.0, 150.0))
    ss_swarm("Wire cubes", 0.0, 0.0 - 1.0, 0.0 - 2.0, 18.0, 9.0, 0.0, 1.1, 0.12, c_rgb(120.0, 220.0, 255.0), "rainbow=1")
    ss_swarm("Letters", 0.0, 0.0 - 2.0, 2.0, 24.0, 7.0, 1.0, 0.12, 0.25, c_rgb(255.0, 240.0, 200.0), "rainbow=1")
    ss_swarm("Stars", 0.0, 0.0 - 20.0, 30.0, 160.0, 60.0, 1.0, 0.05, 0.02, c_rgb(200.0, 210.0, 255.0), "")
    ss_knot("Mandala", 0.0, 0.0 - 1.0, 0.0 - 6.0, 24.0, 4.2, 1.7, 0.05, 0.22)
    save_scene(p_join(proj_root(), "scenes/title_bg.lscene"))
}
# the menu background: fill (2,2,9), warm front light, the Celtic knot chain of rainbow cubes
fn ss_build_menu_bg(root) {
    new_scene("3d")
    sc_set("name", "Menu backdrop")
    sc_set("bg", c_rgb(2.0, 2.0, 9.0))
    sc_set("amb", 0.62)
    sc_set("ex", "bgspin=0;bgzd=2.8;rim=0.3")
    ss_view(0.0, 0.0, 0.0, 0.0, 0.0, 22.0)
    bind g = nd_find_name("Ground")
    if g >= 0.0 { nd_del_id(nd_id(g)) }
    bind spawn = nd_find_name("Spawn")
    if spawn >= 0.0 { nd_del_id(nd_id(spawn)) }
    bind sun = nd_find_name("Sun")
    if sun >= 0.0 { nd_set(sun, "px", 0.0)  nd_set(sun, "py", 0.0 - 5.0)  nd_set(sun, "pz", 0.0 - 13.0)  nd_set(sun, "col", c_rgb(255.0, 235.0, 209.0))  nd_set(sun, "a", 1.25)  nd_set(sun, "b", 280.0) }
    bind cam = nd_find_name("Camera")
    if cam >= 0.0 { nd_set(cam, "px", 0.0)  nd_set(cam, "py", 0.0)  nd_set(cam, "pz", 0.0 - 22.0)  nd_set(cam, "rx", 0.0)  nd_set(cam, "ry", 0.0)  nd_set(cam, "a", 2.8) }
    ss_knot("Mandala", 0.0, 0.0, 0.0, 30.0, 6.0, 2.4, 0.06, 0.3)
    bind k = 0.0
    while k < 3.0 {
        bind l = ss_light("Chain light " + num_str(k + 1.0), 6.0 * cos(k * 2.094), 6.0 * sin(k * 2.094), 0.0 - 4.0, c_pal(k * 2.0), 1.2, 120.0)
        ss_orbit_keys(l, 0.0, 0.0, 0.0 - 4.0, 6.0, 0.0, 0.0, 10.0, 1.0)
        bind k = k + 1.0
    }
    sc_set("len", 10.0)
    save_scene(p_join(proj_root(), "scenes/menu_bg.lscene"))
}
# a row of the original's menu: plain text, centred, ROYGBIV by index, the ink cursor on the selected one
fn ss_row(label, action, idx, y, size, font) {
    bind b = nd_add("button", label, 0.0)
    nd_set(b, "txt", label)
    nd_set(b, "scr", action)
    nd_set(b, "px", 0.0)
    nd_set(b, "py", y)
    nd_set(b, "w", 520.0)
    nd_set(b, "h", size + 14.0)
    nd_set(b, "a", size)
    nd_set(b, "anc", "tc")
    nd_set(b, "col", c_mix(c_pal(idx), C_WHITE, 0.25))
    nd_set(b, "ex", "style=text" + sel(font == "", "", ";font=" + font))
    return b
}
fn ss_menu_scene(name, file, bg, rows, hint) {
    sc_new(name, "2d")
    st_lclear("undo")
    st_lclear("redo")
    sel_clear()
    view_reset_camera()
    sc_set("bg", c_rgb(2.0, 2.0, 9.0))
    sc_set("ex", "bg3d=" + bg)
    bind n = len(rows)
    bind spacing = 46.0
    bind y0 = 360.0 - (n - 1.0) * spacing * 0.5 - 20.0
    bind i = 0.0
    while i < n {
        bind p = str_split(list_get(rows, i), "|")
        ss_row(list_get(p, 0), list_get(p, 1), i, y0 + i * spacing, 26.0, "")
        bind i = i + 1.0
    }
    if hint != "" {
        bind h = nd_add("label", "Hint", 0.0)
        nd_set(h, "txt", hint)
        nd_set(h, "px", 0.0)
        nd_set(h, "py", 0.0 - 40.0)
        nd_set(h, "w", 1280.0)
        nd_set(h, "a", 13.0)
        nd_set(h, "anc", "bc")
        nd_set(h, "col", c_rgb(150.0, 150.0, 190.0))
    }
    save_scene(p_join(proj_root(), "scenes/" + file))
}
fn ss_build_menus(root, order) {
    # press start
    sc_new("Title", "2d")
    st_lclear("undo")
    st_lclear("redo")
    sel_clear()
    view_reset_camera()
    sc_set("bg", c_rgb(4.0, 0.0, 8.0))
    sc_set("ex", "bg3d=scenes/title_bg.lscene")
    bind logo = p_join(root, "image/SoulSymphonyLogo.png")
    if file_exists(logo) {
        bind im = nd_add("image", "Logo", 0.0)
        nd_set(im, "src", logo)
        nd_set(im, "px", 0.0)
        nd_set(im, "py", 150.0)
        nd_set(im, "w", 620.0)
        nd_set(im, "h", 258.0)
        nd_set(im, "anc", "tc")
        key_add(nd_id(im), "py", 0.0, 150.0, "linear")
        key_add(nd_id(im), "py", 2.0, 162.0, "sine_in_out")
        key_add(nd_id(im), "py", 4.0, 150.0, "sine_in_out")
        sc_set("len", 4.0)
    }
    bind ps = ss_row("press start", "goto:scenes/menu.lscene", 7.0, 0.0 - 120.0, 22.0, "Daemon")
    nd_set(ps, "anc", "bc")
    nd_set(ps, "col", c_rgb(230.0, 230.0, 255.0))
    nd_set(ps, "ex", "style=text;font=Daemon;blink=1")
    bind sub = nd_add("label", "Subtitle", 0.0)
    nd_set(sub, "txt", "A spiritual 3D adventure in the soul realm  -  LingDev edition")
    nd_set(sub, "px", 0.0)
    nd_set(sub, "py", 0.0 - 60.0)
    nd_set(sub, "w", 1280.0)
    nd_set(sub, "a", 13.0)
    nd_set(sub, "anc", "bc")
    nd_set(sub, "col", c_rgb(150.0, 150.0, 190.0))
    save_scene(p_join(proj_root(), "scenes/title.lscene"))
    # main menu: the original rows, in its order
    bind first = sel(l_has(order, "intro"), "intro", list_get(order, 0))
    ss_menu_scene("Main menu", "menu.lscene", "scenes/menu_bg.lscene", ["load game|say:Saved games stay in the original (run_original.bat); the LingDev edition starts from the Level select", "new game|goto:scenes/" + first + ".lscene", "level select|goto:scenes/levels.lscene", "multiplayer|goto:scenes/multiplayer.lscene", "options|goto:scenes/options.lscene", "language|lang", "quit|quit"], "arrows / W S choose   enter / space select   esc back   -   Main_Menu.wav plays in the original")
    # level select: hub + the story scenes in order
    bind rows = ["hub cathedral (walk)|goto:scenes/hub.lscene"]
    bind s = 0.0
    while s < len(order) {
        bind id = list_get(order, s)
        bind rows = list_push(rows, str_lower(ss_title(id)) + "|goto:scenes/" + id + ".lscene")
        bind s = s + 1.0
    }
    bind rows = list_push(rows, "back|back")
    ss_menu_scene("Level select", "levels.lscene", "scenes/menu_bg.lscene", rows, "the procedural levels between the chapters live in the original game")
    ss_menu_scene("Multiplayer", "multiplayer.lscene", "scenes/menu_bg.lscene", ["host a game (port 7777)|host:7777", "join 127.0.0.1|join:127.0.0.1:7777", "back|back"], "co-op shares the orb's position with one friend (desktop runtime, TCP)")
    ss_menu_scene("Options", "options.lscene", "scenes/menu_bg.lscene", ["music volume +|vol:0.1", "music volume -|vol:-0.1", "subtitles on / off|toggle:subtitles", "language|lang", "back|back"], "")
    # music for the menus (the original: Main_Menu.wav)
    bind mm = ss_music_file(root, "Main_Menu")
    if mm != "" {
        open_scene(p_join(proj_root(), "scenes/menu.lscene"))
        sc_set("music", mm)
        save_scene(sc_get("path", ""))
    }
    bind tm = ss_music_file(root, "SoulSymphony")
    if tm == "" { bind tm = ss_music_file(root, "Main_Menu") }
    if tm != "" {
        open_scene(p_join(proj_root(), "scenes/title.lscene"))
        sc_set("music", tm)
        save_scene(sc_get("path", ""))
    }
}

# ── cutscenes ────────────────────────────────────────────────────────────────
fn ss_build_cutscene(root, id, next_scene, stems) {
    new_scene("cutscene")
    while clip_count() > 0.0 { clip_del(0.0) }
    sc_set("name", ss_title(id))
    sc_set("bg", c_rgb(6.0, 6.0, 20.0))
    sc_set("amb", 0.35)
    sc_set("next", next_scene)
    bind music = ss_scene_music(root, id, stems)
    bind g = nd_find_name("Ground")
    if g >= 0.0 { nd_set(g, "sx", 60.0)  nd_set(g, "sz", 60.0)  nd_set(g, "col", c_rgb(14.0, 18.0, 40.0)) }
    bind spawn = nd_find_name("Spawn")
    if spawn >= 0.0 { nd_del_id(nd_id(spawn)) }
    bind king = ss_model(root, "king", "King", 0.0 - 2.2, 0.0, 0.0, 2.2, 0.5, C_YELLOW)
    bind queen = ss_model(root, "queen", "Queen", 2.2, 0.0, 0.0, 2.2, 0.0 - 0.5, C_VIOLET)
    nd_set(king, "tag", "king")
    nd_set(queen, "tag", "queen")
    ss_light("Rim", 0.0 - 6.0, 0.0 - 5.0, 0.0 - 6.0, C_BLUE, 1.8, 30.0)
    # dialogue lines -> clips with voice-over; the camera cuts between shots like the original's blocking
    bind t = 1.2
    bind idx = 0.0
    bind n = 0.0
    bind i = 0.0
    while i < st_llen("ss_scene") {
        if st_lget("ss_scene", i) == id {
            bind ln = st_lget("ss_lines", i)
            bind ci = str_find(ln, ":")
            bind speaker = str_trim(str_slice(ln, 0.0, ci))
            bind line = str_trim(str_slice(ln, ci + 1.0, str_len(ln) - ci - 1.0))
            bind role = ss_role(speaker)
            bind vo = p_join(root, "game/voice/" + id + "/" + ss_two(idx) + "_" + role + "_en.wav")
            bind d = 2.2 + str_len(line) * 0.055
            if file_exists(vo) {
                if plat_has_audio() {
                    bind sid = plat_sample_load(vo)
                    bind dur = plat_sample_duration(sid)
                    if dur > 0.2 { bind d = dur + 0.35 }
                }
            } else { bind vo = "" }
            bind who = sel(role == "king", nd_id(king), sel(role == "queen", nd_id(queen), 0.0))
            clip_add("dialog", t, d, "say", speaker, line, vo, who, "linear")
            bind shot = idx % 4.0
            if who > 0.0 {
                if shot == 0.0 { clip_add("camera", t, d, "orbit", "", "", "0,-1.5,0,5,0.18," + sel(role == "king", "0.12", "-0.12"), who, "linear") }
                else if shot == 1.0 { clip_add("camera", t, d, "follow", "", "", "1.2,-1.6,4.2,0.12", who, "linear") }
                else if shot == 2.0 { clip_add("camera", t, d, "orbit", "", "", "0,-1.2,0,3.6,0.08,0.06", who, "linear") }
                else { clip_add("camera", t, d, "orbit", "", "", "0,-2,0,7,0.3,-0.08", who, "linear") }
            } else {
                if shot == 1.0 { clip_add("camera", t, d, "orbit", "", "", "0,-3,0,14,0.35,0.08", 0.0, "linear") }
                else { clip_add("camera", t, d, "orbit", "", "", "0,-2,0,9,0.22,0.1", 0.0, "linear") }
            }
            bind t = t + d + 0.25
            bind idx = idx + 1.0
            bind n = n + 1.0
        }
        bind i = i + 1.0
    }
    bind total = t + 1.5
    sc_set("len", total)
    clip_add("fx", 0.0, 1.5, "fade_in", "", "", "1", 0.0, "linear")
    clip_add("fx", 0.0, total, "letterbox", "", "", "1", 0.0, "linear")
    clip_add("fx", total - 1.5, 1.5, "fade_out", "", "", "1", 0.0, "linear")
    if music != "" { clip_add("audio", 0.0, 1.0, "music", "", music, "0.6", 0.0, "linear") }
    clip_add("event", total, 0.1, "end", "", "", "", 0.0, "linear")
    ss_decorate(root, id, king, queen, total)
    save_scene(p_join(proj_root(), "scenes/" + id + ".lscene"))
    return n
}

# each chapter's backdrop in the mood of the original module
fn ss_decorate(root, id, king, queen, total) {
    bind g = nd_find_name("Ground")
    if id == "intro" {
        sc_set("bg", c_rgb(2.0, 2.0, 12.0))
        if g >= 0.0 { nd_set(g, "vis", 0.0) }
        nd_set(king, "px", 0.0 - 7.0)  nd_set(king, "py", 2.0)  nd_set(king, "pz", 14.0)  nd_set(king, "sx", 6.0)  nd_set(king, "sy", 6.0)  nd_set(king, "sz", 6.0)
        nd_set(queen, "px", 7.0)  nd_set(queen, "py", 2.0)  nd_set(queen, "pz", 14.0)  nd_set(queen, "sx", 6.0)  nd_set(queen, "sy", 6.0)  nd_set(queen, "sz", 6.0)
        bind planet = ss_node("sphere", "Planet", 0.0, 0.0 - 3.0, 2.0, 3.0, 3.0, 3.0, c_rgb(40.0, 110.0, 235.0))
        ss_spin_keys(planet, total, 2.0)
        bind ring = ss_node("torus", "Ring", 0.0, 0.0 - 3.0, 2.0, 5.2, 0.3, 5.2, c_rgb(240.0, 175.0, 60.0))
        nd_set(ring, "rx", 0.35)
        ss_spin_keys(ring, total, 0.0 - 1.5)
        ss_swarm("Stars", 0.0, 0.0 - 6.0, 10.0, 240.0, 70.0, 1.0, 0.07, 0.02, c_rgb(220.0, 225.0, 255.0), "")
        ss_swarm("Comets", 0.0, 0.0 - 4.0, 4.0, 10.0, 16.0, 0.0, 0.35, 1.2, c_rgb(255.0, 230.0, 160.0), "rainbow=1")
        bind i = 0.0
        while i < clip_count() {
            if clip_get(i, "track") == "camera" { clip_set(i, "c", "0,-3,2,12,0.18,0.12")  clip_set(i, "node", 0.0) }
            bind i = i + 1.0
        }
    } else if id == "post_level1" {
        sc_set("bg", c_rgb(70.0, 28.0, 62.0))
        if g >= 0.0 { nd_set(g, "col", c_rgb(44.0, 72.0, 36.0)) }
        ss_swarm("Lanterns", 0.0, 0.0 - 5.0, 4.0, 24.0, 12.0, 2.0, 0.35, 0.3, c_rgb(255.0, 170.0, 60.0), "")
        ss_swarm("Leaves", 0.0, 0.0 - 3.0, 0.0, 60.0, 14.0, 0.0, 0.12, 0.8, c_rgb(220.0, 120.0, 50.0), "")
        ss_light("Lantern glow", 0.0, 0.0 - 6.0, 4.0, c_rgb(255.0, 180.0, 90.0), 2.5, 40.0)
    } else if id == "chapter1" {
        sc_set("bg", c_rgb(12.0, 22.0, 48.0))
        if g >= 0.0 { nd_set(g, "col", c_rgb(34.0, 96.0, 46.0)) }
        bind k = 0.0
        while k < 10.0 {
            bind ang = k * 0.9 + 0.4
            bind r = 9.0 + (k % 3.0) * 4.0
            bind tx = cos(ang) * r
            bind tz = sin(ang) * r + 6.0
            ss_node("cylinder", "Trunk " + num_str(k + 1.0), tx, 0.0 - 1.2, tz, 0.25, 1.2, 0.25, c_rgb(90.0, 60.0, 40.0))
            ss_node("cone", "Tree " + num_str(k + 1.0), tx, 0.0 - 4.2, tz, 1.6 + (k % 2.0) * 0.5, 2.2, 1.6 + (k % 2.0) * 0.5, c_rgb(40.0 + k * 6.0, 140.0, 60.0))
            bind k = k + 1.0
        }
        ss_swarm("Fireflies", 0.0, 0.0 - 2.0, 4.0, 40.0, 12.0, 1.0, 0.09, 0.6, c_rgb(255.0, 240.0, 120.0), "")
    } else if id == "chapter2" {
        sc_set("bg", c_rgb(3.0, 3.0, 14.0))
        sc_set("ex", "fog=20,30,80,22,70")
        if g >= 0.0 { nd_set(g, "col", c_rgb(10.0, 14.0, 36.0)) }
        bind moon = ss_node("sphere", "The eye", 0.0, 0.0 - 16.0, 22.0, 4.5, 4.5, 4.5, c_rgb(236.0, 226.0, 206.0))
        bind e1 = nd_add("sphere", "Pupil L", nd_id(moon))
        nd_set(e1, "px", 0.0 - 1.4)  nd_set(e1, "py", 0.0 - 0.6)  nd_set(e1, "pz", 0.0 - 3.9)  nd_set(e1, "sx", 0.7)  nd_set(e1, "sy", 0.7)  nd_set(e1, "sz", 0.7)  nd_set(e1, "col", c_rgb(40.0, 20.0, 30.0))
        bind e2 = nd_add("sphere", "Pupil R", nd_id(moon))
        nd_set(e2, "px", 1.4)  nd_set(e2, "py", 0.0 - 0.6)  nd_set(e2, "pz", 0.0 - 3.9)  nd_set(e2, "sx", 0.7)  nd_set(e2, "sy", 0.7)  nd_set(e2, "sz", 0.7)  nd_set(e2, "col", c_rgb(40.0, 20.0, 30.0))
        bind mouth = nd_add("torus", "Grin", nd_id(moon))
        nd_set(mouth, "py", 1.6)  nd_set(mouth, "pz", 0.0 - 3.6)  nd_set(mouth, "sx", 1.6)  nd_set(mouth, "sy", 0.4)  nd_set(mouth, "sz", 1.0)  nd_set(mouth, "rx", 1.4)  nd_set(mouth, "col", c_rgb(60.0, 25.0, 40.0))
        key_add(nd_id(moon), "py", 0.0, 0.0 - 16.0, "linear")
        key_add(nd_id(moon), "py", total, 0.0 - 11.0, "sine_in_out")
        ss_model(root, "palace", "Old palace", 0.0, 0.0, 34.0, 12.0, 3.1416, C_INDIGO)
        bind k = 0.0
        while k < 14.0 {
            ss_node("cylinder", "Reed " + num_str(k + 1.0), cos(k * 1.3) * (6.0 + (k % 4.0) * 2.0), 0.0 - 1.0, 8.0 + sin(k * 1.3) * 5.0, 0.05, 1.0 + (k % 3.0) * 0.3, 0.05, c_rgb(70.0, 110.0, 60.0))
            bind k = k + 1.0
        }
        ss_swarm("Fireflies", 0.0, 0.0 - 2.0, 6.0, 60.0, 14.0, 1.0, 0.08, 0.7, c_rgb(255.0, 235.0, 110.0), "")
        ss_light("Lantern", 0.0, 0.0 - 4.0, 30.0, c_rgb(255.0, 170.0, 80.0), 2.0, 60.0)
    } else if id == "chapter3" {
        sc_set("bg", c_rgb(18.0, 10.0, 30.0))
        if g >= 0.0 { nd_set(g, "col", c_rgb(34.0, 22.0, 52.0)) }
        bind pal = ss_model(root, "palace", "High court", 0.0, 0.0, 12.0, 12.0, 3.1416, C_INDIGO)
        nd_set(pal, "ex", "hue=0.85;gain=1.05")
        bind k = 0.0
        while k < 6.0 {
            ss_node("cylinder", "Pillar " + num_str(k + 1.0), cos(k * 1.047) * 9.0, 0.0 - 4.0, sin(k * 1.047) * 9.0 + 2.0, 0.6, 4.0, 0.6, c_rgb(90.0, 70.0, 130.0))
            bind k = k + 1.0
        }
        ss_swarm("Instruments", 0.0, 0.0 - 3.0, 2.0, 10.0, 6.0, 2.0, 0.3, 0.5, c_rgb(250.0, 210.0, 120.0), "rainbow=1")
        bind comet = ss_node("sphere", "Cold comet", 0.0 - 60.0, 0.0 - 30.0, 40.0, 0.8, 0.8, 0.8, c_rgb(200.0, 240.0, 255.0))
        key_add(nd_id(comet), "px", 0.0, 0.0 - 60.0, "linear")
        key_add(nd_id(comet), "px", total - 8.0, 0.0 - 60.0, "linear")
        key_add(nd_id(comet), "px", total - 3.0, 60.0, "quad_in")
        key_add(nd_id(comet), "py", total - 8.0, 0.0 - 30.0, "linear")
        key_add(nd_id(comet), "py", total - 3.0, 0.0 - 2.0, "quad_in")
        clip_add("fx", total - 3.2, 0.6, "flash", "", "", "1", 0.0, "linear")
        clip_add("fx", total - 3.0, 1.2, "shake", "", "", "1", 0.0, "linear")
    } else if id == "chapter4" {
        sc_set("bg", c_rgb(0.0, 0.0, 0.0))
        if g >= 0.0 { nd_set(g, "vis", 0.0) }
        bind k = 0.0
        while k < 14.0 {
            bind ring = ss_node("torus", "Tunnel " + num_str(k + 1.0), 0.0, 0.0 - 2.0, k * 5.0 - 12.0, 7.0, 7.0, 7.0, c_pal(k))
            nd_set(ring, "rx", 1.5708)
            ss_spin_keys(ring, total, sel(k % 2.0 < 0.5, 1.0, 0.0 - 1.0))
            bind k = k + 1.0
        }
        bind k = 0.0
        while k < 4.0 {
            bind drum = ss_node("cylinder", "Bronze drum " + num_str(k + 1.0), cos(k * 1.57) * 4.5, 0.0 - 2.0 + sin(k * 1.57) * 4.5, 6.0 + k * 3.0, 1.0, 0.25, 1.0, c_rgb(190.0, 120.0, 60.0))
            nd_set(drum, "rx", 1.5708)
            bind k = k + 1.0
        }
        bind water = ss_node("torus", "Water spirit", 3.0, 0.0 - 2.5, 2.0, 1.2, 0.5, 1.2, c_rgb(80.0, 180.0, 255.0))
        ss_orbit_keys(water, 0.0, 0.0 - 2.5, 2.0, 3.5, 1.0, 0.0, total, 3.0)
        bind fire = ss_node("cone", "Fire spirit", 0.0 - 3.0, 0.0 - 2.5, 2.0, 0.9, 1.4, 0.9, c_rgb(255.0, 120.0, 40.0))
        ss_orbit_keys(fire, 0.0, 0.0 - 2.5, 2.0, 3.5, 1.0, 0.0, total, 0.0 - 3.0)
        ss_swarm("Embers", 0.0, 0.0 - 2.0, 10.0, 120.0, 20.0, 1.0, 0.08, 1.0, c_rgb(255.0, 200.0, 120.0), "rainbow=1")
        ss_light("Spirit light", 0.0, 0.0 - 3.0, 2.0, c_rgb(120.0, 200.0, 255.0), 2.5, 30.0)
    } else if id == "chapter5" {
        sc_set("bg", c_rgb(40.0, 0.0, 60.0))
        if g >= 0.0 { nd_set(g, "col", c_rgb(60.0, 10.0, 80.0)) }
        bind cold = ss_node("sphere", "The cold light", 0.0, 0.0 - 6.0, 12.0, 2.6, 2.6, 2.6, c_rgb(220.0, 250.0, 255.0))
        key_add(nd_id(cold), "sx", 0.0, 2.6, "linear")
        key_add(nd_id(cold), "sx", total * 0.6, 4.0, "sine_in_out")
        key_add(nd_id(cold), "sx", total, 0.4, "quad_in")
        ss_light("Cold glow", 0.0, 0.0 - 6.0, 12.0, c_rgb(200.0, 240.0, 255.0), 4.0, 60.0)
        ss_swarm("Halo", 0.0, 0.0 - 5.0, 6.0, 30.0, 10.0, 0.0, 0.4, 1.5, c_rgb(255.0, 220.0, 255.0), "rainbow=1")
        ss_swarm("Snow", 0.0, 0.0 - 10.0, 6.0, 200.0, 30.0, 1.0, 0.06, 0.5, c_rgb(240.0, 245.0, 255.0), "")
        bind ft = 2.0
        while ft < total * 0.6 {
            clip_add("fx", ft, 0.35, "flash", "", "", "0.5", 0.0, "linear")
            bind ft = ft + 1.6
        }
        clip_add("fx", total * 0.2, 0.8, "shake", "", "", "1", 0.0, "linear")
        clip_add("fx", total * 0.4, 0.8, "shake", "", "", "1", 0.0, "linear")
        clip_add("fx", total * 0.58, 1.2, "shake", "", "", "1", 0.0, "linear")
    } else if id == "chapter6" {
        sc_set("bg", c_rgb(16.0, 6.0, 26.0))
        if g >= 0.0 { nd_set(g, "vis", 0.0) }
        ss_model(root, "palace_p0", "Breathing floor", 0.0, 2.0, 4.0, 8.0, 3.1416, C_INDIGO)
        bind piece = ["palace_p1", "palace_p2", "palace_p4", "palace_p1", "palace_p2"]
        bind k = 0.0
        while k < 5.0 {
            bind m = ss_model(root, list_get(piece, k), "Shard " + num_str(k + 1.0), 0.0, 0.0 - 3.0, 4.0, 3.0, 0.0, c_pal(k + 1.0))
            nd_set(m, "ex", "hue=0.5;gain=1.1")
            ss_orbit_keys(m, 0.0, 0.0 - 3.0, 4.0, 9.0 + k, 1.5, 0.0, total, 1.0 + k * 0.2)
            bind k = k + 1.0
        }
        bind daemon = ss_node("sphere", "Daemon", 2.0, 0.0 - 2.0, 2.0, 0.9, 0.9, 0.9, c_rgb(95.0, 30.0, 85.0))
        bind eye1 = nd_add("sphere", "Eye L", nd_id(daemon))
        nd_set(eye1, "px", 0.0 - 0.35)  nd_set(eye1, "py", 0.0 - 0.15)  nd_set(eye1, "pz", 0.0 - 0.75)  nd_set(eye1, "sx", 0.22)  nd_set(eye1, "sy", 0.22)  nd_set(eye1, "sz", 0.22)  nd_set(eye1, "col", c_rgb(255.0, 205.0, 60.0))
        bind eye2 = nd_add("sphere", "Eye R", nd_id(daemon))
        nd_set(eye2, "px", 0.35)  nd_set(eye2, "py", 0.0 - 0.15)  nd_set(eye2, "pz", 0.0 - 0.75)  nd_set(eye2, "sx", 0.22)  nd_set(eye2, "sy", 0.22)  nd_set(eye2, "sz", 0.22)  nd_set(eye2, "col", c_rgb(255.0, 205.0, 60.0))
        bind w1 = nd_add("box", "Wing L", nd_id(daemon))
        nd_set(w1, "px", 0.0 - 1.1)  nd_set(w1, "py", 0.0 - 0.3)  nd_set(w1, "sx", 0.6)  nd_set(w1, "sy", 0.35)  nd_set(w1, "sz", 0.05)  nd_set(w1, "col", c_rgb(150.0, 70.0, 140.0))
        bind w2 = nd_add("box", "Wing R", nd_id(daemon))
        nd_set(w2, "px", 1.1)  nd_set(w2, "py", 0.0 - 0.3)  nd_set(w2, "sx", 0.6)  nd_set(w2, "sy", 0.35)  nd_set(w2, "sz", 0.05)  nd_set(w2, "col", c_rgb(150.0, 70.0, 140.0))
        key_add(nd_id(daemon), "py", 0.0, 0.0 - 2.0, "linear")
        key_add(nd_id(daemon), "py", 1.5, 0.0 - 2.6, "sine_in_out")
        key_add(nd_id(daemon), "py", 3.0, 0.0 - 2.0, "sine_in_out")
        ss_knot("Letter ring", 0.0, 0.0 - 3.0, 4.0, 26.0, 5.0, 1.8, 0.04, 0.25)
        ss_light("Warm aura", 2.0, 0.0 - 3.0, 1.0, c_rgb(255.0, 170.0, 120.0), 2.0, 20.0)
    } else if id == "chapter7" {
        sc_set("bg", c_rgb(2.0, 4.0, 16.0))
        if g >= 0.0 { nd_set(g, "py", 0.5)  nd_set(g, "sx", 300.0)  nd_set(g, "sz", 300.0)  nd_set(g, "col", c_rgb(6.0, 10.0, 40.0))  nd_set(g, "name", "Star sea") }
        ss_swarm("Stars", 0.0, 0.0 - 30.0, 20.0, 220.0, 60.0, 1.0, 0.07, 0.02, c_rgb(220.0, 225.0, 255.0), "")
        ss_swarm("Reflections", 0.0, 12.0, 20.0, 120.0, 60.0, 1.0, 0.05, 0.02, c_rgb(120.0, 130.0, 200.0), "flat=1")
        ss_swarm("Lantern buoys", 0.0, 0.0 - 0.6, 10.0, 12.0, 20.0, 2.0, 0.35, 0.2, c_rgb(255.0, 170.0, 70.0), "flat=1")
        bind wheel = ss_node("torus", "Galaxy wheel", 0.0, 0.0 - 24.0, 70.0, 24.0, 24.0, 24.0, c_rgb(150.0, 90.0, 220.0))
        nd_set(wheel, "rx", 1.2)
        ss_spin_keys(wheel, total, 0.3)
        bind train = ss_node("box", "Train of light", 0.0 - 80.0, 0.0 - 6.0, 40.0, 7.0, 0.3, 0.5, c_rgb(255.0, 240.0, 200.0))
        key_add(nd_id(train), "px", 0.0, 0.0 - 90.0, "linear")
        key_add(nd_id(train), "px", total * 0.25, 0.0 - 90.0, "linear")
        key_add(nd_id(train), "px", total * 0.45, 90.0, "linear")
        bind daemon = ss_node("sphere", "Daemon", 0.0, 0.0 - 3.0, 3.0, 0.7, 0.7, 0.7, c_rgb(95.0, 30.0, 85.0))
        ss_orbit_keys(daemon, 0.0, 0.0 - 3.0, 3.0, 4.0, 0.8, 0.0, total, 2.0)
        ss_light("Moon", 0.0, 0.0 - 30.0, 30.0, c_rgb(180.0, 200.0, 255.0), 2.5, 120.0)
    } else if id == "chapter8" {
        sc_set("bg", c_rgb(236.0, 80.0, 120.0))
        sc_set("amb", 0.5)
        if g >= 0.0 { nd_set(g, "col", c_rgb(250.0, 150.0, 60.0)) }
        ss_swarm("Screentone", 0.0, 0.0 - 8.0, 10.0, 160.0, 40.0, 1.0, 0.12, 0.1, c_rgb(120.0, 30.0, 70.0), "")
        bind k = 0.0
        while k < 5.0 {
            bind m = ss_model(root, "palace_p" + num_str(k), "Palace piece " + num_str(k), cos(k * 1.257) * 30.0, 0.0 - 20.0 - k * 4.0, 20.0 + sin(k * 1.257) * 20.0, 10.0, 3.1416, c_pal(k))
            key_add(nd_id(m), "px", 0.0, cos(k * 1.257) * 30.0, "linear")
            key_add(nd_id(m), "px", total * 0.6, 0.0, "cubic_in_out")
            key_add(nd_id(m), "py", 0.0, 0.0 - 20.0 - k * 4.0, "linear")
            key_add(nd_id(m), "py", total * 0.6, 1.0, "cubic_in_out")
            key_add(nd_id(m), "pz", 0.0, 20.0 + sin(k * 1.257) * 20.0, "linear")
            key_add(nd_id(m), "pz", total * 0.6, 10.0, "cubic_in_out")
            key_add(nd_id(m), "ry", 0.0, k * 2.0, "linear")
            key_add(nd_id(m), "ry", total * 0.6, 3.1416, "cubic_in_out")
            bind k = k + 1.0
        }
        bind daemon = ss_node("sphere", "Daemon", 0.0, 0.0 - 4.0, 4.0, 0.9, 0.9, 0.9, c_rgb(95.0, 30.0, 85.0))
        key_add(nd_id(daemon), "py", 0.0, 0.0 - 4.0, "linear")
        key_add(nd_id(daemon), "py", 1.0, 0.0 - 4.6, "sine_in_out")
        key_add(nd_id(daemon), "py", 2.0, 0.0 - 4.0, "sine_in_out")
        ss_knot("Letter choir", 0.0, 0.0 - 6.0, 6.0, 30.0, 5.0, 2.0, 0.08, 0.3)
    } else if str_starts_with(id, "finale") {
        sc_set("bg", c_rgb(6.0, 4.0, 12.0))
        if g >= 0.0 { nd_set(g, "vis", 0.0) }
        ss_model(root, "palace_p0", "Palace floor", 0.0, 2.0, 6.0, 10.0, 3.1416, C_INDIGO)
        ss_node("box", "The gap", 0.0, 1.9, 6.0, 6.0, 0.08, 1.5, c_rgb(0.0, 0.0, 0.0))
        bind coda = ss_node("sphere", "Coda", 3.0, 0.0 - 2.0, 4.0, 0.4, 0.4, 0.4, c_rgb(255.0, 250.0, 160.0))
        ss_orbit_keys(coda, 0.0, 0.0 - 2.5, 4.0, 4.0, 1.0, 0.0, total, 6.0)
        bind daemon = ss_node("sphere", "Daemon", 0.0 - 3.0, 0.0 - 2.0, 3.0, 0.8, 0.8, 0.8, c_rgb(95.0, 30.0, 85.0))
        ss_swarm("Stars", 0.0, 0.0 - 20.0, 20.0, 200.0, 60.0, 1.0, 0.06, 0.02, c_rgb(200.0, 210.0, 255.0), "")
        if id != "finale1" {
            bind water = ss_node("torus", "Water spirit", 4.0, 0.0 - 3.0, 2.0, 1.2, 0.5, 1.2, c_rgb(80.0, 180.0, 255.0))
            ss_orbit_keys(water, 0.0, 0.0 - 3.0, 4.0, 5.0, 1.2, 0.0, total, 2.0)
            bind fire = ss_node("cone", "Fire spirit", 0.0 - 4.0, 0.0 - 3.0, 2.0, 0.9, 1.4, 0.9, c_rgb(255.0, 120.0, 40.0))
            ss_orbit_keys(fire, 0.0, 0.0 - 3.0, 4.0, 5.0, 1.2, 0.0, total, 0.0 - 2.0)
        }
        if id == "finale3" {
            ss_knot("Final chord", 0.0, 0.0 - 5.0, 6.0, 36.0, 6.0, 2.4, 0.1, 0.3)
            clip_add("fx", total - 2.5, 1.0, "flash", "", "", "1", 0.0, "linear")
        }
        ss_light("Lamp", 0.0, 0.0 - 6.0, 2.0, c_rgb(255.0, 200.0, 150.0), 2.0, 40.0)
    } else if id == "bonus" {
        sc_set("bg", c_rgb(4.0, 4.0, 10.0))
        if g >= 0.0 { nd_set(g, "col", c_rgb(10.0, 14.0, 30.0)) }
        nd_set(king, "vis", 0.0)
        nd_set(queen, "vis", 0.0)
        bind loop = ss_node("torus", "Loop-de-loop", 0.0, 0.0 - 8.0, 10.0, 8.0, 8.0, 8.0, c_rgb(80.0, 220.0, 255.0))
        nd_set(loop, "rx", 1.5708)
        bind k = 0.0
        while k < 6.0 {
            bind r = ss_node("torus", "Rail " + num_str(k + 1.0), 0.0, 0.0 - 2.0, 0.0 - 10.0 + k * 4.0, 2.5, 2.5, 2.5, c_pal(k))
            nd_set(r, "rx", 1.5708)
            bind k = k + 1.0
        }
        bind coda = ss_node("sphere", "Coda", 0.0, 0.0 - 2.0, 0.0, 0.5, 0.5, 0.5, c_rgb(255.0, 250.0, 160.0))
        bind steps = 24.0
        bind s = 0.0
        while s <= steps {
            bind u = s / steps
            bind a = u * 6.2831853 * 2.0
            key_add(nd_id(coda), "px", total * u, sin(a) * 0.5, "linear")
            key_add(nd_id(coda), "py", total * u, 0.0 - 8.0 + cos(a) * 8.0, "linear")
            key_add(nd_id(coda), "pz", total * u, 10.0 + sin(a) * 8.0, "linear")
            bind s = s + 1.0
        }
        bind i = 0.0
        while i < clip_count() {
            if clip_get(i, "track") == "camera" { clip_set(i, "kind", "follow")  clip_set(i, "c", "0,-1.5,6,0.2")  clip_set(i, "node", nd_id(coda)) }
            bind i = i + 1.0
        }
        ss_swarm("Sparks", 0.0, 0.0 - 6.0, 8.0, 80.0, 16.0, 1.0, 0.1, 2.0, c_rgb(255.0, 240.0, 160.0), "rainbow=1")
        clip_add("fx", total - 2.0, 1.2, "flash", "", "", "1", 0.0, "linear")
    }
}

# ── the hub cathedral from game/level.ling: floor y=3, ceiling -171, room +-256, doors +-40, four wings
fn ss_build_hub(root) {
    new_scene("3d")
    sc_set("name", "Hub Cathedral")
    sc_set("bg", c_rgb(4.0, 5.0, 14.0))
    sc_set("floor", 3.0)
    sc_set("amb", 0.3)
    sc_set("ex", "fog=8,10,30,160,900")
    bind music = ss_music_file(root, "SoulSymphony")
    if music != "" { sc_set("music", music) }
    bind g = nd_find_name("Ground")
    if g >= 0.0 { nd_set(g, "py", 3.0)  nd_set(g, "sx", 1000.0)  nd_set(g, "sz", 1000.0)  nd_set(g, "col", c_rgb(12.0, 16.0, 46.0)) }
    bind ceil = ss_node("plane", "Ceiling", 0.0, 0.0 - 171.0, 0.0, 300.0, 0.02, 300.0, c_rgb(8.0, 10.0, 30.0))
    nd_set(ceil, "tag", "")
    bind wallc = c_rgb(28.0, 34.0, 82.0)
    bind hgt = 87.0
    bind cy = 3.0 - hgt
    ss_wall("Wall N-left", 0.0 - 148.0, cy, 0.0 - 256.0, 108.0, hgt, 2.0, wallc)
    ss_wall("Wall N-right", 148.0, cy, 0.0 - 256.0, 108.0, hgt, 2.0, wallc)
    ss_wall("Wall S-left", 0.0 - 148.0, cy, 256.0, 108.0, hgt, 2.0, wallc)
    ss_wall("Wall S-right", 148.0, cy, 256.0, 108.0, hgt, 2.0, wallc)
    ss_wall("Wall W-near", 0.0 - 256.0, cy, 0.0 - 148.0, 2.0, hgt, 108.0, wallc)
    ss_wall("Wall W-far", 0.0 - 256.0, cy, 148.0, 2.0, hgt, 108.0, wallc)
    ss_wall("Wall E-near", 256.0, cy, 0.0 - 148.0, 2.0, hgt, 108.0, wallc)
    ss_wall("Wall E-far", 256.0, cy, 148.0, 2.0, hgt, 108.0, wallc)
    # the four wings: corridors 80 wide running out to z / x = +-960
    ss_wall("North corridor W", 0.0 - 41.0, cy, 0.0 - 608.0, 1.0, hgt, 352.0, wallc)
    ss_wall("North corridor E", 41.0, cy, 0.0 - 608.0, 1.0, hgt, 352.0, wallc)
    ss_wall("South corridor W", 0.0 - 41.0, cy, 608.0, 1.0, hgt, 352.0, wallc)
    ss_wall("South corridor E", 41.0, cy, 608.0, 1.0, hgt, 352.0, wallc)
    ss_wall("West corridor N", 0.0 - 608.0, cy, 0.0 - 41.0, 352.0, hgt, 1.0, wallc)
    ss_wall("West corridor S", 0.0 - 608.0, cy, 41.0, 352.0, hgt, 1.0, wallc)
    ss_wall("East corridor N", 608.0, cy, 0.0 - 41.0, 352.0, hgt, 1.0, wallc)
    ss_wall("East corridor S", 608.0, cy, 41.0, 352.0, hgt, 1.0, wallc)
    bind pc = c_rgb(40.0, 30.0, 70.0)
    bind k = 0.0
    while k < 8.0 {
        bind ang = k * 0.7854 + 0.3927
        ss_wall("Pillar " + num_str(k + 1.0), cos(ang) * 120.0, cy, sin(ang) * 120.0, 14.0, hgt, 14.0, pc)
        bind k = k + 1.0
    }
    # the court
    bind king = ss_model(root, "king", "King", 0.0 - 14.0, 3.0, 60.0, 9.0, 3.1416, C_YELLOW)
    bind queen = ss_model(root, "queen", "Queen", 14.0, 3.0, 60.0, 9.0, 3.1416, C_VIOLET)
    bind temple = ss_model(root, "palace_temple", "Temple", 0.0, 3.0, 200.0, 60.0, 3.1416, C_INDIGO)
    nd_set(temple, "ex", "hue=0.3;gain=1.0")
    ss_knot("Court mandala", 0.0, 0.0 - 30.0, 60.0, 30.0, 18.0, 7.0, 0.04, 1.0)
    ss_swarm("Dust", 0.0, 0.0 - 40.0, 0.0, 200.0, 200.0, 1.0, 0.25, 0.1, c_rgb(120.0, 140.0, 220.0), "")
    # the orb, spawn, camera, lights, triggers to the wings
    bind a = nd_add("actor", "Orb", 0.0)
    nd_set(a, "px", 0.0)
    nd_set(a, "py", 2.1)
    nd_set(a, "pz", 0.0 - 40.0)
    nd_set(a, "sx", 0.9)
    nd_set(a, "sy", 0.9)
    nd_set(a, "sz", 0.9)
    nd_set(a, "d", 16.0)
    nd_set(a, "col", c_rgb(235.0, 240.0, 255.0))
    nd_set(a, "scr", "player")
    nd_set(a, "ex", "shape=sphere")
    bind s = nd_add("spawn", "Spawn", 0.0)
    nd_set(s, "py", 3.0)
    nd_set(s, "pz", 0.0 - 40.0)
    bind c = nd_find_name("Camera")
    if c >= 0.0 { nd_set(c, "b", 2.0)  nd_set(c, "txt", "Orb")  nd_set(c, "d", 22.0)  nd_set(c, "rx", 0.32) }
    bind l = nd_find_name("Sun")
    if l >= 0.0 { nd_set(l, "py", 0.0 - 80.0)  nd_set(l, "b", 600.0)  nd_set(l, "a", 3.0)  nd_set(l, "col", c_rgb(200.0, 210.0, 255.0)) }
    bind k = 0.0
    while k < 8.0 {
        bind ang = k * 0.7854
        ss_light("Lantern " + num_str(k + 1.0), cos(ang) * 70.0, 0.0 - 12.0, sin(ang) * 70.0, c_pal(k), 2.0, 90.0)
        bind k = k + 1.0
    }
    bind wings = ["North wing|0|-250", "South wing|0|250", "West wing|-250|0", "East wing|250|0"]
    bind i = 0.0
    while i < 4.0 {
        bind p = str_split(list_get(wings, i), "|")
        bind t = nd_add("trigger", list_get(p, 0), 0.0)
        nd_set(t, "px", to_number(list_get(p, 1)))
        nd_set(t, "py", 0.0)
        nd_set(t, "pz", to_number(list_get(p, 2)))
        nd_set(t, "sx", 40.0)
        nd_set(t, "sy", 6.0)
        nd_set(t, "sz", 12.0)
        nd_set(t, "a", 0.0)
        nd_set(t, "txt", "say:" + list_get(p, 0) + " - the levels beyond are generated by the original game (proc_gen_level.ling)")
        bind i = i + 1.0
    }
    bind t2 = nd_add("trigger", "Court", 0.0)
    nd_set(t2, "py", 0.0)
    nd_set(t2, "pz", 40.0)
    nd_set(t2, "sx", 24.0)
    nd_set(t2, "sy", 6.0)
    nd_set(t2, "sz", 8.0)
    nd_set(t2, "a", 1.0)
    nd_set(t2, "txt", "cutscene:scenes/chapter1.lscene")
    bind t3 = nd_add("trigger", "Back to the menu", 0.0)
    nd_set(t3, "py", 0.0)
    nd_set(t3, "pz", 0.0 - 240.0)
    nd_set(t3, "sx", 40.0)
    nd_set(t3, "sy", 6.0)
    nd_set(t3, "sz", 6.0)
    nd_set(t3, "a", 0.0)
    nd_set(t3, "txt", "say:Esc returns to the menu")
    save_scene(p_join(proj_root(), "scenes/hub.lscene"))
}
fn ss_wall(name, x, y, z, sx, sy, sz, col) {
    bind b = nd_add("box", name, 0.0)
    nd_set(b, "px", x)
    nd_set(b, "py", y)
    nd_set(b, "pz", z)
    nd_set(b, "sx", sx)
    nd_set(b, "sy", sy)
    nd_set(b, "sz", sz)
    nd_set(b, "col", col)
    nd_set(b, "tag", "solid")
    return b
}

← Back to index