Download unify/game/assets/scenes/level.lua from Snapkitty/repoverse: direct link, hf CLI and curl.
- Browser
- Download file 12.5 kB
-
https://huggingface.co/Snapkitty/repoverse/resolve/main/unify/game/assets/scenes/level.lua
- Command line
-
hf download hf://Snapkitty/repoverse/unify/game/assets/scenes/level.lua
-
curl -L -o level.lua https://huggingface.co/Snapkitty/repoverse/resolve/main/unify/game/assets/scenes/level.lua
12.5 kB
| -- 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 | |