@jjlmoya/utils-games-development 1.68.0 → 1.70.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 (72) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +3 -1
  3. package/src/entries.ts +8 -1
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +9 -2
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/fixerEditor/auto-trim.ts +37 -0
  8. package/src/tool/fixerEditor/bibliography.astro +6 -0
  9. package/src/tool/fixerEditor/bibliography.ts +6 -0
  10. package/src/tool/fixerEditor/component.astro +114 -0
  11. package/src/tool/fixerEditor/controller.ts +275 -0
  12. package/src/tool/fixerEditor/dom-views.ts +200 -0
  13. package/src/tool/fixerEditor/entry.ts +28 -0
  14. package/src/tool/fixerEditor/evaluator.ts +17 -0
  15. package/src/tool/fixerEditor/events.ts +98 -0
  16. package/src/tool/fixerEditor/exporter.ts +42 -0
  17. package/src/tool/fixerEditor/fixer-editor.css +603 -0
  18. package/src/tool/fixerEditor/i18n/de.ts +82 -0
  19. package/src/tool/fixerEditor/i18n/en.ts +114 -0
  20. package/src/tool/fixerEditor/i18n/es.ts +82 -0
  21. package/src/tool/fixerEditor/i18n/fr.ts +82 -0
  22. package/src/tool/fixerEditor/i18n/id.ts +82 -0
  23. package/src/tool/fixerEditor/i18n/it.ts +82 -0
  24. package/src/tool/fixerEditor/i18n/ja.ts +81 -0
  25. package/src/tool/fixerEditor/i18n/ko.ts +81 -0
  26. package/src/tool/fixerEditor/i18n/localized.ts +391 -0
  27. package/src/tool/fixerEditor/i18n/nl.ts +82 -0
  28. package/src/tool/fixerEditor/i18n/pl.ts +82 -0
  29. package/src/tool/fixerEditor/i18n/pt.ts +82 -0
  30. package/src/tool/fixerEditor/i18n/ru.ts +82 -0
  31. package/src/tool/fixerEditor/i18n/sv.ts +82 -0
  32. package/src/tool/fixerEditor/i18n/tr.ts +82 -0
  33. package/src/tool/fixerEditor/i18n/zh.ts +80 -0
  34. package/src/tool/fixerEditor/index.ts +11 -0
  35. package/src/tool/fixerEditor/logic.test.ts +69 -0
  36. package/src/tool/fixerEditor/logic.ts +234 -0
  37. package/src/tool/fixerEditor/runtime.ts +88 -0
  38. package/src/tool/fixerEditor/seo.astro +15 -0
  39. package/src/tool/fixerEditor/state.ts +10 -0
  40. package/src/tool/fixerEditor/storage.ts +68 -0
  41. package/src/tool/fixerEditor/types.ts +70 -0
  42. package/src/tool/fixerEditor/ui.ts +71 -0
  43. package/src/tool/gamePixelPerUnitPlanner/bibliography.astro +6 -0
  44. package/src/tool/gamePixelPerUnitPlanner/bibliography.ts +20 -0
  45. package/src/tool/gamePixelPerUnitPlanner/component.astro +56 -0
  46. package/src/tool/gamePixelPerUnitPlanner/controller.ts +127 -0
  47. package/src/tool/gamePixelPerUnitPlanner/dom-views.ts +47 -0
  48. package/src/tool/gamePixelPerUnitPlanner/entry.ts +27 -0
  49. package/src/tool/gamePixelPerUnitPlanner/evaluator.ts +14 -0
  50. package/src/tool/gamePixelPerUnitPlanner/game-pixel-per-unit-planner.css +652 -0
  51. package/src/tool/gamePixelPerUnitPlanner/i18n/de.ts +51 -0
  52. package/src/tool/gamePixelPerUnitPlanner/i18n/en.ts +171 -0
  53. package/src/tool/gamePixelPerUnitPlanner/i18n/es.ts +53 -0
  54. package/src/tool/gamePixelPerUnitPlanner/i18n/fr.ts +33 -0
  55. package/src/tool/gamePixelPerUnitPlanner/i18n/id.ts +33 -0
  56. package/src/tool/gamePixelPerUnitPlanner/i18n/it.ts +33 -0
  57. package/src/tool/gamePixelPerUnitPlanner/i18n/ja.ts +33 -0
  58. package/src/tool/gamePixelPerUnitPlanner/i18n/ko.ts +33 -0
  59. package/src/tool/gamePixelPerUnitPlanner/i18n/nl.ts +33 -0
  60. package/src/tool/gamePixelPerUnitPlanner/i18n/pl.ts +33 -0
  61. package/src/tool/gamePixelPerUnitPlanner/i18n/pt.ts +33 -0
  62. package/src/tool/gamePixelPerUnitPlanner/i18n/ru.ts +33 -0
  63. package/src/tool/gamePixelPerUnitPlanner/i18n/sv.ts +33 -0
  64. package/src/tool/gamePixelPerUnitPlanner/i18n/tr.ts +33 -0
  65. package/src/tool/gamePixelPerUnitPlanner/i18n/zh.ts +33 -0
  66. package/src/tool/gamePixelPerUnitPlanner/index.ts +11 -0
  67. package/src/tool/gamePixelPerUnitPlanner/logic.test.ts +34 -0
  68. package/src/tool/gamePixelPerUnitPlanner/logic.ts +129 -0
  69. package/src/tool/gamePixelPerUnitPlanner/seo.astro +15 -0
  70. package/src/tool/gamePixelPerUnitPlanner/storage.ts +23 -0
  71. package/src/tool/gamePixelPerUnitPlanner/ui.ts +65 -0
  72. package/src/tools.ts +4 -0
