Skip to content

Jump Game #110

Description

@zzangddo-stack
<title>우당탕탕 점프 모험나라</title> <script src="https://cdn.tailwindcss.com"></script> <style> body { font-family: 'Jua', sans-serif; user-select: none; -webkit-user-select: none; touch-action: manipulation; } /* 모바일 터치 오작동 방지 */ .no-double-tap { touch-action: manipulation; } /* 12간지 선택 그리드 스크롤 디자인 */ .char-grid-scroll { max-height: 280px; overflow-y: auto; } /* 스크롤바 커스텀 */ .char-grid-scroll::-webkit-scrollbar { width: 8px; } .char-grid-scroll::-webkit-scrollbar-track { background: rgba(255,255,255,0.1); border-radius: 10px; } .char-grid-scroll::-webkit-scrollbar-thumb { background: #facc15; border-radius: 10px; } </style>
<!-- 1. 게임 방법 안내 팝업 -->
<div id="screen-tutorial" class="absolute inset-0 z-30 flex items-center justify-center bg-black/85 p-4 text-center">
    <div class="bg-gradient-to-b from-blue-500 to-indigo-700 border-8 border-yellow-300 rounded-3xl p-6 max-w-md w-full shadow-2xl">
        <h2 class="text-3xl text-yellow-300 mb-4 animate-pulse">🎮 게임 방법 배우기 🎮</h2>
        
        <div class="bg-white/10 p-4 rounded-xl text-left text-sm md:text-base space-y-3 mb-6">
            <div class="flex items-center gap-2">
                <span class="text-2xl">🏃‍♂️</span>
                <span><strong>이동하기:</strong> PC 방향키 <kbd class="bg-black/40 px-1 rounded">◀</kbd> <kbd class="bg-black/40 px-1 rounded">▶</kbd> 또는 모바일 화살표 터치</span>
            </div>
            <div class="flex items-center gap-2">
                <span class="text-2xl">🦘</span>
                <span><strong>점프하기:</strong> PC <kbd class="bg-black/40 px-1.5 rounded">Space</kbd> / 모바일 <strong class="text-yellow-300">[점프]</strong> 버튼</span>
            </div>
            <div class="flex items-center gap-2">
                <span class="text-2xl">🪙</span>
                <span><strong>총알 공격 발사 조건:</strong> 코인을 <strong>5개</strong> 모으면 총알 발사 기능 잠금 해제!</span>
            </div>
            <div class="flex items-center gap-2 text-yellow-300 font-bold">
                <span class="text-2xl">🔥</span>
                <span><strong>총알 쏘기:</strong> PC <kbd class="bg-black/40 px-1.5 rounded">F</kbd> 키 / 모바일 <strong class="text-emerald-300">[공격]</strong> 버튼</span>
            </div>
            <div class="flex items-center gap-2">
                <span class="text-2xl">🌟</span>
                <span><strong>12간지 캐릭터:</strong> 양, 토끼, 용 등 동물마다 강력하고 귀여운 특별 초능력이 있어요!</span>
            </div>
        </div>

        <button id="btn-tutorial-ok" class="w-full py-3 bg-yellow-400 hover:bg-yellow-300 text-blue-900 font-extrabold text-2xl rounded-2xl shadow-[0_4px_0_#b45309] transition-transform active:scale-95">
            안내 확인 완료! Go! 👍
        </button>
    </div>
</div>

<!-- 2. 메인 시작 화면 -->
<div id="screen-start" class="absolute inset-0 z-20 hidden flex flex-col items-center justify-center bg-gradient-to-b from-sky-400 via-emerald-300 to-emerald-500 p-4 text-center">
    <div class="animate-bounce mb-2 text-yellow-300 text-4xl md:text-6xl drop-shadow-[0_5px_5px_rgba(0,0,0,0.5)] font-extrabold tracking-wide">
        우당탕탕 점프 모험나라!
    </div>
    <p class="text-white text-lg md:text-xl drop-shadow-[0_2px_2px_rgba(0,0,0,0.6)] mb-8">
        몬스터의 무시무시한 탄막을 피하고 총알로 격파하세요! 🚀
    </p>
    
    <button id="btn-go-char" class="px-8 py-4 bg-yellow-400 hover:bg-yellow-300 border-4 border-white text-blue-900 font-bold text-2xl rounded-full shadow-[0_6px_0px_#b45309] transition-transform active:scale-95">
        캐릭터 고르러 가기! ▶
    </button>

    <div class="mt-8 text-sm text-blue-900 bg-white/60 px-4 py-2 rounded-lg">
        🎮 PC 조작: 방향키(이동), 스페이스바(점프), F키(총알 공격)<br>
        📱 모바일: 가상 버튼 제공 (로그인 필요 없이 바로 가능!)
    </div>
</div>

<!-- 3. 캐릭터 선택 화면 -->
<div id="screen-char-select" class="absolute inset-0 z-20 hidden flex flex-col items-center justify-center bg-gradient-to-b from-indigo-600 via-purple-500 to-indigo-800 p-4">
    <h2 class="text-2xl md:text-3xl text-yellow-300 mb-3 drop-shadow text-center">모험을 떠날 히어로를 골라주세요! ⭐</h2>
    
    <div class="w-full max-w-2xl char-grid-scroll grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 p-2 bg-black/30 rounded-2xl mb-4">
        <!-- 연우 & 재연 -->
        <button onclick="selectHero('boy')" id="btn-char-boy" class="char-card flex flex-col items-center bg-white/10 hover:bg-white/20 border-2 border-transparent p-3 rounded-xl transition-all">
            <canvas id="canvas-boy" width="60" height="60" class="mb-1"></canvas>
            <span class="text-sm font-bold text-yellow-100">연우 👦</span>
            <span class="text-[10px] text-sky-200">균형잡힌 정교한 힘</span>
        </button>
        <button onclick="selectHero('girl')" id="btn-char-girl" class="char-card flex flex-col items-center bg-white/10 hover:bg-white/20 border-2 border-transparent p-3 rounded-xl transition-all">
            <canvas id="canvas-girl" width="60" height="60" class="mb-1"></canvas>
            <span class="text-sm font-bold text-pink-200">재연 👧</span>
            <span class="text-[10px] text-pink-200">초고속 달리기 달인</span>
        </button>
        <!-- 12간지 -->
        <button onclick="selectHero('rat')" id="btn-char-rat" class="char-card flex flex-col items-center bg-white/10 hover:bg-white/20 border-2 border-transparent p-3 rounded-xl transition-all">
            <canvas id="canvas-rat" width="60" height="60" class="mb-1"></canvas>
            <span class="text-sm font-bold text-gray-200">쥐(자) 🐭</span>
            <span class="text-[10px] text-green-300">초미니 피격면적 최소</span>
        </button>
        <button onclick="selectHero('ox')" id="btn-char-ox" class="char-card flex flex-col items-center bg-white/10 hover:bg-white/20 border-2 border-transparent p-3 rounded-xl transition-all">
            <canvas id="canvas-ox" width="60" height="60" class="mb-1"></canvas>
            <span class="text-sm font-bold text-amber-500">소(축) 🐮</span>
            <span class="text-[10px] text-green-300">기본 하트 +1개 보너스</span>
        </button>
        <button onclick="selectHero('tiger')" id="btn-char-tiger" class="char-card flex flex-col items-center bg-white/10 hover:bg-white/20 border-2 border-transparent p-3 rounded-xl transition-all">
            <canvas id="canvas-tiger" width="60" height="60" class="mb-1"></canvas>
            <span class="text-sm font-bold text-orange-400">호랑이(인) 🐯</span>
            <span class="text-[10px] text-green-300">엄청난 기동 속도&화력</span>
        </button>
        <button onclick="selectHero('rabbit')" id="btn-char-rabbit" class="char-card flex flex-col items-center bg-white/10 hover:bg-white/20 border-2 border-transparent p-3 rounded-xl transition-all">
            <canvas id="canvas-rabbit" width="60" height="60" class="mb-1"></canvas>
            <span class="text-sm font-bold text-rose-300">토끼(묘) 🐰</span>
            <span class="text-[10px] text-green-300">하늘 높이 고공 체공 점프</span>
        </button>
        <button onclick="selectHero('dragon')" id="btn-char-dragon" class="char-card flex flex-col items-center bg-white/10 hover:bg-white/20 border-2 border-transparent p-3 rounded-xl transition-all">
            <canvas id="canvas-dragon" width="60" height="60" class="mb-1"></canvas>
            <span class="text-sm font-bold text-emerald-400">용(진) 🐲</span>
            <span class="text-[10px] text-green-300">처음부터 무제한 총알 슛</span>
        </button>
        <button onclick="selectHero('snake')" id="btn-char-snake" class="char-card flex flex-col items-center bg-white/10 hover:bg-white/20 border-2 border-transparent p-3 rounded-xl transition-all">
            <canvas id="canvas-snake" width="60" height="60" class="mb-1"></canvas>
            <span class="text-sm font-bold text-green-400">뱀(사) 🐍</span>
            <span class="text-[10px] text-green-300">슬림 포복 자세 회피율 업</span>
        </button>
        <button onclick="selectHero('horse')" id="btn-char-horse" class="char-card flex flex-col items-center bg-white/10 hover:bg-white/20 border-2 border-transparent p-3 rounded-xl transition-all">
            <canvas id="canvas-horse" width="60" height="60" class="mb-1"></canvas>
            <span class="text-sm font-bold text-yellow-600">말(오) 🐴</span>
            <span class="text-[10px] text-green-300">시원한 광속 스피드 러시</span>
        </button>
        <button onclick="selectHero('sheep')" id="btn-char-sheep" class="char-card flex flex-col items-center bg-white/10 hover:bg-white/20 border-2 border-transparent p-3 rounded-xl transition-all">
            <canvas id="canvas-sheep" width="60" height="60" class="mb-1"></canvas>
            <span class="text-sm font-bold text-neutral-300">양(미) 🐑</span>
            <span class="text-[10px] text-green-300">기본 장착형 면역 보호막</span>
        </button>
        <button onclick="selectHero('monkey')" id="btn-char-monkey" class="char-card flex flex-col items-center bg-white/10 hover:bg-white/20 border-2 border-transparent p-3 rounded-xl transition-all">
            <canvas id="canvas-monkey" width="60" height="60" class="mb-1"></canvas>
            <span class="text-sm font-bold text-amber-700">원숭이(신) 🐵</span>
            <span class="text-[10px] text-green-300">기본 무제한 3단 공중점프</span>
        </button>
        <button onclick="selectHero('rooster')" id="btn-char-rooster" class="char-card flex flex-col items-center bg-white/10 hover:bg-white/20 border-2 border-transparent p-3 rounded-xl transition-all">
            <canvas id="canvas-rooster" width="60" height="60" class="mb-1"></canvas>
            <span class="text-sm font-bold text-red-500">닭(유) 🐔</span>
            <span class="text-[10px] text-green-300">천천히 안착하는 글라이더</span>
        </button>
        <button onclick="selectHero('dog')" id="btn-char-dog" class="char-card flex flex-col items-center bg-white/10 hover:bg-white/20 border-2 border-transparent p-3 rounded-xl transition-all">
            <canvas id="canvas-dog" width="60" height="60" class="mb-1"></canvas>
            <span class="text-sm font-bold text-yellow-500">개(술) 🐶</span>
            <span class="text-[10px] text-green-300">주변 코인을 빨아들이는 자석</span>
        </button>
        <button onclick="selectHero('pig')" id="btn-char-pig" class="char-card flex flex-col items-center bg-white/10 hover:bg-white/20 border-2 border-transparent p-3 rounded-xl transition-all">
            <canvas id="canvas-pig" width="60" height="60" class="mb-1"></canvas>
            <span class="text-sm font-bold text-pink-400">돼지(해) 🐷</span>
            <span class="text-[10px] text-green-300">강철 생명력 하트 5개 기본</span>
        </button>
    </div>

    <button id="btn-back-start" class="text-white underline hover:text-yellow-200 text-sm">이전 화면으로</button>
</div>

<!-- 4. 스테이지 선택 화면 -->
<div id="screen-level-select" class="absolute inset-0 z-20 hidden flex flex-col items-center justify-center bg-gradient-to-b from-sky-500 via-indigo-500 to-purple-800 p-4 overflow-y-auto">
    <h2 class="text-3xl text-yellow-300 mb-6 drop-shadow">도전할 스테이지를 고르세요!</h2>
    
    <div class="grid grid-cols-5 gap-3 max-w-lg mb-8" id="level-grid"></div>
    <button id="btn-back-char" class="text-white underline hover:text-yellow-200">캐릭터 다시 고르기</button>
</div>

<!-- 4.5. 스테이지 프리뷰 (지도 미션 안내) -->
<div id="screen-stage-preview" class="absolute inset-0 z-20 hidden flex flex-col items-center justify-center bg-black/90 p-4 text-center">
    <h2 class="text-5xl text-yellow-300 font-extrabold mb-4 animate-bounce" id="preview-title">스테이지 1</h2>
    <div class="text-2xl text-white mb-8 bg-white/20 px-8 py-4 rounded-3xl shadow-lg border-2 border-white/50" id="preview-mission">
        목표: 우측으로 돌파하세요! 👉
    </div>
    <div class="text-xl text-emerald-300 animate-pulse font-bold">잠시 후 게임이 시작됩니다... 🚀</div>
</div>

<!-- 5. 게임 화면 상단 HUD -->
<div id="hud" class="absolute top-0 left-0 right-0 z-10 flex justify-between items-center px-4 py-2 bg-gradient-to-b from-black/50 to-transparent pointer-events-none text-sm md:text-lg">
    <div class="flex items-center gap-3">
        <span class="bg-yellow-400 text-blue-900 px-3 py-1 rounded-full font-bold shadow" id="hud-level">스테이지 1</span>
        <div class="flex items-center gap-1 text-xl" id="hud-lives">❤️❤️❤️</div>
    </div>
    <div class="flex flex-col items-end gap-1">
        <div class="flex gap-4 items-center">
            <span class="text-yellow-300 font-bold">✨ 점수: <span id="hud-score">0</span></span>
            <span class="text-emerald-300 font-bold">🪙 코인: <span id="hud-coins">0/5</span></span>
            <span class="bg-black/40 px-3 py-1 rounded-lg">⏱️ <span id="hud-timer">120</span>초</span>
        </div>
        <div id="hud-weapon-status" class="text-xs bg-red-600/90 text-white font-bold px-2 py-0.5 rounded animate-pulse">
            🔒 코인 5개를 모으면 총알 가능!
        </div>
    </div>
</div>

<!-- 파워업 오버레이 -->
<div id="hud-powerup" class="absolute top-14 left-4 z-10 hidden flex items-center gap-2 bg-yellow-400/90 text-blue-900 font-bold text-xs md:text-sm px-3 py-1 rounded-full shadow border-2 border-white animate-pulse">
    <span id="powerup-icon">⭐</span> <span id="powerup-text">2단 점프 활성화!</span>
</div>

<!-- 캔버스 -->
<canvas id="gameCanvas" class="w-full h-full flex-grow bg-gradient-to-b from-sky-300 to-sky-100 block"></canvas>

<!-- 6. 모바일 컨트롤 -->
<div id="touch-controls" class="absolute bottom-4 left-0 right-0 z-10 flex justify-between px-6 pointer-events-none md:hidden">
    <div class="flex gap-3 pointer-events-auto">
        <button id="btn-left" class="no-double-tap w-16 h-16 bg-white/20 active:bg-yellow-400 border-4 border-white text-white active:text-blue-900 rounded-2xl flex items-center justify-center text-3xl font-bold shadow-lg transition-all transform active:scale-90">◀</button>
        <button id="btn-right" class="no-double-tap w-16 h-16 bg-white/20 active:bg-yellow-400 border-4 border-white text-white active:text-blue-900 rounded-2xl flex items-center justify-center text-3xl font-bold shadow-lg transition-all transform active:scale-90">▶</button>
    </div>
    <div class="flex gap-2 pointer-events-auto items-end">
        <button id="btn-shoot" class="no-double-tap w-16 h-16 bg-red-500 active:bg-red-400 border-4 border-white text-white rounded-2xl flex flex-col items-center justify-center shadow-lg transition-all transform active:scale-90">
            <span class="text-xl font-bold">공격</span>
            <span class="text-[9px] font-bold">F KEY</span>
        </button>
        <button id="btn-jump" class="no-double-tap w-20 h-16 bg-yellow-400 active:bg-yellow-300 border-4 border-white text-blue-900 rounded-2xl flex flex-col items-center justify-center shadow-lg transition-all transform active:scale-90">
            <span class="text-2xl font-bold">점프</span>
            <span class="text-[10px] font-bold">JUMP</span>
        </button>
    </div>
</div>

<!-- 7. 팝업창들 -->
<div id="pop-clear" class="absolute inset-0 z-20 hidden flex flex-col items-center justify-center bg-black/75 p-4 text-center">
    <div class="bg-gradient-to-b from-yellow-300 to-amber-500 border-8 border-white rounded-3xl p-8 max-w-md w-full shadow-2xl transform scale-95 transition-all">
        <div class="text-5xl mb-4">🌟 참 잘했어요! 🌟</div>
        <h3 class="text-3xl font-bold text-blue-900 mb-2" id="clear-title">스테이지 1 완료</h3>
        <p class="text-white drop-shadow mb-6 text-lg">대단해요! 더 높은 타워도 올라갈 수 있죠?</p>
        <div class="flex flex-col gap-3">
            <button id="btn-next-stage" class="w-full py-4 bg-emerald-500 hover:bg-emerald-400 border-4 border-white text-white font-bold text-xl rounded-2xl shadow-md transition-transform active:scale-95">다음 스테이지 도전! 🚀</button>
            <button id="btn-go-levels" class="w-full py-2 bg-slate-600 hover:bg-slate-500 border-2 border-white text-white font-bold text-sm rounded-xl">지도 화면으로 가기</button>
        </div>
    </div>
</div>

<div id="pop-fail" class="absolute inset-0 z-20 hidden flex flex-col items-center justify-center bg-black/80 p-4 text-center">
    <div class="bg-gradient-to-b from-rose-500 to-red-800 border-8 border-white rounded-3xl p-8 max-w-md w-full shadow-2xl">
        <div class="text-5xl mb-4">😢 아쉬워요!</div>
        <h3 class="text-3xl font-bold text-white mb-2">하트가 모두 떨어졌어요</h3>
        <p class="text-rose-200 mb-6 text-lg">포기하지 마세요! 다시 도전하면 할 수 있어요! 👍</p>
        <div class="flex flex-col gap-3">
            <button id="btn-retry" class="w-full py-4 bg-yellow-400 hover:bg-yellow-300 border-4 border-white text-blue-900 font-bold text-xl rounded-2xl shadow-md transition-transform active:scale-95">이어서 다시 하기! 🔄</button>
            <button id="btn-go-levels-fail" class="w-full py-2 bg-slate-600 hover:bg-slate-500 border-2 border-white text-white font-bold text-sm rounded-xl">지도 화면으로 가기</button>
        </div>
    </div>
</div>

<div id="pop-all-clear" class="absolute inset-0 z-20 hidden flex flex-col items-center justify-center bg-black/80 p-4 text-center">
    <div class="bg-gradient-to-b from-violet-600 via-fuchsia-500 to-indigo-700 border-8 border-white rounded-3xl p-8 max-lg w-full shadow-2xl">
        <div class="text-6xl mb-4 animate-bounce">🏆 👑 🏆</div>
        <h3 class="text-4xl font-bold text-yellow-300 mb-4">전설의 모험가 탄생!</h3>
        <p class="text-white mb-6 text-lg leading-relaxed">
            모든 10개 나라를 위협하던 악당과 탄막들을 완벽히 물리쳤습니다!<br>
            연우와 재연이, 그리고 동물 친구들의 활약이 왕국 역사에 영원히 기록될 것입니다! 🌟
        </p>
        <div class="bg-white/20 p-4 rounded-xl mb-6">
            <div class="text-xl font-bold text-yellow-200">최종 스코어</div>
            <div class="text-4xl font-extrabold text-white mt-1" id="final-score">100,000</div>
        </div>
        <button id="btn-ending-restart" class="w-full py-4 bg-yellow-400 hover:bg-yellow-300 border-4 border-white text-blue-900 font-bold text-2xl rounded-2xl shadow-md transition-transform active:scale-95">처음부터 다시 달리기! 🏃‍♀️🏃‍♂️</button>
    </div>
</div>
<script> // 전역 변수 및 게임 상태 초기화 const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); let gameState = 'tutorial'; let selectedCharacter = 'boy'; let currentLevel = 1; const maxLevel = 10; let unlockedLevel = 1; let lives = 3; let maxLives = 3; let score = 0; let coinsCount = 0; let levelTimer = 120; let timerInterval = null; let isVerticalStage = false; // 홀짝 스테이지 방향 const player = { x: 100, y: 300, width: 38, height: 48, vx: 0, vy: 0, speed: 4.9, jumpForce: 11.2, friction: 0.82, magnetRange: 140, isOnGround: false, doubleJumpCount: 0, maxDoubleJumps: 0, invincibilityFrames: 0, powerupType: null, powerupTimer: 0, hasPassiveShield: false, shieldCooldown: 0 }; const GRAVITY = 0.45; let cameraX = 0, cameraY = 0; const keys = { left: false, right: false, jump: false, shoot: false }; let platforms = [], coins = [], enemies = [], items = [], projectiles = [], playerBullets = [], jumpSparks = [], clouds = []; let goalFlag = { x: 0, y: 0, width: 40, height: 160 }; const AudioContext = window.AudioContext || window.webkitAudioContext; let audioCtx = null; let bgmInterval = null, bgmStep = 0, currentBgmType = null; function initAudio() { if (!audioCtx) audioCtx = new AudioContext(); if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume(); } function midiToFreq(note) { return 440 * Math.pow(2, (note - 69) / 12); } function getWeeklyKpopBGM() { const now = new Date(); const start = new Date(now.getFullYear(), 0, 1); const week = Math.floor((now - start) / (7 * 24 * 60 * 60 * 1000)); const trackType = week % 3; let melody = [], bass = [], bpm = 120; if (trackType === 0) { // 걸그룹 팝 melody = [72, 0, 72, 72, 69, 72, 0, 74, 76, 76, 74, 72, 69, 0, 69, 72]; bass = [48, 48, 48, 48, 41, 41, 41, 41, 45, 45, 45, 45, 43, 43, 43, 43]; bpm = 140; } else if (trackType === 1) { // 트랩 베이스 보이그룹 melody = [60, 63, 65, 0, 67, 65, 63, 60, 60, 0, 63, 60, 58, 60, 0, 0]; bass = [36, 0, 0, 36, 0, 36, 36, 0, 43, 0, 0, 43, 0, 41, 39, 0]; bpm = 125; } else { // 레트로 신스팝 melody = [67, 69, 71, 74, 71, 69, 67, 64, 67, 0, 67, 69, 71, 69, 67, 0]; bass = [43, 43, 43, 43, 47, 47, 47, 47, 48, 48, 48, 48, 45, 45, 45, 45]; bpm = 135; } return { melody, bass, bpm }; } function startBGM(type) { initAudio(); if (!audioCtx) return; if (currentBgmType === type && bgmInterval) return; stopBGM(); currentBgmType = type; bgmStep = 0; let melody = [], bass = [], bpm = 120, waveType = 'triangle', bassWaveType = 'sine', melodyVolume = 0.04, bassVolume = 0.05; if (type === 'menu') { melody = [60, 64, 67, 72, 67, 64, 60, 64, 62, 65, 69, 74, 69, 65, 62, 65]; bass = [36, 36, 43, 43, 38, 38, 45, 45]; bpm = 100; } else if (type === 'stage') { const kpop = getWeeklyKpopBGM(); melody = kpop.melody; bass = kpop.bass; bpm = kpop.bpm; waveType = 'triangle'; bassWaveType = 'sawtooth'; melodyVolume = 0.035; bassVolume = 0.035; } const stepDuration = 60 / bpm / 2; bgmInterval = setInterval(() => { if (gameState !== 'playing' && type !== 'menu') { stopBGM(); return; } try { const now = audioCtx.currentTime; if (melody.length > 0) { const midiNote = melody[bgmStep % melody.length]; if (midiNote > 0) { const osc = audioCtx.createOscillator(), gainNode = audioCtx.createGain(); osc.connect(gainNode); gainNode.connect(audioCtx.destination); osc.type = waveType; osc.frequency.setValueAtTime(midiToFreq(midiNote), now); gainNode.gain.setValueAtTime(melodyVolume, now); gainNode.gain.exponentialRampToValueAtTime(0.001, now + stepDuration - 0.015); osc.start(now); osc.stop(now + stepDuration); } } if (bass.length > 0 && bgmStep % 2 === 0) { const bassNote = bass[Math.floor(bgmStep / 2) % bass.length]; if (bassNote > 0) { const oscBass = audioCtx.createOscillator(), gainBass = audioCtx.createGain(); oscBass.connect(gainBass); gainBass.connect(audioCtx.destination); oscBass.type = bassWaveType; oscBass.frequency.setValueAtTime(midiToFreq(bassNote), now); gainBass.gain.setValueAtTime(bassVolume, now); gainBass.gain.exponentialRampToValueAtTime(0.001, now + (stepDuration * 2) - 0.02); oscBass.start(now); oscBass.stop(now + (stepDuration * 2)); } } bgmStep++; } catch (e) { console.warn("BGM 에러", e); } }, stepDuration * 1000); } function stopBGM() { if (bgmInterval) { clearInterval(bgmInterval); bgmInterval = null; } currentBgmType = null; } function playSound(type) { try { initAudio(); if (!audioCtx) return; const osc = audioCtx.createOscillator(), gainNode = audioCtx.createGain(); osc.connect(gainNode); gainNode.connect(audioCtx.destination); const now = audioCtx.currentTime; if (type === 'jump') { osc.type = 'sine'; osc.frequency.setValueAtTime(160, now); osc.frequency.exponentialRampToValueAtTime(650, now + 0.16); gainNode.gain.setValueAtTime(0.25, now); gainNode.gain.exponentialRampToValueAtTime(0.01, now + 0.16); osc.start(now); osc.stop(now + 0.16); } else if (type === 'doubleJump') { osc.type = 'triangle'; osc.frequency.setValueAtTime(320, now); osc.frequency.exponentialRampToValueAtTime(850, now + 0.22); gainNode.gain.setValueAtTime(0.25, now); gainNode.gain.exponentialRampToValueAtTime(0.01, now + 0.22); osc.start(now); osc.stop(now + 0.22); } else if (type === 'coin') { const osc2 = audioCtx.createOscillator(), gainNode2 = audioCtx.createGain(); osc2.connect(gainNode2); gainNode2.connect(audioCtx.destination); osc.type = 'sine'; osc.frequency.setValueAtTime(987.77, now); osc.frequency.setValueAtTime(1318.51, now + 0.08); gainNode.gain.setValueAtTime(0.12, now); gainNode.gain.exponentialRampToValueAtTime(0.005, now + 0.25); osc2.type = 'sine'; osc2.frequency.setValueAtTime(1318.51, now); osc2.frequency.setValueAtTime(1567.98, now + 0.08); gainNode2.gain.setValueAtTime(0.08, now); gainNode2.gain.exponentialRampToValueAtTime(0.005, now + 0.25); osc.start(now); osc.stop(now + 0.25); osc2.start(now); osc2.stop(now + 0.25); } else if (type === 'shoot') { osc.type = 'sawtooth'; osc.frequency.setValueAtTime(550, now); osc.frequency.exponentialRampToValueAtTime(120, now + 0.12); gainNode.gain.setValueAtTime(0.12, now); gainNode.gain.exponentialRampToValueAtTime(0.01, now + 0.12); osc.start(now); osc.stop(now + 0.12); } else if (type === 'hurt') { osc.type = 'sawtooth'; osc.frequency.setValueAtTime(320, now); osc.frequency.linearRampToValueAtTime(80, now + 0.28); gainNode.gain.setValueAtTime(0.4, now); gainNode.gain.exponentialRampToValueAtTime(0.005, now + 0.28); osc.start(now); osc.stop(now + 0.28); } else if (type === 'stomp') { osc.type = 'sawtooth'; osc.frequency.setValueAtTime(150, now); osc.frequency.linearRampToValueAtTime(30, now + 0.14); gainNode.gain.setValueAtTime(0.35, now); gainNode.gain.exponentialRampToValueAtTime(0.01, now + 0.14); osc.start(now); osc.stop(now + 0.14); } else if (type === 'powerup' || type === 'heal' || type === 'clear' || type === 'fail') { let notes = type === 'powerup' ? [261.63, 329.63, 392.00, 523.25, 659.25] : type === 'heal' ? [392.00, 493.88, 587.33, 783.99] : type === 'clear' ? [523.25, 659.25, 783.99, 1046.50] : [440.00, 415.30, 392.00, 349.23]; let dur = type === 'powerup' ? 0.06 : type === 'heal' ? 0.07 : type === 'clear' ? 0.08 : 0.15; notes.forEach((freq, idx) => { const o = audioCtx.createOscillator(), g = audioCtx.createGain(); o.connect(g); g.connect(audioCtx.destination); o.type = type === 'fail' ? 'sawtooth' : 'sine'; o.frequency.setValueAtTime(freq, now + idx * dur); g.gain.setValueAtTime(0.15, now + idx * dur); g.gain.exponentialRampToValueAtTime(0.001, now + idx * dur + 0.25); o.start(now + idx * dur); o.stop(now + idx * dur + 0.25); }); } } catch (e) {} } function showScreen(screenId) { ['tutorial', 'start', 'char-select', 'level-select', 'stage-preview', 'clear', 'fail', 'all-clear'].forEach(id => { let el = document.getElementById(id.startsWith('pop-') ? id : 'screen-' + id) || document.getElementById('pop-' + id); if (el) el.classList.add('hidden'); }); if (screenId === 'tutorial') { document.getElementById('screen-tutorial').classList.remove('hidden'); stopBGM(); } else if (screenId === 'start') { document.getElementById('screen-start').classList.remove('hidden'); startBGM('menu'); } else if (screenId === 'char_select') { document.getElementById('screen-char-select').classList.remove('hidden'); startBGM('menu'); } else if (screenId === 'level_select') { buildLevelGrid(); document.getElementById('screen-level-select').classList.remove('hidden'); startBGM('menu'); } else if (screenId === 'preview') { document.getElementById('screen-stage-preview').classList.remove('hidden'); stopBGM(); } else if (screenId === 'clear') { document.getElementById('pop-clear').classList.remove('hidden'); stopBGM(); } else if (screenId === 'fail') { document.getElementById('pop-fail').classList.remove('hidden'); stopBGM(); } else if (screenId === 'ending') { document.getElementById('pop-all-clear').classList.remove('hidden'); stopBGM(); } } function selectHero(heroName) { selectedCharacter = heroName; playSound('coin'); document.querySelectorAll('.char-card').forEach(btn => btn.classList.remove('border-yellow-400', 'bg-white/30')); const activeBtn = document.getElementById(`btn-char-${heroName}`); if (activeBtn) activeBtn.classList.add('border-yellow-400', 'bg-white/30'); setTimeout(() => showScreen('level_select'), 150); } function buildLevelGrid() { const grid = document.getElementById('level-grid'); grid.innerHTML = ''; for (let i = 1; i <= maxLevel; i++) { const btn = document.createElement('button'); btn.className = `w-14 h-14 rounded-2xl flex items-center justify-center font-bold text-xl transition-all shadow-md `; if (i <= unlockedLevel) { btn.className += `bg-yellow-400 hover:bg-yellow-300 border-4 border-white text-blue-900 cursor-pointer active:scale-90`; btn.innerHTML = `${i}`; btn.onclick = () => { initAudio(); playSound('coin'); startGame(i); }; } else { btn.className += `bg-slate-700/60 border-4 border-slate-600 text-slate-400 cursor-not-allowed`; btn.innerHTML = `🔒`; } grid.appendChild(btn); } } function startTimer() { clearInterval(timerInterval); timerInterval = setInterval(() => { if (gameState === 'playing') { levelTimer--; document.getElementById('hud-timer').textContent = levelTimer; if (levelTimer <= 0) handlePlayerHurt(true); } }, 1000); } function drawCharacterPreview(canvasId, type) { const cvs = document.getElementById(canvasId); if (!cvs) return; const cx = cvs.getContext('2d'); cx.clearRect(0, 0, cvs.width, cvs.height); cx.save(); cx.translate(cvs.width / 2, cvs.height / 2 + 5); cx.fillStyle = 'rgba(0,0,0,0.15)'; cx.beginPath(); cx.ellipse(0, 22, 16, 5, 0, 0, Math.PI*2); cx.fill(); if (type === 'boy' || type === 'girl') { cx.fillStyle = (type === 'boy') ? '#3b82f6' : '#ec4899'; cx.beginPath(); cx.arc(0, 15, 12, Math.PI, 0, false); cx.fill(); cx.fillStyle = '#fed7aa'; cx.fillRect(-15, 8, 4, 10); cx.fillRect(11, 8, 4, 10); cx.beginPath(); cx.arc(0, -3, 13, 0, Math.PI * 2); cx.fill(); if (type === 'boy') { cx.fillStyle = '#78350f'; cx.beginPath(); cx.arc(0, -6, 14, Math.PI, 0); cx.fill(); cx.fillStyle = '#1d4ed8'; cx.beginPath(); cx.arc(0, -11, 10, Math.PI, 0); cx.fill(); } else { cx.fillStyle = '#fbbf24'; cx.beginPath(); cx.arc(0, -6, 14, Math.PI, 0); cx.fill(); cx.fillStyle = '#f43f5e'; cx.beginPath(); cx.arc(-10, -9, 4, 0, Math.PI*2); cx.arc(10, -9, 4, 0, Math.PI*2); cx.fill(); } cx.fillStyle = '#1e293b'; cx.beginPath(); cx.arc(-4, -3, 2, 0, Math.PI * 2); cx.arc(4, -3, 2, 0, Math.PI * 2); cx.fill(); cx.strokeStyle = '#1e293b'; cx.lineWidth = 1.5; cx.beginPath(); cx.arc(0, 1, 3, 0, Math.PI); cx.stroke(); } else { drawZodiacFace(cx, type); } cx.restore(); } function drawZodiacFace(ctx, type) { ctx.save(); const shapes = { 'rat': { c: '#94a3b8', extra: () => { ctx.fillStyle='#475569'; ctx.beginPath(); ctx.arc(-13,-6,7,0,7); ctx.arc(13,-6,7,0,7); ctx.fill(); ctx.fillStyle='#fca5a5'; ctx.beginPath(); ctx.arc(-13,-6,4,0,7); ctx.arc(13,-6,4,0,7); ctx.fill(); } }, 'ox': { c: '#b45309', extra: () => { ctx.fillStyle='#e2e8f0'; ctx.beginPath(); ctx.moveTo(-10,-3); ctx.quadraticCurveTo(-14,-13,-8,-13); ctx.lineTo(-6,-3); ctx.moveTo(10,-3); ctx.quadraticCurveTo(14,-13,8,-13); ctx.lineTo(6,-3); ctx.fill(); } }, 'tiger': { c: '#f97316', extra: () => { ctx.beginPath(); ctx.arc(-10,-6,5,0,7); ctx.arc(10,-6,5,0,7); ctx.fill(); ctx.fillStyle='#000'; ctx.fillRect(-12,4,4,2); ctx.fillRect(8,4,4,2); ctx.fillRect(-3,-7,6,2); } }, 'rabbit': { c: '#f8fafc', extra: () => { ctx.fillStyle='#f1f5f9'; ctx.beginPath(); ctx.roundRect(-9,-15,6,16,3); ctx.roundRect(3,-15,6,16,3); ctx.fill(); ctx.fillStyle='#fca5a5'; ctx.beginPath(); ctx.roundRect(-7,-12,3,11,2); ctx.roundRect(5,-12,3,11,2); ctx.fill(); } }, 'dragon': { c: '#10b981', extra: () => { ctx.fillStyle='#fbbf24'; ctx.fillRect(-6,-11,3,8); ctx.fillRect(3,-11,3,8); } }, 'snake': { c: '#22c55e', r: 11, y: 8, extra: () => { ctx.fillStyle='#ef4444'; ctx.fillRect(-1.5,13,3,6); } }, 'horse': { c: '#854d0e', extra: () => { ctx.fillStyle='#451a03'; ctx.fillRect(-3,-11,6,10); } }, 'sheep': { c: '#f1f5f9', r: 13, extra: () => { ctx.beginPath(); ctx.arc(-10,-5,5,0,7); ctx.arc(10,-5,5,0,7); ctx.arc(0,-10,6,0,7); ctx.fill(); } }, 'monkey': { c: '#b45309', extra: () => { ctx.fillStyle='#ffedd5'; ctx.beginPath(); ctx.arc(-5,5,7,0,7); ctx.arc(5,5,7,0,7); ctx.fill(); } }, 'rooster': { c: '#ffffff', r: 13, y: 7, extra: () => { ctx.fillStyle='#ef4444'; ctx.beginPath(); ctx.arc(0,-6,5,0,7); ctx.fill(); ctx.fillStyle='#f59e0b'; ctx.beginPath(); ctx.moveTo(-4,5); ctx.lineTo(4,5); ctx.lineTo(0,11); ctx.fill(); } }, 'dog': { c: '#eab308', r: 13, extra: () => { ctx.fillStyle='#ca8a04'; ctx.beginPath(); ctx.roundRect(-15,-1,5,12,2); ctx.roundRect(10,-1,5,12,2); ctx.fill(); } }, 'pig': { c: '#f472b6', extra: () => { ctx.fillStyle='#ec4899'; ctx.beginPath(); ctx.roundRect(-5,5,10,6,3); ctx.fill(); } }, }; let s = shapes[type]; ctx.fillStyle = s.c; ctx.beginPath(); ctx.arc(0, s.y || 5, s.r || 14, 0, Math.PI*2); ctx.fill(); if(s.extra) s.extra(); ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(-4, 2, 2.2, 0, 7); ctx.arc(4, 2, 2.2, 0, 7); ctx.fill(); ctx.restore(); } window.addEventListener('DOMContentLoaded', () => { ['boy', 'girl', 'rat', 'ox', 'tiger', 'rabbit', 'dragon', 'snake', 'horse', 'sheep', 'monkey', 'rooster', 'dog', 'pig'].forEach(id => drawCharacterPreview('canvas-' + id, id)); }); function createPlatform(x, y, w, h, type = 'grass', isDisappearing = false) { platforms.push({ x, y, width: w, height: h, type, isDisappearing, opacity: 1, timer: 0 }); } function createCoin(x, y) { coins.push({ x, y, radius: 10, isCollected: false }); } function createItem(x, y, type) { items.push({ x, y, width: 30, height: 30, type, isCollected: false }); } function createEnemy(x, y, type = 'slime', patrolDist = 150) { enemies.push({ startX: x, x, y, width: type === 'boss' ? 70 : 34, height: type === 'boss' ? 60 : 30, type, patrolDist, speed: type === 'boss' ? 3 : type === 'bat' ? 2.6 : 1.7 * (1.35 + currentLevel * 0.12), dir: 1, bossHp: type === 'boss' ? 6 : 1, squashed: false, squashTimer: 0, shootCooldown: Math.random() * 60 }); } function generateMap(level) { platforms = []; coins = []; enemies = []; items = []; clouds = []; projectiles = []; playerBullets = []; jumpSparks = []; isVerticalStage = (level % 2 === 0); for (let i = 0; i < 15; i++) { clouds.push({ x: Math.random() * 4000, y: (isVerticalStage ? -2000 : 40) + Math.random() * (isVerticalStage ? 2500 : 150), size: 40 + Math.random() * 50, speed: 0.1 + Math.random() * 0.3 }); } if (isVerticalStage) { const mapW = Math.max(800, canvas.width); createPlatform(0, 420, mapW, 80, 'grass'); let curY = 300, side = 0; const targetMinY = -600 - (level * 350); while(curY > targetMinY) { let pW = 120 + Math.random() * 80, pX; if (side === 0) pX = 40 + Math.random() * 80; else if (side === 1) pX = mapW - pW - 40 - Math.random() * 80; else pX = (mapW - pW)/2 + (Math.random() * 100 - 50); createPlatform(pX, curY, pW, 30, Math.random() > 0.6 ? 'brick' : 'cloud_platform'); if(Math.random() < 0.6) createCoin(pX + pW/2, curY - 30); // 짝수 스테이지에서도 적 출현하도록 로직 수정 if(Math.random() < 0.45) createEnemy(pX + 10, curY - 30, Math.random() > 0.5 ? 'bat' : 'slime', pW - 20); if(Math.random() < 0.1) createItem(pX + pW/2, curY - 60, ['star', 'potion', 'heart'][Math.floor(Math.random()*3)]); // 계단 높이 고정 (70~110 픽셀) curY -= (70 + Math.random() * 40); side = (side + 1 + Math.floor(Math.random()*2)) % 3; } const topPlatformX = mapW/2 - 100; createPlatform(topPlatformX, targetMinY, 200, 20, 'brick'); goalFlag.x = topPlatformX + 80; goalFlag.y = targetMinY - 160; if(level === 10) { createEnemy(topPlatformX + 50, targetMinY - 60, 'boss', 150); } } else { const mapLength = 1900 + (level * 350); let curX = 0, gaps = []; while (curX < mapLength) { let gap = (level > 1 && curX > 400 && curX < mapLength - 800 && Math.random() < 0.28 + (level * 0.03)) ? Math.min(260, 120 + (Math.random() * 100) + (level * 6)) : 0; if (gap > 0) { gaps.push({start: curX, end: curX + gap}); curX += gap; } let chunkW = Math.min(mapLength - curX, 400 + Math.random() * 400); createPlatform(curX, 420, chunkW, 80, 'grass'); curX += chunkW; } let segmentX = 300; while (segmentX < mapLength - 800) { // 발판 높이를 60~110 픽셀 사이로 고정 const h = 420 - (60 + Math.random() * 50); const w = level === 1 ? 140 : 90 + Math.random() * 70; let isSafe = true; for(let g of gaps) if (segmentX + w > g.start - 30 && segmentX < g.end + 30) { isSafe = false; break; } if (isSafe) { createPlatform(segmentX, h, w, 30, Math.random() > 0.5 ? 'brick' : 'cloud_platform'); if (level === 1) { createCoin(segmentX+w/2-20, h-25); createCoin(segmentX+w/2, h-25); createCoin(segmentX+w/2+20, h-25); } else if (Math.random() < 0.7) { createCoin(segmentX+w/2, h-30); createCoin(segmentX+w/2-25, h-30); } if (Math.random() < 0.45) createItem(segmentX + w/2, h - 65, ['star', 'gem', 'potion', 'heart'][Math.floor(Math.random() * 4)]); if (Math.random() < 0.5 + (level * 0.04)) createEnemy(segmentX + 15, level >= 3 && Math.random() < 0.5 ? h - 75 : 390, level >= 3 && Math.random() < 0.5 ? 'bat' : 'slime', w - 30); if (level >= 3 && Math.random() < 0.35) createPlatform(segmentX + w - 30, 390, 35, 30, 'spike'); if (level >= 4 && Math.random() < 0.4) createPlatform(segmentX + w + 35, h + 20, 70, 20, 'disappearing', true); } segmentX += w + 110 + (Math.random() * 100); } const twrX = mapLength - 350; createPlatform(twrX, 340, 80, 20, 'brick'); createPlatform(twrX + 80, 260, 80, 20, 'brick'); createPlatform(twrX + 160, 180, 80, 20, 'brick'); createPlatform(twrX + 240, 110, 100, 20, 'brick'); goalFlag.x = twrX + 270; goalFlag.y = 110 - 160; } } function drawSkyGradient() { let grad = ctx.createLinearGradient(0, 0, 0, canvas.height); if (currentLevel <= 3) { grad.addColorStop(0, '#7dd3fc'); grad.addColorStop(1, '#e0f2fe'); } else if (currentLevel <= 7) { grad.addColorStop(0, '#f97316'); grad.addColorStop(0.6, '#ec4899'); grad.addColorStop(1, '#818cf8'); } else { grad.addColorStop(0, '#0f172a'); grad.addColorStop(0.6, '#311042'); grad.addColorStop(1, '#1e1b4b'); } ctx.fillStyle = grad; ctx.fillRect(0, 0, canvas.width, canvas.height); } function drawClouds() { ctx.fillStyle = 'rgba(255, 255, 255, 0.75)'; clouds.forEach(c => { const rX = (c.x - cameraX * 0.3) % (canvas.width + 200), aX = rX < -100 ? rX + canvas.width + 200 : rX - 100, aY = c.y - cameraY * 0.1; ctx.beginPath(); ctx.arc(aX, aY, c.size * 0.5, 0, 7); ctx.arc(aX + c.size * 0.4, aY - c.size * 0.1, c.size * 0.4, 0, 7); ctx.arc(aX - c.size * 0.4, aY - c.size * 0.1, c.size * 0.35, 0, 7); ctx.arc(aX + c.size * 0.7, aY + c.size * 0.1, c.size * 0.3, 0, 7); ctx.fill(); }); } function drawPlatforms() { platforms.forEach(p => { if (p.x + p.width - cameraX < -50 || p.x - cameraX > canvas.width + 50 || p.y + p.height - cameraY < -50 || p.y - cameraY > canvas.height + 50) return; ctx.save(); ctx.globalAlpha = p.opacity; const rx = p.x - cameraX, ry = p.y - cameraY; if (p.type === 'grass') { ctx.fillStyle = '#15803d'; ctx.fillRect(rx, ry, p.width, 18); ctx.fillStyle = '#78350f'; ctx.fillRect(rx, ry + 18, p.width, p.height - 18); ctx.fillStyle = '#451a03'; for (let ox = 15; ox < p.width; ox += 60) { ctx.fillRect(rx + ox, ry + 25, 10, 10); ctx.fillRect(rx + ox + 20, ry + 45, 10, 10); } } else if (p.type === 'brick' || p.type === 'disappearing') { ctx.fillStyle = p.type === 'brick' ? '#b45309' : '#f59e0b'; ctx.fillRect(rx, ry, p.width, p.height); ctx.strokeStyle = '#fef08a'; ctx.lineWidth = 2.5; ctx.strokeRect(rx, ry, p.width, p.height); if(p.type === 'brick') { ctx.beginPath(); for (let ey = ry + 15; ey < ry + p.height; ey += 15) { ctx.moveTo(rx, ey); ctx.lineTo(rx + p.width, ey); } ctx.stroke(); } } else if (p.type === 'cloud_platform') { ctx.fillStyle = '#f1f5f9'; ctx.strokeStyle = '#cbd5e1'; ctx.lineWidth = 3; ctx.beginPath(); ctx.roundRect(rx, ry, p.width, p.height, 15); ctx.fill(); ctx.stroke(); } else if (p.type === 'spike') { ctx.fillStyle = '#cbd5e1'; ctx.strokeStyle = '#475569'; ctx.lineWidth = 2; for (let i = 0; i < Math.floor(p.width / 20); i++) { ctx.beginPath(); ctx.moveTo(rx + i*20, ry + p.height); ctx.lineTo(rx + i*20 + 10, ry); ctx.lineTo(rx + i*20 + 20, ry + p.height); ctx.fill(); ctx.stroke(); } } ctx.restore(); }); } function drawCoins() { coins.forEach(c => { if (!c.isCollected) { ctx.save(); ctx.fillStyle = '#fbbf24'; ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.ellipse(c.x - cameraX, c.y - cameraY, c.radius + Math.sin(Date.now() * 0.01) * 1.5, c.radius, 0, 0, 7); ctx.fill(); ctx.stroke(); ctx.restore(); } }); } function drawItems() { items.forEach(it => { if (!it.isCollected) { ctx.save(); const rx = it.x - cameraX, ry = it.y - cameraY + Math.sin(Date.now() * 0.008) * 5; ctx.fillStyle = 'rgba(255,255,255,0.2)'; ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(rx + 15, ry + 15, 22, 0, 7); ctx.fill(); ctx.stroke(); if (it.type === 'star') { ctx.fillStyle = '#facc15'; drawStarShape(rx + 15, ry + 15, 5, 14, 6); } else if (it.type === 'gem') { ctx.fillStyle = '#ec4899'; ctx.beginPath(); ctx.moveTo(rx+15, ry+3); ctx.lineTo(rx+28, ry+15); ctx.lineTo(rx+15, ry+27); ctx.lineTo(rx+2, ry+15); ctx.fill(); } else if (it.type === 'potion') { ctx.fillStyle = '#3b82f6'; ctx.fillRect(rx+10, ry+12, 10, 14); ctx.fillStyle = '#60a5fa'; ctx.fillRect(rx+12, ry+4, 6, 8); } else if (it.type === 'heart') { ctx.fillStyle = '#ef4444'; ctx.beginPath(); ctx.arc(rx+10, ry+10, 7, 0, Math.PI, true); ctx.arc(rx+20, ry+10, 7, 0, Math.PI, true); ctx.lineTo(rx+15, ry+25); ctx.fill(); } ctx.restore(); } }); } function drawStarShape(cx, cy, spikes, outerRadius, innerRadius) { let rot = Math.PI / 2 * 3, step = Math.PI / spikes; ctx.beginPath(); ctx.moveTo(cx, cy - outerRadius); for (let i = 0; i < spikes; i++) { ctx.lineTo(cx + Math.cos(rot) * outerRadius, cy + Math.sin(rot) * outerRadius); rot += step; ctx.lineTo(cx + Math.cos(rot) * innerRadius, cy + Math.sin(rot) * innerRadius); rot += step; } ctx.lineTo(cx, cy - outerRadius); ctx.fill(); } function drawEnemies() { enemies.forEach(e => { if (e.x - cameraX < -100 || e.x - cameraX > canvas.width + 100) return; ctx.save(); const rx = e.x - cameraX, ry = e.y - cameraY; if (e.squashed) { ctx.fillStyle = '#cbd5e1'; ctx.fillRect(rx, ry + e.height - 10, e.width, 10); } else if (e.type === 'slime') { const sq = Math.sin(Date.now() * 0.015) * 2; ctx.fillStyle = '#a855f7'; ctx.beginPath(); ctx.ellipse(rx + 17, ry + 15 + sq/2, 17, 15 - sq/2, 0, 0, 7); ctx.fill(); ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(rx + 10, ry + 12, 4, 0, 7); ctx.arc(rx + 24, ry + 12, 4, 0, 7); ctx.fill(); } else if (e.type === 'bat') { const wY = Math.sin(Date.now() * 0.02) * 10; ctx.fillStyle = '#ef4444'; ctx.beginPath(); ctx.arc(rx + 17, ry + 15, 12, 0, 7); ctx.fill(); ctx.fillStyle = '#991b1b'; ctx.beginPath(); ctx.moveTo(rx + 9, ry + 15); ctx.lineTo(rx - 8, ry + 15 - wY); ctx.lineTo(rx + 2, ry + 21); ctx.fill(); } else if (e.type === 'boss') { ctx.fillStyle = '#374151'; ctx.beginPath(); ctx.roundRect(rx, ry, e.width, e.height, 15); ctx.fill(); ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.moveTo(rx + 15, ry); ctx.lineTo(rx + 10, ry - 20); ctx.lineTo(rx + 35, ry - 25); ctx.lineTo(rx + 55, ry); ctx.fill(); ctx.fillStyle = '#1e293b'; ctx.fillRect(rx - 5, ry - 40, 80, 8); ctx.fillStyle = '#ef4444'; ctx.fillRect(rx - 5, ry - 40, 80 * (e.bossHp / 6), 8); } ctx.restore(); }); } function drawProjectiles() { projectiles.forEach(p => { ctx.save(); const rx = p.x - cameraX, ry = p.y - cameraY; ctx.fillStyle = p.type === 'acid' ? '#22c55e' : p.type === 'darkness' ? '#a855f7' : '#f97316'; ctx.beginPath(); ctx.arc(rx, ry, p.type === 'boss_fire' ? 12 : p.type === 'darkness' ? 8 : 7, 0, 7); ctx.fill(); ctx.restore(); }); } function drawPlayerBullets() { playerBullets.forEach(b => { ctx.save(); ctx.fillStyle = selectedCharacter === 'tiger' ? '#ef4444' : '#facc15'; ctx.shadowColor = '#f59e0b'; ctx.shadowBlur = 8; ctx.beginPath(); ctx.arc(b.x - cameraX, b.y - cameraY, selectedCharacter === 'tiger' ? 10 : 7, 0, 7); ctx.fill(); ctx.restore(); }); } function drawGoalFlag() { const rx = goalFlag.x - cameraX, ry = goalFlag.y - cameraY; ctx.save(); ctx.fillStyle = '#94a3b8'; ctx.fillRect(rx + 16, ry, 8, goalFlag.height); ctx.fillStyle = '#eab308'; ctx.beginPath(); ctx.arc(rx + 20, ry, 8, 0, 7); ctx.fill(); ctx.fillStyle = '#10b981'; ctx.beginPath(); ctx.moveTo(rx + 24, ry + 15); ctx.lineTo(rx + 80 + Math.sin(Date.now() * 0.007) * 4, ry + 35); ctx.lineTo(rx + 24, ry + 55); ctx.fill(); ctx.fillStyle = '#475569'; ctx.fillRect(rx, ry + goalFlag.height - 15, 40, 15); ctx.restore(); } function drawPlayer() { if (player.invincibilityFrames > 0 && Math.floor(player.invincibilityFrames / 4) % 2 === 0) return; ctx.save(); ctx.translate(player.x - cameraX + player.width / 2, player.y - cameraY + player.height / 2); ctx.rotate(player.vx * 0.02); if (player.powerupType === 'gem' || player.hasPassiveShield) { ctx.strokeStyle = player.hasPassiveShield ? 'rgba(147, 197, 253, 0.8)' : 'rgba(236, 72, 153, 0.7)'; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(0, 0, 32 + Math.sin(Date.now() * 0.02) * 3, 0, 7); ctx.stroke(); } if (selectedCharacter === 'boy' || selectedCharacter === 'girl') { ctx.fillStyle = selectedCharacter === 'boy' ? '#3b82f6' : '#ec4899'; ctx.beginPath(); ctx.roundRect(-19, -14, 38, 38, 12); ctx.fill(); ctx.fillStyle = '#fed7aa'; const ac = Math.sin(Date.now() * 0.015) * 8; if (!player.isOnGround) { ctx.fillRect(-23, -12, 5, 12); ctx.fillRect(18, -12, 5, 12); } else if (Math.abs(player.vx) > 0.2) { ctx.fillRect(-22, 4 + ac, 5, 10); ctx.fillRect(17, 4 - ac, 5, 10); } else { ctx.fillRect(-22, 6, 5, 10); ctx.fillRect(17, 6, 5, 10); } ctx.fillStyle = '#fed7aa'; ctx.beginPath(); ctx.arc(0, -12, 14, 0, 7); ctx.fill(); if (selectedCharacter === 'boy') { ctx.fillStyle = '#78350f'; ctx.beginPath(); ctx.arc(0, -15, 15, Math.PI, 0); ctx.fill(); ctx.fillStyle = '#1d4ed8'; ctx.beginPath(); ctx.arc(0, -18, 11, Math.PI, 0); ctx.fill(); } else { ctx.fillStyle = '#fbbf24'; ctx.beginPath(); ctx.arc(0, -14, 15, Math.PI, 0); ctx.fill(); } ctx.fillStyle = '#1e293b'; ctx.beginPath(); ctx.arc(-5, -12, 2.5, 0, 7); ctx.arc(5, -12, 2.5, 0, 7); ctx.fill(); ctx.strokeStyle = '#1e293b'; ctx.lineWidth = 1.8; ctx.beginPath(); ctx.arc(0, -9, 3, 0, Math.PI); ctx.stroke(); } else { ctx.save(); ctx.scale(1.2, 1.2); if(selectedCharacter === 'snake') ctx.scale(1, 0.7); drawZodiacFace(ctx, selectedCharacter); ctx.restore(); } ctx.restore(); } function createJumpSpark(x, y) { jumpSparks = []; for (let i = 0; i < 8; i++) jumpSparks.push({ x, y, vx: (Math.random() - 0.5) * 6, vy: (Math.random() - 0.5) * 6, life: 15 }); } function updateAndDrawSparks() { jumpSparks.forEach(s => { s.x += s.vx; s.y += s.vy; s.life--; if (s.life > 0) { ctx.fillStyle = '#fef08a'; ctx.beginPath(); ctx.arc(s.x - cameraX, s.y - cameraY, 4, 0, 7); ctx.fill(); } }); } function updatePhysics() { if (gameState !== 'playing') return; let activeSpeed = player.powerupType === 'potion' ? player.speed * 1.35 : player.speed; if (keys.left) player.vx = -activeSpeed; else if (keys.right) player.vx = activeSpeed; else player.vx *= player.friction; player.vy += GRAVITY; if (player.vy > 12) player.vy = 12; player.x += player.vx; if (player.x < 0) { player.x = 0; player.vx = 0; } const maxMapLimitX = isVerticalStage ? Math.max(800, canvas.width) : (1900 + (currentLevel * 350) + 100); if (player.x > maxMapLimitX - player.width) player.x = maxMapLimitX - player.width; coins.forEach(c => { if (!c.isCollected && Math.hypot(player.x + player.width/2 - c.x, player.y + player.height/2 - c.y) < player.magnetRange) { const a = Math.atan2(player.y + player.height/2 - c.y, player.x + player.width/2 - c.x); c.x += Math.cos(a)*5; c.y += Math.sin(a)*5; } }); if (selectedCharacter === 'sheep' && !player.hasPassiveShield) { if (--player.shieldCooldown <= 0) { player.hasPassiveShield = true; playSound('powerup'); } } platforms.forEach(p => { if (checkCollision(player, p)) { if (p.type === 'spike') return handlePlayerHurt(); if (player.vx > 0) { player.x = p.x - player.width; player.vx = 0; } else if (player.vx < 0) { player.x = p.x + p.width; player.vx = 0; } } }); player.y += player.vy; player.isOnGround = false; platforms.forEach(p => { if (checkCollision(player, p)) { if (p.type === 'spike') return handlePlayerHurt(); if (player.vy > 0) { player.y = p.y - player.height; player.vy = 0; player.isOnGround = true; player.doubleJumpCount = 0; if (p.isDisappearing) { p.timer++; p.opacity = Math.max(0, 1 - (p.timer / 30)); if (p.timer > 30) p.y += 1000; } } else if (player.vy < 0) { player.y = p.y + p.height; player.vy = 0; } } }); if (selectedCharacter === 'rooster' && keys.jump && player.vy > 0.5) player.vy = 0.65; if (player.powerupType && --player.powerupTimer <= 0) { player.powerupType = null; player.maxDoubleJumps = selectedCharacter === 'monkey' ? 2 : 0; document.getElementById('hud-powerup').classList.add('hidden'); } if (player.invincibilityFrames > 0) player.invincibilityFrames--; cameraX = Math.max(0, Math.min(cameraX * 0.9 + (player.x - canvas.width * (isVerticalStage ? 0.5 : 0.35)) * 0.1, isVerticalStage ? maxMapLimitX - canvas.width : 1900 + currentLevel * 350 - canvas.width)); cameraY = Math.min(50, cameraY * 0.9 + (isVerticalStage ? player.y - canvas.height * 0.55 : Math.min(0, player.y - canvas.height * 0.55)) * 0.1); if (player.y > (isVerticalStage ? cameraY + canvas.height + 400 : canvas.height + 40)) handlePlayerHurt(true); } function checkCollision(a, b) { return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; } function performJump() { if (player.isOnGround) { player.vy = -player.jumpForce; player.isOnGround = false; playSound('jump'); } else if (player.doubleJumpCount < player.maxDoubleJumps) { player.vy = -player.jumpForce * 0.95; player.doubleJumpCount++; playSound('doubleJump'); createJumpSpark(player.x + 19, player.y + 48); } } function fireBullet() { if (coinsCount >= 5 || selectedCharacter === 'dragon') { playSound('shoot'); playerBullets.push({ x: player.x + player.width, y: player.y + 24, vx: selectedCharacter === 'tiger' ? 11.5 : 7.5, vy: 0, life: 80 }); } } function updateCollisionsAndEntities() { if (gameState !== 'playing') return; coins.forEach(c => { if (!c.isCollected && Math.hypot((player.x + 19) - c.x, (player.y + 24) - c.y) < c.radius + 20) { c.isCollected = true; score += 100; coinsCount++; document.getElementById('hud-score').textContent = score; document.getElementById('hud-coins').textContent = `${coinsCount}/5`; playSound('coin'); if (coinsCount === 5) { const ws = document.getElementById('hud-weapon-status'); ws.textContent = "🔥 총알 공격 준비완료! (PC: F / 모바일: 공격)"; ws.classList.replace('bg-red-600/90', 'bg-emerald-500/90'); playSound('powerup'); } } }); for(let i=playerBullets.length-1; i>=0; i--) { let b = playerBullets[i]; b.x += b.vx; b.life--; if (b.life <= 0) { playerBullets.splice(i, 1); continue; } for(let e of enemies) { if (!e.squashed && Math.hypot(b.x - (e.x + e.width/2), b.y - (e.y + e.height/2)) < 26) { playerBullets.splice(i, 1); e.bossHp -= selectedCharacter === 'tiger' ? 2 : 1; playSound('stomp'); if (e.bossHp <= 0) { e.squashed = true; score += e.type === 'boss' ? 3000 : 200; document.getElementById('hud-score').textContent = score; } break; } } } for(let i=projectiles.length-1; i>=0; i--) { let p = projectiles[i]; p.x += p.vx; p.y += p.vy; p.life--; if (p.life <= 0) { projectiles.splice(i, 1); continue; } if (Math.hypot((player.x + 19) - p.x, (player.y + 24) - p.y) < 20) { if (player.powerupType === 'gem' || player.hasPassiveShield) { projectiles.splice(i, 1); playSound('coin'); } else { handlePlayerHurt(); projectiles.splice(i, 1); } } } items.forEach(it => { if (!it.isCollected && checkCollision(player, it)) { it.isCollected = true; playSound(it.type === 'heart' ? 'heal' : 'powerup'); applyPowerup(it.type); } }); enemies.forEach(e => { if (e.squashed) return; e.x += e.speed * e.dir; if (Math.abs(e.x - e.startX) > e.patrolDist) e.dir *= -1; if (e.type === 'bat') e.y += Math.sin(Date.now() * 0.015) * 1.5; if (--e.shootCooldown <= 0) { if (Math.abs(player.x - e.x) < 420) { if (e.type === 'slime') { projectiles.push({ x: e.x + 17, y: e.y + 15, vx: (player.x < e.x ? -1 : 1) * (4.2 + currentLevel * 0.3), vy: 0, type: 'acid', life: 100 }); e.shootCooldown = 110 - currentLevel * 5; } else if (e.type === 'bat') { projectiles.push({ x: e.x + 17, y: e.y + 30, vx: 0, vy: 5.2 + currentLevel * 0.4, type: 'darkness', life: 110 }); e.shootCooldown = 80 - currentLevel * 4; } else if (e.type === 'boss') { const ang = Math.atan2((player.y + 24) - (e.y + 30), (player.x + 19) - (e.x + 35)); [-0.35, 0, 0.35].forEach(off => projectiles.push({ x: e.x + 35, y: e.y + 30, vx: Math.cos(ang + off)*5.2, vy: Math.sin(ang + off)*5.2, type: 'boss_fire', life: 140 })); e.shootCooldown = 75; } } else e.shootCooldown = 30; } if (checkCollision(player, e)) { if (player.vy > 0 && player.y + player.height - player.vy <= e.y + 16) { player.vy = -player.jumpForce * 0.85; playSound('stomp'); e.bossHp--; if (e.bossHp <= 0) { e.squashed = true; score += e.type === 'boss' ? 3000 : 200; } else if (e.type === 'boss') e.x += e.dir * -50; document.getElementById('hud-score').textContent = score; } else if (player.powerupType === 'gem' || player.hasPassiveShield) { e.squashed = true; playSound('stomp'); score += 200; document.getElementById('hud-score').textContent = score; } else handlePlayerHurt(); } }); if (checkCollision(player, goalFlag)) handleStageClear(); } function applyPowerup(type) { player.powerupType = type; const banner = document.getElementById('hud-powerup'); banner.classList.remove('hidden'); if (type === 'star') { player.maxDoubleJumps = selectedCharacter === 'monkey' ? 3 : 1; player.powerupTimer = 600; document.getElementById('powerup-icon').textContent = '⭐'; document.getElementById('powerup-text').textContent = '공중 다단 점프 개방!'; } else if (type === 'gem') { player.powerupTimer = 480; document.getElementById('powerup-icon').textContent = '🛡️'; document.getElementById('powerup-text').textContent = '천하무적 반짝 보호막!'; } else if (type === 'potion') { player.powerupTimer = 600; document.getElementById('powerup-icon').textContent = '⚡'; document.getElementById('powerup-text').textContent = '바람보다 빠른 신속 물약!'; } else if (type === 'heart') { if (lives < maxLives) { lives++; updateLivesHUD(); } player.powerupType = null; banner.classList.add('hidden'); } } function handlePlayerHurt(forceInstantKill = false) { if (player.invincibilityFrames > 0 && !forceInstantKill) return; if (player.powerupType === 'gem' && !forceInstantKill) return; if (player.hasPassiveShield && !forceInstantKill) { player.hasPassiveShield = false; player.shieldCooldown = 1200; player.invincibilityFrames = 90; playSound('heal'); return; } lives--; playSound('hurt'); updateLivesHUD(); if (lives <= 0 || forceInstantKill) { gameState = 'fail'; clearInterval(timerInterval); playSound('fail'); showScreen('fail'); } else { player.invincibilityFrames = 120; player.vy = -6; player.vx = -6; } } function updateLivesHUD() { document.getElementById('hud-lives').textContent = '❤️'.repeat(lives) + '🖤'.repeat(maxLives - lives); } function handleStageClear() { gameState = 'clear'; clearInterval(timerInterval); playSound('clear'); score += levelTimer * 50; document.getElementById('hud-score').textContent = score; if (currentLevel === unlockedLevel && unlockedLevel < maxLevel) unlockedLevel++; if (currentLevel === maxLevel) { document.getElementById('final-score').textContent = score.toLocaleString() + " 점"; showScreen('ending'); } else { document.getElementById('clear-title').textContent = `스테이지 ${currentLevel} 완료!`; showScreen('clear'); } } function startGame(level) { showScreen('preview'); gameState = 'preview'; document.getElementById('preview-title').textContent = `스테이지 ${level}`; document.getElementById('preview-mission').innerHTML = (level % 2 === 0) ? "임무: 위로 높이 올라가 타워 정상에 도달하세요! 👆" : "임무: 앞으로 치고 달려가 우측 돌파구로 나가세요! 👉"; setTimeout(() => executeStartGame(level), 2500); } function executeStartGame(level) { gameState = 'playing'; currentLevel = level; coinsCount = 0; Object.assign(player, { speed: 4.9, jumpForce: 11.2, width: 38, height: 48, friction: 0.82, magnetRange: 140, maxDoubleJumps: 2, // 모든 캐릭터 3단 점프 기본 적용 hasPassiveShield: false, shieldCooldown: 0, x: 100, y: 200, vx: 0, vy: 0, isOnGround: false, powerupType: null, powerupTimer: 0, invincibilityFrames: 0 }); maxLives = 3; if (selectedCharacter === 'girl') player.speed = 5.7; else if (selectedCharacter === 'rat') { player.width = 28; player.height = 36; player.speed = 5.4; } else if (selectedCharacter === 'ox') maxLives = 5; else if (selectedCharacter === 'tiger') { player.speed = 6.1; player.jumpForce = 11.5; } else if (selectedCharacter === 'rabbit') { player.jumpForce = 14.0; player.speed = 4.8; } else if (selectedCharacter === 'snake') player.height = 24; else if (selectedCharacter === 'horse') { player.speed = 7.2; player.friction = 0.95; } else if (selectedCharacter === 'sheep') player.hasPassiveShield = true; else if (selectedCharacter === 'monkey') player.maxDoubleJumps = 2; // 원숭이는 그대로 유지 else if (selectedCharacter === 'dog') player.magnetRange = 300; else if (selectedCharacter === 'pig') { maxLives = 6; player.speed = 4.3; } lives = maxLives; updateLivesHUD(); levelTimer = 120; document.getElementById('hud-level').textContent = `스테이지 ${currentLevel}`; document.getElementById('hud-score').textContent = score; document.getElementById('hud-coins').textContent = `0/5`; document.getElementById('hud-timer').textContent = levelTimer; document.getElementById('hud-powerup').classList.add('hidden'); const ws = document.getElementById('hud-weapon-status'); if (selectedCharacter === 'dragon') { ws.textContent = "🔥 용의 특수능력: 총알 발사 무제한!"; ws.classList.replace('bg-red-600/90', 'bg-emerald-500/90'); } else { ws.textContent = "🔒 코인 5개를 모으면 총알 가능!"; ws.classList.replace('bg-emerald-500/90', 'bg-red-600/90'); } generateMap(currentLevel); cameraX = 0; cameraY = isVerticalStage ? player.y - 240 : 0; showScreen('playing'); startTimer(); startBGM('stage'); } window.addEventListener('keydown', e => { if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'KeyF'].includes(e.code)) e.preventDefault(); if (e.code === 'ArrowLeft') keys.left = true; if (e.code === 'ArrowRight') keys.right = true; if ((e.code === 'Space' || e.code === 'ArrowUp') && !keys.jump) { performJump(); keys.jump = true; } if (e.code === 'KeyF' && !keys.shoot) { fireBullet(); keys.shoot = true; } }); window.addEventListener('keyup', e => { if (e.code === 'ArrowLeft') keys.left = false; if (e.code === 'ArrowRight') keys.right = false; if (e.code === 'Space' || e.code === 'ArrowUp') keys.jump = false; if (e.code === 'KeyF') keys.shoot = false; }); function setupMobileControls() { const attach = (id, k, act) => { const b = document.getElementById(id); b.addEventListener('touchstart', e => { e.preventDefault(); initAudio(); if(act) act(); else keys[k] = true; }); b.addEventListener('touchend', e => { e.preventDefault(); if(!act) keys[k] = false; else if(k) keys[k] = false; }); }; attach('btn-left', 'left'); attach('btn-right', 'right'); attach('btn-jump', 'jump', () => { performJump(); keys.jump = true; }); attach('btn-shoot', '', fireBullet); } document.getElementById('btn-tutorial-ok').onclick = () => { initAudio(); playSound('clear'); showScreen('start'); }; document.getElementById('btn-go-char').onclick = () => { initAudio(); playSound('coin'); showScreen('char_select'); }; ['btn-back-start', 'btn-back-char', 'btn-go-levels', 'btn-go-levels-fail'].forEach(id => { document.getElementById(id).onclick = () => { playSound('jump'); showScreen(id.includes('start') ? 'start' : id.includes('char') ? 'char_select' : 'level_select'); }; }); document.getElementById('btn-next-stage').onclick = () => { playSound('coin'); startGame(++currentLevel); }; document.getElementById('btn-retry').onclick = () => { playSound('coin'); startGame(currentLevel); }; document.getElementById('btn-ending-restart').onclick = () => { playSound('coin'); unlockedLevel = 1; score = 0; showScreen('start'); }; setupMobileControls(); function resizeCanvas() { canvas.width = canvas.parentElement.clientWidth; canvas.height = 480; } window.addEventListener('resize', resizeCanvas); function mainLoop() { updatePhysics(); updateCollisionsAndEntities(); ctx.clearRect(0, 0, canvas.width, canvas.height); drawSkyGradient(); drawClouds(); drawPlatforms(); drawCoins(); drawItems(); drawEnemies(); drawProjectiles(); drawPlayerBullets(); drawGoalFlag(); updateAndDrawSparks(); drawPlayer(); requestAnimationFrame(mainLoop); } window.onload = function () { resizeCanvas(); requestAnimationFrame(mainLoop); }; </script>

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions