灵符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_soul.ling
# rt_soul.ling - "soul" mode: the 2019 Soul Symphony (Panda3D, soul-symphony-zh) in Ling.
#
# A scene with `mode=soul` is either the title (`screen=title`) or a level (`level=level00`). The
# assets are the original's, converted by the project's tools/ssz_import.py: the .bam levels as
# .ltm textured meshes (+ .marks for their portals / NPCs / letters / monsters), every texture as
# a traced SVGZ, the sfx WAVs and the OGG soundtrack.
#
# Faithful to the original (worldcage.py, level.py, player.py, titleScreen.py):
#   ball: Bullet sphere r 1.5, mass 4, restitution 0.75, gravity 9.81*8; WASD / arrows / left stick
#     push it with force 100 along the world axes, Q / E / right stick torque it, Space / A jumps
#     (impulse 160, a double jump), Shift / B brakes (damping 0.82); the camera sits at
#     ball + (0, -48, +30) Panda units looking at it, 40 degree lens
#   level: floor / wall / ceiling textures picked at random from the 100 patterns of each kind,
#     their UVs scrolling and scaling with the level clock, colour-cycled through the seven
#     colours, translucent (floor most, ceiling least)
#   stars: textured sprites from the 209 star images falling at 90.2 u/s, alpha 0.5, shrinking
#   letters: a-z in Daemon / Chesilin / Note / Music fonts on every letter mark, spinning, +1 each
#   portals: base + flower spinning, colour-cycled; hold jump near one, release to warp
#   NPCs: face + emblem planes spinning opposite ways, a name, a poem when you talk
#   HUD: the rainbow health strip, 得分 in a random Chinese font, the tilted minimap,
#     the spinning star mandalas; music: a random track per level, the title theme on the title
# Improvements: the soft see-through circle around the ball (walls between it and the camera fade),
# torches along the corridors lighting the walls per pixel, a little dark distance fog,
# depth-buffered sorting, smooth colour cycling instead of per-frame strobing, frame-blur trails
# set to a moderate amount, working yin-yang monsters (yin hurts, yang heals), a hub.

fn soul_on() { return st_get("ss_on", 0.0) > 0.5 }
fn ss_is_title() { return st_get("ss_screen", "") == "title" }

# ── palettes ─────────────────────────────────────────────────────────────────
fn ss_roygbiv(k) {
    bind cols = [c_rgb(255.0, 0.0, 0.0), c_rgb(255.0, 128.0, 0.0), c_rgb(255.0, 255.0, 0.0), c_rgb(0.0, 255.0, 0.0), c_rgb(0.0, 0.0, 255.0), c_rgb(74.0, 0.0, 130.0), c_rgb(143.0, 0.0, 255.0)]
    bind i = floor(k) - floor(floor(k) / 7.0) * 7.0
    return list_get(cols, i)
}
# the level surfaces' seven: R O Y G B violet magenta
fn ss_surf(k) {
    bind cols = [c_rgb(255.0, 0.0, 0.0), c_rgb(255.0, 128.0, 0.0), c_rgb(255.0, 255.0, 0.0), c_rgb(0.0, 255.0, 0.0), c_rgb(0.0, 0.0, 255.0), c_rgb(128.0, 0.0, 255.0), c_rgb(255.0, 0.0, 255.0)]
    bind i = floor(k) - floor(floor(k) / 7.0) * 7.0
    return list_get(cols, i)
}
# smooth position on a 7-colour wheel (t in colours)
fn ss_cycle(t, surf) {
    bind a = floor(t)
    bind f = t - a
    if surf { return c_mix(ss_surf(a), ss_surf(a + 1.0), f) }
    return c_mix(ss_roygbiv(a), ss_roygbiv(a + 1.0), f)
}
# one step of the [R, O, Y, G, B, I, V] array per rendered frame; each element passes its own
# offset so floor, walls, ceiling, ball, letters, portals and NPCs never share a colour
fn ss_strobe(off) { return ss_roygbiv(st_get("ss_frame", 0.0) + off) }

# the 36-step gradient the ball, letters and score use: red..violet in 6 segments
fn ss_grad(t) {
    bind stops = [c_rgb(255.0, 0.0, 0.0), c_rgb(255.0, 128.0, 0.0), c_rgb(255.0, 255.0, 0.0), c_rgb(0.0, 255.0, 0.0), c_rgb(0.0, 255.0, 255.0), c_rgb(0.0, 0.0, 255.0), c_rgb(143.0, 0.0, 255.0)]
    bind u = t - floor(t / 6.0) * 6.0
    bind i = floor(u)
    bind f = sin(1.5708 * (u - i))
    return c_mix(list_get(stops, i), list_get(stops, i + 1.0), f)
}

# ── assets ───────────────────────────────────────────────────────────────────
fn ss_p(rel) { return game_path("assets/" + rel) }
fn ss_files(dir, ext) {
    bind out = []
    bind items = plat_list(ss_p(dir))
    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) == ext { bind out = list_push(out, nm) }
        bind i = i + 1.0
    }
    return out
}
fn ss_tex(rel, size) {
    bind key = "sst:" + rel
    if st_has(key) > 0.5 { return st_get(key, 0.0 - 1.0) }
    bind id = plat_tex_load(ss_p(rel), size)
    st_set(key, id)
    return id
}
fn ss_lines(rel) {
    bind text = plat_read(ss_p(rel))
    bind out = []
    bind ls = str_split(text, "\n")
    bind i = 0.0
    while i < len(ls) {
        bind l = trim(list_get(ls, i))
        if l != "" { bind out = list_push(out, l) }
        bind i = i + 1.0
    }
    return out
}
fn ss_pick(l) { return list_get(l, floor(rand() * (len(l) - 0.001))) }
fn ss_assets() {
    if st_get("ss_assets", 0.0) > 0.5 { return 0.0 }
    st_set("ss_assets", 1.0)
    st_set("ss_orb", plat_tm_load(ss_p("models/orb.ltm")))
    st_set("ss_orb_top", ss_tex("textures/ball/ball_top.svgz", 256.0))
    st_set("ss_orb_bot", ss_tex("textures/ball/ball_bottom.svgz", 256.0))
    st_set("ss_orb_mid", ss_tex("textures/ball/ball_middle.svgz", 256.0))
    st_set("ss_portal", plat_tm_load(ss_p("models/portal00.ltm")))
    st_set("ss_ptex_base", ss_tex("textures/portals/base00.svgz", 256.0))
    st_set("ss_ptex_flower", ss_tex("textures/portals/effect01.svgz", 256.0))
    st_set("ss_npc", plat_tm_load(ss_p("models/npc01.ltm")))
    st_set("ss_yinyang", ss_tex("textures/monster/yinyang.svg", 128.0))
    st_set("ss_ui_star", ss_tex("textures/ui/star.svgz", 512.0))
    st_set("ss_ui_star2", ss_tex("textures/ui/star2.svgz", 512.0))
    st_set("ss_logo", ss_tex("textures/ui/SoulSymphonyLogoCN.svgz", 1024.0))
    # the 209 star sprites, contiguous texture ids
    bind stars = ss_files("textures/stars", "svgz")
    bind first = 0.0 - 1.0
    bind i = 0.0
    while i < len(stars) {
        bind id = plat_tex_load(ss_p("textures/stars/" + list_get(stars, i)), 64.0)
        if i == 0.0 { bind first = id }
        bind i = i + 1.0
    }
    st_set("ss_star0", first)
    st_set("ss_nstars", len(stars))
    st_set("ss_pat_floor", ss_files("textures/patterns/floor", "svgz"))
    st_set("ss_pat_wall", ss_files("textures/patterns/wall", "svgz"))
    st_set("ss_pat_ceil", ss_files("textures/patterns/ceiling", "svgz"))
    st_set("ss_faces", ss_files("textures/npc/faces", "svgz"))
    st_set("ss_emblems", ss_files("textures/npc/emblems", "svgz"))
    st_set("ss_music", ss_files("music", "ogg"))
    st_set("ss_text_fonts", ss_files("fonts/text", "ttf"))
    st_set("ss_dialogs", ss_lines("text/dialog.txt"))
    st_set("ss_names", ss_lines("text/names.txt"))
    st_set("ss_portal_lines", ss_lines("text/portal.txt"))
    bind gf = ["Daemon.otf", "Chesilin.otf", "Note.otf", "Music.otf"]
    bind i = 0.0
    while i < 4.0 {
        st_set("ss_gfont" + num_str(i), plat_font_load(ss_p("fonts/konnarian/" + list_get(gf, i))))
        bind i = i + 1.0
    }
    # sfx
    bind names = ["ball-jump", "ball_roll", "boing00", "boing01", "boing02", "boing03", "boing04", "hover", "pickup", "start-dialog", "warp", "soul-symphony", "portal_loop", "portal_loop01", "portal_loop02", "portal_loop03", "portal_loop04", "portal_loop05", "portal_loop06"]
    bind i = 0.0
    while i < len(names) {
        if plat_has_audio() { st_set("ss_sfx_" + list_get(names, i), plat_sample_load(ss_p("audio/" + list_get(names, i) + ".wav"))) }
        bind i = i + 1.0
    }
    return 0.0
}
fn ss_sfx(name, vol, pitch) {
    if !plat_has_audio() { return 0.0 - 1.0 }
    bind id = st_get("ss_sfx_" + name, 0.0 - 1.0)
    if id < 0.0 { return 0.0 - 1.0 }
    return plat_sample_play_pitch(id, vol, pitch)
}
fn ss_text_font() {
    bind fonts = st_get("ss_text_fonts", [])
    if len(fonts) < 1.0 { return st_get("font", 0.0) }
    bind nm = ss_pick(fonts)
    bind key = "ssf:" + nm
    if st_has(key) < 0.5 { st_set(key, plat_font_load(ss_p("fonts/text/" + nm))) }
    bind f = st_get(key, 0.0 - 1.0)
    if f < 0.0 { return st_get("font", 0.0) }
    return f
}
# music: a random track per level, no repeat until all have played (bgm.py)
fn ss_next_track() {
    bind all = st_get("ss_music", [])
    if len(all) < 1.0 { return "" }
    if st_llen("ss_mq") < 0.5 {
        bind i = 0.0
        while i < len(all) {
            bind nm = list_get(all, i)
            if str_find(nm, "标题") < 0.0 { st_lpush("ss_mq", nm) }
            bind i = i + 1.0
        }
    }
    bind k = floor(rand() * (st_llen("ss_mq") - 0.001))
    bind nm = st_lget("ss_mq", k)
    st_ldel("ss_mq", k)
    return nm
}
fn ss_play_music(file, vol) {
    if !plat_has_audio() || file == "" { return 0.0 }
    bind mid = plat_music_load(ss_p("music/" + file))
    if mid >= 0.0 { plat_music_volume(vol)  plat_music_play(mid) }
    st_set("ss_track", file)
    return 0.0
}

# ── enter / leave ────────────────────────────────────────────────────────────
fn ss_lists() { return ["sp_x", "sp_y", "sp_z", "sp_v", "sn_x", "sn_y", "sn_z", "sn_face", "sn_emb", "sn_name", "sn_dlg", "sn_ph", "sl_x", "sl_y", "sl_z", "sl_f", "sl_ch", "sl_ph", "sm_x", "sm_y", "sm_z", "sm_vx", "sm_vy", "sm_vz", "sm_s", "sm_yin", "sm_cd", "sg_r", "sg_c", "sg_ch", "sg_roll"] }
fn soul_enter() {
    bind keys = ss_lists()
    bind i = 0.0
    while i < len(keys) {
        st_lclear(list_get(keys, i))
        bind i = i + 1.0
    }
    st_set("ss_on", 1.0)
    st_set("ss_screen", sc_ex("screen", "level"))
    st_set("ss_t", 0.0)
    st_set("ss_fade", 0.0)
    st_set("ss_warp_t", 0.0 - 1.0)
    st_set("ss_dlg", "")
    st_set("ss_tag", "")
    ss_assets()
    plat_pfx_clear()
    plat_pfx_max(3000.0)
    plat_post(1.0, 1.0, 0.0, 0.0, 0.0)
    plat_frame_blur(to_number(sc_ex("blur", "0.55")))
    if ss_is_title() {
        ss_title_enter()
    } else {
        ss_level_enter(sc_ex("level", "level00"))
    }
    return 0.0
}
fn soul_leave() {
    if soul_on() {
        plat_post(0.0, 0.0, 0.0, 0.0, 0.0)
        plat_frame_blur(0.0)
        plat_tm_fov(0.0, 0.0)
        plat_tm_hole(0.0, 0.0, 0.0, 0.0)
        plat_pfx_clear()
        if st_get("ss_roll_v", 0.0 - 1.0) >= 0.0 { plat_sample_stop(st_get("ss_roll_v", 0.0)) }
        st_set("ss_roll_v", 0.0 - 1.0)
        bind i = 0.0
        while i < st_llen("sp_v") {
            if st_lget("sp_v", i) >= 0.0 { plat_sample_stop(st_lget("sp_v", i)) }
            bind i = i + 1.0
        }
    }
    st_set("ss_on", 0.0)
    return 0.0
}

# ── the title ────────────────────────────────────────────────────────────────
fn ss_title_enter() {
    ss_play_music("灵林 - 灵魂交响乐 - 标题画面.ogg", 1.0)
    ss_sfx("soul-symphony", 1.0, 1.0)
    # 16 rings x 16 glyphs (glyphRings.py): random lowercase in Daemon, in-plane turn 90*k
    bind r = 0.0
    while r < 16.0 {
        bind c = 0.0
        while c < 16.0 {
            st_lpush("sg_r", r)
            st_lpush("sg_c", c)
            st_lpush("sg_ch", str_slice("abcdefghijklmnopqrstuvwxyz", floor(rand() * 25.99), 1.0))
            st_lpush("sg_roll", floor(rand() * 4.99) * 1.5708)
            bind c = c + 1.0
        }
        bind r = r + 1.0
    }
    return 0.0
}
fn ss_title_update(dt) {
    if key_pressed("enter") || key_pressed("return") || plat_gamepad_button(7.0) || key_pressed("space") || (mouse_down() && st_get("ss_t", 0.0) > 0.5) {
        if st_get("ss_warp_t", 0.0 - 1.0) < 0.0 { st_set("ss_warp_t", 0.0)  st_set("ss_warp_to", sc_ex("next", "scenes/hub.lscene")) }
    }
    return 0.0
}
# Panda HPR (degrees) -> row-major 3x3 in Ling axes (x right, y down, z forward)
fn ss_hpr(h, p, r) {
    bind ch = cos(h * 0.0174533)
    bind sh = sin(h * 0.0174533)
    bind cp = cos(p * 0.0174533)
    bind sp = sin(p * 0.0174533)
    bind cr = cos(r * 0.0174533)
    bind sr = sin(r * 0.0174533)
    # Panda: M = R(roll about y) * P(pitch about x) * H(heading about z), Z-up, applied to row vectors.
    # As a column matrix in Panda axes: Hz * Px * Ry
    bind a00 = ch * cr - sh * sp * sr
    bind a01 = 0.0 - sh * cp
    bind a02 = ch * sr + sh * sp * cr
    bind a10 = sh * cr + ch * sp * sr
    bind a11 = ch * cp
    bind a12 = sh * sr - ch * sp * cr
    bind a20 = 0.0 - cp * sr
    bind a21 = sp
    bind a22 = cp * cr
    # Ling = S * A * S^-1 with S: (x, y, z)_panda -> (x, -z, y)
    return [a00, 0.0 - a02, a01, 0.0 - a20, a22, 0.0 - a21, a10, 0.0 - a12, a11]
}
fn ss_m3(m, x, y, z) { return [list_get(m, 0) * x + list_get(m, 1) * y + list_get(m, 2) * z, list_get(m, 3) * x + list_get(m, 4) * y + list_get(m, 5) * z, list_get(m, 6) * x + list_get(m, 7) * y + list_get(m, 8) * z] }
fn ss_title_world(t) {
    # the camera: straight down from 128 at the origin, its parent tumbling 0.05 deg / frame
    bind tum = t * 3.0
    bind m = ss_hpr(tum, tum * 0.5, tum)
    bind eye = ss_m3(m, 0.0, 0.0 - 128.0 - sin(t * 10.0), 0.0)
    cam_look_at(list_get(eye, 0), list_get(eye, 1), list_get(eye, 2) - 6.0, 0.0, 0.0, 0.0, 0.5)
    cam_apply_native()
    plat_tm_fov(40.0, st_get("vp_w", 1280.0))
    bind f = st_get("ss_gfont0", 0.0 - 1.0)
    if f < 0.0 { return 0.0 }
    bind n = st_llen("sg_r")
    bind k = 0.0
    # every glyph gets a new ROYGBIV colour 15 times a second
    bind tick = floor(t * 15.0)
    while k < n {
        bind r = st_lget("sg_r", k)
        bind c = st_lget("sg_c", k)
        bind sc = 32.0 - r * 2.0
        bind ang = r * t
        bind rm = ss_hpr(ang, ang, 0.0)
        bind hd = 0.0 - (c + 1.0) * 22.5 * 0.0174533
        bind lx = sin(hd) * 3.14159
        bind lz = cos(hd) * 3.14159
        # glyph plane = the ring plane (Panda XY = Ling XZ), baseline turned by the heading + roll
        bind ro = hd + st_lget("sg_roll", k) + c * 0.01 * t * 0.0174533
        bind ux = cos(ro)
        bind uz = 0.0 - sin(ro)
        bind p = ss_m3(rm, lx * sc, 0.0, lz * sc)
        bind u = ss_m3(rm, ux, 0.0, uz)
        bind v = ss_m3(rm, 0.0 - uz, 0.0, ux)
        bind col = ss_strobe(k)
        plat_color(col)
        plat_text3d(f, list_get(p, 0), list_get(p, 1), list_get(p, 2), list_get(u, 0), list_get(u, 1), list_get(u, 2), list_get(v, 0), list_get(v, 1), list_get(v, 2), sc, st_lget("sg_ch", k))
        bind k = k + 1.0
    }
    return 0.0
}
fn ss_title_hud(x, y, w, h) {
    bind t = st_get("ss_t", 0.0)
    bind cx = x + w * 0.5
    bind cy = y + h * 0.5
    bind half = h * 0.5
    # star mandalas: 2x2 cards on aspect2d, pulsing 4 -> 0 -> 4 over 3 s, rolling +-60 deg/s,
    # colour lerps through the seven at alpha 0.15
    bind ph = t - floor(t / 3.0) * 3.0
    bind s1 = 4.0
    if ph < 1.0 { bind s1 = 4.0 * (1.0 - ph) } else if ph < 1.5 { bind s1 = 0.0 } else if ph < 2.5 { bind s1 = 4.0 * (ph - 1.5) }
    bind s2 = 4.0 - s1
    bind c1 = ss_strobe(5.0)
    bind c2 = ss_strobe(6.0)
    if s1 > 0.01 { plat_tm_sprite(st_get("ss_ui_star", 0.0 - 1.0), cx, cy, 2.0 * half * s1, 2.0 * half * s1, t * 60.0 * 0.0174533, c1, 0.15, 0.0) }
    if s2 > 0.01 { plat_tm_sprite(st_get("ss_ui_star2", 0.0 - 1.0), cx, cy, 2.0 * half * s2, 2.0 * half * s2, 0.0 - t * 60.0 * 0.0174533, c2, 0.15, 0.0) }
    # the logo: 3 x 1.3 aspect2d units, x scale waving 1.45..1.55 every half second, colour cycling
    bind tp = t - floor(t)
    bind sx = 1.45 + 0.1 * 0.5 * (1.0 + sin(6.28318 * 2.0 * tp))
    bind lz = 0.04 * sin(t * 1.3)
    plat_tm_sprite(st_get("ss_logo", 0.0 - 1.0), cx, cy - lz * half, 2.0 * half * sx, 2.0 * half * 0.65, 0.0, ss_strobe(3.0), 1.0, 0.0)
    # a quiet hint (the original's press_start model was never shown)
    if floor(t * 1.5) - floor(t * 1.5 / 2.0) * 2.0 < 0.5 { g_text_c(cx, y + h - 60.0 * h / 720.0, 16.0 * h / 720.0, c_rgb(230.0, 220.0, 255.0), "ENTER  /  START") }
    return 0.0
}

# ── a level ──────────────────────────────────────────────────────────────────
fn ss_level_enter(name) {
    st_set("ss_level", name)
    bind mk = "ssm:" + name
    if st_has(mk) < 0.5 {
        bind m = plat_tm_load(ss_p("levels/" + name + ".ltm"))
        st_set(mk, m)
        st_set("ssc:" + name, plat_coll_build(m, 0.0 - 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 24.0))
        st_set("sse:" + name, plat_tm_edges(m, plat_tm_group(m, "floor"), 3000.0))
    }
    bind m = st_get(mk, 0.0 - 1.0)
    st_set("ss_mesh", m)
    st_set("ss_coll", st_get("ssc:" + name, 0.0 - 1.0))
    st_set("ss_gf", plat_tm_group(m, "floor"))
    st_set("ss_gw", plat_tm_group(m, "wall"))
    st_set("ss_gc", plat_tm_group(m, "ceil"))
    st_set("ss_bounds", plat_tm_bounds(m, 0.0 - 1.0))
    # torches: a spot on every other wall block, 7 units up from the floor
    bind tk = "sstorch:" + name
    if st_has(tk) < 0.5 { st_set(tk, ss_torch_place(plat_tm_wall_spots(m, plat_tm_group(m, "wall"), to_number(sc_ex("torch_spacing", "34")), 7.0), st_get("ssc:" + name, 0.0 - 1.0))) }
    st_set("ss_torches", st_get(tk, []))
    # textures: one random pattern per surface (level.py)
    st_set("ss_tf", ss_tex("textures/patterns/floor/" + ss_pick(st_get("ss_pat_floor", ["x"])), 256.0))
    st_set("ss_tw", ss_tex("textures/patterns/wall/" + ss_pick(st_get("ss_pat_wall", ["x"])), 256.0))
    st_set("ss_tc", ss_tex("textures/patterns/ceiling/" + ss_pick(st_get("ss_pat_ceil", ["x"])), 256.0))
    st_set("ss_cphase", rand() * 7.0)
    # the level's empties
    bind hub = sc_ex("hub", "0") == "1"
    bind lines = str_split(plat_read(ss_p("levels/" + name + ".marks")), "\n")
    bind li = 0.0
    while li < len(lines) {
        bind parts = str_split(trim(list_get(lines, li)), " ")
        if len(parts) >= 4.0 {
            bind nm = list_get(parts, 0)
            bind px = to_number(list_get(parts, 1))
            bind py = to_number(list_get(parts, 2))
            bind pz = to_number(list_get(parts, 3))
            if str_starts_with(nm, "portal") {
                st_lpush("sp_x", px)  st_lpush("sp_y", py)  st_lpush("sp_z", pz)
                bind v = 0.0 - 1.0
                if plat_has_audio() {
                    bind loops = ["portal_loop", "portal_loop01", "portal_loop02", "portal_loop03", "portal_loop04", "portal_loop05", "portal_loop06"]
                    bind sid = st_get("ss_sfx_" + list_get(loops, st_llen("sp_x") - 1.0 - floor((st_llen("sp_x") - 1.0) / 7.0) * 7.0), 0.0 - 1.0)
                    if sid >= 0.0 { bind v = plat_sample_play(sid, px, py, pz, 0.0, 0.15, 1.0) }
                }
                st_lpush("sp_v", v)
            } else if str_starts_with(nm, "npc") {
                st_lpush("sn_x", px)  st_lpush("sn_y", py)  st_lpush("sn_z", pz)
                st_lpush("sn_face", ss_tex("textures/npc/faces/" + ss_pick(st_get("ss_faces", ["x"])), 256.0))
                st_lpush("sn_emb", ss_tex("textures/npc/emblems/" + ss_pick(st_get("ss_emblems", ["x"])), 256.0))
                st_lpush("sn_name", ss_pick(st_get("ss_names", ["灵"])))
                st_lpush("sn_dlg", floor(rand() * max(1.0, len(st_get("ss_dialogs", [])) - 0.01)))
                st_lpush("sn_ph", rand() * 6.0)
            } else if str_starts_with(nm, "letter") {
                st_lpush("sl_x", px)  st_lpush("sl_y", py)  st_lpush("sl_z", pz)
                # the hub's letters are notes: Note / Music glyphs; elsewhere all four fonts
                bind fk = floor(rand() * 3.99)
                if hub { bind fk = 2.0 + floor(rand() * 1.99) }
                st_lpush("sl_f", fk)
                st_lpush("sl_ch", str_slice("abcdefghijklmnopqrstuvwxyz", floor(rand() * 25.99), 1.0))
                st_lpush("sl_ph", rand() * 6.0)
            } else if str_starts_with(nm, "monster") {
                ss_add_monster(px, py - 4.0, pz)
            } else if str_starts_with(nm, "playerStart") {
                st_set("ss_start", [px, py, pz])
            }
        }
        bind li = li + 1.0
    }
    # monsters.place_monsters(10): around the letters when the level has no monster marks
    bind k = st_llen("sm_x")
    while k < 10.0 && st_llen("sl_x") > 0.0 {
        bind j = floor(rand() * (st_llen("sl_x") - 0.01))
        ss_add_monster(st_lget("sl_x", j) + (rand() - 0.5) * 20.0, st_lget("sl_y", j) - 6.0, st_lget("sl_z", j) + (rand() - 0.5) * 20.0)
        bind k = k + 1.0
    }
    # the ball: dropped onto a random portal (+3 up), as worldcage.py does
    bind sx = 0.0
    bind sy = 0.0 - 10.0
    bind sz = 0.0
    if st_llen("sp_x") > 0.0 {
        bind j = floor(rand() * (st_llen("sp_x") - 0.01))
        bind sx = st_lget("sp_x", j)
        bind sy = st_lget("sp_y", j) - 3.0
        bind sz = st_lget("sp_z", j)
    } else if len(st_get("ss_start", [])) > 2.0 {
        bind sp = st_get("ss_start", [0.0, 0.0, 0.0])
        bind sx = list_get(sp, 0)
        bind sy = list_get(sp, 1) - 3.0
        bind sz = list_get(sp, 2)
    }
    st_set("ss_bx", sx)  st_set("ss_by", sy)  st_set("ss_bz", sz)
    st_set("ss_vx", 0.0)  st_set("ss_vy", 0.0)  st_set("ss_vz", 0.0)
    st_set("ss_spawn", [sx, sy, sz])
    st_set("ss_rot", [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0])
    st_set("ss_spin", 0.0)
    st_set("ss_jumps", 0.0)
    st_set("ss_ground", 0.0)
    st_set("ss_contact", 0.0)
    if st_get("ss_run", 0.0) < 0.5 { st_set("ss_run", 1.0)  st_set("ss_score", 0.0)  st_set("ss_hp", 100.0) }
    st_set("ss_hurt_t", 0.0)
    st_set("ss_score_font", st_get("font", 0.0))
    st_set("ss_star_rot", 0.0)
    st_set("ss_star_rot2", 0.0)
    # music + the rolling loop
    ss_play_music(ss_next_track(), 0.8)
    st_set("ss_roll_v", 0.0 - 1.0)
    if plat_has_audio() && st_get("ss_sfx_ball_roll", 0.0 - 1.0) >= 0.0 { st_set("ss_roll_v", plat_sample_play_flat(st_get("ss_sfx_ball_roll", 0.0), 0.0, 1.0)) }
    return 0.0
}
fn ss_add_monster(x, y, z) {
    bind a = rand() * 6.283
    st_lpush("sm_x", x)  st_lpush("sm_y", y)  st_lpush("sm_z", z)
    st_lpush("sm_vx", cos(a) * 10.0)  st_lpush("sm_vy", 0.0)  st_lpush("sm_vz", sin(a) * 10.0)
    st_lpush("sm_s", 1.0 + rand())
    st_lpush("sm_yin", sel(rand() < 0.5, 1.0, 0.0))
    st_lpush("sm_cd", 0.0)
    return 0.0
}

# ── update ───────────────────────────────────────────────────────────────────
fn soul_update(dt) {
    if !soul_on() { return 0.0 }
    bind dt = min(dt, 0.05)
    st_set("ss_t", st_get("ss_t", 0.0) + dt)
    st_set("ss_frame", st_get("ss_frame", 0.0) + 1.0)
    st_set("ss_fade", min(1.0, st_get("ss_fade", 0.0) + dt / 0.5))
    if key_pressed("escape") && st_get("game_embedded", 0.0) < 0.5 { st_set("game_quit", 1.0) }
    if key_pressed("f11") { plat_screenshot("soul") }
    # warp / scene change after a 0.5 s fade out
    if st_get("ss_warp_t", 0.0 - 1.0) >= 0.0 {
        st_set("ss_warp_t", st_get("ss_warp_t", 0.0) + dt)
        if st_get("ss_warp_t", 0.0) > 0.5 {
            bind to = st_get("ss_warp_to", "")
            soul_leave()
            game_goto(to)
            return 0.0
        }
    }
    if ss_is_title() { return ss_title_update(dt) }
    st_set("ss_clock", st_get("ss_clock", 0.0) + dt * 0.06)
    ss_ball_update(dt)
    ss_interact(dt)
    ss_monsters(dt)
    # stars: sprites raining around the ball (SoulParticles: 90.2 u/s, 0..6 s, 12.8 -> 1.28, alpha 0.5)
    bind n = st_get("ss_nstars", 0.0)
    if n > 0.0 {
        bind bx = st_get("ss_bx", 0.0)
        bind by = st_get("ss_by", 0.0)
        bind bz = st_get("ss_bz", 0.0)
        bind col = ss_strobe(4.0)
        plat_pfx_rain(bx - 260.0, bz - 140.0, bx + 260.0, bz + 380.0, by - 160.0, 22.0, 90.2, 6.0, 12.8, 1.28, st_get("ss_star0", 0.0), n, 0.5, col, 120.0)
    }
    plat_pfx_step(dt, 0.0, 0.0, 1000000.0, 0.0)
    # the camera: ball + (0, -48, +30) Panda = (0, -30, -48) here, looking at the ball
    bind bx = st_get("ss_bx", 0.0)
    bind by = st_get("ss_by", 0.0)
    bind bz = st_get("ss_bz", 0.0)
    cam_look_at(bx, by - 30.0, bz - 48.0, bx, by, bz, 0.5)
    return 0.0
}

fn ss_ball_update(dt) {
    bind m = 4.0
    bind r = 1.5
    bind g = 78.48
    # input (worldcage.py: keys override the stick's components)
    bind fx = 0.0
    bind fz = 0.0
    bind tq = 0.0
    if abs(plat_gamepad_lx()) > 0.12 { bind fx = plat_gamepad_lx() }
    if abs(plat_gamepad_ly()) > 0.12 { bind fz = 0.0 - plat_gamepad_ly() }
    if abs(plat_gamepad_rx()) > 0.12 { bind tq = plat_gamepad_rx() }
    if key_down("w") || key_down("up") { bind fz = 1.0 }
    if key_down("s") || key_down("down") { bind fz = 0.0 - 1.0 }
    if key_down("a") || key_down("left") { bind fx = 0.0 - 1.0 }
    if key_down("d") || key_down("right") { bind fx = 1.0 }
    if key_down("q") { bind tq = 1.0 }
    if key_down("e") { bind tq = 0.0 - 1.0 }
    if st_get("game_autoplay", 0.0) > 0.5 {
        bind at = st_get("ss_t", 0.0)
        bind fx = sin(at * 0.45)
        bind fz = cos(at * 0.31)
    }
    bind talking = st_get("ss_talking", 0.0) > 0.5
    if talking { bind fx = 0.0  bind fz = 0.0 }
    bind brake = key_down("lshift") || key_down("rshift") || key_down("shift") || plat_gamepad_button(1.0)
    bind vx = st_get("ss_vx", 0.0)
    bind vy = st_get("ss_vy", 0.0)
    bind vz = st_get("ss_vz", 0.0)
    # jump: impulse 160 (dv 40 up), ground jump + one air jump
    bind jump = key_pressed("space") || (plat_gamepad_button(0.0) && st_get("ss_pad_a", 0.0) < 0.5)
    if st_get("game_autoplay", 0.0) > 0.5 && floor(st_get("ss_t", 0.0) / 3.0) != floor((st_get("ss_t", 0.0) - dt) / 3.0) { bind jump = true }
    st_set("ss_pad_a", sel(plat_gamepad_button(0.0), 1.0, 0.0))
    if jump && !ss_near_talkable() {
        if st_get("ss_ground", 0.0) > 0.5 { st_set("ss_jumps", 0.0) }
        if st_get("ss_jumps", 0.0) < 2.0 {
            bind vy = vy - 160.0 / m
            st_set("ss_jumps", st_get("ss_jumps", 0.0) + 1.0)
            ss_sfx("ball-jump", 0.8, 1.0)
        }
    }
    bind ax = fx * 100.0 / m
    bind az = fz * 100.0 / m
    # spin about the vertical (torque 100, sphere inertia 2/5 m r^2)
    bind spin = st_get("ss_spin", 0.0) + tq * 100.0 / (0.4 * m * r * r) * dt
    bind spin = spin * pow(0.5, dt)
    st_set("ss_spin", spin)
    bind coll = st_get("ss_coll", 0.0 - 1.0)
    bind x = st_get("ss_bx", 0.0)
    bind y = st_get("ss_by", 0.0)
    bind z = st_get("ss_bz", 0.0)
    bind sp = sqrt(vx * vx + vy * vy + vz * vz)
    bind steps = clampf(ceil(sp * dt / 0.45), 1.0, 12.0)
    bind h = dt / steps
    bind ground = false
    bind impact = 0.0
    bind k = 0.0
    while k < steps {
        bind vx = vx + ax * h
        bind vy = vy + g * h
        bind vz = vz + az * h
        if brake || talking {
            bind damp = pow(1.0 - sel(talking, 0.999, 0.82), h)
            bind vx = vx * damp
            bind vy = vy * sel(talking, damp, pow(0.18, h))
            bind vz = vz * damp
        }
        bind x = x + vx * h
        bind y = y + vy * h
        bind z = z + vz * h
        bind res = plat_coll_sphere(coll, x, y, z, r)
        if list_get(res, 6) > 0.0 {
            bind x = list_get(res, 0)
            bind y = list_get(res, 1)
            bind z = list_get(res, 2)
            bind nx = list_get(res, 3)
            bind ny = list_get(res, 4)
            bind nz = list_get(res, 5)
            bind vn = vx * nx + vy * ny + vz * nz
            if vn < 0.0 {
                if vn < 0.0 - 6.0 {
                    # restitution 0.75
                    bind vx = vx - 1.75 * vn * nx
                    bind vy = vy - 1.75 * vn * ny
                    bind vz = vz - 1.75 * vn * nz
                    bind impact = max(impact, 0.0 - vn)
                } else {
                    bind vx = vx - vn * nx
                    bind vy = vy - vn * ny
                    bind vz = vz - vn * nz
                }
                # Bullet friction 0.5 on a rolling ball: a little rolling resistance
                bind fr = pow(0.82, h)
                bind tvx = vx - (vx * nx + vy * ny + vz * nz) * nx
                bind tvz = vz - (vx * nx + vy * ny + vz * nz) * nz
                bind vx = vx - tvx * (1.0 - fr)
                bind vz = vz - tvz * (1.0 - fr)
            }
            if ny < 0.0 - 0.45 { bind ground = true }
        }
        bind k = k + 1.0
    }
    if ground && st_get("ss_ground", 0.0) < 0.5 { st_set("ss_jumps", 0.0) }
    st_set("ss_ground", sel(ground, 1.0, 0.0))
    # bounce sound: a random boing, louder and higher for harder hits
    if impact > 0.0 {
        bind vol = clampf(impact / 40.0, 0.15, 1.0)
        ss_sfx("boing0" + num_str(floor(rand() * 4.99)), vol, vol / 4.0 + 0.5 + rand() * 0.1)
    }
    # fell out of the world: back to the spawn
    bind bnd = st_get("ss_bounds", [0.0, 0.0, 0.0, 0.0, 400.0, 0.0])
    if y > list_get(bnd, 4) + 300.0 {
        bind s = st_get("ss_spawn", [0.0, 0.0, 0.0])
        bind x = list_get(s, 0)
        bind y = list_get(s, 1)
        bind z = list_get(s, 2)
        bind vx = 0.0
        bind vy = 0.0
        bind vz = 0.0
    }
    st_set("ss_bx", x)  st_set("ss_by", y)  st_set("ss_bz", z)
    st_set("ss_vx", vx)  st_set("ss_vy", vy)  st_set("ss_vz", vz)
    # rolling: omega = up x v / r, plus the spin about up; integrate the orientation
    bind wx = (0.0 - vz) / r
    bind wy = 0.0 - spin
    bind wz = vx / r
    if !ground { bind wx = wx * 0.98  bind wz = wz * 0.98 }
    bind wl = sqrt(wx * wx + wy * wy + wz * wz)
    if wl * dt > 0.0001 {
        st_set("ss_rot", ss_rot_mul(ss_axis_angle(wx / wl, wy / wl, wz / wl, wl * dt), st_get("ss_rot", [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0])))
    }
    # rolling sound follows the speed (player.py: playRate 0.05 * (|vx| + |vy| + |vz|))
    bind rv = st_get("ss_roll_v", 0.0 - 1.0)
    if rv >= 0.0 {
        bind rate = 0.05 * (abs(vx) + abs(vy) + abs(vz))
        plat_voice(rv, sel(ground && rate > 0.05, 0.7, 0.0), clampf(rate, 0.05, 3.0))
    }
    return 0.0
}
fn ss_axis_angle(x, y, z, a) {
    bind c = cos(a)
    bind s = sin(a)
    bind t = 1.0 - c
    return [t * x * x + c, t * x * y - s * z, t * x * z + s * y, t * x * y + s * z, t * y * y + c, t * y * z - s * x, t * x * z - s * y, t * y * z + s * x, t * z * z + c]
}
fn ss_rot_mul(a, b) {
    bind out = []
    bind i = 0.0
    while i < 3.0 {
        bind j = 0.0
        while j < 3.0 {
            bind out = list_push(out, list_get(a, i * 3.0) * list_get(b, j) + list_get(a, i * 3.0 + 1.0) * list_get(b, 3.0 + j) + list_get(a, i * 3.0 + 2.0) * list_get(b, 6.0 + j))
            bind j = j + 1.0
        }
        bind i = i + 1.0
    }
    # re-orthonormalise the rows so rounding never shears the ball
    bind r0 = [list_get(out, 0), list_get(out, 1), list_get(out, 2)]
    bind l0 = sqrt(list_get(r0, 0) * list_get(r0, 0) + list_get(r0, 1) * list_get(r0, 1) + list_get(r0, 2) * list_get(r0, 2))
    bind r1 = [list_get(out, 3), list_get(out, 4), list_get(out, 5)]
    bind d = (list_get(r0, 0) * list_get(r1, 0) + list_get(r0, 1) * list_get(r1, 1) + list_get(r0, 2) * list_get(r1, 2)) / (l0 * l0)
    bind r1x = list_get(r1, 0) - d * list_get(r0, 0)
    bind r1y = list_get(r1, 1) - d * list_get(r0, 1)
    bind r1z = list_get(r1, 2) - d * list_get(r0, 2)
    bind l1 = sqrt(r1x * r1x + r1y * r1y + r1z * r1z)
    bind ax = list_get(r0, 0) / l0
    bind ay = list_get(r0, 1) / l0
    bind az = list_get(r0, 2) / l0
    bind bx = r1x / l1
    bind by = r1y / l1
    bind bz = r1z / l1
    return [ax, ay, az, bx, by, bz, ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx]
}

