Playable Example
Geometry Wars
geometry_wars.lua ยท 800 x 600
runtime 800 x 600
console stdout / stderr
geometry_wars.game launch config
entry : geometry_wars.lua
width : 800
height : 600
window_title : "Geometry Wars"
background : 0xff000000
high_dpi : true
geometry_wars.lua source
local soluna = require "soluna"
local app = require "soluna.app"
local ltask = require "ltask"
local matquad = require "soluna.material.quad"
local matmask = require "soluna.material.mask"
local bitmapfont = require "font"
local flow = require "flow"
local persist = require "persist"
local util = require "utils"
math.randomseed(os.time())
soluna.load_sounds "asset/geometry_wars/sounds.dl"
local args = ...
local batch = assert(args.batch)
local W = 800
local H = 600
local KEY = {
ESCAPE = 256,
RELEASE = 0,
PRESS = 1,
LEFT = 263,
RIGHT = 262,
UP = 265,
DOWN = 264,
A = 65,
D = 68,
W = 87,
S = 83,
ENTER = 257,
SPACE = 32,
LEADERBOARD = 76,
DEBUG_NUKE = 294,
DEBUG_JACK = 295,
DEBUG_BLACK_HOLE = 296,
}
local COLOR_WHITE = 0xffffffff
local COLOR_BLACK = 0xff000000
local COLOR_CYAN = 0xff00ffff
local COLOR_ORANGE = 0xffff9a30
local COLOR_GREEN = 0xff44ff72
local COLOR_PURPLE = 0xffb24cff
local COLOR_YELLOW = 0xffffff52
local COLOR_BLUE = 0xff61b8ff
local COLOR_RED = 0xffff554a
local COLOR_GOLD = 0xffffd760
local COLOR_SKY_BLUE = 0xff79c8ff
local function argb(a, r, g, b)
return (a << 24) | (r << 16) | (g << 8) | b
end
local function unpack_argb(color)
local a = color >> 24 & 0xff
local r = color >> 16 & 0xff
local g = color >> 8 & 0xff
local b = color & 0xff
return a, r, g, b
end
local function rgba_bytes(color)
local a, r, g, b = unpack_argb(color)
return string.pack("BBBB", r, g, b, a)
end
local function with_alpha(color, alpha)
return (color & 0x00ffffff) | (alpha << 24)
end
local function scale_alpha(color, factor)
local a = color >> 24 & 0xff
local scaled = math.floor(a * factor + 0.5)
if scaled < 0 then
scaled = 0
elseif scaled > 0xff then
scaled = 0xff
end
return with_alpha(color, scaled)
end
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 blend_argb(dst, src)
local sa, sr, sg, sb = unpack_argb(src)
if sa == 0 then
return dst
end
if sa == 0xff then
return src
end
local da, dr, dg, db = unpack_argb(dst)
local src_alpha = sa / 255.0
local dst_alpha = da / 255.0
local out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha)
if out_alpha <= 0 then
return 0
end
local out_r = (sr * src_alpha + dr * dst_alpha * (1.0 - src_alpha)) / out_alpha
local out_g = (sg * src_alpha + dg * dst_alpha * (1.0 - src_alpha)) / out_alpha
local out_b = (sb * src_alpha + db * dst_alpha * (1.0 - src_alpha)) / out_alpha
return argb(
math.floor(out_alpha * 255.0 + 0.5),
math.floor(out_r + 0.5),
math.floor(out_g + 0.5),
math.floor(out_b + 0.5)
)
end
local build_sprite_assets
local build_font_assets
do
local function create_canvas(width, height)
local pixels = {}
for i = 1, width * height do
pixels[i] = 0
end
local canvas = {}
function canvas.blend_pixel(x, y, color)
x = math.floor(x)
y = math.floor(y)
if x < 0 or x >= width or y < 0 or y >= height then
return
end
local index = y * width + x + 1
pixels[index] = blend_argb(pixels[index], color)
end
function canvas.to_content()
local packed = {}
for i = 1, #pixels do
packed[i] = rgba_bytes(pixels[i])
end
return table.concat(packed)
end
return canvas
end
local function regular_polygon(cx, cy, radius, sides, rotation)
local points = {}
for i = 0, sides - 1 do
local angle = rotation + i * math.pi * 2.0 / sides
points[#points + 1] = cx + math.cos(angle) * radius
points[#points + 1] = cy + math.sin(angle) * radius
end
return points
end
---@return boolean
local function point_in_polygon(px, py, points)
local inside = false
local count = #points // 2
local j = count
for i = 1, count do
local xi = points[i * 2 - 1]
local yi = points[i * 2]
local xj = points[j * 2 - 1]
local yj = points[j * 2]
local intersect = (yi > py) ~= (yj > py)
and px < (xj - xi) * (py - yi) / ((yj - yi) ~= 0 and (yj - yi) or 1e-6) + xi
if intersect then
inside = not inside
end
j = i
end
return inside
end
local function segment_distance_sq(px, py, ax, ay, bx, by)
local abx = bx - ax
local aby = by - ay
local apx = px - ax
local apy = py - ay
local denom = abx * abx + aby * aby
if denom <= 0.000001 then
local dx = px - ax
local dy = py - ay
return dx * dx + dy * dy
end
local t = (apx * abx + apy * aby) / denom
if t < 0 then
t = 0
elseif t > 1 then
t = 1
end
local qx = ax + abx * t
local qy = ay + aby * t
local dx = px - qx
local dy = py - qy
return dx * dx + dy * dy
end
local function fill_circle(canvas, cx, cy, radius, color)
local min_x = math.floor(cx - radius)
local max_x = math.ceil(cx + radius)
local min_y = math.floor(cy - radius)
local max_y = math.ceil(cy + radius)
local radius_sq = radius * radius
for y = min_y, max_y do
for x = min_x, max_x do
local dx = x + 0.5 - cx
local dy = y + 0.5 - cy
if dx * dx + dy * dy <= radius_sq then
canvas.blend_pixel(x, y, color)
end
end
end
end
local function stroke_circle(canvas, cx, cy, radius, thickness, color)
local outer = radius + thickness * 0.5
local inner = radius - thickness * 0.5
local outer_sq = outer * outer
local inner_sq = inner > 0 and inner * inner or 0
local min_x = math.floor(cx - outer)
local max_x = math.ceil(cx + outer)
local min_y = math.floor(cy - outer)
local max_y = math.ceil(cy + outer)
for y = min_y, max_y do
for x = min_x, max_x do
local dx = x + 0.5 - cx
local dy = y + 0.5 - cy
local dist_sq = dx * dx + dy * dy
if dist_sq <= outer_sq and dist_sq >= inner_sq then
canvas.blend_pixel(x, y, color)
end
end
end
end
local function fill_polygon(canvas, points, color)
local min_x = math.huge
local max_x = -math.huge
local min_y = math.huge
local max_y = -math.huge
for i = 1, #points, 2 do
local x = points[i]
local y = points[i + 1]
if x < min_x then
min_x = x
end
if x > max_x then
max_x = x
end
if y < min_y then
min_y = y
end
if y > max_y then
max_y = y
end
end
for y = math.floor(min_y), math.ceil(max_y) do
for x = math.floor(min_x), math.ceil(max_x) do
if point_in_polygon(x + 0.5, y + 0.5, points) then
canvas.blend_pixel(x, y, color)
end
end
end
end
local function stroke_polygon(canvas, points, thickness, color)
local min_x = math.huge
local max_x = -math.huge
local min_y = math.huge
local max_y = -math.huge
for i = 1, #points, 2 do
local x = points[i]
local y = points[i + 1]
if x < min_x then
min_x = x
end
if x > max_x then
max_x = x
end
if y < min_y then
min_y = y
end
if y > max_y then
max_y = y
end
end
local outer = thickness * 0.5
local outer_sq = outer * outer
local count = #points // 2
for y = math.floor(min_y - outer), math.ceil(max_y + outer) do
for x = math.floor(min_x - outer), math.ceil(max_x + outer) do
local px = x + 0.5
local py = y + 0.5
for i = 1, count do
local j = i == count and 1 or i + 1
local ax = points[i * 2 - 1]
local ay = points[i * 2]
local bx = points[j * 2 - 1]
local by = points[j * 2]
if segment_distance_sq(px, py, ax, ay, bx, by) <= outer_sq then
canvas.blend_pixel(x, y, color)
break
end
end
end
end
end
local function add_soft_circle_glow(canvas, cx, cy, radius, color, spread, layers)
for i = layers, 1, -1 do
local t = i / layers
local r = radius + t * spread
local alpha = t * t * 0.35
fill_circle(canvas, cx, cy, r, scale_alpha(color, alpha))
end
end
local function build_circle_mask(radius)
local size = radius * 2 + 4
local canvas = create_canvas(size, size)
local cx = size // 2
local cy = size // 2
fill_circle(canvas, cx, cy, radius, COLOR_WHITE)
return canvas.to_content(), size, size
end
local function build_ring_mask(radius, thickness)
local size = radius * 2 + math.ceil(thickness) + 4
local canvas = create_canvas(size, size)
local cx = size // 2
local cy = size // 2
stroke_circle(canvas, cx, cy, radius, thickness, COLOR_WHITE)
return canvas.to_content(), size, size
end
local function player_triangle_points(cx, cy, scale)
local back = math.pi
return {
cx + 18.0 * scale,
cy,
cx + ((math.cos(back) * 10.0 + math.cos(back + 1.3) * 10.0) * scale),
cy + ((math.sin(back) * 10.0 + math.sin(back + 1.3) * 10.0) * scale),
cx + ((math.cos(back) * 10.0 + math.cos(back - 1.3) * 10.0) * scale),
cy + ((math.sin(back) * 10.0 + math.sin(back - 1.3) * 10.0) * scale),
}
end
local function build_player_core_mask()
local size = 72
local canvas = create_canvas(size, size)
local cx = size // 2
local cy = size // 2
fill_polygon(canvas, player_triangle_points(cx, cy, 1.0), COLOR_WHITE)
return canvas.to_content(), size, size
end
local function build_player_outline_mask()
local size = 88
local canvas = create_canvas(size, size)
local cx = size // 2
local cy = size // 2
stroke_polygon(canvas, player_triangle_points(cx, cy, 1.3), 2.0, COLOR_WHITE)
return canvas.to_content(), size, size
end
local function build_player_sprite()
local size = 96
local canvas = create_canvas(size, size)
local cx = size // 2
local cy = size // 2
fill_circle(canvas, cx, cy, 20, scale_alpha(COLOR_CYAN, 0.24))
stroke_polygon(canvas, player_triangle_points(cx, cy, 1.3), 2.0, scale_alpha(COLOR_WHITE, 0.65))
fill_polygon(canvas, player_triangle_points(cx, cy, 1.0), COLOR_CYAN)
fill_circle(canvas, cx - 12, cy, 4, scale_alpha(COLOR_ORANGE, 0.60))
return canvas.to_content(), size, size
end
local function build_swarm_shape()
local size = 32
local canvas = create_canvas(size, size)
local cx = size // 2
local cy = size // 2
fill_circle(canvas, cx, cy, 6.0, COLOR_WHITE)
stroke_circle(canvas, cx, cy, 7.5, 1.5, scale_alpha(COLOR_WHITE, 0.55))
return canvas.to_content(), size, size
end
local function build_chaser_shape()
local size = 40
local canvas = create_canvas(size, size)
local cx = size // 2
local cy = size // 2
local triangle = {
cx + 11, cy,
cx - 8, cy - 10,
cx - 8, cy + 10,
}
stroke_polygon(canvas, triangle, 2.0, COLOR_WHITE)
return canvas.to_content(), size, size
end
local function build_bouncer_shape()
local size = 40
local canvas = create_canvas(size, size)
local cx = size // 2
local cy = size // 2
local diamond = {
cx, cy - 12,
cx + 12, cy,
cx, cy + 12,
cx - 12, cy,
}
stroke_polygon(canvas, diamond, 2.0, COLOR_WHITE)
return canvas.to_content(), size, size
end
local function build_orbiter_shape()
local size = 56
local canvas = create_canvas(size, size)
local cx = size // 2
local cy = size // 2
fill_circle(canvas, cx, cy, 13.0, scale_alpha(COLOR_WHITE, 0.25))
stroke_circle(canvas, cx, cy, 13.0, 2.0, COLOR_WHITE)
stroke_circle(canvas, cx, cy, 7.5, 2.0, scale_alpha(COLOR_WHITE, 0.85))
return canvas.to_content(), size, size
end
local function build_weaver_shape()
local size = 44
local canvas = create_canvas(size, size)
local cx = size // 2
local cy = size // 2
local pentagon = regular_polygon(cx, cy, 12, 5, -math.pi / 2)
stroke_polygon(canvas, pentagon, 2.0, COLOR_WHITE)
return canvas.to_content(), size, size
end
local function build_bullet_sprite(core_color, glow_color)
local size = 18
local canvas = create_canvas(size, size)
local cx = size // 2
local cy = size // 2
add_soft_circle_glow(canvas, cx, cy, 2.0, glow_color, 2, 2)
fill_circle(canvas, cx, cy, 3.0, glow_color)
fill_circle(canvas, cx, cy, 1.7, core_color)
return canvas.to_content(), size, size
end
local function build_powerup_shape(radius)
local size = radius * 2 + 4
local canvas = create_canvas(size, size)
local cx = size // 2
local cy = size // 2
fill_polygon(canvas, { cx, cy - radius, cx + radius, cy, cx, cy }, COLOR_WHITE)
fill_polygon(canvas, { cx, cy, cx + radius, cy, cx, cy + radius }, COLOR_WHITE)
fill_polygon(canvas, { cx - radius, cy, cx, cy, cx, cy + radius }, COLOR_WHITE)
return canvas.to_content(), size, size
end
local function build_black_hole_sprite()
local size = 64
local canvas = create_canvas(size, size)
local cx = size // 2
local cy = size // 2
stroke_circle(canvas, cx, cy, 18, 2.0, scale_alpha(COLOR_PURPLE, 0.55))
fill_circle(canvas, cx, cy, 11, scale_alpha(COLOR_PURPLE, 0.65))
fill_circle(canvas, cx, cy, 7, scale_alpha(0xff6b1a8d, 0.90))
fill_circle(canvas, cx, cy, 3, COLOR_WHITE)
return canvas.to_content(), size, size
end
local function build_star_mask_sprite(radius)
local size = radius * 2 + 3
local canvas = create_canvas(size, size)
local cx = size // 2
local cy = size // 2
fill_circle(canvas, cx, cy, radius, COLOR_WHITE)
return canvas.to_content(), size, size
end
local function build_explosion_sprite(radius, outer_color, inner_color)
local size = radius * 2 + 8
local canvas = create_canvas(size, size)
local cx = size // 2
local cy = size // 2
add_soft_circle_glow(canvas, cx, cy, radius - 1, outer_color, 3, 2)
fill_circle(canvas, cx, cy, radius, outer_color)
fill_circle(canvas, cx, cy, math.max(radius - 4, 1), inner_color)
return canvas.to_content(), size, size
end
local function register_sprite(add_sprite, name, builder, ...)
local content, width, height = builder(...)
add_sprite(name, content, width, height)
end
local function register_radius_sprites(add_sprite, prefix, builder, first_radius, last_radius, step, ...)
for radius = first_radius, last_radius, step do
local content, width, height = builder(radius, ...)
add_sprite(prefix .. radius, content, width, height)
end
end
local function attach_radius_sprites(loaded, target, prefix, first_radius, last_radius, step)
for radius = first_radius, last_radius, step do
target[radius] = loaded[prefix .. radius]
end
end
local function count_radius_sprites(first_radius, last_radius, step)
return (last_radius - first_radius) // step + 1
end
local function add_preloaded_sprite(bundle, name, content, width, height, offset_x, offset_y)
local filename = "@" .. name
soluna.preload {
filename = filename,
content = content,
w = width,
h = height,
}
bundle[#bundle + 1] = {
name = name,
filename = filename,
x = offset_x == nil and -0.5 or offset_x,
y = offset_y == nil and -0.5 or offset_y,
}
end
local function load_bitmap_font_sprites()
local bundle = {}
bitmapfont.register_bitmap_glyphs(function(name, content, width, height, offset_x, offset_y)
add_preloaded_sprite(bundle, name, content, width, height, offset_x, offset_y)
end)
local loaded = soluna.load_sprites(bundle)
bitmapfont.attach_bitmap_glyphs(loaded)
return loaded.font_glyphs
end
build_sprite_assets = function(progress)
local bundle = {}
local ring_ranges = {
{ 1, 64, 1 },
{ 68, 128, 4 },
{ 144, 256, 16 },
{ 288, 384, 32 },
}
local shape_builders = {
{ "swarm_shape", build_swarm_shape },
{ "chaser_shape", build_chaser_shape },
{ "bouncer_shape", build_bouncer_shape },
{ "orbiter_shape", build_orbiter_shape },
{ "weaver_shape", build_weaver_shape },
}
local explosion_defs = {
{ name = "exp_1", radius = 6, outer = COLOR_WHITE, inner = COLOR_RED },
{ name = "exp_2", radius = 9, outer = COLOR_WHITE, inner = COLOR_RED },
{ name = "exp_3", radius = 12, outer = COLOR_YELLOW, inner = COLOR_RED },
{ name = "exp_4", radius = 15, outer = COLOR_ORANGE, inner = COLOR_RED },
{ name = "exp_5", radius = 18, outer = COLOR_ORANGE, inner = COLOR_RED },
}
if progress then
local total = 0
total = total + 3
total = total + count_radius_sprites(1, 52, 1)
total = total + #shape_builders
total = total + 2
total = total + count_radius_sprites(12, 18, 1)
for _, range in ipairs(ring_ranges) do
total = total + count_radius_sprites(range[1], range[2], range[3])
end
total = total + 3
total = total + count_radius_sprites(1, 3, 1)
total = total + #explosion_defs
progress.done = 0
progress.total = total
progress.current = ""
progress.ready = false
end
local function add_sprite(name, content, width, height, offset_x, offset_y)
add_preloaded_sprite(bundle, name, content, width, height, offset_x, offset_y)
if progress then
progress.done = progress.done + 1
progress.current = name
flow.sleep(0)
end
end
register_sprite(add_sprite, "player", build_player_sprite)
register_sprite(add_sprite, "player_core_mask", build_player_core_mask)
register_sprite(add_sprite, "player_outline_mask", build_player_outline_mask)
register_radius_sprites(add_sprite, "circle_mask_", build_circle_mask, 1, 52, 1)
for _, def in ipairs(shape_builders) do
register_sprite(add_sprite, def[1], def[2])
end
register_sprite(add_sprite, "bullet", build_bullet_sprite, COLOR_WHITE, with_alpha(COLOR_CYAN, 140))
register_sprite(add_sprite, "bullet_homing", build_bullet_sprite, COLOR_WHITE, with_alpha(COLOR_BLUE, 170))
register_radius_sprites(add_sprite, "powerup_shape_", build_powerup_shape, 12, 18, 1)
for _, range in ipairs(ring_ranges) do
register_radius_sprites(add_sprite, "ring_mask_", build_ring_mask, range[1], range[2], range[3], 1.0)
end
register_sprite(add_sprite, "ring_mask_400", build_ring_mask, 400, 1.0)
register_sprite(add_sprite, "slow_ring_mask", build_ring_mask, 180.0, 1.0)
register_sprite(add_sprite, "black_hole", build_black_hole_sprite)
register_radius_sprites(add_sprite, "star_", build_star_mask_sprite, 1, 3, 1)
for _, def in ipairs(explosion_defs) do
register_sprite(add_sprite, def.name, build_explosion_sprite, def.radius, def.outer, def.inner)
end
local loaded = soluna.load_sprites(bundle)
loaded.circle_masks = {}
attach_radius_sprites(loaded, loaded.circle_masks, "circle_mask_", 1, 52, 1)
loaded.ring_masks = {}
for _, range in ipairs(ring_ranges) do
attach_radius_sprites(loaded, loaded.ring_masks, "ring_mask_", range[1], range[2], range[3])
end
loaded.ring_masks[400] = loaded.ring_mask_400
return loaded
end
build_font_assets = load_bitmap_font_sprites
end
soluna.set_window_title "Geometry Wars"
local sprites
local loading_sprites = {
font_glyphs = build_font_assets(),
}
local asset_progress = {
started = false,
ready = false,
done = 0,
total = 1,
current = "",
error = nil,
}
local quad = util.quad_cache(matquad)
local view = util.fixed_view(args, W, H)
local masked = util.cache(function(sprite)
return util.cache(function(color)
return matmask.mask(sprite, color)
end)
end)
local function start_sprite_loading()
if asset_progress.started then
return
end
asset_progress.started = true
ltask.fork(function()
local ok, loaded = pcall(build_sprite_assets, asset_progress)
if ok then
sprites = loaded
sprites.font_glyphs = loading_sprites.font_glyphs
asset_progress.done = asset_progress.total
asset_progress.current = "ready"
asset_progress.ready = true
else
asset_progress.error = tostring(loaded)
end
end)
end
local MAP_W = 1200
local MAP_H = 900
local PLAYER_SPEED = 250.0
local CAMERA_LERP = 0.10
local GRID_SPACING = 25
local GRID_COLS = MAP_W // GRID_SPACING + 1
local GRID_ROWS = MAP_H // GRID_SPACING + 1
local MAX_STARS_FAR = 50
local MAX_STARS_NEAR = 30
local MAX_BULLETS = 150
local MAX_ENEMIES = 100
local MAX_PARTICLES = 800
local MAX_FLOAT_TEXTS = 30
local MAX_TICKER = 4
local MAX_POWERUPS = 5
local BULLET_SPEED = 810.0
local SHOOT_RATE = 0.12
local COMBO_TIMEOUT = 2.0
local POPUP_LIFE = 2.0
local POPUP_GROW_TIME = 0.2
local POPUP_FADE_TIME = 0.5
local MAX_LIVES = 3
local RESPAWN_INVINCIBLE = 2.0
local SPAWN_CLEAR_RADIUS = 150.0
local TRAIL_LEN = 12
local TRAIL_INTERVAL = 0.03
local COLOR_GRID = 0x46323282
local COLOR_BORDER = 0xb46464ff
local BULLET_CORE = argb(255, 200, 255, 200)
local BULLET_GLOW = argb(100, 100, 255, 100)
local BULLET_HOMING_CORE = argb(255, 130, 220, 255)
local BULLET_HOMING_GLOW = argb(100, 80, 180, 255)
local PLAYER_GLOW = argb(60, 0, 255, 255)
local PLAYER_CORE = argb(240, 0, 255, 255)
local PLAYER_OUTLINE = argb(160, 100, 255, 255)
local PLAYER_TRAIL = argb(100, 0, 200, 255)
local ENGINE_GLOW = argb(150, 255, 180, 60)
local PARTICLE_GLOW_ALPHA = 80
local PARTICLE_CORE_ALPHA = 120
local ENEMY_DEFS = {
[0] = {
name = "SWARM",
color = argb(255, 255, 150, 40),
score = 50,
radius = 10,
speed = 60,
hp = 1,
shape_sprite = "swarm_shape",
glow_scale = 1.8,
icon_scale = 0.5,
},
[1] = {
name = "CHASER",
color = argb(255, 255, 80, 140),
score = 100,
radius = 12,
speed = 140,
hp = 1,
shape_sprite = "chaser_shape",
draw_rotated = true,
glow_scale = 2.0,
icon_scale = 0.4,
},
[2] = {
name = "BOUNCER",
color = argb(255, 80, 255, 80),
score = 150,
radius = 14,
speed = 100,
hp = 2,
shape_sprite = "bouncer_shape",
draw_rotated = true,
glow_scale = 1.5,
icon_scale = 0.4,
},
[3] = {
name = "ORBITER",
color = argb(255, 160, 60, 220),
score = 300,
radius = 22,
speed = 50,
hp = 5,
shape_sprite = "orbiter_shape",
glow_scale = 1.6,
icon_scale = 0.36,
core_color = argb(200, 220, 120, 255),
core_scale = 0.6,
},
[4] = {
name = "WEAVER",
color = argb(255, 255, 220, 50),
score = 200,
radius = 12,
speed = 120,
hp = 2,
shape_sprite = "weaver_shape",
draw_rotated = true,
glow_scale = 1.6,
icon_scale = 0.4,
},
}
local NUKE_DEF = {
name = "NUKE",
color = COLOR_CYAN,
glow = argb(80, 0, 200, 255),
outline = argb(150, 0, 255, 255),
}
local ABILITY_DEFS = {
[0] = {
name = "SPREAD",
color = argb(255, 255, 180, 60),
glow = argb(80, 255, 120, 30),
outline = argb(150, 255, 120, 30),
popup = "SPREAD SHOT!",
},
[1] = {
name = "HOMING",
color = argb(255, 130, 220, 255),
glow = argb(80, 80, 180, 255),
outline = argb(150, 80, 180, 255),
popup = "HOMING SHOT!",
},
[2] = {
name = "SHIELD",
color = argb(255, 255, 215, 0),
glow = argb(80, 200, 160, 0),
outline = argb(150, 200, 160, 0),
popup = "SHIELD!",
},
[3] = {
name = "SLOW",
color = argb(255, 80, 180, 255),
glow = argb(80, 40, 100, 220),
outline = argb(150, 40, 100, 220),
popup = "SLOW FIELD!",
},
}
local input = {
---@type boolean
left = false,
---@type boolean
right = false,
---@type boolean
up = false,
---@type boolean
down = false,
---@type boolean
mouse_left = false,
---@type boolean
mouse_pressed = false,
---@type boolean
mouse_released = false,
---@type boolean|string
ui_active_button = false,
key_pressed = {},
ui_actions = {},
}
local window_w = args.width or W
local window_h = args.height or H
local mouse_screen_x = window_w * 0.5
local mouse_screen_y = window_h * 0.5
local mouse_world_x = MAP_W * 0.5
local mouse_world_y = MAP_H * 0.5
local last_tick
local fps = 0.0
local fps_clock
local fps_frames = 0
local state = {
---@type string
scene = "title",
---@type number
scene_time = 0.0,
---@type number
game_time = 0.0,
---@type number
total_time = 0.0,
---@type number
lives = MAX_LIVES,
---@type number
score = 0,
---@type number
combo = 1,
---@type number
highest_combo = 1,
---@type number
total_kills = 0,
---@type number
combo_timer = 0.0,
---@type number
combo_bump_timer = 0.0,
---@type number
spawn_timer = 0.0,
---@type number
shoot_timer = 0.0,
---@type number
trail_timer = 0.0,
---@type number
trail_count = 0,
---@type number
thrust_particle_timer = 0.0,
---@type number
respawn_timer = 0.0,
---@type boolean
respawn_invincible = false,
---@type boolean
player_alive = true,
---@type number
killed_by_type = -1,
---@type number
shake_amt = 0.0,
---@type number
shake_frames = 0,
---@type number
shake_x = 0.0,
---@type number
shake_y = 0.0,
---@type number
screen_shake_y = 0.0,
---@type number
screen_shake_frames = 0,
---@type boolean
combo5_shown = false,
---@type boolean
combo10_shown = false,
---@type boolean
kills50_shown = false,
---@type boolean
kills100_shown = false,
---@type boolean
kills200_shown = false,
---@type number
best_score = 0,
best_time = 0.0,
lb_highlight = -1,
---@type boolean|string
save_path = false,
---@type any[]
leaderboard = {},
}
local audio = {
music_voice = nil,
death_sound_played = false,
shoot = { "shoot_01", "shoot_02", "shoot_03", "shoot_04" },
explosion = {
"explosion_01", "explosion_02", "explosion_03", "explosion_04",
"explosion_05", "explosion_06", "explosion_07", "explosion_08",
},
spawn = { "spawn_01", "spawn_02", "spawn_03", "spawn_04", "spawn_05", "spawn_06", "spawn_07", "spawn_08" },
rise = { "rise_01", "rise_02", "rise_03", "rise_04", "rise_05", "rise_06", "rise_07" },
powerup = "powerup_01",
death = "explosion_01",
game_over = "explosion_02",
}
local trail_x = {}
local trail_y = {}
local trail_a = {}
local player = {
x = MAP_W * 0.5,
y = MAP_H * 0.5,
---@type number
angle = 0,
}
local camera = {
---@type number
x = 0,
---@type number
y = 0,
}
local stars_far = {}
local stars_near = {}
---@type any[]
local grid = {}
---@type any[]
local bullets = {}
---@type any[]
local enemies = {}
---@type any[]
local particles = {}
local feedback = {
---@type any[]
float_texts = {},
---@type any[]
ticker_msgs = {},
popup = {
text = "",
color = COLOR_WHITE,
---@type number
life = 0.0,
---@type number
max_life = 0.0,
---@type number
scale = 1,
},
menu_colors = {
red = 0xffff0000,
dark_gray = 0xff444444,
light_gray = 0xffcccccc,
},
}
state.frame_dt = 1.0 / 60.0
function state.set_scene_hooks(draw_world, draw_overlay)
state.scene_draw_world = draw_world
state.scene_draw_overlay = draw_overlay
end
function input.emit_ui_action(action)
input.ui_actions[action] = true
end
function input.consume_ui_action(action)
local pending = input.ui_actions[action] == true
input.ui_actions[action] = nil
return pending
end
function input.clear_ui_actions()
for action in pairs(input.ui_actions) do
input.ui_actions[action] = nil
end
end
function input.consume_key_press(keycode)
local pressed = input.key_pressed[keycode] == true
input.key_pressed[keycode] = nil
return pressed
end
function input.clear_key_presses()
for keycode in pairs(input.key_pressed) do
input.key_pressed[keycode] = nil
end
end
function feedback.ui_lighten_rgb(color, amount)
amount = clamp(amount, 0, 255)
local a, r, g, b = unpack_argb(color)
r = r + math.floor((255 - r) * amount / 255)
g = g + math.floor((255 - g) * amount / 255)
b = b + math.floor((255 - b) * amount / 255)
return argb(a, r, g, b)
end
function feedback.ui_darken_rgb(color, amount)
amount = clamp(amount, 0, 255)
local a, r, g, b = unpack_argb(color)
local scale = 255 - amount
r = math.floor(r * scale / 255)
g = math.floor(g * scale / 255)
b = math.floor(b * scale / 255)
return argb(a, r, g, b)
end
function feedback.ui_button_text_color(color)
local _, r, g, b = unpack_argb(color)
local luma = r * 299 + g * 587 + b * 114
if luma >= 140000 then
return COLOR_BLACK
end
return COLOR_WHITE
end
function feedback.draw_bevel_rect(x, y, width, height, face, pressed)
if width <= 0 or height <= 0 then
return
end
local function add_rect(rx, ry, rw, rh, color)
if rw <= 0 or rh <= 0 then
return
end
batch:add(quad { width = rw, height = rh, color = color }, rx, ry)
end
add_rect(x, y, width, height, face)
local light_outer = feedback.ui_lighten_rgb(face, 112)
local light_inner = feedback.ui_lighten_rgb(face, 56)
local dark_outer = feedback.ui_darken_rgb(face, 112)
local dark_inner = feedback.ui_darken_rgb(face, 56)
if pressed then
light_outer, dark_outer = dark_outer, light_outer
light_inner, dark_inner = dark_inner, light_inner
end
add_rect(x, y, width, 1, light_outer)
add_rect(x, y, 1, height, light_outer)
add_rect(x, y + height - 1, width, 1, dark_outer)
add_rect(x + width - 1, y, 1, height, dark_outer)
if width > 2 and height > 2 then
add_rect(x + 1, y + 1, width - 2, 1, light_inner)
add_rect(x + 1, y + 1, 1, height - 2, light_inner)
add_rect(x + 1, y + height - 2, width - 2, 1, dark_inner)
add_rect(x + width - 2, y + 1, 1, height - 2, dark_inner)
end
end
local powerup = {
---@type any[]
entries = {},
---@type any[]
black_holes = {},
---@type number
spawn_timer = 0.0,
---@type number
jack_timer = 0.0,
---@type boolean
jack_active = false,
---@type number
jack_spawn_timer = 0.0,
---@type number
jack_count = 0,
---@type number
jack_total = 0,
---@type number
bh_spawn_timer = 0.0,
---@type number
nuke_flash_alpha = 0.0,
---@type number
nuke_wave_radius = 0.0,
---@type number
nuke_wave_alpha = 0.0,
---@type boolean
nuke_fx_active = false,
energy_duration = 5.0,
energy_shoot_rate = 0.08,
---@type boolean
energy_active = false,
---@type number
energy_timer = 0.0,
---@type number
ability_type = 0,
---@type boolean
homing_active = false,
---@type boolean
shield_active = false,
---@type boolean
slow_active = false,
---@type number
shield_angle = 0.0,
jack_phrases = {
"THE SWARM RISES FROM EVERY CORNER!",
"SHADOWS CONVERGE - ENEMIES SURGING FROM ALL SIDES!",
"THE HIVE HAS AWAKENED - INVASION INBOUND!",
"FOUR WALLS BREACHED - DENSITY CRITICAL!",
"NOWHERE TO HIDE - THEY COME FROM EVERYWHERE!",
},
bh_phrases = {
"GRAVITATIONAL ANOMALY DETECTED - CAUTION ADVISED!",
"A SINGULARITY FORMS - ALL TRAJECTORIES BENDING!",
"THE VOID AWAKENS - SPACE ITSELF IS WARPING!",
"GRAVITY WELL ACTIVE - NOTHING ESCAPES ITS PULL!",
"DIMENSIONAL RIFT - REALITY COLLAPSING INWARD!",
},
}
---@type fun(cx, cy, radius, strength)
local grid_impulse
local function random_star_color(alpha_min, alpha_max)
local alpha = math.random(alpha_min, alpha_max)
local variant = math.random(1, 3)
if variant == 1 then
return argb(alpha, 200, 200, 255)
elseif variant == 2 then
return argb(alpha, 180, 220, 255)
end
return argb(alpha, 220, 220, 220)
end
local function quantize_byte(value, step)
local q = math.floor((value + step * 0.5) / step) * step
return clamp(q, 0, 255)
end
local function particle_color(r, g, b)
return argb(255, quantize_byte(r, 16), quantize_byte(g, 16), quantize_byte(b, 16))
end
local function particle_alpha(color, alpha)
local _, r, g, b = unpack_argb(color)
return argb(quantize_byte(alpha, 16), r, g, b)
end
local function distance(ax, ay, bx, by)
local dx = bx - ax
local dy = by - ay
return math.sqrt(dx * dx + dy * dy)
end
local function enemy_color(enemy_type)
local def = ENEMY_DEFS[enemy_type]
return def and def.color or COLOR_WHITE
end
local function stop_music(fade_seconds)
local voice = audio.music_voice
audio.music_voice = nil
if voice == nil then
return
end
voice:stop(fade_seconds)
end
local function ensure_music_playing()
local voice = audio.music_voice
if voice ~= nil and voice:playing() then
return
end
local started, err = soluna.play_sound "music1"
if started == nil then
error(err or "failed to start Geometry Wars music")
end
audio.music_voice = started
end
local function play_effect(name, opts)
local voice, err = soluna.play_sound(name, opts)
if voice == nil then
error(err or ("failed to play sound " .. tostring(name)))
end
return voice
end
local function play_random_effect(list, opts)
return play_effect(list[math.random(1, #list)], opts)
end
function state.sync_scene(next_scene)
if state.scene ~= next_scene then
local previous_scene = state.scene
state.scene = next_scene
state.scene_time = 0.0
input.ui_active_button = false
input.clear_ui_actions()
if next_scene == "death" then
audio.death_sound_played = false
elseif next_scene == "over" then
play_effect(audio.game_over)
end
if next_scene == "combat" then
ensure_music_playing()
elseif previous_scene == "combat" then
stop_music()
end
end
end
function state.center_camera()
camera.x = (MAP_W - W) * 0.5
camera.y = (MAP_H - H) * 0.5
end
function state.load_records()
for i = 1, 10 do
if state.leaderboard[i] == nil then
state.leaderboard[i] = {
score = 0,
time = 0.0,
kills = 0,
combo = 0,
}
end
end
if state.save_path == false then
local ok, dir = pcall(soluna.gamedir)
if ok and type(dir) == "string" then
state.save_path = dir .. "geometry_wars_save.dl"
else
state.save_path = "./geometry_wars_save.dl"
end
end
local ok, data = persist.load(state.save_path)
if not ok or type(data) ~= "table" then
return
end
state.best_score = tonumber(data.best_score) or 0
state.best_time = tonumber(data.best_time) or 0.0
if type(data.leaderboard) == "table" then
for i = 1, 10 do
local src = data.leaderboard[i]
local dst = state.leaderboard[i]
if type(src) == "table" then
dst.score = tonumber(src.score) or 0
dst.time = tonumber(src.time) or 0.0
dst.kills = tonumber(src.kills) or 0
dst.combo = tonumber(src.combo) or 0
else
dst.score = 0
dst.time = 0.0
dst.kills = 0
dst.combo = 0
end
end
end
end
function state.save_records()
if not state.save_path then
state.load_records()
end
local rows = {}
for i = 1, 10 do
local entry = state.leaderboard[i] or {}
rows[i] = {
score = tonumber(entry.score) or 0,
time = tonumber(entry.time) or 0.0,
kills = tonumber(entry.kills) or 0,
combo = tonumber(entry.combo) or 0,
}
end
persist.save(state.save_path, {
best_score = state.best_score,
best_time = state.best_time,
leaderboard = rows,
})
end
function state.insert_leaderboard(score, survival_time, kills, combo)
local pos = -1
for i = 1, 10 do
if score > state.leaderboard[i].score then
pos = i
break
end
end
if pos < 0 then
return -1
end
for i = 10, pos + 1, -1 do
local prev = state.leaderboard[i - 1]
local dst = state.leaderboard[i]
dst.score = prev.score
dst.time = prev.time
dst.kills = prev.kills
dst.combo = prev.combo
end
local row = state.leaderboard[pos]
row.score = score
row.time = survival_time
row.kills = kills
row.combo = combo
state.save_records()
return pos
end
local function shake(amount, frames)
if amount > state.shake_amt then
state.shake_amt = amount
state.shake_frames = frames
end
end
local function draw_masked_circle(color, radius, x, y)
local sprite_radius = math.floor(radius)
if sprite_radius < 1 then
sprite_radius = 1
elseif sprite_radius > 52 then
sprite_radius = 52
end
local masks = assert(sprites.circle_masks)
batch:add(masked[masks[sprite_radius]][color], x, y)
end
local function cached_ring_radius(radius)
radius = math.floor(radius)
if radius < 1 then
return 1
end
if radius <= 64 then
return radius
end
if radius <= 128 then
radius = 68 + math.floor((radius - 68 + 2) / 4) * 4
return clamp(radius, 68, 128)
end
if radius <= 256 then
radius = 144 + math.floor((radius - 144 + 8) / 16) * 16
return clamp(radius, 144, 256)
end
radius = 288 + math.floor((radius - 288 + 16) / 32) * 32
return clamp(radius, 288, 400)
end
local function draw_masked_ring(color, radius, x, y)
local target_radius = math.floor(radius)
if target_radius < 1 then
return
end
local sprite_radius = cached_ring_radius(target_radius)
local masks = assert(sprites.ring_masks)
local sprite = masks[sprite_radius]
if target_radius == sprite_radius then
batch:add(masked[sprite][color], x, y)
return
end
batch:layer(target_radius / sprite_radius, x, y)
batch:add(masked[sprite][color])
batch:layer()
end
function feedback.spawn_float_text(x, y, text, color)
for i = 1, MAX_FLOAT_TEXTS do
local float_text = feedback.float_texts[i]
if float_text.life <= 0 then
float_text.x = x
float_text.y = y
float_text.vy = -60.0
float_text.text = text
float_text.color = color
float_text.life = 1.0
float_text.max_life = 1.0
return
end
end
end
function feedback.show_popup(text, color, scale)
feedback.popup.text = text
feedback.popup.color = color
feedback.popup.life = POPUP_LIFE
feedback.popup.max_life = POPUP_LIFE
feedback.popup.scale = scale
end
function feedback.ticker_add(text, color)
local slot = -1
for i = 1, MAX_TICKER do
if not feedback.ticker_msgs[i].active then
slot = i
break
end
end
if slot == -1 then
local best_life = 999.0
for i = 1, MAX_TICKER do
local remaining = feedback.ticker_msgs[i].life - feedback.ticker_msgs[i].timer
if remaining < best_life then
best_life = remaining
slot = i
end
end
end
local msg = feedback.ticker_msgs[slot]
msg.text = text
msg.color = color
msg.life = 3.0
msg.timer = 0.0
msg.active = true
end
local function spawn_particle(x, y, vx, vy, color, life, size)
for i = 1, MAX_PARTICLES do
local particle = particles[i]
if particle.life <= 0 then
particle.x = x
particle.y = y
particle.vx = vx
particle.vy = vy
particle.color = color
particle.life = life
particle.max_life = life
particle.size = size
return
end
end
end
local function spawn_bullet(angle, homing)
for i = 1, MAX_BULLETS do
local bullet = bullets[i]
if not bullet.active then
bullet.active = true
bullet.x = player.x + math.cos(angle) * 15.0
bullet.y = player.y + math.sin(angle) * 15.0
bullet.vx = math.cos(angle) * BULLET_SPEED
bullet.vy = math.sin(angle) * BULLET_SPEED
bullet.homing = homing
return
end
end
end
local function spawn_explosion(x, y, color, count)
for i = 0, count - 1 do
local angle = (i * 2.0 * math.pi) / count
local speed = 80.0 + math.random() * 280.0
spawn_particle(
x,
y,
math.cos(angle) * speed,
math.sin(angle) * speed,
color,
0.4 + math.random() * 0.8,
3.0 + math.random(0, 3)
)
end
end
local function spawn_enemy(enemy_type, x, y)
local def = assert(ENEMY_DEFS[enemy_type], "Unknown enemy type")
for i = 1, MAX_ENEMIES do
local enemy = enemies[i]
if not enemy.active then
enemy.active = true
enemy.type = enemy_type
enemy.x = x
enemy.y = y
enemy.angle = 0
enemy.r = def.radius
enemy.speed = def.speed
enemy.hp = def.hp
enemy.max_hp = def.hp
enemy.vx = 0
enemy.vy = 0
enemy.timer = 0
if enemy_type == 2 then
local angle = math.random() * 2.0 * math.pi
enemy.vx = math.cos(angle) * enemy.speed
enemy.vy = math.sin(angle) * enemy.speed
enemy.angle = math.random() * 2.0 * math.pi
elseif enemy_type == 3 then
enemy.timer = math.random(0, 599) / 100.0
elseif enemy_type == 4 then
enemy.timer = math.random(0, 599) / 100.0
local angle = math.random() * 2.0 * math.pi
enemy.vx = math.cos(angle) * enemy.speed
enemy.vy = math.sin(angle) * enemy.speed
enemy.angle = angle
end
return true
end
end
return false
end
local function spawn_from_edge(enemy_type)
local margin = 80.0
for _ = 1, 20 do
local x
local y
local side = math.random(0, 3)
if side == 0 then
x = math.random() * MAP_W
y = -margin
elseif side == 1 then
x = math.random() * MAP_W
y = MAP_H + margin
elseif side == 2 then
x = -margin
y = math.random() * MAP_H
else
x = MAP_W + margin
y = math.random() * MAP_H
end
x = clamp(x, -margin, MAP_W + margin)
y = clamp(y, -margin, MAP_H + margin)
if distance(x, y, player.x, player.y) > 250.0 then
return spawn_enemy(enemy_type, x, y)
end
end
return false
end
local function update_enemies(dt)
for i = 1, MAX_ENEMIES do
local enemy = enemies[i]
if enemy.active then
if enemy.type == 0 then
local dx = player.x - enemy.x
local dy = player.y - enemy.y
local dist = distance(enemy.x, enemy.y, player.x, player.y)
if dist > 1 then
enemy.vx = enemy.vx + (dx / dist) * enemy.speed * 2.0 * dt
enemy.vy = enemy.vy + (dy / dist) * enemy.speed * 2.0 * dt
end
local friction = 1.0 - 3.0 * dt
if friction < 0.7 then
friction = 0.7
end
enemy.vx = enemy.vx * friction
enemy.vy = enemy.vy * friction
elseif enemy.type == 1 then
local dx = player.x - enemy.x
local dy = player.y - enemy.y
local dist = distance(enemy.x, enemy.y, player.x, player.y)
if dist > 1 then
enemy.vx = enemy.vx + (dx / dist) * enemy.speed * 4.0 * dt
enemy.vy = enemy.vy + (dy / dist) * enemy.speed * 4.0 * dt
end
local max_speed = enemy.speed * 1.2
local current = math.sqrt(enemy.vx * enemy.vx + enemy.vy * enemy.vy)
if current > max_speed then
enemy.vx = enemy.vx / current * max_speed
enemy.vy = enemy.vy / current * max_speed
end
enemy.angle = math.atan(enemy.vy, enemy.vx)
elseif enemy.type == 2 then
enemy.angle = enemy.angle - (4.0 * math.pi) * dt
elseif enemy.type == 3 then
local dx = player.x - enemy.x
local dy = player.y - enemy.y
local dist = distance(enemy.x, enemy.y, player.x, player.y)
if dist > 1 then
local target_dist = 180.0
local radial_force = (dist - target_dist) / dist
enemy.vx = enemy.vx + (dx / dist) * radial_force * enemy.speed * 2.0 * dt
enemy.vy = enemy.vy + (dy / dist) * radial_force * enemy.speed * 2.0 * dt
enemy.timer = enemy.timer + dt
local tang_angle = math.atan(dy, dx) + math.pi * 0.5
enemy.vx = enemy.vx + math.cos(tang_angle) * enemy.speed * 3.0 * dt
enemy.vy = enemy.vy + math.sin(tang_angle) * enemy.speed * 3.0 * dt
end
local friction = 1.0 - 3.0 * dt
if friction < 0.8 then
friction = 0.8
end
enemy.vx = enemy.vx * friction
enemy.vy = enemy.vy * friction
enemy.angle = enemy.angle + 2.0 * dt
elseif enemy.type == 4 then
enemy.timer = enemy.timer + dt
local base_angle = enemy.angle
local weave_offset = math.sin(enemy.timer * 3.0) * 0.8
local move_angle = base_angle + weave_offset
enemy.vx = math.cos(move_angle) * enemy.speed
enemy.vy = math.sin(move_angle) * enemy.speed
end
enemy.x = enemy.x + enemy.vx * dt
enemy.y = enemy.y + enemy.vy * dt
if powerup.slow_active and distance(enemy.x, enemy.y, player.x, player.y) < 180.0 then
enemy.x = enemy.x - enemy.vx * dt * 0.7
enemy.y = enemy.y - enemy.vy * dt * 0.7
end
if enemy.type ~= 2 and enemy.type ~= 4 then
enemy.x = clamp(enemy.x, enemy.r, MAP_W - enemy.r)
enemy.y = clamp(enemy.y, enemy.r, MAP_H - enemy.r)
elseif enemy.type == 2 then
if enemy.x < enemy.r then
enemy.x = enemy.r
enemy.vx = -enemy.vx
end
if enemy.x > MAP_W - enemy.r then
enemy.x = MAP_W - enemy.r
enemy.vx = -enemy.vx
end
if enemy.y < enemy.r then
enemy.y = enemy.r
enemy.vy = -enemy.vy
end
if enemy.y > MAP_H - enemy.r then
enemy.y = MAP_H - enemy.r
enemy.vy = -enemy.vy
end
else
if enemy.x < enemy.r then
enemy.x = enemy.r
enemy.vx = -enemy.vx
enemy.angle = math.atan(enemy.vy, enemy.vx)
end
if enemy.x > MAP_W - enemy.r then
enemy.x = MAP_W - enemy.r
enemy.vx = -enemy.vx
enemy.angle = math.atan(enemy.vy, enemy.vx)
end
if enemy.y < enemy.r then
enemy.y = enemy.r
enemy.vy = -enemy.vy
enemy.angle = math.atan(enemy.vy, enemy.vx)
end
if enemy.y > MAP_H - enemy.r then
enemy.y = MAP_H - enemy.r
enemy.vy = -enemy.vy
enemy.angle = math.atan(enemy.vy, enemy.vx)
end
end
end
end
end
local function handle_player_hit(enemy_type)
state.killed_by_type = enemy_type
state.lives = state.lives - 1
powerup.clear_ability()
if state.lives <= 0 then
state.player_alive = false
for i = 1, MAX_ENEMIES do
enemies[i].active = false
end
if enemy_type < 0 then
for i = 1, 2 do
powerup.black_holes[i].active = false
end
end
for i = 1, MAX_BULLETS do
bullets[i].active = false
end
spawn_explosion(player.x, player.y, COLOR_WHITE, 80)
spawn_explosion(player.x, player.y, COLOR_CYAN, 60)
spawn_explosion(player.x, player.y, argb(255, 255, 200, 100), 40)
grid_impulse(player.x, player.y, 500, 500)
shake(10, 25)
else
spawn_explosion(player.x, player.y, COLOR_WHITE, 30)
spawn_explosion(player.x, player.y, COLOR_CYAN, 20)
grid_impulse(player.x, player.y, 200, 200)
shake(5, 10)
play_random_effect(audio.explosion, { volume = 0.25 })
player.x = MAP_W * 0.5
player.y = MAP_H * 0.5
player.angle = 0
camera.x = player.x - W * 0.5
camera.y = player.y - H * 0.5
camera.x = clamp(camera.x, 0, MAP_W - W)
camera.y = clamp(camera.y, 0, MAP_H - H)
for i = 1, MAX_ENEMIES do
local enemy = enemies[i]
if enemy.active and distance(player.x, player.y, enemy.x, enemy.y) < SPAWN_CLEAR_RADIUS then
spawn_explosion(enemy.x, enemy.y, enemy_color(enemy.type), 8)
enemy.active = false
end
end
for i = 1, MAX_BULLETS do
bullets[i].active = false
end
state.respawn_invincible = true
state.respawn_timer = RESPAWN_INVINCIBLE
end
end
local function update_collisions()
if not state.player_alive then
return
end
for i = 1, MAX_BULLETS do
local bullet = bullets[i]
if bullet.active then
for j = 1, MAX_ENEMIES do
local enemy = enemies[j]
if enemy.active and distance(bullet.x, bullet.y, enemy.x, enemy.y) < enemy.r + 4 then
local bullet_angle = math.atan(bullet.vy, bullet.vx)
local spark_count = 5 + math.random(0, 3)
for _ = 1, spark_count do
local spread_angle = bullet_angle + math.pi + (math.random(-50, 49) * math.pi) / 180.0
local speed = 150.0 + math.random() * 250.0
spawn_particle(
bullet.x,
bullet.y,
math.cos(spread_angle) * speed,
math.sin(spread_angle) * speed,
argb(255, 255, 255, 200),
0.3 + math.random() * 0.4,
3.0 + math.random(0, 3)
)
end
bullet.active = false
enemy.hp = enemy.hp - 1
if enemy.hp <= 0 then
local points = ENEMY_DEFS[enemy.type].score
state.combo = state.combo + 1
state.combo_timer = COMBO_TIMEOUT
state.combo_bump_timer = 0.3
if state.combo > state.highest_combo then
state.highest_combo = state.combo
end
local earned = points * state.combo
state.score = state.score + earned
state.total_kills = state.total_kills + 1
spawn_explosion(enemy.x, enemy.y, enemy_color(enemy.type), 25 + enemy.type * 10)
grid_impulse(enemy.x, enemy.y, 120, 50 + enemy.type * 20)
shake(1 + enemy.type // 2, 3 + enemy.type)
play_random_effect(audio.explosion, { volume = 0.25 })
feedback.spawn_float_text(enemy.x, enemy.y - 10.0, string.format("+%d", earned), COLOR_YELLOW)
powerup.try_enemy_drop(enemy.type, enemy.x, enemy.y)
if state.combo >= 5 and not state.combo5_shown then
state.combo5_shown = true
feedback.show_popup("x5 COMBO!", COLOR_YELLOW, 3)
shake(4, 10)
end
if state.combo >= 10 and not state.combo10_shown then
state.combo10_shown = true
feedback.show_popup("x10 COMBO!", argb(255, 255, 200, 0), 3)
shake(6, 15)
end
if state.total_kills == 50 and not state.kills50_shown then
state.kills50_shown = true
feedback.show_popup("50 KILLS!", COLOR_GREEN, 3)
shake(3, 8)
end
if state.total_kills == 100 and not state.kills100_shown then
state.kills100_shown = true
feedback.show_popup("100 KILLS!", argb(255, 80, 255, 80), 3)
shake(5, 12)
end
if state.total_kills == 200 and not state.kills200_shown then
state.kills200_shown = true
feedback.show_popup("200 KILLS!", argb(255, 160, 60, 220), 3)
shake(7, 15)
end
if enemy.type == 3 then
shake(7, 30)
state.screen_shake_frames = 30
for k = 0, 2 do
local split_angle = (k * 120.0) * math.pi / 180.0
spawn_enemy(0, enemy.x + math.cos(split_angle) * 20.0,
enemy.y + math.sin(split_angle) * 20.0)
end
end
enemy.active = false
else
shake(1, 2)
end
break
end
end
end
end
if powerup.shield_active and state.player_alive then
for i = 1, MAX_ENEMIES do
local enemy = enemies[i]
if enemy.active then
for d = 0, 2 do
local dot_angle = powerup.shield_angle + d * (2.0 * math.pi / 3.0)
local dot_x = player.x + math.cos(dot_angle) * 50.0
local dot_y = player.y + math.sin(dot_angle) * 50.0
if distance(dot_x, dot_y, enemy.x, enemy.y) < enemy.r + 8.0 then
local points = ENEMY_DEFS[enemy.type].score
state.combo = state.combo + 1
state.combo_timer = COMBO_TIMEOUT
state.combo_bump_timer = 0.3
if state.combo > state.highest_combo then
state.highest_combo = state.combo
end
local earned = points * state.combo
state.score = state.score + earned
state.total_kills = state.total_kills + 1
spawn_explosion(enemy.x, enemy.y, enemy_color(enemy.type), 12)
grid_impulse(enemy.x, enemy.y, 60, 40)
play_random_effect(audio.explosion, { volume = 0.25 })
feedback.spawn_float_text(enemy.x, enemy.y - 10.0, string.format("+%d", earned),
argb(255, 255, 215, 0))
enemy.active = false
if enemy.type == 3 then
for k = 0, 2 do
local split_angle = k * 120.0 * math.pi / 180.0
spawn_enemy(0, dot_x + math.cos(split_angle) * 20.0, dot_y + math.sin(split_angle) * 20.0)
end
end
break
end
end
end
end
end
if not state.respawn_invincible then
for i = 1, MAX_ENEMIES do
local enemy = enemies[i]
if enemy.active and distance(player.x, player.y, enemy.x, enemy.y) < enemy.r + 8 then
handle_player_hit(enemy.type)
break
end
end
end
powerup.try_collect()
for i = 1, MAX_BULLETS do
local bullet = bullets[i]
if bullet.active then
for j = 1, 2 do
local bh = powerup.black_holes[j]
if bh.active and not bh.exploding and distance(bullet.x, bullet.y, bh.x, bh.y) < 30.0 then
for _ = 1, 3 do
local angle = math.atan(bh.y - bullet.y, bh.x - bullet.x)
+ (math.random(-30, 29) * math.pi) / 180.0
local speed = math.random(100, 179)
spawn_particle(
bullet.x,
bullet.y,
math.cos(angle) * speed,
math.sin(angle) * speed,
argb(255, 220, 150, 255),
0.3,
3.0
)
end
bh.absorbed = bh.absorbed + 1
bullet.active = false
break
end
end
end
end
if not state.respawn_invincible and state.player_alive then
for i = 1, 2 do
local bh = powerup.black_holes[i]
if bh.active and not bh.exploding then
local core_r = 15.0 + bh.absorbed * 2.0
if core_r > 35.0 then
core_r = 35.0
end
if distance(player.x, player.y, bh.x, bh.y) < core_r + 8.0 then
handle_player_hit(-1)
break
end
end
end
end
end
local function update_timers(dt)
if state.combo > 1 then
state.combo_timer = state.combo_timer - dt
if state.combo_timer <= 0 then
state.combo = 1
state.combo_timer = 0
end
end
if state.combo_bump_timer > 0 then
state.combo_bump_timer = state.combo_bump_timer - dt
if state.combo_bump_timer < 0 then
state.combo_bump_timer = 0
end
end
if state.respawn_invincible then
state.respawn_timer = state.respawn_timer - dt
if state.respawn_timer <= 0 then
state.respawn_invincible = false
state.respawn_timer = 0
end
end
end
function feedback.update(dt)
for i = 1, MAX_FLOAT_TEXTS do
local float_text = feedback.float_texts[i]
if float_text.life > 0 then
float_text.life = float_text.life - dt
float_text.y = float_text.y + float_text.vy * dt
if float_text.life < 0 then
float_text.life = 0
end
end
end
if feedback.popup.life > 0 then
feedback.popup.life = feedback.popup.life - dt
if feedback.popup.life < 0 then
feedback.popup.life = 0
end
end
for i = 1, MAX_TICKER do
local msg = feedback.ticker_msgs[i]
if msg.active then
msg.timer = msg.timer + dt
if msg.timer >= msg.life then
msg.active = false
end
end
end
if state.shake_frames > 0 then
local a = state.shake_amt * (state.shake_frames / 20.0)
state.shake_x = (math.random(-100, 99) / 100.0) * a
state.shake_y = (math.random(-100, 99) / 100.0) * a
state.shake_frames = state.shake_frames - 1
else
state.shake_amt = 0
state.shake_x = 0
state.shake_y = 0
end
if state.screen_shake_frames > 0 then
local t = state.screen_shake_frames / 30.0
state.screen_shake_y = math.sin(state.screen_shake_frames * 0.7) * 20.0 * t
state.screen_shake_frames = state.screen_shake_frames - 1
else
state.screen_shake_y = 0.0
end
end
function powerup.spawn(x, y, powerup_type, ability_type)
for i = 1, MAX_POWERUPS do
local entry = powerup.entries[i]
if not entry.active then
entry.x = x
entry.y = y
entry.r = 15.0
entry.life = 8.0
entry.pulse = 0.0
entry.active = true
entry.type = powerup_type or 0
entry.ability = ability_type or 0
return
end
end
end
function powerup.describe(powerup_type, ability_type)
if powerup_type == 0 then
return NUKE_DEF.name, NUKE_DEF.color, NUKE_DEF.glow, NUKE_DEF.outline
end
local def = ABILITY_DEFS[ability_type] or ABILITY_DEFS[0]
return def.name, def.color, def.glow, def.outline
end
function powerup.clear_ability()
powerup.energy_active = false
powerup.energy_timer = 0.0
powerup.ability_type = 0
powerup.homing_active = false
powerup.shield_active = false
powerup.slow_active = false
powerup.shield_angle = 0.0
end
function powerup.spawn_black_hole(x, y)
for i = 1, 2 do
local bh = powerup.black_holes[i]
if not bh.active then
bh.active = true
bh.x = x
bh.y = y
bh.radius = 100.0
bh.pull_strength = 120.0
bh.life = 8.0
bh.absorbed = 0
bh.pulse = 0.0
bh.exploding = false
bh.explode_timer = 0.0
return true
end
end
return false
end
function powerup.trigger_jack(total)
powerup.jack_timer = 0.0
powerup.jack_active = true
powerup.jack_spawn_timer = 0.0
powerup.jack_count = 0
powerup.jack_total = total
end
function powerup.activate_ability(ability_type)
local def = ABILITY_DEFS[ability_type] or ABILITY_DEFS[0]
powerup.ability_type = ability_type
powerup.energy_active = true
powerup.energy_timer = powerup.energy_duration
powerup.homing_active = ability_type == 1
powerup.shield_active = ability_type == 2
powerup.slow_active = ability_type == 3
powerup.shield_angle = 0.0
play_effect(audio.rise[ability_type + 1])
feedback.show_popup(def.popup, def.color, 3)
shake(3, 8)
end
function powerup.try_enemy_drop(enemy_type, x, y)
local base_drop = ({ 7, 10, 12, 17, 10 })[enemy_type + 1]
local active_enemies = 0
for i = 1, MAX_ENEMIES do
if enemies[i].active then
active_enemies = active_enemies + 1
end
end
local drop_scale = 1.0
if active_enemies > 15 then
drop_scale = drop_scale - (active_enemies - 15) / 50.0
if drop_scale < 0.3 then
drop_scale = 0.3
end
end
local drop_pct = math.floor(base_drop * drop_scale)
if drop_pct > 0 and math.random(0, 99) < drop_pct then
powerup.spawn(x, y, 1, math.random(0, 3))
end
end
function powerup.trigger_nuke()
feedback.show_popup("NUKE ACTIVATED!", COLOR_CYAN, 3)
shake(8, 20)
grid_impulse(player.x, player.y, 600, 400)
powerup.nuke_flash_alpha = 1.0
powerup.nuke_wave_radius = 0.0
powerup.nuke_wave_alpha = 1.0
powerup.nuke_fx_active = true
for i = 1, MAX_ENEMIES do
local enemy = enemies[i]
if enemy.active then
local earned = ENEMY_DEFS[enemy.type].score
state.score = state.score + earned
state.total_kills = state.total_kills + 1
spawn_explosion(enemy.x, enemy.y, enemy_color(enemy.type), 20 + enemy.type * 8)
feedback.spawn_float_text(enemy.x, enemy.y - 10.0, string.format("+%d", earned), COLOR_WHITE)
enemy.active = false
end
end
spawn_explosion(player.x, player.y, argb(255, 255, 255, 200), 50)
end
function powerup.update(dt)
for i = 1, MAX_POWERUPS do
local entry = powerup.entries[i]
if entry.active then
entry.life = entry.life - dt
entry.pulse = entry.pulse + dt * 5.0
if entry.life <= 0 then
entry.active = false
end
end
end
if powerup.energy_active then
powerup.energy_timer = powerup.energy_timer - dt
if powerup.energy_timer <= 0 then
powerup.clear_ability()
end
end
if powerup.shield_active then
powerup.shield_angle = powerup.shield_angle + 4.0 * math.pi * dt
end
if powerup.nuke_fx_active then
powerup.nuke_flash_alpha = powerup.nuke_flash_alpha - dt * 2.0
if powerup.nuke_flash_alpha < 0 then
powerup.nuke_flash_alpha = 0
end
powerup.nuke_wave_radius = powerup.nuke_wave_radius + dt * 1200.0
powerup.nuke_wave_alpha = powerup.nuke_wave_alpha - dt * 2.5
if powerup.nuke_wave_alpha < 0 then
powerup.nuke_wave_alpha = 0
end
if powerup.nuke_flash_alpha <= 0 and powerup.nuke_wave_alpha <= 0 then
powerup.nuke_fx_active = false
powerup.nuke_wave_radius = 0.0
end
end
end
function powerup.update_events(dt)
for i = 1, 2 do
local bh = powerup.black_holes[i]
if bh.active then
if bh.exploding then
bh.explode_timer = bh.explode_timer + dt
if bh.explode_timer >= 0.5 then
bh.active = false
end
else
bh.pulse = bh.pulse + dt * 3.0
bh.life = bh.life - dt
bh.radius = 100.0 + bh.absorbed * 8.0
if bh.radius > 180.0 then
bh.radius = 180.0
end
for j = 1, MAX_ENEMIES do
local enemy = enemies[j]
if enemy.active then
local dx = bh.x - enemy.x
local dy = bh.y - enemy.y
local dist = distance(enemy.x, enemy.y, bh.x, bh.y)
if dist < bh.radius and dist > 1.0 then
enemy.vx = enemy.vx + (dx / dist) * bh.pull_strength * dt
enemy.vy = enemy.vy + (dy / dist) * bh.pull_strength * dt
end
if dist < 20.0 then
bh.absorbed = bh.absorbed + 1
local points = ENEMY_DEFS[enemy.type].score
local earned = points * state.combo
state.score = state.score + earned
state.combo = state.combo + 1
state.combo_timer = COMBO_TIMEOUT
state.combo_bump_timer = 0.3
if state.combo > state.highest_combo then
state.highest_combo = state.combo
end
spawn_explosion(enemy.x, enemy.y, enemy_color(enemy.type), 8)
feedback.spawn_float_text(enemy.x, enemy.y - 10.0, string.format("+%d", earned), COLOR_WHITE)
enemy.active = false
end
end
end
if bh.absorbed >= 10 then
bh.exploding = true
bh.explode_timer = 0.0
shake(5, 12)
spawn_explosion(bh.x, bh.y, argb(255, 160, 60, 220), 30)
feedback.ticker_add(powerup.bh_phrases[math.random(1, #powerup.bh_phrases)], argb(255, 200, 60, 80))
grid_impulse(bh.x, bh.y, 300, 200)
for k = 0, 7 do
local angle = k * 45.0 * math.pi / 180.0
spawn_enemy(0, bh.x + math.cos(angle) * 20.0, bh.y + math.sin(angle) * 20.0)
end
elseif bh.life <= 0 then
spawn_explosion(bh.x, bh.y, argb(255, 100, 50, 150), 10)
bh.active = false
end
end
end
end
end
function powerup.try_collect()
if not state.player_alive then
return
end
for i = 1, MAX_POWERUPS do
local entry = powerup.entries[i]
if entry.active and distance(player.x, player.y, entry.x, entry.y) < entry.r + 12.0 then
if entry.type == 0 then
powerup.trigger_nuke()
play_effect(audio.powerup)
elseif entry.type == 1 then
powerup.activate_ability(entry.ability)
end
entry.active = false
end
end
end
function powerup.maybe_spawn(dt)
powerup.spawn_timer = powerup.spawn_timer + dt
if powerup.spawn_timer >= 14.0 + math.random(0, 79) / 10.0 then
powerup.spawn_timer = 0.0
local px = math.random() * MAP_W
local py = math.random() * MAP_H
if distance(px, py, player.x, player.y) > 200.0 then
powerup.spawn(px, py, 0, 0)
end
end
end
function powerup.draw()
for i = 1, MAX_POWERUPS do
local entry = powerup.entries[i]
if entry.active then
local pr = clamp(math.floor(entry.r * (math.sin(entry.pulse) * 0.2 + 1.0)), 12, 18)
local label_text, core, glow = powerup.describe(entry.type, entry.ability)
local beacon_phase1 = (entry.pulse * 0.8) % 2.0
local beacon_phase2 = (entry.pulse * 0.8 + 1.0) % 2.0
if beacon_phase1 < 1.5 then
local expand = beacon_phase1 / 1.5
local ring_r = math.floor(entry.r + expand * 30.0)
local ring_color = particle_alpha(core, 120.0 * (1.0 - expand))
draw_masked_ring(ring_color, ring_r, entry.x, entry.y)
end
if beacon_phase2 < 1.5 then
local expand = beacon_phase2 / 1.5
local ring_r = math.floor(entry.r + expand * 30.0)
local ring_color = particle_alpha(core, 120.0 * (1.0 - expand))
draw_masked_ring(ring_color, ring_r, entry.x, entry.y)
end
draw_masked_circle(glow, pr * 2.0, entry.x, entry.y)
batch:add(masked[sprites["powerup_shape_" .. pr]][core], entry.x, entry.y)
feedback.draw_shadowed_text(
entry.x - (#label_text * 3),
entry.y - pr - 10.0,
label_text,
8,
particle_alpha(core, 180.0),
"LT",
#label_text * 8,
10,
argb(80, 255, 255, 255)
)
if entry.life < 3.0 and math.floor(entry.pulse * 2.0) % 2 == 0 then
draw_masked_circle(argb(40, 255, 255, 255), pr * 2.0, entry.x, entry.y)
end
end
end
end
function powerup.draw_black_holes()
for i = 1, 2 do
local bh = powerup.black_holes[i]
if bh.active then
if bh.exploding then
local expand_progress = bh.explode_timer / 0.5
local wave_r = expand_progress * 400.0
local wave_alpha = 200.0 * (1.0 - expand_progress)
if wave_r > 0.01 then
draw_masked_ring(particle_alpha(argb(255, 160, 60, 220), wave_alpha), wave_r, bh.x, bh.y)
end
if wave_r > 0.02 then
draw_masked_ring(particle_alpha(argb(255, 220, 150, 255), wave_alpha * 0.5), wave_r * 0.5, bh.x, bh
.y)
end
else
local pulse_scale = math.sin(bh.pulse) * 0.1 + 1.0
local ring_r = bh.radius * pulse_scale
local core_r = 15.0 + bh.absorbed * 2.0
if core_r > 35.0 then
core_r = 35.0
end
draw_masked_ring(argb(40, 160, 60, 220), ring_r, bh.x, bh.y)
draw_masked_circle(argb(80, 160, 60, 220), core_r + 5.0, bh.x, bh.y)
batch:layer(core_r / 16.0, bh.x, bh.y)
batch:add(sprites.black_hole)
batch:layer()
local dot_alpha = 100.0 + bh.absorbed * 15.0
if dot_alpha > 255.0 then
dot_alpha = 255.0
end
draw_masked_circle(argb(quantize_byte(dot_alpha, 16), 220, 150, 255), 3.0, bh.x, bh.y)
end
end
end
end
function powerup.draw_world_fx()
if powerup.nuke_fx_active and powerup.nuke_wave_alpha > 0 then
local outer = particle_alpha(COLOR_CYAN, powerup.nuke_wave_alpha * 200.0)
local inner = argb(quantize_byte(powerup.nuke_wave_alpha * 100.0, 16), 200, 255, 255)
if powerup.nuke_wave_radius > 0.01 then
draw_masked_ring(outer, powerup.nuke_wave_radius, player.x, player.y)
end
if powerup.nuke_wave_radius > 0.02 then
draw_masked_ring(inner, powerup.nuke_wave_radius * 0.85, player.x, player.y)
end
end
end
function powerup.draw_screen_fx()
if powerup.nuke_fx_active and powerup.nuke_flash_alpha > 0 then
batch:add(quad {
width = W,
height = H,
color = argb(quantize_byte(powerup.nuke_flash_alpha * 220.0, 16), 255, 255, 255),
}, 0, 0)
end
end
local function update_spawner(dt)
if state.scene ~= "combat" or not state.player_alive then
return
end
state.game_time = state.game_time + dt
powerup.jack_timer = powerup.jack_timer + dt
local jack_cooldown = 45.0 - state.game_time * 0.01
if jack_cooldown < 30.0 then
jack_cooldown = 30.0
end
if not powerup.jack_active and powerup.jack_timer >= jack_cooldown then
powerup.jack_timer = 0.0
powerup.jack_active = true
powerup.jack_spawn_timer = 0.0
powerup.jack_count = 0
powerup.jack_total = 20 + math.floor(state.game_time / 30.0)
if powerup.jack_total > 40 then
powerup.jack_total = 40
end
feedback.show_popup("JACK INVASION!", argb(255, 255, 220, 50), 3)
shake(5, 10)
end
if powerup.jack_active then
powerup.jack_spawn_timer = powerup.jack_spawn_timer + dt
if powerup.jack_spawn_timer >= 0.05 and powerup.jack_count < powerup.jack_total then
powerup.jack_spawn_timer = 0.0
local side = math.random(0, 3)
local margin = 80.0
local x
local y
if side == 0 then
x = math.random() * MAP_W
y = -margin
elseif side == 1 then
x = math.random() * MAP_W
y = MAP_H + margin
elseif side == 2 then
x = -margin
y = math.random() * MAP_H
else
x = MAP_W + margin
y = math.random() * MAP_H
end
if spawn_enemy(0, x, y) then
powerup.jack_count = powerup.jack_count + 1
end
end
if powerup.jack_count >= powerup.jack_total then
powerup.jack_active = false
end
if powerup.jack_active then
return
end
end
local spawn_interval = 0.35 - state.game_time * 0.001
if spawn_interval < 0.10 then
spawn_interval = 0.10
end
local max_on_screen = 15 + math.floor(state.game_time / 10.0)
if max_on_screen > 60 then
max_on_screen = 60
end
local active_count = 0
for i = 1, MAX_ENEMIES do
if enemies[i].active then
active_count = active_count + 1
end
end
state.spawn_timer = state.spawn_timer + dt
if state.spawn_timer >= spawn_interval and active_count < max_on_screen then
state.spawn_timer = 0
local enemy_type
if state.game_time <= 15.0 then
enemy_type = 0
elseif state.game_time <= 30.0 then
enemy_type = (math.random(0, 2) == 0) and 2 or 0
elseif state.game_time <= 60.0 then
local roll = math.random(0, 9)
if roll < 5 then
enemy_type = 0
elseif roll < 8 then
enemy_type = 1
else
enemy_type = 2
end
elseif state.game_time <= 90.0 then
local roll = math.random(0, 9)
if roll < 4 then
enemy_type = 0
elseif roll < 6 then
enemy_type = 1
elseif roll < 8 then
enemy_type = 2
else
enemy_type = 4
end
else
local roll = math.random(0, 9)
if roll < 3 then
enemy_type = 0
elseif roll < 5 then
enemy_type = 1
elseif roll < 7 then
enemy_type = 2
elseif roll < 8 then
enemy_type = 3
else
enemy_type = 4
end
end
if spawn_from_edge(enemy_type) and state.game_time > 3.0 and enemy_type ~= 0 then
play_random_effect(audio.spawn)
end
end
powerup.maybe_spawn(dt)
powerup.bh_spawn_timer = powerup.bh_spawn_timer + dt
local bh_cooldown = 60.0 + math.random(0, 29)
local active_bh = 0
for i = 1, 2 do
if powerup.black_holes[i].active then
active_bh = active_bh + 1
end
end
if powerup.bh_spawn_timer >= bh_cooldown and active_bh < 2 and state.game_time > 60.0 then
powerup.bh_spawn_timer = 0.0
for _ = 1, 20 do
local x = 80.0 + math.random() * (MAP_W - 160.0)
local y = 80.0 + math.random() * (MAP_H - 160.0)
if distance(x, y, player.x, player.y) > 250.0 and powerup.spawn_black_hole(x, y) then
break
end
end
end
end
local function init_starfield()
for i = 1, MAX_STARS_FAR do
stars_far[i] = {
x = math.random() * MAP_W,
y = math.random() * MAP_H,
size = math.random(1, 2),
speed = math.random(5, 9),
color = random_star_color(30, 59),
}
end
for i = 1, MAX_STARS_NEAR do
stars_near[i] = {
x = math.random() * MAP_W,
y = math.random() * MAP_H,
size = math.random(2, 3),
speed = math.random(15, 24),
color = random_star_color(60, 99),
}
end
end
local function init_grid()
for row = 0, GRID_ROWS - 1 do
local nodes = {}
grid[row + 1] = nodes
for col = 0, GRID_COLS - 1 do
local x = col * GRID_SPACING
local y = row * GRID_SPACING
nodes[col + 1] = {
x = x,
y = y,
vx = 0,
vy = 0,
}
end
end
end
grid_impulse = function(cx, cy, radius, strength)
for row = 1, GRID_ROWS do
local nodes = grid[row]
for col = 1, GRID_COLS do
local node = nodes[col]
local dx = node.x - cx
local dy = node.y - cy
local dist_sq = dx * dx + dy * dy
if dist_sq > 0.01 and dist_sq < radius * radius then
local dist = math.sqrt(dist_sq)
local force = strength * (1.0 - dist / radius)
node.vx = node.vx + dx / dist * force
node.vy = node.vy + dy / dist * force
end
end
end
end
local function update_grid(dt)
for row = 0, GRID_ROWS - 1 do
local nodes = grid[row + 1]
for col = 0, GRID_COLS - 1 do
local node = nodes[col + 1]
local fx = col * GRID_SPACING
local fy = row * GRID_SPACING
node.vx = node.vx + (fx - node.x) * 12.0 * dt
node.vy = node.vy + (fy - node.y) * 12.0 * dt
local damping = 1.0 - 5.0 * dt
if damping < 0.85 then
damping = 0.85
end
node.vx = node.vx * damping
node.vy = node.vy * damping
node.x = node.x + node.vx * dt * 60.0
node.y = node.y + node.vy * dt * 60.0
end
end
end
local pools_initialized = false
local function ensure_runtime_pools()
if pools_initialized then
return
end
for i = 1, MAX_BULLETS do
bullets[i] = { active = false, homing = false }
end
for i = 1, MAX_ENEMIES do
enemies[i] = { active = false, type = 0 }
end
for i = 1, MAX_PARTICLES do
particles[i] = { life = 0, max_life = 0 }
end
for i = 1, MAX_FLOAT_TEXTS do
feedback.float_texts[i] = { life = 0, max_life = 0, text = "", color = COLOR_WHITE }
end
for i = 1, MAX_TICKER do
feedback.ticker_msgs[i] = { text = "", color = COLOR_WHITE, life = 0.0, timer = 0.0, active = false }
end
for i = 1, 2 do
powerup.black_holes[i] = {
active = false,
x = 0.0,
y = 0.0,
radius = 100.0,
pull_strength = 120.0,
life = 0.0,
absorbed = 0,
pulse = 0.0,
exploding = false,
explode_timer = 0.0,
}
end
for i = 1, MAX_POWERUPS do
powerup.entries[i] = {
x = 0.0,
y = 0.0,
r = 15.0,
life = 0.0,
pulse = 0.0,
active = false,
type = 0,
ability = 0,
}
end
pools_initialized = true
end
local function clear_runtime_state()
state.scene_time = 0.0
state.game_time = 0.0
state.score = 0
state.combo = 1
state.highest_combo = 1
state.total_kills = 0
state.combo_timer = 0.0
state.combo_bump_timer = 0.0
state.lives = MAX_LIVES
state.player_alive = true
state.respawn_invincible = false
state.respawn_timer = 0.0
state.killed_by_type = -1
state.shake_amt = 0.0
state.shake_frames = 0
state.shake_x = 0.0
state.shake_y = 0.0
state.screen_shake_y = 0.0
state.screen_shake_frames = 0
state.combo5_shown = false
state.combo10_shown = false
state.kills50_shown = false
state.kills100_shown = false
state.kills200_shown = false
state.lb_highlight = -1
state.spawn_timer = 0.0
state.shoot_timer = 0.0
state.trail_timer = 0.0
state.trail_count = 0
state.thrust_particle_timer = 0.0
input.mouse_left = false
input.mouse_pressed = false
input.mouse_released = false
input.ui_active_button = false
player.x = MAP_W * 0.5
player.y = MAP_H * 0.5
player.angle = 0
camera.x = clamp(player.x - W * 0.5, 0, MAP_W - W)
camera.y = clamp(player.y - H * 0.5, 0, MAP_H - H)
for i = 1, MAX_BULLETS do
local bullet = bullets[i]
bullet.active = false
bullet.homing = false
end
for i = 1, MAX_ENEMIES do
local enemy = enemies[i]
enemy.active = false
enemy.type = 0
end
for i = 1, MAX_PARTICLES do
local particle = particles[i]
particle.life = 0
particle.max_life = 0
end
for i = 1, MAX_FLOAT_TEXTS do
local float_text = feedback.float_texts[i]
float_text.life = 0
float_text.max_life = 0
float_text.text = ""
end
for i = 1, MAX_TICKER do
local msg = feedback.ticker_msgs[i]
msg.text = ""
msg.life = 0.0
msg.timer = 0.0
msg.active = false
end
feedback.popup.text = ""
feedback.popup.color = COLOR_WHITE
feedback.popup.life = 0.0
feedback.popup.max_life = 0.0
feedback.popup.scale = 1
powerup.spawn_timer = 0.0
powerup.jack_timer = 0.0
powerup.jack_active = false
powerup.jack_spawn_timer = 0.0
powerup.jack_count = 0
powerup.jack_total = 0
powerup.bh_spawn_timer = 0.0
powerup.nuke_flash_alpha = 0.0
powerup.nuke_wave_radius = 0.0
powerup.nuke_wave_alpha = 0.0
powerup.nuke_fx_active = false
powerup.clear_ability()
for i = 1, 2 do
local bh = powerup.black_holes[i]
bh.active = false
bh.life = 0.0
bh.absorbed = 0
bh.pulse = 0.0
bh.exploding = false
bh.explode_timer = 0.0
end
for i = 1, MAX_POWERUPS do
local entry = powerup.entries[i]
entry.life = 0.0
entry.pulse = 0.0
entry.active = false
entry.type = 0
entry.ability = 0
end
end
local function update_star_layer(stars, dt)
for i = 1, #stars do
local star = stars[i]
star.y = star.y + star.speed * dt
if star.y > MAP_H then
star.y = 0
star.x = math.random() * MAP_W
end
end
end
local function update_particles(dt)
for i = 1, MAX_PARTICLES do
local particle = particles[i]
if particle.life > 0 then
particle.life = particle.life - dt
particle.vx = particle.vx * (1.0 - 4.0 * dt)
particle.vy = particle.vy * (1.0 - 4.0 * dt)
particle.x = particle.x + particle.vx * dt
particle.y = particle.y + particle.vy * dt
if particle.life < 0 then
particle.life = 0
end
end
end
end
local function update_shooting(dt)
if state.scene ~= "combat" or not state.player_alive then
return
end
state.shoot_timer = state.shoot_timer + dt
local rate = powerup.energy_active and powerup.energy_shoot_rate or SHOOT_RATE
if input.mouse_left and state.shoot_timer >= rate then
state.shoot_timer = 0
if powerup.energy_active and powerup.ability_type == 0 then
for _, angle_offset in ipairs { -15.0, -7.0, 0.0, 7.0, 15.0 } do
spawn_bullet(player.angle + angle_offset * math.pi / 180.0, powerup.homing_active)
end
else
spawn_bullet(player.angle, powerup.homing_active)
end
play_random_effect(audio.shoot, { volume = 0.25 })
end
end
local function update_bullets(dt)
for i = 1, MAX_BULLETS do
local bullet = bullets[i]
if bullet.active then
if bullet.homing then
local best_dist = 999999.0
local nearest_x = 0.0
local nearest_y = 0.0
for j = 1, MAX_ENEMIES do
local enemy = enemies[j]
if enemy.active then
local dist = distance(bullet.x, bullet.y, enemy.x, enemy.y)
if dist < best_dist then
best_dist = dist
nearest_x = enemy.x
nearest_y = enemy.y
end
end
end
if best_dist < 500.0 then
local bullet_angle = math.atan(bullet.vy, bullet.vx)
local target_angle = math.atan(nearest_y - bullet.y, nearest_x - bullet.x)
local diff = target_angle - bullet_angle
while diff > math.pi do
diff = diff - math.pi * 2.0
end
while diff < -math.pi do
diff = diff + math.pi * 2.0
end
local steer = 3.0 * math.pi / 180.0 * dt * 60.0
if diff > 0 then
bullet_angle = bullet_angle + steer
elseif diff < 0 then
bullet_angle = bullet_angle - steer
end
bullet.vx = math.cos(bullet_angle) * BULLET_SPEED
bullet.vy = math.sin(bullet_angle) * BULLET_SPEED
end
end
bullet.x = bullet.x + bullet.vx * dt
bullet.y = bullet.y + bullet.vy * dt
if bullet.x < -20 or bullet.x > MAP_W + 20 or bullet.y < -20 or bullet.y > MAP_H + 20 then
bullet.active = false
end
end
end
end
local function world_scale()
local scale = math.min(window_w / W, window_h / H)
if scale <= 0 then
return 1, 0, 0
end
local offset_x = (window_w - W * scale) * 0.5
local offset_y = (window_h - H * scale) * 0.5
return scale, offset_x, offset_y
end
local function update_mouse_world()
local scale, offset_x, offset_y = world_scale()
local vx = (mouse_screen_x - offset_x) / scale
local vy = (mouse_screen_y - offset_y) / scale
mouse_world_x = vx + camera.x
mouse_world_y = vy + camera.y
end
local function record_trail()
if state.trail_count < TRAIL_LEN then
state.trail_count = state.trail_count + 1
end
for i = state.trail_count, 2, -1 do
trail_x[i] = trail_x[i - 1]
trail_y[i] = trail_y[i - 1]
trail_a[i] = trail_a[i - 1]
end
trail_x[1] = player.x
trail_y[1] = player.y
trail_a[1] = player.angle
end
local function update_player(dt)
---@type number
local dx = 0
---@type number
local dy = 0
if input.left then
dx = dx - 1
end
if input.right then
dx = dx + 1
end
if input.up then
dy = dy - 1
end
if input.down then
dy = dy + 1
end
local len_sq = dx * dx + dy * dy
local is_moving = len_sq > 0
if len_sq > 0 then
local inv_len = 1.0 / math.sqrt(len_sq)
dx = dx * inv_len
dy = dy * inv_len
player.x = player.x + dx * PLAYER_SPEED * dt
player.y = player.y + dy * PLAYER_SPEED * dt
end
player.x = clamp(player.x, 20, MAP_W - 20)
player.y = clamp(player.y, 20, MAP_H - 20)
for i = 1, 2 do
local bh = powerup.black_holes[i]
if bh.active and not bh.exploding then
local x = bh.x - player.x
local y = bh.y - player.y
local dist = distance(player.x, player.y, bh.x, bh.y)
if dist < bh.radius and dist > 1.0 then
player.x = player.x + (x / dist) * bh.pull_strength * 0.3 * dt
player.y = player.y + (y / dist) * bh.pull_strength * 0.3 * dt
player.x = clamp(player.x, 20, MAP_W - 20)
player.y = clamp(player.y, 20, MAP_H - 20)
end
end
end
local aim_dx = mouse_world_x - player.x
local aim_dy = mouse_world_y - player.y
if aim_dx * aim_dx + aim_dy * aim_dy > 25 then
player.angle = math.atan(aim_dy, aim_dx)
end
state.trail_timer = state.trail_timer + dt
while state.trail_timer >= TRAIL_INTERVAL do
state.trail_timer = state.trail_timer - TRAIL_INTERVAL
record_trail()
end
state.thrust_particle_timer = state.thrust_particle_timer + dt
if is_moving and not state.respawn_invincible and state.thrust_particle_timer >= 0.06 then
state.thrust_particle_timer = 0
local rear_x = player.x - math.cos(player.angle) * 14.0
local rear_y = player.y - math.sin(player.angle) * 14.0
local spread = (math.random(-30, 29) * math.pi) / 180.0
local speed = math.random(350, 599)
local thrust_angle = player.angle + math.pi + spread
local r = powerup.energy_active and 255 or math.random(150, 254)
local g = powerup.energy_active and 240 or ((math.random(0, 1) == 0) and 255 or 220)
local b = math.random(200, 254)
spawn_particle(
rear_x,
rear_y,
math.cos(thrust_angle) * speed,
math.sin(thrust_angle) * speed,
particle_color(r, g, b),
0.35 + math.random() * 0.2,
math.random(2, 3)
)
end
end
local function update_camera(dt)
local target_x = clamp(player.x - W * 0.5, 0, MAP_W - W)
local target_y = clamp(player.y - H * 0.5, 0, MAP_H - H)
local factor = 1.0 - (1.0 - CAMERA_LERP) ^ (dt * 60.0)
camera.x = camera.x + (target_x - camera.x) * factor
camera.y = camera.y + (target_y - camera.y) * factor
end
local function draw_grid_segment(ax, ay, bx, by, color)
local dx = bx - ax
local dy = by - ay
local len = math.sqrt(dx * dx + dy * dy)
if len < 0.5 then
return
end
local angle = math.atan(dy, dx)
local width = math.max(1, math.floor(len + 0.5))
batch:layer(1, angle, ax, ay)
batch:add(quad { width = width, height = 1, color = color }, 0, -0.5)
batch:layer()
end
local function draw_grid()
local first_col = math.max(0, math.floor(camera.x / GRID_SPACING) - 1)
local last_col = math.min(GRID_COLS - 1, math.floor((camera.x + W) / GRID_SPACING) + 1)
local first_row = math.max(0, math.floor(camera.y / GRID_SPACING) - 1)
local last_row = math.min(GRID_ROWS - 1, math.floor((camera.y + H) / GRID_SPACING) + 1)
for row = first_row, last_row do
for col = first_col, last_col do
local node = grid[row + 1][col + 1]
if col + 1 <= last_col and col + 1 < GRID_COLS then
local right = grid[row + 1][col + 2]
draw_grid_segment(node.x, node.y, right.x, right.y, COLOR_GRID)
end
if row + 1 <= last_row and row + 1 < GRID_ROWS then
local down = grid[row + 2][col + 1]
draw_grid_segment(node.x, node.y, down.x, down.y, COLOR_GRID)
end
end
end
end
local function draw_world_border()
batch:add(quad { width = MAP_W, height = 3, color = COLOR_BORDER }, 0, 0)
batch:add(quad { width = MAP_W, height = 3, color = COLOR_BORDER }, 0, MAP_H - 3)
batch:add(quad { width = 3, height = MAP_H, color = COLOR_BORDER }, 0, 0)
batch:add(quad { width = 3, height = MAP_H, color = COLOR_BORDER }, MAP_W - 3, 0)
end
local function draw_stars(stars)
for i = 1, #stars do
local star = stars[i]
if star.x >= camera.x - 8 and star.x <= camera.x + W + 8
and star.y >= camera.y - 8 and star.y <= camera.y + H + 8 then
batch:add(masked[sprites["star_" .. star.size]][star.color], star.x, star.y)
end
end
end
local function draw_player_trail()
for i = state.trail_count, 2, -1 do
local alpha_factor = 1.0 - (i - 1) / TRAIL_LEN
local scale = 0.7 + alpha_factor * 0.3
local color = particle_alpha(PLAYER_TRAIL, alpha_factor * 100.0)
batch:layer(scale, trail_a[i], trail_x[i], trail_y[i])
batch:add(masked[sprites.player_core_mask][color])
batch:layer()
end
end
local function draw_bullets()
for i = 1, MAX_BULLETS do
local bullet = bullets[i]
if bullet.active then
local core = bullet.homing and BULLET_HOMING_CORE or BULLET_CORE
local glow = bullet.homing and BULLET_HOMING_GLOW or BULLET_GLOW
draw_masked_circle(core, 3, bullet.x, bullet.y)
draw_masked_circle(glow, 6, bullet.x, bullet.y)
draw_masked_circle(glow, 2, bullet.x - bullet.vx * 0.008, bullet.y - bullet.vy * 0.008)
end
end
end
local function draw_enemies()
for i = 1, MAX_ENEMIES do
local enemy = enemies[i]
if enemy.active then
local def = ENEMY_DEFS[enemy.type]
local color = def.color
local glow = particle_alpha(color, 60)
if def.draw_rotated then
batch:layer(1, enemy.angle, enemy.x, enemy.y)
batch:add(masked[sprites[def.shape_sprite]][color])
batch:layer()
else
batch:add(masked[sprites[def.shape_sprite]][color], enemy.x, enemy.y)
end
draw_masked_circle(glow, enemy.r * def.glow_scale, enemy.x, enemy.y)
if def.core_color then
draw_masked_circle(def.core_color, enemy.r * def.core_scale, enemy.x, enemy.y)
end
end
end
end
local function draw_particles()
for i = 1, MAX_PARTICLES do
local particle = particles[i]
if particle.life > 0 then
local alpha = particle.life / particle.max_life
local size = particle.size * alpha
if size > 0.5 and size < 20 then
local px = particle.x
local py = particle.y
draw_masked_circle(particle.color, size, px, py)
if size > 1.5 and alpha > 0.2 then
draw_masked_circle(particle_alpha(particle.color, alpha * PARTICLE_GLOW_ALPHA), size * 2.5, px, py)
end
if alpha > 0.6 and size > 2 then
draw_masked_circle(argb(quantize_byte(alpha * PARTICLE_CORE_ALPHA, 16), 255, 255, 255), size * 1.3,
px, py)
end
end
end
end
end
local function add_text(x, y, text, size, color, align, width, height)
bitmapfont.draw_text(batch, masked, sprites.font_glyphs, x, y, text, size, color, align, width, height)
end
function feedback.draw_shadowed_text(x, y, text, size, color, align, width, height, shadow_color)
add_text(x + 1, y + 1, text, size, shadow_color or argb(100, 255, 255, 255), align, width, height)
add_text(x, y, text, size, color, align, width, height)
end
function feedback.screen_mouse()
local scale, offset_x, offset_y = world_scale()
return (mouse_screen_x - offset_x) / scale, (mouse_screen_y - offset_y) / scale
end
function feedback.button(x, y, width, height, text, color)
if width <= 0 or height <= 0 then
return false
end
local mx, my = feedback.screen_mouse()
local hovered = mx >= x and mx <= x + width and my >= y and my <= y + height
local id = string.format("%d:%d:%d:%d:%s", x, y, width, height, text)
if input.mouse_pressed and hovered then
input.ui_active_button = id
end
local pressed = input.ui_active_button == id and input.mouse_left and hovered
local face = color
if pressed then
face = feedback.ui_darken_rgb(color, 36)
elseif hovered then
face = feedback.ui_lighten_rgb(color, 46)
end
feedback.draw_bevel_rect(x, y, width, height, face, pressed)
local text_color = feedback.ui_button_text_color(face)
local shadow_color
if text_color == COLOR_WHITE then
shadow_color = argb(160, 0, 0, 0)
else
shadow_color = argb(112, 255, 255, 255)
end
local text_x = x
local text_y = y
if pressed then
text_x = text_x + 1
text_y = text_y + 1
end
add_text(text_x + 1, text_y + 1, text, 8, shadow_color, "CV", width, height)
add_text(text_x, text_y, text, 8, text_color, "CV", width, height)
local clicked = input.mouse_released and hovered and input.ui_active_button == id
if input.mouse_released and input.ui_active_button == id then
input.ui_active_button = false
end
return clicked
end
function feedback.draw_float_texts()
for i = 1, MAX_FLOAT_TEXTS do
local float_text = feedback.float_texts[i]
if float_text.life > 0 then
local elapsed = float_text.max_life - float_text.life
local hold_time = 0.5
local fade_time = float_text.max_life - hold_time
local alpha = 1.0
if elapsed >= hold_time then
alpha = 1.0 - (elapsed - hold_time) / fade_time
if alpha < 0 then
alpha = 0
end
end
local width = #float_text.text * 10 + 8
feedback.draw_shadowed_text(
float_text.x,
float_text.y,
float_text.text,
10,
particle_alpha(float_text.color, alpha * 255.0),
"LT",
width,
12,
argb(quantize_byte(alpha * 100.0, 16), 255, 255, 255)
)
end
end
end
local function draw_centered_text(text, y, size, color)
add_text(0, y, text, size, color, "CV", W, size)
end
function feedback.draw_popup()
if feedback.popup.life <= 0 then
return
end
local elapsed = feedback.popup.max_life - feedback.popup.life
local current_scale
if elapsed < POPUP_GROW_TIME then
current_scale = 1 + math.floor((elapsed / POPUP_GROW_TIME) * (feedback.popup.scale - 1))
if current_scale < 1 then
current_scale = 1
end
else
current_scale = feedback.popup.scale
end
local draw_color = feedback.popup.color
if feedback.popup.life < POPUP_FADE_TIME then
draw_color = particle_alpha(feedback.popup.color, feedback.popup.life / POPUP_FADE_TIME * 255.0)
end
local size = 8 * current_scale
feedback.draw_shadowed_text(0, H * 0.5 - 20, feedback.popup.text, size, draw_color, "CV", W, size + 8,
argb(100, 255, 255, 255))
end
function feedback.draw_ticker()
local slide_in_dur = 0.4
local slide_out_dur = 0.5
local y_offset = 35
for i = 1, MAX_TICKER do
local msg = feedback.ticker_msgs[i]
if msg.active then
local tw = #msg.text * 8
local y = y_offset + (i - 1) * 15
local x
if msg.timer < slide_in_dur then
local progress = msg.timer / slide_in_dur
x = W + 10 - progress * (W + 10 + tw + 20)
elseif msg.timer < msg.life - slide_out_dur then
x = 20
else
local progress = (msg.timer - (msg.life - slide_out_dur)) / slide_out_dur
x = 20 - progress * (tw + 20)
end
local draw_color
if msg.timer >= msg.life - slide_out_dur then
local alpha = 1.0 - (msg.timer - (msg.life - slide_out_dur)) / slide_out_dur
draw_color = particle_alpha(msg.color, alpha * 200.0)
else
draw_color = particle_alpha(msg.color, 200.0)
end
feedback.draw_shadowed_text(x, y, msg.text, 8, draw_color, "LT", tw + 8, 12, argb(100, 255, 255, 255))
end
end
end
local function draw_hud()
add_text(10, 10, string.format("SCORE: %d", state.score), 8, COLOR_WHITE, "LT", 220, 8)
add_text(W * 0.5 - 80, 8, string.format("BEST: %d", state.best_score), 8, COLOR_GOLD, "LT", 160, 8)
local minutes = math.floor(state.game_time / 60)
local seconds = math.floor(state.game_time) % 60
add_text(W - 100, 10, string.format("TIME %d:%02d", minutes, seconds), 8, COLOR_SKY_BLUE, "LT", 100, 8)
add_text(10, H - 20, string.format("LIVES: %d", state.lives), 8, COLOR_WHITE, "LT", 120, 8)
add_text(W - 60, H - 20, string.format("%.0f FPS", fps), 8, COLOR_WHITE, "LT", 60, 8)
if state.combo > 1 then
local base_size = 16 + (state.combo - 2) * 2
if base_size > 32 then
base_size = 32
end
local draw_size = base_size
if state.combo_bump_timer > 0 then
local bump_progress = state.combo_bump_timer / 0.3
local overshoot
if bump_progress > 0.7 then
local phase1 = (bump_progress - 0.7) / 0.3
overshoot = phase1 * (base_size * 0.25)
else
local phase2 = bump_progress / 0.7
overshoot = phase2 * (base_size * 0.25)
end
draw_size = base_size + math.floor(overshoot)
end
local digits = state.combo < 10 and 1 or (state.combo < 100 and 2 or 3)
local width = draw_size + digits * draw_size
add_text(W - width - 10, 30, string.format("x%d", state.combo), draw_size, COLOR_YELLOW, "LT", width,
draw_size + 8)
end
if powerup.energy_active then
local ability = ABILITY_DEFS[powerup.ability_type] or ABILITY_DEFS[0]
local bar_color = ability.color
local bar_outline = ability.outline
local ability_name = ability.name
local bar_w = 80
local bar_h = 6
local bar_x = W * 0.5 - bar_w * 0.5
local bar_y = 50
local ratio = powerup.energy_timer / powerup.energy_duration
batch:add(quad {
width = math.floor(bar_w * ratio + 0.5),
height = bar_h,
color = particle_alpha(bar_color, 200.0),
}, bar_x, bar_y)
batch:add(quad { width = bar_w, height = 1, color = bar_outline }, bar_x, bar_y)
batch:add(quad { width = bar_w, height = 1, color = bar_outline }, bar_x, bar_y + bar_h - 1)
batch:add(quad { width = 1, height = bar_h, color = bar_outline }, bar_x, bar_y)
batch:add(quad { width = 1, height = bar_h, color = bar_outline }, bar_x + bar_w - 1, bar_y)
feedback.draw_shadowed_text(W * 0.5 - #ability_name * 4, bar_y - 12, ability_name, 8, bar_color, "LT",
#ability_name * 8, 10, argb(80, 255, 255, 255))
end
end
local function draw_player()
if state.respawn_invincible and math.floor(state.total_time * 8) % 2 == 0 then
return
end
draw_player_trail()
draw_masked_circle(PLAYER_GLOW, 20, player.x, player.y)
if powerup.energy_active then
draw_masked_circle(argb(80, 255, 180, 60), 25, player.x, player.y)
end
batch:layer(1, player.angle, player.x, player.y)
batch:add(masked[sprites.player_outline_mask][PLAYER_OUTLINE])
batch:layer()
batch:layer(1, player.angle, player.x, player.y)
batch:add(masked[sprites.player_core_mask][PLAYER_CORE])
batch:layer()
draw_masked_circle(
ENGINE_GLOW,
4,
player.x - math.cos(player.angle) * 12.0,
player.y - math.sin(player.angle) * 12.0
)
if powerup.shield_active then
draw_masked_ring(argb(100, 255, 215, 0), 50.0, player.x, player.y)
for d = 0, 2 do
local dot_angle = powerup.shield_angle + d * (2.0 * math.pi / 3.0)
local dot_x = player.x + math.cos(dot_angle) * 50.0
local dot_y = player.y + math.sin(dot_angle) * 50.0
draw_masked_circle(argb(255, 255, 215, 0), 8, dot_x, dot_y)
draw_masked_circle(argb(80, 255, 215, 0), 12, dot_x, dot_y)
end
end
if powerup.slow_active then
local slow_pulse = math.sin(state.total_time * 3.0) * 0.1 + 1.0
local slow_r = 180.0 * slow_pulse
batch:layer(slow_pulse, player.x, player.y)
batch:add(masked[sprites.slow_ring_mask][argb(60, 80, 180, 255)])
batch:layer()
batch:layer((slow_r - 5.0) / 180.0, player.x, player.y)
batch:add(masked[sprites.slow_ring_mask][argb(30, 120, 220, 255)])
batch:layer()
end
end
local function draw_backdrop(include_border)
batch:add(quad { width = MAP_W, height = MAP_H, color = COLOR_BLACK }, 0, 0)
draw_stars(stars_far)
draw_stars(stars_near)
draw_grid()
if include_border then
draw_world_border()
end
end
local function draw_world()
draw_backdrop(true)
draw_bullets()
powerup.draw_black_holes()
draw_enemies()
if state.player_alive then
draw_player()
end
powerup.draw()
draw_particles()
feedback.draw_float_texts()
end
local function draw_game_over()
draw_centered_text("GAME OVER", H // 2 - 70, 24, feedback.menu_colors.red)
draw_centered_text(string.format("FINAL SCORE: %d", state.score), H // 2 - 10, 8, COLOR_WHITE)
local minutes = math.floor(state.game_time / 60)
local seconds = math.floor(state.game_time) % 60
draw_centered_text(string.format("SURVIVED: %d:%02d", minutes, seconds), H // 2 + 15, 8, COLOR_WHITE)
draw_centered_text(string.format("TOTAL KILLS: %d", state.total_kills), H // 2 + 40, 8, COLOR_WHITE)
draw_centered_text(string.format("MAX COMBO: x%d", state.highest_combo), H // 2 + 65, 8, COLOR_YELLOW)
if state.killed_by_type >= 0 then
local def = assert(ENEMY_DEFS[state.killed_by_type])
local kill_text = "KILLED BY: " .. def.name
local kill_color = def.color
draw_centered_text(kill_text, H // 2 + 90, 8, kill_color)
local icon_x = W * 0.5 - #kill_text * 4 - 20
local icon_y = H // 2 + 93
batch:layer(def.icon_scale, icon_x, icon_y)
batch:add(masked[sprites[def.shape_sprite]][kill_color])
batch:layer()
end
if state.scene_time > 2.0 then
if feedback.button(W * 0.5 - 80, H // 2 + 115, 160, 30, "CONTINUE", feedback.menu_colors.red) then
input.emit_ui_action "confirm"
end
end
end
function feedback.draw_title_scene()
local pulse = math.sin(state.total_time * 2.0) * 0.3 + 0.7
local title_color = argb(math.floor(pulse * 255.0), 0, 255, 255)
draw_centered_text("GEOMETRY WARS", 200, 24, title_color)
if feedback.button(W * 0.5 - 70, 260, 140, 30, "START", COLOR_CYAN) then
input.emit_ui_action "confirm"
end
draw_centered_text("WASD Move | Mouse Aim + Shoot", 310, 8, feedback.menu_colors.dark_gray)
draw_centered_text("L Leaderboard | Enter Start", 330, 8, feedback.menu_colors.dark_gray)
add_text(
W * 0.5 - 130,
H - 50,
string.format("BEST: %d | TIME %d:%02d", state.best_score, math.floor(state.best_time / 60),
math.floor(state.best_time) % 60),
8,
feedback.menu_colors.dark_gray,
"LT",
260,
8
)
draw_centered_text("Powered by Soluna", H - 25, 8, argb(100, 100, 100, 100))
end
function feedback.draw_leaderboard_scene()
draw_centered_text("LEADERBOARD", 30, 24, COLOR_CYAN)
add_text(60, 80, "RANK", 8, feedback.menu_colors.light_gray, "LT", 60, 8)
add_text(120, 80, "SCORE", 8, feedback.menu_colors.light_gray, "LT", 80, 8)
add_text(260, 80, "KILLS", 8, feedback.menu_colors.light_gray, "LT", 80, 8)
add_text(380, 80, "TIME", 8, feedback.menu_colors.light_gray, "LT", 80, 8)
local y = 105
for i = 1, 10 do
local row = state.leaderboard[i]
if row.score > 0 then
local row_color = i == state.lb_highlight and COLOR_GOLD or COLOR_WHITE
add_text(60, y, string.format("#%d", i), 8, row_color, "LT", 40, 12)
add_text(120, y, string.format("%d", row.score), 8, row_color, "LT", 120, 12)
add_text(260, y, string.format("%d", row.kills), 8, row_color, "LT", 80, 12)
add_text(380, y, string.format("%d:%02d", math.floor(row.time / 60), math.floor(row.time) % 60), 8, row_color,
"LT", 80, 12)
y = y + 22
end
end
if state.scene_time > 1.0 then
if feedback.button(W * 0.5 - 80, H - 45, 160, 30, "CONTINUE", COLOR_CYAN) then
input.emit_ui_action "confirm"
end
end
end
do
local function update_combat_scene(dt)
update_star_layer(stars_far, dt)
update_star_layer(stars_near, dt)
update_mouse_world()
update_player(dt)
update_shooting(dt)
update_bullets(dt)
update_enemies(dt)
update_collisions()
powerup.update_events(dt)
update_particles(dt)
update_grid(dt)
update_timers(dt)
powerup.update(dt)
feedback.update(dt)
update_spawner(dt)
update_camera(dt)
end
local function update_title_scene(dt)
state.center_camera()
update_grid(dt)
end
local function update_death_scene(dt)
update_star_layer(stars_far, dt)
update_star_layer(stars_near, dt)
update_particles(dt)
update_grid(dt)
update_timers(dt)
powerup.update(dt)
feedback.update(dt)
if not audio.death_sound_played and state.scene_time >= 0.5 and state.scene_time < 1.0 then
audio.death_sound_played = true
play_effect(audio.death)
end
end
local function update_game_over_scene(dt)
update_star_layer(stars_far, dt)
update_star_layer(stars_near, dt)
update_particles(dt)
update_grid(dt)
update_timers(dt)
powerup.update(dt)
feedback.update(dt)
end
local function update_leaderboard_scene(dt)
update_star_layer(stars_far, dt)
update_star_layer(stars_near, dt)
state.center_camera()
update_grid(dt)
end
local function draw_loading_overlay()
local ratio = 0.0
if asset_progress.total > 0 then
ratio = clamp(asset_progress.done / asset_progress.total, 0.0, 1.0)
end
local bar_w = 360
local bar_h = 10
local bar_x = (W - bar_w) // 2
local bar_y = H // 2 + 48
local fill_w = math.floor((bar_w - 4) * ratio + 0.5)
local pulse = math.floor((math.sin(state.total_time * 4.0) * 0.5 + 0.5) * 80 + 80)
local percent = math.floor(ratio * 100.0 + 0.5)
local current = asset_progress.current
if current == "" then
current = "initializing"
end
batch:add(quad { width = W, height = H, color = COLOR_BLACK }, 0, 0)
batch:add(quad { width = 220, height = 2, color = argb(pulse, 0, 255, 255) }, W // 2 - 110, H // 2 - 22)
batch:add(quad { width = 160, height = 2, color = argb(120, 255, 255, 255) }, W // 2 - 80, H // 2 - 8)
bitmapfont.draw_text(batch, masked, loading_sprites.font_glyphs, 0, H // 2 - 82, "GEOMETRY WARS", 16, COLOR_CYAN,
"CV", W, 20)
bitmapfont.draw_text(batch, masked, loading_sprites.font_glyphs, 0, H // 2 + 12, "LOADING SPRITES", 8,
COLOR_WHITE, "CV", W, 10)
bitmapfont.draw_text(batch, masked, loading_sprites.font_glyphs, 0, H // 2 + 26,
string.format("%3d%% %s", percent, current), 8, argb(180, 180, 220, 255), "CV", W, 10)
batch:add(quad { width = bar_w, height = bar_h, color = argb(120, 20, 40, 60) }, bar_x, bar_y)
if fill_w > 0 then
batch:add(quad { width = fill_w, height = bar_h - 4, color = argb(220, 0, 255, 255) }, bar_x + 2, bar_y + 2)
end
batch:add(quad { width = bar_w, height = 1, color = COLOR_CYAN }, bar_x, bar_y)
batch:add(quad { width = bar_w, height = 1, color = COLOR_CYAN }, bar_x, bar_y + bar_h - 1)
batch:add(quad { width = 1, height = bar_h, color = COLOR_CYAN }, bar_x, bar_y)
batch:add(quad { width = 1, height = bar_h, color = COLOR_CYAN }, bar_x + bar_w - 1, bar_y)
end
local function draw_title_world()
draw_backdrop(false)
end
local function draw_combat_overlay()
feedback.draw_popup()
feedback.draw_ticker()
draw_hud()
batch:layer(-camera.x + state.shake_x, -camera.y + state.shake_y)
powerup.draw_world_fx()
batch:layer()
powerup.draw_screen_fx()
end
local function draw_death_world()
draw_backdrop(true)
draw_particles()
end
local function draw_death_overlay()
if state.scene_time < 0.5 then
local alpha = clamp((0.5 - state.scene_time) * 400.0, 0, 255)
batch:add(quad { width = W, height = H, color = argb(math.floor(alpha + 0.5), 255, 255, 255) }, 0, 0)
end
end
local function draw_game_over_world()
draw_backdrop(false)
draw_particles()
end
local function draw_game_over_overlay()
feedback.draw_popup()
feedback.draw_ticker()
draw_game_over()
end
local function confirm_requested()
return input.consume_ui_action "confirm"
or input.consume_key_press(KEY.ENTER)
or input.consume_key_press(KEY.SPACE)
end
local function handle_combat_debug_keys()
if input.consume_key_press(KEY.DEBUG_NUKE) then
powerup.trigger_nuke()
return
end
if input.consume_key_press(KEY.DEBUG_JACK) then
powerup.trigger_jack(25)
feedback.ticker_add(powerup.jack_phrases[math.random(1, #powerup.jack_phrases)], argb(255, 255, 80, 60))
shake(5, 10)
return
end
if input.consume_key_press(KEY.DEBUG_BLACK_HOLE) then
powerup.spawn_black_hole(player.x + 200.0, player.y)
end
end
local function reset()
ensure_runtime_pools()
clear_runtime_state()
init_starfield()
init_grid()
return "combat"
end
local game = {}
game.reset = reset
function game.loading()
state.set_scene_hooks(nil, draw_loading_overlay)
start_sprite_loading()
while true do
if asset_progress.error then
error(asset_progress.error)
end
if asset_progress.ready then
state.load_records()
reset()
return "title"
end
flow.sleep(0)
end
end
function game.title()
state.set_scene_hooks(draw_title_world, feedback.draw_title_scene)
while true do
if confirm_requested() then
return "reset"
end
if input.consume_key_press(KEY.LEADERBOARD) then
state.lb_highlight = -1
return "leaderboard"
end
update_title_scene(state.frame_dt)
flow.sleep(0)
end
end
function game.combat()
state.set_scene_hooks(draw_world, draw_combat_overlay)
while true do
handle_combat_debug_keys()
update_combat_scene(state.frame_dt)
if not state.player_alive and state.lives <= 0 then
return "death"
end
flow.sleep(0)
end
end
function game.death()
state.set_scene_hooks(draw_death_world, draw_death_overlay)
while true do
update_death_scene(state.frame_dt)
if state.scene_time > 1.5 then
state.lb_highlight = state.insert_leaderboard(state.score, state.game_time, state.total_kills,
state.highest_combo)
if state.score > state.best_score then
state.best_score = state.score
end
if state.game_time > state.best_time then
state.best_time = state.game_time
end
state.save_records()
return "over"
end
flow.sleep(0)
end
end
function game.over()
state.set_scene_hooks(draw_game_over_world, draw_game_over_overlay)
while true do
if state.scene_time > 2.0 and confirm_requested() then
return "leaderboard"
end
update_game_over_scene(state.frame_dt)
flow.sleep(0)
end
end
function game.leaderboard()
state.set_scene_hooks(draw_title_world, feedback.draw_leaderboard_scene)
while true do
if state.scene_time > 1.0 and confirm_requested() then
return "title"
end
update_leaderboard_scene(state.frame_dt)
flow.sleep(0)
end
end
flow.load(game)
flow.enter "loading"
end
local callback = {}
function callback.frame()
local _, now = ltask.now()
local dt = 1.0 / 60.0
if last_tick ~= nil then
dt = clamp((now - last_tick) / 100.0, 1.0 / 240.0, 0.05)
end
last_tick = now
if fps_clock == nil then
fps_clock = now
end
fps_frames = fps_frames + 1
local fps_elapsed = (now - fps_clock) / 100.0
if fps_elapsed >= 0.25 then
fps = fps_frames / fps_elapsed
fps_frames = 0
fps_clock = now
end
state.total_time = state.total_time + dt
state.scene_time = state.scene_time + dt
state.frame_dt = dt
local current_scene = flow.update()
if current_scene ~= nil then
state.sync_scene(current_scene)
end
view.begin(batch)
batch:add(quad { width = W, height = H, color = COLOR_BLACK }, 0, 0)
batch:layer(0, state.screen_shake_y)
batch:layer(-camera.x + state.shake_x, -camera.y + state.shake_y)
if state.scene_draw_world then
state.scene_draw_world()
end
batch:layer()
if state.scene_draw_overlay then
state.scene_draw_overlay()
end
batch:layer()
view.finish(batch)
input.mouse_pressed = false
input.mouse_released = false
input.clear_key_presses()
end
function callback.mouse_move(x, y)
mouse_screen_x = x
mouse_screen_y = y
end
function callback.mouse_button(button, key_state)
if button == 0 then
input.mouse_left = key_state == KEY.PRESS
if key_state == KEY.PRESS then
input.mouse_pressed = true
elseif key_state == KEY.RELEASE then
input.mouse_released = true
end
end
end
function callback.key(keycode, key_state)
local pressed = key_state == KEY.PRESS
if pressed and keycode == KEY.ESCAPE then
app.quit()
return
end
if pressed then
input.key_pressed[keycode] = true
end
if keycode == KEY.LEFT or keycode == KEY.A then
input.left = pressed
elseif keycode == KEY.RIGHT or keycode == KEY.D then
input.right = pressed
elseif keycode == KEY.UP or keycode == KEY.W then
input.up = pressed
elseif keycode == KEY.DOWN or keycode == KEY.S then
input.down = pressed
end
end
function callback.window_resize(w, h)
window_w = w
window_h = h
view.resize(w, h)
end
return callback