@jjlmoya/utils-games-development 1.68.0 → 1.69.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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +5 -1
  4. package/src/tests/locale_completeness.test.ts +2 -2
  5. package/src/tests/tool_validation.test.ts +2 -2
  6. package/src/tool/gamePixelPerUnitPlanner/bibliography.astro +6 -0
  7. package/src/tool/gamePixelPerUnitPlanner/bibliography.ts +20 -0
  8. package/src/tool/gamePixelPerUnitPlanner/component.astro +56 -0
  9. package/src/tool/gamePixelPerUnitPlanner/controller.ts +127 -0
  10. package/src/tool/gamePixelPerUnitPlanner/dom-views.ts +47 -0
  11. package/src/tool/gamePixelPerUnitPlanner/entry.ts +27 -0
  12. package/src/tool/gamePixelPerUnitPlanner/evaluator.ts +14 -0
  13. package/src/tool/gamePixelPerUnitPlanner/game-pixel-per-unit-planner.css +652 -0
  14. package/src/tool/gamePixelPerUnitPlanner/i18n/de.ts +51 -0
  15. package/src/tool/gamePixelPerUnitPlanner/i18n/en.ts +171 -0
  16. package/src/tool/gamePixelPerUnitPlanner/i18n/es.ts +53 -0
  17. package/src/tool/gamePixelPerUnitPlanner/i18n/fr.ts +33 -0
  18. package/src/tool/gamePixelPerUnitPlanner/i18n/id.ts +33 -0
  19. package/src/tool/gamePixelPerUnitPlanner/i18n/it.ts +33 -0
  20. package/src/tool/gamePixelPerUnitPlanner/i18n/ja.ts +33 -0
  21. package/src/tool/gamePixelPerUnitPlanner/i18n/ko.ts +33 -0
  22. package/src/tool/gamePixelPerUnitPlanner/i18n/nl.ts +33 -0
  23. package/src/tool/gamePixelPerUnitPlanner/i18n/pl.ts +33 -0
  24. package/src/tool/gamePixelPerUnitPlanner/i18n/pt.ts +33 -0
  25. package/src/tool/gamePixelPerUnitPlanner/i18n/ru.ts +33 -0
  26. package/src/tool/gamePixelPerUnitPlanner/i18n/sv.ts +33 -0
  27. package/src/tool/gamePixelPerUnitPlanner/i18n/tr.ts +33 -0
  28. package/src/tool/gamePixelPerUnitPlanner/i18n/zh.ts +33 -0
  29. package/src/tool/gamePixelPerUnitPlanner/index.ts +11 -0
  30. package/src/tool/gamePixelPerUnitPlanner/logic.test.ts +34 -0
  31. package/src/tool/gamePixelPerUnitPlanner/logic.ts +129 -0
  32. package/src/tool/gamePixelPerUnitPlanner/seo.astro +15 -0
  33. package/src/tool/gamePixelPerUnitPlanner/storage.ts +23 -0
  34. package/src/tool/gamePixelPerUnitPlanner/ui.ts +65 -0
  35. package/src/tools.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-games-development",
3
- "version": "1.68.0",
3
+ "version": "1.69.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -8,10 +8,11 @@ import { gameUiAccessibilityTester } from '../tool/gameUIAccessibilityTester/ent
8
8
  import { hitboxHurtboxAnimator } from '../tool/hitboxHurtboxAnimator/entry';
9
9
  import { damageFormulaLab } from '../tool/damageFormulaLab/entry';
10
10
  import { gameDeltaTimeFixedTimestepLab } from '../tool/gameDeltaTimeFixedTimestepLab/entry';
11
+ import { gamePixelPerUnitPlanner } from '../tool/gamePixelPerUnitPlanner/entry';
11
12
 
