灵符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/rt/rt_game.ling
# rt_game.ling - the game runtime: scene loading and switching, the player controller, camera rigs,
# triggers, timelines and cutscenes, HUD overlays, 2-D menus with 3-D backgrounds, music, MIDI,
# scripts hooks, co-op networking. The editor's Play uses this in its viewport; exported games
# run it full screen from the generated main.ling.

fn game_quit() { return st_get("game_quit", 0.0) }
fn game_time() { return st_get("game_t", 0.0) }
fn game_dt() { return st_get("game_dt", 0.016) }
fn game_var(k) { return st_get("gv_" + k, 0.0) }
fn game_set(k, v) { st_set("gv_" + k, v) }
fn game_root() { return st_get("proj_root", "") }
fn game_path(p) {
    if p == "" { return "" }
    if p_is_abs(p) || game_root() == "" { return p }
    if str_starts_with(p_norm(p), game_root()) { return p_norm(p) }
    return p_join(game_root(), p)
}

# ── boot / shutdown ──────────────────────────────────────────────────────────
fn game_boot(start_scene, title, w, h) {
    game_setup_common(title, w, h)
    bind first = start_scene
    if st_get("game_embedded", 0.0) < 0.5 && st_get("game_start_override", "") != "" { bind first = st_get("game_start_override", "") }
    game_goto(first)
}
# play a scene from text (the editor plays unsaved scenes this way)
fn game_boot_text(text, title, w, h) {
    game_setup_common(title, w, h)
    nd_use("")
    sc_parse(text)
    game_scene_enter()
}
fn game_setup_common(title, w, h) {
    st_set("game_music_cur", "")
    st_set("game_quit", 0.0)
    st_set("game_t", 0.0)
    st_set("game_now", time_now())
    st_set("game_scene_t", 0.0)
    st_set("game_title", title)
    st_set("game_say_until", 0.0)
    st_set("game_say_text", "")
    st_set("game_msg", "")
    st_lclear("game_stack")
    st_set("game_cut_return", "")
    st_set("game_hud", "")
    st_set("game_bg", "")
    if st_get("game_embedded", 0.0) < 0.5 {
        # --shot N takes a screenshot after N frames and quits (headless checks); --size WxH overrides the window
        bind args = plat_args()
        bind ai = 0.0
        while ai < len(args) {
            if list_get(args, ai) == "--shot" && ai + 1.0 < len(args) { st_set("game_shot_at", to_number(list_get(args, ai + 1.0))) }
            if list_get(args, ai) == "--scene" && ai + 1.0 < len(args) { st_set("game_start_override", list_get(args, ai + 1.0)) }
            if list_get(args, ai) == "--autoplay" { st_set("game_autoplay", 1.0) }
            if list_get(args, ai) == "--shots" && ai + 1.0 < len(args) { st_set("game_shots", list_get(args, ai + 1.0)) }
            if list_get(args, ai) == "--size" && ai + 1.0 < len(args) {
                bind sp = str_split(list_get(args, ai + 1.0), "x")
                if len(sp) > 1.5 { bind w = to_number(list_get(sp, 0))  bind h = to_number(list_get(sp, 1)) }
            }
            bind ai = ai + 1.0
        }
        st_set("game_frames", 0.0)
        open_window(w, h, title, 1.0)
        px_flat()
        if st_get("font", 0.0 - 1.0) < 0.0 || st_has("font") < 0.5 {
            bind f = font_load(game_path("assets/fonts/ui.ttf"))
            if f < 0.0 { bind f = font_load("assets/font.ttf") }
            st_set("font", f)
        }
        if game_root() == "" { st_set("proj_root", fs_cwd()) }
    }
}
fn game_shutdown() {
    if plat_has_audio() { plat_music_stop() }
    st_set("game_music_cur", "")
    st_set("game_quit", 1.0)
}

# ── scenes ───────────────────────────────────────────────────────────────────
fn game_goto(path) {
    bind full = game_path(path)
    nd_use("")
    if !sc_load(full) {
        st_set("game_msg", "Scene not found: " + path)
        return false
    }
    bind prev = st_get("game_scene", "")
    if prev != "" && prev != path && st_get("game_nav_back", 0.0) < 0.5 {
        st_lpush("game_stack", prev)
        while st_llen("game_stack") > 16.0 { st_ldel("game_stack", 0.0) }
    }
    st_set("game_nav_back", 0.0)
    st_set("game_scene", path)
    game_scene_enter()
    return true
}
fn game_scene_enter() {
    st_set("game_scene_t", 0.0)
    st_set("game_scene_t0", st_get("game_t", 0.0))
    st_lclear("trig_done")
    cut_reset()
    # HUD overlay named by the scene
    st_set("game_hud", "")
    bind hud = sc_get("hud", "")
    if hud != "" { game_push_hud(hud) }
    # 3-D background of a 2-D menu
    st_set("game_bg", "")
    bind bg = sc_ex("bg3d", "")
    if bg != "" {
        nd_use("b")
        if sc_load(game_path(bg)) { st_set("game_bg", bg) }
        nd_use("")
    }
    # music
    if plat_has_audio() {
        bind m = sc_get("music", "")
        if m != "" {
            # the same track carries on across scenes (title -> mission select -> difficulty)
            if m != st_get("game_music_cur", "") {
                bind mid = plat_music_load(game_path(m))
                if mid >= 0.0 { plat_music_volume(0.8)  plat_music_play(mid)  st_set("game_music_cur", m) }
            }
        } else if sc_get("kind", "3d") != "cutscene" { plat_music_stop()  st_set("game_music_cur", "") }
    }
    # player spawn
    bind p = game_player()
    bind s = nd_find_tag("player")
    bind sidx = 0.0 - 1.0
    bind i = 0.0
    while i < nd_count() {
        if nd_get(i, "kind") == "spawn" && (nd_get(i, "tag") == "player" || sidx < 0.0) { bind sidx = i }
        bind i = i + 1.0
    }
    if p >= 0.0 && sidx >= 0.0 {
        bind wp = nd_world_pos(sidx)
        nd_set(p, "px", list_get(wp, 0))
        nd_set(p, "py", list_get(wp, 1) - nd_get(p, "sy"))
        nd_set(p, "pz", list_get(wp, 2))
        nd_set(p, "ry", nd_get(sidx, "ry"))
    }
    st_set("pl_vy", 0.0)
    st_set("pl_ground", 1.0)
    # camera start
    bind c = game_camera_node()
    if c >= 0.0 && nd_get(c, "b") < 0.5 {
        bind wp = nd_world_pos(c)
        cam_set(list_get(wp, 0), list_get(wp, 1), list_get(wp, 2), nd_get(c, "ry"), nd_get(c, "rx"), max(0.5, nd_get(c, "a")))
    } else if p >= 0.0 {
        bind wp = nd_world_pos(p)
        cam_orbit(list_get(wp, 0), list_get(wp, 1) - 1.0, list_get(wp, 2), nd_get(p, "ry"), 0.3, 9.0, 2.0)
    } else {
        cam_orbit(sc_get("camx", 0.0), sc_get("camy", 0.0), sc_get("camz", 0.0), sc_get("camyaw", 0.6), sc_get("campitch", 0.4), sc_get("camzd", 16.0), 2.0)
    }
    # music / midi nodes with autoplay, model animation state
    bind i = 0.0
    while i < nd_count() {
        bind k = nd_get(i, "kind")
        if k == "music" && nd_get(i, "c") > 0.5 && plat_has_audio() && nd_get(i, "src") != "" {
            bind mid = plat_music_load(game_path(nd_get(i, "src")))
            if mid >= 0.0 { plat_music_volume(nd_get(i, "a"))  plat_music_play(mid)  st_set("game_music_cur", "") }
        }
        if k == "audio" && nd_get(i, "c") > 0.5 && plat_has_audio() && nd_get(i, "src") != "" {
            bind sid = plat_sample_load(game_path(nd_get(i, "src")))
            if sid >= 0.0 {
                bind wp = nd_world_pos(i)
                plat_sample_play(sid, list_get(wp, 0), list_get(wp, 1), list_get(wp, 2), nd_get(i, "d"), nd_get(i, "a"), nd_get(i, "b"))
            }
        }
        if k == "midi" && nd_get(i, "src") != "" && plat_has_midi() {
            bind mid = plat_midi_load(game_path(nd_get(i, "src")))
            st_set("midi_" + num_str(nd_id(i)), mid)
            if mid >= 0.0 { st_set("midin_" + num_str(nd_id(i)), plat_midi_bars(mid)) }
        }
        bind i = i + 1.0
    }
    if sc_ex("mode", "") == "fps" { fps_enter() } else { fps_leave() }
    if sc_ex("mode", "") == "brick" { brick_enter() } else { brick_leave() }
    if sc_ex("mode", "") == "soul" { soul_enter() } else { soul_leave() }
    if sc_ex("mode", "") == "story" { story_enter() } else { story_leave() }
    game_scripts_call("ready", 0.0, "")
}
fn game_push_hud(path) {
    nd_use("h")
    bind ok = sc_load(game_path(path))
    nd_use("")
    if ok { st_set("game_hud", path) }
    return ok
}
fn game_pop_hud() { st_set("game_hud", "") }
fn game_play_cutscene(path) {
    st_set("game_cut_return", st_get("game_scene", ""))
    game_goto(path)
}

# ── queries ──────────────────────────────────────────────────────────────────
fn game_player() {
    bind n = nd_count()
    bind i = 0.0
    while i < n {
        if nd_get(i, "kind") == "actor" && nd_get(i, "tag") == "player" { return i }
        bind i = i + 1.0
    }
    return 0.0 - 1.0
}
fn game_camera_node() {
    bind n = nd_count()
    bind i = 0.0
    while i < n {
        if nd_get(i, "kind") == "camera" && nd_get(i, "vis") > 0.5 { return i }
        bind i = i + 1.0
    }
    return 0.0 - 1.0
}
fn game_find(name) { return nd_find_name(name) }
fn game_find_tag(tag) { return nd_find_tag(tag) }
fn game_spawn(kind, name, x, y, z) {
    bind i = nd_add(kind, name, 0.0)
    nd_set(i, "px", x)
    nd_set(i, "py", y)
    nd_set(i, "pz", z)
    return i
}
fn game_destroy(i) {
    if i >= 0.0 && i < nd_count() { nd_del_id(nd_id(i)) }
}
fn game_respawn(i) {
    bind sidx = nd_find_tag("player")
    bind j = 0.0
    while j < nd_count() {
        if nd_get(j, "kind") == "spawn" { bind sidx = j }
        bind j = j + 1.0
    }
    if sidx >= 0.0 && nd_get(sidx, "kind") == "spawn" {
        bind wp = nd_world_pos(sidx)
        nd_set(i, "px", list_get(wp, 0))
        nd_set(i, "py", list_get(wp, 1) - nd_get(i, "sy"))
        nd_set(i, "pz", list_get(wp, 2))
    } else { nd_set(i, "px", 0.0)  nd_set(i, "py", 0.0 - nd_get(i, "sy"))  nd_set(i, "pz", 0.0) }
    st_set("pl_vy", 0.0)
}
fn game_say(speaker, text, secs) {
    st_set("game_say_speaker", speaker)
    st_set("game_say_text", text)
    st_set("game_say_until", st_get("game_t", 0.0) + secs)
}
fn game_sound(path, x, y, z) {
    if !plat_has_audio() { return 0.0 }
    bind sid = plat_sample_load(game_path(path))
    if sid >= 0.0 { plat_sample_play(sid, x, y, z, 1.0, 1.0, 0.0) }
    return 0.0
}
fn game_music(path) {
    if !plat_has_audio() { return 0.0 }
    bind mid = plat_music_load(game_path(path))
    if mid >= 0.0 { plat_music_play(mid)  st_set("game_music_cur", path) }
    return 0.0
}
fn game_sfx(freq, dur) {
    if plat_has_audio() { plat_sfx(0.0, 0.0, 0.0, 1.0, freq, 0.3, dur, "sine") }
}
fn game_key(name) { return key_down(name) }
fn game_key_pressed(name) { return key_pressed(name) }
fn game_axis_x() {
    bind v = 0.0
    if key_down("a") || (key_down("left") && !fps_on()) { bind v = v - 1.0 }
    if key_down("d") || (key_down("right") && !fps_on()) { bind v = v + 1.0 }
    if plat_gamepad_connected() { bind v = v + plat_gamepad_lx() }
    return clampf(v, 0.0 - 1.0, 1.0)
}
fn game_axis_y() {
    bind v = 0.0
    if key_down("w") || key_down("up") { bind v = v + 1.0 }
    if key_down("s") || key_down("down") { bind v = v - 1.0 }
    if plat_gamepad_connected() { bind v = v - plat_gamepad_ly() }
    return clampf(v, 0.0 - 1.0, 1.0)
}
# MIDI helpers (midi node i)
fn game_midi_notes(i) { return st_get("midin_" + num_str(nd_id(i)), []) }
fn game_on_beat(i, t) {
    bind notes = game_midi_notes(i)
    bind n = floor(len(notes) / 3.0)
    bind k = 0.0
    while k < n {
        bind nt = list_get(notes, k * 3.0)
        if abs(nt - t) < 0.06 { return true }
        if nt > t + 0.1 { return false }
        bind k = k + 1.0
    }
    return false
}

# ── scripts ──────────────────────────────────────────────────────────────────
fn game_scripts_call(hook, dt, arg) {
    bind n = nd_count()
    bind i = 0.0
    while i < n {
        bind s = nd_get(i, "scr")
        if s != "" && nd_get(i, "vis") > 0.5 { user_script(s, hook, i, dt, arg) }
        bind i = i + 1.0
    }
}
fn game_input_keys() { return ["space", "enter", "e", "f", "q", "r", "tab", "1", "2", "3", "4", "escape", "j", "k", "x", "z"] }