# NPCs and portals within 5 (XZ) can be talked to with jump
fn ss_near(lx, lz, i, d) {
    bind dx = st_lget(lx, i) - st_get("ss_bx", 0.0)
    bind dz = st_lget(lz, i) - st_get("ss_bz", 0.0)
    return dx * dx + dz * dz < d * d
}
fn ss_near_talkable() {
    bind i = 0.0
    while i < st_llen("sn_x") {
        if ss_near("sn_x", "sn_z", i, 5.0) { return true }
        bind i = i + 1.0
    }
    bind i = 0.0
    while i < st_llen("sp_x") {
        if ss_near("sp_x", "sp_z", i, 5.0) { return true }
        bind i = i + 1.0
    }
    return false
}
fn ss_interact(dt) {
    bind held = key_down("space") || plat_gamepad_button(0.0)
    bind pressed = key_pressed("space") || (plat_gamepad_button(0.0) && st_get("ss_pad_a2", 0.0) < 0.5)
    st_set("ss_pad_a2", sel(plat_gamepad_button(0.0), 1.0, 0.0))
    # nametags
    bind tag = 0.0 - 1.0
    bind i = 0.0
    while i < st_llen("sn_x") {
        if ss_near("sn_x", "sn_z", i, 5.0) { bind tag = i }
        bind i = i + 1.0
    }
    if tag >= 0.0 && st_get("ss_tag_i", 0.0 - 1.0) != tag { ss_sfx("hover", 0.7, 1.0)  st_set("ss_tag_font", ss_text_font()) }
    st_set("ss_tag_i", tag)
    # talk to an NPC, or ask a portal
    if pressed {
        if tag >= 0.0 {
            bind dl = st_get("ss_dialogs", [])
            if len(dl) > 0.0 { st_set("ss_dlg", str_replace(list_get(dl, st_lget("sn_dlg", tag)), "|", "\n")) }
            st_set("ss_dlg_font", ss_text_font())
            st_set("ss_talking", 1.0)
            ss_sfx("start-dialog", 0.8, 1.0)
        } else {
            bind p = 0.0
            while p < st_llen("sp_x") {
                if ss_near("sp_x", "sp_z", p, 5.0) {
                    st_set("ss_dlg", ss_pick(st_get("ss_portal_lines", ["出发吧!"])))
                    st_set("ss_dlg_font", ss_text_font())
                    st_set("ss_talking", 1.0)
                    st_set("ss_portal_ask", 1.0)
                }
                bind p = p + 1.0
            }
        }
    }
    if !held && st_get("ss_talking", 0.0) > 0.5 {
        st_set("ss_talking", 0.0)
        st_set("ss_dlg", "")
        if st_get("ss_portal_ask", 0.0) > 0.5 {
            st_set("ss_portal_ask", 0.0)
            bind p = 0.0
            while p < st_llen("sp_x") {
                if ss_near("sp_x", "sp_z", p, 8.0) && st_get("ss_warp_t", 0.0 - 1.0) < 0.0 {
                    ss_sfx("warp", 1.0, 1.0)
                    st_set("ss_warp_t", 0.0)
                    st_set("ss_warp_to", ss_random_level())
                }
                bind p = p + 1.0
            }
        }
    }
    # letters: within 3 (XZ, height ignored) -> +1, pickup at a random pitch 0.1..2
    bind i = st_llen("sl_x") - 1.0
    while i >= 0.0 {
        if ss_near("sl_x", "sl_z", i, 3.0) && abs(st_lget("sl_y", i) - st_get("ss_by", 0.0)) < 12.0 {
            ss_sfx("pickup", 1.0, 0.1 + rand() * 1.9)
            st_set("ss_score", st_get("ss_score", 0.0) + 1.0)
            st_set("ss_score_font", ss_text_font())
            plat_pfx_burst(st_lget("sl_x", i), st_lget("sl_y", i) - 2.0, st_lget("sl_z", i), 30.0, 14.0, 0.9, 1.4, 0.0, ss_strobe(i), c_rgb(255.0, 255.0, 255.0), 0.0, 0.3, 0.0, 0.0 - 4.0, 0.0)
            st_ldel("sl_x", i)  st_ldel("sl_y", i)  st_ldel("sl_z", i)  st_ldel("sl_f", i)  st_ldel("sl_ch", i)  st_ldel("sl_ph", i)
        }
        bind i = i - 1.0
    }
    return 0.0
}
fn ss_random_level() {
    bind all = str_split(sc_ex("warps", "scenes/hub.lscene"), ",")
    bind cur = sc_get("path", "")
    bind k = 0.0
    while k < 8.0 {
        bind pick = ss_pick(all)
        if !str_ends_with(cur, p_base(pick)) || len(all) < 2.0 { return pick }
        bind k = k + 1.0
    }
    return ss_pick(all)
}
fn ss_monsters(dt) {
    bind coll = st_get("ss_coll", 0.0 - 1.0)
    bind bx = st_get("ss_bx", 0.0)
    bind by = st_get("ss_by", 0.0)
    bind bz = st_get("ss_bz", 0.0)
    st_set("ss_hurt_t", max(0.0, st_get("ss_hurt_t", 0.0) - dt))
    bind i = 0.0
    while i < st_llen("sm_x") {
        bind s = st_lget("sm_s", i)
        bind vx = st_lget("sm_vx", i)
        bind vy = st_lget("sm_vy", i) + 9.8 * dt
        bind vz = st_lget("sm_vz", i)
        bind x = st_lget("sm_x", i) + vx * dt
        bind y = st_lget("sm_y", i) + vy * dt
        bind z = st_lget("sm_z", i) + vz * dt
        bind res = plat_coll_sphere(coll, x, y, z, s * 0.5)
        if list_get(res, 6) > 0.0 {
            bind x = list_get(res, 0)
            bind y = list_get(res, 1)
            bind z = list_get(res, 2)
            bind nx = list_get(res, 3)
            bind ny = list_get(res, 4)
            bind nz = list_get(res, 5)
            bind vn = vx * nx + vy * ny + vz * nz
            if vn < 0.0 {
                bind vx = vx - 2.0 * vn * nx
                bind vy = vy - 1.6 * vn * ny
                bind vz = vz - 2.0 * vn * nz
            }
        }
        # keep wandering at 10 u/s
        bind hs = sqrt(vx * vx + vz * vz)
        if hs > 0.01 { bind vx = vx / hs * 10.0  bind vz = vz / hs * 10.0 }
        if rand() < dt * 0.3 {
            bind a = rand() * 6.283
            bind vx = cos(a) * 10.0
            bind vz = sin(a) * 10.0
        }
        st_lset("sm_x", i, x)  st_lset("sm_y", i, y)  st_lset("sm_z", i, z)
        st_lset("sm_vx", i, vx)  st_lset("sm_vy", i, vy)  st_lset("sm_vz", i, vz)
        # yin hurts 10, yang heals 10
        bind dx = x - bx
        bind dy = y - by
        bind dz = z - bz
        if dx * dx + dy * dy + dz * dz < (1.5 + s * 0.5) * (1.5 + s * 0.5) && st_get("ss_hurt_t", 0.0) <= 0.0 {
            st_set("ss_hurt_t", 1.0)
            bind yin = st_lget("sm_yin", i) > 0.5
            st_set("ss_hp", clampf(st_get("ss_hp", 100.0) + sel(yin, 0.0 - 10.0, 10.0), 0.0, 100.0))
            ss_sfx(sel(yin, "boing00", "pickup"), 0.8, sel(yin, 0.5, 1.5))
            plat_pfx_burst(x, y, z, 20.0, 10.0, 0.6, 1.0, 0.0, sel(yin, c_rgb(40.0, 40.0, 40.0), c_rgb(255.0, 255.0, 255.0)), c_rgb(255.0, 0.0, 128.0), 0.0, 0.0, 0.0, 0.0, 0.0)
            if st_get("ss_hp", 100.0) <= 0.0 {
                st_set("ss_hp", 100.0)
                bind sp = st_get("ss_spawn", [0.0, 0.0, 0.0])
                st_set("ss_bx", list_get(sp, 0))  st_set("ss_by", list_get(sp, 1))  st_set("ss_bz", list_get(sp, 2))
                st_set("ss_vx", 0.0)  st_set("ss_vy", 0.0)  st_set("ss_vz", 0.0)
            }
        }
        bind i = i + 1.0
    }
    return 0.0
}

