@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.
- package/package.json +65 -0
- package/scripts/postinstall.mjs +27 -0
- package/src/category/TabletopCategorySEO.astro +14 -0
- package/src/category/i18n/de.ts +27 -0
- package/src/category/i18n/en.ts +27 -0
- package/src/category/i18n/es.ts +27 -0
- package/src/category/i18n/fr.ts +27 -0
- package/src/category/i18n/id.ts +27 -0
- package/src/category/i18n/it.ts +27 -0
- package/src/category/i18n/ja.ts +27 -0
- package/src/category/i18n/ko.ts +27 -0
- package/src/category/i18n/nl.ts +27 -0
- package/src/category/i18n/pl.ts +27 -0
- package/src/category/i18n/pt.ts +27 -0
- package/src/category/i18n/ru.ts +27 -0
- package/src/category/i18n/sv.ts +27 -0
- package/src/category/i18n/tr.ts +27 -0
- package/src/category/i18n/zh.ts +27 -0
- package/src/category/index.ts +27 -0
- package/src/components/PreviewNavSidebar.astro +116 -0
- package/src/components/PreviewToolbar.astro +143 -0
- package/src/data.ts +10 -0
- package/src/entries.ts +10 -0
- package/src/env.d.ts +5 -0
- package/src/index.ts +21 -0
- package/src/layouts/PreviewLayout.astro +117 -0
- package/src/pages/[locale]/[slug].astro +161 -0
- package/src/pages/[locale].astro +251 -0
- package/src/pages/index.astro +4 -0
- package/src/tests/faq_count.test.ts +18 -0
- package/src/tests/i18n_coverage.test.ts +36 -0
- package/src/tests/locale_completeness.test.ts +29 -0
- package/src/tests/mocks/astro_mock.js +2 -0
- package/src/tests/no_en_dash.test.ts +41 -0
- package/src/tests/no_h1_in_components.test.ts +48 -0
- package/src/tests/schemas_fulfillment.test.ts +23 -0
- package/src/tests/seo_length.test.ts +22 -0
- package/src/tests/shared-test-helpers.ts +56 -0
- package/src/tests/slug_language_code_format.test.ts +23 -0
- package/src/tests/slug_uniqueness.test.ts +81 -0
- package/src/tests/title_quality.test.ts +55 -0
- package/src/tests/tool_exports.test.ts +34 -0
- package/src/tests/tool_validation.test.ts +16 -0
- package/src/tool/board-game-timer/bibliography.astro +16 -0
- package/src/tool/board-game-timer/bibliography.ts +12 -0
- package/src/tool/board-game-timer/board-game-timer.css +1615 -0
- package/src/tool/board-game-timer/client.ts +17 -0
- package/src/tool/board-game-timer/component.astro +22 -0
- package/src/tool/board-game-timer/components/DuelTimer.astro +75 -0
- package/src/tool/board-game-timer/components/MultiplayerTimer.astro +58 -0
- package/src/tool/board-game-timer/components/SetupPanel.astro +155 -0
- package/src/tool/board-game-timer/components/StatsModal.astro +52 -0
- package/src/tool/board-game-timer/entry.ts +76 -0
- package/src/tool/board-game-timer/i18n/de.ts +303 -0
- package/src/tool/board-game-timer/i18n/en.ts +304 -0
- package/src/tool/board-game-timer/i18n/es.ts +303 -0
- package/src/tool/board-game-timer/i18n/fr.ts +303 -0
- package/src/tool/board-game-timer/i18n/id.ts +303 -0
- package/src/tool/board-game-timer/i18n/it.ts +303 -0
- package/src/tool/board-game-timer/i18n/ja.ts +306 -0
- package/src/tool/board-game-timer/i18n/ko.ts +303 -0
- package/src/tool/board-game-timer/i18n/nl.ts +303 -0
- package/src/tool/board-game-timer/i18n/pl.ts +303 -0
- package/src/tool/board-game-timer/i18n/pt.ts +303 -0
- package/src/tool/board-game-timer/i18n/ru.ts +303 -0
- package/src/tool/board-game-timer/i18n/sv.ts +303 -0
- package/src/tool/board-game-timer/i18n/tr.ts +303 -0
- package/src/tool/board-game-timer/i18n/zh.ts +303 -0
- package/src/tool/board-game-timer/index.ts +9 -0
- package/src/tool/board-game-timer/logic.test.ts +282 -0
- package/src/tool/board-game-timer/logic.ts +6 -0
- package/src/tool/board-game-timer/modules/ColorHelper.ts +26 -0
- package/src/tool/board-game-timer/modules/DuelViewManager.ts +120 -0
- package/src/tool/board-game-timer/modules/FormatHelper.ts +12 -0
- package/src/tool/board-game-timer/modules/GameRunner.ts +136 -0
- package/src/tool/board-game-timer/modules/GameState.ts +48 -0
- package/src/tool/board-game-timer/modules/MultiplayerViewManager.ts +163 -0
- package/src/tool/board-game-timer/modules/SetupPanelManager.ts +209 -0
- package/src/tool/board-game-timer/modules/SoundManager.ts +109 -0
- package/src/tool/board-game-timer/modules/SpeechManager.ts +1 -0
- package/src/tool/board-game-timer/modules/StatsManager.ts +87 -0
- package/src/tool/board-game-timer/modules/StatsModalManager.ts +74 -0
- package/src/tool/board-game-timer/modules/TimerEngine.ts +175 -0
- package/src/tool/board-game-timer/modules/TimerEngineBase.ts +49 -0
- package/src/tool/board-game-timer/modules/TimerEngineClock.ts +127 -0
- package/src/tool/board-game-timer/modules/TimerStateHelper.ts +55 -0
- package/src/tool/board-game-timer/modules/particles.ts +108 -0
- package/src/tool/board-game-timer/seo.astro +16 -0
- package/src/tool/board-game-timer/types.ts +97 -0
- package/src/tool/dice-roller-simulator/bibliography.astro +16 -0
- package/src/tool/dice-roller-simulator/bibliography.ts +12 -0
- package/src/tool/dice-roller-simulator/client.ts +273 -0
- package/src/tool/dice-roller-simulator/component.astro +75 -0
- package/src/tool/dice-roller-simulator/components/DiceGrid.astro +55 -0
- package/src/tool/dice-roller-simulator/components/RollHistorySection.astro +26 -0
- package/src/tool/dice-roller-simulator/components/RollResultBanner.astro +10 -0
- package/src/tool/dice-roller-simulator/components/StatsPanel.astro +36 -0
- package/src/tool/dice-roller-simulator/dice-roller-simulator.css +1319 -0
- package/src/tool/dice-roller-simulator/entry.ts +39 -0
- package/src/tool/dice-roller-simulator/i18n/de.ts +305 -0
- package/src/tool/dice-roller-simulator/i18n/en.ts +305 -0
- package/src/tool/dice-roller-simulator/i18n/es.ts +305 -0
- package/src/tool/dice-roller-simulator/i18n/fr.ts +305 -0
- package/src/tool/dice-roller-simulator/i18n/id.ts +305 -0
- package/src/tool/dice-roller-simulator/i18n/it.ts +305 -0
- package/src/tool/dice-roller-simulator/i18n/ja.ts +305 -0
- package/src/tool/dice-roller-simulator/i18n/ko.ts +305 -0
- package/src/tool/dice-roller-simulator/i18n/nl.ts +305 -0
- package/src/tool/dice-roller-simulator/i18n/pl.ts +305 -0
- package/src/tool/dice-roller-simulator/i18n/pt.ts +305 -0
- package/src/tool/dice-roller-simulator/i18n/ru.ts +305 -0
- package/src/tool/dice-roller-simulator/i18n/sv.ts +305 -0
- package/src/tool/dice-roller-simulator/i18n/tr.ts +305 -0
- package/src/tool/dice-roller-simulator/i18n/zh.ts +305 -0
- package/src/tool/dice-roller-simulator/index.ts +9 -0
- package/src/tool/dice-roller-simulator/modules/chart.ts +32 -0
- package/src/tool/dice-roller-simulator/modules/history.ts +87 -0
- package/src/tool/dice-roller-simulator/modules/particles.ts +22 -0
- package/src/tool/dice-roller-simulator/seo.astro +16 -0
- package/src/tools.ts +9 -0
- 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
|
+
}
|