|
246 | 246 | const STORAGE_KEY = 'robotics_playground_state'; |
247 | 247 | let lastSaveTime = 0; |
248 | 248 |
|
| 249 | + // ========== HUMANS ========== |
| 250 | + let humans = []; |
| 251 | + |
249 | 252 | function generateBlocks() { |
250 | 253 | blocks = []; |
251 | 254 | const shapes = ['square', 'circle', 'triangle']; |
|
263 | 266 | document.getElementById('block-count').textContent = blocks.length; |
264 | 267 | } |
265 | 268 |
|
| 269 | + function generateHumans() { |
| 270 | + humans = []; |
| 271 | + for (let i = 0; i < 5; i++) { |
| 272 | + humans.push({ |
| 273 | + id: 'human_' + i, |
| 274 | + x: 80 + Math.random() * (canvas.width - 160), |
| 275 | + y: 80 + Math.random() * (canvas.height - 160), |
| 276 | + angle: Math.random() * Math.PI * 2, |
| 277 | + speed: 0.4 + Math.random() * 0.6, |
| 278 | + size: 18, |
| 279 | + }); |
| 280 | + } |
| 281 | + } |
| 282 | + |
| 283 | + function updateHumans() { |
| 284 | + humans.forEach((h) => { |
| 285 | + if (Math.random() < 0.02) h.angle += (Math.random() - 0.5) * Math.PI * 0.5; |
| 286 | + h.x += Math.sin(h.angle) * h.speed; |
| 287 | + h.y -= Math.cos(h.angle) * h.speed; |
| 288 | + if (h.x < 30 || h.x > canvas.width - 30) h.angle = Math.PI - h.angle; |
| 289 | + if (h.y < 30 || h.y > canvas.height - 30) h.angle = -h.angle; |
| 290 | + h.x = Math.max(30, Math.min(canvas.width - 30, h.x)); |
| 291 | + h.y = Math.max(30, Math.min(canvas.height - 30, h.y)); |
| 292 | + }); |
| 293 | + } |
| 294 | + |
266 | 295 | // ========== INPUT ========== |
267 | 296 | let keys = {}; |
268 | 297 | let spaceJustPressed = false; |
|
349 | 378 | robot.y = canvas.height / 2; |
350 | 379 | document.getElementById('start-prompt').classList.add('hidden'); |
351 | 380 | generateBlocks(); |
| 381 | + generateHumans(); |
352 | 382 | } |
353 | 383 | if (type === 'camera') { |
354 | 384 | document.getElementById('camera-feed').classList.remove('hidden'); |
|
421 | 451 | }, |
422 | 452 | }; |
423 | 453 | blocks = []; |
| 454 | + humans = []; |
424 | 455 | document.getElementById('start-prompt').classList.remove('hidden'); |
425 | 456 | document.getElementById('camera-feed').classList.add('hidden'); |
426 | 457 | document.getElementById('block-count').textContent = '0'; |
|
494 | 525 | updateDetectionUI(detected); |
495 | 526 | } |
496 | 527 | } |
| 528 | + |
| 529 | + updateHumans(); |
497 | 530 | } |
498 | 531 |
|
499 | 532 | function drawGrid() { |
|
677 | 710 | function drawCameraView() { |
678 | 711 | if (!robot.parts.camera) return; |
679 | 712 |
|
| 713 | + const camW = 176, camH = 112, horizon = 56; |
| 714 | + const fov = Math.PI / 2; |
| 715 | + const detectionPad = 3; |
| 716 | + |
680 | 717 | // Sky gradient |
681 | | - const sky = camCtx.createLinearGradient(0, 0, 0, 56); |
| 718 | + const sky = camCtx.createLinearGradient(0, 0, 0, horizon); |
682 | 719 | sky.addColorStop(0, '#0c1929'); |
683 | 720 | sky.addColorStop(1, '#1e3a5f'); |
684 | 721 | camCtx.fillStyle = sky; |
685 | | - camCtx.fillRect(0, 0, 176, 56); |
| 722 | + camCtx.fillRect(0, 0, camW, horizon); |
686 | 723 |
|
687 | 724 | // Ground gradient |
688 | | - const gnd = camCtx.createLinearGradient(0, 56, 0, 112); |
| 725 | + const gnd = camCtx.createLinearGradient(0, horizon, 0, camH); |
689 | 726 | gnd.addColorStop(0, '#374151'); |
690 | 727 | gnd.addColorStop(1, '#1f2937'); |
691 | 728 | camCtx.fillStyle = gnd; |
692 | | - camCtx.fillRect(0, 56, 176, 56); |
| 729 | + camCtx.fillRect(0, horizon, camW, horizon); |
693 | 730 |
|
694 | | - // Render blocks in view |
695 | | - blocks.forEach((b) => { |
696 | | - const cx = b.x + b.size / 2; |
697 | | - const cy = b.y + b.size / 2; |
698 | | - const dx = cx - robot.x; |
699 | | - const dy = cy - robot.y; |
| 731 | + // Helper: project a world position into camera screen coordinates |
| 732 | + function project(wx, wy) { |
| 733 | + const dx = wx - robot.x; |
| 734 | + const dy = wy - robot.y; |
700 | 735 | const dist = Math.hypot(dx, dy); |
701 | | - |
702 | 736 | let worldAng = Math.atan2(dx, -dy); |
703 | 737 | let relAng = worldAng - robot.angle; |
704 | 738 | while (relAng > Math.PI) relAng -= 2 * Math.PI; |
705 | 739 | while (relAng < -Math.PI) relAng += 2 * Math.PI; |
| 740 | + if (Math.abs(relAng) >= fov / 2 || dist > 400 || dist < 20) return null; |
| 741 | + const screenX = 88 + (relAng / (fov / 2)) * 88; |
| 742 | + const sz = Math.max(6, 600 / dist); |
| 743 | + const screenY = horizon - sz / 2 + dist / 30; |
| 744 | + return { screenX, screenY, sz, dist }; |
| 745 | + } |
706 | 746 |
|
707 | | - const fov = Math.PI / 2; |
708 | | - if (Math.abs(relAng) < fov / 2 && dist < 400 && dist > 40) { |
709 | | - const screenX = 88 + (relAng / (fov / 2)) * 88; |
710 | | - const sz = Math.max(6, 600 / dist); |
711 | | - const screenY = 56 - sz / 2 + dist / 30; |
712 | | - |
713 | | - camCtx.fillStyle = b.color; |
714 | | - camCtx.fillRect(screenX - sz / 2, Math.max(5, screenY), sz, sz); |
715 | | - camCtx.strokeStyle = '#fff'; |
716 | | - camCtx.lineWidth = 1; |
717 | | - camCtx.strokeRect(screenX - sz / 2, Math.max(5, screenY), sz, sz); |
718 | | - } |
| 747 | + // Render blocks with detection bounding boxes |
| 748 | + blocks.forEach((b) => { |
| 749 | + if (b.held) return; |
| 750 | + const proj = project(b.x + b.size / 2, b.y + b.size / 2); |
| 751 | + if (!proj) return; |
| 752 | + const { screenX, screenY, sz } = proj; |
| 753 | + const sy = Math.max(5, screenY); |
| 754 | + |
| 755 | + camCtx.fillStyle = b.color; |
| 756 | + camCtx.fillRect(screenX - sz / 2, sy, sz, sz); |
| 757 | + camCtx.strokeStyle = '#fff'; |
| 758 | + camCtx.lineWidth = 1; |
| 759 | + camCtx.strokeRect(screenX - sz / 2, sy, sz, sz); |
| 760 | + |
| 761 | + // Detection bounding box (cyan) |
| 762 | + const bx = screenX - sz / 2 - detectionPad; |
| 763 | + const by = sy - detectionPad; |
| 764 | + const bw = sz + detectionPad * 2; |
| 765 | + const bh = sz + detectionPad * 2; |
| 766 | + camCtx.strokeStyle = '#22d3ee'; |
| 767 | + camCtx.lineWidth = 1; |
| 768 | + camCtx.strokeRect(bx, by, bw, bh); |
| 769 | + |
| 770 | + // Label |
| 771 | + const label = b.shape.charAt(0).toUpperCase() + b.shape.slice(1); |
| 772 | + camCtx.fillStyle = '#22d3ee'; |
| 773 | + camCtx.font = 'bold 6px monospace'; |
| 774 | + const ly = by - 1; |
| 775 | + camCtx.fillText(label, bx, ly > 6 ? ly : by + bh + 7); |
| 776 | + }); |
| 777 | + |
| 778 | + // Render humans with detection bounding boxes (orange) |
| 779 | + humans.forEach((h) => { |
| 780 | + const proj = project(h.x, h.y); |
| 781 | + if (!proj) return; |
| 782 | + const { screenX, screenY, sz } = proj; |
| 783 | + const sy = Math.max(5, screenY); |
| 784 | + const hw = Math.max(6, sz * 0.7); |
| 785 | + const headR = hw / 3; |
| 786 | + const bodyH = Math.max(4, sz * 0.6); |
| 787 | + |
| 788 | + // Silhouette: head + body |
| 789 | + camCtx.fillStyle = '#94a3b8'; |
| 790 | + camCtx.beginPath(); |
| 791 | + camCtx.arc(screenX, sy, headR, 0, Math.PI * 2); |
| 792 | + camCtx.fill(); |
| 793 | + camCtx.fillRect(screenX - hw / 3, sy, (hw * 2) / 3, bodyH); |
| 794 | + |
| 795 | + // Detection bounding box (orange) |
| 796 | + const bx = screenX - hw / 2 - detectionPad; |
| 797 | + const by = sy - headR - detectionPad; |
| 798 | + const bw = hw + detectionPad * 2; |
| 799 | + const bh = headR + bodyH + detectionPad * 2; |
| 800 | + camCtx.strokeStyle = '#fb923c'; |
| 801 | + camCtx.lineWidth = 1; |
| 802 | + camCtx.strokeRect(bx, by, bw, bh); |
| 803 | + |
| 804 | + // Label |
| 805 | + camCtx.fillStyle = '#fb923c'; |
| 806 | + camCtx.font = 'bold 6px monospace'; |
| 807 | + const ly = by - 1; |
| 808 | + camCtx.fillText('Human', bx, ly > 6 ? ly : by + bh + 7); |
719 | 809 | }); |
720 | 810 |
|
721 | 811 | // Crosshair |
|
729 | 819 | camCtx.stroke(); |
730 | 820 | } |
731 | 821 |
|
| 822 | + function drawHumans() { |
| 823 | + humans.forEach((h) => { |
| 824 | + ctx.save(); |
| 825 | + ctx.translate(h.x, h.y); |
| 826 | + ctx.rotate(h.angle); |
| 827 | + ctx.strokeStyle = '#e2e8f0'; |
| 828 | + ctx.fillStyle = '#e2e8f0'; |
| 829 | + ctx.lineWidth = 2; |
| 830 | + ctx.lineCap = 'round'; |
| 831 | + // Head |
| 832 | + ctx.beginPath(); |
| 833 | + ctx.arc(0, -14, 5, 0, Math.PI * 2); |
| 834 | + ctx.fill(); |
| 835 | + // Body |
| 836 | + ctx.beginPath(); |
| 837 | + ctx.moveTo(0, -9); |
| 838 | + ctx.lineTo(0, 4); |
| 839 | + ctx.stroke(); |
| 840 | + // Arms |
| 841 | + ctx.beginPath(); |
| 842 | + ctx.moveTo(-8, -4); |
| 843 | + ctx.lineTo(8, -4); |
| 844 | + ctx.stroke(); |
| 845 | + // Legs |
| 846 | + ctx.beginPath(); |
| 847 | + ctx.moveTo(0, 4); |
| 848 | + ctx.lineTo(-6, 14); |
| 849 | + ctx.moveTo(0, 4); |
| 850 | + ctx.lineTo(6, 14); |
| 851 | + ctx.stroke(); |
| 852 | + ctx.restore(); |
| 853 | + }); |
| 854 | + } |
| 855 | + |
732 | 856 | function render() { |
733 | 857 | ctx.fillStyle = '#0a0a0a'; |
734 | 858 | ctx.fillRect(0, 0, canvas.width, canvas.height); |
735 | 859 | drawGrid(); |
736 | 860 | drawBlocks(); |
| 861 | + drawHumans(); |
737 | 862 | drawRobot(); |
738 | 863 | drawCameraView(); |
739 | 864 | } |
|
800 | 925 | } |
801 | 926 |
|
802 | 927 | function getLidarDetection() { |
803 | | - if (!robot.parts.lidar || blocks.length === 0) return null; |
| 928 | + if (!robot.parts.lidar || (blocks.length === 0 && humans.length === 0)) return null; |
804 | 929 | const sw = (((Date.now() / 15) % 360) * Math.PI) / 180; |
805 | 930 | // Lidar center in world coordinates (local offset: 0, -32) |
806 | 931 | const lidarX = robot.x + 32 * Math.sin(robot.angle); |
|
812 | 937 | const beamEndY = lidarY + Math.sin(worldBeamAngle) * beamRange; |
813 | 938 | for (const b of blocks) { |
814 | 939 | if (b.held) continue; |
815 | | - if (beamIntersectsBlock(lidarX, lidarY, beamEndX, beamEndY, b)) return b; |
| 940 | + if (beamIntersectsBlock(lidarX, lidarY, beamEndX, beamEndY, b)) return { ...b, type: 'block' }; |
| 941 | + } |
| 942 | + for (const h of humans) { |
| 943 | + if (pointToSegmentDist(h.x, h.y, lidarX, lidarY, beamEndX, beamEndY) < h.size / 2) { |
| 944 | + return { ...h, type: 'human' }; |
| 945 | + } |
816 | 946 | } |
817 | 947 | return null; |
818 | 948 | } |
|
836 | 966 | const el = document.getElementById('detection-info'); |
837 | 967 | const indicator = document.getElementById('detection-indicator'); |
838 | 968 | if (detected) { |
839 | | - const colorName = getColorName(detected.color); |
840 | | - const shapeName = detected.shape.charAt(0).toUpperCase() + detected.shape.slice(1); |
841 | | - el.innerHTML = |
842 | | - '<div class="text-green-400 font-semibold">● OBJECT DETECTED</div>' + |
843 | | - '<div class="text-gray-300 mt-0.5">Shape: <span class="text-yellow-300">' + shapeName + '</span></div>' + |
844 | | - '<div class="text-gray-300">Color: <span style="color:' + detected.color + ';font-weight:600">' + colorName + '</span></div>'; |
845 | 969 | indicator.classList.remove('hidden'); |
| 970 | + if (detected.type === 'human') { |
| 971 | + el.innerHTML = |
| 972 | + '<div class="text-orange-400 font-semibold">● HUMAN DETECTED</div>' + |
| 973 | + '<div class="text-gray-300 mt-0.5">Type: <span class="text-orange-300">Human</span></div>'; |
| 974 | + } else { |
| 975 | + const colorName = getColorName(detected.color); |
| 976 | + const shapeName = detected.shape.charAt(0).toUpperCase() + detected.shape.slice(1); |
| 977 | + el.innerHTML = |
| 978 | + '<div class="text-green-400 font-semibold">● OBJECT DETECTED</div>' + |
| 979 | + '<div class="text-gray-300 mt-0.5">Shape: <span class="text-yellow-300">' + shapeName + '</span></div>' + |
| 980 | + '<div class="text-gray-300">Color: <span style="color:' + detected.color + ';font-weight:600">' + colorName + '</span></div>'; |
| 981 | + } |
846 | 982 | } else { |
847 | 983 | el.innerHTML = '<div class="text-gray-500">No objects detected</div>'; |
848 | 984 | indicator.classList.add('hidden'); |
|
0 commit comments