灵符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_story.ling
# rt_story.ling - "story" mode: a kawaii pop-up storybook platformer in the spirit of Yoshi's Story.
# A scene with `mode=story` is a page. Box nodes tagged `ground` are solid, `cloud` are one-way
# platforms, `eggblock` give eggs when bumped, `spring` bounce, `fruit` (ex kind=apple|banana|
# grapes|melon|heart) are eaten, `puff` (ex kind=walker|flyer|spiky) are the enemies, `flower`,
# `tree` and `cloudface` are animated scenery and the `player` spawn is where Moko starts.
#
# Moko runs, jumps (hold for height), flutters (hold jump while falling), ground-pounds (down in the
# air), licks with the tongue (eats fruit, swallows puffs into eggs), and throws eggs (up to six
# follow behind). Eat 30 fruits to clear the page; the page's lucky fruit counts three. The smile
# flower's eight petals are health; hearts give two back. The world bobs to the music's beat.

fn story_on() { return st_get("sy_on", 0.0) > 0.5 }

# ── palette ──────────────────────────────────────────────────────────────────
fn sy_c(name) {
    if name == "moko" { return c_rgb(255.0, 168.0, 196.0) }
    if name == "moko_dark" { return c_rgb(232.0, 118.0, 158.0) }
    if name == "belly" { return c_rgb(255.0, 246.0, 236.0) }
    if name == "saddle" { return c_rgb(120.0, 214.0, 190.0) }
    if name == "shoe" { return c_rgb(255.0, 196.0, 110.0) }
    if name == "cheek" { return c_rgb(255.0, 120.0, 150.0) }
    if name == "eye" { return c_rgb(40.0, 30.0, 52.0) }
    if name == "white" { return c_rgb(255.0, 255.0, 255.0) }
    if name == "tongue" { return c_rgb(255.0, 96.0, 140.0) }
    return c_rgb(255.0, 255.0, 255.0)
}
fn sy_fruit_kinds() { return ["apple", "banana", "grapes", "melon", "heart"] }
fn sy_fruit_code(k) {
    bind i = l_index(sy_fruit_kinds(), k)
    if i < 0.0 { return 0.0 }
    return i
}
fn sy_fruit_name(code) { return list_get(["Apple", "Banana", "Grapes", "Melon", "Heart"], clampf(code, 0.0, 4.0)) }

# ── sound ────────────────────────────────────────────────────────────────────
fn sy_sfx_names() { return ["jump", "flutter", "tongue", "nom", "gulp", "egg", "throw", "pop", "pound", "chime", "sparkle", "hurt", "spring", "bonk", "poof", "page", "blip", "heart", "clear"] }
fn sy_sfx_load() {
    if st_get("sy_sfx_loaded", 0.0) > 0.5 || !plat_has_audio() { return 0.0 }
    bind names = sy_sfx_names()
    bind i = 0.0
    while i < len(names) {
        st_set("sy_sfx_" + list_get(names, i), plat_sample_load(game_path("assets/sfx/" + list_get(names, i) + ".wav")))
        bind i = i + 1.0
    }
    st_set("sy_sfx_loaded", 1.0)
    return 0.0
}
fn sy_sfx(name, vol, pitch) {
    if !plat_has_audio() { return 0.0 }
    bind id = st_get("sy_sfx_" + name, 0.0 - 1.0)
    if id >= 0.0 { plat_sample_play_pitch(id, vol, pitch) }
    return 0.0
}

# ── enter / leave ────────────────────────────────────────────────────────────
fn sy_lists() { return ["sd_x", "sd_y", "sd_hw", "sd_hh", "sd_k", "sd_n", "sd_bump", "sd_charge", "fr_x", "fr_y", "fr_k", "fr_ph", "fr_on", "en_x", "en_y", "en_vx", "en_vy", "en_k", "en_x0", "en_y0", "en_on", "en_t", "eg_x", "eg_y", "eg_vx", "eg_vy", "eg_t", "eg_b", "hx", "hy", "dc_x", "dc_y", "dc_z", "dc_k", "dc_s", "dc_c", "pp_x", "pp_y", "pp_t", "pp_txt", "pp_c"] }
fn story_enter() {
    bind keys = sy_lists()
    bind i = 0.0
    while i < len(keys) {
        st_lclear(list_get(keys, i))
        bind i = i + 1.0
    }
    st_set("sy_on", 1.0)
    sy_sfx_load()
    plat_pfx_clear()
    plat_pfx_max(3000.0)
    st_set("sy_page", to_number(sc_ex("page", "1")))
    st_set("sy_title", sc_ex("title", sc_get("name", "")))
    st_set("sy_lucky", sy_fruit_code(sc_ex("lucky", "grapes")))
    st_set("sy_goal", to_number(sc_ex("goal", "30")))
    st_set("sy_bpm", to_number(sc_ex("bpm", "100")))
    st_set("sy_floor_death", to_number(sc_ex("death", "12")))
    bk_beats_load(sc_ex("beats", ""))
    st_set("bk_last_pos", 0.0)
    # world from the scene's nodes
    bind n = nd_count()
    bind sx = 0.0
    bind sy = 0.0 - 2.0
    bind i = 0.0
    while i < n {
        bind tag = nd_get(i, "tag")
        if tag == "ground" || tag == "cloud" || tag == "eggblock" || tag == "spring" {
            st_lpush("sd_x", nd_get(i, "px"))
            st_lpush("sd_y", nd_get(i, "py"))
            st_lpush("sd_hw", nd_get(i, "sx"))
            st_lpush("sd_hh", nd_get(i, "sy"))
            st_lpush("sd_k", l_index(["ground", "cloud", "eggblock", "spring"], tag))
            st_lpush("sd_n", i)
            st_lpush("sd_bump", 0.0)
            st_lpush("sd_charge", to_number(nd_ex(i, "eggs", "3")))
        } else if tag == "fruit" {
            st_lpush("fr_x", nd_get(i, "px"))
            st_lpush("fr_y", nd_get(i, "py"))
            st_lpush("fr_k", sy_fruit_code(nd_ex(i, "kind", "apple")))
            st_lpush("fr_ph", rand() * 6.28)
            st_lpush("fr_on", 1.0)
        } else if tag == "puff" {
            st_lpush("en_x", nd_get(i, "px"))
            st_lpush("en_y", nd_get(i, "py"))
            st_lpush("en_vx", sel(rand() < 0.5, 0.0 - 1.6, 1.6))
            st_lpush("en_vy", 0.0)
            st_lpush("en_k", l_index(["walker", "flyer", "spiky"], nd_ex(i, "kind", "walker")))
            st_lpush("en_x0", nd_get(i, "px"))
            st_lpush("en_y0", nd_get(i, "py"))
            st_lpush("en_on", 1.0)
            st_lpush("en_t", rand() * 6.28)
        } else if tag == "flower" || tag == "tree" || tag == "cloudface" {
            st_lpush("dc_x", nd_get(i, "px"))
            st_lpush("dc_y", nd_get(i, "py"))
            st_lpush("dc_z", nd_get(i, "pz"))
            st_lpush("dc_k", l_index(["flower", "tree", "cloudface"], tag))
            st_lpush("dc_s", nd_get(i, "sx"))
            st_lpush("dc_c", nd_get(i, "col"))
        } else if tag == "player" {
            bind sx = nd_get(i, "px")
            bind sy = nd_get(i, "py")
        }
        bind i = i + 1.0
    }
    st_set("sy_px", sx)  st_set("sy_py", sy)  st_set("sy_vx", 0.0)  st_set("sy_vy", 0.0)
    st_set("sy_safe_x", sx)  st_set("sy_safe_y", sy)
    st_set("sy_face", 1.0)
    st_set("sy_ground", 0.0)
    st_set("sy_coyote", 0.0)
    st_set("sy_buffer", 0.0)
    st_set("sy_flutter", 0.0)
    st_set("sy_flutter_fuel", 1.0)
    st_set("sy_pound", 0.0)
    st_set("sy_tongue", 0.0)
    st_set("sy_full", 0.0)
    st_set("sy_eggs", 0.0)
    st_set("sy_petals", 8.0)
    st_set("sy_inv", 0.0)
    st_set("sy_fruits", 0.0)
    st_set("sy_melons", 0.0)
    st_set("sy_combo_note", 0.0)
    st_set("sy_squash", 0.0)
    st_set("sy_state", "intro")
    st_set("sy_state_t", 0.0)
    st_set("sy_time", 0.0)
    st_set("sy_shake", 0.0)
    st_set("sy_cam_x", sx)
    st_set("sy_cam_y", sy)
    bind k = 0.0
    while k < 60.0 {
        st_lpush("hx", sx)
        st_lpush("hy", sy)
        bind k = k + 1.0
    }
    plat_post(1.0, 1.0, 1.4, c_rgb(92.0, 54.0, 70.0), 0.1)
    plat_outline_threshold(0.6)
    return 0.0
}
fn story_leave() {
    if story_on() {
        plat_post(0.0, 0.0, 0.0, 0.0, 0.0)
        plat_pfx_clear()
    }
    st_set("sy_on", 0.0)
    return 0.0
}

