Playable Example
Space Shooter
space_shooter.lua ยท 480 x 640
runtime 480 x 640
console stdout / stderr
space_shooter.game launch config
entry : space_shooter.lua
width : 480
height : 640
window_title : "Space Shooter"
background : 0xff000000
space_shooter.lua source
local soluna = require "soluna"
local app = require "soluna.app"
local file = require "soluna.file"
local matquad = require "soluna.material.quad"
local mattext = require "soluna.material.text"
local font = require "soluna.font"
local util = require "utils"
math.randomseed(os.time())
local args = ...
local batch = args.batch
local W = 480
local H = 640
local MAX_STARS = 80
local MAX_BULLETS = 30
local MAX_ENEMIES = 20
local MAX_EXPLOSIONS = 15
local MAX_ENEMY_BULLETS = 20
local KEY_ESCAPE = 256
local KEY_LEFT = 263
local KEY_RIGHT = 262
local KEY_UP = 265
local KEY_DOWN = 264
local KEY_SPACE = 32
local KEY_R = 82
local KEYSTATE_RELEASE = 0
local KEYSTATE_PRESS = 1
local COLOR_WHITE = 0xffffffff
local COLOR_CYAN = 0xff00ffff
local COLOR_GRAY = 0xff909090
local COLOR_DARK_GRAY = 0xff505050
local COLOR_RED = 0xffff3030
local COLOR_ORANGE = 0xffff9000
local COLOR_YELLOW = 0xffffff40
local COLOR_GREEN = 0xff30ff30
local COLOR_MAGENTA = 0xffff40ff
local function clamp(v, min_v, max_v)
if v < min_v then
return min_v
end
if v > max_v then
return max_v
end
return v
end
local function rect_overlap(ax, ay, aw, ah, bx, by, bw, bh)
return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah
end
local function rgba(color)
local a = color >> 24 & 0xff
local r = color >> 16 & 0xff
local g = color >> 8 & 0xff
local b = color & 0xff
return string.pack("BBBB", r, g, b, a)
end
local function create_canvas(width, height)
local pixels = {}
local clear = rgba(0)
for i = 1, width * height do
pixels[i] = clear
end
local canvas = {}
function canvas.set_pixel(x, y, color)
if x < 0 or x >= width or y < 0 or y >= height then
return
end
pixels[y * width + x + 1] = rgba(color)
end
function canvas.to_content()
return table.concat(pixels)
end
return canvas
end
local function create_player_content()
local canvas = create_canvas(24, 24)
for y = 4, 19 do
for x = 9, 14 do
canvas.set_pixel(x, y, COLOR_CYAN)
end
end
for x = 10, 13 do
canvas.set_pixel(x, 2, COLOR_WHITE)
canvas.set_pixel(x, 3, COLOR_WHITE)
end
canvas.set_pixel(11, 0, COLOR_WHITE)
canvas.set_pixel(12, 0, COLOR_WHITE)
canvas.set_pixel(11, 1, COLOR_WHITE)
canvas.set_pixel(12, 1, COLOR_WHITE)
for x = 2, 8 do
for y = 13, 16 do
canvas.set_pixel(x, y, COLOR_GRAY)
end
end
for x = 15, 21 do
for y = 13, 16 do
canvas.set_pixel(x, y, COLOR_GRAY)
end
end
canvas.set_pixel(11, 20, COLOR_ORANGE)
canvas.set_pixel(12, 20, COLOR_ORANGE)
canvas.set_pixel(11, 21, COLOR_YELLOW)
canvas.set_pixel(12, 21, COLOR_YELLOW)
return canvas:to_content()
end
local function create_enemy_content(body_color)
local canvas = create_canvas(20, 20)
for y = 2, 13 do
local half = 14 - y
local cx = 10
for x = cx - half, cx + half - 1 do
canvas.set_pixel(x, y, body_color)
end
end
for y = 3, 7 do
canvas.set_pixel(1, y, COLOR_DARK_GRAY)
canvas.set_pixel(18, y, COLOR_DARK_GRAY)
end
canvas.set_pixel(9, 5, COLOR_YELLOW)
canvas.set_pixel(10, 5, COLOR_YELLOW)
canvas.set_pixel(9, 6, COLOR_YELLOW)
canvas.set_pixel(10, 6, COLOR_YELLOW)
return canvas:to_content()
end
local function create_explosion_content(radius, outer_color, inner_color)
local size = radius * 2 + 6
local canvas = create_canvas(size, size)
local cx = size // 2
local cy = size // 2
local inner_radius = math.max(radius - 3, 0)
local outer_sq = radius * radius
local inner_sq = inner_radius * inner_radius
for y = 0, size - 1 do
for x = 0, size - 1 do
local dx = x - cx
local dy = y - cy
local dist_sq = dx * dx + dy * dy
if dist_sq <= outer_sq then
if dist_sq <= inner_sq then
canvas.set_pixel(x, y, inner_color)
else
canvas.set_pixel(x, y, outer_color)
end
end
end
end
return canvas:to_content(), size, size
end
local function build_sprite_assets()
local preload = {
{
filename = "@player",
content = create_player_content(),
w = 24,
h = 24,
},
{
filename = "@enemy_red",
content = create_enemy_content(COLOR_RED),
w = 20,
h = 20,
},
{
filename = "@enemy_magenta",
content = create_enemy_content(COLOR_MAGENTA),
w = 20,
h = 20,
},
}
local bundle = {
{ name = "player", filename = "@player" },
{ name = "enemy_red", filename = "@enemy_red" },
{ name = "enemy_magenta", filename = "@enemy_magenta" },
}
local explosion_defs = {
{ name = "exp_1", radius = 5, outer = COLOR_WHITE, inner = COLOR_RED },
{ name = "exp_2", radius = 8, outer = COLOR_WHITE, inner = COLOR_RED },
{ name = "exp_3", radius = 11, outer = COLOR_YELLOW, inner = COLOR_RED },
{ name = "exp_4", radius = 14, outer = COLOR_ORANGE, inner = COLOR_RED },
{ name = "exp_5", radius = 17, outer = COLOR_ORANGE, inner = COLOR_RED },
}
for _, def in ipairs(explosion_defs) do
local content, width, height = create_explosion_content(def.radius, def.outer, def.inner)
local filename = "@" .. def.name
preload[#preload + 1] = {
filename = filename,
content = content,
w = width,
h = height,
}
bundle[#bundle + 1] = {
name = def.name,
filename = filename,
x = -(width // 2),
y = -(height // 2),
}
end
soluna.preload(preload)
return soluna.load_sprites(bundle)
end
soluna.set_window_title "Space Shooter"
local sprites = build_sprite_assets()
local fontid = util.font_init(soluna, font, file, {
error_message = "No available system font for space_shooter",
})
local fontcobj = font.cobj()
local title_block = mattext.block(fontcobj, fontid, 32, COLOR_RED, "LT")
local body_block = mattext.block(fontcobj, fontid, 16, COLOR_WHITE, "LT")
local hint_block = mattext.block(fontcobj, fontid, 14, COLOR_DARK_GRAY, "LT")
local score_block = mattext.block(fontcobj, fontid, 16, COLOR_WHITE, "LT")
local lives_block = mattext.block(fontcobj, fontid, 16, COLOR_GREEN, "LT")
local level_block = mattext.block(fontcobj, fontid, 16, COLOR_YELLOW, "LT")
local label = util.label_cache()
local quad = util.quad_cache(matquad)
local view = util.fixed_view(args, W, H)
local stars = {}
for i = 1, MAX_STARS do
local speed = math.random(1, 4)
local brightness = math.min(255, 80 + speed * 40)
stars[i] = {
x = math.random(0, W - 1),
y = math.random(0, H - 1),
speed = speed,
color = 0xff000000 | brightness << 16 | brightness << 8 | brightness,
}
end
local bullets = {}
for i = 1, MAX_BULLETS do
bullets[i] = { active = false, x = 0, y = 0 }
end
local enemies = {}
for i = 1, MAX_ENEMIES do
enemies[i] = { active = false, x = 0, y = 0, vx = 0, vy = 0, hp = 0, type = 0 }
end
local enemy_bullets = {}
for i = 1, MAX_ENEMY_BULLETS do
enemy_bullets[i] = { active = false, x = 0, y = 0, vy = 0 }
end
local explosions = {}
for i = 1, MAX_EXPLOSIONS do
explosions[i] = { active = false, x = 0, y = 0, timer = 0 }
end
local keys_down = {}
local player_x = W / 2 - 12
local player_y = H - 60
local shoot_timer = 0
local spawn_timer = 0
local score = 0
local lives = 3
local level = 1
local kill_count = 0
local game_over = false
local invincible = 0
local function reset_game()
player_x = W / 2 - 12
player_y = H - 60
shoot_timer = 0
spawn_timer = 0
score = 0
lives = 3
level = 1
kill_count = 0
game_over = false
invincible = 0
for _, bullet in ipairs(bullets) do
bullet.active = false
end
for _, enemy in ipairs(enemies) do
enemy.active = false
end
for _, bullet in ipairs(enemy_bullets) do
bullet.active = false
end
for _, explosion in ipairs(explosions) do
explosion.active = false
end
end
local function spawn_player_bullet()
for _, bullet in ipairs(bullets) do
if not bullet.active then
bullet.active = true
bullet.x = player_x + 10
bullet.y = player_y - 5
return
end
end
end
local function spawn_enemy()
for _, enemy in ipairs(enemies) do
if not enemy.active then
local enemy_type = math.random(0, 1)
enemy.active = true
enemy.x = math.random(10, W - 30)
enemy.y = math.random(-80, -20)
enemy.vx = math.random(-2, 2)
enemy.vy = math.random(1, 2 + level // 2)
enemy.type = enemy_type
enemy.hp = enemy_type == 0 and 1 or 2
return
end
end
end
local function spawn_enemy_bullet(enemy)
for _, bullet in ipairs(enemy_bullets) do
if not bullet.active then
bullet.active = true
bullet.x = enemy.x + 10
bullet.y = enemy.y + 20
bullet.vy = 4 + level * 0.5
return
end
end
end
local function spawn_explosion(x, y)
for _, explosion in ipairs(explosions) do
if not explosion.active then
explosion.active = true
explosion.x = x
explosion.y = y
explosion.timer = 15
return
end
end
end
local function apply_player_hit()
lives = lives - 1
invincible = 90
if lives <= 0 then
game_over = true
end
end
local function update_stars()
for _, star in ipairs(stars) do
star.y = star.y + star.speed
if star.y > H then
star.y = 0
star.x = math.random(0, W - 1)
end
end
end
local function update_game()
if game_over then
return
end
local speed = 5
if keys_down[KEY_LEFT] then
player_x = player_x - speed
end
if keys_down[KEY_RIGHT] then
player_x = player_x + speed
end
if keys_down[KEY_UP] then
player_y = player_y - speed
end
if keys_down[KEY_DOWN] then
player_y = player_y + speed
end
player_x = clamp(player_x, 0, W - 24)
player_y = clamp(player_y, 0, H - 24)
if keys_down[KEY_SPACE] then
shoot_timer = shoot_timer + 1
if shoot_timer >= 6 then
shoot_timer = 0
spawn_player_bullet()
end
else
shoot_timer = 5
end
for _, bullet in ipairs(bullets) do
if bullet.active then
bullet.y = bullet.y - 10
if bullet.y < -10 then
bullet.active = false
end
end
end
spawn_timer = spawn_timer + 1
local rate = math.max(15, 50 - level * 5)
if spawn_timer >= rate then
spawn_timer = 0
spawn_enemy()
end
for _, enemy in ipairs(enemies) do
if enemy.active then
enemy.x = enemy.x + enemy.vx
enemy.y = enemy.y + enemy.vy
if enemy.x < 0 or enemy.x > W - 20 then
enemy.vx = -enemy.vx
enemy.x = clamp(enemy.x, 0, W - 20)
end
if enemy.y > H + 20 then
enemy.active = false
elseif math.random(0, 200) < 1 + level then
spawn_enemy_bullet(enemy)
end
end
end
for _, bullet in ipairs(enemy_bullets) do
if bullet.active then
bullet.y = bullet.y + bullet.vy
if bullet.y > H + 10 then
bullet.active = false
end
end
end
for _, bullet in ipairs(bullets) do
if bullet.active then
for _, enemy in ipairs(enemies) do
if enemy.active and rect_overlap(bullet.x - 1, bullet.y - 4, 4, 8, enemy.x, enemy.y, 20, 20) then
bullet.active = false
enemy.hp = enemy.hp - 1
if enemy.hp <= 0 then
enemy.active = false
score = score + (enemy.type + 1) * 100
kill_count = kill_count + 1
if kill_count >= 10 + level * 5 then
level = level + 1
kill_count = 0
end
spawn_explosion(enemy.x + 10, enemy.y + 10)
end
break
end
end
end
end
if invincible > 0 then
invincible = invincible - 1
else
for _, bullet in ipairs(enemy_bullets) do
if bullet.active and rect_overlap(bullet.x - 2, bullet.y - 2, 4, 8, player_x + 4, player_y + 2, 16, 20) then
bullet.active = false
apply_player_hit()
break
end
end
if invincible == 0 then
for _, enemy in ipairs(enemies) do
if enemy.active and rect_overlap(enemy.x, enemy.y, 20, 20, player_x + 2, player_y + 2, 20, 20) then
enemy.active = false
apply_player_hit()
break
end
end
end
end
for _, explosion in ipairs(explosions) do
if explosion.active then
explosion.timer = explosion.timer - 1
if explosion.timer <= 0 then
explosion.active = false
end
end
end
end
local function draw_stars()
for _, star in ipairs(stars) do
batch:add(quad { width = 1, height = 1, color = star.color }, star.x, star.y)
end
end
local function draw_player_bullets()
for _, bullet in ipairs(bullets) do
if bullet.active then
batch:add(quad { width = 3, height = 8, color = COLOR_YELLOW }, bullet.x, bullet.y - 4)
end
end
end
local function draw_enemy_bullets()
for _, bullet in ipairs(enemy_bullets) do
if bullet.active then
batch:add(quad { width = 3, height = 6, color = COLOR_RED }, bullet.x - 1, bullet.y)
end
end
end
local function draw_enemies()
for _, enemy in ipairs(enemies) do
if enemy.active then
local sprite = enemy.type == 0 and sprites.enemy_red or sprites.enemy_magenta
batch:add(sprite, enemy.x, enemy.y)
end
end
end
local function draw_explosions()
for _, explosion in ipairs(explosions) do
if explosion.active then
local index = clamp(6 - math.ceil(explosion.timer / 3), 1, 5)
batch:add(sprites["exp_" .. index], explosion.x, explosion.y)
end
end
end
local function draw_player()
if invincible == 0 or (invincible // 4) % 2 == 0 then
batch:add(sprites.player, player_x, player_y)
end
end
local function draw_hud()
batch:add(label { block = score_block, text = string.format("SCORE: %d", score), width = 160, height = 20 }, 10, 10)
batch:add(label { block = lives_block, text = string.format("LIVES: %d", lives), width = 100, height = 20 }, W - 100,
10)
batch:add(label { block = level_block, text = string.format("LV.%d", level), width = 60, height = 20 }, W / 2 - 30,
10)
batch:add(label { block = hint_block, text = "Arrows:Move Space:Shoot", width = 240, height = 20 }, 10, H - 20)
end
local function draw_game_over()
if not game_over then
return
end
local panel_x = W / 2 - 120
local panel_y = H / 2 - 50
batch:add(quad { width = 240, height = 100, color = COLOR_DARK_GRAY }, panel_x, panel_y)
batch:add(quad { width = 240, height = 2, color = COLOR_WHITE }, panel_x, panel_y)
batch:add(quad { width = 240, height = 2, color = COLOR_WHITE }, panel_x, panel_y + 98)
batch:add(quad { width = 2, height = 100, color = COLOR_WHITE }, panel_x, panel_y)
batch:add(quad { width = 2, height = 100, color = COLOR_WHITE }, panel_x + 238, panel_y)
batch:add(label { block = title_block, text = "GAME OVER", width = 160, height = 40 }, W / 2 - 65, H / 2 - 40)
batch:add(label { block = body_block, text = string.format("Final Score: %d", score), width = 140, height = 20 },
W / 2 - 55,
H / 2)
batch:add(label { block = body_block, text = string.format("Level: %d", level), width = 100, height = 20 },
W / 2 - 45,
H / 2 + 18)
batch:add(label { block = level_block, text = "R to restart", width = 100, height = 20 }, W / 2 - 50, H / 2 + 36)
end
local callback = {}
function callback.frame()
view.begin(batch)
update_stars()
update_game()
draw_stars()
draw_player_bullets()
draw_enemy_bullets()
draw_enemies()
draw_explosions()
draw_player()
draw_hud()
draw_game_over()
view.finish(batch)
end
function callback.key(keycode, state)
if state == KEYSTATE_RELEASE then
keys_down[keycode] = false
return
end
keys_down[keycode] = true
if state == KEYSTATE_PRESS then
if keycode == KEY_ESCAPE then
app.quit()
elseif keycode == KEY_R and game_over then
reset_game()
end
end
end
function callback.window_resize(w, h)
view.resize(w, h)
player_x = clamp(player_x, 0, W - 24)
player_y = clamp(player_y, 0, H - 24)
end
return callback