@jjlmoya/utils-games-development 1.61.0 → 1.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/category/index.ts +3 -1
- package/src/entries.ts +5 -1
- package/src/index.ts +2 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/gameUIAccessibilityTester/bibliography.astro +6 -0
- package/src/tool/gameUIAccessibilityTester/bibliography.ts +10 -0
- package/src/tool/gameUIAccessibilityTester/component.astro +210 -0
- package/src/tool/gameUIAccessibilityTester/controller-actions.ts +92 -0
- package/src/tool/gameUIAccessibilityTester/controller.ts +237 -0
- package/src/tool/gameUIAccessibilityTester/dom-views.ts +193 -0
- package/src/tool/gameUIAccessibilityTester/entry.ts +28 -0
- package/src/tool/gameUIAccessibilityTester/evaluator.ts +43 -0
- package/src/tool/gameUIAccessibilityTester/game-ui-accessibility-stress-tester.css +982 -0
- package/src/tool/gameUIAccessibilityTester/i18n/de.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/en.ts +199 -0
- package/src/tool/gameUIAccessibilityTester/i18n/es.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/fr.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/id.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/it.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/ja.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/ko.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/nl.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/pl.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/pt.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/ru.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/sv.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/tr.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/i18n/zh.ts +203 -0
- package/src/tool/gameUIAccessibilityTester/index.ts +11 -0
- package/src/tool/gameUIAccessibilityTester/logic.test.ts +57 -0
- package/src/tool/gameUIAccessibilityTester/logic.ts +157 -0
- package/src/tool/gameUIAccessibilityTester/report.ts +59 -0
- package/src/tool/gameUIAccessibilityTester/seo.astro +15 -0
- package/src/tool/gameUIAccessibilityTester/storage.ts +34 -0
- package/src/tool/gameUIAccessibilityTester/ui.ts +80 -0
- package/src/tool/hitboxHurtboxAnimator/bibliography.astro +12 -0
- package/src/tool/hitboxHurtboxAnimator/bibliography.ts +10 -0
- package/src/tool/hitboxHurtboxAnimator/canvas-interactions.ts +59 -0
- package/src/tool/hitboxHurtboxAnimator/canvas-metrics.ts +8 -0
- package/src/tool/hitboxHurtboxAnimator/component.astro +148 -0
- package/src/tool/hitboxHurtboxAnimator/controller.ts +247 -0
- package/src/tool/hitboxHurtboxAnimator/dom-views.ts +194 -0
- package/src/tool/hitboxHurtboxAnimator/editor-state.ts +19 -0
- package/src/tool/hitboxHurtboxAnimator/entry.ts +28 -0
- package/src/tool/hitboxHurtboxAnimator/evaluator.ts +15 -0
- package/src/tool/hitboxHurtboxAnimator/file-io.ts +82 -0
- package/src/tool/hitboxHurtboxAnimator/hitbox-hurtbox-animator.css +614 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/de.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/en.ts +191 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/es.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/fr.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/id.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/it.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/ja.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/ko.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/nl.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/pl.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/pt.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/ru.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/sv.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/tr.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/i18n/zh.ts +195 -0
- package/src/tool/hitboxHurtboxAnimator/index.ts +11 -0
- package/src/tool/hitboxHurtboxAnimator/logic.test.ts +97 -0
- package/src/tool/hitboxHurtboxAnimator/logic.ts +173 -0
- package/src/tool/hitboxHurtboxAnimator/project-actions.ts +128 -0
- package/src/tool/hitboxHurtboxAnimator/seo.astro +13 -0
- package/src/tool/hitboxHurtboxAnimator/storage.ts +27 -0
- package/src/tool/hitboxHurtboxAnimator/ui.ts +70 -0
- package/src/tools.ts +4 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
colorDistance,
|
|
4
|
+
contrastRatio,
|
|
5
|
+
fromHex,
|
|
6
|
+
relativeLuminance,
|
|
7
|
+
separationRetention,
|
|
8
|
+
simulateColor,
|
|
9
|
+
toHex,
|
|
10
|
+
} from './logic';
|
|
11
|
+
|
|
12
|
+
describe('game UI accessibility stress tester logic', () => {
|
|
13
|
+
it('matches WCAG reference luminance and contrast cases', () => {
|
|
14
|
+
expect(relativeLuminance({ r: 0, g: 0, b: 0 })).toBe(0);
|
|
15
|
+
expect(relativeLuminance({ r: 255, g: 255, b: 255 })).toBe(1);
|
|
16
|
+
expect(contrastRatio({ r: 0, g: 0, b: 0 }, { r: 255, g: 255, b: 255 })).toBe(21);
|
|
17
|
+
expect(contrastRatio({ r: 119, g: 119, b: 119 }, { r: 255, g: 255, b: 255 })).toBeCloseTo(4.478, 2);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('applies documented severity one CVD matrices in linear RGB', () => {
|
|
21
|
+
const red = { r: 255, g: 0, b: 0 };
|
|
22
|
+
expect(simulateColor(red, 'protanopia')).toEqual({ r: 109, g: 95, b: 0 });
|
|
23
|
+
expect(simulateColor(red, 'deuteranopia')).toEqual({ r: 163, g: 144, b: 0 });
|
|
24
|
+
expect(simulateColor(red, 'tritanopia')).toEqual({ r: 255, g: 0, b: 15 });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('preserves black and white through every CVD matrix', () => {
|
|
28
|
+
const modes = ['protanopia', 'deuteranopia', 'tritanopia'] as const;
|
|
29
|
+
modes.forEach((mode) => {
|
|
30
|
+
expect(simulateColor({ r: 0, g: 0, b: 0 }, mode)).toEqual({ r: 0, g: 0, b: 0 });
|
|
31
|
+
expect(simulateColor({ r: 255, g: 255, b: 255 }, mode)).toEqual({ r: 255, g: 255, b: 255 });
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('supports grayscale, desaturation, reduced contrast and original modes', () => {
|
|
36
|
+
expect(simulateColor({ r: 255, g: 0, b: 0 }, 'achromatopsia')).toEqual({ r: 127, g: 127, b: 127 });
|
|
37
|
+
expect(simulateColor({ r: 255, g: 0, b: 0 }, 'desaturation')).toEqual({ r: 159, g: 95, b: 95 });
|
|
38
|
+
expect(simulateColor({ r: 255, g: 0, b: 0 }, 'reduced-contrast')).toEqual({ r: 198, g: 58, b: 58 });
|
|
39
|
+
expect(simulateColor({ r: 300, g: -10, b: Number.NaN }, 'original')).toEqual({ r: 255, g: 0, b: 0 });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('measures separation loss with stable limits', () => {
|
|
43
|
+
const black = { r: 0, g: 0, b: 0 };
|
|
44
|
+
const white = { r: 255, g: 255, b: 255 };
|
|
45
|
+
expect(colorDistance(black, black)).toBe(0);
|
|
46
|
+
expect(separationRetention(black, white, black, white)).toBe(100);
|
|
47
|
+
expect(separationRetention(black, white, black, black)).toBe(0);
|
|
48
|
+
expect(separationRetention(black, black, black, white)).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('formats clamped RGB colors as lowercase hexadecimal', () => {
|
|
52
|
+
expect(toHex({ r: 255, g: 16, b: 0 })).toBe('#ff1000');
|
|
53
|
+
expect(toHex({ r: -1, g: 300, b: Number.NaN })).toBe('#00ff00');
|
|
54
|
+
expect(fromHex('#1aB2c3')).toEqual({ r: 26, g: 178, b: 195 });
|
|
55
|
+
expect(fromHex('invalid')).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
export type SimulationMode =
|
|
2
|
+
| 'original'
|
|
3
|
+
| 'protanopia'
|
|
4
|
+
| 'deuteranopia'
|
|
5
|
+
| 'tritanopia'
|
|
6
|
+
| 'achromatopsia'
|
|
7
|
+
| 'reduced-contrast'
|
|
8
|
+
| 'desaturation';
|
|
9
|
+
|
|
10
|
+
export interface RGBColor {
|
|
11
|
+
r: number;
|
|
12
|
+
g: number;
|
|
13
|
+
b: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Matrix3 = readonly [
|
|
17
|
+
number, number, number,
|
|
18
|
+
number, number, number,
|
|
19
|
+
number, number, number,
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const CVD_MATRICES: Partial<Record<SimulationMode, Matrix3>> = {
|
|
23
|
+
protanopia: [
|
|
24
|
+
0.152286, 1.052583, -0.204868,
|
|
25
|
+
0.114503, 0.786281, 0.099216,
|
|
26
|
+
-0.003882, -0.048116, 1.051998,
|
|
27
|
+
],
|
|
28
|
+
deuteranopia: [
|
|
29
|
+
0.367322, 0.860646, -0.227968,
|
|
30
|
+
0.280085, 0.672501, 0.047413,
|
|
31
|
+
-0.011820, 0.042940, 0.968881,
|
|
32
|
+
],
|
|
33
|
+
tritanopia: [
|
|
34
|
+
1.255528, -0.076749, -0.178779,
|
|
35
|
+
-0.078411, 0.930809, 0.147602,
|
|
36
|
+
0.004733, 0.691367, 0.303900,
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function clamp(value: number, minimum: number, maximum: number): number {
|
|
41
|
+
if (!Number.isFinite(value)) return minimum;
|
|
42
|
+
return Math.min(maximum, Math.max(minimum, value));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeChannel(value: number): number {
|
|
46
|
+
return clamp(value, 0, 255) / 255;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function linearize(value: number): number {
|
|
50
|
+
const channel = normalizeChannel(value);
|
|
51
|
+
return channel <= 0.04045
|
|
52
|
+
? channel / 12.92
|
|
53
|
+
: ((channel + 0.055) / 1.055) ** 2.4;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function encodeLinear(value: number): number {
|
|
57
|
+
const channel = clamp(value, 0, 1);
|
|
58
|
+
const encoded = channel <= 0.0031308
|
|
59
|
+
? 12.92 * channel
|
|
60
|
+
: 1.055 * channel ** (1 / 2.4) - 0.055;
|
|
61
|
+
return Math.round(encoded * 255);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function multiplyMatrix(color: RGBColor, matrix: Matrix3): RGBColor {
|
|
65
|
+
const [red, green, blue] = [linearize(color.r), linearize(color.g), linearize(color.b)];
|
|
66
|
+
return {
|
|
67
|
+
r: encodeLinear(matrix[0] * red + matrix[1] * green + matrix[2] * blue),
|
|
68
|
+
g: encodeLinear(matrix[3] * red + matrix[4] * green + matrix[5] * blue),
|
|
69
|
+
b: encodeLinear(matrix[6] * red + matrix[7] * green + matrix[8] * blue),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function grayscale(color: RGBColor): RGBColor {
|
|
74
|
+
const value = encodeLinear(relativeLuminance(color));
|
|
75
|
+
return { r: value, g: value, b: value };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function reduceContrast(color: RGBColor): RGBColor {
|
|
79
|
+
const midpoint = 128;
|
|
80
|
+
const factor = 0.55;
|
|
81
|
+
return {
|
|
82
|
+
r: Math.round(midpoint + (clamp(color.r, 0, 255) - midpoint) * factor),
|
|
83
|
+
g: Math.round(midpoint + (clamp(color.g, 0, 255) - midpoint) * factor),
|
|
84
|
+
b: Math.round(midpoint + (clamp(color.b, 0, 255) - midpoint) * factor),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function desaturate(color: RGBColor): RGBColor {
|
|
89
|
+
const gray = grayscale(color);
|
|
90
|
+
const saturation = 0.25;
|
|
91
|
+
return {
|
|
92
|
+
r: Math.round(gray.r + (clamp(color.r, 0, 255) - gray.r) * saturation),
|
|
93
|
+
g: Math.round(gray.g + (clamp(color.g, 0, 255) - gray.g) * saturation),
|
|
94
|
+
b: Math.round(gray.b + (clamp(color.b, 0, 255) - gray.b) * saturation),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function simulateColor(color: RGBColor, mode: SimulationMode): RGBColor {
|
|
99
|
+
const matrix = CVD_MATRICES[mode];
|
|
100
|
+
if (matrix) return multiplyMatrix(color, matrix);
|
|
101
|
+
if (mode === 'achromatopsia') return grayscale(color);
|
|
102
|
+
if (mode === 'reduced-contrast') return reduceContrast(color);
|
|
103
|
+
if (mode === 'desaturation') return desaturate(color);
|
|
104
|
+
return {
|
|
105
|
+
r: Math.round(clamp(color.r, 0, 255)),
|
|
106
|
+
g: Math.round(clamp(color.g, 0, 255)),
|
|
107
|
+
b: Math.round(clamp(color.b, 0, 255)),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function relativeLuminance(color: RGBColor): number {
|
|
112
|
+
return 0.2126 * linearize(color.r)
|
|
113
|
+
+ 0.7152 * linearize(color.g)
|
|
114
|
+
+ 0.0722 * linearize(color.b);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function contrastRatio(first: RGBColor, second: RGBColor): number {
|
|
118
|
+
const firstLuminance = relativeLuminance(first);
|
|
119
|
+
const secondLuminance = relativeLuminance(second);
|
|
120
|
+
const lighter = Math.max(firstLuminance, secondLuminance);
|
|
121
|
+
const darker = Math.min(firstLuminance, secondLuminance);
|
|
122
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function colorDistance(first: RGBColor, second: RGBColor): number {
|
|
126
|
+
const red = linearize(first.r) - linearize(second.r);
|
|
127
|
+
const green = linearize(first.g) - linearize(second.g);
|
|
128
|
+
const blue = linearize(first.b) - linearize(second.b);
|
|
129
|
+
return Math.sqrt(red ** 2 + green ** 2 + blue ** 2);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function separationRetention(
|
|
133
|
+
originalFirst: RGBColor,
|
|
134
|
+
originalSecond: RGBColor,
|
|
135
|
+
simulatedFirst: RGBColor,
|
|
136
|
+
simulatedSecond: RGBColor,
|
|
137
|
+
): number {
|
|
138
|
+
const original = colorDistance(originalFirst, originalSecond);
|
|
139
|
+
if (original === 0) return 0;
|
|
140
|
+
const simulated = colorDistance(simulatedFirst, simulatedSecond);
|
|
141
|
+
return clamp((simulated / original) * 100, 0, 100);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function toHex(color: RGBColor): string {
|
|
145
|
+
const channel = (value: number) => Math.round(clamp(value, 0, 255)).toString(16).padStart(2, '0');
|
|
146
|
+
return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function fromHex(value: string): RGBColor | null {
|
|
150
|
+
const match = value.trim().match(/^#([\da-f]{6})$/i)?.[1];
|
|
151
|
+
if (!match) return null;
|
|
152
|
+
return {
|
|
153
|
+
r: Number.parseInt(match.slice(0, 2), 16),
|
|
154
|
+
g: Number.parseInt(match.slice(2, 4), 16),
|
|
155
|
+
b: Number.parseInt(match.slice(4, 6), 16),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { PairDiagnostic } from './evaluator';
|
|
2
|
+
import { toHex, type RGBColor, type SimulationMode } from './logic';
|
|
3
|
+
import { triggerDownload } from './dom-views';
|
|
4
|
+
import type { GameUiAccessibilityTesterUI } from './ui';
|
|
5
|
+
|
|
6
|
+
export interface ReportInput {
|
|
7
|
+
mode: SimulationMode;
|
|
8
|
+
blur: number;
|
|
9
|
+
downscale: number;
|
|
10
|
+
firstName: string;
|
|
11
|
+
secondName: string;
|
|
12
|
+
first: RGBColor;
|
|
13
|
+
second: RGBColor;
|
|
14
|
+
diagnostic: PairDiagnostic;
|
|
15
|
+
findings: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function reportFindings(input: ReportInput, ui: GameUiAccessibilityTesterUI): string[] {
|
|
19
|
+
const findings = [...input.findings];
|
|
20
|
+
if (input.diagnostic.status === 'review') findings.unshift(ui.reportFindingReview);
|
|
21
|
+
return findings;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createReport(input: ReportInput, ui: GameUiAccessibilityTesterUI): Record<string, unknown> {
|
|
25
|
+
return {
|
|
26
|
+
title: ui.reportTitle,
|
|
27
|
+
generatedAt: new Date().toISOString(),
|
|
28
|
+
scope: 'manual-review-aid',
|
|
29
|
+
processing: 'local-browser-only',
|
|
30
|
+
settings: {
|
|
31
|
+
simulation: input.mode,
|
|
32
|
+
blurPixels: input.blur,
|
|
33
|
+
downscale: input.downscale,
|
|
34
|
+
},
|
|
35
|
+
samples: [
|
|
36
|
+
{ name: input.firstName, color: toHex(input.first) },
|
|
37
|
+
{ name: input.secondName, color: toHex(input.second) },
|
|
38
|
+
],
|
|
39
|
+
measurements: {
|
|
40
|
+
originalContrastRatio: Number(input.diagnostic.originalContrast.toFixed(3)),
|
|
41
|
+
simulatedContrastRatio: Number(input.diagnostic.simulatedContrast.toFixed(3)),
|
|
42
|
+
},
|
|
43
|
+
heuristic: {
|
|
44
|
+
separationRetainedPercent: Number(input.diagnostic.retained.toFixed(1)),
|
|
45
|
+
reviewStatus: input.diagnostic.status,
|
|
46
|
+
},
|
|
47
|
+
findings: reportFindings(input, ui),
|
|
48
|
+
limitations: ui.limitationDisclosure,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function downloadStructuredReport(
|
|
53
|
+
input: ReportInput,
|
|
54
|
+
ui: GameUiAccessibilityTesterUI,
|
|
55
|
+
): void {
|
|
56
|
+
const report = createReport(input, ui);
|
|
57
|
+
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
|
|
58
|
+
triggerDownload(blob, 'game-ui-accessibility-review.json');
|
|
59
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { gameUiAccessibilityTester } from './entry';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props;
|
|
11
|
+
const loader = gameUiAccessibilityTester.i18n[locale] ?? gameUiAccessibilityTester.i18n.en;
|
|
12
|
+
const content = loader ? await loader() : null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{content && content.seo.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { SimulationMode } from './logic';
|
|
2
|
+
|
|
3
|
+
export interface StoredSettings {
|
|
4
|
+
mode: SimulationMode;
|
|
5
|
+
compare: 'side' | 'split' | 'press';
|
|
6
|
+
blur: number;
|
|
7
|
+
downscale: number;
|
|
8
|
+
zoom: number;
|
|
9
|
+
heatmap: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const STORAGE_KEY = 'game-ui-accessibility-stress-tester-settings';
|
|
13
|
+
|
|
14
|
+
export function loadSettings(fallback: StoredSettings): StoredSettings {
|
|
15
|
+
try {
|
|
16
|
+
const value = localStorage.getItem(STORAGE_KEY);
|
|
17
|
+
if (!value) return fallback;
|
|
18
|
+
return { ...fallback, ...JSON.parse(value) as Partial<StoredSettings> };
|
|
19
|
+
} catch {
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function saveSettings(settings: StoredSettings): void {
|
|
25
|
+
try {
|
|
26
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function clearSettings(): void {
|
|
31
|
+
try {
|
|
32
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export interface GameUiAccessibilityTesterUI extends Record<string, string> {
|
|
2
|
+
onboarding: string;
|
|
3
|
+
privacyNote: string;
|
|
4
|
+
dropTitle: string;
|
|
5
|
+
dropHint: string;
|
|
6
|
+
chooseImage: string;
|
|
7
|
+
replaceImage: string;
|
|
8
|
+
supportedFiles: string;
|
|
9
|
+
lensLabel: string;
|
|
10
|
+
lensOriginal: string;
|
|
11
|
+
lensProtanopia: string;
|
|
12
|
+
lensDeuteranopia: string;
|
|
13
|
+
lensTritanopia: string;
|
|
14
|
+
lensAchromatopsia: string;
|
|
15
|
+
lensReducedContrast: string;
|
|
16
|
+
lensDesaturation: string;
|
|
17
|
+
compareLabel: string;
|
|
18
|
+
compareSideBySide: string;
|
|
19
|
+
compareSplit: string;
|
|
20
|
+
comparePress: string;
|
|
21
|
+
holdOriginal: string;
|
|
22
|
+
splitPosition: string;
|
|
23
|
+
stressLabel: string;
|
|
24
|
+
blurLabel: string;
|
|
25
|
+
downscaleLabel: string;
|
|
26
|
+
downscaleFull: string;
|
|
27
|
+
downscaleHalf: string;
|
|
28
|
+
downscaleQuarter: string;
|
|
29
|
+
downscaleEighth: string;
|
|
30
|
+
zoomLabel: string;
|
|
31
|
+
heatmapLabel: string;
|
|
32
|
+
heatmapHint: string;
|
|
33
|
+
originalView: string;
|
|
34
|
+
simulatedView: string;
|
|
35
|
+
emptyCanvas: string;
|
|
36
|
+
sampleTitle: string;
|
|
37
|
+
sampleInstructions: string;
|
|
38
|
+
sampleA: string;
|
|
39
|
+
sampleB: string;
|
|
40
|
+
sampleAName: string;
|
|
41
|
+
sampleBName: string;
|
|
42
|
+
manualColor: string;
|
|
43
|
+
sampleAInitial: string;
|
|
44
|
+
sampleBInitial: string;
|
|
45
|
+
noSample: string;
|
|
46
|
+
originalContrast: string;
|
|
47
|
+
simulatedContrast: string;
|
|
48
|
+
separationRetained: string;
|
|
49
|
+
statusStrong: string;
|
|
50
|
+
statusWatch: string;
|
|
51
|
+
statusReview: string;
|
|
52
|
+
statusPending: string;
|
|
53
|
+
measurementLabel: string;
|
|
54
|
+
heuristicLabel: string;
|
|
55
|
+
manualReviewLabel: string;
|
|
56
|
+
measurementHint: string;
|
|
57
|
+
heuristicHint: string;
|
|
58
|
+
promptTitle: string;
|
|
59
|
+
promptColorOnly: string;
|
|
60
|
+
promptChangingBackground: string;
|
|
61
|
+
promptMinimap: string;
|
|
62
|
+
promptStates: string;
|
|
63
|
+
promptShape: string;
|
|
64
|
+
findingLabel: string;
|
|
65
|
+
findingPlaceholder: string;
|
|
66
|
+
addFinding: string;
|
|
67
|
+
findingsEmpty: string;
|
|
68
|
+
exportSheet: string;
|
|
69
|
+
exportReport: string;
|
|
70
|
+
resetTool: string;
|
|
71
|
+
uploadError: string;
|
|
72
|
+
fileTooLarge: string;
|
|
73
|
+
imageReady: string;
|
|
74
|
+
reportDownloaded: string;
|
|
75
|
+
sheetDownloaded: string;
|
|
76
|
+
localOnlyDisclosure: string;
|
|
77
|
+
limitationDisclosure: string;
|
|
78
|
+
reportTitle: string;
|
|
79
|
+
reportFindingReview: string;
|
|
80
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
+
import type { BibliographyEntry } from '../../types';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
links?: BibliographyEntry[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { links = [] } = Astro.props;
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<SharedBibliography links={links} />
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const bibliographyEntries = [
|
|
2
|
+
{
|
|
3
|
+
name: 'Godot Engine: Rect2 coordinate and size reference',
|
|
4
|
+
url: 'https://docs.godotengine.org/en/stable/classes/class_rect2.html',
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
name: 'Unity Manual: Sprite Editor Custom Physics Shape',
|
|
8
|
+
url: 'https://docs.unity3d.com/es/2021.1/Manual/CustomPhysicsShape.html',
|
|
9
|
+
},
|
|
10
|
+
];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { EditorState } from './editor-state';
|
|
2
|
+
import { renderDraft, renderEditor, setStatus } from './dom-views';
|
|
3
|
+
import { canvasPoint } from './canvas-metrics';
|
|
4
|
+
import { shapeAtPoint } from './logic';
|
|
5
|
+
import { addDrawnShape, checkpoint, moveSelected } from './project-actions';
|
|
6
|
+
|
|
7
|
+
interface PointerSession {
|
|
8
|
+
start: { x: number; y: number };
|
|
9
|
+
last: { x: number; y: number };
|
|
10
|
+
dragging: boolean;
|
|
11
|
+
checkpointed: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function bindCanvas(state: EditorState): void {
|
|
15
|
+
const canvas = state.root.querySelector<HTMLCanvasElement>('[data-role="stage"]');
|
|
16
|
+
if (!canvas) return;
|
|
17
|
+
let session: PointerSession | undefined;
|
|
18
|
+
canvas.addEventListener('pointerdown', (event) => {
|
|
19
|
+
const point = canvasPoint(canvas, event);
|
|
20
|
+
session = { start: point, last: point, dragging: false, checkpointed: false };
|
|
21
|
+
canvas.setPointerCapture(event.pointerId);
|
|
22
|
+
if (state.mode === 'draw') return;
|
|
23
|
+
const frame = state.project.frames[state.currentFrame];
|
|
24
|
+
state.selectedId = frame ? shapeAtPoint(frame.shapes, point.x, point.y)?.id ?? '' : '';
|
|
25
|
+
renderEditor(state);
|
|
26
|
+
});
|
|
27
|
+
canvas.addEventListener('pointermove', (event) => movePointer(state, canvas, event, session));
|
|
28
|
+
canvas.addEventListener('pointerup', (event) => {
|
|
29
|
+
endPointer(state, canvas, event, session);
|
|
30
|
+
session = undefined;
|
|
31
|
+
});
|
|
32
|
+
canvas.addEventListener('pointercancel', () => {
|
|
33
|
+
session = undefined;
|
|
34
|
+
renderEditor(state);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function movePointer(state: EditorState, canvas: HTMLCanvasElement, event: PointerEvent, session?: PointerSession): void {
|
|
39
|
+
if (!session) return;
|
|
40
|
+
const point = canvasPoint(canvas, event);
|
|
41
|
+
session.dragging = Math.abs(point.x - session.start.x) > 0.5 || Math.abs(point.y - session.start.y) > 0.5;
|
|
42
|
+
if (state.mode === 'draw') {
|
|
43
|
+
renderDraft(state, session.start, point);
|
|
44
|
+
} else if (state.selectedId && session.dragging) {
|
|
45
|
+
if (!session.checkpointed) checkpoint(state);
|
|
46
|
+
session.checkpointed = true;
|
|
47
|
+
moveSelected(state, point.x - session.last.x, point.y - session.last.y);
|
|
48
|
+
renderEditor(state);
|
|
49
|
+
}
|
|
50
|
+
session.last = point;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function endPointer(state: EditorState, canvas: HTMLCanvasElement, event: PointerEvent, session?: PointerSession): void {
|
|
54
|
+
if (!session) return;
|
|
55
|
+
const point = canvasPoint(canvas, event);
|
|
56
|
+
if (state.mode === 'draw' && addDrawnShape(state, session.start, point)) setStatus(state, state.ui.statusShapeCreated);
|
|
57
|
+
else if (session.checkpointed) setStatus(state, state.ui.statusShapeUpdated);
|
|
58
|
+
renderEditor(state);
|
|
59
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function canvasPoint(canvas: HTMLCanvasElement, event: PointerEvent): { x: number; y: number } {
|
|
2
|
+
const rect = canvas.getBoundingClientRect();
|
|
3
|
+
const scale = Number(canvas.dataset.scale ?? 1);
|
|
4
|
+
return {
|
|
5
|
+
x: (event.clientX - rect.left) * (canvas.width / rect.width) / scale,
|
|
6
|
+
y: (event.clientY - rect.top) * (canvas.height / rect.height) / scale,
|
|
7
|
+
};
|
|
8
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HitboxHurtboxAnimatorUI } from './ui';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
ui: HitboxHurtboxAnimatorUI;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { ui } = Astro.props;
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<div class="hha" data-hitbox-root tabindex="0">
|
|
12
|
+
<script is:inline type="application/json" data-role="locale-data" set:html={JSON.stringify(ui)}></script>
|
|
13
|
+
|
|
14
|
+
<div class="hha-intro">
|
|
15
|
+
<p>{ui.onboarding}</p>
|
|
16
|
+
<span>{ui.privacyNote}</span>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<section class="hha-load" data-role="dropzone">
|
|
20
|
+
<div>
|
|
21
|
+
<strong>{ui.loadSprite}</strong>
|
|
22
|
+
<p>{ui.loadHint}</p>
|
|
23
|
+
</div>
|
|
24
|
+
<label class="hha-button hha-button-primary">
|
|
25
|
+
{ui.chooseImages}
|
|
26
|
+
<input data-role="sprite-input" type="file" accept="image/png,image/webp" multiple />
|
|
27
|
+
</label>
|
|
28
|
+
</section>
|
|
29
|
+
|
|
30
|
+
<div class="hha-control-deck">
|
|
31
|
+
<fieldset>
|
|
32
|
+
<legend>{ui.slicingTitle}</legend>
|
|
33
|
+
<label>{ui.rowsLabel}<input data-role="rows" type="number" min="1" max="64" value="1" /></label>
|
|
34
|
+
<label>{ui.columnsLabel}<input data-role="columns" type="number" min="1" max="64" value="1" /></label>
|
|
35
|
+
<button type="button" class="hha-button" data-action="slice">{ui.applySlicing}</button>
|
|
36
|
+
</fieldset>
|
|
37
|
+
<fieldset>
|
|
38
|
+
<legend>{ui.playbackTitle}</legend>
|
|
39
|
+
<div class="hha-transport">
|
|
40
|
+
<button type="button" class="hha-icon-button is-previous" data-action="previous" aria-label={ui.previousFrame}><span></span></button>
|
|
41
|
+
<button type="button" class="hha-button hha-play" data-action="play">{ui.play}</button>
|
|
42
|
+
<button type="button" class="hha-icon-button is-next" data-action="next" aria-label={ui.nextFrame}><span></span></button>
|
|
43
|
+
</div>
|
|
44
|
+
<label class="hha-range">{ui.fpsLabel}<input data-preference="fps" type="range" min="1" max="30" value="12" /></label>
|
|
45
|
+
<label class="hha-check"><input data-preference="onionPrevious" type="checkbox" />{ui.onionPrevious}</label>
|
|
46
|
+
<label class="hha-check"><input data-preference="onionNext" type="checkbox" />{ui.onionNext}</label>
|
|
47
|
+
</fieldset>
|
|
48
|
+
<div class="hha-history">
|
|
49
|
+
<button type="button" class="hha-button" data-action="undo">{ui.undo}</button>
|
|
50
|
+
<button type="button" class="hha-button" data-action="redo">{ui.redo}</button>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div class="hha-workbench">
|
|
55
|
+
<aside class="hha-layers">
|
|
56
|
+
<h2>{ui.layerTitle}</h2>
|
|
57
|
+
<div class="hha-mode-switch">
|
|
58
|
+
<button type="button" data-mode="draw">{ui.drawShape}</button>
|
|
59
|
+
<button type="button" data-mode="select">{ui.selectShape}</button>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="hha-type-rack">
|
|
62
|
+
<button type="button" data-type="hitbox"><i></i>{ui.typeHitbox}</button>
|
|
63
|
+
<button type="button" data-type="hurtbox"><i></i>{ui.typeHurtbox}</button>
|
|
64
|
+
<button type="button" data-type="pushbox"><i></i>{ui.typePushbox}</button>
|
|
65
|
+
<button type="button" data-type="grabbox"><i></i>{ui.typeGrabbox}</button>
|
|
66
|
+
<button type="button" data-type="sensor"><i></i>{ui.typeSensor}</button>
|
|
67
|
+
<button type="button" data-type="custom"><i></i>{ui.typeCustom}</button>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="hha-geometry">
|
|
70
|
+
<button type="button" data-geometry="rectangle"><span></span>{ui.shapeRectangle}</button>
|
|
71
|
+
<button type="button" data-geometry="circle"><span></span>{ui.shapeCircle}</button>
|
|
72
|
+
</div>
|
|
73
|
+
</aside>
|
|
74
|
+
|
|
75
|
+
<main class="hha-stage-column">
|
|
76
|
+
<div class="hha-stage-header">
|
|
77
|
+
<span>{ui.stageLabel}</span>
|
|
78
|
+
<div class="hha-badges">
|
|
79
|
+
<b data-role="frames-badge"></b>
|
|
80
|
+
<b data-role="shapes-badge"></b>
|
|
81
|
+
<b data-role="coverage-badge"></b>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="hha-light-table">
|
|
85
|
+
<canvas data-role="stage" aria-label={ui.stageLabel}></canvas>
|
|
86
|
+
<div class="hha-empty-stage">
|
|
87
|
+
<span></span>
|
|
88
|
+
<p>{ui.emptyStage}</p>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="hha-timeline-wrap">
|
|
92
|
+
<strong>{ui.timelineTitle}</strong>
|
|
93
|
+
<div class="hha-timeline" data-role="timeline"></div>
|
|
94
|
+
</div>
|
|
95
|
+
</main>
|
|
96
|
+
|
|
97
|
+
<aside class="hha-inspector">
|
|
98
|
+
<h2>{ui.inspectorTitle}</h2>
|
|
99
|
+
<p class="hha-no-selection" data-role="no-selection">{ui.noSelection}</p>
|
|
100
|
+
<div class="hha-fields" data-role="shape-fields" hidden>
|
|
101
|
+
<label class="hha-wide">{ui.nameLabel}<input data-field="name" type="text" /></label>
|
|
102
|
+
<label>{ui.xLabel}<input data-field="x" type="number" step="0.5" /></label>
|
|
103
|
+
<label>{ui.yLabel}<input data-field="y" type="number" step="0.5" /></label>
|
|
104
|
+
<label>{ui.widthLabel}<input data-field="width" type="number" min="1" step="0.5" /></label>
|
|
105
|
+
<label>{ui.heightLabel}<input data-field="height" type="number" min="1" step="0.5" /></label>
|
|
106
|
+
<label class="hha-wide" data-radius-row>{ui.radiusLabel}<input data-field="radius" type="number" min="0.5" step="0.5" /></label>
|
|
107
|
+
<button type="button" data-action="duplicate">{ui.duplicateShape}</button>
|
|
108
|
+
<button type="button" data-action="mirror">{ui.mirrorShape}</button>
|
|
109
|
+
<button type="button" class="hha-danger hha-wide" data-action="delete">{ui.deleteShape}</button>
|
|
110
|
+
</div>
|
|
111
|
+
<div class="hha-copy-actions">
|
|
112
|
+
<button type="button" data-action="copyPrevious">{ui.copyPrevious}</button>
|
|
113
|
+
<button type="button" data-action="copyAll">{ui.copyAll}</button>
|
|
114
|
+
</div>
|
|
115
|
+
<fieldset class="hha-pivot">
|
|
116
|
+
<legend>{ui.pivotTitle}</legend>
|
|
117
|
+
<label>{ui.pivotXLabel}<input data-field="pivotX" type="number" min="0" step="0.5" /></label>
|
|
118
|
+
<label>{ui.pivotYLabel}<input data-field="pivotY" type="number" min="0" step="0.5" /></label>
|
|
119
|
+
</fieldset>
|
|
120
|
+
<p class="hha-coordinate-note">{ui.coordinatesNote}</p>
|
|
121
|
+
</aside>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<section class="hha-export">
|
|
125
|
+
<div>
|
|
126
|
+
<strong>{ui.exportTitle}</strong>
|
|
127
|
+
<p>{ui.localOnlyDisclosure}</p>
|
|
128
|
+
</div>
|
|
129
|
+
<div>
|
|
130
|
+
<button type="button" class="hha-button hha-button-primary" data-action="exportJson">{ui.exportJson}</button>
|
|
131
|
+
<button type="button" class="hha-button" data-action="importJson">{ui.importJson}</button>
|
|
132
|
+
<button type="button" class="hha-button" data-action="contactSheet">{ui.exportContactSheet}</button>
|
|
133
|
+
<button type="button" class="hha-button hha-danger" data-action="reset">{ui.resetProject}</button>
|
|
134
|
+
<input data-role="project-input" type="file" accept="application/json,.json" aria-label={ui.importJson} />
|
|
135
|
+
</div>
|
|
136
|
+
</section>
|
|
137
|
+
|
|
138
|
+
<div class="hha-footer-line">
|
|
139
|
+
<p data-role="status" aria-live="polite"></p>
|
|
140
|
+
<p>{ui.limitationDisclosure}</p>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<script>
|
|
145
|
+
import { mountHitboxHurtboxAnimator } from './controller';
|
|
146
|
+
|
|
147
|
+
document.querySelectorAll<HTMLElement>('[data-hitbox-root]').forEach(mountHitboxHurtboxAnimator);
|
|
148
|
+
</script>
|