commit 1dee9520260874a4bef6071418ecc59e13555a0e Author: Travis Shears Date: Mon Feb 2 11:55:26 2026 +0100 init project with sprite and bare l5 for mockup diff --git a/kitchen.aseprite b/kitchen.aseprite new file mode 100644 index 0000000..af1e1e2 Binary files /dev/null and b/kitchen.aseprite differ diff --git a/l5-mockup/L5.lua b/l5-mockup/L5.lua new file mode 100644 index 0000000..5f84279 --- /dev/null +++ b/l5-mockup/L5.lua @@ -0,0 +1,3918 @@ +-- L5 0.1.5 (c) Lee Tusman and Contributors GNU LGPL2.1 +VERSION = '0.1.5' + +-- Override love.run() - adds double buffering and custom events +function love.run() + defaults() + + define_env_globals() + if love.load then love.load(love.arg.parseGameArguments(arg), arg) end + if love.timer then love.timer.step() end + local dt = 0 + local setupComplete = false + + -- Main loop + return function() + -- Process events + 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 + + -- Handle mouse events - store them for drawing phase + if name == "mousepressed" then + -- a = x, b = y, c = button, d = istouch, e = presses + L5_env.pendingMouseClicked = {x = a, y = b, button = c} + elseif name == "mousereleased" then + -- a = x, b = y, c = button, d = istouch, e = presses + L5_env.pendingMouseReleased = {x = a, y = b, button = c} + end + + -- Handle other events through the default handlers + if love.handlers[name] then + love.handlers[name](a,b,c,d,e,f) + end + end + end + + -- Update dt + if love.timer then dt = love.timer.step() end + + -- Update + if love.update then love.update(dt) end + + -- Draw with double buffering + if love.graphics and love.graphics.isActive() then + love.graphics.origin() + + -- Set render target to back buffer + if L5_env.backBuffer then + love.graphics.setCanvas(L5_env.backBuffer) + end + + -- Only clear if background() was called this frame + if L5_env.clearscreen then + -- background() already cleared with the right color + L5_env.clearscreen = false + end + + -- Draw current frame + -- Run setup() once in the drawing context + if not setupComplete and setup then + setup() + setupComplete = true + else + if love.draw then love.draw() end + end + + -- Reset to screen and draw the back buffer + love.graphics.setCanvas() + if L5_env.backBuffer then + -- Save current color + local r, g, b, a = love.graphics.getColor() + + -- Set to white (no tint) when drawing the canvas to screen + love.graphics.setColor(1, 1, 1, 1) + + if L5_env.filterOn then + if L5_env.filter == "blur_twopass" then + -- Two-pass blur requires intermediate canvas + if not L5_env.blurTempCanvas or + L5_env.blurTempCanvas:getWidth() ~= love.graphics.getWidth() or + L5_env.blurTempCanvas:getHeight() ~= love.graphics.getHeight() then + L5_env.blurTempCanvas = love.graphics.newCanvas() + end + + -- Pass 1: Horizontal blur to temp canvas + love.graphics.setCanvas(L5_env.blurTempCanvas) + love.graphics.clear() + love.graphics.setShader(L5_filter.blur_horizontal) + love.graphics.draw(L5_env.backBuffer, 0, 0) + + -- Pass 2: Vertical blur to screen + love.graphics.setCanvas() + love.graphics.setShader(L5_filter.blur_vertical) + love.graphics.draw(L5_env.blurTempCanvas, 0, 0) + love.graphics.setShader() + else + -- Single-pass filter + love.graphics.setShader(L5_env.filter) + love.graphics.draw(L5_env.backBuffer, 0, 0) + love.graphics.setShader() + end + L5_env.filterOn = false + else + -- No filter, just draw normally + love.graphics.draw(L5_env.backBuffer, 0, 0) + end + + -- Restore color (after drawing the canvas) + love.graphics.setColor(r, g, b, a) + love.graphics.present() + end + + if love.timer then + if L5_env.framerate then --user-specified framerate + love.timer.sleep(1/L5_env.framerate) + else --default framerate + love.timer.sleep(0.001) + end + end + end + end +end + +function love.load() + love.window.setVSync(1) + love.math.setRandomSeed(os.time()) + + displayWidth, displayHeight = love.window.getDesktopDimensions() + + -- create default-size buffers. will be recreated again if size() or fullscreen(true) called + local w, h = love.graphics.getDimensions() + + -- Create double buffers + L5_env.backBuffer = love.graphics.newCanvas(w, h) + L5_env.frontBuffer = love.graphics.newCanvas(w, h) + + -- Clear both buffers initially + love.graphics.setCanvas(L5_env.backBuffer) + love.graphics.clear(0.5, 0.5, 0.5, 1) -- gray background + love.graphics.setCanvas(L5_env.frontBuffer) + love.graphics.clear(0.5, 0.5, 0.5, 1) -- gray background + love.graphics.setCanvas() + + initShaderDefaults() + + stroke(0) + fill(255) +end + +function love.update(dt) + mouseX, mouseY = love.mouse.getPosition() + movedX=mouseX-pmouseX + movedY=mouseY-pmouseY + deltaTime = dt * 1000 + key = updateLastKeyPressed() + + -- Update looping videos + -- Note: Videos with audio tracks may experience sync issues when looping +-- This is a LÖVE limitation with video/audio stream synchronization + if L5_env.videos then + for _, v in ipairs(L5_env.videos) do + if v._shouldLoop and not v._manuallyPaused and not v._video:isPlaying() then + v._video:rewind() + v._video:play() + end + end + end + + -- Optional update (not typically Processing-like but available) + if update ~= nil then update() end +end + +function love.draw() + -- checking user events happens regardless of whether the user draw() function is currently looping + local isPressed = love.mouse.isDown(1) or love.mouse.isDown(2) or love.mouse.isDown(3) + + if isPressed and not L5_env.wasPressed then + -- Mouse was just pressed this frame + if mousePressed ~= nil then mousePressed() end + mouseIsPressed = true + elseif not isPressed and L5_env.wasPressed then + -- Mouse was just released this frame + if mouseReleased ~= nil then mouseReleased() end + if mouseClicked ~= nil then mouseClicked() end -- Run immediately after mouseReleased + mouseIsPressed = false + elseif isPressed then + -- Still pressed - only call mouseDragged if mouse actually moved + if L5_env.mouseWasMoved then + if mouseDragged ~= nil then mouseDragged() end + L5_env.mouseWasMoved = false -- Clear the flag + end + mouseIsPressed = true + else + mouseIsPressed = false + end + + L5_env.wasPressed = isPressed + + -- Check for keyboard events in the draw cycle + if L5_env.keyWasPressed then + if keyPressed ~= nil then keyPressed() end + L5_env.keyWasPressed = false + end + + if L5_env.keyWasReleased then + if keyReleased ~= nil then keyReleased() end + L5_env.keyWasReleased = false + end + + if L5_env.keyWasTyped then + local savedKey = key + key = L5_env.typedKey -- Temporarily use the typed character + if keyTyped ~= nil then keyTyped() end + key = savedKey -- Restore + L5_env.keyWasTyped = false + L5_env.typedKey = nil + end + + -- Check for mouse events in draw cycle + -- Only call mouseMoved if mouse button is NOT pressed + if L5_env.mouseWasMoved and not isPressed then + if mouseMoved ~= nil then mouseMoved() end + L5_env.mouseWasMoved = false + elseif L5_env.mouseWasMoved and isPressed then + -- Clear the flag even if we don't call mouseMoved + -- (mouseDragged already handled above) + L5_env.mouseWasMoved = false + end + + if L5_env.wheelWasMoved then + if mouseWheel ~= nil then + mouseWheel(L5_env.wheelY or 0) + end + L5_env.wheelWasMoved = false + L5_env.wheelX = nil + L5_env.wheelY = nil + end + + -- only run if user draw() function is looping + if L5_env.drawing then + frameCount = frameCount + 1 + + -- Reset transformation matrix to identity at start of each frame + love.graphics.origin() + love.graphics.push() + + -- Call user draw function + if draw ~= nil then draw() end + + pmouseX, pmouseY = mouseX,mouseY + + love.graphics.pop() + end + + -- Draw print buffer on top of window, if on + if L5_env.showPrintBuffer and #L5_env.printBuffer > 0 then + love.graphics.push() + love.graphics.origin() + + -- Save user's current font and switch to default + local userFont = love.graphics.getFont() + love.graphics.setFont(L5_env.printFont or L5_env.defaultFont) + + -- Calculate max lines that fit on screen + local maxLines = math.floor((height - 10) / L5_env.printLineHeight) + + -- Trim buffer to only show lines that fit + local displayBuffer = {} + local startIdx = math.max(1, #L5_env.printBuffer - maxLines + 1) + for i = startIdx, #L5_env.printBuffer do + table.insert(displayBuffer, L5_env.printBuffer[i]) + end + + -- Get the font to measure text width + local font = love.graphics.getFont() + + -- Draw each line with its own background + local y = 5 + for _, line in ipairs(displayBuffer) do + -- Measure the actual width of this line of text + local textWidth = font:getWidth(line) + + -- Draw background rectangle just for this line + love.graphics.setColor(0, 0, 0, 0.7) + love.graphics.rectangle('fill', 3, y, textWidth + 6, L5_env.printLineHeight) + + -- Draw the text on top + love.graphics.setColor(1, 1, 1) + love.graphics.print(line, 5, y) + + y = y + L5_env.printLineHeight + end + + -- Restore user's font + love.graphics.setFont(userFont) + + love.graphics.pop() + end +end + +function love.mousepressed(_x, _y, button, istouch, presses) + --turned off so as not to duplicate event handling running twice + --if mousePressed ~= nil then mousePressed() end + if button==1 then + mouseButton=LEFT + elseif button==2 then + mouseButton=RIGHT + elseif button==3 then + mouseButton=CENTER + end +end + +function love.mousereleased( x, y, button, istouch, presses ) + --if mouseClicked ~= nil then mouseClicked() end + --if focused and mouseReleased ~= nil then mouseReleased() end +end + +function love.wheelmoved(_x,_y) + L5_env.wheelWasMoved = true + L5_env.wheelX = _x + L5_env.wheelY = _y + return _x, _y +end + +function love.mousemoved(x,y,dx,dy,istouch) + L5_env.mouseWasMoved = true +end + +function love.keypressed(k, scancode, isrepeat) + -- Add key to pressed keys table + L5_env.pressedKeys[k] = true + + key = k + keyCode = love.keyboard.getScancodeFromKey(k) + L5_env.keyWasPressed = true + keyIsPressed = true +end + +function love.keyreleased(k) + -- Remove key from pressed keys table + L5_env.pressedKeys[k] = nil + + key = k + keyCode = love.keyboard.getScancodeFromKey(k) + L5_env.keyWasReleased = true + + -- Only set keyIsPressed to false if no keys are pressed + local anyKeyPressed = false + for _ in pairs(L5_env.pressedKeys) do + anyKeyPressed = true + break + end + keyIsPressed = anyKeyPressed +end + +function love.textinput(_text) + key = _text + L5_env.typedKey = _text + L5_env.keyWasTyped = true +end + +function love.resize(w, h) + -- Recreate buffers when window is resized at density-scaled resolution + if L5_env.backBuffer then L5_env.backBuffer:release() end + if L5_env.frontBuffer then L5_env.frontBuffer:release() end + + L5_env.backBuffer = love.graphics.newCanvas(w, h) + L5_env.frontBuffer = love.graphics.newCanvas(w, h ) + + -- Clear new buffers and apply scaling + love.graphics.setCanvas(L5_env.backBuffer) + love.graphics.clear(0.5, 0.5, 0.5, 1) + + love.graphics.setCanvas(L5_env.frontBuffer) + love.graphics.clear(0.5, 0.5, 0.5, 1) + + love.graphics.setCanvas(L5_env.backBuffer) + + -- Update global width/height to logical size + width, height = w, h + + -- Call user's windowResized function if it exists + if windowResized then + windowResized() + end +end + +function love.focus(_focused) + focused = _focused +end + +------------------- CUSTOM FUNCTIONS ----------------- +function printToScreen(textSize) + L5_env.showPrintBuffer = true + + textSize = textSize or 16 + + L5_env.printFont = love.graphics.newFont(textSize) + L5_env.printLineHeight = L5_env.printFont:getHeight() + +end + +function size(_w, _h) + -- must clear canvas before setMode + love.graphics.setCanvas() + + love.window.setMode(_w, _h) + + -- Recreate buffers for new size + if L5_env.backBuffer then L5_env.backBuffer:release() end + if L5_env.frontBuffer then L5_env.frontBuffer:release() end + + L5_env.backBuffer = love.graphics.newCanvas(_w, _h) + L5_env.frontBuffer = love.graphics.newCanvas(_w, _h) + + -- Clear new buffers + love.graphics.setCanvas(L5_env.backBuffer) + love.graphics.clear(0.5, 0.5, 0.5, 1) + love.graphics.setCanvas(L5_env.frontBuffer) + love.graphics.clear(0.5, 0.5, 0.5, 1) + + -- Set back to back buffer for continued drawing + love.graphics.setCanvas(L5_env.backBuffer) + + width, height = love.graphics.getDimensions() +end + +function fullscreen(display) + display = display or 1 + + love.graphics.setCanvas() + + local displays = love.window.getDisplayCount() + if display > displays then + display = 1 + end + + -- Get dimensions for the specified display + local w, h = love.window.getDesktopDimensions(display) + + -- First, create a windowed mode on that display + love.window.setMode(w, h, {fullscreen = false}) + + -- Position the window on the target display + local xPos = 0 + for i = 1, display - 1 do + local dw, _ = love.window.getDesktopDimensions(i) + xPos = xPos + dw + end + love.window.setPosition(xPos, 0) + + -- Small delay for Windows to process window positioning + if love.timer then love.timer.sleep(0.1) end + + -- Now go fullscreen + local success, err = pcall(function() + love.window.setFullscreen(true, "desktop") + end) + + if not success then + print("Fullscreen error:", err) + return + end + + -- Release old canvases + if L5_env.backBuffer then + pcall(function() L5_env.backBuffer:release() end) + end + if L5_env.frontBuffer then + pcall(function() L5_env.frontBuffer:release() end) + end + + -- Create new canvases + L5_env.backBuffer = love.graphics.newCanvas(w, h) + L5_env.frontBuffer = love.graphics.newCanvas(w, h) + + love.graphics.setCanvas(L5_env.backBuffer) + love.graphics.clear(0.5, 0.5, 0.5, 1) + love.graphics.setCanvas(L5_env.frontBuffer) + love.graphics.clear(0.5, 0.5, 0.5, 1) + + love.graphics.setCanvas(L5_env.backBuffer) + width, height = love.graphics.getDimensions() + + if windowResized then + windowResized() + end +end + +function toColor(_a, _b, _c, _d) + -- If _a is a table, return it (assuming it's already in RGBA format) + if type(_a) == "table" and _b == nil and #_a == 4 then + return _a + end + + local r, g, b, a + + -- Handle different argument patterns + if _b == nil then + -- One argument = grayscale or color name + if type(_a) == "number" then + if L5_env.color_mode == RGB then + r, g, b, a = _a, _a, _a, L5_env.color_max[4] + elseif L5_env.color_mode == HSB then + -- Grayscale in HSB: hue=0, saturation=0, brightness=value + r, g, b = HSVtoRGB(0, 0, _a / L5_env.color_max[3]) + r, g, b = r * L5_env.color_max[1], g * L5_env.color_max[2], b * L5_env.color_max[3] + a = L5_env.color_max[4] + elseif L5_env.color_mode == HSL then + -- Grayscale in HSL: hue=0, saturation=0, lightness=value + r, g, b = HSLtoRGB(0, 0, _a / L5_env.color_max[3]) + r, g, b = r * L5_env.color_max[1], g * L5_env.color_max[2], b * L5_env.color_max[3] + a = L5_env.color_max[4] + end + elseif type(_a) == "string" then + if _a:sub(1, 1) == "#" then -- Hex color + r, g, b = hexToRGB(_a) + a = L5_env.color_max[4] + else -- HTML color name + if htmlColors[_a] then + r, g, b = unpack(htmlColors[_a]) + a = L5_env.color_max[4] + else + error("Color '" .. _a .. "' not found in htmlColors table") + end + end + else + error("Invalid color argument") + end + elseif _c == nil then + -- Two arguments = grayscale with alpha + if L5_env.color_mode == RGB then + r, g, b, a = _a, _a, _a, _b + elseif L5_env.color_mode == HSB then + r, g, b = HSVtoRGB(0, 0, _a / L5_env.color_max[3]) + r, g, b = r * L5_env.color_max[1], g * L5_env.color_max[2], b * L5_env.color_max[3] + a = _b + elseif L5_env.color_mode == HSL then + r, g, b = HSLtoRGB(0, 0, _a / L5_env.color_max[3]) + r, g, b = r * L5_env.color_max[1], g * L5_env.color_max[2], b * L5_env.color_max[3] + a = _b + end + elseif _d == nil then + -- Three arguments = color components without alpha + if L5_env.color_mode == RGB then + r, g, b, a = _a, _b, _c, L5_env.color_max[4] + elseif L5_env.color_mode == HSB then + r, g, b = HSVtoRGB(_a / L5_env.color_max[1], _b / L5_env.color_max[2], _c / L5_env.color_max[3]) + r, g, b = r * L5_env.color_max[1], g * L5_env.color_max[2], b * L5_env.color_max[3] + a = L5_env.color_max[4] + elseif L5_env.color_mode == HSL then + r, g, b = HSLtoRGB(_a / L5_env.color_max[1], _b / L5_env.color_max[2], _c / L5_env.color_max[3]) + r, g, b = r * L5_env.color_max[1], g * L5_env.color_max[2], b * L5_env.color_max[3] + a = L5_env.color_max[4] + end + else + -- Four arguments = color components with alpha + if L5_env.color_mode == RGB then + r, g, b, a = _a, _b, _c, _d + elseif L5_env.color_mode == HSB then + r, g, b = HSVtoRGB(_a / L5_env.color_max[1], _b / L5_env.color_max[2], _c / L5_env.color_max[3]) + r, g, b = r * L5_env.color_max[1], g * L5_env.color_max[2], b * L5_env.color_max[3] + a = _d + elseif L5_env.color_mode == HSL then + r, g, b = HSLtoRGB(_a / L5_env.color_max[1], _b / L5_env.color_max[2], _c / L5_env.color_max[3]) + r, g, b = r * L5_env.color_max[1], g * L5_env.color_max[2], b * L5_env.color_max[3] + a = _d + end + end + + -- Return normalized RGBA values (0-1 range) + return {r/L5_env.color_max[1], g/L5_env.color_max[2], b/L5_env.color_max[3], a/L5_env.color_max[4]} +end + +function hexToRGB(hex) + hex = hex:gsub("#", "") -- Remove # if present + + -- Check valid length + if #hex == 3 then + hex = hex:gsub("(.)", "%1%1") -- Convert 3 to 6-digit + elseif #hex ~= 6 then + return nil, "Invalid hex color format. Expected 3 or 6 characters." + end + + -- Extract RGB components + local r = tonumber(hex:sub(1, 2), 16) + local g = tonumber(hex:sub(3, 4), 16) + local b = tonumber(hex:sub(5, 6), 16) + + -- Check if conversion was successful + if not r or not g or not b then + return nil, "Invalid hex color format. Contains non-hex characters." + end + + return r, g, b +end + +function HSVtoRGB(h, s, v) + if s <= 0 then + return v, v, v + end + h = h * 6 + local c = v * s + local x = c * (1 - math.abs((h % 2) - 1)) + local m = v - c + local r, g, b = 0, 0, 0 + if h < 1 then + r, g, b = c, x, 0 + elseif h < 2 then + r, g, b = x, c, 0 + elseif h < 3 then + r, g, b = 0, c, x + elseif h < 4 then + r, g, b = 0, x, c + elseif h < 5 then + r, g, b = x, 0, c + else + r, g, b = c, 0, x + end + return r + m, g + m, b + m +end + +function HSLtoRGB(h, s, l) + if s <= 0 then + return l, l, l + end + h = h * 6 + local c = (1 - math.abs(2 * l - 1)) * s + local x = c * (1 - math.abs((h % 2) - 1)) + local m = l - c / 2 + local r, g, b = 0, 0, 0 + if h < 1 then + r, g, b = c, x, 0 + elseif h < 2 then + r, g, b = x, c, 0 + elseif h < 3 then + r, g, b = 0, c, x + elseif h < 4 then + r, g, b = 0, x, c + elseif h < 5 then + r, g, b = x, 0, c + else + r, g, b = c, 0, x + end + return r + m, g + m, b + m +end + +function RGBtoHSL(r, g, b) + -- Normalize RGB values to 0-1 range + r = r / 255 + g = g / 255 + b = b / 255 + + local max = math.max(r, g, b) + local min = math.min(r, g, b) + local h, s, l + + -- Calculate lightness + l = (max + min) / 2 + + if max == min then + -- Achromatic (no color) + h = 0 + s = 0 + else + local d = max - min + + -- Calculate saturation + if l > 0.5 then + s = d / (2 - max - min) + else + s = d / (max + min) + end + + -- Calculate hue + if max == r then + h = (g - b) / d + (g < b and 6 or 0) + elseif max == g then + h = (b - r) / d + 2 + elseif max == b then + h = (r - g) / d + 4 + end + + h = h / 6 + end + + -- Convert to 0-360 for hue, 0-100 for saturation and lightness + return h * L5_env.color_max[1], s * L5_env.color_max[2], l * L5_env.color_max[3] +end + +function save(filename) + love.graphics.captureScreenshot(function(imageData) + -- Generate filename + local finalFilename + + if filename then + -- Check if filename ends with .png + if filename:match("%.png$") then + finalFilename = filename + else + -- Add .png extension + finalFilename = filename .. ".png" + end + else + -- Use default timestamp-based name + local timestamp = os.date("%Y%m%d_%H%M%S") + finalFilename = "screenshot_" .. timestamp .. ".png" + end + + -- Encode to PNG + local pngData = imageData:encode("png") + + -- Try to write to current directory first + local programDir = love.filesystem.getSource() + local targetPath = programDir .. "/" .. finalFilename + + local file = io.open(targetPath, "wb") + if file then + file:write(pngData:getString()) + file:close() + print("Screenshot saved to: " .. targetPath) + else + -- Fallback: use Love2d's save directory + print("Warning: Could not write to current directory, using save directory instead") + local success = love.filesystem.write(finalFilename, pngData) + + if success then + local saveDir = love.filesystem.getSaveDirectory() + print("Screenshot saved to: " .. saveDir .. "/" .. finalFilename) + else + print("Error: Could not save screenshot") + end + end + end) +end + +function describe(sceneDescription) + if not L5_env.described then + L5_env.originalPrint("CANVAS_DESCRIPTION: " .. sceneDescription) + io.flush() -- Ensure immediate output for screen readers + L5_env.described = true + end +end + +function defaults() + -- constants + -- shapes + CORNER = "CORNER" + RADIUS = "RADIUS" + CORNERS = "CORNERS" + CENTER = "CENTER" + RADIANS = "RADIANS" + DEGREES = "DEGREES" + ROUND = "smooth" + SQUARE = "rough" + PROJECT = "project" + MITER = "miter" + BEVEL = "bevel" + NONE = "none" + -- typography + LEFT = "left" + RIGHT = "right" + CENTER = "center" + TOP = "top" + BOTTOM = "bottom" + BASELINE = "baseline" + WORD = "word" + CHAR = "char" + -- color + RGB = "rgb" + HSB = "hsb" + HSL = "hsl" + -- math + PI = math.pi + HALF_PI = math.pi/2 + QUARTER_PI=math.pi/4 + TWO_PI = 2 * math.pi + TAU = TWO_PI + PIE = "pie" + OPEN = "open" + CHORD = "closed" + -- filters (shaders) + GRAY = "gray" + THRESHOLD = "threshold" + INVERT = "invert" + POSTERIZE = "posterize" + BLUR = "blur" + ERODE = "erode" + DILATE = "dilate" + -- for applying texture wrapping + NORMAL = "NORMAL" + IMAGE = "IMAGE" + CLAMP = "clamp" + REPEAT = "repeat" + -- blend modes + BLEND = "blend" + ADD = "add" + MULTIPLY = "multiply" + SCREEN = "screen" + LIGHTEST = "lightest" + DARKEST = "darkest" + REPLACE = "replace" + -- system cursors + ARROW = "arrow" + IBEAM = "ibeam" + WAIT = "wait" + WAITARROW = "waitarrow" + CROSSHAIR = "crosshair" + SIZENWSE = "sizenwse" + SIZENESW = "sizenesw" + SIZEWE = "sizewe" + SIZENS = "sizens" + SIZEALL = "sizeall" + NO = "no" + HAND = "hand" + + -- global user vars - can be read by user but shouldn't be altered by user + key = "" --default, overriden with key presses detected in love.update(dt) + width = 800 --default, overridden with size() or fullscreen() + height = 600 --ditto + frameCount = 0 + mouseIsPressed = false + mouseX=0 + mouseY=0 + keyIsPressed = false + pmouseX,pmouseY,movedX,movedY=0,0 + mouseButton = nil + focused = true + pixels = {} +end + +-- environment global variables not user-facing +function define_env_globals() + L5_env = L5_env or {} -- Initialize L5_env if it doesn't exist + L5_env.drawing = true + -- drawing mode state + L5_env.degree_mode = RADIANS --also: DEGREES + L5_env.rect_mode = CORNER --also: CORNERS, CENTER, RADIUS + L5_env.ellipse_mode = CENTER --also: CORNER, CORNERS, RADIUS + L5_env.image_mode = CORNER --also: CENTER, CORNERS + -- global color state + L5_env.fill_mode="fill" --also: "line" + L5_env.stroke_color = {0,0,0} + L5_env.currentTint = {1, 1, 1, 1} -- Default: no tint white + L5_env.color_max = {255,255,255,255} + L5_env.color_mode = RGB --also: HSB, HSL + -- global key state + L5_env.pressedKeys = {} + L5_env.keyWasPressed = false + L5_env.keyWasReleased = false + L5_env.keyWasTyped = false + L5_env.typedKey = nil + -- mouse state + L5_env.mouseWasMoved = false + L5_env.wasPressed = false + L5_env.wheelWasMoved = false + L5_env.wheelX = nil + L5_env.wheelY = nil + L5_env.pendingMouseClicked = nil + L5_env.pendingMouseReleased = nil + -- screen buffer state + L5_env.framerate = nil + L5_env.backBuffer = nil + L5_env.frontBuffer = nil + L5_env.clearscreen = false + L5_env.described = false + -- global video tracking for looping + L5_env.videos = {} + -- global font state + L5_env.fontPaths = {} + L5_env.currentFontPath = nil + L5_env.currentFontSize = 12 + L5_env.textAlignX = LEFT + L5_env.textAlignY = BASELINE + L5_env.textWrap = WORD + -- filters (shaders) + L5_env.filterOn = false + L5_env.filter = nil + -- pixel array + L5_env.pixels = {} + L5_env.imageData = nil + L5_env.pixelsLoaded = false + -- custom shape drawing + L5_env.vertices = {} + -- custom texture mesh + L5_env.currentTexture = nil + L5_env.useTexture = false + L5_env.textureMode=IMAGE -- NORMAL or IMAGE + L5_env.textureWrap=CLAMP -- wrap mode CLAMP or REPEAT + -- custom print output on screen + L5_env.printBuffer = {} + L5_env.defaultFont = love.graphics.getFont() + L5_env.printFont = L5_env.defaultFont + L5_env.showPrintBuffer = false + L5_env.printY = 5 + L5_env.printLineHeight = L5_env.defaultFont:getHeight() + 2 + + -- Override print to also draw to screen + local originalPrint = print + L5_env.originalPrint = originalPrint + function print(...) + originalPrint(...) -- Still print to console + + local text = "" + local args = {...} + for i = 1, #args do + if i > 1 then text = text .. "\t" end + text = text .. tostring(args[i]) + end + + table.insert(L5_env.printBuffer, text) + end +end + +------------------ INIT SHADERS --------------------- +-- initialize shader default values +function initShaderDefaults() + -- Set default values for threshold shader + L5_filter.threshold:send("soft", 0.5) + L5_filter.threshold:send("threshold", 0.5) + + -- Set default value for posterize + L5_filter.posterize:send("levels", 4.0) + -- Set default values for blur +if L5_filter.blurSupportsParameter then + L5_filter.blur_horizontal:send("blurRadius", 4.0) + L5_filter.blur_horizontal:send("textureSize", {love.graphics.getWidth(), love.graphics.getHeight()}) + L5_filter.blur_vertical:send("blurRadius", 4.0) + L5_filter.blur_vertical:send("textureSize", {love.graphics.getWidth(), love.graphics.getHeight()}) +elseif L5_filter.blur then + L5_filter.blur:send("textureSize", {love.graphics.getWidth(), love.graphics.getHeight()}) +end + -- Set default values for erode + L5_filter.erode:send("strength", 0.5) + L5_filter.erode:send("textureSize", {love.graphics.getWidth(), love.graphics.getHeight()}) + + -- Set default values for dilate + L5_filter.dilate:send("strength", 1.0) + L5_filter.dilate:send("threshold", 0.1) + L5_filter.dilate:send("textureSize", {love.graphics.getWidth(), love.graphics.getHeight()}) +end + +----------------------- INPUT ----------------------- + +function loadStrings(_file) + local lines = {} + for line in love.filesystem.lines(_file) do + table.insert(lines, line) + end + return lines +end + +function loadTable(_file, _header) + local extension = _file:match("%.([^%.]+)$") + + if extension == "csv" or extension == "tsv" then + local separator = (extension == "csv") and "," or "\t" + local pattern = (extension == "csv") and "[^,]+" or "[^\t]+" + + local function splitLine(line) + local values = {} + for value in line:gmatch(pattern) do + if tonumber(value) then table.insert(values, tonumber(value)) + elseif value == "true" then table.insert(values, true) + elseif value == "false" then table.insert(values, false) + else table.insert(values, value) + end + end + return values + end + + local function loadDelimitedFile(filename) + local data = {} + local headers = {} + local first_line = true + + for line in love.filesystem.lines(filename) do + local row = splitLine(line) + + if _header == "header" and first_line then + for value in line:gmatch(pattern) do + table.insert(headers, value) + end + first_line = false + else + if _header == "header" then + local record = {} + for i, value in ipairs(row) do + if headers[i] then + record[headers[i]] = value + end + end + table.insert(data, record) + else + table.insert(data, row) + end + end + end + + -- If no headers were loaded, create numbered column identifiers + if #headers == 0 and #data > 0 then + for i = 1, #data[1] do + table.insert(headers, i) + end + end + + data.columns = headers + return data + end + + return loadDelimitedFile(_file) + + elseif extension == "lua" then + local chunk = love.filesystem.load(_file) + if chunk then + return chunk() + else + error("Could not load Lua file: " .. _file) + end + else + error("Unsupported file type: " .. (extension or "no extension") .. " for file: " .. _file) + end +end + +function saveStrings(data, filename) + local lines = {} + for i, value in ipairs(data) do + table.insert(lines, tostring(value)) + end + local content = table.concat(lines, "\n") + + -- Use io.open to write directly to current directory + local file = io.open(filename, "w") + if file then + file:write(content) + file:close() + return true + else + print("Error: Could not open file for writing: " .. filename) + return false + end +end + +function saveTable(data, filename, format) + -- Auto-detect format from filename if not specified + if not format then + local extension = filename:match("%.([^%.]+)$") + format = extension or "lua" + end + + if format == "lua" then + -- Save as Lua file with return + local function serializeValue(val) + if type(val) == "string" then + return string.format("%q", val) + elseif type(val) == "number" or type(val) == "boolean" then + return tostring(val) + elseif val == nil then + return "nil" + else + return tostring(val) + end + end + + local function serializeTable(tbl, indent) + indent = indent or "" + local lines = {} + table.insert(lines, "{") + + for i, value in ipairs(tbl) do + if type(value) == "table" then + table.insert(lines, indent .. " " .. serializeTable(value, indent .. " ") .. ",") + else + table.insert(lines, indent .. " " .. serializeValue(value) .. ",") + end + end + + -- Handle named keys + for key, value in pairs(tbl) do + if type(key) ~= "number" or key > #tbl then + local keyStr = type(key) == "string" and key or "[" .. serializeValue(key) .. "]" + if type(value) == "table" then + table.insert(lines, indent .. " " .. keyStr .. " = " .. serializeTable(value, indent .. " ") .. ",") + else + table.insert(lines, indent .. " " .. keyStr .. " = " .. serializeValue(value) .. ",") + end + end + end + + table.insert(lines, indent .. "}") + return table.concat(lines, "\n") + end + + local content = "return " .. serializeTable(data) + + local file = io.open(filename, "w") + if file then + file:write(content) + file:close() + return true + end + + elseif format == "csv" or format == "tsv" then + local separator = (format == "csv") and "," or "\t" + local lines = {} + + -- Check if data is a single record (has named keys but no array elements) + local isSingleRecord = (#data == 0) + for k, v in pairs(data) do + if type(k) == "string" then + isSingleRecord = true + break + end + end + + -- Convert single record to array of one record + local records = data + if isSingleRecord and #data == 0 then + records = {data} + end + + -- Get headers from first row if it's a table with named keys + local headers = {} + if #records > 0 and type(records[1]) == "table" then -- Fixed: use records + for key, _ in pairs(records[1]) do -- Fixed: use records + if type(key) == "string" then + table.insert(headers, key) + end + end + + if #headers > 0 then + -- Add header row + table.insert(lines, table.concat(headers, separator)) + + -- Add data rows using headers + for i, row in ipairs(records) do -- Fixed: use records + local values = {} + for _, header in ipairs(headers) do + table.insert(values, tostring(row[header] or "")) + end + table.insert(lines, table.concat(values, separator)) + end + else + -- Array-style table, just use indices + for i, row in ipairs(records) do -- Fixed: use records + if type(row) == "table" then + local values = {} + for _, value in ipairs(row) do + table.insert(values, tostring(value)) + end + table.insert(lines, table.concat(values, separator)) + else + table.insert(lines, tostring(row)) + end + end + end + else + -- Simple array + for i, value in ipairs(records) do -- Fixed: use records + table.insert(lines, tostring(value)) + end + end + + local content = table.concat(lines, "\n") + + local file = io.open(filename, "w") + if file then + file:write(content) + file:close() + return true + end + + else + print("Error: Unsupported format '" .. format .. "'. Use 'lua', 'csv', or 'tsv'") + return false + end + + print("Error: Could not open file for writing: " .. filename) + return false +end + +----------------------- EVENTS ---------------------- + +---------------------- KEYBOARD --------------------- + +function updateLastKeyPressed() + local commonKeys = { + -- Letters + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", + "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", + -- Numbers + "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", + -- Function keys + "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12", + -- Special keys + "space", "return", "escape", "backspace", "delete", "tab", + -- Arrow keys + "up", "down", "left", "right", + -- Navigation + "home", "end", "pageup", "pagedown", "insert", + -- Modifiers + "lshift", "rshift", "lctrl", "rctrl", "lalt", "ralt", + "capslock", "numlock", "scrolllock", + -- Punctuation + ".", ",", ";", "'", "/", "\\", "[", "]", "-", "=", "`", + -- Numpad + "kp0", "kp1", "kp2", "kp3", "kp4", "kp5", "kp6", "kp7", "kp8", "kp9", + "kp.", "kp/", "kp*", "kp-", "kp+", "kpenter", + -- Other + "pause", "printscreen" + } + + -- reset keyIsPressed to false initially + keyIsPressed = false + -- Check each key and update vars + for _, k in ipairs(commonKeys) do + if love.keyboard.isDown(k) then + key = k + keyIsPressed = true + break -- Take the first key found + end + end + return key +end + +function keyIsDown(k) + return L5_env.pressedKeys[k] == true +end + +---------------------- TRANSFORM --------------------- + +function push() + love.graphics.push() +end + +function pop() + love.graphics.pop() +end + +function translate(_x,_y) + love.graphics.translate(_x,_y ) +end + +function rotate(_angle) + if L5_env.degree_mode == RADIANS then + love.graphics.rotate(_angle) + else + love.graphics.rotate(radians(_angle)) + end +end + +function scale(_sx,_sy) + if _sy ~= nil then --2 args, 2 dif scales + love.graphics.scale(_sx,_sy) + else --only 1 arg, scale same both directions + love.graphics.scale(_sx,_sx) + end +end + +function applyMatrix(...) + local args = {...} + local a, b, c, d, e, f + + -- Check if first argument is a table + if #args == 1 and type(args[1]) == "table" then + local t = args[1] + if #t ~= 6 then + error("applyMatrix() table must contain exactly 6 values") + end + a, b, c, d, e, f = t[1], t[2], t[3], t[4], t[5], t[6] + elseif #args == 6 then + a, b, c, d, e, f = args[1], args[2], args[3], args[4], args[5], args[6] + else + error("applyMatrix() requires either 6 arguments or a table with 6 values") + end + + -- Validate that all values are numbers + if type(a) ~= "number" or type(b) ~= "number" or type(c) ~= "number" or + type(d) ~= "number" or type(e) ~= "number" or type(f) ~= "number" then + error("applyMatrix() requires all values to be numbers") + end + + -- p5.js matrix format: + -- | a c e | + -- | b d f | + -- | 0 0 1 | + + -- Extract translation + local tx, ty = e, f + + -- Check if it's a pure shear matrix (no rotation/scale, just shear) + -- Pure x-shear: a=1, b=0, d=1, c=shear + if a == 1 and b == 0 and d == 1 then + local transform = love.math.newTransform(tx, ty, 0, 1, 1, 0, 0, c, 0) + love.graphics.applyTransform(transform) + return + end + + -- Pure y-shear: a=1, c=0, d=1, b=shear + if a == 1 and c == 0 and d == 1 then + local transform = love.math.newTransform(tx, ty, 0, 1, 1, 0, 0, 0, b) + love.graphics.applyTransform(transform) + return + end + + -- General case: decompose into scale, rotation, and shear + local sx = math.sqrt(a * a + b * b) + local sy = math.sqrt(c * c + d * d) + local angle = math.atan2(b, a) + + -- Calculate shear + local kx = (a * c + b * d) / (sx * sx) + local ky = 0 + + local transform = love.math.newTransform(tx, ty, angle, sx, sy, 0, 0, kx, ky) + love.graphics.applyTransform(transform) +end + +function resetMatrix() + love.graphics.origin() +end + +-------------------- TIME and DATE ------------------- + +function millis() + return 1000*love.timer.getTime() +end + +function day() + return tonumber(os.date("%d")) +end + +function month() + return tonumber(os.date("%m")) +end + +function year() + return tonumber(os.date("%Y")) +end + +function hour() + return tonumber(os.date("%H")) +end + +function minute() + return tonumber(os.date("%M")) +end + +function second() + return tonumber(os.date("%S")) +end + +------------------------ SHAPE ----------------------- + +-------------------- 2D Primitives ------------------- + +function rect(_a,_b,_c,_d,_e) + if L5_env.rect_mode==CORNERS then --x1,y1,x2,y2 + love.graphics.rectangle(L5_env.fill_mode,_a,_b,_c-_a,_d-_b,_e,_e) + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.rectangle("line",_a,_b,_c-_a,_d-_b,_e,_e) + love.graphics.setColor(r, g, b, a) + elseif L5_env.rect_mode==CENTER then --x-w/2,y-h/2,w,h + love.graphics.rectangle(L5_env.fill_mode, _a-_c/2,_b-_d/2,_c,_d,_e,_e) + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.rectangle("line", _a-_c/2,_b-_d/2,_c,_d,_e,_e) + love.graphics.setColor(r, g, b, a) + elseif L5_env.rect_mode==RADIUS then --x-w/2,y-h/2,r1*2,r2*2 + love.graphics.rectangle(L5_env.fill_mode, _a-_c,_b-_d,_c*2,_d*2,_e,_e) + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.rectangle("line", _a-_c,_b-_d,_c*2,_d*2,_e,_e) + love.graphics.setColor(r, g, b, a) + elseif L5_env.rect_mode==CORNER then --CORNER default x,y,w,h + love.graphics.rectangle(L5_env.fill_mode,_a,_b,_c,_d,_e,_e) + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.rectangle("line",_a,_b,_c,_d,_e,_e) + love.graphics.setColor(r, g, b, a) + end +end + +function square(_a,_b,_c, _d) + --note: _d is not height! it is radius of rounded corners! + --CORNERS mode doesn't exist for squares + if L5_env.rect_mode==CENTER then --x-w/2,y-h/2,w,h + love.graphics.rectangle(L5_env.fill_mode, _a-_c/2,_b-_c/2,_c,_c,_d,_d) + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.rectangle("line", _a-_c/2,_b-_c/2,_c,_c,_d,_d) + love.graphics.setColor(r, g, b, a) + elseif L5_env.rect_mode==RADIUS then --x-w/2,y-h/2,r*2,r*2 + love.graphics.rectangle(L5_env.fill_mode, _a-_c,_b-_c,_c*2,_c*2,_d,_d) + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.rectangle("line", _a-_c,_b-_c,_c*2,_c*2,_d,_d) + love.graphics.setColor(r, g, b, a) + elseif L5_env.rect_mode==CORNER then -- CORNER default x,y,w,h + love.graphics.rectangle(L5_env.fill_mode,_a,_b,_c,_c,_d,_d) + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.rectangle("line",_a,_b,_c,_c,_d,_d) + love.graphics.setColor(r, g, b, a) + end +end + +function ellipse(_a,_b,_c,_d) +--love.graphics.ellipse( mode, x, y, radiusx, radiusy, segments ) + if not _d then + _d = _c + end + if L5_env.ellipse_mode==RADIUS then + love.graphics.ellipse(L5_env.fill_mode,_a,_b,_c,_d) + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.ellipse("line",_a,_b,_c,_d) + love.graphics.setColor(r, g, b, a) + elseif L5_env.ellipse_mode==CORNER then + love.graphics.ellipse(L5_env.fill_mode,_a+_c/2,_b+_d/2,_c/2,_d/2) + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.ellipse("line",_a+_c/2,_b+_d/2,_c/2,_d/2) + love.graphics.setColor(r, g, b, a) + elseif L5_env.ellipse_mode==CORNERS then + love.graphics.ellipse(L5_env.fill_mode,_a+(_c-_a)/2,_b+(_d-_a)/2,(_c-_a)/2,(_d-_b)/2) + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.ellipse("line",_a+(_c-_a)/2,_b+(_d-_a)/2,(_c-_a)/2,(_d-_b)/2) + love.graphics.setColor(r, g, b, a) + else --default CENTER x,y,w/2,h/2 + love.graphics.ellipse(L5_env.fill_mode,_a,_b,_c/2,_d/2) + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.ellipse("line",_a,_b,_c/2,_d/2) + love.graphics.setColor(r, g, b, a) + end +end + +function circle(_a,_b,_c) + if L5_env.ellipse_mode==RADIUS then + love.graphics.ellipse(L5_env.fill_mode,_a,_b,_c,_c) + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.ellipse("line",_a,_b,_c,_d) + love.graphics.setColor(r, g, b, a) + elseif L5_env.ellipse_mode==CORNER then + love.graphics.ellipse(L5_env.fill_mode,_a+_c/2,_b+_c/2,_c/2,_c/2) + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.ellipse("line",_a+_c/2,_b+_c/2,_c/2,_c/2) + love.graphics.setColor(r, g, b, a) + elseif L5_env.ellipse_mode==CORNERS then + love.graphics.ellipse(L5_env.fill_mode,_a+(_c-_a)/2,_b+(_c-_a)/2,(_c-_a)/2,(_c-_b)/2) + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.ellipse("line",_a+(_c-_a)/2,_b+(_c-_a)/2,(_c-_a)/2,(_c-_b)/2) + love.graphics.setColor(r, g, b, a) + elseif L5_env.ellipse_mode==CENTER then --default CENTER x,y,w/2,h/2 + love.graphics.ellipse(L5_env.fill_mode,_a,_b,_c/2,_c/2) + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.ellipse("line",_a,_b,_c/2,_c/2) + love.graphics.setColor(r, g, b, a) + end +end + + + +function quad(_x1,_y1,_x2,_y2,_x3,_y3,_x4,_y4) --this is a 4-sided love2d polygon! a quad implies an applied texture + --for other # of sides, use processing api call createShape + love.graphics.polygon(L5_env.fill_mode,_x1,_y1,_x2,_y2,_x3,_y3,_x4,_y4) + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.polygon("line",_x1,_y1,_x2,_y2,_x3,_y3,_x4,_y4) + love.graphics.setColor(r, g, b, a) +end + +function triangle(_x1,_y1,_x2,_y2,_x3,_y3) --this is a 3-sided love2d polygon + love.graphics.polygon(L5_env.fill_mode,_x1,_y1,_x2,_y2,_x3,_y3) + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.polygon("line",_x1,_y1,_x2,_y2,_x3,_y3) + love.graphics.setColor(r, g, b, a) +end + +--p5 calls arctype parameter "mode" +function arc(_x, _y, _w, _h, _start, _stop, _arctype) + local arctype = _arctype or PIE + + -- Convert angles to radians if in DEGREES mode + local start_angle = _start + local stop_angle = _stop + + if L5_env.degree_mode == DEGREES then + start_angle = math.rad(_start) + stop_angle = math.rad(_stop) + end + + local radius_x = _w / 2 + local radius_y = _h / 2 + + -- Calculate center based on ellipseMode + local center_x = _x + local center_y = _y + + if L5_env.ellipse_mode == CENTER then + center_x = _x + center_y = _y + elseif L5_env.ellipse_mode == RADIUS then + center_x = _x + center_y = _y + radius_x = _w -- In RADIUS mode, w and h are the radii directly + radius_y = _h + elseif L5_env.ellipse_mode == CORNER then + center_x = _x + radius_x + center_y = _y + radius_y + elseif L5_env.ellipse_mode == CORNERS then + center_x = (_x + _w) / 2 + center_y = (_y + _h) / 2 + radius_x = (_w - _x) / 2 + radius_y = (_h - _y) / 2 + end + + -- Normalize angles to [0, 2π) range + local function normalize_angle(angle) + local TWO_PI = 2 * math.pi + angle = angle % TWO_PI + if angle < 0 then + angle = angle + TWO_PI + end + return angle + end + + local start_norm = normalize_angle(start_angle) + local stop_norm = normalize_angle(stop_angle) + + -- Processing always draws clockwise from start to stop + local arc_span + if stop_norm <= start_norm then + -- Arc crosses the 0° boundary - go the long way around + arc_span = (2 * math.pi - start_norm) + stop_norm + else + -- Normal case - direct clockwise arc + arc_span = stop_norm - start_norm + end + + -- Check if this should be a full circle + local epsilon = 1e-6 + local is_full_circle = arc_span >= (2 * math.pi - epsilon) + + if is_full_circle then + -- Draw a full ellipse + if L5_env.fill_mode and L5_env.fill_mode ~= "line" then + love.graphics.ellipse("fill", center_x, center_y, radius_x, radius_y) + end + + if L5_env.stroke_color then + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.ellipse("line", center_x, center_y, radius_x, radius_y) + love.graphics.setColor(r, g, b, a) + end + else + -- Handle elliptical arcs (when _w != _h) + if math.abs(radius_x - radius_y) < epsilon then + -- Circular arc - use Love2D's built-in arc function + local radius = radius_x + + if L5_env.fill_mode and L5_env.fill_mode ~= "line" then + love.graphics.arc("fill", arctype, center_x, center_y, radius, start_norm, start_norm + arc_span) + end + + if L5_env.stroke_color then + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.arc("line", arctype, center_x, center_y, radius, start_norm, start_norm + arc_span) + love.graphics.setColor(r, g, b, a) + end + else + -- Elliptical arc - need to draw manually with vertices + draw_elliptical_arc(center_x, center_y, radius_x, radius_y, start_norm, arc_span, arctype) + end + end +end + +-- Helper function to draw elliptical arcs +function draw_elliptical_arc(cx, cy, rx, ry, start_angle, arc_span, arctype) + local segments = math.max(8, math.floor(math.abs(arc_span) * 12)) -- Adaptive segments + local vertices = {} + + -- Generate arc vertices + for i = 0, segments do + local angle = start_angle + (arc_span * i / segments) + local x = cx + rx * math.cos(angle) + local y = cy + ry * math.sin(angle) + table.insert(vertices, x) + table.insert(vertices, y) + end + + if arctype == PIE then + -- Add center point for pie + table.insert(vertices, 1, cy) -- Insert at position 2 (after first vertex) + table.insert(vertices, 1, cx) -- Insert at position 1 + elseif arctype == CHORD then + -- Close the arc by connecting endpoints + -- vertices already has the right points + end + -- "open" type doesn't need modification + + -- Draw filled arc + if L5_env.fill_mode and L5_env.fill_mode ~= "line" and #vertices >= 6 then + if arctype == "pie" then + love.graphics.polygon("fill", vertices) + elseif arctype == CHORD then + love.graphics.polygon("fill", vertices) + end + -- "open" type doesn't get filled + end + + -- Draw stroke + if L5_env.stroke_color then + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + + if arctype == OPEN then + -- Just draw the arc line + for i = 1, #vertices - 2, 2 do + love.graphics.line(vertices[i], vertices[i+1], vertices[i+2], vertices[i+3]) + end + elseif arctype == CHORD then + -- Draw the arc and the closing line + love.graphics.polygon("line", vertices) + elseif arctype == PIE then + -- Draw the arc and lines to center + love.graphics.polygon("line", vertices) + end + + love.graphics.setColor(r, g, b, a) + end +end + +function point(_x,_y) + --Points unaffected by love.graphics.scale - size is always in pixels + --a line is drawn in the stroke color + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.points(_x,_y) + love.graphics.setColor(r, g, b, a) +end + +function line(_x1,_y1,_x2,_y2) + --a line is drawn in the stroke color + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.line(_x1,_y1,_x2,_y2) + love.graphics.setColor(r, g, b, a) +end + +function background(_r,_g,_b,_a) + if type(_r) == "userdata" and _r:type() == "Image" then + image(_r,0,0,width,height) + else + local prevR, prevG, prevB, prevA = love.graphics.getColor() + love.graphics.setColor(unpack(toColor(_r,_g,_b,_a))) + love.graphics.rectangle("fill", 0, 0, width, height) + love.graphics.setColor(prevR, prevG, prevB, prevA) + L5_env.clearscreen = true + end +end + +function colorMode(_mode, _max1, _max2, _max3, _maxA) + --handles 4 colorMode variations + -- Set the color mode + if _mode == RGB or _mode == HSB or _mode == HSL then + L5_env.color_mode = _mode + else + error("Invalid color mode. Use RGB, HSB, or HSL") + end + + -- Handle different argument patterns + if _max1 == nil then + -- No max specified - use defaults + if _mode == RGB then + L5_env.color_max = {255, 255, 255, 255} + elseif _mode == HSB or _mode == HSL then + L5_env.color_max = {360, 100, 100, 100} + end + elseif _max2 == nil then + -- One max specified - apply to all channels + L5_env.color_max = {_max1, _max1, _max1, _max1} + elseif _max3 == nil then + error("colorMode requires either 1, 3, or 4 max values") + elseif _maxA == nil then + -- Three max values specified (no alpha) + if _mode == RGB then + L5_env.color_max = {_max1, _max2, _max3, 255} -- Default alpha + elseif _mode == HSB or _mode == HSL then + L5_env.color_max = {_max1, _max2, _max3, 100} -- Default alpha + end + else + -- Four max values specified (including alpha) + L5_env.color_max = {_max1, _max2, _max3, _maxA} + end +end + +function fill(...) + L5_env.fill_mode = "fill" + local args = {...} + + -- If single argument is a table + if #args == 1 and type(args[1]) == "table" then + local t = args[1] + -- Check if it's normalized (all values <= 1.0) or raw array + if t[1] <= 1.0 and t[2] <= 1.0 and t[3] <= 1.0 and (not t[4] or t[4] <= 1.0) then + -- Already normalized, use directly + love.graphics.setColor(unpack(t)) + else + -- Raw array, needs conversion + love.graphics.setColor(unpack(toColor(unpack(t)))) + end + else + love.graphics.setColor(unpack(toColor(...))) + end +end + +--------------- CREATING and READING ---------------- + +function color(...) + local args = {...} + + -- Check if first argument is a table + if #args == 1 and type(args[1]) == "table" then + local t = args[1] + if #t == 3 then + return toColor(t[1], t[2], t[3], L5_env.color_max[4]) + elseif #t == 4 then + return toColor(t[1], t[2], t[3], t[4]) + else + error("color() table argument requires 3 or 4 values") + end + end + + -- Regular argument handling + if #args == 3 then + return toColor(args[1], args[2], args[3], L5_env.color_max[4]) + elseif #args == 4 then + return toColor(args[1], args[2], args[3], args[4]) + elseif #args == 2 then + return toColor(args[1], args[1], args[1], args[2]) + elseif #args == 1 then + return toColor(args[1]) + else + error("color() requires 1-4 arguments or a table with 3-4 values") + end +end + +function red(_color) + if type(_color) == "string" then + -- Convert CSS color string to color object first + _color = toColor(_color) + elseif type(_color) ~= "table" then + error("red() requires a color table or CSS string") + end + + -- Check if it's a normalized color object (values 0-1) from color() function + -- versus a raw array with values in the current color mode range + if _color[1] <= 1.0 and _color[2] <= 1.0 and _color[3] <= 1.0 then + -- It's normalized - scale to current color mode range + return _color[1] * L5_env.color_max[1] + else + -- It's a raw array - already in color mode range, return as-is + return _color[1] + end +end + +function green(_color) + if type(_color) == "string" then + -- Convert CSS color string to color object first + _color = toColor(_color) + elseif type(_color) ~= "table" then + error("green() requires a color table or CSS string") + end + + -- Check if it's a normalized color object (values 0-1) from color() function + -- versus a raw array with values in the current color mode range + if _color[1] <= 1.0 and _color[2] <= 1.0 and _color[3] <= 1.0 then + -- It's normalized - scale to current color mode range + return _color[2] * L5_env.color_max[2] + else + -- It's a raw array - already in color mode range, return as-is + return _color[2] + end +end + +function blue(_color) + if type(_color) == "string" then + -- Convert CSS color string to color object first + _color = toColor(_color) + elseif type(_color) ~= "table" then + error("blue() requires a color table or CSS string") + end + + -- Check if it's a normalized color object (values 0-1) from color() function + -- versus a raw array with values in the current color mode range + if _color[1] <= 1.0 and _color[2] <= 1.0 and _color[3] <= 1.0 then + -- It's normalized - scale to current color mode range + return _color[3] * L5_env.color_max[3] + else + -- It's a raw array - already in color mode range, return as-is + return _color[3] + end +end + +function alpha(_color) + if type(_color) == "string" then + -- Convert CSS color string to color object first + _color = toColor(_color) + elseif type(_color) ~= "table" then + error("alpha() requires a color table or CSS string") + end + + -- Check if it's a normalized color object (values 0-1) from color() function + -- versus a raw array with values in the current color mode range + if _color[1] <= 1.0 and _color[2] <= 1.0 and _color[3] <= 1.0 then + -- It's normalized - scale to current color mode range + return _color[4] * L5_env.color_max[4] + else + -- It's a raw array - already in color mode range, return as-is + return _color[4] + end +end + +function brightness(_color) + if type(_color) == "string" then + -- Convert CSS color string to color object first + _color = toColor(_color) + elseif type(_color) ~= "table" then + error("brightness() requires a color table or CSS string") + end + + -- Check if it's a normalized color object (values 0-1) or raw array + local isNormalized = _color[1] <= 1.0 and _color[2] <= 1.0 and _color[3] <= 1.0 + + local r, g, b + if isNormalized then + -- Already normalized (0-1) + r, g, b = _color[1], _color[2], _color[3] + else + -- Raw array - normalize it + r = _color[1] / L5_env.color_max[1] + g = _color[2] / L5_env.color_max[2] + b = _color[3] / L5_env.color_max[3] + end + + -- Convert RGB to HSB and extract brightness (which is the V in HSV) + local max = math.max(r, g, b) + local min = math.min(r, g, b) + local brightness = max -- Brightness is the max of RGB values + + -- Return brightness in the current color mode range + if L5_env.color_mode == HSB then + return brightness * L5_env.color_max[3] + else + -- Default: return in 0-100 range + return brightness * 100 + end +end + +function lightness(_color) + if type(_color) == "string" then + -- Convert CSS color string to color object first + _color = toColor(_color) + elseif type(_color) ~= "table" then + error("lightness() requires a color table or CSS string") + end + + -- Check if it's a normalized color object (values 0-1) or raw array + local isNormalized = _color[1] <= 1.0 and _color[2] <= 1.0 and _color[3] <= 1.0 + + local r, g, b + if isNormalized then + -- Already normalized (0-1) from toColor() + r, g, b = _color[1], _color[2], _color[3] + else + -- Raw array - normalize based on current color mode + if L5_env.color_mode == RGB then + r = _color[1] / L5_env.color_max[1] + g = _color[2] / L5_env.color_max[2] + b = _color[3] / L5_env.color_max[3] + elseif L5_env.color_mode == HSL then + -- Raw HSL array - convert to RGB first + r, g, b = HSLtoRGB(_color[1] / L5_env.color_max[1], _color[2] / L5_env.color_max[2], _color[3] / L5_env.color_max[3]) + elseif L5_env.color_mode == HSB then + -- Raw HSB array - convert to RGB first + r, g, b = HSVtoRGB(_color[1] / L5_env.color_max[1], _color[2] / L5_env.color_max[2], _color[3] / L5_env.color_max[3]) + end + end + + -- Convert RGB to HSL lightness + local max = math.max(r, g, b) + local min = math.min(r, g, b) + local lightness = (max + min) / 2 + + -- Return lightness in the current color mode range + if L5_env.color_mode == HSL then + return lightness * L5_env.color_max[3] + else + -- Default: return in 0-100 range + return lightness * 100 + end +end + +function hue(_color) + if type(_color) == "string" then + _color = toColor(_color) + elseif type(_color) ~= "table" then + error("hue() requires a color table or CSS string") + end + + -- toColor() always returns normalized 0-1 values + -- Raw arrays have values in the color_max range + -- If all values are <= 1, it's normalized; otherwise it's raw + local isNormalized = _color[1] <= 1.0 and _color[2] <= 1.0 and _color[3] <= 1.0 + + local r, g, b + if isNormalized then + -- Already normalized (0-1) from toColor() + r, g, b = _color[1], _color[2], _color[3] + else + -- Raw array - normalize based on current color mode + if L5_env.color_mode == RGB then + r = _color[1] / L5_env.color_max[1] + g = _color[2] / L5_env.color_max[2] + b = _color[3] / L5_env.color_max[3] + elseif L5_env.color_mode == HSL then + -- Raw HSL array - convert to RGB first + r, g, b = HSLtoRGB(_color[1] / L5_env.color_max[1], _color[2] / L5_env.color_max[2], _color[3] / L5_env.color_max[3]) + elseif L5_env.color_mode == HSB then + -- Raw HSB array - convert to RGB first + r, g, b = HSVtoRGB(_color[1] / L5_env.color_max[1], _color[2] / L5_env.color_max[2], _color[3] / L5_env.color_max[3]) + end + end + + -- Convert RGB to hue + local max = math.max(r, g, b) + local min = math.min(r, g, b) + local delta = max - min + + local h = 0 + if delta ~= 0 then + if max == r then + h = ((g - b) / delta) % 6 + elseif max == g then + h = (b - r) / delta + 2 + else + h = (r - g) / delta + 4 + end + h = h * 60 + if h < 0 then h = h + 360 end + end + + -- Return hue in the current color mode range + if L5_env.color_mode == HSB or L5_env.color_mode == HSL then + return (h / 360) * L5_env.color_max[1] + else + return h + end +end + +function lerpColor(_c1, _c2, _amt) + -- Clamp amt to [0, 1] + _amt = math.max(0, math.min(1, _amt)) + + -- Convert string colors if needed + if type(_c1) == "string" then + _c1 = toColor(_c1) + end + if type(_c2) == "string" then + _c2 = toColor(_c2) + end + + -- Check if colors are normalized or raw arrays + local c1_normalized = _c1[1] <= 1.0 and _c1[2] <= 1.0 and _c1[3] <= 1.0 + local c2_normalized = _c2[1] <= 1.0 and _c2[2] <= 1.0 and _c2[3] <= 1.0 + + -- Normalize colors if needed + local c1, c2 + if c1_normalized then + c1 = {_c1[1] * L5_env.color_max[1], _c1[2] * L5_env.color_max[2], _c1[3] * L5_env.color_max[3], _c1[4] * L5_env.color_max[4]} + else + c1 = {_c1[1], _c1[2], _c1[3], _c1[4] or L5_env.color_max[4]} + end + + if c2_normalized then + c2 = {_c2[1] * L5_env.color_max[1], _c2[2] * L5_env.color_max[2], _c2[3] * L5_env.color_max[3], _c2[4] * L5_env.color_max[4]} + else + c2 = {_c2[1], _c2[2], _c2[3], _c2[4] or L5_env.color_max[4]} + end + + -- Interpolate in the current color mode + local result = {} + for i = 1, 4 do + result[i] = c1[i] + (c2[i] - c1[i]) * _amt + end + + -- Convert back to normalized format (what toColor returns) + return { + result[1] / L5_env.color_max[1], + result[2] / L5_env.color_max[2], + result[3] / L5_env.color_max[3], + result[4] / L5_env.color_max[4] + } +end + +----------------------- COLOR ------------------------ +htmlColors = { + ["aliceblue"] = {240, 248, 255}, + ["antiquewhite"] = {250, 235, 215}, + ["aqua"] = {0, 255, 255}, + ["aquamarine"] = {127, 255, 212}, + ["azure"] = {240, 255, 255}, + ["beige"] = {245, 245, 220}, + ["bisque"] = {255, 228, 196}, + ["black"] = {0, 0, 0}, + ["blanchedalmond"] = {255, 235, 205}, + ["blue"] = {0, 0, 255}, + ["blueviolet"] = {138, 43, 226}, + ["brown"] = {165, 42, 42}, + ["burlywood"] = {222, 184, 135}, + ["cadetblue"] = {95, 158, 160}, + ["chartreuse"] = {127, 255, 0}, + ["chocolate"] = {210, 105, 30}, + ["coral"] = {255, 127, 80}, + ["cornflowerblue"] = {100, 149, 237}, + ["cornsilk"] = {255, 248, 220}, + ["crimson"] = {220, 20, 60}, + ["cyan"] = {0, 255, 255}, + ["darkblue"] = {0, 0, 139}, + ["darkcyan"] = {0, 139, 139}, + ["darkgoldenrod"] = {184, 134, 11}, + ["darkgray"] = {169, 169, 169}, + ["darkgreen"] = {0, 100, 0}, + ["darkgrey"] = {169, 169, 169}, + ["darkkhaki"] = {189, 183, 107}, + ["darkmagenta"] = {139, 0, 139}, + ["darkolivegreen"] = {85, 107, 47}, + ["darkorange"] = {255, 140, 0}, + ["darkorchid"] = {153, 50, 204}, + ["darkred"] = {139, 0, 0}, + ["darksalmon"] = {233, 150, 122}, + ["darkseagreen"] = {143, 188, 139}, + ["darkslateblue"] = {72, 61, 139}, + ["darkslategray"] = {47, 79, 79}, + ["darkslategrey"] = {47, 79, 79}, + ["darkturquoise"] = {0, 206, 209}, + ["darkviolet"] = {148, 0, 211}, + ["deeppink"] = {255, 20, 147}, + ["deepskyblue"] = {0, 191, 255}, + ["dimgray"] = {105, 105, 105}, + ["dimgrey"] = {105, 105, 105}, + ["dodgerblue"] = {30, 144, 255}, + ["firebrick"] = {178, 34, 34}, + ["floralwhite"] = {255, 250, 240}, + ["forestgreen"] = {34, 139, 34}, + ["fuchsia"] = {255, 0, 255}, + ["gainsboro"] = {220, 220, 220}, + ["ghostwhite"] = {248, 248, 255}, + ["gold"] = {255, 215, 0}, + ["goldenrod"] = {218, 165, 32}, + ["gray"] = {128, 128, 128}, + ["green"] = {0, 128, 0}, + ["greenyellow"] = {173, 255, 47}, + ["grey"] = {128, 128, 128}, + ["honeydew"] = {240, 255, 240}, + ["hotpink"] = {255, 105, 180}, + ["indianred"] = {205, 92, 92}, + ["indigo"] = {75, 0, 130}, + ["ivory"] = {255, 255, 240}, + ["khaki"] = {240, 230, 140}, + ["lavender"] = {230, 230, 250}, + ["lavenderblush"] = {255, 240, 245}, + ["lawngreen"] = {124, 252, 0}, + ["lemonchiffon"] = {255, 250, 205}, + ["lightblue"] = {173, 216, 230}, + ["lightcoral"] = {240, 128, 128}, + ["lightcyan"] = {224, 255, 255}, + ["lightgoldenrodyellow"] = {250, 250, 210}, + ["lightgray"] = {211, 211, 211}, + ["lightgreen"] = {144, 238, 144}, + ["lightgrey"] = {211, 211, 211}, + ["lightpink"] = {255, 182, 193}, + ["lightsalmon"] = {255, 160, 122}, + ["lightseagreen"] = {32, 178, 170}, + ["lightskyblue"] = {135, 206, 250}, + ["lightslategray"] = {119, 136, 153}, + ["lightslategrey"] = {119, 136, 153}, + ["lightsteelblue"] = {176, 196, 222}, + ["lightyellow"] = {255, 255, 224}, + ["lime"] = {0, 255, 0}, + ["limegreen"] = {50, 205, 50}, + ["linen"] = {250, 240, 230}, + ["magenta"] = {255, 0, 255}, + ["maroon"] = {128, 0, 0}, + ["mediumaquamarine"] = {102, 205, 170}, + ["mediumblue"] = {0, 0, 205}, + ["mediumorchid"] = {186, 85, 211}, + ["mediumpurple"] = {147, 112, 219}, + ["mediumseagreen"] = {60, 179, 113}, + ["mediumslateblue"] = {123, 104, 238}, + ["mediumspringgreen"] = {0, 250, 154}, + ["mediumturquoise"] = {72, 209, 204}, + ["mediumvioletred"] = {199, 21, 133}, + ["midnightblue"] = {25, 25, 112}, + ["mintcream"] = {245, 255, 250}, + ["mistyrose"] = {255, 228, 225}, + ["moccasin"] = {255, 228, 181}, + ["navajowhite"] = {255, 222, 173}, + ["navy"] = {0, 0, 128}, + ["oldlace"] = {253, 245, 230}, + ["olive"] = {128, 128, 0}, + ["olivedrab"] = {107, 142, 35}, + ["orange"] = {255, 165, 0}, + ["orangered"] = {255, 69, 0}, + ["orchid"] = {218, 112, 214}, + ["palegoldenrod"] = {238, 232, 170}, + ["palegreen"] = {152, 251, 152}, + ["paleturquoise"] = {175, 238, 238}, + ["palevioletred"] = {219, 112, 147}, + ["papayawhip"] = {255, 239, 213}, + ["peachpuff"] = {255, 218, 185}, + ["peru"] = {205, 133, 63}, + ["pink"] = {255, 192, 203}, + ["plum"] = {221, 160, 221}, + ["powderblue"] = {176, 224, 230}, + ["purple"] = {128, 0, 128}, + ["rebeccapurple"] = {102, 51, 153}, + ["red"] = {255, 0, 0}, + ["rosybrown"] = {188, 143, 143}, + ["royalblue"] = {65, 105, 225}, + ["saddlebrown"] = {139, 69, 19}, + ["salmon"] = {250, 128, 114}, + ["sandybrown"] = {244, 164, 96}, + ["seagreen"] = {46, 139, 87}, + ["seashell"] = {255, 245, 238}, + ["sienna"] = {160, 82, 45}, + ["silver"] = {192, 192, 192}, + ["skyblue"] = {135, 206, 235}, + ["slateblue"] = {106, 90, 205}, + ["slategray"] = {112, 128, 144}, + ["slategrey"] = {112, 128, 144}, + ["snow"] = {255, 250, 250}, + ["springgreen"] = {0, 255, 127}, + ["steelblue"] = {70, 130, 180}, + ["tan"] = {210, 180, 140}, + ["teal"] = {0, 128, 128}, + ["thistle"] = {216, 191, 216}, + ["tomato"] = {255, 99, 71}, + ["turquoise"] = {64, 224, 208}, + ["violet"] = {238, 130, 238}, + ["wheat"] = {245, 222, 179}, + ["white"] = {255, 255, 255}, + ["whitesmoke"] = {245, 245, 245}, + ["yellow"] = {255, 255, 0}, + ["yellowgreen"] = {154, 205, 50} +} + +function rectMode(_mode) + if _mode == CORNER or _mode == CORNERS or _mode == CENTER or _mode == RADIUS then + L5_env.rect_mode = _mode + else + error("rectMode() must be CORNER, CORNERS, CENTER, or RADIUS") + end +end + +function ellipseMode(_mode) + if _mode == CENTER or _mode == CORNER or _mode == CORNERS or _mode == RADIUS then + L5_env.ellipse_mode = _mode + else + error("ellipseMode() must be CENTER, CORNER, CORNERS, or RADIUS") + end +end + +function imageMode(_mode) + if _mode == CORNER or _mode == CENTER or _mode == CORNERS then + L5_env.image_mode = _mode + else + error("imageMode() must be CORNER, CENTER, or CORNERS") + end +end + +function noFill() + L5_env.fill_mode="line" +end + +function strokeWeight(_w) + love.graphics.setLineWidth(_w) + love.graphics.setPointSize(_w) --also sets sizing on points +end + +function strokeJoin(_style) + love.graphics.setLineJoin(_style) +end + +function noSmooth() + love.graphics.setDefaultFilter("nearest", "nearest", 1) + love.graphics.setLineStyle('rough') + +end + +function smooth() + love.graphics.setDefaultFilter("linear", "linear", 1) + love.graphics.setLineStyle('smooth') +end + +function stroke(_r,_g,_b,_a) + L5_env.stroke_color = toColor(_r,_g,_b,_a) +end + +function noStroke() + L5_env.stroke_color={0,0,0,0} +end + +------------------ RENDERING ------------------------ +function createGraphics(_width, _height) + local pg = {} + + -- Create the offscreen buffer + pg._canvas = love.graphics.newCanvas(_width, _height) + pg.width = _width or width + pg.height = _height or height + pg._previousCanvas = nil + pg._drawing = false + + -- Begin drawing to this graphics buffer + function pg:beginDraw() + if self._drawing then + error("beginDraw() called while already drawing to this buffer") + end + self._previousCanvas = love.graphics.getCanvas() + love.graphics.setCanvas(self._canvas) + self._drawing = true + end + + -- End drawing to this graphics buffer + function pg:endDraw() + if not self._drawing then + error("endDraw() called without beginDraw()") + end + love.graphics.setCanvas(self._previousCanvas) + self._previousCanvas = nil + self._drawing = false + end + + -- Get the canvas for drawing to screen + function pg:getCanvas() + return self._canvas + end + + return pg +end + +-------------------- VERTEX ------------------------- + +function texture(_img) + -- to be applied to vertices + L5_env.currentTexture = _img + L5_env.useTexture = true +end + +function textureMode(_mode) + -- Set how texture coordinates are interpreted + -- NORMAL - coordinates are 0 to 1 (default) + -- IMAGE - coordinates are in pixel dimensions + if _mode == NORMAL or _mode == IMAGE then + L5_env.textureMode = _mode + else + error("textureMode must be NORMAL or IMAGE") + end +end + +function textureWrap(_mode) + -- Set texture wrapping mode + -- Valid modes: CLAMP or REPEAT + if _mode == CLAMP or _mode == REPEAT then + L5_env.textureWrap = _mode + else + error("textureWrap must be CLAMP or REPEAT") + end +end + +function beginShape() + -- reset custom shape vertices table + L5_env.vertices = {} + L5_env.useTexture = false +end + +function vertex(_x, _y, _u, _v) + -- add vertex (x, y) to the custom shape vertices table + if _u ~= nil and _v ~= nil then + local texU, texV = _u, _v + + if L5_env.textureMode == IMAGE and L5_env.currentTexture then + -- Convert from pixel coordinates to normalized 0-1 range + texU = _u / L5_env.currentTexture:getWidth() + texV = _v / L5_env.currentTexture:getHeight() + end + table.insert(L5_env.vertices, {_x, _y, texU, texV}) + else + table.insert(L5_env.vertices, _x) + table.insert(L5_env.vertices, _y) + end +end + +function endShape() + -- draw the custom shape + if #L5_env.vertices > 0 then + if L5_env.useTexture and L5_env.currentTexture then + -- Use mesh for textured polygon + local mesh = love.graphics.newMesh(L5_env.vertices, "fan") + mesh:setTexture(L5_env.currentTexture) + + -- Apply texture wrap mode + L5_env.currentTexture:setWrap(L5_env.textureWrap, L5_env.textureWrap) + + love.graphics.draw(mesh) + else + -- Use regular polygon for non-textured shapes + love.graphics.polygon("fill", L5_env.vertices) + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.polygon("line", L5_env.vertices) + love.graphics.setColor(r, g, b, a) + end + end +end + +function bezier(x1,y1,x2,y2,x3,y3,x4,y4) + local curve = love.math.newBezierCurve({x1,y1,x2,y2,x3,y3,x4,y4}) + local points = curve:render() + + -- Draw fill if fill mode is set + if L5_env.fill_mode == "fill" then + -- Close the shape by connecting end point back to start + local closedPoints = {} + for i, v in ipairs(points) do + table.insert(closedPoints, v) + end + -- Add line back to start to close the shape + table.insert(closedPoints, x1) + table.insert(closedPoints, y1) + + love.graphics.polygon("fill", closedPoints) + end + + -- Draw stroke + local r, g, b, a = love.graphics.getColor() + love.graphics.setColor(unpack(L5_env.stroke_color)) + love.graphics.line(points) + love.graphics.setColor(r, g, b, a) +end + +--catmull-rom spline - generated +-- curve(x1,y1,x2,y2,x3,y3,x4,y4) +-- x1,y1: first control point (not drawn) +-- x2,y2: first anchor point (curve starts here) +-- x3,y3: second anchor point (curve ends here) +-- x4,y4: last control point (not drawn) +function curve(x1, y1, x2, y2, x3, y3, x4, y4) + local points = {} + local segments = 20 -- Number of line segments to approximate the curve + + -- Generate points along the curve + for i = 0, segments do + local t = i / segments + + -- Catmull-Rom spline formula + local t2 = t * t + local t3 = t2 * t + + -- Basis functions for Catmull-Rom spline + local b1 = -0.5 * t3 + t2 - 0.5 * t + local b2 = 1.5 * t3 - 2.5 * t2 + 1 + local b3 = -1.5 * t3 + 2 * t2 + 0.5 * t + local b4 = 0.5 * t3 - 0.5 * t2 + + -- Calculate point coordinates + local x = b1 * x1 + b2 * x2 + b3 * x3 + b4 * x4 + local y = b1 * y1 + b2 * y2 + b3 * y3 + b4 * y4 + + table.insert(points, x) + table.insert(points, y) + end + + -- Draw the curve using love.graphics.line + if #points >= 4 then + love.graphics.line(points) + end +end + +--------------------- MATH -------------------------- +function fract(_n) + return _n - int(_n) +end + +function log(_n) + return math.log(_n) +end + +function pow(n, e) + return n ^ e +end + +function exp(n) + return math.exp(n) +end + +function norm(val, start, stop) + -- normalize the value to 0-1 range + return (val - start) / (stop - start) +end + +function lerp(start, stop, amt) + return start + (stop - start) * amt +end + +function sq(n) + return n * n +end + +function sqrt(n) + return math.sqrt(n) +end + +function random(_a,_b) + if _b then + return love.math.random()*(_b-_a)+_a + elseif _a then + if type(_a) == 'table' then + -- more robust in case a table isn't ordered by integers + local keyset = {} + for k in pairs(_a) do + table.insert(keyset, k) + end + return _a[keyset[math.floor(love.math.random() * #keyset) + 1]] + elseif type(_a) == 'number' then + return love.math.random()*_a + end + else + return love.math.random() + end +end + +function randomSeed(seed) + love.math.setRandomSeed(seed) +end + +function noise(_x,_y,_z) + return love.math.noise(_x,_y,_z) +end + +--self-contained, optional params +randomGaussian = (function() + local hasSpare = false + local spare = 0 + + return function(mean, sd) + mean = mean or 0 + sd = sd or 1 + + local val + + if hasSpare then + val = spare + hasSpare = false + else + local u, v, s + repeat + u = math.random() * 2 - 1 + v = math.random() * 2 - 1 + s = u * u + v * v + until s > 0 and s < 1 + + s = math.sqrt(-2 * math.log(s) / s) + val = u * s + spare = v * s + hasSpare = true + end + + return val * sd + mean + end +end)() + +function abs(_a) + return math.abs(_a) +end + +function round(n, decimals) + decimals = decimals or 0 + local mult = 10 ^ decimals + return math.floor(n * mult + 0.5 * (n >= 0 and 1 or -1)) / mult +end + +function int(_a) + -- Handle table input + if type(_a) == "table" then + local result = {} + for i, v in ipairs(_a) do + result[i] = int(v) -- Recursively convert each element + end + return result + end + + local num + + if type(_a) == "string" then + num = tonumber(_a) + if num == nil then return nil end + elseif type(_a) == "boolean" then + num = _a and 1 or 0 + elseif type(_a) == "number" then + num = _a + else + return nil + end + + -- check for invalid numbers + if num ~= num or num == math.huge or num == -math.huge then + return nil + end + + -- strip decimal via floor + return math.floor(num) +end + +function ceil(_a) + return math.ceil(_a) +end + +function floor(_a) + return math.floor(_a) +end + +function max(...) + local args = {...} + -- If single table argument, unpack it + if #args == 1 and type(args[1]) == "table" then + return math.max(unpack(args[1])) + else + return math.max(unpack(args)) + end +end + +function min(...) + local args = {...} + -- If single table argument, unpack it + if #args == 1 and type(args[1]) == "table" then + return math.min(unpack(args[1])) + else + return math.min(unpack(args)) + end +end + +function constrain(_val,_min,_max) + return math.max(_min, math.min(_val,_max)); +end + +function map(_val, inputMin, inputMax, outputMin, outputMax, withinBounds) + local mapped = outputMin + (outputMax - outputMin) * ((_val - inputMin) / (inputMax - inputMin)) + + if withinBounds then + if outputMin < outputMax then + mapped = math.max(outputMin, math.min(outputMax, mapped)) + else + mapped = math.max(outputMax, math.min(outputMin, mapped)) + end + end + + return mapped +end + +function dist(x1,y1,x2,y2) + return ((x2-x1)^2+(y2-y1)^2)^0.5 +end + +-------------------- TRIGONOMETRY -------------------- + +function angleMode(_mode) + if not _mode then + return L5_env.degree_mode + elseif _mode == RADIANS or _mode == DEGREES then + L5_env.degree_mode = _mode + else + error("angleMode() must be RADIANS or DEGREES") + end +end + +function degrees(_angle) + return math.deg(_angle) +end + +function radians(_angle) + return math.rad(_angle) +end + +function sin(_angle) + if L5_env.degree_mode == RADIANS then + return math.sin(_angle) + else + return math.sin(radians(_angle)) + end +end + +function asin(_angle) + if L5_env.degree_mode == RADIANS then + return math.asin(_angle) + else + return math.asin(radians(_angle)) + end +end + +function cos(_angle) + if L5_env.degree_mode == RADIANS then + return math.cos(_angle) + else + return math.cos(radians(_angle)) + end +end + +function acos(_angle) + if L5_env.degree_mode == RADIANS then + return math.acos(_angle) + else + return math.acos(radians(_angle)) + end +end + +function tan(_angle) + if L5_env.degree_mode == RADIANS then + return math.tan(_angle) + else + return math.tan(radians(_angle)) + end +end + +function atan(_angle) + if L5_env.degree_mode == RADIANS then + return math.atan(_angle) + else + return math.atan(radians(_angle)) + end +end + +function atan2(y, x) + local angle = math.atan2(y, x) -- This returns radians + + if L5_env.degree_mode == DEGREES then + return math.deg(angle) -- convert to degrees + else + return angle -- or keep in default radians + end +end + +---------------------- DATA ------------------------ + +function boolean(n) + if type(n) == "table" then + local result = {} + for i, v in ipairs(n) do + result[i] = boolean(v) -- Recursively convert each element + end + return result + end + + if type(n) == "string" then + return n == "true" + end + + if type(n) == "number" then + return n ~= 0 + end + + if type(n) == "boolean" then + return n + end + + return false +end + +function byte(n) + if type(n) == "table" then + local result = {} + for i, v in ipairs(n) do + result[i] = byte(v) + end + return result + end + + if type(n) == "boolean" then + return n and 1 or 0 + end + + -- Handle strings by converting to number first, or get first character's byte value + if type(n) == "string" then + -- Try to convert to number + local num = tonumber(n) + if num then + n = num + else + -- Get first character's byte value using string library + n = string.byte(n, 1) or 0 + end + end + + if type(n) == "number" then + -- Convert to integer + local int_val = math.floor(n) + + -- Wrap to byte range (-128 to 127) + local wrapped = int_val % 256 + + -- Convert to signed byte range + if wrapped > 127 then + wrapped = wrapped - 256 + end + + return wrapped + end + + -- Default case + return 0 +end + +function char(n) + if type(n) == "table" then + local result = {} + for i, v in ipairs(n) do + result[i] = char(v) + end + return result + end + + -- handle strings by converting to number first + if type(n) == "string" then + local num = tonumber(n) + if num then + n = math.floor(num) + else + -- if not a valid number, return first character or empty string + return n:sub(1, 1) + end + end + + if type(n) == "number" then + local int_val = math.floor(n) + -- Convert to character using string.char + -- handle out of range values gracefully + if int_val >= 0 and int_val <= 1114111 then -- Valid Unicode range + local success, result = pcall(string.char, int_val) + if success then + return result + end + end + return "" + end + + -- handle booleans via converting to string + if type(n) == "boolean" then + return n and "1" or "0" + end + + -- default case + return "" +end + +function float(str) + if type(str) == "table" then + local result = {} + for i, v in ipairs(str) do + result[i] = float(v) + end + return result + end + + -- pass through numbers + if type(str) == "number" then + return str + end + + if type(str) == "boolean" then + return str and 1.0 or 0.0 + end + + if type(str) == "string" then + -- Trim whitespace + str = str:match("^%s*(.-)%s*$") + + -- try to convert to number (returns nil on failure) + return tonumber(str) + end + + -- Default case for anything else (including nil) + return nil +end + +function hex(n, digits) + if type(n) == "table" then + local result = {} + for i, v in ipairs(n) do + result[i] = hex(v, digits) + end + return result + end + + -- Default to 8 digits if not specified (matches p5.js) + digits = digits or 8 + + -- convert to int + local int_val = math.floor(tonumber(n) or 0) + + -- convert to hex string uppercase + local hex_str = string.format("%X", int_val) + + -- pad with zeros if needed + if #hex_str < digits then + hex_str = string.rep("0", digits - #hex_str) .. hex_str + end + + return hex_str +end + +function str(n) + if type(n) == "table" then + local result = {} + for i, v in ipairs(n) do + result[i] = str(v) + end + return result + end + + if type(n) == "boolean" then + return n and "true" or "false" + end + + if type(n) == "number" then + return tostring(n) + end + + -- pass through strings + if type(n) == "string" then + return n + end + + return tostring(n) +end + +function unchar(n) + if type(n) == "table" then + local result = {} + for i, v in ipairs(n) do + result[i] = unchar(v) + end + return result + end + + if type(n) == "string" then + -- get byte value of the first character + if #n > 0 then + return string.byte(n, 1) + else + return nil + end + end + + -- pass through numbers + if type(n) == "number" then + return n + end + + -- default + return nil +end + +function unhex(n) + if type(n) == "table" then + local result = {} + for i, v in ipairs(n) do + result[i] = unhex(v) + end + return result + end + + if type(n) == "string" then + -- trim whitespace + n = n:match("^%s*(.-)%s*$") + + -- convert hex string to number + return tonumber(n, 16) -- base 16 + end + + -- pass through any numbers + if type(n) == "number" then + return n + end + + -- default + return nil +end + +------------------- TYPOGRAPHY --------------------- + +function loadFont(fontPath) + local font = love.graphics.newFont(fontPath) + -- Store the path so we can recreate the font at different sizes + L5_env.fontPaths[font] = fontPath + return font +end + +function textFont(font, size) + -- Update size if provided + if size then + L5_env.currentFontSize = size + end + + -- Font object - look up its stored path + L5_env.currentFontPath = L5_env.fontPaths[font] + if L5_env.currentFontPath then + -- Recreate font with current size using stored path + L5_env.currentFont = love.graphics.newFont(L5_env.currentFontPath, L5_env.currentFontSize) + else + -- No path found, use font as-is (won't be resizable) + L5_env.currentFont = font + end + love.graphics.setFont(L5_env.currentFont) +end + +function textSize(size) + L5_env.currentFontSize = size + if L5_env.currentFontPath then + -- We have a path, recreate with new size + L5_env.currentFont = love.graphics.newFont(L5_env.currentFontPath, size) + else + -- No path stored, use default font + L5_env.currentFont = love.graphics.newFont(size) + end + love.graphics.setFont(L5_env.currentFont) +end + +function textWidth(text) + if L5_env.currentFont then + return L5_env.currentFont:getWidth(text) + end + return 0 +end + +function textHeight() + if L5_env.currentFont then + return L5_env.currentFont:getHeight() + end + return 0 +end + +--------------------- SYSTEM ----------------------- +function exit() + os.exit() +end + +function windowTitle(_title) + if _title ~= nil then + love.window.setTitle(_title) + else + return love.window.getTitle() + end +end + +function resizeWindow(_w, _h) + if _w == nil or _h == nil then --check for 2 args + error("resizeWindow() requires two arguments: width and height") + end + if type(_w) ~= "number" or type(_h) ~= "number" then -- Check if args are numbers + error("resizeWindow() requires width and height to be numbers") + end + if _w <= 0 or _h <= 0 then -- Check for reasonable values + error("resizeWindow() requires positive width and height values") + end + + -- clear active canvas first + love.graphics.setCanvas() + -- then resize + love.window.setMode(_w, _h) + + -- manually resize window + love.resize(_w, _h) +end + +function clear() + love.graphics.clear() +end + +function displayDensity() + return love.graphics.getDPIScale() +end + +function frameRate(_inp) + if _inp then --change frameRate + L5_env.framerate = _inp + else --get frameRate + return love.timer.getFPS( ) + end +end + +function noLoop() + L5_env.drawing = false +end + +function loop() + L5_env.drawing = true +end + +function isLooping() + if L5_env.drawing then + return true + else + return false + end +end + +function redraw() + draw() + noLoop() +end + +--------------------- TYPOGRAPHY --------------------- + +function text(_msg,_x,_y,_w) + if _msg == nil then + return -- Don't draw anything if message is nil + end + _msg = tostring(_msg) -- Convert to string in case it's a number, boolean, etc. + + local x_offset=0 + local y_offset=0 + local font = love.graphics.getFont() + + -- set x-offset + if L5_env.textAlignX==LEFT then + x_offset = 0 + elseif L5_env.textAlignX == RIGHT then + x_offset = font:getWidth(_msg) + elseif L5_env.textAlignX == CENTER then + x_offset = font:getWidth(_msg)/2 + end + + -- set y-offset + -- For wrapped text (when _w is specified), treat BASELINE as TOP + local effectiveAlignY = L5_env.textAlignY + if _w ~= nil and effectiveAlignY == BASELINE then + effectiveAlignY = TOP + end + + if effectiveAlignY == BASELINE then + y_offset = font:getAscent() + elseif effectiveAlignY == TOP then + y_offset = 0 + elseif effectiveAlignY == CENTER then + y_offset = font:getHeight()/2 + elseif effectiveAlignY == BOTTOM then + y_offset = font:getHeight() + end + + if _w ~= nil then + local wrapStyle = L5_env.textWrap + + if wrapStyle == CHAR then + -- Manual character wrapping (ASCII only) + local wrappedText = "" + local currentLine = "" + local lineWidth = 0 + + for i = 1, #_msg do + local char = _msg:sub(i, i) + local charWidth = font:getWidth(char) + + if lineWidth + charWidth > _w then + wrappedText = wrappedText .. currentLine .. "\n" + currentLine = char + lineWidth = charWidth + else + currentLine = currentLine .. char + lineWidth = lineWidth + charWidth + end + end + wrappedText = wrappedText .. currentLine + + love.graphics.printf(wrappedText, _x - x_offset, _y - y_offset, _w, L5_env.textAlignX) + else + -- Default WORD wrapping (LÖVE's default behavior) + love.graphics.printf(_msg, _x - x_offset, _y - y_offset, _w, L5_env.textAlignX) + end + else + -- No specified max width/wrap + love.graphics.print(_msg, _x - x_offset, _y - y_offset) + end +end + +function textAlign(x_alignment,y_alignment) + if x_alignment == LEFT or x_alignment == RIGHT or x_alignment == CENTER then + L5_env.textAlignX=x_alignment + end + if y_alignment and (y_alignment == TOP or y_alignment == CENTER or y_alignment == BOTTOM or y_alignment == BASELINE) then + L5_env.textAlignY=y_alignment + else + L5_env.textAlignY=BASELINE + end +end + +function textWrap(_style) + -- If no argument, return current style + if _style == nil then + return L5_env.textWrap + end + + -- Set the wrap style + if _style == WORD or _style == CHAR then + L5_env.textWrap = _style + else + error("textWrap() style must be WORD or CHAR") + end +end + +---------------- LOADING & DISPLAYING ---------------- + +function loadImage(_filename) + local success, result = pcall(love.graphics.newImage, _filename) + + if success then + return result + else + error("Failed to load image '" .. _filename .. "': " .. tostring(result)) + end +end + +function loadVideo(_filename) + local success, result = pcall(love.graphics.newVideo, _filename) + + if not success then + error("Failed to load video '" .. _filename .. "': " .. tostring(result)) + end + + -- Create a wrapper with additional methods + local videoWrapper = { + _video = result, + _shouldLoop = false, -- Add loop flag + + -- pause override + pause = function(self) + self._manuallyPaused = true + self._video:pause() + end, + + -- stop method - pause and rewind + stop = function(self) + self._manuallyPaused = true + self._video:pause() + self._video:rewind() + end, + + -- play override + play = function(self) + self._manuallyPaused = false + self._video:play() + end, + + -- loop() method + loop = function(self) + self._shouldLoop = true + self._manuallyPaused = false + self._video:play() + end, + + -- noLoop() method + noLoop = function(self) + self._shouldLoop = false + end, + + -- time() method + time = function(self, t) + if t == nil then + return self._video:tell() + else + self._video:seek(t) + end + end, + + -- volume() method + volume = function(self, val) + if val == nil then + local source = self._video:getSource() + return source and source:getVolume() or 1 + else + local source = self._video:getSource() + if source then + source:setVolume(val) + end + end + end + } + + -- Create metatable + setmetatable(videoWrapper, { + __index = function(t, key) + if rawget(t, key) then + return rawget(t, key) + end + local value = t._video[key] + if type(value) == "function" then + return function(_, ...) return value(t._video, ...) end + end + return value + end + }) + + -- Register video for loop tracking + L5_env.videos = L5_env.videos or {} + table.insert(L5_env.videos, videoWrapper) + + return videoWrapper +end + +function image(_img,_x,_y,_w,_h) + local originalWidth = _img:getWidth() + local originalHeight = _img:getHeight() + local xscale, yscale, ox, oy + + if L5_env.image_mode == CENTER then + -- CENTER mode: _x,_y is center, _w,_h are width and height + xscale = _w and (_w/originalWidth) or 1 + yscale = _h and (_h/originalHeight) or xscale + ox = originalWidth/2 + oy = originalHeight/2 + elseif L5_env.image_mode == CORNERS then + -- CORNERS mode: (_x,_y) is top-left corner, (_w,_h) is bottom-right corner + local width = _w - _x + local height = _h - _y + xscale = width / originalWidth + yscale = height / originalHeight + ox, oy = 0, 0 + else -- CORNER mode (default) + -- CORNER mode: _x,_y is top-left, _w,_h are width and height + xscale = _w and (_w/originalWidth) or 1 + yscale = _h and (_h/originalHeight) or xscale + ox, oy = 0, 0 + end + + love.graphics.draw(_img,_x,_y,0,xscale,yscale,ox,oy) +end + +function tint(...) + local args = {...} + if #args == 1 and type(args[1]) == "table" then + L5_env.currentTint = toColor(unpack(args[1])) + else + L5_env.currentTint = toColor(...) + end +end + +function noTint() + L5_env.currentTint = {1, 1, 1, 1} +end + +-- Override love.graphics.draw to automatically apply tint +local originalDraw = love.graphics.draw +function love.graphics.draw(drawable, x, y, r, sx, sy, ox, oy, kx, ky) + local prevR, prevG, prevB, prevA = love.graphics.getColor() + + -- Check if it's a video wrapper (our custom table) + local actualDrawable = drawable + if type(drawable) == "table" and drawable._video then + actualDrawable = drawable._video -- Unwrap to get the real video + end + + -- Handle Image and Video objects + if type(actualDrawable) == "userdata" and + (actualDrawable:type() == "Image" or actualDrawable:type() == "Video") then + if L5_env.currentTint then + love.graphics.setColor(unpack(L5_env.currentTint)) + else + love.graphics.setColor(1, 1, 1, 1) -- No tint = white + end + end + + originalDraw(actualDrawable, x, y, r, sx, sy, ox, oy, kx, ky) + love.graphics.setColor(prevR, prevG, prevB, prevA) +end + +function cursor(_cursor_icon, hotX, hotY) + love.mouse.setVisible(true) + local _cursor_icon = _cursor_icon or "arrow" + local hotX = hotX or 0 + local hotY = hotY or 0 + + -- Check if it's a system cursor type + local systemCursors = { + "arrow", "ibeam", "wait", "crosshair", "waitarrow", + "sizenwse", "sizenesw", "sizewe", "sizens", "sizeall", + "no", "hand" + } + + local isSystemCursor = false + for _, cursorType in ipairs(systemCursors) do + if _cursor_icon == cursorType then + isSystemCursor = true + break + end + end + + if isSystemCursor then + -- Use system cursor + local _cursor = love.mouse.getSystemCursor(_cursor_icon) + love.mouse.setCursor(_cursor) + elseif type(_cursor_icon) == "userdata" and _cursor_icon:type() == "ImageData" then + -- Use ImageData directly + local _cursor = love.mouse.newCursor(_cursor_icon, hotX, hotY) + love.mouse.setCursor(_cursor) + elseif type(_cursor_icon) == "string" then + -- Treat as file path to custom cursor image + local cursorImage = love.image.newImageData(_cursor_icon) + local _cursor = love.mouse.newCursor(cursorImage, hotX, hotY) + love.mouse.setCursor(_cursor) + end +end + +function noCursor() + love.mouse.setVisible(false) +end + +---------------------- Pixels ---------------------- + +function copy(source, sx, sy, sw, sh, dx, dy, dw, dh) + -- If source is nil, try to use the current canvas + if source == nil then + source = love.graphics.getCanvas() + + -- If still nil, we can't copy from the screen + if source == nil then + error("copy() requires a source image or an active canvas") + end + end + + local quad = love.graphics.newQuad(sx, sy, sw, sh, + source:getDimensions()) + + local scaleX = dw / sw + local scaleY = dh / sh + love.graphics.draw(source, quad, dx, dy, 0, scaleX, scaleY) +end + +function blend(source, sx, sy, sw, sh, dx, dy, dw, dh, blendMode) + -- allows blend, normal, add, multiply, screen, lightest, darkest, replace + -- would need to be implemented with shaders: DIFFERENCE, EXCLUSION, OVERLAY, HARD_LIGHT, SOFT_LIGHT, DODGE, BURN + if source == nil then + source = love.graphics.getCanvas() + + if source == nil then + error("blend() requires a source image or an active canvas") + end + end + + local quad = love.graphics.newQuad(sx, sy, sw, sh, + source:getDimensions()) + + -- Save previous blend mode + local previousMode, previousAlphaMode = love.graphics.getBlendMode() + + -- Map p5.js blend modes to LÖVE2D + local mode, alphaMode = "alpha", "alphamultiply" + + if blendMode == BLEND or blendMode == NORMAL then + mode, alphaMode = "alpha", "alphamultiply" + elseif blendMode == ADD then + mode, alphaMode = "add", "alphamultiply" + elseif blendMode == MULTIPLY then + mode, alphaMode = "multiply", "premultiplied" + elseif blendMode == SCREEN then + mode, alphaMode = "screen", "premultiplied" + elseif blendMode == LIGHTEST then + mode, alphaMode = "lighten", "premultiplied" + elseif blendMode == DARKEST then + mode, alphaMode = "darken", "premultiplied" + elseif blendMode == REPLACE then + mode, alphaMode = "replace", "alphamultiply" + else + error("Unknown blend mode "..tostring(blendMode)..". Must be of type: BLEND, NORMAL, ADD, MULTIPLY, SCREEN, LIGHTEST, DARKEST, REPLACE.") + end + + love.graphics.setBlendMode(mode, alphaMode) + + local scaleX = dw / sw + local scaleY = dh / sh + love.graphics.draw(source, quad, dx, dy, 0, scaleX, scaleY) + + love.graphics.setBlendMode(previousMode, previousAlphaMode) +end + +function filter(_name, _param) + if _name == GRAY then + L5_env.filterOn = true + L5_env.filter = L5_filter.grayscale + elseif _name == THRESHOLD then + if _param then + L5_filter.threshold:send("threshold", _param) + end + L5_env.filterOn = true + L5_env.filter = L5_filter.threshold + elseif _name == INVERT then + L5_env.filterOn = true + L5_env.filter = L5_filter.invert + elseif _name == POSTERIZE then + if _param then + L5_filter.posterize:send("levels", _param) + end + L5_env.filterOn = true + L5_env.filter = L5_filter.posterize + elseif _name == BLUR then + if L5_filter.blurSupportsParameter then + -- Scale to match p5.js: their radius 4 = our radius 15 + local radius = (_param or 4.0) * 5.5 + L5_filter.blur_horizontal:send("blurRadius", radius) + L5_filter.blur_vertical:send("blurRadius", radius) + L5_env.filterOn = true + L5_env.filter = "blur_twopass" + elseif L5_filter.blur then + L5_env.filterOn = true + L5_env.filter = L5_filter.blur + else + print("Blur filter not available on this system") + end + elseif _name == ERODE then + if _param then + L5_filter.erode:send("strength", _param) + end + L5_env.filterOn = true + L5_env.filter = L5_filter.erode + elseif _name == DILATE then + if _param then + L5_filter.dilate:send("strength", _param) + end + L5_env.filterOn = true + L5_env.filter = L5_filter.dilate + else + error("Error: not a filter name.") + end +end + +-- Load pixels from the back buffer into the pixels array +function loadPixels() + if not L5_env.backBuffer then + error("L5_env.backBuffer not initialized. Make sure L5 is loaded properly.") + end + + -- Must unbind canvas to call newImageData() on it + local wasActive = love.graphics.getCanvas() == L5_env.backBuffer + love.graphics.setCanvas() + L5_env.imageData = L5_env.backBuffer:newImageData() + if wasActive then + love.graphics.setCanvas(L5_env.backBuffer) + end + + local w = L5_env.imageData:getWidth() + local h = L5_env.imageData:getHeight() + + -- Clear the pixels array + pixels = {} + + -- Fill pixels array with RGBA values (0-255 like p5.js) + -- Index: (x + y * width) * 4 + for y = 0, h - 1 do + for x = 0, w - 1 do + local r, g, b, a = L5_env.imageData:getPixel(x, y) + local idx = (x + y * w) * 4 + pixels[idx] = r * 255 + pixels[idx + 1] = g * 255 + pixels[idx + 2] = b * 255 + pixels[idx + 3] = a * 255 + end + end + + L5_env.pixelsLoaded = true -- Changed from pixelsLoaded to L5_env.pixelsLoaded +end + +-- Update the back buffer with modified pixel data +function updatePixels() + if not L5_env.pixelsLoaded then + return + end + + local w = L5_env.imageData:getWidth() + local h = L5_env.imageData:getHeight() + + -- Write pixels array back to imageData + for y = 0, h - 1 do + for x = 0, w - 1 do + local idx = (x + y * w) * 4 + local r = (pixels[idx] or 0) / 255 -- Changed from L5_env.pixels to pixels + local g = (pixels[idx + 1] or 0) / 255 + local b = (pixels[idx + 2] or 0) / 255 + local a = (pixels[idx + 3] or 255) / 255 + L5_env.imageData:setPixel(x, y, r, g, b, a) + end + end + + -- Create a new image from the modified imageData and draw it to the backBuffer + local tempImage = love.graphics.newImage(L5_env.imageData) + local wasActive = love.graphics.getCanvas() == L5_env.backBuffer + love.graphics.setCanvas(L5_env.backBuffer) + love.graphics.draw(tempImage, 0, 0) + if not wasActive then + love.graphics.setCanvas() + end + + L5_env.pixelsLoaded = false +end + +-- Helper function to get pixel index +function getPixelIndex(x, y) + local w = L5_env.imageData:getWidth() + return (x + y * w) * 4 +end + +-- Helper to set a pixel color (optional convenience function) +function setPixel(x, y, r, g, b, a) + local idx = getPixelIndex(x, y) + pixels[idx] = r + pixels[idx + 1] = g + pixels[idx + 2] = b + pixels[idx + 3] = a or 255 +end + +function get(x, y, w, h) + if not x then + -- No parameters: return entire window as image + local wasActive = love.graphics.getCanvas() == L5_env.backBuffer + love.graphics.setCanvas() + local imageData = L5_env.backBuffer:newImageData() + if wasActive then + love.graphics.setCanvas(L5_env.backBuffer) + end + return love.graphics.newImage(imageData) + elseif not w then + -- Two parameters: return pixel RGBA (0-255 range) + local wasActive = love.graphics.getCanvas() == L5_env.backBuffer + love.graphics.setCanvas() + local imageData = L5_env.backBuffer:newImageData() + local r, g, b, a = imageData:getPixel(x, y) + if wasActive then + love.graphics.setCanvas(L5_env.backBuffer) + end + return r * 255, g * 255, b * 255, a * 255 + else + -- Four parameters: return sub-region as image + local wasActive = love.graphics.getCanvas() == L5_env.backBuffer + love.graphics.setCanvas() + local fullImageData = L5_env.backBuffer:newImageData() + + -- Create a new ImageData for the sub-region + local subImageData = love.image.newImageData(w, h) + subImageData:paste(fullImageData, 0, 0, x, y, w, h) + + if wasActive then + love.graphics.setCanvas(L5_env.backBuffer) + end + return love.graphics.newImage(subImageData) + end +end + +function set(x, y, c) + if type(c) == "userdata" and c.type and c:type() == "Image" then + -- c is an image, draw it at x,y + local wasActive = love.graphics.getCanvas() == L5_env.backBuffer + love.graphics.setCanvas(L5_env.backBuffer) + love.graphics.draw(c, x, y) + if not wasActive then + love.graphics.setCanvas() + end + elseif type(c) == "table" then + -- c is a color table {r, g, b, a} (in 0-1 range) + -- Draw a 1x1 point at x,y with this color + local wasActive = love.graphics.getCanvas() == L5_env.backBuffer + love.graphics.setCanvas(L5_env.backBuffer) + local prevColor = {love.graphics.getColor()} + love.graphics.setColor(c[1], c[2], c[3], c[4] or 1) + love.graphics.points(x, y) + love.graphics.setColor(unpack(prevColor)) + if not wasActive then + love.graphics.setCanvas() + end + elseif type(c) == "number" then + -- c is a grayscale value (0-255) + local wasActive = love.graphics.getCanvas() == L5_env.backBuffer + love.graphics.setCanvas(L5_env.backBuffer) + local prevColor = {love.graphics.getColor()} + local normalized = c / 255 + love.graphics.setColor(normalized, normalized, normalized, 1) + love.graphics.points(x, y) + love.graphics.setColor(unpack(prevColor)) + if not wasActive then + love.graphics.setCanvas() + end + end +end + +--- shaders +local function createShaderSafe(shaderCode, fallbackMessage) + local success, shader = pcall(love.graphics.newShader, shaderCode) + if success then + return shader + else + print("Warning: " .. fallbackMessage) + return nil + end +end + +L5_filter = {} + + +L5_filter.grayscale = createShaderSafe([[ + vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) + { + vec4 pixel = Texel(texture, texture_coords); + float gray = dot(pixel.rgb, vec3(0.299, 0.587, 0.114)); + return vec4(gray, gray, gray, pixel.a) * color; + } +]], "Grayscale shader failed to compile - filter unavailable") + +--from https://www.love2d.org/forums/viewtopic.php?t=3733&start=300, modified to work on Mac +L5_filter.threshold = createShaderSafe([[ +extern float soft; +extern float threshold; +vec4 effect( vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords ) + { + float f = soft * 0.5; + float a = threshold - f; + float b = threshold + f; + + vec4 tx = Texel( texture, texture_coords ); + float l = (tx.r + tx.g + tx.b) * 0.333333; + vec3 col = vec3( smoothstep(a, b, l) ); + + return vec4( col, 1.0 ) * color; + } +]], "Threshold shader failed to compile - filter unavailable") + +-- from https://www.reddit.com/r/love2d/comments/ee8n0j/how_to_make_inverted_colornegative_shader/fcaouw5/ +L5_filter.invert = createShaderSafe([[ +vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 pixel_coords) + { + vec4 col = Texel( texture, texture_coords ); + return vec4(1.0-col.r, 1.0-col.g, 1.0-col.b, col.a) * color; + } +]], "Invert shader failed to compile - filter unavailable") + +L5_filter.posterize = createShaderSafe([[ + uniform float levels; + + vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) { + vec4 pixel = Texel(texture, texture_coords); + + pixel.r = floor(pixel.r * levels) / levels; + pixel.g = floor(pixel.g * levels) / levels; + pixel.b = floor(pixel.b * levels) / levels; + + return pixel * color; + } +]], "Posterize shader failed to compile - filter unavailable") + +-- Two-pass blur matching p5.js 2D implementation +L5_filter.blur_horizontal = createShaderSafe([[ + uniform float blurRadius; + uniform vec2 textureSize; + + vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) { + vec2 pixelSize = 1.0 / textureSize; + + // Clamp to minimum radius to avoid divide by zero + float safeRadius = max(blurRadius, 0.01); + + vec4 sum = vec4(0.0); + float totalWeight = 0.0; + + const int maxSamples = 32; + + // Horizontal pass only + for(int x = -maxSamples; x <= maxSamples; x++) { + float fx = float(x); + float distance = abs(fx); + + if (distance > safeRadius) continue; + + float radiusi = safeRadius - distance; + float weight = radiusi * radiusi; + + vec2 offset = vec2(fx, 0.0) * pixelSize; + sum += Texel(texture, texture_coords + offset) * weight; + totalWeight += weight; + } + + return (sum / totalWeight) * color; + } +]], "Horizontal blur pass failed to compile") + +L5_filter.blur_vertical = createShaderSafe([[ + uniform float blurRadius; + uniform vec2 textureSize; + + vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) { + vec2 pixelSize = 1.0 / textureSize; + + // Clamp to minimum radius to avoid divide by zero + float safeRadius = max(blurRadius, 0.01); + + vec4 sum = vec4(0.0); + float totalWeight = 0.0; + + const int maxSamples = 32; + + // Vertical pass only + for(int y = -maxSamples; y <= maxSamples; y++) { + float fy = float(y); + float distance = abs(fy); + + if (distance > safeRadius) continue; + float radiusi = safeRadius - distance; + float weight = radiusi * radiusi; + + vec2 offset = vec2(0.0, fy) * pixelSize; + sum += Texel(texture, texture_coords + offset) * weight; + totalWeight += weight; + } + + return (sum / totalWeight) * color; + } +]], "Vertical blur pass failed to compile") + +-- Track if two-pass blur is available +L5_filter.blurSupportsParameter = (L5_filter.blur_horizontal ~= nil and L5_filter.blur_vertical ~= nil) + +-- If two-pass failed, create simple 3x3 Gaussian fallback +if not L5_filter.blurSupportsParameter then + L5_filter.blur = createShaderSafe([[ + uniform vec2 textureSize; + + vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) { + vec2 pixelSize = 1.0 / textureSize; + vec4 sum = vec4(0.0); + + // 3x3 Gaussian kernel (radius 1) + sum += Texel(texture, texture_coords + vec2(-1.0, -1.0) * pixelSize) * 1.0; + sum += Texel(texture, texture_coords + vec2( 0.0, -1.0) * pixelSize) * 2.0; + sum += Texel(texture, texture_coords + vec2( 1.0, -1.0) * pixelSize) * 1.0; + sum += Texel(texture, texture_coords + vec2(-1.0, 0.0) * pixelSize) * 2.0; + sum += Texel(texture, texture_coords + vec2( 0.0, 0.0) * pixelSize) * 4.0; + sum += Texel(texture, texture_coords + vec2( 1.0, 0.0) * pixelSize) * 2.0; + sum += Texel(texture, texture_coords + vec2(-1.0, 1.0) * pixelSize) * 1.0; + sum += Texel(texture, texture_coords + vec2( 0.0, 1.0) * pixelSize) * 2.0; + sum += Texel(texture, texture_coords + vec2( 1.0, 1.0) * pixelSize) * 1.0; + + return (sum / 16.0) * color; + } + ]], "Blur shader completely unavailable") +end + +L5_filter.erode = createShaderSafe([[ + uniform float strength; + uniform vec2 textureSize; + + vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) { + vec2 pixelSize = 1.0 / textureSize; + + vec4 centerColor = Texel(texture, texture_coords); + vec4 result = centerColor; + + // 3x3 erosion - unrolled for compatibility + vec2 offset; + vec4 neighborColor; + + // Manually unroll the 3x3 kernel (excluding center) + offset = vec2(-1.0, -1.0) * pixelSize * strength; + neighborColor = Texel(texture, texture_coords + offset); + result = mix(result, min(result, neighborColor), 0.3); + + offset = vec2(0.0, -1.0) * pixelSize * strength; + neighborColor = Texel(texture, texture_coords + offset); + result = mix(result, min(result, neighborColor), 0.3); + + offset = vec2(1.0, -1.0) * pixelSize * strength; + neighborColor = Texel(texture, texture_coords + offset); + result = mix(result, min(result, neighborColor), 0.3); + + offset = vec2(-1.0, 0.0) * pixelSize * strength; + neighborColor = Texel(texture, texture_coords + offset); + result = mix(result, min(result, neighborColor), 0.3); + + offset = vec2(1.0, 0.0) * pixelSize * strength; + neighborColor = Texel(texture, texture_coords + offset); + result = mix(result, min(result, neighborColor), 0.3); + + offset = vec2(-1.0, 1.0) * pixelSize * strength; + neighborColor = Texel(texture, texture_coords + offset); + result = mix(result, min(result, neighborColor), 0.3); + + offset = vec2(0.0, 1.0) * pixelSize * strength; + neighborColor = Texel(texture, texture_coords + offset); + result = mix(result, min(result, neighborColor), 0.3); + + offset = vec2(1.0, 1.0) * pixelSize * strength; + neighborColor = Texel(texture, texture_coords + offset); + result = mix(result, min(result, neighborColor), 0.3); + + return result * color; + } +]], "Erode shader failed to compile - filter unavailable") + +L5_filter.dilate = createShaderSafe([[ + uniform float strength; + uniform float threshold; + uniform vec2 textureSize; + + vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) { + vec2 pixelSize = 1.0 / textureSize; + + vec4 centerColor = Texel(texture, texture_coords); + vec4 maxColor = centerColor; + + float centerBrightness = dot(centerColor.rgb, vec3(0.299, 0.587, 0.114)); + + // Only dilate if center pixel is bright enough + if (centerBrightness > threshold) { + // Simplified 3x3 dilation + vec2 offset; + vec4 neighborColor; + float neighborBrightness; + float weight; + + // Unroll 3x3 kernel (excluding center) + offset = vec2(-1.0, -1.0) * pixelSize; + neighborColor = Texel(texture, texture_coords + offset); + neighborBrightness = dot(neighborColor.rgb, vec3(0.299, 0.587, 0.114)); + if (neighborBrightness > threshold) { + weight = 1.0 - 1.414 / (strength + 1.0); + maxColor = max(maxColor, neighborColor * weight); + } + + offset = vec2(0.0, -1.0) * pixelSize; + neighborColor = Texel(texture, texture_coords + offset); + neighborBrightness = dot(neighborColor.rgb, vec3(0.299, 0.587, 0.114)); + if (neighborBrightness > threshold) { + weight = 1.0 - 1.0 / (strength + 1.0); + maxColor = max(maxColor, neighborColor * weight); + } + + offset = vec2(1.0, -1.0) * pixelSize; + neighborColor = Texel(texture, texture_coords + offset); + neighborBrightness = dot(neighborColor.rgb, vec3(0.299, 0.587, 0.114)); + if (neighborBrightness > threshold) { + weight = 1.0 - 1.414 / (strength + 1.0); + maxColor = max(maxColor, neighborColor * weight); + } + + offset = vec2(-1.0, 0.0) * pixelSize; + neighborColor = Texel(texture, texture_coords + offset); + neighborBrightness = dot(neighborColor.rgb, vec3(0.299, 0.587, 0.114)); + if (neighborBrightness > threshold) { + weight = 1.0 - 1.0 / (strength + 1.0); + maxColor = max(maxColor, neighborColor * weight); + } + + offset = vec2(1.0, 0.0) * pixelSize; + neighborColor = Texel(texture, texture_coords + offset); + neighborBrightness = dot(neighborColor.rgb, vec3(0.299, 0.587, 0.114)); + if (neighborBrightness > threshold) { + weight = 1.0 - 1.0 / (strength + 1.0); + maxColor = max(maxColor, neighborColor * weight); + } + + offset = vec2(-1.0, 1.0) * pixelSize; + neighborColor = Texel(texture, texture_coords + offset); + neighborBrightness = dot(neighborColor.rgb, vec3(0.299, 0.587, 0.114)); + if (neighborBrightness > threshold) { + weight = 1.0 - 1.414 / (strength + 1.0); + maxColor = max(maxColor, neighborColor * weight); + } + + offset = vec2(0.0, 1.0) * pixelSize; + neighborColor = Texel(texture, texture_coords + offset); + neighborBrightness = dot(neighborColor.rgb, vec3(0.299, 0.587, 0.114)); + if (neighborBrightness > threshold) { + weight = 1.0 - 1.0 / (strength + 1.0); + maxColor = max(maxColor, neighborColor * weight); + } + + offset = vec2(1.0, 1.0) * pixelSize; + neighborColor = Texel(texture, texture_coords + offset); + neighborBrightness = dot(neighborColor.rgb, vec3(0.299, 0.587, 0.114)); + if (neighborBrightness > threshold) { + weight = 1.0 - 1.414 / (strength + 1.0); + maxColor = max(maxColor, neighborColor * weight); + } + } + + return maxColor * color; + } +]], "Dilate shader failed to compile - filter unavailable") diff --git a/l5-mockup/README.txt b/l5-mockup/README.txt new file mode 100644 index 0000000..2efd5e5 --- /dev/null +++ b/l5-mockup/README.txt @@ -0,0 +1,19 @@ +# Welcome to L5 + +This is an example L5 project containing the L5.lua library and a starter main.lua file. + +In order to use L5 and run your software you must have Love2d installed on your computer. Refer to the https://L5lua.org/download page or https://L5lua.org/tutorials for more information on installing Love2d on your machine. + +You do not need to edit L5.lua but it must be in your project folder. It is extensively commented, so you should feel welcome to read the source code. + +main.lua is the name of your own program code. We've started you with a couple basic lines. At the top is 'require("L5")', which tells your program to use the L5 library. Following that are setup() and draw() functions you can complete. + +For more information on getting started with L5, visit https://L5lua.org/tutorials + +An online reference to the L5 library is available at https://L5lua.org/reference + +L5 is built in community. For more information and to contribute, report bugs, etc., visit https://l5lua.org/contributing/ + +## License + +The L5 library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, version 2.1. diff --git a/l5-mockup/main.lua b/l5-mockup/main.lua new file mode 100644 index 0000000..2bfbeff --- /dev/null +++ b/l5-mockup/main.lua @@ -0,0 +1,19 @@ +require("L5") + +function setup() + size(400, 400) + + -- Set the program title + windowTitle("Basic sketch") + + -- Sets print command output to display in window + printToScreen() + + describe('Draws a yellow background') +end + +function draw() + -- Fills the background with the color yellow + background(255, 215, 0) +end +