File size: 12,512 Bytes
3ce1a40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
-- Level scene: the platformer. All gameplay lives here; UNIFY owns the lifecycle and calls
-- init/load/update/render/on_contact/on_timer/on_saved/on_loaded at the right scheduler stage.
-- Everything that must survive a save state lives in self.state.
local L = {}

local RUN_SPEED, GROUND_ACCEL, AIR_ACCEL = 7.5, 70, 35
local JUMP_SPEED = 13.5
local COYOTE_TICKS, BUFFER_TICKS = 6, 6   -- grace windows, in simulation ticks
local PARTICLES = 64

local function approach(v, target, delta)
    if v < target then return math.min(v + delta, target) end
    return math.max(v - delta, target)
end

function L:init()
    input:bind("jump", "space", "w", "up", "pad_a")
    input:bind_axis("move", "left", "right", "pad_left_x")
    input:bind_axis("move_alt", "a", "d")
    input:bind("save", "f5")
    input:bind("load", "f9")
    input:bind("menu", "escape")

    local tex = "textures/sprites"
    animation.define{ name = "player_idle", texture = tex, mode = "loop", frames = { { "player_idle_0", 0.5 }, { "player_idle_1", 0.5 } } }
    animation.define{ name = "player_run", texture = tex, mode = "loop",
        frames = { { "player_run_0", 0.08 }, { "player_run_1", 0.08 }, { "player_run_2", 0.08 }, { "player_run_3", 0.08 } } }
    -- Squash and stretch are keyframed tracks blended on top of the sprite frames.
    animation.define{ name = "player_jump", texture = tex, mode = "once", frames = { { "player_jump", 0.4 } },
        tracks = { scale_y = { { 0, 1.2 }, { 0.15, 1 } }, scale_x = { { 0, 0.8 }, { 0.15, 1 } } } }
    animation.define{ name = "player_fall", texture = tex, mode = "loop", frames = { { "player_fall", 1 } } }
    animation.define{ name = "player_land", texture = tex, mode = "once", frames = { { "player_idle_0", 0.12 } },
        tracks = { scale_y = { { 0, 0.75 }, { 0.12, 1 } }, scale_x = { { 0, 1.25 }, { 0.12, 1 } } } }
    animation.define{ name = "coin_spin", texture = tex, mode = "loop",
        frames = { { "coin_0", 0.1 }, { "coin_1", 0.1 }, { "coin_2", 0.1 }, { "coin_3", 0.1 } } }
    animation.define{ name = "flag_wave", texture = tex, mode = "pingpong", frames = { { "flag_0", 0.3 }, { "flag_1", 0.3 } } }
end

function L:load()
    physics:set_gravity(0, -30)
    local s = {
        score = 0, coins = 0, coins_total = 0, jumps = 0, landings = 0, deaths = 0, collisions = 0,
        time = 0, won = false, grounded = false, coyote = 0, fall_speed = 0,
        msg = "", msg_timer = 0, kernel_runs = 0, particles_alive = 0,
        spawn = { 2, 3 }, platforms = {}, clouds = {}, sparks = {}, cam = { 10, 6 },
    }
    self.state = s

    -- Build the level from the tile map. Horizontal runs of ground become one static body each,
    -- so the player never catches on seams between tiles.
    local m = map.load("maps/level1")
    for r = 1, m.height do
        local row, y = m.rows[r], m.height - r
        local run_start = nil
        for x = 0, m.width do
            local ch = row:sub(x + 1, x + 1)
            if ch == "#" then
                run_start = run_start or x
                local above = r > 1 and m.rows[r - 1]:sub(x + 1, x + 1) or " "
                world:spawn(above == "#" and "prefabs/tile" or "prefabs/tile_top", x + 0.5, y + 0.5)
            else
                if run_start then
                    local w = x - run_start
                    physics:add_static_box(run_start + w / 2, y + 0.5, w, 1)
                    run_start = nil
                end
                if ch == "P" then s.spawn = { x + 0.5, y + 0.8 }
                elseif ch == "C" then world:spawn("prefabs/coin", x + 0.5, y + 0.5); s.coins_total = s.coins_total + 1
                elseif ch == "B" then world:spawn("prefabs/crate", x + 0.5, y + 0.45)
                elseif ch == "O" then world:spawn("prefabs/ball", x + 0.5, y + 0.5)
                elseif ch == "F" then world:spawn("prefabs/flag", x + 0.5, y + 1)
                elseif ch == "M" then
                    s.platforms[#s.platforms + 1] = { e = world:spawn("prefabs/platform", x + 1.5, y + 0.5), phase = #s.platforms }
                end
            end
        end
    end
    s.player = world:spawn("prefabs/player", s.spawn[1], s.spawn[2])
    s.sky = world:spawn("prefabs/sky", 0, 0)
    for i = 1, 6 do s.clouds[i] = { e = world:spawn("prefabs/cloud", i * 13, 9), x = i * 13, y = 8 + (i % 3) * 1.3 } end

    -- Sparkle particles are integrated by a WASM kernel compiled from CUDA-like source.
    s.kernel = kernel.load(kernel.compile("kernels/particles"))
    s.px, s.py = buffer.create(PARTICLES), buffer.create(PARTICLES)
    s.vx, s.vy = buffer.create(PARTICLES), buffer.create(PARTICLES)
    s.life = buffer.create(PARTICLES)
    for i = 1, PARTICLES do
        local e = world:spawn("prefabs/spark", -100, -100)
        e:set_visible(false)
        s.sparks[i] = e
    end

    audio:stream("music/theme", 0.45, true, 1.0)
    s.cam = { s.spawn[1], 6 }
    camera:set(s.cam[1], s.cam[2], 32)
end

function L:burst(x, y, count)
    local s = self.state
    local life = buffer.download(s.life)
    local px, py = buffer.download(s.px), buffer.download(s.py)
    local vx, vy = buffer.download(s.vx), buffer.download(s.vy)
    local spawned = 0
    for i = 1, PARTICLES do
        if spawned >= count then break end
        if life[i] <= 0 then
            local angle = math.random() * 2 * math.pi
            local speed = 2 + math.random() * 4
            px[i], py[i] = x, y
            vx[i], vy[i] = math.cos(angle) * speed, math.sin(angle) * speed + 3
            life[i] = 0.6 + math.random() * 0.4
            spawned = spawned + 1
        end
    end
    buffer.upload(s.px, px); buffer.upload(s.py, py)
    buffer.upload(s.vx, vx); buffer.upload(s.vy, vy)
    buffer.upload(s.life, life)
    s.particles_alive = s.particles_alive + spawned
end

function L:update_particles(dt)
    local s = self.state
    if s.particles_alive <= 0 then return end
    kernel.dispatch(s.kernel, 1, PARTICLES, s.px, s.py, s.vx, s.vy, s.life, PARTICLES, dt, -20.0)
    s.kernel_runs = s.kernel_runs + 1
    local px, py, life = buffer.download(s.px), buffer.download(s.py), buffer.download(s.life)
    local alive = 0
    for i = 1, PARTICLES do
        local e = s.sparks[i]
        if life[i] > 0 then
            alive = alive + 1
            e:set_position(px[i], py[i], true)
            e:set_visible(true)
        else
            e:set_visible(false)
        end
    end
    s.particles_alive = alive
end

function L:respawn()
    local s = self.state
    s.player:set_position(s.spawn[1], s.spawn[2], true)
    s.player:set_velocity(0, 0)
    s.deaths = s.deaths + 1
    s.msg, s.msg_timer = "OOPS", 45
end

function L:update_player(dt)
    local s, p = self.state, self.state.player
    local x, y = p:position()
    local vx, vy = p:velocity()

    -- Ground probe: two short rays below the feet, ignoring the player's own body.
    local grounded = false
    for _, ox in ipairs({ -0.33, 0.33 }) do
        local hit, _, _, _, ny = physics:raycast(x + ox, y - 0.6, x + ox, y - 0.85, 65535, p)
        if hit and ny > 0.5 then grounded = true end
    end
    if grounded and not s.grounded and s.fall_speed > 6 then
        audio:play("audio/land", 0.6)
        s.landings = s.landings + 1
        p:set_animation("player_land", 0, true)
    end
    if grounded then s.coyote = COYOTE_TICKS else s.coyote = math.max(0, s.coyote - 1) end
    if grounded then s.fall_speed = 0 else s.fall_speed = math.max(s.fall_speed, -vy) end
    s.grounded = grounded

    local move = math.max(-1, math.min(1, input:axis("move") + input:axis("move_alt")))
    vx = approach(vx, move * RUN_SPEED, (grounded and GROUND_ACCEL or AIR_ACCEL) * dt)

    -- Buffered jump + coyote time: a press up to BUFFER_TICKS before landing, or a jump up to
    -- COYOTE_TICKS after walking off a ledge, still counts.
    if input:buffered("jump", BUFFER_TICKS) and s.coyote > 0 then
        input:consume("jump")
        vy = JUMP_SPEED
        s.coyote = 0
        grounded = false  -- airborne from this tick on: don't let the ground logic below override the jump
        s.jumps = s.jumps + 1
        audio:play("audio/jump", 0.7, 0, 0.95 + math.random() * 0.1)
        p:set_animation("player_jump", 0, true)
    elseif input:is_released("jump") and vy > 0 then
        vy = vy * 0.5  -- short hop when the button is released early
    end
    p:set_velocity(vx, vy)

    local anim, finished = p:animation()
    if grounded then
        if anim ~= "player_land" or finished then
            p:set_animation(math.abs(vx) > 0.5 and "player_run" or "player_idle", 0.05)
        end
    elseif (anim == "player_jump" and finished) or (anim ~= "player_jump" and vy < -1) then
        p:set_animation("player_fall")
    end
    if move ~= 0 then p:set_flip(move < 0) end
    if y < -6 then self:respawn() end
end

function L:update_platforms()
    local s = self.state
    for _, pl in ipairs(s.platforms) do
        pl.e:set_velocity(2.5 * math.cos(s.time * 0.9 + pl.phase), 0)
    end
end

function L:update_camera()
    local s = self.state
    local x, y = s.player:position()
    s.cam[1] = s.cam[1] + (x - s.cam[1]) * 0.12
    s.cam[2] = s.cam[2] + (math.max(y + 1.5, 5.6) - s.cam[2]) * 0.08
    s.cam[1] = math.max(10, math.min(64, s.cam[1]))
    camera:set(s.cam[1], s.cam[2])
    s.sky:set_position(s.cam[1], s.cam[2], true)
    for i, c in ipairs(s.clouds) do
        local cx = c.x - s.cam[1] * 0.4
        cx = cx - math.floor(cx / 78) * 78 + s.cam[1] - 30  -- wrap across the view with parallax
        c.e:set_position(cx, c.y + s.cam[2] * 0.3, true)
    end
end

function L:update(dt)
    local s = self.state
    s.time = s.time + dt
    if s.msg_timer > 0 then s.msg_timer = s.msg_timer - 1 end
    if input:is_pressed("save") then save_state(); save_state("slot1") end
    if input:is_pressed("load") then load_state() end
    if input:is_pressed("menu") then scene.transition("title"); return end
    self:update_player(dt)
    self:update_platforms()
    self:update_particles(dt)
    self:update_camera()
end

function L:on_contact(a, b, kind, nx, ny, impulse)
    local s = self.state
    if a == nil or b == nil then return end
    local other = (a == s.player and b) or (b == s.player and a) or nil
    if kind == "trigger_begin" and other then
        local name = other:name()
        if name == "coin" then
            local x, y = other:position()
            other:destroy()
            s.coins, s.score = s.coins + 1, s.score + 100
            audio:play("audio/coin", 0.7)
            self:burst(x, y, 16)
        elseif name == "flag" and not s.won then
            local x, y = other:position()
            s.won, s.score = true, s.score + 1000
            audio:play("audio/win", 0.8)
            self:burst(x, y + 0.5, 40)
            timer.after(4, "back_to_title")
        end
    elseif kind == "begin" then
        s.collisions = s.collisions + 1
        local ball = (a:name() == "ball") or (b:name() == "ball")
        if ball and impulse > 1.0 then audio:play("audio/bounce", 0.5, 0, 0.9 + math.random() * 0.2) end
    end
end

function L:on_timer(name)
    if name == "back_to_title" then scene.transition("title") end
end

function L:on_saved(ok, slot)
    if slot == "" then self.state.msg, self.state.msg_timer = ok and "STATE SAVED" or "SAVE FAILED", 90 end
end

function L:on_loaded(ok, slot)
    self.state.msg, self.state.msg_timer = ok and "STATE RESTORED" or "NO SAVE", 90
end

local function centered(text, y, scale, r, g, b)
    local w = ui:screen()
    ui:text(text, (w - ui:width(text, scale)) / 2, y, scale, r, g, b)
end

function L:render()
    local s = self.state
    local w, h = ui:screen()
    ui:rect(0, 0, w, 22, 0, 0, 0, 150)
    ui:text("SCORE " .. s.score, 8, 5, 2)
    ui:text("COINS " .. s.coins .. "/" .. s.coins_total, 160, 5, 2, 255, 220, 90)
    ui:text("JUMPS " .. s.jumps, 330, 5, 2, 150, 210, 255)
    ui:text(string.format("%.1fS", s.time), 560, 5, 2, 200, 200, 200)
    local st = engine.stats()
    ui:rect(0, h - 30, w, 30, 0, 0, 0, 150)
    ui:text("ARROWS/AD MOVE  SPACE JUMP  F5 SAVE  F9 LOAD  ESC MENU", 8, h - 26, 1, 210, 210, 210)
    ui:text(string.format("ENT %d  BATCHES %d  AWAKE %d  LUA %dK  WASM RUNS %d  PARTICLES %d",
        st.entities, st.batches, st.awake_bodies, st.lua_bytes // 1024, s.kernel_runs, s.particles_alive), 8, h - 12, 1, 150, 170, 200)
    if s.msg_timer > 0 then centered(s.msg, 80, 3, 255, 255, 160) end
    if s.won then centered("LEVEL COMPLETE", 130, 4, 140, 255, 160) end
end

return L