@mhmttosun/dnd 0.0.0-stage → 1.1.1
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/LICENSE +21 -0
- package/README.md +327 -2
- package/css/answer-key.css +200 -0
- package/css/editor.css +1797 -0
- package/css/player.css +782 -0
- package/css/stage.css +490 -0
- package/dist/dnd-player-element.js +1758 -0
- package/dist/dnd.js +2041 -0
- package/dist/editor.css +3278 -0
- package/dist/editor.d.ts +23 -0
- package/dist/editor.js +8506 -0
- package/dist/index.d.ts +102 -0
- package/dist/player.css +782 -0
- package/dist/player.d.ts +1 -0
- package/dist/player.js +2041 -0
- package/dist/player.umd.cjs +1758 -0
- package/index.html +203 -0
- package/js/app.js +188 -0
- package/js/core/AnswerKeyManager.js +494 -0
- package/js/core/EditorCore.js +220 -0
- package/js/core/HistoryManager.js +194 -0
- package/js/core/LayerManager.js +86 -0
- package/js/core/ObjectManager.js +468 -0
- package/js/core/SelectionManager.js +459 -0
- package/js/core/StageManager.js +295 -0
- package/js/data/SampleProjects.js +883 -0
- package/js/engine/TransformEngine.js +632 -0
- package/js/engine/ValidationEngine.js +147 -0
- package/js/player/ActivityEngine.js +1272 -0
- package/js/player/ExportManager.js +820 -0
- package/js/ui/MiniToolbar.js +1546 -0
- package/js/ui/PropertyPanel.js +776 -0
- package/js/ui/UIManager.js +596 -0
- package/js/utils/AudioSynthesizer.js +111 -0
- package/package.json +69 -4
- package/src/ActivityPlayer.js +309 -0
- package/src/DNDEditor.js +217 -0
- package/src/components/DNDPlayerElement.js +191 -0
- package/src/index.js +12 -0
- package/src/player.js +13 -0
package/dist/player.js
ADDED
|
@@ -0,0 +1,2041 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mhmttosun/dnd - Player Bundle (ES Module)
|
|
3
|
+
* Etkinlik oynatıcı motoru, ActivityPlayer sarmalayıcısı ve <dnd-player> Web Component.
|
|
4
|
+
* Lisans: MIT (c) 2026 mhmttosun
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Global çevre güvencesi
|
|
8
|
+
const windowObj = typeof window !== 'undefined' ? window : {};
|
|
9
|
+
windowObj.DND = windowObj.DND || {};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* AudioSynthesizer - Tarayıcı Web Audio API kullanarak harici ses dosyalarına
|
|
13
|
+
* ihtiyaç duymadan temiz, profesyonel ses efektleri üretir.
|
|
14
|
+
* Tamamen çevrimdışı çalışır.
|
|
15
|
+
*/
|
|
16
|
+
class AudioSynthesizer {
|
|
17
|
+
constructor() {
|
|
18
|
+
this.ctx = null;
|
|
19
|
+
this.enabled = true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
init() {
|
|
23
|
+
if (!this.ctx && (window.AudioContext || window.webkitAudioContext)) {
|
|
24
|
+
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
|
25
|
+
this.ctx = new AudioCtx();
|
|
26
|
+
}
|
|
27
|
+
if (this.ctx && this.ctx.state === 'suspended') {
|
|
28
|
+
this.ctx.resume();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
setEnabled(val) {
|
|
33
|
+
this.enabled = !!val;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
playTone(freq, type, duration, delay = 0, gainLevel = 0.15) {
|
|
37
|
+
if (!this.enabled) return;
|
|
38
|
+
try {
|
|
39
|
+
this.init();
|
|
40
|
+
if (!this.ctx) return;
|
|
41
|
+
|
|
42
|
+
const startTime = this.ctx.currentTime + delay;
|
|
43
|
+
const osc = this.ctx.createOscillator();
|
|
44
|
+
const gain = this.ctx.createGain();
|
|
45
|
+
|
|
46
|
+
osc.type = type;
|
|
47
|
+
osc.frequency.setValueAtTime(freq, startTime);
|
|
48
|
+
|
|
49
|
+
gain.gain.setValueAtTime(gainLevel, startTime);
|
|
50
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, startTime + duration);
|
|
51
|
+
|
|
52
|
+
osc.connect(gain);
|
|
53
|
+
gain.connect(this.ctx.destination);
|
|
54
|
+
|
|
55
|
+
osc.start(startTime);
|
|
56
|
+
osc.stop(startTime + duration);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// Ses hatası sessizce geçilir
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Doğru cevap sesi: Neşeli, yükselen majör akor (C5, E5, G5)
|
|
63
|
+
playCorrect() {
|
|
64
|
+
if (!this.enabled) return;
|
|
65
|
+
this.playTone(523.25, 'triangle', 0.12, 0, 0.18); // C5
|
|
66
|
+
this.playTone(659.25, 'triangle', 0.14, 0.08, 0.18); // E5
|
|
67
|
+
this.playTone(783.99, 'triangle', 0.25, 0.16, 0.22); // G5
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Yanlış cevap sesi: Yumuşak alçalan uyarı tonu
|
|
71
|
+
playWrong() {
|
|
72
|
+
if (!this.enabled) return;
|
|
73
|
+
this.playTone(320, 'sine', 0.12, 0, 0.15);
|
|
74
|
+
this.playTone(260, 'sine', 0.22, 0.09, 0.15);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Nesneyi eline alma / tıklama çıt sesi
|
|
78
|
+
playPickup() {
|
|
79
|
+
if (!this.enabled) return;
|
|
80
|
+
this.playTone(480, 'sine', 0.05, 0, 0.08);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Nesneyi bırakma sesi
|
|
84
|
+
playDrop() {
|
|
85
|
+
if (!this.enabled) return;
|
|
86
|
+
this.playTone(360, 'sine', 0.07, 0, 0.1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Etkinlik tamamlandığında kutlama fanfarı
|
|
90
|
+
playComplete() {
|
|
91
|
+
if (!this.enabled) return;
|
|
92
|
+
const notes = [
|
|
93
|
+
{ f: 523.25, d: 0.12, t: 0 }, // C5
|
|
94
|
+
{ f: 659.25, d: 0.12, t: 0.1 }, // E5
|
|
95
|
+
{ f: 783.99, d: 0.14, t: 0.2 }, // G5
|
|
96
|
+
{ f: 1046.50, d: 0.45, t: 0.32 } // C6
|
|
97
|
+
];
|
|
98
|
+
notes.forEach(n => {
|
|
99
|
+
this.playTone(n.f, 'triangle', n.d, n.t, 0.2);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Web Audio Context kapatma ve bellek temizliği (Lifecycle Destroy)
|
|
104
|
+
close() {
|
|
105
|
+
if (this.ctx && typeof this.ctx.close === 'function') {
|
|
106
|
+
try {
|
|
107
|
+
this.ctx.close();
|
|
108
|
+
} catch (e) {}
|
|
109
|
+
this.ctx = null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
windowObj.DND.AudioSynthesizer = AudioSynthesizer;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* ValidationEngine - Proje JSON verisi, cevap anahtarı ve nesne tutarlılığı
|
|
118
|
+
* doğrulamalarını gerçekleştirir. Hatalı ve eksik durumları tespit eder.
|
|
119
|
+
*/
|
|
120
|
+
class ValidationEngine {
|
|
121
|
+
static get VALID_ACTIVITY_TYPES() {
|
|
122
|
+
return ['image-labeling', 'matching', 'categorization', 'sequencing'];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
static get VALID_ROLES() {
|
|
126
|
+
return ['decorative', 'draggable', 'target', 'category', 'sequence_slot'];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Bir projenin şemasını doğrular
|
|
131
|
+
* @param {Object} projectData
|
|
132
|
+
* @returns {{ valid: boolean, errors: string[], warnings: string[] }}
|
|
133
|
+
*/
|
|
134
|
+
static validateProject(projectData) {
|
|
135
|
+
const errors = [];
|
|
136
|
+
const warnings = [];
|
|
137
|
+
|
|
138
|
+
if (!projectData || typeof projectData !== 'object') {
|
|
139
|
+
return { valid: false, errors: ['Proje verisi geçerli bir JSON nesnesi değil.'], warnings };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Sürüm kontrolü
|
|
143
|
+
if (!projectData.version) {
|
|
144
|
+
warnings.push('Proje sürüm bilgisi bulunamadı, varsayılan sürüm (1.0) kabul edildi.');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Proje başlığı ve türü
|
|
148
|
+
if (!projectData.project || typeof projectData.project !== 'object') {
|
|
149
|
+
errors.push('Proje meta verisi (project alanı) eksik.');
|
|
150
|
+
} else {
|
|
151
|
+
if (!projectData.project.name || typeof projectData.project.name !== 'string') {
|
|
152
|
+
warnings.push('Proje adı belirtilmemiş.');
|
|
153
|
+
}
|
|
154
|
+
if (!this.VALID_ACTIVITY_TYPES.includes(projectData.project.activityType)) {
|
|
155
|
+
errors.push(`Geçersiz etkinlik türü: ${projectData.project.activityType}. Desteklenenler: ${this.VALID_ACTIVITY_TYPES.join(', ')}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Sahne (Stage) kontrolleri
|
|
160
|
+
if (!projectData.stage || typeof projectData.stage !== 'object') {
|
|
161
|
+
errors.push('Sahne ayarları (stage alanı) eksik.');
|
|
162
|
+
} else {
|
|
163
|
+
if (typeof projectData.stage.width !== 'number' || projectData.stage.width <= 0) {
|
|
164
|
+
errors.push('Sahne genişliği pozitif bir sayı olmalıdır.');
|
|
165
|
+
}
|
|
166
|
+
if (typeof projectData.stage.height !== 'number' || projectData.stage.height <= 0) {
|
|
167
|
+
errors.push('Sahne yüksekliği pozitif bir sayı olmalıdır.');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Nesneler (Objects) kontrolleri
|
|
172
|
+
if (!Array.isArray(projectData.objects)) {
|
|
173
|
+
errors.push('Nesneler listesi (objects alanı) bir dizi olmalıdır.');
|
|
174
|
+
} else {
|
|
175
|
+
const idSet = new Set();
|
|
176
|
+
let draggablesCount = 0;
|
|
177
|
+
let targetsCount = 0;
|
|
178
|
+
|
|
179
|
+
projectData.objects.forEach((obj, idx) => {
|
|
180
|
+
if (!obj.id) {
|
|
181
|
+
errors.push(`Nesne #${idx + 1} için 'id' alanı eksik.`);
|
|
182
|
+
} else if (idSet.has(obj.id)) {
|
|
183
|
+
errors.push(`Mükerrer nesne ID'si tespit edildi: '${obj.id}'.`);
|
|
184
|
+
} else {
|
|
185
|
+
idSet.add(obj.id);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (typeof obj.x !== 'number' || typeof obj.y !== 'number') {
|
|
189
|
+
errors.push(`'${obj.id || idx}' nesnesinin X ve Y koordinatları sayı olmalıdır.`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (obj.role === 'draggable') draggablesCount++;
|
|
193
|
+
if (['target', 'category', 'sequence_slot'].includes(obj.role)) targetsCount++;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (draggablesCount === 0) {
|
|
197
|
+
warnings.push('Etkinlikte henüz hiç sürüklenebilir nesne bulunmuyor.');
|
|
198
|
+
}
|
|
199
|
+
if (targetsCount === 0) {
|
|
200
|
+
warnings.push('Etkinlikte henüz hiç hedef alan bulunmuyor.');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Cevap anahtarı kontrolü
|
|
204
|
+
const answerKey = projectData.answerKey || {};
|
|
205
|
+
for (const [dragId, targetId] of Object.entries(answerKey)) {
|
|
206
|
+
if (!idSet.has(dragId)) {
|
|
207
|
+
warnings.push(`Cevap anahtarındaki '${dragId}' ID'li sürüklenebilir nesne sahnede bulunamadı.`);
|
|
208
|
+
}
|
|
209
|
+
if (projectData.project && projectData.project.activityType !== 'sequencing') {
|
|
210
|
+
if (!idSet.has(targetId)) {
|
|
211
|
+
warnings.push(`Cevap anahtarındaki '${targetId}' ID'li hedef nesne sahnede bulunamadı.`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
valid: errors.length === 0,
|
|
219
|
+
errors,
|
|
220
|
+
warnings
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* HTML dışa aktarmadan önce cevap anahtarının oynanabilirlik durumunu kontrol eder
|
|
226
|
+
*/
|
|
227
|
+
static validatePlayability(projectData) {
|
|
228
|
+
const issues = [];
|
|
229
|
+
const objects = projectData.objects || [];
|
|
230
|
+
const answerKey = projectData.answerKey || {};
|
|
231
|
+
const draggables = objects.filter(o => o.role === 'draggable');
|
|
232
|
+
|
|
233
|
+
if (draggables.length === 0) {
|
|
234
|
+
issues.push('En az bir adet sürüklenebilir nesne eklemelisiniz.');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const unassignedDraggables = draggables.filter(d => !answerKey[d.id]);
|
|
238
|
+
if (unassignedDraggables.length > 0) {
|
|
239
|
+
const names = unassignedDraggables.map(d => d.name || d.text || d.id).join(', ');
|
|
240
|
+
issues.push(`Şu sürüklenebilir nesnelerin cevap anahtarında hedefi belirlenmemiş: ${names}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
ready: issues.length === 0,
|
|
245
|
+
issues
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* XSS ve zararlı HTML girişlerini engellemek için metin temizleme
|
|
251
|
+
*/
|
|
252
|
+
static sanitizeText(str) {
|
|
253
|
+
if (str === null || str === undefined) return '';
|
|
254
|
+
const temp = document.createElement('div');
|
|
255
|
+
temp.textContent = String(str);
|
|
256
|
+
return temp.innerHTML;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
windowObj.DND.ValidationEngine = ValidationEngine;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* ActivityEngine - Etkinlik Oynatıcı Motoru.
|
|
264
|
+
* Hem editör içi Önizleme (Preview) modunda hem de bağımsız dışa aktarılan
|
|
265
|
+
* HTML dosyasında çalışan gerçek zamanlı öğrenci deneyimi motorudur.
|
|
266
|
+
*
|
|
267
|
+
* Dört etkinlik türünü eksiksiz destekler:
|
|
268
|
+
* 1. Görsel Üzerinde Etiketleme
|
|
269
|
+
* 2. Sürükle-Bırak Eşleştirme
|
|
270
|
+
* 3. Kategorilere Ayırma
|
|
271
|
+
* 4. Sürükleyerek Sıralama
|
|
272
|
+
*/
|
|
273
|
+
class ActivityEngine {
|
|
274
|
+
constructor(containerEl, projectData, options = {}) {
|
|
275
|
+
this.container = containerEl;
|
|
276
|
+
this.projectData = JSON.parse(JSON.stringify(projectData)); // Orijinal veriyi korumak için derin kopya
|
|
277
|
+
this.options = Object.assign({
|
|
278
|
+
isEmbedded: false,
|
|
279
|
+
stagePadding: null,
|
|
280
|
+
onComplete: null,
|
|
281
|
+
onExit: null,
|
|
282
|
+
onStep: null,
|
|
283
|
+
onDrop: null,
|
|
284
|
+
onReset: null
|
|
285
|
+
}, options);
|
|
286
|
+
|
|
287
|
+
this.eventListeners = {};
|
|
288
|
+
this.audio = new window.DND.AudioSynthesizer();
|
|
289
|
+
this.audio.setEnabled(this.projectData.settings?.soundEnabled !== false);
|
|
290
|
+
|
|
291
|
+
// Oyun durumları
|
|
292
|
+
this.placedMap = {}; // { [draggableId]: targetId }
|
|
293
|
+
this.originPositions = {}; // { [draggableId]: { x, y } }
|
|
294
|
+
this.score = 0;
|
|
295
|
+
this.attempts = 0;
|
|
296
|
+
this.correctCount = 0;
|
|
297
|
+
this.isCompleted = false;
|
|
298
|
+
|
|
299
|
+
// Etkinlik Modu: 'learning' (Öğrenme - Anlık) veya 'assessment' (Değerlendirme - Toplu Kontrol)
|
|
300
|
+
this.playMode = this.options.playMode || this.projectData.settings?.playMode || 'learning';
|
|
301
|
+
this.isRetryMode = false;
|
|
302
|
+
this.lockedTargetIds = new Set();
|
|
303
|
+
|
|
304
|
+
// Sürükleme değişkenleri
|
|
305
|
+
this.activeDragId = null;
|
|
306
|
+
this.dragStartPos = { x: 0, y: 0 };
|
|
307
|
+
this.dragElemStart = { x: 0, y: 0 };
|
|
308
|
+
this.activePointerId = null;
|
|
309
|
+
this.currentScale = 1.0;
|
|
310
|
+
|
|
311
|
+
// Klavye erişilebilirliği için seçili nesne
|
|
312
|
+
this.keyboardSelectedId = null;
|
|
313
|
+
|
|
314
|
+
// Geri bildirim durumu ve zamanlayıcısı
|
|
315
|
+
this.feedbackTimeout = null;
|
|
316
|
+
this.currentFeedback = null;
|
|
317
|
+
|
|
318
|
+
// Yaşam döngüsü dinleyici referansları (Bellek sızıntılarını ve duplicate listener'ları önler)
|
|
319
|
+
this.onResizeBound = () => this.updateScale();
|
|
320
|
+
this.onPointerMoveBound = (e) => this.handlePointerMove(e);
|
|
321
|
+
this.onPointerUpBound = (e) => this.handlePointerUp(e);
|
|
322
|
+
this.onPointerCancelBound = (e) => this.handlePointerCancel(e);
|
|
323
|
+
this.onKeyDownBound = (e) => this.handleKeyDown(e);
|
|
324
|
+
|
|
325
|
+
this.init();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
init() {
|
|
329
|
+
this.renderGameLayout();
|
|
330
|
+
this.setupResizeHandler();
|
|
331
|
+
this.setupPointerEvents();
|
|
332
|
+
this.setupKeyboardEvents();
|
|
333
|
+
this.prepareItems();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Yaşam döngüsü temizliği: SPA, React, Vue unmount durumlarında tüm dinleyicileri ve sesi temizler
|
|
337
|
+
destroy() {
|
|
338
|
+
if (this.resizeObserver) {
|
|
339
|
+
this.resizeObserver.disconnect();
|
|
340
|
+
this.resizeObserver = null;
|
|
341
|
+
}
|
|
342
|
+
if (this.feedbackTimeout) {
|
|
343
|
+
clearTimeout(this.feedbackTimeout);
|
|
344
|
+
this.feedbackTimeout = null;
|
|
345
|
+
}
|
|
346
|
+
if (this.onResizeBound) {
|
|
347
|
+
window.removeEventListener('resize', this.onResizeBound);
|
|
348
|
+
}
|
|
349
|
+
if (this.onPointerMoveBound) {
|
|
350
|
+
window.removeEventListener('pointermove', this.onPointerMoveBound);
|
|
351
|
+
}
|
|
352
|
+
if (this.onPointerUpBound) {
|
|
353
|
+
window.removeEventListener('pointerup', this.onPointerUpBound);
|
|
354
|
+
}
|
|
355
|
+
if (this.onPointerCancelBound) {
|
|
356
|
+
window.removeEventListener('pointercancel', this.onPointerCancelBound);
|
|
357
|
+
}
|
|
358
|
+
if (this.container && this.onKeyDownBound) {
|
|
359
|
+
this.container.removeEventListener('keydown', this.onKeyDownBound);
|
|
360
|
+
}
|
|
361
|
+
if (this.audio && typeof this.audio.close === 'function') {
|
|
362
|
+
this.audio.close();
|
|
363
|
+
}
|
|
364
|
+
this.emit('destroy');
|
|
365
|
+
this.eventListeners = {};
|
|
366
|
+
if (this.container) {
|
|
367
|
+
this.container.innerHTML = '';
|
|
368
|
+
}
|
|
369
|
+
this.isDestroyed = true;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Olay Dinleme ve Yayınlama (Event Emitter API)
|
|
373
|
+
on(event, callback) {
|
|
374
|
+
if (!this.eventListeners[event]) this.eventListeners[event] = [];
|
|
375
|
+
this.eventListeners[event].push(callback);
|
|
376
|
+
return () => this.off(event, callback);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
off(event, callback) {
|
|
380
|
+
if (!this.eventListeners || !this.eventListeners[event]) return;
|
|
381
|
+
this.eventListeners[event] = this.eventListeners[event].filter(cb => cb !== callback);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
emit(event, data) {
|
|
385
|
+
const handlerName = 'on' + event.charAt(0).toUpperCase() + event.slice(1);
|
|
386
|
+
if (this.options && typeof this.options[handlerName] === 'function') {
|
|
387
|
+
try {
|
|
388
|
+
this.options[handlerName](data);
|
|
389
|
+
} catch (err) {
|
|
390
|
+
console.error(`[ActivityEngine] Options callback error in ${event}:`, err);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (this.eventListeners && this.eventListeners[event]) {
|
|
394
|
+
this.eventListeners[event].forEach(cb => {
|
|
395
|
+
try {
|
|
396
|
+
cb(data);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
console.error(`[ActivityEngine] Event listener error in ${event}:`, err);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Anlık etkinlik durumunu döner
|
|
405
|
+
getState() {
|
|
406
|
+
const accuracy = this.totalDraggables > 0
|
|
407
|
+
? Math.round((this.correctCount / this.totalDraggables) * 100)
|
|
408
|
+
: 0;
|
|
409
|
+
return {
|
|
410
|
+
isCompleted: this.isCompleted,
|
|
411
|
+
score: accuracy,
|
|
412
|
+
attempts: this.attempts,
|
|
413
|
+
correctCount: this.correctCount,
|
|
414
|
+
totalDraggables: this.totalDraggables,
|
|
415
|
+
placedMap: Object.assign({}, this.placedMap)
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
setState(value) {
|
|
420
|
+
if (!value || typeof value !== 'object' || !value.placedMap || typeof value.placedMap !== 'object') {
|
|
421
|
+
throw new TypeError('Geçersiz DND oynatıcı durumu.');
|
|
422
|
+
}
|
|
423
|
+
const objects = new Map((this.projectData.objects || []).map(object => [object.id, object]));
|
|
424
|
+
const nextPlaced = {};
|
|
425
|
+
for (const [dragId, targetId] of Object.entries(value.placedMap)) {
|
|
426
|
+
const drag = objects.get(dragId);
|
|
427
|
+
const target = objects.get(targetId);
|
|
428
|
+
if (drag?.role !== 'draggable' || !['target', 'category', 'sequence_slot'].includes(target?.role)) {
|
|
429
|
+
throw new TypeError('Oynatıcı durumu bilinmeyen bir nesne içeriyor.');
|
|
430
|
+
}
|
|
431
|
+
nextPlaced[dragId] = targetId;
|
|
432
|
+
}
|
|
433
|
+
this.prepareItems();
|
|
434
|
+
this.placedMap = nextPlaced;
|
|
435
|
+
this.attempts = Number.isSafeInteger(value.attempts) && value.attempts >= 0 ? value.attempts : 0;
|
|
436
|
+
this.correctCount = 0;
|
|
437
|
+
for (const [dragId, targetId] of Object.entries(nextPlaced)) {
|
|
438
|
+
const target = objects.get(targetId);
|
|
439
|
+
this.snapToTarget(dragId, target);
|
|
440
|
+
const isCorrect = this.projectData.answerKey?.[dragId] === targetId;
|
|
441
|
+
const dragEl = this.container.querySelector(`[data-id="${dragId}"]`);
|
|
442
|
+
if (isCorrect) {
|
|
443
|
+
this.correctCount++;
|
|
444
|
+
dragEl?.classList.add('is-correct');
|
|
445
|
+
if (this.projectData.settings?.correctAnswerBehavior === 'lock') {
|
|
446
|
+
dragEl?.classList.add('is-locked');
|
|
447
|
+
dragEl?.setAttribute('tabindex', '-1');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
this.isCompleted = this.totalDraggables > 0 && this.correctCount === this.totalDraggables && value.isCompleted === true;
|
|
452
|
+
this.setCompletionResultVisible(this.isCompleted);
|
|
453
|
+
this.updateStats();
|
|
454
|
+
return this.getState();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
renderGameLayout() {
|
|
458
|
+
this.container.innerHTML = `
|
|
459
|
+
<div class="player-wrapper">
|
|
460
|
+
${this.options.isEmbedded ? `
|
|
461
|
+
<header class="player-header player-header-minimal">
|
|
462
|
+
<button class="player-btn exit-btn" id="btn-exit-preview" title="Önizlemeden Çık ve Editöre Dön">
|
|
463
|
+
✕ Editöre Dön
|
|
464
|
+
</button>
|
|
465
|
+
</header>
|
|
466
|
+
` : ''}
|
|
467
|
+
|
|
468
|
+
<main class="player-viewport" id="player-viewport">
|
|
469
|
+
<div class="player-stage-container" id="player-stage-container">
|
|
470
|
+
<div class="player-stage-board" id="player-stage-board">
|
|
471
|
+
<div class="player-stage-scaler" id="player-stage-scaler">
|
|
472
|
+
<div class="player-stage" id="player-stage">
|
|
473
|
+
<div class="player-targets-layer" id="player-targets"></div>
|
|
474
|
+
<div class="player-draggables-layer" id="player-draggables"></div>
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
|
|
478
|
+
<!-- Kontrol Alanı (Açık Zemin, Hiyerarşik Butonlar) -->
|
|
479
|
+
<div class="player-controls" id="player-controls">
|
|
480
|
+
<div class="player-mode-switch" role="group" aria-label="Etkinlik Modu">
|
|
481
|
+
<button type="button" class="mode-btn ${this.playMode === 'learning' ? 'active' : ''}" id="btn-mode-learning" data-mode="learning" title="Öğrenme Modu (Anlık Geri Bildirim)">
|
|
482
|
+
Öğrenme
|
|
483
|
+
</button>
|
|
484
|
+
<button type="button" class="mode-btn ${this.playMode === 'assessment' ? 'active' : ''}" id="btn-mode-assessment" data-mode="assessment" title="Değerlendirme Modu (Toplu Kontrol)">
|
|
485
|
+
Değerlendirme
|
|
486
|
+
</button>
|
|
487
|
+
</div>
|
|
488
|
+
|
|
489
|
+
<div class="player-actions">
|
|
490
|
+
<button class="player-btn reset-btn secondary-btn" id="btn-reset-game" title="Yeniden Başlat">
|
|
491
|
+
<span class="btn-icon">↺</span> Sıfırla
|
|
492
|
+
</button>
|
|
493
|
+
<button class="player-btn primary-btn check-answers-btn" id="btn-check-answers" title="Cevapları Kontrol Et" style="${this.playMode === 'learning' ? 'display: none;' : 'display: inline-flex;'}">
|
|
494
|
+
✓ Kontrol Et
|
|
495
|
+
</button>
|
|
496
|
+
</div>
|
|
497
|
+
</div>
|
|
498
|
+
</div>
|
|
499
|
+
|
|
500
|
+
<!-- Tamamlanma Sonuç Kartı (Sahneyi kapatmadan normal akışta gösterilir) -->
|
|
501
|
+
<section class="completion-result-card" id="completion-result" aria-labelledby="completion-result-title" aria-live="polite" hidden>
|
|
502
|
+
<div class="completion-result-message">
|
|
503
|
+
<span class="completion-result-icon" aria-hidden="true">
|
|
504
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
505
|
+
<circle cx="12" cy="12" r="9"></circle>
|
|
506
|
+
<path d="m8 12 2.5 2.5L16 9"></path>
|
|
507
|
+
</svg>
|
|
508
|
+
</span>
|
|
509
|
+
<h2 class="completion-result-title" id="completion-result-title">Harika, hepsi doğru!</h2>
|
|
510
|
+
</div>
|
|
511
|
+
<button type="button" class="completion-replay-btn" id="btn-play-again">
|
|
512
|
+
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
513
|
+
<path d="M3 12a9 9 0 1 0 3-6.7L3 8"></path>
|
|
514
|
+
<path d="M3 3v5h5"></path>
|
|
515
|
+
</svg>
|
|
516
|
+
<span>Tekrar Oyna</span>
|
|
517
|
+
</button>
|
|
518
|
+
</section>
|
|
519
|
+
|
|
520
|
+
<!-- Alt Yardımcı İpucu Metni (Ayrı koyu şerit değil, sade yardımcı metin) -->
|
|
521
|
+
<footer class="player-footer" id="player-footer">
|
|
522
|
+
<div class="player-hint" id="player-hint">
|
|
523
|
+
💡 Öğeleri sürükleyerek doğru hedeflerle eşleştirin.
|
|
524
|
+
</div>
|
|
525
|
+
</footer>
|
|
526
|
+
</div>
|
|
527
|
+
</main>
|
|
528
|
+
</div>
|
|
529
|
+
`;
|
|
530
|
+
|
|
531
|
+
// Buton dinleyicileri
|
|
532
|
+
const modeLearningBtn = this.container.querySelector('#btn-mode-learning');
|
|
533
|
+
if (modeLearningBtn) {
|
|
534
|
+
modeLearningBtn.onclick = () => this.setPlayMode('learning');
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const modeAssessmentBtn = this.container.querySelector('#btn-mode-assessment');
|
|
538
|
+
if (modeAssessmentBtn) {
|
|
539
|
+
modeAssessmentBtn.onclick = () => this.setPlayMode('assessment');
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const resetBtn = this.container.querySelector('#btn-reset-game');
|
|
543
|
+
if (resetBtn) resetBtn.onclick = () => this.resetGame();
|
|
544
|
+
|
|
545
|
+
const exitBtn = this.container.querySelector('#btn-exit-preview');
|
|
546
|
+
if (exitBtn) {
|
|
547
|
+
exitBtn.onclick = () => {
|
|
548
|
+
if (typeof this.options.onExit === 'function') this.options.onExit();
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const checkBtn = this.container.querySelector('#btn-check-answers');
|
|
553
|
+
if (checkBtn) {
|
|
554
|
+
checkBtn.onclick = () => {
|
|
555
|
+
if (this.isRetryMode) {
|
|
556
|
+
this.retryWrongAnswers();
|
|
557
|
+
} else {
|
|
558
|
+
this.checkAllAnswers();
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const playAgainBtn = this.container.querySelector('#btn-play-again');
|
|
564
|
+
if (playAgainBtn) playAgainBtn.onclick = () => this.resetGame();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
getActivityTypeLabel() {
|
|
568
|
+
const type = this.projectData.project.activityType;
|
|
569
|
+
if (type === 'image-labeling') return 'Görsel Etiketleme';
|
|
570
|
+
if (type === 'matching') return 'Eşleştirme';
|
|
571
|
+
if (type === 'categorization') return 'Kategorilere Ayırma';
|
|
572
|
+
if (type === 'sequencing') return 'Sıralama';
|
|
573
|
+
return 'Etkinlik';
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
getPlayMode() {
|
|
577
|
+
return this.playMode;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
setPlayMode(mode) {
|
|
581
|
+
if (mode !== 'learning' && mode !== 'assessment') return;
|
|
582
|
+
if (this.playMode === mode) return;
|
|
583
|
+
this.playMode = mode;
|
|
584
|
+
this.resetGame();
|
|
585
|
+
this.updateModeUI();
|
|
586
|
+
this.emit('playModeChange', { mode: this.playMode });
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
addStatusBadge(dragEl, type) {
|
|
590
|
+
if (!dragEl) return;
|
|
591
|
+
this.removeStatusBadge(dragEl);
|
|
592
|
+
const badge = document.createElement('span');
|
|
593
|
+
badge.className = `player-status-badge badge-${type}`;
|
|
594
|
+
badge.textContent = type === 'correct' ? '✓' : '✕';
|
|
595
|
+
dragEl.appendChild(badge);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
removeStatusBadge(dragEl) {
|
|
599
|
+
if (!dragEl) return;
|
|
600
|
+
const badge = dragEl.querySelector('.player-status-badge');
|
|
601
|
+
if (badge) badge.remove();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
updateModeUI() {
|
|
605
|
+
const learnBtn = this.container.querySelector('#btn-mode-learning');
|
|
606
|
+
const assessBtn = this.container.querySelector('#btn-mode-assessment');
|
|
607
|
+
const checkBtn = this.container.querySelector('#btn-check-answers');
|
|
608
|
+
|
|
609
|
+
if (learnBtn) learnBtn.classList.toggle('active', this.playMode === 'learning');
|
|
610
|
+
if (assessBtn) assessBtn.classList.toggle('active', this.playMode === 'assessment');
|
|
611
|
+
|
|
612
|
+
if (checkBtn) {
|
|
613
|
+
if (this.playMode === 'learning') {
|
|
614
|
+
checkBtn.style.display = 'none';
|
|
615
|
+
} else {
|
|
616
|
+
checkBtn.style.display = 'inline-flex';
|
|
617
|
+
this.setCheckButtonState('check');
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
setCheckButtonState(state) {
|
|
623
|
+
const checkBtn = this.container.querySelector('#btn-check-answers');
|
|
624
|
+
if (!checkBtn) return;
|
|
625
|
+
|
|
626
|
+
if (state === 'retry') {
|
|
627
|
+
this.isRetryMode = true;
|
|
628
|
+
checkBtn.textContent = '🔄 Tekrar Dene';
|
|
629
|
+
checkBtn.title = 'Yanlış seçenekleri geri gönderip tekrar deneyin';
|
|
630
|
+
checkBtn.className = 'player-btn retry-btn';
|
|
631
|
+
} else {
|
|
632
|
+
this.isRetryMode = false;
|
|
633
|
+
checkBtn.textContent = '✓ Kontrol Et';
|
|
634
|
+
checkBtn.title = 'Cevapları Kontrol Et';
|
|
635
|
+
checkBtn.className = 'player-btn primary-btn check-answers-btn';
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
setupResizeHandler() {
|
|
640
|
+
this.stageEl = this.container.querySelector('#player-stage');
|
|
641
|
+
this.stageScalerEl = this.container.querySelector('#player-stage-scaler');
|
|
642
|
+
this.stageBoardEl = this.container.querySelector('#player-stage-board');
|
|
643
|
+
this.completionResultEl = this.container.querySelector('#completion-result');
|
|
644
|
+
this.viewportEl = this.container.querySelector('#player-viewport');
|
|
645
|
+
this.stageContainerEl = this.container.querySelector('#player-stage-container') || this.viewportEl;
|
|
646
|
+
|
|
647
|
+
this.onResizeBound = () => this.updateScale();
|
|
648
|
+
window.addEventListener('resize', this.onResizeBound);
|
|
649
|
+
|
|
650
|
+
if (typeof window !== 'undefined' && window.ResizeObserver && this.stageContainerEl) {
|
|
651
|
+
this.resizeObserver = new window.ResizeObserver(() => {
|
|
652
|
+
this.updateScale();
|
|
653
|
+
});
|
|
654
|
+
this.resizeObserver.observe(this.stageContainerEl);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
this.updateScale();
|
|
658
|
+
if (typeof requestAnimationFrame !== 'undefined') {
|
|
659
|
+
requestAnimationFrame(() => this.updateScale());
|
|
660
|
+
}
|
|
661
|
+
setTimeout(() => this.updateScale(), 30);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
updateScale() {
|
|
665
|
+
if (!this.stageEl || !this.viewportEl) return;
|
|
666
|
+
|
|
667
|
+
const stageW = (this.projectData.stage && this.projectData.stage.width) || 1200;
|
|
668
|
+
const stageH = (this.projectData.stage && this.projectData.stage.height) || 675;
|
|
669
|
+
|
|
670
|
+
this.stageEl.style.width = `${stageW}px`;
|
|
671
|
+
this.stageEl.style.height = `${stageH}px`;
|
|
672
|
+
|
|
673
|
+
// Arka plan stilleri
|
|
674
|
+
this.stageEl.style.backgroundColor = (this.projectData.stage && this.projectData.stage.backgroundColor) || '#ffffff';
|
|
675
|
+
if (this.projectData.stage && this.projectData.stage.backgroundImage) {
|
|
676
|
+
this.stageEl.style.backgroundImage = `url("${this.projectData.stage.backgroundImage}")`;
|
|
677
|
+
this.stageEl.style.backgroundSize = 'contain';
|
|
678
|
+
this.stageEl.style.backgroundPosition = 'center';
|
|
679
|
+
this.stageEl.style.backgroundRepeat = 'no-repeat';
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const targetContainer = this.stageContainerEl || this.viewportEl;
|
|
683
|
+
const vpRect = targetContainer.getBoundingClientRect();
|
|
684
|
+
if (vpRect.width === 0 || vpRect.height === 0) {
|
|
685
|
+
if (typeof requestAnimationFrame !== 'undefined') {
|
|
686
|
+
requestAnimationFrame(() => this.updateScale());
|
|
687
|
+
}
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const requestedPadding = this.options.stagePadding;
|
|
692
|
+
const numericPadding = Number(requestedPadding);
|
|
693
|
+
const pad = requestedPadding !== null
|
|
694
|
+
&& requestedPadding !== undefined
|
|
695
|
+
&& Number.isFinite(numericPadding)
|
|
696
|
+
&& numericPadding >= 0
|
|
697
|
+
? numericPadding
|
|
698
|
+
: (vpRect.width < 600 ? 16 : 32);
|
|
699
|
+
// player-controls (~48px) + player-footer (~36px) = ~84px dikey alan payı
|
|
700
|
+
const extraH = 88;
|
|
701
|
+
const availW = Math.max(vpRect.width - pad, 60);
|
|
702
|
+
const availH = Math.max(vpRect.height - pad - extraH, 60);
|
|
703
|
+
|
|
704
|
+
this.currentScale = Math.min(availW / stageW, availH / stageH);
|
|
705
|
+
const scaledW = Math.round(stageW * this.currentScale);
|
|
706
|
+
const scaledH = Math.round(stageH * this.currentScale);
|
|
707
|
+
|
|
708
|
+
if (this.stageScalerEl) {
|
|
709
|
+
this.stageScalerEl.style.width = `${scaledW}px`;
|
|
710
|
+
this.stageScalerEl.style.height = `${scaledH}px`;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (this.stageBoardEl) {
|
|
714
|
+
this.stageBoardEl.style.width = `${scaledW}px`;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (this.completionResultEl) {
|
|
718
|
+
this.completionResultEl.style.width = `${scaledW}px`;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
this.stageEl.style.transform = `scale(${this.currentScale})`;
|
|
722
|
+
this.stageEl.style.transformOrigin = 'top left';
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
clientToStage(clientX, clientY) {
|
|
726
|
+
const rect = this.stageEl.getBoundingClientRect();
|
|
727
|
+
const stageW = this.projectData.stage.width || 1200;
|
|
728
|
+
const scale = rect.width / stageW;
|
|
729
|
+
return {
|
|
730
|
+
x: (clientX - rect.left) / scale,
|
|
731
|
+
y: (clientY - rect.top) / scale
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
prepareItems() {
|
|
736
|
+
const targetsLayer = this.container.querySelector('#player-targets');
|
|
737
|
+
const draggablesLayer = this.container.querySelector('#player-draggables');
|
|
738
|
+
targetsLayer.innerHTML = '';
|
|
739
|
+
draggablesLayer.innerHTML = '';
|
|
740
|
+
|
|
741
|
+
const objects = this.projectData.objects || [];
|
|
742
|
+
const draggables = objects.filter(o => o.role === 'draggable');
|
|
743
|
+
const nonDraggables = objects.filter(o => o.role !== 'draggable');
|
|
744
|
+
|
|
745
|
+
this.totalDraggables = draggables.length;
|
|
746
|
+
const statTotal = this.container.querySelector('#stat-total');
|
|
747
|
+
if (statTotal) statTotal.textContent = this.totalDraggables;
|
|
748
|
+
const statCorrect = this.container.querySelector('#stat-correct');
|
|
749
|
+
if (statCorrect) statCorrect.textContent = '0';
|
|
750
|
+
const statAttempts = this.container.querySelector('#stat-attempts');
|
|
751
|
+
if (statAttempts) statAttempts.textContent = '0';
|
|
752
|
+
|
|
753
|
+
// Sabit, hedef ve dekoratif nesneleri çiz
|
|
754
|
+
nonDraggables.forEach(obj => {
|
|
755
|
+
const el = this.createDOMObject(obj, false);
|
|
756
|
+
targetsLayer.appendChild(el);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
// Eğer ayarlarda karıştırma aktifse sürüklenebilir nesnelerin konumlarını karıştır
|
|
760
|
+
let renderDraggables = [...draggables];
|
|
761
|
+
if (this.projectData.settings?.shuffleDraggables) {
|
|
762
|
+
const positions = draggables.map(d => ({ x: d.x, y: d.y }));
|
|
763
|
+
// Fisher-Yates karıştırma
|
|
764
|
+
for (let i = positions.length - 1; i > 0; i--) {
|
|
765
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
766
|
+
[positions[i], positions[j]] = [positions[j], positions[i]];
|
|
767
|
+
}
|
|
768
|
+
renderDraggables.forEach((d, idx) => {
|
|
769
|
+
d.currentX = positions[idx].x;
|
|
770
|
+
d.currentY = positions[idx].y;
|
|
771
|
+
});
|
|
772
|
+
} else {
|
|
773
|
+
renderDraggables.forEach(d => {
|
|
774
|
+
d.currentX = d.x;
|
|
775
|
+
d.currentY = d.y;
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Başlangıç konumlarını karıştırma sonrası ekrandaki gerçek yerleriyle kaydet
|
|
780
|
+
renderDraggables.forEach(d => {
|
|
781
|
+
this.originPositions[d.id] = { x: d.currentX, y: d.currentY };
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
// Sürüklenebilir nesneleri çiz
|
|
785
|
+
renderDraggables.forEach(obj => {
|
|
786
|
+
const el = this.createDOMObject(obj, true);
|
|
787
|
+
draggablesLayer.appendChild(el);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
// Değerlendirme modu UI ayarı
|
|
791
|
+
this.updateModeUI();
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
createDOMObject(obj, isDraggable = false) {
|
|
795
|
+
const el = document.createElement('div');
|
|
796
|
+
el.className = `player-object ${isDraggable ? 'is-draggable' : 'is-target'}`;
|
|
797
|
+
el.setAttribute('data-id', obj.id);
|
|
798
|
+
el.setAttribute('data-role', obj.role);
|
|
799
|
+
el.setAttribute('tabindex', isDraggable ? '0' : '-1');
|
|
800
|
+
|
|
801
|
+
const posX = isDraggable && obj.currentX !== undefined ? obj.currentX : obj.x;
|
|
802
|
+
const posY = isDraggable && obj.currentY !== undefined ? obj.currentY : obj.y;
|
|
803
|
+
|
|
804
|
+
el.style.width = `${obj.width}px`;
|
|
805
|
+
el.style.height = `${obj.height}px`;
|
|
806
|
+
el.style.transform = `translate(${posX}px, ${posY}px) rotate(${obj.rotation || 0}deg)`;
|
|
807
|
+
el.style.zIndex = isDraggable ? (obj.zIndex || 10) + 10 : (obj.zIndex || 1);
|
|
808
|
+
|
|
809
|
+
// Stiller
|
|
810
|
+
el.style.backgroundColor = obj.backgroundColor || 'transparent';
|
|
811
|
+
el.style.borderColor = obj.borderColor || 'transparent';
|
|
812
|
+
el.style.borderWidth = `${obj.borderWidth || 0}px`;
|
|
813
|
+
el.style.borderStyle = obj.borderStyle || 'solid';
|
|
814
|
+
el.style.borderRadius = `${obj.borderRadius || 0}px`;
|
|
815
|
+
el.style.boxShadow = obj.boxShadow || 'none';
|
|
816
|
+
|
|
817
|
+
// İçerik
|
|
818
|
+
const content = document.createElement('div');
|
|
819
|
+
content.className = 'player-object-content';
|
|
820
|
+
content.style.fontFamily = obj.fontFamily || 'sans-serif';
|
|
821
|
+
content.style.fontSize = `${obj.fontSize || 16}px`;
|
|
822
|
+
content.style.fontWeight = obj.fontWeight || 'normal';
|
|
823
|
+
content.style.color = obj.textColor || '#1e293b';
|
|
824
|
+
content.style.padding = `${obj.padding !== undefined ? obj.padding : 8}px`;
|
|
825
|
+
|
|
826
|
+
// Dikey hizalama (Top / Middle / Bottom)
|
|
827
|
+
if (obj.verticalAlign === 'top') {
|
|
828
|
+
content.style.justifyContent = 'flex-start';
|
|
829
|
+
} else if (obj.verticalAlign === 'bottom') {
|
|
830
|
+
content.style.justifyContent = 'flex-end';
|
|
831
|
+
} else {
|
|
832
|
+
content.style.justifyContent = 'center';
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Yatay hizalama (Left / Center / Right)
|
|
836
|
+
const textAlign = obj.textAlign || 'center';
|
|
837
|
+
content.style.textAlign = textAlign;
|
|
838
|
+
if (textAlign === 'left') {
|
|
839
|
+
content.style.alignItems = 'flex-start';
|
|
840
|
+
} else if (textAlign === 'right') {
|
|
841
|
+
content.style.alignItems = 'flex-end';
|
|
842
|
+
} else {
|
|
843
|
+
content.style.alignItems = 'center';
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (obj.type === 'line') {
|
|
847
|
+
el.style.backgroundColor = 'transparent';
|
|
848
|
+
el.style.borderColor = 'transparent';
|
|
849
|
+
el.style.borderWidth = '0px';
|
|
850
|
+
el.style.boxShadow = 'none';
|
|
851
|
+
content.style.padding = '0px';
|
|
852
|
+
content.innerHTML = this.renderLineSVG(obj);
|
|
853
|
+
} else if (obj.type === 'image' && obj.src) {
|
|
854
|
+
const img = document.createElement('img');
|
|
855
|
+
img.src = obj.src;
|
|
856
|
+
img.draggable = false;
|
|
857
|
+
content.appendChild(img);
|
|
858
|
+
if (obj.text) {
|
|
859
|
+
const cap = document.createElement('div');
|
|
860
|
+
cap.className = 'image-caption';
|
|
861
|
+
cap.style.width = '100%';
|
|
862
|
+
cap.style.textAlign = textAlign;
|
|
863
|
+
cap.textContent = obj.text;
|
|
864
|
+
content.appendChild(cap);
|
|
865
|
+
}
|
|
866
|
+
} else if (obj.role === 'category') {
|
|
867
|
+
const header = document.createElement('div');
|
|
868
|
+
header.className = 'cat-title';
|
|
869
|
+
header.style.width = '100%';
|
|
870
|
+
header.style.textAlign = textAlign;
|
|
871
|
+
header.textContent = obj.text || obj.name;
|
|
872
|
+
content.appendChild(header);
|
|
873
|
+
|
|
874
|
+
const itemsContainer = document.createElement('div');
|
|
875
|
+
itemsContainer.className = 'category-slots-container';
|
|
876
|
+
itemsContainer.setAttribute('data-category-id', obj.id);
|
|
877
|
+
content.appendChild(itemsContainer);
|
|
878
|
+
} else if (obj.role === 'sequence_slot') {
|
|
879
|
+
const slotNum = document.createElement('div');
|
|
880
|
+
slotNum.className = 'slot-badge';
|
|
881
|
+
slotNum.textContent = (obj.slotIndex !== undefined ? obj.slotIndex + 1 : 1);
|
|
882
|
+
content.appendChild(slotNum);
|
|
883
|
+
if (obj.text) {
|
|
884
|
+
const slotText = document.createElement('div');
|
|
885
|
+
slotText.className = 'slot-text';
|
|
886
|
+
slotText.style.width = '100%';
|
|
887
|
+
slotText.style.textAlign = textAlign;
|
|
888
|
+
slotText.textContent = obj.text;
|
|
889
|
+
content.appendChild(slotText);
|
|
890
|
+
}
|
|
891
|
+
} else {
|
|
892
|
+
if (obj.text) {
|
|
893
|
+
const textSpan = document.createElement('span');
|
|
894
|
+
textSpan.className = 'player-text-span';
|
|
895
|
+
textSpan.style.width = '100%';
|
|
896
|
+
textSpan.style.display = 'block';
|
|
897
|
+
textSpan.style.textAlign = textAlign;
|
|
898
|
+
textSpan.textContent = obj.text;
|
|
899
|
+
content.appendChild(textSpan);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
el.appendChild(content);
|
|
904
|
+
|
|
905
|
+
// Durum ikonu göstergesi (doğru / yanlış işareti)
|
|
906
|
+
const statusBadge = document.createElement('div');
|
|
907
|
+
statusBadge.className = 'status-badge';
|
|
908
|
+
el.appendChild(statusBadge);
|
|
909
|
+
|
|
910
|
+
return el;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Pointer Events API ile Sürükle Bırak
|
|
914
|
+
setupPointerEvents() {
|
|
915
|
+
const draggablesLayer = this.container.querySelector('#player-draggables');
|
|
916
|
+
|
|
917
|
+
draggablesLayer.addEventListener('pointerdown', (e) => {
|
|
918
|
+
const itemEl = e.target.closest('.player-object.is-draggable');
|
|
919
|
+
if (!itemEl || itemEl.classList.contains('is-locked')) return;
|
|
920
|
+
|
|
921
|
+
e.preventDefault();
|
|
922
|
+
e.stopPropagation();
|
|
923
|
+
|
|
924
|
+
const id = itemEl.getAttribute('data-id');
|
|
925
|
+
this.activeDragId = id;
|
|
926
|
+
this.activePointerId = e.pointerId;
|
|
927
|
+
|
|
928
|
+
const stagePos = this.clientToStage(e.clientX, e.clientY);
|
|
929
|
+
this.dragStartPos = stagePos;
|
|
930
|
+
|
|
931
|
+
// Nesnenin mevcut transform koordinatlarını al
|
|
932
|
+
const styleTransform = itemEl.style.transform;
|
|
933
|
+
const match = /translate\(([-\d.]+)px,\s*([-\d.]+)px\)/.exec(styleTransform);
|
|
934
|
+
const currX = match ? parseFloat(match[1]) : 0;
|
|
935
|
+
const currY = match ? parseFloat(match[2]) : 0;
|
|
936
|
+
|
|
937
|
+
this.dragElemStart = { x: currX, y: currY };
|
|
938
|
+
|
|
939
|
+
itemEl.classList.add('is-dragging');
|
|
940
|
+
itemEl.style.zIndex = '9999';
|
|
941
|
+
|
|
942
|
+
try {
|
|
943
|
+
itemEl.setPointerCapture(e.pointerId);
|
|
944
|
+
} catch (err) {}
|
|
945
|
+
|
|
946
|
+
this.audio.playPickup();
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
window.addEventListener('pointermove', this.onPointerMoveBound);
|
|
950
|
+
window.addEventListener('pointerup', this.onPointerUpBound);
|
|
951
|
+
window.addEventListener('pointercancel', this.onPointerCancelBound);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
handlePointerMove(e) {
|
|
955
|
+
if (!this.activeDragId || this.activePointerId !== e.pointerId) return;
|
|
956
|
+
|
|
957
|
+
const itemEl = this.container.querySelector(`[data-id="${this.activeDragId}"]`);
|
|
958
|
+
if (!itemEl) return;
|
|
959
|
+
|
|
960
|
+
const currentStagePos = this.clientToStage(e.clientX, e.clientY);
|
|
961
|
+
const dx = currentStagePos.x - this.dragStartPos.x;
|
|
962
|
+
const dy = currentStagePos.y - this.dragStartPos.y;
|
|
963
|
+
|
|
964
|
+
const objData = this.getObj(this.activeDragId);
|
|
965
|
+
const stageW = (this.projectData.stage && this.projectData.stage.width) || 1200;
|
|
966
|
+
const stageH = (this.projectData.stage && this.projectData.stage.height) || 675;
|
|
967
|
+
const objW = (objData && objData.width) || 60;
|
|
968
|
+
const objH = (objData && objData.height) || 40;
|
|
969
|
+
|
|
970
|
+
const nextX = Math.round(Math.max(0, Math.min(stageW - objW, this.dragElemStart.x + dx)));
|
|
971
|
+
const nextY = Math.round(Math.max(0, Math.min(stageH - objH, this.dragElemStart.y + dy)));
|
|
972
|
+
|
|
973
|
+
itemEl.style.transform = `translate(${nextX}px, ${nextY}px) scale(1.05)`;
|
|
974
|
+
|
|
975
|
+
// Hedef algılama ve vurgulama (Hover highlight)
|
|
976
|
+
const dropTarget = this.findDropTarget(nextX, nextY, (objData && objData.width) || 60, (objData && objData.height) || 40);
|
|
977
|
+
this.highlightHoverTarget(dropTarget ? dropTarget.id : null);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
handlePointerUp(e) {
|
|
981
|
+
if (!this.activeDragId || this.activePointerId !== e.pointerId) return;
|
|
982
|
+
this.finalizeDrop();
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
handlePointerCancel(e) {
|
|
986
|
+
if (!this.activeDragId || this.activePointerId !== e.pointerId) return;
|
|
987
|
+
this.finalizeDrop();
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Klavye Erişilebilirliği (Space/Enter ile seç, hedefte bırak)
|
|
991
|
+
setupKeyboardEvents() {
|
|
992
|
+
this.container.addEventListener('keydown', this.onKeyDownBound);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
handleKeyDown(e) {
|
|
996
|
+
const activeEl = document.activeElement;
|
|
997
|
+
if (!activeEl) return;
|
|
998
|
+
|
|
999
|
+
if (e.key === ' ' || e.key === 'Enter') {
|
|
1000
|
+
if (activeEl.classList.contains('is-draggable') && !activeEl.classList.contains('is-locked')) {
|
|
1001
|
+
e.preventDefault();
|
|
1002
|
+
const id = activeEl.getAttribute('data-id');
|
|
1003
|
+
if (this.keyboardSelectedId === id) {
|
|
1004
|
+
// Seçimi bırak
|
|
1005
|
+
this.keyboardSelectedId = null;
|
|
1006
|
+
activeEl.classList.remove('keyboard-active');
|
|
1007
|
+
} else {
|
|
1008
|
+
// Nesneyi eline al
|
|
1009
|
+
if (this.keyboardSelectedId) {
|
|
1010
|
+
const prev = this.container.querySelector(`[data-id="${this.keyboardSelectedId}"]`);
|
|
1011
|
+
if (prev) prev.classList.remove('keyboard-active');
|
|
1012
|
+
}
|
|
1013
|
+
this.keyboardSelectedId = id;
|
|
1014
|
+
activeEl.classList.add('keyboard-active');
|
|
1015
|
+
this.audio.playPickup();
|
|
1016
|
+
this.showHint('Öğeyi seçtiniz. Şimdi Tab tuşu ile hedef alana gidip Boşluk tuşuna basarak bırakın.');
|
|
1017
|
+
}
|
|
1018
|
+
} else if (activeEl.classList.contains('is-target') && this.keyboardSelectedId) {
|
|
1019
|
+
e.preventDefault();
|
|
1020
|
+
const targetId = activeEl.getAttribute('data-id');
|
|
1021
|
+
this.executeDrop(this.keyboardSelectedId, targetId);
|
|
1022
|
+
const itemEl = this.container.querySelector(`[data-id="${this.keyboardSelectedId}"]`);
|
|
1023
|
+
if (itemEl) itemEl.classList.remove('keyboard-active');
|
|
1024
|
+
this.keyboardSelectedId = null;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
finalizeDrop() {
|
|
1030
|
+
const itemEl = this.container.querySelector(`[data-id="${this.activeDragId}"]`);
|
|
1031
|
+
if (!itemEl) return;
|
|
1032
|
+
|
|
1033
|
+
itemEl.classList.remove('is-dragging');
|
|
1034
|
+
itemEl.style.zIndex = '100';
|
|
1035
|
+
|
|
1036
|
+
try {
|
|
1037
|
+
if (this.activePointerId !== null && itemEl.hasPointerCapture && itemEl.hasPointerCapture(this.activePointerId)) {
|
|
1038
|
+
itemEl.releasePointerCapture(this.activePointerId);
|
|
1039
|
+
}
|
|
1040
|
+
} catch (err) {}
|
|
1041
|
+
|
|
1042
|
+
const styleTransform = itemEl.style.transform;
|
|
1043
|
+
const match = /translate\(([-\d.]+)px,\s*([-\d.]+)px\)/.exec(styleTransform);
|
|
1044
|
+
const currX = match ? parseFloat(match[1]) : 0;
|
|
1045
|
+
const currY = match ? parseFloat(match[2]) : 0;
|
|
1046
|
+
|
|
1047
|
+
const objData = this.getObj(this.activeDragId);
|
|
1048
|
+
const dropTarget = this.findDropTarget(currX, currY, (objData && objData.width) || 60, (objData && objData.height) || 40);
|
|
1049
|
+
|
|
1050
|
+
this.highlightHoverTarget(null);
|
|
1051
|
+
|
|
1052
|
+
const dragId = this.activeDragId;
|
|
1053
|
+
this.activeDragId = null;
|
|
1054
|
+
this.activePointerId = null;
|
|
1055
|
+
|
|
1056
|
+
if (dropTarget) {
|
|
1057
|
+
this.executeDrop(dragId, dropTarget.id);
|
|
1058
|
+
} else {
|
|
1059
|
+
// Hedef dışına bırakıldıysa başlangıç konumuna dön
|
|
1060
|
+
this.returnToOrigin(dragId);
|
|
1061
|
+
this.audio.playDrop();
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Bırakılan hedefin tespiti (AABB ve Merkez noktası çakışması)
|
|
1066
|
+
findDropTarget(itemX, itemY, itemW, itemH) {
|
|
1067
|
+
const itemMidX = itemX + itemW / 2;
|
|
1068
|
+
const itemMidY = itemY + itemH / 2;
|
|
1069
|
+
|
|
1070
|
+
const targets = (this.projectData.objects || []).filter(o =>
|
|
1071
|
+
['target', 'category', 'sequence_slot'].includes(o.role) && !this.lockedTargetIds.has(o.id)
|
|
1072
|
+
);
|
|
1073
|
+
|
|
1074
|
+
// Öncelik: Merkez noktasını içinde barındıran hedef
|
|
1075
|
+
for (const t of targets) {
|
|
1076
|
+
if (itemMidX >= t.x && itemMidX <= t.x + t.width &&
|
|
1077
|
+
itemMidY >= t.y && itemMidY <= t.y + t.height) {
|
|
1078
|
+
return t;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// İkinci öncelik: En az %40 kesişim olan hedef
|
|
1083
|
+
for (const t of targets) {
|
|
1084
|
+
const interLeft = Math.max(itemX, t.x);
|
|
1085
|
+
const interTop = Math.max(itemY, t.y);
|
|
1086
|
+
const interRight = Math.min(itemX + itemW, t.x + t.width);
|
|
1087
|
+
const interBottom = Math.min(itemY + itemH, t.y + t.height);
|
|
1088
|
+
|
|
1089
|
+
if (interRight > interLeft && interBottom > interTop) {
|
|
1090
|
+
const interArea = (interRight - interLeft) * (interBottom - interTop);
|
|
1091
|
+
const itemArea = itemW * itemH;
|
|
1092
|
+
if (interArea / itemArea > 0.35) {
|
|
1093
|
+
return t;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
highlightHoverTarget(targetId) {
|
|
1102
|
+
this.container.querySelectorAll('.player-object.is-target').forEach(el => {
|
|
1103
|
+
const id = el.getAttribute('data-id');
|
|
1104
|
+
if (targetId && id === targetId && !this.lockedTargetIds.has(id)) {
|
|
1105
|
+
el.classList.add('hover-active');
|
|
1106
|
+
} else {
|
|
1107
|
+
el.classList.remove('hover-active');
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
getObj(id) {
|
|
1113
|
+
return (this.projectData.objects || []).find(o => o.id === id);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Bir nesnenin bir hedefe bırakılma işlemi
|
|
1117
|
+
executeDrop(dragId, targetId) {
|
|
1118
|
+
const targetObj = this.getObj(targetId);
|
|
1119
|
+
const dragObj = this.getObj(dragId);
|
|
1120
|
+
if (!targetObj || !dragObj) return;
|
|
1121
|
+
|
|
1122
|
+
// Doğru eşleşmiş kilitli hedeflere nesne bırakılamaz
|
|
1123
|
+
if (this.lockedTargetIds.has(targetId)) return;
|
|
1124
|
+
|
|
1125
|
+
if (this.playMode === 'learning') {
|
|
1126
|
+
this.attempts++;
|
|
1127
|
+
this.updateStats();
|
|
1128
|
+
this.evaluateSingleDrop(dragId, targetId);
|
|
1129
|
+
} else {
|
|
1130
|
+
// Değerlendirme Modu: Kullanıcı tüm nesneleri sürükleyecek, sürüklediği yere yerleşecek.
|
|
1131
|
+
// Eğer hedef kategori değilse ve üzerinde henüz doğrulanmamış başka bir öğe varsa, eski öğeyi başlangıç yerine gönder
|
|
1132
|
+
if (targetObj.role !== 'category') {
|
|
1133
|
+
const prevDragId = Object.keys(this.placedMap).find(id => this.placedMap[id] === targetId && id !== dragId);
|
|
1134
|
+
if (prevDragId) {
|
|
1135
|
+
this.returnToOrigin(prevDragId);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
this.attempts++;
|
|
1140
|
+
this.updateStats();
|
|
1141
|
+
this.snapToTarget(dragId, targetObj);
|
|
1142
|
+
this.placedMap[dragId] = targetId;
|
|
1143
|
+
this.audio.playDrop();
|
|
1144
|
+
this.emit('drop', { dragId, targetId, isPending: true });
|
|
1145
|
+
this.emit('step', this.getState());
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
evaluateSingleDrop(dragId, targetId) {
|
|
1150
|
+
const correctTargetId = this.projectData.answerKey ? this.projectData.answerKey[dragId] : null;
|
|
1151
|
+
const isCorrect = correctTargetId === targetId;
|
|
1152
|
+
|
|
1153
|
+
const dragEl = this.container.querySelector(`[data-id="${dragId}"]`);
|
|
1154
|
+
const targetObj = this.getObj(targetId);
|
|
1155
|
+
const targetEl = this.container.querySelector(`[data-id="${targetId}"]`);
|
|
1156
|
+
|
|
1157
|
+
if (isCorrect) {
|
|
1158
|
+
// DOĞRU CEVAP
|
|
1159
|
+
this.audio.playCorrect();
|
|
1160
|
+
this.placedMap[dragId] = targetId;
|
|
1161
|
+
this.correctCount++;
|
|
1162
|
+
this.updateStats();
|
|
1163
|
+
this.setFeedback('Harika! Doğru eşleştirme.', 'success', 2500);
|
|
1164
|
+
|
|
1165
|
+
this.snapToTarget(dragId, targetObj);
|
|
1166
|
+
|
|
1167
|
+
if (dragEl) {
|
|
1168
|
+
dragEl.classList.remove('is-incorrect');
|
|
1169
|
+
dragEl.classList.add('is-correct');
|
|
1170
|
+
|
|
1171
|
+
if (this.projectData.settings?.correctAnswerBehavior === 'lock') {
|
|
1172
|
+
dragEl.classList.add('is-locked');
|
|
1173
|
+
dragEl.setAttribute('tabindex', '-1');
|
|
1174
|
+
}
|
|
1175
|
+
this.addStatusBadge(dragEl, 'correct');
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
if (targetObj && targetObj.role !== 'category') {
|
|
1179
|
+
this.lockedTargetIds.add(targetId);
|
|
1180
|
+
if (targetEl) targetEl.classList.add('is-target-locked');
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
this.emit('drop', { dragId, targetId, isCorrect: true });
|
|
1184
|
+
this.emit('step', this.getState());
|
|
1185
|
+
this.checkCompletion();
|
|
1186
|
+
} else {
|
|
1187
|
+
// YANLIŞ CEVAP
|
|
1188
|
+
this.audio.playWrong();
|
|
1189
|
+
this.setFeedback('Tekrar deneyin! Yanlış eşleştirme.', 'error', 2500);
|
|
1190
|
+
|
|
1191
|
+
if (dragEl) {
|
|
1192
|
+
dragEl.classList.add('is-incorrect');
|
|
1193
|
+
this.addStatusBadge(dragEl, 'incorrect');
|
|
1194
|
+
setTimeout(() => {
|
|
1195
|
+
if (dragEl) {
|
|
1196
|
+
dragEl.classList.remove('is-incorrect');
|
|
1197
|
+
this.removeStatusBadge(dragEl);
|
|
1198
|
+
}
|
|
1199
|
+
}, 600);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
const wrongBehavior = (this.projectData.settings && this.projectData.settings.wrongAnswerBehavior) || 'return';
|
|
1203
|
+
if (wrongBehavior === 'return') {
|
|
1204
|
+
this.returnToOrigin(dragId);
|
|
1205
|
+
} else {
|
|
1206
|
+
this.snapToTarget(dragId, targetObj);
|
|
1207
|
+
this.placedMap[dragId] = targetId;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
this.emit('drop', { dragId, targetId, isCorrect: false });
|
|
1211
|
+
this.emit('step', this.getState());
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// Hedefe Hizalama / Yerleştirme
|
|
1216
|
+
snapToTarget(dragId, targetObj) {
|
|
1217
|
+
const dragEl = this.container.querySelector(`[data-id="${dragId}"]`);
|
|
1218
|
+
const dragObj = this.getObj(dragId);
|
|
1219
|
+
if (!dragEl || !dragObj) return;
|
|
1220
|
+
|
|
1221
|
+
let targetX = targetObj.x;
|
|
1222
|
+
let targetY = targetObj.y;
|
|
1223
|
+
|
|
1224
|
+
if (targetObj.role === 'category') {
|
|
1225
|
+
// Kategori içine yerleştirme: kategori içindeki mevcut öğe sayısına göre gridleme
|
|
1226
|
+
const existingItems = Object.keys(this.placedMap).filter(id => this.placedMap[id] === targetObj.id && id !== dragId);
|
|
1227
|
+
const slotIndex = existingItems.length;
|
|
1228
|
+
|
|
1229
|
+
const col = slotIndex % 2;
|
|
1230
|
+
const row = Math.floor(slotIndex / 2);
|
|
1231
|
+
|
|
1232
|
+
targetX = targetObj.x + 20 + col * (dragObj.width + 10);
|
|
1233
|
+
targetY = targetObj.y + 60 + row * (dragObj.height + 10);
|
|
1234
|
+
} else if (targetObj.role === 'sequence_slot' || targetObj.role === 'target') {
|
|
1235
|
+
// Merkeze hizalama
|
|
1236
|
+
targetX = targetObj.x + (targetObj.width - dragObj.width) / 2;
|
|
1237
|
+
targetY = targetObj.y + (targetObj.height - dragObj.height) / 2;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
dragEl.style.transition = 'transform 0.25s cubic-bezier(0.2, 0.8, 0.2, 1)';
|
|
1241
|
+
dragEl.style.transform = `translate(${Math.round(targetX)}px, ${Math.round(targetY)}px) rotate(0deg)`;
|
|
1242
|
+
|
|
1243
|
+
setTimeout(() => {
|
|
1244
|
+
if (dragEl) dragEl.style.transition = '';
|
|
1245
|
+
}, 260);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
returnToOrigin(dragId) {
|
|
1249
|
+
const dragEl = this.container.querySelector(`[data-id="${dragId}"]`);
|
|
1250
|
+
const origin = this.originPositions[dragId];
|
|
1251
|
+
if (!dragEl || !origin) return;
|
|
1252
|
+
|
|
1253
|
+
delete this.placedMap[dragId];
|
|
1254
|
+
this.removeStatusBadge(dragEl);
|
|
1255
|
+
if (dragEl) dragEl.classList.remove('is-incorrect');
|
|
1256
|
+
|
|
1257
|
+
if (dragEl.classList.contains('is-correct')) {
|
|
1258
|
+
dragEl.classList.remove('is-correct');
|
|
1259
|
+
dragEl.classList.remove('is-locked');
|
|
1260
|
+
dragEl.setAttribute('tabindex', '0');
|
|
1261
|
+
this.correctCount = Math.max(0, this.correctCount - 1);
|
|
1262
|
+
this.updateStats();
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
const origObj = this.getObj(dragId);
|
|
1266
|
+
const rot = (origObj && origObj.rotation) ? origObj.rotation : 0;
|
|
1267
|
+
|
|
1268
|
+
dragEl.style.transition = 'transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)';
|
|
1269
|
+
dragEl.style.transform = `translate(${origin.x}px, ${origin.y}px) rotate(${rot}deg) scale(1)`;
|
|
1270
|
+
|
|
1271
|
+
setTimeout(() => {
|
|
1272
|
+
if (dragEl) {
|
|
1273
|
+
dragEl.style.transition = '';
|
|
1274
|
+
dragEl.style.zIndex = origObj && origObj.zIndex ? `${origObj.zIndex + 10}` : '20';
|
|
1275
|
+
}
|
|
1276
|
+
}, 360);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// Toplu Değerlendirme (Değerlendirme Modunda Kontrol Et butonu ile)
|
|
1280
|
+
checkAllAnswers() {
|
|
1281
|
+
const answerKey = this.projectData.answerKey || {};
|
|
1282
|
+
let correct = 0;
|
|
1283
|
+
this.attempts++;
|
|
1284
|
+
|
|
1285
|
+
const placedCount = Object.keys(this.placedMap).length;
|
|
1286
|
+
if (placedCount === 0) {
|
|
1287
|
+
this.setFeedback('Henüz bir nesne yerleştirmediniz. Sürükleyip bırakın.', 'warning', 3000);
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
let hasWrong = false;
|
|
1292
|
+
for (const [dragId, targetId] of Object.entries(this.placedMap)) {
|
|
1293
|
+
const isRight = answerKey[dragId] === targetId;
|
|
1294
|
+
const dragEl = this.container.querySelector(`[data-id="${dragId}"]`);
|
|
1295
|
+
const targetObj = this.getObj(targetId);
|
|
1296
|
+
const targetEl = this.container.querySelector(`[data-id="${targetId}"]`);
|
|
1297
|
+
|
|
1298
|
+
if (isRight) {
|
|
1299
|
+
correct++;
|
|
1300
|
+
if (dragEl) {
|
|
1301
|
+
dragEl.classList.remove('is-incorrect');
|
|
1302
|
+
dragEl.classList.add('is-correct');
|
|
1303
|
+
dragEl.classList.add('is-locked');
|
|
1304
|
+
dragEl.setAttribute('tabindex', '-1');
|
|
1305
|
+
this.addStatusBadge(dragEl, 'correct');
|
|
1306
|
+
}
|
|
1307
|
+
if (targetObj && targetObj.role !== 'category') {
|
|
1308
|
+
this.lockedTargetIds.add(targetId);
|
|
1309
|
+
if (targetEl) targetEl.classList.add('is-target-locked');
|
|
1310
|
+
}
|
|
1311
|
+
} else {
|
|
1312
|
+
hasWrong = true;
|
|
1313
|
+
if (dragEl) {
|
|
1314
|
+
dragEl.classList.remove('is-correct');
|
|
1315
|
+
dragEl.classList.add('is-incorrect');
|
|
1316
|
+
this.addStatusBadge(dragEl, 'incorrect');
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
this.correctCount = correct;
|
|
1322
|
+
this.updateStats();
|
|
1323
|
+
|
|
1324
|
+
if (correct === this.totalDraggables) {
|
|
1325
|
+
this.isCompleted = true;
|
|
1326
|
+
this.audio.playComplete();
|
|
1327
|
+
this.showCompletionResult();
|
|
1328
|
+
} else {
|
|
1329
|
+
this.audio.playWrong();
|
|
1330
|
+
this.setFeedback(`${correct} / ${this.totalDraggables} doğru. Yanlışları Tekrar Dene butonuyla düzeltebilirsiniz.`, 'warning', 4000);
|
|
1331
|
+
this.showHint('Bazı eşleştirmeler hatalı. "Tekrar Dene" butonuna basarak yanlışları eski yerine gönderebilirsiniz.');
|
|
1332
|
+
if (hasWrong) {
|
|
1333
|
+
this.setCheckButtonState('retry');
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Değerlendirme Modunda Yanlışları Eski Yerine Gönderip Tekrar Dene Döngüsü
|
|
1339
|
+
retryWrongAnswers() {
|
|
1340
|
+
const answerKey = this.projectData.answerKey || {};
|
|
1341
|
+
const wrongIds = [];
|
|
1342
|
+
|
|
1343
|
+
for (const [dragId, targetId] of Object.entries(this.placedMap)) {
|
|
1344
|
+
if (answerKey[dragId] !== targetId) {
|
|
1345
|
+
wrongIds.push(dragId);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
wrongIds.forEach(dragId => {
|
|
1350
|
+
const dragEl = this.container.querySelector(`[data-id="${dragId}"]`);
|
|
1351
|
+
if (dragEl) {
|
|
1352
|
+
dragEl.classList.remove('is-incorrect');
|
|
1353
|
+
this.removeStatusBadge(dragEl);
|
|
1354
|
+
}
|
|
1355
|
+
this.returnToOrigin(dragId);
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
this.setCheckButtonState('check');
|
|
1359
|
+
this.showHint('Kalan nesneleri boş hedeflere sürükleyip tekrar kontrol edin.');
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
checkCompletion() {
|
|
1363
|
+
if (this.correctCount >= this.totalDraggables && this.totalDraggables > 0) {
|
|
1364
|
+
if (!this.isCompleted) {
|
|
1365
|
+
this.isCompleted = true;
|
|
1366
|
+
this.audio.playComplete();
|
|
1367
|
+
setTimeout(() => this.showCompletionResult(), 400);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
setCompletionResultVisible(isVisible) {
|
|
1373
|
+
const resultCard = this.container.querySelector('#completion-result');
|
|
1374
|
+
const controls = this.container.querySelector('#player-controls');
|
|
1375
|
+
const footer = this.container.querySelector('#player-footer');
|
|
1376
|
+
const replayButton = this.container.querySelector('#btn-play-again');
|
|
1377
|
+
|
|
1378
|
+
if (resultCard) resultCard.hidden = !isVisible;
|
|
1379
|
+
if (controls) controls.hidden = isVisible;
|
|
1380
|
+
if (footer) footer.hidden = isVisible;
|
|
1381
|
+
|
|
1382
|
+
if (isVisible && replayButton && typeof replayButton.focus === 'function') {
|
|
1383
|
+
try {
|
|
1384
|
+
replayButton.focus({ preventScroll: true });
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
replayButton.focus();
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
this.updateScale();
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
showCompletionResult() {
|
|
1394
|
+
const accuracy = Math.round((this.totalDraggables / Math.max(this.attempts, this.totalDraggables)) * 100);
|
|
1395
|
+
this.setCompletionResultVisible(true);
|
|
1396
|
+
|
|
1397
|
+
const payload = {
|
|
1398
|
+
score: accuracy,
|
|
1399
|
+
attempts: this.attempts,
|
|
1400
|
+
correctCount: this.correctCount
|
|
1401
|
+
};
|
|
1402
|
+
|
|
1403
|
+
this.emit('complete', payload);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
updateStats() {
|
|
1407
|
+
const statCorrect = this.container.querySelector('#stat-correct');
|
|
1408
|
+
const statAttempts = this.container.querySelector('#stat-attempts');
|
|
1409
|
+
if (statCorrect) statCorrect.textContent = this.correctCount;
|
|
1410
|
+
if (statAttempts) statAttempts.textContent = this.attempts;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
showHint(msg) {
|
|
1414
|
+
const hintEl = this.container.querySelector('#player-hint');
|
|
1415
|
+
if (hintEl) {
|
|
1416
|
+
hintEl.textContent = `💡 ${msg}`;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* Kontrol alanındaki geri bildirim mesajını günceller
|
|
1422
|
+
* @param {string} message - Gösterilecek mesaj metni
|
|
1423
|
+
* @param {'info'|'success'|'error'|'warning'} [type='info'] - Mesaj türü
|
|
1424
|
+
* @param {number} [duration=0] - Milisaniye cinsinden otomatik sıfırlanma süresi (0 ise kalıcı)
|
|
1425
|
+
*/
|
|
1426
|
+
setFeedback(message, type = 'info', duration = 0) {
|
|
1427
|
+
const fbEl = this.container.querySelector('#player-feedback');
|
|
1428
|
+
const textEl = this.container.querySelector('#feedback-text');
|
|
1429
|
+
const iconEl = this.container.querySelector('#feedback-icon');
|
|
1430
|
+
if (!fbEl || !textEl) return;
|
|
1431
|
+
|
|
1432
|
+
this.currentFeedback = { message, type };
|
|
1433
|
+
|
|
1434
|
+
const icons = {
|
|
1435
|
+
info: '💬',
|
|
1436
|
+
success: '✓',
|
|
1437
|
+
error: '✕',
|
|
1438
|
+
warning: '⚠️'
|
|
1439
|
+
};
|
|
1440
|
+
|
|
1441
|
+
fbEl.className = `player-feedback feedback-${type}`;
|
|
1442
|
+
textEl.textContent = message;
|
|
1443
|
+
if (iconEl) {
|
|
1444
|
+
iconEl.textContent = icons[type] || '💬';
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
if (this.feedbackTimeout) {
|
|
1448
|
+
clearTimeout(this.feedbackTimeout);
|
|
1449
|
+
this.feedbackTimeout = null;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
if (duration > 0) {
|
|
1453
|
+
this.feedbackTimeout = setTimeout(() => {
|
|
1454
|
+
this.resetFeedback();
|
|
1455
|
+
}, duration);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
getFeedback() {
|
|
1460
|
+
return this.currentFeedback || { message: '', type: 'info' };
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
resetFeedback() {
|
|
1464
|
+
this.setFeedback('Öğeleri doğru hedeflere sürükleyin', 'info');
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
renderLineSVG(obj) {
|
|
1468
|
+
const w = Math.max(10, obj.width || 200);
|
|
1469
|
+
const h = Math.max(10, obj.height || 24);
|
|
1470
|
+
const cy = h / 2;
|
|
1471
|
+
const strokeColor = (obj.borderColor && obj.borderColor !== 'transparent') ? obj.borderColor : '#1e293b';
|
|
1472
|
+
const strokeWidth = (obj.borderWidth !== undefined && obj.borderWidth > 0) ? obj.borderWidth : 3;
|
|
1473
|
+
const strokeStyle = obj.borderStyle || 'solid';
|
|
1474
|
+
|
|
1475
|
+
let dashArray = 'none';
|
|
1476
|
+
if (strokeStyle === 'dashed') dashArray = '8 6';
|
|
1477
|
+
else if (strokeStyle === 'dotted') dashArray = '2 6';
|
|
1478
|
+
|
|
1479
|
+
const hasStartArrow = !!obj.arrowStart;
|
|
1480
|
+
const hasEndArrow = !!obj.arrowEnd;
|
|
1481
|
+
const arrowSize = Math.min(Math.max(8, strokeWidth * 3), w / 2.5);
|
|
1482
|
+
|
|
1483
|
+
const x1 = hasStartArrow ? arrowSize - 1 : 0;
|
|
1484
|
+
const x2 = hasEndArrow ? w - (arrowSize - 1) : w;
|
|
1485
|
+
|
|
1486
|
+
let svgHtml = `<svg class="object-line-bar" width="100%" height="100%" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" style="overflow: visible; display: block; pointer-events: none;">`;
|
|
1487
|
+
|
|
1488
|
+
// Ana Çizgi
|
|
1489
|
+
svgHtml += `<line x1="${x1}" y1="${cy}" x2="${x2}" y2="${cy}" stroke="${strokeColor}" stroke-width="${strokeWidth}" stroke-dasharray="${dashArray}" stroke-linecap="round" />`;
|
|
1490
|
+
|
|
1491
|
+
// Başlangıç Oku
|
|
1492
|
+
if (hasStartArrow) {
|
|
1493
|
+
svgHtml += `<polygon points="${arrowSize},${cy - arrowSize * 0.6} 0,${cy} ${arrowSize},${cy + arrowSize * 0.6}" fill="${strokeColor}" />`;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// Bitiş Oku
|
|
1497
|
+
if (hasEndArrow) {
|
|
1498
|
+
svgHtml += `<polygon points="${w - arrowSize},${cy - arrowSize * 0.6} ${w},${cy} ${w - arrowSize},${cy + arrowSize * 0.6}" fill="${strokeColor}" />`;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
svgHtml += `</svg>`;
|
|
1502
|
+
return svgHtml;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
resetGame() {
|
|
1506
|
+
this.placedMap = {};
|
|
1507
|
+
this.score = 0;
|
|
1508
|
+
this.attempts = 0;
|
|
1509
|
+
this.correctCount = 0;
|
|
1510
|
+
this.isCompleted = false;
|
|
1511
|
+
this.lockedTargetIds.clear();
|
|
1512
|
+
|
|
1513
|
+
this.container.querySelectorAll('.player-status-badge').forEach(el => el.remove());
|
|
1514
|
+
this.container.querySelectorAll('.is-target-locked').forEach(el => el.classList.remove('is-target-locked'));
|
|
1515
|
+
|
|
1516
|
+
this.setCheckButtonState('check');
|
|
1517
|
+
this.updateStats();
|
|
1518
|
+
this.resetFeedback();
|
|
1519
|
+
this.setCompletionResultVisible(false);
|
|
1520
|
+
|
|
1521
|
+
this.prepareItems();
|
|
1522
|
+
this.emit('reset', this.getState());
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
windowObj.DND.ActivityEngine = ActivityEngine;
|
|
1527
|
+
|
|
1528
|
+
/**
|
|
1529
|
+
* DND ActivityPlayer - Modern JavaScript / TypeScript Kütüphane Sarmalayıcısı.
|
|
1530
|
+
*
|
|
1531
|
+
* Herhangi bir JS projesine (React, Vue, Angular, Svelte, Vanilla JS)
|
|
1532
|
+
* tek satırla entegre olabilen yaşam döngüsü (lifecycle) yönetimli etkinlik oynatıcısı.
|
|
1533
|
+
*/
|
|
1534
|
+
|
|
1535
|
+
// Global veya import edilmiş ActivityEngine referansı
|
|
1536
|
+
const getActivityEngine = () => ActivityEngine; // internal reference
|
|
1537
|
+
const _unused = () => {
|
|
1538
|
+
if (typeof window !== 'undefined' && window.DND && window.DND.ActivityEngine) {
|
|
1539
|
+
return window.DND.ActivityEngine;
|
|
1540
|
+
}
|
|
1541
|
+
return null;
|
|
1542
|
+
};
|
|
1543
|
+
|
|
1544
|
+
class ActivityPlayer {
|
|
1545
|
+
/**
|
|
1546
|
+
* @param {Object|string} options Veya doğrudan hedef DOM elementi / seçicisi
|
|
1547
|
+
* @param {HTMLElement|string} [options.target] Hedef container elementi veya CSS seçicisi ('#quiz')
|
|
1548
|
+
* @param {Object} [options.data] Etkinlik JSON veri modeli
|
|
1549
|
+
* @param {string} [options.src] Etkinlik JSON dosyasının URL adresi
|
|
1550
|
+
* @param {boolean} [options.sound=true] Ses efektleri açık mı
|
|
1551
|
+
* @param {number|null} [options.stagePadding=null] Sahnenin kapsayıcı içindeki toplam yatay/dikey boşluğu
|
|
1552
|
+
* @param {Function} [options.onComplete] Etkinlik tamamlandığında çağrılacak callback
|
|
1553
|
+
* @param {Function} [options.onStep] Her adımda (drop) çağrılacak callback
|
|
1554
|
+
* @param {Function} [options.onDrop] Her bırakma işleminde çağrılacak callback
|
|
1555
|
+
* @param {Function} [options.onReset] Yeniden başlatıldığında çağrılacak callback
|
|
1556
|
+
* @param {Function} [options.onReady] Oynatıcı hazır olduğunda çağrılacak callback
|
|
1557
|
+
*/
|
|
1558
|
+
constructor(options = {}) {
|
|
1559
|
+
this.options = Object.assign({
|
|
1560
|
+
sound: true,
|
|
1561
|
+
playMode: 'learning',
|
|
1562
|
+
stagePadding: null,
|
|
1563
|
+
onComplete: null,
|
|
1564
|
+
onStep: null,
|
|
1565
|
+
onDrop: null,
|
|
1566
|
+
onReset: null,
|
|
1567
|
+
onReady: null,
|
|
1568
|
+
initialState: null
|
|
1569
|
+
}, options);
|
|
1570
|
+
|
|
1571
|
+
this.container = null;
|
|
1572
|
+
this.engine = null;
|
|
1573
|
+
this.isDestroyed = false;
|
|
1574
|
+
|
|
1575
|
+
// Hedef elementi çözümle
|
|
1576
|
+
if (this.options.target) {
|
|
1577
|
+
this.resolveContainer(this.options.target);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// Veri başlatma (Doğrudan veri, URL, File veya JSON metni)
|
|
1581
|
+
if (this.options.data) {
|
|
1582
|
+
this.mount(this.options.data);
|
|
1583
|
+
} else if (this.options.src) {
|
|
1584
|
+
this.loadFromUrl(this.options.src);
|
|
1585
|
+
} else if (this.options.file) {
|
|
1586
|
+
this.loadFromFile(this.options.file);
|
|
1587
|
+
} else if (this.options.jsonString) {
|
|
1588
|
+
this.loadFromJsonString(this.options.jsonString);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
resolveContainer(target) {
|
|
1593
|
+
if (typeof target === 'string') {
|
|
1594
|
+
this.container = document.querySelector(target);
|
|
1595
|
+
} else if (target instanceof HTMLElement) {
|
|
1596
|
+
this.container = target;
|
|
1597
|
+
}
|
|
1598
|
+
if (!this.container) {
|
|
1599
|
+
throw new Error(`[ActivityPlayer] Hedef container elementi bulunamadı: ${target}`);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
/**
|
|
1604
|
+
* JSON dosya URL'sinden veya yolundan veriyi asenkron çekip oynatıcıyı başlatır
|
|
1605
|
+
* @param {string} url
|
|
1606
|
+
*/
|
|
1607
|
+
async loadFromUrl(url) {
|
|
1608
|
+
try {
|
|
1609
|
+
const response = await fetch(url);
|
|
1610
|
+
if (!response.ok) {
|
|
1611
|
+
throw new Error(`HTTP Hata: ${response.status} (${response.statusText})`);
|
|
1612
|
+
}
|
|
1613
|
+
const data = await response.json();
|
|
1614
|
+
this.mount(data);
|
|
1615
|
+
} catch (err) {
|
|
1616
|
+
console.error(`[ActivityPlayer] Veri yüklenemedi:`, err);
|
|
1617
|
+
if (this.container) {
|
|
1618
|
+
this.container.innerHTML = `
|
|
1619
|
+
<div style="padding: 24px; text-align: center; color: #ef4444; font-family: sans-serif;">
|
|
1620
|
+
⚠️ Etkinlik verisi yüklenemedi: ${err.message}
|
|
1621
|
+
</div>
|
|
1622
|
+
`;
|
|
1623
|
+
}
|
|
1624
|
+
throw err;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
/**
|
|
1629
|
+
* Bir File veya Blob nesnesini (örneğin <input type="file"> ile seçilen yerel dosyayı)
|
|
1630
|
+
* doğrudan okuyup etkinliği başlatır
|
|
1631
|
+
* @param {File|Blob} file
|
|
1632
|
+
*/
|
|
1633
|
+
async loadFromFile(file) {
|
|
1634
|
+
if (!file) throw new Error('[ActivityPlayer] Dosya nesnesi boş olamaz.');
|
|
1635
|
+
try {
|
|
1636
|
+
let text = '';
|
|
1637
|
+
if (typeof file.text === 'function') {
|
|
1638
|
+
text = await file.text();
|
|
1639
|
+
} else {
|
|
1640
|
+
text = await new Promise((resolve, reject) => {
|
|
1641
|
+
const reader = new FileReader();
|
|
1642
|
+
reader.onload = () => resolve(reader.result);
|
|
1643
|
+
reader.onerror = () => reject(reader.error);
|
|
1644
|
+
reader.readAsText(file, 'utf-8');
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1647
|
+
this.loadFromJsonString(text);
|
|
1648
|
+
} catch (err) {
|
|
1649
|
+
console.error('[ActivityPlayer] Dosya okunamadı:', err);
|
|
1650
|
+
if (this.container) {
|
|
1651
|
+
this.container.innerHTML = `
|
|
1652
|
+
<div style="padding: 24px; text-align: center; color: #ef4444; font-family: sans-serif;">
|
|
1653
|
+
⚠️ Dosya okunamadı: ${err.message}
|
|
1654
|
+
</div>
|
|
1655
|
+
`;
|
|
1656
|
+
}
|
|
1657
|
+
throw err;
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
/**
|
|
1662
|
+
* Ham JSON metnini (JSON String) ayrıştırıp etkinliği başlatır
|
|
1663
|
+
* @param {string} jsonString
|
|
1664
|
+
*/
|
|
1665
|
+
loadFromJsonString(jsonString) {
|
|
1666
|
+
try {
|
|
1667
|
+
const data = typeof jsonString === 'string' ? JSON.parse(jsonString) : jsonString;
|
|
1668
|
+
this.mount(data);
|
|
1669
|
+
} catch (err) {
|
|
1670
|
+
console.error('[ActivityPlayer] JSON ayrıştırma hatası:', err);
|
|
1671
|
+
if (this.container) {
|
|
1672
|
+
this.container.innerHTML = `
|
|
1673
|
+
<div style="padding: 24px; text-align: center; color: #ef4444; font-family: sans-serif;">
|
|
1674
|
+
⚠️ Geçersiz JSON formatı: ${err.message}
|
|
1675
|
+
</div>
|
|
1676
|
+
`;
|
|
1677
|
+
}
|
|
1678
|
+
throw err;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/**
|
|
1683
|
+
* Oynatıcıyı container içine monte eder
|
|
1684
|
+
* @param {Object} projectData
|
|
1685
|
+
*/
|
|
1686
|
+
mount(projectData) {
|
|
1687
|
+
if (this.isDestroyed || !this.container) return;
|
|
1688
|
+
|
|
1689
|
+
// Önceki motor varsa temizle
|
|
1690
|
+
if (this.engine) {
|
|
1691
|
+
this.engine.destroy();
|
|
1692
|
+
this.engine = null;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
const EngineClass = getActivityEngine();
|
|
1696
|
+
if (!EngineClass) {
|
|
1697
|
+
throw new Error('[ActivityPlayer] ActivityEngine motoru bulunamadı! DND kütüphanesinin yüklendiğinden emin olun.');
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// Seçenekleri hazırla
|
|
1701
|
+
const engineOptions = {
|
|
1702
|
+
isEmbedded: false,
|
|
1703
|
+
playMode: this.options.playMode,
|
|
1704
|
+
stagePadding: this.options.stagePadding,
|
|
1705
|
+
onComplete: (res) => {
|
|
1706
|
+
if (typeof this.options.onComplete === 'function') this.options.onComplete(res);
|
|
1707
|
+
},
|
|
1708
|
+
onStep: (state) => {
|
|
1709
|
+
if (typeof this.options.onStep === 'function') this.options.onStep(state);
|
|
1710
|
+
},
|
|
1711
|
+
onDrop: (dropInfo) => {
|
|
1712
|
+
if (typeof this.options.onDrop === 'function') this.options.onDrop(dropInfo);
|
|
1713
|
+
},
|
|
1714
|
+
onReset: (state) => {
|
|
1715
|
+
if (typeof this.options.onReset === 'function') this.options.onReset(state);
|
|
1716
|
+
}
|
|
1717
|
+
};
|
|
1718
|
+
|
|
1719
|
+
// Ses ayarını uygula
|
|
1720
|
+
const mountedData = JSON.parse(JSON.stringify(projectData));
|
|
1721
|
+
mountedData.settings = Object.assign({}, mountedData.settings, {
|
|
1722
|
+
soundEnabled: this.options.sound !== false
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
this.engine = new EngineClass(this.container, mountedData, engineOptions);
|
|
1726
|
+
if (this.options.initialState) this.engine.setState(this.options.initialState);
|
|
1727
|
+
|
|
1728
|
+
if (typeof this.options.onReady === 'function') {
|
|
1729
|
+
this.options.onReady(this);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
/**
|
|
1734
|
+
* Olay dinleyici kaydeder ('complete', 'step', 'drop', 'reset', 'destroy')
|
|
1735
|
+
*/
|
|
1736
|
+
on(event, callback) {
|
|
1737
|
+
if (this.engine) {
|
|
1738
|
+
return this.engine.on(event, callback);
|
|
1739
|
+
}
|
|
1740
|
+
return () => {};
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/**
|
|
1744
|
+
* Olay dinleyiciyi kaldırır
|
|
1745
|
+
*/
|
|
1746
|
+
off(event, callback) {
|
|
1747
|
+
if (this.engine) {
|
|
1748
|
+
this.engine.off(event, callback);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
/**
|
|
1753
|
+
* Anlık oyun durumu ve başarı puanını döner
|
|
1754
|
+
*/
|
|
1755
|
+
getState() {
|
|
1756
|
+
return this.engine ? this.engine.getState() : null;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
setState(state) {
|
|
1760
|
+
if (!this.engine) throw new Error('[ActivityPlayer] Oynatıcı henüz başlatılmadı.');
|
|
1761
|
+
return this.engine.setState(state);
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
/**
|
|
1765
|
+
* Ses efektlerini açar veya kapatır
|
|
1766
|
+
*/
|
|
1767
|
+
setSound(enabled) {
|
|
1768
|
+
if (this.engine && this.engine.audio) {
|
|
1769
|
+
this.engine.audio.setEnabled(!!enabled);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
/**
|
|
1774
|
+
* Kontrol alanındaki geri bildirim mesajını günceller
|
|
1775
|
+
* @param {string} message
|
|
1776
|
+
* @param {'info'|'success'|'error'|'warning'} [type='info']
|
|
1777
|
+
* @param {number} [duration=0]
|
|
1778
|
+
*/
|
|
1779
|
+
setFeedback(message, type = 'info', duration = 0) {
|
|
1780
|
+
if (this.engine && typeof this.engine.setFeedback === 'function') {
|
|
1781
|
+
this.engine.setFeedback(message, type, duration);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* Anlık geri bildirim durumunu döner
|
|
1787
|
+
*/
|
|
1788
|
+
getFeedback() {
|
|
1789
|
+
return (this.engine && typeof this.engine.getFeedback === 'function')
|
|
1790
|
+
? this.engine.getFeedback()
|
|
1791
|
+
: null;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
/**
|
|
1795
|
+
* Aktif oyun modunu döner ('learning' veya 'assessment')
|
|
1796
|
+
*/
|
|
1797
|
+
getPlayMode() {
|
|
1798
|
+
return (this.engine && typeof this.engine.getPlayMode === 'function')
|
|
1799
|
+
? this.engine.getPlayMode()
|
|
1800
|
+
: this.options.playMode || 'learning';
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* Oyun modunu değiştirir ('learning' veya 'assessment')
|
|
1805
|
+
* @param {'learning'|'assessment'} mode
|
|
1806
|
+
*/
|
|
1807
|
+
setPlayMode(mode) {
|
|
1808
|
+
if (this.engine && typeof this.engine.setPlayMode === 'function') {
|
|
1809
|
+
this.engine.setPlayMode(mode);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* Etkinliği sıfırlayıp baştan başlatır
|
|
1815
|
+
*/
|
|
1816
|
+
reset() {
|
|
1817
|
+
if (this.engine) {
|
|
1818
|
+
this.engine.resetGame();
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/**
|
|
1823
|
+
* Yaşam döngüsü sonlandırıcı (React useEffect cleanup, Vue onUnmounted)
|
|
1824
|
+
* Tüm event listener'ları ve ses kanallarını kapatır, bellek sızıntısını önler.
|
|
1825
|
+
*/
|
|
1826
|
+
destroy() {
|
|
1827
|
+
if (this.isDestroyed) return;
|
|
1828
|
+
if (this.engine) {
|
|
1829
|
+
this.engine.destroy();
|
|
1830
|
+
this.engine = null;
|
|
1831
|
+
}
|
|
1832
|
+
if (this.container) {
|
|
1833
|
+
this.container.innerHTML = '';
|
|
1834
|
+
}
|
|
1835
|
+
this.isDestroyed = true;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
/**
|
|
1841
|
+
* <dnd-player> Web Component (Custom Element)
|
|
1842
|
+
*
|
|
1843
|
+
* Herhangi bir framework (React, Vue, Svelte, Angular veya düz HTML) içinde
|
|
1844
|
+
* standart bir HTML etiketi gibi çalışan bağımsız etkinlik oynatıcı bileşeni.
|
|
1845
|
+
*
|
|
1846
|
+
* Kullanım Örneği:
|
|
1847
|
+
* <dnd-player src="/samples/2_hayvanlar_eslestirme.json" sound="true"></dnd-player>
|
|
1848
|
+
*/
|
|
1849
|
+
|
|
1850
|
+
// ActivityPlayer doğrudan modül içinde mevcut
|
|
1851
|
+
|
|
1852
|
+
class DNDPlayerElement extends HTMLElement {
|
|
1853
|
+
static get observedAttributes() {
|
|
1854
|
+
return ['src', 'sound', 'mode'];
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
constructor() {
|
|
1858
|
+
super();
|
|
1859
|
+
this._player = null;
|
|
1860
|
+
this._data = null;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
connectedCallback() {
|
|
1864
|
+
this.render();
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
disconnectedCallback() {
|
|
1868
|
+
if (this._player) {
|
|
1869
|
+
this._player.destroy();
|
|
1870
|
+
this._player = null;
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
1875
|
+
if (oldValue === newValue || !this.isConnected) return;
|
|
1876
|
+
|
|
1877
|
+
if (name === 'src' && newValue) {
|
|
1878
|
+
this.initPlayer();
|
|
1879
|
+
} else if (name === 'sound' && this._player) {
|
|
1880
|
+
this._player.setSound(newValue !== 'false');
|
|
1881
|
+
} else if (name === 'mode' && this._player && (newValue === 'learning' || newValue === 'assessment')) {
|
|
1882
|
+
this._player.setPlayMode(newValue);
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
/**
|
|
1887
|
+
* Doğrudan JavaScript nesnesi olarak etkinlik verisi atanabilir:
|
|
1888
|
+
* element.data = { ... }
|
|
1889
|
+
*/
|
|
1890
|
+
get data() {
|
|
1891
|
+
return this._data;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
set data(val) {
|
|
1895
|
+
this._data = val;
|
|
1896
|
+
this.initPlayer();
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
get player() {
|
|
1900
|
+
return this._player;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
render() {
|
|
1904
|
+
this.style.display = 'block';
|
|
1905
|
+
this.style.position = 'relative';
|
|
1906
|
+
this.style.width = '100%';
|
|
1907
|
+
this.style.height = this.style.height || '100%';
|
|
1908
|
+
this.style.minHeight = this.style.minHeight || '500px';
|
|
1909
|
+
|
|
1910
|
+
this.initPlayer();
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
initPlayer() {
|
|
1914
|
+
if (this._player) {
|
|
1915
|
+
this._player.destroy();
|
|
1916
|
+
this._player = null;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
const src = this.getAttribute('src');
|
|
1920
|
+
const soundAttr = this.getAttribute('sound');
|
|
1921
|
+
const sound = soundAttr !== 'false';
|
|
1922
|
+
const modeAttr = this.getAttribute('mode') || 'learning';
|
|
1923
|
+
|
|
1924
|
+
if (!src && !this._data) return;
|
|
1925
|
+
|
|
1926
|
+
this._player = new ActivityPlayer({
|
|
1927
|
+
target: this,
|
|
1928
|
+
src: src || undefined,
|
|
1929
|
+
data: this._data || undefined,
|
|
1930
|
+
sound: sound,
|
|
1931
|
+
playMode: modeAttr,
|
|
1932
|
+
onComplete: (stats) => {
|
|
1933
|
+
this.dispatchEvent(new CustomEvent('dnd:complete', { detail: stats, bubbles: true }));
|
|
1934
|
+
},
|
|
1935
|
+
onStep: (state) => {
|
|
1936
|
+
this.dispatchEvent(new CustomEvent('dnd:step', { detail: state, bubbles: true }));
|
|
1937
|
+
},
|
|
1938
|
+
onDrop: (dropInfo) => {
|
|
1939
|
+
this.dispatchEvent(new CustomEvent('dnd:drop', { detail: dropInfo, bubbles: true }));
|
|
1940
|
+
},
|
|
1941
|
+
onReset: (state) => {
|
|
1942
|
+
this.dispatchEvent(new CustomEvent('dnd:reset', { detail: state, bubbles: true }));
|
|
1943
|
+
},
|
|
1944
|
+
onReady: (p) => {
|
|
1945
|
+
this.dispatchEvent(new CustomEvent('dnd:ready', { detail: { player: p }, bubbles: true }));
|
|
1946
|
+
}
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/**
|
|
1951
|
+
* Bir File veya Blob nesnesi yükler (<input type="file"> için)
|
|
1952
|
+
*/
|
|
1953
|
+
loadFile(file) {
|
|
1954
|
+
if (this._player) {
|
|
1955
|
+
this._player.loadFromFile(file);
|
|
1956
|
+
} else {
|
|
1957
|
+
this._player = new ActivityPlayer({
|
|
1958
|
+
target: this,
|
|
1959
|
+
file: file,
|
|
1960
|
+
sound: this.getAttribute('sound') !== 'false'
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
/**
|
|
1966
|
+
* Ham JSON metni yükler
|
|
1967
|
+
*/
|
|
1968
|
+
loadJsonString(jsonString) {
|
|
1969
|
+
if (this._player) {
|
|
1970
|
+
this._player.loadFromJsonString(jsonString);
|
|
1971
|
+
} else {
|
|
1972
|
+
this._player = new ActivityPlayer({
|
|
1973
|
+
target: this,
|
|
1974
|
+
jsonString: jsonString,
|
|
1975
|
+
sound: this.getAttribute('sound') !== 'false'
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
/**
|
|
1981
|
+
* Kontrol alanındaki geri bildirim mesajını günceller
|
|
1982
|
+
*/
|
|
1983
|
+
setFeedback(message, type = 'info', duration = 0) {
|
|
1984
|
+
if (this._player) {
|
|
1985
|
+
this._player.setFeedback(message, type, duration);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
/**
|
|
1990
|
+
* Anlık geri bildirim durumunu döner
|
|
1991
|
+
*/
|
|
1992
|
+
getFeedback() {
|
|
1993
|
+
return this._player ? this._player.getFeedback() : null;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
/**
|
|
1997
|
+
* Etkinliği sıfırlama metodu
|
|
1998
|
+
*/
|
|
1999
|
+
reset() {
|
|
2000
|
+
if (this._player) this._player.reset();
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
/**
|
|
2004
|
+
* Durum sorgulama metodu
|
|
2005
|
+
*/
|
|
2006
|
+
getState() {
|
|
2007
|
+
return this._player ? this._player.getState() : null;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
/**
|
|
2011
|
+
* Aktif oyun modunu döner ('learning' veya 'assessment')
|
|
2012
|
+
*/
|
|
2013
|
+
getPlayMode() {
|
|
2014
|
+
return this._player ? this._player.getPlayMode() : (this.getAttribute('mode') || 'learning');
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
/**
|
|
2018
|
+
* Oyun modunu değiştirir ('learning' veya 'assessment')
|
|
2019
|
+
* @param {'learning'|'assessment'} mode
|
|
2020
|
+
*/
|
|
2021
|
+
setPlayMode(mode) {
|
|
2022
|
+
if (this._player) this._player.setPlayMode(mode);
|
|
2023
|
+
this.setAttribute('mode', mode);
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// Custom element kaydı
|
|
2028
|
+
if (typeof customElements !== 'undefined' && !customElements.get('dnd-player')) {
|
|
2029
|
+
customElements.define('dnd-player', DNDPlayerElement);
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
|
|
2033
|
+
export {
|
|
2034
|
+
ActivityPlayer,
|
|
2035
|
+
DNDPlayerElement,
|
|
2036
|
+
ActivityEngine,
|
|
2037
|
+
AudioSynthesizer,
|
|
2038
|
+
ValidationEngine
|
|
2039
|
+
};
|
|
2040
|
+
|
|
2041
|
+
export default ActivityPlayer;
|