# ── drawing ──────────────────────────────────────────────────────────────────
fn soul_world_native() {
    if !soul_on() { return 0.0 }
    bind t = st_get("ss_t", 0.0)
    if ss_is_title() { return ss_title_world(t) }
    plat_tm_fov(40.0, st_get("vp_w", 1280.0))
    # a little dark violet fog: the far level sinks into the night
    bind fogv = nums_parse(sc_ex("fog", "6,3,14,110,560"))
    plat_fog(nums_get(fogv, 0, 6.0), nums_get(fogv, 1, 3.0), nums_get(fogv, 2, 14.0), nums_get(fogv, 3, 110.0), nums_get(fogv, 4, 560.0))
    ss_torch_lights(t)
    bind m = st_get("ss_mesh", 0.0 - 1.0)
    bind clock = st_get("ss_clock", 0.0)
    bind bx = st_get("ss_bx", 0.0)
    bind by = st_get("ss_by", 0.0)
    bind bz = st_get("ss_bz", 0.0)
    # the see-through circle around the ball for every surface in front of it
    plat_tm_hole(bx, by, bz, 7.0)
    bind off = 0.5 * sin(clock / 6.0)
    bind fsc = 0.05 * sin(clock) + 2.5
    bind cp = st_get("ss_cphase", 0.0) + t * 0.45
    plat_tm_draw_lit(m, st_get("ss_gf", 0.0), st_get("ss_tf", 0.0), 0.0, 0.0, 0.0, 1.0, 0.0, ss_strobe(0.0), 0.82, off, off, fsc, fsc, 0.25, 0.0, 0.0, to_number(sc_ex("ambient", "0.42")))
    plat_tm_draw_lit(m, st_get("ss_gw", 1.0), st_get("ss_tw", 0.0), 0.0, 0.0, 0.0, 1.0, 0.0, ss_strobe(2.0), 0.62, off, off, 0.5 * sin(clock) + 1.0, 0.25 * sin(clock) + 0.5, 0.25, 0.0, 0.0, to_number(sc_ex("ambient", "0.42")))
    plat_tm_draw_lit(m, st_get("ss_gc", 2.0), st_get("ss_tc", 0.0), 0.0, 0.0, 0.0, 1.0, 0.0, ss_strobe(4.0), 0.38, off, off, fsc, fsc, 0.25, 0.0, 0.0, to_number(sc_ex("ambient", "0.42")))
    plat_tm_hole(0.0, 0.0, 0.0, 0.0)
    # portals: base + flower (portal00.bam), spinning and colour-cycled
    bind pm = st_get("ss_portal", 0.0 - 1.0)
    bind i = 0.0
    while i < st_llen("sp_x") {
        bind px = st_lget("sp_x", i)
        bind py = st_lget("sp_y", i)
        bind pz = st_lget("sp_z", i)
        bind sb = 1.4 + 0.6 * sin(t * 2.0 + i)
        plat_tm_draw(pm, 0.0, st_get("ss_ptex_base", 0.0), px, py, pz, sb, ss_hpr(0.0 - t * 60.0, 0.0, 0.0), ss_strobe(i + 3.0), 0.75, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0)
        bind a = t * 90.0
        plat_tm_draw(pm, 1.0, st_get("ss_ptex_flower", 0.0), px, py - 0.6, pz, 1.0 + sin(t * 5.0 + i), ss_hpr(a, a, a), ss_strobe(i + 5.0), (sin(t * 3.6) + 1.0) * 0.5, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0)
        bind i = i + 1.0
    }
    ss_torches_draw(t)
    # NPCs: face + emblem planes spinning opposite ways, bobbing
    bind nm = st_get("ss_npc", 0.0 - 1.0)
    bind i = 0.0
    while i < st_llen("sn_x") {
        bind nx = st_lget("sn_x", i)
        bind ny = st_lget("sn_y", i) - 0.05 * sin(3.14159 * t + st_lget("sn_ph", i)) * 6.0
        bind nz = st_lget("sn_z", i)
        plat_tm_draw(nm, 0.0, st_lget("sn_face", i), nx, ny, nz, 1.0, ss_hpr(t * 60.0, 0.0, 0.0), ss_strobe(i + 2.0), 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0)
        plat_tm_draw(nm, 1.0, st_lget("sn_emb", i), nx, ny, nz, 3.0, ss_hpr(0.0 - t * 60.0, 0.0, 0.0), ss_strobe(i + 6.0), 0.5, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0)
        bind i = i + 1.0
    }
    # letters / notes: TextScale 4, spinning +1 deg a frame, colour from the 36-step gradient
    bind i = 0.0
    while i < st_llen("sl_x") {
        bind f = st_get("ss_gfont" + num_str(st_lget("sl_f", i)), 0.0 - 1.0)
        bind ddx = st_lget("sl_x", i) - bx
        bind ddz = st_lget("sl_z", i) - bz
        if f >= 0.0 && ddx * ddx + ddz * ddz < 220.0 * 220.0 {
            bind a = t * 1.047 + st_lget("sl_ph", i)
            bind ux = cos(a)
            bind uz = sin(a)
            bind lx = st_lget("sl_x", i) - ux * 1.2
            bind lz = st_lget("sl_z", i) - uz * 1.2
            plat_color(ss_strobe(i))
            plat_text3d(f, lx, st_lget("sl_y", i), lz, ux, 0.0, uz, 0.0, 0.0 - 1.0, 0.0, 4.0, st_lget("sl_ch", i))
        }
        bind i = i + 1.0
    }
    # yin-yang monsters
    bind i = 0.0
    while i < st_llen("sm_x") {
        plat_tm_billboard(st_get("ss_yinyang", 0.0 - 1.0), st_lget("sm_x", i), st_lget("sm_y", i), st_lget("sm_z", i), st_lget("sm_s", i) * 2.0, t * sel(st_lget("sm_yin", i) > 0.5, 3.0, 0.0 - 3.0), c_rgb(255.0, 255.0, 255.0), 1.0, 0.0)
        bind i = i + 1.0
    }
    # the ball: orb.bam's three textured parts, rolling, tinted from the gradient
    bind om = st_get("ss_orb", 0.0 - 1.0)
    bind rot = st_get("ss_rot", [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0])
    bind tint = ss_strobe(1.0)
    plat_tm_draw(om, 0.0, st_get("ss_orb_top", 0.0), bx, by, bz, 1.0, rot, tint, 1.0, 0.0, 0.0, 1.0, 1.0, 0.35, 0.0, 0.0)
    plat_tm_draw(om, 1.0, st_get("ss_orb_bot", 0.0), bx, by, bz, 1.0, rot, tint, 1.0, 0.0, 0.0, 1.0, 1.0, 0.35, 0.0, 0.0)
    plat_tm_draw(om, 2.0, st_get("ss_orb_mid", 0.0), bx, by, bz, 1.0, rot, tint, 1.0, 0.0, 0.0, 1.0, 1.0, 0.35, 0.0, 0.0)
    plat_pfx_draw()
    return 0.0
}

# ── torches ──────────────────────────────────────────────────────────────────
# put each wall spot on the corridor side whose floor is close below, 7 units above that floor;
# spots high up a tall wall (no floor within 40) are dropped
fn ss_torch_place(sp, coll) {
    bind out = []
    bind n = floor(len(sp) / 6.0)
    bind k = 0.0
    while k < n {
        bind i = k * 6.0
        bind x = list_get(sp, i)
        bind y = list_get(sp, i + 1.0)
        bind z = list_get(sp, i + 2.0)
        bind nx = list_get(sp, i + 3.0)
        bind nz = list_get(sp, i + 5.0)
        bind a = plat_coll_ray(coll, x + nx * 2.0, y - 2.0, z + nz * 2.0, 0.0, 1.0, 0.0, 40.0)
        bind b = plat_coll_ray(coll, x - nx * 2.0, y - 2.0, z - nz * 2.0, 0.0, 1.0, 0.0, 40.0)
        bind ta = list_get(a, 0)
        bind tb = list_get(b, 0)
        bind sgn = 0.0
        bind t = 0.0
        if ta > 0.0 { bind sgn = 1.0  bind t = ta }
        if tb > 0.0 && (ta < 0.0 || tb < ta) { bind sgn = 0.0 - 1.0  bind t = tb }
        if sgn != 0.0 {
            bind fy = y - 2.0 + t
            bind out = list_push(out, x)
            bind out = list_push(out, fy - 7.0)
            bind out = list_push(out, z)
            bind out = list_push(out, nx * sgn)
            bind out = list_push(out, 0.0)
            bind out = list_push(out, nz * sgn)
        }
        bind k = k + 1.0
    }
    return out
}
# the flickering lights of the torches near the ball (they light the level per pixel)
fn ss_torch_lights(t) {
    bind sp = st_get("ss_torches", [])
    bind n = floor(len(sp) / 6.0)
    bind bx = st_get("ss_bx", 0.0)
    bind bz = st_get("ss_bz", 0.0)
    bind k = 0.0
    bind used = 0.0
    while k < n && used < 24.0 {
        bind i = k * 6.0
        bind x = list_get(sp, i)
        bind z = list_get(sp, i + 2.0)
        if (x - bx) * (x - bx) + (z - bz) * (z - bz) < 300.0 * 300.0 {
            bind fl = 0.85 + 0.15 * sin(t * 11.0 + k * 1.7) + 0.08 * sin(t * 23.0 + k)
            plat_light(x + list_get(sp, i + 3.0) * 2.5, list_get(sp, i + 1.0) - 1.0, z + list_get(sp, i + 5.0) * 2.5, 255.0, 225.0, 180.0, 3.2 * fl, 58.0)
            bind used = used + 1.0
        }
        bind k = k + 1.0
    }
    return 0.0
}
# the torches themselves: an iron bracket, a flame of layered glows, rising embers
fn ss_torches_draw(t) {
    bind sp = st_get("ss_torches", [])
    bind n = floor(len(sp) / 6.0)
    bind bx = st_get("ss_bx", 0.0)
    bind bz = st_get("ss_bz", 0.0)
    bind k = 0.0
    while k < n {
        bind i = k * 6.0
        bind x = list_get(sp, i)
        bind y = list_get(sp, i + 1.0)
        bind z = list_get(sp, i + 2.0)
        if (x - bx) * (x - bx) + (z - bz) * (z - bz) < 300.0 * 300.0 {
            bind nx = list_get(sp, i + 3.0)
            bind nz = list_get(sp, i + 5.0)
            bind ang = atan2(nx, nz)
            plat_color(c_rgb(40.0, 26.0, 18.0))
            plat_shape("box", x + nx * 0.6, y + 0.6, z + nz * 0.6, 0.3, 0.3, 0.6, 0.0, ang, 0.0, 0.0)
            plat_shape("cylinder", x + nx * 1.2, y, z + nz * 1.2, 0.35, 1.4, 0.35, 0.0, 0.0, 0.0, 0.0)
            bind fl = 0.8 + 0.2 * sin(t * 13.0 + k * 2.1)
            bind fx = x + nx * 1.2
            bind fz = z + nz * 1.2
            plat_tm_billboard(0.0 - 1.0, fx, y - 2.4, fz, 12.0 * fl, 0.0, c_rgb(255.0, 110.0, 30.0), 0.6, 1.0)
            plat_tm_billboard(0.0 - 1.0, fx, y - 2.1, fz, 5.5 * fl, 0.0, c_rgb(255.0, 210.0, 90.0), 0.9, 1.0)
            plat_tm_billboard(0.0 - 1.0, fx, y - 1.7, fz, 1.6, 0.0, c_rgb(255.0, 255.0, 220.0), 1.0, 1.0)
            if rand() < 0.15 {
                plat_pfx_emit(fx + (rand() - 0.5) * 0.6, y - 2.4, fz + (rand() - 0.5) * 0.6, (rand() - 0.5) * 1.5, 0.0 - 4.0 - rand() * 3.0, (rand() - 0.5) * 1.5, 1.0, 0.35, 0.0, c_rgb(255.0, 200.0, 80.0), c_rgb(255.0, 60.0, 10.0), 0.0, 0.0, 0.0, 1.0)
            }
        }
        bind k = k + 1.0
    }
    return 0.0
}