12
13
  export const gamesCategory: GamesCategoryEntry = {
13
14
  icon: 'mdi:gamepad-variant',
14
- tools: [steamCapsuleGenerator, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, steamBbcodeTranslator, gameUiAccessibilityTester, hitboxHurtboxAnimator, damageFormulaLab, gameDeltaTimeFixedTimestepLab],
15
+ tools: [steamCapsuleGenerator, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, steamBbcodeTranslator, gameUiAccessibilityTester, hitboxHurtboxAnimator, damageFormulaLab, gameDeltaTimeFixedTimestepLab, gamePixelPerUnitPlanner],
15
16
  i18n: {
16
17
  de: () => import('./i18n/de').then((m) => m.content),
17
18
  en: () => import('./i18n/en').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -22,7 +22,10 @@ export { damageFormulaLab } from './tool/damageFormulaLab/entry';
22
22
  export type { DamageFormulaLabUI, DamageFormulaLabLocaleContent } from './tool/damageFormulaLab/entry';
23
23
  export { gameDeltaTimeFixedTimestepLab } from './tool/gameDeltaTimeFixedTimestepLab/entry';
24
24
  export type { GameDeltaTimeFixedTimestepLabUI, GameDeltaTimeFixedTimestepLabLocaleContent } from './tool/gameDeltaTimeFixedTimestepLab/entry';
25
+ export { gamePixelPerUnitPlanner } from './tool/gamePixelPerUnitPlanner/entry';
26
+ export type { GamePixelPerUnitPlannerUI, GamePixelPerUnitPlannerLocaleContent } from './tool/gamePixelPerUnitPlanner/entry';
25
27
  export { gamesCategory } from './category';
28
+
26
29
  import { steamCapsuleGenerator } from './tool/steamCapsuleGenerator/entry';
27
30
  import { itchioGameTester } from './tool/itchioGameTester/entry';
28
31
  import { spriteSheetPacker } from './tool/spriteSheetPacker/entry';
@@ -36,5 +39,6 @@ import { gameUiAccessibilityTester } from './tool/gameUIAccessibilityTester/entr
36
39
  import { hitboxHurtboxAnimator } from './tool/hitboxHurtboxAnimator/entry';
37
40
  import { damageFormulaLab } from './tool/damageFormulaLab/entry';
38
41
  import { gameDeltaTimeFixedTimestepLab } from './tool/gameDeltaTimeFixedTimestepLab/entry';
42
+ import { gamePixelPerUnitPlanner } from './tool/gamePixelPerUnitPlanner/entry';
39
43
 
40
- export const ALL_ENTRIES = [steamCapsuleGenerator, itchioGameTester, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, localizationSanitizer, steamBbcodeTranslator, retroSfxGenerator, pixelArtPaletteSwapper, gameUiAccessibilityTester, hitboxHurtboxAnimator, damageFormulaLab, gameDeltaTimeFixedTimestepLab];
44
+ export const ALL_ENTRIES = [steamCapsuleGenerator, itchioGameTester, spriteSheetPacker, audioLoopPointFinder, saveFileEditor, localizationSanitizer, steamBbcodeTranslator, retroSfxGenerator, pixelArtPaletteSwapper, gameUiAccessibilityTester, hitboxHurtboxAnimator, damageFormulaLab, gameDeltaTimeFixedTimestepLab, gamePixelPerUnitPlanner];
@@ -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('thirteen tools are registered', () => {
6
- expect(ALL_TOOLS.length).toBe(13);
5
+ it('fourteen tools are registered', () => {
6
+ expect(ALL_TOOLS.length).toBe(14);
7
7
  });
8
8
  });
@@ -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 13 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(13);
7
+ it('should have 14 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(14);
9
9
  });
10
10
 
11
11
  it('gamesCategory should be defined', () => {
@@ -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,20 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliographyEntries: BibliographyEntry[] = [
4
+ {
5
+ name: '2D Pixel Perfect by Unity Technologies',
6
+ url: 'https://docs.unity3d.com/ja/Packages/com.unity.render-pipelines.universal%4014.0/manual/2d-pixelperfect.html',
7
+ },
8
+ {
9
+ name: 'CanvasRenderingContext2D imageSmoothingEnabled by MDN',
10
+ url: 'https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/imageSmoothingEnabled',
11
+ },
12
+ {
13
+ name: 'Importer des images by Godot Engine',
14
+ url: 'https://docs.godotengine.org/fr/4.x/tutorials/assets_pipeline/importing_images.html',
15
+ },
16
+ {
17
+ name: '精灵与渲染 by Estella Engine',
18
+ url: 'https://estellaengine.com/docs/zh-cn/graphics/sprites/',
19
+ },
20
+ ];
@@ -0,0 +1,56 @@
1
+ ---
2
+ import type { GamePixelPerUnitPlannerUI } from './ui';
3
+ import './game-pixel-per-unit-planner.css';
4
+
5
+ interface Props {
6
+ ui: GamePixelPerUnitPlannerUI;
7
+ }
8
+
9
+ const { ui } = Astro.props as Props;
10
+ ---
11
+
12
+ <div class="gppu-tool" data-game-pixel-per-unit-planner>
13
+ <div class="gppu-layout">
14
+ <aside class="gppu-controls">
15
+ <div class="gppu-heading"><span class="gppu-kicker">01</span><h2>{ui.inputsTitle}</h2></div>
16
+ <div class="gppu-upload" data-upload-zone>
17
+ <div class="gppu-upload-heading"><strong>{ui.uploadTitle}</strong><span class="gppu-upload-icon" aria-hidden="true">+</span></div>
18
+ <p>{ui.uploadHint}</p>
19
+ <label class="gppu-upload-button"><span>{ui.chooseSpriteLabel}</span><input data-sprite-upload type="file" accept="image/png,image/gif,image/webp,image/jpeg" /></label>
20
+ <span class="gppu-file-name" data-file-name>{ui.defaultSpriteLabel}</span>
21
+ <button class="gppu-clear-sprite" type="button" data-clear-sprite>{ui.clearSpriteLabel}</button>
22
+ </div>
23
+ <div class="gppu-field-grid">
24
+ <label><span>{ui.displayWidthLabel}</span><input data-field="displayWidth" type="number" min="160" max="7680" step="1" value="320" /></label>
25
+ <label><span>{ui.displayHeightLabel}</span><input data-field="displayHeight" type="number" min="90" max="4320" step="1" value="180" /></label>
26
+ <label><span>{ui.spriteWidthLabel}</span><input data-field="spriteWidth" type="number" min="1" max="4096" step="1" value="16" readonly /></label>
27
+ <label><span>{ui.spriteHeightLabel}</span><input data-field="spriteHeight" type="number" min="1" max="4096" step="1" value="16" readonly /></label>
28
+ <label><span>{ui.worldWidthLabel}</span><input data-field="worldWidth" type="number" min="0.01" max="1000" step="0.01" value="1" /></label>
29
+ <label><span>{ui.worldHeightLabel}</span><input data-field="worldHeight" type="number" min="0.01" max="1000" step="0.01" value="1" /></label>
30
+ </div>
31
+ <label class="gppu-scale-field"><span>{ui.targetScaleLabel}</span><output data-output="targetScale">4</output><input data-field="targetScale" type="range" min="0.5" max="16" step="0.25" value="4" /></label>
32
+ <p class="gppu-hint">{ui.targetScaleHint}</p>
33
+ <div class="gppu-preset-group"><span class="gppu-preset-label">{ui.resolutionPresetsLabel}</span><div class="gppu-chip-row"><button type="button" data-resolution-preset="320x180">{ui.preset320}</button><button type="button" data-resolution-preset="384x216">{ui.preset384}</button><button type="button" data-resolution-preset="640x360">{ui.preset640}</button></div></div>
34
+ <div class="gppu-preset-group"><span class="gppu-preset-label">{ui.scalePresetsLabel}</span><div class="gppu-chip-row"><button type="button" data-scale-preset="1">{ui.scale1}</button><button type="button" data-scale-preset="2">{ui.scale2}</button><button type="button" data-scale-preset="3">{ui.scale3}</button><button type="button" data-scale-preset="4">{ui.scale4}</button><button type="button" data-scale-preset="6">{ui.scale6}</button></div></div>
35
+ <button class="gppu-reset" type="button" data-reset>{ui.resetLabel}</button>
36
+ </aside>
37
+ <main class="gppu-stage" aria-labelledby="gppu-field-title">
38
+ <div class="gppu-stage-heading"><div><span class="gppu-kicker">02</span><h2 id="gppu-field-title">{ui.fieldTitle}</h2><p>{ui.fieldCaption}</p></div><span class="gppu-live" data-live aria-live="polite">{ui.statusReady}</span></div>
39
+ <div class="gppu-field" data-pixel-field></div>
40
+ <div class="gppu-summary" data-summary></div>
41
+ <section class="gppu-crisp"><div class="gppu-section-heading"><div><span class="gppu-kicker">03</span><h3>{ui.crispTitle}</h3><p>{ui.crispCaption}</p></div></div><div class="gppu-step-grid" data-scale-steps></div></section>
42
+ <div class="gppu-table" data-table></div>
43
+ <p class="gppu-model-note">{ui.modelNote}</p>
44
+ <p class="gppu-privacy">{ui.privacyDisclosure}</p>
45
+ </main>
46
+ </div>
47
+ </div>
48
+
49
+ <script is:inline type="application/json" data-game-pixel-per-unit-ui set:html={JSON.stringify(ui)}></script>
50
+
51
+ <script>
52
+ import { mountGamePixelPerUnitPlanner } from './controller';
53
+ const root = document.querySelector<HTMLElement>('[data-game-pixel-per-unit-planner]');
54
+ const uiScript = document.querySelector<HTMLScriptElement>('[data-game-pixel-per-unit-ui]');
55
+ if (root && uiScript) mountGamePixelPerUnitPlanner(root, JSON.parse(uiScript.textContent ?? '{}'));
56
+ </script>
@@ -0,0 +1,127 @@
1
+ import { evaluatePixelPlan } from './evaluator';
2
+ import { calculatePixelPlan, DEFAULT_PIXEL_CONFIG, normalizePixelConfig, type PixelPerUnitConfig } from './logic';
3
+ import { renderAccessibleTable, renderPixelField, renderScaleSteps, renderSummary } from './dom-views';
4
+ import { loadPixelConfig, savePixelConfig } from './storage';
5
+ import type { GamePixelPerUnitPlannerUI } from './ui';
6
+
7
+ const DEFAULT_SPRITE_URL = '/assets/game-pixel-per-unit-planner/default-sprite.png';
8
+
9
+ const fieldNames: (keyof PixelPerUnitConfig)[] = ['displayWidth', 'displayHeight', 'spriteWidth', 'spriteHeight', 'worldWidth', 'worldHeight', 'targetScale'];
10
+
11
+ function field(root: HTMLElement, name: keyof PixelPerUnitConfig): HTMLInputElement {
12
+ return root.querySelector<HTMLInputElement>(`[data-field="${name}"]`) as HTMLInputElement;
13
+ }
14
+
15
+ function readConfig(root: HTMLElement): PixelPerUnitConfig {
16
+ const values = Object.fromEntries(fieldNames.map((name) => [name, Number(field(root, name).value)]));
17
+ return normalizePixelConfig(values);
18
+ }
19
+
20
+ function syncOutputs(root: HTMLElement): void {
21
+ fieldNames.forEach((name) => {
22
+ const output = root.querySelector<HTMLOutputElement>(`[data-output="${name}"]`);
23
+ if (output) output.value = field(root, name).value;
24
+ });
25
+ }
26
+
27
+ function render(root: HTMLElement, ui: GamePixelPerUnitPlannerUI, config: PixelPerUnitConfig, spriteUrl?: string): void {
28
+ const result = calculatePixelPlan(config);
29
+ const diagnostic = evaluatePixelPlan(result);
30
+ const fieldTarget = root.querySelector<HTMLElement>('[data-pixel-field]');
31
+ const stepsTarget = root.querySelector<HTMLElement>('[data-scale-steps]');
32
+ const summaryTarget = root.querySelector<HTMLElement>('[data-summary]');
33
+ const tableTarget = root.querySelector<HTMLElement>('[data-table]');
34
+ if (fieldTarget) renderPixelField(fieldTarget, result, ui, spriteUrl);
35
+ if (stepsTarget) renderScaleSteps(stepsTarget, result, ui, spriteUrl);
36
+ if (summaryTarget) renderSummary(summaryTarget, result, diagnostic, ui);
37
+ if (tableTarget) renderAccessibleTable(tableTarget, result, ui);
38
+ root.querySelector<HTMLElement>('[data-live]')!.textContent = ui.statusReady;
39
+ savePixelConfig(config);
40
+ }
41
+
42
+ function setConfig(root: HTMLElement, config: PixelPerUnitConfig): void {
43
+ fieldNames.forEach((name) => { field(root, name).value = String(config[name]); });
44
+ syncOutputs(root);
45
+ }
46
+
47
+ function bindPresets(root: HTMLElement, ui: GamePixelPerUnitPlannerUI, state: { spriteUrl?: string }): void {
48
+ root.querySelectorAll<HTMLButtonElement>('[data-resolution-preset]').forEach((button) => button.addEventListener('click', () => {
49
+ const [displayWidth, displayHeight] = (button.dataset.resolutionPreset ?? '').split('x').map(Number);
50
+ const config = { ...readConfig(root), displayWidth, displayHeight };
51
+ setConfig(root, config);
52
+ render(root, ui, config, state.spriteUrl);
53
+ }));
54
+ root.querySelectorAll<HTMLButtonElement>('[data-scale-preset]').forEach((button) => button.addEventListener('click', () => {
55
+ const config = { ...readConfig(root), targetScale: Number(button.dataset.scalePreset) };
56
+ setConfig(root, config);
57
+ render(root, ui, config, state.spriteUrl);
58
+ }));
59
+ }
60
+
61
+ function bindReset(root: HTMLElement, ui: GamePixelPerUnitPlannerUI, state: { spriteUrl?: string }): void {
62
+ root.querySelector<HTMLButtonElement>('[data-reset]')?.addEventListener('click', () => {
63
+ if (state.spriteUrl && state.spriteUrl !== DEFAULT_SPRITE_URL) URL.revokeObjectURL(state.spriteUrl);
64
+ state.spriteUrl = DEFAULT_SPRITE_URL;
65
+ const fileInput = root.querySelector<HTMLInputElement>('[data-sprite-upload]');
66
+ if (fileInput) fileInput.value = '';
67
+ const fileName = root.querySelector<HTMLElement>('[data-file-name]');
68
+ if (fileName) fileName.textContent = ui.defaultSpriteLabel;
69
+ const clearButton = root.querySelector<HTMLButtonElement>('[data-clear-sprite]');
70
+ if (clearButton) clearButton.hidden = false;
71
+ setConfig(root, DEFAULT_PIXEL_CONFIG);
72
+ render(root, ui, DEFAULT_PIXEL_CONFIG, state.spriteUrl);
73
+ });
74
+ }
75
+
76
+ function bindSpriteUpload(root: HTMLElement, ui: GamePixelPerUnitPlannerUI, state: { spriteUrl?: string }): void {
77
+ const upload = root.querySelector<HTMLInputElement>('[data-sprite-upload]');
78
+ upload?.addEventListener('change', () => {
79
+ const file = upload.files?.[0];
80
+ if (!file) return;
81
+ const nextUrl = URL.createObjectURL(file);
82
+ const image = new Image();
83
+ image.onload = () => {
84
+ if (state.spriteUrl && state.spriteUrl !== DEFAULT_SPRITE_URL) URL.revokeObjectURL(state.spriteUrl);
85
+ state.spriteUrl = nextUrl;
86
+ const config = normalizePixelConfig({ ...readConfig(root), spriteWidth: image.naturalWidth, spriteHeight: image.naturalHeight });
87
+ setConfig(root, config);
88
+ root.querySelector<HTMLElement>('[data-file-name]')!.textContent = `${ui.loadedSpriteLabel}: ${file.name}`;
89
+ root.querySelector<HTMLButtonElement>('[data-clear-sprite]')!.hidden = false;
90
+ render(root, ui, config, state.spriteUrl);
91
+ };
92
+ image.onerror = () => URL.revokeObjectURL(nextUrl);
93
+ image.src = nextUrl;
94
+ });
95
+ }
96
+
97
+ function bindClearSprite(root: HTMLElement, ui: GamePixelPerUnitPlannerUI, state: { spriteUrl?: string }): void {
98
+ root.querySelector<HTMLButtonElement>('[data-clear-sprite]')?.addEventListener('click', () => {
99
+ if (state.spriteUrl && state.spriteUrl !== DEFAULT_SPRITE_URL) URL.revokeObjectURL(state.spriteUrl);
100
+ state.spriteUrl = undefined;
101
+ const uploadInput = root.querySelector<HTMLInputElement>('[data-sprite-upload]');
102
+ if (uploadInput) uploadInput.value = '';
103
+ root.querySelector<HTMLElement>('[data-file-name]')!.textContent = ui.noSpriteLabel;
104
+ root.querySelector<HTMLButtonElement>('[data-clear-sprite]')!.hidden = true;
105
+ render(root, ui, readConfig(root));
106
+ });
107
+ }
108
+
109
+ function bindInputs(root: HTMLElement, ui: GamePixelPerUnitPlannerUI, state: { spriteUrl?: string }): void {
110
+ fieldNames.forEach((name) => field(root, name).addEventListener('input', () => {
111
+ syncOutputs(root);
112
+ render(root, ui, readConfig(root), state.spriteUrl);
113
+ }));
114
+ bindReset(root, ui, state);
115
+ bindSpriteUpload(root, ui, state);
116
+ bindClearSprite(root, ui, state);
117
+ bindPresets(root, ui, state);
118
+ }
119
+
120
+ export function mountGamePixelPerUnitPlanner(root: HTMLElement, ui: GamePixelPerUnitPlannerUI): void {
121
+ const state: { spriteUrl?: string } = { spriteUrl: DEFAULT_SPRITE_URL };
122
+ root.querySelector<HTMLElement>('[data-file-name]')!.textContent = ui.defaultSpriteLabel;
123
+ const config = normalizePixelConfig(loadPixelConfig());
124
+ setConfig(root, config);
125
+ bindInputs(root, ui, state);
126
+ render(root, ui, config, state.spriteUrl);
127
+ }
@@ -0,0 +1,47 @@
1
+ import type { Diagnostic } from './evaluator';
2
+ import type { CrispScaleStep, PixelPerUnitResult } from './logic';
3
+ import type { GamePixelPerUnitPlannerUI } from './ui';
4
+
5
+ function escapeHtml(value: string): string {
6
+ return value.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character] ?? character);
7
+ }
8
+
9
+ function number(value: number, digits = 2): string {
10
+ return value.toLocaleString('en-US', { maximumFractionDigits: digits, minimumFractionDigits: digits });
11
+ }
12
+
13
+ export function renderPixelField(target: HTMLElement, result: PixelPerUnitResult, ui: GamePixelPerUnitPlannerUI, spriteUrl?: string): void {
14
+ const image = spriteUrl
15
+ ? `<div class="gppu-hero-canvas"><img src="${escapeHtml(spriteUrl)}" width="${result.renderedSpriteWidth}" height="${result.renderedSpriteHeight}" alt="${escapeHtml(ui.sourceImageAlt)}" /></div>`
16
+ : `<div class="gppu-empty-preview"><span aria-hidden="true">+</span><strong>${escapeHtml(ui.previewPlaceholder)}</strong></div>`;
17
+ target.innerHTML = `<div class="gppu-preview-shell"><div class="gppu-preview-topline"><span>${escapeHtml(ui.viewportLabel)} ${number(result.config.displayWidth, 0)} x ${number(result.config.displayHeight, 0)} ${escapeHtml(ui.unitPixels)}</span><strong>${escapeHtml(ui.previewScaleLabel)} ${number(result.config.targetScale, 2)}x</strong></div>${image}<div class="gppu-preview-meta"><span>${escapeHtml(ui.spriteLabel)} ${number(result.config.spriteWidth, 0)} x ${number(result.config.spriteHeight, 0)} ${escapeHtml(ui.unitPixels)}</span><span>${number(result.renderedSpriteWidth, 0)} x ${number(result.renderedSpriteHeight, 0)} ${escapeHtml(ui.unitPixels)} on screen</span></div></div>`;
18
+ }
19
+
20
+ function stepCard(step: CrispScaleStep, result: PixelPerUnitResult, ui: GamePixelPerUnitPlannerUI, spriteUrl?: string): string {
21
+ const status = step.fitsViewport ? ui.yesLabel : ui.noLabel;
22
+ const recommended = step.scale === result.recommendedScale ? `<span class="gppu-recommended">${escapeHtml(ui.recommendedLabel)}</span>` : '';
23
+ const preview = spriteUrl ? `<div class="gppu-step-canvas"><img src="${escapeHtml(spriteUrl)}" width="${step.renderedWidth}" height="${step.renderedHeight}" alt="${escapeHtml(ui.sourceImageAlt)} at ${step.scale}x" /></div>` : '';
24
+ return `<div class="gppu-step ${step.fitsViewport ? 'gppu-step-fit' : 'gppu-step-over'} ${step.scale === Math.round(result.config.targetScale) ? 'gppu-step-current' : ''}">${preview}<div><strong>${step.scale}x</strong>${recommended}</div><span>${number(step.renderedWidth, 0)} x ${number(step.renderedHeight, 0)} ${escapeHtml(ui.unitPixels)}</span><small>${escapeHtml(ui.fitLabel)} ${escapeHtml(status)}</small></div>`;
25
+ }
26
+
27
+ export function renderScaleSteps(target: HTMLElement, result: PixelPerUnitResult, ui: GamePixelPerUnitPlannerUI, spriteUrl?: string): void {
28
+ target.innerHTML = result.steps.map((step) => stepCard(step, result, ui, spriteUrl)).join('');
29
+ }
30
+
31
+ function riskLabel(result: PixelPerUnitResult, ui: GamePixelPerUnitPlannerUI): string {
32
+ const labels = { low: ui.lowRisk, medium: ui.mediumRisk, high: ui.highRisk };
33
+ return labels[result.bleedRisk];
34
+ }
35
+
36
+ function diagnosticMessage(diagnostic: Diagnostic, ui: GamePixelPerUnitPlannerUI): string {
37
+ return ui[diagnostic.messageKey];
38
+ }
39
+
40
+ export function renderSummary(target: HTMLElement, result: PixelPerUnitResult, diagnostic: Diagnostic, ui: GamePixelPerUnitPlannerUI): void {
41
+ target.innerHTML = `<h3 class="gppu-summary-title">${escapeHtml(ui.summaryTitle)}</h3><div class="gppu-summary-grid"><div><span>${escapeHtml(ui.ppuXLabel)}</span><strong>${number(result.ppuX)} ${escapeHtml(ui.unitPixels)} / ${escapeHtml(ui.unitUnits)}</strong></div><div><span>${escapeHtml(ui.ppuYLabel)}</span><strong>${number(result.ppuY)} ${escapeHtml(ui.unitPixels)} / ${escapeHtml(ui.unitUnits)}</strong></div><div><span>${escapeHtml(ui.viewportWorldLabel)}</span><strong>${number(result.viewportWorldWidth)} x ${number(result.viewportWorldHeight)} ${escapeHtml(ui.unitUnits)}</strong></div><div><span>${escapeHtml(ui.fitScaleLabel)}</span><strong>${number(result.fitScale, 2)}x</strong></div><div><span>${escapeHtml(ui.bleedingRiskLabel)}</span><strong class="gppu-risk-${diagnostic.key}">${escapeHtml(riskLabel(result, ui))}</strong></div></div><p class="gppu-diagnostic gppu-diagnostic-${diagnostic.key}"><strong>${escapeHtml(ui.alignmentLabel)}:</strong> ${escapeHtml(diagnosticMessage(diagnostic, ui))}</p>`;
42
+ }
43
+
44
+ export function renderAccessibleTable(target: HTMLElement, result: PixelPerUnitResult, ui: GamePixelPerUnitPlannerUI): void {
45
+ const rows = result.steps.map((step) => `<tr><td>${step.scale}x</td><td>${number(step.renderedWidth, 0)} ${escapeHtml(ui.unitPixels)}</td><td>${number(step.renderedHeight, 0)} ${escapeHtml(ui.unitPixels)}</td><td>${escapeHtml(step.fitsViewport ? ui.yesLabel : ui.noLabel)}</td></tr>`).join('');
46
+ target.innerHTML = `<h3>${escapeHtml(ui.tableTitle)}</h3><table><thead><tr><th>${escapeHtml(ui.tableScale)}</th><th>${escapeHtml(ui.tableWidth)}</th><th>${escapeHtml(ui.tableHeight)}</th><th>${escapeHtml(ui.tableFits)}</th></tr></thead><tbody>${rows}</tbody></table>`;
47
+ }
@@ -0,0 +1,27 @@
1
+ import type { GamesToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { GamePixelPerUnitPlannerUI } from './ui';
3
+
4
+ export type { GamePixelPerUnitPlannerUI };
5
+ export type GamePixelPerUnitPlannerLocaleContent = ToolLocaleContent<GamePixelPerUnitPlannerUI>;
6
+
7
+ export const gamePixelPerUnitPlanner: GamesToolEntry<GamePixelPerUnitPlannerUI> = {
8
+ id: 'game-pixel-per-unit-planner',
9
+ icons: { bg: 'mdi:grid', fg: 'mdi:gamepad-variant' },
10
+ i18n: {
11
+ de: () => import('./i18n/de').then((module) => module.content),
12
+ en: () => import('./i18n/en').then((module) => module.content),
13
+ es: () => import('./i18n/es').then((module) => module.content),
14
+ fr: () => import('./i18n/fr').then((module) => module.content),
15
+ id: () => import('./i18n/id').then((module) => module.content),
16
+ it: () => import('./i18n/it').then((module) => module.content),
17
+ ja: () => import('./i18n/ja').then((module) => module.content),
18
+ ko: () => import('./i18n/ko').then((module) => module.content),
19
+ nl: () => import('./i18n/nl').then((module) => module.content),
20
+ pl: () => import('./i18n/pl').then((module) => module.content),
21
+ pt: () => import('./i18n/pt').then((module) => module.content),
22
+ ru: () => import('./i18n/ru').then((module) => module.content),
23
+ sv: () => import('./i18n/sv').then((module) => module.content),
24
+ tr: () => import('./i18n/tr').then((module) => module.content),
25
+ zh: () => import('./i18n/zh').then((module) => module.content),
26
+ },
27
+ };
@@ -0,0 +1,14 @@
1
+ import type { PixelPerUnitResult } from './logic';
2
+
3
+ export type DiagnosticKey = 'low' | 'medium' | 'high';
4
+
5
+ export interface Diagnostic {
6
+ key: DiagnosticKey;
7
+ messageKey: 'riskLowMessage' | 'riskMediumMessage' | 'riskHighMessage';
8
+ }
9
+
10
+ export function evaluatePixelPlan(result: PixelPerUnitResult): Diagnostic {
11
+ if (result.bleedRisk === 'high') return { key: 'high', messageKey: 'riskHighMessage' };
12
+ if (result.bleedRisk === 'medium') return { key: 'medium', messageKey: 'riskMediumMessage' };
13
+ return { key: 'low', messageKey: 'riskLowMessage' };
14
+ }