@jjlmoya/utils-tabletop 1.10.0 → 1.12.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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/entries.ts +8 -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/decision-wheel/client.ts +2 -0
  6. package/src/tool/decision-wheel/components/SegmentEditor.astro +1 -1
  7. package/src/tool/decision-wheel/decision-wheel.css +23 -7
  8. package/src/tool/dice-roller-simulator/component.astro +116 -26
  9. package/src/tool/hidden-role-dealer/bibliography.astro +16 -0
  10. package/src/tool/hidden-role-dealer/bibliography.ts +12 -0
  11. package/src/tool/hidden-role-dealer/client.ts +265 -0
  12. package/src/tool/hidden-role-dealer/component.astro +184 -0
  13. package/src/tool/hidden-role-dealer/entry.ts +145 -0
  14. package/src/tool/hidden-role-dealer/hidden-role-dealer.css +604 -0
  15. package/src/tool/hidden-role-dealer/i18n/de.ts +195 -0
  16. package/src/tool/hidden-role-dealer/i18n/en.ts +279 -0
  17. package/src/tool/hidden-role-dealer/i18n/es.ts +279 -0
  18. package/src/tool/hidden-role-dealer/i18n/fr.ts +215 -0
  19. package/src/tool/hidden-role-dealer/i18n/id.ts +191 -0
  20. package/src/tool/hidden-role-dealer/i18n/it.ts +191 -0
  21. package/src/tool/hidden-role-dealer/i18n/ja.ts +191 -0
  22. package/src/tool/hidden-role-dealer/i18n/ko.ts +191 -0
  23. package/src/tool/hidden-role-dealer/i18n/nl.ts +191 -0
  24. package/src/tool/hidden-role-dealer/i18n/pl.ts +191 -0
  25. package/src/tool/hidden-role-dealer/i18n/pt.ts +191 -0
  26. package/src/tool/hidden-role-dealer/i18n/ru.ts +191 -0
  27. package/src/tool/hidden-role-dealer/i18n/sv.ts +191 -0
  28. package/src/tool/hidden-role-dealer/i18n/tr.ts +191 -0
  29. package/src/tool/hidden-role-dealer/i18n/zh.ts +191 -0
  30. package/src/tool/hidden-role-dealer/index.ts +10 -0
  31. package/src/tool/hidden-role-dealer/logic.test.ts +99 -0
  32. package/src/tool/hidden-role-dealer/logic.ts +134 -0
  33. package/src/tool/hidden-role-dealer/modules/custom-role-manager.ts +35 -0
  34. package/src/tool/hidden-role-dealer/modules/dealer-logic.ts +73 -0
  35. package/src/tool/hidden-role-dealer/modules/hold-controller.ts +38 -0
  36. package/src/tool/hidden-role-dealer/modules/particle-effects.ts +40 -0
  37. package/src/tool/hidden-role-dealer/modules/player-manager.ts +44 -0
  38. package/src/tool/hidden-role-dealer/modules/presets.ts +46 -0
  39. package/src/tool/hidden-role-dealer/modules/setup-handlers.ts +34 -0
  40. package/src/tool/hidden-role-dealer/modules/storage.ts +23 -0
  41. package/src/tool/hidden-role-dealer/modules/utils.ts +18 -0
  42. package/src/tool/hidden-role-dealer/modules/wizard-html.ts +161 -0
  43. package/src/tool/hidden-role-dealer/modules/wizard-renderer.ts +161 -0
  44. package/src/tool/hidden-role-dealer/modules/writer-handler.ts +70 -0
  45. package/src/tool/hidden-role-dealer/seo.astro +16 -0
  46. package/src/tool/hidden-role-dealer/types.ts +31 -0
  47. package/src/tool/scatter-direction-selector/animation.ts +278 -0
  48. package/src/tool/scatter-direction-selector/bibliography.astro +16 -0
  49. package/src/tool/scatter-direction-selector/bibliography.ts +12 -0
  50. package/src/tool/scatter-direction-selector/client.ts +296 -0
  51. package/src/tool/scatter-direction-selector/component.astro +184 -0
  52. package/src/tool/scatter-direction-selector/entry.ts +59 -0
  53. package/src/tool/scatter-direction-selector/history.ts +71 -0
  54. package/src/tool/scatter-direction-selector/i18n/de.ts +143 -0
  55. package/src/tool/scatter-direction-selector/i18n/en.ts +231 -0
  56. package/src/tool/scatter-direction-selector/i18n/es.ts +143 -0
  57. package/src/tool/scatter-direction-selector/i18n/fr.ts +143 -0
  58. package/src/tool/scatter-direction-selector/i18n/id.ts +143 -0
  59. package/src/tool/scatter-direction-selector/i18n/it.ts +143 -0
  60. package/src/tool/scatter-direction-selector/i18n/ja.ts +143 -0
  61. package/src/tool/scatter-direction-selector/i18n/ko.ts +143 -0
  62. package/src/tool/scatter-direction-selector/i18n/nl.ts +143 -0
  63. package/src/tool/scatter-direction-selector/i18n/pl.ts +143 -0
  64. package/src/tool/scatter-direction-selector/i18n/pt.ts +143 -0
  65. package/src/tool/scatter-direction-selector/i18n/ru.ts +143 -0
  66. package/src/tool/scatter-direction-selector/i18n/sv.ts +143 -0
  67. package/src/tool/scatter-direction-selector/i18n/tr.ts +143 -0
  68. package/src/tool/scatter-direction-selector/i18n/zh.ts +143 -0
  69. package/src/tool/scatter-direction-selector/index.ts +10 -0
  70. package/src/tool/scatter-direction-selector/logic.test.ts +62 -0
  71. package/src/tool/scatter-direction-selector/logic.ts +52 -0
  72. package/src/tool/scatter-direction-selector/scatter-direction-selector.css +794 -0
  73. package/src/tool/scatter-direction-selector/seo.astro +16 -0
  74. package/src/tool/scatter-direction-selector/settings.ts +81 -0
  75. package/src/tool/scatter-direction-selector/sound.ts +22 -0
  76. package/src/tools.ts +4 -0