# ── collision ────────────────────────────────────────────────────────────────
# the solid overlapping box (x, y, hw, hh) -> index or -1; one-way clouds only when `feet_prev`
# was above their top and the mover is falling
fn sy_hit_solid(x, y, hw, hh, vy, feet_prev) {
    bind n = st_llen("sd_x")
    bind i = 0.0
    while i < n {
        bind sx = st_lget("sd_x", i)
        bind sw = st_lget("sd_hw", i)
        if abs(x - sx) < hw + sw {
            bind sy = st_lget("sd_y", i)
            bind sh = st_lget("sd_hh", i)
            if abs(y - sy) < hh + sh {
                if st_lget("sd_k", i) == 1.0 {
                    if vy >= 0.0 && feet_prev <= sy - sh + 0.06 { return i }
                } else { return i }
            }
        }
        bind i = i + 1.0
    }
    return 0.0 - 1.0
}
# is there ground just under the point (x, y)?
fn sy_ground_at(x, y) {
    return sy_hit_solid(x, y + 0.15, 0.05, 0.1, 1.0, y - 0.05) >= 0.0
}

# ── effects ──────────────────────────────────────────────────────────────────
fn sy_popup(x, y, txt, c) {
    st_lpush("pp_x", x)  st_lpush("pp_y", y)  st_lpush("pp_t", 0.0)  st_lpush("pp_txt", txt)  st_lpush("pp_c", c)
    return 0.0
}
fn sy_hearts(x, y, n) {
    plat_pfx_burst(x, y, 0.0 - 0.3, n, 3.5, 0.9, 0.55, 0.1, c_rgb(255.0, 150.0, 190.0), c_rgb(255.0, 230.0, 240.0), 0.0, 0.0 - 0.4, 0.0, 0.0 - 2.5, 0.0)
    return 0.0
}
fn sy_stars(x, y, n, c) {
    plat_pfx_burst(x, y, 0.0 - 0.3, n, 6.0, 0.7, 0.4, 0.0, c, c_rgb(255.0, 255.0, 255.0), 1.0, 0.6, 0.0, 0.0 - 2.0, 0.0)
    return 0.0
}
fn sy_dust(x, y) {
    plat_pfx_burst(x, y, 0.0 - 0.2, 8.0, 2.2, 0.45, 0.45, 0.8, c_rgb(255.0, 250.0, 235.0), c_rgb(240.0, 228.0, 210.0), 0.0, 0.0 - 0.3, 0.0, 0.0 - 0.6, 0.0)
    return 0.0
}

# ── eating ───────────────────────────────────────────────────────────────────
fn sy_eat_fruit(i) {
    st_lset("fr_on", i, 0.0)
    bind k = st_lget("fr_k", i)
    bind x = st_lget("fr_x", i)
    bind y = st_lget("fr_y", i)
    if k == 4.0 {
        st_set("sy_petals", min(8.0, st_get("sy_petals", 8.0) + 2.0))
        sy_sfx("heart", 0.8, 1.0)
        sy_hearts(x, y, 14.0)
        sy_popup(x, y - 0.8, "+2 petals", c_rgb(255.0, 110.0, 160.0))
        return 0.0
    }
    bind worth = 1.0
    if k == st_get("sy_lucky", 2.0) { bind worth = 3.0 }
    if k == 3.0 { st_set("sy_melons", st_get("sy_melons", 0.0) + 1.0)  bind worth = max(worth, 3.0) }
    st_set("sy_fruits", st_get("sy_fruits", 0.0) + worth)
    # the chime climbs a major scale as Moko keeps eating
    bind note = st_get("sy_combo_note", 0.0)
    bind scale = [0.0, 2.0, 4.0, 5.0, 7.0, 9.0, 11.0, 12.0, 14.0, 16.0]
    bind semi = list_get(scale, note - floor(note / 10.0) * 10.0)
    sy_sfx("chime", 0.5, pow(2.0, semi / 12.0) * 0.5)
    sy_sfx("nom", 0.7, 0.95 + rand() * 0.15)
    st_set("sy_combo_note", note + 1.0)
    st_set("sy_combo_t", 1.2)
    st_set("sy_full", 0.25)
    sy_hearts(x, y, 6.0)
    if worth > 1.0 {
        sy_sfx("sparkle", 0.7, 1.0)
        sy_stars(x, y, 18.0, c_rgb(255.0, 230.0, 120.0))
        sy_popup(x, y - 0.8, sel(k == 3.0, "Melon! +3", "Lucky! +3"), c_rgb(255.0, 170.0, 60.0))
    }
    if st_get("sy_fruits", 0.0) >= st_get("sy_goal", 30.0) && st_get("sy_state", "") == "play" {
        st_set("sy_state", "clear")
        st_set("sy_state_t", 0.0)
        sy_sfx("clear", 0.9, 1.0)
    }
    return 0.0
}
fn sy_add_egg() {
    if st_get("sy_eggs", 0.0) < 6.0 {
        st_set("sy_eggs", st_get("sy_eggs", 0.0) + 1.0)
        sy_sfx("egg", 0.7, 1.0 + st_get("sy_eggs", 0.0) * 0.05)
    }
    return 0.0
}
fn sy_kill_enemy(i, why) {
    st_lset("en_on", i, 0.0)
    bind x = st_lget("en_x", i)
    bind y = st_lget("en_y", i)
    plat_pfx_burst(x, y, 0.0 - 0.3, 16.0, 3.0, 0.6, 0.7, 0.1, c_rgb(255.0, 255.0, 255.0), c_rgb(230.0, 230.0, 255.0), 0.0, 0.0 - 0.5, 0.0, 0.0 - 1.0, 0.0)
    sy_sfx("poof", 0.7, 0.95 + rand() * 0.2)
    if why == "eat" { sy_sfx("gulp", 0.8, 1.0)  sy_add_egg()  st_set("sy_full", 0.4) }
    return 0.0
}
fn sy_hurt(from_x) {
    if st_get("sy_inv", 0.0) > 0.0 { return 0.0 }
    st_set("sy_petals", st_get("sy_petals", 8.0) - 1.0)
    st_set("sy_inv", 1.6)
    st_set("sy_vx", sel(st_get("sy_px", 0.0) < from_x, 0.0 - 7.0, 7.0))
    st_set("sy_vy", 0.0 - 8.0)
    st_set("sy_shake", 0.3)
    sy_sfx("hurt", 0.8, 1.0)
    sy_stars(st_get("sy_px", 0.0), st_get("sy_py", 0.0) - 0.4, 10.0, c_rgb(255.0, 200.0, 220.0))
    if st_get("sy_petals", 0.0) <= 0.0 {
        st_set("sy_state", "oops")
        st_set("sy_state_t", 0.0)
    }
    return 0.0
}
fn sy_bump_block(i) {
    st_lset("sd_bump", i, 0.25)
    if st_lget("sd_charge", i) > 0.0 {
        st_lset("sd_charge", i, st_lget("sd_charge", i) - 1.0)
        sy_add_egg()
        sy_sfx("bonk", 0.7, 1.2)
        sy_stars(st_lget("sd_x", i), st_lget("sd_y", i) - st_lget("sd_hh", i) - 0.4, 10.0, c_rgb(255.0, 240.0, 140.0))
    } else { sy_sfx("bonk", 0.5, 0.8) }
    return 0.0
}

# ── input ────────────────────────────────────────────────────────────────────
fn sy_edge(name, now) {
    bind was = st_get("sy_in_" + name, 0.0) > 0.5
    st_set("sy_in_" + name, sel(now, 1.0, 0.0))
    return now && !was
}

