Download index.html from kyoungsim/microduck: direct link, hf CLI and curl.
- Browser
- Download file 39.7 kB
-
https://huggingface.co/kyoungsim/microduck/resolve/main/index.html
- Command line
-
hf download hf://kyoungsim/microduck/index.html
-
curl -L -o index.html https://huggingface.co/kyoungsim/microduck/resolve/main/index.html
39.7 kB
| <html lang="ko"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>MicroDuck 3D Robot Web Viewer</title> | |
| <!-- Google Fonts: Inter --> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --bg-dark: #0b0f19; | |
| --panel-bg: rgba(15, 23, 42, 0.85); | |
| --panel-border: rgba(255, 255, 255, 0.12); | |
| --primary: #f59e0b; | |
| --primary-hover: #d97706; | |
| --primary-glow: rgba(245, 158, 11, 0.45); | |
| --accent: #38bdf8; | |
| --text-main: #f8fafc; | |
| --text-muted: #94a3b8; | |
| --card-bg: rgba(30, 41, 59, 0.65); | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| user-select: none; | |
| } | |
| body { | |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; | |
| background-color: var(--bg-dark); | |
| color: var(--text-main); | |
| overflow: hidden; | |
| width: 100vw; | |
| height: 100vh; | |
| } | |
| #webgl-canvas { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| z-index: 1; | |
| } | |
| /* Floating UI Container */ | |
| .ui-overlay { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| z-index: 10; | |
| pointer-events: none; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: space-between; | |
| padding: 24px; | |
| } | |
| /* Header */ | |
| header { | |
| pointer-events: auto; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| background: var(--panel-bg); | |
| backdrop-filter: blur(20px); | |
| -webkit-backdrop-filter: blur(20px); | |
| border: 1px solid var(--panel-border); | |
| border-radius: 20px; | |
| padding: 14px 26px; | |
| box-shadow: 0 12px 35px rgba(0, 0, 0, 0.5); | |
| max-width: 1240px; | |
| margin: 0 auto; | |
| width: 100%; | |
| } | |
| .brand { | |
| display: flex; | |
| align-items: center; | |
| gap: 15px; | |
| } | |
| .brand-logo { | |
| width: 44px; | |
| height: 44px; | |
| background: linear-gradient(135deg, #f59e0b, #ea580c); | |
| border-radius: 12px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 26px; | |
| box-shadow: 0 4px 18px var(--primary-glow); | |
| } | |
| .brand-text h1 { | |
| font-size: 20px; | |
| font-weight: 800; | |
| letter-spacing: -0.5px; | |
| color: #fff; | |
| } | |
| .brand-text p { | |
| font-size: 12px; | |
| color: var(--text-muted); | |
| margin-top: 1px; | |
| } | |
| .header-links { | |
| display: flex; | |
| gap: 12px; | |
| } | |
| .btn-link { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| background: rgba(255, 255, 255, 0.08); | |
| color: #fff; | |
| text-decoration: none; | |
| font-size: 13px; | |
| font-weight: 600; | |
| padding: 9px 18px; | |
| border-radius: 12px; | |
| border: 1px solid var(--panel-border); | |
| transition: all 0.2s ease; | |
| } | |
| .btn-link:hover { | |
| background: rgba(255, 255, 255, 0.16); | |
| transform: translateY(-1px); | |
| } | |
| .btn-link.primary { | |
| background: linear-gradient(135deg, var(--primary), var(--primary-hover)); | |
| border: none; | |
| box-shadow: 0 4px 16px var(--primary-glow); | |
| } | |
| .btn-link.primary:hover { | |
| filter: brightness(1.12); | |
| transform: translateY(-1px); | |
| } | |
| /* Controls Wrapper */ | |
| .controls-wrapper { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-end; | |
| width: 100%; | |
| max-width: 1240px; | |
| margin: 0 auto; | |
| gap: 20px; | |
| } | |
| .panel { | |
| pointer-events: auto; | |
| background: var(--panel-bg); | |
| backdrop-filter: blur(20px); | |
| -webkit-backdrop-filter: blur(20px); | |
| border: 1px solid var(--panel-border); | |
| border-radius: 20px; | |
| padding: 20px; | |
| box-shadow: 0 12px 35px rgba(0, 0, 0, 0.5); | |
| } | |
| .motion-panel { | |
| width: 360px; | |
| } | |
| .panel-title { | |
| font-size: 14px; | |
| font-weight: 700; | |
| color: #fff; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 14px; | |
| } | |
| .motion-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 9px; | |
| margin-bottom: 14px; | |
| } | |
| .btn-motion { | |
| background: var(--card-bg); | |
| border: 1px solid var(--panel-border); | |
| border-radius: 11px; | |
| color: var(--text-main); | |
| padding: 10px 10px; | |
| font-size: 12.5px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 6px; | |
| transition: all 0.2s ease; | |
| } | |
| .btn-motion:hover { | |
| background: rgba(245, 158, 11, 0.18); | |
| border-color: var(--primary); | |
| color: #fff; | |
| transform: translateY(-2px); | |
| } | |
| .btn-motion.active { | |
| background: linear-gradient(135deg, var(--primary), var(--primary-hover)); | |
| border-color: transparent; | |
| color: #fff; | |
| box-shadow: 0 4px 14px var(--primary-glow); | |
| } | |
| .btn-motion.kick-special { | |
| border: 1px solid #10b981; | |
| color: #a7f3d0; | |
| } | |
| .btn-motion.kick-special:hover { | |
| background: rgba(16, 185, 129, 0.2); | |
| border-color: #10b981; | |
| color: #fff; | |
| } | |
| .view-toggles { | |
| display: flex; | |
| gap: 8px; | |
| margin-top: 10px; | |
| padding-top: 12px; | |
| border-top: 1px solid rgba(255, 255, 255, 0.08); | |
| } | |
| .btn-icon { | |
| flex: 1; | |
| background: rgba(255, 255, 255, 0.06); | |
| border: 1px solid var(--panel-border); | |
| border-radius: 9px; | |
| color: var(--text-muted); | |
| padding: 9px; | |
| font-size: 12px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| text-align: center; | |
| transition: all 0.2s ease; | |
| } | |
| .btn-icon:hover { | |
| background: rgba(255, 255, 255, 0.14); | |
| color: #fff; | |
| } | |
| .btn-icon.ball-btn { | |
| color: #38bdf8; | |
| border-color: rgba(56, 189, 248, 0.3); | |
| } | |
| .btn-icon.ball-btn:hover { | |
| background: rgba(56, 189, 248, 0.15); | |
| color: #fff; | |
| } | |
| /* Specs Panel */ | |
| .specs-panel { | |
| width: 300px; | |
| } | |
| .spec-item { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 8px 0; | |
| font-size: 12px; | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.06); | |
| } | |
| .spec-item:last-child { | |
| border-bottom: none; | |
| } | |
| .spec-label { | |
| color: var(--text-muted); | |
| } | |
| .spec-val { | |
| color: #fff; | |
| font-weight: 600; | |
| } | |
| /* Top-Left Soccer & Drive Control Menu (๋์ ๊ฐ๋ฆฌ์ง ์๋๋ก ์ข์ธก ์๋จ ๋ฐฐ์น) */ | |
| .soccer-ctrl-card { | |
| pointer-events: auto; | |
| position: absolute; | |
| top: 92px; | |
| left: 24px; | |
| background: var(--panel-bg); | |
| backdrop-filter: blur(16px); | |
| -webkit-backdrop-filter: blur(16px); | |
| border: 1px solid var(--panel-border); | |
| border-radius: 16px; | |
| padding: 13px 16px; | |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45); | |
| z-index: 25; | |
| width: 255px; | |
| } | |
| .ctrl-item { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 7px; | |
| font-size: 12px; | |
| } | |
| .ctrl-item:last-child { | |
| margin-bottom: 0; | |
| } | |
| .ctrl-label { | |
| color: var(--text-main); | |
| font-weight: 500; | |
| display: flex; | |
| align-items: center; | |
| gap: 5px; | |
| } | |
| .ctrl-key { | |
| background: rgba(245, 158, 11, 0.18); | |
| border: 1px solid rgba(245, 158, 11, 0.4); | |
| color: #f59e0b; | |
| font-weight: 700; | |
| padding: 2px 7px; | |
| border-radius: 6px; | |
| font-size: 11.5px; | |
| white-space: nowrap; | |
| } | |
| .kick-alert { | |
| position: absolute; | |
| top: 90px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: linear-gradient(135deg, #10b981, #059669); | |
| color: #fff; | |
| padding: 8px 24px; | |
| border-radius: 20px; | |
| font-size: 14px; | |
| font-weight: 700; | |
| box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4); | |
| opacity: 0; | |
| pointer-events: none; | |
| transition: opacity 0.3s ease, transform 0.3s ease; | |
| z-index: 50; | |
| } | |
| .kick-alert.show { | |
| opacity: 1; | |
| transform: translateX(-50%) translateY(10px); | |
| } | |
| @media (max-width: 768px) { | |
| .controls-wrapper { | |
| flex-direction: column; | |
| align-items: stretch; | |
| } | |
| .motion-panel, .specs-panel { | |
| width: 100%; | |
| } | |
| .hint-bar { | |
| display: none; | |
| } | |
| } | |
| </style> | |
| <!-- Three.js and OrbitControls --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script> | |
| </head> | |
| <body> | |
| <!-- Canvas for 3D View --> | |
| <canvas id="webgl-canvas"></canvas> | |
| <!-- Kick Goal Notification Popup --> | |
| <div id="kick-alert" class="kick-alert">โฝ GOAL! ์ค๋ฆฌ๊ฐ ์ถ๊ตฌ๊ณต์ ๋ฉ์ง๊ฒ ์ฐผ์ต๋๋ค!</div> | |
| <!-- UI Overlay --> | |
| <div class="ui-overlay"> | |
| <!-- Header --> | |
| <header> | |
| <div class="brand"> | |
| <div class="brand-logo">๐ฆ</div> | |
| <div class="brand-text"> | |
| <h1>MicroDuck 3D Robot & Soccer Game</h1> | |
| <p>Cute Bipedal Duck Robot & Ball Kicking RL Showcase</p> | |
| </div> | |
| </div> | |
| <div class="header-links"> | |
| <a href="https://huggingface.co/kyoungsim/microduck" target="_blank" class="btn-link primary"> | |
| <span>๐ฆ</span> Hugging Face Model Hub | |
| </a> | |
| </div> | |
| </header> | |
| <!-- Top-Left Soccer & Drive Control Guide (์ค์ ์์ผ๋ฅผ ๊ฐ๋ฆฌ์ง ์๋๋ก ์ข์ธก ์๋จ ๋ฐฐ์น) --> | |
| <div class="soccer-ctrl-card"> | |
| <div class="panel-title" style="font-size: 13px; margin-bottom: 10px;"> | |
| <span>โฝ</span> ์ถ๊ตฌ & ์กฐ์ ํค ์๋ด | |
| </div> | |
| <div class="ctrl-item"> | |
| <span class="ctrl-label">๐ ์ผ๋ฐ ๊ณต์ฐจ๊ธฐ ์</span> | |
| <span class="ctrl-key">K / Space</span> | |
| </div> | |
| <div class="ctrl-item"> | |
| <span class="ctrl-label">๐ ์์ผ๋ก ๊ฑธ์ด๊ฐ๊ธฐ / ๊ณต ๋ฐ๊ธฐ</span> | |
| <span class="ctrl-key">โ / W</span> | |
| </div> | |
| <div class="ctrl-item"> | |
| <span class="ctrl-label">๐ ๋ฐฉํฅ ์ ํ</span> | |
| <span class="ctrl-key">โ โ / A D</span> | |
| </div> | |
| <div class="ctrl-item"> | |
| <span class="ctrl-label">โฝ ๊ณต ๋ฆฌ์ </span> | |
| <span class="ctrl-key">R</span> | |
| </div> | |
| </div> | |
| <!-- Bottom Controls --> | |
| <div class="controls-wrapper"> | |
| <!-- Motion Controller Panel --> | |
| <div class="panel motion-panel"> | |
| <div class="panel-title"> | |
| <span>๐ฏ</span> ๋ก๋ด ๋ชจ์ & ์ถ๊ตฌ ์กฐ์ | |
| </div> | |
| <div class="motion-grid"> | |
| <button id="btn-kick" class="btn-motion kick-special" onclick="setMotion('kick')">โฝ ์ผ๋ฐ ์ (Kick!)</button> | |
| <button id="btn-walk" class="btn-motion active" onclick="setMotion('walk')">๐ ์ ์ง ๊ฑท๊ธฐ (Forward)</button> | |
| <button id="btn-waddle" class="btn-motion" onclick="setMotion('waddle')">๐ฆ ์ ์๋ฆฌ ๋ค๋ฑ (Waddle)</button> | |
| <button id="btn-stand" class="btn-motion" onclick="setMotion('stand')">๐ง ๊ท ํ ์๊ธฐ (Stand)</button> | |
| <button id="btn-sit" class="btn-motion" onclick="setMotion('sit')">๐ช ์ชผ๊ทธ๋ ค ์๊ธฐ (Sit)</button> | |
| <button id="btn-peck" class="btn-motion" onclick="setMotion('peck')">๐พ ๋ฐ๋ฅ ์ชผ๊ธฐ (Peck)</button> | |
| </div> | |
| <div class="panel-title" style="margin-top: 10px; margin-bottom: 8px;"> | |
| <span>๐ฅ</span> ์นด๋ฉ๋ผ & ๊ณต ์กฐ์ | |
| </div> | |
| <div class="view-toggles"> | |
| <button class="btn-icon ball-btn" onclick="resetBall()">โฝ ๊ณต ๋ฆฌ์ </button> | |
| <button class="btn-icon" onclick="resetPosition()">๐ ์์น ๋ฆฌ์ </button> | |
| <button class="btn-icon" onclick="resetCamera('orbit')">3D ์ ์ฒด</button> | |
| <button class="btn-icon" onclick="toggleAutoRotate()">๐ ํ์ </button> | |
| </div> | |
| </div> | |
| <!-- Specs Panel --> | |
| <div class="panel specs-panel"> | |
| <div class="panel-title"> | |
| <span>โ๏ธ</span> MicroDuck & ์ถ๊ตฌ๊ณต ๋ฌผ๋ฆฌ ์ํ | |
| </div> | |
| <div class="spec-item"> | |
| <span class="spec-label">ํ์ฌ ๋์</span> | |
| <span id="stat-mode" class="spec-val" style="color: #10b981;">์ ์ง ๊ฑท๊ธฐ (Forward)</span> | |
| </div> | |
| <div class="spec-item"> | |
| <span class="spec-label">์ถ๊ตฌ๊ณต ์๋</span> | |
| <span id="stat-ball-speed" class="spec-val">0.00 m/s</span> | |
| </div> | |
| <div class="spec-item"> | |
| <span class="spec-label">์ถ๊ตฌ๊ณต ์ด๋ ๊ฑฐ๋ฆฌ</span> | |
| <span id="stat-ball-dist" class="spec-val">0.00 m</span> | |
| </div> | |
| <div class="spec-item"> | |
| <span class="spec-label">๊ฐํํ์ต ๊ณต์ฐจ๊ธฐ ์ ์ฑ </span> | |
| <span class="spec-val" style="color: #38bdf8;">ball_kick_left.onnx</span> | |
| </div> | |
| <div class="spec-item"> | |
| <span class="spec-label">๋ฌผ๋ฆฌ ์๋ฎฌ๋ ์ดํฐ</span> | |
| <span class="spec-val">MuJoCo scene_ball.xml</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // --- Three.js Scene Setup --- | |
| const canvas = document.getElementById('webgl-canvas'); | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x0b0f19); | |
| scene.fog = new THREE.FogExp2(0x0b0f19, 0.16); | |
| const camera = new THREE.PerspectiveCamera(42, window.innerWidth / window.innerHeight, 0.05, 50); | |
| camera.position.set(0.65, 0.42, 0.75); | |
| const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| const controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| controls.target.set(0, 0.13, 0); | |
| controls.maxPolarAngle = Math.PI / 2 - 0.01; | |
| controls.minDistance = 0.25; | |
| controls.maxDistance = 4.0; | |
| // --- Studio Lighting --- | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.85); | |
| scene.add(ambientLight); | |
| const mainLight = new THREE.DirectionalLight(0xffedd5, 1.4); | |
| mainLight.position.set(1.5, 3.5, 2.0); | |
| mainLight.castShadow = true; | |
| mainLight.shadow.mapSize.width = 2048; | |
| mainLight.shadow.mapSize.height = 2048; | |
| mainLight.shadow.bias = -0.0003; | |
| scene.add(mainLight); | |
| const fillLight = new THREE.DirectionalLight(0x38bdf8, 0.85); | |
| fillLight.position.set(-2.0, 1.5, -1.0); | |
| scene.add(fillLight); | |
| const rimLight = new THREE.PointLight(0xf59e0b, 1.6, 4); | |
| rimLight.position.set(0, 1.5, -1.5); | |
| scene.add(rimLight); | |
| // --- Soccer Ground Grid (y = 0) --- | |
| const gridHelper = new THREE.GridHelper(30, 150, 0x10b981, 0x1e293b); | |
| gridHelper.position.y = 0.0005; | |
| scene.add(gridHelper); | |
| const floorGeo = new THREE.PlaneGeometry(60, 60); | |
| const floorMat = new THREE.MeshStandardMaterial({ | |
| color: 0x091410, // ์ด์ง ์๋ ๋๋์ด ๊ฐ๋๋ ์ธ๋ จ๋ ๋คํฌ ๊ทธ๋ฆฐ | |
| roughness: 0.85, | |
| metalness: 0.15 | |
| }); | |
| const floor = new THREE.Mesh(floorGeo, floorMat); | |
| floor.rotation.x = -Math.PI / 2; | |
| floor.receiveShadow = true; | |
| scene.add(floor); | |
| // ========================================== | |
| // โฝ SOCCER BALL (ํด๋์ ํ ์คํ ๋ฌด๋ฌ ์ถ๊ตฌ๊ณต) | |
| // ========================================== | |
| const BALL_RADIUS = 0.035; // MuJoCo scene_ball.xml๊ณผ ์ผ์นํ๋ ๋ฐ๊ฒฝ 3.5cm | |
| function createSoccerTexture() { | |
| const cvs = document.createElement('canvas'); | |
| cvs.width = 512; | |
| cvs.height = 256; | |
| const ctx = cvs.getContext('2d'); | |
| // ํฐ์ ๊ฐ์ฃฝ ๋ฒ ์ด์ค | |
| ctx.fillStyle = '#f8fafc'; | |
| ctx.fillRect(0, 0, 512, 256); | |
| // ๋ธ๋ 5๊ฐํ ํจํด | |
| ctx.fillStyle = '#0f172a'; | |
| const drawPentagon = (cx, cy, r) => { | |
| ctx.beginPath(); | |
| for (let i = 0; i < 5; i++) { | |
| const a = (i * 2 * Math.PI / 5) - Math.PI / 2; | |
| const x = cx + r * Math.cos(a); | |
| const y = cy + r * Math.sin(a); | |
| if (i === 0) ctx.moveTo(x, y); | |
| else ctx.lineTo(x, y); | |
| } | |
| ctx.closePath(); | |
| ctx.fill(); | |
| }; | |
| const centers = [ | |
| [85, 60], [256, 60], [427, 60], | |
| [170, 130], [341, 130], | |
| [85, 200], [256, 200], [427, 200], | |
| [0, 130], [512, 130] | |
| ]; | |
| centers.forEach(([x, y]) => drawPentagon(x, y, 22)); | |
| // ๊ฐ์ฃฝ ๋ด์ ์ ๋ํ ์ผ | |
| ctx.strokeStyle = '#94a3b8'; | |
| ctx.lineWidth = 2.5; | |
| centers.forEach(([cx, cy]) => { | |
| for (let i = 0; i < 5; i++) { | |
| const a = (i * 2 * Math.PI / 5) - Math.PI / 2; | |
| ctx.beginPath(); | |
| ctx.moveTo(cx + 22 * Math.cos(a), cy + 22 * Math.sin(a)); | |
| ctx.lineTo(cx + 38 * Math.cos(a), cy + 38 * Math.sin(a)); | |
| ctx.stroke(); | |
| } | |
| }); | |
| return new THREE.CanvasTexture(cvs); | |
| } | |
| const ballTexture = createSoccerTexture(); | |
| const ballGeo = new THREE.SphereGeometry(BALL_RADIUS, 32, 24); | |
| const ballMat = new THREE.MeshStandardMaterial({ | |
| map: ballTexture, | |
| roughness: 0.35, | |
| metalness: 0.1, | |
| }); | |
| const ballMesh = new THREE.Mesh(ballGeo, ballMat); | |
| ballMesh.castShadow = true; | |
| ballMesh.receiveShadow = true; | |
| scene.add(ballMesh); | |
| // Ball Physics State | |
| const ballPos = new THREE.Vector3(-0.045, BALL_RADIUS, 0.08); // ์ค๋ฆฌ ์ผ๋ฐ ์ ์์น | |
| const ballVel = new THREE.Vector3(0, 0, 0); | |
| const ballSpin = new THREE.Vector3(0, 0, 0); | |
| let totalBallDistance = 0.0; | |
| let ballStartPos = ballPos.clone(); | |
| ballMesh.position.copy(ballPos); | |
| function resetBall() { | |
| // ์ค๋ฆฌ์ ํ์ฌ ์์น์ ๋ฐฉํฅ์ ๊ณ ๋ คํด ์ผ๋ฐ ์์ชฝ์ผ๋ก ๋ฆฌ์ | |
| const fwd = new THREE.Vector3(Math.sin(robotHeading), 0, Math.cos(robotHeading)); | |
| const left = new THREE.Vector3(-Math.cos(robotHeading), 0, Math.sin(robotHeading)); | |
| const targetPos = robotRoot.position.clone() | |
| .addScaledVector(fwd, 0.08) | |
| .addScaledVector(left, 0.042); | |
| targetPos.y = BALL_RADIUS; | |
| ballPos.copy(targetPos); | |
| ballVel.set(0, 0, 0); | |
| ballSpin.set(0, 0, 0); | |
| ballMesh.position.copy(ballPos); | |
| ballStartPos.copy(ballPos); | |
| totalBallDistance = 0.0; | |
| document.getElementById('stat-ball-dist').innerText = `0.00 m`; | |
| document.getElementById('stat-ball-speed').innerText = `0.00 m/s`; | |
| } | |
| function triggerKickGoal() { | |
| const alert = document.getElementById('kick-alert'); | |
| alert.classList.add('show'); | |
| setTimeout(() => alert.classList.remove('show'), 2200); | |
| } | |
| // ========================================== | |
| // ๐ฆ CUTE DETAILED MICRODUCK ROBOT ASSEMBLY | |
| // ========================================== | |
| const duckYellowMat = new THREE.MeshStandardMaterial({ | |
| color: 0xfbbf24, | |
| roughness: 0.28, | |
| metalness: 0.12, | |
| }); | |
| const mechDarkMat = new THREE.MeshStandardMaterial({ | |
| color: 0x1e293b, | |
| roughness: 0.4, | |
| metalness: 0.4, | |
| }); | |
| const metalSilverMat = new THREE.MeshStandardMaterial({ | |
| color: 0xa1a1aa, | |
| roughness: 0.25, | |
| metalness: 0.75, | |
| }); | |
| const duckOrangeBeakMat = new THREE.MeshStandardMaterial({ | |
| color: 0xf97316, | |
| roughness: 0.3, | |
| metalness: 0.08, | |
| }); | |
| const eyeBlackMat = new THREE.MeshStandardMaterial({ | |
| color: 0x09090b, | |
| roughness: 0.08, | |
| metalness: 0.95, | |
| }); | |
| const eyeGlintMat = new THREE.MeshBasicMaterial({ color: 0xffffff }); | |
| const sensorBlueMat = new THREE.MeshStandardMaterial({ | |
| color: 0x0284c7, | |
| emissive: 0x0284c7, | |
| emissiveIntensity: 0.7, | |
| roughness: 0.1, | |
| }); | |
| const BASE_STAND_Y = 0.155; | |
| const robotRoot = new THREE.Group(); | |
| robotRoot.position.set(0, BASE_STAND_Y, 0); | |
| scene.add(robotRoot); | |
| // 1. Trunk (๋ชธํต) | |
| const trunkGroup = new THREE.Group(); | |
| robotRoot.add(trunkGroup); | |
| const bodyGeo = new THREE.SphereGeometry(0.052, 32, 24); | |
| bodyGeo.scale(0.9, 0.85, 1.15); | |
| const bodyMesh = new THREE.Mesh(bodyGeo, duckYellowMat); | |
| bodyMesh.castShadow = true; | |
| trunkGroup.add(bodyMesh); | |
| const packGeo = new THREE.BoxGeometry(0.058, 0.048, 0.035); | |
| const packMesh = new THREE.Mesh(packGeo, mechDarkMat); | |
| packMesh.position.set(0, 0.005, -0.052); | |
| packMesh.castShadow = true; | |
| trunkGroup.add(packMesh); | |
| const wingGeo = new THREE.SphereGeometry(0.025, 20, 16); | |
| wingGeo.scale(0.4, 0.9, 1.4); | |
| const leftWing = new THREE.Mesh(wingGeo, duckYellowMat); | |
| leftWing.position.set(-0.048, 0.002, 0.005); | |
| leftWing.rotation.z = 0.18; | |
| leftWing.castShadow = true; | |
| trunkGroup.add(leftWing); | |
| const rightWing = new THREE.Mesh(wingGeo, duckYellowMat); | |
| rightWing.position.set(0.048, 0.002, 0.005); | |
| rightWing.rotation.z = -0.18; | |
| rightWing.castShadow = true; | |
| trunkGroup.add(rightWing); | |
| const tailGeo = new THREE.ConeGeometry(0.022, 0.038, 16); | |
| const tailMesh = new THREE.Mesh(tailGeo, duckYellowMat); | |
| tailMesh.rotation.x = -Math.PI / 2.6; | |
| tailMesh.position.set(0, 0.022, -0.068); | |
| tailMesh.castShadow = true; | |
| trunkGroup.add(tailMesh); | |
| // 2. Neck (๋ชฉ ๊ด์ ) | |
| const neckBaseGeo = new THREE.CylinderGeometry(0.016, 0.02, 0.022, 16); | |
| const neckBase = new THREE.Mesh(neckBaseGeo, mechDarkMat); | |
| neckBase.position.set(0, 0.038, 0.028); | |
| neckBase.castShadow = true; | |
| trunkGroup.add(neckBase); | |
| const neckGroup = new THREE.Group(); | |
| neckGroup.position.set(0, 0.048, 0.028); | |
| trunkGroup.add(neckGroup); | |
| const neckLinkGeo = new THREE.CylinderGeometry(0.013, 0.014, 0.03, 16); | |
| const neckLink = new THREE.Mesh(neckLinkGeo, metalSilverMat); | |
| neckLink.position.set(0, 0.014, 0.004); | |
| neckLink.rotation.x = 0.2; | |
| neckLink.castShadow = true; | |
| neckGroup.add(neckLink); | |
| const neckJointGeo = new THREE.SphereGeometry(0.016, 16, 16); | |
| const neckJoint = new THREE.Mesh(neckJointGeo, mechDarkMat); | |
| neckJoint.position.set(0, 0.028, 0.007); | |
| neckJoint.castShadow = true; | |
| neckGroup.add(neckJoint); | |
| // 3. Head & Cute Duck Bill (๊ท์ฌ์ด ์ค๋ฆฌ ์ผ๊ตด๊ณผ ์ค๋ง์ผ ๋ถ๋ฆฌ) | |
| const headGroup = new THREE.Group(); | |
| headGroup.position.set(0, 0.032, 0.007); | |
| neckGroup.add(headGroup); | |
| const headGeo = new THREE.SphereGeometry(0.042, 32, 28); | |
| headGeo.scale(1.02, 0.98, 1.08); | |
| const headMesh = new THREE.Mesh(headGeo, duckYellowMat); | |
| headMesh.position.set(0, 0.025, 0.01); | |
| headMesh.castShadow = true; | |
| headGroup.add(headMesh); | |
| const billGroup = new THREE.Group(); | |
| billGroup.position.set(0, 0.018, 0.046); | |
| headGroup.add(billGroup); | |
| const upperBillGeo = new THREE.SphereGeometry(0.026, 24, 20); | |
| upperBillGeo.scale(1.15, 0.42, 1.35); | |
| const upperBill = new THREE.Mesh(upperBillGeo, duckOrangeBeakMat); | |
| upperBill.position.set(0, 0.004, 0.022); | |
| upperBill.rotation.x = -0.05; | |
| upperBill.castShadow = true; | |
| billGroup.add(upperBill); | |
| const nostrilGeo = new THREE.SphereGeometry(0.0025, 8, 8); | |
| const leftNostril = new THREE.Mesh(nostrilGeo, mechDarkMat); | |
| leftNostril.position.set(-0.009, 0.014, 0.03); | |
| const rightNostril = new THREE.Mesh(nostrilGeo, mechDarkMat); | |
| rightNostril.position.set(0.009, 0.014, 0.03); | |
| billGroup.add(leftNostril, rightNostril); | |
| const lowerBillGeo = new THREE.SphereGeometry(0.02, 20, 16); | |
| lowerBillGeo.scale(1.0, 0.35, 1.15); | |
| const lowerBill = new THREE.Mesh(lowerBillGeo, duckOrangeBeakMat); | |
| lowerBill.position.set(0, -0.005, 0.018); | |
| lowerBill.castShadow = true; | |
| billGroup.add(lowerBill); | |
| const eyeGeo = new THREE.SphereGeometry(0.010, 24, 24); | |
| const bigGlintGeo = new THREE.SphereGeometry(0.0035, 12, 12); | |
| const smallGlintGeo = new THREE.SphereGeometry(0.0018, 8, 8); | |
| const leftEye = new THREE.Mesh(eyeGeo, eyeBlackMat); | |
| leftEye.position.set(-0.031, 0.032, 0.03); | |
| const leftGlint1 = new THREE.Mesh(bigGlintGeo, eyeGlintMat); | |
| leftGlint1.position.set(-0.033, 0.036, 0.037); | |
| const leftGlint2 = new THREE.Mesh(smallGlintGeo, eyeGlintMat); | |
| leftGlint2.position.set(-0.030, 0.029, 0.038); | |
| headGroup.add(leftEye, leftGlint1, leftGlint2); | |
| const rightEye = new THREE.Mesh(eyeGeo, eyeBlackMat); | |
| rightEye.position.set(0.031, 0.032, 0.03); | |
| const rightGlint1 = new THREE.Mesh(bigGlintGeo, eyeGlintMat); | |
| rightGlint1.position.set(0.033, 0.036, 0.037); | |
| const rightGlint2 = new THREE.Mesh(smallGlintGeo, eyeGlintMat); | |
| rightGlint2.position.set(0.030, 0.029, 0.038); | |
| headGroup.add(rightEye, rightGlint1, rightGlint2); | |
| const sensorGeo = new THREE.CylinderGeometry(0.005, 0.005, 0.006, 16); | |
| const sensorMesh = new THREE.Mesh(sensorGeo, sensorBlueMat); | |
| sensorMesh.rotation.x = Math.PI / 2; | |
| sensorMesh.position.set(0, 0.046, 0.043); | |
| headGroup.add(sensorMesh); | |
| // 4. Legs & Webbed Feet | |
| function createLeg(isLeft) { | |
| const legRoot = new THREE.Group(); | |
| const sign = isLeft ? -1 : 1; | |
| legRoot.position.set(sign * 0.042, -0.022, 0.005); | |
| const hipBallGeo = new THREE.SphereGeometry(0.016, 16, 16); | |
| const hipBall = new THREE.Mesh(hipBallGeo, mechDarkMat); | |
| hipBall.castShadow = true; | |
| legRoot.add(hipBall); | |
| const upperLegGroup = new THREE.Group(); | |
| upperLegGroup.position.set(0, -0.012, 0); | |
| legRoot.add(upperLegGroup); | |
| const thighGeo = new THREE.CylinderGeometry(0.012, 0.010, 0.042, 16); | |
| const thighMesh = new THREE.Mesh(thighGeo, metalSilverMat); | |
| thighMesh.position.set(0, -0.021, 0); | |
| thighMesh.castShadow = true; | |
| upperLegGroup.add(thighMesh); | |
| const kneeGroup = new THREE.Group(); | |
| kneeGroup.position.set(0, -0.042, 0); | |
| upperLegGroup.add(kneeGroup); | |
| const kneeBallGeo = new THREE.SphereGeometry(0.014, 16, 16); | |
| const kneeBall = new THREE.Mesh(kneeBallGeo, mechDarkMat); | |
| kneeBall.castShadow = true; | |
| kneeGroup.add(kneeBall); | |
| const shinGeo = new THREE.CylinderGeometry(0.009, 0.008, 0.044, 16); | |
| const shinMesh = new THREE.Mesh(shinGeo, metalSilverMat); | |
| shinMesh.position.set(0, -0.022, 0); | |
| shinMesh.castShadow = true; | |
| kneeGroup.add(shinMesh); | |
| const ankleGroup = new THREE.Group(); | |
| ankleGroup.position.set(0, -0.044, 0); | |
| kneeGroup.add(ankleGroup); | |
| const ankleBallGeo = new THREE.SphereGeometry(0.011, 16, 16); | |
| const ankleBall = new THREE.Mesh(ankleBallGeo, mechDarkMat); | |
| ankleBall.castShadow = true; | |
| ankleGroup.add(ankleBall); | |
| const footGroup = new THREE.Group(); | |
| footGroup.position.set(0, -0.012, 0); | |
| ankleGroup.add(footGroup); | |
| const footPadGeo = new THREE.BoxGeometry(0.042, 0.008, 0.052); | |
| const footPad = new THREE.Mesh(footPadGeo, duckOrangeBeakMat); | |
| footPad.position.set(0, 0.004, 0.015); | |
| footPad.castShadow = true; | |
| footGroup.add(footPad); | |
| for (let i = -1; i <= 1; i++) { | |
| const toeGeo = new THREE.SphereGeometry(0.009, 14, 12); | |
| toeGeo.scale(0.8, 0.45, 1.4); | |
| const toeMesh = new THREE.Mesh(toeGeo, duckOrangeBeakMat); | |
| toeMesh.rotation.y = i * 0.22; | |
| toeMesh.position.set(i * 0.016, 0.004, 0.044); | |
| toeMesh.castShadow = true; | |
| footGroup.add(toeMesh); | |
| } | |
| const heelGeo = new THREE.SphereGeometry(0.011, 12, 10); | |
| heelGeo.scale(1.2, 0.45, 0.9); | |
| const heelMesh = new THREE.Mesh(heelGeo, duckOrangeBeakMat); | |
| heelMesh.position.set(0, 0.004, -0.012); | |
| heelMesh.castShadow = true; | |
| footGroup.add(heelMesh); | |
| return { | |
| root: legRoot, | |
| upper: upperLegGroup, | |
| knee: kneeGroup, | |
| ankle: ankleGroup, | |
| foot: footGroup | |
| }; | |
| } | |
| const leftLeg = createLeg(true); | |
| const rightLeg = createLeg(false); | |
| robotRoot.add(leftLeg.root); | |
| robotRoot.add(rightLeg.root); | |
| // ========================================== | |
| // ๐ INTERACTIVE WALKING & SOCCER SIMULATOR | |
| // ========================================== | |
| let currentMotion = 'walk'; | |
| let clock = new THREE.Clock(); | |
| let robotHeading = 0.0; | |
| let hasKickedBall = false; | |
| const keys = {}; | |
| window.addEventListener('keydown', (e) => { | |
| keys[e.key.toLowerCase()] = true; | |
| keys[e.code] = true; | |
| if (['KeyK', 'k', 'K'].includes(e.key) || e.code === 'Space' || e.key === ' ') { | |
| setMotion('kick'); | |
| } else if (['ArrowUp', 'KeyW', 'w', 'W'].includes(e.key) || ['ArrowUp', 'KeyW'].includes(e.code)) { | |
| setMotion('walk'); | |
| } else if (['ArrowDown', 'KeyS', 's', 'S'].includes(e.key) || ['ArrowDown', 'KeyS'].includes(e.code)) { | |
| setMotion('backward'); | |
| } else if (['KeyR', 'r', 'R'].includes(e.key)) { | |
| resetBall(); | |
| } | |
| }); | |
| window.addEventListener('keyup', (e) => { | |
| keys[e.key.toLowerCase()] = false; | |
| keys[e.code] = false; | |
| }); | |
| function setMotion(name) { | |
| currentMotion = name; | |
| hasKickedBall = false; // ๋ฆฌ์ ํฅ ์ํ | |
| document.querySelectorAll('.btn-motion').forEach(b => b.classList.remove('active')); | |
| const activeBtn = document.getElementById(`btn-${name}`); | |
| if (activeBtn) activeBtn.classList.add('active'); | |
| const modeLabel = { | |
| 'walk': '์ ์ง ๊ฑท๊ธฐ (Forward)', | |
| 'backward': 'ํ์ง ๊ฑท๊ธฐ (Backward)', | |
| 'waddle': '์ ์๋ฆฌ ๋ค๋ฑ (Waddle)', | |
| 'stand': '์ ์๋ฆฌ ์๊ธฐ (Stand)', | |
| 'sit': '์ชผ๊ทธ๋ ค ์๊ธฐ (Sit)', | |
| 'kick': 'โฝ ์ผ๋ฐ ์ (Kick!)', | |
| 'peck': '๋ฐ๋ฅ ์ชผ๊ธฐ (Peck)', | |
| }[name] || name; | |
| document.getElementById('stat-mode').innerText = modeLabel; | |
| } | |
| function resetPosition() { | |
| robotRoot.position.set(0, BASE_STAND_Y, 0); | |
| robotHeading = 0; | |
| robotRoot.rotation.y = 0; | |
| controls.target.set(0, 0.13, 0); | |
| camera.position.set(0.65, 0.42, 0.75); | |
| resetBall(); | |
| } | |
| function resetCamera(mode) { | |
| if (mode === 'orbit') { | |
| controls.target.set(robotRoot.position.x, 0.13, robotRoot.position.z); | |
| camera.position.set(robotRoot.position.x + 0.65, 0.42, robotRoot.position.z + 0.75); | |
| } | |
| } | |
| let isAutoRotating = false; | |
| function toggleAutoRotate() { | |
| isAutoRotating = !isAutoRotating; | |
| controls.autoRotate = isAutoRotating; | |
| controls.autoRotateSpeed = 2.0; | |
| } | |
| // --- Main Simulation & Animation Loop --- | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const delta = Math.min(clock.getDelta(), 0.05); | |
| const time = clock.getElapsedTime(); | |
| // Reset local part rotations | |
| trunkGroup.rotation.set(0, 0, 0); | |
| neckGroup.rotation.set(0, 0, 0); | |
| headGroup.rotation.set(0, 0, 0); | |
| leftLeg.upper.rotation.set(0, 0, 0); | |
| leftLeg.knee.rotation.set(0, 0, 0); | |
| leftLeg.ankle.rotation.set(0, 0, 0); | |
| rightLeg.upper.rotation.set(0, 0, 0); | |
| rightLeg.knee.rotation.set(0, 0, 0); | |
| rightLeg.ankle.rotation.set(0, 0, 0); | |
| // Keyboard dynamic turning | |
| if (keys['arrowleft'] || keys['keya']) { | |
| robotHeading += 1.8 * delta; | |
| } | |
| if (keys['arrowright'] || keys['keyd']) { | |
| robotHeading -= 1.8 * delta; | |
| } | |
| robotRoot.rotation.y = robotHeading; | |
| // --- ๐ 1. FORWARD WALKING (์์ผ๋ก ์ ์ง ๊ฑท๊ธฐ) --- | |
| if (currentMotion === 'walk' || currentMotion === 'backward') { | |
| const isForward = (currentMotion === 'walk'); | |
| const dir = isForward ? 1 : -1; | |
| const currentSpeed = 0.22 * dir; | |
| const waddleFreq = 7.5; | |
| const swing = Math.sin(time * waddleFreq); | |
| const bob = Math.abs(Math.sin(time * waddleFreq)) * 0.013; | |
| const moveDist = currentSpeed * delta; | |
| robotRoot.position.z += Math.cos(robotHeading) * moveDist; | |
| robotRoot.position.x += Math.sin(robotHeading) * moveDist; | |
| robotRoot.position.y = BASE_STAND_Y + bob; | |
| controls.target.x += Math.sin(robotHeading) * moveDist; | |
| controls.target.z += Math.cos(robotHeading) * moveDist; | |
| camera.position.x += Math.sin(robotHeading) * moveDist; | |
| camera.position.z += Math.cos(robotHeading) * moveDist; | |
| trunkGroup.rotation.z = swing * 0.11; | |
| trunkGroup.rotation.y = swing * 0.07; | |
| leftWing.rotation.z = 0.18 + Math.abs(swing) * 0.16; | |
| rightWing.rotation.z = -0.18 - Math.abs(swing) * 0.16; | |
| neckGroup.rotation.x = -0.12 + Math.sin(time * waddleFreq * 2) * 0.12; | |
| headGroup.rotation.x = 0.08 + Math.sin(time * waddleFreq * 2) * 0.09; | |
| headGroup.rotation.y = -swing * 0.08; | |
| leftLeg.upper.rotation.x = swing * 0.42 * dir; | |
| leftLeg.knee.rotation.x = Math.max(0, -swing * 0.5 * dir); | |
| leftLeg.ankle.rotation.x = -swing * 0.18 * dir; | |
| rightLeg.upper.rotation.x = -swing * 0.42 * dir; | |
| rightLeg.knee.rotation.x = Math.max(0, swing * 0.5 * dir); | |
| rightLeg.ankle.rotation.x = swing * 0.18 * dir; | |
| // ๊ฑธ์ด๊ฐ๋ฉด์ ์ผ๋ฐ/์ค๋ฅธ๋ฐ์ด ๊ณต์ ์ง์ ๋ฟ์์ ๋๋ง ํญ ์ฐจ๊ณ ๋ฐ๊ธฐ (Physical Foot Dribble) | |
| const leftFootPos = new THREE.Vector3(); | |
| const rightFootPos = new THREE.Vector3(); | |
| leftLeg.foot.getWorldPosition(leftFootPos); | |
| rightLeg.foot.getWorldPosition(rightFootPos); | |
| const distLeft = leftFootPos.distanceTo(ballPos); | |
| const distRight = rightFootPos.distanceTo(ballPos); | |
| const contactRadius = BALL_RADIUS + 0.035; // ์ฝ 0.07m | |
| if (distLeft < contactRadius) { | |
| const pushDir = new THREE.Vector3().subVectors(ballPos, leftFootPos).normalize(); | |
| pushDir.y = 0.1; // ์ด์ง ํ๊น | |
| ballVel.addScaledVector(pushDir, 0.75); | |
| } else if (distRight < contactRadius) { | |
| const pushDir = new THREE.Vector3().subVectors(ballPos, rightFootPos).normalize(); | |
| pushDir.y = 0.1; | |
| ballVel.addScaledVector(pushDir, 0.75); | |
| } | |
| } | |
| // --- โฝ 2. SOCCER BALL KICK (๋ฐ์ ๋ฟ์์ ๋๋ง ์!) --- | |
| else if (currentMotion === 'kick') { | |
| const kickCycle = (time * 3.6) % (Math.PI * 2); | |
| robotRoot.position.y = BASE_STAND_Y; | |
| if (kickCycle < Math.PI) { | |
| trunkGroup.rotation.z = 0.18; // ์ค๋ฅธ์ชฝ์ผ๋ก ์ฒด์ค ์ฃ๊ธฐ | |
| const kickSwing = Math.sin(kickCycle); | |
| leftLeg.upper.rotation.x = -kickSwing * 1.05; | |
| leftLeg.knee.rotation.x = kickSwing * 0.45; | |
| leftWing.rotation.z = 0.5; // ๋ ๊ฐ ํ์ง ํผ์ณ ๊ท ํ | |
| neckGroup.rotation.x = 0.18; | |
| // ๋ฐ์ ์ค์ 3D ์๋ ์ขํ ๊ณ์ฐ | |
| const leftFootPos = new THREE.Vector3(); | |
| leftLeg.foot.getWorldPosition(leftFootPos); | |
| const distToFoot = leftFootPos.distanceTo(ballPos); | |
| const kickHitRadius = BALL_RADIUS + 0.045; // ์ฝ 0.08m | |
| // โญ๏ธ ๋ฐ์ ์์ผ๋ก ํ์ฐจ๊ฒ ๋ป๋ ๊ตฌ๊ฐ(kickCycle > 0.8)์ด๊ณ , ๋ฐ์ด ์ค์ ๋ก ์ถ๊ตฌ๊ณต์ ๋ฟ์์ ๋๋ง ํ๊ฒฉ! | |
| if (kickCycle > 0.8 && kickCycle < 2.0 && !hasKickedBall) { | |
| if (distToFoot <= kickHitRadius) { | |
| // ๋ฐ๊ณผ ๊ณต์ด ๋ฌผ๋ฆฌ์ ์ผ๋ก ์ถฉ๋ํจ! | |
| hasKickedBall = true; | |
| triggerKickGoal(); | |
| // ์ผ๋ฐ์ ์ค์ ๋ฐฉํฅ ๋ฒกํฐ๋ก ์์ํ ์ ์ธ๊ฐ | |
| const shootDir = new THREE.Vector3( | |
| Math.sin(robotHeading) * 1.8, | |
| 0.58, // ์ด์ง ๊ณต์ค์ผ๋ก ๋จ๋ ํฌ๋ฌผ์ ์ | |
| Math.cos(robotHeading) * 1.8 | |
| ); | |
| ballVel.copy(shootDir); | |
| ballSpin.set(16, 0, 0); // ์ ์ง ํ์ ์คํ | |
| } | |
| } | |
| } else { | |
| trunkGroup.rotation.z = 0; | |
| leftWing.rotation.z = 0.18; | |
| hasKickedBall = false; // ๋ค์ ํฅ ์ฌ์ดํด ์ค๋น | |
| } | |
| } | |
| // --- 3. IN-PLACE WADDLE --- | |
| else if (currentMotion === 'waddle') { | |
| const waddleFreq = 7.0; | |
| const swing = Math.sin(time * waddleFreq); | |
| const bob = Math.abs(Math.sin(time * waddleFreq)) * 0.012; | |
| robotRoot.position.y = BASE_STAND_Y + bob; | |
| trunkGroup.rotation.z = swing * 0.10; | |
| neckGroup.rotation.x = -0.10 + Math.sin(time * waddleFreq * 2) * 0.10; | |
| headGroup.rotation.x = 0.08 + Math.sin(time * waddleFreq * 2) * 0.08; | |
| leftLeg.upper.rotation.x = swing * 0.38; | |
| leftLeg.knee.rotation.x = Math.max(0, -swing * 0.45); | |
| rightLeg.upper.rotation.x = -swing * 0.38; | |
| rightLeg.knee.rotation.x = Math.max(0, swing * 0.45); | |
| } | |
| // --- 4. STAND BALANCE --- | |
| else if (currentMotion === 'stand') { | |
| const breath = Math.sin(time * 2.2) * 0.002; | |
| robotRoot.position.y = BASE_STAND_Y + breath; | |
| neckGroup.rotation.y = Math.sin(time * 1.2) * 0.32; | |
| headGroup.rotation.z = Math.sin(time * 1.6) * 0.12; | |
| } | |
| // --- 5. SIT DOWN --- | |
| else if (currentMotion === 'sit') { | |
| const sitProgress = (Math.sin(time * 2.0) + 1) / 2; | |
| robotRoot.position.y = BASE_STAND_Y - sitProgress * 0.055; | |
| leftLeg.upper.rotation.x = -sitProgress * 0.75; | |
| leftLeg.knee.rotation.x = sitProgress * 1.15; | |
| rightLeg.upper.rotation.x = -sitProgress * 0.75; | |
| rightLeg.knee.rotation.x = sitProgress * 1.15; | |
| trunkGroup.rotation.x = sitProgress * 0.22; | |
| } | |
| // --- 6. PECK --- | |
| else if (currentMotion === 'peck') { | |
| const peckProg = (Math.sin(time * 3.2) + 1) / 2; | |
| robotRoot.position.y = BASE_STAND_Y - peckProg * 0.035; | |
| trunkGroup.rotation.x = peckProg * 0.6; | |
| neckGroup.rotation.x = peckProg * 0.7; | |
| headGroup.rotation.x = peckProg * 0.3; | |
| } | |
| // ========================================== | |
| // โฝ REALISTIC SOCCER BALL PHYSICS UPDATE | |
| // ========================================== | |
| // Gravity | |
| ballVel.y -= 9.8 * 0.4 * delta; // ์ฝ๊ฐ ๊ฐ๋ฒผ์ด ์์ผ์ด๋ ๋ฌผ๋ฆฌ ์ค๋ ฅ | |
| // Apply velocity | |
| ballPos.addScaledVector(ballVel, delta); | |
| // Floor collision & Bounce | |
| if (ballPos.y <= BALL_RADIUS) { | |
| ballPos.y = BALL_RADIUS; | |
| if (ballVel.y < -0.05) { | |
| ballVel.y = -ballVel.y * 0.55; // ํ์ฑ ๋ฐ์ด์ค | |
| } else { | |
| ballVel.y = 0; | |
| } | |
| // ์ง๋ฉด ๊ตฌ๋ฆ ๋ง์ฐฐ | |
| ballVel.x *= 0.965; | |
| ballVel.z *= 0.965; | |
| } | |
| // Air resistance | |
| ballVel.x *= 0.995; | |
| ballVel.z *= 0.995; | |
| // Ball Rolling Rotation | |
| const horizontalSpeed = Math.sqrt(ballVel.x * ballVel.x + ballVel.z * ballVel.z); | |
| if (horizontalSpeed > 0.01) { | |
| ballMesh.rotation.x += (ballVel.z / BALL_RADIUS) * delta; | |
| ballMesh.rotation.z -= (ballVel.x / BALL_RADIUS) * delta; | |
| } | |
| ballMesh.position.copy(ballPos); | |
| // Stats Update | |
| totalBallDistance = ballPos.distanceTo(ballStartPos); | |
| document.getElementById('stat-ball-speed').innerText = `${horizontalSpeed.toFixed(2)} m/s`; | |
| document.getElementById('stat-ball-dist').innerText = `${totalBallDistance.toFixed(2)} m`; | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| } | |
| animate(); | |
| // Window Resize | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |