@jjlmoya/utils-sports 1.18.0 → 1.20.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 +1 -1
- package/src/category/index.ts +2 -0
- package/src/entries.ts +7 -1
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/pingPongScoreKeeper/bibliography.astro +6 -0
- package/src/tool/pingPongScoreKeeper/bibliography.ts +8 -0
- package/src/tool/pingPongScoreKeeper/component.astro +124 -0
- package/src/tool/pingPongScoreKeeper/entry.ts +31 -0
- package/src/tool/pingPongScoreKeeper/game-logic.ts +120 -0
- package/src/tool/pingPongScoreKeeper/i18n/de.ts +201 -0
- package/src/tool/pingPongScoreKeeper/i18n/en.ts +201 -0
- package/src/tool/pingPongScoreKeeper/i18n/es.ts +201 -0
- package/src/tool/pingPongScoreKeeper/i18n/fr.ts +201 -0
- package/src/tool/pingPongScoreKeeper/i18n/id.ts +201 -0
- package/src/tool/pingPongScoreKeeper/i18n/it.ts +201 -0
- package/src/tool/pingPongScoreKeeper/i18n/ja.ts +201 -0
- package/src/tool/pingPongScoreKeeper/i18n/ko.ts +201 -0
- package/src/tool/pingPongScoreKeeper/i18n/nl.ts +201 -0
- package/src/tool/pingPongScoreKeeper/i18n/pl.ts +201 -0
- package/src/tool/pingPongScoreKeeper/i18n/pt.ts +201 -0
- package/src/tool/pingPongScoreKeeper/i18n/ru.ts +201 -0
- package/src/tool/pingPongScoreKeeper/i18n/sv.ts +201 -0
- package/src/tool/pingPongScoreKeeper/i18n/tr.ts +201 -0
- package/src/tool/pingPongScoreKeeper/i18n/zh.ts +201 -0
- package/src/tool/pingPongScoreKeeper/index.ts +9 -0
- package/src/tool/pingPongScoreKeeper/logic.ts +278 -0
- package/src/tool/pingPongScoreKeeper/ping-pong-scorekeeper.css +576 -0
- package/src/tool/pingPongScoreKeeper/render.ts +86 -0
- package/src/tool/pingPongScoreKeeper/seo.astro +15 -0
- package/src/tool/pingPongScoreKeeper/ui.ts +25 -0
- package/src/tool/tennisScoreKeeper/bibliography.astro +6 -0
- package/src/tool/tennisScoreKeeper/bibliography.ts +8 -0
- package/src/tool/tennisScoreKeeper/component.astro +164 -0
- package/src/tool/tennisScoreKeeper/entry.ts +30 -0
- package/src/tool/tennisScoreKeeper/events.ts +136 -0
- package/src/tool/tennisScoreKeeper/game-logic.ts +248 -0
- package/src/tool/tennisScoreKeeper/i18n/de.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/en.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/es.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/fr.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/id.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/it.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/ja.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/ko.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/nl.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/pl.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/pt.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/ru.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/sv.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/tr.ts +194 -0
- package/src/tool/tennisScoreKeeper/i18n/zh.ts +195 -0
- package/src/tool/tennisScoreKeeper/index.ts +9 -0
- package/src/tool/tennisScoreKeeper/logic.ts +205 -0
- package/src/tool/tennisScoreKeeper/render.ts +256 -0
- package/src/tool/tennisScoreKeeper/seo.astro +15 -0
- package/src/tool/tennisScoreKeeper/tennis-scorekeeper.css +827 -0
- package/src/tool/tennisScoreKeeper/ui.ts +27 -0
- package/src/tools.ts +4 -0
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { scoreKeeper } from '../tool/scoreKeeper/index';
|
|
|
5
5
|
import { tournamentBracket } from '../tool/tournamentBracket/index';
|
|
6
6
|
import { gymTracker } from '../tool/gymTracker/index';
|
|
7
7
|
import { reactionTester } from '../tool/reactionTester/index';
|
|
8
|
+
import { pingPongScoreKeeper } from '../tool/pingPongScoreKeeper/index';
|
|
8
9
|
|
|
9
10
|
export const sportsCategory: SportsCategoryEntry = {
|
|
10
11
|
icon: 'mdi:soccer',
|
|
@@ -15,6 +16,7 @@ export const sportsCategory: SportsCategoryEntry = {
|
|
|
15
16
|
tournamentBracket,
|
|
16
17
|
gymTracker,
|
|
17
18
|
reactionTester,
|
|
19
|
+
pingPongScoreKeeper,
|
|
18
20
|
] as unknown as SportsToolEntry<Record<string, string>>[],
|
|
19
21
|
i18n: {
|
|
20
22
|
es: () => import('./i18n/es').then((m) => m.content),
|
package/src/entries.ts
CHANGED
|
@@ -4,17 +4,23 @@ export { footballScoreKeeper } from './tool/footballScoreKeeper/entry';
|
|
|
4
4
|
export type { FootballScoreKeeperLocaleContent } from './tool/footballScoreKeeper/entry';
|
|
5
5
|
export { gymTracker } from './tool/gymTracker/entry';
|
|
6
6
|
export type { GymTrackerLocaleContent } from './tool/gymTracker/entry';
|
|
7
|
+
export { pingPongScoreKeeper } from './tool/pingPongScoreKeeper/entry';
|
|
8
|
+
export type { PingPongScoreKeeperLocaleContent } from './tool/pingPongScoreKeeper/entry';
|
|
7
9
|
export { reactionTester } from './tool/reactionTester/entry';
|
|
8
10
|
export type { ReactionTesterLocaleContent } from './tool/reactionTester/entry';
|
|
9
11
|
export { scoreKeeper } from './tool/scoreKeeper/entry';
|
|
10
12
|
export type { ScoreKeeperLocaleContent } from './tool/scoreKeeper/entry';
|
|
11
13
|
export { tournamentBracket } from './tool/tournamentBracket/entry';
|
|
12
14
|
export type { TournamentBracketLocaleContent } from './tool/tournamentBracket/entry';
|
|
15
|
+
export { tennisScoreKeeper } from './tool/tennisScoreKeeper/entry';
|
|
16
|
+
export type { TennisScoreKeeperLocaleContent } from './tool/tennisScoreKeeper/entry';
|
|
13
17
|
export { sportsCategory } from './category';
|
|
14
18
|
import { basketScoreKeeper } from './tool/basketScoreKeeper/entry';
|
|
15
19
|
import { footballScoreKeeper } from './tool/footballScoreKeeper/entry';
|
|
16
20
|
import { gymTracker } from './tool/gymTracker/entry';
|
|
21
|
+
import { pingPongScoreKeeper } from './tool/pingPongScoreKeeper/entry';
|
|
17
22
|
import { reactionTester } from './tool/reactionTester/entry';
|
|
18
23
|
import { scoreKeeper } from './tool/scoreKeeper/entry';
|
|
19
24
|
import { tournamentBracket } from './tool/tournamentBracket/entry';
|
|
20
|
-
|
|
25
|
+
import { tennisScoreKeeper } from './tool/tennisScoreKeeper/entry';
|
|
26
|
+
export const ALL_ENTRIES = [basketScoreKeeper, footballScoreKeeper, gymTracker, pingPongScoreKeeper, reactionTester, scoreKeeper, tournamentBracket, tennisScoreKeeper];
|
|
@@ -4,8 +4,8 @@ import { sportsCategory } from '../data';
|
|
|
4
4
|
|
|
5
5
|
describe('Tool Validation Suite', () => {
|
|
6
6
|
describe('Library Registration', () => {
|
|
7
|
-
it('should have
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
7
|
+
it('should have 8 tools in ALL_TOOLS', () => {
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(8);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('sportsCategory should be defined', () => {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'ITTF Table Tennis Rules',
|
|
6
|
+
url: 'https://documents.ittf.sport/sites/default/files/public/2026-02/2026_Statutes_v1_consolidated_clean.pdf',
|
|
7
|
+
},
|
|
8
|
+
];
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { KnownLocale } from '../../types';
|
|
3
|
+
import type { PingPongScoreKeeperUI } from './ui';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
locale?: KnownLocale;
|
|
7
|
+
ui?: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { ui } = Astro.props;
|
|
11
|
+
const t = (ui ?? {}) as PingPongScoreKeeperUI;
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<div class="pp-card" id="pp-card" data-pp-ui={JSON.stringify(t)}>
|
|
15
|
+
<div class="pp-bar">
|
|
16
|
+
<button class="pp-bar-btn" data-pp-fs>
|
|
17
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentcolor" stroke-width="2" stroke-linecap="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
|
|
18
|
+
</button>
|
|
19
|
+
<div class="pp-mode">
|
|
20
|
+
<button class="pp-mode-btn" data-mode-bo1>{t.bo1}</button>
|
|
21
|
+
<button class="pp-mode-btn" data-mode-bo3>{t.bo3}</button>
|
|
22
|
+
<button class="pp-mode-btn pp-mode-active" data-mode-bo5>{t.bo5}</button>
|
|
23
|
+
<button class="pp-mode-btn" data-mode-bo7>{t.bo7}</button>
|
|
24
|
+
</div>
|
|
25
|
+
<button class="pp-bar-btn" data-pp-reset>
|
|
26
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentcolor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
27
|
+
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
|
28
|
+
</svg>
|
|
29
|
+
</button>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div class="pp-board" id="pp-board">
|
|
33
|
+
<div class="pp-side pp-side-a" id="pp-side-a">
|
|
34
|
+
<div id="pp-particles-a" class="pp-particles"></div>
|
|
35
|
+
<svg class="pp-ball" id="pp-ball-a" viewBox="0 0 60 60" fill="none">
|
|
36
|
+
<circle cx="30" cy="30" r="28" fill="currentcolor" opacity="0.12"/>
|
|
37
|
+
<path d="M12 12 Q30 50 48 48" stroke="currentcolor" stroke-width="0.8" opacity="0.08" fill="none"/>
|
|
38
|
+
<path d="M12 48 Q30 10 48 12" stroke="currentcolor" stroke-width="0.8" opacity="0.08" fill="none"/>
|
|
39
|
+
</svg>
|
|
40
|
+
<div class="pp-name-wrap">
|
|
41
|
+
<input type="text" value={t.playerA} class="pp-name" id="pp-name-a" list="pp-names-a" />
|
|
42
|
+
<datalist id="pp-names-a"></datalist>
|
|
43
|
+
<svg class="pp-pencil" viewBox="0 0 24 24" fill="none" stroke="currentcolor" stroke-width="2" stroke-linecap="round"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="pp-dots" id="pp-dots-a"></div>
|
|
46
|
+
<div class="pp-score pp-score-a" id="pp-score-a">0</div>
|
|
47
|
+
<button class="pp-btn-plus pp-btn-plus-a" data-pp-a>+</button>
|
|
48
|
+
<button class="pp-btn-minus pp-btn-minus-a" data-minus-a>−</button>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="pp-side pp-side-b" id="pp-side-b">
|
|
51
|
+
<div id="pp-particles-b" class="pp-particles"></div>
|
|
52
|
+
<svg class="pp-ball" id="pp-ball-b" viewBox="0 0 60 60" fill="none">
|
|
53
|
+
<circle cx="30" cy="30" r="28" fill="currentcolor" opacity="0.12"/>
|
|
54
|
+
<path d="M12 12 Q30 50 48 48" stroke="currentcolor" stroke-width="0.8" opacity="0.08" fill="none"/>
|
|
55
|
+
<path d="M12 48 Q30 10 48 12" stroke="currentcolor" stroke-width="0.8" opacity="0.08" fill="none"/>
|
|
56
|
+
</svg>
|
|
57
|
+
<div class="pp-name-wrap">
|
|
58
|
+
<input type="text" value={t.playerB} class="pp-name" id="pp-name-b" list="pp-names-b" />
|
|
59
|
+
<datalist id="pp-names-b"></datalist>
|
|
60
|
+
<svg class="pp-pencil" viewBox="0 0 24 24" fill="none" stroke="currentcolor" stroke-width="2" stroke-linecap="round"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="pp-dots" id="pp-dots-b"></div>
|
|
63
|
+
<div class="pp-score pp-score-b" id="pp-score-b">0</div>
|
|
64
|
+
<button class="pp-btn-plus pp-btn-plus-b" data-pp-b>+</button>
|
|
65
|
+
<button class="pp-btn-minus pp-btn-minus-b" data-minus-b>−</button>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div class="pp-status" id="pp-status"></div>
|
|
70
|
+
|
|
71
|
+
<div class="pp-actions">
|
|
72
|
+
<button class="pp-action" data-pp-new>{t.newGame}</button>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div class="pp-history" id="pp-history"></div>
|
|
76
|
+
|
|
77
|
+
<div id="pp-swap" class="pp-swap">
|
|
78
|
+
<div class="pp-swap-bg"></div>
|
|
79
|
+
<div class="pp-swap-box" data-pp-swap>
|
|
80
|
+
<div class="pp-swap-icon">
|
|
81
|
+
<svg viewBox="0 0 48 48" fill="none" stroke="currentcolor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="40" height="40">
|
|
82
|
+
<path d="M12 36L6 30M6 30L12 24M6 30H36"/>
|
|
83
|
+
<path d="M36 12L42 18M42 18L36 24M42 18H12"/>
|
|
84
|
+
</svg>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="pp-swap-text">{t.changeSide}</div>
|
|
87
|
+
<div class="pp-swap-sub">{t.swapHint}</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div id="pp-winner" class="pp-winner">
|
|
92
|
+
<div class="pp-winner-bg" data-close-winner></div>
|
|
93
|
+
<div class="pp-winner-box">
|
|
94
|
+
<button class="pp-winner-close" data-close-winner>×</button>
|
|
95
|
+
<div class="pp-winner-cup">
|
|
96
|
+
<svg viewBox="0 0 64 64" fill="currentcolor" width="40" height="40">
|
|
97
|
+
<path d="M8 48l8-32 12 16 4-24 4 24 12-16 8 32z"/>
|
|
98
|
+
<rect x="12" y="48" width="40" height="6" rx="2"/>
|
|
99
|
+
</svg>
|
|
100
|
+
</div>
|
|
101
|
+
<div class="pp-winner-label">{t.winnerLabel}</div>
|
|
102
|
+
<div id="pp-winner-team" class="pp-winner-team">Player 1</div>
|
|
103
|
+
<div id="pp-winner-score" class="pp-winner-score">0 − 0</div>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div id="pp-confetti" class="pp-confetti"></div>
|
|
108
|
+
|
|
109
|
+
<div id="pp-modal" class="pp-modal">
|
|
110
|
+
<div class="pp-modal-bg"></div>
|
|
111
|
+
<div class="pp-modal-box">
|
|
112
|
+
<p class="pp-modal-text">{t.resetConfirm}</p>
|
|
113
|
+
<div class="pp-modal-btns">
|
|
114
|
+
<button id="pp-modal-cancel" class="pp-modal-btn pp-modal-btn-cancel">{t.cancel}</button>
|
|
115
|
+
<button id="pp-modal-confirm" class="pp-modal-btn pp-modal-btn-reset">{t.reset}</button>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<script>
|
|
122
|
+
import { initPingPongScoreKeeper } from './logic';
|
|
123
|
+
initPingPongScoreKeeper();
|
|
124
|
+
</script>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { SportsToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
|
|
3
|
+
import type { PingPongScoreKeeperUI } from './ui';
|
|
4
|
+
|
|
5
|
+
export type { PingPongScoreKeeperUI };
|
|
6
|
+
export type PingPongScoreKeeperLocaleContent = ToolLocaleContent<PingPongScoreKeeperUI>;
|
|
7
|
+
|
|
8
|
+
export const pingPongScoreKeeper: SportsToolEntry<PingPongScoreKeeperUI> = {
|
|
9
|
+
id: 'ping-pong-scorekeeper',
|
|
10
|
+
icons: {
|
|
11
|
+
bg: 'mdi:table-tennis',
|
|
12
|
+
fg: 'mdi:scoreboard-outline',
|
|
13
|
+
},
|
|
14
|
+
i18n: {
|
|
15
|
+
en: () => import('./i18n/en').then((m) => m.content),
|
|
16
|
+
es: () => import('./i18n/es').then((m) => m.content),
|
|
17
|
+
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
18
|
+
de: () => import('./i18n/de').then((m) => m.content),
|
|
19
|
+
id: () => import('./i18n/id').then((m) => m.content),
|
|
20
|
+
it: () => import('./i18n/it').then((m) => m.content),
|
|
21
|
+
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
22
|
+
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
23
|
+
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
24
|
+
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
25
|
+
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
26
|
+
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
27
|
+
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
28
|
+
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
29
|
+
zh: () => import('./i18n/zh').then((m) => m.content),
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
export const POINTS_TO_WIN_GAME = 11;
|
|
2
|
+
export const MIN_POINT_LEAD = 2;
|
|
3
|
+
|
|
4
|
+
export type PlayerSide = 'a' | 'b';
|
|
5
|
+
export type MatchFormat = 'bo1' | 'bo3' | 'bo5' | 'bo7';
|
|
6
|
+
|
|
7
|
+
export interface MatchScore {
|
|
8
|
+
currentGamePointsA: number;
|
|
9
|
+
currentGamePointsB: number;
|
|
10
|
+
gamesWonByA: number;
|
|
11
|
+
gamesWonByB: number;
|
|
12
|
+
servingPlayer: PlayerSide;
|
|
13
|
+
servesSinceLastChange: number;
|
|
14
|
+
format: MatchFormat;
|
|
15
|
+
areSidesSwapped: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createInitialScore(): MatchScore {
|
|
19
|
+
return {
|
|
20
|
+
currentGamePointsA: 0,
|
|
21
|
+
currentGamePointsB: 0,
|
|
22
|
+
gamesWonByA: 0,
|
|
23
|
+
gamesWonByB: 0,
|
|
24
|
+
servingPlayer: 'a',
|
|
25
|
+
servesSinceLastChange: 0,
|
|
26
|
+
format: 'bo5',
|
|
27
|
+
areSidesSwapped: false,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function gamesNeededForMatchWin(format: MatchFormat): number {
|
|
32
|
+
if (format === 'bo1') return 1;
|
|
33
|
+
if (format === 'bo3') return 2;
|
|
34
|
+
if (format === 'bo5') return 3;
|
|
35
|
+
return 4;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function checkGameOver(score: MatchScore): PlayerSide | null {
|
|
39
|
+
if (score.currentGamePointsA >= POINTS_TO_WIN_GAME && score.currentGamePointsA - score.currentGamePointsB >= MIN_POINT_LEAD) return 'a';
|
|
40
|
+
if (score.currentGamePointsB >= POINTS_TO_WIN_GAME && score.currentGamePointsB - score.currentGamePointsA >= MIN_POINT_LEAD) return 'b';
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function checkMatchOver(score: MatchScore): PlayerSide | null {
|
|
45
|
+
const need = gamesNeededForMatchWin(score.format);
|
|
46
|
+
if (score.gamesWonByA >= need) return 'a';
|
|
47
|
+
if (score.gamesWonByB >= need) return 'b';
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function checkGamePointOpportunity(score: MatchScore): PlayerSide | null {
|
|
52
|
+
if (score.currentGamePointsA >= POINTS_TO_WIN_GAME - 1 && score.currentGamePointsA > score.currentGamePointsB) return 'a';
|
|
53
|
+
if (score.currentGamePointsB >= POINTS_TO_WIN_GAME - 1 && score.currentGamePointsB > score.currentGamePointsA) return 'b';
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isOneGameAway(leader: number, trailing: number, need: number): boolean {
|
|
58
|
+
return leader >= need - 1 && leader > trailing;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isAtGamePoint(leaderPoints: number, trailingPoints: number): boolean {
|
|
62
|
+
return leaderPoints >= POINTS_TO_WIN_GAME - 1 && leaderPoints > trailingPoints;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function checkMatchPointOpportunity(score: MatchScore): PlayerSide | null {
|
|
66
|
+
const need = gamesNeededForMatchWin(score.format);
|
|
67
|
+
if (isOneGameAway(score.gamesWonByA, score.gamesWonByB, need) && isAtGamePoint(score.currentGamePointsA, score.currentGamePointsB)) return 'a';
|
|
68
|
+
if (isOneGameAway(score.gamesWonByB, score.gamesWonByA, need) && isAtGamePoint(score.currentGamePointsB, score.currentGamePointsA)) return 'b';
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function awardPointToPlayer(score: MatchScore, side: PlayerSide): MatchScore {
|
|
73
|
+
if (checkMatchOver(score)) return score;
|
|
74
|
+
const next = { ...score };
|
|
75
|
+
if (side === 'a') next.currentGamePointsA += 1;
|
|
76
|
+
else next.currentGamePointsB += 1;
|
|
77
|
+
next.servesSinceLastChange += 1;
|
|
78
|
+
if (next.servesSinceLastChange >= 2) {
|
|
79
|
+
next.servingPlayer = next.servingPlayer === 'a' ? 'b' : 'a';
|
|
80
|
+
next.servesSinceLastChange = 0;
|
|
81
|
+
}
|
|
82
|
+
return next;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function undoLastPoint(score: MatchScore, side: PlayerSide): MatchScore {
|
|
86
|
+
if (checkMatchOver(score)) return score;
|
|
87
|
+
const next = { ...score };
|
|
88
|
+
if (side === 'a' && next.currentGamePointsA > 0) next.currentGamePointsA -= 1;
|
|
89
|
+
else if (side === 'b' && next.currentGamePointsB > 0) next.currentGamePointsB -= 1;
|
|
90
|
+
else return score;
|
|
91
|
+
if (next.servesSinceLastChange > 0) next.servesSinceLastChange -= 1;
|
|
92
|
+
return next;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function concludeGame(score: MatchScore, gameWinner: PlayerSide): MatchScore {
|
|
96
|
+
const next = { ...score };
|
|
97
|
+
if (gameWinner === 'a') next.gamesWonByA += 1;
|
|
98
|
+
else next.gamesWonByB += 1;
|
|
99
|
+
next.currentGamePointsA = 0;
|
|
100
|
+
next.currentGamePointsB = 0;
|
|
101
|
+
next.servesSinceLastChange = 0;
|
|
102
|
+
next.servingPlayer = next.servingPlayer === 'a' ? 'b' : 'a';
|
|
103
|
+
return next;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function swapPlayerSides(score: MatchScore): MatchScore {
|
|
107
|
+
return {
|
|
108
|
+
...score,
|
|
109
|
+
gamesWonByA: score.gamesWonByB,
|
|
110
|
+
gamesWonByB: score.gamesWonByA,
|
|
111
|
+
currentGamePointsA: 0,
|
|
112
|
+
currentGamePointsB: 0,
|
|
113
|
+
servesSinceLastChange: 0,
|
|
114
|
+
areSidesSwapped: !score.areSidesSwapped,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function createCleanMatch(score: MatchScore): MatchScore {
|
|
119
|
+
return { ...createInitialScore(), format: score.format };
|
|
120
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
2
|
+
import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
|
|
3
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
4
|
+
import type { PingPongScoreKeeperUI } from '../ui';
|
|
5
|
+
|
|
6
|
+
const slug = 'tischtennis-punktestand';
|
|
7
|
+
const title = 'Tischtennis Punktestand Online : Kostenloser Table Tennis Tracker';
|
|
8
|
+
const description =
|
|
9
|
+
'Verfolge Tischtennis-Matches mit Spiel- und Satzpunktzählung. Kostenloser Online-Punktestand für Trainingsspiele und Turniere. Keine Anmeldung nötig.';
|
|
10
|
+
|
|
11
|
+
const faqData = [
|
|
12
|
+
{
|
|
13
|
+
question: 'Wie funktioniert die Zählweise beim Tischtennis?',
|
|
14
|
+
answer:
|
|
15
|
+
'Ein normales Tischtennisspiel wird bis 11 Punkte gespielt. Du musst mit 2 Punkten Vorsprung gewinnen. Bei 10:10 wird weitergespielt, bis jemand mit 2 Punkten führt. Der Aufschlag wechselt alle 2 Punkte. Dieser Punktestand verfolgt all das automatisch.',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
question: 'Wie benutze ich diesen Punktestand?',
|
|
19
|
+
answer:
|
|
20
|
+
'Drücke die +-Taste unter jedem Spieler, um einen Punkt zu vergeben. Der Spielstand aktualisiert sich automatisch. Wenn ein Spieler 11 Punkte mit 2 Punkten Vorsprung erreicht, endet das Spiel und ein neues beginnt. Der Spielstandsanzeiger zeigt, wie viele Spiele jeder Spieler gewonnen hat. Drücke Spiel beenden, wenn das Match vorbei ist.',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
question: 'Wie funktioniert die Aufschlag-Anzeige?',
|
|
24
|
+
answer:
|
|
25
|
+
'Der Aufschlag wechselt alle 2 Punkte. Ein Punkt erscheint neben dem Spieler, der aufschlägt. Dies folgt den offiziellen Tischtennisregeln. Du kannst jederzeit im Match sehen, wer aufschlagen müsste.',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
question: 'Kann ich es während eines Spiels auf dem Handy benutzen?',
|
|
29
|
+
answer:
|
|
30
|
+
'Ja. Die Benutzeroberfläche ist mobilfreundlich mit großen Tasten. Der Vollbildmodus blendet den Browser aus und hält den Bildschirm wach.',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
question: 'Speichert es meine Spieldaten?',
|
|
34
|
+
answer:
|
|
35
|
+
'Ja. Der aktuelle Spielstand, gewonnene Spiele und Spielernamen werden automatisch im Browser gespeichert.',
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const howToData = [
|
|
40
|
+
{
|
|
41
|
+
name: 'Namen eingeben',
|
|
42
|
+
text: 'Tippe auf den Standard-Spielernamen und gib deinen eigenen ein. Namen werden automatisch gespeichert.',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'Punkt vergeben',
|
|
46
|
+
text: 'Drücke den großen runden +-Knopf für den Spieler, der gepunktet hat. Der Stand aktualisiert sich mit einer Jubel-Animation.',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'Punkt zurücknehmen',
|
|
50
|
+
text: 'Drücke den Minus-Knopf, falls du versehentlich einen Punkt vergeben hast.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'Neues Spiel starten',
|
|
54
|
+
text: 'Wenn ein Spiel endet, drücke Neues Spiel, um das nächste zu beginnen. Oder drücke Spiel beenden, um das Match zu beenden.',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'Match beenden',
|
|
58
|
+
text: 'Drücke Spiel beenden, um den Gewinner mit Trophäe und Konfetti zu feiern.',
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const faqSchema: WithContext<FAQPage> = {
|
|
63
|
+
'@context': 'https://schema.org',
|
|
64
|
+
'@type': 'FAQPage',
|
|
65
|
+
mainEntity: faqData.map((item) => ({
|
|
66
|
+
'@type': 'Question',
|
|
67
|
+
name: item.question,
|
|
68
|
+
acceptedAnswer: { '@type': 'Answer', text: item.answer },
|
|
69
|
+
})),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const howToSchema: WithContext<HowTo> = {
|
|
73
|
+
'@context': 'https://schema.org',
|
|
74
|
+
'@type': 'HowTo',
|
|
75
|
+
name: title,
|
|
76
|
+
description,
|
|
77
|
+
step: howToData.map((step, i) => ({
|
|
78
|
+
'@type': 'HowToStep',
|
|
79
|
+
position: i + 1,
|
|
80
|
+
name: step.name,
|
|
81
|
+
text: step.text,
|
|
82
|
+
})),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const appSchema: WithContext<SoftwareApplication> = {
|
|
86
|
+
'@context': 'https://schema.org',
|
|
87
|
+
'@type': 'SoftwareApplication',
|
|
88
|
+
name: title,
|
|
89
|
+
description,
|
|
90
|
+
applicationCategory: 'SportsApplication',
|
|
91
|
+
operatingSystem: 'All',
|
|
92
|
+
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
|
|
93
|
+
inLanguage: 'de',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const content: ToolLocaleContent<PingPongScoreKeeperUI> = {
|
|
97
|
+
slug,
|
|
98
|
+
title,
|
|
99
|
+
description,
|
|
100
|
+
faq: faqData,
|
|
101
|
+
bibliography,
|
|
102
|
+
howTo: howToData,
|
|
103
|
+
schemas: [faqSchema, howToSchema, appSchema],
|
|
104
|
+
seo: [
|
|
105
|
+
{
|
|
106
|
+
type: 'title',
|
|
107
|
+
text: 'Kostenloser Online-Tischtennis-Punktestand : Match-Tracker',
|
|
108
|
+
level: 2,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
type: 'paragraph',
|
|
112
|
+
html: 'Die Zählweise beim Tischtennis sollte einfach sein, aber die Regeln können verwirrend sein. Wer schlägt als Nächstes auf? Steht es 10:10 oder 11:9? Wie viele Spiele hat jeder Spieler gewonnen? Dieser kostenlose Online-Punktestand erledigt all das automatisch. Du drückst einfach auf +, wenn jemand punktet. Der Punktestand verfolgt Punkte pro Spiel, gewonnene Spiele und den Aufschlag. Alles aktualisiert sich in Echtzeit mit Jubel-Animationen, die jeden Punkt zählen lassen. Keine Anmeldung, kein Download, keine komplizierten Menüs.',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
type: 'title',
|
|
116
|
+
text: 'Wie die Zählweise in diesem Punktestand funktioniert',
|
|
117
|
+
level: 2,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
type: 'paragraph',
|
|
121
|
+
html: 'Tischtennis folgt einem standardisierten Zählsystem. Jedes Spiel geht bis 11 Punkte. Ein Spieler muss mit 2 Punkten Vorsprung gewinnen. Bei 10:10 wird weitergespielt, bis jemand mit 2 Punkten führt. Der Aufschlag wechselt alle 2 Punkte während eines Spiels. Dieser Punktestand verfolgt all diese Regeln automatisch. Du musst dir nicht merken, wer aufschlägt oder wann gewechselt wird. Die Anzeige zeigt einen Punkt neben dem aktuellen Aufschläger. Wenn ein Spieler ein Spiel gewinnt, wechselt der Punktestand automatisch zum nächsten Spiel. Der Zähler für gewonnene Spiele erhöht sich für den Gewinner. Ein Match kann beliebig viele Spiele umfassen, ist aber typischerweise Best of 5 oder 7. Drücke Spiel beenden, wenn das Match vorbei ist, und der Gewinner wird mit einer Feier bekannt gegeben.',
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
type: 'comparative',
|
|
125
|
+
columns: 3,
|
|
126
|
+
items: [
|
|
127
|
+
{
|
|
128
|
+
title: 'Trainingsspiele',
|
|
129
|
+
description: 'Schnelle und einfache Zählweise für lockere Spiele mit Freunden. Automatische Spiel- und Match-Verfolgung.',
|
|
130
|
+
icon: 'mdi:table-tennis',
|
|
131
|
+
points: ['Ein Klick pro Punkt', 'Automatische Aufschlag-Verfolgung', 'Funktioniert offline'],
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
title: 'Verein & Liga',
|
|
135
|
+
description: 'Führe saubere Aufzeichnungen über Spiele und Ergebnisse. Perfekt für Vereinsturniere und Ligaspiele.',
|
|
136
|
+
icon: 'mdi:trophy-outline',
|
|
137
|
+
points: ['Gewonnene Spiele verfolgen', 'Best of 5 oder 7 Modus', 'Mobilfreundlich'],
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
title: 'Turnierbetrieb',
|
|
141
|
+
description: 'Verfolge mehrere Partien im Turniermodus. Schneller Reset zwischen den Matches.',
|
|
142
|
+
icon: 'mdi:school',
|
|
143
|
+
points: ['Schneller Match-Reset', 'Stand speichert', 'Vollbildmodus'],
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
type: 'title',
|
|
149
|
+
text: 'Was diesen Tischtennis-Punktestand besonders macht',
|
|
150
|
+
level: 2,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
type: 'list',
|
|
154
|
+
items: [
|
|
155
|
+
'<strong>Automatische Spielzählung</strong> der Punktestand kennt die Regeln des Tischtennis. Spiele bis 11, Sieg mit 2 Punkten Vorsprung, automatische Aufschlagwechsel.',
|
|
156
|
+
'<strong>Gewonnene Spiele verfolgen</strong> jedes gewonnene Spiel wird aufgezeichnet. Sieh auf einen Blick, wie viele Spiele jeder Spieler im Match gewonnen hat.',
|
|
157
|
+
'<strong>Aufschlag-Anzeige</strong> ein sichtbarer Punkt zeigt, welcher Spieler aufschlägt, nach der 2-Punkte-Wechselregel.',
|
|
158
|
+
'<strong>Jubel-Animationen</strong> jeder Punkt löst eine zufällige Jubel-Animation aus. Acht verschiedene Effekte halten jeden Punkt spannend.',
|
|
159
|
+
'<strong>Schwebende Partikel</strong> jeder erzielte Punkt erzeugt schwebenden Text, der den Moment feiert.',
|
|
160
|
+
'<strong>Match-Abschluss-Zeremonie</strong> drücke Spiel beenden, um eine Gewinner-Bekanntgabe mit Trophäe und Konfetti auszulösen.',
|
|
161
|
+
'<strong>Bearbeitbare Spielernamen</strong> tippe auf das Namensfeld, um Spieler umzubenennen. Namen werden im Browser gespeichert.',
|
|
162
|
+
'<strong>Vollbildmodus</strong> blendet die Browser-Oberfläche aus, sodass die Anzeige den Bildschirm füllt und wach bleibt.',
|
|
163
|
+
'<strong>Offline zuerst</strong> funktioniert ohne Internet. Keine Werbung, kein Tracking, keine Datensammlung.',
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
type: 'title',
|
|
168
|
+
text: 'Punktestand vs. Manuelle Zählweise',
|
|
169
|
+
level: 2,
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
type: 'paragraph',
|
|
173
|
+
html: 'Bei der manuellen Zählweise im Tischtennis muss man den Punktestand verfolgen, sich merken, wer aufschlägt, wissen, wann der Aufschlag wechselt, und die gewonnenen Spiele zählen. Besonders in einem schnellen Spiel verliert man leicht den Überblick. Dieser digitale Punktestand erledigt alles automatisch. Du musst nur einen Knopf drücken, wenn ein Punkt erzielt wird. Der Punktestand verfolgt das Spielergebnis, erkennt, wann ein Spiel gewonnen wird, zeichnet gewonnene Spiele auf und zeigt an, wer aufschlägt. Jeder Punkt wird mit Animationen und Partikeln gefeiert. Der Stand gerät nie durcheinander und du verpasst nie einen Aufschlagwechsel. Ob du nun ein lockeres Spiel mit Freunden spielst oder an einem Turnier teilnimmst, dieser kostenlose Online-Tischtennis-Punktestand bietet alles, was du brauchst.',
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
ui: {
|
|
177
|
+
playerA: 'Spieler 1',
|
|
178
|
+
playerB: 'Spieler 2',
|
|
179
|
+
winnerLabel: 'SIEGER',
|
|
180
|
+
finishMatch: 'Spiel beenden',
|
|
181
|
+
newGame: 'Neues Spiel',
|
|
182
|
+
serving: 'Aufschlag',
|
|
183
|
+
changeSide: 'Seiten wechseln',
|
|
184
|
+
swapHint: 'Zum Wechseln tippen',
|
|
185
|
+
game: 'Spiel',
|
|
186
|
+
set: 'Satz',
|
|
187
|
+
gamePoint: 'Spielpunkt',
|
|
188
|
+
matchPoint: 'Matchpunkt',
|
|
189
|
+
mode: 'Format',
|
|
190
|
+
bo1: 'BO1',
|
|
191
|
+
bo3: 'BO3',
|
|
192
|
+
bo5: 'BO5',
|
|
193
|
+
bo7: 'BO7',
|
|
194
|
+
points: 'Punkte',
|
|
195
|
+
reset: 'Zurücksetzen',
|
|
196
|
+
resetConfirm: 'Match zurücksetzen? Alle Daten gehen verloren.',
|
|
197
|
+
cancel: 'Abbrechen',
|
|
198
|
+
fullscreen: 'Vollbild',
|
|
199
|
+
exitFullscreen: 'Vollbild beenden',
|
|
200
|
+
},
|
|
201
|
+
};
|