@jjlmoya/utils-tabletop 1.25.0 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/category/index.ts +4 -0
- package/src/entries.ts +4 -0
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/encounter-difficulty-calculator/bibliography.astro +6 -0
- package/src/tool/encounter-difficulty-calculator/bibliography.ts +12 -0
- package/src/tool/encounter-difficulty-calculator/component.astro +53 -0
- package/src/tool/encounter-difficulty-calculator/controller.ts +133 -0
- package/src/tool/encounter-difficulty-calculator/dnd-5e-encounter-difficulty-calculator.css +389 -0
- package/src/tool/encounter-difficulty-calculator/dom-views.ts +54 -0
- package/src/tool/encounter-difficulty-calculator/entry.ts +27 -0
- package/src/tool/encounter-difficulty-calculator/evaluator.ts +12 -0
- package/src/tool/encounter-difficulty-calculator/i18n/de.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/en.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/es.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/fr.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/id.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/it.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/ja.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/ko.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/nl.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/pl.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/pt.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/ru.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/sv.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/tr.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/zh.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/index.ts +11 -0
- package/src/tool/encounter-difficulty-calculator/logic.test.ts +37 -0
- package/src/tool/encounter-difficulty-calculator/logic.ts +174 -0
- package/src/tool/encounter-difficulty-calculator/seo.astro +16 -0
- package/src/tool/encounter-difficulty-calculator/storage.ts +26 -0
- package/src/tool/encounter-difficulty-calculator/ui.ts +45 -0
- package/src/tool/token-stamp-studio/bibliography.astro +16 -0
- package/src/tool/token-stamp-studio/bibliography.ts +12 -0
- package/src/tool/token-stamp-studio/component.astro +91 -0
- package/src/tool/token-stamp-studio/controller.ts +179 -0
- package/src/tool/token-stamp-studio/dom-views.ts +266 -0
- package/src/tool/token-stamp-studio/entry.ts +27 -0
- package/src/tool/token-stamp-studio/evaluator.ts +6 -0
- package/src/tool/token-stamp-studio/i18n/de.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/en.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/es.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/fr.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/id.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/it.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/ja.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/ko.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/nl.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/pl.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/pt.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/ru.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/sv.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/tr.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/zh.ts +132 -0
- package/src/tool/token-stamp-studio/index.ts +11 -0
- package/src/tool/token-stamp-studio/logic.test.ts +36 -0
- package/src/tool/token-stamp-studio/logic.ts +127 -0
- package/src/tool/token-stamp-studio/seo.astro +16 -0
- package/src/tool/token-stamp-studio/storage.ts +69 -0
- package/src/tool/token-stamp-studio/store.ts +139 -0
- package/src/tool/token-stamp-studio/token-stamp-studio.css +646 -0
- package/src/tool/token-stamp-studio/types.ts +65 -0
- package/src/tool/token-stamp-studio/ui.ts +50 -0
- package/src/tool/token-stamp-studio/workspace-media.ts +36 -0
- package/src/tool/token-stamp-studio/workspace.ts +83 -0
- package/src/tools.ts +4 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { getFrameGeometry, getFramePreset } from './logic';
|
|
2
|
+
import type { FramePreset, SavedMarker, TextLayer, TokenStampState } from './types';
|
|
3
|
+
|
|
4
|
+
export function pointerToCanvas(canvas: HTMLCanvasElement, event: PointerEvent): { x: number; y: number } {
|
|
5
|
+
const rect = canvas.getBoundingClientRect();
|
|
6
|
+
return {
|
|
7
|
+
x: Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)),
|
|
8
|
+
y: Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function hexToRgba(hex: string, alpha: number): string {
|
|
13
|
+
const value = hex.replace('#', '');
|
|
14
|
+
const red = Number.parseInt(value.slice(0, 2), 16);
|
|
15
|
+
const green = Number.parseInt(value.slice(2, 4), 16);
|
|
16
|
+
const blue = Number.parseInt(value.slice(4, 6), 16);
|
|
17
|
+
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function drawGeometry(ctx: CanvasRenderingContext2D, frame: FramePreset, size: number, inset = 0): void {
|
|
21
|
+
const geometry = getFrameGeometry(frame.shape);
|
|
22
|
+
ctx.beginPath();
|
|
23
|
+
if (geometry.kind === 'circle') {
|
|
24
|
+
const radius = size * 0.45 - inset;
|
|
25
|
+
ctx.arc(size / 2, size / 2, radius, 0, Math.PI * 2);
|
|
26
|
+
} else {
|
|
27
|
+
geometry.points?.forEach((point, index) => {
|
|
28
|
+
const x = point.x * size;
|
|
29
|
+
const y = point.y * size;
|
|
30
|
+
if (index === 0) ctx.moveTo(x, y);
|
|
31
|
+
else ctx.lineTo(x, y);
|
|
32
|
+
});
|
|
33
|
+
ctx.closePath();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function drawStageGrid(ctx: CanvasRenderingContext2D, size: number): void {
|
|
38
|
+
ctx.fillStyle = '#111720';
|
|
39
|
+
ctx.fillRect(0, 0, size, size);
|
|
40
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.035)';
|
|
41
|
+
ctx.lineWidth = 1;
|
|
42
|
+
for (let offset = 0; offset <= size; offset += size / 8) {
|
|
43
|
+
ctx.beginPath();
|
|
44
|
+
ctx.moveTo(offset, 0);
|
|
45
|
+
ctx.lineTo(offset, size);
|
|
46
|
+
ctx.moveTo(0, offset);
|
|
47
|
+
ctx.lineTo(size, offset);
|
|
48
|
+
ctx.stroke();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function drawPlaceholder(ctx: CanvasRenderingContext2D, size: number): void {
|
|
53
|
+
ctx.fillStyle = '#273242';
|
|
54
|
+
ctx.fillRect(size * 0.26, size * 0.28, size * 0.48, size * 0.44);
|
|
55
|
+
ctx.strokeStyle = '#d89555';
|
|
56
|
+
ctx.lineWidth = 5;
|
|
57
|
+
ctx.strokeRect(size * 0.31, size * 0.33, size * 0.38, size * 0.34);
|
|
58
|
+
ctx.fillStyle = '#fff4d6';
|
|
59
|
+
ctx.font = '700 30px Georgia, serif';
|
|
60
|
+
ctx.textAlign = 'center';
|
|
61
|
+
ctx.fillText('DROP', size / 2, size * 0.49);
|
|
62
|
+
ctx.font = '500 17px Inter, sans-serif';
|
|
63
|
+
ctx.fillStyle = '#b9c5d6';
|
|
64
|
+
ctx.fillText('your character art', size / 2, size * 0.56);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function drawImage(ctx: CanvasRenderingContext2D, image: HTMLImageElement | null, state: TokenStampState, size: number): void {
|
|
68
|
+
if (!image || !image.complete || image.naturalWidth === 0) {
|
|
69
|
+
drawPlaceholder(ctx, size);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const sourceRatio = image.naturalWidth / image.naturalHeight;
|
|
73
|
+
const box = size * 0.9 * state.imageZoom;
|
|
74
|
+
const width = sourceRatio >= 1 ? box : box * sourceRatio;
|
|
75
|
+
const height = sourceRatio >= 1 ? box / sourceRatio : box;
|
|
76
|
+
const x = (size - width) / 2 + state.imageX * size;
|
|
77
|
+
const y = (size - height) / 2 + state.imageY * size;
|
|
78
|
+
ctx.drawImage(image, x, y, width, height);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function drawTextLayers(ctx: CanvasRenderingContext2D, state: TokenStampState, size: number): void {
|
|
82
|
+
state.texts.forEach((text) => {
|
|
83
|
+
ctx.save();
|
|
84
|
+
ctx.fillStyle = state.textColor;
|
|
85
|
+
ctx.strokeStyle = 'rgba(17, 23, 32, 0.85)';
|
|
86
|
+
ctx.lineWidth = Math.max(2, text.size / 16);
|
|
87
|
+
ctx.font = `700 ${text.size}px Georgia, serif`;
|
|
88
|
+
ctx.textAlign = text.align;
|
|
89
|
+
ctx.textBaseline = 'middle';
|
|
90
|
+
ctx.strokeText(text.text, text.x * size, text.y * size, size * 0.82);
|
|
91
|
+
ctx.fillText(text.text, text.x * size, text.y * size, size * 0.82);
|
|
92
|
+
if (text.id === state.selectedTextId) {
|
|
93
|
+
ctx.setLineDash([8, 6]);
|
|
94
|
+
ctx.strokeStyle = '#fff4d6';
|
|
95
|
+
ctx.lineWidth = 2;
|
|
96
|
+
ctx.strokeRect(text.x * size - size * 0.25, text.y * size - text.size * 0.7, size * 0.5, text.size * 1.4);
|
|
97
|
+
}
|
|
98
|
+
ctx.restore();
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function drawFrame(ctx: CanvasRenderingContext2D, frame: FramePreset, state: TokenStampState, size: number): void {
|
|
103
|
+
ctx.save();
|
|
104
|
+
if (frame.shape === 'star') ctx.lineJoin = 'round';
|
|
105
|
+
drawGeometry(ctx, frame, size, state.borderWidth * 0.65);
|
|
106
|
+
ctx.strokeStyle = hexToRgba(state.border, state.borderOpacity * 0.38);
|
|
107
|
+
ctx.lineWidth = state.borderWidth + 22;
|
|
108
|
+
ctx.stroke();
|
|
109
|
+
drawGeometry(ctx, frame, size, state.borderWidth * 0.65);
|
|
110
|
+
ctx.strokeStyle = hexToRgba(state.border, state.borderOpacity);
|
|
111
|
+
ctx.lineWidth = state.borderWidth;
|
|
112
|
+
ctx.stroke();
|
|
113
|
+
drawGeometry(ctx, frame, size, state.borderWidth * 0.92);
|
|
114
|
+
ctx.strokeStyle = 'rgba(17, 23, 32, 0.45)';
|
|
115
|
+
ctx.lineWidth = 3;
|
|
116
|
+
ctx.stroke();
|
|
117
|
+
ctx.restore();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function drawFrameIcon(canvas: HTMLCanvasElement, frame: FramePreset, state: TokenStampState): void {
|
|
121
|
+
const size = 96;
|
|
122
|
+
canvas.width = size;
|
|
123
|
+
canvas.height = size;
|
|
124
|
+
const ctx = canvas.getContext('2d');
|
|
125
|
+
if (!ctx) return;
|
|
126
|
+
ctx.clearRect(0, 0, size, size);
|
|
127
|
+
ctx.fillStyle = state.background;
|
|
128
|
+
ctx.fillRect(0, 0, size, size);
|
|
129
|
+
drawFrame(ctx, frame, { ...state, borderWidth: 7 }, size);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function drawTokenCanvas(canvas: HTMLCanvasElement, state: TokenStampState, image: HTMLImageElement | null, includeStageGrid = true): void {
|
|
133
|
+
const size = 640;
|
|
134
|
+
canvas.width = size;
|
|
135
|
+
canvas.height = size;
|
|
136
|
+
const ctx = canvas.getContext('2d');
|
|
137
|
+
if (!ctx) return;
|
|
138
|
+
const frame = getFramePreset(state.frameId);
|
|
139
|
+
if (includeStageGrid) drawStageGrid(ctx, size);
|
|
140
|
+
else ctx.clearRect(0, 0, size, size);
|
|
141
|
+
ctx.save();
|
|
142
|
+
drawGeometry(ctx, frame, size);
|
|
143
|
+
ctx.clip();
|
|
144
|
+
ctx.fillStyle = state.background;
|
|
145
|
+
ctx.fillRect(0, 0, size, size);
|
|
146
|
+
drawImage(ctx, image, state, size);
|
|
147
|
+
if (state.overlayOpacity > 0) {
|
|
148
|
+
ctx.fillStyle = hexToRgba(state.overlay, state.overlayOpacity);
|
|
149
|
+
ctx.fillRect(0, 0, size, size);
|
|
150
|
+
}
|
|
151
|
+
ctx.restore();
|
|
152
|
+
drawFrame(ctx, frame, state, size);
|
|
153
|
+
drawTextLayers(ctx, state, size);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function renderFrameGallery(container: HTMLElement, frames: FramePreset[], selectedId: string): void {
|
|
157
|
+
container.replaceChildren();
|
|
158
|
+
frames.forEach((frame) => {
|
|
159
|
+
const button = document.createElement('button');
|
|
160
|
+
button.type = 'button';
|
|
161
|
+
button.className = `frame-choice${frame.id === selectedId ? ' is-selected' : ''}`;
|
|
162
|
+
button.dataset.frameId = frame.id;
|
|
163
|
+
button.setAttribute('aria-pressed', String(frame.id === selectedId));
|
|
164
|
+
button.title = frame.description;
|
|
165
|
+
const swatch = document.createElement('canvas');
|
|
166
|
+
swatch.className = 'frame-swatch';
|
|
167
|
+
swatch.setAttribute('aria-hidden', 'true');
|
|
168
|
+
drawFrameIcon(swatch, frame, { frameId: frame.id, background: '#111720', border: frame.accent, borderWidth: 7, borderOpacity: 1, overlay: frame.accent, overlayOpacity: 0, textColor: '#fff4d6', imageSrc: null, imageName: '', imageX: 0, imageY: 0, imageZoom: 1, scale: 1, texts: [], selectedTextId: null });
|
|
169
|
+
const name = document.createElement('span');
|
|
170
|
+
name.textContent = frame.shortName;
|
|
171
|
+
button.append(swatch, name);
|
|
172
|
+
container.append(button);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function renderTextLayers(container: HTMLElement, texts: TextLayer[], selectedId: string | null, emptyText: string): void {
|
|
177
|
+
container.replaceChildren();
|
|
178
|
+
if (texts.length === 0) {
|
|
179
|
+
const empty = document.createElement('p');
|
|
180
|
+
empty.className = 'text-empty';
|
|
181
|
+
empty.textContent = emptyText;
|
|
182
|
+
container.append(empty);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
texts.forEach((text) => {
|
|
186
|
+
const button = document.createElement('button');
|
|
187
|
+
button.type = 'button';
|
|
188
|
+
button.className = `text-layer-choice${text.id === selectedId ? ' is-selected' : ''}`;
|
|
189
|
+
button.dataset.textId = text.id;
|
|
190
|
+
button.textContent = text.text || 'Untitled label';
|
|
191
|
+
container.append(button);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
interface LibraryLabels {
|
|
196
|
+
emptyText: string;
|
|
197
|
+
reuseText: string;
|
|
198
|
+
deleteText: string;
|
|
199
|
+
newText: string;
|
|
200
|
+
activeId: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function createMarkerPreview(marker: SavedMarker): HTMLCanvasElement {
|
|
204
|
+
const preview = document.createElement('canvas');
|
|
205
|
+
preview.className = 'marker-card-preview';
|
|
206
|
+
const drawPreview = (image: HTMLImageElement | null) => {
|
|
207
|
+
if (image?.complete && image.naturalWidth > 0) drawTokenCanvas(preview, marker.state, image, false);
|
|
208
|
+
else drawFrameIcon(preview, getFramePreset(marker.state.frameId), { ...marker.state, borderWidth: Math.max(5, marker.state.borderWidth / 2) });
|
|
209
|
+
};
|
|
210
|
+
drawPreview(null);
|
|
211
|
+
if (marker.state.imageSrc) {
|
|
212
|
+
const image = new Image();
|
|
213
|
+
image.addEventListener('load', () => drawPreview(image), { once: true });
|
|
214
|
+
image.src = marker.state.imageSrc;
|
|
215
|
+
}
|
|
216
|
+
return preview;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function createMarkerCard(marker: SavedMarker, labels: LibraryLabels): HTMLElement {
|
|
220
|
+
const card = document.createElement('article'); card.className = `marker-card${marker.id === labels.activeId ? ' is-active' : ''}`; card.dataset.markerId = marker.id;
|
|
221
|
+
const button = document.createElement('button'); button.type = 'button';
|
|
222
|
+
button.className = 'marker-card-main';
|
|
223
|
+
button.dataset.markerId = marker.id;
|
|
224
|
+
button.setAttribute('aria-label', `${labels.reuseText}: ${marker.name}`);
|
|
225
|
+
if (marker.id === labels.activeId) button.setAttribute('aria-current', 'true');
|
|
226
|
+
const preview = createMarkerPreview(marker);
|
|
227
|
+
const image = document.createElement('img');
|
|
228
|
+
image.alt = marker.name;
|
|
229
|
+
image.loading = 'lazy';
|
|
230
|
+
if (marker.state.imageSrc) image.src = marker.state.imageSrc;
|
|
231
|
+
preview.append(image);
|
|
232
|
+
const details = document.createElement('span');
|
|
233
|
+
details.className = 'marker-card-details';
|
|
234
|
+
const name = document.createElement('strong');
|
|
235
|
+
name.textContent = marker.name;
|
|
236
|
+
const date = document.createElement('small');
|
|
237
|
+
date.textContent = new Date(marker.updatedAt).toLocaleDateString();
|
|
238
|
+
details.append(name, date);
|
|
239
|
+
button.append(preview, details);
|
|
240
|
+
const remove = document.createElement('button');
|
|
241
|
+
remove.type = 'button';
|
|
242
|
+
remove.className = 'marker-card-delete';
|
|
243
|
+
remove.dataset.deleteMarker = marker.id;
|
|
244
|
+
remove.setAttribute('aria-label', `${labels.deleteText}: ${marker.name}`);
|
|
245
|
+
remove.textContent = 'x';
|
|
246
|
+
card.append(button, remove);
|
|
247
|
+
return card;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function renderMarkerLibrary(container: HTMLElement, markers: SavedMarker[], labels: LibraryLabels): void {
|
|
251
|
+
container.replaceChildren();
|
|
252
|
+
const add = document.createElement('button');
|
|
253
|
+
add.type = 'button';
|
|
254
|
+
add.className = 'marker-card marker-card-new';
|
|
255
|
+
add.dataset.newMarker = 'true';
|
|
256
|
+
add.textContent = `+ ${labels.newText}`;
|
|
257
|
+
container.append(add);
|
|
258
|
+
if (markers.length === 0) {
|
|
259
|
+
const empty = document.createElement('p');
|
|
260
|
+
empty.className = 'library-empty';
|
|
261
|
+
empty.textContent = labels.emptyText;
|
|
262
|
+
container.append(empty);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
markers.forEach((marker) => container.append(createMarkerCard(marker, labels)));
|
|
266
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { TabletopToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { TokenStampUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type { TokenStampUI } from './ui';
|
|
5
|
+
export type TokenStampLocaleContent = ToolLocaleContent<TokenStampUI>;
|
|
6
|
+
|
|
7
|
+
export const tokenStampStudio: TabletopToolEntry<TokenStampUI> = {
|
|
8
|
+
id: 'token-stamp-studio',
|
|
9
|
+
icons: { bg: 'mdi:account-circle-outline', fg: 'mdi:account-circle' },
|
|
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,6 @@
|
|
|
1
|
+
import { getStatus } from './logic';
|
|
2
|
+
import type { TokenStampState, TokenStampStatus } from './types';
|
|
3
|
+
|
|
4
|
+
export function evaluateTokenState(state: TokenStampState, ui: { statusReady: string; statusAddImage: string; statusAddText: string }): TokenStampStatus {
|
|
5
|
+
return getStatus(state, ui);
|
|
6
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
|
|
2
|
+
import { bibliography } from '../bibliography';
|
|
3
|
+
import type { TokenStampLocaleContent, TokenStampUI } from '../entry';
|
|
4
|
+
|
|
5
|
+
const ui: TokenStampUI = {
|
|
6
|
+
frameLegend: 'Wähle einen Rahmen',
|
|
7
|
+
frameHint: 'Wähle die Silhouette, die der Gruppe zeigt, wer dieser Charakter ist, bevor jemand den Namen liest.',
|
|
8
|
+
randomFrame: 'Zufälliger Rahmen',
|
|
9
|
+
surfaceLegend: 'Gestalte den Token',
|
|
10
|
+
backgroundLabel: 'Hintergrund',
|
|
11
|
+
borderLabel: 'Rahmen',
|
|
12
|
+
textLabel: 'Beschriftung',
|
|
13
|
+
overlayLabel: 'Tönung',
|
|
14
|
+
randomColors: 'Zufällige Farben',
|
|
15
|
+
borderWidthLabel: 'Rahmenstärke',
|
|
16
|
+
opacityLabel: 'Rahmentransparenz',
|
|
17
|
+
overlayOpacityLabel: 'Porträttönung',
|
|
18
|
+
stageLabel: 'Live Token Schmiede',
|
|
19
|
+
chooseImage: 'Porträt wählen',
|
|
20
|
+
clearImage: 'Porträt entfernen',
|
|
21
|
+
positionHint: 'Ziehe das Porträt oder Beschriftungen direkt auf den Token.',
|
|
22
|
+
markerName: 'Marker Name',
|
|
23
|
+
markerNamePlaceholder: 'Marker benennen',
|
|
24
|
+
textLegend: 'Füge lesbare Details hinzu',
|
|
25
|
+
textHint: 'Verwende kurze Angaben wie Name, Stufe, Rolle oder Zustand. Ziehe jede Beschriftung auf den Token.',
|
|
26
|
+
textPlaceholder: 'Charaktername oder Rolle',
|
|
27
|
+
addText: 'Beschriftung hinzufügen',
|
|
28
|
+
removeText: 'Beschriftung entfernen',
|
|
29
|
+
textSizeLabel: 'Schriftgröße',
|
|
30
|
+
alignmentLabel: 'Textausrichtung',
|
|
31
|
+
alignLeft: 'Links ausrichten',
|
|
32
|
+
alignCenter: 'Zentrieren',
|
|
33
|
+
alignRight: 'Rechts ausrichten',
|
|
34
|
+
imageLegend: 'Porträt anpassen',
|
|
35
|
+
imageZoomLabel: 'Porträt Zoom',
|
|
36
|
+
scaleLabel: 'Export Skalierung',
|
|
37
|
+
savedMarkers: 'Gespeicherte Marker',
|
|
38
|
+
noSavedMarkers: 'Gespeicherte Marker erscheinen hier, um sie erneut zu öffnen, zu bearbeiten und wiederzuverwenden.',
|
|
39
|
+
reuseMarker: 'Wähle einen Marker zum erneuten Öffnen.',
|
|
40
|
+
deleteMarker: 'Marker löschen',
|
|
41
|
+
newMarker: 'Neuer Marker',
|
|
42
|
+
download: 'PNG herunterladen',
|
|
43
|
+
copy: 'PNG kopieren',
|
|
44
|
+
downloadBatch: 'Stapel herunterladen',
|
|
45
|
+
batchLabel: 'Erweiterter Stapel Export',
|
|
46
|
+
batchHint: 'Optional: Exportiere mehrere Porträts mit den aktuellen Rahmen und Farbeinstellungen.',
|
|
47
|
+
chooseBatch: 'Porträts auswählen',
|
|
48
|
+
batchReady: '{count} Porträts bereit zum Schmieden.',
|
|
49
|
+
noImage: 'Kein Porträt geladen',
|
|
50
|
+
noText: 'Noch keine Beschriftungen. Füge Details hinzu, die deine Gruppe benötigt.',
|
|
51
|
+
tokenDetails: 'Charakter Token Steuerung',
|
|
52
|
+
exportHint: 'Das PNG ist außerhalb der gewählten Silhouette transparent, bereit für VTT oder Druckbögen.',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const faq = [
|
|
56
|
+
{
|
|
57
|
+
question: 'Was macht ein gutes Charakter Token Porträt aus?',
|
|
58
|
+
answer: 'Wähle ein Bild mit gut erkennbarem Gesicht oder starker Silhouette und ausreichend Kontrast zum Rahmen. Ein Kopf und Schultern Ausschnitt bleibt auch bei kleiner Darstellung auf dem VTT gut lesbar.',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
question: 'Wie platziere ich das Porträt optimal im Rahmen?',
|
|
62
|
+
answer: 'Lade das Bild hoch und ziehe es direkt auf dem Token an die richtige Position. Nutze den Porträt Zoom, um das Gesicht zu vergrößern, ohne den Rahmen zu verändern.',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
question: 'Kann ich Charaktername und Stufe hinzufügen?',
|
|
66
|
+
answer: 'Ja. Füge so viele kurze Beschriftungen hinzu wie nötig, wähle sie aus, passe Schriftgröße und Ausrichtung an und platziere sie gut lesbar auf dem Token.',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
question: 'Bleibt das PNG außerhalb des Tokens transparent?',
|
|
70
|
+
answer: 'Ja. Das exportierte Bild behält den Bereich außerhalb der Silhouette transparent, sodass der Marker perfekt auf Spielplänen oder Druckbögen platziert werden kann.',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
question: 'Kann ich eine ganze Abenteurergruppe auf einmal erstellen?',
|
|
74
|
+
answer: 'Ja. Lade mehrere Porträts im Stapel Export hoch, verwende dieselben Rahmen und Farbeinstellungen und lade für jedes Porträt ein transparentes PNG herunter.',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
question: 'Wo werden meine gespeicherten Marker abgelegt?',
|
|
78
|
+
answer: 'Das Speichern auf diesem Gerät sichert den Marker im lokalen Browserspeicher. Es werden keine Bilder hochgeladen. Das Löschen von Browserdaten entfernt auch die lokale Kopie.',
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const howTo = [
|
|
83
|
+
{ name: 'Wähle die Charakter Silhouette', text: 'Starte mit einem Rahmen, der zum Charakter passt: Kreis für klassische Helden, Sechseck für taktische Spiele oder Stern für Bosse und Champions.' },
|
|
84
|
+
{ name: 'Porträt laden und ausrichten', text: 'Wähle ein Bild aus und ziehe es auf den Live Token. Positioniere das Gesicht mittig im Rahmen und passe den Zoom an.' },
|
|
85
|
+
{ name: 'Wichtige Details hinzufügen', text: 'Ergänze kurze Beschriftungen für Name, Stufe oder Status. Halte den Text kurz, damit das Bild gut erkennbar bleibt.' },
|
|
86
|
+
{ name: 'Speichern und als PNG exportieren', text: 'Der Marker wird automatisch gespeichert. Lade ein transparentes PNG für dein Virtual Tabletop oder den Druckbogen herunter.' },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const faqSchema: WithContext<FAQPage> = {
|
|
90
|
+
'@context': 'https://schema.org',
|
|
91
|
+
'@type': 'FAQPage',
|
|
92
|
+
mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const appSchema: WithContext<SoftwareApplication> = {
|
|
96
|
+
'@context': 'https://schema.org',
|
|
97
|
+
'@type': 'SoftwareApplication',
|
|
98
|
+
name: 'Token Stamp Studio',
|
|
99
|
+
operatingSystem: 'All',
|
|
100
|
+
applicationCategory: 'DesignApplication',
|
|
101
|
+
description: 'Erstelle gut lesbare Charakter Tokens für Pen and Paper Rollenspiele mit individuellen Rahmen, Beschriftungen, Transparenz und Stapel Export.',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const howToSchema: WithContext<HowTo> = {
|
|
105
|
+
'@context': 'https://schema.org',
|
|
106
|
+
'@type': 'HowTo',
|
|
107
|
+
name: 'So erstellst du ein Charakter Token für Rollenspiele',
|
|
108
|
+
step: howTo.map((item) => ({ '@type': 'HowToStep', name: item.name, text: item.text })),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const content: TokenStampLocaleContent = {
|
|
112
|
+
slug: 'character-token-maker-rpg-rollenspiel',
|
|
113
|
+
title: 'Charakter Token Ersteller für Pen and Paper Rollenspiele',
|
|
114
|
+
description: 'Erstelle gut lesbare Charakter Tokens und Marker aus Porträts mit ausdrucksstarken Rahmen, verschiebbaren Ausschnitten, Beschriftungen und transparentem PNG Export.',
|
|
115
|
+
ui,
|
|
116
|
+
seo: [
|
|
117
|
+
{ type: 'title', text: 'Gestalte deutliche Charakter Tokens für jede Kartengröße', level: 2 },
|
|
118
|
+
{ type: 'paragraph', html: 'Ein Token ist ein visueller Signalgeber am Spieltisch. Seine Silhouette verrät der Gruppe sofort, ob es sich um einen Helden, ein Monster oder ein wichtiges Ziel handelt. Wähle den passenden Rahmen und halte Gesicht und Text auch bei kleiner Darstellung auf dem VTT gut lesbar.' },
|
|
119
|
+
{ type: 'title', text: 'Der richtige Rahmen für jeden Charaktertyp', level: 2 },
|
|
120
|
+
{ type: 'list', items: ['<strong>Kreis oder Ring:</strong> bewährt für Spielercharaktere und Verbündete.', '<strong>Sechseck oder Achteck:</strong> ideal zur Unterscheidung in taktischen Gefechten mit vielen Einheiten.', '<strong>Stern:</strong> perfekt für Bosse, Champions oder Schlüsselpersonen.', '<strong>Wolke:</strong> sanfte Form für Geister, Vertraute und Fabelwesen.'] },
|
|
121
|
+
{ type: 'tip', title: 'Entwirf Tokens für die kleinste Spielansicht', html: 'Zoome auf der Karte heraus, bis der Token nur noch wenige Pixel klein ist. Wenn Gesicht, Rahmen und Text weiterhin gut trennbar sind, bleibt der Marker im Spielgeschehen optimal nutzbar.' },
|
|
122
|
+
{ type: 'title', text: 'Kurze und nützliche Beschriftungen am Spieltisch', level: 2 },
|
|
123
|
+
{ type: 'paragraph', html: 'Ein Token ersetzt keinen Charakterbogen. Ergänze nur die ein oder zwei Informationen, die am Tisch ständig gebraucht werden: Name, Stufe oder Status wie benommen. Lange Texte lenken nur vom Porträt ab.' },
|
|
124
|
+
{ type: 'title', text: 'Erstelle ganze Abenteurergruppen mit einheitlichem Stil', level: 2 },
|
|
125
|
+
{ type: 'paragraph', html: 'Der Stapel Export eignet sich hervorragend nach einer Session Zero. Nutze einen gemeinsamen Rahmenstil für die Gruppe, während jedes Gesicht individuell bleibt. Das sorgt für visuellen Zusammenhalt bei klarer Unterscheidbarkeit.' },
|
|
126
|
+
{ type: 'tip', title: 'Automatische Speicherung auf deinem Gerät', html: 'Das lokale Speichern behält den Ausschnitt und die Beschriftung auf deinem Gerät. Exportierte PNGs nutzt du im Spiel; der gespeicherte Marker bleibt deine bearbeitbare Vorlage.' },
|
|
127
|
+
],
|
|
128
|
+
faq,
|
|
129
|
+
bibliography,
|
|
130
|
+
howTo,
|
|
131
|
+
schemas: [faqSchema, appSchema, howToSchema] as unknown as Record<string, unknown>[],
|
|
132
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
|
|
2
|
+
import { bibliography } from '../bibliography';
|
|
3
|
+
import type { TokenStampLocaleContent, TokenStampUI } from '../entry';
|
|
4
|
+
|
|
5
|
+
const ui: TokenStampUI = {
|
|
6
|
+
frameLegend: 'Choose a frame',
|
|
7
|
+
frameHint: 'Pick the silhouette that tells the table who this character is before anyone reads the name.',
|
|
8
|
+
randomFrame: 'Random frame',
|
|
9
|
+
surfaceLegend: 'Paint the token',
|
|
10
|
+
backgroundLabel: 'Backdrop',
|
|
11
|
+
borderLabel: 'Frame',
|
|
12
|
+
textLabel: 'Lettering',
|
|
13
|
+
overlayLabel: 'Tint',
|
|
14
|
+
randomColors: 'Random colours',
|
|
15
|
+
borderWidthLabel: 'Frame weight',
|
|
16
|
+
opacityLabel: 'Frame opacity',
|
|
17
|
+
overlayOpacityLabel: 'Portrait tint',
|
|
18
|
+
stageLabel: 'Live token forge',
|
|
19
|
+
chooseImage: 'Choose portrait',
|
|
20
|
+
clearImage: 'Clear portrait',
|
|
21
|
+
positionHint: 'Drag the portrait or a label directly on the token.',
|
|
22
|
+
markerName: 'Marker name',
|
|
23
|
+
markerNamePlaceholder: 'Name this marker',
|
|
24
|
+
textLegend: 'Add table readable details',
|
|
25
|
+
textHint: 'Use short labels such as a name, level, role, or condition. Drag each one on the token.',
|
|
26
|
+
textPlaceholder: 'Character name or role',
|
|
27
|
+
addText: 'Add label',
|
|
28
|
+
removeText: 'Remove label',
|
|
29
|
+
textSizeLabel: 'Letter size',
|
|
30
|
+
alignmentLabel: 'Label alignment',
|
|
31
|
+
alignLeft: 'Align label left',
|
|
32
|
+
alignCenter: 'Align label center',
|
|
33
|
+
alignRight: 'Align label right',
|
|
34
|
+
imageLegend: 'Tune the portrait',
|
|
35
|
+
imageZoomLabel: 'Portrait zoom',
|
|
36
|
+
scaleLabel: 'Export scale',
|
|
37
|
+
savedMarkers: 'Saved markers',
|
|
38
|
+
noSavedMarkers: 'Saved markers will appear here so you can reopen, edit, and reuse them for another scene.',
|
|
39
|
+
reuseMarker: 'Select a marker to reopen it in the forge.',
|
|
40
|
+
deleteMarker: 'Delete marker',
|
|
41
|
+
newMarker: 'New marker',
|
|
42
|
+
download: 'Download PNG',
|
|
43
|
+
copy: 'Copy PNG',
|
|
44
|
+
downloadBatch: 'Download batch',
|
|
45
|
+
batchLabel: 'Advanced batch export',
|
|
46
|
+
batchHint: 'Optional: export several portraits with the current frame and paint settings.',
|
|
47
|
+
chooseBatch: 'Choose portraits',
|
|
48
|
+
batchReady: '{count} portraits ready to forge.',
|
|
49
|
+
noImage: 'No portrait loaded',
|
|
50
|
+
noText: 'No labels yet. Add the detail your group needs.',
|
|
51
|
+
tokenDetails: 'Character token controls',
|
|
52
|
+
exportHint: 'The PNG is transparent outside the chosen silhouette, ready for a virtual tabletop or print sheet.',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const faq = [
|
|
56
|
+
{
|
|
57
|
+
question: 'What makes a good character token portrait?',
|
|
58
|
+
answer: 'Choose art with a clear face or strong silhouette and enough contrast against the frame. A head and shoulders crop usually stays readable when the token is small on a virtual tabletop.',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
question: 'How do I position a portrait inside the frame?',
|
|
62
|
+
answer: 'Drop in the image, then drag directly on the token to move the crop. Use Portrait zoom to bring a face forward without changing the frame or labels.',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
question: 'Can I add a character name and level?',
|
|
66
|
+
answer: 'Yes. Add as many short labels as you need, select each label in the inspector, change its size and alignment, and drag it to a readable position on the token.',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
question: 'Does the PNG keep transparency outside the token?',
|
|
70
|
+
answer: 'Yes. The exported image keeps the outside of the selected silhouette transparent, which makes it easy to place the marker over a map or print a sheet of tokens.',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
question: 'Can I create a whole party at once?',
|
|
74
|
+
answer: 'Yes. Load several portraits in Batch forge, keep the current frame and paint settings, then download one PNG per portrait. Each file uses its source filename.',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
question: 'Where is my saved marker stored?',
|
|
78
|
+
answer: 'Save on this device keeps the current marker in local browser storage. It never uploads the portrait, but clearing site data or using another device removes that local copy.',
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const howTo = [
|
|
83
|
+
{ name: 'Choose the character silhouette', text: 'Start with the frame that matches the character mood: ring for a classic hero, hex for a tactical game, star for a champion, or a softer shape for a spirit or fey creature.' },
|
|
84
|
+
{ name: 'Drop in and frame the portrait', text: 'Choose a portrait or drag it onto the live token. Drag the image until the face or defining silhouette sits inside the frame, then adjust Portrait zoom.' },
|
|
85
|
+
{ name: 'Add only the information the table needs', text: 'Add a short name, role, level, or condition. Keep labels brief, choose a readable size, and drag them so the artwork remains visible.' },
|
|
86
|
+
{ name: 'Reuse and export for play', text: 'Your active marker saves as you work. Reopen it from Saved markers, or download a transparent PNG for your virtual tabletop, print sheet, or campaign handout.' },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const faqSchema: WithContext<FAQPage> = {
|
|
90
|
+
'@context': 'https://schema.org',
|
|
91
|
+
'@type': 'FAQPage',
|
|
92
|
+
mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const appSchema: WithContext<SoftwareApplication> = {
|
|
96
|
+
'@context': 'https://schema.org',
|
|
97
|
+
'@type': 'SoftwareApplication',
|
|
98
|
+
name: 'Token Stamp Studio',
|
|
99
|
+
operatingSystem: 'All',
|
|
100
|
+
applicationCategory: 'DesignApplication',
|
|
101
|
+
description: 'Create readable character tokens for tabletop RPG maps with custom silhouettes, portraits, labels, transparency, and batch PNG export.',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const howToSchema: WithContext<HowTo> = {
|
|
105
|
+
'@context': 'https://schema.org',
|
|
106
|
+
'@type': 'HowTo',
|
|
107
|
+
name: 'How to make a tabletop character token',
|
|
108
|
+
step: howTo.map((item) => ({ '@type': 'HowToStep', name: item.name, text: item.text })),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const content: TokenStampLocaleContent = {
|
|
112
|
+
slug: 'token-stamp-studio',
|
|
113
|
+
title: 'Character Token Maker for Tabletop RPGs',
|
|
114
|
+
description: 'Forge readable character markers from portraits with expressive frames, draggable crops, labels, transparency, local saving, and batch PNG export.',
|
|
115
|
+
ui,
|
|
116
|
+
seo: [
|
|
117
|
+
{ type: 'title', text: 'Make a Character Token That Reads Across the Map', level: 2 },
|
|
118
|
+
{ type: 'paragraph', html: 'A token is a piece of table language. Its silhouette tells the group whether they are looking at a hero, a monster, a spell effect, or a named location before anyone zooms in. Start with the frame that matches the character, then keep the portrait and label readable at the scale your virtual tabletop uses.' },
|
|
119
|
+
{ type: 'title', text: 'Choose the Right Frame for the Character', level: 2 },
|
|
120
|
+
{ type: 'list', items: ['<strong>Ring:</strong> dependable for player characters and recurring allies.', '<strong>Hex or octagon:</strong> easy to distinguish in tactical scenes with many units.', '<strong>Star:</strong> useful for a champion, boss, or character who needs instant attention.', '<strong>Cloud:</strong> a softer signal for spirits, familiars, and fey creatures.'] },
|
|
121
|
+
{ type: 'tip', title: 'Design for the smallest size you will actually play at', html: 'Zoom the map out until a token is only a few dozen pixels wide. If the face, border, and label still separate from one another, the marker will remain useful during a busy encounter.' },
|
|
122
|
+
{ type: 'title', text: 'Keep Labels Short and Useful During Play', level: 2 },
|
|
123
|
+
{ type: 'paragraph', html: 'A character token is not a character sheet. Add the one or two facts the table repeatedly needs: a short name, a level, a role such as healer, or a condition such as stunned. Long sentences become visual noise and compete with the portrait.' },
|
|
124
|
+
{ type: 'title', text: 'Prepare a Whole Party Without Losing Their Identity', level: 2 },
|
|
125
|
+
{ type: 'paragraph', html: 'Batch export is useful after a session zero or when a campaign gains new allies. Choose one frame and paint language for the group, then let each portrait stay individual. Consistent framing makes the party feel like one cast while the faces remain instantly distinct.' },
|
|
126
|
+
{ type: 'tip', title: 'Your marker saves as you work', html: 'Local saving keeps the editable portrait crop and labels on this device. Exported PNGs are the play copies; the saved marker is the version to reopen when the character changes.' },
|
|
127
|
+
],
|
|
128
|
+
faq,
|
|
129
|
+
bibliography,
|
|
130
|
+
howTo,
|
|
131
|
+
schemas: [faqSchema, appSchema, howToSchema] as unknown as Record<string, unknown>[],
|
|
132
|
+
};
|