Add color blind accessibility filter

This commit is contained in:
Your Name 2026-08-11 14:08:36 -04:00
parent b42569eb9c
commit fa0bada313
2 changed files with 132 additions and 2 deletions

View file

@ -31,6 +31,7 @@ function love.load()
_initGridVars() _initGridVars()
_initSounds() _initSounds()
_initGlobalColors() _initGlobalColors()
_initAccessibility()
gameWorld = World:new() gameWorld = World:new()
_initLevelEditor() _initLevelEditor()
@ -116,16 +117,42 @@ function love.draw()
love.graphics.pop() love.graphics.pop()
love.graphics.setCanvas() 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.setBlendMode("alpha")
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(finalCanvas, 0, 0) love.graphics.draw(finalCanvas, 0, 0)
love.graphics.draw(sideBarCanvas, sideBarBox.x, sideBarBox.y) love.graphics.draw(sideBarCanvas, sideBarBox.x, sideBarBox.y)
love.graphics.print("Current FPS: "..tostring(love.timer.getFPS( )), 10, 10) love.graphics.print("Current FPS: "..tostring(love.timer.getFPS( )), 10, 10)
Dialogue.draw() 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 end
function love.keypressed(key) 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. -- Talking to a sign captures input: navigate/choose, don't move the player.
if Dialogue.isOpen() then if Dialogue.isOpen() then
Dialogue.keypressed(key) Dialogue.keypressed(key)
@ -237,6 +264,7 @@ end
function _initCanvases() function _initCanvases()
gameCanvas = love.graphics.newCanvas() gameCanvas = love.graphics.newCanvas()
finalCanvas = 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 = love.graphics.newCanvas(math.max(1, sideBarBox.w), math.max(1, sideBarBox.h))
sideBarCanvas:setFilter("nearest", "nearest") -- never average when sampled sideBarCanvas:setFilter("nearest", "nearest") -- never average when sampled
end 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") gColor.red, gColor.green, gColor.blue, gColor.white = Color:new("red"), Color:new("green"), Color:new("blue"), Color:new("white")
end 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() function _initSounds()
staticWave = sfxr.newSound() staticWave = sfxr.newSound()
staticWave.waveform = sfxr.WAVEFORM.NOISE -- was `wavetype = 3`; field renamed in this sfxr staticWave.waveform = sfxr.WAVEFORM.NOISE -- was `wavetype = 3`; field renamed in this sfxr

View file

@ -58,3 +58,74 @@ glow = love.graphics.newShader[[
return pixel_me; return pixel_me;
} }
]] ]]
-- 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);
}
]]