# ── update ───────────────────────────────────────────────────────────────────
fn story_update(dt) {
    if !story_on() { return 0.0 }
    bind dt = min(dt, 0.033)
    bind t = st_get("game_t", 0.0)
    if key_pressed("p") || (key_pressed("escape") && st_get("game_embedded", 0.0) < 0.5) {
        st_set("sy_paused", sel(st_get("sy_paused", 0.0) > 0.5, 0.0, 1.0))
        sy_sfx("blip", 0.5, 1.0)
    }
    if st_get("sy_paused", 0.0) > 0.5 {
        if key_pressed("q") { story_leave()  game_goto(sc_ex("menu", "scenes/pages.lscene")) }
        return 0.0
    }
    bind st = st_get("sy_state", "play")
    st_set("sy_state_t", st_get("sy_state_t", 0.0) + dt)
    bind stt = st_get("sy_state_t", 0.0)
    # beat
    bind pos = 0.0
    if plat_has_audio() { bind pos = plat_music_pos() }
    bind beat = bk_beat_hits("kick", pos) + bk_beat_hits("snare", pos)
    st_set("bk_last_pos", pos)
    if st_llen("bt_kick") < 1.0 {
        # no MIDI: a steady bob at the page's tempo
        bind spb = 60.0 / max(40.0, st_get("sy_bpm", 100.0))
        if floor(t / spb) != floor((t - dt) / spb) { bind beat = 1.0 }
    }
    if beat > 0.0 { st_set("sy_beat", 1.0) }
    st_set("sy_beat", max(0.0, st_get("sy_beat", 0.0) - dt * 5.0))
    # timers
    bind tl = ["sy_inv", "sy_full", "sy_tongue_cd", "sy_combo_t", "sy_shake", "sy_squash_t"]
    bind i = 0.0
    while i < len(tl) {
        st_set(list_get(tl, i), max(0.0, st_get(list_get(tl, i), 0.0) - dt))
        bind i = i + 1.0
    }
    if st_get("sy_combo_t", 0.0) <= 0.0 { st_set("sy_combo_note", 0.0) }
    if st == "intro" {
        if stt > 2.2 || (stt > 0.5 && (key_pressed("space") || key_pressed("enter") || plat_gamepad_button(0.0))) { st_set("sy_state", "play")  st_set("sy_state_t", 0.0)  bind st = "play" }
    }
    if st == "play" {
        st_set("sy_time", st_get("sy_time", 0.0) + dt)
        sy_player_step(dt)
    }
    if st == "clear" {
        if floor(stt * 4.0) != floor((stt - dt) * 4.0) && stt < 2.5 { sy_stars(st_get("sy_px", 0.0) + (rand() - 0.5) * 6.0, st_get("sy_py", 0.0) - 2.0 - rand() * 3.0, 24.0, list_get([c_rgb(255.0, 170.0, 200.0), c_rgb(150.0, 220.0, 255.0), c_rgb(255.0, 230.0, 120.0), c_rgb(170.0, 240.0, 180.0)], floor(rand() * 3.99))) }
        if stt > 3.6 { sy_page_done() }
    }
    if st == "oops" {
        if stt > 2.0 {
            story_leave()
            game_goto(sc_get("path", ""))
            return 0.0
        }
    }
    sy_enemies_step(dt)
    sy_eggs_step(dt)
    # popups
    bind i = st_llen("pp_t") - 1.0
    while i >= 0.0 {
        bind pt = st_lget("pp_t", i) + dt
        if pt > 1.2 { st_ldel("pp_x", i)  st_ldel("pp_y", i)  st_ldel("pp_t", i)  st_ldel("pp_txt", i)  st_ldel("pp_c", i) } else { st_lset("pp_t", i, pt) }
        bind i = i - 1.0
    }
    bind i = 0.0
    while i < st_llen("sd_x") {
        st_lset("sd_bump", i, max(0.0, st_lget("sd_bump", i) - dt))
        bind i = i + 1.0
    }
    plat_pfx_step(dt, 9.0, 1.2, 1000.0, 0.3)
    sy_camera(dt)
    return 0.0
}