# ── per-frame ────────────────────────────────────────────────────────────────
fn game_frame() {
    bind now = time_now()
    bind dt = clampf(now - st_get("game_now", now), 0.0, 0.05)
    st_set("game_now", now)
    st_set("game_dt", dt)
    st_set("game_t", st_get("game_t", 0.0) + dt)
    st_set("game_scene_t", st_get("game_scene_t", 0.0) + dt)
    if st_get("game_embedded", 0.0) < 0.5 { u_begin() }
    game_update(dt)
    bind W = get_width()
    bind H = get_height()
    game_render(0.0, 0.0, W, H)
    if st_get("game_embedded", 0.0) < 0.5 {
        u_end()
        present()
        if key_pressed("escape") && sc_get("kind", "3d") != "2d" && !fps_on() && !brick_on() && !soul_on() && !story_on() { st_set("game_quit", 1.0) }
        st_set("game_frames", st_get("game_frames", 0.0) + 1.0)
        if st_get("game_shot_at", 0.0) > 0.5 && st_get("game_frames", 0.0) >= st_get("game_shot_at", 0.0) {
            plat_screenshot("game")
            st_set("game_quit", 1.0)
        }
        # --shots 60,200,400: a screenshot at each frame, quit after the last
        if st_get("game_shots", "") != "" {
            bind sh = nums_parse(st_get("game_shots", ""))
            bind fr = st_get("game_frames", 0.0)
            bind k = 0.0
            while k < len(sh) {
                if list_get(sh, k) == fr { plat_screenshot("f" + num_str(fr)) }
                bind k = k + 1.0
            }
            if fr >= list_get(sh, len(sh) - 1.0) { st_set("game_quit", 1.0) }
        }
    }
}

