rockbox/apps/plugins/boomshine.lua

597 lines
18 KiB
Lua
Raw Normal View History

--[[
__________ __ ___.
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, logic rewrite, 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]]
pla = {}
for key, val in pairs(rb.actions) do
for _, v in ipairs({"PLA_", "TOUCHSCREEN", "_NONE"}) do
if string.find (key, v) then
pla[key] = val; break
end
end
end
rb.actions, rb.contexts = nil, nil
--------------------------------------
local _LCD = rb.lcd_framebuffer()
local rocklib_image = getmetatable(_LCD)
local _ellipse = rocklib_image.ellipse
local BSAND = 0x8
local irand = math.random
local HAS_TOUCHSCREEN = rb.action_get_touchscreen_press ~= nil
local LCD_H, LCD_W = rb.LCD_HEIGHT, rb.LCD_WIDTH
local DEFAULT_BALL_SZ = LCD_H > LCD_W and LCD_W / 30 or LCD_H / 30
DEFAULT_BALL_SZ = DEFAULT_BALL_SZ - bit.band(DEFAULT_BALL_SZ, 1)
local MAX_BALL_SPEED = DEFAULT_BALL_SZ / 2 + 1
-- Ball states
local B_DEAD, B_MOVE, B_EXPLODE, B_DIE, B_WAIT, B_IMPLODE = 5, 4, 3, 2, 1, 0
local DEFAULT_FG_CLR = 1
local DEFAULT_BG_CLR = 0
if rb.lcd_get_foreground ~= nil then
DEFAULT_FG_CLR = rb.lcd_get_foreground()
DEFAULT_BG_CLR = rb.lcd_get_background()
elseif rb.LCD_DEFAULT_FG ~= nil then
DEFAULT_FG_CLR = rb.LCD_DEFAULT_FG
DEFAULT_BG_CLR = rb.LCD_DEFAULT_BG
end
local FMT_EXPEND, FMT_LEVEL = "%d balls expended", "Level %d"
local FMT_TOTPTS, FMT_LVPTS = "%d total points", "%d level points"
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},
{58, 60},
{28, 30},
{23, 25},
{18, 20},
{13, 15},
{8, 10},
{9, 10},
{4, 5},
{5, 5}
}
local function getstringsize(str)
local _, w, h = rb.font_getstringsize(str, rb.FONT_UI)
return w, h
end
local function set_foreground(color)
if rb.lcd_set_foreground ~= nil then
rb.lcd_set_foreground(color)
end
end
local function random_color()
if rb.lcd_rgbpack ~= nil then -- color target
return rb.lcd_rgbpack(irand(1,255), irand(1,255), irand(1,255))
end
local color = irand(1, rb.LCD_DEPTH)
color = (rb.LCD_DEPTH == 2) and (3 - color) or color -- invert for 2-bit screens
return color
end
local Ball = {
-- Ball defaults
sz = DEFAULT_BALL_SZ,
next_tick = 0,
state = B_MOVE
}
function Ball:new(o, level)
if o == nil then
level = level or 1
local maxdelay = (level <= 5) and 10 or level
o = {
x = irand(1, LCD_W - self.sz),
y = irand(1, LCD_H - self.sz),
color = random_color(),
xi = Ball:genSpeed(),
yi = Ball:genSpeed(),
step_delay = irand(3, maxdelay / 2),
explosion_sz = irand(2 * self.sz, 4 * self.sz),
life_ticks = irand(rb.HZ / level, rb.HZ * (maxdelay / 5))
}
end
o.life_ticks = o.life_ticks + DEFAULT_BALL_SZ -- extra time for larger screens
setmetatable(o, self)
self.__index = self
return o
end
function Ball:genSpeed()
local speed = irand(-MAX_BALL_SPEED, MAX_BALL_SPEED)
return speed ~= 0 and speed or MAX_BALL_SPEED -- Make sure all balls move
end
function Ball:draw()
_ellipse(_LCD, self.x, self.y,
self.x + self.sz, self.y + self.sz , self.color, self.color, true)
end
function Ball:step(tick)
self.next_tick = tick + self.step_delay
self.x = self.x + self.xi
self.y = self.y + self.yi
if (self.x <= 0 or self.x >= (LCD_W - self.sz)) then
self.xi = -self.xi
self.x = self.x + self.xi
end
if (self.y <= 0 or self.y >= (LCD_H - self.sz)) then
self.yi = -self.yi
self.y = self.y + self.yi
end
end
function Ball:checkHit(other)
if (other.xi >= self.x) and (other.yi >= self.y) and
(self.x + self.sz >= other.x) and (self.y + self.sz >= other.y) then
self.state = B_EXPLODE
-- x/y increment no longer needed it is now impact region
self.xi = self.x + self.sz
self.yi = self.y + self.sz
if other.state < B_EXPLODE then -- add duration to the ball that got hit
other.next_tick = other.next_tick + self.life_ticks
end
return true
end
return false
end
function Ball:draw_exploded()
_ellipse(_LCD, self.x, self.y, self.xi, self.yi, self.color, nil, true)
end
function Ball:step_exploded(tick)
-- exploding ball state machine
-- B_EXPLODE >> B_DIE >> BWAIT >> B_IMPLODE >> B_DEAD
if self.state == B_EXPLODE and self.sz < self.explosion_sz then
self.sz = self.sz + 2
self.x = self.x - 1 -- We do this because we want to stay centered
self.y = self.y - 1
elseif self.state == B_DIE then
self.state = B_WAIT
self.next_tick = tick + self.life_ticks
return
elseif self.state == B_IMPLODE and self.sz > 0 then
self.sz = self.sz - 2
self.x = self.x + 1 -- We do this because we want to stay centered
self.y = self.y + 1
elseif self.state <= B_IMPLODE then
self.state = B_DEAD
return
elseif self.next_tick < tick then
self.state = self.state - 1
return
end
-- fell through, update next_tick and impact region
self.next_tick = tick + self.step_delay
self.xi = self.x + self.sz
self.yi = self.y + self.sz
end
local Cursor = {
sz = (DEFAULT_BALL_SZ * 2),
x = (LCD_W / 2),
y = (LCD_H / 2),
color = DEFAULT_FG_CLR
}
function Cursor:new()
if rb.LCD_DEPTH == 2 then -- invert for 2 - bit screens
self.color = 3 - DEFAULT_FG_CLR
end
self:create_image(DEFAULT_BALL_SZ * 2)
return self
end
function Cursor:create_image(sz)
if not HAS_TOUCHSCREEN then
sz = sz + 1
local img = rb.new_image(sz, sz)
local sz2 = (sz / 2) + 1
local sz4 = (sz / 4)
img:clear(0)
img:line(1, 1, sz4 + 1, 1, 1)
img:line(1, 1, 1, sz4 + 1, 1)
img:line(1, sz, sz4 + 1, sz, 1)
img:line(1, sz, 1, sz - sz4, 1)
img:line(sz, sz, sz - sz4, sz, 1)
img:line(sz, sz, sz, sz - sz4, 1)
img:line(sz, 1, sz - sz4, 1, 1)
img:line(sz, 1, sz, sz4 + 1, 1)
-- crosshairs
img:line(sz2 - sz4, sz2, sz2 + sz4, sz2, 1)
img:line(sz2, sz2 - sz4, sz2, sz2 + sz4, 1)
self.image = img
end
end
local function clamp_roll(iVal, iMin, iMax)
if iVal < iMin then
iVal = iMax
elseif iVal > iMax then
iVal = iMin
end
return iVal
end
function Cursor:do_action(action)
local xi, yi = 0, 0
if HAS_TOUCHSCREEN and action == pla.ACTION_TOUCHSCREEN then
_, self.x, self.y = rb.action_get_touchscreen_press()
return true
elseif action == pla.PLA_SELECT then
return true
elseif (action == pla.PLA_RIGHT or action == pla.PLA_RIGHT_REPEAT) then
xi = self.sz
elseif (action == pla.PLA_LEFT or action == pla.PLA_LEFT_REPEAT) then
xi = -self.sz
elseif (action == pla.PLA_UP or action == pla.PLA_UP_REPEAT) then
yi = -self.sz
elseif (action == pla.PLA_DOWN or action == pla.PLA_DOWN_REPEAT) then
yi = self.sz
end
self.x = clamp_roll(self.x + xi, 1, LCD_W - self.sz)
self.y = clamp_roll(self.y + yi, 1, LCD_H - self.sz)
return false
end
function Cursor:draw()
rocklib_image.copy(_LCD, self.image, self.x, self.y, _NIL, _NIL,
_NIL, _NIL, true, BSAND, self.color)
end
local function calc_score(total, level, goal, expended)
local score = (expended * level) * 100
if expended < goal then
score = -(score + (level * 100))
end
total = total + score
return total
end
local function draw_pos_str(bottom, right, str)
local w, h = getstringsize(str)
local x = (right > 0) and ((LCD_W - w) * right - 1) or 1
local y = (bottom > 0) and ((LCD_H - h) * bottom - 1) or 1
rb.lcd_putsxy(x, y, str)
end
local function wait_anykey(to_secs)
rb.sleep(rb.HZ / 2)
rb.button_clear_queue()
rb.button_get_w_tmo(rb.HZ * to_secs)
end
local function start_round(level, goal, nrBalls, total)
local player_added, score = false, 0
local last_expend, nrBalls_expend = 0, 0
local balls_exploded = 1 -- keep looping when player_added == false
local action = 0
local Balls = {}
local str_level = string.format(FMT_LEVEL, level) -- static
local str_totpts = string.format(FMT_TOTPTS, total) -- static
local str_expend, str_lvlpts
local tick, cursor
local test_spd = false
local function update_stats()
-- we only create a new string when a hit is detected
str_expend = string.format(FMT_EXPEND, nrBalls_expend)
str_lvlpts = string.format(FMT_LVPTS, score)
end
local function draw_stats()
draw_pos_str(0, 0, str_expend)
draw_pos_str(0, 1, str_level)
draw_pos_str(1, 1, str_lvlpts)
draw_pos_str(1, 0, str_totpts)
end
local function add_player()
-- cursor becomes exploded ball
local player = Ball:new({
x = cursor.x,
y = cursor.y,
color = cursor.color,
step_delay = 3,
explosion_sz = (3 * DEFAULT_BALL_SZ),
life_ticks = (test_time == true) and (100) or
irand(rb.HZ * 2, rb.HZ * DEFAULT_BALL_SZ),
sz = 10,
state = B_EXPLODE
})
-- set x/y impact region
player.xi = player.x + player.sz
player.yi = player.y + player.sz
table.insert(Balls, player)
balls_exploded = 1
player_added = true
cursor = nil
end
if level < 1 then
-- check speed of target
set_foreground(DEFAULT_BG_CLR) --hide text during test
local bkcolor = (rb.LCD_DEPTH == 2) and (3) or 0
level = 1
nrBalls = 20
cursor = { x = LCD_W * 2, y = LCD_H * 2, color = bkcolor}
table.insert(Balls, Ball:new({
x = 1, y = 1, xi = 1, yi = 1,
color = bkcolor, step_delay = 1,
explosion_sz = 0, life_ticks = 0,
step = function() test_spd = test_spd + 1 end
})
)
add_player()
test_spd = 0
else
set_foreground(DEFAULT_FG_CLR) -- color for text
cursor = Cursor:new()
end
-- Initialize the balls
for i=1, nrBalls do
table.insert(Balls, Ball:new(nil, level))
end
-- Make sure there are no unwanted touchscreen presses
rb.button_clear_queue()
update_stats() -- load status strings
-- Check if the round is over
while balls_exploded > 0 do
tick = rb.current_tick()
if action ~= pla.ACTION_NONE and (action == pla.PLA_EXIT or
action == pla.PLA_CANCEL) then
action = pla.PLA_EXIT
break
end
rb.lcd_clear_display()
if not player_added then
if action ~= pla.ACTION_NONE and cursor:do_action(action) then
add_player()
elseif not HAS_TOUCHSCREEN then
cursor:draw()
end
end
for _, Ball in ipairs(Balls) do
if Ball.state == B_MOVE then
if tick > Ball.next_tick then
Ball:step(tick)
for i = #Balls, 1, -1 do
if Balls[i].state < B_MOVE and
Ball:checkHit(Balls[i]) then -- exploded?
balls_exploded = balls_exploded + 1
nrBalls_expend = nrBalls_expend + 1
break
end
end
end
-- check if state changed draw ball if still moving
if Ball.state == B_MOVE then
Ball:draw()
end
elseif Ball.state ~= B_DEAD then
if tick > Ball.next_tick then
Ball:step_exploded(tick)
end
if Ball.state ~= B_DEAD then
Ball:draw_exploded()
else
balls_exploded = balls_exploded - 1
end
end
end
draw_stats() -- stats redrawn every frame
rb.lcd_update() -- Push framebuffer to the LCD
if nrBalls_expend ~= last_expend then -- hit detected?
last_expend = nrBalls_expend
score = (nrBalls_expend * level) * 100
update_stats() -- only update stats when not current
if nrBalls_expend == nrBalls then break end -- round is over?
end
rb.yield() -- yield to other tasks
action = rb.get_plugin_action(0) -- Check for actions
end
if test_spd and test_spd > 0 then return test_spd end
-- splash the final stats for a moment at end
rb.lcd_clear_display()
for _, Ball in ipairs(Balls) do
-- move balls back to their initial exploded positions
if Ball.state == B_DEAD then
Ball.sz = Ball.explosion_sz + 2
Ball.x = Ball.x - (Ball.explosion_sz / 2)
Ball.y = Ball.y - (Ball.explosion_sz / 2)
end
Ball:draw()
end
if DEFAULT_BALL_SZ > 3 then
_LCD:clear(nil, nil, nil, nil, nil, nil, 2, 2)
end
total = calc_score(total, level, goal, nrBalls_expend)
str_totpts = string.format(FMT_TOTPTS, total)
draw_stats()
rb.lcd_update()
wait_anykey(2)
return action == pla.PLA_EXIT, score, nrBalls_expend
end
-- Helper function to display a message
local function disp_msg(to, ...)
local message = string.format(...)
local w, h = getstringsize(message)
local x, y = (LCD_W - w) / 2, (LCD_H - h) / 2
rb.lcd_clear_display()
set_foreground(DEFAULT_FG_CLR)
if w > LCD_W 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, h = getstringsize(msg)
x = (LCD_W - 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
wait_anykey(60)
else
rb.sleep(to)
end
rb.lcd_scroll_stop() -- Stop our scrolling message
end
--[[MAIN PROGRAM]]
do -- attempt to get stats to fit on screen
local function getwidth(str)
local w, _ = getstringsize(str)
return w
end
local w0, w1 = getwidth(FMT_EXPEND), getwidth(FMT_LEVEL)
if (w0 + w1) > LCD_W then
FMT_EXPEND, FMT_LEVEL = "%d balls", "Lv %d"
end
w0, w1 = getwidth(FMT_TOTPTS), getwidth(FMT_LVPTS)
if (w0 + w1 + getwidth("0000000")) > LCD_W then
FMT_TOTPTS, FMT_LVPTS = "%d total", "%d lv"
end
end
if HAS_TOUCHSCREEN then
rb.touchscreen_mode(rb.TOUCHSCREEN_POINT)
end
rb.backlight_force_on()
math.randomseed(os.time())
local idx, highscore = 1, 0
local test_spd = start_round(0, 0, 0, 0) -- test speed of target
if test_spd < 100 then
rb.splash(100, "Slow Target..")
if test_spd < 25 then
MAX_BALL_SPEED = MAX_BALL_SPEED + MAX_BALL_SPEED
elseif test_spd < 50 then
MAX_BALL_SPEED = MAX_BALL_SPEED + MAX_BALL_SPEED / 2
elseif test_spd < 100 then
MAX_BALL_SPEED = MAX_BALL_SPEED + MAX_BALL_SPEED / 4
end
end
while levels[idx] ~= nil do
local goal, nrBalls = levels[idx][1], levels[idx][2]
collectgarbage("collect") -- run gc now to hopefully prevent interruption later
disp_msg(rb.HZ * 2, "Level %d: get %d out of %d balls", idx, goal, nrBalls)
local exit, score, nrBalls_expend = start_round(idx, goal, nrBalls, highscore)
if exit then
break -- Exiting..
else
highscore = calc_score(highscore, idx, goal, nrBalls_expend)
if nrBalls_expend >= goal then
disp_msg(rb.HZ * 2, "You won!")
levels[idx] = nil
idx = idx + 1
else
disp_msg(rb.HZ * 2, "You lost %d points!", score + (idx * 100))
if highscore < 0 then break end
end
end
end
if highscore <= 0 then
disp_msg(-1, "You lost at level %d", idx)
elseif idx > #levels then
disp_msg(-1, "You finished the game with %d points!", highscore)
else
disp_msg(-1, "You made it till level %d with %d points!", idx, highscore)
end
-- Restore user backlight settings
rb.backlight_use_settings()