| --[[ |
| __________ __ ___. |
| Open \______ \ ____ ____ | | _\_ |__ _______ ___ |
| Source | _// _ \_/ ___\| |/ /| __ \ / _ \ \/ / |
| Jukebox | | ( <_> ) \___| < | \_\ ( <_> > < < |
| Firmware |____|_ /\____/ \___ >__|_ \|___ /\____/__/\_ \ |
| \/ \/ \/ \/ \/ |
| $Id$ |
| |
| Port of Chain Reaction (which is based on Boomshine) to Rockbox in Lua. |
| See http://www.yvoschaap.com/chainrxn/ and http://www.k2xl.com/games/boomshine/ |
| |
| Copyright (C) 2009 by Maurus Cuelenaere |
| Copyright (C) 2018 William Wilgus -- Added circles, blit cursor, hard levels |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
| |
| This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY |
| KIND, either express or implied. |
| |
| ]]-- |
| require "actions" |
| -- [[only save the actions we are using]] |
| actions_pla = {} |
| for key, val in pairs(rb.actions) do |
| for _, v in ipairs({"PLA_", "TOUCHSCREEN"}) do |
| if string.find (key, v) then |
| actions_pla[key] = val |
| break |
| end |
| end |
| end |
| rb.actions = nil |
| rb.contexts = nil |
| ------------------------------------- |
| |
| local _LCD = rb.lcd_framebuffer() |
| local BSAND = 0x8 |
| local rocklib_image = getmetatable(rb.lcd_framebuffer()) |
| local _ellipse = rocklib_image.ellipse |
| |
| local CYCLETIME = rb.HZ / 50 |
| local HAS_TOUCHSCREEN = rb.action_get_touchscreen_press ~= nil |
| local DEFAULT_BALL_SIZE = rb.LCD_HEIGHT > rb.LCD_WIDTH and rb.LCD_WIDTH / 30 |
| or rb.LCD_HEIGHT / 30 |
| local MAX_BALL_SPEED = DEFAULT_BALL_SIZE / 2 |
| local DEC_BALL_SPEED = DEFAULT_BALL_SIZE / 8 |
| local DEFAULT_FOREGROUND_COLOR = rb.lcd_get_foreground ~= nil |
| and rb.lcd_get_foreground() |
| or 0 |
| |
| local levels = { |
| -- {GOAL, TOTAL_BALLS}, |
| {1, 5}, |
| {2, 10}, |
| {4, 15}, |
| {6, 20}, |
| {10, 25}, |
| {15, 30}, |
| {18, 35}, |
| {22, 40}, |
| {30, 45}, |
| {37, 50}, |
| {48, 55}, |
| {59, 60}, |
| {29, 30}, |
| {24, 25}, |
| {19, 20}, |
| {14, 15}, |
| {9, 10}, |
| {10, 10}, |
| {4, 5}, |
| {5, 5} |
| } |
| |
| local Ball = { |
| size = DEFAULT_BALL_SIZE, |
| exploded = false, |
| implosion = false |
| } |
| |
| local function create_cursor(size) |
| local cursor |
| if not HAS_TOUCHSCREEN then |
| cursor = rb.new_image(size, size) |
| cursor:clear(0) |
| local sz2 = size / 2 |
| local sz4 = size / 4 |
| |
| cursor:line(1, 1, sz4, 1, 1) |
| cursor:line(1, 1, 1, sz4, 1) |
| |
| cursor:line(1, size, sz4, size, 1) |
| cursor:line(1, size, 1, size - sz4, 1) |
| |
| cursor:line(size, size, size - sz4, size, 1) |
| cursor:line(size, size, size, size - sz4, 1) |
| |
| cursor:line(size, 1, size - sz4, 1, 1) |
| cursor:line(size, 1, size, sz4, 1) |
| |
| --crosshairs |
| cursor:line(sz2 - sz4, sz2, sz2 + sz4, sz2, 1) |
| cursor:line(sz2, sz2 - sz4, sz2, sz2 + sz4, 1) |
| end |
| return cursor |
| end |
| |
| function Ball:new(o, level) |
| level = level or 1 |
| if o == nil then |
| o = { |
| x = math.random(0, rb.LCD_WIDTH - self.size), |
| y = math.random(0, rb.LCD_HEIGHT - self.size), |
| color = random_color(), |
| up_speed = Ball:generateSpeed(level), |
| right_speed = Ball:generateSpeed(level), |
| explosion_size = math.random(2*self.size, 4*self.size), |
| life_duration = math.random(rb.HZ / level, rb.HZ*5), |
| } |
| end |
| |
| setmetatable(o, self) |
| self.__index = self |
| return o |
| end |
| |
| function Ball:generateSpeed(level) |
| local ballspeed = MAX_BALL_SPEED - (DEC_BALL_SPEED * (level - 1)) |
| if ballspeed < 2 then ballspeed = 2 end |
| local speed = math.random(-ballspeed, ballspeed) |
| if speed == 0 then |
| speed = 1 -- Make sure all balls move |
| end |
| |
| return speed |
| end |
| |
| function Ball:draw_exploded() |
| --[[ |
| --set_foreground(self.color) |
| --rb.lcd_fillrect(self.x, self.y, self.size, self.size) |
| ]] |
| |
| _ellipse(_LCD, self.x, self.y, |
| self.x + self.size, self.y + self.size , self.color, nil, true) |
| end |
| |
| function Ball:draw() |
| --[[ |
| --set_foreground(self.color) |
| --rb.lcd_fillrect(self.x, self.y, self.size, self.size) |
| ]] |
| |
| _ellipse(_LCD, self.x, self.y, |
| self.x + self.size, self.y + self.size , self.color, self.color, true) |
| end |
| |
| function Ball:step_exploded() |
| if self.implosion and self.size > 0 then |
| self.size = self.size - 2 |
| self.x = self.x + 1 -- We do this because we want to stay centered |
| self.y = self.y + 1 |
| elseif self.size < self.explosion_size then |
| self.size = self.size + 2 |
| self.x = self.x - 1 -- We do this for the same reasons as above |
| self.y = self.y - 1 |
| end |
| end |
| |
| function Ball:step() |
| self.x = self.x + self.right_speed |
| self.y = self.y + self.up_speed |
| if (self.x <= 0 or self.x >= rb.LCD_WIDTH - self.size) then |
| self.right_speed = -self.right_speed |
| self.x = self.x + self.right_speed |
| end |
| if (self.y <= 0 or self.y >= rb.LCD_HEIGHT - self.size ) then |
| self.up_speed = -self.up_speed |
| self.y = self.y + self.up_speed |
| end |
| end |
| |
| function Ball:checkHit(other) |
| if (other.x + other.size >= self.x) and (self.x + self.size >= other.x) and |
| (other.y + other.size >= self.y) and (self.y + self.size >= other.y) then |
| assert(not self.exploded) |
| self.exploded = true |
| self.death_time = rb.current_tick() + self.life_duration |
| if not other.exploded then |
| other.exploded = true |
| other.death_time = rb.current_tick() + other.life_duration |
| end |
| return true |
| end |
| |
| return false |
| end |
| |
| local Cursor = { |
| size = DEFAULT_BALL_SIZE*2, |
| x = rb.LCD_WIDTH/2, |
| y = rb.LCD_HEIGHT/2, |
| image = create_cursor(DEFAULT_BALL_SIZE*2) |
| } |
| |
| function Cursor:new() |
| return self |
| end |
| |
| function Cursor:do_action(action) |
| if action == actions_pla.ACTION_TOUCHSCREEN and HAS_TOUCHSCREEN then |
| _, self.x, self.y = rb.action_get_touchscreen_press() |
| return true |
| elseif action == actions_pla.PLA_SELECT then |
| return true |
| elseif (action == actions_pla.PLA_RIGHT or action == actions_pla.PLA_RIGHT_REPEAT) then |
| self.x = self.x + self.size |
| elseif (action == actions_pla.PLA_LEFT or action == actions_pla.PLA_LEFT_REPEAT) then |
| self.x = self.x - self.size |
| elseif (action == actions_pla.PLA_UP or action == actions_pla.PLA_UP_REPEAT) then |
| self.y = self.y - self.size |
| elseif (action == actions_pla.PLA_DOWN or action == actions_pla.PLA_DOWN_REPEAT) then |
| self.y = self.y + self.size |
| end |
| |
| if self.x > rb.LCD_WIDTH then |
| self.x = 0 |
| elseif self.x < 0 then |
| self.x = rb.LCD_WIDTH |
| end |
| |
| if self.y > rb.LCD_HEIGHT then |
| self.y = 0 |
| elseif self.y < 0 then |
| self.y = rb.LCD_HEIGHT |
| end |
| |
| return false |
| end |
| |
| function Cursor:draw() |
| |
| rocklib_image.copy(_LCD, self.image, self.x - self.size/2, self.y - self.size/2, |
| _NIL, _NIL, _NIL, _NIL, true, BSAND, DEFAULT_FOREGROUND_COLOR) |
| end |
| |
| function draw_positioned_string(bottom, right, str) |
| local _, w, h = rb.font_getstringsize(str, rb.FONT_UI) |
| local x = not right or (rb.LCD_WIDTH-w)*right - 1 |
| local y = not bottom or (rb.LCD_HEIGHT-h)*bottom - 1 |
| rb.lcd_putsxy(x, y, str) |
| end |
| |
| function set_foreground(color) |
| if rb.lcd_set_foreground ~= nil then |
| rb.lcd_set_foreground(color) |
| end |
| end |
| |
| function random_color() |
| if rb.lcd_rgbpack ~= nil then --color target |
| return rb.lcd_rgbpack(math.random(1,255), math.random(1,255), math.random(1,255)) |
| end |
| |
| return math.random(1, rb.LCD_DEPTH) |
| end |
| |
| function start_round(level, goal, nrBalls, total) |
| local player_added, score, exit, nrExpendedBalls = false, 0, false, 0 |
| local Balls, explodedBalls = {}, {} |
| local ball_ct, ball_el |
| local tick, endtick |
| local action = 0 |
| local hit_detected = false |
| local cursor = Cursor:new() |
| |
| -- Initialize the balls |
| for _=1,nrBalls do |
| table.insert(Balls, Ball:new(nil, level)) |
| end |
| |
| local function draw_stats() |
| draw_positioned_string(0, 0, string.format("%d balls expended", nrExpendedBalls)) |
| draw_positioned_string(0, 1, string.format("Level %d", level)) |
| draw_positioned_string(1, 1, string.format("%d level points", score)) |
| draw_positioned_string(1, 0, string.format("%d total points", total + score)) |
| end |
| |
| -- Make sure there are no unwanted touchscreen presses |
| rb.button_clear_queue() |
| |
| set_foreground(DEFAULT_FOREGROUND_COLOR) -- color for text |
| |
| while true do |
| endtick = rb.current_tick() + CYCLETIME |
| |
| -- Check if the round is over |
| if player_added and #explodedBalls == 0 then |
| break |
| end |
| |
| if action == actions_pla.PLA_EXIT or action == actions_pla.PLA_CANCEL then |
| exit = true |
| break |
| end |
| |
| if not player_added and cursor:do_action(action) then |
| local player = Ball:new({ |
| x = cursor.x, |
| y = cursor.y, |
| color = DEFAULT_FOREGROUND_COLOR, |
| size = 10, |
| explosion_size = 3*DEFAULT_BALL_SIZE, |
| exploded = true, |
| death_time = rb.current_tick() + rb.HZ * 3 |
| }) |
| explodedBalls[1] = player |
| player_added = true |
| cursor = nil |
| end |
| |
| -- Check for hits |
| for i, Ball in ipairs(Balls) do |
| for _, explodedBall in ipairs(explodedBalls) do |
| if Ball:checkHit(explodedBall) then |
| explodedBalls[#explodedBalls + 1] = Ball |
| --table.remove(Balls, i) |
| Balls[i] = false |
| hit_detected = true |
| break |
| end |
| end |
| end |
| |
| -- Check if we're dead yet |
| tick = rb.current_tick() |
| for i, explodedBall in ipairs(explodedBalls) do |
| if explodedBall.death_time < tick then |
| if explodedBall.size > 0 then |
| explodedBall.implosion = true -- We should be dying |
| else |
| table.remove(explodedBalls, i) -- We're imploded! |
| end |
| end |
| end |
| |
| -- Drawing phase |
| if hit_detected then |
| hit_detected = false |
| -- same as table.remove(Balls, i) but more efficient |
| ball_el = 1 |
| ball_ct = #Balls |
| for i = 1, ball_ct do |
| if Balls[i] then |
| Balls[ball_el] = Balls[i] |
| ball_el = ball_el + 1 |
| end |
| end |
| -- remove any remaining exploded balls |
| for i = ball_el, ball_ct do |
| Balls[i] = nil |
| end |
| -- Calculate score |
| nrExpendedBalls = nrBalls - ball_el + 1 |
| score = nrExpendedBalls * level * 100 |
| end |
| |
| rb.lcd_clear_display() |
| draw_stats() |
| |
| if not (player_added or HAS_TOUCHSCREEN) then |
| cursor:draw() |
| end |
| |
| for _, Ball in ipairs(Balls) do |
| Ball:step() |
| Ball:draw() |
| end |
| |
| for _, explodedBall in ipairs(explodedBalls) do |
| explodedBall:step_exploded() |
| explodedBall:draw_exploded() |
| end |
| |
| -- Push framebuffer to the LCD |
| rb.lcd_update() |
| |
| -- Check for actions |
| if rb.current_tick() < endtick then |
| action = rb.get_plugin_action(endtick - rb.current_tick()) |
| else |
| rb.yield() |
| action = rb.get_plugin_action(0) |
| end |
| |
| end |
| |
| --splash the final stats for a moment at end |
| rb.lcd_clear_display() |
| for _, Ball in ipairs(Balls) do Ball:draw() end |
| _LCD:clear(nil, nil, nil, nil, nil, nil, 2, 2) |
| draw_stats() |
| rb.lcd_update() |
| rb.sleep(rb.HZ * 2) |
| |
| return exit, score, nrExpendedBalls |
| end |
| |
| -- Helper function to display a message |
| function display_message(to, ...) |
| local message = string.format(...) |
| local _, w, h = rb.font_getstringsize(message, rb.FONT_UI) |
| local x, y = (rb.LCD_WIDTH - w) / 2, (rb.LCD_HEIGHT - h) / 2 |
| |
| rb.lcd_clear_display() |
| set_foreground(DEFAULT_FOREGROUND_COLOR) |
| |
| if w > rb.LCD_WIDTH then |
| rb.lcd_puts_scroll(0, y/h, message) |
| else |
| rb.lcd_putsxy(x, y, message) |
| end |
| if to == -1 then |
| local msg = "Press button to exit" |
| w = rb.font_getstringsize(msg, rb.FONT_UI) |
| x = (rb.LCD_WIDTH - w) / 2 |
| if x < 0 then |
| rb.lcd_puts_scroll(0, y/h + 1, msg) |
| else |
| rb.lcd_putsxy(x, y + h, msg) |
| end |
| end |
| rb.lcd_update() |
| |
| if to == -1 then |
| rb.sleep(rb.HZ/2) |
| rb.button_clear_queue() |
| rb.button_get(rb.HZ * 60) |
| else |
| rb.sleep(to) |
| end |
| |
| rb.lcd_scroll_stop() -- Stop our scrolling message |
| end |
| |
| if HAS_TOUCHSCREEN then |
| rb.touchscreen_set_mode(rb.TOUCHSCREEN_POINT) |
| end |
| |
| --[[MAIN PROGRAM]] |
| rb.backlight_force_on() |
| |
| math.randomseed(os.time()) |
| |
| local idx, highscore = 1, 0 |
| while levels[idx] ~= nil do |
| local goal, nrBalls = levels[idx][1], levels[idx][2] |
| |
| collectgarbage("collect") --run gc now to hopefully prevent interruption later |
| |
| display_message(rb.HZ*2, "Level %d: get %d out of %d balls", idx, goal, nrBalls) |
| |
| local exit, score, nrExpendedBalls = start_round(idx, goal, nrBalls, highscore) |
| |
| if exit then |
| break -- Exiting.. |
| else |
| if nrExpendedBalls >= goal then |
| display_message(rb.HZ*2, "You won!") |
| idx = idx + 1 |
| highscore = highscore + score |
| else |
| display_message(rb.HZ*2, "You lost!") |
| highscore = highscore - score - idx * 100 |
| if highscore < 0 then break end |
| end |
| end |
| end |
| if highscore <= 0 then |
| display_message(-1, "You lost at level %d", idx) |
| elseif idx > #levels then |
| display_message(-1, "You finished the game with %d points!", highscore) |
| else |
| display_message(-1, "You made it till level %d with %d points!", idx, highscore) |
| end |
| |
| -- Restore user backlight settings |
| rb.backlight_use_settings() |
| |