fn game_update(dt) {
    nd_use("")
    bind kind = sc_get("kind", "3d")
    bind t = st_get("game_scene_t", 0.0)
    bind ln = sc_get("len", 10.0)
    bind tl_t = t
    if kind != "cutscene" && ln > 0.0 { bind tl_t = t - floor(t / ln) * ln }
    st_set("game_tl_t", tl_t)
    # network
    net_poll()
    # cutscene tracks
    if kind == "cutscene" || clip_count() > 0.0 {
        cut_tick(t, true)
        bind ev = st_get("cut_event", "")
        if ev != "" { game_handle_event(ev, st_get("cut_event_arg", "")) }
        if kind == "cutscene" && (t > ln || (key_pressed("escape") && st_get("game_embedded", 0.0) < 0.5)) { game_cutscene_done()  return 0.0 }
    }
    if brick_on() {
        brick_update(dt)
    } else if story_on() {
        story_update(dt)
    } else if soul_on() {
        soul_update(dt)
    } else if fps_on() {
        fps_update(dt)
        if fps_on() && st_get("fp_paused", 0.0) < 0.5 && st_get("fp_dead", 0.0) < 0.5 { game_triggers() }
    } else {
        if kind == "3d" || (kind == "cutscene" && game_player() >= 0.0) {
            game_player_update(dt)
            game_triggers()
        }
        if kind != "cutscene" { game_camera_update(dt) }
    }
    if kind == "2d" { game_menu_keys() }
    # input hooks
    bind keys = game_input_keys()
    bind k = 0.0
    while k < len(keys) {
        if key_pressed(list_get(keys, k)) { game_scripts_call("input", dt, list_get(keys, k)) }
        bind k = k + 1.0
    }
    game_scripts_call("update", dt, "")
    # background 3-D scene of a menu: slow orbit
    if st_get("game_bg", "") != "" {
        nd_use("b")
        bind spin = to_number(sc_ex("bgspin", "0.12"))
        bind sway = to_number(sc_ex("bgsway", "0"))
        bind yaw = sc_get("camyaw", 0.0) + st_get("game_t", 0.0) * spin + sin(st_get("game_t", 0.0) * 0.21) * sway
        cam_orbit(sc_get("camx", 0.0), sc_get("camy", 0.0), sc_get("camz", 0.0), yaw, sc_get("campitch", 0.35), sc_get("camzd", 16.0), to_number(sc_ex("bgzd", "2")))
        nd_use("")
    }
    return 0.0
}
# arrows / W S move the selection between the scene's buttons, Enter / Space press it, Esc goes back
fn game_menu_keys() {
    bind btns = hud_buttons()
    if len(btns) < 0.5 { return 0.0 }
    bind cur = l_index(btns, st_get("menu_sel", 0.0))
    bind moved = false
    if key_pressed("down") || key_pressed("s") { bind cur = cur + 1.0  bind moved = true }
    if key_pressed("up") || key_pressed("w") { bind cur = cur - 1.0  bind moved = true }
    if plat_gamepad_connected() {
        bind ly = plat_gamepad_ly()
        bind was = st_get("pad_y_was", 0.0)
        if ly > 0.6 && was < 0.6 { bind cur = cur + 1.0  bind moved = true }
        if ly < 0.0 - 0.6 && was > 0.0 - 0.6 { bind cur = cur - 1.0  bind moved = true }
        st_set("pad_y_was", ly)
        if plat_gamepad_button(0.0) && st_get("pad_a_was", 0.0) < 0.5 { st_set("hud_key_fire", 1.0) }
        st_set("pad_a_was", bool_num(plat_gamepad_button(0.0)))
    }
    if cur < 0.0 { bind cur = sel(moved, len(btns) - 1.0, 0.0) }
    if cur >= len(btns) { bind cur = 0.0 }
    if moved || l_index(btns, st_get("menu_sel", 0.0)) < 0.0 {
        st_set("menu_sel", list_get(btns, cur))
        if moved && plat_has_audio() { plat_ui_sound("tick") }
    }
    if key_pressed("enter") || key_pressed("space") { st_set("hud_key_fire", 1.0) }
    if key_pressed("escape") && st_get("game_embedded", 0.0) < 0.5 { game_action("back", 0.0 - 1.0) }
    return 0.0
}
fn game_cutscene_done() {
    bind nxt = sc_get("next", "")
    bind back = st_get("game_cut_return", "")
    if nxt != "" { game_goto(nxt) }
    else if back != "" { st_set("game_cut_return", "")  game_goto(back) }
    else { st_set("game_quit", 1.0) }
}
fn game_handle_event(ev, arg) {
    if ev == "scene" && arg != "" { game_goto(arg) }
    else if ev == "call" { game_scripts_call("trigger", 0.0, arg) }
    else if ev == "end" { game_cutscene_done() }
    else if ev == "wait" { st_set("game_wait", 1.0) }
}
# "goto:path", "cutscene:path", "say:text", "sound:file", "music:file", "call:hook", "hud:path", "quit", "set:k=v"
fn game_action(act, source_i) {
    if act == "" { return 0.0 }
    if str_find(act, ";") >= 0.0 {
        bind parts = str_split(act, ";")
        bind pi = 0.0
        while pi < len(parts) {
            game_action(str_trim(list_get(parts, pi)), source_i)
            bind pi = pi + 1.0
        }
        return 0.0
    }
    bind kind = act
    bind arg = ""
    bind ci = str_find(act, ":")
    if ci >= 0.0 {
        bind kind = str_slice(act, 0.0, ci)
        bind arg = str_slice(act, ci + 1.0, str_len(act) - ci - 1.0)
    }
    if kind == "goto" && str_starts_with(arg, "@") { bind arg = st_get("gs_" + str_slice(arg, 1.0, str_len(arg) - 1.0), "") }
    if kind == "goto" && arg != "" { game_goto(arg) }
    else if kind == "store" {
        bind eq = str_find(arg, "=")
        if eq > 0.0 { st_set("gs_" + str_slice(arg, 0.0, eq), str_slice(arg, eq + 1.0, str_len(arg) - eq - 1.0)) }
    }
    else if kind == "exit" { fps_try_exit() }
    else if kind == "cutscene" && arg != "" { game_play_cutscene(arg) }
    else if kind == "say" { game_say("", arg, 3.0) }
    else if kind == "sound" { game_sound(arg, cam_x(), cam_y(), cam_z()) }
    else if kind == "music" { game_music(arg) }
    else if kind == "call" { game_scripts_call("trigger", 0.0, arg) }
    else if kind == "hud" { game_push_hud(arg) }
    else if kind == "quit" { st_set("game_quit", 1.0) }
    else if kind == "set" {
        bind kv = str_split(arg, "=")
        if len(kv) > 1.0 { game_set(list_get(kv, 0), to_number(list_get(kv, 1))) }
    }
    else if kind == "add" {
        bind kv = str_split(arg, "=")
        if len(kv) > 1.0 { game_set(list_get(kv, 0), game_var(list_get(kv, 0)) + to_number(list_get(kv, 1))) }
    }
    else if kind == "back" {
        bind back = st_get("game_cut_return", "")
        if back != "" { st_set("game_cut_return", "")  st_set("game_nav_back", 1.0)  game_goto(back) }
        else if st_llen("game_stack") > 0.5 {
            bind prev = st_lget("game_stack", st_llen("game_stack") - 1.0)
            st_ldel("game_stack", st_llen("game_stack") - 1.0)
            st_set("game_nav_back", 1.0)
            game_goto(prev)
        } else if st_get("game_embedded", 0.0) < 0.5 { st_set("game_quit", 1.0) }
    }
    else if kind == "host" { net_host_game(sel(arg == "", 7777.0, to_number(arg)))  game_say("", "Hosting on port " + sel(arg == "", "7777", arg) + " - a friend can Join with your IP", 4.0) }
    else if kind == "join" {
        bind jp = str_split(arg, ":")
        net_join_game(list_get(jp, 0), sel(len(jp) > 1.0, to_number(list_get(jp, 1)), 7777.0))
        game_say("", "Joining " + arg, 3.0)
    }
    else if kind == "vol" {
        bind v = clampf(st_get("gv_volume", 0.8) + to_number(arg), 0.0, 1.0)
        game_set("volume", v)
        if plat_has_audio() { plat_volume(v)  plat_music_volume(v) }
        game_say("", "Volume " + num_str(round(v * 100.0)) + "%", 1.5)
    }
    else if kind == "toggle" { game_set(arg, 1.0 - game_var(arg))  game_say("", arg + sel(game_var(arg) > 0.5, " on", " off"), 1.5) }
    else if kind == "lang" {
        bind langs = ["English", "Daemon", "Thai", "Korean", "Japanese", "Chinese"]
        bind li = (game_var("lang") + 1.0) % 6.0
        game_set("lang", li)
        game_say("", "Language: " + list_get(langs, li) + " (the LingDev edition speaks English)", 2.5)
    }
    return 0.0
}

