commit bc88613e07950d6bfc3e5fd95416acdb0c19fa74 Author: 28allday Date: Mon Apr 13 12:58:03 2026 +0100 Asteroids: complete Love2D game with Omarchy integration Faithful recreation of Atari Asteroids (1979) with vector wireframe aesthetic. Auto-detects Omarchy system theme and font on launch. Features: - Inertia physics (zero friction), rotate/thrust/fire/hyperspace controls - 3 asteroid sizes that split on destroy (large→medium→small) - Large and small UFO saucers with AI (random vs aimed shooting) - Screen wrapping for ship/asteroids/saucers, bullets expire at edges - Ship death fragments, explosion particles - Iconic heartbeat that speeds up as wave clears - Wave progression (4→11 asteroids, speed ramps) - 3 lives, extra life every 10k points - Persistent high scores with 3-letter initial entry - Procedural sound effects (beat, thrust, fire, explosions, saucer drone) - Full-screen scaling, system font detection Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/audio/sounds.lua b/audio/sounds.lua new file mode 100644 index 0000000..eaa10a9 --- /dev/null +++ b/audio/sounds.lua @@ -0,0 +1,123 @@ +local Sounds = {} + +local sources = {} +local SAMPLE_RATE = 44100 + +local function makeSoundData(duration, generator) + local samples = math.floor(SAMPLE_RATE * duration) + local sd = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 1) + for i = 0, samples - 1 do + local t = i / SAMPLE_RATE + local p = i / samples + local val = generator(t, p) + sd:setSample(i, math.max(-1, math.min(1, val))) + end + return sd +end + +local function makeSource(sd) + return love.audio.newSource(sd, "static") +end + +-- Sound generators + +local function genBeat1(t, p) + local env = (1 - p) ^ 4 + return math.sin(2 * math.pi * 80 * t) * env * 0.5 +end + +local function genBeat2(t, p) + local env = (1 - p) ^ 4 + return math.sin(2 * math.pi * 100 * t) * env * 0.5 +end + +local function genThrust(t, p) + local noise = (math.random() * 2 - 1) * 0.3 + local low = math.sin(2 * math.pi * 40 * t) * 0.2 + return (noise + low) * 0.4 +end + +local function genFire(t, p) + local freq = 1500 - p * 1100 + local env = (1 - p) ^ 2 + return math.sin(2 * math.pi * freq * t) * env * 0.35 +end + +local function genExplodeLarge(t, p) + local env = (1 - p) ^ 1.5 + local sine = math.sin(2 * math.pi * (50 + 30 * (1-p)) * t) * 0.5 + local noise = (math.random() * 2 - 1) * 0.5 + return (sine + noise) * env * 0.5 +end + +local function genExplodeMedium(t, p) + local env = (1 - p) ^ 2 + local sine = math.sin(2 * math.pi * (80 + 40 * (1-p)) * t) * 0.4 + local noise = (math.random() * 2 - 1) * 0.4 + return (sine + noise) * env * 0.45 +end + +local function genExplodeSmall(t, p) + local env = (1 - p) ^ 3 + local sine = math.sin(2 * math.pi * 150 * t) * 0.3 + local noise = (math.random() * 2 - 1) * 0.5 + return (sine + noise) * env * 0.4 +end + +local function genSaucerLarge(t, p) + local freq = 200 + math.sin(2 * math.pi * 5 * t) * 50 + return math.sin(2 * math.pi * freq * t) * 0.25 +end + +local function genSaucerSmall(t, p) + local freq = 350 + math.sin(2 * math.pi * 8 * t) * 80 + return math.sin(2 * math.pi * freq * t) * 0.25 +end + +local function genExtraLife(t, p) + local freq + if p < 0.33 then freq = 400 + elseif p < 0.66 then freq = 600 + else freq = 800 end + local env = 0.7 + if p < 0.05 then env = p / 0.05 * 0.7 end + if p > 0.9 then env = (1-p) / 0.1 * 0.7 end + return math.sin(2 * math.pi * freq * t) * env * 0.35 +end + +local function genShipExplode(t, p) + local freq = 500 * (1 - p * 0.7) + local env = (1 - p) ^ 0.8 + local sine = math.sin(2 * math.pi * freq * t) * 0.4 + local noise = (math.random() * 2 - 1) * 0.3 * p + local pulse = 0.7 + math.sin(2 * math.pi * 4 * t) * 0.3 + return (sine + noise) * env * pulse * 0.5 +end + +function Sounds.init() + sources = {} + local defs = { + beat1 = {0.08, genBeat1}, + beat2 = {0.08, genBeat2}, + thrust = {0.3, genThrust}, + fire = {0.05, genFire}, + explode_large = {0.5, genExplodeLarge}, + explode_medium = {0.3, genExplodeMedium}, + explode_small = {0.15, genExplodeSmall}, + saucer_large = {0.4, genSaucerLarge}, + saucer_small = {0.4, genSaucerSmall}, + extra_life = {0.3, genExtraLife}, + ship_explode = {1.0, genShipExplode}, + } + for name, def in pairs(defs) do + sources[name] = makeSource(makeSoundData(def[1], def[2])) + end +end + +function Sounds.play(name) + local src = sources[name] + if not src then return end + src:clone():play() +end + +return Sounds diff --git a/conf.lua b/conf.lua new file mode 100644 index 0000000..c5334b3 --- /dev/null +++ b/conf.lua @@ -0,0 +1,11 @@ +function love.conf(t) + t.window.title = "ASTEROIDS" + t.window.width = 1024 + t.window.height = 768 + t.window.resizable = true + t.window.vsync = 1 + t.window.fullscreen = false + t.window.fullscreentype = "desktop" + t.window.minwidth = 512 + t.window.minheight = 384 +end diff --git a/data/waves.lua b/data/waves.lua new file mode 100644 index 0000000..5e2b894 --- /dev/null +++ b/data/waves.lua @@ -0,0 +1,13 @@ +local Waves = {} + +function Waves.get(wave) + local counts = {4, 6, 8, 10, 11} + local count = counts[math.min(wave, #counts)] + local speedMult = 1.0 + (math.min(wave, 11) - 1) * 0.08 + return { + asteroidCount = count, + speedMultiplier = speedMult, + } +end + +return Waves diff --git a/game/asteroids.lua b/game/asteroids.lua new file mode 100644 index 0000000..1a2c360 --- /dev/null +++ b/game/asteroids.lua @@ -0,0 +1,166 @@ +local World = require("game.world") +local Palette = require("rendering.palette") + +local Asteroids = {} + +local active = {} +local shapes = { large = {}, medium = {}, small = {} } + +local SIZES = { + large = { radius = 45, minSpeed = 40, maxSpeed = 80, points = 20 }, + medium = { radius = 22, minSpeed = 60, maxSpeed = 120, points = 50 }, + small = { radius = 11, minSpeed = 80, maxSpeed = 160, points = 100 }, +} + +local NUM_VARIANTS = 5 +local NUM_VERTICES = 12 + +local function generateShape(baseRadius) + local verts = {} + for i = 1, NUM_VERTICES do + local angle = (i - 1) / NUM_VERTICES * math.pi * 2 + angle = angle + (math.random() - 0.5) * 0.3 + local r = baseRadius * (0.7 + math.random() * 0.6) + table.insert(verts, {math.cos(angle) * r, math.sin(angle) * r}) + end + return verts +end + +function Asteroids.init() + for size, info in pairs(SIZES) do + shapes[size] = {} + for i = 1, NUM_VARIANTS do + shapes[size][i] = generateShape(info.radius) + end + end + active = {} +end + +function Asteroids.spawnWave(wave) + local Waves = require("data.waves") + local config = Waves.get(wave) + local count = config.asteroidCount + local speedMult = config.speedMultiplier + + for i = 1, count do + -- Spawn at edges, not near centre + local x, y + repeat + x = math.random(0, World.GAME_W) + y = math.random(0, World.GAME_H) + until math.abs(x - World.GAME_W/2) > 150 or math.abs(y - World.GAME_H/2) > 150 + + local angle = math.random() * math.pi * 2 + local speed = (SIZES.large.minSpeed + math.random() * (SIZES.large.maxSpeed - SIZES.large.minSpeed)) * speedMult + + table.insert(active, { + x = x, y = y, + vx = math.cos(angle) * speed, + vy = math.sin(angle) * speed, + rot = 0, + rotSpeed = (math.random() - 0.5) * 3, + size = "large", + shapeIdx = math.random(1, NUM_VARIANTS), + radius = SIZES.large.radius, + }) + end +end + +function Asteroids.update(dt) + for _, a in ipairs(active) do + a.x = World.wrapX(a.x + a.vx * dt) + a.y = World.wrapY(a.y + a.vy * dt) + a.rot = a.rot + a.rotSpeed * dt + end +end + +function Asteroids.destroy(idx) + local a = active[idx] + if not a then return nil end + + local result = { + x = a.x, y = a.y, + size = a.size, + points = SIZES[a.size].points, + } + + -- Spawn children + local childSize = nil + if a.size == "large" then childSize = "medium" + elseif a.size == "medium" then childSize = "small" + end + + if childSize then + local info = SIZES[childSize] + for c = 1, 2 do + local angle = math.random() * math.pi * 2 + local speed = info.minSpeed + math.random() * (info.maxSpeed - info.minSpeed) + table.insert(active, { + x = a.x, y = a.y, + vx = math.cos(angle) * speed, + vy = math.sin(angle) * speed, + rot = 0, + rotSpeed = (math.random() - 0.5) * 3, + size = childSize, + shapeIdx = math.random(1, NUM_VARIANTS), + radius = info.radius, + }) + end + end + + table.remove(active, idx) + return result +end + +function Asteroids.count() + return #active +end + +function Asteroids.getAll() + return active +end + +function Asteroids.clear() + active = {} +end + +local function drawAsteroidAt(a, ox, oy) + local shape = shapes[a.size][a.shapeIdx] + if not shape then return end + + local pts = {} + local cos_r = math.cos(a.rot) + local sin_r = math.sin(a.rot) + + for _, v in ipairs(shape) do + local rx = v[1] * cos_r - v[2] * sin_r + ox + local ry = v[1] * sin_r + v[2] * cos_r + oy + table.insert(pts, rx) + table.insert(pts, ry) + end + -- Close the loop + table.insert(pts, pts[1]) + table.insert(pts, pts[2]) + love.graphics.line(pts) +end + +function Asteroids.draw() + local p = Palette.get() + local lw = 1 / World.scale + + love.graphics.setColor(p.asteroid) + love.graphics.setLineWidth(lw * 1.5) + + local W, H = World.GAME_W, World.GAME_H + + for _, a in ipairs(active) do + drawAsteroidAt(a, a.x, a.y) + -- Wrap ghosts + if a.x < a.radius then drawAsteroidAt(a, a.x + W, a.y) end + if a.x > W - a.radius then drawAsteroidAt(a, a.x - W, a.y) end + if a.y < a.radius then drawAsteroidAt(a, a.x, a.y + H) end + if a.y > H - a.radius then drawAsteroidAt(a, a.x, a.y - H) end + end +end + +return Asteroids diff --git a/game/bullets.lua b/game/bullets.lua new file mode 100644 index 0000000..b2824b1 --- /dev/null +++ b/game/bullets.lua @@ -0,0 +1,72 @@ +local World = require("game.world") +local Palette = require("rendering.palette") + +local Bullets = {} + +local active = {} +local BULLET_SPEED = 600 +local BULLET_LIFETIME = 0.667 +local MAX_PLAYER_BULLETS = 4 + +function Bullets.fire(x, y, angle, shipVx, shipVy, owner) + if owner == "player" then + local count = 0 + for _, b in ipairs(active) do + if b.owner == "player" then count = count + 1 end + end + if count >= MAX_PLAYER_BULLETS then return false end + end + + table.insert(active, { + x = x, y = y, + vx = shipVx + math.cos(angle) * BULLET_SPEED, + vy = shipVy + math.sin(angle) * BULLET_SPEED, + timer = BULLET_LIFETIME, + owner = owner or "player", + }) + return true +end + +function Bullets.update(dt) + for i = #active, 1, -1 do + local b = active[i] + b.x = b.x + b.vx * dt + b.y = b.y + b.vy * dt + b.timer = b.timer - dt + + -- Bullets don't wrap — remove when out of bounds or expired + if b.timer <= 0 or b.x < -5 or b.x > World.GAME_W + 5 or + b.y < -5 or b.y > World.GAME_H + 5 then + table.remove(active, i) + end + end +end + +function Bullets.getAll() + return active +end + +function Bullets.remove(idx) + table.remove(active, idx) +end + +function Bullets.clear() + active = {} +end + +function Bullets.draw() + local p = Palette.get() + local lw = 1 / World.scale + + for _, b in ipairs(active) do + if b.owner == "player" then + love.graphics.setColor(p.bullet) + else + love.graphics.setColor(p.saucer) + end + love.graphics.setLineWidth(lw * 2) + love.graphics.circle("fill", b.x, b.y, 1.5) + end +end + +return Bullets diff --git a/game/highscores.lua b/game/highscores.lua new file mode 100644 index 0000000..aeb59dc --- /dev/null +++ b/game/highscores.lua @@ -0,0 +1,198 @@ +local HighScores = {} + +local MAX_SCORES = 10 +local SAVE_FILE = "asteroids_scores.dat" +local scores = {} + +local entry = { + active = false, + score = 0, + letters = {"A", "A", "A"}, + position = 1, + blink = 0, +} + +function HighScores.init() + HighScores.load() +end + +function HighScores.load() + scores = {} + if love.filesystem.getInfo(SAVE_FILE) then + local data = love.filesystem.read(SAVE_FILE) + if data then + for line in data:gmatch("[^\n]+") do + local initials, score = line:match("^(%a%a%a)%s+(%d+)$") + if initials and score then + table.insert(scores, {initials = initials:upper(), score = tonumber(score)}) + end + end + end + end + table.sort(scores, function(a, b) return a.score > b.score end) + while #scores > MAX_SCORES do table.remove(scores) end +end + +function HighScores.save() + local lines = {} + for _, e in ipairs(scores) do + table.insert(lines, string.format("%s %d", e.initials, e.score)) + end + love.filesystem.write(SAVE_FILE, table.concat(lines, "\n") .. "\n") +end + +function HighScores.isHighScore(score) + if score <= 0 then return false end + if #scores < MAX_SCORES then return true end + return score > scores[#scores].score +end + +function HighScores.addScore(initials, score) + table.insert(scores, {initials = initials:upper(), score = score}) + table.sort(scores, function(a, b) return a.score > b.score end) + while #scores > MAX_SCORES do table.remove(scores) end + HighScores.save() +end + +function HighScores.getScores() + local result = {} + for _, s in ipairs(scores) do + table.insert(result, {initials = s.initials, score = s.score}) + end + return result +end + +function HighScores.getHighest() + if #scores > 0 then return scores[1].score end + return 0 +end + +function HighScores.startEntry(score) + entry.active = true + entry.score = score + entry.letters = {"A", "A", "A"} + entry.position = 1 + entry.blink = 0 +end + +function HighScores.isEntryActive() return entry.active end + +function HighScores.updateEntry(dt) + entry.blink = entry.blink + dt +end + +function HighScores.keypressedEntry(key) + if not entry.active then return nil end + if key == "left" then + entry.position = math.max(1, entry.position - 1) + elseif key == "right" then + entry.position = math.min(3, entry.position + 1) + elseif key == "up" then + local b = entry.letters[entry.position]:byte() + b = b + 1; if b > 90 then b = 65 end + entry.letters[entry.position] = string.char(b) + elseif key == "down" then + local b = entry.letters[entry.position]:byte() + b = b - 1; if b < 65 then b = 90 end + entry.letters[entry.position] = string.char(b) + elseif key == "return" or key == "kpenter" then + local initials = table.concat(entry.letters) + local score = entry.score + entry.active = false + HighScores.addScore(initials, score) + return "done", {initials = initials, score = score} + elseif key:match("^%a$") and #key == 1 then + entry.letters[entry.position] = key:upper() + if entry.position < 3 then entry.position = entry.position + 1 end + end + return nil +end + +function HighScores.drawEntry(screenW, screenH, palette, fonts) + local midX = screenW / 2 + local midY = screenH * 0.25 + local t = love.timer.getTime() + + love.graphics.setFont(fonts.large) + local pulse = 0.7 + math.sin(t * 4) * 0.3 + love.graphics.setColor(palette.bright[1], palette.bright[2], palette.bright[3], pulse) + love.graphics.printf("NEW HIGH SCORE", 0, midY, screenW, "center") + + midY = midY + fonts.large:getHeight() + 16 + love.graphics.setFont(fonts.medium) + love.graphics.setColor(palette.fg) + love.graphics.printf(string.format("%d", entry.score), 0, midY, screenW, "center") + + midY = midY + fonts.medium:getHeight() + 32 + local boxW = math.floor(screenW * 0.06) + local boxH = math.floor(boxW * 1.3) + local gap = math.floor(boxW * 0.4) + local totalW = boxW * 3 + gap * 2 + local startX = midX - totalW / 2 + + love.graphics.setFont(fonts.large) + local letterH = fonts.large:getHeight() + + for i = 1, 3 do + local bx = startX + (i - 1) * (boxW + gap) + local by = midY + local isSelected = (i == entry.position) + + if isSelected then + local blinkOn = math.floor(entry.blink * 3) % 2 == 0 + love.graphics.setColor(blinkOn and palette.bright or palette.dim) + love.graphics.setLineWidth(3) + else + love.graphics.setColor(palette.dim) + love.graphics.setLineWidth(2) + end + love.graphics.rectangle("line", bx, by, boxW, boxH) + + if isSelected then + love.graphics.setColor(palette.bright[1], palette.bright[2], palette.bright[3], 0.5) + local arrowX = bx + boxW / 2 + love.graphics.polygon("fill", arrowX-5, by-4, arrowX+5, by-4, arrowX, by-12) + love.graphics.polygon("fill", arrowX-5, by+boxH+4, arrowX+5, by+boxH+4, arrowX, by+boxH+12) + end + + love.graphics.setColor(palette.fg) + local lw = fonts.large:getWidth(entry.letters[i]) + love.graphics.print(entry.letters[i], bx + (boxW - lw)/2, by + (boxH - letterH)/2) + end + + love.graphics.setFont(fonts.small) + love.graphics.setColor(palette.dim) + love.graphics.printf("TYPE LETTERS / ARROWS / ENTER TO CONFIRM", 0, midY + boxH + 32, screenW, "center") +end + +function HighScores.drawTable(screenW, screenH, palette, fonts) + local topY = screenH * 0.68 + local lineH = fonts.medium:getHeight() + 4 + + love.graphics.setFont(fonts.medium) + love.graphics.setColor(palette.bright) + love.graphics.printf("HIGH SCORES", 0, topY, screenW, "center") + + topY = topY + lineH + 8 + love.graphics.setFont(fonts.small) + local entryH = fonts.small:getHeight() + 3 + + if #scores == 0 then return end + + local colW = math.floor(screenW * 0.4) + local startX = (screenW - colW) / 2 + + for i, s in ipairs(scores) do + local y = topY + (i - 1) * entryH + if i == 1 then + love.graphics.setColor(palette.bright) + else + love.graphics.setColor(palette.fg[1], palette.fg[2], palette.fg[3], 0.8) + end + love.graphics.print(string.format("%2d. %s", i, s.initials), startX, y) + local sw = fonts.small:getWidth(string.format("%d", s.score)) + love.graphics.print(string.format("%d", s.score), startX + colW - sw, y) + end +end + +return HighScores diff --git a/game/hud.lua b/game/hud.lua new file mode 100644 index 0000000..c0296de --- /dev/null +++ b/game/hud.lua @@ -0,0 +1,45 @@ +local World = require("game.world") +local Palette = require("rendering.palette") +local Fonts = require("rendering.fonts") +local Ship = require("game.ship") + +local HUD = {} + +function HUD.draw() + local p = Palette.get() + + -- Pop out of game transform for crisp screen-space text + love.graphics.pop() + + local font = Fonts.medium or love.graphics.getFont() + love.graphics.setFont(font) + local pad = 8 + + -- Score (top left) + love.graphics.setColor(p.hud) + love.graphics.print(string.format("%06d", World.score), pad, pad) + + -- High score (top centre) + if World.highScore > 0 then + love.graphics.setFont(Fonts.small) + love.graphics.setColor(p.dim) + local hsText = string.format("%06d", World.highScore) + local tw = Fonts.small:getWidth(hsText) + love.graphics.print(hsText, (World.screenW - tw) / 2, pad) + end + + -- Lives (small ship icons below score) + local iconY = pad + font:getHeight() + 6 + local iconScale = 0.5 + local iconSpacing = 20 + for i = 1, math.min(World.lives, 10) do + Ship.drawIcon(pad + 10 + (i-1) * iconSpacing, iconY + 8, iconScale) + end + + -- Restore game transform + love.graphics.push() + love.graphics.translate(World.offsetX, World.offsetY) + love.graphics.scale(World.scale) +end + +return HUD diff --git a/game/particles.lua b/game/particles.lua new file mode 100644 index 0000000..ecfccc5 --- /dev/null +++ b/game/particles.lua @@ -0,0 +1,121 @@ +local World = require("game.world") +local Palette = require("rendering.palette") + +local Particles = {} + +local particles = {} + +function Particles.spawnExplosion(x, y, count, speed, maxTime) + count = count or 8 + speed = speed or 100 + maxTime = maxTime or 1.0 + + for i = 1, count do + local angle = math.random() * math.pi * 2 + local spd = speed * (0.3 + math.random() * 0.7) + table.insert(particles, { + x = x, y = y, + vx = math.cos(angle) * spd, + vy = math.sin(angle) * spd, + timer = maxTime * (0.5 + math.random() * 0.5), + maxTime = maxTime, + ptype = "dot", + }) + end +end + +function Particles.spawnShipDeath(x, y, angle, vx, vy) + -- Break ship into 3 line segments that drift apart + local cos_a = math.cos(angle) + local sin_a = math.sin(angle) + + local function transform(px, py) + return px * cos_a - py * sin_a, + px * sin_a + py * cos_a + end + + -- Ship vertices + local nose = {20, 0} + local lwing = {-14, -12} + local notch = {-8, 0} + local rwing = {-14, 12} + + local segments = { + {nose, lwing}, + {lwing, notch}, + {notch, rwing}, + {rwing, nose}, + } + + for _, seg in ipairs(segments) do + local x1r, y1r = transform(seg[1][1], seg[1][2]) + local x2r, y2r = transform(seg[2][1], seg[2][2]) + local midX = (x1r + x2r) / 2 + local midY = (y1r + y2r) / 2 + + local spread = 40 + math.random() * 40 + local sAngle = math.atan2(midY, midX) + (math.random() - 0.5) * 1.5 + + table.insert(particles, { + x = x + midX, y = y + midY, + vx = vx * 0.3 + math.cos(sAngle) * spread, + vy = vy * 0.3 + math.sin(sAngle) * spread, + -- Line offsets from centre + lx1 = x1r - midX, ly1 = y1r - midY, + lx2 = x2r - midX, ly2 = y2r - midY, + rotSpeed = (math.random() - 0.5) * 4, + rot = 0, + timer = 2.0, + maxTime = 2.0, + ptype = "line", + }) + end +end + +function Particles.update(dt) + for i = #particles, 1, -1 do + local p = particles[i] + p.x = p.x + p.vx * dt + p.y = p.y + p.vy * dt + p.timer = p.timer - dt + if p.rot then p.rot = p.rot + (p.rotSpeed or 0) * dt end + if p.timer <= 0 then + table.remove(particles, i) + end + end +end + +function Particles.anyActive() + return #particles > 0 +end + +function Particles.clear() + particles = {} +end + +function Particles.draw() + local pal = Palette.get() + local lw = 1 / World.scale + + for _, p in ipairs(particles) do + local alpha = math.max(0, p.timer / p.maxTime) + + if p.ptype == "dot" then + love.graphics.setColor(pal.explosion[1], pal.explosion[2], pal.explosion[3], alpha) + love.graphics.setLineWidth(lw) + love.graphics.circle("fill", p.x, p.y, 1.2) + elseif p.ptype == "line" then + love.graphics.setColor(pal.ship[1], pal.ship[2], pal.ship[3], alpha) + love.graphics.setLineWidth(lw * 2) + local cos_r = math.cos(p.rot) + local sin_r = math.sin(p.rot) + local x1 = p.x + p.lx1 * cos_r - p.ly1 * sin_r + local y1 = p.y + p.lx1 * sin_r + p.ly1 * cos_r + local x2 = p.x + p.lx2 * cos_r - p.ly2 * sin_r + local y2 = p.y + p.lx2 * sin_r + p.ly2 * cos_r + love.graphics.line(x1, y1, x2, y2) + end + end +end + +return Particles diff --git a/game/saucers.lua b/game/saucers.lua new file mode 100644 index 0000000..37fb707 --- /dev/null +++ b/game/saucers.lua @@ -0,0 +1,155 @@ +local World = require("game.world") +local Palette = require("rendering.palette") +local Bullets = require("game.bullets") + +local Saucers = {} + +local saucer = nil +local spawnTimer = 0 +local SPAWN_INTERVAL = 15 + +local DEFS = { + large = { speed = 150, fireRate = 1.5, radius = 20, points = 200 }, + small = { speed = 200, fireRate = 1.0, radius = 12, points = 1000 }, +} + +-- Saucer shape: classic flying saucer profile +local SHAPE_LARGE = { + {-20, 0}, {-10, -8}, {10, -8}, {20, 0}, + {10, 6}, {-10, 6}, {-20, 0}, +} +local SHAPE_LARGE_TOP = { + {-10, -8}, {-6, -14}, {6, -14}, {10, -8}, +} +local SHAPE_SMALL = { + {-12, 0}, {-6, -5}, {6, -5}, {12, 0}, + {6, 4}, {-6, 4}, {-12, 0}, +} +local SHAPE_SMALL_TOP = { + {-6, -5}, {-3, -9}, {3, -9}, {6, -5}, +} + +function Saucers.update(dt, shipX, shipY) + if not saucer then + spawnTimer = spawnTimer + dt + if spawnTimer >= SPAWN_INTERVAL then + spawnTimer = 0 + -- Determine size based on score + local size + if World.score >= 40000 then + size = "small" + elseif World.score >= 10000 then + size = math.random() < 0.6 and "small" or "large" + else + size = "large" + end + + local fromLeft = math.random() < 0.5 + local def = DEFS[size] + saucer = { + x = fromLeft and -20 or (World.GAME_W + 20), + y = math.random(80, World.GAME_H - 80), + vx = (fromLeft and 1 or -1) * def.speed, + vy = 0, + size = size, + radius = def.radius, + points = def.points, + fireTimer = def.fireRate * 0.5, + fireRate = def.fireRate, + altTimer = 1 + math.random() * 2, + alive = true, + } + end + return + end + + local s = saucer + + -- Move + s.x = s.x + s.vx * dt + s.y = s.y + s.vy * dt + s.y = World.wrapY(s.y) + + -- Altitude changes + s.altTimer = s.altTimer - dt + if s.altTimer <= 0 then + s.altTimer = 1 + math.random() * 2 + s.vy = (math.random() - 0.5) * 200 + end + + -- Remove if off screen + if (s.vx > 0 and s.x > World.GAME_W + 30) or (s.vx < 0 and s.x < -30) then + saucer = nil + return + end + + -- Firing + s.fireTimer = s.fireTimer - dt + if s.fireTimer <= 0 then + s.fireTimer = s.fireRate + + local angle + if s.size == "large" then + angle = math.random() * math.pi * 2 + else + -- Aim at player with some error + local dx, dy = World.wrappedDist(s.x, s.y, shipX, shipY) + angle = math.atan2(dy, dx) + (math.random() - 0.5) * 0.35 + end + + Bullets.fire(s.x, s.y, angle, 0, 0, "saucer") + end +end + +function Saucers.get() + return saucer +end + +function Saucers.destroy() + local points = saucer and saucer.points or 0 + local x, y = saucer.x, saucer.y + saucer = nil + return points, x, y +end + +function Saucers.clear() + saucer = nil + spawnTimer = 0 +end + +function Saucers.isActive() + return saucer ~= nil +end + +local function drawShape(shape, ox, oy) + local pts = {} + for _, v in ipairs(shape) do + table.insert(pts, v[1] + ox) + table.insert(pts, v[2] + oy) + end + love.graphics.line(pts) +end + +function Saucers.draw() + if not saucer then return end + local p = Palette.get() + local lw = 1 / World.scale + local t = love.timer.getTime() + local pulse = 0.7 + math.sin(t * 10) * 0.3 + + love.graphics.setColor(p.saucer[1], p.saucer[2], p.saucer[3], pulse) + love.graphics.setLineWidth(lw * 1.5) + + if saucer.size == "large" then + drawShape(SHAPE_LARGE, saucer.x, saucer.y) + drawShape(SHAPE_LARGE_TOP, saucer.x, saucer.y) + -- Centre line + love.graphics.line(saucer.x - 10, saucer.y - 8, saucer.x + 10, saucer.y - 8) + else + drawShape(SHAPE_SMALL, saucer.x, saucer.y) + drawShape(SHAPE_SMALL_TOP, saucer.x, saucer.y) + love.graphics.line(saucer.x - 6, saucer.y - 5, saucer.x + 6, saucer.y - 5) + end +end + +return Saucers diff --git a/game/ship.lua b/game/ship.lua new file mode 100644 index 0000000..63b6733 --- /dev/null +++ b/game/ship.lua @@ -0,0 +1,184 @@ +local World = require("game.world") +local Palette = require("rendering.palette") + +local Ship = {} + +local ROTATION_SPEED = 4.712 -- ~270 deg/sec +local THRUST_ACCEL = 300 +local MAX_SPEED = 400 +local INVULN_TIME = 3.0 +local HYPER_COOLDOWN = 0.5 +local COLLISION_RADIUS = 14 + +-- Ship shape vertices (pointing right at angle=0) +local SHAPE = { + {20, 0}, -- nose + {-14, -12}, -- left wing + {-8, 0}, -- rear notch + {-14, 12}, -- right wing +} + +local ship = {} + +function Ship.init() + ship.x = World.GAME_W / 2 + ship.y = World.GAME_H / 2 + ship.vx = 0 + ship.vy = 0 + ship.angle = -math.pi / 2 -- pointing up + ship.alive = true + ship.invulnerable = true + ship.invulnTimer = INVULN_TIME + ship.thrustOn = false + ship.hyperCooldown = 0 +end + +function Ship.get() + return ship +end + +function Ship.update(dt) + if not ship.alive then return end + + ship.invulnTimer = math.max(0, ship.invulnTimer - dt) + if ship.invulnTimer <= 0 then ship.invulnerable = false end + ship.hyperCooldown = math.max(0, ship.hyperCooldown - dt) + + -- Rotation + if love.keyboard.isDown("left", "a") then + ship.angle = ship.angle - ROTATION_SPEED * dt + end + if love.keyboard.isDown("right", "d") then + ship.angle = ship.angle + ROTATION_SPEED * dt + end + + -- Thrust + ship.thrustOn = love.keyboard.isDown("up", "w") + if ship.thrustOn then + ship.vx = ship.vx + math.cos(ship.angle) * THRUST_ACCEL * dt + ship.vy = ship.vy + math.sin(ship.angle) * THRUST_ACCEL * dt + -- Cap speed + local speed = math.sqrt(ship.vx * ship.vx + ship.vy * ship.vy) + if speed > MAX_SPEED then + ship.vx = ship.vx / speed * MAX_SPEED + ship.vy = ship.vy / speed * MAX_SPEED + end + end + + -- Move and wrap + ship.x = World.wrapX(ship.x + ship.vx * dt) + ship.y = World.wrapY(ship.y + ship.vy * dt) +end + +function Ship.die() + ship.alive = false +end + +function Ship.respawn() + ship.x = World.GAME_W / 2 + ship.y = World.GAME_H / 2 + ship.vx = 0 + ship.vy = 0 + ship.angle = -math.pi / 2 + ship.alive = true + ship.invulnerable = true + ship.invulnTimer = INVULN_TIME +end + +function Ship.hyperspace() + if ship.hyperCooldown > 0 or not ship.alive then return false end + ship.hyperCooldown = HYPER_COOLDOWN + + -- 25% chance of death + if math.random() < 0.25 then + return true -- signal death + end + + ship.x = math.random(50, World.GAME_W - 50) + ship.y = math.random(50, World.GAME_H - 50) + ship.vx = 0 + ship.vy = 0 + return false +end + +function Ship.getCollider() + return ship.x, ship.y, COLLISION_RADIUS +end + +local function transformPoint(px, py, angle, cx, cy) + local cos_a = math.cos(angle) + local sin_a = math.sin(angle) + return cx + px * cos_a - py * sin_a, + cy + px * sin_a + py * cos_a +end + +function Ship.getVertices() + local verts = {} + for _, v in ipairs(SHAPE) do + local wx, wy = transformPoint(v[1], v[2], ship.angle, ship.x, ship.y) + table.insert(verts, {wx, wy}) + end + return verts +end + +function Ship.draw() + if not ship.alive then return end + + -- Blink when invulnerable + if ship.invulnerable and math.floor(ship.invulnTimer * 8) % 2 == 0 then + return + end + + local p = Palette.get() + local lw = 1 / World.scale + + local verts = Ship.getVertices() + + -- Draw ship outline + love.graphics.setColor(p.ship) + love.graphics.setLineWidth(lw * 2) + local pts = {} + for _, v in ipairs(verts) do + table.insert(pts, v[1]) + table.insert(pts, v[2]) + end + table.insert(pts, verts[1][1]) + table.insert(pts, verts[1][2]) + love.graphics.line(pts) + + -- Thrust flame + if ship.thrustOn then + local flicker = 0.6 + math.random() * 0.4 + love.graphics.setColor(p.thrust[1], p.thrust[2], p.thrust[3], flicker) + love.graphics.setLineWidth(lw * 1.5) + + local flameLen = 8 + math.random() * 8 + local fx, fy = transformPoint(-8, 0, ship.angle, ship.x, ship.y) + local fl, fl2 = transformPoint(-8, -4, ship.angle, ship.x, ship.y) + local fr, fr2 = transformPoint(-8, 4, ship.angle, ship.x, ship.y) + local ft, ft2 = transformPoint(-8 - flameLen, 0, ship.angle, ship.x, ship.y) + + love.graphics.line(fl, fl2, ft, ft2, fr, fr2) + end +end + +-- Draw a small ship icon for lives display +function Ship.drawIcon(cx, cy, scale) + local p = Palette.get() + local lw = 1 + love.graphics.setColor(p.ship) + love.graphics.setLineWidth(lw) + + local pts = {} + for _, v in ipairs(SHAPE) do + local rx = cx + v[1] * scale + local ry = cy + v[2] * scale + table.insert(pts, rx) + table.insert(pts, ry) + end + table.insert(pts, pts[1]) + table.insert(pts, pts[2]) + love.graphics.line(pts) +end + +return Ship diff --git a/game/world.lua b/game/world.lua new file mode 100644 index 0000000..f61df97 --- /dev/null +++ b/game/world.lua @@ -0,0 +1,85 @@ +local World = { + GAME_W = 1024, + GAME_H = 768, + visibleH = 768, + scale = 1, + offsetX = 0, + offsetY = 0, + state = "title", + wave = 1, + score = 0, + highScore = 0, + lives = 3, + nextExtraLife = 10000, + gameOverTimer = 0, + screenW = 0, + screenH = 0, + respawnTimer = 0, + waveTimer = 0, +} + +function World.resize(w, h) + if not w or not h then w, h = love.graphics.getDimensions() end + World.screenW = w + World.screenH = h + World.scale = w / World.GAME_W + World.visibleH = h / World.scale + World.offsetX = 0 + World.offsetY = h - (World.GAME_H * World.scale) +end + +function World.ensureScale() + local w, h = love.graphics.getDimensions() + if w ~= World.screenW or h ~= World.screenH then + World.resize(w, h) + local Fonts = require("rendering.fonts") + Fonts.init(World.scale) + end +end + +function World.toGame(sx, sy) + local gx = (sx - World.offsetX) / World.scale + local gy = (sy - World.offsetY) / World.scale + return gx, gy +end + +function World.toScreen(gx, gy) + return gx * World.scale + World.offsetX, gy * World.scale + World.offsetY +end + +function World.visibleTop() + return World.GAME_H - World.visibleH +end + +function World.wrapX(x) + return (x % World.GAME_W + World.GAME_W) % World.GAME_W +end + +function World.wrapY(y) + return (y % World.GAME_H + World.GAME_H) % World.GAME_H +end + +function World.wrappedDist(x1, y1, x2, y2) + local dx = x2 - x1 + local dy = y2 - y1 + if math.abs(dx) > World.GAME_W / 2 then + dx = dx - World.GAME_W * (dx > 0 and 1 or -1) + end + if math.abs(dy) > World.GAME_H / 2 then + dy = dy - World.GAME_H * (dy > 0 and 1 or -1) + end + return dx, dy, math.sqrt(dx*dx + dy*dy) +end + +function World.addScore(points) + World.score = World.score + points + while World.score >= World.nextExtraLife do + World.lives = World.lives + 1 + World.nextExtraLife = World.nextExtraLife + 10000 + end + if World.score > World.highScore then + World.highScore = World.score + end +end + +return World diff --git a/main.lua b/main.lua new file mode 100644 index 0000000..81ec0b8 --- /dev/null +++ b/main.lua @@ -0,0 +1,469 @@ +local World = require("game.world") +local Ship = require("game.ship") +local AsteroidsMod = require("game.asteroids") +local Bullets = require("game.bullets") +local Saucers = require("game.saucers") +local Particles = require("game.particles") +local HUD = require("game.hud") +local HighScores = require("game.highscores") +local Palette = require("rendering.palette") +local Fonts = require("rendering.fonts") +local Waves = require("data.waves") +local Sounds = require("audio.sounds") + +-- Beat system +local beat = { + timer = 0, + which = 1, -- alternates 1 and 2 + waveStartCount = 4, +} + +-- Thrust sound state +local thrustPlaying = false +local thrustTimer = 0 + +-- Wave transition +local waveDelay = 0 +local WAVE_DELAY_TIME = 2.0 + +-- Respawn +local respawnDelay = 0 +local RESPAWN_DELAY_TIME = 2.0 + +-- Title screen asteroids (decorative) +local titleAsteroidsInited = false + +local function startGame() + World.state = "playing" + World.wave = 1 + World.score = 0 + World.lives = 3 + World.nextExtraLife = 10000 + World.gameOverTimer = 0 + + Ship.init() + AsteroidsMod.clear() + Bullets.clear() + Saucers.clear() + Particles.clear() + + AsteroidsMod.spawnWave(1) + beat.waveStartCount = AsteroidsMod.count() + beat.timer = 0 + beat.which = 1 + waveDelay = 0 + respawnDelay = 0 + + Sounds.play("beat1") +end + +local function getBeatInterval() + local total = math.max(beat.waveStartCount, 1) + local remaining = math.max(AsteroidsMod.count(), 0) + local ratio = remaining / total + return 0.1 + 0.7 * ratio +end + +local function drawGameWorld() + love.graphics.push() + love.graphics.translate(World.offsetX, World.offsetY) + love.graphics.scale(World.scale) + + local p = Palette.get() + local top = World.visibleTop() + + -- Background + love.graphics.setColor(p.bg) + love.graphics.rectangle("fill", 0, top, World.GAME_W, World.visibleH) + + AsteroidsMod.draw() + Saucers.draw() + Bullets.draw() + Ship.draw() + Particles.draw() + + HUD.draw() + + love.graphics.pop() +end + +local function drawTitleScreen() + local p = Palette.get() + local sw, sh = World.screenW, World.screenH + local t = love.timer.getTime() + + -- Background with drifting asteroids + love.graphics.push() + love.graphics.translate(World.offsetX, World.offsetY) + love.graphics.scale(World.scale) + + local top = World.visibleTop() + love.graphics.setColor(p.bg) + love.graphics.rectangle("fill", 0, top, World.GAME_W, World.visibleH) + AsteroidsMod.draw() + + love.graphics.pop() + + -- Title text + local centerY = sh * 0.18 + + love.graphics.setFont(Fonts.large) + love.graphics.setColor(p.bright) + love.graphics.printf("ASTEROIDS", 0, centerY, sw, "center") + + -- Decorative wireframe lines + love.graphics.setColor(p.asteroid[1], p.asteroid[2], p.asteroid[3], 0.2) + love.graphics.setLineWidth(1) + local bx = sw * 0.2 + local by1 = centerY - 10 + local by2 = centerY + Fonts.large:getHeight() + 10 + love.graphics.line(bx, by1, sw - bx, by1) + love.graphics.line(bx, by2, sw - bx, by2) + + -- Controls + love.graphics.setFont(Fonts.small) + love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.4) + local ctrlY = sh * 0.42 + love.graphics.printf("ARROWS / WASD: MOVE SPACE: FIRE SHIFT: HYPERSPACE", 0, ctrlY, sw, "center") + + -- Press Enter + local pulse = 0.3 + math.sin(t * 3) * 0.3 + love.graphics.setFont(Fonts.medium) + love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], pulse + 0.2) + love.graphics.printf("PRESS ENTER TO START", 0, sh * 0.54, sw, "center") + + -- High score table + local allScores = HighScores.getScores() + if #allScores > 0 then + HighScores.drawTable(sw, sh, p, Fonts) + end +end + +-- Collision helpers +local function circleCollision(x1, y1, r1, x2, y2, r2) + local _, _, dist = World.wrappedDist(x1, y1, x2, y2) + return dist < r1 + r2 +end + +function love.load() + love.mouse.setVisible(false) + love.graphics.setBackgroundColor(0, 0, 0) + love.graphics.setLineStyle("smooth") + Palette.loadFromSystem() + World.resize(love.graphics.getDimensions()) + Fonts.init(World.scale) + HighScores.init() + Sounds.init() + AsteroidsMod.init() + World.highScore = HighScores.getHighest() + World.state = "title" + + -- Spawn some decorative asteroids for title screen + AsteroidsMod.spawnWave(1) +end + +function love.resize(w, h) + World.resize(w, h) + Fonts.init(World.scale) +end + +function love.update(dt) + World.ensureScale() + + if World.state == "title" then + AsteroidsMod.update(dt) + return + end + + if World.state == "playing" then + local s = Ship.get() + + -- Ship update + if s.alive then + Ship.update(dt) + + -- Thrust sound + if s.thrustOn then + thrustTimer = thrustTimer + dt + if thrustTimer > 0.25 then + thrustTimer = 0 + Sounds.play("thrust") + end + else + thrustTimer = 0.2 -- play immediately on next thrust + end + end + + AsteroidsMod.update(dt) + Bullets.update(dt) + Saucers.update(dt, s.x, s.y) + Particles.update(dt) + + -- Beat timer + beat.timer = beat.timer + dt + local interval = getBeatInterval() + if beat.timer >= interval then + beat.timer = 0 + if beat.which == 1 then + Sounds.play("beat1") + beat.which = 2 + else + Sounds.play("beat2") + beat.which = 1 + end + end + + -- === COLLISION DETECTION === + local asteroids = AsteroidsMod.getAll() + local bullets = Bullets.getAll() + + -- Bullets vs asteroids + for bi = #bullets, 1, -1 do + local b = bullets[bi] + for ai = #asteroids, 1, -1 do + local a = asteroids[ai] + if circleCollision(b.x, b.y, 2, a.x, a.y, a.radius) then + local result = AsteroidsMod.destroy(ai) + if result then + World.addScore(result.points) + local expSize = result.size == "large" and 12 or (result.size == "medium" and 8 or 5) + Particles.spawnExplosion(result.x, result.y, expSize, 80) + Sounds.play("explode_" .. result.size) + end + Bullets.remove(bi) + asteroids = AsteroidsMod.getAll() -- refresh after destroy + break + end + end + end + + -- Bullets vs saucer + local saucer = Saucers.get() + if saucer then + for bi = #bullets, 1, -1 do + local b = bullets[bi] + if b.owner == "player" and circleCollision(b.x, b.y, 2, saucer.x, saucer.y, saucer.radius) then + local points, sx, sy = Saucers.destroy() + World.addScore(points) + Particles.spawnExplosion(sx, sy, 15, 120) + Sounds.play("explode_large") + Bullets.remove(bi) + break + end + end + end + + -- Ship vs asteroids + if s.alive and not s.invulnerable then + local sx, sy, sr = Ship.getCollider() + for ai = #asteroids, 1, -1 do + local a = asteroids[ai] + if circleCollision(sx, sy, sr, a.x, a.y, a.radius) then + Ship.die() + Particles.spawnShipDeath(s.x, s.y, s.angle, s.vx, s.vy) + Sounds.play("ship_explode") + World.lives = World.lives - 1 + respawnDelay = RESPAWN_DELAY_TIME + break + end + end + end + + -- Ship vs saucer + saucer = Saucers.get() + if s.alive and not s.invulnerable and saucer then + local sx, sy, sr = Ship.getCollider() + if circleCollision(sx, sy, sr, saucer.x, saucer.y, saucer.radius) then + Ship.die() + Particles.spawnShipDeath(s.x, s.y, s.angle, s.vx, s.vy) + Sounds.play("ship_explode") + World.lives = World.lives - 1 + local points, sx2, sy2 = Saucers.destroy() + World.addScore(points) + Particles.spawnExplosion(sx2, sy2, 15, 120) + respawnDelay = RESPAWN_DELAY_TIME + end + end + + -- Saucer bullets vs ship + if s.alive and not s.invulnerable then + local sx, sy, sr = Ship.getCollider() + for bi = #bullets, 1, -1 do + local b = bullets[bi] + if b.owner == "saucer" and circleCollision(b.x, b.y, 2, sx, sy, sr) then + Ship.die() + Particles.spawnShipDeath(s.x, s.y, s.angle, s.vx, s.vy) + Sounds.play("ship_explode") + World.lives = World.lives - 1 + Bullets.remove(bi) + respawnDelay = RESPAWN_DELAY_TIME + break + end + end + end + + -- Respawn logic + if not s.alive then + respawnDelay = respawnDelay - dt + if respawnDelay <= 0 then + if World.lives > 0 then + -- Check centre is safe + local safe = true + for _, a in ipairs(AsteroidsMod.getAll()) do + local _, _, dist = World.wrappedDist(World.GAME_W/2, World.GAME_H/2, a.x, a.y) + if dist < a.radius + 80 then safe = false; break end + end + if safe then + Ship.respawn() + end + else + -- Game over + World.state = "game_over" + World.gameOverTimer = 0 + Sounds.play("ship_explode") + end + end + end + + -- Wave clear check + if AsteroidsMod.count() == 0 and not Saucers.isActive() then + waveDelay = waveDelay + dt + if waveDelay >= WAVE_DELAY_TIME then + waveDelay = 0 + World.wave = World.wave + 1 + AsteroidsMod.spawnWave(World.wave) + beat.waveStartCount = AsteroidsMod.count() + end + else + waveDelay = 0 + end + + elseif World.state == "game_over" then + World.gameOverTimer = World.gameOverTimer + dt + Particles.update(dt) + AsteroidsMod.update(dt) + + elseif World.state == "high_score_entry" then + HighScores.updateEntry(dt) + AsteroidsMod.update(dt) + end +end + +function love.draw() + love.graphics.clear(0, 0, 0) + local p = Palette.get() + local sw, sh = World.screenW, World.screenH + + if World.state == "title" then + drawTitleScreen() + return + end + + if World.state == "high_score_entry" then + love.graphics.push() + love.graphics.translate(World.offsetX, World.offsetY) + love.graphics.scale(World.scale) + local top = World.visibleTop() + love.graphics.setColor(p.bg) + love.graphics.rectangle("fill", 0, top, World.GAME_W, World.visibleH) + AsteroidsMod.draw() + love.graphics.pop() + + HighScores.drawEntry(sw, sh, p, Fonts) + return + end + + drawGameWorld() + + if World.state == "game_over" then + local t = love.timer.getTime() + local midY = sh * 0.35 + + if World.gameOverTimer > 0.5 then + love.graphics.setFont(Fonts.large) + local textPulse = 0.7 + math.sin(t * 3) * 0.3 + love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], textPulse) + love.graphics.printf("GAME OVER", 0, midY, sw, "center") + end + + if World.gameOverTimer > 1.5 then + love.graphics.setFont(Fonts.medium) + love.graphics.setColor(p.fg[1], p.fg[2], p.fg[3], 0.7) + love.graphics.printf("SCORE: " .. string.format("%06d", World.score), 0, midY + Fonts.large:getHeight() + 8, sw, "center") + end + + if World.gameOverTimer > 2.5 then + local pulse = 0.3 + math.sin(t * 3) * 0.3 + love.graphics.setFont(Fonts.small) + love.graphics.setColor(p.bright[1], p.bright[2], p.bright[3], pulse + 0.2) + love.graphics.printf("PRESS ENTER", 0, midY + Fonts.large:getHeight() + Fonts.medium:getHeight() + 24, sw, "center") + end + end +end + +function love.keypressed(key) + if World.state == "title" then + if key == "return" then startGame() end + if key == "escape" then love.event.quit() end + return + end + + if World.state == "high_score_entry" then + local result = HighScores.keypressedEntry(key) + if result == "done" then + World.highScore = HighScores.getHighest() + World.state = "title" + AsteroidsMod.clear() + AsteroidsMod.spawnWave(1) -- decorative title asteroids + end + return + end + + if World.state == "playing" then + local s = Ship.get() + if key == "space" and s.alive then + if Bullets.fire( + s.x + math.cos(s.angle) * 16, + s.y + math.sin(s.angle) * 16, + s.angle, s.vx, s.vy, "player" + ) then + Sounds.play("fire") + end + end + + if (key == "lshift" or key == "rshift") and s.alive then + local died = Ship.hyperspace() + if died then + Ship.die() + Particles.spawnShipDeath(s.x, s.y, s.angle, s.vx, s.vy) + Sounds.play("ship_explode") + World.lives = World.lives - 1 + respawnDelay = RESPAWN_DELAY_TIME + end + end + end + + if World.state == "game_over" then + if key == "return" and World.gameOverTimer > 2.0 then + if HighScores.isHighScore(World.score) then + World.state = "high_score_entry" + HighScores.startEntry(World.score) + else + World.state = "title" + AsteroidsMod.clear() + AsteroidsMod.spawnWave(1) + end + return + end + if key == "escape" then + World.state = "title" + AsteroidsMod.clear() + AsteroidsMod.spawnWave(1) + return + end + end + + if key == "escape" then + love.event.quit() + end +end diff --git a/rendering/fonts.lua b/rendering/fonts.lua new file mode 100644 index 0000000..4f8558d --- /dev/null +++ b/rendering/fonts.lua @@ -0,0 +1,64 @@ +local Fonts = {} + +Fonts.small = nil +Fonts.medium = nil +Fonts.large = nil +Fonts.currentH = 0 +Fonts.fontPath = nil +Fonts.fontData = nil + +function Fonts.detectSystemFont() + local home = os.getenv("HOME") + local f = io.open(home .. "/.config/waybar/style.css", "r") + if not f then return nil end + local content = f:read("*a") + f:close() + local fontName = content:match("font%-family:%s*[\"']?([^;\"']+)") + if not fontName then return nil end + fontName = fontName:match("^%s*(.-)%s*$") + local handle = io.popen('fc-match "' .. fontName .. '" --format="%{file}"') + if not handle then return nil end + local path = handle:read("*a") + handle:close() + if path and path ~= "" then + local test = io.open(path, "r") + if test then test:close(); return path end + end + return nil +end + +function Fonts.init(scale) + local h = love.graphics.getHeight() + if h == Fonts.currentH then return end + Fonts.currentH = h + + if not Fonts.fontPath then + Fonts.fontPath = Fonts.detectSystemFont() or false + end + if Fonts.fontPath and not Fonts.fontData then + local f = io.open(Fonts.fontPath, "rb") + if f then + local data = f:read("*a") + f:close() + Fonts.fontData = love.filesystem.newFileData(data, "systemfont.ttf") + else + Fonts.fontPath = false + end + end + + local sS = math.max(10, math.floor(h * 0.018)) + local sM = math.max(12, math.floor(h * 0.025)) + local sL = math.max(16, math.floor(h * 0.045)) + + if Fonts.fontData then + Fonts.small = love.graphics.newFont(Fonts.fontData, sS) + Fonts.medium = love.graphics.newFont(Fonts.fontData, sM) + Fonts.large = love.graphics.newFont(Fonts.fontData, sL) + else + Fonts.small = love.graphics.newFont(sS) + Fonts.medium = love.graphics.newFont(sM) + Fonts.large = love.graphics.newFont(sL) + end +end + +return Fonts diff --git a/rendering/palette.lua b/rendering/palette.lua new file mode 100644 index 0000000..9f6e871 --- /dev/null +++ b/rendering/palette.lua @@ -0,0 +1,70 @@ +-- Palette auto-detected from current Omarchy system theme +local Palette = {} + +local theme = {} + +local function hexToRGB(hex) + hex = hex:gsub("#", "") + return { + tonumber(hex:sub(1,2), 16) / 255, + tonumber(hex:sub(3,4), 16) / 255, + tonumber(hex:sub(5,6), 16) / 255, + } +end + +function Palette.loadFromSystem() + local path = os.getenv("HOME") .. "/.config/omarchy/current/theme/ghostty.conf" + local f = io.open(path, "r") + if not f then + theme.bg = {0, 0, 0} + theme.fg = {1, 1, 1} + theme.accent = {1, 1, 1} + for i = 0, 15 do theme["color" .. i] = {i/15, i/15, i/15} end + theme.dim = theme.color8 + return + end + + for line in f:lines() do + local key, val = line:match("^(%S+)%s*=%s*(#%x+)") + if key and val then + if key == "background" then theme.bg = hexToRGB(val) + elseif key == "foreground" then theme.fg = hexToRGB(val) + elseif key == "cursor-color" then theme.accent = hexToRGB(val) + end + end + local idx, hex = line:match("^palette%s*=%s*(%d+)=(#%x+)") + if idx and hex then theme["color" .. idx] = hexToRGB(hex) end + end + f:close() + + theme.dim = theme.color8 or theme.color0 or {0.2, 0.2, 0.2} + for i = 0, 15 do + if not theme["color" .. i] then theme["color" .. i] = theme.fg or {1, 1, 1} end + end + if not theme.bg then theme.bg = {0, 0, 0} end + if not theme.fg then theme.fg = {1, 1, 1} end + if not theme.accent then theme.accent = theme.color12 or theme.fg end +end + +function Palette.get() + return { + bg = theme.bg, + fg = theme.fg, + ship = theme.fg, + asteroid = theme.color4, + bullet = theme.fg, + saucer = theme.color2 or theme.color3, + thrust = theme.accent, + explosion = theme.accent, + dim = theme.dim, + bright = theme.accent, + hud = theme.fg, + grid = theme.color0, + } +end + +function Palette.raw() + return theme +end + +return Palette