@jjlmoya/utils-sports 1.22.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/entries.ts +4 -1
  3. package/src/tests/locale_completeness.test.ts +1 -1
  4. package/src/tests/tool_validation.test.ts +2 -2
  5. package/src/tool/streetballScoreKeeper/audio.ts +64 -0
  6. package/src/tool/streetballScoreKeeper/bibliography.astro +15 -0
  7. package/src/tool/streetballScoreKeeper/bibliography.ts +10 -0
  8. package/src/tool/streetballScoreKeeper/component.astro +163 -0
  9. package/src/tool/streetballScoreKeeper/entry.ts +30 -0
  10. package/src/tool/streetballScoreKeeper/game-logic.ts +165 -0
  11. package/src/tool/streetballScoreKeeper/i18n/de.ts +178 -0
  12. package/src/tool/streetballScoreKeeper/i18n/en.ts +178 -0
  13. package/src/tool/streetballScoreKeeper/i18n/es.ts +178 -0
  14. package/src/tool/streetballScoreKeeper/i18n/fr.ts +178 -0
  15. package/src/tool/streetballScoreKeeper/i18n/id.ts +178 -0
  16. package/src/tool/streetballScoreKeeper/i18n/it.ts +178 -0
  17. package/src/tool/streetballScoreKeeper/i18n/ja.ts +178 -0
  18. package/src/tool/streetballScoreKeeper/i18n/ko.ts +178 -0
  19. package/src/tool/streetballScoreKeeper/i18n/nl.ts +178 -0
  20. package/src/tool/streetballScoreKeeper/i18n/pl.ts +178 -0
  21. package/src/tool/streetballScoreKeeper/i18n/pt.ts +178 -0
  22. package/src/tool/streetballScoreKeeper/i18n/ru.ts +178 -0
  23. package/src/tool/streetballScoreKeeper/i18n/sv.ts +178 -0
  24. package/src/tool/streetballScoreKeeper/i18n/tr.ts +178 -0
  25. package/src/tool/streetballScoreKeeper/i18n/zh.ts +178 -0
  26. package/src/tool/streetballScoreKeeper/index.ts +9 -0
  27. package/src/tool/streetballScoreKeeper/logic.ts +269 -0
  28. package/src/tool/streetballScoreKeeper/particles.ts +34 -0
  29. package/src/tool/streetballScoreKeeper/render.ts +226 -0
  30. package/src/tool/streetballScoreKeeper/seo.astro +4 -0
  31. package/src/tool/streetballScoreKeeper/streetball-3x3-basketball-scorekeeper.css +1127 -0
  32. package/src/tool/streetballScoreKeeper/ui.ts +25 -0
  33. package/src/tools.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-sports",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
package/src/entries.ts CHANGED
@@ -18,6 +18,8 @@ export { dartsScoreKeeper } from './tool/dartsScoreKeeper/entry';
18
18
  export type { DartsScoreKeeperLocaleContent } from './tool/dartsScoreKeeper/entry';
19
19
  export { padelScoreKeeper } from './tool/padelScoreKeeper/entry';
20
20
  export type { PadelScoreKeeperLocaleContent } from './tool/padelScoreKeeper/entry';
21
+ export { streetballScoreKeeper } from './tool/streetballScoreKeeper/entry';
22
+ export type { StreetballLocaleContent } from './tool/streetballScoreKeeper/entry';
21
23
  export { sportsCategory } from './category';
22
24
  import { basketScoreKeeper } from './tool/basketScoreKeeper/entry';
23
25
  import { footballScoreKeeper } from './tool/footballScoreKeeper/entry';
@@ -29,4 +31,5 @@ import { tournamentBracket } from './tool/tournamentBracket/entry';
29
31
  import { tennisScoreKeeper } from './tool/tennisScoreKeeper/entry';
30
32
  import { dartsScoreKeeper } from './tool/dartsScoreKeeper/entry';
31
33
  import { padelScoreKeeper } from './tool/padelScoreKeeper/entry';
32
- export const ALL_ENTRIES = [basketScoreKeeper, footballScoreKeeper, gymTracker, pingPongScoreKeeper, reactionTester, scoreKeeper, tournamentBracket, tennisScoreKeeper, dartsScoreKeeper, padelScoreKeeper];
34
+ import { streetballScoreKeeper } from './tool/streetballScoreKeeper/entry';
35
+ export const ALL_ENTRIES = [basketScoreKeeper, footballScoreKeeper, gymTracker, pingPongScoreKeeper, reactionTester, scoreKeeper, tournamentBracket, tennisScoreKeeper, dartsScoreKeeper, padelScoreKeeper, streetballScoreKeeper];
@@ -3,6 +3,6 @@ import { ALL_TOOLS } from '../tools';
3
3
 
