@jjlmoya/utils-games-development 1.55.0 → 1.57.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/entries.ts +5 -1
- package/src/index.ts +1 -0
- package/src/tests/contrast_legibility.test.ts +56 -10
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/runtime_registry.test.ts +27 -0
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/localizationSanitizer/localization-sanitizer.css +4 -2
- package/src/tool/pixelArtPaletteSwapper/app-client.ts +234 -0
- package/src/tool/pixelArtPaletteSwapper/bibliography.astro +6 -0
- package/src/tool/pixelArtPaletteSwapper/bibliography.ts +16 -0
- package/src/tool/pixelArtPaletteSwapper/component.astro +153 -0
- package/src/tool/pixelArtPaletteSwapper/entry.ts +28 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/de.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/en.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/es.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/fr.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/id.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/it.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/ja.ts +225 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/ko.ts +225 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/nl.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/pl.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/pt.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/ru.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/sv.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/tr.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/zh.ts +225 -0
- package/src/tool/pixelArtPaletteSwapper/index.ts +12 -0
- package/src/tool/pixelArtPaletteSwapper/logic.test.ts +43 -0
- package/src/tool/pixelArtPaletteSwapper/logic.ts +201 -0
- package/src/tool/pixelArtPaletteSwapper/pixel-art-palette-swapper.css +728 -0
- package/src/tool/pixelArtPaletteSwapper/seo.astro +15 -0
- package/src/tool/pixelArtPaletteSwapper/ui.ts +35 -0
- package/src/tool/retroSfxGenerator/client.ts +9 -2
- package/src/tool/retroSfxGenerator/logic.test.ts +21 -0
- package/src/tool/retroSfxGenerator/logic.ts +1 -1
- package/src/tool/retroSfxGenerator/retro-sfx-generator.css +10 -4
- package/src/tools.ts +2 -0
package/package.json
CHANGED
package/src/entries.ts
CHANGED
|
@@ -14,6 +14,8 @@ export { steamBbcodeTranslator } from './tool/steamBbcodeTranslator/entry';
|
|
|
14
14
|
export type { SteamBbcodeTranslatorUI, SteamBbcodeTranslatorLocaleContent } from './tool/steamBbcodeTranslator/entry';
|
|
15
15
|
export { retroSfxGenerator } from './tool/retroSfxGenerator/entry';
|
|
16
16
|
export type { RetroSfxGeneratorUI, RetroSfxGeneratorLocaleContent } from './tool/retroSfxGenerator/entry';
|
|
17
|
+
export { pixelArtPaletteSwapper } from './tool/pixelArtPaletteSwapper/entry';
|
|
18
|
+
export type { PixelArtPaletteSwapperUI, PixelArtPaletteSwapperLocaleContent } from './tool/pixelArtPaletteSwapper/entry';
|
|
17
19
|
export { gamesCategory } from './category';
|
|
18
20
|
import { steamCapsuleGenerator } from './tool/steamCapsuleGenerator/entry';
|
|
19
21
|
import { itchioGameTester } from './tool/itchioGameTester/entry';
|
|
@@ -21,6 +23,8 @@ import { spriteSheetPacker } from './tool/spriteSheetPacker/entry';
|
|
|
21
23
|
import { audioLoopPointFinder } from './tool/audioLoopPointFinder/entry';
|
|
22
24
|
import { saveFileEditor } from './tool/saveFileEditor/entry';
|
|
23
25
|
import { localizationSanitizer } from './tool/localizationSanitizer/entry';
|
|
26
|
+
import { steamBbcodeTranslator } from './tool/steamBbcodeTranslator/entry';
|
|
24
27
|
import { retroSfxGenerator } from './tool/retroSfxGenerator/entry';
|
|
28
|
+
import { pixelArtPaletteSwapper } from './tool/pixelArtPaletteSwapper/entry';
|
|
25
29
|
|
|
26
|
-
export const ALL_ENTRIES = [steamCapsuleGenerator, itchioGameTester, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, localizationSanitizer, retroSfxGenerator];
|
|
30
|
+
export const ALL_ENTRIES = [steamCapsuleGenerator, itchioGameTester, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, localizationSanitizer, steamBbcodeTranslator, retroSfxGenerator, pixelArtPaletteSwapper];
|
package/src/index.ts
CHANGED
|
@@ -19,5 +19,6 @@ export { ALL_ENTRIES, ALL_TOOLS } from './tools';
|
|
|
19
19
|
export { STEAM_CAPSULE_GENERATOR_TOOL, steamCapsuleGenerator } from './tool/steamCapsuleGenerator';
|
|
20
20
|
export { STEAM_BBCODE_TRANSLATOR_TOOL, steamBbcodeTranslator } from './tool/steamBbcodeTranslator';
|
|
21
21
|
export { RETRO_SFX_GENERATOR_TOOL, retroSfxGenerator } from './tool/retroSfxGenerator';
|
|
22
|
+
export { PIXEL_ART_PALETTE_SWAPPER_TOOL, pixelArtPaletteSwapper } from './tool/pixelArtPaletteSwapper';
|
|
22
23
|
|
|
23
24
|
export type { ToolLocaleContent as GamesToolLocaleContent } from './types';
|
|
@@ -5,10 +5,13 @@ import { join, relative } from 'node:path';
|
|
|
5
5
|
type Color = { r: number; g: number; b: number; a: number };
|
|
6
6
|
type Declaration = { name: string; value: string };
|
|
7
7
|
type CssRule = { selector: string; declarations: Declaration[]; line: number };
|
|
8
|
+
type Theme = 'light' | 'dark';
|
|
9
|
+
type Variables = Record<string, string>;
|
|
8
10
|
|
|
9
11
|
const toolRoot = join(process.cwd(), 'src', 'tool');
|
|
10
12
|
const minimumTextContrast = 4.5;
|
|
11
13
|
const minimumReviewFontSizeRem = 0.75;
|
|
14
|
+
const themes: Theme[] = ['light', 'dark'];
|
|
12
15
|
|
|
13
16
|
function findFiles(directory: string, extensions: string[]): string[] {
|
|
14
17
|
const files: string[] = [];
|
|
@@ -184,14 +187,48 @@ function simpleBackground(value: string): Color | null {
|
|
|
184
187
|
return parseColor(value);
|
|
185
188
|
}
|
|
186
189
|
|
|
187
|
-
function
|
|
190
|
+
function isThemeSelector(selector: string, theme: Theme): boolean {
|
|
191
|
+
return selector.includes(`.theme-${theme}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isActiveRule(selector: string, theme: Theme): boolean {
|
|
195
|
+
const otherTheme: Theme = theme === 'dark' ? 'light' : 'dark';
|
|
196
|
+
return !isThemeSelector(selector, otherTheme);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function addVariables(target: Variables, rule: CssRule): void {
|
|
200
|
+
rule.declarations
|
|
201
|
+
.filter((declaration) => declaration.name.startsWith('--'))
|
|
202
|
+
.forEach((declaration) => { target[declaration.name] = declaration.value; });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function collectVariables(rules: CssRule[], theme: Theme): Variables {
|
|
206
|
+
const variables: Variables = {};
|
|
207
|
+
rules.filter((rule) => !isThemeSelector(rule.selector, 'dark') && !isThemeSelector(rule.selector, 'light'))
|
|
208
|
+
.forEach((rule) => addVariables(variables, rule));
|
|
209
|
+
rules.filter((rule) => isThemeSelector(rule.selector, theme))
|
|
210
|
+
.forEach((rule) => addVariables(variables, rule));
|
|
211
|
+
return variables;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function resolveCssValue(value: string, variables: Variables): string {
|
|
215
|
+
let resolved = value;
|
|
216
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
217
|
+
const next = resolved.replace(/var\(\s*(--[\w-]+)(?:\s*,\s*([^()]+))?\s*\)/g, (_, name, fallback) => variables[name] ?? fallback ?? '');
|
|
218
|
+
if (next === resolved) return resolved;
|
|
219
|
+
resolved = next;
|
|
220
|
+
}
|
|
221
|
+
return resolved;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function contrastFinding(path: string, rule: CssRule, theme: Theme, variables: Variables): string | null {
|
|
188
225
|
const foreground = lastDeclaration(rule.declarations, ['color']);
|
|
189
226
|
const background = lastDeclaration(rule.declarations, ['background-color', 'background']);
|
|
190
|
-
const foregroundColor = foreground ? parseColor(foreground.value) : null;
|
|
191
|
-
const backgroundColor = background ? simpleBackground(background.value) : null;
|
|
227
|
+
const foregroundColor = foreground ? parseColor(resolveCssValue(foreground.value, variables)) : null;
|
|
228
|
+
const backgroundColor = background ? simpleBackground(resolveCssValue(background.value, variables)) : null;
|
|
192
229
|
const ratio = foregroundColor && backgroundColor ? contrastRatio(foregroundColor, backgroundColor) : null;
|
|
193
230
|
return ratio !== null && ratio < minimumTextContrast
|
|
194
|
-
? `[A11Y-06] ${relativePath(path)}:${rule.line} ${rule.selector} — ${ratio.toFixed(2)}:1 `
|
|
231
|
+
? `[A11Y-06][${theme}] ${relativePath(path)}:${rule.line} ${rule.selector} — ${ratio.toFixed(2)}:1 `
|
|
195
232
|
+ `(mínimo ${minimumTextContrast}:1)`
|
|
196
233
|
: null;
|
|
197
234
|
}
|
|
@@ -205,18 +242,27 @@ function fontSizeFinding(path: string, rule: CssRule): string | null {
|
|
|
205
242
|
+ `(revisar por debajo de ${minimumReviewFontSizeRem}rem)`;
|
|
206
243
|
}
|
|
207
244
|
|
|
208
|
-
function
|
|
209
|
-
const
|
|
210
|
-
const
|
|
245
|
+
function inspectSource(path: string, source: string): { contrastFailures: string[]; fontSizeCandidates: string[] } {
|
|
246
|
+
const rules = parseRules(source);
|
|
247
|
+
const contrastFailures = themes.flatMap((theme) => {
|
|
248
|
+
const variables = collectVariables(rules, theme);
|
|
249
|
+
return rules
|
|
250
|
+
.filter((rule) => isActiveRule(rule.selector, theme))
|
|
251
|
+
.map((rule) => contrastFinding(path, rule, theme, variables))
|
|
252
|
+
.filter((finding): finding is string => finding !== null);
|
|
253
|
+
});
|
|
254
|
+
const fontSizeCandidates = rules
|
|
255
|
+
.map((rule) => fontSizeFinding(path, rule))
|
|
256
|
+
.filter((finding): finding is string => finding !== null);
|
|
211
257
|
return {
|
|
212
|
-
contrastFailures
|
|
213
|
-
fontSizeCandidates
|
|
258
|
+
contrastFailures,
|
|
259
|
+
fontSizeCandidates,
|
|
214
260
|
};
|
|
215
261
|
}
|
|
216
262
|
|
|
217
263
|
function inspectStyles(): { contrastFailures: string[]; fontSizeCandidates: string[] } {
|
|
218
264
|
const findings = findFiles(toolRoot, ['.css', '.astro'])
|
|
219
|
-
.flatMap((path) => cssSources(path).
|
|
265
|
+
.flatMap((path) => cssSources(path).map((source) => inspectSource(path, source)));
|
|
220
266
|
return {
|
|
221
267
|
contrastFailures: findings.flatMap((finding) => finding.contrastFailures),
|
|
222
268
|
fontSizeCandidates: findings.flatMap((finding) => finding.fontSizeCandidates),
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import { ALL_TOOLS } from '../tools';
|
|
3
3
|
|
|
4
4
|
describe('Locale Completeness Validation', () => {
|
|
5
|
-
it('
|
|
6
|
-
expect(ALL_TOOLS.length).toBe(
|
|
5
|
+
it('nine tools are registered', () => {
|
|
6
|
+
expect(ALL_TOOLS.length).toBe(9);
|
|
7
7
|
});
|
|
8
8
|
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { ALL_ENTRIES } from '../entries';
|
|
5
|
+
import { ALL_TOOLS } from '../tools';
|
|
6
|
+
|
|
7
|
+
describe('runtime registry', () => {
|
|
8
|
+
it('keeps every runtime tool represented in the data registry', () => {
|
|
9
|
+
const toolIds = ALL_TOOLS.map(({ entry }) => entry.id).sort();
|
|
10
|
+
const entryIds = ALL_ENTRIES.map(({ id }) => id).sort();
|
|
11
|
+
|
|
12
|
+
expect(entryIds).toEqual(toolIds);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('keeps every runtime directory represented in the data registry', () => {
|
|
16
|
+
const runtimeIds = readdirSync(join(process.cwd(), 'src', 'tool'), { withFileTypes: true })
|
|
17
|
+
.filter((directory) => directory.isDirectory())
|
|
18
|
+
.map((directory) => join(process.cwd(), 'src', 'tool', directory.name))
|
|
19
|
+
.filter((directory) => existsSync(join(directory, 'index.ts')) && existsSync(join(directory, 'entry.ts')))
|
|
20
|
+
.map((directory) => readFileSync(join(directory, 'entry.ts'), 'utf8').match(/\bid\s*:\s*['"]([^'"]+)['"]/)?.[1])
|
|
21
|
+
.filter((id): id is string => Boolean(id))
|
|
22
|
+
.sort();
|
|
23
|
+
|
|
24
|
+
const entryIds = ALL_ENTRIES.map(({ id }) => id).sort();
|
|
25
|
+
expect(entryIds).toEqual(runtimeIds);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -4,8 +4,8 @@ import { gamesCategory } from '../data';
|
|
|
4
4
|
|
|
5
5
|
describe('Tool Validation Suite', () => {
|
|
6
6
|
describe('Library Registration', () => {
|
|
7
|
-
it('should have
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
7
|
+
it('should have 9 tools in ALL_TOOLS', () => {
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(9);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('gamesCategory should be defined', () => {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
--ls-panel-soft: #f4f7fa;
|
|
7
7
|
--ls-accent: #e56a47;
|
|
8
8
|
--ls-accent-dark: #bc4d30;
|
|
9
|
+
--ls-on-accent: #17202f;
|
|
9
10
|
--ls-cyan: #19a6a0;
|
|
10
11
|
--ls-yellow: #e7a633;
|
|
11
12
|
--ls-red: #ce4f61;
|
|
@@ -24,6 +25,7 @@
|
|
|
24
25
|
--ls-panel-soft: #172232;
|
|
25
26
|
--ls-accent: #f1845d;
|
|
26
27
|
--ls-accent-dark: #da6744;
|
|
28
|
+
--ls-on-accent: #111926;
|
|
27
29
|
--ls-cyan: #42c3b8;
|
|
28
30
|
--ls-yellow: #efbd57;
|
|
29
31
|
--ls-red: #f07889;
|
|
@@ -170,7 +172,7 @@
|
|
|
170
172
|
|
|
171
173
|
.ls-drop-mark span:last-child {
|
|
172
174
|
background: var(--ls-accent);
|
|
173
|
-
color: var(--ls-
|
|
175
|
+
color: var(--ls-on-accent);
|
|
174
176
|
transform: translateX(0.4rem);
|
|
175
177
|
}
|
|
176
178
|
|
|
@@ -219,7 +221,7 @@
|
|
|
219
221
|
|
|
220
222
|
.ls-button-primary {
|
|
221
223
|
background: var(--ls-accent);
|
|
222
|
-
color: var(--ls-
|
|
224
|
+
color: var(--ls-on-accent);
|
|
223
225
|
}
|
|
224
226
|
|
|
225
227
|
.ls-button-primary:hover:not(:disabled) {
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PRESET_PALETTES,
|
|
3
|
+
parsePaletteInput,
|
|
4
|
+
quantizeImageData,
|
|
5
|
+
type PaletteColor,
|
|
6
|
+
} from './logic';
|
|
7
|
+
|
|
8
|
+
interface SourceImageState {
|
|
9
|
+
name: string;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
data: Uint8ClampedArray;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface AppEls {
|
|
16
|
+
root: HTMLElement;
|
|
17
|
+
fileInput: HTMLInputElement;
|
|
18
|
+
chooseButton: HTMLButtonElement;
|
|
19
|
+
dropZone: HTMLElement;
|
|
20
|
+
sourceCanvas: HTMLCanvasElement;
|
|
21
|
+
resultCanvas: HTMLCanvasElement;
|
|
22
|
+
emptyState: HTMLElement;
|
|
23
|
+
canvasPair: HTMLElement;
|
|
24
|
+
customPaletteInput: HTMLTextAreaElement;
|
|
25
|
+
downloadButton: HTMLButtonElement;
|
|
26
|
+
zoomInput: HTMLInputElement;
|
|
27
|
+
zoomValue: HTMLOutputElement;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let initialized = false;
|
|
31
|
+
|
|
32
|
+
function getEl<T extends HTMLElement>(root: HTMLElement, sel: string): T | null {
|
|
33
|
+
return root.querySelector<T>(sel);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function queryAppElements(root: HTMLElement): AppEls | null {
|
|
37
|
+
const req = [
|
|
38
|
+
'#pas-file-input', '#pas-choose-image', '#pas-drop-zone',
|
|
39
|
+
'#pas-source-canvas', '#pas-result-canvas', '#pas-empty-state',
|
|
40
|
+
'#pas-canvas-pair', '#pas-custom-palette', '#pas-download',
|
|
41
|
+
'#pas-zoom', '#pas-zoom-value',
|
|
42
|
+
];
|
|
43
|
+
const found = req.map((s) => root.querySelector(s));
|
|
44
|
+
if (found.some((el) => !el)) return null;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
root,
|
|
48
|
+
fileInput: found[0] as HTMLInputElement,
|
|
49
|
+
chooseButton: found[1] as HTMLButtonElement,
|
|
50
|
+
dropZone: found[2] as HTMLElement,
|
|
51
|
+
sourceCanvas: found[3] as HTMLCanvasElement,
|
|
52
|
+
resultCanvas: found[4] as HTMLCanvasElement,
|
|
53
|
+
emptyState: found[5] as HTMLElement,
|
|
54
|
+
canvasPair: found[6] as HTMLElement,
|
|
55
|
+
customPaletteInput: found[7] as HTMLTextAreaElement,
|
|
56
|
+
downloadButton: found[8] as HTMLButtonElement,
|
|
57
|
+
zoomInput: found[9] as HTMLInputElement,
|
|
58
|
+
zoomValue: found[10] as HTMLOutputElement,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function renderSwatches(root: HTMLElement): void {
|
|
63
|
+
root.querySelectorAll<HTMLElement>('[data-swatch-palette]').forEach((strip) => {
|
|
64
|
+
const id = strip.dataset.swatchPalette;
|
|
65
|
+
const palette = id && id in PRESET_PALETTES
|
|
66
|
+
? PRESET_PALETTES[id as keyof typeof PRESET_PALETTES]
|
|
67
|
+
: [];
|
|
68
|
+
strip.replaceChildren(...palette.map((c) => {
|
|
69
|
+
const s = document.createElement('span');
|
|
70
|
+
s.style.backgroundColor = c.hex;
|
|
71
|
+
return s;
|
|
72
|
+
}));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function fitCanvas(canvas: HTMLCanvasElement, width: number, height: number, zoom: number): void {
|
|
77
|
+
const displayWidth = Math.min(620, Math.max(32, width * zoom));
|
|
78
|
+
const displayHeight = Math.max(32, Math.round(displayWidth * height / width));
|
|
79
|
+
canvas.style.width = `${displayWidth}px`;
|
|
80
|
+
canvas.style.height = `${displayHeight}px`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface CanvasDrawOptions {
|
|
84
|
+
canvas: HTMLCanvasElement;
|
|
85
|
+
width: number;
|
|
86
|
+
height: number;
|
|
87
|
+
data: Uint8ClampedArray;
|
|
88
|
+
zoom: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function drawCanvas(opts: CanvasDrawOptions): void {
|
|
92
|
+
opts.canvas.width = opts.width;
|
|
93
|
+
opts.canvas.height = opts.height;
|
|
94
|
+
const context = opts.canvas.getContext('2d');
|
|
95
|
+
if (!context) return;
|
|
96
|
+
context.imageSmoothingEnabled = false;
|
|
97
|
+
context.putImageData(new ImageData(new Uint8ClampedArray(opts.data), opts.width, opts.height), 0, 0);
|
|
98
|
+
fitCanvas(opts.canvas, opts.width, opts.height, opts.zoom);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function setStatus(root: HTMLElement, message: string, tone = 'idle'): void {
|
|
102
|
+
const statusEl = getEl<HTMLElement>(root, '#pas-status');
|
|
103
|
+
if (!statusEl) return;
|
|
104
|
+
statusEl.textContent = message;
|
|
105
|
+
statusEl.dataset.tone = tone;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function updateSummaryTexts(root: HTMLElement, q: { sourceColors: number; mappedColors: number }, width: number, height: number): void {
|
|
109
|
+
const scc = getEl<HTMLElement>(root, '#pas-source-colors');
|
|
110
|
+
const mcc = getEl<HTMLElement>(root, '#pas-mapped-colors');
|
|
111
|
+
const isz = getEl<HTMLElement>(root, '#pas-image-size');
|
|
112
|
+
const rn = getEl<HTMLElement>(root, '#pas-render-note');
|
|
113
|
+
if (scc) scc.textContent = String(q.sourceColors);
|
|
114
|
+
if (mcc) mcc.textContent = String(q.mappedColors);
|
|
115
|
+
if (isz) isz.textContent = `${width} × ${height}`;
|
|
116
|
+
if (rn) rn.textContent = (root.dataset.mappedSummary ?? '').replace('{source}', String(q.sourceColors)).replace('{mapped}', String(q.mappedColors));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function processImageLoad(els: AppEls, file: File, img: HTMLImageElement): SourceImageState | null {
|
|
120
|
+
const canvas = document.createElement('canvas');
|
|
121
|
+
canvas.width = img.naturalWidth;
|
|
122
|
+
canvas.height = img.naturalHeight;
|
|
123
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
124
|
+
if (!ctx) return null;
|
|
125
|
+
ctx.imageSmoothingEnabled = false;
|
|
126
|
+
ctx.drawImage(img, 0, 0);
|
|
127
|
+
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
|
128
|
+
const fn = getEl<HTMLElement>(els.root, '#pas-file-name');
|
|
129
|
+
if (fn) fn.textContent = file.name;
|
|
130
|
+
getEl<HTMLElement>(els.root, '.pas-command-bar')?.classList.add('has-image');
|
|
131
|
+
els.chooseButton.textContent = els.root.dataset.replaceImage ?? 'Replace image';
|
|
132
|
+
els.emptyState.hidden = true;
|
|
133
|
+
els.canvasPair.hidden = false;
|
|
134
|
+
return { name: file.name, width: canvas.width, height: canvas.height, data };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function setupFileHandler(els: AppEls, onLoaded: (src: SourceImageState) => void): void {
|
|
138
|
+
els.chooseButton.addEventListener('click', () => els.fileInput.click());
|
|
139
|
+
els.dropZone.addEventListener('click', () => els.fileInput.click());
|
|
140
|
+
els.fileInput.addEventListener('change', () => {
|
|
141
|
+
const file = els.fileInput.files?.[0];
|
|
142
|
+
if (!file || !file.type.startsWith('image/')) return;
|
|
143
|
+
const url = URL.createObjectURL(file);
|
|
144
|
+
const img = new Image();
|
|
145
|
+
img.onload = () => {
|
|
146
|
+
const src = processImageLoad(els, file, img);
|
|
147
|
+
if (src) onLoaded(src);
|
|
148
|
+
URL.revokeObjectURL(url);
|
|
149
|
+
};
|
|
150
|
+
img.src = url;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function setupPresetButtons(els: AppEls, onApply: (p: PaletteColor[], id: string) => void): void {
|
|
155
|
+
els.root.querySelectorAll<HTMLButtonElement>('[data-palette-id]').forEach((b) => b.addEventListener('click', () => {
|
|
156
|
+
const id = b.dataset.paletteId ?? 'gameBoy';
|
|
157
|
+
const palette = PRESET_PALETTES[id as keyof typeof PRESET_PALETTES];
|
|
158
|
+
if (!palette) return;
|
|
159
|
+
els.customPaletteInput.value = palette.map((c) => c.hex).join(', ');
|
|
160
|
+
onApply(palette, id);
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function setupCustomAndDownload(els: AppEls, onCustomApply: (hexList: string) => void, onCustomReset: () => void, onDownload: () => void): void {
|
|
165
|
+
getEl<HTMLButtonElement>(els.root, '#pas-apply-custom')?.addEventListener('click', () => onCustomApply(els.customPaletteInput.value));
|
|
166
|
+
getEl<HTMLButtonElement>(els.root, '#pas-reset-custom')?.addEventListener('click', () => onCustomReset());
|
|
167
|
+
els.downloadButton.addEventListener('click', onDownload);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function triggerDownload(canvas: HTMLCanvasElement, name: string, id: string): void {
|
|
171
|
+
canvas.toBlob((blob) => {
|
|
172
|
+
if (!blob) return;
|
|
173
|
+
const a = document.createElement('a');
|
|
174
|
+
a.download = `${name.replace(/\.[^.]+$/, '') || 'pixel-art'}-${id}.png`;
|
|
175
|
+
a.href = URL.createObjectURL(blob);
|
|
176
|
+
a.click();
|
|
177
|
+
}, 'image/png');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function runQuantizeRender(els: AppEls, src: SourceImageState, pal: PaletteColor[]): Uint8ClampedArray {
|
|
181
|
+
setStatus(els.root, els.root.dataset.processing ?? 'Mapping pixels', 'working');
|
|
182
|
+
const alpha = getEl<HTMLInputElement>(els.root, '#pas-preserve-alpha')?.checked ?? true;
|
|
183
|
+
const q = quantizeImageData(src.data, pal, alpha);
|
|
184
|
+
drawCanvas({ canvas: els.resultCanvas, width: src.width, height: src.height, data: q.data, zoom: Number(els.zoomInput.value) });
|
|
185
|
+
const ip = getEl<HTMLElement>(els.root, '#pas-inspector-preview');
|
|
186
|
+
if (ip) ip.style.backgroundImage = `url(${els.resultCanvas.toDataURL('image/png')})`;
|
|
187
|
+
updateSummaryTexts(els.root, q, src.width, src.height);
|
|
188
|
+
setStatus(els.root, els.root.dataset.ready ?? 'Ready', 'ready');
|
|
189
|
+
els.downloadButton.disabled = false;
|
|
190
|
+
return q.data;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function setupCoreAppLogic(els: AppEls): void {
|
|
194
|
+
let activePalette = PRESET_PALETTES.gameBoy;
|
|
195
|
+
let sourceImage: SourceImageState | null = null;
|
|
196
|
+
let resultData: Uint8ClampedArray | null = null;
|
|
197
|
+
let activePaletteId = 'gameBoy';
|
|
198
|
+
|
|
199
|
+
const renderResult = (): void => {
|
|
200
|
+
if (sourceImage) resultData = runQuantizeRender(els, sourceImage, activePalette);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const applyPalette = (palette: PaletteColor[], paletteId: string): void => {
|
|
204
|
+
if (palette.length === 0) return;
|
|
205
|
+
activePalette = palette;
|
|
206
|
+
activePaletteId = paletteId;
|
|
207
|
+
els.root.querySelectorAll<HTMLButtonElement>('[data-palette-id]').forEach((b) => b.classList.toggle('is-selected', b.dataset.paletteId === paletteId));
|
|
208
|
+
renderResult();
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
setupFileHandler(els, (src) => {
|
|
212
|
+
sourceImage = src;
|
|
213
|
+
drawCanvas({ canvas: els.sourceCanvas, width: src.width, height: src.height, data: src.data, zoom: Number(els.zoomInput.value) });
|
|
214
|
+
renderResult();
|
|
215
|
+
});
|
|
216
|
+
setupPresetButtons(els, applyPalette);
|
|
217
|
+
setupCustomAndDownload(els,
|
|
218
|
+
(val) => applyPalette(parsePaletteInput(val), 'custom'),
|
|
219
|
+
() => { els.customPaletteInput.value = PRESET_PALETTES.gameBoy.map((c) => c.hex).join(', '); applyPalette(PRESET_PALETTES.gameBoy, 'gameBoy'); },
|
|
220
|
+
() => { if (sourceImage && resultData) triggerDownload(els.resultCanvas, sourceImage.name, activePaletteId); }
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function initPixelArtPaletteSwapper(): void {
|
|
225
|
+
if (initialized) return;
|
|
226
|
+
initialized = true;
|
|
227
|
+
const root = getEl<HTMLElement>(document.body, '[data-tool="pixel-art-palette-swapper"]');
|
|
228
|
+
if (!root) return;
|
|
229
|
+
const els = queryAppElements(root);
|
|
230
|
+
if (!els) return;
|
|
231
|
+
|
|
232
|
+
setupCoreAppLogic(els);
|
|
233
|
+
renderSwatches(els.root);
|
|
234
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliographyEntries: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'MDN Canvas API',
|
|
6
|
+
url: 'https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'Lospec Palette List',
|
|
10
|
+
url: 'https://lospec.com/palette-list',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'W3C Portable Network Graphics Specification',
|
|
14
|
+
url: 'https://www.w3.org/TR/png-3/',
|
|
15
|
+
},
|
|
16
|
+
];
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { PixelArtPaletteSwapperUI } from './ui';
|
|
3
|
+
import './pixel-art-palette-swapper.css';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
ui: PixelArtPaletteSwapperUI;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { ui } = Astro.props;
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<div
|
|
13
|
+
class="pas-shell"
|
|
14
|
+
data-tool="pixel-art-palette-swapper"
|
|
15
|
+
data-processing={ui.processing}
|
|
16
|
+
data-ready={ui.readyStatus}
|
|
17
|
+
data-invalid-palette={ui.invalidPalette}
|
|
18
|
+
data-invalid-image={ui.invalidImage}
|
|
19
|
+
data-mapped-summary={ui.mappedSummary}
|
|
20
|
+
data-replace-image={ui.replaceImage}
|
|
21
|
+
data-drop-active={ui.dropActive}
|
|
22
|
+
data-upload-title={ui.uploadTitle}
|
|
23
|
+
>
|
|
24
|
+
<div class="pas-command-bar">
|
|
25
|
+
<div class="pas-drop-zone" id="pas-drop-zone" tabindex="0" role="button" aria-label={ui.uploadTitle}>
|
|
26
|
+
<span class="pas-drop-mark" aria-hidden="true">+</span>
|
|
27
|
+
<span class="pas-drop-copy">
|
|
28
|
+
<strong id="pas-drop-title">{ui.uploadTitle}</strong>
|
|
29
|
+
<small id="pas-drop-hint">{ui.uploadHint}</small>
|
|
30
|
+
</span>
|
|
31
|
+
<input id="pas-file-input" class="pas-visually-hidden" type="file" accept="image/png,image/jpeg,image/webp" aria-label={ui.chooseImage} />
|
|
32
|
+
</div>
|
|
33
|
+
<button id="pas-choose-image" class="pas-button pas-button-primary" type="button">{ui.chooseImage}</button>
|
|
34
|
+
<span id="pas-file-name" class="pas-file-name">{ui.waitingForImage}</span>
|
|
35
|
+
<span id="pas-status" class="pas-status" role="status" aria-live="polite">{ui.waitingForImage}</span>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div class="pas-workbench">
|
|
39
|
+
<aside class="pas-panel pas-palette-panel">
|
|
40
|
+
<div class="pas-panel-heading">
|
|
41
|
+
<span class="pas-kicker">01</span>
|
|
42
|
+
<h2>{ui.paletteTitle}</h2>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div class="pas-preset-grid" role="list" aria-label={ui.paletteTitle}>
|
|
46
|
+
<button class="pas-preset is-selected" type="button" data-palette-id="gameBoy" role="listitem">
|
|
47
|
+
<span class="pas-preset-name">{ui.gameBoyPalette}</span>
|
|
48
|
+
<span class="pas-swatch-strip" data-swatch-palette="gameBoy" aria-hidden="true"></span>
|
|
49
|
+
</button>
|
|
50
|
+
<button class="pas-preset" type="button" data-palette-id="nes" role="listitem">
|
|
51
|
+
<span class="pas-preset-name">{ui.nesPalette}</span>
|
|
52
|
+
<span class="pas-swatch-strip" data-swatch-palette="nes" aria-hidden="true"></span>
|
|
53
|
+
</button>
|
|
54
|
+
<button class="pas-preset" type="button" data-palette-id="pico8" role="listitem">
|
|
55
|
+
<span class="pas-preset-name">{ui.pico8Palette}</span>
|
|
56
|
+
<span class="pas-swatch-strip" data-swatch-palette="pico8" aria-hidden="true"></span>
|
|
57
|
+
</button>
|
|
58
|
+
<button class="pas-preset" type="button" data-palette-id="commodore64" role="listitem">
|
|
59
|
+
<span class="pas-preset-name">{ui.commodore64Palette}</span>
|
|
60
|
+
<span class="pas-swatch-strip" data-swatch-palette="commodore64" aria-hidden="true"></span>
|
|
61
|
+
</button>
|
|
62
|
+
<button class="pas-preset" type="button" data-palette-id="dawnBringer16" role="listitem">
|
|
63
|
+
<span class="pas-preset-name">{ui.dawnBringerPalette}</span>
|
|
64
|
+
<span class="pas-swatch-strip" data-swatch-palette="dawnBringer16" aria-hidden="true"></span>
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<div class="pas-custom-block">
|
|
69
|
+
<div class="pas-field-heading">
|
|
70
|
+
<label for="pas-custom-palette">{ui.customPalette}</label>
|
|
71
|
+
<span id="pas-palette-count">4 {ui.paletteCount}</span>
|
|
72
|
+
</div>
|
|
73
|
+
<textarea id="pas-custom-palette" rows="3" spellcheck="false" aria-describedby="pas-custom-hint">#0f380f, #306230, #8bac0f, #9bbc0f</textarea>
|
|
74
|
+
<small id="pas-custom-hint">{ui.customPaletteHint}</small>
|
|
75
|
+
<div class="pas-inline-actions">
|
|
76
|
+
<button id="pas-apply-custom" class="pas-button pas-button-dark" type="button">{ui.applyCustomPalette}</button>
|
|
77
|
+
<button id="pas-reset-custom" class="pas-text-button" type="button">{ui.resetCustomPalette}</button>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<label class="pas-toggle-row" for="pas-preserve-alpha">
|
|
82
|
+
<span>{ui.preserveAlpha}</span>
|
|
83
|
+
<input id="pas-preserve-alpha" type="checkbox" checked />
|
|
84
|
+
<span class="pas-toggle" aria-hidden="true"></span>
|
|
85
|
+
</label>
|
|
86
|
+
</aside>
|
|
87
|
+
|
|
88
|
+
<main class="pas-preview-panel">
|
|
89
|
+
<div class="pas-panel-heading pas-preview-heading">
|
|
90
|
+
<div>
|
|
91
|
+
<span class="pas-kicker">02</span>
|
|
92
|
+
<h2>{ui.sourcePreview} <span aria-hidden="true">→</span> {ui.resultPreview}</h2>
|
|
93
|
+
</div>
|
|
94
|
+
<label class="pas-zoom-control" for="pas-zoom">
|
|
95
|
+
<span>{ui.zoomLabel}</span>
|
|
96
|
+
<input id="pas-zoom" type="range" min="1" max="8" value="3" />
|
|
97
|
+
<output id="pas-zoom-value" for="pas-zoom">3x</output>
|
|
98
|
+
</label>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div class="pas-canvas-stage" id="pas-canvas-stage">
|
|
102
|
+
<div class="pas-empty-state" id="pas-empty-state">
|
|
103
|
+
<div class="pas-empty-pixel" aria-hidden="true"></div>
|
|
104
|
+
<strong>{ui.uploadToPreview}</strong>
|
|
105
|
+
<span>{ui.resultEmpty}</span>
|
|
106
|
+
</div>
|
|
107
|
+
<div class="pas-canvas-pair" id="pas-canvas-pair" hidden>
|
|
108
|
+
<figure class="pas-canvas-card">
|
|
109
|
+
<figcaption><span class="pas-dot pas-dot-source"></span>{ui.sourcePreview}</figcaption>
|
|
110
|
+
<canvas id="pas-source-canvas" width="1" height="1" aria-label={ui.sourcePreview}></canvas>
|
|
111
|
+
</figure>
|
|
112
|
+
<figure class="pas-canvas-card pas-result-card">
|
|
113
|
+
<figcaption><span class="pas-dot pas-dot-result"></span>{ui.resultPreview}</figcaption>
|
|
114
|
+
<canvas id="pas-result-canvas" width="1" height="1" aria-label={ui.resultPreview}></canvas>
|
|
115
|
+
</figure>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div class="pas-preview-footer">
|
|
120
|
+
<span class="pas-shortcut">PNG <span aria-hidden="true">·</span> RGB nearest-color mapping</span>
|
|
121
|
+
<span id="pas-render-note">{ui.waitingForImage}</span>
|
|
122
|
+
</div>
|
|
123
|
+
</main>
|
|
124
|
+
|
|
125
|
+
<aside class="pas-panel pas-inspector-panel">
|
|
126
|
+
<div class="pas-panel-heading">
|
|
127
|
+
<span class="pas-kicker">03</span>
|
|
128
|
+
<h2>{ui.resultPreview}</h2>
|
|
129
|
+
</div>
|
|
130
|
+
<div class="pas-inspector-preview" id="pas-inspector-preview" aria-hidden="true">
|
|
131
|
+
<div class="pas-inspector-pixel-grid"></div>
|
|
132
|
+
</div>
|
|
133
|
+
<div class="pas-stats-list">
|
|
134
|
+
<div><span>{ui.imageSize}</span><strong id="pas-image-size">—</strong></div>
|
|
135
|
+
<div><span>{ui.colorCount}</span><strong id="pas-source-colors">—</strong></div>
|
|
136
|
+
<div><span>{ui.mappedCount}</span><strong id="pas-mapped-colors">—</strong></div>
|
|
137
|
+
<div><span>{ui.paletteCount}</span><strong id="pas-inspector-palette-count">4</strong></div>
|
|
138
|
+
</div>
|
|
139
|
+
<button id="pas-download" class="pas-button pas-download-button" type="button" disabled>{ui.downloadPng}</button>
|
|
140
|
+
<span id="pas-download-hint" class="pas-download-hint">{ui.downloadDisabled}</span>
|
|
141
|
+
</aside>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<script>
|
|
146
|
+
import { initPixelArtPaletteSwapper } from './app-client';
|
|
147
|
+
|
|
148
|
+
if (document.readyState === 'loading') {
|
|
149
|
+
document.addEventListener('DOMContentLoaded', initPixelArtPaletteSwapper, { once: true });
|
|
150
|
+
} else {
|
|
151
|
+
initPixelArtPaletteSwapper();
|
|
152
|
+
}
|
|
153
|
+
</script>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { GamesToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { PixelArtPaletteSwapperUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type { PixelArtPaletteSwapperUI };
|
|
5
|
+
|
|
6
|
+
export type PixelArtPaletteSwapperLocaleContent = ToolLocaleContent<PixelArtPaletteSwapperUI>;
|
|
7
|
+
|
|
8
|
+
export const pixelArtPaletteSwapper: GamesToolEntry<PixelArtPaletteSwapperUI> = {
|
|
9
|
+
id: 'pixel-art-palette-swapper',
|
|
10
|
+
icons: { bg: 'mdi:palette', fg: 'mdi:format-color-fill' },
|
|
11
|
+
i18n: {
|
|
12
|
+
de: () => import('./i18n/de').then((module) => module.content),
|
|
13
|
+
en: () => import('./i18n/en').then((module) => module.content),
|
|
14
|
+
es: () => import('./i18n/es').then((module) => module.content),
|
|
15
|
+
fr: () => import('./i18n/fr').then((module) => module.content),
|
|
16
|
+
id: () => import('./i18n/id').then((module) => module.content),
|
|
17
|
+
it: () => import('./i18n/it').then((module) => module.content),
|
|
18
|
+
ja: () => import('./i18n/ja').then((module) => module.content),
|
|
19
|
+
ko: () => import('./i18n/ko').then((module) => module.content),
|
|
20
|
+
nl: () => import('./i18n/nl').then((module) => module.content),
|
|
21
|
+
pl: () => import('./i18n/pl').then((module) => module.content),
|
|
22
|
+
pt: () => import('./i18n/pt').then((module) => module.content),
|
|
23
|
+
ru: () => import('./i18n/ru').then((module) => module.content),
|
|
24
|
+
sv: () => import('./i18n/sv').then((module) => module.content),
|
|
25
|
+
tr: () => import('./i18n/tr').then((module) => module.content),
|
|
26
|
+
zh: () => import('./i18n/zh').then((module) => module.content),
|
|
27
|
+
},
|
|
28
|
+
};
|