fn sy_player_step(dt) {
    bind auto = st_get("game_autoplay", 0.0) > 0.5
    bind t = st_get("game_t", 0.0)
    # input
    bind ax = 0.0
    if key_down("a") || key_down("left") { bind ax = ax - 1.0 }
    if key_down("d") || key_down("right") { bind ax = ax + 1.0 }
    if abs(plat_gamepad_lx()) > 0.2 { bind ax = ax + plat_gamepad_lx() }
    bind jump_held = key_down("space") || key_down("w") || key_down("up") || plat_gamepad_button(0.0)
    bind down = key_down("s") || key_down("down") || plat_gamepad_ly() > 0.6
    bind up = key_down("w") || key_down("up") || plat_gamepad_ly() < 0.0 - 0.6
    bind run = key_down("lshift") || key_down("rshift") || plat_gamepad_button(2.0)
    bind lick = key_down("j") || key_down("x") || mouse_down() || plat_gamepad_button(1.0)
    bind throw = key_down("k") || key_down("c") || mouse_down_right() || plat_gamepad_button(3.0)
    if auto {
        bind ax = 1.0
        bind ph = t - floor(t / 2.6) * 2.6
        bind jump_held = ph < 0.9
        bind lick = sy_enemy_near(2.8)
        bind throw = ph > 1.2 && ph < 1.25
        bind down = ph > 2.3 && ph < 2.35 && st_get("sy_ground", 0.0) < 0.5
    }
    bind jump_pressed = sy_edge("jump", jump_held)
    bind lick_pressed = sy_edge("lick", lick)
    bind throw_pressed = sy_edge("throw", throw)
    bind down_pressed = sy_edge("down", down)
    bind x = st_get("sy_px", 0.0)
    bind y = st_get("sy_py", 0.0)
    bind vx = st_get("sy_vx", 0.0)
    bind vy = st_get("sy_vy", 0.0)
    bind ground = st_get("sy_ground", 0.0) > 0.5
    bind hw = 0.42
    bind hh = 0.58
    # facing
    if ax > 0.1 { st_set("sy_face", 1.0) }
    if ax < 0.0 - 0.1 { st_set("sy_face", 0.0 - 1.0) }
    bind face = st_get("sy_face", 1.0)
    # horizontal
    bind maxs = sel(run, 9.0, 6.2)
    bind acc = sel(ground, 42.0, 24.0)
    bind target = ax * maxs
    if st_get("sy_pound", 0.0) > 0.0 { bind target = 0.0 }
    if vx < target { bind vx = min(target, vx + acc * dt) }
    if vx > target { bind vx = max(target, vx - acc * dt) }
    # jump buffering + coyote time
    if jump_pressed { st_set("sy_buffer", 0.14) }
    st_set("sy_buffer", max(0.0, st_get("sy_buffer", 0.0) - dt))
    if ground { st_set("sy_coyote", 0.1) } else { st_set("sy_coyote", max(0.0, st_get("sy_coyote", 0.0) - dt)) }
    if st_get("sy_buffer", 0.0) > 0.0 && st_get("sy_coyote", 0.0) > 0.0 && st_get("sy_pound", 0.0) <= 0.0 {
        bind vy = 0.0 - 13.0
        st_set("sy_buffer", 0.0)
        st_set("sy_coyote", 0.0)
        st_set("sy_flutter_fuel", 1.0)
        st_set("sy_squash", 0.0 - 0.35)
        sy_sfx("jump", 0.6, 0.95 + rand() * 0.1)
        sy_dust(x, y + hh)
    }
    # gravity, the held-jump float and the flutter
    bind g = 34.0
    if vy < 0.0 && jump_held { bind g = 19.0 }
    bind flutter = false
    if !ground && jump_held && vy > 0.0 - 1.0 && st_get("sy_flutter_fuel", 0.0) > 0.0 && st_get("sy_pound", 0.0) <= 0.0 {
        bind flutter = true
        st_set("sy_flutter_fuel", st_get("sy_flutter_fuel", 0.0) - dt)
        bind g = 0.0
        bind vy = vy + (0.0 - 3.2 - vy) * min(1.0, dt * 9.0)
        if floor(t * 2.8) != floor((t - dt) * 2.8) { sy_sfx("flutter", 0.45, 1.0 + rand() * 0.06) }
        if rand() < 0.4 { sy_dust(x - face * 0.3, y + hh) }
    }
    st_set("sy_flutter", sel(flutter, 1.0, 0.0))
    # ground pound: a spin in the air, then straight down
    if down_pressed && !ground && st_get("sy_pound", 0.0) <= 0.0 {
        st_set("sy_pound", 1.0)
        st_set("sy_pound_t", 0.0)
        bind vx = 0.0
        bind vy = 0.0
        sy_sfx("pound", 0.7, 1.0)
    }
    if st_get("sy_pound", 0.0) > 0.0 {
        st_set("sy_pound_t", st_get("sy_pound_t", 0.0) + dt)
        if st_get("sy_pound_t", 0.0) < 0.2 { bind vy = 0.0  bind g = 0.0 } else { bind vy = 26.0  bind g = 0.0 }
    }
    bind vy = min(20.0, vy + g * dt)
    if st_get("sy_pound", 0.0) > 0.0 && st_get("sy_pound_t", 0.0) >= 0.2 { bind vy = 26.0 }
    # move with sub-steps, x then y
    bind steps = max(1.0, ceil(max(abs(vx), abs(vy)) * dt / 0.12))
    bind h = dt / steps
    bind landed = false
    bind k = 0.0
    while k < steps {
        bind nx = x + vx * h
        bind hit = sy_hit_solid(nx, y, hw, hh - 0.02, vy, y + hh)
        if hit >= 0.0 && st_lget("sd_k", hit) != 1.0 {
            if vx > 0.0 { bind nx = st_lget("sd_x", hit) - st_lget("sd_hw", hit) - hw - 0.001 } else { bind nx = st_lget("sd_x", hit) + st_lget("sd_hw", hit) + hw + 0.001 }
            bind vx = 0.0
        }
        bind x = nx
        bind ny = y + vy * h
        bind hit = sy_hit_solid(x, ny, hw - 0.04, hh, vy, y + hh)
        if hit >= 0.0 {
            bind top = st_lget("sd_y", hit) - st_lget("sd_hh", hit)
            bind bot = st_lget("sd_y", hit) + st_lget("sd_hh", hit)
            bind kind = st_lget("sd_k", hit)
            if vy >= 0.0 {
                bind ny = top - hh - 0.001
                if kind == 3.0 {
                    bind vy = 0.0 - 21.0
                    st_lset("sd_bump", hit, 0.3)
                    sy_sfx("spring", 0.8, 1.0)
                    st_set("sy_flutter_fuel", 1.0)
                    st_set("sy_pound", 0.0)
                } else {
                    if st_get("sy_pound", 0.0) > 0.0 { sy_pound_land(x, ny + hh, hit) }
                    if vy > 6.0 { st_set("sy_squash", 0.35)  sy_dust(x, ny + hh) }
                    bind vy = 0.0
                    bind landed = true
                }
            } else {
                if kind != 1.0 {
                    bind ny = bot + hh + 0.001
                    bind vy = 0.0
                    if kind == 2.0 { sy_bump_block(hit) }
                }
            }
        }
        bind y = ny
        bind k = k + 1.0
    }
    bind ground = landed || sy_ground_at(x - hw * 0.8, y + hh) || sy_ground_at(x + hw * 0.8, y + hh)
    if ground && vy >= 0.0 {
        st_set("sy_flutter_fuel", 1.0)
        if st_get("sy_pound", 0.0) > 0.0 { st_set("sy_pound", 0.0) }
        if sy_ground_at(x, y + hh) { st_set("sy_safe_x", x)  st_set("sy_safe_y", y) }
    }
    st_set("sy_ground", sel(ground && vy >= 0.0, 1.0, 0.0))
    # fell off the page
    if y > st_get("sy_floor_death", 12.0) {
        bind x = st_get("sy_safe_x", 0.0)
        bind y = st_get("sy_safe_y", 0.0) - 1.0
        bind vx = 0.0
        bind vy = 0.0
        st_set("sy_inv", 0.0)
        sy_hurt(x)
        sy_hurt_extra()
    }
    st_set("sy_px", x)  st_set("sy_py", y)  st_set("sy_vx", vx)  st_set("sy_vy", vy)
    st_set("sy_squash", st_get("sy_squash", 0.0) * pow(0.0006, dt))
    # trail for the eggs
    st_lpush("hx", x)
    st_lpush("hy", y)
    st_ldel("hx", 0.0)
    st_ldel("hy", 0.0)
    # the tongue
    if lick_pressed && st_get("sy_tongue", 0.0) <= 0.0 && st_get("sy_tongue_cd", 0.0) <= 0.0 {
        st_set("sy_tongue", 0.001)
        st_set("sy_tongue_up", sel(up, 1.0, 0.0))
        st_set("sy_tongue_hit", 0.0)
        sy_sfx("tongue", 0.6, 1.0)
    }
    if st_get("sy_tongue", 0.0) > 0.0 {
        bind tt = st_get("sy_tongue", 0.0) + dt
        st_set("sy_tongue", tt)
        if tt > 0.3 { st_set("sy_tongue", 0.0)  st_set("sy_tongue_cd", 0.12) } else { sy_tongue_check() }
    }
    # throw an egg
    if throw_pressed && st_get("sy_eggs", 0.0) > 0.0 {
        st_set("sy_eggs", st_get("sy_eggs", 0.0) - 1.0)
        bind evx = face * 13.0 + vx * 0.4
        bind evy = 0.0 - 8.0
        if up { bind evx = face * 7.0  bind evy = 0.0 - 15.0 }
        st_lpush("eg_x", x + face * 0.6)  st_lpush("eg_y", y - 0.3)
        st_lpush("eg_vx", evx)  st_lpush("eg_vy", evy)  st_lpush("eg_t", 0.0)  st_lpush("eg_b", 0.0)
        sy_sfx("throw", 0.7, 1.0)
    }
    # fruit by touch
    bind n = st_llen("fr_x")
    bind i = 0.0
    while i < n {
        if st_lget("fr_on", i) > 0.5 {
            if abs(st_lget("fr_x", i) - x) < 0.9 && abs(st_lget("fr_y", i) - y) < 1.0 { sy_eat_fruit(i) }
        }
        bind i = i + 1.0
    }
    return 0.0
}
fn sy_hurt_extra() {
    st_set("sy_inv", 0.0)
    sy_hurt(st_get("sy_px", 0.0))
    return 0.0
}
fn sy_pound_land(x, feet, hit) {
    st_set("sy_shake", 0.35)
    sy_stars(x, feet - 0.2, 22.0, c_rgb(255.0, 230.0, 140.0))
    sy_sfx("bonk", 0.6, 0.6)
    if st_lget("sd_k", hit) == 2.0 { sy_bump_block(hit) }
    bind i = 0.0
    while i < st_llen("en_x") {
        if st_lget("en_on", i) > 0.5 {
            if abs(st_lget("en_x", i) - x) < 2.6 && abs(st_lget("en_y", i) - (feet - 0.6)) < 1.6 { sy_kill_enemy(i, "pound")  sy_popup(st_lget("en_x", i), st_lget("en_y", i) - 1.0, "Bonk!", c_rgb(255.0, 140.0, 80.0)) }
        }
        bind i = i + 1.0
    }
    return 0.0
}
fn sy_tongue_tip() {
    bind tt = st_get("sy_tongue", 0.0)
    bind ext = sin(clampf(tt / 0.3, 0.0, 1.0) * 3.14159) * 3.3
    bind face = st_get("sy_face", 1.0)
    bind mx = st_get("sy_px", 0.0) + face * 0.55
    bind my = st_get("sy_py", 0.0) - 0.35
    if st_get("sy_tongue_up", 0.0) > 0.5 { return [mx + face * ext * 0.6, my - ext * 0.8] }
    return [mx + face * ext, my]
}
fn sy_tongue_check() {
    if st_get("sy_tongue_hit", 0.0) > 0.5 { return 0.0 }
    bind tip = sy_tongue_tip()
    bind tx = list_get(tip, 0)
    bind ty = list_get(tip, 1)
    bind i = 0.0
    while i < st_llen("en_x") {
        if st_lget("en_on", i) > 0.5 {
            bind dx = st_lget("en_x", i) - tx
            bind dy = st_lget("en_y", i) - ty
            if dx * dx + dy * dy < 0.75 * 0.75 {
                st_set("sy_tongue_hit", 1.0)
                if st_lget("en_k", i) == 2.0 {
                    sy_sfx("bonk", 0.6, 1.4)
                    sy_stars(tx, ty, 8.0, c_rgb(255.0, 160.0, 90.0))
                } else {
                    sy_kill_enemy(i, "eat")
                    sy_popup(tx, ty - 0.8, "Gulp! +egg", c_rgb(120.0, 200.0, 160.0))
                }
                return 0.0
            }
        }
        bind i = i + 1.0
    }
    bind i = 0.0
    while i < st_llen("fr_x") {
        if st_lget("fr_on", i) > 0.5 {
            bind dx = st_lget("fr_x", i) - tx
            bind dy = st_lget("fr_y", i) - ty
            if dx * dx + dy * dy < 0.8 * 0.8 { sy_eat_fruit(i) }
        }
        bind i = i + 1.0
    }
    return 0.0
}
fn sy_enemy_near(r) {
    bind i = 0.0
    while i < st_llen("en_x") {
        if st_lget("en_on", i) > 0.5 && st_lget("en_k", i) != 2.0 {
            bind dx = (st_lget("en_x", i) - st_get("sy_px", 0.0)) * st_get("sy_face", 1.0)
            if dx > 0.0 && dx < r && abs(st_lget("en_y", i) - st_get("sy_py", 0.0)) < 1.2 { return true }
        }
        bind i = i + 1.0
    }
    return false
}

