Object = require "classic" lume = require "lume" local linspect = require "inspect" local CONFIG = { screen_w = 800, screen_h = 600, light_radius = 200, visibility_p = 0.75, ray_skew = 0.05, } function CONFIG:visibility() return self.light_radius * self.visibility_p end function inspect(o) local remove_all_metatables = function(item, path) if path[#path] ~= linspect.METATABLE then return item end end return linspect(o, { newline = '', indent = '', process = remove_all_metatables }) end Point = Object:extend() function Point:new(x, y) self.x = x self.y = y end function Point:distance(other) return math.sqrt(self:distance_2(other)) end function Point:distance_2(other) local dx, dy = self.x - other.x, self.y - other.y return dx * dx + dy * dy end function Point:__tostring() return "Point(" .. self.x .. ", " .. self.y .. ")" end Shape = Object:extend() function Shape:new(points) self.points = points local love_points = {} for i, p in ipairs(self.points) do table.insert(love_points, p.x) table.insert(love_points, p.y) end self.love_points = love_points local convex = love.math.isConvex(love_points) if not convex then self.triangles = love.math.triangulate(love_points) end end function Shape:draw() love.graphics.setColor(0.1, 0.2, 0.3, 1) if self.triangles then for _, tri in ipairs(self.triangles) do love.graphics.polygon("fill", tri) end if debug_draw then love.graphics.setColor(0.6, 0.6, 0.3, 1) for _, tri in ipairs(self.triangles) do love.graphics.polygon("line", tri) end end else love.graphics.polygon("fill", self.love_points) end end function generate_rays(from, all_shapes) local rays = {} for si, shape in ipairs(all_shapes) do for i, p in ipairs(shape.points) do local ci = find_closest_intersection(from, p, all_shapes) if ci then local deg = degree(from, ci) table.insert(rays, { point = ci, shape = si, vertex = i, angle = deg }) -- TODO: I should check if left_i or right_i hits the same shape (or at least same segment) -- as the ci and ignore such hits. This left/right thing is meant to create a shadow/light shape -- passing the corner of a body and hiting something farther away. So hiting the same body is not -- just wastes time. local left = rotate_vector(from, ci, CONFIG.ray_skew) local left_i = find_closest_intersection(from, left, all_shapes) if left_i then deg = degree(from, left_i) table.insert(rays, { point = left_i, shape = si, vertex = i, angle = deg }) end local right = rotate_vector(from, ci, -CONFIG.ray_skew) local right_i = find_closest_intersection(from, right, all_shapes) if right_i then deg = degree(from, right_i) table.insert(rays, { point = right_i, shape = si, vertex = i, angle = deg }) end end end end return rays end function ray_config_by_deg(ray_config1, ray_config2) return ray_config1.angle < ray_config2.angle end function draw_rays(from) local rays = generate_rays(from, shapes) if #rays == 0 then return end local verts = {} table.sort(rays, ray_config_by_deg) for _, ray_config in ipairs(rays) do table.insert(verts, ray_config.point.x) table.insert(verts, ray_config.point.y) end table.insert(verts, rays[1].point.x) table.insert(verts, rays[1].point.y) for i = 3, #verts, 2 do love.graphics.polygon("fill", verts[i - 2], verts[i - 1], verts[i], verts[i + 1], from.x, from.y) end end function find_closest_intersection(p1, p2, shapes) local best_d = -1 local best = nil for si, shape in ipairs(shapes) do for i = 2, #shape.points + 1 do local prev, curr = i - 1, i if i == #shape.points + 1 then prev = #shape.points curr = 1 end local pi = intersection(p1, p2, shape.points[prev], shape.points[curr]) if pi then local d = p1:distance_2(pi) if best_d == -1 or d < best_d then best_d = d best = pi end end end end return best end function love.load() love.window.setTitle("Shadows") love.window.setMode(CONFIG.screen_w, CONFIG.screen_h, { resizable = false }) shapes = { Shape({ Point(-1, -1), Point(CONFIG.screen_w + 1, -1), Point(CONFIG.screen_w + 1, CONFIG.screen_h + 1), Point(-1, CONFIG.screen_h + 1) }), -- Border Shape({ Point(50, 50), Point(150, 50), Point(150, 150) }), Shape({ Point(490, 250), Point(690, 300), Point(640, 350), Point(670, 450), Point(540, 550) }), Shape({ Point(330, 312), Point(379, 393), Point(380, 443), Point(313, 371), Point(259, 379), Point(222, 340) }), Shape({ Point(123, 292), Point(200, 372), Point(181, 414), Point(99, 323) }), Shape({ Point(122, 182), Point(120, 212), Point(90, 189) }), Shape({ Point(366, 257), Point(324, 209), Point(278, 207), Point(249, 173), Point(397, 120), Point(419, 152), Point( 372, 185), Point(409, 234) }), Shape({ Point(489, 45), Point(632, 44), Point(716, 104), Point(731, 183), Point(628, 216), Point(536, 212), Point( 486, 182), Point(568, 149), Point(636, 146), Point(648, 102), Point(610, 79), Point(566, 83), Point(493, 90), Point( 461, 73) }) } mouse = Point(400, 300) love.mouse.setPosition(mouse.x, mouse.y) tmp_points = {} love.graphics.setLineWidth(1) love.mouse.setVisible(false) light_canvas = love.graphics.newCanvas(CONFIG.screen_w, CONFIG.screen_h) shader = love.graphics.newShader([[ extern vec2 mouse; extern float radius; float easeOutExpo(float x) { return x == 1 ? 1 : 1 - pow(2, -10 * x); } vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) { vec4 pixel = Texel(texture, texture_coords); float d = distance(screen_coords, mouse); vec4 color_bg = vec4(1,1,1,1); if (d <= radius) { float v = 1 - d/radius; return pixel * color_bg*easeOutExpo(v); } return vec4(0,0,0,1); } ]]) ghost = love.graphics.newImage("ghost.png") ghosts = { Point(180, 270), Point(276, 375), Point(594, 94), Point(727, 111), Point(646, 336), Point(44, 1), Point(562, 159), Point(-2, 550) } ghosts_found = {} ghost_shader = love.graphics.newShader([[ extern Image light; extern vec2 screen_size; vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) { vec4 pixel = Texel(texture, texture_coords); vec2 uv = screen_coords / screen_size; vec4 light_pix = Texel(light, uv); vec4 ret = light_pix * pixel; ret.a *= light_pix.r; return ret; } ]]) debug_draw = false debug_font = love.graphics.newFont(12) font = love.graphics.newFont(20) game_start = love.timer.getTime() end function draw_light() love.graphics.setCanvas(light_canvas) love.graphics.clear(0, 0, 0, 1) shader:send("mouse", { mouse.x, mouse.y }) shader:send("radius", CONFIG.light_radius) love.graphics.setShader(shader) love.graphics.setColor(1, 1, 1, 1) draw_rays(mouse) love.graphics.setShader() love.graphics.setCanvas() love.graphics.setColor(1, 1, 1, 0.3) love.graphics.draw(light_canvas) end function love.draw() local t = love.timer.getTime() love.graphics.clear(0, .1, .3, 1) for i, s in ipairs(shapes) do if i > 1 then s:draw() end end love.graphics.setColor(1, 1, 1, 1) for _, p in ipairs(tmp_points) do love.graphics.setColor(1, 1, 1, 1) love.graphics.circle("line", p.x, p.y, 2) end draw_light() love.graphics.setShader(ghost_shader) ghost_shader:send("light", light_canvas) ghost_shader:send("screen_size", { love.graphics.getDimensions() }) for _, g in ipairs(ghosts) do love.graphics.draw(ghost, g.x, g.y) end love.graphics.setShader() love.graphics.setColor(1, 1, 1, 1) for _, g in ipairs(ghosts_found) do love.graphics.draw(ghost, g.x, g.y) end love.graphics.circle("fill", mouse.x, mouse.y, 5) love.graphics.setColor(1, 0, 0, 1) love.graphics.setFont(debug_font) if debug_draw then love.graphics.print("Current FPS: " .. tostring(love.timer.getFPS()), 10, 10) end love.graphics.setFont(font) if game_completed_at then love.graphics.print("Time: " .. string.format("%.2f", game_completed_at - game_start), 10, 570) else love.graphics.print("Time: " .. string.format("%.2f", t - game_start), 10, 570) end love.graphics.print("Missing ghosts: " .. tostring(#ghosts), 620, 570) if debug_draw then love.graphics.circle("line", mouse.x, mouse.y, CONFIG:visibility()) local w, h = ghost:getPixelDimensions() for _, g in ipairs(ghosts) do love.graphics.circle("fill", g.x + (w / 2), g.y + (h / 2), 3) end end end -- Returns degree between the X line and a vector function degree(from, to) local x = Point(from.x + 1, from.y) return degree_between(from, x, to) end -- Compute the angle in degrees between vector from->to1 and from->to2 function degree_between(from, to1, to2) local v1x, v1y = to1.x - from.x, to1.y - from.y local v2x, v2y = to2.x - from.x, to2.y - from.y local angle1 = math.atan2(v1y, v1x) local angle2 = math.atan2(v2y, v2x) local diff = math.deg(angle2 - angle1) return 360 - (diff % 360) end function rotate_vector(from, to, degrees) local vx = to.x - from.x local vy = to.y - from.y local current_angle = math.atan2(vy, vx) local radians = math.rad(degrees) local new_angle = current_angle - radians local length = math.sqrt(vx ^ 2 + vy ^ 2) local new_to_x = from.x + math.cos(new_angle) * length local new_to_y = from.y + math.sin(new_angle) * length return Point(new_to_x, new_to_y) end -- Find a point of intersection of a line starting at l_start and passing -- through l_end and a segments 's_start' and 's_end' function intersection(l_start, l_end, s_start, s_end) local x1, y1 = l_start.x, l_start.y local x2, y2 = l_end.x, l_end.y local x3, y3 = s_start.x, s_start.y local x4, y4 = s_end.x, s_end.y local denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4) if denom == 0 then return nil end local EPS = 1e-9 local t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom local u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom -- NOTE: this is how to detect if two *segments* intersect: -- if (t >= -EPS and t <= (1 + EPS)) and (u >= -EPS and u <= (1 + EPS)) then if t >= -EPS and (u >= -EPS and u <= (1 + EPS)) then local px = x1 + t * (x2 - x1) local py = y1 + t * (y2 - y1) return Point(px, py) else return nil end end function love.update(dt) if #ghosts == 0 then return end local w, h = ghost:getPixelDimensions() for i = #ghosts, 1, -1 do local g = ghosts[i] local gp = Point(g.x + (w / 2), g.y + (h / 2)) local d = mouse:distance(gp) if d <= CONFIG:visibility() then local intersection = find_closest_intersection(mouse, gp, shapes) if intersection then local intersection_d = mouse:distance(intersection) if intersection_d > d then table.insert(ghosts_found, table.remove(ghosts, i)) if #ghosts == 0 then game_completed_at = love.timer.getTime() end end end end end end function love.mousemoved(x, y) if #ghosts == 0 then return end mouse.x = x mouse.y = y end function love.keypressed(key) if key == "space" then local x, y = love.mouse.getPosition() table.insert(tmp_points, Point(x, y)) elseif key == "a" then if #tmp_points >= 3 then local shape = Shape(tmp_points) tmp_points = {} table.insert(shapes, shape) end elseif key == 'v' then debug_draw = not debug_draw elseif key == 'g' then local w, h = ghost:getPixelDimensions() table.insert(ghosts_found, Point(mouse.x - (w / 2), mouse.y - (h / 2))) elseif key == "d" then print("ghosts: " .. inspect(ghosts)) print("ghosts found: " .. inspect(ghosts_found)) for si, shape in ipairs(shapes) do io.write("Shape({") for pi, point in ipairs(shape.points) do io.write("Point(" .. point.x .. ", " .. point.y .. ")") if pi ~= #shape.points then io.write(", ") end end if si ~= #shapes then print("}),") else print("})") end end end end function love.wheelmoved(x, y) if #ghosts == 0 then return end CONFIG.light_radius = lume.clamp(CONFIG.light_radius - y, 0, CONFIG.screen_w) end