@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,37 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { calculateEncounter } from './logic';
|
|
3
|
+
|
|
4
|
+
describe('calculateEncounter', () => {
|
|
5
|
+
it('calculates a medium encounter for a level five party', () => {
|
|
6
|
+
const result = calculateEncounter({ partyLevel: 5, partySize: 4, monsterCr: 3, monsterCount: 2 });
|
|
7
|
+
expect(result.monsterXp).toBe(700);
|
|
8
|
+
expect(result.adjustedXp).toBe(2100);
|
|
9
|
+
expect(result.difficulty).toBe('medium');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('applies the multiple monster multiplier', () => {
|
|
13
|
+
const result = calculateEncounter({ partyLevel: 3, partySize: 4, monsterCr: 1, monsterCount: 4 });
|
|
14
|
+
expect(result.baseXp).toBe(800);
|
|
15
|
+
expect(result.multiplier).toBe(2);
|
|
16
|
+
expect(result.adjustedXp).toBe(1600);
|
|
17
|
+
expect(result.difficulty).toBe('deadly');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('raises the multiplier for a small party', () => {
|
|
21
|
+
const result = calculateEncounter({ partyLevel: 5, partySize: 2, monsterCr: 2, monsterCount: 2 });
|
|
22
|
+
expect(result.multiplier).toBe(2);
|
|
23
|
+
expect(result.warnings).toContain('partyAdjustment');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('lowers the multiplier for a large party', () => {
|
|
27
|
+
const result = calculateEncounter({ partyLevel: 5, partySize: 6, monsterCr: 2, monsterCount: 6 });
|
|
28
|
+
expect(result.multiplier).toBe(1.5);
|
|
29
|
+
expect(result.warnings).toContain('partyAdjustment');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('clamps unsafe inputs to the supported range', () => {
|
|
33
|
+
const result = calculateEncounter({ partyLevel: 99, partySize: 99, monsterCr: 99, monsterCount: 99 });
|
|
34
|
+
expect(result.settings).toEqual({ partyLevel: 20, partySize: 8, monsterCr: 30, monsterCount: 15 });
|
|
35
|
+
expect(result.difficulty).toBe('deadly');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
export type EncounterDifficulty = 'belowEasy' | 'easy' | 'medium' | 'hard' | 'deadly';
|
|
2
|
+
|
|
3
|
+
export interface EncounterSettings {
|
|
4
|
+
partyLevel: number;
|
|
5
|
+
partySize: number;
|
|
6
|
+
monsterCr: number;
|
|
7
|
+
monsterCount: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface EncounterThresholds {
|
|
11
|
+
easy: number;
|
|
12
|
+
medium: number;
|
|
13
|
+
hard: number;
|
|
14
|
+
deadly: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface EncounterResult {
|
|
18
|
+
settings: EncounterSettings;
|
|
19
|
+
monsterXp: number;
|
|
20
|
+
baseXp: number;
|
|
21
|
+
multiplier: number;
|
|
22
|
+
adjustedXp: number;
|
|
23
|
+
thresholds: EncounterThresholds;
|
|
24
|
+
difficulty: EncounterDifficulty;
|
|
25
|
+
crLabel: string;
|
|
26
|
+
warnings: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface CrEntry {
|
|
30
|
+
value: number;
|
|
31
|
+
label: string;
|
|
32
|
+
xp: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const CR_ENTRIES: CrEntry[] = [
|
|
36
|
+
{ value: 0, label: '0', xp: 10 },
|
|
37
|
+
{ value: 0.125, label: '1/8', xp: 25 },
|
|
38
|
+
{ value: 0.25, label: '1/4', xp: 50 },
|
|
39
|
+
{ value: 0.5, label: '1/2', xp: 100 },
|
|
40
|
+
{ value: 1, label: '1', xp: 200 },
|
|
41
|
+
{ value: 2, label: '2', xp: 450 },
|
|
42
|
+
{ value: 3, label: '3', xp: 700 },
|
|
43
|
+
{ value: 4, label: '4', xp: 1100 },
|
|
44
|
+
{ value: 5, label: '5', xp: 1800 },
|
|
45
|
+
{ value: 6, label: '6', xp: 2300 },
|
|
46
|
+
{ value: 7, label: '7', xp: 2900 },
|
|
47
|
+
{ value: 8, label: '8', xp: 3900 },
|
|
48
|
+
{ value: 9, label: '9', xp: 5000 },
|
|
49
|
+
{ value: 10, label: '10', xp: 5900 },
|
|
50
|
+
{ value: 11, label: '11', xp: 7200 },
|
|
51
|
+
{ value: 12, label: '12', xp: 8400 },
|
|
52
|
+
{ value: 13, label: '13', xp: 10000 },
|
|
53
|
+
{ value: 14, label: '14', xp: 11500 },
|
|
54
|
+
{ value: 15, label: '15', xp: 13000 },
|
|
55
|
+
{ value: 16, label: '16', xp: 15000 },
|
|
56
|
+
{ value: 17, label: '17', xp: 18000 },
|
|
57
|
+
{ value: 18, label: '18', xp: 20000 },
|
|
58
|
+
{ value: 19, label: '19', xp: 22000 },
|
|
59
|
+
{ value: 20, label: '20', xp: 25000 },
|
|
60
|
+
{ value: 21, label: '21', xp: 33000 },
|
|
61
|
+
{ value: 22, label: '22', xp: 41000 },
|
|
62
|
+
{ value: 23, label: '23', xp: 50000 },
|
|
63
|
+
{ value: 24, label: '24', xp: 62000 },
|
|
64
|
+
{ value: 25, label: '25', xp: 75000 },
|
|
65
|
+
{ value: 26, label: '26', xp: 90000 },
|
|
66
|
+
{ value: 27, label: '27', xp: 105000 },
|
|
67
|
+
{ value: 28, label: '28', xp: 120000 },
|
|
68
|
+
{ value: 29, label: '29', xp: 135000 },
|
|
69
|
+
{ value: 30, label: '30', xp: 155000 },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const THRESHOLDS = [
|
|
73
|
+
[25, 50, 75, 100], [50, 100, 150, 200], [75, 150, 225, 400],
|
|
74
|
+
[125, 250, 375, 500], [250, 500, 750, 1100], [300, 600, 900, 1400],
|
|
75
|
+
[350, 750, 1100, 1700], [450, 900, 1400, 2100], [550, 1100, 1600, 2400],
|
|
76
|
+
[600, 1200, 1900, 2800], [800, 1600, 2400, 3600], [1000, 2000, 3000, 4500],
|
|
77
|
+
[1100, 2200, 3400, 5100], [1250, 2500, 3800, 5700], [1400, 2800, 4300, 6400],
|
|
78
|
+
[1600, 3200, 4800, 7200], [2000, 3900, 5900, 8800], [2100, 4200, 6300, 9500],
|
|
79
|
+
[2400, 4900, 7300, 10900], [2800, 5700, 8500, 12700],
|
|
80
|
+
] as const;
|
|
81
|
+
|
|
82
|
+
const MULTIPLIERS = [1, 1.5, 2, 2.5, 3, 4];
|
|
83
|
+
|
|
84
|
+
export const DEFAULT_SETTINGS: EncounterSettings = {
|
|
85
|
+
partyLevel: 5,
|
|
86
|
+
partySize: 4,
|
|
87
|
+
monsterCr: 2,
|
|
88
|
+
monsterCount: 2,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const CR_OPTIONS = CR_ENTRIES;
|
|
92
|
+
export const QUICK_CR_VALUES = [0, 0.125, 0.25, 0.5, 1, 2, 3, 4, 5, 8, 10, 15];
|
|
93
|
+
|
|
94
|
+
function clamp(value: number, min: number, max: number): number {
|
|
95
|
+
return Math.min(Math.max(value, min), max);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function findCrEntry(cr: number): CrEntry {
|
|
99
|
+
return CR_ENTRIES.reduce((closest, entry) => (
|
|
100
|
+
Math.abs(entry.value - cr) < Math.abs(closest.value - cr) ? entry : closest
|
|
101
|
+
));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getBaseMultiplier(monsterCount: number): number {
|
|
105
|
+
if (monsterCount === 1) return 1;
|
|
106
|
+
if (monsterCount === 2) return 1.5;
|
|
107
|
+
if (monsterCount <= 6) return 2;
|
|
108
|
+
if (monsterCount <= 10) return 2.5;
|
|
109
|
+
if (monsterCount <= 14) return 3;
|
|
110
|
+
return 4;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getPartyAdjustment(partySize: number): number {
|
|
114
|
+
if (partySize < 3) return 1;
|
|
115
|
+
if (partySize > 5) return -1;
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function getMultiplier(monsterCount: number, partySize: number): number {
|
|
120
|
+
const baseIndex = MULTIPLIERS.indexOf(getBaseMultiplier(monsterCount));
|
|
121
|
+
const partyAdjustment = getPartyAdjustment(partySize);
|
|
122
|
+
return MULTIPLIERS[clamp(baseIndex + partyAdjustment, 0, MULTIPLIERS.length - 1)];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getThresholds(partyLevel: number, partySize: number): EncounterThresholds {
|
|
126
|
+
const row = THRESHOLDS[clamp(partyLevel, 1, 20) - 1];
|
|
127
|
+
return {
|
|
128
|
+
easy: row[0] * partySize,
|
|
129
|
+
medium: row[1] * partySize,
|
|
130
|
+
hard: row[2] * partySize,
|
|
131
|
+
deadly: row[3] * partySize,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getDifficulty(adjustedXp: number, thresholds: EncounterThresholds): EncounterDifficulty {
|
|
136
|
+
if (adjustedXp < thresholds.easy) return 'belowEasy';
|
|
137
|
+
if (adjustedXp < thresholds.medium) return 'easy';
|
|
138
|
+
if (adjustedXp < thresholds.hard) return 'medium';
|
|
139
|
+
if (adjustedXp < thresholds.deadly) return 'hard';
|
|
140
|
+
return 'deadly';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function getWarnings(settings: EncounterSettings): string[] {
|
|
144
|
+
const warnings: string[] = [];
|
|
145
|
+
if (settings.partySize < 3 || settings.partySize > 5) warnings.push('partyAdjustment');
|
|
146
|
+
if (settings.monsterCr > settings.partyLevel) warnings.push('highCr');
|
|
147
|
+
if (settings.monsterCount >= 11) warnings.push('manyMonsters');
|
|
148
|
+
return warnings;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function calculateEncounter(rawSettings: EncounterSettings): EncounterResult {
|
|
152
|
+
const settings: EncounterSettings = {
|
|
153
|
+
partyLevel: clamp(Math.round(rawSettings.partyLevel), 1, 20),
|
|
154
|
+
partySize: clamp(Math.round(rawSettings.partySize), 1, 8),
|
|
155
|
+
monsterCr: findCrEntry(rawSettings.monsterCr).value,
|
|
156
|
+
monsterCount: clamp(Math.round(rawSettings.monsterCount), 1, 15),
|
|
157
|
+
};
|
|
158
|
+
const crEntry = findCrEntry(settings.monsterCr);
|
|
159
|
+
const thresholds = getThresholds(settings.partyLevel, settings.partySize);
|
|
160
|
+
const baseXp = crEntry.xp * settings.monsterCount;
|
|
161
|
+
const multiplier = getMultiplier(settings.monsterCount, settings.partySize);
|
|
162
|
+
const adjustedXp = Math.round(baseXp * multiplier);
|
|
163
|
+
return {
|
|
164
|
+
settings,
|
|
165
|
+
monsterXp: crEntry.xp,
|
|
166
|
+
baseXp,
|
|
167
|
+
multiplier,
|
|
168
|
+
adjustedXp,
|
|
169
|
+
thresholds,
|
|
170
|
+
difficulty: getDifficulty(adjustedXp, thresholds),
|
|
171
|
+
crLabel: crEntry.label,
|
|
172
|
+
warnings: getWarnings(settings),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { encounterDifficultyCalculator } 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 = encounterDifficultyCalculator.i18n[locale] || encounterDifficultyCalculator.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 }} />}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { EncounterSettings } from './logic';
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = 'jjlmoya-encounter-difficulty-settings-v1';
|
|
4
|
+
|
|
5
|
+
function isSettings(value: unknown): value is EncounterSettings {
|
|
6
|
+
if (!value || typeof value !== 'object') return false;
|
|
7
|
+
const candidate = value as Record<string, unknown>;
|
|
8
|
+
return ['partyLevel', 'partySize', 'monsterCr', 'monsterCount'].every((key) => typeof candidate[key] === 'number');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function readEncounterSettings(): EncounterSettings | null {
|
|
12
|
+
try {
|
|
13
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
14
|
+
if (!stored) return null;
|
|
15
|
+
const value: unknown = JSON.parse(stored);
|
|
16
|
+
return isSettings(value) ? value : null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function writeEncounterSettings(settings: EncounterSettings): void {
|
|
23
|
+
try {
|
|
24
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
|
25
|
+
} catch {}
|
|
26
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface EncounterDifficultyUI {
|
|
2
|
+
intro: string;
|
|
3
|
+
partySection: string;
|
|
4
|
+
partyLevel: string;
|
|
5
|
+
partyLevelHint: string;
|
|
6
|
+
partySize: string;
|
|
7
|
+
partySizeHint: string;
|
|
8
|
+
threatSection: string;
|
|
9
|
+
monsterCr: string;
|
|
10
|
+
monsterCrHint: string;
|
|
11
|
+
moreCr: string;
|
|
12
|
+
lessCr: string;
|
|
13
|
+
monsterCount: string;
|
|
14
|
+
monsterCountHint: string;
|
|
15
|
+
presets: string;
|
|
16
|
+
presetClassic: string;
|
|
17
|
+
presetBoss: string;
|
|
18
|
+
presetSwarm: string;
|
|
19
|
+
resultSection: string;
|
|
20
|
+
belowEasyHint: string;
|
|
21
|
+
easyHint: string;
|
|
22
|
+
mediumHint: string;
|
|
23
|
+
hardHint: string;
|
|
24
|
+
deadlyHint: string;
|
|
25
|
+
adjustedXp: string;
|
|
26
|
+
baseXp: string;
|
|
27
|
+
multiplier: string;
|
|
28
|
+
partyThreshold: string;
|
|
29
|
+
belowEasy: string;
|
|
30
|
+
easy: string;
|
|
31
|
+
medium: string;
|
|
32
|
+
hard: string;
|
|
33
|
+
deadly: string;
|
|
34
|
+
warning: string;
|
|
35
|
+
partyAdjustment: string;
|
|
36
|
+
highCr: string;
|
|
37
|
+
manyMonsters: string;
|
|
38
|
+
rulesNote: string;
|
|
39
|
+
rulesLinkLabel: string;
|
|
40
|
+
reset: string;
|
|
41
|
+
xpUnit: string;
|
|
42
|
+
sceneLabel: string;
|
|
43
|
+
partyMarker: string;
|
|
44
|
+
threatMarker: string;
|
|
45
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography as SharedBibliography } 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 && <SharedBibliography links={content.bibliography} />}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'Foundry Virtual Tabletop, Tokens',
|
|
6
|
+
url: 'https://foundryvtt.com/article/tokens/',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'Foundry Virtual Tabletop, Actors',
|
|
10
|
+
url: 'https://foundryvtt.com/article/actors/',
|
|
11
|
+
},
|
|
12
|
+
];
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './token-stamp-studio.css';
|
|
3
|
+
import type { TokenStampUI } from './ui';
|
|
4
|
+
|
|
5
|
+
const { ui } = Astro.props as { ui: TokenStampUI };
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<div class="token-stamp-studio" data-token-stamp-studio data-ui={JSON.stringify(ui)}>
|
|
9
|
+
<div class="foundry-shell">
|
|
10
|
+
<section class="marker-library marker-library-header" aria-label={ui.savedMarkers}>
|
|
11
|
+
<div class="marker-library-grid" data-marker-library></div>
|
|
12
|
+
</section>
|
|
13
|
+
<div class="foundry-layout">
|
|
14
|
+
<section class="foundry-stage-panel" aria-label={ui.stageLabel}>
|
|
15
|
+
<label class="marker-name-field marker-name-stage"><input data-marker-name type="text" maxlength="48" placeholder={ui.markerNamePlaceholder} aria-label={ui.markerName} /></label>
|
|
16
|
+
<div class="token-stage" data-drop-zone>
|
|
17
|
+
<canvas data-token-canvas aria-label={ui.stageLabel}></canvas>
|
|
18
|
+
<p class="stage-caption"><span>{ui.positionHint}</span><span data-current-image>{ui.noImage}</span></p>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="stage-actions">
|
|
21
|
+
<label class="button button-primary" for="token-image-input">{ui.chooseImage}</label>
|
|
22
|
+
<input id="token-image-input" data-image-input type="file" accept="image/png,image/jpeg,image/webp,image/gif" hidden />
|
|
23
|
+
<button class="button button-quiet" type="button" data-clear-image>{ui.clearImage}</button>
|
|
24
|
+
<button class="button button-quiet" type="button" data-copy>{ui.copy}</button>
|
|
25
|
+
<button class="button button-primary" type="button" data-download>{ui.download}</button>
|
|
26
|
+
</div>
|
|
27
|
+
<p class="export-hint">{ui.exportHint}</p>
|
|
28
|
+
</section>
|
|
29
|
+
|
|
30
|
+
<aside class="foundry-inspector" aria-label={ui.tokenDetails}>
|
|
31
|
+
<section class="inspector-block inspector-frames">
|
|
32
|
+
<div class="block-heading"><div><p class="eyebrow">01</p><h3>{ui.frameLegend}</h3></div><button class="micro-action" type="button" data-random-frame>{ui.randomFrame}</button></div>
|
|
33
|
+
<p class="block-hint">{ui.frameHint}</p>
|
|
34
|
+
<div class="frame-gallery" data-frame-gallery></div>
|
|
35
|
+
</section>
|
|
36
|
+
|
|
37
|
+
<section class="inspector-block">
|
|
38
|
+
<div class="block-heading"><div><p class="eyebrow">02</p><h3>{ui.surfaceLegend}</h3></div><button class="micro-action" type="button" data-random-colors>{ui.randomColors}</button></div>
|
|
39
|
+
<div class="color-grid">
|
|
40
|
+
<label class="color-control"><span>{ui.backgroundLabel}</span><input data-color="background" type="color" value="#1d2430" /></label>
|
|
41
|
+
<label class="color-control"><span>{ui.borderLabel}</span><input data-color="border" type="color" value="#d89555" /></label>
|
|
42
|
+
<label class="color-control"><span>{ui.textLabel}</span><input data-color="text" type="color" value="#fff4d6" /></label>
|
|
43
|
+
<label class="color-control"><span>{ui.overlayLabel}</span><input data-color="overlay" type="color" value="#e05b4f" /></label>
|
|
44
|
+
</div>
|
|
45
|
+
<label class="range-control"><span>{ui.borderWidthLabel}<output data-border-width-output>16</output></span><input data-border-width type="range" min="4" max="34" step="1" value="16" /></label>
|
|
46
|
+
<label class="range-control"><span>{ui.opacityLabel}<output data-border-opacity-output>100%</output></span><input data-border-opacity type="range" min="0.2" max="1" step="0.05" value="1" /></label>
|
|
47
|
+
<label class="range-control"><span>{ui.overlayOpacityLabel}<output data-overlay-opacity-output>0%</output></span><input data-overlay-opacity type="range" min="0" max="0.8" step="0.05" value="0" /></label>
|
|
48
|
+
</section>
|
|
49
|
+
|
|
50
|
+
<section class="inspector-block">
|
|
51
|
+
<div class="block-heading"><div><p class="eyebrow">03</p><h3>{ui.imageLegend}</h3></div></div>
|
|
52
|
+
<label class="range-control"><span>{ui.imageZoomLabel}<output data-image-zoom-output>100%</output></span><input data-image-zoom type="range" min="0.7" max="1.8" step="0.05" value="1" /></label>
|
|
53
|
+
<label class="range-control"><span>{ui.scaleLabel}<output data-scale-output>100%</output></span><input data-scale type="range" min="0.75" max="1.5" step="0.05" value="1" /></label>
|
|
54
|
+
</section>
|
|
55
|
+
|
|
56
|
+
<section class="inspector-block">
|
|
57
|
+
<div class="block-heading"><div><p class="eyebrow">04</p><h3>{ui.textLegend}</h3></div></div>
|
|
58
|
+
<p class="block-hint" data-text-hint>{ui.textHint}</p>
|
|
59
|
+
<div class="text-layer-list" data-text-layers></div>
|
|
60
|
+
<div class="text-editor">
|
|
61
|
+
<input data-text-input type="text" maxlength="24" placeholder={ui.textPlaceholder} aria-label={ui.textPlaceholder} />
|
|
62
|
+
<div class="editor-row"><label class="range-control"><span>{ui.textSizeLabel}<output data-text-size-output>54</output></span><input data-text-size type="range" min="22" max="86" step="1" value="54" /></label></div>
|
|
63
|
+
<div class="align-actions" aria-label={ui.alignmentLabel}>
|
|
64
|
+
<button type="button" data-align="left" aria-label={ui.alignLeft} title={ui.alignLeft}>↤</button><button type="button" data-align="center" aria-label={ui.alignCenter} title={ui.alignCenter}>↔</button><button type="button" data-align="right" aria-label={ui.alignRight} title={ui.alignRight}>↦</button>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="editor-actions"><button class="button button-quiet" type="button" data-add-text>{ui.addText}</button><button class="button button-danger" type="button" data-remove-text>{ui.removeText}</button></div>
|
|
67
|
+
</div>
|
|
68
|
+
</section>
|
|
69
|
+
|
|
70
|
+
</aside>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<details class="batch-advanced">
|
|
74
|
+
<summary>{ui.batchLabel}</summary>
|
|
75
|
+
<section class="batch-dock" aria-labelledby="batch-title">
|
|
76
|
+
<div><h3 id="batch-title">{ui.batchHint}</h3><p data-batch-status>{ui.batchHint}</p></div>
|
|
77
|
+
<div class="batch-actions"><label class="button button-quiet" for="token-batch-input">{ui.chooseBatch}</label><input id="token-batch-input" data-batch-input type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple hidden /><button class="button button-primary" type="button" data-batch-download disabled>{ui.downloadBatch}</button></div>
|
|
78
|
+
</section>
|
|
79
|
+
</details>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<script>
|
|
84
|
+
import { mountTokenStampStudio } from './controller';
|
|
85
|
+
|
|
86
|
+
const root = document.querySelector<HTMLElement>('[data-token-stamp-studio]');
|
|
87
|
+
if (root) {
|
|
88
|
+
const ui = JSON.parse(root.dataset.ui ?? '{}');
|
|
89
|
+
void mountTokenStampStudio(root, ui);
|
|
90
|
+
}
|
|
91
|
+
</script>
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { createTextLayer, FRAME_PRESETS, getRandomPalette, hitTestText, updateTextLayer, clamp } from './logic';
|
|
2
|
+
import { pointerToCanvas } from './dom-views';
|
|
3
|
+
import { attachBatchEvents, attachFileEvents, attachWorkspaceEvents } from './workspace';
|
|
4
|
+
import { StudioStore } from './store';
|
|
5
|
+
import type { TokenStampUI } from './ui';
|
|
6
|
+
import type { TextAlign, TokenStampState } from './types';
|
|
7
|
+
|
|
8
|
+
export interface StudioElements {
|
|
9
|
+
root: HTMLElement;
|
|
10
|
+
canvas: HTMLCanvasElement;
|
|
11
|
+
frames: HTMLElement;
|
|
12
|
+
textLayers: HTMLElement;
|
|
13
|
+
imageInput: HTMLInputElement;
|
|
14
|
+
batchInput: HTMLInputElement;
|
|
15
|
+
textInput: HTMLInputElement;
|
|
16
|
+
textSize: HTMLInputElement;
|
|
17
|
+
imageZoom: HTMLInputElement;
|
|
18
|
+
scale: HTMLInputElement;
|
|
19
|
+
borderWidth: HTMLInputElement;
|
|
20
|
+
borderOpacity: HTMLInputElement;
|
|
21
|
+
overlayOpacity: HTMLInputElement;
|
|
22
|
+
currentImage: HTMLElement;
|
|
23
|
+
batchStatus: HTMLElement;
|
|
24
|
+
library: HTMLElement;
|
|
25
|
+
markerName: HTMLInputElement;
|
|
26
|
+
copy: HTMLButtonElement;
|
|
27
|
+
download: HTMLButtonElement;
|
|
28
|
+
batchDownload: HTMLButtonElement;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getElement<T extends Element>(root: HTMLElement, selector: string): T {
|
|
32
|
+
const element = root.querySelector<T>(selector);
|
|
33
|
+
if (!element) throw new Error(`Token stamp studio element missing: ${selector}`);
|
|
34
|
+
return element;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getElements(root: HTMLElement): StudioElements {
|
|
38
|
+
return {
|
|
39
|
+
root,
|
|
40
|
+
canvas: getElement(root, '[data-token-canvas]'),
|
|
41
|
+
frames: getElement(root, '[data-frame-gallery]'),
|
|
42
|
+
textLayers: getElement(root, '[data-text-layers]'),
|
|
43
|
+
imageInput: getElement(root, '[data-image-input]'),
|
|
44
|
+
batchInput: getElement(root, '[data-batch-input]'),
|
|
45
|
+
textInput: getElement(root, '[data-text-input]'),
|
|
46
|
+
textSize: getElement(root, '[data-text-size]'),
|
|
47
|
+
imageZoom: getElement(root, '[data-image-zoom]'),
|
|
48
|
+
scale: getElement(root, '[data-scale]'),
|
|
49
|
+
borderWidth: getElement(root, '[data-border-width]'),
|
|
50
|
+
borderOpacity: getElement(root, '[data-border-opacity]'),
|
|
51
|
+
overlayOpacity: getElement(root, '[data-overlay-opacity]'),
|
|
52
|
+
currentImage: getElement(root, '[data-current-image]'),
|
|
53
|
+
batchStatus: getElement(root, '[data-batch-status]'),
|
|
54
|
+
library: getElement(root, '[data-marker-library]'),
|
|
55
|
+
markerName: getElement<HTMLInputElement>(root, '[data-marker-name]'),
|
|
56
|
+
copy: getElement<HTMLButtonElement>(root, '[data-copy]'),
|
|
57
|
+
download: getElement(root, '[data-download]'),
|
|
58
|
+
batchDownload: getElement(root, '[data-batch-download]'),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getColor(root: HTMLElement, name: string): HTMLInputElement {
|
|
63
|
+
return getElement<HTMLInputElement>(root, `[data-color="${name}"]`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function attachFrameEvents(elements: StudioElements, getState: () => TokenStampState, setState: (state: TokenStampState) => void): void {
|
|
67
|
+
elements.frames.addEventListener('click', (event) => {
|
|
68
|
+
const target = event.target as HTMLElement;
|
|
69
|
+
const button = target.closest<HTMLButtonElement>('[data-frame-id]');
|
|
70
|
+
if (button?.dataset.frameId) setState({ ...getState(), frameId: button.dataset.frameId });
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function attachColorEvents(elements: StudioElements, getState: () => TokenStampState, setState: (state: TokenStampState) => void): void {
|
|
75
|
+
const colors: Array<[string, keyof TokenStampState]> = [['background', 'background'], ['border', 'border'], ['text', 'textColor'], ['overlay', 'overlay']];
|
|
76
|
+
colors.forEach(([name, key]) => getColor(elements.root, name).addEventListener('input', (event) => {
|
|
77
|
+
setState({ ...getState(), [key]: (event.target as HTMLInputElement).value } as TokenStampState);
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function attachRangeEvents(elements: StudioElements, getState: () => TokenStampState, setState: (state: TokenStampState) => void): void {
|
|
82
|
+
const ranges: Array<[HTMLInputElement, keyof TokenStampState]> = [
|
|
83
|
+
[elements.imageZoom, 'imageZoom'], [elements.scale, 'scale'], [elements.borderWidth, 'borderWidth'], [elements.borderOpacity, 'borderOpacity'], [elements.overlayOpacity, 'overlayOpacity'],
|
|
84
|
+
];
|
|
85
|
+
ranges.forEach(([input, key]) => input.addEventListener('input', (event) => setState({ ...getState(), [key]: Number((event.target as HTMLInputElement).value) } as TokenStampState)));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function updateDraftText(value: string, getState: () => TokenStampState, setState: (state: TokenStampState) => void): void {
|
|
89
|
+
const current = getState();
|
|
90
|
+
if (!current.selectedTextId && value.trim()) {
|
|
91
|
+
const text = { ...createTextLayer(current.texts.length + 1), text: value };
|
|
92
|
+
setState({ ...current, texts: [...current.texts, text], selectedTextId: text.id });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
setState({ ...current, texts: updateTextLayer(current.texts, current.selectedTextId, { text: value }) });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function attachTextEvents(elements: StudioElements, getState: () => TokenStampState, setState: (state: TokenStampState) => void, ui: TokenStampUI): void {
|
|
99
|
+
elements.textLayers.addEventListener('click', (event) => {
|
|
100
|
+
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-text-id]');
|
|
101
|
+
if (button?.dataset.textId) setState({ ...getState(), selectedTextId: button.dataset.textId });
|
|
102
|
+
});
|
|
103
|
+
elements.textInput.addEventListener('input', (event) => updateDraftText((event.target as HTMLInputElement).value, getState, setState));
|
|
104
|
+
elements.textSize.addEventListener('input', (event) => setState({ ...getState(), texts: updateTextLayer(getState().texts, getState().selectedTextId, { size: Number((event.target as HTMLInputElement).value) }) }));
|
|
105
|
+
elements.root.querySelectorAll<HTMLButtonElement>('[data-align]').forEach((button) => button.addEventListener('click', () => {
|
|
106
|
+
const align = button.dataset.align as TextAlign;
|
|
107
|
+
setState({ ...getState(), texts: updateTextLayer(getState().texts, getState().selectedTextId, { align }) });
|
|
108
|
+
}));
|
|
109
|
+
getElement<HTMLButtonElement>(elements.root, '[data-add-text]').addEventListener('click', () => {
|
|
110
|
+
const text = createTextLayer(getState().texts.length + 1);
|
|
111
|
+
setState({ ...getState(), texts: [...getState().texts, text], selectedTextId: text.id });
|
|
112
|
+
});
|
|
113
|
+
getElement<HTMLButtonElement>(elements.root, '[data-remove-text]').addEventListener('click', () => {
|
|
114
|
+
const current = getState();
|
|
115
|
+
const texts = current.texts.filter((text) => text.id !== current.selectedTextId);
|
|
116
|
+
setState({ ...current, texts, selectedTextId: texts[0]?.id ?? null });
|
|
117
|
+
});
|
|
118
|
+
getElement<HTMLElement>(elements.root, '[data-text-hint]').textContent = ui.textHint;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function attachCanvasEvents(elements: StudioElements, getState: () => TokenStampState, setState: (state: TokenStampState) => void): void {
|
|
122
|
+
let drag: { mode: 'image' | 'text'; start: { x: number; y: number }; imageX: number; imageY: number; textX: number; textY: number } | null = null;
|
|
123
|
+
elements.canvas.addEventListener('pointerdown', (event) => {
|
|
124
|
+
const point = pointerToCanvas(elements.canvas, event);
|
|
125
|
+
const state = getState();
|
|
126
|
+
const text = state.texts.find((item) => hitTestText(item, point));
|
|
127
|
+
if (text) setState({ ...state, selectedTextId: text.id });
|
|
128
|
+
drag = { mode: text ? 'text' : 'image', start: point, imageX: state.imageX, imageY: state.imageY, textX: text?.x ?? 0.5, textY: text?.y ?? 0.8 };
|
|
129
|
+
elements.canvas.setPointerCapture(event.pointerId);
|
|
130
|
+
});
|
|
131
|
+
elements.canvas.addEventListener('pointermove', (event) => {
|
|
132
|
+
if (!drag) return;
|
|
133
|
+
const point = pointerToCanvas(elements.canvas, event);
|
|
134
|
+
const state = getState();
|
|
135
|
+
const delta = { x: point.x - drag.start.x, y: point.y - drag.start.y };
|
|
136
|
+
if (drag.mode === 'image') setState({ ...state, imageX: clamp(drag.imageX + delta.x, -0.5, 0.5), imageY: clamp(drag.imageY + delta.y, -0.5, 0.5) });
|
|
137
|
+
else setState({ ...state, texts: updateTextLayer(state.texts, state.selectedTextId, { x: clamp(drag.textX + delta.x, 0.12, 0.88), y: clamp(drag.textY + delta.y, 0.12, 0.9) }) });
|
|
138
|
+
});
|
|
139
|
+
elements.canvas.addEventListener('pointerup', () => { drag = null; });
|
|
140
|
+
elements.canvas.addEventListener('pointercancel', () => { drag = null; });
|
|
141
|
+
elements.canvas.addEventListener('wheel', (event) => {
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
const nextZoom = clamp(getState().imageZoom - event.deltaY * 0.001, 0.7, 1.8);
|
|
144
|
+
setState({ ...getState(), imageZoom: nextZoom });
|
|
145
|
+
}, { passive: false });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function attachStudioInteractions(elements: StudioElements, ui: TokenStampUI, store: StudioStore): void {
|
|
149
|
+
attachFrameEvents(elements, store.getState, store.setState);
|
|
150
|
+
attachRandomEvents(elements, store.getState, store.setState);
|
|
151
|
+
attachColorEvents(elements, store.getState, store.setState);
|
|
152
|
+
attachRangeEvents(elements, store.getState, store.setState);
|
|
153
|
+
attachTextEvents(elements, store.getState, store.setState, ui);
|
|
154
|
+
attachCanvasEvents(elements, store.getState, store.setState);
|
|
155
|
+
const context = { getState: store.getState, setState: store.setState, ui, getMarkers: store.getMarkers, getImage: store.getImage, selectMarker: store.selectMarker, addMarker: store.addMarker, removeMarker: store.removeMarker, setImage: store.setImage };
|
|
156
|
+
attachWorkspaceEvents(elements, context);
|
|
157
|
+
attachFileEvents(elements, context);
|
|
158
|
+
attachBatchEvents(elements, context);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function mountTokenStampStudio(root: HTMLElement, ui: TokenStampUI): Promise<void> {
|
|
162
|
+
const elements = getElements(root);
|
|
163
|
+
const store = new StudioStore(elements, ui);
|
|
164
|
+
await store.restore();
|
|
165
|
+
store.render();
|
|
166
|
+
attachStudioInteractions(elements, ui, store);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function attachRandomEvents(elements: StudioElements, getState: () => TokenStampState, setState: (state: TokenStampState) => void): void {
|
|
170
|
+
getElement<HTMLButtonElement>(elements.root, '[data-random-frame]').addEventListener('click', () => {
|
|
171
|
+
const available = FRAME_PRESETS.filter((frame) => frame.id !== getState().frameId);
|
|
172
|
+
const frame = available[Math.floor(Math.random() * available.length)] ?? FRAME_PRESETS[0]!;
|
|
173
|
+
setState({ ...getState(), frameId: frame.id });
|
|
174
|
+
});
|
|
175
|
+
getElement<HTMLButtonElement>(elements.root, '[data-random-colors]').addEventListener('click', () => {
|
|
176
|
+
const palette = getRandomPalette();
|
|
177
|
+
setState({ ...getState(), ...palette, overlayOpacity: 0.18 });
|
|
178
|
+
});
|
|
179
|
+
}
|