fn sy_enemies_step(dt) {
    bind px = st_get("sy_px", 0.0)
    bind py = st_get("sy_py", 0.0)
    bind pvy = st_get("sy_vy", 0.0)
    bind playing = st_get("sy_state", "") == "play"
    bind n = st_llen("en_x")
    bind i = 0.0
    while i < n {
        if st_lget("en_on", i) > 0.5 {
            bind k = st_lget("en_k", i)
            bind x = st_lget("en_x", i)
            bind y = st_lget("en_y", i)
            bind vx = st_lget("en_vx", i)
            st_lset("en_t", i, st_lget("en_t", i) + dt)
            bind et = st_lget("en_t", i)
            if abs(x - px) < 30.0 {
                if k == 1.0 {
                    # flyers bob and drift around their spot
                    bind x = st_lget("en_x0", i) + sin(et * 0.7) * 2.5
                    bind y = st_lget("en_y0", i) + sin(et * 2.2) * 0.8
                } else {
                    bind spd = sel(k == 2.0, 1.0, 1.6)
                    if vx > 0.0 { bind vx = spd } else { bind vx = 0.0 - spd }
                    bind vy = min(18.0, st_lget("en_vy", i) + 30.0 * dt)
                    bind nx = x + vx * dt
                    # turn at walls and at ledges
                    bind wall = sy_hit_solid(nx + sel(vx > 0.0, 0.45, 0.0 - 0.45), y, 0.05, 0.3, 0.0, y) >= 0.0
                    bind ledge = !sy_ground_at(nx + sel(vx > 0.0, 0.5, 0.0 - 0.5), y + 0.45)
                    if wall || (ledge && sy_ground_at(x, y + 0.45)) { bind vx = 0.0 - vx } else { bind x = nx }
                    bind ny = y + vy * dt
                    bind hit = sy_hit_solid(x, ny, 0.4, 0.45, vy, y + 0.45)
                    if hit >= 0.0 && vy >= 0.0 { bind ny = st_lget("sd_y", hit) - st_lget("sd_hh", hit) - 0.45  bind vy = 0.0 }
                    bind y = ny
                    st_lset("en_vy", i, vy)
                }
                st_lset("en_x", i, x)
                st_lset("en_y", i, y)
                st_lset("en_vx", i, vx)
                # touching Moko: stomp from above (not spiky), otherwise it hurts
                if playing && abs(x - px) < 0.85 && abs(y - py) < 1.05 {
                    if pvy > 2.0 && py < y - 0.4 && k != 2.0 {
                        sy_kill_enemy(i, "stomp")
                        st_set("sy_vy", 0.0 - 9.0)
                        st_set("sy_flutter_fuel", 1.0)
                        sy_popup(x, y - 0.8, "Squish!", c_rgb(150.0, 170.0, 255.0))
                    } else { sy_hurt(x) }
                }
            }
        }
        bind i = i + 1.0
    }
    return 0.0
}

fn sy_eggs_step(dt) {
    bind i = st_llen("eg_x") - 1.0
    while i >= 0.0 {
        bind t = st_lget("eg_t", i) + dt
        bind x = st_lget("eg_x", i)
        bind y = st_lget("eg_y", i)
        bind vx = st_lget("eg_vx", i)
        bind vy = st_lget("eg_vy", i) + 26.0 * dt
        bind gone = t > 2.6
        bind nx = x + vx * dt
        bind ny = y + vy * dt
        bind hit = sy_hit_solid(nx, ny, 0.25, 0.3, vy, y + 0.3)
        if hit >= 0.0 {
            if st_lget("sd_k", hit) == 2.0 { sy_bump_block(hit)  bind gone = true }
            if abs(ny - st_lget("sd_y", hit)) < st_lget("sd_hh", hit) * 0.7 && st_lget("sd_k", hit) != 1.0 { bind vx = 0.0 - vx * 0.7  bind nx = x } else { bind vy = 0.0 - abs(vy) * 0.62  bind ny = y }
            st_lset("eg_b", i, st_lget("eg_b", i) + 1.0)
            sy_sfx("bonk", 0.35, 1.6)
            if st_lget("eg_b", i) > 3.0 { bind gone = true }
        }
        # eggs pop puffs (even spiky ones) and collect fruit they touch
        bind e = 0.0
        while e < st_llen("en_x") && !gone {
            if st_lget("en_on", e) > 0.5 && abs(st_lget("en_x", e) - nx) < 0.75 && abs(st_lget("en_y", e) - ny) < 0.75 {
                sy_kill_enemy(e, "egg")
                sy_sfx("pop", 0.7, 1.0)
                sy_popup(nx, ny - 0.8, "Pop!", c_rgb(255.0, 200.0, 90.0))
                bind gone = true
            }
            bind e = e + 1.0
        }
        bind f = 0.0
        while f < st_llen("fr_x") {
            if st_lget("fr_on", f) > 0.5 && abs(st_lget("fr_x", f) - nx) < 0.7 && abs(st_lget("fr_y", f) - ny) < 0.7 { sy_eat_fruit(f) }
            bind f = f + 1.0
        }
        if gone {
            sy_stars(nx, ny, 10.0, c_rgb(255.0, 255.0, 255.0))
            st_ldel("eg_x", i)  st_ldel("eg_y", i)  st_ldel("eg_vx", i)  st_ldel("eg_vy", i)  st_ldel("eg_t", i)  st_ldel("eg_b", i)
        } else {
            st_lset("eg_x", i, nx)  st_lset("eg_y", i, ny)  st_lset("eg_vx", i, vx)  st_lset("eg_vy", i, vy)  st_lset("eg_t", i, t)
        }
        bind i = i - 1.0
    }
    return 0.0
}

fn sy_camera(dt) {
    bind face = st_get("sy_face", 1.0)
    bind tx = st_get("sy_px", 0.0) + face * 2.4
    bind ty = min(st_get("sy_py", 0.0) - 1.4, 2.0)
    bind cx = st_get("sy_cam_x", tx) + (tx - st_get("sy_cam_x", tx)) * min(1.0, dt * 3.5)
    bind cy = st_get("sy_cam_y", ty) + (ty - st_get("sy_cam_y", ty)) * min(1.0, dt * 2.5)
    st_set("sy_cam_x", cx)
    st_set("sy_cam_y", cy)
    bind sh = st_get("sy_shake", 0.0)
    bind intro = 0.0
    if st_get("sy_state", "") == "intro" { bind intro = 1.0 - clampf(st_get("sy_state_t", 0.0) / 2.2, 0.0, 1.0) }
    cam_orbit(cx + (rand() - 0.5) * sh, cy + (rand() - 0.5) * sh, 0.0, 0.0, 0.12 + intro * 0.1, 11.5 + intro * 4.0, 1.0)
    return 0.0
}
fn sy_page_done() {
    st_set("gs_fruits", num_str(st_get("sy_fruits", 0.0)))
    st_set("gs_melons", num_str(st_get("sy_melons", 0.0)))
    bind secs = st_get("sy_time", 0.0)
    bind m = floor(secs / 60.0)
    st_set("gs_time", num_str(m) + ":" + pad2(floor(secs - m * 60.0)))
    st_set("gs_pagename", st_get("sy_title", ""))
    bind petals = st_get("sy_petals", 0.0)
    st_set("gs_hearts", list_get(["", "*", "**", "***"], clampf(floor(petals / 3.0) + 1.0, 1.0, 3.0)))
    st_set("gs_done" + num_str(st_get("sy_page", 1.0)), "Page cleared  -  " + num_str(st_get("sy_fruits", 0.0)) + " fruits, " + st_get("gs_time", ""))
    st_set("gs_next", sc_ex("next", "scenes/pages.lscene"))
    story_leave()
    game_goto("scenes/cleared.lscene")
    return 0.0
}

