BomberScene = {} class("BomberScene").extends(BaseScene) local scene = BomberScene local font = Graphics.font.new('assets/fonts/Mini Sans 2X') function scene:init() scene.super.init(self) self.bg = Graphics.image.new("assets/sprites/backgrounds/bomber") self.bgY = 0 self.scrollSpeed = 0.6 scene.dropSound = playdate.sound.fileplayer.new("assets/audio/drop1") scene.themeSound = playdate.sound.fileplayer.new("assets/audio/bomberTheme") scene.themeSound:setVolume(0.5) scene.themeSound:play() scene.progressBar = ProgressBar(50, 210, 50, 5) scene.progressBar:set(0) scene.progressBar:setVisible(false) scene.grenadeCooldown = false scene.grenadeCooldownTimer = nil scene.grenadeCooldownDuration = 100 scene.progressBarMax = 100 scene.autoReload = false scene.reloadProgress = 0 scene.crankSensitivity = 0.2 scene.availableGrenades = 8 scene.killCount = 0 scene.killTarget = Maps[CurrentMission.mapId].killTarget or 10 scene.missionEnded = false scene.enemies = {} scene.enemySpawnTimer = nil scene.enemySpawnInterval = 1000 scene.maxEnemies = 5 scene.nextEnemyIndex = 1 scene.minSpawnDelay = 500 scene.maxSpawnDelay = 3500 scene.enemySpeedBonus = 0 scene.enemySpeedMax = 1.5 scene.enemySpeedRamp = 0.0005 scene.crateTimer = 0 scene.crateInterval = math.random(400, 800) scene.allyBulletTimer = 0 scene.allyBulletInterval = math.random(200, 500) -- Drone battery (in frames, ~60 seconds at 50fps) scene.battery = 3000 scene.batteryMax = 3000 -- Falling state scene.falling = false -- Combo tracking scene.comboCount = 0 scene.comboText = nil scene.comboTextTimer = nil BomberScene.instance = self end function scene:drawBackground() if scene.missionEnded and scene.falling then Graphics.clear(Graphics.kColorBlack) return end self.bgY = self.bgY + self.scrollSpeed if self.bgY >= 720 then self.bgY = 0 end self.bg:draw(0, self.bgY - 720) self.bg:draw(0, self.bgY) end scene.inputHandler = { upButtonHold = function() scene.crosshair:moveUp() end, downButtonHold = function() scene.crosshair:moveDown() end, leftButtonHold = function() scene.crosshair:moveLeft() end, rightButtonHold = function() scene.crosshair:moveRight() end, AButtonDown = function() if scene.availableGrenades <= 0 then return end if not scene.grenadeCooldown then Granade(scene.crosshair.x, scene.crosshair.y) scene.grenadeCooldown = true scene.progressBar:set(0) scene.progressBar:setVisible(true) scene.availableGrenades = scene.availableGrenades - 1 scene.dropSound:play() if scene.autoReload then scene.grenadeCooldownTimer = playdate.timer.new(scene.grenadeCooldownDuration, function() scene.grenadeCooldown = false scene.progressBar:setVisible(false) end) scene.grenadeCooldownTimer.updateCallback = function(timer) local percentage = (scene.grenadeCooldownDuration - timer.timeLeft) / scene.grenadeCooldownDuration * scene.progressBarMax scene.progressBar:set(percentage) end else scene.reloadProgress = 0 end end end } function scene:enter() scene.super.enter(self) Noble.Input.setHandler(scene.inputHandler) scene.crosshair = MovableCrosshair(100, 100) scene:scheduleNextEnemySpawn() NoiseAnimation(200, 120) end function scene:start() scene.super.start(self) self.optionsMenu:addMenuItem("Main Menu", function() Noble.transition(Menu) end) Noble.showFPS = false end function scene:hasActiveGrenades() local sprites = playdate.graphics.sprite.getAllSprites() for i = 1, #sprites do if sprites[i]:getTag() == Tags.granade then return true end end return false end function scene:update() scene.super.update(self) if scene.missionEnded then return end local killsBefore = scene.killCount -- Ramp up enemy speed over time if scene.enemySpeedBonus < scene.enemySpeedMax then scene.enemySpeedBonus = scene.enemySpeedBonus + scene.enemySpeedRamp end -- Drone battery scene.battery = scene.battery - 1 if scene.battery <= 0 and not scene.falling then scene.falling = true scene.fallTimer = 0 scene.fallDuration = 120 -- Stop spawning new enemies if scene.enemySpawnTimer then scene.enemySpawnTimer:remove() end end if scene.falling then scene.fallTimer = scene.fallTimer + 1 if scene.fallTimer == 1 then scene.crosshair:setVisible(false) scene.fallSnapshot = playdate.graphics.getDisplayImage():copy() -- Remove all gameplay sprites during fall local allSprites = playdate.graphics.sprite.getAllSprites() for i = 1, #allSprites do allSprites[i]:remove() end end local t = scene.fallTimer / scene.fallDuration local scale = 1 + t * t * 5 local w = math.floor(400 * scale) local h = math.floor(240 * scale) local x = math.floor((400 - w) / 2) local y = math.floor((240 - h) / 2) Graphics.clear(Graphics.kColorBlack) scene.fallSnapshot:drawScaled(x, y, scale) if scene.fallTimer >= scene.fallDuration and not scene.missionEnded then scene.missionEnded = true scene.fallSnapshot = nil -- Remove all gameplay sprites local allSprites = playdate.graphics.sprite.getAllSprites() for i = 1, #allSprites do allSprites[i]:remove() end -- Crash effects BigBoom() screenShake(1500, 8) local random = math.random(1, 4) local crashSound = playdate.sound.fileplayer.new("assets/audio/boom" .. random) crashSound:setVolume(0.8) crashSound:play(1) playdate.timer.performAfterDelay(2000, function() local c c = notify("Battery dead!", function() Noble.transition(DroneCardSelector) c:remove() end) c:moveTo(200, 120) c:add() end) end return end -- Spawn ammo crates scene.crateTimer = scene.crateTimer + 1 if scene.crateTimer >= scene.crateInterval then scene.crateTimer = 0 scene.crateInterval = math.random(400, 800) AmmoCrate(math.random(30, 370), -20) end -- Ally bullets (steal kills) scene.allyBulletTimer = scene.allyBulletTimer + 1 if scene.allyBulletTimer >= scene.allyBulletInterval then scene.allyBulletTimer = 0 scene.allyBulletInterval = math.random(200, 500) -- Find a random alive enemy to target local alive = {} for i = 1, #scene.enemies do if scene.enemies[i] and not scene.enemies[i].removed and not scene.enemies[i].isDying then alive[#alive + 1] = scene.enemies[i] end end if #alive > 0 then local target = alive[math.random(1, #alive)] AllyBullet(target) end end -- Victory check if scene.killCount >= scene.killTarget then scene.missionEnded = true scene.crosshair:setVisible(false) local reward = 100 Noble.GameData.set("missionsCompleted", Noble.GameData.get("missionsCompleted") + 1) Noble.GameData.set("money", Noble.GameData.get("money") + reward) local c c = notify("Mission Complete! +$" .. reward, function() Noble.transition(DroneCardSelector) c:remove() end) c:moveTo(200, 120) c:add() return end -- Defeat check: no grenades left and no active grenades on screen if scene.availableGrenades <= 0 and not scene.grenadeCooldown and not scene:hasActiveGrenades() then scene.missionEnded = true scene.crosshair:setVisible(false) local c c = notify("Mission Failed!", function() Noble.transition(DroneCardSelector) c:remove() end) c:moveTo(200, 120) c:add() return end if scene.grenadeCooldown and not scene.autoReload and not playdate.isCrankDocked() then local change = playdate.getCrankChange() if change > 0 or change < 0 then scene.reloadProgress = scene.reloadProgress + (change * scene.crankSensitivity) if scene.reloadProgress > scene.progressBarMax then scene.reloadProgress = scene.progressBarMax scene.grenadeCooldown = false scene.progressBar:setVisible(false) end scene.progressBar:set(scene.reloadProgress) end end -- Combo detection local frameKills = scene.killCount - killsBefore if frameKills >= 2 then scene.comboText = "x" .. frameKills .. " COMBO!" scene.comboTextTimer = playdate.timer.new(1500, 0, 1500, playdate.easingFunctions.linear) scene.availableGrenades = scene.availableGrenades + (frameKills - 1) end -- HUD: kill count Noble.Text.draw(scene.killCount .. "/" .. scene.killTarget, 350, 10, Noble.Text.ALIGN_RIGHT, false, font) -- HUD: battery bar local batW = 40 local batH = 6 local batX = 180 local batY = 10 local batFill = (scene.battery / scene.batteryMax) * batW Graphics.drawRect(batX, batY, batW, batH) Graphics.fillRect(batX, batY, batFill, batH) -- HUD: combo text if scene.comboText and scene.comboTextTimer then if scene.comboTextTimer.value < 1500 then Noble.Text.draw(scene.comboText, 200, 100, Noble.Text.ALIGN_CENTER, false, font) else scene.comboText = nil scene.comboTextTimer = nil end end Noble.Text.draw(scene.availableGrenades .. "x", 10, 210, Noble.Text.ALIGN_LEFT, false, font) if scene.availableGrenades <= 0 and not scene:hasActiveGrenades() then Noble.Text.draw("No grenades left", 200, 110, Noble.Text.ALIGN_CENTER, false, font) scene.crosshair:setVisible(false) scene.progressBar:setVisible(false) elseif scene.availableGrenades <= 0 then -- grenades still flying, wait elseif playdate.isCrankDocked() then Noble.Text.draw("Crank it to reload!", 200, 110, Noble.Text.ALIGN_CENTER, false, font) playdate.ui.crankIndicator:draw() end end function scene:spawnEnemies() local activeEnemies = 0 for i = 1, #scene.enemies do if scene.enemies[i] and not scene.enemies[i].removed then activeEnemies = activeEnemies + 1 end end if activeEnemies < self.maxEnemies then local isScout = math.random() < 0.1 scene.enemies[scene.nextEnemyIndex] = Enemy(math.random(30, 370), -20, isScout) scene.nextEnemyIndex = scene.nextEnemyIndex + 1 end scene:scheduleNextEnemySpawn() end function scene:scheduleNextEnemySpawn() local delay = math.random(scene.minSpawnDelay, scene.maxSpawnDelay) scene.enemySpawnTimer = playdate.timer.new(delay, function() scene:spawnEnemies() end) end function scene:finish() scene.themeSound:stop() scene.enemySpawnTimer:remove() -- Remove ALL sprites to prevent leaking into next scene local allSprites = playdate.graphics.sprite.getAllSprites() for i = 1, #allSprites do allSprites[i]:remove() end scene.enemies = {} scene.progressBar = nil if scene.grenadeCooldownTimer then scene.grenadeCooldownTimer:remove() end scene.grenadeCooldownTimer = nil scene.crosshair = nil BomberScene.instance = nil NoiseAnimation.isJamming = false end -- TODO: random spawn some decorations -- TODO: add clouds or smoke -- TODO: random disactivate granades