# ── player controller: WASD / gamepad, jump, gravity, solid boxes ────────────
fn game_player_update(dt) {
    bind p = game_player()
    if p < 0.0 { return 0.0 }
    bind speed = max(0.5, nd_get(p, "d"))
    bind ax = game_axis_x()
    bind ay = game_axis_y()
    # camera-relative on the ground plane
    bind yaw = cam_yaw()
    bind fx = sin(yaw)
    bind fz = cos(yaw)
    bind rx = cos(yaw)
    bind rz = 0.0 - sin(yaw)
    bind mx = (fx * ay + rx * ax)
    bind mz = (fz * ay + rz * ax)
    bind ml = sqrt(mx * mx + mz * mz)
    if ml > 1.0 { bind mx = mx / ml  bind mz = mz / ml }
    bind x = nd_get(p, "px")
    bind y = nd_get(p, "py")
    bind z = nd_get(p, "pz")
    bind hx = nd_get(p, "sx")
    bind hy = nd_get(p, "sy")
    bind hz = nd_get(p, "sz")
    bind nx = x + mx * speed * dt
    bind nz = z + mz * speed * dt
    if ml > 0.05 { nd_set(p, "ry", atan2(mx, mz)) }
    # vertical: +y is down
    bind vy = st_get("pl_vy", 0.0)
    bind grounded = st_get("pl_ground", 0.0) > 0.5
    if grounded && !fps_on() && (key_pressed("space") || plat_gamepad_button(0.0)) { bind vy = 0.0 - sqrt(2.0 * sc_get("grav", 9.0) * 2.2)  bind grounded = false }
    bind vy = vy + sc_get("grav", 9.0) * dt
    bind ny = y + vy * dt
    bind floor_y = sc_get("floor", 0.0)
    bind grounded = false
    if ny + hy >= floor_y { bind ny = floor_y - hy  bind vy = 0.0  bind grounded = true }
    # solids
    bind n = nd_count()
    bind i = 0.0
    while i < n {
        bind k = nd_get(i, "kind")
        if i != p && nd_get(i, "vis") > 0.5 && (nd_get(i, "tag") == "solid" || (k == "box" && nd_get(i, "tag") != "pickup" && nd_get(i, "tag") != "deco")) && (k == "box" || k == "plane") {
            bind wp = nd_world_pos_t(i, st_get("game_tl_t", 0.0))
            bind bx = list_get(wp, 0)
            bind by = list_get(wp, 1)
            bind bz = list_get(wp, 2)
            bind sx = nd_get(i, "sx")
            bind sy = nd_get(i, "sy")
            bind sz = nd_get(i, "sz")
            if k == "plane" { bind sy = 0.05 }
            # landing on top
            bind over = nx + hx > bx - sx && nx - hx < bx + sx && nz + hz > bz - sz && nz - hz < bz + sz
            if over {
                bind top = by - sy
                if y + hy <= top + 0.08 && ny + hy >= top && vy >= 0.0 { bind ny = top - hy  bind vy = 0.0  bind grounded = true }
                else if ny + hy > top && ny - hy < by + sy && k == "box" {
                    # side push-out on the shallower axis
                    bind ox = sel(nx > bx, (bx + sx) - (nx - hx), (nx + hx) - (bx - sx))
                    bind oz = sel(nz > bz, (bz + sz) - (nz - hz), (nz + hz) - (bz - sz))
                    if ox < oz { bind nx = sel(nx > bx, bx + sx + hx, bx - sx - hx) } else { bind nz = sel(nz > bz, bz + sz + hz, bz - sz - hz) }
                }
            }
        }
        bind i = i + 1.0
    }
    nd_set(p, "px", nx)
    nd_set(p, "py", ny)
    nd_set(p, "pz", nz)
    st_set("pl_vy", vy)
    st_set("pl_ground", bool_num(grounded))
    # overlap hooks with scripted nodes
    bind i = 0.0
    while i < n {
        if i != p && nd_get(i, "scr") != "" && nd_get(i, "kind") != "trigger" {
            bind wp = nd_world_pos(i)
            if abs(list_get(wp, 0) - nx) < nd_get(i, "sx") + hx && abs(list_get(wp, 2) - nz) < nd_get(i, "sz") + hz && abs(list_get(wp, 1) - ny) < nd_get(i, "sy") + hy { user_script(nd_get(i, "scr"), "hit", i, dt, p) }
        }
        bind i = i + 1.0
    }
    return 0.0
}
fn game_triggers() {
    bind p = game_player()
    if p < 0.0 { return 0.0 }
    bind x = nd_get(p, "px")
    bind y = nd_get(p, "py")
    bind z = nd_get(p, "pz")
    bind n = nd_count()
    bind i = 0.0
    while i < n {
        if nd_get(i, "kind") == "trigger" && nd_get(i, "vis") > 0.5 {
            bind wp = nd_world_pos(i)
            bind inside = abs(list_get(wp, 0) - x) < nd_get(i, "sx") && abs(list_get(wp, 1) - y) < nd_get(i, "sy") + 0.5 && abs(list_get(wp, 2) - z) < nd_get(i, "sz")
            bind key = "trig_in_" + num_str(nd_id(i))
            bind was = st_get(key, 0.0) > 0.5
            if inside && !was {
                bind once = nd_get(i, "a") > 0.5
                bind done = l_has(sl_to_list("trig_done"), nd_id(i))
                if !(once && done) {
                    if once { st_lpush("trig_done", nd_id(i)) }
                    game_action(nd_get(i, "txt"), i)
                    if nd_get(i, "scr") != "" { user_script(nd_get(i, "scr"), "trigger", i, 0.0, p) }
                    user_script(nd_get(p, "scr"), "trigger", p, 0.0, i)
                }
            }
            st_set(key, bool_num(inside))
        }
        bind i = i + 1.0
    }
    return 0.0
}

