@jjlmoya/utils-tabletop 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/entries.ts +4 -0
  3. package/src/tests/locale_completeness.test.ts +1 -1
  4. package/src/tests/tool_validation.test.ts +1 -1
  5. package/src/tool/dice-roller-simulator/component.astro +116 -26
  6. package/src/tool/scatter-direction-selector/animation.ts +278 -0
  7. package/src/tool/scatter-direction-selector/bibliography.astro +16 -0
  8. package/src/tool/scatter-direction-selector/bibliography.ts +12 -0
  9. package/src/tool/scatter-direction-selector/client.ts +293 -0
  10. package/src/tool/scatter-direction-selector/component.astro +183 -0
  11. package/src/tool/scatter-direction-selector/entry.ts +59 -0
  12. package/src/tool/scatter-direction-selector/history.ts +71 -0
  13. package/src/tool/scatter-direction-selector/i18n/de.ts +143 -0
  14. package/src/tool/scatter-direction-selector/i18n/en.ts +231 -0
  15. package/src/tool/scatter-direction-selector/i18n/es.ts +143 -0
  16. package/src/tool/scatter-direction-selector/i18n/fr.ts +143 -0
  17. package/src/tool/scatter-direction-selector/i18n/id.ts +143 -0
  18. package/src/tool/scatter-direction-selector/i18n/it.ts +143 -0
  19. package/src/tool/scatter-direction-selector/i18n/ja.ts +143 -0
  20. package/src/tool/scatter-direction-selector/i18n/ko.ts +143 -0
  21. package/src/tool/scatter-direction-selector/i18n/nl.ts +143 -0
  22. package/src/tool/scatter-direction-selector/i18n/pl.ts +143 -0
  23. package/src/tool/scatter-direction-selector/i18n/pt.ts +143 -0
  24. package/src/tool/scatter-direction-selector/i18n/ru.ts +143 -0
  25. package/src/tool/scatter-direction-selector/i18n/sv.ts +143 -0
  26. package/src/tool/scatter-direction-selector/i18n/tr.ts +143 -0
  27. package/src/tool/scatter-direction-selector/i18n/zh.ts +143 -0
  28. package/src/tool/scatter-direction-selector/index.ts +10 -0
  29. package/src/tool/scatter-direction-selector/logic.test.ts +62 -0
  30. package/src/tool/scatter-direction-selector/logic.ts +52 -0
  31. package/src/tool/scatter-direction-selector/scatter-direction-selector.css +809 -0
  32. package/src/tool/scatter-direction-selector/seo.astro +16 -0
  33. package/src/tool/scatter-direction-selector/settings.ts +81 -0
  34. package/src/tool/scatter-direction-selector/sound.ts +22 -0
  35. package/src/tools.ts +2 -0