# ── HUD ──────────────────────────────────────────────────────────────────────
fn ss_text(f, x, y, px, c, s) {
    plat_color(c)
    plat_text_fill(f, x, y, px, s)
    return 0.0
}
fn soul_hud(x, y, w, h) {
    if !soul_on() { return 0.0 }
    bind t = st_get("ss_t", 0.0)
    bind half = h * 0.5
    bind cx = x + w * 0.5
    bind cy = y + h * 0.5
    if ss_is_title() {
        ss_title_hud(x, y, w, h)
    } else {
        # the spinning star mandalas (alpha 0.25 in game, turning with the ball's speed)
        bind sp = sqrt(st_get("ss_vx", 0.0) * st_get("ss_vx", 0.0) + st_get("ss_vz", 0.0) * st_get("ss_vz", 0.0))
        bind dt = st_get("game_dt", 0.016)
        st_set("ss_star_rot", st_get("ss_star_rot", 0.0) + sp * 2.0 * dt * 0.0174533)
        bind ph = t - floor(t / 3.0) * 3.0
        bind s1 = 4.0
        if ph < 1.0 { bind s1 = 4.0 * (1.0 - ph) } else if ph < 1.5 { bind s1 = 0.0 } else if ph < 2.5 { bind s1 = 4.0 * (ph - 1.5) }
        bind s2 = 4.0 - s1
        if s1 > 0.01 { plat_tm_sprite(st_get("ss_ui_star", 0.0 - 1.0), cx, cy, 2.0 * half * s1, 2.0 * half * s1, st_get("ss_star_rot", 0.0), ss_strobe(5.0), 0.18, 0.0) }
        if s2 > 0.01 { plat_tm_sprite(st_get("ss_ui_star2", 0.0 - 1.0), cx, cy, 2.0 * half * s2, 2.0 * half * s2, 0.0 - st_get("ss_star_rot", 0.0), ss_strobe(6.0), 0.18, 0.0) }
        # health: a strip along the top edge, 0.025 aspect2d units tall, cycling the rainbow
        bind hp = st_get("ss_hp", 100.0) / 100.0
        g_rect(x, y, w, half * 0.025, c_rgb(51.0, 51.0, 51.0))
        g_rect(x, y, w * hp, half * 0.025, ss_strobe(0.0))
        # score: right-aligned at aspect2d (0.98, 0.894), text scale 0.1
        bind sf = st_get("ss_score_font", st_get("font", 0.0))
        bind txt = "得分: " + num_str(st_get("ss_score", 0.0))
        bind px = half * 0.1 * 1.4
        bind tw = g_text_w(px, txt)
        ss_text(sf, cx + 0.98 * half - tw, cy - 0.894 * half - px * 0.8, px, ss_strobe(2.0), txt)
        # minimap: the level's floor outline tilted 45 degrees, bottom right, alpha 0.25
        ss_minimap(x, y, w, h)
        # NPC nametag
        bind ti = st_get("ss_tag_i", 0.0 - 1.0)
        if ti >= 0.0 && ti < st_llen("sn_x") {
            bind p = ss_project(st_lget("sn_x", ti), st_lget("sn_y", ti) - 7.0, st_lget("sn_z", ti), x, y, w, h)
            if list_get(p, 2) > 0.0 {
                bind nm = st_lget("sn_name", ti)
                bind f = st_get("ss_tag_font", st_get("font", 0.0))
                ss_text(f, list_get(p, 0) - g_text_w(26.0 * h / 720.0, nm) * 0.5, list_get(p, 1), 26.0 * h / 720.0, ss_strobe(3.0), nm)
            }
        }
        # dialog: centred, word-wrapped, a random Chinese font
        bind dlg = st_get("ss_dlg", "")
        if dlg != "" {
            bind f = st_get("ss_dlg_font", st_get("font", 0.0))
            bind lines = str_split(dlg, "\n")
            bind sz = half * 0.08
            bind ly = cy - len(lines) * sz * 0.65
            bind k = 0.0
            while k < len(lines) {
                bind l = list_get(lines, k)
                bind lw = g_text_w(sz, l)
                ss_text(f, cx - lw * 0.5 + 2.0, ly + 2.0, sz, c_rgb(0.0, 0.0, 0.0), l)
                ss_text(f, cx - lw * 0.5, ly, sz, c_rgb(255.0, 255.0, 255.0), l)
                bind ly = ly + sz * 1.3
                bind k = k + 1.0
            }
        }
    }
    # fades: in over 0.5 s on enter, out over 0.5 s before a warp
    bind a = 1.0 - st_get("ss_fade", 1.0)
    if st_get("ss_warp_t", 0.0 - 1.0) >= 0.0 { bind a = max(a, clampf(st_get("ss_warp_t", 0.0) / 0.5, 0.0, 1.0)) }
    if a > 0.01 { g_rect_a(x, y, w, h, c_rgb(0.0, 0.0, 0.0), a) }
    return 0.0
}
# world -> screen with the 40 degree lens; [sx, sy, visible]
fn ss_project(px, py, pz, x, y, w, h) {
    bind v = cam_to_view(px, py, pz)
    bind d = list_get(v, 2) + 0.5
    if d < 0.5 { return [0.0, 0.0, 0.0] }
    bind f = (w * 0.5) / tan(0.349066)
    return [x + w * 0.5 + f * list_get(v, 0) / d, y + h * 0.5 + f * list_get(v, 1) / d, 1.0]
}
fn ss_minimap(x, y, w, h) {
    bind e = st_get("sse:" + st_get("ss_level", ""), [])
    bind n = floor(len(e) / 6.0)
    if n < 1.0 { return 0.0 }
    bind b = st_get("ss_bounds", [0.0 - 1.0, 0.0 - 1.0, 0.0 - 1.0, 1.0, 1.0, 1.0])
    bind sx = list_get(b, 3) - list_get(b, 0)
    bind sz = list_get(b, 5) - list_get(b, 2)
    bind mx = (list_get(b, 3) + list_get(b, 0)) * 0.5
    bind mz = (list_get(b, 5) + list_get(b, 2)) * 0.5
    bind half = h * 0.5
    bind size = half * 0.5
    bind sc = size / max(sx, sz)
    bind ox = x + w * 0.5 + 0.974 * half - size * 0.55
    bind oy = y + h * 0.5 + 0.954 * half - size * 0.42
    bind c = ss_strobe(5.0)
    bind k = 0.0
    while k < n {
        bind i = k * 6.0
        bind ax = ox + (list_get(e, i) - mx) * sc
        bind ay = oy - (list_get(e, i + 2.0) - mz) * sc * 0.7071 + list_get(e, i + 1.0) * sc * 0.7071
        bind bx = ox + (list_get(e, i + 3.0) - mx) * sc
        bind by = oy - (list_get(e, i + 5.0) - mz) * sc * 0.7071 + list_get(e, i + 4.0) * sc * 0.7071
        g_line_a(ax, ay, bx, by, c, 0.45)
        bind k = k + 1.0
    }
    bind px = ox + (st_get("ss_bx", 0.0) - mx) * sc
    bind py = oy - (st_get("ss_bz", 0.0) - mz) * sc * 0.7071 + st_get("ss_by", 0.0) * sc * 0.7071
    g_disc(px, py, 3.5, ss_strobe(1.0))
    return 0.0
}

← Back to index