# ── camera rigs ──────────────────────────────────────────────────────────────
fn game_camera_update(dt) {
    bind c = game_camera_node()
    bind p = game_player()
    bind t = st_get("game_tl_t", 0.0)
    if c < 0.0 {
        if p >= 0.0 {
            bind wp = nd_world_pos(p)
            bind yaw = st_get("cam_follow_yaw", nd_get(p, "ry"))
            # orbit with the mouse right button, otherwise ease behind the player
            if mouse_down_right() { bind yaw = yaw + u_dx() * 0.006 } else { bind yaw = ease_angle(yaw, nd_get(p, "ry"), 0.0, "linear") + 0.0 }
            st_set("cam_follow_yaw", yaw)
            cam_orbit(list_get(wp, 0), list_get(wp, 1) - 0.8, list_get(wp, 2), yaw, 0.32, 9.0, 2.0)
        }
        return 0.0
    }
    bind rig = nd_get(c, "b")
    bind zd = max(0.5, nd_get(c, "a"))
    bind dist = max(0.5, nd_get(c, "d"))
    bind target = nd_find_name(nd_get(c, "txt"))
    if target < 0.0 { bind target = p }
    if rig < 0.5 || rig > 2.5 {
        bind wp = nd_world_pos_t(c, t)
        cam_set(list_get(wp, 0), list_get(wp, 1), list_get(wp, 2), nd_world_yaw_t(c, t), nd_val(c, "rx", t), zd)
    } else if rig < 1.5 {
        bind tx = 0.0  bind ty = 0.0  bind tz = 0.0
        if target >= 0.0 { bind wp = nd_world_pos(target)  bind tx = list_get(wp, 0)  bind ty = list_get(wp, 1)  bind tz = list_get(wp, 2) }
        bind yaw = nd_get(c, "ry") + st_get("game_t", 0.0) * nd_get(c, "c")
        if mouse_down_right() { nd_set(c, "ry", nd_get(c, "ry") + u_dx() * 0.006) }
        cam_orbit(tx, ty, tz, yaw, nd_get(c, "rx"), dist, zd)
    } else {
        if target >= 0.0 {
            bind wp = nd_world_pos(target)
            bind yaw = st_get("cam_follow_yaw", nd_get(target, "ry"))
            if mouse_down_right() { bind yaw = yaw + u_dx() * 0.006 } else { bind yaw = yaw + (nd_get(target, "ry") - yaw) * min(1.0, dt * 2.0) }
            st_set("cam_follow_yaw", yaw)
            cam_orbit(list_get(wp, 0), list_get(wp, 1) - 0.8, list_get(wp, 2), yaw, nd_get(c, "rx"), dist, zd)
        }
    }
    return 0.0
}