4
4
  describe('Locale Completeness Validation', () => {
5
5
  it('all tools registered', () => {
6
- expect(ALL_TOOLS.length).toBe(10);
6
+ expect(ALL_TOOLS.length).toBe(11);
7
7
  });
8
8
  });
@@ -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 10 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(10);
7
+ it('should have 11 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(11);
9
9
  });
10
10
 
11
11
  it('sportsCategory should be defined', () => {
@@ -0,0 +1,64 @@
1
+ import { el } from './render';
2
+
3
+ let isMuted = false;
4
+
5
+ try {
6
+ isMuted = localStorage.getItem('sb_muted') === 'true';
7
+ } catch {}
8
+
9
+ function updateSoundIcon(): void {
10
+ const onIcon = el('tn-sound-icon-on');
11
+ const offIcon = el('tn-sound-icon-off');
12
+ if (onIcon && offIcon) {
13
+ onIcon.style.display = isMuted ? 'none' : 'block';
14
+ offIcon.style.display = isMuted ? 'block' : 'none';
15
+ }
16
+ }
17
+
18
+ export function setMuted(muted: boolean): void {
19
+ isMuted = muted;
20
+ try {
21
+ localStorage.setItem('sb_muted', String(muted));
22
+ } catch {}
23
+ updateSoundIcon();
24
+ }
25
+
26
+ export function getMuted(): boolean {
27
+ return isMuted;
28
+ }
29
+
30
+ export function playBeep(): void {
31
+ if (isMuted) return;
32
+ try {
33
+ const webkitCtx = (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
34
+ const ctx = new (window.AudioContext || webkitCtx)();
35
+ const osc = ctx.createOscillator();
36
+ const gain = ctx.createGain();
37
+ osc.type = 'sine';
38
+ osc.frequency.setValueAtTime(800, ctx.currentTime);
39
+ osc.connect(gain);
40
+ gain.connect(ctx.destination);
41
+ gain.gain.setValueAtTime(0.25, ctx.currentTime);
42
+ gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15);
43
+ osc.start();
44
+ osc.stop(ctx.currentTime + 0.15);
45
+ } catch {}
46
+ }
47
+
48
+ export function playBuzzer(): void {
49
+ if (isMuted) return;
50
+ try {
51
+ const webkitCtx = (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
52
+ const ctx = new (window.AudioContext || webkitCtx)();
53
+ const osc = ctx.createOscillator();
54
+ const gain = ctx.createGain();
55
+ osc.type = 'sawtooth';
56
+ osc.frequency.setValueAtTime(120, ctx.currentTime);
57
+ osc.connect(gain);
58
+ gain.connect(ctx.destination);
59
+ gain.gain.setValueAtTime(0.5, ctx.currentTime);
60
+ gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 1.2);
61
+ osc.start();
62
+ osc.stop(ctx.currentTime + 1.2);
63
+ } catch {}
64
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ import { bibliography } from './bibliography';
3
+ ---
4
+
5
+ <ul class="tn-bib-list">
6
+ {
7
+ bibliography.map((entry) => (
8
+ <li>
9
+ <a href={entry.url} target="_blank" rel="noopener noreferrer">
10
+ {entry.name}
11
+ </a>
12
+ </li>
13
+ ))
14
+ }
15
+ </ul>
@@ -0,0 +1,10 @@
1
+ export const bibliography = [
2
+ {
3
+ name: 'FIBA 3x3 Basketball Rules of the Game',
4
+ url: 'https://fiba3x3.com/en/rules.html',
5
+ },
6
+ {
7
+ name: 'Official 3x3 Basketball Equipment Guide',
8
+ url: 'https://www.fiba.basketball/documents',
9
+ },
10
+ ];
@@ -0,0 +1,163 @@
1
+ ---
2
+ import type { KnownLocale } from '../../types';
3
+ import type { StreetballScoreKeeperUI } 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 StreetballScoreKeeperUI;
12
+ ---
13
+
14
+ <div class="tn-console" id="tn-card" data-tn-ui={JSON.stringify(t)}>
15
+ <div class="tn-header-panel">
16
+ <div class="tn-header-actions">
17
+ <button class="tn-action-btn" id="tn-sound-btn" title={t.toggleSound}>
18
+ <svg id="tn-sound-icon-on" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
19
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
20
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/>
21
+ </svg>
22
+ <svg id="tn-sound-icon-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: none;">
23
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
24
+ <line x1="23" y1="9" x2="17" y2="15"/>
25
+ <line x1="17" y1="9" x2="23" y2="15"/>
26
+ </svg>
27
+ </button>
28
+ <button class="tn-action-btn" id="tn-reset-btn" title={t.reset}>
29
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
30
+ <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
31
+ <path d="M16 3h5v5M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
32
+ <path d="M8 21H3v-5"/>
33
+ </svg>
34
+ </button>
35
+ <button class="tn-action-btn" data-tn-fs title={t.fullscreen}>
36
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
37
+ <path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
38
+ </svg>
39
+ </button>
40
+ </div>
41
+ </div>
42
+
43
+ <div class="tn-clear-ball-alert" id="tn-clear-ball-alert">
44
+ {t.clearBall}
45
+ </div>
46
+
47
+ <div class="tn-scoreboard">
48
+ <div class="tn-player-card tn-player-card-a">
49
+ <div class="tn-name-section">
50
+ <input type="text" value={t.teamA} class="tn-player-input" id="tn-name-a" />
51
+ <span class="tn-poss-indicator" id="tn-poss-a"></span>
52
+ </div>
53
+ <div class="tn-big-score" id="tn-score-a">0</div>
54
+ <div class="tn-team-actions">
55
+ <button class="tn-team-btn" data-point-team="a" data-points="1">+1</button>
56
+ <button class="tn-team-btn" data-point-team="a" data-points="2">+2</button>
57
+ </div>
58
+ <div class="tn-stats-row">
59
+ <button class="tn-stat-box" data-foul-team="a" title={t.fouls}>
60
+ <span class="tn-stat-val" id="tn-fouls-a">0</span>
61
+ <span class="tn-stat-lbl">{t.fouls}</span>
62
+ </button>
63
+ <button class="tn-stat-box" data-timeout-team="a" title={t.timeouts}>
64
+ <span class="tn-stat-val" id="tn-timeouts-a">1</span>
65
+ <span class="tn-stat-lbl">{t.timeouts}</span>
66
+ </button>
67
+ </div>
68
+ </div>
69
+
70
+ <div class="tn-player-card tn-player-card-b">
71
+ <div class="tn-name-section">
72
+ <input type="text" value={t.teamB} class="tn-player-input" id="tn-name-b" />
73
+ <span class="tn-poss-indicator" id="tn-poss-b"></span>
74
+ </div>
75
+ <div class="tn-big-score" id="tn-score-b">0</div>
76
+ <div class="tn-team-actions">
77
+ <button class="tn-team-btn" data-point-team="b" data-points="1">+1</button>
78
+ <button class="tn-team-btn" data-point-team="b" data-points="2">+2</button>
79
+ </div>
80
+ <div class="tn-stats-row">
81
+ <button class="tn-stat-box" data-foul-team="b" title={t.fouls}>
82
+ <span class="tn-stat-val" id="tn-fouls-b">0</span>
83
+ <span class="tn-stat-lbl">{t.fouls}</span>
84
+ </button>
85
+ <button class="tn-stat-box" data-timeout-team="b" title={t.timeouts}>
86
+ <span class="tn-stat-val" id="tn-timeouts-b">1</span>
87
+ <span class="tn-stat-lbl">{t.timeouts}</span>
88
+ </button>
89
+ </div>
90
+ </div>
91
+ </div>
92
+
93
+ <div class="tn-clocks-panel">
94
+ <div class="tn-clock-container">
95
+ <div class="tn-clock-box tn-clock-box-game" id="tn-btn-start-clock">
96
+ <div class="tn-clock-lbl">{t.gameTime}</div>
97
+ <div class="tn-clock-digits tn-clock-digits-game" id="tn-game-clock">10:00</div>
98
+ </div>
99
+ <div class="tn-clock-box tn-clock-box-shot" id="tn-shot-clock-box">
100
+ <div class="tn-clock-lbl">{t.shotClock}</div>
101
+ <div class="tn-clock-digits tn-clock-digits-shot" id="tn-shot-clock">12</div>
102
+ </div>
103
+ </div>
104
+ <div class="tn-clock-controls">
105
+ <button class="tn-control-btn" id="tn-btn-reset-14">+14s</button>
106
+ <button class="tn-control-btn" id="tn-poss-toggle">{t.possession}</button>
107
+ <button class="tn-control-btn" id="tn-undo-btn">Undo</button>
108
+ </div>
109
+ </div>
110
+
111
+ <div class="tn-court-panel">
112
+ <div class="tn-court-wrapper">
113
+ <svg class="tn-street-court" viewBox="0 0 240 200" id="tn-court">
114
+ <rect class="tn-court-arc-outer" x="0" y="0" width="240" height="200" />
115
+ <path class="tn-court-arc-inner tn-court-quadrant" d="M 10 20 A 110 110 0 0 0 230 20 L 230 200 L 10 200 Z" />
116
+ <line class="tn-board" x1="100" y1="12" x2="140" y2="12" />
117
+ <circle class="tn-rim" cx="120" cy="18" r="6" />
118
+ <rect class="tn-key-lines" x="96" y="20" width="48" height="60" />
119
+ <text x="120" y="60" class="tn-court-text tn-court-text-inner">+1</text>
120
+ <text x="40" y="160" class="tn-court-text tn-court-text-outer">+2</text>
121
+ <text x="120" y="160" class="tn-court-text tn-court-text-outer">+2</text>
122
+ <text x="200" y="160" class="tn-court-text tn-court-text-outer">+2</text>
123
+ </svg>
124
+ </div>
125
+ </div>
126
+
127
+ <div id="tn-timeout-overlay" class="tn-timeout-overlay">
128
+ <div class="tn-timeout-bg-glow"></div>
129
+ <div class="tn-timeout-content">
130
+ <div class="tn-timeout-title">{t.timeoutActive}</div>
131
+ <div id="tn-timeout-timer" class="tn-timeout-digits">30.0</div>
132
+ <div class="tn-timeout-subtitle">Click to Resume</div>
133
+ </div>
134
+ </div>
135
+
136
+ <div id="tn-winner" class="tn-winner-overlay">
137
+ <div class="tn-winner-box">
138
+ <svg class="tn-winner-cup" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
139
+ <path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6m12 5h1.5a2.5 2.5 0 0 0 0-5H18M6 4h12M12 18v3m-4 0h8m-4-17v14a4 4 0 0 1-4-4"/>
140
+ </svg>
141
+ <div class="tn-winner-title">{t.matchWon}</div>
142
+ <div id="tn-winner-team" class="tn-winner-team">Team 1</div>
143
+ <button class="tn-control-btn" data-close-winner>Reset</button>
144
+ </div>
145
+ </div>
146
+
147
+ <div id="tn-particle-container" class="tn-particle-container"></div>
148
+
149
+ <div id="tn-modal" class="tn-modal">
150
+ <div class="tn-modal-box">
151
+ <p class="tn-modal-text">{t.resetConfirm}</p>
152
+ <div class="tn-modal-btns">
153
+ <button id="tn-modal-cancel" class="tn-modal-btn tn-btn-cancel">{t.cancel}</button>
154
+ <button id="tn-modal-confirm" class="tn-modal-btn tn-btn-confirm">{t.reset}</button>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ </div>
159
+
160
+ <script>
161
+ import { initStreetballScoreKeeper } from './logic';
162
+ initStreetballScoreKeeper();
163
+ </script>
@@ -0,0 +1,30 @@
1
+ import type { SportsToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { StreetballScoreKeeperUI } from './ui';
3
+
4
+ export type { StreetballScoreKeeperUI };
5
+ export type StreetballLocaleContent = ToolLocaleContent<StreetballScoreKeeperUI>;
6
+
7
+ export const streetballScoreKeeper: SportsToolEntry<StreetballScoreKeeperUI> = {
8
+ id: 'streetball-3x3-basketball-scorekeeper',
9
+ icons: {
10
+ bg: 'mdi:basketball',
11
+ fg: 'mdi:scoreboard-outline',
12
+ },
13
+ i18n: {
14
+ en: () => import('./i18n/en').then((m) => m.content),
15
+ es: () => import('./i18n/es').then((m) => m.content),
16
+ fr: () => import('./i18n/fr').then((m) => m.content),
17
+ de: () => import('./i18n/de').then((m) => m.content),
18
+ id: () => import('./i18n/id').then((m) => m.content),
19
+ it: () => import('./i18n/it').then((m) => m.content),
20
+ ja: () => import('./i18n/ja').then((m) => m.content),
21
+ ko: () => import('./i18n/ko').then((m) => m.content),
22
+ nl: () => import('./i18n/nl').then((m) => m.content),
23
+ pl: () => import('./i18n/pl').then((m) => m.content),
24
+ pt: () => import('./i18n/pt').then((m) => m.content),
25
+ ru: () => import('./i18n/ru').then((m) => m.content),
26
+ sv: () => import('./i18n/sv').then((m) => m.content),
27
+ tr: () => import('./i18n/tr').then((m) => m.content),
28
+ zh: () => import('./i18n/zh').then((m) => m.content),
29
+ },
30
+ };
@@ -0,0 +1,165 @@
1
+ export interface TeamState {
2
+ score: number;
3
+ fouls: number;
4
+ timeouts: number;
5
+ }
6
+
7
+ export interface StreetballMatchState {
8
+ teamA: TeamState;
9
+ teamB: TeamState;
10
+ possession: 'a' | 'b';
11
+ clearBallNeeded: boolean;
12
+ isOvertime: boolean;
13
+ winner: 'a' | 'b' | null;
14
+ gameTimeMs: number;
15
+ gameTimeActive: boolean;
16
+ shotClockMs: number;
17
+ shotClockActive: boolean;
18
+ timeoutTimeMs: number;
19
+ timeoutActive: boolean;
20
+ timeoutTeam: 'a' | 'b' | null;
21
+ }
22
+
23
+ export function createInitialTeam(): TeamState {
24
+ return {
25
+ score: 0,
26
+ fouls: 0,
27
+ timeouts: 1,
28
+ };
29
+ }
30
+
31
+ export function createInitialMatch(): StreetballMatchState {
32
+ return {
33
+ teamA: createInitialTeam(),
34
+ teamB: createInitialTeam(),
35
+ possession: 'a',
36
+ clearBallNeeded: false,
37
+ isOvertime: false,
38
+ winner: null,
39
+ gameTimeMs: 10 * 60 * 1000,
40
+ gameTimeActive: false,
41
+ shotClockMs: 12 * 1000,
42
+ shotClockActive: false,
43
+ timeoutTimeMs: 0,
44
+ timeoutActive: false,
45
+ timeoutTeam: null,
46
+ };
47
+ }
48
+
49
+ export function checkWinner(state: StreetballMatchState): void {
50
+ if (state.winner) return;
51
+ if (!state.isOvertime) {
52
+ if (state.teamA.score >= 21) {
53
+ state.winner = 'a';
54
+ state.gameTimeActive = false;
55
+ state.shotClockActive = false;
56
+ } else if (state.teamB.score >= 21) {
57
+ state.winner = 'b';
58
+ state.gameTimeActive = false;
59
+ state.shotClockActive = false;
60
+ }
61
+ } else {
62
+ if (state.teamA.score >= 2) {
63
+ state.winner = 'a';
64
+ state.gameTimeActive = false;
65
+ state.shotClockActive = false;
66
+ } else if (state.teamB.score >= 2) {
67
+ state.winner = 'b';
68
+ state.gameTimeActive = false;
69
+ state.shotClockActive = false;
70
+ }
71
+ }
72
+ }
73
+
74
+ export function processPoint(state: StreetballMatchState, team: 'a' | 'b', points: number): StreetballMatchState {
75
+ if (state.winner) return state;
76
+ const next = JSON.parse(JSON.stringify(state)) as StreetballMatchState;
77
+ const t = team === 'a' ? next.teamA : next.teamB;
78
+ t.score += points;
79
+ checkWinner(next);
80
+ if (!next.winner) {
81
+ next.shotClockMs = 12 * 1000;
82
+ }
83
+ return next;
84
+ }
85
+
86
+ export function processFoul(state: StreetballMatchState, team: 'a' | 'b'): StreetballMatchState {
87
+ if (state.winner) return state;
88
+ const next = JSON.parse(JSON.stringify(state)) as StreetballMatchState;
89
+ const t = team === 'a' ? next.teamA : next.teamB;
90
+ t.fouls += 1;
91
+ return next;
92
+ }
93
+
94
+ export function togglePossession(state: StreetballMatchState): StreetballMatchState {
95
+ if (state.winner) return state;
96
+ const next = JSON.parse(JSON.stringify(state)) as StreetballMatchState;
97
+ next.possession = next.possession === 'a' ? 'b' : 'a';
98
+ next.clearBallNeeded = true;
99
+ next.shotClockMs = 12 * 1000;
100
+ return next;
101
+ }
102
+
103
+ export function clearBall(state: StreetballMatchState): StreetballMatchState {
104
+ const next = JSON.parse(JSON.stringify(state)) as StreetballMatchState;
105
+ next.clearBallNeeded = false;
106
+ return next;
107
+ }
108
+
109
+ export function triggerTimeout(state: StreetballMatchState, team: 'a' | 'b'): StreetballMatchState {
110
+ if (state.winner || state.timeoutActive) return state;
111
+ const next = JSON.parse(JSON.stringify(state)) as StreetballMatchState;
112
+ const t = team === 'a' ? next.teamA : next.teamB;
113
+ if (t.timeouts > 0) {
114
+ t.timeouts -= 1;
115
+ next.timeoutActive = true;
116
+ next.timeoutTimeMs = 30 * 1000;
117
+ next.timeoutTeam = team;
118
+ next.gameTimeActive = false;
119
+ next.shotClockActive = false;
120
+ }
121
+ return next;
122
+ }
123
+
124
+ function tickTimeout(next: StreetballMatchState, elapsedMs: number): void {
125
+ next.timeoutTimeMs = Math.max(0, next.timeoutTimeMs - elapsedMs);
126
+ if (next.timeoutTimeMs === 0) {
127
+ next.timeoutActive = false;
128
+ next.timeoutTeam = null;
129
+ }
130
+ }
131
+
132
+ function tickGameClock(next: StreetballMatchState, elapsedMs: number): void {
133
+ next.gameTimeMs = Math.max(0, next.gameTimeMs - elapsedMs);
134
+ if (next.gameTimeMs === 0) {
135
+ next.gameTimeActive = false;
136
+ next.shotClockActive = false;
137
+ if (next.teamA.score === next.teamB.score) {
138
+ next.isOvertime = true;
139
+ next.teamA.score = 0;
140
+ next.teamB.score = 0;
141
+ } else {
142
+ next.winner = next.teamA.score > next.teamB.score ? 'a' : 'b';
143
+ }
144
+ }
145
+ }
146
+
147
+ export function tickGame(state: StreetballMatchState, elapsedMs: number): StreetballMatchState {
148
+ const next = JSON.parse(JSON.stringify(state)) as StreetballMatchState;
149
+ if (next.winner) return next;
150
+ if (next.timeoutActive) {
151
+ tickTimeout(next, elapsedMs);
152
+ return next;
153
+ }
154
+ if (next.gameTimeActive) {
155
+ tickGameClock(next, elapsedMs);
156
+ if (next.shotClockActive) {
157
+ next.shotClockMs = Math.max(0, next.shotClockMs - elapsedMs);
158
+ if (next.shotClockMs === 0) {
159
+ next.shotClockActive = false;
160
+ next.gameTimeActive = false;
161
+ }
162
+ }
163
+ }
164
+ return next;
165
+ }
@@ -0,0 +1,178 @@
1
+ import { bibliography } from '../bibliography';
2
+ import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
3
+ import type { StreetballLocaleContent } from '../entry';
4
+
5
+ const slug = 'streetball-3x3-spielstand';
6
+ const title = 'Premium Streetball 3x3 Spielstand mit Wurfuhr';
7
+ const description = 'Verfolge FIBA 3x3 Streetball-Ergebnisse mit integrierter 12-Sekunden-Wurfuhr, Teamfouls, Sudden-Death-Punkten und dynamischen visuellen Anzeigen für die Spielfeldhälfte.';
8
+
9
+ const faq = [
10
+ {
11
+ question: 'Wie funktioniert die 12-Sekunden-Wurfuhr im 3x3 Streetball?',
12
+ answer: 'Im FIBA 3x3 haben Teams nur 12 Sekunden Zeit für einen Korbversuch, sobald sie in Ballbesitz kommen. Die Wurfuhr wird bei Ballbesitzwechsel auf 12 zurückgesetzt oder bei offensiven Rebounds und Fouls unter bestimmten Bedingungen auf 14 Sekunden.',
13
+ },
14
+ {
15
+ question: 'Was ist das Sudden-Death-Score-Limit im 3x3 Basketball?',
16
+ answer: 'Das erste Team, das 21 Punkte erzielt, gewinnt das Spiel sofort, unabhängig von der verbleibenden Spielzeit. Dies ist die Sudden-Death-Regel.',
17
+ },
18
+ {
19
+ question: 'Wie wirken sich Teamfouls auf das Spiel aus?',
20
+ answer: 'Ab dem 7. Teamfoul erhält der Gegner 2 Freiwürfe. Beim 10. und jedem weiteren Foul gibt es 2 Freiwürfe plus Ballbesitz, was den Strafstatus auslöst.',
21
+ },
22
+ ];
23
+
24
+ const howTo = [
25
+ {
26
+ name: 'Teamnamen festlegen',
27
+ text: 'Gib benutzerdefinierte Namen für die beiden Streetball-Teams ein, um die Anzeige anzupassen.',
28
+ },
29
+ {
30
+ name: 'Punkte und Ballbesitz erfassen',
31
+ text: 'Tippe auf das interaktive Asphaltfeld, um 1 Punkt (innerhalb des Bogens) oder 2 Punkte (außerhalb des Bogens) hinzuzufügen und den Ballbesitz-Indikator umzuschalten.',
32
+ },
33
+ {
34
+ name: 'Wurfuhr steuern',
35
+ text: 'Tippe auf die Wurfuhr, um auf 12 zurückzusetzen, klicke auf den sekundären Reset für 14, oder tippe doppelt, um den Countdown zu pausieren.',
36
+ },
37
+ {
38
+ name: 'Teamfouls verwalten',
39
+ text: 'Erfasse Teamfouls mit dem Zähler, der bei Strafstatus (7+ Fouls) rot wird.',
40
+ },
41
+ ];
42
+
43
+ const faqSchema: WithContext<FAQPage> = {
44
+ '@context': 'https://schema.org',
45
+ '@type': 'FAQPage',
46
+ mainEntity: faq.map((item) => ({
47
+ '@type': 'Question',
48
+ name: item.question,
49
+ acceptedAnswer: { '@type': 'Answer', text: item.answer },
50
+ })),
51
+ };
52
+
53
+ const howToSchema: WithContext<HowTo> = {
54
+ '@context': 'https://schema.org',
55
+ '@type': 'HowTo',
56
+ name: title,
57
+ description,
58
+ step: howTo.map((step, i) => ({
59
+ '@type': 'HowToStep',
60
+ position: i + 1,
61
+ name: step.name,
62
+ text: step.text,
63
+ })),
64
+ };
65
+
66
+ const appSchema: WithContext<SoftwareApplication> = {
67
+ '@context': 'https://schema.org',
68
+ '@type': 'SoftwareApplication',
69
+ name: title,
70
+ description,
71
+ applicationCategory: 'SportsApplication',
72
+ operatingSystem: 'All',
73
+ offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
74
+ inLanguage: 'de',
75
+ };
76
+
77
+ export const content: StreetballLocaleContent = {
78
+ slug,
79
+ title,
80
+ description,
81
+ faq,
82
+ bibliography,
83
+ howTo,
84
+ schemas: [faqSchema, howToSchema, appSchema],
85
+ seo: [
86
+ {
87
+ type: 'title',
88
+ text: 'Kostenloses Online-3x3-Streetball-Scoreboard',
89
+ level: 2,
90
+ },
91
+ {
92
+ type: 'paragraph',
93
+ html: 'Das Mitführen der Punktzahl in schnellen 3x3-Basketballspielen kann schwierig sein, während man eine kurze Wurfuhr verwaltet und Teamfouls im Auge behält. Dieses kostenlose Online-3x3-Streetball-Scoreboard bietet ein industrielles Asphaltthema mit kontrastreichem Neon-Design. Es verwaltet automatisch die 12-Sekunden-Wurfuhr, die Spieluhr, das Foul-Strafensystem und die Ballbesitz-Anzeigen.',
94
+ },
95
+ {
96
+ type: 'title',
97
+ text: 'FIBA 3x3 Streetball Wertungs- und Wurfuhr-Regeln',
98
+ level: 2,
99
+ },
100
+ {
101
+ type: 'paragraph',
102
+ html: 'FIBA 3x3 Streetball unterscheidet sich vom traditionellen 5x5 Basketball. Die Spiele dauern eine 10-minütige Periode oder enden sofort, wenn ein Team 21 Punkte erreicht (Sudden Death). Würfe innerhalb des Bogens und Freiwürfe zählen 1 Punkt, während Würfe von hinter dem 6,75m-Bogen 2 Punkte zählen. Die 12-Sekunden-Wurfuhr erzwingt schnelle Angriffsaktionen, und die Spieler müssen den Ball bei einem Ballbesitzwechsel hinter den Bogen bringen.',
103
+ },
104
+ {
105
+ type: 'comparative',
106
+ columns: 3,
107
+ items: [
108
+ {
109
+ title: 'Freizeitspiele',
110
+ description: 'Schnelle Punkterfassung für Straßenbasketball mit Freunden auf lokalen Plätzen.',
111
+ icon: 'mdi:basketball',
112
+ points: ['Einfache Punktauslöser', 'Responsives Layout', 'Funktioniert offline'],
113
+ },
114
+ {
115
+ title: 'Turnierspiele',
116
+ description: 'Perfekt für offizielle 3x3 Turniere und Streetball-Ligen.',
117
+ icon: 'mdi:trophy-outline',
118
+ points: ['10-Minuten-Countdown', 'Sudden Death bei 21 Pkt', 'Foul-Strafzustände'],
119
+ },
120
+ {
121
+ title: 'Schiedsrichter Dashboard',
122
+ description: 'Speziell für Schiedsrichter entwickelt, um schnelle Wurfuhr-Rücksetzungen und Ballbesitz zu verwalten.',
123
+ icon: 'mdi:school',
124
+ points: ['12s und 14s Rücksetzung', 'Summer-Sound', 'Taktile Gesten'],
125
+ },
126
+ ],
127
+ },
128
+ {
129
+ type: 'title',
130
+ text: 'Interaktive Steuerung und taktile Animationen',
131
+ level: 2,
132
+ },
133
+ {
134
+ type: 'list',
135
+ items: [
136
+ '<strong>12-Sekunden-Wurfuhr</strong> blinkt rot und zeigt Dezimalstellen unter 4 Sekunden an, gefolgt von einem simulierten Summer.',
137
+ '<strong>Interaktive Beton-Spielfeldhälfte</strong> ermöglicht das Antippen der 1- und 2-Punkt-Bereiche, um Punkte direkt auf dem Diagramm zu erfassen.',
138
+ '<strong>Foul-Zähler-Warnung</strong> wird rot und wackelt, um Teamfoul-Strafen anzuzeigen (7+ und 10+ Fouls).',
139
+ '<strong>Ballfreigabe-Anzeige</strong> zeigt eine Erinnerung bei Ballbesitzwechsel, bis der Ball hinter den Bogen gebracht wurde.',
140
+ '<strong>Auszeit-Tracker</strong> startet einen 30-Sekunden-Countdown mit benutzerdefinierten Soundwarnungen.',
141
+ ],
142
+ },
143
+ {
144
+ type: 'title',
145
+ text: 'Warum einen digitalen Streetball-Tracker verwenden?',
146
+ level: 2,
147
+ },
148
+ {
149
+ type: 'paragraph',
150
+ html: 'Eine digitale Anzeigetafel beseitigt Meinungsverschiedenheiten über Punkte, Fouls oder Wurfuhr-Verstöße auf dem Asphalt. Die hellen Neon-Zahlen sind aus der Ferne gut lesbar, und die automatischen Ballbesitz- und Freigabe-Erinnerungen sorgen für einen reibungslosen Spielablauf ohne Unterbrechungen.',
151
+ },
152
+ ],
153
+ ui: {
154
+ teamA: 'Team 1',
155
+ teamB: 'Team 2',
156
+ points: 'Punkte',
157
+ fouls: 'Fouls',
158
+ timeouts: 'Auszeiten',
159
+ shotClock: 'Wurfuhr',
160
+ reset: 'Zurücksetzen',
161
+ resetConfirm: 'Spiel zurücksetzen? Alle Daten gehen verloren.',
162
+ cancel: 'Abbrechen',
163
+ gameTime: 'Zeit',
164
+ possession: 'Ballbesitz',
165
+ clearBall: 'Ball freigeben',
166
+ matchWon: 'Spiel gewonnen',
167
+ timeoutActive: 'Auszeit',
168
+ penalty: 'Strafe',
169
+ fullscreen: 'Vollbild',
170
+ exitFullscreen: 'Vollbild beenden',
171
+ overtime: 'Verlängerung',
172
+ ptsInside: '+1 Punkt',
173
+ ptsOutside: '+2 Punkte',
174
+ toggleSound: 'Sound umschalten',
175
+ soundOn: 'Sound an',
176
+ soundOff: 'Sound aus',
177
+ },
178
+ };