@alepot55/chessboardjs 2.2.2 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -3,9 +3,24 @@
3
3
  "chess.js": "^1.0.0"
4
4
  },
5
5
  "name": "@alepot55/chessboardjs",
6
- "version": "2.2.2",
7
- "main": "src/index.js",
6
+ "version": "2.3.0",
7
+ "main": "dist/chessboard.cjs.js",
8
+ "module": "dist/chessboard.esm.js",
9
+ "browser": "dist/chessboard.iife.js",
8
10
  "type": "module",
11
+ "exports": {
12
+ ".": {
13
+ "import": "./dist/chessboard.esm.js",
14
+ "require": "./dist/chessboard.cjs.js",
15
+ "default": "./dist/chessboard.esm.js"
16
+ },
17
+ "./dist/*": "./dist/*"
18
+ },
19
+ "files": [
20
+ "dist/",
21
+ "src/",
22
+ "assets/"
23
+ ],
9
24
  "scripts": {
10
25
  "dev": "rollup -c config/rollup.config.js --watch",
11
26
  "build": "rollup -c config/rollup.config.js",
@@ -21,7 +36,7 @@
21
36
  "lint:fix": "echo 'ESLint not configured yet'",
22
37
  "docs": "echo 'JSDoc not configured yet'",
23
38
  "examples": "echo 'Example server not configured yet'",
24
- "prepublishOnly": "npm run build"
39
+ "prepublishOnly": "npm run test && npm run build"
25
40
  },
26
41
  "author": "alepot55",
27
42
  "license": "ISC",
@@ -155,6 +155,121 @@ class Piece {
155
155
  }
156
156
  }
157
157
 
158
+ /**
159
+ * Animate piece appearance with configurable style
160
+ * @param {string} style - Appearance style: 'fade', 'pulse', 'pop', 'drop', 'instant'
161
+ * @param {number} duration - Animation duration in ms
162
+ * @param {Function} callback - Callback when complete
163
+ */
164
+ appearAnimate(style, duration, callback) {
165
+ if (!this.element) {
166
+ if (callback) callback();
167
+ return;
168
+ }
169
+
170
+ const element = this.element;
171
+ const cleanup = () => {
172
+ if (element) {
173
+ element.style.opacity = '1';
174
+ element.style.transform = '';
175
+ }
176
+ if (callback) callback();
177
+ };
178
+
179
+ const smoothDecel = 'cubic-bezier(0.33, 1, 0.68, 1)';
180
+ const springOvershoot = 'cubic-bezier(0.34, 1.56, 0.64, 1)';
181
+
182
+ switch (style) {
183
+ case 'instant':
184
+ element.style.opacity = '1';
185
+ cleanup();
186
+ break;
187
+
188
+ case 'fade':
189
+ if (element.animate) {
190
+ element.style.opacity = '0';
191
+ const anim = element.animate([
192
+ { opacity: 0, transform: 'scale(0.95)' },
193
+ { opacity: 1, transform: 'scale(1)' }
194
+ ], { duration, easing: smoothDecel, fill: 'forwards' });
195
+ anim.onfinish = () => {
196
+ anim.cancel();
197
+ cleanup();
198
+ };
199
+ } else {
200
+ element.style.opacity = '0';
201
+ element.style.transform = 'scale(0.95)';
202
+ setTimeout(cleanup, duration);
203
+ }
204
+ break;
205
+
206
+ case 'pulse':
207
+ if (element.animate) {
208
+ element.style.opacity = '0';
209
+ const anim = element.animate([
210
+ { opacity: 0, transform: 'scale(0.6)', offset: 0 },
211
+ { opacity: 1, transform: 'scale(1.12)', offset: 0.3 },
212
+ { opacity: 1, transform: 'scale(0.92)', offset: 0.55 },
213
+ { opacity: 1, transform: 'scale(1.06)', offset: 0.8 },
214
+ { opacity: 1, transform: 'scale(1)', offset: 1 }
215
+ ], { duration, easing: smoothDecel, fill: 'forwards' });
216
+ anim.onfinish = () => {
217
+ anim.cancel();
218
+ cleanup();
219
+ };
220
+ } else {
221
+ element.style.opacity = '0';
222
+ element.style.transform = 'scale(0.6)';
223
+ setTimeout(cleanup, duration);
224
+ }
225
+ break;
226
+
227
+ case 'pop':
228
+ if (element.animate) {
229
+ element.style.opacity = '0';
230
+ const anim = element.animate([
231
+ { opacity: 0, transform: 'scale(0)', offset: 0 },
232
+ { opacity: 1, transform: 'scale(1.15)', offset: 0.6 },
233
+ { opacity: 1, transform: 'scale(1)', offset: 1 }
234
+ ], { duration, easing: springOvershoot, fill: 'forwards' });
235
+ anim.onfinish = () => {
236
+ anim.cancel();
237
+ cleanup();
238
+ };
239
+ } else {
240
+ element.style.opacity = '0';
241
+ element.style.transform = 'scale(0)';
242
+ setTimeout(cleanup, duration);
243
+ }
244
+ break;
245
+
246
+ case 'drop':
247
+ if (element.animate) {
248
+ element.style.opacity = '0';
249
+ const anim = element.animate([
250
+ { opacity: 0, transform: 'translateY(-15px) scale(0.95)', offset: 0 },
251
+ { opacity: 1, transform: 'translateY(3px) scale(1.02)', offset: 0.5 },
252
+ { opacity: 1, transform: 'translateY(-1px) scale(1)', offset: 0.75 },
253
+ { opacity: 1, transform: 'translateY(0) scale(1)', offset: 1 }
254
+ ], { duration, easing: smoothDecel, fill: 'forwards' });
255
+ anim.onfinish = () => {
256
+ anim.cancel();
257
+ cleanup();
258
+ };
259
+ } else {
260
+ element.style.opacity = '0';
261
+ element.style.transform = 'translateY(-15px) scale(0.95)';
262
+ setTimeout(cleanup, duration);
263
+ }
264
+ break;
265
+
266
+ default:
267
+ this.appearAnimate('fade', duration, callback);
268
+ return;
269
+ }
270
+ }
271
+
272
+ /** @deprecated Use appearAnimate() instead */
158
273
  fadeIn(duration, speed, transition_f, callback) {
159
274
  let start = performance.now();
160
275
  let opacity = 0;
@@ -188,15 +303,105 @@ class Piece {
188
303
  if (elapsed < duration) {
189
304
  requestAnimationFrame(fade);
190
305
  } else {
191
- if (!piece.element) { console.debug(`[Piece] fadeOut: ${piece.id} - element is null (end)`); if (callback) callback(); return; }
306
+ if (!piece.element) { if (callback) callback(); return; }
192
307
  piece.element.style.opacity = 0;
193
- console.debug(`[Piece] fadeOut complete: ${piece.id}`);
308
+ // Remove element from DOM after fade completes
309
+ if (piece.element.parentNode) {
310
+ piece.element.parentNode.removeChild(piece.element);
311
+ }
194
312
  if (callback) callback();
195
313
  }
196
314
  }
197
315
  fade();
198
316
  }
199
317
 
318
+ /**
319
+ * Animate piece capture with configurable style
320
+ * Uses fluid easing for smooth, connected animations
321
+ * @param {string} style - Capture style: 'fade', 'shrink', 'instant', 'explode'
322
+ * @param {number} duration - Animation duration in ms
323
+ * @param {Function} callback - Callback when complete
324
+ */
325
+ captureAnimate(style, duration, callback) {
326
+ if (!this.element) {
327
+ if (callback) callback();
328
+ return;
329
+ }
330
+
331
+ const element = this.element;
332
+ const cleanup = () => {
333
+ if (element && element.parentNode) {
334
+ element.parentNode.removeChild(element);
335
+ }
336
+ if (callback) callback();
337
+ };
338
+
339
+ // Fluid easing functions
340
+ const smoothDecel = 'cubic-bezier(0.33, 1, 0.68, 1)';
341
+ const smoothAccel = 'cubic-bezier(0.32, 0, 0.67, 0)';
342
+
343
+ switch (style) {
344
+ case 'instant':
345
+ cleanup();
346
+ break;
347
+
348
+ case 'fade':
349
+ if (element.animate) {
350
+ // Smooth fade with subtle scale down
351
+ const anim = element.animate([
352
+ { opacity: 1, transform: 'scale(1)' },
353
+ { opacity: 0, transform: 'scale(0.9)' }
354
+ ], { duration, easing: smoothDecel, fill: 'forwards' });
355
+ anim.onfinish = cleanup;
356
+ } else {
357
+ element.style.transition = `opacity ${duration}ms ease-out, transform ${duration}ms ease-out`;
358
+ element.style.opacity = '0';
359
+ element.style.transform = 'scale(0.9)';
360
+ setTimeout(cleanup, duration);
361
+ }
362
+ break;
363
+
364
+ case 'shrink':
365
+ if (element.animate) {
366
+ // Smooth shrink with accelerating easing for "sucked in" feel
367
+ const anim = element.animate([
368
+ { transform: 'scale(1)', opacity: 1, offset: 0 },
369
+ { transform: 'scale(0.7)', opacity: 0.6, offset: 0.6 },
370
+ { transform: 'scale(0)', opacity: 0, offset: 1 }
371
+ ], { duration, easing: smoothAccel, fill: 'forwards' });
372
+ anim.onfinish = cleanup;
373
+ } else {
374
+ element.style.transition = `transform ${duration}ms ease-in, opacity ${duration}ms ease-in`;
375
+ element.style.transform = 'scale(0)';
376
+ element.style.opacity = '0';
377
+ setTimeout(cleanup, duration);
378
+ }
379
+ break;
380
+
381
+ case 'explode':
382
+ if (element.animate) {
383
+ // Subtle expand with smooth deceleration - less dramatic
384
+ const anim = element.animate([
385
+ { transform: 'scale(1)', opacity: 1, offset: 0 },
386
+ { transform: 'scale(1.15)', opacity: 0.5, offset: 0.5 },
387
+ { transform: 'scale(1.25)', opacity: 0, offset: 1 }
388
+ ], { duration, easing: smoothDecel, fill: 'forwards' });
389
+ anim.onfinish = cleanup;
390
+ } else {
391
+ element.style.transition = `transform ${duration}ms ease-out, opacity ${duration}ms ease-out`;
392
+ element.style.transform = 'scale(1.25)';
393
+ element.style.opacity = '0';
394
+ setTimeout(cleanup, duration);
395
+ }
396
+ break;
397
+
398
+ default:
399
+ // Default to fade
400
+ this.captureAnimate('fade', duration, callback);
401
+ return;
402
+ }
403
+ }
404
+
200
405
  setDrag(f) {
201
406
  if (!this.element) { console.debug(`[Piece] setDrag: ${this.id} - element is null`); return; }
202
407
 
@@ -236,41 +441,319 @@ class Piece {
236
441
  }
237
442
  }
238
443
 
