@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/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;