@jjlmoya/utils-tabletop 1.26.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 +2 -0
- package/src/entries.ts +2 -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/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 +2 -0
|
@@ -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: 'Välj en ram',
|
|
7
|
+
frameHint: 'Välj den silhuett som visar gruppen vem karaktären är innan någon hinner läsa namnet.',
|
|
8
|
+
randomFrame: 'Slumpmässig ram',
|
|
9
|
+
surfaceLegend: 'Färglägg din token',
|
|
10
|
+
backgroundLabel: 'Bakgrund',
|
|
11
|
+
borderLabel: 'Ram',
|
|
12
|
+
textLabel: 'Text',
|
|
13
|
+
overlayLabel: 'Toning',
|
|
14
|
+
randomColors: 'Slumpmässiga färger',
|
|
15
|
+
borderWidthLabel: 'Ramtjocklek',
|
|
16
|
+
opacityLabel: 'Ramopacitet',
|
|
17
|
+
overlayOpacityLabel: 'Porträttoning',
|
|
18
|
+
stageLabel: 'Live token-smedja',
|
|
19
|
+
chooseImage: 'Välj porträtt',
|
|
20
|
+
clearImage: 'Ta bort porträtt',
|
|
21
|
+
positionHint: 'Dra porträttet eller etiketter direkt på din token.',
|
|
22
|
+
markerName: 'Markörnamn',
|
|
23
|
+
markerNamePlaceholder: 'Namnge denna markör',
|
|
24
|
+
textLegend: 'Lägg till läsbar information',
|
|
25
|
+
textHint: 'Använd korta etiketter som namn, nivå eller roll. Dra varje etikett på din token.',
|
|
26
|
+
textPlaceholder: 'Karaktärsnamn eller roll',
|
|
27
|
+
addText: 'Lägg till etikett',
|
|
28
|
+
removeText: 'Ta bort etikett',
|
|
29
|
+
textSizeLabel: 'Textstorlek',
|
|
30
|
+
alignmentLabel: 'Textjustering',
|
|
31
|
+
alignLeft: 'Vänsterjustera',
|
|
32
|
+
alignCenter: 'Centrera',
|
|
33
|
+
alignRight: 'Högerjustera',
|
|
34
|
+
imageLegend: 'Justera porträttet',
|
|
35
|
+
imageZoomLabel: 'Porträttzoom',
|
|
36
|
+
scaleLabel: 'Exportskala',
|
|
37
|
+
savedMarkers: 'Sparade markörer',
|
|
38
|
+
noSavedMarkers: 'Sparade markörer visas här så att du kan öppna och redigera dem igen.',
|
|
39
|
+
reuseMarker: 'Välj en markör för att öppna den igen.',
|
|
40
|
+
deleteMarker: 'Ta bort markör',
|
|
41
|
+
newMarker: 'Ny markör',
|
|
42
|
+
download: 'Ladda ner PNG',
|
|
43
|
+
copy: 'Kopiera PNG',
|
|
44
|
+
downloadBatch: 'Ladda ner batch',
|
|
45
|
+
batchLabel: 'Avancerad batchexport',
|
|
46
|
+
batchHint: 'Valfritt: exportera flera porträtt med nuvarande ram och färger.',
|
|
47
|
+
chooseBatch: 'Välj porträtt',
|
|
48
|
+
batchReady: '{count} porträtt redo att smidas.',
|
|
49
|
+
noImage: 'Inget porträtt laddat',
|
|
50
|
+
noText: 'Inga etiketter ännu. Lägg till informationen som din spelgrupp behöver.',
|
|
51
|
+
tokenDetails: 'Token-kontroller',
|
|
52
|
+
exportHint: 'PNG-filen har transparent bakgrund utanför den valda silhuetten, redo för VTT eller utskrift.',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const faq = [
|
|
56
|
+
{
|
|
57
|
+
question: 'Vad gör ett porträtt bra för en karaktärs-token?',
|
|
58
|
+
answer: 'Välj en bild med tydligt ansikte och bra kontrast mot ramen. En beskärning av huvud och axlar förblir lättläst på det virtuella spelbordet.',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
question: 'Hur placerar jag porträttet bäst i ramen?',
|
|
62
|
+
answer: 'Ladda upp bilden och dra den direkt på token för att centrera ansiktet. Använd porträttzoom för att förstora utan att ändra ramen.',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
question: 'Kan jag lägga till namnet och nivån på karaktären?',
|
|
66
|
+
answer: 'Ja. Lägg till korta etiketter, anpassa storlek och justering, och dra dem till en tydlig plats på din token.',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
question: 'Behåller PNG-filen sin transparens utanför ramen?',
|
|
70
|
+
answer: 'Ja. Den exporterade bilden har genomskinlig bakgrund utanför silhuetten, vilket gör den enkelt att placera över kartor.',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
question: 'Kan jag skapa tokens för hela spelgruppen samtidigt?',
|
|
74
|
+
answer: 'Ja. Använd batchexport för att ladda upp flera porträtt och ladda ner en transparent PNG för varje bild.',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
question: 'Var sparas mina markörer?',
|
|
78
|
+
answer: 'Markörerna sparas lokalt i din webbläsare. Inga bilder laddas upp till externa servrar.',
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const howTo = [
|
|
83
|
+
{ name: 'Välj karaktärens silhuett', text: 'Välj en ram som passar rollen: rund för hjältar, sexkantig för taktiska strider eller stjärna för bossar.' },
|
|
84
|
+
{ name: 'Ladda upp och centrera porträttet', text: 'Välj en bild, dra den till din token och justera zoomen så att ansiktet hamnar i mitten.' },
|
|
85
|
+
{ name: 'Lägg till viktig information', text: 'Skriv ett kort namn eller status utan att täcka över viktiga detaljer i bilden.' },
|
|
86
|
+
{ name: 'Exportera som transparent PNG', text: 'Ladda ner din transparenta PNG redo för digitala spelbord eller utskrift.' },
|
|
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: 'Skapa tydliga karaktärs-tokens för rollspel med anpassade ramar, etiketter, transparens och batchexport.',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const howToSchema: WithContext<HowTo> = {
|
|
105
|
+
'@context': 'https://schema.org',
|
|
106
|
+
'@type': 'HowTo',
|
|
107
|
+
name: 'Hur man skapar en karaktärs-token för rollspel',
|
|
108
|
+
step: howTo.map((item) => ({ '@type': 'HowToStep', name: item.name, text: item.text })),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const content: TokenStampLocaleContent = {
|
|
112
|
+
slug: 'karaktars-token-skapare-rpg-bordssport',
|
|
113
|
+
title: 'Skapa Karaktärs Tokens för Rollspel Online',
|
|
114
|
+
description: 'Skapa tydliga karaktärs-tokens för rollspel med anpassade ramar, etiketter, transparens och batchexport.',
|
|
115
|
+
ui,
|
|
116
|
+
seo: [
|
|
117
|
+
{ type: 'title', text: 'Skapa tydliga karaktärs-tokens för alla dina digitala spelkartor', level: 2 },
|
|
118
|
+
{ type: 'paragraph', html: 'En token är ett extremt viktigt visuellt hjälpmedel vid det digitala spelbordet. Silhuetten visar spelgruppen direkt om det rör sig om en hjälte, ett farligt monster, en förtrollningseffekt eller en viktig allierad innan någon ens hinner zooma in på kartan. Välj rätt ram och se till att både bild och text förblir perfekt läsbara på den storlek din virtuella spelplattform använder.' },
|
|
119
|
+
{ type: 'title', text: 'Välj rätt ram för varje unik karaktärstyp i kampanjen', level: 2 },
|
|
120
|
+
{ type: 'list', items: ['<strong>Rund eller ring:</strong> klassisk och pålitlig standard för spelarkaraktärer och återkommande allierade.', '<strong>Sexkant eller åttkant:</strong> mycket enkelt att skilja ut i taktiska strider med många olika enheter.', '<strong>Stjärna:</strong> utmärkt för bossar, mästare och viktiga mål som kräver omedelbar uppmärksamhet.', '<strong>Moln:</strong> en mjuk och stilren form för andar, familjärer och magiska varelser.'] },
|
|
121
|
+
{ type: 'tip', title: 'Designa alltid för den minsta spelstorleken du använder', html: 'Zooma ut på kartan tills din token blir bara ett tiotal pixlar bred. Om ansiktet, ramen och texten fortfarande syns tydligt kommer din token fungera utmärkt under intensiva strider.' },
|
|
122
|
+
{ type: 'title', text: 'Håll alla etiketter korta och praktiska under spelsessionen', level: 2 },
|
|
123
|
+
{ type: 'paragraph', html: 'En token ska aldrig ersätta ett fullständigt karaktärsblad. Lägg bara till den allra viktigaste informationen som gruppen ständigt behöver: ett kort namn, en nivå, en roll eller en status. För långa meningar skapar bara visuellt brus och täcker över bilden.' },
|
|
124
|
+
{ type: 'title', text: 'Skapa tokens för hela spelgruppen samtidigt utan att förlora deras stil', level: 2 },
|
|
125
|
+
{ type: 'paragraph', html: 'Batchexport gör det väldigt enkelt att ge hela gruppen samma visuella ramstil efter session noll medan varje enskilt porträtt behåller sin unika karaktär. En enhetlig inramning skapar sammanhållning i gruppen samtidigt som varje ansikte känns igen direkt.' },
|
|
126
|
+
{ type: 'tip', title: 'Automatisk lokal sparande direkt på din enhet', html: 'Din aktiva markör sparas automatiskt i webbläsaren så att du enkelt kan öppna och redigera den igen när karaktären går upp i nivå.' },
|
|
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: 'Bir çerçeve seçin',
|
|
7
|
+
frameHint: 'Kimse ismi okumadan önce bu karakterin kim olduğunu masaya gösteren silüeti seçin.',
|
|
8
|
+
randomFrame: 'Rastgele çerçeve',
|
|
9
|
+
surfaceLegend: 'Pulu renklendirin',
|
|
10
|
+
backgroundLabel: 'Arka plan',
|
|
11
|
+
borderLabel: 'Çerçeve',
|
|
12
|
+
textLabel: 'Yazı',
|
|
13
|
+
overlayLabel: 'Ton',
|
|
14
|
+
randomColors: 'Rastgele renkler',
|
|
15
|
+
borderWidthLabel: 'Çerçeve kalınlığı',
|
|
16
|
+
opacityLabel: 'Çerçeve saydamlığı',
|
|
17
|
+
overlayOpacityLabel: 'Portre tonu',
|
|
18
|
+
stageLabel: 'Canlı pul atölyesi',
|
|
19
|
+
chooseImage: 'Portre seçin',
|
|
20
|
+
clearImage: 'Portreyi kaldır',
|
|
21
|
+
positionHint: 'Portreyi veya etiketleri doğrudan pulun üzerine sürükleyin.',
|
|
22
|
+
markerName: 'İşaretçi adı',
|
|
23
|
+
markerNamePlaceholder: 'Bu işaretçiyi adlandırın',
|
|
24
|
+
textLegend: 'Okunabilir detaylar ekleyin',
|
|
25
|
+
textHint: 'İsim, seviye, rol gibi kısa etiketler kullanın. Her birini pulun üzerine sürükleyin.',
|
|
26
|
+
textPlaceholder: 'Karakter adı veya rolü',
|
|
27
|
+
addText: 'Etiket ekle',
|
|
28
|
+
removeText: 'Etiketi kaldır',
|
|
29
|
+
textSizeLabel: 'Yazı boyutu',
|
|
30
|
+
alignmentLabel: 'Yazı hizalaması',
|
|
31
|
+
alignLeft: 'Sola hizala',
|
|
32
|
+
alignCenter: 'Ortala',
|
|
33
|
+
alignRight: 'Sağa hizala',
|
|
34
|
+
imageLegend: 'Portreyi ayarlayın',
|
|
35
|
+
imageZoomLabel: 'Portre yakınlaştırma',
|
|
36
|
+
scaleLabel: 'Dışa aktarma ölçeği',
|
|
37
|
+
savedMarkers: 'Kaydedilen işaretçiler',
|
|
38
|
+
noSavedMarkers: 'Kaydedilen işaretçiler yeniden açıp düzenlemeniz için burada görünecektir.',
|
|
39
|
+
reuseMarker: 'Yeniden açmak için bir işaretçi seçin.',
|
|
40
|
+
deleteMarker: 'İşaretçiyi sil',
|
|
41
|
+
newMarker: 'Yeni işaretçi',
|
|
42
|
+
download: 'PNG indir',
|
|
43
|
+
copy: 'PNG kopyala',
|
|
44
|
+
downloadBatch: 'Toplu indir',
|
|
45
|
+
batchLabel: 'Gelişmiş toplu dışa aktarma',
|
|
46
|
+
batchHint: 'İsteğe bağlı: Mevcut çerçeve ve renk ayarlarıyla birden fazla portreyi dışa aktarın.',
|
|
47
|
+
chooseBatch: 'Portreleri seçin',
|
|
48
|
+
batchReady: '{count} portre hazırlanmaya hazır.',
|
|
49
|
+
noImage: 'Portre yüklenmedi',
|
|
50
|
+
noText: 'Henüz etiket yok. Grubunuzun ihtiyaç duyduğu detayları ekleyin.',
|
|
51
|
+
tokenDetails: 'Karakter pulu kontrolleri',
|
|
52
|
+
exportHint: 'PNG dosyası seçilen silüetin dışında şeffaftır, sanal masaüstü veya baskı için hazırdır.',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const faq = [
|
|
56
|
+
{
|
|
57
|
+
question: 'İyi bir karakter pulu portresi nasıl olmalıdır?',
|
|
58
|
+
answer: 'Net bir yüze ve çerçeveye göre yeterli kontrasta sahip bir görsel seçin. Baş ve omuz kesimi sanal masaüstünde pul küçüldüğünde bile okunabilir kalır.',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
question: 'Portreyi çerçeve içine en iyi şekilde nasıl yerleştiririm?',
|
|
62
|
+
answer: 'Görseli yükleyin ve yüzü ortalamak için doğrudan pul üzerine sürükleyin. Çerçeveyi değiştirmeden yüzü büyütmek için portre yakınlaştırmayı kullanın.',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
question: 'Karakter adı ve seviyesini ekleyebilir miyim?',
|
|
66
|
+
answer: 'Evet. İstediğiniz kadar kısa etiket ekleyin, boyutunu ve hizalamasını ayarlayın ve pul üzerinde okunabilir bir yere sürükleyin.',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
question: 'PNG dosyası çerçevenin dışında şeffaflığı korur mu?',
|
|
70
|
+
answer: 'Evet. Dışa aktarılan görsel seçilen silüetin dışını şeffaf tutar, bu da haritalar üzerine yerleştirmeyi kolaylaştırır.',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
question: 'Tüm grup için tek seferde pul oluşturabilir miyim?',
|
|
74
|
+
answer: 'Evet. Toplu dışa aktarmayı kullanarak birden fazla portre yükleyin ve her biri için şeffaf bir PNG indirin.',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
question: 'Kaydedilen işaretçilerim nerede saklanır?',
|
|
78
|
+
answer: 'İşaretçiler tarayıcınızın yerel depolama alanında saklanır. Hiçbir görsel harici sunuculara yüklenmez.',
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const howTo = [
|
|
83
|
+
{ name: 'Karakter silüetini seçin', text: 'Role uygun bir çerçeve seçin: kahramanlar için daire, taktik savaşlar için altıgen veya patronlar için yıldız.' },
|
|
84
|
+
{ name: 'Portreyi yükleyin ve ortalayın', text: 'Görseli seçin, pula sürükleyin ve yüz ortalanacak şekilde yakınlaştırmayı ayarlayın.' },
|
|
85
|
+
{ name: 'Önemli bilgileri ekleyin', text: 'Görseli kapatmayacak şekilde kısa bir isim veya durum bilgisi ekleyin.' },
|
|
86
|
+
{ name: 'Şeffaf PNG olarak dışa aktarın', text: 'Sanal masaüstünüzde (VTT) veya baskıda kullanmak üzere şeffaf PNG dosyasını indirin.' },
|
|
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: 'Masaüstü RPG oyunları için özel çerçeveli, etiketli, şeffaf ve toplu PNG indirmeli karakter pulları (tokens) oluşturun.',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const howToSchema: WithContext<HowTo> = {
|
|
105
|
+
'@context': 'https://schema.org',
|
|
106
|
+
'@type': 'HowTo',
|
|
107
|
+
name: 'Masaüstü RPG karakter pulu nasıl yapılır',
|
|
108
|
+
step: howTo.map((item) => ({ '@type': 'HowToStep', name: item.name, text: item.text })),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const content: TokenStampLocaleContent = {
|
|
112
|
+
slug: 'rpg-karakter-pulu-olusturucu-masaustu',
|
|
113
|
+
title: 'Masaüstü RPG Karakter Pulu Oluşturucu (Tokens)',
|
|
114
|
+
description: 'Masaüstü RPG oyunları için özel çerçeveli, etiketli, şeffaf ve toplu PNG indirmeli karakter pulları (tokens) oluşturun.',
|
|
115
|
+
ui,
|
|
116
|
+
seo: [
|
|
117
|
+
{ type: 'title', text: 'Oyun haritanızda son derece net görünen karakter pulları tasarlayın', level: 2 },
|
|
118
|
+
{ type: 'paragraph', html: 'Bir pul, oyun masasındaki en önemli görsel iletişim araçlarından biridir. Silüeti, tüm gruba henüz haritayı yakınlaştırmadan önce bir kahramanla mı, tehlikeli bir canavarla mı yoksa kilit bir müttefikle mi karşı karşıya olduklarını anında gösterir. Doğru çerçeveyi seçin ve sanal masaüstü platformunuzun kullandığı ölçekte hem görselin hem de yazıların mükemmel bir okunabilirlikte kalmasını sağlayın.' },
|
|
119
|
+
{ type: 'title', text: 'Karakter tipine ve rolüne en uygun çerçeveyi seçin', level: 2 },
|
|
120
|
+
{ type: 'list', items: ['<strong>Daire veya halka:</strong> oyuncu karakterleri ve sürekli müttefikler için standart ve güvenilir bir seçim.', '<strong>Altıgen veya sekizgen:</strong> çok birimli taktik savaşlarda birimleri birbirinden kolayca ayırmak için harikadır.', '<strong>Yıldız:</strong> patronlar, şampiyonlar veya hemen dikkat çekmesi gereken önemli hedefler için mükemmeldir.', '<strong>Bulut:</strong> ruhlar, büyülü yaratıklar ve mistik varlıklar için yumuşak bir form.'] },
|
|
121
|
+
{ type: 'tip', title: 'Her zaman aslında oynayacağınız en küçük boyutu hedefleyerek tasarlayın', html: 'Haritayı pul yalnızca birkaç düzine piksel genişliğinde kalana kadar uzaklaştırın. Yüz, çerçeve ve yazı hala net bir şekilde birbirinden ayrılabiliyorsa, pulunuz yoğun savaş anlarında harika çalışacaktır.' },
|
|
122
|
+
{ type: 'title', text: 'Oyun sırasında kısa ve kullanışlı etiketler kullanın', level: 2 },
|
|
123
|
+
{ type: 'paragraph', html: 'Bir pul asla eksiksiz bir karakter kağıdının yerini almaz. Yalnızca masanın sürekli ihtiyaç duyduğu en önemli bilgileri ekleyin: kısa bir isim, seviye veya sersemlemiş gibi bir durum bilgisi. Çok uzun cümleler yalnızca görsel kirlilik yaratır ve portrenin önüne geçer.' },
|
|
124
|
+
{ type: 'title', text: 'Tüm ekibin pullarını kimliklerini kaybetmeden tek seferde hazırlayın', level: 2 },
|
|
125
|
+
{ type: 'paragraph', html: 'Toplu dışa aktarma, her portrenin özgünlüğünü ve benzersizliğini korurken grubun genel stilini birleştirmenize olanak tanır. Tutarlı bir çerçeveleme ekibe görsel bir bütünlük katar.' },
|
|
126
|
+
{ type: 'tip', title: 'Cihazınızda otomatik yerel kaydetme kolaylığı', html: 'Aktif işaretçiniz tarayıcınızda otomatik olarak saklanır, böylece karakter seviye atladığında kolayca yeniden açıp düzenleyebilirsiniz.' },
|
|
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: '选择边框',
|
|
7
|
+
frameHint: '选择能在看到名字前就传达角色身份的轮廓边框。',
|
|
8
|
+
randomFrame: '随机边框',
|
|
9
|
+
surfaceLegend: '棋子配色设置',
|
|
10
|
+
backgroundLabel: '背景颜色',
|
|
11
|
+
borderLabel: '边框颜色',
|
|
12
|
+
textLabel: '文字颜色',
|
|
13
|
+
overlayLabel: '色调覆盖',
|
|
14
|
+
randomColors: '随机颜色',
|
|
15
|
+
borderWidthLabel: '边框粗细',
|
|
16
|
+
opacityLabel: '边框透明度',
|
|
17
|
+
overlayOpacityLabel: '头像遮罩透明度',
|
|
18
|
+
stageLabel: '实时棋子锻造台',
|
|
19
|
+
chooseImage: '选择头像',
|
|
20
|
+
clearImage: '清除头像',
|
|
21
|
+
positionHint: '可以直接在棋子上拖动调整头像或文字标签的位置。',
|
|
22
|
+
markerName: '标记名称',
|
|
23
|
+
markerNamePlaceholder: '为此标记命名',
|
|
24
|
+
textLegend: '添加清晰可读的桌面细节',
|
|
25
|
+
textHint: '使用简短标签(如名字、等级、职业或状态),并将其拖动到棋子上的合适位置。',
|
|
26
|
+
textPlaceholder: '角色名字或职业',
|
|
27
|
+
addText: '添加标签',
|
|
28
|
+
removeText: '移除标签',
|
|
29
|
+
textSizeLabel: '字体大小',
|
|
30
|
+
alignmentLabel: '对齐方式',
|
|
31
|
+
alignLeft: '左对齐',
|
|
32
|
+
alignCenter: '居中对齐',
|
|
33
|
+
alignRight: '右对齐',
|
|
34
|
+
imageLegend: '微调头像',
|
|
35
|
+
imageZoomLabel: '头像缩放',
|
|
36
|
+
scaleLabel: '导出比例',
|
|
37
|
+
savedMarkers: '已保存的标记',
|
|
38
|
+
noSavedMarkers: '已保存的标记将显示在此处,方便重新打开和编辑。',
|
|
39
|
+
reuseMarker: '选择标记以重新在锻造台编辑。',
|
|
40
|
+
deleteMarker: '删除标记',
|
|
41
|
+
newMarker: '新建标记',
|
|
42
|
+
download: '下载 PNG',
|
|
43
|
+
copy: '复制 PNG',
|
|
44
|
+
downloadBatch: '批量下载',
|
|
45
|
+
batchLabel: '高级批量导出',
|
|
46
|
+
batchHint: '可选:使用当前的边框和配色方案批量导出多个头像。',
|
|
47
|
+
chooseBatch: '选择批量图片',
|
|
48
|
+
batchReady: '已准备好 {count} 张图片。',
|
|
49
|
+
noImage: '未加载头像',
|
|
50
|
+
noText: '暂无标签。请添加团队所需的详细信息。',
|
|
51
|
+
tokenDetails: '角色棋子控制面板',
|
|
52
|
+
exportHint: '导出的 PNG 图片在选定轮廓外保持透明,可直接用于跑团软件(VTT)或打印。',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const faq = [
|
|
56
|
+
{
|
|
57
|
+
question: '什么样的头像适合制作角色棋子?',
|
|
58
|
+
answer: '选择面部清晰、与边框对比鲜明的图片。头部和肩膀的近景裁切在虚拟桌面上缩小显示时最清晰。',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
question: '如何在边框内精准调整头像位置?',
|
|
62
|
+
answer: '导入图片后直接在棋子上拖动。使用头像缩放功能可以放大面部,而无需更改边框形状。',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
question: '可以在棋子上添加角色名字和等级吗?',
|
|
66
|
+
answer: '可以。添加简短的文本标签,调整字体大小和对齐方式,然后拖动到棋子上的清晰位置。',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
question: '导出的 PNG 图片背景是透明的吗?',
|
|
70
|
+
answer: '是的。导出的图片在边框轮廓之外保持透明,方便直接覆盖在战场地图上。',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
question: '可以一次性为整个小队制作棋子吗?',
|
|
74
|
+
answer: '可以。使用批量导出功能,导入多张图片,即可应用相同的边框样式批量生成透明 PNG 棋子。',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
question: '保存的标记保存在哪里?',
|
|
78
|
+
answer: '标记保存在您浏览器的本地存储中,不会将您的个人图片上传到任何服务器。',
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const howTo = [
|
|
83
|
+
{ name: '选择适合角色的边框', text: '根据角色属性选择边框:圆形适合主角,六边形适合战术棋盘,星形适合 Boss。' },
|
|
84
|
+
{ name: '导入并调整头像位置', text: '拖入图片,调整拖动和缩放比例,使面部处于中央。' },
|
|
85
|
+
{ name: '添加必要的桌面文本', text: '添加简短的名字或状态标签,避免遮挡主要画面。' },
|
|
86
|
+
{ name: '导出透明 PNG', text: '下载透明 PNG 棋子,直接在 VTT 跑团软件中使用。' },
|
|
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: 'TRPG 桌面角色棋子(Token)制作工具,支持自定义边框、文本标签、背景透明及批量 PNG 导出。',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const howToSchema: WithContext<HowTo> = {
|
|
105
|
+
'@context': 'https://schema.org',
|
|
106
|
+
'@type': 'HowTo',
|
|
107
|
+
name: '如何制作跑团角色棋子',
|
|
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: 'TRPG跑团角色棋子在线制作工具 (Token Stamp)',
|
|
114
|
+
description: 'TRPG 桌面角色棋子(Token)制作工具,支持自定义边框、文本标签、背景透明及批量 PNG 导出。',
|
|
115
|
+
ui,
|
|
116
|
+
seo: [
|
|
117
|
+
{ type: 'title', text: '制作在游戏地图上清晰辨识的在线桌面跑团角色棋子', level: 2 },
|
|
118
|
+
{ type: 'paragraph', html: '棋子是桌游和 TRPG 跑团中不可或缺的核心视觉交流工具。棋子的轮廓能够在玩家放大地图细节之前,就直观地传达出当前代表的是英雄、强敌怪物、魔法效果还是重要盟友。为角色选择最契合的边框,并确保人物头像和文本标签在实际缩放比例下依然清晰易读。' },
|
|
119
|
+
{ type: 'title', text: '为不同定位与阵营的角色选择最合适的边框样式', level: 2 },
|
|
120
|
+
{ type: 'list', items: ['<strong>圆形与圆环:</strong> 玩家角色(PC)与常驻盟友的最经典可靠选择。', '<strong>六边形与八边形:</strong> 适合包含大量战斗单位的战术棋盘与战场地图。', '<strong>星形:</strong> 非常适合 Boss 怪物、精英敌人或需要引起注意的重要目标。', '<strong>云朵形:</strong> 适合召唤兽、精灵、魔宠等特殊的存在。'] },
|
|
121
|
+
{ type: 'tip', title: '针对游戏时实际最小显示尺寸进行细致设计', html: '缩小地图视角,观察棋子缩到很小时面部特征与文字是否依然清晰可辨。' },
|
|
122
|
+
{ type: 'title', text: '使用简短而实用的文本标签辅助对局', level: 2 },
|
|
123
|
+
{ type: 'paragraph', html: '跑团棋子无需替代完整的角色卡,只需添加名字、等级或晕眩等状态标签,避免遮挡插画。' },
|
|
124
|
+
{ type: 'title', text: '一键为整个冒险小队制作视觉统一的角色棋子', level: 2 },
|
|
125
|
+
{ type: 'paragraph', html: '批量导出功能可以在保持团队整体视觉规范统一的同时,完美保留每个角色的独特插画。' },
|
|
126
|
+
{ type: 'tip', title: '设备本地自动保存功能', html: '正在制作的标记会自动保存在浏览器本地,随角色升级轻松打开重新编辑。' },
|
|
127
|
+
],
|
|
128
|
+
faq,
|
|
129
|
+
bibliography,
|
|
130
|
+
howTo,
|
|
131
|
+
schemas: [faqSchema, appSchema, howToSchema] as unknown as Record<string, unknown>[],
|
|
132
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../types';
|
|
2
|
+
import { tokenStampStudio } from './entry';
|
|
3
|
+
|
|
4
|
+
export * from './entry';
|
|
5
|
+
|
|
6
|
+
export const TOKEN_STAMP_STUDIO_TOOL: ToolDefinition = {
|
|
7
|
+
entry: tokenStampStudio,
|
|
8
|
+
Component: () => import('./component.astro'),
|
|
9
|
+
SEOComponent: () => import('./seo.astro'),
|
|
10
|
+
BibliographyComponent: () => import('./bibliography.astro'),
|
|
11
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { FRAME_PRESETS, clamp, createDefaultState, getFrameGeometry, getStatus, hitTestText } from './logic';
|
|
3
|
+
|
|
4
|
+
describe('token stamp studio logic', () => {
|
|
5
|
+
it('starts with a usable foundry state', () => {
|
|
6
|
+
const state = createDefaultState();
|
|
7
|
+
expect(state.frameId).toBe(FRAME_PRESETS[0]!.id);
|
|
8
|
+
expect(state.overlayOpacity).toBe(0);
|
|
9
|
+
expect(state.imageSrc).toBeNull();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('clamps numeric controls to their safe ranges', () => {
|
|
13
|
+
expect(clamp(-4, 0, 1)).toBe(0);
|
|
14
|
+
expect(clamp(2, 0, 1)).toBe(1);
|
|
15
|
+
expect(clamp(0.4, 0, 1)).toBe(0.4);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('builds a geometry for every frame family', () => {
|
|
19
|
+
FRAME_PRESETS.forEach((frame) => expect(getFrameGeometry(frame.shape)).toBeDefined());
|
|
20
|
+
expect(getFrameGeometry('circle').kind).toBe('circle');
|
|
21
|
+
expect(getFrameGeometry('hexagon').points).toHaveLength(6);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('diagnoses the two empty states before ready', () => {
|
|
25
|
+
const messages = { statusReady: 'Ready', statusAddImage: 'Add an image', statusAddText: 'Finish the label' };
|
|
26
|
+
const state = createDefaultState();
|
|
27
|
+
expect(getStatus(state, messages).message).toBe('Add an image');
|
|
28
|
+
expect(getStatus({ ...state, imageSrc: 'data:image/png;base64,a', texts: [{ id: 'a', text: '', x: 0.5, y: 0.8, size: 54, align: 'center' }], selectedTextId: 'a' }, messages).message).toBe('Finish the label');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('detects a text layer under the pointer', () => {
|
|
32
|
+
const layer = { id: 'a', text: 'Goblin', x: 0.5, y: 0.8, size: 54, align: 'center' as const };
|
|
33
|
+
expect(hitTestText(layer, { x: 0.5, y: 0.8 })).toBe(true);
|
|
34
|
+
expect(hitTestText(layer, { x: 0.05, y: 0.1 })).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { FrameGeometry, FramePreset, TextLayer, TokenPalette, TokenStampState, TokenStampStatus } from './types';
|
|
2
|
+
|
|
3
|
+
export const FRAME_PRESETS: FramePreset[] = [
|
|
4
|
+
{ id: 'ember-ring', name: 'Ember ring', shortName: 'Ring', description: 'A warm double ring for classic character tokens.', shape: 'circle', accent: '#d89555' },
|
|
5
|
+
{ id: 'parchment-square', name: 'Parchment square', shortName: 'Square', description: 'A clean square cut for maps, cards, and item art.', shape: 'square', accent: '#c6a875' },
|
|
6
|
+
{ id: 'hex-warden', name: 'Hex warden', shortName: 'Hex', description: 'A six sided badge with a tactical silhouette.', shape: 'hexagon', accent: '#89b9ae' },
|
|
7
|
+
{ id: 'octagon-steel', name: 'Octagon steel', shortName: 'Octagon', description: 'A hard edged frame for monsters and machines.', shape: 'octagon', accent: '#a9b6c4' },
|
|
8
|
+
{ id: 'star-forge', name: 'Star forge', shortName: 'Star', description: 'A bright eight point emblem for heroes and relics.', shape: 'star', accent: '#e0b35e' },
|
|
9
|
+
{ id: 'diamond-sigil', name: 'Diamond sigil', shortName: 'Diamond', description: 'A turned frame that reads like a spell seal.', shape: 'diamond', accent: '#b48bd0' },
|
|
10
|
+
{ id: 'ticket-relic', name: 'Ticket relic', shortName: 'Ticket', description: 'A notched frame for quests, clues, and locations.', shape: 'ticket', accent: '#d17f69' },
|
|
11
|
+
{ id: 'cloud-echo', name: 'Cloud echo', shortName: 'Cloud', description: 'A soft irregular edge for spirits and fey creatures.', shape: 'cloud', accent: '#84b9ca' },
|
|
12
|
+
{ id: 'scallop-crown', name: 'Scallop crown', shortName: 'Scallop', description: 'A ceremonial edge for bosses and named allies.', shape: 'scallop', accent: '#d4a4b9' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_FRAME_ID = FRAME_PRESETS[0]!.id;
|
|
16
|
+
|
|
17
|
+
export const COLOR_PALETTES: TokenPalette[] = [
|
|
18
|
+
{ background: '#1d2430', border: '#d89555', text: '#fff4d6', overlay: '#e05b4f' },
|
|
19
|
+
{ background: '#182b2a', border: '#80b8a5', text: '#f5f1dc', overlay: '#d26b5b' },
|
|
20
|
+
{ background: '#29233a', border: '#c49ad7', text: '#fff1ca', overlay: '#6e91ca' },
|
|
21
|
+
{ background: '#33251e', border: '#d4a45f', text: '#fff8e9', overlay: '#8f574c' },
|
|
22
|
+
{ background: '#202c3d', border: '#86a9d3', text: '#f8f0dd', overlay: '#9c668c' },
|
|
23
|
+
{ background: '#243329', border: '#c1b36a', text: '#fffbe3', overlay: '#b86a4e' },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
27
|
+
return Math.min(max, Math.max(min, value));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createDefaultState(): TokenStampState {
|
|
31
|
+
return {
|
|
32
|
+
markerName: '',
|
|
33
|
+
frameId: DEFAULT_FRAME_ID,
|
|
34
|
+
background: '#1d2430',
|
|
35
|
+
border: '#d89555',
|
|
36
|
+
borderWidth: 16,
|
|
37
|
+
borderOpacity: 1,
|
|
38
|
+
overlay: '#e05b4f',
|
|
39
|
+
overlayOpacity: 0,
|
|
40
|
+
imageSrc: null,
|
|
41
|
+
imageName: '',
|
|
42
|
+
imageX: 0,
|
|
43
|
+
imageY: 0,
|
|
44
|
+
imageZoom: 1,
|
|
45
|
+
scale: 1,
|
|
46
|
+
textColor: '#fff4d6',
|
|
47
|
+
texts: [],
|
|
48
|
+
selectedTextId: null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getFramePreset(frameId: string): FramePreset {
|
|
53
|
+
return FRAME_PRESETS.find((frame) => frame.id === frameId) ?? FRAME_PRESETS[0]!;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getRandomPalette(): TokenPalette {
|
|
57
|
+
return COLOR_PALETTES[Math.floor(Math.random() * COLOR_PALETTES.length)]!;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function polygonGeometry(sides: number, rotation: number): FrameGeometry {
|
|
61
|
+
const points = Array.from({ length: sides }, (_, index) => {
|
|
62
|
+
const angle = rotation + (Math.PI * 2 * index) / sides;
|
|
63
|
+
return { x: 0.5 + Math.cos(angle) * 0.45, y: 0.5 + Math.sin(angle) * 0.45 };
|
|
64
|
+
});
|
|
65
|
+
return { kind: 'polygon', points };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function starGeometry(): FrameGeometry {
|
|
69
|
+
const points = Array.from({ length: 16 }, (_, index) => {
|
|
70
|
+
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / 16;
|
|
71
|
+
const radius = index % 2 === 0 ? 0.45 : 0.25;
|
|
72
|
+
return { x: 0.5 + Math.cos(angle) * radius, y: 0.5 + Math.sin(angle) * radius };
|
|
73
|
+
});
|
|
74
|
+
return { kind: 'polygon', points };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function ticketGeometry(): FrameGeometry {
|
|
78
|
+
return { kind: 'polygon', points: [
|
|
79
|
+
{ x: 0.24, y: 0.08 }, { x: 0.76, y: 0.08 }, { x: 0.82, y: 0.14 },
|
|
80
|
+
{ x: 0.82, y: 0.86 }, { x: 0.76, y: 0.92 }, { x: 0.24, y: 0.92 },
|
|
81
|
+
{ x: 0.18, y: 0.86 }, { x: 0.18, y: 0.14 },
|
|
82
|
+
] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function scallopGeometry(): FrameGeometry {
|
|
86
|
+
const points = Array.from({ length: 24 }, (_, index) => {
|
|
87
|
+
const angle = -Math.PI / 2 + (Math.PI * 2 * index) / 24;
|
|
88
|
+
const radius = index % 2 === 0 ? 0.45 : 0.38;
|
|
89
|
+
return { x: 0.5 + Math.cos(angle) * radius, y: 0.5 + Math.sin(angle) * radius };
|
|
90
|
+
});
|
|
91
|
+
return { kind: 'polygon', points };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const FRAME_GEOMETRIES: Record<FramePreset['shape'], () => FrameGeometry> = {
|
|
95
|
+
circle: () => ({ kind: 'circle' }),
|
|
96
|
+
square: () => polygonGeometry(4, Math.PI / 4),
|
|
97
|
+
hexagon: () => polygonGeometry(6, -Math.PI / 2),
|
|
98
|
+
octagon: () => polygonGeometry(8, Math.PI / 8),
|
|
99
|
+
star: starGeometry,
|
|
100
|
+
diamond: () => polygonGeometry(4, 0),
|
|
101
|
+
ticket: ticketGeometry,
|
|
102
|
+
cloud: () => ({ kind: 'circle' }),
|
|
103
|
+
scallop: scallopGeometry,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export function getFrameGeometry(shape: FramePreset['shape']): FrameGeometry {
|
|
107
|
+
return FRAME_GEOMETRIES[shape]();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function updateTextLayer(texts: TextLayer[], selectedTextId: string | null, update: Partial<TextLayer>): TextLayer[] {
|
|
111
|
+
return texts.map((text) => text.id === selectedTextId ? { ...text, ...update } : text);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function createTextLayer(index: number): TextLayer {
|
|
115
|
+
return { id: `text-${Date.now()}-${index}`, text: 'New label', x: 0.5, y: 0.8, size: 54, align: 'center' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function getStatus(state: TokenStampState, ui: { statusReady: string; statusAddImage: string; statusAddText: string }): TokenStampStatus {
|
|
119
|
+
if (!state.imageSrc) return { tone: 'attention', message: ui.statusAddImage };
|
|
120
|
+
if (state.texts.some((text) => text.text.trim().length === 0)) return { tone: 'attention', message: ui.statusAddText };
|
|
121
|
+
return { tone: 'ready', message: ui.statusReady };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function hitTestText(text: TextLayer, point: { x: number; y: number }): boolean {
|
|
125
|
+
const width = Math.max(0.12, text.text.length * text.size / 900);
|
|
126
|
+
return Math.abs(point.x - text.x) <= width / 2 && Math.abs(point.y - text.y) <= text.size / 1100;
|
|
127
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { tokenStampStudio } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props as Props;
|
|
11
|
+
const loader = tokenStampStudio.i18n[locale] || tokenStampStudio.i18n.en;
|
|
12
|
+
const content = await loader?.();
|
|
13
|
+
if (!content) return null;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|