-
0. 크롬 확장 프로그램 만들기 [마법의 소라고동]Projects/ToyProject 2024. 12. 5. 18:12
글 목록
0. 크롬 확장 프로그램 만들기 [마법의 소라고동] < 현재 글
1. 크롬 확장 프로그램 출시하기 [마법의 소라고동]GitHub : https://github.com/T3rryAhn/magic-conch-extension
chrome 웹 스토어 다운로드 링크 : 마법의 소라고동
안녕하세요! 크롬 확장 프로그램을 만들어 보는 글을 작성해보겠습니다.
해야 할 것.
- 크롬 확장 프로그램 패키지 만들기
- 구글 웹 개발자 계정 등록 하기
- 스토어에 출시 하기
크롬 확장 프로그램 패키지 만들기
취업 코테 광탈하고 털털한 마음을 추스리기 위해 '뭐라도 만들까' 라는 생각을 하던 중, 스폰지밥에 나온 마법의 소라고동을 간단하게 만들어보면 좋겠다고 떠올렸습니다.
마법의 소라고동
스폰지밥에 나온 '마법의 소라고동'을 크롬 확장 프로그램으로 만들었습니다.
마음속으로 혹은 육성으로 예/아니오 로 답변이 가능한 질문을 하시고 고리를 당기시면, 마법의 소라고동 님이 답변을 해주십니다.고리를 드래그 했다 놓으시면 마법의 소라고동이 대답을 해줍니다.
긍정
- 그럼
- 돼 (음성 5가지)
부정
- 가만히 있어
- 그것도 안돼
- 안돼 (음성 5가지)
기타
- 다시한번 물어봐
이런 컨셉을 잡았고 간단하게 크롬 오른쪽위 확장 프로그램 아이콘을 누르면 팝업으로 소라고동이 뜨고 고리를 당기면 긍정/부정 답변중 랜덤으로 하나가 텍스트와 소리가 재생이 되고 고리는 당기고 다시 되감아지는 애니메이션이 있는 아이디어를 구상했습니다.
그럼 바로 만들어보죠!!
1. 크롬 확장 프로그램 패키지 만들기
저는 확장프로그램 만드는 법을 모릅니다 그러니 다른 경험자의 글을 찾아보고 chatGPT를 활용해서 만들어봅시다.
프로젝트 구조
📂MAGIC-CONCH-EXTENSION
>📂assets
>📂scripts
>📂styles
📄manifest.json
📄popup.html📂MAGIC-CONCH-EXTENSION
프로젝트 루트 디렉토리
📂assets
확장 프로그램에서 사용하는 이미지, 아이콘, 기타 리소스 저장하는 폴더
📂scripts
JavaScript 파일이 위치한 폴더입니다. 확장 프로그램의 주요 로직이 여기에 작성됩니다.
📂styles
확장 프로그램의 CSS 파일이 위치하는 폴더
📄manifest.json
크롬 확장 프로그램의 설정 파일.
확장 프로그램의 메타데이터와 권한을 정의함.
📄popup.html
확장 프로그램의 팝업 인터페이스 html 파일
크롬 툴바에서 확장 프로그램의 아이콘을 클릭하면 나타나는 팝업 창 파일이다.
📂assets
자 이제 우리 프로그램에 필요한 에셋들을 모아봅시다!! 필요한건 대답에 사용할 mp3 파일과 소라고동 이미지 파일을 구해야 겠죠. 정리해 보면
- 대답 mp3 파일
- 긍정 대답들
- 부정 대답들
- 기타 대답
- 소라고동 icon
- 소라고동 (당기는 고리와 줄 없는 이미지)
- 소라고동 줄을 당기는 고리의 이미지
유튜브에서 mp3를 추출하고 잘라내기로 원하는 부분들을 추출했습니다. 이미지는 캡처한후 누끼따주는 사이트에서 따서 편집했습니다.
📄manifest.json
재료를 다 모았으니 본격적인 코딩에 앞서 manifest.json 부터 작성해 봅시다.
이 파일은 크롬 확장 프로그램의 동작을 브라우저에 알리는 파일이라고 합니다.
- 확장 프로그램 이름, 버전, 설명
- 어떤 파일이 백그라운드 스크립트인지, 팝업 html은 무엇인지.
- 확장 프로그램이 어떤 권한을 사용하는지.
- 아이콘 경로 및 확장 프로그램 작동 방식.
{ "manifest_version": 3, "name": "마법의 소라고동", "version": "1.0", "icons": { "128": "assets/images/128.png" }, "description": "마음속 혹은 육성으로 예/아니오로 답변 가능한 질문을 외치시고, 🐚마법의 소라고동🔮의 고리를 드래그 한뒤 놓아보세요! ", "action": { "default_popup": "popup.html", "default_icon": "assets/images/128.png" }, "permissions": [] }
manifest_version
: 크롬 확장 프로그램의 매니페스트 버전을 지정합니다. 현재 최신 버전은 3name
: 확장 프로그램의 이름. 크롬 웹 스토어 및 확장 프로그램 관리 페이지에 표시된다.version
: 확장 프로그램의 버전 정보. 본인만의 버전 규칙대로 작성하면 된다.icons
: 아이콘 파일경로를 지정해주면 된다. 크기별로 설정이 가능하다. 크롬 웹스토어와 브라우저 툴바에 표시된다.description
: 확장 프로그램의 설명. 여기 작성된 설명이 추후에 스토어 등록정보에 패키지 요약에 입력되어 있으면 스토어 설명란에 추가되니 잘 작성해보자action
: 확장 프로그램 아이콘의 동작 정의.default_popup
: 아이콘을 클릭하면 열리는 팝업 파일.default_icon
: 툴바 아이콘 이미지 경로permissions
: 필요한 권한 목록
📄popup.html
프로그램 아이콘을 누르면 뜨는 팝업 화면을 먼저 만들어 봅시다!!
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Magic Conch Shell</title> <link rel="stylesheet" href="styles\popup-style.css"> </head> <body> <div id="conch-container"> <div id="conch-image-wrapper"> <img id="conch-image" src="assets\images\magic_conch_no_strip.png" alt="Magic Conch" draggable="false"> <div id="pull-string"></div> <img id="pull-ring" src="assets\images\strip.png" draggable="false"> </div> <p id="response"></p> </div> <script src="scripts/response.js"></script> <script src="scripts/pull-ring.js"></script> </body> </html>
<body>
<div id="conch-container">
팝업 인터페이스의 주요 컨테이너로, 마법의 소라고동과 결과 메시지를 포함합니다.<div id="conch-image-wrapper">
소라고동 이미지와 고리(pull-ring)를 포함하는 래퍼입니다.<img id="conch-image">
: 소라고동 이미지
draggable="false": 를 해줘야지 이미지가 반투명상태로 드래그 되지 않습니다.<div id="pull-string">
: 고리가 붙어 있는 줄을 나타냅니다.<img id="pull-ring">
: 줄에 연결된 고리 이미지
<p id="response">
소라고동이 응답(예/아니오 등)을 표시하는 텍스트 영역입니다.
<script>
팝업에서 동작을 제어하는 JavaScript 파일을 로드합니다.scripts/response.js
: 사용자가 질문에 대한 응답을 받을 때의 동작을 정의.scripts/pull-ring.js
: 고리를 드래그하거나 놓을 때의 동작을 처리.
📂scripts
이제 프로그램의 핵심 로직과 애니메이션 자바스크립트를 작성해봅시다.
response.js
파일은 사용자가 고리를 드래그하여 놓을 때마다 무작위로 답변을 선택합니다. 답변 선택 알고리즘은 다음과 같은 단계를 따릅니다:- 답변 카테고리 정의:
- 긍정 답변:
그럼
,돼
- 부정 답변:
안돼
,그것도 안돼
,가만히 있어
- 기타 답변:
다시한번 물어봐
- 긍정 답변:
- 이전 답변 확인:
- 이전 답변이
안돼
인 경우, 다음 답변 선택 시안돼
를 제외한 다른 답변을 선택합니다.
- 이전 답변이
- 가능한 답변 목록 생성:
- 이전 답변에 따라 가능한 답변 목록을 생성합니다.
- 무작위 답변 선택:
- 가능한 답변 목록에서 무작위로 하나의 답변을 선택합니다. 이를 위해 Math.random() 함수를 사용하여 0과 1 사이의 난수를 생성하고, 이를 답변 목록의 길이와 곱한 후 Math.floor() 함수를 사용하여 정수로 변환합니다.
- 예를 들어, 긍정 답변 목록에서 무작위로 답변을 선택하는 코드는 다음과 같습니다:
const randomIndex = Math.floor(Math.random() * positiveAudios.length); const randomResponse = positiveAudios[randomIndex];
- 선택된 답변 출력 및 음성 재생:
- 선택된 답변을 화면에 출력하고, 해당 답변에 맞는 음성을 재생합니다.
이 알고리즘을 통해 마법의 소라고동은 무작위로 답변을 선택하여 사용자에게 제공합니다.
🔻
response.js
전체 코드더보기document.addEventListener('DOMContentLoaded', function() { let lastResponse = ''; function playMagicConchResponse() { const positiveResponses = ['그럼', '돼']; const negativeResponses = ['안돼', '그것도 안돼', '가만히 있어']; const otherResponses = ['다시한번 물어봐']; let possibleResponses = []; if (lastResponse === '안돼') { possibleResponses = [...positiveResponses, ...negativeResponses.filter(r => r !== '안돼'), ...otherResponses]; } else { possibleResponses = [...positiveResponses, ...negativeResponses.filter(r => r !== '그것도 안돼'), ...otherResponses]; } const randomResponse = possibleResponses[Math.floor(Math.random() * possibleResponses.length)]; document.getElementById('response').textContent = randomResponse; lastResponse = randomResponse; // Play corresponding audio let audioPath = ''; if (randomResponse === '그럼') { audioPath = 'assets/audio/positive/그럼.mp3'; } else if (randomResponse === '돼') { const positiveAudios = [ 'assets/audio/positive/돼_0.mp3', 'assets/audio/positive/돼_1.mp3', 'assets/audio/positive/돼_2.mp3', 'assets/audio/positive/돼_3.mp3', 'assets/audio/positive/돼_4.mp3' ]; audioPath = positiveAudios[Math.floor(Math.random() * positiveAudios.length)]; } else if (randomResponse === '안돼') { const negativeAudios = [ 'assets/audio/negative/안돼_0.mp3', 'assets/audio/negative/안돼_1.mp3', 'assets/audio/negative/안돼_3.mp3', 'assets/audio/negative/안돼_2.mp3', 'assets/audio/negative/안돼_4.mp3' ]; audioPath = negativeAudios[Math.floor(Math.random() * negativeAudios.length)]; } else if (randomResponse === '그것도 안돼') { audioPath = 'assets/audio/negative/그것도_안돼.mp3'; } else if (randomResponse === '가만히 있어') { audioPath = 'assets/audio/negative/가만히_있어.mp3'; } else { audioPath = 'assets/audio/other/다시한번_물어봐.mp3'; } console.log('Playing audio path:', audioPath); const audio = new Audio(audioPath); audio.play().catch(error => console.error('Failed to play audio:', error)); }; window.playMagicConchResponse = playMagicConchResponse; });
📄 pull-ring.js & 📄 popup-style.css
pull-ring.js 파일은 사용자가 마법의 소라고동의 고리를 드래그할 때 발생하는 모든 상호작용과 애니메이션을 처리합니다.
- 고리의 위치 및 회전:
- 고리의 이동: 사용자가 마우스로 고리를 클릭하고 드래그하면, mousedown, mousemove, mouseup 이벤트를 활용하여 고리의 위치를 업데이트합니다.
pullRing.addEventListener('mousedown', onDragStart); document.addEventListener('mousemove', onDrag); document.addEventListener('mouseup', onDragEnd);
- 회전 구현: 고리가 이동할 때, 고리의 상단이 항상 원래 위치를 향하도록 회전합니다. 이를 위해 고리의 현재 위치와 초기 위치 사이의 벡터를 계산하고, Math.atan2 함수를 사용하여 회전 각도를 구했습니다.
const deltaX = initialX - currentX; const deltaY = initialY - currentY; const angle = Math.atan2(deltaY, deltaX) * (180 / Math.PI) + 90; pullRing.style.transform =
translate(${x}px, ${y}px) rotate(${angle}deg)
; - 회전 중심 설정: css에서 고리 이미지의 회전 중심(transform-origin)을 이미지의 중앙 상단으로 설정하여 자연스러운 회전이 이루어지도록 했습니다.
#pull-ring { transform-origin: center top; }
- 고리의 이동: 사용자가 마우스로 고리를 클릭하고 드래그하면, mousedown, mousemove, mouseup 이벤트를 활용하여 고리의 위치를 업데이트합니다.
- 끈의 움직임과 연결:
- 끈의 동적 생성: 끈은 고리와 소라고동 사이를 연결하는 요소로, 고리의 움직임에 따라 길이와 각도가 동적으로 변경됩니다.
- 끈의 길이 계산: 고리의 상단 중심과 소라고동의 고정된 위치 사이의 거리를 계산하여 끈의 길이를 설정했습니다.
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); pullString.style.height =
${distance}px
; - 끈의 회전 각도 계산: 끈이 정확한 방향을 가리키도록 고리와 소라고동 사이의 각도를 계산하여 적용했습니다.
const angle = Math.atan2(deltaY, deltaX) * (180 / Math.PI) - 90; pullString.style.transform =
rotate(${angle}deg)
; - 끈의 시작점 설정: css에서 끈의 transform-origin을 상단 중앙으로 설정하여 회전 및 길이 변경 시 시작점이 고정되도록 했습니다.
#pull-string { transform-origin: top center; }
- 애니메이션 효과:
- 드래그 시 부드러운 움직임: 고리와 끈의 움직임이 부드럽게 보이도록 CSS 전환 효과와 JavaScript를 활용하여 애니메이션을 구현했습니다.
- 고리를 놓을 때의 애니메이션: 사용자가 고리를 놓으면 고리가 원래 위치로 돌아가도록 애니메이션을 추가했습니다. 이때 이징 함수를 사용하여 자연스러운 복귀 효과를 연출했습니다.
function animateReturnToOrigin() { // 이징 함수와 requestAnimationFrame을 사용하여 애니메이션 구현 }
🔻
pull-ring.js
전체 코드더보기document.addEventListener('DOMContentLoaded', function() { const pullRing = document.getElementById('pull-ring'); const pullString = document.getElementById('pull-string'); const imageWrapper = document.getElementById('conch-image-wrapper'); let isDragging = false; let startX = 0, startY = 0; let currentX = 0, currentY = 0; let ringInitialX = 0, ringInitialY = 0; let ringImageRadius = 0; let animationFrameId; // 페이지 로드 시 고리의 초기 위치 저장 window.addEventListener('load', function() { const ringRect = pullRing.getBoundingClientRect(); const wrapperRect = imageWrapper.getBoundingClientRect(); ringImageRadius = ringRect.height / 2; // 고리의 중심 위치 const ringCenterX = ringRect.left + ringRect.width / 2 - wrapperRect.left; const ringCenterY = ringRect.top + ringRect.height / 2 - wrapperRect.top; // 고리의 상단 중앙 위치 계산 ringInitialX = ringCenterX; ringInitialY = ringCenterY - ringImageRadius; // 끈의 시작점 설정 pullString.style.top =
${ringInitialY}px
; pullString.style.left =${ringInitialX}px
; }); pullRing.addEventListener('mousedown', function(event) { isDragging = true; startX = event.clientX; startY = event.clientY; pullRing.style.transition = 'none'; pullString.style.transition = 'none'; pullRing.style.cursor = 'grabbing'; }); document.addEventListener('mousemove', function(event) { if (isDragging) { currentX = event.clientX - startX; currentY = event.clientY - startY; // 고리 위치 및 회전 업데이트 updateRingPositionAndRotation(currentX, currentY); // 끈 업데이트 updateString(); } }); document.addEventListener('mouseup', function() { if (isDragging) { isDragging = false; pullRing.style.cursor = 'grab'; // 랜덤 응답 생성 window.playMagicConchResponse(); // 고리를 원위치로 애니메이션 const startTime = performance.now(); const duration = 500; // 애니메이션 지속 시간 (밀리초) const ringStartX = currentX; const ringStartY = currentY; function animateReturnToOrigin(timestamp) { const elapsed = timestamp - startTime; const progress = Math.min(elapsed / duration, 1); // 진행률 (0 ~ 1) // 이징 함수 (ease-in-out) const easeInOut = progress < 0.5 ? 2 * progress * progress : -1 + (4 - 2 * progress) * progress; // 고리 위치 업데이트 const newX = ringStartX * (1 - easeInOut); const newY = ringStartY * (1 - easeInOut); updateRingPositionAndRotation(newX, newY); updateString(); if (progress < 1) { animationFrameId = requestAnimationFrame(animateReturnToOrigin); } else { // 애니메이션 완료 후 초기 상태로 설정 pullRing.style.transform = 'translateX(-50%)'; pullString.style.height = '0'; pullString.style.transform = 'translateX(-50%) rotate(0deg)'; } } animationFrameId = requestAnimationFrame(animateReturnToOrigin); } }); function updateRingPositionAndRotation(x, y) { // 전체 변환 적용 const totalTransform =translateX(-50%) translate(${x}px, ${y}px)
; // 고리의 현재 위치 계산 (중앙 기준) const ringRect = pullRing.getBoundingClientRect(); const wrapperRect = imageWrapper.getBoundingClientRect(); const ringCenterX = ringRect.left + ringRect.width / 2 - wrapperRect.left; const ringCenterY = ringRect.top + ringRect.height / 2 - wrapperRect.top; // 고리의 현재 위치에서 초기 위치까지의 벡터 계산 const deltaX = ringInitialX - ringCenterX; const deltaY = ringInitialY - (ringCenterY - ringImageRadius); // 회전 각도 계산 let ringAngle = Math.atan2(deltaY, deltaX) * (180 / Math.PI); // 이미지의 기본 방향에 따라 각도 보정 (이미지가 위쪽을 향할 경우) ringAngle += 90; // 전체 변환 적용 pullRing.style.transform =${totalTransform} rotate(${ringAngle}deg)
; } function updateString() { // 고리의 현재 위치 및 회전 각도 계산 const ringRect = pullRing.getBoundingClientRect(); const wrapperRect = imageWrapper.getBoundingClientRect(); const ringCenterX = ringRect.left + ringRect.width / 2 - wrapperRect.left; const ringCenterY = ringRect.top + ringRect.height / 2 - wrapperRect.top; // 고리의 회전 각도 가져오기 const transform = pullRing.style.transform; const rotateMatch = transform.match(/rotate\((-?\d+\.?\d*)deg\)/); let ringAngle = 0; if (rotateMatch) { ringAngle = parseFloat(rotateMatch[1]); } // 고리의 상단 중심 좌표 계산 (회전 후) const angleRad = (ringAngle - 90) * (Math.PI / 180); // 각도 조정 const ringTopX = ringCenterX + ringImageRadius * Math.cos(angleRad); const ringTopY = ringCenterY + ringImageRadius * Math.sin(angleRad); // 끈의 길이 및 각도 계산 const deltaX = ringTopX - ringInitialX; const deltaY = ringTopY - ringInitialY; const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); const angle = Math.atan2(deltaY, deltaX) * (180 / Math.PI) - 90; // 끈 업데이트 pullString.style.height =${distance}px
; pullString.style.transform =translateX(-50%) rotate(${angle}deg)
; // 끈의 시작점은 ringInitialX, ringInitialY에 고정 pullString.style.top =${ringInitialY}px
; pullString.style.left =${ringInitialX}px
; } });
이제 거의다 만들었네요. popup.html 파일을 브라우저로 확인하면서 css 마무리 작업을 해주면 됩니다. 참고로 팝업화면 배경을 투명으로 하고 싶었는데 현재 배경을 투명으로 할수는 없다고 합니다.
완성한 프로젝트의 전체 구조입니다.
마치며,
간단히 후딱 만들줄 알았던 프로젝트였는데 생각보다 공부할게 많고 시간이 꽤 걸렸습니다. 특히, 저 고리 애니메이션 만드는게 참 어려웠습니다. 스크립트도 어려웠지만 위치를 원하는데로 잡기가 어려웠고 크롬 개발자 모드에서 css하나하나 수정해보면서 테스트해보는데, css의 작은 설정하나마다 엄청 예상과 다르게 동작하더라구요. css 적용 우선순위와 이미지의 속성에 대해 좀더 공부해봐야겠습니다.
이번 프로젝트 역시 깃을 사용해서 버전관리를 했는데요 기능 & 버전 별로 브랜치를 나누니 실수했거나 이전버전이 좋았을때 돌아오기 참 간편했습니다. 아 그런데 에셋(mp3, image)같은 것들은 미리
.gitignore
에 등록해놓고 프로젝트를 시작하길... 저는 그냥 같이 올려버리긴 했습니다.제작은 이것으로 마치고 다음글은 크롬 웹 스토어에 등록하는 이야기로 찾아뵙겠습니다.
'Projects > ToyProject' 카테고리의 다른 글
1. 크롬 확장 프로그램 출시하기 [마법의 소라고동] (2) 2024.12.05