tavern-harness 0.1.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/README.md +161 -0
- package/README.zh.md +123 -0
- package/bin/cli.js +48 -0
- package/index.html +35 -0
- package/package.json +50 -0
- package/public/favicon.png +0 -0
- package/public/tavern-harness-logo.png +0 -0
- package/sandbox-server.mjs +997 -0
- package/src/App.tsx +268 -0
- package/src/assets/tavern-handbook.md +39 -0
- package/src/components/AchievementModal.tsx +158 -0
- package/src/components/CharactersView.tsx +904 -0
- package/src/components/ChatView.tsx +1639 -0
- package/src/components/ConfirmationDialog.tsx +56 -0
- package/src/components/Dashboard.tsx +180 -0
- package/src/components/DeleteConfirmDialog.tsx +48 -0
- package/src/components/FileDisplayModal.tsx +556 -0
- package/src/components/GameplayDialogs.tsx +191 -0
- package/src/components/NewSessionMenu.tsx +362 -0
- package/src/components/SettingsView.tsx +517 -0
- package/src/components/Sidebar.tsx +392 -0
- package/src/components/StatsView.tsx +189 -0
- package/src/components/Toasts.tsx +20 -0
- package/src/components/WorkspaceFileManagerModal.tsx +216 -0
- package/src/components/shared.tsx +369 -0
- package/src/core/achievements.ts +85 -0
- package/src/core/fileDownload.ts +67 -0
- package/src/core/gameplay.ts +521 -0
- package/src/core/horizontalWheel.ts +93 -0
- package/src/core/i18n.ts +1403 -0
- package/src/core/linkedScroll.ts +236 -0
- package/src/core/markdown.ts +170 -0
- package/src/core/openai.ts +451 -0
- package/src/core/openingGreeting.ts +41 -0
- package/src/core/prompts.ts +254 -0
- package/src/core/proxy.ts +58 -0
- package/src/core/sillyTavernImporter.ts +317 -0
- package/src/core/stats.ts +91 -0
- package/src/core/strings.ts +19 -0
- package/src/core/toolDefinitions.ts +400 -0
- package/src/core/tools/builtinTools.ts +38 -0
- package/src/core/tools/generatedSkillExecutor.ts +706 -0
- package/src/core/tools/generatedWorkspace.ts +13 -0
- package/src/core/tools/toolExecutor.ts +718 -0
- package/src/core/turnLoop.ts +211 -0
- package/src/db/database.ts +647 -0
- package/src/main.tsx +23 -0
- package/src/store/store.ts +2419 -0
- package/src/theme/chat.css +1227 -0
- package/src/theme/global.css +1377 -0
- package/src/theme/theme.ts +143 -0
- package/src/theme/views.css +1430 -0
- package/src/types/models.ts +364 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +273 -0
package/src/App.tsx
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useStore } from './store/store';
|
|
3
|
+
import { Sidebar } from './components/Sidebar';
|
|
4
|
+
import { ChatView, ChatInput, MessageMenu, TurnQueuePanel } from './components/ChatView';
|
|
5
|
+
import { CharactersView } from './components/CharactersView';
|
|
6
|
+
import { SettingsView } from './components/SettingsView';
|
|
7
|
+
import { StatsView } from './components/StatsView';
|
|
8
|
+
import { Dashboard } from './components/Dashboard';
|
|
9
|
+
import { NewSessionMenu } from './components/NewSessionMenu';
|
|
10
|
+
import { ConfirmationDialog } from './components/ConfirmationDialog';
|
|
11
|
+
import { FileDisplayModal } from './components/FileDisplayModal';
|
|
12
|
+
import { WorkspaceFileManagerModal } from './components/WorkspaceFileManagerModal';
|
|
13
|
+
import { Toasts } from './components/Toasts';
|
|
14
|
+
import { AchievementModal, showAchievementUnlock } from './components/AchievementModal';
|
|
15
|
+
import { GameplayExportModal, GameplayImportModal } from './components/GameplayDialogs';
|
|
16
|
+
import { Icon, SessionVisual, Modal } from './components/shared';
|
|
17
|
+
import type { NpcCharacter } from './types/models';
|
|
18
|
+
import { registerUnlockDispatcher } from './core/achievements';
|
|
19
|
+
import { useT } from './core/i18n';
|
|
20
|
+
import './theme/chat.css';
|
|
21
|
+
import './theme/views.css';
|
|
22
|
+
|
|
23
|
+
export default function App() {
|
|
24
|
+
const initialized = useStore((s) => s.initialized);
|
|
25
|
+
const init = useStore((s) => s.init);
|
|
26
|
+
const activeView = useStore((s) => s.activeView);
|
|
27
|
+
const activeSessionId = useStore((s) => s.activeSessionId);
|
|
28
|
+
const sessions = useStore((s) => s.sessions);
|
|
29
|
+
const messages = useStore((s) => s.messages);
|
|
30
|
+
const contextCompressions = useStore((s) => s.contextCompressions);
|
|
31
|
+
const participants = useStore((s) => s.participants);
|
|
32
|
+
const isStreaming = useStore((s) => s.streaming.sessionId != null);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
registerUnlockDispatcher(async (achievement, total) => {
|
|
36
|
+
await useStore.getState().refreshAchievements();
|
|
37
|
+
showAchievementUnlock(achievement, total);
|
|
38
|
+
});
|
|
39
|
+
return () => registerUnlockDispatcher(() => {});
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
init();
|
|
44
|
+
}, [init]);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!isStreaming) return;
|
|
48
|
+
const confirmLeave = (event: BeforeUnloadEvent) => {
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
event.returnValue = '';
|
|
51
|
+
};
|
|
52
|
+
window.addEventListener('beforeunload', confirmLeave);
|
|
53
|
+
return () => window.removeEventListener('beforeunload', confirmLeave);
|
|
54
|
+
}, [isStreaming]);
|
|
55
|
+
|
|
56
|
+
const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null;
|
|
57
|
+
const sessionMessages = activeSessionId != null ? (messages[activeSessionId] ?? []) : [];
|
|
58
|
+
const sessionParticipants = activeSessionId != null ? (participants[activeSessionId] ?? []) : [];
|
|
59
|
+
const isStreamingSession = useStore((s) => s.streaming.sessionId === activeSessionId);
|
|
60
|
+
|
|
61
|
+
if (!initialized) {
|
|
62
|
+
return <div className="tav-bg" />;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<>
|
|
67
|
+
<div className="tav-bg" />
|
|
68
|
+
<div className="app-shell">
|
|
69
|
+
<Sidebar />
|
|
70
|
+
<main className="main-area">
|
|
71
|
+
{activeView === 'chat' && activeSession && (
|
|
72
|
+
<>
|
|
73
|
+
<SessionHeader session={activeSession} participants={sessionParticipants} />
|
|
74
|
+
<div className="chat-main-row">
|
|
75
|
+
<div className="chat-main-col">
|
|
76
|
+
<ChatView
|
|
77
|
+
session={activeSession}
|
|
78
|
+
messages={sessionMessages}
|
|
79
|
+
participants={sessionParticipants}
|
|
80
|
+
compressions={contextCompressions[activeSession.id!] ?? []}
|
|
81
|
+
streaming={isStreamingSession}
|
|
82
|
+
/>
|
|
83
|
+
<ChatInput sessionId={activeSession.id!} />
|
|
84
|
+
</div>
|
|
85
|
+
<TurnQueuePanel session={activeSession} participants={sessionParticipants} />
|
|
86
|
+
</div>
|
|
87
|
+
</>
|
|
88
|
+
)}
|
|
89
|
+
{activeView === 'chat' && !activeSession && <Dashboard />}
|
|
90
|
+
{activeView === 'characters' && <CharactersView />}
|
|
91
|
+
{activeView === 'settings' && <SettingsView />}
|
|
92
|
+
{activeView === 'stats' && <StatsView />}
|
|
93
|
+
</main>
|
|
94
|
+
</div>
|
|
95
|
+
<ConfirmationDialog />
|
|
96
|
+
<MessageMenu />
|
|
97
|
+
<Toasts />
|
|
98
|
+
<AchievementModal />
|
|
99
|
+
<FileDisplayModal />
|
|
100
|
+
{/* 游戏导出 / 导入的全局入口(会话右键菜单 / 侧边栏导入按钮共用) */}
|
|
101
|
+
<GameplayEntryDialogs />
|
|
102
|
+
</>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 全局注册的游戏导出/导入弹窗(供 Sidebar 与 SessionHeader 之外的入口使用) */
|
|
107
|
+
function GameplayEntryDialogs() {
|
|
108
|
+
const [exportTarget, setExportTarget] = useState<{ sessionId: number; includeHistory: boolean } | null>(null);
|
|
109
|
+
const [importOpen, setImportOpen] = useState(false);
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const onExport = (e: CustomEvent<{ sessionId: number; includeHistory: boolean }>) => {
|
|
113
|
+
setImportOpen(false);
|
|
114
|
+
setExportTarget(e.detail);
|
|
115
|
+
};
|
|
116
|
+
const onImport = () => {
|
|
117
|
+
setExportTarget(null);
|
|
118
|
+
setImportOpen(true);
|
|
119
|
+
};
|
|
120
|
+
window.addEventListener('th-gameplay-export', onExport as EventListener);
|
|
121
|
+
window.addEventListener('th-gameplay-import', onImport as EventListener);
|
|
122
|
+
return () => {
|
|
123
|
+
window.removeEventListener('th-gameplay-export', onExport as EventListener);
|
|
124
|
+
window.removeEventListener('th-gameplay-import', onImport as EventListener);
|
|
125
|
+
};
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<>
|
|
130
|
+
{exportTarget && (
|
|
131
|
+
<GameplayExportModal
|
|
132
|
+
sessionId={exportTarget.sessionId}
|
|
133
|
+
defaultIncludeHistory={exportTarget.includeHistory}
|
|
134
|
+
onClose={() => setExportTarget(null)}
|
|
135
|
+
/>
|
|
136
|
+
)}
|
|
137
|
+
{importOpen && <GameplayImportModal onClose={() => setImportOpen(false)} />}
|
|
138
|
+
</>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function SessionHeader({
|
|
143
|
+
session,
|
|
144
|
+
participants,
|
|
145
|
+
}: {
|
|
146
|
+
session: NonNullable<ReturnType<typeof useStore.getState>['sessions'][number]>;
|
|
147
|
+
participants: ReturnType<typeof useStore.getState>['participants'][number];
|
|
148
|
+
}) {
|
|
149
|
+
const npcs = useStore((s) => s.npcs);
|
|
150
|
+
const addToast = useStore((s) => s.addToast);
|
|
151
|
+
const deleteSession = useStore((s) => s.deleteSession);
|
|
152
|
+
const resetSessionConversation = useStore((s) => s.resetSessionConversation);
|
|
153
|
+
const t = useT();
|
|
154
|
+
const [showEdit, setShowEdit] = useState(false);
|
|
155
|
+
const [confirmReset, setConfirmReset] = useState(false);
|
|
156
|
+
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
157
|
+
const [exportOpen, setExportOpen] = useState(false);
|
|
158
|
+
const [showWorkspace, setShowWorkspace] = useState(false);
|
|
159
|
+
|
|
160
|
+
const modeLabel = session.mode === 'STANDARD' ? t('header.modeStandard') : session.mode === 'NPC' ? t('header.modeNpc') : t('header.modeGroup');
|
|
161
|
+
const npcRef = session.associatedId ? npcs.find((n) => n.id === session.associatedId) : null;
|
|
162
|
+
const usesPublicWorkspace = session.mode === 'NPC' && npcRef?.isBuiltIn === true;
|
|
163
|
+
const groupNpcs = participants.filter((p) => p.kind === 'NPC').map((p) => npcs.find((n) => n.id === p.npcId)).filter(Boolean);
|
|
164
|
+
const groupMemberAvatars = (groupNpcs as NpcCharacter[]).slice(0, 4).map((n) => ({
|
|
165
|
+
name: n.name,
|
|
166
|
+
colorOrdinal: n.avatarColorOrdinal,
|
|
167
|
+
imageUrl: n.avatarDataUrl,
|
|
168
|
+
}));
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<div className="chat-header">
|
|
172
|
+
<SessionVisual mode={session.mode} npcName={npcRef?.name ?? undefined} hue={npcRef?.avatarColorOrdinal ?? 0} imageUrl={npcRef?.avatarDataUrl} members={session.mode === 'GROUP' ? groupMemberAvatars : undefined} size="lg" />
|
|
173
|
+
<div className="tinfo">
|
|
174
|
+
<div className="ttitle">{session.title}</div>
|
|
175
|
+
<div className="tsub">
|
|
176
|
+
<span className="tsub-mode">{modeLabel}</span>
|
|
177
|
+
{session.mode === 'GROUP' && (
|
|
178
|
+
<span className="tsub-members">
|
|
179
|
+
{groupNpcs.map((n) => n!.name).join(' · ')}
|
|
180
|
+
</span>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
<div className="chat-actions">
|
|
185
|
+
<button className="btn-ghost icon-tooltip" title={t(usesPublicWorkspace ? 'header.workspaceRootHint' : 'header.workspaceHint')} onClick={() => setShowWorkspace(true)}>
|
|
186
|
+
<Icon name="folder" size={17} />
|
|
187
|
+
</button>
|
|
188
|
+
<button className="btn-ghost icon-tooltip" title={t('header.editSessionTip')} onClick={() => setShowEdit(true)}>
|
|
189
|
+
<Icon name="pencil" size={17} />
|
|
190
|
+
</button>
|
|
191
|
+
<button className="btn-ghost icon-tooltip" title={t('header.resetSessionTip')} onClick={() => setConfirmReset(true)}>
|
|
192
|
+
<Icon name="refresh" size={17} />
|
|
193
|
+
</button>
|
|
194
|
+
<button className="btn-ghost icon-tooltip" title={t('header.exportGameplayTip')} onClick={() => setExportOpen(true)}>
|
|
195
|
+
<Icon name="share" size={17} />
|
|
196
|
+
</button>
|
|
197
|
+
<button className="btn-ghost icon-tooltip danger" title={t('header.deleteTip')} onClick={() => setConfirmDelete(true)}>
|
|
198
|
+
<Icon name="trash" size={17} />
|
|
199
|
+
</button>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
{showEdit && <NewSessionMenu editingSession={session} onClose={() => setShowEdit(false)} />}
|
|
203
|
+
|
|
204
|
+
{showWorkspace && (
|
|
205
|
+
<WorkspaceFileManagerModal sessionId={session.id!} onClose={() => setShowWorkspace(false)} />
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
{exportOpen && (
|
|
209
|
+
<GameplayExportModal
|
|
210
|
+
sessionId={session.id!}
|
|
211
|
+
defaultIncludeHistory={true}
|
|
212
|
+
onClose={() => setExportOpen(false)}
|
|
213
|
+
/>
|
|
214
|
+
)}
|
|
215
|
+
|
|
216
|
+
{confirmReset && (
|
|
217
|
+
<Modal onClose={() => setConfirmReset(false)} width="min(400px, calc(100vw - 32px))">
|
|
218
|
+
<div className="modal-head">
|
|
219
|
+
<span style={{ fontWeight: 800, fontSize: 15 }}>{t('header.resetSessionTitle')}</span>
|
|
220
|
+
<button className="icon-btn" onClick={() => setConfirmReset(false)}><Icon name="x" /></button>
|
|
221
|
+
</div>
|
|
222
|
+
<div className="modal-body">
|
|
223
|
+
{t('header.resetSessionConfirm', { title: session.title })}
|
|
224
|
+
</div>
|
|
225
|
+
<div className="modal-foot">
|
|
226
|
+
<button className="btn" onClick={() => setConfirmReset(false)}>{t('common.cancel')}</button>
|
|
227
|
+
<button
|
|
228
|
+
className="btn btn-primary"
|
|
229
|
+
onClick={async () => {
|
|
230
|
+
await resetSessionConversation(session.id!);
|
|
231
|
+
setConfirmReset(false);
|
|
232
|
+
addToast(t('toast.sessionReset'));
|
|
233
|
+
}}
|
|
234
|
+
>
|
|
235
|
+
<Icon name="refresh" size={13} /> {t('header.resetSessionAction')}
|
|
236
|
+
</button>
|
|
237
|
+
</div>
|
|
238
|
+
</Modal>
|
|
239
|
+
)}
|
|
240
|
+
|
|
241
|
+
{confirmDelete && (
|
|
242
|
+
<Modal onClose={() => setConfirmDelete(false)} width="min(400px, calc(100vw - 32px))">
|
|
243
|
+
<div className="modal-head">
|
|
244
|
+
<span style={{ fontWeight: 800, fontSize: 15 }}>{t('header.deleteTitle')}</span>
|
|
245
|
+
<button className="icon-btn" onClick={() => setConfirmDelete(false)}><Icon name="x" /></button>
|
|
246
|
+
</div>
|
|
247
|
+
<div className="modal-body">
|
|
248
|
+
{t('header.deleteConfirm', { title: session.title })}
|
|
249
|
+
</div>
|
|
250
|
+
<div className="modal-foot">
|
|
251
|
+
<button className="btn" onClick={() => setConfirmDelete(false)}>{t('common.cancel')}</button>
|
|
252
|
+
<button
|
|
253
|
+
className="btn btn-danger"
|
|
254
|
+
onClick={async () => {
|
|
255
|
+
await deleteSession(session.id!);
|
|
256
|
+
setConfirmDelete(false);
|
|
257
|
+
addToast(t('toast.sessionDeleted'));
|
|
258
|
+
}}
|
|
259
|
+
>
|
|
260
|
+
<Icon name="trash" size={13} /> {t('common.delete')}
|
|
261
|
+
</button>
|
|
262
|
+
</div>
|
|
263
|
+
</Modal>
|
|
264
|
+
)}
|
|
265
|
+
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Tavern Operations Handbook (Essentials)
|
|
2
|
+
|
|
3
|
+
## Service Principles
|
|
4
|
+
|
|
5
|
+
- **Understand before acting**: Confirm the goal, current state, constraints, and acceptance criteria before making changes. Do not modify content based on assumptions.
|
|
6
|
+
- **Minimal yet complete**: Change only what is necessary to meet the requirement, while fixing issues directly caused by the change.
|
|
7
|
+
- **State uncertainty clearly**: When information is insufficient, rules are unclear, or several reasonable options exist, state the assumptions and ask for a decision.
|
|
8
|
+
|
|
9
|
+
## Standard Workflow
|
|
10
|
+
|
|
11
|
+
1. **Clarify the requirement**
|
|
12
|
+
- Restate the outcome the guest wants to achieve.
|
|
13
|
+
- Identify inputs, outputs, scope, compatibility requirements, and completion criteria.
|
|
14
|
+
- If a key decision would substantially affect implementation, provide clear options and wait for a choice.
|
|
15
|
+
|
|
16
|
+
2. **Understand the current state**
|
|
17
|
+
- Review the relevant characters, World Info entries, skill definitions, and configuration.
|
|
18
|
+
- Identify affected files, behaviors, and potential risks. Do not treat unverified assumptions as facts.
|
|
19
|
+
|
|
20
|
+
3. **Plan the solution**
|
|
21
|
+
- Choose the smallest solution that works within the tavern's current capabilities.
|
|
22
|
+
- Define the required changes, expected behavior, and validation approach.
|
|
23
|
+
- When multiple approaches are reasonable, explain the trade-offs—cost, risk, maintainability, performance, and compatibility—and let the guest decide.
|
|
24
|
+
|
|
25
|
+
4. **Implement and validate**
|
|
26
|
+
- Make precise changes only to relevant content; avoid incidental, unrelated changes.
|
|
27
|
+
- Run the smallest test or check that covers the change first, expanding validation only when needed.
|
|
28
|
+
- If validation fails, accurately report the failed command, relevant output, impact, and recommended next steps.
|
|
29
|
+
|
|
30
|
+
5. **Close out: delivery summary**
|
|
31
|
+
- Briefly state what was completed, why it was done that way, and what was validated.
|
|
32
|
+
- Link relevant files or symbols and disclose anything not yet validated and any known limitations.
|
|
33
|
+
|
|
34
|
+
## Common Scenarios and Considerations
|
|
35
|
+
|
|
36
|
+
### Game Creation
|
|
37
|
+
1. **Create the minimum number of skills**: Assess responsibilities against the user's needs. If one skill can accomplish the goal, do not split it into or add multiple redundant skills.
|
|
38
|
+
2. **Document skill usage and workflows clearly**: In both World Info and the character persona, clearly record each skill's trigger conditions and usage steps so the character can invoke it as required.
|
|
39
|
+
3. **Verify character skill equipment**: After creating or configuring a game, confirm that each relevant character has every skill required by the workflow. In particular, verify that relevant built-in skills are enabled.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import type { AchievementDef } from '../core/achievements';
|
|
4
|
+
import { useT, currentLocale } from '../core/i18n';
|
|
5
|
+
import { idToKey } from '../core/strings';
|
|
6
|
+
|
|
7
|
+
// ============================================================
|
|
8
|
+
// 成就解锁庆祝弹窗(奖杯 + 撒花)
|
|
9
|
+
// 全局单例:core 层解锁成就时通过 showAchievementUnlock 触发
|
|
10
|
+
// ============================================================
|
|
11
|
+
|
|
12
|
+
interface QueueItem {
|
|
13
|
+
id: number;
|
|
14
|
+
ach: AchievementDef;
|
|
15
|
+
total: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let seq = 0;
|
|
19
|
+
let currentItem: QueueItem | null = null;
|
|
20
|
+
const pendingQueue: QueueItem[] = [];
|
|
21
|
+
const listeners = new Set<() => void>();
|
|
22
|
+
|
|
23
|
+
function notify() {
|
|
24
|
+
listeners.forEach((l) => l());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 展示成就解锁弹窗(core 层调用;同一时刻只展示一个,其余排队) */
|
|
28
|
+
export function showAchievementUnlock(ach: AchievementDef, total: number): void {
|
|
29
|
+
const item: QueueItem = { id: ++seq, ach, total };
|
|
30
|
+
if (currentItem) {
|
|
31
|
+
pendingQueue.push(item);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
currentItem = item;
|
|
35
|
+
notify();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function next(): void {
|
|
39
|
+
currentItem = pendingQueue.shift() ?? null;
|
|
40
|
+
notify();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const CONFETTI_COLORS = ['#ff5e7d', '#ffb84d', '#53d769', '#42a5f5', '#c56cf0', '#ffd166', '#7f9ad9'];
|
|
44
|
+
|
|
45
|
+
interface ConfettiPiece {
|
|
46
|
+
id: number;
|
|
47
|
+
left: number; // vw
|
|
48
|
+
delay: number; // s
|
|
49
|
+
duration: number; // s
|
|
50
|
+
size: number; // px
|
|
51
|
+
color: string;
|
|
52
|
+
rotate: number;
|
|
53
|
+
drift: number;
|
|
54
|
+
round: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function makeConfetti(count: number): ConfettiPiece[] {
|
|
58
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
59
|
+
id: i,
|
|
60
|
+
left: Math.random() * 100,
|
|
61
|
+
delay: Math.random() * 0.9 + 0.35,
|
|
62
|
+
duration: 2.6 + Math.random() * 1.8,
|
|
63
|
+
size: 6 + Math.random() * 7,
|
|
64
|
+
color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
|
|
65
|
+
rotate: Math.random() * 360,
|
|
66
|
+
drift: (Math.random() - 0.5) * 120,
|
|
67
|
+
round: Math.random() < 0.3,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function AchievementCelebration() {
|
|
72
|
+
const [, force] = useState(0);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
listeners.add(() => force((n) => n + 1));
|
|
75
|
+
return () => {
|
|
76
|
+
listeners.delete(() => force((n) => n + 1));
|
|
77
|
+
};
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
return currentItem ? <CelebrationOverlay key={currentItem.id} item={currentItem} /> : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function CelebrationOverlay({ item }: { item: QueueItem }) {
|
|
84
|
+
const t = useT();
|
|
85
|
+
const confetti = useMemo(() => makeConfetti(90), [item.id]);
|
|
86
|
+
const [leaving, setLeaving] = useState(false);
|
|
87
|
+
const closeTimer = useRef<number | null>(null);
|
|
88
|
+
|
|
89
|
+
const dismiss = (queueNext = true) => {
|
|
90
|
+
setLeaving(true);
|
|
91
|
+
window.setTimeout(() => {
|
|
92
|
+
if (queueNext) {
|
|
93
|
+
next();
|
|
94
|
+
} else {
|
|
95
|
+
// 关闭整个展示链(清空队列)
|
|
96
|
+
pendingQueue.length = 0;
|
|
97
|
+
currentItem = null;
|
|
98
|
+
next();
|
|
99
|
+
}
|
|
100
|
+
}, 260);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
closeTimer.current = window.setTimeout(() => dismiss(true), 5800);
|
|
105
|
+
return () => {
|
|
106
|
+
if (closeTimer.current != null) window.clearTimeout(closeTimer.current);
|
|
107
|
+
};
|
|
108
|
+
}, [item.id]);
|
|
109
|
+
|
|
110
|
+
const fmt = (n: number) => n.toLocaleString(currentLocale());
|
|
111
|
+
|
|
112
|
+
return createPortal(
|
|
113
|
+
<>
|
|
114
|
+
<div className="ach-overlay" onClick={() => dismiss(true)} />
|
|
115
|
+
<div className="ach-celebration">
|
|
116
|
+
<div className={`ach-rain ${leaving ? 'leave' : ''}`}>
|
|
117
|
+
{confetti.map((c) => (
|
|
118
|
+
<i
|
|
119
|
+
key={c.id}
|
|
120
|
+
className={c.round ? 'round' : ''}
|
|
121
|
+
style={{
|
|
122
|
+
left: `${c.left}vw`,
|
|
123
|
+
width: c.size,
|
|
124
|
+
height: c.round ? c.size : c.size * 1.45,
|
|
125
|
+
background: c.color,
|
|
126
|
+
animationDelay: `${c.delay}s`,
|
|
127
|
+
animationDuration: `${c.duration}s`,
|
|
128
|
+
['--drift' as string]: `${c.drift}px`,
|
|
129
|
+
['--rot' as string]: `${c.rotate}deg`,
|
|
130
|
+
}}
|
|
131
|
+
/>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
<div className={`ach-trophy ${leaving ? 'leave' : ''}`}>
|
|
135
|
+
<div className="ach-trophy-badge">{item.ach.icon}</div>
|
|
136
|
+
<div className="ach-trophy-glow" />
|
|
137
|
+
</div>
|
|
138
|
+
<div className={`ach-caption ${leaving ? 'leave' : ''}`}>
|
|
139
|
+
<div className="ach-caption-title">{t('achModal.title')}</div>
|
|
140
|
+
<div className="ach-caption-name">{t(`ach.${idToKey(item.ach.id)}.name`)}</div>
|
|
141
|
+
<div className="ach-caption-desc">
|
|
142
|
+
{t(`ach.${idToKey(item.ach.id)}.desc`)}
|
|
143
|
+
<br />
|
|
144
|
+
<span className="ach-caption-total">{t('achModal.total', { n: fmt(item.total) })}</span>
|
|
145
|
+
</div>
|
|
146
|
+
<button className="btn btn-sm" onClick={() => dismiss(true)}>
|
|
147
|
+
{t('achModal.accept')}
|
|
148
|
+
</button>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</>,
|
|
152
|
+
document.body
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function AchievementModal() {
|
|
157
|
+
return <AchievementCelebration />;
|
|
158
|
+
}
|