@jjlmoya/utils-tabletop 1.3.0 → 1.5.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/TabletopCategorySEO.astro +2 -7
- package/src/category/i18n/en.ts +4 -4
- package/src/category/i18n/ru.ts +2 -2
- package/src/category/i18n/zh.ts +8 -8
- package/src/category/index.ts +2 -1
- package/src/entries.ts +14 -1
- package/src/index.ts +1 -0
- package/src/pages/[locale]/[slug].astro +7 -6
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/no_em_dash.test.ts +47 -0
- package/src/tests/seo_length.test.ts +1 -0
- package/src/tests/title_quality.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/board-game-timer/modules/ColorHelper.ts +2 -2
- package/src/tool/board-game-timer/modules/DuelViewManager.ts +4 -4
- package/src/tool/board-game-timer/modules/GameRunner.ts +1 -1
- package/src/tool/board-game-timer/modules/MultiplayerViewManager.ts +2 -2
- package/src/tool/board-game-timer/modules/SetupPanelManager.ts +1 -1
- package/src/tool/board-game-timer/modules/TimerEngine.ts +1 -1
- package/src/tool/board-game-timer/types.ts +1 -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/dice-roller-simulator/client.ts +2 -2
- package/src/tool/dice-roller-simulator/i18n/ru.ts +8 -8
- package/src/tool/dice-roller-simulator/modules/history.ts +1 -1
- 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/first-player-selector/bibliography.astro +16 -0
- package/src/tool/first-player-selector/bibliography.ts +12 -0
- package/src/tool/first-player-selector/client.ts +197 -0
- package/src/tool/first-player-selector/component.astro +77 -0
- package/src/tool/first-player-selector/entry.ts +42 -0
- package/src/tool/first-player-selector/first-player-selector.css +303 -0
- package/src/tool/first-player-selector/i18n/de.ts +226 -0
- package/src/tool/first-player-selector/i18n/en.ts +226 -0
- package/src/tool/first-player-selector/i18n/es.ts +226 -0
- package/src/tool/first-player-selector/i18n/fr.ts +227 -0
- package/src/tool/first-player-selector/i18n/id.ts +226 -0
- package/src/tool/first-player-selector/i18n/it.ts +227 -0
- package/src/tool/first-player-selector/i18n/ja.ts +227 -0
- package/src/tool/first-player-selector/i18n/ko.ts +227 -0
- package/src/tool/first-player-selector/i18n/nl.ts +226 -0
- package/src/tool/first-player-selector/i18n/pl.ts +227 -0
- package/src/tool/first-player-selector/i18n/pt.ts +226 -0
- package/src/tool/first-player-selector/i18n/ru.ts +227 -0
- package/src/tool/first-player-selector/i18n/sv.ts +226 -0
- package/src/tool/first-player-selector/i18n/tr.ts +226 -0
- package/src/tool/first-player-selector/i18n/zh.ts +227 -0
- package/src/tool/first-player-selector/index.ts +9 -0
- package/src/tool/first-player-selector/modules/AnimationEffects.ts +256 -0
- package/src/tool/first-player-selector/modules/SelectionEngine.ts +249 -0
- package/src/tool/first-player-selector/modules/SoundEffects.ts +87 -0
- package/src/tool/first-player-selector/modules/TouchTracker.ts +148 -0
- package/src/tool/first-player-selector/seo.astro +16 -0
- package/src/tool/first-player-selector/types.ts +21 -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 +10 -0
- package/src/types.ts +1 -2
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { WheelSegment, SpinResultEntry, PresetKey } from './types';
|
|
2
|
+
|
|
3
|
+
export const SEGMENT_COLORS = [
|
|
4
|
+
'#3b82f6', '#ef4444', '#22c55e', '#f59e0b',
|
|
5
|
+
'#8b5cf6', '#ec4899', '#14b8a6', '#f97316',
|
|
6
|
+
'#6366f1', '#84cc16', '#06b6d4', '#e11d48',
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const PRESETS: Record<PresetKey, WheelSegment[]> = {
|
|
10
|
+
yesno: [
|
|
11
|
+
{ id: 'seg-0', label: 'Yes', color: SEGMENT_COLORS[2], weight: 1 },
|
|
12
|
+
{ id: 'seg-1', label: 'No', color: SEGMENT_COLORS[1], weight: 1 },
|
|
13
|
+
],
|
|
14
|
+
numbers: Array.from({ length: 6 }, (_, i) => ({
|
|
15
|
+
id: `seg-${i}`,
|
|
16
|
+
label: String(i + 1),
|
|
17
|
+
color: SEGMENT_COLORS[i],
|
|
18
|
+
weight: 1,
|
|
19
|
+
})),
|
|
20
|
+
actions: [
|
|
21
|
+
{ id: 'seg-0', label: 'Attack', color: SEGMENT_COLORS[1], weight: 1 },
|
|
22
|
+
{ id: 'seg-1', label: 'Defend', color: SEGMENT_COLORS[0], weight: 1 },
|
|
23
|
+
{ id: 'seg-2', label: 'Explore', color: SEGMENT_COLORS[2], weight: 1 },
|
|
24
|
+
{ id: 'seg-3', label: 'Negotiate', color: SEGMENT_COLORS[3], weight: 1 },
|
|
25
|
+
{ id: 'seg-4', label: 'Rest', color: SEGMENT_COLORS[4], weight: 1 },
|
|
26
|
+
],
|
|
27
|
+
d20: Array.from({ length: 20 }, (_, i) => ({
|
|
28
|
+
id: `seg-${i}`,
|
|
29
|
+
label: String(i + 1),
|
|
30
|
+
color: SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
|
31
|
+
weight: 1,
|
|
32
|
+
})),
|
|
33
|
+
alignment: [
|
|
34
|
+
{ id: 'seg-0', label: 'Lawful Good', color: SEGMENT_COLORS[2], weight: 1 },
|
|
35
|
+
{ id: 'seg-1', label: 'Neutral Good', color: SEGMENT_COLORS[0], weight: 1 },
|
|
36
|
+
{ id: 'seg-2', label: 'Chaotic Good', color: SEGMENT_COLORS[6], weight: 1 },
|
|
37
|
+
{ id: 'seg-3', label: 'Lawful Neutral', color: SEGMENT_COLORS[3], weight: 1 },
|
|
38
|
+
{ id: 'seg-4', label: 'True Neutral', color: SEGMENT_COLORS[4], weight: 1 },
|
|
39
|
+
{ id: 'seg-5', label: 'Chaotic Neutral', color: SEGMENT_COLORS[7], weight: 1 },
|
|
40
|
+
{ id: 'seg-6', label: 'Lawful Evil', color: SEGMENT_COLORS[1], weight: 1 },
|
|
41
|
+
{ id: 'seg-7', label: 'Neutral Evil', color: SEGMENT_COLORS[11], weight: 1 },
|
|
42
|
+
{ id: 'seg-8', label: 'Chaotic Evil', color: SEGMENT_COLORS[5], weight: 1 },
|
|
43
|
+
],
|
|
44
|
+
loot: [
|
|
45
|
+
{ id: 'seg-0', label: 'Common', color: '#9ca3af', weight: 5 },
|
|
46
|
+
{ id: 'seg-1', label: 'Uncommon', color: '#22c55e', weight: 4 },
|
|
47
|
+
{ id: 'seg-2', label: 'Rare', color: '#3b82f6', weight: 3 },
|
|
48
|
+
{ id: 'seg-3', label: 'Very Rare', color: '#a855f7', weight: 2 },
|
|
49
|
+
{ id: 'seg-4', label: 'Legendary', color: '#eab308', weight: 1 },
|
|
50
|
+
],
|
|
51
|
+
custom: [
|
|
52
|
+
{ id: 'seg-0', label: 'Option A', color: SEGMENT_COLORS[0], weight: 1 },
|
|
53
|
+
{ id: 'seg-1', label: 'Option B', color: SEGMENT_COLORS[1], weight: 1 },
|
|
54
|
+
{ id: 'seg-2', label: 'Option C', color: SEGMENT_COLORS[2], weight: 1 },
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function getPresetSegments(preset: PresetKey): WheelSegment[] {
|
|
59
|
+
return (PRESETS[preset] || PRESETS.custom).map((s) => ({ ...s }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function addSegment(segments: WheelSegment[]): WheelSegment[] {
|
|
63
|
+
const idx = segments.length;
|
|
64
|
+
return [
|
|
65
|
+
...segments,
|
|
66
|
+
{
|
|
67
|
+
id: `seg-${Date.now()}`,
|
|
68
|
+
label: `Option ${String.fromCharCode(65 + Math.min(idx, 25))}`,
|
|
69
|
+
color: SEGMENT_COLORS[idx % SEGMENT_COLORS.length],
|
|
70
|
+
weight: 1,
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function removeSegment(segments: WheelSegment[], id: string): WheelSegment[] {
|
|
76
|
+
if (segments.length <= 1) return segments;
|
|
77
|
+
return segments.filter((s) => s.id !== id);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function updateSegment(
|
|
81
|
+
segments: WheelSegment[],
|
|
82
|
+
id: string,
|
|
83
|
+
field: 'label' | 'color' | 'weight',
|
|
84
|
+
value: string,
|
|
85
|
+
): WheelSegment[] {
|
|
86
|
+
return segments.map((s) => {
|
|
87
|
+
if (s.id === id) {
|
|
88
|
+
if (field === 'weight') {
|
|
89
|
+
return { ...s, weight: Math.max(1, parseInt(value, 10) || 1) };
|
|
90
|
+
}
|
|
91
|
+
return { ...s, [field]: value };
|
|
92
|
+
}
|
|
93
|
+
return s;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function selectRandomSegment(segments: WheelSegment[]): number {
|
|
98
|
+
const totalWeight = segments.reduce((sum, s) => sum + (s.weight || 1), 0);
|
|
99
|
+
const r = Math.random() * totalWeight;
|
|
100
|
+
let acc = 0;
|
|
101
|
+
for (let i = 0; i < segments.length; i++) {
|
|
102
|
+
acc += segments[i].weight || 1;
|
|
103
|
+
if (r < acc) {
|
|
104
|
+
return i;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return segments.length - 1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function getSelectedIndex(
|
|
111
|
+
segments: WheelSegment[],
|
|
112
|
+
currentAngle: number,
|
|
113
|
+
): number {
|
|
114
|
+
const totalWeight = segments.reduce((sum, s) => sum + (s.weight || 1), 0);
|
|
115
|
+
const segmentAngle = (360 - (currentAngle % 360)) % 360;
|
|
116
|
+
let acc = 0;
|
|
117
|
+
for (let i = 0; i < segments.length; i++) {
|
|
118
|
+
const nextAcc = acc + (segments[i].weight || 1);
|
|
119
|
+
const startAngle = (acc / totalWeight) * 360;
|
|
120
|
+
const endAngle = (nextAcc / totalWeight) * 360;
|
|
121
|
+
if (segmentAngle >= startAngle && segmentAngle < endAngle) {
|
|
122
|
+
return i;
|
|
123
|
+
}
|
|
124
|
+
acc = nextAcc;
|
|
125
|
+
}
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function getArcAngle(segmentCount: number): number {
|
|
130
|
+
return 360 / segmentCount;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function addHistoryItem(
|
|
134
|
+
history: SpinResultEntry[],
|
|
135
|
+
segment: WheelSegment,
|
|
136
|
+
index: number,
|
|
137
|
+
): SpinResultEntry[] {
|
|
138
|
+
const entry: SpinResultEntry = {
|
|
139
|
+
segment,
|
|
140
|
+
index,
|
|
141
|
+
timestamp: Date.now(),
|
|
142
|
+
};
|
|
143
|
+
const updated = [entry, ...history];
|
|
144
|
+
return updated.slice(0, 10);
|
|
145
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { decisionWheel } 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 = decisionWheel.i18n[locale] || decisionWheel.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,14 @@
|
|
|
1
|
+
export interface WheelSegment {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
color: string;
|
|
5
|
+
weight?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface SpinResultEntry {
|
|
9
|
+
segment: WheelSegment;
|
|
10
|
+
index: number;
|
|
11
|
+
timestamp: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type PresetKey = 'yesno' | 'numbers' | 'actions' | 'custom' | 'd20' | 'alignment' | 'loot';
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { WheelSegment, SpinResultEntry } from './types';
|
|
2
|
+
|
|
3
|
+
function selected(value: number | undefined, target: number): string {
|
|
4
|
+
return value === target ? 'selected' : '';
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function createSegmentRow(seg: WheelSegment, t: (key: string) => string): HTMLDivElement {
|
|
8
|
+
const row = document.createElement('div');
|
|
9
|
+
row.className = 'segment-row';
|
|
10
|
+
const labelPlaceholder = t('segmentLabelPlaceholder') || 'Label';
|
|
11
|
+
const weightLabel = t('weightLabel') || 'Weight';
|
|
12
|
+
const removeLabel = t('removeSegmentLabel') || 'Remove';
|
|
13
|
+
const sw = seg.weight;
|
|
14
|
+
row.innerHTML = `
|
|
15
|
+
<input type="color" class="segment-color" data-id="${seg.id}" value="${seg.color}" />
|
|
16
|
+
<input type="text" class="segment-input" data-id="${seg.id}" value="${seg.label}" placeholder="${labelPlaceholder}" />
|
|
17
|
+
<select class="segment-weight" data-id="${seg.id}" title="${weightLabel}">
|
|
18
|
+
<option value="1" ${selected(sw, 1)}>w1</option>
|
|
19
|
+
<option value="2" ${selected(sw, 2)}>w2</option>
|
|
20
|
+
<option value="3" ${selected(sw, 3)}>w3</option>
|
|
21
|
+
<option value="4" ${selected(sw, 4)}>w4</option>
|
|
22
|
+
<option value="5" ${selected(sw, 5)}>w5</option>
|
|
23
|
+
</select>
|
|
24
|
+
<button type="button" class="btn-remove-segment" data-id="${seg.id}">${removeLabel}</button>
|
|
25
|
+
`;
|
|
26
|
+
return row;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function bindSegmentEvents(
|
|
30
|
+
segmentList: HTMLElement,
|
|
31
|
+
onUpdateSegment: (id: string, field: string, value: string) => void,
|
|
32
|
+
onRemoveSegment: (id: string) => void,
|
|
33
|
+
): void {
|
|
34
|
+
segmentList.querySelectorAll('.segment-color').forEach((el) => {
|
|
35
|
+
el.addEventListener('input', (e) => {
|
|
36
|
+
const input = e.target as HTMLInputElement;
|
|
37
|
+
onUpdateSegment(input.dataset.id || '', 'color', input.value);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
segmentList.querySelectorAll('.segment-input').forEach((el) => {
|
|
41
|
+
el.addEventListener('input', (e) => {
|
|
42
|
+
const input = e.target as HTMLInputElement;
|
|
43
|
+
onUpdateSegment(input.dataset.id || '', 'label', input.value);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
segmentList.querySelectorAll('.segment-weight').forEach((el) => {
|
|
47
|
+
el.addEventListener('change', (e) => {
|
|
48
|
+
const select = e.target as HTMLSelectElement;
|
|
49
|
+
onUpdateSegment(select.dataset.id || '', 'weight', select.value);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
segmentList.querySelectorAll('.btn-remove-segment').forEach((el) => {
|
|
53
|
+
el.addEventListener('click', (e) => {
|
|
54
|
+
const btn = e.currentTarget as HTMLButtonElement;
|
|
55
|
+
onRemoveSegment(btn.dataset.id || '');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function renderSegmentEditor(
|
|
61
|
+
segmentList: HTMLElement,
|
|
62
|
+
segments: WheelSegment[],
|
|
63
|
+
t: (key: string) => string,
|
|
64
|
+
onBind: () => void,
|
|
65
|
+
): void {
|
|
66
|
+
segmentList.innerHTML = '';
|
|
67
|
+
if (segments.length === 0) {
|
|
68
|
+
segmentList.innerHTML = `<div class="no-segments-msg">${t('noSegments') || 'Add segments to the wheel before spinning.'}</div>`;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
segments.forEach((seg) => {
|
|
72
|
+
segmentList.appendChild(createSegmentRow(seg, t));
|
|
73
|
+
});
|
|
74
|
+
onBind();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderResult(
|
|
78
|
+
resultDisplay: HTMLElement,
|
|
79
|
+
result: SpinResultEntry | null,
|
|
80
|
+
spinning: boolean,
|
|
81
|
+
t: (key: string) => string,
|
|
82
|
+
): void {
|
|
83
|
+
if (!result) {
|
|
84
|
+
resultDisplay.innerHTML = `<div class="result-placeholder">${spinning ? 'Spinning...' : (t('noHistory') || 'Spin to see result')}</div>`;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
resultDisplay.innerHTML = `<div class="result-card" id="result-card">
|
|
88
|
+
<div class="result-header">${t('resultHeading') || 'Result'}</div>
|
|
89
|
+
<span class="result-value"><span class="result-color-dot" style="background:${result.segment.color}"></span>${result.segment.label}</span>
|
|
90
|
+
</div>`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function triggerEmphasis(): void {
|
|
94
|
+
const card = document.getElementById('result-card');
|
|
95
|
+
if (!card) return;
|
|
96
|
+
card.classList.remove('result-emphasis');
|
|
97
|
+
void card.offsetWidth;
|
|
98
|
+
card.classList.add('result-emphasis');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function renderHistory(
|
|
102
|
+
historyList: HTMLElement,
|
|
103
|
+
historyEmpty: HTMLElement,
|
|
104
|
+
history: SpinResultEntry[],
|
|
105
|
+
): void {
|
|
106
|
+
if (history.length === 0) {
|
|
107
|
+
historyEmpty.style.display = 'block';
|
|
108
|
+
historyList.querySelectorAll('.history-item').forEach((el) => el.remove());
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
historyEmpty.style.display = 'none';
|
|
112
|
+
historyList.querySelectorAll('.history-item').forEach((el) => el.remove());
|
|
113
|
+
history.forEach((h) => {
|
|
114
|
+
const item = document.createElement('div');
|
|
115
|
+
item.className = 'history-item';
|
|
116
|
+
const time = new Date(h.timestamp);
|
|
117
|
+
const timeStr = time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
118
|
+
item.innerHTML = `<span class="history-item-label"><span class="history-item-dot" style="background:${h.segment.color}"></span>${h.segment.label}</span><span class="history-item-time">${timeStr}</span>`;
|
|
119
|
+
historyList.appendChild(item);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -185,14 +185,14 @@ function rollDiceTumble(selectedDice: { type: string; max: number }[], visualDic
|
|
|
185
185
|
return new Promise((resolve) => {
|
|
186
186
|
const intervalId = setInterval(() => {
|
|
187
187
|
visualDiceEls.forEach((el, index) => {
|
|
188
|
-
const d = selectedDice[index]
|
|
188
|
+
const d = selectedDice[index]!;
|
|
189
189
|
el.textContent = (Math.floor(Math.random() * d.max) + 1).toString();
|
|
190
190
|
});
|
|
191
191
|
}, 60);
|
|
192
192
|
setTimeout(() => {
|
|
193
193
|
clearInterval(intervalId);
|
|
194
194
|
const results = visualDiceEls.map((el, index) => {
|
|
195
|
-
const d = selectedDice[index]
|
|
195
|
+
const d = selectedDice[index]!;
|
|
196
196
|
el.classList.remove('rolling-pulse');
|
|
197
197
|
const val = Math.floor(Math.random() * d.max) + 1;
|
|
198
198
|
if (val === d.max) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { bibliography } from '../bibliography';
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
2
2
|
import type { ToolLocaleContent } from '../../../types';
|
|
3
3
|
|
|
4
4
|
export const content: ToolLocaleContent = {
|
|
@@ -20,7 +20,7 @@ export const content: ToolLocaleContent = {
|
|
|
20
20
|
},
|
|
21
21
|
seo: [
|
|
22
22
|
{ type: 'title', text: 'Управление вероятностями в настольных играх: Как математика кубиков влияет на игровой процесс', level: 2 },
|
|
23
|
-
{ type: 'paragraph', html: 'Многогранные кости
|
|
23
|
+
{ type: 'paragraph', html: 'Многогранные кости - это сердце настольных ролевых игр и современных настолок. Стремитесь ли вы выбросить высокий спасбросок в Dungeons & Dragons или бросаете кости для получения ресурсов, результат подчиняется математической вероятности. Понимание того, как сочетание различных типов кубиков влияет на ваши шансы, имеет решающее значение для принятия тактических решений во время игры.' },
|
|
24
24
|
{
|
|
25
25
|
type: 'stats',
|
|
26
26
|
items: [
|
|
@@ -33,7 +33,7 @@ export const content: ToolLocaleContent = {
|
|
|
33
33
|
{ type: 'title', text: 'Математика нескольких костей: Почему 3d6 надежнее, чем 1d20', level: 2 },
|
|
34
34
|
{ type: 'paragraph', html: 'Когда вы бросаете одну кость D20, каждый результат от 1 до 20 имеет равный шанс в 5%, что дает равномерное распределение вероятностей. Однако при броске нескольких костей, например 3d6, результаты группируются вокруг среднего значения 10.5. Эта куполообразная кривая (распределение Гаусса) означает, что вы гораздо чаще будете выбрасывать средние значения, чем крайние, что обеспечивает стабильность и сглаживает непредсказуемость одиночного кубика.' },
|
|
35
35
|
{ type: 'title', text: 'Разбор вероятностей D20: Критические попадания, критические провалы (pifia) и роль модификаторов', level: 2 },
|
|
36
|
-
{ type: 'paragraph', html: 'Простой модификатор «плюс два» может кардинально изменить вероятность успеха, превращая сложные задачи в легко выполнимые. Во многих игровых системах чистая 20 означает автоматический критический успех (шанс 5%), тогда как чистая 1
|
|
36
|
+
{ type: 'paragraph', html: 'Простой модификатор «плюс два» может кардинально изменить вероятность успеха, превращая сложные задачи в легко выполнимые. Во многих игровых системах чистая 20 означает автоматический критический успех (шанс 5%), тогда как чистая 1 - критический провал (pifia). Визуализация этих исходов помогает демистифицировать случайность и позволяет планировать тактические ходы на основе статистики.' },
|
|
37
37
|
{
|
|
38
38
|
type: 'title',
|
|
39
39
|
text: 'Таблица характеристик стандартных многогранных костей',
|
|
@@ -108,7 +108,7 @@ export const content: ToolLocaleContent = {
|
|
|
108
108
|
},
|
|
109
109
|
{
|
|
110
110
|
title: 'Процентная система (Бросок под значение)',
|
|
111
|
-
description: 'Использует две 10-гранные кости для получения числа от 1 до 100. Цель
|
|
111
|
+
description: 'Использует две 10-гранные кости для получения числа от 1 до 100. Цель - выбросить значение меньше показателя персонажа, что делает шансы наглядными.',
|
|
112
112
|
icon: 'mdi:percent-box-outline',
|
|
113
113
|
highlight: false,
|
|
114
114
|
points: [
|
|
@@ -126,7 +126,7 @@ export const content: ToolLocaleContent = {
|
|
|
126
126
|
title: 'Ошибка игрока: Почему у кубиков нет памяти',
|
|
127
127
|
icon: 'mdi:information-outline',
|
|
128
128
|
badge: 'PROBABILITY TIP',
|
|
129
|
-
html: 'Распространенное заблуждение в настольных играх
|
|
129
|
+
html: 'Распространенное заблуждение в настольных играх - вера в то, что предыдущие броски влияют на будущие. Если вы выбросили три «1» подряд на D20, вам может показаться, что высокий результат уже «на подходе». На самом деле каждый бросок - независимое событие. Вероятность выбросить 20 при следующем броске по-прежнему составляет ровно 5%. �-нание этого помогает игрокам делать тактический выбор объективно.',
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
type: 'title',
|
|
@@ -154,7 +154,7 @@ export const content: ToolLocaleContent = {
|
|
|
154
154
|
},
|
|
155
155
|
{
|
|
156
156
|
term: 'Математическое ожидание',
|
|
157
|
-
definition: 'Долгосрочный средний результат ваших бросков. Для одного D6 математическое ожидание составляет 3.5; для D20
|
|
157
|
+
definition: 'Долгосрочный средний результат ваших бросков. Для одного D6 математическое ожидание составляет 3.5; для D20 - 10.5.',
|
|
158
158
|
},
|
|
159
159
|
{
|
|
160
160
|
term: 'Процентные кости',
|
|
@@ -174,7 +174,7 @@ export const content: ToolLocaleContent = {
|
|
|
174
174
|
},
|
|
175
175
|
{
|
|
176
176
|
question: 'Что такое модификаторы и как они работают?',
|
|
177
|
-
answer: 'Модификаторы
|
|
177
|
+
answer: 'Модификаторы - это фиксированные значения (+1, -2 и т. д.), которые прибавляются к сумме броска или вычитаются из нее для отражения навыков персонажа, бонусов или штрафов.',
|
|
178
178
|
},
|
|
179
179
|
{
|
|
180
180
|
question: 'Что означает бросок с Преимуществом или Помехой?',
|
|
@@ -242,7 +242,7 @@ export const content: ToolLocaleContent = {
|
|
|
242
242
|
'name': 'Что такое модификаторы и как они работают?',
|
|
243
243
|
'acceptedAnswer': {
|
|
244
244
|
'@type': 'Answer',
|
|
245
|
-
'text': 'Модификаторы
|
|
245
|
+
'text': 'Модификаторы - это фиксированные значения (+1, -2 и т. д.), которые прибавляются к сумме броска или вычитаются из нее для отражения навыков персонажа, бонусов или штрафов.'
|
|
246
246
|
}
|
|
247
247
|
},
|
|
248
248
|
{
|
|
@@ -81,7 +81,7 @@ export function renderHistory(refs: HistoryUIRefs, onDrawChart: (totals: number[
|
|
|
81
81
|
setText(refs.totalVal, history.length.toString());
|
|
82
82
|
setText(refs.badge, history.length.toString());
|
|
83
83
|
setText(refs.averageVal, (sum / history.length).toFixed(1));
|
|
84
|
-
setText(refs.lastVal, history[history.length - 1]
|
|
84
|
+
setText(refs.lastVal, history[history.length - 1]!.total.toString());
|
|
85
85
|
|
|
86
86
|
onDrawChart(totals);
|
|
87
87
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { fantasyRunesTranslator } 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 = fantasyRunesTranslator.i18n[locale] || fantasyRunesTranslator.i18n.en;
|
|
12
|
+
const content = await loader?.();
|
|
13
|
+
if (!content) return null;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
{content && <SharedBibliography links={content.bibliography} />}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'Elder Futhark (Wikipedia)',
|
|
6
|
+
url: 'https://en.wikipedia.org/wiki/Elder_Futhark',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'Anglo-Saxon Runes (Wikipedia)',
|
|
10
|
+
url: 'https://en.wikipedia.org/wiki/Anglo-Saxon_runes',
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'Runic Unicode Block',
|
|
14
|
+
url: 'https://en.wikipedia.org/wiki/Runic_(Unicode_block)',
|
|
15
|
+
},
|
|
16
|
+
];
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import type { RuneAlphabetKey, RuneCharacterResult, RuneCharacter } from './types';
|
|
2
|
+
import { translateText } from './logic';
|
|
3
|
+
|
|
4
|
+
const wrap = document.querySelector('.runes-wrap') as HTMLElement;
|
|
5
|
+
const input = document.getElementById('runes-input') as HTMLInputElement;
|
|
6
|
+
const seedIn = document.getElementById('runes-seed') as HTMLInputElement;
|
|
7
|
+
const seedTag = document.getElementById('seed-tag') as HTMLElement;
|
|
8
|
+
const seedShuf = document.getElementById('seed-shuffle-btn') as HTMLElement;
|
|
9
|
+
const seedToggle = document.getElementById('seed-toggle-btn') as HTMLElement;
|
|
10
|
+
const seedDrawer = document.getElementById('seed-drawer') as HTMLElement;
|
|
11
|
+
const output = document.getElementById('runes-output') as HTMLElement;
|
|
12
|
+
const detailCard = document.getElementById('detail-card') as HTMLElement;
|
|
13
|
+
const dName = document.getElementById('detail-name') as HTMLElement;
|
|
14
|
+
const dMeaning = document.getElementById('detail-meaning') as HTMLElement;
|
|
15
|
+
const dPhonetic = document.getElementById('detail-phonetic') as HTMLElement;
|
|
16
|
+
const copyBtn = document.getElementById('runes-copy-btn') as HTMLElement;
|
|
17
|
+
const clearBtn = document.getElementById('runes-clear-btn') as HTMLElement;
|
|
18
|
+
const pills = document.getElementById('alphabet-pills') as HTMLElement;
|
|
19
|
+
const gmBtn = document.getElementById('runes-gm-btn') as HTMLElement;
|
|
20
|
+
const gmOverlay = document.getElementById('gm-overlay') as HTMLElement;
|
|
21
|
+
const gmDisplay = document.getElementById('gm-runes') as HTMLElement;
|
|
22
|
+
const gmParts = document.getElementById('gm-particles') as HTMLElement;
|
|
23
|
+
const gmExit = document.getElementById('gm-exit-btn') as HTMLElement;
|
|
24
|
+
const refFoundBtn = document.getElementById('runes-ref-found-btn') as HTMLElement;
|
|
25
|
+
const refAllBtn = document.getElementById('runes-ref-all-btn') as HTMLElement;
|
|
26
|
+
const refOverlay = document.getElementById('ref-overlay') as HTMLElement;
|
|
27
|
+
const refGrid = document.getElementById('ref-grid') as HTMLElement;
|
|
28
|
+
const refTitle = document.getElementById('ref-title') as HTMLElement;
|
|
29
|
+
const refClose = document.getElementById('ref-close-btn') as HTMLElement;
|
|
30
|
+
const ALPHA_KEY = 'runes-alphabet';
|
|
31
|
+
let alphabet: RuneAlphabetKey = (localStorage.getItem(ALPHA_KEY) as RuneAlphabetKey) || 'elder-futhark';
|
|
32
|
+
let result: RuneCharacterResult[] = [];
|
|
33
|
+
let seed: number | null = null;
|
|
34
|
+
const discovered = new Set<string>();
|
|
35
|
+
|
|
36
|
+
function ui(key: string): string {
|
|
37
|
+
try { return JSON.parse(wrap.getAttribute('data-ui') || '{}')[key] || ''; } catch { return ''; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function spawnPart(sym: string, x: number, y: number) {
|
|
41
|
+
const el = document.createElement('div');
|
|
42
|
+
el.className = 'particle'; el.textContent = sym;
|
|
43
|
+
el.style.cssText = `left:${x}px;top:${y}px;position:fixed`;
|
|
44
|
+
document.body.appendChild(el);
|
|
45
|
+
setTimeout(() => el.remove(), 1000);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function spawnGmParts() {
|
|
49
|
+
gmParts.innerHTML = '';
|
|
50
|
+
const rs = ['ᚠ','ᛟ','ᚱ','ᚲ','ᚨ','ᚢ','ᛉ','ᚷ','ᚹ','ᛞ','ᛁ','ᛃ','ᛚ','ᛗ','ᚾ','ᛟ','ᛈ','ᛏ','ᛒ','ᛇ','ᛉ','ᚱ','ᛋ','ᚠ','ᚢ','ᚦ','ᚬ'];
|
|
51
|
+
for (let i = 0; i < 10; i++) {
|
|
52
|
+
const p = document.createElement('span');
|
|
53
|
+
p.className = 'gm-float'; p.textContent = rs[Math.floor(Math.random() * rs.length)];
|
|
54
|
+
p.style.left = Math.random() * 100 + '%';
|
|
55
|
+
p.style.fontSize = (0.7 + Math.random() * 1.3) + 'rem';
|
|
56
|
+
p.style.animationDuration = (3 + Math.random() * 3) + 's';
|
|
57
|
+
p.style.animationDelay = Math.random() * 4 + 's';
|
|
58
|
+
gmParts.appendChild(p);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function selPill(key: RuneAlphabetKey) {
|
|
63
|
+
pills.querySelectorAll('.pill').forEach(b => b.classList.toggle('active', b.getAttribute('data-alphabet') === key));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function emptyOut() {
|
|
67
|
+
output.innerHTML = '<div class="runes-output-empty"><span class="empty-rune">ᛟ</span><span class="empty-label">' + ui('noInput') + '</span></div>';
|
|
68
|
+
output.classList.remove('has-chars');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function showDetail(c: RuneCharacter | null) {
|
|
72
|
+
if (!c) { detailCard.classList.remove('vis'); return; }
|
|
73
|
+
dName.textContent = c.name; dMeaning.textContent = c.meaning; dPhonetic.textContent = c.phonetic;
|
|
74
|
+
detailCard.classList.add('vis');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function clickRune(cr: RuneCharacterResult, card: HTMLElement) {
|
|
78
|
+
output.querySelectorAll('.rune-card').forEach(c => c.classList.remove('active'));
|
|
79
|
+
card.classList.add('active');
|
|
80
|
+
if (cr.char) {
|
|
81
|
+
showDetail(cr.char);
|
|
82
|
+
const r = card.getBoundingClientRect();
|
|
83
|
+
spawnPart(cr.char.symbol, r.left + r.width / 2, r.top);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function render(rs: RuneCharacterResult[]) {
|
|
88
|
+
output.classList.add('has-chars');
|
|
89
|
+
result = rs;
|
|
90
|
+
const shim = document.createElement('div');
|
|
91
|
+
shim.className = 'shimmer';
|
|
92
|
+
output.innerHTML = '';
|
|
93
|
+
output.appendChild(shim);
|
|
94
|
+
setTimeout(() => {
|
|
95
|
+
const g = document.createElement('div');
|
|
96
|
+
g.className = 'runes-grid';
|
|
97
|
+
rs.forEach((cr, i) => {
|
|
98
|
+
const c = document.createElement('div');
|
|
99
|
+
c.className = 'rune-card'; c.style.animationDelay = i * 25 + 'ms';
|
|
100
|
+
const sy = document.createElement('span');
|
|
101
|
+
sy.className = 'sym'; sy.textContent = cr.char ? cr.char.symbol : '?';
|
|
102
|
+
const le = document.createElement('span');
|
|
103
|
+
le.className = 'let'; le.textContent = cr.input.toUpperCase();
|
|
104
|
+
c.appendChild(sy); c.appendChild(le);
|
|
105
|
+
c.addEventListener('click', () => clickRune(cr, c));
|
|
106
|
+
g.appendChild(c);
|
|
107
|
+
});
|
|
108
|
+
output.innerHTML = '';
|
|
109
|
+
output.appendChild(g);
|
|
110
|
+
}, 300);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function translate() {
|
|
114
|
+
const t = input.value.trim();
|
|
115
|
+
if (!t) { emptyOut(); detailCard.classList.remove('vis'); result = []; return; }
|
|
116
|
+
for (const ch of t.toLowerCase()) { if (ch >= 'a' && ch <= 'z') discovered.add(ch); }
|
|
117
|
+
render(translateText(t, alphabet, seed).characters);
|
|
118
|
+
detailCard.classList.remove('vis');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function copyText() {
|
|
122
|
+
if (result.length === 0) return;
|
|
123
|
+
const t = result.map(c => (c.char ? c.char.symbol : c.input)).join('');
|
|
124
|
+
navigator.clipboard.writeText(t).catch(() => {});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function clearAll() {
|
|
128
|
+
input.value = ''; seedIn.value = ''; seed = null; seedTag.style.display = 'none';
|
|
129
|
+
emptyOut(); detailCard.classList.remove('vis'); result = [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function applySeed() {
|
|
133
|
+
const v = parseInt(seedIn.value);
|
|
134
|
+
seed = isNaN(v) || v < 1 ? null : v;
|
|
135
|
+
seedTag.style.display = seed ? '' : 'none';
|
|
136
|
+
if (input.value.trim()) translate();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function randSeed() {
|
|
140
|
+
seedIn.value = String(Math.floor(Math.random() * 99999) + 1);
|
|
141
|
+
applySeed();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function openGm() {
|
|
145
|
+
if (result.length === 0) return;
|
|
146
|
+
gmDisplay.textContent = result.map(c => (c.char ? c.char.symbol : c.input)).join('');
|
|
147
|
+
gmOverlay.classList.add('active');
|
|
148
|
+
spawnGmParts();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function closeGm() { gmOverlay.classList.remove('active'); }
|
|
152
|
+
|
|
153
|
+
function openRef(mode: 'found' | 'all') {
|
|
154
|
+
const mapping = translateText('abcdefghijklmnopqrstuvwxyz', alphabet, seed).characters;
|
|
155
|
+
refTitle.textContent = mode === 'found' ? 'Discovered Runes' : 'Full Rune Dictionary';
|
|
156
|
+
refGrid.innerHTML = mapping.map(c => {
|
|
157
|
+
if (mode === 'found' && !discovered.has(c.input)) return '';
|
|
158
|
+
return `<div class="ref-cell lit"><span class="ref-sym">${c.char ? c.char.symbol : '?'}</span><span class="ref-let">${c.input}</span></div>`;
|
|
159
|
+
}).join('');
|
|
160
|
+
refOverlay.classList.add('active');
|
|
161
|
+
spawnRefParticles();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function spawnRefParticles() {
|
|
165
|
+
const container = refOverlay;
|
|
166
|
+
const existing = container.querySelectorAll('.gm-float');
|
|
167
|
+
existing.forEach(e => e.remove());
|
|
168
|
+
const rs = ['ᚠ','ᛟ','ᚱ','ᚲ','ᚨ','ᚢ','ᛉ','ᚷ','ᚹ','ᛞ','ᛁ','ᛃ','ᛚ','ᛗ','ᚾ','ᛟ','ᛈ','ᛏ','ᛒ','ᛇ','ᛉ','ᚱ','ᛋ','ᚠ','ᚢ','ᚦ','ᚬ'];
|
|
169
|
+
for (let i = 0; i < 8; i++) {
|
|
170
|
+
const p = document.createElement('span');
|
|
171
|
+
p.className = 'gm-float'; p.textContent = rs[Math.floor(Math.random() * rs.length)];
|
|
172
|
+
p.style.left = Math.random() * 100 + '%';
|
|
173
|
+
p.style.fontSize = (0.7 + Math.random() * 1.3) + 'rem';
|
|
174
|
+
p.style.animationDuration = (4 + Math.random() * 3) + 's';
|
|
175
|
+
p.style.animationDelay = Math.random() * 4 + 's';
|
|
176
|
+
container.appendChild(p);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function closeRef() { refOverlay.classList.remove('active'); }
|
|
180
|
+
|
|
181
|
+
input.addEventListener('input', () => { if (input.value.trim()) translate(); });
|
|
182
|
+
seedIn.addEventListener('input', applySeed);
|
|
183
|
+
seedShuf.addEventListener('click', randSeed);
|
|
184
|
+
seedToggle.addEventListener('click', () => seedDrawer.classList.toggle('open'));
|
|
185
|
+
copyBtn.addEventListener('click', copyText);
|
|
186
|
+
clearBtn.addEventListener('click', clearAll);
|
|
187
|
+
gmBtn.addEventListener('click', openGm);
|
|
188
|
+
gmExit.addEventListener('click', closeGm);
|
|
189
|
+
refFoundBtn.addEventListener('click', () => openRef('found'));
|
|
190
|
+
refAllBtn.addEventListener('click', () => openRef('all'));
|
|
191
|
+
refClose.addEventListener('click', closeRef);
|
|
192
|
+
refOverlay.addEventListener('click', (e) => { if (e.target === refOverlay) closeRef(); });
|
|
193
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeGm(); closeRef(); } });
|
|
194
|
+
|
|
195
|
+
pills.addEventListener('click', (e) => {
|
|
196
|
+
const b = (e.target as HTMLElement).closest('.pill') as HTMLButtonElement;
|
|
197
|
+
if (!b) return;
|
|
198
|
+
alphabet = b.getAttribute('data-alphabet') as RuneAlphabetKey;
|
|
199
|
+
localStorage.setItem(ALPHA_KEY, alphabet);
|
|
200
|
+
selPill(alphabet);
|
|
201
|
+
if (input.value.trim()) translate();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
selPill(alphabet);
|
|
205
|
+
emptyOut();
|