From fa0bada31337a587cc14b79a6c076ff5936be8f7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 11 Aug 2026 14:08:36 -0400 Subject: [PATCH] Add color blind accessibility filter --- main.lua | 61 +++++++++++++++++++++++++++++++++++++++++++- shaders.lua | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/main.lua b/main.lua index cde834c..e80f763 100644 --- a/main.lua +++ b/main.lua @@ -31,6 +31,7 @@ function love.load() _initGridVars() _initSounds() _initGlobalColors() + _initAccessibility() gameWorld = World:new() _initLevelEditor() @@ -116,16 +117,42 @@ function love.draw() love.graphics.pop() love.graphics.setCanvas() + -- Collect every visible layer before the accessibility pass. Applying the + -- shader here also covers the editor sidebar, FPS display, and dialogue, + -- rather than only recolouring the room sprites. + love.graphics.setCanvas(presentationCanvas) + love.graphics.clear() love.graphics.setBlendMode("alpha") love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(finalCanvas, 0, 0) love.graphics.draw(sideBarCanvas, sideBarBox.x, sideBarBox.y) love.graphics.print("Current FPS: "..tostring(love.timer.getFPS( )), 10, 10) - Dialogue.draw() + love.graphics.setCanvas() + + love.graphics.setColor(1, 1, 1, 1) + local filter = accessibilityModes[accessibilityModeIndex] + if filter.shader then + filter.shader:send("accessibilityTime", love.timer.getTime()) + love.graphics.setShader(filter.shader) + end + love.graphics.draw(presentationCanvas, 0, 0) + love.graphics.setShader() + + if accessibilityMessage and love.timer.getTime() < accessibilityMessageExpires then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.printf(accessibilityMessage, 0, 30, love.graphics.getWidth(), "center") + end end function love.keypressed(key) + -- Available even while a dialogue is open, so the player never has to + -- dismiss something important just to change the colour treatment. + if key == "f4" then + cycleAccessibilityMode() + return + end + -- Talking to a sign captures input: navigate/choose, don't move the player. if Dialogue.isOpen() then Dialogue.keypressed(key) @@ -237,6 +264,7 @@ end function _initCanvases() gameCanvas = love.graphics.newCanvas() finalCanvas = love.graphics.newCanvas() + presentationCanvas = love.graphics.newCanvas() sideBarCanvas = love.graphics.newCanvas(math.max(1, sideBarBox.w), math.max(1, sideBarBox.h)) sideBarCanvas:setFilter("nearest", "nearest") -- never average when sampled end @@ -253,6 +281,37 @@ function _initGlobalColors() gColor.red, gColor.green, gColor.blue, gColor.white = Color:new("red"), Color:new("green"), Color:new("blue"), Color:new("white") end +function _initAccessibility() + accessibilityModes = { + { id = "normal", label = "Normal", shader = nil }, + { id = "deuteranopia", label = "Deuteranopia", shader = accessibilityColorShader }, + } + accessibilityModeIndex = 1 + + local savedMode = love.filesystem.read("accessibility_mode.txt") + if savedMode then + savedMode = savedMode:match("[%w_]+") + if savedMode == "deuteranopia_high_contrast" then savedMode = "deuteranopia" end + for index, mode in ipairs(accessibilityModes) do + if mode.id == savedMode then + accessibilityModeIndex = index + break + end + end + end + local mode = accessibilityModes[accessibilityModeIndex] + accessibilityMessage = "Color filter: " .. mode.label .. " (F4 to change)" + accessibilityMessageExpires = love.timer.getTime() + 4 +end + +function cycleAccessibilityMode() + accessibilityModeIndex = accessibilityModeIndex % #accessibilityModes + 1 + local mode = accessibilityModes[accessibilityModeIndex] + love.filesystem.write("accessibility_mode.txt", mode.id) + accessibilityMessage = "Color filter: " .. mode.label .. " (F4 to change)" + accessibilityMessageExpires = love.timer.getTime() + 2.5 +end + function _initSounds() staticWave = sfxr.newSound() staticWave.waveform = sfxr.WAVEFORM.NOISE -- was `wavetype = 3`; field renamed in this sfxr diff --git a/shaders.lua b/shaders.lua index a08f415..a046df1 100644 --- a/shaders.lua +++ b/shaders.lua @@ -57,4 +57,75 @@ glow = love.graphics.newShader[[ } return pixel_me; } - ]] \ No newline at end of file + ]] + +-- Accessibility post-process. The puzzle's RGB channels are normally also +-- its visual language, so this runs only after the entire scene has been +-- composed. It leaves neutral UI/text alone and remaps saturated colours to +-- a palette whose red, green, and yellow accents remain distinct for people +-- with deuteranopia. +accessibilityColorShader = love.graphics.newShader[[ + extern number accessibilityTime; + + vec3 rgbToHsv(vec3 c) { + float maximum = max(c.r, max(c.g, c.b)); + float minimum = min(c.r, min(c.g, c.b)); + float delta = maximum - minimum; + float hue = 0.0; + + if (delta > 0.0001) { + if (maximum == c.r) { + hue = mod((c.g - c.b) / delta, 6.0); + } else if (maximum == c.g) { + hue = ((c.b - c.r) / delta) + 2.0; + } else { + hue = ((c.r - c.g) / delta) + 4.0; + } + hue = hue / 6.0; + if (hue < 0.0) hue += 1.0; + } + + float saturation = maximum <= 0.0 ? 0.0 : delta / maximum; + return vec3(hue, saturation, maximum); + } + + vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) { + vec4 pixel = Texel(texture, texture_coords) * color; + vec3 hsv = rgbToHsv(pixel.rgb); + + // Preserve greys, white (including additive RGB overlap), and near-black + // background detail. Only colourful puzzle elements are recoloured. + if (hsv.y < 0.22 || hsv.z < 0.06) return pixel; + + // Keep every additive pair in its own bucket. In particular, magenta + // (red + blue) must not become the same colour as red, and cyan + // (green + blue) must not become the same colour as green. + vec3 replacement; + float pattern = 0.0; + // Advance in whole pixels so the texture stays crisp instead of swimming. + float ditherStep = floor(accessibilityTime * 1.5); + if (hsv.x < 0.055 || hsv.x >= 0.94) { + replacement = vec3(0.93, 0.18, 0.50); // red: pink + } else if (hsv.x < 0.19) { + replacement = vec3(1.00, 0.78, 0.08); // red + green: gold + vec2 cell = mod(floor(screen_coords + vec2(ditherStep, 0.0)), vec2(9.0)); + pattern = length(cell - vec2(4.0)) < 1.6 ? 1.0 : 0.0; // dithered dots + } else if (hsv.x < 0.43) { + replacement = vec3(0.00, 0.72, 0.62); // green: turquoise + } else if (hsv.x < 0.58) { + replacement = vec3(0.12, 0.55, 0.96); // green + blue: sky blue + pattern = mod(floor(screen_coords.x + screen_coords.y + ditherStep), 10.0) < 2.0 ? 1.0 : 0.0; // dithered slashes + } else if (hsv.x < 0.77) { + replacement = vec3(0.38, 0.32, 0.96); // blue: indigo + } else { + replacement = vec3(0.72, 0.25, 0.92); // red + blue: purple + pattern = mod(floor((screen_coords.x + ditherStep) / 4.0) + floor(screen_coords.y / 4.0), 2.0); // dithered checker + } + + // Secondary colours get a gently animated dither cue, so they are + // identifiable even when their hue is hard to perceive. Lighting and + // fades remain. + replacement = mix(replacement, vec3(1.0), pattern * 0.55); + return vec4(replacement * hsv.z, pixel.a); + } +]]