@@ -0,0 +1,293 @@
1
+ import { rollScatter } from './logic';
2
+ import type { ScatterConfig } from './logic';
3
+ import { Animator, Particle } from './animation';
4
+ import { HistoryManager } from './history';
5
+ import { SettingsBinder } from './settings';
6
+ import { SoundManager } from './sound';
7
+
8
+ class ScatterSelectorApp {
9
+ private root: HTMLElement;
10
+ private canvas: HTMLCanvasElement;
11
+ private ctx: CanvasRenderingContext2D;
12
+ private compass: HTMLElement;
13
+ private pointer: HTMLElement;
14
+ private rollBtn: HTMLElement;
15
+ private displayStatus: HTMLElement;
16
+ private displayNums: HTMLElement;
17
+ private ticksGroup: SVGGElement;
18
+ private animator: Animator;
19
+ private history: HistoryManager;
20
+ private settings: SettingsBinder;
21
+ private sound: SoundManager;
22
+
23
+ private currentAngle: number = 0;
24
+ private scatterAngle: number = 0;
25
+ private particles: Particle[] = [];
26
+
27
+ private isDragging: boolean = false;
28
+ private dragStartAngle: number = 0;
29
+ private compassBaseAngle: number = 0;
30
+
31
+ constructor(root: HTMLElement) {
32
+ this.root = root;
33
+ this.canvas = root.querySelector('#sds-particles') as HTMLCanvasElement;
34
+ this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D;
35
+ this.compass = root.querySelector('#sds-compass') as HTMLElement;
36
+ this.pointer = root.querySelector('#sds-pointer') as HTMLElement;
37
+ this.rollBtn = root.querySelector('#sds-roll-btn') as HTMLElement;
38
+ this.displayStatus = root.querySelector('#sds-display-status') as HTMLElement;
39
+ this.displayNums = root.querySelector('#sds-display-nums') as HTMLElement;
40
+ this.ticksGroup = root.querySelector('#sds-ticks') as SVGGElement;
41
+ this.animator = new Animator(root.querySelector('#sds-anim-canvas') as HTMLCanvasElement);
42
+ this.history = new HistoryManager(root.querySelector('#sds-history-list') as HTMLElement, 'sds-history');
43
+ this.settings = new SettingsBinder();
44
+ this.sound = new SoundManager();
45
+
46
+ this.initCanvas();
47
+ this.initTicks();
48
+ this.bindSettings();
49
+ this.observeTheme();
50
+ this.bindCompassDrag();
51
+ this.rollBtn.addEventListener('click', () => this.roll());
52
+ this.root.querySelector('#sds-clear-history')?.addEventListener('click', () => this.history.clear());
53
+ this.root.querySelector('#sds-sound-toggle')?.addEventListener('click', () => this.toggleSound());
54
+ this.root.addEventListener('click', (e) => this.spawnClickParticles(e));
55
+ this.loop();
56
+ }
57
+
58
+ private initCanvas(): void {
59
+ const resize = () => {
60
+ this.canvas.width = this.root.clientWidth;
61
+ this.canvas.height = this.root.clientHeight;
62
+ };
63
+ resize();
64
+ window.addEventListener('resize', resize);
65
+ }
66
+
67
+ private resolveColor(property: string, fallback: string): string {
68
+ return getComputedStyle(document.documentElement).getPropertyValue(property).trim() || fallback;
69
+ }
70
+
71
+ private makeTick(i: number): void {
72
+ const names8 = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
73
+ const ss = 360 / this.settings.getSectors();
74
+ const angle = i * ss;
75
+ const rad = (angle - 90) * (Math.PI / 180);
76
+ const tc = this.resolveColor('--text-base', '#e2e8f0');
77
+ const lc = this.resolveColor('--border-base', '#334155');
78
+
79
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
80
+ line.setAttribute('x1', String(100 + Math.cos(rad) * 80));
81
+ line.setAttribute('y1', String(100 + Math.sin(rad) * 80));
82
+ line.setAttribute('x2', String(100 + Math.cos(rad) * 92));
83
+ line.setAttribute('y2', String(100 + Math.sin(rad) * 92));
84
+ line.setAttribute('stroke', lc);
85
+ line.setAttribute('stroke-width', '1.5');
86
+ this.ticksGroup.appendChild(line);
87
+
88
+ const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
89
+ text.setAttribute('x', String(100 + Math.cos(rad) * 72));
90
+ text.setAttribute('y', String(100 + Math.sin(rad) * 72 + 3));
91
+ text.setAttribute('text-anchor', 'middle');
92
+ text.setAttribute('dominant-baseline', 'middle');
93
+ text.setAttribute('fill', tc);
94
+ text.setAttribute('font-size', '7');
95
+ text.setAttribute('font-weight', '700');
96
+ text.textContent = this.settings.getSectors() === 8 ? names8[i] : String(i || 12);
97
+ this.ticksGroup.appendChild(text);
98
+ }
99
+
100
+ private initTicks(): void {
101
+ this.ticksGroup.innerHTML = '';
102
+ for (let i = 0; i < this.settings.getSectors(); i++) {
103
+ this.makeTick(i);
104
+ }
105
+ }
106
+
107
+ private observeTheme(): void {
108
+ const observer = new MutationObserver(() => this.initTicks());
109
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
110
+ }
111
+
112
+ private bindSettings(): void {
113
+ this.settings.bind(this.root);
114
+ this.settings.onSectorsChange = () => this.initTicks();
115
+ }
116
+
117
+ private toggleSound(): void {
118
+ this.sound.enabled = !this.sound.enabled;
119
+ const on = this.root.querySelector('#sds-sound-on-icon') as HTMLElement;
120
+ const off = this.root.querySelector('#sds-sound-off-icon') as HTMLElement;
121
+ on.style.display = this.sound.enabled ? 'block' : 'none';
122
+ off.style.display = this.sound.enabled ? 'none' : 'block';
123
+ }
124
+
125
+ private bindCompassDrag(): void {
126
+ this.compass.addEventListener('pointerdown', (e) => this.startDrag(e));
127
+ window.addEventListener('pointermove', (e) => this.drag(e));
128
+ window.addEventListener('pointerup', () => this.endDrag());
129
+ }
130
+
131
+ private getCompassCenter(): { cx: number; cy: number } {
132
+ const r = this.compass.getBoundingClientRect();
133
+ return { cx: r.left + r.width / 2, cy: r.top + r.height / 2 };
134
+ }
135
+
136
+ private startDrag(e: PointerEvent): void {
137
+ this.isDragging = true;
138
+ this.compass.setPointerCapture(e.pointerId);
139
+ const { cx, cy } = this.getCompassCenter();
140
+ this.dragStartAngle = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI);
141
+ this.compassBaseAngle = this.currentAngle;
142
+ }
143
+
144
+ private drag(e: PointerEvent): void {
145
+ if (!this.isDragging) return;
146
+ const { cx, cy } = this.getCompassCenter();
147
+ const angle = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI);
148
+ this.setAngle((this.compassBaseAngle + angle - this.dragStartAngle + 360) % 360);
149
+ }
150
+
151
+ private endDrag(): void { this.isDragging = false; }
152
+
153
+ private setAngle(angle: number): void {
154
+ const prev = this.currentAngle;
155
+ this.currentAngle = Math.round(angle);
156
+ this.pointer.style.transform = `rotate(${this.currentAngle}deg)`;
157
+ this.displayNums.textContent = `${String(this.currentAngle).padStart(3, '0')}\u00b0`;
158
+
159
+ const ss = 360 / this.settings.getSectors();
160
+ if (Math.floor(prev / ss) !== Math.floor(this.currentAngle / ss)) {
161
+ this.sound.play(440, 'sine', 0.03);
162
+ }
163
+ }
164
+
165
+ private roll(): void {
166
+ if (this.animator.active) return;
167
+
168
+ const config: ScatterConfig = {
169
+ diceType: this.settings.getDice() as 'd6' | '2d6' | 'd10' | 'custom',
170
+ customMax: this.settings.getMaxDist(),
171
+ hitChance: this.settings.getHitChance()
172
+ };
173
+
174
+ const result = rollScatter(config);
175
+ this.sound.play(220, 'sine', 0.15);
176
+
177
+ const core = this.root.querySelector('.sds-center-core') as HTMLElement;
178
+ core.classList.remove('sds-peak-result', 'sds-warning-result');
179
+
180
+ const sp = this.root.querySelector('#sds-scatter-pointer') as HTMLElement;
181
+ sp.style.display = 'none';
182
+
183
+ this.scatterAngle = this.settings.getMode() ? this.currentAngle : result.angle;
184
+ const angleRad = (this.scatterAngle - 90) * (Math.PI / 180);
185
+ const maxDist = this.getMaxDistance();
186
+
187
+ let tx = 100, ty = 100;
188
+ if (!result.isHit) {
189
+ const vr = 15 + (result.distance / Math.max(maxDist, 1)) * 70;
190
+ tx = 100 + Math.cos(angleRad) * vr;
191
+ ty = 100 + Math.sin(angleRad) * vr;
192
+ }
193
+
194
+ this.displayStatus.textContent = '\u2014\u2014\u2014';
195
+ this.displayNums.textContent = '\u2014\u2014\u2014';
196
+
197
+ this.animator.start({ hit: result.isHit, dist: result.distance, tgtX: tx, tgtY: ty });
198
+ }
199
+
200
+ private getMaxDistance(): number {
201
+ const d = this.settings.getDice();
202
+ if (d === 'd6') return 6;
203
+ if (d === '2d6') return 12;
204
+ if (d === 'd10') return 10;
205
+ return Math.max(this.settings.getMaxDist(), 1);
206
+ }
207
+
208
+ private finishAnim(): void {
209
+ const result = this.animator.getResult();
210
+
211
+ this.displayStatus.textContent = result.isHit ? 'DIRECT HIT' : 'SCATTER';
212
+
213
+ const core = this.root.querySelector('.sds-center-core') as HTMLElement;
214
+ const sp = this.root.querySelector('#sds-scatter-pointer') as HTMLElement;
215
+
216
+ if (result.isHit) {
217
+ this.displayNums.textContent = 'HIT';
218
+ core.classList.add('sds-peak-result');
219
+ this.sound.play(660, 'triangle', 0.4);
220
+ } else {
221
+ this.displayNums.textContent = `${result.distance}"`;
222
+ core.classList.add('sds-warning-result');
223
+ this.sound.play(180, 'sawtooth', 0.35);
224
+ const distLabel = this.root.querySelector('#sds-warning-dist');
225
+ if (distLabel) distLabel.textContent = `${result.distance}"`;
226
+
227
+ sp.style.display = 'block';
228
+ sp.style.transition = 'transform 0.3s ease-out';
229
+ sp.style.transform = `rotate(${this.scatterAngle}deg)`;
230
+ void sp.offsetHeight;
231
+ sp.style.animation = 'none';
232
+ void sp.offsetHeight;
233
+ sp.style.animation = '';
234
+ }
235
+
236
+ this.history.add(result.isHit, this.scatterAngle, result.distance);
237
+ }
238
+
239
+ private spawnClickParticles(e: MouseEvent): void {
240
+ const rect = this.root.getBoundingClientRect();
241
+ const x = e.clientX - rect.left;
242
+ const y = e.clientY - rect.top;
243
+
244
+ const p = document.createElement('div');
245
+ p.className = 'sds-click-particle';
246
+ p.style.left = `${x}px`;
247
+ p.style.top = `${y}px`;
248
+ p.textContent = '+';
249
+ this.root.appendChild(p);
250
+
251
+ setTimeout(() => p.remove(), 800);
252
+ }
253
+
254
+ private loop(): void {
255
+ requestAnimationFrame(() => this.loop());
256
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
257
+
258
+ if (this.animator.active) {
259
+ const done = this.animator.drawFrame();
260
+ if (done) {
261
+ this.animator.reset();
262
+ this.finishAnim();
263
+ }
264
+ }
265
+
266
+ this.drawAmbientParticles();
267
+ }
268
+
269
+ private drawAmbientParticles(): void {
270
+ if (!this.animator.active && Math.random() < 0.15) {
271
+ const angleRad = (this.currentAngle - 90) * (Math.PI / 180);
272
+ this.particles.push(new Particle(this.canvas.width, this.canvas.height, angleRad, false));
273
+ }
274
+
275
+ this.particles = this.particles.filter((p) => {
276
+ p.update();
277
+ if (p.alpha <= 0) return false;
278
+ this.ctx.fillStyle = `rgba(${p.color}, ${p.alpha})`;
279
+ this.ctx.beginPath();
280
+ this.ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
281
+ this.ctx.fill();
282
+ return true;
283
+ });
284
+ }
285
+ }
286
+
287
+ function init(): void {
288
+ const el = document.querySelector('[data-tool="scatter-direction-selector"]') as HTMLElement;
289
+ if (el) new ScatterSelectorApp(el);
290
+ }
291
+
292
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
293
+ else init();
@@ -0,0 +1,183 @@
1
+ ---
2
+ import './scatter-direction-selector.css';
3
+
4
+ interface Props {
5
+ ui: Record<string, string>;
6
+ }
7
+
8
+ const { ui } = Astro.props;
9
+ ---
10
+
11
+ <div class="sds-container" data-tool="scatter-direction-selector" data-ui={JSON.stringify(ui)}>
12
+ <canvas id="sds-particles" class="sds-particles-canvas"></canvas>
13
+
14
+ <header class="sds-header">
15
+ <div class="sds-header-main">
16
+ <span class="sds-subtitle">{ui.dragHint}</span>
17
+ </div>
18
+ <button type="button" id="sds-sound-toggle" class="sds-sound-btn" aria-label="Toggle Sound">
19
+ <svg id="sds-sound-on-icon" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
20
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
21
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
22
+ </svg>
23
+ <svg id="sds-sound-off-icon" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" style="display: none;">
24
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
25
+ <line x1="23" y1="9" x2="17" y2="15"></line>
26
+ <line x1="17" y1="9" x2="23" y2="15"></line>
27
+ </svg>
28
+ </button>
29
+ </header>
30
+
31
+ <main class="sds-grid">
32
+ <section class="sds-compass-area">
33
+ <div class="sds-compass-wrapper">
34
+ <div id="sds-compass" class="sds-compass">
35
+ <div class="sds-compass-ring"></div>
36
+ <svg class="sds-compass-svg" viewBox="0 0 200 200">
37
+ <defs>
38
+ <radialGradient id="compass-glow" cx="50%" cy="50%" r="50%">
39
+ <stop offset="0%" stop-color="var(--sds-glow-inner)" stop-opacity="0.15"></stop>
40
+ <stop offset="100%" stop-color="var(--sds-glow-outer)" stop-opacity="0"></stop>
41
+ </radialGradient>
42
+ </defs>
43
+ <circle cx="100" cy="100" r="95" fill="url(#compass-glow)" stroke="var(--sds-border-glow)" stroke-width="2"></circle>
44
+ <circle cx="100" cy="100" r="80" fill="none" stroke="var(--sds-border)" stroke-width="1" stroke-dasharray="2, 4"></circle>
45
+ <g id="sds-ticks"></g>
46
+ </svg>
47
+ <div id="sds-pointer" class="sds-pointer sds-pointer-wind">
48
+ <svg viewBox="0 0 40 160" class="sds-pointer-svg">
49
+ <path d="M20,0 L35,60 L24,55 L20,160 L16,55 L5,60 Z" fill="var(--sds-accent)"></path>
50
+ </svg>
51
+ </div>
52
+ <div id="sds-scatter-pointer" class="sds-pointer sds-pointer-scatter" style="display: none;">
53
+ <svg viewBox="0 0 40 160" class="sds-pointer-svg">
54
+ <path d="M20,4 L30,50 L26,44 L32,60 L22,52 L20,160 L18,52 L8,60 L14,44 L10,50 Z" fill="var(--sds-danger)"></path>
55
+ </svg>
56
+ </div>
57
+ <div class="sds-center-core">
58
+ <div id="sds-sigil-target" class="sds-sigil">
59
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
60
+ <circle cx="12" cy="12" r="10"></circle>
61
+ <circle cx="12" cy="12" r="6"></circle>
62
+ <circle cx="12" cy="12" r="2"></circle>
63
+ <line x1="12" y1="1" x2="12" y2="23"></line>
64
+ <line x1="1" y1="12" x2="23" y2="12"></line>
65
+ </svg>
66
+ </div>
67
+ <div id="sds-sigil-warning" class="sds-sigil">
68
+ <svg viewBox="0 0 24 24" fill="none">
69
+ <circle cx="12" cy="12" r="10.5" stroke="currentColor" stroke-width="0.5" opacity="0.25"></circle>
70
+ <text id="sds-warning-dist" x="12" y="13" text-anchor="middle" dominant-baseline="central" font-size="10" font-weight="900" stroke="none" fill="currentColor">2"</text>
71
+ </svg>
72
+ </div>
73
+ <div id="sds-digital-display" class="sds-digital-display">
74
+ <div id="sds-display-status" class="sds-display-status">READY</div>
75
+ <div id="sds-display-nums" class="sds-display-nums">000°</div>
76
+ </div>
77
+ </div>
78
+ </div>
79
+ <canvas id="sds-anim-canvas" class="sds-anim-canvas"></canvas>
80
+ </div>
81
+ <ol class="sds-guide">
82
+ <li class="sds-guide-step">
83
+ <span class="sds-guide-num">1</span>
84
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="sds-guide-icon">
85
+ <circle cx="12" cy="12" r="10"></circle>
86
+ <line x1="12" y1="8" x2="12" y2="16"></line>
87
+ <line x1="8" y1="12" x2="16" y2="12"></line>
88
+ </svg>
89
+ <span class="sds-guide-label">{ui.guideStep1}</span>
90
+ </li>
91
+ <li class="sds-guide-step">
92
+ <span class="sds-guide-num">2</span>
93
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="sds-guide-icon">
94
+ <path d="M12 20h9"></path>
95
+ <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
96
+ </svg>
97
+ <span class="sds-guide-label">{ui.guideStep2}</span>
98
+ </li>
99
+ <li class="sds-guide-step sds-guide-step-block">
100
+ <span class="sds-guide-num">3</span>
101
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="sds-guide-icon">
102
+ <polygon points="5 3 19 12 5 21 5 3"></polygon>
103
+ </svg>
104
+ <div class="sds-guide-outcomes">
105
+ <span class="sds-guide-outcome sds-guide-outcome--hit">
106
+ <span class="sds-guide-dot sds-guide-dot-hit"></span>
107
+ {ui.guideStep3}
108
+ </span>
109
+ <span class="sds-guide-outcome sds-guide-outcome--scatter">
110
+ <span class="sds-guide-dot sds-guide-dot-scatter"></span>
111
+ {ui.guideStep3Scatter}
112
+ </span>
113
+ </div>
114
+ </li>
115
+ </ol>
116
+ </section>
117
+
118
+ <section class="sds-control-panel">
119
+ <div class="sds-panel-card">
120
+ <h2 class="sds-panel-title">{ui.setupTitle}</h2>
121
+
122
+ <div class="sds-control-group">
123
+ <span class="sds-label">{ui.sectorsLabel}</span>
124
+ <div class="sds-segmented-control">
125
+ <button type="button" class="sds-segment-btn active" data-sectors="8">{ui.sectors8}</button>
126
+ <button type="button" class="sds-segment-btn" data-sectors="12">{ui.sectors12}</button>
127
+ </div>
128
+ </div>
129
+
130
+ <div class="sds-control-group">
131
+ <span class="sds-label">{ui.scatterModeLabel}</span>
132
+ <div class="sds-segmented-control">
133
+ <button type="button" id="sds-mode-random" class="sds-segment-btn active" data-mode="random">{ui.scatterModeRandom}</button>
134
+ <button type="button" id="sds-mode-wind" class="sds-segment-btn" data-mode="wind">{ui.scatterModeWind}</button>
135
+ </div>
136
+ <span id="sds-wind-hint" class="sds-mode-hint" style="display: none;">{ui.scatterModeWindHint}</span>
137
+ </div>
138
+
139
+ <div class="sds-control-group">
140
+ <span class="sds-label">{ui.diceLabel}</span>
141
+ <div class="sds-select-wrapper">
142
+ <select id="sds-dice-select" class="sds-select">
143
+ <option value="d6">{ui.diceD6}</option>
144
+ <option value="2d6">{ui.dice2D6}</option>
145
+ <option value="d10">{ui.diceD10}</option>
146
+ <option value="custom">{ui.diceCustom}</option>
147
+ </select>
148
+ </div>
149
+ </div>
150
+
151
+ <div id="sds-custom-dist-wrapper" class="sds-control-group" style="display: none;">
152
+ <label class="sds-label" for="sds-custom-max">
153
+ <span>{ui.customMaxLabel}</span>
154
+ </label>
155
+ <input type="number" id="sds-custom-max" class="sds-input" value="12" min="1" max="100" />
156
+ </div>
157
+
158
+ <div class="sds-control-group">
159
+ <div class="sds-label-row">
160
+ <span class="sds-label">{ui.hitChanceLabel}</span>
161
+ <span id="sds-hit-chance-val" class="sds-value-display">33%</span>
162
+ </div>
163
+ <input type="range" id="sds-hit-chance" class="sds-slider" min="0" max="100" value="33" />
164
+ </div>
165
+
166
+ <button type="button" id="sds-roll-btn" class="sds-btn-primary">
167
+ <span class="sds-btn-glow"></span>
168
+ <span>{ui.rollButton}</span>
169
+ </button>
170
+ </div>
171
+
172
+ <div class="sds-panel-card">
173
+ <div class="sds-header-row">
174
+ <h2 class="sds-panel-title">{ui.historyTitle}</h2>
175
+ <button type="button" id="sds-clear-history" class="sds-btn-secondary">{ui.clearHistory}</button>
176
+ </div>
177
+ <div id="sds-history-list" class="sds-history-list"></div>
178
+ </div>
179
+ </section>
180
+ </main>
181
+ </div>
182
+
183
+ <script src="./client.ts"></script>
@@ -0,0 +1,59 @@
1
+ import type { TabletopToolEntry, ToolLocaleContent } from '../../types';
2
+
3
+ export type ScatterSelectorUI = {
4
+ title: string;
5
+ setupTitle: string;
6
+ sectorsLabel: string;
7
+ sectors8: string;
8
+ sectors12: string;
9
+ diceLabel: string;
10
+ diceD6: string;
11
+ dice2D6: string;
12
+ diceD10: string;
13
+ diceCustom: string;
14
+ customMaxLabel: string;
15
+ hitChanceLabel: string;
16
+ rollButton: string;
17
+ hitResult: string;
18
+ scatterResult: string;
19
+ distanceLabel: string;
20
+ angleLabel: string;
21
+ dragHint: string;
22
+ historyTitle: string;
23
+ clearHistory: string;
24
+ presetTitle: string;
25
+ soundOn: string;
26
+ soundOff: string;
27
+ guideStep1: string;
28
+ guideStep2: string;
29
+ guideStep3: string;
30
+ guideStep3Scatter: string;
31
+ scatterModeLabel: string;
32
+ scatterModeRandom: string;
33
+ scatterModeWind: string;
34
+ scatterModeWindHint: string;
35
+ };
36
+
37
+ export type ScatterSelectorLocaleContent = ToolLocaleContent<ScatterSelectorUI>;
38
+
39
+ export const scatterDirectionSelector: TabletopToolEntry<ScatterSelectorUI> = {
40
+ id: 'scatter-direction-selector',
41
+ icons: { bg: 'mdi:compass-outline', fg: 'mdi:compass-rose' },
42
+ i18n: {
43
+ en: () => import('./i18n/en').then((m) => m.content),
44
+ de: () => import('./i18n/de').then((m) => m.content),
45
+ es: () => import('./i18n/es').then((m) => m.content),
46
+ fr: () => import('./i18n/fr').then((m) => m.content),
47
+ id: () => import('./i18n/id').then((m) => m.content),
48
+ it: () => import('./i18n/it').then((m) => m.content),
49
+ ja: () => import('./i18n/ja').then((m) => m.content),
50
+ ko: () => import('./i18n/ko').then((m) => m.content),
51
+ nl: () => import('./i18n/nl').then((m) => m.content),
52
+ pl: () => import('./i18n/pl').then((m) => m.content),
53
+ pt: () => import('./i18n/pt').then((m) => m.content),
54
+ ru: () => import('./i18n/ru').then((m) => m.content),
55
+ sv: () => import('./i18n/sv').then((m) => m.content),
56
+ tr: () => import('./i18n/tr').then((m) => m.content),
57
+ zh: () => import('./i18n/zh').then((m) => m.content),
58
+ },
59
+ };
@@ -0,0 +1,71 @@
1
+ interface HistoryItem {
2
+ isHit: boolean;
3
+ angle: number;
4
+ distance: number;
5
+ time: string;
6
+ }
7
+
8
+ export class HistoryManager {
9
+ private items: HistoryItem[] = [];
10
+ private listEl: HTMLElement;
11
+ private storageKey: string;
12
+
13
+ constructor(listEl: HTMLElement, storageKey: string) {
14
+ this.listEl = listEl;
15
+ this.storageKey = storageKey;
16
+ this.load();
17
+ this.render();
18
+ }
19
+
20
+ add(isHit: boolean, angle: number, distance: number): void {
21
+ const item: HistoryItem = {
22
+ isHit,
23
+ angle,
24
+ distance,
25
+ time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
26
+ };
27
+ this.items.unshift(item);
28
+ this.items = this.items.slice(0, 10);
29
+ this.save();
30
+ this.render();
31
+ }
32
+
33
+ clear(): void {
34
+ this.items = [];
35
+ this.save();
36
+ this.render();
37
+ }
38
+
39
+ getAll(): HistoryItem[] {
40
+ return this.items;
41
+ }
42
+
43
+ private save(): void {
44
+ localStorage.setItem(this.storageKey, JSON.stringify(this.items));
45
+ }
46
+
47
+ private load(): void {
48
+ const saved = localStorage.getItem(this.storageKey);
49
+ if (saved) {
50
+ try {
51
+ this.items = JSON.parse(saved);
52
+ } catch {
53
+ this.items = [];
54
+ }
55
+ }
56
+ }
57
+
58
+ private render(): void {
59
+ this.listEl.innerHTML = this.items
60
+ .map(
61
+ (h) => `
62
+ <div class="sds-history-item">
63
+ <span class="sds-history-badge ${h.isHit ? 'hit' : 'scatter'}">${h.isHit ? 'HIT' : 'SCATTER'}</span>
64
+ <span class="sds-history-val">${h.isHit ? '\u2014' : `${h.distance}" @ ${h.angle}\u00b0`}</span>
65
+ <span class="sds-history-time">${h.time}</span>
66
+ </div>
67
+ `
68
+ )
69
+ .join('');
70
+ }
71
+ }