# ── rendering ────────────────────────────────────────────────────────────────
fn game_render(x, y, w, h) {
    nd_use("")
    bind kind = sc_get("kind", "3d")
    bind t = st_get("game_tl_t", 0.0)
    if kind == "cutscene" { bind t = st_get("game_scene_t", 0.0) }
    cam_viewport(x, y, w, h)
    if kind == "2d" {
        g_rect(x, y, w, h, sc_get("bg", 0.0))
        if st_get("game_bg", "") != "" {
            nd_use("b")
            draw_scene_game(st_get("game_t", 0.0))
            nd_use("")
        }
        bind fit = hud_fit(x, y, w, h)
        bind act = hud_draw(t, list_get(fit, 0), list_get(fit, 1), list_get(fit, 2), true)
        if act != "" { game_action(act, nd_find(st_get("hud_action_node", 0.0))) }
    } else {
        g_rect(x, y, w, h, sc_get("bg", 0.0))
        draw_scene_game(t)
        if fps_on() { fps_draw_view() }
        game_scripts_call("draw", 0.0, "")
        if story_on() { plat_post_now() }
        if st_get("game_hud", "") != "" {
            nd_use("h")
            bind fit = hud_fit(x, y, w, h)
            bind act = hud_draw(st_get("game_t", 0.0), list_get(fit, 0), list_get(fit, 1), list_get(fit, 2), true)
            nd_use("")
            if act != "" { game_action(act, 0.0 - 1.0) }
        }
        cut_overlay(x, y, w, h)
        if fps_on() { fps_hud(x, y, w, h) }
        if brick_on() { brick_hud(x, y, w, h) }
        if soul_on() { soul_hud(x, y, w, h) }
        if story_on() { story_hud(x, y, w, h) }
    }
    # script subtitles
    if st_get("game_say_until", 0.0) > st_get("game_t", 0.0) && st_get("game_say_text", "") != "" {
        bind txt = st_get("game_say_text", "")
        bind sp = st_get("game_say_speaker", "")
        bind size = max(11.0, h * 0.026)
        bind bw = g_text_w(size, txt) + 40.0 + sel(sp != "", g_text_w(size, sp + ": "), 0.0)
        bind bx = x + (w - bw) * 0.5
        bind by = y + h * 0.82
        g_rrect_a(bx, by, bw, size + 18.0, 6.0, C_BG_DEEP, 0.8)
        if sp != "" { g_text(bx + 20.0, by + 9.0, size, C_YELLOW, sp + ": ")  g_text(bx + 20.0 + g_text_w(size, sp + ": "), by + 9.0, size, C_TEXT_STRONG, txt) }
        else { g_text(bx + 20.0, by + 9.0, size, C_TEXT_STRONG, txt) }
    }
    bind msg = st_get("game_msg", "")
    if msg != "" { g_text(x + 12.0, y + h - 24.0, 12.0, C_RED, msg) }
}

# ── co-op networking (desktop): share the player transform ──────────────────
fn net_host_game(port) {
    if !plat_has_net() { return 0.0 }
    plat_net_host(port)
    st_set("net_on", 1.0)
}
fn net_join_game(ip, port) {
    if !plat_has_net() { return 0.0 }
    plat_net_join(ip, port)
    st_set("net_on", 1.0)
}
fn net_send_state() {
    if st_get("net_on", 0.0) < 0.5 { return 0.0 }
    bind p = game_player()
    if p < 0.0 { return 0.0 }
    plat_net_send("P|" + num_str(nd_get(p, "px")) + "|" + num_str(nd_get(p, "py")) + "|" + num_str(nd_get(p, "pz")) + "|" + num_str(nd_get(p, "ry")))
}
fn net_poll() {
    if st_get("net_on", 0.0) < 0.5 { return 0.0 }
    net_send_state()
    bind msg = plat_net_recv()
    bind guard = 0.0
    while msg != "" && guard < 32.0 {
        bind f = str_split(msg, "|")
        if list_get(f, 0) == "P" && len(f) >= 5.0 {
            bind peer = nd_find_tag("peer")
            if peer < 0.0 {
                bind peer = game_spawn("actor", "Peer", to_number(list_get(f, 1)), to_number(list_get(f, 2)), to_number(list_get(f, 3)))
                nd_set(peer, "tag", "peer")
                nd_set(peer, "col", C_GREEN)
            }
            nd_set(peer, "px", to_number(list_get(f, 1)))
            nd_set(peer, "py", to_number(list_get(f, 2)))
            nd_set(peer, "pz", to_number(list_get(f, 3)))
            nd_set(peer, "ry", to_number(list_get(f, 4)))
        } else { game_scripts_call("net", 0.0, msg) }
        bind msg = plat_net_recv()
        bind guard = guard + 1.0
    }
    return 0.0
}

← Back to index