@@ -0,0 +1,88 @@
1
+ import { createFrameSpecs, detectGridFromImageData } from './logic';
2
+ import { saveDraftImage } from './storage';
3
+ import type { ControllerContext } from './controller';
4
+
5
+ export function loadImage(context: ControllerContext, file: File): void {
6
+ const image = new Image();
7
+ image.onload = () => rememberImage(context, image, file.name, file);
8
+ image.onerror = () => { context.refs.status.textContent = context.ui.statusInvalidImage; URL.revokeObjectURL(image.src); };
9
+ image.src = URL.createObjectURL(file);
10
+ }
11
+
12
+ function rememberImage(context: ControllerContext, image: HTMLImageElement, sourceName: string, file: File): void {
13
+ const reader = new FileReader();
14
+ reader.onload = () => {
15
+ const imageDataUrl = typeof reader.result === 'string' ? reader.result : undefined;
16
+ if (imageDataUrl) saveDraftImage(imageDataUrl);
17
+ applyImage(context, image, sourceName, imageDataUrl);
18
+ };
19
+ reader.onerror = () => applyImage(context, image, sourceName);
20
+ reader.readAsDataURL(file);
21
+ }
22
+
23
+ export function restoreDraftImage(context: ControllerContext, sourceImage: string, sourceName: string): void {
24
+ const image = new Image();
25
+ image.onload = () => applyImage(context, image, sourceName, sourceImage);
26
+ image.onerror = () => { context.refs.status.textContent = context.ui.statusInvalidImage; };
27
+ image.src = sourceImage;
28
+ }
29
+
30
+ function applyImage(context: ControllerContext, image: HTMLImageElement, sourceName: string, imageDataUrl?: string): void {
31
+ const pending = context.state.pendingProject;
32
+ const matchesProject = !pending || hasMatchingSize(pending, image);
33
+ context.state.image = image;
34
+ if (imageDataUrl) context.state.imageDataUrl = imageDataUrl;
35
+ else delete context.state.imageDataUrl;
36
+ context.state.sourceName = sourceName;
37
+ context.state.grid = chooseGrid(pending, matchesProject, context.state.grid, image);
38
+ context.state.adjustments = chooseAdjustments(pending, matchesProject);
39
+ context.state.pendingProject = null;
40
+ context.state.frames = createFrameSpecs(image.naturalWidth, image.naturalHeight, context.state.grid);
41
+ context.state.selected = Math.min(context.state.selected, Math.max(0, context.state.frames.length - 1));
42
+ context.updateView(matchesProject ? context.ui.loaded : context.ui.statusImageMismatch);
43
+ URL.revokeObjectURL(image.src);
44
+ }
45
+
46
+ function chooseGrid(project: NonNullable<ControllerContext['state']['pendingProject']> | null, matches: boolean, current: ControllerContext['state']['grid'], image: HTMLImageElement): ControllerContext['state']['grid'] {
47
+ if (matches && project) return project.grid;
48
+ return detectImageGrid(image) ?? current;
49
+ }
50
+
51
+ function chooseAdjustments(project: NonNullable<ControllerContext['state']['pendingProject']> | null, matches: boolean): ControllerContext['state']['adjustments'] {
52
+ if (!matches) return {};
53
+ return project?.adjustments ?? {};
54
+ }
55
+
56
+ function hasMatchingSize(project: NonNullable<ControllerContext['state']['pendingProject']>, image: HTMLImageElement): boolean {
57
+ return project.sourceWidth === image.naturalWidth && project.sourceHeight === image.naturalHeight;
58
+ }
59
+
60
+ function detectImageGrid(image: HTMLImageElement) {
61
+ const canvas = document.createElement('canvas');
62
+ canvas.width = image.naturalWidth;
63
+ canvas.height = image.naturalHeight;
64
+ const context = canvas.getContext('2d');
65
+ if (!context) return null;
66
+ context.drawImage(image, 0, 0);
67
+ return detectGridFromImageData(context.getImageData(0, 0, canvas.width, canvas.height).data, canvas.width, canvas.height);
68
+ }
69
+
70
+ export function autoDetectGrid(context: ControllerContext): void {
71
+ const { state, ui } = context;
72
+ if (!state.image) return;
73
+ const grid = detectImageGrid(state.image);
74
+ if (!grid) return;
75
+ context.recordHistory();
76
+ state.grid = grid;
77
+ state.frames = createFrameSpecs(state.image.naturalWidth, state.image.naturalHeight, state.grid);
78
+ state.selected = Math.min(state.selected, Math.max(0, state.frames.length - 1));
79
+ context.updateView(ui.ready);
80
+ }
81
+
82
+ export function animate(context: ControllerContext): void {
83
+ const { state } = context;
84
+ if (!state.playing || !state.image || state.frames.length === 0) return;
85
+ state.selected = (state.selected + 1) % state.frames.length;
86
+ context.updateView(context.ui.ready);
87
+ state.animationId = window.setTimeout(() => animate(context), 1000 / state.fps);
88
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { fixerEditor } from './entry';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props;
11
+ const loader = fixerEditor.i18n[locale] ?? fixerEditor.i18n.en;
12
+ const content = loader ? await loader() : null;
13
+ ---
14
+
15
+ {content && content.seo.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,10 @@
1
+ import { DEFAULT_GRID } from './logic';
2
+ import type { EditorState } from './controller';
3
+ import type { DraftFile } from './types';
4
+
5
+ export function createInitialState(draft: DraftFile | null): EditorState {
6
+ const saved = { sourceName: '', grid: DEFAULT_GRID, adjustments: {}, selected: 0, step: 1, zoom: 3, fps: 12, ...(draft || {}) };
7
+ const state: EditorState = { image: null, sourceName: saved.sourceName, grid: saved.grid, frames: [], adjustments: saved.adjustments, selected: saved.selected, step: saved.step, zoom: saved.zoom, fps: saved.fps, playing: false, animationId: null, history: [], future: [], pendingProject: draft };
8
+ if (saved.sourceImage) state.imageDataUrl = saved.sourceImage;
9
+ return state;
10
+ }
@@ -0,0 +1,68 @@
1
+ import { clamp, parseProjectFile } from './logic';
2
+ import type { DraftFile, DraftSession, ProjectFile } from './types';
3
+
4
+ const STORAGE_KEY = 'jjlmoya-fixer-editor-draft-v1';
5
+ const IMAGE_KEY = 'jjlmoya-fixer-editor-image-v1';
6
+
7
+ export function saveDraft(project: ProjectFile, session: DraftSession): boolean {
8
+ try {
9
+ const draft = { ...project, ...session };
10
+ delete draft.sourceImage;
11
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(draft));
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ export function loadDraft(): DraftFile | null {
19
+ try {
20
+ const raw = localStorage.getItem(STORAGE_KEY);
21
+ if (!raw) return null;
22
+ const value = JSON.parse(raw) as unknown;
23
+ const project = parseProjectFile(value);
24
+ const sourceImage = loadDraftImage();
25
+ return project ? { ...project, ...readDraftSession(value), ...(sourceImage ? { sourceImage } : {}) } : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ export function saveDraftImage(sourceImage: string): boolean {
32
+ try {
33
+ localStorage.setItem(IMAGE_KEY, sourceImage);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ function loadDraftImage(): string | null {
41
+ try {
42
+ const sourceImage = localStorage.getItem(IMAGE_KEY);
43
+ return sourceImage && sourceImage.startsWith('data:image/') ? sourceImage : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function readDraftSession(value: unknown): DraftSession {
50
+ const candidate = value && typeof value === 'object' ? value as Partial<DraftSession> : {};
51
+ const session: DraftSession = { selected: readInteger(candidate.selected, 0, 8192, 0), step: readInteger(candidate.step, 1, 4, 1), zoom: readInteger(candidate.zoom, 1, 8, 3), fps: readInteger(candidate.fps, 1, 30, 12) };
52
+ if (typeof candidate.sourceImage === 'string') session.sourceImage = candidate.sourceImage;
53
+ return session;
54
+ }
55
+
56
+ function readInteger(value: unknown, min: number, max: number, fallback: number): number {
57
+ if (typeof value !== 'number' || !Number.isInteger(value)) return fallback;
58
+ return clamp(value, min, max);
59
+ }
60
+
61
+ export function clearDraft(): void {
62
+ try {
63
+ localStorage.removeItem(STORAGE_KEY);
64
+ localStorage.removeItem(IMAGE_KEY);
65
+ } catch {
66
+ return;
67
+ }
68
+ }
@@ -0,0 +1,70 @@
1
+ export interface GridSettings {
2
+ columns: number;
3
+ rows: number;
4
+ cellWidth: number;
5
+ cellHeight: number;
6
+ marginX: number;
7
+ marginY: number;
8
+ gapX: number;
9
+ gapY: number;
10
+ }
11
+
12
+ export interface CropRect {
13
+ x: number;
14
+ y: number;
15
+ width: number;
16
+ height: number;
17
+ }
18
+
19
+ export interface FrameAdjustment {
20
+ crop: CropRect;
21
+ offsetX: number;
22
+ offsetY: number;
23
+ }
24
+
25
+ export interface FrameSpec {
26
+ index: number;
27
+ row: number;
28
+ column: number;
29
+ sourceX: number;
30
+ sourceY: number;
31
+ width: number;
32
+ height: number;
33
+ adjustment: FrameAdjustment;
34
+ }
35
+
36
+ export interface FrameMetadata {
37
+ index: number;
38
+ x: number;
39
+ y: number;
40
+ width: number;
41
+ height: number;
42
+ crop: CropRect;
43
+ offset: { x: number; y: number };
44
+ modified: boolean;
45
+ }
46
+
47
+ export interface ProjectFile {
48
+ version: 1;
49
+ sourceName: string;
50
+ sourceWidth: number;
51
+ sourceHeight: number;
52
+ grid: GridSettings;
53
+ adjustments: Record<string, FrameAdjustment>;
54
+ }
55
+
56
+ export interface DraftSession {
57
+ selected: number;
58
+ step: number;
59
+ zoom: number;
60
+ fps: number;
61
+ sourceImage?: string;
62
+ }
63
+
64
+ export type DraftFile = ProjectFile & DraftSession;
65
+
66
+ export interface HistoryState {
67
+ grid: GridSettings;
68
+ adjustments: Record<string, FrameAdjustment>;
69
+ selected: number;
70
+ }
@@ -0,0 +1,71 @@
1
+ export interface FixerEditorUI extends Record<string, string> {
2
+ uploadTitle: string;
3
+ uploadHint: string;
4
+ chooseImage: string;
5
+ sourceFile: string;
6
+ noImage: string;
7
+ gridSettings: string;
8
+ columns: string;
9
+ rows: string;
10
+ cellWidth: string;
11
+ cellHeight: string;
12
+ marginX: string;
13
+ marginY: string;
14
+ gapX: string;
15
+ gapY: string;
16
+ frameTools: string;
17
+ cropX: string;
18
+ cropY: string;
19
+ cropWidth: string;
20
+ cropHeight: string;
21
+ offsetX: string;
22
+ offsetY: string;
23
+ trimTransparent: string;
24
+ trimAll: string;
25
+ autoDetectGrid: string;
26
+ resetFrame: string;
27
+ nudgeStep: string;
28
+ stepOne: string;
29
+ stepTwo: string;
30
+ stepFour: string;
31
+ history: string;
32
+ undo: string;
33
+ redo: string;
34
+ resetAll: string;
35
+ saveProject: string;
36
+ loadProject: string;
37
+ exportSheet: string;
38
+ exportJson: string;
39
+ sheetView: string;
40
+ correctedView: string;
41
+ activeFrame: string;
42
+ frameStrip: string;
43
+ flipbook: string;
44
+ play: string;
45
+ pause: string;
46
+ fps: string;
47
+ zoom: string;
48
+ ready: string;
49
+ loaded: string;
50
+ modified: string;
51
+ unchanged: string;
52
+ frameCount: string;
53
+ correctionCount: string;
54
+ imageSize: string;
55
+ statusEmpty: string;
56
+ statusSaved: string;
57
+ statusLoaded: string;
58
+ statusExported: string;
59
+ statusInvalidImage: string;
60
+ statusInvalidProject: string;
61
+ statusProjectNeedsImage: string;
62
+ statusImageMismatch: string;
63
+ metadataNote: string;
64
+ sourceLabel: string;
65
+ resultLabel: string;
66
+ selectFrame: string;
67
+ moveUp: string;
68
+ moveDown: string;
69
+ moveLeft: string;
70
+ moveRight: string;
71
+ }
@@ -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
+ }