diff --git a/source/Main.hx b/source/Main.hx index d6192f69d09..7e905b3d855 100644 --- a/source/Main.hx +++ b/source/Main.hx @@ -183,6 +183,8 @@ class Main extends Sprite addChild(game); + funkin.util.ColorblindFilter.attach(); + #if FEATURE_DEBUG_FUNCTIONS #if !FLX_NO_DEBUG game.debugger.interaction.addTool(new funkin.util.TrackerToolButtonUtil()); #end funkin.util.macro.ConsoleMacro.init(); diff --git a/source/funkin/Preferences.hx b/source/funkin/Preferences.hx index 901bc835254..e596bfa270d 100644 --- a/source/funkin/Preferences.hx +++ b/source/funkin/Preferences.hx @@ -5,6 +5,7 @@ import funkin.mobile.ui.FunkinHitbox; import funkin.mobile.util.InAppPurchasesUtil; #end import funkin.save.Save; +import funkin.util.ColorblindFilter; import funkin.util.WindowUtil; import funkin.util.HapticUtil.HapticsMode; import funkin.ui.debug.FunkinDebugDisplay.DebugDisplayMode; @@ -270,6 +271,52 @@ class Preferences return value; } + /** + * Color assist filter applied over the game display. + * @default `Off` + */ + public static var colorblindMode(get, set):funkin.util.ColorblindFilter.ColorblindMode; + + static function get_colorblindMode():funkin.util.ColorblindFilter.ColorblindMode + { + return ColorblindFilter.normalizeMode(Save?.instance?.options?.colorblindMode); + } + + static function set_colorblindMode(value:funkin.util.ColorblindFilter.ColorblindMode):funkin.util.ColorblindFilter.ColorblindMode + { + value = ColorblindFilter.normalizeMode(value); + var save:Save = Save.instance; + save.options.colorblindMode = value; + Save.system.flush(); + funkin.util.ColorblindFilter.apply(); + return value; + } + + /** + * How strongly the color assist correction is applied, 1 (light) to 10 (full). + * @default `10` + */ + public static var colorblindStrength(get, set):Int; + + static function get_colorblindStrength():Int + { + var value:Int = Save?.instance?.options?.colorblindStrength ?? 10; + if (value < ColorblindFilter.STRENGTH_MIN) value = ColorblindFilter.STRENGTH_MIN; + if (value > ColorblindFilter.STRENGTH_MAX) value = ColorblindFilter.STRENGTH_MAX; + return value; + } + + static function set_colorblindStrength(value:Int):Int + { + if (value < ColorblindFilter.STRENGTH_MIN) value = ColorblindFilter.STRENGTH_MIN; + if (value > ColorblindFilter.STRENGTH_MAX) value = ColorblindFilter.STRENGTH_MAX; + var save:Save = Save.instance; + save.options.colorblindStrength = value; + Save.system.flush(); + funkin.util.ColorblindFilter.apply(); + return value; + } + /** * If enabled, the game will automatically pause when tabbing out. * Always enabled on mobile. diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx index 9a3ab400376..21aa0291793 100644 --- a/source/funkin/save/Save.hx +++ b/source/funkin/save/Save.hx @@ -107,6 +107,8 @@ class Save implements ConsoleClass subtitles: true, hapticsMode: 'All', hapticsIntensityMultiplier: 1, + colorblindMode: 'Off', + colorblindStrength: 10, autoPause: true, vsyncMode: 'Off', strumlineBackgroundOpacity: 0, @@ -1163,6 +1165,19 @@ typedef SaveDataOptions = */ var hapticsIntensityMultiplier:Float; + /** + * Color assist filter applied over the game display. + * One of 'Off', 'Protan', 'Deutan', 'Tritan'. + * @default `Off` + */ + var ?colorblindMode:String; + + /** + * How strongly the color assist correction is applied, 1 (light) to 10 (full). + * @default `10` + */ + var ?colorblindStrength:Int; + /** * If enabled, the game will automatically pause when tabbing out. * @default `true` diff --git a/source/funkin/ui/options/PreferencesMenu.hx b/source/funkin/ui/options/PreferencesMenu.hx index 43d79aeda42..401299632a1 100644 --- a/source/funkin/ui/options/PreferencesMenu.hx +++ b/source/funkin/ui/options/PreferencesMenu.hx @@ -37,6 +37,7 @@ class PreferencesMenu extends Page var menuCamera:FlxCamera; var hudCamera:FlxCamera; var camFollow:FlxObject; + var colorblindStrengthItem:Null; public function new() { @@ -151,6 +152,28 @@ class PreferencesMenu extends Page { Preferences.flashingLights = value; }, Preferences.flashingLights); + createPrefItemEnum('Color Assist Mode', 'Adjust game colors for a color vision deficiency.', [ + "Off" => funkin.util.ColorblindFilter.ColorblindMode.OFF, + "Protan" => funkin.util.ColorblindFilter.ColorblindMode.PROTAN, + "Deutan" => funkin.util.ColorblindFilter.ColorblindMode.DEUTAN, + "Tritan" => funkin.util.ColorblindFilter.ColorblindMode.TRITAN, + ], function(key:String, value:funkin.util.ColorblindFilter.ColorblindMode):Void + { + Preferences.colorblindMode = value; + syncColorblindStrengthAvailability(); + }, switch (Preferences.colorblindMode) + { + case funkin.util.ColorblindFilter.ColorblindMode.PROTAN: "Protan"; + case funkin.util.ColorblindFilter.ColorblindMode.DEUTAN: "Deutan"; + case funkin.util.ColorblindFilter.ColorblindMode.TRITAN: "Tritan"; + default: "Off"; + }); + colorblindStrengthItem = createPrefItemNumber('Color Assist Strength', 'How strongly the color assist is applied on a scale of 1-10.', + function(value:Float):Void + { + Preferences.colorblindStrength = Std.int(value); + }, null, Preferences.colorblindStrength, funkin.util.ColorblindFilter.STRENGTH_MIN, funkin.util.ColorblindFilter.STRENGTH_MAX, 1, 0); + syncColorblindStrengthAvailability(); createPrefItemCheckbox('Camera Zooms', 'When enabled, the camera bounces during songs.', function(value:Bool):Void { Preferences.zoomCamera = value; @@ -315,13 +338,14 @@ class PreferencesMenu extends Page * @param precision Rounds decimals up to a `precision` amount of digits (ex: 4 -> 0.1234, 2 -> 0.12) */ function createPrefItemNumber(prefName:String, prefDesc:String, onChange:Float->Void, ?valueFormatter:Float->String, defaultValue:Float, min:Float, - max:Float, step:Float = 0.1, precision:Int):Void + max:Float, step:Float = 0.1, precision:Int):NumberPreferenceItem { var item = new NumberPreferenceItem(funkin.ui.FullScreenScaleMode.gameNotchSize.x, (120 * items.length) + 30, prefName, defaultValue, min, max, step, precision, onChange, valueFormatter); items.addItem(prefName, item); preferenceItems.add(item.lefthandText); preferenceDesc.push(prefDesc); + return item; } /** @@ -362,6 +386,22 @@ class PreferencesMenu extends Page preferenceDesc.push(prefDesc); } + function syncColorblindStrengthAvailability():Void + { + if (colorblindStrengthItem == null) return; + + final available:Bool = Preferences.colorblindMode != funkin.util.ColorblindFilter.ColorblindMode.OFF; + colorblindStrengthItem.available = available; + colorblindStrengthItem.lefthandText.alpha = available ? 1.0 : 0.35; + colorblindStrengthItem.atlasText.alpha = available ? colorblindStrengthItem.alpha : 0.35; + + if (!available && items.selectedItem == colorblindStrengthItem) + { + final nextIndex:Int = items.selectedIndex + 1 < items.length ? items.selectedIndex + 1 : items.selectedIndex - 1; + if (nextIndex >= 0) items.selectItem(nextIndex); + } + } + override function exit():Void { camFollow.setPosition(640, 30); diff --git a/source/funkin/ui/transition/stickers/StickerSubState.hx b/source/funkin/ui/transition/stickers/StickerSubState.hx index 5986fb34593..f6965a6b736 100644 --- a/source/funkin/ui/transition/stickers/StickerSubState.hx +++ b/source/funkin/ui/transition/stickers/StickerSubState.hx @@ -16,6 +16,7 @@ import funkin.ui.freeplay.FreeplayState; import funkin.ui.MusicBeatSubState; import funkin.ui.transition.stickers.StickerPack; import funkin.FunkinMemory; +import funkin.util.ColorblindFilter; import funkin.util.DeviceUtil; import funkin.Preferences; @@ -298,6 +299,7 @@ class StickerTransitionSprite extends openfl.display.Sprite grpStickers?.draw(); + ColorblindFilter.applyToCamera(stickersCamera); stickersCamera.render(); } diff --git a/source/funkin/util/ColorblindFilter.hx b/source/funkin/util/ColorblindFilter.hx new file mode 100644 index 00000000000..29b5ff3d2c7 --- /dev/null +++ b/source/funkin/util/ColorblindFilter.hx @@ -0,0 +1,199 @@ +package funkin.util; + +import flixel.FlxG; +import flixel.FlxCamera; +import openfl.filters.BitmapFilter; +import openfl.filters.ColorMatrixFilter; + +enum abstract ColorblindMode(String) from String to String +{ + var OFF = 'Off'; + var PROTAN = 'Protan'; + var DEUTAN = 'Deutan'; + var TRITAN = 'Tritan'; +} + +/** + * Player-aid color filter applied to every camera. + * Pick a deficiency type and tune assist strength (1-10). Off is the no-effect state. + * + * Manually rendered cameras outside `FlxG.cameras` should call `applyToCamera()`. + */ +class ColorblindFilter +{ + // Daltonization-style RGB correction matrices: pre-combined `I + Shift * (I - Sim)` so + // the unperceived axis gets redistributed onto channels the viewer still sees. + static final MATRIX_PROTAN:Array = [ + 1.0000, 0.0000, 0.0000, + -0.2549, 1.2549, 0.0000, + 0.3031, -0.5451, 1.2420 + ]; + + static final MATRIX_DEUTAN:Array = [ + 0.8850, 0.1150, 0.0000, + 0.0000, 1.0000, 0.0000, + -0.4900, 0.1900, 1.3000 + ]; + + static final MATRIX_TRITAN:Array = [ + 1.0500, -0.3825, 0.3325, + 0.0000, 1.2345, -0.2345, + 0.0000, 0.0000, 1.0000 + ]; + + static final IDENTITY_MATRIX:Array = [ + 1.0, 0.0, 0.0, + 0.0, 1.0, 0.0, + 0.0, 0.0, 1.0 + ]; + + public static final STRENGTH_MIN:Int = 1; + public static final STRENGTH_MAX:Int = 10; + + static var attached:Bool = false; + + public static function attach():Void + { + if (attached) return; + attached = true; + FlxG.signals.postStateSwitch.add(reapply); + FlxG.signals.preDraw.add(reapply); + FlxG.cameras.cameraAdded.add((camera:FlxCamera) -> applyToCamera(camera)); + reapply(); + } + + public static function apply():Void + { + reapply(); + } + + public static function normalizeMode(value:Null):ColorblindMode + { + return switch (value) + { + case 'Protan' | 'Protanopia' | 'Protanomaly': + PROTAN; + case 'Deutan' | 'Deuteranopia' | 'Deuteranomaly': + DEUTAN; + case 'Tritan' | 'Tritanopia' | 'Tritanomaly': + TRITAN; + default: + OFF; + }; + } + + static function reapply():Void + { + for (camera in FlxG.cameras.list) + { + applyToCamera(camera); + } + } + + public static function applyToCamera(camera:Null):Void + { + if (camera == null) return; + + var mode:ColorblindMode = funkin.Preferences.colorblindMode; + var strength:Int = funkin.Preferences.colorblindStrength; + final filterActive:Bool = mode != OFF; + final targetMatrix:Null> = filterActive ? buildBlendedMatrix(mode, strength) : null; + + final cameraFilters:Array = camera.filters ?? []; + var hasCurrentFilter:Bool = false; + var hasStaleFilter:Bool = false; + var currentFilterIsLast:Bool = false; + var colorblindFilterCount:Int = 0; + + for (index => filter in cameraFilters) + { + if (!isColorblindFilter(filter)) continue; + + colorblindFilterCount++; + if (targetMatrix != null && matrixEquals(cast(filter, ColorMatrixFilter).matrix, targetMatrix)) + { + hasCurrentFilter = true; + currentFilterIsLast = index == cameraFilters.length - 1; + } + else + { + hasStaleFilter = true; + } + } + + if (!filterActive && colorblindFilterCount == 0) return; + if (filterActive && hasCurrentFilter && currentFilterIsLast && !hasStaleFilter && colorblindFilterCount == 1) + { + if (!camera.filtersEnabled) camera.filtersEnabled = true; + return; + } + + var existing:Array = [for (filter in cameraFilters) if (!isColorblindFilter(filter)) filter]; + + if (filterActive) + { + existing.push(new ColorMatrixFilter(targetMatrix)); + camera.filtersEnabled = true; + } + + if (existing.length == 0 && cameraFilters.length == 0) return; + camera.filters = existing.length > 0 ? existing : null; + } + + static function buildBlendedMatrix(mode:ColorblindMode, strength:Int):Array + { + if (strength < STRENGTH_MIN) strength = STRENGTH_MIN; + if (strength > STRENGTH_MAX) strength = STRENGTH_MAX; + + final correction:Array = rgbMatrixFor(mode); + final t:Float = strength / STRENGTH_MAX; + final inv:Float = 1.0 - t; + final r:Array = []; + for (i in 0...9) r.push(IDENTITY_MATRIX[i] * inv + correction[i] * t); + return [ + r[0], r[1], r[2], 0, 0, + r[3], r[4], r[5], 0, 0, + r[6], r[7], r[8], 0, 0, + 0, 0, 0, 1, 0 + ]; + } + + static function rgbMatrixFor(mode:ColorblindMode):Array + { + return switch (mode) + { + case PROTAN: MATRIX_PROTAN; + case DEUTAN: MATRIX_DEUTAN; + case TRITAN: MATRIX_TRITAN; + default: IDENTITY_MATRIX; + }; + } + + static function isColorblindFilter(filter:BitmapFilter):Bool + { + if (!Std.isOfType(filter, ColorMatrixFilter)) return false; + + final matrix:Array = cast(filter, ColorMatrixFilter).matrix; + for (mode in [PROTAN, DEUTAN, TRITAN]) + { + for (strength in STRENGTH_MIN...(STRENGTH_MAX + 1)) + { + if (matrixEquals(matrix, buildBlendedMatrix(mode, strength))) return true; + } + } + + return false; + } + + static function matrixEquals(a:Array, b:Array):Bool + { + if (a == null || b == null || a.length != b.length) return false; + + for (i in 0...a.length) + { + if (Math.abs(a[i] - b[i]) > 0.0001) return false; + } + + return true; + } +}