@jjlmoya/utils-games-development 1.54.0 → 1.56.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.
Files changed (65) hide show
  1. package/package.json +1 -1
  2. package/src/entries.ts +7 -1
  3. package/src/index.ts +2 -0
  4. package/src/tests/contrast_legibility.test.ts +291 -0
  5. package/src/tests/locale_completeness.test.ts +2 -2
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/itchioGameTester/itchio-game-tester.css +3 -3
  8. package/src/tool/localizationSanitizer/localization-sanitizer.css +17 -15
  9. package/src/tool/pixelArtPaletteSwapper/app-client.ts +229 -0
  10. package/src/tool/pixelArtPaletteSwapper/bibliography.astro +6 -0
  11. package/src/tool/pixelArtPaletteSwapper/bibliography.ts +16 -0
  12. package/src/tool/pixelArtPaletteSwapper/component.astro +153 -0
  13. package/src/tool/pixelArtPaletteSwapper/entry.ts +28 -0
  14. package/src/tool/pixelArtPaletteSwapper/i18n/de.ts +233 -0
  15. package/src/tool/pixelArtPaletteSwapper/i18n/en.ts +233 -0
  16. package/src/tool/pixelArtPaletteSwapper/i18n/es.ts +233 -0
  17. package/src/tool/pixelArtPaletteSwapper/i18n/fr.ts +233 -0
  18. package/src/tool/pixelArtPaletteSwapper/i18n/id.ts +233 -0
  19. package/src/tool/pixelArtPaletteSwapper/i18n/it.ts +233 -0
  20. package/src/tool/pixelArtPaletteSwapper/i18n/ja.ts +225 -0
  21. package/src/tool/pixelArtPaletteSwapper/i18n/ko.ts +225 -0
  22. package/src/tool/pixelArtPaletteSwapper/i18n/nl.ts +233 -0
  23. package/src/tool/pixelArtPaletteSwapper/i18n/pl.ts +233 -0
  24. package/src/tool/pixelArtPaletteSwapper/i18n/pt.ts +233 -0
  25. package/src/tool/pixelArtPaletteSwapper/i18n/ru.ts +233 -0
  26. package/src/tool/pixelArtPaletteSwapper/i18n/sv.ts +233 -0
  27. package/src/tool/pixelArtPaletteSwapper/i18n/tr.ts +233 -0
  28. package/src/tool/pixelArtPaletteSwapper/i18n/zh.ts +225 -0
  29. package/src/tool/pixelArtPaletteSwapper/index.ts +12 -0
  30. package/src/tool/pixelArtPaletteSwapper/logic.test.ts +43 -0
  31. package/src/tool/pixelArtPaletteSwapper/logic.ts +171 -0
  32. package/src/tool/pixelArtPaletteSwapper/pixel-art-palette-swapper.css +728 -0
  33. package/src/tool/pixelArtPaletteSwapper/seo.astro +15 -0
  34. package/src/tool/pixelArtPaletteSwapper/ui.ts +35 -0
  35. package/src/tool/retroSfxGenerator/bibliography.astro +6 -0
  36. package/src/tool/retroSfxGenerator/bibliography.ts +16 -0
  37. package/src/tool/retroSfxGenerator/client.ts +263 -0
  38. package/src/tool/retroSfxGenerator/component.astro +146 -0
  39. package/src/tool/retroSfxGenerator/entry.ts +27 -0
  40. package/src/tool/retroSfxGenerator/i18n/de.ts +211 -0
  41. package/src/tool/retroSfxGenerator/i18n/en.ts +224 -0
  42. package/src/tool/retroSfxGenerator/i18n/es.ts +211 -0
  43. package/src/tool/retroSfxGenerator/i18n/fr.ts +211 -0
  44. package/src/tool/retroSfxGenerator/i18n/id.ts +211 -0
  45. package/src/tool/retroSfxGenerator/i18n/it.ts +211 -0
  46. package/src/tool/retroSfxGenerator/i18n/ja.ts +211 -0
  47. package/src/tool/retroSfxGenerator/i18n/ko.ts +211 -0
  48. package/src/tool/retroSfxGenerator/i18n/nl.ts +211 -0
  49. package/src/tool/retroSfxGenerator/i18n/pl.ts +211 -0
  50. package/src/tool/retroSfxGenerator/i18n/pt.ts +211 -0
  51. package/src/tool/retroSfxGenerator/i18n/ru.ts +211 -0
  52. package/src/tool/retroSfxGenerator/i18n/sv.ts +211 -0
  53. package/src/tool/retroSfxGenerator/i18n/tr.ts +211 -0
  54. package/src/tool/retroSfxGenerator/i18n/zh.ts +211 -0
  55. package/src/tool/retroSfxGenerator/index.ts +11 -0
  56. package/src/tool/retroSfxGenerator/logic.test.ts +61 -0
  57. package/src/tool/retroSfxGenerator/logic.ts +214 -0
  58. package/src/tool/retroSfxGenerator/retro-sfx-generator.css +435 -0
  59. package/src/tool/retroSfxGenerator/seo.astro +15 -0
  60. package/src/tool/retroSfxGenerator/ui.ts +41 -0
  61. package/src/tool/saveFileEditor/game-save-file-editor.css +5 -5
  62. package/src/tool/spriteSheetPacker/sprite-sheet-packer.css +5 -5
  63. package/src/tool/steamBbcodeTranslator/steam-bbcode-translator.css +8 -8
  64. package/src/tool/steamCapsuleGenerator/steam-capsule-generator.css +6 -6
  65. package/src/tools.ts +4 -0
