repoverse / unify /game /assets /scenes /level.lua
SNAPKITTYWEST's picture
October 2026 main drop: mirror from GitHub
3ce1a40 verified
Raw History Blame Contribute Delete
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