microduck / index.html
kyoungsim's picture
Upload index.html with huggingface_hub
774df47 verified
Raw History Blame Contribute Delete
39.7 kB
<!DOCTYPE html>
<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>