game.js (약 2830줄)
│
├── [1~55] 캔버스 초기화 & 반응형 크기
├── [57~100] 게임 상태 변수들 (v2.0 신규 포함)
├── [102~265] SoundManager (Web Audio API 프로시저럴 사운드)
├── [267~310] 맵 & 경로 시스템
├── [312~410] 앰비언트 시스템 (잔디/경로 디테일/파티클 생성)
├── [412~525] 타워 종류 정의 & 클래스
├── [527~620] 적/투사체/파티클/텍스트/충격파/지면마크 클래스
├── [622~700] 웨이브 & 난이도 시스템
├── [702~760] 타워 배치/업그레이드/판매 (사운드 포함)
├── [762~810] 적 사망 파티클 + 충격파 + 스크린쉐이크
├── [812~1100] ★ update() - 핵심 게임 로직 (+ 새 이펙트 업데이트)
├── [1102~1115] 그리기 헬퍼 함수
├── [1117~1720] ★ draw() - 화면 렌더링 (스크린쉐이크, 앰비언트, 글로우, UI 그라디언트)
├── [1722~1870] 타워/적 그리기 함수
├── [1872~2530] 입력 처리 (마우스, 터치, 키보드, 음소거)
├── [2532~2575] 게임 리셋 (v2.0 변수 초기화 포함)
└── [2577~2830] 게임 루프 (스크린쉐이크/웨이브전환/게임오버 타이머)
위치: game.js:1572~1582
gameLoop() 호출 (60fps)
├── update(dt) ← 모든 계산 (위치, 충돌, AI)
├── draw() ← 화면에 그리기
└── requestAnimationFrame(gameLoop) ← 다음 프레임 예약
const dt = Math.min((time - lastTime) / 1000, 0.05);- 이전 프레임과 현재 프레임 사이의 경과 시간 (초)
- 60fps면 dt ≈ 0.016초
- 모든 이동/타이머에 dt를 곱해서 프레임 속도에 독립적으로 동작
- 느린 컴퓨터든 빠른 컴퓨터든 같은 속도로 게임 진행
위치: game.js:59~120
20칸(가로) x 14칸(세로) 격자
각 칸의 값:
0 = 잔디 (타워 배치 가능)
1 = 길 (적이 지나가는 곳)
2 = 입구
3 = 출구
const waypoints = [
{ x: -1, y: 2 }, // 왼쪽 밖에서 시작
{ x: 4, y: 2 }, // → 오른쪽으로
{ x: 4, y: 5 }, // ↓ 아래로
{ x: 10, y: 5 }, // → 오른쪽으로
{ x: 10, y: 2 }, // ↑ 위로
{ x: 16, y: 2 }, // → 오른쪽으로
{ x: 16, y: 7 }, // ↓ 아래로
{ x: 6, y: 7 }, // ← 왼쪽으로
{ x: 6, y: 10 }, // ↓ 아래로
{ x: 14, y: 10 }, // → 오른쪽으로
{ x: 14, y: 12 }, // ↓ 아래로
{ x: 20, y: 12 }, // → 오른쪽 밖으로 (출구)
];S자 형태로 꺾이는 경로. 적은 이 좌표들을 순서대로 따라감.
두 waypoint 사이를 잇는 직선을 격자에 1로 표시:
- 같은 y좌표면 → 가로로 칸을 채움
- 같은 x좌표면 → 세로로 칸을 채움
경로를 바꾸고 싶으면 waypoints 배열만 수정하면 됨!
더 복잡한 경로 = 타워 배치 전략이 다양해짐
위치: game.js:122~214
| 속성 | 화살 | 대포 | 얼음 | 번개 | 독 |
|---|---|---|---|---|---|
| 비용 | 50G | 100G | 75G | 130G | 90G |
| 데미지 | 8 | 30 | 5 | 18 | 4 |
| 사거리 | 3.0칸 | 2.8칸 | 2.5칸 | 3.5칸 | 2.5칸 |
| 공격속도 | 0.4초 | 1.2초 | 0.6초 | 0.8초 | 0.7초 |
| 특수효과 | 없음 | 범위(스플래시) | 감속 50% | 없음 | 독 DOT 4초 |
| 총알속도 | 8 | 5 | 6 | 12 | 5 |
| 투사체 | 화살선 | 포탄+연기 | 얼음결정 | 전기줄기 | 독방울 |
class Tower {
col, row // 격자 위치
x, y // 픽셀 위치 (격자 중앙)
level // 현재 레벨 (1~5)
cooldown // 다음 발사까지 남은 시간
angle // 포탑이 바라보는 각도
target // 현재 조준 중인 적
totalDamage // 누적 데미지 (통계용)
}레벨업 할 때마다:
데미지 = 기본 × (1 + (레벨-1) × 0.5) → 레벨5면 3배
사거리 = 기본 + (레벨-1) × 0.3칸 → 레벨5면 +1.2칸
공격속도 = 기본 × 0.88^(레벨-1) → 레벨5면 약 60%로 빨라짐
업그레이드 비용 = 기본비용 × 0.6 × 현재레벨
판매가 = 총 투자금 × 60%
위치: game.js:524~539
1. 사거리 안의 모든 적을 검색
2. "출구에 가장 가까운 적"을 우선 공격
→ pathIndex(경로 진행도)가 높은 적 선택
3. 타겟을 향해 포탑 각도를 회전
4. 쿨다운이 0이 되면 발사
이 전략을 "가장 진행된 적 우선(First/Furthest)" 이라고 함. 다른 전략: 가장 가까운 적, HP 가장 낮은 적, HP 가장 높은 적 등
위치: game.js:216~236
class Enemy {
hp, maxHp // 체력
speed, baseSpeed // 이동속도
goldValue // 처치 시 골드 보상
type // 'normal', 'fast', 'tank', 'boss'
pathIndex // 현재 따라가는 경로 인덱스
x, y // 현재 위치
armor // 방어력 (받는 데미지에서 차감)
slowTimer // 감속 남은 시간
slowAmount // 감속 비율 (0.5 = 50% 감속)
poisonTimer // 독 남은 시간
poisonDmg // 독 틱당 데미지
poisonTick // 다음 독 틱까지 남은 시간 (0.5초 간격)
hitFlash // 피격 시 흰색 반짝임
size // 그리기 크기
}위치: game.js:486~501
매 프레임:
1. 현재 목표 waypoint를 가져옴 (enemyPath[pathIndex])
2. 목표까지의 거리(dx, dy)를 계산
3. 방향을 정규화 (dx/d, dy/d) → 단위 벡터
4. 이동속도만큼 그 방향으로 전진
5. 목표에 도착하면 pathIndex++ → 다음 waypoint로
6. 마지막 waypoint 도착 = 출구 도달 → 생명 감소
정규화(normalize)란?
거리 d = √(dx² + dy²)
방향 = (dx/d, dy/d) ← 길이가 항상 1인 벡터
이동 = 방향 × 속도 × dt
이렇게 하면 어느 방향이든 동일한 속도로 이동.
| 종류 | 등장 | HP 배율 | 속도 배율 | 특수 |
|---|---|---|---|---|
| normal | 웨이브 1~ | ×1.0 | ×1.0 | - |
| fast | 웨이브 3~ | ×0.5 | ×1.6 | 크기 작음 |
| tank | 웨이브 5~ | ×2.5 | ×0.6 | 방어력 2 |
| boss | 5의 배수 | ×6.0 | ×0.45 | 방어력 3, 통과 시 생명 -5 |
위치: game.js:238~253, 555~631
발사 → 매 프레임 적 추적 → 충돌 → 데미지 처리
1. 타워 위치에서 생성
2. 매 프레임: 타겟 적을 향해 이동 (유도 미사일처럼)
3. 적과의 거리 < 충돌 거리면 → 명중!
4. 명중 시:
- 데미지 적용 (데미지 - 방어력, 최소 1)
- 스플래시면 주변 적에게도 50% 데미지
- 감속 효과가 있으면 적에게 감속 부여
- 독 효과가 있으면 적에게 DOT(지속 피해) 부여
- 적 HP ≤ 0이면 사망 처리 + 골드 획득
명중 시 → enemy.poisonTimer = 4초, poisonDmg = 5
↓
매 0.5초마다 독 데미지 적용 (방어력 감산)
↓
초록 기포 파티클 + 독 오라 이펙트
↓
4초 후 해제 (재감염 가능)
- 스플래시로 주변 적도 독 감염 (지속시간 60%)
- 독 데미지는 레벨업 시 기본 공식에 따라 증가
| 타워 | 투사체 스타일 |
|---|---|
| 화살 | 얇은 직선 궤적 + 작은 화살촉 |
| 대포 | 큰 포탄 + 연기 잔상 |
| 얼음 | 회전하는 다이아몬드 결정 + 사각 파편 |
| 번개 | 지그재그 전기 줄기 (2중) + 전기 구체 |
| 독 | 물결치는 독방울 + 초록 드립 |
proj.trail.push({ x: proj.x, y: proj.y }); // 현재 위치 저장
if (proj.trail.length > 5) proj.trail.shift(); // 최대 5개만 유지과거 5프레임의 위치를 저장해서 점점 투명하게 그리면 → 꼬리처럼 보임
위치: game.js:281~340
baseHp = 30 + wave × 15 + wave^1.5 × 5 // 지수적 증가
count = 5 + wave × 1.5 // 수도 증가
speed = 1.0 + wave × 0.03 // 미세하게 빨라짐| 웨이브 | HP | 적 수 | 구성 |
|---|---|---|---|
| 1 | ~50 | 6 | 일반 6 |
| 3 | ~90 | 9 | 일반 9 + 빠른 1 |
| 5 | ~140 | 12 | 일반 12 + 빠른 2 + 탱크 1 + 보스 |
| 10 | ~310 | 20 | 일반 20 + 빠른 5 + 탱크 3 + 보스 |
| 20 | ~747 | 35 | 일반 35 + 빠른 10 + 탱크 6 + 보스 |
웨이브 사이 5초 대기 (스킵 가능)
↓
startWave() → 적 목록 생성 (getWaveEnemies)
↓
0.5~0.8초 간격으로 적을 하나씩 스폰
↓
모든 적 처치 또는 통과
↓
웨이브 클리어 보너스 (10 + wave × 5 골드)
↓
다음 웨이브 대기...
function dist(a, b) {
return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
}두 점 사이의 거리 = 피타고라스 정리
dist(타워, 적) ≤ 타워사거리 × 타일크기 → 사거리 안에 있음!
dist(투사체, 적) < 이동속도 + 적크기 → 명중!
명중 지점에서 dist(투사체, 다른적) ≤ 스플래시 범위 → 범위 피해!
위치: game.js:255~267, 398~418, 845~865
class Particle {
x, y // 위치
vx, vy // 속도 (방향 + 크기)
life // 남은 수명 (0이 되면 제거)
maxLife // 최초 수명 (투명도 계산용)
color // 색상
size // 크기
}p.x += p.vx; // 이동
p.y += p.vy;
p.life -= dt; // 수명 감소
p.vx *= 0.95; // 마찰력 (점점 느려짐)
p.vy *= 0.95;ctx.globalAlpha = p.life / p.maxLife; // 수명에 따라 투명해짐
ctx.arc(p.x, p.y, p.size * alpha, 0, Math.PI * 2); // 크기도 줄어듦- 피격: 작은 파티클 1개 (투사체 색상)
- 스플래시 히트: 방사형 파티클 6개
- 적 사망: 12~30개 폭발 (적 종류별 색상)
- 떠오르는 텍스트: "+5G", "웨이브 클리어!" 등
위치: game.js:1200~1540
mousemove → hoveredTile 업데이트 (어느 칸 위에 있는지)
click →
1. UI 패널 영역? → 타워 선택 / 업그레이드 / 판매
2. 기존 타워 위? → 업그레이드 패널 표시
3. 빈 잔디 칸? → 타워 배치 (골드 충분하면)
마우스와 동일한 로직, touchstart/touchmove/touchend 사용
1~5: 타워 선택 (5=독 타워)
Space/Enter: 웨이브 시작
U: 선택된 타워 업그레이드
S: 선택된 타워 판매
Q: 게임 속도 전환 (x1 → x2 → x3 → x1)
M: 사운드 음소거/해제
Escape: 선택 해제
let gameSpeed = 1; // 1, 2, 3
const dt = rawDt * gameSpeed; // 모든 로직에 배속 적용- Q키 또는 UI 버튼 클릭으로 전환
- x1: 기본 속도 / x2: 2배속 / x3: 3배속
rawDt(실제 시간)에 배속을 곱해서 모든 이동/타이머에 적용- 보스 경고 연출은 실제 시간(rawDt)으로 동작 (배속 영향 없음)
웨이브 5, 10, 15, 20...
↓
빨간 경고 화면 2.5초 ("⚠ BOSS WAVE ⚠")
↓
보스 + 호위병(탱크) 함께 출현
보스 레벨 = 웨이브 / 5
HP 배율 = 6 + 보스레벨 × 2 (8배, 10배, 12배...)
호위병 수 = 보스레벨 × 2 (최대 8)
보상 = 기본 × 5 + 보스레벨 × 10
- 붉은 맥동 오라 (1.6배 크기)
- 양쪽 뿔 + 왕관
- 빛나는 빨간 눈 (shadowBlur 효과)
- 세로 슬릿 동공
- 톱니 이빨
그리는 순서가 곧 레이어 순서 (먼저 그린 것이 아래에 깔림):
1. 잔디 & 길 배경 ← 가장 아래
2. 경로 방향 화살표
3. 입구/출구 마커 (IN, OUT)
4. 타워 사거리 미리보기 (배치 시)
5. 타워들
6. 타워 레벨 별표 (★)
7. 선택된 타워 사거리 원
8. 적들 (독/감속 오라 + 체력바)
9. 투사체 (타워별 고유 이펙트)
10. 파티클 이펙트
11. 떠오르는 텍스트 (+5G 등)
12. 보스 경고 오버레이 (5웨이브마다)
13. UI 패널 (하단) + 속도 버튼
14. 업그레이드 팝업 ← 가장 위
15. 게임오버 화면
코드 구조를 이해했으니, 추가할 수 있는 것들:
- 새 타워 종류 →
TOWER_TYPES배열에 추가하면 됨 (독 타워 추가 완료) - 새 적 종류 →
getWaveEnemies()에 조건 추가 - 경로 변경 →
waypoints배열 수정 - 타워 최대 레벨 올리기 → 현재 5 → 원하는 만큼
- 타워 특수능력 (독 DOT 데미지 추가 완료, 연쇄 번개/치명타는 미구현)
- 적 특수능력 (치유, 분열, 투명화, 보호막 등)
- 맵 선택 (waypoints 배열을 여러 개 만들어 선택)
- 스킬/마법 (화면 탭으로 범위 폭발 등)
- 타워 조합 시스템 (두 타워를 합쳐서 새 타워)
- 멀티 경로 (적이 두 갈래로 나뉘어 진입)
- 보스 이벤트 (경고 연출, 레벨 스케일링, 호위병 동반 완료. 특수 공격/페이즈는 미구현)
- 무한 모드 + 리더보드
위치: game.js:14~48, index.html @media 쿼리
canvas.width / canvas.height → 내부 그리기 해상도 (종이 크기)
canvas.style.width / height → 화면에 보이는 크기 (액자 크기)
내부 해상도를 충분히 크게 유지(TILE ≥ 36)하면 텍스트와 그래픽이 선명하게 렌더링됨. CSS로 축소하면 화면에 맞게 보이되, 내부 좌표계는 변하지 않음.
1. 사용 가능한 화면 크기 계산 (availW, availH)
2. TILE = min(availW/COLS, availH/TOTAL_ROWS)
3. TILE을 MIN_TILE(36) ~ MAX_TILE(56) 범위로 클램프
4. 내부 해상도 설정: canvas.width = COLS × TILE
5. CSS 축소 비율 계산: cssScale = min(availW/W, availH/H, 1.0)
6. CSS 크기 설정: canvas.style.width = W × cssScale
| 기기 | TILE | 내부 해상도 | CSS 표시 크기 |
|---|---|---|---|
| iPhone (390×844) | 36 | 720×612 | 386×328 |
| iPad (1080×810) | 44 | 880×748 | 880×748 |
| 데스크톱 (1920×1080) | 52 | 1040×884 | 1040×884 |
getCanvasPos() 함수가 canvas.getBoundingClientRect()로 CSS 크기를 얻고,
canvas.width / rect.width 비율로 좌표를 변환하므로, CSS 축소 시에도 터치/클릭 좌표가 정확히 매핑됨.
isMobile = ('ontouchstart' in window) && (innerWidth <= 768 || innerHeight <= 500);이 플래그로 다음을 조건부 처리:
- 키보드 단축키 힌트
[1],[Q]숨김 - "스페이스바 / 탭으로 시작" → "탭으로 시작"
- "클릭하여 다시 시작" → "탭하여 다시 시작"
- 잔디 블레이드 수 감소 (3개→1개/타일)
위치: game.js:102~265
외부 음원 파일(.mp3) 없이 OscillatorNode로 실시간 파형 생성.
AudioContext → OscillatorNode → GainNode → masterGain → destination
(파형 생성) (볼륨/엔벨로프) (마스터 볼륨) (스피커)
| 파형 | 음색 | 용도 |
|---|---|---|
| sine | 부드러운 순음 | 얼음, 독, 업그레이드 |
| square | 8bit 레트로 | 화살, 게임오버, UI |
| sawtooth | 거친 톱날음 | 보스 경고, 라이프 감소 |
| triangle | 부드러운 중간음 | 타워 배치 |
- 주파수 스윕:
frequency.exponentialRampToValueAtTime()으로 음높이 변화 → "pew" 효과 - 화이트 노이즈:
AudioBuffer에 랜덤값 채워서 "쉬~" 질감 → 폭발/전기 - 맥놀이(Beat): 미세하게 다른 두 주파수 동시 재생 → 크리스탈/종소리 (얼음탑)
- 아르페지오: 시간차를 두고 여러 음 순차 재생 → 웨이브 시작 팡파레
- 엔벨로프:
gain.exponentialRampToValueAtTime(0.001, endTime)→ 자연스러운 소멸
| 사운드 | 트리거 | 합성 방식 |
|---|---|---|
| Arrow fire | 화살탑 발사 | square 880→440Hz |
| Cannon fire | 대포탑 발사 | sine 150→40Hz + noise |
| Ice fire | 냉기탑 발사 | sine 1200 + 1500Hz 맥놀이 |
| Lightning fire | 번개탑 발사 | noise + sawtooth 600→100Hz |
| Poison fire | 독탑 발사 | sine 주파수 워블 |
| Enemy death | 적 사망 | noise + sine 400→100Hz |
| Boss death | 보스 사망 | noise×2 + sine×2 |
| Wave start | 웨이브 시작 | C5→E5→G5 아르페지오 |
| Boss warning | 보스 웨이브 | sawtooth 55Hz 럼블 |
| Tower place | 배치 | triangle 200 + sine 100Hz |
| Tower upgrade | 업그레이드 | sine 600→800→1000→1300Hz |
| Tower sell | 판매 | square C6→E6 |
| Life lost | 라이프 감소 | sawtooth 440→220Hz |
| Game over | 게임오버 | square C5→G4→C4→C3 하강 |
| UI click | UI 클릭 | sine 800Hz, 0.03s |
첫 클릭/터치 시 soundManager.init()을 호출하여 AudioContext를 생성.
브라우저는 사용자 상호작용 없이 오디오 재생을 차단하므로 이 패턴이 필수.
위치: game.js:312~410
- 잔디 블레이드: 잔디 타일마다 1~3개,
Math.sin()기반 바람 흔들림 - 경로 디테일: 경로 타일마다 3~5개 조약돌/균열, 반투명 렌더링
- 먼지/반딧불이: 잔디 위에 랜덤 스폰, 최대 30개
- 반딧불이는
shadowBlur글로우 + 위로 떠오름 - 먼지는 작고 반투명, 미세한 드리프트
ctx.save() → ctx.translate(randomX, randomY) → [게임 렌더링] → ctx.restore() → [UI 렌더링]
UI 패널은 흔들리지 않도록 restore() 후에 그림.
| 트리거 | 강도 | 지속 |
|---|---|---|
| 보스 사망 | 8px | 0.4s |
| 대포 스플래시 | 3px | 0.15s |
| 라이프 감소 | 4px | 0.2s |
반지름 0→maxRadius 확장 + 투명도 감소. 적 사망 시 생성. 보스는 TILE×3 크기, 일반 적은 TILE×1.5. 최대 10개 동시.
- 패널 그라디언트:
createLinearGradient()로 위→아래 밝음→어두움 - 버튼 호버 글로우:
mousePos추적, 호버 시 밝은 배경 + 선택 시shadowBlur - 골드/라이프 플래시: 값 변경 시 뱃지에 색상 오버레이 (0.4~0.5s)
- 웨이브 전환: 텍스트 슬라이드인→정지→슬라이드아웃 (1.5s)
- 게임오버 연출: 어두워지기(0.5s) → GAME OVER 스케일인 → 점수 카운트업 → 최고점수 → 재시작 안내 깜빡임
- 볼륨 버튼: 🔊/🔇 토글, M키 단축키