# ── drawing ──────────────────────────────────────────────────────────────────
# spheres with a level of detail by size: eyes and cheeks need far fewer facets than a hill
fn sy_ball(c, x, y, z, rx, ry, rz) {
    plat_color(c)
    bind r = max(rx, max(ry, rz))
    if r < 0.16 { plat_sphere_lod(x, y, z, rx, ry, rz, 8.0, 5.0) }
    else if r < 0.6 { plat_sphere_lod(x, y, z, rx, ry, rz, 12.0, 8.0) }
    else { plat_sphere_lod(x, y, z, rx, ry, rz, 16.0, 10.0) }
    return 0.0
}
fn sy_box(c, x, y, z, sx, sy, sz, rx, ry, rz) {
    plat_color(c)
    plat_shape("box", x, y, z, sx, sy, sz, rx, ry, rz, 0.0)
    return 0.0
}
fn story_lights() {
    if !story_on() { return 0.0 }
    plat_light(st_get("sy_px", 0.0) - 6.0, 0.0 - 12.0, 0.0 - 10.0, 255.0, 246.0, 236.0, 1.4, 60.0)
    plat_light(st_get("sy_px", 0.0) + 10.0, 0.0 - 6.0, 0.0 - 6.0, 255.0, 200.0, 230.0, 0.8 + st_get("sy_beat", 0.0) * 0.5, 40.0)
    return 0.0
}
# Moko: a round pastel dino with a mint saddle, big shiny eyes and blushing cheeks
fn sy_draw_moko(t) {
    bind x = st_get("sy_px", 0.0)
    bind y = st_get("sy_py", 0.0)
    bind f = st_get("sy_face", 1.0)
    if st_get("sy_inv", 0.0) > 0.0 && floor(t * 16.0) - floor(t * 16.0 / 2.0) * 2.0 < 0.5 { return 0.0 }
    bind sq = st_get("sy_squash", 0.0)
    bind bob = sin(t * 9.0) * 0.04 * sel(abs(st_get("sy_vx", 0.0)) > 0.5 && st_get("sy_ground", 0.0) > 0.5, 1.0, 0.0)
    bind sx = 1.0 + sq * 0.5
    bind syy = 1.0 - sq
    bind spin = 0.0
    if st_get("sy_pound", 0.0) > 0.0 && st_get("sy_pound_t", 0.0) < 0.2 { bind spin = st_get("sy_pound_t", 0.0) / 0.2 * 6.283 }
    bind full = st_get("sy_full", 0.0) > 0.0
    bind by = y + 0.1 - bob
    # body + belly + saddle
    sy_ball(sy_c("moko"), x, by, 0.0, 0.52 * sx, 0.5 * syy, 0.48)
    sy_ball(sy_c("belly"), x + f * 0.2, by + 0.08, 0.0 - 0.18, 0.32 * sx, 0.36 * syy, 0.3)
    sy_ball(sy_c("saddle"), x - f * 0.12, by - 0.42 * syy, 0.0, 0.34, 0.12, 0.38)
    # head (bigger when the cheeks are full)
    bind hx = x + f * 0.32
    bind hy = by - 0.62 * syy
    bind hr = 0.42 + sel(full, 0.06, 0.0)
    sy_ball(sy_c("moko"), hx, hy, 0.0, hr * sx, hr * syy, hr)
    # snout
    sy_ball(sy_c("moko"), hx + f * 0.32, hy + 0.08, 0.0, 0.28, 0.22 * syy, 0.27)
    # eyes: white, dark pupils, shine; blink every few seconds
    bind blink = sel(t - floor(t / 3.7) * 3.7 < 0.12, 0.25, 1.0)
    bind ex = hx + f * 0.14
    sy_ball(sy_c("white"), ex, hy - 0.16, 0.0 - 0.25, 0.14, 0.18 * blink, 0.08)
    sy_ball(sy_c("white"), ex - f * 0.2, hy - 0.16, 0.0 - 0.22, 0.12, 0.16 * blink, 0.07)
    if blink > 0.5 {
        sy_ball(sy_c("eye"), ex + f * 0.03, hy - 0.14, 0.0 - 0.32, 0.08, 0.12, 0.04)
        sy_ball(sy_c("eye"), ex - f * 0.17, hy - 0.14, 0.0 - 0.28, 0.07, 0.1, 0.04)
        sy_ball(sy_c("white"), ex + f * 0.06, hy - 0.2, 0.0 - 0.36, 0.03, 0.03, 0.02)
        sy_ball(sy_c("white"), ex - f * 0.14, hy - 0.2, 0.0 - 0.32, 0.025, 0.025, 0.02)
    }
    # cheeks
    sy_ball(sy_c("cheek"), hx + f * 0.25, hy + 0.06, 0.0 - 0.22, 0.09, 0.05, 0.03)
    # crest
    sy_ball(sy_c("saddle"), hx - f * 0.25, hy - 0.35, 0.0, 0.1, 0.14, 0.1)
    # tail
    sy_ball(sy_c("moko_dark"), x - f * 0.55, by + 0.2, 0.0, 0.18, 0.12, 0.14)
    # feet: running cycle, flutter whirl, tucked when pounding
    bind ph = t * 14.0
    bind fl = st_get("sy_flutter", 0.0) > 0.5
    bind run = abs(st_get("sy_vx", 0.0)) > 0.5 && st_get("sy_ground", 0.0) > 0.5
    bind k = 0.0
    while k < 2.0 {
        bind off = sel(k < 0.5, 0.0, 3.14159)
        bind fx = x + f * 0.12 + sel(run, cos(ph + off) * 0.22, 0.0)
        bind fy = y + 0.5 + sel(run, min(0.0, sin(ph + off)) * 0.12, 0.0)
        if fl {
            bind fx = x + cos(ph * 2.2 + off) * 0.28
            bind fy = y + 0.42 + sin(ph * 2.2 + off) * 0.16
        }
        if spin > 0.0 { bind fy = y + 0.3 }
        sy_ball(sy_c("shoe"), fx, fy, sel(k < 0.5, 0.0 - 0.18, 0.18), 0.2, 0.12, 0.14)
        bind k = k + 1.0
    }
    # the tongue
    if st_get("sy_tongue", 0.0) > 0.0 {
        bind tip = sy_tongue_tip()
        bind mx = x + f * 0.55
        bind my = st_get("sy_py", 0.0) - 0.35
        bind tx = list_get(tip, 0)
        bind ty = list_get(tip, 1)
        bind ln = sqrt((tx - mx) * (tx - mx) + (ty - my) * (ty - my))
        bind ang = atan2(ty - my, tx - mx)
        plat_color(sy_c("tongue"))
        plat_shape("capsule", (mx + tx) * 0.5, (my + ty) * 0.5, 0.0 - 0.1, 0.08, ln * 0.5 + 0.08, 0.08, 0.0, 0.0, ang - 1.5708, 0.0)
        sy_ball(sy_c("tongue"), tx, ty, 0.0 - 0.1, 0.18, 0.18, 0.18)
    }
    return 0.0
}
fn sy_draw_egg(x, y, z, s) {
    sy_ball(c_rgb(255.0, 253.0, 246.0), x, y, z, 0.26 * s, 0.33 * s, 0.26 * s)
    sy_ball(c_rgb(120.0, 214.0, 150.0), x + 0.1 * s, y - 0.1 * s, z - 0.2 * s, 0.08 * s, 0.08 * s, 0.05 * s)
    sy_ball(c_rgb(120.0, 214.0, 150.0), x - 0.12 * s, y + 0.08 * s, z - 0.19 * s, 0.07 * s, 0.07 * s, 0.05 * s)
    return 0.0
}
fn sy_draw_fruit(k, x, y, t, ph) {
    bind b = sin(t * 2.5 + ph) * 0.12
    bind yy = y + b
    bind spin = t * 1.5 + ph
    if k == 0.0 {
        sy_ball(c_rgb(255.0, 90.0, 100.0), x, yy, 0.0, 0.34, 0.32, 0.34)
        sy_ball(c_rgb(255.0, 180.0, 180.0), x - 0.1, yy - 0.12, 0.0 - 0.28, 0.07, 0.07, 0.03)
        sy_box(c_rgb(130.0, 90.0, 60.0), x, yy - 0.36, 0.0, 0.03, 0.08, 0.03, 0.0, 0.0, 0.0)
        sy_ball(c_rgb(110.0, 210.0, 120.0), x + 0.12, yy - 0.4, 0.0, 0.12, 0.05, 0.08)
    } else if k == 1.0 {
        plat_color(c_rgb(255.0, 230.0, 90.0))
        plat_shape("capsule", x - 0.1, yy, 0.0, 0.12, 0.3, 0.12, 0.0, spin * 0.2, 0.5, 0.0)
        plat_shape("capsule", x + 0.12, yy - 0.05, 0.0, 0.12, 0.28, 0.12, 0.0, spin * 0.2, 0.0 - 0.4, 0.0)
        sy_box(c_rgb(130.0, 100.0, 60.0), x + 0.02, yy - 0.32, 0.0, 0.04, 0.06, 0.04, 0.0, 0.0, 0.0)
    } else if k == 2.0 {
        bind gx = [0.0, 0.0 - 0.15, 0.15, 0.0 - 0.08, 0.08, 0.0]
        bind gy = [0.0 - 0.18, 0.0 - 0.05, 0.0 - 0.05, 0.1, 0.1, 0.24]
        bind j = 0.0
        while j < 6.0 {
            sy_ball(c_rgb(170.0, 110.0, 230.0), x + list_get(gx, j), yy + list_get(gy, j), 0.0, 0.11, 0.11, 0.11)
            bind j = j + 1.0
        }
        sy_ball(c_rgb(110.0, 210.0, 120.0), x + 0.08, yy - 0.32, 0.0, 0.12, 0.05, 0.08)
    } else if k == 3.0 {
        sy_ball(c_rgb(120.0, 210.0, 110.0), x, yy, 0.0, 0.48, 0.42, 0.42)
        bind j = 0.0
        while j < 3.0 {
            sy_box(c_rgb(60.0, 150.0, 70.0), x - 0.25 + j * 0.25, yy, 0.0 - 0.4, 0.04, 0.38, 0.04, 0.0, 0.0, 0.0)
            bind j = j + 1.0
        }
    } else {
        bind pulse = 1.0 + 0.1 * sin(t * 6.0)
        sy_ball(c_rgb(255.0, 110.0, 160.0), x - 0.14 * pulse, yy - 0.06, 0.0, 0.2 * pulse, 0.2 * pulse, 0.18)
        sy_ball(c_rgb(255.0, 110.0, 160.0), x + 0.14 * pulse, yy - 0.06, 0.0, 0.2 * pulse, 0.2 * pulse, 0.18)
        plat_color(c_rgb(255.0, 110.0, 160.0))
        plat_shape("cone", x, yy + 0.16, 0.0, 0.3 * pulse, 0.26, 0.18, 3.14159, 0.0, 0.0, 0.0)
    }
    return 0.0
}
fn sy_draw_puff(k, x, y, t, vx) {
    bind f = sel(vx >= 0.0, 1.0, 0.0 - 1.0)
    bind wob = sin(t * 8.0) * 0.05
    bind body = list_get([c_rgb(140.0, 190.0, 255.0), c_rgb(200.0, 160.0, 255.0), c_rgb(255.0, 170.0, 90.0)], clampf(k, 0.0, 2.0))
    sy_ball(body, x, y - 0.05 + wob, 0.0, 0.42, 0.42 - wob, 0.4)
    # the round mask with two eyes
    sy_ball(c_rgb(255.0, 255.0, 255.0), x + f * 0.2, y - 0.08, 0.0 - 0.24, 0.26, 0.28, 0.12)
    sy_ball(sy_c("eye"), x + f * 0.28, y - 0.14, 0.0 - 0.35, 0.05, 0.08, 0.03)
    sy_ball(sy_c("eye"), x + f * 0.12, y - 0.14, 0.0 - 0.34, 0.05, 0.08, 0.03)
    sy_ball(sy_c("cheek"), x + f * 0.26, y + 0.03, 0.0 - 0.35, 0.05, 0.03, 0.02)
    if k == 1.0 {
        # little wings
        bind flap = sin(t * 18.0) * 0.5
        sy_ball(c_rgb(255.0, 255.0, 255.0), x - 0.36, y - 0.3, 0.1, 0.26, 0.1, 0.12 + flap * 0.08)
        sy_ball(c_rgb(255.0, 255.0, 255.0), x + 0.36, y - 0.3, 0.1, 0.26, 0.1, 0.12 - flap * 0.08)
    } else {
        bind ph = t * 10.0
        sy_ball(c_rgb(255.0, 200.0, 120.0), x - 0.15 + cos(ph) * 0.06, y + 0.38, 0.0 - 0.1, 0.13, 0.08, 0.1)
        sy_ball(c_rgb(255.0, 200.0, 120.0), x + 0.15 + cos(ph + 3.14) * 0.06, y + 0.38, 0.1, 0.13, 0.08, 0.1)
    }
    if k == 2.0 {
        bind j = 0.0
        while j < 6.0 {
            bind a = j / 6.0 * 6.283 + 0.3
            plat_color(c_rgb(255.0, 245.0, 220.0))
            plat_shape("cone", x + cos(a) * 0.45, y - 0.05 + sin(a) * 0.45, 0.0, 0.1, 0.16, 0.1, 0.0, 0.0, a - 1.5708, 0.0)
            bind j = j + 1.0
        }
    }
    return 0.0
}
fn story_world_native() {
    if !story_on() { return 0.0 }
    bind t = st_get("game_t", 0.0)
    bind beat = st_get("sy_beat", 0.0)
    bind cx = st_get("sy_cam_x", 0.0)
    # animated scenery: flowers sway, trees and cloud faces bob to the beat
    bind i = 0.0
    while i < st_llen("dc_x") {
        bind x = st_lget("dc_x", i)
        if abs(x - cx) < 24.0 + abs(st_lget("dc_z", i)) * 0.5 {
            bind y = st_lget("dc_y", i)
            bind z = st_lget("dc_z", i)
            bind k = st_lget("dc_k", i)
            bind s = st_lget("dc_s", i)
            bind c = st_lget("dc_c", i)
            if k == 0.0 {
                bind sway = sin(t * 2.0 + x) * 0.18 + beat * 0.1
                plat_color(c_rgb(110.0, 190.0, 110.0))
                plat_shape("cylinder", x + sway * 0.4, y - 0.4 * s, z, 0.05 * s, 0.4 * s, 0.05 * s, 0.0, 0.0, sway, 0.0)
                bind fx = x + sway * 0.8
                bind fy = y - 0.85 * s - beat * 0.06
                bind j = 0.0
                while j < 5.0 {
                    bind a = j / 5.0 * 6.283 + t * 0.4
                    sy_ball(c, fx + cos(a) * 0.2 * s, fy + sin(a) * 0.2 * s, z - 0.05, 0.14 * s, 0.14 * s, 0.06 * s)
                    bind j = j + 1.0
                }
                sy_ball(c_rgb(255.0, 230.0, 120.0), fx, fy, z - 0.1, 0.12 * s, 0.12 * s, 0.08 * s)
            } else if k == 1.0 {
                bind sq = 1.0 + beat * 0.06
                plat_color(c_rgb(190.0, 140.0, 110.0))
                plat_shape("cylinder", x, y - 1.0 * s, z, 0.22 * s, 1.0 * s, 0.22 * s, 0.0, 0.0, 0.0, 0.0)
                sy_ball(c, x, y - 2.3 * s * sq, z, 1.1 * s * sq, 0.9 * s * sq, 1.0 * s)
                sy_ball(c, x - 0.7 * s, y - 1.9 * s * sq, z + 0.2, 0.7 * s * sq, 0.6 * s * sq, 0.7 * s)
                sy_ball(c, x + 0.75 * s, y - 2.0 * s * sq, z + 0.1, 0.7 * s * sq, 0.6 * s * sq, 0.7 * s)
            } else {
                bind bx = x + sin(t * 0.3 + i) * 1.5
                bind by = y + sin(t * 0.8 + i) * 0.3
                sy_ball(c, bx, by, z, 1.4 * s, 0.8 * s, 0.8 * s)
                sy_ball(c, bx - 1.2 * s, by + 0.2 * s, z, 0.9 * s, 0.6 * s, 0.6 * s)
                sy_ball(c, bx + 1.2 * s, by + 0.2 * s, z, 0.9 * s, 0.6 * s, 0.6 * s)
                sy_ball(sy_c("eye"), bx - 0.35 * s, by - 0.1 * s, z - 0.75 * s, 0.08 * s, 0.13 * s, 0.04)
                sy_ball(sy_c("eye"), bx + 0.35 * s, by - 0.1 * s, z - 0.75 * s, 0.08 * s, 0.13 * s, 0.04)
                sy_ball(sy_c("cheek"), bx - 0.6 * s, by + 0.15 * s, z - 0.7 * s, 0.14 * s, 0.07 * s, 0.03)
                sy_ball(sy_c("cheek"), bx + 0.6 * s, by + 0.15 * s, z - 0.7 * s, 0.14 * s, 0.07 * s, 0.03)
            }
        }
        bind i = i + 1.0
    }
    # egg blocks and springs (bounce when hit)
    bind i = 0.0
    while i < st_llen("sd_x") {
        bind k = st_lget("sd_k", i)
        bind x = st_lget("sd_x", i)
        if k >= 2.0 && abs(x - cx) < 30.0 {
            bind y = st_lget("sd_y", i)
            bind hw = st_lget("sd_hw", i)
            bind hh = st_lget("sd_hh", i)
            bind bump = st_lget("sd_bump", i)
            if k == 2.0 {
                bind lift = sin(bump / 0.25 * 3.14159) * 0.25
                bind c = sel(st_lget("sd_charge", i) > 0.0, c_rgb(255.0, 220.0, 120.0), c_rgb(220.0, 200.0, 180.0))
                sy_box(c, x, y - lift, 0.0, hw, hh, hw, 0.0, 0.0, 0.0)
                sy_draw_egg(x, y - lift, 0.0 - hw - 0.05, 0.9)
            } else {
                bind sq = 1.0 - sin(bump / 0.3 * 3.14159) * 0.4
                plat_color(c_rgb(255.0, 120.0, 140.0))
                plat_shape("cylinder", x, y + hh * 0.5, 0.0, hw, hh * 0.5, hw, 0.0, 0.0, 0.0, 0.0)
                plat_color(c_rgb(255.0, 255.0, 255.0))
                plat_shape("torus", x, y - hh * 0.3 * sq, 0.0, hw * 0.8, 0.12, hw * 0.8, 0.0, 0.0, 0.0, 0.0)
                plat_color(c_rgb(255.0, 120.0, 140.0))
                plat_shape("cylinder", x, y - hh * 0.8 * sq, 0.0, hw * 1.05, 0.12, hw * 1.05, 0.0, 0.0, 0.0, 0.0)
            }
        }
        bind i = i + 1.0
    }
    # fruit
    bind i = 0.0
    while i < st_llen("fr_x") {
        if st_lget("fr_on", i) > 0.5 && abs(st_lget("fr_x", i) - cx) < 26.0 {
            sy_draw_fruit(st_lget("fr_k", i), st_lget("fr_x", i), st_lget("fr_y", i), t, st_lget("fr_ph", i))
            if st_lget("fr_k", i) == st_get("sy_lucky", 2.0) || st_lget("fr_k", i) == 3.0 {
                plat_tm_billboard(0.0 - 1.0, st_lget("fr_x", i), st_lget("fr_y", i), 0.3, 1.8 + sin(t * 4.0 + i) * 0.3, 0.0, c_rgb(255.0, 240.0, 160.0), 0.45, 1.0)
            }
        }
        bind i = i + 1.0
    }
    # puffs
    bind i = 0.0
    while i < st_llen("en_x") {
        if st_lget("en_on", i) > 0.5 && abs(st_lget("en_x", i) - cx) < 26.0 {
            sy_draw_puff(st_lget("en_k", i), st_lget("en_x", i), st_lget("en_y", i), t + st_lget("en_t", i), st_lget("en_vx", i))
        }
        bind i = i + 1.0
    }
    # eggs following Moko along her trail, and eggs in flight
    bind e = 0.0
    while e < st_get("sy_eggs", 0.0) {
        bind hi = max(0.0, 59.0 - (e + 1.0) * 7.0)
        sy_draw_egg(st_lget("hx", hi), st_lget("hy", hi) + 0.25 + sin(t * 8.0 + e) * 0.05, 0.25, 0.9)
        bind e = e + 1.0
    }
    bind i = 0.0
    while i < st_llen("eg_x") {
        sy_draw_egg(st_lget("eg_x", i), st_lget("eg_y", i), 0.0, 1.0)
        bind i = i + 1.0
    }
    sy_draw_moko(t)
    plat_pfx_draw()
    return 0.0
}