@@ -0,0 +1,70 @@
1
+ import { assignRoles, generateImpostorRoles, calculateImpostorCount, getRandomSecretWord } from '../logic';
2
+ import type { ImpostorValues } from '../types';
3
+ import type { PlayerManager } from './player-manager';
4
+
5
+ export interface PreroundOptions {
6
+ secret: string;
7
+ preroundWords: string[];
8
+ preroundStep: number;
9
+ playerMgr: PlayerManager;
10
+ vals: ImpostorValues;
11
+ mode: string;
12
+ ui: Record<string, string>;
13
+ }
14
+
15
+ export interface PreroundResult {
16
+ preroundWords: string[];
17
+ preroundStep: number;
18
+ preroundActive: boolean;
19
+ impostorSecret: string;
20
+ finished: boolean;
21
+ }
22
+
23
+ function completePreround(options: PreroundOptions, words: string[]): string {
24
+ const secretWord = words[Math.floor(Math.random() * words.length)];
25
+ const players = options.playerMgr.getPlayers();
26
+ const impCount = calculateImpostorCount(options.mode, players.length, options.vals);
27
+ const nextPlayers = assignRoles(players, generateImpostorRoles(players.length, impCount, secretWord, options.ui));
28
+ options.playerMgr.setPlayers(nextPlayers);
29
+ return secretWord;
30
+ }
31
+
32
+ export function savePreroundWord(options: PreroundOptions): PreroundResult {
33
+ const nextWords = [...options.preroundWords, options.secret];
34
+ const nextStep = options.preroundStep + 1;
35
+ const totalPlayers = options.playerMgr.getPlayers().length;
36
+ if (nextStep < totalPlayers) {
37
+ return {
38
+ preroundWords: nextWords,
39
+ preroundStep: nextStep,
40
+ preroundActive: true,
41
+ impostorSecret: '',
42
+ finished: false
43
+ };
44
+ }
45
+ const secretWord = completePreround(options, nextWords);
46
+ return {
47
+ preroundWords: nextWords,
48
+ preroundStep: nextStep,
49
+ preroundActive: false,
50
+ impostorSecret: secretWord,
51
+ finished: true
52
+ };
53
+ }
54
+
55
+ export function saveWriterWord(
56
+ secret: string,
57
+ playerMgr: PlayerManager,
58
+ ui: Record<string, string>
59
+ ): string {
60
+ const actualSecret = secret.trim() || getRandomSecretWord();
61
+ const prefix = ui.roleCrewmateSecret || 'The Secret Word is:';
62
+ const players = playerMgr.getPlayers();
63
+ players.forEach(p => {
64
+ if (p.role && (p.role.id.startsWith('crew-') || p.role.id === 'writer')) {
65
+ p.role.secretInfo = `${prefix} ${actualSecret}`;
66
+ }
67
+ });
68
+ playerMgr.setPlayers(players);
69
+ return actualSecret;
70
+ }
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { hiddenRoleDealer } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props;
11
+ const loader = hiddenRoleDealer.i18n[locale] || hiddenRoleDealer.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,31 @@
1
+ export interface Role {
2
+ id: string;
3
+ name: string;
4
+ description: string;
5
+ team: string;
6
+ secretInfo: string;
7
+ alignment: 'good' | 'evil' | 'neutral';
8
+ quantity?: number | 'remaining';
9
+ }
10
+
11
+ export interface Player {
12
+ id: string;
13
+ name: string;
14
+ role: Role | null;
15
+ revealed: boolean;
16
+ }
17
+
18
+ export interface GamePreset {
19
+ id: string;
20
+ name: string;
21
+ roles: Role[];
22
+ }
23
+
24
+ export type DealerState = 'setup' | 'distributing' | 'revealed' | 'complete';
25
+
26
+ export interface ImpostorValues {
27
+ fixedVal: number;
28
+ percentVal: number;
29
+ minVal: number;
30
+ maxVal: number;
31
+ }
@@ -0,0 +1,278 @@
1
+ export class Particle {
2
+ x: number = 0;
3
+ y: number = 0;
4
+ vx: number = 0;
5
+ vy: number = 0;
6
+ size: number = 0;
7
+ alpha: number = 1;
8
+ color: string = '';
9
+
10
+ constructor(w: number, h: number, angleRad: number, burst: boolean) {
11
+ if (burst) {
12
+ this.x = w / 2;
13
+ this.y = h / 2;
14
+ const speed = Math.random() * 6 + 4;
15
+ this.vx = Math.cos(angleRad) * speed + (Math.random() - 0.5) * 2;
16
+ this.vy = Math.sin(angleRad) * speed + (Math.random() - 0.5) * 2;
17
+ this.size = Math.random() * 3 + 2;
18
+ this.color = '56, 189, 248';
19
+ } else {
20
+ this.x = Math.random() * w;
21
+ this.y = Math.random() * h;
22
+ this.vx = Math.cos(angleRad) * 0.8 + (Math.random() - 0.5) * 0.2;
23
+ this.vy = Math.sin(angleRad) * 0.8 + (Math.random() - 0.5) * 0.2;
24
+ this.size = Math.random() * 1.5 + 0.5;
25
+ this.color = '125, 211, 252';
26
+ }
27
+ }
28
+
29
+ update(): void {
30
+ this.x += this.vx;
31
+ this.y += this.vy;
32
+ this.alpha -= 0.012;
33
+ }
34
+ }
35
+
36
+ interface FrameCtx {
37
+ ctx: CanvasRenderingContext2D;
38
+ cx: number;
39
+ cy: number;
40
+ progress: number;
41
+ tgtX: number;
42
+ tgtY: number;
43
+ }
44
+
45
+ export class Animator {
46
+ canvas: HTMLCanvasElement;
47
+ ctx: CanvasRenderingContext2D;
48
+ active: boolean = false;
49
+ private startTime: number = 0;
50
+ private isHit: boolean = false;
51
+ private distance: number = 0;
52
+ private targetX: number = 100;
53
+ private targetY: number = 100;
54
+ private particles: Particle[] = [];
55
+
56
+ constructor(canvas: HTMLCanvasElement) {
57
+ this.canvas = canvas;
58
+ this.ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
59
+ this.canvas.width = 300;
60
+ this.canvas.height = 300;
61
+ }
62
+
63
+ start(opts: { hit: boolean; dist: number; tgtX: number; tgtY: number }): void {
64
+ this.active = true;
65
+ this.startTime = performance.now();
66
+ this.isHit = opts.hit;
67
+ this.distance = opts.dist;
68
+ this.targetX = opts.tgtX;
69
+ this.targetY = opts.tgtY;
70
+ this.particles = [];
71
+ }
72
+
73
+ drawFrame(): boolean {
74
+ const elapsed = performance.now() - this.startTime;
75
+ const duration = this.isHit ? 1800 : 2200;
76
+ const progress = Math.min(elapsed / duration, 1);
77
+ const w = this.canvas.width;
78
+ const h = this.canvas.height;
79
+ const cx = w / 2;
80
+ const cy = h / 2;
81
+ const s = w / 200;
82
+ const tgtX = cx + (this.targetX - 100) * s;
83
+ const tgtY = cy + (this.targetY - 100) * s;
84
+ const f: FrameCtx = { ctx: this.ctx, cx, cy, progress, tgtX, tgtY };
85
+
86
+ this.ctx.clearRect(0, 0, w, h);
87
+
88
+ if (this.isHit) {
89
+ this.drawHitPulse(f);
90
+ this.drawHitRings(f);
91
+ this.drawHitText(f);
92
+ } else {
93
+ this.drawScatterTravel(f);
94
+ this.drawScatterImpact(f);
95
+ }
96
+
97
+ this.drawParticles();
98
+ return progress >= 1;
99
+ }
100
+
101
+ reset(): void {
102
+ this.active = false;
103
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
104
+ this.particles = [];
105
+ }
106
+
107
+ getResult(): { isHit: boolean; distance: number } {
108
+ return { isHit: this.isHit, distance: this.distance };
109
+ }
110
+
111
+ private drawHitPulse(f: FrameCtx): void {
112
+ if (f.progress >= 0.35) return;
113
+ const t = f.progress / 0.35;
114
+ const pulse = Math.sin(t * Math.PI * 3) * 0.5 + 0.5;
115
+ const r = 5 + pulse * 18;
116
+ const g = f.ctx.createRadialGradient(f.cx, f.cy, 0, f.cx, f.cy, r);
117
+ g.addColorStop(0, `rgba(52, 211, 153, ${0.7 * (1 - pulse * 0.3)})`);
118
+ g.addColorStop(1, 'rgba(52, 211, 153, 0)');
119
+ f.ctx.fillStyle = g;
120
+ f.ctx.beginPath();
121
+ f.ctx.arc(f.cx, f.cy, r, 0, Math.PI * 2);
122
+ f.ctx.fill();
123
+ }
124
+
125
+ private drawHitRings(f: FrameCtx): void {
126
+ if (f.progress <= 0.3 || f.progress >= 0.85) return;
127
+ const t = (f.progress - 0.3) / 0.55;
128
+ const rr = 5 + t * 75;
129
+ const a = 1 - t;
130
+ f.ctx.strokeStyle = `rgba(52, 211, 153, ${a})`;
131
+ f.ctx.lineWidth = 2;
132
+ f.ctx.beginPath();
133
+ f.ctx.arc(f.cx, f.cy, rr, 0, Math.PI * 2);
134
+ f.ctx.stroke();
135
+ f.ctx.strokeStyle = `rgba(52, 211, 153, ${a * 0.5})`;
136
+ f.ctx.lineWidth = 1.5;
137
+ f.ctx.beginPath();
138
+ f.ctx.arc(f.cx, f.cy, rr * 0.6, 0, Math.PI * 2);
139
+ f.ctx.stroke();
140
+ }
141
+
142
+ private drawHitText(f: FrameCtx): void {
143
+ if (f.progress <= 0.6) return;
144
+ const t = Math.min((f.progress - 0.6) / 0.4, 1);
145
+ f.ctx.save();
146
+ f.ctx.translate(f.cx, f.cy + 5);
147
+ f.ctx.scale(0.5 + t * 0.5, 0.5 + t * 0.5);
148
+ f.ctx.font = 'bold 16px Orbitron, system-ui, sans-serif';
149
+ f.ctx.textAlign = 'center';
150
+ f.ctx.textBaseline = 'middle';
151
+ f.ctx.shadowColor = 'rgba(52, 211, 153, 0.6)';
152
+ f.ctx.shadowBlur = 12;
153
+ f.ctx.fillStyle = `rgba(52, 211, 153, ${Math.min(t * 2, 1)})`;
154
+ f.ctx.fillText('DIRECT HIT', 0, 0);
155
+ f.ctx.restore();
156
+ }
157
+
158
+ private drawScatterTravel(f: FrameCtx): void {
159
+ if (f.progress >= 0.6) return;
160
+ const t = f.progress / 0.6;
161
+ const eased = 1 - (1 - t) * (1 - t);
162
+ const curX = f.cx + (f.tgtX - f.cx) * eased;
163
+ const curY = f.cy + (f.tgtY - f.cy) * eased;
164
+
165
+ f.ctx.beginPath();
166
+ f.ctx.moveTo(f.cx, f.cy);
167
+ f.ctx.lineTo(curX, curY);
168
+ f.ctx.strokeStyle = 'rgba(248, 113, 113, 0.5)';
169
+ f.ctx.lineWidth = 2;
170
+ f.ctx.setLineDash([4, 6]);
171
+ f.ctx.stroke();
172
+ f.ctx.setLineDash([]);
173
+
174
+ this.drawProjectile(f.ctx, curX, curY);
175
+
176
+ if (Math.random() < 0.5) {
177
+ const along = eased - 0.15 * Math.random();
178
+ this.spawnTrailParticle(f.cx + (f.tgtX - f.cx) * Math.max(along, 0), f.cy + (f.tgtY - f.cy) * Math.max(along, 0));
179
+ }
180
+ }
181
+
182
+ private drawScatterImpact(f: FrameCtx): void {
183
+ if (f.progress < 0.6) return;
184
+ const t = (f.progress - 0.6) / 0.4;
185
+ const impactT = Math.min(t * 2, 1);
186
+
187
+ this.drawImpactGlow(f.ctx, f.tgtX, f.tgtY, impactT);
188
+ this.drawImpactLabel(f);
189
+ this.spawnImpactParticles(f.ctx, f.tgtX, f.tgtY, t);
190
+ }
191
+
192
+ private drawProjectile(ctx: CanvasRenderingContext2D, x: number, y: number): void {
193
+ const g = ctx.createRadialGradient(x, y, 0, x, y, 16);
194
+ g.addColorStop(0, 'rgba(248, 113, 113, 0.7)');
195
+ g.addColorStop(1, 'rgba(248, 113, 113, 0)');
196
+ ctx.fillStyle = g;
197
+ ctx.beginPath();
198
+ ctx.arc(x, y, 16, 0, Math.PI * 2);
199
+ ctx.fill();
200
+
201
+ ctx.fillStyle = '#f87171';
202
+ ctx.beginPath();
203
+ ctx.arc(x, y, 4, 0, Math.PI * 2);
204
+ ctx.fill();
205
+ }
206
+
207
+ private drawImpactGlow(ctx: CanvasRenderingContext2D, x: number, y: number, t: number): void {
208
+ const r = 5 + t * 28;
209
+ const a = 0.6 * (1 - t * 0.5);
210
+ const g = ctx.createRadialGradient(x, y, 0, x, y, r);
211
+ g.addColorStop(0, `rgba(248, 113, 113, ${a})`);
212
+ g.addColorStop(1, 'rgba(248, 113, 113, 0)');
213
+ ctx.fillStyle = g;
214
+ ctx.beginPath();
215
+ ctx.arc(x, y, r, 0, Math.PI * 2);
216
+ ctx.fill();
217
+
218
+ ctx.fillStyle = '#f87171';
219
+ ctx.beginPath();
220
+ ctx.arc(x, y, 3 + t * 2, 0, Math.PI * 2);
221
+ ctx.fill();
222
+ }
223
+
224
+ private drawImpactLabel(f: FrameCtx): void {
225
+ const t = (f.progress - 0.6) / 0.4;
226
+ if (t <= 0.2) return;
227
+ const textT = Math.min((t - 0.2) / 0.3, 1);
228
+ const dx = f.tgtX - f.cx;
229
+ const dy = f.tgtY - f.cy;
230
+ const len = Math.sqrt(dx * dx + dy * dy) || 1;
231
+ f.ctx.save();
232
+ f.ctx.font = 'bold 12px Orbitron, system-ui, sans-serif';
233
+ f.ctx.textAlign = 'center';
234
+ f.ctx.textBaseline = 'middle';
235
+ f.ctx.shadowColor = 'rgba(0, 0, 0, 0.8)';
236
+ f.ctx.shadowBlur = 4;
237
+ f.ctx.fillStyle = `rgba(248, 113, 113, ${textT})`;
238
+ f.ctx.fillText(`${this.distance}"`, f.tgtX + (dx / len) * 22, f.tgtY + (dy / len) * 18);
239
+ f.ctx.restore();
240
+ }
241
+
242
+ private spawnTrailParticle(x: number, y: number): void {
243
+ const p = new Particle(this.canvas.width, this.canvas.height, 0, false);
244
+ p.x = x;
245
+ p.y = y;
246
+ p.color = '248, 113, 113';
247
+ p.size = Math.random() * 2 + 1;
248
+ this.particles.push(p);
249
+ }
250
+
251
+ private spawnImpactParticles(ctx: CanvasRenderingContext2D, x: number, y: number, t: number): void {
252
+ if (t >= 0.5 || Math.random() >= 0.7) return;
253
+ const a = Math.random() * Math.PI * 2;
254
+ const spd = Math.random() * 3 + 1;
255
+ const p = new Particle(this.canvas.width, this.canvas.height, a, false);
256
+ p.x = x;
257
+ p.y = y;
258
+ p.vx = Math.cos(a) * spd;
259
+ p.vy = Math.sin(a) * spd;
260
+ p.color = '248, 113, 113';
261
+ p.size = Math.random() * 2.5 + 1;
262
+ p.alpha = 1;
263
+ this.particles.push(p);
264
+ }
265
+
266
+ private drawParticles(): void {
267
+ const ctx = this.ctx;
268
+ this.particles = this.particles.filter(p => {
269
+ p.update();
270
+ if (p.alpha <= 0) return false;
271
+ ctx.fillStyle = `rgba(${p.color}, ${p.alpha})`;
272
+ ctx.beginPath();
273
+ ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
274
+ ctx.fill();
275
+ return true;
276
+ });
277
+ }
278
+ }
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { scatterDirectionSelector } 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 = scatterDirectionSelector.i18n[locale] || scatterDirectionSelector.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: 'Wargaming Scatter Mechanics',
6
+ url: 'https://en.wikipedia.org/wiki/Miniature_wargaming',
7
+ },
8
+ {
9
+ name: 'Compass Rose & Wind Directions',
10
+ url: 'https://en.wikipedia.org/wiki/Points_of_the_compass',
11
+ },
12
+ ];