@jjlmoya/utils-tabletop 1.4.0 → 1.6.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 +5 -3
- package/src/entries.ts +15 -1
- package/src/index.ts +3 -0
- package/src/tests/locale_completeness.test.ts +2 -1
- package/src/tests/tool_validation.test.ts +2 -1
- package/src/tool/decision-wheel/audio.ts +78 -0
- package/src/tool/decision-wheel/bibliography.astro +16 -0
- package/src/tool/decision-wheel/bibliography.ts +8 -0
- package/src/tool/decision-wheel/client.ts +226 -0
- package/src/tool/decision-wheel/component.astro +48 -0
- package/src/tool/decision-wheel/components/SegmentEditor.astro +22 -0
- package/src/tool/decision-wheel/components/SpinHistory.astro +17 -0
- package/src/tool/decision-wheel/components/SpinResult.astro +13 -0
- package/src/tool/decision-wheel/components/WheelCanvas.astro +23 -0
- package/src/tool/decision-wheel/decision-wheel.css +632 -0
- package/src/tool/decision-wheel/drawing.ts +144 -0
- package/src/tool/decision-wheel/entry.ts +48 -0
- package/src/tool/decision-wheel/i18n/de.ts +97 -0
- package/src/tool/decision-wheel/i18n/en.ts +251 -0
- package/src/tool/decision-wheel/i18n/es.ts +93 -0
- package/src/tool/decision-wheel/i18n/fr.ts +93 -0
- package/src/tool/decision-wheel/i18n/id.ts +93 -0
- package/src/tool/decision-wheel/i18n/it.ts +93 -0
- package/src/tool/decision-wheel/i18n/ja.ts +93 -0
- package/src/tool/decision-wheel/i18n/ko.ts +93 -0
- package/src/tool/decision-wheel/i18n/nl.ts +93 -0
- package/src/tool/decision-wheel/i18n/pl.ts +93 -0
- package/src/tool/decision-wheel/i18n/pt.ts +93 -0
- package/src/tool/decision-wheel/i18n/ru.ts +75 -0
- package/src/tool/decision-wheel/i18n/sv.ts +93 -0
- package/src/tool/decision-wheel/i18n/tr.ts +93 -0
- package/src/tool/decision-wheel/i18n/zh.ts +93 -0
- package/src/tool/decision-wheel/index.ts +9 -0
- package/src/tool/decision-wheel/logic.test.ts +163 -0
- package/src/tool/decision-wheel/logic.ts +145 -0
- package/src/tool/decision-wheel/seo.astro +16 -0
- package/src/tool/decision-wheel/types.ts +14 -0
- package/src/tool/decision-wheel/ui.ts +121 -0
- package/src/tool/fantasy-runes-translator/bibliography.astro +16 -0
- package/src/tool/fantasy-runes-translator/bibliography.ts +16 -0
- package/src/tool/fantasy-runes-translator/client.ts +205 -0
- package/src/tool/fantasy-runes-translator/component.astro +95 -0
- package/src/tool/fantasy-runes-translator/entry.ts +56 -0
- package/src/tool/fantasy-runes-translator/fantasy-runes-translator.css +712 -0
- package/src/tool/fantasy-runes-translator/i18n/de.ts +139 -0
- package/src/tool/fantasy-runes-translator/i18n/en.ts +139 -0
- package/src/tool/fantasy-runes-translator/i18n/es.ts +139 -0
- package/src/tool/fantasy-runes-translator/i18n/fr.ts +139 -0
- package/src/tool/fantasy-runes-translator/i18n/id.ts +139 -0
- package/src/tool/fantasy-runes-translator/i18n/it.ts +139 -0
- package/src/tool/fantasy-runes-translator/i18n/ja.ts +139 -0
- package/src/tool/fantasy-runes-translator/i18n/ko.ts +139 -0
- package/src/tool/fantasy-runes-translator/i18n/nl.ts +139 -0
- package/src/tool/fantasy-runes-translator/i18n/pl.ts +139 -0
- package/src/tool/fantasy-runes-translator/i18n/pt.ts +139 -0
- package/src/tool/fantasy-runes-translator/i18n/ru.ts +139 -0
- package/src/tool/fantasy-runes-translator/i18n/sv.ts +139 -0
- package/src/tool/fantasy-runes-translator/i18n/tr.ts +139 -0
- package/src/tool/fantasy-runes-translator/i18n/zh.ts +139 -0
- package/src/tool/fantasy-runes-translator/index.ts +9 -0
- package/src/tool/fantasy-runes-translator/logic.ts +174 -0
- package/src/tool/fantasy-runes-translator/seo.astro +16 -0
- package/src/tool/fantasy-runes-translator/types.ts +26 -0
- package/src/tool/investigation-board/bibliography.astro +16 -0
- package/src/tool/investigation-board/bibliography.ts +12 -0
- package/src/tool/investigation-board/boardContextMenu.ts +64 -0
- package/src/tool/investigation-board/client.ts +223 -0
- package/src/tool/investigation-board/component.astro +23 -0
- package/src/tool/investigation-board/components/BoardCanvas.astro +39 -0
- package/src/tool/investigation-board/components/CardDrawer.astro +73 -0
- package/src/tool/investigation-board/components/ConnectionModal.astro +49 -0
- package/src/tool/investigation-board/components/ContextMenu.astro +26 -0
- package/src/tool/investigation-board/components/ControlPanel.astro +57 -0
- package/src/tool/investigation-board/connectionModal.ts +60 -0
- package/src/tool/investigation-board/dom.ts +89 -0
- package/src/tool/investigation-board/drawer.ts +110 -0
- package/src/tool/investigation-board/entry.ts +59 -0
- package/src/tool/investigation-board/i18n/de.ts +126 -0
- package/src/tool/investigation-board/i18n/en.ts +127 -0
- package/src/tool/investigation-board/i18n/es.ts +126 -0
- package/src/tool/investigation-board/i18n/fr.ts +126 -0
- package/src/tool/investigation-board/i18n/id.ts +126 -0
- package/src/tool/investigation-board/i18n/it.ts +126 -0
- package/src/tool/investigation-board/i18n/ja.ts +126 -0
- package/src/tool/investigation-board/i18n/ko.ts +126 -0
- package/src/tool/investigation-board/i18n/nl.ts +126 -0
- package/src/tool/investigation-board/i18n/pl.ts +126 -0
- package/src/tool/investigation-board/i18n/pt.ts +126 -0
- package/src/tool/investigation-board/i18n/ru.ts +126 -0
- package/src/tool/investigation-board/i18n/sv.ts +126 -0
- package/src/tool/investigation-board/i18n/tr.ts +126 -0
- package/src/tool/investigation-board/i18n/zh.ts +126 -0
- package/src/tool/investigation-board/index.ts +11 -0
- package/src/tool/investigation-board/interactDrag.ts +58 -0
- package/src/tool/investigation-board/investigation-board.css +799 -0
- package/src/tool/investigation-board/layout.ts +16 -0
- package/src/tool/investigation-board/logic.test.ts +105 -0
- package/src/tool/investigation-board/logic.ts +106 -0
- package/src/tool/investigation-board/panzoom.ts +24 -0
- package/src/tool/investigation-board/renderer.ts +256 -0
- package/src/tool/investigation-board/seo.astro +16 -0
- package/src/tool/investigation-board/stateManager.ts +126 -0
- package/src/tool/investigation-board/types.ts +26 -0
- package/src/tool/rpg-initiative-tracker/bibliography.astro +16 -0
- package/src/tool/rpg-initiative-tracker/bibliography.ts +12 -0
- package/src/tool/rpg-initiative-tracker/client.ts +201 -0
- package/src/tool/rpg-initiative-tracker/component.astro +23 -0
- package/src/tool/rpg-initiative-tracker/components/AddForm.astro +49 -0
- package/src/tool/rpg-initiative-tracker/components/CombatantList.astro +16 -0
- package/src/tool/rpg-initiative-tracker/components/TurnControls.astro +28 -0
- package/src/tool/rpg-initiative-tracker/entry.ts +50 -0
- package/src/tool/rpg-initiative-tracker/i18n/de.ts +266 -0
- package/src/tool/rpg-initiative-tracker/i18n/en.ts +263 -0
- package/src/tool/rpg-initiative-tracker/i18n/es.ts +264 -0
- package/src/tool/rpg-initiative-tracker/i18n/fr.ts +263 -0
- package/src/tool/rpg-initiative-tracker/i18n/id.ts +263 -0
- package/src/tool/rpg-initiative-tracker/i18n/it.ts +263 -0
- package/src/tool/rpg-initiative-tracker/i18n/ja.ts +263 -0
- package/src/tool/rpg-initiative-tracker/i18n/ko.ts +263 -0
- package/src/tool/rpg-initiative-tracker/i18n/nl.ts +263 -0
- package/src/tool/rpg-initiative-tracker/i18n/pl.ts +264 -0
- package/src/tool/rpg-initiative-tracker/i18n/pt.ts +263 -0
- package/src/tool/rpg-initiative-tracker/i18n/ru.ts +264 -0
- package/src/tool/rpg-initiative-tracker/i18n/sv.ts +263 -0
- package/src/tool/rpg-initiative-tracker/i18n/tr.ts +263 -0
- package/src/tool/rpg-initiative-tracker/i18n/zh.ts +263 -0
- package/src/tool/rpg-initiative-tracker/index.ts +9 -0
- package/src/tool/rpg-initiative-tracker/logic.test.ts +121 -0
- package/src/tool/rpg-initiative-tracker/logic.ts +122 -0
- package/src/tool/rpg-initiative-tracker/rpg-initiative-tracker.css +617 -0
- package/src/tool/rpg-initiative-tracker/seo.astro +16 -0
- package/src/tool/rpg-initiative-tracker/types.ts +15 -0
- package/src/tool/score-tracker/bibliography.astro +16 -0
- package/src/tool/score-tracker/bibliography.ts +12 -0
- package/src/tool/score-tracker/client.ts +248 -0
- package/src/tool/score-tracker/component.astro +34 -0
- package/src/tool/score-tracker/components/Leaderboard.astro +19 -0
- package/src/tool/score-tracker/components/PlayerSetup.astro +32 -0
- package/src/tool/score-tracker/components/RoundScoring.astro +22 -0
- package/src/tool/score-tracker/components/ScoreHistory.astro +25 -0
- package/src/tool/score-tracker/entry.ts +57 -0
- package/src/tool/score-tracker/i18n/de.ts +278 -0
- package/src/tool/score-tracker/i18n/en.ts +278 -0
- package/src/tool/score-tracker/i18n/es.ts +251 -0
- package/src/tool/score-tracker/i18n/fr.ts +278 -0
- package/src/tool/score-tracker/i18n/id.ts +278 -0
- package/src/tool/score-tracker/i18n/it.ts +278 -0
- package/src/tool/score-tracker/i18n/ja.ts +278 -0
- package/src/tool/score-tracker/i18n/ko.ts +278 -0
- package/src/tool/score-tracker/i18n/nl.ts +278 -0
- package/src/tool/score-tracker/i18n/pl.ts +278 -0
- package/src/tool/score-tracker/i18n/pt.ts +278 -0
- package/src/tool/score-tracker/i18n/ru.ts +278 -0
- package/src/tool/score-tracker/i18n/sv.ts +278 -0
- package/src/tool/score-tracker/i18n/tr.ts +278 -0
- package/src/tool/score-tracker/i18n/zh.ts +278 -0
- package/src/tool/score-tracker/index.ts +9 -0
- package/src/tool/score-tracker/logic.test.ts +83 -0
- package/src/tool/score-tracker/logic.ts +122 -0
- package/src/tool/score-tracker/score-tracker.css +1029 -0
- package/src/tool/score-tracker/seo.astro +16 -0
- package/src/tool/score-tracker/types.ts +17 -0
- package/src/tools.ts +11 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { initiativeTracker } 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 = initiativeTracker.i18n[locale] || initiativeTracker.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,15 @@
|
|
|
1
|
+
export interface Combatant {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
initiative: number;
|
|
5
|
+
modifier: number;
|
|
6
|
+
isPlayer: boolean;
|
|
7
|
+
statuses: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface InitiativeState {
|
|
11
|
+
combatants: Combatant[];
|
|
12
|
+
currentIndex: number;
|
|
13
|
+
round: number;
|
|
14
|
+
isCombatActive: boolean;
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { scoreTracker } 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 = scoreTracker.i18n[locale] || scoreTracker.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: 'Wikipedia Scorekeeping',
|
|
6
|
+
url: 'https://en.wikipedia.org/wiki/Score_(game)',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'Board Game Geek Scoring Guide',
|
|
10
|
+
url: 'https://boardgamegeek.com/blog/4289/blogpost/38927/the-bgg-ranking-and-rating-system-flaws-advantages',
|
|
11
|
+
},
|
|
12
|
+
];
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { addPlayer, removePlayer, renamePlayer, startNewRound, updateRoundScore, deleteLastRound, resetGame, createBlankState } from './logic';
|
|
2
|
+
import type { ScoreState } from './types';
|
|
3
|
+
const STORAGE_KEY = 'score-tracker-state';
|
|
4
|
+
const wrapper = document.querySelector('.score-main-wrapper') as HTMLElement;
|
|
5
|
+
if (!wrapper) throw new Error('Score tracker wrapper not found');
|
|
6
|
+
const ui: Record<string, string> = JSON.parse(wrapper.dataset.ui || '{}');
|
|
7
|
+
function saveState() {
|
|
8
|
+
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch { }
|
|
9
|
+
}
|
|
10
|
+
function loadState(): ScoreState {
|
|
11
|
+
try { const s = localStorage.getItem(STORAGE_KEY); if (s) return JSON.parse(s) as ScoreState; } catch { }
|
|
12
|
+
return createBlankState();
|
|
13
|
+
}
|
|
14
|
+
let state: ScoreState = loadState();
|
|
15
|
+
let previousLeaderId = '';
|
|
16
|
+
const $ = (id: string) => document.getElementById(id) as HTMLElement;
|
|
17
|
+
const addPlayerInput = $('add-player-input') as HTMLInputElement;
|
|
18
|
+
const addPlayerBtn = $('add-player-btn');
|
|
19
|
+
const playerList = $('player-list');
|
|
20
|
+
const startRoundBtn = $('start-round-btn');
|
|
21
|
+
const nextRoundBtn = $('next-round-btn');
|
|
22
|
+
const resetGameBtn = $('reset-game-btn');
|
|
23
|
+
const resetAllBtn = $('reset-all-btn');
|
|
24
|
+
const roundLabel = $('round-label');
|
|
25
|
+
const roundScoreRows = $('round-score-rows');
|
|
26
|
+
const leaderboardList = $('leaderboard-list');
|
|
27
|
+
const undoRoundBtn = $('undo-round-btn');
|
|
28
|
+
const historyContent = $('history-content');
|
|
29
|
+
const leaderboardInline = $('leaderboard-inline');
|
|
30
|
+
const roundScoringPanel = $('round-scoring-panel');
|
|
31
|
+
function tabListeners() {
|
|
32
|
+
document.querySelectorAll('.tab-score-btn').forEach(btn => {
|
|
33
|
+
btn.addEventListener('click', () => {
|
|
34
|
+
document.querySelectorAll('.tab-score-btn').forEach(b => b.classList.remove('active'));
|
|
35
|
+
document.querySelectorAll('.tab-score-pane').forEach(p => p.classList.remove('active'));
|
|
36
|
+
btn.classList.add('active');
|
|
37
|
+
const id = btn.getAttribute('data-score-tab');
|
|
38
|
+
if (id) $(id)?.classList.add('active');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function spawnParticle(parent: HTMLElement, opts: { text: string; x: number; y: number; isPos: boolean }) {
|
|
43
|
+
const el = document.createElement('span');
|
|
44
|
+
el.className = 'floating-score-particle' + (opts.isPos ? ' particle-positive' : ' particle-negative');
|
|
45
|
+
el.textContent = opts.text;
|
|
46
|
+
el.style.cssText = `left:${opts.x}px;top:${opts.y}px;position:absolute`;
|
|
47
|
+
parent.appendChild(el);
|
|
48
|
+
setTimeout(() => el.remove(), 800);
|
|
49
|
+
}
|
|
50
|
+
function spawnBig(text: string, isPos: boolean) {
|
|
51
|
+
const el = document.createElement('div');
|
|
52
|
+
el.className = 'floating-big-particle' + (isPos ? ' big-positive' : ' big-negative');
|
|
53
|
+
el.textContent = text;
|
|
54
|
+
el.style.cssText = 'left:50%;top:50%;position:fixed';
|
|
55
|
+
document.body.appendChild(el);
|
|
56
|
+
setTimeout(() => el.remove(), 1000);
|
|
57
|
+
}
|
|
58
|
+
function sortedPlayers() {
|
|
59
|
+
return [...state.players].sort((a, b) => b.total - a.total);
|
|
60
|
+
}
|
|
61
|
+
function getPlayerRank(pid: string) {
|
|
62
|
+
return sortedPlayers().findIndex(p => p.id === pid) + 1;
|
|
63
|
+
}
|
|
64
|
+
const editSvg = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
|
|
65
|
+
const closeSvg = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
|
66
|
+
const emptySvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>';
|
|
67
|
+
function renderPlayerList() {
|
|
68
|
+
playerList.innerHTML = state.players.map(p => {
|
|
69
|
+
const rank = getPlayerRank(p.id);
|
|
70
|
+
return `<div class="player-card" data-player-id="${p.id}"><div class="player-card-name"><span class="player-rank-badge rank-${rank <= 3 ? rank : 'other'}">${rank}</span><span>${p.name}</span></div><div class="player-card-actions"><button type="button" class="btn-icon-sm rename-btn" data-action="rename" title="${ui.renamePlayer}">${editSvg}</button><button type="button" class="btn-icon-sm remove" data-action="remove" title="${ui.removePlayer}">${closeSvg}</button></div></div>`;
|
|
71
|
+
}).join('');
|
|
72
|
+
playerList.querySelectorAll('[data-action="remove"]').forEach(b => b.addEventListener('click', onRemovePlayer));
|
|
73
|
+
playerList.querySelectorAll('[data-action="rename"]').forEach(b => b.addEventListener('click', onRenamePlayer));
|
|
74
|
+
}
|
|
75
|
+
function renderLB(container: HTMLElement, emptyMsg: string) {
|
|
76
|
+
const sorted = sortedPlayers();
|
|
77
|
+
if (sorted.length === 0) { container.innerHTML = `<div class="no-round-message"><span>${emptyMsg}</span></div>`; return; }
|
|
78
|
+
container.innerHTML = sorted.map((p, i) => {
|
|
79
|
+
const rank = i + 1;
|
|
80
|
+
return `<div class="leaderboard-item${rank === 1 ? ' first' : ''}" data-player-id="${p.id}"><span class="leaderboard-rank rank-${rank <= 3 ? rank : 'other'}">${rank}</span><span class="leaderboard-name">${p.name}</span><span class="leaderboard-total" data-lb-total="${p.id}">${p.total}</span></div>`;
|
|
81
|
+
}).join('');
|
|
82
|
+
}
|
|
83
|
+
function reorderLB(container: HTMLElement) {
|
|
84
|
+
const sorted = sortedPlayers();
|
|
85
|
+
const items = container.querySelectorAll('.leaderboard-item');
|
|
86
|
+
if (items.length === 0) { renderLB(container, ''); return; }
|
|
87
|
+
const itemMap = new Map<string, Element>();
|
|
88
|
+
items.forEach(el => { const id = el.getAttribute('data-player-id'); if (id) itemMap.set(id, el); });
|
|
89
|
+
sorted.forEach((p, i) => {
|
|
90
|
+
const el = itemMap.get(p.id);
|
|
91
|
+
if (!el) return;
|
|
92
|
+
const rank = i + 1;
|
|
93
|
+
const rankEl = el.querySelector('.leaderboard-rank');
|
|
94
|
+
const totalEl = el.querySelector('.leaderboard-total');
|
|
95
|
+
if (rankEl) { rankEl.textContent = String(rank); rankEl.className = `leaderboard-rank rank-${rank <= 3 ? rank : 'other'}`; }
|
|
96
|
+
if (totalEl) totalEl.textContent = String(p.total);
|
|
97
|
+
el.classList.toggle('first', rank === 1);
|
|
98
|
+
container.appendChild(el);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function longPress(el: HTMLElement, duration: number, cb: () => void) {
|
|
102
|
+
let timer: number | null = null;
|
|
103
|
+
const start = () => {
|
|
104
|
+
el.style.setProperty('--lp-dur', duration + 'ms');
|
|
105
|
+
el.classList.add('lp-active');
|
|
106
|
+
timer = window.setTimeout(() => { el.classList.remove('lp-active'); cb(); }, duration);
|
|
107
|
+
};
|
|
108
|
+
const cancel = () => {
|
|
109
|
+
if (timer !== null) { clearTimeout(timer); timer = null; }
|
|
110
|
+
el.classList.remove('lp-active');
|
|
111
|
+
};
|
|
112
|
+
const ev = (n: string, fn: typeof start) => el.addEventListener(n, fn);
|
|
113
|
+
ev('mousedown', start); ev('mouseup', cancel); ev('mouseleave', cancel);
|
|
114
|
+
ev('touchstart', start); ev('touchend', cancel); ev('touchcancel', cancel);
|
|
115
|
+
}
|
|
116
|
+
function checkLeaderChange() {
|
|
117
|
+
const currentLeader = sortedPlayers()[0]?.id || '';
|
|
118
|
+
if (currentLeader && currentLeader !== previousLeaderId && previousLeaderId !== '') {
|
|
119
|
+
const leader = state.players.find(p => p.id === currentLeader);
|
|
120
|
+
if (leader) spawnBig(leader.name + ' Takes the Lead!', true);
|
|
121
|
+
}
|
|
122
|
+
previousLeaderId = currentLeader;
|
|
123
|
+
}
|
|
124
|
+
function onRemovePlayer(e: Event) {
|
|
125
|
+
const card = (e.currentTarget as HTMLElement).closest('.player-card') as HTMLElement;
|
|
126
|
+
const pid = card?.dataset.playerId;
|
|
127
|
+
if (pid) { state = removePlayer(state, pid); renderAll(); }
|
|
128
|
+
}
|
|
129
|
+
function onRenamePlayer(e: Event) {
|
|
130
|
+
const card = (e.currentTarget as HTMLElement).closest('.player-card') as HTMLElement;
|
|
131
|
+
const pid = card?.dataset.playerId;
|
|
132
|
+
if (pid) showRenamePopup(pid);
|
|
133
|
+
}
|
|
134
|
+
function showRenamePopup(playerId: string) {
|
|
135
|
+
const player = state.players.find(p => p.id === playerId);
|
|
136
|
+
if (!player) return;
|
|
137
|
+
const overlay = document.createElement('div');
|
|
138
|
+
overlay.className = 'rename-popup';
|
|
139
|
+
overlay.innerHTML = `<div class="rename-popup-content"><h3>${ui.renamePlayer}</h3><input type="text" id="rename-input" value="${player.name}" autocomplete="off" /><div class="rename-popup-actions"><button type="button" class="btn-rename-cancel" id="rename-cancel-btn">${ui.cancelLabel}</button><button type="button" class="btn-rename-confirm" id="rename-confirm-btn">${ui.confirmLabel}</button></div></div>`;
|
|
140
|
+
document.body.appendChild(overlay);
|
|
141
|
+
const input = overlay.querySelector('#rename-input') as HTMLInputElement;
|
|
142
|
+
input.focus();
|
|
143
|
+
input.select();
|
|
144
|
+
overlay.querySelector('#rename-cancel-btn')?.addEventListener('click', () => overlay.remove());
|
|
145
|
+
overlay.querySelector('#rename-confirm-btn')?.addEventListener('click', () => {
|
|
146
|
+
const newName = input.value.trim();
|
|
147
|
+
if (newName) { state = renamePlayer(state, playerId, newName); renderAll(); }
|
|
148
|
+
overlay.remove();
|
|
149
|
+
});
|
|
150
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
|
151
|
+
}
|
|
152
|
+
function updateScoreDisplay(pid: string) {
|
|
153
|
+
const el = $('score-val-' + pid);
|
|
154
|
+
if (!el) return;
|
|
155
|
+
const player = state.players.find(p => p.id === pid);
|
|
156
|
+
if (player) el.textContent = String(player.total);
|
|
157
|
+
el.classList.remove('pulse-score');
|
|
158
|
+
void el.offsetWidth;
|
|
159
|
+
el.classList.add('pulse-score');
|
|
160
|
+
}
|
|
161
|
+
function spawnScoreFx(delta: number, row: HTMLElement, e: MouseEvent) {
|
|
162
|
+
const sign = delta > 0 ? '+' : '';
|
|
163
|
+
const txt = sign + delta;
|
|
164
|
+
const rect = row.getBoundingClientRect();
|
|
165
|
+
spawnParticle(row, { text: txt, x: e.clientX - rect.left, y: e.clientY - rect.top, isPos: delta > 0 });
|
|
166
|
+
if (Math.abs(delta) >= 5) spawnBig(txt, delta > 0);
|
|
167
|
+
}
|
|
168
|
+
function onScoreAdjust(e: Event) {
|
|
169
|
+
const row = (e.currentTarget as HTMLElement).closest('.score-row') as HTMLElement;
|
|
170
|
+
const pid = row?.dataset.playerId;
|
|
171
|
+
const roundIdx = parseInt(row?.dataset.roundIndex || '0');
|
|
172
|
+
const delta = parseInt((e.currentTarget as HTMLElement).dataset.delta || '0');
|
|
173
|
+
if (!pid) return;
|
|
174
|
+
state = updateRoundScore(state, roundIdx, pid, delta);
|
|
175
|
+
updateScoreDisplay(pid);
|
|
176
|
+
reorderLB(leaderboardList);
|
|
177
|
+
reorderLB(leaderboardInline);
|
|
178
|
+
checkLeaderChange();
|
|
179
|
+
spawnScoreFx(delta, row, e as MouseEvent);
|
|
180
|
+
saveState();
|
|
181
|
+
}
|
|
182
|
+
function renderRoundScores() {
|
|
183
|
+
if (state.rounds.length === 0) {
|
|
184
|
+
roundScoreRows.innerHTML = `<div class="no-round-message">${emptySvg}<span>${ui.noRoundsYet}</span></div>`;
|
|
185
|
+
roundLabel.textContent = `${ui.currentRoundLabel}: --`;
|
|
186
|
+
startRoundBtn.style.display = 'inline-block';
|
|
187
|
+
nextRoundBtn.style.display = 'none';
|
|
188
|
+
roundScoringPanel?.classList.remove('has-scores');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
roundScoringPanel?.classList.add('has-scores');
|
|
192
|
+
const idx = state.rounds.length - 1;
|
|
193
|
+
const cr = state.rounds[idx];
|
|
194
|
+
roundLabel.textContent = `${ui.roundLabel} ${cr.round}`;
|
|
195
|
+
startRoundBtn.style.display = 'none';
|
|
196
|
+
nextRoundBtn.style.display = 'inline-block';
|
|
197
|
+
roundScoreRows.innerHTML = state.players.map(p => {
|
|
198
|
+
const sc = cr.scores[p.id] || 0;
|
|
199
|
+
return `<div class="score-row" data-player-id="${p.id}" data-round-index="${idx}"><span class="score-row-name">${p.name}</span><div class="score-row-controls"><button type="button" class="btn-score-adjust dec" data-delta="-1">-</button><button type="button" class="btn-score-adjust dec" data-delta="-5">-5</button><span class="score-row-value" id="score-val-${p.id}">${sc}</span><button type="button" class="btn-score-adjust inc" data-delta="5">+5</button><button type="button" class="btn-score-adjust inc" data-delta="1">+</button></div></div>`;
|
|
200
|
+
}).join('');
|
|
201
|
+
roundScoreRows.querySelectorAll('.btn-score-adjust').forEach(b => b.addEventListener('click', onScoreAdjust));
|
|
202
|
+
}
|
|
203
|
+
function renderHistory() {
|
|
204
|
+
if (state.rounds.length === 0) {
|
|
205
|
+
historyContent.innerHTML = `<div class="no-history-msg">${ui.noRoundsYet}</div>`;
|
|
206
|
+
undoRoundBtn.style.display = 'none';
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
undoRoundBtn.style.display = 'inline-block';
|
|
210
|
+
historyContent.innerHTML = [...state.rounds].reverse().map(r => {
|
|
211
|
+
const lines = state.players.map(p => {
|
|
212
|
+
const sc = r.scores[p.id];
|
|
213
|
+
if (sc === undefined) return null;
|
|
214
|
+
const cls = sc > 0 ? 'positive' : 'negative';
|
|
215
|
+
const fCls = sc === 0 ? 'zero' : cls;
|
|
216
|
+
const sign = sc >= 0 ? '+' : '';
|
|
217
|
+
return `<div class="history-score-line"><span class="history-player-name">${p.name}</span><span class="history-player-score ${fCls}">${sign}${sc}</span></div>`;
|
|
218
|
+
}).filter(Boolean).join('');
|
|
219
|
+
const roundTotal = state.players.reduce((acc, p) => acc + (r.scores[p.id] || 0), 0);
|
|
220
|
+
return `<div class="history-round-card"><div class="history-round-header"><span class="history-round-title">${ui.roundLabel} ${r.round}</span><span class="history-round-total">Total: ${roundTotal >= 0 ? '+' : ''}${roundTotal}</span></div><div class="history-scores-body">${lines}</div></div>`;
|
|
221
|
+
}).join('');
|
|
222
|
+
}
|
|
223
|
+
function renderAll() {
|
|
224
|
+
renderPlayerList();
|
|
225
|
+
renderLB(leaderboardList, ui.noPlayersHint);
|
|
226
|
+
renderRoundScores();
|
|
227
|
+
renderHistory();
|
|
228
|
+
renderLB(leaderboardInline, ui.noPlayersHint);
|
|
229
|
+
const hasPlayers = state.players.length > 0;
|
|
230
|
+
startRoundBtn.disabled = !hasPlayers;
|
|
231
|
+
resetGameBtn.disabled = !hasPlayers;
|
|
232
|
+
saveState();
|
|
233
|
+
}
|
|
234
|
+
addPlayerBtn.addEventListener('click', () => {
|
|
235
|
+
const n = addPlayerInput.value.trim(); if (!n) return;
|
|
236
|
+
state = addPlayer(state, n); addPlayerInput.value = ''; previousLeaderId = ''; renderAll(); addPlayerInput.focus();
|
|
237
|
+
});
|
|
238
|
+
addPlayerInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') addPlayerBtn.click(); });
|
|
239
|
+
startRoundBtn.addEventListener('click', () => { state = startNewRound(state); renderAll(); });
|
|
240
|
+
nextRoundBtn.addEventListener('click', () => { state = startNewRound(state); renderAll(); });
|
|
241
|
+
longPress(resetGameBtn, 800, () => { state = resetGame(state); renderAll(); previousLeaderId = ''; });
|
|
242
|
+
longPress(resetAllBtn, 1600, () => { state = createBlankState(); previousLeaderId = ''; renderAll(); });
|
|
243
|
+
undoRoundBtn.addEventListener('click', () => {
|
|
244
|
+
if (state.rounds.length === 0) return; state = deleteLastRound(state); previousLeaderId = ''; renderAll();
|
|
245
|
+
});
|
|
246
|
+
tabListeners();
|
|
247
|
+
previousLeaderId = sortedPlayers()[0]?.id || '';
|
|
248
|
+
renderAll();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './score-tracker.css';
|
|
3
|
+
import PlayerSetup from './components/PlayerSetup.astro';
|
|
4
|
+
import RoundScoring from './components/RoundScoring.astro';
|
|
5
|
+
import Leaderboard from './components/Leaderboard.astro';
|
|
6
|
+
import ScoreHistory from './components/ScoreHistory.astro';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
ui: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { ui } = Astro.props;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<div class="score-main-wrapper" data-ui={JSON.stringify(ui)}>
|
|
16
|
+
<PlayerSetup labels={ui} />
|
|
17
|
+
<div class="score-layout">
|
|
18
|
+
<RoundScoring labels={ui} />
|
|
19
|
+
<div>
|
|
20
|
+
<Leaderboard labels={ui} />
|
|
21
|
+
<ScoreHistory labels={ui} />
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="score-footer-actions">
|
|
25
|
+
<button type="button" class="btn-score-action danger" id="reset-game-btn">
|
|
26
|
+
{ui.resetGame}
|
|
27
|
+
</button>
|
|
28
|
+
<button type="button" class="btn-score-action danger" id="reset-all-btn">
|
|
29
|
+
{ui.resetAllLabel}
|
|
30
|
+
</button>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<script src="./client.ts"></script>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
labels: Record<string, string>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const { labels } = Astro.props;
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<div class="leaderboard-panel" id="leaderboard-panel">
|
|
10
|
+
<h3 class="panel-title">{labels.leaderboardTitle}</h3>
|
|
11
|
+
<div class="leaderboard-list" id="leaderboard-list">
|
|
12
|
+
<div class="no-round-message">
|
|
13
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
14
|
+
<path d="M6 9l6 6 6-6"/>
|
|
15
|
+
</svg>
|
|
16
|
+
<span>{labels.noPlayersHint}</span>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
labels: Record<string, string>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const { labels } = Astro.props;
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<div class="player-setup-panel" id="player-setup-panel">
|
|
10
|
+
<h3 class="panel-title">Players</h3>
|
|
11
|
+
<div class="add-player-row">
|
|
12
|
+
<input
|
|
13
|
+
type="text"
|
|
14
|
+
class="add-player-input"
|
|
15
|
+
id="add-player-input"
|
|
16
|
+
placeholder={labels.playerNamePlaceholder}
|
|
17
|
+
autocomplete="off"
|
|
18
|
+
/>
|
|
19
|
+
<button type="button" class="btn-score-action primary" id="add-player-btn">
|
|
20
|
+
{labels.addPlayer}
|
|
21
|
+
</button>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="player-list" id="player-list"></div>
|
|
24
|
+
<div class="score-round-actions">
|
|
25
|
+
<button type="button" class="btn-score-action" id="start-round-btn">
|
|
26
|
+
{labels.startRound}
|
|
27
|
+
</button>
|
|
28
|
+
<button type="button" class="btn-score-action" id="next-round-btn" style="display:none">
|
|
29
|
+
{labels.nextRound}
|
|
30
|
+
</button>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
labels: Record<string, string>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const { labels } = Astro.props;
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<div class="round-scoring-panel" id="round-scoring-panel">
|
|
10
|
+
<div class="round-header">
|
|
11
|
+
<h3 class="panel-title">{labels.scoringTitle}</h3>
|
|
12
|
+
<span class="round-label" id="round-label">{labels.currentRoundLabel}: --</span>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="round-score-rows" id="round-score-rows">
|
|
15
|
+
<div class="no-round-message" id="no-round-msg">
|
|
16
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
17
|
+
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
|
18
|
+
</svg>
|
|
19
|
+
<span>{labels.noRoundsYet}</span>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
labels: Record<string, string>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const { labels } = Astro.props;
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<div class="score-history-panel" id="score-history-panel">
|
|
10
|
+
<div class="tabs-nav-score">
|
|
11
|
+
<button type="button" class="tab-score-btn active" data-score-tab="leaderboard-tab">{labels.leaderboardTitle}</button>
|
|
12
|
+
<button type="button" class="tab-score-btn" data-score-tab="history-tab">{labels.historyTitle}</button>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="tab-score-pane active" id="leaderboard-tab">
|
|
15
|
+
<div id="leaderboard-inline"></div>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="tab-score-pane" id="history-tab">
|
|
18
|
+
<div id="history-content">
|
|
19
|
+
<div class="no-history-msg">{labels.noRoundsYet}</div>
|
|
20
|
+
</div>
|
|
21
|
+
<button type="button" class="btn-score-action danger" id="undo-round-btn" style="display:none;margin-top:0.75rem">
|
|
22
|
+
{labels.undoRound}
|
|
23
|
+
</button>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { TabletopToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
|
|
3
|
+
export type ScoreTrackerUI = {
|
|
4
|
+
title: string;
|
|
5
|
+
addPlayer: string;
|
|
6
|
+
removePlayer: string;
|
|
7
|
+
renamePlayer: string;
|
|
8
|
+
startRound: string;
|
|
9
|
+
nextRound: string;
|
|
10
|
+
resetGame: string;
|
|
11
|
+
undoRound: string;
|
|
12
|
+
playerLabel: string;
|
|
13
|
+
scoreLabel: string;
|
|
14
|
+
roundLabel: string;
|
|
15
|
+
totalLabel: string;
|
|
16
|
+
rankLabel: string;
|
|
17
|
+
leaderboardTitle: string;
|
|
18
|
+
historyTitle: string;
|
|
19
|
+
noPlayersHint: string;
|
|
20
|
+
playerNamePlaceholder: string;
|
|
21
|
+
confirmLabel: string;
|
|
22
|
+
cancelLabel: string;
|
|
23
|
+
deleteRoundConfirm: string;
|
|
24
|
+
playerAdded: string;
|
|
25
|
+
playerRemoved: string;
|
|
26
|
+
roundStarted: string;
|
|
27
|
+
gameReset: string;
|
|
28
|
+
noRoundsYet: string;
|
|
29
|
+
currentRoundLabel: string;
|
|
30
|
+
scoringTitle: string;
|
|
31
|
+
resetAllLabel: string;
|
|
32
|
+
confirmResetAll: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type ScoreTrackerLocaleContent = ToolLocaleContent<ScoreTrackerUI>;
|
|
36
|
+
|
|
37
|
+
export const scoreTracker: TabletopToolEntry<ScoreTrackerUI> = {
|
|
38
|
+
id: 'score-tracker',
|
|
39
|
+
icons: { bg: 'mdi:scoreboard-outline', fg: 'mdi:scoreboard' },
|
|
40
|
+
i18n: {
|
|
41
|
+
de: () => import('./i18n/de').then((m) => m.content),
|
|
42
|
+
en: () => import('./i18n/en').then((m) => m.content),
|
|
43
|
+
es: () => import('./i18n/es').then((m) => m.content),
|
|
44
|
+
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
45
|
+
id: () => import('./i18n/id').then((m) => m.content),
|
|
46
|
+
it: () => import('./i18n/it').then((m) => m.content),
|
|
47
|
+
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
48
|
+
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
49
|
+
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
50
|
+
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
51
|
+
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
52
|
+
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
53
|
+
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
54
|
+
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
55
|
+
zh: () => import('./i18n/zh').then((m) => m.content),
|
|
56
|
+
},
|
|
57
|
+
};
|