-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgame.js
More file actions
2002 lines (1722 loc) · 66.4 KB
/
Copy pathgame.js
File metadata and controls
2002 lines (1722 loc) · 66.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Matter.js module aliases
const Engine = Matter.Engine,
Render = Matter.Render,
Runner = Matter.Runner,
Bodies = Matter.Bodies,
Composite = Matter.Composite,
Events = Matter.Events,
Mouse = Matter.Mouse,
MouseConstraint = Matter.MouseConstraint,
Body = Matter.Body,
Query = Matter.Query;
// Game constants
const CANVAS_WIDTH = 1200;
const CANVAS_HEIGHT = 600;
const GROUND_HEIGHT = 20;
const NPC_LEG_OFFSET = 35; // Distance from body center to leg center
const NPC_HALF_LEG_HEIGHT = 10; // Half the height of NPC legs
const MAX_HISTORY_SIZE = 50; // Maximum undo/redo history entries to prevent memory leaks
const GRID_SIZE = 40; // Grid cell size in pixels
const GRID_LINE_COLOR = 'rgba(0, 0, 0, 0.1)'; // Subtle gray grid lines
// Object labels
const LABEL_SEESAW_PIVOT = 'seesaw-pivot';
const LABEL_SEESAW_PLANK = 'seesaw-plank';
// Physics constants
const POSITION_ITERATIONS = 10; // Increased from default 6 to reduce tunneling
const VELOCITY_ITERATIONS = 6; // Increased from default 4 to reduce tunneling
const DOOM_VELOCITY_THRESHOLD = 2; // Minimum velocity to doom NPC
const MAX_OBJECTS = 100; // Maximum number of objects allowed for performance
const MAX_SAVE_NAME_LENGTH = 30; // Maximum characters for save design names
// Object type detection thresholds (used in getObjectType for serialization)
const PLATFORM_MIN_WIDTH = 140; // Platforms are wider than ramps (150 vs 120)
const DOMINO_MIN_HEIGHT = 50; // Dominoes are taller than boxes (60 vs 40)
/**
* Normalize an angle in radians to the range [0, 2π).
* @param {number} angle
* @returns {number}
*/
function normalizeAngle(angle) {
const twoPi = 2 * Math.PI;
let result = angle % twoPi;
if (result < 0) {
result += twoPi;
}
return result;
}
/**
* Snap coordinates to the nearest grid intersection.
* @param {number} x
* @param {number} y
* @returns {{x: number, y: number}}
*/
function snapToGrid(x, y) {
if (!isGridEnabled) {
return { x, y };
}
return {
x: Math.round(x / GRID_SIZE) * GRID_SIZE,
y: Math.round(y / GRID_SIZE) * GRID_SIZE
};
}
/**
* Draw grid overlay on canvas.
* @param {CanvasRenderingContext2D} context
*/
function drawGrid(context) {
if (!isGridEnabled) return;
context.strokeStyle = GRID_LINE_COLOR;
context.lineWidth = 1;
context.beginPath();
// Draw vertical lines
for (let x = 0; x <= CANVAS_WIDTH; x += GRID_SIZE) {
context.moveTo(x, 0);
context.lineTo(x, CANVAS_HEIGHT);
}
// Draw horizontal lines
for (let y = 0; y <= CANVAS_HEIGHT; y += GRID_SIZE) {
context.moveTo(0, y);
context.lineTo(CANVAS_WIDTH, y);
}
context.stroke();
}
const DEFAULT_RAMP_ANGLE = normalizeAngle(-17 * Math.PI / 180); // -17 degrees, normalized to [0, 2π)
const ROTATION_INCREMENT = Math.PI / 12; // 15 degrees per key press
// Mobile detection
const isMobile = () => {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
window.innerWidth < 768;
};
// Game variables
let engine;
let render;
let runner;
let world;
let canvas;
let selectedTool = null;
let isRunning = false;
let isPaused = false;
let isSlowMotion = false;
let isGridEnabled = false; // Grid toggle state
let npc;
let npcDoomed = false;
let placedObjects = [];
let placedConstraints = [];
let currentRampAngle = DEFAULT_RAMP_ANGLE;
let seesawIdCounter = 0; // Counter for unique seesaw IDs
// Mobile touch variables
let touchStartTime = 0;
let touchStartPos = { x: 0, y: 0 };
let longPressTimer = null;
const LONG_PRESS_DURATION = 500; // ms for long press to delete
let lastTouchDistance = 0; // For pinch-to-zoom detection
let initialPinchAngle = 0; // For two-finger rotation
let lastTouchAngle = 0;
// Undo/Redo system
let actionHistory = [];
let historyIndex = -1;
// Sound system
let soundEnabled = true;
let audioContext = null;
let lastCollisionSoundTime = 0; // Track last collision sound to prevent spam
const COLLISION_SOUND_COOLDOWN = 100; // Minimum ms between collision sounds
// Initialize audio context with feature detection
try {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (AudioContextClass) {
audioContext = new AudioContextClass();
} else {
// Web Audio API not supported; disable sound but keep the game running
console.warn('Web Audio API not supported in this browser. Sound effects disabled.');
soundEnabled = false;
}
} catch (e) {
// Audio context creation failed (blocked/disabled); disable sound safely
console.warn('Audio context creation failed. Sound effects disabled.', e);
soundEnabled = false;
audioContext = null;
}
// Scoring system
let gameStartTime = 0;
let doomTime = 0;
let collisionCount = 0;
let currentScore = 0;
let currentStars = 0;
/**
* Apply explosion force to all nearby objects from an explosion center
* @param {number} explosionX - X coordinate of explosion center
* @param {number} explosionY - Y coordinate of explosion center
* @param {number} explosionRadius - Radius of explosion effect (default 150)
* @param {number} explosionForce - Force magnitude (default 0.08)
* @param {Matter.Body|null} explosiveBody - The body representing the explosive itself, which will be excluded from the explosion force
*/
function applyExplosionForce(explosionX, explosionY, explosionRadius = 150, explosionForce = 0.08, explosiveBody = null) {
// Get all bodies in the explosion radius
const allBodies = Composite.allBodies(world);
allBodies.forEach(body => {
// Skip static bodies and the explosive itself
if (body.isStatic || body === explosiveBody) return;
const dx = body.position.x - explosionX;
const dy = body.position.y - explosionY;
const distance = Math.sqrt(dx * dx + dy * dy);
// Apply force if within radius
if (distance < explosionRadius && distance > 0) {
// Calculate force direction (normalized)
const forceMagnitude = explosionForce * (1 - distance / explosionRadius);
const forceX = (dx / distance) * forceMagnitude;
const forceY = (dy / distance) * forceMagnitude;
// Apply the force at the body's position
Body.applyForce(body, body.position, { x: forceX, y: forceY });
}
});
}
/**
* Play a simple sound effect using Web Audio API
* @param {string} type - Type of sound: 'place', 'collision', 'doom', 'ui'
*/
function playSound(type) {
if (!soundEnabled || !audioContext) return;
// Resume audio context if suspended (for autoplay policies)
if (audioContext.state === 'suspended') {
audioContext.resume();
}
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
// Configure sound based on type
switch(type) {
case 'place':
// Object placement - short pop
oscillator.frequency.value = 400;
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.1);
break;
case 'collision':
// Collision - quick thud
oscillator.frequency.value = 100;
oscillator.type = 'square';
gainNode.gain.setValueAtTime(0.2, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.15);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.15);
break;
case 'doom':
// Doom - dramatic descending tone
oscillator.frequency.setValueAtTime(600, audioContext.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(100, audioContext.currentTime + 0.5);
oscillator.type = 'sawtooth';
gainNode.gain.setValueAtTime(0.4, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.5);
break;
case 'ui':
// UI action - subtle click
oscillator.frequency.value = 800;
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.2, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.08);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.08);
break;
default:
// Unknown sound type - clean up and return
oscillator.disconnect();
gainNode.disconnect();
console.warn(`Unknown sound type: ${type}`);
return;
}
}
/**
* Toggle sound on/off and save preference
*/
function toggleSound() {
soundEnabled = !soundEnabled;
localStorage.setItem('doomberg_sound_enabled', soundEnabled);
updateSoundButton();
}
/**
* Update sound button text and state
*/
function updateSoundButton() {
const soundBtn = document.getElementById('soundBtn');
if (soundBtn) {
if (!audioContext) {
// Audio not available - disable button and show unavailable state
soundBtn.textContent = '🔇 Sound: N/A';
soundBtn.disabled = true;
soundBtn.title = 'Web Audio API not supported in this browser';
} else {
soundBtn.textContent = soundEnabled ? '🔊 Sound: ON' : '🔇 Sound: OFF';
soundBtn.disabled = false;
}
}
}
/**
* Load sound preference from localStorage
*/
function loadSoundPreference() {
// Only load preference if audio is available
if (!audioContext) {
soundEnabled = false;
updateSoundButton();
return;
}
const saved = localStorage.getItem('doomberg_sound_enabled');
if (saved !== null) {
soundEnabled = saved === 'true';
}
updateSoundButton();
}
/**
* Apply mobile-specific UI adjustments
*/
function applyMobileUI() {
// Update instructions text for mobile
const instructions = document.querySelector('.instructions');
if (instructions) {
const mobileInstructions = `
<h3>Mobile Instructions:</h3>
<ol>
<li>Tap on an object type to select it</li>
<li>Tap on the canvas to place objects</li>
<li>Long-press (0.5s) on objects to delete them</li>
<li>For ramps: use two fingers to rotate the angle</li>
<li>Use Undo and Redo buttons to reverse actions</li>
<li>Build a contraption that will hit the NPC (red figure on the right)</li>
<li>Tap "Run Machine" to start the physics simulation</li>
<li>Use Pause or Slow-Mo to control playback</li>
<li>Watch your machine doom the NPC! 💀</li>
</ol>
`;
instructions.innerHTML = mobileInstructions;
}
// Add mobile class to body for CSS targeting
document.body.classList.add('mobile-device');
// Setup collapsible control groups
const controlGroups = document.querySelectorAll('.control-group');
controlGroups.forEach((group, index) => {
const heading = group.querySelector('h3');
if (heading) {
// Start with Actions and Status expanded, others collapsed
if (index !== 1 && index !== 3) { // Not Actions or Status
group.classList.add('collapsed');
}
heading.addEventListener('click', () => {
group.classList.toggle('collapsed');
});
}
});
}
// Initialize the game
function init() {
canvas = document.getElementById('gameCanvas');
// Create engine with improved iteration settings to prevent tunneling
engine = Engine.create();
world = engine.world;
world.gravity.y = 1;
// Adjust physics iterations based on device capabilities
if (isMobile()) {
// Reduce iterations on mobile for better performance
engine.positionIterations = 8;
engine.velocityIterations = 4;
} else {
engine.positionIterations = POSITION_ITERATIONS;
engine.velocityIterations = VELOCITY_ITERATIONS;
}
// Create renderer
render = Render.create({
canvas: canvas,
engine: engine,
options: {
width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
wireframes: false,
background: '#87CEEB',
// Optimize rendering for mobile
pixelRatio: isMobile() ? 1 : window.devicePixelRatio || 1
}
});
// Apply mobile-specific UI adjustments
if (isMobile()) {
applyMobileUI();
}
// Create ground
const ground = Bodies.rectangle(CANVAS_WIDTH / 2, CANVAS_HEIGHT - GROUND_HEIGHT / 2, CANVAS_WIDTH, GROUND_HEIGHT, {
isStatic: true,
render: {
fillStyle: '#8B4513'
}
});
Composite.add(world, ground);
// Create walls
const leftWall = Bodies.rectangle(5, CANVAS_HEIGHT / 2, 10, CANVAS_HEIGHT, {
isStatic: true,
render: {
fillStyle: '#696969'
}
});
const rightWall = Bodies.rectangle(CANVAS_WIDTH - 5, CANVAS_HEIGHT / 2, 10, CANVAS_HEIGHT, {
isStatic: true,
render: {
fillStyle: '#696969'
}
});
Composite.add(world, [leftWall, rightWall]);
// Create NPC
createNPC();
// Setup mouse control for placing objects
setupMouseControl();
// Setup button listeners
setupEventListeners();
// Load sound preference from localStorage
loadSoundPreference();
// Setup collision detection (only once)
Events.on(engine, 'collisionStart', (event) => {
const pairs = event.pairs;
pairs.forEach(pair => {
// Track all collisions during game for combo scoring
if (isRunning) {
collisionCount++;
}
// Check for explosive collision
const explosiveBody = pair.bodyA.label === 'explosive' ? pair.bodyA :
(pair.bodyB.label === 'explosive' ? pair.bodyB : null);
if (explosiveBody && isRunning && !explosiveBody.hasDetonated) {
// Get the other body in the collision
const otherBody = pair.bodyA === explosiveBody ? pair.bodyB : pair.bodyA;
// Calculate relative velocity between the two bodies
const relativeVelocity = Math.sqrt(
Math.pow(explosiveBody.velocity.x - otherBody.velocity.x, 2) +
Math.pow(explosiveBody.velocity.y - otherBody.velocity.y, 2)
);
// Detonate on significant impact based on relative velocity
if (relativeVelocity > 1) {
// Mark as detonated immediately to prevent multiple detonations
explosiveBody.hasDetonated = true;
// Apply explosion force to nearby objects (excluding the explosive itself)
applyExplosionForce(explosiveBody.position.x, explosiveBody.position.y, 150, 0.08, explosiveBody);
// Change color to indicate explosion (visual feedback)
explosiveBody.render.fillStyle = '#FFA500'; // Orange explosion color
// Remove explosive immediately
Composite.remove(world, explosiveBody);
placedObjects = placedObjects.filter(obj => obj !== explosiveBody);
updateObjectCounter();
updateStatus('💥 EXPLOSION!');
}
}
// Check for NPC doom
if ((pair.bodyA.label === 'npc' || pair.bodyB.label === 'npc') && !npcDoomed) {
// Check if collision is significant based on the other body's velocity
const otherBody = pair.bodyA.label === 'npc' ? pair.bodyB : pair.bodyA;
const velocity = Math.abs(otherBody.velocity.x) + Math.abs(otherBody.velocity.y);
if (velocity > DOOM_VELOCITY_THRESHOLD) {
npcDoomed = true;
playSound('collision'); // Play collision sound
doomTime = (Date.now() - gameStartTime) / 1000; // Time in seconds
doomNPC();
}
}
// Play general collision sounds for significant impacts during gameplay
// (rate-limited to avoid audio spam)
if (isRunning) {
const now = Date.now();
if (now - lastCollisionSoundTime > COLLISION_SOUND_COOLDOWN) {
// Calculate impact velocity between the two bodies
const { bodyA, bodyB } = pair;
// Skip if both bodies are static (no collision sound needed)
if (bodyA.isStatic && bodyB.isStatic) return;
// Calculate relative velocity magnitude
const dx = bodyA.velocity.x - bodyB.velocity.x;
const dy = bodyA.velocity.y - bodyB.velocity.y;
const relativeVelocity = Math.sqrt(dx * dx + dy * dy);
// Play collision sound for impacts above a minimum threshold
const COLLISION_SOUND_THRESHOLD = 1.5;
if (relativeVelocity > COLLISION_SOUND_THRESHOLD) {
playSound('collision');
lastCollisionSoundTime = now;
}
}
}
});
});
// Draw grid overlay after each render
Events.on(render, 'afterRender', () => {
drawGrid(render.context);
});
// Start render after all bodies are created; runner will be started in runMachine()
Render.run(render);
updateStatus('Ready to build! Select an object and click to place it.');
}
function createNPC() {
// Create NPC as a compound body (head + body)
// Position NPC standing on the ground
const npcX = CANVAS_WIDTH - 100;
// Calculate Y position so NPC stands on ground (ground top is at CANVAS_HEIGHT - GROUND_HEIGHT)
// Legs are positioned NPC_LEG_OFFSET pixels below body center
const groundTop = CANVAS_HEIGHT - GROUND_HEIGHT;
const npcY = groundTop - NPC_LEG_OFFSET - NPC_HALF_LEG_HEIGHT;
// Body
const body = Bodies.rectangle(npcX, npcY, 30, 50, {
render: {
fillStyle: '#FF0000'
}
});
// Head
const head = Bodies.circle(npcX, npcY - 35, 15, {
render: {
fillStyle: '#FFB6C1'
}
});
// Arms
const leftArm = Bodies.rectangle(npcX - 20, npcY, 5, 30, {
render: {
fillStyle: '#FFB6C1'
}
});
const rightArm = Bodies.rectangle(npcX + 20, npcY, 5, 30, {
render: {
fillStyle: '#FFB6C1'
}
});
// Legs
const leftLeg = Bodies.rectangle(npcX - 10, npcY + 35, 8, 20, {
render: {
fillStyle: '#0000FF'
}
});
const rightLeg = Bodies.rectangle(npcX + 10, npcY + 35, 8, 20, {
render: {
fillStyle: '#0000FF'
}
});
npc = Body.create({
parts: [body, head, leftArm, rightArm, leftLeg, rightLeg],
isStatic: true,
label: 'npc'
});
Composite.add(world, npc);
}
function setupMouseControl() {
canvas.addEventListener('click', (event) => {
if (isRunning || !selectedTool) return;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
let x = (event.clientX - rect.left) * scaleX;
let y = (event.clientY - rect.top) * scaleY;
// Apply snap-to-grid if enabled
({ x, y } = snapToGrid(x, y));
placeObject(selectedTool, x, y);
});
// Right-click to delete objects
canvas.addEventListener('contextmenu', (event) => {
event.preventDefault();
if (isRunning) return;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = (event.clientX - rect.left) * scaleX;
const y = (event.clientY - rect.top) * scaleY;
deleteObjectAtPosition(x, y);
});
// Touch controls for mobile
setupTouchControls();
}
function setupTouchControls() {
// Helper function to calculate distance between two touches
function getTouchDistance(touch1, touch2) {
const dx = touch2.clientX - touch1.clientX;
const dy = touch2.clientY - touch1.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
// Helper function to calculate angle between two touches
function getTouchAngle(touch1, touch2) {
return Math.atan2(touch2.clientY - touch1.clientY, touch2.clientX - touch1.clientX);
}
// Prevent default touch behaviors to avoid zooming/scrolling
canvas.addEventListener('touchstart', (event) => {
event.preventDefault();
if (event.touches.length === 1) {
// Single touch - placement or long press to delete
const touch = event.touches[0];
touchStartTime = Date.now();
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
touchStartPos.x = (touch.clientX - rect.left) * scaleX;
touchStartPos.y = (touch.clientY - rect.top) * scaleY;
// Start long press timer for delete
if (!isRunning) {
longPressTimer = setTimeout(() => {
// Long press detected - delete object
deleteObjectAtPosition(touchStartPos.x, touchStartPos.y);
longPressTimer = null;
// Visual feedback with vibration if supported
if (navigator.vibrate) {
navigator.vibrate(50);
}
}, LONG_PRESS_DURATION);
}
} else if (event.touches.length === 2) {
// Two-finger touch - rotation for ramp
if (!isRunning && selectedTool === 'ramp') {
lastTouchDistance = getTouchDistance(event.touches[0], event.touches[1]);
lastTouchAngle = getTouchAngle(event.touches[0], event.touches[1]);
initialPinchAngle = lastTouchAngle;
// Cancel long press timer if active
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
}
}, { passive: false });
canvas.addEventListener('touchmove', (event) => {
event.preventDefault();
// Cancel long press timer if finger moves
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
// Two-finger rotation for ramp
if (event.touches.length === 2 && !isRunning && selectedTool === 'ramp') {
const currentAngle = getTouchAngle(event.touches[0], event.touches[1]);
const angleDelta = currentAngle - lastTouchAngle;
// Update ramp angle
rotateRamp(angleDelta);
lastTouchAngle = currentAngle;
// Provide haptic feedback for rotation
if (navigator.vibrate) {
navigator.vibrate(5);
}
}
}, { passive: false });
canvas.addEventListener('touchend', (event) => {
event.preventDefault();
// Clear long press timer if still active
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
const touchDuration = Date.now() - touchStartTime;
// Short tap - place object (if not a long press and single touch)
if (event.touches.length === 0 && touchDuration < LONG_PRESS_DURATION && !isRunning && selectedTool) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
let x = touchStartPos.x;
let y = touchStartPos.y;
// Apply snap-to-grid if enabled
({ x, y } = snapToGrid(x, y));
placeObject(selectedTool, x, y);
}
// Reset touch tracking variables when all touches end
if (event.touches.length === 0) {
lastTouchDistance = 0;
lastTouchAngle = 0;
}
}, { passive: false });
canvas.addEventListener('touchcancel', (event) => {
event.preventDefault();
// Clear long press timer
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
// Reset touch tracking variables
lastTouchDistance = 0;
lastTouchAngle = 0;
}, { passive: false });
}
function setupEventListeners() {
// Tool buttons
document.querySelectorAll('.tool-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
selectedTool = btn.dataset.tool;
// Reset ramp angle when selecting ramp tool
if (selectedTool === 'ramp') {
currentRampAngle = DEFAULT_RAMP_ANGLE;
}
// Extract just the text part without emoji
const toolName = selectedTool.charAt(0).toUpperCase() + selectedTool.slice(1);
updateStatus(`Selected: ${toolName}. Click on canvas to place.`);
});
});
// Action buttons
document.getElementById('runBtn').addEventListener('click', runMachine);
document.getElementById('resetBtn').addEventListener('click', resetMachine);
document.getElementById('clearBtn').addEventListener('click', clearAll);
document.getElementById('undoBtn').addEventListener('click', undo);
document.getElementById('redoBtn').addEventListener('click', redo);
document.getElementById('pauseBtn').addEventListener('click', togglePause);
document.getElementById('slowMotionBtn').addEventListener('click', toggleSlowMotion);
document.getElementById('gridToggleBtn').addEventListener('click', toggleGrid);
// Save/Load buttons
document.getElementById('saveBtn').addEventListener('click', () => {
const name = document.getElementById('saveName').value;
saveContraption(name);
});
document.getElementById('loadBtn').addEventListener('click', () => {
const name = document.getElementById('savedList').value;
loadContraption(name);
});
document.getElementById('deleteBtn').addEventListener('click', () => {
const name = document.getElementById('savedList').value;
deleteContraption(name);
});
// Allow Enter key in save name input
document.getElementById('saveName').addEventListener('keypress', (event) => {
if (event.key === 'Enter') {
const name = document.getElementById('saveName').value;
saveContraption(name);
}
});
// Refresh saved list on page load
refreshSavedList();
// Sound button
const soundBtn = document.getElementById('soundBtn');
if (soundBtn) {
soundBtn.addEventListener('click', () => {
toggleSound();
playSound('ui');
});
}
// Keyboard controls for rotating ramps and undo/redo
document.addEventListener('keydown', (event) => {
// Undo/Redo shortcuts (Ctrl+Z, Ctrl+Y, Ctrl+Shift+Z)
if ((event.ctrlKey || event.metaKey) && !isRunning) {
if (event.shiftKey && (event.key === 'z' || event.key === 'Z')) {
event.preventDefault();
redo();
return;
} else if (event.key === 'z' || event.key === 'Z') {
event.preventDefault();
undo();
return;
} else if (event.key === 'y' || event.key === 'Y') {
event.preventDefault();
redo();
return;
}
}
// Space key for pause/unpause
if (event.key === ' ') {
if (isRunning) {
event.preventDefault(); // Prevent page scroll
togglePause();
}
return;
}
// Ramp rotation controls
if (isRunning || selectedTool !== 'ramp') return;
if (event.key === 'q' || event.key === 'Q') {
// Rotate counter-clockwise
rotateRamp(-ROTATION_INCREMENT);
} else if (event.key === 'e' || event.key === 'E') {
// Rotate clockwise
rotateRamp(ROTATION_INCREMENT);
}
});
}
function rotateRamp(angleChange) {
currentRampAngle += angleChange;
// Normalize angle to keep it within [0, 2π) range
currentRampAngle = normalizeAngle(currentRampAngle);
// Display angle in degrees, wrapped to [0, 359]
const degrees = Math.round(currentRampAngle * 180 / Math.PI) % 360;
updateStatus(`Ramp angle: ${degrees}°`);
}
function recordAction(action) {
// Clear any future history if we're not at the end
actionHistory = actionHistory.slice(0, historyIndex + 1);
actionHistory.push(action);
historyIndex++;
// Prune old history if it exceeds MAX_HISTORY_SIZE
// Keep only the most recent MAX_HISTORY_SIZE entries
if (actionHistory.length > MAX_HISTORY_SIZE) {
const overflow = actionHistory.length - MAX_HISTORY_SIZE;
actionHistory = actionHistory.slice(overflow);
historyIndex -= overflow;
}
updateUndoRedoButtons();
}
function undo() {
if (isRunning || historyIndex < 0) {
if (historyIndex < 0) {
updateStatus('Nothing to undo');
}
return;
}
playSound('ui'); // Play UI sound for undo action
const action = actionHistory[historyIndex];
revertAction(action);
historyIndex--;
updateUndoRedoButtons();
updateStatus('Undone');
}
function redo() {
if (isRunning || historyIndex >= actionHistory.length - 1) {
if (historyIndex >= actionHistory.length - 1) {
updateStatus('Nothing to redo');
}
return;
}
playSound('ui'); // Play UI sound for redo action
historyIndex++;
const action = actionHistory[historyIndex];
applyAction(action);
updateUndoRedoButtons();
updateStatus('Redone');
}
/**
* Helper function to create a seesaw with all its components
* @param {number} x - X position
* @param {number} y - Y position
* @param {number} seesawId - Unique ID for the seesaw (if null, generates new one)
* @returns {{pivot: Body, plank: Body, constraint: Constraint}} The created seesaw components
*/
function createSeesaw(x, y, seesawId = null) {
// Use provided ID or generate new one
if (seesawId === null) {
seesawId = seesawIdCounter++;
}
const pivot = Bodies.rectangle(x, y, 10, 40, {
isStatic: true,
label: LABEL_SEESAW_PIVOT,
render: { fillStyle: '#AA8976' }
});
const plank = Bodies.rectangle(x, y - 20, 120, 10, {
density: 0.05,
label: LABEL_SEESAW_PLANK,
render: { fillStyle: '#EAAC8B' }
});
// Store original positions
pivot.originalPosition = { x: x, y: y };
pivot.originalAngle = 0;
pivot.seesawId = seesawId;
plank.originalPosition = { x: x, y: y - 20 };
plank.originalAngle = 0;
plank.seesawId = seesawId;
// Create constraint
const constraint = Matter.Constraint.create({
bodyA: pivot,
bodyB: plank,
length: 0,
stiffness: 0.9
});
constraint.seesawId = seesawId;
// Store original constraint properties for reset
constraint.originalStiffness = constraint.stiffness;
constraint.originalLength = constraint.length;
// Add to world
Composite.add(world, [pivot, plank, constraint]);
placedObjects.push(pivot, plank);
placedConstraints.push(constraint);
return { pivot, plank, constraint };
}
/**
* Helper function to remove a seesaw by its ID
* @param {number} seesawId - The unique ID of the seesaw to remove
*/
function removeSeesaw(seesawId) {
const pivot = placedObjects.find(obj => obj.label === LABEL_SEESAW_PIVOT && obj.seesawId === seesawId);
const plank = placedObjects.find(obj => obj.label === LABEL_SEESAW_PLANK && obj.seesawId === seesawId);
const constraint = placedConstraints.find(c => c.seesawId === seesawId);
if (pivot) {
Composite.remove(world, pivot);
placedObjects = placedObjects.filter(obj => obj !== pivot);
}
if (plank) {
Composite.remove(world, plank);
placedObjects = placedObjects.filter(obj => obj !== plank);
}
if (constraint) {
Composite.remove(world, constraint);
placedConstraints = placedConstraints.filter(c => c !== constraint);
}
}
function revertAction(action) {
if (action.type === 'place') {
// Remove the placed object(s)
if (action.objectType === 'seesaw') {
// Find and remove seesaw parts by seesawId
removeSeesaw(action.seesawId);
} else {
// Remove single object
// Note: If object was removed during runtime (e.g., explosive detonation),
// this check will fail gracefully and skip removal
const body = action.body;
if (body && placedObjects.includes(body)) {