@@ -0,0 +1,229 @@
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 palette = PRESET_PALETTES[strip.dataset.swatchPalette ?? ''] ?? [];
65
+ strip.replaceChildren(...palette.map((c) => {
66
+ const s = document.createElement('span');
67
+ s.style.backgroundColor = c.hex;
68
+ return s;
69
+ }));
70
+ });
71
+ }
72
+
73
+ function fitCanvas(canvas: HTMLCanvasElement, width: number, height: number, zoom: number): void {
74
+ const displayWidth = Math.min(620, Math.max(32, width * zoom));
75
+ const displayHeight = Math.max(32, Math.round(displayWidth * height / width));
76
+ canvas.style.width = `${displayWidth}px`;
77
+ canvas.style.height = `${displayHeight}px`;
78
+ }
79
+
80
+ interface CanvasDrawOptions {
81
+ canvas: HTMLCanvasElement;
82
+ width: number;
83
+ height: number;
84
+ data: Uint8ClampedArray;
85
+ zoom: number;
86
+ }
87
+
88
+ function drawCanvas(opts: CanvasDrawOptions): void {
89
+ opts.canvas.width = opts.width;
90
+ opts.canvas.height = opts.height;
91
+ const context = opts.canvas.getContext('2d');
92
+ if (!context) return;
93
+ context.imageSmoothingEnabled = false;
94
+ context.putImageData(new ImageData(new Uint8ClampedArray(opts.data), opts.width, opts.height), 0, 0);
95
+ fitCanvas(opts.canvas, opts.width, opts.height, opts.zoom);
96
+ }
97
+
98
+ function setStatus(root: HTMLElement, message: string, tone = 'idle'): void {
99
+ const statusEl = getEl<HTMLElement>(root, '#pas-status');
100
+ if (!statusEl) return;
101
+ statusEl.textContent = message;
102
+ statusEl.dataset.tone = tone;
103
+ }
104
+
105
+ function updateSummaryTexts(root: HTMLElement, q: { sourceColors: number; mappedColors: number }, width: number, height: number): void {
106
+ const scc = getEl<HTMLElement>(root, '#pas-source-colors');
107
+ const mcc = getEl<HTMLElement>(root, '#pas-mapped-colors');
108
+ const isz = getEl<HTMLElement>(root, '#pas-image-size');
109
+ const rn = getEl<HTMLElement>(root, '#pas-render-note');
110
+ if (scc) scc.textContent = String(q.sourceColors);
111
+ if (mcc) mcc.textContent = String(q.mappedColors);
112
+ if (isz) isz.textContent = `${width} × ${height}`;
113
+ if (rn) rn.textContent = (root.dataset.mappedSummary ?? '').replace('{source}', String(q.sourceColors)).replace('{mapped}', String(q.mappedColors));
114
+ }
115
+
116
+ function processImageLoad(els: AppEls, file: File, img: HTMLImageElement): SourceImageState | null {
117
+ const canvas = document.createElement('canvas');
118
+ canvas.width = img.naturalWidth;
119
+ canvas.height = img.naturalHeight;
120
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
121
+ if (!ctx) return null;
122
+ ctx.imageSmoothingEnabled = false;
123
+ ctx.drawImage(img, 0, 0);
124
+ const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
125
+ const fn = getEl<HTMLElement>(els.root, '#pas-file-name');
126
+ if (fn) fn.textContent = file.name;
127
+ getEl<HTMLElement>(els.root, '.pas-command-bar')?.classList.add('has-image');
128
+ els.chooseButton.textContent = els.root.dataset.replaceImage ?? 'Replace image';
129
+ els.emptyState.hidden = true;
130
+ els.canvasPair.hidden = false;
131
+ return { name: file.name, width: canvas.width, height: canvas.height, data };
132
+ }
133
+
134
+ function setupFileHandler(els: AppEls, onLoaded: (src: SourceImageState) => void): void {
135
+ els.chooseButton.addEventListener('click', () => els.fileInput.click());
136
+ els.dropZone.addEventListener('click', () => els.fileInput.click());
137
+ els.fileInput.addEventListener('change', () => {
138
+ const file = els.fileInput.files?.[0];
139
+ if (!file || !file.type.startsWith('image/')) return;
140
+ const url = URL.createObjectURL(file);
141
+ const img = new Image();
142
+ img.onload = () => {
143
+ const src = processImageLoad(els, file, img);
144
+ if (src) onLoaded(src);
145
+ URL.revokeObjectURL(url);
146
+ };
147
+ img.src = url;
148
+ });
149
+ }
150
+
151
+ function setupPresetButtons(els: AppEls, onApply: (p: PaletteColor[], id: string) => void): void {
152
+ els.root.querySelectorAll<HTMLButtonElement>('[data-palette-id]').forEach((b) => b.addEventListener('click', () => {
153
+ const id = b.dataset.paletteId ?? 'gameBoy';
154
+ els.customPaletteInput.value = PRESET_PALETTES[id].map((c) => c.hex).join(', ');
155
+ onApply(PRESET_PALETTES[id], id);
156
+ }));
157
+ }
158
+
159
+ function setupCustomAndDownload(els: AppEls, onCustomApply: (hexList: string) => void, onCustomReset: () => void, onDownload: () => void): void {
160
+ getEl<HTMLButtonElement>(els.root, '#pas-apply-custom')?.addEventListener('click', () => onCustomApply(els.customPaletteInput.value));
161
+ getEl<HTMLButtonElement>(els.root, '#pas-reset-custom')?.addEventListener('click', () => onCustomReset());
162
+ els.downloadButton.addEventListener('click', onDownload);
163
+ }
164
+
165
+ function triggerDownload(canvas: HTMLCanvasElement, name: string, id: string): void {
166
+ canvas.toBlob((blob) => {
167
+ if (!blob) return;
168
+ const a = document.createElement('a');
169
+ a.download = `${name.replace(/\.[^.]+$/, '') || 'pixel-art'}-${id}.png`;
170
+ a.href = URL.createObjectURL(blob);
171
+ a.click();
172
+ }, 'image/png');
173
+ }
174
+
175
+ function runQuantizeRender(els: AppEls, src: SourceImageState, pal: PaletteColor[]): Uint8ClampedArray {
176
+ setStatus(els.root, els.root.dataset.processing ?? 'Mapping pixels', 'working');
177
+ const alpha = getEl<HTMLInputElement>(els.root, '#pas-preserve-alpha')?.checked ?? true;
178
+ const q = quantizeImageData(src.data, pal, alpha);
179
+ drawCanvas({ canvas: els.resultCanvas, width: src.width, height: src.height, data: q.data, zoom: Number(els.zoomInput.value) });
180
+ const ip = getEl<HTMLElement>(els.root, '#pas-inspector-preview');
181
+ if (ip) ip.style.backgroundImage = `url(${els.resultCanvas.toDataURL('image/png')})`;
182
+ updateSummaryTexts(els.root, q, src.width, src.height);
183
+ setStatus(els.root, els.root.dataset.ready ?? 'Ready', 'ready');
184
+ els.downloadButton.disabled = false;
185
+ return q.data;
186
+ }
187
+
188
+ function setupCoreAppLogic(els: AppEls): void {
189
+ let activePalette = PRESET_PALETTES.gameBoy;
190
+ let sourceImage: SourceImageState | null = null;
191
+ let resultData: Uint8ClampedArray | null = null;
192
+ let activePaletteId = 'gameBoy';
193
+
194
+ const renderResult = (): void => {
195
+ if (sourceImage) resultData = runQuantizeRender(els, sourceImage, activePalette);
196
+ };
197
+
198
+ const applyPalette = (palette: PaletteColor[], paletteId: string): void => {
199
+ if (palette.length === 0) return;
200
+ activePalette = palette;
201
+ activePaletteId = paletteId;
202
+ els.root.querySelectorAll<HTMLButtonElement>('[data-palette-id]').forEach((b) => b.classList.toggle('is-selected', b.dataset.paletteId === paletteId));
203
+ renderResult();
204
+ };
205
+
206
+ setupFileHandler(els, (src) => {
207
+ sourceImage = src;
208
+ drawCanvas({ canvas: els.sourceCanvas, width: src.width, height: src.height, data: src.data, zoom: Number(els.zoomInput.value) });
209
+ renderResult();
210
+ });
211
+ setupPresetButtons(els, applyPalette);
212
+ setupCustomAndDownload(els,
213
+ (val) => applyPalette(parsePaletteInput(val), 'custom'),
214
+ () => { els.customPaletteInput.value = PRESET_PALETTES.gameBoy.map((c) => c.hex).join(', '); applyPalette(PRESET_PALETTES.gameBoy, 'gameBoy'); },
215
+ () => { if (sourceImage && resultData) triggerDownload(els.resultCanvas, sourceImage.name, activePaletteId); }
216
+ );
217
+ }
218
+
219
+ export function initPixelArtPaletteSwapper(): void {
220
+ if (initialized) return;
221
+ initialized = true;
222
+ const root = getEl<HTMLElement>(document.body, '[data-tool="pixel-art-palette-swapper"]');
223
+ if (!root) return;
224
+ const els = queryAppElements(root);
225
+ if (!els) return;
226
+
227
+ setupCoreAppLogic(els);
228
+ renderSwatches(els.root);
229
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliographyEntries } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliographyEntries} />
@@ -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
+ };
@@ -0,0 +1,233 @@
1
+ import type { ToolLocaleContent } from '../../../types';
2
+ import type { PixelArtPaletteSwapperUI } from '../ui';
3
+
4
+ export const content: ToolLocaleContent<PixelArtPaletteSwapperUI> = {
5
+ slug: 'pixel-art-palette-tauscher',
6
+ title: 'Pixel Art Palette Tauscher',
7
+ description: 'Reduzieren Sie Sprites und Spritesheets auf klassische Konsolenpaletten oder eigene Hex-Farben direkt in Ihrem Browser.',
8
+ ui: {
9
+ uploadTitle: 'Sprite oder Spritesheet ablegen',
10
+ uploadHint: 'PNG, JPEG oder WebP bleiben auf Ihrem Gerät',
11
+ chooseImage: 'Bild auswählen',
12
+ replaceImage: 'Bild ersetzen',
13
+ paletteTitle: 'Palette wählen',
14
+ gameBoyPalette: 'Game Boy',
15
+ nesPalette: 'NES inspiriert',
16
+ pico8Palette: 'PICO-8',
17
+ commodore64Palette: 'Commodore 64',
18
+ dawnBringerPalette: 'DawnBringer 16',
19
+ customPalette: 'Benutzerdefinierte Farben',
20
+ customPaletteHint: 'Trennen Sie Hex-Werte durch Kommas, Leerzeichen oder Zeilenumbrüche.',
21
+ applyCustomPalette: 'Palette anwenden',
22
+ resetCustomPalette: 'Zurücksetzen',
23
+ sourcePreview: 'Original',
24
+ resultPreview: 'Reduziertes Ergebnis',
25
+ waitingForImage: 'Warten auf ein Bild',
26
+ uploadToPreview: 'Laden Sie ein Bild hoch, um es vorzuschauen',
27
+ resultEmpty: 'Original und reduzierte Version erscheinen nebeneinander.',
28
+ downloadPng: 'PNG herunterladen',
29
+ downloadDisabled: 'Bild hochladen zum Exportieren.',
30
+ colorCount: 'Quellfarben',
31
+ mappedCount: 'Verwendete Farben',
32
+ imageSize: 'Bildgröße',
33
+ paletteCount: 'Palettenfarben',
34
+ preserveAlpha: 'Transparenz erhalten',
35
+ zoomLabel: 'Zoom',
36
+ processing: 'Pixel werden zugeordnet',
37
+ invalidPalette: 'Mindestens eine gültige Hex-Farbe eingeben',
38
+ invalidImage: 'Wählen Sie ein PNG-, JPEG- oder WebP-Bild',
39
+ readyStatus: 'Bereit',
40
+ dropActive: 'Zum Laden loslassen',
41
+ mappedSummary: '{source} Quellfarben auf {mapped} Palettenfarben abgebildet',
42
+ },
43
+ seo: [
44
+ {
45
+ type: 'title',
46
+ level: 2,
47
+ text: 'Verwandeln Sie Vollfarb-Sprites in Gezielte Retro-Paletten',
48
+ },
49
+ {
50
+ type: 'paragraph',
51
+ html: 'Eine begrenzte Palette ist mehr als eine technische Einschränkung. Sie verleiht einem Sprite eine wiedererkennbare Farbsprache, lässt Szenen wie aus einem Guss wirken und erinnert an den visuellen Charakter einer bestimmten Konsole oder Fantasie-Hardware. Dieser browserbasierte Palette-Swapper ermöglicht es Ihnen, das Quellbild mit einer reduzierten Version zu vergleichen, während Sie mit Game Boy, NES-inspirierten, PICO-8, Commodore 64, DawnBringer 16 und eigenen Paletten experimentieren.',
52
+ },
53
+ {
54
+ type: 'title',
55
+ level: 2,
56
+ text: 'Funktionsweise der Nächstgelegenen Farbreduktion',
57
+ },
58
+ {
59
+ type: 'paragraph',
60
+ html: 'Das Werkzeug liest die Rot-, Grün- und Blaukanäle für jeden sichtbaren Pixel und vergleicht diese Farbe mit jeder Farbe der ausgewählten Palette. Es wählt den Paletteneintrag mit dem geringsten quadratischen RGB-Abstand aus und schreibt die Ersatzfarbe in einen neuen Canvas-Puffer. Der Alphakanal bleibt getrennt, sodass transparente Pixel transparent bleiben und halbtransparente Kanten ihre ursprüngliche Deckkraft behalten, wenn Transparenz erhalten aktiviert ist.',
61
+ },
62
+ {
63
+ type: 'comparative',
64
+ columns: 2,
65
+ items: [
66
+ {
67
+ title: 'Palettenbegrenzung',
68
+ description: 'Jede Quellfarbe wird durch den nächstgelegenen verfügbaren Farbton ersetzt.',
69
+ points: [
70
+ 'Schnell und vorhersehbar für Sprites, Icons, Tilesets und UI-Grafiken',
71
+ 'Behält ursprüngliche Maße und Pixelpositionen exakt bei',
72
+ 'Ermöglicht eine übersichtliche Kontrolle des festgelegten Farbbudgets',
73
+ ],
74
+ },
75
+ {
76
+ title: 'Palette Swapping',
77
+ description: 'Das gleiche Artwork kann auf ein anderes sorgfältig gewähltes Farbset neu abgebildet werden.',
78
+ points: [
79
+ 'Nützlich für alternative Kostüme, Biome und Schadenszustände',
80
+ 'Benutzerdefinierte Hex-Listen passen sich Ihrer eigenen Art-Direction an',
81
+ 'Das heruntergeladene PNG kann direkt in Ihren Editor importiert werden',
82
+ ],
83
+ },
84
+ ],
85
+ },
86
+ {
87
+ type: 'title',
88
+ level: 2,
89
+ text: 'Wahl Einer Palette für Pixel Art',
90
+ },
91
+ {
92
+ type: 'table',
93
+ headers: ['Palette', 'Farben', 'Eignung', 'Beachten'],
94
+ rows: [
95
+ ['Game Boy', '4', 'Monochromer Handheld-Stil und starke Tonwertstudien', 'Geringer Kontrast kann nahe beieinander liegende Materialien verschmelzen'],
96
+ ['NES inspiriert', '16', 'Markante Arcade-Sprites, Charaktere und Tilesets', 'Sehr helle Töne können kleine Details überdecken'],
97
+ ['PICO-8', '16', 'Modernes Pixel Art mit kräftigen Farb-Akzenten', 'Hohe Sättigung erfordert gezielt eingesetzten Kontrast'],
98
+ ['Commodore 64', '16', 'Dezente Retro-Szenen und Computergame-Ästhetik', 'Niedriger Kontrast profitiert von klaren Silhouetten'],
99
+ ['DawnBringer 16', '16', 'Vielseitige handverlesene Palette für allgemeine Pixel-Grafik', 'Farbrampen benötigen weiterhin eine bewusste Lichtrichtung'],
100
+ ],
101
+ },
102
+ {
103
+ type: 'title',
104
+ level: 2,
105
+ text: 'Praktischer Workflow für Spritesheets',
106
+ },
107
+ {
108
+ type: 'paragraph',
109
+ html: 'Beginnen Sie mit der größten Quellgrafik, die Sie komfortabel bearbeiten können, und laden Sie das exportierte Sprite oder Spritesheet hier hoch. Wählen Sie eine Voreinstellung, um eine Stilrichtung festzulegen, oder fügen Sie eine eigene Hex-Liste ein. Prüfen Sie beide Bildflächen bei höherem Zoom auf verlorene Gesichtszüge, verschmolzene Konturen oder Highlights. Wenn das Ergebnis matschig wirkt, versuchen Sie eine Palette mit stärkeren Tonwertstufen oder fügen Sie eine gezielte Akzentfarbe hinzu.',
110
+ },
111
+ {
112
+ type: 'tip',
113
+ title: 'Palette Bewusst Wählen',
114
+ html: 'Eine größere Farbliste ist nicht automatisch besser. Beginnen Sie mit vier bis sechzehn Farben, weisen Sie jeder Farbe eine klare Aufgabe zu und reservieren Sie die hellsten Werte für Fokusspunkte oder gut lesbare Highlights. Der Algorithmus behält die Pixelpositionen bei, kann aber nicht entscheiden, welche Farben die visuelle Hierarchie Ihres Sprites tragen sollen.',
115
+ },
116
+ {
117
+ type: 'title',
118
+ level: 2,
119
+ text: 'Export-Checkliste für Pixel Art',
120
+ },
121
+ {
122
+ type: 'diagnostic',
123
+ variant: 'info',
124
+ title: 'Vor dem Import der reduzierten PNG Datei',
125
+ html: 'Überprüfen Sie das Ergebnis bei 100 Prozent sowie im finalen Spielmaßstab, stellen Sie sicher, dass transparente Ränder sauber bleiben, prüfen Sie die Lesbarkeit wichtiger Silhouetten und bewahren Sie die ursprüngliche Quelldatei neben dem Export auf, um die Palette anzupassen ohne von vorn beginnen zu müssen.',
126
+ },
127
+ {
128
+ type: 'glossary',
129
+ items: [
130
+ {
131
+ term: 'Farbquantisierung',
132
+ definition: 'Der Prozess der Reduzierung einer großen Menge an Quellfarben auf ein kleineres, fest definiertes Farbset.',
133
+ },
134
+ {
135
+ term: 'Farbrampe',
136
+ definition: 'Eine zusammenhängende Folge von dunklen, mittleren und hellen Farben zur Schattierung einer Oberfläche.',
137
+ },
138
+ {
139
+ term: 'Indizierte Palette',
140
+ definition: 'Eine kompakte Farbtabelle, bei der Bildpixel auf Einträge in einer gemeinsamen Liste verweisen, anstatt vollständige Farbwerte zu speichern.',
141
+ },
142
+ ],
143
+ },
144
+ ],
145
+ faq: [
146
+ {
147
+ question: 'Werden meine Bilder auf einen Server hochgeladen?',
148
+ answer: 'Nein. Das Bild wird in Ihrem Browser auf einem Canvas decodiert, lokal mit JavaScript verarbeitet und direkt als PNG exportiert. Das Werkzeug enthält keinen Hochladevorgang.',
149
+ },
150
+ {
151
+ question: 'Kann ich meine eigene Palette verwenden?',
152
+ answer: 'Ja. Füge 6-stellige oder 3-stellige Hex-Farbcodes durch Kommas, Leerzeichen oder Zeilenumbrüche getrennt in das Feld für benutzerdefinierte Farben ein und wähle Palette anwenden.',
153
+ },
154
+ {
155
+ question: 'Wird die Größe meines Sprites oder Spritesheets verändert?',
156
+ answer: 'Nein. Die Ausgabedatei behält die ursprüngliche Breite, Höhe, Pixelpositionen und Alpha-Werte bei, wenn Transparenz erhalten aktiviert ist.',
157
+ },
158
+ {
159
+ question: 'Welcher Algorithmus wird verwendet?',
160
+ answer: 'Jeder sichtbare Pixel wird der nächstgelegenen Farbe der ausgewählten Palette mithilfe des quadratischen euklidischen Abstands im RGB-Raum zugewiesen. Es ist schnell, deterministisch und einfach vorzuschauen, wendet jedoch kein Dithering oder eine perzeptive Lab-Farbkorrektur an.',
161
+ },
162
+ ],
163
+ howTo: [
164
+ {
165
+ name: 'Sprite laden',
166
+ text: 'Ziehen Sie ein PNG-, JPEG- oder WebP-Sprite oder Spritesheet in den Arbeitsbereich oder nutzen Sie Bild auswählen.',
167
+ },
168
+ {
169
+ name: 'Palette wählen',
170
+ text: 'Wählen Sie eine klassische Voreinstellung oder geben Sie eigene Hex-Farben ein. Das Ergebnis aktualisiert sich sofort nach dem Anwenden.',
171
+ },
172
+ {
173
+ name: 'Vergleichen und exportieren',
174
+ text: 'Inspeizieren Sie das Original und das reduzierte Bild, passen Sie den Vorschau-Zoom an und laden Sie das Ergebnis als PNG herunter.',
175
+ },
176
+ ],
177
+ schemas: [
178
+ {
179
+ '@context': 'https://schema.org',
180
+ '@type': 'SoftwareApplication',
181
+ name: 'Pixel Art Palette Tauscher',
182
+ applicationCategory: 'DesignApplication',
183
+ operatingSystem: 'Any',
184
+ offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
185
+ },
186
+ {
187
+ '@context': 'https://schema.org',
188
+ '@type': 'FAQPage',
189
+ mainEntity: [
190
+ {
191
+ '@type': 'Question',
192
+ name: 'Werden meine Bilder auf einen Server hochgeladen?',
193
+ acceptedAnswer: {
194
+ '@type': 'Answer',
195
+ text: 'Nein. Das Bild wird lokal im Browser verarbeitet und direkt als PNG exportiert.',
196
+ },
197
+ },
198
+ {
199
+ '@type': 'Question',
200
+ name: 'Kann ich meine eigene Palette verwenden?',
201
+ acceptedAnswer: {
202
+ '@type': 'Answer',
203
+ text: 'Ja. Füge Hex-Farbcodes in das entsprechende Feld ein und wähle Palette anwenden.',
204
+ },
205
+ },
206
+ ],
207
+ },
208
+ {
209
+ '@context': 'https://schema.org',
210
+ '@type': 'HowTo',
211
+ name: 'Wie man ein Sprite auf eine Retro-Palette reduziert',
212
+ step: [
213
+ { '@type': 'HowToStep', name: 'Sprite laden', text: 'Ziehen Sie ein Bild in den Arbeitsbereich.' },
214
+ { '@type': 'HowToStep', name: 'Palette wählen', text: 'Wählen Sie ein Preset oder geben Sie Hex-Farben ein.' },
215
+ { '@type': 'HowToStep', name: 'Vergleichen und exportieren', text: 'Prüfen Sie das Ergebnis und laden Sie das PNG herunter.' },
216
+ ],
217
+ },
218
+ ],
219
+ bibliography: [
220
+ {
221
+ name: 'MDN Canvas API',
222
+ url: 'https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API',
223
+ },
224
+ {
225
+ name: 'Lospec Palette List',
226
+ url: 'https://lospec.com/palette-list',
227
+ },
228
+ {
229
+ name: 'W3C Portable Network Graphics Specification',
230
+ url: 'https://www.w3.org/TR/png-3/',
231
+ },
232
+ ],
233
+ };