Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions source/Main.hx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
47 changes: 47 additions & 0 deletions source/funkin/Preferences.hx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions source/funkin/save/Save.hx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ class Save implements ConsoleClass
subtitles: true,
hapticsMode: 'All',
hapticsIntensityMultiplier: 1,
colorblindMode: 'Off',
colorblindStrength: 10,
autoPause: true,
vsyncMode: 'Off',
strumlineBackgroundOpacity: 0,
Expand Down Expand Up @@ -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`
Expand Down
42 changes: 41 additions & 1 deletion source/funkin/ui/options/PreferencesMenu.hx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class PreferencesMenu extends Page<OptionsState.OptionsMenuPageName>
var menuCamera:FlxCamera;
var hudCamera:FlxCamera;
var camFollow:FlxObject;
var colorblindStrengthItem:Null<NumberPreferenceItem>;

public function new()
{
Expand Down Expand Up @@ -151,6 +152,28 @@ class PreferencesMenu extends Page<OptionsState.OptionsMenuPageName>
{
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;
Expand Down Expand Up @@ -315,13 +338,14 @@ class PreferencesMenu extends Page<OptionsState.OptionsMenuPageName>
* @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;
}

/**
Expand Down Expand Up @@ -362,6 +386,22 @@ class PreferencesMenu extends Page<OptionsState.OptionsMenuPageName>
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);
Expand Down
2 changes: 2 additions & 0 deletions source/funkin/ui/transition/stickers/StickerSubState.hx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -298,6 +299,7 @@ class StickerTransitionSprite extends openfl.display.Sprite

grpStickers?.draw();

ColorblindFilter.applyToCamera(stickersCamera);
stickersCamera.render();
}

Expand Down
199 changes: 199 additions & 0 deletions source/funkin/util/ColorblindFilter.hx
Original file line number Diff line number Diff line change
@@ -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<Float> = [
1.0000, 0.0000, 0.0000,
-0.2549, 1.2549, 0.0000,
0.3031, -0.5451, 1.2420
];

static final MATRIX_DEUTAN:Array<Float> = [
0.8850, 0.1150, 0.0000,
0.0000, 1.0000, 0.0000,
-0.4900, 0.1900, 1.3000
];

static final MATRIX_TRITAN:Array<Float> = [
1.0500, -0.3825, 0.3325,
0.0000, 1.2345, -0.2345,
0.0000, 0.0000, 1.0000
];

static final IDENTITY_MATRIX:Array<Float> = [
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<String>):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<FlxCamera>):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<Array<Float>> = filterActive ? buildBlendedMatrix(mode, strength) : null;

final cameraFilters:Array<BitmapFilter> = 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<BitmapFilter> = [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<Float>
{
if (strength < STRENGTH_MIN) strength = STRENGTH_MIN;
if (strength > STRENGTH_MAX) strength = STRENGTH_MAX;

final correction:Array<Float> = rgbMatrixFor(mode);
final t:Float = strength / STRENGTH_MAX;
final inv:Float = 1.0 - t;
final r:Array<Float> = [];
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<Float>
{
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<Float> = 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<Float>, b:Array<Float>):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;
}
}
Loading