local easing = require('lib.easing') local tau = math.pi * 2 local delta_time = 1/120 local game = {} local function load_level(level, bubble_diameter, row_gap) local slots = {} local bubble_radius = bubble_diameter / 2 local num_rows = #level local max_cols = math.max(#level[1], #level[2]) local min_cols = math.min(#level[1], #level[2]) assert( max_cols == min_cols + 1, 'number of columns in alternating rows must differ by one' ) local num_bubble_slots = 0 if num_rows % 2 == 0 then num_bubble_slots = num_rows / 2 * #level[1] + num_rows / 2 * #level[2] else num_bubble_slots = math.ceil(num_rows / 2) * #level[1] + math.floor(num_rows / 2) * #level[2] end print('slots', num_bubble_slots) for row = 1, #level do local num_cols = #level[row] assert( num_cols == min_cols or num_cols == max_cols, string.format("wrong number of columns (%d) in row %d", num_cols, row) ) local x_offset = num_cols == min_cols and bubble_radius or 0 for col = 1, num_cols do local index = #slots + 1 slots[index] = { x = x_offset + bubble_radius + (col - 1) * bubble_diameter, y = bubble_radius + (row - 1) * row_gap, bubble_type = level[row][col], neighbours = {} } if col > 1 then -- left slots[index].neighbours[#slots[index].neighbours+1] = index - 1 end if col < num_cols then -- right slots[index].neighbours[#slots[index].neighbours+1] = index + 1 end if row % 2 == 0 or (row > 1 and col > 1) then -- up left slots[index].neighbours[#slots[index].neighbours+1] = index - max_cols end if row % 2 == 0 or (row > 1 and col < num_cols) then -- up right slots[index].neighbours[#slots[index].neighbours+1] = index - min_cols end if row % 2 == 0 or col > 1 then -- down left if index + min_cols <= num_bubble_slots then slots[index].neighbours[#slots[index].neighbours+1] = index + min_cols end end if row % 2 == 0 or col < num_cols then -- down right if index + max_cols <= num_bubble_slots then slots[index].neighbours[#slots[index].neighbours+1] = index + max_cols end end end end return slots end -- ex. remaining_bubble_types = {1,4,5,8} -- should only be regular, not special local function get_next_bubble_type(index, remaining_bubble_types) return remaining_bubble_types[love.math.random(#remaining_bubble_types)] end local function get_bubble_counts() local total = 0 local count_by_type = {} for i = 1, #game.bubble_slots do local bubble_type = game.bubble_slots[i].bubble_type if bubble_type ~= 0 then total = total + 1 end if count_by_type[bubble_type] == nil then count_by_type[bubble_type] = 0 end count_by_type[bubble_type] = count_by_type[bubble_type] + 1 end return total, count_by_type end local function find_nearest_slot(x, y) local nearest_slot_index = 0 local min_dist = math.huge for i = 1, #game.bubble_slots do local slot = game.bubble_slots[i] if slot.bubble_type == 0 then local diff_x = x - slot.x local diff_y = y - slot.y local dist = math.sqrt(diff_x * diff_x + diff_y * diff_y) if dist < min_dist then min_dist = dist nearest_slot_index = i end end end assert(nearest_slot_index ~= 0, "no nearest slot found") return nearest_slot_index end local function move_bubble(x, y, velocity_x, velocity_y) local start_time = love.timer.getTime() local new_x = x + velocity_x local new_y = y + velocity_y local new_velocity_x = velocity_x local new_velocity_y = velocity_y local should_stop = false -- collision with ceiling if new_y - game.bubble_radius <= game.level_top then should_stop = true end -- collision with walls if not should_stop then if new_x + game.bubble_radius >= game.level_right or new_x - game.bubble_radius <= game.level_left then local impact_x = game.level_left + game.bubble_radius if new_x + game.bubble_radius >= game.level_right then impact_x = game.level_right - game.bubble_radius end local dx = impact_x - x local ratio = dx / new_velocity_x local dy = new_velocity_y * ratio local impact_y = y + dy local ratio_after_bounce = 1.0 - ratio new_velocity_x = -new_velocity_x new_x = impact_x + new_velocity_x * ratio_after_bounce new_y = impact_y + new_velocity_y * ratio_after_bounce end end -- collision with another bubble if not should_stop then for i = 1, #game.bubble_slots do local slot = game.bubble_slots[i] if slot.bubble_type ~= 0 then local diff_x = new_x - slot.x local diff_y = new_y - slot.y local dist = math.sqrt(diff_x * diff_x + diff_y * diff_y) if dist <= game.bubble_diameter - 3 then -- -3 to allow squeezing through should_stop = true break end end end end local nearest_slot_index = 0 if should_stop then nearest_slot_index = find_nearest_slot(new_x, new_y) new_x = game.bubble_slots[nearest_slot_index].x new_y = game.bubble_slots[nearest_slot_index].y new_velocity_x = 0 new_velocity_y = 0 end -- print(string.format("%.10f", love.timer.getTime() - start_time)) return { x = new_x, y = new_y, velocity_x = new_velocity_x, velocity_y = new_velocity_y, should_stop = should_stop, nearest_slot_index = nearest_slot_index } end local function generate_aim_guide(towards_x, towards_y) -- TODO: don't allow aiming backwards local diff_x = towards_x - game.launcher_x local diff_y = towards_y - game.launcher_y local dist = math.sqrt(diff_x * diff_x + diff_y * diff_y) local velocity_x = diff_x / dist * game.bubble_speed local velocity_y = diff_y / dist * game.bubble_speed local new_x = game.launcher_x local new_y = game.launcher_y local radius = 5 game.aim_guide = {} for i = 1, 125 do if i >= 15 and i % 5 == 0 then game.aim_guide[#game.aim_guide+1] = { x = new_x, y = new_y, radius = radius } end local m = move_bubble(new_x, new_y, velocity_x, velocity_y) new_x = m.x new_y = m.y velocity_x = m.velocity_x velocity_y = m.velocity_y if m.should_stop then game.aim_guide[#game.aim_guide+1] = { x = new_x, y = new_y, radius = radius } break end end for i = 1, #game.aim_guide - 1 do local curr_dot = game.aim_guide[i] local next_dot = game.aim_guide[i+1] curr_dot.tween = { delay = 20, t = 0, d = 40, b_x = curr_dot.x, c_x = next_dot.x - curr_dot.x, b_y = curr_dot.y, c_y = next_dot.y - curr_dot.y } end end local function find_matches(start_index) local bubble_type = game.bubble_slots[start_index].bubble_type local to_check = {start_index} local matches = {start_index} local visited = {} visited[start_index] = true while #to_check > 0 do local current_index = table.remove(to_check) local slot = game.bubble_slots[current_index] for i = 1, #slot.neighbours do local neighbour_index = slot.neighbours[i] local neighbour = game.bubble_slots[neighbour_index] if not visited[neighbour_index] and neighbour.bubble_type == bubble_type then matches[#matches+1] = neighbour_index to_check[#to_check+1] = neighbour_index end visited[neighbour_index] = true end end return matches end local function find_unattached_bubbles() local to_check = {} local visited = {} local attached = {} local unattached = {} -- start with top row bubbles attached to the ceiling for i = 1, #game.levels[game.current_level][1] do if game.bubble_slots[i].bubble_type ~= 0 then to_check[#to_check+1] = i attached[i] = true end visited[i] = true end while #to_check > 0 do local current_index = table.remove(to_check) local slot = game.bubble_slots[current_index] for i = 1, #slot.neighbours do local neighbour_index = slot.neighbours[i] local neighbour = game.bubble_slots[neighbour_index] if not visited[neighbour_index] and neighbour.bubble_type ~= 0 then attached[neighbour_index] = true to_check[#to_check+1] = neighbour_index end visited[neighbour_index] = true end end for i = 1, #game.bubble_slots do if game.bubble_slots[i].bubble_type ~= 0 and not attached[i] then unattached[#unattached+1] = i end end return unattached end function love.load(arg) if arg[#arg] == "debug" then require("lldebugger").start() end game.window_width, game.window_height = love.graphics.getDimensions() game.score_font = love.graphics.setNewFont(50) game.points_font = love.graphics.newFont(30) game.paused = false game.frame_by_frame = false game.bubble_diameter = 60 game.bubble_radius = game.bubble_diameter / 2 game.bubble_speed = 960 * delta_time -- 960 px/s -- vertical distance between bubble center points game.row_gap = math.ceil(math.sqrt( (game.bubble_diameter * game.bubble_diameter) - (game.bubble_radius * game.bubble_radius) )) game.background_image = love.graphics.newImage('images/background.png') game.background_width = game.background_image:getWidth() game.bubble_images = { love.graphics.newImage('images/red.png'), -- 1 love.graphics.newImage('images/orange.png'), -- 2 love.graphics.newImage('images/yellow.png'), -- 3 love.graphics.newImage('images/green.png'), -- 4 love.graphics.newImage('images/blue.png'), -- 5 love.graphics.newImage('images/purple.png'), -- 6 love.graphics.newImage('images/pink.png'), -- 7 love.graphics.newImage('images/white.png') -- 8 } game.score = 0 game.points_display = {} game.levels = {} game.levels[1] = { {1,1,1,3,3,2,2,2}, { 1,1,3,3,3,2,2 }, {4,4,7,8,8,6,5,5}, { 4,7,7,8,6,6,5 }, {0,0,0,0,0,0,0,0}, { 0,0,0,0,0,0,0 }, {0,0,0,0,0,0,0,0}, { 0,0,0,0,0,0,0 }, {0,0,0,0,0,0,0,0}, { 0,0,0,0,0,0,0 }, {0,0,0,0,0,0,0,0}, { 0,0,0,0,0,0,0 }, {0,0,0,0,0,0,0,0}, { 0,0,0,0,0,0,0 } } game.current_level = 1 game.bubble_slots = load_level( game.levels[game.current_level], game.bubble_diameter, game.row_gap ) local max_cols = math.max( #game.levels[game.current_level][1], #game.levels[game.current_level][2] ) local num_rows = #game.levels[game.current_level] game.level_width = max_cols * game.bubble_diameter game.level_height = (num_rows - 1) * game.row_gap + game.bubble_diameter game.level_left = (game.window_width - game.level_width) / 2 game.level_top = game.bubble_radius game.level_right = game.level_left + game.level_width game.level_bottom = game.level_top + game.level_height for i = 1, #game.bubble_slots do game.bubble_slots[i].x = game.bubble_slots[i].x + game.level_left game.bubble_slots[i].y = game.bubble_slots[i].y + game.level_top end game.launcher_image = love.graphics.newImage('images/launcher.png') game.launcher_height = game.launcher_image:getHeight() game.launcher_width = game.launcher_image:getWidth() game.launcher_x = game.window_width / 2 game.launcher_y = game.level_top + game.level_height + game.bubble_diameter game.launcher_offset_x = 90 -- rotation point in launcher image game.launcher_offset_y = game.launcher_height / 2 game.launcher_rotation = -tau / 4 -- up game.show_aim_guide = true game.aim_guide = {} local total_bubble_count, counts_by_type = get_bubble_counts() local bubble_types = {} for bubble_type, count in pairs(counts_by_type) do if bubble_type >= 1 and bubble_type <= 8 then bubble_types[#bubble_types+1] = bubble_type end end game.next_bubble_index = 1 game.next_bubble = { x = game.launcher_x, y = game.launcher_y, bubble_type = get_next_bubble_type(game.next_bubble_index, bubble_types), velocity_x = 0, velocity_y = 0 } game.bursting_bubbles = {} game.falling_bubbles = {} end function love.update(dt) if game.paused then return end if game.show_aim_guide then for i = 1, #game.aim_guide do local dot = game.aim_guide[i] if dot.tween then if dot.tween.delay == 0 then dot.tween.t = dot.tween.t + 1 if dot.tween.t == dot.tween.d then dot.tween.t = 0 else dot.x = easing.linear(dot.tween.t, dot.tween.b_x, dot.tween.c_x, dot.tween.d) dot.y = easing.linear(dot.tween.t, dot.tween.b_y, dot.tween.c_y, dot.tween.d) end else dot.tween.delay = dot.tween.delay - 1 end end end end if game.next_bubble and (game.next_bubble.velocity_x ~= 0 or game.next_bubble.velocity_y ~= 0) then local movement_info movement_info = move_bubble( game.next_bubble.x, game.next_bubble.y, game.next_bubble.velocity_x, game.next_bubble.velocity_y ) game.next_bubble.x = movement_info.x game.next_bubble.y = movement_info.y game.next_bubble.velocity_x = movement_info.velocity_x game.next_bubble.velocity_y = movement_info.velocity_y if movement_info.should_stop then game.bubble_slots[movement_info.nearest_slot_index].bubble_type = game.next_bubble.bubble_type local matches = find_matches(movement_info.nearest_slot_index) if #matches >= 3 then game.next_bubble = nil -- remove matches for i = 1, #matches do local index = matches[i] game.bursting_bubbles[#game.bursting_bubbles+1] = { delay = (i - 1) * 5, x = game.bubble_slots[index].x, y = game.bubble_slots[index].y, bubble_type = game.bubble_slots[index].bubble_type, scale = 1.0, alpha = 1.0 } game.bubble_slots[index].bubble_type = 0 local points = i * 5 game.score = game.score + points game.points_display[#game.points_display+1] = { points = points, x = game.bubble_slots[index].x, y = game.bubble_slots[index].y, tween = { delay = 0, t = 0, d = 50, b = game.bubble_slots[index].y - 10, c = -game.bubble_radius } } end -- remove unattached bubbles local unattached = find_unattached_bubbles() for i = 1, #unattached do local index = unattached[i] game.falling_bubbles[#game.falling_bubbles+1] = { x = game.bubble_slots[index].x, y = game.bubble_slots[index].y, velocity_x = (i % 2 == 0 and -200 or 200) * delta_time, velocity_y = love.math.random(-200.0, -500.0) * delta_time, bubble_type = game.bubble_slots[index].bubble_type } game.bubble_slots[index].bubble_type = 0 local points = (i + 1) ^ 3 game.score = game.score + points print(points, "points") game.points_display[#game.points_display+1] = { points = points, x = game.bubble_slots[index].x, y = game.bubble_slots[index].y, tween = { delay = 0, t = 0, d = 50, b = game.bubble_slots[index].y - 10, c = -game.bubble_radius } } end end if game.show_aim_guide then generate_aim_guide(love.mouse:getX(), love.mouse:getY()) end -- calculate remaining bubble types local total_bubble_count, counts_by_type = get_bubble_counts() if total_bubble_count == 0 then print('level cleared!') else local bubble_types = {} for bubble_type, count in pairs(counts_by_type) do if bubble_type >= 1 and bubble_type <= 8 then bubble_types[#bubble_types+1] = bubble_type end end -- get next bubble game.next_bubble_index = game.next_bubble_index + 1 game.next_bubble = { x = game.launcher_x, y = game.launcher_y, bubble_type = get_next_bubble_type(game.next_bubble_index, bubble_types), velocity_x = 0, velocity_y = 0 } end end end if #game.bursting_bubbles > 0 then for i = #game.bursting_bubbles, 1, -1 do local bubble = game.bursting_bubbles[i] if bubble.delay == 0 then bubble.scale = bubble.scale * 1.03 bubble.alpha = bubble.alpha - 0.02 if bubble.scale >= 1.5 then table.remove(game.bursting_bubbles, i) end else bubble.delay = bubble.delay - 1 end end end if #game.falling_bubbles > 0 then for i = #game.falling_bubbles, 1, -1 do local bubble = game.falling_bubbles[i] bubble.x = bubble.x + bubble.velocity_x bubble.y = bubble.y + bubble.velocity_y bubble.velocity_x = bubble.velocity_x * 0.99 bubble.velocity_y = bubble.velocity_y + 36 * delta_time -- gravity if bubble.x - game.bubble_radius <= game.level_left or bubble.x + game.bubble_radius >= game.level_right then bubble.velocity_x = -bubble.velocity_x end if bubble.y - game.bubble_radius > game.window_height then table.remove(game.falling_bubbles, i) end end end for i = #game.points_display, 1, -1 do local p = game.points_display[i] if p.tween then if p.tween.delay == 0 then p.tween.t = p.tween.t + 1 if p.tween.t == p.tween.d then table.remove(game.points_display, i) else p.y = easing.outBack(p.tween.t, p.tween.b, p.tween.c, p.tween.d) end else p.tween.delay = p.tween.delay - 1 end end end if game.frame_by_frame then game.paused = true game.frame_by_frame = false end end function love.draw(alpha) if game.paused then alpha = 0 end -- draw background love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(game.background_image, 0, 0) love.graphics.draw(game.background_image, game.background_width, 0) -- draw stationary bubbles love.graphics.setColor(1, 1, 1, 1) for i = 1, #game.bubble_slots do local slot = game.bubble_slots[i] if slot.bubble_type >= 1 and slot.bubble_type <= #game.bubble_images then love.graphics.draw( game.bubble_images[slot.bubble_type], slot.x, slot.y, 0, 1, 1, game.bubble_radius, game.bubble_radius ) else love.graphics.circle( 'line', slot.x, slot.y, game.bubble_radius ) end end -- draw walls love.graphics.setColor(0, 0, 0, 1) love.graphics.rectangle( 'line', game.level_left, game.level_top, game.level_width, game.level_height ) -- draw launcher love.graphics.setColor(1, 1, 1, 1) love.graphics.draw( game.launcher_image, game.launcher_x, game.launcher_y, game.launcher_rotation, 1, 1, game.launcher_offset_x, game.launcher_offset_y ) -- draw bursting bubbles if #game.bursting_bubbles > 0 then for i = 1, #game.bursting_bubbles do local bubble = game.bursting_bubbles[i] love.graphics.setColor(1, 1, 1, bubble.alpha) love.graphics.draw( game.bubble_images[bubble.bubble_type], bubble.x, bubble.y, 0, bubble.scale, bubble.scale, game.bubble_radius, game.bubble_radius ) end end -- draw falling bubbles if #game.falling_bubbles > 0 then for i = 1, #game.falling_bubbles do local bubble = game.falling_bubbles[i] love.graphics.setColor(1, 1, 1, bubble.alpha) love.graphics.draw( game.bubble_images[bubble.bubble_type], bubble.x + bubble.velocity_x * alpha, bubble.y + bubble.velocity_y * alpha, 0, bubble.scale, bubble.scale, game.bubble_radius, game.bubble_radius ) end end -- draw next bubble -- if moving, extrapolate position based on alpha value from love.run if game.next_bubble then love.graphics.setColor(1, 1, 1, 1) love.graphics.draw( game.bubble_images[game.next_bubble.bubble_type], game.next_bubble.x + game.next_bubble.velocity_x * alpha, game.next_bubble.y + game.next_bubble.velocity_y * alpha, 0, 1, 1, game.bubble_radius, game.bubble_radius ) end -- draw score love.graphics.setFont(game.score_font) love.graphics.setColor(0, 0, 0, 1) love.graphics.print( string.format("%08d", game.score), game.bubble_radius, game.bubble_radius ) love.graphics.setFont(game.points_font) for i = 1, #game.points_display do local p = game.points_display[i] love.graphics.printf( p.points, p.x - game.bubble_radius, p.y, game.bubble_diameter, -- TODO: fix this, wraps sometimes 'center' ) end -- draw aim guide if game.show_aim_guide then love.graphics.setColor(0, 0, 0, 1) for i = 1, #game.aim_guide - 1 do love.graphics.circle( 'line', game.aim_guide[i].x, game.aim_guide[i].y, game.aim_guide[i].radius ) end end end function love.mousepressed(x, y, button, is_touch, presses) if button == 1 and game.next_bubble then local diff_x = x - game.launcher_x local diff_y = y - game.launcher_y local dist = math.sqrt(diff_x * diff_x + diff_y * diff_y) game.next_bubble.velocity_x = diff_x / dist * game.bubble_speed game.next_bubble.velocity_y = diff_y / dist * game.bubble_speed end end function love.mousemoved(x, y, dx, dy, is_touch) local diff_x = x - game.launcher_x local diff_y = y - game.launcher_y game.launcher_rotation = math.atan2(diff_y, diff_x) if game.show_aim_guide then generate_aim_guide(x, y) end end function love.keypressed(key, scan_code, is_repeat) if key == 'escape' then love.event.quit() elseif key == 'r' then game.next_bubble.x = game.launcher_x game.next_bubble.y = game.launcher_y game.next_bubble.velocity_x = 0 game.next_bubble.velocity_y = 0 game.next_bubble.bubble_type = get_next_bubble_type( game.next_bubble_index, {1,2,3,4,5,6,7,8} ) game.paused = false elseif key == 'space' then game.paused = not game.paused elseif key == 'n' and game.paused then game.frame_by_frame = true game.paused = false end end function love.run() if love.load then love.load(love.arg.parseGameArguments(arg), arg) end if love.timer then love.timer.step() end local dt = delta_time -- update 120 times per second local accumulator = 0.0 return function() if love.event then love.event.pump() for name, a,b,c,d,e,f in love.event.poll() do if name == "quit" then if not love.quit or not love.quit() then return a or 0 end end love.handlers[name](a,b,c,d,e,f) end end local frame_time = dt -- default in case love.timer is disabled if love.timer then frame_time = love.timer.step() if frame_time > 0.25 then frame_time = 0.25 end end accumulator = accumulator + frame_time while (accumulator >= dt) do if love.update then love.update(dt) end accumulator = accumulator - dt end local alpha = accumulator / dt if love.graphics and love.graphics.isActive() then love.graphics.origin() love.graphics.clear(love.graphics.getBackgroundColor()) if love.draw then love.draw(alpha) end love.graphics.present() end if love.timer then love.timer.sleep(0.001) end end end