239
- translate(to, duration, transition_f, speed, callback = null) {
240
- if (!this.element) { console.debug(`[Piece] translate: ${this.id} - element is null`); if (callback) callback(); return; }
241
- let sourceRect = this.element.getBoundingClientRect();
242
- let targetRect = to.getBoundingClientRect();
243
- let x_start = sourceRect.left + sourceRect.width / 2;
244
- let y_start = sourceRect.top + sourceRect.height / 2;
245
- let x_end = targetRect.left + targetRect.width / 2;
246
- let y_end = targetRect.top + targetRect.height / 2;
247
- let dx = x_end - x_start;
248
- let dy = y_end - y_start;
249
-
250
- let keyframes = [
251
- { transform: 'translate(0, 0)' },
252
- { transform: `translate(${dx}px, ${dy}px)` }
253
- ];
444
+ /**
445
+ * Translate piece to target position with configurable movement style
446
+ * Uses Chessground-style cubic easing for fluid, natural movement
447
+ * @param {HTMLElement} to - Target element
448
+ * @param {number} duration - Animation duration in ms
449
+ * @param {Function} transition_f - Transition function (unused, for compatibility)
450
+ * @param {number} speed - Speed factor (unused, for compatibility)
451
+ * @param {Function} callback - Callback when complete
452
+ * @param {Object} options - Movement options
453
+ * @param {string} options.style - Movement style: 'slide', 'arc', 'hop', 'teleport', 'fade'
454
+ * @param {string} options.easing - CSS easing function
455
+ * @param {number} options.arcHeight - Arc height ratio (for arc/hop styles)
456
+ * @param {string} options.landingEffect - Landing effect: 'none', 'bounce', 'pulse', 'settle'
457
+ * @param {number} options.landingDuration - Landing effect duration in ms
458
+ */
459
+ translate(to, duration, transition_f, speed, callback = null, options = {}) {
460
+ if (!this.element) {
461
+ console.debug(`[Piece] translate: ${this.id} - element is null`);
462
+ if (callback) callback();
463
+ return;
464
+ }
465
+
466
+ const style = options.style || 'slide';
467
+ const arcHeight = options.arcHeight || 0.3;
468
+ const landingEffect = options.landingEffect || 'none';
469
+ const landingDuration = options.landingDuration || 150;
470
+
471
+ // Map easing names to Chessground-style fluid cubic-bezier curves
472
+ // Default: smooth deceleration like Lichess/Chessground
473
+ const easingMap = {
474
+ 'ease': 'cubic-bezier(0.33, 1, 0.68, 1)', // Chessground default - smooth decel
475
+ 'linear': 'linear',
476
+ 'ease-in': 'cubic-bezier(0.32, 0, 0.67, 0)', // Smooth acceleration
477
+ 'ease-out': 'cubic-bezier(0.33, 1, 0.68, 1)', // Smooth deceleration (same as default)
478
+ 'ease-in-out': 'cubic-bezier(0.65, 0, 0.35, 1)' // Smooth both ways
479
+ };
480
+ const easing = easingMap[options.easing] || easingMap['ease'];
481
+
482
+ // Handle teleport (instant)
483
+ if (style === 'teleport' || duration === 0) {
484
+ if (callback) callback();
485
+ return;
486
+ }
487
+
488
+ // Handle fade style
489
+ if (style === 'fade') {
490
+ this._translateFade(to, duration, easing, landingEffect, landingDuration, callback);
491
+ return;
492
+ }
493
+
494
+ // Calculate movement vectors
495
+ const sourceRect = this.element.getBoundingClientRect();
496
+ const targetRect = to.getBoundingClientRect();
497
+ const dx = (targetRect.left + targetRect.width / 2) - (sourceRect.left + sourceRect.width / 2);
498
+ const dy = (targetRect.top + targetRect.height / 2) - (sourceRect.top + sourceRect.height / 2);
499
+ const distance = Math.sqrt(dx * dx + dy * dy);
500
+
501
+ // Build keyframes based on style
502
+ let keyframes;
503
+ let animationEasing = easing;
504
+
505
+ switch (style) {
506
+ case 'arc':
507
+ keyframes = this._buildArcKeyframes(dx, dy, distance, arcHeight);
508
+ // Arc uses linear easing because the curve is in the keyframes
509
+ animationEasing = 'linear';
510
+ break;
511
+ case 'hop':
512
+ keyframes = this._buildHopKeyframes(dx, dy, distance, arcHeight);
513
+ // Hop uses ease-out for natural landing feel
514
+ animationEasing = 'cubic-bezier(0.33, 1, 0.68, 1)';
515
+ break;
516
+ case 'slide':
517
+ default:
518
+ keyframes = [
519
+ { transform: 'translate(0, 0)' },
520
+ { transform: `translate(${dx}px, ${dy}px)` }
521
+ ];
522
+ }
254
523
 
524
+ // Animate
255
525
  if (this.element.animate) {
256
- let animation = this.element.animate(keyframes, {
526
+ const animation = this.element.animate(keyframes, {
257
527
  duration: duration,
258
- easing: 'ease',
259
- fill: 'none'
528
+ easing: animationEasing,
529
+ fill: 'forwards'
260
530
  });
261
531
 
262
532
  animation.onfinish = () => {
263
- if (!this.element) { console.debug(`[Piece] translate.onfinish: ${this.id} - element is null`); if (callback) callback(); return; }
264
- if (callback) callback();
533
+ if (!this.element) {
534
+ if (callback) callback();
535
+ return;
536
+ }
537
+
538
+ // Cancel animation and move piece in DOM first
539
+ animation.cancel();
265
540
  if (this.element) this.element.style = '';
266
- console.debug(`[Piece] translate complete: ${this.id}`);
541
+
542
+ // Callback moves piece to new square in DOM
543
+ if (callback) callback();
544
+
545
+ // Apply landing effect AFTER piece is in new position
546
+ if (landingEffect !== 'none') {
547
+ // Small delay to ensure DOM is updated
548
+ requestAnimationFrame(() => {
549
+ this._applyLandingEffect(landingEffect, landingDuration);
550
+ });
551
+ }
267
552
  };
268
553
  } else {
269
- this.element.style.transition = `transform ${duration}ms ease`;
554
+ // Fallback for browsers without Web Animations API
555
+ this.element.style.transition = `transform ${duration}ms ${animationEasing}`;
270
556
  this.element.style.transform = `translate(${dx}px, ${dy}px)`;
557
+ setTimeout(() => {
558
+ if (!this.element) {
559
+ if (callback) callback();
560
+ return;
561
+ }
562
+ if (this.element) this.element.style = '';
563
+ if (callback) callback();
564
+
565
+ // Apply landing effect after DOM update
566
+ if (landingEffect !== 'none') {
567
+ requestAnimationFrame(() => {
568
+ this._applyLandingEffect(landingEffect, landingDuration);
569
+ });
570
+ }
571
+ }, duration);
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Build arc-shaped keyframes (smooth parabolic curve)
577
+ * Uses many keyframes for fluid motion without relying on easing
578
+ * @private
579
+ */
580
+ _buildArcKeyframes(dx, dy, distance, arcHeight) {
581
+ const lift = distance * arcHeight;
582
+ const steps = 10;
583
+ const keyframes = [];
584
+
585
+ for (let i = 0; i <= steps; i++) {
586
+ const t = i / steps;
587
+ // Smooth easing for horizontal movement (Chessground-style cubic)
588
+ const easedT = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
589
+ // Parabolic arc for vertical lift: peaks at t=0.5
590
+ const arcT = 4 * t * (1 - t); // Parabola: 0 at t=0, 1 at t=0.5, 0 at t=1
591
+
592
+ const x = dx * easedT;
593
+ const y = dy * easedT - lift * arcT;
594
+
595
+ keyframes.push({
596
+ transform: `translate(${x}px, ${y}px)`,
597
+ offset: t
598
+ });
599
+ }
600
+ return keyframes;
601
+ }
602
+
603
+ /**
604
+ * Build hop-shaped keyframes (knight-like jump with subtle scale)
605
+ * More aggressive vertical movement, subtle scale for emphasis
606
+ * @private
607
+ */
608
+ _buildHopKeyframes(dx, dy, distance, arcHeight) {
609
+ const lift = distance * arcHeight * 1.2;
610
+ const steps = 12;
611
+ const keyframes = [];
612
+
613
+ for (let i = 0; i <= steps; i++) {
614
+ const t = i / steps;
615
+ // Chessground-style cubic easing for smooth acceleration/deceleration
616
+ const easedT = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
617
+ // Sharp parabolic hop - peaks earlier for snappier feel
618
+ const hopT = Math.sin(t * Math.PI); // Sine for smooth hop curve
619
+ // Subtle scale: peaks at 1.03 at the top of the hop
620
+ const scale = 1 + 0.03 * hopT;
621
+
622
+ const x = dx * easedT;
623
+ const y = dy * easedT - lift * hopT;
624
+
625
+ keyframes.push({
626
+ transform: `translate(${x}px, ${y}px) scale(${scale})`,
627
+ offset: t
628
+ });
629
+ }
630
+ return keyframes;
631
+ }
632
+
633
+ /**
634
+ * Translate using fade effect (smooth crossfade with scale)
635
+ * @private
636
+ */
637
+ _translateFade(to, duration, easing, landingEffect, landingDuration, callback) {
638
+ const halfDuration = duration / 2;
639
+ // Use smooth deceleration for fade
640
+ const fadeEasing = 'cubic-bezier(0.33, 1, 0.68, 1)';
641
+
642
+ // Fade out with subtle scale down
643
+ if (this.element.animate) {
644
+ const fadeOut = this.element.animate([
645
+ { opacity: 1, transform: 'scale(1)' },
646
+ { opacity: 0, transform: 'scale(0.95)' }
647
+ ], { duration: halfDuration, easing: fadeEasing, fill: 'forwards' });
648
+
649
+ fadeOut.onfinish = () => {
650
+ if (!this.element) {
651
+ if (callback) callback();
652
+ return;
653
+ }
654
+ // Move instantly (hidden)
655
+ fadeOut.cancel();
656
+ this.element.style.opacity = '0';
657
+ this.element.style.transform = 'scale(0.95)';
658
+
659
+ // Let parent move the piece in DOM, then fade in
660
+ if (callback) callback();
661
+
662
+ // Fade in at new position with subtle scale up
663
+ requestAnimationFrame(() => {
664
+ if (!this.element) return;
665
+ const fadeIn = this.element.animate([
666
+ { opacity: 0, transform: 'scale(0.95)' },
667
+ { opacity: 1, transform: 'scale(1)' }
668
+ ], { duration: halfDuration, easing: fadeEasing, fill: 'forwards' });
669
+
670
+ fadeIn.onfinish = () => {
671
+ if (this.element) {
672
+ fadeIn.cancel();
673
+ this.element.style.opacity = '';
674
+ this.element.style.transform = '';
675
+ this._applyLandingEffect(landingEffect, landingDuration);
676
+ }
677
+ };
678
+ });
679
+ };
680
+ } else {
681
+ // Fallback
682
+ this.element.style.transition = `opacity ${halfDuration}ms ease, transform ${halfDuration}ms ease`;
683
+ this.element.style.opacity = '0';
684
+ this.element.style.transform = 'scale(0.95)';
685
+ setTimeout(() => {
686
+ if (callback) callback();
687
+ if (this.element) {
688
+ this.element.style.opacity = '1';
689
+ this.element.style.transform = 'scale(1)';
690
+ setTimeout(() => {
691
+ if (this.element) this.element.style = '';
692
+ }, halfDuration);
693
+ }
694
+ }, halfDuration);
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Apply landing effect after movement completes
700
+ * Uses spring-like overshoot easing for natural, connected feel
701
+ * @private
702
+ */
703
+ _applyLandingEffect(effect, duration, callback) {
704
+ if (!this.element || effect === 'none') {
705
+ if (callback) callback();
706
+ return;
707
+ }
708
+
709
+ let keyframes;
710
+ // Overshoot easing for spring-like natural feel
711
+ let effectEasing = 'cubic-bezier(0.34, 1.56, 0.64, 1)';
712
+
713
+ switch (effect) {
714
+ case 'bounce':
715
+ // Subtle bounce using spring easing - single smooth bounce
716
+ keyframes = [
717
+ { transform: 'translateY(0)', offset: 0 },
718
+ { transform: 'translateY(-5px)', offset: 0.4 },
719
+ { transform: 'translateY(0)', offset: 1 }
720
+ ];
721
+ // Use ease-out for natural deceleration
722
+ effectEasing = 'cubic-bezier(0.33, 1, 0.68, 1)';
723
+ break;
724
+ case 'pulse':
725
+ // Subtle scale pulse - less aggressive, more refined
726
+ keyframes = [
727
+ { transform: 'scale(1)', offset: 0 },
728
+ { transform: 'scale(1.08)', offset: 0.5 },
729
+ { transform: 'scale(1)', offset: 1 }
730
+ ];
731
+ break;
732
+ case 'settle':
733
+ // Minimal settle - piece "clicks" into place
734
+ keyframes = [
735
+ { transform: 'scale(1.02)', offset: 0 },
736
+ { transform: 'scale(1)', offset: 1 }
737
+ ];
738
+ effectEasing = 'cubic-bezier(0.33, 1, 0.68, 1)';
739
+ break;
740
+ default:
741
+ if (callback) callback();
742
+ return;
743
+ }
744
+
745
+ if (this.element.animate) {
746
+ const animation = this.element.animate(keyframes, {
747
+ duration: duration,
748
+ easing: effectEasing,
749
+ fill: 'forwards'
750
+ });
751
+ animation.onfinish = () => {
752
+ if (this.element) animation.cancel();
753
+ if (callback) callback();
754
+ };
755
+ } else {
271
756
  if (callback) callback();
272
- if (this.element) this.element.style = '';
273
- console.debug(`[Piece] translate complete (no animate): ${this.id}`);
274
757
  }
275
758
  }
276
759
 
@@ -106,9 +106,9 @@ class Square {
106
106
  }
107
107
 
108
108
  putPiece(piece) {
109
- // If there's already a piece, remove it first, but preserve if moving
109
+ // If there's already a piece, destroy it to avoid orphaned DOM elements
110
110
  if (this.piece) {
111
- this.removePiece(true);
111
+ this.removePiece(false);
112
112
  }
113
113
  this.piece = piece;
114
114
  if (piece && piece.element) {
@@ -220,7 +220,7 @@ class Square {
220
220
  }
221
221
 
222
222
  getColor() {
223
- return this.piece.getColor();
223
+ return this.piece ? this.piece.getColor() : null;
224
224
  }
225
225
 
226
226
  check() {