# ── HUD ──────────────────────────────────────────────────────────────────────
fn sy_text_c(cx, y, size, c, s) {
    bind w = g_text_w(size, s)
    g_text(cx - w * 0.5 + 2.0, y + 2.0, size, c_rgb(255.0, 255.0, 255.0), s)
    g_text(cx - w * 0.5, y, size, c, s)
    return 0.0
}
fn story_hud(x, y, w, h) {
    if !story_on() { return 0.0 }
    bind s = h / 720.0
    bind t = st_get("game_t", 0.0)
    bind ink = c_rgb(110.0, 64.0, 90.0)
    # the smile flower: eight petals around a smiling sun face
    bind fx = x + 70.0 * s
    bind fy = y + 70.0 * s
    bind pet = st_get("sy_petals", 8.0)
    bind k = 0.0
    while k < 8.0 {
        bind a = k / 8.0 * 6.283 - 1.5708 + sin(t * 2.0) * 0.05
        bind c = c_rgb(255.0, 150.0, 190.0)
        if k >= pet { bind c = c_rgb(225.0, 215.0, 222.0) }
        g_disc(fx + cos(a) * 34.0 * s, fy + sin(a) * 34.0 * s, 15.0 * s, c)
        bind k = k + 1.0
    }
    g_disc(fx, fy, 24.0 * s, c_rgb(255.0, 226.0, 110.0))
    g_disc(fx - 8.0 * s, fy - 4.0 * s, 3.5 * s, ink)
    g_disc(fx + 8.0 * s, fy - 4.0 * s, 3.5 * s, ink)
    g_disc(fx - 13.0 * s, fy + 5.0 * s, 4.0 * s, c_rgb(255.0, 150.0, 160.0))
    g_disc(fx + 13.0 * s, fy + 5.0 * s, 4.0 * s, c_rgb(255.0, 150.0, 160.0))
    if pet > 3.0 {
        g_line(fx - 6.0 * s, fy + 7.0 * s, fx, fy + 11.0 * s, ink)
        g_line(fx, fy + 11.0 * s, fx + 6.0 * s, fy + 7.0 * s, ink)
    } else {
        g_line(fx - 6.0 * s, fy + 11.0 * s, fx, fy + 7.0 * s, ink)
        g_line(fx, fy + 7.0 * s, fx + 6.0 * s, fy + 11.0 * s, ink)
    }
    # eggs
    bind e = 0.0
    while e < 6.0 {
        bind c = sel(e < st_get("sy_eggs", 0.0), c_rgb(255.0, 252.0, 244.0), c_rgb(255.0, 255.0, 255.0))
        bind a = sel(e < st_get("sy_eggs", 0.0), 1.0, 0.25)
        g_disc_a(x + 130.0 * s + e * 26.0 * s, y + 92.0 * s, 9.0 * s, c, a)
        if e < st_get("sy_eggs", 0.0) { g_disc(x + 133.0 * s + e * 26.0 * s, y + 89.0 * s, 2.5 * s, c_rgb(120.0, 214.0, 150.0)) }
        bind e = e + 1.0
    }
    # fruit ribbon: 30 little fruits, filled as Moko eats
    bind goal = st_get("sy_goal", 30.0)
    bind got = st_get("sy_fruits", 0.0)
    bind rx = x + w * 0.5 - 15.0 * 22.0 * s * 0.5
    g_rrect_a(rx - 18.0 * s, y + 14.0 * s, 15.0 * 22.0 * s + 36.0 * s, 66.0 * s, 14.0 * s, c_rgb(255.0, 250.0, 240.0), 0.85)
    bind k = 0.0
    while k < goal {
        bind col = floor(k / 2.0)
        bind row = k - col * 2.0
        bind dx = rx + col * 22.0 * s
        bind dy = y + 32.0 * s + row * 26.0 * s
        if k < got {
            bind fc = list_get([c_rgb(255.0, 100.0, 110.0), c_rgb(255.0, 220.0, 90.0), c_rgb(175.0, 120.0, 235.0), c_rgb(120.0, 210.0, 110.0)], k - floor(k / 4.0) * 4.0)
            g_disc(dx, dy, 8.5 * s, fc)
            g_disc(dx - 2.5 * s, dy - 2.5 * s, 2.5 * s, c_rgb(255.0, 255.0, 255.0))
        } else { g_disc_a(dx, dy, 7.0 * s, c_rgb(220.0, 200.0, 210.0), 0.7) }
        bind k = k + 1.0
    }
    sy_text_c(x + w * 0.5, y + 84.0 * s, 15.0 * s, ink, num_str(min(got, goal)) + " / " + num_str(goal) + "  fruits")
    # lucky fruit
    g_rrect_a(x + w - 200.0 * s, y + 14.0 * s, 180.0 * s, 44.0 * s, 12.0 * s, c_rgb(255.0, 250.0, 240.0), 0.85)
    g_text(x + w - 186.0 * s, y + 26.0 * s, 15.0 * s, ink, "Lucky fruit: " + sy_fruit_name(st_get("sy_lucky", 2.0)))
    # popups
    bind i = 0.0
    while i < st_llen("pp_x") {
        bind pt = st_lget("pp_t", i)
        bind p = cam_project(st_lget("pp_x", i), st_lget("pp_y", i) - pt * 1.2, 0.0)
        if cam_visible(list_get(p, 2)) { sy_text_c(list_get(p, 0), list_get(p, 1), 18.0 * s * (1.0 + (1.2 - pt) * 0.15), st_lget("pp_c", i), st_lget("pp_txt", i)) }
        bind i = i + 1.0
    }
    # cards
    bind st = st_get("sy_state", "")
    bind stt = st_get("sy_state_t", 0.0)
    if st == "intro" {
        bind a = clampf(stt * 2.0, 0.0, 1.0)
        g_rrect_a(x + w * 0.5 - 300.0 * s, y + h * 0.34, 600.0 * s, 150.0 * s, 26.0 * s, c_rgb(255.0, 250.0, 242.0), 0.92 * a)
        sy_text_c(x + w * 0.5, y + h * 0.34 + 20.0 * s, 18.0 * s, c_rgb(232.0, 118.0, 158.0), "Page " + num_str(st_get("sy_page", 1.0)))
        sy_text_c(x + w * 0.5, y + h * 0.34 + 46.0 * s, 38.0 * s, ink, st_get("sy_title", ""))
        sy_text_c(x + w * 0.5, y + h * 0.34 + 104.0 * s, 16.0 * s, c_rgb(160.0, 110.0, 140.0), "Eat " + num_str(goal) + " fruits!   The lucky " + sy_fruit_name(st_get("sy_lucky", 2.0)) + " counts three.")
    }
    if st == "clear" {
        bind pop = 1.0 + max(0.0, 0.4 - stt) * 1.5
        sy_text_c(x + w * 0.5, y + h * 0.36, 64.0 * s * pop, c_rgb(255.0, 120.0, 170.0), "Yummy!")
        sy_text_c(x + w * 0.5, y + h * 0.36 + 80.0 * s, 26.0 * s, ink, "Page clear!")
    }
    if st == "oops" {
        g_rect_a(x, y, w, h, c_rgb(255.0, 240.0, 245.0), clampf(stt, 0.0, 0.7))
        sy_text_c(x + w * 0.5, y + h * 0.4, 52.0 * s, c_rgb(232.0, 118.0, 158.0), "Oops!")
        sy_text_c(x + w * 0.5, y + h * 0.4 + 70.0 * s, 20.0 * s, ink, "Let's try that page again")
    }
    if st_get("sy_paused", 0.0) > 0.5 {
        g_rect_a(x, y, w, h, c_rgb(255.0, 245.0, 250.0), 0.7)
        sy_text_c(x + w * 0.5, y + h * 0.4, 48.0 * s, ink, "Paused")
        sy_text_c(x + w * 0.5, y + h * 0.4 + 64.0 * s, 16.0 * s, c_rgb(160.0, 110.0, 140.0), "P resume     Q back to the book")
    }
    return 0.0
}

← Back to index