@jjlmoya/utils-tabletop 1.3.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 (121) hide show
  1. package/package.json +65 -0
  2. package/scripts/postinstall.mjs +27 -0
  3. package/src/category/TabletopCategorySEO.astro +14 -0
  4. package/src/category/i18n/de.ts +27 -0
  5. package/src/category/i18n/en.ts +27 -0
  6. package/src/category/i18n/es.ts +27 -0
  7. package/src/category/i18n/fr.ts +27 -0
  8. package/src/category/i18n/id.ts +27 -0
  9. package/src/category/i18n/it.ts +27 -0
  10. package/src/category/i18n/ja.ts +27 -0
  11. package/src/category/i18n/ko.ts +27 -0
  12. package/src/category/i18n/nl.ts +27 -0
  13. package/src/category/i18n/pl.ts +27 -0
  14. package/src/category/i18n/pt.ts +27 -0
  15. package/src/category/i18n/ru.ts +27 -0
  16. package/src/category/i18n/sv.ts +27 -0
  17. package/src/category/i18n/tr.ts +27 -0
  18. package/src/category/i18n/zh.ts +27 -0
  19. package/src/category/index.ts +27 -0
  20. package/src/components/PreviewNavSidebar.astro +116 -0
  21. package/src/components/PreviewToolbar.astro +143 -0
  22. package/src/data.ts +10 -0
  23. package/src/entries.ts +10 -0
  24. package/src/env.d.ts +5 -0
  25. package/src/index.ts +21 -0
  26. package/src/layouts/PreviewLayout.astro +117 -0
  27. package/src/pages/[locale]/[slug].astro +161 -0
  28. package/src/pages/[locale].astro +251 -0
  29. package/src/pages/index.astro +4 -0
  30. package/src/tests/faq_count.test.ts +18 -0
  31. package/src/tests/i18n_coverage.test.ts +36 -0
  32. package/src/tests/locale_completeness.test.ts +29 -0
  33. package/src/tests/mocks/astro_mock.js +2 -0
  34. package/src/tests/no_en_dash.test.ts +41 -0
  35. package/src/tests/no_h1_in_components.test.ts +48 -0
  36. package/src/tests/schemas_fulfillment.test.ts +23 -0
  37. package/src/tests/seo_length.test.ts +22 -0
  38. package/src/tests/shared-test-helpers.ts +56 -0
  39. package/src/tests/slug_language_code_format.test.ts +23 -0
  40. package/src/tests/slug_uniqueness.test.ts +81 -0
  41. package/src/tests/title_quality.test.ts +55 -0
  42. package/src/tests/tool_exports.test.ts +34 -0
  43. package/src/tests/tool_validation.test.ts +16 -0
  44. package/src/tool/board-game-timer/bibliography.astro +16 -0
  45. package/src/tool/board-game-timer/bibliography.ts +12 -0
  46. package/src/tool/board-game-timer/board-game-timer.css +1615 -0
  47. package/src/tool/board-game-timer/client.ts +17 -0
  48. package/src/tool/board-game-timer/component.astro +22 -0
  49. package/src/tool/board-game-timer/components/DuelTimer.astro +75 -0
  50. package/src/tool/board-game-timer/components/MultiplayerTimer.astro +58 -0
  51. package/src/tool/board-game-timer/components/SetupPanel.astro +155 -0
  52. package/src/tool/board-game-timer/components/StatsModal.astro +52 -0
  53. package/src/tool/board-game-timer/entry.ts +76 -0
  54. package/src/tool/board-game-timer/i18n/de.ts +303 -0
  55. package/src/tool/board-game-timer/i18n/en.ts +304 -0
  56. package/src/tool/board-game-timer/i18n/es.ts +303 -0
  57. package/src/tool/board-game-timer/i18n/fr.ts +303 -0
  58. package/src/tool/board-game-timer/i18n/id.ts +303 -0
  59. package/src/tool/board-game-timer/i18n/it.ts +303 -0
  60. package/src/tool/board-game-timer/i18n/ja.ts +306 -0
  61. package/src/tool/board-game-timer/i18n/ko.ts +303 -0
  62. package/src/tool/board-game-timer/i18n/nl.ts +303 -0
  63. package/src/tool/board-game-timer/i18n/pl.ts +303 -0
  64. package/src/tool/board-game-timer/i18n/pt.ts +303 -0
  65. package/src/tool/board-game-timer/i18n/ru.ts +303 -0
  66. package/src/tool/board-game-timer/i18n/sv.ts +303 -0
  67. package/src/tool/board-game-timer/i18n/tr.ts +303 -0
  68. package/src/tool/board-game-timer/i18n/zh.ts +303 -0
  69. package/src/tool/board-game-timer/index.ts +9 -0
  70. package/src/tool/board-game-timer/logic.test.ts +282 -0
  71. package/src/tool/board-game-timer/logic.ts +6 -0
  72. package/src/tool/board-game-timer/modules/ColorHelper.ts +26 -0
  73. package/src/tool/board-game-timer/modules/DuelViewManager.ts +120 -0
  74. package/src/tool/board-game-timer/modules/FormatHelper.ts +12 -0
  75. package/src/tool/board-game-timer/modules/GameRunner.ts +136 -0
  76. package/src/tool/board-game-timer/modules/GameState.ts +48 -0
  77. package/src/tool/board-game-timer/modules/MultiplayerViewManager.ts +163 -0
  78. package/src/tool/board-game-timer/modules/SetupPanelManager.ts +209 -0
  79. package/src/tool/board-game-timer/modules/SoundManager.ts +109 -0
  80. package/src/tool/board-game-timer/modules/SpeechManager.ts +1 -0
  81. package/src/tool/board-game-timer/modules/StatsManager.ts +87 -0
  82. package/src/tool/board-game-timer/modules/StatsModalManager.ts +74 -0
  83. package/src/tool/board-game-timer/modules/TimerEngine.ts +175 -0
  84. package/src/tool/board-game-timer/modules/TimerEngineBase.ts +49 -0
  85. package/src/tool/board-game-timer/modules/TimerEngineClock.ts +127 -0
  86. package/src/tool/board-game-timer/modules/TimerStateHelper.ts +55 -0
  87. package/src/tool/board-game-timer/modules/particles.ts +108 -0
  88. package/src/tool/board-game-timer/seo.astro +16 -0
  89. package/src/tool/board-game-timer/types.ts +97 -0
  90. package/src/tool/dice-roller-simulator/bibliography.astro +16 -0
  91. package/src/tool/dice-roller-simulator/bibliography.ts +12 -0
  92. package/src/tool/dice-roller-simulator/client.ts +273 -0
  93. package/src/tool/dice-roller-simulator/component.astro +75 -0
  94. package/src/tool/dice-roller-simulator/components/DiceGrid.astro +55 -0
  95. package/src/tool/dice-roller-simulator/components/RollHistorySection.astro +26 -0
  96. package/src/tool/dice-roller-simulator/components/RollResultBanner.astro +10 -0
  97. package/src/tool/dice-roller-simulator/components/StatsPanel.astro +36 -0
  98. package/src/tool/dice-roller-simulator/dice-roller-simulator.css +1319 -0
  99. package/src/tool/dice-roller-simulator/entry.ts +39 -0
  100. package/src/tool/dice-roller-simulator/i18n/de.ts +305 -0
  101. package/src/tool/dice-roller-simulator/i18n/en.ts +305 -0
  102. package/src/tool/dice-roller-simulator/i18n/es.ts +305 -0
  103. package/src/tool/dice-roller-simulator/i18n/fr.ts +305 -0
  104. package/src/tool/dice-roller-simulator/i18n/id.ts +305 -0
  105. package/src/tool/dice-roller-simulator/i18n/it.ts +305 -0
  106. package/src/tool/dice-roller-simulator/i18n/ja.ts +305 -0
  107. package/src/tool/dice-roller-simulator/i18n/ko.ts +305 -0
  108. package/src/tool/dice-roller-simulator/i18n/nl.ts +305 -0
  109. package/src/tool/dice-roller-simulator/i18n/pl.ts +305 -0
  110. package/src/tool/dice-roller-simulator/i18n/pt.ts +305 -0
  111. package/src/tool/dice-roller-simulator/i18n/ru.ts +305 -0
  112. package/src/tool/dice-roller-simulator/i18n/sv.ts +305 -0
  113. package/src/tool/dice-roller-simulator/i18n/tr.ts +305 -0
  114. package/src/tool/dice-roller-simulator/i18n/zh.ts +305 -0
  115. package/src/tool/dice-roller-simulator/index.ts +9 -0
  116. package/src/tool/dice-roller-simulator/modules/chart.ts +32 -0
  117. package/src/tool/dice-roller-simulator/modules/history.ts +87 -0
  118. package/src/tool/dice-roller-simulator/modules/particles.ts +22 -0
  119. package/src/tool/dice-roller-simulator/seo.astro +16 -0
  120. package/src/tools.ts +9 -0
  121. package/src/types.ts +70 -0
