@jjlmoya/utils-tabletop 1.3.0 → 1.4.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 +4 -1
- 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/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/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/tools.ts +2 -0
- package/src/types.ts +1 -2
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { bibliography } from '../bibliography';
|
|
2
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
3
|
+
import type { FirstPlayerSelectorUI } from '../entry';
|
|
4
|
+
|
|
5
|
+
const ui: FirstPlayerSelectorUI = {
|
|
6
|
+
title: '起始玩家选择器',
|
|
7
|
+
instructions: '将手指放在屏幕上或点击添加标记',
|
|
8
|
+
tapToSelect: '点击选择',
|
|
9
|
+
winner: '胜者已定!',
|
|
10
|
+
reset: '重置',
|
|
11
|
+
start: '开始选择',
|
|
12
|
+
setupTitle: '选择器设置',
|
|
13
|
+
clearPlayers: '清除所有标记',
|
|
14
|
+
fingerMode: '手指模式',
|
|
15
|
+
wheelMode: '轮盘模式',
|
|
16
|
+
presetsTitle: '选项',
|
|
17
|
+
soundOn: '音效开启',
|
|
18
|
+
soundOff: '音效关闭',
|
|
19
|
+
playerCountLabel: '标记 / 手指数',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const faq = [
|
|
23
|
+
{
|
|
24
|
+
question: '手指模式是如何工作的?',
|
|
25
|
+
answer: '在手指模式下,每位玩家将一根手指放在移动设备屏幕上。该工具会检测所有触控点,在其周围显示发光的彩色圆环,并自动开始一个短暂的倒计时。倒计时结束后,一名玩家会被高亮显示为获胜者,其余玩家则淡出,同时伴有粒子爆炸效果。',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
question: '我可以在台式电脑上使用这个工具吗?',
|
|
29
|
+
answer: '可以!如果您使用的是非触控设备,该工具会自动默认为轮盘模式。您可以点击画布放置代表玩家的彩色标记,然后点击"开始选择"按钮来旋转选择轮盘。',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
question: '最多可以有多少名玩家参与?',
|
|
33
|
+
answer: '该工具支持最多10名玩家同时参与。在手指模式下,最多可以同时检测到10根手指。在轮盘模式下,您可以点击画布放置最多10个彩色标记。',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
question: '选择结果是真正随机的吗?',
|
|
37
|
+
answer: '是的。选择算法使用JavaScript的加密级Math.random()来确保无偏结果。在手指模式下,获胜者是在所有检测到的触控点中均匀随机选择的。在轮盘模式下,轮盘通过摩擦物理模拟逼真减速,最终角度通过数学计算确定获胜者。',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
question: '在轮盘模式下如何移除玩家标记?',
|
|
41
|
+
answer: '只需点击现有标记即可将其移除。该工具会检测任何距离已放置标记30像素以内的点击并将其删除,这样您可以在触发旋转前调整玩家阵容。',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
question: '这个工具会收集或传输数据吗?',
|
|
45
|
+
answer: '完全不会。一切都在您的浏览器中本地运行。任何手指数据、触控位置或选择结果都不会发送到任何服务器。您的游戏之夜将完全保持私密。',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
question: '它能在投影仪或外部屏幕上使用吗?',
|
|
49
|
+
answer: '可以。轮盘模式在大屏幕和投影仪上也能很好地用于桌面游戏。高对比度的霓虹视觉效果和大型倒计时计时器设计得让房间另一端也能清晰可见。',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
question: '为什么有人抬起手指时倒计时会重置?',
|
|
53
|
+
answer: '这是有意设计的。在手指模式下,只有当所有玩家将手指稳定地放在屏幕上时,倒计时才会进行。如果有人抬起或移动手指,计时器会重置,以确保在选择运行前每个人都已做好准备。这可以防止意外触发。',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
question: '我可以更改音效或将其静音吗?',
|
|
57
|
+
answer: '可以。点击右上角的扬声器图标可以切换音效的开启或关闭。该工具会在倒计时期间播放滴答声,在选定获胜者时播放号角声。',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
question: '如果只有一名玩家会怎样?',
|
|
61
|
+
answer: '在两种模式下,都需要至少两名玩家才能进行选择。在手指模式下,直到检测到至少两根手指时倒计时才会开始。在轮盘模式下,"开始选择"按钮将保持禁用状态,直到您放置至少两个标记。',
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const howTo = [
|
|
66
|
+
{
|
|
67
|
+
name: '放置手指或标记',
|
|
68
|
+
text: '在手指模式下,所有玩家将一根手指放在屏幕上。在轮盘模式下,点击画布上的任意位置来放置玩家标记。',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: '触发选择',
|
|
72
|
+
text: '在手指模式下,保持几秒钟后选择会自动开始。在轮盘模式下,点击"开始选择"按钮进行旋转。',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: '查看获胜者',
|
|
76
|
+
text: '获胜者会以脉冲效果、粒子爆发和音效号角的方式高亮显示。点击"重置"或所有人抬起手指即可重新开始。',
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
export const content: ToolLocaleContent<FirstPlayerSelectorUI> = {
|
|
81
|
+
slug: 'first-player-selector',
|
|
82
|
+
title: '起始玩家选择器:手指选择器与回合轮盘',
|
|
83
|
+
description: '通过这款华丽的手指选择器和交互式回合轮盘,来决定您的桌游中谁先开始。',
|
|
84
|
+
ui,
|
|
85
|
+
seo: [
|
|
86
|
+
{ type: 'title', text: '公平与策略:消除桌游中的先手优势', level: 2 },
|
|
87
|
+
{ type: 'paragraph', html: '在桌面游戏中,决定起始玩家会显著影响游戏动态。在博弈论中,先手优势(FPA)指的是率先行动的玩家相对于对手拥有的统计优势。在策略性国际象棋比赛、战棋游戏以及 Agricola 或 Puerto Rico 等现代德式游戏中,先行动作的玩家可以在对手做出反应之前抢占关键资源或布置棋子。为了减轻FPA并保证公平的竞争环境,一种可靠、无偏的选择方法至关重要。我们的数字起始玩家选择器保证了绝对的随机性,让游戏小组能够立即确定回合顺序并直接开始游戏。' },
|
|
88
|
+
{
|
|
89
|
+
type: 'stats',
|
|
90
|
+
items: [
|
|
91
|
+
{ value: '100% 随机', label: '无偏选择' },
|
|
92
|
+
{ value: '2秒', label: '倒计时触发' },
|
|
93
|
+
{ value: '多点触控', label: '移动端兼容' },
|
|
94
|
+
],
|
|
95
|
+
columns: 3,
|
|
96
|
+
},
|
|
97
|
+
{ type: 'title', text: '桌游规则 vs 数字随机器:触控革命', level: 2 },
|
|
98
|
+
{ type: 'paragraph', html: '许多现代桌游规则书包含主题性的起始玩家规则,例如"最年轻的玩家先开始"、"最后一个去过农场的玩家先开始"或"个子最高的玩家先开始"。虽然初次游戏时很有趣,但这些规则很快变得重复、静态且对固定游戏群体不公平。物理随机化方法如掷骰子或抽牌会增加准备时间,并且容易受到人为偏见或洗牌不彻底的影响。手指选择器机制通过提供一种快速、触摸并保持的随机化方法,在无需额外卡牌或骰子的情况下,几秒钟内选出一位起始玩家,从而解决了这个问题。' },
|
|
99
|
+
{
|
|
100
|
+
type: 'table',
|
|
101
|
+
headers: ['选择方式', '速度与准备', '随机性质量', '最适合场景'],
|
|
102
|
+
rows: [
|
|
103
|
+
['任意规则书规则', '即时,但重复', '首局后随机性为零', '家庭游戏、聚会游戏'],
|
|
104
|
+
['掷骰子比拼', '需要寻找和投掷骰子,容易平局', '随机性高,但多人时繁琐', '战棋游戏、RPG'],
|
|
105
|
+
['抽牌', '需要洗牌和发牌', '随机性好,但需要实体卡牌', '牌库构筑游戏、卡牌游戏'],
|
|
106
|
+
['触控手指选择器', '即时,无需准备,视觉动画', '绝对算法随机性', '所有桌面游戏和桌游'],
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
type: 'tip',
|
|
111
|
+
title: '优化您的多点触控选择器设置',
|
|
112
|
+
html: '为了在手指模式下获得最佳体验,请将平板电脑或智能手机平放在桌子中央。让所有玩家将一根手指清晰地放在画布上。如果有玩家抬起或移动手指,倒计时将重置,防止意外触发选择,确保在挑选器运行前达成绝对共识。',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
type: 'title',
|
|
116
|
+
text: '选择您的工具:触控手指选择器 vs 轮盘旋转',
|
|
117
|
+
level: 3,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
type: 'paragraph',
|
|
121
|
+
html: '根据您的硬件设置和环境,您可以在两种不同的布局模式之间切换,这两种模式旨在最大化屏幕空间和可访问性。',
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
type: 'proscons',
|
|
125
|
+
title: '选择模式对比',
|
|
126
|
+
items: [
|
|
127
|
+
{
|
|
128
|
+
pro: '手指模式提供极快的触觉体验,最多可让10名玩家同时放置手指。',
|
|
129
|
+
con: '需要触摸屏,如智能手机或平板电脑。',
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
pro: '轮盘模式可在任何笔记本电脑、台式机或投影仪屏幕上运行,非常适合虚拟会话和远程游戏之夜。',
|
|
133
|
+
con: '需要手动点击来放置标记,然后再触发旋转。',
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
type: 'title',
|
|
139
|
+
text: '视觉优化:多点触控网络 vs 减速轮盘',
|
|
140
|
+
level: 3,
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
type: 'comparative',
|
|
144
|
+
items: [
|
|
145
|
+
{
|
|
146
|
+
title: '多点触控选择器',
|
|
147
|
+
description: '追踪屏幕上的多个不同触控点。在每个手指周围绘制霓虹连接网络和缩小的倒计时环,最终以庆祝爆发效果收尾。',
|
|
148
|
+
icon: 'mdi:gesture-double-tap',
|
|
149
|
+
highlight: true,
|
|
150
|
+
points: [
|
|
151
|
+
'同步触控追踪',
|
|
152
|
+
'触觉式按压保持检测',
|
|
153
|
+
'壮观的粒子爆炸效果',
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
title: '轮盘旋转器',
|
|
158
|
+
description: '在数学计算的重心周围放置彩色玩家标记。旋转一个彩色扇形轮盘,通过模拟摩擦力自然减速,最终确定获胜者。',
|
|
159
|
+
icon: 'mdi:compass-outline',
|
|
160
|
+
highlight: false,
|
|
161
|
+
points: [
|
|
162
|
+
'基于重心的对齐',
|
|
163
|
+
'基于摩擦的减速物理',
|
|
164
|
+
'通用桌面鼠标支持',
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
columns: 2,
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
type: 'glossary',
|
|
172
|
+
items: [
|
|
173
|
+
{
|
|
174
|
+
term: '先手优势(FPA)',
|
|
175
|
+
definition: '桌游中的回合顺序偏差,起始玩家在行动选择或资源控制方面获得统计上显著的领先优势。',
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
term: '触控选择协议',
|
|
179
|
+
definition: '一种触摸并保持的交互方式,所有参与者将手指放在触摸屏上,直到随机高亮显示一位获胜者。',
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
term: '减速物理',
|
|
183
|
+
definition: '一种数学动画程序,对旋转的轮盘施加摩擦系数以模拟自然惯性并增强期待感。',
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
term: '重心计算',
|
|
187
|
+
definition: '计算分散坐标标记集合的中心点,用作轮盘图形旋转的枢轴。',
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
faq: faq,
|
|
193
|
+
bibliography,
|
|
194
|
+
howTo: howTo,
|
|
195
|
+
schemas: [
|
|
196
|
+
{
|
|
197
|
+
'@context': 'https://schema.org',
|
|
198
|
+
'@type': 'FAQPage',
|
|
199
|
+
'mainEntity': faq.map((f) => ({
|
|
200
|
+
'@type': 'Question',
|
|
201
|
+
'name': f.question,
|
|
202
|
+
'acceptedAnswer': {
|
|
203
|
+
'@type': 'Answer',
|
|
204
|
+
'text': f.answer,
|
|
205
|
+
},
|
|
206
|
+
})),
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
'@context': 'https://schema.org',
|
|
210
|
+
'@type': 'SoftwareApplication',
|
|
211
|
+
'name': '起始玩家选择器与手指选择器',
|
|
212
|
+
'operatingSystem': 'All',
|
|
213
|
+
'applicationCategory': 'UtilitiesApplication',
|
|
214
|
+
'browserRequirements': 'Requires HTML5. Requires JavaScript.',
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
'@context': 'https://schema.org',
|
|
218
|
+
'@type': 'HowTo',
|
|
219
|
+
'name': '如何使用起始玩家选择器',
|
|
220
|
+
'step': howTo.map((h) => ({
|
|
221
|
+
'@type': 'HowToStep',
|
|
222
|
+
'name': h.name,
|
|
223
|
+
'text': h.text,
|
|
224
|
+
})),
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../types';
|
|
2
|
+
import { firstPlayerSelector } from './entry';
|
|
3
|
+
export * from './entry';
|
|
4
|
+
export const FIRST_PLAYER_SELECTOR_TOOL: ToolDefinition = {
|
|
5
|
+
entry: firstPlayerSelector,
|
|
6
|
+
Component: () => import('./component.astro'),
|
|
7
|
+
SEOComponent: () => import('./seo.astro'),
|
|
8
|
+
BibliographyComponent: () => import('./bibliography.astro'),
|
|
9
|
+
};
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type { ContactPoint, Particle, SelectionState } from '../types';
|
|
2
|
+
|
|
3
|
+
interface FrameContext {
|
|
4
|
+
ctx: CanvasRenderingContext2D;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
contacts: ContactPoint[];
|
|
8
|
+
state: SelectionState;
|
|
9
|
+
progress: number;
|
|
10
|
+
wheelAngle: number;
|
|
11
|
+
centroid: { x: number; y: number };
|
|
12
|
+
isLightMode: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ContactDrawCtx {
|
|
16
|
+
ctx: CanvasRenderingContext2D;
|
|
17
|
+
c: ContactPoint;
|
|
18
|
+
state: SelectionState;
|
|
19
|
+
progress: number;
|
|
20
|
+
isLightMode: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface RouletteCtx {
|
|
24
|
+
ctx: CanvasRenderingContext2D;
|
|
25
|
+
contacts: ContactPoint[];
|
|
26
|
+
wheelAngle: number;
|
|
27
|
+
centroid: { x: number; y: number };
|
|
28
|
+
state: SelectionState;
|
|
29
|
+
isLightMode: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class AnimationEffects {
|
|
33
|
+
private particles: Particle[] = [];
|
|
34
|
+
private pulsePhase = 0;
|
|
35
|
+
|
|
36
|
+
public spawnCelebration(x: number, y: number, color: string): void {
|
|
37
|
+
this.particles = [];
|
|
38
|
+
for (let i = 0; i < 80; i++) {
|
|
39
|
+
const angle = Math.random() * Math.PI * 2;
|
|
40
|
+
const speed = 2 + Math.random() * 8;
|
|
41
|
+
this.particles.push({
|
|
42
|
+
x, y,
|
|
43
|
+
vx: Math.cos(angle) * speed,
|
|
44
|
+
vy: Math.sin(angle) * speed,
|
|
45
|
+
radius: 2 + Math.random() * 4,
|
|
46
|
+
color,
|
|
47
|
+
alpha: 1
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public updateAndDraw(fc: FrameContext): void {
|
|
53
|
+
fc.ctx.clearRect(0, 0, fc.width, fc.height);
|
|
54
|
+
this.pulsePhase = (this.pulsePhase + 0.05) % (Math.PI * 2);
|
|
55
|
+
|
|
56
|
+
if (fc.contacts.length >= 2 && fc.state !== 'celebration' && fc.state !== 'selecting') {
|
|
57
|
+
this.drawConnections(fc);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const showRoulette = fc.state === 'selecting' || (fc.state === 'celebration' && fc.centroid.x !== 0);
|
|
61
|
+
if (showRoulette) {
|
|
62
|
+
this.drawRoulette({
|
|
63
|
+
ctx: fc.ctx, contacts: fc.contacts, wheelAngle: fc.wheelAngle,
|
|
64
|
+
centroid: fc.centroid, state: fc.state, isLightMode: fc.isLightMode
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fc.contacts.forEach((c) => {
|
|
69
|
+
this.drawContact({
|
|
70
|
+
ctx: fc.ctx, c, state: fc.state,
|
|
71
|
+
progress: fc.progress, isLightMode: fc.isLightMode
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
this.drawParticles(fc.ctx);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private drawConnections(fc: FrameContext): void {
|
|
79
|
+
fc.ctx.beginPath();
|
|
80
|
+
fc.ctx.strokeStyle = fc.isLightMode ? 'rgba(15, 23, 42, 0.08)' : 'rgba(255, 255, 255, 0.08)';
|
|
81
|
+
fc.ctx.lineWidth = 2;
|
|
82
|
+
for (let i = 0; i < fc.contacts.length; i++) {
|
|
83
|
+
for (let j = i + 1; j < fc.contacts.length; j++) {
|
|
84
|
+
fc.ctx.moveTo(fc.contacts[i]!.x, fc.contacts[i]!.y);
|
|
85
|
+
fc.ctx.lineTo(fc.contacts[j]!.x, fc.contacts[j]!.y);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
fc.ctx.stroke();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private drawContact(dc: ContactDrawCtx): void {
|
|
92
|
+
const isWinner = !!dc.c.isWinner;
|
|
93
|
+
const isCelebration = dc.state === 'celebration';
|
|
94
|
+
const showCountdown = dc.state === 'countdown' && !isCelebration;
|
|
95
|
+
const showWinRing = isWinner && isCelebration;
|
|
96
|
+
|
|
97
|
+
this.saveForLoserInCelebration(dc.ctx, isCelebration, isWinner);
|
|
98
|
+
|
|
99
|
+
const pulseOffset = Math.sin(this.pulsePhase) * 4;
|
|
100
|
+
const currentRadius = 45 + (isWinner ? pulseOffset * 2 : pulseOffset);
|
|
101
|
+
|
|
102
|
+
this.drawGlow(dc.ctx, dc.c, currentRadius, isWinner);
|
|
103
|
+
this.drawInnerCircle(dc.ctx, dc.c, currentRadius, dc.isLightMode);
|
|
104
|
+
|
|
105
|
+
if (showCountdown) {
|
|
106
|
+
this.drawCountdownRing({ ctx: dc.ctx, c: dc.c, radius: currentRadius, progress: dc.progress, isLightMode: dc.isLightMode });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (showWinRing) {
|
|
110
|
+
this.drawWinnerRing(dc.ctx, dc.c, currentRadius);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
this.restoreForLoserInCelebration(dc.ctx, isCelebration, isWinner);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private saveForLoserInCelebration(ctx: CanvasRenderingContext2D, isCelebration: boolean, isWinner: boolean): void {
|
|
117
|
+
if (isCelebration && !isWinner) {
|
|
118
|
+
ctx.save();
|
|
119
|
+
ctx.globalAlpha = 0.15;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private restoreForLoserInCelebration(ctx: CanvasRenderingContext2D, isCelebration: boolean, isWinner: boolean): void {
|
|
124
|
+
if (isCelebration && !isWinner) {
|
|
125
|
+
ctx.restore();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private drawGlow(ctx: CanvasRenderingContext2D, c: ContactPoint, radius: number, isWinner: boolean): void {
|
|
130
|
+
ctx.beginPath();
|
|
131
|
+
ctx.arc(c.x, c.y, radius, 0, Math.PI * 2);
|
|
132
|
+
ctx.fillStyle = c.color;
|
|
133
|
+
ctx.shadowBlur = isWinner ? 30 : 15;
|
|
134
|
+
ctx.shadowColor = c.color;
|
|
135
|
+
ctx.fill();
|
|
136
|
+
ctx.shadowBlur = 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private drawInnerCircle(ctx: CanvasRenderingContext2D, c: ContactPoint, radius: number, isLightMode: boolean): void {
|
|
140
|
+
ctx.beginPath();
|
|
141
|
+
ctx.arc(c.x, c.y, radius - 8, 0, Math.PI * 2);
|
|
142
|
+
ctx.fillStyle = isLightMode ? 'rgba(248, 250, 252, 0.95)' : 'rgba(15, 23, 42, 0.85)';
|
|
143
|
+
ctx.fill();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private drawCountdownRing(dc: { ctx: CanvasRenderingContext2D; c: ContactPoint; radius: number; progress: number; isLightMode: boolean }): void {
|
|
147
|
+
const countdownRadius = dc.radius + 8;
|
|
148
|
+
dc.ctx.beginPath();
|
|
149
|
+
dc.ctx.arc(dc.c.x, dc.c.y, countdownRadius, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * dc.progress);
|
|
150
|
+
dc.ctx.strokeStyle = dc.isLightMode ? '#0f172a' : '#FFFFFF';
|
|
151
|
+
dc.ctx.lineWidth = 4;
|
|
152
|
+
dc.ctx.stroke();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private drawWinnerRing(ctx: CanvasRenderingContext2D, c: ContactPoint, radius: number): void {
|
|
156
|
+
ctx.beginPath();
|
|
157
|
+
ctx.arc(c.x, c.y, radius + 12, 0, Math.PI * 2);
|
|
158
|
+
ctx.strokeStyle = c.color;
|
|
159
|
+
ctx.lineWidth = 2;
|
|
160
|
+
ctx.stroke();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private drawRoulette(rc: RouletteCtx): void {
|
|
164
|
+
if (rc.contacts.length === 0) return;
|
|
165
|
+
|
|
166
|
+
const outerRadius = 160;
|
|
167
|
+
const innerRadius = 30;
|
|
168
|
+
|
|
169
|
+
rc.ctx.save();
|
|
170
|
+
rc.ctx.translate(rc.centroid.x, rc.centroid.y);
|
|
171
|
+
rc.ctx.rotate(rc.wheelAngle);
|
|
172
|
+
|
|
173
|
+
this.drawRouletteSectors(rc, outerRadius);
|
|
174
|
+
this.drawRouletteCenter(rc.ctx, innerRadius, rc.isLightMode);
|
|
175
|
+
|
|
176
|
+
rc.ctx.restore();
|
|
177
|
+
|
|
178
|
+
this.drawPointer(rc.ctx, rc.centroid, outerRadius, rc.isLightMode);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private drawRouletteSectors(rc: RouletteCtx, outerRadius: number): void {
|
|
182
|
+
const numSectors = rc.contacts.length;
|
|
183
|
+
const sectorAngle = (Math.PI * 2) / numSectors;
|
|
184
|
+
|
|
185
|
+
for (let i = 0; i < numSectors; i++) {
|
|
186
|
+
const startA = i * sectorAngle;
|
|
187
|
+
const endA = startA + sectorAngle;
|
|
188
|
+
const contact = rc.contacts[i]!;
|
|
189
|
+
|
|
190
|
+
rc.ctx.globalAlpha = (rc.state === 'celebration' && !contact.isWinner) ? 0.15 : 0.7;
|
|
191
|
+
|
|
192
|
+
rc.ctx.beginPath();
|
|
193
|
+
rc.ctx.moveTo(0, 0);
|
|
194
|
+
rc.ctx.arc(0, 0, outerRadius, startA, endA);
|
|
195
|
+
rc.ctx.closePath();
|
|
196
|
+
rc.ctx.fillStyle = contact.color;
|
|
197
|
+
rc.ctx.fill();
|
|
198
|
+
|
|
199
|
+
rc.ctx.globalAlpha = 1.0;
|
|
200
|
+
rc.ctx.beginPath();
|
|
201
|
+
rc.ctx.moveTo(0, 0);
|
|
202
|
+
rc.ctx.lineTo(Math.cos(startA) * outerRadius, Math.sin(startA) * outerRadius);
|
|
203
|
+
rc.ctx.strokeStyle = rc.isLightMode ? 'rgba(255, 255, 255, 0.6)' : 'rgba(15, 23, 42, 0.5)';
|
|
204
|
+
rc.ctx.lineWidth = 2;
|
|
205
|
+
rc.ctx.stroke();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private drawRouletteCenter(ctx: CanvasRenderingContext2D, innerRadius: number, isLightMode: boolean): void {
|
|
210
|
+
ctx.beginPath();
|
|
211
|
+
ctx.arc(0, 0, innerRadius, 0, Math.PI * 2);
|
|
212
|
+
ctx.fillStyle = isLightMode ? '#F8FAFC' : '#0F172A';
|
|
213
|
+
ctx.fill();
|
|
214
|
+
|
|
215
|
+
ctx.beginPath();
|
|
216
|
+
ctx.arc(0, 0, innerRadius, 0, Math.PI * 2);
|
|
217
|
+
ctx.strokeStyle = isLightMode ? 'rgba(15, 23, 42, 0.1)' : 'rgba(255, 255, 255, 0.2)';
|
|
218
|
+
ctx.lineWidth = 2;
|
|
219
|
+
ctx.stroke();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private drawPointer(ctx: CanvasRenderingContext2D, centroid: { x: number; y: number }, outerRadius: number, isLightMode: boolean): void {
|
|
223
|
+
ctx.beginPath();
|
|
224
|
+
ctx.moveTo(centroid.x, centroid.y - outerRadius - 15);
|
|
225
|
+
ctx.lineTo(centroid.x - 10, centroid.y - outerRadius);
|
|
226
|
+
ctx.lineTo(centroid.x + 10, centroid.y - outerRadius);
|
|
227
|
+
ctx.closePath();
|
|
228
|
+
ctx.fillStyle = isLightMode ? '#0f172a' : '#FFFFFF';
|
|
229
|
+
ctx.shadowBlur = 10;
|
|
230
|
+
ctx.shadowColor = isLightMode ? '#0f172a' : '#FFFFFF';
|
|
231
|
+
ctx.fill();
|
|
232
|
+
ctx.shadowBlur = 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private drawParticles(ctx: CanvasRenderingContext2D): void {
|
|
236
|
+
this.particles.forEach((p) => {
|
|
237
|
+
ctx.beginPath();
|
|
238
|
+
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
|
|
239
|
+
ctx.fillStyle = p.color;
|
|
240
|
+
ctx.globalAlpha = p.alpha;
|
|
241
|
+
ctx.shadowBlur = 8;
|
|
242
|
+
ctx.shadowColor = p.color;
|
|
243
|
+
ctx.fill();
|
|
244
|
+
ctx.shadowBlur = 0;
|
|
245
|
+
ctx.globalAlpha = 1;
|
|
246
|
+
|
|
247
|
+
p.x += p.vx;
|
|
248
|
+
p.y += p.vy;
|
|
249
|
+
p.vx *= 0.96;
|
|
250
|
+
p.vy *= 0.96;
|
|
251
|
+
p.alpha -= 0.015;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
this.particles = this.particles.filter((p) => p.alpha > 0);
|
|
255
|
+
}
|
|
256
|
+
}
|