@@ -0,0 +1,120 @@
1
+ import type { GameState } from './GameState';
2
+ import { getContrastColorForBackground } from './ColorHelper';
3
+ import { formatTime } from './FormatHelper';
4
+ import { spawnTurnBurst, spawnExpiredBurst, spawnRippleEffect, spawnDigitSparkle, spawnWarningFlash } from './particles';
5
+
6
+ export class DuelViewManager {
7
+ private state: GameState;
8
+ private duelPanel: HTMLElement;
9
+
10
+ constructor(state: GameState) {
11
+ this.state = state;
12
+ this.duelPanel = document.getElementById('duel-timer-panel') as HTMLElement;
13
+ this.initEvents();
14
+ }
15
+
16
+ private initEvents() {
17
+ const rotateBtns = document.querySelectorAll('.side-rotate-btn');
18
+ rotateBtns.forEach(btn => {
19
+ btn.addEventListener('click', (e) => {
20
+ e.stopPropagation();
21
+ btn.closest('.duel-player-side')?.classList.toggle('rotated');
22
+ });
23
+ });
24
+ document.getElementById('duel-p1')?.addEventListener('click', () => this.handleDuelClick(0, 1));
25
+ document.getElementById('duel-p2')?.addEventListener('click', () => this.handleDuelClick(1, 0));
26
+ }
27
+
28
+ private handleDuelClick(currIdx: number, nextIdx: number) {
29
+ if (this.state.isPaused) return;
30
+ if (this.state.engine.activeTimerId === this.state.players[currIdx].id) {
31
+ this.state.sound.playTurn();
32
+ this.state.stats.endTurn(this.state.players[currIdx].id, this.state.roundNumber);
33
+ this.state.engine.switchTurn(this.state.players[nextIdx].id);
34
+ if (currIdx === 1) {
35
+ this.state.roundNumber++;
36
+ document.getElementById('duel-round-number')!.textContent = this.state.roundNumber.toString();
37
+ }
38
+ this.state.stats.startTurn();
39
+ this.update();
40
+
41
+ const nextSide = document.getElementById(`duel-p${nextIdx + 1}`) as HTMLElement;
42
+ if (nextSide) {
43
+ spawnTurnBurst(nextSide, `var(--color-${this.state.players[nextIdx].color})`);
44
+ spawnRippleEffect(nextSide.querySelector('.duel-ripple-effect') || nextSide);
45
+ }
46
+ }
47
+ }
48
+
49
+ public initView() {
50
+ this.duelPanel.style.display = 'grid';
51
+ const p1 = this.state.players[0];
52
+ const p2 = this.state.players[1];
53
+ const d1 = document.getElementById('duel-p1') as HTMLElement;
54
+ const d2 = document.getElementById('duel-p2') as HTMLElement;
55
+ d1.setAttribute('data-player-id', p1.id);
56
+ d2.setAttribute('data-player-id', p2.id);
57
+ d1.style.background = `var(--color-${p1.color})`;
58
+ d1.style.color = getContrastColorForBackground(p1.color);
59
+ d2.style.background = `var(--color-${p2.color})`;
60
+ d2.style.color = getContrastColorForBackground(p2.color);
61
+ document.getElementById('duel-p1-name')!.textContent = p1.name;
62
+ document.getElementById('duel-p2-name')!.textContent = p2.name;
63
+ document.getElementById('duel-round-number')!.textContent = '1';
64
+ document.getElementById('duel-paused-overlay')!.style.display = 'none';
65
+ d1.classList.remove('rotated');
66
+ d2.classList.remove('rotated');
67
+ this.update();
68
+ }
69
+
70
+ private updateSide(p: { id: string }, t: { remaining: number; status: string; roundsPlayed: number }, els: { side: HTMLElement; time: HTMLElement; status: HTMLElement }) {
71
+ const prev = els.time.textContent;
72
+ els.time.textContent = formatTime(t.remaining);
73
+ if (prev !== els.time.textContent) spawnDigitSparkle(els.time);
74
+
75
+ const rotated = els.side.classList.contains('rotated');
76
+ els.side.className = `duel-player-side ${t.status}`;
77
+ if (rotated) els.side.classList.add('rotated');
78
+ if (this.state.engine.activeTimerId === p.id) els.side.classList.add('active');
79
+
80
+ els.status.textContent = t.status.toUpperCase();
81
+ els.status.className = 'side-status-badge';
82
+ if (t.status === 'overtime') {
83
+ els.status.classList.add('warning');
84
+ spawnExpiredBurst(els.side);
85
+ }
86
+ }
87
+
88
+ public update() {
89
+ const p1 = this.state.players[0];
90
+ const p2 = this.state.players[1];
91
+ const t1 = this.state.engine.getTimer(p1.id);
92
+ const t2 = this.state.engine.getTimer(p2.id);
93
+ if (!t1 || !t2) return;
94
+
95
+ const d1 = document.getElementById('duel-p1') as HTMLElement;
96
+ const d2 = document.getElementById('duel-p2') as HTMLElement;
97
+
98
+ document.getElementById('duel-p1-rounds')!.textContent = `Turns: ${t1.roundsPlayed}`;
99
+ document.getElementById('duel-p2-rounds')!.textContent = `Turns: ${t2.roundsPlayed}`;
100
+
101
+ this.updateSide(p1, t1, { side: d1, time: document.getElementById('duel-p1-timer') as HTMLElement, status: document.getElementById('duel-p1-status') as HTMLElement });
102
+ this.updateSide(p2, t2, { side: d2, time: document.getElementById('duel-p2-timer') as HTMLElement, status: document.getElementById('duel-p2-status') as HTMLElement });
103
+ }
104
+
105
+ public triggerWarning(playerId: string) {
106
+ if (this.state.players[0]?.id === playerId) {
107
+ spawnWarningFlash(document.getElementById('duel-p1') as HTMLElement);
108
+ } else if (this.state.players[1]?.id === playerId) {
109
+ spawnWarningFlash(document.getElementById('duel-p2') as HTMLElement);
110
+ }
111
+ }
112
+
113
+ public triggerExpired(playerId: string) {
114
+ if (this.state.players[0]?.id === playerId) {
115
+ spawnExpiredBurst(document.getElementById('duel-p1') as HTMLElement);
116
+ } else if (this.state.players[1]?.id === playerId) {
117
+ spawnExpiredBurst(document.getElementById('duel-p2') as HTMLElement);
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,12 @@
1
+ export function formatTime(seconds: number): string {
2
+ const m = Math.floor(seconds / 60);
3
+ const s = Math.floor(seconds % 60);
4
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
5
+ }
6
+
7
+ export function formatFullTime(seconds: number): string {
8
+ const h = Math.floor(seconds / 3600);
9
+ const m = Math.floor((seconds % 3600) / 60);
10
+ const s = Math.floor(seconds % 60);
11
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
12
+ }
@@ -0,0 +1,136 @@
1
+ import type { GameState } from './GameState';
2
+ import { DuelViewManager } from './DuelViewManager';
3
+ import { MultiplayerViewManager } from './MultiplayerViewManager';
4
+ import { StatsModalManager } from './StatsModalManager';
5
+ import { spawnConfetti } from './particles';
6
+
7
+ export class GameRunner {
8
+ private state: GameState;
9
+ private setupPanel: HTMLElement;
10
+ private duelPanel: HTMLElement;
11
+ private multiPanel: HTMLElement;
12
+ private duelView: DuelViewManager;
13
+ private multiView: MultiplayerViewManager;
14
+ private statsModalManager: StatsModalManager;
15
+
16
+ constructor(state: GameState) {
17
+ this.state = state;
18
+ this.setupPanel = document.getElementById('setup-panel') as HTMLElement;
19
+ this.duelPanel = document.getElementById('duel-timer-panel') as HTMLElement;
20
+ this.multiPanel = document.getElementById('multiplayer-timer-panel') as HTMLElement;
21
+ this.duelView = new DuelViewManager(state);
22
+ this.multiView = new MultiplayerViewManager(state);
23
+ this.statsModalManager = new StatsModalManager(state, () => {
24
+ this.duelPanel.style.display = 'none';
25
+ this.multiPanel.style.display = 'none';
26
+ this.setupPanel.style.display = 'flex';
27
+ });
28
+ this.initEvents();
29
+ }
30
+
31
+ public startGame(baseDuration: number) {
32
+ this.state.players.forEach(p => {
33
+ this.state.engine.create(p.id, p.name, baseDuration);
34
+ });
35
+ this.setupPanel.style.display = 'none';
36
+ this.state.isPaused = false;
37
+ this.state.roundNumber = 1;
38
+ this.state.activePlayerIndex = 0;
39
+ this.state.stats.start();
40
+ this.bindCallbacks();
41
+ if (this.state.players.length === 2) {
42
+ this.state.activeMode = 'duel';
43
+ this.duelView.initView();
44
+ } else {
45
+ this.state.activeMode = 'multi';
46
+ this.multiView.initView();
47
+ }
48
+ const firstPlayer = this.state.players[0];
49
+ this.state.engine.start(firstPlayer.id);
50
+ this.state.stats.startTurn();
51
+ }
52
+
53
+ private initEvents() {
54
+ this.initDuelButtons();
55
+ this.initMultiButtons();
56
+ }
57
+
58
+ private initDuelButtons() {
59
+ document.getElementById('duel-pause-btn')?.addEventListener('click', () => this.pauseGame('duel'));
60
+ document.getElementById('duel-resume-btn')?.addEventListener('click', () => this.resumeGame('duel'));
61
+ document.getElementById('duel-reset-btn')?.addEventListener('click', () => this.resetToSetup(this.duelPanel));
62
+ document.getElementById('duel-back-btn')?.addEventListener('click', () => this.resetToSetup(this.duelPanel));
63
+ }
64
+
65
+ private initMultiButtons() {
66
+ document.getElementById('multi-pause-btn')?.addEventListener('click', () => this.pauseGame('multi'));
67
+ document.getElementById('multi-resume-btn')?.addEventListener('click', () => this.resumeGame('multi'));
68
+ document.getElementById('multi-reset-btn')?.addEventListener('click', () => this.resetToSetup(this.multiPanel));
69
+ document.getElementById('multi-back-btn')?.addEventListener('click', () => this.resetToSetup(this.multiPanel));
70
+ }
71
+
72
+ private pauseGame(mode: 'duel' | 'multi') {
73
+ this.state.isPaused = true;
74
+ this.state.engine.pauseAll();
75
+ this.state.stats.pauseTurn();
76
+ document.getElementById(`${mode}-paused-overlay`)!.style.display = 'flex';
77
+ }
78
+
79
+ private resumeGame(mode: 'duel' | 'multi') {
80
+ this.state.isPaused = false;
81
+ this.state.stats.resumeTurn();
82
+ this.state.engine.resumeAll(this.state.players.map(p => p.id), this.state.activePlayerIndex);
83
+ document.getElementById(`${mode}-paused-overlay`)!.style.display = 'none';
84
+ }
85
+
86
+ private resetToSetup(panel: HTMLElement) {
87
+ this.state.engine.destroy();
88
+ panel.style.display = 'none';
89
+ this.setupPanel.style.display = 'flex';
90
+ }
91
+
92
+ private handleExpired(id: string) {
93
+ this.state.sound.playTimeUp();
94
+ this.state.stats.end();
95
+ if (this.state.activeMode === 'duel') {
96
+ this.duelView.triggerExpired(id);
97
+ } else {
98
+ this.multiView.triggerExpired(id);
99
+ }
100
+ const panel = this.state.activeMode === 'duel' ? this.duelPanel : this.multiPanel;
101
+ spawnConfetti(panel, 30);
102
+ setTimeout(() => this.statsModalManager.show(this.state.roundNumber), 1200);
103
+ }
104
+
105
+ private bindCallbacks() {
106
+ this.state.engine.onTick((id) => {
107
+ if (this.state.activeMode === 'duel') {
108
+ this.duelView.update();
109
+ } else {
110
+ this.multiView.update();
111
+ }
112
+ this.checkAudioTriggers(id);
113
+ });
114
+ this.state.engine.onWarning((id) => {
115
+ if (this.state.activeMode === 'duel') {
116
+ this.duelView.triggerWarning(id);
117
+ } else {
118
+ this.multiView.triggerWarning(id);
119
+ }
120
+ });
121
+ this.state.engine.onExpired((id) => this.handleExpired(id));
122
+ }
123
+
124
+ private checkAudioTriggers(id: string) {
125
+ const t = this.state.engine.getTimer(id);
126
+ if (!t) return;
127
+ if (Math.floor(t.remaining) === 5 && t.remaining > 4.8) {
128
+ this.state.sound.playWarning();
129
+ } else if (Math.floor(t.remaining) <= 5 && t.remaining > 0) {
130
+ const exactSec = Math.ceil(t.remaining);
131
+ if (exactSec > 0) {
132
+ this.state.sound.playTick();
133
+ }
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,48 @@
1
+ import type { PlayerConfig, PlayerColor, TimeControlMode } from '../types';
2
+ import { SoundManager } from './SoundManager';
3
+ import { StatsManager } from './StatsManager';
4
+ import { createTimerEngine } from '../logic';
5
+
6
+ export class GameState {
7
+ public ui: Record<string, string> = {};
8
+ public sound = new SoundManager();
9
+ public stats = new StatsManager();
10
+ public engine = createTimerEngine();
11
+ public players: PlayerConfig[] = [];
12
+ public selectedColor: PlayerColor = 'blue';
13
+ public activePlayerIndex = 0;
14
+ public isPaused = false;
15
+ public roundNumber = 1;
16
+ public activeMode: 'duel' | 'multi' = 'multi';
17
+
18
+ constructor() {
19
+ const el = document.querySelector('.timer-main-wrapper');
20
+ if (el) {
21
+ try {
22
+ this.ui = JSON.parse(el.getAttribute('data-ui') || '{}');
23
+ } catch {}
24
+ }
25
+ try {
26
+ const saved = localStorage.getItem('board-game-timer-players');
27
+ if (saved) {
28
+ this.players = JSON.parse(saved);
29
+ }
30
+ } catch {}
31
+ }
32
+
33
+ public savePlayers() {
34
+ try {
35
+ localStorage.setItem('board-game-timer-players', JSON.stringify(this.players));
36
+ } catch {}
37
+ }
38
+
39
+ public recreateEngine(options: { mode: TimeControlMode; increment: number; delay: number; warningTime: number }) {
40
+ this.engine = createTimerEngine({
41
+ tickInterval: 100,
42
+ mode: options.mode,
43
+ increment: options.increment,
44
+ delay: options.delay,
45
+ warningTime: options.warningTime
46
+ });
47
+ }
48
+ }
@@ -0,0 +1,163 @@
1
+ import type { GameState } from './GameState';
2
+ import type { PlayerConfig, TimerState } from '../types';
3
+ import { getContrastColorForBackground } from './ColorHelper';
4
+ import { formatTime } from './FormatHelper';
5
+ import { spawnTurnBurst, spawnParticle, spawnDigitSparkle, spawnWarningFlash, spawnExpiredBurst } from './particles';
6
+
7
+ export class MultiplayerViewManager {
8
+ private state: GameState;
9
+ private multiPanel: HTMLElement;
10
+
11
+ constructor(state: GameState) {
12
+ this.state = state;
13
+ this.multiPanel = document.getElementById('multiplayer-timer-panel') as HTMLElement;
14
+ this.initEvents();
15
+ }
16
+
17
+ private initEvents() {
18
+ document.getElementById('giant-next-turn-btn')?.addEventListener('click', () => this.handleGiantButtonClick());
19
+ }
20
+
21
+ private handleGiantButtonClick() {
22
+ if (this.state.isPaused) return;
23
+ this.state.sound.playTurn();
24
+ const currPlayer = this.state.players[this.state.activePlayerIndex];
25
+ this.state.stats.endTurn(currPlayer.id, this.state.roundNumber);
26
+ this.state.activePlayerIndex = (this.state.activePlayerIndex + 1) % this.state.players.length;
27
+ if (this.state.activePlayerIndex === 0) {
28
+ this.state.roundNumber++;
29
+ const roundEl = document.getElementById('multi-round-number');
30
+ if (roundEl) roundEl.textContent = this.state.roundNumber.toString();
31
+ }
32
+ const nextPlayer = this.state.players[this.state.activePlayerIndex];
33
+ this.state.engine.switchTurn(nextPlayer.id);
34
+ this.state.stats.startTurn();
35
+ this.update();
36
+
37
+ const giantBtn = document.getElementById('giant-next-turn-btn') as HTMLElement;
38
+ if (giantBtn) spawnTurnBurst(giantBtn, `var(--color-${nextPlayer.color})`);
39
+ }
40
+
41
+ public initView() {
42
+ this.multiPanel.style.display = 'flex';
43
+ document.getElementById('multi-round-number')!.textContent = '1';
44
+ const modeIndicator = document.getElementById('setting-mode') as HTMLSelectElement;
45
+ document.getElementById('multi-mode-indicator')!.textContent = modeIndicator.value.toUpperCase();
46
+ document.getElementById('multi-paused-overlay')!.style.display = 'none';
47
+ this.renderGrid();
48
+ }
49
+
50
+ private renderGrid() {
51
+ const grid = document.getElementById('multiplayer-players-grid') as HTMLElement;
52
+ grid.innerHTML = '';
53
+ this.state.players.forEach((p) => {
54
+ const t = this.state.engine.getTimer(p.id);
55
+ if (!t) return;
56
+ grid.appendChild(this.createCard(p, t));
57
+ });
58
+ this.update();
59
+ }
60
+
61
+ private createCard(p: PlayerConfig, t: TimerState): HTMLElement {
62
+ const card = document.createElement('div');
63
+ card.className = `multi-player-card ${t.status}`;
64
+ card.id = `multi-card-${p.id}`;
65
+ card.style.setProperty('--color-timer-accent', `var(--color-${p.color})`);
66
+ const pct = p.time > 0 ? (t.remaining / p.time) * 100 : 0;
67
+ card.innerHTML = `
68
+ <div class="multi-card-header">
69
+ <span class="multi-card-name" style="color: var(--color-text-${p.color})">${p.name}</span>
70
+ <span class="multi-card-rounds" id="rounds-val-${p.id}">Turns: ${t.roundsPlayed}</span>
71
+ </div>
72
+ <div class="multi-card-timer" id="timer-val-${p.id}">${formatTime(t.remaining)}</div>
73
+ <div class="multi-card-progress">
74
+ <div class="multi-card-fill" id="timer-fill-${p.id}" style="width: ${pct}%; background-color: var(--color-${p.color})"></div>
75
+ </div>
76
+ <div class="multi-card-actions">
77
+ <button type="button" class="card-btn add-btn" data-id="${p.id}">+30s</button>
78
+ <button type="button" class="card-btn sub-btn" data-id="${p.id}">-30s</button>
79
+ </div>
80
+ `;
81
+ this.bindCardActions(card, p.id);
82
+ return card;
83
+ }
84
+
85
+ private bindCardActions(card: HTMLElement, id: string) {
86
+ card.querySelector('.add-btn')?.addEventListener('click', (e) => {
87
+ e.stopPropagation();
88
+ this.state.engine.addTime(id, 30);
89
+ this.update();
90
+ const timerEl = document.getElementById(`timer-val-${id}`);
91
+ if (timerEl) spawnDigitSparkle(timerEl);
92
+ spawnParticle({ parent: card, text: '+30s', x: 20 + Math.random() * 60, y: 30, variant: 'success' });
93
+ });
94
+ card.querySelector('.sub-btn')?.addEventListener('click', (e) => {
95
+ e.stopPropagation();
96
+ this.state.engine.removeTime(id, 30);
97
+ this.update();
98
+ const timerEl = document.getElementById(`timer-val-${id}`);
99
+ if (timerEl) spawnDigitSparkle(timerEl);
100
+ spawnParticle({ parent: card, text: '-30s', x: 20 + Math.random() * 60, y: 30, variant: 'warning' });
101
+ });
102
+ }
103
+
104
+ public update() {
105
+ this.state.players.forEach((p, idx) => {
106
+ const t = this.state.engine.getTimer(p.id);
107
+ if (t) this.syncCard(p, t, idx);
108
+ });
109
+ const activePlayer = this.state.players[this.state.activePlayerIndex];
110
+ if (activePlayer) {
111
+ const nameEl = document.getElementById('giant-active-player-name');
112
+ if (nameEl) nameEl.textContent = activePlayer.name;
113
+ const giantBtn = document.getElementById('giant-next-turn-btn');
114
+ if (giantBtn) {
115
+ giantBtn.style.backgroundColor = `var(--color-${activePlayer.color})`;
116
+ giantBtn.style.color = getContrastColorForBackground(activePlayer.color);
117
+ }
118
+ }
119
+ }
120
+
121
+ private syncCard(p: PlayerConfig, t: TimerState, idx: number) {
122
+ const card = document.getElementById(`multi-card-${p.id}`);
123
+ if (card) this.updateCardStyles(card, p, t, idx);
124
+ this.updateCardTimer(p, t);
125
+ this.updateCardFill(p, t);
126
+ const roundsVal = document.getElementById(`rounds-val-${p.id}`);
127
+ if (roundsVal) roundsVal.textContent = `Turns: ${t.roundsPlayed}`;
128
+ if (t.status === 'overtime' && card) spawnExpiredBurst(card);
129
+ }
130
+
131
+ private updateCardStyles(card: HTMLElement, p: PlayerConfig, t: TimerState, idx: number) {
132
+ card.className = `multi-player-card ${t.status}`;
133
+ if (this.state.engine.activeTimerId === p.id) {
134
+ card.classList.add('active');
135
+ this.state.activePlayerIndex = idx;
136
+ }
137
+ }
138
+
139
+ private updateCardTimer(p: PlayerConfig, t: TimerState) {
140
+ const el = document.getElementById(`timer-val-${p.id}`) as HTMLElement;
141
+ if (!el) return;
142
+ const prev = el.textContent;
143
+ el.textContent = formatTime(t.remaining);
144
+ if (prev && prev !== el.textContent) spawnDigitSparkle(el);
145
+ }
146
+
147
+ private updateCardFill(p: PlayerConfig, t: TimerState) {
148
+ const el = document.getElementById(`timer-fill-${p.id}`) as HTMLElement;
149
+ if (!el) return;
150
+ const pct = p.time > 0 ? (t.remaining / p.time) * 100 : 0;
151
+ el.style.width = `${pct}%`;
152
+ }
153
+
154
+ public triggerWarning(playerId: string) {
155
+ const card = document.getElementById(`multi-card-${playerId}`);
156
+ if (card) spawnWarningFlash(card);
157
+ }
158
+
159
+ public triggerExpired(playerId: string) {
160
+ const card = document.getElementById(`multi-card-${playerId}`);
161
+ if (card) spawnExpiredBurst(card);
162
+ }
163
+ }